diff --git a/.annotaterb.yml b/.annotaterb.yml new file mode 100644 index 0000000..0a2eacf --- /dev/null +++ b/.annotaterb.yml @@ -0,0 +1,58 @@ +--- +:position: bottom +:position_in_additional_file_patterns: bottom +:position_in_class: bottom +:position_in_factory: bottom +:position_in_fixture: bottom +:position_in_routes: bottom +:position_in_serializer: bottom +:position_in_test: bottom +:classified_sort: true +:exclude_controllers: true +:exclude_factories: true +:exclude_fixtures: true +:exclude_helpers: true +:exclude_scaffolds: true +:exclude_serializers: true +:exclude_sti_subclasses: false +:exclude_tests: true +:force: false +:format_markdown: false +:format_rdoc: false +:format_yard: false +:frozen: false +:ignore_model_sub_dir: false +:ignore_unknown_models: false +:include_version: false +:show_check_constraints: false +:show_complete_foreign_keys: false +:show_foreign_keys: true +:show_indexes: true +:simple_indexes: false +:sort: false +:timestamp: false +:trace: false +:with_comment: true +:with_column_comments: true +:with_table_comments: true +:active_admin: false +:command: +:debug: false +:hide_default_column_types: "json,jsonb,hstore" +:hide_limit_column_types: "integer,bigint,boolean" +:ignore_columns: +:ignore_routes: +:models: true +:routes: false +:skip_on_db_migrate: false +:target_action: :do_annotations +:wrapper: +:wrapper_close: +:wrapper_open: +:classes_default_to_s: [] +:additional_file_patterns: [] +:model_dir: +- app/models +:require: [] +:root_dir: +- '' diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0622cae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,16 @@ +/tmp/* +.rsa_private.pem +.rsa_public.pem + +.git +.gitignore +.github + +README.md +LICENSE +CODE_OF_CONDUCT.md +CONTRIBUTING.md +SECURITY.md + +Dockerfile +Dockerfile.dev diff --git a/.env.dist b/.env.dist new file mode 100644 index 0000000..34d392f --- /dev/null +++ b/.env.dist @@ -0,0 +1 @@ +DATABASE_URL=postgresql://lago:changeme@localhost:5432/lago_test diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..101c19a --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,5 @@ +# Enable TrailingCommaInHashLiteral rubocop rule for all files +3803519f622313a50def9b5261dfce899bdaf12d +f83dbe1f847414c927273523b6cf60f3a881a9f1 +747a2feea6530ef298722f464e47855507c21886 +15179f005dc0a6f7d61140581e123662535e4e53 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..fd8d930 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# See https://git-scm.com/docs/gitattributes for more about git attribute files. + +# Mark the database schema as having been generated. +db/schema.rb linguist-generated +schema.json linguist-generated +schema.graphql linguist-generated + +# Mark any vendored files as having been vendored. +vendor/* linguist-vendored diff --git a/.github/workflows/front-compatibility.yml b/.github/workflows/front-compatibility.yml new file mode 100644 index 0000000..2df036a --- /dev/null +++ b/.github/workflows/front-compatibility.yml @@ -0,0 +1,78 @@ +name: Front compatibility (PR check, informational) + +# Runs on PRs targeting main that modify schema.graphql. Regenerates +# lago-front's types from the PR's schema and runs tsc against +# lago-front main. Failure is informational only — the job is marked +# continue-on-error so it never blocks merging. Authors can still see +# a red/green signal in the PR's checks panel and inspect logs when +# something breaks. +# +# Path filter on schema.graphql — most PRs don't change the schema, so +# the workflow rarely runs. Cost: a few minutes of CI per schema change. + +on: + pull_request: + branches: [main] + paths: ['schema.graphql'] + +jobs: + front-typecheck: + name: Front typecheck against PR schema (informational) + runs-on: ubuntu-latest + # Non-blocking: a failure here surfaces in the checks panel but does + # not fail the workflow run, so branch protection cannot gate on it. + continue-on-error: true + + steps: + - name: Checkout lago-api (PR head — the new schema) + uses: actions/checkout@v6 + with: + ref: ${{ github.event.pull_request.head.sha }} + path: api + + - name: Checkout lago-front (main — stable baseline) + uses: actions/checkout@v6 + with: + repository: getlago/lago-front + ref: main + path: front + + - name: Set up pnpm + uses: pnpm/action-setup@v6 + with: + # lago-front declares its pnpm version in `packageManager`. + # Point the action at front's package.json so the version + # tracks whatever lago-front pins, no manual upkeep here. + package_json_file: front/package.json + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + # Inherit the version from lago-front's own package.json + # (engines.node) so this workflow tracks any node bump on the + # front side without manual upkeep here. + node-version-file: front/package.json + cache: pnpm + cache-dependency-path: front/pnpm-lock.yaml + + - name: Install front dependencies + working-directory: front + run: pnpm install --frozen-lockfile + + - name: Build workspace packages (prebuild) + working-directory: front + run: pnpm prebuild + + - name: Regenerate front types from PR schema + working-directory: front + env: + # graphql-codegen accepts file paths in its `schema:` field. + # codegen.yml reads ${CODEGEN_API}; pointing it at the API + # PR's checked-in schema.graphql skips the API boot step + # entirely — no docker compose, no resolvers, just the schema. + CODEGEN_API: ../api/schema.graphql + run: pnpm codegen + + - name: Type-check front + working-directory: front + run: pnpm tsc --noEmit diff --git a/.github/workflows/internal-build.yml b/.github/workflows/internal-build.yml new file mode 100644 index 0000000..bfcc538 --- /dev/null +++ b/.github/workflows/internal-build.yml @@ -0,0 +1,52 @@ +name: "Internal Build" +on: + push: + branches: + - main + workflow_dispatch: + inputs: + sidekiq_pro: + type: boolean + default: true + description: Enable Sidekiq Pro +permissions: {} +jobs: + build-api-image: + runs-on: ubuntu-latest + name: Build API Image + steps: + - uses: actions/checkout@v4 + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: us-east-1 + + - name: Login to Amazon ECR + id: login-ecr + uses: aws-actions/amazon-ecr-login@v2 + + - name: Add version into docker image + run: echo ${{ github.sha }} > ./LAGO_VERSION + + - name: Docker tag + id: docker_tag + env: + ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} + ECR_REPOSITORY: lago-api-production + IMAGE_TAG: ${{ github.sha }} + run: echo "tag=$(echo $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG)" >> $GITHUB_OUTPUT + + - name: Build and push + id: build + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.docker_tag.outputs.tag }} + build-args: | + BUNDLE_WITH=${{ (github.event_name == 'push' || github.event.inputs.sidekiq_pro == 'true') && 'sidekiq-pro' || '' }} + secrets: | + "BUNDLE_GEMS__CONTRIBSYS__COM=${{ secrets.BUNDLE_GEMS__CONTRIBSYS__COM }}" diff --git a/.github/workflows/linters.yml b/.github/workflows/linters.yml new file mode 100644 index 0000000..0b391ea --- /dev/null +++ b/.github/workflows/linters.yml @@ -0,0 +1,43 @@ +name: Linters +on: [pull_request] +permissions: {} +jobs: + linters: + name: Linters + runs-on: ubuntu-latest + if: ${{ github.actor != 'dependabot[bot]' }} + env: + RAILS_ENV: production + RAILS_MASTER_KEY: N+XcWoGDzKjuoxrU8BIPN5D0/GSuqx9s + SECRET_KEY_BASE: cvIAI6ycC0OnVDRAjT5hmbRxnjCxl4YB + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0.2" + bundler-cache: true + - name: Setup Rubocop + # A bug requires the use of `bundle install` to use `rubocop-rails`. A fix is coming to Rubocop. + # See: https://github.com/rubocop/rubocop/issues/12823 + run: | + bundle install + - name: Run Rubocop + # We must pass the list of files because for now, rubocop on the entire project throws too many errors. + # We exclude the db/schema.rb file explicitly because passing a list of files will override the `AllCops.Exclude` config in .rubocop.yml + run: | + FILES=$(git diff --diff-filter=d --name-only origin/${{ github.base_ref }}...HEAD -- '*.rb' ':!db/*structure.sql') + if [ -z "$FILES" ]; then + echo "No Ruby files to lint" + exit 0 + else + echo "Linting Ruby files" + bundle exec rubocop $FILES + fi + - name: Generate RSA keys + run: ./scripts/generate.rsa.sh + - name: Zeitwerk check + run: bin/rails zeitwerk:check diff --git a/.github/workflows/migrations-test.yml b/.github/workflows/migrations-test.yml new file mode 100644 index 0000000..4b5c84f --- /dev/null +++ b/.github/workflows/migrations-test.yml @@ -0,0 +1,75 @@ +name: Run rails migrations +on: + push: + branches: + - "main" + pull_request: + types: [opened, synchronize, reopened] +permissions: {} +jobs: + run-migrations: + name: Run migrations + runs-on: ubuntu-latest + services: + postgres: + image: getlago/postgres-partman:15.0-alpine + ports: + - "5432:5432" + env: + POSTGRES_DB: lago + POSTGRES_USER: lago + POSTGRES_PASSWORD: lago + env: + RAILS_ENV: test + DATABASE_URL: "postgres://lago:lago@localhost:5432/lago" + RAILS_MASTER_KEY: N+XcWoGDzKjuoxrU8BIPN5D0/GSuqx9s + SECRET_KEY_BASE: cvIAI6ycC0OnVDRAjT5hmbRxnjCxl4YB + ENCRYPTION_PRIMARY_KEY: 5I9mjfzry2P787x4S5ZuDdJwXNgYEwqo + ENCRYPTION_DETERMINISTIC_KEY: SGiZzmh18EjBF9gSW8LCNk7pelauWVr4 + ENCRYPTION_KEY_DERIVATION_SALT: q3pkMw34ZkRPFSf2LmtWe705yw532Pf7 + LAGO_API_URL: https://api.lago.dev + LAGO_PDF_URL: https://pdf.lago.dev + LAGO_FROM_EMAIL: noreply@getlago.com + LAGO_CLICKHOUSE_ENABLED: true + LAGO_CLICKHOUSE_MIGRATIONS_ENABLED: true + LAGO_CLICKHOUSE_HOST: localhost + LAGO_CLICKHOUSE_DATABASE: default + LAGO_CLICKHOUSE_USERNAME: "" + LAGO_CLICKHOUSE_PASSWORD: "password" + LAGO_DISABLE_SCHEMA_DUMP: true + LAGO_KAFKA_EVENTS_CHARGED_IN_ADVANCE_TOPIC: events_charged_in_advance + ANNOTATE_SKIP_ON_DB_MIGRATE: 1 + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Ruby and gems + uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0.2" + bundler-cache: true + - name: Install pg 14 + uses: tj-actions/install-postgresql@v3 + with: + postgresql-version: "15" + - name: Start Clickhouse database + run: | + docker run -d --rm -p 8123:8123 -p 9000:9000 --ulimit nofile=262144:262144 -v ./clickhouse-s3:/var/lib/clickhouse-s3 -v ./ci/clickhouse/config.xml:/etc/clickhouse-server/config.d/config.xml -e CLICKHOUSE_PASSWORD=password clickhouse/clickhouse-server:25.12-alpine + shell: bash + - name: Generate RSA keys + run: ./scripts/generate.rsa.sh + - run: "pg_dump --version" + - name: Move db schema for comparison + run: mv db/structure.sql db/structure-before-dump.sql + - name: Perform Postgres database migrations + run: bin/rails db:migrate:primary + - name: dump schema + run: LAGO_DISABLE_SCHEMA_DUMP="" bin/rails db:schema:dump:primary + - name: Ensure no changes to structure.sql (excluding restrict/unrestrict) + run: | + grep -v '\\restrict' db/structure.sql | grep -v '\\unrestrict' | grep -v 'Dumped from database version' | grep -v 'Dumped by pg_dump version' | sed '/^[[:space:]]*$/d' > db/structure.sql.filtered + grep -v '\\restrict' db/structure-before-dump.sql | grep -v '\\unrestrict' | grep -v 'Dumped from database version' | grep -v 'Dumped by pg_dump version' | sed '/^[[:space:]]*$/d' > db/structure-before-dump.sql.filtered + diff db/structure.sql.filtered db/structure-before-dump.sql.filtered + - name: Ensure annotations are up to date + run: bundle exec annotaterb --frozen + - name: Perform Clickhouse database migrations + run: bin/rails db:migrate:clickhouse diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..7ad9ee6 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,156 @@ +name: Release +on: + release: + types: [released] + workflow_dispatch: + inputs: + version: + description: Version + required: true +env: + REGISTRY_IMAGE: getlago/api +permissions: { } +jobs: + build-images: + strategy: + matrix: + platform: + - version: linux/amd64 + runner: ubuntu-latest + - version: linux/arm64 + runner: linux-arm64 + name: Build ${{ matrix.platform.version }} Image + runs-on: ${{ matrix.platform.runner }} + steps: + - name: Prepare + run: | + platform=${{ matrix.platform.version }} + echo "PLATFORM_PAIR=${platform//\//-}" >> $GITHUB_ENV + + - name: Checkout Repository + uses: actions/checkout@v4 + + - name: Docker Meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + tags: | + type=raw,value=${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.version }} + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + version: latest + + - name: Log In to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Add version into docker image + id: add_version + run: | + echo "${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.version }}" > LAGO_VERSION + + - name: Build and push Docker image + uses: docker/build-push-action@v6 + id: build + with: + context: . + platforms: ${{ matrix.platform.version }} + labels: ${{ steps.meta.outputs.labels }} + outputs: type=image,name=${{ env.REGISTRY_IMAGE }},push-by-digest=true,name-canonical=true,push=true + build-args: | + SEGMENT_WRITE_KEY=${{ secrets.SEGMENT_WRITE_KEY }} + GOCARDLESS_CLIENT_ID=${{ secrets.PRODUCTION_GOCARDLESS_CLIENT_ID }} + GOCARDLESS_CLIENT_SECRET=${{ secrets.PRODUCTION_GOCARDLESS_CLIENT_SECRET }} + LAGO_OAUTH_URL=https://proxy.getlago.com + + - name: Export Digest + run: | + mkdir -p ./_tmp/${{ github.run_id }}/${{ github.run_attempt }}/digests + digest="${{ steps.build.outputs.digest }}" + touch "./_tmp/${{ github.run_id }}/${{ github.run_attempt }}/digests/${digest#sha256:}" + + - name: Upload Digest + uses: actions/upload-artifact@v4 + with: + name: digests-${{ env.PLATFORM_PAIR }} + path: ./_tmp/${{ github.run_id }}/${{ github.run_attempt }}/digests/* + if-no-files-found: error + retention-days: 1 + + - name: Clean up + if: always() + run: | + [ -e ./_tmp/${{ github.run_id }}/${{ github.run_attempt }}/digests ] && \ + rm -rf ./_tmp/${{ github.run_id }}/${{ github.run_attempt }}/digests + + merge: + name: Merge Images + runs-on: ubuntu-latest + needs: [build-images] + steps: + - name: Download Digests + uses: actions/download-artifact@v4 + with: + path: ./_tmp/${{ github.run_id}}/${{ github.run_attempt }}/digests + pattern: digests-* + merge-multiple: true + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY_IMAGE }} + tags: | + type=raw,value=${{ github.event_name == 'release' && github.event.release.tag_name || github.event.inputs.version }} + + - name: Set up Docker buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_PASSWORD }} + + - name: Create manifest and push + working-directory: ./_tmp/${{ github.run_id }}/${{ github.run_attempt}}/digests + run: | + docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + $(printf '${{ env.REGISTRY_IMAGE }}@sha256:%s ' *) + + - name: Inspect Image + run: | + docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} + + - name: Clean up + if: always() + run: | + [ -e ./_tmp/${{ github.run_id }}/${{ github.run_attempt }}/digests ] && \ + rm -rf ./_tmp/${{ github.run_id }}/${{ github.run_attempt }}/digests + + notify: + name: Notify Private Release + runs-on: ubuntu-latest + needs: [merge] + steps: + - name: Set version for dispatch + id: version + run: | + if [ "${{ github.event_name }}" = "release" ]; then + echo "version=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT + else + echo "version=${{ github.event.inputs.version }}" >> $GITHUB_OUTPUT + fi + + - name: Trigger lago-embedded repository + uses: peter-evans/repository-dispatch@v3 + with: + token: ${{ secrets.GH_TOKEN }} + repository: getlago/lago-embedded + event-type: lago-private-release + client-payload: '{"version": "${{ steps.version.outputs.version }}"}' diff --git a/.github/workflows/spec.yml b/.github/workflows/spec.yml new file mode 100644 index 0000000..06cde81 --- /dev/null +++ b/.github/workflows/spec.yml @@ -0,0 +1,96 @@ +name: Run Spec +on: + push: + branches: + - "main" + pull_request: + types: [opened, synchronize, reopened] +permissions: {} +jobs: + run-spec: + name: Run Spec + runs-on: ubuntu-latest + services: + postgres: + image: getlago/postgres-partman:15.0-alpine + ports: + - "5432:5432" + env: + POSTGRES_DB: lago + POSTGRES_USER: lago + POSTGRES_PASSWORD: lago + redis: + image: redis + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + strategy: + fail-fast: false + matrix: + ci_node_total: [10] + ci_node_index: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] + + env: + RAILS_ENV: test + DATABASE_URL: "postgres://lago:lago@localhost:5432/lago" + LAGO_REDIS_CACHE_URL: "redis://localhost:6379" + LAGO_REDIS_STORE_URL: "localhost:6379" + RAILS_MASTER_KEY: N+XcWoGDzKjuoxrU8BIPN5D0/GSuqx9s + SECRET_KEY_BASE: cvIAI6ycC0OnVDRAjT5hmbRxnjCxl4YB + LAGO_API_URL: https://api.lago.dev + LAGO_PDF_URL: https://pdf.lago.dev + LAGO_DATA_API_URL: http://data_api + LAGO_FROM_EMAIL: noreply@getlago.com + LAGO_CLICKHOUSE_ENABLED: true + LAGO_CLICKHOUSE_MIGRATIONS_ENABLED: true + LAGO_CLICKHOUSE_HOST: localhost + LAGO_CLICKHOUSE_DATABASE: default + LAGO_CLICKHOUSE_USERNAME: "" + LAGO_CLICKHOUSE_PASSWORD: "password" + LAGO_KAFKA_BOOTSTRAP_SERVERS: localhost:9092 + LAGO_KAFKA_ACTIVITY_LOGS_TOPIC: activity_logs + LAGO_KAFKA_API_LOGS_TOPIC: api_logs + LAGO_KAFKA_EVENTS_CHARGED_IN_ADVANCE_TOPIC: events_charged_in_advance + LAGO_KAFKA_SECURITY_LOGS_TOPIC: security_logs + KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC: ${{ secrets.KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC }} + KNAPSACK_PRO_CI_NODE_TOTAL: ${{ matrix.ci_node_total }} + KNAPSACK_PRO_CI_NODE_INDEX: ${{ matrix.ci_node_index }} + KNAPSACK_PRO_FIXED_QUEUE_SPLIT: true + KNAPSACK_PRO_LOG_LEVEL: info + MISTRAL_API_KEY: "" + MISTRAL_AGENT_ID: "" + + steps: + - name: Checkout code + uses: actions/checkout@v4 + - name: Install Ruby and gems + uses: ruby/setup-ruby@v1 + with: + ruby-version: "4.0.2" + bundler-cache: true + - name: Start Clickhouse database + run: | + docker run -d --rm -p 8123:8123 -p 9000:9000 --ulimit nofile=262144:262144 -v ./clickhouse-s3:/var/lib/clickhouse-s3 -v ./ci/clickhouse/config.xml:/etc/clickhouse-server/config.d/config.xml -e CLICKHOUSE_PASSWORD=password clickhouse/clickhouse-server:25.12-alpine + + shell: bash + - name: Generate RSA keys + run: ./scripts/generate.rsa.sh + - name: Set up Postgres database schema + run: bin/rails db:schema:load:primary + - name: Set up Clickhouse database schema + run: bin/rails db:migrate:clickhouse + - name: Run tests + run: | + if [[ -z "$KNAPSACK_PRO_TEST_SUITE_TOKEN_RSPEC" ]]; then + echo "::warning::This is a community PR. We'll default to running the test using 'parallel_tests'." + bundle exec parallel_rspec --only-group "${KNAPSACK_PRO_CI_NODE_INDEX}" -n "${KNAPSACK_PRO_CI_NODE_TOTAL}" --exclude-pattern "spec/integration/.*_integration_spec.rb" + else + bundle exec rake knapsack_pro:queue:rspec + fi + continue-on-error: true + - name: retry failed tests + run: bundle exec rspec --only-failures diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7db2f68 --- /dev/null +++ b/.gitignore @@ -0,0 +1,48 @@ +# Ignore bundler config. +/.bundle + +# Ignore the default SQLite database. +/db/*.sqlite3 +/db/*.sqlite3-* + +# Ignore all logfiles and tempfiles. +/log/* +/tmp/* +!/log/.keep +!/tmp/.keep + +# Ignore pidfiles, but keep the directory. +/tmp/pids/* +!/tmp/pids/ +!/tmp/pids/.keep + +# Ignore uploaded files in development. +/storage/* +!/storage/.keep +/tmp/storage/* +!/tmp/storage/ +!/tmp/storage/.keep +db/events_schema.rb +db/events_structure.sql +db/clickhouse_schema.rb +db/clickhouse_structure.sql +/config/master.key + +.env +.byebug_history +.irb_history + +# Ignore Simplecov coverage status +/coverage/* + +config/keys/* +.rsa_private.pem +.rsa_public.pem +LAGO_VERSION + +**/.DS_Store +.vscode +.zed +.idea + +/vendor/bundle diff --git a/.irbrc b/.irbrc new file mode 100644 index 0000000..f850f01 --- /dev/null +++ b/.irbrc @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +IRB.conf[:USE_AUTOCOMPLETE] = false diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..d016dc8 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--require spec_helper +--format Fuubar +--color diff --git a/.rubocop.yml b/.rubocop.yml new file mode 100644 index 0000000..e71075e --- /dev/null +++ b/.rubocop.yml @@ -0,0 +1,180 @@ +inherit_mode: + merge: + - Exclude + +plugins: + - rubocop-rails + - rubocop-graphql + - rubocop-factory_bot + - rubocop-performance + - rubocop-thread_safety + - rubocop-rspec + - rubocop-rspec_rails + +require: + - standard + - ./dev/cops/service_call_cop.rb + - ./dev/cops/discard_all_cop.rb + - ./dev/cops/no_drop_column_or_table_cop.rb + - ./dev/cops/lago_premium_around_cop.rb + +inherit_gem: + standard: config/base.yml + +AllCops: + NewCops: disable + DisplayStyleGuide: true + Exclude: + - "bin/**/*" + - "db/schema.rb" + - "db/*_schema.rb" + - "storage/**/*" + - "tmp/**/*" + - "coverage/**/*" + - "log/**/*" + +Lago/LagoPremiumAround: + Include: + - "spec/**/*.rb" + +Lago/NoDropColumnOrTable: + Include: + - "db/migrate/**/*.rb" + Exclude: + - "db/migrate/20250218165958_drop_invoice_error_table.rb" + - "db/migrate/20250227091909_remove_is_default_from_billing_entity.rb" + - "db/migrate/20250526133654_drop_clickhouse_aggregation_from_organizations.rb" + - "db/migrate/20250528133222_drop_invoice_custom_section_selections.rb" + - "db/migrate/20250912081524_change_wallet_min_max_to_big_int.rb" + - "db/migrate/20251007103421_drop_customer_snapshots.rb" + - "db/migrate/20251020090137_remove_event_id_from_cached_aggregation.rb" + - "db/migrate/20251221174251_create_roles.rb" + - "db/migrate/20251221174733_create_membership_roles.rb" + - "db/migrate/20251221174938_add_roles_to_invites.rb" + - "db/migrate/20260109110146_create_enriched_events.rb" + - "db/migrate/20260116121015_remove_role_from_memberships.rb" + - "db/migrate/20260116121019_remove_role_from_invites.rb" + - "db/migrate/20260119162712_drop_subscription_fixed_charge_units_overrides.rb" + - "db/migrate/20260129145352_drop_properties_from_enriched_events.rb" + - "db/migrate/20260409151451_create_enriched_store_migrations.rb" + - "db/migrate/20260409161142_create_enriched_store_subscription_migrations.rb" + +# TODO: Fix these services +Lago/ServiceCall: + Exclude: + - "app/services/integration_mappings/create_service.rb" + - "app/services/integrations/anrok/create_service.rb" + - "app/services/integrations/okta/create_service.rb" + - "app/services/integrations/xero/create_service.rb" + - "app/services/invites/accept_service.rb" + - "app/services/invoices/finalize_batch_service.rb" + - "app/services/invoices/payments/retry_batch_service.rb" + - "app/services/invoices/retry_batch_service.rb" + +Style/FrozenStringLiteralComment: + Enabled: true + SafeAutoCorrect: true + +Performance/CaseWhenSplat: + Enabled: true + +Rails/ApplicationJob: + Exclude: + - "spec/support/jobs/**/*" + +# strong_migration does not work with bulk changes +Rails/BulkChangeTable: + Enabled: false + +Rails/InverseOf: + Description: "Checks for associations where the inverse cannot be determined automatically." + Enabled: false + +Rails/HttpStatus: + Description: "Enforces use of symbolic or numeric value to describe HTTP status." + Enabled: false + +Rails/HasManyOrHasOneDependent: + Description: 'Forces a "dependent" options for has_one and has_many rails relations.' + Enabled: false + +Rails/TransactionExitStatement: + Enabled: true + +RSpec/ExampleLength: + Description: "Checks for long examples." + Enabled: false + +RSpec/MultipleExpectations: + Description: "Checks if examples contain too many expect calls." + Enabled: false + +RSpec/MultipleMemoizedHelpers: + Enabled: false + +RSpec/NestedGroups: + Enabled: false + +RSpec/NamedSubject: + Enabled: false + +RSpec/DescribeClass: + Exclude: + - "spec/scenarios/**/*" + +# RSpec rules below are enabled by default in rubocop-rspec 3.X but we disabled to ease migration. +RSpec/IndexedLet: + Enabled: false + +RSpec/MatchArray: + Enabled: false + +RSpec/MetadataStyle: + Enabled: false + +RSpec/NoExpectationExample: + Enabled: false + +RSpec/ReceiveMessages: + Enabled: false + +RSpec/VerifiedDoubleReference: + Enabled: false + +RSpec/BeEq: + Enabled: false + +RSpec/BeNil: + Enabled: false + +# GraphQL + +GraphQL/ArgumentDescription: + Enabled: false + +GraphQL/FieldDescription: + Enabled: false + +GraphQL/ObjectDescription: + Enabled: false + +GraphQL/ExtractType: + Enabled: false + +GraphQL/ExtractInputType: + Exclude: + - "app/graphql/mutations/applied_coupons/create.rb" + - "app/graphql/mutations/credit_notes/create.rb" + - "app/graphql/mutations/invites/accept.rb" + - "app/graphql/mutations/plans/create.rb" + - "app/graphql/mutations/plans/update.rb" + - "app/graphql/mutations/register_user.rb" + - "app/graphql/mutations/wallet_transactions/create.rb" + +ThreadSafety/NewThread: + Exclude: + - "spec/**/*" + +ThreadSafety/DirChdir: + Exclude: + - "spec/**/*" diff --git a/.ruby-version b/.ruby-version new file mode 100644 index 0000000..4d54dad --- /dev/null +++ b/.ruby-version @@ -0,0 +1 @@ +4.0.2 diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 0000000..0f1e605 --- /dev/null +++ b/.tool-versions @@ -0,0 +1 @@ +ruby 4.0.2 diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..38c8320 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,224 @@ +--- +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 `. +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: + +``` +[optional scope]: + +## Context + +... + +## Description + +... +``` + +Where: +- `` 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) +- `` 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: + +```ruby +# 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: + + ```ruby + 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`) + +# Backward Compatibility + +- New optional parameters must not break existing functionality + +# 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()` +- 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 +- Use `let!` only for objects that need to be created before the test runs; if not referencing the object in tests, consider creating them directly in a `before` block instead of using `let` +- 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: + ```ruby + 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 + +- 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` diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..100df10 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +- Demonstrating empathy and kindness toward other people +- Being respectful of differing opinions, viewpoints, and experiences +- Giving and gracefully accepting constructive feedback +- Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +- Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or + advances of any kind +- Trolling, insulting or derogatory comments, and personal or political attacks +- Public or private harassment +- Publishing others' private information, such as a physical or email + address, without their explicit permission +- Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +dev@getlago.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..861c1e7 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,213 @@ +# Contributing to Lago + +If you're reading this, we would first like to thank you for taking the time to contribute. + +The following is a set of guidelines for contributing to Lago and its packages, which are hosted in the [Lago Organization](https://github.com/getlago) on GitHub. These are mostly guidelines, not rules. Use your best judgment, and feel free to propose changes to this document in a pull request. + +#### Table Of Contents + +[Code of Conduct](#code-of-conduct) + +[I don't want to read this whole thing, I just have a question!!!](#i-dont-want-to-read-this-whole-thing-i-just-have-a-question) + +[What should I know before I get started?](#what-should-i-know-before-i-get-started) + +- [Lago and Packages](#lago-and-packages) +- [Design Decisions](#design-decisions) + +[How Can I Contribute?](#how-can-i-contribute) + +- [Reporting Bugs](#reporting-bugs) +- [Suggesting Enhancements](#suggesting-enhancements) +- [Your First Code Contribution](#your-first-code-contribution) +- [Pull Requests](#pull-requests) + +[Styleguides](#styleguides) + +- [Git Commit Messages](#git-commit-messages) +- [General style guide](#general-style-guide) + +[Additional Notes](#additional-notes) + +- [Issue and Pull Request Labels](#issue-and-pull-request-labels) + +## Code of Conduct + +This project and everyone participating in it is governed by the [Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. Please report unacceptable behavior to [dev@getlago.com](mailto:dev@getlago.com). + +## I don't want to read this whole thing I just have a question!!! + +- [Lago Discussions](https://lago-community.slack.com) +- [Lago official documentation](https://doc.getlago.com/docs/guide/intro/welcome) +- [Lago feature request](https://getlago.canny.io/) + +## What should I know before I get started? + +### Lago and Packages + +Lago is an open source project. When you initially consider contributing to Lago, you might be unsure about which of Lago elements implements the functionality you want to change or report a bug for. This section should help you with that. + +Here's a list of Lago's elements: + +- [lago](https://github.com/getlago/lago) - The entry point of the lago application +- [lago/front](https://github.com/getlago/lago-front) - Lago's UI +- [lago/api](https://github.com/getlago/lago-api) - Lago's API (you are here 📍) +- [lago/openAPI](https://github.com/getlago/lago-openapi) - OpenAPI definition for the Lago API + +#### The different clients + +- [lago/client/go](https://github.com/getlago/lago-go-client) - Lago's Go Client +- [lago/client/javascript](https://github.com/getlago/lago-javascript-client) - Lago's Javascript Client +- [lago/client/python](https://github.com/getlago/lago-python-client) - Lago's Python Client +- [lago/client/ruby](https://github.com/getlago/lago-ruby-client) - Lago's Ruby Client + +Also, because Lago is extensible, it's possible that a feature you've become accustomed to in Lago or an issue you're encountering isn't coming from a bundled package at all, but rather a community package you've installed. Each community package has its own repository too. + +### Design Decisions + +If you have a question around how we do things, check to see if it is documented in the wiki of the related repository. If it is _not_ documented there, please open a new topic on [Lago Discussions](https://lago-community.slack.com) and ask your question. + +## How Can I Contribute? + +### Reporting Bugs + +This section guides you through submitting a bug report for Lago. Following these guidelines helps maintainers and the community understand your report :pencil:, reproduce the behavior :computer: :computer:, and find related reports :mag_right:. + +Before creating bug reports, please check [this list](#before-submitting-a-bug-report) as you might find out that you don't need to create one. When you are creating a bug report, please [include as many details as possible](#how-do-i-submit-a-good-bug-report). Fill out the required [template](https://github.com/getlago/lago-api/issues/new?assignees=&labels=%F0%9F%90%9E+bug&template=bug_report.md&title=%5BBUG%5D%3A+), the information it asks for helps us resolve issues faster. + +> **Note:** If you find a **Closed** issue that seems like it is the same thing that you're experiencing, open a new issue and include a link to the original issue in the body of your new one. + +#### Before Submitting A Bug Report + +- **Check [Lago Discussions](https://lago-community.slack.com)** for a list of common questions and problems. +- **Determine [which element the problem should be reported in](#lago-and-packages)**. +- **Perform a [cursory search](https://github.com/search?q=+is%3Aissue+user%3Agetlago)** to see if the problem has already been reported. If it has **and the issue is still open**, add a comment to the existing issue instead of opening a new one. + +#### How Do I Submit A (Good) Bug Report? + +Bugs are tracked as [GitHub issues](https://guides.github.com/features/issues/). After you've determined [which element](#lago-and-packages) your bug is related to, create an issue and provide the following information by filling in the template. + +Explain the problem and include additional details to help maintainers reproduce the problem: + +- **Use a clear and descriptive title** for the issue to identify the problem. +- **Describe the exact steps which reproduce the problem** in as many details as possible. For example, start by explaining how you started Lago, e.g. which command exactly you used in the terminal, or how you started Lago otherwise. When listing steps, **don't just say what you did, but explain how you did it**. +- **Provide specific examples to demonstrate the steps**. Include links to files or GitHub projects, or copy/pasteable snippets, which you use in those examples. If you're providing snippets in the issue, use [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). +- **Describe the behavior you observed after following the steps** and point out what exactly is the problem with that behavior. +- **Explain which behavior you expected to see instead and why.** +- **Include screenshots and animated GIFs** which show you following the described steps and clearly demonstrate the problem. If you use the keyboard while following the steps. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. +- **If you're reporting that Lago crashed**, include a crash report with a stack trace from the operating system. Include the crash report in the issue in a [code block](https://help.github.com/articles/markdown-basics/#multiple-lines), a [file attachment](https://help.github.com/articles/file-attachments-on-issues-and-pull-requests/), or put it in a [gist](https://gist.github.com/) and provide link to that gist. +- **If the problem wasn't triggered by a specific action**, describe what you were doing before the problem happened and share more information using the guidelines below. + +Provide more context by answering these questions: + +- **Did the problem start happening recently** (e.g. after updating to a new version of Lago) or was this always a problem? +- If the problem started happening recently, **can you reproduce the problem in an older version of Lago?** What's the most recent version in which the problem doesn't happen? You can download older versions of Lago from [the releases page](https://github.com/getlago/lago/releases). +- **Can you reliably reproduce the issue?** If not, provide details about how often the problem happens and under which conditions it normally happens. + +Include details about your configuration and environment: + +- **Which version of Lago are you using?** +- **What's the name and version of the OS you're using**? +- **Are you running Lago in a virtual machine?** If so, which VM software are you using and which operating systems and versions are used for the host and the guest? +- **Which [packages](#lago-and-packages) do you have installed?**. + +### Suggesting Enhancements + +This section guides you through submitting an enhancement suggestion for Lago, including completely new features and minor improvements to existing functionality. Following these guidelines helps maintainers and the community understand your suggestion :pencil: and find related suggestions :mag_right:. + +Before creating enhancement suggestions, please check [this list](#before-submitting-an-enhancement-suggestion) as you might find out that you don't need to create one. When you are creating an enhancement suggestion, please [include as many details as possible](#how-do-i-submit-a-good-enhancement-suggestion). Fill in the [template](https://github.com/getlago/lago-api/issues/new?assignees=&labels=%F0%9F%9B%A0+feature&template=feature_request.md&title=%5BFEAT%5D%3A+), including the steps that you imagine you would take if the feature you're requesting existed. + +#### Before Submitting An Enhancement Suggestion + +- **Check the [documentation](https://doc.getlago.com)** you might discover that the enhancement is already available. Most importantly, check if you're using [the latest version of Lago](https://github.com/getlago/lago/releases). +- **Determine [which element the enhancement should be suggested in](#lago-and-packages).** +- **Perform a [cursory search](https://github.com/search?q=+is%3Aissue+user%3Agetlago)** to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one. + +#### How Do I Submit A (Good) Enhancement Suggestion? + +Enhancement suggestions are tracked as [GitHub issues](https://guides.github.com/features/issues/). After you've determined [which repository](#lago-and-packages) your enhancement suggestion is related to, create an issue and provide the following information: + +- **Use a clear and descriptive title** for the issue to identify the suggestion. +- **Provide a step-by-step description of the suggested enhancement** in as many details as possible. +- **Provide specific examples to demonstrate the steps**. Include copy/pasteable snippets which you use in those examples, as [Markdown code blocks](https://help.github.com/articles/markdown-basics/#multiple-lines). +- **Describe the current behavior** and **explain which behavior you expected to see instead** and why. +- **Include screenshots and animated GIFs** which help you demonstrate the steps or point out the part of Lago which the suggestion is related to. You can use [this tool](https://www.cockos.com/licecap/) to record GIFs on macOS and Windows, and [this tool](https://github.com/colinkeenan/silentcast) or [this tool](https://github.com/GNOME/byzanz) on Linux. +- **Explain why this enhancement would be useful** to most Lago users and isn't something that can or should be implemented as a [community package](#lago-and-packages). +- **Specify which version of Lago you're using.** +- **Specify the name and version of the OS you're using.** + +### Your First Code Contribution + +Unsure where to begin contributing to Lago? You can start by looking through these `good first issue` and `help-wanted` labels: + +- Good first issues - issues which should only require a few lines of code, and a test or two. +- Help wanted issues - issues which should be a bit more involved than `good first issue` issues. + +Both issue lists are sorted by total number of comments. While not perfect, number of comments is a reasonable proxy for impact a given change will have. + +#### Local development + +Lago and all packages can be developed locally. For instructions on how to do this, see the dedicated section in the README or in the wiki of the related repository. + +### Pull Requests + +The process described here has several goals: + +- Maintain Lago's quality +- Fix problems that are important to users +- Engage the community in working toward the best possible Lago +- Enable a sustainable system for Lago's maintainers to review contributions + +Please follow these steps to have your contribution considered by the maintainers: + +1. Follow all instructions in [the template](PULL_REQUEST_TEMPLATE.md) +2. Follow the [styleguides](#styleguides) +3. After you submit your pull request, verify that all [status checks](https://help.github.com/articles/about-status-checks/) are passing
What if the status checks are failing?If a status check is failing, and you believe that the failure is unrelated to your change, please leave a comment on the pull request explaining why you believe the failure is unrelated. A maintainer will re-run the status check for you. If we conclude that the failure was a false positive, then we will open an issue to track that problem with our status check suite.
+ +While the prerequisites above must be satisfied prior to having your pull request reviewed, the reviewer(s) may ask you to complete additional design work, tests, or other changes before your pull request can be ultimately accepted. + +## Styleguides + +### Git Commit Messages + +- Use the present tense ("Add feature" not "Added feature") +- Use the imperative mood ("Move cursor to..." not "Moves cursor to...") +- Limit the first line to 72 characters or less +- Reference issues and pull requests liberally after the first line +- When only changing documentation, include `[ci skip]` in the commit title +- Use the [Convention commits](https://www.conventionalcommits.org/en/v1.0.0/) convention. + +## Additional Notes + +### Issue and Pull Request Labels + +This section lists the labels we use to help us track and manage issues and pull requests. + +[GitHub search](https://help.github.com/articles/searching-issues/) makes it easy to use labels for finding groups of issues or pull requests you're interested in. To help you find issues and pull requests, each label is listed with search links for finding open items with that label. We encourage you to read about [other search filters](https://help.github.com/articles/searching-issues/) which will help you write more focused queries. + +The labels are loosely grouped by their purpose, but it's not required that every issue has a label from every group or that an issue can't have more than one label from the same group. + +Please open an issue if you have suggestions for new labels. + +#### Type of Issue and Issue State + +| Label name | :mag_right: | Description | +| --------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ | +| `feature` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%9B%A0%20feature) | Feature requests. | +| `improvement` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%8C%88%20improvement) | Improvement requests (of existing features). | +| `documentation` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%93%84%20Documentation) | Feature requests. | +| `bug` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%90%9E%20bug) | Confirmed bugs or reports that are very likely to be bugs. | +| `critical bug` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%99%80%20Critical%20bug) | Confirmed critical bugs or reports that are very likely to be bugs. | +| `chore` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%A5%B7%20chore) | Chore related issues | +| `help wanted` | [Issues](https://github.com/getlago/lago-api/labels/help-wanted) | The Lago core team would appreciate help from the community in resolving these issues. | +| `good first issue` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%90%A3%20Beginner) | Less complex issues which would be good first issues to work on for users who want to contribute to Lago. | +| `wontfix` | [Issues](https://github.com/getlago/lago-api/labels/%E2%9D%8C%20wontfix) | The Lago core team has decided not to fix these issues for now, either because they're working as intended or for some other reason. | +| `dependencies` | [Issues](https://github.com/getlago/lago-api/labels/%F0%9F%94%97%20dependencies) | Issues reported on the wrong repository | + +#### Pull Request Labels + +| Label name | :mag_right: | Description | +| ------------------ | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `needs-review` | [PR](https://github.com/getlago/lago-api/pulls?q=is%3Apr+is%3Aopen+review%3Arequired) | Pull requests which need code review, and approval from maintainers or Lago core team. | +| `requires-changes` | [PR](https://github.com/getlago/lago-api/pulls?q=is%3Apr+is%3Aopen+review%3Achanges-requested) | Pull requests which need to be updated based on review comments and then reviewed again. | +| `review-approved` | [PR](https://github.com/getlago/lago-api/pulls?q=is%3Apr+is%3Aopen+review%3Aapproved) | That has been approved | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9d58d83 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,56 @@ +ARG PDFCPU_VERSION=0.11.1 +ARG GO_VERSION=1.25.8 + +FROM golang:${GO_VERSION} AS pdfcpu-build + +ARG PDFCPU_VERSION + +RUN go install github.com/pdfcpu/pdfcpu/cmd/pdfcpu@v${PDFCPU_VERSION} + +FROM ruby:4.0.2-slim AS build + +ARG BUNDLE_WITH + +WORKDIR /app + +RUN apt update && apt upgrade -y +RUN apt install nodejs curl build-essential git pkg-config libpq-dev libclang-dev postgresql-client curl libyaml-dev -y && \ + curl https://sh.rustup.rs -sSf | bash -s -- -y + +COPY ./Gemfile /app/Gemfile +COPY ./Gemfile.lock /app/Gemfile.lock + +ENV BUNDLER_VERSION='4.0.4' +ENV PATH="$PATH:/root/.cargo/bin/" +RUN gem install bundler --no-document -v '4.0.4' + +ENV BUNDLE_WITH=${BUNDLE_WITH:-} +ENV BUNDLE_WITHOUT="development test" +RUN --mount=type=secret,id=BUNDLE_GEMS__CONTRIBSYS__COM,env=BUNDLE_GEMS__CONTRIBSYS__COM \ + bundle config set build.nokogiri --use-system-libraries &&\ + bundle install --jobs=3 --retry=3 + +FROM ruby:4.0.2-slim + +ARG BUNDLE_WITH + +RUN apt update && apt upgrade -y +RUN apt install git libpq-dev curl postgresql-client -y + +ARG SEGMENT_WRITE_KEY +ARG GOCARDLESS_CLIENT_ID +ARG GOCARDLESS_CLIENT_SECRET + +ENV SEGMENT_WRITE_KEY=$SEGMENT_WRITE_KEY +ENV GOCARDLESS_CLIENT_ID=$GOCARDLESS_CLIENT_ID +ENV GOCARDLESS_CLIENT_SECRET=$GOCARDLESS_CLIENT_SECRET + +ENV BUNDLE_WITH=${BUNDLE_WITH:-} +ENV BUNDLE_WITHOUT="development test" + +COPY --from=build /usr/local/bundle/ /usr/local/bundle +COPY --from=pdfcpu-build /go/bin/pdfcpu /usr/local/bin/pdfcpu +WORKDIR /app +COPY . . + +CMD ["./scripts/start.sh"] diff --git a/Dockerfile.dev b/Dockerfile.dev new file mode 100644 index 0000000..385c50e --- /dev/null +++ b/Dockerfile.dev @@ -0,0 +1,33 @@ +ARG PDFCPU_VERSION=0.11.1 +ARG GO_VERSION=1.25.8 + +FROM golang:${GO_VERSION} AS pdfcpu-build + +ARG PDFCPU_VERSION + +RUN go install github.com/pdfcpu/pdfcpu/cmd/pdfcpu@v${PDFCPU_VERSION} + +FROM ruby:4.0.2-slim + +WORKDIR /app +ENV POSTGRES_VERSION=15 + +RUN apt update -qq && \ + apt-get install nodejs build-essential curl git pkg-config libpq-dev libclang-dev postgresql-common libyaml-dev -y && \ + curl https://sh.rustup.rs -sSf | bash -s -- -y && \ + /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh -i -v $POSTGRES_VERSION + +COPY --from=pdfcpu-build /go/bin/pdfcpu /usr/local/bin/pdfcpu + +ENV BUNDLER_VERSION='4.0.4' +ENV PATH="$PATH:/root/.cargo/bin/" + +COPY ./Gemfile /app/Gemfile +COPY ./Gemfile.lock /app/Gemfile.lock + +RUN gem install bundler --no-document -v '4.0.4' + +RUN bundle config set build.nokogiri --use-system-libraries && \ + bundle install + +CMD ["./scripts/start.dev.sh"] diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..561e938 --- /dev/null +++ b/Gemfile @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +source "https://rubygems.org" +git_source(:github) { |repo| "https://github.com/#{repo}.git" } + +ruby "4.0.2" + +# Core +gem "aasm" +gem "activejob-uniqueness", require: "active_job/uniqueness/sidekiq_patch" +gem "redlock", "~> 2.0.6" # Used through `activejob-uniqueness`. It's pinned to 2.0.x because we patched the library to fix a bug. +gem "active_storage_validations" +gem "bootsnap", require: false +gem "clockwork", require: false +gem "parallel" +gem "puma", "~> 6.5" +gem "rails", "~> 8.0" +gem "redis" +gem "sidekiq" +gem "sidekiq-prometheus-exporter" +group :"sidekiq-pro", optional: true do + source "https://gems.contribsys.com/" do + gem "sidekiq-pro" + end + gem "dogstatsd-ruby" +end +gem "sidekiq-throttled", "1.4.0" # '1.5.0' was losing some jobs +gem "throttling" +gem "device_detector" +gem "dry-validation" + +# Security +gem "bcrypt" +gem "googleauth", "~> 1.11.0" +gem "jwt" +gem "oauth2" +gem "rack-cors" + +# Database +gem "after_commit_everywhere" +gem "clickhouse-activerecord", "~> 1.6.1" +gem "discard", "~> 1.2" +gem "kaminari-activerecord" +gem "paper_trail" +gem "pg" +gem "ransack" +gem "scenic" +gem "with_advisory_lock" +gem "strong_migrations" +gem "connection_pool", "<3" # Temporary fix. See https://github.com/mperham/connection_pool/issues/212 + +# Currencies, Countries, Timezones... +gem "bigdecimal" +gem "countries" +gem "money-rails" +gem "timecop", require: false +gem "tzinfo-data", platforms: %i[windows jruby] + +# GraphQL +gem "graphql" +gem "graphql-pagination" + +# Payment processing +gem "adyen-ruby-api-library" +gem "gocardless_pro", "~> 2.34" +gem "stripe" + +# Analytics +gem "analytics-ruby", require: "segment/analytics" + +# SSE +gem "event_stream_parser" + +# Logging +gem "lograge" +gem "logstash-event" + +# HTTP and Multipart support +gem "multipart-post" +gem "mutex_m" + +# Monitoring +gem "newrelic_rpm" +gem "opentelemetry-exporter-otlp" +gem "opentelemetry-instrumentation-all" +gem "opentelemetry-sdk" +gem "yabeda" +gem "yabeda-rails" +gem "yabeda-puma-plugin" +gem "yabeda-prometheus" + +gem "stackprof", require: false, platforms: [:ruby, :mri] +gem "sentry-rails" +gem "sentry-ruby" +gem "sentry-sidekiq" + +gem "datadog", require: false + +# Storage +gem "aws-sdk-s3", require: false +gem "google-cloud-storage", require: false + +# Templating +gem "slim" +gem "slim-rails" +gem "addressing" + +# Kafka +gem "karafka", "~> 2.5.0" +gem "karafka-web", "~> 0.11.3" + +# Taxes +gem "valvat" + +# Data Export +gem "csv", "~> 3.0" +gem "ostruct" + +gem "lago-expression", github: "getlago/lago-expression", glob: "expression-ruby/lago-expression.gemspec", ref: "2abd2b3" + +group :development, :test, :staging do + gem "factory_bot_rails" + gem "faker" +end + +group :development, :test do + gem "bullet" + gem "clockwork-test" + gem "debug", platforms: %i[mri windows], require: false + gem "dotenv" + gem "fuubar" + gem "rspec-rails" + gem "simplecov", require: false + gem "webmock" + gem "awesome_print" + gem "pry-byebug" + gem "knapsack_pro", "~> 9.0" + gem "parallel_tests", "~> 5.3" + + gem "database_cleaner-active_record" + gem "rspec-graphql_matchers" + gem "shoulda-matchers" + + gem "i18n-tasks", require: false + + gem "rubocop-rails", require: false + gem "rubocop-graphql", require: false + gem "rubocop-performance", require: false + gem "rubocop-rspec", require: false + gem "rubocop-rspec_rails", require: false + gem "rubocop-factory_bot", require: false + gem "rubocop-thread_safety", require: false + + gem "vernier", "~> 1.0", require: false + gem "super_diff", "~> 0.18.0" +end + +group :test do + gem "guard-rspec", require: false + gem "karafka-testing" + + # HTML testing (invoice rendering) + gem "rspec-snapshot", "~> 2.0" + gem "htmlbeautifier", "~> 1.4" +end + +group :development do + gem "coffee-rails" + gem "graphiql-rails" + gem "httplog" + + gem "standard", require: false + gem "annotaterb" + + gem "sass-rails" + gem "uglifier" + + gem "ruby-lsp-rails", require: false +end diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..a4c2a15 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,1176 @@ +GIT + remote: https://github.com/getlago/lago-expression.git + revision: 2abd2b315c75c2ba831f64e37a8409dd45f0603e + ref: 2abd2b3 + glob: expression-ruby/lago-expression.gemspec + specs: + lago-expression (0.2.0) + bigdecimal + rake (~> 13) + rake-compiler (~> 1.2) + rb_sys (~> 0.9.124) + +GEM + remote: https://gems.contribsys.com/ + specs: + sidekiq-pro (7.3.6) + sidekiq (>= 7.3.7, < 8) + +GEM + remote: https://rubygems.org/ + specs: + aasm (5.5.2) + concurrent-ruby (~> 1.0) + actioncable (8.0.4.1) + actionpack (= 8.0.4.1) + activesupport (= 8.0.4.1) + nio4r (~> 2.0) + websocket-driver (>= 0.6.1) + zeitwerk (~> 2.6) + actionmailbox (8.0.4.1) + actionpack (= 8.0.4.1) + activejob (= 8.0.4.1) + activerecord (= 8.0.4.1) + activestorage (= 8.0.4.1) + activesupport (= 8.0.4.1) + mail (>= 2.8.0) + actionmailer (8.0.4.1) + actionpack (= 8.0.4.1) + actionview (= 8.0.4.1) + activejob (= 8.0.4.1) + activesupport (= 8.0.4.1) + mail (>= 2.8.0) + rails-dom-testing (~> 2.2) + actionpack (8.0.4.1) + actionview (= 8.0.4.1) + activesupport (= 8.0.4.1) + nokogiri (>= 1.8.5) + rack (>= 2.2.4) + rack-session (>= 1.0.1) + rack-test (>= 0.6.3) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + useragent (~> 0.16) + actiontext (8.0.4.1) + actionpack (= 8.0.4.1) + activerecord (= 8.0.4.1) + activestorage (= 8.0.4.1) + activesupport (= 8.0.4.1) + globalid (>= 0.6.0) + nokogiri (>= 1.8.5) + actionview (8.0.4.1) + activesupport (= 8.0.4.1) + builder (~> 3.1) + erubi (~> 1.11) + rails-dom-testing (~> 2.2) + rails-html-sanitizer (~> 1.6) + active_storage_validations (3.0.4) + activejob (>= 6.1.4) + activemodel (>= 6.1.4) + activestorage (>= 6.1.4) + activesupport (>= 6.1.4) + marcel (>= 1.0.3) + activejob (8.0.4.1) + activesupport (= 8.0.4.1) + globalid (>= 0.3.6) + activejob-uniqueness (0.4.0) + activejob (>= 4.2, < 8.1) + redlock (>= 2.0, < 3) + activemodel (8.0.4.1) + activesupport (= 8.0.4.1) + activerecord (8.0.4.1) + activemodel (= 8.0.4.1) + activesupport (= 8.0.4.1) + timeout (>= 0.4.0) + activestorage (8.0.4.1) + actionpack (= 8.0.4.1) + activejob (= 8.0.4.1) + activerecord (= 8.0.4.1) + activesupport (= 8.0.4.1) + marcel (~> 1.0) + activesupport (8.0.4.1) + base64 + benchmark (>= 0.3) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.3.1) + connection_pool (>= 2.2.5) + drb + i18n (>= 1.6, < 2) + logger (>= 1.4.2) + minitest (>= 5.1, < 6) + securerandom (>= 0.3) + tzinfo (~> 2.0, >= 2.0.5) + uri (>= 0.13.1) + addressable (2.9.0) + public_suffix (>= 2.0.2, < 8.0) + addressing (1.1.0) + adyen-ruby-api-library (7.3.1) + faraday + after_commit_everywhere (1.6.0) + activerecord (>= 4.2) + activesupport + analytics-ruby (2.5.0) + annotaterb (4.20.0) + activerecord (>= 6.0.0) + activesupport (>= 6.0.0) + anyway_config (2.8.0) + ruby-next-core (~> 1.0) + ast (2.4.3) + attr_extras (7.1.0) + awesome_print (1.9.2) + aws-eventstream (1.4.0) + aws-partitions (1.1208.0) + aws-sdk-core (3.241.4) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + bigdecimal + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.121.0) + aws-sdk-core (~> 3, >= 3.241.4) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.212.0) + aws-sdk-core (~> 3, >= 3.241.4) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + base64 (0.3.0) + bcrypt (3.1.22) + benchmark (0.5.0) + bigdecimal (4.0.1) + bootsnap (1.21.1) + msgpack (~> 1.2) + builder (3.3.0) + bullet (8.1.0) + activesupport (>= 3.0.0) + uniform_notifier (~> 1.11) + byebug (13.0.0) + reline (>= 0.6.0) + cgi (0.5.1) + clickhouse-activerecord (1.6.7) + activerecord (>= 7.1, < 9.0) + bundler (>= 1.13.4) + clockwork (3.0.2) + activesupport + tzinfo + clockwork-test (0.5.1) + clockwork + timecop + coderay (1.1.3) + coffee-rails (5.0.0) + coffee-script (>= 2.2.0) + railties (>= 5.2.0) + coffee-script (2.4.1) + coffee-script-source + execjs + coffee-script-source (1.12.2) + concurrent-ruby (1.3.6) + connection_pool (2.5.5) + countries (8.1.0) + unaccent (~> 0.3) + crack (1.0.1) + bigdecimal + rexml + crass (1.0.6) + csv (3.3.5) + database_cleaner-active_record (2.2.2) + activerecord (>= 5.a) + database_cleaner-core (~> 2.0) + database_cleaner-core (2.0.1) + datadog (2.27.0) + cgi + datadog-ruby_core_source (~> 3.5, >= 3.5.1) + libdatadog (~> 25.0.0.1.0) + libddwaf (~> 1.30.0.0.0) + logger + msgpack + datadog-ruby_core_source (3.5.2) + date (3.5.1) + debug (1.11.1) + irb (~> 1.10) + reline (>= 0.3.8) + declarative (0.0.20) + device_detector (1.1.3) + diff-lcs (1.6.2) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + discard (1.4.0) + activerecord (>= 4.2, < 9.0) + docile (1.4.1) + dogstatsd-ruby (5.7.1) + dotenv (3.2.0) + drb (2.2.3) + dry-configurable (1.3.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-core (1.2.0) + concurrent-ruby (~> 1.0) + logger + zeitwerk (~> 2.6) + dry-inflector (1.3.1) + dry-initializer (3.2.0) + dry-logic (1.6.0) + bigdecimal + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + zeitwerk (~> 2.6) + dry-schema (1.15.0) + concurrent-ruby (~> 1.0) + dry-configurable (~> 1.0, >= 1.0.1) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-logic (~> 1.6) + dry-types (~> 1.8) + zeitwerk (~> 2.6) + dry-types (1.9.1) + bigdecimal (>= 3.0) + concurrent-ruby (~> 1.0) + dry-core (~> 1.0) + dry-inflector (~> 1.0) + dry-logic (~> 1.4) + zeitwerk (~> 2.6) + dry-validation (1.11.1) + concurrent-ruby (~> 1.0) + dry-core (~> 1.1) + dry-initializer (~> 3.2) + dry-schema (~> 1.14) + zeitwerk (~> 2.6) + erb (6.0.4) + erubi (1.13.1) + event_stream_parser (1.0.0) + execjs (2.10.0) + factory_bot (6.5.6) + activesupport (>= 6.1.0) + factory_bot_rails (6.5.1) + factory_bot (~> 6.5) + railties (>= 6.1.0) + faker (3.6.1) + i18n (>= 1.8.11, < 2) + faraday (2.14.1) + faraday-net_http (>= 2.0, < 3.5) + json + logger + faraday-net_http (3.4.2) + net-http (~> 0.5) + ffi (1.17.3-aarch64-linux-gnu) + ffi (1.17.3-aarch64-linux-musl) + ffi (1.17.3-arm64-darwin) + ffi (1.17.3-x64-mingw-ucrt) + ffi (1.17.3-x86_64-darwin) + ffi (1.17.3-x86_64-linux-gnu) + fiber-storage (1.0.1) + formatador (1.2.3) + reline + fuubar (2.5.1) + rspec-core (~> 3.0) + ruby-progressbar (~> 1.4) + globalid (1.3.0) + activesupport (>= 6.1) + gocardless_pro (2.57.0) + faraday (>= 2, < 3) + google-apis-core (0.18.0) + addressable (~> 2.5, >= 2.5.1) + googleauth (~> 1.9) + httpclient (>= 2.8.3, < 3.a) + mini_mime (~> 1.0) + mutex_m + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + google-apis-iamcredentials_v1 (0.26.0) + google-apis-core (>= 0.15.0, < 2.a) + google-apis-storage_v1 (0.59.0) + google-apis-core (>= 0.15.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (2.3.1) + base64 (~> 0.2) + faraday (>= 1.0, < 3.a) + google-cloud-errors (1.5.0) + google-cloud-storage (1.58.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-core (>= 0.18, < 2) + google-apis-iamcredentials_v1 (~> 0.18) + google-apis-storage_v1 (>= 0.42) + google-cloud-core (~> 1.6) + googleauth (~> 1.9) + mini_mime (~> 1.0) + google-protobuf (4.33.4) + bigdecimal + rake (>= 13) + googleapis-common-protos-types (1.22.0) + google-protobuf (~> 4.26) + googleauth (1.11.2) + faraday (>= 1.0, < 3.a) + google-cloud-env (~> 2.1) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + graphiql-rails (1.10.5) + railties + graphql (2.5.26) + base64 + fiber-storage + logger + graphql-pagination (2.4.1) + graphql (>= 2.4.7) + guard (2.20.1) + formatador (>= 0.2.4) + listen (>= 2.7, < 4.0) + logger (~> 1.6) + lumberjack (>= 1.0.12, < 2.0) + nenv (~> 0.1) + notiffany (~> 0.0) + pry (>= 0.13.0) + shellany (~> 0.0) + thor (>= 0.18.1) + guard-compat (1.2.1) + guard-rspec (4.7.3) + guard (~> 2.1) + guard-compat (~> 1.1) + rspec (>= 2.99.0, < 4.0) + hashdiff (1.2.1) + hashie (5.1.0) + logger + highline (3.1.2) + reline + htmlbeautifier (1.4.3) + httpclient (2.9.0) + mutex_m + httplog (1.8.0) + benchmark + rack (>= 2.0) + rainbow (>= 2.0.0) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + i18n-tasks (1.1.2) + activesupport (>= 4.0.2) + ast (>= 2.1.0) + erubi + highline (>= 3.0.0) + i18n + parser (>= 3.2.2.1) + prism + rails-i18n + rainbow (>= 2.2.2, < 4.0) + ruby-progressbar (~> 1.8, >= 1.8.1) + terminal-table (>= 1.5.1) + io-console (0.8.2) + irb (1.17.0) + pp (>= 0.6.0) + prism (>= 1.3.0) + rdoc (>= 4.0.0) + reline (>= 0.4.2) + jmespath (1.6.2) + json (2.19.2) + jwt (2.10.2) + base64 + kaminari-activerecord (1.2.2) + activerecord + kaminari-core (= 1.2.2) + kaminari-core (1.2.2) + karafka (2.5.7) + karafka-core (>= 2.5.6, < 2.6.0) + karafka-rdkafka (>= 0.24.0) + waterdrop (>= 2.8.14, < 3.0.0) + zeitwerk (~> 2.3) + karafka-core (2.5.10) + karafka-rdkafka (>= 0.20.0) + logger (>= 1.6.0) + karafka-rdkafka (0.24.0) + ffi (~> 1.17.1) + json (> 2.0) + logger + mini_portile2 (~> 2.6) + rake (> 12) + karafka-rdkafka (0.24.0-aarch64-linux-gnu) + ffi (~> 1.17.1) + json (> 2.0) + logger + mini_portile2 (~> 2.6) + rake (> 12) + karafka-rdkafka (0.24.0-aarch64-linux-musl) + ffi (~> 1.17.1) + json (> 2.0) + logger + mini_portile2 (~> 2.6) + rake (> 12) + karafka-rdkafka (0.24.0-arm64-darwin) + ffi (~> 1.17.1) + json (> 2.0) + logger + mini_portile2 (~> 2.6) + rake (> 12) + karafka-rdkafka (0.24.0-x86_64-linux-gnu) + ffi (~> 1.17.1) + json (> 2.0) + logger + mini_portile2 (~> 2.6) + rake (> 12) + karafka-testing (2.5.5) + karafka (>= 2.5.0, < 2.6.0) + waterdrop (>= 2.8.0) + karafka-web (0.11.5) + erubi (~> 1.4) + karafka (>= 2.5.2, < 2.6.0) + karafka-core (>= 2.5.0, < 2.6.0) + roda (~> 3.69, >= 3.69) + tilt (~> 2.0) + knapsack_pro (9.2.3) + logger + rake + language_server-protocol (3.17.0.5) + libdatadog (25.0.0.1.0) + libdatadog (25.0.0.1.0-aarch64-linux) + libdatadog (25.0.0.1.0-x86_64-linux) + libddwaf (1.30.0.0.2) + ffi (~> 1.0) + libddwaf (1.30.0.0.2-aarch64-linux) + ffi (~> 1.0) + libddwaf (1.30.0.0.2-arm64-darwin) + ffi (~> 1.0) + libddwaf (1.30.0.0.2-x86_64-darwin) + ffi (~> 1.0) + libddwaf (1.30.0.0.2-x86_64-linux) + ffi (~> 1.0) + lint_roller (1.1.0) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + lograge (0.14.0) + actionpack (>= 4) + activesupport (>= 4) + railties (>= 4) + request_store (~> 1.0) + logstash-event (1.2.02) + loofah (2.25.1) + crass (~> 1.0.2) + nokogiri (>= 1.12.0) + lumberjack (1.4.2) + mail (2.9.0) + logger + mini_mime (>= 0.1.1) + net-imap + net-pop + net-smtp + marcel (1.1.0) + method_source (1.1.0) + mini_mime (1.1.5) + mini_portile2 (2.8.9) + minitest (5.27.0) + monetize (2.0.0) + money (~> 7.0) + money (7.0.2) + bigdecimal + i18n (~> 1.9) + money-rails (3.0.0) + activesupport (>= 7.0) + monetize (~> 2.0) + money (~> 7.0) + railties (>= 7.0) + msgpack (1.8.0) + multi_json (1.19.1) + multi_xml (0.8.1) + bigdecimal (>= 3.1, < 5) + multipart-post (2.4.1) + mutex_m (0.3.0) + nenv (0.3.0) + net-http (0.9.1) + uri (>= 0.11.1) + net-imap (0.6.4) + date + net-protocol + net-pop (0.1.2) + net-protocol + net-protocol (0.2.2) + timeout + net-smtp (0.5.1) + net-protocol + newrelic_rpm (10.0.0) + logger + nio4r (2.7.5) + nokogiri (1.19.3-aarch64-linux-gnu) + racc (~> 1.4) + nokogiri (1.19.3-aarch64-linux-musl) + racc (~> 1.4) + nokogiri (1.19.3-arm64-darwin) + racc (~> 1.4) + nokogiri (1.19.3-x64-mingw-ucrt) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-darwin) + racc (~> 1.4) + nokogiri (1.19.3-x86_64-linux-gnu) + racc (~> 1.4) + notiffany (0.1.3) + nenv (~> 0.1) + shellany (~> 0.0) + oauth2 (2.0.18) + faraday (>= 0.17.3, < 4.0) + jwt (>= 1.0, < 4.0) + logger (~> 1.2) + multi_xml (~> 0.5) + rack (>= 1.2, < 4) + snaky_hash (~> 2.0, >= 2.0.3) + version_gem (~> 1.1, >= 1.1.9) + opentelemetry-api (1.7.0) + opentelemetry-common (0.23.0) + opentelemetry-api (~> 1.0) + opentelemetry-exporter-otlp (0.31.1) + google-protobuf (>= 3.18) + googleapis-common-protos-types (~> 1.3) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-sdk (~> 1.10) + opentelemetry-semantic_conventions + opentelemetry-helpers-mysql (0.4.0) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) + opentelemetry-helpers-sql (0.3.0) + opentelemetry-api (~> 1.7) + opentelemetry-helpers-sql-processor (0.4.0) + opentelemetry-api (~> 1.0) + opentelemetry-common (~> 0.21) + opentelemetry-instrumentation-action_mailer (0.6.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-action_pack (0.15.1) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-action_view (0.11.2) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_job (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_model_serializers (0.24.0) + opentelemetry-instrumentation-active_support (>= 0.7.0) + opentelemetry-instrumentation-active_record (0.11.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-active_storage (0.3.1) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-active_support (0.10.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-all (0.90.1) + opentelemetry-instrumentation-active_model_serializers (~> 0.24.0) + opentelemetry-instrumentation-anthropic (~> 0.3.0) + opentelemetry-instrumentation-aws_lambda (~> 0.6.0) + opentelemetry-instrumentation-aws_sdk (~> 0.11.0) + opentelemetry-instrumentation-bunny (~> 0.24.0) + opentelemetry-instrumentation-concurrent_ruby (~> 0.24.0) + opentelemetry-instrumentation-dalli (~> 0.29.0) + opentelemetry-instrumentation-delayed_job (~> 0.25.1) + opentelemetry-instrumentation-ethon (~> 0.27.0) + opentelemetry-instrumentation-excon (~> 0.27.0) + opentelemetry-instrumentation-faraday (~> 0.31.0) + opentelemetry-instrumentation-grape (~> 0.5.0) + opentelemetry-instrumentation-graphql (~> 0.31.1) + opentelemetry-instrumentation-grpc (~> 0.4.1) + opentelemetry-instrumentation-gruf (~> 0.5.0) + opentelemetry-instrumentation-http (~> 0.28.0) + opentelemetry-instrumentation-http_client (~> 0.27.0) + opentelemetry-instrumentation-httpx (~> 0.6.0) + opentelemetry-instrumentation-koala (~> 0.23.0) + opentelemetry-instrumentation-lmdb (~> 0.25.0) + opentelemetry-instrumentation-mongo (~> 0.25.0) + opentelemetry-instrumentation-mysql2 (~> 0.33.0) + opentelemetry-instrumentation-net_http (~> 0.27.0) + opentelemetry-instrumentation-pg (~> 0.35.0) + opentelemetry-instrumentation-que (~> 0.12.0) + opentelemetry-instrumentation-racecar (~> 0.6.0) + opentelemetry-instrumentation-rack (~> 0.29.0) + opentelemetry-instrumentation-rails (~> 0.39.1) + opentelemetry-instrumentation-rake (~> 0.5.0) + opentelemetry-instrumentation-rdkafka (~> 0.9.0) + opentelemetry-instrumentation-redis (~> 0.28.0) + opentelemetry-instrumentation-resque (~> 0.8.0) + opentelemetry-instrumentation-restclient (~> 0.26.0) + opentelemetry-instrumentation-ruby_kafka (~> 0.24.0) + opentelemetry-instrumentation-sidekiq (~> 0.28.1) + opentelemetry-instrumentation-sinatra (~> 0.28.0) + opentelemetry-instrumentation-trilogy (~> 0.66.0) + opentelemetry-instrumentation-anthropic (0.3.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_lambda (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-aws_sdk (0.11.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-base (0.25.0) + opentelemetry-api (~> 1.7) + opentelemetry-common (~> 0.21) + opentelemetry-registry (~> 0.1) + opentelemetry-instrumentation-bunny (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-concurrent_ruby (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-dalli (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-delayed_job (0.25.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ethon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-excon (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-faraday (0.31.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grape (0.5.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-graphql (0.31.2) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-grpc (0.4.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-gruf (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-http_client (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-httpx (0.6.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-koala (0.23.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-lmdb (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mongo (0.25.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-mysql2 (0.33.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-net_http (0.27.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-pg (0.35.0) + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-que (0.12.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-racecar (0.6.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rack (0.29.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rails (0.39.1) + opentelemetry-instrumentation-action_mailer (~> 0.6) + opentelemetry-instrumentation-action_pack (~> 0.15) + opentelemetry-instrumentation-action_view (~> 0.11) + opentelemetry-instrumentation-active_job (~> 0.10) + opentelemetry-instrumentation-active_record (~> 0.11) + opentelemetry-instrumentation-active_storage (~> 0.3) + opentelemetry-instrumentation-active_support (~> 0.10) + opentelemetry-instrumentation-concurrent_ruby (~> 0.23) + opentelemetry-instrumentation-rake (0.5.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-rdkafka (0.9.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-redis (0.28.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-resque (0.8.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-restclient (0.26.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-ruby_kafka (0.24.0) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sidekiq (0.28.1) + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-instrumentation-sinatra (0.28.0) + opentelemetry-instrumentation-rack (~> 0.29) + opentelemetry-instrumentation-trilogy (0.66.0) + opentelemetry-helpers-mysql + opentelemetry-helpers-sql + opentelemetry-helpers-sql-processor + opentelemetry-instrumentation-base (~> 0.25) + opentelemetry-semantic_conventions (>= 1.8.0) + opentelemetry-registry (0.4.0) + opentelemetry-api (~> 1.1) + opentelemetry-sdk (1.10.0) + opentelemetry-api (~> 1.1) + opentelemetry-common (~> 0.20) + opentelemetry-registry (~> 0.2) + opentelemetry-semantic_conventions + opentelemetry-semantic_conventions (1.36.0) + opentelemetry-api (~> 1.0) + optimist (3.2.1) + os (1.1.4) + ostruct (0.6.3) + paper_trail (17.0.0) + activerecord (>= 7.1) + request_store (~> 1.4) + parallel (1.27.0) + parallel_tests (5.5.0) + parallel + parser (3.3.10.2) + ast (~> 2.4.1) + racc + patience_diff (1.2.0) + optimist (~> 3.0) + pg (1.5.9) + pp (0.6.3) + prettyprint + prettyprint (0.2.0) + prism (1.9.0) + prometheus-client (4.2.5) + base64 + pry (0.16.0) + coderay (~> 1.1) + method_source (~> 1.0) + reline (>= 0.6.0) + pry-byebug (3.12.0) + byebug (~> 13.0) + pry (>= 0.13, < 0.17) + psych (5.3.1) + date + stringio + public_suffix (7.0.5) + puma (6.6.1) + nio4r (~> 2.0) + racc (1.8.1) + rack (3.2.6) + rack-cors (3.0.0) + logger + rack (>= 3.0.14) + rack-session (2.1.2) + base64 (>= 0.1.0) + rack (>= 3.0.0) + rack-test (2.2.0) + rack (>= 1.3) + rackup (2.3.1) + rack (>= 3) + rails (8.0.4.1) + actioncable (= 8.0.4.1) + actionmailbox (= 8.0.4.1) + actionmailer (= 8.0.4.1) + actionpack (= 8.0.4.1) + actiontext (= 8.0.4.1) + actionview (= 8.0.4.1) + activejob (= 8.0.4.1) + activemodel (= 8.0.4.1) + activerecord (= 8.0.4.1) + activestorage (= 8.0.4.1) + activesupport (= 8.0.4.1) + bundler (>= 1.15.0) + railties (= 8.0.4.1) + rails-dom-testing (2.3.0) + activesupport (>= 5.0.0) + minitest + nokogiri (>= 1.6) + rails-html-sanitizer (1.7.0) + loofah (~> 2.25) + nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) + rails-i18n (8.0.1) + i18n (>= 0.7, < 2) + railties (>= 8.0.0, < 9) + railties (8.0.4.1) + actionpack (= 8.0.4.1) + activesupport (= 8.0.4.1) + irb (~> 1.13) + rackup (>= 1.0.0) + rake (>= 12.2) + thor (~> 1.0, >= 1.2.2) + tsort (>= 0.2) + zeitwerk (~> 2.6) + rainbow (3.1.1) + rake (13.3.1) + rake-compiler (1.3.1) + rake + rake-compiler-dock (1.11.0) + ransack (4.4.1) + activerecord (>= 7.2) + activesupport (>= 7.2) + i18n + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rb_sys (0.9.124) + rake-compiler-dock (= 1.11.0) + rbs (4.0.2) + logger + prism (>= 1.6.0) + tsort + rdoc (7.2.0) + erb + psych (>= 4.0.0) + tsort + redis (5.4.1) + redis-client (>= 0.22.0) + redis-client (0.26.3) + connection_pool + redis-prescription (2.6.0) + redlock (2.0.6) + redis-client (>= 0.14.1, < 1.0.0) + regexp_parser (2.11.3) + reline (0.6.3) + io-console (~> 0.5) + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + request_store (1.7.0) + rack (>= 1.4) + retriable (3.1.2) + rexml (3.4.4) + roda (3.102.0) + rack + rspec (3.13.2) + rspec-core (~> 3.13.0) + rspec-expectations (~> 3.13.0) + rspec-mocks (~> 3.13.0) + rspec-core (3.13.6) + rspec-support (~> 3.13.0) + rspec-expectations (3.13.5) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-graphql_matchers (2.0.0.pre.rc.0) + graphql (~> 2.0) + rspec (~> 3.0) + rspec-mocks (3.13.8) + diff-lcs (>= 1.2.0, < 2.0) + rspec-support (~> 3.13.0) + rspec-rails (8.0.4) + actionpack (>= 7.2) + activesupport (>= 7.2) + railties (>= 7.2) + rspec-core (>= 3.13.0, < 5.0.0) + rspec-expectations (>= 3.13.0, < 5.0.0) + rspec-mocks (>= 3.13.0, < 5.0.0) + rspec-support (>= 3.13.0, < 5.0.0) + rspec-snapshot (2.0.3) + awesome_print (> 1.0.0) + rspec (> 3.0.0) + rspec-support (3.13.7) + rubocop (1.84.2) + json (~> 2.3) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.1.0) + parallel (~> 1.10) + parser (>= 3.3.0.2) + rainbow (>= 2.2.2, < 4.0) + regexp_parser (>= 2.9.3, < 3.0) + rubocop-ast (>= 1.49.0, < 2.0) + ruby-progressbar (~> 1.7) + unicode-display_width (>= 2.4.0, < 4.0) + rubocop-ast (1.49.1) + parser (>= 3.3.7.2) + prism (~> 1.7) + rubocop-factory_bot (2.28.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-graphql (1.5.6) + lint_roller (~> 1.1) + rubocop (>= 1.72.1, < 2) + rubocop-performance (1.26.1) + lint_roller (~> 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.47.1, < 2.0) + rubocop-rails (2.34.3) + activesupport (>= 4.2.0) + lint_roller (~> 1.1) + rack (>= 1.1) + rubocop (>= 1.75.0, < 2.0) + rubocop-ast (>= 1.44.0, < 2.0) + rubocop-rspec (3.9.0) + lint_roller (~> 1.1) + rubocop (~> 1.81) + rubocop-rspec_rails (2.32.0) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-rspec (~> 3.5) + rubocop-thread_safety (0.7.3) + lint_roller (~> 1.1) + rubocop (~> 1.72, >= 1.72.1) + rubocop-ast (>= 1.44.0, < 2.0) + ruby-lsp (0.26.9) + language_server-protocol (~> 3.17.0) + prism (>= 1.2, < 2.0) + rbs (>= 3, < 5) + ruby-lsp-rails (0.4.8) + ruby-lsp (>= 0.26.0, < 0.27.0) + ruby-next-core (1.2.0) + ruby-progressbar (1.13.0) + sass-rails (6.0.0) + sassc-rails (~> 2.1, >= 2.1.1) + sassc (2.4.0) + ffi (~> 1.9) + sassc-rails (2.1.2) + railties (>= 4.0.0) + sassc (>= 2.0) + sprockets (> 3.0) + sprockets-rails + tilt + scenic (1.9.0) + activerecord (>= 4.0.0) + railties (>= 4.0.0) + securerandom (0.4.1) + sentry-rails (6.3.0) + railties (>= 5.2.0) + sentry-ruby (~> 6.3.0) + sentry-ruby (6.3.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) + sentry-sidekiq (6.3.0) + sentry-ruby (~> 6.3.0) + sidekiq (>= 5.0) + shellany (0.0.1) + shoulda-matchers (7.0.1) + activesupport (>= 7.1) + sidekiq (7.3.10) + base64 + connection_pool (>= 2.3.0, < 3) + logger + rack (>= 2.2.4, < 3.3) + redis-client (>= 0.23.0, < 1) + sidekiq-prometheus-exporter (0.3.0) + rack (>= 1.6.0) + sidekiq (>= 4.1.0) + sidekiq-throttled (1.4.0) + concurrent-ruby (>= 1.2.0) + redis-prescription (~> 2.2) + sidekiq (>= 6.5) + signet (0.21.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 4.0) + multi_json (~> 1.10) + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) + slim (5.2.1) + temple (~> 0.10.0) + tilt (>= 2.1.0) + slim-rails (4.0.0) + actionpack (>= 3.1) + railties (>= 3.1) + slim (>= 3.0, < 6.0, != 5.0.0) + snaky_hash (2.0.3) + hashie (>= 0.1.0, < 6) + version_gem (>= 1.1.8, < 3) + sprockets (4.2.2) + concurrent-ruby (~> 1.0) + logger + rack (>= 2.2.4, < 4) + sprockets-rails (3.5.2) + actionpack (>= 6.1) + activesupport (>= 6.1) + sprockets (>= 3.0.0) + stackprof (0.2.28) + standard (1.54.0) + language_server-protocol (~> 3.17.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.84.0) + standard-custom (~> 1.0.0) + standard-performance (~> 1.8) + standard-custom (1.0.2) + lint_roller (~> 1.0) + rubocop (~> 1.50) + standard-performance (1.9.0) + lint_roller (~> 1.1) + rubocop-performance (~> 1.26.0) + stringio (3.2.0) + stripe (6.5.0) + strong_migrations (2.5.2) + activerecord (>= 7.1) + super_diff (0.18.0) + attr_extras (>= 6.2.4) + diff-lcs + patience_diff + temple (0.10.4) + terminal-table (4.0.0) + unicode-display_width (>= 1.1.1, < 4) + thor (1.5.0) + throttling (0.4.1) + logger + tilt (2.7.0) + timecop (0.9.10) + timeout (0.6.1) + trailblazer-option (0.1.2) + tsort (0.2.0) + tzinfo (2.0.6) + concurrent-ruby (~> 1.0) + tzinfo-data (1.2025.3) + tzinfo (>= 1.0.0) + uber (0.1.0) + uglifier (4.2.1) + execjs (>= 0.3.0, < 3) + unaccent (0.4.0) + unicode-display_width (3.2.0) + unicode-emoji (~> 4.1) + unicode-emoji (4.2.0) + uniform_notifier (1.18.0) + uri (1.1.1) + useragent (0.16.11) + valvat (2.0.1) + rexml (>= 3.3.6, < 4.0.0) + vernier (1.9.0) + version_gem (1.1.9) + waterdrop (2.8.16) + karafka-core (>= 2.4.9, < 3.0.0) + karafka-rdkafka (>= 0.24.0) + zeitwerk (~> 2.3) + webmock (3.26.2) + addressable (>= 2.8.0) + crack (>= 0.3.2) + hashdiff (>= 0.4.0, < 2.0.0) + websocket-driver (0.8.0) + base64 + websocket-extensions (>= 0.1.0) + websocket-extensions (0.1.5) + with_advisory_lock (5.3.0) + activerecord (>= 6.1) + zeitwerk (>= 2.6) + yabeda (0.16.0) + anyway_config (>= 1.0, < 3) + concurrent-ruby + dry-initializer + yabeda-prometheus (0.9.1) + prometheus-client (>= 3.0, < 5.0) + rack + yabeda (~> 0.10) + yabeda-puma-plugin (0.9.0) + json + puma + yabeda (~> 0.5) + yabeda-rails (0.11.0) + activesupport + anyway_config (>= 1.3, < 3) + railties + yabeda (~> 0.8) + zeitwerk (2.7.5) + +PLATFORMS + aarch64-linux + aarch64-linux-musl + arm64-darwin-22 + arm64-darwin-23 + arm64-darwin-24 + arm64-darwin-25 + x64-mingw-ucrt + x86_64-darwin-21 + x86_64-darwin-22 + x86_64-darwin-23 + x86_64-linux + +DEPENDENCIES + aasm + active_storage_validations + activejob-uniqueness + addressing + adyen-ruby-api-library + after_commit_everywhere + analytics-ruby + annotaterb + awesome_print + aws-sdk-s3 + bcrypt + bigdecimal + bootsnap + bullet + clickhouse-activerecord (~> 1.6.1) + clockwork + clockwork-test + coffee-rails + connection_pool (< 3) + countries + csv (~> 3.0) + database_cleaner-active_record + datadog + debug + device_detector + discard (~> 1.2) + dogstatsd-ruby + dotenv + dry-validation + event_stream_parser + factory_bot_rails + faker + fuubar + gocardless_pro (~> 2.34) + google-cloud-storage + googleauth (~> 1.11.0) + graphiql-rails + graphql + graphql-pagination + guard-rspec + htmlbeautifier (~> 1.4) + httplog + i18n-tasks + jwt + kaminari-activerecord + karafka (~> 2.5.0) + karafka-testing + karafka-web (~> 0.11.3) + knapsack_pro (~> 9.0) + lago-expression! + lograge + logstash-event + money-rails + multipart-post + mutex_m + newrelic_rpm + oauth2 + opentelemetry-exporter-otlp + opentelemetry-instrumentation-all + opentelemetry-sdk + ostruct + paper_trail + parallel + parallel_tests (~> 5.3) + pg + pry-byebug + puma (~> 6.5) + rack-cors + rails (~> 8.0) + ransack + redis + redlock (~> 2.0.6) + rspec-graphql_matchers + rspec-rails + rspec-snapshot (~> 2.0) + rubocop-factory_bot + rubocop-graphql + rubocop-performance + rubocop-rails + rubocop-rspec + rubocop-rspec_rails + rubocop-thread_safety + ruby-lsp-rails + sass-rails + scenic + sentry-rails + sentry-ruby + sentry-sidekiq + shoulda-matchers + sidekiq + sidekiq-pro! + sidekiq-prometheus-exporter + sidekiq-throttled (= 1.4.0) + simplecov + slim + slim-rails + stackprof + standard + stripe + strong_migrations + super_diff (~> 0.18.0) + throttling + timecop + tzinfo-data + uglifier + valvat + vernier (~> 1.0) + webmock + with_advisory_lock + yabeda + yabeda-prometheus + yabeda-puma-plugin + yabeda-rails + +RUBY VERSION + ruby 4.0.2 + +BUNDLED WITH + 4.0.4 diff --git a/Guardfile b/Guardfile new file mode 100644 index 0000000..672841a --- /dev/null +++ b/Guardfile @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +guard :rspec, cmd: "bundle exec rspec" do + directories ["app", "db/seeds", "lib", "spec", "dev"] + + watch("spec/spec_helper.rb") { "spec" } + watch("config/routes.rb") { "spec/routing" } + watch("app/controllers/application_controller.rb") { "spec/requests" } + watch("app/services/integrations/aggregator/invoices/payloads/base_payload.rb") do + "spec/services/integrations/aggregator/invoices/payloads" + end + watch("app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb") do + "spec/services/integrations/aggregator/credit_notes/payloads" + end + watch("app/services/integrations/aggregator/contacts/payloads/base_payload.rb") do + "spec/services/integrations/aggregator/contacts/payloads" + end + watch("app/services/integrations/aggregator/payments/payloads/base_payload.rb") do + "spec/services/integrations/aggregator/payments/payloads" + end + + watch(%r{^app/services/(customers/refresh_wallets_service|wallets/balance/refresh_ongoing_usage_service)\.rb$}) do + [ + "spec/scenarios/wallets/balance_spec.rb", + "spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb", + "spec/scenarios/invoices/invoicing_with_prepaid_credits_spec.rb" + ] + end + + watch("app/services/integrations/aggregator/base_service.rb") { "spec/services/integrations/aggregator/" } + watch("app/services/base_service.rb") { "spec/services/" } + watch("app/jobs/application_job.rb") { "spec/jobs/" } + watch("app/models/application_record.rb") { "spec/models/" } + watch("app/mailers/application_mailer.rb") { "spec/mailers/" } + watch("app/serializers/model_serializer.rb") { "spec/serializers/" } + watch("app/graphql/lago_api_schema.rb") { "spec/graphql/" } + watch(%r{^spec/.+_spec\.rb$}) + watch(%r{^app/(.+)\.rb$}) { |m| "spec/#{m[1]}_spec.rb" } + watch(%r{^lib/(.+)\.rb$}) { |m| "spec/lib/#{m[1]}_spec.rb" } + watch(%r{^lib/tasks/(.+)\.rake$}) { |m| "spec/lib/tasks/#{m[1]}_rake_spec.rb" } + watch(%r{^app/controllers/(.+)_(controller)\.rb$}) do |m| + [ + "spec/#{m[2]}s/#{m[1]}_#{m[2]}_spec.rb", + "spec/requests/#{m[1]}_controller_spec.rb" + ] + end + + # Run schema check for any change in Graphql folder + watch(%r{^app/graphql/(.+)\.rb$}) { |m| "spec/graphql/lago_api_schema_spec.rb" } +end diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..be3f7b2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..cdda7da --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## Roadmap Task + +👉 https://getlago.canny.io/feature-requests/p/{{FEATURE_SLUG}} + +## Context + +Include relevant motivation and context. + +## Description + +Describe your changes in detail. + +List any dependencies that are required. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4349322 --- /dev/null +++ b/README.md @@ -0,0 +1,28 @@ +# Lago API + +Lago is an open-source Stripe Billing alternative. + +This library will allow you to build an entire billing logic from scratch, even the most complex one. Lago is a real-time event-based library made for usage-based billing, subscription-based billing, and all the nuances of pricing in between. + +## Current Releases + +| Project | Release Badge | +|--------------------|-----------------------------------------------------------------------------------------------------| +| **Lago** | [![Lago Release](https://img.shields.io/github/v/release/getlago/lago)](https://github.com/getlago/lago/releases) | +| **Lago API** | [![Lago API Release](https://img.shields.io/github/v/release/getlago/lago-api)](https://github.com/getlago/lago-api/releases) | + +## Documentation + +The official Lago documentation is available here : https://doc.getlago.com + +## Contributing + +The contribution documentation is available [here](https://github.com/getlago/lago-api/blob/main/CONTRIBUTING.md) + +## Development Environment + +Check the wiki [guide](https://github.com/getlago/lago-api/wiki) + +## License + +Lago is distributed under [AGPL-3.0](LICENSE). diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..2f4f9e9 --- /dev/null +++ b/Rakefile @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +# Add your own tasks in files placed in lib/tasks ending in .rake, +# for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. + +require_relative "config/application" +require "graphql/rake_task" + +Rails.application.load_tasks + +GraphQL::RakeTask.new(schema_name: "LagoApiSchema") diff --git a/app/assets/config/manifest.js b/app/assets/config/manifest.js new file mode 100644 index 0000000..aa11d2a --- /dev/null +++ b/app/assets/config/manifest.js @@ -0,0 +1,2 @@ +//= link graphiql/rails/application.css +//= link graphiql/rails/application.js diff --git a/app/channels/application_cable/channel.rb b/app/channels/application_cable/channel.rb new file mode 100644 index 0000000..9aec230 --- /dev/null +++ b/app/channels/application_cable/channel.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module ApplicationCable + class Channel < ActionCable::Channel::Base + end +end diff --git a/app/channels/application_cable/connection.rb b/app/channels/application_cable/connection.rb new file mode 100644 index 0000000..7dc76ec --- /dev/null +++ b/app/channels/application_cable/connection.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ApplicationCable + class Connection < ActionCable::Connection::Base + identified_by :connection_identifier + + def connect + self.connection_identifier = SecureRandom.uuid + end + end +end diff --git a/app/channels/graphql_channel.rb b/app/channels/graphql_channel.rb new file mode 100644 index 0000000..cfb0617 --- /dev/null +++ b/app/channels/graphql_channel.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class GraphqlChannel < ApplicationCable::Channel + def subscribed + end + + def execute(data) + query = data["query"] + variables = ensure_hash(data["variables"]) + operation_name = data["operationName"] + context = {channel: self} + + result = LagoApiSchema.execute(query, context:, variables:, operation_name:) + payload = {result: result.to_h} + + # Track the subscription here so we can remove it on unsubscribe. + if result.context[:subscription_id] + @subscription_ids ||= [] + @subscription_ids << result.context[:subscription_id] + end + + if result.context[:subscription_id] + transmit(payload.merge(more: true)) + else + transmit(payload.merge(more: false)) + end + end + + def unsubscribed + @subscription_ids.each { |id| LagoApiSchema.subscriptions.delete_subscription(id) } + end + + private + + def ensure_hash(ambiguous_param) + case ambiguous_param + when String + if ambiguous_param.present? + ensure_hash(JSON.parse(ambiguous_param)) + else + {} + end + when Hash, ActionController::Parameters + ambiguous_param + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{ambiguous_param}" + end + end +end diff --git a/app/config/feature_flags.yaml b/app/config/feature_flags.yaml new file mode 100644 index 0000000..dcb5346 --- /dev/null +++ b/app/config/feature_flags.yaml @@ -0,0 +1,35 @@ +multiple_payment_methods: + description: "Allow multiple payment methods per customer" + owners: ["@lovrocolic", "@mariohd"] + +non_persistable_charge_cache_optimization: + description: "Optimize current usage cache by not storing non-persistable fees (zero units/amounts/events)" + owners: ["@groyoh"] + +postgres_enriched_events: + description: "Turn on events pre-enrichement for organizations using the PG event store" + owners: ["@vincent-pochet"] + +enriched_events_aggregation: + description: "Use the enriched events store for aggregation queries in ClickHouse" + owners: ["@vincent-pochet"] + +wallet_traceability: + description: "Enable wallet traceability on newly created wallets" + owners: ["@groyoh", "@D1353L"] + +multi_currency: + description: "Allow customers to have billing objects in multiple currencies" + owners: ["@feliperodrigues", "@mariohd", "@lovrocolic"] + +payment_gated_subscriptions: + description: "Enable payment-gated subscription activation rules" + owners: ["@osmarluz", "@ancorcruz"] + +multi_entity_billing: + description: "Allow customers to have billing objects across multiple billing entities" + owners: ["@lovrocolic", "@feliperodrigues", "@mariohd"] + +order_forms: + description: "Enable quotes, order forms and orders" + owners: ["@murparreira", "@toommz", "@rinasergeeva"] diff --git a/app/consumers/application_consumer.rb b/app/consumers/application_consumer.rb new file mode 100644 index 0000000..2214b6c --- /dev/null +++ b/app/consumers/application_consumer.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Application consumer from which all Karafka consumers should inherit +# You can rename it if it would conflict with your current code base (in case you're integrating +# Karafka with other frameworks) +class ApplicationConsumer < Karafka::BaseConsumer +end diff --git a/app/consumers/events_charged_in_advance_consumer.rb b/app/consumers/events_charged_in_advance_consumer.rb new file mode 100644 index 0000000..2e937ce --- /dev/null +++ b/app/consumers/events_charged_in_advance_consumer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class EventsChargedInAdvanceConsumer < ApplicationConsumer + def consume + messages.each do |message| + Events::PayInAdvanceJob.set(wait: Events::Stores::ClickhouseStore::CLICKHOUSE_MERGE_DELAY).perform_later(message.payload) + end + end +end diff --git a/app/contracts/queries/billable_metrics_query_filters_contract.rb b/app/contracts/queries/billable_metrics_query_filters_contract.rb new file mode 100644 index 0000000..8bbe432 --- /dev/null +++ b/app/contracts/queries/billable_metrics_query_filters_contract.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Queries + class BillableMetricsQueryFiltersContract < Dry::Validation::Contract + params do + optional(:recurring).filled(:bool) + optional(:aggregation_types).array(:string, included_in?: %w[max_agg count_agg]) + end + end +end diff --git a/app/contracts/queries/customers_query_filters_contract.rb b/app/contracts/queries/customers_query_filters_contract.rb new file mode 100644 index 0000000..78f11af --- /dev/null +++ b/app/contracts/queries/customers_query_filters_contract.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Queries + class CustomersQueryFiltersContract < Dry::Validation::Contract + params do + optional(:account_type).array(:string, included_in?: Customer::ACCOUNT_TYPES.values) + optional(:billing_entity_ids).maybe { array(:string, format?: Regex::UUID) } + optional(:countries).array(:string, included_in?: ISO3166::Country.codes) + optional(:states).array(:string) + optional(:zipcodes).array(:string) + optional(:currencies).array(:string, included_in?: Customer.currency_list) + optional(:has_tax_identification_number).value(:"coercible.string", included_in?: %w[true false]) + optional(:metadata).value(:hash) + optional(:has_customer_type).value(:"coercible.string", included_in?: %w[true false]) + optional(:customer_type).value(:string, included_in?: Customer::CUSTOMER_TYPES.values) + end + + rule("metadata") do + if key? && value.is_a?(Hash) + value.each_with_index do |(k, v), index| + key(:metadata).failure("keys must be string") unless k.is_a?(String) + key([:metadata, k]).failure("must be a string") unless v.is_a?(String) + end + end + end + + rule("has_customer_type", "customer_type") do + if values["has_customer_type"] == "false" && values["customer_type"].present? + key(:customer_type).failure("must be nil when has_customer_type is false") + end + end + end +end diff --git a/app/contracts/queries/events_query_filters_contract.rb b/app/contracts/queries/events_query_filters_contract.rb new file mode 100644 index 0000000..396857c --- /dev/null +++ b/app/contracts/queries/events_query_filters_contract.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Queries + class EventsQueryFiltersContract < Dry::Validation::Contract + params do + optional(:code).maybe(:string) + optional(:external_subscription_id).maybe(:string) + optional(:timestamp_from_started_at).value(:"coercible.string", included_in?: %w[true false]) + optional(:timestamp_from) + optional(:timestamp_to) + optional(:enriched).value(:bool) + end + + rule("timestamp_from_started_at", "timestamp_from") do + if ActiveModel::Type::Boolean.new.cast(values["timestamp_from_started_at"]) && values["timestamp_from"].present? + key(:timestamp_from).failure("cannot be used with timestamp_from_started_at") + end + end + + rule("timestamp_from_started_at", "external_subscription_id") do + if ActiveModel::Type::Boolean.new.cast(values["timestamp_from_started_at"]) && values["external_subscription_id"].blank? + key(:external_subscription_id).failure("required with timestamp_from_started_at") + end + end + end +end diff --git a/app/contracts/queries/invoices_query_filters_contract.rb b/app/contracts/queries/invoices_query_filters_contract.rb new file mode 100644 index 0000000..0b6c71c --- /dev/null +++ b/app/contracts/queries/invoices_query_filters_contract.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Queries + class InvoicesQueryFiltersContract < Dry::Validation::Contract + params do + optional(:billing_entity_ids).maybe { array(:string, format?: Regex::UUID) } + + optional(:settlements).maybe do + value(:string, included_in?: InvoiceSettlement.settlement_types.keys) | + array(:string, included_in?: InvoiceSettlement.settlement_types.keys) + end + + optional(:payment_status).maybe do + value(:string, included_in?: Invoice.payment_statuses.keys) | + array(:string, included_in?: Invoice.payment_statuses.keys) + end + + optional(:status).maybe do + value(:string, included_in?: Invoice::VISIBLE_STATUS.keys.map(&:to_s)) | + array(:string, included_in?: Invoice::VISIBLE_STATUS.keys.map(&:to_s)) + end + + optional(:self_billed).maybe(:bool) + optional(:partially_paid).maybe(:bool) + optional(:payment_overdue).maybe(:bool) + end + end +end diff --git a/app/contracts/queries/payment_receipts_query_filters_contract.rb b/app/contracts/queries/payment_receipts_query_filters_contract.rb new file mode 100644 index 0000000..4abde7e --- /dev/null +++ b/app/contracts/queries/payment_receipts_query_filters_contract.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Queries + class PaymentReceiptsQueryFiltersContract < Dry::Validation::Contract + params do + optional(:invoice_id).maybe(:string, format?: Regex::UUID) + end + end +end diff --git a/app/contracts/queries/payments_query_filters_contract.rb b/app/contracts/queries/payments_query_filters_contract.rb new file mode 100644 index 0000000..6fa6f86 --- /dev/null +++ b/app/contracts/queries/payments_query_filters_contract.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Queries + class PaymentsQueryFiltersContract < Dry::Validation::Contract + params do + optional(:invoice_id).maybe(:string, format?: Regex::UUID) + optional(:external_customer_id).maybe(:string) + end + end +end diff --git a/app/contracts/queries/quotes_query_filters_contract.rb b/app/contracts/queries/quotes_query_filters_contract.rb new file mode 100644 index 0000000..2f0a973 --- /dev/null +++ b/app/contracts/queries/quotes_query_filters_contract.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Queries + class QuotesQueryFiltersContract < Dry::Validation::Contract + params do + optional(:customers).maybe do + array(:string, format?: Regex::UUID) + end + optional(:statuses).maybe do + array(:string, included_in?: QuoteVersion::STATUSES.values) + end + optional(:numbers).maybe do + array(:string, format?: Quote::QUOTE_NUMBER_REGEX) + end + optional(:from_date).maybe(:date) + optional(:to_date).maybe(:date) + optional(:owners).maybe do + array(:string, format?: Regex::UUID) + end + optional(:order_types).maybe do + array(:string, included_in?: Quote::ORDER_TYPES.values) + end + end + end +end diff --git a/app/contracts/queries/wallet_transaction_consumptions_query_filters_contract.rb b/app/contracts/queries/wallet_transaction_consumptions_query_filters_contract.rb new file mode 100644 index 0000000..6ee827d --- /dev/null +++ b/app/contracts/queries/wallet_transaction_consumptions_query_filters_contract.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Queries + class WalletTransactionConsumptionsQueryFiltersContract < Dry::Validation::Contract + params do + required(:wallet_transaction_id).filled(:string) + required(:direction).filled(:"coercible.string", included_in?: %w[consumptions fundings]) + end + end +end diff --git a/app/contracts/queries/webhooks_query_filters_contract.rb b/app/contracts/queries/webhooks_query_filters_contract.rb new file mode 100644 index 0000000..668f3d8 --- /dev/null +++ b/app/contracts/queries/webhooks_query_filters_contract.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Queries + class WebhooksQueryFiltersContract < Dry::Validation::Contract + # HTTP status filter accepts: + # * individual status codes (e.g., "200", "404") + # * wildcards (e.g., "5xx") + # * ranges (e.g., "200-204") + # * "timeout" word (special case for empty http statuses) + HTTP_STATUS_REGEX = /\A(\d{3}|\dxx|\d{3}\s*-\s*\d{3}|timeout)\z/i + + params do + required(:webhook_endpoint_id).filled(:string) + + optional(:statuses).maybe do + array(:string, included_in?: Webhook::STATUS.map(&:to_s)) + end + + optional(:event_types).maybe do + array(:string, included_in?: WebhookEndpoint::WEBHOOK_EVENT_TYPES) + end + + optional(:http_statuses).maybe(:array) do + each(:string, format?: HTTP_STATUS_REGEX) + end + + optional(:from_date).maybe(:time) + + optional(:to_date).maybe(:time) + end + end +end diff --git a/app/controllers/admin/base_controller.rb b/app/controllers/admin/base_controller.rb new file mode 100644 index 0000000..1484e0f --- /dev/null +++ b/app/controllers/admin/base_controller.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Admin + class BaseController < ApplicationController + include ApiErrors + + before_action :authenticate + before_action :set_context_source + + private + + def authenticate + auth_header = request.headers["Authorization"] + + if auth_header&.start_with?("Bearer ") + begin + token = auth_header.split(" ").second + payload = Google::Auth::IDTokens.verify_oidc( + token, + aud: ENV["GOOGLE_AUTH_CLIENT_ID"] + ) + + CurrentContext.email = payload["email"] + return true + rescue Google::Auth::IDTokens::SignatureError + return unauthorized_error + end + end + + # Fallback to X-Admin-API-Key header + key_header = request.headers["X-Admin-API-Key"] + expected_key = ENV["ADMIN_API_KEY"] + + if key_header.present? && expected_key.present? && ActiveSupport::SecurityUtils.secure_compare(key_header, expected_key) + CurrentContext.email = nil + return true + end + + unauthorized_error + end + + def set_context_source + CurrentContext.source = "admin" + CurrentContext.api_key_id = nil + end + end +end diff --git a/app/controllers/admin/invoices_controller.rb b/app/controllers/admin/invoices_controller.rb new file mode 100644 index 0000000..1834bca --- /dev/null +++ b/app/controllers/admin/invoices_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Admin + class InvoicesController < BaseController + def regenerate + result = ::Invoices::GeneratePdfService.call(invoice:, context: "admin") + + return render_error_response(result) unless result.success? + + head(:ok) + end + + def show + service = ::Invoices::GeneratePdfService.new(invoice:) + + render(html: service.render_html.html_safe) # rubocop:disable Rails/OutputSafety + end + + private + + def invoice + @invoice ||= Invoice.find(params[:id]) + end + end +end diff --git a/app/controllers/admin/memberships_controller.rb b/app/controllers/admin/memberships_controller.rb new file mode 100644 index 0000000..7d206e9 --- /dev/null +++ b/app/controllers/admin/memberships_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Admin + class MembershipsController < BaseController + def create + result = ::Memberships::CreateService.call(user:, organization:) + + return render_error_response(result) unless result.success? + + render( + json: ::V1::MembershipSerializer.new( + result.membership, + root_name: "membership" + ) + ) + end + + private + + def user + @user ||= User.find_by(id: params[:user_id]) + end + + def organization + @organization ||= Organization.find_by(id: params[:organization_id]) + end + end +end diff --git a/app/controllers/admin/organizations_controller.rb b/app/controllers/admin/organizations_controller.rb new file mode 100644 index 0000000..a1bda6c --- /dev/null +++ b/app/controllers/admin/organizations_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Admin + class OrganizationsController < BaseController + def update + result = Admin::Organizations::UpdateService.call( + organization:, + params: update_params + ) + + return render_error_response(result) unless result.success? + + render( + json: ::Admin::OrganizationSerializer.new( + result.organization, + root_name: "organization" + ) + ) + end + + def create + result = ::Organizations::CreateService + .call( + name: create_params[:name], + document_numbering: "per_customer", + premium_integrations: create_params[:premium_integrations] + ) + + return render_error_response(result) unless result.success? + + organization = result.organization + + invite_result = ::Invites::CreateService.call( + current_organization: organization, + email: create_params[:email], + roles: %w[admin], + skip_admin_check: true + ) + + return render_error_response(invite_result) unless invite_result.success? + + render json: { + organization: ::Admin::OrganizationSerializer.new(organization).serialize, + invite_url: invite_result.invite_url + }, status: :created + end + + private + + def organization + @organization ||= Organization.find_by(id: params[:id]) + end + + def update_params + params.permit(:name, premium_integrations: []) + end + + def create_params + params.permit(:name, :email, premium_integrations: []) + end + end +end diff --git a/app/controllers/api/base_controller.rb b/app/controllers/api/base_controller.rb new file mode 100644 index 0000000..cf58571 --- /dev/null +++ b/app/controllers/api/base_controller.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Api + class BaseController < ApplicationController + include Pagination + include Common + include ApiErrors + + before_action :authenticate + before_action :set_context_source + before_action :track_api_key_usage + before_action :authorize + include Trackable + include ApiLoggable + + rescue_from ActionController::ParameterMissing, with: :bad_request_error + + private + + attr_reader :current_api_key, :current_organization + + def ensure_organization_uses_clickhouse + forbidden_error(code: "endpoint_not_available") unless current_organization.clickhouse_events_store? + end + + def authenticate + return unauthorized_error unless auth_token + + @current_api_key, organization = ApiKeys::CacheService.call(auth_token, with_cache: cached_api_key?) + return unauthorized_error unless current_api_key + + @current_organization = organization + true + end + + def auth_token + request.headers["Authorization"]&.split(" ")&.second + end + + def set_context_source + CurrentContext.source = "api" + CurrentContext.api_key_id = current_api_key.id + end + + def set_beta_header! + response.set_header("X-Lago-Endpoint-Status", "beta") + end + + def track_api_key_usage + return unless track_api_key_usage? + + Rails.cache.write( + "api_key_last_used_#{current_api_key.id}", + Time.current.iso8601 + ) + end + + def track_api_key_usage? + true + end + + def authorize + return if current_api_key.permit?(resource_name, mode) + + forbidden_error(code: "#{mode}_action_not_allowed_for_#{resource_name}") + end + + def resource_name + nil + end + + def mode + (request.method == "GET") ? "read" : "write" + end + + def cached_api_key? + false + end + end +end diff --git a/app/controllers/api/v1/activity_logs_controller.rb b/app/controllers/api/v1/activity_logs_controller.rb new file mode 100644 index 0000000..3858343 --- /dev/null +++ b/app/controllers/api/v1/activity_logs_controller.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Api + module V1 + class ActivityLogsController < Api::BaseController + include PremiumFeatureOnly + + skip_audit_logs! + + def index + result = ActivityLogsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.activity_logs, + ::V1::ActivityLogSerializer, + collection_name: "activity_logs", + meta: pagination_metadata(result.activity_logs) + ) + ) + else + render_error_response(result) + end + end + + def show + activity_log = current_organization.activity_logs.find_by( + activity_id: params[:activity_id] + ) + + return not_found_error(resource: "activity_log") unless activity_log + + render( + json: ::V1::ActivityLogSerializer.new( + activity_log, + root_name: "activity_log" + ) + ) + end + + private + + def resource_name + "activity_log" + end + + def index_filters + { + from_date: params[:from_date], + to_date: params[:to_date], + activity_types: params[:activity_types], + activity_sources: params[:activity_sources], + user_emails: params[:user_emails], + external_customer_id: params[:external_customer_id], + external_subscription_id: params[:external_subscription_id], + resource_ids: params[:resource_ids], + resource_types: params[:resource_types] + } + end + end + end +end diff --git a/app/controllers/api/v1/add_ons_controller.rb b/app/controllers/api/v1/add_ons_controller.rb new file mode 100644 index 0000000..0bac58c --- /dev/null +++ b/app/controllers/api/v1/add_ons_controller.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module Api + module V1 + class AddOnsController < Api::BaseController + def create + result = AddOns::CreateService.call( + input_params + .merge(organization_id: current_organization.id) + .to_h + .symbolize_keys + ) + + if result.success? + render_add_on(result.add_on) + else + render_error_response(result) + end + end + + def update + add_on = current_organization.add_ons.find_by(code: params[:code]) + result = AddOns::UpdateService.call(add_on:, params: input_params) + + if result.success? + render_add_on(result.add_on) + else + render_error_response(result) + end + end + + def destroy + add_on = current_organization.add_ons.find_by(code: params[:code]) + result = AddOns::DestroyService.call(add_on:) + + if result.success? + render_add_on(result.add_on) + else + render_error_response(result) + end + end + + def show + add_on = current_organization.add_ons.find_by( + code: params[:code] + ) + + return not_found_error(resource: "add_on") unless add_on + + render_add_on(add_on) + end + + def index + result = AddOnsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.add_ons.includes(:taxes), + ::V1::AddOnSerializer, + collection_name: "add_ons", + meta: pagination_metadata(result.add_ons), + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + private + + def input_params + params.require(:add_on).permit( + :name, + :invoice_display_name, + :code, + :amount_cents, + :amount_currency, + :description, + tax_codes: [] + ) + end + + def render_add_on(add_on) + render( + json: ::V1::AddOnSerializer.new( + add_on, + root_name: "add_on", + includes: %i[taxes] + ) + ) + end + + def resource_name + "add_on" + end + end + end +end diff --git a/app/controllers/api/v1/analytics/base_controller.rb b/app/controllers/api/v1/analytics/base_controller.rb new file mode 100644 index 0000000..a4dd9e9 --- /dev/null +++ b/app/controllers/api/v1/analytics/base_controller.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Api + module V1 + module Analytics + class BaseController < Api::BaseController + def index + if @result.success? + render_result(@result) + else + render_error_response(@result) + end + end + + private + + def render_result(result) + render( + json: ::CollectionSerializer.new( + result.records, + "::V1::Analytics::#{controller_name.classify}Serializer".constantize, + collection_name: controller_name + ) + ) + end + + def resource_name + "analytic" + end + + def billing_entity + @billing_entity ||= BillingEntity.find_by(organization_id: current_organization.id, code: params[:billing_entity_code]) + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/gross_revenues_controller.rb b/app/controllers/api/v1/analytics/gross_revenues_controller.rb new file mode 100644 index 0000000..cf02aa2 --- /dev/null +++ b/app/controllers/api/v1/analytics/gross_revenues_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + module V1 + module Analytics + class GrossRevenuesController < BaseController + def index + @result = ::Analytics::GrossRevenuesService.call(current_organization, **filters) + + super + end + + private + + def filters + { + external_customer_id: params[:external_customer_id], + currency: params[:currency]&.upcase, + months: params[:months], + billing_entity_id: billing_entity&.id + } + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/invoice_collections_controller.rb b/app/controllers/api/v1/analytics/invoice_collections_controller.rb new file mode 100644 index 0000000..da7330c --- /dev/null +++ b/app/controllers/api/v1/analytics/invoice_collections_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V1 + module Analytics + class InvoiceCollectionsController < BaseController + def index + @result = ::Analytics::InvoiceCollectionsService.call(current_organization, **filters) + + super + end + + private + + def filters + { + currency: params[:currency]&.upcase, + months: params[:months], + billing_entity_id: billing_entity&.id + } + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/invoiced_usages_controller.rb b/app/controllers/api/v1/analytics/invoiced_usages_controller.rb new file mode 100644 index 0000000..aca3d7f --- /dev/null +++ b/app/controllers/api/v1/analytics/invoiced_usages_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V1 + module Analytics + class InvoicedUsagesController < BaseController + def index + @result = ::Analytics::InvoicedUsagesService.call(current_organization, **filters) + + super + end + + private + + def filters + { + currency: params[:currency]&.upcase, + months: params[:months], + billing_entity_id: billing_entity&.id + } + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/mrrs_controller.rb b/app/controllers/api/v1/analytics/mrrs_controller.rb new file mode 100644 index 0000000..5edfd50 --- /dev/null +++ b/app/controllers/api/v1/analytics/mrrs_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V1 + module Analytics + class MrrsController < BaseController + def index + @result = ::Analytics::MrrsService.call(current_organization, **filters) + + super + end + + private + + def filters + { + currency: params[:currency]&.upcase, + months: params[:months], + billing_entity_id: billing_entity&.id + } + end + end + end + end +end diff --git a/app/controllers/api/v1/analytics/overdue_balances_controller.rb b/app/controllers/api/v1/analytics/overdue_balances_controller.rb new file mode 100644 index 0000000..bd8b980 --- /dev/null +++ b/app/controllers/api/v1/analytics/overdue_balances_controller.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Api + module V1 + module Analytics + class OverdueBalancesController < BaseController + def index + @result = ::Analytics::OverdueBalancesService.call(current_organization, **filters) + + super + end + + private + + def filters + { + external_customer_id: params[:external_customer_id], + currency: params[:currency]&.upcase, + months: params[:months], + billing_entity_id: billing_entity&.id + } + end + end + end + end +end diff --git a/app/controllers/api/v1/api_logs_controller.rb b/app/controllers/api/v1/api_logs_controller.rb new file mode 100644 index 0000000..140a03e --- /dev/null +++ b/app/controllers/api/v1/api_logs_controller.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Api + module V1 + class ApiLogsController < Api::BaseController + include PremiumFeatureOnly + + skip_audit_logs! + + def index + result = ApiLogsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.api_logs, + ::V1::ApiLogSerializer, + collection_name: "api_logs", + meta: pagination_metadata(result.api_logs) + ) + ) + else + render_error_response(result) + end + end + + def show + api_log = current_organization.api_logs.find_by( + request_id: params[:request_id] + ) + + return not_found_error(resource: "api_log") unless api_log + + render( + json: ::V1::ApiLogSerializer.new( + api_log, + root_name: "api_log" + ) + ) + end + + private + + def resource_name + "api_log" + end + + def index_filters + { + from_date: params[:from_date], + to_date: params[:to_date], + http_methods: params[:http_methods], + http_statuses: params[:http_statuses], + api_version: params[:api_version], + request_paths: params[:request_paths], + clients: params[:clients] + } + end + end + end +end diff --git a/app/controllers/api/v1/applied_coupons_controller.rb b/app/controllers/api/v1/applied_coupons_controller.rb new file mode 100644 index 0000000..221be6f --- /dev/null +++ b/app/controllers/api/v1/applied_coupons_controller.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Api + module V1 + class AppliedCouponsController < Api::BaseController + include AppliedCouponIndex + + def create + customer = Customer.find_by( + external_id: create_params[:external_customer_id], + organization_id: current_organization.id + ) + + coupon = Coupon.find_by( + code: create_params[:coupon_code], + organization_id: current_organization.id + ) + + result = AppliedCoupons::CreateService.call(customer:, coupon:, params: create_params) + + if result.success? + render( + json: ::V1::AppliedCouponSerializer.new( + result.applied_coupon, + root_name: "applied_coupon" + ) + ) + else + render_error_response(result) + end + end + + def index + external_customer_id = params.permit(:external_customer_id).fetch(:external_customer_id, nil) + applied_coupon_index(external_customer_id: external_customer_id) + end + + private + + def create_params + params.require(:applied_coupon).permit( + :external_customer_id, + :coupon_code, + :frequency, + :frequency_duration, + :amount_cents, + :amount_currency, + :percentage_rate + ) + end + + def resource_name + "applied_coupon" + end + end + end +end diff --git a/app/controllers/api/v1/billable_metrics_controller.rb b/app/controllers/api/v1/billable_metrics_controller.rb new file mode 100644 index 0000000..d4fba78 --- /dev/null +++ b/app/controllers/api/v1/billable_metrics_controller.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module Api + module V1 + class BillableMetricsController < Api::BaseController + def create + result = ::BillableMetrics::CreateService.call( + input_params.merge(organization_id: current_organization.id).to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::BillableMetricSerializer.new( + result.billable_metric, + root_name: "billable_metric", + includes: %i[counters] + ) + ) + else + render_error_response(result) + end + end + + def update + billable_metric = BillableMetric.find_by( + code: params[:code], + organization_id: current_organization.id + ) + + result = ::BillableMetrics::UpdateService.call( + billable_metric:, + params: input_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::BillableMetricSerializer.new( + result.billable_metric, + root_name: "billable_metric", + includes: %i[counters] + ) + ) + else + render_error_response(result) + end + end + + def destroy + result = ::BillableMetrics::DestroyService.call( + metric: current_organization.billable_metrics.find_by(code: params[:code]) + ) + + if result.success? + render( + json: ::V1::BillableMetricSerializer.new( + result.billable_metric, + root_name: "billable_metric", + includes: %i[counters] + ) + ) + else + render_error_response(result) + end + end + + def show + metric = current_organization.billable_metrics.find_by( + code: params[:code] + ) + + return not_found_error(resource: "billable_metric") unless metric + + render( + json: ::V1::BillableMetricSerializer.new( + metric, + root_name: "billable_metric" + ) + ) + end + + def index + result = BillableMetricsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.billable_metrics.includes(:filters), + ::V1::BillableMetricSerializer, + collection_name: "billable_metrics", + meta: pagination_metadata(result.billable_metrics), + includes: %i[counters] # DEPRECATED since 2024-11-22 + ) + ) + else + render_error_response(result) + end + end + + def evaluate_expression + result = ::BillableMetrics::EvaluateExpressionService.call( + expression: params[:expression], + event: expression_event_params[:event] + ) + + if result.success? + render( + json: ::V1::BillableMetricExpressionResultSerializer.new( + result, + root_name: "expression_result" + ) + ) + else + render_error_response(result) + end + end + + private + + def input_params + params.expect(billable_metric: [ + :name, + :code, + :description, + :aggregation_type, + :weighted_interval, + :recurring, + :field_name, + :expression, + :rounding_function, + :rounding_precision, + filters: [[:key, values: []]] + ]) + end + + def expression_event_params + params.permit(event: [ + :code, + :timestamp, + properties: {} + ]) + end + + def resource_name + "billable_metric" + end + end + end +end diff --git a/app/controllers/api/v1/billing_entities_controller.rb b/app/controllers/api/v1/billing_entities_controller.rb new file mode 100644 index 0000000..5d49cd8 --- /dev/null +++ b/app/controllers/api/v1/billing_entities_controller.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module Api + module V1 + class BillingEntitiesController < Api::BaseController + def index + render( + json: ::CollectionSerializer.new( + current_organization.billing_entities, + ::V1::BillingEntitySerializer, + collection_name: "billing_entities" + ) + ) + end + + def show + entity = BillingEntity.find_by(code: params[:code], organization: current_organization) + + return not_found_error(resource: "billing_entity") if entity.blank? + + render( + json: ::V1::BillingEntitySerializer.new( + entity, + root_name: "billing_entity", + includes: [:taxes, :selected_invoice_custom_sections] + ) + ) + end + + def create + result = BillingEntities::CreateService.new( + organization: current_organization, + params: create_params + ).call + + if result.success? + render( + json: ::V1::BillingEntitySerializer.new( + result.billing_entity, + root_name: "billing_entity" + ) + ) + else + render_error_response(result) + end + end + + def update + entity = BillingEntity.find_by(code: params[:code], organization: current_organization) + return not_found_error(resource: "billing_entity") if entity.blank? + + result = BillingEntities::UpdateService.call(billing_entity: entity, params: update_params) + + if result.success? + render( + json: ::V1::BillingEntitySerializer.new( + result.billing_entity, + root_name: "billing_entity", + includes: [:taxes, :selected_invoice_custom_sections] + ) + ) + else + render_error_response(result) + end + end + + private + + def create_params + params.require(:billing_entity).permit( + :code, + :name, + :einvoicing, + :email, + :legal_name, + :legal_number, + :tax_identification_number, + :address_line1, + :address_line2, + :city, + :state, + :zipcode, + :country, + :default_currency, + :timezone, + :document_numbering, + :document_number_prefix, + :finalize_zero_amount_invoice, + :net_payment_term, + :eu_tax_management, + :logo, + email_settings: [], + billing_configuration: [ + :invoice_footer, + :invoice_grace_period, + :subscription_invoice_issuing_date_anchor, + :subscription_invoice_issuing_date_adjustment, + :document_locale + ] + ) + end + + def update_params + params.require(:billing_entity).permit( + :name, + :einvoicing, + :email, + :legal_name, + :legal_number, + :tax_identification_number, + :address_line1, + :address_line2, + :city, + :state, + :zipcode, + :country, + :default_currency, + :timezone, + :document_numbering, + :document_number_prefix, + :finalize_zero_amount_invoice, + :net_payment_term, + :eu_tax_management, + :logo, + email_settings: [], + billing_configuration: [ + :invoice_footer, + :invoice_grace_period, + :subscription_invoice_issuing_date_anchor, + :subscription_invoice_issuing_date_adjustment, + :document_locale + ], + tax_codes: [], + invoice_custom_section_codes: [] + ) + end + + def resource_name + "billing_entity" + end + end + end +end diff --git a/app/controllers/api/v1/coupons_controller.rb b/app/controllers/api/v1/coupons_controller.rb new file mode 100644 index 0000000..3ded40b --- /dev/null +++ b/app/controllers/api/v1/coupons_controller.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +module Api + module V1 + class CouponsController < Api::BaseController + def create + result = Coupons::CreateService.call( + input_params.merge(organization_id: current_organization.id).to_h + ) + + if result.success? + render_coupon(result.coupon) + else + render_error_response(result) + end + end + + def update + coupon = current_organization.coupons.find_by(code: params[:code]) + + result = Coupons::UpdateService.call( + coupon:, + params: input_params.to_h + ) + + if result.success? + render_coupon(result.coupon) + else + render_error_response(result) + end + end + + def destroy + coupon = current_organization.coupons.find_by(code: params[:code]) + result = Coupons::DestroyService.call(coupon:) + + if result.success? + render_coupon(result.coupon) + else + render_error_response(result) + end + end + + def show + coupon = current_organization.coupons.find_by( + code: params[:code] + ) + + return not_found_error(resource: "coupon") unless coupon + + render_coupon(coupon) + end + + def index + result = CouponsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.coupons, + ::V1::CouponSerializer, + collection_name: "coupons", + meta: pagination_metadata(result.coupons) + ) + ) + else + render_error_response(result) + end + end + + private + + def input_params + params.require(:coupon).permit( + :name, + :code, + :description, + :coupon_type, + :amount_cents, + :amount_currency, + :percentage_rate, + :frequency, + :frequency_duration, + :expiration, + :expiration_at, + :reusable, + applies_to: [ + plan_codes: [], + billable_metric_codes: [] + ] + ) + end + + def render_coupon(coupon) + render( + json: ::V1::CouponSerializer.new( + coupon, + root_name: "coupon" + ) + ) + end + + def resource_name + "coupon" + end + end + end +end diff --git a/app/controllers/api/v1/credit_notes/base_controller.rb b/app/controllers/api/v1/credit_notes/base_controller.rb new file mode 100644 index 0000000..3f9eac0 --- /dev/null +++ b/app/controllers/api/v1/credit_notes/base_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V1 + module CreditNotes + class BaseController < Api::BaseController + before_action :find_credit_note + + private + + attr_reader :credit_note + + def find_credit_note + @credit_note = current_organization.credit_notes.finalized.find_by!(id: params[:credit_note_id]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "credit_note") + end + + def resource_name + "credit_note" + end + end + end + end +end diff --git a/app/controllers/api/v1/credit_notes/metadata_controller.rb b/app/controllers/api/v1/credit_notes/metadata_controller.rb new file mode 100644 index 0000000..6a2720a --- /dev/null +++ b/app/controllers/api/v1/credit_notes/metadata_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Api + module V1 + module CreditNotes + class MetadataController < BaseController + def create + result = ::CreditNotes::UpdateService.call(credit_note:, metadata:) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def update + result = ::CreditNotes::UpdateService.call(credit_note:, partial_metadata: true, metadata:) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def destroy + result = ::CreditNotes::UpdateService.call(credit_note:, metadata: nil) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def destroy_key + return not_found_error(resource: "metadata") unless credit_note.metadata + + result = Metadata::DeleteItemKeyService.call(item: credit_note.metadata, key: params[:key]) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + private + + def metadata + params.fetch(:metadata, {}).permit!.to_h + end + + def render_metadata + render(json: {metadata: credit_note.reload.metadata&.value}) + end + end + end + end +end diff --git a/app/controllers/api/v1/credit_notes_controller.rb b/app/controllers/api/v1/credit_notes_controller.rb new file mode 100644 index 0000000..268d05d --- /dev/null +++ b/app/controllers/api/v1/credit_notes_controller.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +module Api + module V1 + class CreditNotesController < Api::BaseController + include CreditNoteIndex + + def create + result = ::CreditNotes::CreateService.call( + invoice: current_organization.invoices.visible.find_by(id: input_params[:invoice_id]), + **input_params + ) + + if result.success? + render( + json: ::V1::CreditNoteSerializer.new( + result.credit_note, + root_name: "credit_note", + includes: include_in_serializer + ) + ) + else + render_error_response(result) + end + end + + def show + credit_note = current_organization.credit_notes.finalized.find_by(id: params[:id]) + return not_found_error(resource: "credit_note") unless credit_note + + render( + json: ::V1::CreditNoteSerializer.new( + credit_note, + root_name: "credit_note", + includes: include_in_serializer + ) + ) + end + + def update + credit_note = current_organization.credit_notes.find_by(id: params[:id]) + return not_found_error(resource: "credit_note") unless credit_note + + result = ::CreditNotes::UpdateService.new(credit_note:, partial_metadata: true, **update_params).call + + if result.success? + render( + json: ::V1::CreditNoteSerializer.new( + result.credit_note, + root_name: "credit_note", + includes: include_in_serializer + ) + ) + else + render_error_response(result) + end + end + + def download_pdf + credit_note = current_organization.credit_notes.finalized.find_by(id: params[:id]) + return not_found_error(resource: "credit_note") unless credit_note + + if credit_note.file.present? + return render( + json: ::V1::CreditNoteSerializer.new( + credit_note, + root_name: "credit_note" + ) + ) + end + + ::CreditNotes::GeneratePdfJob.perform_later(credit_note) + + head(:ok) + end + + def download_xml + credit_note = current_organization.credit_notes.finalized.find_by(id: params[:id]) + return not_found_error(resource: "credit_note") unless credit_note + + if credit_note.file.present? + return render( + json: ::V1::CreditNoteSerializer.new( + credit_note, + root_name: "credit_note" + ) + ) + end + + ::CreditNotes::GenerateXmlJob.perform_later(credit_note) + + head(:ok) + end + + def void + credit_note = current_organization.credit_notes.find_by(id: params[:id]) + return not_found_error(resource: "credit_note") unless credit_note + + result = ::CreditNotes::VoidService.new(credit_note:).call + + if result.success? + render( + json: ::V1::CreditNoteSerializer.new( + credit_note, + root_name: "credit_note", + includes: include_in_serializer + ) + ) + else + render_error_response(result) + end + end + + def index + permitted_params = params.permit(:external_customer_id) + external_customer_id = permitted_params[:external_customer_id] + + credit_note_index(external_customer_id:) + end + + def resend_email + credit_note = current_organization.credit_notes.finalized.find_by(id: params[:id]) + return not_found_error(resource: "credit_note") unless credit_note + + result = Emails::ResendService.call( + resource: credit_note, + to: resend_email_params[:to], + cc: resend_email_params[:cc], + bcc: resend_email_params[:bcc] + ) + + if result.success? + head(:ok) + else + render_error_response(result) + end + end + + def estimate + result = ::CreditNotes::EstimateService.call( + invoice: current_organization.invoices.visible.find_by(id: estimate_params[:invoice_id]), + items: estimate_params[:items] + ) + + if result.success? + render( + json: ::V1::CreditNotes::EstimateSerializer.new( + result.credit_note, + root_name: "estimated_credit_note" + ) + ) + else + render_error_response(result) + end + end + + private + + def include_in_serializer + [:items, :applied_taxes, :error_details, {customer: [:integration_customers]}] + end + + def input_params + @input_params ||= params.require(:credit_note) + .permit( + :invoice_id, + :reason, + :description, + :credit_amount_cents, + :refund_amount_cents, + :offset_amount_cents, + metadata: {}, + items: [ + :fee_id, + :amount_cents + ] + ) + end + + def update_params + params.require(:credit_note).permit(:refund_status, metadata: {}) + end + + def estimate_params + @estimate_params ||= params.require(:credit_note) + .permit( + :invoice_id, + items: [ + :fee_id, + :amount_cents + ] + ) + end + + def resend_email_params + params.permit(to: [], cc: [], bcc: []) + end + + def resource_name + "credit_note" + end + end + end +end diff --git a/app/controllers/api/v1/customers/applied_coupons_controller.rb b/app/controllers/api/v1/customers/applied_coupons_controller.rb new file mode 100644 index 0000000..e3b365a --- /dev/null +++ b/app/controllers/api/v1/customers/applied_coupons_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class AppliedCouponsController < BaseController + include AppliedCouponIndex + + def index + applied_coupon_index(external_customer_id: customer.external_id) + end + + def destroy + applied_coupon = customer.applied_coupons.find_by(id: params[:id]) + return not_found_error(resource: "applied_coupon") unless applied_coupon + + result = ::AppliedCoupons::TerminateService.call(applied_coupon:) + if result.success? + render(json: ::V1::AppliedCouponSerializer.new(result.applied_coupon, root_name: "applied_coupon")) + else + render_error_response(result) + end + end + + private + + def resource_name + "applied_coupon" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/base_controller.rb b/app/controllers/api/v1/customers/base_controller.rb new file mode 100644 index 0000000..818bf54 --- /dev/null +++ b/app/controllers/api/v1/customers/base_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class BaseController < Api::BaseController + before_action :find_customer + + private + + attr_reader :customer + + def find_customer + @customer = current_organization.customers.find_by!(external_id: params[:customer_external_id]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/credit_notes_controller.rb b/app/controllers/api/v1/customers/credit_notes_controller.rb new file mode 100644 index 0000000..fba4325 --- /dev/null +++ b/app/controllers/api/v1/customers/credit_notes_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class CreditNotesController < BaseController + include CreditNoteIndex + + def index + credit_note_index(external_customer_id: customer.external_id) + end + + private + + def resource_name + "credit_note" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/invoices_controller.rb b/app/controllers/api/v1/customers/invoices_controller.rb new file mode 100644 index 0000000..332b398 --- /dev/null +++ b/app/controllers/api/v1/customers/invoices_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class InvoicesController < BaseController + include InvoiceIndex + + def index + invoice_index(customer_external_id: customer.external_id) + end + + private + + def resource_name + "invoice" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/payment_methods_controller.rb b/app/controllers/api/v1/customers/payment_methods_controller.rb new file mode 100644 index 0000000..a65248b --- /dev/null +++ b/app/controllers/api/v1/customers/payment_methods_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class PaymentMethodsController < BaseController + include PaymentMethodIndex + + def index + payment_method_index(external_customer_id: customer.external_id) + end + + def destroy + result = ::PaymentMethods::DestroyService.call( + payment_method: customer.payment_methods.find_by(id: params[:id]) + ) + + if result.success? + render_payment_method(result.payment_method) + else + render_error_response(result) + end + end + + def set_as_default + payment_method = customer.payment_methods.find_by(id: params[:id]) + return not_found_error(resource: "payment_method") unless payment_method + + result = ::PaymentMethods::SetAsDefaultService.call(payment_method:) + if result.success? + render_payment_method(result.payment_method) + else + render_error_response(result) + end + end + + private + + def resource_name + "payment_method" + end + + def render_payment_method(payment_method) + render( + json: ::V1::PaymentMethodSerializer.new( + payment_method, + root_name: "payment_method" + ) + ) + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/payment_requests_controller.rb b/app/controllers/api/v1/customers/payment_requests_controller.rb new file mode 100644 index 0000000..ae9abd1 --- /dev/null +++ b/app/controllers/api/v1/customers/payment_requests_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class PaymentRequestsController < BaseController + include PaymentRequestIndex + + def index + payment_request_index(external_customer_id: customer.external_id) + end + + private + + def resource_name + "payment_request" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/payments_controller.rb b/app/controllers/api/v1/customers/payments_controller.rb new file mode 100644 index 0000000..0bbb1f0 --- /dev/null +++ b/app/controllers/api/v1/customers/payments_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class PaymentsController < BaseController + include PaymentIndex + + def index + payment_index(customer_external_id: customer.external_id) + end + + private + + def resource_name + "payment" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/projected_usage_controller.rb b/app/controllers/api/v1/customers/projected_usage_controller.rb new file mode 100644 index 0000000..52fcc13 --- /dev/null +++ b/app/controllers/api/v1/customers/projected_usage_controller.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class ProjectedUsageController < Api::BaseController + def current + apply_taxes = ActiveModel::Type::Boolean.new.cast(params.fetch(:apply_taxes, true)) + result = ::Invoices::CustomerUsageService + .with_external_ids( + customer_external_id: params[:customer_external_id], + external_subscription_id: params[:external_subscription_id], + organization_id: current_organization.id, + apply_taxes:, + calculate_projected_usage: true + ).call + + if result.success? + render( + json: ::V1::Customers::ProjectedUsageSerializer.new( + result.usage, + root_name: "customer_projected_usage" + ) + ) + else + render_error_response(result) + end + end + + private + + def resource_name + "customer_usage" + end + + def authorize + super + + return if current_organization.projected_usage_enabled? + + forbidden_error(code: "projected_usage_not_enabled") + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/subscriptions_controller.rb b/app/controllers/api/v1/customers/subscriptions_controller.rb new file mode 100644 index 0000000..85a23fa --- /dev/null +++ b/app/controllers/api/v1/customers/subscriptions_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class SubscriptionsController < BaseController + include SubscriptionIndex + + def index + subscription_index(external_customer_id: customer.external_id) + end + + private + + def resource_name + "subscription" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/usage_controller.rb b/app/controllers/api/v1/customers/usage_controller.rb new file mode 100644 index 0000000..ac4958c --- /dev/null +++ b/app/controllers/api/v1/customers/usage_controller.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class UsageController < Api::BaseController + def current + apply_taxes = ActiveModel::Type::Boolean.new.cast(params.fetch(:apply_taxes, true)) + usage_filters = UsageFilters.init_from_params(params) + result = ::Invoices::CustomerUsageService + .with_external_ids( + customer_external_id: params[:customer_external_id], + external_subscription_id: params[:external_subscription_id], + organization_id: current_organization.id, + apply_taxes:, + usage_filters: + ).call + + if result.success? + render( + json: ::V1::Customers::UsageSerializer.new( + result.usage, + root_name: "customer_usage", + includes: %i[charges_usage] + ) + ) + else + render_error_response(result) + end + end + + def past + result = PastUsageQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: past_usage_filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.usage_periods, + ::V1::Customers::PastUsageSerializer, + collection_name: "usage_periods", + meta: pagination_metadata(result), + includes: %i[charges_usage] + ) + ) + else + render_error_response(result) + end + end + + private + + def past_usage_filters + params.permit( + :external_subscription_id, + :billable_metric_code, + :periods_count + ).merge( + external_customer_id: params[:customer_external_id] + ) + end + + def resource_name + "customer_usage" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/wallets/alerts_controller.rb b/app/controllers/api/v1/customers/wallets/alerts_controller.rb new file mode 100644 index 0000000..1484d3f --- /dev/null +++ b/app/controllers/api/v1/customers/wallets/alerts_controller.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + module Wallets + class AlertsController < BaseController + before_action :find_alert, only: %i[show update destroy] + + def index + result = UsageMonitoring::AlertsQuery.call( + organization: current_organization, + filters: { + wallet_id: wallet.id + }, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.alerts.includes(:thresholds, :wallet), + ::V1::UsageMonitoring::AlertSerializer, + collection_name: "alerts", + meta: pagination_metadata(result.alerts), + includes: %i[thresholds] + ) + ) + else + render_error_response(result) + end + end + + def show + render_alert(alert) + end + + def create + if params[:alerts].present? + create_batch + else + create_single + end + end + + def update + result = UsageMonitoring::UpdateAlertService.call( + alert:, + params: update_params.to_h + ) + + if result.success? + render_alert(result.alert) + else + render_error_response(result) + end + end + + def destroy + result = UsageMonitoring::DestroyAlertService.call(alert:) + + if result.success? + render_alert(result.alert) + else + render_error_response(result) + end + end + + def destroy_all + result = UsageMonitoring::Alerts::DestroyAllService.call(alertable: wallet) + + if result.success? + head(:ok) + else + render_error_response(result) + end + end + + private + + attr_reader :alert + + def create_single + result = UsageMonitoring::CreateAlertService.call( + organization: current_organization, + alertable: wallet, + params: create_params.to_h + ) + + if result.success? + render_alert(result.alert) + else + render_error_response(result) + end + end + + def create_batch + result = UsageMonitoring::Alerts::CreateBatchService.call( + organization: current_organization, + alertable: wallet, + alerts_params: batch_create_params[:alerts] + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.alerts, + ::V1::UsageMonitoring::AlertSerializer, + collection_name: "alerts", + includes: %i[thresholds] + ) + ) + else + render_error_response(result) + end + end + + def find_alert + @alert = current_organization.alerts.find_by!( + wallet_id: wallet.id, + code: params[:code] + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "alert") + end + + def render_alert(alert) + render( + json: ::V1::UsageMonitoring::AlertSerializer.new( + alert, + root_name: "alert", + includes: %i[thresholds] + ) + ) + end + + def create_params + params.require(:alert).permit(:alert_type, :code, :name, thresholds: %i[code value recurring]) + end + + def update_params + params.require(:alert).permit(:code, :name, thresholds: %i[code value recurring]) + end + + def batch_create_params + params.permit(alerts: [:alert_type, :code, :name, {thresholds: %i[code value recurring]}]) + end + + def resource_name + "alert" + end + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/wallets/base_controller.rb b/app/controllers/api/v1/customers/wallets/base_controller.rb new file mode 100644 index 0000000..9773036 --- /dev/null +++ b/app/controllers/api/v1/customers/wallets/base_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + module Wallets + class BaseController < Api::V1::Customers::BaseController + before_action :find_wallet + + private + + attr_reader :wallet + + def find_wallet + @wallet = customer.wallets.order(:status).find_by!(code: params[:wallet_code]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "wallet") + end + + def resource_name + "wallet" + end + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/wallets/metadata_controller.rb b/app/controllers/api/v1/customers/wallets/metadata_controller.rb new file mode 100644 index 0000000..e030521 --- /dev/null +++ b/app/controllers/api/v1/customers/wallets/metadata_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + module Wallets + class MetadataController < BaseController + include WalletMetadataActions + + def create + metadata_create(wallet) + end + + def update + metadata_update(wallet) + end + + def destroy + metadata_destroy(wallet) + end + + def destroy_key + metadata_destroy_key(wallet) + end + end + end + end + end +end diff --git a/app/controllers/api/v1/customers/wallets_controller.rb b/app/controllers/api/v1/customers/wallets_controller.rb new file mode 100644 index 0000000..7d3360b --- /dev/null +++ b/app/controllers/api/v1/customers/wallets_controller.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Api + module V1 + module Customers + class WalletsController < BaseController + include WalletActions + + def create + wallet_create(customer) + end + + def update + wallet = customer.wallets.find_by(code: params[:code]) + + wallet_update(wallet) + end + + def terminate + wallet = customer.wallets.find_by(code: params[:code]) + + wallet_terminate(wallet) + end + + def show + wallet = customer.wallets.find_by(code: params[:code]) + + wallet_show(wallet) + end + + def index + wallet_index(external_customer_id: customer.external_id, currency: params[:currency]) + end + + private + + def resource_name + "wallet" + end + end + end + end +end diff --git a/app/controllers/api/v1/customers_controller.rb b/app/controllers/api/v1/customers_controller.rb new file mode 100644 index 0000000..05cdafe --- /dev/null +++ b/app/controllers/api/v1/customers_controller.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +module Api + module V1 + class CustomersController < Api::BaseController + def create + result = ::Customers::UpsertFromApiService.call( + organization: current_organization, + params: create_params.to_h.deep_symbolize_keys + ) + + if result.success? + render_customer(result.customer) + else + render_error_response(result) + end + end + + def portal_url + customer = current_organization.customers.find_by(external_id: params[:customer_external_id]) + + result = ::CustomerPortal::GenerateUrlService.call(customer:) + + if result.success? + render( + json: { + customer: { + portal_url: result.url + } + } + ) + else + render_error_response(result) + end + end + + def index + filter_params = params.permit( + :search_term, + :has_tax_identification_number, + :has_customer_type, + :customer_type, + currencies: [], + countries: [], + states: [], + zipcodes: [], + billing_entity_codes: [], + account_type: [], + metadata: {} + ) + search_term = filter_params.delete(:search_term) + billing_entity_codes = filter_params.delete(:billing_entity_codes) + if billing_entity_codes.present? + billing_entities = current_organization.all_billing_entities.where(code: billing_entity_codes) + return not_found_error(resource: "billing_entity") if billing_entities.count != billing_entity_codes.uniq.count + end + + result = CustomersQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + search_term:, + filters: filter_params.merge(billing_entity_ids: billing_entities&.ids) + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.customers.includes(:taxes, :integration_customers), + ::V1::CustomerSerializer, + collection_name: "customers", + meta: pagination_metadata(result.customers), + includes: %i[taxes integration_customers] + ) + ) + else + render_error_response(result) + end + end + + def show + customer = current_organization.customers.find_by(external_id: params[:external_id]) + + return not_found_error(resource: "customer") unless customer + + render_customer(customer) + end + + def destroy + customer = current_organization.customers.find_by(external_id: params[:external_id]) + result = ::Customers::DestroyService.call(customer:) + + if result.success? + render_customer(result.customer) + else + render_error_response(result) + end + end + + def checkout_url + customer = current_organization.customers.find_by(external_id: params[:customer_external_id]) + + result = ::Customers::GenerateCheckoutUrlService.call(customer:) + + if result.success? + render( + json: ::V1::PaymentProviders::CustomerCheckoutSerializer.new( + customer, + root_name: "customer", + checkout_url: result.checkout_url + ) + ) + else + render_error_response(result) + end + end + + private + + def create_params + params.expect(customer: [ + :account_type, + :external_id, + :name, + :firstname, + :lastname, + :customer_type, + :country, + :address_line1, + :address_line2, + :state, + :zipcode, + :email, + :city, + :url, + :phone, + :logo_url, + :legal_name, + :legal_number, + :tax_identification_number, + :currency, + :timezone, + :net_payment_term, + :external_salesforce_id, + :finalize_zero_amount_invoice, + :skip_invoice_custom_sections, + :billing_entity_code, + integration_customers: [ + [ + :id, + :external_customer_id, + :integration_type, + :integration_code, + :subsidiary_id, + :sync_with_provider, + :targeted_object + ] + ], + billing_configuration: [ + :invoice_grace_period, + :subscription_invoice_issuing_date_anchor, + :subscription_invoice_issuing_date_adjustment, + :payment_provider, + :payment_provider_code, + :provider_customer_id, + :sync, + :sync_with_provider, + :document_locale, + provider_payment_methods: [] + ], + metadata: [ + [ + :id, + :key, + :value, + :display_in_invoice + ] + ], + shipping_address: [ + :address_line1, + :address_line2, + :city, + :zipcode, + :state, + :country + ], + tax_codes: [], + invoice_custom_section_codes: [] + ]) + end + + def render_customer(customer) + render( + json: ::V1::CustomerSerializer.new( + customer, + root_name: "customer", + includes: %i[taxes integration_customers applicable_invoice_custom_sections error_details] + ) + ) + end + + def resource_name + "customer" + end + end + end +end diff --git a/app/controllers/api/v1/data_api/base_controller.rb b/app/controllers/api/v1/data_api/base_controller.rb new file mode 100644 index 0000000..884c3cd --- /dev/null +++ b/app/controllers/api/v1/data_api/base_controller.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Api + module V1 + module DataApi + class BaseController < Api::BaseController + private + + def resource_name + "analytic" + end + end + end + end +end diff --git a/app/controllers/api/v1/data_api/usages_controller.rb b/app/controllers/api/v1/data_api/usages_controller.rb new file mode 100644 index 0000000..8af4650 --- /dev/null +++ b/app/controllers/api/v1/data_api/usages_controller.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Api + module V1 + module DataApi + class UsagesController < Api::V1::DataApi::BaseController + def index + @result = ::DataApi::UsagesService.call(current_organization, **filter_params) + + if @result.success? + render_result(@result) + else + render_error_response(@result) + end + end + + private + + def render_result(result) + render(json: {"usages" => result.usages}.to_json) + end + + def filter_params + params.permit( + :time_granularity, + :currency, + :from_date, + :to_date, + :customer_type, + :external_customer_id, + :customer_country, + :external_subscription_id, + :is_billable_metric_recurring, + :plan_code, + :billable_metric_code + ).to_h.compact + end + end + end + end +end diff --git a/app/controllers/api/v1/events_controller.rb b/app/controllers/api/v1/events_controller.rb new file mode 100644 index 0000000..8d52537 --- /dev/null +++ b/app/controllers/api/v1/events_controller.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +module Api + module V1 + class EventsController < Api::BaseController + skip_audit_logs! + + before_action :ensure_organization_uses_clickhouse, only: [:index_enriched] + + ACTIONS_WITH_CACHED_API_KEY = %i[create batch estimate_instant_fees batch_estimate_instant_fees].freeze + + def create + result = ::Events::CreateService.call( + organization: current_organization, + params: create_params, + timestamp: Time.current.to_f, + metadata: event_metadata + ) + + if result.success? + render( + json: ::V1::EventSerializer.new( + result.event, + root_name: "event" + ) + ) + else + render_error_response(result) + end + end + + def batch + result = ::Events::CreateBatchService.call( + organization: current_organization, + events_params: batch_params, + timestamp: Time.current.to_f, + metadata: event_metadata + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.events, + ::V1::EventSerializer, + collection_name: "events" + ) + ) + else + render_error_response(result) + end + end + + def show + event_scope = current_organization.clickhouse_events_store? ? Clickhouse::EventsRaw : Event + event = event_scope.find_by( + organization_id: current_organization.id, + transaction_id: params[:id] + ) + + return not_found_error(resource: "event") unless event + + render( + json: ::V1::EventSerializer.new( + event, + root_name: "event" + ) + ) + end + + def index + result = EventsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.events, + ::V1::EventSerializer, + collection_name: "events", + meta: pagination_metadata(result.events) + ) + ) + else + render_error_response(result) + end + end + + def index_enriched + set_beta_header! + + result = EventsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters.to_h.merge(enriched: true) + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.events, + ::V1::EventEnrichedSerializer, + collection_name: "events", + meta: pagination_metadata(result.events) + ) + ) + else + render_error_response(result) + end + end + + def estimate_instant_fees + result = Fees::EstimateInstant::PayInAdvanceService.call( + organization: current_organization, + params: create_params + ) + + if result.success? + render( + json: {fees: result.fees} + ) + else + render_error_response(result) + end + end + + def batch_estimate_instant_fees + fees = [] + batch_params[:events].group_by { |h| h[:external_subscription_id] }.each do |external_subscription_id, events| + fees += Fees::EstimateInstant::BatchPayInAdvanceService.call!( + organization: current_organization, + external_subscription_id:, + events: + ).fees + end + + render( + json: {fees: fees} + ) + end + + def estimate_fees + result = Fees::EstimatePayInAdvanceService.call( + organization: current_organization, + params: create_params + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.fees, + ::V1::FeeSerializer, + collection_name: "fees", + includes: %i[applied_taxes] + ) + ) + else + render_error_response(result) + end + end + + private + + def create_params + params + .require(:event) + .permit( + :transaction_id, + :code, + :timestamp, + :external_subscription_id, + :precise_total_amount_cents, + properties: {} + ) + end + + def batch_params + params + .permit( + events: [ + :transaction_id, + :code, + :timestamp, + :external_subscription_id, + :precise_total_amount_cents, + properties: {} # rubocop:disable Style/HashAsLastArrayItem + ] + ).to_h.deep_symbolize_keys + end + + def index_filters + params.permit( + :code, + :external_subscription_id, + :timestamp_from_started_at, + :timestamp_from, + :timestamp_to + ) + end + + def event_metadata + { + user_agent: request.user_agent, + ip_address: request.remote_ip + } + end + + def track_api_key_usage? + action_name&.to_sym != :create + end + + def resource_name + "event" + end + + def cached_api_key? + ACTIONS_WITH_CACHED_API_KEY.include?(action_name&.to_sym) + end + end + end +end diff --git a/app/controllers/api/v1/features/privileges_controller.rb b/app/controllers/api/v1/features/privileges_controller.rb new file mode 100644 index 0000000..00cf2fd --- /dev/null +++ b/app/controllers/api/v1/features/privileges_controller.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Api + module V1 + module Features + class PrivilegesController < Api::BaseController + def destroy + feature = current_organization.features.find_by(code: params[:feature_code]) + return not_found_error(resource: "feature") unless feature + + privilege = feature.privileges.where(code: params[:code]).first + return not_found_error(resource: "privilege") unless privilege + + result = ::Entitlement::PrivilegeDestroyService.call(privilege:) + + if result.success? + render( + json: ::V1::Entitlement::FeatureSerializer.new( + feature, + root_name: + ) + ) + else + render_error_response(result) + end + end + + private + + def root_name + "feature" + end + + def resource_name + "feature" + end + end + end + end +end diff --git a/app/controllers/api/v1/features_controller.rb b/app/controllers/api/v1/features_controller.rb new file mode 100644 index 0000000..7fe4c0d --- /dev/null +++ b/app/controllers/api/v1/features_controller.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Api + module V1 + class FeaturesController < Api::BaseController + def index + result = ::Entitlement::FeaturesQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + search_term: params[:search_term] + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.features.includes(:privileges), + ::V1::Entitlement::FeatureSerializer, + collection_name: "features", + meta: pagination_metadata(result.features) + ) + ) + else + render_error_response(result) + end + end + + def create + result = ::Entitlement::FeatureCreateService.call( + organization: current_organization, + params: feature_create_params + ) + + if result.success? + render( + json: ::V1::Entitlement::FeatureSerializer.new( + result.feature, + root_name: + ) + ) + else + render_error_response(result) + end + end + + def show + feature = current_organization.features.where(code: params[:code]).first + + return not_found_error(resource: "feature") unless feature + + render( + json: ::V1::Entitlement::FeatureSerializer.new( + feature, + root_name: + ) + ) + end + + def update + feature = current_organization.features.where(code: params[:code]).first + return not_found_error(resource: "feature") unless feature + + result = ::Entitlement::FeatureUpdateService.call( + feature:, + params: feature_update_params, + partial: true + ) + + if result.success? + render( + json: ::V1::Entitlement::FeatureSerializer.new( + result.feature, + root_name: + ) + ) + else + render_error_response(result) + end + end + + def destroy + feature = current_organization.features.where(code: params[:code]).first + result = ::Entitlement::FeatureDestroyService.call(feature:) + + if result.success? + render( + json: ::V1::Entitlement::FeatureSerializer.new( + result.feature, + root_name: + ) + ) + else + render_error_response(result) + end + end + + private + + def feature_create_params + params.require(:feature).permit(:code, :name, :description, privileges: [ + :code, :name, :value_type, config: {} + ]) + end + + def feature_update_params + params.require(:feature).permit(:name, :description, privileges: [ + :code, :name, :value_type, config: {} + ]) + end + + def root_name + "feature" + end + + def resource_name + "feature" + end + end + end +end diff --git a/app/controllers/api/v1/fees_controller.rb b/app/controllers/api/v1/fees_controller.rb new file mode 100644 index 0000000..67b3442 --- /dev/null +++ b/app/controllers/api/v1/fees_controller.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Api + module V1 + class FeesController < Api::BaseController + def show + fee = Fee.from_organization(current_organization) + .find_by(id: params[:id]) + + return not_found_error(resource: "fee") unless fee + + render(json: ::V1::FeeSerializer.new(fee, root_name: "fee", includes: %i[applied_taxes])) + end + + def update + fee = Fee.from_organization(current_organization) + .find_by(id: params[:id]) + result = Fees::UpdateService.call(fee:, params: update_params) + + if result.success? + render(json: ::V1::FeeSerializer.new(fee, root_name: "fee", includes: %i[applied_taxes])) + else + render_error_response(result) + end + end + + def index + result = FeesQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.fees.includes( + :billable_metric, + subscription: :plan + ).preload( + :add_on, + :applied_taxes, + :charge, + :charge_filter, + :customer, + :fixed_charge, + :fixed_charge_add_on, + :invoice, + :invoiceable, + :true_up_fee, + :presentation_breakdowns + ), + ::V1::FeeSerializer, + collection_name: "fees", + meta: pagination_metadata(result.fees), + includes: %i[applied_taxes] + ) + ) + else + render_error_response(result) + end + end + + def destroy + fee = Fee.from_organization(current_organization).find_by(id: params[:id]) + result = ::Fees::DestroyService.call(fee:) + + if result.success? + render_fee(result.fee) + else + render_error_response(result) + end + end + + private + + def update_params + params.require(:fee).permit(:payment_status) + end + + def render_fee(fee) + render( + json: ::V1::FeeSerializer.new( + fee, + root_name: "fee" + ) + ) + end + + def index_filters + params.permit( + :fee_type, + :payment_status, + :external_subscription_id, + :external_customer_id, + :billable_metric_code, + :currency, + :event_transaction_id, + :created_at_from, + :created_at_to, + :failed_at_from, + :failed_at_to, + :succeeded_at_from, + :succeeded_at_to, + :refunded_at_from, + :refunded_at_to + ) + end + + def resource_name + "fee" + end + end + end +end diff --git a/app/controllers/api/v1/invoices_controller.rb b/app/controllers/api/v1/invoices_controller.rb new file mode 100644 index 0000000..53d1dbf --- /dev/null +++ b/app/controllers/api/v1/invoices_controller.rb @@ -0,0 +1,407 @@ +# frozen_string_literal: true + +module Api + module V1 + class InvoicesController < Api::BaseController + include InvoiceIndex + + def create + result = Invoices::CreateOneOffService.call( + customer:, + currency: create_params[:currency], + fees: create_params[:fees], + timestamp: Time.current.to_i, + skip_psp: create_params[:skip_psp], + invoice_custom_section: create_params[:invoice_custom_section] || {}, + payment_method_params: create_params[:payment_method], + billing_entity_code: create_params[:billing_entity_code] + ) + + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def update + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + + result = Invoices::UpdateService.new( + invoice:, + params: update_params.to_h.deep_symbolize_keys, + webhook_notification: true + ).call + + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def show + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + + return not_found_error(resource: "invoice") unless invoice + + render_invoice(invoice) + end + + def index + permitted_params = params.permit(:external_customer_id, :customer_external_id) + customer_external_id = permitted_params[:external_customer_id] || permitted_params[:customer_external_id] + invoice_index(customer_external_id: customer_external_id) + end + + def download_pdf + invoice = current_organization.invoices.finalized.find_by(id: params[:id]) + + return not_found_error(resource: "invoice") unless invoice + + if invoice.file.present? + return render( + json: ::V1::InvoiceSerializer.new( + invoice, + root_name: "invoice" + ) + ) + end + + Invoices::GeneratePdfJob.perform_later(invoice) + + head(:ok) + end + + def download_xml + invoice = current_organization.invoices.finalized.find_by(id: params[:id]) + + return not_found_error(resource: "invoice") unless invoice + + if invoice.xml_file.present? + return render( + json: ::V1::InvoiceSerializer.new( + invoice, + root_name: "invoice" + ) + ) + end + + Invoices::GenerateXmlJob.perform_later(invoice) + + head(:ok) + end + + def refresh + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + return not_found_error(resource: "invoice") unless invoice + + result = Invoices::RefreshDraftService.call(invoice:) + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def finalize + invoice = current_organization.invoices.draft.find_by(id: params[:id]) + return not_found_error(resource: "invoice") unless invoice + + result = Invoices::RefreshDraftAndFinalizeService.call(invoice:) + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def void + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + + result = Invoices::VoidService.call(invoice: invoice, params: void_params) + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def lose_dispute + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + + result = Invoices::LoseDisputeService.call(invoice:, payment_dispute_lost_at: DateTime.current) + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def retry_payment + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + return not_found_error(resource: "invoice") unless invoice + + result = Invoices::Payments::RetryService.new( + invoice:, + payment_method_params: retry_payment_params[:payment_method] + ).call + return render_error_response(result) unless result.success? + + head(:ok) + end + + def retry + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + return not_found_error(resource: "invoice") unless invoice + + result = Invoices::RetryService.new(invoice:).call + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def resend_email + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + return not_found_error(resource: "invoice") unless invoice + + result = Emails::ResendService.call( + resource: invoice, + to: resend_email_params[:to], + cc: resend_email_params[:cc], + bcc: resend_email_params[:bcc] + ) + + if result.success? + head(:ok) + else + render_error_response(result) + end + end + + def payment_url + invoice = current_organization.invoices.visible.includes(:customer).find_by(id: params[:id]) + return not_found_error(resource: "invoice") unless invoice + + result = ::Invoices::Payments::GeneratePaymentUrlService.call(invoice:) + + if result.success? + render( + json: ::V1::PaymentProviders::InvoicePaymentSerializer.new( + invoice, + root_name: "invoice_payment_details", + payment_url: result.payment_url + ) + ) + else + render_error_response(result) + end + end + + def sync_salesforce_id + invoice = current_organization.invoices.visible.find_by(id: params[:id]) + return not_found_error(resource: "invoice") unless invoice + + result = Invoices::SyncSalesforceIdService.call(invoice:, params: sync_salesforce_id_params) + + if result.success? + render_invoice(result.invoice) + else + render_error_response(result) + end + end + + def preview + if preview_params[:coupons] && !preview_params[:coupons].is_a?(Array) + return render( + json: { + status: 400, + error: "coupons_must_be_an_array" + }, + status: :bad_request + ) + end + + if preview_params[:subscriptions] && !preview_params.to_h[:subscriptions].is_a?(Hash) + return render( + json: { + status: 400, + error: "subscriptions_must_be_an_object" + }, + status: :bad_request + ) + end + + billing_entity_resolver = BillingEntities::ResolveService.call( + organization: current_organization, billing_entity_code: params[:billing_entity_code] + ) + return render_error_response(billing_entity_resolver) unless billing_entity_resolver.success? + billing_entity = billing_entity_resolver.billing_entity + + result = Invoices::PreviewContextService.call( + organization: current_organization, + billing_entity: billing_entity, + params: preview_params.to_h.deep_symbolize_keys + ) + return render_error_response(result) unless result.success? + + result = Invoices::PreviewService.call( + customer: result.customer, + subscriptions: result.subscriptions, + applied_coupons: result.applied_coupons + ) + if result.success? + render( + json: ::V1::InvoiceSerializer.new( + result.invoice, + root_name: "invoice", + includes: %i[customer integration_customers credits applied_taxes preview_subscriptions preview_fees] + ) + ) + else + render_error_response(result) + end + end + + private + + def create_params + return @create_params if defined? @create_params + + @create_params = + params.require(:invoice) + .permit( + :external_customer_id, + :currency, + :skip_psp, + :billing_entity_code, + fees: [ + :add_on_code, + :invoice_display_name, + :unit_amount_cents, + :units, + :description, + :from_datetime, + :to_datetime, + {tax_codes: []} + ], + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ], + payment_method: [ + :payment_method_type, + :payment_method_id + ] + ).to_h.deep_symbolize_keys + end + + def update_params + params.require(:invoice).permit( + :payment_status, + metadata: [ + :id, + :key, + :value + ] + ) + end + + def retry_payment_params + params.permit( + payment_method: [ + :payment_method_type, + :payment_method_id + ] + ).to_h.deep_symbolize_keys + end + + def preview_params + params.permit( + :plan_code, + :billing_time, + :subscription_at, + subscriptions: [ + :plan_code, + :terminated_at, + external_ids: [] + ], + coupons: [ + :code, + :name, + :coupon_type, + :amount_cents, + :amount_currency, + :percentage_rate, + :frequency, + :frequency_duration, + :frequency_duration_remaining + ], + customer: [ + :external_id, + :name, + :tax_identification_number, + :currency, + :timezone, + :address_line1, + :address_line2, + :city, + :zipcode, + :state, + :country, + shipping_address: [ + :address_line1, + :address_line2, + :city, + :zipcode, + :state, + :country + ], + integration_customers: [ + :integration_type, + :integration_code + ] + ] + ) + end + + def void_params + params.permit(:generate_credit_note, :refund_amount, :credit_amount) + end + + def sync_salesforce_id_params + params.permit( + :external_id, + :integration_code + ) + end + + def resend_email_params + params.permit(to: [], cc: [], bcc: []) + end + + def render_invoice(invoice) + render( + json: ::V1::InvoiceSerializer.new( + invoice, + root_name: "invoice", + includes: %i[customer integration_customers billing_periods subscriptions fees credits metadata applied_taxes error_details applied_invoice_custom_sections] + ) + ) + end + + def customer + Customer.find_by( + external_id: create_params[:external_customer_id], + organization_id: current_organization.id + ) + end + + def resource_name + "invoice" + end + end + end +end diff --git a/app/controllers/api/v1/organizations_controller.rb b/app/controllers/api/v1/organizations_controller.rb new file mode 100644 index 0000000..666fd82 --- /dev/null +++ b/app/controllers/api/v1/organizations_controller.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module Api + module V1 + class OrganizationsController < Api::BaseController + def show + render( + json: ::V1::OrganizationSerializer.new( + current_organization, + root_name: "organization", + include: %i[taxes] + ) + ) + end + + def update + result = Organizations::UpdateService.call(organization: current_organization, params: input_params) + + if result.success? + render( + json: ::V1::OrganizationSerializer.new( + result.organization, + root_name: "organization", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + def grpc_token + payload = { + organization_id: current_organization.id, + aud: "lago-grpc" + } + grpc_token = JWT.encode(payload, RsaPrivateKey, "RS256") + + render( + json: { + organization: { + grpc_token: + } + } + ) + end + + private + + def input_params + params.require(:organization).permit( + :country, + :default_currency, + :address_line1, + :address_line2, + :state, + :zipcode, + :email, + :city, + :legal_name, + :legal_number, + :net_payment_term, + :tax_identification_number, + :timezone, + :webhook_url, + :document_numbering, + :document_number_prefix, + :finalize_zero_amount_invoice, + :slug, + email_settings: [], + billing_configuration: [ + :invoice_footer, + :invoice_grace_period, + :document_locale + ] + ) + end + + def resource_name + "organization" + end + end + end +end diff --git a/app/controllers/api/v1/payment_receipts_controller.rb b/app/controllers/api/v1/payment_receipts_controller.rb new file mode 100644 index 0000000..6df751b --- /dev/null +++ b/app/controllers/api/v1/payment_receipts_controller.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Api + module V1 + class PaymentReceiptsController < Api::BaseController + def index + result = PaymentReceiptsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.payment_receipts, + ::V1::PaymentReceiptSerializer, + collection_name: serialized_resource_name.pluralize, + meta: pagination_metadata(result.payment_receipts) + ) + ) + else + render_error_response(result) + end + end + + def show + payment_receipt = PaymentReceipt.where(organization: current_organization).find_by(id: params[:id]) + return not_found_error(resource: resource_name) unless payment_receipt + + render_payment_receipt(payment_receipt) + end + + def resend_email + payment_receipt = PaymentReceipt.where(organization: current_organization).find_by(id: params[:id]) + return not_found_error(resource: resource_name) unless payment_receipt + + result = Emails::ResendService.call( + resource: payment_receipt, + to: resend_email_params[:to], + cc: resend_email_params[:cc], + bcc: resend_email_params[:bcc] + ) + + if result.success? + head(:ok) + else + render_error_response(result) + end + end + + private + + def index_filters + params.permit(:invoice_id) + end + + def resend_email_params + params.permit(to: [], cc: [], bcc: []) + end + + def render_payment_receipt(payment_receipt) + render( + json: ::V1::PaymentReceiptSerializer.new( + payment_receipt, + root_name: serialized_resource_name + ) + ) + end + + def resource_name + "invoice" + end + + def serialized_resource_name + "payment_receipt" + end + end + end +end diff --git a/app/controllers/api/v1/payment_requests_controller.rb b/app/controllers/api/v1/payment_requests_controller.rb new file mode 100644 index 0000000..13a6a38 --- /dev/null +++ b/app/controllers/api/v1/payment_requests_controller.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Api + module V1 + class PaymentRequestsController < Api::BaseController + include PaymentRequestIndex + + def create + result = PaymentRequests::CreateService.call( + organization: current_organization, + params: create_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::PaymentRequestSerializer.new( + result.payment_request, + root_name: "payment_request", + includes: %i[customer invoices] + ) + ) + else + render_error_response(result) + end + end + + def index + permitted_params = params.permit(:external_customer_id, :payment_status) + external_customer_id = permitted_params[:external_customer_id] + payment_request_index(external_customer_id: external_customer_id) + end + + def show + payment_request = PaymentRequest.where(organization: current_organization).find_by(id: params[:id]) + return not_found_error(resource: resource_name) unless payment_request + + render_payment_request(payment_request) + end + + private + + def create_params + params.require(:payment_request).permit( + :email, + :external_customer_id, + lago_invoice_ids: [] + ) + end + + def render_payment_request(payment_request) + render( + json: ::V1::PaymentRequestSerializer.new( + payment_request, + root_name: resource_name, + includes: %i[customer invoices] + ) + ) + end + + def resource_name + "payment_request" + end + end + end +end diff --git a/app/controllers/api/v1/payments_controller.rb b/app/controllers/api/v1/payments_controller.rb new file mode 100644 index 0000000..73f72da --- /dev/null +++ b/app/controllers/api/v1/payments_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Api + module V1 + class PaymentsController < Api::BaseController + include PaymentIndex + + def create + result = Payments::ManualCreateService.call( + organization: current_organization, + params: create_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::PaymentSerializer.new(result.payment, root_name: resource_name) + ) + else + render_error_response(result) + end + end + + def index + permitted_params = params.permit(:external_customer_id) + customer_external_id = permitted_params[:external_customer_id] + payment_index(customer_external_id: customer_external_id) + end + + def show + payment = Payment.for_organization(current_organization).find_by(id: params[:id]) + return not_found_error(resource: resource_name) unless payment + + render_payment(payment) + end + + private + + def create_params + params.require(:payment).permit( + :invoice_id, + :amount_cents, + :reference, + :paid_at + ) + end + + def render_payment(payment) + render( + json: ::V1::PaymentSerializer.new( + payment, + root_name: resource_name + ) + ) + end + + def resource_name + "payment" + end + end + end +end diff --git a/app/controllers/api/v1/plans/base_controller.rb b/app/controllers/api/v1/plans/base_controller.rb new file mode 100644 index 0000000..be19909 --- /dev/null +++ b/app/controllers/api/v1/plans/base_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Api + module V1 + module Plans + class BaseController < Api::BaseController + before_action :find_plan + + private + + attr_reader :plan + + def find_plan + @plan = current_organization.plans.parents.find_by!( + code: params[:plan_code] + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "plan") + end + + def resource_name + "plan" + end + end + end + end +end diff --git a/app/controllers/api/v1/plans/charges/filters_controller.rb b/app/controllers/api/v1/plans/charges/filters_controller.rb new file mode 100644 index 0000000..2390faa --- /dev/null +++ b/app/controllers/api/v1/plans/charges/filters_controller.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Api + module V1 + module Plans + module Charges + class FiltersController < Plans::BaseController + before_action :find_charge + before_action :find_charge_filter, only: %i[show update destroy] + + def index + charge_filters = charge.filters + .includes(:charge, :billable_metric_filters) + .page(params[:page]) + .per(params[:per_page] || PER_PAGE) + + render( + json: ::CollectionSerializer.new( + charge_filters, + ::V1::ChargeFilterSerializer, + collection_name: "filters", + meta: pagination_metadata(charge_filters) + ) + ) + end + + def show + render(json: ::V1::ChargeFilterSerializer.new(charge_filter, root_name: "filter")) + end + + def create + result = ChargeFilters::CreateService.call( + charge:, params: input_params.to_h.deep_symbolize_keys, cascade_updates: cascade_updates? + ) + + if result.success? + render( + json: ::V1::ChargeFilterSerializer.new( + result.charge_filter, + root_name: "filter" + ) + ) + else + render_error_response(result) + end + end + + def update + result = ChargeFilters::UpdateService.call( + charge_filter:, + params: input_params.to_h.deep_symbolize_keys, + cascade_updates: cascade_updates? + ) + + if result.success? + render( + json: ::V1::ChargeFilterSerializer.new( + result.charge_filter, + root_name: "filter" + ) + ) + else + render_error_response(result) + end + end + + def destroy + result = ChargeFilters::DestroyService.call(charge_filter:, cascade_updates: cascade_updates?) + + if result.success? + render( + json: ::V1::ChargeFilterSerializer.new( + result.charge_filter, + root_name: "filter" + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :charge, :charge_filter + + def input_params + params.require(:filter).permit( + :invoice_display_name, + properties: {}, + values: {} + ) + end + + def cascade_updates? + ActiveModel::Type::Boolean.new.cast(params.dig(:filter, :cascade_updates)) + end + + def find_charge + @charge = plan.charges.parents.find_by!(code: params[:charge_code]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "charge") + end + + def find_charge_filter + @charge_filter = charge.filters.find_by!(id: params[:id]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "charge_filter") + end + end + end + end + end +end diff --git a/app/controllers/api/v1/plans/charges_controller.rb b/app/controllers/api/v1/plans/charges_controller.rb new file mode 100644 index 0000000..856739b --- /dev/null +++ b/app/controllers/api/v1/plans/charges_controller.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Api + module V1 + module Plans + class ChargesController < BaseController + before_action :find_charge, only: %i[show update destroy] + + def index + charges = plan.charges + .parents + .includes(:billable_metric, :taxes, :applied_pricing_unit, :pricing_unit, filters: :billable_metric_filters) + .order(created_at: :desc) + .page(params[:page]) + .per(params[:per_page] || PER_PAGE) + + render( + json: ::CollectionSerializer.new( + charges, + ::V1::ChargeSerializer, + collection_name: "charges", + meta: pagination_metadata(charges), + includes: %i[taxes] + ) + ) + end + + def show + render( + json: ::V1::ChargeSerializer.new( + charge, + root_name: "charge", + includes: %i[taxes] + ) + ) + end + + def create + result = ::Charges::CreateService.call( + plan:, params: input_params.to_h.deep_symbolize_keys, cascade_updates: cascade_updates? + ) + + if result.success? + render( + json: ::V1::ChargeSerializer.new( + result.charge, + root_name: "charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + def update + result = ::Charges::UpdateService.call( + charge:, + params: input_params.to_h.deep_symbolize_keys, + cascade_updates: cascade_updates? + ) + + if result.success? + render( + json: ::V1::ChargeSerializer.new( + result.charge, + root_name: "charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + def destroy + result = ::Charges::DestroyService.call(charge:, cascade_updates: cascade_updates?) + + if result.success? + render( + json: ::V1::ChargeSerializer.new( + result.charge, + root_name: "charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :charge + + def input_params + params.require(:charge).permit( + :billable_metric_id, + :code, + :invoice_display_name, + :charge_model, + :pay_in_advance, + :prorated, + :invoiceable, + :regroup_paid_fees, + :min_amount_cents, + :accepts_target_wallet, + properties: {}, + filters: [ + :invoice_display_name, + { + properties: {}, + values: {} + } + ], + tax_codes: [], + applied_pricing_unit: %i[code conversion_rate] + ) + end + + def cascade_updates? + ActiveModel::Type::Boolean.new.cast(params.dig(:charge, :cascade_updates)) + end + + def find_charge + @charge = plan.charges.parents.find_by!(code: params[:code]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "charge") + end + end + end + end +end diff --git a/app/controllers/api/v1/plans/entitlements/privileges_controller.rb b/app/controllers/api/v1/plans/entitlements/privileges_controller.rb new file mode 100644 index 0000000..f4f7ff9 --- /dev/null +++ b/app/controllers/api/v1/plans/entitlements/privileges_controller.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Api + module V1 + module Plans + module Entitlements + class PrivilegesController < Api::BaseController + before_action :find_plan + before_action :find_entitlement + + def destroy + result = ::Entitlement::PlanEntitlementPrivilegeDestroyService.call( + entitlement: entitlement, + privilege_code: params[:code] + ) + + if result.success? + render( + json: ::V1::Entitlement::PlanEntitlementSerializer.new( + result.entitlement, + root_name: + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :plan, :entitlement + + def root_name + "entitlement" + end + + def resource_name + "plan" + end + + def find_plan + @plan = current_organization.plans.parents.find_by!( + code: params[:plan_code] + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "plan") + end + + def find_entitlement + @entitlement = current_organization.entitlements + .joins(:feature) + .where(plan: plan, entitlement_features: {code: params[:entitlement_code]}) + .includes(:feature, values: :privilege) + .first! + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "entitlement") + end + end + end + end + end +end diff --git a/app/controllers/api/v1/plans/entitlements_controller.rb b/app/controllers/api/v1/plans/entitlements_controller.rb new file mode 100644 index 0000000..c28199e --- /dev/null +++ b/app/controllers/api/v1/plans/entitlements_controller.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Api + module V1 + module Plans + class EntitlementsController < BaseController + before_action :find_entitlement, only: %i[show destroy] + + def index + entitlements = current_organization.entitlements + .where(plan: plan) + .includes(:feature, values: :privilege) + + render( + json: ::CollectionSerializer.new( + entitlements, + ::V1::Entitlement::PlanEntitlementSerializer, + collection_name: "entitlements" + ) + ) + end + + def show + render( + json: ::V1::Entitlement::PlanEntitlementSerializer.new( + entitlement, + root_name: + ) + ) + end + + def create + result = ::Entitlement::PlanEntitlementsUpdateService.call( + organization: current_organization, + plan:, + entitlements_params: update_params, + partial: false + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.entitlements, + ::V1::Entitlement::PlanEntitlementSerializer, + collection_name: "entitlements" + ) + ) + else + render_error_response(result) + end + end + + def update + result = ::Entitlement::PlanEntitlementsUpdateService.call( + organization: current_organization, + plan:, + entitlements_params: update_params, + partial: true + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.entitlements, + ::V1::Entitlement::PlanEntitlementSerializer, + collection_name: "entitlements" + ) + ) + else + render_error_response(result) + end + end + + def destroy + result = ::Entitlement::PlanEntitlementDestroyService.call(entitlement: entitlement) + + if result.success? + render( + json: ::V1::Entitlement::PlanEntitlementSerializer.new( + result.entitlement, + root_name: + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :plan, :entitlement + + def update_params + params.fetch(:entitlements, {}).permit! + end + + def root_name + "entitlement" + end + + def resource_name + "plan" + end + + def find_entitlement + @entitlement = current_organization.entitlements + .joins(:feature) + .where(plan: plan, entitlement_features: {code: params[:code]}) + .includes(:feature, values: :privilege) + .first! + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "entitlement") + end + end + end + end +end diff --git a/app/controllers/api/v1/plans/fixed_charges_controller.rb b/app/controllers/api/v1/plans/fixed_charges_controller.rb new file mode 100644 index 0000000..5c137ea --- /dev/null +++ b/app/controllers/api/v1/plans/fixed_charges_controller.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Api + module V1 + module Plans + class FixedChargesController < BaseController + before_action :find_fixed_charge, only: %i[show update destroy] + + def index + fixed_charges = plan.fixed_charges + .parents + .includes(:add_on, :taxes) + .order(created_at: :desc) + .page(params[:page]) + .per(params[:per_page] || PER_PAGE) + + render( + json: ::CollectionSerializer.new( + fixed_charges, + ::V1::FixedChargeSerializer, + collection_name: "fixed_charges", + meta: pagination_metadata(fixed_charges), + includes: %i[taxes] + ) + ) + end + + def show + render( + json: ::V1::FixedChargeSerializer.new( + fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + end + + def create + result = FixedCharges::CreateService.call( + plan:, params: input_params.to_h.deep_symbolize_keys, cascade_updates: cascade_updates? + ) + + if result.success? + render( + json: ::V1::FixedChargeSerializer.new( + result.fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + def update + result = FixedCharges::UpdateService.call( + fixed_charge:, + params: input_params.to_h.deep_symbolize_keys, + timestamp: Time.current.to_i, + cascade_updates: cascade_updates? + ) + + if result.success? + render( + json: ::V1::FixedChargeSerializer.new( + result.fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + def destroy + result = FixedCharges::DestroyService.call(fixed_charge:, cascade_updates: cascade_updates?) + + if result.success? + render( + json: ::V1::FixedChargeSerializer.new( + result.fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :fixed_charge + + def input_params + params.require(:fixed_charge).permit( + :add_on_id, + :add_on_code, + :code, + :invoice_display_name, + :charge_model, + :pay_in_advance, + :prorated, + :units, + :apply_units_immediately, + properties: {}, + tax_codes: [] + ) + end + + def cascade_updates? + ActiveModel::Type::Boolean.new.cast(params.dig(:fixed_charge, :cascade_updates)) + end + + def find_fixed_charge + @fixed_charge = plan.fixed_charges.parents.find_by!(code: params[:code]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "fixed_charge") + end + end + end + end +end diff --git a/app/controllers/api/v1/plans/metadata_controller.rb b/app/controllers/api/v1/plans/metadata_controller.rb new file mode 100644 index 0000000..d223256 --- /dev/null +++ b/app/controllers/api/v1/plans/metadata_controller.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Api + module V1 + module Plans + class MetadataController < BaseController + def create + result = ::Plans::UpdateService.call(plan:, params: metadata_params) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def update + result = ::Plans::UpdateService.call(plan:, partial_metadata: true, params: metadata_params) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def destroy + result = ::Plans::UpdateService.call(plan:, params: {metadata: nil}) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def destroy_key + return not_found_error(resource: "metadata") unless plan.metadata + + result = Metadata::DeleteItemKeyService.call(item: plan.metadata, key: params[:key]) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + private + + def metadata_params + params.permit(metadata: {}).to_h + end + + def render_metadata + render(json: {metadata: plan.reload.metadata&.value}) + end + end + end + end +end diff --git a/app/controllers/api/v1/plans_controller.rb b/app/controllers/api/v1/plans_controller.rb new file mode 100644 index 0000000..3a90cc8 --- /dev/null +++ b/app/controllers/api/v1/plans_controller.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +module Api + module V1 + class PlansController < Api::BaseController + def create + result = ::Plans::CreateService.call( + input_params.merge(organization_id: current_organization.id).to_h.deep_symbolize_keys + ) + + if result.success? + render_plan(result.plan) + else + render_error_response(result) + end + end + + def update + plan = current_organization.plans.parents.find_by(code: params[:code]) + result = ::Plans::UpdateService.call(plan:, params: input_params.to_h.deep_symbolize_keys) + + if result.success? + # Reload to eager-load relationships, like :entitlements + plan = Plan.includes( + :usage_thresholds, + :fixed_charges, + charges: {filters: {values: :billable_metric_filter}}, + entitlements: [:feature, values: :privilege] + ).find(result.plan.id) + + render_plan(plan) + else + render_error_response(result) + end + end + + def destroy + plan = current_organization.plans.parents.find_by(code: params[:code]) + result = ::Plans::PrepareDestroyService.call(plan:) + + if result.success? + # Reload to eager-load relationships, like :entitlements + plan = Plan.with_discarded.includes( + :usage_thresholds, + charges: {filters: {values: :billable_metric_filter}}, + entitlements: [:feature, values: :privilege] + ).find(result.plan.id) + + render_plan(plan) + else + render_error_response(result) + end + end + + def show + plan = current_organization.plans.parents + .includes( + :usage_thresholds, + charges: {filters: {values: :billable_metric_filter}}, + entitlements: [:feature, values: :privilege] + ) + .find_by(code: params[:code]) + + if plan + render_plan(plan) + else + not_found_error(resource: "plan") + end + end + + def index + result = PlansQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: {include_pending_deletion: true} + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.plans.includes( + :usage_thresholds, + :taxes, + :minimum_commitment, + charges: {filters: {values: :billable_metric_filter}}, + entitlements: [:feature, values: :privilege] + ), + ::V1::PlanSerializer, + collection_name: "plans", + meta: pagination_metadata(result.plans), + includes: %i[charges usage_thresholds applicable_usage_thresholds taxes minimum_commitment entitlements] + ) + ) + else + render_error_response(result) + end + end + + private + + def input_params + params.require(:plan).permit( + :name, + :invoice_display_name, + :code, + :interval, + :description, + :amount_cents, + :amount_currency, + :trial_period, + :pay_in_advance, + :bill_charges_monthly, + :bill_fixed_charges_monthly, + :cascade_updates, + metadata: {}, + tax_codes: [], + minimum_commitment: [ + :id, + :invoice_display_name, + :amount_cents, + {tax_codes: []} + ], + charges: [ + :id, + :code, + :invoice_display_name, + :billable_metric_id, + :charge_model, + :pay_in_advance, + :prorated, + :invoiceable, + :regroup_paid_fees, + :min_amount_cents, + :accepts_target_wallet, + { + properties: {} + }, + { + filters: [ + :invoice_display_name, + { + properties: {}, + values: {} + } + ] + }, + {tax_codes: []}, + { + applied_pricing_unit: [ + :code, + :conversion_rate + ] + } + ], + fixed_charges: [ + :id, + :code, + :invoice_display_name, + :units, + :add_on_id, + :apply_units_immediately, + :charge_model, + :pay_in_advance, + :prorated, + {properties: {}}, + {tax_codes: []} + ], + usage_thresholds: [ + :id, + :threshold_display_name, + :amount_cents, + :recurring + ] + ) + end + + def render_plan(plan) + render( + json: ::V1::PlanSerializer.new( + plan, + root_name: "plan", + includes: %i[charges fixed_charges usage_thresholds applicable_usage_thresholds taxes minimum_commitment entitlements] + ) + ) + end + + def resource_name + "plan" + end + end + end +end diff --git a/app/controllers/api/v1/security_logs_controller.rb b/app/controllers/api/v1/security_logs_controller.rb new file mode 100644 index 0000000..1491f12 --- /dev/null +++ b/app/controllers/api/v1/security_logs_controller.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Api + module V1 + class SecurityLogsController < Api::BaseController + include PremiumFeatureOnly + + skip_audit_logs! + + before_action :ensure_security_logs_enabled + + def index + result = SecurityLogsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: index_filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.security_logs, + ::V1::SecurityLogSerializer, + collection_name: "security_logs", + meta: pagination_metadata(result.security_logs) + ) + ) + else + render_error_response(result) + end + end + + def show + security_log = current_organization.security_logs.find_by( + log_id: params[:log_id] + ) + + return not_found_error(resource: "security_log") unless security_log + + render( + json: ::V1::SecurityLogSerializer.new( + security_log, + root_name: "security_log" + ) + ) + end + + private + + def ensure_security_logs_enabled + forbidden_error(code: "forbidden") unless current_organization.security_logs_enabled? + end + + def resource_name + "security_log" + end + + def index_filters + { + from_date: params[:from_date], + to_date: params[:to_date], + user_ids: params[:user_ids], + api_key_ids: params[:api_key_ids], + log_types: params[:log_types], + log_events: params[:log_events] + } + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/alerts_controller.rb b/app/controllers/api/v1/subscriptions/alerts_controller.rb new file mode 100644 index 0000000..7c3c3e7 --- /dev/null +++ b/app/controllers/api/v1/subscriptions/alerts_controller.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + class AlertsController < BaseController + before_action :find_alert, only: %i[show update destroy] + + def destroy_all + result = UsageMonitoring::Alerts::DestroyAllService.call( + alertable: subscription + ) + + if result.success? + head(:ok) + else + render_error_response(result) + end + end + + def index + result = UsageMonitoring::AlertsQuery.call( + organization: current_organization, + filters: { + subscription_external_id: subscription.external_id + }, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.alerts.includes(:thresholds, :billable_metric), + ::V1::UsageMonitoring::AlertSerializer, + collection_name: "alerts", + meta: pagination_metadata(result.alerts), + includes: %i[thresholds] + ) + ) + else + render_error_response(result) + end + end + + def show + render_alert(alert) + end + + def create + if params[:alerts].present? + create_batch + else + create_single + end + end + + def update + result = UsageMonitoring::UpdateAlertService.call( + alert:, + params: update_params.to_h + ) + + if result.success? + render_alert(result.alert) + else + render_error_response(result) + end + end + + def destroy + result = UsageMonitoring::DestroyAlertService.call(alert:) + + if result.success? + render_alert(result.alert) + else + render_error_response(result) + end + end + + private + + def create_single + result = UsageMonitoring::CreateAlertService.call( + organization: current_organization, + alertable: subscription, + params: create_params.to_h + ) + + if result.success? + render_alert(result.alert) + else + render_error_response(result) + end + end + + def create_batch + result = UsageMonitoring::Alerts::CreateBatchService.call( + organization: current_organization, + alertable: subscription, + alerts_params: batch_create_params[:alerts] + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.alerts, + ::V1::UsageMonitoring::AlertSerializer, + collection_name: "alerts", + includes: %i[thresholds] + ) + ) + else + render_error_response(result) + end + end + + attr_reader :subscription, :alert + + def find_alert + @alert = current_organization.alerts.find_by!( + subscription_external_id: subscription.external_id, + code: params[:code] + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "alert") + end + + def render_alert(alert) + render( + json: ::V1::UsageMonitoring::AlertSerializer.new( + alert, + root_name: "alert", + includes: %i[thresholds] + ) + ) + end + + def create_params + params.require(:alert).permit(:alert_type, :code, :name, :billable_metric_code, thresholds: %i[code value recurring]) + end + + def update_params + params.require(:alert).permit(:code, :name, :billable_metric_code, thresholds: %i[code value recurring]) + end + + def batch_create_params + params.permit(alerts: [:alert_type, :code, :name, :billable_metric_code, {thresholds: %i[code value recurring]}]) + end + + def resource_name + "alert" + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/base_controller.rb b/app/controllers/api/v1/subscriptions/base_controller.rb new file mode 100644 index 0000000..0e0b507 --- /dev/null +++ b/app/controllers/api/v1/subscriptions/base_controller.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + class BaseController < Api::BaseController + before_action :find_subscription + + private + + attr_reader :subscription + + def find_subscription + @subscription = current_organization.subscriptions + .order("terminated_at DESC NULLS FIRST, started_at DESC") # TODO: Confirm + .find_by!( + external_id: params[:subscription_external_id], + # we're keeping both `subscription_status` and `status` for backward compatibility, + # but we should rely more on the `subscription_status` + status: params[:subscription_status] || params[:status] || :active + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "subscription") + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/charges/filters_controller.rb b/app/controllers/api/v1/subscriptions/charges/filters_controller.rb new file mode 100644 index 0000000..5780e1a --- /dev/null +++ b/app/controllers/api/v1/subscriptions/charges/filters_controller.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + module Charges + class FiltersController < Api::V1::Subscriptions::BaseController + before_action :find_charge + before_action :find_charge_filter, only: %i[show update destroy] + + def index + charge_filters = charge.filters + .includes(:charge, :billable_metric_filters) + .page(params[:page]) + .per(params[:per_page] || PER_PAGE) + + render( + json: ::CollectionSerializer.new( + charge_filters, + ::V1::ChargeFilterSerializer, + collection_name: "filters", + meta: pagination_metadata(charge_filters) + ) + ) + end + + def show + render(json: ::V1::ChargeFilterSerializer.new(charge_filter, root_name: "filter")) + end + + def create + result = ::Subscriptions::ChargeFilters::CreateService.call( + subscription:, + charge:, + params: input_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::ChargeFilterSerializer.new( + result.charge_filter, + root_name: "filter" + ) + ) + else + render_error_response(result) + end + end + + def update + result = ::Subscriptions::ChargeFilters::UpdateOrOverrideService.call( + subscription:, + charge:, + charge_filter:, + params: input_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::ChargeFilterSerializer.new( + result.charge_filter, + root_name: "filter" + ) + ) + else + render_error_response(result) + end + end + + def destroy + result = ::Subscriptions::ChargeFilters::DestroyService.call( + subscription:, + charge:, + charge_filter: + ) + + if result.success? + render( + json: ::V1::ChargeFilterSerializer.new( + result.charge_filter, + root_name: "filter" + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :charge, :charge_filter + + def resource_name + "subscription" + end + + def input_params + params.require(:filter).permit( + :invoice_display_name, + properties: {}, + values: {} + ) + end + + def find_charge + @charge = subscription.plan.charges.find_by(code: params[:charge_code]) + not_found_error(resource: "charge") unless @charge + end + + def find_charge_filter + @charge_filter = charge.filters.find_by(id: params[:id]) + not_found_error(resource: "charge_filter") unless @charge_filter + end + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/charges_controller.rb b/app/controllers/api/v1/subscriptions/charges_controller.rb new file mode 100644 index 0000000..7217112 --- /dev/null +++ b/app/controllers/api/v1/subscriptions/charges_controller.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + class ChargesController < BaseController + before_action :find_charge, only: %i[show update] + + def index + charges = subscription.plan.charges + .includes(:billable_metric, :taxes, :applied_pricing_unit, :pricing_unit, filters: :billable_metric_filters) + .order(created_at: :desc) + .page(params[:page]) + .per(params[:per_page] || PER_PAGE) + + render( + json: ::CollectionSerializer.new( + charges, + ::V1::ChargeSerializer, + collection_name: "charges", + meta: pagination_metadata(charges), + includes: %i[taxes] + ) + ) + end + + def show + render( + json: ::V1::ChargeSerializer.new( + charge, + root_name: "charge", + includes: %i[taxes] + ) + ) + end + + def update + result = ::Subscriptions::UpdateOrOverrideChargeService.call( + subscription:, + charge:, + params: input_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::ChargeSerializer.new( + result.charge, + root_name: "charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :charge + + def resource_name + "subscription" + end + + def input_params + params.require(:charge).permit( + :invoice_display_name, + :min_amount_cents, + properties: {}, + filters: [ + :invoice_display_name, + { + properties: {}, + values: {} + } + ], + tax_codes: [], + applied_pricing_unit: %i[code conversion_rate] + ) + end + + def find_charge + @charge = subscription.plan.charges.find_by(code: params[:code]) + not_found_error(resource: "charge") unless @charge + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/entitlements/privileges_controller.rb b/app/controllers/api/v1/subscriptions/entitlements/privileges_controller.rb new file mode 100644 index 0000000..70823ce --- /dev/null +++ b/app/controllers/api/v1/subscriptions/entitlements/privileges_controller.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + module Entitlements + class PrivilegesController < Api::BaseController + before_action :find_subscription + + def destroy + result = ::Entitlement::SubscriptionFeaturePrivilegeRemoveService.call( + subscription:, + feature_code: params[:entitlement_code], + privilege_code: params[:code] + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + Entitlement::SubscriptionEntitlement.for_subscription(subscription), + ::V1::Entitlement::SubscriptionEntitlementSerializer, + collection_name: "entitlements" + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :subscription + + def resource_name + "subscription" + end + + def find_subscription + @subscription = current_organization.subscriptions + .order("terminated_at DESC NULLS FIRST, started_at DESC") # TODO: Confirm + .find_by!( + external_id: params[:subscription_external_id], + # we're keeping both `subscription_status` and `status` for backward compatibility, + # but we should rely more on the `subscription_status` + status: params[:subscription_status] || params[:status] || :active + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "subscription") + end + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/entitlements_controller.rb b/app/controllers/api/v1/subscriptions/entitlements_controller.rb new file mode 100644 index 0000000..007a28a --- /dev/null +++ b/app/controllers/api/v1/subscriptions/entitlements_controller.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + class EntitlementsController < Api::BaseController + # Shall we update BaseController to include hae active subscriptions only? + before_action :find_subscription + + def index + render( + json: ::CollectionSerializer.new( + Entitlement::SubscriptionEntitlement.for_subscription(subscription), + ::V1::Entitlement::SubscriptionEntitlementSerializer, + collection_name: "entitlements" + ) + ) + end + + def update + result = ::Entitlement::SubscriptionEntitlementsUpdateService.call( + subscription: subscription, + entitlements_params: update_params, + partial: true + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + Entitlement::SubscriptionEntitlement.for_subscription(subscription), + ::V1::Entitlement::SubscriptionEntitlementSerializer, + collection_name: "entitlements" + ) + ) + else + render_error_response(result) + end + end + + def destroy + result = ::Entitlement::SubscriptionFeatureRemoveService.call(subscription:, feature_code: params[:code]) + + if result.success? + render( + json: ::CollectionSerializer.new( + Entitlement::SubscriptionEntitlement.for_subscription(subscription), + ::V1::Entitlement::SubscriptionEntitlementSerializer, + collection_name: "entitlements" + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :subscription + + def resource_name + "subscription" + end + + def update_params + params.fetch(:entitlements, {}).permit! + end + + def find_subscription + @subscription = current_organization.subscriptions + .order("terminated_at DESC NULLS FIRST, started_at DESC") # TODO: Confirm + .find_by!( + external_id: params[:subscription_external_id], + # we're keeping both `subscription_status` and `status` for backward compatibility, + # but we should rely more on the `subscription_status` + status: params[:subscription_status] || params[:status] || :active + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "subscription") + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/fixed_charges_controller.rb b/app/controllers/api/v1/subscriptions/fixed_charges_controller.rb new file mode 100644 index 0000000..762e53d --- /dev/null +++ b/app/controllers/api/v1/subscriptions/fixed_charges_controller.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + class FixedChargesController < BaseController + before_action :find_fixed_charge, only: %i[show update] + + def index + fixed_charges = subscription.plan.fixed_charges + .includes(:add_on, :taxes) + .order(created_at: :desc) + .page(params[:page]) + .per(params[:per_page] || PER_PAGE) + + render( + json: ::CollectionSerializer.new( + fixed_charges, + ::V1::FixedChargeSerializer, + collection_name: "fixed_charges", + meta: pagination_metadata(fixed_charges), + includes: %i[taxes] + ) + ) + end + + def show + render( + json: ::V1::FixedChargeSerializer.new( + fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + end + + def update + result = ::Subscriptions::UpdateOrOverrideFixedChargeService.call( + subscription:, + fixed_charge:, + params: input_params.to_h.deep_symbolize_keys + ) + + if result.success? + render( + json: ::V1::FixedChargeSerializer.new( + result.fixed_charge, + root_name: "fixed_charge", + includes: %i[taxes] + ) + ) + else + render_error_response(result) + end + end + + private + + attr_reader :fixed_charge + + def resource_name + "subscription" + end + + def input_params + params.require(:fixed_charge).permit( + :invoice_display_name, + :units, + :apply_units_immediately, + properties: {}, + tax_codes: [] + ) + end + + def find_fixed_charge + @fixed_charge = subscription.plan.fixed_charges.find_by(code: params[:code]) + not_found_error(resource: "fixed_charge") unless @fixed_charge + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions/lifetime_usages_controller.rb b/app/controllers/api/v1/subscriptions/lifetime_usages_controller.rb new file mode 100644 index 0000000..b89fde5 --- /dev/null +++ b/app/controllers/api/v1/subscriptions/lifetime_usages_controller.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Api + module V1 + module Subscriptions + class LifetimeUsagesController < Api::BaseController + def show + # Note: lifetime usage is counted against all sub upgrades-downgrades + lifetime_usage = current_organization.subscriptions + .find_by(external_id: params[:subscription_external_id])&.lifetime_usage + + return not_found_error(resource: "lifetime_usage") unless lifetime_usage + render_lifetime_usage lifetime_usage + end + + def update + lifetime_usage = current_organization.subscriptions + .find_by(external_id: params[:subscription_external_id])&.lifetime_usage + + result = LifetimeUsages::UpdateService.call( + lifetime_usage:, + params: update_params.to_h + ) + if result.success? + render_lifetime_usage lifetime_usage + else + render_error_response(result) + end + end + + private + + def update_params + params.require(:lifetime_usage).permit( + :external_historical_usage_amount_cents + ) + end + + def render_lifetime_usage(lifetime_usage) + render( + json: ::V1::LifetimeUsageSerializer.new( + lifetime_usage, + root_name: "lifetime_usage", + includes: %i[usage_thresholds] + ) + ) + end + + def resource_name + "lifetime_usage" + end + end + end + end +end diff --git a/app/controllers/api/v1/subscriptions_controller.rb b/app/controllers/api/v1/subscriptions_controller.rb new file mode 100644 index 0000000..dc1b5fc --- /dev/null +++ b/app/controllers/api/v1/subscriptions_controller.rb @@ -0,0 +1,296 @@ +# frozen_string_literal: true + +module Api + module V1 + class SubscriptionsController < Api::BaseController + include SubscriptionIndex + + def create + response = {} + billing_entity_result = BillingEntities::ResolveService.call( + organization: current_organization, + billing_entity_code: params.dig(:subscription, :billing_entity_code) + ) + return render_error_response(billing_entity_result) unless billing_entity_result.success? + billing_entity = billing_entity_result.billing_entity + + customer = Customer.find_or_initialize_by( + external_id: create_params[:external_customer_id].to_s.strip, + organization_id: current_organization.id + ) + customer.billing_entity ||= billing_entity + + if params[:authorization] && !current_organization.beta_payment_authorization_enabled? + return render( + json: { + status: 403, + error: "Forbidden", + code: "feature_not_available", + message: "Payment authorization (beta_payment_authorization) is not available for this organization" + }, + status: :forbidden + ) + end + + if params[:authorization] + unless customer.payment_provider&.to_sym == :stripe + return render( + json: { + status: 422, + error: "Unprocessable Entity", + code: "stripe_required", + message: "Only Stripe is supported for authorization" + }, + status: :unprocessable_content + ) + end + + payment_method_params = create_params[:payment_method] || {} + payment_method = if payment_method_params[:payment_method_id].present? + customer.payment_methods.find(payment_method_params[:payment_method_id]) + else + customer.default_payment_method + end + + result = PaymentProviders::Stripe::Payments::AuthorizeService.call( + amount: params[:authorization].fetch(:amount_cents), + currency: params[:authorization].fetch(:amount_currency), + provider_customer: customer.provider_customer, + payment_method:, + metadata: {plan_code: create_params[:plan_code]}, + unique_id: request.request_id + ) + + if result.success? + response[:authorization] = result.stripe_payment_intent.to_hash.slice( + :id, :object, :amount, :amount_capturable, :status + ) + else + return render_error_response(result) + end + end + + plan = Plan.parents.find_by( + code: create_params[:plan_code], + organization_id: current_organization.id + ) + + result = ::Subscriptions::CreateService.call( + customer:, + plan:, + params: create_params.to_h + ) + + if result.success? + preload_subscription(result.subscription) + + response[:subscription] = ::V1::SubscriptionSerializer.new( + result.subscription, includes: %i[plan entitlements applicable_usage_thresholds applied_invoice_custom_sections] + ).serialize + + render(json: response) + else + render_error_response(result) + end + end + + # NOTE: We can't destroy a subscription, it will terminate it + def terminate + query = current_organization.subscriptions.where(external_id: params[:external_id]) + subscription = if params[:status] == "pending" + query.pending + else + query.active + end.first + + kwargs = params.permit(:on_termination_credit_note, :on_termination_invoice).to_h.symbolize_keys + + result = ::Subscriptions::TerminateService.call(subscription:, **kwargs) + + if result.success? + render_subscription(result.subscription) + else + render_error_response(result) + end + end + + def update + query = current_organization.subscriptions + .where(external_id: params[:external_id]) + .order(subscription_at: :desc) + subscription = if query.count > 1 + if params[:status] == "pending" + query.pending + elsif params[:status] == "incomplete" + query.incomplete + else + query.active + end + else + query + end.first + + result = ::Subscriptions::UpdateService.call( + subscription:, + params: update_params.to_h + ) + + if result.success? + render_subscription(result.subscription, includes: %i[plan applicable_usage_thresholds applied_invoice_custom_sections]) + else + render_error_response(result) + end + end + + def show + subscription = current_organization.subscriptions + .order("terminated_at DESC NULLS FIRST, started_at DESC") + .find_by( + external_id: params[:external_id], + status: params[:status] || :active + ) + return not_found_error(resource: "subscription") unless subscription + + render_subscription(subscription, includes: %i[plan entitlements applicable_usage_thresholds applied_invoice_custom_sections]) + end + + def index + permitted_params = params.permit(:external_customer_id) + external_customer_id = permitted_params[:external_customer_id] + subscription_index(external_customer_id:) + end + + private + + def create_params + params.require(:subscription) + .permit( + :external_customer_id, + :plan_code, + :billing_entity_code, + :billing_entity_id, + :name, + :external_id, + :billing_time, + :subscription_at, + :ending_at, + :progressive_billing_disabled, + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ], + activation_rules: [:type, :timeout_hours], + payment_method: [ + :payment_method_type, + :payment_method_id + ], + usage_thresholds: usage_thresholds_params, + plan_overrides: + ) + end + + def update_params + params.require(:subscription).permit( + :name, + :subscription_at, + :ending_at, + :on_termination_credit_note, + :on_termination_invoice, + :progressive_billing_disabled, + activation_rules: [:type, :timeout_hours], + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ], + payment_method: [ + :payment_method_type, + :payment_method_id + ], + usage_thresholds: usage_thresholds_params, + plan_overrides: + ) + end + + def usage_thresholds_params + [ + :amount_cents, + :threshold_display_name, + :recurring + ] + end + + def plan_overrides + [ + :amount_cents, + :amount_currency, + :description, + :name, + :invoice_display_name, + :trial_period, + tax_codes: [], + minimum_commitment: [ + :invoice_display_name, + :amount_cents, + tax_codes: [] + ], + charges: [ + :id, + :billable_metric_id, + :code, + :min_amount_cents, + :invoice_display_name, + :charge_model, + properties: {}, + filters: [ + :invoice_display_name, + properties: {}, + values: {} + ], + tax_codes: [], + applied_pricing_unit: [ + :code, + :conversion_rate + ] + ], + fixed_charges: [ + :id, + :invoice_display_name, + :units, + :apply_units_immediately, + properties: {}, + tax_codes: [] + ], + usage_thresholds: [ + :id, + :threshold_display_name, + :amount_cents, + :recurring + ] + ] + end + + def render_subscription(subscription, includes: %i[plan applied_invoice_custom_sections]) + preload_subscription(subscription) + + render( + json: ::V1::SubscriptionSerializer.new( + subscription, + root_name: "subscription", + includes: + ) + ) + end + + def preload_subscription(subscription) + ActiveRecord::Associations::Preloader.new( + records: [subscription], + associations: [:plan, :customer, {previous_subscription: :plan, next_subscriptions: :plan}] + ).call + end + + def resource_name + "subscription" + end + end + end +end diff --git a/app/controllers/api/v1/taxes_controller.rb b/app/controllers/api/v1/taxes_controller.rb new file mode 100644 index 0000000..0acdbdb --- /dev/null +++ b/app/controllers/api/v1/taxes_controller.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Api + module V1 + class TaxesController < Api::BaseController + def create + result = Taxes::CreateService.call(organization: current_organization, params: input_params) + + if result.success? + render_tax(result.tax) + else + render_error_response(result) + end + end + + def update + tax = current_organization.taxes.find_by(code: params[:code]) + result = Taxes::UpdateService.call(tax:, params: input_params) + + if result.success? + render_tax(result.tax) + else + render_error_response(result) + end + end + + def destroy + tax = current_organization.taxes.find_by(code: params[:code]) + result = Taxes::DestroyService.call(tax:) + + if result.success? + render_tax(result.tax) + else + render_error_response(result) + end + end + + def show + tax = current_organization.taxes.find_by(code: params[:code]) + return not_found_error(resource: "tax") unless tax + + render_tax(tax) + end + + def index + result = TaxesQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.taxes, + ::V1::TaxSerializer, + collection_name: "taxes", + meta: pagination_metadata(result.taxes) + ) + ) + else + render_error_response(result) + end + end + + private + + def input_params + params.require(:tax).permit(:code, :description, :name, :rate, :applied_to_organization) + end + + def render_tax(tax) + render(json: ::V1::TaxSerializer.new(tax, root_name: "tax")) + end + + def resource_name + "tax" + end + end + end +end diff --git a/app/controllers/api/v1/wallet_transactions_controller.rb b/app/controllers/api/v1/wallet_transactions_controller.rb new file mode 100644 index 0000000..f85ef81 --- /dev/null +++ b/app/controllers/api/v1/wallet_transactions_controller.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +module Api + module V1 + class WalletTransactionsController < Api::BaseController + def create + result = WalletTransactions::CreateFromParamsService.call( + organization: current_organization, + params: input_params + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.wallet_transactions, + ::V1::WalletTransactionSerializer, + collection_name: "wallet_transactions" + ) + ) + else + render_error_response(result) + end + end + + def index + result = WalletTransactionsQuery.call( + organization: current_organization, + wallet_id: params[:id], + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: { + status: params[:status], + transaction_type: params[:transaction_type], + transaction_status: params[:transaction_status] + } + ) + + return render_error_response(result) unless result.success? + + render( + json: ::CollectionSerializer.new( + result.wallet_transactions, + ::V1::WalletTransactionSerializer, + collection_name: "wallet_transactions", + meta: pagination_metadata(result.wallet_transactions) + ) + ) + end + + def show + wallet_transaction = current_organization.wallet_transactions.find_by( + id: params[:id] + ) + + return not_found_error(resource: "wallet_transaction") unless wallet_transaction + + render( + json: ::V1::WalletTransactionSerializer.new( + wallet_transaction, + root_name: "wallet_transaction", + includes: %i[applied_invoice_custom_sections] + ) + ) + end + + def payment_url + wallet_transaction = current_organization.wallet_transactions.find_by(id: params[:id]) + result = ::WalletTransactions::Payments::GeneratePaymentUrlService.call(wallet_transaction:) + + if result.success? + render( + json: ::V1::PaymentProviders::WalletTransactionPaymentSerializer.new( + wallet_transaction, + root_name: "wallet_transaction_payment_details", + payment_url: result.payment_url + ) + ) + else + render_error_response(result) + end + end + + def consumptions + wallet_transaction_consumptions(direction: :consumptions) + end + + def fundings + wallet_transaction_consumptions(direction: :fundings) + end + + private + + def wallet_transaction_consumptions(direction:) + result = WalletTransactionConsumptionsQuery.call( + organization: current_organization, + pagination: {page: params[:page], limit: params[:per_page] || PER_PAGE}, + filters: { + wallet_transaction_id: params[:id], + direction: direction.to_s + } + ) + + return render_error_response(result) unless result.success? + + includes = (direction == :consumptions) ? %i[outbound_wallet_transaction] : %i[inbound_wallet_transaction] + collection_name = (direction == :consumptions) ? "wallet_transaction_consumptions" : "wallet_transaction_fundings" + + render( + json: ::CollectionSerializer.new( + result.wallet_transaction_consumptions.includes(includes), + ::V1::WalletTransactionConsumptionSerializer, + collection_name:, + meta: pagination_metadata(result.wallet_transaction_consumptions), + includes: + ) + ) + end + + def input_params + @input_params ||= params.require(:wallet_transaction).permit( + :wallet_id, + :paid_credits, + :granted_credits, + :voided_credits, + :invoice_requires_successful_payment, + :name, + :ignore_paid_top_up_limits, + :priority, + payment_method: [ + :payment_method_type, + :payment_method_id + ], + metadata: [ + :key, + :value + ], + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ] + ) + end + + def resource_name + "wallet_transaction" + end + end + end +end diff --git a/app/controllers/api/v1/wallets/base_controller.rb b/app/controllers/api/v1/wallets/base_controller.rb new file mode 100644 index 0000000..2ea2c29 --- /dev/null +++ b/app/controllers/api/v1/wallets/base_controller.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Api + module V1 + module Wallets + class BaseController < Api::BaseController + before_action :find_wallet + + private + + attr_reader :wallet + + def find_wallet + @wallet = current_organization.wallets.find_by!(id: params[:wallet_id]) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "wallet") + end + + def resource_name + "wallet" + end + end + end + end +end diff --git a/app/controllers/api/v1/wallets/metadata_controller.rb b/app/controllers/api/v1/wallets/metadata_controller.rb new file mode 100644 index 0000000..564a67f --- /dev/null +++ b/app/controllers/api/v1/wallets/metadata_controller.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Api + module V1 + module Wallets + class MetadataController < BaseController + include WalletMetadataActions + + def create + metadata_create(wallet) + end + + def update + metadata_update(wallet) + end + + def destroy + metadata_destroy(wallet) + end + + def destroy_key + metadata_destroy_key(wallet) + end + end + end + end +end diff --git a/app/controllers/api/v1/wallets_controller.rb b/app/controllers/api/v1/wallets_controller.rb new file mode 100644 index 0000000..70c0ba5 --- /dev/null +++ b/app/controllers/api/v1/wallets_controller.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Api + module V1 + class WalletsController < Api::BaseController + include WalletActions + + def create + wallet_create(customer) + end + + def update + wallet = current_organization.wallets.find_by(id: params[:id]) + + wallet_update(wallet) + end + + def terminate + wallet = current_organization.wallets.find_by(id: params[:id]) + + wallet_terminate(wallet) + end + + def show + wallet = current_organization.wallets.find_by(id: params[:id]) + + wallet_show(wallet) + end + + def index + permitted_params = params.permit(:external_customer_id, :currency) + external_customer_id = permitted_params[:external_customer_id] + currency = permitted_params[:currency] + + wallet_index(external_customer_id:, currency:) + end + + private + + def customer_params + params.require(:wallet).permit(:external_customer_id) + end + + def customer + Customer.find_by(external_id: customer_params[:external_customer_id], organization_id: current_organization.id) + end + + def resource_name + "wallet" + end + end + end +end diff --git a/app/controllers/api/v1/webhook_endpoints_controller.rb b/app/controllers/api/v1/webhook_endpoints_controller.rb new file mode 100644 index 0000000..a9071b2 --- /dev/null +++ b/app/controllers/api/v1/webhook_endpoints_controller.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Api + module V1 + class WebhookEndpointsController < Api::BaseController + def create + service = ::WebhookEndpoints::CreateService.new( + organization: current_organization, + params: create_params + ) + + result = service.call + + return render_webhook_endpoint(result.webhook_endpoint) if result.success? + + render_error_response(result) + end + + def update + service = ::WebhookEndpoints::UpdateService.new( + id: params[:id], + organization: current_organization, + params: update_params + ) + + result = service.call + + return render_webhook_endpoint(result.webhook_endpoint) if result.success? + + render_error_response(result) + end + + def index + result = WebhookEndpointsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.webhook_endpoints, + ::V1::WebhookEndpointSerializer, + collection_name: "webhook_endpoints", + meta: pagination_metadata(result.webhook_endpoints) + ) + ) + else + render_error_response(result) + end + end + + def show + webhook_endpoint = current_organization.webhook_endpoints.find_by(id: params[:id]) + + return not_found_error(resource: "webhook_endpoint") unless webhook_endpoint + + render_webhook_endpoint(webhook_endpoint) + end + + def destroy + webhook_endpoint = current_organization.webhook_endpoints.find_by(id: params[:id]) + result = ::WebhookEndpoints::DestroyService.call(webhook_endpoint:) + + if result.success? + render_webhook_endpoint(result.webhook_endpoint) + else + render_error_response(result) + end + end + + private + + def create_params + webhook_endpoint_params + end + + def update_params + webhook_endpoint_params.except(:id) + end + + def webhook_endpoint_params + raw_options = params.require(:webhook_endpoint) + permitted_options = raw_options.permit( + :id, + :webhook_url, + :signature_algo, + :name, + event_types: [] + ) + + # preserve event_types non-array value if it was explicitly provided + # invalid values will be handled in the model validation + if raw_options.has_key?(:event_types) && !permitted_options.has_key?(:event_types) + permitted_options[:event_types] = raw_options[:event_types] + end + + permitted_options + end + + def render_webhook_endpoint(webhook_endpoint) + render( + json: ::V1::WebhookEndpointSerializer.new( + webhook_endpoint, + root_name: "webhook_endpoint" + ) + ) + end + + def resource_name + "webhook_endpoint" + end + end + end +end diff --git a/app/controllers/api/v1/webhooks_controller.rb b/app/controllers/api/v1/webhooks_controller.rb new file mode 100644 index 0000000..9c628e7 --- /dev/null +++ b/app/controllers/api/v1/webhooks_controller.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Api + module V1 + class WebhooksController < Api::BaseController + # Deprecated method + def public_key + render(plain: Base64.encode64(RsaPublicKey.to_s)) + end + + def json_public_key + render( + json: { + webhook: { + public_key: Base64.encode64(RsaPublicKey.to_s) + } + }, + status: :ok + ) + end + + private + + def resource_name + "webhook_jwt_public_key" + end + end + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb new file mode 100644 index 0000000..c0a80b9 --- /dev/null +++ b/app/controllers/application_controller.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class ApplicationController < ActionController::API + wrap_parameters false + + include ApiResponses + + rescue_from ActionController::RoutingError, with: :not_found + rescue_from ActiveRecord::RecordNotFound, with: :not_found + + def health + ActiveRecord::Base.connection.execute("") + render( + json: { + version: LAGO_VERSION.number, + github_url: LAGO_VERSION.github_url, + message: "Success" + }, + status: :ok + ) + rescue ActiveRecord::ActiveRecordError => e + render( + json: { + version: LAGO_VERSION.number, + github_url: LAGO_VERSION.github_url, + message: "Unhealthy", + details: e.message + }, + status: :internal_server_error + ) + end + + def ready + if $shutdown_requested # rubocop:disable Style/GlobalVars + render json: {status: "shutting down"}, status: :service_unavailable + else + render json: {status: "ok"} + end + end + + def not_found + not_found_error(resource: "resource") + end + + def append_info_to_payload(payload) + super + payload[:level] = + case payload[:status].to_i + when 200..299 + "info" + when 400..499 + "warn" + else + "error" + end + payload[:organization_id] = current_organization&.id if defined? current_organization + rescue + # NOTE: Rescue potential errors on JWT token, it should break later to avoid bad responses on GraphQL + end +end diff --git a/app/controllers/concerns/api_errors.rb b/app/controllers/concerns/api_errors.rb new file mode 100644 index 0000000..7b32f35 --- /dev/null +++ b/app/controllers/concerns/api_errors.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module ApiErrors + extend ActiveSupport::Concern + + def bad_request_error(error) + render( + json: { + status: 400, + error: "BadRequest: #{error.message}" + }, + status: :bad_request + ) + end + + def unauthorized_error(message: "Unauthorized") + render( + json: { + status: 401, + error: message + }, + status: :unauthorized + ) + end + + def validation_errors(errors:) + render( + json: { + status: 422, + error: "Unprocessable Entity", + code: "validation_errors", + error_details: errors + }, + status: :unprocessable_content + ) + end + + def forbidden_error(code:) + render( + json: { + status: 403, + error: "Forbidden", + code: + }, + status: :forbidden + ) + end + + def method_not_allowed_error(code:) + render( + json: { + status: 405, + error: "Method Not Allowed", + code: + }, + status: :method_not_allowed + ) + end + + def provider_error(provider, error) + response = { + status: 422, + error: "Unprocessable Entity", + code: "provider_error", + provider: { + code: provider.code + }, + error_details: V1::Errors::ErrorSerializerFactory.new_instance(error).serialize + } + + render json: response, status: :unprocessable_content + end + + def thirdpary_error(error:) + render( + json: { + status: 422, + error: "Unprocessable Entity", + code: "third_party_error", + error_details: { + third_party: error.third_party, + thirdparty_error: error.error_message + } + }, + status: :unprocessable_content + ) + end + + def too_many_provider_requests_error(error:) + render( + json: { + status: 429, + error: "Too Many Provider Requests", + code: "too_many_provider_requests", + error_details: { + provider_name: error.provider_name, + message: error.message + } + }, + status: :too_many_requests + ) + end + + def render_error_response(error_result) + case error_result.error + when BaseService::NotFoundFailure + not_found_error(resource: error_result.error.resource) + when BaseService::MethodNotAllowedFailure + method_not_allowed_error(code: error_result.error.code) + when BaseService::ValidationFailure + validation_errors(errors: error_result.error.messages) + when BaseService::ForbiddenFailure + forbidden_error(code: error_result.error.code) + when BaseService::UnauthorizedFailure + unauthorized_error(message: error_result.error.message) + when BaseService::ProviderFailure + provider_error(error_result.error.provider, error_result.error.original_error) + when BaseService::TooManyProviderRequestsFailure + too_many_provider_requests_error(error: error_result.error) + when BaseService::ThirdPartyFailure + thirdpary_error(error: error_result.error) + else + raise(error_result.error) + end + end +end diff --git a/app/controllers/concerns/api_loggable.rb b/app/controllers/concerns/api_loggable.rb new file mode 100644 index 0000000..2c97742 --- /dev/null +++ b/app/controllers/concerns/api_loggable.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ApiLoggable + extend ActiveSupport::Concern + + included do + around_action :produce_api_log, if: :produce_api_log? + + # rubocop:disable ThreadSafety/ClassAndModuleAttributes + class_attribute :skip_audit_logs, instance_writer: false, default: false + # rubocop:enable ThreadSafety/ClassAndModuleAttributes + end + + module ClassMethods + def skip_audit_logs! + self.skip_audit_logs = true + end + + def skip_audit_logs? + skip_audit_logs + end + end + + def produce_api_log? + !(request.get? || skip_audit_logs?) + end + + private + + def produce_api_log + yield + + begin + Utils::ApiLog.produce(request, response, organization: current_organization) + rescue => e + Sentry.capture_exception(e) if ENV["SENTRY_DSN"].present? + Rails.logger.error("[Audit Logs] Failed to produce API log: #{e.class} - #{e.message}") + Rails.logger.error { e.backtrace.join("\n") } + ensure + request.body.rewind + end + end +end diff --git a/app/controllers/concerns/api_responses.rb b/app/controllers/concerns/api_responses.rb new file mode 100644 index 0000000..3b85cbc --- /dev/null +++ b/app/controllers/concerns/api_responses.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ApiResponses + extend ActiveSupport::Concern + + included do + before_action :set_json_format + end + + def not_found_error(resource:) + render( + json: { + status: 404, + error: "Not Found", + code: "#{resource}_not_found" + }, + status: :not_found + ) + end + + protected + + def set_json_format + request.format = :json + end +end diff --git a/app/controllers/concerns/applied_coupon_index.rb b/app/controllers/concerns/applied_coupon_index.rb new file mode 100644 index 0000000..74c36b9 --- /dev/null +++ b/app/controllers/concerns/applied_coupon_index.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module AppliedCouponIndex + include Pagination + extend ActiveSupport::Concern + + def applied_coupon_index(external_customer_id:) + filters = params.permit(:status, coupon_code: []) + filters[:external_customer_id] = external_customer_id + result = AppliedCouponsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.applied_coupons.includes(:credits, :coupon, :customer), + ::V1::AppliedCouponSerializer, + collection_name: "applied_coupons", + meta: pagination_metadata(result.applied_coupons), + includes: %i[credits] + ) + ) + else + render_error_response(result) + end + end +end diff --git a/app/controllers/concerns/authenticable_user.rb b/app/controllers/concerns/authenticable_user.rb new file mode 100644 index 0000000..829924f --- /dev/null +++ b/app/controllers/concerns/authenticable_user.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module AuthenticableUser + extend ActiveSupport::Concern + + included do + before_action :renew_token, if: :token_near_expiration? + end + + private + + def current_user + @current_user ||= User.find_by(id: decoded_token["sub"]) if token && decoded_token + end + + def current_organization + return unless organization_header + return unless current_user + + @current_organization ||= current_membership&.organization + end + + def current_membership + return unless current_user + + @current_membership ||= current_user.memberships.active.find_by(organization_id: organization_header) + end + + def organization_header + request.headers["x-lago-organization"] + end + + def login_method + @login_method ||= decoded_token["login_method"] if token && decoded_token + end + + def token + @token ||= request.headers["Authorization"].to_s.split(" ").last + end + + def decoded_token + @decoded_token ||= Auth::TokenService.decode(token:) + rescue JWT::DecodeError => e + raise e if e.is_a?(JWT::ExpiredSignature) || Rails.env.development? + end + + def token_near_expiration? + return false unless token && decoded_token + + # NOTE: we consider the token is near expiration if it expires in less than 1 hour + Time.now.to_i > decoded_token["exp"] - 1.hour.to_i + end + + def renew_token + return unless current_user + + renewed = Auth::TokenService.renew(token:) + response.set_header(Auth::TokenService::LAGO_TOKEN_HEADER, renewed) if renewed.present? + rescue => e + Rails.logger.warn("Error renewing token: #{e.message}") + end +end diff --git a/app/controllers/concerns/common.rb b/app/controllers/concerns/common.rb new file mode 100644 index 0000000..a5a7530 --- /dev/null +++ b/app/controllers/concerns/common.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Common + extend ActiveSupport::Concern + + private + + def valid_date?(date) + return false unless date + + Date.iso8601(date) + true + rescue Date::Error + false + end +end diff --git a/app/controllers/concerns/credit_note_index.rb b/app/controllers/concerns/credit_note_index.rb new file mode 100644 index 0000000..a062673 --- /dev/null +++ b/app/controllers/concerns/credit_note_index.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module CreditNoteIndex + include Pagination + extend ActiveSupport::Concern + + def credit_note_index(external_customer_id:) + billing_entities = current_organization.billing_entities.where(code: params[:billing_entity_codes]) if params[:billing_entity_codes].present? + return not_found_error(resource: "billing_entity") if params[:billing_entity_codes].present? && billing_entities.count != params[:billing_entity_codes].count + + includes = [ + :applied_taxes, + :error_details, + :metadata, + invoice: :billing_entity, + file_attachment: :blob, + xml_file_attachment: :blob, + items: { + fee: [ + :charge_filter, + :charge, + :billable_metric, + :invoice, + :pricing_unit_usage, + :true_up_fee, + {subscription: :plan}, + :customer + ] + }, + customer: [:billing_entity, :metadata, :stripe_customer, :gocardless_customer, :cashfree_customer, :adyen_customer, :moneyhash_customer, :integration_customers] + ] + + result = CreditNotesQuery.call( + organization: current_organization, + includes: includes, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + search_term: params[:search_term], + filters: { + customer_external_id: external_customer_id, + amount_from: params[:amount_from], + amount_to: params[:amount_to], + billing_entity_ids: billing_entities&.ids, + credit_status: params[:credit_status], + currency: params[:currency], + invoice_number: params[:invoice_number], + issuing_date_from: (Date.iso8601(params[:issuing_date_from]) if valid_date?(params[:issuing_date_from])), + issuing_date_to: (Date.iso8601(params[:issuing_date_to]) if valid_date?(params[:issuing_date_to])), + reason: params[:reason], + refund_status: params[:refund_status], + self_billed: params[:self_billed], + types: params[:types] + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.credit_notes, + ::V1::CreditNoteSerializer, + collection_name: "credit_notes", + meta: pagination_metadata(result.credit_notes), + includes: [:items, :applied_taxes, :error_details, {customer: [:integration_customers]}] + ) + ) + else + render_error_response(result) + end + end +end diff --git a/app/controllers/concerns/customer_portal_user.rb b/app/controllers/concerns/customer_portal_user.rb new file mode 100644 index 0000000..cfc6174 --- /dev/null +++ b/app/controllers/concerns/customer_portal_user.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module CustomerPortalUser + extend ActiveSupport::Concern + + def customer_portal_user + return unless customer_portal_token + + public_authenticator = ActiveSupport::MessageVerifier.new(ENV["SECRET_KEY_BASE"]) + id = public_authenticator.verify(customer_portal_token) + + @customer_portal_user ||= Customer.find_by(id:) + rescue ActiveSupport::MessageVerifier::InvalidSignature + nil + end + + private + + def customer_portal_token + request.headers["customer-portal-token"] + end +end diff --git a/app/controllers/concerns/invoice_index.rb b/app/controllers/concerns/invoice_index.rb new file mode 100644 index 0000000..f924da8 --- /dev/null +++ b/app/controllers/concerns/invoice_index.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module InvoiceIndex + include Pagination + extend ActiveSupport::Concern + + WHITELIST = [ + :amount_from, + :amount_to, + :billing_entity_codes, + :currency, + :invoice_type, + :issuing_date_from, + :issuing_date_to, + :page, + :partially_paid, + :payment_dispute_lost, + :payment_overdue, + :payment_status, + :payment_statuses, + :per_page, + :search_term, + :self_billed, + :settlements, + :status, + :statuses, + metadata: {} + ].freeze + + def invoice_index(customer_external_id: nil) + billing_entities = current_organization.all_billing_entities.where(code: params[:billing_entity_codes]) if params[:billing_entity_codes].present? + return not_found_error(resource: "billing_entity") if params[:billing_entity_codes].present? && billing_entities.count != params[:billing_entity_codes].count + + result = InvoicesQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + search_term: params[:search_term], + filters: { + amount_from: params[:amount_from], + amount_to: params[:amount_to], + billing_entity_ids: billing_entities&.ids, + currency: params[:currency], + customer_external_id: customer_external_id, + invoice_type: params[:invoice_type], + issuing_date_from: (Date.iso8601(params[:issuing_date_from]) if valid_date?(params[:issuing_date_from])), + issuing_date_to: (Date.iso8601(params[:issuing_date_to]) if valid_date?(params[:issuing_date_to])), + metadata: params[:metadata]&.permit!.to_h, + partially_paid: params[:partially_paid], + payment_dispute_lost: params[:payment_dispute_lost], + payment_overdue: params[:payment_overdue], + payment_status: params[:payment_status] || params[:payment_statuses], + settlements: params[:settlements], + self_billed: params[:self_billed], + status: params[:status] || params[:statuses] + } + ) + + if result.success? + invoices = Invoice.preload_offset_amounts( + result.invoices.includes( + :metadata, + :applied_taxes, + :billing_entity, + :applied_usage_thresholds, + customer: [ + :billing_entity, + :metadata, + :stripe_customer, + :gocardless_customer, + :cashfree_customer, + :adyen_customer, + :moneyhash_customer, + {integration_customers: :integration} + ] + ) + ) + + render( + json: ::CollectionSerializer.new( + invoices, + ::V1::InvoiceSerializer, + collection_name: "invoices", + meta: pagination_metadata( + invoices, + key: "invoices", + organization_id: current_organization.id, + params: params.permit(*WHITELIST) + ), + includes: %i[customer integration_customers metadata applied_taxes] + ) + ) + else + render_error_response(result) + end + end +end diff --git a/app/controllers/concerns/pagination.rb b/app/controllers/concerns/pagination.rb new file mode 100644 index 0000000..0b52ab1 --- /dev/null +++ b/app/controllers/concerns/pagination.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Pagination + extend ActiveSupport::Concern + + # Default number of records per page + PER_PAGE = 100 + # The TTL for caching the number of records + DEFAULT_TTL = 30.minutes.freeze + + def pagination_metadata(records, **count_params) + current_page = prev_page = next_page = total_pages = nil + total_count = _count_total(**count_params) { records.total_count } + + if total_count.positive? + current_page = records.current_page + total_pages = _total_pages(records, total_count) + next_page = current_page + 1 if current_page < total_pages + prev_page = current_page - 1 if current_page > 1 + end + + { + "current_page" => current_page.to_i, + "next_page" => next_page, + "prev_page" => prev_page, + "total_pages" => total_pages.to_i, + "total_count" => total_count + } + end + + private + + # Computes total pages from the record count. + # For kaminari collections, derives it from limit_value to avoid an extra COUNT(*) query. + # For custom result objects (e.g. PastUsageQuery::Result), uses the precomputed value. + def _total_pages(records, total_count) + return records.total_pages unless records.respond_to?(:limit_value) + + (total_count.to_f / records.limit_value).ceil + end + + def _count_total(key: nil, organization_id: nil, params: nil, ttl: DEFAULT_TTL) + # backward-compatibility: skip caching if it is not requested explicitly + return yield unless key && organization_id && params + + page = (params[:page] || 1).to_i + per_page = (params[:per_page] || PER_PAGE).to_i + + # prepare the key for caching + hash_data = _deep_sort(params.except(:page).merge(organization_id:)) + hash = Digest::SHA256.hexdigest(hash_data.to_json) + cache_key = "pagination_count/#{key}/#{hash}" + + # Re-calculate on the last page because the number of records could have changed. + # If the count is small (less than the page size), caching is useless but re-querying is acceptable. + cached = Rails.cache.read(cache_key) + return cached if cached.to_i > per_page * page + + yield.tap { |count| Rails.cache.write(cache_key, count, expires_in: ttl) } + end + + # Recursively converts hashes into sorted arrays of pairs + # to ensure deterministic JSON serialization regardless of key order. + def _deep_sort(obj) + case obj + when Hash, ActionController::Parameters + obj.to_h.map { |k, v| [k.to_s, _deep_sort(v)] }.sort_by(&:first) + when Array then obj.map { |v| _deep_sort(v) } + else obj + end + end +end diff --git a/app/controllers/concerns/payment_index.rb b/app/controllers/concerns/payment_index.rb new file mode 100644 index 0000000..40c8205 --- /dev/null +++ b/app/controllers/concerns/payment_index.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module PaymentIndex + include Pagination + extend ActiveSupport::Concern + + def payment_index(customer_external_id: nil) + filters = params.permit(:invoice_id) + filters[:external_customer_id] = customer_external_id + result = PaymentsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.payments.includes( + :payment_provider_customer, + :payment_provider, + payable: :customer + ), + ::V1::PaymentSerializer, + collection_name: resource_name.pluralize, + meta: pagination_metadata(result.payments) + ) + ) + else + render_error_response(result) + end + end +end diff --git a/app/controllers/concerns/payment_method_index.rb b/app/controllers/concerns/payment_method_index.rb new file mode 100644 index 0000000..0025d85 --- /dev/null +++ b/app/controllers/concerns/payment_method_index.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentMethodIndex + include Pagination + extend ActiveSupport::Concern + + def payment_method_index(external_customer_id:) + result = PaymentMethodsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: { + external_customer_id: + } + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.payment_methods.preload(:customer, :payment_provider), + ::V1::PaymentMethodSerializer, + collection_name: "payment_methods", + meta: pagination_metadata(result.payment_methods) + ) + ) + else + render_error_response(result) + end + end +end diff --git a/app/controllers/concerns/payment_request_index.rb b/app/controllers/concerns/payment_request_index.rb new file mode 100644 index 0000000..da77240 --- /dev/null +++ b/app/controllers/concerns/payment_request_index.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module PaymentRequestIndex + include Pagination + extend ActiveSupport::Concern + + def payment_request_index(external_customer_id:) + filters = params.permit(:payment_status, :currency) + filters[:external_customer_id] = external_customer_id + result = PaymentRequestsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: filters + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.payment_requests.preload(:customer, :invoices), + ::V1::PaymentRequestSerializer, + collection_name: "payment_requests", + meta: pagination_metadata(result.payment_requests), + includes: %i[customer invoices] + ) + ) + else + render_error_response(result) + end + end +end diff --git a/app/controllers/concerns/premium_feature_only.rb b/app/controllers/concerns/premium_feature_only.rb new file mode 100644 index 0000000..6824e01 --- /dev/null +++ b/app/controllers/concerns/premium_feature_only.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PremiumFeatureOnly + extend ActiveSupport::Concern + + included do + before_action :ensure_premium_license + end + + private + + def ensure_premium_license + forbidden_error(code: "feature_unavailable") unless License.premium? + end +end diff --git a/app/controllers/concerns/subscription_index.rb b/app/controllers/concerns/subscription_index.rb new file mode 100644 index 0000000..27def63 --- /dev/null +++ b/app/controllers/concerns/subscription_index.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module SubscriptionIndex + include Pagination + extend ActiveSupport::Concern + + def subscription_index(external_customer_id: nil) + filters = params.permit(:plan_code, :overriden, :overridden, :currency, status: []) + filters[:status] = ["active"] if filters[:status].blank? + filters[:external_customer_id] = external_customer_id + result = SubscriptionsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: filters + ) + + if result.success? + subscriptions = result.subscriptions + .includes(:plan, previous_subscription: :plan, next_subscriptions: :plan, customer: :billing_entity) + + render( + json: ::CollectionSerializer.new( + subscriptions, + ::V1::SubscriptionSerializer, + collection_name: "subscriptions", + meta: pagination_metadata(subscriptions), + organization: current_organization + ) + ) + else + render_error_response(result) + end + end +end diff --git a/app/controllers/concerns/trackable.rb b/app/controllers/concerns/trackable.rb new file mode 100644 index 0000000..4bf9b4b --- /dev/null +++ b/app/controllers/concerns/trackable.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Trackable + extend ActiveSupport::Concern + + included do + before_action :set_tracing_information + end + + def set_tracing_information + CurrentContext.membership = "membership/#{membership_id || "unidentifiable"}" + end + + def membership_id + return nil unless current_organization + + # NOTE: When doing requests from the API, we haven't the current user information. + # In that case, we add tracing information on the first created membership of the organization. + return first_membership_id unless defined?(current_user) && current_user + + current_organization.memberships.find_by(user_id: current_user.id).id + end + + def first_membership_id + @first_membership_id ||= current_organization.memberships.order(:created_at).first&.id + end +end diff --git a/app/controllers/concerns/wallet_actions.rb b/app/controllers/concerns/wallet_actions.rb new file mode 100644 index 0000000..f7db705 --- /dev/null +++ b/app/controllers/concerns/wallet_actions.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +module WalletActions + include Pagination + extend ActiveSupport::Concern + + def wallet_create(customer) + result = ::Wallets::CreateService.call( + params: input_params + .merge(organization_id: current_organization.id) + .merge(customer:).to_h.deep_symbolize_keys + ) + + if result.success? + render_wallet(result.wallet) + else + render_error_response(result) + end + end + + def wallet_update(wallet) + result = ::Wallets::UpdateService.call( + wallet:, + params: update_params.merge(id: wallet&.id).to_h.deep_symbolize_keys + ) + + if result.success? + render_wallet(result.wallet) + else + render_error_response(result) + end + end + + def wallet_terminate(wallet) + result = ::Wallets::TerminateService.call(wallet:) + + if result.success? + render_wallet(result.wallet) + else + render_error_response(result) + end + end + + def wallet_show(wallet) + return not_found_error(resource: "wallet") unless wallet + + render_wallet(wallet) + end + + def wallet_index(external_customer_id:, currency:) + result = WalletsQuery.call( + organization: current_organization, + pagination: { + page: params[:page], + limit: params[:per_page] || PER_PAGE + }, + filters: {external_customer_id: external_customer_id, currency:} + ) + + if result.success? + render( + json: ::CollectionSerializer.new( + result.wallets.includes( + :customer, + :metadata, + :billable_metrics, + {applied_invoice_custom_sections: :invoice_custom_section}, + {recurring_transaction_rules: {applied_invoice_custom_sections: :invoice_custom_section}} + ), + ::V1::WalletSerializer, + collection_name: "wallets", + meta: pagination_metadata(result.wallets), + includes: %i[recurring_transaction_rules limitations applied_invoice_custom_sections] + ) + ) + else + render_error_response(result) + end + end + + private + + def input_params + params.require(:wallet).permit( + :rate_amount, + :name, + :code, + :priority, + :currency, + :paid_credits, + :granted_credits, + :expiration_at, + :invoice_requires_successful_payment, + :paid_top_up_min_amount_cents, + :paid_top_up_max_amount_cents, + :ignore_paid_top_up_limits_on_creation, + :transaction_name, + :transaction_priority, + :billing_entity_code, + :billing_entity_id, + metadata: {}, + transaction_metadata: [ + :key, + :value + ], + recurring_transaction_rules: [ + :granted_credits, + :interval, + :method, + :paid_credits, + :started_at, + :expiration_at, + :target_ongoing_balance, + :threshold_credits, + :trigger, + :invoice_requires_successful_payment, + :ignore_paid_top_up_limits, + :transaction_name, + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ], + transaction_metadata: [ + :key, + :value + ], + payment_method: [ + :payment_method_type, + :payment_method_id + ] + ], + applies_to: [ + fee_types: [], + billable_metric_codes: [] + ], + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ], + payment_method: [ + :payment_method_type, + :payment_method_id + ] + ) + end + + def update_params + params.require(:wallet).permit( + :name, + :code, + :priority, + :expiration_at, + :invoice_requires_successful_payment, + :paid_top_up_min_amount_cents, + :paid_top_up_max_amount_cents, + metadata: {}, + recurring_transaction_rules: [ + :lago_id, + :interval, + :method, + :started_at, + :expiration_at, + :target_ongoing_balance, + :threshold_credits, + :trigger, + :paid_credits, + :granted_credits, + :invoice_requires_successful_payment, + :ignore_paid_top_up_limits, + :transaction_name, + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ], + transaction_metadata: [ + :key, + :value + ], + payment_method: [ + :payment_method_type, + :payment_method_id + ] + ], + applies_to: [ + fee_types: [], + billable_metric_codes: [] + ], + invoice_custom_section: [ + :skip_invoice_custom_sections, + {invoice_custom_section_codes: []} + ], + payment_method: [ + :payment_method_type, + :payment_method_id + ] + ) + end + + def render_wallet(wallet) + render( + json: ::V1::WalletSerializer.new( + wallet, + root_name: "wallet", + includes: %i[recurring_transaction_rules limitations applied_invoice_custom_sections] + ) + ) + end +end diff --git a/app/controllers/concerns/wallet_metadata_actions.rb b/app/controllers/concerns/wallet_metadata_actions.rb new file mode 100644 index 0000000..1eea00c --- /dev/null +++ b/app/controllers/concerns/wallet_metadata_actions.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module WalletMetadataActions + include Pagination + extend ActiveSupport::Concern + + def metadata_create(wallet) + result = ::Wallets::UpdateService.call(wallet:, params: metadata_params) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def metadata_update(wallet) + result = ::Wallets::UpdateService.call(wallet:, partial_metadata: true, params: metadata_params) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def metadata_destroy(wallet) + result = ::Wallets::UpdateService.call(wallet:, params: {metadata: nil}) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + def metadata_destroy_key(wallet) + return not_found_error(resource: "metadata") unless wallet.metadata + + result = Metadata::DeleteItemKeyService.call(item: wallet.metadata, key: params[:key]) + + if result.success? + render_metadata + else + render_error_response(result) + end + end + + private + + def metadata_params + params.permit(metadata: {}).to_h + end + + def render_metadata + render(json: {metadata: wallet.reload.metadata&.value}) + end +end diff --git a/app/controllers/data_api/base_controller.rb b/app/controllers/data_api/base_controller.rb new file mode 100644 index 0000000..c6578a0 --- /dev/null +++ b/app/controllers/data_api/base_controller.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DataApi + class BaseController < ApplicationController + include ApiErrors + + before_action :authenticate + before_action :set_context_source + + private + + def authenticate + request.headers["Authorization"] + + key_header = request.headers["X-Data-API-Key"] + expected_key = ENV["LAGO_DATA_API_BEARER_TOKEN"] + + if key_header.present? && expected_key.present? && ActiveSupport::SecurityUtils.secure_compare(key_header, expected_key) + CurrentContext.email = nil + return true + end + + unauthorized_error + end + + def set_context_source + CurrentContext.source = "data" + CurrentContext.api_key_id = nil + end + end +end diff --git a/app/controllers/data_api/v1/charges_controller.rb b/app/controllers/data_api/v1/charges_controller.rb new file mode 100644 index 0000000..06202e4 --- /dev/null +++ b/app/controllers/data_api/v1/charges_controller.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module DataApi + module V1 + class ChargesController < DataApi::BaseController + include PremiumFeatureOnly + + def bulk_forecasted_usage_amount + charges_data = params[:charges] || [] + + if charges_data.empty? + render json: {error: "No charges provided"}, status: :bad_request + return + end + + result = Charges::BulkForecastedUsageAmountService.call( + charges_data: charges_data + ) + + render json: { + results: result.results, + failed_charges: result.failed_charges, + processed_count: result.processed_count, + failed_count: result.failed_count + } + end + + def resource_name + "analytic" + end + end + end +end diff --git a/app/controllers/dev_tools/invoices_controller.rb b/app/controllers/dev_tools/invoices_controller.rb new file mode 100644 index 0000000..3c6c0f5 --- /dev/null +++ b/app/controllers/dev_tools/invoices_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DevTools + class InvoicesController < ApplicationController + def show + service = ::Invoices::GeneratePdfService.new(invoice:) + + # For PDFs we need to use a simple file name and the file is passed to `gotenberg` + # In order to reuse the exact same template to display in HTML, we replace the image path + html = service.render_html.gsub('src="lago-logo-invoice.png', 'src="/assets/images/lago-logo-invoice.png') + + render(html: html.html_safe) # rubocop:disable Rails/OutputSafety + end + + private + + def invoice + @invoice ||= Invoice.find(params[:id]) + end + end +end diff --git a/app/controllers/dev_tools/payment_receipts_controller.rb b/app/controllers/dev_tools/payment_receipts_controller.rb new file mode 100644 index 0000000..ae2aae3 --- /dev/null +++ b/app/controllers/dev_tools/payment_receipts_controller.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module DevTools + class PaymentReceiptsController < ApplicationController + def show + service = ::PaymentReceipts::GeneratePdfService.new(payment_receipt:) + + # For PDFs we need to use a simple file name and the file is passed to `gotenberg` + # In order to reuse the exact same template to display in HTML, we replace the image path + html = service.render_html.gsub('src="lago-logo-invoice.png', 'src="/assets/images/lago-logo-invoice.png') + + render(html: html.html_safe) # rubocop:disable Rails/OutputSafety + end + + private + + def payment_receipt + @payment_receipt ||= PaymentReceipt.find(params[:id]) + end + end +end diff --git a/app/controllers/graphql_controller.rb b/app/controllers/graphql_controller.rb new file mode 100644 index 0000000..8aba5bc --- /dev/null +++ b/app/controllers/graphql_controller.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +class GraphqlController < ApplicationController + MAX_QUERY_LENGTH = 15_000 + + include AuthenticableUser + include CustomerPortalUser + include Trackable + + before_action :set_context_source + + rescue_from JWT::ExpiredSignature do + render_graphql_error(code: "expired_jwt_token", status: 401) + end + + # If accessing from outside this domain, nullify the session + # This allows for outside API access while preventing CSRF attacks, + # but you'll have to authenticate your user separately + # protect_from_forgery with: :null_session + + def execute + variables = prepare_variables(params[:variables]) + query = params[:query] + operation_name = params[:operationName] + context = { + login_method:, + current_user:, + current_organization:, + current_membership:, + customer_portal_user:, + request:, + permissions: (current_membership || Permission)&.permissions_hash + } + + CurrentContext.device_info = Utils::DeviceInfo.parse(request) + + if query.present? && query.length > MAX_QUERY_LENGTH + return render_graphql_error( + code: "query_is_too_large", + status: 413, + message: "Max query length is #{MAX_QUERY_LENGTH}, your query is #{query.length}" + ) + end + + OpenTelemetry::Trace.current_span.add_attributes({"query" => query, "operation_name" => operation_name}) + result = LagoTracer.in_span("LagoApiSchema.execute") do + LagoApiSchema.execute(query, variables:, context:, operation_name:) + end + + render(json: result) + rescue JWT::ExpiredSignature + render_graphql_error(code: "expired_jwt_token", status: 401) + rescue => e + raise e unless Rails.env.development? + + handle_error_in_development(e) + end + + private + + # Handle variables in form data, JSON body, or a blank value + def prepare_variables(variables_param) + case variables_param + when String + if variables_param.present? + JSON.parse(variables_param) || {} + else + {} + end + when Hash + variables_param + when ActionController::Parameters + variables_param.to_unsafe_hash # GraphQL-Ruby will validate name and type of incoming variables. + when nil + {} + else + raise ArgumentError, "Unexpected parameter: #{variables_param}" + end + end + + def handle_error_in_development(error) + logger.error(error.message) + logger.error(error.backtrace.join("\n")) + + render(json: {errors: [{message: error.message, backtrace: error.backtrace}], data: {}}, status: 500) + end + + def render_graphql_error(code:, status:, message: nil) + render( + json: { + data: {}, + errors: [ + { + message: message || code, + extensions: {status:, code:} + } + ] + } + ) + end + + def set_context_source + CurrentContext.source = "graphql" + CurrentContext.api_key_id = nil + end +end diff --git a/app/controllers/webhooks_controller.rb b/app/controllers/webhooks_controller.rb new file mode 100644 index 0000000..dcfce6c --- /dev/null +++ b/app/controllers/webhooks_controller.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +class WebhooksController < ApplicationController + def stripe + result = InboundWebhooks::CreateService.call( + organization_id: params[:organization_id], + webhook_source: :stripe, + code: params[:code].presence, + payload: request.body.read, + signature: request.headers["HTTP_STRIPE_SIGNATURE"], + event_type: params[:type] + ) + + return head(:bad_request) unless result.success? + + head(:ok) + end + + def cashfree + result = PaymentProviders::Cashfree::HandleIncomingWebhookService.call( + organization_id: params[:organization_id], + code: params[:code].presence, + body: request.body.read, + timestamp: request.headers["X-Cashfree-Timestamp"] || request.headers["X-Webhook-Timestamp"], + signature: request.headers["X-Cashfree-Signature"] || request.headers["X-Webhook-Signature"] + ) + + unless result.success? + if result.error.is_a?(BaseService::ServiceFailure) && result.error.code == "webhook_error" + return head(:bad_request) + end + + result.raise_if_error! + end + + head(:ok) + end + + def flutterwave + result = PaymentProviders::Flutterwave::HandleIncomingWebhookService.call( + organization_id: params[:organization_id], + code: params[:code].presence, + body: request.body.read, + secret: request.headers["verif-hash"] + ) + + unless result.success? + if result.error.is_a?(BaseService::ServiceFailure) && result.error.code == "webhook_error" + return head(:bad_request) + end + + result.raise_if_error! + end + + head(:ok) + end + + def gocardless + result = PaymentProviders::Gocardless::HandleIncomingWebhookService.call( + organization_id: params[:organization_id], + code: params[:code].presence, + body: request.body.read, + signature: request.headers["Webhook-Signature"] + ) + + unless result.success? + if result.error.is_a?(BaseService::ServiceFailure) && result.error.code == "webhook_error" + return head(:bad_request) + end + + result.raise_if_error! + end + + head(:ok) + end + + def adyen + result = PaymentProviders::Adyen::HandleIncomingWebhookService.call( + organization_id: params[:organization_id], + code: params[:code].presence, + body: adyen_params.to_h + ) + + unless result.success? + return head(:bad_request) if result.error.code == "webhook_error" + + result.raise_if_error! + end + + render(json: "[accepted]") + end + + def adyen_params + params["notificationItems"]&.first&.dig("NotificationRequestItem")&.permit! + end + + def moneyhash + result = InboundWebhooks::CreateService.call( + organization_id: params[:organization_id], + webhook_source: :moneyhash, + code: params[:code].presence, + payload: JSON.parse(request.body.read), + signature: request.headers["MoneyHash-Signature"], + event_type: params[:type] + ) + + return head(:bad_request) unless result.success? + + head(:ok) + end +end diff --git a/app/graphql/concerns/authenticable_api_user.rb b/app/graphql/concerns/authenticable_api_user.rb new file mode 100644 index 0000000..e02112b --- /dev/null +++ b/app/graphql/concerns/authenticable_api_user.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module AuthenticableApiUser + extend ActiveSupport::Concern + + private + + def ready?(**args) + raise unauthorized_error unless context[:current_user] + + super + end + + def unauthorized_error + GraphQL::ExecutionError.new("unauthorized", extensions: {status: :unauthorized, code: "unauthorized"}) + end +end diff --git a/app/graphql/concerns/authenticable_customer_portal_user.rb b/app/graphql/concerns/authenticable_customer_portal_user.rb new file mode 100644 index 0000000..04e39d3 --- /dev/null +++ b/app/graphql/concerns/authenticable_customer_portal_user.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module AuthenticableCustomerPortalUser + extend ActiveSupport::Concern + + private + + def ready?(**args) + raise unauthorized_error unless context[:customer_portal_user] + + super + end + + def unauthorized_error + GraphQL::ExecutionError.new("unauthorized", extensions: {status: :unauthorized, code: "unauthorized"}) + end +end diff --git a/app/graphql/concerns/can_require_permissions.rb b/app/graphql/concerns/can_require_permissions.rb new file mode 100644 index 0000000..c44ff4e --- /dev/null +++ b/app/graphql/concerns/can_require_permissions.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module CanRequirePermissions + extend ActiveSupport::Concern + + private + + def ready?(**args) + if defined? self.class::REQUIRED_PERMISSION + permissions_list = Array.wrap(self.class::REQUIRED_PERMISSION) + has_permission = permissions_list.any? do |permission| + context.dig(:permissions, permission) + end + raise not_enough_permissions_error unless has_permission + end + + super + end + + def not_enough_permissions_error + extensions = { + status: :forbidden, + code: "forbidden", + required_permissions: Array.wrap(self.class::REQUIRED_PERMISSION) + } + + GraphQL::ExecutionError.new("Missing permissions", extensions:) + end +end diff --git a/app/graphql/concerns/execution_error_responder.rb b/app/graphql/concerns/execution_error_responder.rb new file mode 100644 index 0000000..1944d94 --- /dev/null +++ b/app/graphql/concerns/execution_error_responder.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +# ExecutionErrorResponder Module +module ExecutionErrorResponder + extend ActiveSupport::Concern + + private + + def execution_error(error: "Internal Error", status: 422, code: "internal_error", details: nil) + payload = { + status:, + code: + } + + if details.is_a?(Hash) + payload[:details] = details&.transform_keys do |key| + key.to_s.camelize(:lower) + end + end + + GraphQL::ExecutionError.new(error, extensions: payload) + end + + def not_found_error(resource:) + execution_error( + error: "Resource not found", + status: 404, + code: "not_found", + details: { + resource => ["not_found"] + } + ) + end + + def not_allowed_error(code:) + execution_error( + error: "Method Not Allowed", + status: 405, + code: + ) + end + + def forbidden_error(code:) + execution_error( + error: "forbidden", + status: 403, + code: + ) + end + + def validation_error(messages:) + execution_error( + error: "Unprocessable Entity", + status: 422, + code: "unprocessable_entity", + details: messages + ) + end + + def third_party_failure(messages:) + execution_error( + error: "Unprocessable Entity", + status: 422, + code: "third_party_error", + details: {error: messages} + ) + end + + def result_error(service_result) + case service_result.error + when BaseService::NotFoundFailure + not_found_error(resource: service_result.error.resource) + when BaseService::MethodNotAllowedFailure + not_allowed_error(code: service_result.error.code) + when BaseService::ValidationFailure + validation_error(messages: service_result.error.messages) + when BaseService::ForbiddenFailure + forbidden_error(code: service_result.error.code) + when BaseService::ThirdPartyFailure + third_party_failure(messages: service_result.error.message) + else + execution_error( + error: "Internal error", + status: 500, + code: service_result.error.code + ) + end + end +end diff --git a/app/graphql/concerns/required_organization.rb b/app/graphql/concerns/required_organization.rb new file mode 100644 index 0000000..487af04 --- /dev/null +++ b/app/graphql/concerns/required_organization.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module RequiredOrganization + extend ActiveSupport::Concern + + private + + def ready?(**args) + raise organization_error("Missing organization id") unless current_organization + raise organization_error("Missing membership") unless current_membership + raise organization_error("Not in organization") unless user_is_member_of_organization? + + super + end + + def current_organization + context[:current_organization] + end + + def current_membership + context[:current_membership] + end + + def user_is_member_of_organization? + return false unless context[:current_user] + + context[:current_user].id == current_membership.user_id && current_membership.organization_id == current_organization.id + end + + def organization_error(message) + GraphQL::ExecutionError.new(message, extensions: {status: :forbidden, code: "forbidden"}) + end +end diff --git a/app/graphql/extensions/field_authorization_extension.rb b/app/graphql/extensions/field_authorization_extension.rb new file mode 100644 index 0000000..3295faf --- /dev/null +++ b/app/graphql/extensions/field_authorization_extension.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Extensions + class FieldAuthorizationExtension < GraphQL::Schema::FieldExtension + def resolve(object:, arguments:, context:) + super if field.permissions.any? { |p| context.dig(:permissions, p) } + end + end +end diff --git a/app/graphql/lago_api_schema.rb b/app/graphql/lago_api_schema.rb new file mode 100644 index 0000000..30bba69 --- /dev/null +++ b/app/graphql/lago_api_schema.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class LagoApiSchema < GraphQL::Schema + mutation(Types::MutationType) + query(Types::QueryType) + subscription(Types::GraphqlSubscriptionType) + + use GraphQL::Subscriptions::ActionCableSubscriptions + + # For batch-loading (see https://graphql-ruby.org/dataloader/overview.html) + use GraphQL::Dataloader + + max_depth 15 + max_complexity 350 + + # GraphQL-Ruby calls this when something goes wrong while running a query: + + # Union and Interface Resolution + def self.resolve_type(_abstract_type, _obj, _ctx) + # TODO: Implement this method + # to return the correct GraphQL object type for `obj` + raise(GraphQL::RequiredImplementationMissingError) + end + + # Relay-style Object Identification: + + # Return a string UUID for `object` + def self.id_from_object(object, type_definition, _query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + object_id = object.to_global_id.to_s + # Remove this redundant prefix to make IDs shorter: + object_id = object_id.sub("gid://#{GlobalID.app}/", "") + encoded_id = Base64.urlsafe_encode64(object_id) + # Remove the "=" padding + encoded_id = encoded_id.sub(/=+/, "") + # Add a type hint + type_hint = type_definition.graphql_name.first + "#{type_hint}_#{encoded_id}" + end + + # Given a string UUID, find the object + def self.object_from_id(encoded_id_with_hint, _query_ctx) + # For example, use Rails' GlobalID library (https://github.com/rails/globalid): + # Split off the type hint + _type_hint, encoded_id = encoded_id_with_hint.split("_", 2) + # Decode the ID + id = Base64.urlsafe_decode64(encoded_id) + # Rebuild it for Rails then find the object: + full_global_id = "gid://#{GlobalID.app}/#{id}" + GlobalID::Locator.locate(full_global_id) + end + + default_max_page_size 25 +end diff --git a/app/graphql/mutations/add_ons/create.rb b/app/graphql/mutations/add_ons/create.rb new file mode 100644 index 0000000..e7949af --- /dev/null +++ b/app/graphql/mutations/add_ons/create.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module AddOns + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "addons:create" + + graphql_name "CreateAddOn" + description "Creates a new add-on" + + input_object_class Types::AddOns::CreateInput + + type Types::AddOns::Object + + def resolve(**args) + result = ::AddOns::CreateService.call(args.merge(organization_id: current_organization.id)) + + result.success? ? result.add_on : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/add_ons/destroy.rb b/app/graphql/mutations/add_ons/destroy.rb new file mode 100644 index 0000000..e95c830 --- /dev/null +++ b/app/graphql/mutations/add_ons/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module AddOns + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "addons:delete" + + graphql_name "DestroyAddOn" + description "Deletes an add-on" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + add_on = current_organization.add_ons.find_by(id:) + result = ::AddOns::DestroyService.call(add_on:) + + result.success? ? result.add_on : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/add_ons/update.rb b/app/graphql/mutations/add_ons/update.rb new file mode 100644 index 0000000..3532fac --- /dev/null +++ b/app/graphql/mutations/add_ons/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module AddOns + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "addons:update" + + graphql_name "UpdateAddOn" + description "Update an existing add-on" + + input_object_class Types::AddOns::UpdateInput + + type Types::AddOns::Object + + def resolve(**args) + add_on = current_organization.add_ons.find_by(id: args[:id]) + result = ::AddOns::UpdateService.call(add_on:, params: args) + + result.success? ? result.add_on : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/adjusted_fees/create.rb b/app/graphql/mutations/adjusted_fees/create.rb new file mode 100644 index 0000000..5cbaea9 --- /dev/null +++ b/app/graphql/mutations/adjusted_fees/create.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module AdjustedFees + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "CreateAdjustedFee" + description "Creates Adjusted Fee" + + input_object_class Types::AdjustedFees::CreateInput + + type Types::Fees::Object + + def resolve(**args) + invoice = current_organization.invoices.find_by(id: args[:invoice_id]) + + result = ::AdjustedFees::CreateService.call(invoice:, params: args) + result.success? ? result.fee : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/adjusted_fees/destroy.rb b/app/graphql/mutations/adjusted_fees/destroy.rb new file mode 100644 index 0000000..7518c05 --- /dev/null +++ b/app/graphql/mutations/adjusted_fees/destroy.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module AdjustedFees + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "DestroyAdjustedFee" + description "Deletes an adjusted fee" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + fee = current_organization.fees + .where(invoice_id: current_organization.invoices.draft.select(:id)) + .find_by(id:) + + result = ::AdjustedFees::DestroyService.call(fee:) + + result.success? ? result.fee : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/adjusted_fees/preview.rb b/app/graphql/mutations/adjusted_fees/preview.rb new file mode 100644 index 0000000..a2b4bb9 --- /dev/null +++ b/app/graphql/mutations/adjusted_fees/preview.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module AdjustedFees + class Preview < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "PreviewAdjustedFee" + description "Preview Adjusted Fee" + + input_object_class Types::AdjustedFees::CreateInput + + type Types::Fees::Object + + def resolve(**args) + invoice = current_organization.invoices.find_by(id: args[:invoice_id]) + + result = ::AdjustedFees::EstimateService.call(invoice:, params: args) + result.success? ? result.fee : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/ai_conversations/create.rb b/app/graphql/mutations/ai_conversations/create.rb new file mode 100644 index 0000000..7a921e9 --- /dev/null +++ b/app/graphql/mutations/ai_conversations/create.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module AiConversations + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "ai_conversations:create" + + graphql_name "CreateAiConversation" + description "Creates an AI conversation and appends a message to it" + + argument :conversation_id, ID, required: false + argument :message, String, required: true + + type Types::AiConversations::Object + + def resolve(message:, conversation_id: nil) + raise unauthorized_error unless License.premium? + raise forbidden_error(code: "feature_unavailable") if ENV["MISTRAL_API_KEY"].blank? || ENV["MISTRAL_AGENT_ID"].blank? + + membership = current_organization.memberships.find_by(user_id: context[:current_user].id) + + ai_conversation = if conversation_id.present? + current_organization.ai_conversations.find(conversation_id) + else + current_organization.ai_conversations.create!( + membership:, + name: message + ) + end + + ::AiConversations::StreamJob.perform_later(ai_conversation:, message:) + + ai_conversation + end + end + end +end diff --git a/app/graphql/mutations/api_keys/create.rb b/app/graphql/mutations/api_keys/create.rb new file mode 100644 index 0000000..06c4c70 --- /dev/null +++ b/app/graphql/mutations/api_keys/create.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module ApiKeys + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:keys:manage" + + graphql_name "CreateApiKey" + description "Creates a new API key" + + argument :name, String, required: false + argument :permissions, GraphQL::Types::JSON, required: false + + type Types::ApiKeys::Object + + def resolve(**args) + result = ::ApiKeys::CreateService.call(args.merge(organization: current_organization)) + + result.success? ? result.api_key : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/api_keys/destroy.rb b/app/graphql/mutations/api_keys/destroy.rb new file mode 100644 index 0000000..2c108b7 --- /dev/null +++ b/app/graphql/mutations/api_keys/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module ApiKeys + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:keys:manage" + + graphql_name "DestroyApiKey" + description "Deletes an API key" + + argument :id, ID, required: true + + type Types::ApiKeys::Object + + def resolve(id:) + api_key = current_organization.api_keys.find_by(id:) + result = ::ApiKeys::DestroyService.call(api_key) + + result.success? ? result.api_key : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/api_keys/rotate.rb b/app/graphql/mutations/api_keys/rotate.rb new file mode 100644 index 0000000..810d68b --- /dev/null +++ b/app/graphql/mutations/api_keys/rotate.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module ApiKeys + class Rotate < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:keys:manage" + + graphql_name "RotateApiKey" + description "Create new ApiKey while expiring provided" + + input_object_class Types::ApiKeys::RotateInput + + type Types::ApiKeys::Object + + def resolve(id:, name: nil, expires_at: nil) + api_key = current_organization.api_keys.find_by(id:) + + result = ::ApiKeys::RotateService.call( + api_key:, + params: { + name:, + expires_at: + } + ) + + result.success? ? result.api_key : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/api_keys/update.rb b/app/graphql/mutations/api_keys/update.rb new file mode 100644 index 0000000..fd74fb6 --- /dev/null +++ b/app/graphql/mutations/api_keys/update.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module ApiKeys + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:keys:manage" + + graphql_name "UpdateApiKey" + + input_object_class Types::ApiKeys::UpdateInput + + type Types::ApiKeys::Object + + def resolve(id:, **params) + api_key = current_organization.api_keys.find_by(id:) + result = ::ApiKeys::UpdateService.call(api_key:, params:) + result.success? ? result.api_key : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/applied_coupons/create.rb b/app/graphql/mutations/applied_coupons/create.rb new file mode 100644 index 0000000..6ed7c7a --- /dev/null +++ b/app/graphql/mutations/applied_coupons/create.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Mutations + module AppliedCoupons + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:attach" + + graphql_name "CreateAppliedCoupon" + description "Assigns a Coupon to a Customer" + + argument :coupon_id, ID, required: true + argument :customer_id, ID, required: true + + argument :amount_cents, GraphQL::Types::BigInt, required: false + argument :amount_currency, Types::CurrencyEnum, required: false + argument :frequency, Types::Coupons::FrequencyEnum, required: false + argument :frequency_duration, Integer, required: false + argument :percentage_rate, Float, required: false + + type Types::AppliedCoupons::Object + + def resolve(**args) + customer = current_organization.customers.find_by(id: args[:customer_id]) + coupon = current_organization.coupons.find_by(id: args[:coupon_id]) + + result = ::AppliedCoupons::CreateService.call(customer:, coupon:, params: args) + result.success? ? result.applied_coupon : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/applied_coupons/terminate.rb b/app/graphql/mutations/applied_coupons/terminate.rb new file mode 100644 index 0000000..5b13cf7 --- /dev/null +++ b/app/graphql/mutations/applied_coupons/terminate.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module AppliedCoupons + class Terminate < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:detach" + + graphql_name "TerminateAppliedCoupon" + description "Unassign a coupon from a customer" + + argument :id, ID, required: true + + type Types::AppliedCoupons::Object + + def resolve(id:) + applied_coupon = current_organization.applied_coupons.find_by(id:) + + result = ::AppliedCoupons::TerminateService.call(applied_coupon:) + + result.success? ? result.applied_coupon : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/auth/google/accept_invite.rb b/app/graphql/mutations/auth/google/accept_invite.rb new file mode 100644 index 0000000..73ef982 --- /dev/null +++ b/app/graphql/mutations/auth/google/accept_invite.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module Auth + module Google + class AcceptInvite < BaseMutation + graphql_name "GoogleAcceptInvite" + description "Accepts a membership invite with Google Oauth" + + argument :code, String, required: true + argument :invite_token, String, required: true + + type Types::Payloads::RegisterUserType + + def resolve(code:, invite_token:) + result = ::Auth::GoogleService.new.accept_invite(code, invite_token) + + result.success? ? result : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/auth/google/login_user.rb b/app/graphql/mutations/auth/google/login_user.rb new file mode 100644 index 0000000..94072b0 --- /dev/null +++ b/app/graphql/mutations/auth/google/login_user.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +# Mutations::Auth::Google::LoginUser Mutation +module Mutations + module Auth + module Google + class LoginUser < BaseMutation + graphql_name "GoogleLoginUser" + description "Opens a session for an existing user with Google Oauth" + + argument :code, String, required: true + + type Types::Payloads::LoginUserType + + def resolve(code:) + result = ::Auth::GoogleService.new.login(code) + result.success? ? result : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/auth/google/register_user.rb b/app/graphql/mutations/auth/google/register_user.rb new file mode 100644 index 0000000..57a8e67 --- /dev/null +++ b/app/graphql/mutations/auth/google/register_user.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module Auth + module Google + class RegisterUser < BaseMutation + graphql_name "GoogleRegisterUser" + description "Register a new user with Google Oauth" + + argument :code, String, required: true + argument :organization_name, String, required: true + + type Types::Payloads::RegisterUserType + + def resolve(code:, organization_name:) + result = ::Auth::GoogleService.new.register_user(code, organization_name) + result.success? ? result : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/auth/okta/accept_invite.rb b/app/graphql/mutations/auth/okta/accept_invite.rb new file mode 100644 index 0000000..d7f92c5 --- /dev/null +++ b/app/graphql/mutations/auth/okta/accept_invite.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module Auth + module Okta + class AcceptInvite < BaseMutation + graphql_name "OktaAcceptInvite" + description "Accepts a membership invite with Okta Oauth" + + input_object_class Types::Auth::Okta::AcceptInviteInput + + type Types::Payloads::LoginUserType + + def resolve(code:, invite_token:, state:) + result = ::Auth::Okta::AcceptInviteService.call(code:, invite_token:, state:) + + result.success? ? result : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/auth/okta/authorize.rb b/app/graphql/mutations/auth/okta/authorize.rb new file mode 100644 index 0000000..3971923 --- /dev/null +++ b/app/graphql/mutations/auth/okta/authorize.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Mutations + module Auth + module Okta + class Authorize < BaseMutation + graphql_name "OktaAuthorize" + + argument :email, String, required: true + argument :invite_token, String, required: false + + type Types::Auth::Okta::Authorize + + def resolve(email:, invite_token: nil) + result = ::Auth::Okta::AuthorizeService.call(email:, invite_token:) + result.success? ? result : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/auth/okta/login.rb b/app/graphql/mutations/auth/okta/login.rb new file mode 100644 index 0000000..9763c92 --- /dev/null +++ b/app/graphql/mutations/auth/okta/login.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Mutations + module Auth + module Okta + class Login < BaseMutation + graphql_name "OktaLogin" + + argument :code, String, required: true + argument :state, String, required: true + + type Types::Payloads::LoginUserType + + def resolve(code:, state:) + result = ::Auth::Okta::LoginService.call(code:, state:) + result.success? ? result : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb new file mode 100644 index 0000000..cd1fca3 --- /dev/null +++ b/app/graphql/mutations/base_mutation.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +# Mutations::BaseMutation Mutation +module Mutations + class BaseMutation < GraphQL::Schema::RelayClassicMutation + include ExecutionErrorResponder + include CanRequirePermissions + + argument_class Types::BaseArgument + field_class Types::BaseField + input_object_class Types::BaseInputObject + object_class Types::BaseObject + end +end diff --git a/app/graphql/mutations/billable_metrics/create.rb b/app/graphql/mutations/billable_metrics/create.rb new file mode 100644 index 0000000..fb3f6cd --- /dev/null +++ b/app/graphql/mutations/billable_metrics/create.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module BillableMetrics + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billable_metrics:create" + + graphql_name "CreateBillableMetric" + description "Creates a new Billable metric" + + input_object_class Types::BillableMetrics::CreateInput + + type Types::BillableMetrics::Object + + def resolve(**args) + result = ::BillableMetrics::CreateService + .call(**args.merge(organization_id: current_organization.id)) + + result.success? ? result.billable_metric : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/billable_metrics/destroy.rb b/app/graphql/mutations/billable_metrics/destroy.rb new file mode 100644 index 0000000..0046b79 --- /dev/null +++ b/app/graphql/mutations/billable_metrics/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module BillableMetrics + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billable_metrics:delete" + + graphql_name "DestroyBillableMetric" + description "Deletes a Billable metric" + + argument :id, String, required: true + + field :id, ID, null: true + + def resolve(id:) + metric = current_organization.billable_metrics.find_by(id:) + result = ::BillableMetrics::DestroyService.call(metric:) + + result.success? ? result.billable_metric : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/billable_metrics/update.rb b/app/graphql/mutations/billable_metrics/update.rb new file mode 100644 index 0000000..557f50d --- /dev/null +++ b/app/graphql/mutations/billable_metrics/update.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module BillableMetrics + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billable_metrics:update" + + graphql_name "UpdateBillableMetric" + description "Updates an existing Billable metric" + + input_object_class Types::BillableMetrics::UpdateInput + + type Types::BillableMetrics::Object + + def resolve(**args) + billable_metric = current_organization.billable_metrics.find_by(id: args[:id]) + result = ::BillableMetrics::UpdateService.call(billable_metric:, params: args) + result.success? ? result.billable_metric : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/billing_entities/apply_taxes.rb b/app/graphql/mutations/billing_entities/apply_taxes.rb new file mode 100644 index 0000000..3a7b4cd --- /dev/null +++ b/app/graphql/mutations/billing_entities/apply_taxes.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module BillingEntities + class ApplyTaxes < ::Mutations::BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:update" + + argument :billing_entity_id, ID, required: true + argument :tax_codes, [String], required: true + + field :applied_taxes, [Types::Taxes::Object], null: false + + def resolve(billing_entity_id:, tax_codes:) + billing_entity = current_organization.billing_entities.find(billing_entity_id) + result = ::BillingEntities::Taxes::ApplyTaxesService.call(billing_entity:, tax_codes:) + + result.success? ? {applied_taxes: result.taxes_to_apply || []} : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/billing_entities/create.rb b/app/graphql/mutations/billing_entities/create.rb new file mode 100644 index 0000000..4d9c54c --- /dev/null +++ b/app/graphql/mutations/billing_entities/create.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module BillingEntities + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:create" + + graphql_name "CreateBillingEntity" + description "Creates a new Billing Entity" + + input_object_class Types::BillingEntities::CreateInput + + type Types::BillingEntities::Object + + def resolve(**args) + result = ::BillingEntities::CreateService.call( + organization: current_organization, + params: args + ) + + result.success? ? result.billing_entity : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/billing_entities/destroy.rb b/app/graphql/mutations/billing_entities/destroy.rb new file mode 100644 index 0000000..5605b5b --- /dev/null +++ b/app/graphql/mutations/billing_entities/destroy.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module BillingEntities + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:delete" + + graphql_name "DestroyBillingEntity" + description "Destroys a new Billing Entity" + + argument :code, String, required: true + field :code, String, null: true + + # We're not allowing now to destroy billing entities + def resolve(**args) + current_organization.default_billing_entity + end + end + end +end diff --git a/app/graphql/mutations/billing_entities/remove_taxes.rb b/app/graphql/mutations/billing_entities/remove_taxes.rb new file mode 100644 index 0000000..2d2d868 --- /dev/null +++ b/app/graphql/mutations/billing_entities/remove_taxes.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module BillingEntities + class RemoveTaxes < ::Mutations::BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:update" + + argument :billing_entity_id, ID, required: true + argument :tax_codes, [String], required: true + + field :removed_taxes, [Types::Taxes::Object], null: false + + def resolve(billing_entity_id:, tax_codes:) + billing_entity = current_organization.billing_entities.find(billing_entity_id) + result = ::BillingEntities::Taxes::RemoveTaxesService.call(billing_entity:, tax_codes:) + + result.success? ? {removed_taxes: result.taxes_to_remove || []} : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/billing_entities/update.rb b/app/graphql/mutations/billing_entities/update.rb new file mode 100644 index 0000000..ac5d32d --- /dev/null +++ b/app/graphql/mutations/billing_entities/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module BillingEntities + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:update" + + graphql_name "UpdateBillingEntity" + description "Updates a Billing Entity" + + input_object_class Types::BillingEntities::UpdateInput + + type Types::BillingEntities::Object + + def resolve(**args) + billing_entity = current_organization.billing_entities.find_by(id: args[:id]) + result = ::BillingEntities::UpdateService.call(billing_entity:, params: args) + + result.success? ? result.billing_entity : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/billing_entities/update_applied_dunning_campaign.rb b/app/graphql/mutations/billing_entities/update_applied_dunning_campaign.rb new file mode 100644 index 0000000..0fbe41e --- /dev/null +++ b/app/graphql/mutations/billing_entities/update_applied_dunning_campaign.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module BillingEntities + class UpdateAppliedDunningCampaign < ::Mutations::BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:update" + + graphql_name "BillingEntityUpdateAppliedDunningCampaign" + description "Updates the applied dunning campaign for a billing entity" + + argument :applied_dunning_campaign_id, String, required: false + argument :billing_entity_id, ID, required: true + + type Types::BillingEntities::Object + + def resolve(billing_entity_id:, applied_dunning_campaign_id:) + billing_entity = current_organization.billing_entities.find_by(id: billing_entity_id) + result = ::BillingEntities::UpdateAppliedDunningCampaignService.call(billing_entity:, applied_dunning_campaign_id:) + + result.success? ? result.billing_entity : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/charge_filters/create.rb b/app/graphql/mutations/charge_filters/create.rb new file mode 100644 index 0000000..3832521 --- /dev/null +++ b/app/graphql/mutations/charge_filters/create.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module ChargeFilters + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:create" + + graphql_name "CreateChargeFilter" + description "Creates a new Charge Filter" + + input_object_class Types::ChargeFilters::CreateInput + type Types::ChargeFilters::Object + + def resolve(**args) + charge = current_organization.charges.parents.find_by(id: args[:charge_id]) + + params = args.except(:charge_id).to_h.deep_symbolize_keys + cascade_updates = params.delete(:cascade_updates) || false + params[:properties] = params[:properties].to_h if params[:properties] + + result = ::ChargeFilters::CreateService.call(charge:, params:, cascade_updates:) + + result.success? ? result.charge_filter : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/charge_filters/destroy.rb b/app/graphql/mutations/charge_filters/destroy.rb new file mode 100644 index 0000000..faa92b8 --- /dev/null +++ b/app/graphql/mutations/charge_filters/destroy.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module ChargeFilters + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:delete" + + graphql_name "DestroyChargeFilter" + description "Deletes a Charge Filter" + + argument :cascade_updates, Boolean, required: false + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:, cascade_updates: false) + charge_filter = current_organization.charge_filters.find_by(id:) + + result = ::ChargeFilters::DestroyService.call( + charge_filter:, + cascade_updates: + ) + + result.success? ? result.charge_filter : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/charge_filters/update.rb b/app/graphql/mutations/charge_filters/update.rb new file mode 100644 index 0000000..eec34ce --- /dev/null +++ b/app/graphql/mutations/charge_filters/update.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module ChargeFilters + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:update" + + graphql_name "UpdateChargeFilter" + description "Updates an existing Charge Filter" + + input_object_class Types::ChargeFilters::UpdateInput + type Types::ChargeFilters::Object + + def resolve(**args) + charge_filter = current_organization.charge_filters.find_by(id: args[:id]) + + params = args.except(:id, :cascade_updates).to_h.deep_symbolize_keys + params[:properties] = params[:properties].to_h if params[:properties] + + result = ::ChargeFilters::UpdateService.call( + charge_filter:, + params:, + cascade_updates: args[:cascade_updates] || false + ) + + result.success? ? result.charge_filter : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/charges/create.rb b/app/graphql/mutations/charges/create.rb new file mode 100644 index 0000000..4dcbc8c --- /dev/null +++ b/app/graphql/mutations/charges/create.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Charges + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:create" + + graphql_name "CreateCharge" + description "Creates a new Charge for a Plan" + + input_object_class Types::Charges::CreateInput + type Types::Charges::Object + + def resolve(**args) + plan = current_organization.plans.parents.find_by(id: args[:plan_id]) + + params = args.except(:plan_id).to_h.deep_symbolize_keys + cascade_updates = params.delete(:cascade_updates) || false + params[:properties] = params[:properties].to_h if params[:properties] + params[:filters]&.map!(&:to_h) + params[:applied_pricing_unit] = params[:applied_pricing_unit].to_h if params[:applied_pricing_unit] + + result = ::Charges::CreateService.call(plan:, params:, cascade_updates:) + + result.success? ? result.charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/charges/destroy.rb b/app/graphql/mutations/charges/destroy.rb new file mode 100644 index 0000000..64e81c9 --- /dev/null +++ b/app/graphql/mutations/charges/destroy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Charges + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:delete" + + graphql_name "DestroyCharge" + description "Deletes a Charge" + + argument :cascade_updates, Boolean, required: false + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:, cascade_updates: false) + charge = current_organization.charges.parents.find_by(id:) + + result = ::Charges::DestroyService.call(charge:, cascade_updates:) + + result.success? ? result.charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/charges/update.rb b/app/graphql/mutations/charges/update.rb new file mode 100644 index 0000000..6cc8888 --- /dev/null +++ b/app/graphql/mutations/charges/update.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Charges + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:update" + + graphql_name "UpdateCharge" + description "Updates an existing Charge" + + input_object_class Types::Charges::UpdateInput + type Types::Charges::Object + + def resolve(**args) + charge = current_organization.charges.parents.find_by(id: args[:id]) + + params = args.except(:id).to_h.deep_symbolize_keys + params[:properties] = params[:properties].to_h if params[:properties] + params[:filters]&.map!(&:to_h) + params[:applied_pricing_unit] = params[:applied_pricing_unit].to_h if params[:applied_pricing_unit] + + cascade_updates = params.delete(:cascade_updates) || false + result = ::Charges::UpdateService.call(charge:, params:, cascade_updates:) + + result.success? ? result.charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/coupons/create.rb b/app/graphql/mutations/coupons/create.rb new file mode 100644 index 0000000..2355c50 --- /dev/null +++ b/app/graphql/mutations/coupons/create.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Coupons + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:create" + + graphql_name "CreateCoupon" + description "Creates a new Coupon" + + input_object_class Types::Coupons::CreateInput + + type Types::Coupons::Object + + def resolve(**args) + result = ::Coupons::CreateService.call(args.merge(organization_id: current_organization.id)) + + result.success? ? result.coupon : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/coupons/destroy.rb b/app/graphql/mutations/coupons/destroy.rb new file mode 100644 index 0000000..71e07ff --- /dev/null +++ b/app/graphql/mutations/coupons/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Coupons + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:delete" + + graphql_name "DestroyCoupon" + description "Deletes a coupon" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + coupon = current_organization.coupons.find_by(id:) + result = ::Coupons::DestroyService.call(coupon:) + + result.success? ? result.coupon : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/coupons/terminate.rb b/app/graphql/mutations/coupons/terminate.rb new file mode 100644 index 0000000..4747070 --- /dev/null +++ b/app/graphql/mutations/coupons/terminate.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Coupons + class Terminate < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:update" + + graphql_name "TerminateCoupon" + description "Deletes a coupon" + + argument :id, ID, required: true + + type Types::Coupons::Object + + def resolve(id:) + coupon = current_organization.coupons.find_by(id:) + result = ::Coupons::TerminateService.call(coupon) + + result.success? ? result.coupon : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/coupons/update.rb b/app/graphql/mutations/coupons/update.rb new file mode 100644 index 0000000..c3b2294 --- /dev/null +++ b/app/graphql/mutations/coupons/update.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Coupons + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:update" + + graphql_name "UpdateCoupon" + description "Update an existing coupon" + + input_object_class Types::Coupons::UpdateInput + + type Types::Coupons::Object + + def resolve(**args) + coupon = current_organization.coupons.find_by(id: args[:id]) + result = ::Coupons::UpdateService.call(coupon:, params: args) + result.success? ? result.coupon : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/credit_notes/create.rb b/app/graphql/mutations/credit_notes/create.rb new file mode 100644 index 0000000..9afe3ae --- /dev/null +++ b/app/graphql/mutations/credit_notes/create.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module CreditNotes + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:create" + + graphql_name "CreateCreditNote" + description "Creates a new Credit Note" + + argument :description, String, required: false + argument :invoice_id, ID, required: true + argument :reason, Types::CreditNotes::ReasonTypeEnum, required: true + + argument :credit_amount_cents, GraphQL::Types::BigInt, required: false + argument :offset_amount_cents, GraphQL::Types::BigInt, required: false + argument :refund_amount_cents, GraphQL::Types::BigInt, required: false + + argument :items, [Types::CreditNoteItems::Input], required: true + argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS + + type Types::CreditNotes::Object + + def resolve(**args) + args[:items].map!(&:to_h) + + result = ::CreditNotes::CreateService + .call( + invoice: current_organization.invoices.visible.find_by(id: args[:invoice_id]), + **args + ) + + result.success? ? result.credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/credit_notes/download.rb b/app/graphql/mutations/credit_notes/download.rb new file mode 100644 index 0000000..4bf20d7 --- /dev/null +++ b/app/graphql/mutations/credit_notes/download.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module CreditNotes + class Download < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:view" + + graphql_name "DownloadCreditNote" + description "Download a Credit Note PDF" + + argument :id, ID, required: true + + type Types::CreditNotes::Object + + def resolve(**args) + result = ::CreditNotes::GeneratePdfService.call( + credit_note: current_organization.credit_notes.find_by(id: args[:id]) + ) + + result.success? ? result.credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/credit_notes/download_xml.rb b/app/graphql/mutations/credit_notes/download_xml.rb new file mode 100644 index 0000000..12d55cf --- /dev/null +++ b/app/graphql/mutations/credit_notes/download_xml.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module CreditNotes + class DownloadXml < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:view" + + graphql_name "DownloadXmlCreditNote" + description "Download a Credit Note XML" + + argument :id, ID, required: true + + type Types::CreditNotes::Object + + def resolve(**args) + result = ::CreditNotes::GenerateXmlService.call( + credit_note: current_organization.credit_notes.find_by(id: args[:id]) + ) + + result.success? ? result.credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/credit_notes/resend_email.rb b/app/graphql/mutations/credit_notes/resend_email.rb new file mode 100644 index 0000000..6e11a57 --- /dev/null +++ b/app/graphql/mutations/credit_notes/resend_email.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module CreditNotes + class ResendEmail < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:send" + + graphql_name "ResendCreditNoteEmail" + description "Resend credit note email with optional custom recipients" + + input_object_class Types::Emails::ResendEmailInput + + type Types::CreditNotes::Object + + def resolve(**args) + credit_note = current_organization.credit_notes.finalized.find_by(id: args[:id]) + + result = ::Emails::ResendService.call( + resource: credit_note, + to: args[:to], + cc: args[:cc], + bcc: args[:bcc] + ) + + result.success? ? credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/credit_notes/retry_tax_reporting.rb b/app/graphql/mutations/credit_notes/retry_tax_reporting.rb new file mode 100644 index 0000000..b3e2259 --- /dev/null +++ b/app/graphql/mutations/credit_notes/retry_tax_reporting.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module CreditNotes + class RetryTaxReporting < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:update" + + description "Retry tax reporting" + + argument :id, ID, required: true + + type Types::CreditNotes::Object + + def resolve(**args) + credit_note = current_organization.credit_notes.find_by(id: args[:id]) + result = ::CreditNotes::ProviderTaxes::ReportService.call(credit_note:) + + result.success? ? result.credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/credit_notes/update.rb b/app/graphql/mutations/credit_notes/update.rb new file mode 100644 index 0000000..1c732cf --- /dev/null +++ b/app/graphql/mutations/credit_notes/update.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module CreditNotes + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:update" + + graphql_name "UpdateCreditNote" + description "Updates an existing Credit Note" + + input_object_class Types::CreditNotes::UpdateCreditNoteInput + + type Types::CreditNotes::Object + + def resolve(**args) + result = ::CreditNotes::UpdateService.new( + credit_note: current_organization.credit_notes.find_by(id: args[:id]), + refund_status: args[:refund_status], + metadata: args[:metadata] + ).call + + result.success? ? result.credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/credit_notes/void.rb b/app/graphql/mutations/credit_notes/void.rb new file mode 100644 index 0000000..c9fb176 --- /dev/null +++ b/app/graphql/mutations/credit_notes/void.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module CreditNotes + class Void < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:void" + + graphql_name "VoidCreditNote" + description "Voids a Credit Note" + + argument :id, ID, required: true + + type Types::CreditNotes::Object + + def resolve(id:) + result = ::CreditNotes::VoidService.new( + credit_note: current_organization.credit_notes.find_by(id:) + ).call + + result.success? ? result.credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/customer_portal/download_invoice.rb b/app/graphql/mutations/customer_portal/download_invoice.rb new file mode 100644 index 0000000..7e709ca --- /dev/null +++ b/app/graphql/mutations/customer_portal/download_invoice.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module CustomerPortal + class DownloadInvoice < BaseMutation + include AuthenticableCustomerPortalUser + + graphql_name "DownloadCustomerPortalInvoice" + description "Download customer portal invoice PDF" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(id:) + invoice = context[:customer_portal_user].invoices.visible.find_by(id:) + result = ::Invoices::GeneratePdfService.call(invoice:) + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/customer_portal/generate_url.rb b/app/graphql/mutations/customer_portal/generate_url.rb new file mode 100644 index 0000000..6324abf --- /dev/null +++ b/app/graphql/mutations/customer_portal/generate_url.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module CustomerPortal + class GenerateUrl < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + graphql_name "GenerateCustomerPortalUrl" + description "Generate customer portal URL" + + argument :id, ID, required: true + + field :url, String, null: false + + def resolve(id:) + customer = current_organization.customers.find_by(id:) + result = ::CustomerPortal::GenerateUrlService.call(customer:) + + if result.success? + {url: result.url} + else + result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/customer_portal/update_customer.rb b/app/graphql/mutations/customer_portal/update_customer.rb new file mode 100644 index 0000000..de30e8c --- /dev/null +++ b/app/graphql/mutations/customer_portal/update_customer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module CustomerPortal + class UpdateCustomer < BaseMutation + include AuthenticableCustomerPortalUser + + graphql_name "UpdateCustomerPortalCustomer" + description "Update customer data from Customer Portal" + + input_object_class Types::CustomerPortal::Customers::UpdateInput + type Types::CustomerPortal::Customers::Object + + def resolve(**args) + result = ::CustomerPortal::CustomerUpdateService.call( + customer: context[:customer_portal_user], + args: + ) + + result.success? ? result.customer : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/customer_portal/wallet_transactions/create.rb b/app/graphql/mutations/customer_portal/wallet_transactions/create.rb new file mode 100644 index 0000000..dd35532 --- /dev/null +++ b/app/graphql/mutations/customer_portal/wallet_transactions/create.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module CustomerPortal + module WalletTransactions + class Create < BaseMutation + include AuthenticableCustomerPortalUser + + graphql_name "CreateCustomerPortalWalletTransaction" + description "Creates a new Customer Wallet Transaction from Customer Portal" + + argument :paid_credits, String, required: false + argument :wallet_id, ID, required: true + + type Types::CustomerPortal::WalletTransactions::Object.collection_type + + def resolve(**args) + organization = context[:customer_portal_user].organization + result = ::WalletTransactions::CreateFromParamsService.call( + organization:, + params: args.merge(customer: context[:customer_portal_user]) + ) + + result.success? ? result.wallet_transactions : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/customers/create.rb b/app/graphql/mutations/customers/create.rb new file mode 100644 index 0000000..91f5ed0 --- /dev/null +++ b/app/graphql/mutations/customers/create.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Customers + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "customers:create" + + graphql_name "CreateCustomer" + description "Creates a new customer" + + input_object_class Types::Customers::CreateCustomerInput + + type Types::Customers::Object + + def resolve(**args) + result = ::Customers::CreateService.call( + **args.merge(organization_id: current_organization.id) + ) + + result.success? ? result.customer : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/customers/destroy.rb b/app/graphql/mutations/customers/destroy.rb new file mode 100644 index 0000000..5a26392 --- /dev/null +++ b/app/graphql/mutations/customers/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Customers + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "customers:delete" + + graphql_name "DestroyCustomer" + description "Delete a Customer" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + customer = current_organization.customers.find_by(id:) + result = ::Customers::DestroyService.call(customer:) + + result.success? ? result.customer : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/customers/update.rb b/app/graphql/mutations/customers/update.rb new file mode 100644 index 0000000..ec1eba9 --- /dev/null +++ b/app/graphql/mutations/customers/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Customers + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = %w[customers:update] + + graphql_name "UpdateCustomer" + description "Updates an existing Customer" + + input_object_class Types::Customers::UpdateCustomerInput + + type Types::Customers::Object + + def resolve(**args) + customer = current_organization.customers.find_by(id: args[:id]) + result = ::Customers::UpdateService.call(customer:, args:) + + result.success? ? result.customer : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/customers/update_invoice_grace_period.rb b/app/graphql/mutations/customers/update_invoice_grace_period.rb new file mode 100644 index 0000000..44afe85 --- /dev/null +++ b/app/graphql/mutations/customers/update_invoice_grace_period.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +# TODO: Remove this mutation +# The Mutations::Customers::Update allows you to modify the customer grace period +module Mutations + module Customers + class UpdateInvoiceGracePeriod < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = %w[customers:update] + + graphql_name "UpdateCustomerInvoiceGracePeriod" + description "Assign the invoice grace period to Customers" + + argument :id, ID, required: true + argument :invoice_grace_period, Integer, required: false + + type Types::Customers::Object + + def resolve(id:, invoice_grace_period:) + customer = current_organization.customers.find_by(id:) + result = ::Customers::UpdateService.call(customer:, args: {invoice_grace_period:}) + + result.success? ? result.customer : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/data_exports/credit_notes/create.rb b/app/graphql/mutations/data_exports/credit_notes/create.rb new file mode 100644 index 0000000..37ac36f --- /dev/null +++ b/app/graphql/mutations/data_exports/credit_notes/create.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Mutations + module DataExports + module CreditNotes + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:export" + + graphql_name "CreateCreditNotesDataExport" + description "Request data export of credit notes" + + input_object_class Types::DataExports::CreditNotes::CreateInput + + type Types::DataExports::Object + + def resolve(format:, filters:, resource_type:) + result = ::DataExports::CreateService + .call( + organization: current_organization, + user: context[:current_user], + format:, + resource_type:, + resource_query: filters + ) + + result.success? ? result.data_export : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/data_exports/invoices/create.rb b/app/graphql/mutations/data_exports/invoices/create.rb new file mode 100644 index 0000000..57bbf23 --- /dev/null +++ b/app/graphql/mutations/data_exports/invoices/create.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Mutations + module DataExports + module Invoices + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:export" + + graphql_name "CreateInvoicesDataExport" + description "Request data export of invoices" + + input_object_class Types::DataExports::Invoices::CreateInput + + type Types::DataExports::Object + + def resolve(format:, filters:, resource_type:) + result = ::DataExports::CreateService + .call( + organization: current_organization, + user: context[:current_user], + format:, + resource_type:, + resource_query: filters + ) + + result.success? ? result.data_export : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/dunning_campaigns/create.rb b/app/graphql/mutations/dunning_campaigns/create.rb new file mode 100644 index 0000000..9d40fdd --- /dev/null +++ b/app/graphql/mutations/dunning_campaigns/create.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module DunningCampaigns + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "dunning_campaigns:create" + + graphql_name "CreateDunningCampaign" + description "Creates a new dunning campaign" + + input_object_class Types::DunningCampaigns::CreateInput + + type Types::DunningCampaigns::Object + + def resolve(**args) + result = ::DunningCampaigns::CreateService.call(organization: current_organization, params: args) + result.success? ? result.dunning_campaign : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/dunning_campaigns/destroy.rb b/app/graphql/mutations/dunning_campaigns/destroy.rb new file mode 100644 index 0000000..5dec32f --- /dev/null +++ b/app/graphql/mutations/dunning_campaigns/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module DunningCampaigns + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "dunning_campaigns:delete" + + graphql_name "DestroyDunningCampaign" + description "Deletes a dunning campaign" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + dunning_campaign = current_organization.dunning_campaigns.find_by(id:) + result = ::DunningCampaigns::DestroyService.call(dunning_campaign:) + + result.success? ? result.dunning_campaign : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/dunning_campaigns/update.rb b/app/graphql/mutations/dunning_campaigns/update.rb new file mode 100644 index 0000000..ab96cd9 --- /dev/null +++ b/app/graphql/mutations/dunning_campaigns/update.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module DunningCampaigns + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "dunning_campaigns:update" + + graphql_name "UpdateDunningCampaign" + description "Updates a dunning campaign and its thresholds" + + input_object_class Types::DunningCampaigns::UpdateInput + type Types::DunningCampaigns::Object + + def resolve(**args) + dunning_campaign = current_organization.dunning_campaigns.find_by(id: args[:id]) + + result = ::DunningCampaigns::UpdateService.call( + organization: current_organization, + dunning_campaign:, + params: args + ) + + result.success? ? result.dunning_campaign : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/entitlement/create_feature.rb b/app/graphql/mutations/entitlement/create_feature.rb new file mode 100644 index 0000000..db65d46 --- /dev/null +++ b/app/graphql/mutations/entitlement/create_feature.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Entitlement + class CreateFeature < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "features:create" + + description "Creates a new feature" + + input_object_class Types::Entitlement::CreateFeatureInput + + type Types::Entitlement::FeatureObject + + def resolve(**args) + result = ::Entitlement::FeatureCreateService.call( + organization: current_organization, + params: { + code: args[:code], + name: args[:name], + description: args[:description], + privileges: args[:privileges].map(&:to_h) + } + ) + + result.success? ? result.feature : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/entitlement/create_or_update_subscription_entitlement.rb b/app/graphql/mutations/entitlement/create_or_update_subscription_entitlement.rb new file mode 100644 index 0000000..85a2d79 --- /dev/null +++ b/app/graphql/mutations/entitlement/create_or_update_subscription_entitlement.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Entitlement + class CreateOrUpdateSubscriptionEntitlement < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + description "Updates a subscription entitlement" + + argument :subscription_id, ID, required: true + + argument :entitlement, Types::Entitlement::EntitlementInput, required: true + + type Types::Entitlement::SubscriptionEntitlementObject + + def resolve(subscription_id:, entitlement: nil) + subscription = current_organization.subscriptions.find_by(id: subscription_id) + + result = ::Entitlement::SubscriptionEntitlementUpdateService.call( + subscription:, + feature_code: entitlement[:feature_code], + privilege_params: entitlement[:privileges]&.map { [it.privilege_code, it.value] }.to_h, + partial: false + ) + + result.success? ? result.entitlement : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/entitlement/destroy_feature.rb b/app/graphql/mutations/entitlement/destroy_feature.rb new file mode 100644 index 0000000..10404f1 --- /dev/null +++ b/app/graphql/mutations/entitlement/destroy_feature.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Entitlement + class DestroyFeature < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "features:delete" + + description "Destroys an existing feature" + + argument :id, ID, required: true, description: "The ID of the feature to destroy" + + type Types::Entitlement::FeatureObject + + def resolve(**args) + feature = current_organization.features.find_by(id: args[:id]) + result = ::Entitlement::FeatureDestroyService.call(feature:) + + result.success? ? result.feature : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/entitlement/remove_subscription_entitlement.rb b/app/graphql/mutations/entitlement/remove_subscription_entitlement.rb new file mode 100644 index 0000000..42c47c3 --- /dev/null +++ b/app/graphql/mutations/entitlement/remove_subscription_entitlement.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Entitlement + class RemoveSubscriptionEntitlement < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + description "Removes a feature entitlement from a subscription" + + argument :subscription_id, ID, required: true + + argument :feature_code, String, required: true + + field :feature_code, String + + def resolve(**args) + subscription = current_organization.subscriptions.find_by(id: args[:subscription_id]) + + result = ::Entitlement::SubscriptionFeatureRemoveService.call( + subscription:, + feature_code: args[:feature_code] + ) + + result.success? ? {feature_code: result.feature_code} : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/entitlement/update_feature.rb b/app/graphql/mutations/entitlement/update_feature.rb new file mode 100644 index 0000000..deb59f6 --- /dev/null +++ b/app/graphql/mutations/entitlement/update_feature.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Mutations + module Entitlement + class UpdateFeature < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "features:update" + + description "Updates an existing feature" + + input_object_class Types::Entitlement::UpdateFeatureInput + + type Types::Entitlement::FeatureObject + + def resolve(**args) + feature = current_organization.features.find_by(id: args[:id]) + + result = ::Entitlement::FeatureUpdateService.call( + feature:, + params: { + name: args[:name], + description: args[:description], + privileges: args[:privileges].map(&:to_h) + }, + partial: false + ) + + result.success? ? result.feature : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/fixed_charges/create.rb b/app/graphql/mutations/fixed_charges/create.rb new file mode 100644 index 0000000..22e2f03 --- /dev/null +++ b/app/graphql/mutations/fixed_charges/create.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module FixedCharges + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:create" + + graphql_name "CreateFixedCharge" + description "Creates a new Fixed Charge for a Plan" + + input_object_class Types::FixedCharges::CreateInput + type Types::FixedCharges::Object + + def resolve(**args) + plan = current_organization.plans.parents.find_by(id: args[:plan_id]) + + params = args.except(:plan_id).to_h.deep_symbolize_keys + cascade_updates = params.delete(:cascade_updates) || false + params[:properties] = params[:properties].to_h if params[:properties] + + result = ::FixedCharges::CreateService.call(plan:, params:, cascade_updates:) + + result.success? ? result.fixed_charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/fixed_charges/destroy.rb b/app/graphql/mutations/fixed_charges/destroy.rb new file mode 100644 index 0000000..9066eaa --- /dev/null +++ b/app/graphql/mutations/fixed_charges/destroy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module FixedCharges + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:delete" + + graphql_name "DestroyFixedCharge" + description "Deletes a Fixed Charge" + + argument :cascade_updates, Boolean, required: false + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:, cascade_updates: false) + fixed_charge = current_organization.fixed_charges.parents.find_by(id:) + + result = ::FixedCharges::DestroyService.call(fixed_charge:, cascade_updates:) + + result.success? ? result.fixed_charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/fixed_charges/update.rb b/app/graphql/mutations/fixed_charges/update.rb new file mode 100644 index 0000000..9f28d91 --- /dev/null +++ b/app/graphql/mutations/fixed_charges/update.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module FixedCharges + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "charges:update" + + graphql_name "UpdateFixedCharge" + description "Updates an existing Fixed Charge" + + input_object_class Types::FixedCharges::UpdateInput + type Types::FixedCharges::Object + + def resolve(**args) + fixed_charge = current_organization.fixed_charges.parents.find_by(id: args[:id]) + + params = args.except(:id).to_h.deep_symbolize_keys + params[:properties] = params[:properties].to_h if params[:properties] + + cascade_updates = params.delete(:cascade_updates) || false + result = ::FixedCharges::UpdateService.call(fixed_charge:, params:, timestamp: Time.current.to_i, cascade_updates:) + + result.success? ? result.fixed_charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_collection_mappings/create.rb b/app/graphql/mutations/integration_collection_mappings/create.rb new file mode 100644 index 0000000..d895216 --- /dev/null +++ b/app/graphql/mutations/integration_collection_mappings/create.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationCollectionMappings + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "CreateIntegrationCollectionMapping" + description "Create integration collection mapping" + + input_object_class Types::IntegrationCollectionMappings::CreateInput + + type Types::IntegrationCollectionMappings::Object + + def resolve(**args) + result = ::IntegrationCollectionMappings::CreateService + .call(params: args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration_collection_mapping : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_collection_mappings/destroy.rb b/app/graphql/mutations/integration_collection_mappings/destroy.rb new file mode 100644 index 0000000..78908c8 --- /dev/null +++ b/app/graphql/mutations/integration_collection_mappings/destroy.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationCollectionMappings + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "DestroyIntegrationCollectionMapping" + description "Destroy an integration collection mapping" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + integration_collection_mapping = ::IntegrationCollectionMappings::BaseCollectionMapping + .joins(:integration) + .where(id:) + .where(integration: {organization: current_organization}).first + + result = ::IntegrationCollectionMappings::DestroyService.call(integration_collection_mapping:) + + result.success? ? result.integration_collection_mapping : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_collection_mappings/update.rb b/app/graphql/mutations/integration_collection_mappings/update.rb new file mode 100644 index 0000000..73815a6 --- /dev/null +++ b/app/graphql/mutations/integration_collection_mappings/update.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationCollectionMappings + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateIntegrationCollectionMapping" + description "Update integration mapping" + + input_object_class Types::IntegrationCollectionMappings::UpdateInput + + type Types::IntegrationCollectionMappings::Object + + def resolve(id:, **args) + integration_collection_mapping = ::IntegrationCollectionMappings::BaseCollectionMapping + .joins(:integration) + .find_by(id:, integration: {organization: current_organization}) + + result = ::IntegrationCollectionMappings::UpdateService.call( + integration_collection_mapping:, + params: args + ) + + result.success? ? result.integration_collection_mapping : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_items/fetch_accounts.rb b/app/graphql/mutations/integration_items/fetch_accounts.rb new file mode 100644 index 0000000..7b575c2 --- /dev/null +++ b/app/graphql/mutations/integration_items/fetch_accounts.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationItems + class FetchAccounts < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "FetchIntegrationAccounts" + description "Fetch integration accounts" + + argument :integration_id, ID, required: true + + type Types::IntegrationItems::Object.collection_type, null: false + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:integration_id]) + + ::Integrations::Aggregator::SyncService.call(integration:, options: {only_accounts: true}) + + result = ::Integrations::Aggregator::AccountsService.call(integration:) + + result.success? ? result.accounts : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_items/fetch_items.rb b/app/graphql/mutations/integration_items/fetch_items.rb new file mode 100644 index 0000000..6aa07cd --- /dev/null +++ b/app/graphql/mutations/integration_items/fetch_items.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationItems + class FetchItems < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "FetchIntegrationItems" + description "Fetch integration items" + + argument :integration_id, ID, required: true + + type Types::IntegrationItems::Object.collection_type, null: false + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:integration_id]) + + ::Integrations::Aggregator::SyncService.call(integration:, options: {only_items: true}) + + result = ::Integrations::Aggregator::ItemsService.call(integration:) + + result.success? ? result.items : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_mappings/create.rb b/app/graphql/mutations/integration_mappings/create.rb new file mode 100644 index 0000000..527df49 --- /dev/null +++ b/app/graphql/mutations/integration_mappings/create.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationMappings + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "CreateIntegrationMapping" + description "Create integration mapping" + + input_object_class Types::IntegrationMappings::CreateInput + + type Types::IntegrationMappings::Object + + def resolve(**args) + result = ::IntegrationMappings::CreateService + .new(context[:current_user]) + .call(**args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration_mapping : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_mappings/destroy.rb b/app/graphql/mutations/integration_mappings/destroy.rb new file mode 100644 index 0000000..ac57c26 --- /dev/null +++ b/app/graphql/mutations/integration_mappings/destroy.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationMappings + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "DestroyIntegrationMapping" + description "Destroy an integration mapping" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + integration_mapping = ::IntegrationMappings::BaseMapping + .joins(:integration) + .where(id:) + .where(integration: {organization: current_organization}).first + + result = ::IntegrationMappings::DestroyService.call(integration_mapping:) + + result.success? ? result.integration_mapping : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integration_mappings/update.rb b/app/graphql/mutations/integration_mappings/update.rb new file mode 100644 index 0000000..7f411f0 --- /dev/null +++ b/app/graphql/mutations/integration_mappings/update.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module IntegrationMappings + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateIntegrationMapping" + description "Update integration mapping" + + input_object_class Types::IntegrationMappings::UpdateInput + + type Types::IntegrationMappings::Object + + def resolve(**args) + integration_mapping = ::IntegrationMappings::BaseMapping + .joins(:integration) + .where(id: args[:id], integration: {organization: current_organization}).first + + result = ::IntegrationMappings::UpdateService.call(integration_mapping:, params: args) + + result.success? ? result.integration_mapping : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/integrations/anrok/create.rb b/app/graphql/mutations/integrations/anrok/create.rb new file mode 100644 index 0000000..4138f82 --- /dev/null +++ b/app/graphql/mutations/integrations/anrok/create.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Anrok + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "CreateAnrokIntegration" + description "Create Anrok integration" + + input_object_class Types::Integrations::Anrok::CreateInput + + type Types::Integrations::Anrok + + def resolve(**args) + result = ::Integrations::Anrok::CreateService + .new(context[:current_user]) + .call(**args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/anrok/update.rb b/app/graphql/mutations/integrations/anrok/update.rb new file mode 100644 index 0000000..307e301 --- /dev/null +++ b/app/graphql/mutations/integrations/anrok/update.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Anrok + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateAnrokIntegration" + description "Update Anrok integration" + + input_object_class Types::Integrations::Anrok::UpdateInput + + type Types::Integrations::Anrok + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:id]) + result = ::Integrations::Anrok::UpdateService.call(integration:, params: args) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/avalara/create.rb b/app/graphql/mutations/integrations/avalara/create.rb new file mode 100644 index 0000000..78d40e2 --- /dev/null +++ b/app/graphql/mutations/integrations/avalara/create.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Avalara + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "CreateAvalaraIntegration" + description "Create Avalara integration" + + input_object_class Types::Integrations::Avalara::CreateInput + + type Types::Integrations::Avalara + + def resolve(**args) + result = ::Integrations::Avalara::CreateService + .call(params: args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/avalara/update.rb b/app/graphql/mutations/integrations/avalara/update.rb new file mode 100644 index 0000000..a06bbba --- /dev/null +++ b/app/graphql/mutations/integrations/avalara/update.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Avalara + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateAvalaraIntegration" + description "Update Avalara integration" + + input_object_class Types::Integrations::Avalara::UpdateInput + + type Types::Integrations::Avalara + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:id]) + result = ::Integrations::Avalara::UpdateService.call(integration:, params: args) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/destroy.rb b/app/graphql/mutations/integrations/destroy.rb new file mode 100644 index 0000000..6198740 --- /dev/null +++ b/app/graphql/mutations/integrations/destroy.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:delete" + + graphql_name "DestroyIntegration" + description "Destroy an integration" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + integration = current_organization.integrations.find_by(id:) + result = destroy_service(integration:).call(integration:) + + result.success? ? result.integration : result_error(result) + end + + private + + def destroy_service(integration:) + case integration + when ::Integrations::OktaIntegration + ::Integrations::Okta::DestroyService + else + ::Integrations::DestroyService + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/fetch_draft_invoice_taxes.rb b/app/graphql/mutations/integrations/fetch_draft_invoice_taxes.rb new file mode 100644 index 0000000..0ee146b --- /dev/null +++ b/app/graphql/mutations/integrations/fetch_draft_invoice_taxes.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + class FetchDraftInvoiceTaxes < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:create" + + description "Fetches taxes for one-off invoice" + + input_object_class Types::Invoices::CreateInvoiceInput + + type Types::Integrations::TaxObjects::FeeObject.collection_type + + def resolve(**args) + customer = current_organization.customers.find_by(id: args[:customer_id]) + + result = ::Integrations::Aggregator::Taxes::Invoices::CreateDraftService.new( + invoice: invoice(customer, args), + fees: fees(args) + ).call + + result.success? ? result.fees : validation_error(messages: {tax_error: [result.error.code]}) + end + + private + + # Note: We need to pass invoice object to the service that return taxes. This service should + # work with real invoice objects. In this case, it should also work with invoice that is not stored yet, + # because we need to fetch taxes for one-off invoice UI form. + def invoice(customer, args) + OpenStruct.new( + issuing_date: Time.current.in_time_zone(customer.applicable_timezone).to_date, + currency: args[:currency], + customer: + ) + end + + def fees(args) + args[:fees].map do |fee| + unit_amount_cents = fee[:unit_amount_cents] + units = fee[:units]&.to_f || 1 + + OpenStruct.new( + add_on_id: fee[:add_on_id], + item_id: fee[:add_on_id], + sub_total_excluding_taxes_amount_cents: (unit_amount_cents * units).round + ) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/hubspot/create.rb b/app/graphql/mutations/integrations/hubspot/create.rb new file mode 100644 index 0000000..c80dd9a --- /dev/null +++ b/app/graphql/mutations/integrations/hubspot/create.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Hubspot + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "CreateHubspotIntegration" + description "Create Hubspot integration" + + input_object_class Types::Integrations::Hubspot::CreateInput + + type Types::Integrations::Hubspot + + def resolve(**args) + result = ::Integrations::Hubspot::CreateService + .call(params: args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/hubspot/sync_invoice.rb b/app/graphql/mutations/integrations/hubspot/sync_invoice.rb new file mode 100644 index 0000000..51f0776 --- /dev/null +++ b/app/graphql/mutations/integrations/hubspot/sync_invoice.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Hubspot + class SyncInvoice < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "SyncHubspotInvoice" + description "Sync hubspot integration invoice" + + input_object_class Types::Integrations::SyncHubspotInvoiceInput + + field :invoice_id, ID, null: true + + def resolve(**args) + invoice = current_organization.invoices.find_by(id: args[:invoice_id]) + + result = ::Integrations::Aggregator::Invoices::Hubspot::CreateService.call_async(invoice:) + result.success? ? result.invoice_id : result_error(result) + result + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/hubspot/update.rb b/app/graphql/mutations/integrations/hubspot/update.rb new file mode 100644 index 0000000..57a7fa4 --- /dev/null +++ b/app/graphql/mutations/integrations/hubspot/update.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Hubspot + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateHubspotIntegration" + description "Update Hubspot integration" + + input_object_class Types::Integrations::Hubspot::UpdateInput + + type Types::Integrations::Hubspot + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:id]) + result = ::Integrations::Hubspot::UpdateService.call(integration:, params: args) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/netsuite/create.rb b/app/graphql/mutations/integrations/netsuite/create.rb new file mode 100644 index 0000000..0225acf --- /dev/null +++ b/app/graphql/mutations/integrations/netsuite/create.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Netsuite + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "CreateNetsuiteIntegration" + description "Create Netsuite integration" + + input_object_class Types::Integrations::Netsuite::CreateInput + + type Types::Integrations::Netsuite + + def resolve(**args) + result = ::Integrations::Netsuite::CreateService + .call(params: args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/netsuite/update.rb b/app/graphql/mutations/integrations/netsuite/update.rb new file mode 100644 index 0000000..7a6054e --- /dev/null +++ b/app/graphql/mutations/integrations/netsuite/update.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Netsuite + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateNetsuiteIntegration" + description "Update Netsuite integration" + + input_object_class Types::Integrations::Netsuite::UpdateInput + + type Types::Integrations::Netsuite + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:id]) + result = ::Integrations::Netsuite::UpdateService.call(integration:, params: args) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/okta/create.rb b/app/graphql/mutations/integrations/okta/create.rb new file mode 100644 index 0000000..3df277c --- /dev/null +++ b/app/graphql/mutations/integrations/okta/create.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Okta + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "CreateOktaIntegration" + description "Create Okta integration" + + input_object_class Types::Integrations::Okta::CreateInput + + type Types::Integrations::Okta + + def resolve(**args) + result = ::Integrations::Okta::CreateService + .new(context[:current_user]) + .call(**args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/okta/update.rb b/app/graphql/mutations/integrations/okta/update.rb new file mode 100644 index 0000000..e89709f --- /dev/null +++ b/app/graphql/mutations/integrations/okta/update.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Okta + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateOktaIntegration" + description "Update Okta integration" + + input_object_class Types::Integrations::Okta::UpdateInput + + type Types::Integrations::Okta + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:id]) + result = ::Integrations::Okta::UpdateService.call(integration:, params: args) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/salesforce/create.rb b/app/graphql/mutations/integrations/salesforce/create.rb new file mode 100644 index 0000000..945cbd3 --- /dev/null +++ b/app/graphql/mutations/integrations/salesforce/create.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Salesforce + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "CreateSalesforceIntegration" + description "Create Salesforce integration" + + input_object_class Types::Integrations::Salesforce::CreateInput + + type Types::Integrations::Salesforce + + def resolve(**args) + result = ::Integrations::Salesforce::CreateService + .call(params: args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/salesforce/sync_invoice.rb b/app/graphql/mutations/integrations/salesforce/sync_invoice.rb new file mode 100644 index 0000000..0f00dd1 --- /dev/null +++ b/app/graphql/mutations/integrations/salesforce/sync_invoice.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Salesforce + class SyncInvoice < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "SyncSalesforceInvoice" + description "Sync Salesforce integration invoice" + + input_object_class Types::Integrations::Salesforce::SyncInvoiceInput + + field :invoice_id, ID, null: true + + def resolve(**args) + invoice = current_organization.invoices.find_by(id: args[:invoice_id]) + + result = ::Integrations::Salesforce::Invoices::SyncService.call(invoice) + result.success? ? result.invoice_id : result_error(result) + result + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/salesforce/update.rb b/app/graphql/mutations/integrations/salesforce/update.rb new file mode 100644 index 0000000..a34e5b1 --- /dev/null +++ b/app/graphql/mutations/integrations/salesforce/update.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Salesforce + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateSalesforceIntegration" + description "Update Salesforce integration" + + input_object_class Types::Integrations::Salesforce::UpdateInput + + type Types::Integrations::Salesforce + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:id]) + result = ::Integrations::Salesforce::UpdateService.call(integration:, params: args) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/sync_credit_note.rb b/app/graphql/mutations/integrations/sync_credit_note.rb new file mode 100644 index 0000000..f8db65e --- /dev/null +++ b/app/graphql/mutations/integrations/sync_credit_note.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + class SyncCreditNote < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "SyncIntegrationCreditNote" + description "Sync integration credit note" + + input_object_class Types::Integrations::SyncCreditNoteInput + + field :credit_note_id, ID, null: true + + def resolve(**args) + credit_note = current_organization.credit_notes.find_by(id: args[:credit_note_id]) + + result = ::Integrations::Aggregator::CreditNotes::CreateService.call_async(credit_note:) + result.success? ? result.credit_note_id : result_error(result) + result + end + end + end +end diff --git a/app/graphql/mutations/integrations/sync_invoice.rb b/app/graphql/mutations/integrations/sync_invoice.rb new file mode 100644 index 0000000..5b55a9f --- /dev/null +++ b/app/graphql/mutations/integrations/sync_invoice.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + class SyncInvoice < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "SyncIntegrationInvoice" + description "Sync integration invoice" + + input_object_class Types::Integrations::SyncInvoiceInput + + field :invoice_id, ID, null: true + + def resolve(**args) + invoice = current_organization.invoices.find_by(id: args[:invoice_id]) + + result = ::Integrations::Aggregator::Invoices::CreateService.call_async(invoice:) + result.success? ? result.invoice_id : result_error(result) + result + end + end + end +end diff --git a/app/graphql/mutations/integrations/xero/create.rb b/app/graphql/mutations/integrations/xero/create.rb new file mode 100644 index 0000000..2d0d013 --- /dev/null +++ b/app/graphql/mutations/integrations/xero/create.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Xero + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "CreateXeroIntegration" + description "Create Xero integration" + + input_object_class Types::Integrations::Xero::CreateInput + + type Types::Integrations::Xero + + def resolve(**args) + result = ::Integrations::Xero::CreateService + .new(context[:current_user]) + .call(**args.merge(organization_id: current_organization.id)) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/integrations/xero/update.rb b/app/graphql/mutations/integrations/xero/update.rb new file mode 100644 index 0000000..bc470e5 --- /dev/null +++ b/app/graphql/mutations/integrations/xero/update.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Integrations + module Xero + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateXeroIntegration" + description "Update Xero integration" + + input_object_class Types::Integrations::Xero::UpdateInput + + type Types::Integrations::Xero + + def resolve(**args) + integration = current_organization.integrations.find_by(id: args[:id]) + result = ::Integrations::Xero::UpdateService.call(integration:, params: args) + + result.success? ? result.integration : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/invites/accept.rb b/app/graphql/mutations/invites/accept.rb new file mode 100644 index 0000000..d43e4ef --- /dev/null +++ b/app/graphql/mutations/invites/accept.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Mutations + module Invites + class Accept < BaseMutation + graphql_name "AcceptInvite" + description "Accepts a new Invite" + + argument :email, String, required: true + argument :password, String, required: true + argument :token, String, required: true, description: "Uniq token of the Invite" + + type Types::Payloads::RegisterUserType + + def resolve(**args) + result = ::Invites::AcceptService.new.call(**args.merge(login_method: ::Organizations::AuthenticationMethods::EMAIL_PASSWORD)) + + result.success? ? result : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invites/create.rb b/app/graphql/mutations/invites/create.rb new file mode 100644 index 0000000..f2b85d7 --- /dev/null +++ b/app/graphql/mutations/invites/create.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Invites + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:members:create" + + graphql_name "CreateInvite" + description "Creates a new Invite" + + argument :email, String, required: true + argument :roles, [String], required: true + + type Types::Invites::Object + + def resolve(**args) + result = ::Invites::CreateService.call( + current_organization:, + user: context[:current_user], + email: args[:email], + roles: args[:roles] + ) + + result.success? ? result.invite : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invites/revoke.rb b/app/graphql/mutations/invites/revoke.rb new file mode 100644 index 0000000..7db4ab1 --- /dev/null +++ b/app/graphql/mutations/invites/revoke.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Invites + class Revoke < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:members:delete" + + graphql_name "RevokeInvite" + description "Revokes an invite" + + argument :id, ID, required: true + + type Types::Invites::Object + + def resolve(id:) + invite = current_organization.invites.pending.find_by(id:, status: :pending) + result = ::Invites::RevokeService.call(invite) + + result.success? ? result.invite : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invites/update.rb b/app/graphql/mutations/invites/update.rb new file mode 100644 index 0000000..f337640 --- /dev/null +++ b/app/graphql/mutations/invites/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Invites + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:members:update" + + graphql_name "UpdateInvite" + description "Update an invite" + + argument :id, ID, required: true + argument :roles, [String], required: true + + type Types::Invites::Object + + def resolve(**args) + invite = current_organization.invites.pending.find_by(id: args[:id]) + result = ::Invites::UpdateService.call(user: context[:current_user], invite:, params: {roles: args[:roles]}) + result.success? ? result.invite : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoice_custom_sections/create.rb b/app/graphql/mutations/invoice_custom_sections/create.rb new file mode 100644 index 0000000..2eca3f9 --- /dev/null +++ b/app/graphql/mutations/invoice_custom_sections/create.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module InvoiceCustomSections + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoice_custom_sections:create" + + graphql_name "CreateInvoiceCustomSection" + description "Creates a new InvoiceCustomSection" + + input_object_class Types::InvoiceCustomSections::CreateInput + + type Types::InvoiceCustomSections::Object + + def resolve(**args) + result = ::InvoiceCustomSections::CreateService.call( + organization: current_organization, create_params: args.to_h + ) + + result.success? ? result.invoice_custom_section : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoice_custom_sections/destroy.rb b/app/graphql/mutations/invoice_custom_sections/destroy.rb new file mode 100644 index 0000000..aa817ba --- /dev/null +++ b/app/graphql/mutations/invoice_custom_sections/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module InvoiceCustomSections + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoice_custom_sections:delete" + + graphql_name "DestroyInvoiceCustomSection" + description "Deletes an invoice_custom_section" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + invoice_custom_section = current_organization.invoice_custom_sections.find_by(id:) + result = ::InvoiceCustomSections::DestroyService.call(invoice_custom_section:) + + result.success? ? result.invoice_custom_section : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoice_custom_sections/update.rb b/app/graphql/mutations/invoice_custom_sections/update.rb new file mode 100644 index 0000000..9a9dab1 --- /dev/null +++ b/app/graphql/mutations/invoice_custom_sections/update.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module InvoiceCustomSections + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoice_custom_sections:update" + + graphql_name "UpdateInvoiceCustomSection" + description "Updates an InvoiceCustomSection" + + input_object_class Types::InvoiceCustomSections::UpdateInput + + type Types::InvoiceCustomSections::Object + + def resolve(**args) + invoice_custom_section = current_organization + .invoice_custom_sections + .find_by(id: args.delete(:id)) + + result = ::InvoiceCustomSections::UpdateService.call( + invoice_custom_section:, update_params: args.to_h + ) + + result.success? ? result.invoice_custom_section : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/create.rb b/app/graphql/mutations/invoices/create.rb new file mode 100644 index 0000000..7259798 --- /dev/null +++ b/app/graphql/mutations/invoices/create.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:create" + + graphql_name "CreateInvoice" + description "Creates a new Invoice" + + input_object_class Types::Invoices::CreateInvoiceInput + + type Types::Invoices::Object + + def resolve(**args) + customer = current_organization.customers.find_by(id: args[:customer_id]) + + result = ::Invoices::CreateOneOffService.call( + customer:, + currency: args[:currency], + fees: args[:fees], + timestamp: Time.current.to_i, + voided_invoice_id: args[:voided_invoice_id], + payment_method_params: args[:payment_method]&.to_h, + invoice_custom_section: args[:invoice_custom_section], + billing_entity_id: args[:billing_entity_id] + ) + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/download.rb b/app/graphql/mutations/invoices/download.rb new file mode 100644 index 0000000..99d3862 --- /dev/null +++ b/app/graphql/mutations/invoices/download.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class Download < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:view" + + graphql_name "DownloadInvoice" + description "Download an Invoice PDF" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(id:) + invoice = current_organization.invoices.visible.find_by(id:) + result = ::Invoices::GeneratePdfService.call(invoice:) + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/download_xml.rb b/app/graphql/mutations/invoices/download_xml.rb new file mode 100644 index 0000000..c13a494 --- /dev/null +++ b/app/graphql/mutations/invoices/download_xml.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class DownloadXml < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:view" + + graphql_name "DownloadXmlInvoice" + description "Download an Invoice XML" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(id:) + invoice = current_organization.invoices.visible.find_by(id:) + result = ::Invoices::GenerateXmlService.call(invoice:) + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/finalize.rb b/app/graphql/mutations/invoices/finalize.rb new file mode 100644 index 0000000..63650b5 --- /dev/null +++ b/app/graphql/mutations/invoices/finalize.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class Finalize < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "FinalizeInvoice" + description "Finalize a draft invoice" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(**args) + result = ::Invoices::RefreshDraftAndFinalizeService.call( + invoice: current_organization.invoices.draft.find_by(id: args[:id]) + ) + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/finalize_all.rb b/app/graphql/mutations/invoices/finalize_all.rb new file mode 100644 index 0000000..138a00f --- /dev/null +++ b/app/graphql/mutations/invoices/finalize_all.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class FinalizeAll < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "FinalizeAllInvoices" + description "Finalize all draft invoices" + + type Types::Invoices::Object.collection_type + + def resolve + result = ::Invoices::FinalizeBatchService.new(organization: current_organization).call_async + + result.success? ? Kaminari.paginate_array(result.invoices) : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/generate_payment_url.rb b/app/graphql/mutations/invoices/generate_payment_url.rb new file mode 100644 index 0000000..d0ade94 --- /dev/null +++ b/app/graphql/mutations/invoices/generate_payment_url.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class GeneratePaymentUrl < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + description "Generates a payment URL for an invoice" + + argument :invoice_id, ID, required: true + + field :payment_url, String, null: true + + def resolve(invoice_id:) + invoice = current_organization.invoices.visible.includes(:customer).find_by(id: invoice_id) + return not_found_error(resource: "invoice") unless invoice + + result = ::Invoices::Payments::GeneratePaymentUrlService.call(invoice:) + + result.success? ? result : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/lose_dispute.rb b/app/graphql/mutations/invoices/lose_dispute.rb new file mode 100644 index 0000000..27a0bdc --- /dev/null +++ b/app/graphql/mutations/invoices/lose_dispute.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class LoseDispute < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "LoseInvoiceDispute" + description "Mark payment dispute as lost" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(**args) + result = ::Invoices::LoseDisputeService.call( + invoice: current_organization.invoices.visible.find_by(id: args[:id]), + payment_dispute_lost_at: DateTime.current + ) + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/refresh.rb b/app/graphql/mutations/invoices/refresh.rb new file mode 100644 index 0000000..35d06ea --- /dev/null +++ b/app/graphql/mutations/invoices/refresh.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class Refresh < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "RefreshInvoice" + description "Refresh a draft invoice" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(**args) + result = ::Invoices::RefreshDraftService.call( + invoice: current_organization.invoices.visible.find_by(id: args[:id]) + ) + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/regenerate_from_voided.rb b/app/graphql/mutations/invoices/regenerate_from_voided.rb new file mode 100644 index 0000000..92549da --- /dev/null +++ b/app/graphql/mutations/invoices/regenerate_from_voided.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# app/graphql/mutations/invoices/regenerate_from_voided.rb +module Mutations + module Invoices + class RegenerateFromVoided < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "RegenerateInvoice" + description "Regenerate an invoice from a voided invoice" + + argument :fees, [Types::Invoices::VoidedInvoiceFeeInput], required: true + argument :voided_invoice_id, ID, required: true + + type Types::Invoices::Object + + def resolve(voided_invoice_id:, fees:) + invoice = current_organization.invoices.visible.find_by(id: voided_invoice_id) + result = ::Invoices::RegenerateFromVoidedService.new(voided_invoice: invoice, fees_params: fees).call + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/resend_email.rb b/app/graphql/mutations/invoices/resend_email.rb new file mode 100644 index 0000000..f0a4511 --- /dev/null +++ b/app/graphql/mutations/invoices/resend_email.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class ResendEmail < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:send" + + graphql_name "ResendInvoiceEmail" + description "Resend invoice email with optional custom recipients" + + input_object_class Types::Emails::ResendEmailInput + + type Types::Invoices::Object + + def resolve(**args) + invoice = current_organization.invoices.visible.find_by(id: args[:id]) + + result = ::Emails::ResendService.call( + resource: invoice, + to: args[:to], + cc: args[:cc], + bcc: args[:bcc] + ) + + result.success? ? invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/retry.rb b/app/graphql/mutations/invoices/retry.rb new file mode 100644 index 0000000..05e0ce6 --- /dev/null +++ b/app/graphql/mutations/invoices/retry.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class Retry < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "RetryInvoice" + description "Retry failed invoice" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(**args) + invoice = current_organization.invoices.visible.find_by(id: args[:id]) + result = ::Invoices::RetryService.new(invoice:).call + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/retry_all.rb b/app/graphql/mutations/invoices/retry_all.rb new file mode 100644 index 0000000..e11eea1 --- /dev/null +++ b/app/graphql/mutations/invoices/retry_all.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class RetryAll < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "RetryAllInvoices" + description "Retry all failed invoices" + + type Types::Invoices::Object.collection_type + + def resolve + result = ::Invoices::RetryBatchService.new(organization: current_organization).call_async + + result.success? ? Kaminari.paginate_array(result.invoices) : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/retry_all_payments.rb b/app/graphql/mutations/invoices/retry_all_payments.rb new file mode 100644 index 0000000..953b1e6 --- /dev/null +++ b/app/graphql/mutations/invoices/retry_all_payments.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class RetryAllPayments < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "RetryAllInvoicePayments" + description "Retry all invoice payments" + + type Types::Invoices::Object.collection_type + + def resolve + result = ::Invoices::Payments::RetryBatchService.new(organization_id: current_organization.id).call_async + + result.success? ? Kaminari.paginate_array(result.invoices) : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/retry_payment.rb b/app/graphql/mutations/invoices/retry_payment.rb new file mode 100644 index 0000000..94efc75 --- /dev/null +++ b/app/graphql/mutations/invoices/retry_payment.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class RetryPayment < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + graphql_name "RetryInvoicePayment" + description "Retry invoice payment" + + input_object_class Types::Invoices::RetryPaymentInput + + type Types::Invoices::Object + + def resolve(**args) + invoice = current_organization.invoices.visible.find_by(id: args[:id]) + result = ::Invoices::Payments::RetryService.new( + invoice:, + payment_method_params: args[:payment_method]&.to_h + ).call + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/retry_tax_provider_voiding.rb b/app/graphql/mutations/invoices/retry_tax_provider_voiding.rb new file mode 100644 index 0000000..cd4b3f5 --- /dev/null +++ b/app/graphql/mutations/invoices/retry_tax_provider_voiding.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class RetryTaxProviderVoiding < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:update" + + description "Retry voided invoice sync" + + argument :id, ID, required: true + + type Types::Invoices::Object + + def resolve(**args) + invoice = current_organization.invoices.visible.find_by(id: args[:id]) + result = ::Invoices::ProviderTaxes::VoidService.call(invoice:) + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/update.rb b/app/graphql/mutations/invoices/update.rb new file mode 100644 index 0000000..621c0e6 --- /dev/null +++ b/app/graphql/mutations/invoices/update.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class Update < BaseMutation + include AuthenticableApiUser + + REQUIRED_PERMISSION = "invoices:update" + + description "Update an existing invoice" + graphql_name "UpdateInvoice" + + input_object_class Types::Invoices::UpdateInvoiceInput + + type Types::Invoices::Object + + def resolve(**args) + invoice = context[:current_organization].invoices.visible.find_by(id: args[:id]) + result = ::Invoices::UpdateService.new(invoice:, params: args, webhook_notification: true).call + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/invoices/void.rb b/app/graphql/mutations/invoices/void.rb new file mode 100644 index 0000000..cb07c09 --- /dev/null +++ b/app/graphql/mutations/invoices/void.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Invoices + class Void < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:void" + + graphql_name "VoidInvoice" + description "Void an invoice" + + input_object_class Types::Invoices::VoidInvoiceInput + + type Types::Invoices::Object + + def resolve(**args) + invoice = current_organization.invoices.visible.find_by(id: args[:id]) + params = args.slice(:generate_credit_note, :refund_amount, :credit_amount) + + result = ::Invoices::VoidService.call( + invoice: invoice, + params: params + ) + + result.success? ? result.invoice : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/login_user.rb b/app/graphql/mutations/login_user.rb new file mode 100644 index 0000000..a2a04a0 --- /dev/null +++ b/app/graphql/mutations/login_user.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +# Mutations::LoginUser Mutation +module Mutations + class LoginUser < BaseMutation + description "Opens a session for an existing user" + + argument :email, String, required: true + argument :password, String, required: true + + type Types::Payloads::LoginUserType + + def resolve(email:, password:) + result = UsersService.new.login(email, password) + result.success? ? result : result_error(result) + end + end +end diff --git a/app/graphql/mutations/memberships/revoke.rb b/app/graphql/mutations/memberships/revoke.rb new file mode 100644 index 0000000..709a18b --- /dev/null +++ b/app/graphql/mutations/memberships/revoke.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Memberships + class Revoke < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:members:update" + + graphql_name "RevokeMembership" + description "Revoke a membership" + + argument :id, ID, required: true + + type Types::MembershipType + + def resolve(id:) + membership = current_organization.memberships.find_by(id: id) + result = ::Memberships::RevokeService.call(user: context[:current_user], membership:) + + result.success? ? result.membership : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/memberships/update.rb b/app/graphql/mutations/memberships/update.rb new file mode 100644 index 0000000..66fc4f6 --- /dev/null +++ b/app/graphql/mutations/memberships/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Memberships + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:members:update" + + graphql_name "UpdateMembership" + description "Update a membership" + + argument :id, ID, required: true + argument :roles, [String], required: true + + type Types::MembershipType + + def resolve(**args) + membership = current_organization.memberships.find_by(id: args[:id]) + result = ::Memberships::UpdateService.call(user: context[:current_user], membership:, params: args) + result.success? ? result.membership : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/organizations/update.rb b/app/graphql/mutations/organizations/update.rb new file mode 100644 index 0000000..97b3215 --- /dev/null +++ b/app/graphql/mutations/organizations/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Organizations + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + graphql_name "UpdateOrganization" + description "Updates an Organization" + + input_object_class Types::Organizations::UpdateOrganizationInput + + type Types::Organizations::CurrentOrganizationType + + def resolve(**args) + result = ::Organizations::UpdateService.call( + organization: current_organization, + params: args, + user: context[:current_user] + ) + result.success? ? result.organization : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/password_resets/create.rb b/app/graphql/mutations/password_resets/create.rb new file mode 100644 index 0000000..12e0966 --- /dev/null +++ b/app/graphql/mutations/password_resets/create.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PasswordResets + class Create < BaseMutation + graphql_name "CreatePasswordReset" + description "Creates a new password reset" + + argument :email, String, required: true + field :id, String, null: false + + def resolve(email:) + user = User.find_by(email:) + result = ::PasswordResets::CreateService.call(user:) + + result.success? ? result : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/password_resets/reset.rb b/app/graphql/mutations/password_resets/reset.rb new file mode 100644 index 0000000..bed600d --- /dev/null +++ b/app/graphql/mutations/password_resets/reset.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Mutations + module PasswordResets + class Reset < BaseMutation + graphql_name "ResetPassword" + description "Reset password for user and log in" + + argument :new_password, String, required: true + argument :token, String, required: true + + type Types::Payloads::LoginUserType + + def resolve(new_password:, token:) + result = ::PasswordResets::ResetService.call(token:, new_password:) + + result.success? ? result : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_methods/destroy.rb b/app/graphql/mutations/payment_methods/destroy.rb new file mode 100644 index 0000000..8d42bba --- /dev/null +++ b/app/graphql/mutations/payment_methods/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module PaymentMethods + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payment_methods:delete" + + graphql_name "DestroyPaymentMethod" + description "Deletes a payment method" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + payment_method = current_organization.payment_methods.find_by(id:) + result = ::PaymentMethods::DestroyService.call(payment_method:) + + result.success? ? result.payment_method : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_methods/generate_checkout_url.rb b/app/graphql/mutations/payment_methods/generate_checkout_url.rb new file mode 100644 index 0000000..3b0d50b --- /dev/null +++ b/app/graphql/mutations/payment_methods/generate_checkout_url.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module PaymentMethods + class GenerateCheckoutUrl < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payment_methods:create" + + description "Generates checkout url for payment method" + + argument :customer_id, ID, required: true + + field :checkout_url, String, null: false + + def resolve(**args) + customer = current_organization.customers.find_by(id: args[:customer_id]) + + result = ::Customers::GenerateCheckoutUrlService.call(customer:) + + result.success? ? result : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_methods/set_as_default.rb b/app/graphql/mutations/payment_methods/set_as_default.rb new file mode 100644 index 0000000..b0dae0c --- /dev/null +++ b/app/graphql/mutations/payment_methods/set_as_default.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module PaymentMethods + class SetAsDefault < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payment_methods:update" + + description "Set payment method as default" + + argument :id, ID, required: true + + type Types::PaymentMethods::Object + + def resolve(**args) + payment_method = current_organization.payment_methods.find_by(id: args[:id]) + result = ::PaymentMethods::SetAsDefaultService.call(payment_method:) + + result.success? ? result.payment_method : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/adyen/base.rb b/app/graphql/mutations/payment_providers/adyen/base.rb new file mode 100644 index 0000000..067652d --- /dev/null +++ b/app/graphql/mutations/payment_providers/adyen/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Adyen + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::AdyenService + .new(context[:current_user]) + .create_or_update(**args.merge(organization: current_organization)) + + result.success? ? result.adyen_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/adyen/create.rb b/app/graphql/mutations/payment_providers/adyen/create.rb new file mode 100644 index 0000000..9261ad9 --- /dev/null +++ b/app/graphql/mutations/payment_providers/adyen/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Adyen + class Create < Base + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "AddAdyenPaymentProvider" + description "Add Adyen payment provider" + + input_object_class Types::PaymentProviders::AdyenInput + + type Types::PaymentProviders::Adyen + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/adyen/update.rb b/app/graphql/mutations/payment_providers/adyen/update.rb new file mode 100644 index 0000000..c4e584d --- /dev/null +++ b/app/graphql/mutations/payment_providers/adyen/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Adyen + class Update < Base + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateAdyenPaymentProvider" + description "Update Adyen payment provider" + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Adyen + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/cashfree/base.rb b/app/graphql/mutations/payment_providers/cashfree/base.rb new file mode 100644 index 0000000..a28059e --- /dev/null +++ b/app/graphql/mutations/payment_providers/cashfree/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Cashfree + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::CashfreeService + .new(context[:current_user]) + .create_or_update(**args.merge(organization: current_organization)) + + result.success? ? result.cashfree_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/cashfree/create.rb b/app/graphql/mutations/payment_providers/cashfree/create.rb new file mode 100644 index 0000000..a6736f7 --- /dev/null +++ b/app/graphql/mutations/payment_providers/cashfree/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Cashfree + class Create < Base + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "AddCashfreePaymentProvider" + description "Add or update Cashfree payment provider" + + input_object_class Types::PaymentProviders::CashfreeInput + + type Types::PaymentProviders::Cashfree + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/cashfree/update.rb b/app/graphql/mutations/payment_providers/cashfree/update.rb new file mode 100644 index 0000000..d4c3468 --- /dev/null +++ b/app/graphql/mutations/payment_providers/cashfree/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Cashfree + class Update < Base + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateCashfreePaymentProvider" + description "Update Cashfree payment provider" + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Cashfree + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/destroy.rb b/app/graphql/mutations/payment_providers/destroy.rb new file mode 100644 index 0000000..a4c5a62 --- /dev/null +++ b/app/graphql/mutations/payment_providers/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:delete" + + graphql_name "DestroyPaymentProvider" + description "Destroy a payment provider" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + payment_provider = current_organization.payment_providers.find_by(id:) + result = ::PaymentProviders::DestroyService.call(payment_provider) + + result.success? ? result.payment_provider : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/flutterwave/base.rb b/app/graphql/mutations/payment_providers/flutterwave/base.rb new file mode 100644 index 0000000..e41cf50 --- /dev/null +++ b/app/graphql/mutations/payment_providers/flutterwave/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Flutterwave + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::FlutterwaveService + .new(context[:current_user]) + .create_or_update(**args.merge(organization: current_organization)) + + result.success? ? result.flutterwave_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/flutterwave/create.rb b/app/graphql/mutations/payment_providers/flutterwave/create.rb new file mode 100644 index 0000000..481b245 --- /dev/null +++ b/app/graphql/mutations/payment_providers/flutterwave/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Flutterwave + class Create < Base + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "AddFlutterwavePaymentProvider" + description "Add Flutterwave payment provider" + + input_object_class Types::PaymentProviders::FlutterwaveInput + + type Types::PaymentProviders::Flutterwave + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/flutterwave/update.rb b/app/graphql/mutations/payment_providers/flutterwave/update.rb new file mode 100644 index 0000000..2a1b286 --- /dev/null +++ b/app/graphql/mutations/payment_providers/flutterwave/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Flutterwave + class Update < Base + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateFlutterwavePaymentProvider" + description "Update Flutterwave payment provider" + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Flutterwave + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/gocardless/base.rb b/app/graphql/mutations/payment_providers/gocardless/base.rb new file mode 100644 index 0000000..c100058 --- /dev/null +++ b/app/graphql/mutations/payment_providers/gocardless/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Gocardless + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::GocardlessService + .new(context[:current_user]) + .create_or_update(**args.merge(organization: current_organization)) + + result.success? ? result.gocardless_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/gocardless/create.rb b/app/graphql/mutations/payment_providers/gocardless/create.rb new file mode 100644 index 0000000..8d05bb9 --- /dev/null +++ b/app/graphql/mutations/payment_providers/gocardless/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Gocardless + class Create < Base + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "AddGocardlessPaymentProvider" + description "Add or update Gocardless payment provider" + + input_object_class Types::PaymentProviders::GocardlessInput + + type Types::PaymentProviders::Gocardless + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/gocardless/update.rb b/app/graphql/mutations/payment_providers/gocardless/update.rb new file mode 100644 index 0000000..0db55de --- /dev/null +++ b/app/graphql/mutations/payment_providers/gocardless/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Gocardless + class Update < Base + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateGocardlessPaymentProvider" + description "Update Gocardless payment provider" + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Gocardless + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/moneyhash/base.rb b/app/graphql/mutations/payment_providers/moneyhash/base.rb new file mode 100644 index 0000000..3c17f97 --- /dev/null +++ b/app/graphql/mutations/payment_providers/moneyhash/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Moneyhash + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::MoneyhashService + .new(context[:current_user]) + .create_or_update(**args.merge(organization: current_organization)) + + result.success? ? result.moneyhash_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/moneyhash/create.rb b/app/graphql/mutations/payment_providers/moneyhash/create.rb new file mode 100644 index 0000000..b46f04e --- /dev/null +++ b/app/graphql/mutations/payment_providers/moneyhash/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Moneyhash + class Create < Base + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "AddMoneyhashPaymentProvider" + description "Add Moneyhash payment provider" + + input_object_class Types::PaymentProviders::MoneyhashInput + + type Types::PaymentProviders::Moneyhash + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/moneyhash/update.rb b/app/graphql/mutations/payment_providers/moneyhash/update.rb new file mode 100644 index 0000000..4f23d76 --- /dev/null +++ b/app/graphql/mutations/payment_providers/moneyhash/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Moneyhash + class Update < Base + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateMoneyhashPaymentProvider" + description "Update Moneyhash payment provider" + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Moneyhash + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/stripe/base.rb b/app/graphql/mutations/payment_providers/stripe/base.rb new file mode 100644 index 0000000..8569ce1 --- /dev/null +++ b/app/graphql/mutations/payment_providers/stripe/base.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Stripe + class Base < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + def resolve(**args) + result = ::PaymentProviders::StripeService + .new(context[:current_user]) + .create_or_update(**args.merge(organization_id: current_organization.id)) + + result.success? ? result.stripe_provider : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/stripe/create.rb b/app/graphql/mutations/payment_providers/stripe/create.rb new file mode 100644 index 0000000..afaa0c5 --- /dev/null +++ b/app/graphql/mutations/payment_providers/stripe/create.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Stripe + class Create < Base + REQUIRED_PERMISSION = "organization:integrations:create" + + graphql_name "AddStripePaymentProvider" + description "Add Stripe API keys to the organization" + + input_object_class Types::PaymentProviders::StripeInput + + type Types::PaymentProviders::Stripe + end + end + end +end diff --git a/app/graphql/mutations/payment_providers/stripe/update.rb b/app/graphql/mutations/payment_providers/stripe/update.rb new file mode 100644 index 0000000..96ff6c0 --- /dev/null +++ b/app/graphql/mutations/payment_providers/stripe/update.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mutations + module PaymentProviders + module Stripe + class Update < Base + REQUIRED_PERMISSION = "organization:integrations:update" + + graphql_name "UpdateStripePaymentProvider" + description "Update Stripe payment provider" + + input_object_class Types::PaymentProviders::UpdateInput + + type Types::PaymentProviders::Stripe + end + end + end +end diff --git a/app/graphql/mutations/payment_receipts/download.rb b/app/graphql/mutations/payment_receipts/download.rb new file mode 100644 index 0000000..745079b --- /dev/null +++ b/app/graphql/mutations/payment_receipts/download.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module PaymentReceipts + class Download < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:view" + + graphql_name "DownloadPaymentReceipt" + description "Download an PaymentReceipt PDF" + + argument :id, ID, required: true + + type Types::PaymentReceipts::Object + + def resolve(id:) + payment_receipt = current_organization.payment_receipts.find_by(id:) + result = ::PaymentReceipts::GeneratePdfService.call(payment_receipt:) + result.success? ? result.payment_receipt : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_receipts/download_xml.rb b/app/graphql/mutations/payment_receipts/download_xml.rb new file mode 100644 index 0000000..2f000ef --- /dev/null +++ b/app/graphql/mutations/payment_receipts/download_xml.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module PaymentReceipts + class DownloadXml < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:view" + + graphql_name "DownloadXMLPaymentReceipt" + description "Download an PaymentReceipt XML" + + argument :id, ID, required: true + + type Types::PaymentReceipts::Object + + def resolve(id:) + payment_receipt = current_organization.payment_receipts.find_by(id:) + result = ::PaymentReceipts::GenerateXmlService.call(payment_receipt:) + result.success? ? result.payment_receipt : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_receipts/resend_email.rb b/app/graphql/mutations/payment_receipts/resend_email.rb new file mode 100644 index 0000000..80cdb9b --- /dev/null +++ b/app/graphql/mutations/payment_receipts/resend_email.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module PaymentReceipts + class ResendEmail < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payment_receipts:send" + + graphql_name "ResendPaymentReceiptEmail" + description "Resend payment receipt email with optional custom recipients" + + input_object_class Types::Emails::ResendEmailInput + + type Types::PaymentReceipts::Object + + def resolve(**args) + payment_receipt = PaymentReceipt.find_by(id: args[:id], organization_id: current_organization.id) + + result = ::Emails::ResendService.call( + resource: payment_receipt, + to: args[:to], + cc: args[:cc], + bcc: args[:bcc] + ) + + result.success? ? payment_receipt : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payment_requests/create.rb b/app/graphql/mutations/payment_requests/create.rb new file mode 100644 index 0000000..28238f3 --- /dev/null +++ b/app/graphql/mutations/payment_requests/create.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module PaymentRequests + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payments:create" + + graphql_name "CreatePaymentRequest" + description "Creates a payment request" + + input_object_class Types::PaymentRequests::CreateInput + type Types::PaymentRequests::Object + + def resolve(**args) + result = ::PaymentRequests::CreateService.call(organization: current_organization, params: args) + result.success? ? result.payment_request : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/payments/create.rb b/app/graphql/mutations/payments/create.rb new file mode 100644 index 0000000..3b9cb67 --- /dev/null +++ b/app/graphql/mutations/payments/create.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + module Payments + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payments:create" + + graphql_name "CreatePayment" + description "Creates a manual payment" + + input_object_class Types::Payments::CreateInput + type Types::Payments::Object + + def resolve(**args) + result = ::Payments::ManualCreateService.call(organization: current_organization, params: args) + result.success? ? result.payment : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/plans/create.rb b/app/graphql/mutations/plans/create.rb new file mode 100644 index 0000000..81329ef --- /dev/null +++ b/app/graphql/mutations/plans/create.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module Plans + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "plans:create" + + graphql_name "CreatePlan" + description "Creates a new Plan" + + input_object_class Types::Plans::CreateInput + type Types::Plans::Object + + def resolve(entitlements: nil, **args) + args[:charges].map!(&:to_h) + args[:fixed_charges]&.map!(&:to_h) + + result = ::Plans::CreateService.call(args.merge(organization_id: current_organization.id)) + + return result_error(result) unless result.success? + + plan = result.plan + + unless entitlements.nil? + result = ::Entitlement::PlanEntitlementsUpdateService.call( + organization: plan.organization, + plan:, + entitlements_params: Utils::Entitlement.convert_gql_input_to_params(entitlements), + partial: false + ) + end + + result.success? ? plan.reload : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/plans/destroy.rb b/app/graphql/mutations/plans/destroy.rb new file mode 100644 index 0000000..9aaf4ff --- /dev/null +++ b/app/graphql/mutations/plans/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Plans + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "plans:delete" + + graphql_name "DestroyPlan" + description "Deletes a Plan" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + plan = current_organization.plans.find_by(id:) + result = ::Plans::PrepareDestroyService.call(plan:) + + result.success? ? result.plan : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/plans/update.rb b/app/graphql/mutations/plans/update.rb new file mode 100644 index 0000000..97a6f84 --- /dev/null +++ b/app/graphql/mutations/plans/update.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Plans + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "plans:update" + + graphql_name "UpdatePlan" + description "Updates an existing Plan" + + input_object_class Types::Plans::UpdateInput + type Types::Plans::Object + + def resolve(entitlements: nil, **args) + args[:charges].map!(&:to_h) + args[:fixed_charges]&.map!(&:to_h) + plan = current_organization.plans.find_by(id: args[:id]) + + result = ::Plans::UpdateService.call(plan:, params: args) + + return result_error(result) unless result.success? + + unless entitlements.nil? + result = ::Entitlement::PlanEntitlementsUpdateService.call( + organization: plan.organization, + plan:, + entitlements_params: Utils::Entitlement.convert_gql_input_to_params(entitlements), + partial: false + ) + end + + result.success? ? plan.reload : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/pricing_units/create.rb b/app/graphql/mutations/pricing_units/create.rb new file mode 100644 index 0000000..54e858a --- /dev/null +++ b/app/graphql/mutations/pricing_units/create.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module PricingUnits + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "pricing_units:create" + + graphql_name "CreatePricingUnit" + description "Creates a new pricing unit" + + input_object_class Types::PricingUnits::CreateInput + + type Types::PricingUnits::Object + + def resolve(**args) + result = ::PricingUnits::CreateService.call(args.merge(organization: current_organization)) + + result.success? ? result.pricing_unit : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/pricing_units/update.rb b/app/graphql/mutations/pricing_units/update.rb new file mode 100644 index 0000000..55140c3 --- /dev/null +++ b/app/graphql/mutations/pricing_units/update.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module PricingUnits + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "pricing_units:update" + + graphql_name "UpdatePricingUnit" + + input_object_class Types::PricingUnits::UpdateInput + + type Types::PricingUnits::Object + + def resolve(id:, **params) + pricing_unit = current_organization.pricing_units.find_by(id:) + result = ::PricingUnits::UpdateService.call(pricing_unit:, params:) + + result.success? ? result.pricing_unit : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/quote_versions/approve.rb b/app/graphql/mutations/quote_versions/approve.rb new file mode 100644 index 0000000..e81d029 --- /dev/null +++ b/app/graphql/mutations/quote_versions/approve.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module QuoteVersions + class Approve < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:approve" + + graphql_name "ApproveQuoteVersion" + description "Approve a quote version" + + argument :id, ID, required: true + + type Types::QuoteVersions::Object + + def resolve(**args) + quote_version = current_organization.quote_versions.find_by(id: args[:id]) + result = ::QuoteVersions::ApproveService.call(quote_version:) + + result.success? ? result.quote_version : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/quote_versions/clone.rb b/app/graphql/mutations/quote_versions/clone.rb new file mode 100644 index 0000000..50e7cc3 --- /dev/null +++ b/app/graphql/mutations/quote_versions/clone.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module QuoteVersions + class Clone < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:clone" + + graphql_name "CloneQuoteVersion" + description "Clone a quote version" + + argument :id, ID, required: true + + type Types::QuoteVersions::Object + + def resolve(**args) + quote_version = current_organization.quote_versions.find_by(id: args[:id]) + result = ::QuoteVersions::CloneService.call(quote_version:) + + result.success? ? result.quote_version : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/quote_versions/update.rb b/app/graphql/mutations/quote_versions/update.rb new file mode 100644 index 0000000..541f2e2 --- /dev/null +++ b/app/graphql/mutations/quote_versions/update.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module QuoteVersions + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:update" + + graphql_name "UpdateQuoteVersion" + description "Update a quote version" + + input_object_class Types::QuoteVersions::UpdateInput + + type Types::QuoteVersions::Object + + def resolve(**args) + quote_version = current_organization.quote_versions.find_by(id: args[:id]) + result = ::QuoteVersions::UpdateService.call( + quote_version:, + params: args.except(:id) + ) + + result.success? ? result.quote_version : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/quote_versions/void.rb b/app/graphql/mutations/quote_versions/void.rb new file mode 100644 index 0000000..0b83015 --- /dev/null +++ b/app/graphql/mutations/quote_versions/void.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module QuoteVersions + class Void < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:void" + + graphql_name "VoidQuoteVersion" + description "Void a quote version" + + argument :id, ID, required: true + argument :reason, Types::QuoteVersions::VoidReasonEnum, required: true + + type Types::QuoteVersions::Object + + def resolve(**args) + quote_version = current_organization.quote_versions.find_by(id: args[:id]) + result = ::QuoteVersions::VoidService.call( + quote_version:, + reason: args[:reason] + ) + + result.success? ? result.quote_version : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/quotes/create.rb b/app/graphql/mutations/quotes/create.rb new file mode 100644 index 0000000..73db263 --- /dev/null +++ b/app/graphql/mutations/quotes/create.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Quotes + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:create" + + graphql_name "CreateQuote" + description "Create a new quote" + + input_object_class Types::Quotes::CreateInput + + type Types::Quotes::Object + + def resolve(**args) + customer = current_organization.customers.find_by(id: args[:customer_id]) + subscription = customer&.subscriptions&.find_by(id: args[:subscription_id]) if args[:subscription_id] + result = ::Quotes::CreateService.call( + organization: current_organization, + customer:, + subscription:, + params: args.except(:customer_id, :subscription_id) + ) + result.success? ? result.quote : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/quotes/update.rb b/app/graphql/mutations/quotes/update.rb new file mode 100644 index 0000000..de8984a --- /dev/null +++ b/app/graphql/mutations/quotes/update.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module Quotes + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:update" + + graphql_name "UpdateQuote" + description "Update a quote" + + input_object_class Types::Quotes::UpdateInput + + type Types::Quotes::Object + + def resolve(**args) + quote = current_organization.quotes.find_by(id: args[:id]) + result = ::Quotes::UpdateService.call( + quote:, + params: args.except(:id) + ) + + result.success? ? result.quote : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/register_user.rb b/app/graphql/mutations/register_user.rb new file mode 100644 index 0000000..4411afe --- /dev/null +++ b/app/graphql/mutations/register_user.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mutations + class RegisterUser < BaseMutation + description "Registers a new user and creates related organization" + + argument :email, String, required: true + argument :organization_name, String, required: true + argument :password, String, required: true + + type Types::Payloads::RegisterUserType + + def resolve(email:, password:, organization_name:) + result = UsersService.new.register( + email, + password, + organization_name + ) + + result.success? ? result : result_error(result) + end + end +end diff --git a/app/graphql/mutations/roles/create.rb b/app/graphql/mutations/roles/create.rb new file mode 100644 index 0000000..233adb9 --- /dev/null +++ b/app/graphql/mutations/roles/create.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Roles + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "roles:create" + + graphql_name "CreateRole" + description "Creates a new custom role" + + input_object_class Types::Roles::CreateInput + + type Types::RoleType + + def resolve(**args) + result = ::Roles::CreateService.call( + organization: current_organization, + code: args[:code], + name: args[:name], + description: args[:description], + permissions: args[:permissions] + ) + + result.success? ? result.role : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/roles/destroy.rb b/app/graphql/mutations/roles/destroy.rb new file mode 100644 index 0000000..caa7ecc --- /dev/null +++ b/app/graphql/mutations/roles/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Roles + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "roles:delete" + + graphql_name "DestroyRole" + description "Deletes a custom role" + + argument :id, ID, required: true + + type Types::RoleType + + def resolve(id:) + role = Role.with_organization(current_organization.id).find_by(id:) + result = ::Roles::DestroyService.call(role:) + + result.success? ? result.role : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/roles/update.rb b/app/graphql/mutations/roles/update.rb new file mode 100644 index 0000000..aa007ea --- /dev/null +++ b/app/graphql/mutations/roles/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Roles + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "roles:update" + + graphql_name "UpdateRole" + description "Updates an existing custom role" + + input_object_class Types::Roles::UpdateInput + + type Types::RoleType + + def resolve(id:, **args) + role = Role.with_organization(current_organization.id).find_by(id:) + result = ::Roles::UpdateService.call(role:, params: args) + + result.success? ? result.role : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/alerts/create.rb b/app/graphql/mutations/subscriptions/alerts/create.rb new file mode 100644 index 0000000..eb1769a --- /dev/null +++ b/app/graphql/mutations/subscriptions/alerts/create.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + module Alerts + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "CreateSubscriptionAlert" + description "Creates a new Alert for subscription" + + input_object_class Types::UsageMonitoring::Alerts::CreateInput + argument :subscription_id, ID, required: true + + type Types::UsageMonitoring::Alerts::Object + + def resolve(**args) + result = ::UsageMonitoring::CreateAlertService.call( + organization: current_organization, + alertable: current_organization.subscriptions.find(args[:subscription_id]), + params: args + ) + + result.success? ? result.alert : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/alerts/destroy.rb b/app/graphql/mutations/subscriptions/alerts/destroy.rb new file mode 100644 index 0000000..8a6f2f0 --- /dev/null +++ b/app/graphql/mutations/subscriptions/alerts/destroy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + module Alerts + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "DestroySubscriptionAlert" + description "Deletes an alert" + + argument :id, ID, required: true + + type Types::UsageMonitoring::Alerts::Object + + def resolve(**args) + alert = current_organization.alerts.using_subscription.find_by(id: args[:id]) + result = ::UsageMonitoring::DestroyAlertService.call(alert:) + + result.success? ? result.alert : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/alerts/update.rb b/app/graphql/mutations/subscriptions/alerts/update.rb new file mode 100644 index 0000000..b514e90 --- /dev/null +++ b/app/graphql/mutations/subscriptions/alerts/update.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + module Alerts + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "UpdateSubscriptionAlert" + description "Updates an alert" + + input_object_class Types::UsageMonitoring::Alerts::UpdateInput + type Types::UsageMonitoring::Alerts::Object + + def resolve(**args) + alert = current_organization.alerts.using_subscription.find_by(id: args[:id]) + + result = ::UsageMonitoring::UpdateAlertService.call( + alert:, + params: args + ) + + result.success? ? result.alert : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/create.rb b/app/graphql/mutations/subscriptions/create.rb new file mode 100644 index 0000000..c1b699c --- /dev/null +++ b/app/graphql/mutations/subscriptions/create.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:create" + + graphql_name "CreateSubscription" + description "Create a new Subscription" + + input_object_class Types::Subscriptions::CreateSubscriptionInput + + type Types::Subscriptions::Object + + def resolve(entitlements: nil, **args) + customer = current_organization.customers.find_by(id: args[:customer_id]) + plan = current_organization.plans.find_by(id: args[:plan_id]) + + result = ::Subscriptions::CreateService.call( + customer:, + plan:, + params: args.merge(external_id: args[:external_id] || SecureRandom.uuid) + ) + + return result_error(result) unless result.success? + + subscription = result.subscription + + result.success? ? subscription.reload : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/create_charge_filter.rb b/app/graphql/mutations/subscriptions/create_charge_filter.rb new file mode 100644 index 0000000..e5026ba --- /dev/null +++ b/app/graphql/mutations/subscriptions/create_charge_filter.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class CreateChargeFilter < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "CreateSubscriptionChargeFilter" + description "Create a charge filter for a subscription" + + input_object_class Types::Subscriptions::CreateChargeFilterInput + + type Types::ChargeFilters::Object + + def resolve(**args) + subscription = current_organization.subscriptions.find_by(id: args[:subscription_id]) + charge = subscription&.plan&.charges&.find_by(code: args[:charge_code]) + + params = args.except(:subscription_id, :charge_code).to_h.deep_symbolize_keys + params[:properties] = params[:properties].to_h if params[:properties] + + result = ::Subscriptions::ChargeFilters::CreateService.call( + subscription:, + charge:, + params: + ) + + result.success? ? result.charge_filter : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/destroy_charge_filter.rb b/app/graphql/mutations/subscriptions/destroy_charge_filter.rb new file mode 100644 index 0000000..c94a3df --- /dev/null +++ b/app/graphql/mutations/subscriptions/destroy_charge_filter.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class DestroyChargeFilter < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "DestroySubscriptionChargeFilter" + description "Destroy a charge filter for a subscription" + + input_object_class Types::Subscriptions::DestroyChargeFilterInput + + type Types::ChargeFilters::Object + + def resolve(**args) + subscription = current_organization.subscriptions.find_by(id: args[:subscription_id]) + charge = subscription&.plan&.charges&.find_by(code: args[:charge_code]) + charge_filter = find_charge_filter(charge, args[:values]) + + result = ::Subscriptions::ChargeFilters::DestroyService.call( + subscription:, + charge:, + charge_filter: + ) + + result.success? ? result.charge_filter : result_error(result) + end + + private + + def find_charge_filter(charge, values) + return nil unless charge + + sorted_values = values.sort + charge.filters.find { |f| f.to_h.sort == sorted_values } + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/terminate.rb b/app/graphql/mutations/subscriptions/terminate.rb new file mode 100644 index 0000000..5b9c1f0 --- /dev/null +++ b/app/graphql/mutations/subscriptions/terminate.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class Terminate < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "TerminateSubscription" + description "Terminate a Subscription" + + input_object_class Types::Subscriptions::TerminateSubscriptionInput + + type Types::Subscriptions::Object + + def resolve(id:, **args) + subscription = current_organization.subscriptions.find_by(id:) + result = ::Subscriptions::TerminateService.call(subscription:, **args.compact) + + result.success? ? result.subscription : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/update.rb b/app/graphql/mutations/subscriptions/update.rb new file mode 100644 index 0000000..b4d41fd --- /dev/null +++ b/app/graphql/mutations/subscriptions/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "UpdateSubscription" + description "Update a Subscription" + + input_object_class Types::Subscriptions::UpdateSubscriptionInput + + type Types::Subscriptions::Object + + def resolve(entitlements: nil, **args) + subscription = current_organization.subscriptions.find_by(id: args[:id]) + result = ::Subscriptions::UpdateService.call(subscription:, params: args) + + result.success? ? subscription.reload : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/update_charge.rb b/app/graphql/mutations/subscriptions/update_charge.rb new file mode 100644 index 0000000..6cc1439 --- /dev/null +++ b/app/graphql/mutations/subscriptions/update_charge.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class UpdateCharge < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "UpdateSubscriptionCharge" + description "Update a charge for a subscription" + + input_object_class Types::Subscriptions::UpdateChargeInput + + type Types::Charges::Object + + def resolve(**args) + subscription = current_organization.subscriptions.find_by(id: args[:subscription_id]) + charge = subscription&.plan&.charges&.find_by(code: args[:charge_code]) + + result = ::Subscriptions::UpdateOrOverrideChargeService.call( + subscription:, + charge:, + params: args.except(:subscription_id, :charge_code) + ) + + result.success? ? result.charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/update_charge_filter.rb b/app/graphql/mutations/subscriptions/update_charge_filter.rb new file mode 100644 index 0000000..454ec54 --- /dev/null +++ b/app/graphql/mutations/subscriptions/update_charge_filter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class UpdateChargeFilter < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "UpdateSubscriptionChargeFilter" + description "Update a charge filter for a subscription" + + input_object_class Types::Subscriptions::UpdateChargeFilterInput + + type Types::ChargeFilters::Object + + def resolve(**args) + subscription = current_organization.subscriptions.find_by(id: args[:subscription_id]) + charge = subscription&.plan&.charges&.find_by(code: args[:charge_code]) + charge_filter = find_charge_filter(charge, args[:values]) + + params = args.except(:subscription_id, :charge_code, :values).to_h.deep_symbolize_keys + params[:properties] = params[:properties].to_h if params[:properties] + + result = ::Subscriptions::ChargeFilters::UpdateOrOverrideService.call( + subscription:, + charge:, + charge_filter:, + params: + ) + + result.success? ? result.charge_filter : result_error(result) + end + + private + + def find_charge_filter(charge, values) + return nil unless charge + + sorted_values = values.sort + charge.filters.find { |f| f.to_h.sort == sorted_values } + end + end + end +end diff --git a/app/graphql/mutations/subscriptions/update_fixed_charge.rb b/app/graphql/mutations/subscriptions/update_fixed_charge.rb new file mode 100644 index 0000000..c45077f --- /dev/null +++ b/app/graphql/mutations/subscriptions/update_fixed_charge.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Subscriptions + class UpdateFixedCharge < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:update" + + graphql_name "UpdateSubscriptionFixedCharge" + description "Update a fixed charge for a subscription" + + input_object_class Types::Subscriptions::UpdateFixedChargeInput + + type Types::FixedCharges::Object + + def resolve(**args) + subscription = current_organization.subscriptions.find_by(id: args[:subscription_id]) + fixed_charge = subscription&.plan&.fixed_charges&.find_by(code: args[:fixed_charge_code]) + + result = ::Subscriptions::UpdateOrOverrideFixedChargeService.call( + subscription:, + fixed_charge:, + params: args.except(:subscription_id, :fixed_charge_code) + ) + + result.success? ? result.fixed_charge : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/taxes/create.rb b/app/graphql/mutations/taxes/create.rb new file mode 100644 index 0000000..b38521d --- /dev/null +++ b/app/graphql/mutations/taxes/create.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Mutations + module Taxes + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + # Permissions ?? + # REQUIRED_PERMISSION = 'taxes:create' + + graphql_name "CreateTax" + description "Creates a tax" + + input_object_class Types::Taxes::CreateInput + type Types::Taxes::Object + + def resolve(**args) + result = ::Taxes::CreateService.call(organization: current_organization, params: args) + result.success? ? result.tax : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/taxes/destroy.rb b/app/graphql/mutations/taxes/destroy.rb new file mode 100644 index 0000000..c06150e --- /dev/null +++ b/app/graphql/mutations/taxes/destroy.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module Taxes + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + # Permissions ?? + # REQUIRED_PERMISSION = 'taxes:create' + + graphql_name "DestroyTax" + description "Deletes a tax" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + tax = current_organization.taxes.find_by(id:) + result = ::Taxes::DestroyService.call(tax:) + + result.success? ? result.tax : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/taxes/update.rb b/app/graphql/mutations/taxes/update.rb new file mode 100644 index 0000000..f7e4ff9 --- /dev/null +++ b/app/graphql/mutations/taxes/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Taxes + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + # Permissions ?? + # REQUIRED_PERMISSION = 'taxes:create' + + graphql_name "UpdateTax" + description "Update an existing tax" + + input_object_class Types::Taxes::UpdateInput + type Types::Taxes::Object + + def resolve(**args) + tax = current_organization.taxes.find_by(id: args[:id]) + result = ::Taxes::UpdateService.call(tax:, params: args) + + result.success? ? result.tax : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/wallet_transactions/create.rb b/app/graphql/mutations/wallet_transactions/create.rb new file mode 100644 index 0000000..16bbb51 --- /dev/null +++ b/app/graphql/mutations/wallet_transactions/create.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Mutations + module WalletTransactions + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:top_up" + + graphql_name "CreateCustomerWalletTransaction" + description "Creates a new Customer Wallet Transaction" + + input_object_class Types::WalletTransactions::CreateInput + + type Types::WalletTransactions::Object.collection_type + + def resolve(**args) + result = ::WalletTransactions::CreateFromParamsService.call(organization: current_organization, params: args) + + result.success? ? result.wallet_transactions : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/wallets/alerts/create.rb b/app/graphql/mutations/wallets/alerts/create.rb new file mode 100644 index 0000000..b0525dc --- /dev/null +++ b/app/graphql/mutations/wallets/alerts/create.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Mutations + module Wallets + module Alerts + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:update" + + graphql_name "CreateCustomerWalletAlert" + description "Creates a new Alert for wallet" + + input_object_class Types::UsageMonitoring::Alerts::CreateInput + argument :wallet_id, ID, required: true + + type Types::UsageMonitoring::Alerts::Object + + def resolve(**args) + result = ::UsageMonitoring::CreateAlertService.call( + organization: current_organization, + alertable: current_organization.wallets.find(args[:wallet_id]), + params: args + ) + + result.success? ? result.alert : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/wallets/alerts/destroy.rb b/app/graphql/mutations/wallets/alerts/destroy.rb new file mode 100644 index 0000000..552cf69 --- /dev/null +++ b/app/graphql/mutations/wallets/alerts/destroy.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Mutations + module Wallets + module Alerts + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:update" + + graphql_name "DestroyCustomerWalletAlert" + description "Deletes an alert" + + argument :id, ID, required: true + + type Types::UsageMonitoring::Alerts::Object + + def resolve(**args) + alert = current_organization.alerts.using_wallet.find_by(id: args[:id]) + result = ::UsageMonitoring::DestroyAlertService.call(alert:) + + result.success? ? result.alert : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/wallets/alerts/update.rb b/app/graphql/mutations/wallets/alerts/update.rb new file mode 100644 index 0000000..3914191 --- /dev/null +++ b/app/graphql/mutations/wallets/alerts/update.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Mutations + module Wallets + module Alerts + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:update" + + graphql_name "UpdateCustomerWalletAlert" + description "Updates an alert" + + input_object_class Types::UsageMonitoring::Alerts::UpdateInput + type Types::UsageMonitoring::Alerts::Object + + def resolve(**args) + alert = current_organization.alerts.using_wallet.find_by(id: args[:id]) + + result = ::UsageMonitoring::UpdateAlertService.call( + alert:, + params: args + ) + + result.success? ? result.alert : result_error(result) + end + end + end + end +end diff --git a/app/graphql/mutations/wallets/create.rb b/app/graphql/mutations/wallets/create.rb new file mode 100644 index 0000000..7996dcf --- /dev/null +++ b/app/graphql/mutations/wallets/create.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Mutations + module Wallets + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:create" + + graphql_name "CreateCustomerWallet" + description "Creates a new Customer Wallet" + + input_object_class Types::Wallets::CreateInput + + type Types::Wallets::Object + + def resolve(**args) + result = ::Wallets::CreateService.call( + params: args.merge(organization_id: current_organization.id) + .merge(customer: current_customer(args[:customer_id])) + .except(:customer_id) + ) + + result.success? ? result.wallet : result_error(result) + end + + def current_customer(id) + current_organization.customers.find_by(id:) + end + end + end +end diff --git a/app/graphql/mutations/wallets/terminate.rb b/app/graphql/mutations/wallets/terminate.rb new file mode 100644 index 0000000..9f552dc --- /dev/null +++ b/app/graphql/mutations/wallets/terminate.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Wallets + class Terminate < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:terminate" + + graphql_name "TerminateCustomerWallet" + description "Terminates a new Customer Wallet" + + argument :id, ID, required: true + + type Types::Wallets::Object + + def resolve(id:) + wallet = current_organization.wallets.find_by(id:) + result = ::Wallets::TerminateService.call(wallet:) + + result.success? ? result.wallet : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/wallets/update.rb b/app/graphql/mutations/wallets/update.rb new file mode 100644 index 0000000..efe9982 --- /dev/null +++ b/app/graphql/mutations/wallets/update.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Wallets + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:update" + + graphql_name "UpdateCustomerWallet" + description "Updates a new Customer Wallet" + + input_object_class Types::Wallets::UpdateInput + + type Types::Wallets::Object + + def resolve(**args) + wallet = current_organization.wallets.find_by(id: args[:id]) + result = ::Wallets::UpdateService.call(wallet:, params: args) + + result.success? ? result.wallet : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/webhook_endpoints/create.rb b/app/graphql/mutations/webhook_endpoints/create.rb new file mode 100644 index 0000000..6766ec5 --- /dev/null +++ b/app/graphql/mutations/webhook_endpoints/create.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Mutations + module WebhookEndpoints + class Create < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + graphql_name "CreateWebhookEndpoint" + description "Create a new webhook endpoint" + + input_object_class Types::WebhookEndpoints::CreateInput + + type Types::WebhookEndpoints::Object + + def resolve(**args) + result = ::WebhookEndpoints::CreateService.call( + organization: current_organization, + params: args + ) + result.success? ? result.webhook_endpoint : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/webhook_endpoints/destroy.rb b/app/graphql/mutations/webhook_endpoints/destroy.rb new file mode 100644 index 0000000..e45660e --- /dev/null +++ b/app/graphql/mutations/webhook_endpoints/destroy.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module WebhookEndpoints + class Destroy < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + graphql_name "DestroyWebhookEndpoint" + description "Deletes a webhook endpoint" + + argument :id, ID, required: true + + field :id, ID, null: true + + def resolve(id:) + webhook_endpoint = current_organization.webhook_endpoints.find_by(id:) + result = ::WebhookEndpoints::DestroyService.call(webhook_endpoint:) + + result.success? ? result.webhook_endpoint : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/webhook_endpoints/update.rb b/app/graphql/mutations/webhook_endpoints/update.rb new file mode 100644 index 0000000..940c47c --- /dev/null +++ b/app/graphql/mutations/webhook_endpoints/update.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Mutations + module WebhookEndpoints + class Update < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + graphql_name "UpdateWebhookEndpoint" + description "Update a new webhook endpoint" + + input_object_class Types::WebhookEndpoints::UpdateInput + + type Types::WebhookEndpoints::Object + + def resolve(**args) + result = ::WebhookEndpoints::UpdateService.call( + id: args[:id], + organization: current_organization, + params: args + ) + + result.success? ? result.webhook_endpoint : result_error(result) + end + end + end +end diff --git a/app/graphql/mutations/webhooks/retry.rb b/app/graphql/mutations/webhooks/retry.rb new file mode 100644 index 0000000..1ee9a9c --- /dev/null +++ b/app/graphql/mutations/webhooks/retry.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Mutations + module Webhooks + class Retry < BaseMutation + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + graphql_name "RetryWebhook" + description "Retry a Webhook" + + argument :id, ID, required: true + + type Types::Webhooks::Object + + def resolve(id:) + webhook = current_organization.webhooks.find_by(id:) + result = ::Webhooks::RetryService.call(webhook:) + + result.success? ? result.webhook : result_error(result) + end + end + end +end diff --git a/app/graphql/resolvers/activity_log_resolver.rb b/app/graphql/resolvers/activity_log_resolver.rb new file mode 100644 index 0000000..56531a8 --- /dev/null +++ b/app/graphql/resolvers/activity_log_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + class ActivityLogResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "audit_logs:view" + + description "Query a single activity log of an organization" + + argument :activity_id, ID, required: true, description: "Uniq ID of the activity log" + + type Types::ActivityLogs::Object, null: true + + def resolve(activity_id: nil) + raise unauthorized_error unless License.premium? + raise forbidden_error(code: "feature_unavailable") unless Utils::ActivityLog.available? + + current_organization.activity_logs.find_by!(activity_id: activity_id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "activity_log") + end + end +end diff --git a/app/graphql/resolvers/activity_logs_resolver.rb b/app/graphql/resolvers/activity_logs_resolver.rb new file mode 100644 index 0000000..bbdec0e --- /dev/null +++ b/app/graphql/resolvers/activity_logs_resolver.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Resolvers + class ActivityLogsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "audit_logs:view" + + description "Query activity logs of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + argument :activity_ids, [String], required: false + argument :activity_sources, [Types::ActivityLogs::ActivitySourceEnum], required: false + argument :activity_types, [Types::ActivityLogs::ActivityTypeEnum], required: false + argument :api_key_ids, [String], required: false + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + argument :resource_ids, [String], required: false + argument :resource_types, [Types::ActivityLogs::ResourceTypeEnum], required: false + argument :user_emails, [String], required: false + + # from_date and to_date are deprecated in favor of from_datetime and to_datetime as it is not possible to update the type in-place (See commit). + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :from_datetime, GraphQL::Types::ISO8601DateTime, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + argument :to_datetime, GraphQL::Types::ISO8601DateTime, required: false + + type Types::ActivityLogs::Object.collection_type, null: true + + def resolve(**args) + raise unauthorized_error unless License.premium? + raise forbidden_error(code: "feature_unavailable") unless Utils::ActivityLog.available? + + result = ActivityLogsQuery.call( + organization: current_organization, + filters: { + from_date: args[:from_datetime] || args[:from_date], + to_date: args[:to_datetime] || args[:to_date], + api_key_ids: args[:api_key_ids], + activity_ids: args[:activity_ids], + activity_types: args[:activity_types], + activity_sources: args[:activity_sources], + user_emails: args[:user_emails], + external_customer_id: args[:external_customer_id], + external_subscription_id: args[:external_subscription_id], + resource_ids: args[:resource_ids], + resource_types: args[:resource_types] + }, + pagination: { + page: args[:page], + limit: args[:limit] + } + ) + + result.activity_logs + end + end +end diff --git a/app/graphql/resolvers/add_on_resolver.rb b/app/graphql/resolvers/add_on_resolver.rb new file mode 100644 index 0000000..2b46b3b --- /dev/null +++ b/app/graphql/resolvers/add_on_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class AddOnResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "addons:view" + + description "Query a single add-on of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the add-on" + + type Types::AddOns::Object, null: true + + def resolve(id: nil) + current_organization.add_ons.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "add_on") + end + end +end diff --git a/app/graphql/resolvers/add_ons_resolver.rb b/app/graphql/resolvers/add_ons_resolver.rb new file mode 100644 index 0000000..1f69b59 --- /dev/null +++ b/app/graphql/resolvers/add_ons_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + class AddOnsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "addons:view" + + description "Query add-ons of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::AddOns::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, search_term: nil) + result = ::AddOnsQuery.call( + organization: current_organization, + search_term:, + pagination: {page:, limit:} + ) + + result.add_ons + end + end +end diff --git a/app/graphql/resolvers/ai_conversation_resolver.rb b/app/graphql/resolvers/ai_conversation_resolver.rb new file mode 100644 index 0000000..702698b --- /dev/null +++ b/app/graphql/resolvers/ai_conversation_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + class AiConversationResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "ai_conversations:view" + + description "Query a single ai conversation of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the ai conversation" + + type Types::AiConversations::ObjectWithMessages, null: true + + def resolve(id:) + raise unauthorized_error unless License.premium? + raise forbidden_error(code: "feature_unavailable") if ENV["MISTRAL_API_KEY"].blank? || ENV["MISTRAL_AGENT_ID"].blank? + + ai_conversation = current_organization.ai_conversations.find(id) + result = ::AiConversations::FetchMessagesService.call(ai_conversation:).raise_if_error! + + ai_conversation.attributes.symbolize_keys.merge( + messages: result.messages + ) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "ai_conversation") + end + end +end diff --git a/app/graphql/resolvers/ai_conversations_resolver.rb b/app/graphql/resolvers/ai_conversations_resolver.rb new file mode 100644 index 0000000..f6074df --- /dev/null +++ b/app/graphql/resolvers/ai_conversations_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + class AiConversationsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "ai_conversations:view" + + description "Query the latest AI conversations of current organization" + + argument :limit, Integer, required: false + + type Types::AiConversations::Object.collection_type, null: true + + def resolve(limit: nil) + raise unauthorized_error unless License.premium? + raise forbidden_error(code: "feature_unavailable") if ENV["MISTRAL_API_KEY"].blank? || ENV["MISTRAL_AGENT_ID"].blank? + + membership = current_organization.memberships.find_by(user_id: context[:current_user].id) + current_organization.ai_conversations.where(membership:).order(created_at: :desc).first(limit) + end + end +end diff --git a/app/graphql/resolvers/analytics/gross_revenues_resolver.rb b/app/graphql/resolvers/analytics/gross_revenues_resolver.rb new file mode 100644 index 0000000..7f5b6b7 --- /dev/null +++ b/app/graphql/resolvers/analytics/gross_revenues_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + class GrossRevenuesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "analytics:view" + + description "Query gross revenue of an organization" + + argument :billing_entity_id, ID, required: false + argument :currency, Types::CurrencyEnum, required: false + argument :external_customer_id, String, required: false + argument :months, Integer, required: false + + argument :expire_cache, Boolean, required: false + + type Types::Analytics::GrossRevenues::Object.collection_type, null: false + + def resolve(**args) + ::Analytics::GrossRevenue.find_all_by(current_organization.id, **args) + end + end + end +end diff --git a/app/graphql/resolvers/analytics/invoice_collections_resolver.rb b/app/graphql/resolvers/analytics/invoice_collections_resolver.rb new file mode 100644 index 0000000..6c47520 --- /dev/null +++ b/app/graphql/resolvers/analytics/invoice_collections_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + class InvoiceCollectionsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "analytics:view" + + description "Query invoice collections of an organization" + + argument :billing_entity_code, String, required: false + argument :billing_entity_id, ID, required: false + argument :currency, Types::CurrencyEnum, required: false + argument :is_customer_tin_empty, Boolean, required: false + + type Types::Analytics::InvoiceCollections::Object.collection_type, null: false + + def resolve(**args) + raise unauthorized_error unless License.premium? + + ::Analytics::InvoiceCollection.find_all_by(current_organization.id, **args.merge(months: 12)) + end + end + end +end diff --git a/app/graphql/resolvers/analytics/invoiced_usages_resolver.rb b/app/graphql/resolvers/analytics/invoiced_usages_resolver.rb new file mode 100644 index 0000000..d3e2723 --- /dev/null +++ b/app/graphql/resolvers/analytics/invoiced_usages_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + class InvoicedUsagesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "analytics:view" + + description "Query invoiced usage of an organization" + + argument :billing_entity_id, ID, required: false + argument :currency, Types::CurrencyEnum, required: false + + type Types::Analytics::InvoicedUsages::Object.collection_type, null: false + + def resolve(**args) + raise unauthorized_error unless License.premium? + + ::Analytics::InvoicedUsage.find_all_by(current_organization.id, **args.merge({months: 12})) + end + end + end +end diff --git a/app/graphql/resolvers/analytics/mrrs_resolver.rb b/app/graphql/resolvers/analytics/mrrs_resolver.rb new file mode 100644 index 0000000..da1ef63 --- /dev/null +++ b/app/graphql/resolvers/analytics/mrrs_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + class MrrsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "analytics:view" + + description "Query MRR of an organization" + + argument :billing_entity_id, ID, required: false + argument :currency, Types::CurrencyEnum, required: false + + type Types::Analytics::Mrrs::Object.collection_type, null: false + + def resolve(**args) + raise unauthorized_error unless License.premium? + + ::Analytics::Mrr.find_all_by(current_organization.id, **args.merge({months: 12})) + end + end + end +end diff --git a/app/graphql/resolvers/analytics/overdue_balances_resolver.rb b/app/graphql/resolvers/analytics/overdue_balances_resolver.rb new file mode 100644 index 0000000..b7f0e70 --- /dev/null +++ b/app/graphql/resolvers/analytics/overdue_balances_resolver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Resolvers + module Analytics + class OverdueBalancesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "analytics:view" + + description "Query overdue balances of an organization" + + argument :billing_entity_code, String, required: false + argument :billing_entity_id, ID, required: false + argument :currency, Types::CurrencyEnum, required: false + argument :external_customer_id, String, required: false + argument :is_customer_tin_empty, Boolean, required: false + argument :months, Integer, required: false + + argument :expire_cache, Boolean, required: false + + type Types::Analytics::OverdueBalances::Object.collection_type, null: false + + def resolve(**args) + ::Analytics::OverdueBalance.find_all_by(current_organization.id, **args) + end + end + end +end diff --git a/app/graphql/resolvers/api_key_resolver.rb b/app/graphql/resolvers/api_key_resolver.rb new file mode 100644 index 0000000..9bb92de --- /dev/null +++ b/app/graphql/resolvers/api_key_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class ApiKeyResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:keys:manage" + + argument :id, ID, required: true, description: "Uniq ID of the API key" + + description "Query the API key" + + type Types::ApiKeys::Object, null: false + + def resolve(id: nil) + current_organization.api_keys.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "api_key") + end + end +end diff --git a/app/graphql/resolvers/api_keys_resolver.rb b/app/graphql/resolvers/api_keys_resolver.rb new file mode 100644 index 0000000..3b74727 --- /dev/null +++ b/app/graphql/resolvers/api_keys_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + class ApiKeysResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:keys:manage" + + description "Query the API keys of current organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::ApiKeys::SanitizedObject.collection_type, null: false + + def resolve(page: nil, limit: nil) + current_organization.api_keys.order(created_at: :asc).page(page).per(limit) + end + end +end diff --git a/app/graphql/resolvers/api_log_resolver.rb b/app/graphql/resolvers/api_log_resolver.rb new file mode 100644 index 0000000..02525f5 --- /dev/null +++ b/app/graphql/resolvers/api_log_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + class ApiLogResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "audit_logs:view" + + description "Query a single api log of an organization" + + argument :request_id, ID, required: true, description: "Uniq ID of the api log" + + type Types::ApiLogs::Object, null: true + + def resolve(request_id: nil) + raise unauthorized_error unless License.premium? + raise forbidden_error(code: "feature_unavailable") unless Utils::ApiLog.available? + + current_organization.api_logs.find_by!(request_id:) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "api_log") + end + end +end diff --git a/app/graphql/resolvers/api_logs_resolver.rb b/app/graphql/resolvers/api_logs_resolver.rb new file mode 100644 index 0000000..6b86a5f --- /dev/null +++ b/app/graphql/resolvers/api_logs_resolver.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Resolvers + class ApiLogsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "audit_logs:view" + + description "Query api logs of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + # from_date and to_date are deprecated in favor of from_datetime and to_datetime as it is not possible to update the type in-place (See commit). + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :from_datetime, GraphQL::Types::ISO8601DateTime, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + argument :to_datetime, GraphQL::Types::ISO8601DateTime, required: false + + argument :api_key_ids, [String], required: false + argument :http_methods, [Types::ApiLogs::HttpMethodEnum], required: false + argument :http_statuses, [Types::ApiLogs::HttpStatus], required: false + argument :request_ids, [String], required: false + argument :request_paths, [String], required: false + + type Types::ApiLogs::Object.collection_type, null: true + + def resolve(**args) + raise unauthorized_error unless License.premium? + raise forbidden_error(code: "feature_unavailable") unless Utils::ApiLog.available? + + result = ApiLogsQuery.call( + organization: current_organization, + filters: { + from_date: args[:from_datetime] || args[:from_date], + to_date: args[:to_datetime] || args[:to_date], + api_key_ids: args[:api_key_ids], + request_ids: args[:request_ids], + http_statuses: args[:http_statuses], + http_methods: args[:http_methods], + api_version: args[:api_version], + request_paths: args[:request_paths] + }, + pagination: { + page: args[:page], + limit: args[:limit] + } + ) + + result.api_logs + end + end +end diff --git a/app/graphql/resolvers/applied_coupons_resolver.rb b/app/graphql/resolvers/applied_coupons_resolver.rb new file mode 100644 index 0000000..88a5bf7 --- /dev/null +++ b/app/graphql/resolvers/applied_coupons_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Resolvers + class AppliedCouponsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:view" + + description "Query applied coupons of an organization" + + argument :coupon_code, [String], required: false + argument :external_customer_id, String, required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :status, Types::AppliedCoupons::StatusEnum, required: false + + type Types::AppliedCoupons::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, status: nil, external_customer_id: nil, coupon_code: nil) + result = AppliedCouponsQuery.call( + organization: current_organization, + pagination: {page:, limit:}, + filters: { + status:, + external_customer_id:, + coupon_code: + } + ) + + result.applied_coupons + end + end +end diff --git a/app/graphql/resolvers/auth/google/auth_url_resolver.rb b/app/graphql/resolvers/auth/google/auth_url_resolver.rb new file mode 100644 index 0000000..799054d --- /dev/null +++ b/app/graphql/resolvers/auth/google/auth_url_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + module Auth + module Google + class AuthUrlResolver < Resolvers::BaseResolver + graphql_name "GoogleAuthUrl" + description "Get Google auth url." + + type Types::Auth::Google::AuthUrl, null: false + + def resolve(**_args) + result = ::Auth::GoogleService + .new + .authorize_url(context[:request]) + + result.success? ? result : result_error(result) + end + end + end + end +end diff --git a/app/graphql/resolvers/base_resolver.rb b/app/graphql/resolvers/base_resolver.rb new file mode 100644 index 0000000..b5f1fac --- /dev/null +++ b/app/graphql/resolvers/base_resolver.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Resolvers + class BaseResolver < GraphQL::Schema::Resolver + include ExecutionErrorResponder + include CanRequirePermissions + end +end diff --git a/app/graphql/resolvers/billable_metric_resolver.rb b/app/graphql/resolvers/billable_metric_resolver.rb new file mode 100644 index 0000000..f0fd1b0 --- /dev/null +++ b/app/graphql/resolvers/billable_metric_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class BillableMetricResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billable_metrics:view" + + description "Query a single billable metric of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the billable metric" + + type Types::BillableMetrics::Object, null: true + + def resolve(id: nil) + current_organization.billable_metrics.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "billable_metric") + end + end +end diff --git a/app/graphql/resolvers/billable_metrics_resolver.rb b/app/graphql/resolvers/billable_metrics_resolver.rb new file mode 100644 index 0000000..2f3a6ab --- /dev/null +++ b/app/graphql/resolvers/billable_metrics_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + class BillableMetricsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billable_metrics:view" + + description "Query billable metrics of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :recurring, Boolean, required: false + argument :search_term, String, required: false + + argument :aggregation_types, [Types::BillableMetrics::AggregationTypeEnum], required: false + argument :plan_id, ID, required: false + + type Types::BillableMetrics::Object.collection_type, null: false + + def resolve(**args) + result = ::BillableMetricsQuery.call( + organization: current_organization, + search_term: args[:search_term], + pagination: {page: args[:page], limit: args[:limit]}, + filters: args.slice(:recurring, :aggregation_types, :plan_id) + ) + + result.billable_metrics + end + end +end diff --git a/app/graphql/resolvers/billing_entities_resolver.rb b/app/graphql/resolvers/billing_entities_resolver.rb new file mode 100644 index 0000000..68dd496 --- /dev/null +++ b/app/graphql/resolvers/billing_entities_resolver.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Resolvers + class BillingEntitiesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:view" + + description "Query active billing_entities of an organization" + + type Types::BillingEntities::Object.collection_type, null: false + + def resolve(**args) + current_organization.billing_entities + end + end +end diff --git a/app/graphql/resolvers/billing_entity_resolver.rb b/app/graphql/resolvers/billing_entity_resolver.rb new file mode 100644 index 0000000..ac80fe4 --- /dev/null +++ b/app/graphql/resolvers/billing_entity_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class BillingEntityResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "billing_entities:view" + + description "Query a single billing_entity of an organization" + + argument :code, String, required: true, description: "Code of the billing_entity" + + type Types::BillingEntities::Object, null: true + + def resolve(code:) + current_organization.all_billing_entities.find_by!(code:) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "billing_entity") + end + end +end diff --git a/app/graphql/resolvers/billing_entity_taxes_resolver.rb b/app/graphql/resolvers/billing_entity_taxes_resolver.rb new file mode 100644 index 0000000..18287de --- /dev/null +++ b/app/graphql/resolvers/billing_entity_taxes_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + class BillingEntityTaxesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query taxes of a billing entity" + argument :billing_entity_id, ID, required: true, description: "Uniq ID of the billing entity" + + type Types::Taxes::Object.collection_type, null: false + + def resolve(billing_entity_id:) + billing_entity = current_organization.billing_entities.find(billing_entity_id) + billing_entity.taxes + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "billing_entity") + end + end +end diff --git a/app/graphql/resolvers/coupon_resolver.rb b/app/graphql/resolvers/coupon_resolver.rb new file mode 100644 index 0000000..8e3cfd6 --- /dev/null +++ b/app/graphql/resolvers/coupon_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class CouponResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:view" + + description "Query a single coupon of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the coupon" + + type Types::Coupons::Object, null: true + + def resolve(id: nil) + current_organization.coupons.with_discarded.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "coupon") + end + end +end diff --git a/app/graphql/resolvers/coupons_resolver.rb b/app/graphql/resolvers/coupons_resolver.rb new file mode 100644 index 0000000..fe5b657 --- /dev/null +++ b/app/graphql/resolvers/coupons_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + class CouponsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "coupons:view" + + description "Query coupons of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + argument :status, Types::Coupons::StatusEnum, required: false + + type Types::Coupons::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, status: nil, search_term: nil) + result = CouponsQuery.call( + organization: current_organization, + search_term:, + filters: { + status: + }, + pagination: { + page:, + limit: + } + ) + + result.coupons + end + end +end diff --git a/app/graphql/resolvers/credit_note_resolver.rb b/app/graphql/resolvers/credit_note_resolver.rb new file mode 100644 index 0000000..48b4100 --- /dev/null +++ b/app/graphql/resolvers/credit_note_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class CreditNoteResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:view" + + description "Query a single credit note" + + argument :id, ID, required: true, description: "Uniq ID of the credit note" + + type Types::CreditNotes::Object, null: true + + def resolve(id: nil) + current_organization.credit_notes.finalized.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "credit_note") + end + end +end diff --git a/app/graphql/resolvers/credit_notes/estimate_resolver.rb b/app/graphql/resolvers/credit_notes/estimate_resolver.rb new file mode 100644 index 0000000..e9762ce --- /dev/null +++ b/app/graphql/resolvers/credit_notes/estimate_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + module CreditNotes + class EstimateResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Fetch amounts for credit note creation" + + argument :invoice_id, ID, required: true + argument :items, [Types::CreditNoteItems::Input], required: true + + type Types::CreditNotes::Estimate, null: false + + def resolve(invoice_id:, items:) + result = ::CreditNotes::EstimateService.call( + invoice: current_organization.invoices.visible.find_by(id: invoice_id), + items: + ) + + result.success? ? result.credit_note : result_error(result) + end + end + end +end diff --git a/app/graphql/resolvers/credit_notes_resolver.rb b/app/graphql/resolvers/credit_notes_resolver.rb new file mode 100644 index 0000000..89690c8 --- /dev/null +++ b/app/graphql/resolvers/credit_notes_resolver.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Resolvers + class CreditNotesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:view" + + description "Query credit notes" + + argument :search_term, String, required: false + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + argument :amount_from, Integer, required: false + argument :amount_to, Integer, required: false + argument :billing_entity_ids, [ID], required: false + argument :credit_status, [Types::CreditNotes::CreditStatusTypeEnum], required: false + argument :currency, Types::CurrencyEnum, required: false + argument :customer_external_id, String, required: false + argument :customer_id, ID, required: false, description: "Uniq ID of the customer" + argument :invoice_number, String, required: false + argument :issuing_date_from, GraphQL::Types::ISO8601Date, required: false + argument :issuing_date_to, GraphQL::Types::ISO8601Date, required: false + argument :reason, [Types::CreditNotes::ReasonTypeEnum], required: false + argument :refund_status, [Types::CreditNotes::RefundStatusTypeEnum], required: false + argument :self_billed, Boolean, required: false + argument :types, [Types::CreditNotes::TypeEnum], required: false + + type Types::CreditNotes::Object.collection_type, null: false + + FILTER_KEYS = %i[ + amount_from amount_to billing_entity_ids credit_status currency customer_external_id + customer_id invoice_number issuing_date_from issuing_date_to reason refund_status self_billed types + ].freeze + + def resolve(**args) + includes = [ + :customer, + :error_details, + :metadata, + {invoice: :billing_entity}, + items: { + fee: [ + :charge_filter, + {charge: :billable_metric}, + :billable_metric, + :pricing_unit_usage, + :true_up_fee, + {subscription: :plan} + ] + } + ] + + CreditNotesQuery.call( + organization: current_organization, + search_term: args[:search_term], + includes:, + filters: args.slice(*FILTER_KEYS), + pagination: args.slice(:page, :limit) + ).credit_notes + end + end +end diff --git a/app/graphql/resolvers/current_user_resolver.rb b/app/graphql/resolvers/current_user_resolver.rb new file mode 100644 index 0000000..ba7f701 --- /dev/null +++ b/app/graphql/resolvers/current_user_resolver.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Resolvers + # MeResolver resolves current user field + class CurrentUserResolver < Resolvers::BaseResolver + include AuthenticableApiUser + + description "Retrieves currently connected user" + + type Types::UserType, null: false + + def resolve + context[:current_user] + end + end +end diff --git a/app/graphql/resolvers/customer_portal/analytics/invoice_collections_resolver.rb b/app/graphql/resolvers/customer_portal/analytics/invoice_collections_resolver.rb new file mode 100644 index 0000000..ba225e3 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/analytics/invoice_collections_resolver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + module Analytics + class InvoiceCollectionsResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query invoice collections of a customer portal user" + + argument :months, Integer, required: false + + argument :expire_cache, Boolean, required: false + + type Types::Analytics::InvoiceCollections::Object.collection_type, null: false + + def resolve(**args) + ::Analytics::InvoiceCollection.find_all_by( + context[:customer_portal_user].organization.id, + **args.merge( + currency: context[:customer_portal_user].currency, + external_customer_id: context[:customer_portal_user].external_id + ) + ) + end + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/analytics/overdue_balances_resolver.rb b/app/graphql/resolvers/customer_portal/analytics/overdue_balances_resolver.rb new file mode 100644 index 0000000..05108d0 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/analytics/overdue_balances_resolver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + module Analytics + class OverdueBalancesResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query overdue balances of a customer portal user" + + argument :months, Integer, required: false + + argument :expire_cache, Boolean, required: false + + type Types::Analytics::OverdueBalances::Object.collection_type, null: false + + def resolve(**args) + ::Analytics::OverdueBalance.find_all_by( + context[:customer_portal_user].organization.id, + **args.merge( + currency: context[:customer_portal_user].currency, + external_customer_id: context[:customer_portal_user].external_id + ) + ) + end + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/customer_resolver.rb b/app/graphql/resolvers/customer_portal/customer_resolver.rb new file mode 100644 index 0000000..0d4e4d0 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/customer_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + class CustomerResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query a customer portal user" + + type Types::CustomerPortal::Customers::Object, null: true + + def resolve + context[:customer_portal_user] + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/customers/projected_usage_resolver.rb b/app/graphql/resolvers/customer_portal/customers/projected_usage_resolver.rb new file mode 100644 index 0000000..66bbded --- /dev/null +++ b/app/graphql/resolvers/customer_portal/customers/projected_usage_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + module Customers + class ProjectedUsageResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query the projected usage of the customer on the current billing period" + + argument :subscription_id, type: ID, required: true + + type Types::Customers::Usage::Projected, null: false + + def resolve(subscription_id:) + result = Invoices::CustomerUsageService.with_ids( + organization_id: context[:customer_portal_user].organization_id, + customer_id: context[:customer_portal_user].id, + subscription_id:, + apply_taxes: false, + calculate_projected_usage: true + ).call + + result.success? ? result.usage : result_error(result) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/customers/usage_resolver.rb b/app/graphql/resolvers/customer_portal/customers/usage_resolver.rb new file mode 100644 index 0000000..bea7dd7 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/customers/usage_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + module Customers + class UsageResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query the usage of the customer on the current billing period" + + argument :subscription_id, type: ID, required: true + + type Types::Customers::Usage::Current, null: false + + def resolve(subscription_id:) + result = Invoices::CustomerUsageService.with_ids( + organization_id: context[:customer_portal_user].organization_id, + customer_id: context[:customer_portal_user].id, + subscription_id:, + apply_taxes: false + ).call + + result.success? ? result.usage : result_error(result) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/invoices_resolver.rb b/app/graphql/resolvers/customer_portal/invoices_resolver.rb new file mode 100644 index 0000000..3682a03 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/invoices_resolver.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + class InvoicesResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query invoices of a customer" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + argument :status, [Types::Invoices::StatusTypeEnum], required: false + + type Types::Invoices::Object.collection_type, null: false + + def resolve(status: nil, page: nil, limit: nil, search_term: nil) + result = InvoicesQuery.call( + organization: context[:customer_portal_user], + pagination: {page:, limit:}, + search_term:, + filters: { + customer_id: context[:customer_portal_user].id, + status: + } + ) + + return result_error(result) unless result.success? + + Invoice.preload_offset_amounts(result.invoices) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/organization_resolver.rb b/app/graphql/resolvers/customer_portal/organization_resolver.rb new file mode 100644 index 0000000..8fe0640 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/organization_resolver.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + class OrganizationResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query customer portal organization" + + type Types::CustomerPortal::Organizations::Object, null: true + + def resolve + context[:customer_portal_user].organization + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/subscription_resolver.rb b/app/graphql/resolvers/customer_portal/subscription_resolver.rb new file mode 100644 index 0000000..0c08a33 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/subscription_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + class SubscriptionResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query a single subscription from the customer portal" + + argument :id, ID, required: true, description: "Uniq ID of the subscription" + + type Types::Subscriptions::Object, null: true + + def resolve(id: nil) + context[:customer_portal_user].subscriptions.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "subscription") + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/subscriptions_resolver.rb b/app/graphql/resolvers/customer_portal/subscriptions_resolver.rb new file mode 100644 index 0000000..1aa8885 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/subscriptions_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + class SubscriptionsResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query customer portal subscriptions" + + argument :currency, String, required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :plan_code, String, required: false + argument :status, [Types::Subscriptions::StatusTypeEnum], required: false + + type Types::Subscriptions::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, plan_code: nil, status: nil, currency: nil) + result = SubscriptionsQuery.call( + organization: nil, + pagination: {page:, limit:}, + filters: { + external_customer_id: context[:customer_portal_user].external_id, + plan_code:, + status:, + currency:, + customer: context[:customer_portal_user] + } + ) + + result.subscriptions + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/wallet_resolver.rb b/app/graphql/resolvers/customer_portal/wallet_resolver.rb new file mode 100644 index 0000000..885b996 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/wallet_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + class WalletResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query a single wallet from the customer portal" + + argument :id, ID, required: true, description: "Uniq ID of the wallet" + + type Types::CustomerPortal::Wallets::Object, null: true + + def resolve(id: nil) + context[:customer_portal_user].wallets.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "wallet") + end + end + end +end diff --git a/app/graphql/resolvers/customer_portal/wallets_resolver.rb b/app/graphql/resolvers/customer_portal/wallets_resolver.rb new file mode 100644 index 0000000..8890471 --- /dev/null +++ b/app/graphql/resolvers/customer_portal/wallets_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + module CustomerPortal + class WalletsResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + description "Query wallets" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :status, Types::Wallets::StatusEnum, required: false + + type Types::CustomerPortal::Wallets::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, status: nil) + wallets = context[:customer_portal_user].wallets + wallets = status.present? ? wallets.where(status:) : wallets.active + wallets + .page(page) + .per(limit) + .order(:priority, :created_at) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end + end +end diff --git a/app/graphql/resolvers/customer_resolver.rb b/app/graphql/resolvers/customer_resolver.rb new file mode 100644 index 0000000..d29ce47 --- /dev/null +++ b/app/graphql/resolvers/customer_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + class CustomerResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "customers:view" + + description "Query a single customer of an organization" + + argument :external_id, ID, required: false, description: "External ID of the customer" + argument :id, ID, required: false, description: "Lago ID of the customer" + + type Types::Customers::Object, null: true + + def resolve(id: nil, external_id: nil) + if id.nil? && external_id.nil? + raise GraphQL::ExecutionError, "You must provide either `id` or `external_id`." + end + + return current_organization.customers.find(id) if id.present? + current_organization.customers.find_by!(external_id:) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end +end diff --git a/app/graphql/resolvers/customers/invoices_resolver.rb b/app/graphql/resolvers/customers/invoices_resolver.rb new file mode 100644 index 0000000..871b38d --- /dev/null +++ b/app/graphql/resolvers/customers/invoices_resolver.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Resolvers + module Customers + class InvoicesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:view" + + description "Query invoices of a customer" + + argument :customer_id, type: ID, required: true + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + argument :status, [Types::Invoices::StatusTypeEnum], required: false + + type Types::Invoices::Object.collection_type, null: false + + def resolve(customer_id: nil, status: nil, page: nil, limit: nil, search_term: nil) + result = InvoicesQuery.call( + organization: current_organization, + pagination: {page:, limit:}, + search_term:, + filters: { + customer_id:, + status: + } + ) + + return result_error(result) unless result.success? + + Invoice.preload_offset_amounts(result.invoices) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end + end +end diff --git a/app/graphql/resolvers/customers/projected_usage_resolver.rb b/app/graphql/resolvers/customers/projected_usage_resolver.rb new file mode 100644 index 0000000..aaad288 --- /dev/null +++ b/app/graphql/resolvers/customers/projected_usage_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module Customers + class ProjectedUsageResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "customers:view" + + description "Query the projected usage of the customer on the current billing period" + + argument :customer_id, type: ID, required: false + argument :subscription_id, type: ID, required: true + + type Types::Customers::Usage::Projected, null: false + + def resolve(customer_id:, subscription_id:) + result = Invoices::CustomerUsageService.with_ids( + organization_id: current_organization.id, + customer_id:, + subscription_id:, + apply_taxes: false, + calculate_projected_usage: true + ).call + + result.success? ? result.usage : result_error(result) + end + end + end +end diff --git a/app/graphql/resolvers/customers/subscriptions_resolver.rb b/app/graphql/resolvers/customers/subscriptions_resolver.rb new file mode 100644 index 0000000..00c7f1f --- /dev/null +++ b/app/graphql/resolvers/customers/subscriptions_resolver.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Resolvers + module Customers + class SubscriptionsResolver < Resolvers::BaseResolver + REQUIRED_PERMISSION = "customers:view" + + description "Query subscriptions of a customer" + + argument :status, [Types::Subscriptions::StatusTypeEnum], required: false do + description "Statuses of subscriptions to retrieve" + end + + type Types::Subscriptions::Object, null: false + + # FE needs possibility to fetch subscriptions by status. However if status is pending, only + # starting_in_the_future subscriptions should be returned since FE handles downgraded (pending) + # subscriptions a bit different (it checks if next_plan exists and it uses some of next plan's properties + # that are needed in the UI) + def resolve(status: nil) + statuses = status + subscriptions = object.subscriptions + + return subscriptions.order(created_at: :desc).reject { |s| s.pending? && s.previous_subscription&.downgraded? } if statuses.blank? + + # if requested statuses do not include pending ones we should just perform filtering by given statuses + return subscriptions.where(status: statuses).order(created_at: :desc) unless statuses&.include?("pending") + + statuses -= ["pending"] + + # if requested status is only pending, we should return only subscriptions that are starting later + return subscriptions.starting_in_the_future.order(created_at: :desc) if statuses.blank? + + # if requested statuses are array of pending and some other statuses, we should return pending subscriptions + # that are starting later or other subscriptions filtered by statuses without pending one + subscriptions.where(status: statuses).or(subscriptions.starting_in_the_future).order(created_at: :desc) + end + end + end +end diff --git a/app/graphql/resolvers/customers/usage_resolver.rb b/app/graphql/resolvers/customers/usage_resolver.rb new file mode 100644 index 0000000..4619486 --- /dev/null +++ b/app/graphql/resolvers/customers/usage_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + module Customers + class UsageResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "customers:view" + + description "Query the usage of the customer on the current billing period" + + argument :customer_id, type: ID, required: false + argument :subscription_id, type: ID, required: true + + type Types::Customers::Usage::Current, null: false + + def resolve(customer_id:, subscription_id:) + result = Invoices::CustomerUsageService.with_ids( + organization_id: current_organization.id, + customer_id:, + subscription_id:, + apply_taxes: false + ).call + + result.success? ? result.usage : result_error(result) + end + end + end +end diff --git a/app/graphql/resolvers/customers_resolver.rb b/app/graphql/resolvers/customers_resolver.rb new file mode 100644 index 0000000..ed0afc4 --- /dev/null +++ b/app/graphql/resolvers/customers_resolver.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Resolvers + class CustomersResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "customers:view" + + description "Query customers of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + argument :search_term, String, required: false + + argument :account_type, [Types::Customers::AccountTypeEnum], required: false + argument :active_subscriptions_count_from, Integer, required: false + argument :active_subscriptions_count_to, Integer, required: false + argument :billing_entity_ids, [ID], required: false + argument :countries, [Types::CountryCodeEnum], required: false + argument :currencies, [Types::CurrencyEnum], required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + argument :has_customer_type, Boolean, required: false + argument :has_tax_identification_number, Boolean, required: false + argument :metadata, [Types::Customers::Metadata::Filter], required: false + argument :states, [String], required: false + argument :with_deleted, Boolean, required: false + argument :zipcodes, [String], required: false + + type Types::Customers::Object.collection_type, null: false + + def resolve(search_term: nil, page: nil, limit: nil, metadata: nil, **filters) + if metadata.present? + filters[:metadata] = metadata.to_h { |m| [m[:key], m[:value]] } + end + result = CustomersQuery.call( + organization: current_organization, + search_term:, + pagination: { + page:, + limit: + }, + filters: + ) + + result.customers + end + end +end diff --git a/app/graphql/resolvers/data_api/mrrs/plans_resolver.rb b/app/graphql/resolvers/data_api/mrrs/plans_resolver.rb new file mode 100644 index 0000000..37bf6cd --- /dev/null +++ b/app/graphql/resolvers/data_api/mrrs/plans_resolver.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + module Mrrs + class PlansResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiMrrsPlans" + description "Query monthly recurring revenues plans of an organization" + + argument :currency, Types::CurrencyEnum, required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::DataApi::Mrrs::Plans::Collection, null: false + + def resolve(**args) + result = ::DataApi::Mrrs::PlansService.call(current_organization, **args) + + { + collection: result.data_mrrs_plans["mrrs_plans"], + metadata: result.data_mrrs_plans["meta"] + } + end + end + end + end +end diff --git a/app/graphql/resolvers/data_api/mrrs_resolver.rb b/app/graphql/resolvers/data_api/mrrs_resolver.rb new file mode 100644 index 0000000..2dce159 --- /dev/null +++ b/app/graphql/resolvers/data_api/mrrs_resolver.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + class MrrsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiMrrs" + description "Query monthly recurring revenues of an organization" + + argument :currency, Types::CurrencyEnum, required: false + + argument :customer_country, Types::CountryCodeEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + argument :time_granularity, Types::DataApi::TimeGranularityEnum, required: false + + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + + argument :billing_entity_code, String, required: false + argument :plan_code, String, required: false + + argument :is_customer_tin_empty, Boolean, required: false + + type Types::DataApi::Mrrs::Object.collection_type, null: false + + def resolve(**args) + result = ::DataApi::MrrsService.call(current_organization, **args) + result.mrrs + end + end + end +end diff --git a/app/graphql/resolvers/data_api/prepaid_credits_resolver.rb b/app/graphql/resolvers/data_api/prepaid_credits_resolver.rb new file mode 100644 index 0000000..d43f39a --- /dev/null +++ b/app/graphql/resolvers/data_api/prepaid_credits_resolver.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + class PrepaidCreditsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiPrepaidCredits" + description "Query prepaid credits of an organization" + + argument :currency, Types::CurrencyEnum, required: false + + argument :customer_country, Types::CountryCodeEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + argument :time_granularity, Types::DataApi::TimeGranularityEnum, required: false + + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + + argument :billing_entity_code, String, required: false + argument :plan_code, String, required: false + + argument :is_customer_tin_empty, Boolean, required: false + + type Types::DataApi::PrepaidCredits::Object.collection_type, null: false + + def resolve(**args) + result = ::DataApi::PrepaidCreditsService.call(current_organization, **args) + result.prepaid_credits + end + end + end +end diff --git a/app/graphql/resolvers/data_api/revenue_streams/customers_resolver.rb b/app/graphql/resolvers/data_api/revenue_streams/customers_resolver.rb new file mode 100644 index 0000000..acef4d5 --- /dev/null +++ b/app/graphql/resolvers/data_api/revenue_streams/customers_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + module RevenueStreams + class CustomersResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiRevenueStreamsCustomers" + description "Query revenue streams customers of an organization" + + argument :currency, Types::CurrencyEnum, required: false + argument :limit, Integer, required: false + argument :order_by, Types::DataApi::RevenueStreams::OrderByEnum, required: false + argument :page, Integer, required: false + + type Types::DataApi::RevenueStreams::Customers::Collection, null: false + + def resolve(**args) + result = ::DataApi::RevenueStreams::CustomersService.call(current_organization, **args) + + { + collection: result.data_revenue_streams_customers["revenue_streams_customers"], + metadata: result.data_revenue_streams_customers["meta"] + } + end + end + end + end +end diff --git a/app/graphql/resolvers/data_api/revenue_streams/plans_resolver.rb b/app/graphql/resolvers/data_api/revenue_streams/plans_resolver.rb new file mode 100644 index 0000000..53d216a --- /dev/null +++ b/app/graphql/resolvers/data_api/revenue_streams/plans_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + module RevenueStreams + class PlansResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiRevenueStreamsPlans" + description "Query revenue streams plans of an organization" + + argument :currency, Types::CurrencyEnum, required: false + argument :limit, Integer, required: false + argument :order_by, Types::DataApi::RevenueStreams::OrderByEnum, required: false + argument :page, Integer, required: false + + type Types::DataApi::RevenueStreams::Plans::Collection, null: false + + def resolve(**args) + result = ::DataApi::RevenueStreams::PlansService.call(current_organization, **args) + + { + collection: result.data_revenue_streams_plans["revenue_streams_plans"], + metadata: result.data_revenue_streams_plans["meta"] + } + end + end + end + end +end diff --git a/app/graphql/resolvers/data_api/revenue_streams_resolver.rb b/app/graphql/resolvers/data_api/revenue_streams_resolver.rb new file mode 100644 index 0000000..4b73dc8 --- /dev/null +++ b/app/graphql/resolvers/data_api/revenue_streams_resolver.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + class RevenueStreamsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiRevenueStreams" + description "Query revenue streams of an organization" + + argument :currency, Types::CurrencyEnum, required: false + + argument :customer_country, Types::CountryCodeEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + argument :time_granularity, Types::DataApi::TimeGranularityEnum, required: false + + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + + argument :billing_entity_code, String, required: false + argument :plan_code, String, required: false + + argument :is_customer_tin_empty, Boolean, required: false + + type Types::DataApi::RevenueStreams::Object.collection_type, null: false + + def resolve(**args) + result = ::DataApi::RevenueStreamsService.call(current_organization, **args) + result.revenue_streams + end + end + end +end diff --git a/app/graphql/resolvers/data_api/usages/aggregated_amounts_resolver.rb b/app/graphql/resolvers/data_api/usages/aggregated_amounts_resolver.rb new file mode 100644 index 0000000..0baa992 --- /dev/null +++ b/app/graphql/resolvers/data_api/usages/aggregated_amounts_resolver.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + module Usages + class AggregatedAmountsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiUsagesAggregatedAmounts" + description "Query usages of an organization" + + argument :currency, Types::CurrencyEnum, required: false + + argument :customer_country, Types::CountryCodeEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + argument :is_billable_metric_recurring, Boolean, required: false + argument :time_granularity, Types::DataApi::TimeGranularityEnum, required: false + + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + + argument :billing_entity_code, String, required: false + argument :plan_code, String, required: false + + argument :is_customer_tin_empty, Boolean, required: false + + type Types::DataApi::Usages::AggregatedAmounts::Object.collection_type, null: false + + def resolve(**args) + result = ::DataApi::Usages::AggregatedAmountsService.call(current_organization, **args) + result.aggregated_amounts_usages + end + end + end + end +end diff --git a/app/graphql/resolvers/data_api/usages/forecasted_resolver.rb b/app/graphql/resolvers/data_api/usages/forecasted_resolver.rb new file mode 100644 index 0000000..e967d27 --- /dev/null +++ b/app/graphql/resolvers/data_api/usages/forecasted_resolver.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + module Usages + class ForecastedResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiUsagesForecasted" + description "Query forecasted usages of an organization" + + argument :charge_filter_id, String, required: false + argument :charge_id, String, required: false + + argument :currency, Types::CurrencyEnum, required: false + + argument :customer_country, Types::CountryCodeEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + argument :time_granularity, Types::DataApi::TimeGranularityEnum, required: false + + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + + argument :billable_metric_code, String, required: false + argument :billing_entity_code, String, required: false + argument :plan_code, String, required: false + + argument :is_customer_tin_empty, Boolean, required: false + + type Types::DataApi::Usages::Forecasted::Object.collection_type, null: false + + def resolve(**args) + result = ::DataApi::Usages::ForecastedService.call(current_organization, **args) + result.forecasted_usages + end + end + end + end +end diff --git a/app/graphql/resolvers/data_api/usages/invoiced_resolver.rb b/app/graphql/resolvers/data_api/usages/invoiced_resolver.rb new file mode 100644 index 0000000..1572d50 --- /dev/null +++ b/app/graphql/resolvers/data_api/usages/invoiced_resolver.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + module Usages + class InvoicedResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiUsagesInvoiced" + description "Query invoiced usages of an organization" + + argument :currency, Types::CurrencyEnum, required: false + + argument :customer_country, Types::CountryCodeEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + argument :time_granularity, Types::DataApi::TimeGranularityEnum, required: false + + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + + argument :billable_metric_code, String, required: false + argument :billing_entity_code, String, required: false + argument :plan_code, String, required: false + + argument :filter_values, String, required: false + argument :grouped_by, String, required: false + + argument :is_customer_tin_empty, Boolean, required: false + + type Types::DataApi::Usages::Invoiced::Object.collection_type, null: false + + def resolve(**args) + result = ::DataApi::Usages::InvoicedService.call(current_organization, **args) + result.invoiced_usages + end + end + end + end +end diff --git a/app/graphql/resolvers/data_api/usages_resolver.rb b/app/graphql/resolvers/data_api/usages_resolver.rb new file mode 100644 index 0000000..ddc121a --- /dev/null +++ b/app/graphql/resolvers/data_api/usages_resolver.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Resolvers + module DataApi + class UsagesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "data_api:view" + + graphql_name "DataApiUsages" + description "Query usages of an organization" + + argument :currency, Types::CurrencyEnum, required: false + + argument :customer_country, Types::CountryCodeEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + argument :is_billable_metric_recurring, Boolean, required: false + argument :time_granularity, Types::DataApi::TimeGranularityEnum, required: false + + argument :external_customer_id, String, required: false + argument :external_subscription_id, String, required: false + + argument :billable_metric_code, String, required: false + argument :billing_entity_code, String, required: false + argument :plan_code, String, required: false + + argument :is_customer_tin_empty, Boolean, required: false + + type Types::DataApi::Usages::Object.collection_type, null: false + + def resolve(**args) + result = ::DataApi::UsagesService.call(current_organization, **args) + result.usages + end + end + end +end diff --git a/app/graphql/resolvers/dunning_campaign_resolver.rb b/app/graphql/resolvers/dunning_campaign_resolver.rb new file mode 100644 index 0000000..db79694 --- /dev/null +++ b/app/graphql/resolvers/dunning_campaign_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class DunningCampaignResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query a single dunning campaign of an organization" + + REQUIRED_PERMISSION = "dunning_campaigns:view" + + argument :id, ID, required: true, description: "Unique ID of the dunning campaign" + + type Types::DunningCampaigns::Object, null: false + + def resolve(id: nil) + current_organization.dunning_campaigns.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "dunning_campaign") + end + end +end diff --git a/app/graphql/resolvers/dunning_campaigns_resolver.rb b/app/graphql/resolvers/dunning_campaigns_resolver.rb new file mode 100644 index 0000000..344626c --- /dev/null +++ b/app/graphql/resolvers/dunning_campaigns_resolver.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Resolvers + class DunningCampaignsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query dunning campaigns of an organization" + + REQUIRED_PERMISSION = "dunning_campaigns:view" + + argument :applied_to_organization, Boolean, required: false + argument :currency, [Types::CurrencyEnum], required: false + argument :limit, Integer, required: false + argument :order, String, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::DunningCampaigns::Object.collection_type, null: false + + def resolve( # rubocop:disable Metrics/ParameterLists + applied_to_organization: nil, + currency: nil, + order: nil, + page: nil, + limit: nil, + search_term: nil + ) + result = ::DunningCampaignsQuery.call( + organization: current_organization, + search_term:, + order:, + pagination: {page:, limit:}, + filters: { + applied_to_organization:, + currency: + } + ) + + result.dunning_campaigns + end + end +end diff --git a/app/graphql/resolvers/entitlement/feature_resolver.rb b/app/graphql/resolvers/entitlement/feature_resolver.rb new file mode 100644 index 0000000..3e363e8 --- /dev/null +++ b/app/graphql/resolvers/entitlement/feature_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module Entitlement + class FeatureResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "features:view" + + description "Query a single feature" + + argument :code, String, required: false, description: "Unique code of the feature" + argument :id, ID, required: false, description: "Unique ID of the feature" + + validates required: {one_of: [:id, :code]} + + type Types::Entitlement::FeatureObject, null: false + + def resolve(id: nil, code: nil) + if id + current_organization.features.find(id) + elsif code + current_organization.features.find_by!(code:) + end + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "feature") + end + end + end +end diff --git a/app/graphql/resolvers/entitlement/features_resolver.rb b/app/graphql/resolvers/entitlement/features_resolver.rb new file mode 100644 index 0000000..010e1ca --- /dev/null +++ b/app/graphql/resolvers/entitlement/features_resolver.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Resolvers + module Entitlement + class FeaturesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "features:view" + + description "Query features of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::Entitlement::FeatureObject.collection_type, null: false + + def resolve(**args) + result = ::Entitlement::FeaturesQuery.call( + organization: current_organization, + search_term: args[:search_term], + pagination: { + page: args[:page], + limit: args[:limit] + } + ) + + result.features.includes(:privileges) + end + end + end +end diff --git a/app/graphql/resolvers/entitlement/subscription_entitlement_resolver.rb b/app/graphql/resolvers/entitlement/subscription_entitlement_resolver.rb new file mode 100644 index 0000000..4c356b5 --- /dev/null +++ b/app/graphql/resolvers/entitlement/subscription_entitlement_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module Entitlement + class SubscriptionEntitlementResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:view" + + description "Retrieve an entitlement of a subscriptions" + + argument :feature_code, String, required: true + argument :subscription_id, ID, required: true + + type Types::Entitlement::SubscriptionEntitlementObject, null: false + + def resolve(subscription_id:, feature_code:) + subscription = current_organization.subscriptions.find(subscription_id) + + # TODO: Replace this once we have `where` clause on SubscriptionEntitlementQuery + all_entitlements = ::Entitlement::SubscriptionEntitlement.for_subscription(subscription) + entitlement = all_entitlements.find { it.code == feature_code } + + entitlement || not_found_error(resource: "entitlement") + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "subscription") + end + end + end +end diff --git a/app/graphql/resolvers/entitlement/subscription_entitlements_resolver.rb b/app/graphql/resolvers/entitlement/subscription_entitlements_resolver.rb new file mode 100644 index 0000000..274197a --- /dev/null +++ b/app/graphql/resolvers/entitlement/subscription_entitlements_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + module Entitlement + class SubscriptionEntitlementsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:view" + + description "Query entitlements of a subscriptions" + + argument :subscription_id, ID, required: true + + type Types::Entitlement::SubscriptionEntitlementObject.collection_type, null: false + + def resolve(subscription_id:) + subscription = current_organization.subscriptions.find(subscription_id) + + ::Entitlement::SubscriptionEntitlement.for_subscription(subscription) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "subscription") + end + end + end +end diff --git a/app/graphql/resolvers/event_resolver.rb b/app/graphql/resolvers/event_resolver.rb new file mode 100644 index 0000000..f9d6503 --- /dev/null +++ b/app/graphql/resolvers/event_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + class EventResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query a single event of an organization" + + argument :transaction_id, ID, required: true, description: "Transaction ID of the event" + + type Types::Events::Object, null: true + + def resolve(transaction_id: nil) + event_scope = current_organization.clickhouse_events_store? ? Clickhouse::EventsRaw : Event + event_scope.find_by!(organization_id: current_organization.id, transaction_id:) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "event") + end + end +end diff --git a/app/graphql/resolvers/event_types_resolver.rb b/app/graphql/resolvers/event_types_resolver.rb new file mode 100644 index 0000000..8495bd1 --- /dev/null +++ b/app/graphql/resolvers/event_types_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + class EventTypesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + + REQUIRED_PERMISSION = "developers:manage" + + description "Query Event Types for Webhook Endpoints" + + type [Types::WebhookEndpoints::EventType], null: false + + def resolve + WebhookEndpoint::WEBHOOK_EVENT_TYPE_CONFIG.map do |key, event_type| + { + key: event_type[:name], + name: event_type[:name], + description: event_type[:description], + category: event_type[:category], + deprecated: event_type[:deprecated] + } + end + end + end +end diff --git a/app/graphql/resolvers/events_resolver.rb b/app/graphql/resolvers/events_resolver.rb new file mode 100644 index 0000000..6441a63 --- /dev/null +++ b/app/graphql/resolvers/events_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class EventsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + MAX_LIMIT = 1000 + + description "Query events of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::Events::Object.collection_type, null: true + + def resolve(page: nil, limit: nil) + if current_organization.clickhouse_events_store? + Clickhouse::EventsRaw.where(organization_id: current_organization.id) + .order(ingested_at: :desc) + .page(page) + .per((limit >= MAX_LIMIT) ? MAX_LIMIT : limit) + else + current_organization.events + .order(created_at: :desc) + .page(page) + .per((limit >= MAX_LIMIT) ? MAX_LIMIT : limit) + end + end + end +end diff --git a/app/graphql/resolvers/integration_collection_mappings_resolver.rb b/app/graphql/resolvers/integration_collection_mappings_resolver.rb new file mode 100644 index 0000000..23ea5d8 --- /dev/null +++ b/app/graphql/resolvers/integration_collection_mappings_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + class IntegrationCollectionMappingsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:view" + + description "Query integration collection mappings" + + argument :integration_id, ID, required: true + + type Types::IntegrationCollectionMappings::Object.collection_type, null: true + + def resolve(integration_id:) + result = ::IntegrationCollectionMappingsQuery.call( + organization: current_organization, + filters: {integration_id:} + ) + + result.integration_collection_mappings + end + end +end diff --git a/app/graphql/resolvers/integration_items_resolver.rb b/app/graphql/resolvers/integration_items_resolver.rb new file mode 100644 index 0000000..8e80437 --- /dev/null +++ b/app/graphql/resolvers/integration_items_resolver.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Resolvers + class IntegrationItemsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:view" + + description "Query integration items of an integration" + + argument :integration_id, ID, required: true + argument :item_type, Types::IntegrationItems::ItemTypeEnum, required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::IntegrationItems::Object.collection_type, null: false + + def resolve(integration_id:, page: nil, limit: nil, search_term: nil, item_type: nil) + integration = current_organization.integrations.where(id: integration_id).first + + return not_found_error(resource: "integration") unless integration + + result = ::IntegrationItemsQuery.call( + organization: current_organization, + search_term:, + filters: { + integration_id:, + item_type: + }, + pagination: { + page:, + limit: + } + ) + + result.integration_items + end + end +end diff --git a/app/graphql/resolvers/integration_resolver.rb b/app/graphql/resolvers/integration_resolver.rb new file mode 100644 index 0000000..4a70a99 --- /dev/null +++ b/app/graphql/resolvers/integration_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class IntegrationResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:view" + + description "Query a single integration" + + argument :id, ID, required: false, description: "Uniq ID of the integration" + + type Types::Integrations::Object, null: true + + def resolve(id: nil) + current_organization.integrations.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "integration") + end + end +end diff --git a/app/graphql/resolvers/integrations/subsidiaries_resolver.rb b/app/graphql/resolvers/integrations/subsidiaries_resolver.rb new file mode 100644 index 0000000..a18ed4a --- /dev/null +++ b/app/graphql/resolvers/integrations/subsidiaries_resolver.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Resolvers + module Integrations + class SubsidiariesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:view" + + description "Query integration subsidiaries" + + argument :integration_id, ID, required: false + + type Types::Integrations::Subsidiaries::Object.collection_type, null: true + + def resolve(integration_id: nil) + integration = current_organization.integrations.find(integration_id) + + result = ::Integrations::Aggregator::SubsidiariesService.call(integration:) + + result.subsidiaries + end + end + end +end diff --git a/app/graphql/resolvers/integrations_resolver.rb b/app/graphql/resolvers/integrations_resolver.rb new file mode 100644 index 0000000..59eae57 --- /dev/null +++ b/app/graphql/resolvers/integrations_resolver.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Resolvers + class IntegrationsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = %w[organization:integrations:view customers:view] + + description "Query organization's integrations" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :types, [Types::Integrations::IntegrationTypeEnum], required: false + + type Types::Integrations::Object.collection_type, null: true + + def resolve(types: nil, page: nil, limit: nil) + scope = current_organization.integrations.page(page).per(limit) + scope = scope.where(type: types(types)) if types.present? + scope + end + + private + + def types(input) + input.map { |type| ::Integrations::BaseIntegration.integration_type(type) } + end + end +end diff --git a/app/graphql/resolvers/invite_resolver.rb b/app/graphql/resolvers/invite_resolver.rb new file mode 100644 index 0000000..8bfea26 --- /dev/null +++ b/app/graphql/resolvers/invite_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class InviteResolver < Resolvers::BaseResolver + description "Query a single Invite" + + argument :token, String, required: true, description: "Uniq token of the Invite" + + type Types::Invites::Object, null: true + + def resolve(token:) + invite = Invite.find_by( + token:, + status: "pending" + ) + + return not_found_error(resource: "invite") unless invite + + invite + end + end +end diff --git a/app/graphql/resolvers/invites_resolver.rb b/app/graphql/resolvers/invites_resolver.rb new file mode 100644 index 0000000..d191b4f --- /dev/null +++ b/app/graphql/resolvers/invites_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + class InvitesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query pending invites of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::Invites::Object.collection_type, null: false + + def resolve(page: nil, limit: nil) + current_organization + .invites + .pending + .order(created_at: :desc) + .page(page) + .per(limit) + end + end +end diff --git a/app/graphql/resolvers/invoice_credit_notes_resolver.rb b/app/graphql/resolvers/invoice_credit_notes_resolver.rb new file mode 100644 index 0000000..4b0196d --- /dev/null +++ b/app/graphql/resolvers/invoice_credit_notes_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class InvoiceCreditNotesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "credit_notes:view" + + description "Query invoice's credit note" + + argument :invoice_id, ID, required: true, description: "Uniq ID of the invoice" + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::CreditNotes::Object.collection_type, null: true + + def resolve(invoice_id: nil, page: nil, limit: nil) + current_organization + .invoices + .find(invoice_id) + .credit_notes + .finalized + .order(created_at: :desc) + .page(page) + .per(limit) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "invoice") + end + end +end diff --git a/app/graphql/resolvers/invoice_custom_section_resolver.rb b/app/graphql/resolvers/invoice_custom_section_resolver.rb new file mode 100644 index 0000000..dc6deb9 --- /dev/null +++ b/app/graphql/resolvers/invoice_custom_section_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + class InvoiceCustomSectionResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query a single invoice_custom_section of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the invoice_custom_section" + + type Types::InvoiceCustomSections::Object, null: false + + def resolve(id: nil) + current_organization.invoice_custom_sections.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "invoice_custom_section") + end + end +end diff --git a/app/graphql/resolvers/invoice_custom_sections_resolver.rb b/app/graphql/resolvers/invoice_custom_sections_resolver.rb new file mode 100644 index 0000000..f29d323 --- /dev/null +++ b/app/graphql/resolvers/invoice_custom_sections_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + class InvoiceCustomSectionsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoice_custom_sections:view" + + description "Query invoice_custom_sections" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::InvoiceCustomSections::Object.collection_type, null: true + + def resolve(page: nil, limit: nil) + current_organization.manual_invoice_custom_sections.order(name: :asc).page(page).per(limit) + end + end +end diff --git a/app/graphql/resolvers/invoice_resolver.rb b/app/graphql/resolvers/invoice_resolver.rb new file mode 100644 index 0000000..0e35bb8 --- /dev/null +++ b/app/graphql/resolvers/invoice_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class InvoiceResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:view" + + description "Query a single Invoice of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the invoice" + + type Types::Invoices::Object, null: true + + def resolve(id:) + current_organization.invoices.visible.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "invoice") + end + end +end diff --git a/app/graphql/resolvers/invoices_resolver.rb b/app/graphql/resolvers/invoices_resolver.rb new file mode 100644 index 0000000..b79d74b --- /dev/null +++ b/app/graphql/resolvers/invoices_resolver.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Resolvers + class InvoicesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "invoices:view" + + description "Query invoices" + + argument :amount_from, Integer, required: false + argument :amount_to, Integer, required: false + argument :billing_entity_ids, [ID], required: false + argument :currency, Types::CurrencyEnum, required: false + argument :customer_external_id, String, required: false + argument :customer_id, ID, required: false, description: "Uniq ID of the customer" + argument :invoice_type, [Types::Invoices::InvoiceTypeEnum], required: false + argument :issuing_date_from, GraphQL::Types::ISO8601Date, required: false + argument :issuing_date_to, GraphQL::Types::ISO8601Date, required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :partially_paid, Boolean, required: false + argument :payment_dispute_lost, Boolean, required: false + argument :payment_overdue, Boolean, required: false + argument :payment_status, [Types::Invoices::PaymentStatusTypeEnum], required: false + argument :positive_due_amount, Boolean, required: false + argument :search_term, String, required: false + argument :self_billed, Boolean, required: false + argument :settlements, [Types::Invoices::SettlementTypeEnum], required: false + argument :status, [Types::Invoices::StatusTypeEnum], required: false + argument :subscription_id, ID, required: false + + type Types::Invoices::Object.collection_type, null: false + + def resolve( # rubocop:disable Metrics/ParameterLists + amount_from: nil, + amount_to: nil, + billing_entity_ids: nil, + currency: nil, + customer_external_id: nil, + customer_id: nil, + invoice_type: nil, + issuing_date_from: nil, + issuing_date_to: nil, + limit: nil, + page: nil, + payment_dispute_lost: nil, + payment_overdue: nil, + partially_paid: nil, + positive_due_amount: nil, + payment_status: nil, + search_term: nil, + self_billed: nil, + status: nil, + settlements: nil, + subscription_id: nil + ) + result = InvoicesQuery.call( + organization: current_organization, + pagination: {page:, limit:}, + search_term:, + filters: { + amount_from:, + amount_to:, + billing_entity_ids:, + currency:, + customer_external_id:, + customer_id:, + invoice_type:, + issuing_date_from:, + issuing_date_to:, + partially_paid:, + payment_dispute_lost:, + payment_overdue:, + payment_status:, + positive_due_amount:, + self_billed:, + status:, + settlements:, + subscription_id: + } + ) + + return result_error(result) unless result.success? + + Invoice.preload_offset_amounts( + result.invoices.preload( + :fees, + :regenerated_invoice, + :error_details, + :billing_entity, + {customer: :billing_entity} + ) + ) + end + end +end diff --git a/app/graphql/resolvers/memberships_resolver.rb b/app/graphql/resolvers/memberships_resolver.rb new file mode 100644 index 0000000..38f24e1 --- /dev/null +++ b/app/graphql/resolvers/memberships_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + class MembershipsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query memberships of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::MembershipType.collection_type(metadata_type: Types::Memberships::Metadata), null: false + + def resolve(page: nil, limit: nil) + current_organization + .memberships + .includes(:user) + .active + .page(page) + .per(limit) + end + end +end diff --git a/app/graphql/resolvers/organization_resolver.rb b/app/graphql/resolvers/organization_resolver.rb new file mode 100644 index 0000000..4f677d8 --- /dev/null +++ b/app/graphql/resolvers/organization_resolver.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Resolvers + class OrganizationResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query the current organization" + + type Types::Organizations::CurrentOrganizationType, null: true + + def resolve + current_organization + end + end +end diff --git a/app/graphql/resolvers/password_reset_resolver.rb b/app/graphql/resolvers/password_reset_resolver.rb new file mode 100644 index 0000000..ff291c9 --- /dev/null +++ b/app/graphql/resolvers/password_reset_resolver.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Resolvers + class PasswordResetResolver < Resolvers::BaseResolver + description "Query a password reset by token" + + argument :token, String, required: true, description: "Uniq token of the password reset" + + type Types::ResetPasswords::Object, null: false + + def resolve(token: nil) + password_reset = PasswordReset.where("expire_at > ?", Time.current).find_by(token:) + + return not_found_error(resource: "password_reset") unless password_reset + + password_reset + end + end +end diff --git a/app/graphql/resolvers/payment_methods_resolver.rb b/app/graphql/resolvers/payment_methods_resolver.rb new file mode 100644 index 0000000..03491ea --- /dev/null +++ b/app/graphql/resolvers/payment_methods_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + class PaymentMethodsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payment_methods:view" + + description "Query payment methods of a customer" + + argument :external_customer_id, ID, required: true, description: "External ID of the customer" + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :with_deleted, Boolean, required: false + + type Types::PaymentMethods::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, external_customer_id: nil, with_deleted: nil) + result = PaymentMethodsQuery.call( + organization: current_organization, + filters: { + external_customer_id:, + with_deleted: + }, + pagination: { + page:, + limit: + } + ) + + result.payment_methods.includes(:payment_provider) + end + end +end diff --git a/app/graphql/resolvers/payment_provider_resolver.rb b/app/graphql/resolvers/payment_provider_resolver.rb new file mode 100644 index 0000000..96646e4 --- /dev/null +++ b/app/graphql/resolvers/payment_provider_resolver.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Resolvers + class PaymentProviderResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "organization:integrations:view" + + description "Query a single payment provider" + + argument :code, String, required: false, description: "Code of the payment provider" + argument :id, ID, required: false, description: "Uniq ID of the payment provider" + + type Types::PaymentProviders::Object, null: true + + def resolve(id: nil, code: nil) + if id.present? + current_organization.payment_providers.find(id) + else + current_organization.payment_providers.find_by!(code:) + end + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "payment_provider") + end + end +end diff --git a/app/graphql/resolvers/payment_providers_resolver.rb b/app/graphql/resolvers/payment_providers_resolver.rb new file mode 100644 index 0000000..5c6cc42 --- /dev/null +++ b/app/graphql/resolvers/payment_providers_resolver.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Resolvers + class PaymentProvidersResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = %w[organization:integrations:view customers:view] + + description "Query organization's payment providers" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :type, Types::PaymentProviders::ProviderTypeEnum, required: false + + type Types::PaymentProviders::Object.collection_type, null: true + + def resolve(type: nil, page: nil, limit: nil) + scope = current_organization.payment_providers.page(page).per(limit) + scope = scope.where(type: provider_type(type)) if type.present? + scope + end + + private + + def provider_type(type) + case type + when "adyen" + PaymentProviders::AdyenProvider.to_s + when "stripe" + PaymentProviders::StripeProvider.to_s + when "gocardless" + PaymentProviders::GocardlessProvider.to_s + when "cashfree" + PaymentProviders::CashfreeProvider.to_s + when "flutterwave" + PaymentProviders::FlutterwaveProvider.to_s + when "moneyhash" + PaymentProviders::MoneyhashProvider.to_s + else + raise(NotImplementedError) + end + end + end +end diff --git a/app/graphql/resolvers/payment_requests_resolver.rb b/app/graphql/resolvers/payment_requests_resolver.rb new file mode 100644 index 0000000..e9f521f --- /dev/null +++ b/app/graphql/resolvers/payment_requests_resolver.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Resolvers + class PaymentRequestsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payments:view" + + description "Query payment requests of an organization" + + argument :currency, String, required: false + argument :external_customer_id, String, required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :payment_status, Types::Invoices::PaymentStatusTypeEnum, required: false + + type Types::PaymentRequests::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, external_customer_id: nil, payment_status: nil, currency: nil) + result = PaymentRequestsQuery.call( + organization: current_organization, + filters: { + external_customer_id:, + payment_status:, + currency: + }, + pagination: { + page:, + limit: + } + ) + + result.payment_requests + end + end +end diff --git a/app/graphql/resolvers/payment_resolver.rb b/app/graphql/resolvers/payment_resolver.rb new file mode 100644 index 0000000..909c0bf --- /dev/null +++ b/app/graphql/resolvers/payment_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class PaymentResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payments:view" + + description "Query a single Payment" + + argument :id, ID, required: true, description: "Uniq ID of the payment" + + type Types::Payments::Object, null: true + + def resolve(id:) + Payment.for_organization(current_organization).find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "payment") + end + end +end diff --git a/app/graphql/resolvers/payments_resolver.rb b/app/graphql/resolvers/payments_resolver.rb new file mode 100644 index 0000000..06203cb --- /dev/null +++ b/app/graphql/resolvers/payments_resolver.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Resolvers + class PaymentsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "payments:view" + + description "Query payments of an organization" + + argument :external_customer_id, ID, required: false + argument :invoice_id, ID, required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::Payments::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, invoice_id: nil, external_customer_id: nil, search_term: nil) + result = PaymentsQuery.call( + organization: current_organization, + filters: { + invoice_id:, + external_customer_id: + }, + search_term:, + pagination: { + page:, + limit: + } + ) + + result.payments + end + end +end diff --git a/app/graphql/resolvers/plan_resolver.rb b/app/graphql/resolvers/plan_resolver.rb new file mode 100644 index 0000000..05f7049 --- /dev/null +++ b/app/graphql/resolvers/plan_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class PlanResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "plans:view" + + description "Query a single plan of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the plan" + + type Types::Plans::Object, null: true + + def resolve(id: nil) + current_organization.plans.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "plan") + end + end +end diff --git a/app/graphql/resolvers/plans_resolver.rb b/app/graphql/resolvers/plans_resolver.rb new file mode 100644 index 0000000..db2bad3 --- /dev/null +++ b/app/graphql/resolvers/plans_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + class PlansResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "plans:view" + + description "Query plans of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + argument :with_deleted, Boolean, required: false + + type Types::Plans::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, search_term: nil, with_deleted: nil) + result = PlansQuery.call( + organization: current_organization, + search_term:, + filters: { + with_deleted: + }, + pagination: { + page:, + limit: + } + ) + + result.plans + end + end +end diff --git a/app/graphql/resolvers/pricing_unit_resolver.rb b/app/graphql/resolvers/pricing_unit_resolver.rb new file mode 100644 index 0000000..8c12782 --- /dev/null +++ b/app/graphql/resolvers/pricing_unit_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class PricingUnitResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "pricing_units:view" + + argument :id, ID, required: true, description: "Uniq ID of the pricing unit" + + description "Query the pricing unit" + + type Types::PricingUnits::Object, null: false + + def resolve(id: nil) + current_organization.pricing_units.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "pricing_unit") + end + end +end diff --git a/app/graphql/resolvers/pricing_units_resolver.rb b/app/graphql/resolvers/pricing_units_resolver.rb new file mode 100644 index 0000000..6479b45 --- /dev/null +++ b/app/graphql/resolvers/pricing_units_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + class PricingUnitsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "pricing_units:view" + + description "Query the pricing units of current organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::PricingUnits::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, search_term: nil) + result = ::PricingUnitsQuery.call( + organization: current_organization, + search_term:, + pagination: {page:, limit:} + ) + + result.pricing_units + end + end +end diff --git a/app/graphql/resolvers/quote_resolver.rb b/app/graphql/resolvers/quote_resolver.rb new file mode 100644 index 0000000..ca21bd8 --- /dev/null +++ b/app/graphql/resolvers/quote_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class QuoteResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:view" + + description "Query a quote" + + argument :id, ID, required: true + + type Types::Quotes::Object, null: true + + def resolve(id:) + current_organization.quotes.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "quote") + end + end +end diff --git a/app/graphql/resolvers/quotes_resolver.rb b/app/graphql/resolvers/quotes_resolver.rb new file mode 100644 index 0000000..45ce7fc --- /dev/null +++ b/app/graphql/resolvers/quotes_resolver.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Resolvers + class QuotesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "quotes:view" + + description "Query quotes of an organization" + + argument :customers, [ID], required: false + argument :from_date, GraphQL::Types::ISO8601Date, required: false + argument :limit, Integer, required: false + argument :numbers, [String], required: false + argument :order_types, [Types::Quotes::OrderTypeEnum], required: false + argument :owners, [ID], required: false + argument :page, Integer, required: false + argument :statuses, [Types::QuoteVersions::StatusEnum], required: false + argument :to_date, GraphQL::Types::ISO8601Date, required: false + + type Types::Quotes::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, customers: nil, numbers: nil, statuses: nil, from_date: nil, to_date: nil, owners: nil, order_types: nil) + result = ::QuotesQuery.call( + organization: current_organization, + filters: { + customers:, + statuses:, + numbers:, + from_date:, + to_date:, + owners:, + order_types: + }, + pagination: { + page:, + limit: + } + ) + + result.success? ? result.quotes : result_error(result) + end + end +end diff --git a/app/graphql/resolvers/role_resolver.rb b/app/graphql/resolvers/role_resolver.rb new file mode 100644 index 0000000..44fee33 --- /dev/null +++ b/app/graphql/resolvers/role_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class RoleResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "roles:view" + + description "Query a single role" + + argument :id, ID, required: true, description: "Uniq ID of the role" + + type Types::RoleType, null: true + + def resolve(id:) + Role.with_organization(current_organization.id).find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "role") + end + end +end diff --git a/app/graphql/resolvers/roles_resolver.rb b/app/graphql/resolvers/roles_resolver.rb new file mode 100644 index 0000000..908f9a0 --- /dev/null +++ b/app/graphql/resolvers/roles_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + class RolesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "roles:view" + + description "Query roles available for the organization" + + type [Types::RoleType], null: false + + def resolve + Role + .includes(active_memberships: :user) + .where(organization_id: [nil, current_organization.id]) + .order("organization_id NULLS FIRST, LOWER(name)") + end + end +end diff --git a/app/graphql/resolvers/security_log_resolver.rb b/app/graphql/resolvers/security_log_resolver.rb new file mode 100644 index 0000000..0999c90 --- /dev/null +++ b/app/graphql/resolvers/security_log_resolver.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Resolvers + class SecurityLogResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "security_logs:view" + + description "Query a single security log by ID" + + argument :log_id, ID, required: true + + type Types::SecurityLogs::Object, null: true + + def resolve(log_id:) + raise forbidden_error(code: "feature_unavailable") unless SecurityLogsQuery.available? + raise forbidden_error(code: "feature_unavailable") unless current_organization.security_logs_enabled? + + current_organization.security_logs.find_by!(log_id:) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "security_log") + end + end +end diff --git a/app/graphql/resolvers/security_logs_resolver.rb b/app/graphql/resolvers/security_logs_resolver.rb new file mode 100644 index 0000000..5d07526 --- /dev/null +++ b/app/graphql/resolvers/security_logs_resolver.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Resolvers + class SecurityLogsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "security_logs:view" + + description "Query security logs of an organization" + + argument :api_key_ids, [ID], required: false + argument :from_datetime, GraphQL::Types::ISO8601DateTime, required: false + argument :limit, Integer, required: false + argument :log_events, [Types::SecurityLogs::LogEventEnum], required: false + argument :log_types, [Types::SecurityLogs::LogTypeEnum], required: false + argument :page, Integer, required: false + argument :to_datetime, GraphQL::Types::ISO8601DateTime, required: true, + description: "Upper date boundary (required for consistent pagination)" + argument :user_ids, [ID], required: false + + type Types::SecurityLogs::Object.collection_type, null: true + + def resolve(**args) + result = SecurityLogsQuery.call( + organization: current_organization, + filters: { + from_date: args[:from_datetime], + to_date: args[:to_datetime], + api_key_ids: args[:api_key_ids], + user_ids: args[:user_ids], + log_types: args[:log_types], + log_events: args[:log_events] + }, + pagination: { + page: args[:page], + limit: args[:limit] + } + ) + + return result_error(result) unless result.success? + + result.security_logs + end + end +end diff --git a/app/graphql/resolvers/subscription_resolver.rb b/app/graphql/resolvers/subscription_resolver.rb new file mode 100644 index 0000000..cdd4fe3 --- /dev/null +++ b/app/graphql/resolvers/subscription_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class SubscriptionResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:view" + + description "Query a single subscription of an organization" + + argument :external_id, ID, required: false, description: "External ID of the subscription" + argument :id, ID, required: false, description: "Lago ID of the subscription" + + type Types::Subscriptions::Object, null: true + + def resolve(id: nil, external_id: nil) + if id.nil? && external_id.nil? + raise GraphQL::ExecutionError, "You must provide either `id` or `external_id`." + end + + return current_organization.subscriptions.find(id) if id.present? + + current_organization.subscriptions + .order("terminated_at DESC NULLS FIRST, started_at DESC") + .find_by!(external_id:) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "subscription") + end + end +end diff --git a/app/graphql/resolvers/subscriptions/alert_resolver.rb b/app/graphql/resolvers/subscriptions/alert_resolver.rb new file mode 100644 index 0000000..3aa09ca --- /dev/null +++ b/app/graphql/resolvers/subscriptions/alert_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + module Subscriptions + class AlertResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:view" + + description "Query a single subscription alert" + + argument :id, ID, required: true, description: "Unique ID of the alert" + + type Types::UsageMonitoring::Alerts::Object, null: true + + def resolve(id:) + current_organization.alerts.using_subscription.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "alert") + end + end + end +end diff --git a/app/graphql/resolvers/subscriptions/alerts_resolver.rb b/app/graphql/resolvers/subscriptions/alerts_resolver.rb new file mode 100644 index 0000000..5c5ac2e --- /dev/null +++ b/app/graphql/resolvers/subscriptions/alerts_resolver.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Resolvers + module Subscriptions + class AlertsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:view" + + description "Query alerts of a subscription" + + # extras [:lookahead] + + argument :subscription_external_id, String, required: true, description: "External id of a subscription" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::UsageMonitoring::Alerts::Object.collection_type, null: false + + def resolve(subscription_external_id:, limit: nil, page: nil) + ::UsageMonitoring::AlertsQuery.call( + organization: current_organization, + filters: { + subscription_external_id: + }, + pagination: { + page:, + limit: + } + ).alerts + + # if lookahead.selection(:collection).selects?(:thresholds) + # alerts_query = alerts_query.includes(:thresholds) + # end + # + # if lookahead.selection(:collection).selects?(:billable_metric) + # alerts_query = alerts_query.includes(:billable_metric) + # end + end + end + end +end diff --git a/app/graphql/resolvers/subscriptions_resolver.rb b/app/graphql/resolvers/subscriptions_resolver.rb new file mode 100644 index 0000000..1d125ba --- /dev/null +++ b/app/graphql/resolvers/subscriptions_resolver.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Resolvers + class SubscriptionsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "subscriptions:view" + + description "Query subscriptions of an organization" + + argument :currency, String, required: false + argument :external_customer_id, String, required: false + argument :limit, Integer, required: false + argument :overriden, Boolean, required: false + argument :page, Integer, required: false + argument :plan_code, String, required: false + argument :search_term, String, required: false + argument :status, [Types::Subscriptions::StatusTypeEnum], required: false + + type Types::Subscriptions::Object.collection_type, null: false + + def resolve(page: nil, limit: nil, plan_code: nil, status: nil, external_customer_id: nil, overriden: nil, search_term: nil, currency: nil) + # In FE we include next subscription in the list, so we need to exclude subscriptions with previous subscription from the list + result = SubscriptionsQuery.call( + organization: current_organization, + pagination: {page:, limit:}, + filters: {plan_code:, status:, external_customer_id:, overriden:, currency:, exclude_next_subscriptions: true}, + search_term: + ) + + result.subscriptions + end + end +end diff --git a/app/graphql/resolvers/superset/dashboards_resolver.rb b/app/graphql/resolvers/superset/dashboards_resolver.rb new file mode 100644 index 0000000..654162b --- /dev/null +++ b/app/graphql/resolvers/superset/dashboards_resolver.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Resolvers + module Superset + class DashboardsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "analytics:view" + + graphql_name "SupersetDashboards" + description "Query all Superset dashboards with embedded configuration and guest tokens" + + type [Types::Superset::Dashboard::Object], null: false + + def resolve + result = ::Auth::SupersetService.call( + organization: current_organization, + user: nil + ) + + return result_error(result) unless result.success? + + result.dashboards + end + end + end +end diff --git a/app/graphql/resolvers/tax_resolver.rb b/app/graphql/resolvers/tax_resolver.rb new file mode 100644 index 0000000..a98109a --- /dev/null +++ b/app/graphql/resolvers/tax_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + class TaxResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query a single tax of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the tax" + + type Types::Taxes::Object, null: true + + def resolve(id: nil) + current_organization.taxes.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "tax") + end + end +end diff --git a/app/graphql/resolvers/taxes_resolver.rb b/app/graphql/resolvers/taxes_resolver.rb new file mode 100644 index 0000000..7a1144f --- /dev/null +++ b/app/graphql/resolvers/taxes_resolver.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Resolvers + class TaxesResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query taxes of an organization" + + argument :applied_to_organization, Boolean, required: false + argument :auto_generated, Boolean, required: false + argument :limit, Integer, required: false + argument :order, String, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::Taxes::Object.collection_type, null: false + + def resolve( # rubocop:disable Metrics/ParameterLists + applied_to_organization: nil, + auto_generated: nil, + order: nil, + page: nil, + limit: nil, + search_term: nil + ) + result = ::TaxesQuery.call( + organization: current_organization, + search_term:, + order:, + pagination: { + page:, + limit: + }, + filters: { + applied_to_organization:, + auto_generated: + } + ) + + result.taxes + end + end +end diff --git a/app/graphql/resolvers/version_resolver.rb b/app/graphql/resolvers/version_resolver.rb new file mode 100644 index 0000000..25fbb7a --- /dev/null +++ b/app/graphql/resolvers/version_resolver.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Resolvers + class VersionResolver < Resolvers::BaseResolver + description "Retrieve the version of the application" + + type Types::Utils::CurrentVersion, null: false + + def resolve + LAGO_VERSION + end + end +end diff --git a/app/graphql/resolvers/wallet_resolver.rb b/app/graphql/resolvers/wallet_resolver.rb new file mode 100644 index 0000000..b425e4a --- /dev/null +++ b/app/graphql/resolvers/wallet_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + class WalletResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query a single wallet of an organization" + + argument :id, ID, required: true, description: "Uniq ID of the wallet" + + type Types::Wallets::Object, null: true + + def resolve(id:) + current_organization.wallets.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "wallet") + end + end +end diff --git a/app/graphql/resolvers/wallet_transaction_consumptions_resolver.rb b/app/graphql/resolvers/wallet_transaction_consumptions_resolver.rb new file mode 100644 index 0000000..0370551 --- /dev/null +++ b/app/graphql/resolvers/wallet_transaction_consumptions_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class WalletTransactionConsumptionsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query wallet transaction consumptions for an inbound transaction" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :wallet_transaction_id, ID, required: true, description: "Uniq ID of the inbound wallet transaction" + + type Types::WalletTransactionConsumptions::Object.collection_type, null: false + + def resolve(wallet_transaction_id:, page: nil, limit: nil) + result = WalletTransactionConsumptionsQuery.call( + organization: current_organization, + filters: { + wallet_transaction_id:, + direction: :consumptions + }, + pagination: {page:, limit:} + ) + + return result_error(result) unless result.success? + + result.wallet_transaction_consumptions + end + end +end diff --git a/app/graphql/resolvers/wallet_transaction_fundings_resolver.rb b/app/graphql/resolvers/wallet_transaction_fundings_resolver.rb new file mode 100644 index 0000000..cb0e1a0 --- /dev/null +++ b/app/graphql/resolvers/wallet_transaction_fundings_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class WalletTransactionFundingsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query wallet transaction fundings for an outbound transaction" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :wallet_transaction_id, ID, required: true, description: "Uniq ID of the outbound wallet transaction" + + type Types::WalletTransactionFundings::Object.collection_type, null: false + + def resolve(wallet_transaction_id:, page: nil, limit: nil) + result = WalletTransactionConsumptionsQuery.call( + organization: current_organization, + filters: { + wallet_transaction_id:, + direction: :fundings + }, + pagination: {page:, limit:} + ) + + return result_error(result) unless result.success? + + result.wallet_transaction_consumptions + end + end +end diff --git a/app/graphql/resolvers/wallet_transaction_resolver.rb b/app/graphql/resolvers/wallet_transaction_resolver.rb new file mode 100644 index 0000000..2746f75 --- /dev/null +++ b/app/graphql/resolvers/wallet_transaction_resolver.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Resolvers + class WalletTransactionResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query a single wallet transaction" + + argument :id, ID, required: true, description: "Unique ID of the wallet transaction" + + type Types::WalletTransactions::Object, null: true + + def resolve(id:) + current_organization.wallet_transactions.includes(:invoice).find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "wallet_transaction") + end + end +end diff --git a/app/graphql/resolvers/wallet_transactions_resolver.rb b/app/graphql/resolvers/wallet_transactions_resolver.rb new file mode 100644 index 0000000..59233bc --- /dev/null +++ b/app/graphql/resolvers/wallet_transactions_resolver.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Resolvers + class WalletTransactionsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query wallet transactions" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :status, Types::WalletTransactions::StatusEnum, required: false + argument :transaction_type, Types::WalletTransactions::TransactionTypeEnum, required: false + argument :wallet_id, ID, required: true, description: "Uniq ID of the wallet" + + type Types::WalletTransactions::Object.collection_type, null: false + + def resolve( + wallet_id: nil, + page: nil, + limit: nil, + status: nil, + transaction_type: nil + ) + result = WalletTransactionsQuery.call( + organization: current_organization, + wallet_id:, + pagination: { + page:, + limit: + }, + filters: { + status:, + transaction_type: + } + ) + + return result_error(result) unless result.success? + + result.wallet_transactions + end + end +end diff --git a/app/graphql/resolvers/wallets/alert_resolver.rb b/app/graphql/resolvers/wallets/alert_resolver.rb new file mode 100644 index 0000000..ee8967d --- /dev/null +++ b/app/graphql/resolvers/wallets/alert_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + module Wallets + class AlertResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:update" + + description "Query a single wallet alert" + + argument :id, ID, required: true, description: "Unique ID of the alert" + + type Types::UsageMonitoring::Alerts::Object, null: true + + def resolve(id:) + current_organization.alerts.using_wallet.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "alert") + end + end + end +end diff --git a/app/graphql/resolvers/wallets/alerts_resolver.rb b/app/graphql/resolvers/wallets/alerts_resolver.rb new file mode 100644 index 0000000..ef09e47 --- /dev/null +++ b/app/graphql/resolvers/wallets/alerts_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Resolvers + module Wallets + class AlertsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "wallets:update" + + description "Query alerts of a wallet" + + argument :wallet_id, String, required: true, description: "Id of a wallet" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + + type Types::UsageMonitoring::Alerts::Object.collection_type, null: false + + def resolve(wallet_id:, limit: nil, page: nil) + ::UsageMonitoring::AlertsQuery.call( + organization: current_organization, + filters: { + wallet_id: + }, + pagination: { + page:, + limit: + } + ).alerts + end + end + end +end diff --git a/app/graphql/resolvers/wallets_resolver.rb b/app/graphql/resolvers/wallets_resolver.rb new file mode 100644 index 0000000..622ac0a --- /dev/null +++ b/app/graphql/resolvers/wallets_resolver.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Resolvers + class WalletsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + description "Query wallets" + + argument :customer_id, ID, required: true, description: "Uniq ID of the customer" + argument :ids, [ID], required: false, description: "List of wallet IDs to fetch" + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :status, Types::Wallets::StatusEnum, required: false + + type Types::Wallets::Object.collection_type(metadata_type: Types::Wallets::Metadata), null: false + + def resolve(customer_id: nil, ids: nil, page: nil, limit: nil, status: nil) + current_customer = current_organization.customers.find(customer_id) + + wallets = current_customer + .wallets + .page(page) + .per(limit) + + wallets = wallets.where(status:) if status.present? + wallets = wallets.where(id: ids) if ids.present? + + wallets.order(status: :asc, created_at: :desc) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "customer") + end + end +end diff --git a/app/graphql/resolvers/webhook_endpoint_resolver.rb b/app/graphql/resolvers/webhook_endpoint_resolver.rb new file mode 100644 index 0000000..3703147 --- /dev/null +++ b/app/graphql/resolvers/webhook_endpoint_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class WebhookEndpointResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + description "Query a single webhook endpoint" + + argument :id, ID, required: true, description: "Uniq ID of the webhook endpoint" + + type Types::WebhookEndpoints::Object, null: true + + def resolve(id:) + current_organization.webhook_endpoints.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "webhook_endpoint") + end + end +end diff --git a/app/graphql/resolvers/webhook_endpoints_resolver.rb b/app/graphql/resolvers/webhook_endpoints_resolver.rb new file mode 100644 index 0000000..1347bbc --- /dev/null +++ b/app/graphql/resolvers/webhook_endpoints_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + class WebhookEndpointsResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + description "Query webhook endpoints of an organization" + + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + + type Types::WebhookEndpoints::Object.collection_type, null: false + + def resolve(ids: nil, page: nil, limit: nil, search_term: nil) + result = ::WebhookEndpointsQuery.call( + organization: current_organization, + search_term:, + pagination: { + page:, + limit: + } + ) + + result.webhook_endpoints + end + end +end diff --git a/app/graphql/resolvers/webhook_resolver.rb b/app/graphql/resolvers/webhook_resolver.rb new file mode 100644 index 0000000..4e8b7ff --- /dev/null +++ b/app/graphql/resolvers/webhook_resolver.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Resolvers + class WebhookResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + description "Query a webhook" + + argument :id, ID, required: true + + type Types::Webhooks::Object, null: true + + def resolve(id:) + current_organization.webhooks.find(id) + rescue ActiveRecord::RecordNotFound + not_found_error(resource: "webhook") + end + end +end diff --git a/app/graphql/resolvers/webhooks_resolver.rb b/app/graphql/resolvers/webhooks_resolver.rb new file mode 100644 index 0000000..f512f90 --- /dev/null +++ b/app/graphql/resolvers/webhooks_resolver.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Resolvers + class WebhooksResolver < Resolvers::BaseResolver + include AuthenticableApiUser + include RequiredOrganization + + REQUIRED_PERMISSION = "developers:manage" + + description "Query Webhooks" + + argument :event_types, [String], required: false + argument :from_date, GraphQL::Types::ISO8601DateTime, required: false + argument :http_statuses, [String], required: false + argument :limit, Integer, required: false + argument :page, Integer, required: false + argument :search_term, String, required: false + argument :status, Types::Webhooks::StatusEnum, required: false # TODO: remove :status after migrating to :statuses + argument :statuses, [Types::Webhooks::StatusEnum], required: false + argument :to_date, GraphQL::Types::ISO8601DateTime, required: false + argument :webhook_endpoint_id, String, required: true + + type Types::Webhooks::Object.collection_type, null: false + + def resolve(webhook_endpoint_id:, page: nil, limit: nil, search_term: nil, status: nil, statuses: nil, event_types: nil, http_statuses: nil, from_date: nil, to_date: nil) + # TODO: remove :status after migrating to :statuses + statuses_filter = statuses || (status.present? ? [status] : nil) + + result = WebhooksQuery.call( + organization: current_organization, + search_term:, + filters: { + webhook_endpoint_id:, + statuses: statuses_filter, + event_types:, + http_statuses:, + from_date:, + to_date: + }, + pagination: { + page:, + limit: + } + ) + + result.webhooks + end + end +end diff --git a/app/graphql/sources/active_record_association.rb b/app/graphql/sources/active_record_association.rb new file mode 100644 index 0000000..2ec1f50 --- /dev/null +++ b/app/graphql/sources/active_record_association.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# This is a reusable, model-agnostic loader for ActiveRecord associations. +# +# Prevents N+1 queries when fetching associated records across multiple objects +# in a single GraphQL query (e.g., `quotes { versions { ... } }`). +# +# Collects all requested objects and preloads the specified association +# in a single query, then maps the results back to the original objects, +# ensuring each object receives its associated records. +# +# Usage in GraphQL types: +# dataloader.with(Sources::ActiveRecordAssociation, :versions).load(object) + +module Sources + class ActiveRecordAssociation < GraphQL::Dataloader::Source + def initialize(association_name) + @association_name = association_name + end + + def fetch(records) + ::ActiveRecord::Associations::Preloader.new( + records: records, + associations: @association_name + ).call + + records.map { |record| record.public_send(@association_name) } + end + end +end diff --git a/app/graphql/sources/memberships_for_role.rb b/app/graphql/sources/memberships_for_role.rb new file mode 100644 index 0000000..3fd2dc5 --- /dev/null +++ b/app/graphql/sources/memberships_for_role.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Sources + # GraphQL DataLoader source for batch loading memberships by role IDs. + # + # Prevents N+1 queries when fetching memberships for multiple roles + # in a single GraphQL query (e.g., `roles { memberships { ... } }`). + # + # Loads all membership_roles for the given role IDs in a single query, + # then groups them by role_id to return the correct memberships for each role. + # + # Usage in GraphQL types: + # dataloader.with(Sources::MembershipsForRole, current_organization).load(role.id) + # + class MembershipsForRole < GraphQL::Dataloader::Source + def initialize(organization) + @organization = organization + end + + def fetch(role_ids) + membership_roles = MembershipRole + .joins(:membership) + .includes(:membership) + .where(role_id: role_ids, organization: @organization) + .merge(Membership.active) + + memberships_by_role = membership_roles.group_by(&:role_id).transform_values do |mrs| + mrs.map(&:membership) + end + + role_ids.map { |role_id| memberships_by_role[role_id].to_a } + end + end +end diff --git a/app/graphql/types/activity_logs/activity_source_enum.rb b/app/graphql/types/activity_logs/activity_source_enum.rb new file mode 100644 index 0000000..07a5fdd --- /dev/null +++ b/app/graphql/types/activity_logs/activity_source_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ActivityLogs + class ActivitySourceEnum < Types::BaseEnum + description "Activity Logs source enums" + + [:api, :front, :system].each do |source| + value source + end + end + end +end diff --git a/app/graphql/types/activity_logs/activity_type_enum.rb b/app/graphql/types/activity_logs/activity_type_enum.rb new file mode 100644 index 0000000..e4ce5fb --- /dev/null +++ b/app/graphql/types/activity_logs/activity_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ActivityLogs + class ActivityTypeEnum < Types::BaseEnum + description "Activity Logs type enums" + + Clickhouse::ActivityLog::ACTIVITY_TYPES.each do |key, value| + value key, value:, description: value + end + end + end +end diff --git a/app/graphql/types/activity_logs/object.rb b/app/graphql/types/activity_logs/object.rb new file mode 100644 index 0000000..1aa4d32 --- /dev/null +++ b/app/graphql/types/activity_logs/object.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Types + module ActivityLogs + class Object < Types::BaseObject + graphql_name "ActivityLog" + description "Base activity log" + + field :activity_id, ID, null: false + field :activity_object, GraphQL::Types::JSON + field :activity_object_changes, GraphQL::Types::JSON + field :activity_source, Types::ActivityLogs::ActivitySourceEnum, null: false + field :activity_type, Types::ActivityLogs::ActivityTypeEnum, null: false + field :api_key, Types::ApiKeys::SanitizedObject + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :external_customer_id, String + field :external_subscription_id, String + field :logged_at, GraphQL::Types::ISO8601DateTime, null: false + field :organization, Types::Organizations::OrganizationType + field :resource, Types::ActivityLogs::ResourceObject + field :user_email, String + + def user_email + object.user&.email + end + + # TODO: remove this once we have a proper way to handle JSON in Clickhouse + # https://github.com/PNixx/clickhouse-activerecord/pull/192 + def activity_object + object.activity_object.transform_values do |value| + parsed = value.is_a?(String) ? JSON.parse(value) : value + (parsed.is_a?(Array) || parsed.is_a?(Hash)) ? parsed : value + rescue JSON::ParserError + value + end + end + + # TODO: remove this once we have a proper way to handle JSON in Clickhouse + # https://github.com/PNixx/clickhouse-activerecord/pull/192 + def activity_object_changes + object.activity_object_changes.transform_values { |v| JSON.parse(v) } + end + end + end +end diff --git a/app/graphql/types/activity_logs/resource_object.rb b/app/graphql/types/activity_logs/resource_object.rb new file mode 100644 index 0000000..521a51c --- /dev/null +++ b/app/graphql/types/activity_logs/resource_object.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Types + module ActivityLogs + class ResourceObject < Types::BaseUnion + graphql_name "ActivityLogResourceObject" + + description "Activity log resource" + + possible_types Types::BillableMetrics::Object, + Types::Plans::Object, + Types::Customers::Object, + Types::Invoices::Object, + Types::CreditNotes::Object, + Types::BillingEntities::Object, + Types::Subscriptions::Object, + Types::Wallets::Object, + Types::Coupons::Object, + Types::PaymentRequests::Object, + Types::PaymentReceipts::Object, + Types::Entitlement::FeatureObject + + def self.resolve_type(object, _context) + case object.class.to_s + when "BillableMetric" + Types::BillableMetrics::Object + when "Plan" + Types::Plans::Object + when "Customer" + Types::Customers::Object + when "Invoice" + Types::Invoices::Object + when "CreditNote" + Types::CreditNotes::Object + when "BillingEntity" + Types::BillingEntities::Object + when "Subscription" + Types::Subscriptions::Object + when "Wallet" + Types::Wallets::Object + when "Coupon" + Types::Coupons::Object + when "PaymentRequest" + Types::PaymentRequests::Object + when "PaymentReceipt" + Types::PaymentReceipts::Object + when "Entitlement::Feature" + Types::Entitlement::FeatureObject + else + raise "Unexpected activity log resource type: #{object.inspect}" + end + end + end + end +end diff --git a/app/graphql/types/activity_logs/resource_type_enum.rb b/app/graphql/types/activity_logs/resource_type_enum.rb new file mode 100644 index 0000000..1cc06fb --- /dev/null +++ b/app/graphql/types/activity_logs/resource_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ActivityLogs + class ResourceTypeEnum < Types::BaseEnum + description "Activity Logs resource type enums" + + Clickhouse::ActivityLog::RESOURCE_TYPES.each do |key, value| + value key, value:, description: value + end + end + end +end diff --git a/app/graphql/types/add_ons/create_input.rb b/app/graphql/types/add_ons/create_input.rb new file mode 100644 index 0000000..83cb4ee --- /dev/null +++ b/app/graphql/types/add_ons/create_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module AddOns + class CreateInput < Types::BaseInputObject + graphql_name "CreateAddOnInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :amount_currency, Types::CurrencyEnum, required: true + argument :code, String, required: true + argument :description, String, required: false + argument :invoice_display_name, String, required: false + argument :name, String, required: true + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/add_ons/object.rb b/app/graphql/types/add_ons/object.rb new file mode 100644 index 0000000..598a804 --- /dev/null +++ b/app/graphql/types/add_ons/object.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Types + module AddOns + class Object < Types::BaseObject + graphql_name "AddOn" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType + + field :code, String, null: false + field :description, String, null: true + field :invoice_display_name, String, null: true + field :name, String, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :applied_add_ons_count, Integer, null: false + field :customers_count, Integer, null: false, description: "Number of customers using this add-on" + + field :taxes, [Types::Taxes::Object] + + field :integration_mappings, [Types::IntegrationMappings::Object], null: true do + argument :integration_id, ID, required: false + end + + def customers_count + object.applied_add_ons.select(:customer_id).distinct.count + end + + def applied_add_ons_count + object.applied_add_ons.count + end + + def integration_mappings(integration_id: nil) + mappings = object.integration_mappings + mappings = mappings.where(integration_id:) if integration_id + mappings + end + end + end +end diff --git a/app/graphql/types/add_ons/update_input.rb b/app/graphql/types/add_ons/update_input.rb new file mode 100644 index 0000000..2391b0a --- /dev/null +++ b/app/graphql/types/add_ons/update_input.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module AddOns + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateAddOnInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :amount_currency, Types::CurrencyEnum, required: true + argument :code, String, required: true + argument :description, String, required: false + argument :id, ID, required: true + argument :invoice_display_name, String, required: false + argument :name, String, required: true + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/adjusted_fees/adjusted_fee_type_enum.rb b/app/graphql/types/adjusted_fees/adjusted_fee_type_enum.rb new file mode 100644 index 0000000..7d99249 --- /dev/null +++ b/app/graphql/types/adjusted_fees/adjusted_fee_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module AdjustedFees + class AdjustedFeeTypeEnum < Types::BaseEnum + AdjustedFee::ADJUSTED_FEE_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/adjusted_fees/create_input.rb b/app/graphql/types/adjusted_fees/create_input.rb new file mode 100644 index 0000000..109bf81 --- /dev/null +++ b/app/graphql/types/adjusted_fees/create_input.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module AdjustedFees + class CreateInput < Types::BaseInputObject + description "Create Adjusted Fee Input" + + argument :invoice_id, ID, required: true + + # NOTE: adjust an existing fee + argument :fee_id, ID, required: false + + # NOTE: adjust a empty charge or fixed charge fee + argument :charge_filter_id, ID, required: false + argument :charge_id, ID, required: false + argument :fixed_charge_id, ID, required: false + argument :subscription_id, ID, required: false + + argument :invoice_display_name, String, required: false + argument :invoice_subscription_id, ID, required: false + argument :unit_precise_amount, String, required: false + argument :units, GraphQL::Types::Float, required: false + end + end +end diff --git a/app/graphql/types/ai_conversations/message.rb b/app/graphql/types/ai_conversations/message.rb new file mode 100644 index 0000000..cdb802e --- /dev/null +++ b/app/graphql/types/ai_conversations/message.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module AiConversations + class Message < Types::BaseObject + graphql_name "AiConversationMessage" + + field :content, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :type, String, null: false + end + end +end diff --git a/app/graphql/types/ai_conversations/object.rb b/app/graphql/types/ai_conversations/object.rb new file mode 100644 index 0000000..7273423 --- /dev/null +++ b/app/graphql/types/ai_conversations/object.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module AiConversations + class Object < Types::BaseObject + graphql_name "AiConversation" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType, null: false + + field :mistral_conversation_id, String + field :name, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/ai_conversations/object_with_messages.rb b/app/graphql/types/ai_conversations/object_with_messages.rb new file mode 100644 index 0000000..3efeb5d --- /dev/null +++ b/app/graphql/types/ai_conversations/object_with_messages.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module AiConversations + class ObjectWithMessages < Types::AiConversations::Object + graphql_name "AiConversationWithMessages" + + field :messages, + [Types::AiConversations::Message], + null: false, + description: "Messages belonging to this conversation" + end + end +end diff --git a/app/graphql/types/ai_conversations/stream.rb b/app/graphql/types/ai_conversations/stream.rb new file mode 100644 index 0000000..5d45e1f --- /dev/null +++ b/app/graphql/types/ai_conversations/stream.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module AiConversations + class Stream < Types::BaseObject + graphql_name "AiConversationStream" + + field :chunk, String + field :done, Boolean, null: false + end + end +end diff --git a/app/graphql/types/analytics/gross_revenues/object.rb b/app/graphql/types/analytics/gross_revenues/object.rb new file mode 100644 index 0000000..e3c9475 --- /dev/null +++ b/app/graphql/types/analytics/gross_revenues/object.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Analytics + module GrossRevenues + class Object < Types::BaseObject + graphql_name "GrossRevenue" + + field :amount_cents, GraphQL::Types::BigInt, null: true + field :currency, Types::CurrencyEnum, null: true + field :invoices_count, GraphQL::Types::BigInt, null: false + field :month, GraphQL::Types::ISO8601DateTime, null: false + end + end + end +end diff --git a/app/graphql/types/analytics/invoice_collections/object.rb b/app/graphql/types/analytics/invoice_collections/object.rb new file mode 100644 index 0000000..aa7921d --- /dev/null +++ b/app/graphql/types/analytics/invoice_collections/object.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Analytics + module InvoiceCollections + class Object < Types::BaseObject + graphql_name "FinalizedInvoiceCollection" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :currency, Types::CurrencyEnum, null: true + field :invoices_count, GraphQL::Types::BigInt, null: false + field :month, GraphQL::Types::ISO8601DateTime, null: false + field :payment_status, Types::Invoices::PaymentStatusTypeEnum, null: true + end + end + end +end diff --git a/app/graphql/types/analytics/invoiced_usages/object.rb b/app/graphql/types/analytics/invoiced_usages/object.rb new file mode 100644 index 0000000..f1989f6 --- /dev/null +++ b/app/graphql/types/analytics/invoiced_usages/object.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Analytics + module InvoicedUsages + class Object < Types::BaseObject + graphql_name "InvoicedUsage" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :code, String, null: true + field :currency, Types::CurrencyEnum, null: false + field :month, GraphQL::Types::ISO8601DateTime, null: false + end + end + end +end diff --git a/app/graphql/types/analytics/mrrs/object.rb b/app/graphql/types/analytics/mrrs/object.rb new file mode 100644 index 0000000..fcb490a --- /dev/null +++ b/app/graphql/types/analytics/mrrs/object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Analytics + module Mrrs + class Object < Types::BaseObject + graphql_name "Mrr" + + field :amount_cents, GraphQL::Types::BigInt, null: true + field :currency, Types::CurrencyEnum, null: true + field :month, GraphQL::Types::ISO8601DateTime, null: false + end + end + end +end diff --git a/app/graphql/types/analytics/overdue_balances/object.rb b/app/graphql/types/analytics/overdue_balances/object.rb new file mode 100644 index 0000000..a9d02f3 --- /dev/null +++ b/app/graphql/types/analytics/overdue_balances/object.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Analytics + module OverdueBalances + class Object < Types::BaseObject + graphql_name "OverdueBalance" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :currency, Types::CurrencyEnum, null: false + field :lago_invoice_ids, [String], null: false + field :month, GraphQL::Types::ISO8601DateTime, null: false + + def lago_invoice_ids + JSON.parse(object["lago_invoice_ids"]).flatten + end + end + end + end +end diff --git a/app/graphql/types/api_keys/object.rb b/app/graphql/types/api_keys/object.rb new file mode 100644 index 0000000..508cb47 --- /dev/null +++ b/app/graphql/types/api_keys/object.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module ApiKeys + class Object < Types::BaseObject + graphql_name "ApiKey" + + field :id, ID, null: false + field :name, String, null: true + field :permissions, GraphQL::Types::JSON, null: false + field :value, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :expires_at, GraphQL::Types::ISO8601DateTime, null: true + field :last_used_at, GraphQL::Types::ISO8601DateTime, null: true + end + end +end diff --git a/app/graphql/types/api_keys/rotate_input.rb b/app/graphql/types/api_keys/rotate_input.rb new file mode 100644 index 0000000..c5fc4de --- /dev/null +++ b/app/graphql/types/api_keys/rotate_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ApiKeys + class RotateInput < Types::BaseInputObject + graphql_name "RotateApiKeyInput" + + argument :expires_at, GraphQL::Types::ISO8601DateTime, required: false + argument :id, ID, required: true + argument :name, String, required: false + end + end +end diff --git a/app/graphql/types/api_keys/sanitized_object.rb b/app/graphql/types/api_keys/sanitized_object.rb new file mode 100644 index 0000000..9d5424e --- /dev/null +++ b/app/graphql/types/api_keys/sanitized_object.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ApiKeys + class SanitizedObject < Object + graphql_name "SanitizedApiKey" + + def value + "••••••••" + object.value.last(3) + end + end + end +end diff --git a/app/graphql/types/api_keys/update_input.rb b/app/graphql/types/api_keys/update_input.rb new file mode 100644 index 0000000..045be1e --- /dev/null +++ b/app/graphql/types/api_keys/update_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ApiKeys + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateApiKeyInput" + + argument :id, ID, required: true + argument :name, String, required: false + argument :permissions, GraphQL::Types::JSON, required: false + end + end +end diff --git a/app/graphql/types/api_logs/http_method_enum.rb b/app/graphql/types/api_logs/http_method_enum.rb new file mode 100644 index 0000000..3a1c75f --- /dev/null +++ b/app/graphql/types/api_logs/http_method_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ApiLogs + class HttpMethodEnum < Types::BaseEnum + description "Api Logs http method enums" + + Clickhouse::ApiLog::HTTP_METHODS.keys.without(:get).each do |key| + value key + end + end + end +end diff --git a/app/graphql/types/api_logs/http_status.rb b/app/graphql/types/api_logs/http_status.rb new file mode 100644 index 0000000..b7ef363 --- /dev/null +++ b/app/graphql/types/api_logs/http_status.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module ApiLogs + class HttpStatus < Types::BaseScalar + description "Api Logs HTTP status" + + def self.coerce_input(input_value, _context) + Integer(input_value) + rescue ArgumentError, TypeError + input_value.to_s + end + + def self.coerce_result(result_value, _context) + result_value + end + end + end +end diff --git a/app/graphql/types/api_logs/object.rb b/app/graphql/types/api_logs/object.rb new file mode 100644 index 0000000..72b2117 --- /dev/null +++ b/app/graphql/types/api_logs/object.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Types + module ApiLogs + class Object < Types::BaseObject + graphql_name "ApiLog" + description "Base api log" + + field :api_key, Types::ApiKeys::SanitizedObject + field :api_version, String + field :client, String + field :http_method, Types::ApiLogs::HttpMethodEnum, null: false + field :http_status, Integer, null: false + field :request_body, GraphQL::Types::JSON + field :request_id, ID, null: false + field :request_origin, String + field :request_path, String + field :request_response, GraphQL::Types::JSON, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :logged_at, GraphQL::Types::ISO8601DateTime, null: false + + # TODO: remove this once we have a proper way to handle JSON in Clickhouse + # https://github.com/PNixx/clickhouse-activerecord/pull/192 + def request_body + deep_json_parser(object.request_body) + end + + def request_response + deep_json_parser(object.request_response) + end + + def deep_json_parser(attribute) + attribute.transform_values do |value| + parsed = value.is_a?(String) ? JSON.parse(value) : value + (parsed.is_a?(Array) || parsed.is_a?(Hash)) ? parsed : value + rescue JSON::ParserError + value + end + end + end + end +end diff --git a/app/graphql/types/applied_add_ons/object.rb b/app/graphql/types/applied_add_ons/object.rb new file mode 100644 index 0000000..98a3267 --- /dev/null +++ b/app/graphql/types/applied_add_ons/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module AppliedAddOns + class Object < Types::BaseObject + graphql_name "AppliedAddOn" + + field :add_on, Types::AddOns::Object, null: false + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + + def add_on + AddOn.with_discarded.find(object.add_on_id) + end + end + end +end diff --git a/app/graphql/types/applied_coupons/object.rb b/app/graphql/types/applied_coupons/object.rb new file mode 100644 index 0000000..4955c1f --- /dev/null +++ b/app/graphql/types/applied_coupons/object.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Types + module AppliedCoupons + class Object < Types::BaseObject + graphql_name "AppliedCoupon" + + field :coupon, Types::Coupons::Object, null: false + field :customer, Types::Customers::Object, null: false + field :id, ID, null: false + field :status, Types::AppliedCoupons::StatusEnum, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: true + field :amount_currency, Types::CurrencyEnum, null: true + + field :amount_cents_remaining, GraphQL::Types::BigInt, null: true + field :frequency, Types::Coupons::FrequencyEnum, null: false + field :frequency_duration, Integer, null: true + field :frequency_duration_remaining, Integer, null: true + field :percentage_rate, Float, null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :terminated_at, GraphQL::Types::ISO8601DateTime, null: true + + def amount_cents_remaining + return nil if object.recurring? + return nil if object.coupon.percentage? + + object.amount_cents - object.credits.active.sum(:amount_cents) + end + end + end +end diff --git a/app/graphql/types/applied_coupons/status_enum.rb b/app/graphql/types/applied_coupons/status_enum.rb new file mode 100644 index 0000000..27be6ac --- /dev/null +++ b/app/graphql/types/applied_coupons/status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module AppliedCoupons + class StatusEnum < Types::BaseEnum + graphql_name "AppliedCouponStatusEnum" + + AppliedCoupon::STATUSES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/applied_pricing_units/input.rb b/app/graphql/types/applied_pricing_units/input.rb new file mode 100644 index 0000000..89159aa --- /dev/null +++ b/app/graphql/types/applied_pricing_units/input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module AppliedPricingUnits + class Input < Types::BaseInputObject + graphql_name "AppliedPricingUnitInput" + + argument :code, String, required: true + argument :conversion_rate, GraphQL::Types::Float, required: true + end + end +end diff --git a/app/graphql/types/applied_pricing_units/object.rb b/app/graphql/types/applied_pricing_units/object.rb new file mode 100644 index 0000000..de003b8 --- /dev/null +++ b/app/graphql/types/applied_pricing_units/object.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module AppliedPricingUnits + class Object < Types::BaseObject + graphql_name "AppliedPricingUnit" + + field :id, ID, null: false + + field :conversion_rate, GraphQL::Types::Float, null: false + field :pricing_unit, Types::PricingUnits::Object, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/applied_pricing_units/override_input.rb b/app/graphql/types/applied_pricing_units/override_input.rb new file mode 100644 index 0000000..05f1ff5 --- /dev/null +++ b/app/graphql/types/applied_pricing_units/override_input.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module AppliedPricingUnits + class OverrideInput < Types::BaseInputObject + graphql_name "AppliedPricingUnitOverrideInput" + + argument :conversion_rate, GraphQL::Types::Float, required: true + end + end +end diff --git a/app/graphql/types/auth/google/auth_url.rb b/app/graphql/types/auth/google/auth_url.rb new file mode 100644 index 0000000..0d189a3 --- /dev/null +++ b/app/graphql/types/auth/google/auth_url.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Auth + module Google + class AuthUrl < Types::BaseObject + field :url, String, null: false + end + end + end +end diff --git a/app/graphql/types/auth/okta/accept_invite_input.rb b/app/graphql/types/auth/okta/accept_invite_input.rb new file mode 100644 index 0000000..b201d00 --- /dev/null +++ b/app/graphql/types/auth/okta/accept_invite_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Auth + module Okta + class AcceptInviteInput < BaseInputObject + description "Accept Invite with Okta Oauth input arguments" + + argument :code, String, required: true + argument :invite_token, String, required: true + argument :state, String, required: true + end + end + end +end diff --git a/app/graphql/types/auth/okta/authorize.rb b/app/graphql/types/auth/okta/authorize.rb new file mode 100644 index 0000000..bed99bf --- /dev/null +++ b/app/graphql/types/auth/okta/authorize.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Auth + module Okta + class Authorize < Types::BaseObject + field :url, String, null: false + end + end + end +end diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb new file mode 100644 index 0000000..5a97774 --- /dev/null +++ b/app/graphql/types/base_argument.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + class BaseArgument < GraphQL::Schema::Argument + attr_reader :permissions + + def initialize(*, permission: nil, permissions: nil, **, &) + @permissions = if permission + [permission].compact + elsif permissions + Array.wrap(permissions).compact + end + + super(*, **, &) + end + end +end diff --git a/app/graphql/types/base_connection.rb b/app/graphql/types/base_connection.rb new file mode 100644 index 0000000..366c69e --- /dev/null +++ b/app/graphql/types/base_connection.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseConnection < Types::BaseObject + # add `nodes` and `pageInfo` fields, as well as `edge_type(...)` and `node_nullable(...)` overrides + include GraphQL::Types::Relay::ConnectionBehaviors + end +end diff --git a/app/graphql/types/base_edge.rb b/app/graphql/types/base_edge.rb new file mode 100644 index 0000000..e0d2f79 --- /dev/null +++ b/app/graphql/types/base_edge.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Types + class BaseEdge < Types::BaseObject + # add `node` and `cursor` fields, as well as `node_type(...)` override + include GraphQL::Types::Relay::EdgeBehaviors + end +end diff --git a/app/graphql/types/base_enum.rb b/app/graphql/types/base_enum.rb new file mode 100644 index 0000000..cf43fea --- /dev/null +++ b/app/graphql/types/base_enum.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseEnum < GraphQL::Schema::Enum + end +end diff --git a/app/graphql/types/base_field.rb b/app/graphql/types/base_field.rb new file mode 100644 index 0000000..bb4af46 --- /dev/null +++ b/app/graphql/types/base_field.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + class BaseField < GraphQL::Schema::Field + argument_class Types::BaseArgument + + attr_reader :permissions + + def initialize(*, permission: nil, permissions: nil, **kwargs, &) + if permission + @permissions = [permission.to_s] + elsif permissions + @permissions = Array.wrap(permissions).map(&:to_s) + end + + kwargs[:null] = true if @permissions + + super(*, **kwargs, &) + + extension(Extensions::FieldAuthorizationExtension) if @permissions + end + end +end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb new file mode 100644 index 0000000..74482b5 --- /dev/null +++ b/app/graphql/types/base_input_object.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + class BaseInputObject < GraphQL::Schema::InputObject + argument_class Types::BaseArgument + + # NOTE: This is how you can remove fields from the input object based on permissions + def initialize(arguments, ruby_kwargs:, context:, defaults_used:) + cleaned_arguments = arguments.argument_values.dup + cleaned_kwargs = ruby_kwargs.dup + + self.class.arguments(context).each_value do |arg_defn| + next if arg_defn.try(:permissions).blank? + + if arg_defn.permissions.none? { |p| context.dig(:permissions, p) } + cleaned_arguments.delete(arg_defn.keyword) + cleaned_kwargs.delete(arg_defn.keyword) + end + end + + super(cleaned_arguments, ruby_kwargs: cleaned_kwargs, context:, defaults_used:) + end + end +end diff --git a/app/graphql/types/base_interface.rb b/app/graphql/types/base_interface.rb new file mode 100644 index 0000000..3844aed --- /dev/null +++ b/app/graphql/types/base_interface.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module BaseInterface + include GraphQL::Schema::Interface + + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + + field_class Types::BaseField + end +end diff --git a/app/graphql/types/base_object.rb b/app/graphql/types/base_object.rb new file mode 100644 index 0000000..7a9ac78 --- /dev/null +++ b/app/graphql/types/base_object.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + class BaseObject < GraphQL::Schema::Object + edge_type_class(Types::BaseEdge) + connection_type_class(Types::BaseConnection) + field_class Types::BaseField + + # Defines a field method that batches the named ActiveRecord associations + # through `Sources::ActiveRecordAssociation`, preventing N+1 queries when + # the same association is requested for several parent records in a single + # GraphQL query. + # + # Usage: + # dataload_association :customer, :organization, :subscription + def self.dataload_association(*names) + names.each do |name| + define_method(name) do + dataloader.with(Sources::ActiveRecordAssociation, name).load(object) + end + end + end + end +end diff --git a/app/graphql/types/base_scalar.rb b/app/graphql/types/base_scalar.rb new file mode 100644 index 0000000..719bc80 --- /dev/null +++ b/app/graphql/types/base_scalar.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseScalar < GraphQL::Schema::Scalar + end +end diff --git a/app/graphql/types/base_subscription.rb b/app/graphql/types/base_subscription.rb new file mode 100644 index 0000000..d51293d --- /dev/null +++ b/app/graphql/types/base_subscription.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +module Types + class BaseSubscription < GraphQL::Schema::Subscription + end +end diff --git a/app/graphql/types/base_union.rb b/app/graphql/types/base_union.rb new file mode 100644 index 0000000..ccf5f83 --- /dev/null +++ b/app/graphql/types/base_union.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class BaseUnion < GraphQL::Schema::Union + extend GraphqlPagination::CollectionType + end +end diff --git a/app/graphql/types/billable_metric_filters/input.rb b/app/graphql/types/billable_metric_filters/input.rb new file mode 100644 index 0000000..4c1b61d --- /dev/null +++ b/app/graphql/types/billable_metric_filters/input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module BillableMetricFilters + class Input < BaseInputObject + graphql_name "BillableMetricFiltersInput" + description "Billable metric filters input arguments" + + argument :key, String, required: true + argument :values, [String], required: true + end + end +end diff --git a/app/graphql/types/billable_metric_filters/object.rb b/app/graphql/types/billable_metric_filters/object.rb new file mode 100644 index 0000000..5d45046 --- /dev/null +++ b/app/graphql/types/billable_metric_filters/object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module BillableMetricFilters + class Object < BaseObject + graphql_name "BillableMetricFilter" + description "Billable metric filters" + + field :id, ID, null: false + + field :key, String, null: false + field :values, [String], null: false + + def values + object.values.sort + end + end + end +end diff --git a/app/graphql/types/billable_metrics/aggregation_type_enum.rb b/app/graphql/types/billable_metrics/aggregation_type_enum.rb new file mode 100644 index 0000000..c5a2fe7 --- /dev/null +++ b/app/graphql/types/billable_metrics/aggregation_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BillableMetrics + class AggregationTypeEnum < Types::BaseEnum + BillableMetric::AGGREGATION_TYPES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/billable_metrics/create_input.rb b/app/graphql/types/billable_metrics/create_input.rb new file mode 100644 index 0000000..e9ca6ff --- /dev/null +++ b/app/graphql/types/billable_metrics/create_input.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module BillableMetrics + class CreateInput < BaseInputObject + description "Create Billable metric input arguments" + + argument :aggregation_type, Types::BillableMetrics::AggregationTypeEnum, required: true + argument :code, String, required: true + argument :description, String + argument :expression, String, required: false + argument :field_name, String, required: false + argument :name, String, required: true + argument :recurring, Boolean, required: false + argument :rounding_function, Types::BillableMetrics::RoundingFunctionEnum, required: false + argument :rounding_precision, Integer, required: false + argument :weighted_interval, Types::BillableMetrics::WeightedIntervalEnum, required: false + + argument :filters, [Types::BillableMetricFilters::Input], required: false + end + end +end diff --git a/app/graphql/types/billable_metrics/object.rb b/app/graphql/types/billable_metrics/object.rb new file mode 100644 index 0000000..fb1cae0 --- /dev/null +++ b/app/graphql/types/billable_metrics/object.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Types + module BillableMetrics + class Object < Types::BaseObject + graphql_name "BillableMetric" + description "Base billable metric" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType + + field :code, String, null: false + field :name, String, null: false + + field :description, String + + field :aggregation_type, Types::BillableMetrics::AggregationTypeEnum, null: false + field :expression, String, null: true + field :field_name, String, null: true + field :weighted_interval, Types::BillableMetrics::WeightedIntervalEnum, null: true + + field :filters, [Types::BillableMetricFilters::Object], null: true + + field :recurring, Boolean, null: false + + field :has_active_subscriptions, Boolean, null: false + field :has_draft_invoices, Boolean, null: false + field :has_plans, Boolean, null: false + field :has_subscriptions, Boolean, null: false + + field :rounding_function, Types::BillableMetrics::RoundingFunctionEnum, null: true + field :rounding_precision, Integer, null: true + + field :activity_logs, [Types::ActivityLogs::Object], null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :integration_mappings, [Types::IntegrationMappings::Object], null: true do + argument :integration_id, ID, required: false + end + + def has_active_subscriptions + object.attached_subscriptions.active.exists? + end + + def has_subscriptions + object.attached_subscriptions.exists? + end + + def has_draft_invoices + object.invoices.draft.exists? + end + + def has_plans + object.plans.exists? + end + + def integration_mappings(integration_id: nil) + mappings = object.integration_mappings + mappings = mappings.where(integration_id:) if integration_id + mappings + end + end + end +end diff --git a/app/graphql/types/billable_metrics/rounding_function_enum.rb b/app/graphql/types/billable_metrics/rounding_function_enum.rb new file mode 100644 index 0000000..1ef3dfa --- /dev/null +++ b/app/graphql/types/billable_metrics/rounding_function_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BillableMetrics + class RoundingFunctionEnum < Types::BaseEnum + BillableMetric::ROUNDING_FUNCTIONS.values.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/billable_metrics/update_input.rb b/app/graphql/types/billable_metrics/update_input.rb new file mode 100644 index 0000000..ba7f246 --- /dev/null +++ b/app/graphql/types/billable_metrics/update_input.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module BillableMetrics + class UpdateInput < BaseInputObject + description "Update Billable metric input arguments" + + argument :id, String, required: true + + argument :aggregation_type, Types::BillableMetrics::AggregationTypeEnum, required: true + argument :code, String, required: true + argument :description, String + argument :expression, String, required: false + argument :field_name, String, required: false + argument :name, String, required: true + argument :recurring, Boolean, required: false + argument :rounding_function, Types::BillableMetrics::RoundingFunctionEnum, required: false + argument :rounding_precision, Integer, required: false + argument :weighted_interval, Types::BillableMetrics::WeightedIntervalEnum, required: false + + argument :filters, [Types::BillableMetricFilters::Input], required: false + end + end +end diff --git a/app/graphql/types/billable_metrics/weighted_interval_enum.rb b/app/graphql/types/billable_metrics/weighted_interval_enum.rb new file mode 100644 index 0000000..73cd953 --- /dev/null +++ b/app/graphql/types/billable_metrics/weighted_interval_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module BillableMetrics + class WeightedIntervalEnum < Types::BaseEnum + BillableMetric::WEIGHTED_INTERVAL.values.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/billing_entities/billing_configuration.rb b/app/graphql/types/billing_entities/billing_configuration.rb new file mode 100644 index 0000000..aaa9505 --- /dev/null +++ b/app/graphql/types/billing_entities/billing_configuration.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class BillingConfiguration < Types::BaseObject + graphql_name "BillingEntityBillingConfiguration" + + field :document_locale, String + field :id, ID, null: false + field :invoice_footer, String + field :invoice_grace_period, Integer, null: false + field :subscription_invoice_issuing_date_adjustment, Types::BillingEntities::SubscriptionInvoiceIssuingDateAdjustmentEnum, null: false + field :subscription_invoice_issuing_date_anchor, Types::BillingEntities::SubscriptionInvoiceIssuingDateAnchorEnum, null: false + end + end +end diff --git a/app/graphql/types/billing_entities/billing_configuration_input.rb b/app/graphql/types/billing_entities/billing_configuration_input.rb new file mode 100644 index 0000000..3780342 --- /dev/null +++ b/app/graphql/types/billing_entities/billing_configuration_input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class BillingConfigurationInput < Types::BaseInputObject + graphql_name "BillingEntityBillingConfigurationInput" + + argument :document_locale, String, required: false + argument :document_numbering, Types::BillingEntities::DocumentNumberingEnum, required: false + argument :invoice_footer, String, required: false + argument :invoice_grace_period, Integer, required: false + argument :subscription_invoice_issuing_date_adjustment, Types::BillingEntities::SubscriptionInvoiceIssuingDateAdjustmentEnum, required: false + argument :subscription_invoice_issuing_date_anchor, Types::BillingEntities::SubscriptionInvoiceIssuingDateAnchorEnum, required: false + end + end +end diff --git a/app/graphql/types/billing_entities/create_input.rb b/app/graphql/types/billing_entities/create_input.rb new file mode 100644 index 0000000..f4cda2b --- /dev/null +++ b/app/graphql/types/billing_entities/create_input.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class CreateInput < BaseInputObject + description "Create Billing Entity input arguments" + + argument :code, String, required: true + argument :name, String, required: true + + argument :default_currency, Types::CurrencyEnum, required: false + argument :einvoicing, Boolean, required: false + argument :email, String, required: false + argument :legal_name, String, required: false + argument :legal_number, String, required: false + argument :logo, String, required: false + argument :tax_identification_number, String, required: false + + argument :address_line1, String, required: false + argument :address_line2, String, required: false + argument :city, String, required: false + argument :country, Types::CountryCodeEnum, required: false + argument :net_payment_term, Integer, required: false + argument :state, String, required: false + argument :zipcode, String, required: false + + argument :timezone, Types::TimezoneEnum, required: false + + argument :eu_tax_management, Boolean, required: false + + argument :document_number_prefix, String, required: false + argument :document_numbering, Types::BillingEntities::DocumentNumberingEnum, required: false + + argument :billing_configuration, Types::BillingEntities::BillingConfigurationInput, required: false, permission: "billing_entities:view" + argument :email_settings, [Types::BillingEntities::EmailSettingsEnum], required: false, permission: "billing_entities:view" + argument :finalize_zero_amount_invoice, Boolean, required: false + end + end +end diff --git a/app/graphql/types/billing_entities/document_numbering_enum.rb b/app/graphql/types/billing_entities/document_numbering_enum.rb new file mode 100644 index 0000000..3259ad5 --- /dev/null +++ b/app/graphql/types/billing_entities/document_numbering_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class DocumentNumberingEnum < Types::BaseEnum + graphql_name "BillingEntityDocumentNumberingEnum" + description "Document numbering type" + + ::BillingEntity::DOCUMENT_NUMBERINGS.keys.each do |code| + value code + end + end + end +end diff --git a/app/graphql/types/billing_entities/email_settings_enum.rb b/app/graphql/types/billing_entities/email_settings_enum.rb new file mode 100644 index 0000000..0e5fa85 --- /dev/null +++ b/app/graphql/types/billing_entities/email_settings_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class EmailSettingsEnum < Types::BaseEnum + graphql_name "BillingEntityEmailSettingsEnum" + description "BillingEntity Email Settings Values" + + ::BillingEntity::EMAIL_SETTINGS.each do |value| + value(value.tr(".", "_"), value, value:) + end + end + end +end diff --git a/app/graphql/types/billing_entities/object.rb b/app/graphql/types/billing_entities/object.rb new file mode 100644 index 0000000..c68ee3b --- /dev/null +++ b/app/graphql/types/billing_entities/object.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class Object < Types::BaseObject + graphql_name "BillingEntity" + description "Base billing entity" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType, null: false + + field :code, String, null: false + field :default_currency, Types::CurrencyEnum, null: false + field :einvoicing, Boolean, null: false + field :email, String + field :logo_url, String + field :name, String, null: false + field :timezone, Types::TimezoneEnum + + field :legal_name, String + field :legal_number, String + field :tax_identification_number, String + + field :address_line1, String + field :address_line2, String + field :city, String + field :country, Types::CountryCodeEnum + field :net_payment_term, Integer, null: false + field :state, String + field :zipcode, String + + field :document_number_prefix, String, null: false + field :document_numbering, Types::BillingEntities::DocumentNumberingEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :eu_tax_management, Boolean, null: false + + field :billing_configuration, Types::BillingEntities::BillingConfiguration, permission: "billing_entities:view" + field :email_settings, [Types::BillingEntities::EmailSettingsEnum], permission: "billing_entities:view" + field :finalize_zero_amount_invoice, Boolean, null: false + field :is_default, Boolean, null: false + + field :applied_dunning_campaign, Types::DunningCampaigns::Object + + field :selected_invoice_custom_sections, [Types::InvoiceCustomSections::Object] + + def is_default + object.organization.default_billing_entity&.id == object.id + end + + def billing_configuration + { + id: "#{object&.id}-c1nf", # Each nested object needs ID so that appollo cache system can work properly + document_locale: object&.document_locale, + invoice_footer: object&.invoice_footer, + invoice_grace_period: object&.invoice_grace_period, + subscription_invoice_issuing_date_anchor: object&.subscription_invoice_issuing_date_anchor, + subscription_invoice_issuing_date_adjustment: object&.subscription_invoice_issuing_date_adjustment + } + end + end + end +end diff --git a/app/graphql/types/billing_entities/subscription_invoice_issuing_date_adjustment_enum.rb b/app/graphql/types/billing_entities/subscription_invoice_issuing_date_adjustment_enum.rb new file mode 100644 index 0000000..6e3517b --- /dev/null +++ b/app/graphql/types/billing_entities/subscription_invoice_issuing_date_adjustment_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class SubscriptionInvoiceIssuingDateAdjustmentEnum < Types::BaseEnum + graphql_name "BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum" + description "Subscription Invoice Issuing Date Adjustment Values" + + ::BillingEntity::SUBSCRIPTION_INVOICE_ISSUING_DATE_ADJUSTMENTS.keys.each do |code| + value code + end + end + end +end diff --git a/app/graphql/types/billing_entities/subscription_invoice_issuing_date_anchor_enum.rb b/app/graphql/types/billing_entities/subscription_invoice_issuing_date_anchor_enum.rb new file mode 100644 index 0000000..e800a8d --- /dev/null +++ b/app/graphql/types/billing_entities/subscription_invoice_issuing_date_anchor_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class SubscriptionInvoiceIssuingDateAnchorEnum < Types::BaseEnum + graphql_name "BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum" + description "Subscription Invoice Issuing Date Anchor Values" + + ::BillingEntity::SUBSCRIPTION_INVOICE_ISSUING_DATE_ANCHORS.keys.each do |code| + value code + end + end + end +end diff --git a/app/graphql/types/billing_entities/update_input.rb b/app/graphql/types/billing_entities/update_input.rb new file mode 100644 index 0000000..8f8d0d0 --- /dev/null +++ b/app/graphql/types/billing_entities/update_input.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module BillingEntities + class UpdateInput < BaseInputObject + description "Update Billing Entity input arguments" + + argument :id, ID, required: true + + argument :code, String, required: false + argument :name, String, required: false + + argument :default_currency, Types::CurrencyEnum, required: false + argument :einvoicing, Boolean, required: false + argument :email, String, required: false + argument :legal_name, String, required: false + argument :legal_number, String, required: false + argument :logo, String, required: false + argument :tax_identification_number, String, required: false + + argument :address_line1, String, required: false + argument :address_line2, String, required: false + argument :city, String, required: false + argument :country, Types::CountryCodeEnum, required: false + argument :net_payment_term, Integer, required: false + argument :state, String, required: false + argument :zipcode, String, required: false + + argument :timezone, Types::TimezoneEnum, required: false + + argument :eu_tax_management, Boolean, required: false + + argument :document_number_prefix, String, required: false + argument :document_numbering, Types::BillingEntities::DocumentNumberingEnum, required: false + + argument :billing_configuration, Types::BillingEntities::BillingConfigurationInput, required: false, permission: "billing_entities:view" + argument :email_settings, [Types::BillingEntities::EmailSettingsEnum], required: false, permission: "billing_entities:view" + argument :finalize_zero_amount_invoice, Boolean, required: false + + # Invoice custom sections settings + argument :invoice_custom_section_ids, [ID], required: false + end + end +end diff --git a/app/graphql/types/charge_filters/create_input.rb b/app/graphql/types/charge_filters/create_input.rb new file mode 100644 index 0000000..5b99b54 --- /dev/null +++ b/app/graphql/types/charge_filters/create_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module ChargeFilters + class CreateInput < BaseInputObject + graphql_name "ChargeFilterCreateInput" + description "Charge filter create input arguments" + + argument :charge_id, ID, required: true + + argument :cascade_updates, Boolean, required: false + argument :invoice_display_name, String, required: false + argument :properties, Types::Charges::PropertiesInput, required: true + argument :values, Types::ChargeFilters::Values, required: true + end + end +end diff --git a/app/graphql/types/charge_filters/input.rb b/app/graphql/types/charge_filters/input.rb new file mode 100644 index 0000000..f6a5945 --- /dev/null +++ b/app/graphql/types/charge_filters/input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module ChargeFilters + class Input < BaseInputObject + graphql_name "ChargeFilterInput" + description "Charge filters input arguments" + + argument :invoice_display_name, String, required: false + argument :properties, Types::Charges::PropertiesInput, required: true + argument :values, Types::ChargeFilters::Values, required: true + end + end +end diff --git a/app/graphql/types/charge_filters/object.rb b/app/graphql/types/charge_filters/object.rb new file mode 100644 index 0000000..7d5d2f0 --- /dev/null +++ b/app/graphql/types/charge_filters/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module ChargeFilters + class Object < Types::BaseObject + graphql_name "ChargeFilter" + description "Charge filters object" + + field :charge_code, String, null: true + field :id, ID, null: false + + field :invoice_display_name, String, null: true + field :properties, Types::Charges::Properties, null: false + field :values, Types::ChargeFilters::Values, null: false, method: :to_h + + def charge_code + object.charge&.code + end + end + end +end diff --git a/app/graphql/types/charge_filters/update_input.rb b/app/graphql/types/charge_filters/update_input.rb new file mode 100644 index 0000000..5ff7984 --- /dev/null +++ b/app/graphql/types/charge_filters/update_input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module ChargeFilters + class UpdateInput < BaseInputObject + graphql_name "ChargeFilterUpdateInput" + description "Charge filter update input arguments" + + argument :id, ID, required: true + + argument :cascade_updates, Boolean, required: false + argument :invoice_display_name, String, required: false + argument :properties, Types::Charges::PropertiesInput, required: false + end + end +end diff --git a/app/graphql/types/charge_filters/values.rb b/app/graphql/types/charge_filters/values.rb new file mode 100644 index 0000000..38e4acb --- /dev/null +++ b/app/graphql/types/charge_filters/values.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module ChargeFilters + class Values < Types::BaseScalar + graphql_name "ChargeFilterValues" + + def self.coerce_input(input_value, _context) + input_value.to_h.each_with_object({}) do |(key, values), result| + result[key.to_s] = values&.map(&:to_s) || [] + end + rescue + raise GraphQL::CoercionError, "#{input_value.inspect} is not a valid hash object" + end + + def self.coerce_result(ruby_value, _context) + ruby_value.to_h + end + end + end +end diff --git a/app/graphql/types/charge_models/graduated_percentage_range.rb b/app/graphql/types/charge_models/graduated_percentage_range.rb new file mode 100644 index 0000000..c525e23 --- /dev/null +++ b/app/graphql/types/charge_models/graduated_percentage_range.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ChargeModels + class GraduatedPercentageRange < Types::BaseObject + field :from_value, Float, null: false + field :to_value, Float, null: true + + field :flat_amount, String, null: false + field :rate, String, null: false + end + end +end diff --git a/app/graphql/types/charge_models/graduated_percentage_range_input.rb b/app/graphql/types/charge_models/graduated_percentage_range_input.rb new file mode 100644 index 0000000..3f17b9d --- /dev/null +++ b/app/graphql/types/charge_models/graduated_percentage_range_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ChargeModels + class GraduatedPercentageRangeInput < Types::BaseInputObject + argument :from_value, Float, required: true + argument :to_value, Float, required: false + + argument :flat_amount, String, required: true + argument :rate, String, required: true + end + end +end diff --git a/app/graphql/types/charge_models/graduated_range.rb b/app/graphql/types/charge_models/graduated_range.rb new file mode 100644 index 0000000..131d7ad --- /dev/null +++ b/app/graphql/types/charge_models/graduated_range.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ChargeModels + class GraduatedRange < Types::BaseObject + field :from_value, Float, null: false + field :to_value, Float, null: true + + field :flat_amount, String, null: false + field :per_unit_amount, String, null: false + end + end +end diff --git a/app/graphql/types/charge_models/graduated_range_input.rb b/app/graphql/types/charge_models/graduated_range_input.rb new file mode 100644 index 0000000..6676353 --- /dev/null +++ b/app/graphql/types/charge_models/graduated_range_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ChargeModels + class GraduatedRangeInput < Types::BaseInputObject + argument :from_value, Float, required: true + argument :to_value, Float, required: false + + argument :flat_amount, String, required: true + argument :per_unit_amount, String, required: true + end + end +end diff --git a/app/graphql/types/charge_models/volume_range.rb b/app/graphql/types/charge_models/volume_range.rb new file mode 100644 index 0000000..9894825 --- /dev/null +++ b/app/graphql/types/charge_models/volume_range.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ChargeModels + class VolumeRange < Types::BaseObject + field :from_value, GraphQL::Types::BigInt, null: false + field :to_value, GraphQL::Types::BigInt, null: true + + field :flat_amount, String, null: false + field :per_unit_amount, String, null: false + end + end +end diff --git a/app/graphql/types/charge_models/volume_range_input.rb b/app/graphql/types/charge_models/volume_range_input.rb new file mode 100644 index 0000000..bea04b7 --- /dev/null +++ b/app/graphql/types/charge_models/volume_range_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module ChargeModels + class VolumeRangeInput < Types::BaseInputObject + argument :from_value, GraphQL::Types::BigInt, required: true + argument :to_value, GraphQL::Types::BigInt, required: false + + argument :flat_amount, String, required: true + argument :per_unit_amount, String, required: true + end + end +end diff --git a/app/graphql/types/charges/charge_model_enum.rb b/app/graphql/types/charges/charge_model_enum.rb new file mode 100644 index 0000000..91c26ca --- /dev/null +++ b/app/graphql/types/charges/charge_model_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Charges + class ChargeModelEnum < Types::BaseEnum + Charge::CHARGE_MODELS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/charges/create_input.rb b/app/graphql/types/charges/create_input.rb new file mode 100644 index 0000000..b607979 --- /dev/null +++ b/app/graphql/types/charges/create_input.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Charges + class CreateInput < Types::BaseInputObject + graphql_name "ChargeCreateInput" + + argument :plan_id, ID, required: true + + argument :billable_metric_id, ID, required: true + argument :charge_model, Types::Charges::ChargeModelEnum, required: true + argument :code, String, required: false + argument :invoice_display_name, String, required: false + argument :invoiceable, Boolean, required: false + argument :min_amount_cents, GraphQL::Types::BigInt, required: false + argument :pay_in_advance, Boolean, required: false + argument :prorated, Boolean, required: false + argument :regroup_paid_fees, Types::Charges::RegroupPaidFeesEnum, required: false + + argument :filters, [Types::ChargeFilters::Input], required: false + argument :properties, Types::Charges::PropertiesInput, required: false + + argument :applied_pricing_unit, Types::AppliedPricingUnits::Input, required: false + argument :cascade_updates, Boolean, required: false + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/charges/input.rb b/app/graphql/types/charges/input.rb new file mode 100644 index 0000000..2a629a7 --- /dev/null +++ b/app/graphql/types/charges/input.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Charges + class Input < Types::BaseInputObject + graphql_name "ChargeInput" + + argument :billable_metric_id, ID, required: true + argument :charge_model, Types::Charges::ChargeModelEnum, required: true + argument :id, ID, required: false + argument :invoice_display_name, String, required: false + argument :invoiceable, Boolean, required: false + argument :min_amount_cents, GraphQL::Types::BigInt, required: false + argument :pay_in_advance, Boolean, required: false + argument :prorated, Boolean, required: false + argument :regroup_paid_fees, Types::Charges::RegroupPaidFeesEnum, required: false + + argument :filters, [Types::ChargeFilters::Input], required: false + argument :properties, Types::Charges::PropertiesInput, required: false + + argument :applied_pricing_unit, Types::AppliedPricingUnits::Input, required: false + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/charges/object.rb b/app/graphql/types/charges/object.rb new file mode 100644 index 0000000..6785125 --- /dev/null +++ b/app/graphql/types/charges/object.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module Charges + class Object < Types::BaseObject + graphql_name "Charge" + + field :code, String, null: true + field :id, ID, null: false + field :invoice_display_name, String, null: true + field :parent_id, ID, null: true + + field :billable_metric, Types::BillableMetrics::Object, null: false + field :charge_model, Types::Charges::ChargeModelEnum, null: false + field :invoiceable, Boolean, null: false + field :min_amount_cents, GraphQL::Types::BigInt, null: false + field :pay_in_advance, Boolean, null: false + field :properties, Types::Charges::Properties, null: true + field :prorated, Boolean, null: false + field :regroup_paid_fees, Types::Charges::RegroupPaidFeesEnum, null: true + + field :filters, [Types::ChargeFilters::Object], null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :applied_pricing_unit, Types::AppliedPricingUnits::Object, null: true + field :taxes, [Types::Taxes::Object] + + def properties + return object.properties unless object.properties == "{}" + + JSON.parse(object.properties) + end + + def billable_metric + return object.billable_metric unless object.discarded? + + BillableMetric.with_discarded.find_by(id: object.billable_metric_id) + end + end + end +end diff --git a/app/graphql/types/charges/presentation_group_key.rb b/app/graphql/types/charges/presentation_group_key.rb new file mode 100644 index 0000000..9f3fe12 --- /dev/null +++ b/app/graphql/types/charges/presentation_group_key.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Charges + class PresentationGroupKey < Types::BaseObject + field :options, Types::Charges::PresentationGroupKeyOptions, null: true + field :value, String, null: false + end + end +end diff --git a/app/graphql/types/charges/presentation_group_key_input.rb b/app/graphql/types/charges/presentation_group_key_input.rb new file mode 100644 index 0000000..ca2d502 --- /dev/null +++ b/app/graphql/types/charges/presentation_group_key_input.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Charges + class PresentationGroupKeyInput < Types::BaseInputObject + argument :options, Types::Charges::PresentationGroupKeyOptionsInput, required: false + argument :value, String, required: true + end + end +end diff --git a/app/graphql/types/charges/presentation_group_key_options.rb b/app/graphql/types/charges/presentation_group_key_options.rb new file mode 100644 index 0000000..087135e --- /dev/null +++ b/app/graphql/types/charges/presentation_group_key_options.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + module Charges + class PresentationGroupKeyOptions < Types::BaseObject + field :display_in_invoice, Boolean, null: true + end + end +end diff --git a/app/graphql/types/charges/presentation_group_key_options_input.rb b/app/graphql/types/charges/presentation_group_key_options_input.rb new file mode 100644 index 0000000..502fdd9 --- /dev/null +++ b/app/graphql/types/charges/presentation_group_key_options_input.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + module Charges + class PresentationGroupKeyOptionsInput < Types::BaseInputObject + argument :display_in_invoice, Boolean, required: false + end + end +end diff --git a/app/graphql/types/charges/properties.rb b/app/graphql/types/charges/properties.rb new file mode 100644 index 0000000..8b9ad95 --- /dev/null +++ b/app/graphql/types/charges/properties.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Types + module Charges + class Properties < Types::BaseObject + # NOTE: Standard and Package charge model + field :amount, String, null: true + field :pricing_group_keys, [String], null: true + + # NOTE: Graduated charge model + field :graduated_ranges, [Types::ChargeModels::GraduatedRange], null: true + + # NOTE: presentation group keys for all charge models + field :presentation_group_keys, [Types::Charges::PresentationGroupKey], null: true + + # NOTE: Graduated percentage modle + field :graduated_percentage_ranges, [Types::ChargeModels::GraduatedPercentageRange], null: true + + # NOTE: Package charge model + field :free_units, GraphQL::Types::BigInt, null: true + field :package_size, GraphQL::Types::BigInt, null: true + + # NOTE: Percentage charge model + field :fixed_amount, String, null: true + field :free_units_per_events, GraphQL::Types::BigInt, null: true + field :free_units_per_total_aggregation, String, null: true + field :per_transaction_max_amount, String, null: true + field :per_transaction_min_amount, String, null: true + field :rate, String, null: true + + # NOTE: Volume charge model + field :volume_ranges, [Types::ChargeModels::VolumeRange], null: true + + # NOTE: properties for the custom aggregation + field :custom_properties, GraphQL::Types::JSON, null: true + + def pricing_group_keys + # TODO(pricing_group_keys): remove after deprecation of grouped_by + object["pricing_group_keys"].presence || object["grouped_by"] + end + end + end +end diff --git a/app/graphql/types/charges/properties_input.rb b/app/graphql/types/charges/properties_input.rb new file mode 100644 index 0000000..e4fc260 --- /dev/null +++ b/app/graphql/types/charges/properties_input.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Types + module Charges + class PropertiesInput < Types::BaseInputObject + # NOTE: Standard and Package charge model + argument :amount, String, required: false + argument :pricing_group_keys, [String], required: false + + # NOTE: presentation group keys + argument :presentation_group_keys, [Types::Charges::PresentationGroupKeyInput], required: false + + # NOTE: Graduated charge model + argument :graduated_ranges, [Types::ChargeModels::GraduatedRangeInput], required: false + + # NOTE: Graduated percentage charge model + argument :graduated_percentage_ranges, [Types::ChargeModels::GraduatedPercentageRangeInput], required: false + + # NOTE: Package charge model + argument :free_units, GraphQL::Types::BigInt, required: false + argument :package_size, GraphQL::Types::BigInt, required: false + + # NOTE: Percentage charge model + argument :fixed_amount, String, required: false + argument :free_units_per_events, GraphQL::Types::BigInt, required: false + argument :free_units_per_total_aggregation, String, required: false + argument :per_transaction_max_amount, String, required: false + argument :per_transaction_min_amount, String, required: false + argument :rate, String, required: false + + # NOTE: Volume charge model + argument :volume_ranges, [Types::ChargeModels::VolumeRangeInput], required: false + + # NOTE: properties for the custom aggregation + argument :custom_properties, GraphQL::Types::JSON, required: false + end + end +end diff --git a/app/graphql/types/charges/regroup_paid_fees_enum.rb b/app/graphql/types/charges/regroup_paid_fees_enum.rb new file mode 100644 index 0000000..2f444bb --- /dev/null +++ b/app/graphql/types/charges/regroup_paid_fees_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Charges + class RegroupPaidFeesEnum < Types::BaseEnum + Charge::REGROUPING_PAID_FEES_OPTIONS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/charges/update_input.rb b/app/graphql/types/charges/update_input.rb new file mode 100644 index 0000000..e202fe4 --- /dev/null +++ b/app/graphql/types/charges/update_input.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module Charges + class UpdateInput < Types::BaseInputObject + graphql_name "ChargeUpdateInput" + + argument :id, ID, required: true + + argument :charge_model, Types::Charges::ChargeModelEnum, required: false + argument :code, String, required: false + argument :invoice_display_name, String, required: false + argument :invoiceable, Boolean, required: false + argument :min_amount_cents, GraphQL::Types::BigInt, required: false + argument :pay_in_advance, Boolean, required: false + argument :prorated, Boolean, required: false + argument :regroup_paid_fees, Types::Charges::RegroupPaidFeesEnum, required: false + + argument :filters, [Types::ChargeFilters::Input], required: false + argument :properties, Types::Charges::PropertiesInput, required: false + + argument :applied_pricing_unit, Types::AppliedPricingUnits::Input, required: false + argument :cascade_updates, Boolean, required: false + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/commitments/commitment_type_enum.rb b/app/graphql/types/commitments/commitment_type_enum.rb new file mode 100644 index 0000000..8ee2831 --- /dev/null +++ b/app/graphql/types/commitments/commitment_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Commitments + class CommitmentTypeEnum < Types::BaseEnum + Commitment::COMMITMENT_TYPES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/commitments/input.rb b/app/graphql/types/commitments/input.rb new file mode 100644 index 0000000..3596249 --- /dev/null +++ b/app/graphql/types/commitments/input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Commitments + class Input < Types::BaseInputObject + graphql_name "CommitmentInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: false + argument :commitment_type, Types::Commitments::CommitmentTypeEnum, required: false + argument :id, ID, required: false + argument :invoice_display_name, String, required: false + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/commitments/object.rb b/app/graphql/types/commitments/object.rb new file mode 100644 index 0000000..ac25304 --- /dev/null +++ b/app/graphql/types/commitments/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Commitments + class Object < Types::BaseObject + graphql_name "Commitment" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :commitment_type, Types::Commitments::CommitmentTypeEnum, null: false + field :invoice_display_name, String, null: true + field :plan, Types::Plans::Object, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :taxes, [Types::Taxes::Object], null: true + end + end +end diff --git a/app/graphql/types/country_code_enum.rb b/app/graphql/types/country_code_enum.rb new file mode 100644 index 0000000..027d051 --- /dev/null +++ b/app/graphql/types/country_code_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class CountryCodeEnum < Types::BaseEnum + graphql_name "CountryCode" + + ISO3166::Country.all.each do |country| # rubocop:disable Rails/FindEach + value country.alpha2, country.iso_short_name + end + end +end diff --git a/app/graphql/types/coupons/coupon_type_enum.rb b/app/graphql/types/coupons/coupon_type_enum.rb new file mode 100644 index 0000000..1e68a1b --- /dev/null +++ b/app/graphql/types/coupons/coupon_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Coupons + class CouponTypeEnum < Types::BaseEnum + Coupon::COUPON_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/coupons/create_input.rb b/app/graphql/types/coupons/create_input.rb new file mode 100644 index 0000000..2aebb09 --- /dev/null +++ b/app/graphql/types/coupons/create_input.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Coupons + class CreateInput < Types::BaseInputObject + graphql_name "CreateCouponInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: false + argument :amount_currency, Types::CurrencyEnum, required: false + argument :code, String, required: false + argument :coupon_type, Types::Coupons::CouponTypeEnum, required: true + argument :description, String, required: false + argument :frequency, Types::Coupons::FrequencyEnum, required: true + argument :frequency_duration, Integer, required: false + argument :name, String, required: true + argument :percentage_rate, Float, required: false + argument :reusable, Boolean, required: false + + argument :applies_to, Types::Coupons::LimitationInput, required: false + + argument :expiration, Types::Coupons::ExpirationEnum, required: true + argument :expiration_at, GraphQL::Types::ISO8601DateTime, required: false + end + end +end diff --git a/app/graphql/types/coupons/expiration_enum.rb b/app/graphql/types/coupons/expiration_enum.rb new file mode 100644 index 0000000..3e468b8 --- /dev/null +++ b/app/graphql/types/coupons/expiration_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Coupons + class ExpirationEnum < Types::BaseEnum + graphql_name "CouponExpiration" + + Coupon::EXPIRATION_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/coupons/frequency_enum.rb b/app/graphql/types/coupons/frequency_enum.rb new file mode 100644 index 0000000..e388623 --- /dev/null +++ b/app/graphql/types/coupons/frequency_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Coupons + class FrequencyEnum < Types::BaseEnum + graphql_name "CouponFrequency" + + Coupon::FREQUENCIES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/coupons/limitation_input.rb b/app/graphql/types/coupons/limitation_input.rb new file mode 100644 index 0000000..a6f4ee9 --- /dev/null +++ b/app/graphql/types/coupons/limitation_input.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Coupons + class LimitationInput < BaseInputObject + argument :billable_metric_ids, [ID], required: false + argument :plan_ids, [ID], required: false + end + end +end diff --git a/app/graphql/types/coupons/object.rb b/app/graphql/types/coupons/object.rb new file mode 100644 index 0000000..43e5fe5 --- /dev/null +++ b/app/graphql/types/coupons/object.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Types + module Coupons + class Object < Types::BaseObject + graphql_name "Coupon" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType + + field :amount_cents, GraphQL::Types::BigInt, null: true + field :amount_currency, Types::CurrencyEnum, null: true + field :code, String, null: true + field :coupon_type, Types::Coupons::CouponTypeEnum, null: false + field :description, String, null: true + field :frequency, Types::Coupons::FrequencyEnum, null: false + field :frequency_duration, Integer, null: true + field :name, String, null: false + field :percentage_rate, Float, null: true + field :reusable, Boolean, null: false + field :status, Types::Coupons::StatusEnum, null: false + + field :expiration, Types::Coupons::ExpirationEnum, null: false + field :expiration_at, GraphQL::Types::ISO8601DateTime, null: true + + field :activity_logs, [Types::ActivityLogs::Object], null: true + field :billable_metrics, [Types::BillableMetrics::Object] + field :limited_billable_metrics, Boolean, null: false + field :limited_plans, Boolean, null: false + field :plans, [Types::Plans::Object] + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :terminated_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :applied_coupons_count, Integer, null: false + field :customers_count, Integer, null: false, description: "Number of customers using this coupon" + + def customers_count + object.applied_coupons.active.select(:customer_id).distinct.count + end + + def applied_coupons_count + object.applied_coupons.count + end + + def plans + object.plans.parents + end + end + end +end diff --git a/app/graphql/types/coupons/status_enum.rb b/app/graphql/types/coupons/status_enum.rb new file mode 100644 index 0000000..14740e1 --- /dev/null +++ b/app/graphql/types/coupons/status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Coupons + class StatusEnum < Types::BaseEnum + graphql_name "CouponStatusEnum" + + Coupon::STATUSES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/coupons/update_input.rb b/app/graphql/types/coupons/update_input.rb new file mode 100644 index 0000000..13a156c --- /dev/null +++ b/app/graphql/types/coupons/update_input.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Coupons + class UpdateInput < Types::Coupons::CreateInput + graphql_name "UpdateCouponInput" + + argument :id, String, required: true + end + end +end diff --git a/app/graphql/types/credit_note_items/estimate.rb b/app/graphql/types/credit_note_items/estimate.rb new file mode 100644 index 0000000..de2b1cb --- /dev/null +++ b/app/graphql/types/credit_note_items/estimate.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module CreditNoteItems + class Estimate < Types::BaseObject + graphql_name "CreditNoteItemEstimate" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :fee, Types::Fees::Object, null: false + end + end +end diff --git a/app/graphql/types/credit_note_items/input.rb b/app/graphql/types/credit_note_items/input.rb new file mode 100644 index 0000000..d5fd764 --- /dev/null +++ b/app/graphql/types/credit_note_items/input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module CreditNoteItems + class Input < Types::BaseInputObject + graphql_name "CreditNoteItemInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :fee_id, ID, required: true + end + end +end diff --git a/app/graphql/types/credit_note_items/object.rb b/app/graphql/types/credit_note_items/object.rb new file mode 100644 index 0000000..41921ac --- /dev/null +++ b/app/graphql/types/credit_note_items/object.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module CreditNoteItems + class Object < Types::BaseObject + graphql_name "CreditNoteItem" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + + field :fee, Types::Fees::Object, null: false + end + end +end diff --git a/app/graphql/types/credit_notes/applied_taxes/object.rb b/app/graphql/types/credit_notes/applied_taxes/object.rb new file mode 100644 index 0000000..928f7cb --- /dev/null +++ b/app/graphql/types/credit_notes/applied_taxes/object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + module AppliedTaxes + class Object < Types::BaseObject + graphql_name "CreditNoteAppliedTax" + implements Types::Taxes::AppliedTax + + field :base_amount_cents, GraphQL::Types::BigInt, null: false + field :credit_note, Types::CreditNotes::Object, null: false + end + end + end +end diff --git a/app/graphql/types/credit_notes/credit_status_type_enum.rb b/app/graphql/types/credit_notes/credit_status_type_enum.rb new file mode 100644 index 0000000..e4fb6ad --- /dev/null +++ b/app/graphql/types/credit_notes/credit_status_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + class CreditStatusTypeEnum < Types::BaseEnum + graphql_name "CreditNoteCreditStatusEnum" + + CreditNote::CREDIT_STATUS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/credit_notes/estimate.rb b/app/graphql/types/credit_notes/estimate.rb new file mode 100644 index 0000000..ef18961 --- /dev/null +++ b/app/graphql/types/credit_notes/estimate.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + class Estimate < Types::BaseObject + description "Estimate amounts for credit note creation" + graphql_name "CreditNoteEstimate" + + field :currency, Types::CurrencyEnum, null: false + + field :coupons_adjustment_amount_cents, GraphQL::Types::BigInt, null: false + field :max_creditable_amount_cents, GraphQL::Types::BigInt, method: :credit_amount_cents, null: false + field :max_offsettable_amount_cents, GraphQL::Types::BigInt, null: false + field :max_refundable_amount_cents, GraphQL::Types::BigInt, method: :refund_amount_cents, null: false + field :precise_coupons_adjustment_amount_cents, GraphQL::Types::Float, null: false + field :sub_total_excluding_taxes_amount_cents, GraphQL::Types::BigInt, null: false + + field :precise_taxes_amount_cents, GraphQL::Types::Float, null: false + field :taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :taxes_rate, GraphQL::Types::Float, null: false + + field :items, [Types::CreditNoteItems::Estimate], null: false + + field :applied_taxes, [Types::CreditNotes::AppliedTaxes::Object], null: false + + def max_offsettable_amount_cents + creditable = object.credit_amount_cents.to_i + due = object.invoice&.total_due_amount_cents.to_i + + due.clamp(0, creditable) + end + end + end +end diff --git a/app/graphql/types/credit_notes/object.rb b/app/graphql/types/credit_notes/object.rb new file mode 100644 index 0000000..85ee327 --- /dev/null +++ b/app/graphql/types/credit_notes/object.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + class Object < Types::BaseObject + description "CreditNote" + graphql_name "CreditNote" + + field :id, ID, null: false + field :number, String, null: false + field :sequential_id, ID, null: false + + field :issuing_date, GraphQL::Types::ISO8601Date, null: false + + field :description, String, null: true + field :reason, Types::CreditNotes::ReasonTypeEnum, null: false + + field :credit_status, Types::CreditNotes::CreditStatusTypeEnum, null: true + field :refund_status, Types::CreditNotes::RefundStatusTypeEnum, null: true + + field :currency, Types::CurrencyEnum, null: false + field :taxes_rate, Float, null: false + + field :balance_amount_cents, GraphQL::Types::BigInt, null: false + field :coupons_adjustment_amount_cents, GraphQL::Types::BigInt, null: false + field :credit_amount_cents, GraphQL::Types::BigInt, null: false + field :offset_amount_cents, GraphQL::Types::BigInt, null: false + field :refund_amount_cents, GraphQL::Types::BigInt, null: false + field :sub_total_excluding_taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :total_amount_cents, GraphQL::Types::BigInt, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :refunded_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :voided_at, GraphQL::Types::ISO8601DateTime, null: true + + field :file_url, String, null: true + field :xml_url, String, null: true + + field :activity_logs, [Types::ActivityLogs::Object], null: true + field :applied_taxes, [Types::CreditNotes::AppliedTaxes::Object] + field :billing_entity, Types::BillingEntities::Object, null: false + field :customer, Types::Customers::Object, null: false + field :invoice, Types::Invoices::Object + field :items, [Types::CreditNoteItems::Object], null: false + + field :can_be_voided, Boolean, null: false, method: :voidable? do + description "Check if credit note can be voided" + end + + field :error_details, [Types::ErrorDetails::Object], null: true + field :external_integration_id, String, null: true + field :integration_syncable, GraphQL::Types::Boolean, null: false + field :metadata, [Types::Metadata::Object], null: true + field :tax_provider_id, String, null: true + field :tax_provider_syncable, GraphQL::Types::Boolean, null: false + + def metadata + object.metadata&.value + end + + def applied_taxes + object.applied_taxes.order(tax_rate: :desc) + end + + def integration_syncable + object.should_sync_credit_note? && + object.integration_resources + .joins(:integration) + .where(integration: {type: ::Integrations::BaseIntegration::INTEGRATION_ACCOUNTING_TYPES}) + .where(resource_type: "credit_note", syncable_type: "CreditNote").none? + end + + def tax_provider_syncable + return false unless object.finalized? + return false if object.invoice.credit? + + object.error_details.tax_error.any? + end + + def external_integration_id + integration_customer = object.customer&.integration_customers&.accounting_kind&.first + + return nil unless integration_customer + + IntegrationResource.find_by( + integration: integration_customer.integration, + syncable_id: object.id, + syncable_type: "CreditNote", + resource_type: :credit_note + )&.external_id + end + + def tax_provider_id + integration_customer = object.customer&.tax_customer + return nil unless integration_customer + + object.integration_resources.where(integration_id: integration_customer.integration_id).last&.external_id + end + end + end +end diff --git a/app/graphql/types/credit_notes/reason_type_enum.rb b/app/graphql/types/credit_notes/reason_type_enum.rb new file mode 100644 index 0000000..234a5ff --- /dev/null +++ b/app/graphql/types/credit_notes/reason_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + class ReasonTypeEnum < Types::BaseEnum + graphql_name "CreditNoteReasonEnum" + + CreditNote::REASON.each do |reason| + value reason + end + end + end +end diff --git a/app/graphql/types/credit_notes/refund_status_type_enum.rb b/app/graphql/types/credit_notes/refund_status_type_enum.rb new file mode 100644 index 0000000..cc0de6a --- /dev/null +++ b/app/graphql/types/credit_notes/refund_status_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + class RefundStatusTypeEnum < Types::BaseEnum + graphql_name "CreditNoteRefundStatusEnum" + + CreditNote::REFUND_STATUS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/credit_notes/type_enum.rb b/app/graphql/types/credit_notes/type_enum.rb new file mode 100644 index 0000000..777f8ae --- /dev/null +++ b/app/graphql/types/credit_notes/type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + class TypeEnum < Types::BaseEnum + graphql_name "CreditNoteTypeEnum" + + CreditNote::TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/credit_notes/update_credit_note_input.rb b/app/graphql/types/credit_notes/update_credit_note_input.rb new file mode 100644 index 0000000..3c5ad53 --- /dev/null +++ b/app/graphql/types/credit_notes/update_credit_note_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module CreditNotes + class UpdateCreditNoteInput < BaseInputObject + description "Update Credit Note input arguments" + + argument :id, ID, required: true + argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS + argument :refund_status, Types::CreditNotes::RefundStatusTypeEnum, required: false + end + end +end diff --git a/app/graphql/types/currency_enum.rb b/app/graphql/types/currency_enum.rb new file mode 100644 index 0000000..e5c10cd --- /dev/null +++ b/app/graphql/types/currency_enum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + class CurrencyEnum < Types::BaseEnum + Currencies::ACCEPTED_CURRENCIES.each do |code, description| + value code, description + end + end +end diff --git a/app/graphql/types/customer_portal/customers/object.rb b/app/graphql/types/customer_portal/customers/object.rb new file mode 100644 index 0000000..040cdcf --- /dev/null +++ b/app/graphql/types/customer_portal/customers/object.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Types + module CustomerPortal + module Customers + class Object < Types::BaseObject + graphql_name "CustomerPortalCustomer" + + field :id, ID, null: false + + field :account_type, Types::Customers::AccountTypeEnum, null: false + field :applicable_timezone, Types::TimezoneEnum, null: false + field :currency, Types::CurrencyEnum, null: true + field :customer_type, Types::Customers::CustomerTypeEnum + field :display_name, String, null: false + field :email, String, null: true + field :firstname, String + field :lastname, String + field :legal_name, String, null: true + field :legal_number, String, null: true + field :name, String + field :tax_identification_number, String, null: true + + field :billing_configuration, Types::Customers::BillingConfiguration, null: true + field :billing_entity_billing_configuration, Types::BillingEntities::BillingConfiguration, null: false + + # Billing address + field :address_line1, String, null: true + field :address_line2, String, null: true + field :city, String, null: true + field :country, Types::CountryCodeEnum, null: true + field :state, String, null: true + field :zipcode, String, null: true + + field :shipping_address, Types::Customers::Address, null: true + + field :premium, Boolean, null: false + + def billing_configuration + { + id: "#{object&.id}-c0nf", + document_locale: object&.document_locale + } + end + + def billing_entity_billing_configuration + { + id: "#{object&.billing_entity&.id}-c1nf", + document_locale: object&.billing_entity&.document_locale + } + end + + def premium + License.premium? + end + end + end + end +end diff --git a/app/graphql/types/customer_portal/customers/update_input.rb b/app/graphql/types/customer_portal/customers/update_input.rb new file mode 100644 index 0000000..baa1169 --- /dev/null +++ b/app/graphql/types/customer_portal/customers/update_input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module CustomerPortal + module Customers + class UpdateInput < BaseInputObject + graphql_name "UpdateCustomerPortalCustomerInput" + description "Customer Portal Customer Update input arguments" + + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + argument :document_locale, String, required: false + argument :email, String, required: false + argument :firstname, String, required: false + argument :lastname, String, required: false + argument :legal_name, String, required: false + argument :name, String, required: false + argument :tax_identification_number, String, required: false + + # Billing address + argument :address_line1, String, required: false + argument :address_line2, String, required: false + argument :city, String, required: false + argument :country, Types::CountryCodeEnum, required: false + argument :state, String, required: false + argument :zipcode, String, required: false + + argument :shipping_address, Types::Customers::AddressInput, required: false + end + end + end +end diff --git a/app/graphql/types/customer_portal/organizations/object.rb b/app/graphql/types/customer_portal/organizations/object.rb new file mode 100644 index 0000000..86f196a --- /dev/null +++ b/app/graphql/types/customer_portal/organizations/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module CustomerPortal + module Organizations + class Object < Types::Organizations::BaseOrganizationType + graphql_name "CustomerPortalOrganization" + description "CustomerPortalOrganization" + + field :id, ID, null: false + + field :billing_configuration, Types::Organizations::BillingConfiguration, null: true + field :default_currency, Types::CurrencyEnum, null: false + field :logo_url, String + field :name, String, null: false + field :premium_integrations, [Types::Integrations::PremiumIntegrationTypeEnum], null: false + field :timezone, Types::TimezoneEnum, null: true + end + end + end +end diff --git a/app/graphql/types/customer_portal/wallet_transactions/object.rb b/app/graphql/types/customer_portal/wallet_transactions/object.rb new file mode 100644 index 0000000..eb84aa5 --- /dev/null +++ b/app/graphql/types/customer_portal/wallet_transactions/object.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module CustomerPortal + module WalletTransactions + class Object < Types::BaseObject + graphql_name "CustomerPortalWalletTransaction" + + field :id, ID, null: false + field :wallet, Types::CustomerPortal::Wallets::Object + + field :amount, String, null: false + field :credit_amount, String, null: false + field :status, Types::WalletTransactions::StatusEnum, null: false + field :transaction_status, Types::WalletTransactions::TransactionStatusEnum, null: false + field :transaction_type, Types::WalletTransactions::TransactionTypeEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :settled_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end + end +end diff --git a/app/graphql/types/customer_portal/wallets/object.rb b/app/graphql/types/customer_portal/wallets/object.rb new file mode 100644 index 0000000..a6d2ba9 --- /dev/null +++ b/app/graphql/types/customer_portal/wallets/object.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module CustomerPortal + module Wallets + class Object < Types::BaseObject + graphql_name "CustomerPortalWallet" + description "CustomerPortalWallet" + + field :id, ID, null: false + + field :code, String, null: true + field :currency, Types::CurrencyEnum, null: false + field :expiration_at, GraphQL::Types::ISO8601DateTime, null: true + field :name, String, null: true + field :priority, Integer, null: false + field :status, Types::Wallets::StatusEnum, null: false + + field :balance_cents, GraphQL::Types::BigInt, null: false + field :consumed_amount_cents, GraphQL::Types::BigInt, null: false + field :consumed_credits, GraphQL::Types::Float, null: false + field :credits_balance, GraphQL::Types::Float, null: false + field :credits_ongoing_balance, GraphQL::Types::Float, null: false + field :ongoing_balance_cents, GraphQL::Types::BigInt, null: false + field :ongoing_usage_balance_cents, GraphQL::Types::BigInt, null: false + field :rate_amount, GraphQL::Types::Float, null: false + + field :paid_top_up_max_amount_cents, GraphQL::Types::BigInt, null: true + field :paid_top_up_max_credits, GraphQL::Types::BigInt, null: true + field :paid_top_up_min_amount_cents, GraphQL::Types::BigInt, null: true + field :paid_top_up_min_credits, GraphQL::Types::BigInt, null: true + + field :last_balance_sync_at, GraphQL::Types::ISO8601DateTime, null: true + end + end + end +end diff --git a/app/graphql/types/customers/account_type_enum.rb b/app/graphql/types/customers/account_type_enum.rb new file mode 100644 index 0000000..29956b3 --- /dev/null +++ b/app/graphql/types/customers/account_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Customers + class AccountTypeEnum < Types::BaseEnum + graphql_name "CustomerAccountTypeEnum" + + Customer::ACCOUNT_TYPES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/customers/address.rb b/app/graphql/types/customers/address.rb new file mode 100644 index 0000000..8fd833a --- /dev/null +++ b/app/graphql/types/customers/address.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Customers + class Address < Types::BaseObject + graphql_name "CustomerAddress" + + field :address_line1, String, null: true + field :address_line2, String, null: true + field :city, String, null: true + field :country, Types::CountryCodeEnum, null: true + field :state, String, null: true + field :zipcode, String, null: true + end + end +end diff --git a/app/graphql/types/customers/address_input.rb b/app/graphql/types/customers/address_input.rb new file mode 100644 index 0000000..6f12b36 --- /dev/null +++ b/app/graphql/types/customers/address_input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Customers + class AddressInput < BaseInputObject + graphql_name "CustomerAddressInput" + + argument :address_line1, String, required: false + argument :address_line2, String, required: false + argument :city, String, required: false + argument :country, Types::CountryCodeEnum, required: false + argument :state, String, required: false + argument :zipcode, String, required: false + end + end +end diff --git a/app/graphql/types/customers/billing_configuration.rb b/app/graphql/types/customers/billing_configuration.rb new file mode 100644 index 0000000..b4dd5c0 --- /dev/null +++ b/app/graphql/types/customers/billing_configuration.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Customers + class BillingConfiguration < Types::BaseObject + graphql_name "CustomerBillingConfiguration" + + field :document_locale, String + field :id, ID, null: false + field :subscription_invoice_issuing_date_adjustment, Types::Customers::SubscriptionInvoiceIssuingDateAdjustmentEnum, null: true + field :subscription_invoice_issuing_date_anchor, Types::Customers::SubscriptionInvoiceIssuingDateAnchorEnum, null: true + end + end +end diff --git a/app/graphql/types/customers/billing_configuration_input.rb b/app/graphql/types/customers/billing_configuration_input.rb new file mode 100644 index 0000000..317aa30 --- /dev/null +++ b/app/graphql/types/customers/billing_configuration_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Customers + class BillingConfigurationInput < BaseInputObject + graphql_name "CustomerBillingConfigurationInput" + + argument :document_locale, String, required: false, permissions: %w[customers:create customers:update] + argument :subscription_invoice_issuing_date_adjustment, Types::Customers::SubscriptionInvoiceIssuingDateAdjustmentEnum, required: false, permissions: %w[customers:create customers:update] + argument :subscription_invoice_issuing_date_anchor, Types::Customers::SubscriptionInvoiceIssuingDateAnchorEnum, required: false, permissions: %w[customers:create customers:update] + end + end +end diff --git a/app/graphql/types/customers/create_customer_input.rb b/app/graphql/types/customers/create_customer_input.rb new file mode 100644 index 0000000..d56dd20 --- /dev/null +++ b/app/graphql/types/customers/create_customer_input.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Types + module Customers + class CreateCustomerInput < BaseInputObject + description "Create Customer input arguments" + + argument :account_type, Types::Customers::AccountTypeEnum, required: false + argument :address_line1, String, required: false + argument :address_line2, String, required: false + argument :city, String, required: false + argument :country, Types::CountryCodeEnum, required: false + argument :currency, Types::CurrencyEnum, required: false + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false + argument :email, String, required: false + argument :external_id, String, required: true + argument :external_salesforce_id, String, required: false + argument :firstname, String, required: false + argument :invoice_grace_period, Integer, required: false + argument :lastname, String, required: false + argument :legal_name, String, required: false + argument :legal_number, String, required: false + argument :logo_url, String, required: false + argument :name, String, required: false + argument :net_payment_term, Integer, required: false + argument :phone, String, required: false + argument :state, String, required: false + argument :tax_codes, [String], required: false + argument :tax_identification_number, String, required: false + argument :timezone, Types::TimezoneEnum, required: false + argument :url, String, required: false + argument :zipcode, String, required: false + + argument :billing_entity_code, String, required: false + argument :shipping_address, Types::Customers::AddressInput, required: false + + argument :metadata, [Types::Customers::Metadata::Input], required: false + + argument :payment_provider, Types::PaymentProviders::ProviderTypeEnum, required: false + argument :payment_provider_code, String, required: false + argument :provider_customer, Types::PaymentProviderCustomers::ProviderInput, required: false + + argument :integration_customers, [Types::IntegrationCustomers::Input], required: false + + argument :billing_configuration, Types::Customers::BillingConfigurationInput, required: false + argument :finalize_zero_amount_invoice, Types::Customers::FinalizeZeroAmountInvoiceEnum, required: false + end + end +end diff --git a/app/graphql/types/customers/credit_notes_balance.rb b/app/graphql/types/customers/credit_notes_balance.rb new file mode 100644 index 0000000..e8f879e --- /dev/null +++ b/app/graphql/types/customers/credit_notes_balance.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Customers + class CreditNotesBalance < Types::BaseObject + graphql_name "CustomerCreditNotesBalance" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :currency, Types::CurrencyEnum, null: false + end + end +end diff --git a/app/graphql/types/customers/customer_type_enum.rb b/app/graphql/types/customers/customer_type_enum.rb new file mode 100644 index 0000000..72445fc --- /dev/null +++ b/app/graphql/types/customers/customer_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Customers + class CustomerTypeEnum < Types::BaseEnum + Customer::CUSTOMER_TYPES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/customers/finalize_zero_amount_invoice_enum.rb b/app/graphql/types/customers/finalize_zero_amount_invoice_enum.rb new file mode 100644 index 0000000..2db639e --- /dev/null +++ b/app/graphql/types/customers/finalize_zero_amount_invoice_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Customers + class FinalizeZeroAmountInvoiceEnum < BaseEnum + Customer::FINALIZE_ZERO_AMOUNT_INVOICE_OPTIONS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/customers/metadata/filter.rb b/app/graphql/types/customers/metadata/filter.rb new file mode 100644 index 0000000..f7a1b5b --- /dev/null +++ b/app/graphql/types/customers/metadata/filter.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Customers + module Metadata + class Filter < Types::BaseInputObject + graphql_name "CustomerMetadataFilter" + + argument :key, String, required: true + argument :value, String, required: true + end + end + end +end diff --git a/app/graphql/types/customers/metadata/input.rb b/app/graphql/types/customers/metadata/input.rb new file mode 100644 index 0000000..363b5b1 --- /dev/null +++ b/app/graphql/types/customers/metadata/input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Customers + module Metadata + class Input < Types::BaseInputObject + graphql_name "CustomerMetadataInput" + + argument :id, ID, required: false + argument :key, String, required: true + argument :value, String, required: true + + argument :display_in_invoice, Boolean, required: true + end + end + end +end diff --git a/app/graphql/types/customers/metadata/object.rb b/app/graphql/types/customers/metadata/object.rb new file mode 100644 index 0000000..87675c2 --- /dev/null +++ b/app/graphql/types/customers/metadata/object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Customers + module Metadata + class Object < Types::BaseObject + graphql_name "CustomerMetadata" + + field :display_in_invoice, Boolean, null: false + field :id, ID, null: false + field :key, String, null: false + field :value, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end + end +end diff --git a/app/graphql/types/customers/object.rb b/app/graphql/types/customers/object.rb new file mode 100644 index 0000000..59afd86 --- /dev/null +++ b/app/graphql/types/customers/object.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +module Types + module Customers + class Object < Types::BaseObject + graphql_name "Customer" + + field :id, ID, null: false + + field :account_type, Types::Customers::AccountTypeEnum, null: false + field :customer_type, Types::Customers::CustomerTypeEnum + field :display_name, String, null: false + field :external_id, String, null: false + field :firstname, String + field :lastname, String + field :name, String + field :sequential_id, String, null: false + field :slug, String, null: false + + field :address_line1, String, null: true + field :address_line2, String, null: true + field :applicable_timezone, Types::TimezoneEnum, null: false + field :city, String, null: true + field :country, Types::CountryCodeEnum, null: true + field :currency, Types::CurrencyEnum, null: true + field :email, String, null: true + field :external_salesforce_id, String, null: true + field :invoice_grace_period, Integer, null: true + field :legal_name, String, null: true + field :legal_number, String, null: true + field :logo_url, String, null: true + field :net_payment_term, Integer, null: true + field :payment_provider, Types::PaymentProviders::ProviderTypeEnum, null: true + field :payment_provider_code, String, null: true + field :phone, String, null: true + field :state, String, null: true + field :tax_identification_number, String, null: true + field :timezone, Types::TimezoneEnum, null: true + field :url, String, null: true + field :zipcode, String, null: true + + field :shipping_address, Types::Customers::Address, null: true + + field :metadata, [Types::Customers::Metadata::Object], null: true + + field :billing_configuration, Types::Customers::BillingConfiguration, null: true + + field :provider_customer, Types::PaymentProviderCustomers::Provider, null: true + field :subscriptions, [Types::Subscriptions::Object], resolver: Resolvers::Customers::SubscriptionsResolver + + field :anrok_customer, Types::IntegrationCustomers::Anrok, null: true + field :avalara_customer, Types::IntegrationCustomers::Avalara, null: true + field :hubspot_customer, Types::IntegrationCustomers::Hubspot, null: true + field :netsuite_customer, Types::IntegrationCustomers::Netsuite, null: true + field :salesforce_customer, Types::IntegrationCustomers::Salesforce, null: true + field :xero_customer, Types::IntegrationCustomers::Xero, null: true + + field :billing_entity, Types::BillingEntities::Object, null: false + field :invoices, [Types::Invoices::Object] + + field :activity_logs, [Types::ActivityLogs::Object], null: true + field :applied_add_ons, [Types::AppliedAddOns::Object], null: true + field :applied_coupons, [Types::AppliedCoupons::Object], null: true + field :taxes, [Types::Taxes::Object], null: true + + field :credit_notes, [Types::CreditNotes::Object], null: true + + field :applied_dunning_campaign, Types::DunningCampaigns::Object, null: true + field :exclude_from_dunning_campaign, Boolean, null: false + field :last_dunning_campaign_attempt, Integer, null: false + field :last_dunning_campaign_attempt_at, GraphQL::Types::ISO8601DateTime, null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :active_subscriptions_count, + Integer, + null: false, + description: "Number of active subscriptions per customer" + field :credit_notes_balance_amount_cents, + GraphQL::Types::BigInt, + null: false, + description: "Credit notes credits balance available per customer" + field :credit_notes_balances, + [Types::Customers::CreditNotesBalance], + null: false, + description: "Credit notes credits balance available per customer per currency" + field :credit_notes_credits_available_count, + Integer, + null: false, + description: "Number of available credits from credit notes per customer" + field :has_active_wallet, Boolean, null: false, description: "Define if a customer has an active wallet" + field :has_credit_notes, Boolean, null: false, description: "Define if a customer has any credit note" + field :has_overdue_invoices, Boolean, null: false, description: "Define if a customer has overdue invoices" + + field :can_edit_attributes, Boolean, null: false, method: :editable? do + description "Check if customer attributes are editable" + end + + field :finalize_zero_amount_invoice, Types::Customers::FinalizeZeroAmountInvoiceEnum, null: true, description: "Options for handling invoices with a zero total amount." + + field :configurable_invoice_custom_sections, [Types::InvoiceCustomSections::Object], null: true, description: "Invoice custom sections manually configured for the customer" + field :has_overwritten_invoice_custom_sections_selection, Boolean, null: true, description: "Define if the customer has custom invoice custom sections selection" + field :skip_invoice_custom_sections, Boolean, null: true, description: "Skip invoice custom sections for the customer" + + field :error_details, [Types::ErrorDetails::Object], null: true + + def invoices + object.invoices.visible.order(created_at: :desc) + end + + def applied_coupons + object.applied_coupons.active.order(created_at: :asc) + end + + def applied_add_ons + object.applied_add_ons.order(created_at: :desc) + end + + def has_active_wallet + object.wallets.active.any? + end + + def has_credit_notes + object.credit_notes.finalized.any? + end + + def has_overdue_invoices + object.invoices.payment_overdue.any? + end + + def active_subscriptions_count + object.active_subscriptions.count + end + + def provider_customer # rubocop:disable GraphQL/ResolverMethodLength + case object&.payment_provider&.to_sym + when :stripe + object.stripe_customer + when :gocardless + object.gocardless_customer + when :cashfree + object.cashfree_customer + when :adyen + object.adyen_customer + when :moneyhash + object.moneyhash_customer + end + end + + def credit_notes_credits_available_count + object.credit_notes.finalized.where("credit_notes.credit_amount_cents > 0").count + end + + def credit_notes_balance_amount_cents + object.credit_notes.finalized.sum("credit_notes.balance_amount_cents") + end + + def credit_notes_balances + object.credit_notes + .finalized + .where("credit_notes.balance_amount_cents > 0") + .group("credit_notes.total_amount_currency") + .sum("credit_notes.balance_amount_cents") + .map { |currency, amount_cents| {currency:, amount_cents:} } + end + + def billing_configuration + { + id: "#{object&.id}-c0nf", + document_locale: object&.document_locale, + subscription_invoice_issuing_date_anchor: object&.subscription_invoice_issuing_date_anchor, + subscription_invoice_issuing_date_adjustment: object&.subscription_invoice_issuing_date_adjustment + } + end + + def has_overwritten_invoice_custom_sections_selection + !object.skip_invoice_custom_sections && object.manual_selected_invoice_custom_sections.any? + end + end + end +end diff --git a/app/graphql/types/customers/subscription_invoice_issuing_date_adjustment_enum.rb b/app/graphql/types/customers/subscription_invoice_issuing_date_adjustment_enum.rb new file mode 100644 index 0000000..2954289 --- /dev/null +++ b/app/graphql/types/customers/subscription_invoice_issuing_date_adjustment_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Customers + class SubscriptionInvoiceIssuingDateAdjustmentEnum < Types::BaseEnum + graphql_name "CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum" + description "Subscription Invoice Issuing Date Adjustment Values" + + ::Customer::SUBSCRIPTION_INVOICE_ISSUING_DATE_ADJUSTMENTS.keys.each do |code| + value code + end + end + end +end diff --git a/app/graphql/types/customers/subscription_invoice_issuing_date_anchor_enum.rb b/app/graphql/types/customers/subscription_invoice_issuing_date_anchor_enum.rb new file mode 100644 index 0000000..bec3560 --- /dev/null +++ b/app/graphql/types/customers/subscription_invoice_issuing_date_anchor_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Customers + class SubscriptionInvoiceIssuingDateAnchorEnum < Types::BaseEnum + graphql_name "CustomerSubscriptionInvoiceIssuingDateAnchorEnum" + description "Subscription Invoice Issuing Date Anchor Values" + + ::Customer::SUBSCRIPTION_INVOICE_ISSUING_DATE_ANCHORS.keys.each do |code| + value code + end + end + end +end diff --git a/app/graphql/types/customers/update_customer_input.rb b/app/graphql/types/customers/update_customer_input.rb new file mode 100644 index 0000000..acc9c62 --- /dev/null +++ b/app/graphql/types/customers/update_customer_input.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Types + module Customers + class UpdateCustomerInput < BaseInputObject + description "Update Customer input arguments" + + argument :id, ID, required: true + + argument :account_type, Types::Customers::AccountTypeEnum, required: false + argument :address_line1, String, required: false, permission: "customers:update" + argument :address_line2, String, required: false, permission: "customers:update" + argument :city, String, required: false, permission: "customers:update" + argument :country, Types::CountryCodeEnum, required: false, permission: "customers:update" + argument :currency, Types::CurrencyEnum, required: false, permission: "customers:update" + argument :customer_type, Types::Customers::CustomerTypeEnum, required: false, permission: "customers:update" + argument :email, String, required: false, permission: "customers:update" + argument :external_id, String, required: true, permission: "customers:update" + argument :external_salesforce_id, String, required: false, permission: "customers:update" + argument :firstname, String, required: false, permission: "customers:update" + argument :lastname, String, required: false, permission: "customers:update" + argument :legal_name, String, required: false, permission: "customers:update" + argument :legal_number, String, required: false, permission: "customers:update" + argument :logo_url, String, required: false, permission: "customers:update" + argument :name, String, required: false, permission: "customers:update" + argument :phone, String, required: false, permission: "customers:update" + argument :state, String, required: false, permission: "customers:update" + argument :tax_identification_number, String, required: false, permission: "customers:update" + argument :timezone, Types::TimezoneEnum, required: false, permission: "customers:update" + argument :url, String, required: false, permission: "customers:update" + argument :zipcode, String, required: false, permission: "customers:update" + + argument :billing_entity_code, String, required: false, permission: "customers:update" + argument :shipping_address, Types::Customers::AddressInput, required: false, permission: "customers:update" + + argument :metadata, [Types::Customers::Metadata::Input], required: false, permission: "customers:update" + + argument :payment_provider, Types::PaymentProviders::ProviderTypeEnum, required: false, permission: "customers:update" + argument :payment_provider_code, String, required: false, permission: "customers:update" + argument :provider_customer, Types::PaymentProviderCustomers::ProviderInput, required: false, permission: "customers:update" + + argument :integration_customers, [Types::IntegrationCustomers::Input], required: false, permission: "customers:update" + + # Customer settings + argument :invoice_grace_period, Integer, required: false, permissions: %w[customers:update] + argument :net_payment_term, Integer, required: false, permissions: %w[customers:update] + argument :tax_codes, [String], required: false, permissions: %w[customers:update] + + argument :billing_configuration, Types::Customers::BillingConfigurationInput, required: false + argument :finalize_zero_amount_invoice, Types::Customers::FinalizeZeroAmountInvoiceEnum, required: false + + # Dunning campaign settings + argument :applied_dunning_campaign_id, ID, required: false + argument :exclude_from_dunning_campaign, Boolean, required: false + + # Invoice custom sections settings + argument :configurable_invoice_custom_section_ids, [ID], required: false + argument :skip_invoice_custom_sections, Boolean, required: false + end + end +end diff --git a/app/graphql/types/customers/usage/charge.rb b/app/graphql/types/customers/usage/charge.rb new file mode 100644 index 0000000..d9fbb97 --- /dev/null +++ b/app/graphql/types/customers/usage/charge.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class Charge < Types::BaseObject + graphql_name "ChargeUsage" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :events_count, Integer, null: false + field :id, ID, null: false + field :pricing_unit_amount_cents, GraphQL::Types::BigInt, null: true + field :units, GraphQL::Types::Float, null: false + + field :billable_metric, Types::BillableMetrics::Object, null: false + field :charge, Types::Charges::Object, null: false + field :filters, [Types::Customers::Usage::ChargeFilter], null: true + field :grouped_usage, [Types::Customers::Usage::GroupedUsage], null: false + field :presentation_breakdowns, [Types::Customers::Usage::PresentationBreakdown], null: true + + def id + SecureRandom.uuid + end + + def events_count + object.sum(&:events_count) + end + + def units + object.map { |f| BigDecimal(f.units) }.sum + end + + def amount_cents + object.sum(&:amount_cents) + end + + def pricing_unit_amount_cents + return if charge.applied_pricing_unit.nil? + + object.map(&:pricing_unit_usage).sum(&:amount_cents) + end + + def charge + object.first.charge + end + + def billable_metric + object.first.billable_metric + end + + def filters + return [] unless object.first.has_charge_filters? + + object.sort_by { |f| f.charge_filter&.display_name.to_s } + end + + def grouped_usage + return [] unless object.any? { |f| f.grouped_by.present? } + + object.group_by(&:grouped_by).values + end + + def presentation_breakdowns + Types::Fees::PresentationBreakdownBuilder.call(object, filter: Types::Fees::PresentationBreakdownBuilder::UNGROUPED) + end + end + end + end +end diff --git a/app/graphql/types/customers/usage/charge_filter.rb b/app/graphql/types/customers/usage/charge_filter.rb new file mode 100644 index 0000000..f052aea --- /dev/null +++ b/app/graphql/types/customers/usage/charge_filter.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class ChargeFilter < Types::BaseObject + graphql_name "ChargeFilterUsage" + + field :id, ID, null: true, method: :charge_filter_id + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :events_count, Integer, null: false + field :invoice_display_name, String, null: true + field :pricing_unit_amount_cents, GraphQL::Types::BigInt, null: true + field :units, GraphQL::Types::Float, null: false + field :values, Types::ChargeFilters::Values, null: false + + def values + object.charge_filter&.to_h || {} # rubocop:disable Lint/RedundantSafeNavigation + end + + def pricing_unit_amount_cents + object.pricing_unit_usage&.amount_cents + end + + def invoice_display_name + object.charge_filter&.invoice_display_name + end + end + end + end +end diff --git a/app/graphql/types/customers/usage/current.rb b/app/graphql/types/customers/usage/current.rb new file mode 100644 index 0000000..968289b --- /dev/null +++ b/app/graphql/types/customers/usage/current.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class Current < Types::BaseObject + graphql_name "CustomerUsage" + + field :from_datetime, GraphQL::Types::ISO8601DateTime, null: false + field :to_datetime, GraphQL::Types::ISO8601DateTime, null: false + + field :currency, Types::CurrencyEnum, null: false + field :issuing_date, GraphQL::Types::ISO8601Date, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :total_amount_cents, GraphQL::Types::BigInt, null: false + + field :charges_usage, [Types::Customers::Usage::Charge], null: false + + def charges_usage + object.fees.group_by(&:charge_id).values + end + end + end + end +end diff --git a/app/graphql/types/customers/usage/grouped_usage.rb b/app/graphql/types/customers/usage/grouped_usage.rb new file mode 100644 index 0000000..6f0185a --- /dev/null +++ b/app/graphql/types/customers/usage/grouped_usage.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class GroupedUsage < Types::BaseObject + graphql_name "GroupedChargeUsage" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :events_count, Integer, null: false + field :id, ID, null: false + field :pricing_unit_amount_cents, GraphQL::Types::BigInt, null: true + field :units, GraphQL::Types::Float, null: false + + field :filters, [Types::Customers::Usage::ChargeFilter], null: true + field :grouped_by, GraphQL::Types::JSON, null: true + field :presentation_breakdowns, [Types::Customers::Usage::PresentationBreakdown], null: true + + def id + SecureRandom.uuid + end + + def amount_cents + object.sum(&:amount_cents) + end + + def pricing_unit_amount_cents + return if object.first.charge.applied_pricing_unit.nil? + + object.map(&:pricing_unit_usage).sum(&:amount_cents) + end + + def events_count + object.sum(&:events_count) + end + + def units + object.map { |f| BigDecimal(f.units) }.sum + end + + def grouped_by + object.first.grouped_by + end + + def filters + return [] unless object.first.has_charge_filters? + + object.sort_by { |f| f.charge_filter&.display_name.to_s } + end + + def presentation_breakdowns + Types::Fees::PresentationBreakdownBuilder.call(object, filter: Types::Fees::PresentationBreakdownBuilder::GROUPED) + end + end + end + end +end diff --git a/app/graphql/types/customers/usage/presentation_breakdown.rb b/app/graphql/types/customers/usage/presentation_breakdown.rb new file mode 100644 index 0000000..b87fcf1 --- /dev/null +++ b/app/graphql/types/customers/usage/presentation_breakdown.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class PresentationBreakdown < Types::BaseObject + graphql_name "PresentationBreakdownUsage" + + field :presentation_by, GraphQL::Types::JSON, null: false + field :units, GraphQL::Types::String, null: false + end + end + end +end diff --git a/app/graphql/types/customers/usage/projected.rb b/app/graphql/types/customers/usage/projected.rb new file mode 100644 index 0000000..a150f43 --- /dev/null +++ b/app/graphql/types/customers/usage/projected.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class Projected < Types::BaseObject + graphql_name "CustomerProjectedUsage" + + field :from_datetime, GraphQL::Types::ISO8601DateTime, null: false + field :to_datetime, GraphQL::Types::ISO8601DateTime, null: false + + field :currency, Types::CurrencyEnum, null: false + field :issuing_date, GraphQL::Types::ISO8601Date, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :projected_amount_cents, GraphQL::Types::BigInt, null: false + field :taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :total_amount_cents, GraphQL::Types::BigInt, null: false + + field :charges_usage, [Types::Customers::Usage::ProjectedCharge], null: false + + def charges_usage + object.fees.group_by(&:charge_id).values + end + + def projected_amount_cents + fee_groups_by_charge.sum { |fee_group| projected_amount_for_fee_group(fee_group) } + end + + private + + def fee_groups_by_charge + object.fees.group_by(&:charge_id).values + end + + def projected_amount_for_fee_group(fee_group) + charge = fee_group.first.charge + + if charge.filters.any? + projected_amount_for_filtered_fees(fee_group) + elsif has_grouping?(fee_group) + projected_amount_for_grouped_fees(fee_group) + else + projected_amount_for_simple_fees(fee_group) + end + end + + def has_grouping?(fee_group) + fee_group.any? { |f| f.grouped_by.present? } + end + + def projected_amount_for_filtered_fees(fee_group) + defined_filter_fees = fee_group.select(&:charge_filter_id) + defined_filter_fees.sum { |fee| project_fees([fee]) } + end + + def projected_amount_for_grouped_fees(fee_group) + groups = fee_group.group_by(&:grouped_by).values + groups.sum { |single_group_fees| project_fees(single_group_fees) } + end + + def projected_amount_for_simple_fees(fee_group) + project_fees(fee_group) + end + + def project_fees(fees) + ::Fees::ProjectionService.call!(fees: fees).projected_amount_cents + end + end + end + end +end diff --git a/app/graphql/types/customers/usage/projected_charge.rb b/app/graphql/types/customers/usage/projected_charge.rb new file mode 100644 index 0000000..d23aed4 --- /dev/null +++ b/app/graphql/types/customers/usage/projected_charge.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class ProjectedCharge < Types::BaseObject + graphql_name "ProjectedChargeUsage" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :events_count, Integer, null: false + field :id, ID, null: false + field :pricing_unit_amount_cents, GraphQL::Types::BigInt, null: true + field :pricing_unit_projected_amount_cents, GraphQL::Types::BigInt, null: true + field :projected_amount_cents, GraphQL::Types::BigInt, null: false + field :projected_units, GraphQL::Types::Float, null: false + field :units, GraphQL::Types::Float, null: false + + field :billable_metric, Types::BillableMetrics::Object, null: false + field :charge, Types::Charges::Object, null: false + field :filters, [Types::Customers::Usage::ProjectedChargeFilter], null: true + field :grouped_usage, [Types::Customers::Usage::ProjectedGroupedUsage], null: false + field :presentation_breakdowns, [Types::Customers::Usage::PresentationBreakdown], null: true + + def id + SecureRandom.uuid + end + + def events_count + object.sum(&:events_count) + end + + def units + object.map { |f| BigDecimal(f.units) }.sum + end + + def amount_cents + object.sum(&:amount_cents) + end + + def pricing_unit_amount_cents + return if charge.applied_pricing_unit.nil? + + object.map(&:pricing_unit_usage).sum(&:amount_cents) + end + + def pricing_unit_projected_amount_cents + projection_result.projected_pricing_unit_amount_cents + end + + def charge + object.first.charge + end + + def billable_metric + object.first.billable_metric + end + + def filters + return [] unless object.first.has_charge_filters? + + object.sort_by { |f| f.charge_filter&.display_name.to_s } + end + + def grouped_usage + return [] unless object.any? { |f| f.grouped_by.present? } + + object.group_by(&:grouped_by).values + end + + def projected_units + calculate_projection(:projected_units, BigDecimal(0)) + end + + def projected_amount_cents + calculate_projection(:projected_amount_cents, 0) + end + + def presentation_breakdowns + Types::Fees::PresentationBreakdownBuilder.call(object, filter: Types::Fees::PresentationBreakdownBuilder::UNGROUPED) + end + + private + + def calculate_projection(attribute, zero_value) + if charge.filters.any? + calculate_filtered_projection(attribute, zero_value) + elsif has_grouping? + calculate_grouped_projection(attribute) + else + projection_result.public_send(attribute) + end + end + + def calculate_filtered_projection(attribute, zero_value) + filter_groups = object.group_by(&:charge_filter_id).values + + filter_groups.sum do |filter_fee_group| + next zero_value unless filter_fee_group.first.charge_filter_id + + result = ::Fees::ProjectionService.call!(fees: filter_fee_group) + result.public_send(attribute) + end + end + + def calculate_grouped_projection(attribute) + grouped_fees = object.group_by(&:grouped_by).values + + grouped_fees.sum do |group_fee_list| + result = ::Fees::ProjectionService.call!(fees: group_fee_list) + result.public_send(attribute) + end + end + + def has_grouping? + object.any? { |f| f.grouped_by.present? } + end + + def projection_result + @projection_result ||= ::Fees::ProjectionService.call!(fees: object) + end + end + end + end +end diff --git a/app/graphql/types/customers/usage/projected_charge_filter.rb b/app/graphql/types/customers/usage/projected_charge_filter.rb new file mode 100644 index 0000000..13d7dd7 --- /dev/null +++ b/app/graphql/types/customers/usage/projected_charge_filter.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class ProjectedChargeFilter < Types::BaseObject + graphql_name "ProjectedChargeFilterUsage" + + delegate :projected_units, :projected_amount_cents, to: :projection_result + + field :id, ID, null: true, method: :charge_filter_id + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :events_count, Integer, null: false + field :invoice_display_name, String, null: true + field :pricing_unit_amount_cents, GraphQL::Types::BigInt, null: true + field :pricing_unit_projected_amount_cents, GraphQL::Types::BigInt, null: true + field :projected_amount_cents, GraphQL::Types::BigInt, null: false + field :projected_units, GraphQL::Types::Float, null: false + field :units, GraphQL::Types::Float, null: false + field :values, Types::ChargeFilters::Values, null: false + + def values + object.charge_filter&.to_h || {} # rubocop:disable Lint/RedundantSafeNavigation + end + + def pricing_unit_amount_cents + object.pricing_unit_usage&.amount_cents + end + + def pricing_unit_projected_amount_cents + projection_result.projected_pricing_unit_amount_cents + end + + def invoice_display_name + object.charge_filter&.invoice_display_name + end + + private + + def projection_result + @projection_result ||= ::Fees::ProjectionService.call!(fees: [object]) + end + end + end + end +end diff --git a/app/graphql/types/customers/usage/projected_grouped_usage.rb b/app/graphql/types/customers/usage/projected_grouped_usage.rb new file mode 100644 index 0000000..17b4c1a --- /dev/null +++ b/app/graphql/types/customers/usage/projected_grouped_usage.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module Types + module Customers + module Usage + class ProjectedGroupedUsage < Types::BaseObject + graphql_name "ProjectedGroupedChargeUsage" + + delegate :projected_units, :projected_amount_cents, to: :projection_result + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :events_count, Integer, null: false + field :id, ID, null: false + field :pricing_unit_amount_cents, GraphQL::Types::BigInt, null: true + field :pricing_unit_projected_amount_cents, GraphQL::Types::BigInt, null: true + field :projected_amount_cents, GraphQL::Types::BigInt, null: false + field :projected_units, GraphQL::Types::Float, null: false + field :units, GraphQL::Types::Float, null: false + + field :filters, [Types::Customers::Usage::ProjectedChargeFilter], null: true + field :grouped_by, GraphQL::Types::JSON, null: true + field :presentation_breakdowns, [Types::Customers::Usage::PresentationBreakdown], null: true + + def id + SecureRandom.uuid + end + + def amount_cents + object.sum(&:amount_cents) + end + + def pricing_unit_amount_cents + return if object.first.charge.applied_pricing_unit.nil? + + object.map(&:pricing_unit_usage).sum(&:amount_cents) + end + + def pricing_unit_projected_amount_cents + projection_result.projected_pricing_unit_amount_cents + end + + def events_count + object.sum(&:events_count) + end + + def units + object.map { |f| BigDecimal(f.units) }.sum + end + + def grouped_by + object.first.grouped_by + end + + def filters + return [] unless object.first.has_charge_filters? + + object.sort_by { |f| f.charge_filter&.display_name.to_s } + end + + def presentation_breakdowns + Types::Fees::PresentationBreakdownBuilder.call(object, filter: Types::Fees::PresentationBreakdownBuilder::GROUPED) + end + + private + + def projection_result + @projection_result ||= ::Fees::ProjectionService.call!(fees: object) + end + end + end + end +end diff --git a/app/graphql/types/data_api/metadata.rb b/app/graphql/types/data_api/metadata.rb new file mode 100644 index 0000000..eedce2a --- /dev/null +++ b/app/graphql/types/data_api/metadata.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module DataApi + class Metadata < Types::BaseObject + graphql_name "DataApiMetadata" + + field :current_page, Integer, null: false + field :next_page, Integer, null: false + field :prev_page, Integer, null: false + field :total_count, Integer, null: false + field :total_pages, Integer, null: false + end + end +end diff --git a/app/graphql/types/data_api/mrrs/object.rb b/app/graphql/types/data_api/mrrs/object.rb new file mode 100644 index 0000000..497b8a3 --- /dev/null +++ b/app/graphql/types/data_api/mrrs/object.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module DataApi + module Mrrs + class Object < Types::BaseObject + graphql_name "DataApiMrr" + + field :amount_currency, Types::CurrencyEnum, null: false + + field :ending_mrr, GraphQL::Types::BigInt, null: false + field :starting_mrr, GraphQL::Types::BigInt, null: false + + field :mrr_change, GraphQL::Types::BigInt, null: false + field :mrr_churn, GraphQL::Types::BigInt, null: false + field :mrr_contraction, GraphQL::Types::BigInt, null: false + field :mrr_expansion, GraphQL::Types::BigInt, null: false + field :mrr_new, GraphQL::Types::BigInt, null: false + + field :end_of_period_dt, GraphQL::Types::ISO8601Date, null: false + field :start_of_period_dt, GraphQL::Types::ISO8601Date, null: false + end + end + end +end diff --git a/app/graphql/types/data_api/mrrs/plans/collection.rb b/app/graphql/types/data_api/mrrs/plans/collection.rb new file mode 100644 index 0000000..e69e74a --- /dev/null +++ b/app/graphql/types/data_api/mrrs/plans/collection.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module DataApi + module Mrrs + module Plans + class Collection < Types::BaseObject + graphql_name "DataApiMrrsPlans" + + field :collection, [Types::DataApi::Mrrs::Plans::Object], null: false + + field :metadata, Types::DataApi::Metadata, null: false + end + end + end + end +end diff --git a/app/graphql/types/data_api/mrrs/plans/object.rb b/app/graphql/types/data_api/mrrs/plans/object.rb new file mode 100644 index 0000000..e5f0319 --- /dev/null +++ b/app/graphql/types/data_api/mrrs/plans/object.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module DataApi + module Mrrs + module Plans + class Object < Types::BaseObject + graphql_name "DataApiMrrPlan" + + field :amount_currency, Types::CurrencyEnum, null: false + field :dt, GraphQL::Types::ISO8601Date, null: false + + field :plan_code, String, null: false + field :plan_deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :plan_id, ID, null: false + field :plan_interval, Types::Plans::IntervalEnum, null: false + field :plan_name, String, null: false + + field :active_customers_count, GraphQL::Types::BigInt, null: false + field :active_customers_share, Float, null: false + + field :mrr, Float, null: false + field :mrr_share, Float, null: true + end + end + end + end +end diff --git a/app/graphql/types/data_api/prepaid_credits/object.rb b/app/graphql/types/data_api/prepaid_credits/object.rb new file mode 100644 index 0000000..89fa443 --- /dev/null +++ b/app/graphql/types/data_api/prepaid_credits/object.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module DataApi + module PrepaidCredits + class Object < Types::BaseObject + graphql_name "DataApiPrepaidCredit" + + field :amount_currency, Types::CurrencyEnum, null: false + + field :consumed_amount, Float, null: false + field :offered_amount, Float, null: false + field :purchased_amount, Float, null: false + field :voided_amount, Float, null: false + + field :consumed_credits_quantity, Float, null: false + field :offered_credits_quantity, Float, null: false + field :purchased_credits_quantity, Float, null: false + field :voided_credits_quantity, Float, null: false + + field :end_of_period_dt, GraphQL::Types::ISO8601Date, null: false + field :start_of_period_dt, GraphQL::Types::ISO8601Date, null: false + end + end + end +end diff --git a/app/graphql/types/data_api/revenue_streams/customers/collection.rb b/app/graphql/types/data_api/revenue_streams/customers/collection.rb new file mode 100644 index 0000000..4940708 --- /dev/null +++ b/app/graphql/types/data_api/revenue_streams/customers/collection.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module DataApi + module RevenueStreams + module Customers + class Collection < Types::BaseObject + graphql_name "DataApiRevenueStreamsCustomers" + + field :collection, [Types::DataApi::RevenueStreams::Customers::Object], null: false + + field :metadata, Types::DataApi::Metadata, null: false + end + end + end + end +end diff --git a/app/graphql/types/data_api/revenue_streams/customers/object.rb b/app/graphql/types/data_api/revenue_streams/customers/object.rb new file mode 100644 index 0000000..67679c4 --- /dev/null +++ b/app/graphql/types/data_api/revenue_streams/customers/object.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module DataApi + module RevenueStreams + module Customers + class Object < Types::BaseObject + graphql_name "DataApiRevenueStreamCustomer" + + field :customer_deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :customer_id, ID, null: false + field :customer_name, String, null: true + field :external_customer_id, String, null: false + + field :amount_currency, Types::CurrencyEnum, null: false + field :gross_revenue_amount_cents, GraphQL::Types::BigInt, null: false + field :gross_revenue_share, Float, null: true + field :net_revenue_amount_cents, GraphQL::Types::BigInt, null: false + field :net_revenue_share, Float, null: true + end + end + end + end +end diff --git a/app/graphql/types/data_api/revenue_streams/object.rb b/app/graphql/types/data_api/revenue_streams/object.rb new file mode 100644 index 0000000..5df7c9f --- /dev/null +++ b/app/graphql/types/data_api/revenue_streams/object.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module DataApi + module RevenueStreams + class Object < Types::BaseObject + graphql_name "DataApiRevenueStream" + + field :amount_currency, Types::CurrencyEnum, null: false + + field :coupons_amount_cents, GraphQL::Types::BigInt, null: false + field :gross_revenue_amount_cents, GraphQL::Types::BigInt, null: false + field :net_revenue_amount_cents, GraphQL::Types::BigInt, null: false + + field :commitment_fee_amount_cents, GraphQL::Types::BigInt, null: false + field :one_off_fee_amount_cents, GraphQL::Types::BigInt, null: false + field :subscription_fee_amount_cents, GraphQL::Types::BigInt, null: false + field :usage_based_fee_amount_cents, GraphQL::Types::BigInt, null: false + + field :contra_revenue_amount_cents, GraphQL::Types::BigInt + field :credit_notes_credits_amount_cents, GraphQL::Types::BigInt + field :free_credits_amount_cents, GraphQL::Types::BigInt + field :prepaid_credits_amount_cents, GraphQL::Types::BigInt + field :progressive_billing_credit_amount_cents, GraphQL::Types::BigInt + + field :end_of_period_dt, GraphQL::Types::ISO8601Date, null: false + field :start_of_period_dt, GraphQL::Types::ISO8601Date, null: false + end + end + end +end diff --git a/app/graphql/types/data_api/revenue_streams/order_by_enum.rb b/app/graphql/types/data_api/revenue_streams/order_by_enum.rb new file mode 100644 index 0000000..745cfe8 --- /dev/null +++ b/app/graphql/types/data_api/revenue_streams/order_by_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module DataApi + module RevenueStreams + class OrderByEnum < Types::BaseEnum + value :gross_revenue_amount_cents + value :net_revenue_amount_cents + end + end + end +end diff --git a/app/graphql/types/data_api/revenue_streams/plans/collection.rb b/app/graphql/types/data_api/revenue_streams/plans/collection.rb new file mode 100644 index 0000000..da042e7 --- /dev/null +++ b/app/graphql/types/data_api/revenue_streams/plans/collection.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module DataApi + module RevenueStreams + module Plans + class Collection < Types::BaseObject + graphql_name "DataApiRevenueStreamsPlans" + + field :collection, [Types::DataApi::RevenueStreams::Plans::Object], null: false + + field :metadata, Types::DataApi::Metadata, null: false + end + end + end + end +end diff --git a/app/graphql/types/data_api/revenue_streams/plans/object.rb b/app/graphql/types/data_api/revenue_streams/plans/object.rb new file mode 100644 index 0000000..28f8949 --- /dev/null +++ b/app/graphql/types/data_api/revenue_streams/plans/object.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module DataApi + module RevenueStreams + module Plans + class Object < Types::BaseObject + graphql_name "DataApiRevenueStreamPlan" + + field :plan_code, String, null: false + field :plan_deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :plan_id, ID, null: false + field :plan_interval, Types::Plans::IntervalEnum, null: false + field :plan_name, String, null: false + + field :customers_count, Integer, null: false + field :customers_share, Float, null: false + + field :amount_currency, Types::CurrencyEnum, null: false + field :gross_revenue_amount_cents, GraphQL::Types::BigInt, null: false + field :gross_revenue_share, Float, null: true + field :net_revenue_amount_cents, GraphQL::Types::BigInt, null: false + field :net_revenue_share, Float, null: true + end + end + end + end +end diff --git a/app/graphql/types/data_api/time_granularity_enum.rb b/app/graphql/types/data_api/time_granularity_enum.rb new file mode 100644 index 0000000..6cd6012 --- /dev/null +++ b/app/graphql/types/data_api/time_granularity_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module DataApi + class TimeGranularityEnum < Types::BaseEnum + value :daily + value :weekly + value :monthly + end + end +end diff --git a/app/graphql/types/data_api/usages/aggregated_amounts/object.rb b/app/graphql/types/data_api/usages/aggregated_amounts/object.rb new file mode 100644 index 0000000..a30e6b2 --- /dev/null +++ b/app/graphql/types/data_api/usages/aggregated_amounts/object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module DataApi + module Usages + module AggregatedAmounts + class Object < Types::BaseObject + graphql_name "DataApiUsageAggregatedAmount" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :end_of_period_dt, GraphQL::Types::ISO8601Date, null: false + field :start_of_period_dt, GraphQL::Types::ISO8601Date, null: false + end + end + end + end +end diff --git a/app/graphql/types/data_api/usages/forecasted/object.rb b/app/graphql/types/data_api/usages/forecasted/object.rb new file mode 100644 index 0000000..2df8565 --- /dev/null +++ b/app/graphql/types/data_api/usages/forecasted/object.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module DataApi + module Usages + module Forecasted + class Object < Types::BaseObject + graphql_name "DataApiUsageForecasted" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_cents_forecast_conservative, GraphQL::Types::BigInt, null: false + field :amount_cents_forecast_optimistic, GraphQL::Types::BigInt, null: false + field :amount_cents_forecast_realistic, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :units, Float, null: false + field :units_forecast_conservative, Float, null: false + field :units_forecast_optimistic, Float, null: false + field :units_forecast_realistic, Float, null: false + + field :end_of_period_dt, GraphQL::Types::ISO8601Date, null: false + field :start_of_period_dt, GraphQL::Types::ISO8601Date, null: false + end + end + end + end +end diff --git a/app/graphql/types/data_api/usages/invoiced/object.rb b/app/graphql/types/data_api/usages/invoiced/object.rb new file mode 100644 index 0000000..e02dde7 --- /dev/null +++ b/app/graphql/types/data_api/usages/invoiced/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module DataApi + module Usages + module Invoiced + class Object < Types::BaseObject + graphql_name "DataApiUsageInvoiced" + + field :end_of_period_dt, GraphQL::Types::ISO8601Date, null: false + field :start_of_period_dt, GraphQL::Types::ISO8601Date, null: false + + field :billable_metric_code, String, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + end + end + end + end +end diff --git a/app/graphql/types/data_api/usages/object.rb b/app/graphql/types/data_api/usages/object.rb new file mode 100644 index 0000000..34b87b5 --- /dev/null +++ b/app/graphql/types/data_api/usages/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module DataApi + module Usages + class Object < Types::BaseObject + graphql_name "DataApiUsage" + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + field :billable_metric_code, String, null: false + field :units, Float, null: false + + field :is_billable_metric_deleted, Boolean, null: false + + field :end_of_period_dt, GraphQL::Types::ISO8601Date, null: false + field :start_of_period_dt, GraphQL::Types::ISO8601Date, null: false + end + end + end +end diff --git a/app/graphql/types/data_exports/credit_notes/create_input.rb b/app/graphql/types/data_exports/credit_notes/create_input.rb new file mode 100644 index 0000000..870880f --- /dev/null +++ b/app/graphql/types/data_exports/credit_notes/create_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module DataExports + module CreditNotes + class CreateInput < Types::BaseInputObject + graphql_name "CreateDataExportsCreditNotesInput" + + argument :filters, Types::DataExports::CreditNotes::FiltersInput + argument :format, Types::DataExports::FormatTypeEnum + argument :resource_type, Types::DataExports::CreditNotes::ExportTypeEnum + end + end + end +end diff --git a/app/graphql/types/data_exports/credit_notes/export_type_enum.rb b/app/graphql/types/data_exports/credit_notes/export_type_enum.rb new file mode 100644 index 0000000..b414123 --- /dev/null +++ b/app/graphql/types/data_exports/credit_notes/export_type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module DataExports + module CreditNotes + class ExportTypeEnum < Types::BaseEnum + graphql_name "CreditNoteExportTypeEnum" + + value "credit_notes" + value "credit_note_items" + end + end + end +end diff --git a/app/graphql/types/data_exports/credit_notes/filters_input.rb b/app/graphql/types/data_exports/credit_notes/filters_input.rb new file mode 100644 index 0000000..98fc5d5 --- /dev/null +++ b/app/graphql/types/data_exports/credit_notes/filters_input.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module DataExports + module CreditNotes + class FiltersInput < BaseInputObject + graphql_name "DataExportCreditNoteFiltersInput" + description "Export credit notes search query and filters input argument" + + argument :amount_from, Integer, required: false + argument :amount_to, Integer, required: false + argument :billing_entity_ids, [ID], required: false + argument :credit_status, [Types::CreditNotes::CreditStatusTypeEnum], required: false + argument :currency, Types::CurrencyEnum, required: false + argument :customer_external_id, String, required: false + argument :customer_id, ID, required: false, description: "Uniq ID of the customer" + argument :invoice_number, String, required: false + argument :issuing_date_from, GraphQL::Types::ISO8601Date, required: false + argument :issuing_date_to, GraphQL::Types::ISO8601Date, required: false + argument :reason, [Types::CreditNotes::ReasonTypeEnum], required: false + argument :refund_status, [Types::CreditNotes::RefundStatusTypeEnum], required: false + argument :search_term, String, required: false + argument :self_billed, Boolean, required: false + argument :types, [Types::CreditNotes::TypeEnum], required: false + end + end + end +end diff --git a/app/graphql/types/data_exports/format_type_enum.rb b/app/graphql/types/data_exports/format_type_enum.rb new file mode 100644 index 0000000..3e2cd8f --- /dev/null +++ b/app/graphql/types/data_exports/format_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module DataExports + class FormatTypeEnum < Types::BaseEnum + graphql_name "DataExportFormatTypeEnum" + + DataExport::EXPORT_FORMATS.each do |format| + value format + end + end + end +end diff --git a/app/graphql/types/data_exports/invoices/create_input.rb b/app/graphql/types/data_exports/invoices/create_input.rb new file mode 100644 index 0000000..1578bc6 --- /dev/null +++ b/app/graphql/types/data_exports/invoices/create_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module DataExports + module Invoices + class CreateInput < Types::BaseInputObject + graphql_name "CreateDataExportsInvoicesInput" + + argument :filters, Types::DataExports::Invoices::FiltersInput + argument :format, Types::DataExports::FormatTypeEnum + argument :resource_type, Types::DataExports::Invoices::ExportTypeEnum + end + end + end +end diff --git a/app/graphql/types/data_exports/invoices/export_type_enum.rb b/app/graphql/types/data_exports/invoices/export_type_enum.rb new file mode 100644 index 0000000..ee30f77 --- /dev/null +++ b/app/graphql/types/data_exports/invoices/export_type_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module DataExports + module Invoices + class ExportTypeEnum < Types::BaseEnum + graphql_name "InvoiceExportTypeEnum" + + value "invoices" + value "invoice_fees" + end + end + end +end diff --git a/app/graphql/types/data_exports/invoices/filters_input.rb b/app/graphql/types/data_exports/invoices/filters_input.rb new file mode 100644 index 0000000..6c5b8ce --- /dev/null +++ b/app/graphql/types/data_exports/invoices/filters_input.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module DataExports + module Invoices + class FiltersInput < BaseInputObject + graphql_name "DataExportInvoiceFiltersInput" + description "Export Invoices search query and filters input argument" + + argument :amount_from, Integer, required: false + argument :amount_to, Integer, required: false + argument :billing_entity_ids, [ID], required: false + argument :currency, Types::CurrencyEnum, required: false + argument :customer_external_id, String, required: false + argument :invoice_type, [Types::Invoices::InvoiceTypeEnum], required: false + argument :issuing_date_from, GraphQL::Types::ISO8601Date, required: false + argument :issuing_date_to, GraphQL::Types::ISO8601Date, required: false + argument :payment_dispute_lost, Boolean, required: false + argument :payment_overdue, Boolean, required: false + argument :payment_status, [Types::Invoices::PaymentStatusTypeEnum], required: false + argument :search_term, String, required: false + argument :self_billed, Boolean, required: false + argument :status, [Types::Invoices::StatusTypeEnum], required: false + end + end + end +end diff --git a/app/graphql/types/data_exports/object.rb b/app/graphql/types/data_exports/object.rb new file mode 100644 index 0000000..c65dca2 --- /dev/null +++ b/app/graphql/types/data_exports/object.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module DataExports + class Object < Types::BaseObject + graphql_name "DataExport" + + field :id, ID, null: false + field :status, Types::DataExports::StatusEnum, null: false + end + end +end diff --git a/app/graphql/types/data_exports/status_enum.rb b/app/graphql/types/data_exports/status_enum.rb new file mode 100644 index 0000000..bf2665a --- /dev/null +++ b/app/graphql/types/data_exports/status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module DataExports + class StatusEnum < Types::BaseEnum + graphql_name "DataExportStatusEnum" + + DataExport::STATUSES.each do |status| + value status + end + end + end +end diff --git a/app/graphql/types/dunning_campaign_thresholds/input.rb b/app/graphql/types/dunning_campaign_thresholds/input.rb new file mode 100644 index 0000000..2ebe027 --- /dev/null +++ b/app/graphql/types/dunning_campaign_thresholds/input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module DunningCampaignThresholds + class Input < Types::BaseInputObject + graphql_name "DunningCampaignThresholdInput" + + argument :id, ID, required: false + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :currency, Types::CurrencyEnum, required: true + end + end +end diff --git a/app/graphql/types/dunning_campaign_thresholds/object.rb b/app/graphql/types/dunning_campaign_thresholds/object.rb new file mode 100644 index 0000000..0da1403 --- /dev/null +++ b/app/graphql/types/dunning_campaign_thresholds/object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module DunningCampaignThresholds + class Object < Types::BaseObject + graphql_name "DunningCampaignThreshold" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :currency, Types::CurrencyEnum, null: false + end + end +end diff --git a/app/graphql/types/dunning_campaigns/create_input.rb b/app/graphql/types/dunning_campaigns/create_input.rb new file mode 100644 index 0000000..736bf44 --- /dev/null +++ b/app/graphql/types/dunning_campaigns/create_input.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module DunningCampaigns + class CreateInput < Types::BaseInputObject + graphql_name "CreateDunningCampaignInput" + + argument :applied_to_organization, Boolean, required: true + argument :bcc_emails, [String], required: false + argument :code, String, required: true + argument :days_between_attempts, Integer, required: true + argument :max_attempts, Integer, required: true + argument :name, String, required: true + argument :thresholds, [Types::DunningCampaignThresholds::Input], required: true + + argument :description, String, required: false + end + end +end diff --git a/app/graphql/types/dunning_campaigns/object.rb b/app/graphql/types/dunning_campaigns/object.rb new file mode 100644 index 0000000..3f79fa9 --- /dev/null +++ b/app/graphql/types/dunning_campaigns/object.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Types + module DunningCampaigns + class Object < Types::BaseObject + graphql_name "DunningCampaign" + + field :id, ID, null: false + + field :applied_to_organization, Boolean, null: false + field :bcc_emails, [String], null: true + field :code, String, null: false + field :customers_count, Integer, null: false + field :days_between_attempts, Integer, null: false + field :max_attempts, Integer, null: false + field :name, String, null: false + field :thresholds, [Types::DunningCampaignThresholds::Object], null: false + + field :description, String, null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + def applied_to_organization + object.organization.default_billing_entity.applied_dunning_campaign_id == object.id + end + + # rubocop:disable GraphQL/ResolverMethodLength + def customers_count + Customer.where( + <<~SQL.squish, + exclude_from_dunning_campaign = false + AND ( + applied_dunning_campaign_id = :campaign_id + OR ( + applied_dunning_campaign_id IS NULL + AND billing_entity_id IN (:billing_entity_ids) + ) + ) + SQL + campaign_id: object.id, + billing_entity_ids: object.billing_entities.ids + ).count + end + # rubocop:enable GraphQL/ResolverMethodLength + end + end +end diff --git a/app/graphql/types/dunning_campaigns/update_input.rb b/app/graphql/types/dunning_campaigns/update_input.rb new file mode 100644 index 0000000..e2d8acf --- /dev/null +++ b/app/graphql/types/dunning_campaigns/update_input.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module DunningCampaigns + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateDunningCampaignInput" + + argument :id, ID, required: true + + argument :applied_to_organization, Boolean, required: false + argument :bcc_emails, [String], required: false + argument :code, String, required: false + argument :days_between_attempts, Integer, required: false + argument :description, String, required: false + argument :max_attempts, Integer, required: false + argument :name, String, required: false + argument :thresholds, [Types::DunningCampaignThresholds::Input], required: false + end + end +end diff --git a/app/graphql/types/emails/resend_email_input.rb b/app/graphql/types/emails/resend_email_input.rb new file mode 100644 index 0000000..d10b961 --- /dev/null +++ b/app/graphql/types/emails/resend_email_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Emails + class ResendEmailInput < Types::BaseInputObject + description "Resend email input arguments" + + argument :bcc, [String], required: false, description: "BCC recipients" + argument :cc, [String], required: false, description: "CC recipients" + argument :id, ID, required: true, description: "Document ID" + argument :to, [String], required: false, description: "Custom recipients (defaults to customer email)" + end + end +end diff --git a/app/graphql/types/entitlement/create_feature_input.rb b/app/graphql/types/entitlement/create_feature_input.rb new file mode 100644 index 0000000..f906199 --- /dev/null +++ b/app/graphql/types/entitlement/create_feature_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class CreateFeatureInput < Types::BaseInputObject + description "Input for creating a feature" + + argument :code, String, required: true, description: "The code of the feature" + argument :description, String, required: false, description: "The description of the feature" + argument :name, String, required: false, description: "The name of the feature" + argument :privileges, [UpdatePrivilegeInput], required: true, description: "The privileges configuration" + end + end +end diff --git a/app/graphql/types/entitlement/entitlement_input.rb b/app/graphql/types/entitlement/entitlement_input.rb new file mode 100644 index 0000000..af93254 --- /dev/null +++ b/app/graphql/types/entitlement/entitlement_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class EntitlementInput < Types::BaseInputObject + description "Input for updating a plan entitlement" + + argument :feature_code, String, required: true + argument :privileges, [EntitlementPrivilegeInput], required: false, description: "The privileges configuration" + end + end +end diff --git a/app/graphql/types/entitlement/entitlement_privilege_input.rb b/app/graphql/types/entitlement/entitlement_privilege_input.rb new file mode 100644 index 0000000..d2942bd --- /dev/null +++ b/app/graphql/types/entitlement/entitlement_privilege_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class EntitlementPrivilegeInput < Types::BaseInputObject + description "Input for updating a plan entitlement privilege value" + + argument :privilege_code, String, required: true + argument :value, String, required: true + end + end +end diff --git a/app/graphql/types/entitlement/feature_object.rb b/app/graphql/types/entitlement/feature_object.rb new file mode 100644 index 0000000..85f2d9c --- /dev/null +++ b/app/graphql/types/entitlement/feature_object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class FeatureObject < Types::BaseObject + field :id, ID, null: false + + field :code, String, null: false + field :description, String, null: true + field :name, String, null: true + + field :privileges, [Types::Entitlement::PrivilegeObject], null: false + + field :subscriptions_count, Integer, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/entitlement/plan_entitlement_object.rb b/app/graphql/types/entitlement/plan_entitlement_object.rb new file mode 100644 index 0000000..6a605db --- /dev/null +++ b/app/graphql/types/entitlement/plan_entitlement_object.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class PlanEntitlementObject < Types::BaseObject + graphql_name "PlanEntitlement" + + field :code, String, null: false + field :description, String, null: true + field :name, String, null: false + field :privileges, [PlanEntitlementPrivilegeObject], null: false + + def code + object.feature.code + end + + def name + object.feature.name + end + + def description + object.feature.description + end + + def privileges + object.values.order(:created_at) + end + end + end +end diff --git a/app/graphql/types/entitlement/plan_entitlement_privilege_object.rb b/app/graphql/types/entitlement/plan_entitlement_privilege_object.rb new file mode 100644 index 0000000..91223d4 --- /dev/null +++ b/app/graphql/types/entitlement/plan_entitlement_privilege_object.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class PlanEntitlementPrivilegeObject < Types::BaseObject + field :code, String, null: false + field :config, Types::Entitlement::PrivilegeConfigObject, null: false + field :name, String, null: true + field :value_type, Types::Entitlement::PrivilegeValueTypeEnum, null: false + + field :value, String, null: false + + def code + object.privilege.code + end + + def config + object.privilege.config + end + + def name + object.privilege.name + end + + def value_type + object.privilege.value_type + end + + def value + # NOTE: If the boolean `true`/`false` were sent to via the API, ActiveRecord will store them as `"t"`/`"f"` + # We convert them to full words, as string, for the frontent + if object.privilege.value_type == "boolean" + return "false" if object.value == "f" + return "true" if object.value == "t" + end + + object.value + end + end + end +end diff --git a/app/graphql/types/entitlement/privilege_config_input.rb b/app/graphql/types/entitlement/privilege_config_input.rb new file mode 100644 index 0000000..e4ed3a8 --- /dev/null +++ b/app/graphql/types/entitlement/privilege_config_input.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class PrivilegeConfigInput < Types::BaseInputObject + description "Input for privilege configuration" + + argument :select_options, [String], required: false, description: "Available options for select type privileges" + end + end +end diff --git a/app/graphql/types/entitlement/privilege_config_object.rb b/app/graphql/types/entitlement/privilege_config_object.rb new file mode 100644 index 0000000..92cfeb7 --- /dev/null +++ b/app/graphql/types/entitlement/privilege_config_object.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class PrivilegeConfigObject < Types::BaseObject + description "Configuration object for privileges" + + field :select_options, [String], null: true, description: "Available options for select type privileges" + end + end +end diff --git a/app/graphql/types/entitlement/privilege_object.rb b/app/graphql/types/entitlement/privilege_object.rb new file mode 100644 index 0000000..f54e596 --- /dev/null +++ b/app/graphql/types/entitlement/privilege_object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class PrivilegeObject < Types::BaseObject + field :id, ID, null: false + + field :code, String, null: false + field :config, Types::Entitlement::PrivilegeConfigObject, null: false + field :name, String, null: true + field :value_type, Types::Entitlement::PrivilegeValueTypeEnum, null: false + end + end +end diff --git a/app/graphql/types/entitlement/privilege_value_type_enum.rb b/app/graphql/types/entitlement/privilege_value_type_enum.rb new file mode 100644 index 0000000..1f28b97 --- /dev/null +++ b/app/graphql/types/entitlement/privilege_value_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class PrivilegeValueTypeEnum < Types::BaseEnum + ::Entitlement::Privilege::VALUE_TYPES.each do |value_type| + value value_type + end + end + end +end diff --git a/app/graphql/types/entitlement/subscription_entitlement_object.rb b/app/graphql/types/entitlement/subscription_entitlement_object.rb new file mode 100644 index 0000000..6c046ac --- /dev/null +++ b/app/graphql/types/entitlement/subscription_entitlement_object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class SubscriptionEntitlementObject < Types::BaseObject + graphql_name "SubscriptionEntitlement" + + field :code, String, null: false + field :description, String, null: true + field :name, String, null: false + field :privileges, [SubscriptionEntitlementPrivilegeObject], null: false + end + end +end diff --git a/app/graphql/types/entitlement/subscription_entitlement_privilege_object.rb b/app/graphql/types/entitlement/subscription_entitlement_privilege_object.rb new file mode 100644 index 0000000..756f84e --- /dev/null +++ b/app/graphql/types/entitlement/subscription_entitlement_privilege_object.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class SubscriptionEntitlementPrivilegeObject < Types::BaseObject + field :code, String, null: false + field :config, Types::Entitlement::PrivilegeConfigObject, null: false + field :name, String, null: true + field :value_type, Types::Entitlement::PrivilegeValueTypeEnum, null: false + + field :value, String, null: true + + def value + # NOTE: If the boolean `true`/`false` were sent to via the API, ActiveRecord will store them as `"t"`/`"f"` + # We convert them to full words, as string, for the frontent + if object.value_type == "boolean" + return "false" if object.value == "f" + return "true" if object.value == "t" + end + + object.value + end + end + end +end diff --git a/app/graphql/types/entitlement/update_feature_input.rb b/app/graphql/types/entitlement/update_feature_input.rb new file mode 100644 index 0000000..a8cf49e --- /dev/null +++ b/app/graphql/types/entitlement/update_feature_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class UpdateFeatureInput < Types::BaseInputObject + description "Input for updating a feature" + + argument :id, ID, required: true, description: "The ID of the feature to update" + + argument :description, String, required: false, description: "The description of the feature" + argument :name, String, required: false, description: "The name of the feature" + argument :privileges, [UpdatePrivilegeInput], required: true, description: "The privileges configuration" + end + end +end diff --git a/app/graphql/types/entitlement/update_privilege_input.rb b/app/graphql/types/entitlement/update_privilege_input.rb new file mode 100644 index 0000000..47ba363 --- /dev/null +++ b/app/graphql/types/entitlement/update_privilege_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Entitlement + class UpdatePrivilegeInput < Types::BaseInputObject + description "Input for updating a privilege" + + argument :code, String, required: true + argument :config, Types::Entitlement::PrivilegeConfigInput, required: false + argument :name, String, required: false + argument :value_type, PrivilegeValueTypeEnum, required: false + end + end +end diff --git a/app/graphql/types/error_details/error_codes_enum.rb b/app/graphql/types/error_details/error_codes_enum.rb new file mode 100644 index 0000000..ddd593a --- /dev/null +++ b/app/graphql/types/error_details/error_codes_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module ErrorDetails + class ErrorCodesEnum < Types::BaseEnum + ErrorDetail::ERROR_CODES.keys.each do |code| + value code + end + end + end +end diff --git a/app/graphql/types/error_details/object.rb b/app/graphql/types/error_details/object.rb new file mode 100644 index 0000000..e3c1e1b --- /dev/null +++ b/app/graphql/types/error_details/object.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module ErrorDetails + class Object < Types::BaseObject + graphql_name "ErrorDetail" + + field :error_code, Types::ErrorDetails::ErrorCodesEnum, null: false + field :error_details, String, null: true + field :id, ID, null: false + + def error_details + object.details[object.error_code] + end + end + end +end diff --git a/app/graphql/types/events/object.rb b/app/graphql/types/events/object.rb new file mode 100644 index 0000000..674886b --- /dev/null +++ b/app/graphql/types/events/object.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Types + module Events + class Object < Types::BaseObject + graphql_name "Event" + + field :code, String, null: false + field :id, ID, null: false + + field :external_subscription_id, String, null: true + field :transaction_id, String, null: true + + field :customer_timezone, Types::TimezoneEnum, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :received_at, GraphQL::Types::ISO8601DateTime, null: true, method: :created_at + field :timestamp, GraphQL::Types::ISO8601DateTime, null: true + + field :api_client, String, null: true + field :ip_address, String, null: true + + field :billable_metric_name, String, null: true + field :payload, GraphQL::Types::JSON, null: false + + field :match_billable_metric, Boolean, null: true + field :match_custom_field, Boolean, null: true + field :match_customer, Boolean, null: true + field :match_subscription, Boolean, null: true + + def payload + { + event: { + transaction_id: object.transaction_id, + external_subscription_id: object.external_subscription_id, + code: object.code, + timestamp: object.timestamp.to_i, + properties: object.properties || {} + } + } + end + + def match_billable_metric + object.billable_metric.present? + end + + def match_custom_field + return true if object.billable_metric.blank? + return true if object.billable_metric.field_name.blank? + + object.properties.key?(object.billable_metric.field_name) + end + + def customer_timezone + object.customer&.applicable_timezone || object.organization.timezone || "UTC" + end + + def billable_metric_name + object.billable_metric&.name + end + + def match_customer + object.customer_id.present? + end + + def match_subscription + object.subscription_id.present? + end + end + end +end diff --git a/app/graphql/types/fees/amount_details/graduated_percentage_range.rb b/app/graphql/types/fees/amount_details/graduated_percentage_range.rb new file mode 100644 index 0000000..ac0d4ba --- /dev/null +++ b/app/graphql/types/fees/amount_details/graduated_percentage_range.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Fees + module AmountDetails + class GraduatedPercentageRange < Types::BaseObject + graphql_name "FeeAmountDetailsGraduatedPercentageRange" + + field :flat_unit_amount, String, null: true + field :from_value, GraphQL::Types::BigInt, null: true + field :per_unit_total_amount, String, null: true + field :rate, String, null: true + field :to_value, GraphQL::Types::BigInt, null: true + field :total_with_flat_amount, String, null: true + field :units, String, null: true + end + end + end +end diff --git a/app/graphql/types/fees/amount_details/graduated_range.rb b/app/graphql/types/fees/amount_details/graduated_range.rb new file mode 100644 index 0000000..4af0c71 --- /dev/null +++ b/app/graphql/types/fees/amount_details/graduated_range.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Fees + module AmountDetails + class GraduatedRange < Types::BaseObject + graphql_name "FeeAmountDetailsGraduatedRange" + + field :flat_unit_amount, String, null: true + field :from_value, Float, null: true + field :per_unit_amount, String, null: true + field :per_unit_total_amount, String, null: true + field :to_value, Float, null: true + field :total_with_flat_amount, String, null: true + field :units, String, null: true + end + end + end +end diff --git a/app/graphql/types/fees/amount_details/object.rb b/app/graphql/types/fees/amount_details/object.rb new file mode 100644 index 0000000..4c23d14 --- /dev/null +++ b/app/graphql/types/fees/amount_details/object.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module Fees + module AmountDetails + class Object < Types::BaseObject + graphql_name "FeeAmountDetails" + + # NOTE: Graduated charge model + field :graduated_ranges, [Types::Fees::AmountDetails::GraduatedRange], null: true + + # NOTE: Graduated percentage modle + field :graduated_percentage_ranges, [Types::Fees::AmountDetails::GraduatedPercentageRange], null: true + + # NOTE: Package charge model + field :per_package_size, Integer, null: true + field :per_package_unit_amount, String, null: true + + # NOTE: Percentage charge model + field :fixed_fee_total_amount, String, null: true + field :fixed_fee_unit_amount, String, null: true + field :free_events, Integer, null: true + field :min_max_adjustment_total_amount, String, null: true + field :paid_events, Integer, null: true + field :rate, String, null: true + field :units, String, null: true + + # NOTE: Volume charge model + field :flat_unit_amount, String, null: true + field :per_unit_amount, String, null: true + + # NOTE: Percentage & Volume charge model + field :per_unit_total_amount, String, null: true + + # NOTE: Package & Percentage charge model + field :free_units, String, null: true + field :paid_units, String, null: true + end + end + end +end diff --git a/app/graphql/types/fees/applied_taxes/object.rb b/app/graphql/types/fees/applied_taxes/object.rb new file mode 100644 index 0000000..424fb80 --- /dev/null +++ b/app/graphql/types/fees/applied_taxes/object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Fees + module AppliedTaxes + class Object < Types::BaseObject + graphql_name "FeeAppliedTax" + implements Types::Taxes::AppliedTax + + field :fee, Types::Fees::Object, null: false + end + end + end +end diff --git a/app/graphql/types/fees/object.rb b/app/graphql/types/fees/object.rb new file mode 100644 index 0000000..1bd86f1 --- /dev/null +++ b/app/graphql/types/fees/object.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Types + module Fees + class Object < Types::BaseObject + graphql_name "Fee" + implements Types::Invoices::InvoiceItem + + field :id, ID, null: false + + field :add_on, Types::AddOns::Object, null: true + field :charge, Types::Charges::Object, null: true + field :currency, Types::CurrencyEnum, null: false + field :description, String, null: true + field :fixed_charge, Types::FixedCharges::Object, null: true + field :grouped_by, GraphQL::Types::JSON, null: false + field :invoice_display_name, String, null: true + field :invoice_id, ID, null: true + field :invoice_name, String, null: true + field :subscription, Types::Subscriptions::Object, null: true + field :true_up_fee, Types::Fees::Object, null: true + field :true_up_parent_fee, Types::Fees::Object, null: true + field :wallet_transaction, Types::WalletTransactions::Object, null: true + + field :creditable_amount_cents, GraphQL::Types::BigInt, null: false + field :events_count, GraphQL::Types::BigInt, null: true + field :fee_type, Types::Fees::TypesEnum, null: false + field :offsettable_amount_cents, GraphQL::Types::BigInt, null: false + field :precise_unit_amount, GraphQL::Types::Float, null: false + field :succeeded_at, GraphQL::Types::ISO8601DateTime, null: true + field :taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :taxes_rate, GraphQL::Types::Float, null: true + field :units, GraphQL::Types::Float, null: false + + field :applied_taxes, [Types::Fees::AppliedTaxes::Object] + + field :amount_details, Types::Fees::AmountDetails::Object, null: true + + field :adjusted_fee, Boolean, null: false + field :adjusted_fee_type, Types::AdjustedFees::AdjustedFeeTypeEnum, null: true + + field :charge_filter, Types::ChargeFilters::Object, null: true + field :presentation_breakdowns, [Types::Customers::Usage::PresentationBreakdown], null: true + field :pricing_unit_usage, Types::PricingUnitUsages::Object, null: true + field :properties, Types::Fees::Properties, null: true, method: :itself + + def wallet_transaction + object.invoiceable if object.credit? + end + + def item_type + object.fee_type + end + + def applied_taxes + if object.applied_taxes.any? { |t| !t.persisted? } + object.applied_taxes.sort_by { |tax| -tax.tax_rate.to_f } + else + object.applied_taxes.order(tax_rate: :desc) + end + end + + def adjusted_fee + object.adjusted_fee.present? + end + + def adjusted_fee_type + return nil if object.adjusted_fee.blank? + return nil if object.adjusted_fee.adjusted_display_name? + + object.adjusted_fee.adjusted_units? ? "adjusted_units" : "adjusted_amount" + end + + def presentation_breakdowns + Types::Fees::PresentationBreakdownBuilder.call([object], filter: Types::Fees::PresentationBreakdownBuilder::ALL) + end + end + end +end diff --git a/app/graphql/types/fees/presentation_breakdown_builder.rb b/app/graphql/types/fees/presentation_breakdown_builder.rb new file mode 100644 index 0000000..2bea41f --- /dev/null +++ b/app/graphql/types/fees/presentation_breakdown_builder.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Types + module Fees + class PresentationBreakdownBuilder + ALL = :all + UNGROUPED = :ungrouped + GROUPED = :grouped + + def self.call(fees, filter:) + new(fees, filter:).call + end + + def initialize(fees, filter:) + @fees = fees + @filter = filter + end + + def call + Array(fees).flat_map do |fee| + next [] if filter == UNGROUPED && fee.grouped_by.present? + next [] if filter == GROUPED && fee.grouped_by.blank? + + fee.presentation_breakdowns.map do |breakdown| + { + presentation_by: breakdown.presentation_by, + units: breakdown.units.to_s + } + end + end + end + + private + + attr_reader :fees, :filter + end + end +end diff --git a/app/graphql/types/fees/properties.rb b/app/graphql/types/fees/properties.rb new file mode 100644 index 0000000..2414d37 --- /dev/null +++ b/app/graphql/types/fees/properties.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Fees + class Properties < Types::BaseObject + graphql_name "FeeProperties" + + field :from_datetime, GraphQL::Types::ISO8601DateTime, null: true + field :to_datetime, GraphQL::Types::ISO8601DateTime, null: true + + def from_datetime + object.date_boundaries[:from_date] + end + + def to_datetime + object.date_boundaries[:to_date] + end + end + end +end diff --git a/app/graphql/types/fees/types_enum.rb b/app/graphql/types/fees/types_enum.rb new file mode 100644 index 0000000..4f7de17 --- /dev/null +++ b/app/graphql/types/fees/types_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Fees + class TypesEnum < Types::BaseEnum + graphql_name "FeeTypesEnum" + + Fee::FEE_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/fixed_charges/charge_model_enum.rb b/app/graphql/types/fixed_charges/charge_model_enum.rb new file mode 100644 index 0000000..124ebd5 --- /dev/null +++ b/app/graphql/types/fixed_charges/charge_model_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module FixedCharges + class ChargeModelEnum < Types::BaseEnum + graphql_name "FixedChargeChargeModelEnum" + + FixedCharge::CHARGE_MODELS.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/fixed_charges/create_input.rb b/app/graphql/types/fixed_charges/create_input.rb new file mode 100644 index 0000000..7ecd1dd --- /dev/null +++ b/app/graphql/types/fixed_charges/create_input.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module FixedCharges + class CreateInput < Types::BaseInputObject + graphql_name "FixedChargeCreateInput" + + argument :plan_id, ID, required: true + + argument :add_on_id, ID, required: true + argument :apply_units_immediately, Boolean, required: false + argument :charge_model, Types::FixedCharges::ChargeModelEnum, required: true + argument :code, String, required: false + argument :invoice_display_name, String, required: false + argument :pay_in_advance, Boolean, required: false + argument :prorated, Boolean, required: false + argument :units, String, required: false + + argument :properties, Types::FixedCharges::PropertiesInput, required: false + + argument :cascade_updates, Boolean, required: false + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/fixed_charges/input.rb b/app/graphql/types/fixed_charges/input.rb new file mode 100644 index 0000000..79bf77c --- /dev/null +++ b/app/graphql/types/fixed_charges/input.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module FixedCharges + class Input < Types::BaseInputObject + graphql_name "FixedChargeInput" + + argument :id, ID, required: false + + argument :add_on_id, ID, required: true + argument :apply_units_immediately, Boolean, required: false + argument :charge_model, Types::FixedCharges::ChargeModelEnum, required: true + argument :invoice_display_name, String, required: false + argument :pay_in_advance, Boolean, required: false + argument :prorated, Boolean, required: false + argument :units, String, required: false + + argument :properties, Types::FixedCharges::PropertiesInput, required: false + + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/fixed_charges/object.rb b/app/graphql/types/fixed_charges/object.rb new file mode 100644 index 0000000..6dbfb23 --- /dev/null +++ b/app/graphql/types/fixed_charges/object.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Types + module FixedCharges + class Object < Types::BaseObject + graphql_name "FixedCharge" + + field :code, String, null: true + field :id, ID, null: false + field :invoice_display_name, String, null: true + field :parent_id, ID, null: true + + field :add_on, Types::AddOns::Object, null: false + field :charge_model, Types::FixedCharges::ChargeModelEnum, null: false + field :pay_in_advance, Boolean, null: false + field :properties, Types::FixedCharges::Properties, null: true + field :prorated, Boolean, null: false + field :units, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :taxes, [Types::Taxes::Object] + + def properties + return object.properties unless object.properties == "{}" + + JSON.parse(object.properties) + end + + def units + # Remove decimal part when it is zero (e.g., "1.0" becomes "1") + value = object.units.to_f + (value == value.to_i) ? value.to_i.to_s : value.to_s + end + + def add_on + AddOn.with_discarded.find_by(id: object.add_on_id) + end + end + end +end diff --git a/app/graphql/types/fixed_charges/properties.rb b/app/graphql/types/fixed_charges/properties.rb new file mode 100644 index 0000000..9a14cc6 --- /dev/null +++ b/app/graphql/types/fixed_charges/properties.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module FixedCharges + class Properties < Types::BaseObject + graphql_name "FixedChargeProperties" + + # NOTE: Standard and Package charge model + field :amount, String, null: true + + # NOTE: Graduated charge model + field :graduated_ranges, [Types::ChargeModels::GraduatedRange], null: true + + # NOTE: Volume charge model + field :volume_ranges, [Types::ChargeModels::VolumeRange], null: true + end + end +end diff --git a/app/graphql/types/fixed_charges/properties_input.rb b/app/graphql/types/fixed_charges/properties_input.rb new file mode 100644 index 0000000..26c5141 --- /dev/null +++ b/app/graphql/types/fixed_charges/properties_input.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module FixedCharges + class PropertiesInput < Types::BaseInputObject + graphql_name "FixedChargePropertiesInput" + + # NOTE: Standard and Package charge model + argument :amount, String, required: false + + # NOTE: Graduated charge model + argument :graduated_ranges, [Types::ChargeModels::GraduatedRangeInput], required: false + + # NOTE: Volume charge model + argument :volume_ranges, [Types::ChargeModels::VolumeRangeInput], required: false + end + end +end diff --git a/app/graphql/types/fixed_charges/update_input.rb b/app/graphql/types/fixed_charges/update_input.rb new file mode 100644 index 0000000..7e5eba4 --- /dev/null +++ b/app/graphql/types/fixed_charges/update_input.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module FixedCharges + class UpdateInput < Types::BaseInputObject + graphql_name "FixedChargeUpdateInput" + + argument :id, ID, required: true + + argument :apply_units_immediately, Boolean, required: false + argument :charge_model, Types::FixedCharges::ChargeModelEnum, required: false + argument :code, String, required: false + argument :invoice_display_name, String, required: false + argument :pay_in_advance, Boolean, required: false + argument :prorated, Boolean, required: false + argument :units, String, required: false + + argument :cascade_updates, Boolean, required: false + argument :properties, Types::FixedCharges::PropertiesInput, required: false + + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/graphql_subscription_type.rb b/app/graphql/types/graphql_subscription_type.rb new file mode 100644 index 0000000..358f6fb --- /dev/null +++ b/app/graphql/types/graphql_subscription_type.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Types + class GraphqlSubscriptionType < Types::BaseObject + field :ai_conversation_streamed, subscription: Types::GraphqlSubscriptions::AiConversation + end +end diff --git a/app/graphql/types/graphql_subscriptions/ai_conversation.rb b/app/graphql/types/graphql_subscriptions/ai_conversation.rb new file mode 100644 index 0000000..98e61a2 --- /dev/null +++ b/app/graphql/types/graphql_subscriptions/ai_conversation.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module GraphqlSubscriptions + class AiConversation < Types::BaseSubscription + argument :id, ID, required: true + type Types::AiConversations::Stream, null: false + + def subscribe(id:) + # Return an empty object to keep subscription alive + {chunk: nil, done: false} + end + + def update(id:) + object + end + end + end +end diff --git a/app/graphql/types/integration_collection_mappings/base_input.rb b/app/graphql/types/integration_collection_mappings/base_input.rb new file mode 100644 index 0000000..937aa55 --- /dev/null +++ b/app/graphql/types/integration_collection_mappings/base_input.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Types + module IntegrationCollectionMappings + class BaseInput < Types::BaseInputObject + graphql_name "BaseIntegrationCollectionMappingInput" + + argument :external_account_code, String, required: false + argument :external_id, String, required: false + argument :external_name, String, required: false + + argument :tax_code, String, required: false + argument :tax_nexus, String, required: false + argument :tax_type, String, required: false + + argument :currencies, [CurrencyMappingItemInput], required: false, + prepare: :convert_currencies_to_hash, + validates: {::Validators::UniqueByFieldValidator => {field_name: :currency_code}} + + def convert_currencies_to_hash(value) + value.map { |item| [item[:currency_code], item[:currency_external_code]] }.to_h + end + end + end +end diff --git a/app/graphql/types/integration_collection_mappings/create_input.rb b/app/graphql/types/integration_collection_mappings/create_input.rb new file mode 100644 index 0000000..276c94f --- /dev/null +++ b/app/graphql/types/integration_collection_mappings/create_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module IntegrationCollectionMappings + class CreateInput < BaseInput + graphql_name "CreateIntegrationCollectionMappingInput" + + argument :billing_entity_id, ID, required: false + argument :integration_id, ID, required: true + argument :mapping_type, Types::IntegrationCollectionMappings::MappingTypeEnum, required: true + end + end +end diff --git a/app/graphql/types/integration_collection_mappings/currency_mapping_item.rb b/app/graphql/types/integration_collection_mappings/currency_mapping_item.rb new file mode 100644 index 0000000..582c2bb --- /dev/null +++ b/app/graphql/types/integration_collection_mappings/currency_mapping_item.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module IntegrationCollectionMappings + class CurrencyMappingItem < Types::BaseObject + field :currency_code, Types::CurrencyEnum, null: false + field :currency_external_code, String, null: false + end + end +end diff --git a/app/graphql/types/integration_collection_mappings/currency_mapping_item_input.rb b/app/graphql/types/integration_collection_mappings/currency_mapping_item_input.rb new file mode 100644 index 0000000..d6875a3 --- /dev/null +++ b/app/graphql/types/integration_collection_mappings/currency_mapping_item_input.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module IntegrationCollectionMappings + class CurrencyMappingItemInput < Types::BaseInputObject + argument :currency_code, Types::CurrencyEnum, required: true + argument :currency_external_code, String, required: true + end + end +end diff --git a/app/graphql/types/integration_collection_mappings/mapping_type_enum.rb b/app/graphql/types/integration_collection_mappings/mapping_type_enum.rb new file mode 100644 index 0000000..f5fb2e4 --- /dev/null +++ b/app/graphql/types/integration_collection_mappings/mapping_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module IntegrationCollectionMappings + class MappingTypeEnum < Types::BaseEnum + ::IntegrationCollectionMappings::BaseCollectionMapping::MAPPING_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/integration_collection_mappings/object.rb b/app/graphql/types/integration_collection_mappings/object.rb new file mode 100644 index 0000000..c5356f1 --- /dev/null +++ b/app/graphql/types/integration_collection_mappings/object.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module IntegrationCollectionMappings + class Object < Types::BaseObject + graphql_name "CollectionMapping" + + field :billing_entity_id, ID, null: true + field :id, ID, null: false + field :integration_id, ID, null: false + field :mapping_type, Types::IntegrationCollectionMappings::MappingTypeEnum, null: false + + field :external_account_code, String, null: true + field :external_id, String, null: true + field :external_name, String, null: true + + field :tax_code, String, null: true + field :tax_nexus, String, null: true + field :tax_type, String, null: true + + field :currencies, [Types::IntegrationCollectionMappings::CurrencyMappingItem], null: true + + def currencies + return nil if object.currencies.nil? + + object.currencies.map do |currency_code, currency_external_code| + {currency_code:, currency_external_code:} + end + end + end + end +end diff --git a/app/graphql/types/integration_collection_mappings/update_input.rb b/app/graphql/types/integration_collection_mappings/update_input.rb new file mode 100644 index 0000000..06cf050 --- /dev/null +++ b/app/graphql/types/integration_collection_mappings/update_input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module IntegrationCollectionMappings + class UpdateInput < BaseInput + graphql_name "UpdateIntegrationCollectionMappingInput" + + argument :id, ID, required: true + + # @deprecated This field is deprecated and will be ignored. Integration ID cannot be updated. + argument :integration_id, ID, required: false + # @deprecated This field is deprecated and will be ignored. Mapping type cannot be updated. + argument :mapping_type, Types::IntegrationCollectionMappings::MappingTypeEnum, required: false + end + end +end diff --git a/app/graphql/types/integration_customers/anrok.rb b/app/graphql/types/integration_customers/anrok.rb new file mode 100644 index 0000000..05dbeaa --- /dev/null +++ b/app/graphql/types/integration_customers/anrok.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Types + module IntegrationCustomers + class Anrok < Types::BaseObject + graphql_name "AnrokCustomer" + + field :external_account_id, String, null: true + field :external_customer_id, String, null: true + field :id, ID, null: false + field :integration_code, String, null: true + field :integration_id, ID, null: true + field :integration_type, Types::Integrations::IntegrationTypeEnum, null: true + field :sync_with_provider, Boolean, null: true + + def integration_type + object.integration&.type + case object.integration&.type + when "Integrations::AnrokIntegration" + "anrok" + end + end + + def integration_code + object.integration&.code + end + + def external_account_id + api_key = object.integration.api_key + + return nil unless api_key.include?("/") + + api_key.split("/")[0] + end + end + end +end diff --git a/app/graphql/types/integration_customers/avalara.rb b/app/graphql/types/integration_customers/avalara.rb new file mode 100644 index 0000000..2dfe44f --- /dev/null +++ b/app/graphql/types/integration_customers/avalara.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + module IntegrationCustomers + class Avalara < Types::BaseObject + graphql_name "AvalaraCustomer" + + field :external_customer_id, String, null: true + field :id, ID, null: false + field :integration_code, String, null: true + field :integration_id, ID, null: true + field :integration_type, Types::Integrations::IntegrationTypeEnum, null: true + field :sync_with_provider, Boolean, null: true + + def integration_type + "avalara" + end + + def integration_code + object.integration&.code + end + end + end +end diff --git a/app/graphql/types/integration_customers/hubspot.rb b/app/graphql/types/integration_customers/hubspot.rb new file mode 100644 index 0000000..7d5ea1e --- /dev/null +++ b/app/graphql/types/integration_customers/hubspot.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module IntegrationCustomers + class Hubspot < Types::BaseObject + graphql_name "HubspotCustomer" + + field :external_customer_id, String, null: true + field :id, ID, null: false + field :integration_code, String, null: true + field :integration_id, ID, null: true + field :integration_type, Types::Integrations::IntegrationTypeEnum, null: true + field :sync_with_provider, Boolean, null: true + field :targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, null: true + + def integration_type + object.integration&.type + case object.integration&.type + when "Integrations::HubspotIntegration" + "hubspot" + end + end + + def integration_code + object.integration&.code + end + end + end +end diff --git a/app/graphql/types/integration_customers/input.rb b/app/graphql/types/integration_customers/input.rb new file mode 100644 index 0000000..52c75c6 --- /dev/null +++ b/app/graphql/types/integration_customers/input.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module IntegrationCustomers + class Input < Types::BaseInputObject + graphql_name "IntegrationCustomerInput" + + argument :id, ID, required: false + + argument :external_customer_id, String, required: false + argument :integration_code, String, required: false + argument :integration_id, ID, required: false + argument :integration_type, Types::Integrations::IntegrationTypeEnum, required: false + argument :subsidiary_id, String, required: false + argument :sync_with_provider, Boolean, required: false + argument :targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, required: false + end + end +end diff --git a/app/graphql/types/integration_customers/netsuite.rb b/app/graphql/types/integration_customers/netsuite.rb new file mode 100644 index 0000000..73b361c --- /dev/null +++ b/app/graphql/types/integration_customers/netsuite.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module IntegrationCustomers + class Netsuite < Types::BaseObject + graphql_name "NetsuiteCustomer" + + field :external_customer_id, String, null: true + field :id, ID, null: false + field :integration_code, String, null: true + field :integration_id, ID, null: true + field :integration_type, Types::Integrations::IntegrationTypeEnum, null: true + field :subsidiary_id, String, null: true + field :sync_with_provider, Boolean, null: true + + def integration_type + object.integration&.type + case object.integration&.type + when "Integrations::NetsuiteIntegration" + "netsuite" + end + end + + def integration_code + object.integration&.code + end + end + end +end diff --git a/app/graphql/types/integration_customers/salesforce.rb b/app/graphql/types/integration_customers/salesforce.rb new file mode 100644 index 0000000..543080f --- /dev/null +++ b/app/graphql/types/integration_customers/salesforce.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module IntegrationCustomers + class Salesforce < Types::BaseObject + graphql_name "SalesforceCustomer" + + field :external_customer_id, String, null: true + field :id, ID, null: false + field :integration_code, String, null: true + field :integration_id, ID, null: true + field :integration_type, Types::Integrations::IntegrationTypeEnum, null: true + field :sync_with_provider, Boolean, null: true + + def integration_type + object.integration&.type + case object.integration&.type + when "Integrations::SalesforceIntegration" + "salesforce" + end + end + + def integration_code + object.integration&.code + end + end + end +end diff --git a/app/graphql/types/integration_customers/xero.rb b/app/graphql/types/integration_customers/xero.rb new file mode 100644 index 0000000..24aff9d --- /dev/null +++ b/app/graphql/types/integration_customers/xero.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module IntegrationCustomers + class Xero < Types::BaseObject + graphql_name "XeroCustomer" + + field :external_customer_id, String, null: true + field :id, ID, null: false + field :integration_code, String, null: true + field :integration_id, ID, null: true + field :integration_type, Types::Integrations::IntegrationTypeEnum, null: true + field :sync_with_provider, Boolean, null: true + + def integration_type + object.integration&.type + case object.integration&.type + when "Integrations::XeroIntegration" + "xero" + end + end + + def integration_code + object.integration&.code + end + end + end +end diff --git a/app/graphql/types/integration_items/item_type_enum.rb b/app/graphql/types/integration_items/item_type_enum.rb new file mode 100644 index 0000000..6b54897 --- /dev/null +++ b/app/graphql/types/integration_items/item_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module IntegrationItems + class ItemTypeEnum < Types::BaseEnum + graphql_name "IntegrationItemTypeEnum" + + IntegrationItem::ITEM_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/integration_items/object.rb b/app/graphql/types/integration_items/object.rb new file mode 100644 index 0000000..ee2ced0 --- /dev/null +++ b/app/graphql/types/integration_items/object.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module IntegrationItems + class Object < Types::BaseObject + graphql_name "IntegrationItem" + + field :external_account_code, String, null: true + field :external_id, String, null: false + field :external_name, String, null: true + field :id, ID, null: false + field :integration_id, ID, null: false + field :item_type, Types::IntegrationItems::ItemTypeEnum, null: false + end + end +end diff --git a/app/graphql/types/integration_mappings/create_input.rb b/app/graphql/types/integration_mappings/create_input.rb new file mode 100644 index 0000000..f9dc84e --- /dev/null +++ b/app/graphql/types/integration_mappings/create_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module IntegrationMappings + class CreateInput < Types::BaseInputObject + graphql_name "CreateIntegrationMappingInput" + + argument :billing_entity_id, ID, required: false + argument :external_account_code, String, required: false + argument :external_id, String, required: true + argument :external_name, String, required: false + argument :integration_id, ID, required: true + argument :mappable_id, ID, required: true + argument :mappable_type, Types::IntegrationMappings::MappableTypeEnum, required: true + end + end +end diff --git a/app/graphql/types/integration_mappings/mappable_type_enum.rb b/app/graphql/types/integration_mappings/mappable_type_enum.rb new file mode 100644 index 0000000..892e568 --- /dev/null +++ b/app/graphql/types/integration_mappings/mappable_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module IntegrationMappings + class MappableTypeEnum < Types::BaseEnum + ::IntegrationMappings::BaseMapping::MAPPABLE_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/integration_mappings/object.rb b/app/graphql/types/integration_mappings/object.rb new file mode 100644 index 0000000..0c5b55e --- /dev/null +++ b/app/graphql/types/integration_mappings/object.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module IntegrationMappings + class Object < Types::BaseObject + graphql_name "Mapping" + + field :billing_entity_id, ID, null: true + field :external_account_code, String, null: true + field :external_id, String, null: false + field :external_name, String, null: true + field :id, ID, null: false + field :integration_id, ID, null: false + field :mappable_id, ID, null: false + field :mappable_type, Types::IntegrationMappings::MappableTypeEnum, null: false + end + end +end diff --git a/app/graphql/types/integration_mappings/update_input.rb b/app/graphql/types/integration_mappings/update_input.rb new file mode 100644 index 0000000..6be527e --- /dev/null +++ b/app/graphql/types/integration_mappings/update_input.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module IntegrationMappings + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateIntegrationMappingInput" + + argument :external_account_code, String, required: false + argument :external_id, String, required: false + argument :external_name, String, required: false + argument :id, ID, required: true + + # @deprecated This field is deprecated and will be ignored. Integration ID cannot be updated. + argument :integration_id, ID, required: false + # @deprecated This field is deprecated and will be ignored. Mappable ID cannot be updated. + argument :mappable_id, ID, required: false + # @deprecated This field is deprecated and will be ignored. Mappable type cannot be updated. + argument :mappable_type, Types::IntegrationMappings::MappableTypeEnum, required: false + end + end +end diff --git a/app/graphql/types/integrations/accounts/object.rb b/app/graphql/types/integrations/accounts/object.rb new file mode 100644 index 0000000..0abae3a --- /dev/null +++ b/app/graphql/types/integrations/accounts/object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Integrations + module Accounts + class Object < Types::BaseObject + graphql_name "Account" + + field :external_account_code, String, null: false + field :external_id, String, null: false + field :external_name, String, null: true + end + end + end +end diff --git a/app/graphql/types/integrations/anrok.rb b/app/graphql/types/integrations/anrok.rb new file mode 100644 index 0000000..8ba59f0 --- /dev/null +++ b/app/graphql/types/integrations/anrok.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Anrok < Types::BaseObject + graphql_name "AnrokIntegration" + + field :api_key, ObfuscatedStringType, null: false + field :code, String, null: false + field :external_account_id, String, null: true + field :failed_invoices_count, Integer, null: true + field :has_mappings_configured, Boolean + field :id, ID, null: false + field :name, String, null: false + + def has_mappings_configured + object.integration_collection_mappings.where(type: "IntegrationCollectionMappings::AnrokCollectionMapping").any? + end + + def external_account_id + return nil unless object.api_key.include?("/") + + object.api_key.split("/")[0] + end + + def failed_invoices_count + object.organization.failed_tax_invoices_count + end + end + end +end diff --git a/app/graphql/types/integrations/anrok/create_input.rb b/app/graphql/types/integrations/anrok/create_input.rb new file mode 100644 index 0000000..3e22e58 --- /dev/null +++ b/app/graphql/types/integrations/anrok/create_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Anrok + class CreateInput < Types::BaseInputObject + graphql_name "CreateAnrokIntegrationInput" + + argument :code, String, required: true + argument :name, String, required: true + + argument :api_key, String, required: true + argument :connection_id, String, required: true + end + end + end +end diff --git a/app/graphql/types/integrations/anrok/update_input.rb b/app/graphql/types/integrations/anrok/update_input.rb new file mode 100644 index 0000000..c083446 --- /dev/null +++ b/app/graphql/types/integrations/anrok/update_input.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Anrok + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateAnrokIntegrationInput" + + argument :id, ID, required: false + + argument :code, String, required: false + argument :name, String, required: false + + argument :api_key, String, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/avalara.rb b/app/graphql/types/integrations/avalara.rb new file mode 100644 index 0000000..7dd9e33 --- /dev/null +++ b/app/graphql/types/integrations/avalara.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Avalara < Types::BaseObject + graphql_name "AvalaraIntegration" + + field :account_id, String, null: true + field :code, String, null: false + field :company_code, String, null: false + field :company_id, String, null: true + field :failed_invoices_count, Integer, null: true + field :has_mappings_configured, Boolean + field :id, ID, null: false + field :license_key, ObfuscatedStringType, null: false + field :name, String, null: false + + def has_mappings_configured + object.integration_collection_mappings.where(type: "IntegrationCollectionMappings::AvalaraCollectionMapping").any? + end + + def failed_invoices_count + object.organization.failed_tax_invoices_count + end + end + end +end diff --git a/app/graphql/types/integrations/avalara/create_input.rb b/app/graphql/types/integrations/avalara/create_input.rb new file mode 100644 index 0000000..ec9890a --- /dev/null +++ b/app/graphql/types/integrations/avalara/create_input.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Avalara + class CreateInput < Types::BaseInputObject + graphql_name "CreateAvalaraIntegrationInput" + + argument :code, String, required: true + argument :name, String, required: true + + argument :account_id, String, required: true + argument :company_code, String, required: true + argument :connection_id, String, required: true + argument :license_key, String, required: true + end + end + end +end diff --git a/app/graphql/types/integrations/avalara/update_input.rb b/app/graphql/types/integrations/avalara/update_input.rb new file mode 100644 index 0000000..45a4cdb --- /dev/null +++ b/app/graphql/types/integrations/avalara/update_input.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Avalara + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateAvalaraIntegrationInput" + + argument :id, ID, required: false + + argument :code, String, required: false + argument :company_code, String, required: false + argument :name, String, required: false + + argument :account_id, String, required: false + argument :license_key, String, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/hubspot.rb b/app/graphql/types/integrations/hubspot.rb new file mode 100644 index 0000000..cd38395 --- /dev/null +++ b/app/graphql/types/integrations/hubspot.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Hubspot < Types::BaseObject + graphql_name "HubspotIntegration" + + field :code, String, null: false + field :connection_id, ID, null: false + field :default_targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, null: false + field :id, ID, null: false + field :invoices_object_type_id, String + field :name, String, null: false + field :portal_id, String + field :subscriptions_object_type_id, String + field :sync_invoices, Boolean + field :sync_subscriptions, Boolean + end + end +end diff --git a/app/graphql/types/integrations/hubspot/create_input.rb b/app/graphql/types/integrations/hubspot/create_input.rb new file mode 100644 index 0000000..cba42d2 --- /dev/null +++ b/app/graphql/types/integrations/hubspot/create_input.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Hubspot + class CreateInput < Types::BaseInputObject + graphql_name "CreateHubspotIntegrationInput" + + argument :code, String, required: true + argument :name, String, required: true + + argument :connection_id, String, required: true + argument :default_targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, required: true + argument :sync_invoices, Boolean, required: false + argument :sync_subscriptions, Boolean, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/hubspot/targeted_objects_enum.rb b/app/graphql/types/integrations/hubspot/targeted_objects_enum.rb new file mode 100644 index 0000000..d0eeceb --- /dev/null +++ b/app/graphql/types/integrations/hubspot/targeted_objects_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Hubspot + class TargetedObjectsEnum < Types::BaseEnum + graphql_name "HubspotTargetedObjectsEnum" + + ::Integrations::HubspotIntegration::TARGETED_OBJECTS.each do |type| + value type + end + end + end + end +end diff --git a/app/graphql/types/integrations/hubspot/update_input.rb b/app/graphql/types/integrations/hubspot/update_input.rb new file mode 100644 index 0000000..ea63ca3 --- /dev/null +++ b/app/graphql/types/integrations/hubspot/update_input.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Hubspot + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateHubspotIntegrationInput" + + argument :id, ID, required: false + + argument :code, String, required: false + argument :name, String, required: false + + argument :connection_id, String, required: false + argument :default_targeted_object, Types::Integrations::Hubspot::TargetedObjectsEnum, required: false + argument :sync_invoices, Boolean, required: false + argument :sync_subscriptions, Boolean, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/integration_type_enum.rb b/app/graphql/types/integrations/integration_type_enum.rb new file mode 100644 index 0000000..ca03176 --- /dev/null +++ b/app/graphql/types/integrations/integration_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Integrations + class IntegrationTypeEnum < Types::BaseEnum + Organization::INTEGRATIONS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/integrations/netsuite.rb b/app/graphql/types/integrations/netsuite.rb new file mode 100644 index 0000000..057ea19 --- /dev/null +++ b/app/graphql/types/integrations/netsuite.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Netsuite < Types::BaseObject + graphql_name "NetsuiteIntegration" + + field :account_id, String, null: true + field :client_id, String, null: true + field :client_secret, ObfuscatedStringType, null: true + field :code, String, null: false + field :connection_id, ID, null: false + field :has_mappings_configured, Boolean + field :id, ID, null: false + field :name, String, null: false + field :script_endpoint_url, String, null: false + field :sync_credit_notes, Boolean + field :sync_invoices, Boolean + field :sync_payments, Boolean + field :token_id, String, null: true + field :token_secret, ObfuscatedStringType, null: true + + def has_mappings_configured + object.integration_collection_mappings + .where(type: "IntegrationCollectionMappings::NetsuiteCollectionMapping") + .any? + end + end + end +end diff --git a/app/graphql/types/integrations/netsuite/create_input.rb b/app/graphql/types/integrations/netsuite/create_input.rb new file mode 100644 index 0000000..559796e --- /dev/null +++ b/app/graphql/types/integrations/netsuite/create_input.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Netsuite + class CreateInput < Types::BaseInputObject + graphql_name "CreateNetsuiteIntegrationInput" + + argument :code, String, required: true + argument :name, String, required: true + + argument :account_id, String, required: true + argument :client_id, String, required: true + argument :client_secret, String, required: true + argument :connection_id, String, required: true + argument :script_endpoint_url, String, required: true + argument :token_id, String, required: true + argument :token_secret, String, required: true + + argument :sync_credit_notes, Boolean, required: false + argument :sync_invoices, Boolean, required: false + argument :sync_payments, Boolean, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/netsuite/update_input.rb b/app/graphql/types/integrations/netsuite/update_input.rb new file mode 100644 index 0000000..2fc29c2 --- /dev/null +++ b/app/graphql/types/integrations/netsuite/update_input.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Netsuite + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateNetsuiteIntegrationInput" + + argument :id, ID, required: false + + argument :code, String, required: false + argument :name, String, required: false + + argument :account_id, String, required: false + argument :client_id, String, required: false + argument :client_secret, String, required: false + argument :connection_id, String, required: false + argument :script_endpoint_url, String, required: false + argument :token_id, String, required: false + argument :token_secret, String, required: false + + argument :sync_credit_notes, Boolean, required: false + argument :sync_invoices, Boolean, required: false + argument :sync_payments, Boolean, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/object.rb b/app/graphql/types/integrations/object.rb new file mode 100644 index 0000000..cb6ab57 --- /dev/null +++ b/app/graphql/types/integrations/object.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Object < Types::BaseUnion + graphql_name "Integration" + + possible_types Types::Integrations::Netsuite, + Types::Integrations::Okta, + Types::Integrations::Anrok, + Types::Integrations::Avalara, + Types::Integrations::Xero, + Types::Integrations::Hubspot, + Types::Integrations::Salesforce + + def self.resolve_type(object, _context) + case object.class.to_s + when "Integrations::NetsuiteIntegration" + Types::Integrations::Netsuite + when "Integrations::OktaIntegration" + Types::Integrations::Okta + when "Integrations::AnrokIntegration" + Types::Integrations::Anrok + when "Integrations::AvalaraIntegration" + Types::Integrations::Avalara + when "Integrations::XeroIntegration" + Types::Integrations::Xero + when "Integrations::HubspotIntegration" + Types::Integrations::Hubspot + when "Integrations::SalesforceIntegration" + Types::Integrations::Salesforce + else + raise "Unexpected integration type: #{object.inspect}" + end + end + end + end +end diff --git a/app/graphql/types/integrations/okta.rb b/app/graphql/types/integrations/okta.rb new file mode 100644 index 0000000..467d9ac --- /dev/null +++ b/app/graphql/types/integrations/okta.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Okta < Types::BaseObject + graphql_name "OktaIntegration" + + field :client_id, String, null: true + field :client_secret, ObfuscatedStringType, null: true + field :code, String, null: false + field :domain, String, null: false + field :host, String, null: true + field :id, ID, null: false + field :name, String, null: false + field :organization_name, String, null: false + end + end +end diff --git a/app/graphql/types/integrations/okta/create_input.rb b/app/graphql/types/integrations/okta/create_input.rb new file mode 100644 index 0000000..0ce47b1 --- /dev/null +++ b/app/graphql/types/integrations/okta/create_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Okta + class CreateInput < Types::BaseInputObject + graphql_name "CreateOktaIntegrationInput" + + argument :client_id, String, required: true + argument :client_secret, String, required: true + argument :domain, String, required: true + argument :host, String, required: false + argument :organization_name, String, required: true + end + end + end +end diff --git a/app/graphql/types/integrations/okta/update_input.rb b/app/graphql/types/integrations/okta/update_input.rb new file mode 100644 index 0000000..d253940 --- /dev/null +++ b/app/graphql/types/integrations/okta/update_input.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Okta + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateOktaIntegrationInput" + + argument :id, ID, required: false + + argument :client_id, String, required: false + argument :client_secret, String, required: false + argument :domain, String, required: false + argument :host, String, required: false + argument :organization_name, String, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/premium_integration_type_enum.rb b/app/graphql/types/integrations/premium_integration_type_enum.rb new file mode 100644 index 0000000..a3ba6db --- /dev/null +++ b/app/graphql/types/integrations/premium_integration_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Integrations + class PremiumIntegrationTypeEnum < Types::BaseEnum + Organization::PREMIUM_INTEGRATIONS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/integrations/salesforce.rb b/app/graphql/types/integrations/salesforce.rb new file mode 100644 index 0000000..1cdb9a8 --- /dev/null +++ b/app/graphql/types/integrations/salesforce.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Salesforce < Types::BaseObject + graphql_name "SalesforceIntegration" + + field :code, String, null: false + field :id, ID, null: false + field :instance_id, String, null: false + field :name, String, null: false + end + end +end diff --git a/app/graphql/types/integrations/salesforce/create_input.rb b/app/graphql/types/integrations/salesforce/create_input.rb new file mode 100644 index 0000000..308d4c6 --- /dev/null +++ b/app/graphql/types/integrations/salesforce/create_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Salesforce + class CreateInput < Types::BaseInputObject + graphql_name "CreateSalesforceIntegrationInput" + + argument :code, String, required: true + argument :instance_id, String, required: true + argument :name, String, required: true + end + end + end +end diff --git a/app/graphql/types/integrations/salesforce/sync_invoice_input.rb b/app/graphql/types/integrations/salesforce/sync_invoice_input.rb new file mode 100644 index 0000000..608b5d4 --- /dev/null +++ b/app/graphql/types/integrations/salesforce/sync_invoice_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Salesforce + class SyncInvoiceInput < Types::BaseInputObject + graphql_name "SyncSalesforceInvoiceInput" + + argument :invoice_id, ID, required: true + end + end + end +end diff --git a/app/graphql/types/integrations/salesforce/update_input.rb b/app/graphql/types/integrations/salesforce/update_input.rb new file mode 100644 index 0000000..7938e6f --- /dev/null +++ b/app/graphql/types/integrations/salesforce/update_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Salesforce + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateSalesforceIntegrationInput" + + argument :id, ID, required: false + + argument :code, String, required: false + argument :instance_id, String, required: false + argument :name, String, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/subsidiaries/object.rb b/app/graphql/types/integrations/subsidiaries/object.rb new file mode 100644 index 0000000..ff35d33 --- /dev/null +++ b/app/graphql/types/integrations/subsidiaries/object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Integrations + module Subsidiaries + class Object < Types::BaseObject + graphql_name "Subsidiary" + + field :external_id, String, null: false + field :external_name, String, null: true + end + end + end +end diff --git a/app/graphql/types/integrations/sync_credit_note_input.rb b/app/graphql/types/integrations/sync_credit_note_input.rb new file mode 100644 index 0000000..d0e4bf5 --- /dev/null +++ b/app/graphql/types/integrations/sync_credit_note_input.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Integrations + class SyncCreditNoteInput < Types::BaseInputObject + graphql_name "SyncIntegrationCreditNoteInput" + + argument :credit_note_id, ID, required: true + end + end +end diff --git a/app/graphql/types/integrations/sync_hubspot_invoice_input.rb b/app/graphql/types/integrations/sync_hubspot_invoice_input.rb new file mode 100644 index 0000000..db5d048 --- /dev/null +++ b/app/graphql/types/integrations/sync_hubspot_invoice_input.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Integrations + class SyncHubspotInvoiceInput < Types::BaseInputObject + graphql_name "SyncHubspotIntegrationInvoiceInput" + + argument :invoice_id, ID, required: true + end + end +end diff --git a/app/graphql/types/integrations/sync_invoice_input.rb b/app/graphql/types/integrations/sync_invoice_input.rb new file mode 100644 index 0000000..4e3ae56 --- /dev/null +++ b/app/graphql/types/integrations/sync_invoice_input.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Integrations + class SyncInvoiceInput < Types::BaseInputObject + graphql_name "SyncIntegrationInvoiceInput" + + argument :invoice_id, ID, required: true + end + end +end diff --git a/app/graphql/types/integrations/tax_objects/breakdown_object.rb b/app/graphql/types/integrations/tax_objects/breakdown_object.rb new file mode 100644 index 0000000..7007fe6 --- /dev/null +++ b/app/graphql/types/integrations/tax_objects/breakdown_object.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module Integrations + module TaxObjects + class BreakdownObject < Types::BaseObject + graphql_name "TaxBreakdownObject" + + # we need to show how this tax will behave when invoice is generated - will it be applied + # on whole invoice specific rule or just a normal tax + field :enumed_tax_code, Types::Invoices::AppliedTaxes::WholeInvoiceApplicableTaxCodeEnum, null: true + field :name, String, null: true + field :rate, GraphQL::Types::Float, null: true + field :tax_amount, GraphQL::Types::BigInt, null: true + field :type, String, null: true + + def rate + BigDecimal(object.rate) + end + + def tax_code + @tax_code ||= object.name&.parameterize(separator: "_") + end + + def enumed_tax_code + tax_code if Invoice::AppliedTax::TAX_CODES_APPLICABLE_ON_WHOLE_INVOICE.include?(tax_code) + end + end + end + end +end diff --git a/app/graphql/types/integrations/tax_objects/fee_object.rb b/app/graphql/types/integrations/tax_objects/fee_object.rb new file mode 100644 index 0000000..93ee5e5 --- /dev/null +++ b/app/graphql/types/integrations/tax_objects/fee_object.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Integrations + module TaxObjects + class FeeObject < Types::BaseObject + graphql_name "TaxFeeObject" + + field :amount_cents, GraphQL::Types::BigInt, null: true + field :item_code, String, null: true + field :item_id, String, null: true + field :tax_amount_cents, GraphQL::Types::BigInt, null: true + + field :tax_breakdown, [Types::Integrations::TaxObjects::BreakdownObject] + end + end + end +end diff --git a/app/graphql/types/integrations/xero.rb b/app/graphql/types/integrations/xero.rb new file mode 100644 index 0000000..a35fde8 --- /dev/null +++ b/app/graphql/types/integrations/xero.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Xero < Types::BaseObject + graphql_name "XeroIntegration" + + field :code, String, null: false + field :connection_id, ID, null: false + field :has_mappings_configured, Boolean + field :id, ID, null: false + field :name, String, null: false + field :sync_credit_notes, Boolean + field :sync_invoices, Boolean + field :sync_payments, Boolean + + def has_mappings_configured + object.integration_collection_mappings.where(type: "IntegrationCollectionMappings::XeroCollectionMapping").any? + end + end + end +end diff --git a/app/graphql/types/integrations/xero/create_input.rb b/app/graphql/types/integrations/xero/create_input.rb new file mode 100644 index 0000000..9a60890 --- /dev/null +++ b/app/graphql/types/integrations/xero/create_input.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Xero + class CreateInput < Types::BaseInputObject + graphql_name "CreateXeroIntegrationInput" + + argument :code, String, required: true + argument :name, String, required: true + + argument :connection_id, String, required: true + + argument :sync_credit_notes, Boolean, required: false + argument :sync_invoices, Boolean, required: false + argument :sync_payments, Boolean, required: false + end + end + end +end diff --git a/app/graphql/types/integrations/xero/update_input.rb b/app/graphql/types/integrations/xero/update_input.rb new file mode 100644 index 0000000..d4cc1a5 --- /dev/null +++ b/app/graphql/types/integrations/xero/update_input.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Integrations + class Xero + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateXeroIntegrationInput" + + argument :id, ID, required: false + + argument :code, String, required: false + argument :name, String, required: false + + argument :connection_id, String, required: false + + argument :sync_credit_notes, Boolean, required: false + argument :sync_invoices, Boolean, required: false + argument :sync_payments, Boolean, required: false + end + end + end +end diff --git a/app/graphql/types/invites/create_input.rb b/app/graphql/types/invites/create_input.rb new file mode 100644 index 0000000..1856538 --- /dev/null +++ b/app/graphql/types/invites/create_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Invites + class CreateInput < Types::BaseInputObject + graphql_name "CreateInviteInput" + + argument :email, String, required: true + argument :roles, [String], required: false, default_value: [] + end + end +end diff --git a/app/graphql/types/invites/object.rb b/app/graphql/types/invites/object.rb new file mode 100644 index 0000000..444201c --- /dev/null +++ b/app/graphql/types/invites/object.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Invites + class Object < Types::BaseObject + graphql_name "Invite" + + field :organization, Types::Organizations::OrganizationType, null: false + field :recipient, Types::MembershipType, null: false + + field :id, ID, null: false + + field :email, String, null: false + field :roles, [String], null: false + field :status, Types::Invites::StatusTypeEnum, null: false + field :token, String, null: false + + field :accepted_at, GraphQL::Types::ISO8601DateTime, null: true + field :revoked_at, GraphQL::Types::ISO8601DateTime, null: true + end + end +end diff --git a/app/graphql/types/invites/status_type_enum.rb b/app/graphql/types/invites/status_type_enum.rb new file mode 100644 index 0000000..3164893 --- /dev/null +++ b/app/graphql/types/invites/status_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Invites + class StatusTypeEnum < Types::BaseEnum + graphql_name "InviteStatusTypeEnum" + + Invite::INVITE_STATUS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/invites/update_input.rb b/app/graphql/types/invites/update_input.rb new file mode 100644 index 0000000..2f78a7b --- /dev/null +++ b/app/graphql/types/invites/update_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Invites + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateInviteInput" + + argument :id, ID, required: true + argument :roles, [String], required: false + end + end +end diff --git a/app/graphql/types/invoice_custom_sections/create_input.rb b/app/graphql/types/invoice_custom_sections/create_input.rb new file mode 100644 index 0000000..881ddf0 --- /dev/null +++ b/app/graphql/types/invoice_custom_sections/create_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module InvoiceCustomSections + class CreateInput < Types::BaseInputObject + graphql_name "CreateInvoiceCustomSectionInput" + + argument :code, String, required: true + argument :description, String, required: false + argument :details, String, required: false + argument :display_name, String, required: false + argument :name, String, required: true + end + end +end diff --git a/app/graphql/types/invoice_custom_sections/object.rb b/app/graphql/types/invoice_custom_sections/object.rb new file mode 100644 index 0000000..3d073cd --- /dev/null +++ b/app/graphql/types/invoice_custom_sections/object.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module InvoiceCustomSections + class Object < Types::BaseObject + graphql_name "InvoiceCustomSection" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType + + field :code, String, null: false + field :description, String, null: true + field :details, String, null: true + field :display_name, String, null: true + field :name, String, null: false + end + end +end diff --git a/app/graphql/types/invoice_custom_sections/reference_input.rb b/app/graphql/types/invoice_custom_sections/reference_input.rb new file mode 100644 index 0000000..af63ccb --- /dev/null +++ b/app/graphql/types/invoice_custom_sections/reference_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module InvoiceCustomSections + class ReferenceInput < Types::BaseInputObject + graphql_name "InvoiceCustomSectionsReferenceInput" + + argument :invoice_custom_section_ids, [ID], required: false + argument :skip_invoice_custom_sections, Boolean, required: false + end + end +end diff --git a/app/graphql/types/invoice_custom_sections/update_input.rb b/app/graphql/types/invoice_custom_sections/update_input.rb new file mode 100644 index 0000000..1886017 --- /dev/null +++ b/app/graphql/types/invoice_custom_sections/update_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module InvoiceCustomSections + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateInvoiceCustomSectionInput" + + argument :id, ID, required: true + + argument :code, String, required: false + argument :description, String, required: false + argument :details, String, required: false + argument :display_name, String, required: false + argument :name, String, required: false + end + end +end diff --git a/app/graphql/types/invoice_subscriptions/object.rb b/app/graphql/types/invoice_subscriptions/object.rb new file mode 100644 index 0000000..860010c --- /dev/null +++ b/app/graphql/types/invoice_subscriptions/object.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Types + module InvoiceSubscriptions + class Object < Types::BaseObject + graphql_name "InvoiceSubscription" + + field :invoice, Types::Invoices::Object, null: false + field :subscription, Types::Subscriptions::Object, null: false + + field :charge_amount_cents, GraphQL::Types::BigInt, null: false + field :subscription_amount_cents, GraphQL::Types::BigInt, null: false + field :total_amount_cents, GraphQL::Types::BigInt, null: false + + field :fees, [Types::Fees::Object], null: true + + field :charges_from_datetime, GraphQL::Types::ISO8601DateTime, null: true + field :charges_to_datetime, GraphQL::Types::ISO8601DateTime, null: true + + field :in_advance_charges_from_datetime, GraphQL::Types::ISO8601DateTime, null: true + field :in_advance_charges_to_datetime, GraphQL::Types::ISO8601DateTime, null: true + + field :from_datetime, GraphQL::Types::ISO8601DateTime, null: true + field :to_datetime, GraphQL::Types::ISO8601DateTime, null: true + + field :accept_new_charge_fees, Boolean, null: false + + def in_advance_charges_from_datetime + return nil unless should_use_in_advance_charges_interval + + charge_pay_in_advance_interval[:charges_from_date] + end + + def in_advance_charges_to_datetime + return nil unless should_use_in_advance_charges_interval + + charge_pay_in_advance_interval[:charges_to_date] + end + + def should_use_in_advance_charges_interval + return @should_use_in_advance_charges_interval if defined? @should_use_in_advance_charges_interval + + @should_use_in_advance_charges_interval = + object.fees.charge.any? && + object.subscription.plan.charges.where(pay_in_advance: true).any? && + !object.subscription.plan.pay_in_advance? + end + + def charge_pay_in_advance_interval + @charge_pay_in_advance_interval ||= + ::Subscriptions::DatesService.charge_pay_in_advance_interval(object.timestamp, object.subscription) + end + + def accept_new_charge_fees + return false if object.invoice.skip_charges + + object.subscription_periodic? || object.subscription_terminating? + end + end + end +end diff --git a/app/graphql/types/invoices/applied_taxes/object.rb b/app/graphql/types/invoices/applied_taxes/object.rb new file mode 100644 index 0000000..ac56948 --- /dev/null +++ b/app/graphql/types/invoices/applied_taxes/object.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Invoices + module AppliedTaxes + class Object < Types::BaseObject + graphql_name "InvoiceAppliedTax" + implements Types::Taxes::AppliedTax + + field :applied_on_whole_invoice, GraphQL::Types::Boolean, null: false, method: :applied_on_whole_invoice? + field :enumed_tax_code, Types::Invoices::AppliedTaxes::WholeInvoiceApplicableTaxCodeEnum, null: true + field :fees_amount_cents, GraphQL::Types::BigInt, null: false + field :invoice, Types::Invoices::Object, null: false + field :taxable_amount_cents, GraphQL::Types::BigInt, null: false + + def enumed_tax_code + object.tax_code if object.applied_on_whole_invoice? + end + end + end + end +end diff --git a/app/graphql/types/invoices/applied_taxes/whole_invoice_applicable_tax_code_enum.rb b/app/graphql/types/invoices/applied_taxes/whole_invoice_applicable_tax_code_enum.rb new file mode 100644 index 0000000..50c512a --- /dev/null +++ b/app/graphql/types/invoices/applied_taxes/whole_invoice_applicable_tax_code_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Invoices + module AppliedTaxes + class WholeInvoiceApplicableTaxCodeEnum < Types::BaseEnum + graphql_name "InvoiceAppliedTaxOnWholeInvoiceCodeEnum" + + Invoice::AppliedTax::TAX_CODES_APPLICABLE_ON_WHOLE_INVOICE.each do |type| + value type + end + end + end + end +end diff --git a/app/graphql/types/invoices/create_invoice_input.rb b/app/graphql/types/invoices/create_invoice_input.rb new file mode 100644 index 0000000..9c82fd7 --- /dev/null +++ b/app/graphql/types/invoices/create_invoice_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Invoices + class CreateInvoiceInput < BaseInputObject + description "Create Invoice input arguments" + + argument :billing_entity_id, ID, required: false + argument :currency, Types::CurrencyEnum, required: false + argument :customer_id, ID, required: true + argument :fees, [Types::Invoices::FeeInput], required: true + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + argument :voided_invoice_id, ID, required: false + end + end +end diff --git a/app/graphql/types/invoices/fee_input.rb b/app/graphql/types/invoices/fee_input.rb new file mode 100644 index 0000000..57c2b30 --- /dev/null +++ b/app/graphql/types/invoices/fee_input.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Invoices + class FeeInput < BaseInputObject + description "Fee input for creating invoice" + + argument :add_on_id, ID, required: false + argument :description, String, required: false + argument :from_datetime, GraphQL::Types::ISO8601DateTime, required: true + argument :invoice_display_name, String, required: false + argument :name, String, required: false + argument :tax_codes, [String], required: false + argument :to_datetime, GraphQL::Types::ISO8601DateTime, required: true + argument :unit_amount_cents, GraphQL::Types::BigInt, required: false + argument :units, GraphQL::Types::Float, required: false + end + end +end diff --git a/app/graphql/types/invoices/invoice_item.rb b/app/graphql/types/invoices/invoice_item.rb new file mode 100644 index 0000000..14a8d32 --- /dev/null +++ b/app/graphql/types/invoices/invoice_item.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Invoices + module InvoiceItem + include Types::BaseInterface + + description "Invoice Item" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :item_code, String, null: false + field :item_name, String, null: false + field :item_type, String, null: false + end + end +end diff --git a/app/graphql/types/invoices/invoice_type_enum.rb b/app/graphql/types/invoices/invoice_type_enum.rb new file mode 100644 index 0000000..5ff0d07 --- /dev/null +++ b/app/graphql/types/invoices/invoice_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Invoices + class InvoiceTypeEnum < Types::BaseEnum + Invoice::INVOICE_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/invoices/metadata/input.rb b/app/graphql/types/invoices/metadata/input.rb new file mode 100644 index 0000000..b923089 --- /dev/null +++ b/app/graphql/types/invoices/metadata/input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Invoices + module Metadata + class Input < Types::BaseInputObject + description "Attributes for creating or updating invoice metadata object" + graphql_name "InvoiceMetadataInput" + + argument :id, ID, required: false + argument :key, String, required: true + argument :value, String, required: true + end + end + end +end diff --git a/app/graphql/types/invoices/metadata/object.rb b/app/graphql/types/invoices/metadata/object.rb new file mode 100644 index 0000000..2d686ad --- /dev/null +++ b/app/graphql/types/invoices/metadata/object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Invoices + module Metadata + class Object < Types::BaseObject + description "Attributes for invoice metadata object" + graphql_name "InvoiceMetadata" + + field :id, ID, null: false + field :key, String, null: false + field :value, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end + end +end diff --git a/app/graphql/types/invoices/object.rb b/app/graphql/types/invoices/object.rb new file mode 100644 index 0000000..dc29915 --- /dev/null +++ b/app/graphql/types/invoices/object.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +module Types + module Invoices + class Object < Types::BaseObject + description "Invoice" + graphql_name "Invoice" + + field :billing_entity, Types::BillingEntities::Object, null: false + field :customer, Types::Customers::Object, null: false + + field :id, ID, null: false + field :number, String, null: false + field :sequential_id, ID, null: false + + field :self_billed, Boolean, null: false + field :version_number, Integer, null: false + + field :invoice_type, Types::Invoices::InvoiceTypeEnum, null: false + field :payment_dispute_losable, Boolean, null: false, method: :payment_dispute_losable? + field :payment_dispute_lost_at, GraphQL::Types::ISO8601DateTime + field :payment_status, Types::Invoices::PaymentStatusTypeEnum, null: false + field :status, Types::Invoices::StatusTypeEnum, null: false + field :tax_status, Types::Invoices::TaxStatusTypeEnum, null: true + field :voidable, Boolean, null: false, method: :voidable? + + field :currency, Types::CurrencyEnum + field :taxes_rate, Float, null: false + + field :charge_amount_cents, GraphQL::Types::BigInt, null: false + field :coupons_amount_cents, GraphQL::Types::BigInt, null: false + field :credit_notes_amount_cents, GraphQL::Types::BigInt, null: false + field :fees_amount_cents, GraphQL::Types::BigInt, null: false + field :prepaid_credit_amount_cents, GraphQL::Types::BigInt, null: false + field :prepaid_granted_credit_amount_cents, GraphQL::Types::BigInt, null: true + field :prepaid_purchased_credit_amount_cents, GraphQL::Types::BigInt, null: true + field :progressive_billing_credit_amount_cents, GraphQL::Types::BigInt, null: false + field :ready_for_payment_processing, Boolean, null: false + field :sub_total_excluding_taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :sub_total_including_taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :taxes_amount_cents, GraphQL::Types::BigInt, null: false + field :total_amount_cents, GraphQL::Types::BigInt, null: false + field :total_due_amount_cents, GraphQL::Types::BigInt, null: false + field :total_paid_amount_cents, GraphQL::Types::BigInt, null: false + field :total_settled_amount_cents, GraphQL::Types::BigInt, null: false + + field :expected_finalization_date, GraphQL::Types::ISO8601Date, null: false + field :issuing_date, GraphQL::Types::ISO8601Date, null: false + field :payment_due_date, GraphQL::Types::ISO8601Date, null: false + field :payment_overdue, Boolean, null: false + + field :all_charges_have_fees, Boolean, null: false, method: :all_charges_have_fees? + field :all_fixed_charges_have_fees, Boolean, null: false, method: :all_fixed_charges_have_fees? + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :associated_active_wallet_present, Boolean, null: false + field :available_to_credit_amount_cents, GraphQL::Types::BigInt, null: false + field :creditable_amount_cents, GraphQL::Types::BigInt, null: false + field :offsettable_amount_cents, GraphQL::Types::BigInt, null: false + field :refundable_amount_cents, GraphQL::Types::BigInt, null: false + + field :file_url, String, null: true + field :xml_url, String, null: true + + field :metadata, [Types::Invoices::Metadata::Object], null: true + + field :activity_logs, [Types::ActivityLogs::Object], null: true + field :applied_taxes, [Types::Invoices::AppliedTaxes::Object] + field :credit_notes, [Types::CreditNotes::Object], null: true + field :error_details, [Types::ErrorDetails::Object], null: true + field :fees, [Types::Fees::Object], null: true + field :invoice_subscriptions, [Types::InvoiceSubscriptions::Object], method: :sorted_invoice_subscriptions + field :subscriptions, [Types::Subscriptions::Object], method: :sorted_subscriptions + + field :external_hubspot_integration_id, String, null: true + field :external_integration_id, String, null: true + field :external_salesforce_integration_id, String, null: true + field :integration_hubspot_syncable, GraphQL::Types::Boolean, null: false + field :integration_salesforce_syncable, GraphQL::Types::Boolean, null: false + field :integration_syncable, GraphQL::Types::Boolean, null: false + field :payable_type, GraphQL::Types::String, null: false + field :payments, [Types::Payments::Object], null: true + field :regenerated_invoice_id, String, null: true + field :tax_provider_id, String, null: true + field :tax_provider_voidable, GraphQL::Types::Boolean, null: false + field :voided_at, GraphQL::Types::ISO8601DateTime, null: true + field :voided_invoice_id, String, null: true + + def payable_type + "Invoice" + end + + def regenerated_invoice_id + object.regenerated_invoice&.id + end + + def applied_taxes + object.applied_taxes.order(tax_rate: :desc) + end + + def payments + object.payments.where.not(customer_id: nil).order(updated_at: :desc) + end + + def integration_syncable + object.should_sync_invoice? && + object.integration_resources + .joins(:integration) + .where(integration: {type: ::Integrations::BaseIntegration::INTEGRATION_ACCOUNTING_TYPES}) + .where(resource_type: "invoice", syncable_type: "Invoice").none? + end + + def integration_hubspot_syncable + object.should_sync_hubspot_invoice? && + object.integration_resources + .joins(:integration) + .where(integration: {type: "Integrations::HubspotIntegration"}) + .where(resource_type: "invoice", syncable_type: "Invoice").none? + end + + def integration_salesforce_syncable + object.should_sync_salesforce_invoice? && + object.integration_resources + .joins(:integration) + .where(integration: {type: "Integrations::SalesforceIntegration"}) + .where(resource_type: "invoice", syncable_type: "Invoice").none? + end + + def tax_provider_voidable + return false if !object.voided? && !object.payment_dispute_lost_at + + object.error_details.tax_voiding_error.any? + end + + def external_salesforce_integration_id + integration_customer = object.customer&.integration_customers&.salesforce_kind&.first + + return nil unless integration_customer + + IntegrationResource.find_by( + integration: integration_customer.integration, + syncable_id: object.id, + syncable_type: "Invoice", + resource_type: :invoice + )&.external_id + end + + def external_hubspot_integration_id + integration_customer = object.customer&.integration_customers&.hubspot_kind&.first + + return nil unless integration_customer + + IntegrationResource.find_by( + integration: integration_customer.integration, + syncable_id: object.id, + syncable_type: "Invoice", + resource_type: :invoice + )&.external_id + end + + def external_integration_id + integration_customer = object.customer&.integration_customers&.accounting_kind&.first + + return nil unless integration_customer + + IntegrationResource.find_by( + integration: integration_customer.integration, + syncable_id: object.id, + syncable_type: "Invoice", + resource_type: :invoice + )&.external_id + end + + def associated_active_wallet_present + object.associated_active_wallet.present? + end + + def tax_provider_id + integration_customer = object.customer&.tax_customer + return nil unless integration_customer + + IntegrationResource.find_by( + integration: integration_customer.integration, + syncable_id: object.id, + syncable_type: "Invoice", + resource_type: :invoice + )&.external_id + end + end + end +end diff --git a/app/graphql/types/invoices/payment_status_type_enum.rb b/app/graphql/types/invoices/payment_status_type_enum.rb new file mode 100644 index 0000000..001984f --- /dev/null +++ b/app/graphql/types/invoices/payment_status_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Invoices + class PaymentStatusTypeEnum < Types::BaseEnum + graphql_name "InvoicePaymentStatusTypeEnum" + + Invoice::PAYMENT_STATUS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/invoices/retry_payment_input.rb b/app/graphql/types/invoices/retry_payment_input.rb new file mode 100644 index 0000000..2641748 --- /dev/null +++ b/app/graphql/types/invoices/retry_payment_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Invoices + class RetryPaymentInput < BaseInputObject + description "Retry payment input arguments" + + argument :id, ID, required: true + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + end + end +end diff --git a/app/graphql/types/invoices/settlement_type_enum.rb b/app/graphql/types/invoices/settlement_type_enum.rb new file mode 100644 index 0000000..fd7896b --- /dev/null +++ b/app/graphql/types/invoices/settlement_type_enum.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Invoices + class SettlementTypeEnum < Types::BaseEnum + graphql_name "InvoiceSettlementTypeEnum" + + # we only have settlements for credit notes for now + value InvoiceSettlement::SETTLEMENT_TYPES.fetch(:credit_note) + end + end +end diff --git a/app/graphql/types/invoices/status_type_enum.rb b/app/graphql/types/invoices/status_type_enum.rb new file mode 100644 index 0000000..12261ae --- /dev/null +++ b/app/graphql/types/invoices/status_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Invoices + class StatusTypeEnum < Types::BaseEnum + graphql_name "InvoiceStatusTypeEnum" + + Invoice::STATUS.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/invoices/tax_status_type_enum.rb b/app/graphql/types/invoices/tax_status_type_enum.rb new file mode 100644 index 0000000..12561a0 --- /dev/null +++ b/app/graphql/types/invoices/tax_status_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Invoices + class TaxStatusTypeEnum < Types::BaseEnum + graphql_name "InvoiceTaxStatusTypeEnum" + + Invoice::TAX_STATUSES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/invoices/update_invoice_input.rb b/app/graphql/types/invoices/update_invoice_input.rb new file mode 100644 index 0000000..ec76dcf --- /dev/null +++ b/app/graphql/types/invoices/update_invoice_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Invoices + class UpdateInvoiceInput < BaseInputObject + description "Update Invoice input arguments" + + argument :id, ID, required: true + argument :metadata, [Types::Invoices::Metadata::Input], required: false + argument :payment_status, Types::Invoices::PaymentStatusTypeEnum, required: false + end + end +end diff --git a/app/graphql/types/invoices/void_invoice_input.rb b/app/graphql/types/invoices/void_invoice_input.rb new file mode 100644 index 0000000..97e6408 --- /dev/null +++ b/app/graphql/types/invoices/void_invoice_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Invoices + class VoidInvoiceInput < Types::BaseInputObject + description "Void Invoice input arguments" + argument :id, ID, required: true + + argument :credit_amount, GraphQL::Types::BigInt, required: false + argument :generate_credit_note, Boolean, required: false + argument :refund_amount, GraphQL::Types::BigInt, required: false + end + end +end diff --git a/app/graphql/types/invoices/voided_invoice_fee_input.rb b/app/graphql/types/invoices/voided_invoice_fee_input.rb new file mode 100644 index 0000000..cd8aeeb --- /dev/null +++ b/app/graphql/types/invoices/voided_invoice_fee_input.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module Invoices + class VoidedInvoiceFeeInput < BaseInputObject + description "Fee input for creating or updating invoice from voided invoice" + + argument :add_on_id, ID, required: false + argument :charge_filter_id, ID, required: false + argument :charge_id, ID, required: false + argument :description, String, required: false + argument :fixed_charge_id, ID, required: false + argument :id, ID, required: false + argument :invoice_display_name, String, required: false + argument :subscription_id, ID, required: false + argument :unit_amount_cents, GraphQL::Types::BigInt, required: false + argument :units, GraphQL::Types::Float, required: false + end + end +end diff --git a/app/graphql/types/membership_type.rb b/app/graphql/types/membership_type.rb new file mode 100644 index 0000000..0747a59 --- /dev/null +++ b/app/graphql/types/membership_type.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + class MembershipType < Types::BaseObject + field :id, ID, null: false + + field :organization, Types::Organizations::OrganizationType, null: false + field :user, Types::UserType, null: false + + field :permissions, Types::PermissionsType, null: false + field :roles, [String], null: false + field :status, Types::Memberships::StatusEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :revoked_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + def permissions + object.permissions_hash.transform_keys { |key| key.tr(":", "_") } + end + + def roles + object.roles.pluck(:name) + end + end +end diff --git a/app/graphql/types/memberships/metadata.rb b/app/graphql/types/memberships/metadata.rb new file mode 100644 index 0000000..dda3e94 --- /dev/null +++ b/app/graphql/types/memberships/metadata.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Memberships + class Metadata < GraphqlPagination::CollectionMetadataType + field :admin_count, Integer, null: false + + def admin_count + context[:current_organization].memberships.active.admins.count + end + end + end +end diff --git a/app/graphql/types/memberships/status_enum.rb b/app/graphql/types/memberships/status_enum.rb new file mode 100644 index 0000000..7bc31d4 --- /dev/null +++ b/app/graphql/types/memberships/status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Memberships + class StatusEnum < Types::BaseEnum + graphql_name "MembershipStatus" + + Membership::STATUSES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/metadata/input.rb b/app/graphql/types/metadata/input.rb new file mode 100644 index 0000000..da9faf9 --- /dev/null +++ b/app/graphql/types/metadata/input.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Metadata + class Input < Types::BaseInputObject + graphql_name "MetadataInput" + description "Input for metadata key-value pair" + + argument :key, String, required: true + argument :value, String, required: :nullable + + ARGUMENT_OPTIONS = { + prepare: ->(value, _ctx) { value&.reduce({}) { |h, item| h.merge(item[:key] => item[:value]) } }, + validates: {::Validators::UniqueByFieldValidator => {field_name: :key}} + }.freeze + end + end +end diff --git a/app/graphql/types/metadata/object.rb b/app/graphql/types/metadata/object.rb new file mode 100644 index 0000000..e83afe4 --- /dev/null +++ b/app/graphql/types/metadata/object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Metadata + class Object < Types::BaseObject + graphql_name "ItemMetadata" + description "Metadata key-value pair" + + # metadata is stored as a jsonb object, so when sent as array it comes as array of [key, value] pairs + field :key, String, null: false, method: :first + field :value, String, null: true, method: :last + end + end +end diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb new file mode 100644 index 0000000..9587abf --- /dev/null +++ b/app/graphql/types/mutation_type.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +module Types + class MutationType < Types::BaseObject + field :login_user, mutation: Mutations::LoginUser + field :register_user, mutation: Mutations::RegisterUser + + field :update_organization, mutation: Mutations::Organizations::Update + + field :create_billable_metric, mutation: Mutations::BillableMetrics::Create + field :destroy_billable_metric, mutation: Mutations::BillableMetrics::Destroy + field :update_billable_metric, mutation: Mutations::BillableMetrics::Update + + field :billing_entity_apply_taxes, mutation: Mutations::BillingEntities::ApplyTaxes + field :billing_entity_remove_taxes, mutation: Mutations::BillingEntities::RemoveTaxes + field :billing_entity_update_applied_dunning_campaign, mutation: Mutations::BillingEntities::UpdateAppliedDunningCampaign + field :create_billing_entity, mutation: Mutations::BillingEntities::Create + field :destroy_billing_entity, mutation: Mutations::BillingEntities::Destroy + field :update_billing_entity, mutation: Mutations::BillingEntities::Update + + field :create_adjusted_fee, mutation: Mutations::AdjustedFees::Create + field :destroy_adjusted_fee, mutation: Mutations::AdjustedFees::Destroy + field :preview_adjusted_fee, mutation: Mutations::AdjustedFees::Preview + + field :create_plan, mutation: Mutations::Plans::Create + field :destroy_plan, mutation: Mutations::Plans::Destroy + field :update_plan, mutation: Mutations::Plans::Update + + field :create_charge, mutation: Mutations::Charges::Create + field :destroy_charge, mutation: Mutations::Charges::Destroy + field :update_charge, mutation: Mutations::Charges::Update + + field :create_charge_filter, mutation: Mutations::ChargeFilters::Create + field :destroy_charge_filter, mutation: Mutations::ChargeFilters::Destroy + field :update_charge_filter, mutation: Mutations::ChargeFilters::Update + + field :create_fixed_charge, mutation: Mutations::FixedCharges::Create + field :destroy_fixed_charge, mutation: Mutations::FixedCharges::Destroy + field :update_fixed_charge, mutation: Mutations::FixedCharges::Update + + field :create_customer, mutation: Mutations::Customers::Create + field :destroy_customer, mutation: Mutations::Customers::Destroy + field :update_customer, mutation: Mutations::Customers::Update + field :update_customer_invoice_grace_period, mutation: Mutations::Customers::UpdateInvoiceGracePeriod + + field :create_customer_portal_wallet_transaction, mutation: Mutations::CustomerPortal::WalletTransactions::Create + field :download_customer_portal_invoice, mutation: Mutations::CustomerPortal::DownloadInvoice + field :generate_customer_portal_url, mutation: Mutations::CustomerPortal::GenerateUrl + field :update_customer_portal_customer, mutation: Mutations::CustomerPortal::UpdateCustomer + + field :create_credit_notes_data_export, mutation: Mutations::DataExports::CreditNotes::Create + field :create_invoices_data_export, mutation: Mutations::DataExports::Invoices::Create + + field :create_subscription, mutation: Mutations::Subscriptions::Create + field :terminate_subscription, mutation: Mutations::Subscriptions::Terminate + field :update_subscription, mutation: Mutations::Subscriptions::Update + field :update_subscription_charge, mutation: Mutations::Subscriptions::UpdateCharge + field :update_subscription_fixed_charge, mutation: Mutations::Subscriptions::UpdateFixedCharge + + field :create_subscription_charge_filter, mutation: Mutations::Subscriptions::CreateChargeFilter + field :destroy_subscription_charge_filter, mutation: Mutations::Subscriptions::DestroyChargeFilter + field :update_subscription_charge_filter, mutation: Mutations::Subscriptions::UpdateChargeFilter + + field :create_coupon, mutation: Mutations::Coupons::Create + field :destroy_coupon, mutation: Mutations::Coupons::Destroy + field :terminate_coupon, mutation: Mutations::Coupons::Terminate + field :update_coupon, mutation: Mutations::Coupons::Update + + field :create_add_on, mutation: Mutations::AddOns::Create + field :create_applied_coupon, mutation: Mutations::AppliedCoupons::Create + field :destroy_add_on, mutation: Mutations::AddOns::Destroy + field :terminate_applied_coupon, mutation: Mutations::AppliedCoupons::Terminate + field :update_add_on, mutation: Mutations::AddOns::Update + + field :add_adyen_payment_provider, mutation: Mutations::PaymentProviders::Adyen::Create + field :add_cashfree_payment_provider, mutation: Mutations::PaymentProviders::Cashfree::Create + field :add_flutterwave_payment_provider, mutation: Mutations::PaymentProviders::Flutterwave::Create + field :add_gocardless_payment_provider, mutation: Mutations::PaymentProviders::Gocardless::Create + field :add_moneyhash_payment_provider, mutation: Mutations::PaymentProviders::Moneyhash::Create + field :add_stripe_payment_provider, mutation: Mutations::PaymentProviders::Stripe::Create + + field :update_adyen_payment_provider, mutation: Mutations::PaymentProviders::Adyen::Update + field :update_cashfree_payment_provider, mutation: Mutations::PaymentProviders::Cashfree::Update + field :update_flutterwave_payment_provider, mutation: Mutations::PaymentProviders::Flutterwave::Update + field :update_gocardless_payment_provider, mutation: Mutations::PaymentProviders::Gocardless::Update + field :update_moneyhash_payment_provider, mutation: Mutations::PaymentProviders::Moneyhash::Update + field :update_stripe_payment_provider, mutation: Mutations::PaymentProviders::Stripe::Update + + field :destroy_payment_provider, mutation: Mutations::PaymentProviders::Destroy + + field :destroy_payment_method, mutation: Mutations::PaymentMethods::Destroy + field :generate_checkout_url, mutation: Mutations::PaymentMethods::GenerateCheckoutUrl + field :set_payment_method_as_default, mutation: Mutations::PaymentMethods::SetAsDefault + + field :create_netsuite_integration, mutation: Mutations::Integrations::Netsuite::Create + field :destroy_integration, mutation: Mutations::Integrations::Destroy + field :update_netsuite_integration, mutation: Mutations::Integrations::Netsuite::Update + + field :create_integration_mapping, mutation: Mutations::IntegrationMappings::Create + field :update_integration_mapping, mutation: Mutations::IntegrationMappings::Update + + field :create_integration_collection_mapping, mutation: Mutations::IntegrationCollectionMappings::Create + field :update_integration_collection_mapping, mutation: Mutations::IntegrationCollectionMappings::Update + + field :destroy_integration_collection_mapping, mutation: Mutations::IntegrationCollectionMappings::Destroy + field :destroy_integration_mapping, mutation: Mutations::IntegrationMappings::Destroy + + field :fetch_integration_accounts, mutation: Mutations::IntegrationItems::FetchAccounts + field :fetch_integration_items, mutation: Mutations::IntegrationItems::FetchItems + + field :sync_hubspot_integration_invoice, mutation: Mutations::Integrations::Hubspot::SyncInvoice + field :sync_integration_credit_note, mutation: Mutations::Integrations::SyncCreditNote + field :sync_integration_invoice, mutation: Mutations::Integrations::SyncInvoice + field :sync_salesforce_invoice, mutation: Mutations::Integrations::Salesforce::SyncInvoice + + field :create_credit_note, mutation: Mutations::CreditNotes::Create + field :download_credit_note, mutation: Mutations::CreditNotes::Download + field :download_xml_credit_note, mutation: Mutations::CreditNotes::DownloadXml + field :resend_credit_note_email, mutation: Mutations::CreditNotes::ResendEmail + field :retry_tax_reporting, mutation: Mutations::CreditNotes::RetryTaxReporting + field :update_credit_note, mutation: Mutations::CreditNotes::Update + field :void_credit_note, mutation: Mutations::CreditNotes::Void + + field :create_invoice, mutation: Mutations::Invoices::Create + field :download_invoice, mutation: Mutations::Invoices::Download + field :download_invoice_xml, mutation: Mutations::Invoices::DownloadXml + field :finalize_all_invoices, mutation: Mutations::Invoices::FinalizeAll + field :finalize_invoice, mutation: Mutations::Invoices::Finalize + field :generate_payment_url, mutation: Mutations::Invoices::GeneratePaymentUrl + field :lose_invoice_dispute, mutation: Mutations::Invoices::LoseDispute + field :refresh_invoice, mutation: Mutations::Invoices::Refresh + field :regenerate_from_voided, mutation: Mutations::Invoices::RegenerateFromVoided + field :resend_invoice_email, mutation: Mutations::Invoices::ResendEmail + field :retry_all_invoice_payments, mutation: Mutations::Invoices::RetryAllPayments + field :retry_all_invoices, mutation: Mutations::Invoices::RetryAll + field :retry_invoice, mutation: Mutations::Invoices::Retry + field :retry_invoice_payment, mutation: Mutations::Invoices::RetryPayment + field :retry_tax_provider_voiding, mutation: Mutations::Invoices::RetryTaxProviderVoiding + field :update_invoice, mutation: Mutations::Invoices::Update + field :void_invoice, mutation: Mutations::Invoices::Void + + field :create_quote, mutation: Mutations::Quotes::Create + field :update_quote, mutation: Mutations::Quotes::Update + + field :approve_quote_version, mutation: Mutations::QuoteVersions::Approve + field :clone_quote_version, mutation: Mutations::QuoteVersions::Clone + field :update_quote_version, mutation: Mutations::QuoteVersions::Update + field :void_quote_version, mutation: Mutations::QuoteVersions::Void + + field :download_payment_receipt, mutation: Mutations::PaymentReceipts::Download + field :download_xml_payment_receipt, mutation: Mutations::PaymentReceipts::DownloadXml + field :resend_payment_receipt_email, mutation: Mutations::PaymentReceipts::ResendEmail + + field :create_customer_wallet, mutation: Mutations::Wallets::Create + field :terminate_customer_wallet, mutation: Mutations::Wallets::Terminate + field :update_customer_wallet, mutation: Mutations::Wallets::Update + + field :create_customer_wallet_transaction, mutation: Mutations::WalletTransactions::Create + + field :create_customer_wallet_alert, mutation: Mutations::Wallets::Alerts::Create + field :destroy_customer_wallet_alert, mutation: Mutations::Wallets::Alerts::Destroy + field :update_customer_wallet_alert, mutation: Mutations::Wallets::Alerts::Update + + field :accept_invite, mutation: Mutations::Invites::Accept + field :create_invite, mutation: Mutations::Invites::Create + field :revoke_invite, mutation: Mutations::Invites::Revoke + field :update_invite, mutation: Mutations::Invites::Update + + field :revoke_membership, mutation: Mutations::Memberships::Revoke + field :update_membership, mutation: Mutations::Memberships::Update + + field :create_payment, mutation: Mutations::Payments::Create + + field :create_payment_request, mutation: Mutations::PaymentRequests::Create + + field :create_password_reset, mutation: Mutations::PasswordResets::Create + field :reset_password, mutation: Mutations::PasswordResets::Reset + + field :create_tax, mutation: Mutations::Taxes::Create + field :destroy_tax, mutation: Mutations::Taxes::Destroy + field :update_tax, mutation: Mutations::Taxes::Update + + field :retry_webhook, mutation: Mutations::Webhooks::Retry + + field :create_webhook_endpoint, mutation: Mutations::WebhookEndpoints::Create + field :destroy_webhook_endpoint, mutation: Mutations::WebhookEndpoints::Destroy + field :update_webhook_endpoint, mutation: Mutations::WebhookEndpoints::Update + + field :google_accept_invite, mutation: Mutations::Auth::Google::AcceptInvite + field :google_login_user, mutation: Mutations::Auth::Google::LoginUser + field :google_register_user, mutation: Mutations::Auth::Google::RegisterUser + + field :create_okta_integration, mutation: Mutations::Integrations::Okta::Create + field :update_okta_integration, mutation: Mutations::Integrations::Okta::Update + + field :create_anrok_integration, mutation: Mutations::Integrations::Anrok::Create + field :update_anrok_integration, mutation: Mutations::Integrations::Anrok::Update + + field :create_avalara_integration, mutation: Mutations::Integrations::Avalara::Create + field :update_avalara_integration, mutation: Mutations::Integrations::Avalara::Update + + field :fetch_draft_invoice_taxes, mutation: Mutations::Integrations::FetchDraftInvoiceTaxes + + field :create_xero_integration, mutation: Mutations::Integrations::Xero::Create + field :update_xero_integration, mutation: Mutations::Integrations::Xero::Update + + field :create_hubspot_integration, mutation: Mutations::Integrations::Hubspot::Create + field :update_hubspot_integration, mutation: Mutations::Integrations::Hubspot::Update + + field :create_salesforce_integration, mutation: Mutations::Integrations::Salesforce::Create + field :update_salesforce_integration, mutation: Mutations::Integrations::Salesforce::Update + + field :okta_accept_invite, mutation: Mutations::Auth::Okta::AcceptInvite + field :okta_authorize, mutation: Mutations::Auth::Okta::Authorize + field :okta_login, mutation: Mutations::Auth::Okta::Login + + field :create_dunning_campaign, mutation: Mutations::DunningCampaigns::Create + field :destroy_dunning_campaign, mutation: Mutations::DunningCampaigns::Destroy + field :update_dunning_campaign, mutation: Mutations::DunningCampaigns::Update + + field :create_api_key, mutation: Mutations::ApiKeys::Create + field :destroy_api_key, mutation: Mutations::ApiKeys::Destroy + field :rotate_api_key, mutation: Mutations::ApiKeys::Rotate + field :update_api_key, mutation: Mutations::ApiKeys::Update + + field :create_pricing_unit, mutation: Mutations::PricingUnits::Create + field :update_pricing_unit, mutation: Mutations::PricingUnits::Update + + field :create_role, mutation: Mutations::Roles::Create + field :destroy_role, mutation: Mutations::Roles::Destroy + field :update_role, mutation: Mutations::Roles::Update + + field :create_invoice_custom_section, mutation: Mutations::InvoiceCustomSections::Create + field :destroy_invoice_custom_section, mutation: Mutations::InvoiceCustomSections::Destroy + field :update_invoice_custom_section, mutation: Mutations::InvoiceCustomSections::Update + + field :create_subscription_alert, mutation: Mutations::Subscriptions::Alerts::Create + field :destroy_subscription_alert, mutation: Mutations::Subscriptions::Alerts::Destroy + field :update_subscription_alert, mutation: Mutations::Subscriptions::Alerts::Update + + field :create_feature, mutation: Mutations::Entitlement::CreateFeature + field :destroy_feature, mutation: Mutations::Entitlement::DestroyFeature + field :update_feature, mutation: Mutations::Entitlement::UpdateFeature + + field :create_or_update_subscription_entitlement, mutation: Mutations::Entitlement::CreateOrUpdateSubscriptionEntitlement + field :remove_subscription_entitlement, mutation: Mutations::Entitlement::RemoveSubscriptionEntitlement + + field :create_ai_conversation, mutation: Mutations::AiConversations::Create + end +end diff --git a/app/graphql/types/node_type.rb b/app/graphql/types/node_type.rb new file mode 100644 index 0000000..c71ec3e --- /dev/null +++ b/app/graphql/types/node_type.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Types + module NodeType + include Types::BaseInterface + # Add the `id` field + include GraphQL::Types::Relay::NodeBehaviors + end +end diff --git a/app/graphql/types/obfuscated_string_type.rb b/app/graphql/types/obfuscated_string_type.rb new file mode 100644 index 0000000..7538daa --- /dev/null +++ b/app/graphql/types/obfuscated_string_type.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class ObfuscatedStringType < GraphQL::Schema::Scalar + def self.coerce_result(value, _ctx) + return nil unless value + + "#{"•" * 8}…#{value.to_s[-3..]}" + end + end +end diff --git a/app/graphql/types/organizations/authentication_methods_enum.rb b/app/graphql/types/organizations/authentication_methods_enum.rb new file mode 100644 index 0000000..82189aa --- /dev/null +++ b/app/graphql/types/organizations/authentication_methods_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Organizations + class AuthenticationMethodsEnum < Types::BaseEnum + description "Organization Authentication Methods Values" + + Organization::AUTHENTICATION_METHODS.each do |method| + value method + end + end + end +end diff --git a/app/graphql/types/organizations/base_organization_type.rb b/app/graphql/types/organizations/base_organization_type.rb new file mode 100644 index 0000000..ba7b42d --- /dev/null +++ b/app/graphql/types/organizations/base_organization_type.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Organizations + class BaseOrganizationType < BaseObject + def billing_configuration + { + id: "#{object&.id}-c0nf", # Each nested object needs ID so that appollo cache system can work properly + invoice_footer: object&.invoice_footer, + invoice_grace_period: object&.invoice_grace_period, + document_locale: object&.document_locale, + eu_tax_management: object&.eu_tax_management + } + end + end + end +end diff --git a/app/graphql/types/organizations/billing_configuration.rb b/app/graphql/types/organizations/billing_configuration.rb new file mode 100644 index 0000000..af4d787 --- /dev/null +++ b/app/graphql/types/organizations/billing_configuration.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Organizations + class BillingConfiguration < Types::BaseObject + graphql_name "OrganizationBillingConfiguration" + + field :document_locale, String + field :id, ID, null: false + field :invoice_footer, String + field :invoice_grace_period, Integer, null: false + end + end +end diff --git a/app/graphql/types/organizations/billing_configuration_input.rb b/app/graphql/types/organizations/billing_configuration_input.rb new file mode 100644 index 0000000..77fd169 --- /dev/null +++ b/app/graphql/types/organizations/billing_configuration_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Organizations + class BillingConfigurationInput < BaseInputObject + graphql_name "OrganizationBillingConfigurationInput" + + argument :document_locale, String, required: false + argument :invoice_footer, String, required: false + argument :invoice_grace_period, Integer, required: false + end + end +end diff --git a/app/graphql/types/organizations/current_organization_type.rb b/app/graphql/types/organizations/current_organization_type.rb new file mode 100644 index 0000000..de7a249 --- /dev/null +++ b/app/graphql/types/organizations/current_organization_type.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Types + module Organizations + class CurrentOrganizationType < BaseOrganizationType + description "Current Organization Type" + + field :id, ID, null: false + field :logo_url, String + field :name, String, null: false + field :slug, String, null: false + field :timezone, Types::TimezoneEnum + + field :default_currency, Types::CurrencyEnum, null: false + field :email, String + + field :legal_name, String + field :legal_number, String + field :tax_identification_number, String + + field :address_line1, String + field :address_line2, String + field :city, String + field :country, Types::CountryCodeEnum + field :net_payment_term, Integer, null: false + field :state, String + field :zipcode, String + + field :api_key, String, permission: "developers:keys:manage" + field :hmac_key, String, permission: "developers:keys:manage" + field :webhook_url, String, permission: "developers:manage" + + field :document_number_prefix, String, null: false + field :document_numbering, Types::Organizations::DocumentNumberingEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :eu_tax_management, Boolean, null: false + + # TODO: Also check if Nango ENV var is set in order to lock/unlock this feature + # This would enable us to use premium add_on logic on OSS version + field :premium_integrations, [Types::Integrations::PremiumIntegrationTypeEnum], null: false + + field :feature_flags, [Types::Organizations::FeatureFlagEnum], null: false + + field :billing_configuration, Types::Organizations::BillingConfiguration, permission: "organization:invoices:view" + field :email_settings, [Types::Organizations::EmailSettingsEnum], permission: "organization:emails:view" + field :finalize_zero_amount_invoice, Boolean, null: false + field :taxes, [Types::Taxes::Object], resolver: Resolvers::TaxesResolver, permission: "organization:taxes:view" + + field :adyen_payment_providers, [Types::PaymentProviders::Adyen], permission: "organization:integrations:view" + field :cashfree_payment_providers, [Types::PaymentProviders::Cashfree], permission: "organization:integrations:view" + field :gocardless_payment_providers, [Types::PaymentProviders::Gocardless], permission: "organization:integrations:view" + field :stripe_payment_providers, [Types::PaymentProviders::Stripe], permission: "organization:integrations:view" + + field :applied_dunning_campaign, Types::DunningCampaigns::Object + field :can_create_billing_entity, Boolean, null: false, method: :can_create_billing_entity? + + field :accessible_by_current_session, Boolean, null: false + field :authenticated_method, Types::Organizations::AuthenticationMethodsEnum, null: false + field :authentication_methods, [Types::Organizations::AuthenticationMethodsEnum], null: false + + def feature_flags + object.feature_flags.select { |flag| FeatureFlag.valid?(flag) } + end + + def webhook_url + object.webhook_endpoints.map(&:webhook_url).first + end + + def api_key + object.api_keys.first.value + end + + def accessible_by_current_session + object.authentication_methods.include?(context[:login_method]) + end + + def authenticated_method + context[:login_method] + end + end + end +end diff --git a/app/graphql/types/organizations/document_numbering_enum.rb b/app/graphql/types/organizations/document_numbering_enum.rb new file mode 100644 index 0000000..424006c --- /dev/null +++ b/app/graphql/types/organizations/document_numbering_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Organizations + class DocumentNumberingEnum < Types::BaseEnum + description "Document numbering type" + + Organization::DOCUMENT_NUMBERINGS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/organizations/email_settings_enum.rb b/app/graphql/types/organizations/email_settings_enum.rb new file mode 100644 index 0000000..b568a90 --- /dev/null +++ b/app/graphql/types/organizations/email_settings_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Organizations + class EmailSettingsEnum < Types::BaseEnum + description "Organization Email Settings Values" + + Organization::EMAIL_SETTINGS.each do |value| + value(value.tr(".", "_"), value, value:) + end + end + end +end diff --git a/app/graphql/types/organizations/feature_flag_enum.rb b/app/graphql/types/organizations/feature_flag_enum.rb new file mode 100644 index 0000000..fe82870 --- /dev/null +++ b/app/graphql/types/organizations/feature_flag_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Organizations + class FeatureFlagEnum < Types::BaseEnum + description "Organization Feature Flag Values" + + FeatureFlag::DEFINITION.each_key do |flag| + value flag + end + end + end +end diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb new file mode 100644 index 0000000..5cc96de --- /dev/null +++ b/app/graphql/types/organizations/organization_type.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Types + module Organizations + # This type is used to expose organization information to users that + # are potentially not members of that organization. It's used where the organization + # is a relationship on the other object. + # It cannot expose any sensitive fields like `api_key` because there is a risk of GraphQL traversal attack. + # + # Only the `CurrentOrganizationType` can expose sensitive fields. + # + # Ex: current organization > memberships > another member > organizations can lead to an organization + # the current user is not supposed to have access to + class OrganizationType < BaseOrganizationType + description "Safe Organization Type" + + field :id, ID, null: false + + field :default_currency, Types::CurrencyEnum, null: false + field :logo_url, String + field :name, String, null: false + field :slug, String, null: false + field :timezone, Types::TimezoneEnum, null: true + + field :accessible_by_current_session, Boolean, null: false + + field :billing_configuration, Types::Organizations::BillingConfiguration, null: true + + field :can_create_billing_entity, Boolean, null: false, method: :can_create_billing_entity? + + def accessible_by_current_session + return false if context[:current_user].nil? + + context[:current_user].organizations.include?(object) && + object.authentication_methods.include?(context[:login_method]) + end + end + end +end diff --git a/app/graphql/types/organizations/update_organization_input.rb b/app/graphql/types/organizations/update_organization_input.rb new file mode 100644 index 0000000..3e58021 --- /dev/null +++ b/app/graphql/types/organizations/update_organization_input.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Types + module Organizations + class UpdateOrganizationInput < BaseInputObject + description "Update Organization input arguments" + + argument :authentication_methods, [Types::Organizations::AuthenticationMethodsEnum], required: false + argument :default_currency, Types::CurrencyEnum, required: false + argument :email, String, required: false + argument :legal_name, String, required: false + argument :legal_number, String, required: false + argument :logo, String, required: false + argument :slug, String, required: false + argument :tax_identification_number, String, required: false + + argument :address_line1, String, required: false + argument :address_line2, String, required: false + argument :city, String, required: false + argument :country, Types::CountryCodeEnum, required: false + argument :net_payment_term, Integer, required: false + argument :state, String, required: false + argument :zipcode, String, required: false + + argument :webhook_url, String, required: false, permission: "developers:manage" + + argument :timezone, Types::TimezoneEnum, required: false + + argument :eu_tax_management, Boolean, required: false + + argument :document_number_prefix, String, required: false + argument :document_numbering, Types::Organizations::DocumentNumberingEnum, required: false + + argument :billing_configuration, Types::Organizations::BillingConfigurationInput, required: false, permission: "organization:invoices:view" + argument :email_settings, [Types::Organizations::EmailSettingsEnum], required: false, permission: "organization:emails:view" + argument :finalize_zero_amount_invoice, Boolean, required: false + end + end +end diff --git a/app/graphql/types/payables/object.rb b/app/graphql/types/payables/object.rb new file mode 100644 index 0000000..98cf551 --- /dev/null +++ b/app/graphql/types/payables/object.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Payables + class Object < Types::BaseUnion + graphql_name "Payable" + + possible_types Types::Invoices::Object, Types::PaymentRequests::Object + + def self.resolve_type(object, _context) + case object.class.to_s + when "Invoice" + Types::Invoices::Object + when "PaymentRequest" + Types::PaymentRequests::Object + else + raise "Unexpected payable type: #{object.inspect}" + end + end + end + end +end diff --git a/app/graphql/types/payloads/login_user_type.rb b/app/graphql/types/payloads/login_user_type.rb new file mode 100644 index 0000000..c9f37d0 --- /dev/null +++ b/app/graphql/types/payloads/login_user_type.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Payloads + class LoginUserType < Types::BaseObject + field :token, String, null: false + field :user, Types::UserType, null: false + end + end +end diff --git a/app/graphql/types/payloads/register_user_type.rb b/app/graphql/types/payloads/register_user_type.rb new file mode 100644 index 0000000..c7340c2 --- /dev/null +++ b/app/graphql/types/payloads/register_user_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Payloads + class RegisterUserType < Types::BaseObject + field :membership, Types::MembershipType, null: false + field :organization, Types::Organizations::OrganizationType, null: false + field :token, String, null: false + field :user, Types::UserType, null: false + end + end +end diff --git a/app/graphql/types/payment_methods/details.rb b/app/graphql/types/payment_methods/details.rb new file mode 100644 index 0000000..592948c --- /dev/null +++ b/app/graphql/types/payment_methods/details.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PaymentMethods + class Details < Types::BaseObject + graphql_name "PaymentMethodDetails" + + field :brand, String, null: true + field :expiration_month, String, null: true + field :expiration_year, String, null: true + field :last4, String, null: true + field :type, String, null: true + end + end +end diff --git a/app/graphql/types/payment_methods/method_type_enum.rb b/app/graphql/types/payment_methods/method_type_enum.rb new file mode 100644 index 0000000..84cbff0 --- /dev/null +++ b/app/graphql/types/payment_methods/method_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module PaymentMethods + class MethodTypeEnum < Types::BaseEnum + graphql_name "PaymentMethodTypeEnum" + + PaymentMethod::PAYMENT_METHOD_TYPES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/payment_methods/object.rb b/app/graphql/types/payment_methods/object.rb new file mode 100644 index 0000000..bc5f436 --- /dev/null +++ b/app/graphql/types/payment_methods/object.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module PaymentMethods + class Object < Types::BaseObject + graphql_name "PaymentMethod" + + field :id, ID, null: false + + field :customer, Types::Customers::Object, null: false + field :details, Types::PaymentMethods::Details, null: true + field :is_default, Boolean, null: false + field :payment_provider_code, String, null: true + field :payment_provider_customer_id, ID, null: true + field :payment_provider_name, String, null: true + field :payment_provider_type, Types::PaymentProviders::ProviderTypeEnum, null: true + field :provider_method_id, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: true + + def payment_provider_code + object.payment_provider&.code + end + + def payment_provider_name + object.payment_provider&.name + end + end + end +end diff --git a/app/graphql/types/payment_methods/reference_input.rb b/app/graphql/types/payment_methods/reference_input.rb new file mode 100644 index 0000000..aab9ec2 --- /dev/null +++ b/app/graphql/types/payment_methods/reference_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module PaymentMethods + class ReferenceInput < Types::BaseInputObject + graphql_name "PaymentMethodReferenceInput" + + argument :payment_method_id, ID, required: false + argument :payment_method_type, Types::PaymentMethods::MethodTypeEnum, required: false + end + end +end diff --git a/app/graphql/types/payment_provider_customers/provider.rb b/app/graphql/types/payment_provider_customers/provider.rb new file mode 100644 index 0000000..57258f4 --- /dev/null +++ b/app/graphql/types/payment_provider_customers/provider.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module PaymentProviderCustomers + class Provider < Types::BaseObject + graphql_name "ProviderCustomer" + + field :id, ID, null: false + field :provider_customer_id, ID, null: true + field :provider_payment_methods, [Types::PaymentProviderCustomers::ProviderPaymentMethodsEnum], null: true + field :sync_with_provider, Boolean, null: true + end + end +end diff --git a/app/graphql/types/payment_provider_customers/provider_input.rb b/app/graphql/types/payment_provider_customers/provider_input.rb new file mode 100644 index 0000000..0625cfc --- /dev/null +++ b/app/graphql/types/payment_provider_customers/provider_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module PaymentProviderCustomers + class ProviderInput < BaseInputObject + graphql_name "ProviderCustomerInput" + + argument :provider_customer_id, ID, required: false + argument :provider_payment_methods, [Types::PaymentProviderCustomers::ProviderPaymentMethodsEnum], required: false + argument :sync_with_provider, Boolean, required: false + end + end +end diff --git a/app/graphql/types/payment_provider_customers/provider_payment_methods_enum.rb b/app/graphql/types/payment_provider_customers/provider_payment_methods_enum.rb new file mode 100644 index 0000000..1d67f5c --- /dev/null +++ b/app/graphql/types/payment_provider_customers/provider_payment_methods_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module PaymentProviderCustomers + class ProviderPaymentMethodsEnum < Types::BaseEnum + ::PaymentProviderCustomers::StripeCustomer::PAYMENT_METHODS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/payment_providers/adyen.rb b/app/graphql/types/payment_providers/adyen.rb new file mode 100644 index 0000000..ce5103a --- /dev/null +++ b/app/graphql/types/payment_providers/adyen.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Adyen < Types::BaseObject + graphql_name "AdyenProvider" + + field :code, String, null: false + field :id, ID, null: false + field :name, String, null: false + + field :api_key, ObfuscatedStringType, null: true, permission: "organization:integrations:view" + field :hmac_key, ObfuscatedStringType, null: true, permission: "organization:integrations:view" + field :live_prefix, String, null: true, permission: "organization:integrations:view" + field :merchant_account, String, null: false, permission: "organization:integrations:view" + field :success_redirect_url, String, null: true, permission: "organization:integrations:view" + end + end +end diff --git a/app/graphql/types/payment_providers/adyen_input.rb b/app/graphql/types/payment_providers/adyen_input.rb new file mode 100644 index 0000000..2157706 --- /dev/null +++ b/app/graphql/types/payment_providers/adyen_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class AdyenInput < BaseInputObject + description "Adyen input arguments" + + argument :api_key, String, required: true + argument :code, String, required: true + argument :hmac_key, String, required: false + argument :live_prefix, String, required: false + argument :merchant_account, String, required: true + argument :name, String, required: true + argument :success_redirect_url, String, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/cashfree.rb b/app/graphql/types/payment_providers/cashfree.rb new file mode 100644 index 0000000..ff13d58 --- /dev/null +++ b/app/graphql/types/payment_providers/cashfree.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Cashfree < Types::BaseObject + graphql_name "CashfreeProvider" + + field :code, String, null: false + field :id, ID, null: false + field :name, String, null: false + + field :client_id, String, null: true, permission: "organization:integrations:view" + field :client_secret, String, null: true, permission: "organization:integrations:view" + field :success_redirect_url, String, null: true, permission: "organization:integrations:view" + end + end +end diff --git a/app/graphql/types/payment_providers/cashfree_input.rb b/app/graphql/types/payment_providers/cashfree_input.rb new file mode 100644 index 0000000..7375a49 --- /dev/null +++ b/app/graphql/types/payment_providers/cashfree_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class CashfreeInput < BaseInputObject + description "Cashfree input arguments" + + argument :client_id, String, required: true + argument :client_secret, String, required: true + argument :code, String, required: true + argument :name, String, required: true + argument :success_redirect_url, String, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/flutterwave.rb b/app/graphql/types/payment_providers/flutterwave.rb new file mode 100644 index 0000000..9158fcf --- /dev/null +++ b/app/graphql/types/payment_providers/flutterwave.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Flutterwave < Types::BaseObject + graphql_name "FlutterwaveProvider" + + field :code, String, null: false + field :id, ID, null: false + field :name, String, null: false + field :secret_key, ObfuscatedStringType, null: true, permission: "organization:integrations:view" + field :success_redirect_url, String, null: true, permission: "organization:integrations:view" + field :webhook_secret, String, null: true, permission: "organization:integrations:view" + end + end +end diff --git a/app/graphql/types/payment_providers/flutterwave_input.rb b/app/graphql/types/payment_providers/flutterwave_input.rb new file mode 100644 index 0000000..a25312b --- /dev/null +++ b/app/graphql/types/payment_providers/flutterwave_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class FlutterwaveInput < BaseInputObject + description "Flutterwave input arguments" + + argument :code, String, required: true + argument :name, String, required: true + argument :secret_key, String, required: true + argument :success_redirect_url, String, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/gocardless.rb b/app/graphql/types/payment_providers/gocardless.rb new file mode 100644 index 0000000..2e28466 --- /dev/null +++ b/app/graphql/types/payment_providers/gocardless.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Gocardless < Types::BaseObject + graphql_name "GocardlessProvider" + + field :code, String, null: false + field :id, ID, null: false + field :name, String, null: false + + field :has_access_token, Boolean, null: false, permission: "organization:integrations:view" + field :success_redirect_url, String, null: true, permission: "organization:integrations:view" + field :webhook_secret, String, null: true, permission: "organization:integrations:view" + + # NOTE: Access token is a sensitive information. It should not be sent back to the + # front end application + def has_access_token + object.access_token.present? + end + end + end +end diff --git a/app/graphql/types/payment_providers/gocardless_input.rb b/app/graphql/types/payment_providers/gocardless_input.rb new file mode 100644 index 0000000..9424d79 --- /dev/null +++ b/app/graphql/types/payment_providers/gocardless_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class GocardlessInput < BaseInputObject + description "Gocardless input arguments" + + argument :access_code, String, required: false + argument :code, String, required: true + argument :name, String, required: true + argument :success_redirect_url, String, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/moneyhash.rb b/app/graphql/types/payment_providers/moneyhash.rb new file mode 100644 index 0000000..8336575 --- /dev/null +++ b/app/graphql/types/payment_providers/moneyhash.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Moneyhash < Types::BaseObject + graphql_name "MoneyhashProvider" + + field :api_key, String, null: true, permission: "organization:integrations:view" + field :code, String, null: false + field :flow_id, String, null: true, permission: "organization:integrations:view" + field :id, ID, null: false + field :name, String, null: false + field :success_redirect_url, String, null: true, permission: "organization:integrations:view" + + # NOTE: Api key is a sensitive information. It should not be sent back to the + # front end application. Instead we send an obfuscated value + def api_key + "#{"•" * 8}…#{object.api_key[-3..]}" + end + end + end +end diff --git a/app/graphql/types/payment_providers/moneyhash_input.rb b/app/graphql/types/payment_providers/moneyhash_input.rb new file mode 100644 index 0000000..a0600c2 --- /dev/null +++ b/app/graphql/types/payment_providers/moneyhash_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class MoneyhashInput < BaseInputObject + description "Moneyhash input arguments" + + argument :api_key, String, required: true + argument :code, String, required: true + argument :flow_id, String, required: true + argument :name, String, required: true + argument :success_redirect_url, String, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/object.rb b/app/graphql/types/payment_providers/object.rb new file mode 100644 index 0000000..6266b0c --- /dev/null +++ b/app/graphql/types/payment_providers/object.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Object < Types::BaseUnion + graphql_name "PaymentProvider" + + possible_types Types::PaymentProviders::Adyen, + Types::PaymentProviders::Gocardless, + Types::PaymentProviders::Stripe, + Types::PaymentProviders::Cashfree, + Types::PaymentProviders::Flutterwave, + Types::PaymentProviders::Moneyhash + + def self.resolve_type(object, _context) + case object.class.to_s + when "PaymentProviders::AdyenProvider" + Types::PaymentProviders::Adyen + when "PaymentProviders::StripeProvider" + Types::PaymentProviders::Stripe + when "PaymentProviders::GocardlessProvider" + Types::PaymentProviders::Gocardless + when "PaymentProviders::CashfreeProvider" + Types::PaymentProviders::Cashfree + when "PaymentProviders::FlutterwaveProvider" + Types::PaymentProviders::Flutterwave + when "PaymentProviders::MoneyhashProvider" + Types::PaymentProviders::Moneyhash + else + raise "Unexpected Payment provider type: #{object.inspect}" + end + end + end + end +end diff --git a/app/graphql/types/payment_providers/provider_type_enum.rb b/app/graphql/types/payment_providers/provider_type_enum.rb new file mode 100644 index 0000000..141d1a4 --- /dev/null +++ b/app/graphql/types/payment_providers/provider_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class ProviderTypeEnum < Types::BaseEnum + Customer::PAYMENT_PROVIDERS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/payment_providers/stripe.rb b/app/graphql/types/payment_providers/stripe.rb new file mode 100644 index 0000000..5179c31 --- /dev/null +++ b/app/graphql/types/payment_providers/stripe.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class Stripe < Types::BaseObject + graphql_name "StripeProvider" + + field :code, String, null: false + field :id, ID, null: false + field :name, String, null: false + + field :secret_key, ObfuscatedStringType, null: true, permission: "organization:integrations:view" + field :success_redirect_url, String, null: true, permission: "organization:integrations:view" + field :supports_3ds, Boolean, null: true + end + end +end diff --git a/app/graphql/types/payment_providers/stripe_input.rb b/app/graphql/types/payment_providers/stripe_input.rb new file mode 100644 index 0000000..316ea77 --- /dev/null +++ b/app/graphql/types/payment_providers/stripe_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class StripeInput < BaseInputObject + description "Stripe input arguments" + + argument :code, String, required: true + argument :name, String, required: true + argument :secret_key, String, required: false + argument :success_redirect_url, String, required: false + argument :supports_3ds, Boolean, required: false + end + end +end diff --git a/app/graphql/types/payment_providers/update_input.rb b/app/graphql/types/payment_providers/update_input.rb new file mode 100644 index 0000000..53934aa --- /dev/null +++ b/app/graphql/types/payment_providers/update_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module PaymentProviders + class UpdateInput < BaseInputObject + description "Update input arguments" + + argument :code, String, required: false + argument :flow_id, String, required: false + argument :id, ID, required: true + argument :name, String, required: false + argument :success_redirect_url, String, required: false + + argument :supports_3ds, Boolean, required: false + end + end +end diff --git a/app/graphql/types/payment_receipts/object.rb b/app/graphql/types/payment_receipts/object.rb new file mode 100644 index 0000000..12a6248 --- /dev/null +++ b/app/graphql/types/payment_receipts/object.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Types + module PaymentReceipts + class Object < Types::BaseObject + description "PaymentReceipt" + graphql_name "PaymentReceipt" + + field :id, ID, null: false + + field :file_url, String, null: true + field :number, String, null: false + field :organization, Types::Organizations::OrganizationType, null: false + field :payment, Types::Payments::Object, null: false + field :xml_url, String, null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/payment_requests/create_input.rb b/app/graphql/types/payment_requests/create_input.rb new file mode 100644 index 0000000..beda483 --- /dev/null +++ b/app/graphql/types/payment_requests/create_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module PaymentRequests + class CreateInput < Types::BaseInputObject + graphql_name "PaymentRequestCreateInput" + + argument :external_customer_id, String, required: true + + argument :email, String, required: false + argument :lago_invoice_ids, [String], required: false + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + end + end +end diff --git a/app/graphql/types/payment_requests/object.rb b/app/graphql/types/payment_requests/object.rb new file mode 100644 index 0000000..6b5f9a1 --- /dev/null +++ b/app/graphql/types/payment_requests/object.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module PaymentRequests + class Object < Types::BaseObject + graphql_name "PaymentRequest" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :email, String, null: false + field :payment_status, Types::Invoices::PaymentStatusTypeEnum, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :customer, Types::Customers::Object, null: false + field :invoices, [Types::Invoices::Object], null: false + field :payable_type, GraphQL::Types::String, null: false + + def payable_type + "PaymentRequest" + end + end + end +end diff --git a/app/graphql/types/payments/create_input.rb b/app/graphql/types/payments/create_input.rb new file mode 100644 index 0000000..52f7835 --- /dev/null +++ b/app/graphql/types/payments/create_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Payments + class CreateInput < Types::BaseInputObject + graphql_name "CreatePaymentInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :created_at, GraphQL::Types::ISO8601DateTime, required: true + argument :invoice_id, ID, required: true + argument :reference, String, required: true + end + end +end diff --git a/app/graphql/types/payments/object.rb b/app/graphql/types/payments/object.rb new file mode 100644 index 0000000..2327a3e --- /dev/null +++ b/app/graphql/types/payments/object.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Payments + class Object < Types::BaseObject + graphql_name "Payment" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :customer, Types::Customers::Object, null: false + field :payable, Types::Payables::Object, null: false + field :payable_payment_status, Types::Payments::PayablePaymentStatusEnum, null: true + field :payment_method_id, ID, null: true + field :payment_provider, Types::PaymentProviders::Object, null: true + field :payment_provider_type, Types::PaymentProviders::ProviderTypeEnum, null: true + field :payment_receipt, Types::PaymentReceipts::Object, null: true + field :payment_type, Types::Payments::PaymentTypeEnum, null: false + field :provider_payment_id, GraphQL::Types::String, null: true + field :reference, GraphQL::Types::String, null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: true + end + end +end diff --git a/app/graphql/types/payments/payable_payment_status_enum.rb b/app/graphql/types/payments/payable_payment_status_enum.rb new file mode 100644 index 0000000..3f43aea --- /dev/null +++ b/app/graphql/types/payments/payable_payment_status_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Payments + class PayablePaymentStatusEnum < Types::BaseEnum + Payment::PAYABLE_PAYMENT_STATUS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/payments/payment_type_enum.rb b/app/graphql/types/payments/payment_type_enum.rb new file mode 100644 index 0000000..4042da9 --- /dev/null +++ b/app/graphql/types/payments/payment_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Payments + class PaymentTypeEnum < Types::BaseEnum + Payment::PAYMENT_TYPES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/permission_enum.rb b/app/graphql/types/permission_enum.rb new file mode 100644 index 0000000..25fb825 --- /dev/null +++ b/app/graphql/types/permission_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + class PermissionEnum < Types::BaseEnum + description "Permission" + + Permission.permissions_hash.each_key do |permission| + value permission.tr(":", "_"), value: permission + end + end +end diff --git a/app/graphql/types/permissions_type.rb b/app/graphql/types/permissions_type.rb new file mode 100644 index 0000000..40b9b66 --- /dev/null +++ b/app/graphql/types/permissions_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + class PermissionsType < Types::BaseObject + description "Permissions Type" + + # NOTE: GraphQL field names cannot contain colons, so we convert them to underscores + # `billing_metrics:view` becomes `billing_metrics_view` which becomes `billingMetricsView` + # https://spec.graphql.org/October2021/#sec-Punctuators + # https://spec.graphql.org/October2021/#sec-Names + Permission.permissions_hash.keys.each do |permissions| + field permissions.tr(":", "_"), Boolean, null: false + end + end +end diff --git a/app/graphql/types/plans/create_input.rb b/app/graphql/types/plans/create_input.rb new file mode 100644 index 0000000..75ca53f --- /dev/null +++ b/app/graphql/types/plans/create_input.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Types + module Plans + class CreateInput < Types::BaseInputObject + graphql_name "CreatePlanInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :amount_currency, Types::CurrencyEnum + argument :bill_charges_monthly, Boolean, required: false + argument :bill_fixed_charges_monthly, Boolean, required: false + argument :code, String, required: true + argument :description, String, required: false + argument :interval, Types::Plans::IntervalEnum, required: true + argument :invoice_display_name, String, required: false + argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS + argument :name, String, required: true + argument :pay_in_advance, Boolean, required: true + argument :tax_codes, [String], required: false + argument :trial_period, Float, required: false + + argument :charges, [Types::Charges::Input] + argument :fixed_charges, [Types::FixedCharges::Input], required: false + argument :minimum_commitment, Types::Commitments::Input, required: false + + argument :usage_thresholds, [Types::UsageThresholds::Input], required: false + + argument :entitlements, [Types::Entitlement::EntitlementInput], required: false + end + end +end diff --git a/app/graphql/types/plans/interval_enum.rb b/app/graphql/types/plans/interval_enum.rb new file mode 100644 index 0000000..32323da --- /dev/null +++ b/app/graphql/types/plans/interval_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Plans + class IntervalEnum < Types::BaseEnum + graphql_name "PlanInterval" + + Plan::INTERVALS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/plans/object.rb b/app/graphql/types/plans/object.rb new file mode 100644 index 0000000..33ffc06 --- /dev/null +++ b/app/graphql/types/plans/object.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module Types + module Plans + class Object < Types::BaseObject + graphql_name "Plan" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + field :bill_charges_monthly, Boolean + field :bill_fixed_charges_monthly, Boolean + field :code, String, null: false + field :description, String + field :interval, Types::Plans::IntervalEnum, null: false + field :invoice_display_name, String + field :minimum_commitment, Types::Commitments::Object, null: true + field :name, String, null: false + field :parent, Types::Plans::Object, null: true + field :pay_in_advance, Boolean, null: false + field :trial_period, Float + + field :applicable_usage_thresholds, [Types::UsageThresholds::Object] + field :usage_thresholds, [Types::UsageThresholds::Object] + + field :entitlements, [Types::Entitlement::PlanEntitlementObject] + + field :activity_logs, [Types::ActivityLogs::Object], null: true + field :charges, [Types::Charges::Object] + field :fixed_charges, [Types::FixedCharges::Object] + field :taxes, [Types::Taxes::Object] + + field :has_active_subscriptions, Boolean, null: false + field :has_charges, Boolean, null: false + field :has_customers, Boolean, null: false + field :has_draft_invoices, Boolean, null: false + field :has_fixed_charges, Boolean, null: false + field :has_overridden_plans, Boolean + field :has_subscriptions, Boolean, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :active_subscriptions_count, Integer, null: false + field :charges_count, Integer, null: false, description: "Number of charges attached to a plan" + field :customers_count, Integer, null: false, description: "Number of customers attached to a plan" + field :draft_invoices_count, Integer, null: false + field :fixed_charges_count, Integer, null: false, description: "Number of fixed charges attached to a plan" + field :is_overridden, Boolean, null: false + field :subscriptions_count, Integer, null: false + + field :metadata, [Types::Metadata::Object], null: true + + def entitlements + object.entitlements.order(:created_at) + end + + def applicable_usage_thresholds + object.applicable_usage_thresholds.order(amount_cents: :asc) + end + + def usage_thresholds + object.usage_thresholds.order(amount_cents: :asc) + end + + def charges + object.charges.includes(filters: {values: :billable_metric_filter}).order(created_at: :asc) + end + + def fixed_charges + object.fixed_charges.order(created_at: :asc) + end + + def charges_count + object.charges.count + end + + def fixed_charges_count + object.fixed_charges.count + end + + def subscriptions_count + count = object.subscriptions.count + return count unless object.children + + count + object.children.joins(:subscriptions).select("subscriptions.id").distinct.count + end + + def is_overridden + object.parent_id.present? + end + + def has_active_subscriptions + object.subscriptions.active.exists? || has_active_subscriptions_on_children + end + + def has_active_subscriptions_on_children + object.children.joins(:subscriptions).merge(Subscription.active).exists? + end + + # NOTE: should this one include children charges? + def has_charges + object.charges.exists? + end + + def has_fixed_charges + object.fixed_charges.exists? + end + + # NOTE: if it has active subscriptions, it has customers + def has_customers + has_active_subscriptions + end + + def has_draft_invoices + object.invoices.draft.exists? || has_draft_invoices_on_children + end + + def has_draft_invoices_on_children + object.children.joins(:invoices).merge(Invoice.draft).exists? + end + + def has_overridden_plans + object.children.exists? + end + + def has_subscriptions + object.subscriptions.exists? || has_subscriptions_on_children + end + + def has_subscriptions_on_children + object.children.joins(:subscriptions).exists? + end + + def metadata + object.metadata&.value + end + end + end +end diff --git a/app/graphql/types/plans/update_input.rb b/app/graphql/types/plans/update_input.rb new file mode 100644 index 0000000..5ea51fe --- /dev/null +++ b/app/graphql/types/plans/update_input.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Types + module Plans + class UpdateInput < Types::BaseInputObject + graphql_name "UpdatePlanInput" + + argument :id, ID, required: true + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :amount_currency, Types::CurrencyEnum, required: true + argument :bill_charges_monthly, Boolean, required: false + argument :bill_fixed_charges_monthly, Boolean, required: false + argument :cascade_updates, Boolean, required: false + argument :code, String, required: true + argument :description, String, required: false + argument :interval, Types::Plans::IntervalEnum, required: true + argument :invoice_display_name, String, required: false + argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS + argument :name, String, required: true + argument :pay_in_advance, Boolean, required: true + argument :tax_codes, [String], required: false + argument :trial_period, Float, required: false + + argument :charges, [Types::Charges::Input] + argument :fixed_charges, [Types::FixedCharges::Input], required: false + argument :minimum_commitment, Types::Commitments::Input, required: false + + argument :usage_thresholds, [Types::UsageThresholds::Input], required: false + + argument :entitlements, [Types::Entitlement::EntitlementInput], required: false + end + end +end diff --git a/app/graphql/types/pricing_unit_usages/object.rb b/app/graphql/types/pricing_unit_usages/object.rb new file mode 100644 index 0000000..2ae29b8 --- /dev/null +++ b/app/graphql/types/pricing_unit_usages/object.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module PricingUnitUsages + class Object < Types::BaseObject + graphql_name "PricingUnitUsage" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :conversion_rate, GraphQL::Types::Float, null: false + field :precise_amount_cents, GraphQL::Types::Float, null: false + field :precise_unit_amount, GraphQL::Types::Float, null: false + field :pricing_unit, Types::PricingUnits::Object, null: false + field :short_name, String, null: false + field :unit_amount_cents, GraphQL::Types::BigInt, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/pricing_units/create_input.rb b/app/graphql/types/pricing_units/create_input.rb new file mode 100644 index 0000000..0a7fd91 --- /dev/null +++ b/app/graphql/types/pricing_units/create_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module PricingUnits + class CreateInput < Types::BaseInputObject + argument :code, String, required: true + argument :description, String, required: false + argument :name, String, required: true + argument :short_name, String, required: true + end + end +end diff --git a/app/graphql/types/pricing_units/object.rb b/app/graphql/types/pricing_units/object.rb new file mode 100644 index 0000000..d472296 --- /dev/null +++ b/app/graphql/types/pricing_units/object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module PricingUnits + class Object < Types::BaseObject + graphql_name "PricingUnit" + + field :id, ID, null: false + + field :code, String, null: false + field :description, String, null: true + field :name, String, null: false + field :short_name, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/pricing_units/update_input.rb b/app/graphql/types/pricing_units/update_input.rb new file mode 100644 index 0000000..4b097cd --- /dev/null +++ b/app/graphql/types/pricing_units/update_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module PricingUnits + class UpdateInput < Types::BaseInputObject + argument :id, ID, required: true + + argument :description, String, required: false + argument :name, String, required: false + argument :short_name, String, required: false + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb new file mode 100644 index 0000000..337ae1a --- /dev/null +++ b/app/graphql/types/query_type.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module Types + # QueryType + class QueryType < Types::BaseObject + # Add `node(id: ID!) and `nodes(ids: [ID!]!)` + include GraphQL::Types::Relay::HasNodeField + include GraphQL::Types::Relay::HasNodesField + + field :current_user, resolver: Resolvers::CurrentUserResolver + + field :activity_log, resolver: Resolvers::ActivityLogResolver + field :activity_logs, resolver: Resolvers::ActivityLogsResolver + field :add_on, resolver: Resolvers::AddOnResolver + field :add_ons, resolver: Resolvers::AddOnsResolver + field :ai_conversation, resolver: Resolvers::AiConversationResolver + field :ai_conversations, resolver: Resolvers::AiConversationsResolver + field :alert, resolver: Resolvers::Subscriptions::AlertResolver + field :alerts, resolver: Resolvers::Subscriptions::AlertsResolver + field :api_key, resolver: Resolvers::ApiKeyResolver + field :api_keys, resolver: Resolvers::ApiKeysResolver + field :api_log, resolver: Resolvers::ApiLogResolver + field :api_logs, resolver: Resolvers::ApiLogsResolver + field :applied_coupons, resolver: Resolvers::AppliedCouponsResolver + field :billable_metric, resolver: Resolvers::BillableMetricResolver + field :billable_metrics, resolver: Resolvers::BillableMetricsResolver + field :billing_entities, resolver: Resolvers::BillingEntitiesResolver + field :billing_entity, resolver: Resolvers::BillingEntityResolver + field :billing_entity_taxes, resolver: Resolvers::BillingEntityTaxesResolver + field :coupon, resolver: Resolvers::CouponResolver + field :coupons, resolver: Resolvers::CouponsResolver + field :credit_note, resolver: Resolvers::CreditNoteResolver + field :credit_note_estimate, resolver: Resolvers::CreditNotes::EstimateResolver + field :credit_notes, resolver: Resolvers::CreditNotesResolver + field :current_version, resolver: Resolvers::VersionResolver + field :customer, resolver: Resolvers::CustomerResolver + field :customer_invoices, resolver: Resolvers::Customers::InvoicesResolver + field :customer_portal_customer_projected_usage, resolver: Resolvers::CustomerPortal::Customers::ProjectedUsageResolver + field :customer_portal_customer_usage, resolver: Resolvers::CustomerPortal::Customers::UsageResolver + field :customer_portal_invoice_collections, resolver: Resolvers::CustomerPortal::Analytics::InvoiceCollectionsResolver + field :customer_portal_invoices, resolver: Resolvers::CustomerPortal::InvoicesResolver + field :customer_portal_organization, resolver: Resolvers::CustomerPortal::OrganizationResolver + field :customer_portal_overdue_balances, resolver: Resolvers::CustomerPortal::Analytics::OverdueBalancesResolver + field :customer_portal_subscription, resolver: Resolvers::CustomerPortal::SubscriptionResolver + field :customer_portal_subscriptions, resolver: Resolvers::CustomerPortal::SubscriptionsResolver + field :customer_portal_user, resolver: Resolvers::CustomerPortal::CustomerResolver + field :customer_portal_wallet, resolver: Resolvers::CustomerPortal::WalletResolver + field :customer_portal_wallets, resolver: Resolvers::CustomerPortal::WalletsResolver + field :customer_projected_usage, resolver: Resolvers::Customers::ProjectedUsageResolver + field :customer_usage, resolver: Resolvers::Customers::UsageResolver + field :customers, resolver: Resolvers::CustomersResolver + field :dunning_campaign, resolver: Resolvers::DunningCampaignResolver + field :dunning_campaigns, resolver: Resolvers::DunningCampaignsResolver + field :event, resolver: Resolvers::EventResolver + field :event_types, resolver: Resolvers::EventTypesResolver + field :events, resolver: Resolvers::EventsResolver + field :feature, resolver: Resolvers::Entitlement::FeatureResolver + field :features, resolver: Resolvers::Entitlement::FeaturesResolver + field :google_auth_url, resolver: Resolvers::Auth::Google::AuthUrlResolver + field :gross_revenues, resolver: Resolvers::Analytics::GrossRevenuesResolver + field :integration, resolver: Resolvers::IntegrationResolver + field :integration_collection_mappings, resolver: Resolvers::IntegrationCollectionMappingsResolver + field :integration_items, resolver: Resolvers::IntegrationItemsResolver + field :integration_subsidiaries, resolver: Resolvers::Integrations::SubsidiariesResolver + field :integrations, resolver: Resolvers::IntegrationsResolver + field :invite, resolver: Resolvers::InviteResolver + field :invites, resolver: Resolvers::InvitesResolver + field :invoice, resolver: Resolvers::InvoiceResolver + field :invoice_collections, resolver: Resolvers::Analytics::InvoiceCollectionsResolver + field :invoice_credit_notes, resolver: Resolvers::InvoiceCreditNotesResolver + field :invoice_custom_section, resolver: Resolvers::InvoiceCustomSectionResolver + field :invoice_custom_sections, resolver: Resolvers::InvoiceCustomSectionsResolver + field :invoiced_usages, resolver: Resolvers::Analytics::InvoicedUsagesResolver + field :invoices, resolver: Resolvers::InvoicesResolver + field :memberships, resolver: Resolvers::MembershipsResolver + field :mrrs, resolver: Resolvers::Analytics::MrrsResolver + field :organization, resolver: Resolvers::OrganizationResolver + field :overdue_balances, resolver: Resolvers::Analytics::OverdueBalancesResolver + field :password_reset, resolver: Resolvers::PasswordResetResolver + field :payment, resolver: Resolvers::PaymentResolver + field :payment_methods, resolver: Resolvers::PaymentMethodsResolver + field :payment_provider, resolver: Resolvers::PaymentProviderResolver + field :payment_providers, resolver: Resolvers::PaymentProvidersResolver + field :payment_requests, resolver: Resolvers::PaymentRequestsResolver + field :payments, resolver: Resolvers::PaymentsResolver + field :plan, resolver: Resolvers::PlanResolver + field :plans, resolver: Resolvers::PlansResolver + field :pricing_unit, resolver: Resolvers::PricingUnitResolver + field :pricing_units, resolver: Resolvers::PricingUnitsResolver + field :quote, resolver: Resolvers::QuoteResolver + field :quotes, resolver: Resolvers::QuotesResolver + field :role, resolver: Resolvers::RoleResolver + field :roles, resolver: Resolvers::RolesResolver + field :security_log, resolver: Resolvers::SecurityLogResolver + field :security_logs, resolver: Resolvers::SecurityLogsResolver + field :subscription, resolver: Resolvers::SubscriptionResolver + field :subscription_alert, resolver: Resolvers::Subscriptions::AlertResolver + field :subscription_alerts, resolver: Resolvers::Subscriptions::AlertsResolver + field :subscription_entitlement, resolver: Resolvers::Entitlement::SubscriptionEntitlementResolver + field :subscription_entitlements, resolver: Resolvers::Entitlement::SubscriptionEntitlementsResolver + field :subscriptions, resolver: Resolvers::SubscriptionsResolver + field :tax, resolver: Resolvers::TaxResolver + field :taxes, resolver: Resolvers::TaxesResolver + field :wallet, resolver: Resolvers::WalletResolver + field :wallet_alert, resolver: Resolvers::Wallets::AlertResolver + field :wallet_alerts, resolver: Resolvers::Wallets::AlertsResolver + field :wallet_transaction, resolver: Resolvers::WalletTransactionResolver + field :wallet_transaction_consumptions, resolver: Resolvers::WalletTransactionConsumptionsResolver + field :wallet_transaction_fundings, resolver: Resolvers::WalletTransactionFundingsResolver + field :wallet_transactions, resolver: Resolvers::WalletTransactionsResolver + field :wallets, resolver: Resolvers::WalletsResolver + field :webhook, resolver: Resolvers::WebhookResolver + field :webhook_endpoint, resolver: Resolvers::WebhookEndpointResolver + field :webhook_endpoints, resolver: Resolvers::WebhookEndpointsResolver + field :webhooks, resolver: Resolvers::WebhooksResolver + + field :data_api_mrrs, resolver: Resolvers::DataApi::MrrsResolver + field :data_api_mrrs_plans, resolver: Resolvers::DataApi::Mrrs::PlansResolver + field :data_api_prepaid_credits, resolver: Resolvers::DataApi::PrepaidCreditsResolver + field :data_api_revenue_streams, resolver: Resolvers::DataApi::RevenueStreamsResolver + field :data_api_revenue_streams_customers, resolver: Resolvers::DataApi::RevenueStreams::CustomersResolver + field :data_api_revenue_streams_plans, resolver: Resolvers::DataApi::RevenueStreams::PlansResolver + field :data_api_usages, resolver: Resolvers::DataApi::UsagesResolver + field :data_api_usages_aggregated_amounts, resolver: Resolvers::DataApi::Usages::AggregatedAmountsResolver + field :data_api_usages_forecasted, resolver: Resolvers::DataApi::Usages::ForecastedResolver + field :data_api_usages_invoiced, resolver: Resolvers::DataApi::Usages::InvoicedResolver + field :superset_dashboards, resolver: Resolvers::Superset::DashboardsResolver + end +end diff --git a/app/graphql/types/quote_versions/object.rb b/app/graphql/types/quote_versions/object.rb new file mode 100644 index 0000000..191cb6c --- /dev/null +++ b/app/graphql/types/quote_versions/object.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Types + module QuoteVersions + class Object < Types::BaseObject + graphql_name "QuoteVersion" + + field :approved_at, GraphQL::Types::ISO8601DateTime, null: true + field :billing_items, GraphQL::Types::JSON, null: true + field :content, String, null: true + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType, null: false + field :quote, Types::Quotes::Object, null: false + field :share_token, String, null: true + field :status, Types::QuoteVersions::StatusEnum, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :version, Integer, null: false + field :void_reason, Types::QuoteVersions::VoidReasonEnum, null: true + field :voided_at, GraphQL::Types::ISO8601DateTime, null: true + # TODO: field :order_form, Types::OrderForms::Object, null: true + + dataload_association :organization, :quote + end + end +end diff --git a/app/graphql/types/quote_versions/status_enum.rb b/app/graphql/types/quote_versions/status_enum.rb new file mode 100644 index 0000000..aa8e4f0 --- /dev/null +++ b/app/graphql/types/quote_versions/status_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module QuoteVersions + class StatusEnum < Types::BaseEnum + QuoteVersion::STATUSES.keys.each do |status| + value status + end + end + end +end diff --git a/app/graphql/types/quote_versions/update_input.rb b/app/graphql/types/quote_versions/update_input.rb new file mode 100644 index 0000000..edd8114 --- /dev/null +++ b/app/graphql/types/quote_versions/update_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module QuoteVersions + class UpdateInput < BaseInputObject + graphql_name "UpdateQuoteVersionInput" + + argument :billing_items, GraphQL::Types::JSON, required: false + argument :content, String, required: false + argument :id, ID, required: true + end + end +end diff --git a/app/graphql/types/quote_versions/void_reason_enum.rb b/app/graphql/types/quote_versions/void_reason_enum.rb new file mode 100644 index 0000000..792c5f4 --- /dev/null +++ b/app/graphql/types/quote_versions/void_reason_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module QuoteVersions + class VoidReasonEnum < Types::BaseEnum + QuoteVersion::VOID_REASONS.keys.each do |reason| + value reason + end + end + end +end diff --git a/app/graphql/types/quotes/create_input.rb b/app/graphql/types/quotes/create_input.rb new file mode 100644 index 0000000..08a51fb --- /dev/null +++ b/app/graphql/types/quotes/create_input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Quotes + class CreateInput < BaseInputObject + graphql_name "CreateQuoteInput" + + argument :billing_items, GraphQL::Types::JSON, required: false + argument :content, String, required: false + argument :customer_id, ID, required: true + argument :order_type, Types::Quotes::OrderTypeEnum, required: true + argument :owners, [ID], required: false + argument :subscription_id, ID, required: false + end + end +end diff --git a/app/graphql/types/quotes/object.rb b/app/graphql/types/quotes/object.rb new file mode 100644 index 0000000..75db8a3 --- /dev/null +++ b/app/graphql/types/quotes/object.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Quotes + class Object < Types::BaseObject + graphql_name "Quote" + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :current_version, Types::QuoteVersions::Object, null: false + field :customer, Types::Customers::Object, null: false + field :id, ID, null: false + field :number, String, null: false + field :order_type, Types::Quotes::OrderTypeEnum, null: false + field :organization, Types::Organizations::OrganizationType, null: false + field :owners, [Types::UserType], null: true + field :subscription, Types::Subscriptions::Object, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :versions, [Types::QuoteVersions::Object], null: false + + dataload_association :customer, :organization, :subscription, :owners, :versions, :current_version + end + end +end diff --git a/app/graphql/types/quotes/order_type_enum.rb b/app/graphql/types/quotes/order_type_enum.rb new file mode 100644 index 0000000..797c589 --- /dev/null +++ b/app/graphql/types/quotes/order_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Quotes + class OrderTypeEnum < Types::BaseEnum + Quote::ORDER_TYPES.keys.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/quotes/update_input.rb b/app/graphql/types/quotes/update_input.rb new file mode 100644 index 0000000..4a28ce4 --- /dev/null +++ b/app/graphql/types/quotes/update_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Quotes + class UpdateInput < BaseInputObject + graphql_name "UpdateQuoteInput" + + argument :id, ID, required: true + argument :owners, [ID], required: false + end + end +end diff --git a/app/graphql/types/reset_passwords/object.rb b/app/graphql/types/reset_passwords/object.rb new file mode 100644 index 0000000..753bc77 --- /dev/null +++ b/app/graphql/types/reset_passwords/object.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module ResetPasswords + class Object < Types::BaseObject + graphql_name "ResetPassword" + description "ResetPassword type" + + field :user, Types::UserType, null: false + + field :id, ID, null: false + field :token, String, null: false + + field :expire_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/role_type.rb b/app/graphql/types/role_type.rb new file mode 100644 index 0000000..0cd382f --- /dev/null +++ b/app/graphql/types/role_type.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Types + class RoleType < Types::BaseObject + field :admin, Boolean, null: false + field :code, String, null: false + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :description, String, null: true + field :id, ID, null: false + field :memberships, [Types::MembershipType], null: false + field :name, String, null: false + field :permissions, [PermissionEnum], null: false + + def memberships + dataloader + .with(Sources::MembershipsForRole, context[:current_organization]) + .load(object.id) + end + + def permissions + object.permissions_hash.filter_map { |k, v| k if v } + end + end +end diff --git a/app/graphql/types/roles/create_input.rb b/app/graphql/types/roles/create_input.rb new file mode 100644 index 0000000..d2e3063 --- /dev/null +++ b/app/graphql/types/roles/create_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Roles + class CreateInput < BaseInputObject + description "Create Role input arguments" + + argument :code, String, required: true + argument :description, String, required: false + argument :name, String, required: true + argument :permissions, [Types::PermissionEnum], required: true + end + end +end diff --git a/app/graphql/types/roles/update_input.rb b/app/graphql/types/roles/update_input.rb new file mode 100644 index 0000000..e4720e5 --- /dev/null +++ b/app/graphql/types/roles/update_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Roles + class UpdateInput < BaseInputObject + description "Update Role input arguments" + + argument :description, String, required: false + argument :id, ID, required: true + argument :name, String, required: false + argument :permissions, [Types::PermissionEnum], required: false + end + end +end diff --git a/app/graphql/types/security_logs/log_event_enum.rb b/app/graphql/types/security_logs/log_event_enum.rb new file mode 100644 index 0000000..36a454a --- /dev/null +++ b/app/graphql/types/security_logs/log_event_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module SecurityLogs + class LogEventEnum < Types::BaseEnum + description "Security Log event" + + Clickhouse::SecurityLog::LOG_EVENTS.each do |event| + value event.tr(".", "_"), value: event, description: event + end + end + end +end diff --git a/app/graphql/types/security_logs/log_type_enum.rb b/app/graphql/types/security_logs/log_type_enum.rb new file mode 100644 index 0000000..f17282d --- /dev/null +++ b/app/graphql/types/security_logs/log_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module SecurityLogs + class LogTypeEnum < Types::BaseEnum + description "Security Log type" + + Clickhouse::SecurityLog::LOG_TYPES.each do |type| + value type.tr(".", "_"), value: type, description: type + end + end + end +end diff --git a/app/graphql/types/security_logs/object.rb b/app/graphql/types/security_logs/object.rb new file mode 100644 index 0000000..55a83ee --- /dev/null +++ b/app/graphql/types/security_logs/object.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module SecurityLogs + class Object < Types::BaseObject + graphql_name "SecurityLog" + description "Security log entry" + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :device_info, GraphQL::Types::JSON + field :log_event, Types::SecurityLogs::LogEventEnum, null: false + field :log_id, ID, null: false + field :log_type, Types::SecurityLogs::LogTypeEnum, null: false + field :logged_at, GraphQL::Types::ISO8601DateTime, null: false + field :resources, GraphQL::Types::JSON + field :user_email, String, null: true + + def user_email + object.user&.email + end + end + end +end diff --git a/app/graphql/types/subscriptions/activation_rule_input.rb b/app/graphql/types/subscriptions/activation_rule_input.rb new file mode 100644 index 0000000..da1a7c3 --- /dev/null +++ b/app/graphql/types/subscriptions/activation_rule_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class ActivationRuleInput < Types::BaseInputObject + graphql_name "SubscriptionActivationRuleInput" + + argument :id, ID, required: false + argument :timeout_hours, Integer, required: false + argument :type, Types::Subscriptions::ActivationRuleTypeEnum, required: true + end + end +end diff --git a/app/graphql/types/subscriptions/activation_rule_status_enum.rb b/app/graphql/types/subscriptions/activation_rule_status_enum.rb new file mode 100644 index 0000000..cde8c9d --- /dev/null +++ b/app/graphql/types/subscriptions/activation_rule_status_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class ActivationRuleStatusEnum < Types::BaseEnum + Subscription::ActivationRule::STATUSES.each_key do |status| + value status + end + end + end +end diff --git a/app/graphql/types/subscriptions/activation_rule_type.rb b/app/graphql/types/subscriptions/activation_rule_type.rb new file mode 100644 index 0000000..a782e4f --- /dev/null +++ b/app/graphql/types/subscriptions/activation_rule_type.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class ActivationRuleType < Types::BaseObject + graphql_name "SubscriptionActivationRule" + + field :id, ID, null: false + field :status, Types::Subscriptions::ActivationRuleStatusEnum, null: false + field :timeout_hours, Integer, null: true + field :type, Types::Subscriptions::ActivationRuleTypeEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :expires_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/subscriptions/activation_rule_type_enum.rb b/app/graphql/types/subscriptions/activation_rule_type_enum.rb new file mode 100644 index 0000000..bf589d3 --- /dev/null +++ b/app/graphql/types/subscriptions/activation_rule_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class ActivationRuleTypeEnum < Types::BaseEnum + Subscription::ActivationRule::TYPES.each_key do |type| + value type + end + end + end +end diff --git a/app/graphql/types/subscriptions/billing_time_enum.rb b/app/graphql/types/subscriptions/billing_time_enum.rb new file mode 100644 index 0000000..4b0ed90 --- /dev/null +++ b/app/graphql/types/subscriptions/billing_time_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class BillingTimeEnum < Types::BaseEnum + Subscription::BILLING_TIME.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/subscriptions/cancelation_reason_enum.rb b/app/graphql/types/subscriptions/cancelation_reason_enum.rb new file mode 100644 index 0000000..272fb6f --- /dev/null +++ b/app/graphql/types/subscriptions/cancelation_reason_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class CancelationReasonEnum < Types::BaseEnum + Subscription::CANCELATION_REASONS.each_key do |reason| + value reason + end + end + end +end diff --git a/app/graphql/types/subscriptions/charge_overrides_input.rb b/app/graphql/types/subscriptions/charge_overrides_input.rb new file mode 100644 index 0000000..a696e99 --- /dev/null +++ b/app/graphql/types/subscriptions/charge_overrides_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class ChargeOverridesInput < Types::BaseInputObject + argument :billable_metric_id, ID, required: true + argument :id, ID, required: false + + argument :applied_pricing_unit, Types::AppliedPricingUnits::OverrideInput, required: false + argument :filters, [Types::ChargeFilters::Input], required: false + argument :invoice_display_name, String, required: false + argument :min_amount_cents, GraphQL::Types::BigInt, required: false + argument :properties, Types::Charges::PropertiesInput, required: false + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/subscriptions/create_charge_filter_input.rb b/app/graphql/types/subscriptions/create_charge_filter_input.rb new file mode 100644 index 0000000..5db88af --- /dev/null +++ b/app/graphql/types/subscriptions/create_charge_filter_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class CreateChargeFilterInput < Types::BaseInputObject + graphql_name "CreateSubscriptionChargeFilterInput" + description "Create subscription charge filter input arguments" + + argument :charge_code, String, required: true + argument :subscription_id, ID, required: true + + argument :invoice_display_name, String, required: false + argument :properties, Types::Charges::PropertiesInput, required: true + argument :values, Types::ChargeFilters::Values, required: true + end + end +end diff --git a/app/graphql/types/subscriptions/create_subscription_input.rb b/app/graphql/types/subscriptions/create_subscription_input.rb new file mode 100644 index 0000000..457c7fa --- /dev/null +++ b/app/graphql/types/subscriptions/create_subscription_input.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class CreateSubscriptionInput < BaseInputObject + description "Create Subscription input arguments" + + argument :ending_at, GraphQL::Types::ISO8601DateTime, required: false + argument :external_id, String, required: false + argument :name, String, required: false + argument :subscription_id, ID, required: false + + argument :billing_entity_id, ID, required: false + argument :customer_id, ID, required: true + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + argument :plan_id, ID, required: true + argument :plan_overrides, Types::Subscriptions::PlanOverridesInput, required: false + argument :usage_thresholds, [Types::UsageThresholds::Input], required: false + + argument :activation_rules, [Types::Subscriptions::ActivationRuleInput], required: false + argument :billing_time, Types::Subscriptions::BillingTimeEnum, required: true + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + argument :progressive_billing_disabled, Boolean, required: false + argument :subscription_at, GraphQL::Types::ISO8601DateTime, required: false + end + end +end diff --git a/app/graphql/types/subscriptions/destroy_charge_filter_input.rb b/app/graphql/types/subscriptions/destroy_charge_filter_input.rb new file mode 100644 index 0000000..2ceddbf --- /dev/null +++ b/app/graphql/types/subscriptions/destroy_charge_filter_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class DestroyChargeFilterInput < Types::BaseInputObject + graphql_name "DestroySubscriptionChargeFilterInput" + description "Destroy subscription charge filter input arguments" + + argument :charge_code, String, required: true + argument :subscription_id, ID, required: true + argument :values, Types::ChargeFilters::Values, required: true + end + end +end diff --git a/app/graphql/types/subscriptions/fixed_charge_overrides_input.rb b/app/graphql/types/subscriptions/fixed_charge_overrides_input.rb new file mode 100644 index 0000000..1ab6982 --- /dev/null +++ b/app/graphql/types/subscriptions/fixed_charge_overrides_input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class FixedChargeOverridesInput < Types::BaseInputObject + argument :add_on_id, ID, required: false + argument :id, ID, required: false + + argument :apply_units_immediately, Boolean, required: false + argument :invoice_display_name, String, required: false + argument :properties, Types::FixedCharges::PropertiesInput, required: false + argument :tax_codes, [String], required: false + argument :units, String, required: false + end + end +end diff --git a/app/graphql/types/subscriptions/lifetime_usage_object.rb b/app/graphql/types/subscriptions/lifetime_usage_object.rb new file mode 100644 index 0000000..f11ee6b --- /dev/null +++ b/app/graphql/types/subscriptions/lifetime_usage_object.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class LifetimeUsageObject < Types::BaseObject + graphql_name "SubscriptionLifetimeUsage" + + field :total_usage_amount_cents, GraphQL::Types::BigInt, method: :total_amount_cents, null: false + field :total_usage_from_datetime, GraphQL::Types::ISO8601DateTime, null: false + field :total_usage_to_datetime, GraphQL::Types::ISO8601DateTime, null: false + + field :last_threshold_amount_cents, GraphQL::Types::BigInt, null: true + field :next_threshold_amount_cents, GraphQL::Types::BigInt, null: true + field :next_threshold_ratio, GraphQL::Types::Float, null: true + + def total_usage_from_datetime + object.subscription.subscription_at + end + + def total_usage_to_datetime + Time.current + end + + private + + delegate :last_threshold_amount_cents, + :next_threshold_amount_cents, + :next_threshold_ratio, + to: :last_and_next_thresholds + + def last_and_next_thresholds + @last_and_next_thresholds ||= LifetimeUsages::FindLastAndNextThresholdsService.call(lifetime_usage: object) + end + end + end +end diff --git a/app/graphql/types/subscriptions/next_subscription_type_enum.rb b/app/graphql/types/subscriptions/next_subscription_type_enum.rb new file mode 100644 index 0000000..8fa3da6 --- /dev/null +++ b/app/graphql/types/subscriptions/next_subscription_type_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class NextSubscriptionTypeEnum < Types::BaseEnum + value "upgrade" + value "downgrade" + end + end +end diff --git a/app/graphql/types/subscriptions/object.rb b/app/graphql/types/subscriptions/object.rb new file mode 100644 index 0000000..44ca881 --- /dev/null +++ b/app/graphql/types/subscriptions/object.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class Object < Types::BaseObject + graphql_name "Subscription" + + field :customer, Types::Customers::Object, null: false + field :external_id, String, null: false + field :id, ID, null: false + field :plan, Types::Plans::Object, null: false + + field :name, String, null: true + field :period_end_date, GraphQL::Types::ISO8601Date + field :status, Types::Subscriptions::StatusTypeEnum + + field :billing_time, Types::Subscriptions::BillingTimeEnum + field :canceled_at, GraphQL::Types::ISO8601DateTime + field :ending_at, GraphQL::Types::ISO8601DateTime + field :on_termination_credit_note, Types::Subscriptions::OnTerminationCreditNoteEnum + field :on_termination_invoice, Types::Subscriptions::OnTerminationInvoiceEnum, null: false + field :started_at, GraphQL::Types::ISO8601DateTime + field :subscription_at, GraphQL::Types::ISO8601DateTime + field :terminated_at, GraphQL::Types::ISO8601DateTime + + field :current_billing_period_ending_at, GraphQL::Types::ISO8601DateTime + field :current_billing_period_started_at, GraphQL::Types::ISO8601DateTime + + field :selected_invoice_custom_sections, [Types::InvoiceCustomSections::Object], null: true + field :skip_invoice_custom_sections, Boolean + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :downgrade_plan_date, GraphQL::Types::ISO8601Date + field :next_name, String, null: true + field :next_plan, Types::Plans::Object + field :next_subscription, Types::Subscriptions::Object + field :next_subscription_at, GraphQL::Types::ISO8601DateTime + field :next_subscription_type, Types::Subscriptions::NextSubscriptionTypeEnum + field :previous_plan, Types::Plans::Object + field :previous_subscription, Types::Subscriptions::Object + + field :activity_logs, [Types::ActivityLogs::Object], null: true + field :charges, [Types::Charges::Object], null: true + field :fees, [Types::Fees::Object], null: true + field :fixed_charges, [Types::FixedCharges::Object], null: true + + field :lifetime_usage, Types::Subscriptions::LifetimeUsageObject, null: true + + field :usage_thresholds, [Types::UsageThresholds::Object], null: false + + field :payment_method, Types::PaymentMethods::Object + field :payment_method_type, Types::PaymentMethods::MethodTypeEnum + field :progressive_billing_disabled, Boolean + + field :activated_at, GraphQL::Types::ISO8601DateTime, null: true + field :activation_rules, [Types::Subscriptions::ActivationRuleType], null: false + field :cancelation_reason, Types::Subscriptions::CancelationReasonEnum, null: true + + def next_plan + object.next_subscription&.plan + end + + def previous_plan + object.previous_subscription&.plan + end + + def next_name + object.next_subscription&.name + end + + def next_subscription_type + if object.upgraded? + "upgrade" + elsif object.downgraded? + "downgrade" + end + end + + def next_subscription_at + object.next_subscription&.started_at || object.next_subscription&.subscription_at + end + + def period_end_date + ::Subscriptions::DatesService.new_instance(object, Time.current) + .next_end_of_period + end + + def lifetime_usage + return nil unless object.has_progressive_billing? || object.organization.lifetime_usage_enabled? + + object.lifetime_usage + end + + def current_billing_period_started_at + dates_service.charges_from_datetime + end + + def current_billing_period_ending_at + dates_service.charges_to_datetime + end + + def charges + object.plan.charges + .includes(:billable_metric, :taxes, :applied_pricing_unit, filters: :billable_metric_filters) + .order(created_at: :asc) + end + + def fixed_charges + object.plan.fixed_charges + .includes(:add_on, :taxes) + .order(created_at: :asc) + end + + def dates_service + @dates_service ||= ::Subscriptions::DatesService.new_instance(object, Time.current, current_usage: true) + end + end + end +end diff --git a/app/graphql/types/subscriptions/on_termination_credit_note_enum.rb b/app/graphql/types/subscriptions/on_termination_credit_note_enum.rb new file mode 100644 index 0000000..9df1bb4 --- /dev/null +++ b/app/graphql/types/subscriptions/on_termination_credit_note_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class OnTerminationCreditNoteEnum < Types::BaseEnum + Subscription::ON_TERMINATION_CREDIT_NOTES.each_key do |reason| + value reason + end + end + end +end diff --git a/app/graphql/types/subscriptions/on_termination_invoice_enum.rb b/app/graphql/types/subscriptions/on_termination_invoice_enum.rb new file mode 100644 index 0000000..fc18570 --- /dev/null +++ b/app/graphql/types/subscriptions/on_termination_invoice_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class OnTerminationInvoiceEnum < Types::BaseEnum + Subscription::ON_TERMINATION_INVOICES.each_key do |action| + value action + end + end + end +end diff --git a/app/graphql/types/subscriptions/plan_overrides_input.rb b/app/graphql/types/subscriptions/plan_overrides_input.rb new file mode 100644 index 0000000..a0d7987 --- /dev/null +++ b/app/graphql/types/subscriptions/plan_overrides_input.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class PlanOverridesInput < Types::BaseInputObject + argument :amount_cents, GraphQL::Types::BigInt, required: false + argument :amount_currency, Types::CurrencyEnum, required: false + argument :charges, [Types::Subscriptions::ChargeOverridesInput], required: false + argument :description, String, required: false + argument :fixed_charges, [Types::Subscriptions::FixedChargeOverridesInput], required: false + argument :invoice_display_name, String, required: false + argument :minimum_commitment, Types::Commitments::Input, required: false + argument :name, String, required: false + argument :tax_codes, [String], required: false + argument :trial_period, Float, required: false + end + end +end diff --git a/app/graphql/types/subscriptions/status_type_enum.rb b/app/graphql/types/subscriptions/status_type_enum.rb new file mode 100644 index 0000000..c8b3fca --- /dev/null +++ b/app/graphql/types/subscriptions/status_type_enum.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class StatusTypeEnum < Types::BaseEnum + Subscription::STATUSES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/subscriptions/terminate_subscription_input.rb b/app/graphql/types/subscriptions/terminate_subscription_input.rb new file mode 100644 index 0000000..b1d94b7 --- /dev/null +++ b/app/graphql/types/subscriptions/terminate_subscription_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class TerminateSubscriptionInput < Types::BaseInputObject + description "Input for terminating a subscription" + + argument :id, ID, required: true + argument :on_termination_credit_note, Types::Subscriptions::OnTerminationCreditNoteEnum, required: false + argument :on_termination_invoice, Types::Subscriptions::OnTerminationInvoiceEnum, required: false + end + end +end diff --git a/app/graphql/types/subscriptions/update_charge_filter_input.rb b/app/graphql/types/subscriptions/update_charge_filter_input.rb new file mode 100644 index 0000000..4509c7c --- /dev/null +++ b/app/graphql/types/subscriptions/update_charge_filter_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class UpdateChargeFilterInput < Types::BaseInputObject + graphql_name "UpdateSubscriptionChargeFilterInput" + description "Update subscription charge filter input arguments" + + argument :charge_code, String, required: true + argument :subscription_id, ID, required: true + argument :values, Types::ChargeFilters::Values, required: true + + argument :invoice_display_name, String, required: false + argument :properties, Types::Charges::PropertiesInput, required: false + end + end +end diff --git a/app/graphql/types/subscriptions/update_charge_input.rb b/app/graphql/types/subscriptions/update_charge_input.rb new file mode 100644 index 0000000..8349a54 --- /dev/null +++ b/app/graphql/types/subscriptions/update_charge_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class UpdateChargeInput < Types::BaseInputObject + argument :charge_code, String, required: true + argument :subscription_id, ID, required: true + + argument :applied_pricing_unit, Types::AppliedPricingUnits::OverrideInput, required: false + argument :filters, [Types::ChargeFilters::Input], required: false + argument :invoice_display_name, String, required: false + argument :min_amount_cents, GraphQL::Types::BigInt, required: false + argument :properties, Types::Charges::PropertiesInput, required: false + argument :tax_codes, [String], required: false + end + end +end diff --git a/app/graphql/types/subscriptions/update_fixed_charge_input.rb b/app/graphql/types/subscriptions/update_fixed_charge_input.rb new file mode 100644 index 0000000..5a15377 --- /dev/null +++ b/app/graphql/types/subscriptions/update_fixed_charge_input.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class UpdateFixedChargeInput < Types::BaseInputObject + argument :fixed_charge_code, String, required: true + argument :subscription_id, ID, required: true + + argument :apply_units_immediately, Boolean, required: false + argument :invoice_display_name, String, required: false + argument :properties, Types::FixedCharges::PropertiesInput, required: false + argument :tax_codes, [String], required: false + argument :units, String, required: false + end + end +end diff --git a/app/graphql/types/subscriptions/update_subscription_input.rb b/app/graphql/types/subscriptions/update_subscription_input.rb new file mode 100644 index 0000000..195f0e2 --- /dev/null +++ b/app/graphql/types/subscriptions/update_subscription_input.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Subscriptions + class UpdateSubscriptionInput < BaseInputObject + description "Update Subscription input arguments" + + argument :id, ID, required: true + + argument :activation_rules, [Types::Subscriptions::ActivationRuleInput], required: false + argument :ending_at, GraphQL::Types::ISO8601DateTime, required: false + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + argument :name, String, required: false + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + argument :plan_overrides, Types::Subscriptions::PlanOverridesInput, required: false + argument :progressive_billing_disabled, Boolean, required: false + argument :subscription_at, GraphQL::Types::ISO8601DateTime, required: false + argument :usage_thresholds, [Types::UsageThresholds::Input], required: false + end + end +end diff --git a/app/graphql/types/superset/dashboard/object.rb b/app/graphql/types/superset/dashboard/object.rb new file mode 100644 index 0000000..9c05987 --- /dev/null +++ b/app/graphql/types/superset/dashboard/object.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + module Superset + module Dashboard + class Object < Types::BaseObject + graphql_name "SupersetDashboard" + + field :dashboard_title, String, null: false + field :embedded_id, String, null: false + field :guest_token, String, null: false + field :id, String, null: false + field :superset_url, String, null: false + + def superset_url + ENV["SUPERSET_URL"] + end + end + end + end +end diff --git a/app/graphql/types/taxes/applied_tax.rb b/app/graphql/types/taxes/applied_tax.rb new file mode 100644 index 0000000..7f4c3c4 --- /dev/null +++ b/app/graphql/types/taxes/applied_tax.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Types + module Taxes + module AppliedTax + include Types::BaseInterface + + field :id, ID, null: false + + field :tax, Types::Taxes::Object, null: true + field :tax_code, String, null: false + field :tax_description, String, null: true + field :tax_name, String, null: false + field :tax_rate, Float, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :amount_currency, Types::CurrencyEnum, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/taxes/create_input.rb b/app/graphql/types/taxes/create_input.rb new file mode 100644 index 0000000..d4c167d --- /dev/null +++ b/app/graphql/types/taxes/create_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Taxes + class CreateInput < Types::BaseInputObject + graphql_name "TaxCreateInput" + + argument :code, String, required: true + argument :description, String, required: false + argument :name, String, required: true + argument :rate, Float, required: true + end + end +end diff --git a/app/graphql/types/taxes/object.rb b/app/graphql/types/taxes/object.rb new file mode 100644 index 0000000..59da950 --- /dev/null +++ b/app/graphql/types/taxes/object.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module Taxes + class Object < Types::BaseObject + graphql_name "Tax" + + field :id, ID, null: false + field :organization, Types::Organizations::OrganizationType + + field :code, String, null: false + field :description, String, null: true + field :name, String, null: false + field :rate, Float, null: false + + field :applied_to_organization, Boolean, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :add_ons_count, Integer, null: false, description: "Number of add ons using this tax" + field :charges_count, Integer, null: false, description: "Number of charges using this tax" + field :customers_count, Integer, null: false, description: "Number of customers using this tax" + field :plans_count, Integer, null: false, description: "Number of plans using this tax" + + field :auto_generated, Boolean, null: false + + def add_ons_count + object.add_ons.count + end + + def charges_count + object.charges.count + end + + def plans_count + object.plans.count + end + end + end +end diff --git a/app/graphql/types/taxes/update_input.rb b/app/graphql/types/taxes/update_input.rb new file mode 100644 index 0000000..5845703 --- /dev/null +++ b/app/graphql/types/taxes/update_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module Taxes + class UpdateInput < Types::BaseInputObject + graphql_name "TaxUpdateInput" + + argument :code, String, required: false + argument :description, String, required: false + argument :id, ID, required: true + argument :name, String, required: false + argument :rate, Float, required: false + + argument :applied_to_organization, Boolean, required: false + end + end +end diff --git a/app/graphql/types/timezone_enum.rb b/app/graphql/types/timezone_enum.rb new file mode 100644 index 0000000..248008b --- /dev/null +++ b/app/graphql/types/timezone_enum.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Types + class TimezoneEnum < Types::BaseEnum + Timezones.all + .uniq { |tz| tz.tzinfo.identifier } + .each_with_object([]) { |tz, result| result << tz.tzinfo.identifier } + .sort_by { |tz| tz.split("/") } + .map do |tz| + symbol = tz.gsub(/[^_a-zA-Z0-9]/, "_").squeeze("_").upcase + value = tz + + if tz == "Etc/UTC" + symbol = "UTC" + value = "UTC" + end + + value("TZ_#{symbol}", value, value:) + end + end +end diff --git a/app/graphql/types/usage_monitoring/alerts/alert_type_enum.rb b/app/graphql/types/usage_monitoring/alerts/alert_type_enum.rb new file mode 100644 index 0000000..2d93fef --- /dev/null +++ b/app/graphql/types/usage_monitoring/alerts/alert_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module UsageMonitoring + module Alerts + class AlertTypeEnum < Types::BaseEnum + ::UsageMonitoring::Alert::STI_MAPPING.keys.each do |type| + value type + end + end + end + end +end diff --git a/app/graphql/types/usage_monitoring/alerts/create_input.rb b/app/graphql/types/usage_monitoring/alerts/create_input.rb new file mode 100644 index 0000000..e2121a1 --- /dev/null +++ b/app/graphql/types/usage_monitoring/alerts/create_input.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module UsageMonitoring + module Alerts + class CreateInput < BaseInputObject + argument :alert_type, Types::UsageMonitoring::Alerts::AlertTypeEnum, required: true + + argument :billable_metric_id, ID, required: false + argument :code, String, required: true + argument :name, String, required: false + argument :subscription_id, ID, required: false + argument :wallet_id, ID, required: false + + argument :thresholds, [Types::UsageMonitoring::Alerts::ThresholdInput], required: true + end + end + end +end diff --git a/app/graphql/types/usage_monitoring/alerts/direction_enum.rb b/app/graphql/types/usage_monitoring/alerts/direction_enum.rb new file mode 100644 index 0000000..a639df5 --- /dev/null +++ b/app/graphql/types/usage_monitoring/alerts/direction_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module UsageMonitoring + module Alerts + class DirectionEnum < Types::BaseEnum + ::UsageMonitoring::Alert::DIRECTIONS.each_key do |direction| + value direction + end + end + end + end +end diff --git a/app/graphql/types/usage_monitoring/alerts/object.rb b/app/graphql/types/usage_monitoring/alerts/object.rb new file mode 100644 index 0000000..8482d20 --- /dev/null +++ b/app/graphql/types/usage_monitoring/alerts/object.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module UsageMonitoring + module Alerts + class Object < Types::BaseObject + graphql_name "Alert" + + field :id, ID, null: false + + field :alert_type, AlertTypeEnum, null: false + field :billable_metric, Types::BillableMetrics::Object + field :billable_metric_id, ID + field :direction, DirectionEnum, null: false + field :subscription_external_id, String + field :wallet_id, String + + field :code, String, null: false + field :name, String + + field :thresholds, [Types::UsageMonitoring::Alerts::ThresholdObject] + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :deleted_at, GraphQL::Types::ISO8601DateTime + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end + end +end diff --git a/app/graphql/types/usage_monitoring/alerts/threshold_input.rb b/app/graphql/types/usage_monitoring/alerts/threshold_input.rb new file mode 100644 index 0000000..6afebed --- /dev/null +++ b/app/graphql/types/usage_monitoring/alerts/threshold_input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module UsageMonitoring + module Alerts + class ThresholdInput < BaseInputObject + argument :code, String, required: false + argument :recurring, Boolean, required: false + argument :value, String, required: true + end + end + end +end diff --git a/app/graphql/types/usage_monitoring/alerts/threshold_object.rb b/app/graphql/types/usage_monitoring/alerts/threshold_object.rb new file mode 100644 index 0000000..3d27bc4 --- /dev/null +++ b/app/graphql/types/usage_monitoring/alerts/threshold_object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module UsageMonitoring + module Alerts + class ThresholdObject < Types::BaseObject + graphql_name "AlertThreshold" + + field :code, String, null: true + field :recurring, Boolean, null: false + field :value, String, null: false + end + end + end +end diff --git a/app/graphql/types/usage_monitoring/alerts/update_input.rb b/app/graphql/types/usage_monitoring/alerts/update_input.rb new file mode 100644 index 0000000..bc626d3 --- /dev/null +++ b/app/graphql/types/usage_monitoring/alerts/update_input.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module UsageMonitoring + module Alerts + class UpdateInput < BaseInputObject + argument :id, ID, required: true + + argument :billable_metric_id, ID, required: false + argument :code, String, required: false + argument :name, String, required: false + + argument :thresholds, [Types::UsageMonitoring::Alerts::ThresholdInput], required: false + end + end + end +end diff --git a/app/graphql/types/usage_thresholds/input.rb b/app/graphql/types/usage_thresholds/input.rb new file mode 100644 index 0000000..ac71185 --- /dev/null +++ b/app/graphql/types/usage_thresholds/input.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module UsageThresholds + class Input < BaseInputObject + graphql_name "UsageThresholdInput" + + argument :amount_cents, GraphQL::Types::BigInt, required: true + argument :recurring, Boolean, required: false + argument :threshold_display_name, String, required: false + end + end +end diff --git a/app/graphql/types/usage_thresholds/object.rb b/app/graphql/types/usage_thresholds/object.rb new file mode 100644 index 0000000..366e6e6 --- /dev/null +++ b/app/graphql/types/usage_thresholds/object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module UsageThresholds + class Object < Types::BaseObject + graphql_name "UsageThreshold" + + field :id, ID, null: false + + field :amount_cents, GraphQL::Types::BigInt, null: false + field :recurring, Boolean, null: false + field :threshold_display_name, String, null: true + end + end +end diff --git a/app/graphql/types/user_type.rb b/app/graphql/types/user_type.rb new file mode 100644 index 0000000..0e02baa --- /dev/null +++ b/app/graphql/types/user_type.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + class UserType < Types::BaseObject + field :id, ID, null: false + + field :email, String + field :premium, Boolean, null: false + + field :memberships, [Types::MembershipType], null: false + # TODO: keeping organization for backwards compatibility, remove once the frontend is updated + field :organizations, [Types::Organizations::OrganizationType], null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + def memberships + object.memberships.active.includes(:organization) + end + + def organizations + memberships.map(&:organization) + end + + def premium + License.premium? + end + end +end diff --git a/app/graphql/types/utils/current_version.rb b/app/graphql/types/utils/current_version.rb new file mode 100644 index 0000000..e78520e --- /dev/null +++ b/app/graphql/types/utils/current_version.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Utils + class CurrentVersion < Types::BaseObject + field :github_url, String, null: false + field :number, String, null: false + end + end +end diff --git a/app/graphql/types/wallet_transaction_consumptions/object.rb b/app/graphql/types/wallet_transaction_consumptions/object.rb new file mode 100644 index 0000000..65ed1ed --- /dev/null +++ b/app/graphql/types/wallet_transaction_consumptions/object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module WalletTransactionConsumptions + class Object < Types::BaseObject + graphql_name "WalletTransactionConsumption" + + field :amount_cents, GraphQL::Types::BigInt, null: false, method: :consumed_amount_cents + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :credit_amount, String, null: false + field :id, ID, null: false + field :wallet_transaction, Types::WalletTransactions::Object, null: false, method: :outbound_wallet_transaction + end + end +end diff --git a/app/graphql/types/wallet_transaction_fundings/object.rb b/app/graphql/types/wallet_transaction_fundings/object.rb new file mode 100644 index 0000000..9045330 --- /dev/null +++ b/app/graphql/types/wallet_transaction_fundings/object.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module WalletTransactionFundings + class Object < Types::BaseObject + graphql_name "WalletTransactionFunding" + + field :amount_cents, GraphQL::Types::BigInt, null: false, method: :consumed_amount_cents + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :credit_amount, String, null: false + field :id, ID, null: false + field :wallet_transaction, Types::WalletTransactions::Object, null: false, method: :inbound_wallet_transaction + end + end +end diff --git a/app/graphql/types/wallet_transactions/create_input.rb b/app/graphql/types/wallet_transactions/create_input.rb new file mode 100644 index 0000000..9d75951 --- /dev/null +++ b/app/graphql/types/wallet_transactions/create_input.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class CreateInput < Types::BaseInputObject + graphql_name "CreateCustomerWalletTransactionInput" + + argument :wallet_id, ID, required: true + + argument :granted_credits, String, required: false + argument :ignore_paid_top_up_limits, Boolean, required: false + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + argument :invoice_requires_successful_payment, Boolean, required: false + argument :metadata, [Types::WalletTransactions::MetadataInput], required: false + argument :name, String, required: false + argument :paid_credits, String, required: false + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + argument :priority, Integer, required: false + argument :voided_credits, String, required: false + end + end +end diff --git a/app/graphql/types/wallet_transactions/metadata_input.rb b/app/graphql/types/wallet_transactions/metadata_input.rb new file mode 100644 index 0000000..e0ea119 --- /dev/null +++ b/app/graphql/types/wallet_transactions/metadata_input.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class MetadataInput < Types::BaseInputObject + graphql_name "WalletTransactionMetadataInput" + + argument :key, String, required: true + argument :value, String, required: true + end + end +end diff --git a/app/graphql/types/wallet_transactions/metadata_object.rb b/app/graphql/types/wallet_transactions/metadata_object.rb new file mode 100644 index 0000000..d7c5bed --- /dev/null +++ b/app/graphql/types/wallet_transactions/metadata_object.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class MetadataObject < Types::BaseObject + graphql_name "WalletTransactionMetadataObject" + + field :key, String, null: false + field :value, String, null: false + end + end +end diff --git a/app/graphql/types/wallet_transactions/object.rb b/app/graphql/types/wallet_transactions/object.rb new file mode 100644 index 0000000..fd88497 --- /dev/null +++ b/app/graphql/types/wallet_transactions/object.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class Object < Types::BaseObject + graphql_name "WalletTransaction" + + field :id, ID, null: false + field :wallet, Types::Wallets::Object + + field :amount, String, null: false + field :credit_amount, String, null: false + field :invoice_requires_successful_payment, Boolean, null: false + field :name, String, null: true + field :priority, Integer, null: false + field :source, Types::WalletTransactions::SourceEnum, null: false + field :status, Types::WalletTransactions::StatusEnum, null: false + field :transaction_status, Types::WalletTransactions::TransactionStatusEnum, null: false + field :transaction_type, Types::WalletTransactions::TransactionTypeEnum, null: false + field :wallet_name, String, null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :failed_at, GraphQL::Types::ISO8601DateTime, null: true + field :invoice, Types::Invoices::Object, null: true + field :metadata, [Types::WalletTransactions::MetadataObject], null: true + field :remaining_amount_cents, GraphQL::Types::BigInt, null: true + field :remaining_credit_amount, String, null: true + field :settled_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + field :voided_invoice, Types::Invoices::Object, null: true + + field :selected_invoice_custom_sections, [Types::InvoiceCustomSections::Object], null: true + field :skip_invoice_custom_sections, Boolean + + def invoice + object.invoice&.visible? ? object.invoice : nil + end + + def wallet_name + object.wallet.name + end + end + end +end diff --git a/app/graphql/types/wallet_transactions/source_enum.rb b/app/graphql/types/wallet_transactions/source_enum.rb new file mode 100644 index 0000000..9326a43 --- /dev/null +++ b/app/graphql/types/wallet_transactions/source_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class SourceEnum < Types::BaseEnum + graphql_name "WalletTransactionSourceEnum" + + WalletTransaction::SOURCES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/wallet_transactions/status_enum.rb b/app/graphql/types/wallet_transactions/status_enum.rb new file mode 100644 index 0000000..4648812 --- /dev/null +++ b/app/graphql/types/wallet_transactions/status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class StatusEnum < Types::BaseEnum + graphql_name "WalletTransactionStatusEnum" + + WalletTransaction::STATUSES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/wallet_transactions/transaction_status_enum.rb b/app/graphql/types/wallet_transactions/transaction_status_enum.rb new file mode 100644 index 0000000..eb15d1a --- /dev/null +++ b/app/graphql/types/wallet_transactions/transaction_status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class TransactionStatusEnum < Types::BaseEnum + graphql_name "WalletTransactionTransactionStatusEnum" + + WalletTransaction::TRANSACTION_STATUSES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/wallet_transactions/transaction_type_enum.rb b/app/graphql/types/wallet_transactions/transaction_type_enum.rb new file mode 100644 index 0000000..78f885a --- /dev/null +++ b/app/graphql/types/wallet_transactions/transaction_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WalletTransactions + class TransactionTypeEnum < Types::BaseEnum + graphql_name "WalletTransactionTransactionTypeEnum" + + WalletTransaction::TRANSACTION_TYPES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/wallets/applies_to.rb b/app/graphql/types/wallets/applies_to.rb new file mode 100644 index 0000000..5060e76 --- /dev/null +++ b/app/graphql/types/wallets/applies_to.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module Wallets + class AppliesTo < Types::BaseObject + graphql_name "WalletAppliesTo" + + field :billable_metrics, [Types::BillableMetrics::Object] + field :fee_types, [Types::Fees::TypesEnum], null: true, method: :allowed_fee_types + end + end +end diff --git a/app/graphql/types/wallets/applies_to_input.rb b/app/graphql/types/wallets/applies_to_input.rb new file mode 100644 index 0000000..d7240e4 --- /dev/null +++ b/app/graphql/types/wallets/applies_to_input.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + module Wallets + class AppliesToInput < BaseInputObject + argument :billable_metric_ids, [ID], required: false + argument :fee_types, [Types::Fees::TypesEnum], required: false + end + end +end diff --git a/app/graphql/types/wallets/create_input.rb b/app/graphql/types/wallets/create_input.rb new file mode 100644 index 0000000..435d417 --- /dev/null +++ b/app/graphql/types/wallets/create_input.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module Wallets + class CreateInput < Types::BaseInputObject + description "Create Wallet Input" + + argument :billing_entity_id, ID, required: false + argument :code, String, required: false + argument :currency, Types::CurrencyEnum, required: true + argument :customer_id, ID, required: true + argument :expiration_at, GraphQL::Types::ISO8601DateTime, required: false + argument :granted_credits, String, required: true + argument :invoice_requires_successful_payment, Boolean, required: false + argument :name, String, required: false + argument :paid_credits, String, required: true + argument :priority, Integer, required: true + argument :rate_amount, String, required: true + + argument :ignore_paid_top_up_limits_on_creation, Boolean, required: false + argument :paid_top_up_max_amount_cents, GraphQL::Types::BigInt, required: false + argument :paid_top_up_min_amount_cents, GraphQL::Types::BigInt, required: false + + argument :recurring_transaction_rules, [Types::Wallets::RecurringTransactionRules::CreateInput], required: false + argument :transaction_name, String, required: false + + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + + argument :applies_to, Types::Wallets::AppliesToInput, required: false + + argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS + + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + end + end +end diff --git a/app/graphql/types/wallets/metadata.rb b/app/graphql/types/wallets/metadata.rb new file mode 100644 index 0000000..0edbc82 --- /dev/null +++ b/app/graphql/types/wallets/metadata.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Wallets + class Metadata < GraphqlPagination::CollectionMetadataType + graphql_name "WalletCollectionMetadata" + field :customer_active_wallets_count, Integer, null: false + + def customer_active_wallets_count + return 0 if object.empty? + + object.first.customer.wallets.active.count + end + end + end +end diff --git a/app/graphql/types/wallets/object.rb b/app/graphql/types/wallets/object.rb new file mode 100644 index 0000000..5b0e3a3 --- /dev/null +++ b/app/graphql/types/wallets/object.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Types + module Wallets + class Object < Types::BaseObject + graphql_name "Wallet" + description "Wallet" + + field :id, ID, null: false + + field :customer, Types::Customers::Object + + field :code, String, null: true + field :currency, Types::CurrencyEnum, null: false + field :name, String, null: true + field :priority, Integer, null: false + field :status, Types::Wallets::StatusEnum, null: false + + field :rate_amount, GraphQL::Types::Float, null: false + + field :balance_cents, GraphQL::Types::BigInt, null: false + field :consumed_amount_cents, GraphQL::Types::BigInt, null: false + field :ongoing_balance_cents, GraphQL::Types::BigInt, null: false + field :ongoing_usage_balance_cents, GraphQL::Types::BigInt, null: false + + field :consumed_credits, GraphQL::Types::Float, null: false + field :credits_balance, GraphQL::Types::Float, null: false + field :credits_ongoing_balance, GraphQL::Types::Float, null: false + field :credits_ongoing_usage_balance, GraphQL::Types::Float, null: false + + field :last_balance_sync_at, GraphQL::Types::ISO8601DateTime, null: true + field :last_consumed_credit_at, GraphQL::Types::ISO8601DateTime, null: true + field :last_ongoing_balance_sync_at, GraphQL::Types::ISO8601DateTime, null: true + + field :activity_logs, [Types::ActivityLogs::Object], null: true + field :recurring_transaction_rules, [Types::Wallets::RecurringTransactionRules::Object], null: true + + field :invoice_requires_successful_payment, Boolean, null: false + + field :paid_top_up_max_amount_cents, GraphQL::Types::BigInt, null: true + field :paid_top_up_max_credits, GraphQL::Types::BigInt, null: true + field :paid_top_up_min_amount_cents, GraphQL::Types::BigInt, null: true + field :paid_top_up_min_credits, GraphQL::Types::BigInt, null: true + + field :selected_invoice_custom_sections, [Types::InvoiceCustomSections::Object], null: true + field :skip_invoice_custom_sections, Boolean + + field :payment_method, Types::PaymentMethods::Object + field :payment_method_type, Types::PaymentMethods::MethodTypeEnum + + field :applies_to, Types::Wallets::AppliesTo, null: true, method: :itself + + field :metadata, [Types::Metadata::Object], null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :expiration_at, GraphQL::Types::ISO8601DateTime, null: true + field :terminated_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + field :traceable, Boolean, null: false + + def recurring_transaction_rules + object.recurring_transaction_rules.active + end + + def metadata + object.metadata&.value + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/create_input.rb b/app/graphql/types/wallets/recurring_transaction_rules/create_input.rb new file mode 100644 index 0000000..2fe0354 --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/create_input.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class CreateInput < Types::BaseInputObject + graphql_name "CreateRecurringTransactionRuleInput" + + argument :expiration_at, GraphQL::Types::ISO8601DateTime, required: false + argument :granted_credits, String, required: false + argument :ignore_paid_top_up_limits, Boolean, required: false + argument :interval, Types::Wallets::RecurringTransactionRules::IntervalEnum, required: false + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + argument :invoice_requires_successful_payment, Boolean, required: false + argument :method, Types::Wallets::RecurringTransactionRules::MethodEnum, required: false + argument :paid_credits, String, required: false + argument :started_at, GraphQL::Types::ISO8601DateTime, required: false + argument :target_ongoing_balance, String, required: false + argument :threshold_credits, String, required: false + argument :transaction_metadata, [Types::Wallets::RecurringTransactionRules::TransactionMetadataInput], required: false + argument :transaction_name, String, required: false + argument :trigger, Types::Wallets::RecurringTransactionRules::TriggerEnum, required: true + + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/interval_enum.rb b/app/graphql/types/wallets/recurring_transaction_rules/interval_enum.rb new file mode 100644 index 0000000..da44a11 --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/interval_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class IntervalEnum < Types::BaseEnum + graphql_name "RecurringTransactionIntervalEnum" + + RecurringTransactionRule::INTERVALS.each do |type| + value type + end + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/method_enum.rb b/app/graphql/types/wallets/recurring_transaction_rules/method_enum.rb new file mode 100644 index 0000000..11917ab --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/method_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class MethodEnum < Types::BaseEnum + graphql_name "RecurringTransactionMethodEnum" + + RecurringTransactionRule::METHODS.each do |type| + value type + end + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/object.rb b/app/graphql/types/wallets/recurring_transaction_rules/object.rb new file mode 100644 index 0000000..dbdf83c --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/object.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class Object < Types::BaseObject + graphql_name "RecurringTransactionRule" + + field :lago_id, ID, null: false, method: :id + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :expiration_at, GraphQL::Types::ISO8601DateTime, null: true + field :granted_credits, String, null: false + field :ignore_paid_top_up_limits, Boolean, null: false + field :interval, Types::Wallets::RecurringTransactionRules::IntervalEnum, null: true + field :invoice_requires_successful_payment, Boolean, null: false + field :method, Types::Wallets::RecurringTransactionRules::MethodEnum, null: false, resolver_method: :resolver_method + field :paid_credits, String, null: false + field :payment_method, Types::PaymentMethods::Object + field :payment_method_type, Types::PaymentMethods::MethodTypeEnum + field :selected_invoice_custom_sections, [Types::InvoiceCustomSections::Object], null: true + field :skip_invoice_custom_sections, Boolean + field :started_at, GraphQL::Types::ISO8601DateTime, null: true + field :target_ongoing_balance, String, null: true + field :threshold_credits, String, null: true + field :transaction_metadata, [Types::Wallets::RecurringTransactionRules::TransactionMetadataObject], null: true + field :transaction_name, String, null: true + field :trigger, Types::Wallets::RecurringTransactionRules::TriggerEnum, null: false + + def resolver_method + object.method + end + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_input.rb b/app/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_input.rb new file mode 100644 index 0000000..8e7300e --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class TransactionMetadataInput < Types::BaseInputObject + graphql_name "CreateTransactionMetadataInput" + + argument :key, String, required: true + argument :value, String, required: true + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_object.rb b/app/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_object.rb new file mode 100644 index 0000000..42b0ebf --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_object.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class TransactionMetadataObject < Types::BaseObject + graphql_name "TransactionMetadata" + + field :key, String, null: false + field :value, String, null: false + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/trigger_enum.rb b/app/graphql/types/wallets/recurring_transaction_rules/trigger_enum.rb new file mode 100644 index 0000000..fb14cf2 --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/trigger_enum.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class TriggerEnum < Types::BaseEnum + graphql_name "RecurringTransactionTriggerEnum" + + RecurringTransactionRule::TRIGGERS.each do |type| + value type + end + end + end + end +end diff --git a/app/graphql/types/wallets/recurring_transaction_rules/update_input.rb b/app/graphql/types/wallets/recurring_transaction_rules/update_input.rb new file mode 100644 index 0000000..cfab0d2 --- /dev/null +++ b/app/graphql/types/wallets/recurring_transaction_rules/update_input.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Types + module Wallets + module RecurringTransactionRules + class UpdateInput < Types::BaseInputObject + graphql_name "UpdateRecurringTransactionRuleInput" + + argument :expiration_at, GraphQL::Types::ISO8601DateTime, required: false + argument :granted_credits, String, required: false + argument :ignore_paid_top_up_limits, Boolean, required: false + argument :interval, Types::Wallets::RecurringTransactionRules::IntervalEnum, required: false + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + argument :invoice_requires_successful_payment, Boolean, required: false + argument :lago_id, ID, required: false + argument :method, Types::Wallets::RecurringTransactionRules::MethodEnum, required: false + argument :paid_credits, String, required: false + argument :started_at, GraphQL::Types::ISO8601DateTime, required: false + argument :target_ongoing_balance, String, required: false + argument :threshold_credits, String, required: false + argument :transaction_metadata, [Types::Wallets::RecurringTransactionRules::TransactionMetadataInput], required: false + argument :transaction_name, String, required: false + argument :trigger, Types::Wallets::RecurringTransactionRules::TriggerEnum, required: false + + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + end + end + end +end diff --git a/app/graphql/types/wallets/status_enum.rb b/app/graphql/types/wallets/status_enum.rb new file mode 100644 index 0000000..47808b8 --- /dev/null +++ b/app/graphql/types/wallets/status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Wallets + class StatusEnum < Types::BaseEnum + graphql_name "WalletStatusEnum" + + Wallet::STATUSES.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/wallets/update_input.rb b/app/graphql/types/wallets/update_input.rb new file mode 100644 index 0000000..3b0ffa9 --- /dev/null +++ b/app/graphql/types/wallets/update_input.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Types + module Wallets + class UpdateInput < Types::BaseInputObject + description "Update Wallet Input" + + argument :code, String, required: false + argument :expiration_at, GraphQL::Types::ISO8601DateTime, required: false + argument :id, ID, required: true + argument :invoice_requires_successful_payment, Boolean, required: false + argument :name, String, required: false + argument :priority, Integer, required: true + + argument :paid_top_up_max_amount_cents, GraphQL::Types::BigInt, required: false + argument :paid_top_up_min_amount_cents, GraphQL::Types::BigInt, required: false + + argument :invoice_custom_section, Types::InvoiceCustomSections::ReferenceInput, required: false + argument :recurring_transaction_rules, [Types::Wallets::RecurringTransactionRules::UpdateInput], required: false + + argument :applies_to, Types::Wallets::AppliesToInput, required: false + + argument :metadata, [Types::Metadata::Input], required: false, **Types::Metadata::Input::ARGUMENT_OPTIONS + + argument :payment_method, Types::PaymentMethods::ReferenceInput, required: false + end + end +end diff --git a/app/graphql/types/webhook_endpoints/create_input.rb b/app/graphql/types/webhook_endpoints/create_input.rb new file mode 100644 index 0000000..9ee79ee --- /dev/null +++ b/app/graphql/types/webhook_endpoints/create_input.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module WebhookEndpoints + class CreateInput < BaseInputObject + graphql_name "WebhookEndpointCreateInput" + + argument :event_types, [Types::WebhookEndpoints::EventTypeEnum], required: false + argument :name, String, required: false + argument :signature_algo, Types::WebhookEndpoints::SignatureAlgoEnum, required: false + argument :webhook_url, String, required: true + end + end +end diff --git a/app/graphql/types/webhook_endpoints/event_category_enum.rb b/app/graphql/types/webhook_endpoints/event_category_enum.rb new file mode 100644 index 0000000..135f88f --- /dev/null +++ b/app/graphql/types/webhook_endpoints/event_category_enum.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module WebhookEndpoints + class EventCategoryEnum < Types::BaseEnum + WebhookEndpoint::WEBHOOK_EVENT_TYPE_CONFIG.values + .map { |e| e[:category].to_s } + .uniq + .each do |category| + graphql_key = category.parameterize(separator: "_").upcase + + value graphql_key, value: category + end + end + end +end diff --git a/app/graphql/types/webhook_endpoints/event_type.rb b/app/graphql/types/webhook_endpoints/event_type.rb new file mode 100644 index 0000000..cdfffc6 --- /dev/null +++ b/app/graphql/types/webhook_endpoints/event_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module WebhookEndpoints + class EventType < Types::BaseObject + graphql_name "WebhookEventType" + + field :category, Types::WebhookEndpoints::EventCategoryEnum, null: false + field :deprecated, Boolean, null: false + field :description, String, null: false + field :key, Types::WebhookEndpoints::EventTypeEnum, null: false + field :name, String, null: false + end + end +end diff --git a/app/graphql/types/webhook_endpoints/event_type_enum.rb b/app/graphql/types/webhook_endpoints/event_type_enum.rb new file mode 100644 index 0000000..0713b26 --- /dev/null +++ b/app/graphql/types/webhook_endpoints/event_type_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WebhookEndpoints + class EventTypeEnum < Types::BaseEnum + WebhookEndpoint::WEBHOOK_EVENT_TYPE_CONFIG.each do |key, event_type| + value key.to_s, value: event_type[:name] + end + # special case for "all" event type which is not in the config but is a valid event type + value "all", value: "*" + end + end +end diff --git a/app/graphql/types/webhook_endpoints/object.rb b/app/graphql/types/webhook_endpoints/object.rb new file mode 100644 index 0000000..2fd3d85 --- /dev/null +++ b/app/graphql/types/webhook_endpoints/object.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module WebhookEndpoints + class Object < Types::BaseObject + graphql_name "WebhookEndpoint" + + field :event_types, [Types::WebhookEndpoints::EventTypeEnum], null: true + field :id, ID, null: false + field :name, String, null: true + field :organization, Types::Organizations::OrganizationType + field :signature_algo, Types::WebhookEndpoints::SignatureAlgoEnum + field :webhook_url, String, null: false + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + end + end +end diff --git a/app/graphql/types/webhook_endpoints/signature_algo_enum.rb b/app/graphql/types/webhook_endpoints/signature_algo_enum.rb new file mode 100644 index 0000000..26740b3 --- /dev/null +++ b/app/graphql/types/webhook_endpoints/signature_algo_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module WebhookEndpoints + class SignatureAlgoEnum < Types::BaseEnum + graphql_name "WebhookEndpointSignatureAlgoEnum" + + WebhookEndpoint::SIGNATURE_ALGOS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/types/webhook_endpoints/update_input.rb b/app/graphql/types/webhook_endpoints/update_input.rb new file mode 100644 index 0000000..e2cafaf --- /dev/null +++ b/app/graphql/types/webhook_endpoints/update_input.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module WebhookEndpoints + class UpdateInput < BaseInputObject + graphql_name "WebhookEndpointUpdateInput" + + argument :event_types, [Types::WebhookEndpoints::EventTypeEnum], required: false + argument :id, ID, required: true + argument :name, String, required: false + argument :signature_algo, Types::WebhookEndpoints::SignatureAlgoEnum, required: false + argument :webhook_url, String, required: true + end + end +end diff --git a/app/graphql/types/webhooks/object.rb b/app/graphql/types/webhooks/object.rb new file mode 100644 index 0000000..1ad7d57 --- /dev/null +++ b/app/graphql/types/webhooks/object.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Types + module Webhooks + class Object < Types::BaseObject + graphql_name "Webhook" + + field :id, ID, null: false + field :webhook_endpoint, Types::WebhookEndpoints::Object + + field :endpoint, String, null: false + field :object_type, String, null: false + field :retries, Integer, null: false + field :status, Types::Webhooks::StatusEnum, null: false + field :webhook_type, String, null: false + + field :http_status, Integer, null: true + field :payload, String, null: true + field :response, String, null: true + + field :created_at, GraphQL::Types::ISO8601DateTime, null: false + field :last_retried_at, GraphQL::Types::ISO8601DateTime, null: true + field :updated_at, GraphQL::Types::ISO8601DateTime, null: false + + def payload + object.payload&.to_json + end + end + end +end diff --git a/app/graphql/types/webhooks/status_enum.rb b/app/graphql/types/webhooks/status_enum.rb new file mode 100644 index 0000000..2aaa1b1 --- /dev/null +++ b/app/graphql/types/webhooks/status_enum.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Types + module Webhooks + class StatusEnum < Types::BaseEnum + graphql_name "WebhookStatusEnum" + + Webhook::STATUS.each do |type| + value type + end + end + end +end diff --git a/app/graphql/validators/unique_by_field_validator.rb b/app/graphql/validators/unique_by_field_validator.rb new file mode 100644 index 0000000..94088d2 --- /dev/null +++ b/app/graphql/validators/unique_by_field_validator.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Validators + class UniqueByFieldValidator < GraphQL::Schema::Validator + attr_reader :code_key + + def initialize(field_name: :code, **default_options) + @code_key = field_name + super(**default_options) + end + + def validate(object, context, value) + duplicates = value.map { it[code_key] }.tally.select { |_, count| count > 1 }.keys + + if duplicates.any? + "duplicated_field" + end + end + end +end diff --git a/app/jobs/ai_conversations/stream_job.rb b/app/jobs/ai_conversations/stream_job.rb new file mode 100644 index 0000000..38e0c80 --- /dev/null +++ b/app/jobs/ai_conversations/stream_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AiConversations + class StreamJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_AI_AGENT"]) + :ai_agent + else + :default + end + end + + unique :until_executed, on_conflict: :log + + def perform(ai_conversation:, message:) + AiConversations::StreamService.call(ai_conversation:, message:) + end + end +end diff --git a/app/jobs/application_job.rb b/app/jobs/application_job.rb new file mode 100644 index 0000000..2375ce3 --- /dev/null +++ b/app/jobs/application_job.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class ApplicationJob < ActiveJob::Base + sidekiq_options retry: 0 + + MAX_LOCK_RETRY_ATTEMPTS = 25 + MAX_LOCK_RETRY_DELAY = 16 # seconds + + # This is a generic error to trigger the retry of any job + # The max attempt is set to avoid infinite loops + retry_on RetriableError, wait: :polynomially_longer, attempts: 20 + + # This method is used to perform a job after a commit. + # + # It is meant to avoid race-conditions where a job run before changes are committed to the DB and we end up with stale + # data in the job. + # + # It is also possible to rely on `ActiveJob::Base.enqueue_after_transaction_commit` but this doesn't allow incremental + # changes. + # + # Note that this method is not compatible with configured jobs, e.g. + # `Invoices::UpdateFeesPaymentStatusJob.set(wait: 30.seconds).perform_later(invoice)`. + # + def self.perform_after_commit(...) + AfterCommitEverywhere.after_commit do + perform_later(...) + end + end + + # This method is a generic proc for specifying random wait between retries + # Usage: + # retry_on ExceptionClass, attempts: 5, wait: random_delay(16) + # + def self.random_delay(max_seconds) + ->(*) { rand(0...max_seconds) } + end + + # This method is a generic proc for using with lock retry attempts + # Usage: + # retry_on ExceptionClass, attempts: 5, wait: random_lock_retry_delay + # + def self.random_lock_retry_delay + random_delay(MAX_LOCK_RETRY_DELAY) + end +end diff --git a/app/jobs/bill_non_invoiceable_fees_job.rb b/app/jobs/bill_non_invoiceable_fees_job.rb new file mode 100644 index 0000000..4c0de48 --- /dev/null +++ b/app/jobs/bill_non_invoiceable_fees_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class BillNonInvoiceableFeesJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :default + end + end + + retry_on Sequenced::SequenceError, ActiveJob::DeserializationError, wait: :polynomially_longer, attempts: 15, jitter: 0.75 + + unique :until_executed, on_conflict: :log, lock_ttl: 4.hours + + def perform(subscriptions, billing_at) + result = Invoices::AdvanceChargesService.call(initial_subscriptions: subscriptions, billing_at:) + result.raise_if_error! + end +end diff --git a/app/jobs/bill_paid_credit_job.rb b/app/jobs/bill_paid_credit_job.rb new file mode 100644 index 0000000..a503773 --- /dev/null +++ b/app/jobs/bill_paid_credit_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class BillPaidCreditJob < ApplicationJob + queue_as "high_priority" + + retry_on Sequenced::SequenceError, wait: :polynomially_longer, attempts: 15, jitter: 0.75 + + def perform(wallet_transaction, timestamp, invoice: nil) + result = Invoices::PaidCreditService.call( + wallet_transaction:, + timestamp:, + invoice: + ) + return result if result.success? + + result.raise_if_error! if invoice || result.invoice.nil? || !result.invoice.generating? + + # NOTE: retry the job with the already created invoice in a previous failed attempt + self.class.set(wait: 3.seconds).perform_later( + wallet_transaction, + timestamp, + invoice: result.invoice + ) + end +end diff --git a/app/jobs/bill_subscription_job.rb b/app/jobs/bill_subscription_job.rb new file mode 100644 index 0000000..bc41118 --- /dev/null +++ b/app/jobs/bill_subscription_job.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class BillSubscriptionJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :default + end + end + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay + retry_on Sequenced::SequenceError, ActiveJob::DeserializationError, wait: :polynomially_longer, attempts: 15, jitter: 0.75 + + def perform(subscriptions, timestamp, invoicing_reason:, invoice: nil, skip_charges: false) + Rails.logger.info("BillSubscriptionJob[Invoice ID: #{invoice&.id}] - Started") + + result = Invoices::SubscriptionService.call( + subscriptions:, + timestamp:, + invoicing_reason:, + invoice:, + skip_charges: + ) + + if result.success? + Rails.logger.info("BillSubscriptionJob[Invoice ID: #{invoice&.id}] - Finished [SUCCESS]") + return + end + + Rails.logger.info("BillSubscriptionJob[Invoice ID: #{invoice&.id}] - Before reload [#{result.invoice&.inspect}]") + result.invoice&.reload + Rails.logger.info("BillSubscriptionJob[Invoice ID: #{invoice&.id}] - After reload [#{result.invoice&.inspect}]") + + # If the invoice was passed as an argument, it means the job was already retried (see end of function) + if invoice || !result.invoice&.generating? + Rails.logger.info("BillSubscriptionJob[Invoice ID: #{invoice&.id}] - generating?: #{result.invoice&.generating?}") + + ErrorDetail.create_generation_error_for(invoice: result.invoice, error: result.error) + Rails.logger.info("BillSubscriptionJob[Invoice ID: #{invoice&.id}] - Raising error: #{result.error.inspect}") + return result.raise_if_error! + end + + # On billing day, we'll retry the job further in the future because the system is typically under heavy load + is_billing_date = invoicing_reason.to_sym == :subscription_periodic + + Rails.logger.info("BillSubscriptionJob[Invoice ID: #{invoice&.id}] - Retrying with invoice") + + self.class.set(wait: is_billing_date ? 5.minutes : 3.seconds).perform_later( + subscriptions, + timestamp, + invoicing_reason:, + invoice: result.invoice, + skip_charges: + ) + end + + # Each hour, we check for each customer whether they need to be billed today. If it is the case and there's not + # invoice for today in the DB, we will schedule the BillSubscriptionJob with timestamp of the current time. So it + # could occur that we schedule a second job while the first one (from one hour ago) hasn't been processed yet due to a + # high number of jobs. As the timestamp won't be the same, the lock key would be different and both jobs could be + # processed concurrently, causing unnecessary jobs. Note that even if the job is schduled twice, we'll still prevent + # duplicate invoices. + # + # To avoid this, we normalize the timestamp in the customer's timezone and use the date as the lock key argument. + def lock_key_arguments + arguments = self.arguments.dup + + # if there is no subscription, we don't need to normalize anything + return arguments if arguments[0].empty? + timestamp = arguments[1] + subscriptions = arguments[0] + + # BillSubscriptionJob subscriptions will always contain subscriptions for the same customer + customer = subscriptions.first.customer + date = Time.zone.at(timestamp).in_time_zone(customer.applicable_timezone).to_date + arguments[1] = date + arguments + end +end diff --git a/app/jobs/billable_metric_filters/destroy_all_job.rb b/app/jobs/billable_metric_filters/destroy_all_job.rb new file mode 100644 index 0000000..f132bff --- /dev/null +++ b/app/jobs/billable_metric_filters/destroy_all_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module BillableMetricFilters + class DestroyAllJob < ApplicationJob + queue_as :low_priority + + def perform(billable_metric_id) + billable_metric = BillableMetric.with_discarded.find_by(id: billable_metric_id) + BillableMetricFilters::DestroyAllService.call!(billable_metric) + end + end +end diff --git a/app/jobs/billable_metric_filters/refresh_draft_invoices_job.rb b/app/jobs/billable_metric_filters/refresh_draft_invoices_job.rb new file mode 100644 index 0000000..530c677 --- /dev/null +++ b/app/jobs/billable_metric_filters/refresh_draft_invoices_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module BillableMetricFilters + class RefreshDraftInvoicesJob < ApplicationJob + queue_as :default + + def perform(billable_metric_id) + billable_metric = BillableMetric.find_by(id: billable_metric_id) + return unless billable_metric + + Invoice.draft + .joins(plans: [:billable_metrics]) + .where(billable_metrics: {id: billable_metric.id}) + .distinct + .in_batches do |batch| + batch.update_all(ready_to_be_refreshed: true) # rubocop:disable Rails/SkipsModelValidations + end + end + end +end diff --git a/app/jobs/billable_metrics/delete_events_job.rb b/app/jobs/billable_metrics/delete_events_job.rb new file mode 100644 index 0000000..b8f675c --- /dev/null +++ b/app/jobs/billable_metrics/delete_events_job.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module BillableMetrics + class DeleteEventsJob < ApplicationJob + queue_as :default + + def perform(metric) + return unless metric.discarded? + + deleted_at = Time.current + + # Delete events having an old-style `subscription_id` + Event.where( + code: metric.code, + subscription_id: Charge.with_discarded + .where(billable_metric_id: metric.id) + .joins(plan: :subscriptions).pluck("subscriptions.id") + ).update_all(deleted_at:) # rubocop:disable Rails/SkipsModelValidations + + # Delete events using the new `external_subscription_id` + Event.where( + organization_id: metric.organization.id, + code: metric.code, + external_subscription_id: Charge.with_discarded + .where(billable_metric_id: metric.id) + .joins(plan: :subscriptions).pluck("subscriptions.external_id") + ).update_all(deleted_at:) # rubocop:disable Rails/SkipsModelValidations + + # Delete events_raw & events_enriched on clickhouse using `external_subscription_id` + if ENV["LAGO_CLICKHOUSE_ENABLED"].present? + Clickhouse::EventsRaw.where( + organization_id: metric.organization.id, + code: metric.code, + external_subscription_id: Charge.with_discarded + .where(billable_metric_id: metric.id) + .joins(plan: :subscriptions).pluck("subscriptions.external_id") + ).delete_all + + Clickhouse::EventsEnriched.where( + organization_id: metric.organization.id, + code: metric.code, + external_subscription_id: Charge.with_discarded + .where(billable_metric_id: metric.id) + .joins(plan: :subscriptions).pluck("subscriptions.external_id") + ).delete_all + end + end + end +end diff --git a/app/jobs/billing_entities/taxes/refresh_draft_invoices_job.rb b/app/jobs/billing_entities/taxes/refresh_draft_invoices_job.rb new file mode 100644 index 0000000..042a386 --- /dev/null +++ b/app/jobs/billing_entities/taxes/refresh_draft_invoices_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module BillingEntities + module Taxes + class RefreshDraftInvoicesJob < ApplicationJob + queue_as :default + + def perform(billing_entity_id) + billing_entity = BillingEntity.find_by(id: billing_entity_id) + return unless billing_entity + + billing_entity.invoices.draft + .in_batches do |batch| + batch.update_all(ready_to_be_refreshed: true) # rubocop:disable Rails/SkipsModelValidations + end + end + end + end +end diff --git a/app/jobs/charge_filters/cascade_job.rb b/app/jobs/charge_filters/cascade_job.rb new file mode 100644 index 0000000..9b53b83 --- /dev/null +++ b/app/jobs/charge_filters/cascade_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module ChargeFilters + class CascadeJob < ApplicationJob + queue_as :default + + def perform(charge_id, action, filter_values, old_properties, new_properties, invoice_display_name) + charge = Charge.find_by(id: charge_id) + return unless charge + + ChargeFilters::CascadeService.call!( + charge:, + action:, + filter_values:, + old_properties:, + new_properties:, + invoice_display_name: + ) + end + end +end diff --git a/app/jobs/charges/create_children_batch_job.rb b/app/jobs/charges/create_children_batch_job.rb new file mode 100644 index 0000000..3450124 --- /dev/null +++ b/app/jobs/charges/create_children_batch_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Charges + class CreateChildrenBatchJob < ApplicationJob + queue_as "default" + + def perform(child_ids:, charge:, payload:) + Charges::CreateChildrenService.call!(child_ids:, charge:, payload:) + end + end +end diff --git a/app/jobs/charges/create_children_job.rb b/app/jobs/charges/create_children_job.rb new file mode 100644 index 0000000..6191524 --- /dev/null +++ b/app/jobs/charges/create_children_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Charges + class CreateChildrenJob < ApplicationJob + queue_as "default" + + def perform(charge:, payload:) + plan = charge.plan + return unless plan&.children&.any? + + plan.children.joins(:subscriptions).where(subscriptions: {status: %w[active pending]}).distinct.pluck(:id).each_slice(20) do |child_ids| + Charges::CreateChildrenBatchJob.perform_later( + child_ids:, + charge:, + payload: + ) + end + end + end +end diff --git a/app/jobs/charges/destroy_children_job.rb b/app/jobs/charges/destroy_children_job.rb new file mode 100644 index 0000000..4766899 --- /dev/null +++ b/app/jobs/charges/destroy_children_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Charges + class DestroyChildrenJob < ApplicationJob + queue_as :default + + def perform(charge_id) + charge = Charge.with_discarded.find_by(id: charge_id) + Charges::DestroyChildrenService.call!(charge) + end + end +end diff --git a/app/jobs/charges/update_children_batch_job.rb b/app/jobs/charges/update_children_batch_job.rb new file mode 100644 index 0000000..c472bd6 --- /dev/null +++ b/app/jobs/charges/update_children_batch_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Charges + class UpdateChildrenBatchJob < ApplicationJob + queue_as :low_priority + retry_on WithAdvisoryLock::FailedToAcquireLock, wait: :polynomially_longer, attempts: 5 + + def perform(child_ids:, params:, old_parent_attrs:, old_parent_filters_attrs:, old_parent_applied_pricing_unit_attrs:) + Rails.logger.info("Charges::UpdateChildrenBatchJob - Started the execution for parent charge with ID: #{old_parent_attrs["id"]}") + + charge = Charge.find_by(id: old_parent_attrs["id"]) + + Charges::UpdateChildrenService.call!( + charge:, + params:, + old_parent_attrs:, + old_parent_filters_attrs:, + old_parent_applied_pricing_unit_attrs:, + child_ids: + ) + + Rails.logger.info("Charges::UpdateChildrenBatchJob - Ended the execution for parent charge with ID: #{old_parent_attrs["id"]}") + end + end +end diff --git a/app/jobs/charges/update_children_job.rb b/app/jobs/charges/update_children_job.rb new file mode 100644 index 0000000..a55ebc3 --- /dev/null +++ b/app/jobs/charges/update_children_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Charges + class UpdateChildrenJob < ApplicationJob + queue_as :default + + def perform(params:, old_parent_attrs:, old_parent_filters_attrs:, old_parent_applied_pricing_unit_attrs:) + charge = Charge.find_by(id: old_parent_attrs["id"]) + return unless charge + + charge.children.joins(plan: :subscriptions).where(subscriptions: {status: %w[active pending]}).distinct.pluck(:id).each_slice(20) do |child_ids| + Charges::UpdateChildrenBatchJob.perform_later( + child_ids:, + params:, + old_parent_attrs:, + old_parent_filters_attrs:, + old_parent_applied_pricing_unit_attrs: + ) + end + end + end +end diff --git a/app/jobs/clock/activate_subscriptions_job.rb b/app/jobs/clock/activate_subscriptions_job.rb new file mode 100644 index 0000000..132e92d --- /dev/null +++ b/app/jobs/clock/activate_subscriptions_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + class ActivateSubscriptionsJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + Subscriptions::ActivateAllPendingService.call!(timestamp: Time.current.to_i) + end + end +end diff --git a/app/jobs/clock/api_keys/track_usage_job.rb b/app/jobs/clock/api_keys/track_usage_job.rb new file mode 100644 index 0000000..dc31433 --- /dev/null +++ b/app/jobs/clock/api_keys/track_usage_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + module ApiKeys + class TrackUsageJob < ClockJob + def perform + ::ApiKeys::TrackUsageService.call + end + end + end +end diff --git a/app/jobs/clock/compute_all_daily_usages_job.rb b/app/jobs/clock/compute_all_daily_usages_job.rb new file mode 100644 index 0000000..b89ca12 --- /dev/null +++ b/app/jobs/clock/compute_all_daily_usages_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + class ComputeAllDailyUsagesJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + DailyUsages::ComputeAllService.call(timestamp: Time.current) + end + end +end diff --git a/app/jobs/clock/consume_subscription_refreshed_queue_job.rb b/app/jobs/clock/consume_subscription_refreshed_queue_job.rb new file mode 100644 index 0000000..ceb9e66 --- /dev/null +++ b/app/jobs/clock/consume_subscription_refreshed_queue_job.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class Clock::ConsumeSubscriptionRefreshedQueueJob < ClockJob + unique :until_executed, on_conflict: :log + + # DEPRECATED: legacy version argument is kept for compatibility + def perform(version = "v2") + Subscriptions::ConsumeSubscriptionRefreshedQueueService.call! + end +end diff --git a/app/jobs/clock/create_interval_wallet_transactions_job.rb b/app/jobs/clock/create_interval_wallet_transactions_job.rb new file mode 100644 index 0000000..ca06881 --- /dev/null +++ b/app/jobs/clock/create_interval_wallet_transactions_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Clock + class CreateIntervalWalletTransactionsJob < ClockJob + def perform + Wallets::CreateIntervalWalletTransactionsService.call + end + end +end diff --git a/app/jobs/clock/events_validation_job.rb b/app/jobs/clock/events_validation_job.rb new file mode 100644 index 0000000..f66ffe3 --- /dev/null +++ b/app/jobs/clock/events_validation_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Clock + class EventsValidationJob < ClockJob + unique :until_executed + + def perform + # NOTE: refresh the last hour events materialized view + Scenic.database.refresh_materialized_view( + Events::LastHourMv.table_name, + concurrently: false, + cascade: false + ) + + organizations = Organization.where( + id: Events::LastHourMv.pluck("DISTINCT(organization_id)") + ) + + organizations.find_each do |organization| + next unless organization.webhook_endpoints.exists? + + Events::PostValidationJob.perform_later(organization:) + end + end + end +end diff --git a/app/jobs/clock/finalize_invoices_job.rb b/app/jobs/clock/finalize_invoices_job.rb new file mode 100644 index 0000000..ff652ad --- /dev/null +++ b/app/jobs/clock/finalize_invoices_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Clock + class FinalizeInvoicesJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + Invoice.ready_to_be_finalized.find_each do |invoice| + Invoices::FinalizeJob.perform_later(invoice) + end + end + end +end diff --git a/app/jobs/clock/free_trial_subscriptions_biller_job.rb b/app/jobs/clock/free_trial_subscriptions_biller_job.rb new file mode 100644 index 0000000..974a38f --- /dev/null +++ b/app/jobs/clock/free_trial_subscriptions_biller_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Clock + class FreeTrialSubscriptionsBillerJob < ClockJob + def perform + Subscriptions::FreeTrialBillingService.call + end + end +end diff --git a/app/jobs/clock/inbound_webhooks_cleanup_job.rb b/app/jobs/clock/inbound_webhooks_cleanup_job.rb new file mode 100644 index 0000000..da44e67 --- /dev/null +++ b/app/jobs/clock/inbound_webhooks_cleanup_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Clock + class InboundWebhooksCleanupJob < ClockJob + def perform + InboundWebhook.where("updated_at < ?", 90.days.ago).in_batches.delete_all + end + end +end diff --git a/app/jobs/clock/inbound_webhooks_retry_job.rb b/app/jobs/clock/inbound_webhooks_retry_job.rb new file mode 100644 index 0000000..1c4e7d5 --- /dev/null +++ b/app/jobs/clock/inbound_webhooks_retry_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + class InboundWebhooksRetryJob < ClockJob + def perform + InboundWebhook.retriable.find_each do |inbound_webhook| + InboundWebhooks::ProcessJob.perform_later(inbound_webhook:) + end + end + end +end diff --git a/app/jobs/clock/mark_invoices_as_payment_overdue_job.rb b/app/jobs/clock/mark_invoices_as_payment_overdue_job.rb new file mode 100644 index 0000000..c6370e5 --- /dev/null +++ b/app/jobs/clock/mark_invoices_as_payment_overdue_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Clock + class MarkInvoicesAsPaymentOverdueJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + Invoice + .finalized + .not_payment_succeeded + .where(payment_overdue: false) + .where(payment_dispute_lost_at: nil) + .where(payment_due_date: ...Time.current) + .in_batches(of: 1000, cursor: [:payment_due_date, :id]) do |batch| + jobs = batch.map do |invoice| + Invoices::Payments::MarkOverdueJob.new(invoice:) + end + ActiveJob.perform_all_later(jobs) + end + end + end +end diff --git a/app/jobs/clock/process_all_subscription_activities_job.rb b/app/jobs/clock/process_all_subscription_activities_job.rb new file mode 100644 index 0000000..be3ac7e --- /dev/null +++ b/app/jobs/clock/process_all_subscription_activities_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Clock + class ProcessAllSubscriptionActivitiesJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + return unless License.premium? + + UsageMonitoring::ProcessAllSubscriptionActivitiesService.call! + end + end +end diff --git a/app/jobs/clock/process_dedicated_orgs_subscription_activities_job.rb b/app/jobs/clock/process_dedicated_orgs_subscription_activities_job.rb new file mode 100644 index 0000000..2eaf213 --- /dev/null +++ b/app/jobs/clock/process_dedicated_orgs_subscription_activities_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Clock + class ProcessDedicatedOrgsSubscriptionActivitiesJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + return unless License.premium? + + organization_ids = Utils::DedicatedWorkerConfig.organization_ids + return if organization_ids.empty? + + UsageMonitoring::SubscriptionActivity + .where(organization_id: organization_ids, enqueued: false) + .distinct + .pluck(:organization_id) + .each do |organization_id| + UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob.perform_later(organization_id) + end + end + end +end diff --git a/app/jobs/clock/process_dunning_campaigns_job.rb b/app/jobs/clock/process_dunning_campaigns_job.rb new file mode 100644 index 0000000..b422fea --- /dev/null +++ b/app/jobs/clock/process_dunning_campaigns_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Clock + class ProcessDunningCampaignsJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + return unless License.premium? + + DunningCampaigns::BulkProcessJob.perform_later + end + end +end diff --git a/app/jobs/clock/refresh_dedicated_org_wallets_ongoing_balance_job.rb b/app/jobs/clock/refresh_dedicated_org_wallets_ongoing_balance_job.rb new file mode 100644 index 0000000..33924e8 --- /dev/null +++ b/app/jobs/clock/refresh_dedicated_org_wallets_ongoing_balance_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Clock + class RefreshDedicatedOrgWalletsOngoingBalanceJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + return unless License.premium? + + org_ids = Utils::DedicatedWorkerConfig.organization_ids + return if org_ids.empty? + + Customer + .where(organization_id: org_ids) + .with_active_wallets + .awaiting_wallet_refresh + .without_tax_errors + .find_each do |customer| + Customers::RefreshWalletJob.perform_later(customer) + end + end + end +end diff --git a/app/jobs/clock/refresh_draft_invoices_job.rb b/app/jobs/clock/refresh_draft_invoices_job.rb new file mode 100644 index 0000000..235e09f --- /dev/null +++ b/app/jobs/clock/refresh_draft_invoices_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Clock + class RefreshDraftInvoicesJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + Invoice.ready_to_be_refreshed.with_active_subscriptions.find_each do |invoice| + Invoices::RefreshDraftJob.perform_later(invoice:) + end + end + end +end diff --git a/app/jobs/clock/refresh_lifetime_usages_job.rb b/app/jobs/clock/refresh_lifetime_usages_job.rb new file mode 100644 index 0000000..292708c --- /dev/null +++ b/app/jobs/clock/refresh_lifetime_usages_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Clock + class RefreshLifetimeUsagesJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + return unless License.premium? + + Organization + .with_progressive_billing_support + .or(Organization.with_lifetime_usage_support) + .find_each do |organization| + LifetimeUsage + .where(organization_id: organization.id) + .where(recalculate_invoiced_usage: true) + .find_each do |ltu| + LifetimeUsages::RecalculateAndCheckJob.perform_later(ltu) + end + end + end + end +end diff --git a/app/jobs/clock/refresh_wallets_ongoing_balance_job.rb b/app/jobs/clock/refresh_wallets_ongoing_balance_job.rb new file mode 100644 index 0000000..93f5382 --- /dev/null +++ b/app/jobs/clock/refresh_wallets_ongoing_balance_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Clock + class RefreshWalletsOngoingBalanceJob < ClockJob + unique :until_executed, on_conflict: :log + + def perform + return unless License.premium? + + scope = Customer.with_active_wallets.awaiting_wallet_refresh.without_tax_errors + + dedicated_org_ids = Utils::DedicatedWorkerConfig.organization_ids + scope = scope.where.not(organization_id: dedicated_org_ids) if dedicated_org_ids.any? + + scope.find_each do |customer| + Customers::RefreshWalletJob.perform_later(customer) + end + end + end +end diff --git a/app/jobs/clock/retry_failed_invoices_job.rb b/app/jobs/clock/retry_failed_invoices_job.rb new file mode 100644 index 0000000..db4db03 --- /dev/null +++ b/app/jobs/clock/retry_failed_invoices_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Clock + class RetryFailedInvoicesJob < ClockJob + def perform + Invoice + .failed + .joins(:error_details) + .where("error_details.details ? 'tax_error_message'") + .where("error_details.details ->> 'tax_error_message' ILIKE ?", "%API limit%").find_each do |i| + Invoices::RetryService.call(invoice: i) + end + end + end +end diff --git a/app/jobs/clock/retry_generating_subscription_invoices_job.rb b/app/jobs/clock/retry_generating_subscription_invoices_job.rb new file mode 100644 index 0000000..f59ed5b --- /dev/null +++ b/app/jobs/clock/retry_generating_subscription_invoices_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Clock + class RetryGeneratingSubscriptionInvoicesJob < ClockJob + THRESHOLD = -> { 1.day.ago } + + def perform + ids = ErrorDetail.invoice_generation_error.where(owner_type: "Invoice").pluck(:owner_id) + Invoice.subscription.generating.where.not(id: ids).where("created_at < ?", THRESHOLD.call).find_each do |invoice| + next unless invoice.invoice_subscriptions.any? + invoicing_reasons = invoice.invoice_subscriptions.pluck(:invoicing_reason).uniq.compact + invoicing_reason = (invoicing_reasons.size == 1) ? invoicing_reasons.first : :upgrading + + next if invoicing_reason.to_s == "in_advance_charge" + + BillSubscriptionJob.perform_later( + invoice.subscriptions.to_a, + invoice.invoice_subscriptions.first.timestamp.to_i, + invoicing_reason:, + invoice:, + skip_charges: invoice.skip_charges + ) + end + end + end +end diff --git a/app/jobs/clock/subscriptions_biller_job.rb b/app/jobs/clock/subscriptions_biller_job.rb new file mode 100644 index 0000000..230fbef --- /dev/null +++ b/app/jobs/clock/subscriptions_biller_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + class SubscriptionsBillerJob < ClockJob + def perform + Organization.find_each do |organization| + Subscriptions::OrganizationBillingJob.perform_later(organization:) + end + end + end +end diff --git a/app/jobs/clock/subscriptions_to_be_terminated_job.rb b/app/jobs/clock/subscriptions_to_be_terminated_job.rb new file mode 100644 index 0000000..47c660d --- /dev/null +++ b/app/jobs/clock/subscriptions_to_be_terminated_job.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Clock + class SubscriptionsToBeTerminatedJob < ClockJob + def perform + now = Time.current + today = now.to_date + + Subscription + .active + .where("DATE(ending_at::timestamptz) IN (?)", sent_at_dates(now)) + .in_batches do |subscriptions| + subscriptions = subscriptions.to_a + subscription_ids_already_alerted = Webhook.where( + webhook_type: "subscription.termination_alert", + object_type: "Subscription", + object_id: subscriptions.map(&:id) + ) + .where("created_at::date = ?", today) + .pluck(:object_id) + .to_set + subscriptions.filter { |subscription| subscription_ids_already_alerted.exclude?(subscription.id) } + .each do |subscription| + SendWebhookJob.perform_later("subscription.termination_alert", subscription) + end + end + end + + private + + def sent_at_dates(now) + # NOTE: The alert will be sent 15 and 45 days before the subscription is terminated by default. + # You can override the default by setting below env var. + # E.g. LAGO_SUBSCRIPTION_TERMINATION_ALERT_SENT_AT_DAYS=1,15,45 will cause it + # to be sent at 1, 15, 45 days before subscription terminates, respectively. + sent_at_days_config = ENV.fetch("LAGO_SUBSCRIPTION_TERMINATION_ALERT_SENT_AT_DAYS", "15,45") + sent_at_days_config.split(",").map { |day_string| (now + day_string.to_i.days).to_date } + end + end +end diff --git a/app/jobs/clock/terminate_coupons_job.rb b/app/jobs/clock/terminate_coupons_job.rb new file mode 100644 index 0000000..d556ae1 --- /dev/null +++ b/app/jobs/clock/terminate_coupons_job.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Clock + class TerminateCouponsJob < ClockJob + def perform + Coupons::TerminateService.terminate_all_expired + end + end +end diff --git a/app/jobs/clock/terminate_ended_subscriptions_job.rb b/app/jobs/clock/terminate_ended_subscriptions_job.rb new file mode 100644 index 0000000..0b114a6 --- /dev/null +++ b/app/jobs/clock/terminate_ended_subscriptions_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Clock + class TerminateEndedSubscriptionsJob < ClockJob + def perform + Subscription + .joins(customer: :billing_entity) + .active + .where( + "DATE(subscriptions.ending_at#{Utils::Timezone.at_time_zone_sql}) = " \ + "DATE(?#{Utils::Timezone.at_time_zone_sql})", + Time.current + ) + .find_each do |subscription| + Subscriptions::TerminateEndedSubscriptionJob.perform_later(subscription) + end + end + end +end diff --git a/app/jobs/clock/terminate_recurring_transaction_rules_job.rb b/app/jobs/clock/terminate_recurring_transaction_rules_job.rb new file mode 100644 index 0000000..472024d --- /dev/null +++ b/app/jobs/clock/terminate_recurring_transaction_rules_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + class TerminateRecurringTransactionRulesJob < ClockJob + def perform + RecurringTransactionRule.eligible_for_termination.find_each do |recurring_transaction_rule| + Wallets::RecurringTransactionRules::TerminateService.call(recurring_transaction_rule:) + end + end + end +end diff --git a/app/jobs/clock/terminate_wallets_job.rb b/app/jobs/clock/terminate_wallets_job.rb new file mode 100644 index 0000000..49aba15 --- /dev/null +++ b/app/jobs/clock/terminate_wallets_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Clock + class TerminateWalletsJob < ClockJob + def perform + Wallet.active.expired.find_each do |wallet| + Wallets::TerminateService.call(wallet:) + end + end + end +end diff --git a/app/jobs/clock/webhooks_cleanup_job.rb b/app/jobs/clock/webhooks_cleanup_job.rb new file mode 100644 index 0000000..c82e910 --- /dev/null +++ b/app/jobs/clock/webhooks_cleanup_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Clock + class WebhooksCleanupJob < ClockJob + class_attribute :batch_size, default: 1_000 # rubocop:disable ThreadSafety/ClassAndModuleAttributes + class_attribute :retention_period, default: 90.days # rubocop:disable ThreadSafety/ClassAndModuleAttributes + + # NOTE: Manual batching is used instead of `in_batches` because the table can contain + # millions of rows. `in_batches` adds `ORDER BY id` which prevents PostgreSQL from + # using the covering index on `(updated_at) INCLUDE (id)`. + def perform + loop do + result = Webhook.where( + id: Webhook.where("updated_at < ?", retention_period.ago).limit(batch_size).select(:id) + ).delete_all + + break if result < batch_size + end + end + end +end diff --git a/app/jobs/clock_job.rb b/app/jobs/clock_job.rb new file mode 100644 index 0000000..68faff4 --- /dev/null +++ b/app/jobs/clock_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ClockJob < ApplicationJob + if ENV["SENTRY_DSN"].present? && ENV["SENTRY_ENABLE_CRONS"].present? + include SentryCronConcern + end + + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_CLOCK"]) + :clock_worker + else + :clock + end + end +end diff --git a/app/jobs/concerns/concurrency_throttlable.rb b/app/jobs/concerns/concurrency_throttlable.rb new file mode 100644 index 0000000..236e091 --- /dev/null +++ b/app/jobs/concerns/concurrency_throttlable.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module ConcurrencyThrottlable + extend ActiveSupport::Concern + + included do + include Sidekiq::Throttled::Job + + # The limit of concurrent API calls defined in: config/initializers/throttling.rb + sidekiq_throttle_as :concurrency_limit + end +end diff --git a/app/jobs/concerns/sentry_cron_concern.rb b/app/jobs/concerns/sentry_cron_concern.rb new file mode 100644 index 0000000..ff28830 --- /dev/null +++ b/app/jobs/concerns/sentry_cron_concern.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module SentryCronConcern + extend ActiveSupport::Concern + + included do + include Sentry::Cron::MonitorCheckIns + + class_attribute :sentry # rubocop:disable ThreadSafety/ClassAndModuleAttributes + + after_perform do + if ENV["SENTRY_ENABLE_CRONS"] && self.class.sentry.present? + self.class.sentry_monitor_check_ins( + slug: self.class.sentry["slug"], + monitor_config: Sentry::Cron::MonitorConfig.from_crontab(self.class.sentry["cron"]) + ) + end + end + + def serialize + super.tap do |data| + data["sentry"] = self.class.sentry if self.class.sentry.present? + end + end + + def deserialize(job_data) + super + self.class.sentry = job_data["sentry"] + end + end + + class_methods do + def set(options) + if ENV["SENTRY_ENABLE_CRONS"] && options[:sentry].present? + self.sentry = options[:sentry] + end + + super + end + end +end diff --git a/app/jobs/credit_notes/generate_documents_job.rb b/app/jobs/credit_notes/generate_documents_job.rb new file mode 100644 index 0000000..494b159 --- /dev/null +++ b/app/jobs/credit_notes/generate_documents_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module CreditNotes + class GenerateDocumentsJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :invoices + end + end + + retry_on LagoHttpClient::HttpError, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Net::OpenTimeout, + Net::ReadTimeout, + EOFError, wait: :polynomially_longer, attempts: 6 + + def perform(credit_note) + CreditNotes::GenerateXmlService.call!(credit_note:, context: "api") + CreditNotes::GeneratePdfService.call!(credit_note:, context: "api") + end + end +end diff --git a/app/jobs/credit_notes/generate_pdf_job.rb b/app/jobs/credit_notes/generate_pdf_job.rb new file mode 100644 index 0000000..ed2067e --- /dev/null +++ b/app/jobs/credit_notes/generate_pdf_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module CreditNotes + class GeneratePdfJob < ApplicationJob + queue_as "invoices" + + def perform(credit_note) + result = CreditNotes::GeneratePdfService.call(credit_note:, context: "api") + result.raise_if_error! + end + end +end diff --git a/app/jobs/credit_notes/generate_xml_job.rb b/app/jobs/credit_notes/generate_xml_job.rb new file mode 100644 index 0000000..d554ec5 --- /dev/null +++ b/app/jobs/credit_notes/generate_xml_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module CreditNotes + class GenerateXmlJob < ApplicationJob + queue_as "invoices" + + def perform(credit_note) + result = CreditNotes::GenerateXmlService.call(credit_note:, context: "api") + result.raise_if_error! + end + end +end diff --git a/app/jobs/credit_notes/provider_taxes/report_job.rb b/app/jobs/credit_notes/provider_taxes/report_job.rb new file mode 100644 index 0000000..a04fef1 --- /dev/null +++ b/app/jobs/credit_notes/provider_taxes/report_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module CreditNotes + module ProviderTaxes + class ReportJob < ApplicationJob + queue_as "integrations" + + def perform(credit_note:) + return if credit_note.invoice.credit? + return unless credit_note.customer.tax_customer + + # NOTE: We don't want to raise error here. + # If sync fails, invoice would be marked and retry option would be available in the UI + CreditNotes::ProviderTaxes::ReportService.call(credit_note:) + end + end + end +end diff --git a/app/jobs/credit_notes/refunds/adyen_create_job.rb b/app/jobs/credit_notes/refunds/adyen_create_job.rb new file mode 100644 index 0000000..cb3cefe --- /dev/null +++ b/app/jobs/credit_notes/refunds/adyen_create_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module CreditNotes + module Refunds + class AdyenCreateJob < ApplicationJob + queue_as "providers" + + def perform(credit_note) + result = CreditNotes::Refunds::AdyenService.new(credit_note).create + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/credit_notes/refunds/gocardless_create_job.rb b/app/jobs/credit_notes/refunds/gocardless_create_job.rb new file mode 100644 index 0000000..f59a162 --- /dev/null +++ b/app/jobs/credit_notes/refunds/gocardless_create_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module CreditNotes + module Refunds + class GocardlessCreateJob < ApplicationJob + queue_as "providers" + + def perform(credit_note) + result = CreditNotes::Refunds::GocardlessService.new(credit_note).create + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/credit_notes/refunds/stripe_create_job.rb b/app/jobs/credit_notes/refunds/stripe_create_job.rb new file mode 100644 index 0000000..0ad5a2c --- /dev/null +++ b/app/jobs/credit_notes/refunds/stripe_create_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module CreditNotes + module Refunds + class StripeCreateJob < ApplicationJob + queue_as "providers" + + def perform(credit_note) + result = CreditNotes::Refunds::StripeService.new(credit_note).create + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/customers/refresh_wallet_job.rb b/app/jobs/customers/refresh_wallet_job.rb new file mode 100644 index 0000000..dd49657 --- /dev/null +++ b/app/jobs/customers/refresh_wallet_job.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Customers + class RefreshWalletJob < ApplicationJob + OUT_OF_MEMORY_ERROR = "function_runtime_out_of_memory" + + queue_as do + customer = arguments.first + if Utils::DedicatedWorkerConfig.enabled_for?(customer&.organization_id) + Utils::DedicatedWorkerConfig::DEDICATED_WALLETS_QUEUE + else + :low_priority + end + end + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + retry_on ActiveRecord::StaleObjectError, wait: :polynomially_longer, attempts: 6 + retry_on BaseService::TooManyProviderRequestsFailure, wait: :polynomially_longer, attempts: 25 + retry_on Net::ReadTimeout, + Integrations::Aggregator::BadGatewayError, + Integrations::Aggregator::OutOfMemoryError, wait: :polynomially_longer, attempts: 6 + + def perform(customer) + return unless customer.awaiting_wallet_refresh? + return if customer.error_details.tax_error.exists? + + Customers::RefreshWalletsService.call!(customer:) + rescue BaseService::ValidationFailure => e + tax_error = Array(e.messages[:tax_error]) + + raise Integrations::Aggregator::OutOfMemoryError if tax_error.any? { |msg| msg.include?(OUT_OF_MEMORY_ERROR) } + raise unless tax_error.any? { |msg| msg.include?(Integrations::Aggregator::Taxes::BaseService::CUSTOMER_ADDRESS_INVALID) } + + ErrorDetails::CreateService.call!( + owner: customer, + organization: customer.organization, + params: { + error_code: :tax_error, + details: { + tax_error: e.messages[:tax_error]&.first, + backtrace: e.backtrace, + error: e.inspect.to_json + }.compact + } + ) + end + end +end diff --git a/app/jobs/customers/retry_vies_check_job.rb b/app/jobs/customers/retry_vies_check_job.rb new file mode 100644 index 0000000..64fa2f5 --- /dev/null +++ b/app/jobs/customers/retry_vies_check_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Customers + # Kept for backward compatibility with jobs already enqueued under the old name. + # Previously this job received a customer ID string; the new ViesCheckJob expects + # a Customer object (GlobalID). This class bridges the old signature. + class RetryViesCheckJob < ApplicationJob + queue_as :default + + def perform(customer_id) + customer = Customer.find(customer_id) + + ViesCheckJob.perform_now(customer) + end + end +end diff --git a/app/jobs/customers/terminate_relations_job.rb b/app/jobs/customers/terminate_relations_job.rb new file mode 100644 index 0000000..77d5bde --- /dev/null +++ b/app/jobs/customers/terminate_relations_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Customers + class TerminateRelationsJob < ApplicationJob + def perform(customer_id:) + customer = Customer.with_discarded.find_by(id: customer_id) + + result = Customers::TerminateRelationsService.call(customer:) + result.raise_if_error! + end + end +end diff --git a/app/jobs/customers/vies_check_job.rb b/app/jobs/customers/vies_check_job.rb new file mode 100644 index 0000000..9505c58 --- /dev/null +++ b/app/jobs/customers/vies_check_job.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Customers + class ViesCheckJob < ApplicationJob + RETRY_DELAYS = [5.minutes, 5.minutes, 10.minutes, 20.minutes, 40.minutes].freeze + MAX_RETRY_DELAY = 1.hour + + queue_as :default + + def perform(customer) + vies_check_result = Customers::ViesCheckService.call(customer:) + + if vies_check_result.success? + Customers::ApplyTaxesService.call( + customer: customer, + tax_codes: [vies_check_result.tax_code] + ) + + # Finalize any invoices that were blocked by VIES + enqueue_pending_invoice_finalization(customer) + else + schedule_retry(customer, vies_check_result) + end + end + + private + + def schedule_retry(customer, vies_check_result) + return unless vies_check_result.pending_vies_check + + ViesCheckJob.set(wait: retry_delay(vies_check_result.pending_vies_check)).perform_later(customer) + end + + def enqueue_pending_invoice_finalization(customer) + # status :open + tax_status :pending only occurs for gated invoices — + # EnsureCompletedViesCheckService keeps gated VIES-blocked invoices :open + # instead of transitioning them to :pending — so adding :open to the + # status set is enough to pick up gated cases without an explicit + # subscription_gated? check. + customer.invoices + .where(status: %i[pending open], tax_status: :pending) + .find_each do |invoice| + Invoices::FinalizePendingViesInvoiceJob.perform_later(invoice) + end + end + + def retry_delay(pending_vies_check) + RETRY_DELAYS[pending_vies_check.attempts_count.to_i - 1] || MAX_RETRY_DELAY + end + end +end diff --git a/app/jobs/daily_usages/compute_job.rb b/app/jobs/daily_usages/compute_job.rb new file mode 100644 index 0000000..2a566b7 --- /dev/null +++ b/app/jobs/daily_usages/compute_job.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module DailyUsages + class ComputeJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_ANALYTICS"]) + :analytics + else + :low_priority + end + end + + retry_on ActiveRecord::ActiveRecordError, wait: :polynomially_longer, attempts: 6 + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + def perform(subscription, timestamp:) + DailyUsages::ComputeService.call(subscription:, timestamp:).raise_if_error! + end + + def lock_key_arguments + subscription = arguments[0] + timestamp = arguments[1][:timestamp] + timestamp_in_customer_tz = timestamp.in_time_zone(subscription.customer.applicable_timezone) + [subscription.id, timestamp_in_customer_tz.to_date] + end + end +end diff --git a/app/jobs/daily_usages/fill_from_invoice_job.rb b/app/jobs/daily_usages/fill_from_invoice_job.rb new file mode 100644 index 0000000..32d7c0d --- /dev/null +++ b/app/jobs/daily_usages/fill_from_invoice_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module DailyUsages + class FillFromInvoiceJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_ANALYTICS"]) + :analytics + else + :low_priority + end + end + + def perform(invoice:, subscriptions:) + DailyUsages::FillFromInvoiceService.call(invoice:, subscriptions:).raise_if_error! + end + end +end diff --git a/app/jobs/daily_usages/fill_history_job.rb b/app/jobs/daily_usages/fill_history_job.rb new file mode 100644 index 0000000..c40888f --- /dev/null +++ b/app/jobs/daily_usages/fill_history_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module DailyUsages + class FillHistoryJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_ANALYTICS"]) + :analytics + else + :long_running + end + end + + def perform(subscription:, from_date:, to_date: nil, sandbox: false) + DailyUsages::FillHistoryService.call!(subscription:, from_date:, to_date:, sandbox:) + end + end +end diff --git a/app/jobs/data_exports/combine_parts_job.rb b/app/jobs/data_exports/combine_parts_job.rb new file mode 100644 index 0000000..817625f --- /dev/null +++ b/app/jobs/data_exports/combine_parts_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module DataExports + class CombinePartsJob < ApplicationJob + queue_as :default + + unique :until_executed, on_conflict: :log + + def perform(data_export) + CombinePartsService.call(data_export:).raise_if_error! + end + end +end diff --git a/app/jobs/data_exports/export_resources_job.rb b/app/jobs/data_exports/export_resources_job.rb new file mode 100644 index 0000000..873cc47 --- /dev/null +++ b/app/jobs/data_exports/export_resources_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module DataExports + class ExportResourcesJob < ApplicationJob + queue_as :default + + DEFAULT_BATCH_SIZE = 20 + + def perform(data_export, batch_size: DEFAULT_BATCH_SIZE) + ExportResourcesService.call(data_export:, batch_size:).raise_if_error! + end + end +end diff --git a/app/jobs/data_exports/process_part_job.rb b/app/jobs/data_exports/process_part_job.rb new file mode 100644 index 0000000..153da1b --- /dev/null +++ b/app/jobs/data_exports/process_part_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module DataExports + class ProcessPartJob < ApplicationJob + queue_as :default + + def perform(data_export_part) + ProcessPartService.call(data_export_part:).raise_if_error! + end + end +end diff --git a/app/jobs/database_migrations/backfill_adyen_payment_methods_job.rb b/app/jobs/database_migrations/backfill_adyen_payment_methods_job.rb new file mode 100644 index 0000000..08408d4 --- /dev/null +++ b/app/jobs/database_migrations/backfill_adyen_payment_methods_job.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillAdyenPaymentMethodsJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1_000 + + def perform(organization_id = nil, batch_number = 1) + unless pending_work?(organization_id) + Rails.logger.info("Finished backfilling Adyen payment methods") + return + end + + org_filter = organization_id ? "AND ppc.organization_id = '#{organization_id}'" : "" + + result = ActiveRecord::Base.connection.execute(<<~SQL.squish) + WITH batch AS ( + SELECT + ppc.id AS ppc_id, + ppc.organization_id, + ppc.customer_id, + ppc.payment_provider_id, + ppc.provider_customer_id, + ppc.settings->>'payment_method_id' AS provider_method_id + FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::AdyenCustomer' + AND ppc.settings->>'payment_method_id' IS NOT NULL + AND ppc.deleted_at IS NULL + #{org_filter} + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'payment_method_id' + ) + LIMIT #{BATCH_SIZE} + ) + INSERT INTO payment_methods ( + id, + organization_id, + customer_id, + payment_provider_id, + payment_provider_customer_id, + provider_method_id, + provider_method_type, + is_default, + details, + created_at, + updated_at + ) + SELECT + gen_random_uuid(), + batch.organization_id, + batch.customer_id, + batch.payment_provider_id, + batch.ppc_id, + batch.provider_method_id, + 'card', + true, + jsonb_build_object('provider_customer_id', batch.provider_customer_id, 'from_migration', true), + NOW(), + NOW() + FROM batch + SQL + + self.class.perform_later(organization_id, batch_number + 1) if result.cmd_tuples.positive? + end + + def lock_key_arguments + [arguments] + end + + private + + def pending_work?(organization_id) + scope = PaymentProviderCustomers::AdyenCustomer + .where("settings->>'payment_method_id' IS NOT NULL") + .where( + "NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = payment_provider_customers.id + AND pm.provider_method_id = payment_provider_customers.settings->>'payment_method_id' + )" + ) + scope = scope.where(organization_id:) if organization_id + scope.exists? + end + end +end diff --git a/app/jobs/database_migrations/backfill_charges_code_job.rb b/app/jobs/database_migrations/backfill_charges_code_job.rb new file mode 100644 index 0000000..ca76362 --- /dev/null +++ b/app/jobs/database_migrations/backfill_charges_code_job.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillChargesCodeJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1_000 + + def perform(batch_number = 1) + return Rails.logger.info("Finished populating charge codes") unless Charge.unscoped.where(code: nil).exists? + + result = ActiveRecord::Base.connection.execute(<<-SQL.squish) + WITH plan_batch AS ( + SELECT DISTINCT plan_id + FROM charges + WHERE code IS NULL + LIMIT #{BATCH_SIZE} + ), + ranked_codes AS ( + SELECT + c.id, + bm.code AS base_code, + c.code IS NULL AS needs_update, + ROW_NUMBER() OVER ( + PARTITION BY c.plan_id, bm.code + ORDER BY CASE WHEN c.code IS NOT NULL THEN 0 ELSE 1 END, c.created_at, c.id + ) AS rn + FROM charges c + INNER JOIN plan_batch pb ON pb.plan_id = c.plan_id + INNER JOIN billable_metrics bm ON bm.id = c.billable_metric_id + ) + UPDATE charges + SET code = CASE + WHEN ranked_codes.rn = 1 THEN ranked_codes.base_code + ELSE ranked_codes.base_code || '_' || ranked_codes.rn + END + FROM ranked_codes + WHERE charges.id = ranked_codes.id + AND ranked_codes.needs_update = true + SQL + + if result.cmd_tuples.positive? + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished populating charge codes") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/backfill_fixed_charges_code_job.rb b/app/jobs/database_migrations/backfill_fixed_charges_code_job.rb new file mode 100644 index 0000000..6a38fd2 --- /dev/null +++ b/app/jobs/database_migrations/backfill_fixed_charges_code_job.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillFixedChargesCodeJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1_000 + + def perform(batch_number = 1) + return Rails.logger.info("Finished populating fixed charge codes") unless FixedCharge.unscoped.where(code: nil).exists? + + result = ActiveRecord::Base.connection.execute(<<-SQL.squish) + WITH plan_batch AS ( + SELECT DISTINCT plan_id + FROM fixed_charges + WHERE code IS NULL + LIMIT #{BATCH_SIZE} + ), + ranked_codes AS ( + SELECT + fc.id, + ao.code AS base_code, + fc.code IS NULL AS needs_update, + ROW_NUMBER() OVER ( + PARTITION BY fc.plan_id, ao.code + ORDER BY CASE WHEN fc.code IS NOT NULL THEN 0 ELSE 1 END, fc.created_at, fc.id + ) AS rn + FROM fixed_charges fc + INNER JOIN plan_batch pb ON pb.plan_id = fc.plan_id + INNER JOIN add_ons ao ON ao.id = fc.add_on_id + ) + UPDATE fixed_charges + SET code = CASE + WHEN ranked_codes.rn = 1 THEN ranked_codes.base_code + ELSE ranked_codes.base_code || '_' || ranked_codes.rn + END + FROM ranked_codes + WHERE fixed_charges.id = ranked_codes.id + AND ranked_codes.needs_update = true + SQL + + if result.cmd_tuples.positive? + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished populating fixed charge codes") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/backfill_gocardless_payment_methods_job.rb b/app/jobs/database_migrations/backfill_gocardless_payment_methods_job.rb new file mode 100644 index 0000000..ceabf0f --- /dev/null +++ b/app/jobs/database_migrations/backfill_gocardless_payment_methods_job.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillGocardlessPaymentMethodsJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1_000 + + def perform(organization_id = nil, batch_number = 1) + unless pending_work?(organization_id) + Rails.logger.info("Finished backfilling GoCardless payment methods") + return + end + + org_filter = organization_id ? "AND ppc.organization_id = '#{organization_id}'" : "" + + result = ActiveRecord::Base.connection.execute(<<~SQL.squish) + WITH batch AS ( + SELECT + ppc.id AS ppc_id, + ppc.organization_id, + ppc.customer_id, + ppc.payment_provider_id, + ppc.provider_customer_id, + ppc.settings->>'provider_mandate_id' AS provider_method_id + FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::GocardlessCustomer' + AND ppc.settings->>'provider_mandate_id' IS NOT NULL + AND ppc.deleted_at IS NULL + #{org_filter} + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'provider_mandate_id' + ) + LIMIT #{BATCH_SIZE} + ) + INSERT INTO payment_methods ( + id, + organization_id, + customer_id, + payment_provider_id, + payment_provider_customer_id, + provider_method_id, + provider_method_type, + is_default, + details, + created_at, + updated_at + ) + SELECT + gen_random_uuid(), + batch.organization_id, + batch.customer_id, + batch.payment_provider_id, + batch.ppc_id, + batch.provider_method_id, + 'card', + true, + jsonb_build_object('provider_customer_id', batch.provider_customer_id, 'from_migration', true), + NOW(), + NOW() + FROM batch + SQL + + self.class.perform_later(organization_id, batch_number + 1) if result.cmd_tuples.positive? + end + + def lock_key_arguments + [arguments] + end + + private + + def pending_work?(organization_id) + scope = PaymentProviderCustomers::GocardlessCustomer + .where("settings->>'provider_mandate_id' IS NOT NULL") + .where( + "NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = payment_provider_customers.id + AND pm.provider_method_id = payment_provider_customers.settings->>'provider_mandate_id' + )" + ) + scope = scope.where(organization_id:) if organization_id + scope.exists? + end + end +end diff --git a/app/jobs/database_migrations/backfill_last_received_event_on_job.rb b/app/jobs/database_migrations/backfill_last_received_event_on_job.rb new file mode 100644 index 0000000..06b58a3 --- /dev/null +++ b/app/jobs/database_migrations/backfill_last_received_event_on_job.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillLastReceivedEventOnJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + def perform(organization_id) + organization = Organization.find(organization_id) + + if organization.clickhouse_events_store? + process_clickhouse(organization) + else + process_postgres(organization) + end + end + + def lock_key_arguments + [arguments] + end + + private + + def process_postgres(organization) + organization.subscriptions.active + .where(last_received_event_on: nil) + .find_each do |subscription| + base_scope = Event.where( + organization_id: organization.id, + external_subscription_id: subscription.external_id, + deleted_at: nil + ) + + last_event_date = find_last_event_date(base_scope, subscription.started_at.to_date) + subscription.update_column(:last_received_event_on, last_event_date) if last_event_date # rubocop:disable Rails/SkipsModelValidations + end + end + + def process_clickhouse(organization) + organization.subscriptions.active + .where(last_received_event_on: nil) + .find_each do |subscription| + last_event_date = Clickhouse::EventsRaw + .where(organization_id: organization.id, external_subscription_id: subscription.external_id) + .order(ingested_at: :desc) + .limit(1) + .pick(:ingested_at) + &.to_date + + subscription.update_column(:last_received_event_on, last_event_date) if last_event_date # rubocop:disable Rails/SkipsModelValidations + end + end + + def find_last_event_date(base_scope, start_date) + return nil unless base_scope.where("timestamp >= ?", start_date).exists? + + today = Date.current + days_active = (today - start_date).to_i + + if days_active > 365 + find_last_event_date_windowed(base_scope, start_date, today) + else + find_last_event_date_binary(base_scope, start_date, today) + end + end + + def find_last_event_date_binary(base_scope, low, high) + result = nil + while low <= high + mid = low + ((high - low) / 2).days + if base_scope.where("timestamp >= ?", mid).exists? + result = mid + low = mid + 1.day + else + high = mid - 1.day + end + end + result + end + + def find_last_event_date_windowed(base_scope, start_date, today) + [7, 30, 90, 180, 365, 730, 1500].each do |days| + window_start = [today - days.days, start_date].max + next unless base_scope.where("timestamp >= ?", window_start).exists? + + return find_last_event_date_binary(base_scope, window_start, today) + end + nil + end + end +end diff --git a/app/jobs/database_migrations/backfill_moneyhash_payment_methods_job.rb b/app/jobs/database_migrations/backfill_moneyhash_payment_methods_job.rb new file mode 100644 index 0000000..565f8dd --- /dev/null +++ b/app/jobs/database_migrations/backfill_moneyhash_payment_methods_job.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillMoneyhashPaymentMethodsJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1_000 + + def perform(organization_id = nil, batch_number = 1) + unless pending_work?(organization_id) + Rails.logger.info("Finished backfilling Moneyhash payment methods") + return + end + + org_filter = organization_id ? "AND ppc.organization_id = '#{organization_id}'" : "" + + result = ActiveRecord::Base.connection.execute(<<~SQL.squish) + WITH batch AS ( + SELECT + ppc.id AS ppc_id, + ppc.organization_id, + ppc.customer_id, + ppc.payment_provider_id, + ppc.provider_customer_id, + ppc.settings->>'payment_method_id' AS provider_method_id + FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::MoneyhashCustomer' + AND ppc.settings->>'payment_method_id' IS NOT NULL + AND ppc.deleted_at IS NULL + #{org_filter} + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'payment_method_id' + ) + LIMIT #{BATCH_SIZE} + ) + INSERT INTO payment_methods ( + id, + organization_id, + customer_id, + payment_provider_id, + payment_provider_customer_id, + provider_method_id, + provider_method_type, + is_default, + details, + created_at, + updated_at + ) + SELECT + gen_random_uuid(), + batch.organization_id, + batch.customer_id, + batch.payment_provider_id, + batch.ppc_id, + batch.provider_method_id, + 'card', + true, + jsonb_build_object('provider_customer_id', batch.provider_customer_id, 'from_migration', true), + NOW(), + NOW() + FROM batch + SQL + + self.class.perform_later(organization_id, batch_number + 1) if result.cmd_tuples.positive? + end + + def lock_key_arguments + [arguments] + end + + private + + def pending_work?(organization_id) + scope = PaymentProviderCustomers::MoneyhashCustomer + .where("settings->>'payment_method_id' IS NOT NULL") + .where( + "NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = payment_provider_customers.id + AND pm.provider_method_id = payment_provider_customers.settings->>'payment_method_id' + )" + ) + scope = scope.where(organization_id:) if organization_id + scope.exists? + end + end +end diff --git a/app/jobs/database_migrations/backfill_stripe_payment_method_card_details_job.rb b/app/jobs/database_migrations/backfill_stripe_payment_method_card_details_job.rb new file mode 100644 index 0000000..d4d11c7 --- /dev/null +++ b/app/jobs/database_migrations/backfill_stripe_payment_method_card_details_job.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillStripePaymentMethodCardDetailsJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1_000 + + def perform(organization_id = nil, batch_number = 1) + return Rails.logger.info("Finished backfilling payment method card details") unless pending_work?(organization_id) + + org_filter = organization_id ? "AND pm.organization_id = '#{organization_id}'" : "" + + result = ActiveRecord::Base.connection.execute(<<~SQL.squish) + WITH batch AS ( + SELECT + pm.id, + pm.payment_provider_customer_id, + pm.provider_method_id + FROM payment_methods pm + WHERE pm.details->>'from_migration' = 'true' + AND pm.details->>'last4' IS NULL + AND pm.deleted_at IS NULL + #{org_filter} + AND EXISTS ( + SELECT 1 FROM payments p + WHERE p.payment_provider_customer_id = pm.payment_provider_customer_id + AND p.provider_payment_method_id = pm.provider_method_id + AND p.provider_payment_method_data->>'last4' IS NOT NULL + ) + LIMIT #{BATCH_SIZE} + ), + last_payment_data AS ( + SELECT DISTINCT ON (p.payment_provider_customer_id, p.provider_payment_method_id) + p.payment_provider_customer_id, + p.provider_payment_method_id, + p.provider_payment_method_data + FROM payments p + WHERE p.provider_payment_method_data->>'last4' IS NOT NULL + AND EXISTS ( + SELECT 1 FROM batch b + WHERE b.payment_provider_customer_id = p.payment_provider_customer_id + AND b.provider_method_id = p.provider_payment_method_id + ) + ORDER BY p.payment_provider_customer_id, p.provider_payment_method_id, p.created_at DESC + ) + UPDATE payment_methods pm + SET + details = pm.details || jsonb_strip_nulls(jsonb_build_object( + 'type', lpd.provider_payment_method_data->>'type', + 'brand', lpd.provider_payment_method_data->>'brand', + 'last4', lpd.provider_payment_method_data->>'last4', + 'expiration_month', lpd.provider_payment_method_data->'expiration_month', + 'expiration_year', lpd.provider_payment_method_data->'expiration_year' + )), + updated_at = NOW() + FROM batch + INNER JOIN last_payment_data lpd + ON lpd.payment_provider_customer_id = batch.payment_provider_customer_id + AND lpd.provider_payment_method_id = batch.provider_method_id + WHERE pm.id = batch.id + SQL + + if result.cmd_tuples.positive? + self.class.perform_later(organization_id, batch_number + 1) + else + Rails.logger.info("Finished backfilling payment method card details") + end + end + + def lock_key_arguments + [arguments] + end + + private + + def pending_work?(organization_id) + scope = PaymentMethod.unscoped + .where("details->>'from_migration' = 'true'") + .where("details->>'last4' IS NULL") + .where("deleted_at IS NULL") + .where( + "EXISTS ( + SELECT 1 FROM payments p + WHERE p.payment_provider_customer_id = payment_methods.payment_provider_customer_id + AND p.provider_payment_method_id = payment_methods.provider_method_id + AND p.provider_payment_method_data->>'last4' IS NOT NULL + )" + ) + scope = scope.where(organization_id:) if organization_id + scope.exists? + end + end +end diff --git a/app/jobs/database_migrations/backfill_stripe_payment_methods_job.rb b/app/jobs/database_migrations/backfill_stripe_payment_methods_job.rb new file mode 100644 index 0000000..2179cb9 --- /dev/null +++ b/app/jobs/database_migrations/backfill_stripe_payment_methods_job.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class BackfillStripePaymentMethodsJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1_000 + + def perform(organization_id = nil, batch_number = 1) + unless pending_work?(organization_id) + Rails.logger.info("Finished backfilling payment methods") + DatabaseMigrations::BackfillStripePaymentMethodCardDetailsJob.perform_later(organization_id) + return + end + + org_filter = organization_id ? "AND ppc.organization_id = '#{organization_id}'" : "" + + result = ActiveRecord::Base.connection.execute(<<~SQL.squish) + WITH batch AS ( + SELECT + ppc.id AS ppc_id, + ppc.organization_id, + ppc.customer_id, + ppc.payment_provider_id, + ppc.settings->>'payment_method_id' AS provider_method_id, + COALESCE(ppc.settings->'provider_payment_methods'->>0, 'card') AS provider_method_type + FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::StripeCustomer' + AND ppc.settings->>'payment_method_id' IS NOT NULL + AND ppc.deleted_at IS NULL + #{org_filter} + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'payment_method_id' + ) + LIMIT #{BATCH_SIZE} + ) + INSERT INTO payment_methods ( + id, + organization_id, + customer_id, + payment_provider_id, + payment_provider_customer_id, + provider_method_id, + provider_method_type, + is_default, + details, + created_at, + updated_at + ) + SELECT + gen_random_uuid(), + batch.organization_id, + batch.customer_id, + batch.payment_provider_id, + batch.ppc_id, + batch.provider_method_id, + batch.provider_method_type, + true, + jsonb_build_object('from_migration', true), + NOW(), + NOW() + FROM batch + SQL + + self.class.perform_later(organization_id, batch_number + 1) if result.cmd_tuples.positive? + end + + def lock_key_arguments + [arguments] + end + + private + + def pending_work?(organization_id) + scope = PaymentProviderCustomers::StripeCustomer + .where("settings->>'payment_method_id' IS NOT NULL") + .where( + "NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = payment_provider_customers.id + AND pm.provider_method_id = payment_provider_customers.settings->>'payment_method_id' + )" + ) + scope = scope.where(organization_id:) if organization_id + scope.exists? + end + end +end diff --git a/app/jobs/database_migrations/fix_invoices_organization_sequential_id_job.rb b/app/jobs/database_migrations/fix_invoices_organization_sequential_id_job.rb new file mode 100644 index 0000000..8f5c2c8 --- /dev/null +++ b/app/jobs/database_migrations/fix_invoices_organization_sequential_id_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class FixInvoicesOrganizationSequentialIdJob < ApplicationJob + queue_as :default + + def perform + Organization.per_organization.find_each do |organization| + last_organization_sequential_id = organization.invoices.maximum(:organization_sequential_id) || 0 + invoices_count = organization.invoices.non_self_billed.with_generated_number.count + + next if last_organization_sequential_id == invoices_count + + last_invoice = organization.invoices.non_self_billed.with_generated_number.order(created_at: :desc).limit(1) + last_invoice.update_all(organization_sequential_id: invoices_count) # rubocop:disable Rails/SkipsModelValidations + end + end + end +end diff --git a/app/jobs/database_migrations/populate_add_ons_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_add_ons_taxes_with_organization_job.rb new file mode 100644 index 0000000..b9e2ba4 --- /dev/null +++ b/app/jobs/database_migrations/populate_add_ons_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateAddOnsTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = AddOn::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM add_ons WHERE add_ons.id = add_ons_taxes.add_on_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_adjusted_fees_with_organization_job.rb b/app/jobs/database_migrations/populate_adjusted_fees_with_organization_job.rb new file mode 100644 index 0000000..2ef5036 --- /dev/null +++ b/app/jobs/database_migrations/populate_adjusted_fees_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateAdjustedFeesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = AdjustedFee.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = adjusted_fees.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_applied_coupons_with_organization_job.rb b/app/jobs/database_migrations/populate_applied_coupons_with_organization_job.rb new file mode 100644 index 0000000..768ba76 --- /dev/null +++ b/app/jobs/database_migrations/populate_applied_coupons_with_organization_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateAppliedCouponsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = AppliedCoupon.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM customers WHERE customers.id = applied_coupons.customer_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + private + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_applied_invoice_custom_sections_with_organization_job.rb b/app/jobs/database_migrations/populate_applied_invoice_custom_sections_with_organization_job.rb new file mode 100644 index 0000000..f819b20 --- /dev/null +++ b/app/jobs/database_migrations/populate_applied_invoice_custom_sections_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateAppliedInvoiceCustomSectionsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = AppliedInvoiceCustomSection.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = applied_invoice_custom_sections.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_applied_usage_thresholds_with_organization_job.rb b/app/jobs/database_migrations/populate_applied_usage_thresholds_with_organization_job.rb new file mode 100644 index 0000000..5ceba0d --- /dev/null +++ b/app/jobs/database_migrations/populate_applied_usage_thresholds_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateAppliedUsageThresholdsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = AppliedUsageThreshold.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = applied_usage_thresholds.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_billable_metric_filters_with_organization_job.rb b/app/jobs/database_migrations/populate_billable_metric_filters_with_organization_job.rb new file mode 100644 index 0000000..4597a7c --- /dev/null +++ b/app/jobs/database_migrations/populate_billable_metric_filters_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateBillableMetricFiltersWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = BillableMetricFilter.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM billable_metrics WHERE billable_metrics.id = billable_metric_filters.billable_metric_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_billing_entities_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_billing_entities_taxes_with_organization_job.rb new file mode 100644 index 0000000..3ed1c36 --- /dev/null +++ b/app/jobs/database_migrations/populate_billing_entities_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateBillingEntitiesTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = BillingEntity::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM taxes WHERE taxes.id = billing_entities_taxes.tax_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_charge_filter_values_with_organization_job.rb b/app/jobs/database_migrations/populate_charge_filter_values_with_organization_job.rb new file mode 100644 index 0000000..0733475 --- /dev/null +++ b/app/jobs/database_migrations/populate_charge_filter_values_with_organization_job.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateChargeFilterValuesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = ChargeFilterValue.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + sql = <<-SQL + organization_id = ( + SELECT billable_metrics.organization_id + FROM billable_metric_filters + INNER JOIN billable_metrics ON billable_metric_filters.billable_metric_id = billable_metrics.id + WHERE billable_metric_filters.id = charge_filter_values.billable_metric_filter_id + ) + SQL + + batch.update_all(sql) # rubocop:disable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_charge_filters_with_organization_job.rb b/app/jobs/database_migrations/populate_charge_filters_with_organization_job.rb new file mode 100644 index 0000000..1615e69 --- /dev/null +++ b/app/jobs/database_migrations/populate_charge_filters_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateChargeFiltersWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = ChargeFilter.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM charges WHERE charges.id = charge_filters.charge_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_charges_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_charges_taxes_with_organization_job.rb new file mode 100644 index 0000000..74ad3c9 --- /dev/null +++ b/app/jobs/database_migrations/populate_charges_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateChargesTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Charge::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM charges WHERE charges.id = charges_taxes.charge_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_charges_with_organization_job.rb b/app/jobs/database_migrations/populate_charges_with_organization_job.rb new file mode 100644 index 0000000..7a102f1 --- /dev/null +++ b/app/jobs/database_migrations/populate_charges_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateChargesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Charge.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM plans WHERE plans.id = charges.plan_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_commitments_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_commitments_taxes_with_organization_job.rb new file mode 100644 index 0000000..9f61f4d --- /dev/null +++ b/app/jobs/database_migrations/populate_commitments_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCommitmentsTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Commitment::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM taxes WHERE taxes.id = commitments_taxes.tax_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_commitments_with_organization_job.rb b/app/jobs/database_migrations/populate_commitments_with_organization_job.rb new file mode 100644 index 0000000..099ffdd --- /dev/null +++ b/app/jobs/database_migrations/populate_commitments_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCommitmentsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Commitment.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM plans WHERE plans.id = commitments.plan_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_coupon_targets_with_organization_job.rb b/app/jobs/database_migrations/populate_coupon_targets_with_organization_job.rb new file mode 100644 index 0000000..f57969b --- /dev/null +++ b/app/jobs/database_migrations/populate_coupon_targets_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCouponTargetsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = CouponTarget.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM coupons WHERE coupons.id = coupon_targets.coupon_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_credit_note_items_with_organization_job.rb b/app/jobs/database_migrations/populate_credit_note_items_with_organization_job.rb new file mode 100644 index 0000000..6277335 --- /dev/null +++ b/app/jobs/database_migrations/populate_credit_note_items_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCreditNoteItemsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = CreditNoteItem.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM fees WHERE fees.id = credit_note_items.fee_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_credit_notes_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_credit_notes_taxes_with_organization_job.rb new file mode 100644 index 0000000..dcd7c70 --- /dev/null +++ b/app/jobs/database_migrations/populate_credit_notes_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCreditNotesTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = CreditNote::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM credit_notes WHERE credit_notes.id = credit_notes_taxes.credit_note_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_credit_notes_with_organization_job.rb b/app/jobs/database_migrations/populate_credit_notes_with_organization_job.rb new file mode 100644 index 0000000..9bbe46a --- /dev/null +++ b/app/jobs/database_migrations/populate_credit_notes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCreditNotesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = CreditNote.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = credit_notes.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_credits_with_organization_job.rb b/app/jobs/database_migrations/populate_credits_with_organization_job.rb new file mode 100644 index 0000000..25a6c15 --- /dev/null +++ b/app/jobs/database_migrations/populate_credits_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCreditsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Credit.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = credits.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_customer_metadata_with_organization_job.rb b/app/jobs/database_migrations/populate_customer_metadata_with_organization_job.rb new file mode 100644 index 0000000..79d1331 --- /dev/null +++ b/app/jobs/database_migrations/populate_customer_metadata_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCustomerMetadataWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Metadata::CustomerMetadata.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM customers WHERE customers.id = customer_metadata.customer_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_customers_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_customers_taxes_with_organization_job.rb new file mode 100644 index 0000000..c080812 --- /dev/null +++ b/app/jobs/database_migrations/populate_customers_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateCustomersTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Customer::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM taxes WHERE taxes.id = customers_taxes.tax_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_data_export_parts_with_organization_job.rb b/app/jobs/database_migrations/populate_data_export_parts_with_organization_job.rb new file mode 100644 index 0000000..11c637c --- /dev/null +++ b/app/jobs/database_migrations/populate_data_export_parts_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateDataExportPartsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = DataExportPart.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM data_exports WHERE data_exports.id = data_export_parts.data_export_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_dunning_campaign_thresholds_with_organization_job.rb b/app/jobs/database_migrations/populate_dunning_campaign_thresholds_with_organization_job.rb new file mode 100644 index 0000000..12b9711 --- /dev/null +++ b/app/jobs/database_migrations/populate_dunning_campaign_thresholds_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateDunningCampaignThresholdsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = DunningCampaignThreshold.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM dunning_campaigns WHERE dunning_campaigns.id = dunning_campaign_thresholds.dunning_campaign_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_fees_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_fees_taxes_with_organization_job.rb new file mode 100644 index 0000000..59d3f8a --- /dev/null +++ b/app/jobs/database_migrations/populate_fees_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateFeesTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Fee::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM fees WHERE fees.id = fees_taxes.fee_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_fees_with_billing_entity_id_job.rb b/app/jobs/database_migrations/populate_fees_with_billing_entity_id_job.rb new file mode 100644 index 0000000..7109a8d --- /dev/null +++ b/app/jobs/database_migrations/populate_fees_with_billing_entity_id_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateFeesWithBillingEntityIdJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Fee.unscoped.where(billing_entity_id: nil).limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("billing_entity_id = organization_id") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_fees_with_organization_from_invoice_job.rb b/app/jobs/database_migrations/populate_fees_with_organization_from_invoice_job.rb new file mode 100644 index 0000000..382f9ca --- /dev/null +++ b/app/jobs/database_migrations/populate_fees_with_organization_from_invoice_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateFeesWithOrganizationFromInvoiceJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Fee.unscoped.where(organization_id: nil).where.not(invoice_id: nil) + .joins(:invoice).limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all( + "organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = fees.invoice_id), + billing_entity_id = (SELECT organization_id FROM invoices WHERE invoices.id = fees.invoice_id)" + ) + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_fees_with_organization_from_subscription_job.rb b/app/jobs/database_migrations/populate_fees_with_organization_from_subscription_job.rb new file mode 100644 index 0000000..8096b6e --- /dev/null +++ b/app/jobs/database_migrations/populate_fees_with_organization_from_subscription_job.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateFeesWithOrganizationFromSubscriptionJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Fee.unscoped.where(organization_id: nil).where.not(subscription_id: nil) + .joins(subscription: :customer).limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all( + "organization_id = (SELECT customers.organization_id FROM subscriptions + JOIN customers ON customers.id = subscriptions.customer_id + WHERE subscriptions.id = fees.subscription_id), + billing_entity_id = (SELECT customers.organization_id FROM subscriptions + JOIN customers ON customers.id = subscriptions.customer_id + WHERE subscriptions.id = fees.subscription_id)" + ) + # rubocop:enable Rails/SkipsModelValidations + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_idempotency_records_with_organization_job.rb b/app/jobs/database_migrations/populate_idempotency_records_with_organization_job.rb new file mode 100644 index 0000000..4655fce --- /dev/null +++ b/app/jobs/database_migrations/populate_idempotency_records_with_organization_job.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateIdempotencyRecordsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = IdempotencyRecord.unscoped + .where(organization_id: nil) + .where(resource_type: "Invoice") + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = idempotency_records.resource_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_integration_collection_mappings_with_organization_job.rb b/app/jobs/database_migrations/populate_integration_collection_mappings_with_organization_job.rb new file mode 100644 index 0000000..74e8a36 --- /dev/null +++ b/app/jobs/database_migrations/populate_integration_collection_mappings_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateIntegrationCollectionMappingsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = IntegrationCollectionMappings::BaseCollectionMapping.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM integrations WHERE integrations.id = integration_collection_mappings.integration_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_integration_customers_with_organization_job.rb b/app/jobs/database_migrations/populate_integration_customers_with_organization_job.rb new file mode 100644 index 0000000..02d63a7 --- /dev/null +++ b/app/jobs/database_migrations/populate_integration_customers_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateIntegrationCustomersWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = IntegrationCustomers::BaseCustomer.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM customers WHERE customers.id = integration_customers.customer_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_integration_items_with_organization_job.rb b/app/jobs/database_migrations/populate_integration_items_with_organization_job.rb new file mode 100644 index 0000000..25b5e63 --- /dev/null +++ b/app/jobs/database_migrations/populate_integration_items_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateIntegrationItemsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = IntegrationItem.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM integrations WHERE integrations.id = integration_items.integration_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_integration_mappings_with_organization_job.rb b/app/jobs/database_migrations/populate_integration_mappings_with_organization_job.rb new file mode 100644 index 0000000..bbdd82a --- /dev/null +++ b/app/jobs/database_migrations/populate_integration_mappings_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateIntegrationMappingsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = IntegrationMappings::BaseMapping.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM integrations WHERE integrations.id = integration_mappings.integration_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_integration_resources_with_organization_job.rb b/app/jobs/database_migrations/populate_integration_resources_with_organization_job.rb new file mode 100644 index 0000000..ed75fd6 --- /dev/null +++ b/app/jobs/database_migrations/populate_integration_resources_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateIntegrationResourcesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = IntegrationResource.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM integrations WHERE integrations.id = integration_resources.integration_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_invoice_metadata_with_organization_job.rb b/app/jobs/database_migrations/populate_invoice_metadata_with_organization_job.rb new file mode 100644 index 0000000..0b0f659 --- /dev/null +++ b/app/jobs/database_migrations/populate_invoice_metadata_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateInvoiceMetadataWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Metadata::InvoiceMetadata.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = invoice_metadata.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_invoice_subscriptions_with_organization_job.rb b/app/jobs/database_migrations/populate_invoice_subscriptions_with_organization_job.rb new file mode 100644 index 0000000..31beb85 --- /dev/null +++ b/app/jobs/database_migrations/populate_invoice_subscriptions_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateInvoiceSubscriptionsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = InvoiceSubscription.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = invoice_subscriptions.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_invoices_billing_entity_sequential_id_job.rb b/app/jobs/database_migrations/populate_invoices_billing_entity_sequential_id_job.rb new file mode 100644 index 0000000..ca08205 --- /dev/null +++ b/app/jobs/database_migrations/populate_invoices_billing_entity_sequential_id_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateInvoicesBillingEntitySequentialIdJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Invoice + .where("organization_sequential_id != 0 AND billing_entity_sequential_id IS NULL") + .or(Invoice.where("organization_sequential_id != 0 AND billing_entity_sequential_id != organization_sequential_id")) + .order(:organization_id, :organization_sequential_id) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("billing_entity_sequential_id = organization_sequential_id") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_invoices_payment_requests_with_organization_job.rb b/app/jobs/database_migrations/populate_invoices_payment_requests_with_organization_job.rb new file mode 100644 index 0000000..d47cabb --- /dev/null +++ b/app/jobs/database_migrations/populate_invoices_payment_requests_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateInvoicesPaymentRequestsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = PaymentRequest::AppliedInvoice.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = invoices_payment_requests.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_invoices_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_invoices_taxes_with_organization_job.rb new file mode 100644 index 0000000..c563248 --- /dev/null +++ b/app/jobs/database_migrations/populate_invoices_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateInvoicesTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Invoice::AppliedTax.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = invoices_taxes.invoice_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_payment_provider_customers_with_organization_job.rb b/app/jobs/database_migrations/populate_payment_provider_customers_with_organization_job.rb new file mode 100644 index 0000000..270bb72 --- /dev/null +++ b/app/jobs/database_migrations/populate_payment_provider_customers_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulatePaymentProviderCustomersWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = PaymentProviderCustomers::BaseCustomer.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM customers WHERE customers.id = payment_provider_customers.customer_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_payments_with_customer_id.rb b/app/jobs/database_migrations/populate_payments_with_customer_id.rb new file mode 100644 index 0000000..531a7d9 --- /dev/null +++ b/app/jobs/database_migrations/populate_payments_with_customer_id.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulatePaymentsWithCustomerId < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + # rubocop:disable Rails/SkipsModelValidations + Payment.where(payable_type: "Invoice", customer_id: nil) + .joins("LEFT JOIN invoices ON invoices.id = payments.payable_id AND payments.payable_type = 'Invoice'") + .where("payments.customer_id IS NULL AND invoices.customer_id IS NOT NULL") + .limit(BATCH_SIZE) + .update_all("customer_id = (SELECT customer_id FROM invoices WHERE invoices.id = payments.payable_id)") + + Payment.where(payable_type: "PaymentRequest", customer_id: nil) + .joins("LEFT JOIN payment_requests ON payment_requests.id = payments.payable_id AND payments.payable_type = 'PaymentRequest'") + .where("payments.customer_id IS NULL AND payment_requests.customer_id IS NOT NULL") + .limit(BATCH_SIZE) + .update_all("customer_id = (SELECT customer_id FROM payment_requests WHERE payment_requests.id = payments.payable_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) if Payment.joins("LEFT JOIN invoices ON invoices.id = payments.payable_id AND payments.payable_type = 'Invoice'") + .joins("LEFT JOIN payment_requests ON payment_requests.id = payments.payable_id AND payments.payable_type = 'PaymentRequest'") + .where("payments.customer_id IS NULL AND (invoices.customer_id IS NOT NULL OR payment_requests.customer_id IS NOT NULL)") + .exists? + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_payments_with_organization_from_invoice_job.rb b/app/jobs/database_migrations/populate_payments_with_organization_from_invoice_job.rb new file mode 100644 index 0000000..7fd462b --- /dev/null +++ b/app/jobs/database_migrations/populate_payments_with_organization_from_invoice_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulatePaymentsWithOrganizationFromInvoiceJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Payment.unscoped + .where(organization_id: nil).where(payable_type: "Invoice") + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM invoices WHERE invoices.id = payments.payable_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_payments_with_organization_from_payment_request_job.rb b/app/jobs/database_migrations/populate_payments_with_organization_from_payment_request_job.rb new file mode 100644 index 0000000..fe16426 --- /dev/null +++ b/app/jobs/database_migrations/populate_payments_with_organization_from_payment_request_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulatePaymentsWithOrganizationFromPaymentRequestJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Payment.unscoped + .where(organization_id: nil).where(payable_type: "PaymentRequest") + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM payment_requests WHERE payment_requests.id = payments.payable_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_plans_taxes_with_organization_job.rb b/app/jobs/database_migrations/populate_plans_taxes_with_organization_job.rb new file mode 100644 index 0000000..7584706 --- /dev/null +++ b/app/jobs/database_migrations/populate_plans_taxes_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulatePlansTaxesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Plan::AppliedTax + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM plans WHERE plans.id = plans_taxes.plan_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_recurring_transaction_rules_with_organization_job.rb b/app/jobs/database_migrations/populate_recurring_transaction_rules_with_organization_job.rb new file mode 100644 index 0000000..7d525e2 --- /dev/null +++ b/app/jobs/database_migrations/populate_recurring_transaction_rules_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateRecurringTransactionRulesWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = RecurringTransactionRule.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM wallets WHERE wallets.id = recurring_transaction_rules.wallet_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_refunds_with_organization_job.rb b/app/jobs/database_migrations/populate_refunds_with_organization_job.rb new file mode 100644 index 0000000..9e3a5ba --- /dev/null +++ b/app/jobs/database_migrations/populate_refunds_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateRefundsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Refund.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM credit_notes WHERE credit_notes.id = refunds.credit_note_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_subscriptions_with_organization_job.rb b/app/jobs/database_migrations/populate_subscriptions_with_organization_job.rb new file mode 100644 index 0000000..93a91cb --- /dev/null +++ b/app/jobs/database_migrations/populate_subscriptions_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateSubscriptionsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Subscription + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM customers WHERE customers.id = subscriptions.customer_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_usage_thresholds_with_organization_job.rb b/app/jobs/database_migrations/populate_usage_thresholds_with_organization_job.rb new file mode 100644 index 0000000..ced011f --- /dev/null +++ b/app/jobs/database_migrations/populate_usage_thresholds_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateUsageThresholdsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = UsageThreshold.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM plans WHERE plans.id = usage_thresholds.plan_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_wallet_transactions_with_organization_job.rb b/app/jobs/database_migrations/populate_wallet_transactions_with_organization_job.rb new file mode 100644 index 0000000..47487d3 --- /dev/null +++ b/app/jobs/database_migrations/populate_wallet_transactions_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateWalletTransactionsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = WalletTransaction.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM wallets WHERE wallets.id = wallet_transactions.wallet_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_wallets_with_code_job.rb b/app/jobs/database_migrations/populate_wallets_with_code_job.rb new file mode 100644 index 0000000..da12474 --- /dev/null +++ b/app/jobs/database_migrations/populate_wallets_with_code_job.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateWalletsWithCodeJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + # Number of distinct customers to process per batch + BATCH_SIZE = 1_000 + + def perform(batch_number = 1) + # Check if there are any wallets without code + return Rails.logger.info("Finished populating wallet codes") unless Wallet.unscoped.where(code: nil).exists? + + # Process in batches by customer_id to maintain uniqueness logic + # (all wallets for a customer must be processed together) + result = ActiveRecord::Base.connection.execute(<<-SQL.squish) + WITH customer_batch AS ( + SELECT DISTINCT customer_id + FROM wallets + WHERE code IS NULL + LIMIT #{BATCH_SIZE} + ), + base_codes AS ( + SELECT + w.id, + w.customer_id, + w.created_at, + CASE + WHEN w.name IS NULL OR TRIM(w.name) = '' THEN 'default' + ELSE LOWER(REGEXP_REPLACE(REGEXP_REPLACE(TRIM(w.name), '[^a-zA-Z0-9]+', '_', 'g'), '^_|_$', '', 'g')) + END as base_code + FROM wallets w + INNER JOIN customer_batch cb ON cb.customer_id = w.customer_id + WHERE w.code IS NULL + ), + ranked_codes AS ( + SELECT + id, + customer_id, + created_at, + base_code, + ROW_NUMBER() OVER (PARTITION BY customer_id, base_code ORDER BY created_at) as rn + FROM base_codes + ) + UPDATE wallets + SET code = CASE + WHEN ranked_codes.rn = 1 THEN ranked_codes.base_code + ELSE ranked_codes.base_code || '_' || EXTRACT(EPOCH FROM ranked_codes.created_at)::bigint::text + END + FROM ranked_codes + WHERE wallets.id = ranked_codes.id + SQL + + # Queue the next batch if there were updates + if result.cmd_tuples.positive? + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished populating wallet codes") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_wallets_with_organization_job.rb b/app/jobs/database_migrations/populate_wallets_with_organization_job.rb new file mode 100644 index 0000000..ba9f76d --- /dev/null +++ b/app/jobs/database_migrations/populate_wallets_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateWalletsWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Wallet.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM customers WHERE customers.id = wallets.customer_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/database_migrations/populate_webhooks_with_organization_job.rb b/app/jobs/database_migrations/populate_webhooks_with_organization_job.rb new file mode 100644 index 0000000..df7e716 --- /dev/null +++ b/app/jobs/database_migrations/populate_webhooks_with_organization_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class PopulateWebhooksWithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = Webhook.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM webhook_endpoints WHERE webhook_endpoints.id = webhooks.webhook_endpoint_id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/app/jobs/dunning_campaigns/bulk_process_job.rb b/app/jobs/dunning_campaigns/bulk_process_job.rb new file mode 100644 index 0000000..eef2861 --- /dev/null +++ b/app/jobs/dunning_campaigns/bulk_process_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module DunningCampaigns + class BulkProcessJob < ApplicationJob + queue_as :default + + def perform + return unless License.premium? + + DunningCampaigns::BulkProcessService.call.raise_if_error! + end + end +end diff --git a/app/jobs/dunning_campaigns/process_attempt_job.rb b/app/jobs/dunning_campaigns/process_attempt_job.rb new file mode 100644 index 0000000..64fb3aa --- /dev/null +++ b/app/jobs/dunning_campaigns/process_attempt_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module DunningCampaigns + class ProcessAttemptJob < ApplicationJob + queue_as :default + + def perform(customer:, dunning_campaign_threshold:) + DunningCampaigns::ProcessAttemptService + .call(customer:, dunning_campaign_threshold:) + .raise_if_error! + end + end +end diff --git a/app/jobs/events/create_batch_job.rb b/app/jobs/events/create_batch_job.rb new file mode 100644 index 0000000..2e59274 --- /dev/null +++ b/app/jobs/events/create_batch_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Events + # DEPRECATED + class CreateBatchJob < ApplicationJob + queue_as :default + + def perform(organization, params, timestamp, metadata) + result = Events::CreateBatchService.new.call( + organization:, + params:, + timestamp: Time.zone.at(timestamp.to_f), + metadata: + ) + + result.raise_if_error! + end + end +end diff --git a/app/jobs/events/pay_in_advance_job.rb b/app/jobs/events/pay_in_advance_job.rb new file mode 100644 index 0000000..4841152 --- /dev/null +++ b/app/jobs/events/pay_in_advance_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Events + class PayInAdvanceJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_EVENTS"]) + :events + else + :default + end + end + + unique :until_executed, on_conflict: :log + + def perform(event) + Events::PayInAdvanceService.call!(event:) + end + + def lock_key_arguments + event = Events::CommonFactory.new_instance(source: arguments.first) + [event.organization_id, event.external_subscription_id, event.transaction_id] + end + end +end diff --git a/app/jobs/events/post_process_job.rb b/app/jobs/events/post_process_job.rb new file mode 100644 index 0000000..d4d5e60 --- /dev/null +++ b/app/jobs/events/post_process_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Events + class PostProcessJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_EVENTS"]) + :events + else + :default + end + end + + def perform(event:) + Events::PostProcessService.call(event:).raise_if_error! + end + end +end diff --git a/app/jobs/events/post_validation_job.rb b/app/jobs/events/post_validation_job.rb new file mode 100644 index 0000000..41248d6 --- /dev/null +++ b/app/jobs/events/post_validation_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Events + class PostValidationJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_EVENTS"]) + :events + else + :default + end + end + + def perform(organization:) + Events::PostValidationService.call(organization:) + end + end +end diff --git a/app/jobs/events/stores/clickhouse/enriched_store_migration/orchestrator_job.rb b/app/jobs/events/stores/clickhouse/enriched_store_migration/orchestrator_job.rb new file mode 100644 index 0000000..b341f16 --- /dev/null +++ b/app/jobs/events/stores/clickhouse/enriched_store_migration/orchestrator_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + module EnrichedStoreMigration + class OrchestratorJob < ApplicationJob + queue_as "low_priority" + + def perform(enriched_store_migration) + # TODO: Implement in step 4 — calls OrchestratorService + end + end + end + end + end +end diff --git a/app/jobs/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_job.rb b/app/jobs/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_job.rb new file mode 100644 index 0000000..162d5f5 --- /dev/null +++ b/app/jobs/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + module EnrichedStoreMigration + class SubscriptionOrchestratorJob < ApplicationJob + queue_as "low_priority" + + def perform(subscription_migration) + SubscriptionOrchestratorService.call!(subscription_migration:) + end + end + end + end + end +end diff --git a/app/jobs/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_job.rb b/app/jobs/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_job.rb new file mode 100644 index 0000000..d79d024 --- /dev/null +++ b/app/jobs/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + module EnrichedStoreMigration + class WaitForEnrichmentJob < ApplicationJob + queue_as "low_priority" + + BACKOFF_SCHEDULE = [5, 10].freeze + DEFAULT_BACKOFF = 15 + + def perform(subscription_migration, attempt = 1) + check_result = WaitForEnrichmentService.call!( + subscription_migration:, + attempt:, + max_attempts: WaitForEnrichmentService::MAX_ATTEMPTS + ) + + if check_result.status == :not_ready + wait_minutes = BACKOFF_SCHEDULE[attempt - 1] || DEFAULT_BACKOFF + self.class.set(wait: wait_minutes.minutes).perform_later(subscription_migration, attempt + 1) + end + end + end + end + end + end +end diff --git a/app/jobs/events/stores/clickhouse/pre_enrichment_check_job.rb b/app/jobs/events/stores/clickhouse/pre_enrichment_check_job.rb new file mode 100644 index 0000000..1797e6b --- /dev/null +++ b/app/jobs/events/stores/clickhouse/pre_enrichment_check_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + class PreEnrichmentCheckJob < ApplicationJob + queue_as "low_priority" + + def perform(subscription_id:, codes:, batch_size:, sleep_seconds:) + subscription = Subscription.find_by(id: subscription_id) + return unless subscription + + ReEnrichSubscriptionEventsService.call!( + subscription:, codes:, reprocess: true, batch_size:, sleep_seconds: + ) + end + end + end + end +end diff --git a/app/jobs/fees/create_pay_in_advance_job.rb b/app/jobs/fees/create_pay_in_advance_job.rb new file mode 100644 index 0000000..2292356 --- /dev/null +++ b/app/jobs/fees/create_pay_in_advance_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Fees + class CreatePayInAdvanceJob < ApplicationJob + queue_as :default + + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + unique :until_executed, on_conflict: :log + + def perform(charge:, event:, billing_at: nil) + result = Fees::CreatePayInAdvanceService.call(charge:, event:, billing_at:) + + return if !result.success? && tax_error?(result) + + result.raise_if_error! + end + + def lock_key_arguments + args = arguments.first + event = Events::CommonFactory.new_instance(source: args[:event]) + [args[:charge], event.organization_id, event.external_subscription_id, event.transaction_id] + end + + private + + def tax_error?(result) + return false unless result.error.is_a?(BaseService::ValidationFailure) + + result.error&.messages&.dig(:tax_error).present? + end + end +end diff --git a/app/jobs/fixed_charges/cascade_child_plan_update_job.rb b/app/jobs/fixed_charges/cascade_child_plan_update_job.rb new file mode 100644 index 0000000..4f1fda8 --- /dev/null +++ b/app/jobs/fixed_charges/cascade_child_plan_update_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module FixedCharges + class CascadeChildPlanUpdateJob < ApplicationJob + queue_as :default + + def perform(plan:, cascade_fixed_charges_payload:, timestamp:) + FixedCharges::CascadeChildPlanUpdateService.call!( + plan:, + cascade_fixed_charges_payload:, + timestamp: + ) + end + end +end diff --git a/app/jobs/fixed_charges/cascade_plan_update_job.rb b/app/jobs/fixed_charges/cascade_plan_update_job.rb new file mode 100644 index 0000000..a69933e --- /dev/null +++ b/app/jobs/fixed_charges/cascade_plan_update_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module FixedCharges + class CascadePlanUpdateJob < ApplicationJob + queue_as :default + + def perform(plan:, cascade_fixed_charges_payload:, timestamp:) + plan.children.joins(:subscriptions) + .where(subscriptions: {status: %w[active pending]}).distinct + .find_in_batches do |child_plans| + jobs = child_plans.map do |child_plan| + FixedCharges::CascadeChildPlanUpdateJob.new( + plan: child_plan, + cascade_fixed_charges_payload:, + timestamp: + ) + end + + ActiveJob.perform_all_later(jobs) + end + end + end +end diff --git a/app/jobs/fixed_charges/create_children_batch_job.rb b/app/jobs/fixed_charges/create_children_batch_job.rb new file mode 100644 index 0000000..936b3c8 --- /dev/null +++ b/app/jobs/fixed_charges/create_children_batch_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module FixedCharges + class CreateChildrenBatchJob < ApplicationJob + queue_as "default" + + def perform(child_ids:, fixed_charge:, payload:) + FixedCharges::CreateChildrenService.call!(child_ids:, fixed_charge:, payload:) + end + end +end diff --git a/app/jobs/fixed_charges/create_children_job.rb b/app/jobs/fixed_charges/create_children_job.rb new file mode 100644 index 0000000..33eb3d4 --- /dev/null +++ b/app/jobs/fixed_charges/create_children_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module FixedCharges + class CreateChildrenJob < ApplicationJob + queue_as "default" + + def perform(fixed_charge:, payload:) + plan = fixed_charge.plan + return unless plan&.children&.any? + + plan.children.joins(:subscriptions).where(subscriptions: {status: %w[active pending]}).distinct.pluck(:id).each_slice(20) do |child_ids| + FixedCharges::CreateChildrenBatchJob.perform_later( + child_ids:, + fixed_charge:, + payload: + ) + end + end + end +end diff --git a/app/jobs/fixed_charges/destroy_children_job.rb b/app/jobs/fixed_charges/destroy_children_job.rb new file mode 100644 index 0000000..a8ce966 --- /dev/null +++ b/app/jobs/fixed_charges/destroy_children_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module FixedCharges + class DestroyChildrenJob < ApplicationJob + queue_as :default + + def perform(fixed_charge_id) + fixed_charge = FixedCharge.with_discarded.find_by(id: fixed_charge_id) + FixedCharges::DestroyChildrenService.call!(fixed_charge) + end + end +end diff --git a/app/jobs/fixed_charges/update_children_batch_job.rb b/app/jobs/fixed_charges/update_children_batch_job.rb new file mode 100644 index 0000000..6b29559 --- /dev/null +++ b/app/jobs/fixed_charges/update_children_batch_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module FixedCharges + class UpdateChildrenBatchJob < ApplicationJob + queue_as :low_priority + + def perform(child_ids:, params:, old_parent_attrs:) + Rails.logger.info("FixedCharges::UpdateChildrenBatchJob - Started the execution for parent fixed charge with ID: #{old_parent_attrs["id"]}") + + fixed_charge = FixedCharge.find_by(id: old_parent_attrs["id"]) + + FixedCharges::UpdateChildrenService.call!( + fixed_charge:, + params:, + old_parent_attrs:, + child_ids: + ) + + Rails.logger.info("FixedCharges::UpdateChildrenBatchJob - Ended the execution for parent fixed charge with ID: #{old_parent_attrs["id"]}") + end + end +end diff --git a/app/jobs/fixed_charges/update_children_job.rb b/app/jobs/fixed_charges/update_children_job.rb new file mode 100644 index 0000000..26d776a --- /dev/null +++ b/app/jobs/fixed_charges/update_children_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module FixedCharges + class UpdateChildrenJob < ApplicationJob + queue_as :default + + def perform(params:, old_parent_attrs:) + fixed_charge = FixedCharge.find_by(id: old_parent_attrs["id"]) + return unless fixed_charge + + fixed_charge.children.joins(plan: :subscriptions).where(subscriptions: {status: %w[active pending]}).distinct.pluck(:id).each_slice(20) do |child_ids| + FixedCharges::UpdateChildrenBatchJob.perform_later( + child_ids:, + params:, + old_parent_attrs: + ) + end + end + end +end diff --git a/app/jobs/inbound_webhooks/process_job.rb b/app/jobs/inbound_webhooks/process_job.rb new file mode 100644 index 0000000..c631c8d --- /dev/null +++ b/app/jobs/inbound_webhooks/process_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module InboundWebhooks + class ProcessJob < ApplicationJob + queue_as :default + + def perform(inbound_webhook:) + InboundWebhooks::ProcessService.call!(inbound_webhook:) + end + end +end diff --git a/app/jobs/integration_customers/create_job.rb b/app/jobs/integration_customers/create_job.rb new file mode 100644 index 0000000..8e9867b --- /dev/null +++ b/app/jobs/integration_customers/create_job.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class CreateJob < ApplicationJob + include ConcurrencyThrottlable + + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + # It may happen that a customer was updated in a short time after it was created. As creating integration customers is a + # long operation, by the time we receive the update we still haven't created the integration customer. Therefore we will + # schedule a second `IntegrationCustomers::CreateJob`. This second job should be ignored if the first one is still + # running. + # + # The lock key only includes integration and customer to prevent duplicate creation in external providers (e.g. NetSuite) + # when a create and update happen in quick succession with slightly different params. + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + def perform(integration_customer_params:, integration:, customer:) + IntegrationCustomers::CreateService.call!(params: integration_customer_params, integration:, customer:) + end + + def lock_key_arguments + [arguments.first[:integration], arguments.first[:customer]] + end + end +end diff --git a/app/jobs/integration_customers/update_job.rb b/app/jobs/integration_customers/update_job.rb new file mode 100644 index 0000000..1ad05b7 --- /dev/null +++ b/app/jobs/integration_customers/update_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class UpdateJob < ApplicationJob + include ConcurrencyThrottlable + + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration_customer_params:, integration:, integration_customer:) + result = IntegrationCustomers::UpdateService.call( + params: integration_customer_params, + integration:, + integration_customer: + ) + result.raise_if_error! + end + end +end diff --git a/app/jobs/integrations/aggregator/credit_notes/create_job.rb b/app/jobs/integrations/aggregator/credit_notes/create_job.rb new file mode 100644 index 0000000..fb81f7c --- /dev/null +++ b/app/jobs/integrations/aggregator/credit_notes/create_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + class CreateJob < ApplicationJob + include ConcurrencyThrottlable + + queue_as "integrations" + + unique :until_executed, on_conflict: :log + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(credit_note:) + result = Integrations::Aggregator::CreditNotes::CreateService.call(credit_note:) + result.raise_if_error! + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/fetch_items_job.rb b/app/jobs/integrations/aggregator/fetch_items_job.rb new file mode 100644 index 0000000..6e588bf --- /dev/null +++ b/app/jobs/integrations/aggregator/fetch_items_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class FetchItemsJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + + def perform(integration:) + result = Integrations::Aggregator::ItemsService.call(integration:) + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/integrations/aggregator/invoices/create_job.rb b/app/jobs/integrations/aggregator/invoices/create_job.rb new file mode 100644 index 0000000..e1db2d7 --- /dev/null +++ b/app/jobs/integrations/aggregator/invoices/create_job.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + class CreateJob < ApplicationJob + include ConcurrencyThrottlable + + # NOTE: NetSuite waits longer to avoid racing in-flight Nango calls; others use polynomial backoff. + # 6 minutes covers Nango's 5-minute upstream NetSuite action timeout with a safety margin. + DELAY_BY_PROVIDER_KEY = { + "netsuite" => 6.minutes + }.freeze + + queue_as "integrations" + + unique :until_executed, on_conflict: :log + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + discard_on BaseService::NonRetryableFailure + + # NOTE: `executions_for` and `determine_delay` are ActiveJob internals used by `retry_on`, + # not part of its public API. We reuse them so the per-exception execution counter and jitter + # behave identically to a normal `retry_on`. Revisit this block on Rails upgrades. + rescue_from(Net::ReadTimeout) do |error| + attempts = 6 + executions_count = executions_for([Net::ReadTimeout]) + + if executions_count >= attempts + instrument :retry_stopped, error: + raise + end + + wait_strategy = DELAY_BY_PROVIDER_KEY.fetch(integration_provider_key, :polynomially_longer) + + retry_job( + wait: determine_delay(seconds_or_duration_or_algorithm: wait_strategy, executions: executions_count), + error: error + ) + end + + def perform(invoice:) + result = Integrations::Aggregator::Invoices::CreateService.call(invoice:) + result.raise_if_error! + end + + private + + def integration_provider_key + invoice = arguments.first[:invoice] + invoice&.customer&.integration_customers&.accounting_kind&.first&.integration&.provider_key + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/invoices/hubspot/create_customer_association_job.rb b/app/jobs/integrations/aggregator/invoices/hubspot/create_customer_association_job.rb new file mode 100644 index 0000000..81d436d --- /dev/null +++ b/app/jobs/integrations/aggregator/invoices/hubspot/create_customer_association_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Hubspot + class CreateCustomerAssociationJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 10 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(invoice:) + result = Integrations::Aggregator::Invoices::Hubspot::CreateCustomerAssociationService.call(invoice:) + result.raise_if_error! + end + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/invoices/hubspot/create_job.rb b/app/jobs/integrations/aggregator/invoices/hubspot/create_job.rb new file mode 100644 index 0000000..c408583 --- /dev/null +++ b/app/jobs/integrations/aggregator/invoices/hubspot/create_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Hubspot + class CreateJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 10 + retry_on Integrations::Aggregator::BasePayload::Failure, wait: :polynomially_longer, attempts: 10 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(invoice:) + result = Integrations::Aggregator::Invoices::Hubspot::CreateService.call(invoice:) + + if result.success? + Integrations::Aggregator::Invoices::Hubspot::CreateCustomerAssociationJob.perform_later(invoice:) + end + + result.raise_if_error! + end + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/invoices/hubspot/update_job.rb b/app/jobs/integrations/aggregator/invoices/hubspot/update_job.rb new file mode 100644 index 0000000..2f86112 --- /dev/null +++ b/app/jobs/integrations/aggregator/invoices/hubspot/update_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Hubspot + class UpdateJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 10 + retry_on Integrations::Aggregator::BasePayload::Failure, wait: :polynomially_longer, attempts: 10 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(invoice:) + result = Integrations::Aggregator::Invoices::Hubspot::UpdateService.call(invoice:) + result.raise_if_error! + end + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/payments/create_job.rb b/app/jobs/integrations/aggregator/payments/create_job.rb new file mode 100644 index 0000000..aff8447 --- /dev/null +++ b/app/jobs/integrations/aggregator/payments/create_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Payments + class CreateJob < ApplicationJob + include ConcurrencyThrottlable + + queue_as "integrations" + + unique :until_executed, on_conflict: :log + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 5 + retry_on Integrations::Aggregator::BasePayload::Failure, wait: :polynomially_longer, attempts: 10 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 25 + + def perform(payment:) + result = Integrations::Aggregator::Payments::CreateService.call(payment:) + result.raise_if_error! + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/perform_sync_job.rb b/app/jobs/integrations/aggregator/perform_sync_job.rb new file mode 100644 index 0000000..d3578f5 --- /dev/null +++ b/app/jobs/integrations/aggregator/perform_sync_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class PerformSyncJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + + def perform(integration:, sync_items: true) + sync_result = Integrations::Aggregator::SyncService.call(integration:) + sync_result.raise_if_error! + + if sync_items + items_result = Integrations::Aggregator::ItemsService.call(integration:) + items_result.raise_if_error! + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/send_restlet_endpoint_job.rb b/app/jobs/integrations/aggregator/send_restlet_endpoint_job.rb new file mode 100644 index 0000000..f42baad --- /dev/null +++ b/app/jobs/integrations/aggregator/send_restlet_endpoint_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class SendRestletEndpointJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + + def perform(integration:) + result = Integrations::Aggregator::SendRestletEndpointService.call(integration:) + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/integrations/aggregator/subscriptions/hubspot/create_customer_association_job.rb b/app/jobs/integrations/aggregator/subscriptions/hubspot/create_customer_association_job.rb new file mode 100644 index 0000000..a68d52c --- /dev/null +++ b/app/jobs/integrations/aggregator/subscriptions/hubspot/create_customer_association_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Hubspot + class CreateCustomerAssociationJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 10 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(subscription:) + result = Integrations::Aggregator::Subscriptions::Hubspot::CreateCustomerAssociationService.call(subscription:) + result.raise_if_error! + end + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/subscriptions/hubspot/create_job.rb b/app/jobs/integrations/aggregator/subscriptions/hubspot/create_job.rb new file mode 100644 index 0000000..3d0a64e --- /dev/null +++ b/app/jobs/integrations/aggregator/subscriptions/hubspot/create_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Hubspot + class CreateJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 10 + retry_on Integrations::Aggregator::BasePayload::Failure, wait: :polynomially_longer, attempts: 10 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(subscription:) + result = Integrations::Aggregator::Subscriptions::Hubspot::CreateService.call(subscription:) + + if result.success? + Integrations::Aggregator::Subscriptions::Hubspot::CreateCustomerAssociationJob.perform_later(subscription:) + end + + result.raise_if_error! + end + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/subscriptions/hubspot/update_job.rb b/app/jobs/integrations/aggregator/subscriptions/hubspot/update_job.rb new file mode 100644 index 0000000..61f35a5 --- /dev/null +++ b/app/jobs/integrations/aggregator/subscriptions/hubspot/update_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Hubspot + class UpdateJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 10 + retry_on Integrations::Aggregator::BasePayload::Failure, wait: :polynomially_longer, attempts: 10 + retry_on RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(subscription:) + result = Integrations::Aggregator::Subscriptions::Hubspot::UpdateService.call(subscription:) + result.raise_if_error! + end + end + end + end + end +end diff --git a/app/jobs/integrations/aggregator/sync_custom_objects_and_properties_job.rb b/app/jobs/integrations/aggregator/sync_custom_objects_and_properties_job.rb new file mode 100644 index 0000000..dff9d7b --- /dev/null +++ b/app/jobs/integrations/aggregator/sync_custom_objects_and_properties_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class SyncCustomObjectsAndPropertiesJob < ApplicationJob + queue_as "integrations" + + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration:) + Integrations::Hubspot::Invoices::DeployObjectService.call(integration:) + Integrations::Hubspot::Subscriptions::DeployObjectService.call(integration:) + Integrations::Hubspot::Companies::DeployPropertiesService.call(integration:) + Integrations::Hubspot::Contacts::DeployPropertiesService.call(integration:) + end + end + end +end diff --git a/app/jobs/integrations/avalara/fetch_company_id_job.rb b/app/jobs/integrations/avalara/fetch_company_id_job.rb new file mode 100644 index 0000000..951abdf --- /dev/null +++ b/app/jobs/integrations/avalara/fetch_company_id_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Integrations + module Avalara + class FetchCompanyIdJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration:) + Integrations::Avalara::FetchCompanyIdService.call!(integration:) + end + end + end +end diff --git a/app/jobs/integrations/hubspot/companies/deploy_properties_job.rb b/app/jobs/integrations/hubspot/companies/deploy_properties_job.rb new file mode 100644 index 0000000..2727fa1 --- /dev/null +++ b/app/jobs/integrations/hubspot/companies/deploy_properties_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Companies + class DeployPropertiesJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on Integrations::Aggregator::RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration:) + result = Integrations::Hubspot::Companies::DeployPropertiesService.call(integration:) + result.raise_if_error! + end + end + end + end +end diff --git a/app/jobs/integrations/hubspot/contacts/deploy_properties_job.rb b/app/jobs/integrations/hubspot/contacts/deploy_properties_job.rb new file mode 100644 index 0000000..c920e5c --- /dev/null +++ b/app/jobs/integrations/hubspot/contacts/deploy_properties_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Contacts + class DeployPropertiesJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on Integrations::Aggregator::RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration:) + result = Integrations::Hubspot::Contacts::DeployPropertiesService.call(integration:) + result.raise_if_error! + end + end + end + end +end diff --git a/app/jobs/integrations/hubspot/invoices/deploy_object_job.rb b/app/jobs/integrations/hubspot/invoices/deploy_object_job.rb new file mode 100644 index 0000000..0f45df0 --- /dev/null +++ b/app/jobs/integrations/hubspot/invoices/deploy_object_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Invoices + class DeployObjectJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on Integrations::Aggregator::RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration:) + result = Integrations::Hubspot::Invoices::DeployObjectService.call(integration:) + result.raise_if_error! + end + end + end + end +end diff --git a/app/jobs/integrations/hubspot/save_portal_id_job.rb b/app/jobs/integrations/hubspot/save_portal_id_job.rb new file mode 100644 index 0000000..5fce43b --- /dev/null +++ b/app/jobs/integrations/hubspot/save_portal_id_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + class SavePortalIdJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on Integrations::Aggregator::RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration:) + result = Integrations::Hubspot::SavePortalIdService.call(integration:) + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/integrations/hubspot/subscriptions/deploy_object_job.rb b/app/jobs/integrations/hubspot/subscriptions/deploy_object_job.rb new file mode 100644 index 0000000..9313fd2 --- /dev/null +++ b/app/jobs/integrations/hubspot/subscriptions/deploy_object_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Subscriptions + class DeployObjectJob < ApplicationJob + queue_as "integrations" + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 3 + retry_on Integrations::Aggregator::RequestLimitError, wait: :polynomially_longer, attempts: 100 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + def perform(integration:) + result = Integrations::Hubspot::Subscriptions::DeployObjectService.call(integration:) + result.raise_if_error! + end + end + end + end +end diff --git a/app/jobs/invoices/create_pay_in_advance_charge_job.rb b/app/jobs/invoices/create_pay_in_advance_charge_job.rb new file mode 100644 index 0000000..f558b0b --- /dev/null +++ b/app/jobs/invoices/create_pay_in_advance_charge_job.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Invoices + class CreatePayInAdvanceChargeJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :default + end + end + + retry_on Sequenced::SequenceError, wait: :polynomially_longer, attempts: 15, jitter: 0.75 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay + + # DEPRECATED: These errors should not be raised anymore but we keep them and monitor to be sure. + retry_on ActiveRecord::LockWaitTimeout, PG::LockNotAvailable, queue: :low_priority, wait: :polynomially_longer, attempts: 15 + + unique :until_executed, on_conflict: :log + + def perform(charge:, event:, timestamp:, invoice: nil) + result = Invoices::CreatePayInAdvanceChargeService.call(charge:, event:, timestamp:) + return if result.success? + # NOTE: We don't want a dead job for failed invoice due to the tax reason. + # This invoice should be in failed status and can be retried. + return if tax_error?(result) + + result.raise_if_error! + end + + def lock_key_arguments + args = arguments.first + event = Events::CommonFactory.new_instance(source: args[:event]) + [args[:charge], event.organization_id, event.external_subscription_id, event.transaction_id] + end + + private + + def tax_error?(result) + return false unless result.error.is_a?(BaseService::ValidationFailure) + + result.error&.messages&.dig(:tax_error).present? + end + end +end diff --git a/app/jobs/invoices/create_pay_in_advance_fixed_charges_job.rb b/app/jobs/invoices/create_pay_in_advance_fixed_charges_job.rb new file mode 100644 index 0000000..64123c6 --- /dev/null +++ b/app/jobs/invoices/create_pay_in_advance_fixed_charges_job.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Invoices + class CreatePayInAdvanceFixedChargesJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :default + end + end + + retry_on Sequenced::SequenceError, wait: :polynomially_longer, attempts: 15, jitter: 0.75 + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + + retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay + + unique :until_executed, on_conflict: :log + + def perform(subscription, timestamp) + result = Invoices::CreatePayInAdvanceFixedChargesService.call( + subscription:, + timestamp: + ) + + return if result.success? + + # NOTE: We don't want a dead job for failed invoice due to the tax reason. + # This invoice should be in failed status and can be retried. + return if tax_error?(result) + + result.raise_if_error! + end + + private + + def tax_error?(result) + return false unless result.error.is_a?(BaseService::ValidationFailure) + + result.error.messages&.dig(:tax_error).present? + end + end +end diff --git a/app/jobs/invoices/finalize_all_job.rb b/app/jobs/invoices/finalize_all_job.rb new file mode 100644 index 0000000..0089316 --- /dev/null +++ b/app/jobs/invoices/finalize_all_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Invoices + class FinalizeAllJob < ApplicationJob + queue_as "invoices" + + def perform(organization:, invoice_ids:) + result = Invoices::FinalizeBatchService.new(organization:).call(invoice_ids) + result.raise_if_error! unless tax_error?(result) + end + + private + + def tax_error?(result) + return false unless result.error.is_a?(BaseService::ValidationFailure) + + result.error&.messages&.dig(:tax_error).present? + end + end +end diff --git a/app/jobs/invoices/finalize_job.rb b/app/jobs/invoices/finalize_job.rb new file mode 100644 index 0000000..7c9e620 --- /dev/null +++ b/app/jobs/invoices/finalize_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Invoices + class FinalizeJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :invoices + end + end + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay + retry_on Sequenced::SequenceError, wait: :polynomially_longer, attempts: 15, jitter: 0.75 + + def perform(invoice) + Invoices::RefreshDraftAndFinalizeService.call(invoice:) + end + end +end diff --git a/app/jobs/invoices/finalize_pending_vies_invoice_job.rb b/app/jobs/invoices/finalize_pending_vies_invoice_job.rb new file mode 100644 index 0000000..e09ab93 --- /dev/null +++ b/app/jobs/invoices/finalize_pending_vies_invoice_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Invoices + class FinalizePendingViesInvoiceJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :invoices + end + end + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay + + def perform(invoice) + Invoices::FinalizePendingViesInvoiceService.call!(invoice:) + end + end +end diff --git a/app/jobs/invoices/generate_documents_job.rb b/app/jobs/invoices/generate_documents_job.rb new file mode 100644 index 0000000..e16040a --- /dev/null +++ b/app/jobs/invoices/generate_documents_job.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Invoices + class GenerateDocumentsJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :invoices + end + end + + retry_on LagoHttpClient::HttpError, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Net::OpenTimeout, + Net::ReadTimeout, + EOFError, wait: :polynomially_longer, attempts: 6 + + def perform(invoice:, notify: false) + Invoices::GenerateXmlService.call!(invoice:) + Invoices::GeneratePdfService.call!(invoice:) + + Invoices::NotifyJob.perform_later(invoice:) if notify + end + end +end diff --git a/app/jobs/invoices/generate_pdf_and_notify_job.rb b/app/jobs/invoices/generate_pdf_and_notify_job.rb new file mode 100644 index 0000000..95fd8b2 --- /dev/null +++ b/app/jobs/invoices/generate_pdf_and_notify_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Invoices + class GeneratePdfAndNotifyJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :invoices + end + end + + def perform(invoice:, email:) + Invoices::GenerateDocumentsJob.perform_later(invoice:, notify: email) + end + end +end diff --git a/app/jobs/invoices/generate_pdf_job.rb b/app/jobs/invoices/generate_pdf_job.rb new file mode 100644 index 0000000..096b9a5 --- /dev/null +++ b/app/jobs/invoices/generate_pdf_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Invoices + class GeneratePdfJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :invoices + end + end + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 6 + + def perform(invoice) + result = Invoices::GeneratePdfService.call(invoice:, context: "api") + result.raise_if_error! + end + end +end diff --git a/app/jobs/invoices/generate_xml_job.rb b/app/jobs/invoices/generate_xml_job.rb new file mode 100644 index 0000000..11fbf99 --- /dev/null +++ b/app/jobs/invoices/generate_xml_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Invoices + class GenerateXmlJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :invoices + end + end + + def perform(invoice) + result = Invoices::GenerateXmlService.call(invoice:, context: "api") + result.raise_if_error! + end + end +end diff --git a/app/jobs/invoices/notify_job.rb b/app/jobs/invoices/notify_job.rb new file mode 100644 index 0000000..d60f435 --- /dev/null +++ b/app/jobs/invoices/notify_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Invoices + class NotifyJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :invoices + end + end + + def perform(invoice:) + InvoiceMailer.with(invoice:).created.deliver_later + end + end +end diff --git a/app/jobs/invoices/payments/adyen_create_job.rb b/app/jobs/invoices/payments/adyen_create_job.rb new file mode 100644 index 0000000..bb80607 --- /dev/null +++ b/app/jobs/invoices/payments/adyen_create_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class AdyenCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed, on_conflict: :log + + retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 + + def perform(invoice) + # NOTE: Legacy job, kept only to avoid failure with existing jobs + + Invoices::Payments::CreateService.call!(invoice:, payment_provider: :adyen) + end + end + end +end diff --git a/app/jobs/invoices/payments/create_job.rb b/app/jobs/invoices/payments/create_job.rb new file mode 100644 index 0000000..2614582 --- /dev/null +++ b/app/jobs/invoices/payments/create_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class CreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :default + end + end + + unique :until_executed, on_conflict: :log + + retry_on Invoices::Payments::ConnectionError, wait: :polynomially_longer, attempts: 6 + retry_on Invoices::Payments::RateLimitError, wait: :polynomially_longer, attempts: 6 + + def perform(invoice:, payment_provider:, payment_method_params: {}) + Invoices::Payments::CreateService.call!(invoice:, payment_provider:, payment_method_params:) + end + + def lock_key_arguments + [arguments.first[:invoice]] + end + end + end +end diff --git a/app/jobs/invoices/payments/gocardless_create_job.rb b/app/jobs/invoices/payments/gocardless_create_job.rb new file mode 100644 index 0000000..8c3e9be --- /dev/null +++ b/app/jobs/invoices/payments/gocardless_create_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class GocardlessCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed, on_conflict: :log + + def perform(invoice) + # NOTE: Legacy job, kept only to avoid faileure with existing jobs + + Invoices::Payments::CreateService.call!(invoice:, payment_provider: :gocardless) + end + end + end +end diff --git a/app/jobs/invoices/payments/mark_overdue_job.rb b/app/jobs/invoices/payments/mark_overdue_job.rb new file mode 100644 index 0000000..63d7fbd --- /dev/null +++ b/app/jobs/invoices/payments/mark_overdue_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class MarkOverdueJob < ApplicationJob + unique :until_executed, on_conflict: :log + queue_as do + :low_priority + end + + def perform(invoice:) + MarkOverdueService.call(invoice:) + end + end + end +end diff --git a/app/jobs/invoices/payments/moneyhash_create_job.rb b/app/jobs/invoices/payments/moneyhash_create_job.rb new file mode 100644 index 0000000..abc6c7a --- /dev/null +++ b/app/jobs/invoices/payments/moneyhash_create_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class MoneyhashCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed + + retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 + + def perform(invoice) + result = Invoices::Payments::MoneyhashService.new(invoice).create + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/invoices/payments/retry_all_job.rb b/app/jobs/invoices/payments/retry_all_job.rb new file mode 100644 index 0000000..3f60edc --- /dev/null +++ b/app/jobs/invoices/payments/retry_all_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class RetryAllJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :invoices + end + end + + def perform(organization_id:, invoice_ids:) + result = Invoices::Payments::RetryBatchService.new(organization_id:).call(invoice_ids) + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/invoices/payments/stripe_create_job.rb b/app/jobs/invoices/payments/stripe_create_job.rb new file mode 100644 index 0000000..3380c17 --- /dev/null +++ b/app/jobs/invoices/payments/stripe_create_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class StripeCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed, on_conflict: :log + + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 + retry_on Invoices::Payments::ConnectionError, wait: :polynomially_longer, attempts: 6 + retry_on Invoices::Payments::RateLimitError, wait: :polynomially_longer, attempts: 6 + + def perform(invoice) + # NOTE: Legacy job, kept only to avoid faileure with existing jobs + + Invoices::Payments::CreateService.call!(invoice:, payment_provider: :stripe) + end + end + end +end diff --git a/app/jobs/invoices/prepaid_credit_job.rb b/app/jobs/invoices/prepaid_credit_job.rb new file mode 100644 index 0000000..0c4c1b5 --- /dev/null +++ b/app/jobs/invoices/prepaid_credit_job.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Invoices + class PrepaidCreditJob < ApplicationJob + queue_as "high_priority" + + retry_on ActiveRecord::StaleObjectError, wait: :polynomially_longer, attempts: 6 + unique :until_executed, on_conflict: :log + + def lock_key_arguments + invoice = arguments.first + payment_status = arguments.second || :succeeded + + [invoice, payment_status.to_sym] + end + + def perform(invoice, payment_status = :succeeded) # Default to :succeeded for old jobs + wallet_transaction = invoice.fees.find_by(fee_type: "credit")&.invoiceable + + if should_grant_prepaid_credits?(invoice, payment_status.to_sym) + Wallets::ApplyPaidCreditsService.call(wallet_transaction:) + Invoices::FinalizeOpenCreditService.call(invoice:) + else + WalletTransactions::MarkAsFailedService.call(wallet_transaction:) + end + end + + private + + # This job also runs when an invoice is marked as paid because it was fully settled by credit note + # with offset. This occurs when a credit note is applied to the original invoice (offset value) + # instead of to future invoices. + # + # In this scenario, the invoice is not paid via a payment, but via a credit note, + # so no pre-paid credits should be added to the customer's wallet. + def should_grant_prepaid_credits?(invoice, payment_status) + payment_status == :succeeded && !paid_by_credit_note?(invoice) + end + + # For credit invoices, the credit note is always issued for the full invoice amount. + # That means we don't need to compare amounts here. + # + # If the invoice has any invoice_settlements of type credit_note, it indicates the invoice + # was fully settled by a credit note (not by a payment). + def paid_by_credit_note?(invoice) + invoice.invoice_settlements.where(settlement_type: :credit_note).exists? + end + end +end diff --git a/app/jobs/invoices/provider_taxes/pull_taxes_and_apply_job.rb b/app/jobs/invoices/provider_taxes/pull_taxes_and_apply_job.rb new file mode 100644 index 0000000..ff7a2be --- /dev/null +++ b/app/jobs/invoices/provider_taxes/pull_taxes_and_apply_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Invoices + module ProviderTaxes + class PullTaxesAndApplyJob < ApplicationJob + queue_as "providers" + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + retry_on BaseService::ThrottlingError, wait: :polynomially_longer, attempts: 25 + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 6 + retry_on OpenSSL::SSL::SSLError, wait: :polynomially_longer, attempts: 6 + retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 6 + retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 6 + retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, wait: :polynomially_longer, attempts: MAX_LOCK_RETRY_ATTEMPTS + + def perform(invoice:) + Invoices::ProviderTaxes::PullTaxesAndApplyService.call!(invoice:) + end + end + end +end diff --git a/app/jobs/invoices/provider_taxes/void_job.rb b/app/jobs/invoices/provider_taxes/void_job.rb new file mode 100644 index 0000000..367b51a --- /dev/null +++ b/app/jobs/invoices/provider_taxes/void_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Invoices + module ProviderTaxes + class VoidJob < ApplicationJob + queue_as "integrations" + + def perform(invoice:) + return unless invoice.customer.tax_customer + + # NOTE: We don't want to raise error here. + # If sync fails, invoice would be marked and retry option would be available in the UI + Invoices::ProviderTaxes::VoidService.call(invoice:) + end + end + end +end diff --git a/app/jobs/invoices/refresh_draft_job.rb b/app/jobs/invoices/refresh_draft_job.rb new file mode 100644 index 0000000..9cd4810 --- /dev/null +++ b/app/jobs/invoices/refresh_draft_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Invoices + class RefreshDraftJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :invoices + end + end + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + def perform(invoice:) + # if this has already been set to false, we can skip the job + return unless invoice.ready_to_be_refreshed? + + ::Invoices::RefreshDraftService.call(invoice:) + end + end +end diff --git a/app/jobs/invoices/retry_all_job.rb b/app/jobs/invoices/retry_all_job.rb new file mode 100644 index 0000000..32580a6 --- /dev/null +++ b/app/jobs/invoices/retry_all_job.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Invoices + class RetryAllJob < ApplicationJob + queue_as "invoices" + + def perform(organization:, invoice_ids:) + result = Invoices::RetryBatchService.new(organization:).call(invoice_ids) + result.raise_if_error! + end + end +end diff --git a/app/jobs/invoices/update_all_invoice_grace_period_from_billing_entity_job.rb b/app/jobs/invoices/update_all_invoice_grace_period_from_billing_entity_job.rb new file mode 100644 index 0000000..9ab5cfc --- /dev/null +++ b/app/jobs/invoices/update_all_invoice_grace_period_from_billing_entity_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Invoices + class UpdateAllInvoiceGracePeriodFromBillingEntityJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :invoices + end + end + + def perform(billing_entity, old_grace_period) + Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityService.call!( + billing_entity:, + previous_issuing_date_settings: { + invoice_grace_period: old_grace_period, + subscription_invoice_issuing_date_anchor: "next_period_start", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date" + } + ) + end + end +end diff --git a/app/jobs/invoices/update_all_invoice_issuing_date_from_billing_entity_job.rb b/app/jobs/invoices/update_all_invoice_issuing_date_from_billing_entity_job.rb new file mode 100644 index 0000000..8d35a56 --- /dev/null +++ b/app/jobs/invoices/update_all_invoice_issuing_date_from_billing_entity_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Invoices + class UpdateAllInvoiceIssuingDateFromBillingEntityJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :invoices + end + end + + def perform(billing_entity, previous_issuing_date_settings) + Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityService.call!(billing_entity:, previous_issuing_date_settings:) + end + end +end diff --git a/app/jobs/invoices/update_fees_payment_status_job.rb b/app/jobs/invoices/update_fees_payment_status_job.rb new file mode 100644 index 0000000..6aa214f --- /dev/null +++ b/app/jobs/invoices/update_fees_payment_status_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Invoices + class UpdateFeesPaymentStatusJob < ApplicationJob + queue_as "invoices" + + def perform(invoice) + invoice.fees.update!(payment_status: invoice.payment_status) + end + end +end diff --git a/app/jobs/invoices/update_grace_period_from_billing_entity_job.rb b/app/jobs/invoices/update_grace_period_from_billing_entity_job.rb new file mode 100644 index 0000000..a6d8ec4 --- /dev/null +++ b/app/jobs/invoices/update_grace_period_from_billing_entity_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Invoices + class UpdateGracePeriodFromBillingEntityJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :invoices + end + end + + def perform(invoice, old_grace_period) + Invoices::UpdateIssuingDateFromBillingEntityService.call!( + invoice:, + previous_issuing_date_settings: { + invoice_grace_period: old_grace_period, + subscription_invoice_issuing_date_anchor: "next_period_start", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date" + } + ) + end + end +end diff --git a/app/jobs/invoices/update_issuing_date_from_billing_entity_job.rb b/app/jobs/invoices/update_issuing_date_from_billing_entity_job.rb new file mode 100644 index 0000000..be283b8 --- /dev/null +++ b/app/jobs/invoices/update_issuing_date_from_billing_entity_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Invoices + class UpdateIssuingDateFromBillingEntityJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :invoices + end + end + + def perform(invoice, previous_issuing_date_settings) + Invoices::UpdateIssuingDateFromBillingEntityService.call!(invoice:, previous_issuing_date_settings:) + end + end +end diff --git a/app/jobs/lifetime_usages/flag_refresh_from_plan_update_job.rb b/app/jobs/lifetime_usages/flag_refresh_from_plan_update_job.rb new file mode 100644 index 0000000..60830a1 --- /dev/null +++ b/app/jobs/lifetime_usages/flag_refresh_from_plan_update_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module LifetimeUsages + class FlagRefreshFromPlanUpdateJob < ApplicationJob + queue_as :default + + def perform(plan) + LifetimeUsages::FlagRefreshFromPlanUpdateService.call(plan:) + end + end +end diff --git a/app/jobs/lifetime_usages/recalculate_and_check_job.rb b/app/jobs/lifetime_usages/recalculate_and_check_job.rb new file mode 100644 index 0000000..d4f77ca --- /dev/null +++ b/app/jobs/lifetime_usages/recalculate_and_check_job.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module LifetimeUsages + class RecalculateAndCheckJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing_low_priority + else + :default + end + end + + retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + # NOTE: do not pass current usage with perform_later as it will be a huge JSON + def perform(lifetime_usage, current_usage: nil) + LifetimeUsages::CalculateService.call!(lifetime_usage:, current_usage:) + + if lifetime_usage.organization.progressive_billing_enabled? + LifetimeUsages::CheckThresholdsService.call!(lifetime_usage:) + end + end + + def lock_key_arguments + [arguments.first] + end + end +end diff --git a/app/jobs/payment_provider_customers/adyen_checkout_url_job.rb b/app/jobs/payment_provider_customers/adyen_checkout_url_job.rb new file mode 100644 index 0000000..c758f0a --- /dev/null +++ b/app/jobs/payment_provider_customers/adyen_checkout_url_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class AdyenCheckoutUrlJob < ApplicationJob + queue_as :providers + + retry_on Adyen::AdyenError, wait: :polynomially_longer, attempts: 6 + retry_on ActiveJob::DeserializationError + + def perform(adyen_customer) + result = PaymentProviderCustomers::AdyenService.new(adyen_customer).generate_checkout_url + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_provider_customers/adyen_create_job.rb b/app/jobs/payment_provider_customers/adyen_create_job.rb new file mode 100644 index 0000000..3b3e71b --- /dev/null +++ b/app/jobs/payment_provider_customers/adyen_create_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class AdyenCreateJob < ApplicationJob + queue_as :providers + + retry_on Adyen::AdyenError, wait: :polynomially_longer, attempts: 6 + retry_on ActiveJob::DeserializationError + + def perform(adyen_customer) + result = PaymentProviderCustomers::AdyenService.new(adyen_customer).create + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_provider_customers/gocardless_checkout_url_job.rb b/app/jobs/payment_provider_customers/gocardless_checkout_url_job.rb new file mode 100644 index 0000000..9ee9423 --- /dev/null +++ b/app/jobs/payment_provider_customers/gocardless_checkout_url_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class GocardlessCheckoutUrlJob < ApplicationJob + queue_as :providers + + retry_on GoCardlessPro::GoCardlessError, wait: :polynomially_longer, attempts: 6 + retry_on GoCardlessPro::ApiError, wait: :polynomially_longer, attempts: 6 + retry_on GoCardlessPro::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ActiveJob::DeserializationError + + def perform(gocardless_customer) + result = PaymentProviderCustomers::GocardlessService.new(gocardless_customer).generate_checkout_url + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_provider_customers/gocardless_create_job.rb b/app/jobs/payment_provider_customers/gocardless_create_job.rb new file mode 100644 index 0000000..1252cef --- /dev/null +++ b/app/jobs/payment_provider_customers/gocardless_create_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class GocardlessCreateJob < ApplicationJob + queue_as :providers + + retry_on GoCardlessPro::GoCardlessError, wait: :polynomially_longer, attempts: 6 + retry_on GoCardlessPro::ApiError, wait: :polynomially_longer, attempts: 6 + retry_on GoCardlessPro::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ActiveJob::DeserializationError + + def perform(gocardless_customer) + result = PaymentProviderCustomers::GocardlessService.new(gocardless_customer).create + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_provider_customers/moneyhash_checkout_url_job.rb b/app/jobs/payment_provider_customers/moneyhash_checkout_url_job.rb new file mode 100644 index 0000000..d472de5 --- /dev/null +++ b/app/jobs/payment_provider_customers/moneyhash_checkout_url_job.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashCheckoutUrlJob < ApplicationJob + queue_as :providers + + retry_on ActiveJob::DeserializationError + + def perform(moneyhash_customer) + result = PaymentProviderCustomers::MoneyhashService.new(moneyhash_customer).generate_checkout_url + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_provider_customers/moneyhash_create_job.rb b/app/jobs/payment_provider_customers/moneyhash_create_job.rb new file mode 100644 index 0000000..8506d4a --- /dev/null +++ b/app/jobs/payment_provider_customers/moneyhash_create_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashCreateJob < ApplicationJob + queue_as :providers + + retry_on ActiveJob::DeserializationError + + def perform(moneyhash_customer) + result = PaymentProviderCustomers::MoneyhashService.new(moneyhash_customer).create + + result.raise_if_error! + end + end +end diff --git a/app/jobs/payment_provider_customers/stripe_checkout_url_job.rb b/app/jobs/payment_provider_customers/stripe_checkout_url_job.rb new file mode 100644 index 0000000..08a8479 --- /dev/null +++ b/app/jobs/payment_provider_customers/stripe_checkout_url_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class StripeCheckoutUrlJob < ApplicationJob + queue_as :providers + + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::APIError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ActiveJob::DeserializationError + + def perform(stripe_customer) + PaymentProviderCustomers::StripeService.new(stripe_customer) + .generate_checkout_url + .raise_if_error! + rescue BaseService::UnauthorizedFailure, BaseService::ThirdPartyFailure => e + Rails.logger.warn(e.message) + end + end +end diff --git a/app/jobs/payment_provider_customers/stripe_create_job.rb b/app/jobs/payment_provider_customers/stripe_create_job.rb new file mode 100644 index 0000000..8dbb11c --- /dev/null +++ b/app/jobs/payment_provider_customers/stripe_create_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class StripeCreateJob < ApplicationJob + queue_as :providers + + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::APIError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ActiveJob::DeserializationError + + def perform(stripe_customer) + result = PaymentProviderCustomers::StripeService.new(stripe_customer).create + result.raise_if_error! + rescue BaseService::UnauthorizedFailure => e + Rails.logger.warn(e.message) + end + end +end diff --git a/app/jobs/payment_provider_customers/stripe_sync_funding_instructions_job.rb b/app/jobs/payment_provider_customers/stripe_sync_funding_instructions_job.rb new file mode 100644 index 0000000..59a2dc5 --- /dev/null +++ b/app/jobs/payment_provider_customers/stripe_sync_funding_instructions_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class StripeSyncFundingInstructionsJob < ApplicationJob + queue_as :providers + + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::APIError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ActiveJob::DeserializationError + + def perform(stripe_customer) + result = PaymentProviderCustomers::Stripe::SyncFundingInstructionsService.new(stripe_customer).call + result.raise_if_error! + rescue BaseService::UnauthorizedFailure => e + Rails.logger.warn(e.message) + end + end +end diff --git a/app/jobs/payment_providers/adyen/handle_event_job.rb b/app/jobs/payment_providers/adyen/handle_event_job.rb new file mode 100644 index 0000000..aa31297 --- /dev/null +++ b/app/jobs/payment_providers/adyen/handle_event_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentProviders + module Adyen + class HandleEventJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + def perform(organization:, event_json:) + PaymentProviders::Adyen::HandleEventService.call!(organization:, event_json:) + end + end + end +end diff --git a/app/jobs/payment_providers/cancel_payment_authorization_job.rb b/app/jobs/payment_providers/cancel_payment_authorization_job.rb new file mode 100644 index 0000000..8f012f8 --- /dev/null +++ b/app/jobs/payment_providers/cancel_payment_authorization_job.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module PaymentProviders + class CancelPaymentAuthorizationJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + def perform(payment_provider:, id:) + provider_name = payment_provider.payment_type.to_s + + case provider_name + when "stripe" + ::Stripe::PaymentIntent.cancel(id, {}, api_key: payment_provider.secret_key) + else + raise NotImplementedError.new("Cancelling payment authorization not implemented for #{provider_name}") + end + end + end +end diff --git a/app/jobs/payment_providers/cashfree/handle_event_job.rb b/app/jobs/payment_providers/cashfree/handle_event_job.rb new file mode 100644 index 0000000..c7dd970 --- /dev/null +++ b/app/jobs/payment_providers/cashfree/handle_event_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + class HandleEventJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + def perform(organization:, event:) + PaymentProviders::Cashfree::HandleEventService.call!( + organization:, + event_json: event + ) + end + end + end +end diff --git a/app/jobs/payment_providers/flutterwave/handle_event_job.rb b/app/jobs/payment_providers/flutterwave/handle_event_job.rb new file mode 100644 index 0000000..2e3d048 --- /dev/null +++ b/app/jobs/payment_providers/flutterwave/handle_event_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PaymentProviders + module Flutterwave + class HandleEventJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + def perform(organization:, event:) + PaymentProviders::Flutterwave::HandleEventService.call!( + organization:, + event_json: event + ) + end + end + end +end diff --git a/app/jobs/payment_providers/gocardless/handle_event_job.rb b/app/jobs/payment_providers/gocardless/handle_event_job.rb new file mode 100644 index 0000000..c795fd5 --- /dev/null +++ b/app/jobs/payment_providers/gocardless/handle_event_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + class HandleEventJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + def perform(organization: nil, payment_provider: nil, events_json: nil, event_json: nil) + # NOTE: temporary keeps both events_json and event_json to avoid errors during the deployment + if events_json.present? + JSON.parse(events_json)["events"].each do |event| + PaymentProviders::Gocardless::HandleEventJob.perform_later(payment_provider:, event_json: event.to_json) + end + + return + end + + PaymentProviders::Gocardless::HandleEventService.call(payment_provider:, event_json:).raise_if_error! + end + end + end +end diff --git a/app/jobs/payment_providers/moneyhash/handle_event_job.rb b/app/jobs/payment_providers/moneyhash/handle_event_job.rb new file mode 100644 index 0000000..738209c --- /dev/null +++ b/app/jobs/payment_providers/moneyhash/handle_event_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + class HandleEventJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + def perform(organization:, event_json:) + ::PaymentProviders::Moneyhash::HandleEventService.call(organization:, event_json:) + end + end + end +end diff --git a/app/jobs/payment_providers/stripe/customers/fetch_default_payment_method_job.rb b/app/jobs/payment_providers/stripe/customers/fetch_default_payment_method_job.rb new file mode 100644 index 0000000..9a27b6e --- /dev/null +++ b/app/jobs/payment_providers/stripe/customers/fetch_default_payment_method_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Customers + class FetchDefaultPaymentMethodJob < ApplicationJob + queue_as :default + + def perform(provider_customer) + PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodService.call!(provider_customer:) + end + end + end + end +end diff --git a/app/jobs/payment_providers/stripe/handle_event_job.rb b/app/jobs/payment_providers/stripe/handle_event_job.rb new file mode 100644 index 0000000..1706b80 --- /dev/null +++ b/app/jobs/payment_providers/stripe/handle_event_job.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class HandleEventJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + # NOTE: Sometimes, the stripe webhook is received before the DB update of the impacted resource + retry_on BaseService::NotFoundFailure + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6, jitter: 0.75 + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6, jitter: 0.75 + + def perform(organization:, event:) + result = PaymentProviders::Stripe::HandleEventService.call( + organization:, + event_json: event + ) + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/payment_providers/stripe/refresh_webhook_job.rb b/app/jobs/payment_providers/stripe/refresh_webhook_job.rb new file mode 100644 index 0000000..df1cc2a --- /dev/null +++ b/app/jobs/payment_providers/stripe/refresh_webhook_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class RefreshWebhookJob < ApplicationJob + queue_as "providers" + + def perform(stripe_provider) + PaymentProviders::Stripe::RefreshWebhookService.call!(stripe_provider) + end + end + end +end diff --git a/app/jobs/payment_providers/stripe/register_webhook_job.rb b/app/jobs/payment_providers/stripe/register_webhook_job.rb new file mode 100644 index 0000000..9ec5c7d --- /dev/null +++ b/app/jobs/payment_providers/stripe/register_webhook_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class RegisterWebhookJob < ApplicationJob + queue_as "providers" + + def perform(stripe_provider) + PaymentProviders::Stripe::RegisterWebhookService.call!(stripe_provider) + end + end + end +end diff --git a/app/jobs/payment_receipts/create_job.rb b/app/jobs/payment_receipts/create_job.rb new file mode 100644 index 0000000..5ede825 --- /dev/null +++ b/app/jobs/payment_receipts/create_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module PaymentReceipts + class CreateJob < ApplicationJob + queue_as :low_priority + + def perform(payment) + PaymentReceipts::CreateService.call!(payment:) + end + end +end diff --git a/app/jobs/payment_receipts/generate_documents_job.rb b/app/jobs/payment_receipts/generate_documents_job.rb new file mode 100644 index 0000000..7f7350a --- /dev/null +++ b/app/jobs/payment_receipts/generate_documents_job.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module PaymentReceipts + class GenerateDocumentsJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :low_priority + end + end + + retry_on LagoHttpClient::HttpError, + Errno::ECONNREFUSED, + Errno::EHOSTUNREACH, + Net::OpenTimeout, + Net::ReadTimeout, + EOFError, wait: :polynomially_longer, attempts: 6 + + def perform(payment_receipt:, notify: false) + PaymentReceipts::GenerateXmlService.call(payment_receipt:).raise_if_error! + PaymentReceipts::GeneratePdfService.call(payment_receipt:).raise_if_error! + + PaymentReceipts::NotifyJob.perform_later(payment_receipt:) if notify + end + end +end diff --git a/app/jobs/payment_receipts/generate_pdf_and_notify_job.rb b/app/jobs/payment_receipts/generate_pdf_and_notify_job.rb new file mode 100644 index 0000000..8472f9d --- /dev/null +++ b/app/jobs/payment_receipts/generate_pdf_and_notify_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PaymentReceipts + class GeneratePdfAndNotifyJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :low_priority + end + end + + def perform(payment_receipt:, email:) + PaymentReceipts::GenerateDocumentsJob.perform_later(payment_receipt:, notify: email) + end + end +end diff --git a/app/jobs/payment_receipts/generate_pdf_job.rb b/app/jobs/payment_receipts/generate_pdf_job.rb new file mode 100644 index 0000000..007a442 --- /dev/null +++ b/app/jobs/payment_receipts/generate_pdf_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentReceipts + class GeneratePdfJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :low_priority + end + end + + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 6 + + def perform(payment_receipt) + PaymentReceipts::GeneratePdfService.call!(payment_receipt:, context: "api") + end + end +end diff --git a/app/jobs/payment_receipts/generate_xml_job.rb b/app/jobs/payment_receipts/generate_xml_job.rb new file mode 100644 index 0000000..b54ed8b --- /dev/null +++ b/app/jobs/payment_receipts/generate_xml_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PaymentReceipts + class GenerateXmlJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :low_priority + end + end + + def perform(payment_receipt) + PaymentReceipts::GenerateXmlService.call!(payment_receipt:, context: "api") + end + end +end diff --git a/app/jobs/payment_receipts/notify_job.rb b/app/jobs/payment_receipts/notify_job.rb new file mode 100644 index 0000000..ffda1e4 --- /dev/null +++ b/app/jobs/payment_receipts/notify_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PaymentReceipts + class NotifyJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PDFS"]) + :pdfs + else + :low_priority + end + end + + def perform(payment_receipt:) + PaymentReceiptMailer.with(payment_receipt:).created.deliver_later + end + end +end diff --git a/app/jobs/payment_requests/payments/adyen_create_job.rb b/app/jobs/payment_requests/payments/adyen_create_job.rb new file mode 100644 index 0000000..8feb052 --- /dev/null +++ b/app/jobs/payment_requests/payments/adyen_create_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class AdyenCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed, on_conflict: :log + + retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 + + def perform(payable) + # NOTE: Legacy job, kept only to avoid faileure with existing jobs + + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider: "adyen") + end + end + end +end diff --git a/app/jobs/payment_requests/payments/create_job.rb b/app/jobs/payment_requests/payments/create_job.rb new file mode 100644 index 0000000..491f2ba --- /dev/null +++ b/app/jobs/payment_requests/payments/create_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class CreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed, on_conflict: :log + + retry_on Faraday::ConnectionFailed, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 + + def perform(payable:, payment_provider:, payment_method_params: {}) + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider:, payment_method_params:) + end + end + end +end diff --git a/app/jobs/payment_requests/payments/gocardless_create_job.rb b/app/jobs/payment_requests/payments/gocardless_create_job.rb new file mode 100644 index 0000000..695462f --- /dev/null +++ b/app/jobs/payment_requests/payments/gocardless_create_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class GocardlessCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed, on_conflict: :log + + def perform(payable) + # NOTE: Legacy job, kept only to avoid faileure with existing jobs + + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider: "gocardless") + end + end + end +end diff --git a/app/jobs/payment_requests/payments/moneyhash_create_job.rb b/app/jobs/payment_requests/payments/moneyhash_create_job.rb new file mode 100644 index 0000000..758b4a1 --- /dev/null +++ b/app/jobs/payment_requests/payments/moneyhash_create_job.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class MoneyhashCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed + + def perform(payable) + result = PaymentRequests::Payments::MoneyhashService.new(payable).create + result.raise_if_error! + end + end + end +end diff --git a/app/jobs/payment_requests/payments/stripe_create_job.rb b/app/jobs/payment_requests/payments/stripe_create_job.rb new file mode 100644 index 0000000..06e4b60 --- /dev/null +++ b/app/jobs/payment_requests/payments/stripe_create_job.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class StripeCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :providers + end + end + + unique :until_executed, on_conflict: :log + + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 6 + retry_on ::Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 6 + + def perform(payable) + # NOTE: Legacy job, kept only to avoid faileure with existing jobs + + PaymentRequests::Payments::CreateService.call!(payable:, payment_provider: "stripe") + end + end + end +end diff --git a/app/jobs/payments/manual_create_job.rb b/app/jobs/payments/manual_create_job.rb new file mode 100644 index 0000000..16a2129 --- /dev/null +++ b/app/jobs/payments/manual_create_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Payments + class ManualCreateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_PAYMENTS"]) + :payments + else + :low_priority + end + end + + def perform(organization:, params:) + Payments::ManualCreateService.call!(organization:, params:) + end + end +end diff --git a/app/jobs/payments/set_payment_method_and_create_receipt_job.rb b/app/jobs/payments/set_payment_method_and_create_receipt_job.rb new file mode 100644 index 0000000..322bb83 --- /dev/null +++ b/app/jobs/payments/set_payment_method_and_create_receipt_job.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Payments + class SetPaymentMethodAndCreateReceiptJob < ApplicationJob + queue_as "default" + + # NOTE: Even if the service is protected against running multiple time, this job must be unique. + # https://github.com/getlago/lago-api/pull/3490 + unique :until_executed, on_conflict: :log + + retry_on ::Stripe::RateLimitError, wait: :polynomially_longer, attempts: 5 + + def perform(payment:, provider_payment_method_id:) + set_payment_method(payment:, provider_payment_method_id:) + + ::Payments::SetPaymentMethodDataService.call!(payment:, provider_payment_method_id:) + + # Now that the payment method is saved in the payment, we generate the PaymentReceipt + if payment.customer.organization.issue_receipts_enabled? + PaymentReceipts::CreateJob.perform_later(payment) + end + end + + private + + def set_payment_method(payment:, provider_payment_method_id:) + payment_method = payment.customer.payment_methods.find_by(provider_method_id: provider_payment_method_id) + if payment_method.present? + payment.payment_method = payment_method + payment.save! + end + end + end +end diff --git a/app/jobs/plans/destroy_job.rb b/app/jobs/plans/destroy_job.rb new file mode 100644 index 0000000..58cd9c5 --- /dev/null +++ b/app/jobs/plans/destroy_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Plans + class DestroyJob < ApplicationJob + queue_as "default" + + unique :until_executed, on_conflict: :log + + def perform(plan) + plan.children.each do |children_plan| + Plans::DestroyService.call!(plan: children_plan) + end + + Plans::DestroyService.call!(plan:) + end + end +end diff --git a/app/jobs/plans/update_amount_job.rb b/app/jobs/plans/update_amount_job.rb new file mode 100644 index 0000000..29224bf --- /dev/null +++ b/app/jobs/plans/update_amount_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Plans + class UpdateAmountJob < ApplicationJob + queue_as "default" + + def perform(plan:, amount_cents:, expected_amount_cents:) + Plans::UpdateAmountService.call(plan:, amount_cents:, expected_amount_cents:).raise_if_error! + end + end +end diff --git a/app/jobs/segment_identify_job.rb b/app/jobs/segment_identify_job.rb new file mode 100644 index 0000000..3fccaa3 --- /dev/null +++ b/app/jobs/segment_identify_job.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class SegmentIdentifyJob < ApplicationJob + queue_as :default + + def perform(membership_id:) + return if ENV["LAGO_DISABLE_SEGMENT"] == "true" + return if membership_id.nil? || membership_id == "membership/unidentifiable" + + membership = Membership.find(membership_id.delete_prefix("membership/")) + traits = { + created_at: membership.created_at, + hosting_type:, + version:, + organization_name: membership.organization.name, + email: membership.user.email + } + + SEGMENT_CLIENT.identify(user_id: membership_id, traits:) + end + + private + + def hosting_type + @hosting_type ||= (ENV["LAGO_CLOUD"] == "true") ? "cloud" : "self" + end + + def version + LAGO_VERSION.number + end +end diff --git a/app/jobs/segment_track_job.rb b/app/jobs/segment_track_job.rb new file mode 100644 index 0000000..e129f82 --- /dev/null +++ b/app/jobs/segment_track_job.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class SegmentTrackJob < ApplicationJob + queue_as :default + + def perform(membership_id:, event:, properties:) + return if ENV["LAGO_DISABLE_SEGMENT"] == "true" + + SEGMENT_CLIENT.track( + user_id: membership_id || "membership/unidentifiable", + event:, + properties: properties.merge(hosting_type, version) + ) + end + + private + + def hosting_type + {hosting_type: (ENV["LAGO_CLOUD"] == "true") ? "cloud" : "self"} + end + + def version + {version: LAGO_VERSION.number} + end +end diff --git a/app/jobs/send_email_job.rb b/app/jobs/send_email_job.rb new file mode 100644 index 0000000..783df6c --- /dev/null +++ b/app/jobs/send_email_job.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +# Custom mail delivery job to support activity logging for email events in ClickHouse. +# +# == Usage scenarios +# +# The job accepts optional params that control how the email event is logged: +# +# 1. Automatic/scheduled sending (activity_source: :system) +# When the system sends emails automatically (e.g., invoice finalization): +# +# InvoiceMailer.with(invoice:).created.deliver_later +# +# 2. Resend from UI by a user (activity_source: :front) +# When a user manually resends an email from the frontend: +# +# InvoiceMailer.with( +# invoice:, +# resend: true, +# user_id: current_user.id +# ).created.deliver_later +# +# 3. API-triggered sending (activity_source: :api) +# When email is triggered via API request: +# +# CreditNoteMailer.with( +# credit_note:, +# api_key_id: CurrentContext.api_key_id +# ).created.deliver_later +# +class SendEmailJob < ActionMailer::MailDeliveryJob + queue_as "mailers" + + after_perform :log + + retry_on ActiveJob::DeserializationError, wait: :polynomially_longer, attempts: 6 + retry_on LagoHttpClient::HttpError, wait: :polynomially_longer, attempts: 6 + retry_on Net::ReadTimeout, wait: :polynomially_longer, attempts: 6 + retry_on Net::SMTPServerBusy, wait: :polynomially_longer, attempts: 25 + retry_on PaymentReceipts::FilesNotReadyError, wait: :polynomially_longer, attempts: 8 + + after_discard { |job, error| job.log(error:) } + + def perform(mailer_name, mail_method, delivery_method, args:, kwargs: nil, params: nil) + @log_options = params&.extract!(:user_id, :api_key_id, :resend).to_h.compact + + mailer_class = params ? mailer_name.constantize.with(params) : mailer_name.constantize + message = if kwargs + mailer_class.public_send(mail_method, *args, **kwargs) + else + mailer_class.public_send(mail_method, *args) + end + + # We have to reload the whole method `ActionMailer::MailDeliveryJob::perform` + # to have access to the mailer instance with a cached `document` and `created` methods. + @mailer = message.send(:processed_mailer) + + message.public_send(delivery_method) + end + + protected + + attr_reader :mailer, :log_options + + def log(error: nil) + mailer&.log(error:, **log_options) + end +end diff --git a/app/jobs/send_http_webhook_job.rb b/app/jobs/send_http_webhook_job.rb new file mode 100644 index 0000000..9a8c6bb --- /dev/null +++ b/app/jobs/send_http_webhook_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class SendHttpWebhookJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_WEBHOOK"]) + :webhook_worker + else + :webhook + end + end + + # Retry in case of in transactional webhooks, discard after 3 retries + retry_on ActiveJob::DeserializationError, wait: :polynomially_longer, attempts: 3 do |job, error| + Rails.logger.warn("Discarding #{job.class.name} after 3 retries due to: #{error.message}") + end + + def perform(webhook) + Webhooks::SendHttpService.call(webhook:) + end +end diff --git a/app/jobs/send_webhook_job.rb b/app/jobs/send_webhook_job.rb new file mode 100644 index 0000000..0be32b4 --- /dev/null +++ b/app/jobs/send_webhook_job.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require Rails.root.join("lib/lago_http_client/lago_http_client") + +class SendWebhookJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_WEBHOOK"]) + :webhook_worker + else + :webhook + end + end + + retry_on ActiveJob::DeserializationError, wait: :polynomially_longer, attempts: 6 + + WEBHOOK_SERVICES = { + "alert.triggered" => Webhooks::UsageMonitoring::AlertTriggeredService, + "dunning_campaign.finished" => Webhooks::DunningCampaigns::FinishedService, + "invoice.created" => Webhooks::Invoices::CreatedService, + "invoice.one_off_created" => Webhooks::Invoices::OneOffCreatedService, + "invoice.paid_credit_added" => Webhooks::Invoices::PaidCreditAddedService, + "invoice.generated" => Webhooks::Invoices::GeneratedService, + "invoice.drafted" => Webhooks::Invoices::DraftedService, + "invoice.voided" => Webhooks::Invoices::VoidedService, + "invoice.payment_dispute_lost" => Webhooks::Invoices::PaymentDisputeLostService, + "invoice.payment_status_updated" => Webhooks::Invoices::PaymentStatusUpdatedService, + "invoice.payment_overdue" => Webhooks::Invoices::PaymentOverdueService, + "invoice.payment_failure" => Webhooks::PaymentProviders::InvoicePaymentFailureService, + "invoice.resynced" => Webhooks::Invoices::ResyncedService, + "event.error" => Webhooks::Events::ErrorService, + "events.errors" => Webhooks::Events::ValidationErrorsService, + "fee.created" => Webhooks::Fees::PayInAdvanceCreatedService, + "fee.tax_provider_error" => Webhooks::Integrations::Taxes::FeeErrorService, + "customer.created" => Webhooks::Customers::CreatedService, + "customer.updated" => Webhooks::Customers::UpdatedService, + "customer.accounting_provider_created" => Webhooks::Integrations::AccountingCustomerCreatedService, + "customer.accounting_provider_error" => Webhooks::Integrations::AccountingCustomerErrorService, + "customer.crm_provider_created" => Webhooks::Integrations::CrmCustomerCreatedService, + "customer.crm_provider_error" => Webhooks::Integrations::CrmCustomerErrorService, + "customer.payment_provider_created" => Webhooks::PaymentProviders::CustomerCreatedService, + "customer.payment_provider_error" => Webhooks::PaymentProviders::CustomerErrorService, + "customer.checkout_url_generated" => Webhooks::PaymentProviders::CustomerCheckoutService, + "customer.tax_provider_error" => Webhooks::Integrations::Taxes::ErrorService, + "customer.vies_check" => Webhooks::Customers::ViesCheckService, + "credit_note.created" => Webhooks::CreditNotes::CreatedService, + "credit_note.generated" => Webhooks::CreditNotes::GeneratedService, + "credit_note.provider_refund_failure" => Webhooks::CreditNotes::PaymentProviderRefundFailureService, + "integration.provider_error" => Webhooks::Integrations::ProviderErrorService, + "payment.requires_action" => Webhooks::Payments::RequiresActionService, + "payment.succeeded" => Webhooks::Payments::SucceededService, + "payment_provider.error" => Webhooks::PaymentProviders::ErrorService, + "payment_receipt.created" => Webhooks::PaymentReceipts::CreatedService, + "payment_receipt.generated" => Webhooks::PaymentReceipts::GeneratedService, + "payment_request.created" => Webhooks::PaymentRequests::CreatedService, + "payment_request.payment_failure" => Webhooks::PaymentProviders::PaymentRequestPaymentFailureService, + "payment_request.payment_status_updated" => Webhooks::PaymentRequests::PaymentStatusUpdatedService, + "plan.created" => Webhooks::Plans::CreatedService, + "plan.deleted" => Webhooks::Plans::DeletedService, + "plan.updated" => Webhooks::Plans::UpdatedService, + "feature.created" => Webhooks::Features::CreatedService, + "feature.updated" => Webhooks::Features::UpdatedService, + "feature.deleted" => Webhooks::Features::DeletedService, + "subscription.canceled" => Webhooks::Subscriptions::CanceledService, + "subscription.incomplete" => Webhooks::Subscriptions::IncompleteService, + "subscription.terminated" => Webhooks::Subscriptions::TerminatedService, + "subscription.started" => Webhooks::Subscriptions::StartedService, + "subscription.termination_alert" => Webhooks::Subscriptions::TerminationAlertService, + "subscription.trial_ended" => Webhooks::Subscriptions::TrialEndedService, + "subscription.updated" => Webhooks::Subscriptions::UpdatedService, + "subscription.usage_threshold_reached" => Webhooks::Subscriptions::UsageThresholdsReachedService, + "wallet.created" => Webhooks::Wallets::CreatedService, + "wallet.updated" => Webhooks::Wallets::UpdatedService, + "wallet.terminated" => Webhooks::Wallets::TerminatedService, + "wallet.depleted_ongoing_balance" => Webhooks::Wallets::DepletedOngoingBalanceService, + "wallet_transaction.created" => Webhooks::WalletTransactions::CreatedService, + "wallet_transaction.updated" => Webhooks::WalletTransactions::UpdatedService, + "wallet_transaction.payment_failure" => Webhooks::PaymentProviders::WalletTransactionPaymentFailureService + }.freeze + + # This is a placeholder object to know which arguments were provided. + UNDEFINED = Object.new.freeze + private_constant :UNDEFINED + + # Override the default perform_later to avoid creating webhooks if no webhook endpoints are present. + # + # This will prevent enqueueing jobs only to return early from the jobs. + def self.perform_later(webhook_type, object, options = UNDEFINED, webhook_id = UNDEFINED) + return if (webhook_id.nil? || webhook_id == UNDEFINED) && object.organization.webhook_endpoints.none? + + args = [webhook_type, object, options, webhook_id].filter { |arg| arg != UNDEFINED } + super(*args) + end + + def perform(webhook_type, object, options = {}, webhook_id = nil) + raise(NotImplementedError) unless WEBHOOK_SERVICES.include?(webhook_type) + + # NOTE: This condition is only temporary to handle enqueued jobs + # TODO: Remove this condition after queued jobs are processed + if webhook_id + SendHttpWebhookJob.perform_later(Webhook.find(webhook_id)) + return + end + + WEBHOOK_SERVICES.fetch(webhook_type).new(object:, options:).call + end +end diff --git a/app/jobs/subscriptions/activation_rules/payment/resolve_job.rb b/app/jobs/subscriptions/activation_rules/payment/resolve_job.rb new file mode 100644 index 0000000..4be3cb2 --- /dev/null +++ b/app/jobs/subscriptions/activation_rules/payment/resolve_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + module Payment + class ResolveJob < ApplicationJob + queue_as "default" + + def perform(subscription, invoice, payment_status) + Payment::ResolveService.call!(subscription:, invoice:, payment_status:) + end + end + end + end +end diff --git a/app/jobs/subscriptions/flag_refreshed_job.rb b/app/jobs/subscriptions/flag_refreshed_job.rb new file mode 100644 index 0000000..45efb42 --- /dev/null +++ b/app/jobs/subscriptions/flag_refreshed_job.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Subscriptions + class FlagRefreshedJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_EVENTS"]) + :events + else + :default + end + end + + def perform(subscription_id) + Subscriptions::FlagRefreshedService.call!(subscription_id) + end + end +end diff --git a/app/jobs/subscriptions/organization_billing_job.rb b/app/jobs/subscriptions/organization_billing_job.rb new file mode 100644 index 0000000..8e6f9b9 --- /dev/null +++ b/app/jobs/subscriptions/organization_billing_job.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Subscriptions + class OrganizationBillingJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_CLOCK"]) + :clock_worker + else + :clock + end + end + + unique :until_executed, on_conflict: :log, lock_ttl: 12.hours + + def perform(organization:) + Subscriptions::OrganizationBillingService.call!(organization:) + end + end +end diff --git a/app/jobs/subscriptions/terminate_ended_subscription_job.rb b/app/jobs/subscriptions/terminate_ended_subscription_job.rb new file mode 100644 index 0000000..64541d6 --- /dev/null +++ b/app/jobs/subscriptions/terminate_ended_subscription_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Subscriptions + # Handles async termination of ended subscriptions from `Clock::TerminateEndedSubscriptionsJob`. + # Intentionally on the `default` queue: this job only triggers termination which schedules + # billing separately — it doesn't perform billing itself, so it shouldn't compete + # with billing jobs on the :billing queue. + class TerminateEndedSubscriptionJob < ApplicationJob + unique :until_executed, on_conflict: :log + + def perform(subscription) + Subscriptions::TerminateService.call!(subscription:) + end + end +end diff --git a/app/jobs/subscriptions/terminate_job.rb b/app/jobs/subscriptions/terminate_job.rb new file mode 100644 index 0000000..281f535 --- /dev/null +++ b/app/jobs/subscriptions/terminate_job.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Subscriptions + class TerminateJob < ApplicationJob + queue_as do + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_BILLING"]) + :billing + else + :default + end + end + + def perform(subscription, timestamp) + result = Subscriptions::TerminateService.new(subscription:) + .terminate_and_start_next(timestamp:) + + result.raise_if_error! + end + end +end diff --git a/app/jobs/taxes/update_all_eu_taxes_job.rb b/app/jobs/taxes/update_all_eu_taxes_job.rb new file mode 100644 index 0000000..2d0e359 --- /dev/null +++ b/app/jobs/taxes/update_all_eu_taxes_job.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Taxes + class UpdateAllEuTaxesJob < ApplicationJob + queue_as "default" + + def perform + Organization.where(eu_tax_management: true).find_each do |org| + ::Taxes::UpdateOrganizationEuTaxesJob.perform_later(org) + end + end + end +end diff --git a/app/jobs/taxes/update_organization_eu_taxes_job.rb b/app/jobs/taxes/update_organization_eu_taxes_job.rb new file mode 100644 index 0000000..4da2d81 --- /dev/null +++ b/app/jobs/taxes/update_organization_eu_taxes_job.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Taxes + class UpdateOrganizationEuTaxesJob < ApplicationJob + queue_as "default" + + def perform(organization) + Taxes::AutoGenerateService.call!(organization:) + end + end +end diff --git a/app/jobs/usage_monitoring/process_lifetime_usage_alert_job.rb b/app/jobs/usage_monitoring/process_lifetime_usage_alert_job.rb new file mode 100644 index 0000000..ca30120 --- /dev/null +++ b/app/jobs/usage_monitoring/process_lifetime_usage_alert_job.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessLifetimeUsageAlertJob < ApplicationJob + unique :until_executed, on_conflict: :log + queue_as :default + + def perform(alert:, subscription:) + ProcessLifetimeUsageAlertService.call!(alert:, subscription:) + end + + private + + def lock_key_arguments + [arguments.first[:alert]] + end + end +end diff --git a/app/jobs/usage_monitoring/process_organization_subscription_activities_job.rb b/app/jobs/usage_monitoring/process_organization_subscription_activities_job.rb new file mode 100644 index 0000000..3fd393d --- /dev/null +++ b/app/jobs/usage_monitoring/process_organization_subscription_activities_job.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessOrganizationSubscriptionActivitiesJob < ApplicationJob + queue_as do + organization_id = arguments.first + if Utils::DedicatedWorkerConfig.enabled_for?(organization_id) + Utils::DedicatedWorkerConfig::DEDICATED_ALERTS_QUEUE + else + :default + end + end + + unique :until_executed, on_conflict: :log + + def perform(organization_id) + return unless License.premium? + + organization = Organization.find(organization_id) + UsageMonitoring::ProcessOrganizationSubscriptionActivitiesService.call!(organization:) + end + end +end diff --git a/app/jobs/usage_monitoring/process_subscription_activity_job.rb b/app/jobs/usage_monitoring/process_subscription_activity_job.rb new file mode 100644 index 0000000..c3a0870 --- /dev/null +++ b/app/jobs/usage_monitoring/process_subscription_activity_job.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessSubscriptionActivityJob < ApplicationJob + queue_as :default + + def perform(subscription_activity_id, attempt = 1) + subscription_activity = SubscriptionActivity.find_by(id: subscription_activity_id) + return unless subscription_activity + + ProcessSubscriptionActivityService.call!(subscription_activity:) + rescue => e + Sentry.capture_exception(e) if defined?(Sentry) + if attempt > 3 + SubscriptionActivity.where(id: subscription_activity_id).delete_all + raise e + end + self.class.perform_later(subscription_activity_id, attempt + 1) + end + end +end diff --git a/app/jobs/usage_monitoring/process_wallet_alerts_job.rb b/app/jobs/usage_monitoring/process_wallet_alerts_job.rb new file mode 100644 index 0000000..fbdf32d --- /dev/null +++ b/app/jobs/usage_monitoring/process_wallet_alerts_job.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessWalletAlertsJob < ApplicationJob + queue_as :default + + unique :until_executed, on_conflict: :log + + def perform(wallet) + return unless License.premium? + + ProcessWalletAlertsService.call!(wallet:) + end + end +end diff --git a/app/jobs/wallet_transactions/create_job.rb b/app/jobs/wallet_transactions/create_job.rb new file mode 100644 index 0000000..e88395f --- /dev/null +++ b/app/jobs/wallet_transactions/create_job.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module WalletTransactions + class CreateJob < ApplicationJob + queue_as "high_priority" + unique :until_executed, on_conflict: :log + + # ActiveRecord::StaleObjectError is handled in WalletTransactions::CreateFromParamsService + + def perform(organization_id:, params:, unique_transaction: false) + organization = Organization.find(organization_id) + WalletTransactions::CreateFromParamsService.call!(organization:, params:) + end + + # Override lock_key_arguments to conditionally include only relevant parameters + # when uniqueness is needed (unique_transaction is true) + def lock_key_arguments + args = arguments[0].symbolize_keys + org_id = args[:organization_id] + params = args[:params] + unique_transaction = args[:unique_transaction] + + if unique_transaction + [ + org_id, + params[:wallet_id], + params[:paid_credits], + params[:granted_credits] + ] + else + # Return a unique value for each job to effectively disable uniqueness + # when unique_transaction is false + [SecureRandom.uuid] + end + end + end +end diff --git a/app/legacy_inputs/base_legacy_input.rb b/app/legacy_inputs/base_legacy_input.rb new file mode 100644 index 0000000..04322fd --- /dev/null +++ b/app/legacy_inputs/base_legacy_input.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class BaseLegacyInput + def initialize(organization, args) + @organization = organization + @args = args&.to_h&.symbolize_keys + end + + protected + + attr_reader :organization, :args + + def date_in_organization_timezone(date, end_of_day: false) + return if date.blank? + + result = date.to_date.in_time_zone(organization&.timezone || "UTC") + result = result.end_of_day if end_of_day + result.utc + end +end diff --git a/app/mailers/api_key_mailer.rb b/app/mailers/api_key_mailer.rb new file mode 100644 index 0000000..dff2138 --- /dev/null +++ b/app/mailers/api_key_mailer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class ApiKeyMailer < ApplicationMailer + def rotated + organization = params[:api_key].organization + @organization_name = organization.name + + I18n.with_locale(:en) do + mail( + bcc: organization.admins.pluck(:email), + from: ENV["LAGO_FROM_EMAIL"], + subject: I18n.t("email.api_key.rotated.subject") + ) + end + end + + def created + organization = params[:api_key].organization + @organization_name = organization.name + + I18n.with_locale(:en) do + mail( + bcc: organization.admins.pluck(:email), + from: ENV["LAGO_FROM_EMAIL"], + subject: I18n.t("email.api_key.created.subject") + ) + end + end + + def destroyed + organization = params[:api_key].organization + @organization_name = organization.name + + I18n.with_locale(:en) do + mail( + bcc: organization.admins.pluck(:email), + from: ENV["LAGO_FROM_EMAIL"], + subject: I18n.t("email.api_key.destroyed.subject") + ) + end + end +end diff --git a/app/mailers/application_mailer.rb b/app/mailers/application_mailer.rb new file mode 100644 index 0000000..96e09b0 --- /dev/null +++ b/app/mailers/application_mailer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class ApplicationMailer < ActionMailer::Base + layout "mailer" + + self.delivery_job = SendEmailJob + + before_action :set_shared_variables + + def set_shared_variables + @show_lago_logo = true + @lago_logo_url = "https://assets.getlago.com/lago-logo-email.png" + @pdfs_enabled = !ActiveModel::Type::Boolean.new.cast(ENV["LAGO_DISABLE_PDF_GENERATION"]) + end + + # Shared interface for logging purposes + + # Define if sending this email should be logged + def loggable? + false + end + + def log(**options) + Utils::EmailActivityLog.produce(**options) if loggable? + end +end diff --git a/app/mailers/credit_note_mailer.rb b/app/mailers/credit_note_mailer.rb new file mode 100644 index 0000000..9cd06b1 --- /dev/null +++ b/app/mailers/credit_note_mailer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class CreditNoteMailer < DocumentMailer + before_action :ensure_pdf + + def document + @document ||= params[:credit_note] + end + + private + + def ensure_pdf + CreditNotes::GeneratePdfService.call(credit_note: document) + end + + def create_mail + @credit_note = document + @customer = document.customer + @billing_entity = document.billing_entity + @show_lago_logo = !@billing_entity.organization.remove_branding_watermark_enabled? + + recipients = params[:to].presence || [@customer.email].compact_blank + return if @billing_entity.email.blank? + return if recipients.empty? + + if @pdfs_enabled + document.file.open do |file| + attachments["credit_note-#{document.number}.pdf"] = file.read + end + end + + I18n.with_locale(@customer.preferred_document_locale) do + mail( + to: recipients, + cc: params[:cc], + bcc: params[:bcc], + from: email_address_with_name(@billing_entity.from_email_address, @billing_entity.name), + reply_to: email_address_with_name(@billing_entity.email, @billing_entity.name), + subject: I18n.t( + "email.credit_note.created.subject", + billing_entity_name: @billing_entity.name, + credit_note_number: document.number + ) + ) + end + end +end diff --git a/app/mailers/data_export_mailer.rb b/app/mailers/data_export_mailer.rb new file mode 100644 index 0000000..773b3c1 --- /dev/null +++ b/app/mailers/data_export_mailer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class DataExportMailer < ApplicationMailer + def completed + @data_export = params[:data_export] + @resource_type = @data_export.resource_type.humanize.downcase + user = @data_export.user + + return if @data_export.expired? + return unless @data_export.completed? + + I18n.with_locale(:en) do + mail( + to: user.email, + from: ENV["LAGO_FROM_EMAIL"], + subject: I18n.t( + "email.data_export.completed.subject", + resource_type: @resource_type + ) + ) + end + end +end diff --git a/app/mailers/document_mailer.rb b/app/mailers/document_mailer.rb new file mode 100644 index 0000000..7f6f5d1 --- /dev/null +++ b/app/mailers/document_mailer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class DocumentMailer < ApplicationMailer + def loggable? + true + end + + def log(**context) + super(document:, message: created, **context) if created.present? && document.present? + end + + def created + @created ||= create_mail + end + + def document + end +end diff --git a/app/mailers/invoice_mailer.rb b/app/mailers/invoice_mailer.rb new file mode 100644 index 0000000..bc64584 --- /dev/null +++ b/app/mailers/invoice_mailer.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class InvoiceMailer < DocumentMailer + before_action :ensure_pdf + + def document + @document ||= params[:invoice] + end + + private + + def ensure_pdf + Invoices::GeneratePdfService.new(invoice: document).call + end + + def create_mail + @invoice = document + @billing_entity = document.billing_entity + @customer = document.customer + @show_lago_logo = !@billing_entity.organization.remove_branding_watermark_enabled? + + recipients = params[:to].presence || [@customer.email].compact_blank + return if @billing_entity.email.blank? + return if recipients.empty? + return if document.fees_amount_cents.zero? + + I18n.locale = @customer.preferred_document_locale + + if @pdfs_enabled + document.file.open do |file| + attachments["invoice-#{document.number}.pdf"] = file.read + end + end + + I18n.with_locale(@customer.preferred_document_locale) do + mail( + to: recipients, + cc: params[:cc], + bcc: params[:bcc], + from: email_address_with_name(@billing_entity.from_email_address, @billing_entity.name), + reply_to: email_address_with_name(@billing_entity.email, @billing_entity.name), + subject: I18n.t( + "email.invoice.finalized.subject", + billing_entity_name: @billing_entity.name, + invoice_number: document.number + ) + ) + end + end +end diff --git a/app/mailers/organization_mailer.rb b/app/mailers/organization_mailer.rb new file mode 100644 index 0000000..6469900 --- /dev/null +++ b/app/mailers/organization_mailer.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class OrganizationMailer < ApplicationMailer + def authentication_methods_updated + @organization = params[:organization] + @user = params[:user] + @additions = params[:additions] + @deletions = params[:deletions] + + return if @organization.nil? || @user.nil? + return if @additions.empty? && @deletions.empty? + + I18n.locale = @organization.document_locale + + mail( + bcc: @organization.admins.map(&:email), + from: ENV["LAGO_FROM_EMAIL"], + reply_to: email_address_with_name(@organization.from_email_address, @organization.name), + subject: I18n.t( + "email.organization.authentication_methods_updated.subject" + ) + ) + end +end diff --git a/app/mailers/password_reset_mailer.rb b/app/mailers/password_reset_mailer.rb new file mode 100644 index 0000000..246d216 --- /dev/null +++ b/app/mailers/password_reset_mailer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class PasswordResetMailer < ApplicationMailer + def requested + @password_reset = params[:password_reset] + @email = @password_reset.user.email + + return if @password_reset.token.blank? + return if @email.blank? + + @reset_url = "#{ENV["LAGO_FRONT_URL"]}/reset-password/#{@password_reset.token}" + @forgot_url = "#{ENV["LAGO_FRONT_URL"]}/forgot-password" + + I18n.with_locale(:en) do + mail( + to: @email, + from: ENV["LAGO_FROM_EMAIL"], + subject: I18n.t("email.password_reset.subject") + ) + end + end +end diff --git a/app/mailers/payment_receipt_mailer.rb b/app/mailers/payment_receipt_mailer.rb new file mode 100644 index 0000000..56431d6 --- /dev/null +++ b/app/mailers/payment_receipt_mailer.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +class PaymentReceiptMailer < DocumentMailer + before_action :ensure_pdf + + def document + @document ||= params[:payment_receipt] + end + + private + + def ensure_pdf + PaymentReceipts::GeneratePdfService.new(payment_receipt: document).call + + invoices = document.payment.payable.is_a?(Invoice) ? [document.payment.payable] : document.payment.payable.invoices + + raise PaymentReceipts::FilesNotReadyError unless document.file.attached? + raise PaymentReceipts::FilesNotReadyError unless invoices.all? { |invoice| invoice.file.attached? } + end + + def create_mail + @payment_receipt = document + @billing_entity = document.billing_entity + @customer = document.payment.payable.customer + @show_lago_logo = !@billing_entity.organization.remove_branding_watermark_enabled? + @total_due_amount = document.payment.payable.is_a?(Invoice) ? + document.payment.payable.total_due_amount : + document.payment.payable.amount - document.payment.amount + + recipients = params[:to].presence || [@customer.email].compact_blank + return if @billing_entity.email.blank? + return if recipients.empty? + + @invoices = if document.payment.payable.is_a?(Invoice) + [document.payment.payable] + else + document.payment.payable.invoices + end + + I18n.locale = @customer.preferred_document_locale + + if @pdfs_enabled + document.file.open { |file| attachments["receipt-#{document.number}.pdf"] = file.read } + + @invoices.each do |invoice| + invoice.file.open { |file| attachments["invoice-#{invoice.number}.pdf"] = file.read } + end + end + + I18n.with_locale(@customer.preferred_document_locale) do + mail( + to: recipients, + cc: params[:cc], + bcc: params[:bcc], + from: email_address_with_name(@billing_entity.from_email_address, @billing_entity.name), + reply_to: email_address_with_name(@billing_entity.email, @billing_entity.name), + subject: I18n.t( + "email.payment_receipt.created.subject", + billing_entity_name: @billing_entity.name, + payment_receipt_number: document.number + ) + ) + end + end +end diff --git a/app/mailers/payment_request_mailer.rb b/app/mailers/payment_request_mailer.rb new file mode 100644 index 0000000..9f15a05 --- /dev/null +++ b/app/mailers/payment_request_mailer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class PaymentRequestMailer < ApplicationMailer + before_action :ensure_invoices_pdf + + def requested + @payment_request = params[:payment_request] + @billing_entity = @payment_request.billing_entity + @show_lago_logo = !@billing_entity.organization.remove_branding_watermark_enabled? + + return if @payment_request.email.blank? + return if @billing_entity.email.blank? + + @customer = @payment_request.customer + @invoices = @payment_request.invoices + @payment_url = ::PaymentRequests::Payments::GeneratePaymentUrlService.call(payable: @payment_request).payment_url + + bcc_emails = @payment_request.dunning_campaign&.bcc_emails + + I18n.with_locale(@customer.preferred_document_locale) do + mail( + to: @payment_request.email, + from: email_address_with_name(@billing_entity.from_email_address, @billing_entity.name), + bcc: bcc_emails, + reply_to: email_address_with_name(@billing_entity.email, @billing_entity.name), + subject: I18n.t( + "email.payment_request.requested.subject", + billing_entity_name: @billing_entity.name + ) + ) + end + end + + private + + def ensure_invoices_pdf + params[:payment_request].invoices.each do |invoice| + Invoices::GeneratePdfService.new(invoice:).call + end + end +end diff --git a/app/models/add_on.rb b/app/models/add_on.rb new file mode 100644 index 0000000..f8cdbf6 --- /dev/null +++ b/app/models/add_on.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class AddOn < ApplicationRecord + include PaperTrailTraceable + include Currencies + include IntegrationMappable + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + + has_many :applied_add_ons + has_many :customers, through: :applied_add_ons + has_many :fees + has_many :fixed_charges, dependent: :destroy + + has_many :applied_taxes, class_name: "AddOn::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + + monetize :amount_cents + + validates :name, presence: true + validates :code, + presence: true, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id} + + validates :amount_cents, numericality: {greater_than: 0} + validates :amount_currency, inclusion: {in: currency_list} + + default_scope -> { kept } + + def self.ransackable_attributes(_auth_object = nil) + %w[name code] + end + + def invoice_name + invoice_display_name.presence || name + end +end + +# == Schema Information +# +# Table name: add_ons +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# amount_currency :string not null +# code :string not null +# deleted_at :datetime +# description :string +# invoice_display_name :string +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_add_ons_on_deleted_at (deleted_at) +# index_add_ons_on_organization_id (organization_id) +# index_add_ons_on_organization_id_and_code (organization_id,code) UNIQUE WHERE (deleted_at IS NULL) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/add_on/applied_tax.rb b/app/models/add_on/applied_tax.rb new file mode 100644 index 0000000..dca026a --- /dev/null +++ b/app/models/add_on/applied_tax.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class AddOn + class AppliedTax < ApplicationRecord + self.table_name = "add_ons_taxes" + + belongs_to :add_on + belongs_to :tax + belongs_to :organization + end +end + +# == Schema Information +# +# Table name: add_ons_taxes +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# add_on_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid not null +# +# Indexes +# +# index_add_ons_taxes_on_add_on_id (add_on_id) +# index_add_ons_taxes_on_add_on_id_and_tax_id (add_on_id,tax_id) UNIQUE +# index_add_ons_taxes_on_organization_id (organization_id) +# index_add_ons_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (add_on_id => add_ons.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/adjusted_fee.rb b/app/models/adjusted_fee.rb new file mode 100644 index 0000000..d4efe2f --- /dev/null +++ b/app/models/adjusted_fee.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class AdjustedFee < ApplicationRecord + belongs_to :invoice + belongs_to :subscription + belongs_to :fee, optional: true + belongs_to :charge, optional: true + belongs_to :fixed_charge, optional: true + belongs_to :charge_with_discarded, + -> { with_discarded }, + class_name: "Charge", + foreign_key: :charge_id, + optional: true + belongs_to :fixed_charge_with_discarded, + -> { with_discarded }, + class_name: "FixedCharge", + foreign_key: :fixed_charge_id, + optional: true + belongs_to :group, optional: true + belongs_to :charge_filter, optional: true + belongs_to :charge_filter_with_discarded, + -> { with_discarded }, + class_name: "ChargeFilter", + foreign_key: :charge_filter_id, + optional: true + belongs_to :organization + + ADJUSTED_FEE_TYPES = [ + :adjusted_units, + :adjusted_amount + ].freeze + + enum :fee_type, Fee::FEE_TYPES + + def adjusted_display_name? + adjusted_units.blank? && adjusted_amount.blank? + end +end + +# == Schema Information +# +# Table name: adjusted_fees +# Database name: primary +# +# id :uuid not null, primary key +# adjusted_amount :boolean default(FALSE), not null +# adjusted_units :boolean default(FALSE), not null +# fee_type :integer +# grouped_by :jsonb not null +# invoice_display_name :string +# properties :jsonb not null +# unit_amount_cents :bigint default(0), not null +# unit_precise_amount_cents :decimal(40, 15) default(0.0), not null +# units :decimal(, ) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# charge_filter_id :uuid +# charge_id :uuid +# fee_id :uuid +# fixed_charge_id :uuid +# group_id :uuid +# invoice_id :uuid not null +# organization_id :uuid not null +# subscription_id :uuid +# +# Indexes +# +# index_adjusted_fees_on_charge_filter_id (charge_filter_id) +# index_adjusted_fees_on_charge_id (charge_id) +# index_adjusted_fees_on_fee_id (fee_id) +# index_adjusted_fees_on_group_id (group_id) +# index_adjusted_fees_on_invoice_id (invoice_id) +# index_adjusted_fees_on_organization_id (organization_id) +# index_adjusted_fees_on_subscription_id (subscription_id) +# +# Foreign Keys +# +# fk_rails_... (charge_id => charges.id) +# fk_rails_... (fee_id => fees.id) +# fk_rails_... (fixed_charge_id => fixed_charges.id) +# fk_rails_... (group_id => groups.id) +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/ai_conversation.rb b/app/models/ai_conversation.rb new file mode 100644 index 0000000..d338461 --- /dev/null +++ b/app/models/ai_conversation.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class AiConversation < ApplicationRecord + belongs_to :membership + belongs_to :organization + + validates :name, presence: true +end + +# == Schema Information +# +# Table name: ai_conversations +# Database name: primary +# +# id :uuid not null, primary key +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# membership_id :uuid not null +# mistral_conversation_id :string +# organization_id :uuid not null +# +# Indexes +# +# index_ai_conversations_on_membership_id (membership_id) +# index_ai_conversations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (membership_id => memberships.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/analytics/base.rb b/app/models/analytics/base.rb new file mode 100644 index 0000000..5d94300 --- /dev/null +++ b/app/models/analytics/base.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Analytics + class Base < ApplicationRecord + self.abstract_class = true + + def self.find_all_by(organization_id, **args) + if args[:expire_cache] == true && args[:external_customer_id].present? + expire_cache_for_customer(organization_id, args[:external_customer_id]) + end + + Rails.cache.fetch(cache_key(organization_id, **args), expires_in: cache_expiration) do + sql = query(organization_id, **args) + + result = ActiveRecord::Base.connection.exec_query(sql) + result.to_a + end + end + + def self.cache_expiration + 4.hours + end + + def self.expire_cache_for_customer(organization_id, external_customer_id) + raise NotImplementedError + end + end +end diff --git a/app/models/analytics/gross_revenue.rb b/app/models/analytics/gross_revenue.rb new file mode 100644 index 0000000..fca0536 --- /dev/null +++ b/app/models/analytics/gross_revenue.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +module Analytics + class GrossRevenue < Base + self.abstract_class = true + + class << self + def query(organization_id, **args) + if args[:billing_entity_id].present? + and_billing_entity_id_sql = sanitize_sql(["AND i.billing_entity_id = :billing_entity_id", args[:billing_entity_id]]) + end + + if args[:external_customer_id].present? + and_external_customer_id_sql = sanitize_sql( + ["AND c.external_id = :external_customer_id AND c.deleted_at IS NULL", args[:external_customer_id]] + ) + end + + if args[:months].present? + months_interval = (args[:months].to_i <= 1) ? 0 : args[:months].to_i - 1 + + and_months_sql = sanitize_sql( + [ + "AND am.month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL ':months months')", + {months: months_interval} + ] + ) + end + + if args[:currency].present? + and_currency_sql = sanitize_sql(["AND cd.currency = :currency", args[:currency].upcase]) + select_currency_sql = sanitize_sql(["COALESCE(cd.currency, :currency) as currency", args[:currency].upcase]) + else + select_currency_sql = "cd.currency" + end + + sql = <<~SQL.squish + WITH organization_creation_date AS ( + SELECT + DATE_TRUNC('month', o.created_at) AS start_month + FROM organizations o + WHERE o.id = :organization_id + ), + all_months AS ( + SELECT + generate_series( + (SELECT start_month FROM organization_creation_date), + DATE_TRUNC('month', CURRENT_DATE + INTERVAL '10 years'), + interval '1 month' + ) AS month + ), + issued_invoices AS ( + SELECT + i.id, + i.issuing_date, + i.total_amount_cents::float AS amount_cents, + i.currency, + COALESCE(COUNT(DISTINCT i.id), 0) AS invoices_count, + COALESCE(SUM(refund_amount_cents::float),0) AS total_refund_amount_cents + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + LEFT JOIN credit_notes cn ON cn.invoice_id = i.id + WHERE i.organization_id = :organization_id + AND i.self_billed IS FALSE + AND i.status = 1 + AND i.payment_dispute_lost_at IS NULL + #{and_external_customer_id_sql} + #{and_billing_entity_id_sql} + GROUP BY i.id, i.issuing_date, i.total_amount_cents, i.currency + ORDER BY i.issuing_date ASC + ), + instant_charges AS ( + SELECT + f.id, + f.created_at AS issuing_date, + f.amount_cents AS amount_cents, + f.amount_currency AS currency, + 0 AS invoices_count, + 0 AS total_refund_amount_cents + FROM fees f + LEFT JOIN subscriptions s ON f.subscription_id = s.id + LEFT JOIN customers c ON c.id = s.customer_id + WHERE c.organization_id = :organization_id + AND f.invoice_id IS NULL + AND f.pay_in_advance IS TRUE + #{and_external_customer_id_sql} + ), + combined_data AS ( + SELECT + DATE_TRUNC('month', issuing_date) AS month, + currency, + COALESCE(SUM(invoices_count), 0) AS invoices_count, + COALESCE(SUM(amount_cents), 0) AS amount_cents, + COALESCE(SUM(total_refund_amount_cents), 0) AS total_refund_amount_cents + FROM ( + SELECT * FROM issued_invoices + UNION ALL + SELECT * FROM instant_charges + ) AS gross_revenue + GROUP BY month, currency, total_refund_amount_cents + ) + SELECT + am.month, + #{select_currency_sql}, + COALESCE(SUM(invoices_count), 0) AS invoices_count, + SUM(cd.amount_cents - cd.total_refund_amount_cents) AS amount_cents + FROM all_months am + LEFT JOIN combined_data cd ON am.month = cd.month + WHERE am.month <= DATE_TRUNC('month', CURRENT_DATE) + #{and_months_sql} + #{and_currency_sql} + AND cd.amount_cents IS NOT NULL + GROUP BY am.month, cd.currency + ORDER BY am.month; + SQL + + sanitize_sql([sql, {organization_id:}.merge(args)]) + end + + def cache_key(organization_id, **args) + [ + "gross-revenue", + Date.current.strftime("%Y-%m-%d"), + organization_id, + args[:billing_entity_id], + args[:external_customer_id], + args[:currency], + args[:months] + ].join("/") + end + + def expire_cache_for_customer(organization_id, external_customer_id) + Rails.cache.delete_matched( + "gross-revenue/#{Date.current.strftime("%Y-%m-%d")}/#{organization_id}*#{external_customer_id}*" + ) + end + end + end +end diff --git a/app/models/analytics/invoice_collection.rb b/app/models/analytics/invoice_collection.rb new file mode 100644 index 0000000..12d746a --- /dev/null +++ b/app/models/analytics/invoice_collection.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module Analytics + class InvoiceCollection < Base + self.abstract_class = true + + class << self + def query(organization_id, **args) + if args[:billing_entity_id].present? + and_billing_entity_id_sql = sanitize_sql(["AND i.billing_entity_id = :billing_entity_id", args[:billing_entity_id]]) + end + + if args[:billing_entity_code].present? + and_billing_entity_code_sql = sanitize_sql( + ["AND be.code = :billing_entity_code", args[:billing_entity_code]] + ) + end + + if args[:external_customer_id].present? + and_external_customer_id_sql = sanitize_sql( + ["AND c.external_id = :external_customer_id AND c.deleted_at IS NULL", args[:external_customer_id]] + ) + end + + unless args[:is_customer_tin_empty].nil? + and_is_customer_tin_empty_sql = + if args[:is_customer_tin_empty] == true + sanitize_sql(["AND (c.tax_identification_number IS NULL OR trim(c.tax_identification_number) = '')"]) + else + sanitize_sql(["AND (c.tax_identification_number IS NOT NULL AND trim(c.tax_identification_number) <> '')"]) + end + end + + if args[:months].present? + months_interval = (args[:months].to_i <= 1) ? 0 : args[:months].to_i - 1 + + and_months_sql = sanitize_sql( + [ + "AND am.month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL ':months months')", + {months: months_interval} + ] + ) + end + + if args[:currency].present? + and_currency_sql = sanitize_sql(["AND currency = :currency", args[:currency].upcase]) + end + + sql = <<~SQL.squish + WITH organization_creation_date AS ( + SELECT + DATE_TRUNC('month', o.created_at) AS start_month + FROM organizations o + WHERE o.id = :organization_id + ), + all_months AS ( + SELECT + generate_series( + (SELECT start_month FROM organization_creation_date), + DATE_TRUNC('month', CURRENT_DATE + INTERVAL '10 years'), + interval '1 month' + ) AS month + ), + invoices_per_status AS ( + SELECT + DATE_TRUNC('month', i.issuing_date) AS month, + i.currency, + CASE + WHEN i.payment_status = 0 THEN 'pending' + WHEN i.payment_status = 1 THEN 'succeeded' + WHEN i.payment_status = 2 THEN 'failed' + END AS payment_status, + COALESCE(COUNT(*), 0) AS invoices_count, + COALESCE(SUM(i.total_amount_cents::float), 0) AS amount_cents + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + LEFT JOIN billing_entities be ON i.billing_entity_id = be.id + WHERE i.organization_id = :organization_id + AND i.self_billed IS FALSE + AND i.status = 1 + AND i.payment_dispute_lost_at IS NULL + #{and_external_customer_id_sql} + #{and_is_customer_tin_empty_sql} + #{and_billing_entity_id_sql} + #{and_billing_entity_code_sql} + GROUP BY payment_status, month, i.currency + ) + SELECT + am.month, + payment_status, + ips.currency, + COALESCE(invoices_count, 0) AS invoices_count, + COALESCE(amount_cents, 0) AS amount_cents + FROM all_months am + LEFT JOIN invoices_per_status ips ON ips.month = am.month AND ips.payment_status IS NOT NULL + WHERE am.month <= DATE_TRUNC('month', CURRENT_DATE) + #{and_months_sql} + #{and_currency_sql} + ORDER BY am.month, payment_status, ips.currency; + SQL + + sanitize_sql([sql, {organization_id:}.merge(args)]) + end + + def cache_key(organization_id, **args) + [ + "invoice-collection", + Date.current.strftime("%Y-%m-%d"), + organization_id, + args[:billing_entity_id], + args[:external_customer_id], + args[:currency], + args[:months] + ].join("/") + end + + def expire_cache_for_customer(organization_id, external_customer_id) + Rails.cache.delete_matched( + "invoice-collection/#{Date.current.strftime("%Y-%m-%d")}/#{organization_id}*#{external_customer_id}*" + ) + end + end + end +end diff --git a/app/models/analytics/invoiced_usage.rb b/app/models/analytics/invoiced_usage.rb new file mode 100644 index 0000000..a8a7c89 --- /dev/null +++ b/app/models/analytics/invoiced_usage.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Analytics + class InvoicedUsage < Base + self.abstract_class = true + + class << self + def query(organization_id, **args) + if args[:billing_entity_id].present? + and_billing_entity_id_sql = sanitize_sql(["AND i.billing_entity_id = :billing_entity_id", args[:billing_entity_id]]) + end + + if args[:months].present? + months_interval = (args[:months].to_i <= 1) ? 0 : args[:months].to_i - 1 + + and_months_sql = sanitize_sql( + [ + "AND am.month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL ':months months')", + {months: months_interval} + ] + ) + end + + if args[:currency].present? + and_currency_sql = sanitize_sql(["AND trpmb.currency = :currency", args[:currency].upcase]) + end + + sql = <<~SQL.squish + WITH organization_creation_date AS ( + SELECT + DATE_TRUNC('month', o.created_at) AS start_month + FROM organizations o + WHERE o.id = :organization_id + ), + all_months AS ( + SELECT + generate_series( + (SELECT start_month FROM organization_creation_date), + DATE_TRUNC('month', CURRENT_DATE + INTERVAL '10 years'), + interval '1 month' + ) AS month + ), + usage_fees AS ( + SELECT + f.id, + f.charge_id, + (f.amount_cents::float - f.precise_coupons_amount_cents::float) AS amount_cents, + f.amount_currency AS currency, + f.created_at AS fee_created_at + FROM fees f + LEFT JOIN invoices i ON f.invoice_id = i.id + LEFT JOIN subscriptions s ON s.id = f.subscription_id + LEFT JOIN customers c ON c.id = s.customer_id + WHERE f.invoiceable_type = 'Charge' + AND f.fee_type = 0 + AND i.self_billed IS FALSE + AND i.payment_dispute_lost_at IS NULL + #{and_billing_entity_id_sql} + AND c.organization_id = :organization_id + ), + total_revenue_per_bm AS ( + SELECT + DATE_TRUNC('month', uf.fee_created_at) AS month, + bm.code, + uf.currency, + COALESCE(SUM(amount_cents), 0) AS amount_cents + FROM usage_fees uf + LEFT JOIN charges c ON c.id = uf.charge_id + LEFT JOIN billable_metrics bm ON bm.id = c.billable_metric_id + GROUP BY month, bm.code, currency + ORDER BY month + ) + SELECT + am.month, + trpmb.code, + trpmb.currency, + trpmb.amount_cents + FROM all_months AS am + LEFT JOIN total_revenue_per_bm trpmb ON trpmb.month = am.month + WHERE am.month <= DATE_TRUNC('month', CURRENT_DATE) + #{and_months_sql} + #{and_currency_sql} + AND trpmb.currency IS NOT NULL + AND trpmb.amount_cents IS NOT NULL + ORDER BY am.month DESC, trpmb.amount_cents DESC; + SQL + + sanitize_sql([sql, {organization_id:}.merge(args)]) + end + + def cache_key(organization_id, **args) + [ + "invoiced-usage", + Date.current.strftime("%Y-%m-%d"), + organization_id, + args[:billing_entity_id], + args[:currency], + args[:months] + ].join("/") + end + end + end +end diff --git a/app/models/analytics/mrr.rb b/app/models/analytics/mrr.rb new file mode 100644 index 0000000..81b1b5a --- /dev/null +++ b/app/models/analytics/mrr.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +module Analytics + class Mrr < Base + self.abstract_class = true + + class << self + def query(organization_id, **args) + if args[:billing_entity_id].present? + and_billing_entity_id_sql = sanitize_sql(["AND i.billing_entity_id = :billing_entity_id", args[:billing_entity_id]]) + end + + if args[:months].present? + months_interval = (args[:months].to_i <= 1) ? 0 : args[:months].to_i - 1 + + and_months_sql = sanitize_sql( + [ + "AND am.month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL ':months months')", + {months: months_interval} + ] + ) + end + + if args[:currency].present? + and_currency_sql = sanitize_sql(["AND cm.currency = :currency", args[:currency].upcase]) + end + + sql = <<~SQL.squish + WITH organization_creation_date AS ( + SELECT + DATE_TRUNC('month', o.created_at) AS month + FROM organizations o + WHERE o.id = :organization_id + ), + all_months AS ( + SELECT + * + FROM generate_series( + (SELECT min(month) FROM organization_creation_date), + date_trunc('month', now()) + interval '10 years', + interval '1 month' + ) AS month + ), + invoice_details AS ( + SELECT + f.subscription_id, + f.invoice_id, + c.name, + ((f.amount_cents - f.precise_coupons_amount_cents) + f.taxes_amount_cents) AS amount_cents, + f.amount_currency AS currency, + i.issuing_date, + (EXTRACT(DAY FROM CAST(properties ->> 'to_datetime' AS timestamp) - CAST(properties ->> 'from_datetime' AS timestamp)) + + EXTRACT(HOUR FROM CAST(properties ->> 'to_datetime' AS timestamp) - CAST(properties ->> 'from_datetime' AS timestamp)) / 24 + + EXTRACT(MINUTE FROM CAST(properties ->> 'to_datetime' AS timestamp) - CAST(properties ->> 'from_datetime' AS timestamp)) / 1440) / 30.44 AS billed_months, + p.pay_in_advance, + CASE + WHEN p.interval = 0 THEN 'weekly' + WHEN p.interval = 1 THEN 'monthly' + WHEN p.interval = 2 THEN 'yearly' + WHEN p.interval = 3 THEN 'quarterly' + WHEN p.interval = 4 THEN 'semiannual' + END AS plan_interval + FROM fees f + LEFT JOIN invoices i ON f.invoice_id = i.id + LEFT JOIN customers c ON c.id = i.customer_id + LEFT JOIN organizations o ON o.id = c.organization_id + LEFT JOIN subscriptions s ON f.subscription_id = s.id + LEFT JOIN plans p ON p.id = s.plan_id + WHERE fee_type = 2 + AND c.organization_id = :organization_id + AND i.self_billed IS FALSE + AND i.status = 1 + AND i.payment_dispute_lost_at IS NULL + #{and_billing_entity_id_sql} + ORDER BY issuing_date ASC + ), + quarterly_advance AS ( + SELECT + DATE_TRUNC('month', issuing_date) + INTERVAL '1 month' * gs.month_index AS month, + CASE + WHEN gs.month_index = 0 THEN (amount_cents / billed_months) * (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))) + WHEN gs.month_index = CEIL(billed_months) - 1 THEN (amount_cents - (amount_cents / billed_months) * (FLOOR(billed_months) - 1 + (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))))) + ELSE amount_cents / billed_months + END AS amount_cents, + currency, + name + FROM invoice_details, + LATERAL GENERATE_SERIES(0, CEIL(billed_months) - 1) AS gs(month_index) + WHERE pay_in_advance = TRUE + AND plan_interval = 'quarterly' + ), + quarterly_arrears AS ( + SELECT + DATE_TRUNC('month', issuing_date) - INTERVAL '1 month' * gs.month_index AS month, + CASE + WHEN gs.month_index < CEIL(billed_months::numeric) - 1 THEN + amount_cents::numeric / billed_months::numeric + ELSE + amount_cents::numeric - (amount_cents::numeric / billed_months::numeric) * (CEIL(billed_months::numeric) - 1) + END AS amount_cents, + currency, + name + FROM invoice_details, + LATERAL GENERATE_SERIES(0, CEIL(billed_months::numeric) - 1) AS gs(month_index) + WHERE pay_in_advance = FALSE + AND plan_interval = 'quarterly' + ), + semiannual_advance AS ( + SELECT + DATE_TRUNC('month', issuing_date) + INTERVAL '1 month' * gs.month_index AS month, + CASE + WHEN gs.month_index = 0 THEN (amount_cents / billed_months) * (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))) + WHEN gs.month_index = CEIL(billed_months) - 1 THEN (amount_cents - (amount_cents / billed_months) * (FLOOR(billed_months) - 1 + (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))))) + WHEN gs.month_index = CEIL(billed_months) - 2 THEN (amount_cents - (amount_cents / billed_months) * (FLOOR(billed_months) - 2 + (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))))) + WHEN gs.month_index = CEIL(billed_months) - 3 THEN (amount_cents - (amount_cents / billed_months) * (FLOOR(billed_months) - 3 + (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))))) + WHEN gs.month_index = CEIL(billed_months) - 4 THEN (amount_cents - (amount_cents / billed_months) * (FLOOR(billed_months) - 4 + (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))))) + ELSE amount_cents / billed_months + END AS amount_cents, + currency, + name + FROM invoice_details, + LATERAL GENERATE_SERIES(0, CEIL(billed_months) - 1) AS gs(month_index) + WHERE pay_in_advance = TRUE + AND plan_interval = 'semiannual' + ), + semiannual_arrears AS ( + SELECT + DATE_TRUNC('month', issuing_date) - INTERVAL '1 month' * gs.month_index AS month, + CASE + WHEN gs.month_index < CEIL(billed_months::numeric) - 1 THEN + amount_cents::numeric / billed_months::numeric + ELSE + amount_cents::numeric - (amount_cents::numeric / billed_months::numeric) * (CEIL(billed_months::numeric) - 1) + END AS amount_cents, + currency, + name + FROM invoice_details, + LATERAL GENERATE_SERIES(0, CEIL(billed_months::numeric) - 1) AS gs(month_index) + WHERE pay_in_advance = FALSE + AND plan_interval = 'semiannual' + ), + yearly_advance AS ( + SELECT + DATE_TRUNC('month', issuing_date) + INTERVAL '1 month' * gs.month_index AS month, + CASE + WHEN gs.month_index = 0 THEN (amount_cents / billed_months) * (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))) + WHEN gs.month_index = CEIL(billed_months) - 1 THEN (amount_cents - (amount_cents / billed_months) * (FLOOR(billed_months) - 1 + (DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - issuing_date) / DATE_PART('day', DATE_TRUNC('month', issuing_date + INTERVAL '1 month') - DATE_TRUNC('month', issuing_date))))) + ELSE amount_cents / billed_months + END AS amount_cents, + currency, + name + FROM invoice_details, + LATERAL GENERATE_SERIES(0, CEIL(billed_months) - 1) AS gs(month_index) + WHERE pay_in_advance = TRUE + AND plan_interval = 'yearly' + ), + yearly_arrears AS ( + SELECT + DATE_TRUNC('month', issuing_date) - INTERVAL '1 month' * gs.month_index AS month, + CASE + WHEN gs.month_index < CEIL(billed_months::numeric) - 1 THEN + amount_cents::numeric / billed_months::numeric + ELSE + amount_cents::numeric - (amount_cents::numeric / billed_months::numeric) * (CEIL(billed_months::numeric) - 1) + END AS amount_cents, + currency, + name + FROM invoice_details, + LATERAL GENERATE_SERIES(0, CEIL(billed_months::numeric) - 1) AS gs(month_index) + WHERE pay_in_advance = FALSE + AND plan_interval = 'yearly' + ), + monthly AS ( + SELECT + DATE_TRUNC('month', issuing_date) - interval '1 month' * generate_series(0, 0, -1) AS month, + amount_cents, + currency + FROM invoice_details + WHERE plan_interval = 'monthly' + ), + weekly AS ( + SELECT + DATE_TRUNC('month', issuing_date) - interval '1 month' * generate_series(0, 0, -1) AS month, + currency, + (SUM(amount_cents) / COUNT(*)) * 4.33 AS amount_cents + FROM invoice_details + WHERE plan_interval = 'weekly' + GROUP BY month, currency + ), + consolidated_mrr AS ( + SELECT month, amount_cents::numeric, currency + FROM quarterly_arrears + UNION ALL + SELECT month, amount_cents::numeric, currency + FROM quarterly_advance + UNION ALL + SELECT month, amount_cents::numeric, currency + FROM semiannual_arrears + UNION ALL + SELECT month, amount_cents::numeric, currency + FROM semiannual_advance + UNION ALL + SELECT month, amount_cents::numeric, currency + FROM yearly_arrears + UNION ALL + SELECT month, amount_cents::numeric, currency + FROM yearly_advance + UNION ALL + SELECT month, amount_cents::numeric, currency + FROM monthly + UNION ALL + SELECT month, amount_cents::numeric, currency + FROM weekly + ) + SELECT + am.month, + cm.currency, + SUM(cm.amount_cents) AS amount_cents + FROM all_months am + LEFT JOIN consolidated_mrr cm ON cm.month = am.month + WHERE am.month <= DATE_TRUNC('month', CURRENT_DATE) + #{and_months_sql} + #{and_currency_sql} + GROUP BY am.month, cm.currency + ORDER BY am.month ASC + SQL + + sanitize_sql([sql, {organization_id:}.merge(args)]) + end + + def cache_key(organization_id, **args) + [ + "mrr", + Date.current.strftime("%Y-%m-%d"), + organization_id, + args[:billing_entity_id], + args[:currency], + args[:months] + ].join("/") + end + end + end +end diff --git a/app/models/analytics/overdue_balance.rb b/app/models/analytics/overdue_balance.rb new file mode 100644 index 0000000..0889a72 --- /dev/null +++ b/app/models/analytics/overdue_balance.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Analytics + class OverdueBalance < Base + self.abstract_class = true + + class << self + def query(organization_id, **args) + if args[:billing_entity_id].present? + and_billing_entity_id_sql = sanitize_sql(["AND i.billing_entity_id = :billing_entity_id", args[:billing_entity_id]]) + end + + if args[:billing_entity_code].present? + and_billing_entity_code_sql = sanitize_sql( + ["AND be.code = :billing_entity_code", args[:billing_entity_code]] + ) + end + + if args[:external_customer_id].present? + and_external_customer_id_sql = sanitize_sql( + ["AND c.external_id = :external_customer_id AND c.deleted_at IS NULL", args[:external_customer_id]] + ) + end + + if args[:months].present? + months_interval = (args[:months].to_i <= 1) ? 0 : args[:months].to_i - 1 + + and_months_sql = sanitize_sql( + [ + "AND am.month >= DATE_TRUNC('month', CURRENT_DATE - INTERVAL ':months months')", + {months: months_interval} + ] + ) + end + + if args[:currency].present? + and_currency_sql = sanitize_sql(["AND invs.currency = :currency", args[:currency].upcase]) + select_currency_sql = sanitize_sql(["COALESCE(invs.currency, :currency) as currency", args[:currency].upcase]) + else + select_currency_sql = "invs.currency" + end + + sql = <<~SQL.squish + WITH organization_creation_date AS ( + SELECT + DATE_TRUNC('month', o.created_at) AS start_month + FROM organizations o + WHERE o.id = :organization_id + ), + all_months AS ( + SELECT + generate_series( + (SELECT start_month FROM organization_creation_date), + DATE_TRUNC('month', CURRENT_DATE + INTERVAL '10 years'), + interval '1 month' + ) AS month + ), + payment_overdue_invoices AS ( + SELECT + DATE_TRUNC('month', payment_due_date) AS month, + i.currency, + COALESCE(SUM( + i.total_amount_cents - + i.total_paid_amount_cents - + COALESCE(cn.offset_amount_cents_sum, 0) + ), 0) AS total_amount_cents, + array_agg(DISTINCT i.id) AS ids + FROM invoices i + LEFT JOIN customers c ON i.customer_id = c.id + LEFT JOIN billing_entities be ON i.billing_entity_id = be.id + LEFT JOIN ( + SELECT invoice_id, SUM(offset_amount_cents) AS offset_amount_cents_sum + FROM credit_notes + WHERE status = #{CreditNote.statuses[:finalized]} + GROUP BY invoice_id + ) cn ON cn.invoice_id = i.id + WHERE i.organization_id = :organization_id + AND i.self_billed IS FALSE + AND i.payment_overdue IS TRUE + #{and_external_customer_id_sql} + #{and_billing_entity_id_sql} + #{and_billing_entity_code_sql} + GROUP BY month, i.currency, total_amount_cents + ORDER BY month ASC + ) + SELECT + am.month, + #{select_currency_sql}, + SUM(invs.total_amount_cents) AS amount_cents, + jsonb_agg(DISTINCT invs.ids) AS lago_invoice_ids + FROM all_months am + LEFT JOIN payment_overdue_invoices invs ON am.month = invs.month + WHERE am.month <= DATE_TRUNC('month', CURRENT_DATE) + #{and_months_sql} + #{and_currency_sql} + AND invs.total_amount_cents IS NOT NULL + GROUP BY am.month, invs.currency + ORDER BY am.month; + SQL + + sanitize_sql([sql, {organization_id:}.merge(args)]) + end + + def cache_key(organization_id, **args) + [ + "overdue-balance", + Date.current.strftime("%Y-%m-%d"), + organization_id, + args[:billing_entity_id], + args[:external_customer_id], + args[:currency], + args[:months] + ].join("/") + end + + def expire_cache_for_customer(organization_id, external_customer_id) + Rails.cache.delete_matched( + "overdue-balance/#{Date.current.strftime("%Y-%m-%d")}/#{organization_id}*#{external_customer_id}*" + ) + end + end + end +end diff --git a/app/models/api_key.rb b/app/models/api_key.rb new file mode 100644 index 0000000..24b7a8f --- /dev/null +++ b/app/models/api_key.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +class ApiKey < ApplicationRecord + include PaperTrailTraceable + + RESOURCES = %w[ + activity_log add_on analytic api_log billable_metric coupon applied_coupon credit_note customer_usage + customer event fee invoice organization payment payment_receipt payment_request payment_method plan subscription lifetime_usage + tax wallet wallet_transaction webhook_endpoint webhook_jwt_public_key invoice_custom_section + billing_entity alert feature security_log quote + ].freeze + + MODES = %w[read write].freeze + + attribute :permissions, default: -> { default_permissions } + + belongs_to :organization + + before_create :set_value + + validates :value, uniqueness: true + validates :value, presence: true, on: :update + validates :permissions, presence: true + validate :permissions_keys_compliance + validate :permissions_values_allowed + + default_scope { active } + + scope :active, -> { where("expires_at IS NULL OR expires_at > ?", Time.current) } + scope :non_expiring, -> { where(expires_at: nil) } + scope :with_most_permissions, -> { order(Arel.sql("(SELECT SUM(jsonb_array_length(value)) FROM jsonb_each(permissions))")).last } + + def flat_permissions + permissions.flat_map { |resource, modes| Array(modes).map { |mode| "#{resource}:#{mode}" } }.sort + end + + def permit?(resource, mode) + return true unless organization.api_permissions_enabled? + + Array(permissions[resource]).include?(mode) + end + + def self.default_permissions + RESOURCES.index_with { MODES.dup } + end + + def expired?(time = Time.current) + expires_at.present? && expires_at < time + end + + private + + def permissions_keys_compliance + return unless permissions + + forbidden_permissions = permissions.keys - RESOURCES + + if forbidden_permissions.any? + errors.add(:permissions, :forbidden_keys, keys: forbidden_permissions) + end + end + + def permissions_values_allowed + return unless permissions + + forbidden_values = permissions.values.flatten - MODES + + if forbidden_values.any? + errors.add(:permissions, :forbidden_values, values: forbidden_values) + end + end + + def set_value + loop do + self.value = SecureRandom.uuid + break unless self.class.exists?(value:) + end + end +end + +# == Schema Information +# +# Table name: api_keys +# Database name: primary +# +# id :uuid not null, primary key +# expires_at :datetime +# last_used_at :datetime +# name :string +# permissions :jsonb not null +# value :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_api_keys_on_organization_id (organization_id) +# index_api_keys_on_value (value) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/application_record.rb b/app/models/application_record.rb new file mode 100644 index 0000000..401b66e --- /dev/null +++ b/app/models/application_record.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ApplicationRecord < ActiveRecord::Base + primary_abstract_class + + # Avoid raising ActiveRecord::PreparedStatementCacheExpired + # from transactions when a migration is adding a new column + self.ignored_columns = [:__fake_column__] +end diff --git a/app/models/applied_add_on.rb b/app/models/applied_add_on.rb new file mode 100644 index 0000000..ad58b4d --- /dev/null +++ b/app/models/applied_add_on.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class AppliedAddOn < ApplicationRecord + include PaperTrailTraceable + include Currencies + + belongs_to :add_on + belongs_to :customer + + monetize :amount_cents + + validates :amount_cents, numericality: {greater_than: 0} + validates :amount_currency, inclusion: {in: currency_list} +end + +# == Schema Information +# +# Table name: applied_add_ons +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# amount_currency :string not null +# created_at :datetime not null +# updated_at :datetime not null +# add_on_id :uuid not null +# customer_id :uuid not null +# +# Indexes +# +# index_applied_add_ons_on_add_on_id (add_on_id) +# index_applied_add_ons_on_add_on_id_and_customer_id (add_on_id,customer_id) +# index_applied_add_ons_on_customer_id (customer_id) +# +# Foreign Keys +# +# fk_rails_... (add_on_id => add_ons.id) +# fk_rails_... (customer_id => customers.id) +# diff --git a/app/models/applied_coupon.rb b/app/models/applied_coupon.rb new file mode 100644 index 0000000..d67b396 --- /dev/null +++ b/app/models/applied_coupon.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class AppliedCoupon < ApplicationRecord + include PaperTrailTraceable + include Currencies + + belongs_to :coupon, -> { with_discarded } + belongs_to :customer + belongs_to :organization + + has_many :credits + + STATUSES = [ + :active, + :terminated + ].freeze + + FREQUENCIES = [ + :once, + :recurring, + :forever + ].freeze + + enum :status, STATUSES + enum :frequency, FREQUENCIES + + monetize :amount_cents, disable_validation: true, allow_nil: true + + validates :amount_cents, numericality: {greater_than_or_equal_to: 0}, allow_nil: true + validates :amount_currency, inclusion: {in: currency_list}, allow_nil: true + validates :frequency_duration, presence: true, numericality: {greater_than: 0}, if: :recurring? + validates :frequency_duration_remaining, presence: true, numericality: {greater_than_or_equal_to: 0}, if: :recurring? + + def mark_as_terminated!(timestamp = Time.zone.now) + self.terminated_at ||= timestamp + terminated! + end + + def remaining_amount + return @remaining_amount if defined?(@remaining_amount) + + already_applied_amount = credits.active.sum(&:amount_cents) + @remaining_amount = amount_cents - already_applied_amount + end +end + +# == Schema Information +# +# Table name: applied_coupons +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint +# amount_currency :string +# frequency :integer default("once"), not null +# frequency_duration :integer +# frequency_duration_remaining :integer +# percentage_rate :decimal(10, 5) +# status :integer default("active"), not null +# terminated_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# coupon_id :uuid not null +# customer_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_applied_coupons_on_coupon_id (coupon_id) +# index_applied_coupons_on_customer_id (customer_id) +# index_applied_coupons_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/applied_invoice_custom_section.rb b/app/models/applied_invoice_custom_section.rb new file mode 100644 index 0000000..9c4ad19 --- /dev/null +++ b/app/models/applied_invoice_custom_section.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class AppliedInvoiceCustomSection < ApplicationRecord + belongs_to :invoice + belongs_to :organization +end + +# == Schema Information +# +# Table name: applied_invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# details :string +# display_name :string +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_applied_invoice_custom_sections_on_invoice_id (invoice_id) +# index_applied_invoice_custom_sections_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/applied_pricing_unit.rb b/app/models/applied_pricing_unit.rb new file mode 100644 index 0000000..3aa41a2 --- /dev/null +++ b/app/models/applied_pricing_unit.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class AppliedPricingUnit < ApplicationRecord + belongs_to :organization + belongs_to :pricing_unit + belongs_to :pricing_unitable, polymorphic: true + + validates :conversion_rate, presence: true + validates :conversion_rate, numericality: {greater_than: 0} +end + +# == Schema Information +# +# Table name: applied_pricing_units +# Database name: primary +# +# id :uuid not null, primary key +# conversion_rate :decimal(40, 15) default(0.0), not null +# pricing_unitable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# pricing_unit_id :uuid not null +# pricing_unitable_id :uuid not null +# +# Indexes +# +# index_applied_pricing_units_on_organization_id (organization_id) +# index_applied_pricing_units_on_pricing_unit_id (pricing_unit_id) +# index_applied_pricing_units_on_pricing_unitable (pricing_unitable_type,pricing_unitable_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (pricing_unit_id => pricing_units.id) +# diff --git a/app/models/applied_usage_threshold.rb b/app/models/applied_usage_threshold.rb new file mode 100644 index 0000000..3cf6f19 --- /dev/null +++ b/app/models/applied_usage_threshold.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class AppliedUsageThreshold < ApplicationRecord + belongs_to :usage_threshold, -> { with_discarded } + belongs_to :invoice + belongs_to :organization + + validates :usage_threshold_id, uniqueness: {scope: :invoice_id} + + monetize :lifetime_usage_amount_cents, + with_currency: ->(applied_usage_threshold) { applied_usage_threshold.invoice.currency } + + monetize :passed_threshold_amount_cents, + disable_validation: true, + with_currency: ->(applied_usage_threshold) { applied_usage_threshold.invoice.currency } + + def passed_threshold_amount_cents + if usage_threshold.recurring? + lifetime_usage_amount_cents - (lifetime_usage_amount_cents % usage_threshold.amount_cents) + else + usage_threshold.amount_cents + end + end +end + +# == Schema Information +# +# Table name: applied_usage_thresholds +# Database name: primary +# +# id :uuid not null, primary key +# lifetime_usage_amount_cents :bigint default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# organization_id :uuid not null +# usage_threshold_id :uuid not null +# +# Indexes +# +# idx_on_usage_threshold_id_invoice_id_cb82cdf163 (usage_threshold_id,invoice_id) UNIQUE +# index_applied_usage_thresholds_on_invoice_id (invoice_id) +# index_applied_usage_thresholds_on_organization_id (organization_id) +# index_applied_usage_thresholds_on_usage_threshold_id (usage_threshold_id) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (usage_threshold_id => usage_thresholds.id) +# diff --git a/app/models/billable_metric.rb b/app/models/billable_metric.rb new file mode 100644 index 0000000..82b5b6e --- /dev/null +++ b/app/models/billable_metric.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +class BillableMetric < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + include IntegrationMappable + + self.discard_column = :deleted_at + + belongs_to :organization + + has_many :alerts, class_name: "UsageMonitoring::Alert" + has_many :charges, dependent: :destroy + has_many :plans, through: :charges + has_many :subscriptions, through: :plans + has_many :fees, through: :charges + has_many :invoices, through: :fees + has_many :coupon_targets + has_many :coupons, through: :coupon_targets + has_many :groups, dependent: :delete_all + has_many :filters, -> { order(:key) }, dependent: :delete_all, class_name: "BillableMetricFilter" + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + as: :resource + + AGGREGATION_TYPES = { + count_agg: 0, + sum_agg: 1, + max_agg: 2, + unique_count_agg: 3, + # NOTE: deleted aggregation type, recurring_count_agg: 4, + weighted_sum_agg: 5, + latest_agg: 6, + custom_agg: 7 + }.freeze + AGGREGATION_TYPES_PAYABLE_IN_ADVANCE = %i[count_agg sum_agg unique_count_agg custom_agg].freeze + + ROUNDING_FUNCTIONS = {round: "round", ceil: "ceil", floor: "floor"}.freeze + + UNIQUE_COUNT_OPERATION_TYPES = %w[add remove].freeze + + WEIGHTED_INTERVAL = {seconds: "seconds"}.freeze + + enum :aggregation_type, AGGREGATION_TYPES + enum :rounding_function, ROUNDING_FUNCTIONS + enum :weighted_interval, WEIGHTED_INTERVAL + + validate :validate_recurring + validate :validate_expression + + validates :name, presence: true + validates :field_name, presence: true, if: :should_have_field_name? + validates :aggregation_type, inclusion: {in: AGGREGATION_TYPES.keys.map(&:to_s)} + validates :code, + presence: true, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id} + validates :weighted_interval, + inclusion: {in: WEIGHTED_INTERVAL.values}, + if: :weighted_sum_agg? + validates :custom_aggregator, presence: true, if: :custom_agg? + validates :rounding_function, inclusion: {in: ROUNDING_FUNCTIONS.values}, allow_nil: true + + default_scope -> { kept } + + scope :with_expression, -> { where("expression IS NOT NULL AND expression <> ''") } + + def self.ransackable_attributes(_auth_object = nil) + %w[name code] + end + + def attached_subscriptions + Subscription.where( + plan_id: Charge.where( + billable_metric_id: id, + organization_id: + ).select(:plan_id), + organization_id: + ) + end + + def aggregation_type=(value) + AGGREGATION_TYPES.key?(value&.to_sym) ? super : nil + end + + def payable_in_advance? + AGGREGATION_TYPES_PAYABLE_IN_ADVANCE.include?(aggregation_type.to_sym) + end + + private + + def should_have_field_name? + !count_agg? && !custom_agg? + end + + def validate_recurring + return unless recurring? + return unless count_agg? || max_agg? || latest_agg? + + errors.add(:recurring, :not_compatible_with_aggregation_type) + end + + def validate_expression + return if expression.blank? + return if Lago::ExpressionParser.validate(expression).blank? + + errors.add(:expression, :invalid_expression) + end +end + +# == Schema Information +# +# Table name: billable_metrics +# Database name: primary +# +# id :uuid not null, primary key +# aggregation_type :integer not null +# code :string not null +# custom_aggregator :text +# deleted_at :datetime +# description :string +# expression :string +# field_name :string +# name :string not null +# properties :jsonb +# recurring :boolean default(FALSE), not null +# rounding_function :enum +# rounding_precision :integer +# weighted_interval :enum +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_billable_metrics_on_deleted_at (deleted_at) +# index_billable_metrics_on_org_id_and_code_and_expr (organization_id,code,expression) WHERE ((expression IS NOT NULL) AND ((expression)::text <> ''::text)) +# index_billable_metrics_on_organization_id (organization_id) +# index_billable_metrics_on_organization_id_and_code (organization_id,code) UNIQUE WHERE (deleted_at IS NULL) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/billable_metric_filter.rb b/app/models/billable_metric_filter.rb new file mode 100644 index 0000000..2eafeb7 --- /dev/null +++ b/app/models/billable_metric_filter.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class BillableMetricFilter < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :billable_metric, -> { with_discarded } + belongs_to :organization + + has_many :filter_values, class_name: "ChargeFilterValue", dependent: :destroy + has_many :charge_filters, through: :filter_values + + validates :key, presence: true + validates :values, presence: true + + default_scope -> { kept } +end + +# == Schema Information +# +# Table name: billable_metric_filters +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# key :string not null +# values :string default([]), not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_active_metric_filters (billable_metric_id) WHERE (deleted_at IS NULL) +# index_billable_metric_filters_on_billable_metric_id (billable_metric_id) +# index_billable_metric_filters_on_deleted_at (deleted_at) +# index_billable_metric_filters_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/billing_entity.rb b/app/models/billing_entity.rb new file mode 100644 index 0000000..7467d1f --- /dev/null +++ b/app/models/billing_entity.rb @@ -0,0 +1,230 @@ +# frozen_string_literal: true + +class BillingEntity < ApplicationRecord + include PaperTrailTraceable + include OrganizationTimezone + include Currencies + include Discard::Model + + self.discard_column = :deleted_at + + EMAIL_SETTINGS = [ + "invoice.finalized", + "credit_note.created", + "payment_receipt.created" + ] + + EINVOICING_COUNTRIES = %w[FR].map(&:upcase) + + SUBSCRIPTION_INVOICE_ISSUING_DATE_ANCHORS = { + current_period_end: "current_period_end", + next_period_start: "next_period_start" + }.freeze + + SUBSCRIPTION_INVOICE_ISSUING_DATE_ADJUSTMENTS = { + keep_anchor: "keep_anchor", + align_with_finalization_date: "align_with_finalization_date" + }.freeze + + belongs_to :organization + belongs_to :applied_dunning_campaign, class_name: "DunningCampaign", optional: true + + has_many :applied_taxes, class_name: "BillingEntity::AppliedTax", dependent: :destroy + has_many :customers + has_many :fees + has_many :invoices + has_many :pending_vies_checks + has_many :payment_receipts + has_many :applied_invoice_custom_sections, + class_name: "BillingEntity::AppliedInvoiceCustomSection", + dependent: :destroy + has_many :integration_collection_mappings, + class_name: "IntegrationCollectionMappings::BaseCollectionMapping", + dependent: :destroy + has_many :integration_mappings, + class_name: "IntegrationMappings::BaseMapping", + dependent: :destroy + + has_many :selected_invoice_custom_sections, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + has_many :manual_selected_invoice_custom_sections, + -> { where(section_type: :manual) }, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + has_many :system_generated_selected_invoice_custom_sections, + -> { where(section_type: :system_generated) }, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + + has_many :credit_notes, through: :invoices + has_many :subscriptions, through: :customers + has_many :taxes, through: :applied_taxes + has_many :wallets, through: :customers + has_many :wallet_transactions, through: :wallets + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + as: :resource + + has_one_attached :logo + + DOCUMENT_NUMBERINGS = { + per_customer: "per_customer", + per_billing_entity: "per_billing_entity" + }.freeze + + enum :document_numbering, DOCUMENT_NUMBERINGS + + enum :subscription_invoice_issuing_date_anchor, SUBSCRIPTION_INVOICE_ISSUING_DATE_ANCHORS, prefix: true, validate: true + enum :subscription_invoice_issuing_date_adjustment, SUBSCRIPTION_INVOICE_ISSUING_DATE_ADJUSTMENTS, prefix: true, validate: true + + default_scope -> { kept } + scope :active, -> { where(archived_at: nil).order(created_at: :asc) } + + validates :code, + uniqueness: { + conditions: -> { where(archived_at: nil, deleted_at: nil) }, + scope: :organization_id + } + validates :country, country_code: true, unless: -> { country.nil? } + validates :default_currency, inclusion: {in: currency_list} + validates :document_locale, language_code: true + validates :email, email: true, if: :email? + validates :invoice_footer, length: {maximum: 600} + validates :document_number_prefix, length: {minimum: 1, maximum: 10}, allow_nil: true, on: :create + validates :document_number_prefix, length: {minimum: 1, maximum: 10}, on: :update + validates :invoice_grace_period, numericality: {greater_than_or_equal_to: 0} + validates :net_payment_term, numericality: {greater_than_or_equal_to: 0} + validates :logo, + image: {authorized_content_type: %w[image/png image/jpg image/jpeg], max_size: 800.kilobytes}, + if: :logo? + validates :name, presence: true + validates :timezone, timezone: true + validates :finalize_zero_amount_invoice, inclusion: {in: [true, false]} + + validate :validate_email_settings + validate :validate_einvoicing + + normalizes :email, with: ->(email) { EmailSanitizer.call(email) } + + after_create :generate_document_number_prefix + + def country=(value) + super(value&.upcase) + end + + def document_number_prefix=(value) + super(value&.upcase) + end + + def logo_url + return if logo.blank? + + Rails.application.routes.url_helpers.rails_blob_url(logo, host: ENV["LAGO_API_URL"]) + end + + def base64_logo + return if logo.blank? + + logo.blob.open do |tempfile| + data = tempfile.read + Base64.encode64(data) + end + end + + def eu_vat_eligible? + country && LagoEuVat::Rate.country_codes.include?(country) + end + + def from_email_address + return email if organization.from_email_enabled? + + ENV["LAGO_FROM_EMAIL"] + end + + def reset_customers_last_dunning_campaign_attempt + customers + .falling_back_to_default_dunning_campaign + .update_all( # rubocop:disable Rails/SkipsModelValidations + last_dunning_campaign_attempt: 0, + last_dunning_campaign_attempt_at: nil + ) + end + + private + + def generate_document_number_prefix + update!(document_number_prefix: "#{name.first(3).upcase}-#{id.last(4).upcase}") if document_number_prefix.nil? + end + + def validate_email_settings + return if email_settings.all? { |v| EMAIL_SETTINGS.include?(v) } + + errors.add(:email_settings, :unsupported_value) + end + + def validate_einvoicing + return unless einvoicing + + if country.nil? + errors.add(:einvoicing, :country_must_be_present) + elsif EINVOICING_COUNTRIES.exclude?(country.upcase) + errors.add(:einvoicing, :country_not_supported) + end + end +end + +# == Schema Information +# +# Table name: billing_entities +# Database name: primary +# +# id :uuid not null, primary key +# address_line1 :string +# address_line2 :string +# archived_at :datetime +# city :string +# code :string not null +# country :string +# default_currency :string default("USD"), not null +# deleted_at :datetime +# document_locale :string default("en"), not null +# document_number_prefix :string +# document_numbering :enum default("per_customer"), not null +# einvoicing :boolean default(FALSE), not null +# email :string +# email_settings :string default([]), not null, is an Array +# eu_tax_management :boolean default(FALSE) +# finalize_zero_amount_invoice :boolean default(TRUE), not null +# invoice_footer :text +# invoice_grace_period :integer default(0), not null +# legal_name :string +# legal_number :string +# logo :string +# name :string not null +# net_payment_term :integer default(0), not null +# state :string +# subscription_invoice_issuing_date_adjustment :enum default("align_with_finalization_date"), not null +# subscription_invoice_issuing_date_anchor :enum default("next_period_start"), not null +# tax_identification_number :string +# timezone :string default("UTC"), not null +# vat_rate :float default(0.0), not null +# zipcode :string +# created_at :datetime not null +# updated_at :datetime not null +# applied_dunning_campaign_id :uuid +# organization_id :uuid not null +# +# Indexes +# +# index_billing_entities_on_applied_dunning_campaign_id (applied_dunning_campaign_id) +# index_billing_entities_on_code_and_organization_id (code,organization_id) UNIQUE WHERE ((deleted_at IS NULL) AND (archived_at IS NULL)) +# index_billing_entities_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (applied_dunning_campaign_id => dunning_campaigns.id) ON DELETE => nullify +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/billing_entity/applied_invoice_custom_section.rb b/app/models/billing_entity/applied_invoice_custom_section.rb new file mode 100644 index 0000000..3c22d01 --- /dev/null +++ b/app/models/billing_entity/applied_invoice_custom_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class BillingEntity::AppliedInvoiceCustomSection < ApplicationRecord + self.table_name = "billing_entities_invoice_custom_sections" + + belongs_to :organization + belongs_to :billing_entity + belongs_to :invoice_custom_section +end + +# == Schema Information +# +# Table name: billing_entities_invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid not null +# invoice_custom_section_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# idx_on_billing_entity_id_724373e5ae (billing_entity_id) +# idx_on_billing_entity_id_invoice_custom_section_id_bd78c547d3 (billing_entity_id,invoice_custom_section_id) UNIQUE +# idx_on_invoice_custom_section_id_ccb39e9622 (invoice_custom_section_id) +# idx_on_organization_id_83703a45f4 (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (invoice_custom_section_id => invoice_custom_sections.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/billing_entity/applied_tax.rb b/app/models/billing_entity/applied_tax.rb new file mode 100644 index 0000000..41a9e6b --- /dev/null +++ b/app/models/billing_entity/applied_tax.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class BillingEntity + class AppliedTax < ApplicationRecord + self.table_name = "billing_entities_taxes" + + belongs_to :billing_entity + belongs_to :tax + belongs_to :organization + + validates :tax_id, uniqueness: {scope: :billing_entity_id} + end +end + +# == Schema Information +# +# Table name: billing_entities_taxes +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid not null +# +# Indexes +# +# index_billing_entities_taxes_on_billing_entity_id (billing_entity_id) +# index_billing_entities_taxes_on_billing_entity_id_and_tax_id (billing_entity_id,tax_id) UNIQUE +# index_billing_entities_taxes_on_organization_id (organization_id) +# index_billing_entities_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/billing_period_boundaries.rb b/app/models/billing_period_boundaries.rb new file mode 100644 index 0000000..edc3035 --- /dev/null +++ b/app/models/billing_period_boundaries.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class BillingPeriodBoundaries + attr_reader :from_datetime, + :to_datetime, + :charges_from_datetime, + :charges_duration, + :timestamp, + :issuing_date, + :fixed_charges_from_datetime, + :fixed_charges_to_datetime, + :fixed_charges_duration + + attr_accessor :charges_to_datetime, + :max_timestamp # Used to limit event timestamp when filling the daily usage + + def self.from_fee(fee) + props = fee&.properties || {} + + new( + from_datetime: props["from_datetime"], + to_datetime: props["to_datetime"], + charges_from_datetime: props["charges_from_datetime"], + charges_to_datetime: props["charges_to_datetime"], + charges_duration: props["charges_duration"], + timestamp: props["timestamp"], + issuing_date: props["issuing_date"], + fixed_charges_from_datetime: props["fixed_charges_from_datetime"], + fixed_charges_to_datetime: props["fixed_charges_to_datetime"], + fixed_charges_duration: props["fixed_charges_duration"] + ) + end + + def initialize( + from_datetime:, + to_datetime:, + charges_from_datetime:, + charges_to_datetime:, + charges_duration:, + timestamp:, + fixed_charges_from_datetime: nil, + fixed_charges_to_datetime: nil, + fixed_charges_duration: nil, + issuing_date: nil, + max_timestamp: nil + ) + @from_datetime = from_datetime + @to_datetime = to_datetime + @charges_from_datetime = charges_from_datetime + @charges_to_datetime = charges_to_datetime + @charges_duration = charges_duration + @timestamp = timestamp + @issuing_date = issuing_date + @fixed_charges_from_datetime = fixed_charges_from_datetime + @fixed_charges_to_datetime = fixed_charges_to_datetime + @fixed_charges_duration = fixed_charges_duration + @max_timestamp = max_timestamp + end + + def to_h + h = { + "from_datetime" => from_datetime, + "to_datetime" => to_datetime, + "charges_from_datetime" => charges_from_datetime, + "charges_to_datetime" => charges_to_datetime, + "charges_duration" => charges_duration, + "timestamp" => timestamp, + "fixed_charges_from_datetime" => fixed_charges_from_datetime, + "fixed_charges_to_datetime" => fixed_charges_to_datetime, + "fixed_charges_duration" => fixed_charges_duration + }.with_indifferent_access + h["issuing_date"] = issuing_date if issuing_date.present? + h["max_timestamp"] = max_timestamp if max_timestamp.present? + h + end +end diff --git a/app/models/cached_aggregation.rb b/app/models/cached_aggregation.rb new file mode 100644 index 0000000..50a9f7e --- /dev/null +++ b/app/models/cached_aggregation.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class CachedAggregation < ApplicationRecord + self.ignored_columns += %w[event_id] + + belongs_to :organization + belongs_to :charge + belongs_to :group, optional: true + belongs_to :charge_filter, optional: true + + validates :external_subscription_id, presence: true + validates :timestamp, presence: true + + scope :from_datetime, ->(from_datetime) { where("cached_aggregations.timestamp >= ?", from_datetime&.change(usec: 0)) } + scope :to_datetime, ->(to_datetime) { where("cached_aggregations.timestamp <= ?", to_datetime&.change(usec: 0)) } +end + +# == Schema Information +# +# Table name: cached_aggregations +# Database name: primary +# +# id :uuid not null, primary key +# current_aggregation :decimal(, ) +# current_amount :decimal(, ) +# grouped_by :jsonb not null +# max_aggregation :decimal(, ) +# max_aggregation_with_proration :decimal(, ) +# timestamp :datetime not null +# created_at :datetime not null +# updated_at :datetime not null +# charge_filter_id :uuid +# charge_id :uuid not null +# event_transaction_id :string +# external_subscription_id :string not null +# group_id :uuid +# organization_id :uuid not null +# +# Indexes +# +# idx_aggregation_lookup (external_subscription_id,charge_id,timestamp) +# idx_cached_aggregation_filtered_lookup (organization_id,external_subscription_id,charge_id,timestamp DESC,created_at DESC) +# index_cached_aggregations_on_charge_id (charge_id) +# index_cached_aggregations_on_event_transaction_id (organization_id,event_transaction_id) +# index_cached_aggregations_on_external_subscription_id (external_subscription_id) +# +# Foreign Keys +# +# fk_rails_... (group_id => groups.id) +# diff --git a/app/models/charge.rb b/app/models/charge.rb new file mode 100644 index 0000000..8680197 --- /dev/null +++ b/app/models/charge.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +class Charge < ApplicationRecord + include PaperTrailTraceable + include Currencies + include ChargePropertiesValidation + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + belongs_to :plan, -> { with_discarded }, touch: true + belongs_to :billable_metric, -> { with_discarded } + belongs_to :parent, class_name: "Charge", optional: true + + has_one :applied_pricing_unit, as: :pricing_unitable + has_one :pricing_unit, through: :applied_pricing_unit + + has_many :children, class_name: "Charge", foreign_key: :parent_id, dependent: :nullify + has_many :fees + has_many :filters, dependent: :destroy, class_name: "ChargeFilter" + has_many :filter_values, through: :filters, class_name: "ChargeFilterValue", source: :values + + has_many :applied_taxes, class_name: "Charge::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + + EVENT_TARGET_WALLET_CODE = "target_wallet_code" + + CHARGE_MODELS = %i[ + standard + graduated + package + percentage + volume + graduated_percentage + custom + dynamic + ].freeze + + REGROUPING_PAID_FEES_OPTIONS = %i[invoice].freeze + + enum :charge_model, CHARGE_MODELS, validate: true + + attribute :regroup_paid_fees, :integer + enum :regroup_paid_fees, REGROUPING_PAID_FEES_OPTIONS + + validate :validate_properties + validate :validate_dynamic, if: -> { dynamic? } + validates :min_amount_cents, numericality: {greater_than_or_equal_to: 0}, allow_nil: true + validates :charge_model, :code, presence: true + + validate :validate_code_unique + validate :charge_model_allowance + validate :validate_pay_in_advance + validate :validate_regroup_paid_fees + validate :validate_prorated + validate :validate_min_amount_cents + validate :validate_custom_model + validate :validate_invoiceable_unless_pay_in_advance + validate :validate_accepts_target_wallet, if: -> { accepts_target_wallet_changed? } + + default_scope -> { kept } + + scope :pay_in_advance, -> { where(pay_in_advance: true) } + scope :parents, -> { where(parent_id: nil) } + + def pricing_group_keys + properties["pricing_group_keys"].presence || properties["grouped_by"] + end + + def presentation_group_keys + properties["presentation_group_keys"].presence + end + + def presentation_group_keys_values + return [] if presentation_group_keys.blank? + + presentation_group_keys.map { |e| e.fetch("value", nil) }.compact + end + + def equal_properties?(charge) + charge_model == charge.charge_model && properties == charge.properties + end + + def equal_applied_pricing_unit_rate?(another_charge) + return false unless applied_pricing_unit && another_charge.applied_pricing_unit + + applied_pricing_unit.conversion_rate == another_charge.applied_pricing_unit.conversion_rate + end + + # NOTE: If same charge is NOT included in upgraded plan we still want to bill it. However if new plan is using + # the same charge it should not be billed since it is recurring and will be billed at the end of period + def included_in_next_subscription?(subscription) + return false if subscription.next_subscription.nil? + + next_subscription_charges = subscription.next_subscription.plan.charges + + return false if next_subscription_charges.blank? + + next_subscription_charges.pluck(:billable_metric_id).include?(billable_metric_id) + end + + private + + def validate_properties + validate_charge_model_properties(charge_model) + end + + def validate_invoiceable_unless_pay_in_advance + return if pay_in_advance? || invoiceable? + + errors.add(:invoiceable, :must_be_true_unless_pay_in_advance) + end + + def validate_dynamic + # Only sum aggregation is compatible with Dynamic Pricing for now + return if billable_metric.sum_agg? + + errors.add(:charge_model, :invalid_aggregation_type_or_charge_model) + end + + def validate_pay_in_advance + return unless pay_in_advance? + + if volume? || !billable_metric.payable_in_advance? + errors.add(:pay_in_advance, :invalid_aggregation_type_or_charge_model) + end + end + + # NOTE: regroup_paid_fees only works with pay_in_advance and non-invoiceable charges + def validate_regroup_paid_fees + return if regroup_paid_fees.nil? + return if pay_in_advance? && !invoiceable? + + errors.add(:regroup_paid_fees, :only_compatible_with_pay_in_advance_and_non_invoiceable) + end + + def validate_min_amount_cents + return unless pay_in_advance? && min_amount_cents.positive? + + errors.add(:min_amount_cents, :not_compatible_with_pay_in_advance) + end + + # NOTE: A prorated charge cannot be created in the following cases: + # - for metered charges, + # - for pay_in_arrears, price model cannot be package, graduated and percentage + # - for pay_in_advance, price model cannot be package, graduated, percentage and volume + # - for weighted_sum aggregation as it already apply pro-ration logic + def validate_prorated + return unless prorated? + + unless billable_metric.weighted_sum_agg? + return if billable_metric.recurring? && pay_in_advance? && standard? + return if billable_metric.recurring? && !pay_in_advance? && (standard? || volume? || graduated?) + end + + errors.add(:prorated, :invalid_billable_metric_or_charge_model) + end + + def validate_custom_model + return unless custom? + return if billable_metric.custom_agg? + + errors.add(:charge_model, :invalid_aggregation_type_or_charge_model) + end + + def charge_model_allowance + if graduated_percentage? && !License.premium? + errors.add(:charge_model, :graduated_percentage_requires_premium_license) + end + end + + def validate_code_unique + return unless plan + return if parent_id? + + charge = plan.charges.parents.where(code:).first + errors.add(:code, :taken) if charge && charge != self + end + + def validate_accepts_target_wallet + return unless accepts_target_wallet + + errors.add(:accepts_target_wallet, :feature_unavailable) unless organization.events_targeting_wallets_enabled? + end +end + +# == Schema Information +# +# Table name: charges +# Database name: primary +# +# id :uuid not null, primary key +# accepts_target_wallet :boolean default(FALSE), not null +# amount_currency :string +# charge_model :integer default("standard"), not null +# code :string not null +# deleted_at :datetime +# invoice_display_name :string +# invoiceable :boolean default(TRUE), not null +# min_amount_cents :bigint default(0), not null +# pay_in_advance :boolean default(FALSE), not null +# properties :jsonb not null +# prorated :boolean default(FALSE), not null +# regroup_paid_fees :integer +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# parent_id :uuid +# plan_id :uuid +# +# Indexes +# +# idx_on_plan_id_billable_metric_id_pay_in_advance_4a205974cb (plan_id,billable_metric_id,pay_in_advance) WHERE (deleted_at IS NULL) +# index_charges_on_accepts_target_wallet (accepts_target_wallet) WHERE (accepts_target_wallet = true) +# index_charges_on_billable_metric_id (billable_metric_id) WHERE (deleted_at IS NULL) +# index_charges_on_deleted_at (deleted_at) +# index_charges_on_organization_id (organization_id) +# index_charges_on_parent_id (parent_id) +# index_charges_on_plan_id (plan_id) +# index_charges_on_plan_id_and_code (plan_id,code) UNIQUE WHERE ((deleted_at IS NULL) AND (parent_id IS NULL)) +# index_charges_pay_in_advance (billable_metric_id) WHERE ((deleted_at IS NULL) AND (pay_in_advance = true)) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (parent_id => charges.id) +# fk_rails_... (plan_id => plans.id) +# diff --git a/app/models/charge/applied_tax.rb b/app/models/charge/applied_tax.rb new file mode 100644 index 0000000..762361d --- /dev/null +++ b/app/models/charge/applied_tax.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Charge + class AppliedTax < ApplicationRecord + self.table_name = "charges_taxes" + + belongs_to :charge + belongs_to :tax + belongs_to :organization + end +end + +# == Schema Information +# +# Table name: charges_taxes +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# charge_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid not null +# +# Indexes +# +# index_charges_taxes_on_charge_id (charge_id) +# index_charges_taxes_on_charge_id_and_tax_id (charge_id,tax_id) UNIQUE +# index_charges_taxes_on_organization_id (organization_id) +# index_charges_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (charge_id => charges.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/charge_filter.rb b/app/models/charge_filter.rb new file mode 100644 index 0000000..aa19b8f --- /dev/null +++ b/app/models/charge_filter.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +class ChargeFilter < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + include ChargePropertiesValidation + + self.discard_column = :deleted_at + + belongs_to :charge, -> { with_discarded }, touch: true + belongs_to :organization + + has_many :values, class_name: "ChargeFilterValue", dependent: :destroy + has_many :billable_metric_filters, through: :values + has_many :fees + + has_one :billable_metric, through: :charge + + validate :validate_properties + + # NOTE: Ensure filters are keeping the initial ordering + default_scope -> { kept.order(updated_at: :asc) } + + def display_name(separator: ", ") + invoice_display_name.presence || (values.map do |value| + next value.billable_metric_filter.key if value.values == [ChargeFilterValue::ALL_FILTER_VALUES] + + value.values + end).flatten.join(separator) + end + + def to_h + @to_h ||= values.each_with_object({}) do |filter_value, result| + result[filter_value.billable_metric_filter.key] = filter_value.values + end.freeze + end + + def to_h_with_discarded + @to_h_with_discarded ||= values.with_discarded.each_with_object({}) do |filter_value, result| + result[filter_value.billable_metric_filter.key] = filter_value.values + end.freeze + end + + def to_h_with_all_values + @to_h_with_all_values ||= values.each_with_object({}) do |filter_value, result| + values = filter_value.values + values = filter_value.billable_metric_filter.values if values == [ChargeFilterValue::ALL_FILTER_VALUES] + + result[filter_value.billable_metric_filter.key] = values + end.freeze + end + + def pricing_group_keys + properties["pricing_group_keys"].presence || properties["grouped_by"] + end + + private + + def validate_properties + validate_charge_model_properties(charge&.charge_model) + end +end + +# == Schema Information +# +# Table name: charge_filters +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# invoice_display_name :string +# properties :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# charge_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_active_charge_filters (charge_id) WHERE (deleted_at IS NULL) +# index_charge_filters_on_charge_id (charge_id) +# index_charge_filters_on_deleted_at (deleted_at) +# index_charge_filters_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (charge_id => charges.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/charge_filter_value.rb b/app/models/charge_filter_value.rb new file mode 100644 index 0000000..1f8cc66 --- /dev/null +++ b/app/models/charge_filter_value.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class ChargeFilterValue < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + + ALL_FILTER_VALUES = "__ALL_FILTER_VALUES__" + + belongs_to :charge_filter, -> { with_discarded } + belongs_to :billable_metric_filter, -> { with_discarded } + belongs_to :organization + + validates :values, presence: true + validate :validate_values + + # NOTE: Ensure filters are keeping the initial ordering + default_scope -> { kept.order(updated_at: :asc) } + + delegate :key, to: :billable_metric_filter + + private + + def validate_values + unless values.nil? + return if values.count == 1 && values.first == ALL_FILTER_VALUES + return if values.all? { billable_metric_filter&.values&.include?(it) } # rubocop:disable Performance/InefficientHashSearch + end + + errors.add(:values, :inclusion) + end +end + +# == Schema Information +# +# Table name: charge_filter_values +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# values :string default([]), not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_filter_id :uuid not null +# charge_filter_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_active_charge_filter_values (charge_filter_id) WHERE (deleted_at IS NULL) +# index_charge_filter_values_on_billable_metric_filter_id (billable_metric_filter_id) +# index_charge_filter_values_on_charge_filter_id (charge_filter_id) +# index_charge_filter_values_on_deleted_at (deleted_at) +# index_charge_filter_values_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_filter_id => billable_metric_filters.id) +# fk_rails_... (charge_filter_id => charge_filters.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/clickhouse/activity_log.rb b/app/models/clickhouse/activity_log.rb new file mode 100644 index 0000000..cd75269 --- /dev/null +++ b/app/models/clickhouse/activity_log.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Clickhouse + class ActivityLog < BaseRecord + self.table_name = "activity_logs" + self.primary_key = nil + + belongs_to :organization + belongs_to :resource, polymorphic: true + + belongs_to :customer, + -> { with_discarded }, + primary_key: :external_id, + foreign_key: :external_customer_id, + optional: true + + belongs_to :subscription, + primary_key: :external_id, + foreign_key: :external_subscription_id, + optional: true + + belongs_to :user, optional: true + belongs_to :api_key, optional: true + + RESOURCE_TYPES_WITH_DISCARDED = %w[BillableMetric Plan Customer BillingEntity Coupon].freeze + + RESOURCE_TYPES = { + billable_metric: "BillableMetric", + plan: "Plan", + customer: "Customer", + invoice: "Invoice", + credit_note: "CreditNote", + billing_entity: "BillingEntity", + subscription: "Subscription", + wallet: "Wallet", + coupon: "Coupon", + payment_receipt: "PaymentReceipt", + payment_request: "PaymentRequest", + feature: "Entitlement::Feature" + }.freeze + + ACTIVITY_TYPES = { + billable_metric_created: "billable_metric.created", + billable_metric_updated: "billable_metric.updated", + billable_metric_deleted: "billable_metric.deleted", + plan_created: "plan.created", + plan_updated: "plan.updated", + plan_deleted: "plan.deleted", + customer_created: "customer.created", + customer_updated: "customer.updated", + customer_deleted: "customer.deleted", + invoice_drafted: "invoice.drafted", + invoice_failed: "invoice.failed", + invoice_created: "invoice.created", + invoice_one_off_created: "invoice.one_off_created", + invoice_paid_credit_added: "invoice.paid_credit_added", + invoice_generated: "invoice.generated", + invoice_payment_status_updated: "invoice.payment_status_updated", + invoice_payment_overdue: "invoice.payment_overdue", + invoice_voided: "invoice.voided", + invoice_regenerated: "invoice.regenerated", + invoice_payment_failure: "invoice.payment_failure", + payment_receipt_created: "payment_receipt.created", + payment_receipt_generated: "payment_receipt.generated", + credit_note_created: "credit_note.created", + credit_note_generated: "credit_note.generated", + credit_note_refund_failure: "credit_note.refund_failure", + billing_entities_created: "billing_entities.created", + billing_entities_updated: "billing_entities.updated", + billing_entities_deleted: "billing_entities.deleted", + subscription_canceled: "subscription.canceled", + subscription_incomplete: "subscription.incomplete", + subscription_started: "subscription.started", + subscription_terminated: "subscription.terminated", + subscription_updated: "subscription.updated", + wallet_created: "wallet.created", + wallet_updated: "wallet.updated", + wallet_transaction_payment_failure: "wallet_transaction.payment_failure", + wallet_transaction_created: "wallet_transaction.created", + wallet_transaction_updated: "wallet_transaction.updated", + payment_recorded: "payment.recorded", + coupon_created: "coupon.created", + coupon_updated: "coupon.updated", + coupon_deleted: "coupon.deleted", + applied_coupon_created: "applied_coupon.created", + applied_coupon_deleted: "applied_coupon.deleted", + payment_request_created: "payment_request.created", + email_sent: "email.sent", + feature_created: "feature.created", + feature_deleted: "feature.deleted", + feature_updated: "feature.updated" + } + + before_save :ensure_activity_id + + # TODO: Remove this once we have soft deletion everywhere + def resource + return nil if resource_type.blank? || resource_id.blank? + + klass = resource_type.safe_constantize + if RESOURCE_TYPES_WITH_DISCARDED.include?(resource_type) + klass.with_discarded.find_by(organization_id: organization.id, id: resource_id) + else + klass.find_by(organization_id: organization.id, id: resource_id) + end + end + + private + + def ensure_activity_id + self.activity_id = SecureRandom.uuid if activity_id.blank? + end + end +end + +# == Schema Information +# +# Table name: activity_logs +# Database name: clickhouse +# +# activity_object :string +# activity_object_changes :string +# activity_source :Enum8('api' = 1, not null +# activity_type :string not null +# logged_at :datetime not null +# resource_type :string not null +# created_at :datetime not null +# activity_id :string not null +# api_key_id :string +# external_customer_id :string +# external_subscription_id :string +# organization_id :string not null +# resource_id :string not null +# user_id :string +# diff --git a/app/models/clickhouse/api_log.rb b/app/models/clickhouse/api_log.rb new file mode 100644 index 0000000..6b061c4 --- /dev/null +++ b/app/models/clickhouse/api_log.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Clickhouse + class ApiLog < BaseRecord + self.table_name = "api_logs" + self.primary_key = nil + + belongs_to :organization + belongs_to :api_key + + before_save :ensure_request_id + + HTTP_METHODS = { + get: 1, + post: 2, + put: 3, + delete: 4 + }.freeze + + private + + def ensure_request_id + self.request_id = SecureRandom.uuid if request_id.blank? + end + end +end + +# == Schema Information +# +# Table name: api_logs +# Database name: clickhouse +# +# api_version :string not null +# client :string not null +# http_method :Enum8('get' = 1, not null +# http_status :integer not null +# logged_at :datetime not null +# request_body :string not null +# request_origin :string not null +# request_path :string not null +# request_response :string +# created_at :datetime not null +# api_key_id :string not null +# organization_id :string not null +# request_id :string not null +# diff --git a/app/models/clickhouse/base_record.rb b/app/models/clickhouse/base_record.rb new file mode 100644 index 0000000..4d71b98 --- /dev/null +++ b/app/models/clickhouse/base_record.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Clickhouse + class BaseRecord < ApplicationRecord + self.abstract_class = true + self.ignored_columns = [] # Override ApplicationRecord settings + + connects_to database: {writing: :clickhouse, reading: :clickhouse} + end +end diff --git a/app/models/clickhouse/events_dead_letter.rb b/app/models/clickhouse/events_dead_letter.rb new file mode 100644 index 0000000..a8ac9f6 --- /dev/null +++ b/app/models/clickhouse/events_dead_letter.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Clickhouse + class EventsDeadLetter < BaseRecord + self.table_name = "events_dead_letter" + self.primary_key = nil + + belongs_to :organization + end +end + +# == Schema Information +# +# Table name: events_dead_letter +# Database name: clickhouse +# +# code :string not null +# error_code :string not null +# error_message :string not null +# event :json not null +# failed_at :datetime not null +# ingested_at :datetime not null +# initial_error_message :string not null +# timestamp :datetime not null +# external_subscription_id :string not null +# organization_id :string not null +# transaction_id :string not null +# diff --git a/app/models/clickhouse/events_enriched.rb b/app/models/clickhouse/events_enriched.rb new file mode 100644 index 0000000..51e3fbe --- /dev/null +++ b/app/models/clickhouse/events_enriched.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Clickhouse + class EventsEnriched < BaseRecord + self.table_name = "events_enriched" + end +end + +# == Schema Information +# +# Table name: events_enriched +# Database name: clickhouse +# +# code :string not null, primary key +# decimal_value :decimal(38, 26) +# enriched_at :datetime not null +# precise_total_amount_cents :decimal(40, 15) +# properties :string not null +# sorted_properties :string not null +# timestamp :datetime not null, primary key +# value :string +# external_subscription_id :string not null, primary key +# organization_id :string not null, primary key +# transaction_id :string not null +# diff --git a/app/models/clickhouse/events_enriched_expanded.rb b/app/models/clickhouse/events_enriched_expanded.rb new file mode 100644 index 0000000..4dd7f03 --- /dev/null +++ b/app/models/clickhouse/events_enriched_expanded.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Clickhouse + class EventsEnrichedExpanded < BaseRecord + self.table_name = "events_enriched_expanded" + end +end + +# == Schema Information +# +# Table name: events_enriched_expanded +# Database name: clickhouse +# +# aggregation_type :string not null +# charge_filter_version :datetime +# charge_version :datetime +# code :string not null, primary key +# decimal_value :decimal(38, 26) +# enriched_at :datetime not null +# grouped_by :json not null +# precise_total_amount_cents :decimal(40, 15) +# properties :json not null +# sorted_grouped_by :string not null +# sorted_properties :string not null +# timestamp :datetime not null, primary key +# value :string +# charge_filter_id :string default(""), not null, primary key +# charge_id :string default(""), not null, primary key +# external_subscription_id :string not null, primary key +# organization_id :string not null, primary key +# plan_id :string default(""), not null +# subscription_id :string default(""), not null +# transaction_id :string not null +# diff --git a/app/models/clickhouse/events_raw.rb b/app/models/clickhouse/events_raw.rb new file mode 100644 index 0000000..049c40b --- /dev/null +++ b/app/models/clickhouse/events_raw.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Clickhouse + class EventsRaw < BaseRecord + self.table_name = "events_raw" + self.primary_key = nil + + def id + "#{organization_id}-#{external_subscription_id}-#{transaction_id}-#{ingested_at.to_i}" + end + + def created_at + ingested_at + end + + def billable_metric + BillableMetric.find_by(code:, organization_id:) + end + + def api_client + end + + def ip_address + end + + def subscription + organization.subscriptions + .where(external_id: external_subscription_id) + .where("date_trunc('millisecond', started_at::timestamp) <= ?::timestamp", timestamp) + .where("terminated_at is NULL OR date_trunc('millisecond', terminated_at::timestamp) >= ?::timestamp", timestamp) + .order("terminated_at DESC NULLS FIRST, started_at DESC") + .first + end + + def subscription_id + subscription&.id + end + + def organization + Organization.find_by(id: organization_id) + end + + private + + delegate :customer, :customer_id, to: :subscription, allow_nil: true + end +end + +# == Schema Information +# +# Table name: events_raw +# Database name: clickhouse +# +# code :string not null +# ingested_at :datetime not null +# precise_total_amount_cents :decimal(40, 15) +# properties :string not null +# timestamp :datetime not null +# external_customer_id :string not null +# external_subscription_id :string not null +# organization_id :string not null +# transaction_id :string not null +# diff --git a/app/models/clickhouse/security_log.rb b/app/models/clickhouse/security_log.rb new file mode 100644 index 0000000..4e88109 --- /dev/null +++ b/app/models/clickhouse/security_log.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Clickhouse + class SecurityLog < BaseRecord + self.table_name = "security_logs" + self.primary_key = nil + + belongs_to :organization + belongs_to :user, optional: true + belongs_to :api_key, optional: true + + default_scope -> { where(logged_at: Organization::SECURITY_LOGS_RETENTION_DAYS.days.ago..) } + + LOG_TYPES = %w[ + api_key + billing_entity + export + integration + role + user + webhook_endpoint + ].freeze + + LOG_EVENTS = %w[ + api_key.created + api_key.deleted + api_key.rotated + api_key.updated + billing_entity.created + billing_entity.updated + export.created + integration.created + integration.deleted + integration.updated + role.created + role.deleted + role.updated + user.deleted + user.new_device_logged_in + user.invited + user.password_edited + user.password_reset_requested + user.role_edited + user.signed_up + webhook_endpoint.created + webhook_endpoint.deleted + webhook_endpoint.updated + ].freeze + + before_save :ensure_log_id + + def resources + deep_parse_map_values(super) + end + + def device_info + deep_parse_map_values(super) + end + + private + + def ensure_log_id + self.log_id = SecureRandom.uuid if log_id.blank? + end + + def deep_parse_map_values(hash) + return hash unless hash.is_a?(Hash) + + hash.transform_values do |v| + JSON.parse(v) + rescue JSON::ParserError + v + end + end + end +end + +# == Schema Information +# +# Table name: security_logs +# Database name: clickhouse +# +# device_info :string +# log_event :string not null +# log_type :string not null +# logged_at :datetime not null +# resources :string +# created_at :datetime not null +# api_key_id :string +# log_id :string not null +# organization_id :string not null +# user_id :string +# diff --git a/app/models/commitment.rb b/app/models/commitment.rb new file mode 100644 index 0000000..31eebcb --- /dev/null +++ b/app/models/commitment.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class Commitment < ApplicationRecord + belongs_to :plan + belongs_to :organization + has_many :applied_taxes, class_name: "Commitment::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + + COMMITMENT_TYPES = { + minimum_commitment: 0 + }.freeze + + enum :commitment_type, COMMITMENT_TYPES + + monetize :amount_cents, disable_validation: true, allow_nil: true + + validates :amount_cents, numericality: {greater_than: 0}, allow_nil: false + validates :commitment_type, uniqueness: {scope: :plan_id} + + def invoice_name + invoice_display_name.presence || I18n.t("commitment.minimum.name") + end +end + +# == Schema Information +# +# Table name: commitments +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# commitment_type :integer not null +# invoice_display_name :string +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# plan_id :uuid not null +# +# Indexes +# +# index_commitments_on_commitment_type_and_plan_id (commitment_type,plan_id) UNIQUE +# index_commitments_on_organization_id (organization_id) +# index_commitments_on_plan_id (plan_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (plan_id => plans.id) +# diff --git a/app/models/commitment/applied_tax.rb b/app/models/commitment/applied_tax.rb new file mode 100644 index 0000000..5ba3575 --- /dev/null +++ b/app/models/commitment/applied_tax.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class Commitment + class AppliedTax < ApplicationRecord + self.table_name = "commitments_taxes" + + belongs_to :commitment + belongs_to :tax + belongs_to :organization + end +end + +# == Schema Information +# +# Table name: commitments_taxes +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# commitment_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid not null +# +# Indexes +# +# index_commitments_taxes_on_commitment_id (commitment_id) +# index_commitments_taxes_on_commitment_id_and_tax_id (commitment_id,tax_id) UNIQUE +# index_commitments_taxes_on_organization_id (organization_id) +# index_commitments_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (commitment_id => commitments.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/concerns/billing_entity_timezone.rb b/app/models/concerns/billing_entity_timezone.rb new file mode 100644 index 0000000..84e4f7a --- /dev/null +++ b/app/models/concerns/billing_entity_timezone.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module BillingEntityTimezone + BILLING_ENTITY_SUFFIX = "_in_billing_entity_timezone" + + def method_missing(method_name, *arguments, &block) + return super unless method_name.to_s.end_with?(BILLING_ENTITY_SUFFIX) + + target = if is_a?(BillingEntity) + self + else + billing_entity + end + + initial_method_name = method_name.to_s.gsub(BILLING_ENTITY_SUFFIX, "") + __send__(initial_method_name)&.in_time_zone(target.timezone) + end + + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.end_with?(BILLING_ENTITY_SUFFIX) && respond_to?( + method_name.gsub(BILLING_ENTITY_SUFFIX, "") + ) || super + end + + def self.included(base) + base.extend(self) + end +end diff --git a/app/models/concerns/charge_properties_validation.rb b/app/models/concerns/charge_properties_validation.rb new file mode 100644 index 0000000..34f7b76 --- /dev/null +++ b/app/models/concerns/charge_properties_validation.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module ChargePropertiesValidation + extend ActiveSupport::Concern + + PROPERTIES_VALIDATORS = { + standard: Charges::Validators::StandardService, + graduated: Charges::Validators::GraduatedService, + package: Charges::Validators::PackageService, + percentage: Charges::Validators::PercentageService, + volume: Charges::Validators::VolumeService, + graduated_percentage: Charges::Validators::GraduatedPercentageService + }.freeze + + def validate_charge_model_properties(charge_model) + return unless charge_model + + validator = PROPERTIES_VALIDATORS[charge_model.to_sym] + validator ||= Charges::Validators::BaseService + + instance = validator.new(charge: self) + return if instance.valid? + + instance.result.error.messages.values.flatten.each { errors.add(:properties, it) } + end +end diff --git a/app/models/concerns/currencies.rb b/app/models/concerns/currencies.rb new file mode 100644 index 0000000..9b66cb0 --- /dev/null +++ b/app/models/concerns/currencies.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Currencies + extend ActiveSupport::Concern + + ACCEPTED_CURRENCIES = { + AED: "United Arab Emirates Dirham", + AFN: "Afghan Afghani", + ALL: "Albanian Lek", + AMD: "Armenian Dram", + ANG: "Netherlands Antillean Gulden", + AOA: "Angolan Kwanza", + ARS: "Argentine Peso", + AUD: "Australian Dollar", + AWG: "Aruban Florin", + AZN: "Azerbaijani Manat", + BAM: "Bosnia and Herzegovina Convertible Mark", + BBD: "Barbadian Dollar", + BDT: "Bangladeshi Taka", + BGN: "Bulgarian Lev", + BHD: "Bahraini Dinar", + BIF: "Burundian Franc", + BMD: "Bermudian Dollar", + BND: "Brunei Dollar", + BOB: "Bolivian Boliviano", + BRL: "Brazilian Real", + BSD: "Bahamian Dollar", + BWP: "Botswana Pula", + BYN: "Belarusian Ruble", + BZD: "Belize Dollar", + CAD: "Canadian Dollar", + CDF: "Congolese Franc", + CHF: "Swiss Franc", + CLF: "Unidad de Fomento", + CLP: "Chilean Peso", + CNY: "Chinese Renminbi Yuan", + COP: "Colombian Peso", + CRC: "Costa Rican Colón", + CVE: "Cape Verdean Escudo", + CZK: "Czech Koruna", + DJF: "Djiboutian Franc", + DKK: "Danish Krone", + DOP: "Dominican Peso", + DZD: "Algerian Dinar", + EGP: "Egyptian Pound", + ETB: "Ethiopian Birr", + EUR: "Euro", + FJD: "Fijian Dollar", + FKP: "Falkland Pound", + GBP: "British Pound", + GEL: "Georgian Lari", + GHS: "Ghanaian Cedi", + GIP: "Gibraltar Pound", + GMD: "Gambian Dalasi", + GNF: "Guinean Franc", + GTQ: "Guatemalan Quetzal", + GYD: "Guyanese Dollar", + HKD: "Hong Kong Dollar", + HNL: "Honduran Lempira", + HRK: "Croatian Kuna", + HTG: "Haitian Gourde", + HUF: "Hungarian Forint", + IDR: "Indonesian Rupiah", + ILS: "Israeli New Sheqel", + INR: "Indian Rupee", + IRR: "Iranian Rial", + ISK: "Icelandic Króna", + JMD: "Jamaican Dollar", + JOD: "Jordanian Dinar", + JPY: "Japanese Yen", + KES: "Kenyan Shilling", + KGS: "Kyrgyzstani Som", + KHR: "Cambodian Riel", + KMF: "Comorian Franc", + KRW: "South Korean Won", + KWD: "Kuwaiti Dinar", + KYD: "Cayman Islands Dollar", + KZT: "Kazakhstani Tenge", + LAK: "Lao Kip", + LBP: "Lebanese Pound", + LKR: "Sri Lankan Rupee", + LRD: "Liberian Dollar", + LSL: "Lesotho Loti", + MAD: "Moroccan Dirham", + MDL: "Moldovan Leu", + MGA: "Malagasy Ariary", + MKD: "Macedonian Denar", + MMK: "Myanmar Kyat", + MNT: "Mongolian Tögrög", + MOP: "Macanese Pataca", + MRO: "Mauritanian Ouguiya", + MUR: "Mauritian Rupee", + MVR: "Maldivian Rufiyaa", + MWK: "Malawian Kwacha", + MXN: "Mexican Peso", + MYR: "Malaysian Ringgit", + MZN: "Mozambican Metical", + NAD: "Namibian Dollar", + NGN: "Nigerian Naira", + NIO: "Nicaraguan Córdoba", + NOK: "Norwegian Krone", + NPR: "Nepalese Rupee", + NZD: "New Zealand Dollar", + PAB: "Panamanian Balboa", + PEN: "Peruvian Sol", + PGK: "Papua New Guinean Kina", + PHP: "Philippine Peso", + PKR: "Pakistani Rupee", + PLN: "Polish Złoty", + PYG: "Paraguayan Guaraní", + QAR: "Qatari Riyal", + RON: "Romanian Leu", + RSD: "Serbian Dinar", + RUB: "Russian Ruble", + RWF: "Rwandan Franc", + SAR: "Saudi Riyal", + SBD: "Solomon Islands Dollar", + SCR: "Seychellois Rupee", + SEK: "Swedish Krona", + SGD: "Singapore Dollar", + SHP: "Saint Helenian Pound", + SLL: "Sierra Leonean Leone", + SOS: "Somali Shilling", + SRD: "Surinamese Dollar", + STD: "São Tomé and Príncipe Dobra", + SZL: "Swazi Lilangeni", + THB: "Thai Baht", + TJS: "Tajikistani Somoni", + TOP: "Tongan Paʻanga", + TRY: "Turkish Lira", + TTD: "Trinidad and Tobago Dollar", + TWD: "New Taiwan Dollar", + TZS: "Tanzanian Shilling", + UAH: "Ukrainian Hryvnia", + UGX: "Ugandan Shilling", + USD: "United States Dollar", + UYU: "Uruguayan Peso", + UZS: "Uzbekistan Som", + VND: "Vietnamese Đồng", + VUV: "Vanuatu Vatu", + WST: "Samoan Tala", + XAF: "Central African Cfa Franc", + XCD: "East Caribbean Dollar", + XOF: "West African Cfa Franc", + XPF: "Cfp Franc", + YER: "Yemeni Rial", + ZAR: "South African Rand", + ZMW: "Zambian Kwacha" + }.freeze + + included do + def self.currency_list + ACCEPTED_CURRENCIES.keys.map(&:to_s).map(&:upcase) + end + end +end diff --git a/app/models/concerns/customer_timezone.rb b/app/models/concerns/customer_timezone.rb new file mode 100644 index 0000000..a96afd5 --- /dev/null +++ b/app/models/concerns/customer_timezone.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module CustomerTimezone + CUSTOMER_SUFFIX = "_in_customer_timezone" + + def method_missing(method_name, *arguments, &block) + return super unless method_name.to_s.end_with?(CUSTOMER_SUFFIX) + + target = if is_a?(Customer) + self + else + customer + end + + initial_method_name = method_name.to_s.gsub(CUSTOMER_SUFFIX, "") + __send__(initial_method_name)&.in_time_zone(target.applicable_timezone) + end + + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.end_with?(CUSTOMER_SUFFIX) && respond_to?( + method_name.to_s.gsub(CUSTOMER_SUFFIX, "") + ) || super + end + + def self.included(base) + base.extend(self) + end +end diff --git a/app/models/concerns/has_feature_flags.rb b/app/models/concerns/has_feature_flags.rb new file mode 100644 index 0000000..57a8472 --- /dev/null +++ b/app/models/concerns/has_feature_flags.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module HasFeatureFlags + extend ActiveSupport::Concern + + def feature_flag_enabled?(flag) + flag = flag.to_s + FeatureFlag.validate!(flag) + + return false unless FeatureFlag.valid?(flag) + + feature_flags.include?(flag) + end + + def feature_flag_disabled?(flag) + flag = flag.to_s + !feature_flag_enabled?(flag) + end + + def enable_feature_flag!(flag) + flag = flag.to_s + FeatureFlag.validate!(flag) + + return unless FeatureFlag.valid?(flag) + + update!(feature_flags: feature_flags | [flag]) + end + + def disable_feature_flag!(flag) + flag = flag.to_s + FeatureFlag.validate!(flag) + + return unless FeatureFlag.valid?(flag) + + update!(feature_flags: feature_flags - [flag]) + end +end diff --git a/app/models/concerns/integration_mappable.rb b/app/models/concerns/integration_mappable.rb new file mode 100644 index 0000000..0d98c39 --- /dev/null +++ b/app/models/concerns/integration_mappable.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module IntegrationMappable + extend ActiveSupport::Concern + + included do + has_many :integration_mappings, as: :mappable, class_name: "IntegrationMappings::BaseMapping", dependent: :destroy + has_many :netsuite_mappings, as: :mappable, class_name: "IntegrationMappings::NetsuiteMapping", dependent: :destroy + has_many :xero_mappings, as: :mappable, class_name: "IntegrationMappings::XeroMapping", dependent: :destroy + end +end diff --git a/app/models/concerns/organization_timezone.rb b/app/models/concerns/organization_timezone.rb new file mode 100644 index 0000000..e85df00 --- /dev/null +++ b/app/models/concerns/organization_timezone.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module OrganizationTimezone + ORGANIZATION_SUFFIX = "_in_organization_timezone" + + def method_missing(method_name, *arguments, &block) + return super unless method_name.to_s.end_with?(ORGANIZATION_SUFFIX) + + target = if is_a?(Organization) + self + else + organization + end + + initial_method_name = method_name.to_s.gsub(ORGANIZATION_SUFFIX, "") + __send__(initial_method_name)&.in_time_zone(target.timezone) + end + + def respond_to_missing?(method_name, include_private = false) + method_name.to_s.end_with?(ORGANIZATION_SUFFIX) && respond_to?( + method_name.gsub(ORGANIZATION_SUFFIX, "") + ) || super + end + + def self.included(base) + base.extend(self) + end +end diff --git a/app/models/concerns/organizations/authentication_methods.rb b/app/models/concerns/organizations/authentication_methods.rb new file mode 100644 index 0000000..b118a87 --- /dev/null +++ b/app/models/concerns/organizations/authentication_methods.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Organizations + module AuthenticationMethods + extend ActiveSupport::Concern + + EMAIL_PASSWORD = "email_password" + GOOGLE_OAUTH = "google_oauth" + OKTA = "okta" + + FREE_AUTHENTICATION_METHODS = [EMAIL_PASSWORD, GOOGLE_OAUTH].freeze + PREMIUM_AUTHENTICATION_METHODS = [OKTA].freeze + AUTHENTICATION_METHODS = FREE_AUTHENTICATION_METHODS + PREMIUM_AUTHENTICATION_METHODS + + included do + validates :authentication_methods, length: {minimum: 1} + validates :authentication_methods, inclusion: {in: AUTHENTICATION_METHODS} + + FREE_AUTHENTICATION_METHODS.each do |method| + define_method("#{method}_authentication_enabled?") do + authentication_methods.include?(method) + end + + define_method("enable_#{method}_authentication!") do + return true if send("#{method}_authentication_enabled?") + + authentication_methods << method + save! + end + end + + # NOTE: Authentication methods with the same name as the premium integration. + PREMIUM_AUTHENTICATION_METHODS.each do |method| + define_method("#{method}_authentication_enabled?") do + send("#{method}_enabled?") && authentication_methods.include?(method) + end + + define_method("enable_#{method}_authentication!") do + return false unless send("#{method}_enabled?") + return true if send("#{method}_authentication_enabled?") + + authentication_methods << method + save! + end + end + + AUTHENTICATION_METHODS.each do |method| + define_method("disable_#{method}_authentication!") do + return false unless send("#{method}_authentication_enabled?") + + authentication_methods.delete(method) + save! + end + end + end + end +end diff --git a/app/models/concerns/organizations/sluggable.rb b/app/models/concerns/organizations/sluggable.rb new file mode 100644 index 0000000..6da5aec --- /dev/null +++ b/app/models/concerns/organizations/sluggable.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Organizations + module Sluggable + extend ActiveSupport::Concern + + SLUG_FORMAT = /\A[a-z0-9]([a-z0-9-]*[a-z0-9])?\z/ + + RESERVED_SLUGS = %w[ + auth login sign-up forgot-password reset-password invitation + customer-portal 404 forbidden api admin graphql webhooks google okta + settings new design-system devtool + customers customer plans plan invoices invoice subscriptions + coupons coupon add-ons add-on billable-metrics billable-metric + credit-notes analytics analytics-v2 forecasts payments payment + features feature tax webhook api-keys create update duplicate + ].freeze + + included do + validates :slug, + presence: true, + uniqueness: true, + length: {minimum: 3, maximum: 40}, + format: {with: SLUG_FORMAT}, + exclusion: {in: RESERVED_SLUGS}, + if: -> { new_record? || slug_changed? } + + before_validation :generate_slug, on: :create + end + + private + + def generate_slug + return if slug.present? + + candidate = ActiveSupport::Inflector.transliterate(name.to_s) + .parameterize + .tr("_", "-") + .gsub(/-{2,}/, "-") + .truncate(40, omission: "") + .gsub(/\A-|-\z/, "") + + if candidate.length < 3 || candidate.match?(/\A\d+\z/) || RESERVED_SLUGS.include?(candidate) + loop do + candidate = "org-#{SecureRandom.alphanumeric(5).downcase}" + break unless self.class.exists?(slug: candidate) + end + else + base = candidate.truncate(36, omission: "").delete_suffix("-") + while self.class.exists?(slug: candidate) + suffix = SecureRandom.alphanumeric(3).downcase + candidate = "#{base}-#{suffix}" + end + end + + self.slug = candidate + end + end +end diff --git a/app/models/concerns/paper_trail_traceable.rb b/app/models/concerns/paper_trail_traceable.rb new file mode 100644 index 0000000..84671be --- /dev/null +++ b/app/models/concerns/paper_trail_traceable.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module PaperTrailTraceable + extend ActiveSupport::Concern + + included do + has_paper_trail( + meta: { + whodunnit: proc { |_| CurrentContext.membership }, + lago_version: LAGO_VERSION.number + } + ) + end +end diff --git a/app/models/concerns/ransack_uuid_search.rb b/app/models/concerns/ransack_uuid_search.rb new file mode 100644 index 0000000..b5bde20 --- /dev/null +++ b/app/models/concerns/ransack_uuid_search.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module RansackUuidSearch + extend ActiveSupport::Concern + + included do + ransacker :id do + Arel.sql("\"#{table_name}\".\"id\"::varchar") + end + + ransacker :object_id do + Arel.sql("\"#{table_name}\".\"object_id\"::varchar") + end + end +end diff --git a/app/models/concerns/secrets_storable.rb b/app/models/concerns/secrets_storable.rb new file mode 100644 index 0000000..2e3d955 --- /dev/null +++ b/app/models/concerns/secrets_storable.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module SecretsStorable + extend ActiveSupport::Concern + + included do + encrypts :secrets + end + + class_methods do + def secrets_accessors(*method_names) + method_names.each do |name| + define_method(name) do + get_from_secrets(name.to_s) + end + + define_method(:"#{name}=") do |value| + push_to_secrets(key: name.to_s, value:) + end + end + end + end + + def secrets_json + JSON.parse(secrets || "{}") + end + + def push_to_secrets(key:, value:) + self.secrets = secrets_json.merge(key => value).to_json + end + + def get_from_secrets(key) + secrets_json[key.to_s] + end +end diff --git a/app/models/concerns/sequenced.rb b/app/models/concerns/sequenced.rb new file mode 100644 index 0000000..cf53f5f --- /dev/null +++ b/app/models/concerns/sequenced.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Sequenced + extend ActiveSupport::Concern + + included do + scope :with_sequential_id, -> { where.not(sequential_id: nil) } + + before_save :ensure_sequential_id + + private + + def ensure_sequential_id + return if sequential_id.present? + return unless should_assign_sequential_id? + + self.sequential_id = generate_sequential_id + end + + def should_assign_sequential_id? + true + end + + def generate_sequential_id + acquire_advisory_lock! + + (sequence_scope.maximum(:sequential_id) || 0) + 1 + end + + def acquire_advisory_lock! + conn = self.class.connection + raise SequenceError, "must be called inside a transaction" unless conn.transaction_open? + + quoted_key = conn.quote(lock_key_value) + conn.execute("SET LOCAL lock_timeout = '10s'") + conn.execute("SELECT pg_advisory_xact_lock(hashtext(#{quoted_key}))") + rescue ActiveRecord::LockWaitTimeout + raise SequenceError, "Unable to acquire lock on the database" + end + + def sequence_scope + self.class.class_exec(self, &self.class.sequenced_options[:scope]) + end + + def lock_key_value + "#{self.class.class_exec(self, &self.class.sequenced_lock_key) || self.class.name.underscore}_lock" + end + end + + class_methods do + def sequenced(scope:, lock_key: nil) + self.sequenced_options = {scope:} + self.sequenced_lock_key = lock_key + end + + # rubocop:disable ThreadSafety/ClassInstanceVariable + def sequenced_options=(options) + @sequenced_options = options + end + + def sequenced_options + @sequenced_options + end + + def sequenced_lock_key=(lock_key) + @sequenced_lock_key = lock_key + end + + def sequenced_lock_key + @sequenced_lock_key + end + # rubocop:enable ThreadSafety/ClassInstanceVariable + end + + class SequenceError < StandardError; end +end diff --git a/app/models/concerns/settings_storable.rb b/app/models/concerns/settings_storable.rb new file mode 100644 index 0000000..05ae5dd --- /dev/null +++ b/app/models/concerns/settings_storable.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module SettingsStorable + extend ActiveSupport::Concern + + class_methods do + def settings_accessors(*method_names) + method_names.each do |name| + define_method(name) do + get_from_settings(name.to_s) + end + + define_method(:"#{name}=") do |value| + push_to_settings(key: name.to_s, value:) + end + end + end + end + + def push_to_settings(key:, value:) + self.settings ||= {} + settings[key] = value + end + + def get_from_settings(key) + (settings || {})[key] + end +end diff --git a/app/models/coupon.rb b/app/models/coupon.rb new file mode 100644 index 0000000..3087933 --- /dev/null +++ b/app/models/coupon.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +class Coupon < ApplicationRecord + include PaperTrailTraceable + include Currencies + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + + has_many :applied_coupons + has_many :customers, through: :applied_coupons + has_many :coupon_targets + has_many :plans, through: :coupon_targets + has_many :billable_metrics, through: :coupon_targets + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + as: :resource + + STATUSES = [ + :active, + :terminated + ].freeze + + EXPIRATION_TYPES = [ + :no_expiration, + :time_limit + ].freeze + + COUPON_TYPES = [ + :fixed_amount, + :percentage + ].freeze + + FREQUENCIES = [ + :once, + :recurring, + :forever + ].freeze + + enum :status, STATUSES, validate: true + enum :expiration, EXPIRATION_TYPES, validate: true + enum :coupon_type, COUPON_TYPES, validate: true + enum :frequency, FREQUENCIES, validate: true + + monetize :amount_cents, disable_validation: true, allow_nil: true + + validates :name, presence: true + validates :code, uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id} + + validates :amount_cents, presence: true, if: :fixed_amount? + validates :amount_cents, numericality: {greater_than: 0}, allow_nil: true + + validates :amount_currency, presence: true, if: :fixed_amount? + validates :amount_currency, inclusion: {in: currency_list}, allow_nil: true + + validates :percentage_rate, presence: true, if: :percentage? + + validates :frequency_duration, presence: true, numericality: {greater_than: 0}, if: :recurring? + + validates :reusable, exclusion: [nil] + + default_scope -> { kept } + scope :order_by_status_and_expiration, + lambda { + order( + Arel.sql( + [ + "coupons.status ASC", + "coupons.expiration ASC", + "coupons.expiration_at ASC" + ].join(", ") + ) + ) + } + + scope :expired, -> { where("coupons.expiration_at::timestamp(0) < ?", Time.current) } + + def self.ransackable_attributes(_auth_object = nil) + %w[name code] + end + + def mark_as_terminated!(timestamp = Time.zone.now) + self.terminated_at ||= timestamp + terminated! + end + + def parent_and_overriden_plans + (plans + plans.map(&:children)).flatten + end +end + +# == Schema Information +# +# Table name: coupons +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint +# amount_currency :string +# code :string +# coupon_type :integer default("fixed_amount"), not null +# deleted_at :datetime +# description :text +# expiration :integer not null +# expiration_at :datetime +# frequency :integer default("once"), not null +# frequency_duration :integer +# limited_billable_metrics :boolean default(FALSE), not null +# limited_plans :boolean default(FALSE), not null +# name :string not null +# percentage_rate :decimal(10, 5) +# reusable :boolean default(TRUE), not null +# status :integer default("active"), not null +# terminated_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_coupons_on_deleted_at (deleted_at) +# index_coupons_on_organization_id (organization_id) +# index_coupons_on_organization_id_and_code (organization_id,code) UNIQUE WHERE (deleted_at IS NULL) +# diff --git a/app/models/coupon_target.rb b/app/models/coupon_target.rb new file mode 100644 index 0000000..a1edbaf --- /dev/null +++ b/app/models/coupon_target.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class CouponTarget < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :coupon + belongs_to :plan, optional: true + belongs_to :billable_metric, optional: true + belongs_to :organization + + default_scope -> { kept } +end + +# == Schema Information +# +# Table name: coupon_targets +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# coupon_id :uuid not null +# organization_id :uuid not null +# plan_id :uuid +# +# Indexes +# +# index_coupon_targets_on_billable_metric_id (billable_metric_id) +# index_coupon_targets_on_coupon_id (coupon_id) +# index_coupon_targets_on_deleted_at (deleted_at) +# index_coupon_targets_on_organization_id (organization_id) +# index_coupon_targets_on_plan_id (plan_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (coupon_id => coupons.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (plan_id => plans.id) +# diff --git a/app/models/credit.rb b/app/models/credit.rb new file mode 100644 index 0000000..ae186e0 --- /dev/null +++ b/app/models/credit.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +class Credit < ApplicationRecord + include Currencies + + belongs_to :invoice + belongs_to :applied_coupon, optional: true + belongs_to :credit_note, optional: true + belongs_to :progressive_billing_invoice, class_name: "Invoice", optional: true + belongs_to :organization + + has_one :coupon, -> { with_discarded }, through: :applied_coupon + + monetize :amount_cents, disable_validation: true, allow_nil: true + + validates :amount_currency, inclusion: {in: currency_list} + + scope :coupon_kind, -> { where.not(applied_coupon_id: nil) } + scope :credit_note_kind, -> { where.not(credit_note_id: nil) } + scope :progressive_billing_invoice_kind, -> { where.not(progressive_billing_invoice_id: nil) } + scope :active, -> { joins(:invoice).where.not(invoices: {status: :voided}) } + scope :voided, -> { joins(:invoice).where(invoices: {status: :voided}) } + + def item_id + return coupon&.id if applied_coupon_id + return progressive_billing_invoice_id if progressive_billing_invoice_id? + + credit_note.id + end + + def item_type + return "coupon" if applied_coupon_id? + return "invoice" if progressive_billing_invoice_id? + + "credit_note" + end + + def item_code + return coupon&.code if applied_coupon_id? + return progressive_billing_invoice.number if progressive_billing_invoice_id? + + credit_note.number + end + + def item_name + return coupon&.name if applied_coupon_id? + return progressive_billing_invoice.number if progressive_billing_invoice_id? + + # TODO: change it depending on invoice template + credit_note.invoice.number + end + + def item_description + return coupon&.description if applied_coupon_id? + return credit_note.description if credit_note_id? + + nil + end + + def invoice_coupon_display_name + return nil if applied_coupon.blank? + + suffix = if coupon.percentage? + "#{format("%.2f", applied_coupon.percentage_rate)}%" + else + applied_coupon.amount.format( + format: I18n.t("money.format"), + decimal_mark: I18n.t("money.decimal_mark"), + thousands_separator: I18n.t("money.thousands_separator") + ) + end + + "#{coupon.name} (#{suffix})" + end +end + +# == Schema Information +# +# Table name: credits +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# amount_currency :string not null +# before_taxes :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# applied_coupon_id :uuid +# credit_note_id :uuid +# invoice_id :uuid +# organization_id :uuid not null +# progressive_billing_invoice_id :uuid +# +# Indexes +# +# index_credits_on_applied_coupon_id (applied_coupon_id) +# index_credits_on_credit_note_id (credit_note_id) +# index_credits_on_invoice_id (invoice_id) +# index_credits_on_organization_id (organization_id) +# index_credits_on_progressive_billing_invoice_id (progressive_billing_invoice_id) +# +# Foreign Keys +# +# fk_rails_... (applied_coupon_id => applied_coupons.id) +# fk_rails_... (credit_note_id => credit_notes.id) +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (progressive_billing_invoice_id => invoices.id) +# diff --git a/app/models/credit_note.rb b/app/models/credit_note.rb new file mode 100644 index 0000000..b5c7289 --- /dev/null +++ b/app/models/credit_note.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +class CreditNote < ApplicationRecord + include PaperTrailTraceable + include Sequenced + include RansackUuidSearch + + DB_PRECISION_SCALE = 5 + + before_save :ensure_number + + belongs_to :customer, -> { with_discarded } + belongs_to :invoice + belongs_to :organization + + has_one :billing_entity, through: :invoice + has_one :metadata, + class_name: "Metadata::ItemMetadata", + as: :owner, + dependent: :destroy + + has_many :items, class_name: "CreditNoteItem", dependent: :destroy + has_many :fees, through: :items + has_many :refunds + has_many :invoice_settlements, foreign_key: :source_credit_note_id + + has_many :applied_taxes, class_name: "CreditNote::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + has_many :integration_resources, as: :syncable + has_many :error_details, as: :owner, dependent: :destroy + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + as: :resource + + has_one_attached :file + has_one_attached :xml_file + + monetize :credit_amount_cents + monetize :balance_amount_cents + monetize :refund_amount_cents + monetize :offset_amount_cents + monetize :total_amount_cents + monetize :taxes_amount_cents, + :coupons_adjustment_amount_cents, + :sub_total_excluding_taxes_amount_cents, + with_model_currency: :total_amount_currency + + # NOTE: Status of the credit part + # - available: a credit amount remain available + # - consumed: the credit amount was totaly consumed + CREDIT_STATUS = %i[available consumed voided].freeze + + # NOTE: Status of the refund part + # - pending: the refund is pending for its execution + # - refunded: the refund has been executed + # - failed: the refund process has failed + REFUND_STATUS = %i[pending succeeded failed].freeze + + TYPES = %w[credit refund offset].freeze + + REASON = %i[duplicated_charge product_unsatisfactory order_change order_cancellation fraudulent_charge other].freeze + STATUS = %i[draft finalized].freeze + + enum :credit_status, CREDIT_STATUS + enum :refund_status, REFUND_STATUS, validate: {allow_nil: true} + enum :reason, REASON, validate: true + enum :status, STATUS + + sequenced scope: ->(credit_note) { CreditNote.where(invoice_id: credit_note.invoice_id) }, + lock_key: ->(credit_note) { credit_note.invoice_id } + + validates :total_amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :credit_amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :refund_amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :offset_amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :balance_amount_cents, numericality: {greater_than_or_equal_to: 0} + + def self.ransackable_attributes(_auth_object = nil) + %w[number id] + end + + def self.ransackable_associations(_ = nil) + %w[customer] + end + + def file_url + return if file.blank? + + Rails.application.routes.url_helpers.rails_blob_url(file, host: ENV["LAGO_API_URL"]) + end + + def xml_url + return if xml_file.blank? + + Rails.application.routes.url_helpers.rails_blob_url(xml_file, host: ENV["LAGO_API_URL"]) + end + + def currency + total_amount_currency + end + + def credited? + credit_amount_cents.positive? + end + + def refunded? + refund_amount_cents.positive? + end + + def has_offset? + offset_amount_cents.positive? + end + + def subscription_ids + fees.pluck(:subscription_id).uniq + end + + def subscription_item(subscription_id) + items.joins(:fee) + .merge(Fee.subscription) + .find_by(fees: {subscription_id:}) || Fee.new(amount_cents: 0, amount_currency: currency) + end + + def subscription_charge_items(subscription_id) + items.joins(:fee) + .merge(Fee.charge) + .where(fees: {subscription_id:}) + .includes(:fee) + end + + def subscription_fixed_charge_items(subscription_id) + items.joins(:fee) + .merge(Fee.fixed_charge) + .where(fees: {subscription_id:}) + .includes(:fee) + end + + def add_on_items + items.joins(:fee) + .merge(Fee.add_on) + .includes(:fee) + end + + def should_sync_credit_note? + finalized? && customer.integration_customers.accounting_kind.any? { |c| c.integration.sync_credit_notes } + end + + def voidable? + return false if voided? + + balance_amount_cents.positive? + end + + def mark_as_voided!(timestamp: Time.current) + update!( + credit_status: :voided, + voided_at: timestamp, + balance_amount_cents: 0 + ) + end + + def sub_total_including_taxes_amount_cents + sub_total_excluding_taxes_amount_cents + precise_taxes_amount_cents + end + + def sub_total_excluding_taxes_amount_cents + (items.sum(&:precise_amount_cents) - precise_coupons_adjustment_amount_cents).round + end + + def precise_total + items.sum(&:precise_amount_cents) - precise_coupons_adjustment_amount_cents + precise_taxes_amount_cents + end + + def taxes_rounding_adjustment + taxes_amount_cents - precise_taxes_amount_cents + end + + def rounding_adjustment + total_amount_cents - precise_total + end + + def for_credit_invoice? + invoice.credit? + end + + private + + def ensure_number + return if number.present? && !status_changed_to_finalized? + + formatted_sequential_id = format("%03d", sequential_id) + + self.number = "#{invoice.number}-CN#{formatted_sequential_id}" + end + + def status_changed_to_finalized? + status_changed?(from: "draft", to: "finalized") + end +end + +# == Schema Information +# +# Table name: credit_notes +# Database name: primary +# +# id :uuid not null, primary key +# balance_amount_cents :bigint default(0), not null +# balance_amount_currency :string default("0"), not null +# coupons_adjustment_amount_cents :bigint default(0), not null +# credit_amount_cents :bigint default(0), not null +# credit_amount_currency :string not null +# credit_status :integer +# description :text +# file :string +# issuing_date :date not null +# number :string not null +# offset_amount_cents :bigint default(0), not null +# offset_amount_currency :string +# precise_coupons_adjustment_amount_cents :decimal(30, 5) default(0.0), not null +# precise_taxes_amount_cents :decimal(30, 5) default(0.0), not null +# reason :integer not null +# refund_amount_cents :bigint default(0), not null +# refund_amount_currency :string +# refund_status :integer +# refunded_at :datetime +# status :integer default("finalized"), not null +# taxes_amount_cents :bigint default(0), not null +# taxes_rate :float default(0.0), not null +# total_amount_cents :bigint default(0), not null +# total_amount_currency :string not null +# voided_at :datetime +# xml_file :string +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# invoice_id :uuid not null +# organization_id :uuid not null +# sequential_id :integer not null +# +# Indexes +# +# index_credit_notes_on_customer_id (customer_id) +# index_credit_notes_on_invoice_id (invoice_id) +# index_credit_notes_on_invoice_id_and_sequential_id (invoice_id,sequential_id) UNIQUE +# index_credit_notes_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/credit_note/applied_tax.rb b/app/models/credit_note/applied_tax.rb new file mode 100644 index 0000000..7d57125 --- /dev/null +++ b/app/models/credit_note/applied_tax.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class CreditNote + class AppliedTax < ApplicationRecord + self.table_name = "credit_notes_taxes" + + include PaperTrailTraceable + + belongs_to :credit_note + belongs_to :tax, optional: true + belongs_to :organization + + monetize :amount_cents + monetize :base_amount_cents, with_model_currency: :amount_currency + end +end + +# == Schema Information +# +# Table name: credit_notes_taxes +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint default(0), not null +# amount_currency :string not null +# base_amount_cents :bigint default(0), not null +# tax_code :string not null +# tax_description :string +# tax_name :string not null +# tax_rate :float default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# credit_note_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid +# +# Indexes +# +# index_credit_notes_taxes_on_credit_note_id (credit_note_id) +# index_credit_notes_taxes_on_credit_note_id_and_tax_code (credit_note_id,tax_code) UNIQUE +# index_credit_notes_taxes_on_organization_id (organization_id) +# index_credit_notes_taxes_on_tax_code (tax_code) +# index_credit_notes_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (credit_note_id => credit_notes.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/credit_note_item.rb b/app/models/credit_note_item.rb new file mode 100644 index 0000000..5d0a646 --- /dev/null +++ b/app/models/credit_note_item.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class CreditNoteItem < ApplicationRecord + belongs_to :credit_note + belongs_to :fee + belongs_to :organization + + monetize :amount_cents + + validates :amount_cents, numericality: {greater_than_or_equal_to: 0} + + def applied_taxes + credit_note.applied_taxes.where(tax_code: fee.applied_taxes.select("fees_taxes.tax_code")) + end + + # This method returns item amount with coupons applied + # coupons are applied proportionally to the way they're applied on corresponding fee + # so knowing the item total proportion to fee total we can calculate item amount with coupons + def sub_total_excluding_taxes_amount_cents + return 0 if amount_cents.zero? || fee.amount_cents.zero? + + item_proportion_to_fee = amount_cents.to_f / fee.amount_cents + item_proportion_to_fee * fee.sub_total_excluding_taxes_amount_cents + end + + def fee_rate + precise_amount_cents.fdiv(fee.precise_amount_cents.nonzero? || 1) + end +end + +# == Schema Information +# +# Table name: credit_note_items +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint default(0), not null +# amount_currency :string not null +# precise_amount_cents :decimal(30, 5) not null +# created_at :datetime not null +# updated_at :datetime not null +# credit_note_id :uuid not null +# fee_id :uuid +# organization_id :uuid not null +# +# Indexes +# +# index_credit_note_items_on_credit_note_id (credit_note_id) +# index_credit_note_items_on_fee_id (fee_id) +# index_credit_note_items_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (credit_note_id => credit_notes.id) +# fk_rails_... (fee_id => fees.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/customer.rb b/app/models/customer.rb new file mode 100644 index 0000000..f73ad61 --- /dev/null +++ b/app/models/customer.rb @@ -0,0 +1,427 @@ +# frozen_string_literal: true + +class Customer < ApplicationRecord + include PaperTrailTraceable + include Sequenced + include Currencies + include CustomerTimezone + include OrganizationTimezone + include BillingEntityTimezone + include Discard::Model + + self.discard_column = :deleted_at + + FINALIZE_ZERO_AMOUNT_INVOICE_OPTIONS = [ + :inherit, + :skip, + :finalize + ].freeze + + CUSTOMER_TYPES = { + company: "company", + individual: "individual" + }.freeze + + ACCOUNT_TYPES = { + customer: "customer", + partner: "partner" + }.freeze + + SUBSCRIPTION_INVOICE_ISSUING_DATE_ANCHORS = { + current_period_end: "current_period_end", + next_period_start: "next_period_start" + }.freeze + + SUBSCRIPTION_INVOICE_ISSUING_DATE_ADJUSTMENTS = { + keep_anchor: "keep_anchor", + align_with_finalization_date: "align_with_finalization_date" + }.freeze + + attribute :finalize_zero_amount_invoice, :integer + enum :finalize_zero_amount_invoice, FINALIZE_ZERO_AMOUNT_INVOICE_OPTIONS, prefix: :finalize_zero_amount_invoice + attribute :customer_type, :string + enum :customer_type, CUSTOMER_TYPES, prefix: :customer_type, validate: {allow_nil: true} + attribute :account_type, :string + enum :account_type, ACCOUNT_TYPES, suffix: :account, validate: true + + enum :subscription_invoice_issuing_date_anchor, SUBSCRIPTION_INVOICE_ISSUING_DATE_ANCHORS, prefix: true, validate: {allow_nil: true} + enum :subscription_invoice_issuing_date_adjustment, SUBSCRIPTION_INVOICE_ISSUING_DATE_ADJUSTMENTS, prefix: true, validate: {allow_nil: true} + + before_save :ensure_slug + + belongs_to :organization + belongs_to :billing_entity, optional: true + belongs_to :applied_dunning_campaign, optional: true, class_name: "DunningCampaign" + + has_many :subscriptions + has_many :events + has_many :invoices + has_many :applied_coupons + has_many :metadata, class_name: "Metadata::CustomerMetadata", dependent: :destroy + has_many :coupons, through: :applied_coupons + has_many :credit_notes + has_many :applied_add_ons + has_many :add_ons, through: :applied_add_ons + has_many :daily_usages + has_many :quotes + has_many :wallets + has_many :wallet_transactions, through: :wallets + has_many :payment_provider_customers, + class_name: "PaymentProviderCustomers::BaseCustomer", + dependent: :destroy + has_many :payment_methods, dependent: :destroy + has_many :payment_requests, dependent: :destroy + has_many :quantified_events + has_many :integration_customers, + class_name: "IntegrationCustomers::BaseCustomer", + dependent: :destroy + + has_many :applied_taxes, class_name: "Customer::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + has_many :error_details, as: :owner, dependent: :destroy + + has_many :applied_invoice_custom_sections, + class_name: "Customer::AppliedInvoiceCustomSection", + dependent: :destroy + has_many :selected_invoice_custom_sections, through: :applied_invoice_custom_sections, source: :invoice_custom_section + has_many :manual_selected_invoice_custom_sections, + -> { where(section_type: :manual) }, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + has_many :system_generated_invoice_custom_sections, + -> { where(section_type: :system_generated) }, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + foreign_key: :external_customer_id, + primary_key: :external_id + + has_one :stripe_customer, class_name: "PaymentProviderCustomers::StripeCustomer" + has_one :gocardless_customer, class_name: "PaymentProviderCustomers::GocardlessCustomer" + has_one :cashfree_customer, class_name: "PaymentProviderCustomers::CashfreeCustomer" + has_one :adyen_customer, class_name: "PaymentProviderCustomers::AdyenCustomer" + has_one :flutterwave_customer, class_name: "PaymentProviderCustomers::FlutterwaveCustomer" + has_one :netsuite_customer, class_name: "IntegrationCustomers::NetsuiteCustomer" + has_one :anrok_customer, class_name: "IntegrationCustomers::AnrokCustomer" + has_one :avalara_customer, class_name: "IntegrationCustomers::AvalaraCustomer" + has_one :xero_customer, class_name: "IntegrationCustomers::XeroCustomer" + has_one :hubspot_customer, class_name: "IntegrationCustomers::HubspotCustomer" + has_one :salesforce_customer, class_name: "IntegrationCustomers::SalesforceCustomer" + has_one :moneyhash_customer, class_name: "PaymentProviderCustomers::MoneyhashCustomer" + + has_one :default_payment_method, -> { where(is_default: true) }, class_name: "PaymentMethod" + has_one :pending_vies_check + + delegate :default_currency, to: :organization, prefix: true + + PAYMENT_PROVIDERS = %w[stripe gocardless cashfree adyen flutterwave moneyhash].freeze + + default_scope -> { kept } + sequenced scope: ->(customer) { customer.organization.customers.with_discarded }, + lock_key: ->(customer) { customer.organization_id } + + scope :awaiting_wallet_refresh, -> { where(awaiting_wallet_refresh: true) } + scope :with_active_wallets, -> { joins(:wallets).where(wallets: {status: :active}) } + + scope :falling_back_to_default_dunning_campaign, -> { + where(applied_dunning_campaign_id: nil, exclude_from_dunning_campaign: false) + } + + scope :without_tax_errors, -> { + tax_error_code = ErrorDetail.error_codes[:tax_error] + + joins(sanitize_sql_array([ + 'LEFT OUTER JOIN "error_details" ON "error_details"."owner_id" = "customers"."id" + AND "error_details"."owner_type" = \'Customer\' + AND "error_details"."error_code" = ? + AND "error_details"."deleted_at" IS NULL', tax_error_code + ])) + .where('"error_details"."owner_id" IS NULL') + } + + validates :country, :shipping_country, country_code: true, allow_nil: true + validates :document_locale, language_code: true, unless: -> { document_locale.nil? } + validates :currency, inclusion: {in: currency_list}, allow_nil: true + validates :external_id, + presence: true, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id}, + unless: :deleted_at + validates :invoice_grace_period, numericality: {greater_than_or_equal_to: 0}, allow_nil: true + validates :net_payment_term, numericality: {greater_than_or_equal_to: 0}, allow_nil: true + validates :payment_provider, inclusion: {in: PAYMENT_PROVIDERS}, allow_nil: true + validates :timezone, timezone: true, allow_nil: true + validates :email, email: true, if: -> { email? && will_save_change_to_email? } + + BILLING_ADDRESS_FIELDS = %i[ + address_line1 address_line2 city zipcode state country + ].freeze + + SHIPPING_ADDRESS_FIELDS = %i[ + shipping_address_line1 shipping_address_line2 shipping_city + shipping_zipcode shipping_state shipping_country + ].freeze + + ADDRESS_FIELDS = (BILLING_ADDRESS_FIELDS + SHIPPING_ADDRESS_FIELDS).freeze + + ADDRESS_FIELDS.each do |attribute| + # NOTE: Null byte injection. Prevent 500 errors. + normalizes attribute, with: ->(value) { value.delete("\u0000").presence } + end + + normalizes :email, with: ->(email) { EmailSanitizer.call(email) } + + def self.ransackable_attributes(_auth_object = nil) + %w[id name firstname lastname legal_name external_id email] + end + + def self.ransackable_associations(_auth_object = nil) + [] + end + + def display_name(prefer_legal_name: true) + names = prefer_legal_name ? [legal_name.presence || name.presence] : [name.presence] + + if firstname.present? || lastname.present? + names << "-" if names.compact.present? + names << firstname + names << lastname + end + names.compact.join(" ") + end + + def active_subscription + subscriptions.active.order(started_at: :desc).first + end + + def active_subscriptions + subscriptions.active.order(started_at: :desc) + end + + def applicable_timezone + return timezone if timezone.present? + + billing_entity.timezone || "UTC" + end + + def applicable_invoice_grace_period + return invoice_grace_period if invoice_grace_period.present? + + billing_entity.invoice_grace_period || 0 + end + + def applicable_subscription_invoice_issuing_date_anchor + subscription_invoice_issuing_date_anchor || billing_entity.subscription_invoice_issuing_date_anchor + end + + def applicable_subscription_invoice_issuing_date_adjustment + subscription_invoice_issuing_date_adjustment || billing_entity.subscription_invoice_issuing_date_adjustment + end + + def applicable_net_payment_term + return net_payment_term if net_payment_term.present? + + billing_entity.net_payment_term + end + + # `applicable_invoice_custom_sections` includes: + # - all manually selected (configurable) sections + # - plus any system-generated sections + # These are the ones that will actually appear on the invoice. + def applicable_invoice_custom_sections + InvoiceCustomSection.where(id: configurable_invoice_custom_sections) + .or(InvoiceCustomSection.where(id: system_generated_invoice_custom_sections)) + end + + # `configurable_invoice_custom_sections` are manually selected sections: + # - either directly configured on the customer + # - or fallback to selections at the billing entity level if none on the customer + def configurable_invoice_custom_sections + return InvoiceCustomSection.none if skip_invoice_custom_sections? + + manual_selected_invoice_custom_sections.order(:name).presence || billing_entity.selected_invoice_custom_sections.order(:name) + end + + def editable? + subscriptions.none? && + applied_add_ons.none? && + invoices.none? && + applied_coupons.where.not(amount_currency: nil).none? && + wallets.none? + end + + def vies_check_in_progress? + return false unless billing_entity.eu_tax_management? + + pending_vies_check.present? + end + + def preferred_document_locale + return document_locale.to_sym if document_locale? + + billing_entity.document_locale.to_sym + end + + def provider_customer + case payment_provider&.to_sym + when :stripe + stripe_customer + when :gocardless + gocardless_customer + when :cashfree + cashfree_customer + when :flutterwave + flutterwave_customer + when :adyen + adyen_customer + when :moneyhash + moneyhash_customer + end + end + + def shipping_address + { + address_line1: shipping_address_line1, + address_line2: shipping_address_line2, + city: shipping_city, + zipcode: shipping_zipcode, + state: shipping_state, + country: shipping_country + } + end + + def same_billing_and_shipping_address? + return true if shipping_address.values.all?(&:blank?) + + address_line1 == shipping_address_line1 && + address_line2 == shipping_address_line2 && + city == shipping_city && + zipcode == shipping_zipcode && + state == shipping_state && + country == shipping_country + end + + def empty_billing_and_shipping_address? + shipping_address.values.all?(&:blank?) && + address_line1.blank? && + address_line2.blank? && + city.blank? && + zipcode.blank? && + state.blank? && + country.blank? + end + + def overdue_balance_cents + invoices.non_self_billed.payment_overdue.where(currency:).sum(:total_amount_cents) + end + + def reset_dunning_campaign! + update!( + last_dunning_campaign_attempt: 0, + last_dunning_campaign_attempt_at: nil + ) + end + + def flag_wallets_for_refresh + return unless wallets.active.exists? + + update!(awaiting_wallet_refresh: true) + end + + def tax_customer + anrok_customer || avalara_customer + end + + def address_changed? + ADDRESS_FIELDS.any? { |field| send(:"#{field}_changed?") } + end + + private + + def ensure_slug + return if slug.present? + + formatted_sequential_id = format("%03d", sequential_id) + + self.slug = "#{organization.document_number_prefix}-#{formatted_sequential_id}" + end +end + +# == Schema Information +# +# Table name: customers +# Database name: primary +# +# id :uuid not null, primary key +# account_type :enum default("customer"), not null +# address_line1 :string +# address_line2 :string +# awaiting_wallet_refresh :boolean default(FALSE), not null +# city :string +# country :string +# currency :string +# customer_type :enum +# deleted_at :datetime +# document_locale :string +# email :string +# exclude_from_dunning_campaign :boolean default(FALSE), not null +# finalize_zero_amount_invoice :integer default("inherit"), not null +# firstname :string +# invoice_grace_period :integer +# last_dunning_campaign_attempt :integer default(0), not null +# last_dunning_campaign_attempt_at :datetime +# lastname :string +# legal_name :string +# legal_number :string +# logo_url :string +# name :string +# net_payment_term :integer +# payment_provider :string +# payment_provider_code :string +# payment_receipt_counter :bigint default(0), not null +# phone :string +# shipping_address_line1 :string +# shipping_address_line2 :string +# shipping_city :string +# shipping_country :string +# shipping_state :string +# shipping_zipcode :string +# skip_invoice_custom_sections :boolean default(FALSE), not null +# slug :string +# state :string +# subscription_invoice_issuing_date_adjustment :enum +# subscription_invoice_issuing_date_anchor :enum +# tax_identification_number :string +# timezone :string +# url :string +# vat_rate :float +# zipcode :string +# created_at :datetime not null +# updated_at :datetime not null +# applied_dunning_campaign_id :uuid +# billing_entity_id :uuid not null +# external_id :string not null +# external_salesforce_id :string +# organization_id :uuid not null +# sequential_id :bigint +# +# Indexes +# +# index_customers_on_account_type (account_type) +# index_customers_on_applied_dunning_campaign_id (applied_dunning_campaign_id) +# index_customers_on_awaiting_wallet_refresh (awaiting_wallet_refresh) +# index_customers_on_billing_entity_id (billing_entity_id) +# index_customers_on_deleted_at (deleted_at) +# index_customers_on_external_id (organization_id,external_id) +# index_customers_on_external_id_and_organization_id (external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_customers_on_org_id_and_sequential_id_unique (organization_id,sequential_id) UNIQUE WHERE (sequential_id IS NOT NULL) +# index_customers_on_sequential_id (sequential_id) +# +# Foreign Keys +# +# fk_rails_... (applied_dunning_campaign_id => dunning_campaigns.id) +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/customer/applied_invoice_custom_section.rb b/app/models/customer/applied_invoice_custom_section.rb new file mode 100644 index 0000000..623ab59 --- /dev/null +++ b/app/models/customer/applied_invoice_custom_section.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Customer::AppliedInvoiceCustomSection < ApplicationRecord + self.table_name = "customers_invoice_custom_sections" + + belongs_to :organization + belongs_to :billing_entity + belongs_to :customer + belongs_to :invoice_custom_section +end + +# == Schema Information +# +# Table name: customers_invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid not null +# customer_id :uuid not null +# invoice_custom_section_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# idx_on_billing_entity_id_customer_id_invoice_custom_e7aada65cb (billing_entity_id,customer_id,invoice_custom_section_id) UNIQUE +# idx_on_invoice_custom_section_id_5f37496c8c (invoice_custom_section_id) +# index_customers_invoice_custom_sections_on_billing_entity_id (billing_entity_id) +# index_customers_invoice_custom_sections_on_customer_id (customer_id) +# index_customers_invoice_custom_sections_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (invoice_custom_section_id => invoice_custom_sections.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/customer/applied_tax.rb b/app/models/customer/applied_tax.rb new file mode 100644 index 0000000..46b2889 --- /dev/null +++ b/app/models/customer/applied_tax.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Customer + class AppliedTax < ApplicationRecord + self.table_name = "customers_taxes" + + include PaperTrailTraceable + + belongs_to :customer + belongs_to :tax + belongs_to :organization + end +end + +# == Schema Information +# +# Table name: customers_taxes +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid not null +# +# Indexes +# +# index_customers_taxes_on_customer_id (customer_id) +# index_customers_taxes_on_customer_id_and_tax_id (customer_id,tax_id) UNIQUE +# index_customers_taxes_on_organization_id (organization_id) +# index_customers_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/daily_usage.rb b/app/models/daily_usage.rb new file mode 100644 index 0000000..cfd58b9 --- /dev/null +++ b/app/models/daily_usage.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +class DailyUsage < ApplicationRecord + DEFAULT_HISTORY_DAYS = 120 + + belongs_to :organization + belongs_to :customer + belongs_to :subscription + + scope :usage_date_in_timezone, ->(timestamp) do + at_time_zone = Utils::Timezone.at_time_zone_sql(customer: "cus", billing_entity: "billing_entities") + + joins("INNER JOIN customers AS cus ON daily_usages.customer_id = cus.id") + .joins("INNER JOIN billing_entities ON cus.billing_entity_id = billing_entities.id") + .where("DATE((daily_usages.usage_date)#{at_time_zone}) = DATE(:timestamp#{at_time_zone})", timestamp:) + end +end + +# == Schema Information +# +# Table name: daily_usages +# Database name: primary +# +# id :uuid not null, primary key +# from_datetime :datetime not null +# refreshed_at :datetime not null +# to_datetime :datetime not null +# usage :jsonb not null +# usage_date :date +# usage_diff :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_subscription_id :string not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# idx_on_organization_id_external_subscription_id_df3a30d96d (organization_id,external_subscription_id) +# index_daily_usages_on_customer_id (customer_id) +# index_daily_usages_on_organization_id (organization_id) +# index_daily_usages_on_subscription_id (subscription_id) +# index_daily_usages_on_usage_date (usage_date) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/data_export.rb b/app/models/data_export.rb new file mode 100644 index 0000000..21f6435 --- /dev/null +++ b/app/models/data_export.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class DataExport < ApplicationRecord + EXPORT_FORMATS = %w[csv].freeze + STATUSES = %w[pending processing completed failed].freeze + EXPIRATION_PERIOD = 7.days + + belongs_to :organization + belongs_to :membership + has_one :user, through: :membership + + has_one_attached :file + + has_many :data_export_parts + + enum :format, EXPORT_FORMATS, validate: true + enum :status, STATUSES, validate: true + + validates :resource_type, presence: true + validates :file, attached: true, if: :completed? + + def processing! + update!(status: "processing", started_at: Time.zone.now) + end + + def completed! + update!( + status: "completed", + completed_at: Time.zone.now, + expires_at: EXPIRATION_PERIOD.from_now + ) + end + + def expired? + return false unless expires_at + + expires_at < Time.zone.now + end + + def filename + "#{created_at.strftime("%Y%m%d%H%M%S")}_#{resource_type}.#{format}" + end + + def file_url + return if file.blank? + + blob_path = Rails.application.routes.url_helpers.rails_blob_path( + file, + host: "void", + expires_in: EXPIRATION_PERIOD + ) + + File.join(ENV["LAGO_API_URL"], blob_path) + end + + def export_class + case resource_type + when "credit_notes" then DataExports::Csv::CreditNotes + when "credit_note_items" then DataExports::Csv::CreditNoteItems + when "invoices" then DataExports::Csv::Invoices + when "invoice_fees" then DataExports::Csv::InvoiceFees + end + end +end + +# == Schema Information +# +# Table name: data_exports +# Database name: primary +# +# id :uuid not null, primary key +# completed_at :datetime +# expires_at :datetime +# format :integer +# resource_query :jsonb +# resource_type :string not null +# started_at :datetime +# status :integer default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# membership_id :uuid +# organization_id :uuid not null +# +# Indexes +# +# index_data_exports_on_membership_id (membership_id) +# index_data_exports_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (membership_id => memberships.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/data_export_part.rb b/app/models/data_export_part.rb new file mode 100644 index 0000000..de335ed --- /dev/null +++ b/app/models/data_export_part.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class DataExportPart < ApplicationRecord + belongs_to :data_export + belongs_to :organization + + scope :completed, -> { where(completed: true) } +end + +# == Schema Information +# +# Table name: data_export_parts +# Database name: primary +# +# id :uuid not null, primary key +# completed :boolean default(FALSE), not null +# csv_lines :text +# index :integer +# object_ids :uuid not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# data_export_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_data_export_parts_on_data_export_id (data_export_id) +# index_data_export_parts_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (data_export_id => data_exports.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/deprecation.rb b/app/models/deprecation.rb new file mode 100644 index 0000000..a9f2381 --- /dev/null +++ b/app/models/deprecation.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "csv" + +class Deprecation + EXPIRE_IN = 1.month + + class << self + def report(feature_name, organization_id) + Rails.cache.write(cache_key(feature_name, organization_id, "last_seen_at"), Time.current, expires_in: EXPIRE_IN) + Rails.cache.increment(cache_key(feature_name, organization_id, "count"), 1, expires_in: EXPIRE_IN) + end + + def get_all(feature_name) + Organization.pluck(:id).filter_map do |organization_id| + h = get(feature_name, organization_id) + h[:last_seen_at] ? h : nil + end + end + + def get_all_as_csv(feature_name) + lines = get_all(feature_name) + orgs = Organization.find(lines.pluck(:organization_id)).index_by(&:id) + + CSV.generate do |csv| + csv << %w[org_id org_name org_email last_event_sent_at count] + + lines.each do |d| + o = orgs[d[:organization_id]] + csv << [d[:organization_id], o.name, o.email, d[:last_seen_at], d[:count]] + end + end + end + + def get(feature_name, organization_id) + last_seen_at = Rails.cache.read(cache_key(feature_name, organization_id, "last_seen_at")) + count = Rails.cache.read(cache_key(feature_name, organization_id, "count"), raw: true).to_i + + {organization_id:, last_seen_at:, count:} + end + + def reset_all(feature_name) + Organization.pluck(:id).each do |organization_id| + reset(feature_name, organization_id) + end + end + + def reset(feature_name, organization_id) + Rails.cache.delete(cache_key(feature_name, organization_id, "count")) + Rails.cache.delete(cache_key(feature_name, organization_id, "last_seen_at")) + end + + private + + def cache_key(feature_name, organization_id, suffix) + "deprecation:#{feature_name}:#{organization_id}:#{suffix}" + end + end +end diff --git a/app/models/dunning_campaign.rb b/app/models/dunning_campaign.rb new file mode 100644 index 0000000..51bfc82 --- /dev/null +++ b/app/models/dunning_campaign.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +class DunningCampaign < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + self.ignored_columns += %w[applied_to_organization] + + ORDERS = %w[name code].freeze + + belongs_to :organization + + has_many :thresholds, class_name: "DunningCampaignThreshold", dependent: :destroy + has_many :customers, foreign_key: :applied_dunning_campaign_id, dependent: :nullify + has_many :billing_entities, foreign_key: :applied_dunning_campaign_id + has_many :payment_requests, dependent: :nullify + + accepts_nested_attributes_for :thresholds + + validates :name, presence: true + validates :bcc_emails, email_array: true + validates :days_between_attempts, numericality: {greater_than: 0} + validates :max_attempts, numericality: {greater_than: 0} + validates :code, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id}, + unless: :deleted_at + + default_scope -> { kept } + scope :applied_to_organization, -> { where(applied_to_organization: true) } + scope :with_currency_threshold, ->(currencies) { + joins(:thresholds) + .where(dunning_campaign_thresholds: {currency: currencies}) + .distinct + } + + def self.ransackable_attributes(_auth_object = nil) + %w[name code] + end + + def reset_customers_last_attempt + # NOTE: Reset last attempt on customers with the campaign applied explicitly + customers.update_all( # rubocop:disable Rails/SkipsModelValidations + last_dunning_campaign_attempt: 0, + last_dunning_campaign_attempt_at: nil + ) + + # NOTE: Reset last attempt on customers falling back to the billing_entity campaign + billing_entities.includes(:customers).map(&:reset_customers_last_dunning_campaign_attempt) + end +end + +# == Schema Information +# +# Table name: dunning_campaigns +# Database name: primary +# +# id :uuid not null, primary key +# bcc_emails :string default([]), is an Array +# code :string not null +# days_between_attempts :integer default(1), not null +# deleted_at :datetime +# description :text +# max_attempts :integer default(1), not null +# name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_dunning_campaigns_on_deleted_at (deleted_at) +# index_dunning_campaigns_on_organization_id (organization_id) +# index_dunning_campaigns_on_organization_id_and_code (organization_id,code) UNIQUE WHERE (deleted_at IS NULL) +# index_unique_applied_to_organization_per_organization (organization_id) UNIQUE WHERE ((applied_to_organization = true) AND (deleted_at IS NULL)) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/dunning_campaign_threshold.rb b/app/models/dunning_campaign_threshold.rb new file mode 100644 index 0000000..d8c97a7 --- /dev/null +++ b/app/models/dunning_campaign_threshold.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class DunningCampaignThreshold < ApplicationRecord + include Currencies + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :dunning_campaign + belongs_to :organization + + validates :amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :currency, inclusion: {in: currency_list} + validates :currency, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :dunning_campaign_id}, + unless: :deleted_at + + default_scope -> { kept } +end + +# == Schema Information +# +# Table name: dunning_campaign_thresholds +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# currency :string not null +# deleted_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# dunning_campaign_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# idx_on_dunning_campaign_id_currency_fbf233b2ae (dunning_campaign_id,currency) UNIQUE WHERE (deleted_at IS NULL) +# index_dunning_campaign_thresholds_on_deleted_at (deleted_at) +# index_dunning_campaign_thresholds_on_dunning_campaign_id (dunning_campaign_id) +# index_dunning_campaign_thresholds_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (dunning_campaign_id => dunning_campaigns.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/enriched_event.rb b/app/models/enriched_event.rb new file mode 100644 index 0000000..8e1e210 --- /dev/null +++ b/app/models/enriched_event.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class EnrichedEvent < EventsRecord + belongs_to :event + + validates :code, + :timestamp, + :transaction_id, + :external_subscription_id, + :organization_id, + :subscription_id, + :plan_id, + :charge_id, + :enriched_at, presence: true +end + +# == Schema Information +# +# Table name: enriched_events +# Database name: events +# +# id :uuid not null +# code :string not null +# decimal_value :decimal(40, 15) default(0.0), not null +# enriched_at :datetime not null +# grouped_by :jsonb not null +# operation_type :string +# precise_total_amount_cents :decimal(40, 15) +# target_wallet_code :string +# timestamp :datetime not null +# value :string +# charge_filter_id :uuid +# charge_id :uuid not null +# event_id :uuid not null +# external_subscription_id :string not null +# organization_id :uuid not null +# plan_id :uuid not null +# subscription_id :uuid not null +# transaction_id :string not null +# +# Indexes +# +# idx_billing_on_enriched_events (organization_id,subscription_id,charge_id,charge_filter_id,timestamp) +# idx_lookup_on_enriched_events (organization_id,external_subscription_id,code,timestamp) +# idx_unique_on_enriched_events (organization_id,external_subscription_id,transaction_id,timestamp,charge_id) UNIQUE +# index_enriched_events_on_event_id (event_id) +# diff --git a/app/models/enriched_store_migration.rb b/app/models/enriched_store_migration.rb new file mode 100644 index 0000000..70e73b5 --- /dev/null +++ b/app/models/enriched_store_migration.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +class EnrichedStoreMigration < ApplicationRecord + include AASM + + belongs_to :organization + + has_many :subscription_migrations, + class_name: "EnrichedStoreSubscriptionMigration", + dependent: :destroy + + STATUSES = { + pending: "pending", + checking: "checking", + processing: "processing", + enabling: "enabling", + completed: "completed", + failed: "failed" + }.freeze + + enum :status, STATUSES, validate: true + + aasm column: "status", timestamps: true do + state :pending, initial: true + state :checking + state :processing + state :enabling + state :completed + state :failed + + event :start_check do + transitions from: :pending, to: :checking + end + + event :start_processing do + transitions from: :checking, to: :processing + end + + event :start_enabling do + transitions from: :processing, to: :enabling + end + + event :complete do + transitions from: :enabling, to: :completed + end + + event :fail do + transitions from: [:pending, :checking, :processing, :enabling], to: :failed + end + + event :retry_migration do + transitions from: :failed, to: :pending + end + end + + def all_subscriptions_completed? + subscription_migrations.exists? && subscription_migrations.where.not(status: :completed).none? + end +end + +# == Schema Information +# +# Table name: enriched_store_migrations +# Database name: primary +# +# id :uuid not null, primary key +# completed_at :datetime +# error_message :text +# started_at :datetime +# status :enum default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_enriched_store_migrations_on_organization_id (organization_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/enriched_store_subscription_migration.rb b/app/models/enriched_store_subscription_migration.rb new file mode 100644 index 0000000..6ccbf76 --- /dev/null +++ b/app/models/enriched_store_subscription_migration.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +class EnrichedStoreSubscriptionMigration < ApplicationRecord + include AASM + + belongs_to :enriched_store_migration + belongs_to :subscription + belongs_to :organization + + STATUSES = { + pending: "pending", + comparing: "comparing", + reprocessing: "reprocessing", + waiting_for_enrichment: "waiting_for_enrichment", + deduplicating: "deduplicating", + dedup_paused: "dedup_paused", + validating: "validating", + completed: "completed", + failed: "failed" + }.freeze + + enum :status, STATUSES, validate: true + + aasm column: "status", timestamps: true do + state :pending, initial: true + state :comparing + state :reprocessing + state :waiting_for_enrichment + state :deduplicating + state :dedup_paused + state :validating + state :completed + state :failed + + event :start_comparing do + transitions from: :pending, to: :comparing + end + + event :start_reprocessing do + transitions from: :comparing, to: :reprocessing + end + + event :start_waiting do + transitions from: :reprocessing, to: :waiting_for_enrichment + end + + event :start_deduplicating do + transitions from: :waiting_for_enrichment, to: :deduplicating + end + + event :pause_dedup do + transitions from: :deduplicating, to: :dedup_paused + end + + event :start_validating do + transitions from: [:deduplicating, :dedup_paused], to: :validating + end + + event :complete do + transitions from: [:comparing, :validating], to: :completed + end + + event :fail do + transitions from: [ + :pending, :comparing, :reprocessing, :waiting_for_enrichment, + :deduplicating, :dedup_paused, :validating + ], to: :failed + end + + event :retry_migration do + transitions from: :failed, to: :pending + end + end +end + +# == Schema Information +# +# Table name: enriched_store_subscription_migrations +# Database name: primary +# +# id :uuid not null, primary key +# attempts :integer default(0) +# billable_metric_codes :jsonb +# comparison_results :jsonb +# completed_at :datetime +# dedup_pending_queries :jsonb +# duplicates_removed_count :integer default(0) +# error_message :text +# events_reprocessed_count :integer default(0) +# started_at :datetime +# status :enum default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# enriched_store_migration_id :uuid not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# idx_enriched_store_sub_migrations_on_migration_and_subscription (enriched_store_migration_id,subscription_id) UNIQUE +# idx_on_enriched_store_migration_id_e409c5dc43 (enriched_store_migration_id) +# idx_on_organization_id_2be2ef98ea (organization_id) +# idx_on_subscription_id_b41afd08e0 (subscription_id) +# +# Foreign Keys +# +# fk_rails_... (enriched_store_migration_id => enriched_store_migrations.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/entitlement.rb b/app/models/entitlement.rb new file mode 100644 index 0000000..ecf7a04 --- /dev/null +++ b/app/models/entitlement.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Entitlement + def self.table_name_prefix + "entitlement_" + end +end diff --git a/app/models/entitlement/entitlement.rb b/app/models/entitlement/entitlement.rb new file mode 100644 index 0000000..4c4c129 --- /dev/null +++ b/app/models/entitlement/entitlement.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Entitlement + class Entitlement < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + default_scope -> { kept } + + belongs_to :organization + belongs_to :feature, class_name: "Entitlement::Feature", foreign_key: :entitlement_feature_id + belongs_to :plan, optional: true + belongs_to :subscription, optional: true + has_many :values, class_name: "Entitlement::EntitlementValue", foreign_key: :entitlement_entitlement_id, dependent: :destroy + + validate :exactly_one_parent_present + + private + + def exactly_one_parent_present + return if plan_id.present? && subscription_id.blank? + return if plan_id.blank? && subscription_id.present? + + errors.add(:base, "one_of_plan_or_subscription_required") + end + end +end + +# == Schema Information +# +# Table name: entitlement_entitlements +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# entitlement_feature_id :uuid not null +# organization_id :uuid not null +# plan_id :uuid +# subscription_id :uuid +# +# Indexes +# +# idx_unique_feature_per_plan (entitlement_feature_id,plan_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_unique_feature_per_subscription (entitlement_feature_id,subscription_id) UNIQUE WHERE (deleted_at IS NULL) +# index_entitlement_entitlements_on_entitlement_feature_id (entitlement_feature_id) +# index_entitlement_entitlements_on_organization_id (organization_id) +# index_entitlement_entitlements_on_plan_id (plan_id) +# index_entitlement_entitlements_on_subscription_id (subscription_id) +# +# Foreign Keys +# +# fk_rails_... (entitlement_feature_id => entitlement_features.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/entitlement/entitlement_value.rb b/app/models/entitlement/entitlement_value.rb new file mode 100644 index 0000000..e87111e --- /dev/null +++ b/app/models/entitlement/entitlement_value.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Entitlement + class EntitlementValue < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + default_scope -> { kept } + + belongs_to :organization + belongs_to :privilege, class_name: "Entitlement::Privilege", foreign_key: :entitlement_privilege_id + belongs_to :entitlement, class_name: "Entitlement::Entitlement", foreign_key: :entitlement_entitlement_id + + validates :entitlement_privilege_id, presence: true + validates :entitlement_entitlement_id, presence: true + validates :value, presence: true + end +end + +# == Schema Information +# +# Table name: entitlement_entitlement_values +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# value :string not null +# created_at :datetime not null +# updated_at :datetime not null +# entitlement_entitlement_id :uuid not null +# entitlement_privilege_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# idx_on_entitlement_entitlement_id_48c0b3356a (entitlement_entitlement_id) +# idx_on_entitlement_privilege_id_6a228dc433 (entitlement_privilege_id) +# idx_on_entitlement_privilege_id_entitlement_entitle_9d0542eb1a (entitlement_privilege_id,entitlement_entitlement_id) UNIQUE WHERE (deleted_at IS NULL) +# index_entitlement_entitlement_values_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (entitlement_entitlement_id => entitlement_entitlements.id) +# fk_rails_... (entitlement_privilege_id => entitlement_privileges.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/entitlement/feature.rb b/app/models/entitlement/feature.rb new file mode 100644 index 0000000..1810de9 --- /dev/null +++ b/app/models/entitlement/feature.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Entitlement + class Feature < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + default_scope -> { kept } + + belongs_to :organization + has_many :privileges, class_name: "Entitlement::Privilege", foreign_key: "entitlement_feature_id", dependent: :destroy + has_many :entitlements, class_name: "Entitlement::Entitlement", foreign_key: "entitlement_feature_id", dependent: :destroy + has_many :entitlement_values, through: :entitlements, source: :values, class_name: "Entitlement::EntitlementValue", dependent: :destroy + has_many :plans, through: :entitlements + + validates :code, presence: true, length: {maximum: 255} + validates :name, length: {maximum: 255} + validates :description, length: {maximum: 600} + + def self.ransackable_attributes(_auth_object = nil) + %w[code name description] + end + + def subscriptions_count + base_scope = Subscription.joins(:plan).where(status: [:active, :pending]) + base_scope.where(plan: plans).or(base_scope.where(plan: {parent: plans})).count + end + end +end + +# == Schema Information +# +# Table name: entitlement_features +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# description :string +# name :string +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# idx_features_code_unique_per_organization (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_entitlement_features_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/entitlement/privilege.rb b/app/models/entitlement/privilege.rb new file mode 100644 index 0000000..f7afd94 --- /dev/null +++ b/app/models/entitlement/privilege.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Entitlement + class Privilege < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + default_scope -> { kept } + + VALUE_TYPES = %w[integer string boolean select].freeze + + belongs_to :organization + belongs_to :feature, class_name: "Entitlement::Feature", foreign_key: :entitlement_feature_id + has_many :values, class_name: "Entitlement::EntitlementValue", foreign_key: :entitlement_privilege_id, dependent: :destroy + has_many :entitlements, through: :values, class_name: "Entitlement::Entitlement" + + validates :code, presence: true, length: {maximum: 255} + validates :name, length: {maximum: 255} + validates :value_type, presence: true, inclusion: {in: VALUE_TYPES} + + validate :validate_config + + private + + def validate_config + errors.add(:config, :invalid_format) unless config_valid? + end + + # Config is only used for `select` value_type, and it should contain a list of select_options + # All other value_types should have an empty config + def config_valid? + if value_type == "select" + config&.keys == ["select_options"] && + config["select_options"].is_a?(Array) && + !config["select_options"].empty? && + config["select_options"].all? { it.is_a?(String) } + else + config.blank? + end + end + end +end + +# == Schema Information +# +# Table name: entitlement_privileges +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# config :jsonb not null +# deleted_at :datetime +# name :string +# value_type :enum default("string"), not null +# created_at :datetime not null +# updated_at :datetime not null +# entitlement_feature_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# idx_privileges_code_unique_per_feature (code,entitlement_feature_id) UNIQUE WHERE (deleted_at IS NULL) +# index_entitlement_privileges_on_entitlement_feature_id (entitlement_feature_id) +# index_entitlement_privileges_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (entitlement_feature_id => entitlement_features.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/entitlement/subscription_entitlement.rb b/app/models/entitlement/subscription_entitlement.rb new file mode 100644 index 0000000..c438fab --- /dev/null +++ b/app/models/entitlement/subscription_entitlement.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionEntitlement + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :organization_id, :string + attribute :entitlement_feature_id, :string + attribute :code, :string + attribute :name, :string + attribute :description, :string + attribute :plan_entitlement_id, :string + attribute :sub_entitlement_id, :string + attribute :plan_id, :string + attribute :subscription_id, :string + attribute :ordering_date, :datetime + + attribute :privileges + + def self.for_subscription(subscription) + SubscriptionEntitlementQuery.call( + organization: subscription.organization, + filters: { + subscription_id: subscription.id, + plan_id: subscription.plan.parent_id || subscription.plan.id + } + ) + end + + def to_h + h = attributes + h["privileges"] = (h["privileges"] || []).map(&:to_h).index_by { |p| p[:code] } + h.with_indifferent_access + end + end +end diff --git a/app/models/entitlement/subscription_entitlement_privilege.rb b/app/models/entitlement/subscription_entitlement_privilege.rb new file mode 100644 index 0000000..bc09c82 --- /dev/null +++ b/app/models/entitlement/subscription_entitlement_privilege.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionEntitlementPrivilege + include ActiveModel::Model + include ActiveModel::Attributes + + attribute :organization_id, :string + attribute :entitlement_feature_id, :string + attribute :code, :string + attribute :value, :string + attribute :value_type, :string + attribute :plan_value, :string + attribute :subscription_value, :string + attribute :name, :string + attribute :value_type, :string + attribute :config + attribute :ordering_date, :datetime + attribute :plan_entitlement_id, :string + attribute :sub_entitlement_id, :string + attribute :plan_entitlement_value_id, :string + attribute :sub_entitlement_value_id, :string + + def config + super.then { it.is_a?(String) ? JSON.parse(it) : it } + end + + def to_h + h = attributes + h["config"] = config + h.with_indifferent_access + end + end +end diff --git a/app/models/entitlement/subscription_feature_removal.rb b/app/models/entitlement/subscription_feature_removal.rb new file mode 100644 index 0000000..9e56d28 --- /dev/null +++ b/app/models/entitlement/subscription_feature_removal.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionFeatureRemoval < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + default_scope -> { kept } + + belongs_to :organization + belongs_to :subscription + belongs_to :feature, optional: true, class_name: "Entitlement::Feature", foreign_key: :entitlement_feature_id + belongs_to :privilege, optional: true, class_name: "Entitlement::Privilege", foreign_key: :entitlement_privilege_id + end +end + +# == Schema Information +# +# Table name: entitlement_subscription_feature_removals +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# entitlement_feature_id :uuid +# entitlement_privilege_id :uuid +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# idx_on_entitlement_feature_id_821ae72311 (entitlement_feature_id) +# idx_on_entitlement_privilege_id_9946ccf514 (entitlement_privilege_id) +# idx_on_organization_id_7020c3c43a (organization_id) +# idx_on_subscription_id_295edd8bb3 (subscription_id) +# idx_unique_feature_removal_per_subscription (subscription_id,entitlement_feature_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_unique_privilege_removal_per_subscription (subscription_id,entitlement_privilege_id) UNIQUE WHERE (deleted_at IS NULL) +# index_entitlement_subscription_feature_removals_on_deleted_at (deleted_at) +# +# Foreign Keys +# +# fk_rails_... (entitlement_feature_id => entitlement_features.id) +# fk_rails_... (entitlement_privilege_id => entitlement_privileges.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/error_detail.rb b/app/models/error_detail.rb new file mode 100644 index 0000000..be5ac9b --- /dev/null +++ b/app/models/error_detail.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class ErrorDetail < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + belongs_to :owner, polymorphic: true + belongs_to :organization + + ERROR_CODES = { + not_provided: 0, + tax_error: 1, + tax_voiding_error: 2, + invoice_generation_error: 3 + }.freeze + enum :error_code, ERROR_CODES, validate: true + + def self.create_generation_error_for(invoice:, error:) + return unless invoice + instance = find_or_create_by(owner: invoice, error_code: "invoice_generation_error", organization: invoice.organization) + instance.update( + details: { + backtrace: error.backtrace, + error: error.inspect.to_json, + invoice: invoice.to_json(except: [:file, :xml_file]), + subscriptions: invoice.subscriptions.to_json + } + ) + instance + end +end + +# == Schema Information +# +# Table name: error_details +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# details :jsonb not null +# error_code :integer default("not_provided"), not null +# owner_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# owner_id :uuid not null +# +# Indexes +# +# index_error_details_on_deleted_at (deleted_at) +# index_error_details_on_error_code (error_code) +# index_error_details_on_organization_id (organization_id) +# index_error_details_on_owner (owner_type,owner_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/event.rb b/app/models/event.rb new file mode 100644 index 0000000..5da0f31 --- /dev/null +++ b/app/models/event.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class Event < EventsRecord + include Discard::Model + + self.discard_column = :deleted_at + + include CustomerTimezone + include OrganizationTimezone + + belongs_to :organization + has_many :enriched_events + + validates :transaction_id, presence: true + validates :code, presence: true + + default_scope -> { kept } + scope :from_datetime, ->(from_datetime) { where("events.timestamp >= ?", from_datetime) } + scope :to_datetime, ->(to_datetime) { where("events.timestamp <= ?", to_datetime) } + + def api_client + metadata["user_agent"] + end + + def ip_address + metadata["ip_address"] + end + + def billable_metric + @billable_metric ||= organization.billable_metrics.find_by(code:) + end + + def customer + organization + .customers + .with_discarded + .where(external_id: external_customer_id) + .where("deleted_at IS NULL OR deleted_at > ?", timestamp) + .order("deleted_at DESC NULLS LAST") + .first + end + + def subscription + scope = if external_customer_id && customer + if external_subscription_id + customer.subscriptions.where(external_id: external_subscription_id) + else + customer.subscriptions + end + else + organization.subscriptions.where(external_id: external_subscription_id) + end + + scope + .where("date_trunc('millisecond', started_at::timestamp) <= ?::timestamp", timestamp) + .where( + "terminated_at IS NULL OR date_trunc('millisecond', terminated_at::timestamp) >= ?", + timestamp + ) + .order("terminated_at DESC NULLS FIRST, started_at DESC") + .first + end +end + +# == Schema Information +# +# Table name: events +# Database name: events +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# metadata :jsonb not null +# precise_total_amount_cents :decimal(40, 15) +# properties :jsonb not null +# timestamp :datetime +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid +# external_customer_id :string +# external_subscription_id :string +# organization_id :uuid not null +# subscription_id :uuid +# transaction_id :string not null +# +# Indexes +# +# idx_events_billing_lookup (external_subscription_id,organization_id,code,timestamp) WHERE (deleted_at IS NULL) +# idx_events_for_distinct_codes (external_subscription_id,organization_id,timestamp) WHERE (deleted_at IS NULL) +# index_events_on_created_at (created_at) WHERE (deleted_at IS NULL) +# index_events_on_organization_id (organization_id) +# index_events_on_organization_id_and_code (organization_id,code) +# index_events_on_organization_id_and_created_at (organization_id,created_at DESC) WHERE (deleted_at IS NULL) +# index_events_on_organization_id_and_transaction_id (organization_id,transaction_id) WHERE (deleted_at IS NULL) +# index_unique_transaction_id (organization_id,external_subscription_id,transaction_id) UNIQUE +# diff --git a/app/models/events/common.rb b/app/models/events/common.rb new file mode 100644 index 0000000..bf6a061 --- /dev/null +++ b/app/models/events/common.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Events + Common = Struct.new( + :id, + :organization_id, + :transaction_id, + :external_subscription_id, + :timestamp, + :code, + :properties, + :precise_total_amount_cents, + :persisted, + keyword_init: true + ) do + def initialize(**args) + super + self[:persisted] = true unless args.key?(:persisted) + end + + def event_id + id || transaction_id + end + + def organization + @organization ||= Organization.find_by(id: organization_id) + end + + def billable_metric + @billable_metric ||= organization.billable_metrics.find_by(code: code) + end + + def subscription + return @subscription if defined? @subscription + + @subscription = organization + .subscriptions + .where(external_id: external_subscription_id) + .where("date_trunc('millisecond', started_at::timestamp) <= ?::timestamp", timestamp) + .where( + "terminated_at IS NULL OR date_trunc('millisecond', terminated_at::timestamp) >= ?", + timestamp + ) + .order("terminated_at DESC NULLS FIRST, started_at DESC") + .first + end + + def as_json + super.tap { |j| j["timestamp"] = timestamp.to_f } + end + end +end diff --git a/app/models/events/last_hour_mv.rb b/app/models/events/last_hour_mv.rb new file mode 100644 index 0000000..28acd6e --- /dev/null +++ b/app/models/events/last_hour_mv.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Events + class LastHourMv < ApplicationRecord + self.table_name = "last_hour_events_mv" + + def readonly? + true + end + end +end + +# == Schema Information +# +# Table name: last_hour_events_mv +# Database name: primary +# +# billable_metric_code :string +# field_name_mandatory :boolean +# field_value :text +# has_filter_keys :boolean +# has_valid_filter_values :boolean +# is_numeric_field_value :boolean +# numeric_field_mandatory :boolean +# properties :jsonb +# organization_id :uuid +# transaction_id :string +# diff --git a/app/models/events_record.rb b/app/models/events_record.rb new file mode 100644 index 0000000..7e91256 --- /dev/null +++ b/app/models/events_record.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class EventsRecord < ApplicationRecord + self.abstract_class = true + + connects_to database: {writing: :events, reading: :events} +end diff --git a/app/models/feature_flag.rb b/app/models/feature_flag.rb new file mode 100644 index 0000000..c482c44 --- /dev/null +++ b/app/models/feature_flag.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class FeatureFlag + DEFINITION = begin + yaml = YAML.parse_file(Rails.root.join("app/config/feature_flags.yaml")) + yaml.presence&.to_ruby || {} # Handle empty yaml file + end.with_indifferent_access.freeze + + class << self + def valid?(flag) + DEFINITION.key?(flag) + end + + def validate!(flag) + return if Rails.env.production? + + raise ArgumentError, "Unknown feature flag: #{flag}" unless valid?(flag) + end + + def sanitize! + valid_keys = DEFINITION.keys + + Organization.where.not(feature_flags: []).find_each do |organization| + valid_flags = organization.feature_flags & valid_keys + + organization.update!(feature_flags: valid_flags) + end + end + end +end diff --git a/app/models/fee.rb b/app/models/fee.rb new file mode 100644 index 0000000..8b1da9b --- /dev/null +++ b/app/models/fee.rb @@ -0,0 +1,408 @@ +# frozen_string_literal: true + +class Fee < ApplicationRecord + include Currencies + include Discard::Model + + self.discard_column = :deleted_at + self.ignored_columns += %w[duplicated_in_advance] + default_scope -> { kept } + + belongs_to :invoice, optional: true + belongs_to :charge, -> { with_discarded }, optional: true + belongs_to :add_on, -> { with_discarded }, optional: true + belongs_to :applied_add_on, optional: true + belongs_to :subscription, optional: true + belongs_to :charge_filter, -> { with_discarded }, optional: true + belongs_to :group, -> { with_discarded }, optional: true + belongs_to :invoiceable, polymorphic: true, optional: true + belongs_to :true_up_parent_fee, class_name: "Fee", optional: true + belongs_to :original_fee, class_name: "Fee", optional: true # Points to the root fee in a void/regenerate chain + belongs_to :organization + belongs_to :billing_entity + belongs_to :fixed_charge, -> { with_discarded }, optional: true + + has_one :adjusted_fee, dependent: :nullify + has_one :billable_metric, -> { with_discarded }, through: :charge + has_one :fixed_charge_add_on, -> { with_discarded }, class_name: "AddOn", through: :fixed_charge, source: :add_on + has_one :customer, through: :subscription + has_one :pricing_unit_usage, dependent: :destroy + has_one :true_up_fee, class_name: "Fee", foreign_key: :true_up_parent_fee_id, dependent: :destroy + + has_many :credit_note_items, dependent: :destroy + has_many :credit_notes, through: :credit_note_items + + has_many :applied_taxes, class_name: "Fee::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + has_many :presentation_breakdowns, dependent: :destroy + + monetize :amount_cents + monetize :taxes_amount_cents, with_model_currency: :currency + monetize :total_amount_cents + monetize :precise_amount_cents, with_model_currency: :currency + monetize :taxes_precise_amount_cents, with_model_currency: :currency + monetize :precise_total_amount_cents + monetize :unit_amount_cents, disable_validation: true, allow_nil: true, with_model_currency: :currency + + # TODO: Deprecate add_on type in the near future + FEE_TYPES = %i[charge add_on subscription credit commitment fixed_charge].freeze + PAYMENT_STATUS = %i[pending succeeded failed refunded].freeze + + enum :fee_type, FEE_TYPES + enum :payment_status, PAYMENT_STATUS, prefix: :payment + + validates :amount_currency, inclusion: {in: currency_list} + validates :units, numericality: {greater_than_or_equal_to: 0} + validates :events_count, numericality: {greater_than_or_equal_to: 0}, allow_nil: true + validates :true_up_fee_id, presence: false, unless: :charge? + validates :total_aggregated_units, presence: true, if: :charge? + + scope :positive_units, -> { where("fees.units > ?", 0) } + + # NOTE: pay_in_advance fees are not be linked to any invoice, but add_on fees does not have any subscriptions + # so we need a bit of logic to find the fee in the right organization scope + scope :from_organization, ->(org) { where(organization_id: org.id) } + scope :from_organization_pay_in_advance, ->(org) { from_organization(org).where(invoice_id: nil) } + + scope :from_customer, + lambda { |org, external_customer_id| + union = [from_customer_invoice(org, external_customer_id), from_customer_pay_in_advance(org, external_customer_id)] + .map(&:to_sql) + .join(") UNION (") + unionized_sql = "((#{union})) #{table_name}" + from(unionized_sql) + } + + scope :from_customer_invoice, ->(org, external_customer_id) do + from_organization(org) + .joins(invoice: :customer) + .where(customer: {external_id: external_customer_id}) + end + scope :from_customer_pay_in_advance, ->(org, external_customer_id) do + from_organization_pay_in_advance(org).joins(subscription: :customer).where("customers.external_id = ?", external_customer_id) + end + scope :ordered_by_period, -> do + from = Arel.sql("(properties->>'from_datetime')::timestamptz NULLS LAST") + to = Arel.sql("(properties->>'to_datetime')::timestamptz NULLS LAST") + + order(from, to) + end + + def item_key + id || object_id + end + + def item_id + return billable_metric.id if charge? + return add_on.id if add_on? + return invoiceable_id if credit? + return fixed_charge_add_on.id if fixed_charge? + + subscription_id + end + + def item_type + return BillableMetric.name if charge? + return AddOn.name if add_on? + return WalletTransaction.name if credit? + return AddOn.name if fixed_charge? + + Subscription.name + end + + def item_code + return billable_metric.code if charge? + return add_on.code if add_on? + return fee_type if credit? + return fixed_charge_add_on.code if fixed_charge? + + subscription.plan.code + end + + def item_name + return billable_metric.name if charge? + return add_on.name if add_on? + return invoiceable&.name.presence || fee_type if credit? + return fixed_charge_add_on.name if fixed_charge? + + subscription.plan.name + end + + def item_source + return fixed_charge_add_on.code if fixed_charge? + return add_on.code if add_on? + return "consumed_credits" if credit? + + subscription&.plan&.code.presence || billable_metric&.code + end + + def item_description + return billable_metric.description if charge? + return add_on.description if add_on? + return fee_type if credit? + return fixed_charge_add_on.description if fixed_charge? + + subscription.plan.description + end + + def invoice_name + return invoice_display_name if invoice_display_name.present? + return charge.invoice_display_name.presence || billable_metric.name if charge? + return add_on.invoice_name if add_on? + return invoiceable&.name.presence || fee_type if credit? + return fixed_charge.invoice_display_name.presence || fixed_charge_add_on.invoice_name if fixed_charge? + + subscription.invoice_name + end + + def filter_display_name(separator: ", ") + charge_filter&.display_name(separator:) + end + + def invoice_sorting_clause + base_clause = "#{invoice_name} #{filter_display_name}".downcase + + return base_clause unless charge? + return base_clause if grouped_by.blank? + + "#{invoice_name} #{grouped_by.values.join} #{filter_display_name}".downcase + end + + def currency + amount_currency + end + + def basic_rate_percentage? + return false unless charge? + return false unless charge.percentage? + + if charge_filter + charge_filter.properties.keys == ["rate"] + else + charge.properties.keys == ["rate"] + end + end + + def compute_precise_credit_amount_cents(credit_amount, base_amount_cents) + return 0 if base_amount_cents.zero? + + (credit_amount * (amount_cents - precise_coupons_amount_cents)).fdiv(base_amount_cents) + end + + def sub_total_excluding_taxes_amount_cents + amount_cents - precise_coupons_amount_cents + end + + def sub_total_excluding_taxes_precise_amount_cents + precise_amount_cents - precise_coupons_amount_cents + end + + def total_amount_cents + amount_cents + taxes_amount_cents + end + alias_method :total_amount_currency, :currency + + def precise_total_amount_cents + precise_amount_cents + taxes_precise_amount_cents + end + alias_method :precise_total_amount_currency, :currency + + def offsettable_amount_cents + if invoice.credit? && (invoice.payment_pending? || invoice.payment_failed?) + return amount_cents + end + + creditable_amount_cents + end + + def creditable_amount_cents + remaining_amount = amount_cents - credit_note_items.sum(:amount_cents) + + if credit? + return [remaining_amount, creditable_from_wallet_amount_cents].min + end + + remaining_amount + end + + def creditable_from_wallet_amount_cents + return 0 unless credit? && active_prepaid_credit_fee_wallet? + + if prepaid_credit_fee_wallet.traceable? + invoiceable.remaining_amount_cents || 0 + else + prepaid_credit_fee_wallet.balance_cents + end + end + + def prepaid_credit_fee_wallet + return unless credit? + + # For historical fees, the invoiceable association might be missing, so we need to handle that case. + return unless invoiceable + + # For historical wallet transaction, the wallet association might be missing, so may return nil. + invoiceable.wallet + end + + # There are add_on type and one_off type so in order not to mix those two types with associations, + # this method is added to handle it. In the near future we will deprecate add_on type and remove this method + def add_on + return @add_on if defined? @add_on + + return super if add_on_id.present? + return unless add_on? + + @add_on = AddOn.with_discarded.find_by(id: applied_add_on.add_on_id) + end + + def has_charge_filters? + charge&.filters&.any? + end + + def non_zero? + units.positive? || amount_cents.positive? || events_count.to_i.positive? + end + + def date_boundaries + if charge? && !pay_in_advance? && charge.pay_in_advance? + timestamp = invoice.invoice_subscription(subscription.id).timestamp + interval = ::Subscriptions::DatesService.charge_pay_in_advance_interval(timestamp, subscription) + + return { + from_date: interval[:charges_from_date]&.to_datetime&.iso8601, + to_date: interval[:charges_to_date]&.to_datetime&.end_of_day&.iso8601 + } + end + + if charge? && !charge.invoiceable? && pay_in_advance? + timestamp = Time.parse(properties["timestamp"]).to_i + interval = ::Subscriptions::DatesService.charge_pay_in_advance_interval(timestamp, subscription) + + return { + from_date: interval[:charges_from_date]&.to_datetime&.iso8601, + to_date: interval[:charges_to_date]&.to_datetime&.end_of_day&.iso8601 + } + end + + { + from_date:, + to_date: + } + end + + private + + def active_prepaid_credit_fee_wallet? + prepaid_credit_fee_wallet&.active? + end + + def from_date + property = if charge? + "charges_from_datetime" + elsif fixed_charge? + "fixed_charges_from_datetime" + else + "from_datetime" + end + properties[property]&.to_datetime&.iso8601 + end + + def to_date + property = if charge? + "charges_to_datetime" + elsif fixed_charge? + "fixed_charges_to_datetime" + else + "to_datetime" + end + properties[property]&.to_datetime&.iso8601 + end +end + +# == Schema Information +# +# Table name: fees +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# amount_currency :string not null +# amount_details :jsonb not null +# deleted_at :datetime +# description :string +# events_count :integer +# failed_at :datetime +# fee_type :integer +# grouped_by :jsonb not null +# invoice_display_name :string +# invoiceable_type :string +# pay_in_advance :boolean default(FALSE), not null +# payment_status :integer default("pending"), not null +# precise_amount_cents :decimal(40, 15) default(0.0), not null +# precise_coupons_amount_cents :decimal(30, 5) default(0.0), not null +# precise_credit_notes_amount_cents :decimal(30, 5) default(0.0), not null +# precise_unit_amount :decimal(30, 15) default(0.0), not null +# properties :jsonb not null +# refunded_at :datetime +# succeeded_at :datetime +# taxes_amount_cents :bigint not null +# taxes_base_rate :float default(1.0), not null +# taxes_precise_amount_cents :decimal(40, 15) default(0.0), not null +# taxes_rate :float default(0.0), not null +# total_aggregated_units :decimal(, ) +# unit_amount_cents :bigint default(0), not null +# units :decimal(, ) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# add_on_id :uuid +# applied_add_on_id :uuid +# billing_entity_id :uuid not null +# charge_filter_id :uuid +# charge_id :uuid +# fixed_charge_id :uuid +# group_id :uuid +# invoice_id :uuid +# invoiceable_id :uuid +# organization_id :uuid not null +# original_fee_id :uuid +# pay_in_advance_event_id :uuid +# pay_in_advance_event_transaction_id :string +# subscription_id :uuid +# true_up_parent_fee_id :uuid +# +# Indexes +# +# idx_pay_in_advance_duplication_guard_charge (pay_in_advance_event_transaction_id,charge_id) UNIQUE WHERE ((deleted_at IS NULL) AND (charge_filter_id IS NULL) AND (pay_in_advance_event_transaction_id IS NOT NULL) AND (pay_in_advance = true) AND (duplicated_in_advance = false) AND (original_fee_id IS NULL)) +# idx_pay_in_advance_duplication_guard_charge_filter (pay_in_advance_event_transaction_id,charge_id,charge_filter_id) UNIQUE WHERE ((deleted_at IS NULL) AND (charge_filter_id IS NOT NULL) AND (pay_in_advance_event_transaction_id IS NOT NULL) AND (pay_in_advance = true) AND (duplicated_in_advance = false) AND (original_fee_id IS NULL)) +# index_fees_on_add_on_id (add_on_id) +# index_fees_on_applied_add_on_id (applied_add_on_id) +# index_fees_on_billing_entity_id (billing_entity_id) +# index_fees_on_charge_filter_id (charge_filter_id) +# index_fees_on_charge_id (charge_id) +# index_fees_on_charge_id_and_invoice_id (charge_id,invoice_id) WHERE (deleted_at IS NULL) +# index_fees_on_deleted_at (deleted_at) +# index_fees_on_fixed_charge_id (fixed_charge_id) +# index_fees_on_group_id (group_id) +# index_fees_on_invoice_id (invoice_id) +# index_fees_on_invoiceable (invoiceable_type,invoiceable_id) +# index_fees_on_organization_id (organization_id) +# index_fees_on_original_fee_id (original_fee_id) +# index_fees_on_pay_in_advance_event_transaction_id (pay_in_advance_event_transaction_id) WHERE (deleted_at IS NULL) +# index_fees_on_subscription_id (subscription_id) +# index_fees_on_true_up_parent_fee_id (true_up_parent_fee_id) +# +# Foreign Keys +# +# fk_rails_... (add_on_id => add_ons.id) +# fk_rails_... (applied_add_on_id => applied_add_ons.id) +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (charge_id => charges.id) +# fk_rails_... (fixed_charge_id => fixed_charges.id) +# fk_rails_... (group_id => groups.id) +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (original_fee_id => fees.id) +# fk_rails_... (subscription_id => subscriptions.id) +# fk_rails_... (true_up_parent_fee_id => fees.id) +# diff --git a/app/models/fee/applied_tax.rb b/app/models/fee/applied_tax.rb new file mode 100644 index 0000000..b32b448 --- /dev/null +++ b/app/models/fee/applied_tax.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +class Fee + class AppliedTax < ApplicationRecord + self.table_name = "fees_taxes" + + include PaperTrailTraceable + + belongs_to :organization + belongs_to :fee + # NOTE: Tax isn't really optional, but we used to hard deleted taxes, + # so some AppliedTax had no tax relation + belongs_to :tax, -> { with_discarded }, optional: true + end +end + +# == Schema Information +# +# Table name: fees_taxes +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint default(0), not null +# amount_currency :string not null +# precise_amount_cents :decimal(40, 15) default(0.0), not null +# tax_code :string not null +# tax_description :string +# tax_name :string not null +# tax_rate :float default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# fee_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid +# +# Indexes +# +# index_fees_taxes_on_fee_id (fee_id) +# index_fees_taxes_on_fee_id_and_tax_id (fee_id,tax_id) UNIQUE WHERE ((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone)) +# index_fees_taxes_on_organization_id (organization_id) +# index_fees_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (fee_id => fees.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) ON DELETE => nullify +# diff --git a/app/models/fixed_charge.rb b/app/models/fixed_charge.rb new file mode 100644 index 0000000..41e7d00 --- /dev/null +++ b/app/models/fixed_charge.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +class FixedCharge < ApplicationRecord + include PaperTrailTraceable + include ChargePropertiesValidation + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + belongs_to :organization + belongs_to :plan, -> { with_discarded }, touch: true + belongs_to :add_on, -> { with_discarded }, touch: true + belongs_to :parent, class_name: "FixedCharge", optional: true + has_many :children, class_name: "FixedCharge", foreign_key: :parent_id, dependent: :nullify + has_many :applied_taxes, class_name: "FixedCharge::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + has_many :fees + has_many :events, class_name: "FixedChargeEvent", dependent: :destroy + + has_many :applied_taxes, class_name: "FixedCharge::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + + scope :pay_in_advance, -> { where(pay_in_advance: true) } + scope :pay_in_arrears, -> { where(pay_in_advance: false) } + scope :parents, -> { where(parent_id: nil) } + + CHARGE_MODELS = { + standard: "standard", + graduated: "graduated", + volume: "volume" + }.freeze + + enum :charge_model, CHARGE_MODELS + + validates :units, numericality: {greater_than_or_equal_to: 0} + validates :charge_model, :code, presence: true + validates :pay_in_advance, exclusion: [nil] + validates :prorated, exclusion: [nil] + validates :properties, presence: true + + validate :validate_code_unique + validate :validate_pay_in_advance + validate :validate_prorated + validate :validate_properties + + def equal_properties?(fixed_charge) + charge_model == fixed_charge.charge_model && + properties == fixed_charge.properties && + units == fixed_charge.units + end + + # When upgrading a subscription with fixed_charges paid_in_advance, + # this exact charge might have already been paid at the beginning of billing period. + # in case of prorating, we need to deduct the prorated amount (remaining of the billing_period) + # that was already paid from the new price. + def matching_fixed_charge_prev_subscription(subscription) + return nil if subscription.previous_subscription.nil? + + subscription.previous_subscription.plan.fixed_charges.find_by(add_on_id:) + end + + private + + def validate_pay_in_advance + return unless pay_in_advance? + + if volume? + errors.add(:pay_in_advance, :invalid_charge_model) + end + end + + # NOTE: A prorated fixed charge is valid in the following cases: + # - standard model with any payment timing + # - volume model with pay_in_arrears only + # - graduated model with pay_in_arrears only + # Graduated + pay_in_advance + prorated is NOT allowed + def validate_prorated + return unless prorated? + + if graduated? && pay_in_advance? + errors.add(:prorated, :invalid_charge_model) + end + end + + def validate_properties + return if properties.blank? + + validate_charge_model_properties(charge_model) + end + + def validate_code_unique + return unless plan + return if parent_id? + + fixed_charge = plan.fixed_charges.parents.where(code:).first + errors.add(:code, :taken) if fixed_charge && fixed_charge != self + end +end + +# == Schema Information +# +# Table name: fixed_charges +# Database name: primary +# +# id :uuid not null, primary key +# charge_model :enum default("standard"), not null +# code :string not null +# deleted_at :datetime +# invoice_display_name :string +# pay_in_advance :boolean default(FALSE), not null +# properties :jsonb not null +# prorated :boolean default(FALSE), not null +# units :decimal(30, 10) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# add_on_id :uuid not null +# organization_id :uuid not null +# parent_id :uuid +# plan_id :uuid not null +# +# Indexes +# +# index_fixed_charges_on_add_on_id (add_on_id) +# index_fixed_charges_on_deleted_at (deleted_at) +# index_fixed_charges_on_organization_id (organization_id) +# index_fixed_charges_on_parent_id (parent_id) +# index_fixed_charges_on_plan_id (plan_id) +# index_fixed_charges_on_plan_id_and_code (plan_id,code) UNIQUE WHERE ((deleted_at IS NULL) AND (parent_id IS NULL)) +# +# Foreign Keys +# +# fk_rails_... (add_on_id => add_ons.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (plan_id => plans.id) +# diff --git a/app/models/fixed_charge/applied_tax.rb b/app/models/fixed_charge/applied_tax.rb new file mode 100644 index 0000000..2791176 --- /dev/null +++ b/app/models/fixed_charge/applied_tax.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class FixedCharge::AppliedTax < ApplicationRecord + self.table_name = "fixed_charges_taxes" + + belongs_to :fixed_charge + belongs_to :tax + belongs_to :organization +end + +# == Schema Information +# +# Table name: fixed_charges_taxes +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# fixed_charge_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid not null +# +# Indexes +# +# index_fixed_charges_taxes_on_fixed_charge_id (fixed_charge_id) +# index_fixed_charges_taxes_on_fixed_charge_id_and_tax_id (fixed_charge_id,tax_id) UNIQUE +# index_fixed_charges_taxes_on_organization_id (organization_id) +# index_fixed_charges_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (fixed_charge_id => fixed_charges.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/fixed_charge_event.rb b/app/models/fixed_charge_event.rb new file mode 100644 index 0000000..6260569 --- /dev/null +++ b/app/models/fixed_charge_event.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class FixedChargeEvent < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + belongs_to :subscription + belongs_to :fixed_charge + + validates :units, numericality: {greater_than_or_equal_to: 0} + + default_scope -> { kept } +end + +# == Schema Information +# +# Table name: fixed_charge_events +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# timestamp :datetime +# units :decimal(30, 10) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# fixed_charge_id :uuid not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# index_fixed_charge_events_on_deleted_at (deleted_at) +# index_fixed_charge_events_on_fixed_charge_id (fixed_charge_id) +# index_fixed_charge_events_on_organization_id (organization_id) +# index_fixed_charge_events_on_subscription_id (subscription_id) +# +# Foreign Keys +# +# fk_rails_... (fixed_charge_id => fixed_charges.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/group.rb b/app/models/group.rb new file mode 100644 index 0000000..2322183 --- /dev/null +++ b/app/models/group.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +class Group < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :billable_metric, -> { with_discarded } + belongs_to :parent, -> { with_discarded }, class_name: "Group", foreign_key: "parent_group_id", optional: true + has_many :children, class_name: "Group", foreign_key: "parent_group_id" + has_many :properties, class_name: "GroupProperty" + has_many :fees + + validates :key, :value, presence: true + + default_scope -> { kept } + scope :parents, -> { where(parent_group_id: nil) } + scope :children, -> { where.not(parent_group_id: nil) } + + def name + parent ? "#{parent.value} • #{value}" : value + end + + # NOTE: Discard group and children with properties. + def discard_with_properties! + children.each { |c| c.properties&.update_all(deleted_at: Time.current) && c.discard! } && properties.update_all(deleted_at: Time.current) && discard! # rubocop:disable Rails/SkipsModelValidations + end +end + +# == Schema Information +# +# Table name: groups +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# key :string not null +# value :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid not null +# parent_group_id :uuid +# +# Indexes +# +# index_groups_on_billable_metric_id (billable_metric_id) +# index_groups_on_billable_metric_id_and_parent_group_id (billable_metric_id,parent_group_id) +# index_groups_on_deleted_at (deleted_at) +# index_groups_on_parent_group_id (parent_group_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) ON DELETE => cascade +# fk_rails_... (parent_group_id => groups.id) +# diff --git a/app/models/group_property.rb b/app/models/group_property.rb new file mode 100644 index 0000000..0b785b4 --- /dev/null +++ b/app/models/group_property.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class GroupProperty < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :charge + belongs_to :group, -> { with_discarded } + + validates :values, presence: true + validates :group_id, presence: true, uniqueness: {scope: :charge_id} + + default_scope -> { kept } +end + +# == Schema Information +# +# Table name: group_properties +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# invoice_display_name :string +# values :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# charge_id :uuid not null +# group_id :uuid not null +# +# Indexes +# +# index_group_properties_on_charge_id (charge_id) +# index_group_properties_on_charge_id_and_group_id (charge_id,group_id) UNIQUE +# index_group_properties_on_deleted_at (deleted_at) +# index_group_properties_on_group_id (group_id) +# +# Foreign Keys +# +# fk_rails_... (charge_id => charges.id) ON DELETE => cascade +# fk_rails_... (group_id => groups.id) ON DELETE => cascade +# diff --git a/app/models/idempotency_record.rb b/app/models/idempotency_record.rb new file mode 100644 index 0000000..ead8a56 --- /dev/null +++ b/app/models/idempotency_record.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# # IdempotencyRecord is a low-level model used for tracking idempotent operations. +# +# This class provides the database representation for idempotency tracking, +# but direct usage is discouraged. Instead, use the higher-level API provided +# by the Idempotency class +class IdempotencyRecord < ApplicationRecord + belongs_to :resource, polymorphic: true, optional: true + belongs_to :organization, optional: true +end + +# == Schema Information +# +# Table name: idempotency_records +# Database name: primary +# +# id :uuid not null, primary key +# idempotency_key :binary not null +# resource_type :string +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid +# resource_id :uuid +# +# Indexes +# +# index_idempotency_records_on_idempotency_key (idempotency_key) UNIQUE +# index_idempotency_records_on_organization_id (organization_id) +# index_idempotency_records_on_resource_type_and_resource_id (resource_type,resource_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/inbound_webhook.rb b/app/models/inbound_webhook.rb new file mode 100644 index 0000000..df935b7 --- /dev/null +++ b/app/models/inbound_webhook.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class InboundWebhook < ApplicationRecord + WEBHOOK_PROCESSING_WINDOW = 2.hours + + belongs_to :organization + + validates :event_type, :payload, :source, :status, presence: true + + STATUSES = { + pending: "pending", + processing: "processing", + succeeded: "succeeded", + failed: "failed" + } + + enum :status, STATUSES + + scope :retriable, -> { reprocessable.or(old_pending) } + scope :reprocessable, -> { processing.where("processing_at <= ?", WEBHOOK_PROCESSING_WINDOW.ago) } + scope :old_pending, -> { pending.where("created_at <= ?", WEBHOOK_PROCESSING_WINDOW.ago) } + + def processing! + update!(status: :processing, processing_at: Time.zone.now) + end +end + +# == Schema Information +# +# Table name: inbound_webhooks +# Database name: primary +# +# id :uuid not null, primary key +# code :string +# event_type :string not null +# payload :jsonb not null +# processing_at :datetime +# signature :string +# source :string not null +# status :enum default("pending"), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_inbound_webhooks_on_organization_id (organization_id) +# index_inbound_webhooks_on_status_and_created_at (status,created_at) WHERE (status = 'pending'::inbound_webhook_status) +# index_inbound_webhooks_on_status_and_processing_at (status,processing_at) WHERE (status = 'processing'::inbound_webhook_status) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_collection_mappings/anrok_collection_mapping.rb b/app/models/integration_collection_mappings/anrok_collection_mapping.rb new file mode 100644 index 0000000..21197dc --- /dev/null +++ b/app/models/integration_collection_mappings/anrok_collection_mapping.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class AnrokCollectionMapping < BaseCollectionMapping + end +end + +# == Schema Information +# +# Table name: integration_collection_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_collection_mappings/avalara_collection_mapping.rb b/app/models/integration_collection_mappings/avalara_collection_mapping.rb new file mode 100644 index 0000000..29f1ce5 --- /dev/null +++ b/app/models/integration_collection_mappings/avalara_collection_mapping.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class AvalaraCollectionMapping < BaseCollectionMapping + end +end + +# == Schema Information +# +# Table name: integration_collection_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_collection_mappings/base_collection_mapping.rb b/app/models/integration_collection_mappings/base_collection_mapping.rb new file mode 100644 index 0000000..d0fa019 --- /dev/null +++ b/app/models/integration_collection_mappings/base_collection_mapping.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class BaseCollectionMapping < ApplicationRecord + include PaperTrailTraceable + include SettingsStorable + + self.table_name = "integration_collection_mappings" + + belongs_to :integration, class_name: "Integrations::BaseIntegration" + belongs_to :organization + belongs_to :billing_entity, optional: true + + MAPPING_TYPES = %i[ + fallback_item + coupon + subscription_fee + minimum_commitment + tax + prepaid_credit + credit_note + account + currencies + ].freeze + + enum :mapping_type, MAPPING_TYPES, validate: true + + validates :mapping_type, presence: true + validates :mapping_type, + uniqueness: {scope: [:integration_id, :organization_id, :billing_entity_id]} + + validate :validate_billing_entity_organization + + settings_accessors :external_id, :external_account_code, :external_name + + private + + def validate_billing_entity_organization + return unless billing_entity + + if billing_entity.organization_id != organization_id + errors.add(:billing_entity, :invalid) + end + end + end +end + +# == Schema Information +# +# Table name: integration_collection_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_collection_mappings/netsuite_collection_mapping.rb b/app/models/integration_collection_mappings/netsuite_collection_mapping.rb new file mode 100644 index 0000000..2052a51 --- /dev/null +++ b/app/models/integration_collection_mappings/netsuite_collection_mapping.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class NetsuiteCollectionMapping < BaseCollectionMapping + settings_accessors :tax_nexus, :tax_type, :tax_code + settings_accessors :currencies + + validate :currency_mapping_format + validate :organization_level_only_mapping + + private + + def organization_level_only_mapping + if currencies? && billing_entity_id.present? + errors.add(:billing_entity, "value_must_be_blank") + end + end + + def currency_mapping_format + # Other mapping_types shouldn't have currencies, but if they do, we validate the format + return if !currencies? && currencies.nil? + + if !currencies? && currencies.present? + errors.add(:currencies, "value_must_be_blank") + return + end + + if currencies? && currencies.nil? + errors.add(:currencies, "value_is_mandatory") + return + end + + if !currencies.is_a?(Hash) + errors.add(:currencies, "invalid_format") + elsif currencies.empty? + errors.add(:currencies, "cannot_be_empty") + elsif !currencies_hash_valid? + errors.add(:currencies, "invalid_format") + end + end + + def currencies_hash_valid? + valid_currencies = Currencies::ACCEPTED_CURRENCIES.keys + currencies.keys.all? { it.is_a?(String) && valid_currencies.include?(it.to_sym) } && + currencies.values.all? { it.is_a?(String) && it.present? } + end + end +end + +# == Schema Information +# +# Table name: integration_collection_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_collection_mappings/xero_collection_mapping.rb b/app/models/integration_collection_mappings/xero_collection_mapping.rb new file mode 100644 index 0000000..cbdc520 --- /dev/null +++ b/app/models/integration_collection_mappings/xero_collection_mapping.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class XeroCollectionMapping < BaseCollectionMapping + end +end + +# == Schema Information +# +# Table name: integration_collection_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mapping_type :integer not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_int_collection_mappings_unique_billing_entity_is_not_null (mapping_type,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_int_collection_mappings_unique_billing_entity_is_null (mapping_type,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# index_integration_collection_mappings_on_billing_entity_id (billing_entity_id) +# index_integration_collection_mappings_on_integration_id (integration_id) +# index_integration_collection_mappings_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_customers/anrok_customer.rb b/app/models/integration_customers/anrok_customer.rb new file mode 100644 index 0000000..2f76da1 --- /dev/null +++ b/app/models/integration_customers/anrok_customer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class AnrokCustomer < BaseCustomer + end +end + +# == Schema Information +# +# Table name: integration_customers +# Database name: primary +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_customer_id :string +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_customers_on_customer_id (customer_id) +# index_integration_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_integration_customers_on_external_customer_id (external_customer_id) +# index_integration_customers_on_integration_id (integration_id) +# index_integration_customers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_customers/avalara_customer.rb b/app/models/integration_customers/avalara_customer.rb new file mode 100644 index 0000000..20abd64 --- /dev/null +++ b/app/models/integration_customers/avalara_customer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class AvalaraCustomer < BaseCustomer + end +end + +# == Schema Information +# +# Table name: integration_customers +# Database name: primary +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_customer_id :string +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_customers_on_customer_id (customer_id) +# index_integration_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_integration_customers_on_external_customer_id (external_customer_id) +# index_integration_customers_on_integration_id (integration_id) +# index_integration_customers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_customers/base_customer.rb b/app/models/integration_customers/base_customer.rb new file mode 100644 index 0000000..8711bf2 --- /dev/null +++ b/app/models/integration_customers/base_customer.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class BaseCustomer < ApplicationRecord + include PaperTrailTraceable + include SettingsStorable + + self.table_name = "integration_customers" + + belongs_to :customer + belongs_to :integration, class_name: "Integrations::BaseIntegration" + belongs_to :organization + + TAX_INTEGRATION_TYPES = %w[ + IntegrationCustomers::AnrokCustomer + IntegrationCustomers::AvalaraCustomer + ].freeze + + validates :customer_id, uniqueness: {scope: :type} + validate :only_one_tax_integration_per_customer, if: :tax_kind? + + scope :accounting_kind, -> do + where(type: %w[IntegrationCustomers::NetsuiteCustomer IntegrationCustomers::XeroCustomer]) + end + + scope :tax_kind, -> do + where(type: TAX_INTEGRATION_TYPES) + end + + scope :hubspot_kind, -> do + where(type: %w[IntegrationCustomers::HubspotCustomer]) + end + + scope :salesforce_kind, -> do + where(type: %w[IntegrationCustomers::SalesforceCustomer]) + end + + settings_accessors :sync_with_provider + + def self.customer_type(type) + case type + when "netsuite" + "IntegrationCustomers::NetsuiteCustomer" + when "okta" + "IntegrationCustomers::OktaCustomer" + when "anrok" + "IntegrationCustomers::AnrokCustomer" + when "avalara" + "IntegrationCustomers::AvalaraCustomer" + when "xero" + "IntegrationCustomers::XeroCustomer" + when "hubspot" + "IntegrationCustomers::HubspotCustomer" + when "salesforce" + "IntegrationCustomers::SalesforceCustomer" + else + raise(NotImplementedError) + end + end + + def tax_kind? + TAX_INTEGRATION_TYPES.include?(type) + end + + private + + def only_one_tax_integration_per_customer + conflict = IntegrationCustomers::BaseCustomer.tax_kind.where(customer_id:) + conflict = conflict.where.not(id:) if persisted? + + return unless conflict.exists? + + errors.add(:type, "tax_integration_exists") + end + end +end + +# == Schema Information +# +# Table name: integration_customers +# Database name: primary +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_customer_id :string +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_customers_on_customer_id (customer_id) +# index_integration_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_integration_customers_on_external_customer_id (external_customer_id) +# index_integration_customers_on_integration_id (integration_id) +# index_integration_customers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_customers/hubspot_customer.rb b/app/models/integration_customers/hubspot_customer.rb new file mode 100644 index 0000000..9d98ed8 --- /dev/null +++ b/app/models/integration_customers/hubspot_customer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class HubspotCustomer < BaseCustomer + settings_accessors :targeted_object, :email + + def object_type + if targeted_object == "contacts" + "contact" + else + "company" + end + end + end +end + +# == Schema Information +# +# Table name: integration_customers +# Database name: primary +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_customer_id :string +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_customers_on_customer_id (customer_id) +# index_integration_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_integration_customers_on_external_customer_id (external_customer_id) +# index_integration_customers_on_integration_id (integration_id) +# index_integration_customers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_customers/netsuite_customer.rb b/app/models/integration_customers/netsuite_customer.rb new file mode 100644 index 0000000..25d5b0f --- /dev/null +++ b/app/models/integration_customers/netsuite_customer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class NetsuiteCustomer < BaseCustomer + settings_accessors :subsidiary_id + end +end + +# == Schema Information +# +# Table name: integration_customers +# Database name: primary +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_customer_id :string +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_customers_on_customer_id (customer_id) +# index_integration_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_integration_customers_on_external_customer_id (external_customer_id) +# index_integration_customers_on_integration_id (integration_id) +# index_integration_customers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_customers/salesforce_customer.rb b/app/models/integration_customers/salesforce_customer.rb new file mode 100644 index 0000000..9c5dfe8 --- /dev/null +++ b/app/models/integration_customers/salesforce_customer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class SalesforceCustomer < BaseCustomer + end +end + +# == Schema Information +# +# Table name: integration_customers +# Database name: primary +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_customer_id :string +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_customers_on_customer_id (customer_id) +# index_integration_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_integration_customers_on_external_customer_id (external_customer_id) +# index_integration_customers_on_integration_id (integration_id) +# index_integration_customers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_customers/xero_customer.rb b/app/models/integration_customers/xero_customer.rb new file mode 100644 index 0000000..cd80e68 --- /dev/null +++ b/app/models/integration_customers/xero_customer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class XeroCustomer < BaseCustomer + end +end + +# == Schema Information +# +# Table name: integration_customers +# Database name: primary +# +# id :uuid not null, primary key +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# external_customer_id :string +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_customers_on_customer_id (customer_id) +# index_integration_customers_on_customer_id_and_type (customer_id,type) UNIQUE +# index_integration_customers_on_external_customer_id (external_customer_id) +# index_integration_customers_on_integration_id (integration_id) +# index_integration_customers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_item.rb b/app/models/integration_item.rb new file mode 100644 index 0000000..c43cad2 --- /dev/null +++ b/app/models/integration_item.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class IntegrationItem < ApplicationRecord + include PaperTrailTraceable + + belongs_to :integration, class_name: "Integrations::BaseIntegration" + belongs_to :organization + + ITEM_TYPES = [ + :standard, + :tax, + :account + ].freeze + + enum :item_type, ITEM_TYPES + + validates :external_id, presence: true, uniqueness: {scope: %i[integration_id item_type]} + + def self.ransackable_attributes(_auth_object = nil) + %w[external_account_code external_id external_name] + end +end + +# == Schema Information +# +# Table name: integration_items +# Database name: primary +# +# id :uuid not null, primary key +# external_account_code :string +# external_name :string +# item_type :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# external_id :string not null +# integration_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_int_items_on_external_id_and_int_id_and_type (external_id,integration_id,item_type) UNIQUE +# index_integration_items_on_integration_id (integration_id) +# index_integration_items_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_mappings/anrok_mapping.rb b/app/models/integration_mappings/anrok_mapping.rb new file mode 100644 index 0000000..6a9a458 --- /dev/null +++ b/app/models/integration_mappings/anrok_mapping.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IntegrationMappings + class AnrokMapping < BaseMapping + end +end + +# == Schema Information +# +# Table name: integration_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_mappings/avalara_mapping.rb b/app/models/integration_mappings/avalara_mapping.rb new file mode 100644 index 0000000..b16b4ce --- /dev/null +++ b/app/models/integration_mappings/avalara_mapping.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IntegrationMappings + class AvalaraMapping < BaseMapping + end +end + +# == Schema Information +# +# Table name: integration_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_mappings/base_mapping.rb b/app/models/integration_mappings/base_mapping.rb new file mode 100644 index 0000000..9278ba5 --- /dev/null +++ b/app/models/integration_mappings/base_mapping.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module IntegrationMappings + class BaseMapping < ApplicationRecord + include PaperTrailTraceable + include SettingsStorable + + self.table_name = "integration_mappings" + + belongs_to :integration, class_name: "Integrations::BaseIntegration" + belongs_to :mappable, polymorphic: true + belongs_to :organization + belongs_to :billing_entity, optional: true + + MAPPABLE_TYPES = %i[AddOn BillableMetric].freeze + + validates :mappable_type, inclusion: {in: MAPPABLE_TYPES.map(&:to_s)} + validates :mappable_type, + uniqueness: {scope: [:mappable_id, :integration_id, :organization_id, :billing_entity_id]} + validate :validate_billing_entity_organization + + settings_accessors :external_id, :external_account_code, :external_name + + private + + def validate_billing_entity_organization + return unless billing_entity + + errors.add(:billing_entity, "must belong to the same organization") if billing_entity.organization_id != organization_id + end + end +end + +# == Schema Information +# +# Table name: integration_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_mappings/netsuite_mapping.rb b/app/models/integration_mappings/netsuite_mapping.rb new file mode 100644 index 0000000..7eacbbc --- /dev/null +++ b/app/models/integration_mappings/netsuite_mapping.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IntegrationMappings + class NetsuiteMapping < BaseMapping + end +end + +# == Schema Information +# +# Table name: integration_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_mappings/xero_mapping.rb b/app/models/integration_mappings/xero_mapping.rb new file mode 100644 index 0000000..1e29ff7 --- /dev/null +++ b/app/models/integration_mappings/xero_mapping.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IntegrationMappings + class XeroMapping < BaseMapping + end +end + +# == Schema Information +# +# Table name: integration_mappings +# Database name: primary +# +# id :uuid not null, primary key +# mappable_type :string not null +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# integration_id :uuid not null +# mappable_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_integration_mappings_on_integration_id (integration_id) +# index_integration_mappings_on_mappable (mappable_type,mappable_id) +# index_integration_mappings_on_organization_id (organization_id) +# index_integration_mappings_unique_billing_entity_id_is_not_null (mappable_type,mappable_id,integration_id,billing_entity_id) UNIQUE WHERE (billing_entity_id IS NOT NULL) +# index_integration_mappings_unique_billing_entity_id_is_null (mappable_type,mappable_id,integration_id,organization_id) UNIQUE WHERE (billing_entity_id IS NULL) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) ON DELETE => cascade +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integration_resource.rb b/app/models/integration_resource.rb new file mode 100644 index 0000000..b744851 --- /dev/null +++ b/app/models/integration_resource.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class IntegrationResource < ApplicationRecord + include PaperTrailTraceable + + belongs_to :syncable, polymorphic: true + belongs_to :integration, class_name: "Integrations::BaseIntegration" + belongs_to :organization + + RESOURCE_TYPES = %i[invoice sales_order_deprecated payment credit_note subscription].freeze + + enum :resource_type, RESOURCE_TYPES +end + +# == Schema Information +# +# Table name: integration_resources +# Database name: primary +# +# id :uuid not null, primary key +# resource_type :integer default("invoice"), not null +# syncable_type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# external_id :string +# integration_id :uuid +# organization_id :uuid not null +# syncable_id :uuid not null +# +# Indexes +# +# index_integration_resources_on_integration_id (integration_id) +# index_integration_resources_on_organization_id (organization_id) +# index_integration_resources_on_syncable (syncable_type,syncable_id) +# +# Foreign Keys +# +# fk_rails_... (integration_id => integrations.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/anrok_integration.rb b/app/models/integrations/anrok_integration.rb new file mode 100644 index 0000000..d7b2479 --- /dev/null +++ b/app/models/integrations/anrok_integration.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + class AnrokIntegration < BaseIntegration + has_many :error_details, -> { where({error_details: {error_code: "tax_error"}}) }, + primary_key: :organization_id, + foreign_key: :organization_id + + validates :connection_id, :api_key, presence: true + + secrets_accessors :connection_id, :api_key + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/avalara_integration.rb b/app/models/integrations/avalara_integration.rb new file mode 100644 index 0000000..7d6a9a7 --- /dev/null +++ b/app/models/integrations/avalara_integration.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Integrations + class AvalaraIntegration < BaseIntegration + has_many :error_details, -> { where({error_details: {error_code: "tax_error"}}) }, + primary_key: :organization_id, + foreign_key: :organization_id + + validates :company_code, :connection_id, :account_id, :license_key, presence: true + + settings_accessors :account_id, :company_code, :company_id + secrets_accessors :connection_id, :license_key + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/base_integration.rb b/app/models/integrations/base_integration.rb new file mode 100644 index 0000000..2fa073a --- /dev/null +++ b/app/models/integrations/base_integration.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Integrations + class BaseIntegration < ApplicationRecord + include PaperTrailTraceable + include SecretsStorable + include SettingsStorable + + self.table_name = "integrations" + + INTEGRATION_ACCOUNTING_TYPES = %w[Integrations::NetsuiteIntegration Integrations::XeroIntegration].freeze + INTEGRATION_TAX_TYPES = %w[Integrations::AnrokIntegration Integrations::AvalaraIntegration].freeze + + belongs_to :organization + + has_many :integration_items, dependent: :destroy, foreign_key: :integration_id + has_many :integration_resources, dependent: :destroy, foreign_key: :integration_id + has_many :integration_mappings, + class_name: "IntegrationMappings::BaseMapping", + foreign_key: :integration_id, + dependent: :destroy + has_many :integration_collection_mappings, + class_name: "IntegrationCollectionMappings::BaseCollectionMapping", + foreign_key: :integration_id, + dependent: :destroy + has_many :integration_customers, + class_name: "IntegrationCustomers::BaseCustomer", + foreign_key: :integration_id, + dependent: :destroy + + validates :code, uniqueness: {scope: :organization_id} + validates :name, presence: true + + # Returns the short provider key, e.g. "netsuite", "anrok". + def provider_key + type.demodulize.delete_suffix("Integration").underscore + end + + def self.integration_type(type) + case type + when "netsuite" + "Integrations::NetsuiteIntegration" + when "okta" + "Integrations::OktaIntegration" + when "anrok" + "Integrations::AnrokIntegration" + when "avalara" + "Integrations::AvalaraIntegration" + when "xero" + "Integrations::XeroIntegration" + when "hubspot" + "Integrations::HubspotIntegration" + when "salesforce" + "Integrations::SalesforceIntegration" + else + raise(NotImplementedError) + end + end + + def external_id_key + "id" + end + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/hubspot_integration.rb b/app/models/integrations/hubspot_integration.rb new file mode 100644 index 0000000..caaf95c --- /dev/null +++ b/app/models/integrations/hubspot_integration.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Integrations + class HubspotIntegration < BaseIntegration + validates :connection_id, :default_targeted_object, presence: true + + settings_accessors :default_targeted_object, :sync_subscriptions, :sync_invoices, :subscriptions_object_type_id, + :invoices_object_type_id, :companies_properties_version, :contacts_properties_version, + :subscriptions_properties_version, :invoices_properties_version, :portal_id + secrets_accessors :connection_id + + TARGETED_OBJECTS = %w[companies contacts].freeze + + def companies_object_type_id + "0-2" + end + + def contacts_object_type_id + "0-1" + end + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/netsuite_integration.rb b/app/models/integrations/netsuite_integration.rb new file mode 100644 index 0000000..cdefe32 --- /dev/null +++ b/app/models/integrations/netsuite_integration.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Integrations + class NetsuiteIntegration < BaseIntegration + validates :connection_id, :client_secret, :client_id, :account_id, :script_endpoint_url, presence: true + + settings_accessors :client_id, + :legacy_script, + :sync_credit_notes, + :sync_invoices, + :sync_payments, + :script_endpoint_url, + :token_id + secrets_accessors :connection_id, :client_secret, :token_secret + + def account_id=(value) + push_to_settings(key: "account_id", value: value&.downcase&.strip&.split(" ")&.join("-")) + end + + def account_id + get_from_settings("account_id") + end + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/okta_integration.rb b/app/models/integrations/okta_integration.rb new file mode 100644 index 0000000..b66dee5 --- /dev/null +++ b/app/models/integrations/okta_integration.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Integrations + class OktaIntegration < BaseIntegration + validates :client_secret, :client_id, :domain, :organization_name, presence: true + validate :domain_uniqueness + + settings_accessors :client_id, :domain, :organization_name, :host + secrets_accessors :client_secret + + def host + get_from_settings("host") || "#{organization_name.downcase}.okta.com" + end + + private + + def domain_uniqueness + return if domain.blank? + + okta_integration = ::Integrations::OktaIntegration + .where("settings->>'domain' IS NOT NULL") + .where("settings->>'domain' = ?", domain) + .where.not(id:) + .exists? + + errors.add(:domain, "domain_not_unique") if okta_integration + end + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/salesforce_integration.rb b/app/models/integrations/salesforce_integration.rb new file mode 100644 index 0000000..b94ae87 --- /dev/null +++ b/app/models/integrations/salesforce_integration.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Integrations + class SalesforceIntegration < BaseIntegration + validates :instance_id, presence: true + validates :code, presence: true + + settings_accessors :instance_id + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/integrations/xero_integration.rb b/app/models/integrations/xero_integration.rb new file mode 100644 index 0000000..f4c9e18 --- /dev/null +++ b/app/models/integrations/xero_integration.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Integrations + class XeroIntegration < BaseIntegration + validates :connection_id, presence: true + + settings_accessors :sync_credit_notes, :sync_invoices, :sync_payments + secrets_accessors :connection_id + + def external_id_key + "item_code" + end + end +end + +# == Schema Information +# +# Table name: integrations +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_integrations_on_code_and_organization_id (code,organization_id) UNIQUE +# index_integrations_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/invite.rb b/app/models/invite.rb new file mode 100644 index 0000000..56d6fe1 --- /dev/null +++ b/app/models/invite.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class Invite < ApplicationRecord + include PaperTrailTraceable + + belongs_to :organization + belongs_to :recipient, class_name: "Membership", foreign_key: :membership_id, optional: true + + INVITE_STATUS = %i[ + pending + accepted + revoked + ].freeze + + enum :status, INVITE_STATUS + + validates :email, email: true + validates :token, uniqueness: true + + normalizes :email, with: ->(email) { EmailSanitizer.call(email) } + + def mark_as_revoked!(timestamp = Time.current) + self.revoked_at ||= timestamp + revoked! + end + + def mark_as_accepted!(timestamp = Time.current) + self.accepted_at ||= timestamp + accepted! + end +end + +# == Schema Information +# +# Table name: invites +# Database name: primary +# +# id :uuid not null, primary key +# accepted_at :datetime +# email :string not null +# revoked_at :datetime +# roles :string default([]), not null, is an Array +# status :integer default("pending"), not null +# token :string not null +# created_at :datetime not null +# updated_at :datetime not null +# membership_id :uuid +# organization_id :uuid not null +# +# Indexes +# +# index_invites_on_membership_id (membership_id) +# index_invites_on_organization_id (organization_id) +# index_invites_on_token (token) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (membership_id => memberships.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/invoice.rb b/app/models/invoice.rb new file mode 100644 index 0000000..b600484 --- /dev/null +++ b/app/models/invoice.rb @@ -0,0 +1,748 @@ +# frozen_string_literal: true + +class Invoice < ApplicationRecord + self.ignored_columns += [:negative_amount_cents] # TODO: remove when negative_amount_cents is removed from the database + + include AASM + include PaperTrailTraceable + include Sequenced + include RansackUuidSearch + + CREDIT_NOTES_MIN_VERSION = 2 + COUPON_BEFORE_VAT_VERSION = 3 + TAX_INVOICE_LABEL_COUNTRIES = %w[AU AE NZ ID SG].freeze + + # before_save :ensure_organization_sequential_id, if: -> { organization.per_organization? && !self_billed } + before_save :ensure_billing_entity_sequential_id, if: -> { billing_entity&.per_billing_entity? && !self_billed? } + before_save :ensure_number + before_save :set_finalized_at, if: -> { status_changed_to_finalized? } + + belongs_to :customer, -> { with_discarded } + belongs_to :organization + belongs_to :billing_entity, optional: true + belongs_to :payment_method, optional: true + + has_many :fees + has_many :credits + has_many :wallet_transactions + has_many :invoice_subscriptions + has_many :subscriptions, through: :invoice_subscriptions + has_many :plans, through: :subscriptions + has_many :metadata, class_name: "Metadata::InvoiceMetadata", dependent: :destroy + has_many :credit_notes + has_many :progressive_billing_credits, class_name: "Credit", foreign_key: :progressive_billing_invoice_id + has_many :invoice_settlements, foreign_key: :target_invoice_id + + has_many :applied_taxes, class_name: "Invoice::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + has_many :integration_resources, as: :syncable + has_many :error_details, as: :owner, dependent: :destroy + + has_many :applied_payment_requests, class_name: "PaymentRequest::AppliedInvoice" + has_many :payment_requests, through: :applied_payment_requests + has_many :payments, as: :payable + has_many :payment_receipts, through: :payments + + has_many :applied_usage_thresholds + has_many :usage_thresholds, through: :applied_usage_thresholds + has_many :applied_invoice_custom_sections + has_one :regenerated_invoice, class_name: "Invoice", foreign_key: :voided_invoice_id + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + as: :resource + + has_one_attached :file + has_one_attached :xml_file + + monetize :coupons_amount_cents, + :credit_notes_amount_cents, + :fees_amount_cents, + :prepaid_credit_amount_cents, + :progressive_billing_credit_amount_cents, + :sub_total_excluding_taxes_amount_cents, + :sub_total_including_taxes_amount_cents, + :total_amount_cents, + :total_paid_amount_cents, + :taxes_amount_cents, + with_model_currency: :currency + + # NOTE: Readonly fields + monetize :charge_amount_cents, + :subscription_amount_cents, + :total_due_amount_cents, + disable_validation: true, + allow_nil: true, + with_model_currency: :currency + + # NOTE: Prepaid credit breakdown - nil for historical invoices + monetize :prepaid_granted_credit_amount_cents, + :prepaid_purchased_credit_amount_cents, + disable_validation: true, + allow_nil: true, + with_model_currency: :currency + + INVOICE_TYPES = %i[subscription add_on credit one_off advance_charges progressive_billing].freeze + PAYMENT_STATUS = %i[pending succeeded failed].freeze + TAX_STATUSES = { + pending: "pending", + succeeded: "succeeded", + failed: "failed" + }.freeze + + VISIBLE_STATUS = {draft: 0, finalized: 1, voided: 2, failed: 4, pending: 7}.freeze + INVISIBLE_STATUS = {generating: 3, open: 5, closed: 6}.freeze + MANUALLY_PAYABLE_INVOICE_STATUS = %i[finalized open].freeze + STATUS = VISIBLE_STATUS.merge(INVISIBLE_STATUS).freeze + GENERATED_INVOICE_STATUSES = %w[finalized closed].freeze + + enum :invoice_type, INVOICE_TYPES + enum :payment_status, PAYMENT_STATUS, prefix: :payment + enum :status, STATUS + + attribute :tax_status, :string + enum :tax_status, TAX_STATUSES, prefix: :tax + + aasm column: "status", timestamps: true do + state :generating + state :draft + state :open + state :finalized + state :voided + state :failed + state :closed + state :pending + + event :finalize do + transitions from: :draft, to: :finalized + end + + event :void do + transitions from: :finalized, to: :voided, after: :handle_void_transition! + end + end + + sequenced scope: ->(invoice) { invoice.customer.invoices }, + lock_key: ->(invoice) { invoice.customer_id } + + scope :visible, -> { where(status: VISIBLE_STATUS.keys) } + scope :invisible, -> { where(status: INVISIBLE_STATUS.keys) } + scope :with_generated_number, -> { where(status: %w[finalized voided]) } + scope :ready_to_be_refreshed, -> { draft.where(ready_to_be_refreshed: true) } + scope :ready_to_be_finalized, -> { draft.where("COALESCE(expected_finalization_date, issuing_date) <= ?", Time.current.to_date) } + + scope :created_before, + lambda { |invoice| + where.not(id: invoice.id) + .where("invoices.created_at < ?", invoice.created_at) + } + + scope :payment_overdue, -> { where(payment_overdue: true) } + + scope :with_active_subscriptions, -> { + joins(:subscriptions) + .where(subscriptions: {status: "active"}) + .distinct + } + + scope :self_billed, -> { where(self_billed: true) } + scope :non_self_billed, -> { where(self_billed: false) } + + validates :issuing_date, :currency, presence: true + validates :timezone, timezone: true, allow_nil: true + validates :total_amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :payment_dispute_lost_at, absence: true, unless: :payment_dispute_losable? + + attr_writer :precalculated_offset_amount_cents + + def self.ransackable_attributes(_ = nil) + %w[id number] + end + + def self.ransackable_associations(_ = nil) + %w[customer] + end + + # Batch-loads offset_amount_cents for a collection of invoices in a single query, + # caching the result on each instance to avoid N+1 queries during serialization. + def self.preload_offset_amounts(invoices) + return unless invoices + + invoice_ids = invoices.map(&:id).compact + + offset_amounts = CreditNote + .where(invoice_id: invoice_ids) + .finalized + .group(:invoice_id) + .sum(:offset_amount_cents) + + invoices.each do |invoice| + invoice.precalculated_offset_amount_cents = (offset_amounts[invoice.id] || 0) + end + + invoices + end + + def payment_invoices + Invoice.where(id: id) + end + + def visible? + !invisible? + end + + def invisible? + INVISIBLE_STATUS.key?(status.to_sym) + end + + def file_url + return if file.blank? + + blob_path = Rails.application.routes.url_helpers.rails_blob_path( + file, + host: "void" + ) + + File.join(ENV["LAGO_API_URL"], blob_path) + end + + def xml_url + return if xml_file.blank? + + blob_path = Rails.application.routes.url_helpers.rails_blob_path( + xml_file, + host: "void" + ) + + File.join(ENV["LAGO_API_URL"], blob_path) + end + + def fee_total_amount_cents + amount_cents = fees.sum(:amount_cents) + taxes_amount_cents = fees.sum { |f| f.amount_cents * f.taxes_rate }.fdiv(100).round + amount_cents + taxes_amount_cents + end + + def charge_amount_cents + fees.charge.sum(:amount_cents) + end + + def subscription_amount_cents + fees.subscription.sum(:amount_cents) + end + + def invoice_subscription(subscription_id) + invoice_subscriptions.find_by(subscription_id:) + end + + def sorted_invoice_subscriptions + invoice_subscriptions.order_by_subscription_invoice_name + end + + def sorted_subscriptions + sorted_invoice_subscriptions.map(&:subscription) + end + + def subscription_fees(subscription_id) + invoice_subscription(subscription_id).fees + end + + def progressive_billing_credits_for_subscription(subscription) + credits.where( + progressive_billing_invoice_id: subscription.invoices.progressive_billing.select(:id) + ) + end + + def recurring_fees(subscription_id) + subscription_fees(subscription_id) + .joins(charge: :billable_metric) + .where(billable_metric: {recurring: true}) + .where(billable_metric: {aggregation_type: %i[sum_agg unique_count_agg]}) + .where(charge: {pay_in_advance: false}) + end + + def recurring_breakdown(fee) + service = case fee.charge.billable_metric.aggregation_type.to_sym + when :sum_agg + BillableMetrics::Breakdown::SumService + when :unique_count_agg + BillableMetrics::Breakdown::UniqueCountService + else + raise(NotImplementedError) + end + + filters = {charge_id: fee.charge_id} + if fee.charge_filter + result = ChargeFilters::MatchingAndIgnoredService.call(charge: fee.charge, filter: fee.charge_filter) + filters[:charge_filter] = fee.charge_filter if fee.charge_filter + filters[:matching_filters] = result.matching_filters + filters[:ignored_filters] = result.ignored_filters + end + + service.new( + event_store_class: Events::Stores::StoreFactory.store_class(organization:), + charge: fee.charge, + subscription: fee.subscription, + boundaries: { + from_datetime: Time.zone.parse(fee.properties["charges_from_datetime"]), + to_datetime: Time.zone.parse(fee.properties["charges_to_datetime"]), + charges_duration: fee.properties["charges_duration"] + }, + filters: + ).breakdown.breakdown + end + + def charge_pay_in_advance_proration_range(fee, timestamp) + date_service = Subscriptions::DatesService.new_instance( + fee.subscription, + Time.zone.at(timestamp), + current_usage: true + ) + + event = Event.find_by(id: fee.pay_in_advance_event_id) + + return {} unless event + + number_of_days = Utils::Datetime.date_diff_with_timezone( + event.timestamp, + date_service.charges_to_datetime, + customer.applicable_timezone + ) + + { + number_of_days:, + period_duration: date_service.charges_duration_in_days + } + end + + def offset_amount_cents + return @precalculated_offset_amount_cents if instance_variable_defined?(:@precalculated_offset_amount_cents) + + credit_notes.finalized.sum(:offset_amount_cents) + end + + def total_due_amount_cents + return 0 if voided? + total_amount_cents - total_paid_amount_cents - offset_amount_cents + end + + def total_settled_amount_cents + total_paid_amount_cents + offset_amount_cents + end + + # amount cents onto which we can issue a credit note + def available_to_credit_amount_cents + return 0 if version_number < CREDIT_NOTES_MIN_VERSION || draft? + + fees_total_creditable = fees.sum(&:creditable_amount_cents) + return 0 if fees_total_creditable.zero? + + credit_adjustement = if version_number < Invoice::COUPON_BEFORE_VAT_VERSION + 0 + else + (coupons_amount_cents + progressive_billing_credit_amount_cents).fdiv(fees_amount_cents) * fees_total_creditable + end + + vat = fees.sum do |fee| + # NOTE: Because coupons are applied before VAT, + # we have to discribute the coupon adjustement at prorata of each fees + # to compute the VAT + fee_rate = fee.creditable_amount_cents.fdiv(fees_total_creditable) + prorated_credit_amount = credit_adjustement * fee_rate + (fee.creditable_amount_cents - prorated_credit_amount) * (fee.taxes_rate || 0) + end.fdiv(100).round # BECAUSE OF THIS ROUND the returned value is not precise + + fees_total_creditable - credit_adjustement + vat + end + + # amount cents onto which we can issue a credit note as credit + def creditable_amount_cents + return 0 if credit? + available_to_credit_amount_cents + end + + # amount cents onto which we can issue a credit note as offset + # when invoice type is credit theres no partial payments/refund/offset only full amount + def offsettable_amount_cents + due_amount_cents = total_due_amount_cents + + return total_amount_cents if credit? && + due_amount_cents.positive? && + (payment_pending? || payment_failed?) + + [due_amount_cents, creditable_amount_cents].min + end + + # amount cents onto which we can issue a credit note as refund + def refundable_amount_cents + return 0 if version_number < CREDIT_NOTES_MIN_VERSION || draft? + return 0 if !payment_succeeded? && total_paid_amount_cents == total_amount_cents + + already_refunded_cents = credit_notes.sum("refund_amount_cents") + remaining_paid_cents = total_paid_amount_cents - already_refunded_cents + + # when invoice is for pre paid credits we can issue a credit note only as refund + # so creditable_amount_cents is always 0 but on that case we should allow to issue a credit note + # as refund only if the wallet balance is greater or equal than the remaining paid amount + if credit? + return [prepaid_credit_fee.creditable_from_wallet_amount_cents, remaining_paid_cents].min + end + + refundable_cents = [remaining_paid_cents, creditable_amount_cents].min + refundable_cents.negative? ? 0 : refundable_cents + end + + # Credit invoices have a single credit-type fee linked to the wallet transaction + def prepaid_credit_fee + fees.first + end + + def associated_active_wallet + return if !credit? || customer.wallets.active.empty? + + prepaid_credit_invoice_wallet if prepaid_credit_invoice_wallet&.active? + end + + def payment_dispute_losable? + finalized? || voided? + end + + def subscription_gated? + open? && subscriptions.any?(&:gated?) + end + + def subscription_payment_gated? + open? && subscriptions.any?(&:payment_gated?) + end + + def voidable? + if payment_dispute_lost_at? || total_paid_amount_cents > 0 || credit_notes.where.not(credit_status: :voided).any? + return false + end + + finalized? && (payment_pending? || payment_failed?) + end + + # Checks if all charges from subscription plans have corresponding fees + # For charges without filters: requires a base fee (charge_filter_id IS NULL) + # For charges with filters: requires BOTH a base fee AND fees for each filter + def all_charges_have_fees? + return true unless subscription? + + all_charges_have_base_fees? && all_charge_filters_have_fees? + end + + def all_fixed_charges_have_fees? + return true unless subscription? + + !FixedCharge.exists?( + FixedCharge.joins(plan: :subscriptions) + .where(subscriptions: {id: subscriptions.select(:id)}) + .where.not( + id: fees.fixed_charge.select(:fixed_charge_id) + ) + ) + end + + def has_different_boundaries_for_subscription_and_charges?(subscription) + invoice_subscription = invoice_subscription(subscription.id) + subscription_from = invoice_subscription.from_datetime_in_customer_timezone&.to_date + subscription_to = invoice_subscription.to_datetime_in_customer_timezone&.to_date + charges_from = invoice_subscription.charges_from_datetime_in_customer_timezone&.to_date + charges_to = invoice_subscription.charges_to_datetime_in_customer_timezone&.to_date + + subscription_from != charges_from && subscription_to != charges_to + end + + def has_different_boundaries_for_subscription_and_fixed_charges?(subscription) + invoice_subscription = invoice_subscription(subscription.id) + subscription_from = invoice_subscription.from_datetime_in_customer_timezone&.to_date + subscription_to = invoice_subscription.to_datetime_in_customer_timezone&.to_date + fixed_charges_from = invoice_subscription.fixed_charges_from_datetime_in_customer_timezone&.to_date + fixed_charges_to = invoice_subscription.fixed_charges_to_datetime_in_customer_timezone&.to_date + + subscription_from != fixed_charges_from && subscription_to != fixed_charges_to + end + + def mark_as_dispute_lost!(timestamp = Time.current) + self.payment_dispute_lost_at ||= timestamp + self.payment_overdue = false + save! + end + + def should_sync_invoice? + !self_billed && finalized? && customer.integration_customers.accounting_kind.any? { |c| c.integration.sync_invoices } + end + + def should_sync_hubspot_invoice? + finalized? && should_update_hubspot_invoice? + end + + def should_sync_salesforce_invoice? + !self_billed && finalized? && customer.integration_customers.salesforce_kind.any? + end + + def should_update_hubspot_invoice? + !self_billed && customer.integration_customers.hubspot_kind.any? { |c| c.integration.sync_invoices } + end + + def document_invoice_name + return I18n.t("invoice.self_billed.document_name") if self_billed? + return I18n.t("invoice.prepaid_credit_invoice") if credit? + + if TAX_INVOICE_LABEL_COUNTRIES.include?(organization.country) + return I18n.t("invoice.paid_tax_invoice") if advance_charges? + return I18n.t("invoice.document_tax_name") + end + + return I18n.t("invoice.paid_invoice") if advance_charges? + + I18n.t("invoice.document_name") + end + + def should_apply_provider_tax? + should_finalize_invoice = Invoices::TransitionToFinalStatusService.new(invoice: self).should_finalize_invoice? + + fees.any? && should_finalize_invoice + end + + def allow_manual_payment? + MANUALLY_PAYABLE_INVOICE_STATUS.include?(status.to_sym) + end + + # A safeguard while we're populating the expected finalization date. + # We can drop it once fill_expected_finalization_date has been run. + def expected_finalization_date + read_attribute(:expected_finalization_date) || issuing_date + end + + private + + # Returns the wallet associated with this credit invoice's prepaid credit fee. + # Can be nil for historical invoices where the fee or wallet transaction is missing. + def prepaid_credit_invoice_wallet + return unless credit? + + prepaid_credit_fee.prepaid_credit_fee_wallet + end + + # Checks that every charge has at least one fee without a filter (charge_filter_id IS NULL) + # This "base fee" is created for charges without filters, or for unmatched events when filters exist + def all_charges_have_base_fees? + !Charge.exists?( + Charge.joins(plan: :subscriptions) + .where(subscriptions: {id: subscriptions.select(:id)}) + .where.not( + id: fees.charge.where(charge_filter_id: nil).select(:charge_id) + ) + ) + end + + # Checks that every charge filter has a corresponding fee + # Only relevant for charges that have filters defined + def all_charge_filters_have_fees? + !ChargeFilter.exists?( + ChargeFilter.joins(charge: {plan: :subscriptions}) + .where(subscriptions: {id: subscriptions.select(:id)}) + .where.not( + id: fees.charge.where.not(charge_filter_id: nil).select(:charge_filter_id) + ) + ) + end + + def should_assign_sequential_id? + status_changed_to_finalized? + end + + def handle_void_transition! + update!( + ready_for_payment_processing: false, + payment_overdue: false, + voided_at: Time.current + ) + end + + def ensure_number + self.number = "#{billing_entity.document_number_prefix}-DRAFT" if number.blank? && !status_changed_to_finalized? + + return unless status_changed_to_finalized? + + if billing_entity.per_customer? || self_billed + # NOTE: Example of expected customer slug format is ORG_PREFIX-005 + customer_slug = "#{billing_entity.document_number_prefix}-#{format("%03d", customer.sequential_id)}" + formatted_sequential_id = format("%03d", sequential_id) + + self.number = "#{customer_slug}-#{formatted_sequential_id}" + else + billing_entity_formatted_sequential_id = format("%03d", billing_entity_sequential_id) + formatted_year_and_month = Time.now.in_time_zone(billing_entity.timezone || "UTC").strftime("%Y%m") + + self.number = "#{billing_entity.document_number_prefix}-#{formatted_year_and_month}-#{billing_entity_formatted_sequential_id}" + end + end + + def ensure_billing_entity_sequential_id + return if self_billed? + return if billing_entity_sequential_id + + # NOTE: this should actually be run by the state machine, however, + # we are not using it and status is changed without calling the state machine event + return unless status_changed_to_finalized? + + self.billing_entity_sequential_id = generate_billing_entity_sequential_id + end + + def generate_billing_entity_sequential_id + # Use advisory lock to ensure only one process can generate IDs for this billing entity at a time + lock_key = "billing_entity_sequential_id_#{billing_entity_id}" + + result = Invoice.with_advisory_lock(lock_key, transaction: true, timeout_seconds: 10.seconds) do + billing_entity_sequential_id = billing_entity + .invoices + .non_self_billed + .with_generated_number + .maximum(:billing_entity_sequential_id) || 0 + + loop do + billing_entity_sequential_id += 1 + break billing_entity_sequential_id unless billing_entity.invoices.non_self_billed.with_generated_number.exists?(billing_entity_sequential_id:) + end + end + + # NOTE: If the application was unable to acquire the lock, the block returns false + raise(SequenceError, "Unable to acquire lock on the database") unless result + + result + end + + def ensure_organization_sequential_id + return if organization_sequential_id.present? && organization_sequential_id.positive? + return unless status_changed_to_finalized? + + self.organization_sequential_id = generate_organization_sequential_id + self.billing_entity_sequential_id = organization_sequential_id + end + + def generate_organization_sequential_id + timezone = organization.timezone || "UTC" + organization_sequence_scope = organization.invoices.with_generated_number.where( + "date_trunc('month', created_at::timestamptz AT TIME ZONE ?)::date = ?", + timezone, + Time.now.in_time_zone(timezone).beginning_of_month.to_date + ).non_self_billed + + result = Invoice.with_advisory_lock( + organization_id, + transaction: true, + timeout_seconds: 10.seconds + ) do + organization_sequential_id = organization + .invoices + .non_self_billed + .maximum(:organization_sequential_id) || 0 + + # NOTE: Start with the most recent sequential id and find first available sequential id that haven't occurred + loop do + organization_sequential_id += 1 + + break organization_sequential_id unless organization_sequence_scope.exists?(organization_sequential_id:) + end + end + + # NOTE: If the application was unable to acquire the lock, the block returns false + raise(SequenceError, "Unable to acquire lock on the database") unless result + + result + end + + def status_changed_to_finalized? + status_changed?(from: "draft", to: "finalized") || + status_changed?(from: "generating", to: "finalized") || + status_changed?(from: "open", to: "finalized") || + status_changed?(from: "failed", to: "finalized") || + status_changed?(from: "pending", to: "finalized") + end + + def set_finalized_at + return unless status_changed_to_finalized? + + self.finalized_at ||= Time.current + end +end + +# == Schema Information +# +# Table name: invoices +# Database name: primary +# +# id :uuid not null, primary key +# applied_grace_period :integer +# coupons_amount_cents :bigint default(0), not null +# credit_notes_amount_cents :bigint default(0), not null +# currency :string +# expected_finalization_date :date +# fees_amount_cents :bigint default(0), not null +# file :string +# finalized_at :datetime +# invoice_type :integer default("subscription"), not null +# issuing_date :date +# net_payment_term :integer default(0), not null +# number :string default(""), not null +# payment_attempts :integer default(0), not null +# payment_dispute_lost_at :datetime +# payment_due_date :date +# payment_overdue :boolean default(FALSE) +# payment_status :integer default("pending"), not null +# prepaid_credit_amount_cents :bigint default(0), not null +# prepaid_granted_credit_amount_cents :bigint +# prepaid_purchased_credit_amount_cents :bigint +# progressive_billing_credit_amount_cents :bigint default(0), not null +# ready_for_payment_processing :boolean default(TRUE), not null +# ready_to_be_refreshed :boolean default(FALSE), not null +# self_billed :boolean default(FALSE), not null +# skip_automatic_payment :boolean +# skip_charges :boolean default(FALSE), not null +# status :integer default("finalized"), not null +# sub_total_excluding_taxes_amount_cents :bigint default(0), not null +# sub_total_including_taxes_amount_cents :bigint default(0), not null +# tax_status :enum +# taxes_amount_cents :bigint default(0), not null +# taxes_rate :float default(0.0), not null +# timezone :string default("UTC"), not null +# total_amount_cents :bigint default(0), not null +# total_paid_amount_cents :bigint default(0), not null +# version_number :integer default(4), not null +# voided_at :datetime +# xml_file :string +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid not null +# billing_entity_sequential_id :integer +# customer_id :uuid +# organization_id :uuid not null +# organization_sequential_id :integer default(0), not null +# payment_method_id :uuid +# sequential_id :integer +# voided_invoice_id :uuid +# +# Indexes +# +# idx_invoices_organization_id_status (organization_id,status) +# idx_on_billing_entity_id_billing_entity_sequential__bd26b2e655 (billing_entity_id,billing_entity_sequential_id DESC) +# idx_on_organization_id_organization_sequential_id_2387146f54 (organization_id,organization_sequential_id DESC) +# index_invoices_by_cursor (organization_id,issuing_date DESC,created_at DESC,id) +# index_invoices_on_customer_id_and_sequential_id (customer_id,sequential_id) UNIQUE +# index_invoices_on_number (number) +# index_invoices_on_payment_due_date (payment_due_date) WHERE ((status = 1) AND (payment_status <> 1) AND (payment_overdue = false) AND (payment_dispute_lost_at IS NULL)) +# index_invoices_on_payment_method_id (payment_method_id) +# index_invoices_on_ready_to_be_refreshed (ready_to_be_refreshed) WHERE (ready_to_be_refreshed = true) +# index_invoices_on_voided_invoice_id (voided_invoice_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_method_id => payment_methods.id) +# diff --git a/app/models/invoice/applied_tax.rb b/app/models/invoice/applied_tax.rb new file mode 100644 index 0000000..c6cdd55 --- /dev/null +++ b/app/models/invoice/applied_tax.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +class Invoice + class AppliedTax < ApplicationRecord + self.table_name = "invoices_taxes" + + include PaperTrailTraceable + + belongs_to :organization + belongs_to :invoice + # NOTE: Tax isn't really optional, but we used to hard deleted taxes, + # so some AppliedTax had no tax relation + belongs_to :tax, -> { with_discarded }, optional: true + + monetize :amount_cents, + :fees_amount_cents, + :taxable_amount_cents, + with_model_currency: :amount_currency + + validates :amount_cents, numericality: {greater_than_or_equal_to: 0} + + TAX_CODES_APPLICABLE_ON_WHOLE_INVOICE = %w[not_collecting juris_not_taxed reverse_charge customer_exempt + transaction_exempt juris_has_no_tax unknown_taxation].freeze + + def applied_on_whole_invoice? + TAX_CODES_APPLICABLE_ON_WHOLE_INVOICE.include?(tax_code) + end + + def taxable_amount_cents + base_amount = taxable_base_amount_cents + + return fees_amount_cents if base_amount.blank? || base_amount.zero? + + base_amount + end + end +end + +# == Schema Information +# +# Table name: invoices_taxes +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint default(0), not null +# amount_currency :string not null +# fees_amount_cents :bigint default(0), not null +# tax_code :string not null +# tax_description :string +# tax_name :string not null +# tax_rate :float default(0.0), not null +# taxable_base_amount_cents :bigint default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# organization_id :uuid not null +# tax_id :uuid +# +# Indexes +# +# index_invoices_taxes_on_invoice_id (invoice_id) +# index_invoices_taxes_on_invoice_id_and_tax_id (invoice_id,tax_id) UNIQUE WHERE ((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone)) +# index_invoices_taxes_on_organization_id (organization_id) +# index_invoices_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (tax_id => taxes.id) ON DELETE => nullify +# diff --git a/app/models/invoice_custom_section.rb b/app/models/invoice_custom_section.rb new file mode 100644 index 0000000..10ad54d --- /dev/null +++ b/app/models/invoice_custom_section.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class InvoiceCustomSection < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + has_many :customer_applied_invoice_custom_sections, + class_name: "Customer::AppliedInvoiceCustomSection", + dependent: :destroy + has_many :billing_entity_applied_invoice_custom_sections, + class_name: "BillingEntity::AppliedInvoiceCustomSection", + dependent: :destroy + + SECTION_TYPES = {manual: "manual", system_generated: "system_generated"}.freeze + enum :section_type, SECTION_TYPES, default: :manual, prefix: :section_type + + validates :name, presence: true + validates :code, + presence: true, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id} + + default_scope -> { kept } +end + +# == Schema Information +# +# Table name: invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# description :string +# details :string +# display_name :string +# name :string not null +# section_type :enum default("manual"), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# idx_on_organization_id_deleted_at_225e3f789d (organization_id,deleted_at) +# index_invoice_custom_sections_on_organization_id (organization_id) +# index_invoice_custom_sections_on_organization_id_and_code (organization_id,code) UNIQUE WHERE (deleted_at IS NULL) +# index_invoice_custom_sections_on_section_type (section_type) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/invoice_settlement.rb b/app/models/invoice_settlement.rb new file mode 100644 index 0000000..0ef31c3 --- /dev/null +++ b/app/models/invoice_settlement.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +class InvoiceSettlement < ApplicationRecord + include Currencies + + SETTLEMENT_TYPES = {payment: "payment", credit_note: "credit_note"}.freeze + + belongs_to :organization + belongs_to :billing_entity + belongs_to :target_invoice, class_name: "Invoice" + belongs_to :source_payment, class_name: "Payment", optional: true + belongs_to :source_credit_note, class_name: "CreditNote", optional: true + + enum :settlement_type, SETTLEMENT_TYPES + + monetize :amount_cents, with_model_currency: :amount_currency + + validates :amount_cents, numericality: {greater_than: 0} + validates :amount_currency, inclusion: {in: currency_list} + validates :settlement_type, presence: true + validate :validate_source_presence + + private + + def validate_source_presence + case settlement_type + when "payment" + if source_payment_id.blank? + errors.add(:source_payment_id, "must be present when settlement type is payment") + end + if source_credit_note_id.present? + errors.add(:source_credit_note_id, "must be blank when settlement type is payment") + end + when "credit_note" + if source_credit_note_id.blank? + errors.add(:source_credit_note_id, "must be present when settlement type is credit_note") + end + if source_payment_id.present? + errors.add(:source_payment_id, "must be blank when settlement type is credit_note") + end + end + end +end + +# == Schema Information +# +# Table name: invoice_settlements +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# amount_currency :string not null +# settlement_type :enum not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid not null +# organization_id :uuid not null +# source_credit_note_id :uuid +# source_payment_id :uuid +# target_invoice_id :uuid not null +# +# Indexes +# +# index_invoice_settlements_on_billing_entity_id (billing_entity_id) +# index_invoice_settlements_on_organization_id (organization_id) +# index_invoice_settlements_on_source_credit_note_id (source_credit_note_id) +# index_invoice_settlements_on_source_payment_id (source_payment_id) +# index_invoice_settlements_on_target_invoice_id (target_invoice_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (source_credit_note_id => credit_notes.id) +# fk_rails_... (source_payment_id => payments.id) +# fk_rails_... (target_invoice_id => invoices.id) +# diff --git a/app/models/invoice_subscription.rb b/app/models/invoice_subscription.rb new file mode 100644 index 0000000..19a58ff --- /dev/null +++ b/app/models/invoice_subscription.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +class InvoiceSubscription < ApplicationRecord + include CustomerTimezone + + belongs_to :invoice + belongs_to :subscription + belongs_to :organization + + has_one :customer, through: :subscription + + # NOTE: Readonly fields + monetize :charge_amount_cents, disable_validation: true, allow_nil: true + monetize :fixed_charge_amount_cents, disable_validation: true, allow_nil: true + monetize :subscription_amount_cents, disable_validation: true, allow_nil: true + monetize :total_amount_cents, disable_validation: true, allow_nil: true + + INVOICING_REASONS = { + subscription_starting: "subscription_starting", + subscription_periodic: "subscription_periodic", + subscription_terminating: "subscription_terminating", + in_advance_charge: "in_advance_charge", + in_advance_charge_periodic: "in_advance_charge_periodic", + progressive_billing: "progressive_billing" + }.freeze + + enum :invoicing_reason, INVOICING_REASONS + + scope :order_by_charges_to_datetime, + lambda { + condition = <<-SQL + COALESCE(invoice_subscriptions.to_datetime, invoice_subscriptions.created_at) DESC + SQL + + order(Arel.sql(ActiveRecord::Base.sanitize_sql_for_conditions(condition))) + } + + scope :order_by_subscription_invoice_name, + lambda { + joins(subscription: :plan) + .order( + Arel.sql( + "COALESCE(subscriptions.name, plans.invoice_display_name, plans.name) ASC" + ) + ) + } + + # NOTE: Billed automatically by the recurring billing process + # It is used to prevent double billing on billing day + scope :recurring, -> { where(recurring: true) } + + def self.matching?(subscription, boundaries, recurring: true) + base_query = InvoiceSubscription + .where(subscription_id: subscription.id) + .where(from_datetime: boundaries.from_datetime) + .where(to_datetime: boundaries.to_datetime) + + base_query = base_query.recurring if recurring + + if subscription.plan.charges_billed_in_monthly_split_intervals? + base_query = base_query + .where(charges_from_datetime: boundaries.charges_from_datetime) + .where(charges_to_datetime: boundaries.charges_to_datetime) + end + + if subscription.plan.fixed_charges_billed_in_monthly_split_intervals? + base_query = base_query + .where(fixed_charges_from_datetime: boundaries.fixed_charges_from_datetime) + .where(fixed_charges_to_datetime: boundaries.fixed_charges_to_datetime) + end + + base_query.exists? + end + + def fees + @fees ||= Fee.where( + subscription_id: subscription.id, + invoice_id: invoice.id + ) + end + + def previous_invoice_subscription + self.class + .where(subscription:) + .where("from_datetime <= ?", from_datetime) + .where.not(id:) + .order(from_datetime: :desc) + .find(&:subscription_fee) + end + + def charge_amount_cents + fees.charge.sum(:amount_cents) + end + + def fixed_charge_amount_cents + fees.fixed_charge.sum(:amount_cents) + end + + def subscription_amount_cents + subscription_fee&.amount_cents || 0 + end + + def subscription_fee + fees.subscription.first + end + + def commitment_fee + fees.commitment.first + end + + def total_amount_cents + charge_amount_cents + subscription_amount_cents + fixed_charge_amount_cents + end + + def total_amount_currency + subscription.plan.amount_currency + end + + alias_method :charge_amount_currency, :total_amount_currency + alias_method :subscription_amount_currency, :total_amount_currency + alias_method :fixed_charge_amount_currency, :total_amount_currency +end + +# == Schema Information +# +# Table name: invoice_subscriptions +# Database name: primary +# +# id :uuid not null, primary key +# charges_from_datetime :datetime +# charges_to_datetime :datetime +# fixed_charges_from_datetime :datetime +# fixed_charges_to_datetime :datetime +# from_datetime :datetime +# invoicing_reason :enum +# recurring :boolean +# timestamp :datetime +# to_datetime :datetime +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# organization_id :uuid not null +# regenerated_invoice_id :uuid +# subscription_id :uuid not null +# +# Indexes +# +# idx_invoice_subscriptions_on_subscription_with_timestamps (subscription_id, COALESCE(to_datetime, created_at) DESC) +# index_invoice_subscriptions_boundaries (subscription_id,from_datetime,to_datetime) +# index_invoice_subscriptions_on_invoice_id (invoice_id) +# index_invoice_subscriptions_on_invoice_id_and_subscription_id (invoice_id,subscription_id) UNIQUE WHERE (created_at >= '2023-11-23 00:00:00'::timestamp without time zone) +# index_invoice_subscriptions_on_organization_id (organization_id) +# index_invoice_subscriptions_on_regenerated_invoice_id (regenerated_invoice_id) +# index_invoice_subscriptions_on_subscription_id (subscription_id) +# index_uniq_invoice_subscriptions_on_charges_from_to_datetime (subscription_id,charges_from_datetime,charges_to_datetime) UNIQUE WHERE ((created_at >= '2023-06-09 00:00:00'::timestamp without time zone) AND (recurring IS TRUE) AND (regenerated_invoice_id IS NULL)) +# index_uniq_invoice_subscriptions_on_fixed_charges_boundaries (subscription_id,fixed_charges_from_datetime,fixed_charges_to_datetime) UNIQUE WHERE ((fixed_charges_from_datetime IS NOT NULL) AND (recurring IS TRUE) AND (regenerated_invoice_id IS NULL)) +# index_unique_starting_invoice_subscription (subscription_id,invoicing_reason) UNIQUE WHERE ((invoicing_reason = 'subscription_starting'::subscription_invoicing_reason) AND (regenerated_invoice_id IS NULL)) +# index_unique_terminating_invoice_subscription (subscription_id,invoicing_reason) UNIQUE WHERE ((invoicing_reason = 'subscription_terminating'::subscription_invoicing_reason) AND (regenerated_invoice_id IS NULL)) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (regenerated_invoice_id => invoices.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/lifetime_usage.rb b/app/models/lifetime_usage.rb new file mode 100644 index 0000000..a3677ac --- /dev/null +++ b/app/models/lifetime_usage.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class LifetimeUsage < ApplicationRecord + include Currencies + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + belongs_to :subscription + + validates :current_usage_amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :invoiced_usage_amount_cents, numericality: {greater_than_or_equal_to: 0} + validates :historical_usage_amount_cents, numericality: {greater_than_or_equal_to: 0} + + monetize :current_usage_amount_cents, + :invoiced_usage_amount_cents, + :historical_usage_amount_cents, + with_currency: ->(lifetime_usage) { lifetime_usage.subscription.plan.amount_currency } + + default_scope -> { kept } + + def total_amount_cents + historical_usage_amount_cents + invoiced_usage_amount_cents + current_usage_amount_cents + end +end + +# == Schema Information +# +# Table name: lifetime_usages +# Database name: primary +# +# id :uuid not null, primary key +# current_usage_amount_cents :bigint default(0), not null +# current_usage_amount_refreshed_at :datetime +# deleted_at :datetime +# historical_usage_amount_cents :bigint default(0), not null +# invoiced_usage_amount_cents :bigint default(0), not null +# invoiced_usage_amount_refreshed_at :datetime +# recalculate_current_usage :boolean default(FALSE), not null +# recalculate_invoiced_usage :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# index_lifetime_usages_on_organization_id (organization_id) +# index_lifetime_usages_on_recalculate_current_usage (recalculate_current_usage) WHERE ((deleted_at IS NULL) AND (recalculate_current_usage = true)) +# index_lifetime_usages_on_recalculate_invoiced_usage (recalculate_invoiced_usage) WHERE ((deleted_at IS NULL) AND (recalculate_invoiced_usage = true)) +# index_lifetime_usages_on_subscription_id (subscription_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/membership.rb b/app/models/membership.rb new file mode 100644 index 0000000..5f7f5f1 --- /dev/null +++ b/app/models/membership.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class Membership < ApplicationRecord + include PaperTrailTraceable + + belongs_to :organization + belongs_to :user + + has_many :data_exports + has_many :membership_roles + has_many :roles, through: :membership_roles + + STATUSES = [ + :active, + :revoked + ].freeze + + enum :status, STATUSES + + validates :user_id, uniqueness: {conditions: -> { where(revoked_at: nil) }, scope: :organization_id} + + scope :admins, -> { joins(:roles).where(roles: {admin: true}).distinct } + + def admin? + roles.admins.exists? + end + + def mark_as_revoked!(timestamp = Time.current) + self.revoked_at ||= timestamp + revoked! + end + + def can?(permission) + permissions_hash[permission.to_s] + end + + def permissions_hash + Permission.permissions_hash.dup.tap do |h| + roles.each { |role| role.permissions_hash.each { |key, val| h[key] ||= val } } + end + end +end + +# == Schema Information +# +# Table name: memberships +# Database name: primary +# +# id :uuid not null, primary key +# revoked_at :datetime +# status :integer default("active"), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# user_id :uuid not null +# +# Indexes +# +# index_memberships_by_id_and_organization (id,organization_id) UNIQUE +# index_memberships_on_organization_id (organization_id) +# index_memberships_on_user_id (user_id) +# index_memberships_on_user_id_and_organization_id (user_id,organization_id) UNIQUE WHERE (revoked_at IS NULL) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (user_id => users.id) +# diff --git a/app/models/membership_role.rb b/app/models/membership_role.rb new file mode 100644 index 0000000..30a9579 --- /dev/null +++ b/app/models/membership_role.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +class MembershipRole < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + belongs_to :organization + belongs_to :membership + belongs_to :role + + scope :admins, -> { joins(:role).where(roles: {admin: true}) } + + validate :role_belongs_to_organization, on: :create + validate :forbid_modification, on: :update + + # Only communicate errors on last role discard + before_discard :forbid_last_admin_role_discard + before_discard :forbid_last_membership_role_discard + + private + + def forbid_last_admin_role_discard + return unless role.admin? + return if organization.membership_roles.admins.where.not(id:).exists? + + errors.add(:base, :last_admin_role) + throw(:abort) + end + + def forbid_last_membership_role_discard + return if membership.membership_roles.where.not(id:).exists? + + errors.add(:base, :last_membership_role) + throw(:abort) + end + + def role_belongs_to_organization + return if role.nil? + return if role.organization_id.nil? || role.organization_id == organization_id + + errors.add(:role, "invalid_value") + end + + def forbid_modification + return if changes.keys == ["deleted_at"] + + errors.add(:base, :modification_not_allowed) + end +end + +# == Schema Information +# +# Table name: membership_roles +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# membership_id :uuid not null +# organization_id :uuid not null +# role_id :uuid not null +# +# Indexes +# +# index_membership_roles_by_membership_and_organization (membership_id,organization_id) WHERE (deleted_at IS NULL) +# index_membership_roles_on_role_id (role_id) +# index_membership_roles_uniqueness (membership_id,role_id) UNIQUE WHERE (deleted_at IS NULL) +# +# Foreign Keys +# +# fk_rails_... (role_id => roles.id) +# membership_role_membership_fk ([membership_id, organization_id] => memberships[id, organization_id]) +# diff --git a/app/models/metadata/customer_metadata.rb b/app/models/metadata/customer_metadata.rb new file mode 100644 index 0000000..def176e --- /dev/null +++ b/app/models/metadata/customer_metadata.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Metadata + class CustomerMetadata < ApplicationRecord + COUNT_PER_CUSTOMER = 5 + + belongs_to :customer + belongs_to :organization + + validates :key, presence: true, uniqueness: {scope: :customer_id}, length: {maximum: 20} + validates :value, presence: true, length: {maximum: 100} + + scope :displayable, -> { where(display_in_invoice: true) } + end +end + +# == Schema Information +# +# Table name: customer_metadata +# Database name: primary +# +# id :uuid not null, primary key +# display_in_invoice :boolean default(FALSE), not null +# key :string not null +# value :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_customer_metadata_on_customer_id (customer_id) +# index_customer_metadata_on_customer_id_and_key (customer_id,key) UNIQUE +# index_customer_metadata_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/metadata/invoice_metadata.rb b/app/models/metadata/invoice_metadata.rb new file mode 100644 index 0000000..9da714c --- /dev/null +++ b/app/models/metadata/invoice_metadata.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Metadata + class InvoiceMetadata < ApplicationRecord + COUNT_PER_INVOICE = 5 + + belongs_to :invoice, touch: true + belongs_to :organization + + validates :key, presence: true, uniqueness: {scope: :invoice_id}, length: {maximum: 20} + validates :value, presence: true + end +end + +# == Schema Information +# +# Table name: invoice_metadata +# Database name: primary +# +# id :uuid not null, primary key +# key :string not null +# value :string not null +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_invoice_metadata_on_invoice_id (invoice_id) +# index_invoice_metadata_on_invoice_id_and_key (invoice_id,key) UNIQUE +# index_invoice_metadata_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/metadata/item_metadata.rb b/app/models/metadata/item_metadata.rb new file mode 100644 index 0000000..42e72a7 --- /dev/null +++ b/app/models/metadata/item_metadata.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Metadata + class ItemMetadata < ApplicationRecord + MAX_NUMBER_OF_KEYS = 50 + MAX_KEY_LENGTH = 100 + MAX_VALUE_LENGTH = 255 + + belongs_to :organization + belongs_to :owner, polymorphic: true + + validates :value, exclusion: {in: [nil], message: :blank} + validate :value_correctness + + private + + def value_correctness + return if value.blank? + + unless value.is_a?(Hash) + errors.add(:value, "must be a Hash") + return + end + + if value.size > MAX_NUMBER_OF_KEYS + errors.add(:value, "cannot have more than #{MAX_NUMBER_OF_KEYS} keys") + end + + value.each do |key, val| + unless key.is_a?(String) && key.length <= MAX_KEY_LENGTH + errors.add(:value, "key '#{key}' must be a String up to #{MAX_KEY_LENGTH} characters") + end + + if val.present? && !(val.is_a?(String) && val.length <= MAX_VALUE_LENGTH) + errors.add(:value, "value for key '#{key}' must be empty or a String up to #{MAX_VALUE_LENGTH} characters") + end + end + end + end +end + +# == Schema Information +# +# Table name: item_metadata +# Database name: primary +# +# id :uuid not null, primary key +# owner_type :string not null +# value :jsonb not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# owner_id :uuid not null +# +# Indexes +# +# index_item_metadata_on_organization_id (organization_id) +# index_item_metadata_on_owner_type_and_owner_id (owner_type,owner_id) UNIQUE +# index_item_metadata_on_value (value) USING gin +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) ON DELETE => cascade +# diff --git a/app/models/organization.rb b/app/models/organization.rb new file mode 100644 index 0000000..7f48b16 --- /dev/null +++ b/app/models/organization.rb @@ -0,0 +1,361 @@ +# frozen_string_literal: true + +class Organization < ApplicationRecord + include PaperTrailTraceable + include OrganizationTimezone + include Currencies + include Organizations::AuthenticationMethods + include HasFeatureFlags + include Organizations::Sluggable + + self.ignored_columns += [:clickhouse_aggregation] + + EMAIL_SETTINGS = [ + "invoice.finalized", + "credit_note.created", + "payment_receipt.created" + ].freeze + + MULTI_ENTITIES_MAX = { + default: 1, + pro: 2, + enterprise: Float::INFINITY + }.freeze + + has_many :activity_logs, class_name: "Clickhouse::ActivityLog" + has_many :ai_conversations + has_many :api_logs, class_name: "Clickhouse::ApiLog" + has_many :api_keys + has_many :security_logs, class_name: "Clickhouse::SecurityLog" + has_many :billing_entities, -> { active } + has_many :all_billing_entities, class_name: "BillingEntity" + has_many :memberships + has_many :active_memberships, -> { active }, class_name: "Membership" + has_many :users, through: :memberships + + # TODO: Remove in favor of admins through admin_membership_roles + has_many :admins_memberships, -> { active.admins }, class_name: "Membership" + has_many :admins, through: :admins_memberships, source: :user + # New way to access admin users + has_many :membership_roles, through: :active_memberships + has_many :admin_membership_roles, -> { admins }, through: :active_memberships, source: :membership_roles + has_many :admin_memberships, through: :admin_membership_roles, source: :membership + has_many :admin_users, through: :admin_memberships, source: :user + + has_many :billable_metrics + has_many :plans + has_many :charges + has_many :fixed_charges + has_many :charge_filters + has_many :pricing_units + has_many :customers + has_many :subscriptions + has_many :activation_rules, class_name: "Subscription::ActivationRule" + has_many :invoices + has_many :credit_notes + has_many :fees + has_many :events + has_many :coupons + has_many :applied_coupons + has_many :add_ons + has_many :daily_usages + has_many :invites + has_many :integrations, class_name: "Integrations::BaseIntegration" + has_many :payment_methods + has_many :payment_providers, class_name: "PaymentProviders::BaseProvider" + has_many :payment_receipts + has_many :payment_requests + has_many :taxes + has_many :wallets + has_many :wallet_transactions + has_many :webhook_endpoints + has_many :webhooks + has_many :cached_aggregations + has_many :data_exports + has_many :error_details + has_many :dunning_campaigns + has_many :roles + has_many :quotes + has_many :quote_versions + has_many :activity_logs, class_name: "Clickhouse::ActivityLog" + has_many :features, class_name: "Entitlement::Feature" + has_many :privileges, class_name: "Entitlement::Privilege" + has_many :entitlements, class_name: "Entitlement::Entitlement" + has_many :entitlement_values, class_name: "Entitlement::EntitlementValue" + has_many :subscription_feature_removals, class_name: "Entitlement::SubscriptionFeatureRemoval" + + has_many :subscription_activities, class_name: "UsageMonitoring::SubscriptionActivity" + has_many :alerts, class_name: "UsageMonitoring::Alert" + has_many :triggered_alerts, class_name: "UsageMonitoring::TriggeredAlert" + has_many :pending_vies_checks + + has_many :stripe_payment_providers, class_name: "PaymentProviders::StripeProvider" + has_many :gocardless_payment_providers, class_name: "PaymentProviders::GocardlessProvider" + has_many :cashfree_payment_providers, class_name: "PaymentProviders::CashfreeProvider" + has_many :adyen_payment_providers, class_name: "PaymentProviders::AdyenProvider" + + has_many :hubspot_integrations, class_name: "Integrations::HubspotIntegration" + has_many :netsuite_integrations, class_name: "Integrations::NetsuiteIntegration" + has_many :xero_integrations, class_name: "Integrations::XeroIntegration" + has_one :salesforce_integration, class_name: "Integrations::SalesforceIntegration" + + has_one :applied_dunning_campaign, -> { where(applied_to_organization: true) }, class_name: "DunningCampaign" + has_one :default_billing_entity, -> { active.order(created_at: :asc) }, class_name: "BillingEntity" + has_one :enriched_store_migration + + has_many :invoice_custom_sections + has_many :manual_invoice_custom_sections, -> { where(section_type: "manual") }, class_name: "InvoiceCustomSection" + has_many :system_generated_invoice_custom_sections, -> { where(section_type: "system_generated") }, class_name: "InvoiceCustomSection" + + has_one_attached :logo + + DOCUMENT_NUMBERINGS = [ + :per_customer, + :per_organization + ].freeze + + NON_PREMIUM_INTEGRATIONS = %w[ + anrok + ].freeze + + PREMIUM_INTEGRATIONS = %w[ + beta_payment_authorization + netsuite + okta + avalara + xero + progressive_billing + lifetime_usage + hubspot + auto_dunning + revenue_analytics + salesforce + api_permissions + revenue_share + remove_branding_watermark + manual_payments + from_email + issue_receipts + preview + multi_entities_pro + multi_entities_enterprise + analytics_dashboards + forecasted_usage + projected_usage + custom_roles + events_targeting_wallets + security_logs + granular_lifetime_usage + order_forms + ].freeze + + SECURITY_LOGS_RETENTION_DAYS = 90 + + INTEGRATIONS = (NON_PREMIUM_INTEGRATIONS + PREMIUM_INTEGRATIONS).freeze + + enum :document_numbering, DOCUMENT_NUMBERINGS, validate: true + + validates :country, country_code: true, unless: -> { country.nil? } + validates :default_currency, inclusion: {in: currency_list} + validates :document_locale, language_code: true + validates :email, email: true, if: :email? + validates :invoice_footer, length: {maximum: 600} + validates :document_number_prefix, length: {minimum: 1, maximum: 10}, on: :update + validates :invoice_grace_period, numericality: {greater_than_or_equal_to: 0} + validates :net_payment_term, numericality: {greater_than_or_equal_to: 0} + validates :logo, + image: {authorized_content_type: %w[image/png image/jpg image/jpeg], max_size: 800.kilobytes}, + if: :logo? + validates :name, presence: true + validates :timezone, timezone: true + validates :webhook_url, url: true, allow_nil: true + validates :finalize_zero_amount_invoice, inclusion: {in: [true, false]} + validates :hmac_key, uniqueness: true + validates :hmac_key, presence: true, on: :update + + validate :validate_premium_integrations + validate :validate_email_settings + + normalizes :email, with: ->(email) { EmailSanitizer.call(email) } + + before_create :set_hmac_key + after_create :generate_document_number_prefix + + scope :with_any_premium_integrations, ->(names) { where("premium_integrations && ARRAY[?]::varchar[]", Array.wrap(names)) } + + PREMIUM_INTEGRATIONS.each do |premium_integration| + scope "with_#{premium_integration}_support", -> { where("? = ANY(premium_integrations)", premium_integration) } + + define_method("#{premium_integration}_enabled?") do + License.premium? && premium_integrations.include?(premium_integration) + end + end + + def using_lifetime_usage? + lifetime_usage_enabled? || progressive_billing_enabled? + end + + def logo_url + return if logo.blank? + + Rails.application.routes.url_helpers.rails_blob_url(logo, host: ENV["LAGO_API_URL"]) + end + + def base64_logo + return if logo.blank? + + logo.blob.open do |tempfile| + data = tempfile.read + Base64.encode64(data) + end + end + + def eu_vat_eligible? + country && LagoEuVat::Rate.country_codes.include?(country) + end + + def payment_provider(provider) + case provider + when "stripe" + stripe_payment_provider + when "gocardless" + gocardless_payment_provider + when "cashfree" + cashfree_payment_provider + when "adyen" + adyen_payment_provider + end + end + + def document_number_prefix=(value) + super(value&.upcase) + end + + def from_email_address + return email if from_email_enabled? + + ENV["LAGO_FROM_EMAIL"] + end + + def can_create_billing_entity? + remaining_billing_entities > 0 + end + + def failed_tax_invoices_count + invoices.where(status: :failed).joins(:error_details).where(error_details: {error_code: "tax_error"}).count + end + + # This field used to be on organization, but as we're migrating this data to the billing entity, it should be taken from the billing_entity + def default_currency + return default_billing_entity.default_currency if default_billing_entity + + super + end + + # This field used to be on organization, but as we're migrating this data to the billing entity, it should be taken from the billing_entity + def timezone + return default_billing_entity.timezone if default_billing_entity + + super + end + + def postgres_events_store? + !clickhouse_events_store? + end + + # This is added to have a common interface for all organization-related models to access the organization. + def organization + self + end + + def maximum_wallets_per_customer + max_wallets if events_targeting_wallets_enabled? + end + + private + + # NOTE: After creating an organization, default document_number_prefix needs to be generated. + # Example of expected format is ORG-4321 + def generate_document_number_prefix + update!(document_number_prefix: "#{name.first(3).upcase}-#{id.last(4).upcase}") + end + + def validate_email_settings + return if email_settings.all? { |v| EMAIL_SETTINGS.include?(v) } + + errors.add(:email_settings, :unsupported_value) + end + + def validate_premium_integrations + return if premium_integrations.all? { |v| PREMIUM_INTEGRATIONS.include?(v) } + + errors.add(:premium_integrations, :inclusion, value: premium_integrations) + end + + def set_hmac_key + loop do + self.hmac_key = SecureRandom.uuid + break unless self.class.exists?(hmac_key:) + end + end + + def remaining_billing_entities + return MULTI_ENTITIES_MAX[:enterprise] if multi_entities_enterprise_enabled? + return MULTI_ENTITIES_MAX[:pro] - billing_entities.active.count if multi_entities_pro_enabled? + + MULTI_ENTITIES_MAX[:default] - billing_entities.active.count + end +end + +# == Schema Information +# +# Table name: organizations +# Database name: primary +# +# id :uuid not null, primary key +# address_line1 :string +# address_line2 :string +# api_key :string +# audit_logs_period :integer default(30) +# authentication_methods :string default(["email_password", "google_oauth"]), not null, is an Array +# city :string +# clickhouse_deduplication_enabled :boolean default(FALSE), not null +# clickhouse_events_store :boolean default(FALSE), not null +# country :string +# custom_aggregation :boolean default(FALSE) +# default_currency :string default("USD"), not null +# document_locale :string default("en"), not null +# document_number_prefix :string +# document_numbering :integer default("per_customer"), not null +# email :string +# email_settings :string default([]), not null, is an Array +# eu_tax_management :boolean default(FALSE) +# feature_flags :string default([]), not null, is an Array +# finalize_zero_amount_invoice :boolean default(TRUE), not null +# hmac_key :string not null +# invoice_footer :text +# invoice_grace_period :integer default(0), not null +# legal_name :string +# legal_number :string +# logo :string +# max_wallets :integer +# name :string not null +# net_payment_term :integer default(0), not null +# pre_filter_events :boolean default(FALSE), not null +# premium_integrations :string default([]), not null, is an Array +# slug :string not null +# state :string +# tax_identification_number :string +# timezone :string default("UTC"), not null +# vat_rate :float default(0.0), not null +# webhook_url :string +# zipcode :string +# created_at :datetime not null +# updated_at :datetime not null +# +# Indexes +# +# index_organizations_on_api_key (api_key) UNIQUE +# index_organizations_on_hmac_key (hmac_key) UNIQUE +# index_organizations_on_slug (slug) UNIQUE +# diff --git a/app/models/password_reset.rb b/app/models/password_reset.rb new file mode 100644 index 0000000..3545cb8 --- /dev/null +++ b/app/models/password_reset.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class PasswordReset < ApplicationRecord + belongs_to :user + + validates :token, presence: true, uniqueness: true + validates :expire_at, presence: true +end + +# == Schema Information +# +# Table name: password_resets +# Database name: primary +# +# id :uuid not null, primary key +# expire_at :datetime not null +# token :string not null +# created_at :datetime not null +# updated_at :datetime not null +# user_id :uuid not null +# +# Indexes +# +# index_password_resets_on_token (token) UNIQUE +# index_password_resets_on_user_id (user_id) +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# diff --git a/app/models/payment.rb b/app/models/payment.rb new file mode 100644 index 0000000..4cbc9c1 --- /dev/null +++ b/app/models/payment.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +class Payment < ApplicationRecord + include PaperTrailTraceable + include RansackUuidSearch + + PAYABLE_PAYMENT_STATUS = %w[pending processing succeeded failed].freeze + + belongs_to :organization + belongs_to :customer, -> { with_discarded }, optional: true + belongs_to :payable, polymorphic: true + belongs_to :payment_provider, optional: true, class_name: "PaymentProviders::BaseProvider" + belongs_to :payment_provider_customer, optional: true, class_name: "PaymentProviderCustomers::BaseCustomer" + belongs_to :payment_method, optional: true + + has_many :refunds + has_many :integration_resources, as: :syncable + has_one :invoice_settlement, foreign_key: :source_payment_id + has_one :payment_receipt, dependent: :destroy + + alias_attribute :currency, :amount_currency + + monetize :amount_cents + + PAYMENT_TYPES = {provider: "provider", manual: "manual"}.freeze + attribute :payment_type, :string + enum :payment_type, PAYMENT_TYPES, default: :provider, prefix: :payment_type + validates :payment_type, presence: true + validates :reference, presence: true, length: {maximum: 40}, if: -> { payment_type_manual? } + validates :reference, absence: true, if: -> { payment_type_provider? } + validate :manual_payment_credit_invoice_amount_cents + validate :max_invoice_paid_amount_cents, on: :create + validate :payment_request_succeeded, on: :create + + enum :payable_payment_status, PAYABLE_PAYMENT_STATUS.map { |s| [s, s] }.to_h, validate: {allow_nil: true} + + delegate :billing_entity, to: :customer + + scope :for_organization, lambda { |organization| + payables_join = ActiveRecord::Base.sanitize_sql_array([ + <<~SQL, + LEFT JOIN invoices + ON invoices.id = payments.payable_id + AND payments.payable_type = 'Invoice' + AND invoices.organization_id = :org_id + AND invoices.status IN (:visible_statuses) + LEFT JOIN payment_requests + ON payment_requests.id = payments.payable_id + AND payments.payable_type = 'PaymentRequest' + AND payment_requests.organization_id = :org_id + SQL + {org_id: organization.id, visible_statuses: Invoice::VISIBLE_STATUS.values} + ]) + joins(payables_join) + .where("invoices.id IS NOT NULL OR payment_requests.id IS NOT NULL") + } + + def self.ransackable_attributes(_ = nil) + %w[id provider_payment_id reference] + _ransackers.keys + end + + def self.ransackable_associations(_ = nil) + %w[payable customer] + end + + ransacker :invoice_number do + Arel.sql("(SELECT invoices.number FROM invoices WHERE invoices.id = payments.payable_id AND payments.payable_type = 'Invoice' LIMIT 1)") + end + + def invoices + payable.payment_invoices + end + + def invoice_numbers + invoices.pluck(:number) + end + + def should_sync_payment? + return false unless payable.is_a?(Invoice) + + payable.finalized? && customer.integration_customers.accounting_kind.any? { |c| c.integration.sync_payments } + end + + def payment_provider_type + payment_provider&.payment_type + end + + def method_display_name + return nil if provider_payment_method_data.blank? + + type = provider_payment_method_data["type"] + brand = provider_payment_method_data["brand"] + + if type == "card" + "#{brand.to_s.titleize} #{card_last_digits}" + else + type.to_s.titleize + end + end + + def card_last_digits + return nil if provider_payment_method_data.blank? + return nil if provider_payment_method_data["type"] != "card" + + "**** #{provider_payment_method_data["last4"]}" + end + + private + + def manual_payment_credit_invoice_amount_cents + return if !payable.is_a?(Invoice) || payment_type_provider? || !payable.credit? + return if amount_cents == payable.total_amount_cents + + errors.add(:amount_cents, :invalid_amount) + end + + def max_invoice_paid_amount_cents + return if !payable.is_a?(Invoice) || payment_type_provider? + return if amount_cents <= payable.total_due_amount_cents + + errors.add(:amount_cents, :greater_than) + end + + def payment_request_succeeded + return if !payable.is_a?(Invoice) || payment_type_provider? + + return unless payable.payment_requests.where(payment_status: "succeeded").exists? + + errors.add(:base, :payment_request_is_already_succeeded) + end +end + +# == Schema Information +# +# Table name: payments +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# amount_currency :string not null +# error_code :string +# payable_payment_status :enum +# payable_type :string default("Invoice"), not null +# payment_type :enum default("provider"), not null +# provider_payment_data :jsonb +# provider_payment_method_data :jsonb not null +# reference :string +# status :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid +# invoice_id :uuid +# organization_id :uuid not null +# payable_id :uuid +# payment_method_id :uuid +# payment_provider_customer_id :uuid +# payment_provider_id :uuid +# provider_payment_id :string +# provider_payment_method_id :string +# +# Indexes +# +# index_payments_on_customer_id (customer_id) +# index_payments_on_invoice_id (invoice_id) +# index_payments_on_organization_id (organization_id) +# index_payments_on_payable_id_and_payable_type (payable_id,payable_type) UNIQUE WHERE ((payable_payment_status = ANY (ARRAY['pending'::payment_payable_payment_status, 'processing'::payment_payable_payment_status])) AND (payment_type = 'provider'::payment_type)) +# index_payments_on_payable_id_and_payable_type_and_error_code (payable_id,payable_type,error_code) +# index_payments_on_payable_type_and_payable_id (payable_type,payable_id) +# index_payments_on_payment_method_id (payment_method_id) +# index_payments_on_payment_provider_customer_id (payment_provider_customer_id) +# index_payments_on_payment_provider_id (payment_provider_id) +# index_payments_on_payment_type (payment_type) +# index_payments_on_provider_payment_id_and_payment_provider_id (provider_payment_id,payment_provider_id) UNIQUE WHERE (provider_payment_id IS NOT NULL) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_method_id => payment_methods.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_intent.rb b/app/models/payment_intent.rb new file mode 100644 index 0000000..6ddf814 --- /dev/null +++ b/app/models/payment_intent.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class PaymentIntent < ApplicationRecord + STATUSES = [:active, :expired].freeze + + belongs_to :invoice + belongs_to :organization + + enum :status, STATUSES + + attribute :expires_at, default: -> { 24.hours.from_now } + + validates :status, :expires_at, presence: true + validates :status, uniqueness: {scope: :invoice_id}, if: :active? + + scope :awaiting_expiration, -> { active.where("expires_at <= ?", Time.current) } + scope :non_expired, -> { where("expires_at > ?", Time.current) } +end + +# == Schema Information +# +# Table name: payment_intents +# Database name: primary +# +# id :uuid not null, primary key +# expires_at :datetime not null +# payment_url :string +# status :integer default("active"), not null +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_intents_on_invoice_id (invoice_id) +# index_payment_intents_on_invoice_id_and_status (invoice_id,status) UNIQUE WHERE (status = 0) +# index_payment_intents_on_organization_id (organization_id) +# diff --git a/app/models/payment_method.rb b/app/models/payment_method.rb new file mode 100644 index 0000000..a2d94dc --- /dev/null +++ b/app/models/payment_method.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class PaymentMethod < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + scope :default, -> { where(is_default: true) } + + belongs_to :organization + belongs_to :customer, -> { with_discarded } + belongs_to :payment_provider, optional: true, class_name: "PaymentProviders::BaseProvider" + belongs_to :payment_provider_customer, optional: true, class_name: "PaymentProviderCustomers::BaseCustomer" + + PAYMENT_METHOD_TYPES = { + provider: "provider", + manual: "manual" + }.freeze + + validates :provider_method_id, presence: true + validates :is_default, inclusion: {in: [true, false]} + + def payment_provider_type + payment_provider&.payment_type + end +end + +# == Schema Information +# +# Table name: payment_methods +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# details :jsonb not null +# is_default :boolean default(FALSE), not null +# provider_method_type :string +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_customer_id :uuid +# payment_provider_id :uuid +# provider_method_id :string not null +# +# Indexes +# +# index_payment_methods_on_customer_id (customer_id) +# index_payment_methods_on_organization_id (organization_id) +# index_payment_methods_on_payment_provider_customer_id (payment_provider_customer_id) +# index_payment_methods_on_payment_provider_id (payment_provider_id) +# index_payment_methods_on_provider_customer_and_provider_method (payment_provider_customer_id,provider_method_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_methods_on_provider_method_type (provider_method_type) +# unique_default_payment_method_per_customer (customer_id) UNIQUE WHERE ((is_default = true) AND (deleted_at IS NULL)) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_customer_id => payment_provider_customers.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_methods/card_details.rb b/app/models/payment_methods/card_details.rb new file mode 100644 index 0000000..eac23eb --- /dev/null +++ b/app/models/payment_methods/card_details.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module PaymentMethods + CardDetails = Data.define( + :type, + :last4, + :brand, + :expiration_month, + :expiration_year, + :card_holder_name, + :issuer + ) do + def to_h + super.compact + end + end +end diff --git a/app/models/payment_provider_customers/adyen_customer.rb b/app/models/payment_provider_customers/adyen_customer.rb new file mode 100644 index 0000000..65fb2d0 --- /dev/null +++ b/app/models/payment_provider_customers/adyen_customer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class AdyenCustomer < BaseCustomer + settings_accessors :payment_method_id + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_organization_id (organization_id) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_provider_customers/base_customer.rb b/app/models/payment_provider_customers/base_customer.rb new file mode 100644 index 0000000..39acd0f --- /dev/null +++ b/app/models/payment_provider_customers/base_customer.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class BaseCustomer < ApplicationRecord + include PaperTrailTraceable + include SettingsStorable + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + self.table_name = "payment_provider_customers" + + belongs_to :customer + belongs_to :payment_provider, optional: true, class_name: "PaymentProviders::BaseProvider" + belongs_to :organization + + has_many :payments + has_many :payment_methods, foreign_key: :payment_provider_customer_id + has_many :refunds, foreign_key: :payment_provider_customer_id + + validates :customer_id, uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :type} + + settings_accessors :provider_mandate_id, :sync_with_provider + + scope :by_provider_id_from_organization, ->(organization_id, provider_id) do + joins(:customer) + .where(customers: {organization_id: organization_id}) + .where(provider_customer_id: provider_id) + end + + def provider_payment_methods + nil + end + + def require_provider_payment_id? + true + end + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_organization_id (organization_id) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_provider_customers/cashfree_customer.rb b/app/models/payment_provider_customers/cashfree_customer.rb new file mode 100644 index 0000000..e1cca35 --- /dev/null +++ b/app/models/payment_provider_customers/cashfree_customer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class CashfreeCustomer < BaseCustomer + def require_provider_payment_id? + false + end + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_organization_id (organization_id) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_provider_customers/flutterwave_customer.rb b/app/models/payment_provider_customers/flutterwave_customer.rb new file mode 100644 index 0000000..1d7154e --- /dev/null +++ b/app/models/payment_provider_customers/flutterwave_customer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class FlutterwaveCustomer < BaseCustomer + def require_provider_payment_id? + false + end + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_organization_id (organization_id) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_provider_customers/gocardless_customer.rb b/app/models/payment_provider_customers/gocardless_customer.rb new file mode 100644 index 0000000..8c18b00 --- /dev/null +++ b/app/models/payment_provider_customers/gocardless_customer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class GocardlessCustomer < BaseCustomer + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_organization_id (organization_id) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_provider_customers/moneyhash_customer.rb b/app/models/payment_provider_customers/moneyhash_customer.rb new file mode 100644 index 0000000..f3f4e04 --- /dev/null +++ b/app/models/payment_provider_customers/moneyhash_customer.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashCustomer < BaseCustomer + settings_accessors :payment_method_id + + # extract MoneyHash's billing data from customer + def mh_billing_data + {}.tap do |billing_data| + billing_data[:name] = customer.name if customer.name.present? + billing_data[:first_name] = customer.firstname if customer.firstname.present? + billing_data[:last_name] = customer.lastname if customer.lastname.present? + billing_data[:email] = customer.email if customer.email.present? + billing_data[:phone_number] = customer.phone if customer.phone.present? + billing_data[:address] = customer.address_line1 if customer.address_line1.present? + billing_data[:address1] = customer.address_line2 if customer.address_line2.present? + billing_data[:city] = customer.city if customer.city.present? + billing_data[:state] = customer.state if customer.state.present? + billing_data[:country] = customer.country if customer.country.present? + billing_data[:postal_code] = customer.zipcode if customer.zipcode.present? + end + end + + # extract all possible custom_fields from moneyhash_customer + def mh_custom_fields + { + # connection + lago_mh_connection_id: payment_provider.id, + lago_mh_connection_code: payment_provider.code, + # customer + lago_customer_id: customer.id, + lago_customer_external_id: customer.external_id.to_s, + lago_customer_name: customer.name.to_s, + lago_customer_currency: customer.currency.to_s, + lago_customer_legal_name: customer.legal_name.to_s, + lago_customer_legal_number: customer.legal_number.to_s, + lago_customer_tax_identification_number: customer.tax_identification_number.to_s, + lago_customer_provider_customer_id: provider_customer_id.to_s, + # organization + lago_organization_id: customer.organization.id + } + end + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_organization_id (organization_id) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_provider_customers/stripe_customer.rb b/app/models/payment_provider_customers/stripe_customer.rb new file mode 100644 index 0000000..7a57a06 --- /dev/null +++ b/app/models/payment_provider_customers/stripe_customer.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class StripeCustomer < BaseCustomer + PAYMENT_METHODS_WITH_SETUP = %w[card sepa_debit us_bank_account bacs_debit link boleto].freeze + PAYMENT_METHODS_WITHOUT_SETUP = %w[crypto customer_balance].freeze + PAYMENT_METHODS = (PAYMENT_METHODS_WITH_SETUP + PAYMENT_METHODS_WITHOUT_SETUP).freeze + + validates :provider_payment_methods, presence: true + validate :allowed_provider_payment_methods + validate :link_payment_method_can_exist_only_with_card + validate :customer_balance_must_be_exclusive + + settings_accessors :payment_method_id + + def provider_payment_methods + get_from_settings("provider_payment_methods") + end + + def provider_payment_methods_with_setup + provider_payment_methods & PAYMENT_METHODS_WITH_SETUP + end + + def provider_payment_methods_require_setup? + provider_payment_methods_with_setup.present? + end + + def provider_payment_methods=(provider_payment_methods) + push_to_settings(key: "provider_payment_methods", value: provider_payment_methods.to_a) + end + + private + + def allowed_provider_payment_methods + return if (provider_payment_methods - PAYMENT_METHODS).blank? + + errors.add(:provider_payment_methods, :invalid) + end + + def link_payment_method_can_exist_only_with_card + return if provider_payment_methods.exclude?("link") || provider_payment_methods.include?("card") + + errors.add(:provider_payment_methods, :invalid) + end + + def customer_balance_must_be_exclusive + return unless provider_payment_methods.include?("customer_balance") + return if provider_payment_methods == ["customer_balance"] + + errors.add(:provider_payment_methods, "customer_balance cannot be combined with other payment methods") + end + end +end + +# == Schema Information +# +# Table name: payment_provider_customers +# Database name: primary +# +# id :uuid not null, primary key +# deleted_at :datetime +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# payment_provider_id :uuid +# provider_customer_id :string +# +# Indexes +# +# index_payment_provider_customers_on_customer_id_and_type (customer_id,type) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_provider_customers_on_organization_id (organization_id) +# index_payment_provider_customers_on_payment_provider_id (payment_provider_id) +# index_payment_provider_customers_on_provider_customer_id (provider_customer_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/payment_providers/adyen_provider.rb b/app/models/payment_providers/adyen_provider.rb new file mode 100644 index 0000000..20d6116 --- /dev/null +++ b/app/models/payment_providers/adyen_provider.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module PaymentProviders + class AdyenProvider < BaseProvider + SUCCESS_REDIRECT_URL = "https://www.adyen.com/" + + WEBHOOKS_EVENTS = %w[AUTHORISATION REFUND REFUND_FAILED CHARGEBACK].freeze + IGNORED_WEBHOOK_EVENTS = %w[OFFER_CLOSED REPORT_AVAILABLE RECURRING_CONTRACT].freeze + + PROCESSING_STATUSES = %w[AuthorisedPending Received].freeze + SUCCESS_STATUSES = %w[Authorised SentForSettle SettleScheduled Settled Refunded].freeze + FAILED_STATUSES = %w[Cancelled CaptureFailed Error Expired Refused].freeze + + validates :api_key, :merchant_account, presence: true + validates :success_redirect_url, adyen_url: true, allow_nil: true, length: {maximum: 1024} + + settings_accessors :live_prefix, :merchant_account + secrets_accessors :api_key, :hmac_key + + def environment + if Rails.env.production? && live_prefix.present? + :live + else + :test + end + end + + def payment_type + "adyen" + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_providers/base_provider.rb b/app/models/payment_providers/base_provider.rb new file mode 100644 index 0000000..beac396 --- /dev/null +++ b/app/models/payment_providers/base_provider.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module PaymentProviders + class BaseProvider < ApplicationRecord + include PaperTrailTraceable + include SecretsStorable + include SettingsStorable + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + self.table_name = "payment_providers" + + belongs_to :organization + + has_many :payment_provider_customers, + dependent: :nullify, + class_name: "PaymentProviderCustomers::BaseCustomer", + foreign_key: :payment_provider_id + + has_many :customers, through: :payment_provider_customers + has_many :payments, dependent: :nullify, foreign_key: :payment_provider_id + has_many :payment_methods, dependent: :nullify, foreign_key: :payment_provider_id + has_many :refunds, dependent: :nullify, foreign_key: :payment_provider_id + + validates :code, uniqueness: {conditions: -> { kept }, scope: :organization_id} + validates :name, presence: true + + settings_accessors :webhook_secret, :success_redirect_url + + def determine_payment_status(payment_status) + return :processing if self.class::PROCESSING_STATUSES.include?(payment_status) + return :succeeded if self.class::SUCCESS_STATUSES.include?(payment_status) + return :failed if self.class::FAILED_STATUSES.include?(payment_status) + + payment_status + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_providers/cashfree_provider.rb b/app/models/payment_providers/cashfree_provider.rb new file mode 100644 index 0000000..a982865 --- /dev/null +++ b/app/models/payment_providers/cashfree_provider.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module PaymentProviders + class CashfreeProvider < BaseProvider + CashfreePayment = Data.define(:id, :status, :metadata) + + SUCCESS_REDIRECT_URL = "https://cashfree.com/" + API_VERSION = "2023-08-01" + BASE_URL = (Rails.env.production? ? "https://api.cashfree.com/pg/links" : "https://sandbox.cashfree.com/pg/links") + + PROCESSING_STATUSES = %w[PARTIALLY_PAID].freeze + SUCCESS_STATUSES = %w[PAID].freeze + FAILED_STATUSES = %w[EXPIRED CANCELLED].freeze + + validates :client_id, presence: true + validates :client_secret, presence: true + validates :success_redirect_url, url: true, allow_nil: true, length: {maximum: 1024} + + secrets_accessors :client_id, :client_secret + + def payment_type + "cashfree" + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_providers/flutterwave_provider.rb b/app/models/payment_providers/flutterwave_provider.rb new file mode 100644 index 0000000..2bae06c --- /dev/null +++ b/app/models/payment_providers/flutterwave_provider.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module PaymentProviders + class FlutterwaveProvider < BaseProvider + FlutterwavePayment = Data.define(:id, :status, :metadata) + + SUCCESS_REDIRECT_URL = "https://www.flutterwave.com/ng" + API_URL = "https://api.flutterwave.com/v3" + + PROCESSING_STATUSES = %w[pending].freeze + SUCCESS_STATUSES = %w[successful].freeze + FAILED_STATUSES = %w[failed cancelled].freeze + validates :secret_key, presence: true + validates :success_redirect_url, url: true, allow_nil: true, length: {maximum: 1024} + + secrets_accessors :secret_key, :webhook_secret + + before_create :generate_webhook_secret + + def payment_type + "flutterwave" + end + + def api_url + API_URL + end + + private + + def generate_webhook_secret + self.webhook_secret = SecureRandom.hex(32) if webhook_secret.blank? + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_providers/gocardless_provider.rb b/app/models/payment_providers/gocardless_provider.rb new file mode 100644 index 0000000..55e69c0 --- /dev/null +++ b/app/models/payment_providers/gocardless_provider.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module PaymentProviders + class GocardlessProvider < BaseProvider + SUCCESS_REDIRECT_URL = "https://gocardless.com/" + + PROCESSING_STATUSES = %w[pending_customer_approval pending_submission submitted confirmed].freeze + SUCCESS_STATUSES = %w[paid_out].freeze + FAILED_STATUSES = %w[cancelled customer_approval_denied failed charged_back].freeze + + validates :access_token, presence: true + validates :success_redirect_url, url: true, allow_nil: true, length: {maximum: 1024} + + secrets_accessors :access_token + + def self.auth_site + if Rails.env.production? + "https://connect.gocardless.com" + else + "https://connect-sandbox.gocardless.com" + end + end + + def environment + if Rails.env.production? + :live + else + :sandbox + end + end + + def payment_type + "gocardless" + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_providers/moneyhash_provider.rb b/app/models/payment_providers/moneyhash_provider.rb new file mode 100644 index 0000000..f4f2939 --- /dev/null +++ b/app/models/payment_providers/moneyhash_provider.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module PaymentProviders + class MoneyhashProvider < BaseProvider + SUCCESS_REDIRECT_URL = "https://moneyhash.io/" + + # Lago payment_status -> MoneyHash statuses mapping + PROCESSING_STATUSES = %w[PENDING PENDING_AUTHENTICATION UNPROCESSED].freeze + SUCCESS_STATUSES = %w[SUCCESSFUL PROCESSED].freeze + FAILED_STATUSES = %w[FAILED].freeze + + # MoneyHash payment status -> Lago payable_payment_status mapping + PAYABLE_PAYMENT_STATUS_MAP = { + "PENDING" => "pending", + "PENDING_AUTHENTICATION" => "pending", + "UNPROCESSED" => "pending", + "SUCCESSFUL" => "succeeded", + "PROCESSED" => "succeeded", + "FAILED" => "failed" + }.freeze + + validates :api_key, presence: true + validates :flow_id, presence: true, length: {maximum: 20} + + secrets_accessors :api_key, :signature_key + settings_accessors :flow_id + + def self.api_base_url + if Rails.env.production? + "https://web.moneyhash.io" + else + "https://staging-web.moneyhash.io" + end + end + + def webhook_end_point + URI.join( + ENV["LAGO_API_URL"], + "webhooks/moneyhash/#{organization_id}?code=#{URI.encode_www_form_component(code)}" + ) + end + + def environment + if Rails.env.production? && live_prefix.present? + :live + else + :test + end + end + + def payment_type + "moneyhash" + end + + def payable_payment_status(mh_status) + PAYABLE_PAYMENT_STATUS_MAP[mh_status] + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_providers/stripe_provider.rb b/app/models/payment_providers/stripe_provider.rb new file mode 100644 index 0000000..995ab65 --- /dev/null +++ b/app/models/payment_providers/stripe_provider.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module PaymentProviders + class StripeProvider < BaseProvider + AMOUNT_TOO_SMALL_ERROR_CODE = "amount_too_small" + NEED_3DS_ERROR_CODE = "authentication_required" + + StripePayment = Data.define(:id, :status, :metadata, :error_code) + + SUCCESS_REDIRECT_URL = "https://stripe.com/" + + # NOTE: find the complete list of event types at https://stripe.com/docs/api/events/types + WEBHOOKS_EVENTS = %w[ + setup_intent.succeeded + payment_intent.payment_failed + payment_intent.succeeded + payment_intent.canceled + payment_method.detached + charge.refund.updated + customer.updated + charge.dispute.closed + ].freeze + + PROCESSING_STATUSES = %w[ + processing + requires_capture + requires_action + requires_confirmation + ].freeze + SUCCESS_STATUSES = %w[succeeded].freeze + FAILED_STATUSES = %w[canceled requires_payment_method].freeze + SUPPORTED_EU_BANK_TRANSFER_COUNTRIES = %w[BE DE ES FR IE NL].freeze + + validates :secret_key, presence: true + validates :success_redirect_url, url: true, allow_nil: true, length: {maximum: 1024} + + settings_accessors :webhook_id + secrets_accessors :secret_key + settings_accessors :supports_3ds + + def payment_type + "stripe" + end + end +end + +# == Schema Information +# +# Table name: payment_providers +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# deleted_at :datetime +# name :string not null +# secrets :string +# settings :jsonb not null +# type :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_payment_providers_on_code_and_organization_id (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_payment_providers_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_receipt.rb b/app/models/payment_receipt.rb new file mode 100644 index 0000000..08768bc --- /dev/null +++ b/app/models/payment_receipt.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class PaymentReceipt < ApplicationRecord + belongs_to :payment + belongs_to :organization + belongs_to :billing_entity + + delegate :customer, to: :payment + + has_one_attached :file + has_one_attached :xml_file + + def file_url + return if file.blank? + + Rails.application.routes.url_helpers.rails_blob_url(file, host: ENV["LAGO_API_URL"]) + end + + def xml_url + return if xml_file.blank? + + Rails.application.routes.url_helpers.rails_blob_url(xml_file, host: ENV["LAGO_API_URL"]) + end +end + +# == Schema Information +# +# Table name: payment_receipts +# Database name: primary +# +# id :uuid not null, primary key +# number :string not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid not null +# organization_id :uuid not null +# payment_id :uuid not null +# +# Indexes +# +# index_payment_receipts_on_billing_entity_id (billing_entity_id) +# index_payment_receipts_on_organization_id (organization_id) +# index_payment_receipts_on_payment_id (payment_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_id => payments.id) +# diff --git a/app/models/payment_request.rb b/app/models/payment_request.rb new file mode 100644 index 0000000..9bf3142 --- /dev/null +++ b/app/models/payment_request.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +class PaymentRequest < ApplicationRecord + include PaperTrailTraceable + + has_many :applied_invoices, class_name: "PaymentRequest::AppliedInvoice" + has_many :invoices, through: :applied_invoices + has_many :payments, as: :payable + + belongs_to :organization + belongs_to :customer, -> { with_discarded } + belongs_to :dunning_campaign, -> { with_discarded }, optional: true + + delegate :billing_entity, to: :customer + + validates :amount_cents, presence: true + validates :amount_currency, presence: true + + PAYMENT_STATUS = %i[pending succeeded failed].freeze + + enum :payment_status, PAYMENT_STATUS, prefix: :payment + + alias_attribute :total_amount_cents, :amount_cents + alias_attribute :currency, :amount_currency + + monetize :amount_cents + monetize :total_due_amount_cents, with_model_currency: :currency, allow_nil: true + + normalizes :email, with: ->(email) { EmailSanitizer.call(email) } + + def self.ransackable_attributes(_ = nil) + %w[id number] + end + + def self.ransackable_associations(_ = nil) + %w[customer] + end + + def payment_invoices + invoices + end + + def invoice_ids + applied_invoices.pluck(:invoice_id) + end + + def increment_payment_attempts! + increment(:payment_attempts) + save! + end + + def total_amount_cents=(total_amount_cents) + self.amount_cents = total_amount_cents + end + + def total_due_amount_cents + (payment_status.to_sym == :succeeded) ? 0 : total_amount_cents + end +end + +# == Schema Information +# +# Table name: payment_requests +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint default(0), not null +# amount_currency :string not null +# email :string +# payment_attempts :integer default(0), not null +# payment_status :integer default("pending"), not null +# ready_for_payment_processing :boolean default(TRUE), not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# dunning_campaign_id :uuid +# organization_id :uuid not null +# +# Indexes +# +# index_payment_requests_on_customer_id (customer_id) +# index_payment_requests_on_dunning_campaign_id (dunning_campaign_id) +# index_payment_requests_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (dunning_campaign_id => dunning_campaigns.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/payment_request/applied_invoice.rb b/app/models/payment_request/applied_invoice.rb new file mode 100644 index 0000000..1f30ee0 --- /dev/null +++ b/app/models/payment_request/applied_invoice.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class PaymentRequest + class AppliedInvoice < ApplicationRecord + self.table_name = "invoices_payment_requests" + + belongs_to :invoice + belongs_to :payment_request + belongs_to :organization + end +end + +# == Schema Information +# +# Table name: invoices_payment_requests +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# invoice_id :uuid not null +# organization_id :uuid not null +# payment_request_id :uuid not null +# +# Indexes +# +# idx_on_invoice_id_payment_request_id_aa550779a4 (invoice_id,payment_request_id) UNIQUE +# index_invoices_payment_requests_on_invoice_id (invoice_id) +# index_invoices_payment_requests_on_organization_id (organization_id) +# index_invoices_payment_requests_on_payment_request_id (payment_request_id) +# +# Foreign Keys +# +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_request_id => payment_requests.id) +# diff --git a/app/models/pending_vies_check.rb b/app/models/pending_vies_check.rb new file mode 100644 index 0000000..bd0ed0d --- /dev/null +++ b/app/models/pending_vies_check.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class PendingViesCheck < ApplicationRecord + ERROR_TYPE_MAPPING = { + Valvat::RateLimitError => "rate_limit", + Valvat::Timeout => "timeout", + Valvat::BlockedError => "blocked", + Valvat::InvalidRequester => "invalid_requester", + Valvat::ServiceUnavailable => "service_unavailable", + Valvat::HTTPError => "service_unavailable", + Valvat::MemberStateUnavailable => "member_state_unavailable" + }.freeze + + KNOWN_ERROR_TYPES = (ERROR_TYPE_MAPPING.values + ["unknown"]).freeze + + belongs_to :organization + belongs_to :billing_entity + belongs_to :customer + + validates :customer_id, uniqueness: true + validates :attempts_count, numericality: {greater_than_or_equal_to: 0} + validates :last_error_type, inclusion: {in: KNOWN_ERROR_TYPES}, allow_nil: true + + def self.error_type_for(exception) + ERROR_TYPE_MAPPING.fetch(exception.class, "unknown") + end +end + +# == Schema Information +# +# Table name: pending_vies_checks +# Database name: primary +# +# id :uuid not null, primary key +# attempts_count :integer default(0), not null +# last_attempt_at :datetime +# last_error_message :text +# last_error_type :string +# tax_identification_number :string +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid not null +# customer_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_pending_vies_checks_on_billing_entity_id (billing_entity_id) +# index_pending_vies_checks_on_customer_id (customer_id) UNIQUE +# index_pending_vies_checks_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/permission.rb b/app/models/permission.rb new file mode 100644 index 0000000..c4713ba --- /dev/null +++ b/app/models/permission.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Permission + extend self + + def permissions_hash(role = nil) + role = role.to_s.downcase + DATA.transform_values { |list| role == "admin" || list.include?(role) } + end + + private + + def yaml_to_hash(filename) + h = YAML.parse_file(Rails.root.join("config", filename)).to_ruby + DottedHash.new(h, separator: ":").transform_values(&:to_a) + end + + # rubocop:disable Layout/ClassStructure + DATA = yaml_to_hash("permissions.yml").freeze +end diff --git a/app/models/plan.rb b/app/models/plan.rb new file mode 100644 index 0000000..d1c77f1 --- /dev/null +++ b/app/models/plan.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +class Plan < ApplicationRecord + include PaperTrailTraceable + include Currencies + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + belongs_to :parent, class_name: "Plan", optional: true + + has_one :minimum_commitment, -> { where(commitment_type: :minimum_commitment) }, class_name: "Commitment" + has_one :metadata, + class_name: "Metadata::ItemMetadata", + as: :owner + + has_many :commitments + has_many :charges, dependent: :destroy + has_many :billable_metrics, through: :charges + has_many :charge_filters, through: :charges, source: :filters + has_many :fixed_charges, dependent: :destroy + has_many :add_ons, through: :fixed_charges + has_many :subscriptions + has_many :customers, through: :subscriptions + has_many :children, class_name: "Plan", foreign_key: :parent_id, dependent: :destroy + has_many :coupon_targets + has_many :coupons, through: :coupon_targets + has_many :invoices, through: :subscriptions + has_many :usage_thresholds + + has_many :applied_taxes, class_name: "Plan::AppliedTax", dependent: :destroy + has_many :taxes, through: :applied_taxes + has_many :entitlements, class_name: "Entitlement::Entitlement", dependent: :destroy + has_many :entitlement_values, through: :entitlements, source: :values, class_name: "Entitlement::EntitlementValue", dependent: :destroy + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + as: :resource + + INTERVALS = %i[ + weekly + monthly + yearly + quarterly + semiannual + ].freeze + + enum :interval, INTERVALS, validate: true + + monetize :amount_cents + + validates :name, :code, presence: true + validates :amount_currency, inclusion: {in: currency_list} + validates :pay_in_advance, inclusion: {in: [true, false]} + validate :validate_code_unique + + default_scope -> { kept } + scope :parents, -> { where(parent_id: nil) } + + def self.ransackable_attributes(_auth_object = nil) + %w[name code] + end + + def is_parent? + !is_child? + end + + def is_child? + parent_id.present? + end + + def applicable_usage_thresholds + (parent || self).usage_thresholds + end + + def pay_in_arrears? + !pay_in_advance + end + + def attached_to_subscriptions? + subscriptions.exists? + end + + def has_trial? + trial_period.present? && trial_period.positive? + end + + def charges_billed_in_monthly_split_intervals? + bill_charges_monthly? && (yearly? || semiannual?) + end + + def fixed_charges_billed_in_monthly_split_intervals? + bill_fixed_charges_monthly? && (yearly? || semiannual?) + end + + def charges_or_fixed_charges_billed_in_monthly_split_intervals? + charges_billed_in_monthly_split_intervals? || fixed_charges_billed_in_monthly_split_intervals? + end + + def invoice_name + invoice_display_name.presence || name + end + + # NOTE: Method used to compare plan for upgrade / downgrade on + # a same duration basis. It is not intended to be used + # directly for billing/invoicing purpose + def yearly_amount_cents + return amount_cents if yearly? + return amount_cents * 12 if monthly? + return amount_cents * 4 if quarterly? + return amount_cents * 2 if semiannual? + + amount_cents * 52 + end + + def active_subscriptions_count + count = subscriptions.active.count + return count unless children + + count + children.joins(:subscriptions).merge(Subscription.active).select("subscriptions.id").distinct.count + end + + def customers_count + count = subscriptions.active.select(:customer_id).distinct.count + return count unless children + + count + children.joins(:subscriptions).merge(Subscription.active).select(:customer_id).distinct.count + end + + def draft_invoices_count + count = subscriptions.joins(:invoices).merge(Invoice.draft).select(:invoice_id).distinct.count + return count unless children + + count + children.joins(:subscriptions).joins(:invoices).merge(Invoice.draft).select(:invoice_id).distinct.count + end + + private + + def validate_code_unique + return unless organization + return if parent_id? + + plan = organization.plans.parents.where(code:).first + errors.add(:code, :taken) if plan && plan != self + end +end + +# == Schema Information +# +# Table name: plans +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# amount_currency :string not null +# bill_charges_monthly :boolean +# bill_fixed_charges_monthly :boolean default(FALSE) +# code :string not null +# deleted_at :datetime +# description :string +# interval :integer not null +# invoice_display_name :string +# name :string not null +# pay_in_advance :boolean default(FALSE), not null +# pending_deletion :boolean default(FALSE), not null +# trial_period :float +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# parent_id :uuid +# +# Indexes +# +# index_plans_on_bill_fixed_charges_monthly (bill_fixed_charges_monthly) WHERE ((deleted_at IS NULL) AND (bill_fixed_charges_monthly IS TRUE)) +# index_plans_on_created_at (created_at) +# index_plans_on_deleted_at (deleted_at) +# index_plans_on_organization_id (organization_id) +# index_plans_on_organization_id_and_code (organization_id,code) UNIQUE WHERE ((deleted_at IS NULL) AND (parent_id IS NULL)) +# index_plans_on_parent_id (parent_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (parent_id => plans.id) +# diff --git a/app/models/plan/applied_tax.rb b/app/models/plan/applied_tax.rb new file mode 100644 index 0000000..3b82990 --- /dev/null +++ b/app/models/plan/applied_tax.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class Plan + class AppliedTax < ApplicationRecord + self.table_name = "plans_taxes" + + include PaperTrailTraceable + + belongs_to :plan + belongs_to :tax + belongs_to :organization + end +end + +# == Schema Information +# +# Table name: plans_taxes +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# plan_id :uuid not null +# tax_id :uuid not null +# +# Indexes +# +# index_plans_taxes_on_organization_id (organization_id) +# index_plans_taxes_on_plan_id (plan_id) +# index_plans_taxes_on_plan_id_and_tax_id (plan_id,tax_id) UNIQUE +# index_plans_taxes_on_tax_id (tax_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (tax_id => taxes.id) +# diff --git a/app/models/presentation_breakdown.rb b/app/models/presentation_breakdown.rb new file mode 100644 index 0000000..e6ac8df --- /dev/null +++ b/app/models/presentation_breakdown.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class PresentationBreakdown < ApplicationRecord + belongs_to :organization + belongs_to :fee +end + +# == Schema Information +# +# Table name: presentation_breakdowns +# Database name: primary +# +# id :uuid not null, primary key +# presentation_by :jsonb not null +# units :decimal(, ) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# fee_id :uuid not null +# organization_id :uuid not null +# +# Indexes +# +# index_presentation_breakdowns_on_fee_id (fee_id) +# index_presentation_breakdowns_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (fee_id => fees.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/pricing_unit.rb b/app/models/pricing_unit.rb new file mode 100644 index 0000000..f6ff2a2 --- /dev/null +++ b/app/models/pricing_unit.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +class PricingUnit < ApplicationRecord + belongs_to :organization + has_many :pricing_unit_usages, dependent: :destroy + + validates :name, :code, :short_name, presence: true + validates :code, uniqueness: {scope: :organization_id} + validates :description, length: {maximum: 600}, allow_nil: true + validates :short_name, length: {maximum: 3}, allow_nil: true + + def self.ransackable_attributes(_auth_object = nil) + %w[code name] + end + + def exponent + 2 + end + + def subunit_to_unit + 10**exponent + end +end + +# == Schema Information +# +# Table name: pricing_units +# Database name: primary +# +# id :uuid not null, primary key +# code :string not null +# description :text +# name :string not null +# short_name :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_pricing_units_on_code_and_organization_id (code,organization_id) UNIQUE +# index_pricing_units_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/pricing_unit_usage.rb b/app/models/pricing_unit_usage.rb new file mode 100644 index 0000000..c9454cc --- /dev/null +++ b/app/models/pricing_unit_usage.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +class PricingUnitUsage < ApplicationRecord + belongs_to :organization + belongs_to :fee + belongs_to :pricing_unit + + validates :short_name, :conversion_rate, presence: true + validates :conversion_rate, numericality: {greater_than: 0} + + attr_accessor :projected_amount_cents + + def self.build_from_fiat_amounts(amount:, unit_amount:, applied_pricing_unit:) + pricing_unit = applied_pricing_unit.pricing_unit + + rounded_amount = amount.round(pricing_unit.exponent) + amount_cents = rounded_amount * pricing_unit.subunit_to_unit + precise_amount_cents = amount * pricing_unit.subunit_to_unit.to_d + unit_amount_cents = unit_amount * pricing_unit.subunit_to_unit + + new( + organization: pricing_unit.organization, + pricing_unit:, + short_name: pricing_unit.short_name, + conversion_rate: applied_pricing_unit.conversion_rate, + amount_cents:, + precise_amount_cents:, + unit_amount_cents:, + precise_unit_amount: unit_amount + ) + end + + def to_fiat_currency_cents(currency) + adjusted_amount = amount_cents.to_d * conversion_rate / pricing_unit.subunit_to_unit + adjusted_unit_amount = unit_amount_cents.to_d * conversion_rate / pricing_unit.subunit_to_unit + + { + amount_cents: adjusted_amount.round(currency.exponent) * currency.subunit_to_unit, + precise_amount_cents: adjusted_amount * currency.subunit_to_unit.to_d, + unit_amount_cents: adjusted_unit_amount * currency.subunit_to_unit, + precise_unit_amount: adjusted_unit_amount + } + end + + def currency + PricingUnit.new(short_name:) + end +end + +# == Schema Information +# +# Table name: pricing_unit_usages +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# conversion_rate :decimal(40, 15) default(0.0), not null +# precise_amount_cents :decimal(40, 15) default(0.0), not null +# precise_unit_amount :decimal(30, 15) default(0.0), not null +# short_name :string not null +# unit_amount_cents :bigint default(0), not null +# created_at :datetime not null +# updated_at :datetime not null +# fee_id :uuid not null +# organization_id :uuid not null +# pricing_unit_id :uuid not null +# +# Indexes +# +# index_pricing_unit_usages_on_fee_id (fee_id) +# index_pricing_unit_usages_on_organization_id (organization_id) +# index_pricing_unit_usages_on_pricing_unit_id (pricing_unit_id) +# +# Foreign Keys +# +# fk_rails_... (fee_id => fees.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (pricing_unit_id => pricing_units.id) +# diff --git a/app/models/quantified_event.rb b/app/models/quantified_event.rb new file mode 100644 index 0000000..9aab508 --- /dev/null +++ b/app/models/quantified_event.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class QuantifiedEvent < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + + RECURRING_TOTAL_UNITS = "total_aggregated_units" + + belongs_to :organization + belongs_to :billable_metric + belongs_to :group, optional: true + + has_many :events + + validates :added_at, presence: true + validates :external_subscription_id, presence: true + + default_scope -> { kept } +end + +# == Schema Information +# +# Table name: quantified_events +# Database name: primary +# +# id :uuid not null, primary key +# added_at :datetime not null +# deleted_at :datetime +# grouped_by :jsonb not null +# properties :jsonb not null +# removed_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# charge_filter_id :uuid +# external_id :string +# external_subscription_id :string not null +# group_id :uuid +# organization_id :uuid not null +# +# Indexes +# +# index_quantified_events_on_billable_metric_id (billable_metric_id) +# index_quantified_events_on_charge_filter_id (charge_filter_id) +# index_quantified_events_on_deleted_at (deleted_at) +# index_quantified_events_on_external_id (external_id) +# index_quantified_events_on_group_id (group_id) +# index_quantified_events_on_organization_id (organization_id) +# index_search_quantified_events (organization_id,external_subscription_id,billable_metric_id) +# +# Foreign Keys +# +# fk_rails_... (group_id => groups.id) +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/quote.rb b/app/models/quote.rb new file mode 100644 index 0000000..16ea960 --- /dev/null +++ b/app/models/quote.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class Quote < ApplicationRecord + include Sequenced + + ORDER_TYPES = { + subscription_creation: "subscription_creation", + subscription_amendment: "subscription_amendment", + one_off: "one_off" + }.freeze + + QUOTE_NUMBER_REGEX = /\AQT-\d{4}-\d{4,}\z/ + + before_save :ensure_number + + belongs_to :organization + belongs_to :customer + belongs_to :subscription, optional: true + + has_many :quote_owners, dependent: :destroy + has_many :owners, through: :quote_owners, source: :user, class_name: "User" + + has_many :versions, -> { order(sequential_id: :desc) }, class_name: "QuoteVersion" + has_one :current_version, -> { order(sequential_id: :desc) }, class_name: "QuoteVersion" + + enum :order_type, ORDER_TYPES, + instance_methods: false, + validate: true + + validates :subscription_id, + presence: true, + if: -> { order_type == "subscription_amendment" } + + sequenced( + scope: ->(quote) { quote.organization.quotes }, + lock_key: ->(quote) { quote.organization_id } + ) + + private + + def ensure_number + return if number.present? + return if sequential_id.blank? + + time = created_at || Time.current + formatted_sequential_id = format("%04d", sequential_id) + self.number = "QT-#{time.strftime("%Y")}-#{formatted_sequential_id}" + end +end + +# == Schema Information +# +# Table name: quotes +# Database name: primary +# +# id :uuid not null, primary key +# number :string not null +# order_type :enum not null +# created_at :datetime not null +# updated_at :datetime not null +# customer_id :uuid not null +# organization_id :uuid not null +# sequential_id :integer not null +# subscription_id :uuid +# +# Indexes +# +# index_quotes_on_customer_id (customer_id) +# index_quotes_on_subscription_id (subscription_id) +# index_unique_quotes_on_organization_number (organization_id,number) UNIQUE +# index_unique_quotes_on_organization_sequential_id (organization_id,sequential_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/quote_owner.rb b/app/models/quote_owner.rb new file mode 100644 index 0000000..facd2eb --- /dev/null +++ b/app/models/quote_owner.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class QuoteOwner < ApplicationRecord + belongs_to :organization + belongs_to :quote + belongs_to :user +end + +# == Schema Information +# +# Table name: quote_owners +# Database name: primary +# +# id :bigint not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# quote_id :uuid not null +# user_id :uuid not null +# +# Indexes +# +# index_quote_owners_on_organization_id (organization_id) +# index_quote_owners_on_user_id (user_id) +# index_unique_quote_owners_on_quote_user (quote_id,user_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (quote_id => quotes.id) +# fk_rails_... (user_id => users.id) +# diff --git a/app/models/quote_version.rb b/app/models/quote_version.rb new file mode 100644 index 0000000..f4605c8 --- /dev/null +++ b/app/models/quote_version.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +class QuoteVersion < ApplicationRecord + include Sequenced + + STATUSES = { + draft: "draft", + approved: "approved", + voided: "voided" + }.freeze + + VOID_REASONS = { + manual: "manual", + superseded: "superseded", + cascade_of_expired: "cascade_of_expired", + cascade_of_voided: "cascade_of_voided" + }.freeze + + before_save :ensure_share_token + + belongs_to :organization + belongs_to :quote + + enum :status, STATUSES, + default: :draft, + validate: true + enum :void_reason, VOID_REASONS, + instance_methods: false, + validate: {allow_nil: true} + + validates :share_token, + on: :update, + presence: true, + if: -> { draft? || approved? } + + validates :void_reason, :voided_at, + presence: true, + if: -> { voided? } + + validates :approved_at, + presence: true, + if: -> { approved? } + + sequenced( + scope: ->(quote_version) { quote_version.quote.versions }, + lock_key: ->(quote_version) { quote_version.quote_id } + ) + + def version = sequential_id + + private + + def ensure_share_token + return if voided? + + self.share_token ||= SecureRandom.uuid + end +end + +# == Schema Information +# +# Table name: quote_versions +# Database name: primary +# +# id :uuid not null, primary key +# approved_at :datetime +# billing_items :jsonb +# content :text +# share_token :string +# status :enum default("draft"), not null +# void_reason :enum +# voided_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# quote_id :uuid not null +# sequential_id :integer not null +# +# Indexes +# +# index_quote_versions_on_organization_id (organization_id) +# index_quote_versions_on_quote_id (quote_id) +# index_unique_quote_versions_on_quote_active_status (quote_id) UNIQUE WHERE (status = ANY (ARRAY['draft'::quote_status, 'approved'::quote_status])) +# index_unique_quote_versions_on_quote_sequential_id (quote_id,sequential_id) UNIQUE +# index_unique_quote_versions_on_share_token (share_token) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (quote_id => quotes.id) +# diff --git a/app/models/recurring_transaction_rule.rb b/app/models/recurring_transaction_rule.rb new file mode 100644 index 0000000..de3fcaf --- /dev/null +++ b/app/models/recurring_transaction_rule.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +class RecurringTransactionRule < ApplicationRecord + include PaperTrailTraceable + + belongs_to :wallet + belongs_to :organization + belongs_to :payment_method, optional: true + + has_many :applied_invoice_custom_sections, + class_name: "RecurringTransactionRule::AppliedInvoiceCustomSection", + dependent: :destroy + has_many :selected_invoice_custom_sections, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + + validates :transaction_name, length: {minimum: 1, maximum: 255}, allow_nil: true + + STATUSES = [ + :active, + :terminated + ].freeze + + INTERVALS = [ + :weekly, + :monthly, + :quarterly, + :yearly, + :semiannual + ].freeze + + METHODS = [ + :fixed, + :target + ].freeze + + TRIGGERS = [ + :interval, + :threshold + ].freeze + + enum :interval, INTERVALS + enum :method, METHODS + enum :trigger, TRIGGERS + enum :status, STATUSES + + scope :active, -> { where(status: statuses[:active]).where("expiration_at IS NULL OR expiration_at > ?", Time.current) } + scope :eligible_for_termination, -> { + where(status: statuses[:active]) + .where("expiration_at IS NOT NULL AND expiration_at <= ?", Time.current) + } + scope :expired, -> { where("recurring_transaction_rules.expiration_at::timestamp(0) <= ?", Time.current) } + + def currently_active? + active? && (expiration_at.nil? || expiration_at > Time.current) + end + + def mark_as_terminated!(timestamp = Time.zone.now) + self.terminated_at ||= timestamp + terminated! + end + + def apply_min_top_up_limits(credit_amount:) + if ignore_paid_top_up_limits? + credit_amount + else + credit_amount.clamp(wallet.paid_top_up_min_credits, nil) + end + end + + def compute_paid_credits(ongoing_balance:) + if target? + compute_target_paid_credits(ongoing_balance:) + else + paid_credits + end + end + + def compute_granted_credits + if target? + 0.0 + else + granted_credits + end + end + + private + + def compute_target_paid_credits(ongoing_balance:) + if ongoing_balance >= target_ongoing_balance + return 0.0 + end + + gap = target_ongoing_balance - ongoing_balance + + # NOTE: in case of target rule, we don't apply max because reaching target balance is the most important + apply_min_top_up_limits(credit_amount: gap) + end +end + +# == Schema Information +# +# Table name: recurring_transaction_rules +# Database name: primary +# +# id :uuid not null, primary key +# expiration_at :datetime +# granted_credits :decimal(30, 5) default(0.0), not null +# ignore_paid_top_up_limits :boolean default(FALSE), not null +# interval :integer default("weekly") +# invoice_requires_successful_payment :boolean default(FALSE), not null +# method :integer default("fixed"), not null +# paid_credits :decimal(30, 5) default(0.0), not null +# payment_method_type :enum default("provider"), not null +# skip_invoice_custom_sections :boolean default(FALSE), not null +# started_at :datetime +# status :integer default("active") +# target_ongoing_balance :decimal(30, 5) +# terminated_at :datetime +# threshold_credits :decimal(30, 5) default(0.0) +# transaction_metadata :jsonb +# transaction_name :string(255) +# trigger :integer default("interval"), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# payment_method_id :uuid +# wallet_id :uuid not null +# +# Indexes +# +# index_recurring_transaction_rules_on_expiration_at (expiration_at) +# index_recurring_transaction_rules_on_organization_id (organization_id) +# index_recurring_transaction_rules_on_payment_method_id (payment_method_id) +# index_recurring_transaction_rules_on_started_at (started_at) +# index_recurring_transaction_rules_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_method_id => payment_methods.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/recurring_transaction_rule/applied_invoice_custom_section.rb b/app/models/recurring_transaction_rule/applied_invoice_custom_section.rb new file mode 100644 index 0000000..3ae5574 --- /dev/null +++ b/app/models/recurring_transaction_rule/applied_invoice_custom_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class RecurringTransactionRule::AppliedInvoiceCustomSection < ApplicationRecord + self.table_name = "recurring_transaction_rules_invoice_custom_sections" + + belongs_to :organization + belongs_to :recurring_transaction_rule + belongs_to :invoice_custom_section +end + +# == Schema Information +# +# Table name: recurring_transaction_rules_invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# invoice_custom_section_id :uuid not null +# organization_id :uuid not null +# recurring_transaction_rule_id :uuid not null +# +# Indexes +# +# idx_on_invoice_custom_section_id_50c2a2e7c0 (invoice_custom_section_id) +# idx_on_organization_id_e73219f079 (organization_id) +# idx_on_recurring_transaction_rule_id_fba3d39cca (recurring_transaction_rule_id) +# index_rtr_invoice_custom_sections_unique (recurring_transaction_rule_id,invoice_custom_section_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (invoice_custom_section_id => invoice_custom_sections.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (recurring_transaction_rule_id => recurring_transaction_rules.id) +# diff --git a/app/models/refund.rb b/app/models/refund.rb new file mode 100644 index 0000000..4a60f85 --- /dev/null +++ b/app/models/refund.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class Refund < ApplicationRecord + include PaperTrailTraceable + + belongs_to :payment + belongs_to :credit_note + belongs_to :payment_provider, optional: true, class_name: "PaymentProviders::BaseProvider" + belongs_to :payment_provider_customer, class_name: "PaymentProviderCustomers::BaseCustomer" + belongs_to :organization +end + +# == Schema Information +# +# Table name: refunds +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint default(0), not null +# amount_currency :string not null +# status :string not null +# created_at :datetime not null +# updated_at :datetime not null +# credit_note_id :uuid not null +# organization_id :uuid not null +# payment_id :uuid not null +# payment_provider_customer_id :uuid not null +# payment_provider_id :uuid +# provider_refund_id :string not null +# +# Indexes +# +# index_refunds_on_credit_note_id (credit_note_id) +# index_refunds_on_organization_id (organization_id) +# index_refunds_on_payment_id (payment_id) +# index_refunds_on_payment_provider_customer_id (payment_provider_customer_id) +# index_refunds_on_payment_provider_id (payment_provider_id) +# +# Foreign Keys +# +# fk_rails_... (credit_note_id => credit_notes.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_id => payments.id) +# fk_rails_... (payment_provider_customer_id => payment_provider_customers.id) +# fk_rails_... (payment_provider_id => payment_providers.id) +# diff --git a/app/models/role.rb b/app/models/role.rb new file mode 100644 index 0000000..dcc91ec --- /dev/null +++ b/app/models/role.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class Role < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + belongs_to :organization, optional: true + has_many :membership_roles + has_many :memberships, through: :membership_roles + has_many :active_memberships, -> { active }, through: :membership_roles, source: :membership + + scope :admins, -> { where(admin: true) } + scope :with_code, ->(*codes) { where(code: codes) } + scope :with_organization, ->(organization_id) { where(organization_id: [nil, organization_id]) } + + before_validation :normalize_name + + validate :code_is_not_reserved, if: -> { organization_id && deleted_at.blank? } + validates :code, + presence: true, + length: {maximum: 100}, + format: {with: /\A[a-z0-9_]*\z/, allow_blank: true}, + uniqueness: {conditions: -> { where(deleted_at: nil) }, scope: :organization_id}, + if: -> { organization_id && deleted_at.blank? } + validates :name, + presence: true, + length: {maximum: 100}, + if: -> { organization_id && deleted_at.blank? } + validates :description, length: {maximum: 255} + validates :permissions, presence: true, if: :organization_id + + def permissions_hash + Permission.permissions_hash(name).dup.tap do |h| + permissions.each { |key| h[key] = true if h.key?(key) } + end + end + + private + + RESERVED_CODES = %w[admin finance manager].freeze + + def normalize_name + self.name = name&.strip&.gsub(/\s+/, " ") + end + + def code_is_not_reserved + errors.add(:code, :taken) if RESERVED_CODES.include?(code) + end +end + +# == Schema Information +# +# Table name: roles +# Database name: primary +# +# id :uuid not null, primary key +# admin :boolean default(FALSE), not null +# code :string not null +# deleted_at :datetime +# description :string +# name :string not null +# permissions :string default([]), not null, is an Array +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid +# +# Indexes +# +# index_roles_by_code_per_organization (organization_id NULLS FIRST,code) UNIQUE WHERE (deleted_at IS NULL) +# index_roles_by_unique_admin (admin) UNIQUE WHERE (admin AND (deleted_at IS NULL)) +# index_roles_on_organization_id (organization_id) +# diff --git a/app/models/subscription.rb b/app/models/subscription.rb new file mode 100644 index 0000000..e7b95bf --- /dev/null +++ b/app/models/subscription.rb @@ -0,0 +1,363 @@ +# frozen_string_literal: true + +class Subscription < ApplicationRecord + include PaperTrailTraceable + include RansackUuidSearch + + self.ignored_columns += %w[incompleted_at] + + belongs_to :customer, -> { with_discarded } + belongs_to :plan, -> { with_discarded } + belongs_to :previous_subscription, class_name: "Subscription", optional: true + belongs_to :organization + belongs_to :payment_method, optional: true + belongs_to :billing_entity, optional: true + + has_many :next_subscriptions, class_name: "Subscription", foreign_key: :previous_subscription_id + has_many :events + has_many :invoice_subscriptions + has_many :invoices, through: :invoice_subscriptions + has_many :integration_resources, as: :syncable + has_many :fees + has_many :daily_usages + has_many :usage_thresholds + has_many :entitlements, class_name: "Entitlement::Entitlement" + has_many :entitlement_removals, class_name: "Entitlement::SubscriptionFeatureRemoval" + has_many :fixed_charges, -> { kept }, through: :plan + has_many :fixed_charge_events + has_many :add_ons, through: :fixed_charges + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + foreign_key: :external_subscription_id, + primary_key: :external_id + has_many :activation_rules, class_name: "Subscription::ActivationRule" + has_many :applied_invoice_custom_sections, + class_name: "Subscription::AppliedInvoiceCustomSection", + dependent: :destroy + has_many :selected_invoice_custom_sections, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + + has_one :lifetime_usage, autosave: true + has_one :subscription_activity, class_name: "UsageMonitoring::SubscriptionActivity" + + has_many :alerts, ->(s) { where(organization_id: s.organization_id) }, class_name: "UsageMonitoring::Alert", foreign_key: :subscription_external_id, primary_key: :external_id + + delegate :amount_currency, to: :plan, prefix: true + + validates :external_id, :billing_time, presence: true + validate :validate_external_id, on: :create + + STATUSES = [ + :pending, + :active, + :terminated, + :canceled, + :incomplete + ].freeze + + CANCELATION_REASONS = {payment_failed: "payment_failed", timeout: "timeout"}.freeze + + BILLING_TIME = %i[ + calendar + anniversary + ].freeze + + ON_TERMINATION_CREDIT_NOTES = {credit: "credit", skip: "skip", refund: "refund", offset: "offset"}.freeze + ON_TERMINATION_INVOICES = {generate: "generate", skip: "skip"}.freeze + + enum :status, STATUSES + enum :billing_time, BILLING_TIME + enum :on_termination_credit_note, ON_TERMINATION_CREDIT_NOTES, prefix: true + enum :on_termination_invoice, ON_TERMINATION_INVOICES, prefix: true + enum :cancelation_reason, CANCELATION_REASONS + + validates :on_termination_credit_note, absence: true, if: -> { plan&.pay_in_arrears? } + validates :started_at, presence: true, if: -> { incomplete? } + + scope :starting_in_the_future, -> { pending.where(previous_subscription: nil) } + scope :expirable, -> { incomplete.joins(:activation_rules).merge(Subscription::ActivationRule.expirable) } + + # NOTE: SQL query to get subscription_at into customer timezone + def self.subscription_at_in_timezone_sql + <<-SQL + subscriptions.subscription_at::timestamptz AT TIME ZONE + COALESCE(customers.timezone, organizations.timezone, 'UTC') + SQL + end + + # NOTE: SQL query to get subscription_at into customer timezone + def self.ending_at_in_timezone_sql + <<-SQL + subscriptions.ending_at::timestamptz AT TIME ZONE + COALESCE(customers.timezone, organizations.timezone, 'UTC') + SQL + end + + def self.ransackable_attributes(_ = nil) + %w[id name external_id] + end + + def self.ransackable_associations(_ = nil) + %w[customer plan] + end + + def billing_entity + super || customer&.billing_entity + end + + def mark_as_active!(timestamp = Time.current) + self.started_at ||= timestamp + self.activated_at ||= timestamp + self.lifetime_usage ||= previous_subscription&.lifetime_usage || build_lifetime_usage(organization:) + self.lifetime_usage.recalculate_invoiced_usage = true + active! + end + + def mark_as_terminated!(timestamp = Time.current) + self.terminated_at ||= timestamp + terminated! + end + + def mark_as_canceled! + self.canceled_at ||= Time.current + canceled! + end + + def mark_as_incomplete!(timestamp = Time.current) + self.started_at ||= timestamp + incomplete! + end + + def pending_rules? + activation_rules.pending.any? + end + + def gated? + pending_rules? && incomplete? + end + + def payment_gated? + incomplete? && activation_rules.payment.pending.any? + end + + def upgraded? + return false unless next_subscription + + plan.yearly_amount_cents <= next_subscription.plan.yearly_amount_cents + end + + def downgraded? + return false unless next_subscription + + plan.yearly_amount_cents > next_subscription.plan.yearly_amount_cents + end + + def trial_end_date + return unless plan.has_trial? + + initial_started_at.to_date + plan.trial_period.days + end + + def trial_end_datetime + return unless plan.has_trial? + + initial_started_at + plan.trial_period.days + end + + def in_trial_period? + return false if trial_ended_at + return false if initial_started_at.future? + + trial_end_datetime.present? && trial_end_datetime.future? + end + + def started_in_past? + started_at.to_date < created_at.to_date + end + + def initial_started_at + customer.subscriptions + .where(external_id:) + .where.not(started_at: nil) + .order(started_at: :asc).first&.started_at || subscription_at + end + + def next_subscription + next_subscriptions.reject(&:canceled?).max_by(&:created_at) + end + + def already_billed? + fees.subscription.any? + end + + def starting_in_the_future? + pending? && previous_subscription.nil? + end + + def validate_external_id + return unless active? || incomplete? + return unless organization.subscriptions.where(status:).exists?(external_id:) + + errors.add(:external_id, :value_already_exist) + end + + def downgrade_plan_date + return unless next_subscription + return unless next_subscription.pending? + + ::Subscriptions::DatesService.new_instance(self, Time.current) + .next_end_of_period.to_date + 1.day + end + + def display_name + name.presence || plan.name + end + + def invoice_name + name.presence || plan.invoice_name + end + + # When upgrade, we want to bill one day less since date of the upgrade will be + # included in the first invoice for the new plan + def date_diff_with_timezone(from_datetime, to_datetime) + number_od_days = Utils::Datetime.date_diff_with_timezone( + from_datetime, + to_datetime, + customer.applicable_timezone + ) + + return number_od_days unless terminated? && upgraded? + + number_od_days -= 1 + + number_od_days.negative? ? 0 : number_od_days + end + + def should_sync_hubspot_subscription? + customer.integration_customers.hubspot_kind.any? { |c| c.integration.sync_subscriptions } + end + + def terminated_at?(timestamp) + return false unless terminated? + return false if terminated_at.nil? || timestamp.nil? + + # TODO: should be cleaned up to only use Time + timestamp = timestamp.to_time if [Date, DateTime, String].include?(timestamp.class) + timestamp = Time.zone.at(timestamp) if timestamp.is_a?(Integer) + + terminated_at.round <= timestamp.round + end + + # TODO: Apply this method in CreateInvoiceSubscriptionService + # This method calculates boundaries for terminated subscription. If termination is happening on billing date + # new boundaries will be calculated only if there is no invoice subscription object for previous period. + # Basically, we will bill regular subscription amount for previous period. + # If subscription is happening on any other day, method is returning boundaries only for the used dates in + # current period + def adjusted_boundaries(datetime, boundaries) + return boundaries unless terminated? && next_subscription.nil? + + # First we need to ensure that termination date is not started_at date. In that case boundaries are correct + # and we should bill only one day. If this is not the case we should proceed. + return boundaries if (datetime - 1.day) < started_at + + # Date service has various checks for terminated subscriptions. We want to avoid it and fetch boundaries + # for current usage (current period) but when subscription was active (one day ago) + duplicate = dup.tap { |s| s.status = :active } + + dates_service = Subscriptions::DatesService.new_instance(duplicate, datetime - 1.day, current_usage: true) + return boundaries if datetime < dates_service.charges_to_datetime + return boundaries unless (datetime - dates_service.charges_to_datetime) < 1.day + + # We should calculate boundaries as if subscription was not terminated + dates_service = Subscriptions::DatesService.new_instance(duplicate, datetime, current_usage: false) + + previous_period_boundaries = BillingPeriodBoundaries.new( + from_datetime: dates_service.from_datetime, + to_datetime: dates_service.to_datetime, + charges_from_datetime: dates_service.charges_from_datetime, + charges_to_datetime: dates_service.charges_to_datetime, + fixed_charges_from_datetime: dates_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: dates_service.fixed_charges_to_datetime, + timestamp: datetime, + charges_duration: dates_service.charges_duration_in_days, + fixed_charges_duration: dates_service.fixed_charges_duration_in_days + ) + + InvoiceSubscription.matching?(self, previous_period_boundaries) ? boundaries : previous_period_boundaries + end + + def has_progressive_billing? + # TODO: usage_thresholds optimize to a single query + applicable_usage_thresholds.any? + end + + # If the subscription has direct usage thresholds, these overrides take precedence. + # Thresholds attached to plan override (child plans) are being rmeoved and kept temporarily until the ata is migrated + # If no override, we always use the parent plan thresholds + def applicable_usage_thresholds + return [] if progressive_billing_disabled? + + usage_thresholds.presence || plan.usage_thresholds.presence || plan.applicable_usage_thresholds + end +end + +# == Schema Information +# +# Table name: subscriptions +# Database name: primary +# +# id :uuid not null, primary key +# activated_at :datetime +# billing_time :integer default("calendar"), not null +# cancelation_reason :enum +# canceled_at :datetime +# ending_at :datetime +# last_received_event_on :date +# name :string +# on_termination_credit_note :enum +# on_termination_invoice :enum default("generate"), not null +# payment_method_type :enum default("provider"), not null +# progressive_billing_disabled :boolean default(FALSE), not null +# skip_invoice_custom_sections :boolean default(FALSE), not null +# started_at :datetime +# status :integer not null +# subscription_at :datetime +# terminated_at :datetime +# trial_ended_at :datetime +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# customer_id :uuid not null +# external_id :string not null +# organization_id :uuid not null +# payment_method_id :uuid +# plan_id :uuid not null +# previous_subscription_id :uuid +# +# Indexes +# +# index_subscriptions_on_billing_entity_id (billing_entity_id) +# index_subscriptions_on_customer_id (customer_id) +# index_subscriptions_on_ending_at_active (ending_at) WHERE ((status = 1) AND (ending_at IS NOT NULL)) +# index_subscriptions_on_external_id (external_id) +# index_subscriptions_on_last_received_event_on (last_received_event_on) +# index_subscriptions_on_last_received_event_on_null (id) WHERE (last_received_event_on IS NULL) +# index_subscriptions_on_organization_id (organization_id) +# index_subscriptions_on_payment_method_id (payment_method_id) +# index_subscriptions_on_plan_id (plan_id) +# index_subscriptions_on_previous_subscription_id_and_status (previous_subscription_id,status) +# index_subscriptions_on_started_at (started_at) +# index_subscriptions_on_started_at_and_ending_at (started_at,ending_at) +# index_subscriptions_on_status (status) +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_method_id => payment_methods.id) +# fk_rails_... (plan_id => plans.id) +# diff --git a/app/models/subscription/activation_rule.rb b/app/models/subscription/activation_rule.rb new file mode 100644 index 0000000..02f7145 --- /dev/null +++ b/app/models/subscription/activation_rule.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +class Subscription::ActivationRule < ApplicationRecord + self.table_name = "subscription_activation_rules" + + STI_MAPPING = { + "payment" => "Subscription::ActivationRule::Payment" + }.freeze + + STATUSES = { + inactive: "inactive", + pending: "pending", + satisfied: "satisfied", + declined: "declined", # rule was applicable but declined (e.g., declined after undergoing a manual approval process) + failed: "failed", + expired: "expired", + not_applicable: "not_applicable" + }.freeze + + FULFILLED_STATUSES = STATUSES.values_at(:satisfied, :not_applicable).freeze + REJECTED_STATUSES = STATUSES.values_at(:failed, :expired, :declined).freeze + + TYPES = { + payment: "payment" + }.freeze + + belongs_to :subscription + belongs_to :organization + + enum :status, STATUSES, validate: true + enum :type, TYPES, validate: true + + validates :type, presence: true, inclusion: {in: STI_MAPPING.keys} + + scope :expirable, -> { pending.where("expires_at <= ?", Time.current) } + scope :rejected, -> { where(status: REJECTED_STATUSES) } + scope :fulfilled, -> { where(status: FULFILLED_STATUSES) } + + def self.find_sti_class(type_name) + STI_MAPPING.fetch(type_name).constantize + end + + def self.sti_name + STI_MAPPING.invert.fetch(name) + end + + def applicable? + raise NotImplementedError, "#{self.class}#applicable? must be implemented" + end + + def evaluate! + evaluate_service_class.call!(rule: self) + end + + private + + def evaluate_service_class + type_module = self.class.name.demodulize + "Subscriptions::ActivationRules::#{type_module}::EvaluateService".constantize + end +end + +# == Schema Information +# +# Table name: subscription_activation_rules +# Database name: primary +# +# id :uuid not null, primary key +# expires_at :datetime +# status :enum default("inactive"), not null +# timeout_hours :integer default(0), not null +# type :enum not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# idx_on_subscription_id_type_8feb7b9623 (subscription_id,type) UNIQUE +# index_activation_rules_pending_with_expiry (status,expires_at) WHERE ((status = 'pending'::subscription_activation_rule_statuses) AND (expires_at IS NOT NULL)) +# index_subscription_activation_rules_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/subscription/activation_rule/payment.rb b/app/models/subscription/activation_rule/payment.rb new file mode 100644 index 0000000..773da37 --- /dev/null +++ b/app/models/subscription/activation_rule/payment.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class Subscription::ActivationRule::Payment < Subscription::ActivationRule + def applicable? + return true if subscription.plan.pay_in_advance? && !subscription.in_trial_period? + return true if has_pay_in_advance_fixed_charges? + + false + end + + private + + def has_pay_in_advance_fixed_charges? + subscription.fixed_charges.pay_in_advance.any? + end +end + +# == Schema Information +# +# Table name: subscription_activation_rules +# Database name: primary +# +# id :uuid not null, primary key +# expires_at :datetime +# status :enum default("inactive"), not null +# timeout_hours :integer default(0), not null +# type :enum not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# idx_on_subscription_id_type_8feb7b9623 (subscription_id,type) UNIQUE +# index_activation_rules_pending_with_expiry (status,expires_at) WHERE ((status = 'pending'::subscription_activation_rule_statuses) AND (expires_at IS NOT NULL)) +# index_subscription_activation_rules_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/subscription/applied_invoice_custom_section.rb b/app/models/subscription/applied_invoice_custom_section.rb new file mode 100644 index 0000000..5aa0d0d --- /dev/null +++ b/app/models/subscription/applied_invoice_custom_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Subscription::AppliedInvoiceCustomSection < ApplicationRecord + self.table_name = "subscriptions_invoice_custom_sections" + + belongs_to :organization + belongs_to :subscription + belongs_to :invoice_custom_section +end + +# == Schema Information +# +# Table name: subscriptions_invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# invoice_custom_section_id :uuid not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# idx_on_invoice_custom_section_id_d8b9068730 (invoice_custom_section_id) +# index_subscriptions_invoice_custom_sections_on_organization_id (organization_id) +# index_subscriptions_invoice_custom_sections_on_subscription_id (subscription_id) +# index_subscriptions_invoice_custom_sections_unique (subscription_id,invoice_custom_section_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (invoice_custom_section_id => invoice_custom_sections.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/subscription_usage.rb b/app/models/subscription_usage.rb new file mode 100644 index 0000000..64347dd --- /dev/null +++ b/app/models/subscription_usage.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +SubscriptionUsage = Struct.new( + :from_datetime, + :to_datetime, + :issuing_date, + :currency, + :amount_cents, + :total_amount_cents, + :taxes_amount_cents, + :fees +) diff --git a/app/models/tax.rb b/app/models/tax.rb new file mode 100644 index 0000000..6a23bbd --- /dev/null +++ b/app/models/tax.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class Tax < ApplicationRecord + include PaperTrailTraceable + include Discard::Model + + self.discard_column = :deleted_at + default_scope -> { kept } + + ORDERS = %w[name rate].freeze + + has_many :applied_taxes, class_name: "Customer::AppliedTax", dependent: :destroy + has_many :customers, through: :applied_taxes + + has_many :billing_entities_taxes, class_name: "BillingEntity::AppliedTax", dependent: :destroy + has_many :billing_entities, through: :billing_entities_taxes + has_many :draft_fee_taxes, -> { joins(fee: :invoice).where(invoices: {status: :draft}) }, class_name: "Fee::AppliedTax", dependent: :destroy + has_many :fees_taxes, class_name: "Fee::AppliedTax" + has_many :fees, through: :fees_taxes + has_many :draft_invoice_taxes, -> { joins(:invoice).where(invoices: {status: :draft}) }, class_name: "Invoice::AppliedTax", dependent: :destroy + has_many :invoices_taxes, class_name: "Invoice::AppliedTax" + has_many :invoices, through: :invoices_taxes + has_many :credit_notes_taxes, class_name: "CreditNote::AppliedTax", dependent: :destroy + has_many :credit_notes, through: :credit_notes_taxes + has_many :add_ons_taxes, class_name: "AddOn::AppliedTax", dependent: :destroy + has_many :add_ons, through: :add_ons_taxes + has_many :plans_taxes, class_name: "Plan::AppliedTax", dependent: :destroy + has_many :plans, through: :plans_taxes + has_many :charges_taxes, class_name: "Charge::AppliedTax", dependent: :destroy + has_many :charges, through: :charges_taxes + has_many :commitments_taxes, class_name: "Commitment::AppliedTax", dependent: :destroy + has_many :commitments, through: :commitments_taxes + has_many :fixed_charges_taxes, class_name: "FixedCharge::AppliedTax", dependent: :destroy + has_many :fixed_charges, through: :fixed_charges_taxes + + belongs_to :organization + + validates :name, :rate, presence: true + validates :code, presence: true, uniqueness: {scope: :organization_id} + + scope :applied_to_organization, -> { where(applied_to_organization: true) } + + def self.ransackable_attributes(_auth_object = nil) + %w[name code] + end + + def customers_count + applicable_customers.count + end + + def applicable_customers + # return customers if the tax is not applied to any billing entity (tax is attached only to customers) + return customers if billing_entities.empty? + + # NOTE: When applied to a billing_entity + # customer list = customer without tax in billing_entities with this tax (tax used as default) + + # customer attached to the current tax + customers_without_taxes_query = organization.customers.left_joins(:applied_taxes) + .where(billing_entity_id: billing_entities.select(:id)) + .group("customers.id") + .having("COUNT(customers_taxes.id) = 0") + .select(:id) + organization.customers.where(id: customers_without_taxes_query) + .or(organization.customers.where(id: customers.select(:id))) + end +end + +# == Schema Information +# +# Table name: taxes +# Database name: primary +# +# id :uuid not null, primary key +# applied_to_organization :boolean default(FALSE), not null +# auto_generated :boolean default(FALSE), not null +# code :string not null +# deleted_at :datetime +# description :string +# name :string not null +# rate :float default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# idx_unique_tax_code_per_organization (code,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# index_taxes_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/models/usage_filters.rb b/app/models/usage_filters.rb new file mode 100644 index 0000000..cfab26a --- /dev/null +++ b/app/models/usage_filters.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class UsageFilters + # filter_by_charge - when set, allows to calculate usage only for a specific charge and not all charges of a plan + # example: Charge.last + # + # filter_by_group - when set, calculates usage only for specific {pricing_group_key: value}. + # Note that if sent, will ignore grouping by this pricing_group_key + # example: {user_id: '123'} + # + # skip_grouping - when set, will ignore grouping by pricing_group_keys + # full_usage - when set, will ignore boundaries and will return usage since subscription.started_at + + attr_reader :filter_by_charge_id, :filter_by_charge_code, :filter_by_metric_code, :filter_by_group, :filter_by_presentation, :skip_grouping, :full_usage + + def self.init_from_params(params) + group = params[:group] || params[:filter_by_group] + group = JSON.parse(group) if group.is_a?(String) + group = group.to_unsafe_h if group.respond_to?(:to_unsafe_h) + + presentation = params[:filter_by_presentation] + presentation = JSON.parse(presentation) if presentation.is_a?(String) + presentation = Array(presentation) if presentation.present? + + new( + filter_by_charge_id: params[:charge_id] || params[:filter_by_charge_id], + filter_by_charge_code: params[:charge_code] || params[:filter_by_charge_code], + filter_by_metric_code: params[:billable_metric_code] || params[:filter_by_metric_code], + filter_by_group: group, + filter_by_presentation: presentation, + skip_grouping: ActiveModel::Type::Boolean.new.cast(params[:skip_grouping]), + full_usage: ActiveModel::Type::Boolean.new.cast(params[:full_usage]) + ) + end + + def initialize(filter_by_charge_id: nil, filter_by_charge_code: nil, filter_by_metric_code: nil, filter_by_group: nil, filter_by_presentation: nil, skip_grouping: false, full_usage: false) + @filter_by_charge_id = filter_by_charge_id + @filter_by_charge_code = filter_by_charge_code + @filter_by_metric_code = filter_by_metric_code + @filter_by_group = filter_by_group&.transform_values { |v| Array(v) } + @filter_by_presentation = filter_by_presentation + @skip_grouping = skip_grouping + @full_usage = full_usage + end + + def has_charge_filter? + filter_by_charge_id.present? || filter_by_charge_code.present? || filter_by_metric_code.present? + end + + NONE = new.freeze + WITHOUT_PRESENTATION_FILTER = new(filter_by_presentation: []).freeze +end diff --git a/app/models/usage_monitoring.rb b/app/models/usage_monitoring.rb new file mode 100644 index 0000000..2a84cca --- /dev/null +++ b/app/models/usage_monitoring.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module UsageMonitoring + def self.table_name_prefix + "usage_monitoring_" + end +end diff --git a/app/models/usage_monitoring/alert.rb b/app/models/usage_monitoring/alert.rb new file mode 100644 index 0000000..04900d4 --- /dev/null +++ b/app/models/usage_monitoring/alert.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +module UsageMonitoring + class Alert < ApplicationRecord + include Discard::Model + + self.discard_column = :deleted_at + self.inheritance_column = :alert_type + + STI_MAPPING = { + "current_usage_amount" => "UsageMonitoring::CurrentUsageAmountAlert", + "billable_metric_current_usage_amount" => "UsageMonitoring::BillableMetricCurrentUsageAmountAlert", + "billable_metric_current_usage_units" => "UsageMonitoring::BillableMetricCurrentUsageUnitsAlert", + "lifetime_usage_amount" => "UsageMonitoring::LifetimeUsageAmountAlert", + "billable_metric_lifetime_usage_units" => "UsageMonitoring::BillableMetricLifetimeUsageUnitsAlert", + "wallet_balance_amount" => "UsageMonitoring::WalletBalanceAmountAlert", + "wallet_credits_balance" => "UsageMonitoring::WalletCreditsBalanceAlert", + "wallet_ongoing_balance_amount" => "UsageMonitoring::WalletOngoingBalanceAmountAlert", + "wallet_credits_ongoing_balance" => "UsageMonitoring::WalletCreditsOngoingBalanceAlert" + } + + CURRENT_USAGE_TYPES = %w[current_usage_amount billable_metric_current_usage_amount billable_metric_current_usage_units] + BILLABLE_METRIC_TYPES = %w[billable_metric_current_usage_amount billable_metric_current_usage_units billable_metric_lifetime_usage_units] + BILLABLE_METRIC_LIFETIME_USAGE_TYPES = %w[billable_metric_lifetime_usage_units] + SUBSCRIPTION_TYPES = %w[current_usage_amount billable_metric_current_usage_amount billable_metric_current_usage_units lifetime_usage_amount billable_metric_lifetime_usage_units] + WALLET_TYPES = %w[wallet_balance_amount wallet_credits_balance wallet_ongoing_balance_amount wallet_credits_ongoing_balance] + + DIRECTIONS = {increasing: "increasing", decreasing: "decreasing"}.freeze + + default_scope -> { kept } + + belongs_to :organization + belongs_to :billable_metric, -> { with_discarded }, optional: true + belongs_to :wallet, optional: true + + has_many :thresholds, + foreign_key: :usage_monitoring_alert_id, + class_name: "UsageMonitoring::AlertThreshold", + dependent: :delete_all + + has_many :triggered_alerts, + foreign_key: :usage_monitoring_alert_id, + class_name: "UsageMonitoring::TriggeredAlert" + + validates :alert_type, presence: true, inclusion: {in: STI_MAPPING.keys} + validates :code, presence: true + validates :billable_metric, presence: true, if: :need_billable_metric? + validates :billable_metric, absence: true, unless: :need_billable_metric? + validates :subscription_external_id, presence: true, if: :need_subscription? + validates :wallet, presence: true, if: :need_wallet? + + scope :using_current_usage, -> { where(alert_type: CURRENT_USAGE_TYPES) } + scope :using_lifetime_usage, -> { where(alert_type: "lifetime_usage_amount") } + scope :using_billable_metric_lifetime_usage, -> { where(alert_type: BILLABLE_METRIC_LIFETIME_USAGE_TYPES) } + scope :using_subscription, -> { where(alert_type: SUBSCRIPTION_TYPES) } + scope :using_wallet, -> { where(alert_type: WALLET_TYPES) } + + enum :direction, DIRECTIONS, validate: true + + def self.find_sti_class(type_name) + STI_MAPPING.fetch(type_name).constantize + end + + def self.sti_name + STI_MAPPING.invert.fetch(name) + end + + def find_thresholds_crossed(current) + if increasing? + find_thresholds_crossed_increasing(current) + else + find_thresholds_crossed_decreasing(current) + end + end + + def find_thresholds_crossed_increasing(current) + crossed = [] + return crossed if current <= previous_value + + if one_time_thresholds_values.present? + return crossed if current < one_time_thresholds_values.first + + if previous_value < one_time_thresholds_values.last + crossed += one_time_thresholds_values.filter { it > previous_value && it <= current } + end + end + + crossed += find_recurring_thresholds_crossed_increasing( + previous_value, current, recurring_threshold&.value, one_time_thresholds_values.last || 0 + ) + + crossed.uniq.sort + end + + def find_thresholds_crossed_decreasing(current) + crossed = [] + return crossed if current >= previous_value + + if one_time_thresholds_values.present? + return crossed if current > one_time_thresholds_values.last + + if previous_value > one_time_thresholds_values.first + crossed += one_time_thresholds_values.filter { it < previous_value && it >= current } + end + end + + crossed += find_recurring_thresholds_crossed_decreasing( + previous_value, current, recurring_threshold&.value, one_time_thresholds_values.first || 0 + ) + + crossed.uniq.sort + end + + def recurring_threshold + @recurring_threshold ||= thresholds.find { |th| th.recurring } + end + + def one_time_thresholds_values + @one_time_thresholds_values ||= thresholds.all.filter_map { |th| th.value unless th.recurring }.uniq.sort + end + + def formatted_crossed_thresholds(crossed_threshold_values) + regular_thresholds_values, recurring_thresholds_values = crossed_threshold_values.partition do |v| + one_time_thresholds_values.include?(v) + end + + formatted_regular_thresholds = thresholds + .reject { it.recurring } + .filter { regular_thresholds_values.include?(it.value) } + .map { |t| {code: t.code, value: t.value, recurring: false} } + + formatted_recurring_thresholds = recurring_thresholds_values + .map { |v| {code: recurring_threshold&.code, value: v, recurring: true} } + + formatted_regular_thresholds + formatted_recurring_thresholds + end + + def find_value(current_metrics) + raise NotImplementedError + end + + private + + def need_billable_metric? + BILLABLE_METRIC_TYPES.include?(alert_type) + end + + def need_wallet? + WALLET_TYPES.include?(alert_type) + end + + def need_subscription? + !need_wallet? + end + + def find_recurring_thresholds_crossed_increasing(previous, current, step, initial) + return [] unless step + + previous_steps = ((previous - initial) / step).ceil + previous_recurring = initial + [previous_steps, 1].max * step + + current_steps = ((current - initial) / step).floor + current_recurring = initial + current_steps * step + + return [] if previous_recurring > current_recurring # Shouldn't happen + + (previous_recurring..current_recurring).step(step).to_a + end + + def find_recurring_thresholds_crossed_decreasing(previous, current, step, initial) + return [] unless step + + previous_steps = ((initial - previous) / step).ceil + previous_recurring = initial - [previous_steps, 1].max * step + + current_steps = ((initial - current) / step).floor + current_recurring = initial - current_steps * step + + return [] if previous_recurring < current_recurring # Shouldn't happen + + current_recurring.step(previous_recurring, step).to_a + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/alert_threshold.rb b/app/models/usage_monitoring/alert_threshold.rb new file mode 100644 index 0000000..376bcad --- /dev/null +++ b/app/models/usage_monitoring/alert_threshold.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +class UsageMonitoring::AlertThreshold < ApplicationRecord + SOFT_LIMIT = 20 + + belongs_to :organization + belongs_to :alert, + foreign_key: "usage_monitoring_alert_id", + class_name: "UsageMonitoring::Alert" +end + +# == Schema Information +# +# Table name: usage_monitoring_alert_thresholds +# Database name: primary +# +# id :uuid not null, primary key +# code :string +# recurring :boolean default(FALSE), not null +# value :decimal(30, 5) not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# usage_monitoring_alert_id :uuid not null +# +# Indexes +# +# idx_on_usage_monitoring_alert_id_78eb24d06c (usage_monitoring_alert_id) +# idx_on_usage_monitoring_alert_id_recurring_756a2a370d (usage_monitoring_alert_id,recurring) UNIQUE WHERE (recurring IS TRUE) +# index_usage_monitoring_alert_thresholds_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (usage_monitoring_alert_id => usage_monitoring_alerts.id) +# diff --git a/app/models/usage_monitoring/billable_metric_current_usage_amount_alert.rb b/app/models/usage_monitoring/billable_metric_current_usage_amount_alert.rb new file mode 100644 index 0000000..a182972 --- /dev/null +++ b/app/models/usage_monitoring/billable_metric_current_usage_amount_alert.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module UsageMonitoring + class BillableMetricCurrentUsageAmountAlert < Alert + def find_value(current_usage) + charge_ids = current_usage.fees.map(&:charge_id) + matching_charge_ids = Charge.where(id: charge_ids, billable_metric_id: billable_metric_id).ids + current_usage.fees.select { |fee| matching_charge_ids.include? fee.charge_id }.map(&:amount_cents).max + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/billable_metric_current_usage_units_alert.rb b/app/models/usage_monitoring/billable_metric_current_usage_units_alert.rb new file mode 100644 index 0000000..9c94351 --- /dev/null +++ b/app/models/usage_monitoring/billable_metric_current_usage_units_alert.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module UsageMonitoring + class BillableMetricCurrentUsageUnitsAlert < Alert + def find_value(current_usage) + charge_ids = current_usage.fees.map(&:charge_id) + matching_charge_ids = Charge.where(id: charge_ids, billable_metric_id: billable_metric_id).ids + current_usage.fees.select { |fee| matching_charge_ids.include? fee.charge_id }.map(&:units).max + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/billable_metric_lifetime_usage_units_alert.rb b/app/models/usage_monitoring/billable_metric_lifetime_usage_units_alert.rb new file mode 100644 index 0000000..63fd660 --- /dev/null +++ b/app/models/usage_monitoring/billable_metric_lifetime_usage_units_alert.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module UsageMonitoring + class BillableMetricLifetimeUsageUnitsAlert < Alert + def find_value(current_usage) + charge_ids = current_usage.fees.map(&:charge_id) + matching_charge_ids = Charge.where(id: charge_ids, billable_metric_id: billable_metric_id).ids + current_usage.fees.select { |fee| matching_charge_ids.include? fee.charge_id }.map(&:units).max + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/current_usage_amount_alert.rb b/app/models/usage_monitoring/current_usage_amount_alert.rb new file mode 100644 index 0000000..15134dd --- /dev/null +++ b/app/models/usage_monitoring/current_usage_amount_alert.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module UsageMonitoring + class CurrentUsageAmountAlert < Alert + def find_value(current_usage) + current_usage.amount_cents + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/lifetime_usage_amount_alert.rb b/app/models/usage_monitoring/lifetime_usage_amount_alert.rb new file mode 100644 index 0000000..224111d --- /dev/null +++ b/app/models/usage_monitoring/lifetime_usage_amount_alert.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module UsageMonitoring + class LifetimeUsageAmountAlert < Alert + def find_value(lifetime_usage) + lifetime_usage.total_amount_cents + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/subscription_activity.rb b/app/models/usage_monitoring/subscription_activity.rb new file mode 100644 index 0000000..eb98cc6 --- /dev/null +++ b/app/models/usage_monitoring/subscription_activity.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module UsageMonitoring + class SubscriptionActivity < ApplicationRecord + belongs_to :organization + belongs_to :subscription + end +end + +# == Schema Information +# +# Table name: usage_monitoring_subscription_activities +# Database name: primary +# +# id :bigint not null, primary key +# enqueued :boolean default(FALSE), not null +# enqueued_at :datetime +# inserted_at :datetime not null +# organization_id :uuid not null +# subscription_id :uuid not null +# +# Indexes +# +# idx_enqueued_per_organization (organization_id,enqueued) +# idx_on_organization_id_376a587b04 (organization_id) +# idx_subscription_unique (subscription_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/usage_monitoring/triggered_alert.rb b/app/models/usage_monitoring/triggered_alert.rb new file mode 100644 index 0000000..3e3d964 --- /dev/null +++ b/app/models/usage_monitoring/triggered_alert.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module UsageMonitoring + class TriggeredAlert < ApplicationRecord + belongs_to :organization + belongs_to :subscription, optional: true + belongs_to :wallet, optional: true + belongs_to :alert, + -> { with_discarded }, + foreign_key: "usage_monitoring_alert_id", + class_name: "UsageMonitoring::Alert" + end +end + +# == Schema Information +# +# Table name: usage_monitoring_triggered_alerts +# Database name: primary +# +# id :uuid not null, primary key +# crossed_thresholds :jsonb +# current_value :decimal(30, 5) not null +# previous_value :decimal(30, 5) not null +# triggered_at :datetime not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# subscription_id :uuid +# usage_monitoring_alert_id :uuid not null +# wallet_id :uuid +# +# Indexes +# +# idx_on_usage_monitoring_alert_id_4290c95dec (usage_monitoring_alert_id) +# index_usage_monitoring_triggered_alerts_on_organization_id (organization_id) +# index_usage_monitoring_triggered_alerts_on_subscription_id (subscription_id) +# index_usage_monitoring_triggered_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (subscription_id => subscriptions.id) +# fk_rails_... (usage_monitoring_alert_id => usage_monitoring_alerts.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/wallet_balance_amount_alert.rb b/app/models/usage_monitoring/wallet_balance_amount_alert.rb new file mode 100644 index 0000000..b328466 --- /dev/null +++ b/app/models/usage_monitoring/wallet_balance_amount_alert.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module UsageMonitoring + class WalletBalanceAmountAlert < Alert + def find_value(wallet) + wallet.balance_cents + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/wallet_credits_balance_alert.rb b/app/models/usage_monitoring/wallet_credits_balance_alert.rb new file mode 100644 index 0000000..158a16f --- /dev/null +++ b/app/models/usage_monitoring/wallet_credits_balance_alert.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module UsageMonitoring + class WalletCreditsBalanceAlert < Alert + def find_value(wallet) + wallet.credits_balance + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/wallet_credits_ongoing_balance_alert.rb b/app/models/usage_monitoring/wallet_credits_ongoing_balance_alert.rb new file mode 100644 index 0000000..8086604 --- /dev/null +++ b/app/models/usage_monitoring/wallet_credits_ongoing_balance_alert.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module UsageMonitoring + class WalletCreditsOngoingBalanceAlert < Alert + def find_value(wallet) + wallet.credits_ongoing_balance + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_monitoring/wallet_ongoing_balance_amount_alert.rb b/app/models/usage_monitoring/wallet_ongoing_balance_amount_alert.rb new file mode 100644 index 0000000..492b5a1 --- /dev/null +++ b/app/models/usage_monitoring/wallet_ongoing_balance_amount_alert.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module UsageMonitoring + class WalletOngoingBalanceAmountAlert < Alert + def find_value(wallet) + wallet.ongoing_balance_cents + end + end +end + +# == Schema Information +# +# Table name: usage_monitoring_alerts +# Database name: primary +# +# id :uuid not null, primary key +# alert_type :enum not null +# code :string not null +# deleted_at :datetime +# direction :enum default("increasing"), not null +# last_processed_at :datetime +# name :string +# previous_value :decimal(30, 5) default(0.0), not null +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid +# organization_id :uuid not null +# subscription_external_id :string +# wallet_id :uuid +# +# Indexes +# +# idx_alerts_code_unique_per_subscription (code,subscription_external_id,organization_id) UNIQUE WHERE (deleted_at IS NULL) +# idx_alerts_unique_per_type_per_subscription (subscription_external_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_subscription_with_bm (subscription_external_id,organization_id,alert_type,billable_metric_id) UNIQUE WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)) +# idx_alerts_unique_per_type_per_wallet (wallet_id,organization_id,alert_type) UNIQUE WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)) +# index_usage_monitoring_alerts_on_billable_metric_id (billable_metric_id) +# index_usage_monitoring_alerts_on_organization_id (organization_id) +# index_usage_monitoring_alerts_on_subscription_external_id (subscription_external_id) +# index_usage_monitoring_alerts_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/usage_threshold.rb b/app/models/usage_threshold.rb new file mode 100644 index 0000000..e6af5aa --- /dev/null +++ b/app/models/usage_threshold.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class UsageThreshold < ApplicationRecord + include PaperTrailTraceable + include Currencies + include Discard::Model + + self.discard_column = :deleted_at + + belongs_to :organization + belongs_to :plan, optional: true + belongs_to :subscription, optional: true + + has_many :applied_usage_thresholds + has_many :invoices, through: :applied_usage_thresholds + + monetize :amount_cents, with_currency: ->(threshold) { threshold.currency } + + validates :amount_cents, numericality: {greater_than: 0} + validates :amount_cents, uniqueness: {scope: %i[plan_id recurring deleted_at]}, if: -> { deleted_at.nil? && subscription_id.nil? } + validates :recurring, uniqueness: {scope: %i[plan_id deleted_at]}, if: -> { recurring? && deleted_at.nil? && subscription_id.nil? } + validate :exactly_one_parent_present + + scope :recurring, -> { where(recurring: true) } + scope :not_recurring, -> { where(recurring: false) } + + default_scope -> { kept } + + def invoice_name + threshold_display_name || I18n.t("invoice.usage_threshold") + end + + def currency + plan&.amount_currency || subscription&.plan_amount_currency || organization.default_currency + end + + private + + def exactly_one_parent_present + has_plan = plan_id.present? || plan.present? + has_subscription = subscription_id.present? || subscription.present? + + return if has_plan && !has_subscription + return if !has_plan && has_subscription + + errors.add(:base, "one_of_plan_or_subscription_required") + end +end + +# == Schema Information +# +# Table name: usage_thresholds +# Database name: primary +# +# id :uuid not null, primary key +# amount_cents :bigint not null +# deleted_at :datetime +# recurring :boolean default(FALSE), not null +# threshold_display_name :string +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# plan_id :uuid +# subscription_id :uuid +# +# Indexes +# +# idx_usage_thresholds_on_amount_plan_recurring (amount_cents,plan_id,recurring) UNIQUE WHERE ((deleted_at IS NULL) AND (plan_id IS NOT NULL)) +# idx_usage_thresholds_on_amount_subscription_recurring (amount_cents,subscription_id,recurring) UNIQUE WHERE ((deleted_at IS NULL) AND (subscription_id IS NOT NULL)) +# idx_usage_thresholds_plan_recurring (plan_id,recurring) UNIQUE WHERE ((recurring IS TRUE) AND (deleted_at IS NULL) AND (plan_id IS NOT NULL)) +# idx_usage_thresholds_subscription_recurring (subscription_id,recurring) UNIQUE WHERE ((recurring IS TRUE) AND (deleted_at IS NULL) AND (subscription_id IS NOT NULL)) +# index_usage_thresholds_on_organization_id (organization_id) +# index_usage_thresholds_on_plan_id (plan_id) +# index_usage_thresholds_on_subscription_id (subscription_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (plan_id => plans.id) +# fk_rails_... (subscription_id => subscriptions.id) +# diff --git a/app/models/user.rb b/app/models/user.rb new file mode 100644 index 0000000..896956b --- /dev/null +++ b/app/models/user.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class User < ApplicationRecord + include PaperTrailTraceable + + has_secure_password + + has_many :password_resets + has_many :user_devices + + has_many :memberships + has_many :organizations, through: :memberships, class_name: "Organization" + + has_many :active_memberships, -> { where(status: "active") }, class_name: "Membership" + has_many :active_organizations, through: :active_memberships, source: :organization + + has_many :quote_owners, dependent: :destroy + has_many :quotes, through: :quote_owners + + validates :email, presence: true + validates :password, presence: true + + normalizes :email, with: ->(email) { EmailSanitizer.call(email) } + + def can?(permission, organization:) + memberships.find { |m| m.organization_id == organization.id }&.can?(permission) + end +end + +# == Schema Information +# +# Table name: users +# Database name: primary +# +# id :uuid not null, primary key +# email :string +# password_digest :string +# created_at :datetime not null +# updated_at :datetime not null +# diff --git a/app/models/user_device.rb b/app/models/user_device.rb new file mode 100644 index 0000000..3af93d8 --- /dev/null +++ b/app/models/user_device.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class UserDevice < ApplicationRecord + belongs_to :user + + validates :fingerprint, uniqueness: {scope: :user_id} +end + +# == Schema Information +# +# Table name: user_devices +# Database name: primary +# +# id :uuid not null, primary key +# browser :string +# device_type :string +# fingerprint :string not null +# last_ip_address :string +# last_logged_at :datetime not null +# os :string +# created_at :datetime not null +# updated_at :datetime not null +# user_id :uuid not null +# +# Indexes +# +# index_user_devices_on_user_id_and_fingerprint (user_id,fingerprint) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (user_id => users.id) +# diff --git a/app/models/wallet.rb b/app/models/wallet.rb new file mode 100644 index 0000000..bf3e223 --- /dev/null +++ b/app/models/wallet.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +class Wallet < ApplicationRecord + include PaperTrailTraceable + include Currencies + + belongs_to :customer, -> { with_discarded } + belongs_to :organization + belongs_to :payment_method, optional: true + belongs_to :billing_entity, optional: true + + has_many :wallet_transactions + has_many :recurring_transaction_rules + + has_many :wallet_targets + has_many :billable_metrics, through: :wallet_targets + + has_many :alerts, class_name: "UsageMonitoring::Alert" + has_many :triggered_alerts, class_name: "UsageMonitoring::TriggeredAlert" + + has_many :activity_logs, + -> { order(logged_at: :desc) }, + class_name: "Clickhouse::ActivityLog", + as: :resource + + has_one :metadata, + class_name: "Metadata::ItemMetadata", + as: :owner, + dependent: :destroy + + has_many :applied_invoice_custom_sections, + class_name: "Wallet::AppliedInvoiceCustomSection", + dependent: :destroy + has_many :selected_invoice_custom_sections, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + + monetize :balance_cents + monetize :consumed_amount_cents + monetize :ongoing_balance_cents, :ongoing_usage_balance_cents, with_model_currency: :balance_currency + + LOWEST_PRIORITY = 50 + + REFRESH_RELEVANT_ATTRIBUTES = %w[code priority allowed_fee_types].freeze + + validates :rate_amount, numericality: {greater_than: 0} + validates :currency, inclusion: {in: currency_list} + validates :invoice_requires_successful_payment, exclusion: [nil] + validates :paid_top_up_min_amount_cents, numericality: {greater_than: 0}, allow_nil: true + validates :paid_top_up_max_amount_cents, numericality: {greater_than: 0}, allow_nil: true + validates :priority, inclusion: {in: 1..LOWEST_PRIORITY} + validates :balance_cents, numericality: {greater_than_or_equal_to: 0}, if: :traceable? + validate :paid_top_up_max_greater_than_or_equal_min + validate :unique_code_per_customer, if: :code_changed? + + STATUSES = [ + :active, + :terminated + ].freeze + + enum :status, STATUSES + + scope :expired, -> { where("wallets.expiration_at::timestamp(0) <= ?", Time.current) } + scope :with_positive_balance, -> { where("balance_cents > 0") } + scope :ready_to_be_refreshed, -> { where(ready_to_be_refreshed: true) } + + def self.in_application_order + order(:priority, :created_at) + end + + def billing_entity + super || customer&.billing_entity + end + + def mark_as_terminated!(timestamp = Time.zone.now) + self.terminated_at ||= timestamp + terminated! + end + + def paid_top_up_min_credits + return if paid_top_up_min_amount_cents.nil? + + WalletCredit.from_amount_cents(wallet: self, amount_cents: paid_top_up_min_amount_cents).credit_amount + end + + def paid_top_up_max_credits + return if paid_top_up_max_amount_cents.nil? + + WalletCredit.from_amount_cents(wallet: self, amount_cents: paid_top_up_max_amount_cents).credit_amount + end + + def currency=(currency) + self.balance_currency = currency + self.consumed_amount_currency = currency + end + + def currency + balance_currency + end + + def limited_fee_types? + allowed_fee_types.present? + end + + def limited_to_billable_metrics? + billable_metrics.any? + end + + private + + def paid_top_up_max_greater_than_or_equal_min + return if paid_top_up_min_amount_cents.nil? + return if paid_top_up_max_amount_cents.nil? + + if paid_top_up_max_amount_cents < paid_top_up_min_amount_cents + errors.add(:paid_top_up_max_amount_cents, :must_be_greater_than_or_equal_min) + end + end + + def unique_code_per_customer + return unless active? + + if code && Wallet.where(customer_id: customer_id, code: code, status: "active").where.not(id: id).exists? + errors.add(:code, :taken) + end + end +end + +# == Schema Information +# +# Table name: wallets +# Database name: primary +# +# id :uuid not null, primary key +# allowed_fee_types :string default([]), not null, is an Array +# balance_cents :bigint default(0), not null +# balance_currency :string not null +# code :string +# consumed_amount_cents :bigint default(0), not null +# consumed_amount_currency :string not null +# consumed_credits :decimal(30, 5) default(0.0), not null +# credits_balance :decimal(30, 5) default(0.0), not null +# credits_ongoing_balance :decimal(30, 5) default(0.0), not null +# credits_ongoing_usage_balance :decimal(30, 5) default(0.0), not null +# depleted_ongoing_balance :boolean default(FALSE), not null +# expiration_at :datetime +# invoice_requires_successful_payment :boolean default(FALSE), not null +# last_balance_sync_at :datetime +# last_consumed_credit_at :datetime +# last_ongoing_balance_sync_at :datetime +# lock_version :integer default(0), not null +# name :string +# ongoing_balance_cents :bigint default(0), not null +# ongoing_usage_balance_cents :bigint default(0), not null +# paid_top_up_max_amount_cents :bigint +# paid_top_up_min_amount_cents :bigint +# payment_method_type :enum default("provider"), not null +# priority :integer default(50), not null +# rate_amount :decimal(30, 5) default(0.0), not null +# ready_to_be_refreshed :boolean default(FALSE), not null +# skip_invoice_custom_sections :boolean default(FALSE), not null +# status :integer not null +# terminated_at :datetime +# traceable :boolean default(FALSE), not null +# created_at :datetime not null +# updated_at :datetime not null +# billing_entity_id :uuid +# customer_id :uuid not null +# organization_id :uuid not null +# payment_method_id :uuid +# +# Indexes +# +# index_uniq_wallet_code_per_customer (customer_id,code) UNIQUE WHERE (status = 0) +# index_wallets_on_billing_entity_id (billing_entity_id) +# index_wallets_on_customer_id (customer_id) +# index_wallets_on_organization_id (organization_id) +# index_wallets_on_organization_id_and_customer_id (organization_id,customer_id) +# index_wallets_on_payment_method_id (payment_method_id) +# index_wallets_on_ready_to_be_refreshed (ready_to_be_refreshed) WHERE ready_to_be_refreshed +# +# Foreign Keys +# +# fk_rails_... (billing_entity_id => billing_entities.id) +# fk_rails_... (customer_id => customers.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_method_id => payment_methods.id) +# diff --git a/app/models/wallet/applied_invoice_custom_section.rb b/app/models/wallet/applied_invoice_custom_section.rb new file mode 100644 index 0000000..af02499 --- /dev/null +++ b/app/models/wallet/applied_invoice_custom_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class Wallet::AppliedInvoiceCustomSection < ApplicationRecord + self.table_name = "wallets_invoice_custom_sections" + + belongs_to :organization + belongs_to :wallet + belongs_to :invoice_custom_section +end + +# == Schema Information +# +# Table name: wallets_invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# invoice_custom_section_id :uuid not null +# organization_id :uuid not null +# wallet_id :uuid not null +# +# Indexes +# +# idx_on_invoice_custom_section_id_aca4661c33 (invoice_custom_section_id) +# index_wallets_invoice_custom_sections_on_organization_id (organization_id) +# index_wallets_invoice_custom_sections_on_wallet_id (wallet_id) +# index_wallets_invoice_custom_sections_unique (wallet_id,invoice_custom_section_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (invoice_custom_section_id => invoice_custom_sections.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/wallet_credit.rb b/app/models/wallet_credit.rb new file mode 100644 index 0000000..a764bcd --- /dev/null +++ b/app/models/wallet_credit.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# This class represents a wallet credit and can be represented in credits or in an amount_cents +# Use this class when constructing wallet credits to make sure conversion between monetary amounts and credit amounts remains consistent +class WalletCredit + # Convenience constructor for when you need to construct a credit based on monetary amounts + def self.from_amount_cents(wallet:, amount_cents:) + currency = wallet.currency_for_balance + amount = amount_cents.round.fdiv(currency.subunit_to_unit) + new(wallet:, credit_amount: amount.fdiv(wallet.rate_amount), amount_cents:) + end + + # we'll assume you construct this normally for a wallet and a credit amount + def initialize(wallet:, credit_amount:, invoiceable: true, amount_cents: nil) + @wallet = wallet + currency = wallet.currency_for_balance + @amount = (credit_amount * wallet.rate_amount).round(currency.exponent) + @amount_cents = (amount_cents || @amount * currency.subunit_to_unit).to_i + + @credit_amount = if invoiceable + # Here we convert to amount andconvert back to credits + # as we're talking about invoiceable credits. (only multiples of 1 cent should be accepted) + amount.fdiv(wallet.rate_amount) + else + credit_amount + end + end + + attr_reader :wallet, :credit_amount, :amount, :amount_cents +end diff --git a/app/models/wallet_target.rb b/app/models/wallet_target.rb new file mode 100644 index 0000000..95a7d3b --- /dev/null +++ b/app/models/wallet_target.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class WalletTarget < ApplicationRecord + include PaperTrailTraceable + + belongs_to :wallet + belongs_to :billable_metric + belongs_to :organization +end + +# == Schema Information +# +# Table name: wallet_targets +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# billable_metric_id :uuid not null +# organization_id :uuid not null +# wallet_id :uuid not null +# +# Indexes +# +# index_wallet_targets_on_billable_metric_id (billable_metric_id) +# index_wallet_targets_on_organization_id (organization_id) +# index_wallet_targets_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (billable_metric_id => billable_metrics.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/wallet_transaction.rb b/app/models/wallet_transaction.rb new file mode 100644 index 0000000..a7abd60 --- /dev/null +++ b/app/models/wallet_transaction.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +class WalletTransaction < ApplicationRecord + include PaperTrailTraceable + + belongs_to :wallet + belongs_to :organization + + # these two relationships are populated only for outbound transactions + belongs_to :invoice, optional: true + belongs_to :credit_note, optional: true + belongs_to :payment_method, optional: true + + # populated for inbound transactions created when an invoice is voided + belongs_to :voided_invoice, class_name: "Invoice", optional: true + + has_many :consumptions, + class_name: "WalletTransactionConsumption", + foreign_key: :inbound_wallet_transaction_id, + inverse_of: :inbound_wallet_transaction, + dependent: :destroy + + has_many :fundings, + class_name: "WalletTransactionConsumption", + foreign_key: :outbound_wallet_transaction_id, + inverse_of: :outbound_wallet_transaction, + dependent: :destroy + + has_many :applied_invoice_custom_sections, + class_name: "WalletTransaction::AppliedInvoiceCustomSection", + dependent: :destroy + has_many :selected_invoice_custom_sections, + through: :applied_invoice_custom_sections, + source: :invoice_custom_section + + STATUSES = [ + :pending, + :settled, + :failed + ].freeze + + TRANSACTION_STATUSES = [ + :purchased, + :granted, + :voided, + :invoiced + ].freeze + + TRANSACTION_TYPES = [ + :inbound, + :outbound + ].freeze + + SOURCES = [ + :manual, + :interval, + :threshold + ].freeze + + enum :status, STATUSES + enum :transaction_status, TRANSACTION_STATUSES + enum :transaction_type, TRANSACTION_TYPES + enum :source, SOURCES + + validates :status, :transaction_type, :source, :transaction_status, presence: true + validates :priority, presence: true, inclusion: {in: 1..50} + validates :name, length: {minimum: 1, maximum: 255}, allow_nil: true + validates :invoice_requires_successful_payment, exclusion: [nil] + validates :remaining_amount_cents, + numericality: {greater_than_or_equal_to: 0}, + allow_nil: true, + if: :inbound? + validates :remaining_amount_cents, absence: true, if: :outbound? + + delegate :customer, to: :wallet + + scope :pending, -> { where(status: :pending) } + scope :available_inbound, -> { inbound.settled.where("remaining_amount_cents > 0") } + scope :in_consumption_order, -> { + granted_status = transaction_statuses[:granted] + order( + :priority, + Arel.sql("CASE WHEN transaction_status = #{granted_status} THEN 0 ELSE 1 END") => :asc, + :created_at => :asc + ) + } + + def self.order_by_priority + order(:priority) + .in_order_of(:transaction_status, [:granted, :purchased, :voided, :invoiced]) + .order(:created_at) + end + + def amount_cents + amount * wallet.currency_for_balance.subunit_to_unit + end + + def unit_amount_cents + wallet.rate_amount * wallet.currency_for_balance.subunit_to_unit + end + + def remaining_credit_amount + return nil if remaining_amount_cents.nil? + + currency = wallet.currency_for_balance + remaining_amount_cents.fdiv(currency.subunit_to_unit).fdiv(wallet.rate_amount).to_s + end + + def mark_as_failed!(timestamp = Time.zone.now) + return if failed? + + update!(status: :failed, failed_at: timestamp) + end +end + +# == Schema Information +# +# Table name: wallet_transactions +# Database name: primary +# +# id :uuid not null, primary key +# amount :decimal(30, 5) default(0.0), not null +# credit_amount :decimal(30, 5) default(0.0), not null +# failed_at :datetime +# invoice_requires_successful_payment :boolean default(FALSE), not null +# lock_version :integer default(0), not null +# metadata :jsonb +# name :string(255) +# payment_method_type :enum default("provider"), not null +# priority :integer default(50), not null +# remaining_amount_cents :bigint +# settled_at :datetime +# skip_invoice_custom_sections :boolean default(FALSE), not null +# source :integer default("manual"), not null +# status :integer not null +# transaction_status :integer default("purchased"), not null +# transaction_type :integer not null +# created_at :datetime not null +# updated_at :datetime not null +# credit_note_id :uuid +# invoice_id :uuid +# organization_id :uuid not null +# payment_method_id :uuid +# voided_invoice_id :uuid +# wallet_id :uuid not null +# +# Indexes +# +# idx_wallet_transactions_available_inbound (wallet_id, priority, (\nCASE\n WHEN (transaction_status = 1) THEN 0\n ELSE 1\nEND), created_at) WHERE ((remaining_amount_cents > 0) AND (transaction_type = 0) AND (status = 1)) +# index_wallet_transactions_on_credit_note_id (credit_note_id) +# index_wallet_transactions_on_invoice_id (invoice_id) +# index_wallet_transactions_on_organization_id (organization_id) +# index_wallet_transactions_on_payment_method_id (payment_method_id) +# index_wallet_transactions_on_voided_invoice_id (voided_invoice_id) +# index_wallet_transactions_on_wallet_id (wallet_id) +# +# Foreign Keys +# +# fk_rails_... (credit_note_id => credit_notes.id) +# fk_rails_... (invoice_id => invoices.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (payment_method_id => payment_methods.id) +# fk_rails_... (voided_invoice_id => invoices.id) +# fk_rails_... (wallet_id => wallets.id) +# diff --git a/app/models/wallet_transaction/applied_invoice_custom_section.rb b/app/models/wallet_transaction/applied_invoice_custom_section.rb new file mode 100644 index 0000000..8373fd1 --- /dev/null +++ b/app/models/wallet_transaction/applied_invoice_custom_section.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class WalletTransaction::AppliedInvoiceCustomSection < ApplicationRecord + self.table_name = "wallet_transactions_invoice_custom_sections" + + belongs_to :organization + belongs_to :wallet_transaction + belongs_to :invoice_custom_section +end + +# == Schema Information +# +# Table name: wallet_transactions_invoice_custom_sections +# Database name: primary +# +# id :uuid not null, primary key +# created_at :datetime not null +# updated_at :datetime not null +# invoice_custom_section_id :uuid not null +# organization_id :uuid not null +# wallet_transaction_id :uuid not null +# +# Indexes +# +# idx_on_invoice_custom_section_id_b381df5bb5 (invoice_custom_section_id) +# idx_on_organization_id_ccdf05cbfe (organization_id) +# idx_on_wallet_transaction_id_ac2826109e (wallet_transaction_id) +# index_wt_invoice_custom_sections_unique (wallet_transaction_id,invoice_custom_section_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (invoice_custom_section_id => invoice_custom_sections.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (wallet_transaction_id => wallet_transactions.id) +# diff --git a/app/models/wallet_transaction_consumption.rb b/app/models/wallet_transaction_consumption.rb new file mode 100644 index 0000000..043e6f2 --- /dev/null +++ b/app/models/wallet_transaction_consumption.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class WalletTransactionConsumption < ApplicationRecord + belongs_to :organization + belongs_to :inbound_wallet_transaction, class_name: "WalletTransaction", inverse_of: :consumptions + belongs_to :outbound_wallet_transaction, class_name: "WalletTransaction", inverse_of: :fundings + + validates :consumed_amount_cents, numericality: {greater_than: 0} + validate :inbound_transaction_must_be_inbound + validate :outbound_transaction_must_be_outbound + + def credit_amount + wallet = outbound_wallet_transaction.wallet + currency = wallet.currency_for_balance + consumed_amount_cents.fdiv(currency.subunit_to_unit).fdiv(wallet.rate_amount).to_s + end + + private + + def inbound_transaction_must_be_inbound + return if inbound_wallet_transaction.nil? + return if inbound_wallet_transaction.inbound? + + errors.add(:inbound_wallet_transaction, :invalid) + end + + def outbound_transaction_must_be_outbound + return if outbound_wallet_transaction.nil? + return if outbound_wallet_transaction.outbound? + + errors.add(:outbound_wallet_transaction, :invalid) + end +end + +# == Schema Information +# +# Table name: wallet_transaction_consumptions +# Database name: primary +# +# id :uuid not null, primary key +# consumed_amount_cents :bigint not null +# created_at :datetime not null +# updated_at :datetime not null +# inbound_wallet_transaction_id :uuid not null +# organization_id :uuid not null +# outbound_wallet_transaction_id :uuid not null +# +# Indexes +# +# idx_on_inbound_wallet_transaction_id_e54d00758d (inbound_wallet_transaction_id) +# idx_on_outbound_wallet_transaction_id_cf6ff733c6 (outbound_wallet_transaction_id) +# idx_wallet_tx_consumptions_inbound_outbound (inbound_wallet_transaction_id,outbound_wallet_transaction_id) UNIQUE +# index_wallet_transaction_consumptions_on_organization_id (organization_id) +# +# Foreign Keys +# +# fk_rails_... (inbound_wallet_transaction_id => wallet_transactions.id) +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (outbound_wallet_transaction_id => wallet_transactions.id) +# diff --git a/app/models/webhook.rb b/app/models/webhook.rb new file mode 100644 index 0000000..dd78189 --- /dev/null +++ b/app/models/webhook.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +class Webhook < ApplicationRecord + include RansackUuidSearch + + STATUS = %i[pending succeeded failed retrying].freeze + + belongs_to :webhook_endpoint + belongs_to :object, polymorphic: true, optional: true + belongs_to :organization + + enum :status, STATUS + + def self.ransackable_attributes(_auth_object = nil) + %w[id object_id] + end + + # Up until 1.4.0, we stored the payload as a string. This method + # ensures that we can still read the old payloads. + # Webhooks created after 1.4.0 will have the payload stored as a hash. + # Webhooks are deleted after 90 days, so we can remove this method 90 days after every client has updated to 1.4.0. + def payload + attr = super + if attr.is_a?(String) + JSON.parse(attr) + else + attr + end + end + + def generate_headers + signature = case webhook_endpoint.signature_algo&.to_sym + when :jwt + jwt_signature + when :hmac + hmac_signature + end + + { + "X-Lago-Signature" => signature, + "X-Lago-Signature-Algorithm" => webhook_endpoint.signature_algo.to_s, + "X-Lago-Unique-Key" => id + } + end + + def jwt_signature + JWT.encode( + { + data: payload.to_json, + iss: issuer + }, + RsaPrivateKey, + "RS256" + ) + end + + def hmac_signature + hmac = OpenSSL::HMAC.digest("sha-256", organization.hmac_key, payload.to_json) + Base64.strict_encode64(hmac) + end + + def issuer + ENV["LAGO_API_URL"] + end +end + +# == Schema Information +# +# Table name: webhooks +# Database name: primary +# +# id :uuid not null, primary key +# endpoint :string +# http_status :integer +# last_retried_at :datetime +# object_type :string +# payload :json +# response :json +# retries :integer default(0), not null +# status :integer default("pending"), not null +# webhook_type :string +# created_at :datetime not null +# updated_at :datetime not null +# object_id :uuid +# organization_id :uuid not null +# webhook_endpoint_id :uuid +# +# Indexes +# +# index_webhooks_for_query (organization_id,webhook_endpoint_id,webhook_type,updated_at) +# index_webhooks_on_endpoint_and_timestamps (webhook_endpoint_id,updated_at,created_at) +# index_webhooks_on_endpoint_status_and_timestamps (webhook_endpoint_id,status,updated_at) +# index_webhooks_on_object_type_and_object_id_and_webhook_type (object_type,object_id,webhook_type) +# index_webhooks_on_organization_id (organization_id) +# index_webhooks_on_updated_at_for_cleanup (updated_at) +# index_webhooks_on_webhook_endpoint_id (webhook_endpoint_id) +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# fk_rails_... (webhook_endpoint_id => webhook_endpoints.id) +# diff --git a/app/models/webhook_endpoint.rb b/app/models/webhook_endpoint.rb new file mode 100644 index 0000000..2c3d897 --- /dev/null +++ b/app/models/webhook_endpoint.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class WebhookEndpoint < ApplicationRecord + LIMIT = 10 + + SIGNATURE_ALGOS = [ + :jwt, + :hmac + ].freeze + + WEBHOOK_EVENT_TYPE_CONFIG = YAML.safe_load_file( + Rails.root.join("config/webhook_event_types.yml"), + symbolize_names: true + ).freeze + + WEBHOOK_EVENT_TYPES = WEBHOOK_EVENT_TYPE_CONFIG.map do |_, config| + config[:name].to_s + end.freeze + + belongs_to :organization + has_many :webhooks, dependent: :delete_all + + validates :webhook_url, presence: true, url: true + validates :webhook_url, uniqueness: {scope: :organization_id} + validate :max_webhook_endpoints, on: :create + validate :validate_event_types, if: :event_types_changed? + + before_validation :normalize_event_types, if: :event_types_changed? + + enum :signature_algo, SIGNATURE_ALGOS + + def self.ransackable_attributes(_auth_object = nil) + %w[webhook_url] + end + + private + + def max_webhook_endpoints + errors.add(:base, :exceeded_limit) if organization && + organization.webhook_endpoints.reload.count >= LIMIT + end + + def validate_event_types + return if event_types.nil? + + # since AR casts non-array values to [] we need to check the raw value + if event_types.is_a?(Array) && event_types.blank? && !event_types_before_type_cast.is_a?(Array) + errors.add(:event_types, :must_be_array) + end + + invalid_types = event_types - WEBHOOK_EVENT_TYPES + if invalid_types.present? + errors.add(:event_types, :invalid_types, invalid_types:) + end + end + + def normalize_event_types + return if event_types.blank? + + normalized = event_types + .map { |event_type| event_type&.to_s&.strip&.downcase } + .reject(&:blank?) + .uniq + + # special case: convert ["*"] to nil to disable filtering + if normalized.length == 1 && normalized.first == "*" + normalized = nil + end + + self.event_types = normalized + end +end + +# == Schema Information +# +# Table name: webhook_endpoints +# Database name: primary +# +# id :uuid not null, primary key +# event_types :string is an Array +# name :string +# signature_algo :integer default("jwt"), not null +# webhook_url :string not null +# created_at :datetime not null +# updated_at :datetime not null +# organization_id :uuid not null +# +# Indexes +# +# index_webhook_endpoints_on_organization_id (organization_id) +# index_webhook_endpoints_on_webhook_url_and_organization_id (webhook_url,organization_id) UNIQUE +# +# Foreign Keys +# +# fk_rails_... (organization_id => organizations.id) +# diff --git a/app/queries/activity_logs_query.rb b/app/queries/activity_logs_query.rb new file mode 100644 index 0000000..ce00e66 --- /dev/null +++ b/app/queries/activity_logs_query.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +class ActivityLogsQuery < BaseQuery + Result = BaseResult[:activity_logs] + Filters = BaseFilters[ + :from_date, + :to_date, + :api_key_ids, + :activity_ids, + :activity_types, + :activity_sources, + :user_emails, + :external_customer_id, + :external_subscription_id, + :resource_ids, + :resource_types + ] + + def call + return result.forbidden_failure! unless Utils::ActivityLog.available? + + activity_logs = Clickhouse::ActivityLog.where(organization_id: organization.id) + activity_logs = paginate(activity_logs) + activity_logs = activity_logs.order(logged_at: :desc) + + activity_logs = within_retention_period(activity_logs) if organization.audit_logs_period.present? + activity_logs = with_logged_at_range(activity_logs) if filters.from_date || filters.to_date + activity_logs = with_api_key_ids(activity_logs) if filters.api_key_ids.present? + activity_logs = with_activity_ids(activity_logs) if filters.activity_ids.present? + activity_logs = with_activity_types(activity_logs) if filters.activity_types.present? + activity_logs = with_activity_sources(activity_logs) if filters.activity_sources.present? + activity_logs = with_user_emails(activity_logs) if filters.user_emails.present? + activity_logs = with_external_customer_id(activity_logs) if filters.external_customer_id.present? + activity_logs = with_external_subscription_id(activity_logs) if filters.external_subscription_id.present? + activity_logs = with_resource_ids(activity_logs) if filters.resource_ids.present? + activity_logs = with_resource_types(activity_logs) if filters.resource_types.present? + + result.activity_logs = activity_logs + result + end + + private + + def within_retention_period(scope) + period = organization.audit_logs_period.days + scope.where(logged_at: period.ago..) + end + + def with_logged_at_range(scope) + scope = scope.where(logged_at: from_date..) if filters.from_date + scope = scope.where(logged_at: ..to_date) if filters.to_date + scope + end + + def with_api_key_ids(scope) + scope.where(api_key_id: filters.api_key_ids) + end + + def with_activity_ids(scope) + scope.where(activity_id: filters.activity_ids) + end + + def with_activity_types(scope) + scope.where(activity_type: filters.activity_types) + end + + def with_activity_sources(scope) + scope.where(activity_source: filters.activity_sources) + end + + def with_user_emails(scope) + user_ids = organization.users.where(email: filters.user_emails).pluck(:id) + scope.where(user_id: user_ids) + end + + def with_external_customer_id(scope) + scope.where(external_customer_id: filters.external_customer_id) + end + + def with_external_subscription_id(scope) + scope.where(external_subscription_id: filters.external_subscription_id) + end + + def with_resource_ids(scope) + scope.where(resource_id: filters.resource_ids) + end + + def with_resource_types(scope) + scope.where(resource_type: filters.resource_types) + end + + def from_date + @from_date ||= parse_datetime_filter(:from_date) + end + + def to_date + @to_date ||= parse_datetime_filter(:to_date) + end +end diff --git a/app/queries/add_ons_query.rb b/app/queries/add_ons_query.rb new file mode 100644 index 0000000..1f8548e --- /dev/null +++ b/app/queries/add_ons_query.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class AddOnsQuery < BaseQuery + Result = BaseResult[:add_ons] + + def call + add_ons = base_scope.result + add_ons = paginate(add_ons) + add_ons = apply_consistent_ordering(add_ons) + + result.add_ons = add_ons + result + end + + private + + def base_scope + AddOn.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term + } + end +end diff --git a/app/queries/api_logs_query.rb b/app/queries/api_logs_query.rb new file mode 100644 index 0000000..fe9963a --- /dev/null +++ b/app/queries/api_logs_query.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +class ApiLogsQuery < BaseQuery + Result = BaseResult[:api_logs] + Filters = BaseFilters[ + :from_date, + :to_date, + :http_methods, + :http_statuses, + :api_version, + :api_key_ids, + :request_ids, + :request_paths, + :clients + ] + + def call + return result.forbidden_failure! unless Utils::ApiLog.available? + + api_logs = Clickhouse::ApiLog.where(organization_id: organization.id) + api_logs = api_logs.order(logged_at: :desc) + + api_logs = within_retention_period(api_logs) if organization.audit_logs_period.present? + api_logs = with_logged_at_range(api_logs) if filters.from_date || filters.to_date + api_logs = with_api_key_ids(api_logs) if filters.api_key_ids.present? + api_logs = with_request_ids(api_logs) if filters.request_ids.present? + api_logs = with_http_statuses(api_logs) if filters.http_statuses.present? + api_logs = with_http_methods(api_logs) if filters.http_methods.present? + api_logs = with_api_version(api_logs) if filters.api_version.present? + api_logs = with_request_paths(api_logs) if filters.request_paths.present? + api_logs = with_clients(api_logs) if filters.clients.present? + + api_logs = paginate(api_logs) + result.api_logs = api_logs + result + end + + private + + def within_retention_period(scope) + period = organization.audit_logs_period.days + scope.where(logged_at: period.ago..) + end + + def with_logged_at_range(scope) + scope = scope.where(logged_at: filters.from_date..) if filters.from_date + scope = scope.where(logged_at: ..filters.to_date) if filters.to_date + scope + end + + def with_api_key_ids(scope) + scope.where(api_key_id: filters.api_key_ids) + end + + def with_request_ids(scope) + scope.where(request_id: filters.request_ids) + end + + def with_http_methods(scope) + scope.where(http_method: filters.http_methods) + end + + def with_http_statuses(scope) + filters.http_statuses = Array.wrap(filters.http_statuses) + + if filters.http_statuses.any? { |s| %w[succeeded failed].include?(s) } + scope = scope.where("http_status <= ?", 399) if filters.http_statuses.include?("succeeded") + scope = scope.where("http_status > ?", 399) if filters.http_statuses.include?("failed") + else + # TODO: Improve that to return nothing if filters.http_statuses is not a valid value + scope = scope.where(http_status: filters.http_statuses) + end + scope + end + + def with_api_version(scope) + scope.where(api_version: filters.api_version) + end + + def with_request_paths(scope) + scope.where( + filters.request_paths.map do |path| + if path.include?("*") + like_pattern = path.tr("*", "%") + ActiveRecord::Base.send(:sanitize_sql_array, ["request_path LIKE ?", like_pattern]) + else + ActiveRecord::Base.send(:sanitize_sql_array, ["request_path = ?", path]) + end + end.join(" OR ") + ) + end + + def with_clients(scope) + scope.where(client: filters.clients) + end +end diff --git a/app/queries/applied_coupons_query.rb b/app/queries/applied_coupons_query.rb new file mode 100644 index 0000000..a1e3a94 --- /dev/null +++ b/app/queries/applied_coupons_query.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class AppliedCouponsQuery < BaseQuery + Result = BaseResult[:applied_coupons] + Filters = BaseFilters[:external_customer_id, :status, :coupon_code] + + def call + applied_coupons = paginate(base_scope) + applied_coupons = apply_consistent_ordering(applied_coupons) + + applied_coupons = with_external_customer(applied_coupons) if filters.external_customer_id + applied_coupons = with_coupon_code(applied_coupons) if filters.coupon_code.present? + applied_coupons = with_status(applied_coupons) if valid_status? + + result.applied_coupons = applied_coupons + result + end + + def base_scope + organization.applied_coupons + .joins(:customer).where(customers: {deleted_at: nil}) + end + + def with_coupon_code(scope) + scope.joins(:coupon).where(coupons: {code: filters.coupon_code}) + end + + def with_external_customer(scope) + scope.where(customers: {external_id: filters.external_customer_id}) + end + + def with_status(scope) + scope.where(status: filters.status) + end + + def valid_status? + AppliedCoupon.statuses.key?(filters.status) + end +end diff --git a/app/queries/base_filters.rb b/app/queries/base_filters.rb new file mode 100644 index 0000000..c40a7d7 --- /dev/null +++ b/app/queries/base_filters.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class BaseFilters + def self.[](*attributes) + Class.new(BaseFilters) { attr_accessor(*attributes) } + end + + def initialize(**args) + @filters = args + .select { |key, _| self.class.method_defined?(key.to_sym) } + .to_h + .with_indifferent_access + + @filters.each { |key, value| send("#{key}=", value) } + end + + attr_reader :filters + + delegate :[], :key?, to: :filters + + def to_h + filters + end +end diff --git a/app/queries/base_query.rb b/app/queries/base_query.rb new file mode 100644 index 0000000..fe41fdc --- /dev/null +++ b/app/queries/base_query.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class BaseQuery < BaseService + # nil values force Kaminari to apply its default values for page and limit. + DEFAULT_PAGINATION_PARAMS = {page: nil, limit: nil} + DEFAULT_ORDER = {created_at: :desc} + + Pagination = Struct.new(:page, :limit, keyword_init: true) + Filters = BaseFilters + + def initialize(organization:, pagination: DEFAULT_PAGINATION_PARAMS, filters: {}, search_term: nil, order: nil) + @organization = organization + @pagination_params = pagination + @filters = self.class::Filters.new(**(filters || {})) + @search_term = search_term.to_s.strip + @order = order + + super + end + + private + + attr_reader :organization, :pagination_params, :filters, :search_term, :order + + def validate_filters + validation_result = filters_contract.call(filters.to_h) + + unless validation_result.success? + errors = validation_result.errors.to_h + result.validation_failure!(errors:) + end + + result + end + + def pagination + return if pagination_params.blank? + + @pagination ||= Pagination.new( + page: pagination_params[:page], + limit: pagination_params[:limit] + ) + end + + def paginate(scope) + return scope unless pagination + + scope.page(pagination.page).per(pagination.limit) + end + + def parse_datetime_filter(field_name) + value = filters[field_name] + return value if [Time, ActiveSupport::TimeWithZone, Date, DateTime].include?(value.class) + + DateTime.iso8601(value) + # Date::Error inherits from ArgumentError so it is caught here too. + # A bare ArgumentError is raised e.g. for strings longer than 128 chars + # ("string length exceeds the limit 128"). + rescue ArgumentError + result.single_validation_failure!(field: field_name.to_sym, error_code: "invalid_date") + .raise_if_error! + end + + # Apply consistent ordering across query objects + def apply_consistent_ordering(scope, default_order: DEFAULT_ORDER) + scope.order(default_order).order(id: :asc) + end +end diff --git a/app/queries/billable_metrics_query.rb b/app/queries/billable_metrics_query.rb new file mode 100644 index 0000000..b324649 --- /dev/null +++ b/app/queries/billable_metrics_query.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class BillableMetricsQuery < BaseQuery + Result = BaseResult[:billable_metrics] + Filters = BaseFilters[:organization_id, :recurring, :aggregation_types, :plan_id] + + def call + return result unless validate_filters.success? + + metrics = base_scope.result + metrics = paginate(metrics) + metrics = apply_consistent_ordering(metrics) + + metrics = with_recurring(metrics) unless filters.recurring.nil? + metrics = with_aggregation_type(metrics) if filters.aggregation_types.present? + + metrics = with_plan(metrics) if filters.plan_id.present? + + result.billable_metrics = metrics + result + end + + private + + def filters_contract + @filters_contract ||= Queries::BillableMetricsQueryFiltersContract.new + end + + def base_scope + BillableMetric.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term + } + end + + def with_recurring(scope) + scope.where(recurring: filters.recurring) + end + + def with_aggregation_type(scope) + scope.where(aggregation_type: filters.aggregation_types) + end + + def with_plan(scope) + scope.joins(:charges).where(charges: {plan_id: filters.plan_id}).distinct + end +end diff --git a/app/queries/coupons_query.rb b/app/queries/coupons_query.rb new file mode 100644 index 0000000..918c08c --- /dev/null +++ b/app/queries/coupons_query.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class CouponsQuery < BaseQuery + Result = BaseResult[:coupons] + Filters = BaseFilters[:organization_id, :status] + + def call + coupons = base_scope.result + coupons = paginate(coupons) + coupons = coupons.order_by_status_and_expiration + coupons = apply_consistent_ordering(coupons) + + coupons = with_status(coupons) if filters.status.present? + + result.coupons = coupons + result + end + + private + + def base_scope + Coupon.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term + } + end + + def with_status(scope) + scope.where(status: filters.status) + end +end diff --git a/app/queries/credit_notes_query.rb b/app/queries/credit_notes_query.rb new file mode 100644 index 0000000..3193893 --- /dev/null +++ b/app/queries/credit_notes_query.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +class CreditNotesQuery < BaseQuery + Result = BaseResult[:credit_notes] + Filters = BaseFilters[ + :billing_entity_ids, + :currency, + :customer_external_id, + :customer_id, + :invoice_number, + :issuing_date_from, + :issuing_date_to, + :amount_from, + :amount_to, + :self_billed, + :credit_status, + :reason, + :refund_status, + :types + ] + + def initialize(includes: [], **args) + @includes = includes + super(**args) + end + + def call + credit_notes = base_scope.result + + credit_notes = with_billing_entity_ids(credit_notes) if filters.billing_entity_ids.present? + credit_notes = with_currency(credit_notes) if filters.currency.present? + credit_notes = with_customer_external_id(credit_notes) if filters.customer_external_id + credit_notes = with_customer_id(credit_notes) if filters.customer_id.present? + credit_notes = with_reason(credit_notes) if valid_reasons.present? + credit_notes = with_credit_status(credit_notes) if valid_credit_statuses.present? + credit_notes = with_refund_status(credit_notes) if valid_refund_statuses.present? + credit_notes = with_types(credit_notes) if valid_types.present? + credit_notes = with_invoice_number(credit_notes) if filters.invoice_number.present? + credit_notes = with_issuing_date_range(credit_notes) if filters.issuing_date_from || filters.issuing_date_to + credit_notes = with_amount_range(credit_notes) if filters.amount_from.present? || filters.amount_to.present? + credit_notes = with_self_billed_invoice(credit_notes) unless filters.self_billed.nil? + + credit_notes = paginate(credit_notes) + credit_notes = apply_consistent_ordering(credit_notes) + + result.credit_notes = credit_notes + result + rescue BaseService::FailedResult + result + end + + private + + def base_scope + scope = CreditNote + .where(organization:) + .finalized + + scope = scope.includes(*@includes) if @includes.present? + scope.ransack(search_params) + end + + def search_params + return if search_term.blank? + + terms = { + m: "or", + id_cont: search_term, + number_cont: search_term + } + + return terms if filters.customer_id.present? + + terms.merge( + customer_name_cont: search_term, + customer_firstname_cont: search_term, + customer_lastname_cont: search_term, + customer_external_id_cont: search_term, + customer_email_cont: search_term + ) + end + + def with_currency(scope) + scope.where(total_amount_currency: filters.currency) + end + + def with_customer_external_id(scope) + scope.joins(:customer).where(customers: {external_id: filters.customer_external_id}) + end + + def with_customer_id(scope) + scope.where(customer_id: filters.customer_id) + end + + def with_reason(scope) + scope.where(reason: valid_reasons) + end + + def with_credit_status(scope) + scope.where(credit_status: valid_credit_statuses) + end + + def with_refund_status(scope) + scope.where(refund_status: valid_refund_statuses) + end + + def with_types(scope) + predicates = [] + + if valid_types.include?("credit") + predicates << "credit_notes.credit_amount_cents > 0" + end + + if valid_types.include?("refund") + predicates << "credit_notes.refund_amount_cents > 0" + end + + if valid_types.include?("offset") + predicates << "credit_notes.offset_amount_cents > 0" + end + + return scope.none if predicates.empty? + + scope.where(predicates.join(" OR ")) + end + + def with_invoice_number(scope) + scope.joins(:invoice).where(invoices: {number: filters.invoice_number}) + end + + def with_issuing_date_range(scope) + scope = scope.where(issuing_date: issuing_date_from..) if filters.issuing_date_from + scope = scope.where(issuing_date: ..issuing_date_to) if filters.issuing_date_to + scope + end + + def with_amount_range(scope) + scope = scope.where("credit_notes.total_amount_cents >= ?", filters.amount_from) if filters.amount_from + scope = scope.where("credit_notes.total_amount_cents <= ?", filters.amount_to) if filters.amount_to + scope + end + + def with_self_billed_invoice(scope) + scope + .joins(:invoice) + .where(invoices: { + self_billed: ActiveModel::Type::Boolean.new.cast(filters.self_billed) + }) + end + + def with_billing_entity_ids(scope) + scope.joins(:invoice).where(invoices: {billing_entity_id: filters.billing_entity_ids}) + end + + def issuing_date_from + @issuing_date_from ||= parse_datetime_filter(:issuing_date_from) + end + + def issuing_date_to + @issuing_date_to ||= parse_datetime_filter(:issuing_date_to) + end + + def valid_credit_statuses + @valid_credit_statuses ||= Array(filters.credit_status) + .select { |credit_status| CreditNote.credit_statuses.key?(credit_status) } + end + + def valid_refund_statuses + @valid_refund_statuses ||= Array(filters.refund_status) + .select { |refund_status| CreditNote.refund_statuses.key?(refund_status) } + end + + def valid_reasons + @valid_reasons ||= Array(filters.reason) + .select { |reason| CreditNote.reasons.key?(reason) } + end + + def valid_types + @valid_types ||= Array(filters.types) + .select { |type| CreditNote::TYPES.include?(type) } + end +end diff --git a/app/queries/customers_query.rb b/app/queries/customers_query.rb new file mode 100644 index 0000000..aa11c47 --- /dev/null +++ b/app/queries/customers_query.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +class CustomersQuery < BaseQuery + Result = BaseResult[:customers] + Filters = BaseFilters[ + :organization_id, + :account_type, + :billing_entity_ids, + :with_deleted, + :active_subscriptions_count_from, + :active_subscriptions_count_to, + :countries, + :states, + :zipcodes, + :currencies, + :has_tax_identification_number, + :metadata, + :customer_type, + :has_customer_type + ] + + def call + return result unless validate_filters.success? + + customers = base_scope.result + + customers = with_customer_type(customers) if filters.customer_type.present? || filters.key?(:has_customer_type) + customers = with_account_type(customers) if filters.account_type.present? + customers = with_billing_entity_ids(customers) if filters.billing_entity_ids.present? + customers = with_active_subscriptions_range(customers) if filters.active_subscriptions_count_from.present? || filters.active_subscriptions_count_to.present? + customers = with_billing_address_filter(customers) if billing_address_filter? + customers = with_currencies(customers) if filters.currencies.present? + customers = with_has_tax_identification_number(customers) if filters.key?(:has_tax_identification_number) + customers = with_metadata(customers) if filters.metadata.present? + + customers = customers.with_discarded if filters.with_deleted + customers = paginate(customers) + customers = apply_consistent_ordering(customers) + + result.customers = customers + result + end + + private + + def billing_address_filter? + filters.countries.present? || filters.states.present? || filters.zipcodes.present? + end + + def filters_contract + @filters_contract ||= Queries::CustomersQueryFiltersContract.new + end + + def base_scope + Customer.where(organization:).ransack(search_params) + end + + def with_currencies(scope) + scope.where(currency: filters.currencies) + end + + def with_billing_address_filter(scope) + scope = scope.where(country: filters.countries) if filters.countries.present? + scope = scope.where(state: filters.states) if filters.states.present? + scope = scope.where(zipcode: filters.zipcodes) if filters.zipcodes.present? + scope + end + + def with_metadata(scope) + presence_filters, absence_filters = filters.metadata.partition { |_k, v| v.present? } + + if presence_filters.any? + tuples = presence_filters.map { "(?, ?)" }.join(", ") + subquery = Metadata::CustomerMetadata + .where(organization_id: organization.id) + .where("(key, value) IN (#{tuples})", *presence_filters.flatten) + .group("customer_id") + .having("COUNT(DISTINCT key) = ?", presence_filters.size) + .select(:customer_id) + + scope = scope.where(id: subquery) + end + + if absence_filters.any? + keys = absence_filters.map { |k, _v| k } + subquery = Metadata::CustomerMetadata.where(organization_id: organization.id).where(key: keys).select(:customer_id) + scope = scope.where.not(id: subquery) + end + + scope + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + firstname_cont: search_term, + lastname_cont: search_term, + legal_name_cont: search_term, + external_id_cont: search_term, + email_cont: search_term + } + end + + def with_has_tax_identification_number(scope) + if has_tax_identification_number? + scope.where.not(tax_identification_number: nil) + else + scope.where(tax_identification_number: nil) + end + end + + def with_customer_type(scope) + if filters.customer_type.present? + return scope.where(customer_type: filters.customer_type) + end + + if has_customer_type? + scope.where.not(customer_type: nil) + else + scope.where(customer_type: nil) + end + end + + def with_account_type(scope) + scope.where(account_type: filters.account_type) + end + + def with_billing_entity_ids(scope) + scope.where(billing_entity_id: filters.billing_entity_ids) + end + + def with_active_subscriptions_range(scope) + active_subscriptions_count = "COUNT(CASE WHEN subscriptions.status = 1 THEN 1 END)" + count_scope = scope.left_joins(:subscriptions).group("customers.id") + + count_scope = if filters.active_subscriptions_count_from == filters.active_subscriptions_count_to + count_scope.having("#{active_subscriptions_count} = ?", filters.active_subscriptions_count_from) + elsif filters.active_subscriptions_count_from.present? && filters.active_subscriptions_count_to.nil? + count_scope.having("#{active_subscriptions_count} > ?", filters.active_subscriptions_count_from) + elsif filters.active_subscriptions_count_from.nil? && filters.active_subscriptions_count_to.present? + count_scope.having("#{active_subscriptions_count} < ?", filters.active_subscriptions_count_to) + else + count_scope.having("#{active_subscriptions_count} BETWEEN ? AND ?", filters.active_subscriptions_count_from, filters.active_subscriptions_count_to) + end + + scope.where(id: count_scope.pluck(:id)) + end + + def has_tax_identification_number? + ActiveModel::Type::Boolean.new.cast(filters.has_tax_identification_number) + end + + def has_customer_type? + ActiveModel::Type::Boolean.new.cast(filters.has_customer_type) + end +end diff --git a/app/queries/dunning_campaigns_query.rb b/app/queries/dunning_campaigns_query.rb new file mode 100644 index 0000000..4b5a4de --- /dev/null +++ b/app/queries/dunning_campaigns_query.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class DunningCampaignsQuery < BaseQuery + Result = BaseResult[:dunning_campaigns] + Filters = BaseFilters[:currency, :applied_to_organization] + + DEFAULT_ORDER = "name" + + def call + dunning_campaigns = base_scope.result + dunning_campaigns = paginate(dunning_campaigns) + dunning_campaigns = dunning_campaigns.order(order) + dunning_campaigns = apply_consistent_ordering(dunning_campaigns) + + dunning_campaigns = with_applied_to_organization(dunning_campaigns) unless filters.applied_to_organization.nil? + dunning_campaigns = with_currency_threshold(dunning_campaigns) if filters.currency.present? + + result.dunning_campaigns = dunning_campaigns + result + end + + private + + def base_scope + DunningCampaign.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term + } + end + + def order + DunningCampaign::ORDERS.include?(@order) ? @order : DEFAULT_ORDER + end + + # TODO: remove this method when we not apply dunning campaign on organization anymore + # will we need a way to filter by billing_entity_id? + def with_applied_to_organization(scope) + if filters.applied_to_organization + scope.joins(:billing_entities).where(billing_entities: {id: organization.default_billing_entity.id}) + else + scope.left_joins(:billing_entities).where( + "billing_entities.id IS NULL OR billing_entities.id != ?", + organization.default_billing_entity.id + ) + end + end + + def with_currency_threshold(scope) + scope.with_currency_threshold(filters.currency) + end +end diff --git a/app/queries/entitlement/features_query.rb b/app/queries/entitlement/features_query.rb new file mode 100644 index 0000000..3541272 --- /dev/null +++ b/app/queries/entitlement/features_query.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Entitlement + class FeaturesQuery < BaseQuery + Result = BaseResult[:features] + + def call + features = base_scope.result + features = paginate(features) + features = apply_consistent_ordering(features) + + result.features = features + result + end + + private + + def base_scope + Feature.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term, + description_cont: search_term + } + end + end +end diff --git a/app/queries/entitlement/subscription_entitlement_query.rb b/app/queries/entitlement/subscription_entitlement_query.rb new file mode 100644 index 0000000..fdde47d --- /dev/null +++ b/app/queries/entitlement/subscription_entitlement_query.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionEntitlementQuery < BaseQuery + Result = BaseResult[:entitlements] + Filters = BaseFilters[:subscription_id, :plan_id] + + def call + features_result = ActiveRecord::Base.connection.exec_query( + feature_sql, + "subscription_entitlement_features", + [filters.plan_id, filters.subscription_id] + ) + + features = features_result.map do |row| + SubscriptionEntitlement.new(row) + end + + plan_entitlement_ids = features.map(&:plan_entitlement_id).compact.uniq + sub_entitlement_ids = features.map(&:sub_entitlement_id).compact.uniq + + privileges_result = ActiveRecord::Base.connection.exec_query( + privilege_sql, + "subscription_entitlement_privileges", + [prepare_ids(plan_entitlement_ids), prepare_ids(sub_entitlement_ids), filters.subscription_id] + ) + + privileges_by_feature_id = privileges_result.map do |row| + SubscriptionEntitlementPrivilege.new(row) + end.group_by(&:entitlement_feature_id) + + features.each do |f| + f.privileges = privileges_by_feature_id[f.entitlement_feature_id] || [] + end + + features + end + + private + + def feature_sql + <<~SQL + WITH + plan_entitlements AS ( + SELECT + * + FROM + entitlement_entitlements + WHERE + plan_id = $1 + AND deleted_at IS NULL + ), + sub_entitlements AS ( + SELECT + * + FROM + entitlement_entitlements + WHERE + subscription_id = $2 + AND deleted_at IS NULL + ) + SELECT + COALESCE(pe.organization_id, se.organization_id) AS organization_id, + COALESCE(pe.entitlement_feature_id, se.entitlement_feature_id) AS entitlement_feature_id, + f.code, + f.name, + f.description, + pe.id AS plan_entitlement_id, + se.id AS sub_entitlement_id, + pe.plan_id AS plan_id, + se.subscription_id AS subscription_id, + COALESCE(pe.created_at, se.created_at) AS ordering_date + FROM + plan_entitlements pe + FULL OUTER JOIN sub_entitlements se ON pe.entitlement_feature_id = se.entitlement_feature_id + JOIN entitlement_features f ON f.id = COALESCE(pe.entitlement_feature_id, se.entitlement_feature_id) + WHERE + f.deleted_at IS NULL + AND ( + pe.entitlement_feature_id IS NULL -- Feature is in sub but not in plan + OR pe.entitlement_feature_id NOT IN ( -- Feature is in plan but removed from sub + SELECT + entitlement_feature_id + FROM + entitlement_subscription_feature_removals + WHERE + subscription_id = $2 + AND entitlement_feature_id IS NOT NULL + AND deleted_at IS NULL + ) + ) + ORDER BY + ordering_date + SQL + end + + def privilege_sql + # TODO: ADD EXCLUSION FOR REMOVED PRIVILEGES + # via subquery, same as feature_sql + + <<~SQL + WITH + plan_values AS ( + SELECT + * + FROM + entitlement_entitlement_values + WHERE + deleted_at IS NULL + AND entitlement_entitlement_id = ANY ($1::UUID []) + ), + sub_values AS ( + SELECT + * + FROM + entitlement_entitlement_values + WHERE + deleted_at IS NULL + AND entitlement_entitlement_id = ANY ($2::UUID []) + ) + SELECT + COALESCE(pv.organization_id, sv.organization_id) AS organization_id, + p.entitlement_feature_id, + p.code, + COALESCE(sv.value, pv.value) AS value, + pv.value AS plan_value, + sv.value AS subscription_value, + p.name, + p.value_type, + p.config, + COALESCE(pv.created_at, sv.created_at) AS ordering_date, + pv.entitlement_entitlement_id AS plan_entitlement_id, + sv.entitlement_entitlement_id AS sub_entitlement_id, + pv.id AS plan_entitlement_value_id, + sv.id AS sub_entitlement_value_id + FROM + plan_values pv + FULL OUTER JOIN sub_values sv ON pv.entitlement_privilege_id = sv.entitlement_privilege_id + JOIN entitlement_privileges p ON p.id = COALESCE(pv.entitlement_privilege_id, sv.entitlement_privilege_id) + WHERE + p.deleted_at IS NULL + AND ( + pv.entitlement_privilege_id IS NULL -- Privilege is in sub but not in plan + OR pv.entitlement_privilege_id NOT IN ( -- Privilege is in plan but removed from sub + SELECT + entitlement_privilege_id + FROM + entitlement_subscription_feature_removals + WHERE + subscription_id = $3 + AND entitlement_privilege_id IS NOT NULL + AND deleted_at IS NULL + ) + ) + ORDER BY + ordering_date + SQL + end + + def prepare_ids(ids) + "{#{ids.map { ActiveRecord::Base.connection.quote_string(it) }.join(",")}}" + end + end +end diff --git a/app/queries/events_query.rb b/app/queries/events_query.rb new file mode 100644 index 0000000..0ff1200 --- /dev/null +++ b/app/queries/events_query.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +class EventsQuery < BaseQuery + Result = BaseResult[:events, :event_model] + Filters = BaseFilters[ + :code, + :external_subscription_id, + :timestamp_from_started_at, + :timestamp_from, + :timestamp_to, + :enriched + ] + + def call + return result unless validate_filters.success? + + events = event_model + events = events.where(organization_id: organization.id) + events = paginate(events) + + events = if pg_event? + events.order(timestamp: :desc) + elsif ch_event_raw? + events.order(ingested_at: :desc) + elsif ch_event_enriched? + events.order(enriched_at: :desc) + end + + events = with_code(events) if filters.code + events = with_external_subscription_id(events) if filters.external_subscription_id + events = with_timestamp_range(events) + + result.event_model = event_model.to_s + result.events = events + result + rescue BaseService::FailedResult + result + end + + private + + def filters_contract + @filters_contract ||= Queries::EventsQueryFiltersContract.new + end + + def event_model + if pg_event? + Event + elsif ch_event_raw? + Clickhouse::EventsRaw + elsif ch_event_enriched? + Clickhouse::EventsEnriched + end + end + + def with_code(scope) + scope.where(code: filters.code) + end + + def with_external_subscription_id(scope) + scope.where(external_subscription_id: filters.external_subscription_id) + end + + def with_timestamp_range(scope) + if timestamp_from_started_at? && subscription + scope = scope.where(timestamp: subscription.started_at..) + elsif filters.timestamp_from + scope = scope.where(timestamp: timestamp_from..) + end + + scope = scope.where(timestamp: ..timestamp_to) if filters.timestamp_to + + scope + end + + def subscription + @subscription ||= organization.subscriptions + .order("terminated_at DESC NULLS FIRST, started_at DESC") + .find_by( + external_id: filters.external_subscription_id + ) + end + + def timestamp_from + @timestamp_from ||= parse_datetime_filter(:timestamp_from) + end + + def timestamp_to + @timestamp_to ||= parse_datetime_filter(:timestamp_to) + end + + def timestamp_from_started_at? + ActiveModel::Type::Boolean.new.cast(filters.timestamp_from_started_at) + end + + def pg_event? + !organization.clickhouse_events_store? + end + + def ch_event_raw? + organization.clickhouse_events_store? && !filters.enriched + end + + def ch_event_enriched? + organization.clickhouse_events_store? && filters.enriched + end +end diff --git a/app/queries/fees_query.rb b/app/queries/fees_query.rb new file mode 100644 index 0000000..69d3c16 --- /dev/null +++ b/app/queries/fees_query.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +class FeesQuery < BaseQuery + Result = BaseResult[:fees] + Filters = BaseFilters[ + :external_subscription_id, + :external_customer_id, + :currency, + :billable_metric_code, + :fee_type, + :payment_status, + :event_transaction_id, + :created_at_from, + :created_at_to, + :succeeded_at_from, + :succeeded_at_to, + :failed_at_from, + :failed_at_to, + :refunded_at_from, + :refunded_at_to + ] + + def call + base_scope = if filters.external_customer_id + Fee.from_customer(organization, filters.external_customer_id) + else + Fee.from_organization(organization) + end + + base_scope = base_scope.includes(:pricing_unit_usage) + + fees = paginate(base_scope) + fees = apply_consistent_ordering(fees, default_order: {created_at: :asc}) + + fees = with_external_subscription(fees) if filters.external_subscription_id + + fees = fees.where(amount_currency: filters.currency.upcase) if filters.currency + fees = with_billable_metric_code(fees) if filters.billable_metric_code + + fees = with_fee_type(fees) if filters.fee_type + fees = with_payment_status(fees) if filters.payment_status + + fees = fees.where(pay_in_advance_event_transaction_id: filters.event_transaction_id) if filters.event_transaction_id + + fees = with_created_date_range(fees) if filters.created_at_from || filters.created_at_to + fees = with_succeeded_date_range(fees) if filters.succeeded_at_from || filters.succeeded_at_to + fees = with_failed_date_range(fees) if filters.failed_at_from || filters.failed_at_to + fees = with_refunded_date_range(fees) if filters.refunded_at_from || filters.refunded_at_to + + result.fees = fees + result + rescue BaseService::FailedResult + result + end + + def with_external_subscription(scope) + scope.joins(:subscription).where(subscription: {external_id: filters.external_subscription_id}) + end + + def with_billable_metric_code(scope) + scope.joins(:billable_metric) + .where(billable_metric: {code: filters.billable_metric_code}) + end + + def with_fee_type(scope) + unless Fee::FEE_TYPES.include?(filters.fee_type.to_sym) + result.single_validation_failure!(field: :fee_type, error_code: "value_is_invalid") + .raise_if_error! + end + + scope.where(fee_type: filters.fee_type) + end + + def with_payment_status(scope) + unless Fee::PAYMENT_STATUS.include?(filters.payment_status.to_sym) + result.single_validation_failure!(field: :payment_status, error_code: "value_is_invalid") + .raise_if_error! + end + + scope.where(payment_status: filters.payment_status) + end + + def with_created_date_range(scope) + scope = scope.where(created_at: created_at_from..) if filters.created_at_from + scope = scope.where(created_at: ..created_at_to) if filters.created_at_to + scope + end + + def created_at_from + @created_at_from ||= parse_datetime_filter(:created_at_from) + end + + def created_at_to + @created_at_to ||= parse_datetime_filter(:created_at_to) + end + + def with_succeeded_date_range(scope) + scope = scope.where(succeeded_at: succeeded_at_from..) if filters.succeeded_at_from + scope = scope.where(succeeded_at: ..succeeded_at_to) if filters.succeeded_at_to + scope + end + + def succeeded_at_from + @succeeded_at_from ||= parse_datetime_filter(:succeeded_at_from) + end + + def succeeded_at_to + @succeeded_at_to ||= parse_datetime_filter(:succeeded_at_to) + end + + def with_failed_date_range(scope) + scope = scope.where(failed_at: failed_at_from..) if filters.failed_at_from + scope = scope.where(failed_at: ..failed_at_to) if filters.failed_at_to + scope + end + + def failed_at_from + @failed_at_from ||= parse_datetime_filter(:failed_at_from) + end + + def failed_at_to + @failed_at_to ||= parse_datetime_filter(:failed_at_to) + end + + def with_refunded_date_range(scope) + scope = scope.where(refunded_at: refunded_at_from..) if filters.refunded_at_from + scope = scope.where(refunded_at: ..refunded_at_to) if filters.refunded_at_to + scope + end + + def refunded_at_from + @refunded_at_from ||= parse_datetime_filter(:refunded_at_from) + end + + def refunded_at_to + @refunded_at_to ||= parse_datetime_filter(:refunded_at_to) + end +end diff --git a/app/queries/integration_collection_mappings_query.rb b/app/queries/integration_collection_mappings_query.rb new file mode 100644 index 0000000..69cdade --- /dev/null +++ b/app/queries/integration_collection_mappings_query.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class IntegrationCollectionMappingsQuery < BaseQuery + Result = BaseResult[:integration_collection_mappings] + Filters = BaseFilters[:integration_id] + + def call + integration_collection_mappings = paginate(base_scope) + integration_collection_mappings = apply_consistent_ordering(integration_collection_mappings) + + integration_collection_mappings = with_integration_id(integration_collection_mappings) if filters.integration_id + + result.integration_collection_mappings = integration_collection_mappings + result + end + + private + + def base_scope + ::IntegrationCollectionMappings::BaseCollectionMapping + .joins(:integration).where(integration: {organization:}) + end + + def with_integration_id(scope) + scope.where(integration_id: filters.integration_id) + end +end diff --git a/app/queries/integration_items_query.rb b/app/queries/integration_items_query.rb new file mode 100644 index 0000000..7df898c --- /dev/null +++ b/app/queries/integration_items_query.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +class IntegrationItemsQuery < BaseQuery + Result = BaseResult[:integration_items] + Filters = BaseFilters[:integration_id, :item_type] + + def call + integration_items = base_scope.result + integration_items = paginate(integration_items) + integration_items = integration_items.order(external_name: :asc) + integration_items = apply_consistent_ordering(integration_items) + + integration_items = with_integration_id(integration_items) if filters.integration_id.present? + integration_items = with_item_type(integration_items) unless filters.item_type.nil? + + result.integration_items = integration_items + result + end + + private + + def base_scope + IntegrationItem.joins(:integration).where(integration: {organization_id: organization.id}).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + external_name_cont: search_term, + external_id_cont: search_term, + external_account_code_cont: search_term + } + end + + def with_integration_id(scope) + scope.where(integration_id: filters.integration_id) + end + + def with_item_type(scope) + scope.where(item_type: filters.item_type) + end +end diff --git a/app/queries/invoices_query.rb b/app/queries/invoices_query.rb new file mode 100644 index 0000000..f214174 --- /dev/null +++ b/app/queries/invoices_query.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +class InvoicesQuery < BaseQuery + Result = BaseResult[:invoices] + Filters = BaseFilters[ + :billing_entity_ids, + :currency, + :customer_external_id, + :customer_id, + :invoice_type, + :issuing_date_from, + :issuing_date_to, + :status, + :payment_status, + :payment_dispute_lost, + :payment_overdue, + :amount_from, + :amount_to, + :metadata, + :partially_paid, + :positive_due_amount, + :self_billed, + :subscription_id, + :settlements + ] + + def call + return result unless validate_filters.success? + + invoices = base_scope.result.includes(:customer).preload(file_attachment: :blob, xml_file_attachment: :blob) + invoices = with_customers_filter(invoices) + + invoices = with_billing_entity_ids(invoices) if filters.billing_entity_ids.present? + invoices = with_currency(invoices) if filters.currency + invoices = with_customer_external_id(invoices) if filters.customer_external_id + invoices = with_customer_id(invoices) if filters.customer_id.present? + invoices = with_invoice_type(invoices) if filters.invoice_type.present? + invoices = with_issuing_date_range(invoices) if filters.issuing_date_from || filters.issuing_date_to + invoices = with_status(invoices) + invoices = with_payment_status(invoices) if filters.payment_status.present? + invoices = with_payment_dispute_lost(invoices) unless filters.payment_dispute_lost.nil? + invoices = with_payment_overdue(invoices) unless filters.payment_overdue.nil? + invoices = with_amount_range(invoices) if filters.amount_from.present? || filters.amount_to.present? + invoices = with_metadata(invoices) if filters.metadata.present? + invoices = with_partially_paid(invoices) unless filters.partially_paid.nil? + invoices = with_positive_due_amount(invoices) unless filters.positive_due_amount.nil? + invoices = with_self_billed(invoices) unless filters.self_billed.nil? + invoices = with_subscription_id(invoices) if filters.subscription_id.present? + invoices = with_settlements(invoices) if valid_settlements.present? + + invoices = paginate(invoices) + invoices = apply_consistent_ordering( + invoices, + default_order: {issuing_date: :desc, created_at: :desc} + ) + + result.invoices = invoices + result + rescue BaseService::FailedResult + result + end + + private + + def filters_contract + @filters_contract ||= Queries::InvoicesQueryFiltersContract.new + end + + def base_scope + organization.invoices.ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + id_cont: search_term, + number_cont: search_term + } + end + + def with_customers_filter(scope) + return scope if search_term.blank? + return scope if filters.customer_id.present? + return scope if filters.customer_external_id.present? + + matching_customer_ids = organization.customers + .ransack( + m: "or", + name_cont: search_term, + firstname_cont: search_term, + lastname_cont: search_term, + external_id_cont: search_term, + email_cont: search_term + ).result.select(:id) + + scope.or( + organization.invoices.where(customer_id: matching_customer_ids) + ) + end + + def with_billing_entity_ids(scope) + scope.where(billing_entity_id: filters.billing_entity_ids) + end + + def with_currency(scope) + scope.where(currency: filters.currency) + end + + def with_customer_external_id(scope) + scope.joins(:customer).where(customers: {external_id: filters.customer_external_id}) + end + + def with_customer_id(scope) + scope.where(customer_id: filters.customer_id) + end + + def with_subscription_id(scope) + scope.joins(:invoice_subscriptions).where(invoice_subscriptions: {subscription_id: filters.subscription_id}) + end + + def with_invoice_type(scope) + scope.where(invoice_type: filters.invoice_type) + end + + def with_status(scope) + visible_keys = Invoice::VISIBLE_STATUS.keys.map(&:to_s) + statuses = if filters.status.present? + Array(filters.status).map(&:to_s) & visible_keys + else + visible_keys + end + scope.where(status: statuses) + end + + def with_payment_status(scope) + scope.where(payment_status: filters.payment_status) + end + + def with_payment_dispute_lost(scope) + if filters.payment_dispute_lost + scope.where.not(payment_dispute_lost_at: nil) + else + scope.where(payment_dispute_lost_at: nil) + end + end + + def with_payment_overdue(scope) + scope.where(payment_overdue: filters.payment_overdue) + end + + def with_positive_due_amount(scope) + positive_due_amount = ActiveModel::Type::Boolean.new.cast(filters.positive_due_amount) + + if positive_due_amount + scope.where("total_amount_cents - total_paid_amount_cents > 0") + else + scope.where("total_amount_cents - total_paid_amount_cents <= 0") + end + end + + def with_partially_paid(scope) + partially_paid = ActiveModel::Type::Boolean.new.cast(filters.partially_paid) + + if partially_paid + scope.where("total_amount_cents > total_paid_amount_cents AND total_paid_amount_cents > 0") + else + scope.where("total_amount_cents = total_paid_amount_cents OR total_paid_amount_cents = 0") + end + end + + def with_issuing_date_range(scope) + scope = scope.where(issuing_date: issuing_date_from..) if filters.issuing_date_from + scope = scope.where(issuing_date: ..issuing_date_to) if filters.issuing_date_to + scope + end + + def with_amount_range(scope) + scope = scope.where("invoices.total_amount_cents >= ?::numeric", filters.amount_from) if filters.amount_from + scope = scope.where("invoices.total_amount_cents <= ?::numeric", filters.amount_to) if filters.amount_to + scope + end + + def with_metadata(scope) + base_scope = scope.left_joins(:metadata).limit(nil).offset(nil) + subquery = base_scope + + presence_filters = filters.metadata.select { |_k, v| v.present? } + absence_filters = filters.metadata.select { |_k, v| v.blank? } + + presence_filters.each_with_index do |(key, value), index| + subquery = if index.zero? + subquery.where(metadata: {key:, value:}) + else + subquery.or(base_scope.where(metadata: {key:, value:})) + end + end + + if presence_filters.any? + subquery = subquery + .group("invoices.id") + .having("COUNT(DISTINCT metadata.key) = ?", presence_filters.size) + end + + if absence_filters.any? + subquery = subquery.where.not( + id: base_scope.where(metadata: {key: absence_filters.keys}).select(:invoice_id) + ) + end + + scope.where(id: subquery.select(:id)) + end + + def with_self_billed(scope) + scope.where(self_billed: ActiveModel::Type::Boolean.new.cast(filters.self_billed)) + end + + def with_settlements(scope) + scope.where( + "EXISTS ( + SELECT 1 FROM invoice_settlements + WHERE invoice_settlements.target_invoice_id = invoices.id + AND invoice_settlements.settlement_type IN (?))", valid_settlements + ) + end + + def issuing_date_from + @issuing_date_from ||= parse_datetime_filter(:issuing_date_from) + end + + def issuing_date_to + @issuing_date_to ||= parse_datetime_filter(:issuing_date_to) + end + + def valid_settlements + @valid_settlements ||= Array(filters.settlements) + .select { |settlement| InvoiceSettlement.settlement_types.key?(settlement) } + end +end diff --git a/app/queries/past_usage_query.rb b/app/queries/past_usage_query.rb new file mode 100644 index 0000000..ead3b93 --- /dev/null +++ b/app/queries/past_usage_query.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class PastUsageQuery < BaseQuery + Result = BaseResult[:usage_periods, :current_page, :next_page, :prev_page, :total_pages, :total_count] + Filters = BaseFilters[:external_customer_id, :external_subscription_id, :periods_count, :billable_metric_code] + + UsagePeriods = Data.define(:invoice_subscription, :fees) + + def call + validate_filters + return result if result.error.present? + + query_result = apply_consistent_ordering(query) + result.usage_periods = query_result.map do |invoice_subscription| + UsagePeriods.new( + invoice_subscription:, + fees: fees_query(invoice_subscription.invoice) + ) + end + + # NOTE: Pagination attributes + if pagination + result.current_page = query_result.current_page + result.next_page = query_result.next_page + result.prev_page = query_result.prev_page + result.total_pages = query_result.total_pages + result.total_count = query_result.total_count + end + + result + end + + private + + def query + base_query = InvoiceSubscription.joins(subscription: :customer) + .where.not(charges_from_datetime: nil) + .where(customers: {external_id: filters.external_customer_id, organization_id: organization.id}) + .where(subscriptions: {external_id: filters.external_subscription_id}) + .order(charges_from_datetime: :desc) + .includes(:invoice) + + base_query = paginate(base_query) + base_query = base_query.limit(filters.periods_count.to_i) if filters.periods_count + base_query + end + + def fees_query(invoice) + query = invoice.fees.joins(:subscription).where(subscription: {external_id: filters.external_subscription_id}).charge.includes(:charge_filter, :presentation_breakdowns) + return query unless filters.billable_metric_code + + query.joins(:charge).where(charges: {billable_metric_id: billable_metric.id}) + end + + def validate_filters + if filters.external_customer_id.blank? + return result.single_validation_failure!( + field: :external_customer_id, + error_code: "value_is_mandatory" + ) + end + + if filters.external_subscription_id.blank? + return result.single_validation_failure!( + field: :external_subscription_id, + error_code: "value_is_mandatory" + ) + end + + return if filters.billable_metric_code.blank? + + result.not_found_failure!(resource: "billable_metric") if billable_metric.blank? + end + + def billable_metric + @billable_metric ||= organization.billable_metrics.find_by(code: filters.billable_metric_code) + end +end diff --git a/app/queries/payment_methods_query.rb b/app/queries/payment_methods_query.rb new file mode 100644 index 0000000..6dd326c --- /dev/null +++ b/app/queries/payment_methods_query.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class PaymentMethodsQuery < BaseQuery + Result = BaseResult[:payment_methods] + Filters = BaseFilters[:external_customer_id, :with_deleted] + + def call + payment_methods = base_scope + payment_methods = with_external_customer(payment_methods) if filters.external_customer_id.present? + + payment_methods = apply_consistent_ordering(payment_methods) + payment_methods = paginate(payment_methods) + payment_methods = payment_methods.with_discarded if filters.with_deleted + + result.payment_methods = payment_methods + result + end + + private + + def with_external_customer(scope) + scope.joins(:customer) + .where(customers: {external_id: filters.external_customer_id}) + .where("customers.deleted_at IS NULL") + end + + def base_scope + PaymentMethod.where(organization:) + end +end diff --git a/app/queries/payment_receipts_query.rb b/app/queries/payment_receipts_query.rb new file mode 100644 index 0000000..48b49b1 --- /dev/null +++ b/app/queries/payment_receipts_query.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class PaymentReceiptsQuery < BaseQuery + Result = BaseResult[:payment_receipts] + Filters = BaseFilters[:invoice_id] + + def call + return result unless validate_filters.success? + + payment_receipts = apply_filters(base_scope) + payment_receipts = paginate(payment_receipts) + payment_receipts = apply_consistent_ordering(payment_receipts) + + result.payment_receipts = payment_receipts + result + end + + private + + def filters_contract + @filters_contract ||= Queries::PaymentReceiptsQueryFiltersContract.new + end + + def base_scope + PaymentReceipt.where(organization:) + end + + def apply_filters(scope) + scope = filter_by_invoice(scope) if filters.invoice_id.present? + scope + end + + def filter_by_invoice(scope) + invoice_id = filters.invoice_id + + joins = ActiveRecord::Base.sanitize_sql_array([ + <<~SQL, + INNER JOIN payments ON payments.id = payment_receipts.payment_id + LEFT JOIN invoices ON invoices.id = payments.payable_id AND payments.payable_type = 'Invoice' + LEFT JOIN payment_requests + ON payment_requests.id = payments.payable_id + AND payments.payable_type = 'PaymentRequest' + AND payment_requests.organization_id = ? + LEFT JOIN invoices_payment_requests ON invoices_payment_requests.payment_request_id = payment_requests.id + SQL + organization.id + ]) + + scope.joins(joins) + .where("invoices.id = :invoice_id OR invoices_payment_requests.invoice_id = :invoice_id", invoice_id:) + end +end diff --git a/app/queries/payment_requests_query.rb b/app/queries/payment_requests_query.rb new file mode 100644 index 0000000..4557396 --- /dev/null +++ b/app/queries/payment_requests_query.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class PaymentRequestsQuery < BaseQuery + Result = BaseResult[:payment_requests] + Filters = BaseFilters[:external_customer_id, :payment_status, :currency] + + def call + payment_requests = PaymentRequest.where(organization:) + + payment_requests = with_external_customer(payment_requests) if filters.external_customer_id + payment_requests = with_payment_status(payment_requests) if filters.payment_status + payment_requests = with_currency(payment_requests) if filters.currency + + payment_requests = apply_consistent_ordering(payment_requests) + payment_requests = paginate(payment_requests) + result.payment_requests = payment_requests + result + end + + private + + def with_external_customer(scope) + scope.joins(:customer).where(customers: {external_id: filters.external_customer_id}) + end + + def with_payment_status(scope) + scope.where(payment_status: filters.payment_status) + end + + def with_currency(scope) + scope.where(amount_currency: filters.currency) + end +end diff --git a/app/queries/payments_query.rb b/app/queries/payments_query.rb new file mode 100644 index 0000000..518dc04 --- /dev/null +++ b/app/queries/payments_query.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +class PaymentsQuery < BaseQuery + Result = BaseResult[:payments] + Filters = BaseFilters[:invoice_id, :external_customer_id] + + def call + return result unless validate_filters.success? + + payments = base_scope.result + payments = apply_filters(payments) + payments = paginate(payments) + payments = apply_consistent_ordering(payments) + + result.payments = payments + result + end + + private + + def filters_contract + @filters_contract ||= Queries::PaymentsQueryFiltersContract.new + end + + def base_scope + Payment.where.not(customer_id: nil) + .where(organization:) + .where.not(payable_id: nil) + .where(visible_payable_condition) + .ransack(search_params) + end + + def visible_payable_condition + ActiveRecord::Base.sanitize_sql_array([ + <<~SQL.squish, + CASE payments.payable_type + WHEN 'Invoice' THEN EXISTS( + SELECT 1 FROM invoices + WHERE invoices.id = payments.payable_id + AND invoices.status IN (:visible_statuses) + AND organization_id = :organization_id + ) + ELSE TRUE + END + SQL + { + visible_statuses: Invoice::VISIBLE_STATUS.values, + organization_id: organization.id + } + ]) + end + + def search_params + return if search_term.blank? + + terms = { + m: "or", + id_cont: search_term, + provider_payment_id_cont: search_term, + reference_cont: search_term + } + + # Add payable search terms if not filtering by specific invoice + if filters.invoice_id.blank? + terms[:invoice_number_cont] = search_term + end + + # Add customer search terms if not filtering by specific customer + if filters.external_customer_id.blank? + terms.merge!( + customer_name_cont: search_term, + customer_firstname_cont: search_term, + customer_lastname_cont: search_term, + customer_external_id_cont: search_term, + customer_email_cont: search_term + ) + end + + terms + end + + def apply_filters(scope) + scope = filter_by_invoice(scope) if filters.invoice_id.present? + scope = filter_by_customer(scope) if filters.external_customer_id.present? + scope + end + + def filter_by_customer(scope) + external_customer_id = filters.external_customer_id + + scope.joins(:customer).where("customers.external_id = :external_customer_id", external_customer_id:) + end + + def filter_by_invoice(scope) + invoice_id = filters.invoice_id + + scope.joins(<<~SQL.squish) + LEFT JOIN invoices_payment_requests + ON invoices_payment_requests.payment_request_id = payments.payable_id + AND payments.payable_type = 'PaymentRequest' + SQL + .where( + "(payments.payable_type = 'Invoice' AND payments.payable_id = :invoice_id) " \ + "OR invoices_payment_requests.invoice_id = :invoice_id", + invoice_id: + ) + end +end diff --git a/app/queries/plans_query.rb b/app/queries/plans_query.rb new file mode 100644 index 0000000..1717e78 --- /dev/null +++ b/app/queries/plans_query.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class PlansQuery < BaseQuery + Result = BaseResult[:plans] + Filters = BaseFilters[:with_deleted, :include_pending_deletion] + + def call + plans = base_scope.result + plans = paginate(plans) + plans = apply_consistent_ordering(plans) + + plans = exclude_pending_deletion(plans) unless filters.include_pending_deletion + plans = plans.with_discarded if filters.with_deleted + + result.plans = plans + result + end + + private + + def base_scope + Plan.parents.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term + } + end + + def exclude_pending_deletion(scope) + scope.where(pending_deletion: false) + end +end diff --git a/app/queries/pricing_units_query.rb b/app/queries/pricing_units_query.rb new file mode 100644 index 0000000..27ed41b --- /dev/null +++ b/app/queries/pricing_units_query.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class PricingUnitsQuery < BaseQuery + Result = BaseResult[:pricing_units] + + def call + pricing_units = base_scope.result + pricing_units = paginate(pricing_units) + pricing_units = apply_consistent_ordering( + pricing_units, + default_order: {name: :asc, created_at: :desc} + ) + + result.pricing_units = pricing_units + result + end + + private + + def base_scope + PricingUnit.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term + } + end +end diff --git a/app/queries/quotes_query.rb b/app/queries/quotes_query.rb new file mode 100644 index 0000000..718519f --- /dev/null +++ b/app/queries/quotes_query.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +class QuotesQuery < BaseQuery + Result = BaseResult[:quotes] + Filters = BaseFilters[:customers, :numbers, :statuses, :from_date, :to_date, :owners, :order_types] + + def call + return result unless validate_filters.success? + + quotes = base_scope + quotes = with_customer(quotes) if filters.customers.present? + quotes = with_number(quotes) if filters.numbers.present? + quotes = with_status(quotes) if filters.statuses.present? + quotes = with_date(quotes) if filters.from_date.present? || filters.to_date.present? + quotes = with_owners(quotes) if filters.owners.present? + quotes = with_order_types(quotes) if filters.order_types.present? + + # final ordering and pagination + quotes = quotes.order(created_at: :desc) + quotes = paginate(quotes) + + result.quotes = quotes + result + rescue BaseService::FailedResult + result + end + + private + + def filters_contract + @filters_contract ||= Queries::QuotesQueryFiltersContract.new + end + + def base_scope + Quote.where(organization:) + end + + def with_customer(scope) + scope.where(customer_id: filters.customers) + end + + def with_number(scope) + scope.where(number: filters.numbers) + end + + def with_status(scope) + # check status of the current (latest) version + quote_ids = QuoteVersion + .where( + organization:, + status: filters.statuses + ) + .where("sequential_id = (SELECT MAX(sequential_id) FROM quote_versions qv WHERE qv.quote_id = quote_versions.quote_id)") + .select(:quote_id) + + scope.where(id: quote_ids) + end + + def with_date(scope) + scope.where(created_at: filters.from_date..filters.to_date) + end + + def with_owners(scope) + quote_ids = QuoteOwner + .where( + organization:, + user_id: filters.owners + ) + .select(:quote_id) + .distinct + + scope.where(id: quote_ids) + end + + def with_order_types(scope) + scope.where(order_type: filters.order_types) + end +end diff --git a/app/queries/security_logs_query.rb b/app/queries/security_logs_query.rb new file mode 100644 index 0000000..a81560c --- /dev/null +++ b/app/queries/security_logs_query.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +class SecurityLogsQuery < BaseQuery + Result = BaseResult[:security_logs] + Filters = BaseFilters[ + :from_date, + :to_date, + :api_key_ids, + :user_ids, + :log_types, + :log_events + ] + + def call + return result.forbidden_failure! unless self.class.available? + return result.forbidden_failure! unless organization.security_logs_enabled? + return result.single_validation_failure!(field: :to_date, error_code: "value_is_mandatory") if filters.to_date.blank? + + security_logs = Clickhouse::SecurityLog.where(organization_id: organization.id) + security_logs = paginate(security_logs) + security_logs = security_logs.order(logged_at: :desc) + + security_logs = with_logged_at_range(security_logs) if filters.from_date || filters.to_date + security_logs = with_api_key_ids(security_logs) if filters.api_key_ids.present? + security_logs = with_user_ids(security_logs) if filters.user_ids.present? + security_logs = with_log_types(security_logs) if filters.log_types.present? + security_logs = with_log_events(security_logs) if filters.log_events.present? + + result.security_logs = security_logs + result + end + + def self.available? + ENV["LAGO_CLICKHOUSE_ENABLED"].present? + end + + private + + def with_logged_at_range(scope) + scope = scope.where(logged_at: from_date..) if filters.from_date + scope = scope.where(logged_at: ..to_date) if filters.to_date + scope + end + + def with_api_key_ids(scope) + scope.where(api_key_id: filters.api_key_ids) + end + + def with_user_ids(scope) + scope.where(user_id: filters.user_ids) + end + + def with_log_types(scope) + scope.where(log_type: filters.log_types) + end + + def with_log_events(scope) + scope.where(log_event: filters.log_events) + end + + def from_date + @from_date ||= parse_datetime_filter(:from_date) + end + + def to_date + @to_date ||= parse_datetime_filter(:to_date) + end +end diff --git a/app/queries/subscriptions_query.rb b/app/queries/subscriptions_query.rb new file mode 100644 index 0000000..2d35de6 --- /dev/null +++ b/app/queries/subscriptions_query.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +class SubscriptionsQuery < BaseQuery + Result = BaseResult[:subscriptions] + Filters = BaseFilters[ + :external_customer_id, + :plan_code, + :status, + :customer, + :overriden, # overriden is a legacy typo kept for backward compatibility + :overridden, + :exclude_next_subscriptions, + :currency + ] + + def call + subscriptions = base_scope.result + # FE pulls next_subscription through Graphql object, which creates additional cases to handle when + # next_subscription should be excluded from the result to avoid duplicates. + subscriptions = with_excluded_next_subscriptions(subscriptions) if filters.exclude_next_subscriptions + subscriptions = subscriptions.where(status: filtered_statuses) if valid_status? + subscriptions = apply_consistent_ordering( + subscriptions, + default_order: <<~SQL.squish + subscriptions.subscription_at DESC NULLS LAST, + subscriptions.created_at DESC + SQL + ) + + subscriptions = with_external_customer(subscriptions) if filters.external_customer_id + subscriptions = with_plan_code(subscriptions) if filters.plan_code + subscriptions = with_overridden(subscriptions) unless overridden_filter.nil? + subscriptions = with_currency(subscriptions) if filters.currency + + subscriptions = paginate(subscriptions) + + result.subscriptions = subscriptions + result + end + + def base_scope + scope = if organization.present? + Subscription.where(organization:) + else + Subscription.where(customer: filters.customer) + end.includes(:customer, :plan) + + scope = scope.joins(:customer, :plan) if search_term.present? + scope.ransack(search_params) + end + + def search_params + return if search_term.blank? + + terms = { + m: "or", + id_cont: search_term, + name_cont: search_term, + external_id_cont: search_term, + plan_name_cont: search_term, + plan_code_cont: search_term + } + + return terms if filters.external_customer_id.present? + + terms.merge( + customer_name_cont: search_term, + customer_firstname_cont: search_term, + customer_lastname_cont: search_term, + customer_external_id_cont: search_term, + customer_email_cont: search_term + ) + end + + def with_external_customer(scope) + customers = Customer.where(external_id: filters.external_customer_id) + scope.where(customer_id: customers.select(:id)) + end + + def with_plan_code(scope) + scope.joins(:plan).where(plans: {code: filters.plan_code}) + end + + def overridden_filter + @overridden_filter ||= filters.overridden.nil? ? filters.overriden : filters.overridden + end + + def with_overridden(scope) + if ActiveModel::Type::Boolean.new.cast(overridden_filter) + scope.joins(:plan).where.not(plans: {parent_id: nil}) + else + scope.joins(:plan).where(plans: {parent_id: nil}) + end + end + + def with_currency(scope) + scope.joins(:plan).where(plans: {amount_currency: filters.currency}) + end + + def with_excluded_next_subscriptions(scope) + # If there is a status filter and statuses of previous subscription and next subscritpion do not match, + # previous subscription can be filtered out, while next subscription should be included. + prev_sub_excluded_next_included_in_statuses_clause = "" + if filters.status.present? + status_values = filters.status.map { |s| Subscription.statuses[s] } + prev_sub_excluded_next_included_in_statuses_clause = "OR prev_subscriptions.status NOT IN (#{status_values.join(",")}) AND subscriptions.status IN (#{status_values.join(",")})" + end + # FE does not show next sub for terminated subscriptions, so we need to include them in the query. + prev_sub_terminated_clause = "OR prev_subscriptions.status = #{Subscription.statuses[:terminated]}" + # FE does not show next canceled subscription, so it should be included + next_sub_canceled_clause = "OR subscriptions.status = #{Subscription.statuses[:canceled]}" + + scope.joins("LEFT JOIN subscriptions AS prev_subscriptions ON subscriptions.previous_subscription_id = prev_subscriptions.id") + .where("subscriptions.previous_subscription_id IS NULL #{prev_sub_terminated_clause} #{prev_sub_excluded_next_included_in_statuses_clause} #{next_sub_canceled_clause}") + end + + def filtered_statuses + filters.status + end + + def valid_status? + filters.status.present? && filters.status.all? { |s| Subscription.statuses.key?(s) } + end +end diff --git a/app/queries/taxes_query.rb b/app/queries/taxes_query.rb new file mode 100644 index 0000000..1b83209 --- /dev/null +++ b/app/queries/taxes_query.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class TaxesQuery < BaseQuery + Result = BaseResult[:taxes] + Filters = BaseFilters[:auto_generated, :applied_to_organization] + + DEFAULT_ORDER = "name" + + def call + taxes = base_scope.result + taxes = paginate(taxes) + taxes = taxes.order(order) + taxes = apply_consistent_ordering(taxes) + + taxes = with_auto_generated(taxes) if filters.auto_generated.present? + taxes = with_applied_to_organization(taxes) unless filters.applied_to_organization.nil? + + result.taxes = taxes + result + end + + private + + def base_scope + Tax.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + name_cont: search_term, + code_cont: search_term + } + end + + def order + Tax::ORDERS.include?(@order) ? @order : DEFAULT_ORDER + end + + def with_auto_generated(scope) + scope.where(auto_generated: filters.auto_generated) + end + + def with_applied_to_organization(scope) + if filters.applied_to_organization + scope.joins(:billing_entities_taxes) + .where(billing_entities_taxes: {billing_entity_id: organization.default_billing_entity.id}) + else + scope.where.not(id: scope.joins(:billing_entities_taxes) + .where(billing_entities_taxes: {billing_entity_id: organization.default_billing_entity.id})) + end + end +end diff --git a/app/queries/usage_monitoring/alerts_query.rb b/app/queries/usage_monitoring/alerts_query.rb new file mode 100644 index 0000000..4b7b011 --- /dev/null +++ b/app/queries/usage_monitoring/alerts_query.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module UsageMonitoring + class AlertsQuery < BaseQuery + Result = BaseResult[:alerts] + Filters = BaseFilters[:subscription_external_id, :wallet_id] + + def call + alerts = paginate(base_scope) + alerts = apply_consistent_ordering(alerts) + + alerts = with_external_subscription(alerts) if filters.subscription_external_id.present? + alerts = with_wallet(alerts) if filters.wallet_id.present? + + result.alerts = alerts + result + end + + private + + def base_scope + UsageMonitoring::Alert.where(organization:) + end + + def with_external_subscription(scope) + scope.where(subscription_external_id: filters.subscription_external_id) + end + + def with_wallet(scope) + scope.where(wallet_id: filters.wallet_id) + end + end +end diff --git a/app/queries/wallet_transaction_consumptions_query.rb b/app/queries/wallet_transaction_consumptions_query.rb new file mode 100644 index 0000000..e60fed4 --- /dev/null +++ b/app/queries/wallet_transaction_consumptions_query.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +class WalletTransactionConsumptionsQuery < BaseQuery + Result = BaseResult[:wallet_transaction_consumptions] + Filters = BaseFilters[:wallet_transaction_id, :direction] + + def call + return result unless validate_filters.success? + return result.not_found_failure!(resource: "wallet_transaction") unless wallet_transaction + return result.single_validation_failure!(field: :wallet, error_code: "not_traceable") unless wallet_transaction.wallet.traceable? + return result.single_validation_failure!(field: :transaction_type, error_code: "invalid_transaction_type") unless valid_transaction_type? + + consumptions = wallet_transaction.public_send(direction).includes(eager_load_association) + consumptions = paginate(consumptions) + consumptions = apply_consistent_ordering(consumptions) + + result.wallet_transaction_consumptions = consumptions + result + end + + private + + def direction + filters.direction + end + + def wallet_transaction + @wallet_transaction ||= organization.wallet_transactions.find_by(id: filters.wallet_transaction_id) + end + + def filters_contract + @filters_contract ||= Queries::WalletTransactionConsumptionsQueryFiltersContract.new + end + + def valid_transaction_type? + case direction.to_sym + when :consumptions + wallet_transaction.inbound? + when :fundings + wallet_transaction.outbound? + end + end + + def eager_load_association + case direction.to_sym + when :consumptions + {outbound_wallet_transaction: :wallet} + when :fundings + {inbound_wallet_transaction: :wallet} + end + end +end diff --git a/app/queries/wallet_transactions_query.rb b/app/queries/wallet_transactions_query.rb new file mode 100644 index 0000000..a064929 --- /dev/null +++ b/app/queries/wallet_transactions_query.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class WalletTransactionsQuery < BaseQuery + Result = BaseResult[:wallet_transactions] + Filters = BaseFilters[:transaction_type, :status, :transaction_status] + + def initialize(organization:, wallet_id:, pagination: DEFAULT_PAGINATION_PARAMS, filters: {}, search_term: nil, order: nil) + @wallet = organization.wallets.find_by(id: wallet_id) + super(organization:, pagination:, filters:, search_term:, order:) + end + + def call + return result.not_found_failure!(resource: "wallet") unless wallet + + wallet_transactions = wallet.wallet_transactions + wallet_transactions = paginate(wallet_transactions) + wallet_transactions = apply_consistent_ordering(wallet_transactions) + + if valid_transaction_type?(filters.transaction_type) + wallet_transactions = with_transaction_type(wallet_transactions) + end + + wallet_transactions = with_status(wallet_transactions) if valid_status?(filters.status) + + if valid_transaction_status?(filters.transaction_status) + wallet_transactions = with_transaction_status(wallet_transactions) + end + + result.wallet_transactions = wallet_transactions + result + end + + private + + attr_reader :wallet + + def with_transaction_type(scope) + scope.where(transaction_type: filters.transaction_type) + end + + def with_status(scope) + scope.where(status: filters.status) + end + + def with_transaction_status(scope) + scope.where(transaction_status: filters.transaction_status) + end + + def valid_status?(status) + WalletTransaction.statuses.key?(status) + end + + def valid_transaction_type?(transaction_type) + WalletTransaction.transaction_types.key?(transaction_type) + end + + def valid_transaction_status?(transaction_status) + WalletTransaction.transaction_statuses.key?(transaction_status) + end +end diff --git a/app/queries/wallets_query.rb b/app/queries/wallets_query.rb new file mode 100644 index 0000000..c4d2f72 --- /dev/null +++ b/app/queries/wallets_query.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +class WalletsQuery < BaseQuery + Result = BaseResult[:wallets] + Filters = BaseFilters[:external_customer_id, :currency] + + def call + validate_filters + return result if result.error.present? + + wallets = base_scope + + wallets = with_external_customer_id(wallets) if filters.external_customer_id + wallets = with_currency(wallets) if filters.currency + + wallets = paginate(wallets) + wallets = apply_consistent_ordering(wallets) + + result.wallets = wallets + result + end + + private + + def base_scope + organization.wallets + end + + def with_external_customer_id(scope) + scope.where(customer_id: customer.select(:id)) + end + + def with_currency(scope) + scope.where(balance_currency: filters.currency) + end + + def validate_filters + if filters.to_h.key? :external_customer_id + result.not_found_failure!(resource: "customer") unless customer.exists? + end + end + + def customer + organization.customers.where(external_id: filters.external_customer_id) + end +end diff --git a/app/queries/webhook_endpoints_query.rb b/app/queries/webhook_endpoints_query.rb new file mode 100644 index 0000000..518cb6d --- /dev/null +++ b/app/queries/webhook_endpoints_query.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class WebhookEndpointsQuery < BaseQuery + Result = BaseResult[:webhook_endpoints] + + def call + webhook_endpoints = base_scope.result + webhook_endpoints = paginate(webhook_endpoints) + webhook_endpoints = apply_consistent_ordering( + webhook_endpoints, + default_order: {webhook_url: :asc, created_at: :desc} + ) + + result.webhook_endpoints = webhook_endpoints + result + end + + private + + def base_scope + WebhookEndpoint.where(organization:).ransack(search_params) + end + + def search_params + return if search_term.blank? + + {webhook_url_cont: search_term, m: "or"} + end +end diff --git a/app/queries/webhooks_query.rb b/app/queries/webhooks_query.rb new file mode 100644 index 0000000..f3f4af2 --- /dev/null +++ b/app/queries/webhooks_query.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +class WebhooksQuery < BaseQuery + Result = BaseResult[:webhooks] + Filters = BaseFilters[:webhook_endpoint_id, :statuses, :event_types, :http_statuses, :from_date, :to_date] + + def call + return result unless validate_filters.success? + + webhooks = base_scope.result + webhooks = paginate(webhooks) + webhooks = webhooks.order({updated_at: :desc, created_at: :desc}) + + webhooks = with_statuses(webhooks) if filters.statuses.present? + webhooks = with_event_types(webhooks) if filters.event_types.present? + webhooks = with_from_date(webhooks) if filters.from_date.present? + webhooks = with_to_date(webhooks) if filters.to_date.present? + webhooks = with_http_statuses(webhooks) if filters.http_statuses.present? + + result.webhooks = webhooks + result + rescue BaseService::FailedResult + result + end + + private + + def filters_contract + @filters_contract ||= Queries::WebhooksQueryFiltersContract.new + end + + def base_scope + Webhook.where(organization:, webhook_endpoint_id: filters.webhook_endpoint_id).ransack(search_params) + end + + def search_params + return if search_term.blank? + + { + m: "or", + id_cont: search_term, + object_id_cont: search_term + } + end + + def with_statuses(scope) + scope.where(status: filters.statuses) + end + + def with_event_types(scope) + scope.where(webhook_type: filters.event_types) + end + + def with_from_date(scope) + scope.where(updated_at: filters.from_date..) + end + + def with_to_date(scope) + scope.where(updated_at: ..filters.to_date) + end + + def with_http_statuses(scope) + statuses = filters.http_statuses.map(&:to_s).map(&:downcase) + ranges = get_http_status_ranges(statuses) + + conditions = [] + ranges.each do |range| + conditions << scope.where(http_status: range) + end + if statuses.include?("timeout") + conditions << scope.where(http_status: nil, status: :failed) + end + + return scope if conditions.empty? + + conditions.reduce { |rel, cond| rel.or(cond) } + end + + def get_http_status_ranges(statuses) + statuses.map do |status| + case status + when /\A\d{3}\z/ # exact status like 200, 404 + base = status.to_i + base..base + when /\A(\d)xx\z/i # wildcard like 2xx, 4xx + base = $1.to_i * 100 + base..(base + 99) + when /\A(\d{3})\s*-\s*(\d{3})\z/ # range like "404-412" + $1.to_i..$2.to_i + end + end.compact + end +end diff --git a/app/serializers/admin/organization_serializer.rb b/app/serializers/admin/organization_serializer.rb new file mode 100644 index 0000000..caa8bed --- /dev/null +++ b/app/serializers/admin/organization_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Admin + class OrganizationSerializer < ModelSerializer + def serialize + { + id: model.id, + name: model.name, + document_numbering: model.document_numbering, + premium_integrations: model.premium_integrations, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601 + } + end + end +end diff --git a/app/serializers/collection_serializer.rb b/app/serializers/collection_serializer.rb new file mode 100644 index 0000000..6c0ec72 --- /dev/null +++ b/app/serializers/collection_serializer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class CollectionSerializer + attr_reader :collection, + :model_serializer, + :options + + def initialize(collection, model_serializer, options = {}) + @collection = collection + @model_serializer = model_serializer + @options = options + end + + def serialize + hash = {collection_name => serialize_models} + hash[:meta] = meta if meta? + hash + end + + def to_json(options = {}) + serialize.to_json(options) + end + + def collection_name + options.fetch(:collection_name, :data).to_sym + end + + def meta? + meta.present? + end + + def includes? + options[:includes].present? + end + + def meta + options.fetch(:meta, nil) + end + + def serialize_models + collection.map do |model| + model_serializer.new(model, options).serialize + end + end +end diff --git a/app/serializers/e_invoices/base_serializer.rb b/app/serializers/e_invoices/base_serializer.rb new file mode 100644 index 0000000..8da2a5b --- /dev/null +++ b/app/serializers/e_invoices/base_serializer.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module EInvoices + class BaseSerializer + # More document types defined on UNCL 1001 here + # https://service.unece.org/trade/untdid/d99a/uncl/uncl1001.htm + PAYMENT_RECEIPT = 202 + COMMERCIAL_INVOICE = 380 + CREDIT_NOTE = 381 + PREPAID_INVOICE = 386 + SELF_BILLED_INVOICE = 389 + + # More taxations defined on UNTDID 5153 here + # https://service.unece.org/trade/untdid/d00a/tred/tred5153.htm + VAT = "VAT" + + # More VAT exemptions codes + # https://docs.peppol.eu/poacc/billing/3.0/codelist/vatex/ + O_VAT_EXEMPTION = "VATEX-EU-O" + + # You can see more payments codes UNTDID 4461 here + # https://service.unece.org/trade/untdid/d21b/tred/tred4461.htm + STANDARD_PAYMENT = 1 + CREDIT_CARD_PAYMENT = 48 + PREPAID_PAYMENT = 57 + CREDIT_NOTE_PAYMENT = 97 + + INVOICE_DISCOUNT = false + INVOICE_CHARGE = true + + # More categories for UNTDID 5305 here + # https://service.unece.org/trade/untdid/d00a/tred/tred5305.htm + S_CATEGORY = "S" + O_CATEGORY = "O" + Z_CATEGORY = "Z" + + # More measures codes defined in UNECE Recommendation 20 here + # https://docs.peppol.eu/pracc/catalogue/1.0/codelist/UNECERec20/ + UNIT_CODE = "C62" + + # Response codes for Payments + # There are no strict codes for this + ACKNOWLEDGEMENT = "AC" + PENDING = "PE" + REJECTED = "RE" + PAID = "PD" + + def initialize(xml:, resource: nil) + @xml = xml + @resource = resource + end + + def self.serialize(*, **, &) + new(*, **).serialize(&) + end + + private + + attr_accessor :xml, :resource + + def formatted_date(date) + date.strftime(self.class::DATEFORMAT) + end + + def invoice_type_code(invoice) + if invoice.credit? + EInvoices::BaseSerializer::PREPAID_INVOICE + elsif invoice.self_billed? + EInvoices::BaseSerializer::SELF_BILLED_INVOICE + else + EInvoices::BaseSerializer::COMMERCIAL_INVOICE + end + end + + def payment_information(type, amount) + case type + when STANDARD_PAYMENT + payment_label(type) + when PREPAID_PAYMENT, CREDIT_NOTE_PAYMENT + I18n.t("invoice.e_invoicing.payment_information", payment_label: payment_label(type), currency: resource.currency, amount:) + when CREDIT_CARD_PAYMENT + I18n.t("invoice.e_invoicing.credit_card_information", date: resource.created_at) + end + end + + def payment_label(type) + case type + when STANDARD_PAYMENT + I18n.t("invoice.e_invoicing.standard_payment") + when PREPAID_PAYMENT + I18n.t("invoice.prepaid_credits") + when CREDIT_NOTE_PAYMENT + I18n.t("invoice.credit_notes") + when CREDIT_CARD_PAYMENT + I18n.t("invoice.e_invoicing.credit_card") + end + end + + def discount_reason + I18n.t("invoice.e_invoicing.discount_reason", tax_rate: percent(tax_rate)) + end + + def tax_category_code(tax_rate:, type: nil) + return O_CATEGORY if type == "credit" + + tax_rate.zero? ? Z_CATEGORY : S_CATEGORY + end + + def allowance_charges(&block) + allowances_per_tax_rate.each_pair do |tax_rate, amount| + next if amount.zero? + + yield tax_rate, Money.new(amount) + end + end + + def line_items(items, &block) + resource.send(items).order(amount_cents: :asc).each_with_index do |item, index| + yield item, index + 1 + end + end + + def fee_description(fee) + return fee.invoice_name if fee.invoice_name.present? + + I18n.t( + "invoice.subscription_interval", + plan_interval: I18n.t("invoice.#{fee.subscription.plan.interval}"), + plan_name: fee.subscription.plan.invoice_name + ) + end + + def percent(value) + format_number(value, "%.2f%%") + end + + def format_number(value, mask = "%.2f") + format(mask, value) + end + end +end diff --git a/app/serializers/e_invoices/credit_notes/common.rb b/app/serializers/e_invoices/credit_notes/common.rb new file mode 100644 index 0000000..9d3c924 --- /dev/null +++ b/app/serializers/e_invoices/credit_notes/common.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module EInvoices + module CreditNotes + module Common + def notes + [ + "Credit Note ID: #{credit_note.id}", + "Original Invoice: #{credit_note.invoice.number}", + "Reason: #{credit_note.reason}" + ] + end + + def credits_and_payments(&block) + yield EInvoices::BaseSerializer::STANDARD_PAYMENT, credit_note.credit_amount + end + + def taxes(&block) + resource.fees.group_by(&:taxes_rate).each do |tax_rate, fees| + basis_amount = fees.flat_map(&:credit_note_items).sum(&:precise_amount_cents) - (allowances_per_tax_rate[tax_rate] || 0) + tax_amount = basis_amount * tax_rate.fdiv(100) + tax_category = tax_category_code(type: fees.first.fee_type, tax_rate: tax_rate) + + yield tax_category, tax_rate, Money.new(basis_amount), Money.new(tax_amount) + end + end + + def allowances + credit_note.precise_coupons_adjustment_amount_cents + end + + def allowances_per_tax_rate + credit_note.items.each_with_object(Hash.new(0)) do |item, rates| + item_fee_rate = item.fee.amount_cents.zero? ? 0 : item.precise_amount_cents.fdiv(item.fee.precise_amount_cents) + rates[item.fee.taxes_rate] += item.fee.precise_coupons_amount_cents * item_fee_rate + end + end + end + end +end diff --git a/app/serializers/e_invoices/credit_notes/factur_x/builder.rb b/app/serializers/e_invoices/credit_notes/factur_x/builder.rb new file mode 100644 index 0000000..92d19ba --- /dev/null +++ b/app/serializers/e_invoices/credit_notes/factur_x/builder.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module EInvoices + module CreditNotes::FacturX + class Builder < EInvoices::FacturX::BaseSerializer + include CreditNotes::Common + + def initialize(xml:, credit_note:) + super(xml:, resource: credit_note) + + @credit_note = credit_note + end + + def serialize + FacturX::CrossIndustryInvoice.serialize(xml:) do + FacturX::Header.serialize(xml:, resource:, type_code: CREDIT_NOTE, notes:) + + xml.comment "Supply Chain Trade Transaction" + xml["rsm"].SupplyChainTradeTransaction do + line_items(:items) do |fee, line_id| + FacturX::LineItem.serialize(xml:, resource:, data: line_item_data(line_id, fee)) + end + + FacturX::TradeAgreement.serialize(xml:, resource:) + FacturX::TradeDelivery.serialize(xml:, delivery_date: credit_note.created_at) + FacturX::TradeSettlement.serialize(xml:, resource:) do + credits_and_payments do |type, amount| + FacturX::TradeSettlementPayment.serialize(xml:, resource:, type:, amount:) + end + + taxes do |tax_category, tax_rate, basis_amount, tax_amount| + FacturX::ApplicableTradeTax.serialize(xml:, tax_category:, tax_rate:, basis_amount: -basis_amount, tax_amount: -tax_amount) + end + + allowance_charges do |tax_rate, amount| + FacturX::TradeAllowanceCharge.serialize(xml:, resource:, indicator: INVOICE_CHARGE, tax_rate:, amount: amount) + end + + FacturX::PaymentTerms.serialize(xml:, due_date: credit_note.created_at, description: "Credit note - immediate settlement") + FacturX::MonetarySummation.serialize(xml:, resource:, amounts: monetary_summation_amounts) + end + end + end + end + + private + + attr_accessor :credit_note + + def monetary_summation_amounts + FacturX::MonetarySummation::Amounts.new( + line_total_amount: -Money.new(credit_note.items.sum(:precise_amount_cents)), + charges_amount: Money.new(allowances), + tax_basis_amount: -Money.new(credit_note.sub_total_excluding_taxes_amount), + tax_amount: -Money.new(credit_note.taxes_amount), + grand_total_amount: -Money.new(credit_note.total_amount), + due_payable_amount: -Money.new(credit_note.credit_amount) + ) + end + + def line_item_data(index, item) + category = tax_category_code(type: item.fee.fee_type, tax_rate: item.fee.taxes_rate) + FacturX::LineItem::Data.new( + line_id: index, + name: item.fee.item_name, + description: fee_description(item.fee), + charge_amount: item.fee.precise_unit_amount, + billed_quantity: -item.fee.units, + category_code: category, + rate_percent: (category != O_CATEGORY) ? item.fee.taxes_rate : nil, + line_total_amount: Money.new(-item.precise_amount_cents) + ) + end + end + end +end diff --git a/app/serializers/e_invoices/credit_notes/ubl/builder.rb b/app/serializers/e_invoices/credit_notes/ubl/builder.rb new file mode 100644 index 0000000..9736a65 --- /dev/null +++ b/app/serializers/e_invoices/credit_notes/ubl/builder.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module EInvoices + module CreditNotes::Ubl + class Builder < EInvoices::Ubl::BaseSerializer + include CreditNotes::Common + + def initialize(xml:, credit_note:) + super(xml:, resource: credit_note) + + @credit_note = credit_note + end + + def serialize + xml.CreditNote(CREDIT_NOTE_NAMESPACES) do + xml.comment "UBL Version and Customization" + xml["cbc"].UBLVersionID "2.1" + xml["cbc"].CustomizationID "urn:cen.eu:en16931:2017" + + Ubl::Header.serialize(xml:, resource:, type_code: CREDIT_NOTE, notes:) + Ubl::BillingReference.serialize(xml:, resource: invoice) + + Ubl::SupplierParty.serialize(xml:, resource:, options: supplier_party_options) + Ubl::CustomerParty.serialize(xml:, resource:) + + Ubl::PaymentMeans.serialize(xml:, type: STANDARD_PAYMENT) + Ubl::PaymentTerms.serialize(xml:, note: "Credit note - immediate settlement") + + allowance_charges do |tax_rate, amount| + Ubl::AllowanceCharge.serialize(xml:, resource:, indicator: INVOICE_CHARGE, tax_rate:, amount:) + end + + xml.comment "Tax Total Information" + xml["cac"].TaxTotal do + xml["cbc"].TaxAmount format_number(-Money.new(credit_note.precise_taxes_amount_cents)), currencyID: credit_note.currency + + taxes do |tax_category, tax_rate, basis_amount, tax_amount| + Ubl::TaxSubtotal.serialize(xml:, resource:, tax_category:, tax_rate:, basis_amount: -basis_amount, tax_amount: -tax_amount) + end + end + + Ubl::MonetaryTotal.serialize(xml:, resource:, amounts: monetary_summation_amounts) + + line_items(:items) do |item, line_id| + Ubl::LineItem.serialize(xml:, resource:, data: line_item_data(line_id, item)) + end + end + end + + private + + attr_accessor :xml, :credit_note + + def invoice + credit_note.invoice + end + + def supplier_party_options + Ubl::SupplierParty::Options.new( + tax_registration: !invoice.credit? + ) + end + + def monetary_summation_amounts + Ubl::MonetaryTotal::Amounts.new( + line_extension_amount: -Money.new(credit_note.items.sum(:precise_amount_cents)), + tax_exclusive_amount: -Money.new(credit_note.sub_total_excluding_taxes_amount_cents), + tax_inclusive_amount: -Money.new(credit_note.sub_total_including_taxes_amount_cents), + charge_total_amount: Money.new(credit_note.precise_coupons_adjustment_amount_cents), + prepaid_amount: 0, + payable_amount: -Money.new(credit_note.precise_total) + ) + end + + def line_item_data(index, item) + category = tax_category_code(type: item.fee.fee_type, tax_rate: item.fee.taxes_rate) + Ubl::LineItem::Data.new( + type: :credit_note, + line_id: index, + quantity: -(item.fee_rate * item.fee.units), + line_extension_amount: -item.amount, + currency: item.amount_currency, + item_name: item.fee.item_name, + item_category: category, + item_rate_percent: (category != O_CATEGORY) ? item.fee.taxes_rate : nil, + item_description: fee_description(item.fee), + price_amount: item.fee.precise_unit_amount + ) + end + end + end +end diff --git a/app/serializers/e_invoices/factur_x/applicable_trade_tax.rb b/app/serializers/e_invoices/factur_x/applicable_trade_tax.rb new file mode 100644 index 0000000..3d0cb3f --- /dev/null +++ b/app/serializers/e_invoices/factur_x/applicable_trade_tax.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class ApplicableTradeTax < BaseSerializer + def initialize(xml:, tax_category:, tax_rate:, basis_amount:, tax_amount:) + super(xml:) + + @tax_category = tax_category + @tax_rate = tax_rate + @basis_amount = basis_amount + @tax_amount = tax_amount + end + + def serialize + xml.comment "Tax Information #{percent(tax_rate)} #{VAT}" + xml["ram"].ApplicableTradeTax do + xml["ram"].CalculatedAmount format_number(tax_amount) + xml["ram"].TypeCode VAT + xml["ram"].BasisAmount format_number(basis_amount) + xml["ram"].CategoryCode tax_category + if outside_scope_of_tax? + xml["ram"].ExemptionReasonCode O_VAT_EXEMPTION + else + xml["ram"].RateApplicablePercent format_number(tax_rate) + end + end + end + + private + + attr_accessor :tax_category, :tax_rate, :basis_amount, :tax_amount + + def outside_scope_of_tax? + tax_category == O_CATEGORY + end + end + end +end diff --git a/app/serializers/e_invoices/factur_x/base_serializer.rb b/app/serializers/e_invoices/factur_x/base_serializer.rb new file mode 100644 index 0000000..c3ff662 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/base_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class BaseSerializer < EInvoices::BaseSerializer + ROOT_NAMESPACES = { + "xmlns:xs" => "http://www.w3.org/2001/XMLSchema", + "xmlns:rsm" => "urn:un:unece:uncefact:data:standard:CrossIndustryInvoice:100", + "xmlns:qdt" => "urn:un:unece:uncefact:data:standard:QualifiedDataType:100", + "xmlns:ram" => "urn:un:unece:uncefact:data:standard:ReusableAggregateBusinessInformationEntity:100", + "xmlns:udt" => "urn:un:unece:uncefact:data:standard:UnqualifiedDataType:100" + }.freeze + + DATEFORMAT = "%Y%m%d" + + # More date formats for UNTDID 2379 here + # https://service.unece.org/trade/untdid/d15a/tred/tred2379.htm + CCYYMMDD = 102 + end + end +end diff --git a/app/serializers/e_invoices/factur_x/cross_industry_invoice.rb b/app/serializers/e_invoices/factur_x/cross_industry_invoice.rb new file mode 100644 index 0000000..79b4a27 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/cross_industry_invoice.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class CrossIndustryInvoice < BaseSerializer + def serialize + xml["rsm"].CrossIndustryInvoice(ROOT_NAMESPACES) do + xml.comment "Exchange Document Context" + xml["rsm"].ExchangedDocumentContext do + xml["ram"].GuidelineSpecifiedDocumentContextParameter do + xml["ram"].ID "urn:cen.eu:en16931:2017" + end + end + + yield + end + end + end + end +end diff --git a/app/serializers/e_invoices/factur_x/header.rb b/app/serializers/e_invoices/factur_x/header.rb new file mode 100644 index 0000000..fa013a1 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/header.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class Header < BaseSerializer + def initialize(xml:, resource:, type_code:, notes:) + super(xml:, resource:) + + @type_code = type_code + @notes = notes + end + + def serialize + xml.comment "Exchange Document Header" + xml["rsm"].ExchangedDocument do + xml["ram"].ID resource.number + xml["ram"].TypeCode type_code + xml["ram"].IssueDateTime do + xml["udt"].DateTimeString formatted_date(issue_date), format: CCYYMMDD + end + notes.each do |note| + xml["ram"].IncludedNote do + xml["ram"].Content note + end + end + end + end + + private + + attr_accessor :type_code, :notes + + def issue_date + resource.try(:issuing_date) || resource.created_at + end + end + end +end diff --git a/app/serializers/e_invoices/factur_x/invoice_reference.rb b/app/serializers/e_invoices/factur_x/invoice_reference.rb new file mode 100644 index 0000000..498fe3f --- /dev/null +++ b/app/serializers/e_invoices/factur_x/invoice_reference.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class InvoiceReference < BaseSerializer + def initialize(xml:, invoice_reference:) + super(xml:) + + @invoice_reference = invoice_reference + end + + def serialize + xml.comment "Invoice reference" + xml["ram"].InvoiceReferencedDocument do + xml["ram"].IssuerAssignedID invoice_reference + end + end + + private + + attr_accessor :invoice_reference + end + end +end diff --git a/app/serializers/e_invoices/factur_x/line_item.rb b/app/serializers/e_invoices/factur_x/line_item.rb new file mode 100644 index 0000000..25575b5 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/line_item.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class LineItem < BaseSerializer + Data = Data.define( + :line_id, + :name, + :description, + :charge_amount, + :billed_quantity, + :category_code, + :rate_percent, + :line_total_amount + ) + + def initialize(xml:, resource:, data:) + super(xml:, resource:) + + @data = data + end + + def serialize + xml.comment "Line Item #{data.line_id}: #{data.description}" + xml["ram"].IncludedSupplyChainTradeLineItem do + xml["ram"].AssociatedDocumentLineDocument do + xml["ram"].LineID data.line_id + end + xml["ram"].SpecifiedTradeProduct do + xml["ram"].Name data.name + xml["ram"].Description data.description + end + xml["ram"].SpecifiedLineTradeAgreement do + xml["ram"].NetPriceProductTradePrice do + xml["ram"].ChargeAmount data.charge_amount + end + end + xml["ram"].SpecifiedLineTradeDelivery do + xml["ram"].BilledQuantity data.billed_quantity, unitCode: UNIT_CODE + end + xml["ram"].SpecifiedLineTradeSettlement do + xml["ram"].ApplicableTradeTax do + xml["ram"].TypeCode VAT + xml["ram"].CategoryCode data.category_code + xml["ram"].RateApplicablePercent data.rate_percent if data.rate_percent.present? + end + xml["ram"].SpecifiedTradeSettlementLineMonetarySummation do + xml["ram"].LineTotalAmount format_number(data.line_total_amount) + end + end + end + end + + private + + attr_accessor :data + end + end +end diff --git a/app/serializers/e_invoices/factur_x/monetary_summation.rb b/app/serializers/e_invoices/factur_x/monetary_summation.rb new file mode 100644 index 0000000..164858f --- /dev/null +++ b/app/serializers/e_invoices/factur_x/monetary_summation.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class MonetarySummation < BaseSerializer + Amounts = Data.define( + :line_total_amount, + :charges_amount, + :allowances_amount, + :tax_basis_amount, + :tax_amount, + :grand_total_amount, + :prepaid_amount, + :due_payable_amount + ) do + def initialize(charges_amount: 0, allowances_amount: 0, prepaid_amount: nil, **rest) + super + end + end + + def initialize(xml:, resource:, amounts:) + super(xml:, resource:) + + @amounts = amounts + end + + def serialize + xml.comment "Monetary Summation" + xml["ram"].SpecifiedTradeSettlementHeaderMonetarySummation do + xml["ram"].LineTotalAmount format_number(amounts.line_total_amount) + xml["ram"].ChargeTotalAmount format_number(amounts.charges_amount) + xml["ram"].AllowanceTotalAmount format_number(amounts.allowances_amount) + xml["ram"].TaxBasisTotalAmount format_number(amounts.tax_basis_amount) + xml["ram"].TaxTotalAmount format_number(amounts.tax_amount), currencyID: resource.currency + xml["ram"].GrandTotalAmount format_number(amounts.grand_total_amount) + xml["ram"].TotalPrepaidAmount format_number(amounts.prepaid_amount) if amounts.prepaid_amount.present? + xml["ram"].DuePayableAmount format_number(amounts.due_payable_amount) + end + end + + private + + attr_accessor :amounts + end + end +end diff --git a/app/serializers/e_invoices/factur_x/payment_terms.rb b/app/serializers/e_invoices/factur_x/payment_terms.rb new file mode 100644 index 0000000..7bc3136 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/payment_terms.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class PaymentTerms < BaseSerializer + def initialize(xml:, due_date:, description:) + super(xml:) + + @description = description + @due_date = due_date + end + + def serialize + xml.comment "Payment Terms" + xml["ram"].SpecifiedTradePaymentTerms do + xml["ram"].Description description + xml["ram"].DueDateDateTime do + xml["udt"].DateTimeString formatted_date(due_date), format: CCYYMMDD + end + end + end + + private + + attr_accessor :due_date, :description + end + end +end diff --git a/app/serializers/e_invoices/factur_x/trade_agreement.rb b/app/serializers/e_invoices/factur_x/trade_agreement.rb new file mode 100644 index 0000000..8b544e1 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/trade_agreement.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class TradeAgreement < BaseSerializer + Options = Data.define(:tax_registration) do + def initialize(tax_registration: true) + super + end + end + + TAX_SCHEMA_ID = "VA" + + delegate :billing_entity, to: :resource + delegate :customer, to: :resource + + def initialize(xml:, resource:, options: Options.new) + super(xml:, resource:) + + @options = options + end + + def serialize + xml.comment "Applicable Header Trade Agreement" + xml["ram"].ApplicableHeaderTradeAgreement do + xml["ram"].SellerTradeParty do + xml["ram"].ID billing_entity.code + xml["ram"].Name billing_entity.legal_name + xml["ram"].PostalTradeAddress do + xml["ram"].PostcodeCode billing_entity.zipcode + xml["ram"].LineOne billing_entity.address_line1 + xml["ram"].LineTwo billing_entity.address_line2 + xml["ram"].CityName billing_entity.city + xml["ram"].CountryID billing_entity.country + end + if render_tax_registration? + xml["ram"].SpecifiedTaxRegistration do + xml["ram"].ID billing_entity.tax_identification_number, schemeID: TAX_SCHEMA_ID + end + end + end + xml["ram"].BuyerTradeParty do + xml["ram"].Name customer.name + xml["ram"].PostalTradeAddress do + xml["ram"].PostcodeCode customer.zipcode + xml["ram"].LineOne customer.address_line1 + xml["ram"].LineTwo customer.address_line2 + xml["ram"].CityName customer.city + xml["ram"].CountryID customer.country + end + end + end + end + + private + + attr_accessor :options + + def render_tax_registration? + options && !!options.tax_registration + end + end + end +end diff --git a/app/serializers/e_invoices/factur_x/trade_allowance_charge.rb b/app/serializers/e_invoices/factur_x/trade_allowance_charge.rb new file mode 100644 index 0000000..d4596c6 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/trade_allowance_charge.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class TradeAllowanceCharge < BaseSerializer + def initialize(xml:, resource:, indicator:, tax_rate:, amount:) + super(xml:, resource:) + + @indicator = indicator + @tax_rate = tax_rate + @amount = amount + end + + def serialize + xml.comment "Allowance/Charge - Discount #{percent(tax_rate)} portion" + xml["ram"].SpecifiedTradeAllowanceCharge do + xml["ram"].ChargeIndicator do + xml["udt"].Indicator indicator + end + xml["ram"].ActualAmount format_number(amount) + xml["ram"].Reason discount_reason + xml["ram"].CategoryTradeTax do + xml["ram"].TypeCode VAT + xml["ram"].CategoryCode tax_category_code(tax_rate:) + xml["ram"].RateApplicablePercent format_number(tax_rate) + end + end + end + + private + + attr_accessor :indicator, :tax_rate, :amount + end + end +end diff --git a/app/serializers/e_invoices/factur_x/trade_delivery.rb b/app/serializers/e_invoices/factur_x/trade_delivery.rb new file mode 100644 index 0000000..6c0fbc9 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/trade_delivery.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class TradeDelivery < BaseSerializer + def initialize(xml:, delivery_date:) + super(xml:) + + @delivery_date = delivery_date + end + + def serialize + xml.comment "Applicable Header Trade Delivery" + xml["ram"].ApplicableHeaderTradeDelivery do + xml["ram"].ActualDeliverySupplyChainEvent do + xml["ram"].OccurrenceDateTime do + xml["udt"].DateTimeString formatted_date(delivery_date), format: CCYYMMDD + end + end + end + end + + private + + attr_accessor :delivery_date + end + end +end diff --git a/app/serializers/e_invoices/factur_x/trade_settlement.rb b/app/serializers/e_invoices/factur_x/trade_settlement.rb new file mode 100644 index 0000000..132dd68 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/trade_settlement.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class TradeSettlement < BaseSerializer + def serialize + xml.comment "Applicable Header Trade Settlement" + xml["ram"].ApplicableHeaderTradeSettlement do + xml["ram"].InvoiceCurrencyCode resource.currency + yield + end + end + end + end +end diff --git a/app/serializers/e_invoices/factur_x/trade_settlement_payment.rb b/app/serializers/e_invoices/factur_x/trade_settlement_payment.rb new file mode 100644 index 0000000..c934f10 --- /dev/null +++ b/app/serializers/e_invoices/factur_x/trade_settlement_payment.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module EInvoices + module FacturX + class TradeSettlementPayment < BaseSerializer + def initialize(xml:, resource:, type:, amount: nil) + super(xml:, resource:) + + @type = type + @amount = amount + end + + def serialize + xml.comment "Payment Means: #{payment_label(type)}" + xml["ram"].SpecifiedTradeSettlementPaymentMeans do + xml["ram"].TypeCode type + xml["ram"].Information payment_information(type, amount) + card_attributes if type == CREDIT_CARD_PAYMENT + end + end + + private + + attr_accessor :type, :amount + + def card_attributes + xml["ram"].ApplicableTradeSettlementFinancialCard do + xml["ram"].ID resource.card_last_digits + end + end + end + end +end diff --git a/app/serializers/e_invoices/invoices/common.rb b/app/serializers/e_invoices/invoices/common.rb new file mode 100644 index 0000000..fd3f8df --- /dev/null +++ b/app/serializers/e_invoices/invoices/common.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module EInvoices + module Invoices + module Common + def resource + invoice + end + + def notes + ["Invoice ID: #{invoice.id}"] + end + + def invoice_type_code + if invoice.credit? + EInvoices::BaseSerializer::PREPAID_INVOICE + elsif invoice.self_billed? + EInvoices::BaseSerializer::SELF_BILLED_INVOICE + else + EInvoices::BaseSerializer::COMMERCIAL_INVOICE + end + end + + def delivery_date + case invoice.invoice_type + when "one_off", "credit" + invoice.created_at + when "subscription", "progressive_billing" + invoice.subscriptions.map do |subscription| + ::Subscriptions::DatesService.new_instance(subscription, Time.current, current_usage: true) + .charges_from_datetime + end.min + end + end + + def credits_and_payments(&block) + { + EInvoices::BaseSerializer::STANDARD_PAYMENT => invoice.total_due_amount, + EInvoices::BaseSerializer::PREPAID_PAYMENT => invoice.prepaid_credit_amount, + EInvoices::BaseSerializer::CREDIT_NOTE_PAYMENT => invoice.credit_notes_amount + }.each do |type, amount| + yield(type, amount) if amount.positive? + end + end + + def payment_terms_description + "#{I18n.t("invoice.payment_term")} #{I18n.t("invoice.payment_term_days", net_payment_term: invoice.net_payment_term)}" + end + + def allowances + invoice.coupons_amount_cents + invoice.progressive_billing_credit_amount_cents + end + + def taxes(&block) + invoice.fees.group_by(&:taxes_rate).map do |tax_rate, fees| + total_taxes = fees.sum(&:taxes_precise_amount_cents) + charged_amount = if tax_rate > 0 + (total_taxes * 100).fdiv(tax_rate) + else + fees.sum(&:precise_amount_cents) - allowances_per_tax_rate[tax_rate] + end + + tax_category = tax_category_code(type: invoice.invoice_type, tax_rate: tax_rate) + + yield tax_category, tax_rate, Money.new(charged_amount), Money.new(total_taxes) + end + end + + def allowances_per_tax_rate + fees_total = invoice.fees.sum(:precise_amount_cents) + + invoice.fees.group_by(&:taxes_rate).map do |tax_rate, fees| + total_amount = fees.sum(&:precise_amount_cents) + + if tax_rate > 0 + total_taxes = fees.sum(&:taxes_precise_amount_cents) + charged_amount = (total_taxes * 100).fdiv(tax_rate) + + [tax_rate, total_amount - charged_amount] + else + proportion = fees_total.zero? ? 0 : total_amount.fdiv(fees_total) + [tax_rate, proportion * allowances] + end + end.to_h + end + end + end +end diff --git a/app/serializers/e_invoices/invoices/factur_x/builder.rb b/app/serializers/e_invoices/invoices/factur_x/builder.rb new file mode 100644 index 0000000..f30bf35 --- /dev/null +++ b/app/serializers/e_invoices/invoices/factur_x/builder.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module EInvoices + module Invoices::FacturX + class Builder < EInvoices::FacturX::BaseSerializer + include Invoices::Common + + def initialize(xml:, invoice:) + super(xml:, resource: invoice) + + @invoice = invoice + end + + def serialize + FacturX::CrossIndustryInvoice.serialize(xml:) do + FacturX::Header.serialize(xml:, resource: invoice, type_code: invoice_type_code, notes:) + + xml.comment "Supply Chain Trade Transaction" + xml["rsm"].SupplyChainTradeTransaction do + line_items(:fees) do |fee, line_id| + FacturX::LineItem.serialize(xml:, resource:, data: line_item_data(line_id, fee)) + end + + FacturX::TradeAgreement.serialize(xml:, resource:, options: trade_aggreement_options) + FacturX::TradeDelivery.serialize(xml:, delivery_date:) + FacturX::TradeSettlement.serialize(xml:, resource:) do + credits_and_payments do |type, amount| + FacturX::TradeSettlementPayment.serialize(xml:, resource:, type:, amount:) + end + + taxes do |tax_category, tax_rate, basis_amount, tax_amount| + FacturX::ApplicableTradeTax.serialize(xml:, tax_category:, tax_rate:, basis_amount:, tax_amount:) + end + + allowance_charges do |tax_rate, amount| + FacturX::TradeAllowanceCharge.serialize(xml:, resource:, indicator: INVOICE_DISCOUNT, tax_rate:, amount:) + end + + FacturX::PaymentTerms.serialize(xml:, due_date: invoice.payment_due_date, description: payment_terms_description) + FacturX::MonetarySummation.serialize(xml:, resource:, amounts: monetary_summation_amounts) + end + end + end + end + + private + + attr_accessor :xml, :invoice + + def trade_aggreement_options + FacturX::TradeAgreement::Options.new( + tax_registration: !invoice.credit? + ) + end + + def monetary_summation_amounts + FacturX::MonetarySummation::Amounts.new( + line_total_amount: invoice.fees_amount, + allowances_amount: Money.new(allowances), + tax_basis_amount: invoice.sub_total_excluding_taxes_amount, + tax_amount: invoice.taxes_amount, + grand_total_amount: invoice.sub_total_including_taxes_amount, + prepaid_amount: invoice.prepaid_credit_amount + invoice.credit_notes_amount, + due_payable_amount: invoice.total_amount + ) + end + + def line_item_data(index, fee) + category = tax_category_code(type: fee.fee_type, tax_rate: fee.taxes_rate) + FacturX::LineItem::Data.new( + line_id: index, + name: fee.item_name, + description: fee_description(fee), + charge_amount: fee.precise_unit_amount, + billed_quantity: fee.units, + category_code: category, + rate_percent: (category != O_CATEGORY) ? fee.taxes_rate : nil, + line_total_amount: fee.amount + ) + end + end + end +end diff --git a/app/serializers/e_invoices/invoices/ubl/builder.rb b/app/serializers/e_invoices/invoices/ubl/builder.rb new file mode 100644 index 0000000..e6490f9 --- /dev/null +++ b/app/serializers/e_invoices/invoices/ubl/builder.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module EInvoices + module Invoices::Ubl + class Builder < EInvoices::Ubl::BaseSerializer + include Invoices::Common + + def initialize(xml:, invoice: nil) + super(xml:, resource: invoice) + + @invoice = invoice + end + + def serialize + xml.Invoice(INVOICE_NAMESPACES) do + xml.comment "UBL Version and Customization" + xml["cbc"].UBLVersionID "2.1" + xml["cbc"].CustomizationID "urn:cen.eu:en16931:2017" + + Ubl::Header.serialize(xml:, resource:, type_code: invoice_type_code) + Ubl::SupplierParty.serialize(xml:, resource:, options: supplier_party_options) + Ubl::CustomerParty.serialize(xml:, resource:) + Ubl::Delivery.serialize(xml:, delivery_date:) + Ubl::PaymentMeans.serialize(xml:, type: STANDARD_PAYMENT, amount: invoice.total_due_amount) + Ubl::PaymentTerms.serialize(xml:, note: payment_terms_note) + + allowance_charges do |tax_rate, amount| + Ubl::AllowanceCharge.serialize(xml:, resource:, indicator: INVOICE_DISCOUNT, tax_rate:, amount:) + end + + xml.comment "Tax Total Information" + xml["cac"].TaxTotal do + xml["cbc"].TaxAmount format_number(Money.new(invoice.taxes_amount_cents)), currencyID: invoice.currency + + taxes do |tax_category, tax_rate, basis_amount, tax_amount| + Ubl::TaxSubtotal.serialize(xml:, resource:, tax_category:, tax_rate:, basis_amount:, tax_amount:) + end + end + + Ubl::MonetaryTotal.serialize(xml:, resource:, amounts: monetary_summation_amounts) + + line_items(:fees) do |fee, line_id| + Ubl::LineItem.serialize(xml:, resource:, data: line_item_data(line_id, fee)) + end + end + end + + protected + + attr_accessor :invoice + + def supplier_party_options + Ubl::SupplierParty::Options.new( + tax_registration: !invoice.credit? + ) + end + + def payment_terms_note + [payment_terms_description, payment_notes].flatten.to_sentence + end + + def payment_notes + { + PREPAID_PAYMENT => invoice.prepaid_credit_amount, + CREDIT_NOTE_PAYMENT => invoice.credit_notes_amount + }.map do |type, amount| + next unless amount.positive? + + payment_information(type, amount) + end.compact + end + + def monetary_summation_amounts + Ubl::MonetaryTotal::Amounts.new( + line_extension_amount: invoice.fees_amount, + tax_exclusive_amount: invoice.sub_total_excluding_taxes_amount, + tax_inclusive_amount: invoice.sub_total_including_taxes_amount, + allowance_total_amount: Money.new(allowances), + prepaid_amount: invoice.prepaid_credit_amount + invoice.credit_notes_amount, + payable_amount: invoice.total_amount + ) + end + + def line_item_data(index, fee) + category = tax_category_code(type: fee.fee_type, tax_rate: fee.taxes_rate) + Ubl::LineItem::Data.new( + type: :invoice, + line_id: index, + quantity: fee.units, + line_extension_amount: fee.amount, + currency: fee.currency, + item_name: fee.item_name, + item_category: category, + item_rate_percent: (category != O_CATEGORY) ? fee.taxes_rate : nil, + item_description: fee_description(fee), + price_amount: fee.precise_unit_amount + ) + end + end + end +end diff --git a/app/serializers/e_invoices/payments/common.rb b/app/serializers/e_invoices/payments/common.rb new file mode 100644 index 0000000..5f3f95c --- /dev/null +++ b/app/serializers/e_invoices/payments/common.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module EInvoices + module Payments + module Common + delegate :payment_receipt, to: :payment + + def notes + ["Receipt for payment of #{payment.amount_currency} #{payment.amount} received via #{payment_mode} for invoice #{invoice_numbers}"] + end + + def credits_and_payments(&block) + case payment.payment_type + when Payment::PAYMENT_TYPES[:manual] + yield EInvoices::BaseSerializer::STANDARD_PAYMENT, Money.new(payment.amount_cents) + when Payment::PAYMENT_TYPES[:provider] + yield EInvoices::BaseSerializer::CREDIT_CARD_PAYMENT, Money.new(payment.amount_cents) + end + end + + private + + def invoice_numbers + payment.invoices.pluck(:number).to_sentence + end + + def payment_mode + payment.payment_type_manual? ? "Manual" : payment.payment_provider_type + end + end + end +end diff --git a/app/serializers/e_invoices/payments/factur_x/builder.rb b/app/serializers/e_invoices/payments/factur_x/builder.rb new file mode 100644 index 0000000..913f802 --- /dev/null +++ b/app/serializers/e_invoices/payments/factur_x/builder.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module EInvoices + module Payments::FacturX + class Builder < EInvoices::FacturX::BaseSerializer + include Payments::Common + + def initialize(xml:, payment:) + super(xml:, resource: payment) + + @payment = payment + end + + def serialize + FacturX::CrossIndustryInvoice.serialize(xml:) do + FacturX::Header.serialize(xml:, resource: payment_receipt, type_code: PAYMENT_RECEIPT, notes:) + + xml.comment "Supply Chain Trade Transaction" + xml["rsm"].SupplyChainTradeTransaction do + FacturX::LineItem.serialize(xml:, resource:, data: line_item_data) + FacturX::TradeAgreement.serialize(xml:, resource:, options: trade_aggreement_options) + FacturX::TradeDelivery.serialize(xml:, delivery_date: payment.created_at) + FacturX::TradeSettlement.serialize(xml:, resource:) do + credits_and_payments do |type, amount| + FacturX::TradeSettlementPayment.serialize(xml:, resource:, type:, amount:) + end + + FacturX::ApplicableTradeTax.serialize(xml:, tax_category: Z_CATEGORY, tax_rate: 0.0, basis_amount: Money.new(payment.amount_cents), tax_amount: 0.0) + FacturX::PaymentTerms.serialize(xml:, due_date: payment.created_at, description: payment_terms_description) + FacturX::MonetarySummation.serialize(xml:, resource:, amounts: monetary_summation_amounts) + + FacturX::InvoiceReference.serialize(xml:, invoice_reference: payment.invoices.pluck(:number).to_sentence) + end + end + end + end + + private + + attr_accessor :payment + + def payment_terms_description + "#{pay_method.to_s.titleize} payment received on #{payment.created_at}" + end + + def pay_method + return "manual" if payment.payment_type_manual? + return "provider" if payment.provider_payment_method_data.blank? + + payment.provider_payment_method_data["type"] + end + + def paid_using_card? + return false if payment.payment_type_manual? + return false if payment.provider_payment_method_data.blank? + + payment.provider_payment_method_data["type"] == "card" + end + + def monetary_summation_amounts + FacturX::MonetarySummation::Amounts.new( + line_total_amount: payment.amount, + tax_basis_amount: payment.amount, + tax_amount: 0, + grand_total_amount: payment.amount, + prepaid_amount: payment.amount, + due_payable_amount: 0 + ) + end + + def line_item_data + FacturX::LineItem::Data.new( + line_id: 1, + name: "Payment Received", + description: "Payment received via #{payment_mode} for invoice #{invoice_numbers}", + charge_amount: payment.amount, + billed_quantity: 1, + category_code: Z_CATEGORY, + rate_percent: 0.0, + line_total_amount: payment.amount + ) + end + + def trade_aggreement_options + FacturX::TradeAgreement::Options.new( + tax_registration: true + ) + end + end + end +end diff --git a/app/serializers/e_invoices/payments/ubl/builder.rb b/app/serializers/e_invoices/payments/ubl/builder.rb new file mode 100644 index 0000000..4d7b10a --- /dev/null +++ b/app/serializers/e_invoices/payments/ubl/builder.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module EInvoices + module Payments::Ubl + class Builder < EInvoices::Ubl::BaseSerializer + include Payments::Common + + def initialize(xml:, payment:) + super(xml:, resource: payment) + + @payment = payment + end + + def serialize + xml.ApplicationResponse(RECEIPTS_NAMESPACES) do + xml.comment "UBL Version and Customization" + xml["cbc"].UBLVersionID "2.1" + xml["cbc"].CustomizationID "urn:oasis:names:specification:ubl:xpath:ApplicationResponse-2.4" + xml["cbc"].ProfileID "urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2" + xml["cbc"].ID payment_receipt.number + xml["cbc"].IssueDate formatted_date(payment.created_at) + xml["cbc"].Note notes.to_sentence + + Ubl::SenderParty.serialize(xml:, resource:) + Ubl::ReceiverParty.serialize(xml:, resource:) + + payment.invoices.each do |invoice| + Ubl::DocumentResponse.serialize(xml:, response: paid_response, document: invoice_document(invoice)) + end + end + end + + private + + attr_accessor :payment + + def paid_response + Ubl::DocumentResponse::Response.new( + code: PAID, + description: notes.to_sentence, + date: payment.created_at + ) + end + + def invoice_document(invoice) + Ubl::DocumentResponse::Document.new( + id: invoice.number, + issue_date: invoice.issuing_date, + type_code: invoice_type_code(invoice), + type: invoice.class.to_s, + description: "Invoice ID from payment system: #{invoice.id}" + ) + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/allowance_charge.rb b/app/serializers/e_invoices/ubl/allowance_charge.rb new file mode 100644 index 0000000..64a3e19 --- /dev/null +++ b/app/serializers/e_invoices/ubl/allowance_charge.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class AllowanceCharge < BaseSerializer + def initialize(xml:, resource:, indicator:, tax_rate:, amount:) + super(xml:, resource:) + + @indicator = indicator + @tax_rate = tax_rate + @amount = amount + end + + def serialize + xml.comment "Allowances and Charges - Discount #{percent(tax_rate)} portion" + xml["cac"].AllowanceCharge do + xml["cbc"].ChargeIndicator indicator + xml["cbc"].AllowanceChargeReason discount_reason + xml["cbc"].Amount amount, currencyID: resource.currency + xml["cac"].TaxCategory do + xml["cbc"].ID tax_category_code(tax_rate:) + xml["cbc"].Percent format_number(tax_rate) + xml["cac"].TaxScheme do + xml["cbc"].ID VAT + end + end + end + end + + private + + attr_accessor :indicator, :tax_rate, :amount + end + end +end diff --git a/app/serializers/e_invoices/ubl/base_serializer.rb b/app/serializers/e_invoices/ubl/base_serializer.rb new file mode 100644 index 0000000..107faf8 --- /dev/null +++ b/app/serializers/e_invoices/ubl/base_serializer.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class BaseSerializer < EInvoices::BaseSerializer + COMMON_NAMESPACES = { + "xmlns:cac" => "urn:oasis:names:specification:ubl:schema:xsd:CommonAggregateComponents-2", + "xmlns:cbc" => "urn:oasis:names:specification:ubl:schema:xsd:CommonBasicComponents-2" + } + + INVOICE_NAMESPACES = { + "xmlns" => "urn:oasis:names:specification:ubl:schema:xsd:Invoice-2" + }.merge(COMMON_NAMESPACES).freeze + + CREDIT_NOTE_NAMESPACES = { + "xmlns" => "urn:oasis:names:specification:ubl:schema:xsd:CreditNote-2" + }.merge(COMMON_NAMESPACES).freeze + + RECEIPTS_NAMESPACES = { + "xmlns" => "urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2" + }.merge(COMMON_NAMESPACES).freeze + + DATEFORMAT = "%Y-%m-%d" + end + end +end diff --git a/app/serializers/e_invoices/ubl/billing_reference.rb b/app/serializers/e_invoices/ubl/billing_reference.rb new file mode 100644 index 0000000..8f23f06 --- /dev/null +++ b/app/serializers/e_invoices/ubl/billing_reference.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class BillingReference < BaseSerializer + def serialize + xml.comment "Reference to Original Invoice" + xml["cac"].BillingReference do + xml["cac"].InvoiceDocumentReference do + xml["cbc"].ID resource.number + xml["cbc"].IssueDate formatted_date(resource.issuing_date) + end + end + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/customer_party.rb b/app/serializers/e_invoices/ubl/customer_party.rb new file mode 100644 index 0000000..ae5ef69 --- /dev/null +++ b/app/serializers/e_invoices/ubl/customer_party.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class CustomerParty < BaseSerializer + delegate :customer, to: :resource + + def serialize + xml.comment "Customer Party" + xml["cac"].AccountingCustomerParty do + xml["cac"].Party do + xml["cac"].PostalAddress do + xml["cbc"].StreetName customer.address_line1 + xml["cbc"].AdditionalStreetName customer.address_line2 + xml["cbc"].CityName customer.city + xml["cbc"].PostalZone customer.zipcode + xml["cac"].Country do + xml["cbc"].IdentificationCode customer.country + end + end + xml["cac"].PartyLegalEntity do + xml["cbc"].RegistrationName customer.name + end + end + end + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/delivery.rb b/app/serializers/e_invoices/ubl/delivery.rb new file mode 100644 index 0000000..ebdb808 --- /dev/null +++ b/app/serializers/e_invoices/ubl/delivery.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class Delivery < BaseSerializer + def initialize(xml:, delivery_date:) + super(xml:) + + @delivery_date = delivery_date + end + + def serialize + xml.comment "Delivery Information" + xml["cac"].Delivery do + xml["cbc"].ActualDeliveryDate formatted_date(delivery_date) + end + end + + private + + attr_accessor :delivery_date + end + end +end diff --git a/app/serializers/e_invoices/ubl/document_response.rb b/app/serializers/e_invoices/ubl/document_response.rb new file mode 100644 index 0000000..416a607 --- /dev/null +++ b/app/serializers/e_invoices/ubl/document_response.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class DocumentResponse < BaseSerializer + Response = Data.define( + :code, + :description, + :date + ) + Document = Data.define( + :id, + :issue_date, + :type_code, + :type, + :description + ) + + def initialize(xml:, response:, document:, resource: nil) + super(xml:, resource:) + + @response = response + @document = document + end + + def serialize + xml.comment "Document Response" + xml["cac"].DocumentResponse do + xml["cac"].Response do + xml["cbc"].ResponseCode response.code + xml["cbc"].Description response.description + xml["cbc"].EffectiveDate formatted_date(response.date) + end + xml["cac"].DocumentReference do + xml["cbc"].ID document.id + xml["cbc"].IssueDate formatted_date(document.issue_date) + xml["cbc"].DocumentTypeCode document.type_code + xml["cbc"].DocumentType document.type + xml["cbc"].DocumentDescription document.description + end + end + end + + private + + attr_accessor :response, :document + end + end +end diff --git a/app/serializers/e_invoices/ubl/header.rb b/app/serializers/e_invoices/ubl/header.rb new file mode 100644 index 0000000..4a7436e --- /dev/null +++ b/app/serializers/e_invoices/ubl/header.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class Header < BaseSerializer + def initialize(xml:, resource:, type_code:, notes: []) + super(xml:, resource:) + + @type_code = type_code + @notes = notes + end + + def serialize + xml.comment "Invoice Header Information" + xml["cbc"].ID resource.number + xml["cbc"].IssueDate formatted_date(resource.issuing_date) + xml["cbc"].send(type_code_tag, type_code) + notes.each do |note| + xml["cbc"].Note note + end + xml["cbc"].DocumentCurrencyCode resource.currency + end + + private + + attr_accessor :type_code, :notes + + def type_code_tag + case type_code + when COMMERCIAL_INVOICE, PREPAID_INVOICE, SELF_BILLED_INVOICE + :InvoiceTypeCode + when CREDIT_NOTE + :CreditNoteTypeCode + else + raise "Unknow resource type code #{type_code}" + end + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/line_item.rb b/app/serializers/e_invoices/ubl/line_item.rb new file mode 100644 index 0000000..f12114b --- /dev/null +++ b/app/serializers/e_invoices/ubl/line_item.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class LineItem < BaseSerializer + Data = Data.define( + :type, + :line_id, + :quantity, + :line_extension_amount, + :currency, + :item_name, + :item_category, + :item_rate_percent, + :item_description, + :price_amount + ) + + def initialize(xml:, resource:, data:) + super(xml:, resource:) + + @data = data + end + + def serialize + xml.comment "Line Item #{data.line_id}: #{data.item_description}" + xml["cac"].send(line_tag) do + xml["cbc"].ID data.line_id + xml["cbc"].send(quantity_tag, format_number(data.quantity), unitCode: UNIT_CODE) + xml["cbc"].LineExtensionAmount format_number(data.line_extension_amount), currencyID: data.currency + xml["cac"].Item do + xml["cbc"].Name data.item_name + xml["cac"].ClassifiedTaxCategory do + xml["cbc"].ID data.item_category + xml["cbc"].Percent data.item_rate_percent if data.item_rate_percent.present? + xml["cac"].TaxScheme do + xml["cbc"].ID VAT + end + end + xml["cac"].AdditionalItemProperty do + xml["cbc"].Name "Description" + xml["cbc"].Value data.item_description + end + end + xml["cac"].Price do + xml["cbc"].PriceAmount data.price_amount, currencyID: data.currency + end + end + end + + private + + attr_accessor :data + + def line_tag + case data.type + when :invoice + :InvoiceLine + when :credit_note + :CreditNoteLine + end + end + + def quantity_tag + case data.type + when :invoice + :InvoicedQuantity + when :credit_note + :CreditedQuantity + end + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/monetary_total.rb b/app/serializers/e_invoices/ubl/monetary_total.rb new file mode 100644 index 0000000..6d587b5 --- /dev/null +++ b/app/serializers/e_invoices/ubl/monetary_total.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class MonetaryTotal < BaseSerializer + Amounts = Data.define( + :line_extension_amount, + :tax_exclusive_amount, + :tax_inclusive_amount, + :allowance_total_amount, + :charge_total_amount, + :prepaid_amount, + :payable_amount + ) do + def initialize(allowance_total_amount: 0, charge_total_amount: 0, **rest) + super + end + end + + def initialize(xml:, resource:, amounts:) + super(xml:, resource:) + + @amounts = amounts + end + + def serialize + xml.comment "Legal Monetary Total" + xml["cac"].LegalMonetaryTotal do + xml["cbc"].LineExtensionAmount format_number(amounts.line_extension_amount), currencyID: resource.currency + xml["cbc"].TaxExclusiveAmount format_number(amounts.tax_exclusive_amount), currencyID: resource.currency + xml["cbc"].TaxInclusiveAmount format_number(amounts.tax_inclusive_amount), currencyID: resource.currency + xml["cbc"].AllowanceTotalAmount format_number(amounts.allowance_total_amount), currencyID: resource.currency + xml["cbc"].ChargeTotalAmount format_number(amounts.charge_total_amount), currencyID: resource.currency + xml["cbc"].PrepaidAmount format_number(amounts.prepaid_amount), currencyID: resource.currency + xml["cbc"].PayableAmount format_number(amounts.payable_amount), currencyID: resource.currency + end + end + + private + + attr_accessor :amounts + end + end +end diff --git a/app/serializers/e_invoices/ubl/payment_means.rb b/app/serializers/e_invoices/ubl/payment_means.rb new file mode 100644 index 0000000..cbdab0f --- /dev/null +++ b/app/serializers/e_invoices/ubl/payment_means.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class PaymentMeans < BaseSerializer + def initialize(xml:, type:, amount: nil) + super(xml:) + + @type = type + @amount = amount + end + + def serialize + xml.comment "Payment Means: #{payment_label(type)}" + xml["cac"].PaymentMeans do + xml["cbc"].PaymentMeansCode type + end + end + + private + + attr_accessor :type, :amount + end + end +end diff --git a/app/serializers/e_invoices/ubl/payment_terms.rb b/app/serializers/e_invoices/ubl/payment_terms.rb new file mode 100644 index 0000000..2503d3e --- /dev/null +++ b/app/serializers/e_invoices/ubl/payment_terms.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class PaymentTerms < BaseSerializer + def initialize(xml:, note:) + super(xml:) + + @note = note + end + + def serialize + xml.comment "Payment Terms" + xml["cac"].PaymentTerms do + xml["cbc"].Note note + end + end + + private + + attr_accessor :note + end + end +end diff --git a/app/serializers/e_invoices/ubl/receiver_party.rb b/app/serializers/e_invoices/ubl/receiver_party.rb new file mode 100644 index 0000000..b907b4e --- /dev/null +++ b/app/serializers/e_invoices/ubl/receiver_party.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class ReceiverParty < BaseSerializer + delegate :customer, to: :resource + + def initialize(xml:, resource:) + super + end + + def serialize + xml.comment "Receiver Party" + xml["cac"].ReceiverParty do + xml["cac"].PartyName do + xml["cbc"].Name customer.name + end + xml["cac"].PostalAddress do + xml["cbc"].StreetName customer.address_line1 + xml["cbc"].AdditionalStreetName customer.address_line2 + xml["cbc"].CityName customer.city + xml["cbc"].PostalZone customer.zipcode + xml["cac"].Country do + xml["cbc"].IdentificationCode customer.country + end + end + end + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/sender_party.rb b/app/serializers/e_invoices/ubl/sender_party.rb new file mode 100644 index 0000000..e2fa95b --- /dev/null +++ b/app/serializers/e_invoices/ubl/sender_party.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class SenderParty < BaseSerializer + delegate :billing_entity, to: :resource + + def initialize(xml:, resource:) + super + end + + def serialize + xml.comment "Sender Party" + xml["cac"].SenderParty do + xml["cac"].PartyIdentification do + xml["cbc"].ID billing_entity.code + end + xml["cac"].PartyName do + xml["cbc"].Name billing_entity.name + end + xml["cac"].PostalAddress do + xml["cbc"].StreetName billing_entity.address_line1 + xml["cbc"].AdditionalStreetName billing_entity.address_line2 + xml["cbc"].CityName billing_entity.city + xml["cbc"].PostalZone billing_entity.zipcode + xml["cac"].Country do + xml["cbc"].IdentificationCode billing_entity.country + end + end + xml["cac"].PartyTaxScheme do + xml["cbc"].CompanyID billing_entity.tax_identification_number + xml["cac"].TaxScheme do + xml["cbc"].ID VAT + end + end + xml["cac"].Contact do + xml["cbc"].Name billing_entity.name + xml["cbc"].ElectronicMail billing_entity.email + end + end + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/supplier_party.rb b/app/serializers/e_invoices/ubl/supplier_party.rb new file mode 100644 index 0000000..8976ffa --- /dev/null +++ b/app/serializers/e_invoices/ubl/supplier_party.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class SupplierParty < BaseSerializer + Options = Data.define(:tax_registration) do + def initialize(tax_registration: true) + super + end + end + + delegate :billing_entity, to: :resource + + def initialize(xml:, resource:, options: Options.new) + super(xml:, resource:) + + @options = options + end + + def serialize + xml.comment "Supplier Party" + xml["cac"].AccountingSupplierParty do + xml["cac"].Party do + xml["cac"].PostalAddress do + xml["cbc"].StreetName billing_entity.address_line1 + xml["cbc"].AdditionalStreetName billing_entity.address_line2 + xml["cbc"].CityName billing_entity.city + xml["cbc"].PostalZone billing_entity.zipcode + xml["cac"].Country do + xml["cbc"].IdentificationCode billing_entity.country + end + end + if render_tax_registration? + xml["cac"].PartyTaxScheme do + xml["cbc"].CompanyID billing_entity.tax_identification_number + xml["cac"].TaxScheme do + xml["cbc"].ID VAT + end + end + end + xml["cac"].PartyLegalEntity do + xml["cbc"].RegistrationName billing_entity.legal_name + xml["cbc"].CompanyID billing_entity.tax_identification_number + end + end + end + end + + private + + attr_accessor :options + + def render_tax_registration? + options && !!options.tax_registration + end + end + end +end diff --git a/app/serializers/e_invoices/ubl/tax_subtotal.rb b/app/serializers/e_invoices/ubl/tax_subtotal.rb new file mode 100644 index 0000000..470185c --- /dev/null +++ b/app/serializers/e_invoices/ubl/tax_subtotal.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module EInvoices + module Ubl + class TaxSubtotal < BaseSerializer + def initialize(xml:, resource:, tax_category:, tax_rate:, basis_amount:, tax_amount:) + super(xml:, resource:) + + @tax_category = tax_category + @tax_rate = tax_rate + @basis_amount = basis_amount + @tax_amount = tax_amount + end + + def serialize + xml.comment "Tax Information #{percent(tax_rate)} #{VAT}" + xml["cac"].TaxSubtotal do + xml["cbc"].TaxableAmount format_number(basis_amount), currencyID: resource.currency + xml["cbc"].TaxAmount format_number(tax_amount), currencyID: resource.currency + xml["cac"].TaxCategory do + xml["cbc"].ID tax_category + if outside_scope_of_tax? + xml["cbc"].TaxExemptionReasonCode O_VAT_EXEMPTION + xml["cbc"].TaxExemptionReason "Not subject to VAT" + else + xml["cbc"].Percent format_number(tax_rate) + end + xml["cac"].TaxScheme do + xml["cbc"].ID VAT + end + end + end + end + + private + + attr_accessor :tax_category, :tax_rate, :basis_amount, :tax_amount + + def outside_scope_of_tax? + tax_category == O_CATEGORY + end + end + end +end diff --git a/app/serializers/error_serializer.rb b/app/serializers/error_serializer.rb new file mode 100644 index 0000000..f3bfd05 --- /dev/null +++ b/app/serializers/error_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ErrorSerializer + attr_reader :error + + def initialize(error) + @error = error + end + + def serialize + { + message: error.message + } + end +end diff --git a/app/serializers/model_serializer.rb b/app/serializers/model_serializer.rb new file mode 100644 index 0000000..48721c4 --- /dev/null +++ b/app/serializers/model_serializer.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class ModelSerializer + attr_reader :model, :options + + # The possible values for the options are: + # - includes: Specify the relation to include in the payload + # Expected format: [:customer, {plan: [:charges]}] + # + # Hash values can be passed to the relation serializer + # when used with `included_relations`. + # Example: + # included_relations(:plan, default: [:charges])` + def initialize(model, options = {}) + @model = model + @options = options + end + + def serialize + {id: model.id} + end + + def to_json(options = {}) + { + root_name => serialize + }.to_json(options) + end + + def root_name + options.fetch(:root_name, :data) + end + + # Check if a relation should be included in the payload + def include?(value) + includes = options[:includes] + return false if includes.blank? + + includes.any? do |include| + next value == include if include.is_a?(Symbol) + next include.key?(value) if include.is_a?(Hash) + + false + end + end + + # Retrieve the relations to be included by a subserializer + # When the relation valus is symbol, it returns the default values + # When the relation is a hash key, it will return the matching value + def included_relations(value, default: []) + includes = options[:includes] + return default if includes.blank? + + include = includes.find do |include| + next value == include if include.is_a?(Symbol) + next include.key?(value) if include.is_a?(Hash) + end + + return default if include.is_a?(Symbol) || include.nil? + + include[value] + end +end diff --git a/app/serializers/v1/activity_log_serializer.rb b/app/serializers/v1/activity_log_serializer.rb new file mode 100644 index 0000000..79b9d8f --- /dev/null +++ b/app/serializers/v1/activity_log_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + class ActivityLogSerializer < ModelSerializer + def serialize + { + activity_id: model.activity_id, + activity_type: model.activity_type, + activity_source: model.activity_source, + activity_object: model.activity_object, + activity_object_changes: model.activity_object_changes, + user_email: model.user&.email, + resource_id: model.resource_id, + resource_type: model.resource_type, + external_customer_id: model.external_customer_id, + external_subscription_id: model.external_subscription_id, + logged_at: model.logged_at.iso8601, + created_at: model.created_at.iso8601 + } + end + end +end diff --git a/app/serializers/v1/add_on_serializer.rb b/app/serializers/v1/add_on_serializer.rb new file mode 100644 index 0000000..1d3637a --- /dev/null +++ b/app/serializers/v1/add_on_serializer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module V1 + class AddOnSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + name: model.name, + invoice_display_name: model.invoice_display_name, + code: model.code, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + created_at: model.created_at.iso8601, + description: model.description + } + + payload.merge!(taxes) if include?(:taxes) + payload + end + + private + + def taxes + ::CollectionSerializer.new( + model.taxes, + ::V1::TaxSerializer, + collection_name: "taxes" + ).serialize + end + end +end diff --git a/app/serializers/v1/analytics/gross_revenue_serializer.rb b/app/serializers/v1/analytics/gross_revenue_serializer.rb new file mode 100644 index 0000000..37a2ef5 --- /dev/null +++ b/app/serializers/v1/analytics/gross_revenue_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module V1 + module Analytics + class GrossRevenueSerializer < ModelSerializer + def serialize + { + month: model["month"], + amount_cents: model["amount_cents"], + currency: model["currency"], + invoices_count: model["invoices_count"] + } + end + end + end +end diff --git a/app/serializers/v1/analytics/invoice_collection_serializer.rb b/app/serializers/v1/analytics/invoice_collection_serializer.rb new file mode 100644 index 0000000..66711db --- /dev/null +++ b/app/serializers/v1/analytics/invoice_collection_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + module Analytics + class InvoiceCollectionSerializer < ModelSerializer + def serialize + { + month: model["month"], + payment_status: model["payment_status"], + invoices_count: model["invoices_count"], + amount_cents: model["amount_cents"], + currency: model["currency"] + } + end + end + end +end diff --git a/app/serializers/v1/analytics/invoiced_usage_serializer.rb b/app/serializers/v1/analytics/invoiced_usage_serializer.rb new file mode 100644 index 0000000..9a803a5 --- /dev/null +++ b/app/serializers/v1/analytics/invoiced_usage_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module V1 + module Analytics + class InvoicedUsageSerializer < ModelSerializer + def serialize + { + month: model["month"], + code: model["code"], + currency: model["currency"], + amount_cents: model["amount_cents"] + } + end + end + end +end diff --git a/app/serializers/v1/analytics/mrr_serializer.rb b/app/serializers/v1/analytics/mrr_serializer.rb new file mode 100644 index 0000000..a9ec4d9 --- /dev/null +++ b/app/serializers/v1/analytics/mrr_serializer.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module V1 + module Analytics + class MrrSerializer < ModelSerializer + def serialize + { + month: model["month"], + amount_cents: model["amount_cents"], + currency: model["currency"] + } + end + end + end +end diff --git a/app/serializers/v1/analytics/overdue_balance_serializer.rb b/app/serializers/v1/analytics/overdue_balance_serializer.rb new file mode 100644 index 0000000..1b5c8e9 --- /dev/null +++ b/app/serializers/v1/analytics/overdue_balance_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module V1 + module Analytics + class OverdueBalanceSerializer < ModelSerializer + def serialize + { + month: model["month"], + amount_cents: model["amount_cents"], + currency: model["currency"], + lago_invoice_ids: JSON.parse(model["lago_invoice_ids"]).flatten + } + end + end + end +end diff --git a/app/serializers/v1/api_log_serializer.rb b/app/serializers/v1/api_log_serializer.rb new file mode 100644 index 0000000..8bf45b8 --- /dev/null +++ b/app/serializers/v1/api_log_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module V1 + class ApiLogSerializer < ModelSerializer + def serialize + { + request_id: model.request_id, + client: model.client, + http_method: model.http_method, + http_status: model.http_status, + request_origin: model.request_origin, + request_path: model.request_path, + request_body: model.request_body, + request_response: model.request_response, + api_version: model.api_version, + logged_at: model.logged_at.iso8601, + created_at: model.created_at.iso8601 + } + end + end +end diff --git a/app/serializers/v1/applicable_usage_threshold_serializer.rb b/app/serializers/v1/applicable_usage_threshold_serializer.rb new file mode 100644 index 0000000..ce76b35 --- /dev/null +++ b/app/serializers/v1/applicable_usage_threshold_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module V1 + class ApplicableUsageThresholdSerializer < ModelSerializer + def serialize + { + threshold_display_name: model.threshold_display_name, + amount_cents: model.amount_cents, + recurring: model.recurring + } + end + end +end diff --git a/app/serializers/v1/applied_coupon_serializer.rb b/app/serializers/v1/applied_coupon_serializer.rb new file mode 100644 index 0000000..315293d --- /dev/null +++ b/app/serializers/v1/applied_coupon_serializer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module V1 + class AppliedCouponSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_coupon_id: model.coupon.id, + coupon_code: model.coupon.code, + coupon_name: model.coupon.name, + coupon_description: model.coupon.description, + coupon_status: model.coupon.status, + coupon_deleted_at: model.coupon.deleted_at&.iso8601, + lago_customer_id: model.customer.id, + external_customer_id: model.customer.external_id, + status: model.status, + amount_cents: model.amount_cents, + amount_cents_remaining:, + amount_currency: model.amount_currency, + percentage_rate: model.percentage_rate, + frequency: model.frequency, + frequency_duration: model.frequency_duration, + frequency_duration_remaining: model.frequency_duration_remaining, + expiration_at: model.coupon.expiration_at&.iso8601, + created_at: model.created_at.iso8601, + terminated_at: model.terminated_at&.iso8601 + } + + payload = payload.merge(credits) if include?(:credits) + + payload + end + + private + + def amount_cents_remaining + return nil if model.recurring? || model.forever? + return nil if model.coupon.percentage? + + model.amount_cents - model.credits.active.sum(:amount_cents) + end + + def credits + ::CollectionSerializer.new(model.credits, ::V1::CreditSerializer, collection_name: "credits").serialize + end + end +end diff --git a/app/serializers/v1/applied_invoice_custom_section_serializer.rb b/app/serializers/v1/applied_invoice_custom_section_serializer.rb new file mode 100644 index 0000000..66a173f --- /dev/null +++ b/app/serializers/v1/applied_invoice_custom_section_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + class AppliedInvoiceCustomSectionSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + invoice_custom_section_id: model.invoice_custom_section_id, + created_at: model.created_at.iso8601, + invoice_custom_section: + } + end + + private + + def invoice_custom_section + ::V1::InvoiceCustomSectionSerializer.new( + model.invoice_custom_section + ).serialize + end + end +end diff --git a/app/serializers/v1/applied_usage_threshold_serializer.rb b/app/serializers/v1/applied_usage_threshold_serializer.rb new file mode 100644 index 0000000..ef86ebe --- /dev/null +++ b/app/serializers/v1/applied_usage_threshold_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module V1 + class AppliedUsageThresholdSerializer < ModelSerializer + def serialize + payload = { + lifetime_usage_amount_cents: model.lifetime_usage_amount_cents, + created_at: model.created_at.iso8601 + } + + payload.merge!(usage_threshold) + payload + end + + private + + def usage_threshold + { + usage_threshold: ::V1::UsageThresholdSerializer.new(model.usage_threshold).serialize + } + end + end +end diff --git a/app/serializers/v1/billable_metric_expression_result_serializer.rb b/app/serializers/v1/billable_metric_expression_result_serializer.rb new file mode 100644 index 0000000..22b9083 --- /dev/null +++ b/app/serializers/v1/billable_metric_expression_result_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module V1 + class BillableMetricExpressionResultSerializer < ModelSerializer + def serialize + {value: model.evaluation_result} + end + end +end diff --git a/app/serializers/v1/billable_metric_filter_serializer.rb b/app/serializers/v1/billable_metric_filter_serializer.rb new file mode 100644 index 0000000..2d845f3 --- /dev/null +++ b/app/serializers/v1/billable_metric_filter_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module V1 + class BillableMetricFilterSerializer < ModelSerializer + def serialize + { + key: model.key, + values: model.values.sort + } + end + end +end diff --git a/app/serializers/v1/billable_metric_serializer.rb b/app/serializers/v1/billable_metric_serializer.rb new file mode 100644 index 0000000..a94e312 --- /dev/null +++ b/app/serializers/v1/billable_metric_serializer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module V1 + class BillableMetricSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + name: model.name, + code: model.code, + description: model.description, + aggregation_type: model.aggregation_type, + weighted_interval: model.weighted_interval, + recurring: model.recurring, + rounding_function: model.rounding_function, + rounding_precision: model.rounding_precision, + created_at: model.created_at.iso8601, + field_name: model.field_name, + expression: model.expression + } + + payload.merge!(counters) if include?(:counters) + payload.merge!(filters) + + payload + end + + private + + def counters + { + active_subscriptions_count: 0, + draft_invoices_count: 0, + plans_count: 0 + } + end + + def filters + ::CollectionSerializer.new( + model.filters, + ::V1::BillableMetricFilterSerializer, + collection_name: "filters" + ).serialize + end + end +end diff --git a/app/serializers/v1/billing_entity_serializer.rb b/app/serializers/v1/billing_entity_serializer.rb new file mode 100644 index 0000000..dfa42dc --- /dev/null +++ b/app/serializers/v1/billing_entity_serializer.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module V1 + class BillingEntitySerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + code: model.code, + name: model.name, + default_currency: model.default_currency, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601, + country: model.country, + address_line1: model.address_line1, + address_line2: model.address_line2, + city: model.city, + state: model.state, + zipcode: model.zipcode, + einvoicing: model.einvoicing, + email: model.email, + legal_name: model.legal_name, + legal_number: model.legal_number, + timezone: model.timezone, + net_payment_term: model.net_payment_term, + email_settings: model.email_settings, + document_numbering: model.document_numbering, + document_number_prefix: model.document_number_prefix, + tax_identification_number: model.tax_identification_number, + finalize_zero_amount_invoice: model.finalize_zero_amount_invoice, + invoice_footer: model.invoice_footer, + invoice_grace_period: model.invoice_grace_period, + subscription_invoice_issuing_date_adjustment: model.subscription_invoice_issuing_date_adjustment, + subscription_invoice_issuing_date_anchor: model.subscription_invoice_issuing_date_anchor, + document_locale: model.document_locale, + is_default: model.organization.default_billing_entity&.id == model.id, + eu_tax_management: model.eu_tax_management, + logo_url: model.logo_url + } + + payload = payload.merge(taxes) if include?(:taxes) + payload = payload.merge(selected_invoice_custom_sections) if include?(:selected_invoice_custom_sections) + + payload + end + + private + + def taxes + ::CollectionSerializer.new( + model.taxes, + ::V1::TaxSerializer, + collection_name: "taxes" + ).serialize + end + + def selected_invoice_custom_sections + ::CollectionSerializer.new( + model.selected_invoice_custom_sections, + ::V1::InvoiceCustomSectionSerializer, + collection_name: "selected_invoice_custom_sections" + ).serialize + end + end +end diff --git a/app/serializers/v1/charge_filter_serializer.rb b/app/serializers/v1/charge_filter_serializer.rb new file mode 100644 index 0000000..02143b5 --- /dev/null +++ b/app/serializers/v1/charge_filter_serializer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module V1 + class ChargeFilterSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + charge_code: model.charge.code, + invoice_display_name: model.invoice_display_name, + properties:, + values: model.to_h + } + end + + private + + # TODO(pricing_group_keys): remove after deprecation of grouped_by + def properties + attributes = model.properties + if attributes["grouped_by"].present? && attributes["pricing_group_keys"].blank? + attributes["pricing_group_keys"] = attributes["grouped_by"] + end + + if attributes["pricing_group_keys"].present? && attributes["grouped_by"].blank? + attributes["grouped_by"] = attributes["pricing_group_keys"] + end + + attributes + end + end +end diff --git a/app/serializers/v1/charge_serializer.rb b/app/serializers/v1/charge_serializer.rb new file mode 100644 index 0000000..906211d --- /dev/null +++ b/app/serializers/v1/charge_serializer.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module V1 + class ChargeSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_billable_metric_id: model.billable_metric_id, + code: model.code, + invoice_display_name: model.invoice_display_name, + billable_metric_code: model.billable_metric.code, + created_at: model.created_at.iso8601, + charge_model: model.charge_model, + invoiceable: model.invoiceable, + regroup_paid_fees: model.regroup_paid_fees, + pay_in_advance: model.pay_in_advance, + prorated: model.prorated, + min_amount_cents: model.min_amount_cents, + accepts_target_wallet: model.accepts_target_wallet, + properties:, + applied_pricing_unit:, + lago_parent_id: model.parent_id + } + + payload.merge!(charge_filters) + payload.merge!(taxes) if include?(:taxes) + + payload + end + + private + + def taxes + ::CollectionSerializer.new( + model.taxes, + ::V1::TaxSerializer, + collection_name: "taxes" + ).serialize + end + + def charge_filters + ::CollectionSerializer.new( + model.filters.includes(:charge, values: :billable_metric_filter), + ::V1::ChargeFilterSerializer, + collection_name: "filters" + ).serialize + end + + def applied_pricing_unit + return if model.applied_pricing_unit.nil? + + { + conversion_rate: model.applied_pricing_unit.conversion_rate, + code: model.pricing_unit.code + } + end + + # TODO(pricing_group_keys): remove after deprecation of grouped_by + def properties + attributes = model.properties + if attributes["grouped_by"].present? && attributes["pricing_group_keys"].blank? + attributes["pricing_group_keys"] = attributes["grouped_by"] + end + + if attributes["pricing_group_keys"].present? && attributes["grouped_by"].blank? + attributes["grouped_by"] = attributes["pricing_group_keys"] + end + + attributes + end + end +end diff --git a/app/serializers/v1/commitment_serializer.rb b/app/serializers/v1/commitment_serializer.rb new file mode 100644 index 0000000..17c2c20 --- /dev/null +++ b/app/serializers/v1/commitment_serializer.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module V1 + class CommitmentSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + plan_code: model.plan.code, + invoice_display_name: model.invoice_display_name, + commitment_type: model.commitment_type, + amount_cents: model.amount_cents, + interval: model.plan.interval, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601 + } + + payload.merge!(taxes) if include?(:taxes) + + payload + end + + private + + def taxes + ::CollectionSerializer.new( + model.taxes, + ::V1::TaxSerializer, + collection_name: "taxes" + ).serialize + end + end +end diff --git a/app/serializers/v1/coupon_serializer.rb b/app/serializers/v1/coupon_serializer.rb new file mode 100644 index 0000000..c79f436 --- /dev/null +++ b/app/serializers/v1/coupon_serializer.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module V1 + class CouponSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + name: model.name, + code: model.code, + description: model.description, + coupon_type: model.coupon_type, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + percentage_rate: model.percentage_rate, + frequency: model.frequency, + frequency_duration: model.frequency_duration, + reusable: model.reusable, + limited_plans: model.limited_plans, + limited_billable_metrics: model.limited_billable_metrics, + plan_codes: model.plans.parents.pluck(:code), + billable_metric_codes: model.billable_metrics.pluck(:code), + created_at: model.created_at.iso8601, + expiration: model.expiration, + expiration_at: model.expiration_at&.iso8601, + terminated_at: model.terminated_at&.iso8601 + } + end + end +end diff --git a/app/serializers/v1/credit_note_item_serializer.rb b/app/serializers/v1/credit_note_item_serializer.rb new file mode 100644 index 0000000..58c12df --- /dev/null +++ b/app/serializers/v1/credit_note_item_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module V1 + class CreditNoteItemSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + amount_cents: model.amount_cents, + precise_amount_cents: model.precise_amount_cents&.to_s, + amount_currency: model.amount_currency, + fee: + } + end + + private + + def fee + ::V1::FeeSerializer.new( + model.fee + ).serialize + end + end +end diff --git a/app/serializers/v1/credit_note_serializer.rb b/app/serializers/v1/credit_note_serializer.rb new file mode 100644 index 0000000..cb195ff --- /dev/null +++ b/app/serializers/v1/credit_note_serializer.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module V1 + class CreditNoteSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + billing_entity_code: model.invoice.billing_entity.code, + sequential_id: model.sequential_id, + number: model.number, + lago_invoice_id: model.invoice_id, + invoice_number: model.invoice.number, + issuing_date: model.issuing_date.iso8601, + credit_status: model.credit_status, + refund_status: model.refund_status, + reason: model.reason, + description: model.description, + currency: model.currency, + total_amount_cents: model.total_amount_cents, + precise_total_amount_cents: model.precise_total&.to_s, + taxes_amount_cents: model.taxes_amount_cents, + precise_taxes_amount_cents: model.precise_taxes_amount_cents&.to_s, + sub_total_excluding_taxes_amount_cents: model.sub_total_excluding_taxes_amount_cents, + balance_amount_cents: model.balance_amount_cents, + credit_amount_cents: model.credit_amount_cents, + refund_amount_cents: model.refund_amount_cents, + offset_amount_cents: model.offset_amount_cents, + coupons_adjustment_amount_cents: model.coupons_adjustment_amount_cents, + taxes_rate: model.taxes_rate, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601, + file_url: model.file_url, + xml_url: model.xml_url, + self_billed: model.invoice.self_billed + } + + payload.merge!(customer) if include?(:customer) + payload.merge!(items) if include?(:items) + payload.merge!(applied_taxes) if include?(:applied_taxes) + payload.merge!(error_details) if include?(:error_details) + payload.merge!(metadata) if model.metadata.present? + + payload + end + + private + + def customer + { + customer: ::V1::CustomerSerializer.new( + model.customer, + includes: included_relations(:customer, default: []) + ).serialize + } + end + + def items + ::CollectionSerializer.new( + model.items.sort_by(&:created_at), + ::V1::CreditNoteItemSerializer, + collection_name: "items" + ).serialize + end + + def applied_taxes + ::CollectionSerializer.new( + model.applied_taxes, + ::V1::CreditNotes::AppliedTaxSerializer, + collection_name: "applied_taxes" + ).serialize + end + + def error_details + ::CollectionSerializer.new( + model.error_details, + ::V1::ErrorDetailSerializer, + collection_name: "error_details" + ).serialize + end + + def metadata + { + metadata: ::V1::MetadataSerializer.new(model.metadata).serialize + } + end + end +end diff --git a/app/serializers/v1/credit_notes/applied_tax_serializer.rb b/app/serializers/v1/credit_notes/applied_tax_serializer.rb new file mode 100644 index 0000000..ca7fe15 --- /dev/null +++ b/app/serializers/v1/credit_notes/applied_tax_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module V1 + module CreditNotes + class AppliedTaxSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_credit_note_id: model.credit_note_id, + lago_tax_id: model.tax_id, + tax_name: model.tax_name, + tax_code: model.tax_code, + tax_rate: model.tax_rate, + tax_description: model.tax_description, + base_amount_cents: model.base_amount_cents, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + created_at: model.created_at&.iso8601 + } + end + end + end +end diff --git a/app/serializers/v1/credit_notes/estimate_serializer.rb b/app/serializers/v1/credit_notes/estimate_serializer.rb new file mode 100644 index 0000000..dec13ff --- /dev/null +++ b/app/serializers/v1/credit_notes/estimate_serializer.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module V1 + module CreditNotes + class EstimateSerializer < ModelSerializer + def serialize + payload = { + lago_invoice_id: model.invoice_id, + invoice_number: model.invoice.number, + currency: model.currency, + taxes_amount_cents: model.taxes_amount_cents, + precise_taxes_amount_cents: model.precise_taxes_amount_cents, + sub_total_excluding_taxes_amount_cents: model.sub_total_excluding_taxes_amount_cents, + max_creditable_amount_cents: model.credit_amount_cents, + max_refundable_amount_cents: model.refund_amount_cents, + coupons_adjustment_amount_cents: model.coupons_adjustment_amount_cents, + precise_coupons_adjustment_amount_cents: model.precise_coupons_adjustment_amount_cents, + taxes_rate: model.taxes_rate + } + + payload.merge!(items) + payload.merge!(applied_taxes) + + payload + end + + def items + { + "items" => model.items.map { |i| {lago_fee_id: i.fee_id, amount_cents: i.amount_cents} } + } + end + + def applied_taxes + collection = ::CollectionSerializer.new( + model.applied_taxes, + ::V1::CreditNotes::AppliedTaxSerializer + ).serialize[:data] + + { + "applied_taxes" => collection.map { |t| t.except(%i[lago_id lago_credit_note_id created_at]) } + } + end + end + end +end diff --git a/app/serializers/v1/credit_notes/payment_provider_refund_error_serializer.rb b/app/serializers/v1/credit_notes/payment_provider_refund_error_serializer.rb new file mode 100644 index 0000000..d2e9e97 --- /dev/null +++ b/app/serializers/v1/credit_notes/payment_provider_refund_error_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module V1 + module CreditNotes + class PaymentProviderRefundErrorSerializer < ModelSerializer + alias_method :credit_note, :model + + def serialize + { + lago_credit_note_id: credit_note.id, + lago_customer_id: credit_note.customer.id, + external_customer_id: credit_note.customer.external_id, + provider_customer_id: options[:provider_customer_id], + payment_provider: credit_note.customer.payment_provider, + payment_provider_code: credit_note.customer.payment_provider_code, + provider_error: options[:provider_error] + } + end + end + end +end diff --git a/app/serializers/v1/credit_serializer.rb b/app/serializers/v1/credit_serializer.rb new file mode 100644 index 0000000..4fca020 --- /dev/null +++ b/app/serializers/v1/credit_serializer.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module V1 + class CreditSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + before_taxes: model.before_taxes, + item: { + lago_item_id: model.item_id, + type: model.item_type, + code: model.item_code, + name: model.item_name, + description: model.item_description + }, + invoice: { + lago_id: model.invoice_id, + payment_status: model.invoice.payment_status + } + } + end + end +end diff --git a/app/serializers/v1/customer_serializer.rb b/app/serializers/v1/customer_serializer.rb new file mode 100644 index 0000000..00ad5b5 --- /dev/null +++ b/app/serializers/v1/customer_serializer.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module V1 + class CustomerSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + billing_entity_code: model.billing_entity.code, + external_id: model.external_id, + account_type: model.account_type, + name: model.name, + firstname: model.firstname, + lastname: model.lastname, + customer_type: model.customer_type, + sequential_id: model.sequential_id, + slug: model.slug, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601, + country: model.country, + address_line1: model.address_line1, + address_line2: model.address_line2, + state: model.state, + zipcode: model.zipcode, + email: model.email, + city: model.city, + url: model.url, + phone: model.phone, + logo_url: model.logo_url, + legal_name: model.legal_name, + legal_number: model.legal_number, + currency: model.currency, + tax_identification_number: model.tax_identification_number, + timezone: model.timezone, + applicable_timezone: model.applicable_timezone, + net_payment_term: model.net_payment_term, + external_salesforce_id: model.external_salesforce_id, + finalize_zero_amount_invoice: model.finalize_zero_amount_invoice, + billing_configuration:, + shipping_address: model.shipping_address, + skip_invoice_custom_sections: model.skip_invoice_custom_sections + } + + payload = payload.merge(metadata) + payload = payload.merge(taxes) if include?(:taxes) + payload = payload.merge(vies_check) if include?(:vies_check) + payload = payload.merge(integration_customers) if include?(:integration_customers) + payload = payload.merge(applicable_invoice_custom_sections) if include?(:applicable_invoice_custom_sections) + payload.merge!(error_details) if include?(:error_details) + + payload + end + + private + + def metadata + ::CollectionSerializer.new( + model.metadata, + ::V1::Customers::MetadataSerializer, + collection_name: "metadata" + ).serialize + end + + def billing_configuration + configuration = { + invoice_grace_period: model.invoice_grace_period, + payment_provider: model.payment_provider, + payment_provider_code: model.payment_provider_code, + document_locale: model.document_locale, + subscription_invoice_issuing_date_anchor: model.subscription_invoice_issuing_date_anchor, + subscription_invoice_issuing_date_adjustment: model.subscription_invoice_issuing_date_adjustment + } + + case model.payment_provider&.to_sym + when :stripe + configuration[:provider_customer_id] = model.stripe_customer&.provider_customer_id + configuration[:provider_payment_methods] = model.stripe_customer&.provider_payment_methods + configuration.merge!(model.stripe_customer&.settings&.symbolize_keys || {}) + when :gocardless + configuration[:provider_customer_id] = model.gocardless_customer&.provider_customer_id + configuration.merge!(model.gocardless_customer&.settings&.symbolize_keys || {}) + when :cashfree + configuration[:provider_customer_id] = model.cashfree_customer&.provider_customer_id + configuration.merge!(model.cashfree_customer&.settings&.symbolize_keys || {}) + when :adyen + configuration[:provider_customer_id] = model.adyen_customer&.provider_customer_id + configuration.merge!(model.adyen_customer&.settings&.symbolize_keys || {}) + when :moneyhash + configuration[:provider_customer_id] = model.moneyhash_customer&.provider_customer_id + configuration.merge!(model.moneyhash_customer&.settings&.symbolize_keys || {}) + end + + configuration + end + + def taxes + ::CollectionSerializer.new(model.taxes, ::V1::TaxSerializer, collection_name: "taxes").serialize + end + + def vies_check + { + vies_check: options.fetch(:vies_check) + } + end + + def integration_customers + ::CollectionSerializer.new( + model.integration_customers, + ::V1::IntegrationCustomerSerializer, + collection_name: "integration_customers" + ).serialize + end + + def applicable_invoice_custom_sections + ::CollectionSerializer.new( + model.applicable_invoice_custom_sections, + ::V1::InvoiceCustomSectionSerializer, + collection_name: "applicable_invoice_custom_sections" + ).serialize + end + + def error_details + ::CollectionSerializer.new( + model.error_details, + ::V1::ErrorDetailSerializer, + collection_name: "error_details" + ).serialize + end + end +end diff --git a/app/serializers/v1/customers/charge_usage_serializer.rb b/app/serializers/v1/customers/charge_usage_serializer.rb new file mode 100644 index 0000000..82baf2c --- /dev/null +++ b/app/serializers/v1/customers/charge_usage_serializer.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module V1 + module Customers + class ChargeUsageSerializer < ModelSerializer + def serialize + model.group_by(&:charge_id).map do |charge_id, fees| + fee = fees.first + usage_data = calculate_usage_data(fees) + + { + **usage_data, + charge: charge_data(fee), + billable_metric: billable_metric_data(fee), + filters: filters(fees), + grouped_usage: grouped_usage(fees), + presentation_breakdowns: PresentationBreakdownBuilder.call(fees, filter: PresentationBreakdownBuilder::UNGROUPED) + } + end + end + + private + + def calculate_usage_data(fees) + { + **current_usage_data(fees), + pricing_unit_details: pricing_unit_details(fees) + } + end + + def current_usage_data(fees) + { + units: current_units(fees).to_s, + total_aggregated_units: total_aggregated_units(fees).to_s, + events_count: fees.sum { |f| f.events_count.to_i }, + amount_cents: fees.sum(&:amount_cents), + amount_currency: fees.first.amount_currency + } + end + + def current_units(fees) + fees.sum { |f| BigDecimal(f.units) } + end + + def total_aggregated_units(fees) + fees.sum { |f| BigDecimal(f.total_aggregated_units || 0) } + end + + def past_usage? + root_name == "past_usage" + end + + def pricing_unit_details(fees) + fees.first.pricing_unit_usage&.then do |pricing_unit| + { + amount_cents: fees.map(&:pricing_unit_usage).compact.sum(&:amount_cents), + short_name: pricing_unit.short_name, + conversion_rate: pricing_unit.conversion_rate + } + end + end + + def charge_data(fee) + { + lago_id: fee.charge_id, + charge_model: fee.charge.charge_model, + invoice_display_name: fee.charge.invoice_display_name + } + end + + def billable_metric_data(fee) + metric = fee.billable_metric + { + lago_id: metric.id, + name: metric.name, + code: metric.code, + aggregation_type: metric.aggregation_type + } + end + + def filters(fees) + return [] unless fees.first.charge&.filters&.any? + + fees.group_by { |f| f.charge_filter&.id } + .values + .filter_map { |grouped_fees| build_filter_data(grouped_fees) } + end + + def build_filter_data(grouped_fees) + charge_filter = grouped_fees.first.charge_filter + usage_data = calculate_usage_data(grouped_fees) + + { + **usage_data.except(:amount_currency), + invoice_display_name: charge_filter&.invoice_display_name, + values: charge_filter&.to_h + } + end + + def grouped_usage(fees) + return [] unless fees.any? { |f| f.grouped_by.present? } + + fees.group_by(&:grouped_by) + .values + .map { |grouped_fees| build_grouped_usage_data(grouped_fees) } + end + + def build_grouped_usage_data(grouped_fees) + usage_data = calculate_usage_data(grouped_fees) + + { + **usage_data.except(:amount_currency), + grouped_by: grouped_fees.first.grouped_by, + filters: filters(grouped_fees), + presentation_breakdowns: PresentationBreakdownBuilder.call(grouped_fees, filter: PresentationBreakdownBuilder::GROUPED) + } + end + end + end +end diff --git a/app/serializers/v1/customers/metadata_serializer.rb b/app/serializers/v1/customers/metadata_serializer.rb new file mode 100644 index 0000000..dbd1136 --- /dev/null +++ b/app/serializers/v1/customers/metadata_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + module Customers + class MetadataSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + key: model.key, + value: model.value, + display_in_invoice: model.display_in_invoice, + created_at: model.created_at.iso8601 + } + end + end + end +end diff --git a/app/serializers/v1/customers/past_usage_serializer.rb b/app/serializers/v1/customers/past_usage_serializer.rb new file mode 100644 index 0000000..ac100ad --- /dev/null +++ b/app/serializers/v1/customers/past_usage_serializer.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module V1 + module Customers + class PastUsageSerializer < ModelSerializer + def serialize + payload = { + from_datetime: invoice_subscription.charges_from_datetime.iso8601, + to_datetime: invoice_subscription.charges_to_datetime.iso8601, + issuing_date: invoice.issuing_date.iso8601, + currency: invoice.currency, + amount_cents: invoice.fees_amount_cents, + total_amount_cents: invoice.fees_amount_cents + taxes_amount_cents, + taxes_amount_cents:, + lago_invoice_id: invoice.id + } + + payload.merge!(charges_usage) if include?(:charges_usage) + payload + end + + private + + delegate :invoice_subscription, :fees, to: :model + delegate :invoice, to: :invoice_subscription + + def taxes_amount_cents + @taxes_amount_cents ||= invoice.fees.sum(:taxes_amount_cents) + end + + def charges_usage + { + charges_usage: ::V1::Customers::ChargeUsageSerializer.new( + fees, + root_name: "past_usage" + ).serialize + } + end + end + end +end diff --git a/app/serializers/v1/customers/presentation_breakdown_builder.rb b/app/serializers/v1/customers/presentation_breakdown_builder.rb new file mode 100644 index 0000000..ba9cebc --- /dev/null +++ b/app/serializers/v1/customers/presentation_breakdown_builder.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module V1 + module Customers + class PresentationBreakdownBuilder + ALL = :all + UNGROUPED = :ungrouped + GROUPED = :grouped + + def self.call(fees, filter:) + new(fees, filter:).call + end + + def initialize(fees, filter:) + @fees = fees + @filter = filter + end + + def call + Array(fees).flat_map do |fee| + next [] if filter == UNGROUPED && fee.grouped_by.present? + next [] if filter == GROUPED && fee.grouped_by.blank? + + fee.presentation_breakdowns.map do |breakdown| + ::V1::PresentationBreakdownSerializer.new(breakdown).serialize + end + end + end + + private + + attr_reader :fees, :filter + end + end +end diff --git a/app/serializers/v1/customers/projected_charge_usage_serializer.rb b/app/serializers/v1/customers/projected_charge_usage_serializer.rb new file mode 100644 index 0000000..c1ce533 --- /dev/null +++ b/app/serializers/v1/customers/projected_charge_usage_serializer.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +module V1 + module Customers + class ProjectedChargeUsageSerializer < ModelSerializer + def serialize + @grouped_data = precompute_groupings + + model.group_by(&:charge_id).map do |charge_id, fees| + fee = fees.first + usage_data = memoized_usage_data(fees) + + { + **usage_data, + charge: charge_data(fee), + billable_metric: billable_metric_data(fee), + filters: cached_filters(fees), + grouped_usage: cached_grouped_usage(fees), + presentation_breakdowns: PresentationBreakdownBuilder.call(fees, filter: PresentationBreakdownBuilder::UNGROUPED) + } + end + end + + private + + def calculate_usage_data(fees) + { + **current_usage_data(fees), + **projected_usage_data(fees), + pricing_unit_details: pricing_unit_details(fees) + } + end + + def current_usage_data(fees) + totals = fees.each_with_object({ + units: BigDecimal(0), + events_count: 0, + amount_cents: 0 + }) do |fee, acc| + acc[:units] += BigDecimal(fee.units) + acc[:events_count] += fee.events_count.to_i + acc[:amount_cents] += fee.amount_cents + end + + { + units: totals[:units].to_s, + events_count: totals[:events_count], + amount_cents: totals[:amount_cents], + amount_currency: fees.first.amount_currency + } + end + + def projected_usage_data(fees) + projection = memoized_projection(fees) + + { + projected_units: projection[:units].to_s, + projected_amount_cents: projection[:amount_cents].to_i + } + end + + def calculate_projection(fees) + if charge_has_filters?(fees) + calculate_filtered_projection(fees) + elsif charge_has_grouping?(fees) + calculate_grouped_projection(fees) + else + calculate_simple_projection(fees) + end + end + + def charge_has_filters?(fees) + fees.first.charge&.filters&.any? + end + + def charge_has_grouping?(fees) + fees.any? { |f| f.grouped_by.present? } + end + + def calculate_filtered_projection(fees) + fees_with_defined_filters = fees.select(&:charge_filter_id) + + fees_with_defined_filters.reduce(initial_projection_values) do |totals, fee| + result = ::Fees::ProjectionService.call!(fees: [fee]) + accumulate_projection(totals, result) + end + end + + def calculate_grouped_projection(fees) + grouped_fees = fees.group_by(&:grouped_by).values + + grouped_fees.reduce(initial_projection_values) do |totals, group_fee_list| + result = ::Fees::ProjectionService.call!(fees: group_fee_list) + accumulate_projection(totals, result) + end + end + + def calculate_simple_projection(fees) + result = ::Fees::ProjectionService.call!(fees: fees) + + { + units: result.projected_units, + amount_cents: result.projected_amount_cents, + pricing_unit_amount_cents: result.projected_pricing_unit_amount_cents.to_i + } + end + + def initial_projection_values + { + units: BigDecimal("0.0"), + amount_cents: 0, + pricing_unit_amount_cents: 0 + } + end + + def accumulate_projection(totals, result) + { + units: totals[:units] + result.projected_units, + amount_cents: totals[:amount_cents] + result.projected_amount_cents, + pricing_unit_amount_cents: totals[:pricing_unit_amount_cents] + result.projected_pricing_unit_amount_cents.to_i + } + end + + def pricing_unit_details(fees) + fees.first.pricing_unit_usage&.then do |pricing_unit| + { + amount_cents: fees.map(&:pricing_unit_usage).compact.sum(&:amount_cents), + projected_amount_cents: projected_pricing_unit_amount_cents(fees), + short_name: pricing_unit.short_name, + conversion_rate: pricing_unit.conversion_rate + } + end + end + + def projected_pricing_unit_amount_cents(fees) + memoized_projection(fees)[:pricing_unit_amount_cents] + end + + def charge_data(fee) + { + lago_id: fee.charge_id, + charge_model: fee.charge.charge_model, + invoice_display_name: fee.charge.invoice_display_name + } + end + + def billable_metric_data(fee) + metric = fee.billable_metric + { + lago_id: metric.id, + name: metric.name, + code: metric.code, + aggregation_type: metric.aggregation_type + } + end + + def filters(fees) + return [] unless fees.first.charge&.filters&.any? + + fees.group_by { |f| f.charge_filter&.id } + .values + .filter_map { |grouped_fees| build_filter_data(grouped_fees) } + end + + def cached_filters(fees) + return [] unless fees.first.charge&.filters&.any? + + @grouped_data[:by_charge_filter] + .values + .filter_map { |grouped_fees| + next unless grouped_fees.first.charge_id == fees.first.charge_id + build_filter_data(grouped_fees) + } + end + + def build_filter_data(grouped_fees) + charge_filter = grouped_fees.first.charge_filter + usage_data = memoized_usage_data(grouped_fees) + + { + **usage_data.except(:amount_currency), + invoice_display_name: charge_filter&.invoice_display_name, + values: charge_filter&.to_h + } + end + + def grouped_usage(fees) + return [] unless fees.any? { |f| f.grouped_by.present? } + + fees.group_by(&:grouped_by) + .values + .map { |grouped_fees| build_grouped_usage_data(grouped_fees) } + end + + def cached_grouped_usage(fees) + return [] unless fees.any? { |f| f.grouped_by.present? } + + @grouped_data[:by_grouped_by] + .values + .filter_map { |grouped_fees| + next unless grouped_fees.first.charge_id == fees.first.charge_id + build_grouped_usage_data(grouped_fees) + } + end + + def build_grouped_usage_data(grouped_fees) + usage_data = memoized_usage_data(grouped_fees) + + { + **usage_data.except(:amount_currency), + grouped_by: grouped_fees.first.grouped_by, + filters: filters(grouped_fees), + presentation_breakdowns: PresentationBreakdownBuilder.call(grouped_fees, filter: PresentationBreakdownBuilder::GROUPED) + } + end + + def precompute_groupings + { + by_charge_filter: model.group_by { |f| f.charge_filter&.id }, + by_grouped_by: model.group_by(&:grouped_by) + } + end + + def memoized_projection(fees) + @projections ||= {} + @projections[fees.object_id] ||= calculate_projection(fees) + end + + def memoized_usage_data(fees) + @usage_data_cache ||= {} + @usage_data_cache[fees.object_id] ||= calculate_usage_data(fees) + end + end + end +end diff --git a/app/serializers/v1/customers/projected_usage_serializer.rb b/app/serializers/v1/customers/projected_usage_serializer.rb new file mode 100644 index 0000000..072bef8 --- /dev/null +++ b/app/serializers/v1/customers/projected_usage_serializer.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module V1 + module Customers + class ProjectedUsageSerializer < ModelSerializer + def serialize + payload = { + from_datetime: model.from_datetime, + to_datetime: model.to_datetime, + issuing_date: model.issuing_date, + currency: model.currency, + amount_cents: model.amount_cents, + projected_amount_cents: projected_amount_cents, + total_amount_cents: model.total_amount_cents, + taxes_amount_cents: model.taxes_amount_cents, + lago_invoice_id: nil + } + + payload.merge!(charges_usage) + payload + end + + def projected_amount_cents + fee_groups = model.fees.group_by(&:charge_id).values + fee_groups.sum do |fee_group| + projection_result = ::Fees::ProjectionService.call(fees: fee_group).raise_if_error! + projection_result.projected_amount_cents + end + end + + private + + def charges_usage + { + charges_usage: ::V1::Customers::ProjectedChargeUsageSerializer.new( + model.fees + ).serialize + } + end + end + end +end diff --git a/app/serializers/v1/customers/usage_serializer.rb b/app/serializers/v1/customers/usage_serializer.rb new file mode 100644 index 0000000..e74432f --- /dev/null +++ b/app/serializers/v1/customers/usage_serializer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module V1 + module Customers + class UsageSerializer < ModelSerializer + def serialize + payload = { + from_datetime: model.from_datetime, + to_datetime: model.to_datetime, + issuing_date: model.issuing_date, + currency: model.currency, + amount_cents: model.amount_cents, + total_amount_cents: model.total_amount_cents, + taxes_amount_cents: model.taxes_amount_cents, + lago_invoice_id: nil + } + + payload.merge!(charges_usage) if include?(:charges_usage) + payload + end + + private + + def charges_usage + { + charges_usage: ::V1::Customers::ChargeUsageSerializer.new(model.fees).serialize + } + end + end + end +end diff --git a/app/serializers/v1/dunning_campaign_finished_serializer.rb b/app/serializers/v1/dunning_campaign_finished_serializer.rb new file mode 100644 index 0000000..054b6f3 --- /dev/null +++ b/app/serializers/v1/dunning_campaign_finished_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module V1 + class DunningCampaignFinishedSerializer < ModelSerializer + def serialize + { + external_customer_id: model.external_id, + dunning_campaign_code: options[:dunning_campaign_code], + overdue_balance_cents: model.overdue_balance_cents, + overdue_balance_currency: model.currency, + + customer_external_id: model.external_id # DEPRECATED + } + end + end +end diff --git a/app/serializers/v1/entitlement/feature_serializer.rb b/app/serializers/v1/entitlement/feature_serializer.rb new file mode 100644 index 0000000..24ce94d --- /dev/null +++ b/app/serializers/v1/entitlement/feature_serializer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module V1 + module Entitlement + class FeatureSerializer < ModelSerializer + def serialize + { + code: model.code, + name: model.name, + description: model.description, + privileges: privileges, + created_at: model.created_at.iso8601 + } + end + + private + + def privileges + model.privileges.map do |privilege| + { + code: privilege.code, + name: privilege.name, + value_type: privilege.value_type, + config: privilege.config + } + end + end + end + end +end diff --git a/app/serializers/v1/entitlement/plan_entitlement_serializer.rb b/app/serializers/v1/entitlement/plan_entitlement_serializer.rb new file mode 100644 index 0000000..640a090 --- /dev/null +++ b/app/serializers/v1/entitlement/plan_entitlement_serializer.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module V1 + module Entitlement + class PlanEntitlementSerializer < ModelSerializer + def serialize + { + code: model.feature.code, + name: model.feature.name, + description: model.feature.description, + privileges: + } + end + + private + + def privileges + model.values.map do |ev| + { + code: ev.privilege.code, + name: ev.privilege.name, + value_type: ev.privilege.value_type, + value: Utils::Entitlement.cast_value(ev.value, ev.privilege.value_type), + config: ev.privilege.config + } + end + end + end + end +end diff --git a/app/serializers/v1/entitlement/subscription_entitlement_serializer.rb b/app/serializers/v1/entitlement/subscription_entitlement_serializer.rb new file mode 100644 index 0000000..42eb6b7 --- /dev/null +++ b/app/serializers/v1/entitlement/subscription_entitlement_serializer.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module V1 + module Entitlement + class SubscriptionEntitlementSerializer < ModelSerializer + def serialize + { + code: model.code, + name: model.name, + description: model.description, + privileges:, + overrides: + } + end + + private + + def privileges + model.privileges.map do + { + code: it.code, + name: it.name, + value_type: it.value_type, + value: Utils::Entitlement.cast_value(it.value, it.value_type), + plan_value: Utils::Entitlement.cast_value(it.plan_value, it.value_type), + override_value: Utils::Entitlement.cast_value(it.subscription_value, it.value_type), + config: it.config + } + end + end + + def overrides + model.privileges.filter_map do + [it.code, Utils::Entitlement.cast_value(it.subscription_value, it.value_type)] if it.subscription_value + end.to_h + end + end + end +end diff --git a/app/serializers/v1/error_detail_serializer.rb b/app/serializers/v1/error_detail_serializer.rb new file mode 100644 index 0000000..fb85b9b --- /dev/null +++ b/app/serializers/v1/error_detail_serializer.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module V1 + class ErrorDetailSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + error_code: model.error_code, + details: model.details + } + end + end +end diff --git a/app/serializers/v1/errors/error_serializer_factory.rb b/app/serializers/v1/errors/error_serializer_factory.rb new file mode 100644 index 0000000..29af103 --- /dev/null +++ b/app/serializers/v1/errors/error_serializer_factory.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module V1 + module Errors + class ErrorSerializerFactory + def self.new_instance(error) + if error.is_a?(::Stripe::StripeError) + V1::Errors::StripeErrorSerializer.new(error) + else + ErrorSerializer.new(error) + end + end + end + end +end diff --git a/app/serializers/v1/errors/stripe_error_serializer.rb b/app/serializers/v1/errors/stripe_error_serializer.rb new file mode 100644 index 0000000..6ba9be9 --- /dev/null +++ b/app/serializers/v1/errors/stripe_error_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + module Errors + class StripeErrorSerializer < ErrorSerializer + def serialize + { + code: error.code, + message: error.message, + request_id: error.request_id, + http_status: error.http_status, + http_body: JSON.parse(error.http_body || "{}") + } + end + end + end +end diff --git a/app/serializers/v1/event_enriched_serializer.rb b/app/serializers/v1/event_enriched_serializer.rb new file mode 100644 index 0000000..dfd6637 --- /dev/null +++ b/app/serializers/v1/event_enriched_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module V1 + class EventEnrichedSerializer < ModelSerializer + def serialize + { + transaction_id: model.transaction_id, + external_subscription_id: model.external_subscription_id, + code: model.code, + timestamp: model.timestamp.iso8601(3), + enriched_at: model.enriched_at&.iso8601(3), + value: model.value, + decimal_value: model.decimal_value.to_s, + precise_total_amount_cents: model.precise_total_amount_cents&.to_s, + properties: model.properties + } + end + end +end diff --git a/app/serializers/v1/event_error_serializer.rb b/app/serializers/v1/event_error_serializer.rb new file mode 100644 index 0000000..c6ef781 --- /dev/null +++ b/app/serializers/v1/event_error_serializer.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module V1 + class EventErrorSerializer < ModelSerializer + def serialize + payload = { + status: 422, + error: "Unprocessable entity", + message: model.error.to_json + } + + payload.merge!(event) + end + + private + + def event + {event: ::V1::EventSerializer.new(model.event).serialize} + end + end +end diff --git a/app/serializers/v1/event_serializer.rb b/app/serializers/v1/event_serializer.rb new file mode 100644 index 0000000..270a372 --- /dev/null +++ b/app/serializers/v1/event_serializer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module V1 + class EventSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + transaction_id: model.transaction_id, + lago_customer_id: model.customer_id, + code: model.code, + timestamp: model.timestamp.iso8601(3), + precise_total_amount_cents: model.precise_total_amount_cents&.to_s, + properties: model.properties, + lago_subscription_id: model.subscription_id, + external_subscription_id: model.external_subscription_id, + created_at: model.created_at&.iso8601 + } + end + end +end diff --git a/app/serializers/v1/events_validation_errors_serializer.rb b/app/serializers/v1/events_validation_errors_serializer.rb new file mode 100644 index 0000000..090bf78 --- /dev/null +++ b/app/serializers/v1/events_validation_errors_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module V1 + class EventsValidationErrorsSerializer < ModelSerializer + def serialize + { + invalid_code: model[:invalid_code], + missing_aggregation_property: model[:missing_aggregation_property], + missing_group_key: model[:missing_group_key], + invalid_filter_values: model[:invalid_filter_values] + } + end + end +end diff --git a/app/serializers/v1/fee_serializer.rb b/app/serializers/v1/fee_serializer.rb new file mode 100644 index 0000000..77cceb5 --- /dev/null +++ b/app/serializers/v1/fee_serializer.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module V1 + class FeeSerializer < ModelSerializer + def serialize + subunit_to_unit = model.amount.currency.subunit_to_unit.to_d + + payload = { + lago_id: model.id, + lago_charge_id: model.charge_id, + lago_charge_filter_id: model.charge_filter_id, + lago_fixed_charge_id: model.fixed_charge_id, + lago_invoice_id: model.invoice_id, + lago_true_up_fee_id: model.true_up_fee&.id, + lago_true_up_parent_fee_id: model.true_up_parent_fee_id, + lago_original_fee_id: model.original_fee_id, + lago_subscription_id: model.subscription_id, + external_subscription_id: model.subscription&.external_id, + lago_customer_id: model.customer&.id, + external_customer_id: model.customer&.external_id, + item: { + type: model.fee_type, + code: model.item_code, + name: model.item_name, + description: model.item_description, + invoice_display_name: model.invoice_name, + filters: model.charge_filter&.to_h, + filter_invoice_display_name: model.charge_filter&.display_name, + lago_item_id: model.item_id, + item_type: model.item_type, + grouped_by: model.grouped_by + }, + pay_in_advance:, + invoiceable:, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + precise_amount: model.precise_amount_cents.fdiv(subunit_to_unit), + precise_total_amount: model.precise_total_amount_cents.fdiv(subunit_to_unit), + taxes_amount_cents: model.taxes_amount_cents, + taxes_precise_amount: model.taxes_precise_amount_cents.fdiv(subunit_to_unit), + taxes_rate: model.taxes_rate, + total_aggregated_units: model.total_aggregated_units, + total_amount_cents: model.total_amount_cents, + total_amount_currency: model.amount_currency, + units: model.units, + description: model.description, + precise_unit_amount: model.precise_unit_amount, + precise_coupons_amount_cents: model.precise_coupons_amount_cents, + sub_total_excluding_taxes_amount_cents: model.sub_total_excluding_taxes_amount_cents.round, + sub_total_excluding_taxes_precise_amount_cents: model.sub_total_excluding_taxes_precise_amount_cents, + events_count: model.events_count, + payment_status: model.payment_status, + created_at: model.created_at&.iso8601, + succeeded_at: model.succeeded_at&.iso8601, + failed_at: model.failed_at&.iso8601, + refunded_at: model.refunded_at&.iso8601, + amount_details: model.amount_details, + self_billed: model.invoice&.self_billed || false, + pricing_unit_details:, + presentation_breakdowns: model.presentation_breakdowns.map { |breakdown| PresentationBreakdownSerializer.new(breakdown).serialize } + } + + payload.merge!(model.date_boundaries) if model.charge? || model.subscription? || model.add_on? || model.fixed_charge? + payload.merge!(pay_in_advance_charge_attributes) if model.pay_in_advance? && model.charge? + payload.merge!(applied_taxes) if include?(:applied_taxes) + + payload + end + + private + + def pay_in_advance_charge_attributes + return {} unless model.pay_in_advance? + + {event_transaction_id: model.pay_in_advance_event_transaction_id} + end + + def applied_taxes + ::CollectionSerializer.new( + model.applied_taxes, + ::V1::Fees::AppliedTaxSerializer, + collection_name: "applied_taxes" + ).serialize + end + + def pay_in_advance + if model.charge? || model.fixed_charge? + model.pay_in_advance + elsif model.subscription? + model.subscription&.plan&.pay_in_advance + else + false + end + end + + def invoiceable + model.charge? ? model.charge&.invoiceable : true + end + + def pricing_unit_details + return if model.pricing_unit_usage.nil? + + ::V1::PricingUnitUsageSerializer.new(model.pricing_unit_usage).serialize + end + end +end diff --git a/app/serializers/v1/fees/applied_tax_serializer.rb b/app/serializers/v1/fees/applied_tax_serializer.rb new file mode 100644 index 0000000..2a6f326 --- /dev/null +++ b/app/serializers/v1/fees/applied_tax_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + module Fees + class AppliedTaxSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_fee_id: model.fee_id, + lago_tax_id: model.tax_id, + tax_name: model.tax_name, + tax_code: model.tax_code, + tax_rate: model.tax_rate, + tax_description: model.tax_description, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + created_at: model.created_at&.iso8601 + } + end + end + end +end diff --git a/app/serializers/v1/fixed_charge_serializer.rb b/app/serializers/v1/fixed_charge_serializer.rb new file mode 100644 index 0000000..5a0dd48 --- /dev/null +++ b/app/serializers/v1/fixed_charge_serializer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module V1 + class FixedChargeSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_add_on_id: model.add_on_id, + code: model.code, + invoice_display_name: model.invoice_display_name, + add_on_code: model.add_on.code, + created_at: model.created_at.iso8601, + charge_model: model.charge_model, + pay_in_advance: model.pay_in_advance, + prorated: model.prorated, + properties: model.properties, + units: model.units, + lago_parent_id: model.parent_id + } + + payload.merge!(taxes) if include?(:taxes) + + payload + end + + private + + def taxes + ::CollectionSerializer.new( + model.taxes, + ::V1::TaxSerializer, + collection_name: "taxes" + ).serialize + end + end +end diff --git a/app/serializers/v1/integration_customer_serializer.rb b/app/serializers/v1/integration_customer_serializer.rb new file mode 100644 index 0000000..d07c479 --- /dev/null +++ b/app/serializers/v1/integration_customer_serializer.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module V1 + class IntegrationCustomerSerializer < ModelSerializer + def serialize + base_response = { + lago_id: model.id, + external_customer_id: model.external_customer_id, + type:, + integration_code: model&.integration&.code + } + + base_response.merge!(model&.settings || {}) + end + + private + + def type + case model.type + when "IntegrationCustomers::NetsuiteCustomer" + "netsuite" + when "IntegrationCustomers::AnrokCustomer" + "anrok" + when "IntegrationCustomers::XeroCustomer" + "xero" + when "IntegrationCustomers::HubspotCustomer" + "hubspot" + when "IntegrationCustomers::SalesforceCustomer" + "salesforce" + end + end + end +end diff --git a/app/serializers/v1/integrations/customer_error_serializer.rb b/app/serializers/v1/integrations/customer_error_serializer.rb new file mode 100644 index 0000000..deb6d56 --- /dev/null +++ b/app/serializers/v1/integrations/customer_error_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + module Integrations + class CustomerErrorSerializer < ModelSerializer + def serialize + { + lago_customer_id: model.id, + external_customer_id: model.external_id, + accounting_provider: options[:provider], + accounting_provider_code: options[:provider_code], + provider_error: options[:provider_error] + } + end + end + end +end diff --git a/app/serializers/v1/integrations/provider_error_serializer.rb b/app/serializers/v1/integrations/provider_error_serializer.rb new file mode 100644 index 0000000..51c4289 --- /dev/null +++ b/app/serializers/v1/integrations/provider_error_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module V1 + module Integrations + class ProviderErrorSerializer < ModelSerializer + def serialize + { + lago_integration_id: model.id, + provider: options[:provider], + provider_code: options[:provider_code], + provider_error: options[:provider_error] + } + end + end + end +end diff --git a/app/serializers/v1/integrations/taxes/customer_error_serializer.rb b/app/serializers/v1/integrations/taxes/customer_error_serializer.rb new file mode 100644 index 0000000..5dc612a --- /dev/null +++ b/app/serializers/v1/integrations/taxes/customer_error_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module V1 + module Integrations + module Taxes + class CustomerErrorSerializer < ModelSerializer + def serialize + { + lago_customer_id: model.id, + external_customer_id: model.external_id, + tax_provider: options[:provider], + tax_provider_code: options[:provider_code], + provider_error: options[:provider_error] + } + end + end + end + end +end diff --git a/app/serializers/v1/integrations/taxes/fee_error_serializer.rb b/app/serializers/v1/integrations/taxes/fee_error_serializer.rb new file mode 100644 index 0000000..0d830ff --- /dev/null +++ b/app/serializers/v1/integrations/taxes/fee_error_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module V1 + module Integrations + module Taxes + class FeeErrorSerializer < ModelSerializer + def serialize + { + tax_provider_code: model.code, + lago_charge_id: options[:lago_charge_id], + event_transaction_id: options[:event_transaction_id], + provider_error: options[:provider_error] + } + end + end + end + end +end diff --git a/app/serializers/v1/invoice_custom_section_serializer.rb b/app/serializers/v1/invoice_custom_section_serializer.rb new file mode 100644 index 0000000..7ea1f78 --- /dev/null +++ b/app/serializers/v1/invoice_custom_section_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + class InvoiceCustomSectionSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + code: model.code, + name: model.name, + description: model.description, + details: model.details, + display_name: model.display_name, + organization_id: model.organization_id + } + end + end +end diff --git a/app/serializers/v1/invoice_serializer.rb b/app/serializers/v1/invoice_serializer.rb new file mode 100644 index 0000000..af5389a --- /dev/null +++ b/app/serializers/v1/invoice_serializer.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module V1 + class InvoiceSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + billing_entity_code: model.billing_entity.code, + sequential_id: model.sequential_id, + number: model.number, + issuing_date: model.issuing_date&.iso8601, + payment_due_date: model.payment_due_date&.iso8601, + net_payment_term: model.net_payment_term, + invoice_type: model.invoice_type, + status: model.status, + payment_status: model.payment_status, + payment_dispute_lost_at: model.payment_dispute_lost_at, + payment_overdue: model.payment_overdue, + currency: model.currency, + fees_amount_cents: model.fees_amount_cents, + taxes_amount_cents: model.taxes_amount_cents, + progressive_billing_credit_amount_cents: model.progressive_billing_credit_amount_cents, + coupons_amount_cents: model.coupons_amount_cents, + credit_notes_amount_cents: model.credit_notes_amount_cents, + sub_total_excluding_taxes_amount_cents: model.sub_total_excluding_taxes_amount_cents, + sub_total_including_taxes_amount_cents: model.sub_total_including_taxes_amount_cents, + total_amount_cents: model.total_amount_cents, + total_due_amount_cents: model.total_due_amount_cents, + total_paid_amount_cents: model.total_paid_amount_cents, + total_offsetted_credit_note_amount_cents: model.offset_amount_cents, + prepaid_credit_amount_cents: model.prepaid_credit_amount_cents, + prepaid_granted_credit_amount_cents: model.prepaid_granted_credit_amount_cents, + prepaid_purchased_credit_amount_cents: model.prepaid_purchased_credit_amount_cents, + file_url: model.file_url, + xml_url: model.xml_url, + version_number: model.version_number, + self_billed: model.self_billed, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601, + voided_at: model.voided_at&.iso8601 + } + + payload.merge!(customer) if include?(:customer) + payload.merge!(subscriptions) if include?(:subscriptions) + payload.merge!(billing_periods) if include?(:billing_periods) + payload.merge!(fees) if include?(:fees) + payload.merge!(credits) if include?(:credits) + payload.merge!(metadata) if include?(:metadata) + payload.merge!(applied_taxes) if include?(:applied_taxes) + payload.merge!(error_details) if include?(:error_details) + payload.merge!(applied_usage_thresholds) if model.progressive_billing? + payload.merge!(applied_invoice_custom_sections) if include?(:applied_invoice_custom_sections) + payload.merge!(preview_subscriptions) if include?(:preview_subscriptions) + payload.merge!(preview_fees) if include?(:preview_fees) + + payload + end + + private + + def customer + { + customer: ::V1::CustomerSerializer.new( + model.customer, + includes: include?(:integration_customers) ? [:integration_customers] : [] + ).serialize + } + end + + def subscriptions + ::CollectionSerializer.new( + model.sorted_invoice_subscriptions.includes(subscription: [:customer, :plan]).map(&:subscription), + ::V1::SubscriptionSerializer, + collection_name: "subscriptions", + organization: model.organization + ).serialize + end + + def preview_subscriptions + ::CollectionSerializer.new( + model.subscriptions, ::V1::SubscriptionSerializer, + collection_name: "subscriptions", + organization: model.organization + ).serialize + end + + def fees + ::CollectionSerializer.new( + model.fees.includes( + [ + :true_up_fee, + :subscription, + :customer, + :charge, + :billable_metric, + :presentation_breakdowns, + {charge_filter: {values: :billable_metric_filter}} + ] + ), + ::V1::FeeSerializer, + collection_name: "fees" + ).serialize + end + + def preview_fees + ::CollectionSerializer.new( + model.fees, ::V1::FeeSerializer, collection_name: "fees" + ).serialize + end + + def credits + ::CollectionSerializer.new(model.credits, ::V1::CreditSerializer, collection_name: "credits").serialize + end + + def metadata + ::CollectionSerializer.new( + model.metadata, + ::V1::Invoices::MetadataSerializer, + collection_name: "metadata" + ).serialize + end + + def applied_taxes + ::CollectionSerializer.new( + model.applied_taxes, + ::V1::Invoices::AppliedTaxSerializer, + collection_name: "applied_taxes" + ).serialize + end + + def error_details + ::CollectionSerializer.new( + model.error_details, + ::V1::ErrorDetailSerializer, + collection_name: "error_details" + ).serialize + end + + def applied_usage_thresholds + ::CollectionSerializer.new( + model.applied_usage_thresholds, + ::V1::AppliedUsageThresholdSerializer, + collection_name: "applied_usage_thresholds" + ).serialize + end + + def applied_invoice_custom_sections + ::CollectionSerializer.new( + model.applied_invoice_custom_sections, + ::V1::Invoices::AppliedInvoiceCustomSectionSerializer, + collection_name: "applied_invoice_custom_sections" + ).serialize + end + + def billing_periods + ::CollectionSerializer.new( + model.sorted_invoice_subscriptions, + ::V1::Invoices::BillingPeriodSerializer, + collection_name: "billing_periods" + ).serialize + end + end +end diff --git a/app/serializers/v1/invoices/applied_invoice_custom_section_serializer.rb b/app/serializers/v1/invoices/applied_invoice_custom_section_serializer.rb new file mode 100644 index 0000000..7894892 --- /dev/null +++ b/app/serializers/v1/invoices/applied_invoice_custom_section_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module V1 + module Invoices + class AppliedInvoiceCustomSectionSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_invoice_id: model.invoice_id, + code: model.code, + details: model.details, + display_name: model.display_name, + created_at: model.created_at.iso8601 + } + end + end + end +end diff --git a/app/serializers/v1/invoices/applied_tax_serializer.rb b/app/serializers/v1/invoices/applied_tax_serializer.rb new file mode 100644 index 0000000..234c4d5 --- /dev/null +++ b/app/serializers/v1/invoices/applied_tax_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module V1 + module Invoices + class AppliedTaxSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_invoice_id: model.invoice_id, + lago_tax_id: model.tax_id, + tax_name: model.tax_name, + tax_code: model.tax_code, + tax_rate: model.tax_rate, + tax_description: model.tax_description, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + fees_amount_cents: model.fees_amount_cents, + created_at: model&.created_at&.iso8601 + } + end + end + end +end diff --git a/app/serializers/v1/invoices/billing_period_serializer.rb b/app/serializers/v1/invoices/billing_period_serializer.rb new file mode 100644 index 0000000..5868f4a --- /dev/null +++ b/app/serializers/v1/invoices/billing_period_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + module Invoices + class BillingPeriodSerializer < ModelSerializer + def serialize + { + lago_subscription_id: model.subscription_id, + external_subscription_id: model.subscription&.external_id, + lago_plan_id: model.subscription&.plan_id, + subscription_from_datetime: model.from_datetime&.iso8601, + subscription_to_datetime: model.to_datetime&.iso8601, + charges_from_datetime: model.charges_from_datetime&.iso8601, + charges_to_datetime: model.charges_to_datetime&.iso8601, + fixed_charges_from_datetime: model.fixed_charges_from_datetime&.iso8601, + fixed_charges_to_datetime: model.fixed_charges_to_datetime&.iso8601, + invoicing_reason: model.invoicing_reason + } + end + end + end +end diff --git a/app/serializers/v1/invoices/metadata_serializer.rb b/app/serializers/v1/invoices/metadata_serializer.rb new file mode 100644 index 0000000..b13e9e5 --- /dev/null +++ b/app/serializers/v1/invoices/metadata_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module V1 + module Invoices + class MetadataSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + key: model.key, + value: model.value, + created_at: model.created_at.iso8601 + } + end + end + end +end diff --git a/app/serializers/v1/invoices/payment_dispute_lost_serializer.rb b/app/serializers/v1/invoices/payment_dispute_lost_serializer.rb new file mode 100644 index 0000000..355a16f --- /dev/null +++ b/app/serializers/v1/invoices/payment_dispute_lost_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module V1 + module Invoices + class PaymentDisputeLostSerializer < ModelSerializer + def serialize + result = {invoice:} + result[:provider_error] = options[:provider_error] if options[:provider_error].present? + result + end + + private + + def invoice + ::V1::InvoiceSerializer.new(model, includes: %i[customer]).serialize + end + end + end +end diff --git a/app/serializers/v1/lifetime_usage_serializer.rb b/app/serializers/v1/lifetime_usage_serializer.rb new file mode 100644 index 0000000..8116666 --- /dev/null +++ b/app/serializers/v1/lifetime_usage_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module V1 + class LifetimeUsageSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_subscription_id: model.subscription_id, + external_subscription_id: model.subscription.external_id, + external_historical_usage_amount_cents: model.historical_usage_amount_cents, + invoiced_usage_amount_cents: model.invoiced_usage_amount_cents, + current_usage_amount_cents: model.current_usage_amount_cents, + from_datetime: model.subscription.subscription_at&.iso8601, + to_datetime: Time.current.iso8601 + } + + payload.merge!(usage_thresholds) if include?(:usage_thresholds) && model.subscription.has_progressive_billing? + payload + end + + private + + def usage_thresholds + result = LifetimeUsages::UsageThresholdsCompletionService.call(lifetime_usage: model).raise_if_error! + {usage_thresholds: result.usage_thresholds.map { |r| r.slice(:amount_cents, :completion_ratio, :reached_at) }} + end + end +end diff --git a/app/serializers/v1/membership_serializer.rb b/app/serializers/v1/membership_serializer.rb new file mode 100644 index 0000000..a5d0c9c --- /dev/null +++ b/app/serializers/v1/membership_serializer.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module V1 + class MembershipSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_user_id: model.user_id, + lago_organization_id: model.organization_id, + roles: model.roles.pluck(:name) + } + end + end +end diff --git a/app/serializers/v1/metadata_serializer.rb b/app/serializers/v1/metadata_serializer.rb new file mode 100644 index 0000000..eb1a41b --- /dev/null +++ b/app/serializers/v1/metadata_serializer.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module V1 + class MetadataSerializer < ModelSerializer + def serialize + model&.value + end + end +end diff --git a/app/serializers/v1/organization_serializer.rb b/app/serializers/v1/organization_serializer.rb new file mode 100644 index 0000000..290a557 --- /dev/null +++ b/app/serializers/v1/organization_serializer.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module V1 + class OrganizationSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + name: model.name, + slug: model.slug, + default_currency: model.default_currency, + created_at: model.created_at.iso8601, + webhook_url: webhook_urls.first.to_s, + webhook_urls:, + country: model.country, + address_line1: model.address_line1, + address_line2: model.address_line2, + state: model.state, + zipcode: model.zipcode, + email: model.email, + city: model.city, + legal_name: model.legal_name, + legal_number: model.legal_number, + timezone: model.timezone, + net_payment_term: model.net_payment_term, + email_settings: model.email_settings, + document_numbering: model.document_numbering, + document_number_prefix: model.document_number_prefix, + tax_identification_number: model.tax_identification_number, + finalize_zero_amount_invoice: model.finalize_zero_amount_invoice, + billing_configuration: + } + + payload = payload.merge(taxes) if include?(:taxes) + + payload + end + + private + + def billing_configuration + { + invoice_footer: model.invoice_footer, + invoice_grace_period: model.invoice_grace_period, + document_locale: model.document_locale + } + end + + def taxes + ::CollectionSerializer.new( + model.taxes.applied_to_organization, + ::V1::TaxSerializer, + collection_name: "taxes" + ).serialize + end + + def webhook_urls + model.webhook_endpoints.map(&:webhook_url) + end + end +end diff --git a/app/serializers/v1/payment_method_serializer.rb b/app/serializers/v1/payment_method_serializer.rb new file mode 100644 index 0000000..5c87884 --- /dev/null +++ b/app/serializers/v1/payment_method_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + class PaymentMethodSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + is_default: model.is_default, + payment_provider_code: model.payment_provider&.code, + payment_provider_name: model.payment_provider&.name, + payment_provider_type: model.payment_provider_type, + provider_method_id: model.provider_method_id, + created_at: model.created_at.iso8601 + } + end + end +end diff --git a/app/serializers/v1/payment_providers/customer_checkout_serializer.rb b/app/serializers/v1/payment_providers/customer_checkout_serializer.rb new file mode 100644 index 0000000..b5815e5 --- /dev/null +++ b/app/serializers/v1/payment_providers/customer_checkout_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class CustomerCheckoutSerializer < ModelSerializer + def serialize + { + lago_customer_id: model.id, + external_customer_id: model.external_id, + payment_provider: model.payment_provider, + payment_provider_code: model.payment_provider_code, + checkout_url: options[:checkout_url] + } + end + end + end +end diff --git a/app/serializers/v1/payment_providers/customer_error_serializer.rb b/app/serializers/v1/payment_providers/customer_error_serializer.rb new file mode 100644 index 0000000..79fe70e --- /dev/null +++ b/app/serializers/v1/payment_providers/customer_error_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class CustomerErrorSerializer < ModelSerializer + def serialize + { + lago_customer_id: model.id, + external_customer_id: model.external_id, + payment_provider: model.payment_provider, + payment_provider_code: model.payment_provider_code, + provider_error: options[:provider_error] + } + end + end + end +end diff --git a/app/serializers/v1/payment_providers/error_serializer.rb b/app/serializers/v1/payment_providers/error_serializer.rb new file mode 100644 index 0000000..25620a6 --- /dev/null +++ b/app/serializers/v1/payment_providers/error_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class ErrorSerializer < ModelSerializer + def serialize + { + lago_payment_provider_id: model.id, + payment_provider_code: model.code, + payment_provider_name: model.name, + source: options[:provider_error][:source], + action: options[:provider_error][:action], + provider_error: options[:provider_error].except(:source, :action) + } + end + end + end +end diff --git a/app/serializers/v1/payment_providers/invoice_payment_error_serializer.rb b/app/serializers/v1/payment_providers/invoice_payment_error_serializer.rb new file mode 100644 index 0000000..08b362a --- /dev/null +++ b/app/serializers/v1/payment_providers/invoice_payment_error_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class InvoicePaymentErrorSerializer < ModelSerializer + alias_method :invoice, :model + + def serialize + { + lago_invoice_id: invoice.id, + lago_customer_id: invoice.customer.id, + external_customer_id: invoice.customer.external_id, + provider_customer_id: options[:provider_customer_id], + payment_provider: invoice.customer.payment_provider, + payment_provider_code: invoice.customer.payment_provider_code, + provider_error: options[:provider_error], + error_details: options[:error_details] + } + end + end + end +end diff --git a/app/serializers/v1/payment_providers/invoice_payment_serializer.rb b/app/serializers/v1/payment_providers/invoice_payment_serializer.rb new file mode 100644 index 0000000..b8aa2c9 --- /dev/null +++ b/app/serializers/v1/payment_providers/invoice_payment_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class InvoicePaymentSerializer < ModelSerializer + def serialize + { + lago_customer_id: model.customer&.id, + external_customer_id: model.customer&.external_id, + payment_provider: model.customer&.payment_provider, + lago_invoice_id: model.id, + payment_url: options[:payment_url] + } + end + end + end +end diff --git a/app/serializers/v1/payment_providers/payment_request_payment_error_serializer.rb b/app/serializers/v1/payment_providers/payment_request_payment_error_serializer.rb new file mode 100644 index 0000000..f62e02d --- /dev/null +++ b/app/serializers/v1/payment_providers/payment_request_payment_error_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class PaymentRequestPaymentErrorSerializer < ModelSerializer + alias_method :payment_request, :model + + def serialize + { + lago_payment_request_id: payment_request.id, + lago_invoice_ids: payment_request.invoice_ids, + lago_customer_id: payment_request.customer.id, + external_customer_id: payment_request.customer.external_id, + provider_customer_id: options[:provider_customer_id], + payment_provider: payment_request.customer.payment_provider, + payment_provider_code: payment_request.customer.payment_provider_code, + provider_error: options[:provider_error] + } + end + end + end +end diff --git a/app/serializers/v1/payment_providers/wallet_transaction_payment_error_serializer.rb b/app/serializers/v1/payment_providers/wallet_transaction_payment_error_serializer.rb new file mode 100644 index 0000000..b806006 --- /dev/null +++ b/app/serializers/v1/payment_providers/wallet_transaction_payment_error_serializer.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class WalletTransactionPaymentErrorSerializer < ModelSerializer + alias_method :wallet_transaction, :model + + def serialize + { + lago_wallet_transaction_id: wallet_transaction.id, + lago_customer_id: customer.id, + external_customer_id: customer.external_id, + provider_customer_id: options[:provider_customer_id], + payment_provider: customer.payment_provider, + payment_provider_code: customer.payment_provider_code, + provider_error: options[:provider_error] + } + end + + private + + def customer + wallet_transaction.wallet.customer + end + end + end +end diff --git a/app/serializers/v1/payment_providers/wallet_transaction_payment_serializer.rb b/app/serializers/v1/payment_providers/wallet_transaction_payment_serializer.rb new file mode 100644 index 0000000..67a4518 --- /dev/null +++ b/app/serializers/v1/payment_providers/wallet_transaction_payment_serializer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module V1 + module PaymentProviders + class WalletTransactionPaymentSerializer < ModelSerializer + def serialize + { + lago_customer_id: customer.id, + external_customer_id: customer.external_id, + payment_provider: customer.payment_provider, + lago_wallet_transaction_id: model.id, + payment_url: options[:payment_url] + } + end + + private + + def customer + @customer ||= model.invoice.customer + end + end + end +end diff --git a/app/serializers/v1/payment_receipt_serializer.rb b/app/serializers/v1/payment_receipt_serializer.rb new file mode 100644 index 0000000..e8a83e4 --- /dev/null +++ b/app/serializers/v1/payment_receipt_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + class PaymentReceiptSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + number: model.reload.number, + file_url: model.file_url, + xml_url: model.xml_url, + payment: payment, + created_at: model.created_at.iso8601 + } + end + + private + + def payment + ::V1::PaymentSerializer.new(model.payment).serialize + end + end +end diff --git a/app/serializers/v1/payment_request_serializer.rb b/app/serializers/v1/payment_request_serializer.rb new file mode 100644 index 0000000..b937634 --- /dev/null +++ b/app/serializers/v1/payment_request_serializer.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module V1 + class PaymentRequestSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + email: model.email, + payment_status: model.payment_status, + created_at: model.created_at.iso8601 + } + + payload.merge!(customer) if include?(:customer) + payload.merge!(invoices) if include?(:invoices) + + payload + end + + private + + def customer + { + customer: ::V1::CustomerSerializer.new(model.customer).serialize + } + end + + def invoices + ::CollectionSerializer.new( + model.invoices, + ::V1::InvoiceSerializer, + collection_name: "invoices" + ).serialize + end + end +end diff --git a/app/serializers/v1/payment_serializer.rb b/app/serializers/v1/payment_serializer.rb new file mode 100644 index 0000000..4f4e914 --- /dev/null +++ b/app/serializers/v1/payment_serializer.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module V1 + class PaymentSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_customer_id: model.payable.customer.id, # TODO: why? + external_customer_id: model.payable.customer.external_id, + invoice_ids: invoice_id, + invoice_numbers: model.invoice_numbers, + lago_payable_id: model.payable.id, + payable_type: model.payable_type, + + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + status: model.status, # TODO: Should be `provider_status` + payment_status: model.payable_payment_status, + type: model.payment_type, + reference: model.reference, + + payment_provider_code: model.payment_provider&.code, + payment_provider_type: model.payment_provider&.type, + external_payment_id: model.provider_payment_id, # DEPRECATED, use provider_payment_id + provider_payment_id: model.provider_payment_id, + provider_customer_id: model.payment_provider_customer&.provider_customer_id, # TODO: remove option? + next_action: model.provider_payment_data, + + created_at: model.created_at.iso8601 + } + + payload.merge!(payment_receipt) if include?(:payment_receipt) + payload.merge!(payment_method) if include?(:payment_method) && model.payment_method + payload + end + + private + + def payment_method + { + payment_method: ::V1::PaymentMethodSerializer.new(model.payment_method).serialize + } + end + + def payment_receipt + { + payment_receipt: model.payment_receipt ? + ::V1::PaymentReceiptSerializer.new(model.payment_receipt).serialize : + {} + } + end + + def invoice_id + model.payable.is_a?(Invoice) ? [model.payable.id] : model.payable.invoice_ids + end + end +end diff --git a/app/serializers/v1/plan_serializer.rb b/app/serializers/v1/plan_serializer.rb new file mode 100644 index 0000000..37b4f3e --- /dev/null +++ b/app/serializers/v1/plan_serializer.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +module V1 + class PlanSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + name: model.name, + invoice_display_name: model.invoice_display_name, + created_at: model.created_at.iso8601, + code: model.code, + interval: model.interval, + description: model.description, + amount_cents: model.amount_cents, + amount_currency: model.amount_currency, + trial_period: model.trial_period, + pay_in_advance: model.pay_in_advance, + bill_charges_monthly: model.bill_charges_monthly, + bill_fixed_charges_monthly: model.bill_fixed_charges_monthly, + customers_count: 0, + active_subscriptions_count: 0, + draft_invoices_count: 0, + parent_id: model.parent_id, # TODO: rename to lago_parent_id + pending_deletion: model.pending_deletion + } + + payload.merge!(charges) if include?(:charges) + payload.merge!(fixed_charges) if include?(:fixed_charges) + payload.merge!(entitlements) if include?(:entitlements) + payload.merge!(usage_thresholds) if include?(:usage_thresholds) + payload.merge!(applicable_usage_thresholds) if include?(:applicable_usage_thresholds) + payload.merge!(taxes) if include?(:taxes) + payload.merge!(minimum_commitment) if include?(:minimum_commitment) && model.minimum_commitment + payload.merge!(metadata) if model.metadata.present? + + payload + end + + private + + def charges + ::CollectionSerializer.new( + model.charges.includes(:applied_pricing_unit, :billable_metric, :taxes), + ::V1::ChargeSerializer, + collection_name: "charges", + includes: include?(:taxes) ? %i[taxes] : [] + ).serialize + end + + def fixed_charges + ::CollectionSerializer.new( + model.fixed_charges, + ::V1::FixedChargeSerializer, + collection_name: "fixed_charges", + includes: include?(:taxes) ? %i[taxes] : [] + ).serialize + end + + def entitlements + ::CollectionSerializer.new( + model.entitlements.includes(:feature, values: :privilege), + ::V1::Entitlement::PlanEntitlementSerializer, + collection_name: "entitlements" + ).serialize + end + + def usage_thresholds + ::CollectionSerializer.new( + model.usage_thresholds, + ::V1::UsageThresholdSerializer, + collection_name: "usage_thresholds" + ).serialize + end + + def applicable_usage_thresholds + ::CollectionSerializer.new( + model.applicable_usage_thresholds, + ::V1::ApplicableUsageThresholdSerializer, + collection_name: "applicable_usage_thresholds" + ).serialize + end + + def minimum_commitment + { + minimum_commitment: V1::CommitmentSerializer.new( + model.minimum_commitment, + includes: include?(:taxes) ? %i[taxes] : [] + ).serialize.except(:commitment_type) + } + end + + def taxes + ::CollectionSerializer.new( + model.taxes, + ::V1::TaxSerializer, + collection_name: "taxes" + ).serialize + end + + def metadata + { + metadata: ::V1::MetadataSerializer.new(model.metadata).serialize + } + end + end +end diff --git a/app/serializers/v1/presentation_breakdown_serializer.rb b/app/serializers/v1/presentation_breakdown_serializer.rb new file mode 100644 index 0000000..8efab17 --- /dev/null +++ b/app/serializers/v1/presentation_breakdown_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module V1 + class PresentationBreakdownSerializer < ModelSerializer + def serialize + { + presentation_by: model.presentation_by, + units: model.units.to_s + } + end + end +end diff --git a/app/serializers/v1/pricing_unit_usage_serializer.rb b/app/serializers/v1/pricing_unit_usage_serializer.rb new file mode 100644 index 0000000..c94828c --- /dev/null +++ b/app/serializers/v1/pricing_unit_usage_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module V1 + class PricingUnitUsageSerializer < ModelSerializer + def serialize + { + lago_pricing_unit_id: model.pricing_unit_id, + pricing_unit_code: model.pricing_unit.code, + short_name: model.short_name, + amount_cents: model.amount_cents, + precise_amount_cents: model.precise_amount_cents, + unit_amount_cents: model.unit_amount_cents, + precise_unit_amount: model.precise_unit_amount, + conversion_rate: model.conversion_rate + } + end + end +end diff --git a/app/serializers/v1/security_log_serializer.rb b/app/serializers/v1/security_log_serializer.rb new file mode 100644 index 0000000..e76b95f --- /dev/null +++ b/app/serializers/v1/security_log_serializer.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module V1 + class SecurityLogSerializer < ModelSerializer + def serialize + { + log_id: model.log_id, + log_type: model.log_type, + log_event: model.log_event, + user_email: model.user&.email, + logged_at: model.logged_at.iso8601, + created_at: model.created_at.iso8601, + resources: model.resources, + device_info: model.device_info + } + end + end +end diff --git a/app/serializers/v1/subscription_serializer.rb b/app/serializers/v1/subscription_serializer.rb new file mode 100644 index 0000000..b9ec888 --- /dev/null +++ b/app/serializers/v1/subscription_serializer.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module V1 + class SubscriptionSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + external_id: model.external_id, + lago_customer_id: model.customer_id, + external_customer_id: model.customer.external_id, + name: model.name, + plan_code: model.plan.code, + plan_amount_cents: model.plan.amount_cents, + plan_amount_currency: model.plan.amount_currency, + status: model.status, + billing_time: model.billing_time, + subscription_at: model.subscription_at&.iso8601, + started_at: model.started_at&.iso8601(3), + trial_ended_at: model.trial_ended_at&.iso8601, + ending_at: model.ending_at&.iso8601, + terminated_at: model.terminated_at&.iso8601, + canceled_at: model.canceled_at&.iso8601, + created_at: model.created_at.iso8601, + previous_plan_code: model.previous_subscription&.plan&.code, + next_plan_code: model.next_subscription&.plan&.code, + downgrade_plan_date: model.downgrade_plan_date&.iso8601, + current_billing_period_started_at: dates_service.charges_from_datetime&.iso8601, + current_billing_period_ending_at: dates_service.charges_to_datetime&.iso8601, + on_termination_credit_note: model.on_termination_credit_note, + on_termination_invoice: model.on_termination_invoice, + progressive_billing_disabled: model.progressive_billing_disabled + } + + payload = payload.merge(customer:) if include?(:customer) + payload = payload.merge(entitlements) if include?(:entitlements) + payload = payload.merge(payment_method) + payload = payload.merge(plan:) if include?(:plan) + payload = payload.merge(usage_threshold:) if include?(:usage_threshold) + payload = payload.merge(applicable_usage_thresholds) if include?(:applicable_usage_thresholds) + payload = payload.merge(applied_invoice_custom_sections) if include?(:applied_invoice_custom_sections) + + if organization.feature_flag_enabled?(:payment_gated_subscriptions) + payload[:cancelation_reason] = model.cancelation_reason + payload[:activated_at] = model.activated_at&.iso8601 + payload[:activation_rules] = model.activation_rules.map do |rule| + ::V1::Subscriptions::ActivationRuleSerializer.new(rule).serialize + end + end + + payload + end + + private + + def organization + options[:organization] || model.organization + end + + def customer + ::V1::CustomerSerializer.new(model.customer).serialize + end + + def entitlements + ::CollectionSerializer.new( + ::Entitlement::SubscriptionEntitlement.for_subscription(model), + ::V1::Entitlement::SubscriptionEntitlementSerializer, + collection_name: "entitlements" + ).serialize + end + + def plan + ::V1::PlanSerializer.new( + model.plan, + includes: included_relations( + :plan, + default: %i[charges usage_thresholds applicable_usage_thresholds taxes minimum_commitment] + ) + ).serialize + end + + # NOTE: This attribute is only used when sending the `subscription.usage_threshold_reached` webhook + # Ideally, this shouldn't even be part of the `subscription` object + def usage_threshold + ::V1::UsageThresholdSerializer.new(options[:usage_threshold]).serialize + end + + def dates_service + @dates_service ||= ::Subscriptions::DatesService.new_instance(model, Time.current, current_usage: true) + end + + def applicable_usage_thresholds + ::CollectionSerializer.new( + model.applicable_usage_thresholds, + ::V1::ApplicableUsageThresholdSerializer, + collection_name: "applicable_usage_thresholds" + ).serialize + end + + def applied_invoice_custom_sections + ::CollectionSerializer.new( + model.applied_invoice_custom_sections, + ::V1::AppliedInvoiceCustomSectionSerializer, + collection_name: "applied_invoice_custom_sections" + ).serialize + end + + def payment_method + { + payment_method: { + payment_method_id: model.payment_method_id, + payment_method_type: model.payment_method_type + } + } + end + end +end diff --git a/app/serializers/v1/subscriptions/activation_rule_serializer.rb b/app/serializers/v1/subscriptions/activation_rule_serializer.rb new file mode 100644 index 0000000..41fd5f7 --- /dev/null +++ b/app/serializers/v1/subscriptions/activation_rule_serializer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module V1 + module Subscriptions + class ActivationRuleSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + type: model.class.sti_name, + timeout_hours: model.timeout_hours, + status: model.status, + expires_at: model.expires_at&.iso8601, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601 + } + end + end + end +end diff --git a/app/serializers/v1/tax_serializer.rb b/app/serializers/v1/tax_serializer.rb new file mode 100644 index 0000000..8fd5a49 --- /dev/null +++ b/app/serializers/v1/tax_serializer.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module V1 + class TaxSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + name: model.name, + code: model.code, + rate: model.rate, + description: model.description, + applied_to_organization: model.applied_to_organization, + add_ons_count: 0, + customers_count: 0, + plans_count: 0, + charges_count: 0, + commitments_count: 0, + created_at: model.created_at.iso8601 + } + end + end +end diff --git a/app/serializers/v1/usage_monitoring/alert_serializer.rb b/app/serializers/v1/usage_monitoring/alert_serializer.rb new file mode 100644 index 0000000..567e726 --- /dev/null +++ b/app/serializers/v1/usage_monitoring/alert_serializer.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module V1 + module UsageMonitoring + class AlertSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_organization_id: model.organization_id, + subscription_external_id: model.subscription_external_id, # DEPRECATED + external_subscription_id: model.subscription_external_id, + lago_wallet_id: model.wallet_id, + wallet_code: model.wallet&.code, + alert_type: model.alert_type, + code: model.code, + name: model.name, + direction: model.direction, + previous_value: model.previous_value, + last_processed_at: model.last_processed_at&.iso8601, + thresholds: formatted_thresholds, + created_at: model.created_at&.iso8601, + billable_metric: model.billable_metric_id ? billable_metric : nil + } + end + + private + + def formatted_thresholds + model.thresholds.map do |threshold| + { + code: threshold.code, + value: threshold.value, + recurring: threshold.recurring + } + end + end + + def billable_metric + ::V1::BillableMetricSerializer.new(model.billable_metric).serialize + end + end + end +end diff --git a/app/serializers/v1/usage_monitoring/triggered_alert_serializer.rb b/app/serializers/v1/usage_monitoring/triggered_alert_serializer.rb new file mode 100644 index 0000000..fed7742 --- /dev/null +++ b/app/serializers/v1/usage_monitoring/triggered_alert_serializer.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module V1 + module UsageMonitoring + class TriggeredAlertSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_organization_id: model.organization_id, + lago_alert_id: alert.id, + lago_subscription_id: model.subscription_id, + external_subscription_id: alert.subscription_external_id, + lago_wallet_id: model.wallet_id, + # TODO: Add wallet_code once wallet `code` attribute is added + external_customer_id:, + billable_metric_code: alert.billable_metric&.code, + alert_name: alert.name, + alert_code: alert.code, + alert_type: alert.alert_type, + current_value: model.current_value, + previous_value: model.previous_value, + crossed_thresholds: model.crossed_thresholds, + triggered_at: model.triggered_at.iso8601, + + subscription_external_id: alert.subscription_external_id, # DEPRECATED + customer_external_id: external_customer_id # DEPRECATED + } + end + + private + + delegate :alert, to: :model + + def external_customer_id + if model.subscription + model.subscription.customer.external_id + elsif model.wallet + model.wallet.customer.external_id + end + end + end + end +end diff --git a/app/serializers/v1/usage_threshold_serializer.rb b/app/serializers/v1/usage_threshold_serializer.rb new file mode 100644 index 0000000..9168ab9 --- /dev/null +++ b/app/serializers/v1/usage_threshold_serializer.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module V1 + class UsageThresholdSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + threshold_display_name: model.threshold_display_name, + amount_cents: model.amount_cents, + recurring: model.recurring, + created_at: model.created_at.iso8601, + updated_at: model.updated_at.iso8601 + } + end + end +end diff --git a/app/serializers/v1/wallet_serializer.rb b/app/serializers/v1/wallet_serializer.rb new file mode 100644 index 0000000..4a93195 --- /dev/null +++ b/app/serializers/v1/wallet_serializer.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module V1 + class WalletSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_customer_id: model.customer_id, + external_customer_id: model.customer.external_id, + status: model.status, + currency: model.currency, + name: model.name, + code: model.code, + rate_amount: model.rate_amount, + credits_balance: model.credits_balance, + credits_ongoing_balance: model.credits_ongoing_balance, + credits_ongoing_usage_balance: model.credits_ongoing_usage_balance, + balance_cents: model.balance_cents, + ongoing_balance_cents: model.ongoing_balance_cents, + ongoing_usage_balance_cents: model.ongoing_usage_balance_cents, + consumed_credits: model.consumed_credits, + created_at: model.created_at&.iso8601, + expiration_at: model.expiration_at&.iso8601, + last_balance_sync_at: model.last_balance_sync_at&.iso8601, + last_consumed_credit_at: model.last_consumed_credit_at&.iso8601, + terminated_at: model.terminated_at, + invoice_requires_successful_payment: model.invoice_requires_successful_payment?, + paid_top_up_min_amount_cents: model.paid_top_up_min_amount_cents, + paid_top_up_max_amount_cents: model.paid_top_up_max_amount_cents, + priority: model.priority + } + + payload.merge!(recurring_transaction_rules) if include?(:recurring_transaction_rules) + payload.merge!(limitations) if include?(:limitations) + payload.merge!(applied_invoice_custom_sections) if include?(:applied_invoice_custom_sections) + payload.merge!(payment_method) + payload.merge!(metadata) if model.metadata.present? + + payload + end + + private + + def recurring_transaction_rules + ::CollectionSerializer.new( + active_recurring_transaction_rules, + ::V1::Wallets::RecurringTransactionRuleSerializer, + collection_name: "recurring_transaction_rules" + ).serialize + end + + def active_recurring_transaction_rules + model.recurring_transaction_rules.select(&:currently_active?) + end + + def limitations + { + applies_to: { + fee_types: model.allowed_fee_types, + billable_metric_codes: model.billable_metrics.pluck(:code) + } + } + end + + def payment_method + { + payment_method: { + payment_method_id: model.payment_method_id, + payment_method_type: model.payment_method_type + } + } + end + + def applied_invoice_custom_sections + ::CollectionSerializer.new( + model.applied_invoice_custom_sections, + ::V1::AppliedInvoiceCustomSectionSerializer, + collection_name: "applied_invoice_custom_sections" + ).serialize + end + + def metadata + { + metadata: ::V1::MetadataSerializer.new(model.metadata).serialize + } + end + end +end diff --git a/app/serializers/v1/wallet_transaction_consumption_serializer.rb b/app/serializers/v1/wallet_transaction_consumption_serializer.rb new file mode 100644 index 0000000..10d4afd --- /dev/null +++ b/app/serializers/v1/wallet_transaction_consumption_serializer.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module V1 + class WalletTransactionConsumptionSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + amount_cents: model.consumed_amount_cents, + credit_amount: model.credit_amount, + created_at: model.created_at.iso8601 + } + + payload.merge!(wallet_transaction(:inbound)) if include?(:inbound_wallet_transaction) + payload.merge!(wallet_transaction(:outbound)) if include?(:outbound_wallet_transaction) + + payload + end + + private + + def wallet_transaction(direction) + transaction = (direction == :inbound) ? model.inbound_wallet_transaction : model.outbound_wallet_transaction + { + wallet_transaction: ::V1::WalletTransactionSerializer.new(transaction).serialize + } + end + end +end diff --git a/app/serializers/v1/wallet_transaction_serializer.rb b/app/serializers/v1/wallet_transaction_serializer.rb new file mode 100644 index 0000000..03c51a6 --- /dev/null +++ b/app/serializers/v1/wallet_transaction_serializer.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module V1 + class WalletTransactionSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + lago_wallet_id: model.wallet_id, + lago_invoice_id: model.invoice_id, + lago_credit_note_id: model.credit_note_id, + lago_voided_invoice_id: model.voided_invoice_id, + status: model.status, + source: model.source, + transaction_status: model.transaction_status, + transaction_type: model.transaction_type, + amount: model.amount, + credit_amount: model.credit_amount, + remaining_amount_cents: model.remaining_amount_cents, + remaining_credit_amount: model.remaining_credit_amount, + priority: model.priority, + settled_at: model.settled_at&.iso8601, + failed_at: model.failed_at&.iso8601, + created_at: model.created_at.iso8601, + invoice_requires_successful_payment: model.invoice_requires_successful_payment?, + metadata: model.metadata, + name: model.name + } + + payload.merge!(wallet) if include?(:wallet) + payload.merge!(applied_invoice_custom_sections) if include?(:applied_invoice_custom_sections) + payload.merge!(payment_method) + + payload + end + + private + + def wallet + { + wallet: ::V1::WalletSerializer.new(model.wallet).serialize + } + end + + def applied_invoice_custom_sections + ::CollectionSerializer.new( + model.applied_invoice_custom_sections, + ::V1::AppliedInvoiceCustomSectionSerializer, + collection_name: "applied_invoice_custom_sections" + ).serialize + end + + def payment_method + { + payment_method: { + payment_method_id: model.payment_method_id, + payment_method_type: model.payment_method_type + } + } + end + end +end diff --git a/app/serializers/v1/wallets/recurring_transaction_rule_serializer.rb b/app/serializers/v1/wallets/recurring_transaction_rule_serializer.rb new file mode 100644 index 0000000..8f28707 --- /dev/null +++ b/app/serializers/v1/wallets/recurring_transaction_rule_serializer.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module V1 + module Wallets + class RecurringTransactionRuleSerializer < ModelSerializer + def serialize + payload = { + lago_id: model.id, + paid_credits: model.paid_credits, + granted_credits: model.granted_credits, + interval: model.interval, + method: model.method, + started_at: model.started_at&.iso8601, + expiration_at: model.expiration_at&.iso8601, + status: model.status, + target_ongoing_balance: model.target_ongoing_balance, + threshold_credits: model.threshold_credits, + trigger: model.trigger, + created_at: model.created_at.iso8601, + invoice_requires_successful_payment: model.invoice_requires_successful_payment?, + transaction_metadata: model.transaction_metadata, + transaction_name: model.transaction_name, + ignore_paid_top_up_limits: model.ignore_paid_top_up_limits + } + + payload.merge!(applied_invoice_custom_sections) + payload.merge!(payment_method) + + payload + end + + private + + def applied_invoice_custom_sections + ::CollectionSerializer.new( + model.applied_invoice_custom_sections, + ::V1::AppliedInvoiceCustomSectionSerializer, + collection_name: "applied_invoice_custom_sections" + ).serialize + end + + def payment_method + { + payment_method: { + payment_method_id: model.payment_method_id, + payment_method_type: model.payment_method_type + } + } + end + end + end +end diff --git a/app/serializers/v1/webhook_endpoint_serializer.rb b/app/serializers/v1/webhook_endpoint_serializer.rb new file mode 100644 index 0000000..ec4fc31 --- /dev/null +++ b/app/serializers/v1/webhook_endpoint_serializer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module V1 + class WebhookEndpointSerializer < ModelSerializer + def serialize + { + lago_id: model.id, + lago_organization_id: model.organization_id, + webhook_url: model.webhook_url, + signature_algo: model.signature_algo, + name: model.name, + event_types: model.event_types, + created_at: model.created_at.iso8601 + } + end + end +end diff --git a/app/services/add_ons/apply_taxes_service.rb b/app/services/add_ons/apply_taxes_service.rb new file mode 100644 index 0000000..fdd6298 --- /dev/null +++ b/app/services/add_ons/apply_taxes_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module AddOns + class ApplyTaxesService < BaseService + Result = BaseResult[:applied_taxes] + + def initialize(add_on:, tax_codes:) + @add_on = add_on + @tax_codes = tax_codes + + super + end + + def call + return result.not_found_failure!(resource: "add_on") unless add_on + return result.not_found_failure!(resource: "tax") if (tax_codes - taxes.pluck(:code)).present? + + add_on.applied_taxes.where( + tax_id: add_on.taxes.where.not(code: tax_codes).pluck(:id) + ).destroy_all + + result.applied_taxes = tax_codes.map do |tax_code| + add_on.applied_taxes + .create_with(organization: add_on.organization) + .find_or_create_by!(tax: taxes.find_by(code: tax_code)) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :add_on, :tax_codes + + def taxes + @taxes ||= add_on.organization.taxes.where(code: tax_codes) + end + end +end diff --git a/app/services/add_ons/create_service.rb b/app/services/add_ons/create_service.rb new file mode 100644 index 0000000..c5c53bb --- /dev/null +++ b/app/services/add_ons/create_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module AddOns + class CreateService < BaseService + Result = BaseResult[:add_on] + + def initialize(args) + @args = args + super + end + + def call + ActiveRecord::Base.transaction do + add_on = AddOn.create!( + organization_id: args[:organization_id], + name: args[:name], + invoice_display_name: args[:invoice_display_name], + code: args[:code], + description: args[:description], + amount_cents: args[:amount_cents], + amount_currency: args[:amount_currency] + ) + + if args[:tax_codes] + taxes_result = AddOns::ApplyTaxesService.call(add_on:, tax_codes: args[:tax_codes]) + taxes_result.raise_if_error! + end + + result.add_on = add_on + end + + track_add_on_created(result.add_on) + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :args + + def track_add_on_created(add_on) + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "add_on_created", + properties: { + addon_code: add_on.code, + addon_name: add_on.name, + addon_invoice_display_name: add_on.invoice_display_name, + organization_id: add_on.organization_id + } + ) + end + end +end diff --git a/app/services/add_ons/destroy_service.rb b/app/services/add_ons/destroy_service.rb new file mode 100644 index 0000000..afaba0a --- /dev/null +++ b/app/services/add_ons/destroy_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module AddOns + class DestroyService < BaseService + Result = BaseResult[:add_on] + + def initialize(add_on:) + @add_on = add_on + super + end + + def call + return result.not_found_failure!(resource: "add_on") unless add_on + + ActiveRecord::Base.transaction do + add_on.discard! + add_on.fixed_charges.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + result.add_on = add_on + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :add_on + end +end diff --git a/app/services/add_ons/update_service.rb b/app/services/add_ons/update_service.rb new file mode 100644 index 0000000..e50da2f --- /dev/null +++ b/app/services/add_ons/update_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module AddOns + class UpdateService < BaseService + Result = BaseResult[:add_on] + + def initialize(add_on:, params:) + @add_on = add_on + @params = params + super + end + + def call + return result.not_found_failure!(resource: "add_on") unless add_on + + add_on.name = params[:name] if params.key?(:name) + add_on.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + add_on.description = params[:description] if params.key?(:description) + add_on.code = params[:code] if params.key?(:code) + add_on.amount_cents = params[:amount_cents] if params.key?(:amount_cents) + add_on.amount_currency = params[:amount_currency] if params.key?(:amount_currency) + + ActiveRecord::Base.transaction do + add_on.save! + + if params.key?(:tax_codes) + taxes_result = AddOns::ApplyTaxesService.call(add_on:, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + end + + result.add_on = add_on + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :add_on, :params + end +end diff --git a/app/services/adjusted_fees/create_service.rb b/app/services/adjusted_fees/create_service.rb new file mode 100644 index 0000000..127deac --- /dev/null +++ b/app/services/adjusted_fees/create_service.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +module AdjustedFees + class CreateService < BaseService + Result = BaseResult[:fee, :adjusted_fee] + + # regenerating_voided - used when regenerating fees from a voided invoice into a new invoice (Invoices::RegenerateFromVoidedService); + # if true, skips refreshing draft invoice and license check + def initialize(invoice:, params:, regenerating_voided: false) + @invoice = invoice + @organization = invoice.organization + @params = params + @regenerating_voided = regenerating_voided + + super + end + + def call + return result.forbidden_failure! if forbidden? + + fee = find_or_create_fee + return result unless result.success? + return result.validation_failure!(errors: {adjusted_fee: ["already_exists"]}) if fee.adjusted_fee + + charge = fee.charge + return result.validation_failure!(errors: {charge: ["invalid_charge_model"]}) if disabled_charge_model?(charge) + + unit_precise_amount_cents = params[:unit_precise_amount].to_f * fee.amount.currency.subunit_to_unit + adjusted_fee = AdjustedFee.new( + fee:, + invoice: fee.invoice, + subscription: fee.subscription, + charge: fee.charge, + fixed_charge: fee.fixed_charge, + adjusted_units: params[:units].present? && params[:unit_precise_amount].blank?, + adjusted_amount: params[:units].present? && params[:unit_precise_amount].present?, + invoice_display_name: params[:invoice_display_name], + fee_type: fee.fee_type, + properties: fee.properties, + units: params[:units].presence || 0, + unit_amount_cents: unit_precise_amount_cents.round, + unit_precise_amount_cents: unit_precise_amount_cents, + grouped_by: fee.grouped_by, + charge_filter: fee.charge_filter, + organization: + ) + adjusted_fee.save! + + subscription_id = fee.subscription_id + charge_id = fee.charge_id + fixed_charge_id = fee.fixed_charge_id + charge_filter_id = fee.charge_filter_id + + unless regenerating_voided + refresh_result = Invoices::RefreshDraftService.call(invoice: invoice) + refresh_result.raise_if_error! + end + + result.adjusted_fee = adjusted_fee.reload + result.fee = if fixed_charge_id + invoice.fees.find_by(subscription_id:, fixed_charge_id:) + else + invoice.fees.find_by(subscription_id:, charge_id:, charge_filter_id:) + end + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :invoice, :params, :regenerating_voided + + def subscription + @subscription ||= invoice.subscriptions.includes(plan: {charges: :filters, fixed_charges: nil}).find_by(id: params[:subscription_id]) + end + + def forbidden? + return false if regenerating_voided + + !License.premium? || !invoice.draft? + end + + def find_or_create_fee + return find_existing_fee if params.key?(:fee_id) + + create_empty_fee + end + + def find_existing_fee + fee = invoice.fees.find_by(id: params[:fee_id]) + return result.not_found_failure!(resource: "fee") if fee.blank? + + fee + end + + def create_empty_fee + return result.not_found_failure!(resource: "subscription") unless subscription + return create_empty_fee_for_fixed_charge if params[:fixed_charge_id].present? + + charge = subscription.plan.charges.find_by(id: params[:charge_id]) + return result.not_found_failure!(resource: "charge") unless charge + + if params[:charge_filter_id].present? + charge_filter = charge.filters.find_by(id: params[:charge_filter_id]) + return result.not_found_failure!(resource: "charge_filter") unless charge_filter + end + + fee = invoice.fees.find_by( + subscription_id: subscription.id, + charge_id: charge.id, + charge_filter_id: params[:charge_filter_id] + ) + fee || create_fee(subscription, charge, :charge) + end + + def create_empty_fee_for_fixed_charge + return result.not_found_failure!(resource: "subscription") unless subscription + + fixed_charge = subscription.plan.fixed_charges.find_by(id: params[:fixed_charge_id]) + return result.not_found_failure!(resource: "fixed_charge") unless fixed_charge + + fee = invoice.fees.find_by( + subscription_id: subscription.id, + fixed_charge_id: fixed_charge.id + ) + fee || create_fee(subscription, fixed_charge, :fixed_charge) + end + + def create_fee(subscription, chargeable, fee_type) + invoice_subscription = invoice.invoice_subscriptions.find_by(subscription_id: subscription.id) + + Fee.create!( + organization:, + billing_entity_id: invoice.billing_entity_id, + invoice:, + subscription:, + invoiceable: chargeable, + charge: (chargeable if fee_type == :charge), + fixed_charge: (chargeable if fee_type == :fixed_charge), + charge_filter_id: params[:charge_filter_id], + grouped_by: {}, + fee_type:, + payment_status: :pending, + events_count: 0, + amount_currency: invoice.currency, + amount_cents: 0, + precise_amount_cents: 0.to_d, + unit_amount_cents: 0, + precise_unit_amount: 0.to_d, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + units: 0, + total_aggregated_units: 0, + properties: fee_boundaries(invoice_subscription, fee_type) + ) + end + + def fee_boundaries(invoice_subscription, fee_type) + base = {timestamp: invoice_subscription.timestamp} + + if fee_type == :charge + base.merge( + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime + ) + else + base.merge( + fixed_charges_from_datetime: invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: invoice_subscription.fixed_charges_to_datetime + ) + end + end + + def disabled_charge_model?(charge) + return false unless charge + return false unless unit_adjustment? + + charge.percentage? || (charge.prorated? && charge.graduated?) + end + + def unit_adjustment? + params[:units].present? && params[:unit_precise_amount].blank? + end + end +end diff --git a/app/services/adjusted_fees/destroy_service.rb b/app/services/adjusted_fees/destroy_service.rb new file mode 100644 index 0000000..ad40f7a --- /dev/null +++ b/app/services/adjusted_fees/destroy_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module AdjustedFees + class DestroyService < BaseService + Result = BaseResult[:fee] + + def initialize(fee:) + @fee = fee + + super + end + + def call + return result.not_found_failure!(resource: "fee") unless fee + return result.not_found_failure!(resource: "adjusted_fee") unless fee.adjusted_fee + + fee.adjusted_fee.destroy! + + refresh_result = Invoices::RefreshDraftService.call(invoice: fee.invoice) + refresh_result.raise_if_error! + + result.fee = fee + result + end + + private + + attr_reader :fee + end +end diff --git a/app/services/adjusted_fees/estimate_service.rb b/app/services/adjusted_fees/estimate_service.rb new file mode 100644 index 0000000..d4bc2dd --- /dev/null +++ b/app/services/adjusted_fees/estimate_service.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +module AdjustedFees + class EstimateService < BaseService + Result = BaseResult[:fee, :adjusted_fee] + + def initialize(invoice:, params:) + @invoice = invoice + @organization = invoice.organization + @params = params + + super + end + + def call + fee = find_or_initialize_fee + return result if result.failure? + + return result.validation_failure!(errors: {charge: ["invalid_charge_model"]}) if disabled_charge_model?(fee.charge) + + adjusted_fee = initialize_adjusted_fee(fee, params) + + estimated_fee = if fee.fee_type == "subscription" + adjust_subscription_fee(fee, adjusted_fee) + elsif fee.fee_type == "fixed_charge" + init_from_fixed_charge_fee(adjusted_fee) + else + init_from_charge_fee(adjusted_fee) + end + + apply_taxes_and_assign_ids(estimated_fee) unless customer.tax_customer + result.fee = estimated_fee + result + end + + private + + attr_reader :organization, :invoice, :params + + def apply_taxes_and_assign_ids(fee) + Fees::ApplyTaxesService.call!(fee:) + fee.applied_taxes.each { |tax| tax.id = SecureRandom.uuid } + end + + def init_from_charge_fee(adjusted_fee) + properties = adjusted_fee.charge_filter&.properties || adjusted_fee.charge.properties + + result = Fees::InitFromAdjustedChargeFeeService.call( + adjusted_fee:, + boundaries: adjusted_fee.properties, + properties: + ) + + result.fee.id = SecureRandom.uuid + result.fee + end + + def init_from_fixed_charge_fee(adjusted_fee) + result = Fees::InitFromAdjustedFixedChargeFeeService.call( + adjusted_fee:, + boundaries: adjusted_fee.properties, + properties: adjusted_fee.fixed_charge.properties + ) + + result.fee.id = SecureRandom.uuid + result.fee + end + + def adjust_subscription_fee(fee, adjusted_fee) + if adjusted_fee.adjusted_display_name? + fee.invoice_display_name = adjusted_fee.invoice_display_name + return fee + end + + units = adjusted_fee.units + subunit = invoice.total_amount.currency.subunit_to_unit + + if adjusted_fee.adjusted_units? + unit_cents = fee.unit_amount_cents + amount_cents = (units * unit_cents).round + precise_unit_amount = unit_cents.to_f / subunit + else + unit_cents = adjusted_fee.unit_precise_amount_cents + amount_cents = (units * unit_cents).round + precise_unit_amount = unit_cents / subunit + end + + fee.units = units + fee.unit_amount_cents = unit_cents.round + fee.precise_unit_amount = precise_unit_amount + fee.amount_cents = amount_cents + fee.precise_amount_cents = units * unit_cents + fee.invoice_display_name = adjusted_fee.invoice_display_name if params[:invoice_display_name].present? + + fee + end + + def initialize_adjusted_fee(fee, params) + unit_precise_amount_cents = if params[:unit_precise_amount].present? + params[:unit_precise_amount].to_f * fee.amount.currency.subunit_to_unit + else + fee.precise_unit_amount + end + + AdjustedFee.new( + fee:, + invoice: fee.invoice, + subscription: fee.subscription, + charge: fee.charge, + fixed_charge: fee.fixed_charge, + adjusted_units: params[:units].present? && params[:unit_precise_amount].blank?, + adjusted_amount: params[:units].present? && params[:unit_precise_amount].present?, + invoice_display_name: params[:invoice_display_name], + fee_type: fee.fee_type, + properties: fee.properties, + units: params[:units].presence || 0, + unit_amount_cents: unit_precise_amount_cents.round, + unit_precise_amount_cents: unit_precise_amount_cents, + grouped_by: fee.grouped_by, + charge_filter: fee.charge_filter, + organization: + ) + end + + # TODO: Consider if prorated fixed charges should also have + # graduated charge model disabled when units are adjusted, + # as is currently done for charges. + def disabled_charge_model?(charge) + return false unless charge + + unit_adjustment = params[:units].present? && params[:unit_precise_amount].blank? + return false unless unit_adjustment + + charge.percentage? || (charge.prorated? && charge.graduated?) + end + + def customer + @customer ||= invoice.customer + end + + def find_or_initialize_fee + return find_existing_fee if params.key?(:fee_id) + + initialize_fee + end + + def find_existing_fee + fee = invoice.fees.find_by(id: params[:fee_id]) + + unless fee + result.not_found_failure!(resource: "fee") + return + end + + fee + end + + def initialize_fee + return initialize_fee_for_fixed_charge if params[:fixed_charge_id].present? + + subscription = invoice.subscriptions.includes(plan: {charges: :filters}).find_by(id: params[:invoice_subscription_id]) + unless subscription + result.not_found_failure!(resource: "subscription") + return + end + + charge = subscription.plan.charges.find_by(id: params[:charge_id]) + unless charge + result.not_found_failure!(resource: "charge") + return + end + + if params[:charge_filter_id].present? + charge_filter = charge.filters.find_by(id: params[:charge_filter_id]) + unless charge_filter + result.not_found_failure!(resource: "charge_filter") + return + end + end + + fee = invoice.fees.find_by( + subscription_id: subscription.id, + charge_id: charge.id, + charge_filter_id: params[:charge_filter_id] + ) + fee || initialize_empty_fee(subscription, charge, :charge) + end + + def initialize_fee_for_fixed_charge + subscription = invoice.subscriptions.includes(plan: :fixed_charges).find_by(id: params[:invoice_subscription_id]) + unless subscription + result.not_found_failure!(resource: "subscription") + return + end + + fixed_charge = subscription.plan.fixed_charges.find_by(id: params[:fixed_charge_id]) + unless fixed_charge + result.not_found_failure!(resource: "fixed_charge") + return + end + + fee = invoice.fees.find_by( + subscription_id: subscription.id, + fixed_charge_id: fixed_charge.id + ) + fee || initialize_empty_fee(subscription, fixed_charge, :fixed_charge) + end + + def initialize_empty_fee(subscription, invoiceable, fee_type) + invoice_subscription = invoice.invoice_subscriptions.find_by(subscription_id: subscription.id) + + boundaries = {timestamp: invoice_subscription.timestamp} + + boundaries.merge!( + case fee_type + when :charge + { + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime + } + when :fixed_charge + { + fixed_charges_from_datetime: invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: invoice_subscription.fixed_charges_to_datetime + } + end + ) + + Fee.new( + organization:, + billing_entity_id: invoice.billing_entity_id, + invoice:, + subscription:, + invoiceable:, + charge: ((fee_type == :charge) ? invoiceable : nil), + charge_filter_id: params[:charge_filter_id], + fixed_charge: ((fee_type == :fixed_charge) ? invoiceable : nil), + grouped_by: {}, + fee_type:, + payment_status: :pending, + events_count: 0, + amount_currency: invoice.currency, + amount_cents: 0, + precise_amount_cents: 0.to_d, + unit_amount_cents: 0, + precise_unit_amount: 0.to_d, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + units: 0, + total_aggregated_units: 0, + properties: boundaries + ) + end + end +end diff --git a/app/services/admin/organizations/update_service.rb b/app/services/admin/organizations/update_service.rb new file mode 100644 index 0000000..4c21317 --- /dev/null +++ b/app/services/admin/organizations/update_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Admin + module Organizations + class UpdateService < ::BaseService + Result = BaseResult[:organization] + + def initialize(organization:, params:) + @organization = organization + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "organization") unless organization + + organization.name = params[:name] if params.key?(:name) + organization.premium_integrations = params[:premium_integrations] if params.key?(:premium_integrations) + + organization.save! + + result.organization = organization + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :params + end + end +end diff --git a/app/services/ai_conversations/fetch_messages_service.rb b/app/services/ai_conversations/fetch_messages_service.rb new file mode 100644 index 0000000..aca4882 --- /dev/null +++ b/app/services/ai_conversations/fetch_messages_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module AiConversations + class FetchMessagesService < BaseService + Result = BaseResult[:messages] + MISTRAL_CONVERSATIONS_API_URL = "https://api.mistral.ai/v1/conversations" + + def initialize(ai_conversation:) + @ai_conversation = ai_conversation + @http = LagoHttpClient::Client.new(api_url) + + super + end + + def call + result.messages = [] + return result if ai_conversation.mistral_conversation_id.blank? + + http_result = @http.get(headers:) + messages = http_result["messages"].map { |h| h.slice("content", "created_at", "type") } + + result.messages = messages + result + rescue LagoHttpClient::HttpError => e + Rails.logger.error("Error fetching Mistral messages: #{e.message}") + result + end + + private + + attr_reader :ai_conversation + + def api_url + "#{MISTRAL_CONVERSATIONS_API_URL}/#{ai_conversation.mistral_conversation_id}/messages" + end + + def headers + { + "Authorization" => "Bearer #{ENV.fetch("MISTRAL_API_KEY")}" + } + end + end +end diff --git a/app/services/ai_conversations/stream_service.rb b/app/services/ai_conversations/stream_service.rb new file mode 100644 index 0000000..38612af --- /dev/null +++ b/app/services/ai_conversations/stream_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module AiConversations + class StreamService < BaseService + Result = BaseResult[:ai_conversation] + CHUNK_DELAY = 0.03 + + def initialize(ai_conversation:, message:) + @ai_conversation = ai_conversation + @message = message + + super + end + + def call + return result.forbidden_failure! if mcp_server_url.blank? + + setup_clients! + stream_response + save_conversation_id! + notify_completion + result.ai_conversation = ai_conversation + result + rescue => e + handle_error(e) + end + + private + + attr_reader :ai_conversation, :message + + def setup_clients! + mcp_client.setup! + mistral_agent.setup! + end + + def stream_response + mistral_agent.chat(message) do |chunk| + next if chunk.nil? + + trigger_subscription(chunk:, done: false) + sleep CHUNK_DELAY + end + end + + def save_conversation_id! + return if mistral_agent.conversation_id.blank? + return if ai_conversation.mistral_conversation_id == mistral_agent.conversation_id + + ai_conversation.update!(mistral_conversation_id: mistral_agent.conversation_id) + end + + def notify_completion + trigger_subscription(chunk: nil, done: true) + end + + def trigger_subscription(chunk:, done:) + LagoApiSchema.subscriptions.trigger( + :ai_conversation_streamed, + {id: ai_conversation.id}, + {chunk:, done:} + ) + rescue => e + Rails.logger.error("Failed to trigger subscription: #{e.message}") + end + + def handle_error(error) + Rails.logger.error("StreamService error: #{error.message}") + Rails.logger.error(error.backtrace.join("\n")) + + trigger_subscription(chunk: "Error: #{error.message}", done: true) + result.service_failure!(code: "stream_service_error", message: error.message) + end + + def mcp_server_url + @mcp_server_url ||= ENV["LAGO_MCP_SERVER_URL"] + end + + def mcp_client + @mcp_client ||= LagoMcpClient::Client.new(mcp_client_config) + end + + def mcp_client_config + @mcp_client_config ||= LagoMcpClient::Config.new(mcp_server_url:, lago_api_key:) + end + + def mistral_agent + @mistral_agent ||= LagoMcpClient::Mistral::Agent.new( + client: mcp_client, + conversation_id: ai_conversation.mistral_conversation_id + ) + end + + def lago_api_key + @lago_api_key ||= ai_conversation.organization.api_keys.with_most_permissions.value + end + end +end diff --git a/app/services/analytics/base_service.rb b/app/services/analytics/base_service.rb new file mode 100644 index 0000000..e9edab4 --- /dev/null +++ b/app/services/analytics/base_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Analytics + class BaseService < BaseService + Result = BaseResult[:records] + def initialize(organization, **filters) + @organization = organization + @filters = filters + + super() + end + + private + + attr_reader :organization, :filters, :records + end +end diff --git a/app/services/analytics/gross_revenues_service.rb b/app/services/analytics/gross_revenues_service.rb new file mode 100644 index 0000000..ab7240a --- /dev/null +++ b/app/services/analytics/gross_revenues_service.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Analytics + class GrossRevenuesService < BaseService + def call + @records = ::Analytics::GrossRevenue.find_all_by(organization.id, **filters) + + result.records = records + result + end + end +end diff --git a/app/services/analytics/invoice_collections_service.rb b/app/services/analytics/invoice_collections_service.rb new file mode 100644 index 0000000..9dc6ae0 --- /dev/null +++ b/app/services/analytics/invoice_collections_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Analytics + class InvoiceCollectionsService < BaseService + def call + return result.forbidden_failure! unless License.premium? + + @records = ::Analytics::InvoiceCollection.find_all_by(organization.id, **filters) + + result.records = records + result + end + end +end diff --git a/app/services/analytics/invoiced_usages_service.rb b/app/services/analytics/invoiced_usages_service.rb new file mode 100644 index 0000000..4c46af9 --- /dev/null +++ b/app/services/analytics/invoiced_usages_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Analytics + class InvoicedUsagesService < BaseService + def call + return result.forbidden_failure! unless License.premium? + + @records = ::Analytics::InvoicedUsage.find_all_by(organization.id, **filters) + + result.records = records + result + end + end +end diff --git a/app/services/analytics/mrrs_service.rb b/app/services/analytics/mrrs_service.rb new file mode 100644 index 0000000..636f7dd --- /dev/null +++ b/app/services/analytics/mrrs_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Analytics + class MrrsService < BaseService + def call + return result.forbidden_failure! unless License.premium? + + @records = ::Analytics::Mrr.find_all_by(organization.id, **filters) + + result.records = records + result + end + end +end diff --git a/app/services/analytics/overdue_balances_service.rb b/app/services/analytics/overdue_balances_service.rb new file mode 100644 index 0000000..c2af5bc --- /dev/null +++ b/app/services/analytics/overdue_balances_service.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Analytics + class OverdueBalancesService < BaseService + def call + @records = ::Analytics::OverdueBalance.find_all_by(organization.id, **filters) + + result.records = records + result + end + end +end diff --git a/app/services/api_keys/cache_service.rb b/app/services/api_keys/cache_service.rb new file mode 100644 index 0000000..211012a --- /dev/null +++ b/app/services/api_keys/cache_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module ApiKeys + class CacheService < ::CacheService + CACHE_KEY_VERSION = "1" + + def initialize(auth_token, with_cache: false) + @auth_token = auth_token + @with_cache = with_cache + super(auth_token, expires_in: Rails.application.config.api_key_cache_ttl) + end + + def self.expire_all_cache(organization) + organization.api_keys.each { expire_cache(it.value) } + end + + def call + # When no cache, just return the values from the database + return fetch_from_database unless with_cache + + # Fetch API key and organization from the cache + cache = Rails.cache.read(cache_key) + if cache + cache_json = JSON.parse(cache) + api_key = ApiKey.instantiate(cache_json["api_key"].slice(*ApiKey.column_names)) + + # Avoid returning an expired API key + unless api_key.expired? + organization = Organization.instantiate(cache_json["organization"].slice(*Organization.column_names)) + return api_key, organization + end + end + + # In last resort, fetch from the database and write to the cache + api_key, organization = fetch_from_database + write_to_cache(api_key) if api_key + + [api_key, organization] + end + + def cache_key + [ + "api_key", + CACHE_KEY_VERSION, + auth_token + ].compact.join("/") + end + + private + + attr_reader :auth_token, :with_cache + + def fetch_from_database + api_key = ApiKey.includes(:organization).find_by(value: auth_token) + [api_key, api_key&.organization] + end + + def write_to_cache(api_key) + # Ensure cache is kept for 1 hour at most (10 seconds in development) + cache_duration = Rails.application.config.api_key_cache_ttl + expiration = if api_key.expires_at && api_key.expires_at < Time.current + cache_duration + (api_key.expires_at - Time.current).to_i.seconds + else + cache_duration + end + + Rails.cache.write( + cache_key, + { + organization: api_key.organization.attributes, + api_key: api_key.attributes + }.to_json, + expires_in: expiration + ) + end + end +end diff --git a/app/services/api_keys/create_service.rb b/app/services/api_keys/create_service.rb new file mode 100644 index 0000000..518aaf0 --- /dev/null +++ b/app/services/api_keys/create_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module ApiKeys + class CreateService < BaseService + def initialize(params) + @params = params + super + end + + def call + return result.forbidden_failure! unless License.premium? + + if params[:permissions].present? && !params[:organization].api_permissions_enabled? + return result.forbidden_failure!(code: "premium_integration_missing") + end + + api_key = ApiKey.create!( + params.slice(:organization, :name, :permissions) + ) + + ApiKeyMailer.with(api_key:).created.deliver_later + + register_security_log(api_key) + + result.api_key = api_key + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :params + + def register_security_log(api_key) + Utils::SecurityLog.produce( + organization: api_key.organization, + log_type: "api_key", + log_event: "api_key.created", + resources: { + name: api_key.name, + value_ending: api_key.value.last(4), + permissions: api_key.flat_permissions + } + ) + end + end +end diff --git a/app/services/api_keys/destroy_service.rb b/app/services/api_keys/destroy_service.rb new file mode 100644 index 0000000..b1f32ae --- /dev/null +++ b/app/services/api_keys/destroy_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module ApiKeys + class DestroyService < BaseService + Result = BaseResult[:api_key] + + def initialize(api_key) + @api_key = api_key + super + end + + def call + return result.not_found_failure!(resource: "api_key") unless api_key + + unless api_key.organization.api_keys.non_expiring.without(api_key).exists? + return result.single_validation_failure!(error_code: "last_non_expiring_api_key") + end + + api_key.touch(:expires_at) # rubocop:disable Rails/SkipsModelValidations + + ApiKeyMailer.with(api_key:).destroyed.deliver_later + ApiKeys::CacheService.expire_cache(api_key.value) + + register_security_log + + result.api_key = api_key + result + end + + private + + attr_reader :api_key + + def register_security_log + Utils::SecurityLog.produce( + organization: api_key.organization, + log_type: "api_key", + log_event: "api_key.deleted", + resources: { + name: api_key.name, + value_ending: api_key.value.last(4) + } + ) + end + end +end diff --git a/app/services/api_keys/rotate_service.rb b/app/services/api_keys/rotate_service.rb new file mode 100644 index 0000000..3f58f72 --- /dev/null +++ b/app/services/api_keys/rotate_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ApiKeys + class RotateService < BaseService + Result = BaseResult[:api_key] + + def initialize(api_key:, params:) + @api_key = api_key + @params = params + super + end + + def call + return result.not_found_failure!(resource: "api_key") unless api_key + + if params[:expires_at].present? && !License.premium? + return result.forbidden_failure!(code: "cannot_rotate_with_provided_date") + end + + expires_at = params[:expires_at] || Time.current + new_api_key = api_key.organization.api_keys.new(name: params[:name]) + + ActiveRecord::Base.transaction do + new_api_key.save! + api_key.update!(expires_at:) + end + + ApiKeys::CacheService.expire_cache(api_key.value) + ApiKeyMailer.with(api_key:).rotated.deliver_later + + register_security_log(new_api_key) + + result.api_key = new_api_key + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :api_key, :params + + def register_security_log(new_api_key) + Utils::SecurityLog.produce( + organization: new_api_key.organization, + log_type: "api_key", + log_event: "api_key.rotated", + resources: { + name: new_api_key.name, + value_ending: {deleted: api_key.value.last(4), added: new_api_key.value.last(4)}.compact + } + ) + end + end +end diff --git a/app/services/api_keys/track_usage_service.rb b/app/services/api_keys/track_usage_service.rb new file mode 100644 index 0000000..0ac3623 --- /dev/null +++ b/app/services/api_keys/track_usage_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module ApiKeys + class TrackUsageService < BaseService + def call + ApiKey.find_each do |api_key| + cache_key = "api_key_last_used_#{api_key.id}" + last_used_at = Rails.cache.read(cache_key) + + next unless last_used_at + + api_key.update_columns(last_used_at:) # rubocop:disable Rails/SkipsModelValidations + Rails.cache.delete(cache_key) + end + end + end +end diff --git a/app/services/api_keys/update_service.rb b/app/services/api_keys/update_service.rb new file mode 100644 index 0000000..2d35105 --- /dev/null +++ b/app/services/api_keys/update_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ApiKeys + class UpdateService < BaseService + Result = BaseResult[:api_key] + + def initialize(api_key:, params:) + @api_key = api_key + @params = params + super + end + + def call + return result.not_found_failure!(resource: "api_key") unless api_key + + if params[:permissions].present? && !api_key.organization.api_permissions_enabled? + return result.forbidden_failure!(code: "premium_integration_missing") + end + + old_flat_permissions = api_key.flat_permissions + api_key.update!(params.slice(:name, :permissions)) + ApiKeys::CacheService.expire_cache(api_key.value) + + register_security_log(old_flat_permissions) + + result.api_key = api_key + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :api_key, :params + + def register_security_log(old_flat_permissions) + diff = {} + + if api_key.previous_changes.key?("name") + old_val, new_val = api_key.previous_changes["name"] + diff[:name] = {deleted: old_val, added: new_val}.compact_blank + end + + if api_key.previous_changes.key?("permissions") + new_flat = api_key.flat_permissions + entry = {} + deleted = old_flat_permissions - new_flat + added = new_flat - old_flat_permissions + entry[:deleted] = deleted if deleted.present? + entry[:added] = added if added.present? + diff[:permissions] = entry if entry.present? + end + + Utils::SecurityLog.produce( + organization: api_key.organization, + log_type: "api_key", + log_event: "api_key.updated", + resources: { + name: api_key.name, + value_ending: api_key.value.last(4), + **diff + } + ) + end + end +end diff --git a/app/services/applied_coupons/amount_service.rb b/app/services/applied_coupons/amount_service.rb new file mode 100644 index 0000000..01b72bb --- /dev/null +++ b/app/services/applied_coupons/amount_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module AppliedCoupons + class AmountService < BaseService + Result = BaseResult[:amount] + + def initialize(applied_coupon:, base_amount_cents:) + @applied_coupon = applied_coupon + @base_amount_cents = base_amount_cents + + super + end + + def call + return result.not_found_failure!(resource: "applied_coupon") unless applied_coupon + + result.amount = compute_amount + result + end + + private + + attr_reader :applied_coupon, :base_amount_cents + + def compute_amount + if applied_coupon.coupon.percentage? + discounted_value = base_amount_cents * applied_coupon.percentage_rate.fdiv(100) + + return (discounted_value >= base_amount_cents) ? base_amount_cents : discounted_value.round + end + + if applied_coupon.recurring? || applied_coupon.forever? + return base_amount_cents if applied_coupon.amount_cents > base_amount_cents + + applied_coupon.amount_cents + else + return base_amount_cents if applied_coupon.remaining_amount > base_amount_cents + + applied_coupon.remaining_amount + end + end + end +end diff --git a/app/services/applied_coupons/create_service.rb b/app/services/applied_coupons/create_service.rb new file mode 100644 index 0000000..da5178a --- /dev/null +++ b/app/services/applied_coupons/create_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module AppliedCoupons + class CreateService < BaseService + Result = BaseResult[:applied_coupon] + + def initialize(customer:, coupon:, params:) + @customer = customer + @coupon = coupon + @params = params + + super + end + + activity_loggable( + action: "applied_coupon.created", + record: -> { result.applied_coupon } + ) + + def call + check_preconditions + return result if result.error + + applied_coupon = AppliedCoupon.new( + customer:, + coupon:, + organization: customer.organization, + amount_cents: params[:amount_cents] || coupon.amount_cents, + amount_currency: params[:amount_currency] || coupon.amount_currency, + percentage_rate: params[:percentage_rate] || coupon.percentage_rate, + frequency: params[:frequency] || coupon.frequency, + frequency_duration: params[:frequency_duration] || coupon.frequency_duration, + frequency_duration_remaining: params[:frequency_duration] || coupon.frequency_duration + ) + + if coupon.fixed_amount? + ActiveRecord::Base.transaction do + Customers::UpdateCurrencyService + .call(customer:, currency: params[:amount_currency] || coupon.amount_currency) + .raise_if_error! + + applied_coupon.save! + end + else + applied_coupon.save! + end + + result.applied_coupon = applied_coupon + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :customer, :coupon, :params + + def check_preconditions + return result.not_found_failure!(resource: "customer") unless customer + return result.not_found_failure!(resource: "coupon") unless coupon&.active? + return result.not_allowed_failure!(code: "plan_overlapping") if plan_limitation_overlapping? + return if reusable_coupon? + + result.single_validation_failure!(field: "coupon", error_code: "coupon_is_not_reusable") + end + + def reusable_coupon? + return true if coupon.reusable? + + customer.applied_coupons.where(coupon_id: coupon.id).none? + end + + def plan_limitation_overlapping? + return false if !coupon.limited_plans? && !coupon.limited_billable_metrics? + + relation = customer + .applied_coupons + .active + .joins(coupon: :coupon_targets) + + relation + .where(coupon_targets: {plan_id: coupon.coupon_targets.select(:plan_id)}) + .or(relation.where(coupon_targets: {billable_metric_id: coupon.coupon_targets.select(:billable_metric_id)})) + .or(relation.where(coupon_targets: {plan_id: plans_from_billable_metric_limitations})) + .or(relation.where(coupon_targets: {billable_metric_id: billable_metrics_from_plan_limitations})) + .exists? + end + + def billable_metrics_from_plan_limitations + Charge + .joins(:plan) + .where(plan: {id: coupon.coupon_targets.select(:plan_id)}) + .select(:billable_metric_id) + end + + def plans_from_billable_metric_limitations + Charge + .joins(:billable_metric) + .where(billable_metric: {id: coupon.coupon_targets.select(:billable_metric_id)}) + .select(:plan_id) + end + end +end diff --git a/app/services/applied_coupons/lock_service.rb b/app/services/applied_coupons/lock_service.rb new file mode 100644 index 0000000..0ab3474 --- /dev/null +++ b/app/services/applied_coupons/lock_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module AppliedCoupons + class LockService < BaseService + def initialize(customer:) + @customer = customer + + super + end + + def call + customer.with_advisory_lock!( + "COUPONS-#{customer.id}", + timeout_seconds: 5, + transaction: true, + disable_query_cache: true + ) do + yield + end + rescue WithAdvisoryLock::FailedToAcquireLock + raise Customers::FailedToAcquireLock, "Failed to acquire lock customer-#{customer.id}" + end + + def locked? + customer.advisory_lock_exists?("COUPONS-#{customer.id}") + end + + private + + attr_reader :customer + end +end diff --git a/app/services/applied_coupons/recredit_service.rb b/app/services/applied_coupons/recredit_service.rb new file mode 100644 index 0000000..3f97f77 --- /dev/null +++ b/app/services/applied_coupons/recredit_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module AppliedCoupons + class RecreditService < BaseService + Result = BaseResult[:applied_coupon] + + def initialize(credit:) + @credit = credit + @applied_coupon = credit.applied_coupon + @invoice = credit.invoice + + super + end + + def call + return result.not_found_failure!(resource: "applied_coupon") if applied_coupon.nil? + + applied_coupon.with_lock do + # If the coupon was terminated and this was the last credit that caused it to be terminated, + # reactivate the coupon + if applied_coupon.terminated? && should_reactivate_coupon? + applied_coupon.status = :active + applied_coupon.terminated_at = nil + applied_coupon.save! + end + + # For recurring coupons, increment the frequency_duration_remaining + if applied_coupon.recurring? + applied_coupon.frequency_duration_remaining += 1 + applied_coupon.save! + end + end + + result.applied_coupon = applied_coupon + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :credit, :applied_coupon, :invoice + + def should_reactivate_coupon? + # Forever coupons don't need to be reactivated + return false if applied_coupon.forever? + # Check if the original coupon is still active + return false if applied_coupon.coupon.terminated? + # For both once and recurring coupons, we can reactivate them if they're terminated + # since they would have been terminated due to usage + true + end + end +end diff --git a/app/services/applied_coupons/terminate_service.rb b/app/services/applied_coupons/terminate_service.rb new file mode 100644 index 0000000..b8c0d4a --- /dev/null +++ b/app/services/applied_coupons/terminate_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module AppliedCoupons + class TerminateService < BaseService + Result = BaseResult[:applied_coupon] + + def initialize(applied_coupon:) + @applied_coupon = applied_coupon + super + end + + def call + return result.not_found_failure!(resource: "applied_coupon") unless applied_coupon + + applied_coupon.mark_as_terminated! unless applied_coupon.terminated? + + Utils::ActivityLog.produce(applied_coupon, "applied_coupon.deleted") + result.applied_coupon = applied_coupon + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :applied_coupon + end +end diff --git a/app/services/applied_pricing_units/create_service.rb b/app/services/applied_pricing_units/create_service.rb new file mode 100644 index 0000000..03809cc --- /dev/null +++ b/app/services/applied_pricing_units/create_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module AppliedPricingUnits + class CreateService < BaseService + Result = BaseResult[:charge] + + def initialize(charge:, params:) + @charge = charge + @params = params || {} + + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + return result unless create_applied_pricing_unit? + + pricing_unit = charge.organization.pricing_units.find_by(code: params[:code]) + + charge.create_applied_pricing_unit!( + organization: charge.organization, + pricing_unit:, + conversion_rate: params[:conversion_rate] + ) + + result.charge = charge + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + def create_applied_pricing_unit? + params.present? && License.premium? + end + + private + + attr_reader :charge, :params + end +end diff --git a/app/services/applied_pricing_units/update_service.rb b/app/services/applied_pricing_units/update_service.rb new file mode 100644 index 0000000..e6af7f8 --- /dev/null +++ b/app/services/applied_pricing_units/update_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module AppliedPricingUnits + class UpdateService < BaseService + Result = BaseResult[:charge] + + def initialize(charge:, cascade_options:, params:) + @charge = charge + @cascade_options = cascade_options || {} + @params = params || {} + + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + return result unless charge.applied_pricing_unit + return result unless update_conversion_rate? + + charge.applied_pricing_unit.update!(conversion_rate: params[:conversion_rate]) + result.charge = charge + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + def update_conversion_rate? + params.dig(:conversion_rate).present? && + (!cascade_options[:cascade] || cascade_options[:equal_applied_pricing_unit_rate]) + end + + private + + attr_reader :charge, :cascade_options, :params + end +end diff --git a/app/services/auth/google_service.rb b/app/services/auth/google_service.rb new file mode 100644 index 0000000..38ed58a --- /dev/null +++ b/app/services/auth/google_service.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +module Auth + class GoogleService < BaseService + BASE_SCOPE = %w[profile email openid].freeze + + def authorize_url(request) + ensure_google_auth_setup + return result unless result.success? + + authorizer = Google::Auth::WebUserAuthorizer.new( + client_id, + BASE_SCOPE, + nil, # token_store is nil because we don't need to store the token + "#{ENV["LAGO_FRONT_URL"]}/auth/google/callback" + ) + + result.url = authorizer.get_authorization_url(request:) + + result + end + + def login(code) + ensure_google_auth_setup + return result unless result.success? + + google_oidc = oidc_verifier(code:) + user = User.find_by(email: google_oidc["email"]) + + unless user.present? && user.memberships&.active&.any? + return result.single_validation_failure!(error_code: "user_does_not_exist") + end + + unless user.active_organizations.pluck(:authentication_methods).flatten.uniq.include?(Organizations::AuthenticationMethods::GOOGLE_OAUTH) + return result.single_validation_failure!( + error_code: "login_method_not_authorized", + field: Organizations::AuthenticationMethods::GOOGLE_OAUTH + ) + end + + result.user = user + UserDevices::RegisterService.call!(user:) + generate_token + rescue Google::Auth::IDTokens::SignatureError + result.single_validation_failure!(error_code: "invalid_google_token") + rescue Signet::AuthorizationError + result.single_validation_failure!(error_code: "invalid_google_code") + end + + def register_user(code, organization_name) + ensure_google_auth_setup + return result unless result.success? + + google_oidc = oidc_verifier(code:) + + register(google_oidc["email"], organization_name) + rescue Google::Auth::IDTokens::SignatureError + result.single_validation_failure!(error_code: "invalid_google_token") + rescue Signet::AuthorizationError + result.single_validation_failure!(error_code: "invalid_google_code") + end + + def accept_invite(code, invite_token) + ensure_google_auth_setup + return result unless result.success? + + google_oidc = oidc_verifier(code:) + invite = Invite.find_by(token: invite_token, status: :pending) + + return result.not_found_failure!(resource: "invite") unless invite + + unless google_oidc["email"] == invite.email + return result.single_validation_failure!(error_code: "invite_email_mistmatch") + end + + Invites::AcceptService.new.call( + invite:, + email: google_oidc["email"], + token: invite_token, + password: SecureRandom.hex, + login_method: Organizations::AuthenticationMethods::GOOGLE_OAUTH + ) + rescue Google::Auth::IDTokens::SignatureError + result.single_validation_failure!(error_code: "invalid_google_token") + rescue Signet::AuthorizationError + result.single_validation_failure!(error_code: "invalid_google_code") + end + + private + + def generate_token + result.token = Auth::TokenService.encode(user: result.user, login_method: Organizations::AuthenticationMethods::GOOGLE_OAUTH) + result + rescue => e + result.service_failure!(code: "token_encoding_error", message: e.message) + end + + def register(email, organization_name) + if ENV.fetch("LAGO_DISABLE_SIGNUP", "false") == "true" + return result.not_allowed_failure!(code: "signup_disabled") + end + + if User.exists?(email:) + result.single_validation_failure!(field: :email, error_code: "user_already_exists") + + return result + end + + ActiveRecord::Base.transaction do + result.user = User.create!(email:, password: SecureRandom.hex) + + result.organization = Organizations::CreateService + .call(name: organization_name, document_numbering: "per_organization") + .raise_if_error! + .organization + + result.membership = Membership.create!( + user: result.user, + organization: result.organization + ) + + role = Role.find_by!(admin: true) + MembershipRole.create!(membership: result.membership, organization: result.organization, role:) + + generate_token + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + SegmentIdentifyJob.perform_later(membership_id: "membership/#{result.membership.id}") + track_organization_registered(result.organization, result.membership) + + # Skip log: user.signed_up already covers signup + UserDevices::RegisterService.call!(user: result.user, skip_log: true) + + result + end + + def track_organization_registered(organization, membership) + SegmentTrackJob.perform_later( + membership_id: "membership/#{membership.id}", + event: "organization_registered", + properties: { + organization_name: organization.name, + organization_id: organization.id + } + ) + end + + def client_id + @client_id ||= Google::Auth::ClientId.new(ENV["GOOGLE_AUTH_CLIENT_ID"], ENV["GOOGLE_AUTH_CLIENT_SECRET"]) + end + + def ensure_google_auth_setup + return if ENV["GOOGLE_AUTH_CLIENT_ID"].present? && ENV["GOOGLE_AUTH_CLIENT_SECRET"].present? + + result.service_failure!(code: "google_auth_missing_setup", message: "Google auth is not set up") + end + + def oidc_verifier(code:) + authorizer = Google::Auth::UserAuthorizer.new( + client_id, + BASE_SCOPE, + nil, # token_store is nil because we don't need to store the token + "#{ENV["LAGO_FRONT_URL"]}/auth/google/callback" + ) + + credentials = authorizer.get_credentials_from_code(code:) + Google::Auth::IDTokens.verify_oidc(credentials.id_token, aud: ENV["GOOGLE_AUTH_CLIENT_ID"]) + end + end +end diff --git a/app/services/auth/okta/accept_invite_service.rb b/app/services/auth/okta/accept_invite_service.rb new file mode 100644 index 0000000..708a353 --- /dev/null +++ b/app/services/auth/okta/accept_invite_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Auth + module Okta + class AcceptInviteService < BaseService + def initialize(invite_token:, code:, state:) + @invite_token = invite_token + @code = code + @state = state + + super + end + + def call + check_state + check_code + check_okta_integration(result.email) + check_invite(result.email) + query_okta_access_token + check_userinfo(result.email) + + Invites::AcceptService.new.call( + invite: result.invite, + email: result.email, + token: invite_token, + password: SecureRandom.hex, + login_method: Organizations::AuthenticationMethods::OKTA + ) + rescue ValidationError => e + result.single_validation_failure!(error_code: e.message) + rescue LagoHttpClient::HttpError + result.single_validation_failure!(error_code: "okta_request_error") + result + end + + private + + attr_reader :invite_token, :code, :state + end + end +end diff --git a/app/services/auth/okta/authorize_service.rb b/app/services/auth/okta/authorize_service.rb new file mode 100644 index 0000000..3d087cf --- /dev/null +++ b/app/services/auth/okta/authorize_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "redis" + +module Auth + module Okta + class AuthorizeService < BaseService + def initialize(email:, invite_token: nil) + @email = email + @invite_token = invite_token + initialize_state + + super + end + + def call + check_invite(email) if invite_token.present? + check_okta_integration(email) + + params = { + client_id: result.okta_integration.client_id, + response_type: "code", + response_mode: "query", + scope: "openid profile email", + redirect_uri: "#{ENV["LAGO_FRONT_URL"]}/auth/okta/callback", + state: + } + result.url = URI::HTTPS.build( + host: result.okta_integration.host, + path: "/oauth2/v1/authorize", + query: params.to_query + ).to_s + + result + rescue ValidationError => e + result.single_validation_failure!(error_code: e.message) + result + end + + private + + attr_reader :email, :invite_token + + def initialize_state + Rails.cache.write(state, email, expires_in: 90.seconds) + end + + def state + @state ||= SecureRandom.uuid + end + end + end +end diff --git a/app/services/auth/okta/base_service.rb b/app/services/auth/okta/base_service.rb new file mode 100644 index 0000000..c62e883 --- /dev/null +++ b/app/services/auth/okta/base_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Auth + module Okta + class BaseService < BaseService + private + + def check_code + raise ValidationError, "code_not_found" if code.blank? + end + + def check_state + raise ValidationError, "state_not_found" if state.blank? + + email = Rails.cache.read(state) + raise ValidationError, "state_not_found" if email.blank? + + Rails.cache.delete(state) + + result.email = email + end + + def check_okta_integration(email) + email_domain = email.split("@").last + okta_integration = ::Integrations::OktaIntegration + .where("settings->>'domain' IS NOT NULL") + .where("settings->>'domain' = ?", email_domain) + .first + + raise ValidationError, "domain_not_configured" if okta_integration.blank? + + result.okta_integration = okta_integration + end + + def check_invite(email) + invite = Invite.pending.find_by(token: invite_token) + + raise ValidationError, "invite_not_found" if invite.blank? + raise ValidationError, "invite_email_mismatch" if invite.email != email + + result.invite = invite + end + + def query_okta_access_token + params = { + client_id: result.okta_integration.client_id, + client_secret: result.okta_integration.client_secret, + grant_type: "authorization_code", + code:, + redirect_uri: "#{ENV["LAGO_FRONT_URL"]}/auth/okta/callback" + } + + token_client = LagoHttpClient::Client.new("https://#{result.okta_integration.host}/oauth2/v1/token") + response = token_client.post_url_encoded(params, {}) + result.okta_access_token = response["access_token"] + end + + def check_userinfo(email) + userinfo_client = LagoHttpClient::Client.new("https://#{result.okta_integration.host}/oauth2/v1/userinfo") + userinfo_headers = {"Authorization" => "Bearer #{result.okta_access_token}"} + response = userinfo_client.get(headers: userinfo_headers) + + raise ValidationError, "okta_userinfo_error" if response["email"] != email + + result.userinfo = response + end + end + + class ValidationError < StandardError; end + end +end diff --git a/app/services/auth/okta/login_service.rb b/app/services/auth/okta/login_service.rb new file mode 100644 index 0000000..0468b23 --- /dev/null +++ b/app/services/auth/okta/login_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Auth + module Okta + class LoginService < BaseService + def initialize(code:, state:) + @code = code + @state = state + + super + end + + def call + check_state + check_code + check_okta_integration(result.email) + + query_okta_access_token + check_userinfo(result.email) + + find_or_create_user + find_or_create_membership + + unless result.user.active_organizations.pluck(:authentication_methods).flatten.uniq.include?(Organizations::AuthenticationMethods::OKTA) + return result.single_validation_failure!( + error_code: "login_method_not_authorized", + field: Organizations::AuthenticationMethods::OKTA + ) + end + + UserDevices::RegisterService.call!(user: result.user) + generate_token + rescue ValidationError => e + result.single_validation_failure!(error_code: e.message) + result + end + + private + + attr_reader :code, :state + + def generate_token + result.token = Auth::TokenService.encode(user: result.user, login_method: Organizations::AuthenticationMethods::OKTA) + result + rescue => e + result.service_failure!(code: "token_encoding_error", message: e.message) + end + + def find_or_create_user + user = User.find_or_initialize_by(email: result.email) + + if user.new_record? + user.password = SecureRandom.hex(16) + user.save! + end + + result.user = user + end + + def find_or_create_membership + result.user.memberships.find_or_create_by(organization_id: result.okta_integration.organization_id) + end + end + end +end diff --git a/app/services/auth/superset_service.rb b/app/services/auth/superset_service.rb new file mode 100644 index 0000000..b9327dc --- /dev/null +++ b/app/services/auth/superset_service.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +module Auth + class SupersetService < BaseService + Result = BaseResult[:dashboards] + + def initialize(organization:, user: nil) + @organization = organization + @user = user + @access_token = nil + @csrf_token = nil + @http_client = nil + + super() + end + + def call + ensure_superset_configured + return result unless result.success? + + # Step 1: Authenticate and get access token + auth_result = authenticate_with_api + return result unless auth_result[:success] + + @access_token = auth_result[:access_token] + + # Step 2: Get CSRF token (authenticated with Bearer token) + csrf_result = get_csrf_token + return result unless csrf_result[:success] + + @csrf_token = csrf_result[:csrf_token] + + # Step 3: Fetch all dashboards + dashboards_result = fetch_dashboards + return result unless dashboards_result[:success] + + # Step 4: Process each dashboard to ensure embedded config and get guest token + processed_dashboards = [] + dashboards_result[:dashboards].each do |dashboard| + embedded_config = ensure_embedded_config(dashboard["id"]) + next unless embedded_config[:success] + + guest_token_result = get_guest_token(dashboard["id"]) + next unless guest_token_result[:success] + + processed_dashboards << { + id: dashboard["id"].to_s, + dashboard_title: dashboard["dashboard_title"], + embedded_id: embedded_config[:uuid], + guest_token: guest_token_result[:guest_token] + } + end + + result.dashboards = processed_dashboards + result + rescue URI::InvalidURIError => e + result.service_failure!(code: "superset_invalid_url", message: "Invalid Superset URL: #{e.message}") + rescue Net::OpenTimeout, Net::ReadTimeout => e + result.service_failure!(code: "superset_timeout", message: "Superset request timed out: #{e.message}") + rescue JSON::ParserError => e + result.service_failure!(code: "superset_invalid_response", message: "Invalid JSON response from Superset: #{e.message}") + rescue => e + result.service_failure!(code: "superset_error", message: "Superset operation failed: #{e.message}") + end + + private + + attr_reader :organization, :user, :access_token, :csrf_token + + def http_client + @http_client ||= LagoHttpClient::SessionClient.new(superset_base_url) + end + + def base_headers(referer_path: "/") + { + "Origin" => superset_base_url, + "Referer" => "#{superset_base_url}#{referer_path}" + } + end + + def api_headers(referer_path: "/") + base_headers(referer_path:).merge("Accept" => "application/json") + end + + def authenticated_api_headers(referer_path: "/") + api_headers(referer_path:).merge( + "Authorization" => "Bearer #{access_token}", + "X-CSRFToken" => csrf_token + ) + end + + def authenticated_json_headers(referer_path: "/") + authenticated_api_headers(referer_path:).merge("Content-Type" => "application/json") + end + + def authenticate_with_api + body = { + username: superset_username, + password: superset_password, + provider: "db" + } + + headers = api_headers(referer_path: "/login/").merge("Content-Type" => "application/json") + response = http_client.post("/api/v1/security/login", body:, headers:) + parsed_response = JSON.parse(response.body) + access_token = parsed_response["access_token"] + + unless access_token + result.service_failure!(code: "superset_auth_failed", message: "No access token received from Superset") + return {success: false} + end + + {success: true, access_token:} + rescue LagoHttpClient::HttpError => e + result.service_failure!(code: "superset_auth_failed", message: "Failed to authenticate with Superset: #{e.error_code} #{e.message}") + {success: false} + end + + def get_csrf_token + headers = api_headers.merge("Authorization" => "Bearer #{access_token}") + response = http_client.get("/api/v1/security/csrf_token/", headers:) + parsed_response = JSON.parse(response.body) + csrf_token = parsed_response["result"] + + unless csrf_token + result.service_failure!(code: "superset_no_csrf_token", message: "No CSRF token received from Superset") + return {success: false} + end + + {success: true, csrf_token:} + rescue LagoHttpClient::HttpError => e + result.service_failure!(code: "superset_csrf_failed", message: "Failed to get CSRF token: #{e.error_body}") + {success: false} + end + + def fetch_dashboards + response = http_client.get("/api/v1/dashboard/", headers: authenticated_api_headers) + parsed_response = JSON.parse(response.body) + dashboards = parsed_response["result"] || [] + + {success: true, dashboards:} + rescue LagoHttpClient::HttpError => e + result.service_failure!(code: "superset_fetch_dashboards_failed", message: "Failed to fetch dashboards: #{e.error_body}") + {success: false} + end + + def get_embedded_config(dashboard_id) + response = http_client.get("/api/v1/dashboard/#{dashboard_id}/embedded", headers: authenticated_api_headers) + parsed_response = JSON.parse(response.body) + uuid = parsed_response["result"]&.[]("uuid") + + return {success: true, uuid:, exists: true} if uuid + + {success: true, exists: false} + rescue LagoHttpClient::HttpError, JSON::ParserError + {success: true, exists: false} + end + + def create_embedded_config(dashboard_id) + body = {allowed_domains: []} + response = http_client.post("/api/v1/dashboard/#{dashboard_id}/embedded", body: body, headers: authenticated_json_headers) + parsed_response = JSON.parse(response.body) + uuid = parsed_response["result"]&.[]("uuid") + + return {success: false} unless uuid + + {success: true, uuid: uuid} + rescue LagoHttpClient::HttpError, JSON::ParserError + {success: false} + end + + def ensure_embedded_config(dashboard_id) + embedded_config = get_embedded_config(dashboard_id) + return {success: false} unless embedded_config[:success] + + return {success: true, uuid: embedded_config[:uuid]} if embedded_config[:exists] + + create_embedded_config(dashboard_id) + end + + def get_guest_token(dashboard_id) + body = { + resources: [{id: dashboard_id.to_s, type: "dashboard"}], + rls: [ + { + clause: "organization_id = '#{organization.id}'" + } + ], + user: guest_user_info + } + + response = http_client.post("/api/v1/security/guest_token/", body:, headers: authenticated_json_headers) + parsed_response = JSON.parse(response.body) + guest_token = parsed_response["token"] || parsed_response["result"] || parsed_response["access_token"] + + return {success: false} unless guest_token + + {success: true, guest_token:} + rescue LagoHttpClient::HttpError, JSON::ParserError + {success: false} + end + + def guest_user_info + user.presence || { + first_name: organization.name || "Guest", + last_name: "User", + username: "guest_#{organization.id}" + } + end + + def ensure_superset_configured + missing_vars = [] + missing_vars << "SUPERSET_URL" if superset_base_url.blank? + missing_vars << "SUPERSET_USERNAME" if superset_username.blank? + missing_vars << "SUPERSET_PASSWORD" if superset_password.blank? + + return if missing_vars.empty? + + result.service_failure!( + code: "superset_missing_configuration", + message: "Superset configuration is incomplete. Missing: #{missing_vars.join(", ")}" + ) + end + + def superset_base_url + ENV["SUPERSET_URL"] + end + + def superset_username + ENV["SUPERSET_USERNAME"] + end + + def superset_password + ENV["SUPERSET_PASSWORD"] + end + end +end diff --git a/app/services/auth/token_service.rb b/app/services/auth/token_service.rb new file mode 100644 index 0000000..0ccdaf1 --- /dev/null +++ b/app/services/auth/token_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Auth + class TokenService < BaseService + THREE_HOURS = 10800 + ALGORITHM = "HS256" + LAGO_TOKEN_HEADER = "x-lago-token" + + def self.encode(user: nil, user_id: nil, **extra) + return nil if (user_id || user&.id).blank? + + JWT.encode(payload(user:, user_id:, **extra), ENV["SECRET_KEY_BASE"], ALGORITHM) + end + + def self.decode(token:) + return nil if token.blank? + + JWT.decode(token, ENV["SECRET_KEY_BASE"], true, {algorithm: ALGORITHM}).reduce({}, :merge) + end + + def self.renew(token:) + return nil if token.blank? + + decoded = decode(token:) + user_id = decoded["sub"] + extra = decoded.except(*non_extra_attributes) + + encode(user_id:, **extra) + end + + private_class_method + + def self.non_extra_attributes + ["sub", "exp", "alg"] + end + + def self.payload(user: nil, user_id: nil, **extra) + { + sub: user_id || user.id, + exp: Time.current.to_i + THREE_HOURS + }.merge(extra) + end + end +end diff --git a/app/services/base_result.rb b/app/services/base_result.rb new file mode 100644 index 0000000..0f559a9 --- /dev/null +++ b/app/services/base_result.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class BaseResult + include Result + + class_attribute :attributes, default: [] # rubocop:disable ThreadSafety/ClassAndModuleAttributes + + def self.[](*attributes) + Class.new(BaseResult) do + attr_accessor(*attributes) + + self.attributes = attributes + end + end + + def ==(other) + return false unless other.class == self.class + return false unless failure? == other.failure? + return false unless other.error == error + + self.class.attributes.all? do |attribute| + send(attribute) == other.send(attribute) + end + end +end diff --git a/app/services/base_service.rb b/app/services/base_service.rb new file mode 100644 index 0000000..f1facd1 --- /dev/null +++ b/app/services/base_service.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +class BaseService + include AfterCommitEverywhere + + # rubocop:disable ThreadSafety/ClassAndModuleAttributes + class_attribute :middlewares, instance_writer: false, default: [] + # rubocop:enable ThreadSafety/ClassAndModuleAttributes + + class FailedResult < StandardError + attr_reader :result, :original_error + + def initialize(result, message, original_error: nil) + @result = result + @original_error = original_error + + super(message) + end + end + + class ThrottlingError < StandardError + attr_reader :provider_name + + def initialize(provider_name: nil) + @provider_name = provider_name + + super(message) + end + + def message + "Service #{provider_name} is not available. Try again later." + end + end + + class NotFoundFailure < FailedResult + attr_reader :resource + + def initialize(result, resource:) + @resource = resource + + super(result, error_code) + end + + def error_code + "#{resource}_not_found" + end + end + + class MethodNotAllowedFailure < FailedResult + attr_reader :code + + def initialize(result, code:) + @code = code + + super(result, code) + end + end + + class ValidationFailure < FailedResult + attr_reader :messages + + def initialize(result, messages:) + @messages = messages + + super(result, format_messages) + end + + private + + def format_messages + "Validation errors: #{messages.to_json}" + end + end + + class ServiceFailure < FailedResult + attr_reader :code, :error_message + + def initialize(result, code:, error_message:, original_error: nil) + @code = code + @error_message = error_message + + super(result, "#{code}: #{error_message}", original_error:) + end + end + + class NonRetryableFailure < ServiceFailure; end + + class UnknownTaxFailure < FailedResult + attr_reader :code, :error_message + + def initialize(result, code:, error_message:) + @code = code + @error_message = error_message + + super(result, "#{code}: #{error_message}") + end + end + + class ForbiddenFailure < FailedResult + attr_reader :code + + def initialize(result, code:) + @code = code + + super(result, code) + end + end + + class UnauthorizedFailure < FailedResult + def initialize(result, message:) + super(result, message) + end + end + + class ProviderFailure < FailedResult + attr_reader :provider + + def initialize(result, provider:, error:) + @provider = provider + super(result, nil, original_error: error) + end + end + + class ThirdPartyFailure < FailedResult + attr_reader :third_party, :error_code, :error_message + + def initialize(result, third_party:, error_code:, error_message:) + @third_party = third_party + @error_message = error_message + @error_code = error_code + + super(result, "#{third_party}: #{error_code} - #{error_message}") + end + end + + class TooManyProviderRequestsFailure < FailedResult + attr_reader :provider_name, :error + + def initialize(result, provider_name:, error:) + @provider_name = provider_name + @error = error + + super(result, error.message, original_error: error) + end + end + + # DEPRECATED: This is a legacy result class that should + # be replaced be defining a Result in every service, using the BaseResult + class LegacyResult < OpenStruct + include ::Result + end + + Result = LegacyResult + + def self.activity_loggable(action:, record:, condition: -> { true }, after_commit: true) + use(Middlewares::ActivityLogMiddleware, action:, record:, condition:, after_commit:) + end + + # Register a new middleware + def self.use(middleware_class, *args, on_conflict: :raise, **kwargs) + existing_middleware = middlewares.map(&:first) + + if !existing_middleware.include?(middleware_class) || on_conflict == :append + return self.middlewares += [[middleware_class, args, kwargs]] + end + + # Middleware already exists + case on_conflict + when :raise + raise Middlewares::AlreadyAddedError.new(middleware_class, self) + when :replace + self.middlewares[existing_middleware.index(middleware_class)] = [middleware_class, args, kwargs] + when :ignore + # Do nothing + end + end + + use(Middlewares::LogTracerMiddleware) + use(Middlewares::DatadogMiddleware) if ENV["DD_AGENT_HOST"].present? + + def self.call(*, **, &) + new(*, **).call_with_middlewares(&) + end + + def self.call_async(*, **, &) + LagoTracer.in_span("#{name}#call_async") do + new(*, **).call_async(&) + end + end + + def self.call!(*, **, &) + call(*, **, &).raise_if_error! + end + + def initialize(args = nil) + @result = self.class::Result.new + @source = CurrentContext&.source + end + + def call(**args, &block) + raise NotImplementedError + end + + def call!(*, &) + call(*, &).raise_if_error! + end + + def call_async(**args, &block) + raise NotImplementedError + end + + def call_with_middlewares(&block) + chain = init_middlewares + + chain.call { call(&block) } + end + + protected + + attr_writer :result + + private + + attr_reader :result, :source + + def api_context? + source&.to_sym == :api + end + + def graphql_context? + source&.to_sym == :graphql + end + + def at_time_zone(customer: "customers", billing_entity: "billing_entities") + Utils::Timezone.at_time_zone_sql(customer:, billing_entity:) + end + + def init_middlewares + stack = lambda { |&block| block.call } + + # Initialize middlewares in reverse order (Rake like approach) + self.class.middlewares.reverse_each do |middleware_klass, args, kwargs| + current_stack = stack + + stack = lambda do |&block| + middleware = middleware_klass.new(self, current_stack, *args, **kwargs) + middleware.call(&block) + end + end + + stack + end +end diff --git a/app/services/base_validator.rb b/app/services/base_validator.rb new file mode 100644 index 0000000..ee2aa13 --- /dev/null +++ b/app/services/base_validator.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class BaseValidator + def initialize(result, **args) + @result = result + @args = args.to_h.with_indifferent_access + + @errors = {} + end + + protected + + attr_reader :result, :args, :errors + + def add_error(field:, error_code:) + errors[field.to_sym] ||= [] + errors[field.to_sym] << error_code + + false + end + + def errors? + errors.present? + end +end diff --git a/app/services/billable_metric_filters/create_or_update_batch_service.rb b/app/services/billable_metric_filters/create_or_update_batch_service.rb new file mode 100644 index 0000000..9b8003b --- /dev/null +++ b/app/services/billable_metric_filters/create_or_update_batch_service.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module BillableMetricFilters + class CreateOrUpdateBatchService < BaseService + BATCH_SIZE = 1_000 + + def initialize(billable_metric:, filters_params:) + @billable_metric = billable_metric + @filters_params = filters_params + + super + end + + def call + result.filters = [] + + if filters_params.empty? + discard_all_filters + + return result + end + + return result.validation_failure!(errors: {values: ["value_is_mandatory"]}) if any_filter_params_values_blank? + + ActiveRecord::Base.transaction do + filters_params.each do |filter_param| + filter = billable_metric.filters + .create_with(organization_id: billable_metric.organization_id) + .find_or_initialize_by(key: filter_param[:key]) + new_values = (filter_param[:values] || []).uniq + + if filter.persisted? + deleted_values = filter.values - filter_param[:values] + + if deleted_values.present? + filter_values = filter.filter_values + .where( + deleted_values.map { "? = ANY(values)" }.join(" OR "), + *deleted_values + ) + + discard_filter_values_in_batches(filter_values, new_values:) + end + end + + filter.values = new_values + filter.save! + + result.filters << filter + end + + # NOTE: discard all filters that were not created or updated + billable_metric.filters.where.not(id: result.filters.map(&:id)).unscope(:order).find_each do + discard_filter(it) + end + end + + BillableMetricFilters::RefreshDraftInvoicesJob.perform_later(billable_metric.id) + + result + end + + private + + attr_reader :billable_metric, :filters_params + + def any_filter_params_values_blank? + filters_params.any? do |filter_param| + filter_param[:values].blank? + end + end + + def discard_all_filters + ActiveRecord::Base.transaction do + billable_metric.filters.each { discard_filter(it) } + end + end + + def discard_filter(filter) + discard_filter_values_in_batches(filter.filter_values) + + filter.discard! + end + + def discard_filter_values_in_batches(filter_values, new_values: []) + filter_values.unscope(:order).in_batches(of: BATCH_SIZE) do |filter_value_batch| + values_to_trim, values_to_discard = filter_value_batch.partition { |fv| trimmable?(fv, new_values) } + + bulk_update_trimmed_filter_values(values_to_trim, new_values) + discard_filter_values_and_emptied_charge_filters(values_to_discard) + end + end + + def trimmable?(filter_value, new_values) + filter_value.values.intersect?(new_values) + end + + def bulk_update_trimmed_filter_values(filter_values, new_values) + return if filter_values.empty? + + filter_values.group_by { |fv| fv.values & new_values }.each do |result_values, group| + ChargeFilterValue.where(id: group.map(&:id)).update_all( # rubocop:disable Rails/SkipsModelValidations + values: result_values, updated_at: Time.current + ) + end + end + + def discard_filter_values_and_emptied_charge_filters(filter_values) + return if filter_values.empty? + + filter_value_ids = filter_values.map(&:id) + bulk_discard_filter_values(filter_value_ids) + bulk_discard_emptied_charge_filters_for(filter_value_ids) + end + + def bulk_discard_filter_values(filter_value_ids) + ChargeFilterValue + .where(id: filter_value_ids) + .update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + def bulk_discard_emptied_charge_filters_for(filter_value_ids) + charge_filter_ids = ChargeFilterValue + .with_discarded + .where(id: filter_value_ids) + .unscope(:order) + .distinct + .select(:charge_filter_id) + + return if charge_filter_ids.empty? + + ChargeFilter + .where(id: charge_filter_ids, deleted_at: nil) + .where.not( + id: ChargeFilterValue + .where(deleted_at: nil, charge_filter_id: charge_filter_ids) + .select(:charge_filter_id) + ) + .unscope(:order) + .update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + end +end diff --git a/app/services/billable_metric_filters/destroy_all_service.rb b/app/services/billable_metric_filters/destroy_all_service.rb new file mode 100644 index 0000000..0884b5e --- /dev/null +++ b/app/services/billable_metric_filters/destroy_all_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module BillableMetricFilters + class DestroyAllService < BaseService + Result = BaseResult[:billable_metric] + + def initialize(billable_metric) + @billable_metric = billable_metric + super + end + + def call + return result unless billable_metric + return result unless billable_metric.discarded? + + deleted_at = Time.current + + billable_metric.filters.unscope(:order).find_each do |filter| + # rubocop:disable Rails/SkipsModelValidations + filter.filter_values.update_all(deleted_at: deleted_at) + filter.charge_filters.update_all(deleted_at: deleted_at) + # rubocop:enable Rails/SkipsModelValidations + + filter.discard! + end + + result.billable_metric = billable_metric + result + end + + private + + attr_reader :billable_metric + end +end diff --git a/app/services/billable_metrics/aggregation_factory.rb b/app/services/billable_metrics/aggregation_factory.rb new file mode 100644 index 0000000..03e92c9 --- /dev/null +++ b/app/services/billable_metrics/aggregation_factory.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module BillableMetrics + class AggregationFactory + def self.new_instance(charge:, current_usage: false, **attributes) + aggregator_class(charge, current_usage).new( + event_store_class: Events::Stores::StoreFactory.store_class(organization: charge.billable_metric.organization), + charge:, + **attributes + ) + end + + def self.aggregator_class(charge, current_usage) + case charge.billable_metric.aggregation_type.to_sym + when :count_agg + BillableMetrics::Aggregations::CountService + + when :latest_agg + raise(NotImplementedError) if charge.pay_in_advance? && !current_usage + + BillableMetrics::Aggregations::LatestService + + when :max_agg + raise(NotImplementedError) if charge.pay_in_advance? && !current_usage + + BillableMetrics::Aggregations::MaxService + + when :sum_agg + if charge.prorated? + BillableMetrics::ProratedAggregations::SumService + else + BillableMetrics::Aggregations::SumService + end + + when :unique_count_agg + if charge.prorated? + BillableMetrics::ProratedAggregations::UniqueCountService + else + BillableMetrics::Aggregations::UniqueCountService + end + + when :weighted_sum_agg + raise(NotImplementedError) if charge.pay_in_advance? && !current_usage + + BillableMetrics::Aggregations::WeightedSumService + + when :custom_agg + BillableMetrics::Aggregations::CustomService + + else + raise(NotImplementedError) + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/apply_rounding_service.rb b/app/services/billable_metrics/aggregations/apply_rounding_service.rb new file mode 100644 index 0000000..50c52e5 --- /dev/null +++ b/app/services/billable_metrics/aggregations/apply_rounding_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class ApplyRoundingService < ::BaseService + def initialize(billable_metric:, units:) + @billable_metric = billable_metric + @units = units + + super + end + + def call + precision = billable_metric.rounding_precision || 0 + + result.units = case billable_metric.rounding_function&.to_sym + when :ceil + units.ceil(precision) + when :floor + units.floor(precision) + when :round + units.round(precision) + else + units + end + + result + end + + private + + attr_reader :billable_metric, :units + end + end +end diff --git a/app/services/billable_metrics/aggregations/base_service.rb b/app/services/billable_metrics/aggregations/base_service.rb new file mode 100644 index 0000000..fe6702f --- /dev/null +++ b/app/services/billable_metrics/aggregations/base_service.rb @@ -0,0 +1,251 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class BaseService < ::BaseService + Result = BaseResult[ + :aggregator, # Aggregator instance, used in some charge models + :aggregations, # Array of aggregation result when in a grouped by scenario + :aggregation, # Aggregation result computed using the event store + :breakdowns, # Array of breakdowns when presentation_by is used + :grouped_by, # Pricing group keys applied to this aggregation result + :current_usage_units, # Number of aggregated units when computing the current usage + :count, # Number of events used to compute the aggregation + :full_units_number, # Total number of aggregated units without proration + :options, # Extra options passed to the charge models (running_total, aggregation...) + # Sum aggregation fields + :precise_total_amount_cents, # Sum of events precise amount cents when billable metric is configured to use it + # Weighted sum aggregation fields + :total_aggregated_units, # Total number of active units for a weighted sum aggregation + :variation, # Number of new active units on the current period for a weighted sum aggregation + # Custom aggregation fields + :current_amount, # Current amount computed in a custom aggregation scenario + :custom_aggregation, # Custom aggregation result (Hash with total_units, and amount fields) + # Pay in advance fields + :pay_in_advance_event, # Event that is triggering a pay in advance aggregation + :pay_in_advance_aggregation, # Aggregation result for a single pay in advance event + :pay_in_advance_precise_total_amount_cents, # Precise total amount in cents when in a pay in advance scenario + # Cached aggregation fields + :current_aggregation, # Current total aggregation cached in a pay in advance scenario (billing and current usage) + :max_aggregation, # Maximum aggregation result cached in a pay in advance scenario (billing and current usage) + :max_aggregation_with_proration, # Similar to max_aggregation but with proration on billing period applied + :units_applied, # Number of units applied by the event and cached in a pay in advance scenario (billing and current usage) + :recurring_updated_at # Date when the recurring cached aggregation was updated + ] + PerEventAggregationResult = BaseResult[:event_aggregation] + + def self.null_result(result, grouped_by_keys: nil, apply_aggregation: false) + if apply_aggregation && grouped_by_keys.present? + result.aggregations = [null_result(BaseService::Result.new, grouped_by_keys: grouped_by_keys)] + else + result.grouped_by = grouped_by_keys.index_with { nil } if grouped_by_keys + result.aggregation = 0 + result.count = 0 + result.current_usage_units = 0 + result.options = {running_total: []} + end + result + end + + def initialize(event_store_class:, charge:, subscription:, boundaries:, filters: {}, bypass_aggregation: false) + super(nil) + @event_store_class = event_store_class + @charge = charge + @subscription = subscription + + @filters = filters + @charge_filter = filters[:charge_filter] + @event = filters[:event] + @grouped_by = filters[:grouped_by] + @grouped_by_values = filters[:grouped_by_values] + @presentation_by = filters[:presentation_by] + @uniq_grouped_by_and_presentation_by = ((grouped_by || []) + (presentation_by || [])).uniq + + @boundaries = boundaries + + @bypass_aggregation = bypass_aggregation + + result.aggregator = self + result.pay_in_advance_event = event if event + end + + def aggregate(options: {}) + if grouped_by.present? + compute_grouped_by_aggregation(options:) + if charge.dynamic? + compute_grouped_by_precise_total_amount_cents(options:) + end + + result.aggregations.each { apply_rounding(it) } + else + compute_aggregation(options:) + if charge.dynamic? + compute_precise_total_amount_cents(options:) + end + + apply_rounding(result) + end + result + end + + def compute_aggregation(options: {}) + raise NotImplementedError + end + + def compute_grouped_by_aggregation(options: {}) + raise NotImplementedError + end + + def compute_precise_total_amount_cents(options: {}) + raise NotImplementedError + end + + def compute_grouped_by_precise_total_amount_cents(options: {}) + raise NotImplementedError + end + + # NOTE: + # - With include_event_value: true, the current event (not yet persisted) will be included in the list of event values + # Used only for estimate_fees. + # - With exclude_event: true, the current event (persisted) will be excluded from the list of event values + # Used only for in advance billing + def per_event_aggregation(exclude_event: false, include_event_value: false, grouped_by_values: nil) + PerEventAggregationResult.new.tap do |result| + result.event_aggregation = event_store.with_grouped_by_values(grouped_by_values) do + compute_per_event_aggregation(exclude_event:, include_event_value:) + end + end + end + + # Exposes a null result that carries this aggregator instance, so downstream charge models + # can dispatch `per_event_aggregation` through the real aggregator rather than nil. + def empty_results + self.class.null_result(result, grouped_by_keys: grouped_by, apply_aggregation: true) + result + end + + protected + + attr_accessor :event_store_class, + :charge, + :subscription, + :filters, + :charge_filter, + :event, + :boundaries, + :grouped_by, + :grouped_by_values, + :presentation_by, + :bypass_aggregation, + :uniq_grouped_by_and_presentation_by + + delegate :billable_metric, to: :charge + + delegate :customer, to: :subscription + + def event_store + @event_store ||= event_store_class.new( + code: billable_metric.code, + subscription:, + boundaries:, + filters:, + deduplicate: deduplicate? + ) + end + + def deduplicate? + organization = subscription&.organization + return false unless organization + + organization.clickhouse_events_store? && organization.clickhouse_deduplication_enabled? + end + + def from_datetime + boundaries[:from_datetime] + end + + def to_datetime + boundaries[:to_datetime] + end + + def event_value + return unless event + + (event.properties || {})[billable_metric.field_name] || 0 + end + + def handle_in_advance_current_usage(total_aggregation, target_result: result) + cached_aggregation = find_cached_aggregation( + with_from_datetime: from_datetime, + with_to_datetime: to_datetime, + grouped_by: target_result.grouped_by + ) + + if cached_aggregation + aggregation = total_aggregation - + BigDecimal(cached_aggregation.current_aggregation) + + BigDecimal(cached_aggregation.max_aggregation) + + target_result.aggregation = aggregation + else + target_result.aggregation = total_aggregation + end + + target_result.current_usage_units = total_aggregation + + target_result.aggregation = 0 if target_result.aggregation.negative? + target_result.current_usage_units = 0 if target_result.current_usage_units.negative? + end + + def should_bypass_aggregation? + return false if billable_metric.recurring? + + bypass_aggregation + end + + def empty_result + self.class.null_result(result) + end + + # This method fetches the latest cached aggregation in current period. If such a record exists we know that + # previous aggregation and previous maximum aggregation are stored there. Fetching these values + # would help us in pay in advance value calculation without iterating through all events in current period + def find_cached_aggregation(with_from_datetime:, with_to_datetime:, grouped_by: nil) + query = CachedAggregation + .where(organization_id: billable_metric.organization_id) + .where(external_subscription_id: subscription.external_id) + .where(charge_id: charge.id) + .from_datetime(with_from_datetime) + .to_datetime(with_to_datetime) + .where(grouped_by: grouped_by.presence || {}) + .order(timestamp: :desc, created_at: :desc) + + query = query.where.not(event_transaction_id: event.transaction_id) if event.present? + query = query.where(charge_filter_id: charge_filter.id) if charge_filter + + query.first + end + + def apply_rounding(result) + return if billable_metric.rounding_function.blank? + return if event.present? # Rouding does not apply to the in advance billing + + result.aggregation = BillableMetrics::Aggregations::ApplyRoundingService + .call(billable_metric:, units: result.aggregation) + .units + + if result.full_units_number.present? + result.full_units_number = BillableMetrics::Aggregations::ApplyRoundingService + .call(billable_metric:, units: result.full_units_number) + .units + end + + if result.current_usage_units.present? + result.current_usage_units = BillableMetrics::Aggregations::ApplyRoundingService + .call(billable_metric:, units: result.current_usage_units) + .units + end + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/count_service.rb b/app/services/billable_metrics/aggregations/count_service.rb new file mode 100644 index 0000000..1ac2bf4 --- /dev/null +++ b/app/services/billable_metrics/aggregations/count_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class CountService < BillableMetrics::Aggregations::BaseService + def compute_aggregation(options: {}) + return empty_result if should_bypass_aggregation? + + result.aggregation = event_store.count + result.current_usage_units = result.aggregation + result.count = result.aggregation + result.pay_in_advance_aggregation = BigDecimal(1) + + if presentation_by.present? + result.breakdowns = event_store.grouped_count(uniq_grouped_by_and_presentation_by) + end + + result.options = {running_total: running_total(options, aggregation: result.aggregation)} + result + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group. + # + # This logic is only applicable for in arrears aggregation + # (exept for the current_usage update) + # as pay in advance aggregation will be computed on a single group + # with the grouped_by_values filter + def compute_grouped_by_aggregation(options: {}) + return empty_results if should_bypass_aggregation? + + aggregations = event_store.grouped_count + return empty_results if aggregations.blank? + + result.aggregations = aggregations.map do |aggregation| + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + group_result.aggregation = aggregation[:value] + group_result.count = aggregation[:value] + group_result.current_usage_units = aggregation[:value] + group_result.options = {running_total: running_total(options, aggregation: group_result.aggregation)} + group_result + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_count(uniq_grouped_by_and_presentation_by) + end + + result + end + + # NOTE: Return cumulative sum of event count based on the number of free units + # (per_events or per_total_aggregation). + def running_total(options, aggregation:) + free_units_per_events = options[:free_units_per_events].to_i + free_units_per_total_aggregation = BigDecimal(options[:free_units_per_total_aggregation] || 0) + + return [] if free_units_per_events.zero? && free_units_per_total_aggregation.zero? + + (1..aggregation).to_a + end + + def compute_per_event_aggregation(exclude_event:, include_event_value:) + values = (0...event_store.count).map { |_| 1 } + values << 1 if include_event_value + values + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/custom_service.rb b/app/services/billable_metrics/aggregations/custom_service.rb new file mode 100644 index 0000000..b90aa67 --- /dev/null +++ b/app/services/billable_metrics/aggregations/custom_service.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class CustomService < BillableMetrics::Aggregations::BaseService + # NOTE: DEPRECATED service + + INITIAL_STATE = {total_units: BigDecimal(0), amount: BigDecimal(0)}.freeze + BATCH_SIZE = 1000 + + def compute_aggregation(options: {}) + return empty_result if should_bypass_aggregation? + + result.count = event_store.count + + aggregation_result = perform_custom_aggregation(grouped_by_values:) + in_advance_aggregation_result = compute_pay_in_advance_aggregation + + result.aggregation = aggregation_result[:total_units] + result.current_usage_units = result.aggregation + result.custom_aggregation = event ? in_advance_aggregation_result : aggregation_result + result.options = options + result.pay_in_advance_aggregation = in_advance_aggregation_result[:total_units] + + # NOTE: Compute refresh time for cached aggregation + result.recurring_updated_at = event_store.last_event&.timestamp || from_datetime if billable_metric.recurring? + + result + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group. + # + # This logic is only applicable for in arrears aggregation + # (exept for the current_usage update) + # as pay in advance aggregation will be computed on a single group + # with the grouped_by_values filter + def compute_grouped_by_aggregation(options: {}) + return empty_results if should_bypass_aggregation? + + counts = event_store.grouped_count + return empty_results if counts.blank? + + last_events = [] + last_events = event_store.grouped_last_event if billable_metric.recurring? + + result.aggregations = counts.map do |aggregation| + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + group_result.count = aggregation[:value] + + aggregation_result = perform_custom_aggregation( + target_result: group_result, + grouped_by_values: aggregation[:groups] + ) + + group_result.aggregation = aggregation_result[:total_units] + group_result.current_usage_units = group_result.aggregation + group_result.custom_aggregation = aggregation_result + group_result.options = options + + if billable_metric.recurring? + last_event = last_events.find { |c| c[:groups] == aggregation[:groups] } + + group_result.recurring_updated_at = last_event&.[](:timestamp) || from_datetime + end + + group_result + end + + result + end + + def compute_per_event_aggregation(exclude_event:, include_event_value:) + # TODO: Implement custom aggregation logic returning 1 value per event + event_store.events_properties + end + + private + + def custom_properties + return charge_filter.properties["custom_properties"] if charge_filter.present? + + charge.properties["custom_properties"] + end + + def current_state(grouped_by_values:) + return latest_state(grouped_by_values:) if billable_metric.recurring? + + INITIAL_STATE + end + + def latest_state(grouped_by_values:) + truncated_datetime = to_datetime.change(usec: 0) + + query = CachedAggregation + .where(organization_id: billable_metric.organization_id) + .where(external_subscription_id: subscription.external_id) + .where(charge_id: charge.id) + .where("cached_aggregations.timestamp < ?", truncated_datetime) + .where(grouped_by: grouped_by_values.presence || {}) + .order(timestamp: :desc, created_at: :desc) + + query = query.where(charge_filter_id: charge_filter.id) if charge_filter + cached_aggregation = query.first + + if cached_aggregation + return { + total_units: cached_aggregation.current_aggregation, + amount: cached_aggregation.current_amount + } + end + + # TODO: fetch latest state from the previous subscription + + INITIAL_STATE + end + + def perform_custom_aggregation(target_result: result, grouped_by_values: nil) + total_batches = (target_result.count.to_f / BATCH_SIZE).ceil + state = current_state(grouped_by_values:) + + # NOTE: for grouped_by aggregations we need to initialize + # the event store with the grouped_by values to only fetch the events + # of the group + store = event_store + if grouped_by_values + store = event_store_class.new( + code: billable_metric.code, + subscription:, + boundaries:, + filters: filters.merge(grouped_by_values:) + ) + end + + # NOTE: Loop over events by batch + (1..total_batches).each do |batch| + events_properties = store.events(ordered: true).page(batch).per(BATCH_SIZE) + .map { |event| {timestamp: event.timestamp, properties: event.properties} } + + state = sandboxed_aggregation(events_properties, state) + end + + state + end + + def sandboxed_aggregation(events_properties, state) + sandboxed_result = LagoUtils::RubySandbox.run(aggregator(events_properties, state)) + + { + total_units: BigDecimal(sandboxed_result["total_units"].to_s), + amount: BigDecimal(sandboxed_result["amount"].to_s) + } + end + + def aggregator(events_properties, current_state) + <<~RUBY + class EventValues + def initialize(timestamp:, properties:) + @timestamp = timestamp + @properties = properties + end + + attr_reader :timestamp, :properties + end + + initial_state = { + total_units: BigDecimal('#{current_state[:total_units]}'), + amount: BigDecimal('#{current_state[:amount]}') + } + + aggregation_properties = JSON.parse('#{custom_properties.to_json}') + + #{billable_metric.custom_aggregator} + + events = [ + #{events_properties.map do |event| + "EventValues.new(timestamp: Time.at(#{event[:timestamp].to_f}),properties: #{event[:properties].as_json})" + end.join(",\n")} + ] + + result = events.each_with_object(initial_state.dup) do |event, agg| + res = aggregate(event, agg, aggregation_properties) + + agg[:total_units] = res[:total_units] + agg[:amount] += res[:amount] + end + + result + RUBY + end + + def compute_pay_in_advance_aggregation + return INITIAL_STATE unless event + + cached_aggregation = find_cached_aggregation( + with_from_datetime: from_datetime, + with_to_datetime: to_datetime, + grouped_by: grouped_by_values + ) + + # NOTE: The aggregation was never performed on the period, + # we need to perform a full aggregation and cache it + unless cached_aggregation + state = perform_custom_aggregation(grouped_by_values:) + + assign_cached_metadata( + current_aggregation: state[:total_units], + max_aggregation: state[:total_units], + units_applied: state[:total_units], + current_amount: state[:amount] + ) + + return state + end + + # NOTE: Retrieve values from the previous aggregation + old_aggregation = cached_aggregation.current_aggregation + old_max = cached_aggregation.max_aggregation + old_amount = cached_aggregation.current_amount + + # NOTE: compute aggregation for the current event, using the previous state + event_aggregation = sandboxed_aggregation( + [{timestamp: event.timestamp, properties: event.properties}], + {total_units: old_aggregation, amount: old_amount} + ) + + units_applied = event_aggregation[:total_units] - old_aggregation + max_aggregation = if event_aggregation[:total_units] > old_max + event_aggregation[:total_units] + else + old_max + end + + # NOTE: Update the metadata for the current event + assign_cached_metadata( + current_aggregation: event_aggregation[:total_units], + max_aggregation:, + units_applied:, + current_amount: event_aggregation[:amount] + ) + + # NOTE: Return the amount and units to be charged for the current event + { + total_units: units_applied, + amount: event_aggregation[:amount] - old_amount + } + end + + def assign_cached_metadata(current_aggregation:, max_aggregation:, units_applied: nil, current_amount: nil) + result.current_aggregation = current_aggregation unless current_aggregation.nil? + result.max_aggregation = max_aggregation unless max_aggregation.nil? + result.units_applied = units_applied unless units_applied.nil? + result.current_amount = current_amount unless current_amount.nil? + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/latest_service.rb b/app/services/billable_metrics/aggregations/latest_service.rb new file mode 100644 index 0000000..2abc07d --- /dev/null +++ b/app/services/billable_metrics/aggregations/latest_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class LatestService < BillableMetrics::Aggregations::BaseService + def initialize(...) + super + + event_store.numeric_property = true + event_store.aggregation_property = billable_metric.field_name + end + + def compute_aggregation(options: {}) + return empty_result if should_bypass_aggregation? + + result.aggregation = compute_aggregation_value(event_store.last) + result.count = event_store.count + + if presentation_by.present? + result.breakdowns = event_store.grouped_last(uniq_grouped_by_and_presentation_by) + end + + result.options = options + result + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group + def compute_grouped_by_aggregation(*) + return empty_results if should_bypass_aggregation? + + aggregations = event_store.grouped_last + return empty_results if aggregations.blank? + + counts = event_store.grouped_count + + result.aggregations = aggregations.map do |aggregation| + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + group_result.aggregation = compute_aggregation_value(aggregation[:value]) + + count = counts.find { |c| c[:groups] == aggregation[:groups] } || {} + group_result.count = count[:value] || 0 + group_result + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_last(uniq_grouped_by_and_presentation_by) + end + + result + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + protected + + def compute_aggregation_value(latest_value) + result = BigDecimal((latest_value || 0).to_s) + return BigDecimal(0) if result.negative? + + result + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/max_service.rb b/app/services/billable_metrics/aggregations/max_service.rb new file mode 100644 index 0000000..8c7e95f --- /dev/null +++ b/app/services/billable_metrics/aggregations/max_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class MaxService < BillableMetrics::Aggregations::BaseService + def initialize(...) + super + + event_store.numeric_property = true + event_store.aggregation_property = billable_metric.field_name + end + + def compute_aggregation(options: {}) + return empty_result if should_bypass_aggregation? + + result.aggregation = event_store.max || 0 + result.count = event_store.count + + if presentation_by.present? + result.breakdowns = event_store.grouped_max(uniq_grouped_by_and_presentation_by) + end + + result.options = options + result + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + def compute_grouped_by_aggregation(options) + return empty_results if should_bypass_aggregation? + + aggregations = event_store.grouped_max + return empty_results if aggregations.blank? + + counts = event_store.grouped_count + + result.aggregations = aggregations.map do |aggregation| + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + group_result.aggregation = aggregation[:value] + group_result.options = options + + count = counts.find { |c| c[:groups] == aggregation[:groups] } || {} + group_result.count = count[:value] || 0 + + group_result + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_max(uniq_grouped_by_and_presentation_by) + end + + result + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + # Note: include_event_value is ignored as the model does not support in advance billing + def compute_per_event_aggregation(exclude_event:, include_event_value:) + max_value = event_store.max || 0 + event_values = event_store.events_values + max_value_seen = false + + # NOTE: returns the first max value, 0 for all other events + event_values.map do |value| + if !max_value_seen && value == max_value + max_value_seen = true + + next value + end + + 0 + end + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/sum_service.rb b/app/services/billable_metrics/aggregations/sum_service.rb new file mode 100644 index 0000000..86e9e62 --- /dev/null +++ b/app/services/billable_metrics/aggregations/sum_service.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class SumService < BillableMetrics::Aggregations::BaseService + def initialize(...) + super + + event_store.numeric_property = true + event_store.aggregation_property = billable_metric.field_name + event_store.use_from_boundary = !billable_metric.recurring + end + + def compute_aggregation(options: {}) + return empty_result if should_bypass_aggregation? + + aggregation = event_store.sum + + if options[:is_pay_in_advance] && options[:is_current_usage] + handle_in_advance_current_usage(aggregation) + else + result.aggregation = aggregation + end + + result.pay_in_advance_aggregation = compute_pay_in_advance_aggregation + result.count = event_store.count + + if presentation_by.present? + result.breakdowns = event_store.grouped_sum(uniq_grouped_by_and_presentation_by) + end + + result.options = {running_total: running_total(options)} + result + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group. + # + # This logic is only applicable for in arrears aggregation + # (except for the current_usage update) + # as pay in advance aggregation will be computed on a single group + # with the grouped_by_values filter + def compute_grouped_by_aggregation(options: {}) + return empty_results if should_bypass_aggregation? + + aggregations = event_store.grouped_sum + return empty_results if aggregations.blank? + + counts = event_store.grouped_count + + if presentation_by.present? + result.breakdowns = event_store.grouped_sum(uniq_grouped_by_and_presentation_by) + end + + merged_hash = {} + aggregations.each do |aggregation| + merged_hash[aggregation[:groups]] = {aggregation: aggregation[:value]} + end + counts.each do |count| + next unless merged_hash[count[:groups]] + merged_hash[count[:groups]] = merged_hash[count[:groups]].merge({count: count[:value]}) + end + + result.aggregations = merged_hash.map do |groups, merged_aggregation_count| + group_result = BaseService::Result.new + group_result.grouped_by = groups + + aggregation_value = merged_aggregation_count[:aggregation] + + if options[:is_pay_in_advance] && options[:is_current_usage] + handle_in_advance_current_usage(aggregation_value, target_result: group_result) + else + group_result.aggregation = aggregation_value + end + + group_result.count = merged_aggregation_count[:count] || 0 + group_result.options = {running_total: running_total(options, grouped_by_values: group_result.grouped_by)} + group_result + end + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + def compute_precise_total_amount_cents(options: {}) + result.precise_total_amount_cents = event_store.sum_precise_total_amount_cents + result.pay_in_advance_precise_total_amount_cents = event&.precise_total_amount_cents || 0 + end + + def compute_grouped_by_precise_total_amount_cents(options: {}) + aggregations = event_store.grouped_sum_precise_total_amount_cents + return result if aggregations.blank? + + aggregations.each do |aggregation| + group_result = result.aggregations.find { |group_result| group_result.grouped_by == aggregation[:groups] } + next unless group_result + + group_result.precise_total_amount_cents = aggregation[:value] + end + end + + # NOTE: Return cumulative sum of field_name based on the number of free units + # (per_events or per_total_aggregation). + def running_total(options, grouped_by_values: nil) + free_units_per_events = options[:free_units_per_events].to_i + free_units_per_total_aggregation = BigDecimal(options[:free_units_per_total_aggregation] || 0) + + return [] if free_units_per_events.zero? && free_units_per_total_aggregation.zero? + + event_store.with_grouped_by_values(grouped_by_values) do + return running_total_per_events(free_units_per_events) unless free_units_per_events.zero? + + running_total_per_aggregation(free_units_per_total_aggregation) + end + end + + def running_total_per_events(limit) + total = 0.0 + values = event_store.events_values(limit:) + + # Handles event estimate as event is not persisted + if event && !event.persisted && values.size < limit + values << event_value + end + + values.map { |x| total += x } + end + + def running_total_per_aggregation(aggregation) + total = 0.0 + + values = event_store.events_values + values << event_value if event && !event.persisted + + values.each_with_object([]) do |val, accumulator| + break accumulator if aggregation < total + + accumulator << total += val + end + end + + def compute_pay_in_advance_aggregation + return BigDecimal(0) unless event + return BigDecimal(0) if event.properties.blank? + + value = event.properties.fetch(billable_metric.field_name, 0).to_s + + cached_aggregation = find_cached_aggregation( + with_from_datetime: from_datetime, + with_to_datetime: to_datetime, + grouped_by: grouped_by_values + ) + + unless cached_aggregation + return_value = BigDecimal(value).negative? ? "0" : value + handle_event_metadata(current_aggregation: value, max_aggregation: value, units_applied: value) + + return BigDecimal(return_value) + end + + current_aggregation = BigDecimal(cached_aggregation.current_aggregation) + BigDecimal(value) + + old_max = BigDecimal(cached_aggregation.max_aggregation) + + result = if current_aggregation > old_max + diff = [current_aggregation, current_aggregation - old_max].max + handle_event_metadata(current_aggregation:, max_aggregation: diff) + + current_aggregation - old_max + else + handle_event_metadata(current_aggregation:, max_aggregation: old_max, units_applied: value) + + 0 + end + + BigDecimal(result) + end + + def compute_per_event_aggregation(exclude_event:, include_event_value:) + values = event_store.events_values(force_from: true, exclude_event:) + values += [event_value] if include_event_value + values + end + + def handle_event_metadata(current_aggregation: nil, max_aggregation: nil, units_applied: nil) + result.current_aggregation = current_aggregation unless current_aggregation.nil? + result.max_aggregation = max_aggregation unless max_aggregation.nil? + result.units_applied = units_applied unless units_applied.nil? + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/unique_count_service.rb b/app/services/billable_metrics/aggregations/unique_count_service.rb new file mode 100644 index 0000000..656df42 --- /dev/null +++ b/app/services/billable_metrics/aggregations/unique_count_service.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class UniqueCountService < BillableMetrics::Aggregations::BaseService + def initialize(...) + super + + event_store.aggregation_property = billable_metric.field_name + event_store.use_from_boundary = !billable_metric.recurring + end + + def compute_aggregation(options: {}) + return empty_result if should_bypass_aggregation? + + aggregation = event_store.unique_count.ceil(5) + + if options[:is_pay_in_advance] && options[:is_current_usage] + handle_in_advance_current_usage(aggregation) + else + result.aggregation = aggregation + end + + result.pay_in_advance_aggregation = BigDecimal(compute_pay_in_advance_aggregation) + + if presentation_by.present? + result.breakdowns = event_store.grouped_unique_count(uniq_grouped_by_and_presentation_by) + end + + result.options = {running_total: running_total(options, aggregation:)} + result.count = result.aggregation + result + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group. + # + # This logic is only applicable for in arrears aggregation + # (exept for the current_usage update) + # as pay in advance aggregation will be computed on a single group + # with the grouped_by_values filter + def compute_grouped_by_aggregation(options: {}) + return empty_results if should_bypass_aggregation? + + aggregations = event_store.grouped_unique_count + return empty_results if aggregations.blank? + + result.aggregations = aggregations.map do |aggregation| + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + + if options[:is_pay_in_advance] && options[:is_current_usage] + handle_in_advance_current_usage(aggregation[:value], target_result: group_result) + else + group_result.aggregation = aggregation[:value] + end + + group_result.count = aggregation[:value] + group_result.options = {running_total: running_total(options, aggregation: group_result.aggregation)} + group_result + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_unique_count(uniq_grouped_by_and_presentation_by) + end + + result + end + + def compute_pay_in_advance_aggregation + return 0 unless event + return 0 if event.properties.blank? + + active_unique_property = event_store.active_unique_property?(event) + + newly_applied_units = if operation_type == :add + # NOTE: ensure the unique property is not already present + active_unique_property ? 0 : 1 + else + 0 + end + + cached_aggregation = find_cached_aggregation( + with_from_datetime: from_datetime, + with_to_datetime: to_datetime, + grouped_by: grouped_by_values + ) + + unless cached_aggregation + handle_event_metadata( + current_aggregation: newly_applied_units, + max_aggregation: newly_applied_units, + units_applied: newly_applied_units + ) + + return newly_applied_units + end + + old_aggregation = BigDecimal(cached_aggregation.current_aggregation) + old_max = BigDecimal(cached_aggregation.max_aggregation) + + current_aggregation = if operation_type == :add + old_aggregation + newly_applied_units + else + # NOTE: ensure the unique property is active + old_aggregation - (active_unique_property ? 1 : 0) + end + + if current_aggregation > old_max + handle_event_metadata(current_aggregation:, max_aggregation: current_aggregation) + + 1 + else + handle_event_metadata(current_aggregation:, max_aggregation: old_max, units_applied: newly_applied_units) + + 0 + end + end + + # NOTE: Return cumulative sum of event count based on the number of free units + # (per_events or per_total_aggregation). + def running_total(options, aggregation:) + free_units_per_events = options[:free_units_per_events].to_i + free_units_per_total_aggregation = BigDecimal(options[:free_units_per_total_aggregation] || 0) + + return [] if free_units_per_events.zero? && free_units_per_total_aggregation.zero? + + (1..aggregation).to_a + end + + def compute_per_event_aggregation(exclude_event:, include_event_value:) + count = event_store.events_values(force_from: true).count + count += 1 if include_event_value + (0...count).map { |_| 1 } + end + + def count_unique_group_scope(events) + events = events.where("quantified_events.properties @> ?", {group.key.to_s => group.value}.to_json) + return events unless group.parent + + events.where("quantified_events.properties @> ?", {group.parent.key.to_s => group.parent.value}.to_json) + end + + protected + + def operation_type + @operation_type ||= event.properties.fetch("operation_type", "add")&.to_sym + end + + def handle_event_metadata(current_aggregation: nil, max_aggregation: nil, units_applied: nil) + result.current_aggregation = current_aggregation unless current_aggregation.nil? + result.max_aggregation = max_aggregation unless max_aggregation.nil? + result.units_applied = units_applied unless units_applied.nil? + end + end + end +end diff --git a/app/services/billable_metrics/aggregations/weighted_sum_service.rb b/app/services/billable_metrics/aggregations/weighted_sum_service.rb new file mode 100644 index 0000000..ab376e6 --- /dev/null +++ b/app/services/billable_metrics/aggregations/weighted_sum_service.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module BillableMetrics + module Aggregations + class WeightedSumService < BillableMetrics::Aggregations::BaseService + def initialize(...) + super + + event_store.numeric_property = true + event_store.aggregation_property = billable_metric.field_name + end + + def compute_aggregation(*) + return empty_result if should_bypass_aggregation? + + result.aggregation = event_store.weighted_sum(initial_value:).ceil(20) + result.count = event_store.count + result.variation = event_store.sum || 0 + result.total_aggregated_units = result.variation + result.options = {} + + if presentation_by.present? + result.breakdowns = event_store.grouped_weighted_sum(uniq_grouped_by_and_presentation_by, initial_value:) + end + + if billable_metric.recurring? + result.total_aggregated_units = latest_value + result.variation + result.recurring_updated_at = event_store.last_event&.timestamp || from_datetime + end + + result + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group. + def compute_grouped_by_aggregation(*) + return empty_results if should_bypass_aggregation? + + aggregations = event_store.grouped_weighted_sum(initial_values: grouped_latest_values) + return empty_results if aggregations.blank? + + counts = event_store.grouped_count + sums = event_store.grouped_sum + + latest_values = [] + last_events = [] + if billable_metric.recurring? + latest_values = grouped_latest_values + last_events = event_store.grouped_last_event + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_weighted_sum(uniq_grouped_by_and_presentation_by, initial_values: grouped_latest_values) + end + + result.aggregations = aggregations.map do |aggregation| + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + + aggregation_value = aggregation[:value] + group_result.aggregation = aggregation_value + group_result.count = counts.find { |c| c[:groups] == aggregation[:groups] }&.[](:value) || 0 + group_result.variation = sums.find { |c| c[:groups] == aggregation[:groups] }&.[](:value) || 0 + group_result.total_aggregated_units = group_result.variation + + if billable_metric.recurring? + latest_value = latest_values.find { |c| c[:groups] == aggregation[:groups] }&.[](:value) || 0 + last_event = last_events.find { |c| c[:groups] == aggregation[:groups] } + + group_result.total_aggregated_units = latest_value + group_result.variation + group_result.recurring_updated_at = last_event&.[](:timestamp) || from_datetime + end + + group_result + end + + result + end + + private + + def initial_value + return 0 unless billable_metric.recurring? + + latest_value + end + + def latest_value + return @latest_value if @latest_value + + query = CachedAggregation + .where(organization_id: billable_metric.organization_id) + .where(external_subscription_id: subscription.external_id) + .where(charge_id: charge.id) + .where(timestamp: ...from_datetime) + .where(grouped_by: grouped_by.presence || {}) + .order(timestamp: :desc, created_at: :desc) + + query = query.where(charge_filter_id: charge_filter.id) if charge_filter + cached_aggregation = query.first + + return @latest_value = cached_aggregation.current_aggregation if cached_aggregation + return @latest_value = BigDecimal(latest_value_from_events) if subscription.previous_subscription_id? + + @latest_value = BigDecimal(0) + end + + # NOTE: In case of upgrade/downgrade, if latest value is not persisted yet, + # we need to fetch latest value from previous events attached to the same external subscription ID + def latest_value_from_events + event_store = event_store_class.new( + code: billable_metric.code, + subscription:, + boundaries: {to_datetime: from_datetime - 1.second}, + filters: + ) + + event_store.use_from_boundary = false + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + event_store.sum + end + + def grouped_latest_values + return @grouped_latest_values if @grouped_latest_values + + query = CachedAggregation + .where(organization_id: billable_metric.organization_id) + .where(external_subscription_id: subscription.external_id) + .where(charge_id: charge.id) + .where(timestamp: ...from_datetime) + .order(timestamp: :desc, created_at: :desc) + + grouped_by.each do |key| + query = query.where("grouped_by?:key", key:) + end + + query = query.where(charge_filter_id: charge_filter.id) if charge_filter + + if query.all.any? + return @grouped_latest_values = query.map do |cached_aggregation| + { + groups: cached_aggregation.grouped_by, + value: cached_aggregation.current_aggregation + } + end + end + return @grouped_latest_values = grouped_latest_values_from_events if subscription.previous_subscription_id? + + @grouped_latest_values = {} + end + + def grouped_latest_values_from_events + event_store = event_store_class.new( + code: billable_metric.code, + subscription:, + boundaries: {to_datetime: from_datetime - 1.second}, + filters: + ) + + event_store.use_from_boundary = false + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + event_store.grouped_sum + end + end + end +end diff --git a/app/services/billable_metrics/breakdown/item.rb b/app/services/billable_metrics/breakdown/item.rb new file mode 100644 index 0000000..171d512 --- /dev/null +++ b/app/services/billable_metrics/breakdown/item.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module BillableMetrics + module Breakdown + Item = Data.define(:date, :action, :amount, :duration, :total_duration) + end +end diff --git a/app/services/billable_metrics/breakdown/sum_service.rb b/app/services/billable_metrics/breakdown/sum_service.rb new file mode 100644 index 0000000..479e644 --- /dev/null +++ b/app/services/billable_metrics/breakdown/sum_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module BillableMetrics + module Breakdown + class SumService < BillableMetrics::ProratedAggregations::SumService + Result = BaseResult[:aggregator, :breakdown] + + def breakdown + breakdown = persisted_breakdown + breakdown += period_breakdown + + # NOTE: in the breakdown, dates are in customer timezone + result.breakdown = breakdown.sort_by(&:date) + result + end + + private + + def from_date_in_customer_timezone + from_datetime.in_time_zone(customer.applicable_timezone).to_date + end + + def to_date_in_customer_timezone + to_datetime.in_time_zone(customer.applicable_timezone).to_date + end + + def persisted_breakdown + event_store = event_store_class.new( + code: billable_metric.code, + subscription:, + boundaries: {to_datetime: from_datetime}, + filters: + ) + + event_store.use_from_boundary = false + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + persisted_sum = event_store.sum + return [] if persisted_sum.zero? + + [ + Item.new( + date: from_date_in_customer_timezone, + action: persisted_sum.negative? ? "remove" : "add", + amount: persisted_sum, + duration: (to_date_in_customer_timezone + 1.day - from_date_in_customer_timezone).to_i, + total_duration: period_duration + ) + ] + end + + def period_breakdown + event_store.sum_date_breakdown.map do |aggregation| + Item.new( + date: aggregation[:date], + action: aggregation[:value].negative? ? "remove" : "add", + amount: aggregation[:value], + duration: (to_date_in_customer_timezone + 1.day - aggregation[:date]).to_i, + total_duration: period_duration + ) + end + end + end + end +end diff --git a/app/services/billable_metrics/breakdown/unique_count_service.rb b/app/services/billable_metrics/breakdown/unique_count_service.rb new file mode 100644 index 0000000..4b8d8db --- /dev/null +++ b/app/services/billable_metrics/breakdown/unique_count_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module BillableMetrics + module Breakdown + class UniqueCountService < BillableMetrics::ProratedAggregations::UniqueCountService + Result = BaseResult[:aggregator, :breakdown] + + def breakdown + breakdown = event_store.prorated_unique_count_breakdown(with_remove: true) + .group_by { |r| r["property"] } + .map do |_, rows| + row = rows.first + operation_type = row["operation_type"] + + # NOTE: breakdown, is based only on the current period + datetime = (row["timestamp"] < from_datetime) ? from_datetime : row["timestamp"] + + if rows.count.even? # NOTE: add then remove + operation_type = (row["timestamp"] < from_datetime) ? "remove" : "add_and_removed" + datetime = rows.last["timestamp"] unless operation_type == "add_and_removed" + elsif rows.count > 2 + operation_type = "add" + end + + Item.new( + date: datetime.in_time_zone(customer.applicable_timezone).to_date, + action: operation_type, + amount: row["prorated_value"].ceil, + duration: duration(rows), + total_duration: period_duration + ) + end + + # NOTE: in the breakdown, dates are in customer timezone + result.breakdown = breakdown.sort_by(&:date) + result + end + + private + + def duration(rows) + prorated_value = if rows.count > 2 + rows.sum { |h| h["prorated_value"] } + else + rows.first["prorated_value"] + end + + ((to_datetime - from_datetime).fdiv(1.day).round * prorated_value).round + end + end + end +end diff --git a/app/services/billable_metrics/create_service.rb b/app/services/billable_metrics/create_service.rb new file mode 100644 index 0000000..ef6bb3d --- /dev/null +++ b/app/services/billable_metrics/create_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module BillableMetrics + class CreateService < BaseService + def initialize(args = {}) + @args = args + super + end + + activity_loggable( + action: "billable_metric.created", + record: -> { result.billable_metric } + ) + + def call + organization = Organization.find_by(id: args[:organization_id]) + + if args[:aggregation_type]&.to_sym == :custom_agg && !organization&.custom_aggregation + return result.forbidden_failure! + end + + ActiveRecord::Base.transaction do + metric = BillableMetric.create!( + organization_id: organization&.id, + name: args[:name], + code: args[:code], + description: args[:description], + recurring: args[:recurring] || false, + aggregation_type: args[:aggregation_type]&.to_sym, + field_name: args[:field_name], + rounding_function: args[:rounding_function]&.to_sym, + rounding_precision: args[:rounding_precision], + weighted_interval: args[:weighted_interval]&.to_sym, + expression: args[:expression] + ) + + if args[:filters].present? + BillableMetricFilters::CreateOrUpdateBatchService.call( + billable_metric: metric, + filters_params: args[:filters].map { |f| f.to_h.with_indifferent_access } + ).raise_if_error! + end + + result.billable_metric = metric + track_billable_metric_created(metric) + end + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :args + + def track_billable_metric_created(metric) + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "billable_metric_created", + properties: { + code: metric.code, + name: metric.name, + description: metric.description, + aggregation_type: metric.aggregation_type, + aggregation_property: metric.field_name, + organization_id: metric.organization_id + } + ) + end + end +end diff --git a/app/services/billable_metrics/destroy_service.rb b/app/services/billable_metrics/destroy_service.rb new file mode 100644 index 0000000..2aa4d23 --- /dev/null +++ b/app/services/billable_metrics/destroy_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module BillableMetrics + class DestroyService < BaseService + Result = BaseResult[:billable_metric] + + def initialize(metric:) + @metric = metric + super + end + + activity_loggable( + action: "billable_metric.deleted", + record: -> { metric } + ) + + def call + return result.not_found_failure!(resource: "billable_metric") unless metric + + BillableMetrics::ExpressionCacheService.expire_cache(metric.organization.id, metric.code) + + draft_invoice_ids = Invoice.draft.joins(plans: [:billable_metrics]) + .where(billable_metrics: {id: metric.id}).distinct.pluck(:id) + + ActiveRecord::Base.transaction do + metric.discard! + + # rubocop:disable Rails/SkipsModelValidations + metric.alerts.update_all(deleted_at: Time.current) + metric.charges.update_all(deleted_at: Time.current) + Invoice.where(id: draft_invoice_ids).update_all(ready_to_be_refreshed: true) + # rubocop:enable Rails/SkipsModelValidations + end + + # NOTE: Discard all related events asynchronously. + BillableMetrics::DeleteEventsJob.perform_later(metric) + BillableMetricFilters::DestroyAllJob.perform_later(metric.id) + + result.billable_metric = metric + result + end + + private + + attr_reader :metric + end +end diff --git a/app/services/billable_metrics/evaluate_expression_service.rb b/app/services/billable_metrics/evaluate_expression_service.rb new file mode 100644 index 0000000..40a2bae --- /dev/null +++ b/app/services/billable_metrics/evaluate_expression_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module BillableMetrics + class EvaluateExpressionService < BaseService + def initialize(expression:, event:) + @expression = expression + @event = event || {} + super + end + + def call + if expression.blank? + return result.single_validation_failure!(field: "expression", error_code: "value_is_mandatory") + end + + expression_validation_result = Lago::ExpressionParser.validate(expression) + if expression_validation_result.present? + return result.single_validation_failure!(field: "expression", error_code: "invalid_expression") + end + + evaluation_event = Lago::Event.new( + event["code"].to_s, + (event["timestamp"] || Time.current).to_i, + event["properties"]&.transform_values(&:to_s) || {} + ) + + result.evaluation_result = Lago::ExpressionParser.parse(expression).evaluate(evaluation_event) + result + rescue RuntimeError + result.single_validation_failure!(field: "event", error_code: "invalid_event") + end + + private + + attr_reader :expression, :event + end +end diff --git a/app/services/billable_metrics/expression_cache_service.rb b/app/services/billable_metrics/expression_cache_service.rb new file mode 100644 index 0000000..7c9b711 --- /dev/null +++ b/app/services/billable_metrics/expression_cache_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module BillableMetrics + class ExpressionCacheService < CacheService + CACHE_KEY_VERSION = "1" + + def initialize(organization_id, billable_metric_code) + @organization_id = organization_id + @billable_metric_code = billable_metric_code + + super + end + + def cache_key + [ + "expression", + CACHE_KEY_VERSION, + organization_id, + billable_metric_code + ].compact.join("/") + end + + private + + attr_reader :organization_id, :billable_metric_code + end +end diff --git a/app/services/billable_metrics/prorated_aggregations/base_service.rb b/app/services/billable_metrics/prorated_aggregations/base_service.rb new file mode 100644 index 0000000..0ed167a --- /dev/null +++ b/app/services/billable_metrics/prorated_aggregations/base_service.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module BillableMetrics + module ProratedAggregations + class BaseService < BillableMetrics::Aggregations::BaseService + ProratedPerEventAggregationResult = BaseResult[:event_aggregation, :event_prorated_aggregation] + + def compute_pay_in_advance_aggregation(aggregation_without_proration:) + return BigDecimal(0) unless event + return BigDecimal(0) if event.properties.blank? + + result_without_proration = aggregation_without_proration.pay_in_advance_aggregation + result.full_units_number = result_without_proration + result.units_applied = aggregation_without_proration.units_applied + + # In order to get proration coefficient we have to divide number of seconds with number + # of seconds in one day (86400). That way we will get number of days when the service was used. + proration_coefficient = Utils::Datetime.date_diff_with_timezone( + event.timestamp, + to_datetime, + customer.applicable_timezone + ).fdiv(period_duration) + + value = (result_without_proration * proration_coefficient).ceil(5) + + extend_cached_aggregation(value, aggregation_without_proration) + + value + end + + # We need to extend cached aggregation with max_aggregation_with_proration. This attribute will be used + # for current usage in pay_in_advance case + def extend_cached_aggregation(prorated_value, aggregation_without_proration) + result.max_aggregation = aggregation_without_proration.max_aggregation + result.current_aggregation = aggregation_without_proration.current_aggregation + + cached_aggregation = base_aggregator.find_cached_aggregation( + with_from_datetime: from_datetime, + with_to_datetime: to_datetime, + grouped_by: grouped_by_values + ) + + unless cached_aggregation + result.max_aggregation_with_proration = prorated_value.to_s + + return + end + + result.max_aggregation_with_proration = begin + if BigDecimal(aggregation_without_proration.max_aggregation) > BigDecimal(cached_aggregation.max_aggregation) + BigDecimal(cached_aggregation.max_aggregation_with_proration) + prorated_value + else + BigDecimal(cached_aggregation.max_aggregation_with_proration) + end + end + end + + # In current usage section two main values are presented, number of units in period and amount. + # Proration affects only amount (calculated from aggregation) and number of units shows full number of units + # (calculated from current_usage_units). + def handle_current_usage(result_with_proration, is_pay_in_advance, aggregation_without_proration:, target_result:) + value_without_proration = aggregation_without_proration.aggregation + cached_aggregation = base_aggregator.find_cached_aggregation( + with_from_datetime: from_datetime, + with_to_datetime: to_datetime, + grouped_by: target_result.grouped_by + ) + + if !is_pay_in_advance + target_result.aggregation = result_with_proration.negative? ? 0 : result_with_proration + target_result.current_usage_units = value_without_proration.negative? ? 0 : value_without_proration + elsif cached_aggregation && persisted_pro_rata < 1 + target_result.current_usage_units = aggregation_without_proration.current_usage_units + + persisted_units_without_proration = aggregation_without_proration.current_usage_units - + BigDecimal(cached_aggregation.current_aggregation) + target_result.aggregation = (persisted_units_without_proration * persisted_pro_rata).ceil(5) + + BigDecimal(cached_aggregation.max_aggregation_with_proration) + elsif cached_aggregation + target_result.current_usage_units = aggregation_without_proration.current_usage_units + target_result.aggregation = aggregation_without_proration.current_usage_units - + BigDecimal(cached_aggregation.current_aggregation) + + BigDecimal(cached_aggregation.max_aggregation_with_proration) + elsif persisted_pro_rata < 1 + target_result.aggregation = result_with_proration.negative? ? 0 : result_with_proration + target_result.current_usage_units = aggregation_without_proration.current_usage_units + else + target_result.aggregation = value_without_proration + target_result.current_usage_units = aggregation_without_proration.current_usage_units + end + end + + def period_duration + @period_duration ||= boundaries[:charges_duration] + end + + # NOTE: when subscription is terminated or upgraded, + # we want to bill the persisted metrics at prorata of the full period duration. + # ie: the number of day of the terminated period divided by number of days without termination + def persisted_pro_rata + subscription.date_diff_with_timezone(from_datetime, to_datetime).fdiv(period_duration) + end + + attr_accessor :options + + private + + attr_reader :base_aggregator + + def previous_charge_fee(grouped_by_values: nil) + subscription_ids = customer.subscriptions + .where(external_id: subscription.external_id) + .pluck(:id) + + Fee.joins(:charge) + .where(charge: {billable_metric_id: billable_metric.id}) + .where(charge: {prorated: true}) + .where(subscription_id: subscription_ids, fee_type: :charge, charge_filter_id: charge_filter&.id) + .where("CAST(fees.properties->>'charges_to_datetime' AS timestamp) < ?", boundaries[:to_datetime]) + .where(grouped_by: grouped_by_values.presence || {}) + .order(created_at: :desc) + .first + end + end + end +end diff --git a/app/services/billable_metrics/prorated_aggregations/sum_service.rb b/app/services/billable_metrics/prorated_aggregations/sum_service.rb new file mode 100644 index 0000000..37db48e --- /dev/null +++ b/app/services/billable_metrics/prorated_aggregations/sum_service.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +module BillableMetrics + module ProratedAggregations + class SumService < BillableMetrics::ProratedAggregations::BaseService + def initialize(**args) + super + @base_aggregator = BillableMetrics::Aggregations::SumService.new(**args) + @base_aggregator.result = result + + event_store.numeric_property = true + event_store.aggregation_property = billable_metric.field_name + end + + def compute_aggregation(options: {}) + aggregation_without_proration = base_aggregator.aggregate(options:) + + # For charges that are pay in advance on billing date we always bill full amount + return aggregation_without_proration if event.nil? && options[:is_pay_in_advance] && !options[:is_current_usage] + + aggregation = compute_event_aggregation.ceil(5) + result.full_units_number = aggregation_without_proration.aggregation if event.nil? + + if options[:is_current_usage] + handle_current_usage( + aggregation, + options[:is_pay_in_advance], + target_result: result, + aggregation_without_proration: + ) + else + result.aggregation = aggregation + end + + result.pay_in_advance_aggregation = compute_pay_in_advance_aggregation(aggregation_without_proration:) + result.count = aggregation_without_proration.count + + if presentation_by.present? + result.breakdowns = event_store.grouped_sum(uniq_grouped_by_and_presentation_by) + end + + result.options = options + result + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group. + # + # This logic is only applicable for in arrears aggregation + # (exept for the current_usage update) + # as pay in advance aggregation will be computed on a single group + # with the grouped_by_values filter + def compute_grouped_by_aggregation(options: {}) + aggregation_without_proration = base_aggregator.aggregate(options:) + + # For charges that are pay in advance on billing date we always bill full amount + return aggregation_without_proration if event.nil? && options[:is_pay_in_advance] && !options[:is_current_usage] + + aggregations = compute_grouped_event_aggregation + return empty_results if aggregations.blank? + + result.aggregations = aggregations.map do |aggregation| + aggregation_value = aggregation[:value].ceil(5) + + group_result_without_proration = aggregation_without_proration.aggregations.find do |agg| + agg.grouped_by == aggregation[:groups] + end + + unless group_result_without_proration + group_result_without_proration = empty_results.aggregations.first + group_result_without_proration.grouped_by = aggregation[:groups] + end + + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + group_result.full_units_number = group_result_without_proration&.aggregation || 0 + + if options[:is_current_usage] + handle_current_usage( + aggregation_value, + options[:is_pay_in_advance], + target_result: group_result, + aggregation_without_proration: group_result_without_proration + ) + else + group_result.aggregation = aggregation_value + end + + group_result.count = group_result_without_proration&.count || 0 + group_result.options = options + + group_result + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_sum(uniq_grouped_by_and_presentation_by) + end + + result + rescue ActiveRecord::StatementInvalid => e + result.service_failure!(code: "aggregation_failure", message: e.message) + end + + def compute_per_event_prorated_aggregation + event_store.prorated_events_values(period_duration) + end + + def per_event_aggregation(exclude_event: false, include_event_value: false, grouped_by_values: nil) + recurring_result = recurring_value + recurring_aggregation = recurring_result ? [BigDecimal(recurring_result)] : [] + recurring_prorated_aggregation = recurring_result ? [BigDecimal(recurring_result) * persisted_pro_rata] : [] + + ProratedPerEventAggregationResult.new.tap do |result| + result.event_aggregation = recurring_aggregation + + base_aggregator.per_event_aggregation(exclude_event:, grouped_by_values:).event_aggregation + + event_store.with_grouped_by_values(grouped_by_values) do + result.event_prorated_aggregation = recurring_prorated_aggregation + + compute_per_event_prorated_aggregation + end + end + end + + protected + + def persisted_event_store_instance + event_store = event_store_class.new( + code: billable_metric.code, + subscription:, + boundaries: {to_datetime: from_datetime}, + filters: + ) + + event_store.use_from_boundary = false + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + event_store + end + + def compute_event_aggregation + result = 0.0 + + # NOTE: Billed on the full period + result += persisted_sum || 0 + + # NOTE: Added during the period + result + (event_store.prorated_sum(period_duration:) || 0) + end + + def persisted_sum + persisted_event_store_instance.prorated_sum( + period_duration:, + persisted_duration: subscription.date_diff_with_timezone(from_datetime, to_datetime) + ) + end + + def recurring_value(grouped_by_values: nil) + previous_charge_fee_units = previous_charge_fee(grouped_by_values:)&.units + return previous_charge_fee_units if previous_charge_fee_units + + store = persisted_event_store_instance + recurring_value_before_first_fee = store.with_grouped_by_values(grouped_by_values) { store.sum } + + ((recurring_value_before_first_fee || 0) <= 0) ? nil : recurring_value_before_first_fee + end + + def compute_grouped_event_aggregation + result = grouped_persisted_sums + current_results = event_store.grouped_prorated_sum(period_duration:) + + current_results.each do |group_result| + group = group_result[:groups] + + if (persisted_group = result.find { |r| r[:groups] == group }) + # NOTE: A persisted value already exists for this group + # We just append the value to the persisted one + persisted_group[:value] += group_result[:value] + next + end + + # NOTE: Add the new group to the result + result << group_result + end + + result + end + + def grouped_persisted_sums + persisted_event_store_instance.grouped_prorated_sum( + period_duration:, + persisted_duration: subscription.date_diff_with_timezone(from_datetime, to_datetime) + ) + end + end + end +end diff --git a/app/services/billable_metrics/prorated_aggregations/unique_count_service.rb b/app/services/billable_metrics/prorated_aggregations/unique_count_service.rb new file mode 100644 index 0000000..55df776 --- /dev/null +++ b/app/services/billable_metrics/prorated_aggregations/unique_count_service.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module BillableMetrics + module ProratedAggregations + class UniqueCountService < BillableMetrics::ProratedAggregations::BaseService + def initialize(**args) + super + + @base_aggregator = BillableMetrics::Aggregations::UniqueCountService.new(**args) + @base_aggregator.result = result + + event_store.aggregation_property = billable_metric.field_name + event_store.use_from_boundary = !billable_metric.recurring + end + + def compute_aggregation(options: {}) + aggregation_without_proration = base_aggregator.aggregate(options:) + + # For charges that are pay in advance on billing date we always bill full amount + return aggregation_without_proration if event.nil? && options[:is_pay_in_advance] && !options[:is_current_usage] + + aggregation = event_store.prorated_unique_count.ceil(5) + result.full_units_number = aggregation_without_proration.aggregation if event.nil? + + if options[:is_current_usage] + handle_current_usage( + aggregation, + options[:is_pay_in_advance], + target_result: result, + aggregation_without_proration: + ) + else + result.aggregation = aggregation + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_unique_count(uniq_grouped_by_and_presentation_by) + end + + result.pay_in_advance_aggregation = compute_pay_in_advance_aggregation(aggregation_without_proration:) + result.options = options + result.count = result.aggregation + result + end + + # NOTE: Apply the grouped_by filter to the aggregation + # Result will have an aggregations attribute + # containing the aggregation result of each group. + # + # This logic is only applicable for in arrears aggregation + # (exept for the current_usage update) + # as pay in advance aggregation will be computed on a single group + # with the grouped_by_values filter + def compute_grouped_by_aggregation(options: {}) + aggregation_without_proration = base_aggregator.aggregate(options:) + + # For charges that are pay in advance on billing date we always bill full amount + return aggregation_without_proration if event.nil? && options[:is_pay_in_advance] && !options[:is_current_usage] + + aggregations = event_store.grouped_prorated_unique_count + return empty_results if aggregations.blank? + + result.aggregations = aggregations.map do |aggregation| + aggregation_value = aggregation[:value].ceil(5) + + group_result_without_proration = aggregation_without_proration.aggregations.find do |agg| + agg.grouped_by == aggregation[:groups] + end + + unless group_result_without_proration + group_result_without_proration = empty_results.aggregations.first + group_result_without_proration.grouped_by = aggregation[:groups] + end + + group_result = BaseService::Result.new + group_result.grouped_by = aggregation[:groups] + group_result.full_units_number = group_result_without_proration&.aggregation || 0 + + if options[:is_current_usage] + handle_current_usage( + aggregation_value, + options[:is_pay_in_advance], + target_result: group_result, + aggregation_without_proration: group_result_without_proration + ) + else + group_result.aggregation = aggregation_value + end + + group_result.count = group_result.aggregation + group_result.options = options + + group_result + end + + if presentation_by.present? + result.breakdowns = event_store.grouped_unique_count(uniq_grouped_by_and_presentation_by) + end + + result + end + + def per_event_aggregation(grouped_by_values: nil, include_event_value: false) + event_aggregation_array = [] + period_aggregation = event_store.with_grouped_by_values(grouped_by_values) do + event_store.prorated_unique_count_breakdown(with_remove: true).map do |row| + event_aggregation_array << ((row["operation_type"] == "remove") ? -1 : 1) + row["prorated_value"].ceil(5) + end + end + + ProratedPerEventAggregationResult.new.tap do |result| + result.event_aggregation = event_aggregation_array + result.event_prorated_aggregation = period_aggregation + end + end + end + end +end diff --git a/app/services/billable_metrics/update_service.rb b/app/services/billable_metrics/update_service.rb new file mode 100644 index 0000000..00dae73 --- /dev/null +++ b/app/services/billable_metrics/update_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module BillableMetrics + class UpdateService < BaseService + def initialize(billable_metric:, params:) + @billable_metric = billable_metric + @params = params + + super + end + + activity_loggable( + action: "billable_metric.updated", + record: -> { billable_metric } + ) + + def call + return result.not_found_failure!(resource: "billable_metric") unless billable_metric + + if params.key?(:aggregation_type) && + params[:aggregation_type]&.to_sym == :custom_agg && + !organization&.custom_aggregation + return result.forbidden_failure! + end + + billable_metric.name = params[:name] if params.key?(:name) + billable_metric.description = params[:description] if params.key?(:description) + + ActiveRecord::Base.transaction do + if params.key?(:filters) + BillableMetricFilters::CreateOrUpdateBatchService.call( + billable_metric:, + filters_params: params[:filters].map { |f| f.to_h.with_indifferent_access } + ).raise_if_error! + end + end + + # NOTE: Only name and description are editable if billable metric + # is attached to a plan + unless billable_metric.plans.exists? + billable_metric.code = params[:code] if params.key?(:code) + billable_metric.aggregation_type = params[:aggregation_type]&.to_sym if params.key?(:aggregation_type) + billable_metric.weighted_interval = params[:weighted_interval]&.to_sym if params.key?(:weighted_interval) + billable_metric.field_name = params[:field_name] if params.key?(:field_name) + billable_metric.recurring = params[:recurring] if params.key?(:recurring) + billable_metric.rounding_function = params[:rounding_function] if params.key?(:rounding_function) + billable_metric.rounding_precision = params[:rounding_precision] if params.key?(:rounding_precision) + billable_metric.weighted_interval = params[:weighted_interval]&.to_sym if params.key?(:weighted_interval) + billable_metric.expression = params[:expression] if params.key?(:expression) + + if params.key?(:expression) || params.key?(:field_name) + BillableMetrics::ExpressionCacheService.expire_cache(organization.id, billable_metric.code) + end + end + + billable_metric.save! + + result.billable_metric = billable_metric + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :billable_metric, :params + + delegate :organization, to: :billable_metric + end +end diff --git a/app/services/billing_entities/change_eu_tax_management_service.rb b/app/services/billing_entities/change_eu_tax_management_service.rb new file mode 100644 index 0000000..f44c8bb --- /dev/null +++ b/app/services/billing_entities/change_eu_tax_management_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module BillingEntities + class ChangeEuTaxManagementService < BaseService + Result = BaseResult[:billing_entity] + + ERROR_CODE = "billing_entity_must_be_in_eu" + + def initialize(billing_entity:, eu_tax_management:) + @billing_entity = billing_entity + @eu_tax_management = eu_tax_management + + super + end + + def call + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + + if !billing_entity.eu_vat_eligible? && eu_tax_management + return result.single_validation_failure!(error_code: ERROR_CODE, field: :eu_tax_management) + end + + billing_entity.eu_tax_management = eu_tax_management + + # NOTE: autogenerate service generates taxes. + # Taxes belong to organization, but can be applied to the billing_entity, + # So we auto generate taxes for the billing_entity organization + ::Taxes::AutoGenerateService.call(organization: billing_entity.organization) if eu_tax_management + + result.billing_entity = billing_entity + result + end + + private + + attr_reader :billing_entity, :eu_tax_management + end +end diff --git a/app/services/billing_entities/change_invoice_numbering_service.rb b/app/services/billing_entities/change_invoice_numbering_service.rb new file mode 100644 index 0000000..6729770 --- /dev/null +++ b/app/services/billing_entities/change_invoice_numbering_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module BillingEntities + class ChangeInvoiceNumberingService < BaseService + Result = BaseResult[:billing_entity] + + def initialize(billing_entity:, document_numbering:) + @billing_entity = billing_entity + @document_numbering = document_numbering + super + end + + def call + result.billing_entity = billing_entity + billing_entity.document_numbering = document_numbering + + return result unless billing_entity.document_numbering_changed? + + if billing_entity.per_billing_entity? && last_invoice + last_invoice.update!(billing_entity_sequential_id: billing_entity_invoices_count) + end + + result + end + + private + + attr_reader :billing_entity, :document_numbering + + def last_invoice + @last_invoice ||= billing_entity + .invoices + .non_self_billed + .with_generated_number + .order(created_at: :desc) + .where(billing_entity_sequential_id: nil) + .first + end + + def billing_entity_invoices_count + billing_entity.invoices.non_self_billed.with_generated_number.count + end + end +end diff --git a/app/services/billing_entities/create_service.rb b/app/services/billing_entities/create_service.rb new file mode 100644 index 0000000..fddfb2f --- /dev/null +++ b/app/services/billing_entities/create_service.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module BillingEntities + class CreateService < BaseService + Result = BaseResult[:billing_entity] + + def initialize(organization:, params:) + @organization = organization + @params = params + @billing_entity = organization.billing_entities.new + super + end + + activity_loggable( + action: "billing_entities.created", + record: -> { result.billing_entity } + ) + + def call + return result.forbidden_failure! unless organization.can_create_billing_entity? + + ActiveRecord::Base.transaction do + billing_entity.assign_attributes(create_attributes) + billing_entity.id = params[:id] if params[:id] + billing_entity.invoice_footer = billing_config[:invoice_footer] + billing_entity.document_locale = billing_config[:document_locale] if billing_config[:document_locale] + billing_entity.einvoicing = params[:einvoicing] if params[:einvoicing] + + handle_eu_tax_management if params[:eu_tax_management] + handle_base64_logo + + if License.premium? + billing_entity.invoice_grace_period = billing_config[:invoice_grace_period] if billing_config[:invoice_grace_period] + billing_entity.timezone = params[:timezone] if params[:timezone] + billing_entity.email_settings = params[:email_settings] if params[:email_settings] + billing_entity.subscription_invoice_issuing_date_anchor = billing_config[:subscription_invoice_issuing_date_anchor] if billing_config[:subscription_invoice_issuing_date_anchor] + billing_entity.subscription_invoice_issuing_date_adjustment = billing_config[:subscription_invoice_issuing_date_adjustment] if billing_config[:subscription_invoice_issuing_date_adjustment] + end + + billing_entity.save! + end + + track_billing_entity_created + register_security_log(billing_entity) + + result.billing_entity = billing_entity + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :organization, :params, :billing_entity + + def create_attributes + @create_attributes ||= params.slice( + *%I[ + address_line1 + address_line2 + city + code + country + default_currency + document_number_prefix + document_numbering + email + finalize_zero_amount_invoice + legal_name + legal_number + name + net_payment_term + state + tax_identification_number + vat_rate + zipcode + ] + ) + end + + def billing_config + @billing_config ||= params[:billing_configuration]&.to_h || {} + end + + def handle_base64_logo + return if params[:logo].blank? + + base64_data = params[:logo].split(",") + data = base64_data.second + decoded_base_64_data = Base64.decode64(data) + + # NOTE: data:image/png;base64, should give image/png content_type + content_type = base64_data.first.split(";").first.split(":").second + + billing_entity.logo.attach( + io: StringIO.new(decoded_base_64_data), + filename: "logo", + content_type: + ) + end + + def handle_eu_tax_management + ChangeEuTaxManagementService.call!( + billing_entity:, + eu_tax_management: params[:eu_tax_management] + ) + end + + def register_security_log(billing_entity) + Utils::SecurityLog.produce( + organization:, + log_type: "billing_entity", + log_event: "billing_entity.created", + resources: {billing_entity_name: billing_entity.name, billing_entity_code: billing_entity.code} + ) + end + + def track_billing_entity_created + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "billing_entity_created", + properties: { + billing_entity_code: billing_entity.code, + billing_entity_name: billing_entity.name, + organization_id: billing_entity.organization_id + } + ) + end + end +end diff --git a/app/services/billing_entities/resolve_service.rb b/app/services/billing_entities/resolve_service.rb new file mode 100644 index 0000000..e4e9555 --- /dev/null +++ b/app/services/billing_entities/resolve_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module BillingEntities + class ResolveService < BaseService + Result = BaseResult[:billing_entity] + + extend Forwardable + + def initialize(organization:, billing_entity_code: nil) + @organization = organization + @billing_entity_code = billing_entity_code + + super + end + + def call + return result.not_found_failure!(resource: "billing_entity") if organization.billing_entities.empty? + + return find_by_code if billing_entity_code.present? + + result.billing_entity = default_billing_entity + result + end + + private + + attr_reader :organization, :billing_entity_code + def_delegators :organization, :default_billing_entity + + def find_by_code + billing_entity = organization.billing_entities.find_by(code: billing_entity_code) + + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + + result.billing_entity = billing_entity + result + end + end +end diff --git a/app/services/billing_entities/taxes/apply_taxes_service.rb b/app/services/billing_entities/taxes/apply_taxes_service.rb new file mode 100644 index 0000000..b8174de --- /dev/null +++ b/app/services/billing_entities/taxes/apply_taxes_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module BillingEntities + module Taxes + class ApplyTaxesService < BaseService + Result = BaseResult[:applied_taxes, :taxes_to_apply] + + def initialize(billing_entity:, tax_codes:) + @billing_entity = billing_entity + @tax_codes = tax_codes + + super + end + + def call + return result if tax_codes.blank? + + find_taxes_on_organization + return result if result.failure? + + result.applied_taxes = result.taxes_to_apply.map do |tax| + billing_entity.applied_taxes + .create_with(organization_id: tax.organization_id) + .find_or_create_by!(tax:) + end + refresh_draft_invoices + + result + end + + private + + attr_reader :billing_entity, :tax_codes + + delegate :organization, to: :billing_entity + + def find_taxes_on_organization + result.taxes_to_apply = organization.taxes.where(code: tax_codes) + + if result.taxes_to_apply.count != tax_codes.count + result.not_found_failure!(resource: "tax") + end + end + end + end +end diff --git a/app/services/billing_entities/taxes/base_service.rb b/app/services/billing_entities/taxes/base_service.rb new file mode 100644 index 0000000..06d383d --- /dev/null +++ b/app/services/billing_entities/taxes/base_service.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module BillingEntities + module Taxes + class BaseService < BaseService + private + + attr_reader :billing_entity + + def refresh_draft_invoices + BillingEntities::Taxes::RefreshDraftInvoicesJob.perform_later(billing_entity.id) + end + end + end +end diff --git a/app/services/billing_entities/taxes/manage_taxes_service.rb b/app/services/billing_entities/taxes/manage_taxes_service.rb new file mode 100644 index 0000000..842cf8c --- /dev/null +++ b/app/services/billing_entities/taxes/manage_taxes_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module BillingEntities + module Taxes + class ManageTaxesService < BaseService + Result = BaseResult[:taxes, :applied_taxes] + + def initialize(billing_entity:, tax_codes:) + @billing_entity = billing_entity + @tax_codes = tax_codes || [] + + super + end + + def call + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + + manage_taxes + refresh_draft_invoices + result + end + + private + + attr_reader :billing_entity, :tax_codes + + delegate :organization, to: :billing_entity + + def manage_taxes + # Remove duplicates and normalize case + unique_tax_codes = tax_codes.uniq.map(&:upcase) + taxes = organization.taxes.where("UPPER(code) IN (?)", unique_tax_codes) + + if taxes.count != unique_tax_codes.count + result.not_found_failure!(resource: "tax") + return + end + + billing_entity.applied_taxes = taxes.map do |tax| + BillingEntity::AppliedTax.new(billing_entity:, tax:, organization:) + end + + result.taxes = taxes + result.applied_taxes = billing_entity.applied_taxes + end + end + end +end diff --git a/app/services/billing_entities/taxes/remove_taxes_service.rb b/app/services/billing_entities/taxes/remove_taxes_service.rb new file mode 100644 index 0000000..e1fe41c --- /dev/null +++ b/app/services/billing_entities/taxes/remove_taxes_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module BillingEntities + module Taxes + class RemoveTaxesService < BaseService + Result = BaseResult[:taxes_to_remove] + + def initialize(billing_entity:, tax_codes:) + @billing_entity = billing_entity + @tax_codes = tax_codes + + super + end + + def call + return result if tax_codes.blank? + + find_taxes_to_remove + return result if result.failure? + + billing_entity.applied_taxes.where(tax: result.taxes_to_remove).destroy_all + refresh_draft_invoices + + result + end + + private + + attr_reader :billing_entity, :tax_codes + + delegate :organization, to: :billing_entity + + def find_taxes_to_remove + result.taxes_to_remove = organization.taxes.where(code: tax_codes) + if result.taxes_to_remove.count != tax_codes.count + result.not_found_failure!(resource: "tax") + end + end + + def remove_taxes + @billing_entity.applied_taxes.where(tax: @taxes).destroy_all + end + end + end +end diff --git a/app/services/billing_entities/update_applied_dunning_campaign_service.rb b/app/services/billing_entities/update_applied_dunning_campaign_service.rb new file mode 100644 index 0000000..76dc2b0 --- /dev/null +++ b/app/services/billing_entities/update_applied_dunning_campaign_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module BillingEntities + class UpdateAppliedDunningCampaignService < BaseService + Result = BaseResult[:billing_entity] + def initialize(billing_entity:, applied_dunning_campaign_id: nil) + @billing_entity = billing_entity + @applied_dunning_campaign_id = applied_dunning_campaign_id + super + end + + def call + return result.not_found_failure!(resource: "billing_entity") if billing_entity.nil? + return if billing_entity.applied_dunning_campaign_id == applied_dunning_campaign_id + + old_campaign = billing_entity.applied_dunning_campaign + dunning_campaign = DunningCampaign.find(applied_dunning_campaign_id) if applied_dunning_campaign_id + billing_entity.reset_customers_last_dunning_campaign_attempt + billing_entity.update!(applied_dunning_campaign: dunning_campaign) + + register_security_log(old_campaign, dunning_campaign) + + result.billing_entity = billing_entity + result + rescue ActiveRecord::RecordNotFound + result.not_found_failure!(resource: "dunning_campaign") + end + + private + + attr_reader :billing_entity, :applied_dunning_campaign_id + + def register_security_log(old_campaign, new_campaign) + Utils::SecurityLog.produce( + organization: billing_entity.organization, + log_type: "billing_entity", + log_event: "billing_entity.updated", + resources: { + billing_entity_name: billing_entity.name, + billing_entity_code: billing_entity.code, + applied_dunning_campaign: {deleted: old_campaign&.code, added: new_campaign&.code}.compact + } + ) + end + end +end diff --git a/app/services/billing_entities/update_invoice_issuing_date_settings_service.rb b/app/services/billing_entities/update_invoice_issuing_date_settings_service.rb new file mode 100644 index 0000000..d844ef8 --- /dev/null +++ b/app/services/billing_entities/update_invoice_issuing_date_settings_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module BillingEntities + class UpdateInvoiceIssuingDateSettingsService < BaseService + Result = BaseResult[:billing_entity] + + def initialize(billing_entity:, params:) + @billing_entity = billing_entity + @params = params + @previous_issuing_date_settings = { + invoice_grace_period: billing_entity.invoice_grace_period, + subscription_invoice_issuing_date_anchor: billing_entity.subscription_invoice_issuing_date_anchor, + subscription_invoice_issuing_date_adjustment: billing_entity.subscription_invoice_issuing_date_adjustment + } + super + end + + def call + set_issuing_date_settings + + if billing_entity.changed? && billing_entity.save! + Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityJob.perform_later(billing_entity, previous_issuing_date_settings) + end + + result.billing_entity = billing_entity + result + end + + private + + attr_reader :billing_entity, :params, :previous_issuing_date_settings + + def set_issuing_date_settings + if params.key?(:subscription_invoice_issuing_date_anchor) + billing_entity.subscription_invoice_issuing_date_anchor = params[:subscription_invoice_issuing_date_anchor] + end + + if params.key?(:subscription_invoice_issuing_date_adjustment) + billing_entity.subscription_invoice_issuing_date_adjustment = params[:subscription_invoice_issuing_date_adjustment] + end + + if License.premium? && params.key?(:invoice_grace_period) + billing_entity.invoice_grace_period = params[:invoice_grace_period] + end + end + end +end diff --git a/app/services/billing_entities/update_invoice_payment_due_date_service.rb b/app/services/billing_entities/update_invoice_payment_due_date_service.rb new file mode 100644 index 0000000..1ad8f64 --- /dev/null +++ b/app/services/billing_entities/update_invoice_payment_due_date_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module BillingEntities + class UpdateInvoicePaymentDueDateService < BaseService + Result = BaseResult[:billing_entity] + + def initialize(billing_entity:, net_payment_term:) + @billing_entity = billing_entity + @net_payment_term = net_payment_term + super + end + + def call + ActiveRecord::Base.transaction do + # NOTE: Set payment_due_date if net_payment_term changed + if billing_entity.net_payment_term != net_payment_term + billing_entity.net_payment_term = net_payment_term + + # update only invoices, where the customer does not have a setting + billing_entity.invoices.includes(:customer).draft.find_each do |invoice| + # the customer has a setting of their own, no update needed. + next unless invoice.customer.net_payment_term.nil? + + invoice.update!(net_payment_term:, payment_due_date: invoice_payment_due_date(invoice)) + end + end + + result.billing_entity = billing_entity + result + end + end + + private + + attr_reader :billing_entity, :net_payment_term + + def invoice_payment_due_date(invoice) + invoice.issuing_date + net_payment_term.days + end + end +end diff --git a/app/services/billing_entities/update_service.rb b/app/services/billing_entities/update_service.rb new file mode 100644 index 0000000..221fc94 --- /dev/null +++ b/app/services/billing_entities/update_service.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module BillingEntities + class UpdateService < BaseService + Result = BaseResult[:billing_entity] + + def initialize(billing_entity:, params:) + @billing_entity = billing_entity + @params = params + + super(nil) + end + + activity_loggable( + action: "billing_entities.updated", + record: -> { billing_entity } + ) + + def call + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + + original_attributes = billing_entity.attributes + old_tax_codes = billing_entity.taxes.pluck(:code) + + billing_entity.name = params[:name] if params.key?(:name) + billing_entity.einvoicing = params[:einvoicing] if params.key?(:einvoicing) + billing_entity.email = params[:email] if params.key?(:email) + billing_entity.legal_name = params[:legal_name] if params.key?(:legal_name) + billing_entity.legal_number = params[:legal_number] if params.key?(:legal_number) + if params.key?(:tax_identification_number) + billing_entity.tax_identification_number = params[:tax_identification_number] + end + billing_entity.address_line1 = params[:address_line1] if params.key?(:address_line1) + billing_entity.address_line2 = params[:address_line2] if params.key?(:address_line2) + billing_entity.zipcode = params[:zipcode] if params.key?(:zipcode) + billing_entity.city = params[:city] if params.key?(:city) + billing_entity.state = params[:state] if params.key?(:state) + billing_entity.country = params[:country]&.upcase if params.key?(:country) + billing_entity.default_currency = params[:default_currency]&.upcase if params.key?(:default_currency) + + ActiveRecord::Base.transaction do + if params.key?(:document_numbering) + # TODO: remove when we do not support document_numbering per organization + document_numbering = (params[:document_numbering] == "per_customer") ? "per_customer" : "per_billing_entity" + + BillingEntities::ChangeInvoiceNumberingService.call( + billing_entity:, + document_numbering: + ) + end + + billing_entity.document_number_prefix = params[:document_number_prefix] if params.key?(:document_number_prefix) + billing_entity.finalize_zero_amount_invoice = params[:finalize_zero_amount_invoice] if params.key?(:finalize_zero_amount_invoice) + + billing = params[:billing_configuration]&.to_h || {} + billing_entity.invoice_footer = billing[:invoice_footer] if billing.key?(:invoice_footer) + billing_entity.document_locale = billing[:document_locale] if billing.key?(:document_locale) + + handle_eu_tax_management if params.key?(:eu_tax_management) + + BillingEntities::UpdateInvoiceIssuingDateSettingsService.call(billing_entity:, params: billing) + + if params.key?(:net_payment_term) + # note: this service only assigns new net_payment_term to the billing_entity but doesn't save it + BillingEntities::UpdateInvoicePaymentDueDateService.call( + billing_entity:, + net_payment_term: params[:net_payment_term] + ) + end + + if params.key?(:tax_codes) + BillingEntities::Taxes::ManageTaxesService.call!(billing_entity:, tax_codes: params[:tax_codes]) + end + + handle_invoice_custom_sections if params.key?(:invoice_custom_section_ids) || params.key?(:invoice_custom_section_codes) + + assign_premium_attributes + handle_base64_logo if params.key?(:logo) + + billing_entity.save! + end + + register_security_log(billing_entity, original_attributes, old_tax_codes) + + result.billing_entity = billing_entity + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :billing_entity, :params + + def register_security_log(billing_entity, original_attributes, old_tax_codes) + diff = original_attributes.except("updated_at").each_with_object({}) do |(key, old_val), hash| + new_val = billing_entity[key] + next if old_val == new_val + hash[key.to_sym] = {deleted: old_val, added: new_val}.compact + end + + new_tax_codes = billing_entity.taxes.reload.pluck(:code) + if old_tax_codes.sort != new_tax_codes.sort + entry = {} + deleted = old_tax_codes - new_tax_codes + added = new_tax_codes - old_tax_codes + entry[:deleted] = deleted if deleted.present? + entry[:added] = added if added.present? + diff[:tax_codes] = entry if entry.present? + end + + Utils::SecurityLog.produce( + organization: billing_entity.organization, + log_type: "billing_entity", + log_event: "billing_entity.updated", + resources: {billing_entity_name: billing_entity.name, billing_entity_code: billing_entity.code, **diff} + ) + end + + def assign_premium_attributes + return unless License.premium? + + billing_entity.timezone = params[:timezone] if params.key?(:timezone) + billing_entity.email_settings = params[:email_settings] if params.key?(:email_settings) + end + + def handle_base64_logo + if params[:logo].blank? + billing_entity.logo&.purge + return + end + + base64_data = params[:logo].split(",") + data = base64_data.second + decoded_base_64_data = Base64.decode64(data) + + # NOTE: data:image/png;base64, should give image/png content_type + content_type = base64_data.first.split(";").first.split(":").second + + billing_entity.logo.attach( + io: StringIO.new(decoded_base_64_data), + filename: "logo", + content_type: + ) + end + + def handle_eu_tax_management + ChangeEuTaxManagementService.call!( + billing_entity:, + eu_tax_management: params[:eu_tax_management] + ) + end + + def handle_invoice_custom_sections + existing_section_ids = billing_entity.selected_invoice_custom_sections.ids + new_section_ids = params[:invoice_custom_section_ids] || InvoiceCustomSection.where(code: params[:invoice_custom_section_codes]).ids + + billing_entity.applied_invoice_custom_sections.where.not(invoice_custom_section_id: new_section_ids).destroy_all + + sections_to_create = new_section_ids - existing_section_ids + sections_to_create.each do |invoice_custom_section_id| + billing_entity.applied_invoice_custom_sections.create!( + invoice_custom_section_id:, + organization_id: billing_entity.organization_id + ) + end + end + end +end diff --git a/app/services/cache_service.rb b/app/services/cache_service.rb new file mode 100644 index 0000000..1cea3a7 --- /dev/null +++ b/app/services/cache_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class CacheService < BaseService + def initialize(*, expires_in: nil) + @expires_in = expires_in + super(nil) + end + + def self.expire_cache(*, **) + new(*, **).expire_cache + end + + def cache_key + raise NotImplementedError + end + + def call(&) + # NOTE: We don't rely on fetch here because some services compute expires_in = 0 + # and we think this is the root of an invalid expiration time passed to Redis + value = Rails.cache.read(cache_key) + return value if value + + value = yield + + # NOTE: It seems that passing expires_in: 0 is not a NO-OP, so bypass manually + if expires_in.nil? || expires_in > 0 + Rails.cache.write(cache_key, value, expires_in:) + end + + value + end + + def expire_cache + Rails.cache.delete(cache_key) + end + + private + + attr_reader :expires_in +end diff --git a/app/services/charge_filters/cascade_service.rb b/app/services/charge_filters/cascade_service.rb new file mode 100644 index 0000000..0c73727 --- /dev/null +++ b/app/services/charge_filters/cascade_service.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module ChargeFilters + class CascadeService < BaseService + Result = BaseResult + + def initialize(charge:, action:, filter_values:, old_properties: nil, new_properties: nil, invoice_display_name: nil) + @charge = charge + @action = action + @filter_values = filter_values + @old_properties = old_properties + @new_properties = new_properties + @invoice_display_name = invoice_display_name + + super + end + + def call + charge.children + .joins(plan: :subscriptions) + .where(subscriptions: {status: %w[active pending]}) + .distinct.find_each do |child_charge| + Charge.no_touching do + Plan.no_touching do + case action + when "update" then update_child_filter(child_charge) + when "create" then create_child_filter(child_charge) + when "destroy" then destroy_child_filter(child_charge) + end + end + end + end + + result + end + + private + + attr_reader :charge, :action, :filter_values, :old_properties, :new_properties, :invoice_display_name + + def update_child_filter(child_charge) + child_filter = find_child_filter(child_charge) + return unless child_filter + + if filter_customized?(child_filter) + cascade_group_keys(child_filter) + child_filter.save! if child_filter.changed? + return + end + + child_filter.properties = ChargeModels::FilterPropertiesService.call( + chargeable: child_charge, + properties: new_properties + ).properties + child_filter.invoice_display_name = invoice_display_name + child_filter.save! + end + + def create_child_filter(child_charge) + return if find_child_filter(child_charge) + + ActiveRecord::Base.transaction do + child_filter = child_charge.filters.new( + organization_id: child_charge.organization_id, + invoice_display_name:, + properties: ChargeModels::FilterPropertiesService.call( + chargeable: child_charge, + properties: new_properties + ).properties + ) + child_filter.save! + + filter_values.each do |key, values| + billable_metric_filter = child_charge.billable_metric.filters.find_by(key:) + + child_filter.values.create!( + billable_metric_filter_id: billable_metric_filter&.id, + organization_id: child_charge.organization_id, + values: + ) + end + end + end + + def destroy_child_filter(child_charge) + child_filter = find_child_filter(child_charge) + return unless child_filter + + child_filter.values.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + child_filter.discard! + end + + def find_child_filter(child_charge) + child_charge.filters.includes(values: :billable_metric_filter).find do |f| + f.to_h == filter_values + end + end + + def filter_customized?(child_filter) + return false unless old_properties + + normalize_properties(old_properties) != normalize_properties(child_filter.properties) + end + + # Cascade group keys even for customized filters — group keys are structural + # (they affect how events are bucketed), not pricing overrides. + def cascade_group_keys(child_filter) + pricing_group_keys = new_properties&.dig("pricing_group_keys") || new_properties&.dig("grouped_by") + if pricing_group_keys + child_filter.properties["pricing_group_keys"] = pricing_group_keys + child_filter.properties.delete("grouped_by") + elsif child_filter.pricing_group_keys.present? + child_filter.properties.delete("pricing_group_keys") + child_filter.properties.delete("grouped_by") + end + end + + def normalize_properties(props) + return props unless props.is_a?(Hash) + + props.transform_values do |v| + (v.is_a?(String) && v.match?(/\A-?\d+(\.\d+)?\z/)) ? v.to_f : v + end + end + end +end diff --git a/app/services/charge_filters/create_or_update_batch_service.rb b/app/services/charge_filters/create_or_update_batch_service.rb new file mode 100644 index 0000000..85d1284 --- /dev/null +++ b/app/services/charge_filters/create_or_update_batch_service.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +module ChargeFilters + class CreateOrUpdateBatchService < BaseService + def initialize(charge:, filters_params:, cascade_options: {}) + @charge = charge + @filters_params = filters_params + @cascade_updates = cascade_options[:cascade] + @parent_filters_attributes = cascade_options[:parent_filters] || [] + @parent_filters = if parent_filters_attributes.blank? + ChargeFilter.none + else + ChargeFilter.with_discarded.where(id: parent_filters_attributes.map { |f| f["id"] }) + end + + super + end + + def call + result.filters = [] + + if filters_params.empty? + remove_all + + return result + end + + return result.single_validation_failure!(field: :values, error_code: "value_is_mandatory") if empty_filter_values? + + # We only care about order when you have less than 100 filters. + touch = filters_params.size < 100 + + ActiveRecord::Base.transaction do + filters_params.each do |filter_param| + # NOTE: since a filter could be a refinement of another one, we have to make sure + # that we are targeting the right one + filter = filters.find do |f| + f.to_h.sort == filter_param[:values].sort + end + + # Skip cascade update if properties are already touched + if cascade_updates && filter && parent_filters + parent_filter = parent_filters.find do |pf| + pf.to_h.sort == filter.to_h.sort + end + + if parent_filter.blank? || normalize_properties(parent_filter_properties(parent_filter)) != normalize_properties(filter.properties) + # Make sure that pricing group keys are cascaded even if properties are overridden + cascade_pricing_group_keys(filter, filter_param) + filter.save! if filter.changed? + + PaperTrail.request.disable_model(filter.class) + # NOTE: Make sure update_at is touched even if not changed to keep the order + filter.touch # rubocop:disable Rails/SkipsModelValidations + PaperTrail.request.enable_model(filter.class) + result.filters << filter + + next + end + end + + filter ||= charge.filters.new(organization_id: charge.organization_id) + + filter.invoice_display_name = filter_param[:invoice_display_name] + filter.properties = ChargeModels::FilterPropertiesService.call( + chargeable: charge, + properties: filter_param[:properties]&.deep_symbolize_keys&.except(:presentation_group_keys) + ).properties + if filter.save! && touch && !filter.changed? + PaperTrail.request.disable_model(filter.class) + # NOTE: Make sure update_at is touched even if not changed to keep the right order + filter.touch # rubocop:disable Rails/SkipsModelValidations + PaperTrail.request.enable_model(filter.class) + end + + # NOTE: Create or update the filter values + filter_param[:values].each do |key, values| + billable_metric_filter = charge.billable_metric.filters.find_by(key:) + + filter_value = filter.values.find_or_initialize_by( + billable_metric_filter_id: billable_metric_filter&.id + ) { it.organization_id = charge.organization_id } + + filter_value.values = values + if filter_value.save! && touch && !filter_value.changed? + PaperTrail.request.disable_model(filter_value.class) + # NOTE: Make sure update_at is touched even if not changed to keep the right order + filter_value.touch # rubocop:disable Rails/SkipsModelValidations + PaperTrail.request.enable_model(filter_value.class) + end + end + + result.filters << filter + end + + # NOTE: remove old filters that were not created or updated + remove_query = charge.filters + remove_query = remove_query.where(id: inherited_filter_ids) if cascade_updates && parent_filters + remove_query.where.not(id: result.filters.map(&:id)).unscope(:order).find_each do + remove_filter(it) + end + end + + result + end + + private + + attr_reader :charge, :filters_params, :cascade_updates, :parent_filters, :parent_filters_attributes + + def filters + @filters ||= charge.filters.includes(values: :billable_metric_filter) + end + + def parent_filter_properties(parent_filter) + match = parent_filters_attributes.find do |f| + f["id"] == parent_filter.id + end + + match["properties"] + end + + def remove_all + ActiveRecord::Base.transaction do + if cascade_updates + charge.filters.where(id: inherited_filter_ids).unscope(:order).find_each { remove_filter(it) } + else + charge.filters.each { remove_filter(it) } + end + end + end + + def remove_filter(filter) + filter.values.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + filter.discard! + end + + def inherited_filter_ids + return @inherited_filter_ids if defined? @inherited_filter_ids + + @inherited_filter_ids = [] + + return @inherited_filter_ids if parent_filters.blank? || !cascade_updates + + parent_filters.unscope(:order).find_each do |pf| + value = pf.to_h_with_discarded.sort + + match = filters.find do |f| + value == f.to_h.sort + end + + @inherited_filter_ids << match.id if match + end + + @inherited_filter_ids + end + + def cascade_pricing_group_keys(filter, params) + pricing_group_keys = params.dig(:properties, :pricing_group_keys) || params.dig(:properties, :grouped_by) + + if pricing_group_keys + filter.properties["pricing_group_keys"] = pricing_group_keys + filter.properties.delete("grouped_by") + elsif filter.pricing_group_keys.present? + filter.properties.delete("pricing_group_keys") + filter.properties.delete("grouped_by") + end + end + + def empty_filter_values? + filters_params.any? { |filter_param| filter_param[:values].blank? } + end + + def normalize_properties(props) + return props unless props.is_a?(Hash) + + props.transform_values do |v| + (v.is_a?(String) && v.match?(/\A-?\d+(\.\d+)?\z/)) ? v.to_f : v + end + end + end +end diff --git a/app/services/charge_filters/create_service.rb b/app/services/charge_filters/create_service.rb new file mode 100644 index 0000000..fa114ef --- /dev/null +++ b/app/services/charge_filters/create_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module ChargeFilters + class CreateService < BaseService + include ChargeFilters::FilterCascadable + + Result = BaseResult[:charge_filter] + + def initialize(charge:, params:, cascade_updates: false) + @charge = charge + @params = params + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + return result.single_validation_failure!(field: :values, error_code: "value_is_mandatory") if params[:values].blank? + + ActiveRecord::Base.transaction do + charge_filter = charge.filters.create!( + organization_id: charge.organization_id, + invoice_display_name: params[:invoice_display_name], + properties: filtered_properties + ) + + create_filter_values(charge_filter) + + result.charge_filter = charge_filter + end + + trigger_filter_cascade( + action: "create", + filter_values: result.charge_filter.to_h, + new_properties: result.charge_filter.properties, + invoice_display_name: result.charge_filter.invoice_display_name + ) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :charge, :params, :cascade_updates + + def filtered_properties + ChargeModels::FilterPropertiesService.call( + chargeable: charge, + properties: params[:properties]&.deep_symbolize_keys&.except(:presentation_group_keys) + ).properties + end + + def create_filter_values(charge_filter) + params[:values].each do |key, values| + billable_metric_filter = charge.billable_metric.filters.find_by(key:) + + filter_value = charge_filter.values.new( + billable_metric_filter_id: billable_metric_filter&.id, + organization_id: charge.organization_id + ) + filter_value.values = values + filter_value.save! + end + end + end +end diff --git a/app/services/charge_filters/destroy_service.rb b/app/services/charge_filters/destroy_service.rb new file mode 100644 index 0000000..e9b9b30 --- /dev/null +++ b/app/services/charge_filters/destroy_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ChargeFilters + class DestroyService < BaseService + include ChargeFilters::FilterCascadable + + Result = BaseResult[:charge_filter] + + def initialize(charge_filter:, cascade_updates: false) + @charge_filter = charge_filter + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "charge_filter") unless charge_filter + + # Capture values before the transaction discards them — to_h uses the kept + # scope and would return an empty hash after discard. + filter_values = charge_filter.to_h_with_discarded + + ActiveRecord::Base.transaction do + charge_filter.values.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + charge_filter.discard! + + result.charge_filter = charge_filter + end + + trigger_filter_cascade(action: "destroy", filter_values:) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :charge_filter, :cascade_updates + + delegate :charge, to: :charge_filter + end +end diff --git a/app/services/charge_filters/event_matching_service.rb b/app/services/charge_filters/event_matching_service.rb new file mode 100644 index 0000000..4e633a8 --- /dev/null +++ b/app/services/charge_filters/event_matching_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module ChargeFilters + class EventMatchingService < BaseService + def initialize(charge:, event:) + @charge = charge + @event = event + + super + end + + def call + # NOTE: Find all filters matching event properties + matching_filters = filters.select do |filter| + filter.to_h.all? do |key, values| + applicable_event_properties.key?(key) && + (applicable_event_properties[key].to_s.in?(values) || values == [ChargeFilterValue::ALL_FILTER_VALUES]) + end + end + + # NOTE: An event could match multiple filters, + # but we must take only the one matching the most properties + result.charge_filter = matching_filters.max_by { |filter| filter.to_h.keys.size } + result + end + + private + + attr_reader :charge, :event + + # NOTE: Exclude event properties not matching a billable metric filter + def applicable_event_properties + @applicable_event_properties ||= event.properties.slice(*charge.billable_metric.filters.pluck(:key)) + end + + def filters + # NOTE: when called from the cache invalidator, filters are already pre-loaded, + # we just return the preloaded list to avoid N+1 queries + return charge.filters if charge.association_cached?(:filters) + + charge.filters.includes(values: :billable_metric_filter) + end + end +end diff --git a/app/services/charge_filters/filter_cascadable.rb b/app/services/charge_filters/filter_cascadable.rb new file mode 100644 index 0000000..bcb1e94 --- /dev/null +++ b/app/services/charge_filters/filter_cascadable.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ChargeFilters + module FilterCascadable + extend ActiveSupport::Concern + + private + + def trigger_filter_cascade(action:, filter_values:, old_properties: nil, new_properties: nil, invoice_display_name: nil) + return unless cascade_updates + return unless charge.children.exists? + + ChargeFilters::CascadeJob.perform_later( + charge.id, + action, + filter_values.deep_stringify_keys, + old_properties&.deep_stringify_keys, + new_properties&.deep_stringify_keys, + invoice_display_name + ) + end + end +end diff --git a/app/services/charge_filters/matching_and_ignored_service.rb b/app/services/charge_filters/matching_and_ignored_service.rb new file mode 100644 index 0000000..22a522f --- /dev/null +++ b/app/services/charge_filters/matching_and_ignored_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ChargeFilters + class MatchingAndIgnoredService < BaseService + def initialize(charge:, filter:) + @charge = charge + @filter = filter + super + end + + def call + result.matching_filters = filter.to_h_with_all_values + + # NOTE: Check if filters contains some key/values from input filter + # Result will have the following format: + # { + # key1: [value1, value2], + # key2: [value3, value4] + # } + children = other_filters.find_all do |f| + child = f.to_h_with_all_values + + result.matching_filters.all? do |key, values| + values.any? { (child[key] || []).include?(it) } + end + end + + # NOTE: List of filters that we must ignore to prevent duplicated count of events + # Result will have the following format: + # [ + # { + # key1: [value1], + # key2: [value3, value4] + # }, + # { + # key1: [value2], + # key2: [value3, value4] + # } + # ] + result.ignored_filters = children.map do |child| + res = child.to_h_with_all_values.dup + + if res.keys == result.matching_filters.keys + # NOTE: when child and filter have the same keys, we need to remove the filter value from the child + res.each do |key, values| + next if filter.to_h[key] == [ChargeFilterValue::ALL_FILTER_VALUES] + + res[key] = values - result.matching_filters[key] + end + end + + res + end.compact + + result + end + + private + + attr_reader :charge, :filter + + def other_filters + @other_filters ||= charge.filters.select { it.id != filter.id } + end + end +end diff --git a/app/services/charge_filters/update_service.rb b/app/services/charge_filters/update_service.rb new file mode 100644 index 0000000..7a7392b --- /dev/null +++ b/app/services/charge_filters/update_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module ChargeFilters + class UpdateService < BaseService + include ChargeFilters::FilterCascadable + + Result = BaseResult[:charge_filter] + + def initialize(charge_filter:, params:, cascade_updates: false) + @charge_filter = charge_filter + @params = params + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "charge_filter") unless charge_filter + + old_properties = charge_filter.properties.deep_dup + + ActiveRecord::Base.transaction do + charge_filter.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + charge_filter.properties = filtered_properties if params.key?(:properties) + charge_filter.save! + + result.charge_filter = charge_filter + end + + trigger_filter_cascade( + action: "update", + filter_values: charge_filter.to_h, + old_properties:, + new_properties: charge_filter.properties, + invoice_display_name: charge_filter.invoice_display_name + ) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :charge_filter, :params, :cascade_updates + + delegate :charge, to: :charge_filter + + def filtered_properties + ChargeModels::FilterPropertiesService.call( + chargeable: charge, + properties: params[:properties]&.deep_symbolize_keys&.except(:presentation_group_keys) + ).properties + end + end +end diff --git a/app/services/charge_models/amount_details/range_graduated_percentage_service.rb b/app/services/charge_models/amount_details/range_graduated_percentage_service.rb new file mode 100644 index 0000000..022f643 --- /dev/null +++ b/app/services/charge_models/amount_details/range_graduated_percentage_service.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module ChargeModels + module AmountDetails + class RangeGraduatedPercentageService < ::BaseService + def initialize(range:, total_units:) + super + @range = range + @total_units = total_units + end + + def call + { + from_value:, + to_value:, + flat_unit_amount:, + rate:, + units: BigDecimal(units).to_s, + per_unit_total_amount: per_unit_total_amount.to_s, + total_with_flat_amount: + } + end + + protected + + attr_reader :range, :total_units + + def from_value + @from_value ||= range[:from_value] + end + + def to_value + @to_value ||= range[:to_value] + end + + def flat_unit_amount + @flat_unit_amount ||= units.zero? ? BigDecimal(0) : BigDecimal(range[:flat_amount]) + end + + def rate + @rate ||= BigDecimal(range[:rate].to_s) + end + + def per_unit_total_amount + @per_unit_total_amount ||= units * rate / 100 + end + + def total_with_flat_amount + @total_with_flat_amount ||= if total_units.zero? + per_unit_total_amount + else + per_unit_total_amount + flat_unit_amount + end + end + + # NOTE: compute how many units to bill in the range + def units + # NOTE: total_units is higher than the to_value of the range + if to_value && total_units >= to_value + return to_value - (from_value.zero? ? 1 : from_value) + 1 + end + + return to_value - from_value if to_value && total_units >= to_value + return total_units if from_value.zero? + + # NOTE: total_units is in the range + total_units - from_value + 1 + end + end + end +end diff --git a/app/services/charge_models/amount_details/range_graduated_service.rb b/app/services/charge_models/amount_details/range_graduated_service.rb new file mode 100644 index 0000000..fa08cb5 --- /dev/null +++ b/app/services/charge_models/amount_details/range_graduated_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module ChargeModels + module AmountDetails + class RangeGraduatedService < ::BaseService + def initialize(range:, total_units:, adjacent_model: false) + super + @range = range + @total_units = total_units + @adjacent_model = adjacent_model + end + + def call + { + from_value:, + to_value:, + flat_unit_amount:, + per_unit_amount:, + units: BigDecimal(units).to_s, + per_unit_total_amount:, + total_with_flat_amount: + } + end + + protected + + attr_reader :range, :total_units + + def from_value + @from_value ||= range[:from_value] + end + + def to_value + @to_value ||= range[:to_value] + end + + def flat_unit_amount + @flat_unit_amount ||= units.zero? ? BigDecimal(0) : BigDecimal(range[:flat_amount]) + end + + def per_unit_amount + @per_unit_amount ||= units.zero? ? BigDecimal(0) : BigDecimal(range[:per_unit_amount]) + end + + def per_unit_total_amount + @per_unit_total_amount ||= units * per_unit_amount + end + + def total_with_flat_amount + @total_with_flat_amount ||= if total_units.zero? + per_unit_total_amount + else + per_unit_total_amount + flat_unit_amount + end + end + + # NOTE: compute how many units to bill in the range + def units + effective_total = if to_value && BigDecimal(total_units.to_s) >= BigDecimal(to_value.to_s) + BigDecimal(to_value.to_s) + else + BigDecimal(total_units.to_s) + end + + return effective_total if BigDecimal(from_value.to_s).zero? + + diff = effective_total - BigDecimal(from_value.to_s) + @adjacent_model ? diff : diff + 1 + end + end + end +end diff --git a/app/services/charge_models/base_service.rb b/app/services/charge_models/base_service.rb new file mode 100644 index 0000000..9ea1335 --- /dev/null +++ b/app/services/charge_models/base_service.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module ChargeModels + class BaseService < ::BaseService + Result = BaseResult[ + :units, # Result of the aggregation + :current_usage_units, # Number of units for current usage (mainly used for prorated or in advance charges) + :full_units_number, # Total number of aggregated units ignoring proration + :count, # Total number of events used for the aggregation + :amount, # Amount result of the charge model applied on the units + :unit_amount, # Amount per unit + :amount_details, # Details of the amount calculation. Depends on the charge model. + :total_aggregated_units, # Total number of aggregated units in the case of a weighted sum aggregation + :grouped_by, # Groups applied on event properties for the aggregation + :grouped_results, # Array containing the result for compatibility with grouped aggregation + :projected_amount, # Projected total amount for the billing period + :projected_units # Projected total units for the billing period + ] + + def self.apply(...) + new(...).apply + end + + def initialize(charge:, aggregation_result:, properties:, period_ratio: nil, calculate_projected_usage: false) + super(nil) + @charge = charge + @aggregation_result = aggregation_result + @properties = properties + @period_ratio = period_ratio + @calculate_projected_usage = calculate_projected_usage + end + + def apply + result.units = aggregation_result.aggregation + result.current_usage_units = aggregation_result.current_usage_units + result.full_units_number = aggregation_result.full_units_number + result.count = aggregation_result.count + result.amount = compute_amount + result.unit_amount = unit_amount + result.amount_details = amount_details + + if aggregation_result.total_aggregated_units + result.total_aggregated_units = aggregation_result.total_aggregated_units + end + + if calculate_projected_usage + result.projected_units = projected_units + result.projected_amount = compute_projected_amount + end + + result.grouped_results = [result] + result + end + + protected + + attr_accessor :charge, :aggregation_result, :properties, :period_ratio, :calculate_projected_usage + + delegate :units, to: :result + delegate :grouped_by, to: :aggregation_result + + def projected_units + return BigDecimal("0") if units.nil? || units.zero? + + begin + (period_ratio > 0) ? (units / BigDecimal(period_ratio.to_s)).round(2) : BigDecimal("0") + rescue => e + Rails.logger.error "Error calculating projected_units in #{self.class}: #{e.message}" + BigDecimal("0") + end + end + + def compute_projected_amount + raise NotImplementedError, "#{self.class} must implement #compute_projected_amount" + end + + def compute_amount + raise NotImplementedError, "#{self.class} must implement #compute_amount" + end + + def unit_amount + raise NotImplementedError, "#{self.class} must implement #unit_amount" + end + + def amount_details + {} + end + end +end diff --git a/app/services/charge_models/build_default_properties_service.rb b/app/services/charge_models/build_default_properties_service.rb new file mode 100644 index 0000000..d73a43f --- /dev/null +++ b/app/services/charge_models/build_default_properties_service.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module ChargeModels + class BuildDefaultPropertiesService < ::BaseService + def initialize(charge_model) + @charge_model = charge_model + super + end + + def call + case charge_model&.to_sym + when :standard then default_standard_properties + when :graduated then default_graduated_properties + when :package then default_package_properties + when :percentage then default_percentage_properties + when :volume then default_volume_properties + when :graduated_percentage then default_graduated_percentage_properties + when :dynamic then default_dynamic_properties + end + end + + private + + attr_reader :charge_model + + def default_standard_properties + {amount: "0"} + end + + def default_graduated_properties + { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0", + flat_amount: "0" + } + ] + } + end + + def default_package_properties + { + package_size: 1, + amount: "0", + free_units: 0 + } + end + + def default_percentage_properties + {rate: "0"} + end + + def default_volume_properties + { + volume_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0", + flat_amount: "0" + } + ] + } + end + + def default_graduated_percentage_properties + { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: nil, + rate: "0", + fixed_amount: "0", + flat_amount: "0" + } + ] + } + end + + def default_dynamic_properties + {} + end + end +end diff --git a/app/services/charge_models/custom_service.rb b/app/services/charge_models/custom_service.rb new file mode 100644 index 0000000..dbc2e0c --- /dev/null +++ b/app/services/charge_models/custom_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module ChargeModels + class CustomService < ChargeModels::BaseService + protected + + def compute_amount + aggregation_result.custom_aggregation&.[](:amount) || 0 + end + + def compute_projected_amount + current_amount = compute_amount + return BigDecimal("0") if current_amount.zero? || period_ratio.nil? || period_ratio.zero? + + current_amount / BigDecimal(period_ratio.to_s) + end + + def unit_amount + total_units = aggregation_result.full_units_number || units + return 0 if total_units.zero? + + result.amount / total_units + end + end +end diff --git a/app/services/charge_models/dynamic_service.rb b/app/services/charge_models/dynamic_service.rb new file mode 100644 index 0000000..930d1c5 --- /dev/null +++ b/app/services/charge_models/dynamic_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ChargeModels + class DynamicService < ChargeModels::BaseService + protected + + def compute_amount + total_units = aggregation_result.full_units_number || units + return 0 if total_units.zero? + + amount_cents = aggregation_result.precise_total_amount_cents + amount_cents / currency.subunit_to_unit + end + + def unit_amount + # eventhough `full_units_number` is not set by the SumService, we still keep this code as is, to be future proof + total_units = aggregation_result.full_units_number || units + return 0 if total_units.zero? + + compute_amount / total_units + end + + def compute_projected_amount + current_amount = compute_amount + return BigDecimal("0") if current_amount.zero? || period_ratio.nil? || period_ratio.zero? + + current_amount / BigDecimal(period_ratio.to_s) + end + + private + + def currency + charge.plan.amount.currency + end + end +end diff --git a/app/services/charge_models/factory.rb b/app/services/charge_models/factory.rb new file mode 100644 index 0000000..6764bba --- /dev/null +++ b/app/services/charge_models/factory.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +module ChargeModels + class Factory + def self.new_instance(chargeable:, aggregation_result:, properties:, period_ratio: 1.0, calculate_projected_usage: false) + raise NotImplementedError, "Chargeable: #{chargeable.class.name} is not implemented" unless chargeable.is_a?(Charge) || chargeable.is_a?(FixedCharge) + + charge_model_class = charge_model_class( + chargeable: chargeable, + has_aggregator: aggregation_result.respond_to?(:aggregator) && !aggregation_result.aggregator.nil? + ) + + common_args = { + charge: chargeable, + aggregation_result:, + properties:, + period_ratio:, + calculate_projected_usage: + } + + # TODO(pricing_group_keys): remove after deprecation of grouped_by + pricing_group_keys = properties["pricing_group_keys"].presence || properties["grouped_by"] + use_grouped_service = pricing_group_keys.present? || (chargeable.is_a?(Charge) && chargeable.accepts_target_wallet) + + if use_grouped_service && !aggregation_result.aggregations.nil? + ChargeModels::GroupedService.new(**common_args.merge(charge_model: charge_model_class)) + else + charge_model_class.new(**common_args) + end + end + + # The has_aggregator param determines whether to use prorated charge models. + # When forecasting (no aggregator available), prorated graduated charges fall back to + # the non-prorated GraduatedService since per-event aggregation data is not available. + # This allows forecasting to work for all charge types without failing on nil aggregator. + def self.charge_model_class(chargeable:, has_aggregator: true) + case chargeable.charge_model.to_sym + when :standard + ChargeModels::StandardService + when :graduated + if chargeable.prorated? && has_aggregator + ChargeModels::ProratedGraduatedService + else + ChargeModels::GraduatedService + end + when :graduated_percentage + ChargeModels::GraduatedPercentageService + when :package + ChargeModels::PackageService + when :percentage + ChargeModels::PercentageService + when :volume + ChargeModels::VolumeService + when :custom + ChargeModels::CustomService + when :dynamic + ChargeModels::DynamicService + else + raise NotImplementedError, "Charge model #{chargeable.charge_model} is not implemented" + end + end + + def self.in_advance_charge_model_class(chargeable:) + case chargeable.charge_model.to_sym + when :standard + ChargeModels::StandardService + when :graduated + ChargeModels::GraduatedService + when :graduated_percentage + ChargeModels::GraduatedPercentageService + when :package + ChargeModels::PackageService + when :percentage + ChargeModels::PercentageService + when :custom + ChargeModels::CustomService + when :dynamic + ChargeModels::DynamicService + else + raise NotImplementedError, "Charge model #{chargeable.charge_model} is not implemented" + end + end + end +end diff --git a/app/services/charge_models/filter_properties/base_service.rb b/app/services/charge_models/filter_properties/base_service.rb new file mode 100644 index 0000000..d8193e3 --- /dev/null +++ b/app/services/charge_models/filter_properties/base_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module ChargeModels + module FilterProperties + class BaseService < ::BaseService + Result = BaseResult[:properties] + + def initialize(chargeable:, properties:) + @chargeable = chargeable + @properties = properties&.with_indifferent_access || {} + + super + end + + def call + result.properties = slice_properties || {} + + if result.properties[:custom_properties].present? && result.properties[:custom_properties].is_a?(String) + result.properties[:custom_properties] = begin + JSON.parse(result.properties[:custom_properties]) + rescue JSON::ParserError + {} + end + end + + result + end + + protected + + attr_reader :chargeable, :properties + + def slice_properties + attributes = base_attributes + charge_model_attributes + sliced_attributes = properties.slice(*attributes) + + # TODO(pricing_group_keys):Deprecate grouped_by attribute + grouped_by = sliced_attributes[:grouped_by] + pricing_group_keys = sliced_attributes[:pricing_group_keys] + + pricing_group_keys = grouped_by if grouped_by.present? && pricing_group_keys.blank? + sliced_attributes[:pricing_group_keys] = pricing_group_keys.reject(&:empty?) if pricing_group_keys.present? + sliced_attributes.delete(:grouped_by) + + sliced_attributes + end + + def base_attributes + [] + end + + def charge_model_attributes + attributes = case charge_model&.to_sym + when :standard + %i[amount] + when :graduated + %i[graduated_ranges] + when :volume + %i[volume_ranges] + else + [] + end + + if charge_model + attributes << :grouped_by if properties[:grouped_by].present? && properties[:pricing_group_keys].blank? + attributes << :pricing_group_keys if properties[:pricing_group_keys].present? + attributes << :presentation_group_keys if properties[:presentation_group_keys].present? + end + + attributes + end + + def charge_model + @charge_model ||= chargeable.charge_model + end + end + end +end diff --git a/app/services/charge_models/filter_properties/charge_service.rb b/app/services/charge_models/filter_properties/charge_service.rb new file mode 100644 index 0000000..4f54f78 --- /dev/null +++ b/app/services/charge_models/filter_properties/charge_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module ChargeModels + module FilterProperties + class ChargeService < BaseService + protected + + def base_attributes + chargeable.billable_metric.custom_agg? ? [:custom_properties] : [] + end + + def charge_model_attributes + attributes = super + + case charge_model&.to_sym + when :graduated_percentage + attributes += %i[graduated_percentage_ranges] + when :package + attributes += %i[amount free_units package_size] + when :percentage + attributes += %i[ + fixed_amount + free_units_per_events + free_units_per_total_aggregation + per_transaction_max_amount + per_transaction_min_amount + rate + ] + end + + attributes + end + end + end +end diff --git a/app/services/charge_models/filter_properties/fixed_charge_service.rb b/app/services/charge_models/filter_properties/fixed_charge_service.rb new file mode 100644 index 0000000..de007b5 --- /dev/null +++ b/app/services/charge_models/filter_properties/fixed_charge_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module ChargeModels + module FilterProperties + class FixedChargeService < BaseService + # FixedCharge has no additional base attributes + # and uses only the standard, graduated, and volume charge models + # which are already handled in the base class + end + end +end diff --git a/app/services/charge_models/filter_properties_service.rb b/app/services/charge_models/filter_properties_service.rb new file mode 100644 index 0000000..00ed8ff --- /dev/null +++ b/app/services/charge_models/filter_properties_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module ChargeModels + class FilterPropertiesService < ::BaseService + Result = BaseResult[:properties] + + def initialize(chargeable:, properties:) + @chargeable = chargeable + @properties = properties&.with_indifferent_access || {} + + super + end + + def call + result.properties = filter_service_result.properties + result + end + + private + + attr_reader :chargeable, :properties + + def filter_service_result + case chargeable + when Charge + ChargeModels::FilterProperties::ChargeService.call(chargeable:, properties:) + when FixedCharge + ChargeModels::FilterProperties::FixedChargeService.call(chargeable:, properties:) + else + raise ArgumentError, "Unsupported chargeable type: #{chargeable.class}" + end + end + end +end diff --git a/app/services/charge_models/graduated_percentage_service.rb b/app/services/charge_models/graduated_percentage_service.rb new file mode 100644 index 0000000..9d64a07 --- /dev/null +++ b/app/services/charge_models/graduated_percentage_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module ChargeModels + class GraduatedPercentageService < ChargeModels::BaseService + protected + + def ranges + properties["graduated_percentage_ranges"]&.map(&:with_indifferent_access) + end + + def amount_details + { + graduated_percentage_ranges: ranges.each_with_object([]) do |range, amounts| + amounts << ChargeModels::AmountDetails::RangeGraduatedPercentageService.call(range:, total_units: units) + break amounts if range[:to_value].nil? || range[:to_value] >= units + end + } + end + + def compute_amount + amount_details.fetch(:graduated_percentage_ranges).sum { |e| e[:total_with_flat_amount] } + end + + def compute_projected_amount + current_amount = compute_amount + return BigDecimal("0") if current_amount.zero? || period_ratio.nil? || period_ratio.zero? + + current_amount / BigDecimal(period_ratio.to_s) + end + + def unit_amount + total_units = aggregation_result.full_units_number || units + return 0 if total_units.zero? + + compute_amount / total_units + end + end +end diff --git a/app/services/charge_models/graduated_service.rb b/app/services/charge_models/graduated_service.rb new file mode 100644 index 0000000..e1a3eca --- /dev/null +++ b/app/services/charge_models/graduated_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module ChargeModels + class GraduatedService < ChargeModels::BaseService + protected + + def ranges + properties["graduated_ranges"]&.map(&:with_indifferent_access) + end + + def amount_details + { + graduated_ranges: ranges.each_with_object([]) do |range, amounts| + amounts << ChargeModels::AmountDetails::RangeGraduatedService.call(range:, total_units: units, adjacent_model: adjacent_ranges?) + break amounts if range[:to_value].nil? || range[:to_value] >= units + end + } + end + + def adjacent_ranges? + return false if ranges.size < 2 + + ranges.each_cons(2).all? do |prev, curr| + BigDecimal(curr[:from_value].to_s) == BigDecimal((prev[:to_value] || 0).to_s) + end + end + + def compute_amount + amount_details.fetch(:graduated_ranges).sum { |e| e[:total_with_flat_amount] } + end + + def compute_projected_amount + return BigDecimal("0") if projected_units.zero? + + remaining_units_to_price = projected_units + total_amount = BigDecimal("0") + + priced_units_count = BigDecimal("0") + + ranges.each do |range| + range_to = range[:to_value] ? BigDecimal(range[:to_value].to_s) : Float::INFINITY + tier_capacity = range_to - priced_units_count + units_in_this_tier = [remaining_units_to_price, tier_capacity].min + + if units_in_this_tier > 0 + range_per_unit = BigDecimal(range[:per_unit_amount] || 0) + range_flat_amount = BigDecimal(range[:flat_amount] || 0) + range_amount = (units_in_this_tier * range_per_unit) + range_flat_amount + total_amount += range_amount + remaining_units_to_price -= units_in_this_tier + priced_units_count += units_in_this_tier + end + break if remaining_units_to_price <= 0 + end + + total_amount + end + + def unit_amount + total_units = aggregation_result.full_units_number || units + return 0 if total_units.zero? + + compute_amount / total_units + end + end +end diff --git a/app/services/charge_models/grouped_service.rb b/app/services/charge_models/grouped_service.rb new file mode 100644 index 0000000..1314984 --- /dev/null +++ b/app/services/charge_models/grouped_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module ChargeModels + class GroupedService < ChargeModels::BaseService + Result = BaseResult[ + :grouped_results, + :amount, + :units, + :projected_amount, + :projected_units + ] + + def initialize(charge_model:, charge:, aggregation_result:, properties:, period_ratio:, calculate_projected_usage: false) + super(charge:, aggregation_result:, properties:, period_ratio:, calculate_projected_usage:) + @charge_model = charge_model + end + + def apply + result.grouped_results = aggregation_result.aggregations.map do |aggregation| + aggregation.aggregator = aggregation_result.aggregator + group_result = charge_model.apply( + charge:, + aggregation_result: aggregation, + properties:, + period_ratio:, + calculate_projected_usage: + ) + group_result.grouped_by = aggregation.grouped_by + group_result + end + + result.amount = result.grouped_results.sum(&:amount) + result.units = result.grouped_results.sum(&:units) + + if calculate_projected_usage + result.projected_amount = result.grouped_results.sum(&:projected_amount) + result.projected_units = result.grouped_results.sum(&:projected_units) + end + + result + end + + protected + + attr_accessor :charge_model + end +end diff --git a/app/services/charge_models/package_service.rb b/app/services/charge_models/package_service.rb new file mode 100644 index 0000000..fa7aff9 --- /dev/null +++ b/app/services/charge_models/package_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module ChargeModels + class PackageService < ChargeModels::BaseService + protected + + def compute_amount + return 0 if paid_units.negative? + + # NOTE: Check how many packages (groups of units) are consumed + # It's rounded up, because a group counts from its first unit + package_count = paid_units.fdiv(per_package_size).ceil + package_count * per_package_unit_amount + end + + def compute_projected_amount + return 0 if projected_units.zero? + + # Calculate projected paid units (after free units) + proj_paid_units = projected_units - BigDecimal(free_units.to_s) + return 0 if proj_paid_units <= 0 + + # Calculate how many packages are needed for projected usage + proj_package_count = (proj_paid_units / BigDecimal(per_package_size.to_s)).ceil + proj_package_count * per_package_unit_amount + end + + def unit_amount + return 0 if paid_units <= 0 + + compute_amount / paid_units + end + + def amount_details + if units.zero? + return {free_units: "0.0", paid_units: "0.0", per_package_size: 0, per_package_unit_amount: "0.0"} + end + + if paid_units.negative? + return { + free_units: BigDecimal(free_units).to_s, + paid_units: "0.0", + per_package_size:, + per_package_unit_amount: + } + end + + { + free_units: BigDecimal(free_units).to_s, + paid_units: BigDecimal(paid_units).to_s, + per_package_size:, + per_package_unit_amount: + } + end + + def paid_units + @paid_units ||= units - free_units + end + + def free_units + @free_units ||= properties["free_units"] || 0 + end + + def per_package_size + @per_package_size ||= properties["package_size"] + end + + def per_package_unit_amount + @per_package_unit_amount ||= BigDecimal(properties["amount"]) + end + end +end diff --git a/app/services/charge_models/percentage_service.rb b/app/services/charge_models/percentage_service.rb new file mode 100644 index 0000000..03c3ea7 --- /dev/null +++ b/app/services/charge_models/percentage_service.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +module ChargeModels + class PercentageService < ChargeModels::BaseService + protected + + def compute_amount + # NOTE: if min/max per transacton are applied, we have to compute amount on a per transaction basis. + # In the future, this logic could also be applied for the free units / amount without min/max + return compute_amount_with_transaction_min_max if should_apply_min_max? + + compute_percentage_amount + compute_fixed_amount + end + + def compute_projected_amount + current_amount = compute_amount + return BigDecimal(0) if current_amount.zero? || period_ratio.nil? || period_ratio.zero? + + current_amount / BigDecimal(period_ratio.to_s) + end + + def amount_details + paid_units = units - free_units_value + paid_units = 0 if paid_units.negative? + paid_units.zero? ? BigDecimal(0) : compute_percentage_amount.fdiv(paid_units) + free_events = if aggregation_result.count >= free_units_count + free_units_count + else + aggregation_result.count + end + paid_events = aggregation_result.count - free_events + + { + units: BigDecimal(units).to_s, + free_units: BigDecimal(free_units_value).to_s, + free_events:, + paid_units: BigDecimal(paid_units).to_s, + rate:, + per_unit_total_amount: compute_percentage_amount, + paid_events:, + fixed_fee_unit_amount: paid_events.positive? ? fixed_amount : BigDecimal(0), + fixed_fee_total_amount: compute_fixed_amount.to_s, + min_max_adjustment_total_amount: min_max_adjustment_total_amount.to_s + } + end + + def unit_amount + total_units = aggregation_result.full_units_number || units + return 0 if total_units.zero? + + compute_amount / total_units + end + + def compute_percentage_amount + return 0 if free_units_value > units + + (units - free_units_value) * rate / 100 + end + + def compute_fixed_amount + return 0.0 if units.zero? + return 0.0 if fixed_amount.nil? + return 0.0 if free_units_count >= aggregation_result.count + + (aggregation_result.count - free_units_count) * fixed_amount + end + + # TODO: add memoization as this method is being called 4 times in the class + # TODO: resect properties[:exclude_event] flag + def free_units_value + return 0 if last_running_total.zero? + if free_units_per_events > 0 && free_units_per_events < (aggregation_result.options[:running_total]&.count || 0) + return aggregation_result.options[:running_total][free_units_per_events - 1] + end + return last_running_total if free_units_per_total_aggregation.zero? + return last_running_total if last_running_total <= free_units_per_total_aggregation + + free_units_per_total_aggregation + end + + def free_units_count + [ + free_units_per_events, + aggregation_result.options[:running_total]&.count { |e| e < free_units_per_total_aggregation } || 0 + ].excluding(0).min || 0 + end + + def last_running_total + @last_running_total ||= aggregation_result.options[:running_total]&.last || 0 + end + + def free_units_per_total_aggregation + @free_units_per_total_aggregation ||= BigDecimal(properties["free_units_per_total_aggregation"] || 0) + end + + def free_units_per_events + @free_units_per_events ||= properties["free_units_per_events"].to_i + end + + # NOTE: FE divides percentage rate with 100 and sends to BE. + def rate + BigDecimal(properties["rate"].to_s) + end + + def fixed_amount + @fixed_amount ||= BigDecimal((properties["fixed_amount"] || 0).to_s) + end + + def per_transaction_max_amount? + properties["per_transaction_max_amount"].present? + end + + def per_transaction_min_amount? + properties["per_transaction_min_amount"].present? + end + + def per_transaction_max_amount + BigDecimal(properties["per_transaction_max_amount"]) + end + + def per_transaction_min_amount + BigDecimal(properties["per_transaction_min_amount"]) + end + + def should_apply_min_max? + return false unless License.premium? + + per_transaction_max_amount? || per_transaction_min_amount? + end + + def events_values + # NOTE: when performing aggregation for pay in advance, we have to ignore the current event + # for computing the diff between event included and excluded + # see app/services/charges/apply_pay_in_advance_charge_model_service.rb:18 + aggregation_result.aggregator.per_event_aggregation( + exclude_event: properties[:exclude_event], + include_event_value: properties[:include_event_value], + grouped_by_values: grouped_by + ).event_aggregation + end + + def compute_amount_with_transaction_min_max + return @compute_amount_with_transaction_min_max if defined?(@compute_amount_with_transaction_min_max) + + remaining_free_events = free_units_per_events + remaining_free_amount = free_units_per_total_aggregation + + @compute_amount_with_transaction_min_max ||= events_values.reduce(0) do |total_amount, event_value| + value = event_value + + # NOTE: apply free events + if remaining_free_events.positive? || remaining_free_amount.positive? + remaining_free_events -= 1 + + next 0 unless remaining_free_amount.positive? + + # NOTE: apply free amount + if remaining_free_amount > value + remaining_free_amount -= value + next 0 + else + value -= remaining_free_amount + remaining_free_amount = 0 + remaining_free_events = 0 + end + end + + # NOTE: apply rate + event_amount = (value * rate) / 100 + + # NOTE: apply fixed amount + event_amount += fixed_amount + + # NOTE: apply min and max amount per transaction + event_amount = apply_min_max(event_amount) + + total_amount + event_amount + end + end + + def apply_min_max(amount) + return per_transaction_min_amount if per_transaction_min_amount? && amount < per_transaction_min_amount + return per_transaction_max_amount if per_transaction_max_amount? && amount > per_transaction_max_amount + + amount + end + + def min_max_adjustment_total_amount + return BigDecimal(0) unless should_apply_min_max? + + BigDecimal(compute_amount_with_transaction_min_max - compute_percentage_amount - compute_fixed_amount) + end + end +end diff --git a/app/services/charge_models/prorated_graduated_service.rb b/app/services/charge_models/prorated_graduated_service.rb new file mode 100644 index 0000000..6cf757f --- /dev/null +++ b/app/services/charge_models/prorated_graduated_service.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +module ChargeModels + class ProratedGraduatedService < ChargeModels::BaseService + protected + + def ranges + properties["graduated_ranges"]&.map(&:with_indifferent_access) + end + + def compute_amount + full_units = per_event_aggregation_result.event_aggregation + + prorated_units = if per_event_aggregation_result.respond_to?(:event_prorated_aggregation) + per_event_aggregation_result.event_prorated_aggregation + else + [] + end + units_count = prorated_units.count + + index = 0 + overflow = 0 + full_sum = 0 + max_full_sum = 0 + prorated_sum = 0 + result_amount = 0 + + return 0 if units.zero? + + # Calculate total prorated value inside the tier. The goal is to iterate over both arrays (prorated and full) + # and determine which prorated events goes into certain tier. Full units sum determines tier while + # prorated units sum determines amount that is going to be used for price calculation inside the tier. + # Overflow can happen if event value covers partially both lower and higher tier + while (index < units_count) || !overflow.zero? + range = range(full_sum, overflow, full_units[index]) + + # Here is applied overflow from previous iteration (if any) + unless overflow.zero? + prorated_sum += overflow * prorated_coefficient(prorated_units[index - 1], full_units[index - 1]) + # This condition handles multiple overflows. E.g. We have two tiers: 0 - 5, 6 - inf. + # There is only one event whose value is 75. There will be overflow for each tier and we need to + # calculate it for each tier + if range[:to_value] && full_sum >= range[:to_value] + overflow = full_sum - range[:to_value] + prorated_sum -= overflow * prorated_coefficient(prorated_units[index - 1], full_units[index - 1]) + result_amount += prorated_sum * BigDecimal(range[:per_unit_amount]) + prorated_sum = 0 + + next + end + + overflow = 0 + end + + # If we are into highest range and overflow is handled we should exit the loop if there is no more events + break if prorated_units[index].nil? + + # Skip ADD events with zero prorated value - they shouldn't affect tier assignment. + # For example, when a REMOVE before the current billing period. + if prorated_units[index].zero? && full_units[index].positive? + index += 1 + next + end + + # Skip REMOVE events whose corresponding add was skipped (orphan removes). + # These are identified by having zero prorated value AND would make full_sum negative + # (indicating the matching add event was skipped). + # Note: Remove events within the current period also have prorated_value = 0 by design, + # but they won't make full_sum negative because their matching add was processed. + if prorated_units[index].zero? && full_units[index].negative? && (full_sum + full_units[index]).negative? + index += 1 + next + end + + full_sum += full_units[index] + max_full_sum = full_sum if full_sum > max_full_sum + prorated_sum += prorated_units[index] + + index += 1 + + next if skip_overflow_calculation?(full_sum, range[:to_value], range[:from_value]) + + # Calculating overflow (if any) and aligning current invalid prorated sum with prorated overflow amount + overflow = calculate_overflow(full_sum, range[:to_value], range[:from_value]) + prorated_sum -= overflow * prorated_coefficient(prorated_units[index - 1], full_units[index - 1]) + + result_amount += prorated_sum * BigDecimal(range[:per_unit_amount]) + prorated_sum = 0 + end + + result_amount += prorated_sum * BigDecimal(range[:per_unit_amount]) # Applying units from highest range + + result_with_flat_amount(result_amount, full_sum, max_full_sum) + end + + def compute_projected_amount + current_amount = compute_amount + return BigDecimal(0) if current_amount.zero? || period_ratio.nil? || period_ratio.zero? + + current_amount / BigDecimal(period_ratio.to_s) + end + + def unit_amount + total_units = per_event_aggregation_result.event_aggregation.sum + return 0 if total_units.zero? + + compute_amount / total_units + end + + private + + def result_with_flat_amount(result, total_full_units, max_full_units) + return 0 if units.zero? || total_full_units.negative? + + flat_amount = 0 + result = 0 if result.negative? + + ranges.each do |range| + flat_amount += BigDecimal(range[:flat_amount]) + + return result + flat_amount if range[:to_value].nil? || max_full_units <= range[:to_value] + end + end + + def range(full_units, overflow, next_full_unit) + return ranges[0] if full_units <= 0 + + units = if overflow.zero? + full_units + else + overflow.positive? ? (full_units - overflow + 1) : (full_units + overflow) + end + + ranges.each_with_index do |range, index| + return ranges[index + 1] if units == range[:to_value] && next_full_unit&.positive? + return range if units == range[:to_value] + return range if units >= range[:from_value] && (range[:to_value].nil? || units < range[:to_value]) + end + + ranges[0] + end + + def calculate_overflow(full_sum, to_value, from_value) + return full_sum - from_value + 1 if to_value.nil? + + if full_sum >= to_value + full_sum - to_value + else + full_sum - from_value + 1 + end + end + + def per_event_aggregation_result + @per_event_aggregation_result ||= aggregation_result.aggregator.per_event_aggregation( + grouped_by_values: grouped_by + ) + end + + def prorated_coefficient(prorated_value, full_value) + prorated_value.fdiv(full_value) + end + + def skip_overflow_calculation?(full_sum, to_value, from_value) + return full_sum >= from_value - 1 if to_value.nil? + + full_sum < to_value && full_sum >= from_value + end + end +end diff --git a/app/services/charge_models/standard_service.rb b/app/services/charge_models/standard_service.rb new file mode 100644 index 0000000..514be59 --- /dev/null +++ b/app/services/charge_models/standard_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ChargeModels + class StandardService < ChargeModels::BaseService + protected + + def compute_amount + (units * BigDecimal(properties["amount"])) + end + + def compute_projected_amount + projected_units * BigDecimal(properties["amount"]) + end + + def unit_amount + total_units = aggregation_result.full_units_number || units + return 0 if total_units.zero? + + compute_amount / total_units + end + end +end diff --git a/app/services/charge_models/volume_service.rb b/app/services/charge_models/volume_service.rb new file mode 100644 index 0000000..26cfa70 --- /dev/null +++ b/app/services/charge_models/volume_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module ChargeModels + class VolumeService < ChargeModels::BaseService + protected + + def ranges + properties["volume_ranges"]&.map(&:with_indifferent_access)&.sort_by { |h| h[:from_value] } + end + + def compute_amount + return 0 if units.zero? + + per_unit_total_amount + flat_unit_amount + end + + def compute_projected_amount + return BigDecimal("0") if projected_units.zero? + + range_for_projection = ranges.find do |range| + range[:from_value] <= projected_units.ceil && (!range[:to_value] || projected_units <= range[:to_value]) + end + + return BigDecimal("0") unless range_for_projection + + per_unit_price = BigDecimal(range_for_projection[:per_unit_amount] || 0) + flat_fee = BigDecimal(range_for_projection[:flat_amount] || 0) + + (projected_units * per_unit_price) + flat_fee + end + + def unit_amount + return 0 if number_of_units.zero? + + compute_amount / number_of_units + end + + def amount_details + if number_of_units.zero? + return { + flat_unit_amount: BigDecimal(0), + per_unit_amount: BigDecimal(0), + per_unit_total_amount: BigDecimal(0) + } + end + + { + flat_unit_amount:, + per_unit_amount: per_unit_amount.to_s, + per_unit_total_amount: + } + end + + def flat_unit_amount + @flat_unit_amount ||= BigDecimal(matching_range[:flat_amount]) + end + + def per_unit_amount + @per_unit_amount ||= per_unit_total_amount.fdiv(number_of_units) + end + + def per_unit_total_amount + @per_unit_total_amount ||= units * BigDecimal(matching_range[:per_unit_amount]) + end + + def matching_range + @matching_range ||= ranges.find do |range| + range[:from_value] <= number_of_units&.ceil && (!range[:to_value] || number_of_units <= range[:to_value]) + end + end + + def number_of_units + @number_of_units ||= (charge.prorated? && result.full_units_number) ? result.full_units_number : units + end + end +end diff --git a/app/services/charges/apply_pay_in_advance_charge_model_service.rb b/app/services/charges/apply_pay_in_advance_charge_model_service.rb new file mode 100644 index 0000000..4a0b9cb --- /dev/null +++ b/app/services/charges/apply_pay_in_advance_charge_model_service.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +module Charges + class ApplyPayInAdvanceChargeModelService < BaseService + def initialize(charge:, aggregation_result:, properties:) + @charge = charge + @aggregation_result = aggregation_result + @properties = properties + + super + end + + def call + unless charge.pay_in_advance? + return result.service_failure!(code: "apply_charge_model_error", message: "Charge is not pay_in_advance") + end + + amount = if with_persisted_event? + amount_from_aggregation - amount_excluding_persisted_event + else + amount_including_non_persisted_event - amount_from_aggregation + end + + # NOTE: amount_result should be a BigDecimal, we need to round it + # to the currency decimals and transform it into currency cents + rounded_amount = amount.round(currency.exponent) + amount_cents = rounded_amount * currency.subunit_to_unit + + result.units = compute_units + result.count = 1 + result.amount = amount_cents + result.precise_amount = amount * currency.subunit_to_unit.to_d + result.unit_amount = rounded_amount.zero? ? BigDecimal(0) : rounded_amount / compute_units + result.amount_details = calculated_single_event_amount_details if with_persisted_event? + result + end + + private + + attr_reader :charge, :aggregation_result, :properties + + def with_persisted_event? + aggregation_result.pay_in_advance_event.persisted + end + + def charge_model + @charge_model ||= ChargeModels::Factory.in_advance_charge_model_class(chargeable: charge) + end + + def applied_charge_model + @applied_charge_model ||= charge_model.apply(charge:, aggregation_result:, properties:) + end + + # Compute aggregation and apply charge for all events including the current one + def amount_from_aggregation + @amount_from_aggregation ||= applied_charge_model.amount + end + + def applied_charge_model_excluding_persisted_event + return @applied_charge_model_excluding_persisted_event if defined?(@applied_charge_model_excluding_persisted_event) + + precise_total_amount_cents = if aggregation_result.precise_total_amount_cents + aggregation_result.precise_total_amount_cents - aggregation_result.pay_in_advance_precise_total_amount_cents + end + + result_without_event = build_aggregation_result( + aggregation: aggregation_result.aggregation - aggregation_result.pay_in_advance_aggregation, + count: aggregation_result.count - 1, + precise_total_amount_cents: + ) + + @applied_charge_model_excluding_persisted_event ||= charge_model.apply( + charge:, + aggregation_result: result_without_event, + properties: (properties || {}).merge(exclude_event: true) + ) + end + + # Compute aggregation and apply charge for all events excluding the current one + def amount_excluding_persisted_event + applied_charge_model_excluding_persisted_event.amount + end + + def applied_charge_model_including_non_persisted_event + return @applied_charge_model_including_non_persisted_event if defined?(@applied_charge_model_including_non_persisted_event) + + precise_total_amount_cents = if aggregation_result.precise_total_amount_cents + aggregation_result.precise_total_amount_cents + aggregation_result.pay_in_advance_precise_total_amount_cents + end + + result_with_event = build_aggregation_result( + aggregation: aggregation_result.aggregation + aggregation_result.pay_in_advance_aggregation, + count: aggregation_result.count + 1, + precise_total_amount_cents: + ) + + @applied_charge_model_including_non_persisted_event ||= charge_model.apply( + charge:, + aggregation_result: result_with_event, + properties: (properties || {}).merge(include_event_value: true) + ) + end + + def amount_including_non_persisted_event + applied_charge_model_including_non_persisted_event.amount + end + + def currency + @currency ||= charge.plan.amount.currency + end + + def compute_units + if display_applied_units_for_zero_invoice? + units_applied = BigDecimal(aggregation_result.units_applied) + units_applied.negative? ? 0 : units_applied + elsif charge.prorated? + aggregation_result.full_units_number + else + aggregation_result.pay_in_advance_aggregation + end + end + + def display_applied_units_for_zero_invoice? + aggregation_result.current_aggregation && + aggregation_result.max_aggregation && + aggregation_result.units_applied && + aggregation_result.current_aggregation <= aggregation_result.max_aggregation + end + + def calculated_single_event_amount_details + PayInAdvance::AmountDetailsCalculator.call( + charge:, + applied_charge_model:, + applied_charge_model_excluding_event: applied_charge_model_excluding_persisted_event + ) + end + + def build_aggregation_result(aggregation:, count:, precise_total_amount_cents:) + new_result = BillableMetrics::Aggregations::BaseService::Result.new + new_result.aggregation = aggregation + new_result.count = count + new_result.options = aggregation_result.options + new_result.aggregator = aggregation_result.aggregator + new_result.pay_in_advance_event = aggregation_result.pay_in_advance_event + new_result.precise_total_amount_cents = precise_total_amount_cents + new_result + end + end +end diff --git a/app/services/charges/apply_taxes_service.rb b/app/services/charges/apply_taxes_service.rb new file mode 100644 index 0000000..8b91954 --- /dev/null +++ b/app/services/charges/apply_taxes_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Charges + class ApplyTaxesService < BaseService + def initialize(charge:, tax_codes:) + @charge = charge + @tax_codes = tax_codes + + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + return result.not_found_failure!(resource: "tax") if (tax_codes - taxes.pluck(:code)).present? + + charge.applied_taxes.where( + tax_id: charge.taxes.where.not(code: tax_codes).pluck(:id) + ).destroy_all + + result.applied_taxes = tax_codes.map do |tax_code| + charge.applied_taxes + .create_with(organization_id: charge.organization_id) + .find_or_create_by!(tax: taxes.find_by(code: tax_code)) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :charge, :tax_codes + + def taxes + @taxes ||= charge.plan.organization.taxes.where(code: tax_codes) + end + end +end diff --git a/app/services/charges/bulk_forecasted_usage_amount_service.rb b/app/services/charges/bulk_forecasted_usage_amount_service.rb new file mode 100644 index 0000000..918b660 --- /dev/null +++ b/app/services/charges/bulk_forecasted_usage_amount_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Charges + class BulkForecastedUsageAmountService < BaseService + Result = BaseResult[:results, :failed_charges, :processed_count, :failed_count] + + def initialize(charges_data:) + @charges_data = charges_data + super + end + + def call + charge_ids = charges_data.map { |cd| cd[:charge_id] }.compact.uniq + charge_filter_ids = charges_data.map { |cd| cd[:charge_filter_id] }.compact.uniq.reject(&:blank?) + + charges_lookup = Charge.where(id: charge_ids).index_by(&:id) + charge_filters_lookup = charge_filter_ids.any? ? + ChargeFilter.where(id: charge_filter_ids).index_by(&:id) : {} + + results = [] + failed_charges = [] + + charges_data.each do |charge_data| + charge = charges_lookup[charge_data[:charge_id]] + charge_filter = charge_data[:charge_filter_id].present? ? + charge_filters_lookup[charge_data[:charge_filter_id]] : nil + record_id = charge_data[:record_id] + + unless charge + raise ActiveRecord::RecordNotFound, "Charge not found: #{charge_data[:charge_id]}" + end + + if charge_data[:charge_filter_id].present? && !charge_filter + raise ActiveRecord::RecordNotFound, "ChargeFilter not found: #{charge_data[:charge_filter_id]}" + end + + percentile_results = {} + + [:units_conservative, :units_realistic, :units_optimistic].each do |percentile_key| + units = charge_data[percentile_key] + next unless units + + price_result = Charges::CalculatePriceService.call( + units: units, + charge: charge, + charge_filter: charge_filter + ) + + if price_result.success? + suffix = percentile_key.to_s.gsub("units_", "") + percentile_results[:"charge_amount_cents_#{suffix}"] = price_result.charge_amount_cents * 100 + percentile_results[:"subscription_amount_cents_#{suffix}"] = price_result.subscription_amount_cents * 100 + percentile_results[:"total_amount_cents_#{suffix}"] = price_result.total_amount_cents * 100 + end + end + + results << { + record_id: record_id, + charge_id: charge_data[:charge_id], + charge_filter_id: charge_data[:charge_filter_id], + **percentile_results + } + rescue => e + failed_charges << { + record_id: record_id, + charge_id: charge_data[:charge_id], + error: e.message + } + end + + result.results = results + result.failed_charges = failed_charges + result.processed_count = results.length + result.failed_count = failed_charges.length + + response_summary = { + failed_charges: failed_charges, + processed_count: result.processed_count, + failed_count: result.failed_count + } + + Rails.logger.info "[ChargesController] Response summary: #{response_summary}" + + result + end + + private + + attr_reader :charges_data + end +end diff --git a/app/services/charges/calculate_price_service.rb b/app/services/charges/calculate_price_service.rb new file mode 100644 index 0000000..3a834f8 --- /dev/null +++ b/app/services/charges/calculate_price_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Charges + class CalculatePriceService < BaseService + Result = BaseResult[:charge_amount_cents, :subscription_amount_cents, :total_amount_cents] + AggregationResult = Struct.new(:grouped_by, :aggregator, :aggregations, :aggregation, :total_aggregated_units, :current_usage_units, :full_units_number, :precise_total_amount_cents, :custom_aggregation, :options) + + def initialize(units:, charge:, charge_filter: nil) + @units = BigDecimal(units || 0) + @charge = charge + @charge_filter = charge_filter + @billable_metric = charge&.billable_metric + + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + + result.charge_amount_cents = calculate_charge_amount + result.subscription_amount_cents = BigDecimal(plan.amount_cents) + result.total_amount_cents = result.charge_amount_cents + result.subscription_amount_cents + result + end + + private + + attr_reader :units, :charge, :charge_filter, :billable_metric + + delegate :plan, to: :charge + + def calculate_charge_amount + return 0 unless charge + + properties = charge_filter&.properties || + charge.properties.presence || + ChargeModels::BuildDefaultPropertiesService.call(charge.charge_model) + + filtered_properties = ChargeModels::FilterPropertiesService.call(chargeable: charge, properties:).properties + + charge_model = ChargeModels::Factory.new_instance( + chargeable: charge, + aggregation_result:, + properties: filtered_properties + ) + + charge_model.apply.amount + end + + def aggregation_result + AggregationResult.new(nil, nil, nil, units, units, units, units, 0, nil, running_total: []) + end + end +end diff --git a/app/services/charges/cascade_updatable.rb b/app/services/charges/cascade_updatable.rb new file mode 100644 index 0000000..d27787c --- /dev/null +++ b/app/services/charges/cascade_updatable.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Charges + module CascadeUpdatable + extend ActiveSupport::Concern + + private + + def trigger_cascade(old_filters_attrs, old_parent_attrs: nil, old_applied_pricing_unit_attrs: nil) + return unless cascade_updates + return unless charge.children.exists? + + Charges::UpdateChildrenJob.perform_later( + params: build_cascade_params.deep_stringify_keys, + old_parent_attrs: old_parent_attrs || charge.attributes, + old_parent_filters_attrs: old_filters_attrs.map(&:deep_stringify_keys), + old_parent_applied_pricing_unit_attrs: old_applied_pricing_unit_attrs || charge.applied_pricing_unit&.attributes + ) + end + + def build_cascade_params + { + code: charge.code, + charge_model: charge.charge_model, + properties: charge.properties, + filters: charge.filters.reload.map do |f| + { + invoice_display_name: f.invoice_display_name, + properties: f.properties, + values: f.to_h + } + end + } + end + + def capture_old_filters_attrs + charge.filters.map { |f| {id: f.id, properties: f.properties} } + end + + def capture_old_applied_pricing_unit_attrs + charge.applied_pricing_unit&.attributes + end + end +end diff --git a/app/services/charges/create_children_service.rb b/app/services/charges/create_children_service.rb new file mode 100644 index 0000000..cf23145 --- /dev/null +++ b/app/services/charges/create_children_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Charges + class CreateChildrenService < BaseService + Result = BaseResult[:charge] + + def initialize(child_ids:, charge:, payload:) + @charge = charge + @payload = payload.deep_symbolize_keys + @child_ids = child_ids + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + + ActiveRecord::Base.transaction do + # skip touching to avoid deadlocks + Plan.no_touching do + plan.children.where(id: child_ids).find_each do |child| + create_params = if payload[:code].present? + payload + else + payload.merge(code: charge.code) + end + Charges::CreateService.call!(plan: child, params: create_params.merge(parent_id: charge.id)) + end + end + end + + result.charge = charge + result + end + + private + + attr_reader :charge, :payload, :child_ids + + delegate :plan, to: :charge + end +end diff --git a/app/services/charges/create_service.rb b/app/services/charges/create_service.rb new file mode 100644 index 0000000..4c302c6 --- /dev/null +++ b/app/services/charges/create_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Charges + class CreateService < BaseService + def initialize(plan:, params:, cascade_updates: false) + @plan = plan + @params = params + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "plan") unless plan + return result.not_found_failure!(resource: "billable_metric") unless billable_metric + + ActiveRecord::Base.transaction do + charge = plan.charges.new( + organization_id: plan.organization_id, + billable_metric_id: params[:billable_metric_id], + code: params[:code], + invoice_display_name: params[:invoice_display_name], + amount_currency: params[:amount_currency], + charge_model: params[:charge_model], + parent_id: params[:parent_id], + pay_in_advance: params[:pay_in_advance] || false, + prorated: params[:prorated] || false + ) + + properties = params[:properties].presence || ChargeModels::BuildDefaultPropertiesService.call(charge.charge_model) + charge.properties = ChargeModels::FilterPropertiesService.call( + chargeable: charge, + properties: + ).properties + + if params[:filters].present? + charge.save! + ChargeFilters::CreateOrUpdateBatchService.call( + charge:, + filters_params: params[:filters].map(&:with_indifferent_access) + ).raise_if_error! + end + + if License.premium? + charge.invoiceable = params[:invoiceable] unless params[:invoiceable].nil? + charge.regroup_paid_fees = params[:regroup_paid_fees] if params.key?(:regroup_paid_fees) + charge.min_amount_cents = params[:min_amount_cents] || 0 + + if plan.organization.events_targeting_wallets_enabled? + charge.accepts_target_wallet = params[:accepts_target_wallet] || false + end + end + + charge.save! + + AppliedPricingUnits::CreateService.call!(charge:, params: params[:applied_pricing_unit]) + + if params[:tax_codes] + taxes_result = Charges::ApplyTaxesService.call(charge:, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + result.charge = charge + end + + if cascade_updates && result.success? && result.charge.plan.children.exists? + Charges::CreateChildrenJob.perform_later(charge: result.charge, payload: params) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.single_validation_failure!(field: :code, error_code: "value_already_exist") + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :plan, :params, :cascade_updates + + def billable_metric + @billable_metric ||= plan.organization.billable_metrics.find_by(id: params[:billable_metric_id]) + end + end +end diff --git a/app/services/charges/destroy_children_service.rb b/app/services/charges/destroy_children_service.rb new file mode 100644 index 0000000..f799ad1 --- /dev/null +++ b/app/services/charges/destroy_children_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Charges + class DestroyChildrenService < BaseService + Result = BaseResult[:charge] + + def initialize(charge) + @charge = charge + super + end + + def call + return result unless charge + return result unless charge.discarded? + + ActiveRecord::Base.transaction do + # skip touching to avoid deadlocks + Plan.no_touching do + charge.children.joins(plan: :subscriptions).where(subscriptions: {status: %w[active pending]}).distinct.find_each do |charge| + Charges::DestroyService.call!(charge:) + end + end + end + + result.charge = charge + result + end + + private + + attr_reader :charge + end +end diff --git a/app/services/charges/destroy_service.rb b/app/services/charges/destroy_service.rb new file mode 100644 index 0000000..f9b56fb --- /dev/null +++ b/app/services/charges/destroy_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Charges + class DestroyService < BaseService + Result = BaseResult[:charge] + + def initialize(charge:, cascade_updates: false) + @charge = charge + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + + ActiveRecord::Base.transaction do + charge.discard! + + deleted_at = Time.current + # rubocop:disable Rails/SkipsModelValidations + charge.filter_values.update_all(deleted_at:) + charge.filters.update_all(deleted_at:) + # rubocop:enable Rails/SkipsModelValidations + + result.charge = charge + end + + if cascade_updates && charge.children.exists? + Charges::DestroyChildrenJob.perform_later(charge.id) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :charge, :cascade_updates + end +end diff --git a/app/services/charges/estimate_instant/percentage_service.rb b/app/services/charges/estimate_instant/percentage_service.rb new file mode 100644 index 0000000..0d13c8d --- /dev/null +++ b/app/services/charges/estimate_instant/percentage_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Charges + module EstimateInstant + class PercentageService < BaseService + def initialize(properties:, units:) + @properties = properties + @units = units + super + end + + def call + result.units = units + if units.negative? + result.units = 0 + result.amount = 0 + return result + end + + amount = units * rate / 100 + amount += fixed_amount + amount = amount.clamp(per_transaction_min_amount, per_transaction_max_amount) + + result.amount = amount + result + end + + private + + attr_reader :properties, :units + + def rate + BigDecimal(properties["rate"].to_s) + end + + def per_transaction_max_amount + return nil if properties["per_transaction_max_amount"].blank? + BigDecimal(properties["per_transaction_max_amount"].to_s) + end + + def fixed_amount + BigDecimal((properties["fixed_amount"] || 0).to_s) + end + + def per_transaction_min_amount + return nil if properties["per_transaction_min_amount"].blank? + BigDecimal(properties["per_transaction_min_amount"].to_s) + end + end + end +end diff --git a/app/services/charges/estimate_instant/standard_service.rb b/app/services/charges/estimate_instant/standard_service.rb new file mode 100644 index 0000000..cec4586 --- /dev/null +++ b/app/services/charges/estimate_instant/standard_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Charges + module EstimateInstant + class StandardService < BaseService + def initialize(properties:, units:) + @properties = properties + @units = units + super + end + + def call + result.units = units + if units.negative? + result.units = 0 + result.amount = 0 + return result + end + + result.amount = units * amount + result + end + + private + + attr_reader :properties, :units + + def amount + BigDecimal((properties["amount"] || 0).to_s) + end + end + end +end diff --git a/app/services/charges/generate_code_service.rb b/app/services/charges/generate_code_service.rb new file mode 100644 index 0000000..4d22c53 --- /dev/null +++ b/app/services/charges/generate_code_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Charges + class GenerateCodeService < BaseService + Result = BaseResult[:code] + + def initialize(plan:, billable_metric:) + @plan = plan + @billable_metric = billable_metric + + super + end + + def call + result.code = generate_unique_code + result + end + + private + + attr_reader :plan, :billable_metric + + def generate_unique_code + base_code = billable_metric.code + + return base_code unless plan.charges.parents.exists?(code: base_code) + + existing_suffixes = plan.charges.parents + .where("code ~ ?", "^#{Regexp.escape(base_code)}_\\d+$") + .pluck(:code) + .map { |code| code.delete_prefix("#{base_code}_").to_i } + + next_suffix = (existing_suffixes.max || 1) + 1 + + "#{base_code}_#{next_suffix}" + end + end +end diff --git a/app/services/charges/override_service.rb b/app/services/charges/override_service.rb new file mode 100644 index 0000000..843cc85 --- /dev/null +++ b/app/services/charges/override_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Charges + class OverrideService < BaseService + def initialize(charge:, params:) + @charge = charge + @params = params + + super + end + + def call + return result unless License.premium? + + ActiveRecord::Base.transaction do + new_charge = charge.dup.tap do |c| + c.properties = params[:properties] if params.key?(:properties) + c.min_amount_cents = params[:min_amount_cents] if params.key?(:min_amount_cents) + c.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + c.parent_id = charge.id + c.filters = charge.filters.map do |filter| + f = filter.dup + f.values = filter.values.map(&:dup) + f + end + c.plan_id = params[:plan_id] + end + new_charge.save! + + if params.key?(:filters) + filters_result = ChargeFilters::CreateOrUpdateBatchService.call( + charge: new_charge, + filters_params: params[:filters] + ) + filters_result.raise_if_error! + end + + if charge.applied_pricing_unit + conversion_rate = params.dig(:applied_pricing_unit, :conversion_rate).presence + conversion_rate ||= charge.applied_pricing_unit.conversion_rate + + AppliedPricingUnits::CreateService.call!( + charge: new_charge, + params: { + code: charge.pricing_unit.code, + conversion_rate: + } + ) + end + + if params.key?(:tax_codes) + taxes_result = Charges::ApplyTaxesService.call(charge: new_charge, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + result.charge = new_charge + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :charge, :params + end +end diff --git a/app/services/charges/pay_in_advance/amount_details_calculator.rb b/app/services/charges/pay_in_advance/amount_details_calculator.rb new file mode 100644 index 0000000..a296b81 --- /dev/null +++ b/app/services/charges/pay_in_advance/amount_details_calculator.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Charges + module PayInAdvance + class AmountDetailsCalculator < BaseService + AMOUNT_DETAILS_FOR_SINGLE_EVENT_ENABLED = %w[percentage graduated_percentage].freeze + PERCENTAGE_CHARGE_AMOUNT_DETAILS_KEYS = %i[units free_units paid_units free_events paid_events fixed_fee_total_amount + min_max_adjustment_total_amount per_unit_total_amount].freeze + + def initialize(charge:, applied_charge_model:, applied_charge_model_excluding_event:) + @charge = charge + @all_charges_details = applied_charge_model.amount_details + @charges_details_without_last_event = applied_charge_model_excluding_event.amount_details + end + + def call + return {} unless AMOUNT_DETAILS_FOR_SINGLE_EVENT_ENABLED.include? charge.charge_model + return {} if all_charges_details.blank? || charges_details_without_last_event.blank? + + if charge.percentage? + calculate_percentage_charge_details + elsif charge.graduated_percentage? + calculate_graduated_percentage_charge_details + end + end + + private + + attr_reader :charge, :all_charges_details, :charges_details_without_last_event + + def calculate_percentage_charge_details + fixed_values = {rate: all_charges_details[:rate], fixed_fee_unit_amount: all_charges_details[:fixed_fee_unit_amount]} + details = PERCENTAGE_CHARGE_AMOUNT_DETAILS_KEYS.each_with_object(fixed_values) do |key, result| + result[key] = (BigDecimal(all_charges_details[key].to_s) - BigDecimal(charges_details_without_last_event[key].to_s)).to_s + end + # TODO: remove this when ChargeModels::PercentageService#free_units_value respects :exclude_event flag + details[:free_units] = (BigDecimal(details[:units].to_s) - BigDecimal(details[:paid_units].to_s)).to_s + details + end + + def calculate_graduated_percentage_charge_details + calculated_ranges = all_charges_details[:graduated_percentage_ranges].map do |range_with_last_event| + corresponding_range_without_last_event = charges_details_without_last_event[:graduated_percentage_ranges].find do |range| + range[:from_value] == range_with_last_event[:from_value] && range[:to_value] == range_with_last_event[:to_value] + end || Hash.new(0) + + total_with_flat_amount = range_with_last_event[:total_with_flat_amount] - corresponding_range_without_last_event[:total_with_flat_amount] + units = BigDecimal(range_with_last_event[:units].to_s) - BigDecimal(corresponding_range_without_last_event[:units].to_s) + { + from_value: range_with_last_event[:from_value], + to_value: range_with_last_event[:to_value], + flat_unit_amount: range_with_last_event[:flat_unit_amount] - corresponding_range_without_last_event[:flat_unit_amount], + rate: range_with_last_event[:rate], + units: units.to_s, + per_unit_total_amount: (units > 0) ? (total_with_flat_amount / units).round(2).to_s : "0.0", + total_with_flat_amount: total_with_flat_amount + } + end + {graduated_percentage_ranges: calculated_ranges} + end + end + end +end diff --git a/app/services/charges/pay_in_advance_aggregation_service.rb b/app/services/charges/pay_in_advance_aggregation_service.rb new file mode 100644 index 0000000..5ee4663 --- /dev/null +++ b/app/services/charges/pay_in_advance_aggregation_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Charges + class PayInAdvanceAggregationService < BaseService + def initialize(charge:, boundaries:, properties:, event:, charge_filter: nil) + @charge = charge + @boundaries = boundaries + @properties = properties + @event = event + @charge_filter = charge_filter + + super + end + + def call + aggregator = BillableMetrics::AggregationFactory.new_instance( + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: aggregation_filters + ) + + aggregator.aggregate(options: aggregation_options) + end + + private + + attr_reader :charge, :boundaries, :properties, :event, :charge_filter + + delegate :subscription, to: :event + delegate :billable_metric, to: :charge + + def aggregation_options + { + free_units_per_events: properties["free_units_per_events"].to_i, + free_units_per_total_aggregation: BigDecimal(properties["free_units_per_total_aggregation"] || 0) + } + end + + def aggregation_filters + filters = {event:, charge_id: charge.id} + + model = charge_filter.presence || charge + grouped_by_values = model.pricing_group_keys&.index_with { event.properties[it] } || {} + if charge.accepts_target_wallet && event.properties["target_wallet_code"].present? + grouped_by_values["target_wallet_code"] = event.properties["target_wallet_code"] + end + filters[:grouped_by_values] = grouped_by_values if grouped_by_values.present? + + if charge_filter.present? + result = ChargeFilters::MatchingAndIgnoredService.call(charge:, filter: charge_filter) + filters[:charge_filter] = charge_filter if charge_filter.persisted? + filters[:matching_filters] = result.matching_filters + filters[:ignored_filters] = result.ignored_filters + end + + filters + end + end +end diff --git a/app/services/charges/update_children_service.rb b/app/services/charges/update_children_service.rb new file mode 100644 index 0000000..1cdb532 --- /dev/null +++ b/app/services/charges/update_children_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Charges + class UpdateChildrenService < BaseService + Result = BaseResult[:charge] + + def initialize(charge:, params:, old_parent_attrs:, old_parent_filters_attrs:, old_parent_applied_pricing_unit_attrs:, child_ids:) + @charge = charge + @params = params + @parent_filters = old_parent_filters_attrs + @old_parent = Charge.new(old_parent_attrs) + @child_ids = child_ids + + if old_parent_applied_pricing_unit_attrs.present? + @old_parent.build_applied_pricing_unit(old_parent_applied_pricing_unit_attrs) + end + + super + end + + def call + return result unless charge + + # Acquire an advisory lock on the parent charge to prevent concurrent + # cascades from overlapping (e.g. parent updated twice in quick succession). + # timeout_seconds: 0 fails immediately if another cascade is running; + # the job's retry_on will pick it up later. + Charge.with_advisory_lock!("update_children_charge_#{charge.id}", timeout_seconds: 0) do + # skip touching to avoid deadlocks and redundant cascading updates + Charge.no_touching do + Plan.no_touching do + charge.children.where(id: child_ids).find_each do |child_charge| + Charges::UpdateService.call!( + charge: child_charge, + params:, + cascade_options: { + cascade: true, + parent_filters:, + equal_properties: old_parent.equal_properties?(child_charge), + equal_applied_pricing_unit_rate: old_parent.equal_applied_pricing_unit_rate?(child_charge) + } + ) + end + end + end + end + + result.charge = charge + result + end + + private + + attr_reader :charge, :params, :old_parent, :parent_filters, :child_ids + end +end diff --git a/app/services/charges/update_service.rb b/app/services/charges/update_service.rb new file mode 100644 index 0000000..8d2aef6 --- /dev/null +++ b/app/services/charges/update_service.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +module Charges + class UpdateService < BaseService + include CascadeUpdatable + + def initialize(charge:, params:, cascade_options: {}, cascade_updates: false) + @charge = charge + @params = params.to_h.deep_symbolize_keys + @cascade_options = cascade_options + @cascade = cascade_options[:cascade] + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "charge") unless charge + return result if cascade && charge.charge_model != params[:charge_model] + + old_filters_attrs = capture_old_filters_attrs + old_parent_attrs = charge.attributes.deep_dup + old_applied_pricing_unit_attrs = charge.applied_pricing_unit&.attributes&.deep_dup + + ActiveRecord::Base.transaction do + charge.charge_model = params[:charge_model] unless plan.attached_to_subscriptions? + charge.invoice_display_name = params[:invoice_display_name] unless cascade + charge.code = params[:code] if cascade && params[:code].present? + + # Make sure that pricing group keys and presentation group keys are cascaded even if properties are overridden + if cascade + cascade_pricing_group_keys + cascade_presentation_group_keys + end + + if !cascade || cascade_options[:equal_properties] + properties = params.delete(:properties).presence || ChargeModels::BuildDefaultPropertiesService.call( + params[:charge_model] + ) + charge.properties = ChargeModels::FilterPropertiesService.call(chargeable: charge, properties:).properties + end + + accepts_target_wallet = params.delete(:accepts_target_wallet) + if plan.organization.events_targeting_wallets_enabled? + charge.accepts_target_wallet = accepts_target_wallet unless accepts_target_wallet.nil? + end + + charge.save! + + AppliedPricingUnits::UpdateService.call!( + charge:, + cascade_options:, + params: params.delete(:applied_pricing_unit).presence + ) + + filters = params.delete(:filters) + unless filters.nil? + ChargeFilters::CreateOrUpdateBatchService.call( + charge:, + filters_params: filters.map(&:with_indifferent_access), + cascade_options: + ).raise_if_error! + end + + result.charge = charge + + # In cascade mode it is allowed only to change properties + unless cascade + tax_codes = params.delete(:tax_codes) + if tax_codes + taxes_result = Charges::ApplyTaxesService.call(charge:, tax_codes:) + taxes_result.raise_if_error! + end + + # NOTE: charges cannot be edited if plan is attached to a subscription + unless plan.attached_to_subscriptions? + invoiceable = params.delete(:invoiceable) + min_amount_cents = params.delete(:min_amount_cents) + code = params.delete(:code) + + charge.invoiceable = invoiceable if License.premium? && !invoiceable.nil? + charge.min_amount_cents = min_amount_cents || 0 if License.premium? + charge.code = code if code.present? + + charge.update!(params) + end + end + end + + trigger_cascade(old_filters_attrs, old_parent_attrs:, old_applied_pricing_unit_attrs:) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.single_validation_failure!(field: :code, error_code: "value_already_exist") + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :charge, :params, :cascade_options, :cascade, :cascade_updates + + delegate :plan, to: :charge + + def cascade_presentation_group_keys + presentation_group_keys = params.dig(:properties, :presentation_group_keys) + + if presentation_group_keys + charge.properties["presentation_group_keys"] = presentation_group_keys + elsif charge.properties["presentation_group_keys"].present? + charge.properties.delete("presentation_group_keys") + end + end + + def cascade_pricing_group_keys + pricing_group_keys = params.dig(:properties, :pricing_group_keys) || params.dig(:properties, :grouped_by) + + if pricing_group_keys + charge.properties["pricing_group_keys"] = pricing_group_keys + charge.properties.delete("grouped_by") + elsif charge.pricing_group_keys.present? + charge.properties.delete("pricing_group_keys") + charge.properties.delete("grouped_by") + end + end + end +end diff --git a/app/services/charges/validators/base_service.rb b/app/services/charges/validators/base_service.rb new file mode 100644 index 0000000..9127884 --- /dev/null +++ b/app/services/charges/validators/base_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Charges + module Validators + class BaseService < BaseValidator + ALLOWED_PRESENTATION_GROUP_KEYS_OPTIONS_KEYS = %i[display_in_invoice].freeze + ALLOWED_PRESENTATION_GROUP_KEYS_KEYS = %i[value options].freeze + + def initialize(charge:, properties: nil) + @charge = charge + @properties = properties || charge.properties + @result = ::BaseService::Result.new + + super(result) + end + + def valid? + # NOTE: override and add validation rules + + validate_pricing_group_keys + validate_presentation_group_keys + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + attr_reader :result, :properties + + private + + attr_reader :charge + + def pricing_group_keys + @pricing_group_keys ||= properties[grouped_key] + end + + # NOTE: keep accepting grouped_by until the end of the deprecation period + def grouped_key + return "pricing_group_keys" unless properties["pricing_group_keys"].nil? + + "grouped_by" + end + + def validate_pricing_group_keys + return if pricing_group_keys.nil? || pricing_group_keys.is_a?(Array) && pricing_group_keys.blank? + + if pricing_group_keys.is_a?(Array) + return if pricing_group_keys.all? { it.is_a?(String) } && pricing_group_keys.all?(&:present?) + end + + add_error(field: grouped_key, error_code: "invalid_type") + end + + def validate_presentation_group_keys + raw_keys = properties["presentation_group_keys"] + return if raw_keys.blank? + + valid_presentation_group_keys = raw_keys.is_a?(Array) && raw_keys.all? do |key| + next false unless key.is_a?(Hash) + + key = key.deep_symbolize_keys + keys_valid = (key.keys - ALLOWED_PRESENTATION_GROUP_KEYS_KEYS).empty? + value_key_present = key.key?(:value) + + value_valid = key[:value].is_a?(String) && key[:value].present? + + options_key_valid = true + + if key.key?(:options) + options = key[:options] + + options_key_valid = if options.is_a?(Hash) + options.keys == ALLOWED_PRESENTATION_GROUP_KEYS_OPTIONS_KEYS && [true, false].include?(options[:display_in_invoice]) + else + false + end + end + + keys_valid && value_key_present && value_valid && options_key_valid + end + + unless valid_presentation_group_keys + add_error( + field: "presentation_group_keys", + error_code: "invalid_type" + ) + end + + if raw_keys.size > 2 + add_error(field: "presentation_group_keys", error_code: "too_many_keys") + end + end + end + end +end diff --git a/app/services/charges/validators/graduated_percentage_service.rb b/app/services/charges/validators/graduated_percentage_service.rb new file mode 100644 index 0000000..0554185 --- /dev/null +++ b/app/services/charges/validators/graduated_percentage_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Charges + module Validators + class GraduatedPercentageService < Charges::Validators::BaseService + include ::Validators::RangeBoundsValidator + + def valid? + validate_billable_metric + + if ranges.blank? + add_error(field: :graduated_percentage_ranges, error_code: "missing_graduated_percentage_ranges") + else + next_from_value = 0 + + ranges.each_with_index do |range, index| + validate_rate_and_amounts(range) + + unless valid_bounds?(range, index, next_from_value) + add_error(field: :graduated_percentage_ranges, error_code: "invalid_graduated_percentage_ranges") + end + + next_from_value = range[:to_value] || 0 + end + end + + super + end + + private + + def validate_billable_metric + return unless charge.billable_metric.latest_agg? + + add_error(field: :billable_metric, error_code: "invalid_value") + end + + def ranges + properties["graduated_percentage_ranges"].map(&:with_indifferent_access) + end + + def validate_rate_and_amounts(range) + unless ::Validators::DecimalAmountService.valid_amount?(range[:flat_amount]) + add_error(field: :flat_amount, error_code: "invalid_amount") + end + + return if ::Validators::DecimalAmountService.valid_amount?(range[:rate]) + + add_error(field: :rate, error_code: "invalid_rate") + end + end + end +end diff --git a/app/services/charges/validators/graduated_service.rb b/app/services/charges/validators/graduated_service.rb new file mode 100644 index 0000000..52a74eb --- /dev/null +++ b/app/services/charges/validators/graduated_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Charges + module Validators + class GraduatedService < Charges::Validators::BaseService + include ::Validators::RangeBoundsValidator + + def valid? + if ranges.blank? + add_error(field: :graduated_ranges, error_code: "missing_graduated_ranges") + else + next_from_value = 0 + ranges.each_with_index do |range, index| + validate_amounts(range) + + unless valid_bounds?(range, index, next_from_value) + add_error(field: :graduated_ranges, error_code: "invalid_graduated_ranges") + end + + next_from_value = range[:to_value] || 0 + end + end + + super + end + + private + + def ranges + (properties["graduated_ranges"] || []).map(&:with_indifferent_access) + end + + def validate_amounts(range) + unless ::Validators::DecimalAmountService.new(range[:per_unit_amount]).valid_amount? + add_error(field: :per_unit_amount, error_code: "invalid_amount") + end + + return if ::Validators::DecimalAmountService.new(range[:flat_amount]).valid_amount? + + add_error(field: :flat_amount, error_code: "invalid_amount") + end + end + end +end diff --git a/app/services/charges/validators/package_service.rb b/app/services/charges/validators/package_service.rb new file mode 100644 index 0000000..99a31e9 --- /dev/null +++ b/app/services/charges/validators/package_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Charges + module Validators + class PackageService < Charges::Validators::BaseService + def valid? + validate_amount + validate_free_units + validate_package_size + + super + end + + private + + def amount + properties["amount"] + end + + def validate_amount + return if ::Validators::DecimalAmountService.new(amount).valid_amount? + + add_error(field: :amount, error_code: "invalid_amount") + end + + def package_size + properties["package_size"] + end + + def validate_package_size + return if package_size.present? && package_size.is_a?(Integer) && package_size.positive? + + add_error(field: :package_size, error_code: "invalid_package_size") + end + + def free_units + properties["free_units"] + end + + def validate_free_units + return if free_units.present? && free_units.is_a?(Integer) && free_units >= 0 + + add_error(field: :free_units, error_code: "invalid_free_units") + end + end + end +end diff --git a/app/services/charges/validators/percentage_service.rb b/app/services/charges/validators/percentage_service.rb new file mode 100644 index 0000000..2d16ab1 --- /dev/null +++ b/app/services/charges/validators/percentage_service.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module Charges + module Validators + class PercentageService < Charges::Validators::BaseService + def valid? + validate_billable_metric + validate_rate + validate_fixed_amount + validate_free_units_per_events + validate_free_units_per_total_aggregation + validate_per_transaction_min_max + + super + end + + private + + def rate + properties["rate"] + end + + def validate_billable_metric + return unless charge.billable_metric.latest_agg? + + add_error(field: :billable_metric, error_code: "invalid_value") + end + + def validate_rate + return if ::Validators::DecimalAmountService.new(rate).valid_amount? + + add_error(field: :rate, error_code: "invalid_rate") + end + + def fixed_amount + properties["fixed_amount"] + end + + def validate_fixed_amount + return if fixed_amount.nil? + return if ::Validators::DecimalAmountService.new(fixed_amount).valid_amount? + + add_error(field: :fixed_amount, error_code: "invalid_fixed_amount") + end + + def free_units_per_events + properties["free_units_per_events"] + end + + def validate_free_units_per_events + return if free_units_per_events.nil? + return if free_units_per_events.is_a?(Integer) && free_units_per_events.positive? + + add_error(field: :free_units_per_events, error_code: "invalid_free_units_per_events") + end + + def free_units_per_total_aggregation + properties["free_units_per_total_aggregation"] + end + + def validate_free_units_per_total_aggregation + return if free_units_per_total_aggregation.nil? + return if ::Validators::DecimalAmountService.new(free_units_per_total_aggregation).valid_amount? + + add_error(field: :free_units_per_total_aggregation, error_code: "invalid_free_units_per_total_aggregation") + end + + def validate_per_transaction_min_max + return unless License.premium? + + if properties["per_transaction_min_amount"].present? && + !::Validators::DecimalAmountService.valid_amount?(properties["per_transaction_min_amount"]) + add_error(field: :per_transaction_min_amount, error_code: "invalid_amount") + end + + if properties["per_transaction_max_amount"].present? && + !::Validators::DecimalAmountService.valid_amount?(properties["per_transaction_max_amount"]) + add_error(field: :per_transaction_max_amount, error_code: "invalid_amount") + end + + return if properties["per_transaction_min_amount"].nil? || properties["per_transaction_max_amount"].nil? + return if BigDecimal(properties["per_transaction_min_amount"]) <= + BigDecimal(properties["per_transaction_max_amount"]) + + add_error(field: :per_transaction_max_amount, error_code: "per_transaction_max_lower_than_per_transaction_min") + end + end + end +end diff --git a/app/services/charges/validators/standard_service.rb b/app/services/charges/validators/standard_service.rb new file mode 100644 index 0000000..4bc4889 --- /dev/null +++ b/app/services/charges/validators/standard_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Charges + module Validators + class StandardService < Charges::Validators::BaseService + def valid? + validate_amount + + super + end + + private + + def amount + properties["amount"] + end + + def validate_amount + return if ::Validators::DecimalAmountService.new(amount).valid_amount? + + add_error(field: :amount, error_code: "invalid_amount") + end + end + end +end diff --git a/app/services/charges/validators/volume_service.rb b/app/services/charges/validators/volume_service.rb new file mode 100644 index 0000000..f984ff9 --- /dev/null +++ b/app/services/charges/validators/volume_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Charges + module Validators + class VolumeService < Charges::Validators::BaseService + include ::Validators::RangeBoundsValidator + + def valid? + if ranges.blank? + add_error(field: :volume_ranges, error_code: "missing_volume_ranges") + else + next_from_value = 0 + ranges.each_with_index do |range, index| + validate_amounts(range) + unless valid_bounds?(range, index, next_from_value) + add_error(field: :volume_ranges, error_code: "invalid_volume_ranges") + end + + next_from_value = (range[:to_value] || 0) + 1 + end + end + + super + end + + private + + def ranges + properties["volume_ranges"]&.map(&:with_indifferent_access) + end + + def validate_amounts(range) + unless ::Validators::DecimalAmountService.new(range[:per_unit_amount]).valid_amount? + add_error(field: :per_unit_amount, error_code: "invalid_amount") + end + + return if ::Validators::DecimalAmountService.new(range[:flat_amount]).valid_amount? + + add_error(field: :flat_amount, error_code: "invalid_amount") + end + end + end +end diff --git a/app/services/commitments/apply_taxes_service.rb b/app/services/commitments/apply_taxes_service.rb new file mode 100644 index 0000000..769a852 --- /dev/null +++ b/app/services/commitments/apply_taxes_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Commitments + class ApplyTaxesService < BaseService + def initialize(commitment:, tax_codes:) + @commitment = commitment + @tax_codes = tax_codes + + super + end + + def call + return result.not_found_failure!(resource: "commitment") unless commitment + return result.not_found_failure!(resource: "tax") if (tax_codes - taxes.pluck(:code)).present? + + commitment.applied_taxes.where( + tax_id: commitment.taxes.where.not(code: tax_codes).pluck(:id) + ).destroy_all + + result.applied_taxes = tax_codes.map do |tax_code| + commitment.applied_taxes + .create_with(organization_id: commitment.plan.organization_id) + .find_or_create_by!(tax: taxes.find_by(code: tax_code)) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :commitment, :tax_codes + + def taxes + @taxes ||= commitment.plan.organization.taxes.where(code: tax_codes) + end + end +end diff --git a/app/services/commitments/calculate_amount_service.rb b/app/services/commitments/calculate_amount_service.rb new file mode 100644 index 0000000..bc9d6fd --- /dev/null +++ b/app/services/commitments/calculate_amount_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Commitments + class CalculateAmountService < BaseService + def initialize(commitment:, invoice_subscription:) + @commitment = commitment + @invoice_subscription = invoice_subscription + + super + end + + def call + result.commitment_amount_cents = commitment_amount_cents + result + end + + private + + attr_reader :commitment, :invoice_subscription + + delegate :subscription, to: :invoice_subscription + + def commitment_amount_cents + return 0 if !commitment || !invoice_subscription || commitment.amount_cents.zero? + + service_result = proration_coefficient_service.proration_coefficient + + Money.from_cents( + commitment.amount_cents * service_result.proration_coefficient, + commitment.plan.amount_currency + ).cents + end + + def proration_coefficient_service + @proration_coefficient_service ||= if subscription.plan.pay_in_advance? + is = subscription.terminated? ? invoice_subscription : invoice_subscription.previous_invoice_subscription + + Commitments::CalculateProratedCoefficientService.new(commitment:, invoice_subscription: is) + else + Commitments::CalculateProratedCoefficientService.new(commitment:, invoice_subscription:) + end + end + end +end diff --git a/app/services/commitments/calculate_prorated_coefficient_service.rb b/app/services/commitments/calculate_prorated_coefficient_service.rb new file mode 100644 index 0000000..66448d1 --- /dev/null +++ b/app/services/commitments/calculate_prorated_coefficient_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Commitments + class CalculateProratedCoefficientService < BaseService + def initialize(commitment:, invoice_subscription:) + @commitment = commitment + @invoice_subscription = invoice_subscription + + super + end + + def proration_coefficient + result.proration_coefficient = calculate_proration_coefficient + result + end + + def dates_service + @dates_service ||= Commitments::DatesService.new_instance(commitment:, invoice_subscription:).call + end + + private + + attr_reader :commitment, :invoice_subscription + + delegate :subscription, to: :invoice_subscription + + def calculate_proration_coefficient + invoices_service = Commitments::FetchInvoicesService.new_instance(commitment:, invoice_subscription:) + invoices_result = invoices_service.call + + all_invoice_subscriptions = subscription + .invoice_subscriptions + .where(invoice_id: invoices_result.invoices.ids) + .where("from_datetime >= ?", dates_service.previous_beginning_of_period) + .order( + Arel.sql( + ActiveRecord::Base.sanitize_sql_for_conditions( + "COALESCE(invoice_subscriptions.to_datetime, invoice_subscriptions.timestamp) ASC" + ) + ) + ) + + days = Utils::Datetime.date_diff_with_timezone( + all_invoice_subscriptions.first.from_datetime, + subscription.terminated? ? subscription.terminated_at : invoice_subscription.to_datetime, + subscription.customer.applicable_timezone + ) + + days_total = Utils::Datetime.date_diff_with_timezone( + dates_service.previous_beginning_of_period, + dates_service.end_of_period, + subscription.customer.applicable_timezone + ) + + days / days_total.to_f + end + end +end diff --git a/app/services/commitments/dates_service.rb b/app/services/commitments/dates_service.rb new file mode 100644 index 0000000..12bdbd1 --- /dev/null +++ b/app/services/commitments/dates_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Commitments + class DatesService < BaseService + def self.new_instance(commitment:, invoice_subscription:) + klass = if invoice_subscription.subscription.plan.pay_in_advance? + Commitments::Minimum::InAdvance::DatesService + else + Commitments::Minimum::InArrears::DatesService + end + + klass.new(commitment:, invoice_subscription:) + end + + def initialize(commitment:, invoice_subscription:) + @commitment = commitment + @invoice_subscription = invoice_subscription + + super + end + + def call + ds = Subscriptions::DatesService.new_instance( + invoice_subscription.subscription, + invoice_subscription.timestamp, + current_usage: + ) + + return ds unless invoice_subscription.subscription.terminated? + + Subscriptions::TerminatedDatesService.new( + subscription: invoice_subscription.subscription, + invoice: invoice_subscription.invoice, + date_service: ds, + match_invoice_subscription: invoice_subscription.subscription.plan.pay_in_advance? + ).call + end + + private + + attr_reader :commitment, :invoice_subscription + end +end diff --git a/app/services/commitments/fetch_invoices_service.rb b/app/services/commitments/fetch_invoices_service.rb new file mode 100644 index 0000000..94fe6bd --- /dev/null +++ b/app/services/commitments/fetch_invoices_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Commitments + class FetchInvoicesService < BaseService + def self.new_instance(commitment:, invoice_subscription:) + klass = if invoice_subscription.subscription.plan.pay_in_advance? + Commitments::Minimum::InAdvance::FetchInvoicesService + else + Commitments::Minimum::InArrears::FetchInvoicesService + end + + klass.new(commitment:, invoice_subscription:) + end + + def initialize(commitment:, invoice_subscription:) + @commitment = commitment + @invoice_subscription = invoice_subscription + + super + end + + def call + result.invoices = fetch_invoices + result + end + + private + + attr_reader :commitment, :invoice_subscription + + delegate :subscription, to: :invoice_subscription + delegate :plan, to: :subscription + end +end diff --git a/app/services/commitments/minimum/calculate_true_up_fee_service.rb b/app/services/commitments/minimum/calculate_true_up_fee_service.rb new file mode 100644 index 0000000..2d2e344 --- /dev/null +++ b/app/services/commitments/minimum/calculate_true_up_fee_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Commitments + module Minimum + class CalculateTrueUpFeeService < BaseService + def self.new_instance(invoice_subscription:) + klass = if invoice_subscription.subscription.plan.pay_in_advance? + Commitments::Minimum::InAdvance::CalculateTrueUpFeeService + else + Commitments::Minimum::InArrears::CalculateTrueUpFeeService + end + + klass.new(invoice_subscription:) + end + + def initialize(invoice_subscription:) + @invoice_subscription = invoice_subscription + @minimum_commitment = invoice_subscription.subscription.plan.minimum_commitment + + super + end + + def call + result.amount_cents = amount_cents + result.precise_amount_cents = precise_amount_cents + result + end + + private + + attr_reader :minimum_commitment, :invoice_subscription + + delegate :subscription, to: :invoice_subscription + + def amount_cents + return 0 if !minimum_commitment || fees_total_amount_cents >= commitment_amount_cents + + commitment_amount_cents - fees_total_amount_cents + end + + def precise_amount_cents + return 0.to_d if !minimum_commitment || fees_total_precise_amount_cents >= commitment_amount_cents + + commitment_amount_cents - fees_total_precise_amount_cents + end + + def fees_total_amount_cents + subscription_fees.sum(:amount_cents) + + charge_fees.sum(:amount_cents) + + charge_in_advance_fees.sum(:amount_cents) + + charge_in_advance_recurring_fees.sum(:amount_cents) + + fixed_charge_fees.sum(:amount_cents) + + fixed_charge_in_advance_fees.sum(:amount_cents) + end + + def fees_total_precise_amount_cents + subscription_fees.sum(:precise_amount_cents) + + charge_fees.sum(:precise_amount_cents) + + charge_in_advance_fees.sum(:precise_amount_cents) + + charge_in_advance_recurring_fees.sum(:precise_amount_cents) + + fixed_charge_fees.sum(:precise_amount_cents) + + fixed_charge_in_advance_fees.sum(:precise_amount_cents) + end + + def commitment_amount_cents + result = Commitments::CalculateAmountService.call( + commitment: minimum_commitment, + invoice_subscription: + ) + + result.commitment_amount_cents + end + end + end +end diff --git a/app/services/commitments/minimum/in_advance/calculate_true_up_fee_service.rb b/app/services/commitments/minimum/in_advance/calculate_true_up_fee_service.rb new file mode 100644 index 0000000..ce480a6 --- /dev/null +++ b/app/services/commitments/minimum/in_advance/calculate_true_up_fee_service.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +module Commitments + module Minimum + module InAdvance + class CalculateTrueUpFeeService < Commitments::Minimum::CalculateTrueUpFeeService + private + + def amount_cents + return 0 unless invoice_subscription.previous_invoice_subscription + + super + end + + def precise_amount_cents + return 0.to_d unless invoice_subscription.previous_invoice_subscription + + super + end + + def subscription_fees + Fee + .subscription + .joins(subscription: :plan) + .where( + "(fees.properties->>'from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + .where( + subscription_id: subscription.id, + plan: {pay_in_advance: true} + ) + end + + def charge_fees + Fee + .charge + .joins(:charge) + .where( + subscription_id: subscription.id, + invoice_id: invoices_ids, + charge: {pay_in_advance: false} + ) + end + + def charge_in_advance_fees + Fee + .charge + .joins(:charge) + .where( + subscription_id: subscription.id, + charge: {pay_in_advance: true}, + pay_in_advance: true + ) + .where( + "(fees.properties->>'charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'charges_to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + end + + def charge_in_advance_recurring_fees + return Fee.none unless invoice_subscription.previous_invoice_subscription + + Fee + .charge + .joins(:charge) + .joins(charge: :billable_metric) + .where(billable_metric: {recurring: true}) + .where( + subscription_id: subscription.id, + charge: {pay_in_advance: true}, + pay_in_advance: false + ) + .where( + "(fees.properties->>'from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + end + + def fixed_charge_fees + Fee + .fixed_charge + .joins(:fixed_charge) + .where( + subscription_id: subscription.id, + invoice_id: invoices_ids, + fixed_charge: {pay_in_advance: false} + ) + end + + def fixed_charge_in_advance_fees + Fee + .fixed_charge + .joins(:fixed_charge) + .where( + subscription_id: subscription.id, + fixed_charge: {pay_in_advance: true}, + pay_in_advance: true + ) + .where( + "(fees.properties->>'fixed_charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'fixed_charges_to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + end + + def dates_service + @dates_service ||= Commitments::DatesService.new_instance( + commitment: minimum_commitment, + invoice_subscription: invoice_subscription.previous_invoice_subscription + ).call + end + + def invoices_ids + @invoices_ids ||= FetchInvoicesService.call( + commitment: minimum_commitment, + invoice_subscription: + ).invoices.ids + end + end + end + end +end diff --git a/app/services/commitments/minimum/in_advance/dates_service.rb b/app/services/commitments/minimum/in_advance/dates_service.rb new file mode 100644 index 0000000..73bcf35 --- /dev/null +++ b/app/services/commitments/minimum/in_advance/dates_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Commitments + module Minimum + module InAdvance + class DatesService < Commitments::DatesService + def current_usage + true + end + end + end + end +end diff --git a/app/services/commitments/minimum/in_advance/fetch_invoices_service.rb b/app/services/commitments/minimum/in_advance/fetch_invoices_service.rb new file mode 100644 index 0000000..81989ad --- /dev/null +++ b/app/services/commitments/minimum/in_advance/fetch_invoices_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Commitments + module Minimum + module InAdvance + class FetchInvoicesService < Commitments::FetchInvoicesService + private + + def dates_service + ds = Subscriptions::DatesService.new_instance( + subscription, + invoice_subscription.timestamp, + current_usage: true + ) + + return ds unless subscription.terminated? + + Subscriptions::TerminatedDatesService.new( + subscription:, + invoice: invoice_subscription.invoice, + date_service: ds + ).call + end + + def fetch_invoices + return Invoice.where(id: invoice_subscription.invoice_id) unless previous_invoice_subscription + + date_service = Subscriptions::DatesService.new_instance( + subscription, + previous_invoice_subscription.timestamp, + current_usage: true + ) + + invoice_ids_query = fetch_invoice_ids_for_charges(date_service:) + fetch_invoice_ids_for_fixed_charges(date_service:) + Invoice.where(id: invoice_ids_query) + end + + def previous_invoice_subscription + invoice_subscription.previous_invoice_subscription + end + + def fetch_invoice_ids_for_charges(date_service:) + # If charges are NOT billed monthly, fees are on the current invoice (billed in arrears) + return [invoice_subscription.invoice_id] unless plan.charges_billed_in_monthly_split_intervals? + + subscription + .invoice_subscriptions + .where( + "(charges_from_datetime >= ? AND charges_to_datetime <= ?)", + date_service.previous_beginning_of_period, + date_service.end_of_period + ).pluck(:invoice_id) + end + + def fetch_invoice_ids_for_fixed_charges(date_service:) + # If fixed_charges are NOT billed monthly, fees are on the current invoice (billed in arrears) + return [invoice_subscription.invoice_id] unless plan.fixed_charges_billed_in_monthly_split_intervals? + + subscription + .invoice_subscriptions + .where( + "(fixed_charges_from_datetime >= ? AND fixed_charges_to_datetime <= ?)", + date_service.previous_beginning_of_period, + date_service.end_of_period + ).pluck(:invoice_id) + end + end + end + end +end diff --git a/app/services/commitments/minimum/in_arrears/calculate_true_up_fee_service.rb b/app/services/commitments/minimum/in_arrears/calculate_true_up_fee_service.rb new file mode 100644 index 0000000..93d63f4 --- /dev/null +++ b/app/services/commitments/minimum/in_arrears/calculate_true_up_fee_service.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +module Commitments + module Minimum + module InArrears + class CalculateTrueUpFeeService < Commitments::Minimum::CalculateTrueUpFeeService + private + + def subscription_fees + invoices_result = FetchInvoicesService.call(commitment: minimum_commitment, invoice_subscription:) + + Fee + .subscription + .joins(subscription: :plan) + .where( + subscription_id: subscription.id, + invoice_id: invoices_result.invoices.ids, + plan: {pay_in_advance: false} + ) + end + + def charge_fees + Fee + .charge + .joins(:charge) + .where( + subscription_id: subscription.id, + charge: {pay_in_advance: false} + ) + .where( + "(fees.properties->>'charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'charges_to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + end + + def charge_in_advance_fees + Fee + .charge + .joins(:charge) + .where( + subscription_id: subscription.id, + charge: {pay_in_advance: true}, + pay_in_advance: true + ) + .where( + "(fees.properties->>'charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'charges_to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + end + + def charge_in_advance_recurring_fees + if !invoice_subscription.previous_invoice_subscription && !subscription.plan.charges_billed_in_monthly_split_intervals? + return Fee.none + end + + is = if subscription.plan.charges_billed_in_monthly_split_intervals? + invoice_subscription + else + invoice_subscription.previous_invoice_subscription + end + + dates_service = Commitments::Minimum::InArrears::DatesService.new( + commitment: minimum_commitment, + invoice_subscription: is + ).call + + scope = Fee + .charge + .joins(:charge) + .joins(charge: :billable_metric) + .where(billable_metric: {recurring: true}) + .where( + subscription_id: subscription.id, + charge: {pay_in_advance: true}, + pay_in_advance: false + ) + .where( + "(fees.properties->>'charges_to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + + # rubocop:disable Style/ConditionalAssignment + if subscription.plan.charges_billed_in_monthly_split_intervals? + scope = scope + .where( + "(fees.properties->>'charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period - 1.month + ) + .where.not(invoice_id: invoice_subscription.invoice_id) + else + scope = scope.where( + "(fees.properties->>'charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + end + # rubocop:enable Style/ConditionalAssignment + + scope + end + + def fixed_charge_fees + Fee + .fixed_charge + .joins(:fixed_charge) + .where( + subscription_id: subscription.id, + fixed_charge: {pay_in_advance: false} + ) + .where( + "(fees.properties->>'fixed_charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'fixed_charges_to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + end + + def fixed_charge_in_advance_fees + Fee + .fixed_charge + .joins(:fixed_charge) + .where( + subscription_id: subscription.id, + fixed_charge: {pay_in_advance: true}, + pay_in_advance: true + ) + .where( + "(fees.properties->>'fixed_charges_from_datetime') >= ?", + dates_service.previous_beginning_of_period + ) + .where( + "(fees.properties->>'fixed_charges_to_datetime') <= ?", + dates_service.end_of_period&.iso8601(3) + ) + end + + def dates_service + @dates_service ||= Commitments::DatesService.new_instance( + commitment: minimum_commitment, + invoice_subscription: + ).call + end + end + end + end +end diff --git a/app/services/commitments/minimum/in_arrears/dates_service.rb b/app/services/commitments/minimum/in_arrears/dates_service.rb new file mode 100644 index 0000000..f5841a2 --- /dev/null +++ b/app/services/commitments/minimum/in_arrears/dates_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Commitments + module Minimum + module InArrears + class DatesService < Commitments::DatesService + def current_usage + invoice_subscription.subscription.terminated? + end + end + end + end +end diff --git a/app/services/commitments/minimum/in_arrears/fetch_invoices_service.rb b/app/services/commitments/minimum/in_arrears/fetch_invoices_service.rb new file mode 100644 index 0000000..559d46b --- /dev/null +++ b/app/services/commitments/minimum/in_arrears/fetch_invoices_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Commitments + module Minimum + module InArrears + class FetchInvoicesService < Commitments::FetchInvoicesService + private + + def dates_service + ds = Subscriptions::DatesService.new_instance( + subscription, + invoice_subscription.timestamp, + current_usage: subscription.terminated? + ) + + return ds unless subscription.terminated? + + Subscriptions::TerminatedDatesService.new( + subscription:, + invoice: invoice_subscription.invoice, + date_service: ds + ).call + end + + def fetch_invoices + unless plan.charges_or_fixed_charges_billed_in_monthly_split_intervals? + return Invoice.where(id: invoice_subscription.invoice_id) + end + + invoice_ids_query = subscription + .invoice_subscriptions + .where( + "from_datetime >= ? AND to_datetime <= ?", + dates_service.previous_beginning_of_period, + dates_service.end_of_period + ).select(:invoice_id) + + Invoice.where(id: invoice_ids_query) + end + end + end + end +end diff --git a/app/services/commitments/override_service.rb b/app/services/commitments/override_service.rb new file mode 100644 index 0000000..600f720 --- /dev/null +++ b/app/services/commitments/override_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Commitments + class OverrideService < BaseService + def initialize(commitment:, params:) + @commitment = commitment + @params = params + + super + end + + def call + return result if !License.premium? || !commitment + + ActiveRecord::Base.transaction do + new_commitment = commitment.dup.tap do |c| + c.amount_cents = params[:amount_cents] if params.key?(:amount_cents) + c.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + c.plan_id = params[:plan_id] + end + + new_commitment.save! + + if params.key?(:tax_codes) + taxes_result = Commitments::ApplyTaxesService.call(commitment: new_commitment, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + result.commitment = new_commitment + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :commitment, :params + end +end diff --git a/app/services/coupons/create_service.rb b/app/services/coupons/create_service.rb new file mode 100644 index 0000000..eb85556 --- /dev/null +++ b/app/services/coupons/create_service.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module Coupons + class CreateService < BaseService + def initialize(args) + @args = args + super + end + + activity_loggable( + action: "coupon.created", + record: -> { result.coupon } + ) + + def call + return result unless valid?(args) + + @limitations = args[:applies_to]&.to_h&.deep_symbolize_keys || {} + @organization_id = args[:organization_id] + + reusable = args.key?(:reusable) ? args[:reusable] : true + + coupon = Coupon.new( + organization_id:, + name: args[:name], + code: args[:code], + description: args[:description], + coupon_type: args[:coupon_type], + amount_cents: args[:amount_cents], + amount_currency: args[:amount_currency], + percentage_rate: args[:percentage_rate], + frequency: args[:frequency], + frequency_duration: args[:frequency_duration], + expiration: args[:expiration]&.to_sym, + expiration_at: args[:expiration_at], + limited_plans: plan_identifiers.present?, + limited_billable_metrics: billable_metric_identifiers.present?, + reusable: + ) + + if plan_identifiers.present? && (plan_identifiers - plans.pluck(plan_key)).present? + return result.not_found_failure!(resource: "plans") + end + + if billable_metric_identifiers.present? && billable_metrics.count != billable_metric_identifiers.count + return result.not_found_failure!(resource: "billable_metrics") + end + + if billable_metrics.present? && plans.present? + return result.not_allowed_failure!(code: "only_one_limitation_type_per_coupon_allowed") + end + + ActiveRecord::Base.transaction do + coupon.save! + + plans.each { |plan| CouponTarget.create!(coupon:, plan:, organization_id:) } if plan_identifiers.present? + + if billable_metric_identifiers.present? + billable_metrics.each { |bm| CouponTarget.create!(coupon:, billable_metric: bm, organization_id:) } + end + end + + result.coupon = coupon + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :args, :limitations, :organization_id + + def plan_identifiers + key = api_context? ? :plan_codes : :plan_ids + limitations[key]&.compact&.uniq + end + + def plan_key + api_context? ? :code : :id + end + + def plans + return @plans if defined? @plans + return [] if plan_identifiers.blank? + + @plans = Plan.where(plan_key => plan_identifiers, :organization_id => organization_id) + end + + def billable_metric_identifiers + key = api_context? ? :billable_metric_codes : :billable_metric_ids + limitations[key]&.compact&.uniq + end + + def billable_metrics + return @billable_metrics if defined? @billable_metrics + return [] if billable_metric_identifiers.blank? + + @billable_metrics = if api_context? + BillableMetric.where(code: billable_metric_identifiers, organization_id:) + else + BillableMetric.where(id: billable_metric_identifiers, organization_id:) + end + end + + def valid?(args) + Coupons::ValidateService.new(result, **args).valid? + end + end +end diff --git a/app/services/coupons/destroy_service.rb b/app/services/coupons/destroy_service.rb new file mode 100644 index 0000000..9a4f81b --- /dev/null +++ b/app/services/coupons/destroy_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Coupons + class DestroyService < BaseService + def initialize(coupon:) + @coupon = coupon + super + end + + activity_loggable( + action: "coupon.deleted", + record: -> { coupon } + ) + + def call + return result.not_found_failure!(resource: "coupon") unless coupon + + ActiveRecord::Base.transaction do + coupon.discard! + coupon.coupon_targets.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + + coupon.applied_coupons.active.find_each do |applied_coupon| + AppliedCoupons::TerminateService.call(applied_coupon:) + end + end + + result.coupon = coupon + result + end + + private + + attr_reader :coupon + end +end diff --git a/app/services/coupons/preview_service.rb b/app/services/coupons/preview_service.rb new file mode 100644 index 0000000..8f933dc --- /dev/null +++ b/app/services/coupons/preview_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Coupons + class PreviewService < BaseService + def initialize(invoice:, applied_coupons:) + @invoice = invoice + @applied_coupons = applied_coupons + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result.not_found_failure!(resource: "applied_coupons") unless applied_coupons + + result.credits = [] + + applied_coupons.each do |applied_coupon| + break unless invoice.sub_total_excluding_taxes_amount_cents&.positive? + next if applied_coupon.coupon.fixed_amount? && invoice.currency != applied_coupon.amount_currency + + fees = fees(applied_coupon) + + next if fees.none? + + base_amount_cents = base_amount_cents(applied_coupon, fees) + credit = add_credit(applied_coupon, fees, base_amount_cents) + + result.credits << credit + invoice.credits << credit + end + + result.invoice = invoice + result + end + + private + + attr_reader :applied_coupons, :invoice + + def add_credit(applied_coupon, fees, base_amount_cents) + credit_amount = AppliedCoupons::AmountService.call(applied_coupon:, base_amount_cents:).amount + new_credit = Credit.new( + invoice:, + organization_id: invoice.organization_id, + applied_coupon:, + amount_cents: credit_amount, + amount_currency: invoice.currency, + before_taxes: true + ) + + fees.each do |fee| + unless base_amount_cents.zero? + fee.precise_coupons_amount_cents += fee.compute_precise_credit_amount_cents(credit_amount, base_amount_cents) + end + + fee.precise_coupons_amount_cents = fee.amount_cents if fee.amount_cents < fee.precise_coupons_amount_cents + end + + invoice.coupons_amount_cents += new_credit.amount_cents + invoice.sub_total_excluding_taxes_amount_cents -= new_credit.amount_cents + + new_credit + end + + def base_amount_cents(applied_coupon, fees) + if applied_coupon.coupon.limited_billable_metrics? || applied_coupon.coupon.limited_plans? + fees.sum(&:amount_cents) + else + invoice.sub_total_excluding_taxes_amount_cents + end + end + + # TODO: update later when charges will be added to the preview + def fees(applied_coupon) + if applied_coupon.coupon.limited_billable_metrics? + Fee.none + elsif applied_coupon.coupon.limited_plans? + plan_related_fees(applied_coupon) + else + invoice.fees + end + end + + def plan_related_fees(applied_coupon) + if applied_coupon.coupon.plans.map(&:id).include?(invoice.subscriptions[0].plan_id) + invoice.fees + else + Fee.none + end + end + end +end diff --git a/app/services/coupons/terminate_service.rb b/app/services/coupons/terminate_service.rb new file mode 100644 index 0000000..0d30ee6 --- /dev/null +++ b/app/services/coupons/terminate_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Coupons + class TerminateService < BaseService + def self.terminate_all_expired + Coupon + .active + .time_limit + .expired + .find_each(&:mark_as_terminated!) + end + + def initialize(coupon) + @coupon = coupon + super + end + + def call + return result.not_found_failure!(resource: "coupon") unless coupon + + coupon.mark_as_terminated! unless coupon.terminated? + + result.coupon = coupon + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :coupon + end +end diff --git a/app/services/coupons/update_service.rb b/app/services/coupons/update_service.rb new file mode 100644 index 0000000..dd93d90 --- /dev/null +++ b/app/services/coupons/update_service.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Coupons + class UpdateService < BaseService + def initialize(coupon:, params:) + @coupon = coupon + @params = params + + super + end + + activity_loggable( + action: "coupon.updated", + record: -> { coupon } + ) + + def call + return result.not_found_failure!(resource: "coupon") unless coupon + return result unless valid?(params) + + coupon.name = params[:name] if params.key?(:name) + coupon.description = params[:description] if params.key?(:description) + coupon.expiration = params[:expiration]&.to_sym if params.key?(:expiration) + coupon.expiration_at = params[:expiration_at] if params.key?(:expiration_at) + + @limitations = params[:applies_to]&.to_h&.deep_symbolize_keys || {} + coupon_already_applied = coupon.applied_coupons.exists? + + unless coupon_already_applied + if !plan_identifiers.nil? && plans.count != plan_identifiers.count + return result.not_found_failure!(resource: "plans") + end + + if !billable_metric_identifiers.nil? && billable_metrics.count != billable_metric_identifiers.count + return result.not_found_failure!(resource: "billable_metrics") + end + + if billable_metrics.present? && plans.present? + return result.not_allowed_failure!(code: "only_one_limitation_type_per_coupon_allowed") + end + + if coupon.billable_metrics.exists? && plans.present? && billable_metrics.blank? + coupon.limited_billable_metrics = false + elsif !billable_metric_identifiers.nil? + coupon.limited_billable_metrics = billable_metric_identifiers.present? + end + + if coupon.plans.exists? && billable_metrics.present? && plans.blank? + coupon.limited_plans = false + elsif !plan_identifiers.nil? + coupon.limited_plans = plan_identifiers.present? + end + + coupon.code = params[:code] if params.key?(:code) + coupon.coupon_type = params[:coupon_type] if params.key?(:coupon_type) + coupon.amount_cents = params[:amount_cents] if params.key?(:amount_cents) + coupon.amount_currency = params[:amount_currency] if params.key?(:amount_currency) + coupon.percentage_rate = params[:percentage_rate] if params.key?(:percentage_rate) + coupon.frequency = params[:frequency] if params.key?(:frequency) + coupon.frequency_duration = params[:frequency_duration] if params.key?(:frequency_duration) + coupon.reusable = params[:reusable] if params.key?(:reusable) + end + + ActiveRecord::Base.transaction do + coupon.save! + + process_plans unless coupon_already_applied + process_billable_metrics unless coupon_already_applied + end + + result.coupon = coupon + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :coupon, :params, :limitations + + delegate :organization, to: :coupon + + def plan_identifiers + key = api_context? ? :plan_codes : :plan_ids + limitations[key]&.compact&.uniq + end + + def plans + return @plans if defined? @plans + return [] if plan_identifiers.blank? + + @plans = if api_context? + Plan.where(code: plan_identifiers, organization_id: coupon.organization_id) + else + Plan.where(id: plan_identifiers, organization_id: coupon.organization_id) + end + end + + def process_plans + existing_coupon_plan_ids = coupon.coupon_targets.pluck(:plan_id).compact + + plans.each do |plan| + next if existing_coupon_plan_ids.include?(plan.id) + + CouponTarget.create!(coupon:, plan:, organization_id: organization.id) + end + + sanitize_coupon_plans + end + + def sanitize_coupon_plans + not_needed_coupon_plan_ids = coupon.coupon_targets.pluck(:plan_id).compact - plans.pluck(:id) + + not_needed_coupon_plan_ids.each do |coupon_plan_id| + CouponTarget.find_by(coupon:, plan_id: coupon_plan_id).destroy! + end + end + + def billable_metric_identifiers + key = api_context? ? :billable_metric_codes : :billable_metric_ids + limitations[key]&.compact&.uniq + end + + def billable_metrics + return @billable_metrics if defined? @billable_metrics + return [] if billable_metric_identifiers.blank? + + @billable_metrics = if api_context? + BillableMetric.where(code: billable_metric_identifiers, organization_id: coupon.organization_id) + else + BillableMetric.where(id: billable_metric_identifiers, organization_id: coupon.organization_id) + end + end + + def process_billable_metrics + existing_coupon_billable_metric_ids = coupon.coupon_targets.pluck(:billable_metric_id).compact + + billable_metrics.each do |billable_metric| + next if existing_coupon_billable_metric_ids.include?(billable_metric.id) + + CouponTarget.create!(coupon:, billable_metric:, organization_id: organization.id) + end + + sanitize_coupon_billable_metrics + end + + def sanitize_coupon_billable_metrics + not_needed_coupon_billable_metric_ids = + coupon.coupon_targets.pluck(:billable_metric_id).compact - billable_metrics.pluck(:id) + + not_needed_coupon_billable_metric_ids.each do |coupon_billable_metric_id| + CouponTarget.find_by(coupon:, billable_metric_id: coupon_billable_metric_id).destroy! + end + end + + def valid?(args) + Coupons::ValidateService.new(result, **args).valid? + end + end +end diff --git a/app/services/coupons/validate_service.rb b/app/services/coupons/validate_service.rb new file mode 100644 index 0000000..6c5702d --- /dev/null +++ b/app/services/coupons/validate_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Coupons + class ValidateService < BaseValidator + def valid? + valid_expiration_at? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_expiration_at? + return true if Validators::ExpirationDateValidator.valid?(args[:expiration_at]) + + add_error(field: :expiration_at, error_code: "invalid_date") + false + end + end +end diff --git a/app/services/credit_notes/adjust_amounts_with_rounding_service.rb b/app/services/credit_notes/adjust_amounts_with_rounding_service.rb new file mode 100644 index 0000000..01313ed --- /dev/null +++ b/app/services/credit_notes/adjust_amounts_with_rounding_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module CreditNotes + class AdjustAmountsWithRoundingService < BaseService + Result = BaseResult[:credit_note] + + def initialize(credit_note:) + @credit_note = credit_note + + super + end + + # NOTE: The goal of this service is to adjust the amounts so + # that sub total excluding taxes + taxes amount = total amount + # taking the rounding into account + def call + subtotal = credit_note.total_amount_cents - credit_note.taxes_amount_cents + + if subtotal != credit_note.sub_total_excluding_taxes_amount_cents + if subtotal > credit_note.sub_total_excluding_taxes_amount_cents + credit_note.total_amount_cents -= 1 + else + credit_note.total_amount_cents += 1 + end + + if credit_note.credit_amount_cents > 0 + # NOTE: Adjust credit_amount_cents to make sure that we keep + # total_amount_cents = credit_amount_cents + refund_amount_cents + credit_note.credit_amount_cents = credit_note.total_amount_cents - credit_note.refund_amount_cents + else + credit_note.refund_amount_cents = credit_note.total_amount_cents + end + + credit_note.balance_amount_cents = credit_note.credit_amount_cents + end + + result.credit_note = credit_note + result + end + + private + + attr_reader :credit_note + end +end diff --git a/app/services/credit_notes/apply_taxes_service.rb b/app/services/credit_notes/apply_taxes_service.rb new file mode 100644 index 0000000..3a14291 --- /dev/null +++ b/app/services/credit_notes/apply_taxes_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module CreditNotes + class ApplyTaxesService < BaseService + def initialize(invoice:, items:) + @invoice = invoice + @items = items + + super + end + + def call + result.applied_taxes = [] + result.coupons_adjustment_amount_cents = coupons_adjustment_amount_cents + + applied_taxes_amount_cents = 0 + precise_applied_taxes_amount_cents = 0 + taxes_rate = 0 + + indexed_items.each do |tax_code, _| + invoice_applied_tax = find_invoice_applied_tax(tax_code) + + applied_tax = CreditNote::AppliedTax.new( + organization_id: invoice.organization_id, + tax: invoice_applied_tax.tax, + tax_description: invoice_applied_tax.tax_description, + tax_code: invoice_applied_tax.tax_code, + tax_name: invoice_applied_tax.tax_name, + tax_rate: invoice_applied_tax.tax_rate, + amount_currency: invoice.currency + ) + result.applied_taxes << applied_tax + + base_amount_cents = compute_base_amount_cents(tax_code) + applied_tax.base_amount_cents = (base_amount_cents * taxes_base_rate(invoice_applied_tax)).round + precise_base_amount_cents = (base_amount_cents * taxes_base_rate(invoice_applied_tax)) + precise_tax_amount_cents = (precise_base_amount_cents * invoice_applied_tax.tax_rate).fdiv(100) + applied_tax.amount_cents += precise_tax_amount_cents.round + + precise_applied_taxes_amount_cents += precise_tax_amount_cents + applied_taxes_amount_cents += precise_tax_amount_cents.round + taxes_rate += pro_rated_taxes_rate(applied_tax) + end + + result.precise_taxes_amount_cents = precise_applied_taxes_amount_cents + result.taxes_amount_cents = applied_taxes_amount_cents + result.taxes_rate = taxes_rate.round(5) + + result + end + + private + + attr_reader :invoice, :items + + delegate :organization, to: :invoice + + # NOTE: indexes the credit note fees by taxes. + # Example output will be: { tax1 => [fee1, fee2], tax2 => [fee2] } + def indexed_items + @indexed_items ||= items.each_with_object({}) do |item, applied_taxes| + item.fee.applied_taxes.each do |fee_applied_tax| + applied_taxes[fee_applied_tax.tax_code] ||= [] + applied_taxes[fee_applied_tax.tax_code] << item + end + end + end + + def items_amount_cents + @items_amount_cents ||= items.sum(&:precise_amount_cents) + end + + def coupons_adjustment_amount_cents + return 0 if invoice.version_number < Invoice::COUPON_BEFORE_VAT_VERSION + + items.sum do |item| + item_fee_rate = item.fee.amount_cents.zero? ? 0 : item.precise_amount_cents.fdiv(item.fee.amount_cents) + item.fee.precise_coupons_amount_cents * item_fee_rate + end + end + + def compute_base_amount_cents(tax_code) + indexed_items[tax_code].map do |item| + # NOTE: Part of the item taken from the fee amount + item_fee_rate = item.fee.amount_cents.zero? ? 0 : item.precise_amount_cents.fdiv(item.fee.amount_cents) + + # NOTE: Part of the coupons applied to the item + prorated_coupon_amount = item.fee.precise_coupons_amount_cents * item_fee_rate + + item.precise_amount_cents - prorated_coupon_amount + end.sum + end + + # NOTE: Tax might not be applied to all items of the credit note. + # In order to compute the credit_note#taxes_rate, we have to apply + # a pro-rata of the items attached to the tax on the total items amount + def pro_rated_taxes_rate(applied_tax) + tax_items_amount_cents = compute_base_amount_cents(applied_tax.tax_code) + total_items_amount_cents = items_amount_cents - result.coupons_adjustment_amount_cents + + items_rate = total_items_amount_cents.zero? ? 0 : tax_items_amount_cents.fdiv(total_items_amount_cents) + + items_rate * applied_tax.tax_rate + end + + def find_invoice_applied_tax(tax_code) + invoice.applied_taxes.find_by(tax_code: tax_code) + end + + def taxes_base_rate(applied_tax) + return 1 if applied_tax.fees_amount_cents.blank? || applied_tax.fees_amount_cents.zero? + + applied_tax.taxable_amount_cents.fdiv(applied_tax.fees_amount_cents) + end + end +end diff --git a/app/services/credit_notes/create_from_progressive_billing_invoice.rb b/app/services/credit_notes/create_from_progressive_billing_invoice.rb new file mode 100644 index 0000000..5029e33 --- /dev/null +++ b/app/services/credit_notes/create_from_progressive_billing_invoice.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module CreditNotes + class CreateFromProgressiveBillingInvoice < BaseService + Result = BaseResult[:credit_note] + def initialize(progressive_billing_invoice:, amount:, reason: :other) + @progressive_billing_invoice = progressive_billing_invoice + @amount = amount + @reason = reason + + super + end + + def call + return result unless amount.positive? + return result.forbidden_failure! unless progressive_billing_invoice.progressive_billing? + + # Important to call this method as it modifies @amount if needed + items = calculate_items! + return result unless result.success? + + credit_amount_cents = creditable_amount_cents(amount, items) + return result if credit_amount_cents.zero? + + credit_note_result = CreditNotes::CreateService.call!( + invoice: progressive_billing_invoice, + credit_amount_cents:, + items:, + reason:, + automatic: true + ) + + result.credit_note = credit_note_result.credit_note + result + end + + private + + attr_reader :progressive_billing_invoice, :amount, :reason + + def calculate_items! + items = [] + remaining = amount + + # The amount can be greater than a single fee amount. We'll keep on deducting until we've credited enough + progressive_billing_invoice.fees.order(amount_cents: :desc).each do |fee| + # no further credit remaining + break if remaining.zero? + + # take the lower value of remaining or maximum creditable for this fee. (whichever is the lowest) + fee_credit_amount = [remaining, fee.creditable_amount_cents].min + items << { + fee_id: fee.id, + amount_cents: fee_credit_amount.truncate(CreditNote::DB_PRECISION_SCALE) + } + + remaining -= fee_credit_amount + end + + # it could be that we have some amount remaining due to multiple progressive billing invoices. This case should be handled manually + # TODO(ProgressiveBilling): verify and check in v2 + if remaining.positive? + result.not_allowed_failure!(code: "creditable_amount_is_less_than_requested") + end + + items + end + + def creditable_amount_cents(amount, items) + taxes_result = CreditNotes::ApplyTaxesService.call( + invoice: progressive_billing_invoice, + items: items.map { |item| CreditNoteItem.new(fee_id: item[:fee_id], precise_amount_cents: item[:amount_cents]) } + ) + + ( + amount.truncate(CreditNote::DB_PRECISION_SCALE) - + taxes_result.coupons_adjustment_amount_cents.truncate(CreditNote::DB_PRECISION_SCALE) + + taxes_result.taxes_amount_cents.truncate(CreditNote::DB_PRECISION_SCALE) + ).round + end + end +end diff --git a/app/services/credit_notes/create_from_termination.rb b/app/services/credit_notes/create_from_termination.rb new file mode 100644 index 0000000..7567d53 --- /dev/null +++ b/app/services/credit_notes/create_from_termination.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +module CreditNotes + class CreateFromTermination < BaseService + Result = CreditNotes::CreateService::Result + + # on_termination controls what to do with the unused subscription amount: + # :credit - credits all unused amount back to the customer + # :refund - refunds the unused paid amount, credits any updaid unused amount back to the customer + # :offset - refunds the unused paid amount, offsets the invoice by the updaid unused amount + def initialize(subscription:, reason: "order_change", upgrade: false, context: nil, on_termination: :credit) + @subscription = subscription + @reason = reason + @upgrade = upgrade + @context = context + @on_termination = on_termination + + super + end + + def call + return result if (last_subscription_fee&.amount_cents || 0).zero? || last_subscription_fee.invoice.voided? + + raise NotImplementedError, "Upgrade and refund are not supported together" if upgrade && refund? + raise NotImplementedError, "Upgrade and offset are not supported together" if upgrade && offset? + + base_creditable_amount = calculate_base_creditable_amount + + return result if base_creditable_amount.zero? + + credit_amount_cents, refund_amount_cents, offset_amount_cents = calculate_amounts(base_creditable_amount) + + return result if (credit_amount_cents + refund_amount_cents + offset_amount_cents).zero? + + CreditNotes::CreateService.call( + invoice: last_subscription_fee.invoice, + credit_amount_cents:, + refund_amount_cents:, + offset_amount_cents:, + items: [ + { + fee_id: last_subscription_fee.id, + amount_cents: base_creditable_amount.truncate(CreditNote::DB_PRECISION_SCALE) + } + ], + reason: reason.to_sym, + automatic: true, + context: + ) + end + + private + + attr_accessor :subscription, :reason, :context, :on_termination, :upgrade + + delegate :plan, :terminated_at, :customer, to: :subscription + + def refund? + on_termination == :refund + end + + def offset? + on_termination == :offset + end + + def calculate_base_creditable_amount + amount = calculate_base_unused_amount + return 0 unless amount.positive? + + # NOTE: In some cases, if the fee was already prorated (in case of multiple upgrade) the amount + # could be greater than the last subscription fee amount. + # In that case, we have to use the last subscription fee amount + amount = last_subscription_fee.amount_cents if amount > last_subscription_fee.amount_cents + + # NOTE: if credit notes were already issued on the fee, + # we have to deduct them from the prorated amount + amount -= last_subscription_fee.credit_note_items.sum(:amount_cents) + return 0 unless amount.positive? + + amount + end + + def last_subscription_fee + @last_subscription_fee ||= subscription.fees.subscription.order(created_at: :desc).first + end + + def calculate_base_unused_amount + day_price * remaining_duration + end + + def date_service + @date_service ||= Subscriptions::DatesService.new_instance( + subscription, + terminated_at + ) + end + + def plan_amount_cents + last_subscription_fee&.amount_details&.[]("plan_amount_cents") || plan.amount_cents + end + + def next_end_of_period + date_service.next_end_of_period.to_date + end + + def day_price + date_service.single_day_price(plan_amount_cents:) + end + + def terminated_at_in_timezone + terminated_at.in_time_zone(customer.applicable_timezone) + end + + def remaining_duration + billed_from = terminated_at_in_timezone.end_of_day.utc.to_date + billed_from -= 1.day if upgrade + + if plan.has_trial? && subscription.trial_end_date >= billed_from + billed_from = if subscription.trial_end_date > next_end_of_period + next_end_of_period + else + subscription.trial_end_date - 1.day + end + end + + duration = (next_end_of_period - billed_from).to_i + + duration.negative? ? 0 : duration + end + + def calculate_amounts(base_creditable_amount) + # Calculate the total creditable amount (including taxes) + total_creditable_amount = adjust_for_coupon_and_taxes(base_creditable_amount) + + refund_amount_cents = calculate_refund(total_creditable_amount) + creditable_amount_cents = total_creditable_amount - refund_amount_cents + + # [credit_amount, refund_amount, offset_amount] + case on_termination + when :credit + [total_creditable_amount, 0, 0] + when :refund + [creditable_amount_cents, refund_amount_cents, 0] + when :offset + [0, refund_amount_cents, creditable_amount_cents] + end + end + + def adjust_for_coupon_and_taxes(item_amount) + precise_amount_cents = item_amount.truncate(CreditNote::DB_PRECISION_SCALE) + item = CreditNoteItem.new(fee_id: last_subscription_fee.id, precise_amount_cents:) + taxes_result = CreditNotes::ApplyTaxesService.call(invoice: last_subscription_fee.invoice, items: [item]) + + ( + precise_amount_cents - + taxes_result.coupons_adjustment_amount_cents + + taxes_result.precise_taxes_amount_cents + ).round + end + + def calculate_refund(total_creditable_amount) + potential_refund = paid_amount_prorated_to_subscription - creditable_used_amount + + return 0 if potential_refund <= 0 + + # The refund cannot exceed the creditable amount + refund_amount_cents = [potential_refund, total_creditable_amount].min + refund_amount_cents.round + end + + def credit_only? + !refund + end + + def creditable_used_amount + adjust_for_coupon_and_taxes(base_subscription_used_amount) + end + + def paid_amount_prorated_to_subscription + invoice_amount = last_subscription_fee.invoice.sub_total_including_taxes_amount_cents + + return 0 if invoice_amount.zero? + + fee_amount = last_subscription_fee.sub_total_excluding_taxes_precise_amount_cents + last_subscription_fee.taxes_precise_amount_cents + fee_rate = fee_amount.fdiv(invoice_amount) + paid_amount = last_subscription_fee.invoice.total_paid_amount_cents + + fee_rate * paid_amount + end + + def base_subscription_used_amount + day_price * used_duration + end + + def used_duration + billed_from = date_service.from_datetime.to_date + + # If there's a trial, adjust the billing start to after the trial + if plan.has_trial? && subscription.trial_end_date + trial_end = subscription.trial_end_date.to_date + billed_from = trial_end if trial_end > billed_from + end + + billed_to = terminated_at_in_timezone.end_of_day.utc.to_date + + # TODO:Could it happen that terminated_at is within the next billing period here ? + billed_to = next_end_of_period if billed_to > next_end_of_period + + duration = (billed_to - billed_from).to_i + 1 + duration.negative? ? 0 : duration + end + end +end diff --git a/app/services/credit_notes/create_service.rb b/app/services/credit_notes/create_service.rb new file mode 100644 index 0000000..53a575c --- /dev/null +++ b/app/services/credit_notes/create_service.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +module CreditNotes + class CreateService < BaseService + Result = BaseResult[:credit_note] + + def initialize(invoice:, **args) + @invoice = invoice + args = args.with_indifferent_access + @items_attr = args[:items] + @reason = args[:reason] || :other + @description = args[:description] + @credit_amount_cents = args[:credit_amount_cents] || 0 + @refund_amount_cents = args[:refund_amount_cents] || 0 + @offset_amount_cents = args[:offset_amount_cents] || 0 + @metadata_value = args[:metadata] + + @automatic = args.key?(:automatic) ? args[:automatic] : false + @context = args[:context] + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result.forbidden_failure! unless should_create_credit_note? + return result.not_allowed_failure!(code: "invalid_type_or_status") unless valid_type_or_status? + + ActiveRecord::Base.transaction do + result.credit_note = CreditNote.new( + organization_id: invoice.organization_id, + customer: invoice.customer, + invoice:, + issuing_date:, + total_amount_currency: invoice.currency, + credit_amount_currency: invoice.currency, + refund_amount_currency: invoice.currency, + offset_amount_currency: invoice.currency, + balance_amount_currency: invoice.currency, + credit_amount_cents:, + refund_amount_cents:, + offset_amount_cents:, + reason:, + description:, + credit_status: "available", + status: invoice.voided? ? "finalized" : invoice.status # credit notes dont have void state + ) + + if metadata_value + credit_note.build_metadata( + organization_id: credit_note.organization_id, + value: metadata_value + ) + credit_note.metadata.save! if context != :preview + end + + credit_note.save! if context != :preview + + create_items + result.raise_if_error! + + compute_amounts_and_taxes + + valid_credit_note? + result.raise_if_error! + + credit_note.credit_status = "available" if credit_note.credited? + credit_note.refund_status = "pending" if credit_note.refunded? + + credit_note.assign_attributes( + total_amount_cents: credit_note.credit_amount_cents + + credit_note.refund_amount_cents + + credit_note.offset_amount_cents, + balance_amount_cents: credit_note.credit_amount_cents + ) + CreditNotes::AdjustAmountsWithRoundingService.call!(credit_note:) + + next if context == :preview + + credit_note.save! + + if offset_amount_cents.positive? + InvoiceSettlements::CreateService.call!( + invoice: invoice, + source_credit_note: credit_note, + amount_cents: offset_amount_cents, + amount_currency: invoice.currency + ) + end + + void_prepaid_credit if void_prepaid_credit? + end + return result if context == :preview + + if credit_note.finalized? + after_commit do + track_credit_note_created + deliver_webhook + Utils::ActivityLog.produce(credit_note, "credit_note.created") + CreditNotes::GenerateDocumentsJob.perform_later(credit_note) + deliver_email + handle_refund if should_handle_refund? + report_to_tax_provider + + if credit_note.should_sync_credit_note? + Integrations::Aggregator::CreditNotes::CreateJob.perform_later(credit_note:) + end + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_accessor :invoice, + :items_attr, + :reason, + :description, + :credit_amount_cents, + :refund_amount_cents, + :offset_amount_cents, + :metadata_value, + :automatic, + :context + + delegate :credit_note, to: :result + delegate :customer, to: :invoice + + def invalid_reason? + CreditNote.reasons.keys.exclude?(reason.to_s) + end + + def should_create_credit_note? + # NOTE: created from subscription termination + return true if automatic + + # NOTE: credit note is a premium feature + License.premium? + end + + def valid_type_or_status? + return true if automatic + + if invoice.credit? + return false if prepaid_credit_wallet.nil? + + if invoice.payment_pending? || invoice.payment_failed? + return false if non_offset_amounts_present? + else + return false unless invoice.payment_succeeded? + end + end + + invoice.version_number >= Invoice::CREDIT_NOTES_MIN_VERSION + end + + def non_offset_amounts_present? + credit_amount_cents.positive? || refund_amount_cents.positive? + end + + # NOTE: issuing_date must be in customer time zone (accounting date) + def issuing_date + Time.current.in_time_zone(customer.applicable_timezone).to_date + end + + def create_items + return result.validation_failure!(errors: {items: ["must_be_an_array"]}) unless items_attr.is_a?(Array) + + items_attr.each do |item_attr| + amount_cents = item_attr[:amount_cents] || 0 + + item = credit_note.items.new( + organization_id: invoice.organization_id, + fee: invoice.fees.find_by(id: item_attr[:fee_id]), + amount_cents: amount_cents.round, + precise_amount_cents: amount_cents, + amount_currency: invoice.currency + ) + break unless valid_item?(item) + + item.save! unless context == :preview + end + end + + def valid_item?(item) + CreditNotes::ValidateItemService.new(result, item:).valid? + end + + def valid_credit_note? + CreditNotes::ValidateService.new(result, item: credit_note).valid? + end + + def track_credit_note_created + types = if credit_note.credited? && credit_note.refunded? + "both" + elsif credit_note.credited? + "credit" + elsif credit_note.refunded? + "refund" + end + + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "credit_note_issued", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + invoice_id: credit_note.invoice_id, + credit_note_method: types + } + ) + end + + def deliver_webhook + SendWebhookJob.perform_later( + "credit_note.created", + credit_note + ) + end + + def deliver_email + # NOTE: We already check the premium state for the credit note creation + return unless credit_note.billing_entity.email_settings.include?("credit_note.created") + + CreditNoteMailer.with(credit_note:) + .created.deliver_later(wait: 3.seconds) + end + + def should_handle_refund? + return false unless credit_note.refunded? + return false unless credit_note.invoice.payment_succeeded? + + invoice_payment.present? + end + + def invoice_payment + @invoice_payment ||= credit_note.invoice.payments.order(created_at: :desc).first + end + + def handle_refund + case invoice_payment.payment_provider + when PaymentProviders::StripeProvider + CreditNotes::Refunds::StripeCreateJob.perform_later(credit_note) + when PaymentProviders::GocardlessProvider + CreditNotes::Refunds::GocardlessCreateJob.perform_later(credit_note) + when PaymentProviders::AdyenProvider + CreditNotes::Refunds::AdyenCreateJob.perform_later(credit_note) + end + end + + def report_to_tax_provider + CreditNotes::ProviderTaxes::ReportJob.perform_later(credit_note:) + end + + def compute_amounts_and_taxes + taxes_result = CreditNotes::ApplyTaxesService.call( + invoice:, + items: credit_note.items + ) + + credit_note.precise_coupons_adjustment_amount_cents = taxes_result.coupons_adjustment_amount_cents + credit_note.coupons_adjustment_amount_cents = taxes_result.coupons_adjustment_amount_cents.round + credit_note.precise_taxes_amount_cents = taxes_result.precise_taxes_amount_cents + adjust_credit_note_tax_rounding if credit_note_for_all_remaining_amount? + + credit_note.taxes_amount_cents = credit_note.precise_taxes_amount_cents.round + credit_note.taxes_rate = taxes_result.taxes_rate + + taxes_result.applied_taxes.each { |applied_tax| credit_note.applied_taxes << applied_tax } + end + + def credit_note_for_all_remaining_amount? + credit_note.invoice.creditable_amount_cents == 0 + end + + def adjust_credit_note_tax_rounding + credit_note.precise_taxes_amount_cents -= all_rounding_tax_adjustments + end + + def all_rounding_tax_adjustments + credit_note.invoice.credit_notes.sum(&:taxes_rounding_adjustment) + end + + def prepaid_credit_wallet + @prepaid_credit_wallet ||= invoice.associated_active_wallet + end + + def void_prepaid_credit + wallet_credit = WalletCredit.from_amount_cents(wallet: prepaid_credit_wallet, amount_cents: credit_note.refund_amount_cents) + # When the wallet is traceable, we want to specify which wallet transaction to void. Otherwise, the void service + # will void inbound transactions (decrease remaining amount) based on their priority. + wallet_transaction = prepaid_credit_wallet.traceable? ? invoice.prepaid_credit_fee.invoiceable : nil + WalletTransactions::VoidService.call!( + wallet: prepaid_credit_wallet, + wallet_credit: wallet_credit, + inbound_wallet_transaction: wallet_transaction, + credit_note_id: credit_note.id + ) + end + + def void_prepaid_credit? + invoice.credit? && prepaid_credit_wallet.present? + end + end +end diff --git a/app/services/credit_notes/estimate_service.rb b/app/services/credit_notes/estimate_service.rb new file mode 100644 index 0000000..8dc4ed3 --- /dev/null +++ b/app/services/credit_notes/estimate_service.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +module CreditNotes + class EstimateService < BaseService + def initialize(invoice:, items:) + @invoice = invoice + @items = items + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result.forbidden_failure! unless License.premium? + return result.not_allowed_failure!(code: "invalid_type_or_status") unless valid_type_or_status? + + @credit_note = CreditNote.new( + organization_id: invoice.organization_id, + customer: invoice.customer, + invoice:, + total_amount_currency: invoice.currency, + credit_amount_currency: invoice.currency, + refund_amount_currency: invoice.currency, + balance_amount_currency: invoice.currency + ) + + validate_items + return result unless result.success? + + compute_amounts_and_taxes + adjust_amounts_with_rounding + + result.credit_note = credit_note + result + end + + private + + attr_reader :invoice, :items, :credit_note + + def valid_type_or_status? + return false if invoice.credit? && (invoice.payment_status != "succeeded" || invoice.associated_active_wallet.nil?) + + invoice.version_number >= Invoice::CREDIT_NOTES_MIN_VERSION + end + + def validate_items + return result.validation_failure!(errors: {items: ["must_be_an_array"]}) unless items.is_a?(Array) + + items.each do |item_attr| + amount_cents = item_attr[:amount_cents]&.to_i || 0 + + item = CreditNoteItem.new( + organization_id: invoice.organization_id, + fee: invoice.fees.find_by(id: item_attr[:fee_id]), + amount_cents: amount_cents.round, + precise_amount_cents: amount_cents, + amount_currency: invoice.currency + ) + credit_note.items << item + + break unless valid_item?(item) + end + end + + def valid_credit_note? + CreditNotes::ValidateService.new(result, item: credit_note).valid? + end + + def valid_item?(item) + CreditNotes::ValidateItemService.new(result, item:).valid? + end + + def compute_amounts_and_taxes + taxes_result = CreditNotes::ApplyTaxesService.call( + invoice:, + items: credit_note.items + ) + + credit_note.precise_coupons_adjustment_amount_cents = taxes_result.coupons_adjustment_amount_cents + credit_note.coupons_adjustment_amount_cents = taxes_result.coupons_adjustment_amount_cents.round + credit_note.precise_taxes_amount_cents = taxes_result.precise_taxes_amount_cents + adjust_credit_note_tax_precise_rounding if credit_note_for_all_remaining_amount? + + credit_note.taxes_amount_cents = credit_note.precise_taxes_amount_cents.round + credit_note.taxes_rate = taxes_result.taxes_rate + + taxes_result.applied_taxes.each { |applied_tax| credit_note.applied_taxes << applied_tax } + + credit_note.credit_amount_cents = compute_creditable_amount(taxes_result) + compute_refundable_amount + + credit_note.credit_amount_cents = 0 if invoice.credit? + credit_note.total_amount_cents = credit_note.credit_amount_cents + end + + def credit_note_for_all_remaining_amount? + credit_note.items.sum(&:precise_amount_cents) == credit_note.invoice.fees.sum(&:creditable_amount_cents) + end + + def adjust_credit_note_tax_precise_rounding + credit_note.precise_taxes_amount_cents -= all_rounding_tax_adjustments + end + + def all_rounding_tax_adjustments + credit_note.invoice.credit_notes.sum(&:taxes_rounding_adjustment) + end + + def compute_creditable_amount(taxes_result) + ( + credit_note.items.sum(&:amount_cents) - + taxes_result.coupons_adjustment_amount_cents + + credit_note.precise_taxes_amount_cents + ).round + end + + def compute_refundable_amount + credit_note.refund_amount_cents = credit_note.credit_amount_cents + + refundable_amount_cents = invoice.refundable_amount_cents + return unless credit_note.credit_amount_cents > refundable_amount_cents + + credit_note.refund_amount_cents = refundable_amount_cents + end + + # NOTE: The goal of this method is to adjust the amounts so + # that sub total exluding taxes + taxes amount = total amount + # taking the rounding into account + def adjust_amounts_with_rounding + subtotal = credit_note.total_amount_cents - credit_note.taxes_amount_cents + + if subtotal != credit_note.sub_total_excluding_taxes_amount_cents + if subtotal > credit_note.sub_total_excluding_taxes_amount_cents + credit_note.total_amount_cents -= 1 + elsif credit_note.taxes_amount_cents > 0 + credit_note.taxes_amount_cents -= 1 + end + + credit_note.credit_amount_cents = credit_note.total_amount_cents + credit_note.balance_amount_cents = credit_note.credit_amount_cents + end + end + end +end diff --git a/app/services/credit_notes/generate_pdf_service.rb b/app/services/credit_notes/generate_pdf_service.rb new file mode 100644 index 0000000..fd044b3 --- /dev/null +++ b/app/services/credit_notes/generate_pdf_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module CreditNotes + class GeneratePdfService < BaseService + def initialize(credit_note:, context: nil) + @credit_note = credit_note + @context = context + + super + end + + def call + return result.not_found_failure!(resource: "credit_note") if credit_note.blank? || !credit_note.finalized? + + if should_generate_pdf? + generate_pdf(credit_note) + SendWebhookJob.perform_later("credit_note.generated", credit_note) + Utils::ActivityLog.produce(credit_note, "credit_note.generated") + end + + result.credit_note = credit_note + result + end + + private + + attr_reader :credit_note, :context + + def generate_pdf(credit_note) + I18n.with_locale(credit_note.customer.preferred_document_locale) do + pdf_file = build_pdf_file + xml_file = attach_facturx(pdf_file) if should_generate_facturx_einvoice_xml? + attach_pdf_to_credit_note(pdf_file) + + credit_note.save! + ensure + cleanup_tempfiles(pdf_file, xml_file) + end + end + + def build_pdf_file + pdf_content = Utils::PdfGenerator.call(template:, context: credit_note).io.read + + pdf_file = Tempfile.new([credit_note.number, ".pdf"]) + pdf_file.binmode + pdf_file.write(pdf_content) + pdf_file.flush + + pdf_file + end + + def attach_facturx(pdf_file) + xml_file = Tempfile.new([credit_note.number, ".xml"]) + xml_file.write(EInvoices::CreditNotes::FacturX::CreateService.call(credit_note:).xml) + xml_file.flush + + Utils::PdfAttachmentService.call(file: pdf_file, attachment: xml_file) + xml_file + end + + def attach_pdf_to_credit_note(pdf_file) + credit_note.file.attach( + io: File.open(pdf_file.path), + filename: "#{credit_note.number}.pdf", + content_type: "application/pdf" + ) + end + + def cleanup_tempfiles(pdf_file, xml_file) + pdf_file&.unlink + xml_file&.unlink + end + + def should_generate_facturx_einvoice_xml? + credit_note.billing_entity.einvoicing && BillingEntity::EINVOICING_COUNTRIES.include?(credit_note.billing_entity.country.try(:upcase)) + end + + def should_generate_pdf? + return false if ActiveModel::Type::Boolean.new.cast(ENV["LAGO_DISABLE_PDF_GENERATION"]) + + context == "admin" || credit_note.file.blank? + end + + def template + return "credit_notes/self_billed" if credit_note.invoice.self_billed? + + "credit_notes/credit_note" + end + end +end diff --git a/app/services/credit_notes/generate_xml_service.rb b/app/services/credit_notes/generate_xml_service.rb new file mode 100644 index 0000000..868e732 --- /dev/null +++ b/app/services/credit_notes/generate_xml_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module CreditNotes + class GenerateXmlService < BaseService + def initialize(credit_note:, context: nil) + @credit_note = credit_note + @context = context + + super + end + + def call + return result.not_found_failure!(resource: "credit_note") if credit_note.blank? + return result.not_allowed_failure!(code: "is_draft") if credit_note.draft? + + if should_generate_xml? + generate_xml + end + + result.credit_note = credit_note + result + end + + private + + attr_reader :credit_note, :context + + def generate_xml + I18n.with_locale(credit_note.customer.preferred_document_locale) do + xml_file = build_xml_file + attach_xml_to_credit_note(xml_file) + credit_note.save! + ensure + cleanup_tempfiles(xml_file) + end + end + + def build_xml_file + xml_file = Tempfile.new([credit_note.number, ".xml"]) + xml_file.write(EInvoices::CreditNotes::Ubl::CreateService.call(credit_note:).xml) + xml_file.flush + + xml_file + end + + def attach_xml_to_credit_note(xml_file) + credit_note.xml_file.attach( + io: File.open(xml_file.path), + filename: "#{credit_note.number}.xml", + content_type: "application/xml" + ) + end + + def cleanup_tempfiles(xml_file) + xml_file&.unlink + end + + def should_generate_xml? + return true if context == "admin" + + credit_note.xml_file.blank? && e_invoicing_enabled? + end + + def e_invoicing_enabled? + credit_note.billing_entity.einvoicing && BillingEntity::EINVOICING_COUNTRIES.include?(credit_note.billing_entity.country.try(:upcase)) + end + end +end diff --git a/app/services/credit_notes/lock_service.rb b/app/services/credit_notes/lock_service.rb new file mode 100644 index 0000000..a8ed382 --- /dev/null +++ b/app/services/credit_notes/lock_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module CreditNotes + class LockService < BaseService + def initialize(customer:) + @customer = customer + + super + end + + def call + customer.with_advisory_lock!( + "CREDIT_NOTES-#{customer.id}", + timeout_seconds: 5, + transaction: true, + disable_query_cache: true + ) do + yield + end + rescue WithAdvisoryLock::FailedToAcquireLock + raise Customers::FailedToAcquireLock, "Failed to acquire lock customer-#{customer.id}" + end + + def locked? + customer.advisory_lock_exists?("CREDIT_NOTES-#{customer.id}") + end + + private + + attr_reader :customer + end +end diff --git a/app/services/credit_notes/provider_taxes/report_service.rb b/app/services/credit_notes/provider_taxes/report_service.rb new file mode 100644 index 0000000..a160cb8 --- /dev/null +++ b/app/services/credit_notes/provider_taxes/report_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module CreditNotes + module ProviderTaxes + class ReportService < BaseService + def initialize(credit_note:) + @credit_note = credit_note + + super + end + + def call + return result.not_found_failure!(resource: "credit_note") unless credit_note + + credit_note.error_details.tax_error.discard_all # rubocop:disable Lago/DiscardAll + + tax_result = Integrations::Aggregator::Taxes::CreditNotes::CreateService.new(credit_note:).call + + unless tax_result.success? + create_error_detail(tax_result.error.code) + + return result.validation_failure!(errors: {tax_error: [tax_result.error.code]}) + end + + result.credit_note = credit_note + + result + end + + private + + attr_reader :credit_note + + delegate :customer, to: :credit_note + + def create_error_detail(code) + error_result = ErrorDetails::CreateService.call( + owner: credit_note, + organization: credit_note.organization, + params: { + error_code: :tax_error, + details: { + tax_error: code + } + } + ) + error_result.raise_if_error! + end + end + end +end diff --git a/app/services/credit_notes/recredit_service.rb b/app/services/credit_notes/recredit_service.rb new file mode 100644 index 0000000..a958275 --- /dev/null +++ b/app/services/credit_notes/recredit_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module CreditNotes + class RecreditService < BaseService + def initialize(credit:) + @credit = credit + @credit_note = credit.credit_note + + super + end + + def call + return result.not_found_failure!(resource: "credit_note") if credit_note.nil? + return result.not_allowed_failure!(code: "credit_note_voided") if credit_note.voided? + + result.credit_note = credit_note + + credit_note.balance_amount_cents += credit.amount_cents + credit_note.credit_status = :available + credit_note.save! + + result + end + + private + + attr_reader :credit, :credit_note + end +end diff --git a/app/services/credit_notes/refresh_draft_service.rb b/app/services/credit_notes/refresh_draft_service.rb new file mode 100644 index 0000000..79dfdc7 --- /dev/null +++ b/app/services/credit_notes/refresh_draft_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module CreditNotes + class RefreshDraftService < BaseService + def initialize(credit_note:, fee:, old_fee_values:) + @credit_note = credit_note + @fee = fee + @old_fee_values = old_fee_values + + super + end + + def call + result.credit_note = credit_note + return result unless credit_note.draft? + + credit_note.applied_taxes.destroy_all + credit_note.items.each do |item| + item.fee_id = fee.id + + if old_fee_values.any? + old_entry = old_fee_values.find { |h| h[:credit_note_item_id] == item.id } + item.precise_amount_cents = calculate_item_value(item, old_entry[:fee_amount_cents]) if old_entry + end + + item.save! + end + + taxes_result = CreditNotes::ApplyTaxesService.call( + invoice: fee.invoice, + items: credit_note.items + ) + + credit_note.precise_coupons_adjustment_amount_cents = taxes_result.coupons_adjustment_amount_cents + credit_note.coupons_adjustment_amount_cents = taxes_result.coupons_adjustment_amount_cents.round + credit_note.precise_taxes_amount_cents = taxes_result.taxes_amount_cents + credit_note.taxes_amount_cents = taxes_result.taxes_amount_cents.round + credit_note.taxes_rate = taxes_result.taxes_rate + + taxes_result.applied_taxes.each { |applied_tax| credit_note.applied_taxes << applied_tax } + + credit_note.credit_amount_cents = ( + credit_note.items.sum(:precise_amount_cents).truncate(CreditNote::DB_PRECISION_SCALE) - + taxes_result.coupons_adjustment_amount_cents + + taxes_result.taxes_amount_cents + ).round + + credit_note.balance_amount_cents = credit_note.credit_amount_cents + credit_note.total_amount_cents = credit_note.credit_amount_cents + credit_note.refund_amount_cents + + CreditNotes::AdjustAmountsWithRoundingService.call!(credit_note:) + credit_note.save! + + result + end + + private + + attr_accessor :credit_note, :fee, :old_fee_values + + # NOTE: credit note item value needs to be recalculated based on the ratio between old fee value and + # new fee value + def calculate_item_value(item, old_fee_amount_cents) + return 0 if old_fee_amount_cents.zero? + + item.precise_amount_cents.fdiv(old_fee_amount_cents) * fee.amount_cents + end + end +end diff --git a/app/services/credit_notes/refunds/adyen_service.rb b/app/services/credit_notes/refunds/adyen_service.rb new file mode 100644 index 0000000..406cc4d --- /dev/null +++ b/app/services/credit_notes/refunds/adyen_service.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module CreditNotes + module Refunds + class AdyenService < BaseService + include Customers::PaymentProviderFinder + + def initialize(credit_note = nil) + @credit_note = credit_note + + super + end + + def create + result.credit_note = credit_note + return result unless should_process_refund? + + adyen_result = create_adyen_refund + + refund = Refund.new( + organization_id: credit_note.organization_id, + credit_note:, + payment:, + payment_provider: payment.payment_provider, + payment_provider_customer: payment_provider_customer(customer), + amount_cents: adyen_result.response.dig("amount", "value"), + amount_currency: adyen_result.response.dig("amount", "currency"), + status: "pending", + provider_refund_id: adyen_result.response["pspReference"] + ) + refund.save! + + update_credit_note_status(refund.status) + Utils::SegmentTrack.refund_status_changed(refund.status, credit_note.id, organization.id) + + result.refund = refund + result + end + + def update_status(provider_refund_id:, status:, metadata: {}) + refund = Refund.find_by(provider_refund_id:) + return handle_missing_refund(metadata) unless refund + + result.refund = refund + @credit_note = result.credit_note = refund.credit_note + return result if refund.credit_note.succeeded? + + refund.update!(status:) + update_credit_note_status(status) + Utils::SegmentTrack.refund_status_changed(refund.status, credit_note.id, organization.id) + + if status.to_sym == :failed + deliver_error_webhook(message: "Payment refund failed", code: nil) + Utils::ActivityLog.produce(credit_note, "credit_note.refund_failure") + result.service_failure!(code: "refund_failed", message: "Refund failed to perform") + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :credit_note + + delegate :organization, :customer, :invoice, to: :credit_note + + def client + @client ||= Adyen::Client.new( + api_key: payment.payment_provider.api_key, + env: payment.payment_provider.environment, + live_url_prefix: payment.payment_provider.live_prefix + ) + end + + def should_process_refund? + return false if !credit_note.refunded? || credit_note.succeeded? || invoice.payment_dispute_lost_at? + + payment.present? + end + + def payment + @payment ||= credit_note.invoice.payments.order(created_at: :desc).first + end + + def adyen_api_key + adyen_payment_provider.secret_key + end + + def create_adyen_refund + client.checkout.modifications_api.refund_captured_payment( + Lago::Adyen::Params.new(adyen_refund_params).to_h, + payment.provider_payment_id + ) + rescue Adyen::AdyenError => e + deliver_error_webhook(message: e.msg, code: e.code) + update_credit_note_status(:failed) + Utils::ActivityLog.produce(credit_note, "credit_note.refund_failure") + + raise + end + + def adyen_refund_params + { + paymentPspReference: payment.provider_payment_id, + merchantAccount: payment.payment_provider.merchant_account, + amount: { + value: credit_note.refund_amount_cents, + currency: credit_note.credit_amount_currency.upcase + } + } + end + + def deliver_error_webhook(message:, code:) + SendWebhookJob.perform_later( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: payment_provider_customer(customer)&.provider_customer_id, + provider_error: { + message:, + error_code: code + } + ) + end + + def update_credit_note_status(status) + credit_note.refund_status = status + credit_note.refunded_at = Time.current if credit_note.succeeded? + credit_note.save! + end + + def handle_missing_refund(metadata) + # NOTE: Refund was not initiated by lago + return result unless metadata&.key?(:lago_invoice_id) + + # NOTE: Invoice does not belongs to this lago instance + return result unless Invoice.find_by(id: metadata[:lago_invoice_id]) + + result.not_found_failure!(resource: "adyen_refund") + end + + def adyen_payment_provider + @adyen_payment_provider ||= payment_provider(customer) + end + end + end +end diff --git a/app/services/credit_notes/refunds/gocardless_service.rb b/app/services/credit_notes/refunds/gocardless_service.rb new file mode 100644 index 0000000..ddf6184 --- /dev/null +++ b/app/services/credit_notes/refunds/gocardless_service.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +module CreditNotes + module Refunds + class GocardlessService < BaseService + include Customers::PaymentProviderFinder + + PENDING_STATUSES = %w[created pending_submission submitted refund_settled].freeze + SUCCESS_STATUSES = %w[paid].freeze + FAILED_STATUSES = %w[cancelled bounced funds_returned failed].freeze + + def initialize(credit_note = nil) + @credit_note = credit_note + + super + end + + def create + result.credit_note = credit_note + return result unless should_process_refund? + + gocardless_result = create_gocardless_refund + + refund = Refund.new( + organization_id: credit_note.organization_id, + credit_note:, + payment:, + payment_provider: payment.payment_provider, + payment_provider_customer: payment_provider_customer(customer), + amount_cents: gocardless_result.amount, + amount_currency: gocardless_result.currency&.upcase, + status: gocardless_result.status, + provider_refund_id: gocardless_result.id + ) + refund.save! + + update_credit_note_status(credit_note_status(refund.status)) + Utils::SegmentTrack.refund_status_changed(refund.status, credit_note.id, organization.id) + + result.refund = refund + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue GoCardlessPro::Error, GoCardlessPro::ValidationError => e + deliver_error_webhook(message: e.message, code: e.code) + update_credit_note_status(:failed) + Utils::ActivityLog.produce(credit_note, "credit_note.refund_failure") + + if e.is_a?(GoCardlessPro::ValidationError) + result + else + raise + end + end + + def update_status(provider_refund_id:, status:, metadata: {}) + refund = Refund.find_by(provider_refund_id:) + return handle_missing_refund(metadata) unless refund + + result.refund = refund + @credit_note = result.credit_note = refund.credit_note + return result if refund.credit_note.succeeded? + + refund.update!(status:) + update_credit_note_status(credit_note_status(refund.status)) + Utils::SegmentTrack.refund_status_changed(refund.status, credit_note.id, organization.id) + + if FAILED_STATUSES.include?(status.to_s) + deliver_error_webhook(message: "Payment refund failed", code: nil) + Utils::ActivityLog.produce(credit_note, "credit_note.refund_failure") + result.service_failure!(code: "refund_failed", message: "Refund failed to perform") + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :credit_note + + delegate :organization, :customer, :invoice, to: :credit_note + + def should_process_refund? + return false if !credit_note.refunded? || credit_note.succeeded? || invoice.payment_dispute_lost_at? + + payment.present? + end + + def payment + @payment ||= credit_note.invoice.payments.order(created_at: :desc).first + end + + def client + @client ||= GoCardlessPro::Client.new( + access_token: gocardless_payment_provider.access_token, + environment: gocardless_payment_provider.environment + ) + end + + def gocardless_payment_provider + @gocardless_payment_provider ||= payment_provider(customer) + end + + def create_gocardless_refund + # NOTE: Gocarless API accepts only 3 keys at max in metadata + # See https://developer.gocardless.com/api-reference#refunds-create-a-refund + # for reference + client.refunds.create( + params: { + amount: credit_note.refund_amount_cents, + total_amount_confirmation: credit_note.refund_amount_cents, + links: {payment: payment.provider_payment_id}, + metadata: { + lago_credit_note_id: credit_note.id, + lago_invoice_id: invoice.id, + reason: credit_note.reason.to_s + } + }, + headers: { + "Idempotency-Key" => credit_note.id + } + ) + end + + def deliver_error_webhook(message:, code:) + SendWebhookJob.perform_later( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: payment_provider_customer(customer)&.provider_customer_id, + provider_error: { + message:, + error_code: code + } + ) + end + + def update_credit_note_status(status) + credit_note.refund_status = status + credit_note.refunded_at = Time.current if credit_note.succeeded? + + credit_note.save! + end + + def credit_note_status(status) + return "pending" if PENDING_STATUSES.include?(status) + return "succeeded" if SUCCESS_STATUSES.include?(status) + return "failed" if FAILED_STATUSES.include?(status) + + status + end + + def handle_missing_refund(metadata) + # NOTE: Refund was not initiated by lago + return result unless metadata&.key?(:lago_invoice_id) + + # NOTE: Invoice does not belongs to this lago instance + return result unless Invoice.find_by(id: metadata[:lago_invoice_id]) + + result.not_found_failure!(resource: "gocardless_refund") + end + end + end +end diff --git a/app/services/credit_notes/refunds/stripe_service.rb b/app/services/credit_notes/refunds/stripe_service.rb new file mode 100644 index 0000000..1083269 --- /dev/null +++ b/app/services/credit_notes/refunds/stripe_service.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +module CreditNotes + module Refunds + class StripeService < BaseService + include Customers::PaymentProviderFinder + + INVALID_PAYMENT_METHOD_ERROR = "charge_not_refundable" + + def initialize(credit_note = nil) + @credit_note = credit_note + + super + end + + def create + result.credit_note = credit_note + return result unless should_process_refund? + + stripe_result = create_stripe_refund + + refund = Refund.new( + organization_id: credit_note.organization_id, + credit_note:, + payment:, + payment_provider: payment.payment_provider, + payment_provider_customer: payment_provider_customer(customer), + amount_cents: stripe_result.amount, + amount_currency: stripe_result.currency&.upcase, + status: stripe_result.status, + provider_refund_id: stripe_result.id + ) + refund.save! + + update_credit_note_status(refund.status) + Utils::SegmentTrack.refund_status_changed(refund.status, credit_note.id, organization.id) + + result.refund = refund + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ::Stripe::InvalidRequestError => e + deliver_error_webhook(message: e.message, code: e.code) + update_credit_note_status(:failed) + Utils::ActivityLog.produce(credit_note, "credit_note.refund_failure") + return result if e.code == INVALID_PAYMENT_METHOD_ERROR + + result.service_failure!(code: "stripe_error", message: e.message) + end + + def update_status(provider_refund_id:, status:, metadata: {}) + refund = Refund.find_by(provider_refund_id:) + return handle_missing_refund(metadata) unless refund + + result.refund = refund + @credit_note = result.credit_note = refund.credit_note + return result if refund.credit_note.succeeded? + + refund.update!(status:) + update_credit_note_status(status) + Utils::SegmentTrack.refund_status_changed(refund.status, credit_note.id, organization.id) + + if status.to_sym == :failed + deliver_error_webhook(message: "Payment refund failed", code: nil) + Utils::ActivityLog.produce(credit_note, "credit_note.refund_failure") + result.service_failure!(code: "refund_failed", message: "Refund failed to perform") + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :credit_note + + delegate :organization, :customer, :invoice, to: :credit_note + + def should_process_refund? + return false if !credit_note.refunded? || credit_note.succeeded? || invoice.payment_dispute_lost_at? + + payment.present? + end + + def payment + return @payment if defined?(@payment) + + @payment = if credit_note.invoice.payments.succeeded.present? + credit_note.invoice.payments.succeeded.order(created_at: :desc).first + else + Payment.where(payable_type: "PaymentRequest") + .joins("INNER JOIN invoices_payment_requests ON invoices_payment_requests.payment_request_id = payments.payable_id") + .joins("INNER JOIN payment_requests ON payment_requests.id = invoices_payment_requests.payment_request_id") + .where("invoices_payment_requests.invoice_id = ?", credit_note.invoice_id) + .where(payments: {payable_payment_status: "succeeded"}) + .where(payment_requests: {customer_id: credit_note.customer_id}) + .where(payment_requests: {payment_status: 1}) # 1 is succeeded + .order("payments.created_at DESC") + .first + end + end + + def stripe_api_key + stripe_payment_provider.secret_key + end + + def create_stripe_refund + Stripe::Refund.create( + stripe_refund_payload, + { + api_key: stripe_api_key, + idempotency_key: credit_note.id + } + ) + end + + def stripe_refund_payload + { + payment_intent: payment.provider_payment_id, + amount: credit_note.refund_amount_cents, + reason: stripe_reason, + metadata: { + lago_customer_id: customer.id, + lago_credit_note_id: credit_note.id, + lago_invoice_id: invoice.id + } + } + end + + def stripe_reason + case credit_note.reason.to_sym + when :duplicated_charge + :duplicate + when :product_unsatisfactory, :order_change, :order_cancellation + :requested_by_customer + when :fraudulent_charge + :fraudulent + end + end + + def deliver_error_webhook(message:, code:) + SendWebhookJob.perform_later( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: payment_provider_customer(customer)&.provider_customer_id, + provider_error: { + message:, + error_code: code + } + ) + end + + def update_credit_note_status(status) + credit_note.refund_status = status + credit_note.refunded_at = Time.current if credit_note.succeeded? + credit_note.save! + end + + def handle_missing_refund(metadata) + # NOTE: Refund was not initiated by lago + return result unless metadata&.key?(:lago_invoice_id) + + # NOTE: Invoice does not belongs to this lago instance + return result unless Invoice.find_by(id: metadata[:lago_invoice_id]) + + result.not_found_failure!(resource: "stripe_refund") + end + + def stripe_payment_provider + @stripe_payment_provider ||= payment_provider(customer) + end + end + end +end diff --git a/app/services/credit_notes/update_service.rb b/app/services/credit_notes/update_service.rb new file mode 100644 index 0000000..b1684a1 --- /dev/null +++ b/app/services/credit_notes/update_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module CreditNotes + class UpdateService < BaseService + def initialize(credit_note:, partial_metadata: false, **params) + @params = params&.with_indifferent_access + @credit_note = credit_note + @refund_status = @params[:refund_status] + @partial_metadata = partial_metadata + + super + end + + def call + return result.not_found_failure!(resource: "credit_note") if credit_note.nil? || credit_note.draft? + + ActiveRecord::Base.transaction do + if update_refund_status? + credit_note.refund_status = refund_status + credit_note.refunded_at = Time.current if credit_note.succeeded? + end + + update_metadata! + credit_note.save! if credit_note.changed? + end + + handle_changes + + result.credit_note = credit_note + result + rescue ArgumentError + result.single_validation_failure!(field: :refund_status, error_code: "value_is_invalid") + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :credit_note, :params, :refund_status, :metadata_changed, :partial_metadata + + def update_refund_status? + params.key?(:refund_status) + end + + # @return [Boolean] if the metadata was changed in any way + def update_metadata! + value = params[:metadata]&.then { |m| m.respond_to?(:to_unsafe_h) ? m.to_unsafe_h : m.to_h } + Metadata::UpdateItemService.call!(owner: credit_note, value:, partial: partial_metadata.present?) + @metadata_changed = result.metadata_changed + end + + def handle_changes + if credit_note.previous_changes.key?(:refund_status) + Utils::SegmentTrack.refund_status_changed(credit_note.refund_status, credit_note.id, credit_note.organization.id) + elsif metadata_changed + # Potential place for future tracking of credit note metadata changes + end + end + end +end diff --git a/app/services/credit_notes/validate_item_service.rb b/app/services/credit_notes/validate_item_service.rb new file mode 100644 index 0000000..435ef17 --- /dev/null +++ b/app/services/credit_notes/validate_item_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module CreditNotes + class ValidateItemService < BaseValidator + def valid? + return false unless valid_fee? + + valid_item_amount? + valid_individual_amount? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def item + args[:item] + end + + delegate :credit_note, :fee, to: :item + delegate :invoice, to: :credit_note + + def credited_fee_amount_cents + fee.credit_note_items.sum(:amount_cents) + end + + def refunded_invoice_amount_cents + invoice.credit_notes.finalized.where.not(id: credit_note.id).sum(:refund_amount_cents) + end + + def credited_invoice_amount_cents + invoice.credit_notes.finalized.where.not(id: credit_note.id).sum(:credit_amount_cents) + end + + def offset_amount_cents + invoice.credit_notes.finalized.where.not(id: credit_note.id).sum(:offset_amount_cents) + end + + def invoice_credit_note_total_amount_cents + credited_invoice_amount_cents + refunded_invoice_amount_cents + offset_amount_cents + end + + def total_item_amount_cents + (item.amount_cents + (item.amount_cents * fee.taxes_rate).fdiv(100)).round + end + + def valid_fee? + return true if item.fee.present? + + result.not_found_failure!(resource: "fee") + + false + end + + # NOTE: Check if item amount is not negative + def valid_item_amount? + return true unless item.amount_cents.negative? + + add_error(field: :amount_cents, error_code: "invalid_value") + end + + # NOTE: Check if item amount is less than or equal to fee remaining creditable amount + def valid_individual_amount? + return true if item.amount_cents <= fee.creditable_amount_cents + return true if prepaid_credit_invoice? && (invoice.payment_pending? || invoice.payment_failed?) + + if prepaid_credit_invoice? && wallet_balance_insufficient? + add_error(field: :amount_cents, error_code: "higher_than_wallet_balance") + else + add_error(field: :amount_cents, error_code: "higher_than_remaining_fee_amount") + end + end + + def prepaid_credit_invoice? + invoice.credit? + end + + def wallet_balance_insufficient? + item.amount_cents > invoice.prepaid_credit_fee.creditable_from_wallet_amount_cents + end + + # NOTE: Check if item amount is less than or equal to invoice remaining creditable amount + def valid_global_amount? + return true if total_item_amount_cents <= invoice.fee_total_amount_cents - invoice_credit_note_total_amount_cents + + add_error(field: :amount_cents, error_code: "higher_than_remaining_invoice_amount") + end + end +end diff --git a/app/services/credit_notes/validate_service.rb b/app/services/credit_notes/validate_service.rb new file mode 100644 index 0000000..3c427b2 --- /dev/null +++ b/app/services/credit_notes/validate_service.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +module CreditNotes + class ValidateService < BaseValidator + def valid? + valid_invoice_status? + valid_items_amount? + valid_refund_amount? + valid_credit_amount? + valid_offset_amount? + valid_remaining_invoice_amount? + valid_total_amount_positive? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def credit_note + args[:item] + end + + delegate :invoice, to: :credit_note + + def total_amount_cents + credit_note.credit_amount_cents + + credit_note.refund_amount_cents + + credit_note.offset_amount_cents + end + + def creditable_amount_cents + invoice.fee_total_amount_cents - + credited_invoice_amount_cents - + offset_amount_cents + end + + def refunded_invoice_amount_cents + invoice.credit_notes.finalized.where.not(id: credit_note.id).sum(:refund_amount_cents) + end + + def credited_invoice_amount_cents + invoice.credit_notes.finalized.where.not(id: credit_note.id).sum(:credit_amount_cents) + end + + def offset_amount_cents + invoice.credit_notes.finalized.where.not(id: credit_note.id).sum(:offset_amount_cents) + end + + def invoice_credit_note_total_amount_cents + credited_invoice_amount_cents + refunded_invoice_amount_cents + offset_amount_cents + end + + def precise_total_items_amount_cents + ( + credit_note.items.sum(&:precise_amount_cents) - + credit_note.precise_coupons_adjustment_amount_cents + + credit_note.precise_taxes_amount_cents + ).round + end + + def valid_invoice_status? + if credit_note.refund_amount_cents.positive? + return true if invoice.payment_succeeded? + + if !invoice.payment_succeeded? && + invoice.total_paid_amount_cents == invoice.total_amount_cents && invoice.total_amount_cents > 0 + add_error(field: :refund_amount_cents, error_code: "cannot_refund_unpaid_invoice") + + return false + end + end + + true + end + + def valid_invoice_type? + return unless invoice.credit? + + add_error(field: :base, error_code: "cannot_credit_invoice") + false + end + + # NOTE: Check if total amount matched the items amount + # The comparison takes care of the rounding precision + def valid_items_amount? + return true if (total_amount_cents - precise_total_items_amount_cents).abs <= 1 + + add_error(field: :base, error_code: "does_not_match_item_amounts") + end + + # NOTE: Check if refunded amount is less than or equal to the invoice's paid amount + def valid_refund_amount? + return true if credit_note.refund_amount_cents.zero? + + if invoice.total_paid_amount_cents <= 0 + add_error(field: :refund_amount_cents, error_code: "cannot_refund_unpaid_invoice") + return false + end + + refundable_paid_cents = invoice.total_paid_amount_cents - refunded_invoice_amount_cents + return true if credit_note.refund_amount_cents <= refundable_paid_cents + + add_error(field: :refund_amount_cents, error_code: "higher_than_remaining_invoice_amount") + end + + # NOTE: Check if credited amount is less than or equal to invoice fee amount + def valid_credit_amount? + if invoice.credit? && credit_note.credit_amount_cents > 0 + add_error(field: :credit_amount_cents, error_code: "cannot_credit_invoice") + return false + end + + return true if credit_note.credit_amount_cents <= creditable_amount_cents + + if (credit_note.credit_amount_cents - creditable_amount_cents).abs > 1 + add_error(field: :credit_amount_cents, error_code: "higher_than_remaining_invoice_amount") + end + end + + def valid_offset_amount? + return true if credit_note.offset_amount_cents.zero? + return false unless valid_credit_invoice_application? + + invoice_due_amount_cents = invoice.total_amount_cents - + invoice.total_paid_amount_cents - + offset_amount_cents + + offsettable_amount = [invoice_due_amount_cents, creditable_amount_cents].min + + return true if credit_note.offset_amount_cents <= offsettable_amount + + add_error(field: :offset_amount_cents, error_code: "higher_than_remaining_invoice_amount") + end + + def valid_credit_invoice_application? + return true unless invoice.credit? + + if invoice.total_paid_amount_cents.positive? + add_error(field: :offset_amount_cents, error_code: "cannot_apply_to_paid_invoice") + return false + end + + if credit_note.offset_amount_cents != invoice.total_amount_cents + add_error(field: :offset_amount_cents, error_code: "not_equal_to_total_amount") + return false + end + + true + end + + # NOTE: Check if total amount is less than or equal to invoice fee amount + def valid_remaining_invoice_amount? + remaining = invoice.fee_total_amount_cents - invoice_credit_note_total_amount_cents + return true if total_amount_cents <= remaining + + if (total_amount_cents - remaining).abs > 1 + add_error(field: :base, error_code: "higher_than_remaining_invoice_amount") + end + end + + # NOTE: Check if total amount is greater than 0 + def valid_total_amount_positive? + return true if total_amount_cents > 0 + + add_error(field: :base, error_code: "total_amount_must_be_positive") + end + end +end diff --git a/app/services/credit_notes/void_service.rb b/app/services/credit_notes/void_service.rb new file mode 100644 index 0000000..d0f0802 --- /dev/null +++ b/app/services/credit_notes/void_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module CreditNotes + class VoidService < BaseService + def initialize(credit_note:) + @credit_note = credit_note + + super + end + + def call + return result.not_found_failure!(resource: "credit_note") if credit_note.nil? || credit_note.draft? + + result.credit_note = credit_note + return result.not_allowed_failure!(code: "no_voidable_amount") unless credit_note.voidable? + + credit_note.mark_as_voided! + + result + end + + private + + attr_reader :credit_note + end +end diff --git a/app/services/credits/allocate_prepaid_credits_by_wallets_service.rb b/app/services/credits/allocate_prepaid_credits_by_wallets_service.rb new file mode 100644 index 0000000..8c107da --- /dev/null +++ b/app/services/credits/allocate_prepaid_credits_by_wallets_service.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Credits + class AllocatePrepaidCreditsByWalletsService < BaseService + Result = BaseResult[:wallet_transactions] + + def initialize(invoice:) + @invoice = invoice + + super(nil) + end + + def call + result.wallet_transactions ||= {} + return result if wallets.empty? + + result.wallet_transactions = calculate_wallet_transactions + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :invoice + + delegate :customer, to: :invoice + + def calculate_wallet_transactions + ordered_remaining_amounts = calculate_amounts_for_fees_by_type_and_bm + remaining_invoice_amount = invoice.total_amount_cents + wallets_transactions = {} + + wallets.each do |wallet| + wallet.reload + wallet_fee_transactions = [] + wallet_targets_array = wallet.wallet_targets.map do |wt| + if wt&.billable_metric_id + ["charge", wt.billable_metric_id] + end + end + wallet_types_array = wallet.allowed_fee_types + + ordered_remaining_amounts.each do |fee_key, remaining_amount| + next if remaining_amount <= 0 + + next unless applicable_fee?(fee_key:, targets: wallet_targets_array, types: wallet_types_array, wallet:) + + used_amount = wallet_fee_transactions.sum { |t| t[:amount_cents] } + remaining_wallet_balance = wallet.balance_cents - used_amount + next if remaining_wallet_balance <= 0 + + transaction_amount = [remaining_amount, remaining_wallet_balance, remaining_invoice_amount].min + next if transaction_amount <= 0 + + ordered_remaining_amounts[fee_key] -= transaction_amount + remaining_invoice_amount -= transaction_amount + wallet_fee_transactions << { + fee_key: fee_key, + amount_cents: transaction_amount + } + end + total_amount_cents = wallet_fee_transactions.sum { |t| t[:amount_cents] } + next if total_amount_cents <= 0 + wallets_transactions[wallet] = total_amount_cents + end + wallets_transactions + end + + def calculate_amounts_for_fees_by_type_and_bm + remaining = Hash.new(0) + fees = invoice.persisted? ? invoice.fees.includes(:charge) : invoice.fees + + fees.each do |fee| + next if fee.sub_total_excluding_taxes_amount_cents == 0 + + cap = fee.sub_total_excluding_taxes_amount_cents + + fee.taxes_precise_amount_cents - + fee.precise_credit_notes_amount_cents + + next if cap <= 0 + key = [fee.fee_type, fee.charge&.billable_metric_id] + if fee.organization.events_targeting_wallets_enabled? && fee.charge&.accepts_target_wallet + key << fee.grouped_by&.dig("target_wallet_code") + end + remaining[key] += cap + end + + ordered = remaining.sort_by { |_, v| -v }.to_h + reconcile_remaining_amounts(ordered) + end + + def reconcile_remaining_amounts(ordered_remaining_amounts) + return ordered_remaining_amounts if ordered_remaining_amounts.empty? + + precise_total = ordered_remaining_amounts.values.sum + difference = invoice.total_amount_cents - precise_total + + # Only reconcile small rounding differences (at most 1 cent per fee bucket). + return ordered_remaining_amounts if difference <= 0 + return ordered_remaining_amounts if difference > ordered_remaining_amounts.size + + largest_key = ordered_remaining_amounts.keys.first + ordered_remaining_amounts[largest_key] += difference + ordered_remaining_amounts + end + + def applicable_fee?(fee_key:, targets:, types:, wallet:) + target_wallet_code = fee_key[2] + + # If fee has target_wallet_code, only matching wallet can apply credits + if target_wallet_code.present? + return wallet.code == target_wallet_code + end + + fee_key_without_wallet = fee_key.first(2) + target_match = targets.include?(fee_key_without_wallet) + type_match = types.include?(fee_key.first) + unrestricted_wallet = targets.empty? && types.empty? + + target_match || type_match || unrestricted_wallet + end + + def wallets + @wallets ||= begin + scope = customer.wallets.active.includes(:wallet_targets).with_positive_balance + scope = scope.where(balance_currency: invoice.currency) if invoice.currency.present? + scope.in_application_order + end + end + end +end diff --git a/app/services/credits/applied_coupon_service.rb b/app/services/credits/applied_coupon_service.rb new file mode 100644 index 0000000..804703c --- /dev/null +++ b/app/services/credits/applied_coupon_service.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module Credits + class AppliedCouponService < BaseService + def initialize(invoice:, applied_coupon:) + @invoice = invoice + @applied_coupon = applied_coupon + + super(nil) + end + + def call + if !AppliedCoupons::LockService.new(customer:).locked? + return result.service_failure!(code: "no_lock_acquired", message: "Calling this service without acquiring a lock is not allowed") + end + + return result unless matches_currency? + return result if already_applied? + return result unless fees.any? + + credit_amount = AppliedCoupons::AmountService.call(applied_coupon:, base_amount_cents:).amount + + new_credit = Credit.create!( + organization_id: invoice.organization_id, + invoice:, + applied_coupon:, + amount_cents: credit_amount, + amount_currency: invoice.currency, + before_taxes: true + ) + + weighting_base_amount_cents = base_amount_cents # Ensure that base remains the same during weighting process + fees.reload.each do |fee| + unless weighting_base_amount_cents.zero? + fee.precise_coupons_amount_cents += fee.compute_precise_credit_amount_cents(credit_amount, weighting_base_amount_cents) + end + + fee.precise_coupons_amount_cents = fee.amount_cents if fee.amount_cents < fee.precise_coupons_amount_cents + fee.save! + end + + decrement_frequency_duration_remaining if applied_coupon.recurring? + + if should_terminate_applied_coupon?(credit_amount) + applied_coupon.mark_as_terminated! + elsif applied_coupon.recurring? + applied_coupon.save! + end + + invoice.coupons_amount_cents += new_credit.amount_cents + invoice.sub_total_excluding_taxes_amount_cents -= new_credit.amount_cents + + result.credit = new_credit + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :invoice, :applied_coupon + + delegate :coupon, to: :applied_coupon + delegate :customer, to: :invoice + + def matches_currency? + return true if coupon.percentage? + + applied_coupon.amount_currency == invoice.currency + end + + def already_applied? + invoice.credits.where(applied_coupon_id: applied_coupon.id).exists? + end + + def should_terminate_applied_coupon?(credit_amount) + return false if applied_coupon.forever? + + if applied_coupon.once? + applied_coupon.coupon.percentage? || credit_amount >= applied_coupon.remaining_amount + else + applied_coupon.frequency_duration_remaining <= 0 + end + end + + def decrement_frequency_duration_remaining + applied_coupon.frequency_duration_remaining = [applied_coupon.frequency_duration_remaining.to_i - 1, 0].max + end + + # TODO: ensure targeted amount is right with BM/plan limitation + def base_amount_cents + if applied_coupon.coupon.limited_billable_metrics? || applied_coupon.coupon.limited_plans? + amount = 0 + fees.each do |fee| + amount += fee.amount_cents - fee.precise_coupons_amount_cents + end + + return amount + end + + invoice.sub_total_excluding_taxes_amount_cents + end + + def fees + @fees ||= if applied_coupon.coupon.limited_billable_metrics? + billable_metric_related_fees + elsif applied_coupon.coupon.limited_plans? + plan_related_fees + else + invoice.fees + end + end + + def plan_related_fees + invoice + .fees + .joins(subscription: :plan) + .where(plan: {id: applied_coupon.coupon.parent_and_overriden_plans.map(&:id)}) + end + + def billable_metric_related_fees + invoice + .fees + .joins(charge: :billable_metric) + .where(billable_metric: {id: applied_coupon.coupon.coupon_targets.select(:billable_metric_id)}) + end + end +end diff --git a/app/services/credits/applied_coupons_service.rb b/app/services/credits/applied_coupons_service.rb new file mode 100644 index 0000000..1fe4efe --- /dev/null +++ b/app/services/credits/applied_coupons_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Credits + class AppliedCouponsService < BaseService + def initialize(invoice:) + @invoice = invoice + super + end + + def call + return result if applied_coupons.blank? + return result if invoice.fees_amount_cents.zero? + + result.credits = [] + + ## take an advisory lock on coupons for this customer + # We're not locking individual coupons as that might lead to deadlocks. + # This will also keep the lock for the shortest time possible, otherwise + # we'd have to wait for the transaction to either rollback / commit. + AppliedCoupons::LockService.new(customer:).call do + # reload coupons now that we've acquired the lock + applied_coupons.reload + + applied_coupons.each do |applied_coupon| + break unless invoice.sub_total_excluding_taxes_amount_cents&.positive? + + credit_result = Credits::AppliedCouponService.call(invoice:, applied_coupon:) + credit_result.raise_if_error! + + result.credits << credit_result.credit + end + end + + result.invoice = invoice + result + end + + private + + attr_reader :invoice + + delegate :customer, :currency, to: :invoice + + def applied_coupons + return @applied_coupons if @applied_coupons + + # NOTE: We want to apply first coupons limited to the billable metrics, then the ones limited to the plans + # and finally the ones with no limitation + @applied_coupons = customer + .applied_coupons.active + .joins(:coupon) + .order("coupons.limited_billable_metrics DESC, coupons.limited_plans DESC, applied_coupons.created_at ASC") + end + end +end diff --git a/app/services/credits/applied_prepaid_credits_service.rb b/app/services/credits/applied_prepaid_credits_service.rb new file mode 100644 index 0000000..f5458a8 --- /dev/null +++ b/app/services/credits/applied_prepaid_credits_service.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +module Credits + class AppliedPrepaidCreditsService < BaseService + Result = BaseResult[:prepaid_credit_amount_cents, :wallet_transactions] + + def initialize(invoice:) + @invoice = invoice + + super(nil) + end + + def call + if wallets_already_applied? + return result.service_failure!(code: "already_applied", message: "Prepaid credits already applied") + end + + result.prepaid_credit_amount_cents ||= 0 + result.wallet_transactions ||= [] + + return result if wallets.empty? + + ActiveRecord::Base.transaction do + Customers::LockService.call(customer:, scope: :prepaid_credit) do + # per each wallet we create a single wallet transaction. wallets_transaction_amounts is a hash with wallet and total transaction amount + wallets_transaction_amounts = AllocatePrepaidCreditsByWalletsService.call!(invoice:).wallet_transactions + + wallets_transaction_amounts.each do |wallet, amount_cents| + wallet_transaction = create_wallet_transaction(wallet, amount_cents) + if wallet.traceable? + WalletTransactions::TrackConsumptionService.call!(outbound_wallet_transaction: wallet_transaction) + end + Wallets::Balance::DecreaseService.call(wallet:, wallet_transaction:, skip_refresh: true) + + result.wallet_transactions << wallet_transaction + end + + update_prepaid_credit_amounts(result.wallet_transactions) + Customers::RefreshWalletsService.call(customer:, include_generating_invoices: true) + invoice.save! if invoice.changed? + end + end + + schedule_webhook_notifications(result.wallet_transactions) + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :invoice + + delegate :customer, to: :invoice + + def schedule_webhook_notifications(wallet_transactions) + wallet_transactions.each do |wt| + Utils::ActivityLog.produce_after_commit(wt, "wallet_transaction.created") + SendWebhookJob.perform_after_commit("wallet_transaction.created", wt) + end + end + + def update_prepaid_credit_amounts(wallet_transactions) + return if wallet_transactions.empty? + + total_amount = wallet_transactions.sum(&:amount_cents) + result.prepaid_credit_amount_cents += total_amount + invoice.prepaid_credit_amount_cents += total_amount + + calculate_prepaid_credit_breakdown(wallet_transactions) + end + + def calculate_prepaid_credit_breakdown(wallet_transactions) + return unless invoice.customer.wallets.all?(&:traceable?) + + granted_amount = 0 + purchased_amount = 0 + + consumptions = WalletTransactionConsumption + .where(outbound_wallet_transaction_id: wallet_transactions.map(&:id)) + .includes(:inbound_wallet_transaction) + + consumptions.each do |consumption| + if consumption.inbound_wallet_transaction.granted? + granted_amount += consumption.consumed_amount_cents + else + purchased_amount += consumption.consumed_amount_cents + end + end + + invoice.prepaid_granted_credit_amount_cents = granted_amount if granted_amount > 0 + invoice.prepaid_purchased_credit_amount_cents = purchased_amount if purchased_amount > 0 + end + + def create_wallet_transaction(wallet, amount_cents) + wallet_credit = WalletCredit.from_amount_cents(wallet:, amount_cents:) + + result = WalletTransactions::CreateService.call!( + wallet:, + wallet_credit:, + invoice_id: invoice.id, + transaction_type: :outbound, + status: :settled, + settled_at: Time.current, + transaction_status: :invoiced + ) + result.wallet_transaction + end + + def wallets + @wallets ||= begin + scope = customer.wallets.active.includes(:wallet_targets).with_positive_balance + scope = scope.where(balance_currency: invoice.currency) if invoice.currency.present? + scope.in_application_order + end + end + + def wallets_already_applied? + return false unless invoice + return false unless invoice.persisted? + + WalletTransaction.exists?(invoice_id: invoice.id, wallet_id: wallets.map(&:id)) + end + end +end diff --git a/app/services/credits/credit_note_service.rb b/app/services/credits/credit_note_service.rb new file mode 100644 index 0000000..00e8a4c --- /dev/null +++ b/app/services/credits/credit_note_service.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Credits + class CreditNoteService < BaseService + def initialize(invoice:, context: nil) + @invoice = invoice + @context = context + + super(nil) + end + + def call + return result if already_applied? + + result.credits = [] + + if context == :preview + apply_credits + else + ActiveRecord::Base.transaction do + CreditNotes::LockService.new(customer:).call do + apply_credits + end + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :invoice, :context + + delegate :customer, to: :invoice + + def apply_credits + remaining_invoice_amount = invoice.total_amount_cents + + credit_notes.each do |credit_note| + credit_note.reload unless context == :preview + + credit_amount = compute_credit_amount(credit_note, remaining_invoice_amount) + next unless credit_amount.positive? + + # NOTE: create a new credit line on the invoice + credit = Credit.new( + organization_id: invoice.organization_id, + invoice:, + credit_note:, + amount_cents: credit_amount, + amount_currency: invoice.currency, + before_taxes: false + ) + credit.save! unless context == :preview + + apply_credit_to_fees(credit, remaining_invoice_amount) unless context == :preview + + # NOTE: Consume remaining credit on the credit note + update_remaining_credit(credit_note, credit_amount) unless context == :preview + remaining_invoice_amount -= credit_amount + + result.credits << credit + invoice.credit_notes_amount_cents += credit.amount_cents + + # NOTE: Invoice amount is fully covered by the credit notes + break if remaining_invoice_amount.zero? + end + end + + def credit_notes + customer.credit_notes + .finalized + .available + .where(total_amount_currency: invoice.currency) + .where.not(invoice_id: invoice.id) + .order(created_at: :asc) + .to_a + end + + def already_applied? + invoice.credits.where.not(credit_note_id: nil).exists? + end + + def compute_credit_amount(credit_note, remaining_invoice_amount) + if credit_note.balance_amount_cents > remaining_invoice_amount + remaining_invoice_amount + else + credit_note.balance_amount_cents + end + end + + def update_remaining_credit(credit_note, consumed_credit) + credit_note.update!( + balance_amount_cents: credit_note.balance_amount_cents - consumed_credit + ) + + credit_note.consumed! if credit_note.balance_amount_cents.zero? + end + + def apply_credit_to_fees(credit, remaining_invoice_amount) + invoice.fees.reload.each do |fee| + fee_amount_after_tax = fee.amount_cents - fee.precise_coupons_amount_cents + fee.taxes_amount_cents + + fee.precise_credit_notes_amount_cents += ( + credit.amount_cents * (fee_amount_after_tax - fee.precise_credit_notes_amount_cents) + ).fdiv(remaining_invoice_amount) + + fee.precise_credit_notes_amount_cents = fee_amount_after_tax if fee_amount_after_tax < fee.precise_credit_notes_amount_cents + fee.save! + end + end + end +end diff --git a/app/services/credits/progressive_billing_service.rb b/app/services/credits/progressive_billing_service.rb new file mode 100644 index 0000000..c31cbe0 --- /dev/null +++ b/app/services/credits/progressive_billing_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Credits + class ProgressiveBillingService < BaseService + def initialize(invoice:) + @invoice = invoice + super + end + + def call + result.credits = [] + + invoice.invoice_subscriptions.each do |invoice_subscription| + subscription = invoice_subscription.subscription + + # We can use invoice_subscription.charges_from_datetime as we're looking for the progressive billing invoices + # that are associated to a subscription with boundaries charges_from_datetime <= timestamp; charges_to_datetime > timestamp + progressive_billed_result = Subscriptions::ProgressiveBilledAmount.call(subscription:, + timestamp: invoice_subscription.charges_from_datetime).raise_if_error! + progressive_billing_invoice = progressive_billed_result.progressive_billing_invoice + + next unless progressive_billing_invoice + + total_charges_amount = invoice + .fees + .charge + .where(subscription:) + .where(charge_id: progressive_billing_invoice.fees.charge.pluck(:charge_id)) + .sum(:amount_cents) + + # Don't be tempted to calculate the credit amount yourself, you have to use the result from this service. + amount_to_credit = progressive_billed_result.to_credit_amount + + if amount_to_credit > total_charges_amount + CreditNotes::CreateFromProgressiveBillingInvoice.call( + progressive_billing_invoice:, amount: amount_to_credit - total_charges_amount + ).raise_if_error! + + amount_to_credit = total_charges_amount + end + + if amount_to_credit.positive? + credit = Credit.create!( + organization_id: invoice.organization_id, + invoice:, + progressive_billing_invoice:, + amount_cents: amount_to_credit, + amount_currency: invoice.currency, + before_taxes: true + ) + + apply_credit_to_fees(progressive_billing_invoice) + + invoice.sub_total_excluding_taxes_amount_cents -= credit.amount_cents + invoice.progressive_billing_credit_amount_cents += credit.amount_cents + result.credits << credit + end + end + result + end + + private + + attr_reader :invoice + + def apply_credit_to_fees(progressive_billing_invoice) + invoice_fees = invoice.fees.charge.to_a + progressive_billing_invoice.fees.charge.each do |progressive_fee| + fee = invoice_fees.find { |f| + f.charge_id == progressive_fee.charge_id && + f.charge_filter_id == progressive_fee.charge_filter_id && + f.grouped_by == progressive_fee.grouped_by + } + next unless fee + + fee.precise_coupons_amount_cents += progressive_fee.amount_cents + fee.precise_coupons_amount_cents = fee.amount_cents if fee.amount_cents < fee.precise_coupons_amount_cents + fee.save! + end + end + end +end diff --git a/app/services/customer_portal/customer_update_service.rb b/app/services/customer_portal/customer_update_service.rb new file mode 100644 index 0000000..2f9602c --- /dev/null +++ b/app/services/customer_portal/customer_update_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module CustomerPortal + class CustomerUpdateService < BaseService + def initialize(customer:, args:) + @customer = customer + @args = args + + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + + ActiveRecord::Base.transaction do + original_tax_values = customer.slice(:tax_identification_number, :zipcode, :country).symbolize_keys + + customer.customer_type = args[:customer_type] if args.key?(:customer_type) + customer.name = args[:name] if args.key?(:name) + customer.firstname = args[:firstname] if args.key?(:firstname) + customer.lastname = args[:lastname] if args.key?(:lastname) + customer.legal_name = args[:legal_name] if args.key?(:legal_name) + customer.tax_identification_number = args[:tax_identification_number] if args.key?(:tax_identification_number) + customer.email = args[:email] if args.key?(:email) + + customer.document_locale = args[:document_locale] if args.key?(:document_locale) + + customer.address_line1 = args[:address_line1] if args.key?(:address_line1) + customer.address_line2 = args[:address_line2] if args.key?(:address_line2) + customer.zipcode = args[:zipcode] if args.key?(:zipcode) + customer.city = args[:city] if args.key?(:city) + customer.state = args[:state] if args.key?(:state) + customer.country = args[:country]&.upcase if args.key?(:country) + + shipping_address = args[:shipping_address]&.to_h || {} + customer.shipping_address_line1 = shipping_address[:address_line1] if shipping_address.key?(:address_line1) + customer.shipping_address_line2 = shipping_address[:address_line2] if shipping_address.key?(:address_line2) + customer.shipping_zipcode = shipping_address[:zipcode] if shipping_address.key?(:zipcode) + customer.shipping_city = shipping_address[:city] if shipping_address.key?(:city) + customer.shipping_state = shipping_address[:state] if shipping_address.key?(:state) + customer.shipping_country = shipping_address[:country]&.upcase if shipping_address.key?(:country) + + customer.save! + customer.reload + + tax_codes = [] + # This service does not return a 'result' object but a string + eu_tax_code_result = Customers::EuAutoTaxesService.call( + customer:, + new_record: false, + tax_attributes_changed: original_tax_values.any? { |key, value| args.key?(key) && args[key] != value } + ) + tax_codes << eu_tax_code_result.tax_code if eu_tax_code_result.success? + + if tax_codes.present? + taxes_result = Customers::ApplyTaxesService.call(customer:, tax_codes:) + taxes_result.raise_if_error! + end + end + + result.customer = customer + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :customer, :args + end +end diff --git a/app/services/customer_portal/generate_url_service.rb b/app/services/customer_portal/generate_url_service.rb new file mode 100644 index 0000000..635e338 --- /dev/null +++ b/app/services/customer_portal/generate_url_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module CustomerPortal + class GenerateUrlService < BaseService + def initialize(customer:) + @customer = customer + + super + end + + def call + return result.not_found_failure!(resource: "customer") if customer.blank? + + public_authenticator = ActiveSupport::MessageVerifier.new(ENV["SECRET_KEY_BASE"]) + message = public_authenticator.generate(customer.id, expires_in: 12.hours) + + result.url = "#{ENV["LAGO_FRONT_URL"]}/customer-portal/#{message}" + + result + end + + private + + attr_reader :customer + end +end diff --git a/app/services/customers/apply_taxes_service.rb b/app/services/customers/apply_taxes_service.rb new file mode 100644 index 0000000..faba1ce --- /dev/null +++ b/app/services/customers/apply_taxes_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Customers + class ApplyTaxesService < BaseService + def initialize(customer:, tax_codes:) + @customer = customer + @tax_codes = tax_codes + + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + return result.not_found_failure!(resource: "tax") if (tax_codes - taxes.pluck(:code)).present? + + customer.applied_taxes.where( + tax_id: customer.taxes.where.not(code: tax_codes).pluck(:id) + ).destroy_all + + result.applied_taxes = tax_codes.map do |tax_code| + customer.applied_taxes + .create_with(organization_id: customer.organization_id) + .find_or_create_by!(tax: taxes.find_by(code: tax_code)) + end + + customer.invoices.draft.update_all(ready_to_be_refreshed: true) # rubocop:disable Rails/SkipsModelValidations + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :customer, :tax_codes + + def taxes + @taxes ||= customer.organization.taxes.where(code: tax_codes) + end + end +end diff --git a/app/services/customers/concerns/eu_tax_code_resolver.rb b/app/services/customers/concerns/eu_tax_code_resolver.rb new file mode 100644 index 0000000..2419444 --- /dev/null +++ b/app/services/customers/concerns/eu_tax_code_resolver.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Customers + module Concerns + module EuTaxCodeResolver + private + + def billing_country_code + @billing_country_code ||= customer.billing_entity.country + end + + def process_vies_tax(customer_vies) + return "lago_eu_reverse_charge" unless billing_country_code.casecmp?(customer_vies[:country_code]) + + standard_code = "lago_eu_#{billing_country_code.downcase}_standard" + return standard_code if customer.zipcode.blank? + return standard_code if applicable_tax_exceptions(country_code: customer_vies[:country_code]).blank? + + exception_code = applicable_tax_exceptions(country_code: customer_vies[:country_code]).first["name"].parameterize.underscore + "lago_eu_#{customer_vies[:country_code].downcase}_exception_#{exception_code}" + end + + def process_not_vies_tax + return "lago_eu_#{billing_country_code.downcase}_standard" if customer.country.blank? + return "lago_eu_#{customer.country.downcase}_standard" if eu_countries_code.include?(customer.country.upcase) + + "lago_eu_tax_exempt" + end + + def eu_countries_code + LagoEuVat::Rate.country_codes + end + + def applicable_tax_exceptions(country_code:) + @applicable_tax_exceptions ||= eu_country_exceptions(country_code:).select do |exception| + customer.zipcode.match?(exception["postcode"]) + end + end + + def eu_country_exceptions(country_code:) + @eu_country_exceptions ||= LagoEuVat::Rate.country_rates(country_code:)[:exceptions] + end + + def is_valid_vat_number?(vat_number) + ::Valvat::Syntax.validate(vat_number) + end + end + end +end diff --git a/app/services/customers/create_service.rb b/app/services/customers/create_service.rb new file mode 100644 index 0000000..d874fbb --- /dev/null +++ b/app/services/customers/create_service.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +module Customers + class CreateService < BaseService + include Customers::PaymentProviderFinder + + Result = BaseResult[:customer] + + def initialize(**args) + @organization = Organization.find_by(id: args[:organization_id]) + @args = args + super + end + + activity_loggable( + action: "customer.created", + record: -> { result.customer } + ) + + def call + return result.not_found_failure!(resource: "organization") unless organization + + billing_entity = BillingEntities::ResolveService.call( + organization:, billing_entity_code: args[:billing_entity_code] + ).raise_if_error!.billing_entity + + billing_configuration = args[:billing_configuration]&.to_h || {} + shipping_address = args[:shipping_address]&.to_h || {} + + unless valid_metadata_count?(metadata: args[:metadata]) + return result.single_validation_failure!( + field: :metadata, + error_code: "invalid_count" + ) + end + + customer = billing_entity.customers.new( + organization_id: organization.id, + external_id: args[:external_id], + name: args[:name], + country: args[:country]&.upcase, + address_line1: args[:address_line1], + address_line2: args[:address_line2], + state: args[:state], + zipcode: args[:zipcode], + shipping_address_line1: shipping_address[:address_line1], + shipping_address_line2: shipping_address[:address_line2], + shipping_country: shipping_address[:country]&.upcase, + shipping_state: shipping_address[:state], + shipping_zipcode: shipping_address[:zipcode], + shipping_city: shipping_address[:city], + email: args[:email], + city: args[:city], + url: args[:url], + phone: args[:phone], + logo_url: args[:logo_url], + legal_name: args[:legal_name], + legal_number: args[:legal_number], + net_payment_term: args[:net_payment_term], + external_salesforce_id: args[:external_salesforce_id], + payment_provider: args[:payment_provider], + payment_provider_code: args[:payment_provider_code], + currency: args[:currency], + document_locale: billing_configuration[:document_locale], + subscription_invoice_issuing_date_anchor: billing_configuration[:subscription_invoice_issuing_date_anchor], + subscription_invoice_issuing_date_adjustment: billing_configuration[:subscription_invoice_issuing_date_adjustment], + tax_identification_number: args[:tax_identification_number], + firstname: args[:firstname], + lastname: args[:lastname], + customer_type: args[:customer_type] + ) + + if customer&.organization&.revenue_share_enabled? + customer.account_type = args[:account_type] if args.key?(:account_type) + customer.exclude_from_dunning_campaign = customer.partner_account? + end + + if args.key?(:finalize_zero_amount_invoice) + customer.finalize_zero_amount_invoice = args[:finalize_zero_amount_invoice] + end + + assign_premium_attributes(customer, args) + + ActiveRecord::Base.transaction do + customer.save! + + eu_tax_code_result = Customers::EuAutoTaxesService.call(customer:, new_record: true, tax_attributes_changed: true) + + if eu_tax_code_result.success? + args[:tax_codes] ||= [] + args[:tax_codes] = (args[:tax_codes] + [eu_tax_code_result.tax_code]).uniq + end + + if args[:tax_codes].present? + taxes_result = Customers::ApplyTaxesService.call(customer:, tax_codes: args[:tax_codes]) + taxes_result.raise_if_error! + end + + args[:metadata].each { |m| create_metadata(customer:, args: m) } if args[:metadata].present? + end + + # NOTE: handle configuration for configured payment providers + billing_configuration = args[:provider_customer]&.to_h&.merge( + payment_provider: args[:payment_provider], + payment_provider_code: args[:payment_provider_code] + ) + create_billing_configuration(customer, billing_configuration) + + result.customer = customer + + IntegrationCustomers::CreateOrUpdateBatchService.call( + integration_customers: args[:integration_customers], + customer: result.customer, + new_customer: true + ) + + SendWebhookJob.perform_later("customer.created", customer) + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :args, :organization + + def valid_metadata_count?(metadata:) + return true if metadata.blank? + return true if metadata.count <= ::Metadata::CustomerMetadata::COUNT_PER_CUSTOMER + + false + end + + def create_metadata(customer:, args:) + customer.metadata.create!( + organization_id: organization.id, + key: args[:key], + value: args[:value], + display_in_invoice: args[:display_in_invoice] || false + ) + end + + def assign_premium_attributes(customer, args) + return unless License.premium? + + customer.timezone = args[:timezone] if args.key?(:timezone) + customer.invoice_grace_period = args[:invoice_grace_period] if args.key?(:invoice_grace_period) + end + + def create_billing_configuration(customer, billing_configuration = {}) + return if billing_configuration.blank? || (api_context? && billing_configuration[:payment_provider].nil?) + + create_provider_customer = billing_configuration[:sync_with_provider] + create_provider_customer ||= billing_configuration[:provider_customer_id] + return unless create_provider_customer + + if api_context? + customer.payment_provider = billing_configuration[:payment_provider] + + payment_provider_result = PaymentProviders::FindService.new( + organization_id: customer.organization_id, + code: billing_configuration[:payment_provider_code].presence, + payment_provider_type: customer.payment_provider + ).call + payment_provider_result.raise_if_error! + + customer.payment_provider_code = payment_provider_result.payment_provider.code + customer.save! + end + + create_or_update_provider_customer(customer, billing_configuration) + end + + def create_or_update_provider_customer(customer, billing_configuration = {}) + PaymentProviders::CreateCustomerFactory.new_instance( + provider: billing_configuration[:payment_provider] || customer.payment_provider, + customer:, + payment_provider_id: payment_provider(customer)&.id, + params: billing_configuration, + async: !(billing_configuration || {})[:sync] + ).call.raise_if_error! + end + end +end diff --git a/app/services/customers/destroy_service.rb b/app/services/customers/destroy_service.rb new file mode 100644 index 0000000..f94fac8 --- /dev/null +++ b/app/services/customers/destroy_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Customers + class DestroyService < BaseService + def initialize(customer:) + @customer = customer + + super + end + + activity_loggable( + action: "customer.deleted", + record: -> { customer } + ) + + def call + return result.not_found_failure!(resource: "customer") unless customer + + customer.discard! + + Customers::TerminateRelationsJob.perform_later(customer_id: customer.id) + + result.customer = customer + result + end + + private + + attr_reader :customer + end +end diff --git a/app/services/customers/eu_auto_taxes_service.rb b/app/services/customers/eu_auto_taxes_service.rb new file mode 100644 index 0000000..21807bf --- /dev/null +++ b/app/services/customers/eu_auto_taxes_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Customers + class EuAutoTaxesService < BaseService + include Customers::Concerns::EuTaxCodeResolver + + Result = BaseResult[:tax_code] + + B2B_ONLY_TERRITORY_COUNTRIES = %w[FR].freeze + + def initialize(customer:, new_record:, tax_attributes_changed:) + @customer = customer + @new_record = new_record + @tax_attributes_changed = tax_attributes_changed + + super + end + + def call + return result.not_allowed_failure!(code: "eu_tax_not_applicable") unless should_apply_eu_taxes? + + territory_tax_code = detect_special_territory + if territory_tax_code + result.tax_code = territory_tax_code + delete_pending_vies_check_if_exists + return result + end + + if customer.tax_identification_number.present? + schedule_async_vies_check + return result.service_failure!(code: "vies_check_pending", message: "VIES check scheduled asynchronously") + end + + result.tax_code = process_not_vies_tax + delete_pending_vies_check_if_exists + result + end + + private + + attr_reader :customer, :tax_attributes_changed, :new_record + + def detect_special_territory + country_code = customer.country&.upcase + return if country_code.blank? || customer.zipcode.blank? + + tax_exception = find_territory_tax_exception(country_code) + return unless tax_exception + + territory_tax_code(country_code, tax_exception) + end + + def find_territory_tax_exception(country_code) + return unless eu_countries_code.include?(country_code) + + tax_exceptions = LagoEuVat::Rate.country_rates(country_code:)[:exceptions] + return if tax_exceptions.blank? + + normalized_zip = customer.zipcode.gsub(/\s/, "") + tax_exceptions.find { |tax_exception| normalized_zip.match?(tax_exception["postcode"]) } + end + + def territory_tax_code(country_code, tax_exception) + return if B2B_ONLY_TERRITORY_COUNTRIES.include?(country_code) && !is_b2b? + + exception_code = tax_exception["name"].parameterize.underscore + "lago_eu_#{country_code.downcase}_exception_#{exception_code}" + end + + def is_b2b? + customer.tax_identification_number.present? && is_valid_vat_number?(customer.tax_identification_number) + end + + def should_apply_eu_taxes? + return false unless customer.billing_entity.eu_tax_management + return true if new_record + + non_existing_eu_taxes = customer.taxes.where("code ILIKE ?", "lago_eu%").none? + + non_existing_eu_taxes || tax_attributes_changed + end + + def schedule_async_vies_check + PendingViesCheck.find_or_initialize_by(customer:).update!( + organization: customer.organization, + billing_entity: customer.billing_entity, + tax_identification_number: customer.tax_identification_number, + attempts_count: 0, + last_attempt_at: nil, + last_error_type: nil, + last_error_message: nil + ) + + Customers::ViesCheckJob.perform_after_commit(customer) + end + + def delete_pending_vies_check_if_exists + customer.pending_vies_check&.destroy! + end + end +end diff --git a/app/services/customers/failed_to_acquire_lock.rb b/app/services/customers/failed_to_acquire_lock.rb new file mode 100644 index 0000000..2120ccb --- /dev/null +++ b/app/services/customers/failed_to_acquire_lock.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module Customers + class FailedToAcquireLock < StandardError; end +end diff --git a/app/services/customers/generate_checkout_url_service.rb b/app/services/customers/generate_checkout_url_service.rb new file mode 100644 index 0000000..fbb54f7 --- /dev/null +++ b/app/services/customers/generate_checkout_url_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Customers + class GenerateCheckoutUrlService < BaseService + def initialize(customer:) + @customer = customer + @provider_customer = customer&.provider_customer + + super + end + + def call + return result.not_found_failure!(resource: "customer") if customer.blank? + + if provider_customer.blank? + return result.single_validation_failure!( + error_code: "no_linked_payment_provider" + ) + end + + PaymentProviderCustomers::Factory.new_instance(provider_customer:).generate_checkout_url(send_webhook: false) + end + + private + + attr_reader :customer, :provider_customer + end +end diff --git a/app/services/customers/lock_service.rb b/app/services/customers/lock_service.rb new file mode 100644 index 0000000..529f66d --- /dev/null +++ b/app/services/customers/lock_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Customers + # Acquires a PostgreSQL advisory lock scoped to a customer to prevent concurrent operations. + # + # Usage in jobs: + # retry_on Customers::FailedToAcquireLock, ActiveRecord::StaleObjectError, + # attempts: MAX_LOCK_RETRY_ATTEMPTS, wait: random_lock_retry_delay + # + # - FailedToAcquireLock: Raised when the advisory lock cannot be acquired within the timeout. + # - StaleObjectError: Even with the advisory lock, other code paths (e.g., wallet top-ups via + # IncreaseService) can update wallets without acquiring this lock. Since Wallet uses optimistic + # locking (lock_version), concurrent updates will raise StaleObjectError. + # + class LockService < BaseService + ACQUIRE_LOCK_TIMEOUT = 5.seconds + VALID_SCOPES = %i[prepaid_credit].freeze + + def initialize(customer:, scope:, timeout_seconds: ACQUIRE_LOCK_TIMEOUT, transaction: true) + @customer = customer + @scope = scope + @timeout_seconds = timeout_seconds + @transaction = transaction + + validate_scope! + + super + end + + def call + Customer.with_advisory_lock!(lock_key, timeout_seconds:, transaction:) do + yield + end + rescue WithAdvisoryLock::FailedToAcquireLock + raise FailedToAcquireLock, "Failed to acquire lock #{lock_key}" + end + + def locked? + ActiveRecord::Base.advisory_lock_exists?(lock_key) + end + + private + + attr_reader :customer, :scope, :timeout_seconds, :transaction + + def validate_scope! + return if VALID_SCOPES.include?(scope) + + raise ArgumentError, "Invalid scope: #{scope}. Valid scopes are: #{VALID_SCOPES.join(", ")}" + end + + def lock_key + "customer-#{customer.id}-#{scope}" + end + end +end diff --git a/app/services/customers/manage_invoice_custom_sections_service.rb b/app/services/customers/manage_invoice_custom_sections_service.rb new file mode 100644 index 0000000..e61e7dc --- /dev/null +++ b/app/services/customers/manage_invoice_custom_sections_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Customers + class ManageInvoiceCustomSectionsService < BaseService + Result = BaseResult[:customer] + + def initialize(customer:, skip_invoice_custom_sections:, section_ids: nil, section_codes: nil) + @customer = customer + @section_ids = section_ids + @section_codes = section_codes + @skip_invoice_custom_sections = skip_invoice_custom_sections + + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + return fail_with_double_selection if !section_ids.nil? && !section_codes.nil? + return fail_with_invalid_params if skip_invoice_custom_sections && !(section_ids || section_codes).nil? + + ActiveRecord::Base.transaction do + if !skip_invoice_custom_sections.nil? + customer.selected_invoice_custom_sections = InvoiceCustomSection.none if !!skip_invoice_custom_sections + customer.skip_invoice_custom_sections = skip_invoice_custom_sections + end + + if !section_ids.nil? || !section_codes.nil? + customer.skip_invoice_custom_sections = false + + assign_selected_sections unless selected_sections_match? + end + customer.save! + end + + result.customer = customer + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :customer, :section_ids, :skip_invoice_custom_sections, :section_codes + + def fail_with_double_selection + result.validation_failure!(errors: {invoice_custom_sections: ["section_ids_and_section_codes_sent_together"]}) + end + + def fail_with_invalid_params + result.validation_failure!(errors: {invoice_custom_sections: ["skip_sections_and_selected_ids_sent_together"]}) + end + + def selected_sections_match? + customer.selected_invoice_custom_sections.ids == section_ids || + customer.selected_invoice_custom_sections.map(&:code) == section_codes + end + + def assign_selected_sections + # Note: when assigning billing entity's sections, an empty array will be sent + selected_sections = if section_ids + customer.organization.invoice_custom_sections.where(id: section_ids) + elsif section_codes + customer.organization.invoice_custom_sections.where(code: section_codes) + else + InvoiceCustomSection.none + end + + system_generated_sections = customer.system_generated_invoice_custom_sections + + # Clear existing manual sections + customer.applied_invoice_custom_sections.where.not(invoice_custom_section: system_generated_sections).destroy_all + + # Create new join records for selected sections + selected_sections.each do |section| + customer.applied_invoice_custom_sections.create!( + organization_id: customer.organization_id, + billing_entity_id: customer.billing_entity_id, + invoice_custom_section: section + ) + end + end + end +end diff --git a/app/services/customers/metadata/update_service.rb b/app/services/customers/metadata/update_service.rb new file mode 100644 index 0000000..0d1d10d --- /dev/null +++ b/app/services/customers/metadata/update_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Customers + module Metadata + class UpdateService < BaseService + def initialize(customer:, params:) + @customer = customer + @params = params + super + end + + def call + created_metadata_ids = [] + + hash_metadata = params.map { |m| m.to_h.deep_symbolize_keys } + hash_metadata.each do |payload_metadata| + metadata = customer.metadata.find_by(id: payload_metadata[:id]) + payload_metadata[:display_in_invoice] = payload_metadata[:display_in_invoice] || false + + if metadata + metadata.update!(payload_metadata) + + next + end + + created_metadata = create_metadata(payload_metadata) + created_metadata_ids.push(created_metadata.id) + end + + # NOTE: Delete metadata that are no more linked to the customer + sanitize_metadata(hash_metadata, created_metadata_ids) + + result.customer = customer + result + end + + private + + attr_reader :customer, :params + + def create_metadata(payload) + customer.metadata.create!( + organization_id: customer.organization_id, + key: payload[:key], + value: payload[:value], + display_in_invoice: payload[:display_in_invoice] + ) + end + + def sanitize_metadata(args_metadata, created_metadata_ids) + updated_metadata_ids = args_metadata.reject { |m| m[:id].nil? }.map { |m| m[:id] } + not_needed_ids = customer.metadata.pluck(:id) - updated_metadata_ids - created_metadata_ids + + customer.metadata.where(id: not_needed_ids).destroy_all + end + end + end +end diff --git a/app/services/customers/payment_provider_finder.rb b/app/services/customers/payment_provider_finder.rb new file mode 100644 index 0000000..c453d4d --- /dev/null +++ b/app/services/customers/payment_provider_finder.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Customers + module PaymentProviderFinder + extend ActiveSupport::Concern + + included do + def payment_provider(customer) + payment_provider_result = PaymentProviders::FindService.new( + organization_id: customer.organization_id, + code: customer.payment_provider_code, + payment_provider_type: customer.payment_provider + ).call + + return nil if payment_provider_result.error&.code == "payment_provider_not_found" + + payment_provider_result.raise_if_error! + payment_provider_result.payment_provider + end + + def payment_provider_customer(customer) + return nil unless customer + + PaymentProviderCustomers::BaseCustomer.with_discarded.find_by(customer_id: customer.id) + end + end + end +end diff --git a/app/services/customers/refresh_wallets_service.rb b/app/services/customers/refresh_wallets_service.rb new file mode 100644 index 0000000..50ccf97 --- /dev/null +++ b/app/services/customers/refresh_wallets_service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Customers + class RefreshWalletsService < BaseService + Result = BaseResult[:usage_amount_cents, :wallets, :allocation_rules] + + def initialize(customer:, include_generating_invoices: false, target_wallet_ids: nil) + @customer = customer + @include_generating_invoices = include_generating_invoices + @target_wallet_ids = target_wallet_ids + + super + end + + def call + usage_amount_cents = customer.active_subscriptions.map do |subscription| + invoice = ::Invoices::CustomerUsageService.call!(customer:, subscription:, usage_filters: UsageFilters::WITHOUT_PRESENTATION_FILTER).invoice + + billed_progressive_invoice_subscriptions = ::Subscriptions::ProgressiveBilledAmount + .call(subscription:, include_generating_invoices:) + .invoice_subscriptions + + { + billed_progressive_invoice_subscriptions:, + invoice:, + subscription: + } + end + + @allocation_rules = Wallets::BuildAllocationRulesService.call!(customer:).allocation_rules + + # we need to get both: ALL fees and wallets_applicable_on_fees ({ fee_key => wallet_id, ... }) per each fee type + # to not rebuild wallet assigning per each fee when going per each separate wallet + usage_fees = usage_amount_cents.flat_map { |usage| usage[:invoice].fees } + wallets_applicable_on_usage_fees = assign_wallet_per_fee(usage_fees) # { usage_fee_key => wallet_id } + + draft_invoice_fees = customer.invoices.draft.where.not(total_amount_cents: 0).includes(fees: :charge).flat_map(&:fees) + wallets_applicable_on_draft_fees = assign_wallet_per_fee(draft_invoice_fees) # { draft_fee_key => wallet_id } + + progressive_billing_fees = usage_amount_cents.flat_map { |usage| usage[:billed_progressive_invoice_subscriptions].flat_map { it.invoice.fees } } + wallets_applicable_on_pb_fees = assign_wallet_per_fee(progressive_billing_fees) # { pb_fee_key => wallet_id } + + pay_in_advance_fees = usage_amount_cents.flat_map { |usage| usage[:invoice].fees.select { |f| f.charge.pay_in_advance? } } + wallets_applicable_on_adv_fees = assign_wallet_per_fee(pay_in_advance_fees) # { adv_fee_key => wallet_id } + + wallets_to_process = customer.wallets.active.includes(:recurring_transaction_rules) + wallets_to_process = wallets_to_process.where(id: target_wallet_ids) if target_wallet_ids + wallets_to_process.find_in_batches(batch_size: 100) do |wallets| + wallets.each do |wallet| + Wallets::Balance::RefreshOngoingUsageService.call!( + wallet:, + usage_amount_cents:, + skip_single_wallet_update: true, + current_usage_fees: applicable_fees(usage_fees, wallets_applicable_on_usage_fees, wallet), + draft_invoices_fees: applicable_fees(draft_invoice_fees, wallets_applicable_on_draft_fees, wallet), + progressive_billing_fees: applicable_fees(progressive_billing_fees, wallets_applicable_on_pb_fees, wallet), + pay_in_advance_fees: applicable_fees(pay_in_advance_fees, wallets_applicable_on_adv_fees, wallet) + ) + end + end + wallets_to_process.update_all(last_ongoing_balance_sync_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + + customer.update!(awaiting_wallet_refresh: false) + + result.usage_amount_cents = usage_amount_cents + result.allocation_rules = allocation_rules + result.wallets = customer.wallets.active.reload + result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :customer, :include_generating_invoices, :allocation_rules, :target_wallet_ids + + def assign_wallet_per_fee(fees) + fee_wallet = {} + fee_targeting_wallets_enabled = customer.organization.events_targeting_wallets_enabled? + + fees.each do |fee| + key = fee.item_key + + if fee_targeting_wallets_enabled && fee.charge&.accepts_target_wallet && fee&.grouped_by&.dig("target_wallet_code").present? + targeted_wallet = customer.wallets.active.where(code: fee.grouped_by["target_wallet_code"]).ids.first + fee_wallet[key] = targeted_wallet + next if targeted_wallet + end + + applicable_wallets = Wallets::FindApplicableOnFeesService + .call!(allocation_rules: allocation_rules, fee:, customer_id: customer.id, fee_targeting_wallets_enabled:) + .top_priority_wallet + fee_wallet[key] = applicable_wallets.presence + end + + fee_wallet + end + + def applicable_fees(fees, fee_map, wallet) + fees.select { |fee| fee_map[fee.item_key] == wallet.id } + end + end +end diff --git a/app/services/customers/terminate_relations_service.rb b/app/services/customers/terminate_relations_service.rb new file mode 100644 index 0000000..8d50025 --- /dev/null +++ b/app/services/customers/terminate_relations_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Customers + class TerminateRelationsService < BaseService + def initialize(customer:) + @customer = customer + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + + # NOTE: Terminate active subscriptions. + customer.subscriptions.active.find_each do |subscription| + Subscriptions::TerminateService.call(subscription:, async: false) + end + + # NOTE: Cancel pending subscriptions + customer.subscriptions.pending.find_each(&:mark_as_canceled!) + + # NOTE: Finalize all draft invoices. + customer.invoices.draft.find_each { |invoice| Invoices::FinalizeJob.set(wait: 5.minutes).perform_later(invoice) } + + # NOTE: Terminate applied coupons + customer.applied_coupons.active.find_each do |applied_coupon| + AppliedCoupons::TerminateService.call(applied_coupon:) + end + + # NOTE: Terminate wallets + customer.wallets.active.find_each { |wallet| Wallets::TerminateService.call(wallet:) } + + result.customer = customer + result + end + + private + + attr_reader :customer + end +end diff --git a/app/services/customers/update_currency_service.rb b/app/services/customers/update_currency_service.rb new file mode 100644 index 0000000..a54d292 --- /dev/null +++ b/app/services/customers/update_currency_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Customers + class UpdateCurrencyService < BaseService + def initialize(customer:, currency:, customer_update: false) + @customer = customer + @currency = currency + @customer_update = customer_update + + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + return result if customer.currency == currency + + # Multi-currency: customer.currency becomes a default preference, not a constraint. + if customer.organization.feature_flag_enabled?(:multi_currency) + customer.update!(currency:) if customer_update || customer.currency.blank? + return result + end + + if customer_update + # NOTE: direct update of the customer currency + unless customer.editable? + return result.single_validation_failure!( + field: :currency, + error_code: "currencies_does_not_match" + ) + end + elsif customer.currency.present? || !customer.editable? + # NOTE: Assign currency from another resource + return result.single_validation_failure!( + field: :currency, + error_code: "currencies_does_not_match" + ) + end + + customer.update!(currency:) + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :customer, :currency, :customer_update + end +end diff --git a/app/services/customers/update_invoice_issuing_date_settings_service.rb b/app/services/customers/update_invoice_issuing_date_settings_service.rb new file mode 100644 index 0000000..7e9f236 --- /dev/null +++ b/app/services/customers/update_invoice_issuing_date_settings_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Customers + class UpdateInvoiceIssuingDateSettingsService < BaseService + def initialize(customer:, params:) + @customer = customer + @params = params + @previous_issuing_date_settings = { + invoice_grace_period: customer.invoice_grace_period, + subscription_invoice_issuing_date_anchor: customer.subscription_invoice_issuing_date_anchor, + subscription_invoice_issuing_date_adjustment: customer.subscription_invoice_issuing_date_adjustment + } + super + end + + def call + set_issuing_date_settings + + ActiveRecord::Base.transaction do + if customer.changed? && customer.save! + # NOTE: Update issuing_date on draft invoices. + customer.invoices.draft.find_each do |invoice| + invoice.issuing_date = invoice.issuing_date + issuing_date_adjustment(invoice) + invoice.expected_finalization_date = invoice.expected_finalization_date + grace_period_adjustment(invoice) + invoice.payment_due_date = grace_period_payment_due_date(invoice) + invoice.save! + end + + customer.invoices.ready_to_be_finalized.find_each do |invoice| + Invoices::FinalizeJob.perform_after_commit(invoice) + end + end + end + + result.customer = customer + result + end + + private + + attr_reader :customer, :params, :previous_issuing_date_settings + + def set_issuing_date_settings + billing_configuration = params[:billing_configuration]&.to_h || {} + + if billing_configuration.key?(:subscription_invoice_issuing_date_anchor) + customer.subscription_invoice_issuing_date_anchor = billing_configuration[:subscription_invoice_issuing_date_anchor] + end + + if billing_configuration.key?(:subscription_invoice_issuing_date_adjustment) + customer.subscription_invoice_issuing_date_adjustment = billing_configuration[:subscription_invoice_issuing_date_adjustment] + end + + if License.premium? && params.key?(:invoice_grace_period) + customer.invoice_grace_period = params[:invoice_grace_period] + end + + if License.premium? && billing_configuration.key?(:invoice_grace_period) + customer.invoice_grace_period = billing_configuration[:invoice_grace_period] + end + end + + def issuing_date_adjustment(invoice) + new_issuing_date_adjustment = new_issuing_date_service(invoice).issuing_date_adjustment + old_issuing_date_adjustment = old_issuing_date_service(invoice).issuing_date_adjustment + + new_issuing_date_adjustment - old_issuing_date_adjustment + end + + def grace_period_adjustment(invoice) + new_grace_period = new_issuing_date_service(invoice).grace_period + old_grace_period = old_issuing_date_service(invoice).grace_period + + new_grace_period - old_grace_period + end + + def old_issuing_date_service(invoice) + Invoices::IssuingDateService.new( + customer_settings: previous_issuing_date_settings, + billing_entity_settings: customer.billing_entity, + recurring: recurring(invoice) + ) + end + + def new_issuing_date_service(invoice) + Invoices::IssuingDateService.new( + customer_settings: customer, + billing_entity_settings: customer.billing_entity, + recurring: recurring(invoice) + ) + end + + def recurring(invoice) + invoice.invoice_subscriptions.first&.recurring? + end + + def grace_period_payment_due_date(invoice) + invoice.issuing_date + customer.applicable_net_payment_term.days + end + end +end diff --git a/app/services/customers/update_invoice_payment_due_date_service.rb b/app/services/customers/update_invoice_payment_due_date_service.rb new file mode 100644 index 0000000..227cdc4 --- /dev/null +++ b/app/services/customers/update_invoice_payment_due_date_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Customers + class UpdateInvoicePaymentDueDateService < BaseService + def initialize(customer:, net_payment_term:) + @customer = customer + @net_payment_term = net_payment_term + super + end + + def call + ActiveRecord::Base.transaction do + if net_payment_term != customer.net_payment_term + + # note: we should compare with the applicable_net_payment_term + should_update_draft_invoices = net_payment_term != customer.applicable_net_payment_term + + # But we always store the value! + customer.net_payment_term = net_payment_term + + # NOTE: Update payment_due_date if applicable_net_payment_term changed + if should_update_draft_invoices + customer.invoices.draft.find_each do |invoice| + invoice.update!(net_payment_term: customer.applicable_net_payment_term, payment_due_date: invoice_payment_due_date(invoice)) + end + end + end + end + result.customer = customer + result + end + + private + + attr_reader :customer, :net_payment_term + + def invoice_payment_due_date(invoice) + invoice.issuing_date + customer.applicable_net_payment_term.days + end + end +end diff --git a/app/services/customers/update_service.rb b/app/services/customers/update_service.rb new file mode 100644 index 0000000..8847357 --- /dev/null +++ b/app/services/customers/update_service.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +module Customers + class UpdateService < BaseService + extend Forwardable + include Customers::PaymentProviderFinder + + def initialize(customer:, args:) + @customer = customer + @args = args + + super + end + + activity_loggable( + action: "customer.updated", + record: -> { customer } + ) + + def call + return result.not_found_failure!(resource: "customer") unless customer + + unless valid_metadata_count?(metadata: args[:metadata]) + return result.single_validation_failure!( + field: :metadata, + error_code: "invalid_count" + ) + end + + old_payment_provider = customer.payment_provider + old_provider_customer = customer.provider_customer + original_tax_values = customer.slice(:tax_identification_number, :zipcode, :country).symbolize_keys + ActiveRecord::Base.transaction do + billing_configuration = args[:billing_configuration]&.to_h || {} + shipping_address = args[:shipping_address]&.to_h || {} + + if args.key?(:currency) + Customers::UpdateCurrencyService + .call(customer:, currency: args[:currency], customer_update: true) + .raise_if_error! + end + + customer.name = args[:name] if args.key?(:name) + customer.tax_identification_number = args[:tax_identification_number] if args.key?(:tax_identification_number) + customer.country = args[:country]&.upcase if args.key?(:country) + customer.address_line1 = args[:address_line1] if args.key?(:address_line1) + customer.address_line2 = args[:address_line2] if args.key?(:address_line2) + customer.state = args[:state] if args.key?(:state) + customer.zipcode = args[:zipcode] if args.key?(:zipcode) + customer.email = args[:email] if args.key?(:email) + customer.city = args[:city] if args.key?(:city) + customer.url = args[:url] if args.key?(:url) + customer.phone = args[:phone] if args.key?(:phone) + customer.logo_url = args[:logo_url] if args.key?(:logo_url) + customer.legal_name = args[:legal_name] if args.key?(:legal_name) + customer.legal_number = args[:legal_number] if args.key?(:legal_number) + customer.external_salesforce_id = args[:external_salesforce_id] if args.key?(:external_salesforce_id) + customer.shipping_address_line1 = shipping_address[:address_line1] if shipping_address.key?(:address_line1) + customer.shipping_address_line2 = shipping_address[:address_line2] if shipping_address.key?(:address_line2) + customer.shipping_city = shipping_address[:city] if shipping_address.key?(:city) + customer.shipping_zipcode = shipping_address[:zipcode] if shipping_address.key?(:zipcode) + customer.shipping_state = shipping_address[:state] if shipping_address.key?(:state) + customer.shipping_country = shipping_address[:country]&.upcase if shipping_address.key?(:country) + customer.firstname = args[:firstname] if args.key?(:firstname) + customer.lastname = args[:lastname] if args.key?(:lastname) + customer.customer_type = args[:customer_type] if args.key?(:customer_type) + + if args.key?(:finalize_zero_amount_invoice) + customer.finalize_zero_amount_invoice = args[:finalize_zero_amount_invoice] + end + + assign_premium_attributes(customer, args) + + customer.payment_provider = args[:payment_provider] if args.key?(:payment_provider) + customer.payment_provider_code = args[:payment_provider_code] if args.key?(:payment_provider_code) + customer.invoice_footer = args[:invoice_footer] if args.key?(:invoice_footer) + + if billing_configuration.key?(:document_locale) + customer.document_locale = billing_configuration[:document_locale] + end + + @address_changed = customer.address_changed? + end + + if args.key?(:billing_configuration) + billing = args[:billing_configuration] + customer.invoice_footer = billing[:invoice_footer] if billing.key?(:invoice_footer) + end + + Customers::UpdateInvoiceIssuingDateSettingsService.call(customer:, params: args) + + if args.key?(:net_payment_term) + Customers::UpdateInvoicePaymentDueDateService.call(customer:, net_payment_term: args[:net_payment_term]) + end + + # NOTE: Some fields are not editable if customer is attached to subscriptions: + # external_id, + # account_type, + # billing_entity_id (gated by editable? unless multi_entity_billing flag is enabled) + if args.key?(:billing_entity_code) && allow_billing_entity_update? + customer.billing_entity = billing_entity + end + + if customer.editable? + customer.external_id = args[:external_id] if args.key?(:external_id) + + if organization.revenue_share_enabled? + customer.account_type = args[:account_type] if args.key?(:account_type) + end + end + + if organization.auto_dunning_enabled? + if args.key?(:applied_dunning_campaign_id) + customer.applied_dunning_campaign = applied_dunning_campaign + customer.exclude_from_dunning_campaign = false + end + + # NOTE: exclude_from_dunning_campaign has higher priority than applied campaign + if args.key?(:exclude_from_dunning_campaign) + customer.exclude_from_dunning_campaign = args[:exclude_from_dunning_campaign] + customer.applied_dunning_campaign = nil if args[:exclude_from_dunning_campaign] + end + end + + # NOTE: partner accounts are excluded from dunning campaigns + if customer.partner_account? + customer.exclude_from_dunning_campaign = true + customer.applied_dunning_campaign = nil + end + + ActiveRecord::Base.transaction do + if old_provider_customer && args[:payment_provider].nil? && args[:payment_provider_code].present? + old_provider_customer.discard! + customer.payment_provider_code = nil + end + + if customer.applied_dunning_campaign_id_changed? || customer.exclude_from_dunning_campaign_changed? + customer.reset_dunning_campaign! + end + + Customers::ManageInvoiceCustomSectionsService.call( + customer:, + skip_invoice_custom_sections: args[:skip_invoice_custom_sections], + section_ids: args[:configurable_invoice_custom_section_ids] + ).raise_if_error! + + customer.save! + customer.error_details.tax_error.delete_all if @address_changed + customer.reload + + eu_tax_code_result = Customers::EuAutoTaxesService.call( + customer:, + new_record: false, + tax_attributes_changed: original_tax_values.any? { |key, value| args.key?(key) && args[key] != value } + ) + + if eu_tax_code_result.success? + args[:tax_codes] ||= [] + args[:tax_codes] = (args[:tax_codes] + [eu_tax_code_result.tax_code]).uniq + end + + if args[:tax_codes] + taxes_result = Customers::ApplyTaxesService.call(customer:, tax_codes: args[:tax_codes]) + taxes_result.raise_if_error! + end + Customers::Metadata::UpdateService.call(customer:, params: args[:metadata]) if args[:metadata] + end + + # NOTE: if payment provider is updated, we need to create/update the provider customer + if args.key?(:provider_customer) || args.key?(:payment_provider) + payment_provider = old_payment_provider || customer.payment_provider + create_or_update_provider_customer(customer, payment_provider, args[:provider_customer]) + end + + if args.dig(:provider_customer, :provider_customer_id) + update_result = PaymentProviderCustomers::UpdateService.call(customer) + update_result.raise_if_error! + end + + result.customer = customer + + if old_provider_customer && args.key?(:payment_provider) && args[:payment_provider].nil? + old_provider_customer.payment_methods.find_each do |payment_method| + PaymentMethods::DestroyService.call(payment_method:) + end + end + + IntegrationCustomers::CreateOrUpdateBatchService.call( + integration_customers: args[:integration_customers], + customer: result.customer, + new_customer: false + ) + SendWebhookJob.perform_later("customer.updated", customer) + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotFound => e + result.not_found_failure!(resource: e.model.underscore) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :customer, :args + def_delegators :customer, :organization + + def billing_entity + @billing_entity ||= organization.billing_entities.find_by!(code: args[:billing_entity_code]) + end + + def valid_metadata_count?(metadata:) + return true if metadata.blank? + return true if metadata.count <= ::Metadata::CustomerMetadata::COUNT_PER_CUSTOMER + + false + end + + def allow_billing_entity_update? + organization.feature_flag_enabled?(:multi_entity_billing) || customer.editable? + end + + def assign_premium_attributes(customer, args) + return unless License.premium? + + customer.timezone = args[:timezone] if args.key?(:timezone) + end + + def create_or_update_provider_customer(customer, payment_provider, billing_configuration = {}) + return if payment_provider.nil? + + handle_provider_customer = customer.payment_provider.present? + handle_provider_customer ||= (billing_configuration || {})[:provider_customer_id].present? + handle_provider_customer ||= customer.send(:"#{payment_provider}_customer")&.provider_customer_id.present? + return unless handle_provider_customer + + PaymentProviders::CreateCustomerFactory.new_instance( + provider: payment_provider, + customer:, + payment_provider_id: payment_provider(customer)&.id, + params: billing_configuration + ).call.raise_if_error! + + # NOTE: Create service is modifying an other instance of the provider customer + customer.reload + end + + def applied_dunning_campaign + return customer.applied_dunning_campaign unless args.key?(:applied_dunning_campaign_id) + return unless args[:applied_dunning_campaign_id] + + DunningCampaign.find(args[:applied_dunning_campaign_id]) + end + end +end diff --git a/app/services/customers/upsert_from_api_service.rb b/app/services/customers/upsert_from_api_service.rb new file mode 100644 index 0000000..1d0da51 --- /dev/null +++ b/app/services/customers/upsert_from_api_service.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +module Customers + # Upsert (creates or updates) a customer from an API request + class UpsertFromApiService < BaseService + include Customers::PaymentProviderFinder + + Result = BaseResult[:customer] + + def initialize(organization:, params:) + @organization = organization + @params = params + super + end + + def call + billing_entity = BillingEntities::ResolveService.call( + organization:, billing_entity_code: params[:billing_entity_code] + ).raise_if_error!.billing_entity + + customer = organization.customers.find_or_initialize_by(external_id: params[:external_id]) + new_customer = customer.new_record? + shipping_address = params[:shipping_address] ||= {} + + unless valid_metadata_count?(metadata: params[:metadata]) + return result.single_validation_failure!( + field: :metadata, + error_code: "invalid_count" + ) + end + + unless valid_finalize_zero_amount_invoice?(params[:finalize_zero_amount_invoice]) + return result.single_validation_failure!( + field: :finalize_zero_amount_invoice, + error_code: "invalid_value" + ) + end + + unless valid_integration_customers_count?(integration_customers: params[:integration_customers]) + return result.single_validation_failure!( + field: :integration_customers, + error_code: "invalid_count_per_integration_type" + ) + end + + ActiveRecord::Base.transaction do + original_tax_values = customer.slice(:tax_identification_number, :zipcode, :country).symbolize_keys + + if new_customer || (params.key?(:billing_entity_code) && allow_billing_entity_update?(customer)) + customer.billing_entity = billing_entity + end + customer.name = params[:name] if params.key?(:name) + customer.country = params[:country]&.upcase if params.key?(:country) + customer.address_line1 = params[:address_line1] if params.key?(:address_line1) + customer.address_line2 = params[:address_line2] if params.key?(:address_line2) + customer.state = params[:state] if params.key?(:state) + customer.zipcode = params[:zipcode] if params.key?(:zipcode) + customer.email = params[:email] if params.key?(:email) + customer.city = params[:city] if params.key?(:city) + customer.shipping_address_line1 = shipping_address[:address_line1] if shipping_address.key?(:address_line1) + customer.shipping_address_line2 = shipping_address[:address_line2] if shipping_address.key?(:address_line2) + customer.shipping_city = shipping_address[:city] if shipping_address.key?(:city) + customer.shipping_zipcode = shipping_address[:zipcode] if shipping_address.key?(:zipcode) + customer.shipping_state = shipping_address[:state] if shipping_address.key?(:state) + customer.shipping_country = shipping_address[:country]&.upcase if shipping_address.key?(:country) + customer.url = params[:url] if params.key?(:url) + customer.phone = params[:phone] if params.key?(:phone) + customer.logo_url = params[:logo_url] if params.key?(:logo_url) + customer.legal_name = params[:legal_name] if params.key?(:legal_name) + customer.legal_number = params[:legal_number] if params.key?(:legal_number) + customer.net_payment_term = params[:net_payment_term] if params.key?(:net_payment_term) + customer.external_salesforce_id = params[:external_salesforce_id] if params.key?(:external_salesforce_id) + customer.finalize_zero_amount_invoice = params[:finalize_zero_amount_invoice] || "inherit" if params.key?(:finalize_zero_amount_invoice) + customer.firstname = params[:firstname] if params.key?(:firstname) + customer.lastname = params[:lastname] if params.key?(:lastname) + customer.customer_type = params[:customer_type] if params.key?(:customer_type) + + if customer.organization.revenue_share_enabled? && customer.editable? + customer.account_type = params[:account_type] if params.key?(:account_type) + customer.exclude_from_dunning_campaign = customer.partner_account? + end + + if params.key?(:tax_identification_number) + customer.tax_identification_number = params[:tax_identification_number] + end + + assign_premium_attributes(customer, params) + address_changed = !new_customer && customer.address_changed? + + if params.key?(:currency) + Customers::UpdateCurrencyService + .call(customer:, currency: params[:currency], customer_update: true) + .raise_if_error! + end + + customer.save! + customer.error_details.tax_error.delete_all if address_changed + + eu_tax_code_result = Customers::EuAutoTaxesService.call( + customer:, + new_record: new_customer, + tax_attributes_changed: original_tax_values.any? { |key, value| params.key?(key) && params[key] != value } + ) + + if eu_tax_code_result.success? + params[:tax_codes] ||= [] + params[:tax_codes] = (params[:tax_codes] + [eu_tax_code_result.tax_code]).uniq + end + + if params.key?(:tax_codes) + taxes_result = Customers::ApplyTaxesService.call(customer:, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + Customers::ManageInvoiceCustomSectionsService.call( + customer:, + skip_invoice_custom_sections: params[:skip_invoice_custom_sections], + section_codes: params[:invoice_custom_section_codes] + ).raise_if_error! + + if new_customer && params[:metadata] + params[:metadata].each { |m| create_metadata(customer:, args: m) } + elsif params[:metadata] + Customers::Metadata::UpdateService.call(customer:, params: params[:metadata]) + end + end + + # NOTE: handle configuration for configured payment providers + handle_api_billing_configuration(customer, new_customer) + + result.customer = customer.reload + + IntegrationCustomers::CreateOrUpdateBatchService.call( + integration_customers: params[:integration_customers], + customer: result.customer, + new_customer: + ) + + if new_customer + SendWebhookJob.perform_later("customer.created", customer) + Utils::ActivityLog.produce_after_commit(customer, "customer.created") + else + SendWebhookJob.perform_later("customer.updated", customer) + Utils::ActivityLog.produce_after_commit(customer, "customer.updated") + end + + result + rescue BaseService::ServiceFailure => e + result.single_validation_failure!(error_code: e.code) + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + rescue ActiveRecord::RecordNotUnique + result.single_validation_failure!(field: :external_id, error_code: "value_already_exist") + end + + private + + attr_reader :organization, :params + + def valid_finalize_zero_amount_invoice?(value) + return true if value.nil? + Customer::FINALIZE_ZERO_AMOUNT_INVOICE_OPTIONS.include?(value.to_sym) + end + + def valid_metadata_count?(metadata:) + return true if metadata.blank? + return true if metadata.count <= ::Metadata::CustomerMetadata::COUNT_PER_CUSTOMER + + false + end + + def valid_integration_customers_count?(integration_customers:) + return true if integration_customers.blank? + + input_types = integration_customers&.map { |c| c.to_h.deep_symbolize_keys }&.map { |c| c[:integration_type] } + + input_types.length == input_types.uniq.length + end + + def allow_billing_entity_update?(customer) + organization.feature_flag_enabled?(:multi_entity_billing) || customer.editable? + end + + def create_metadata(customer:, args:) + customer.metadata.create!( + organization_id: organization.id, + key: args[:key], + value: args[:value], + display_in_invoice: args[:display_in_invoice] || false + ) + end + + def assign_premium_attributes(customer, args) + return unless License.premium? + + customer.timezone = args[:timezone] if args.key?(:timezone) + customer.invoice_grace_period = args[:invoice_grace_period] if args.key?(:invoice_grace_period) + end + + def create_billing_configuration(customer, billing_configuration = {}) + return if billing_configuration.blank? || (api_context? && billing_configuration[:payment_provider].nil?) + + create_provider_customer = billing_configuration[:sync_with_provider] + create_provider_customer ||= billing_configuration[:provider_customer_id] + return unless create_provider_customer + + if api_context? + customer.payment_provider = billing_configuration[:payment_provider] + + payment_provider_result = PaymentProviders::FindService.new( + organization_id: customer.organization_id, + code: billing_configuration[:payment_provider_code].presence, + payment_provider_type: customer.payment_provider + ).call + payment_provider_result.raise_if_error! + + customer.payment_provider_code = payment_provider_result.payment_provider.code + customer.save! + end + + create_or_update_provider_customer(customer, billing_configuration) + end + + def handle_api_billing_configuration(customer, new_customer) + params[:billing_configuration] = {} unless params.key?(:billing_configuration) + + billing = params[:billing_configuration] + + Customers::UpdateInvoiceIssuingDateSettingsService.call(customer:, params:) + + customer.document_locale = billing[:document_locale] if billing.key?(:document_locale) + + if new_customer || should_create_billing_configuration?(billing, customer) + create_billing_configuration(customer, billing) + customer.save! + return + end + + old_provider_customer = customer.provider_customer + old_payment_provider = customer.payment_provider + payment_provider_result = PaymentProviders::FindService.new( + organization_id: customer.organization_id, + code: customer.payment_provider_code, + payment_provider_type: old_payment_provider + ).call + old_payment_provider_id = payment_provider_result.payment_provider&.id + + if billing.key?(:payment_provider) + customer.payment_provider = nil + if Customer::PAYMENT_PROVIDERS.include?(billing[:payment_provider]) + customer.payment_provider = billing[:payment_provider] + customer.payment_provider_code = billing[:payment_provider_code] if billing.key?(:payment_provider_code) + end + end + + customer.save! + + if old_provider_customer && billing.key?(:payment_provider) && billing[:payment_provider].nil? + discard_payment_methods(old_provider_customer.payment_methods) + end + + return if customer.payment_provider.nil? + + update_provider_customer = (billing || {})[:provider_customer_id].present? + update_provider_customer ||= customer.provider_customer&.provider_customer_id.present? + + return unless update_provider_customer + + create_or_update_provider_customer(customer, billing) + + if customer.provider_customer&.provider_customer_id + PaymentProviderCustomers::UpdateService.call(customer) + end + + if old_provider_customer + new_payment_provider_id = payment_provider(customer)&.id + + if old_payment_provider != customer.payment_provider + discard_payment_methods(old_provider_customer.payment_methods) + elsif old_payment_provider_id.present? && + new_payment_provider_id.present? && + old_payment_provider_id != new_payment_provider_id + discard_payment_methods(old_provider_customer.payment_methods) + end + end + end + + def create_or_update_provider_customer(customer, billing_configuration = {}) + PaymentProviders::CreateCustomerFactory.new_instance( + provider: billing_configuration[:payment_provider] || customer.payment_provider, + customer:, + payment_provider_id: payment_provider(customer)&.id, + params: billing_configuration, + async: !(billing_configuration || {})[:sync] + ).call.raise_if_error! + end + + def discard_payment_methods(payment_methods) + payment_methods.find_each do |payment_method| + PaymentMethods::DestroyService.call(payment_method:) + end + end + + def should_create_billing_configuration?(billing, customer) + (billing[:sync_with_provider] || billing[:provider_customer_id].present?) && customer.provider_customer&.provider_customer_id.nil? + end + end +end diff --git a/app/services/customers/vies_check_service.rb b/app/services/customers/vies_check_service.rb new file mode 100644 index 0000000..07b418f --- /dev/null +++ b/app/services/customers/vies_check_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Customers + class ViesCheckService < BaseService + include Customers::Concerns::EuTaxCodeResolver + + Result = BaseResult[:tax_code, :vies_check, :pending_vies_check] + + def initialize(customer:) + @customer = customer + + super + end + + def call + return result.not_allowed_failure!(code: "eu_tax_not_applicable") unless customer.billing_entity.eu_tax_management + + vies_api_response = check_vies + + result.tax_code = if vies_api_response.present? + process_vies_tax(vies_api_response) + else + process_not_vies_tax + end + + SendWebhookJob.perform_after_commit( + "customer.vies_check", + customer, + vies_check: vies_api_response.presence || error_vies_check + ) + + result.vies_check = vies_api_response.presence || error_vies_check + delete_pending_vies_check_if_exists + result + rescue Valvat::HTTPError, Valvat::RateLimitError, Valvat::Timeout, Valvat::BlockedError, + Valvat::InvalidRequester, Valvat::ServiceUnavailable, Valvat::MemberStateUnavailable => e + handle_error(e) + end + + private + + attr_reader :customer + + def handle_error(e) + pending_vies_check = create_or_update_pending_vies_check(e) + + SendWebhookJob.perform_after_commit( + "customer.vies_check", + customer, + vies_check: error_vies_check.merge(error: e.message) + ) + + result.pending_vies_check = pending_vies_check + result.service_failure!(code: "vies_check_failed", message: e.message) + end + + def check_vies + return nil if customer.tax_identification_number.blank? + + # Just errors extended from Valvat::Lookup are raised, while Maintenances are not. + # https://github.com/yolk/valvat/blob/master/README.md#handling-of-maintenance-errors + # Check the Unavailable sheet per UE country. + # https://ec.europa.eu/taxation_customs/vies/#/help + Valvat.new(customer.tax_identification_number).exists?(detail: true, raise_error: true) + end + + def error_vies_check + { + valid: false, + valid_format: is_valid_vat_number?(customer.tax_identification_number) + } + end + + def create_or_update_pending_vies_check(exception) + pending_check = PendingViesCheck.find_or_initialize_by(customer:) + pending_check.assign_attributes( + organization: customer.organization, + billing_entity: customer.billing_entity, + tax_identification_number: customer.tax_identification_number, + attempts_count: pending_check.attempts_count + 1, + last_attempt_at: Time.current, + last_error_type: PendingViesCheck.error_type_for(exception), + last_error_message: exception.message + ) + pending_check.save! + pending_check + end + + def delete_pending_vies_check_if_exists + customer.pending_vies_check&.destroy! + end + end +end diff --git a/app/services/daily_usages/compute_all_service.rb b/app/services/daily_usages/compute_all_service.rb new file mode 100644 index 0000000..2715969 --- /dev/null +++ b/app/services/daily_usages/compute_all_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module DailyUsages + class ComputeAllService < BaseService + def initialize(timestamp: Time.current) + @timestamp = timestamp + + super + end + + def call + each_subscription do |subscription| + schedule_daily_usage(subscription) + end + + result + end + + private + + attr_reader :timestamp + + def schedule_daily_usage(subscription) + DailyUsages::ComputeJob.set(wait: job_wait_time).perform_later(subscription, timestamp:) + end + + def job_wait_time + # Randomize job wait time to distribute load across the system. + # This prevents thundering herd effect when processing large batches, + # and helps interleave jobs from different organizations since subscriptions + # within the same organization usually have very similar load profiles. + rand(scheduling_interval) + end + + def scheduling_interval + @scheduling_interval ||= begin + raw_value = ENV["LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS"] + parsed = Integer(raw_value, exception: false) if raw_value + parsed = nil if parsed && parsed <= 0 + (parsed || 30.minutes).to_i + end + end + + def each_organization(&block) + Organization.with_revenue_analytics_support.find_each(&block) + end + + def each_billing_entity(organization, &block) + organization.billing_entities.unscope(:order).find_each(&block) + end + + def each_customer_batch(billing_entity, &block) + Customer.joins(:billing_entity) + .where(billing_entity_id: billing_entity.id) + .where("DATE_PART('hour', (:timestamp#{at_time_zone})) IN (0, 1, 2)", timestamp:) + .in_batches(&block) + end + + def each_subscription(&block) + each_organization do |organization| + each_billing_entity(organization) do |billing_entity| + each_customer_batch(billing_entity) do |customers| + customer_ids = customers.select(:id) + subscription_ids_with_daily_usage = DailyUsage.usage_date_in_timezone(timestamp.to_date - 1.day) + .where(customer_id: customer_ids) + .select(:subscription_id) + Subscription.where(customer_id: customer_ids) + .active + .where.not(id: subscription_ids_with_daily_usage) + .where("last_received_event_on >= ?", timestamp.to_date - 1.day) + .find_each do |subscription| + yield subscription + end + end + end + end + end + + def existing_daily_usage + DailyUsage.usage_date_in_timezone(timestamp.to_date - 1.day).select(:subscription_id) + end + end +end diff --git a/app/services/daily_usages/compute_diff_service.rb b/app/services/daily_usages/compute_diff_service.rb new file mode 100644 index 0000000..67323e7 --- /dev/null +++ b/app/services/daily_usages/compute_diff_service.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module DailyUsages + class ComputeDiffService < BaseService + def initialize(daily_usage:, previous_daily_usage: nil) + @daily_usage = daily_usage + @previous_daily_usage = previous_daily_usage + + super + end + + def call + unless previous_daily_usage + result.usage_diff = daily_usage.usage + return result + end + + diff = daily_usage.usage.deep_dup + previous_usage = previous_daily_usage.usage + + previous_charges_index = previous_usage["charges_usage"].index_by { |cu| cu["charge"]["lago_id"] } + + diff["charges_usage"].each do |current_charge_usage| + previous_charge_usage = previous_charges_index[current_charge_usage["charge"]["lago_id"]] + next unless previous_charge_usage + + apply_diff(previous_charge_usage, current_charge_usage) + apply_filters_diff(previous_charge_usage, current_charge_usage) + apply_presentation_breakdowns_diff(previous_charge_usage, current_charge_usage) + + previous_grouped_index = previous_charge_usage["grouped_usage"].index_by { |gu| gu["grouped_by"] } + current_charge_usage["grouped_usage"].each do |current_grouped_usage| + previous_grouped_usage = previous_grouped_index[current_grouped_usage["grouped_by"]] + next unless previous_grouped_usage + + apply_diff(previous_grouped_usage, current_grouped_usage) + apply_filters_diff(previous_grouped_usage, current_grouped_usage) + apply_presentation_breakdowns_diff(previous_grouped_usage, current_grouped_usage) + end + end + + diff["amount_cents"] = diff["charges_usage"].sum { |cu| cu["amount_cents"] } + diff["taxes_amount_cents"] -= previous_common_taxes(diff, previous_usage, previous_charges_index) + diff["total_amount_cents"] = diff["amount_cents"] + diff["taxes_amount_cents"] + + result.usage_diff = diff + result + end + + private + + attr_reader :daily_usage + + delegate :subscription, :usage_date, :from_datetime, :to_datetime, to: :daily_usage + + def previous_daily_usage + @previous_daily_usage ||= subscription.daily_usages + .where(from_datetime:, to_datetime:) + .find_by(usage_date: usage_date - 1.day) + end + + # Prorates previous taxes based on how much of the previous amount came from charges + # that still exist in the current snapshot. This avoids over-deducting taxes when charges + # are added or removed between snapshots. + # + # Example: previous had charges A(100) + B(200) = 300 with 30 in taxes. + # Current only has charge A. Common ratio = 100/300 = 1/3, so we deduct 10 (not 30). + def previous_common_taxes(diff, previous_usage, previous_charges_index) + return previous_usage["taxes_amount_cents"] unless previous_usage["amount_cents"].positive? + + previous_common_amount = diff["charges_usage"].sum do |cu| + previous_charges_index.dig(cu["charge"]["lago_id"], "amount_cents") || 0 + end + + common_ratio = previous_common_amount.fdiv(previous_usage["amount_cents"]) + (previous_usage["taxes_amount_cents"] * common_ratio).round + end + + def apply_filters_diff(previous_parent, current_parent) + previous_filters_index = previous_parent["filters"].index_by { |fu| fu["values"] } + current_parent["filters"].each do |current_filter| + previous_filter = previous_filters_index[current_filter["values"]] + next unless previous_filter + + apply_diff(previous_filter, current_filter) + end + end + + def apply_presentation_breakdowns_diff(previous_parent, current_parent) + previous_index = Array(previous_parent["presentation_breakdowns"]).index_by { |pb| pb["presentation_by"] } + + current_parent.fetch("presentation_breakdowns", []).each do |current_breakdown| + previous_breakdown = previous_index[current_breakdown["presentation_by"]] + next unless previous_breakdown + + current_units = BigDecimal(current_breakdown["units"] || 0) + previous_units = BigDecimal(previous_breakdown["units"] || 0) + current_breakdown["units"] = (current_units - previous_units).to_s + end + end + + def apply_diff(previous_values, current_values) + current_values["units"] = (BigDecimal(current_values["units"]) - BigDecimal(previous_values["units"])).to_s + current_values["events_count"] -= previous_values["events_count"] + current_values["amount_cents"] -= previous_values["amount_cents"] + end + end +end diff --git a/app/services/daily_usages/compute_service.rb b/app/services/daily_usages/compute_service.rb new file mode 100644 index 0000000..d0584a8 --- /dev/null +++ b/app/services/daily_usages/compute_service.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module DailyUsages + class ComputeService < BaseService + def initialize(subscription:, timestamp:) + @subscription = subscription + @timestamp = timestamp + super + end + + def call + if subscription_billing_day? + # Usage on billing day will be computed using the periodic invoice as we cannot rely on the caching mechanism + return result + end + + if existing_daily_usage.present? + result.daily_usage = existing_daily_usage + return result + end + + current_usage.fees = current_usage.fees.select(&:non_zero?) + + if current_usage.fees.any? + daily_usage = DailyUsage.new( + organization: subscription.organization, + customer: subscription.customer, + subscription:, + external_subscription_id: subscription.external_id, + usage: ::V1::Customers::UsageSerializer.new(current_usage, includes: %i[charges_usage]).serialize, + from_datetime: current_usage.from_datetime, + to_datetime: current_usage.to_datetime, + refreshed_at: timestamp, + usage_date: + ) + + daily_usage.usage_diff = diff_usage(daily_usage) + daily_usage.save! + + result.daily_usage = daily_usage + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :subscription, :timestamp + + delegate :customer, to: :subscription + + def current_usage + return @current_usage if defined?(@current_usage) + with_cache = true + + # Subscription has been terminated before the initial enqueue of the the job + # In that case, we cannot rely on the cache as it will not be relevant anymore + with_cache = false if subscription.terminated? && subscription.terminated_at > timestamp + + @current_usage = Invoices::CustomerUsageService.call( + customer: subscription.customer, + subscription: subscription, + apply_taxes: false, + with_cache:, + # Force the timestamp, to allow computing usage if terminated subscription with the right boundaries + timestamp: with_cache ? Time.current : timestamp + ).raise_if_error!.usage + end + + def existing_daily_usage + @existing_daily_usage ||= DailyUsage.usage_date_in_timezone(usage_date) + .find_by(subscription_id: subscription.id) + end + + def diff_usage(daily_usage) + DailyUsages::ComputeDiffService.call!(daily_usage:).usage_diff + end + + def subscription_billing_day? + previous_billing_date_in_timezone = Subscriptions::DatesService + .new_instance(subscription, timestamp, current_usage: true) + .previous_beginning_of_period + .in_time_zone(customer.applicable_timezone) + .to_date + + date_in_timezone == previous_billing_date_in_timezone + end + + def date_in_timezone + @date_in_timezone ||= timestamp.in_time_zone(customer.applicable_timezone).to_date + end + + def usage_date + @usage_date ||= date_in_timezone - 1.day + end + end +end diff --git a/app/services/daily_usages/fill_from_invoice_service.rb b/app/services/daily_usages/fill_from_invoice_service.rb new file mode 100644 index 0000000..811e9a9 --- /dev/null +++ b/app/services/daily_usages/fill_from_invoice_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module DailyUsages + class FillFromInvoiceService < BaseService + Usage = Struct.new(:from_datetime, :to_datetime, :issuing_date, :currency, :amount_cents, :total_amount_cents, :taxes_amount_cents, :fees) + + def initialize(invoice:, subscriptions:) + @invoice = invoice + @subscriptions = subscriptions + + super + end + + def call + result.daily_usages = [] + + invoice.invoice_subscriptions.each do |invoice_subscription| + subscription = subscriptions.find { |s| s.id == invoice_subscription.subscription_id } + next if subscription.blank? + next unless charge_boundaries_valid?(invoice_subscription) + next if existing_daily_usage(invoice_subscription).present? + + usage = invoice_usage(subscription, invoice_subscription) + if usage.total_amount_cents.positive? + daily_usage = DailyUsage.new( + organization: invoice.organization, + customer: invoice.customer, + subscription: subscription, + external_subscription_id: subscription.external_id, + usage: ::V1::Customers::UsageSerializer.new(usage, includes: %i[charges_usage]).serialize, + from_datetime: invoice_subscription.charges_from_datetime.change(usec: 0), + to_datetime: invoice_subscription.charges_to_datetime.change(usec: 0), + refreshed_at: invoice_subscription.timestamp, + usage_date: usage_date(invoice_subscription) + ) + + daily_usage.usage_diff = diff_usage(daily_usage) + daily_usage.save! + + result.daily_usages << daily_usage + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :subscriptions + + def invoice_usage(subscription, invoice_subscription) + in_adv_fees = in_advance_fees(subscription, invoice_subscription) + + fees = in_adv_fees + + invoice.fees.charge.select { |f| f.subscription_id == subscription.id && f.units.positive? } + + amount_cents = in_adv_fees.sum(:amount_cents) + invoice.fees.charge.sum(:amount_cents) + taxes_amount_cents = in_adv_fees.sum(:taxes_amount_cents) + invoice.fees.charge.sum(:taxes_amount_cents) + total_amount_cents = amount_cents + taxes_amount_cents + + Usage.new( + from_datetime: invoice_subscription.charges_from_datetime.change(usec: 0), + to_datetime: invoice_subscription.charges_to_datetime.change(usec: 0), + issuing_date: invoice.issuing_date.iso8601, + currency: invoice.currency, + amount_cents:, + total_amount_cents:, + taxes_amount_cents:, + fees: + ) + end + + def in_advance_fees(subscription, invoice_subscription) + Fee.charge.where( + subscription_id: subscription.id + ).where.not( + pay_in_advance_event_transaction_id: nil + ).where( + pay_in_advance: true + ).where( + "(properties->>'charges_from_datetime')::timestamptz = ?", invoice_subscription.charges_from_datetime&.iso8601(3) + ).where( + "(properties->>'charges_to_datetime')::timestamptz = ?", invoice_subscription.charges_to_datetime&.iso8601(3) + ) + end + + def diff_usage(daily_usage) + DailyUsages::ComputeDiffService.call!(daily_usage:).usage_diff + end + + def existing_daily_usage(invoice_subscription) + DailyUsage.find_by( + from_datetime: invoice_subscription.charges_from_datetime.change(usec: 0), + to_datetime: invoice_subscription.charges_to_datetime.change(usec: 0), + usage_date: usage_date(invoice_subscription), + subscription_id: invoice_subscription.subscription_id + ) + end + + def charge_boundaries_valid?(invoice_subscription) + return false if invoice_subscription.charges_from_datetime.nil? + return false if invoice_subscription.charges_to_datetime.nil? + + invoice_subscription.charges_from_datetime <= invoice_subscription.charges_to_datetime + end + + def usage_date(invoice_subscription) + invoice_subscription.charges_to_datetime.in_time_zone(invoice.customer.applicable_timezone).to_date + end + end +end diff --git a/app/services/daily_usages/fill_history_service.rb b/app/services/daily_usages/fill_history_service.rb new file mode 100644 index 0000000..9023781 --- /dev/null +++ b/app/services/daily_usages/fill_history_service.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "timecop" + +module DailyUsages + class FillHistoryService < BaseService + def initialize(subscription:, from_date:, to_date: nil, sandbox: false) + @subscription = subscription + @from_date = from_date + @to_date = to_date + @sandbox = sandbox + + super + end + + def call + previous_daily_usage = nil + + (from..to).each do |date| + if !sandbox && (existing_daily_usage = subscription.daily_usages.find_by(usage_date: date)) + previous_daily_usage = existing_daily_usage + next + end + + datetime = date.in_time_zone(subscription.customer.applicable_timezone).beginning_of_day.utc + datetime = date.beginning_of_day.utc if datetime < date # Handle last day for timezone with positive offset + + Timecop.thread_safe = true + time_to_freeze = datetime.in_time_zone(subscription.customer.applicable_timezone).end_of_day + Timecop.freeze(time_to_freeze) do + usage = Invoices::CustomerUsageService.call( + customer: subscription.customer, + subscription: subscription, + apply_taxes: false, + with_cache: false, + max_timestamp: time_to_freeze, + with_zero_units_filters: false + ).raise_if_error!.usage + next if sandbox + + if previous_daily_usage.present? && previous_daily_usage.from_datetime != usage.from_datetime + # NOTE: A new billing period was started, the diff should contains the complete current usage + previous_daily_usage = nil + end + + usage.fees = usage.fees.select(&:non_zero?) + + if usage.fees.any? + daily_usage = DailyUsage.new( + organization:, + customer: subscription.customer, + subscription:, + external_subscription_id: subscription.external_id, + usage: ::V1::Customers::UsageSerializer.new(usage, includes: %i[charges_usage]).serialize, + from_datetime: usage.from_datetime, + to_datetime: usage.to_datetime, + refreshed_at: datetime, + usage_diff: {}, + usage_date: date + ) + + if date != from + daily_usage.usage_diff = DailyUsages::ComputeDiffService + .call(daily_usage:, previous_daily_usage:) + .raise_if_error! + .usage_diff + end + + daily_usage.save! + + previous_daily_usage = daily_usage + end + end + end + + if subscription.terminated? + invoice = subscription.invoices + .joins(:invoice_subscriptions) + .where(invoice_subscriptions: {invoicing_reason: "subscription_terminating"}) + .first + + if invoice.present? + DailyUsages::FillFromInvoiceJob.perform_later(invoice:, subscriptions: [subscription]) + end + end + + result + end + + attr_reader :subscription, :from_date, :to_date, :sandbox + delegate :organization, to: :subscription + + def from + @from ||= [ + subscription.started_at.in_time_zone(timezone).to_date, + from_date + ].max + end + + def to + @to ||= if subscription.terminated? + subscription.terminated_at.in_time_zone(timezone).to_date + else + to_date || Time.zone.yesterday.in_time_zone(timezone).to_date + end + end + + def timezone + @timezone ||= subscription.customer.applicable_timezone + end + end +end diff --git a/app/services/data_api/base_service.rb b/app/services/data_api/base_service.rb new file mode 100644 index 0000000..636bec8 --- /dev/null +++ b/app/services/data_api/base_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "lago_http_client" + +module DataApi + class BaseService < BaseService + def initialize(organization, **params) + @organization = organization + @params = params + + super() + end + + private + + attr_reader :organization, :params + + def http_client + @http_client ||= LagoHttpClient::Client.new(endpoint_url) + end + + def headers + { + "Authorization" => "Bearer #{ENV["LAGO_DATA_API_BEARER_TOKEN"]}" + } + end + + def endpoint_url + "#{ENV["LAGO_DATA_API_URL"]}/#{action_path}" + end + + def action_path + raise NotImplementedError + end + end +end diff --git a/app/services/data_api/mrrs/plans_service.rb b/app/services/data_api/mrrs/plans_service.rb new file mode 100644 index 0000000..fc6e098 --- /dev/null +++ b/app/services/data_api/mrrs/plans_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DataApi + module Mrrs + class PlansService < DataApi::BaseService + Result = BaseResult[:data_mrrs_plans] + + def call + return result.forbidden_failure! unless License.premium? + + data_mrrs_plans = http_client.get(headers:, params:) + + result.data_mrrs_plans = data_mrrs_plans + result + end + + private + + def action_path + "mrrs/#{organization.id}/plans/" + end + end + end +end diff --git a/app/services/data_api/mrrs_service.rb b/app/services/data_api/mrrs_service.rb new file mode 100644 index 0000000..5298860 --- /dev/null +++ b/app/services/data_api/mrrs_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DataApi + class MrrsService < BaseService + Result = BaseResult[:mrrs] + + def call + return result.forbidden_failure! unless License.premium? + + data_mrrs = http_client.get(headers:, params:) + + result.mrrs = data_mrrs + result + end + + private + + def action_path + "mrrs/#{organization.id}/" + end + end +end diff --git a/app/services/data_api/prepaid_credits_service.rb b/app/services/data_api/prepaid_credits_service.rb new file mode 100644 index 0000000..14c286d --- /dev/null +++ b/app/services/data_api/prepaid_credits_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module DataApi + class PrepaidCreditsService < DataApi::BaseService + Result = BaseResult[:prepaid_credits] + + def call + return result.forbidden_failure! unless License.premium? + + result.prepaid_credits = http_client.get(headers:, params:) + result + end + + private + + def action_path + "prepaid_credits/#{organization.id}/" + end + end +end diff --git a/app/services/data_api/revenue_streams/customers_service.rb b/app/services/data_api/revenue_streams/customers_service.rb new file mode 100644 index 0000000..dc32d81 --- /dev/null +++ b/app/services/data_api/revenue_streams/customers_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module DataApi + module RevenueStreams + class CustomersService < DataApi::BaseService + Result = BaseResult[:data_revenue_streams_customers] + + def call + return result.forbidden_failure! unless License.premium? + + data_revenue_streams_customers = http_client.get(headers:, params:) + result.data_revenue_streams_customers = data_revenue_streams_customers + result + end + + private + + def action_path + "revenue_streams/#{organization.id}/customers/" + end + end + end +end diff --git a/app/services/data_api/revenue_streams/plans_service.rb b/app/services/data_api/revenue_streams/plans_service.rb new file mode 100644 index 0000000..73a6b4f --- /dev/null +++ b/app/services/data_api/revenue_streams/plans_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DataApi + module RevenueStreams + class PlansService < DataApi::BaseService + Result = BaseResult[:data_revenue_streams_plans] + + def call + return result.forbidden_failure! unless License.premium? + + data_revenue_streams_plans = http_client.get(headers:, params:) + + result.data_revenue_streams_plans = data_revenue_streams_plans + result + end + + private + + def action_path + "revenue_streams/#{organization.id}/plans/" + end + end + end +end diff --git a/app/services/data_api/revenue_streams_service.rb b/app/services/data_api/revenue_streams_service.rb new file mode 100644 index 0000000..195e414 --- /dev/null +++ b/app/services/data_api/revenue_streams_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module DataApi + class RevenueStreamsService < BaseService + Result = BaseResult[:revenue_streams] + + def call + return result.forbidden_failure! unless License.premium? + + data_revenue_streams = http_client.get(headers:, params:) + + result.revenue_streams = data_revenue_streams + result + end + + private + + def action_path + "revenue_streams/#{organization.id}/" + end + end +end diff --git a/app/services/data_api/usages/aggregated_amounts_service.rb b/app/services/data_api/usages/aggregated_amounts_service.rb new file mode 100644 index 0000000..b84e351 --- /dev/null +++ b/app/services/data_api/usages/aggregated_amounts_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DataApi + module Usages + class AggregatedAmountsService < DataApi::BaseService + Result = BaseResult[:aggregated_amounts_usages] + + def call + return result.forbidden_failure! unless License.premium? + + data_aggregated_amounts_usages = http_client.get(headers:, params:) + + result.aggregated_amounts_usages = data_aggregated_amounts_usages + result + end + + private + + def action_path + "usages/#{organization.id}/aggregated_amounts/" + end + end + end +end diff --git a/app/services/data_api/usages/forecasted_service.rb b/app/services/data_api/usages/forecasted_service.rb new file mode 100644 index 0000000..150aaf3 --- /dev/null +++ b/app/services/data_api/usages/forecasted_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DataApi + module Usages + class ForecastedService < DataApi::BaseService + Result = BaseResult[:forecasted_usages] + + def call + return result.forbidden_failure! unless License.premium? + + data_forecasted_usages = http_client.get(headers:, params:) + + result.forecasted_usages = data_forecasted_usages + result + end + + private + + def action_path + "usages/#{organization.id}/forecasted/" + end + end + end +end diff --git a/app/services/data_api/usages/invoiced_service.rb b/app/services/data_api/usages/invoiced_service.rb new file mode 100644 index 0000000..fb66146 --- /dev/null +++ b/app/services/data_api/usages/invoiced_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module DataApi + module Usages + class InvoicedService < DataApi::BaseService + Result = BaseResult[:invoiced_usages] + + def call + return result.forbidden_failure! unless License.premium? + + data_invoiced_usages = http_client.get(headers:, params:) + + result.invoiced_usages = data_invoiced_usages + result + end + + private + + def action_path + "usages/#{organization.id}/invoiced/" + end + end + end +end diff --git a/app/services/data_api/usages_service.rb b/app/services/data_api/usages_service.rb new file mode 100644 index 0000000..b3587bb --- /dev/null +++ b/app/services/data_api/usages_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module DataApi + class UsagesService < DataApi::BaseService + Result = BaseResult[:usages] + + def call + response = http_client.get(headers:, params: filtered_params) + + result.usages = response.map do |usage| + code = usage["billable_metric_code"] + usage["is_billable_metric_deleted"] = discarded_billable_metrics_codes.include?(code) + usage + end + + result + end + + private + + def discarded_billable_metrics_codes + @discarded_billable_metrics_codes ||= BillableMetric.where(organization:).with_discarded.discarded.pluck(:code) + end + + def filtered_params + if License.premium? + params.dup.tap do |filtered| + filtered[:time_granularity] ||= "daily" + end + else + { + time_granularity: "daily", + start_of_period_dt: Date.current - 30.days + }.tap do |filtered| + filtered[:billable_metric_code] = params[:billable_metric_code] if params[:billable_metric_code].present? + end + end + end + + def action_path + "usages/#{organization.id}/" + end + end +end diff --git a/app/services/data_exports/combine_parts_service.rb b/app/services/data_exports/combine_parts_service.rb new file mode 100644 index 0000000..a5775c1 --- /dev/null +++ b/app/services/data_exports/combine_parts_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module DataExports + class CombinePartsService < BaseService + def initialize(data_export:) + @data_export = data_export + super + end + + def call + result.data_export = data_export + ordered_parts = data_export.data_export_parts.order(index: :asc) + + Tempfile.create([data_export.resource_type, ".#{data_export.format}"]) do |tempfile| + export_service = data_export.export_class.new(data_export_part: ordered_parts.first) + tempfile.write(export_service.headers.join(",")) + tempfile.write("\n") + + # Note the order here, this is crucial to make sure the data is in the expected order + ids = ordered_parts.ids + # This is not the most optimal and will do N+1 queries, but the whole point is to not load the entire CSV in memory + # we're trading speed for reliability here. + ids.each do |id| + tempfile.write(data_export.data_export_parts.find(id).csv_lines) + end + + tempfile.rewind + + data_export.file.attach( + io: tempfile, + filename: data_export.filename, + key: "data_exports/#{data_export.id}-#{SecureRandom.hex(5)}.#{data_export.format}", + content_type: "text/csv" + ) + end + + data_export.completed! + DataExportMailer.with(data_export:).completed.deliver_later + + result + end + + private + + attr_reader :data_export + end +end diff --git a/app/services/data_exports/create_part_service.rb b/app/services/data_exports/create_part_service.rb new file mode 100644 index 0000000..cc1b548 --- /dev/null +++ b/app/services/data_exports/create_part_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module DataExports + class CreatePartService < BaseService + def initialize(data_export:, object_ids:, index:) + @data_export = data_export + @object_ids = object_ids + @index = index + + super + end + + def call + result.data_export_part = data_export.data_export_parts.create!( + organization_id: data_export.organization_id, + object_ids:, + index: + ) + after_commit { DataExports::ProcessPartJob.perform_later(result.data_export_part) } + result + rescue => e + result.service_failure!(code: "data_export_part_creation_failed", message: e.full_message) + end + + private + + attr_reader :data_export, :object_ids, :index + end +end diff --git a/app/services/data_exports/create_service.rb b/app/services/data_exports/create_service.rb new file mode 100644 index 0000000..58666bc --- /dev/null +++ b/app/services/data_exports/create_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module DataExports + class CreateService < BaseService + def initialize(organization:, user:, format:, resource_type:, resource_query:) + @organization = organization + @user = user + @format = format + @resource_type = resource_type + @resource_query = resource_query || {} + + super(user) + end + + def call + data_export = DataExport.create!( + organization:, + membership:, + format:, + resource_type:, + resource_query: + ) + + ExportResourcesJob.perform_later(data_export) + + register_security_log(data_export) + + result.data_export = data_export + result + end + + private + + attr_reader :organization, :user, :format, :resource_type, :resource_query + + def register_security_log(data_export) + Utils::SecurityLog.produce( + organization: organization, + log_type: "export", + log_event: "export.created", + user: user, + resources: {export_type: data_export.resource_type, resource_query: data_export.resource_query} + ) + end + + def membership + user.memberships.find_by(organization: organization) + end + end +end diff --git a/app/services/data_exports/csv/base_csv_service.rb b/app/services/data_exports/csv/base_csv_service.rb new file mode 100644 index 0000000..45062fd --- /dev/null +++ b/app/services/data_exports/csv/base_csv_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "csv" +require "forwardable" + +module DataExports + module Csv + class BaseCsvService < ::BaseService + extend Forwardable + + def call + result.csv_file = with_csv do |csv| + collection.each do |item| + serialize_item(item, csv) + end + end + + result + end + + private + + def with_csv + tempfile = Tempfile.create([data_export_part.id, ".csv"]) + yield CSV.new(tempfile, headers: false) + + tempfile.rewind + tempfile + end + end + end +end diff --git a/app/services/data_exports/csv/credit_note_items.rb b/app/services/data_exports/csv/credit_note_items.rb new file mode 100644 index 0000000..3d19e5d --- /dev/null +++ b/app/services/data_exports/csv/credit_note_items.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "csv" +require "forwardable" + +module DataExports + module Csv + class CreditNoteItems < BaseCsvService + extend Forwardable + + def initialize(data_export_part:, serializer_klass: V1::CreditNoteItemSerializer) + @data_export_part = data_export_part + @serializer_klass = serializer_klass + super + end + + def self.base_headers + %w[ + credit_note_lago_id + credit_note_number + credit_note_invoice_number + credit_note_issuing_date + credit_note_item_lago_id + credit_note_item_fee_lago_id + credit_note_item_currency + credit_note_item_amount_cents + ] + end + + def headers + self.class.base_headers.dup + end + + private + + attr_reader :data_export_part, :serializer_klass + + def serialize_item(credit_note_item, csv) + serialized_item = serializer_klass.new(credit_note_item).serialize + + serialized_note = { + lago_id: credit_note_item.credit_note.id, + number: credit_note_item.credit_note.number, + invoice_number: credit_note_item.credit_note.invoice.number, + issuing_date: credit_note_item.credit_note.issuing_date.iso8601 + } + + csv << [ + serialized_note[:lago_id], + serialized_note[:number], + serialized_note[:invoice_number], + serialized_note[:issuing_date], + serialized_item[:lago_id], + serialized_item.dig(:fee, :lago_id), + serialized_item[:amount_currency], + serialized_item[:amount_cents] + ] + end + + def collection + CreditNoteItem + .includes(:credit_note, :fee) + .where(credit_note_id: data_export_part.object_ids) + end + end + end +end diff --git a/app/services/data_exports/csv/credit_notes.rb b/app/services/data_exports/csv/credit_notes.rb new file mode 100644 index 0000000..4c7ae3f --- /dev/null +++ b/app/services/data_exports/csv/credit_notes.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "csv" +require "forwardable" + +module DataExports + module Csv + class CreditNotes < BaseCsvService + extend Forwardable + + def initialize(data_export_part:, serializer_klass: V1::CreditNoteSerializer) + @data_export_part = data_export_part + @serializer_klass = serializer_klass + super + end + + def self.base_headers + %w[ + lago_id + sequential_id + partner_billing + issuing_date + customer_lago_id + customer_external_id + customer_name + customer_email + customer_country + customer_tax_identification_number + number + invoice_number + credit_status + refund_status + reason + description + currency + total_amount_cents + taxes_amount_cents + sub_total_excluding_taxes_amount_cents + coupons_adjustment_amount_cents + offset_amount_cents + credit_amount_cents + balance_amount_cents + refund_amount_cents + file_url + ] + end + + def headers + base = self.class.base_headers.dup + base << "billing_entity_code" if org_has_multiple_billing_entities? + base + end + + private + + attr_reader :data_export_part, :serializer_klass + + def serialize_item(credit_note, csv) + serialized_note = serializer_klass.new(credit_note, includes: %i[customer]).serialize + + row = [ + serialized_note[:lago_id], + serialized_note[:sequential_id], + serialized_note[:self_billed], + serialized_note[:issuing_date], + serialized_note.dig(:customer, :lago_id), + serialized_note.dig(:customer, :external_id), + serialized_note.dig(:customer, :name), + serialized_note.dig(:customer, :email), + serialized_note.dig(:customer, :country), + serialized_note.dig(:customer, :tax_identification_number), + serialized_note[:number], + serialized_note[:invoice_number], + serialized_note[:credit_status], + serialized_note[:refund_status], + serialized_note[:reason], + serialized_note[:description], + serialized_note[:currency], + serialized_note[:total_amount_cents], + serialized_note[:taxes_amount_cents], + serialized_note[:sub_total_excluding_taxes_amount_cents], + serialized_note[:coupons_adjustment_amount_cents], + serialized_note[:offset_amount_cents], + serialized_note[:credit_amount_cents], + serialized_note[:balance_amount_cents], + serialized_note[:refund_amount_cents], + serialized_note[:file_url] + ] + row << serialized_note[:billing_entity_code] if org_has_multiple_billing_entities? + csv << row + end + + def collection + CreditNote.includes(:customer).find(data_export_part.object_ids) + end + + def organization + @organization ||= data_export_part.data_export.organization + end + + def org_has_multiple_billing_entities? + return false unless organization + + organization.billing_entities.count > 1 + end + end + end +end diff --git a/app/services/data_exports/csv/invoice_fees.rb b/app/services/data_exports/csv/invoice_fees.rb new file mode 100644 index 0000000..0f118dd --- /dev/null +++ b/app/services/data_exports/csv/invoice_fees.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "csv" +require "forwardable" + +module DataExports + module Csv + class InvoiceFees < BaseCsvService + extend Forwardable + + def initialize( + data_export_part:, + invoice_serializer_klass: V1::InvoiceSerializer, + fee_serializer_klass: V1::FeeSerializer + ) + @data_export_part = data_export_part + @invoice_serializer_klass = invoice_serializer_klass + @fee_serializer_klass = fee_serializer_klass + super + end + + def self.base_headers + %w[ + invoice_lago_id + invoice_number + invoice_issuing_date + fee_lago_id + fee_item_type + fee_item_code + fee_item_name + fee_item_description + fee_item_invoice_display_name + fee_item_filter_invoice_display_name + fee_item_grouped_by + subscription_external_id + subscription_plan_code + fee_from_date_utc + fee_to_date_utc + fee_amount_currency + fee_units + fee_precise_unit_amount + fee_taxes_amount_cents + fee_total_amount_cents + ] + end + + def headers + self.class.base_headers.dup + end + + private + + attr_reader :data_export_part, :invoice_serializer_klass, :fee_serializer_klass + + def serialize_item(invoice, csv) + serialized_invoice = invoice_serializer_klass.new(invoice).serialize + + invoice + .fees + .includes( + :invoice, + :subscription, + :charge, + :true_up_fee, + :customer, + :billable_metric, + {charge_filter: {values: :billable_metric_filter}} + ) + .find_each + .lazy + .each do |fee| + serialized_fee = fee_serializer_klass.new(fee).serialize + + serialized_subscription = { + external_id: fee.subscription&.external_id, + plan_code: fee.subscription&.plan&.code + } + + csv << [ + serialized_invoice[:lago_id], + serialized_invoice[:number], + serialized_invoice[:issuing_date], + serialized_fee[:lago_id], + serialized_fee.dig(:item, :type), + serialized_fee.dig(:item, :code), + serialized_fee.dig(:item, :name), + serialized_fee.dig(:item, :description), + serialized_fee.dig(:item, :invoice_display_name), + serialized_fee.dig(:item, :filter_invoice_display_name), + serialized_fee.dig(:item, :grouped_by), + serialized_subscription[:external_id], + serialized_subscription[:plan_code], + serialized_fee[:from_date], + serialized_fee[:to_date], + serialized_fee[:total_amount_currency], + serialized_fee[:units], + serialized_fee[:precise_unit_amount], + serialized_fee[:taxes_amount_cents], + serialized_fee[:total_amount_cents] + ] + end + end + + def collection + Invoice.find(data_export_part.object_ids) + end + end + end +end diff --git a/app/services/data_exports/csv/invoices.rb b/app/services/data_exports/csv/invoices.rb new file mode 100644 index 0000000..8d64f0c --- /dev/null +++ b/app/services/data_exports/csv/invoices.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "csv" +require "forwardable" + +module DataExports + module Csv + class Invoices < BaseCsvService + extend Forwardable + + def initialize(data_export_part:, serializer_klass: V1::InvoiceSerializer) + @data_export_part = data_export_part + @serializer_klass = serializer_klass + @progressive_billing_enabled = organization&.progressive_billing_enabled? + super + end + + def self.base_headers + %w[ + lago_id + sequential_id + partner_billing + issuing_date + customer_lago_id + customer_external_id + customer_name + customer_email + customer_country + customer_tax_identification_number + invoice_number + invoice_type + payment_status + status + file_url + currency + fees_amount_cents + coupons_amount_cents + taxes_amount_cents + credit_notes_amount_cents + prepaid_credit_amount_cents + total_amount_cents + payment_due_date + payment_dispute_lost_at + payment_overdue + total_due_amount_cents + total_paid_amount_cents + total_offsetted_credit_note_amount_cents + ] + end + + def headers + base = self.class.base_headers.dup + base << "progressive_billing_credit_amount_cents" if progressive_billing_enabled + base << "billing_entity_code" if org_has_multiple_billing_entities? + base + end + + private + + attr_reader :data_export_part, :serializer_klass, :progressive_billing_enabled + + def serialize_item(invoice, csv) + serialized_invoice = serializer_klass + .new(invoice, includes: %i[customer]) + .serialize + + row = [ + serialized_invoice[:lago_id], + serialized_invoice[:sequential_id], + serialized_invoice[:self_billed], + serialized_invoice[:issuing_date], + serialized_invoice.dig(:customer, :lago_id), + serialized_invoice.dig(:customer, :external_id), + serialized_invoice.dig(:customer, :name), + serialized_invoice.dig(:customer, :email), + serialized_invoice.dig(:customer, :country), + serialized_invoice.dig(:customer, :tax_identification_number), + serialized_invoice[:number], + serialized_invoice[:invoice_type], + serialized_invoice[:payment_status], + serialized_invoice[:status], + serialized_invoice[:file_url], + serialized_invoice[:currency], + serialized_invoice[:fees_amount_cents], + serialized_invoice[:coupons_amount_cents], + serialized_invoice[:taxes_amount_cents], + serialized_invoice[:credit_notes_amount_cents], + serialized_invoice[:prepaid_credit_amount_cents], + serialized_invoice[:total_amount_cents], + serialized_invoice[:payment_due_date], + serialized_invoice[:payment_dispute_lost_at], + serialized_invoice[:payment_overdue], + serialized_invoice[:total_due_amount_cents], + serialized_invoice[:total_paid_amount_cents], + serialized_invoice[:total_offsetted_credit_note_amount_cents] + ] + + row << serialized_invoice[:progressive_billing_credit_amount_cents] if progressive_billing_enabled + row << serialized_invoice[:billing_entity_code] if org_has_multiple_billing_entities? + csv << row + end + + def collection + Invoice.preload_offset_amounts(Invoice.find(data_export_part.object_ids)) + end + + def organization + @organization ||= data_export_part.data_export.organization + end + + def org_has_multiple_billing_entities? + return false unless organization + + organization.billing_entities.count > 1 + end + end + end +end diff --git a/app/services/data_exports/export_resources_service.rb b/app/services/data_exports/export_resources_service.rb new file mode 100644 index 0000000..f077e06 --- /dev/null +++ b/app/services/data_exports/export_resources_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module DataExports + class ExportResourcesService < BaseService + EXPIRED_FAILURE_MESSAGE = "Data Export already expired" + PROCESSED_FAILURE_MESSAGE = "Data Export already processed" + DEFAULT_BATCH_SIZE = 20 + + ResourceTypeNotSupportedError = Class.new(StandardError) + + extend Forwardable + + def_delegators :data_export, :organization, :resource_query, :resource_type, :format + + def initialize(data_export:, batch_size: DEFAULT_BATCH_SIZE) + @data_export = data_export + @batch_size = batch_size + + super + end + + def call + return result.service_failure!(code: "data_export_expired", message: EXPIRED_FAILURE_MESSAGE) if data_export.expired? + return result.service_failure!(code: "data_export_processed", message: PROCESSED_FAILURE_MESSAGE) unless data_export.pending? + + data_export.processing! + result.data_export = data_export + result.data_export_parts = [] + + data_export.transaction do + all_object_ids.each_slice(batch_size).with_index do |object_ids, index| + part_result = DataExports::CreatePartService.call(data_export:, object_ids:, index:).raise_if_error! + result.data_export_parts << part_result.data_export_part + end + end + + result + rescue => e + data_export.failed! + result.service_failure!(code: e.message, message: e.full_message) + end + + private + + attr_reader :data_export, :batch_size + + def all_object_ids + case resource_type + when "credit_notes", "credit_note_items" then credit_note_ids + when "invoices", "invoice_fees" then all_invoice_ids + else + raise ResourceTypeNotSupportedError.new( + "'#{resource_type}' resource not supported" + ) + end + end + + def all_invoice_ids + search_term = resource_query["search_term"] + filters = resource_query.except("search_term") + + InvoicesQuery.call( + organization:, + pagination: nil, + search_term:, + filters: + ).invoices.pluck(:id).uniq + end + + def credit_note_ids + search_term = resource_query["search_term"] + filters = resource_query.except("search_term") + + CreditNotesQuery.call( + organization:, + pagination: nil, + search_term:, + filters: + ).credit_notes.pluck(:id).uniq + end + end +end diff --git a/app/services/data_exports/process_part_service.rb b/app/services/data_exports/process_part_service.rb new file mode 100644 index 0000000..1fe54e6 --- /dev/null +++ b/app/services/data_exports/process_part_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module DataExports + class ProcessPartService < BaseService + def initialize(data_export_part:) + @data_export_part = data_export_part + @data_export = data_export_part.data_export + super(nil) + end + + def call + result.data_export_part = data_export_part + return result if data_export_part.completed + + # produce CSV lines into StringIO + export_result = data_export.export_class.call(data_export_part:).raise_if_error! + file = export_result.csv_file + data_export_part.update!(csv_lines: file.read, completed: true) + # Explicitly close and unlink the file + file.close + File.unlink(file.path) + + # check if we are the last one to finish + if last_completed + after_commit { DataExports::CombinePartsJob.perform_later(data_export_part.data_export) } + end + result + end + + private + + attr_reader :data_export_part, :data_export + + def last_completed + data_export.data_export_parts.completed.count == data_export.data_export_parts.count + end + end +end diff --git a/app/services/dunning_campaigns/bulk_process_service.rb b/app/services/dunning_campaigns/bulk_process_service.rb new file mode 100644 index 0000000..a8a11fc --- /dev/null +++ b/app/services/dunning_campaigns/bulk_process_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module DunningCampaigns + class BulkProcessService < BaseService + def call + return result unless License.premium? + + eligible_customers.find_each do |customer| + CustomerDunningEvaluator.call(customer) + end + + result + end + + private + + def eligible_customers + Customer + .joins(:organization) + .where(exclude_from_dunning_campaign: false) + .where("organizations.premium_integrations @> ARRAY[?]::varchar[]", ["auto_dunning"]) + .where( + id: Invoice.where(payment_overdue: true, self_billed: false) + .select(:customer_id) + ) + end + + class CustomerDunningEvaluator < BaseService + def initialize(customer) + @customer = customer + @billing_entity = customer.billing_entity + @dunning_campaign = applicable_dunning_campaign + @threshold = applicable_dunning_campaign_threshold + end + + def call + return result unless threshold + return result unless days_between_attempts_satisfied? + return result if max_attempts_reached? + + DunningCampaigns::ProcessAttemptJob.perform_later(customer:, dunning_campaign_threshold: threshold) + + result + end + + private + + attr_reader :customer, :dunning_campaign, :threshold, :billing_entity + + def applicable_dunning_campaign + customer.applied_dunning_campaign || billing_entity.applied_dunning_campaign + end + + def applicable_dunning_campaign_threshold + return unless dunning_campaign + + dunning_campaign + .thresholds + .where(currency: customer.currency) + .find_by("amount_cents <= ?", customer.overdue_balance_cents) + end + + def max_attempts_reached? + customer.last_dunning_campaign_attempt >= dunning_campaign.max_attempts + end + + def days_between_attempts_satisfied? + return true unless customer.last_dunning_campaign_attempt_at + + next_attempt_date = customer.last_dunning_campaign_attempt_at + dunning_campaign.days_between_attempts.days + + Time.zone.now >= next_attempt_date + end + end + end +end diff --git a/app/services/dunning_campaigns/create_service.rb b/app/services/dunning_campaigns/create_service.rb new file mode 100644 index 0000000..5728538 --- /dev/null +++ b/app/services/dunning_campaigns/create_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module DunningCampaigns + class CreateService < BaseService + def initialize(organization:, params:) + @organization = organization + @params = params + + super + end + + def call + return result.forbidden_failure! unless organization.auto_dunning_enabled? + return result.validation_failure!(errors: {thresholds: ["can't be blank"]}) if params[:thresholds].blank? + + ActiveRecord::Base.transaction do + if params[:applied_to_organization] + organization.dunning_campaigns.applied_to_organization + .update_all(applied_to_organization: false) # rubocop:disable Rails/SkipsModelValidations + + organization.default_billing_entity.reset_customers_last_dunning_campaign_attempt + end + + dunning_campaign = organization.dunning_campaigns.create!( + code: params[:code], + bcc_emails: Array.wrap(params[:bcc_emails]), + days_between_attempts: params[:days_between_attempts], + max_attempts: params[:max_attempts], + name: params[:name], + description: params[:description], + thresholds_attributes: params[:thresholds].map { it.to_h.merge(organization_id: organization.id) } + ) + organization.default_billing_entity.update!(applied_dunning_campaign: dunning_campaign) if params[:applied_to_organization] + + result.dunning_campaign = dunning_campaign + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :params + end +end diff --git a/app/services/dunning_campaigns/destroy_service.rb b/app/services/dunning_campaigns/destroy_service.rb new file mode 100644 index 0000000..dea5b66 --- /dev/null +++ b/app/services/dunning_campaigns/destroy_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module DunningCampaigns + class DestroyService < BaseService + def initialize(dunning_campaign:) + @dunning_campaign = dunning_campaign + + super + end + + def call + return result.not_found_failure!(resource: "dunning_campaign") unless dunning_campaign + return result.forbidden_failure! unless dunning_campaign.organization.auto_dunning_enabled? + + # rubocop:disable Rails/SkipsModelValidations + ActiveRecord::Base.transaction do + dunning_campaign.reset_customers_last_attempt + dunning_campaign.discard! + dunning_campaign.thresholds.update_all(deleted_at: Time.current) + dunning_campaign.customers.update_all(applied_dunning_campaign_id: nil) + dunning_campaign.billing_entities.update_all(applied_dunning_campaign_id: nil) + end + # rubocop:enable Rails/SkipsModelValidations + + result.dunning_campaign = dunning_campaign + result + end + + private + + attr_reader :dunning_campaign + end +end diff --git a/app/services/dunning_campaigns/process_attempt_service.rb b/app/services/dunning_campaigns/process_attempt_service.rb new file mode 100644 index 0000000..ac5f261 --- /dev/null +++ b/app/services/dunning_campaigns/process_attempt_service.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module DunningCampaigns + class ProcessAttemptService < BaseService + def initialize(customer:, dunning_campaign_threshold:) + @customer = customer + @dunning_campaign_threshold = dunning_campaign_threshold + @dunning_campaign = dunning_campaign_threshold.dunning_campaign + @organization = customer.organization + @billing_entity = customer.billing_entity + + super + end + + def call + return result unless organization.auto_dunning_enabled? + return result unless applicable_dunning_campaign? + return result unless dunning_campaign_threshold_reached? + return result unless days_between_attempts_passed? + return result if max_attempts_reached? + + ActiveRecord::Base.transaction do + payment_request_result = PaymentRequests::CreateService.call( + organization:, + params: { + external_customer_id: customer.external_id, + lago_invoice_ids: overdue_invoices.pluck(:id) + }, + dunning_campaign: + ).raise_if_error! + + customer.increment(:last_dunning_campaign_attempt) + customer.last_dunning_campaign_attempt_at = Time.zone.now + customer.save! + + send_campaign_finished_webhook if max_attempts_reached? + + result.customer = customer + result.payment_request = payment_request_result.payment_request + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :customer, :dunning_campaign, :dunning_campaign_threshold, :organization, :billing_entity + + def applicable_dunning_campaign? + return false if customer.exclude_from_dunning_campaign? + + custom_campaign = customer.applied_dunning_campaign + default_campaign = billing_entity.applied_dunning_campaign + + custom_campaign == dunning_campaign || (!custom_campaign && default_campaign == dunning_campaign) + end + + def dunning_campaign_threshold_reached? + overdue_invoices.sum(:total_amount_cents) >= dunning_campaign_threshold.amount_cents + end + + def days_between_attempts_passed? + return true unless customer.last_dunning_campaign_attempt_at + + (customer.last_dunning_campaign_attempt_at + dunning_campaign.days_between_attempts.days).past? + end + + def max_attempts_reached? + customer.last_dunning_campaign_attempt >= dunning_campaign.max_attempts + end + + def overdue_invoices + customer + .invoices + .payment_overdue + .where(ready_for_payment_processing: true) + .where(currency: dunning_campaign_threshold.currency) + end + + def send_campaign_finished_webhook + SendWebhookJob.perform_later( + "dunning_campaign.finished", + customer, + dunning_campaign_code: dunning_campaign.code + ) + end + end +end diff --git a/app/services/dunning_campaigns/update_service.rb b/app/services/dunning_campaigns/update_service.rb new file mode 100644 index 0000000..0cfa57a --- /dev/null +++ b/app/services/dunning_campaigns/update_service.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module DunningCampaigns + class UpdateService < BaseService + def initialize(organization:, dunning_campaign:, params:) + @dunning_campaign = dunning_campaign + @organization = organization + @default_billing_entity = organization.default_billing_entity + @params = params + + super + end + + def call + return result.forbidden_failure! unless organization.auto_dunning_enabled? + return result.not_found_failure!(resource: "dunning_campaign") unless dunning_campaign + + ActiveRecord::Base.transaction do + dunning_campaign.assign_attributes(permitted_attributes) + handle_thresholds if params.key?(:thresholds) + # TODO: remove this when FE is released and we handle applied on billing entity + handle_applied_to_organization_update if params.key?(:applied_to_organization) + + dunning_campaign.save! + end + + result.dunning_campaign = dunning_campaign + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :dunning_campaign, :organization, :params, :default_billing_entity + + def permitted_attributes + params.slice(:name, :bcc_emails, :code, :description, :days_between_attempts, :max_attempts) + end + + def handle_thresholds + input_threshold_ids = params[:thresholds].map { |t| t[:id] }.compact + + # Delete thresholds not included in the payload + discarded_thresholds = dunning_campaign # rubocop:disable Lago/DiscardAll + .thresholds + .where.not(id: input_threshold_ids) + .discard_all + + thresholds_updated = discarded_thresholds.present? + + # Update or create new thresholds from the input + params[:thresholds].each do |threshold_input| + threshold = dunning_campaign.thresholds + .find_or_initialize_by(id: threshold_input[:id]) { |t| t.organization_id = organization.id } + + threshold.assign_attributes(threshold_input.to_h.slice(:amount_cents, :currency)) + + thresholds_updated ||= threshold.changed? && threshold.persisted? + threshold.save! + end + + reset_customers_if_no_threshold_match if thresholds_updated + end + + def reset_customers_if_no_threshold_match + customers_to_reset + .includes(:invoices) + .where(invoices: {payment_overdue: true}).find_each do |customer| + threshold_matches = dunning_campaign.thresholds.any? do |threshold| + threshold.currency == customer.currency && + customer.overdue_balance_cents >= threshold.amount_cents + end + + unless threshold_matches + customer.update!( + last_dunning_campaign_attempt: 0, + last_dunning_campaign_attempt_at: nil + ) + end + end + end + + def customers_to_reset + @customers_to_reset ||= customers_applied_campaign.or(customers_fallback_campaign) + end + + def customers_applied_campaign + organization.customers.where(applied_dunning_campaign: dunning_campaign) + end + + def customers_fallback_campaign + organization.customers.falling_back_to_default_dunning_campaign.where(billing_entity_id: dunning_campaign.billing_entities.ids) + end + + def handle_applied_to_organization_update + new_dunning_campaign_id = params[:applied_to_organization] ? dunning_campaign.id : nil + return if default_billing_entity.applied_dunning_campaign_id == new_dunning_campaign_id + + default_billing_entity.update!(applied_dunning_campaign_id: new_dunning_campaign_id) + default_billing_entity.reset_customers_last_dunning_campaign_attempt + end + end +end diff --git a/app/services/e_invoices/credit_notes/factur_x/create_service.rb b/app/services/e_invoices/credit_notes/factur_x/create_service.rb new file mode 100644 index 0000000..337be10 --- /dev/null +++ b/app/services/e_invoices/credit_notes/factur_x/create_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EInvoices + module CreditNotes::FacturX + class CreateService < ::BaseService + def initialize(credit_note:) + super + + @credit_note = credit_note + end + + def call + return result.not_found_failure!(resource: "credit_note") unless credit_note + + result.xml = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + EInvoices::CreditNotes::FacturX::Builder.serialize(xml:, credit_note:) + end.to_xml + + result + end + + private + + attr_accessor :credit_note + end + end +end diff --git a/app/services/e_invoices/credit_notes/ubl/create_service.rb b/app/services/e_invoices/credit_notes/ubl/create_service.rb new file mode 100644 index 0000000..f2723aa --- /dev/null +++ b/app/services/e_invoices/credit_notes/ubl/create_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EInvoices + module CreditNotes::Ubl + class CreateService < ::BaseService + def initialize(credit_note:) + super + + @credit_note = credit_note + end + + def call + return result.not_found_failure!(resource: "credit_note") unless credit_note + + result.xml = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + EInvoices::CreditNotes::Ubl::Builder.serialize(xml:, credit_note:) + end.to_xml + + result + end + + private + + attr_accessor :credit_note + end + end +end diff --git a/app/services/e_invoices/invoices/factur_x/create_service.rb b/app/services/e_invoices/invoices/factur_x/create_service.rb new file mode 100644 index 0000000..fb2821a --- /dev/null +++ b/app/services/e_invoices/invoices/factur_x/create_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EInvoices + module Invoices::FacturX + class CreateService < ::BaseService + def initialize(invoice:) + super + + @invoice = invoice + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + + result.xml = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + ::EInvoices::Invoices::FacturX::Builder.serialize(xml:, invoice:) + end.to_xml + + result + end + + private + + attr_accessor :invoice + end + end +end diff --git a/app/services/e_invoices/invoices/ubl/create_service.rb b/app/services/e_invoices/invoices/ubl/create_service.rb new file mode 100644 index 0000000..a6f6ccf --- /dev/null +++ b/app/services/e_invoices/invoices/ubl/create_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module EInvoices + module Invoices::Ubl + class CreateService < ::BaseService + def initialize(invoice:) + super + + @invoice = invoice + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + + result.xml = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + ::EInvoices::Invoices::Ubl::Builder.serialize(xml:, invoice:) + end.to_xml + + result + end + + private + + attr_accessor :invoice + end + end +end diff --git a/app/services/e_invoices/payments/factur_x/create_service.rb b/app/services/e_invoices/payments/factur_x/create_service.rb new file mode 100644 index 0000000..9ed2c77 --- /dev/null +++ b/app/services/e_invoices/payments/factur_x/create_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module EInvoices + module Payments::FacturX + class CreateService < ::BaseService + def initialize(payment:) + super + + @payment = payment + end + + def call + return result.not_found_failure!(resource: "payment") unless payment + return result.forbidden_failure! unless payment.organization.issue_receipts_enabled? + + result.xml = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + EInvoices::Payments::FacturX::Builder.serialize(xml:, payment:) + end.to_xml + + result + end + + private + + attr_accessor :payment + end + end +end diff --git a/app/services/e_invoices/payments/ubl/create_service.rb b/app/services/e_invoices/payments/ubl/create_service.rb new file mode 100644 index 0000000..47267f5 --- /dev/null +++ b/app/services/e_invoices/payments/ubl/create_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module EInvoices + module Payments::Ubl + class CreateService < ::BaseService + def initialize(payment:) + super + + @payment = payment + end + + def call + return result.not_found_failure!(resource: "payment") unless payment + return result.forbidden_failure! unless payment.organization.issue_receipts_enabled? + + result.xml = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + EInvoices::Payments::Ubl::Builder.serialize(xml:, payment:) + end.to_xml + + result + end + + private + + attr_accessor :payment + end + end +end diff --git a/app/services/emails/resend_service.rb b/app/services/emails/resend_service.rb new file mode 100644 index 0000000..0fc9a1b --- /dev/null +++ b/app/services/emails/resend_service.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Emails + class ResendService < BaseService + Result = BaseResult + + def initialize(resource:, to: nil, cc: nil, bcc: nil) + @resource = resource + @to = to + @cc = cc + @bcc = bcc + super + end + + def call + return result.not_found_failure!(resource: resource_type) unless resource + return result.not_allowed_failure!(code: "#{resource_type}_not_finalized") unless valid_status? + return result.forbidden_failure!(code: "premium_license_required") unless License.premium? + return result.not_allowed_failure!(code: "email_settings_disabled") unless email_settings_enabled? + return result.validation_failure!(errors: validation_errors) if validation_errors.any? + + send_email + result + end + + private + + attr_reader :resource, :to, :cc, :bcc + + def send_email + mailer_class + .with(mailer_params) + .created + .deliver_later + end + + def mailer_class + case resource + when Invoice then InvoiceMailer + when CreditNote then CreditNoteMailer + when PaymentReceipt then PaymentReceiptMailer + end + end + + def mailer_params + param_key = resource.class.name.underscore.to_sym + {param_key => resource, :resend => true, :to => recipients_to, :cc => recipients_cc, :bcc => recipients_bcc} + end + + def valid_status? + return true if resource.is_a?(PaymentReceipt) + + resource.finalized? + end + + def billing_entity + case resource + when Invoice, PaymentReceipt + resource.billing_entity + when CreditNote + resource.invoice.billing_entity + end + end + + def customer + return resource.payment.payable.customer if resource.is_a?(PaymentReceipt) + + resource.customer + end + + def email_settings_enabled? + billing_entity.email_settings.include?(email_settings_key) + end + + def email_settings_key + { + "Invoice" => "invoice.finalized", + "CreditNote" => "credit_note.created", + "PaymentReceipt" => "payment_receipt.created" + }[resource.class.name] + end + + def resource_type + resource&.class&.name&.underscore || "resource" + end + + def recipients_to + return Array(to) if to.present? + + [customer.email].compact + end + + def recipients_cc + Array(cc) + end + + def recipients_bcc + Array(bcc) + end + + def validation_errors + errors = {} + errors[:billing_entity] = ["must have email configured"] if billing_entity.email.blank? + errors[:to] = ["must have at least one recipient"] if recipients_to.empty? + + invalid_to = recipients_to.reject { |email| valid_email?(email) } + errors[:to] = ["invalid email format: #{invalid_to.join(", ")}"] if invalid_to.any? + + invalid_cc = recipients_cc.reject { |email| valid_email?(email) } + errors[:cc] = ["invalid email format: #{invalid_cc.join(", ")}"] if invalid_cc.any? + + invalid_bcc = recipients_bcc.reject { |email| valid_email?(email) } + errors[:bcc] = ["invalid email format: #{invalid_bcc.join(", ")}"] if invalid_bcc.any? + + errors + end + + def valid_email?(email) + email.match?(URI::MailTo::EMAIL_REGEXP) + end + end +end diff --git a/app/services/entitlement/concerns/create_or_update_concern.rb b/app/services/entitlement/concerns/create_or_update_concern.rb new file mode 100644 index 0000000..fb0addd --- /dev/null +++ b/app/services/entitlement/concerns/create_or_update_concern.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Entitlement + module Concerns + module CreateOrUpdateConcern + extend ActiveSupport::Concern + + def validate_value(value, privilege) + return value if value.nil? + + if privilege.value_type == "select" + if privilege.config.dig("select_options").include?(value) + return value + end + + raise BaseService::ValidationFailure.new(result, messages: {"#{privilege.code}_privilege_value": ["value_not_in_select_options"]}) + end + + if privilege.value_type == "boolean" && [true, false, "true", "false"].include?(value) + return value + end + + if privilege.value_type == "integer" + return value if value.is_a?(Integer) + return value if value.is_a?(String) && value.to_i.to_s == value + end + return value if privilege.value_type == "string" && value.is_a?(String) + + raise BaseService::ValidationFailure.new(result, messages: {"#{privilege.code}_privilege_value": ["value_is_invalid"]}) + end + end + end +end diff --git a/app/services/entitlement/feature_create_service.rb b/app/services/entitlement/feature_create_service.rb new file mode 100644 index 0000000..89c1663 --- /dev/null +++ b/app/services/entitlement/feature_create_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Entitlement + class FeatureCreateService < BaseService + Result = BaseResult[:feature] + + def initialize(organization:, params:) + @organization = organization + @params = params.to_h.with_indifferent_access + super + end + + activity_loggable( + action: "feature.created", + record: -> { result.feature } + ) + + def call + return result.not_found_failure!(resource: "organization") unless organization + + if Utils::Entitlement.privilege_code_is_duplicated?(params[:privileges]) + return result.single_validation_failure!(field: :"privilege.code", error_code: "value_is_duplicated") + end + + ActiveRecord::Base.transaction do + feature = Feature.create!( + organization:, + code: params[:code]&.strip, + name: params[:name], + description: params[:description] + ) + + if params[:privileges].present? + create_privileges(feature, params[:privileges]) + end + + result.feature = feature + end + + SendWebhookJob.perform_after_commit("feature.created", result.feature) + + result + rescue ActiveRecord::RecordInvalid => e + if e.record.is_a?(Privilege) + # because you can get "code" error from feature or privilege, I think prefixing the field name is helpful! + errors = e.record.errors.messages.transform_keys { |key| :"privilege.#{key}" } + result.validation_failure!(errors:) + else + result.record_validation_failure!(record: e.record) + end + rescue ActiveRecord::RecordNotUnique + result.single_validation_failure!(field: :code, error_code: "value_already_exist") + end + + private + + attr_reader :organization, :params + + def create_privileges(feature, privileges_params) + privileges_params.each do |privilege_params| + privilege = feature.privileges.new( + organization:, + code: privilege_params[:code]&.strip, + name: privilege_params[:name] + ) + privilege.value_type = privilege_params[:value_type] if privilege_params.has_key? :value_type + privilege.config = privilege_params[:config] if privilege_params.has_key? :config + + privilege.save! + end + end + end +end diff --git a/app/services/entitlement/feature_destroy_service.rb b/app/services/entitlement/feature_destroy_service.rb new file mode 100644 index 0000000..0616f50 --- /dev/null +++ b/app/services/entitlement/feature_destroy_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Entitlement + class FeatureDestroyService < BaseService + Result = BaseResult[:feature] + + def initialize(feature:) + @feature = feature + super + end + + activity_loggable( + action: "feature.deleted", + record: -> { result.feature } + ) + + def call + return result.not_found_failure!(resource: "feature") unless feature + + plans = feature.plans.to_a + + ActiveRecord::Base.transaction do + feature.entitlement_values.discard_all! + feature.entitlements.discard_all! + feature.privileges.discard_all! + feature.discard! + end + + jobs = [] + plans.each do |plan| + Utils::ActivityLog.produce_after_commit(plan, "plan.updated") + jobs << SendWebhookJob.new("plan.updated", plan) + end + + after_commit do + ActiveJob.perform_all_later(jobs) + SendWebhookJob.perform_later("feature.deleted", feature) + end + + result.feature = feature + result + end + + private + + attr_reader :feature + end +end diff --git a/app/services/entitlement/feature_update_service.rb b/app/services/entitlement/feature_update_service.rb new file mode 100644 index 0000000..fa922ca --- /dev/null +++ b/app/services/entitlement/feature_update_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Entitlement + class FeatureUpdateService < BaseService + Result = BaseResult[:feature] + + def initialize(feature:, params:, partial:) + @feature = feature + @params = params.to_h.with_indifferent_access + @partial = partial + super + end + + activity_loggable( + action: "feature.updated", + record: -> { feature } + ) + + def call + return result.not_found_failure!(resource: "feature") unless feature + + if Utils::Entitlement.privilege_code_is_duplicated?(params[:privileges]) + return result.single_validation_failure!(field: :"privilege.code", error_code: "value_is_duplicated") + end + + ActiveRecord::Base.transaction do + update_feature_attributes + delete_missing_privileges unless partial? + update_privileges + + feature.save! + end + + jobs = feature.plans.map do |plan| + Utils::ActivityLog.produce_after_commit(plan, "plan.updated") + SendWebhookJob.new("plan.updated", plan) + end + + # NOTE: The webhooks are sent even if there was no actual change + after_commit do + ActiveJob.perform_all_later(jobs) + SendWebhookJob.perform_later("feature.updated", feature) + end + + result.feature = feature + result + rescue ActiveRecord::RecordInvalid => e + if e.record.is_a?(Privilege) + errors = e.record.errors.messages.transform_keys { |key| :"privilege.#{key}" } + result.validation_failure!(errors:) + else + result.record_validation_failure!(record: e.record) + end + end + + private + + attr_reader :feature, :params, :partial + alias_method :partial?, :partial + + def update_feature_attributes + feature.name = params[:name] if params.key?(:name) + feature.description = params[:description] if params.key?(:description) + end + + def update_privileges + return if params[:privileges].blank? + + params[:privileges].each do |privilege_params| + privilege = feature.privileges.find { it[:code] == privilege_params[:code] } + + if privilege.nil? + create_privilege(privilege_params) + else + privilege.name = privilege_params[:name] if privilege_params.key?(:name) + + if privilege_params.dig(:config, :select_options) + privilege.config["select_options"] ||= [] + privilege.config["select_options"] |= privilege_params[:config][:select_options] + end + + privilege.save! + end + end + end + + def create_privilege(privilege_params) + privilege = feature.privileges.new( + organization: feature.organization, + code: privilege_params[:code]&.strip, + name: privilege_params[:name] + ) + privilege.value_type = privilege_params[:value_type] if privilege_params.has_key? :value_type + privilege.config = privilege_params[:config] if privilege_params.has_key? :config + + privilege.save! + end + + def delete_missing_privileges + # Find privileges that are in the database but not in the params + # Delete all EntitlementValues associated with those privileges + # Then delete the privileges themselves + missing_privilege_codes = feature.privileges.pluck(:code) - (params[:privileges] || []).pluck(:code) + EntitlementValue.where(privilege: feature.privileges.where(code: missing_privilege_codes)).discard_all! + feature.privileges.where(code: missing_privilege_codes).discard_all! + missing_privilege_codes.each do |code| + privilege = feature.privileges.find { it[:code] == code } + next unless privilege + privilege.discard! + end + end + end +end diff --git a/app/services/entitlement/plan_entitlement_destroy_service.rb b/app/services/entitlement/plan_entitlement_destroy_service.rb new file mode 100644 index 0000000..08ec69e --- /dev/null +++ b/app/services/entitlement/plan_entitlement_destroy_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Entitlement + class PlanEntitlementDestroyService < BaseService + Result = BaseResult[:entitlement] + + def initialize(entitlement:) + @entitlement = entitlement + super + end + + activity_loggable( + action: "plan.updated", + record: -> { entitlement&.plan } + ) + + def call + return result.not_found_failure!(resource: "entitlement") unless entitlement + + ActiveRecord::Base.transaction do + entitlement.values.discard_all! + entitlement.discard! + end + + SendWebhookJob.perform_after_commit("plan.updated", entitlement.plan) + + result.entitlement = entitlement + result + end + + private + + attr_reader :entitlement + end +end diff --git a/app/services/entitlement/plan_entitlement_privilege_destroy_service.rb b/app/services/entitlement/plan_entitlement_privilege_destroy_service.rb new file mode 100644 index 0000000..86140da --- /dev/null +++ b/app/services/entitlement/plan_entitlement_privilege_destroy_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Entitlement + class PlanEntitlementPrivilegeDestroyService < BaseService + Result = BaseResult[:entitlement] + + def initialize(entitlement:, privilege_code:) + @entitlement = entitlement + @privilege_code = privilege_code + super + end + + activity_loggable( + action: "plan.updated", + record: -> { entitlement&.plan } + ) + + def call + return result.not_found_failure!(resource: "entitlement") unless entitlement + + entitlement_value = find_entitlement_value + return result.not_found_failure!(resource: "privilege") unless entitlement_value + + entitlement_value.discard! + + SendWebhookJob.perform_after_commit("plan.updated", entitlement.plan) + + # NOTE: reload the entitlement with all the associations required to serialize it + result.entitlement = Entitlement.includes(:feature, values: :privilege).find_by(id: entitlement.id) + result + end + + private + + attr_reader :entitlement, :privilege_code + + def find_entitlement_value + entitlement.values.joins(:privilege).find_by(privilege: {code: privilege_code}) + end + end +end diff --git a/app/services/entitlement/plan_entitlements_update_service.rb b/app/services/entitlement/plan_entitlements_update_service.rb new file mode 100644 index 0000000..d72eaa4 --- /dev/null +++ b/app/services/entitlement/plan_entitlements_update_service.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Entitlement + class PlanEntitlementsUpdateService < BaseService + include ::Entitlement::Concerns::CreateOrUpdateConcern + + Result = BaseResult[:entitlements] + + def initialize(organization:, plan:, entitlements_params:, partial:) + @organization = organization + @plan = plan + @entitlements_params = entitlements_params.to_h.with_indifferent_access + @partial = partial + super + end + + activity_loggable( + action: "plan.updated", + record: -> { plan } + ) + + def call + return result.not_found_failure!(resource: "plan") unless plan + + ActiveRecord::Base.transaction do + delete_missing_entitlements unless partial? + update_entitlements + end + + # NOTE: The webhooks is sent even if no changes were made to the plan + SendWebhookJob.perform_after_commit("plan.updated", plan) + + result.entitlements = plan.entitlements.includes(:feature, values: :privilege).reload + result + rescue ValidationFailure => e + result.fail_with_error!(e) + rescue ActiveRecord::RecordInvalid => e + if e.record.is_a?(EntitlementValue) + errors = e.record.errors.messages.transform_keys { |key| :"privilege.#{key}" } + result.validation_failure!(errors:) + else + result.record_validation_failure!(record: e.record) + end + rescue ActiveRecord::RecordNotFound => e + if e.message.include?("Entitlement::Feature") + result.not_found_failure!(resource: "feature") + elsif e.message.include?("Entitlement::Privilege") + result.not_found_failure!(resource: "privilege") + else + result.not_found_failure!(resource: "record") + end + end + + private + + attr_reader :organization, :plan, :entitlements_params, :partial + alias_method :partial?, :partial + + def delete_missing_entitlements + missing = plan.entitlements.joins(:feature).where.not(feature: {code: entitlements_params.keys}) + EntitlementValue.where(entitlement: missing).discard_all! + missing.discard_all! + end + + def delete_missing_entitlement_values(entitlement, privilege_values) + return if privilege_values.blank? + + entitlement.values.joins(:privilege).where.not(privilege: {code: privilege_values.keys}).discard_all! + end + + def update_entitlements + return if entitlements_params.blank? + + entitlements_params.each do |feature_code, privilege_values| + feature = organization.features.includes(:privileges).find { it.code == feature_code } + + raise ActiveRecord::RecordNotFound.new("Entitlement::Feature") unless feature + + # Find existing entitlement or create new one + entitlement = plan.entitlements.includes(:values).find { it.entitlement_feature_id == feature.id } + + if entitlement.nil? + entitlement = Entitlement.create!( + organization: organization, + feature: feature, + plan: plan + ) + elsif !partial? + delete_missing_entitlement_values(entitlement, privilege_values) + end + + update_entitlement_values(entitlement, feature, privilege_values) + end + end + + def create_entitlement_values(entitlement, feature, privilege_values) + privilege_values.each do |privilege_code, value| + privilege = feature.privileges.find { it.code == privilege_code } + + raise ActiveRecord::RecordNotFound.new("Entitlement::Privilege") unless privilege + + create_entitlement_value(entitlement, privilege, value) + end + end + + def update_entitlement_values(entitlement, feature, privilege_values) + return if privilege_values.blank? + + privilege_values.each do |privilege_code, value| + privilege = feature.privileges.find { it.code == privilege_code } + + raise ActiveRecord::RecordNotFound.new("Entitlement::Privilege") unless privilege + + entitlement_value = entitlement.values.find { it.entitlement_privilege_id == privilege.id } + + if entitlement_value + entitlement_value.update!(value: validate_value(value, privilege)) + else + create_entitlement_value(entitlement, privilege, value) + end + end + end + + def create_entitlement_value(entitlement, privilege, value) + EntitlementValue.create!( + organization: organization, + entitlement: entitlement, + privilege: privilege, + value: validate_value(value, privilege) + ) + end + end +end diff --git a/app/services/entitlement/privilege_destroy_service.rb b/app/services/entitlement/privilege_destroy_service.rb new file mode 100644 index 0000000..17e5d63 --- /dev/null +++ b/app/services/entitlement/privilege_destroy_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Entitlement + class PrivilegeDestroyService < BaseService + Result = BaseResult[:privilege] + + def initialize(privilege:) + @privilege = privilege + super + end + + activity_loggable( + action: "feature.updated", + record: -> { privilege&.feature } + ) + + def call + return result.not_found_failure!(resource: "privilege") unless privilege + + ActiveRecord::Base.transaction do + privilege.values.discard_all! + privilege.discard! + end + + jobs = privilege.feature.plans.map do |plan| + Utils::ActivityLog.produce_after_commit(plan, "plan.updated") + SendWebhookJob.new("plan.updated", plan) + end + + after_commit do + ActiveJob.perform_all_later(jobs) + SendWebhookJob.perform_later("feature.updated", privilege.feature) + end + + result.privilege = privilege + result + end + + private + + attr_reader :privilege + end +end diff --git a/app/services/entitlement/subscription_entitlement_core_update_service.rb b/app/services/entitlement/subscription_entitlement_core_update_service.rb new file mode 100644 index 0000000..a4158e5 --- /dev/null +++ b/app/services/entitlement/subscription_entitlement_core_update_service.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionEntitlementCoreUpdateService < BaseService + include ::Entitlement::Concerns::CreateOrUpdateConcern + + Result = BaseResult + + def initialize(subscription:, plan:, feature:, plan_entitlement:, sub_entitlement:, privilege_params:, partial:) + @subscription = subscription + @plan = plan + @feature = feature + @plan_entitlement = plan_entitlement + @sub_entitlement = sub_entitlement + @privilege_params = privilege_params.to_h.with_indifferent_access + @partial = partial + super + end + + # This inner service is used to update a single entitlement + # It intentionally doesn't add any activity log entries, doesn't return any data, doesn't send any webhooks + # The outer services will handle that + def call + return result.not_found_failure!(resource: "feature") unless feature + + ActiveRecord::Base.transaction do + process_single_entitlement + end + + result + end + + private + + attr_reader :subscription, :plan, :feature, :plan_entitlement, :sub_entitlement, :privilege_params, :partial + delegate :organization, to: :subscription + alias_method :partial?, :partial + + def full? + !partial? + end + + def process_single_entitlement + if plan_entitlement.nil? && sub_entitlement.nil? + subscription.entitlement_removals.where(feature:).update_all(deleted_at: Time.zone.now) # rubocop:disable Rails/SkipsModelValidations + create_entitlement_and_values_for_subscription + elsif plan_entitlement && privilege_params_same_as_plan?(plan_entitlement) + # Restore the plan default by removing all overrides + sub_entitlement&.values&.update_all(deleted_at: Time.zone.now) # rubocop:disable Rails/SkipsModelValidations + sub_entitlement&.discard! + SubscriptionFeatureRemoval.where(subscription: subscription).merge( + SubscriptionFeatureRemoval.where(feature:) + .or(SubscriptionFeatureRemoval.where(privilege: feature.privileges)) + ).update_all(deleted_at: Time.zone.now) # rubocop:disable Rails/SkipsModelValidations + else + subscription.entitlement_removals.where(feature:).update_all(deleted_at: Time.zone.now) # rubocop:disable Rails/SkipsModelValidations + + sub_entitlement = self.sub_entitlement || create_entitlement_for_subscription + remove_missing_entitlement_values(plan_entitlement, sub_entitlement) if full? + update_values_for_subscription(plan_entitlement, sub_entitlement) + end + end + + def create_entitlement_for_subscription + Entitlement.create!( + organization: organization, + subscription: subscription, + feature: feature + ) + end + + def create_entitlement_and_values_for_subscription + entitlement = create_entitlement_for_subscription + + privilege_params.each do |privilege_code, value| + privilege = find_privilege!(privilege_code) + + create_entitlement_value(entitlement, privilege, value) + end + + entitlement + end + + def remove_missing_entitlement_values(plan_entitlement, sub_entitlement) + plan_privilege_codes = plan_entitlement&.values&.map { it.privilege.code } + sub_privilege_codes = sub_entitlement&.values&.map { it.privilege.code } + privilege_codes_to_remove = ((plan_privilege_codes.to_a + sub_privilege_codes.to_a) - privilege_params.keys).uniq + + privilege_codes_to_remove.each do |privilege_code| + sub_val = sub_entitlement&.values&.find { it.privilege.code == privilege_code } + sub_val&.discard! + plan_val = plan_entitlement&.values&.find { it.privilege.code == privilege_code } + + if plan_val && !SubscriptionFeatureRemoval.where(organization:, privilege: plan_val.privilege, subscription:).exists? + SubscriptionFeatureRemoval.create!(organization:, privilege: plan_val.privilege, subscription: subscription) + end + end + end + + def update_values_for_subscription(plan_entitlement, sub_entitlement) + privilege_params.each do |privilege_code, value| + privilege = find_privilege!(privilege_code) + + plan_val = plan_entitlement&.values&.find { it.privilege.code == privilege_code } + sub_val = sub_entitlement&.values&.find { it.privilege.code == privilege_code } + + if plan_val && value_is_the_same?(privilege.value_type, value, plan_val.value) + sub_val&.discard! + elsif sub_val.nil? + subscription.entitlement_removals.where(privilege:).update_all(deleted_at: Time.zone.now) # rubocop:disable Rails/SkipsModelValidations + + create_entitlement_value(sub_entitlement, privilege, value) + elsif sub_val && !value_is_the_same?(privilege.value_type, value, sub_val.value) + sub_val.update!(value: validate_value(value, privilege)) + end + end + end + + def value_is_the_same?(type, value1, value2) + Utils::Entitlement.same_value?(type, value1, value2) + end + + def create_entitlement_value(entitlement, privilege, value) + entitlement.values.create!( + organization: organization, + privilege: privilege, + value: validate_value(value, privilege) + ) + end + + def find_privilege!(privilege_code) + feature.privileges.find { it.code == privilege_code } || raise(ActiveRecord::RecordNotFound.new("Entitlement::Privilege")) + end + + def privilege_params_same_as_plan?(plan_entitlement) + return false if privilege_params.keys.sort != plan_entitlement.values.map(&:privilege).map(&:code).sort + + plan_entitlement.values.all? do |v| + value_is_the_same?(v.privilege.value_type, v.value, privilege_params[v.privilege.code]) + end + end + end +end diff --git a/app/services/entitlement/subscription_entitlement_update_service.rb b/app/services/entitlement/subscription_entitlement_update_service.rb new file mode 100644 index 0000000..4e52226 --- /dev/null +++ b/app/services/entitlement/subscription_entitlement_update_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionEntitlementUpdateService < BaseService + include ::Entitlement::Concerns::CreateOrUpdateConcern + + Result = BaseResult[:entitlement] + + def initialize(subscription:, feature_code:, privilege_params:, partial:) + @subscription = subscription + @feature_code = feature_code + @privilege_params = privilege_params.to_h.with_indifferent_access + @partial = partial + super + end + + activity_loggable( + action: "subscription.updated", + record: -> { subscription } + ) + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + + ActiveRecord::Base.transaction do + plan = subscription.plan.parent || subscription.plan + feature = organization.features.includes(:privileges).find_by!(code: feature_code) + + SubscriptionEntitlementCoreUpdateService.call!( + subscription:, + plan:, + feature:, + plan_entitlement: plan.entitlements.includes(values: :privilege).find_by(feature:), + sub_entitlement: subscription.entitlements.includes(values: :privilege).find_by(feature:), + privilege_params:, + partial: + ) + end + + # NOTE: The webhooks is sent even if no changes were made to the subscription + SendWebhookJob.perform_after_commit("subscription.updated", subscription) + + result.entitlement = SubscriptionEntitlement.for_subscription(subscription).find { it.code == feature_code } + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + rescue ActiveRecord::RecordNotFound => e + if e.message.include?("Entitlement::Feature") + result.not_found_failure!(resource: "feature") + elsif e.message.include?("Entitlement::Privilege") + result.not_found_failure!(resource: "privilege") + else + result.not_found_failure!(resource: "record") + end + end + + private + + attr_reader :subscription, :feature_code, :privilege_params, :partial + delegate :organization, to: :subscription + end +end diff --git a/app/services/entitlement/subscription_entitlements_update_service.rb b/app/services/entitlement/subscription_entitlements_update_service.rb new file mode 100644 index 0000000..cb4a5af --- /dev/null +++ b/app/services/entitlement/subscription_entitlements_update_service.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionEntitlementsUpdateService < BaseService + include ::Entitlement::Concerns::CreateOrUpdateConcern + + Result = BaseResult + + def initialize(subscription:, entitlements_params:, partial:) + @subscription = subscription + @entitlements_params = entitlements_params.to_h.with_indifferent_access + @partial = partial + super + end + + activity_loggable( + action: "subscription.updated", + record: -> { subscription } + ) + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + + ActiveRecord::Base.transaction do + remove_or_delete_missing_features if full? + update_entitlements + end + + # NOTE: The webhooks is sent even if no changes were made to the subscription + SendWebhookJob.perform_after_commit("subscription.updated", subscription) + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + rescue ActiveRecord::RecordInvalid => e + if e.record.is_a?(EntitlementValue) + errors = e.record.errors.messages.transform_keys { |key| :"privilege.#{key}" } + result.validation_failure!(errors:) + else + result.record_validation_failure!(record: e.record) + end + rescue ActiveRecord::RecordNotFound => e + if e.message.include?("Entitlement::Feature") + result.not_found_failure!(resource: "feature") + elsif e.message.include?("Entitlement::Privilege") + result.not_found_failure!(resource: "privilege") + else + result.not_found_failure!(resource: "record") + end + end + + private + + attr_reader :subscription, :entitlements_params, :partial + delegate :organization, to: :subscription + alias_method :partial?, :partial + + def full? + !partial? + end + + def remove_or_delete_missing_features + missing_codes = (SubscriptionEntitlement.for_subscription(subscription).map(&:code) - entitlements_params.keys).uniq + + # If the feature was added as a subscription override, delete it + sub_entitlements = subscription.entitlements.joins(:feature).where(feature: {code: missing_codes}) + EntitlementValue.where(entitlement: sub_entitlements).update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + sub_entitlements.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + + # If the feature is from the plan, create a SubscriptionFeatureRemoval + plan_entitlements = subscription.plan.entitlements.joins(:feature).where(feature: {code: missing_codes}) + plan_entitlements.each do |entitlement| + SubscriptionFeatureRemoval.create!( + organization: subscription.organization, + feature: entitlement.feature, + subscription: subscription + ) + end + + # If there was any privilege removal for a removed feature, we clean them up + subscription.entitlement_removals.where( + privilege: Privilege.joins(:feature).where(feature: {code: missing_codes}) + ).update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + def update_entitlements + return if entitlements_params.blank? + + plan = subscription.plan.parent || subscription.plan + + features_by_code = organization.features + .where(code: entitlements_params.keys) + .includes(:privileges) + .index_by(&:code) + + # NOTE: Some feature code were not found + if features_by_code.size != entitlements_params.size + return result.not_found_failure!(resource: "feature") + end + + feature_ids = features_by_code.values.map(&:id) + + plan_entitlements_by_feature_id = plan.entitlements + .where(entitlement_feature_id: feature_ids) + .includes(values: :privilege) + .index_by(&:entitlement_feature_id) + + sub_entitlements_by_feature_id = subscription.entitlements + .where(entitlement_feature_id: feature_ids) + .includes(values: :privilege) + .index_by(&:entitlement_feature_id) + + entitlements_params.each do |feature_code, privilege_params| + feature = features_by_code[feature_code] + + SubscriptionEntitlementCoreUpdateService.call!( + subscription:, + plan:, + feature:, + privilege_params:, + plan_entitlement: plan_entitlements_by_feature_id[feature.id], + sub_entitlement: sub_entitlements_by_feature_id[feature.id], + partial: + ) + end + end + end +end diff --git a/app/services/entitlement/subscription_feature_privilege_remove_service.rb b/app/services/entitlement/subscription_feature_privilege_remove_service.rb new file mode 100644 index 0000000..b906ac9 --- /dev/null +++ b/app/services/entitlement/subscription_feature_privilege_remove_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionFeaturePrivilegeRemoveService < BaseService + Result = BaseResult[:feature_code, :privilege_code] + + def initialize(subscription:, feature_code:, privilege_code:) + @subscription = subscription + @feature_code = feature_code + @privilege_code = privilege_code + super + end + + activity_loggable( + action: "subscription.updated", + record: -> { subscription } + ) + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_found_failure!(resource: "feature") unless feature + return result.not_found_failure!(resource: "privilege") unless privilege + + ActiveRecord::Base.transaction do + delete_subscription_entitlement_value_if_exists + add_privilege_removal_if_privilege_is_in_plan + end + + SendWebhookJob.perform_after_commit("subscription.updated", subscription) + + result.feature_code = feature_code + result.privilege_code = privilege_code + result + end + + private + + attr_reader :subscription, :feature_code, :privilege_code + delegate :organization, to: :subscription + + def feature + @feature ||= organization.features.find_by(code: feature_code) + end + + def privilege + @privilege ||= organization.privileges.find_by(code: privilege_code, feature:) + end + + def delete_subscription_entitlement_value_if_exists + entitlement = subscription.entitlements.find_by(feature: feature) + return unless entitlement + entitlement.values.where(privilege:).update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + def add_privilege_removal_if_privilege_is_in_plan + plan_id = subscription.plan.parent_id || subscription.plan.id + return unless Entitlement.where(plan_id:, feature:).exists? + + SubscriptionFeatureRemoval.insert_all( # rubocop:disable Rails/SkipsModelValidations + [{organization_id: organization.id, subscription_id: subscription.id, entitlement_privilege_id: privilege.id}], + unique_by: :idx_unique_privilege_removal_per_subscription + ) + end + end +end diff --git a/app/services/entitlement/subscription_feature_remove_service.rb b/app/services/entitlement/subscription_feature_remove_service.rb new file mode 100644 index 0000000..b9636fd --- /dev/null +++ b/app/services/entitlement/subscription_feature_remove_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Entitlement + class SubscriptionFeatureRemoveService < BaseService + Result = BaseResult[:feature_code] + + def initialize(subscription:, feature_code:) + @subscription = subscription + @feature_code = feature_code + super + end + + activity_loggable( + action: "subscription.updated", + record: -> { subscription } + ) + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_found_failure!(resource: "feature") unless feature + + ActiveRecord::Base.transaction do + delete_subscription_entitlement_if_exists + delete_privilege_removals_if_exists + add_feature_removal_if_feature_is_in_plan + end + + SendWebhookJob.perform_after_commit("subscription.updated", subscription) + + result.feature_code = feature_code + result + end + + private + + attr_reader :subscription, :feature_code + delegate :organization, to: :subscription + + def feature + @feature ||= subscription.organization.features.find_by(code: feature_code) + end + + def delete_subscription_entitlement_if_exists + entitlement = subscription.entitlements.find_by(feature:) + return unless entitlement + entitlement.values.update_all(deleted_at: Time.zone.now) # rubocop:disable Rails/SkipsModelValidations + entitlement.discard! + end + + def delete_privilege_removals_if_exists + subscription.entitlement_removals.where(privilege: feature.privileges).update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + def add_feature_removal_if_feature_is_in_plan + plan_id = subscription.plan.parent_id || subscription.plan.id + return unless Entitlement.where(plan_id: plan_id, entitlement_feature_id: feature.id).exists? + + SubscriptionFeatureRemoval.insert_all( # rubocop:disable Rails/SkipsModelValidations + [{organization_id: organization.id, subscription_id: subscription.id, entitlement_feature_id: feature.id}], + unique_by: :idx_unique_feature_removal_per_subscription + ) + end + end +end diff --git a/app/services/error_details/base_service.rb b/app/services/error_details/base_service.rb new file mode 100644 index 0000000..d3c2189 --- /dev/null +++ b/app/services/error_details/base_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module ErrorDetails + class BaseService < BaseService + def initialize(params:, owner:, organization:) + @params = params + @owner = owner + @organization = organization + + super + end + + def call + result.not_found_failure!(resource: "owner") unless owner + result.not_found_failure!(resource: "organization") unless organization + result + end + + private + + attr_reader :params, :owner, :organization + end +end diff --git a/app/services/error_details/create_service.rb b/app/services/error_details/create_service.rb new file mode 100644 index 0000000..4c5af67 --- /dev/null +++ b/app/services/error_details/create_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module ErrorDetails + class CreateService < BaseService + def call + result = super + return result unless result.success? + + create_error_details! + end + + private + + def create_error_details! + new_error = ErrorDetail.create!( + owner:, + organization:, + error_code: params[:error_code], + details: params[:details] + ) + + result.error_details = new_error + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end +end diff --git a/app/services/events/billing_period_filter_service.rb b/app/services/events/billing_period_filter_service.rb new file mode 100644 index 0000000..5110187 --- /dev/null +++ b/app/services/events/billing_period_filter_service.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +module Events + class BillingPeriodFilterService < BaseService + Result = BaseResult[:charges] + + def initialize(subscription:, boundaries:) + @subscription = subscription + @boundaries = boundaries + super + end + + # Return the list of charges and filters that will be used in the billing or usage computation + # The result will be a hash where the key is the charge id and the value is an array of filter ids + # filter ids could also include "nil" as a default filter + def call + result.charges = deduplicate_filters(charges_and_filters) + result + end + + private + + attr_reader :subscription, :boundaries + + delegate :plan, :organization, to: :subscription + + def event_store + @event_store ||= Events::Stores::StoreFactory.new_instance( + organization: organization, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime + } + ) + end + + def distinct_event_codes + event_store.distinct_codes + end + + def charges_and_filters + return charges_and_filters_from_event_codes unless organization.pre_filter_events? + + charges_and_filters_from_pre_enriched_events + end + + # Return the list of all charges and filters that matches the event codes received in the period + # It also includes the recurring charges and filters + # The result will be a hash where the key is the charge id and the value is an array of filter ids + # filter ids also include "nil" as a default filter + def charges_and_filters_from_event_codes + plan.charges.joins(:billable_metric).left_joins(:filters) + .where(billable_metrics: {code: distinct_event_codes}) + .or(plan.charges.joins(:billable_metric).where(billable_metrics: {recurring: true})) + .group("charges.id, charge_filters.id") + .pluck("charges.id", "charge_filters.id") + .then { group_by_charge_id(it) } + .then { add_default_filter(it) } + end + + # Return the list of charges and filters that matches the event pre enriched in clickhouse or Postgres for the period + # It also includes the recurring charges and filters + # The result will be a hash where the key is the charge id and the value is an array of filter ids + # filter ids also include "nil" as a default filter when applicable + def charges_and_filters_from_pre_enriched_events + values = event_store.distinct_charges_and_filters + + charge_filter_ids = values.map(&:last).reject(&:blank?) + charge_ids = values.map(&:first).uniq + + existing_charge_ids = plan.charges.where(id: charge_ids).pluck(:id) + existing_charge_filters = fetch_existing_filters(charge_filter_ids) + + result = recurring_charges_and_filters + + values.each do |charge_id, filter_id| + # Charge has been removed from the plan + next unless existing_charge_ids.include?(charge_id) + + # Charge has no filters or only default bucket received usage in the period + if filter_id.blank? + result[charge_id] << nil + next + end + + # Keep only existing filters + next unless existing_charge_filters.include?(filter_id) + result[charge_id] << filter_id + end + + result + end + + def recurring_charges_and_filters + # First period: no previous usage exists, events from current period are enough + return Hash.new { |h, k| h[k] = [] } if subscription.started_at >= boundaries.charges_from_datetime + + # If the subscription was upgraded, use the upgrade chain to filter recurring charges + return recurring_charges_and_filters_from_upgrade_chain if subscription.previous_subscription_id.present? + + # Use previous fees to filter the recurring charges with existing usage + recurring_charges_and_filters_from_previous_fees + end + + def recurring_charges_and_filters_from_previous_fees + pairs = current_subscription_recurring_fees + + return Hash.new { |h, k| h[k] = [] } if pairs.empty? + + filter_ids = pairs.map(&:last).compact + if filter_ids.any? + existing_filter_ids = fetch_existing_filters(filter_ids) + pairs = pairs.select { |_, f_id| f_id.nil? || existing_filter_ids.include?(f_id) } + end + + pairs.then { group_by_charge_id(it) } + end + + def recurring_charges_and_filters_from_upgrade_chain + # First, let's fetch fees from the current subscription created before the current period + result = current_subscription_recurring_fees + .then { group_by_charge_id(it) } + + # Then, include all filters for charges whose billable metric had previous usage + previous_bm_ids = previous_subscriptions_billable_metric_ids + return result if previous_bm_ids.empty? + + current_recurring_charges.each do |charge| + next unless previous_bm_ids.include?(charge.billable_metric_id) + + filter_ids = charge.filters.map(&:id) + result[charge.id] = (result[charge.id] + filter_ids + [nil]).uniq + end + result + end + + # Fetches all recurring charges for the current plan + def current_recurring_charges + @current_recurring_charges ||= plan.charges + .joins(:billable_metric) + .where(billable_metrics: {recurring: true}) + .includes(:filters) + .to_a + end + + # Fetches all recurring billable metrics IDs from previous subscriptions, + # still used in the current plan + def previous_subscriptions_billable_metric_ids + previous_sub_ids = collect_previous_subscription_ids + return Set.new if previous_sub_ids.empty? + + bm_ids = current_recurring_charges.map(&:billable_metric_id) + return Set.new if bm_ids.empty? + + Fee.where(subscription_id: previous_sub_ids, fee_type: :charge) + .joins(charge: :billable_metric) + .where(billable_metrics: {id: bm_ids}) + .positive_units + .distinct + .pluck(:billable_metric_id) + .to_set + end + + # Fetches all terminated subscription IDs sharing the same external_id + def collect_previous_subscription_ids + organization.subscriptions + .terminated + .where(external_id: subscription.external_id, customer_id: subscription.customer_id) + .where.not(id: subscription.id) + .pluck(:id) + end + + # Fetchs all recurring fees created for the current subscription before the current billing period + def current_subscription_recurring_fees + Fee.where(subscription_id: subscription.id, fee_type: :charge) + .joins(invoice: :invoice_subscriptions) + .where("invoice_subscriptions.subscription_id = fees.subscription_id") + .where("invoice_subscriptions.charges_from_datetime < ?", boundaries.charges_from_datetime) + .joins(charge: :billable_metric) + .where(charges: {plan_id: plan.id, deleted_at: nil}) + .where(billable_metrics: {recurring: true}) + .positive_units + .distinct + .pluck(:charge_id, :charge_filter_id) + end + + # Group all charges and filters by charge_id + def group_by_charge_id(rows) + rows.each_with_object(Hash.new { |h, k| h[k] = [] }) do |(charge_id, filter_id), hash| + hash[charge_id] << filter_id + end + end + + # Include "default" bucket for recurring charges + def add_default_filter(charges_and_filters) + charges_and_filters.each_value { it << nil } + charges_and_filters + end + + # Make sure all filters are unique for each charge + def deduplicate_filters(charges_and_filters) + charges_and_filters.transform_values(&:uniq) + end + + def fetch_existing_filters(charge_filter_ids) + plan.charges.joins(:filters) + .where(charge_filters: {id: charge_filter_ids}) + .pluck("charge_filters.id") + end + end +end diff --git a/app/services/events/calculate_expression_service.rb b/app/services/events/calculate_expression_service.rb new file mode 100644 index 0000000..ede9e03 --- /dev/null +++ b/app/services/events/calculate_expression_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Events + class CalculateExpressionService < BaseService + Result = BaseResult[:event] + + def initialize(organization:, event:) + @organization = organization + @event = event + super + end + + def call + result.event = event + + field_name, expression = BillableMetrics::ExpressionCacheService.call(organization.id, event.code) do + bm = organization.billable_metrics.with_expression.find_by(code: event.code) + [bm&.field_name, bm&.expression] + end + return result if expression.blank? + + evaluation_event = Lago::Event.new(event.code, event.timestamp.to_i, event.properties) + + # The expression can always be parsed, otherwise it would not be saved. + value = Lago::ExpressionParser.parse(expression).evaluate(evaluation_event) + event.properties[field_name] = value + + result + rescue RuntimeError => e + result.service_failure!(code: :expression_evaluation_failed, message: e.message) + end + + private + + attr_reader :organization, :event + end +end diff --git a/app/services/events/common_factory.rb b/app/services/events/common_factory.rb new file mode 100644 index 0000000..a31e78e --- /dev/null +++ b/app/services/events/common_factory.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Events + class CommonFactory + def self.new_instance(source:) + case source.class.name + when "Events::Common" + source + when "Hash" + event = Events::Common.new( + id: source["id"], + organization_id: source["organization_id"], + transaction_id: source["transaction_id"], + external_subscription_id: source["external_subscription_id"], + # Keep in mind that we need the milliseconds precision! + timestamp: Time.zone.at(source["timestamp"].to_f), + code: source["code"], + properties: source["properties"] + ) + + if source["precise_total_amount_cents"].present? + event.precise_total_amount_cents = BigDecimal(source["precise_total_amount_cents"].to_s) + end + + event + when "Event" + Events::Common.new( + id: source.id, + organization_id: source.organization_id, + transaction_id: source.transaction_id, + external_subscription_id: source.external_subscription_id, + timestamp: source.timestamp, + code: source.code, + properties: source.properties, + precise_total_amount_cents: source.precise_total_amount_cents + ) + when "Clickhouse::EventsRaw" + Events::Common.new( + id: nil, + organization_id: source.organization_id, + transaction_id: source.transaction_id, + external_subscription_id: source.external_subscription_id, + timestamp: source.timestamp, + code: source.code, + properties: source.properties, + precise_total_amount_cents: source.precise_total_amount_cents + ) + end + end + end +end diff --git a/app/services/events/create_batch_service.rb b/app/services/events/create_batch_service.rb new file mode 100644 index 0000000..29fa239 --- /dev/null +++ b/app/services/events/create_batch_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Events + class CreateBatchService < BaseService + MAX_LENGTH = ENV.fetch("LAGO_EVENTS_BATCH_MAX_LENGTH", 100).to_i + + Result = BaseResult[:events, :errors] + + def initialize(organization:, events_params:, timestamp:, metadata:) + @organization = organization + @events_params = events_params[:events] + @timestamp = timestamp + @metadata = metadata + + super + end + + def call + if events_params.blank? + return result.single_validation_failure!(error_code: "no_events", field: :events) + end + + if events_params.count > MAX_LENGTH + return result.single_validation_failure!(error_code: "too_many_events", field: :events) + end + + validate_events + return result.validation_failure!(errors: result.errors) if result.errors.present? + + post_validate_events + return result.validation_failure!(errors: result.errors) if result.errors.present? + + result + end + + private + + attr_reader :organization, :events_params, :timestamp, :metadata + + def validate_events + result.events = [] + result.errors = {} + + events_params.each_with_index do |event_params, index| + event = Event.new + event.organization_id = organization.id + event.code = event_params[:code] + event.transaction_id = event_params[:transaction_id] + event.external_subscription_id = event_params[:external_subscription_id] + event.properties = event_params[:properties] || {} + event.metadata = metadata || {} + event.timestamp = Time.zone.at(event_params[:timestamp] ? Float(event_params[:timestamp]) : timestamp) + event.precise_total_amount_cents = event_params[:precise_total_amount_cents] + + expression_result = CalculateExpressionService.call(organization:, event:) + result.errors[index] = expression_result.error.message unless expression_result.success? + + result.events.push(event) + result.errors[index] = event.errors.messages unless event.valid? + rescue ArgumentError + result.errors = result.errors.merge({index => {timestamp: ["invalid_format"]}}) + end + end + + def post_validate_events + if organization.postgres_events_store? + bulk_insert_events + return if result.errors.any? + end + + KafkaProducerService.call!(events: result.events, organization:) + enqueue_post_process_jobs if organization.postgres_events_store? + end + + def bulk_insert_events + records = result.events.map { |event| event.attributes.without("id", "created_at", "updated_at") } + Event.transaction do + saved_attributes = Event.insert_all(records, unique_by: :index_unique_transaction_id, returning: [:transaction_id, :id, :created_at, :updated_at]).rows # rubocop:disable Rails/SkipsModelValidations + attributes_per_transaction_id = saved_attributes.index_by { |attrs| attrs[0] } # maps to { transaction_id => [transaction_id, id, created_at, updated_at] } + + result.events.each_with_index do |event, index| + # We delete to ensure that any duplicate transaction_id in the input events_params + # are caught and reported as errors. + attrs = attributes_per_transaction_id.delete(event.transaction_id) + if attrs + # NOTE: even though we set id, created_at and updated_at here, the event is not considered as `.persisted?` + # because ActiveRecord doesn't track that for bulk inserts. + event.id = attrs[1] + event.created_at = attrs[2] + event.updated_at = attrs[3] + else + result.errors[index] = {transaction_id: ["value_already_exist"]} + end + end + + raise ActiveRecord::Rollback if result.errors.any? + end + end + + def enqueue_post_process_jobs + jobs = result.events.map { |event| Events::PostProcessJob.new(event:) } + ActiveJob.perform_all_later(jobs) + end + end +end diff --git a/app/services/events/create_service.rb b/app/services/events/create_service.rb new file mode 100644 index 0000000..7517767 --- /dev/null +++ b/app/services/events/create_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Events + class CreateService < BaseService + Result = BaseResult[:event] + + def initialize(organization:, params:, timestamp:, metadata:) + @organization = organization + @params = params + @timestamp = timestamp + @metadata = metadata + super + end + + def call + event = Event.new + event.organization_id = organization.id + event.code = params[:code] + event.transaction_id = params[:transaction_id] + event.external_subscription_id = params[:external_subscription_id] + event.properties = params[:properties] || {} + event.metadata = metadata || {} + event.timestamp = Time.zone.at(params[:timestamp] ? Float(params[:timestamp]) : timestamp) + event.precise_total_amount_cents = params[:precise_total_amount_cents] + + expression_result = CalculateExpressionService.call(organization:, event:) + return result.validation_failure!(errors: expression_result.error.message) unless expression_result.success? + + event.save! unless organization.clickhouse_events_store? + + result.event = event + + produce_kafka_event(event) + Events::PostProcessJob.perform_later(event:) unless organization.clickhouse_events_store? + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.single_validation_failure!(field: :transaction_id, error_code: "value_already_exist") + rescue ArgumentError + result.single_validation_failure!(field: :timestamp, error_code: "invalid_format") + end + + private + + attr_reader :organization, :params, :timestamp, :metadata + + def produce_kafka_event(event) + Events::KafkaProducerService.call!(events: event, organization:) + end + end +end diff --git a/app/services/events/enrich_service.rb b/app/services/events/enrich_service.rb new file mode 100644 index 0000000..37eadc5 --- /dev/null +++ b/app/services/events/enrich_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Events + class EnrichService < BaseService + Result = BaseResult[:enriched_events] + + def initialize(event:, subscription:, billable_metric:, charges_and_filters:, persist: true) + @event = event + @subscription = subscription + @billable_metric = billable_metric + @charges_and_filters = charges_and_filters + @persist = persist + + super + end + + def call + enriched_event = init_enriched_event + + EnrichedEvent.transaction do + result.enriched_events = charges_and_filters.map do |charge, filter| + ev = enriched_event.dup + ev.charge_id = charge.id + + ev.charge_filter_id = filter&.id + ev.grouped_by = format_grouped_by(filter&.pricing_group_keys.presence || charge.pricing_group_keys) + + if charge.accepts_target_wallet? && event.properties[Charge::EVENT_TARGET_WALLET_CODE].present? + ev.target_wallet_code = event.properties[Charge::EVENT_TARGET_WALLET_CODE] + ev.grouped_by[Charge::EVENT_TARGET_WALLET_CODE] = event.properties[Charge::EVENT_TARGET_WALLET_CODE] + end + + ev.save! if persist + ev + end + end + + result + end + + private + + attr_reader :event, :subscription, :billable_metric, :charges_and_filters, :persist + + def init_enriched_event + enriched_event = EnrichedEvent.new + enriched_event.event_id = event.id + enriched_event.organization_id = event.organization_id + enriched_event.code = event.code + enriched_event.transaction_id = event.transaction_id + enriched_event.timestamp = event.timestamp + + enriched_event.external_subscription_id = subscription.external_id + enriched_event.subscription_id = subscription.id + enriched_event.plan_id = subscription.plan_id + + enriched_event.enriched_at = Time.current + enriched_event.precise_total_amount_cents = event.precise_total_amount_cents + enriched_event.value = (event.properties || {})[billable_metric.field_name] || 0 + enriched_event.value = 1 if billable_metric.count_agg? + + # NOTE: We might not be able to parse the value as a decimal, it will then fall back to 0 + # The behavior is aligned with the Clickhouse implementation but differs + # a bit from the current PG one where we explicitly filter events with invalid values + enriched_event.decimal_value = decimal_value(enriched_event.value) + + if billable_metric.unique_count_agg? + operation_type = (event.properties || {})["operation_type"] || "add" + enriched_event.operation_type = operation_type if BillableMetric::UNIQUE_COUNT_OPERATION_TYPES.include?(operation_type) + end + + enriched_event + end + + def format_grouped_by(pricing_group_keys) + return {} if pricing_group_keys.blank? + + pricing_group_keys.sort.index_with { event.properties[it] } + end + + def decimal_value(value) + BigDecimal(value.to_s) + rescue ArgumentError + BigDecimal(0) + end + end +end diff --git a/app/services/events/kafka_producer_service.rb b/app/services/events/kafka_producer_service.rb new file mode 100644 index 0000000..4ed9234 --- /dev/null +++ b/app/services/events/kafka_producer_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Events + class KafkaProducerService < BaseService + Result = BaseResult + + EVENT_SOURCE = "http_ruby" + + def initialize(events:, organization:) + @events = Array.wrap(events) + @organization = organization + super + end + + def call + return result if ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"].blank? + return result if ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"].blank? + + messages = events.map { |event| build_message(event) } + Karafka.producer.produce_many_async(messages) + + result + end + + private + + attr_reader :events, :organization + + def build_message(event) + { + topic: ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"], + key: "#{organization.id}-#{event.external_subscription_id}", + payload: build_payload(event).to_json + } + end + + def build_payload(event) + { + organization_id: organization.id, + external_customer_id: event.external_customer_id, + external_subscription_id: event.external_subscription_id, + transaction_id: event.transaction_id, + # NOTE: Removes trailing 'Z' to allow clickhouse parsing + timestamp: event.timestamp.to_f.to_s, + code: event.code, + # NOTE: Default value to 0.0 is required for clickhouse parsing + precise_total_amount_cents: event.precise_total_amount_cents.present? ? event.precise_total_amount_cents.to_s : "0.0", + properties: event.properties, + ingested_at: Time.zone.now.iso8601[...-1], + source: EVENT_SOURCE, + source_metadata: { + api_post_processed: !organization.clickhouse_events_store? + } + } + end + end +end diff --git a/app/services/events/pay_in_advance_service.rb b/app/services/events/pay_in_advance_service.rb new file mode 100644 index 0000000..bedb249 --- /dev/null +++ b/app/services/events/pay_in_advance_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Events + class PayInAdvanceService < BaseService + Result = BaseResult[:event] + + def initialize(event:) + @event = Events::CommonFactory.new_instance(source: event) + super + end + + def call + return result unless billable_metric + return result unless can_create_fee? + return result if already_processed? + + # NOTE: Temporary condition to support both Postgres and Clickhouse (via kafka) + if kafka_producer_enabled? + # NOTE: when clickhouse, ignore event coming from postgres (Rest API) + return result if event.id.present? && event.organization.clickhouse_events_store? + + # NOTE: without clickhouse, ignore events coming from kafka + return result if event.id.nil? && !event.organization.clickhouse_events_store? + end + + charges.where(invoiceable: false).find_each do |charge| + Fees::CreatePayInAdvanceJob.perform_later(charge:, event: event.as_json) + end + + charges.where(invoiceable: true).find_each do |charge| + Invoices::CreatePayInAdvanceChargeJob.perform_later(charge:, event: event.as_json, timestamp: event.timestamp) + end + + result.event = event + result + end + + private + + attr_reader :event + + delegate :billable_metric, :properties, to: :event + + def charges + return Charge.none unless event.subscription + + event.subscription + .plan + .charges + .pay_in_advance + .joins(:billable_metric) + .where(billable_metrics: {id: event.billable_metric.id}) + end + + def already_processed? + Fee.from_organization_pay_in_advance(event.organization).where(pay_in_advance_event_transaction_id: event.transaction_id).exists? + end + + def can_create_fee? + # NOTE: `custom_agg` and `count_agg` are the only 2 aggregations + # that don't require a field set in property. + # For other aggregation, if the field isn't set we shouldn't create a fee/invoice. + billable_metric.count_agg? || billable_metric.custom_agg? || properties[billable_metric.field_name].present? + end + + def kafka_producer_enabled? + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"].present? && ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"].present? + end + end +end diff --git a/app/services/events/post_process_service.rb b/app/services/events/post_process_service.rb new file mode 100644 index 0000000..e784147 --- /dev/null +++ b/app/services/events/post_process_service.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +module Events + class PostProcessService < BaseService + Result = BaseResult[:event] + + def initialize(event:) + @organization = event.organization + @event = event + super + end + + def call + expire_cached_charges + create_enriched_events + track_subscription_activity + customer&.flag_wallets_for_refresh + # TODO: update also event-processor to process targeted wallets + check_targeted_wallets + + handle_pay_in_advance + + result.event = event + result + rescue ActiveRecord::RecordNotUnique + deliver_error_webhook(error: {transaction_id: ["value_already_exist"]}) + + result + end + + private + + attr_reader :event + + delegate :organization, to: :event + + def customer + @customer ||= subscriptions.first&.customer + end + + def subscriptions + return @subscriptions if defined? @subscriptions + + subscriptions = organization.subscriptions + .where(external_id: event.external_subscription_id) + .where.not(status: :incomplete) + return unless subscriptions + + @subscriptions = subscriptions + .where("date_trunc('millisecond', started_at::timestamp) <= ?::timestamp", event.timestamp) + .where( + "terminated_at IS NULL OR date_trunc('millisecond', terminated_at::timestamp) >= ?", + event.timestamp + ) + .order("terminated_at DESC NULLS FIRST, started_at DESC") + end + + def active_subscription + @active_subscription ||= begin + subs = subscriptions.select(&:active?) + raise "Multiple active subscriptions found" if subs.length > 1 + subs.first + end + end + + def billable_metric + @billable_metric ||= organization.billable_metrics.find_by(code: event.code) + end + + def expire_cached_charges + return if active_subscription.nil? + return unless billable_metric + + charges_and_filters.each do |charge, filter| + Subscriptions::ChargeCacheService.expire_cache(subscription: active_subscription, charge:, charge_filter: filter) + end + end + + def create_enriched_events + return unless organization.feature_flag_enabled?(:postgres_enriched_events) + return if active_subscription.nil? + return unless billable_metric + + Events::EnrichService.call!(event:, subscription: active_subscription, billable_metric:, charges_and_filters:) + end + + def track_subscription_activity + # NOTE: We don't eager load usage_thresholds or alerts here so it could be considered an N+1 query + # But there should be only one active subscription here, so it's better to not re-requery to eager load + subscriptions.select(&:active?).each do |subscription| + date = Time.current.in_time_zone(customer.applicable_timezone).to_date + UsageMonitoring::TrackSubscriptionActivityService.call(organization:, subscription:, date:) + end + end + + def check_targeted_wallets + return unless organization.events_targeting_wallets_enabled? + return if event.properties["target_wallet_code"].blank? + return unless subscriptions + return unless Charge.where(organization_id: event.organization_id, plan_id: subscriptions.map(&:plan_id), + billable_metric:, accepts_target_wallet: true).exists? + return if customer.wallets.active.where(code: event.properties["target_wallet_code"]).exists? + + SendWebhookJob.perform_later( + "event.error", + event, + {error: {target_wallet_code: ["target_wallet_code_not_found"]}} + ) + end + + def handle_pay_in_advance + return unless billable_metric + return unless charges.any? + + Events::PayInAdvanceJob.perform_later(Events::CommonFactory.new_instance(source: event).as_json) + end + + def charges + return Charge.none unless subscriptions.first + + subscriptions + .first + .plan + .charges + .pay_in_advance + .joins(:billable_metric) + .where(billable_metric: {code: event.code}) + end + + def deliver_error_webhook(error:) + SendWebhookJob.perform_later("event.error", event, {error:}) + end + + def charges_and_filters + return @charges_and_filters if @charges_and_filters.present? + + charges = billable_metric.charges + .joins(:plan) + .where(plans: {id: active_subscription&.plan_id}) + .includes(filters: {values: :billable_metric_filter}) + + @charges_and_filters = charges + .index_with { |c| ChargeFilters::EventMatchingService.call(charge: c, event:).charge_filter } + end + end +end diff --git a/app/services/events/post_validation_service.rb b/app/services/events/post_validation_service.rb new file mode 100644 index 0000000..1e9cadc --- /dev/null +++ b/app/services/events/post_validation_service.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module Events + class PostValidationService < BaseService + Result = BaseResult[:errors] + + def initialize(organization:) + @organization = organization + + super + end + + def call + errors = { + invalid_code: process_query(invalid_code_query), + missing_aggregation_property: process_query(missing_aggregation_property_query), + invalid_filter_values: process_query(invalid_filter_values_query) + } + + if errors[:invalid_code].present? || + errors[:missing_aggregation_property].present? || + errors[:invalid_filter_values].present? + deliver_webhook(errors) + end + + result.errors = errors + result + end + + private + + attr_reader :organization + + def invalid_code_query + <<-SQL + SELECT DISTINCT transaction_id + FROM last_hour_events_mv + WHERE organization_id = '#{organization.id}' + AND billable_metric_code IS NULL + SQL + end + + def missing_aggregation_property_query + <<-SQL + SELECT DISTINCT transaction_id + FROM last_hour_events_mv + WHERE organization_id = '#{organization.id}' + AND ( + ( + field_name_mandatory = 't' + AND field_value IS NULL + ) + OR ( + numeric_field_mandatory = 't' + AND is_numeric_field_value = 'f' + ) + ) + SQL + end + + def invalid_filter_values_query + <<-SQL + SELECT DISTINCT transaction_id + FROM last_hour_events_mv + WHERE organization_id = '#{organization.id}' + AND has_filter_keys = 't' + AND has_valid_filter_values = 'f' + SQL + end + + def process_query(sql) + ApplicationRecord.connection.select_all(sql).rows.map(&:first) + end + + def deliver_webhook(errors) + SendWebhookJob.perform_later("events.errors", organization, errors:) + end + end +end diff --git a/app/services/events/re_enrich_all_service.rb b/app/services/events/re_enrich_all_service.rb new file mode 100644 index 0000000..0d56c3e --- /dev/null +++ b/app/services/events/re_enrich_all_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Events + class ReEnrichAllService < BaseService + Result = BaseResult[] + + def initialize(subscription:, timestamp: Time.current) + @subscription = subscription + @timestamp = timestamp + + super + end + + def call + return result if organization.clickhouse_events_store? + + events.find_in_batches do |events| + EnrichedEvent.transaction do + drop_enriched_events(events) + + # Batch insert enriched events + enriched = enriched_events(events) + attributes = enriched.map { |ev| ev.attributes.without("id") } + EnrichedEvent.insert_all(attributes) # rubocop:disable Rails/SkipsModelValidations + end + end + + result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :timestamp + + delegate :organization, to: :subscription + + def boundaries + return @boundaries if @boundaries + + date_service = Subscriptions::DatesService.new_instance( + subscription, + timestamp, + current_usage: true + ) + + @boundaries = BillingPeriodBoundaries.new( + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + issuing_date: date_service.next_end_of_period, + charges_duration: date_service.charges_duration_in_days, + timestamp: + ) + end + + def events + Event.where(organization_id: subscription.organization_id) + .where(external_subscription_id: subscription.external_id) + .where(timestamp: boundaries.charges_from_datetime...boundaries.charges_to_datetime) + end + + def drop_enriched_events(events) + EnrichedEvent.where(event_id: events.map(&:id)).delete_all + end + + def enriched_events(events) + events.flat_map do |event| + billable_metric = billable_metrics[event.code] + next [] unless billable_metric + + charges_and_filters = charges[event.code] + .index_with { |c| ChargeFilters::EventMatchingService.call(charge: c, event:).charge_filter } + + # Only returns unpersisted enriched events + Events::EnrichService + .call!(event:, subscription:, billable_metric:, charges_and_filters:, persist: false) + .enriched_events + end + end + + def billable_metrics + @billable_metrics ||= subscription.plan.billable_metrics.index_by(&:code) + end + + def charges + @charges ||= subscription.plan.charges + .includes(:billable_metric, filters: {values: :billable_metric_filter}) + .group_by { it.billable_metric.code } + end + end +end diff --git a/app/services/events/stores/base_store.rb b/app/services/events/stores/base_store.rb new file mode 100644 index 0000000..d97332b --- /dev/null +++ b/app/services/events/stores/base_store.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Events + module Stores + class BaseStore + def initialize(subscription:, boundaries:, code: nil, filters: {}, deduplicate: false) + @code = code + @subscription = subscription + @boundaries = boundaries + + @filters = filters + + @grouped_by = filters[:grouped_by] + @grouped_by_values = filters[:grouped_by_values] + + @charge_id = filters[:charge_id] + @charge_filter_id = filters[:charge_filter]&.id + @matching_filters = filters[:matching_filters] || {} + @ignored_filters = filters[:ignored_filters] || [] + + @aggregation_property = nil + @numeric_property = false + @use_from_boundary = true + @deduplicate = deduplicate + end + + def grouped_by_values? + grouped_by_values.present? + end + + def with_grouped_by_values(grouped_by_values, &block) + previous_grouped_by_values = @grouped_by_values + return yield block if grouped_by_values.nil? + + @grouped_by_values = grouped_by_values + yield block + ensure + @grouped_by_values = previous_grouped_by_values + end + + def events(force_from: false) + raise NotImplementedError + end + + def events_values(limit: nil, force_from: false) + raise NotImplementedError + end + + def last_event + raise NotImplementedError + end + + def prorated_events_values(total_duration) + raise NotImplementedError + end + + delegate :count, to: :events + + def grouped_count + raise NotImplementedError + end + + def max + raise NotImplementedError + end + + def grouped_max + raise NotImplementedError + end + + def last + raise NotImplementedError + end + + def grouped_last + raise NotImplementedError + end + + def sum + raise NotImplementedError + end + + def grouped_sum + raise NotImplementedError + end + + def sum_precise_total_amount_cents + raise NotImplementedError + end + + def grouped_sum_precise_total_amount_cents + raise NotImplementedError + end + + def prorated_sum(period_duration:, persisted_duration: nil) + raise NotImplementedError + end + + def grouped_prorated_sum(period_duration:, persisted_duration: nil) + raise NotImplementedError + end + + # NOTE: returns the breakdown of the sum grouped by date + # The result format will be an array of hash with the format: + # [{ date: Date.parse('2023-11-27'), value: 12.9 }, ...] + def sum_date_breakdown + raise NotImplementedError + end + + def weighted_sum(initial_value: 0) + raise NotImplementedError + end + + def grouped_weighted_sum(initial_values: []) + raise NotImplementedError + end + + def from_datetime + boundaries[:from_datetime]&.to_time&.floor(3) + end + + def to_datetime + boundaries[:to_datetime] + end + + def charges_duration + boundaries[:charges_duration] + end + + def applicable_to_datetime + boundaries[:max_timestamp] || to_datetime + end + + def sanitize_colon(query) + # NOTE: escape ':' to avoid ActiveRecord::PreparedStatementInvalid, + query.gsub("'#{code}'", "'#{code.gsub(":", "\\:")}'") + end + + def timezone + @timezone ||= customer.applicable_timezone + end + + attr_accessor :numeric_property, :aggregation_property, :use_from_boundary, :grouped_by, :charge_id, :charge_filter_id + + protected + + attr_accessor :code, :subscription, :boundaries, :grouped_by_values, :filters, :matching_filters, :ignored_filters, :deduplicate + + delegate :customer, to: :subscription + + def period_duration + @period_duration ||= Subscriptions::DatesService.new_instance( + subscription, + to_datetime + 1.day, + current_usage: subscription.terminated? && subscription.upgraded? + ).charges_duration_in_days + end + end + end +end diff --git a/app/services/events/stores/clickhouse/clean_duplicated_enriched_expanded_service.rb b/app/services/events/stores/clickhouse/clean_duplicated_enriched_expanded_service.rb new file mode 100644 index 0000000..7a5a00b --- /dev/null +++ b/app/services/events/stores/clickhouse/clean_duplicated_enriched_expanded_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + # This service cleans the duplicated events_enriched_expanded records that might have been created by + # the execution of the events:reprocess rake task. + # It uses ClickHouse lightweight `DELETE FROM` which is synchronous. + # A timeout (in seconds) can be specified via the `timeout` parameter to avoid long-running queries. + # When the query times out, the SQL is captured in `result.queries` for manual execution. + class CleanDuplicatedEnrichedExpandedService < BaseService + Result = BaseResult[:duplicated_count, :queries] + + def initialize(subscription:, codes: [], timeout: nil) + @subscription = subscription + @codes = codes + @timeout = timeout + + super + end + + def call + result.queries = [] + result.duplicated_count = count_duplicates + return result if result.duplicated_count.zero? + + delete_duplicated_events + + result + end + + def count_duplicates + sql = ActiveRecord::Base.sanitize_sql_for_conditions([ + <<~SQL.squish, + SELECT count() AS duplicated_count FROM (#{duplicates_subquery}) + SQL + sql_params + ]) + + row = Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + connection.select_one(sql) + end + row["duplicated_count"].to_i + end + + private + + attr_reader :subscription, :codes, :timeout + + def delete_duplicated_events + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + <<~SQL.squish, + DELETE FROM events_enriched_expanded + WHERE #{base_conditions} + AND (transaction_id, timestamp, charge_id, charge_filter_id, enriched_at) + NOT IN (#{keep_subquery}) + #{"SETTINGS max_execution_time=#{timeout.to_i}" if timeout.to_i.positive?} + SQL + sql_params + ] + ) + + begin + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |conn| + conn.execute(sql) + end + rescue Net::ReadTimeout + result.queries << sql + end + end + + def base_conditions + conditions = <<~SQL.squish + organization_id = :organization_id + AND subscription_id = :subscription_id + AND timestamp >= :started_at + SQL + conditions += " AND code IN (:codes)" if codes.present? + conditions + end + + def duplicates_subquery + <<~SQL.squish + SELECT transaction_id, timestamp, charge_id, charge_filter_id + FROM events_enriched_expanded + WHERE #{base_conditions} + GROUP BY transaction_id, timestamp, charge_id, charge_filter_id + HAVING count() > 1 + SQL + end + + def keep_subquery + <<~SQL.squish + SELECT transaction_id, timestamp, charge_id, charge_filter_id, max(enriched_at) + FROM events_enriched_expanded + WHERE #{base_conditions} + GROUP BY transaction_id, timestamp, charge_id, charge_filter_id + SQL + end + + def sql_params + { + organization_id: subscription.organization_id, + subscription_id: subscription.id, + started_at: subscription.started_at, + codes: codes + } + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/clean_duplicated_service.rb b/app/services/events/stores/clickhouse/clean_duplicated_service.rb new file mode 100644 index 0000000..d6f191e --- /dev/null +++ b/app/services/events/stores/clickhouse/clean_duplicated_service.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + class CleanDuplicatedService < BaseService + Result = BaseResult + + def initialize(subscription:, timestamp: Time.current) + @subscription = subscription + @timestamp = timestamp + super + end + + def call + remove_duplicated_events + Subscriptions::ChargeCacheService.expire_for_subscription(subscription) + + result + end + + private + + attr_reader :subscription, :timestamp + + delegate :organization, to: :subscription + + def boundaries + return @boundaries if @boundaries.present? + + date_service = Subscriptions::DatesService.new_instance( + subscription, + timestamp, + current_usage: true + ) + + @boundaries = BillingPeriodBoundaries.new( + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + issuing_date: date_service.next_end_of_period, + charges_duration: date_service.charges_duration_in_days, + timestamp: + ) + end + + def duplicated_events + ::Clickhouse::EventsEnriched + .where(organization_id: organization.id) + .where(external_subscription_id: subscription.external_id) + .where(timestamp: boundaries.charges_from_datetime...boundaries.charges_to_datetime) + .group(:transaction_id, :timestamp, :code) + .having("count() > 1") + .pluck(:transaction_id, :timestamp, :code) + end + + def remove_duplicated_events + duplicates = duplicated_events.to_a + return if duplicates.empty? + + duplicates.each do |transaction_id, event_timestamp, code| + # Fetch all enriched_at timestamps for the duplicated events + enriched_at = ::Clickhouse::EventsEnriched + .where(organization_id: organization.id) + .where(external_subscription_id: subscription.external_id) + .where(timestamp: event_timestamp) + .where(transaction_id: transaction_id) + .where(code: code) + .order(enriched_at: :desc) + .pluck(:enriched_at) + + ::Clickhouse::EventsEnriched + .where(organization_id: organization.id) + .where(external_subscription_id: subscription.external_id) + .where(timestamp: event_timestamp) + .where(transaction_id: transaction_id) + .where(code: code) + .where(enriched_at: enriched_at[1..]) + .delete_all + end + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/enriched_store_migration/comparison_service.rb b/app/services/events/stores/clickhouse/enriched_store_migration/comparison_service.rb new file mode 100644 index 0000000..d2e6295 --- /dev/null +++ b/app/services/events/stores/clickhouse/enriched_store_migration/comparison_service.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "benchmark" + +module Events + module Stores + module Clickhouse + module EnrichedStoreMigration + class ComparisonService < BaseService + Result = BaseResult[:diff_count, :fee_details, :legacy_elapsed, :enriched_elapsed] + + FieldDiff = Data.define(:legacy, :enriched) + FeeValues = Data.define(:units, :amount_cents, :events_count, :total_aggregated_units) + FeeDetail = Data.define( + :charge_id, :charge_filter_id, :grouped_by, + :billable_metric_code, :aggregation_type, :charge_model, + :from, :to, :status, :legacy, :enriched, :diffs + ) + + def initialize(subscription:, deduplicate: false) + @subscription = subscription + @organization = subscription.organization + @deduplicate = deduplicate + super + end + + def call + result.fee_details = [] + + legacy_usage_result = nil + result.legacy_elapsed = Benchmark.realtime do + legacy_usage_result = compute_usage(enriched: false) + end + + return result.fail_with_error!(legacy_usage_result.error) if legacy_usage_result.failure? + + enriched_usage_result = nil + result.enriched_elapsed = Benchmark.realtime do + enriched_usage_result = compute_usage(enriched: true) + end + + return result.fail_with_error!(enriched_usage_result.error) if enriched_usage_result.failure? + + compare_fees(legacy_usage_result.usage.fees, enriched_usage_result.usage.fees) + result + ensure + # Ensure the organization state is restored + organization.reload + end + + private + + attr_reader :subscription, :organization, :deduplicate + + def compute_usage(enriched:) + usage_result = nil + + ActiveRecord::Base.transaction do + if enriched + organization.enable_feature_flag!(:enriched_events_aggregation) + organization.update!(clickhouse_deduplication_enabled: deduplicate, pre_filter_events: true) + else + organization.disable_feature_flag!(:enriched_events_aggregation) + organization.update!(clickhouse_deduplication_enabled: deduplicate) + end + organization.reload + + usage_result = Invoices::CustomerUsageService.call( + customer: subscription.customer, + subscription: subscription, + with_cache: false, + apply_taxes: false + ) + + raise ActiveRecord::Rollback + end + + usage_result + end + + def compare_fees(legacy_fees, enriched_fees) + legacy_by_key = legacy_fees.index_by { |f| fee_key(f) } + enriched_by_key = enriched_fees.index_by { |f| fee_key(f) } + + all_keys = (legacy_by_key.keys + enriched_by_key.keys).uniq + diff_count = 0 + + all_keys.each do |key| + legacy_fee = legacy_by_key[key] + enriched_fee = enriched_by_key[key] + + if legacy_fee && !enriched_fee + diff_count += 1 + result.fee_details << build_detail(key, "only_in_legacy", legacy_fee, nil) + elsif enriched_fee && !legacy_fee + diff_count += 1 + result.fee_details << build_detail(key, "only_in_enriched", nil, enriched_fee) + else + diffs = compute_field_diffs(legacy_fee, enriched_fee) + if diffs.any? + diff_count += 1 + result.fee_details << build_detail(key, "diff", legacy_fee, enriched_fee, diffs:) + else + result.fee_details << build_detail(key, "match", legacy_fee, enriched_fee) + end + end + end + + result.diff_count = diff_count + end + + def fee_key(fee) + grouped = fee.grouped_by.presence || {} + [fee.charge_id, fee.charge_filter_id, grouped] + end + + def compute_field_diffs(legacy_fee, enriched_fee) + { + units: [legacy_fee.units, enriched_fee.units], + amount_cents: [legacy_fee.amount_cents, enriched_fee.amount_cents], + events_count: [legacy_fee.events_count, enriched_fee.events_count], + total_aggregated_units: [legacy_fee.total_aggregated_units, enriched_fee.total_aggregated_units] + }.select { |_, (legacy, enriched)| legacy != enriched } + end + + def build_detail(key, status, legacy_fee, enriched_fee, diffs: {}) + fee = legacy_fee || enriched_fee + + FeeDetail.new( + charge_id: key[0], + charge_filter_id: key[1], + grouped_by: key[2], + billable_metric_code: fee.billable_metric.code, + aggregation_type: fee.billable_metric.aggregation_type, + charge_model: fee.charge.charge_model, + from: fee.properties.dig("charges_from_datetime"), + to: fee.properties.dig("charges_to_datetime"), + status:, + legacy: legacy_fee ? fee_values(legacy_fee) : nil, + enriched: enriched_fee ? fee_values(enriched_fee) : nil, + diffs: diffs.transform_values { |values| FieldDiff.new(legacy: values[0], enriched: values[1]) } + ) + end + + def fee_values(fee) + FeeValues.new( + units: fee.units.to_s, + amount_cents: fee.amount_cents, + events_count: fee.events_count, + total_aggregated_units: fee.total_aggregated_units.to_s + ) + end + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_service.rb b/app/services/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_service.rb new file mode 100644 index 0000000..fc3d45e --- /dev/null +++ b/app/services/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_service.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + module EnrichedStoreMigration + # Drives a single subscription through the enriched store migration pipeline. + # Called repeatedly — it reads the current state and executes the next step: + # + # pending → run initial comparison + # comparing → if no diffs: completed (fast path) + # if diffs + codes: reprocess events via Kafka, then wait for enrichment + # if diffs + no codes: failed (unexpected mismatch) + # deduplicating → clean duplicate enriched_expanded rows + # if ClickHouse times out: pause (queries saved for manual run) + # otherwise: move to validation + # validating → final comparison after reprocessing + dedup + # if clean: completed; if diffs remain: failed + # + # On completion, enqueues the org-level OrchestratorJob to check overall progress. + class SubscriptionOrchestratorService < BaseService + CLEANUP_TIMEOUT = 5.minutes + + Result = BaseResult[:subscription_migration] + + def initialize(subscription_migration:) + @subscription_migration = subscription_migration + super + end + + def call + case subscription_migration.status + when "pending" + handle_pending + when "comparing" + handle_comparing + when "deduplicating" + handle_deduplicating + when "validating" + handle_validating + else + result.service_failure!(code: :invalid_status, message: "Unprocessable status: #{subscription_migration.status}") + end + + result.subscription_migration = subscription_migration + result + end + + private + + attr_reader :subscription_migration + + delegate :subscription, to: :subscription_migration + + def handle_pending + subscription_migration.start_comparing! + handle_comparing + end + + def handle_comparing + comparison = run_comparison + return unless comparison + + if comparison.diff_count.zero? + subscription_migration.complete! + enqueue_orchestrator + elsif subscription_migration.billable_metric_codes.present? + subscription_migration.start_reprocessing! + reprocess_events + else + fail_migration!("Diffs found but no billable metric codes to reprocess") + end + end + + def handle_deduplicating + dedup_result = ::Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService.call( + subscription:, + codes: subscription_migration.billable_metric_codes, + timeout: CLEANUP_TIMEOUT + ) + + if dedup_result.failure? + fail_migration!(dedup_result.error&.message || "Deduplication failed") + return + end + + if dedup_result.queries.present? + subscription_migration.dedup_pending_queries = dedup_result.queries + subscription_migration.pause_dedup! + else + subscription_migration.duplicates_removed_count = dedup_result.duplicated_count + subscription_migration.start_validating! + handle_validating + end + end + + def handle_validating + comparison = run_comparison + return unless comparison + + if comparison.diff_count.zero? + subscription_migration.complete! + enqueue_orchestrator + else + fail_migration!("Validation failed: #{comparison.diff_count} diff(s) remain after reprocessing") + end + end + + def run_comparison + comparison = ::Events::Stores::Clickhouse::EnrichedStoreMigration::ComparisonService.call(subscription:) + + unless comparison.success? + fail_migration!(comparison.error&.message || "Comparison failed") + return + end + + codes = comparison.fee_details + .reject { |detail| detail.status == "match" } + .filter_map(&:billable_metric_code) + .uniq + + subscription_migration.update!( + comparison_results: comparison.fee_details, + billable_metric_codes: codes + ) + + comparison + end + + def reprocess_events + reprocess_result = ::Events::Stores::Clickhouse::ReEnrichSubscriptionEventsService.call( + subscription:, + codes: subscription_migration.billable_metric_codes + ) + + unless reprocess_result.success? + fail_migration!(reprocess_result.error&.message || "Reprocessing failed") + return + end + + subscription_migration.events_reprocessed_count = reprocess_result.events_count + subscription_migration.start_waiting! + + WaitForEnrichmentJob.perform_later(subscription_migration) + rescue => e + fail_migration!(e.message) + end + + def fail_migration!(message) + subscription_migration.update!(error_message: message) + subscription_migration.fail! + end + + def enqueue_orchestrator + OrchestratorJob.perform_later(subscription_migration.enriched_store_migration) + end + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_service.rb b/app/services/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_service.rb new file mode 100644 index 0000000..d9e241d --- /dev/null +++ b/app/services/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + module EnrichedStoreMigration + # After events are reprocessed and republished to Kafka, the enrichment pipeline + # asynchronously produces rows in events_enriched_expanded. This service polls + # ClickHouse to check whether all reprocessed events have been enriched. + # + # A single event can produce multiple enriched_expanded rows (one per charge on the + # billable metric), so we count distinct transaction_ids rather than total rows. + # + # Returns :ready (advances to deduplicating), :not_ready (caller re-enqueues with + # backoff), or :max_attempts_reached (marks the subscription migration as failed). + class WaitForEnrichmentService < BaseService + STATUSES = %i[ready not_ready max_attempts_reached].freeze + MAX_ATTEMPTS = 10 + + Result = BaseResult[:status, :enriched_count] + + def initialize(subscription_migration:, attempt:, max_attempts: MAX_ATTEMPTS) + @subscription_migration = subscription_migration + @attempt = attempt + @max_attempts = max_attempts + super + end + + def call + return result unless subscription_migration.waiting_for_enrichment? + + enriched_count = count_enriched_events + result.enriched_count = enriched_count + + if enriched_count >= subscription_migration.events_reprocessed_count + subscription_migration.update!(attempts: attempt) + subscription_migration.start_deduplicating! + SubscriptionOrchestratorJob.perform_later(subscription_migration) + result.status = :ready + elsif attempt >= max_attempts + subscription_migration.update!( + attempts: attempt, + error_message: "Enrichment not ready after #{max_attempts} attempts " \ + "(enriched: #{enriched_count}, expected: #{subscription_migration.events_reprocessed_count})" + ) + subscription_migration.fail! + result.status = :max_attempts_reached + else + subscription_migration.update!(attempts: attempt) + result.status = :not_ready + end + + result + end + + private + + attr_reader :subscription_migration, :attempt, :max_attempts + + def count_enriched_events + subscription = subscription_migration.subscription + + scope = ::Clickhouse::EventsEnrichedExpanded + .where(organization_id: subscription.organization_id) + .where(external_subscription_id: subscription.external_id) + .where("timestamp >= ?", subscription.started_at) + + scope = scope.where("timestamp <= ?", subscription.terminated_at) if subscription.terminated? + scope = scope.where(code: subscription_migration.billable_metric_codes) if subscription_migration.billable_metric_codes.present? + + scope.pick(Arel.sql("uniqExact(transaction_id)")) + end + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/pre_enrichment_check_service.rb b/app/services/events/stores/clickhouse/pre_enrichment_check_service.rb new file mode 100644 index 0000000..24a197d --- /dev/null +++ b/app/services/events/stores/clickhouse/pre_enrichment_check_service.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + class PreEnrichmentCheckService < BaseService + Result = BaseResult[:subscriptions_to_reprocess] + + # Initial release of the pre-enrichment pipeline + RECURRING_BM_CUTOFF = Time.zone.parse("2025-11-25").freeze + + # Deploy of https://github.com/getlago/lago/pull/710 + PRICING_GROUP_KEYS_CUTOFF = Time.zone.parse("2026-03-06").freeze + + def initialize(organization:, reprocess: false, batch_size: 1000, sleep_seconds: 0.5) + @organization = organization + @reprocess = reprocess + @batch_size = batch_size + @sleep_seconds = sleep_seconds + super + end + + def call + result.subscriptions_to_reprocess = {} + return result if skip_organization? + + merge_results!(recurring_bm_subscriptions) + merge_results!(pricing_group_key_subscriptions) + merge_results!(new_charge_or_filters_subscriptions) + + reprocess_subscriptions! if reprocess + + result + end + + private + + attr_reader :organization, :reprocess, :batch_size, :sleep_seconds + + def recurring_bm_subscriptions + organization.subscriptions + .joins(plan: {charges: :billable_metric}) + .where(billable_metrics: {recurring: true}) + .where("subscriptions.started_at < ?", RECURRING_BM_CUTOFF) + .where(external_id: organization.subscriptions.active.select(:external_id)) + .group("subscriptions.id") + .pluck("subscriptions.id", Arel.sql("ARRAY_AGG(DISTINCT billable_metrics.code)")) + end + + def pricing_group_key_subscriptions + organization.subscriptions.active + .joins(plan: {charges: :billable_metric}) + .joins("LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id") + .where("COALESCE(charge_filters.properties, charges.properties) ? 'pricing_group_keys'") + .where("subscriptions.started_at < ?", PRICING_GROUP_KEYS_CUTOFF) + .where("charge_filters.deleted_at IS NULL") + .group("subscriptions.id") + .pluck("subscriptions.id", Arel.sql("ARRAY_AGG(DISTINCT billable_metrics.code)")) + end + + def new_charge_or_filters_subscriptions + organization.subscriptions.active + .joins(plan: {charges: :billable_metric}) + .joins("LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id") + .where("COALESCE(charge_filters.created_at, charges.created_at) > subscriptions.started_at") + .where("charge_filters.deleted_at IS NULL") + .group("subscriptions.id") + .pluck("subscriptions.id", Arel.sql("ARRAY_AGG(DISTINCT billable_metrics.code)")) + end + + def merge_results!(query_results) + query_results.each do |subscription_id, codes| + result.subscriptions_to_reprocess[subscription_id] ||= [] + result.subscriptions_to_reprocess[subscription_id] |= codes + end + end + + def skip_organization? + !recurring_bm_charges? && !pricing_group_key_charges? && !new_charges_or_filters? + end + + def recurring_bm_charges? + organization.plans + .joins(charges: :billable_metric) + .where(billable_metrics: {recurring: true}) + .exists? + end + + def pricing_group_key_charges? + organization.plans + .joins(:charges) + .joins("LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id") + .where("COALESCE(charge_filters.properties, charges.properties) ? 'pricing_group_keys'") + .where("charge_filters.deleted_at IS NULL") + .exists? + end + + def new_charges_or_filters? + organization.subscriptions.active + .joins(plan: :charges) + .joins("LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id") + .where("COALESCE(charge_filters.created_at, charges.created_at) > subscriptions.started_at") + .where("charge_filters.deleted_at IS NULL") + .exists? + end + + def reprocess_subscriptions! + result.subscriptions_to_reprocess.each do |subscription_id, codes| + PreEnrichmentCheckJob.perform_later(subscription_id:, codes:, batch_size:, sleep_seconds:) + end + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/re_enrich_subscription_events_service.rb b/app/services/events/stores/clickhouse/re_enrich_subscription_events_service.rb new file mode 100644 index 0000000..4170be3 --- /dev/null +++ b/app/services/events/stores/clickhouse/re_enrich_subscription_events_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + class ReEnrichSubscriptionEventsService < BaseService + Result = BaseResult[:events_count, :batch_count] + + def initialize(subscription:, codes: [], reprocess: true, batch_size: 1000, sleep_seconds: 0.5) + @subscription = subscription + @codes = codes + @reprocess = reprocess + @batch_size = batch_size + @sleep_seconds = sleep_seconds + super + end + + def call + return result.service_failure!(code: "missing_kafka_topic", message: "LAGO_KAFKA_RAW_EVENTS_TOPIC env var is not set up") unless topic + + events_count = 0 + batches = 0 + + deduplicated_scope.in_batches(of: batch_size, cursor: [:timestamp, :transaction_id]) do |batch| + events = batch.to_a + messages = events.map { |event| build_message(event) } + + Karafka.producer.produce_many_async(messages) + + batches += 1 + events_count += events.size + + sleep(sleep_seconds) + end + + result.events_count = events_count + result.batch_count = batches + result + end + + private + + attr_reader :subscription, :codes, :reprocess, :batch_size, :sleep_seconds + + def base_scope + scope = ::Clickhouse::EventsRaw + .where(organization_id: subscription.organization_id) + .where(external_subscription_id: subscription.external_id) + .where("timestamp >= ?", subscription.started_at) + + scope = scope.where("timestamp <= ?", subscription.terminated_at) if subscription.terminated? + scope = scope.where(code: codes) if codes.present? + + scope + end + + def deduplicated_scope + deduplicated_sql = base_scope.to_sql + " ORDER BY ingested_at DESC LIMIT 1 BY transaction_id, timestamp" + ::Clickhouse::EventsRaw.from("(#{deduplicated_sql}) AS events_raw") + end + + def topic + @topic ||= ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] + end + + def build_message(event) + { + topic:, + key: "#{event.organization_id}-#{event.external_subscription_id}", + payload: build_payload(event).to_json + } + end + + def build_payload(event) + properties = event.properties + properties = JSON.parse(properties) if properties.is_a?(String) + + { + organization_id: event.organization_id, + external_customer_id: event.external_customer_id, + external_subscription_id: event.external_subscription_id, + transaction_id: event.transaction_id, + timestamp: event.timestamp.strftime("%s.%3N"), + code: event.code, + precise_total_amount_cents: event.precise_total_amount_cents.present? ? event.precise_total_amount_cents.to_s : "0.0", + properties:, + ingested_at: Time.zone.now.iso8601[...-1], + source: Events::KafkaProducerService::EVENT_SOURCE, + source_metadata: { + api_post_processed: true, + reprocess: + } + } + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/unique_count_query.rb b/app/services/events/stores/clickhouse/unique_count_query.rb new file mode 100644 index 0000000..a81cd69 --- /dev/null +++ b/app/services/events/stores/clickhouse/unique_count_query.rb @@ -0,0 +1,503 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + class UniqueCountQuery + def initialize(store:) + @store = store + end + + def query + # NOTE: First sum calculates all operation values for a specific property + # (for instance 2 relevant additions with 1 relevant removal [0, 1, 0, -1, 1] returns 1) + # The next sum combines all properties into a single result + event_values = <<-SQL + SELECT + property, + SUM(adjusted_value) AS sum_adjusted_value + FROM ( + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql} AS adjusted_value + FROM events + ORDER BY timestamp ASC, property ASC + ) adjusted_event_values + GROUP BY property + SQL + + ctes_sql = events_cte_sql.merge!("event_values" => event_values) + + with_ctes(ctes_sql, <<-SQL) + SELECT coalesce(SUM(sum_adjusted_value), 0) AS aggregation FROM event_values + SQL + end + + # NOTE: current implementation of clickhouse's query is different from postgres's one: + # IN POSTGRES we do not ignore Add events at all (they will be handled by adjusted value) + # remove events are ignored if there is an Add event later on the save day. This query is done using + # next_event.operation_type != event.operation_type, which is not supported in current verison of + # Clickhouse we have on production, while this approach is more effective as it only queries one row and does not use + # window function. + # IN CLICKHOUSE we do not ignore Add events at all (they will be handled by adjusted value) + # remove events are not ignored only if they are the last event of the day + # this way we're not using not supported by clickhouse join on !=, but we use window function, which is less + # performant than the postgres approach. + # TODO: we should use the postgres approach in clickhouse as well, but it requires update CLickhouse + def prorated_query + same_day_ignored = <<-SQL + SELECT + property, + operation_type, + timestamp, + #{ignore_remove_events_sql} AS is_ignored + FROM ( + SELECT + timestamp, + property, + operation_type, + -- Check if this is the last event of the day for this property + timestamp = MAX(timestamp) OVER (PARTITION BY property, toDate(timestamp, :timezone)) AS is_last_event_of_day + FROM events + ORDER BY timestamp ASC, property ASC + ) as e + SQL + + # Check if the operation type is the same as previous, so it nullifies this one + event_values = <<-SQL + SELECT + property, + operation_type, + timestamp + FROM ( + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql} AS adjusted_value + FROM same_day_ignored + WHERE is_ignored = false + ORDER BY timestamp ASC, property ASC + ) adjusted_event_values + WHERE adjusted_value != 0 -- adjusted_value = 0 does not impact the total + GROUP BY property, operation_type, timestamp + SQL + + ctes_sql = events_cte_sql.merge!( + "same_day_ignored" => same_day_ignored, + "event_values" => event_values + ) + + with_ctes(ctes_sql, <<-SQL) + SELECT coalesce(SUM(period_ratio), 0) as aggregation + FROM ( + SELECT (#{period_ratio_sql}) AS period_ratio + FROM event_values + ) cumulated_ratios + SQL + end + + def grouped_query + event_values = <<-SQL + SELECT + #{joined_group_names}, + property, + SUM(adjusted_value) AS sum_adjusted_value + FROM ( + SELECT + timestamp, + property, + operation_type, + #{joined_group_names}, + #{grouped_operation_value_sql} AS adjusted_value + FROM events + ORDER BY timestamp ASC, property ASC + ) adjusted_event_values + GROUP BY #{joined_group_names}, property + SQL + + ctes_sql = grouped_events_cte_sql.merge!( + "event_values" => event_values + ) + + with_ctes(ctes_sql, <<-SQL) + SELECT + #{joined_group_names}, + coalesce(SUM(sum_adjusted_value), 0) as aggregation + FROM event_values + GROUP BY #{joined_group_names} + SQL + end + + def grouped_prorated_query + # Only ignore remove events if they are NOT the last event of the day + same_day_ignored = <<-SQL + SELECT + #{joined_group_names}, + property, + operation_type, + timestamp, + #{ignore_remove_events_sql} AS is_ignored + FROM ( + SELECT + timestamp, + property, + operation_type, + #{joined_group_names}, + -- Check if this is the last event of the day for this property and group + timestamp = MAX(timestamp) OVER (PARTITION BY #{joined_group_names}, property, toDate(timestamp, :timezone)) AS is_last_event_of_day + FROM events + ORDER BY timestamp ASC, property ASC + ) as e + SQL + + # Check if the operation type is the same as previous, so it nullifies this one + event_values = <<-SQL + SELECT + #{joined_group_names}, + property, + operation_type, + timestamp + FROM ( + SELECT + timestamp, + property, + operation_type, + #{joined_group_names}, + #{grouped_operation_value_sql} AS adjusted_value + FROM same_day_ignored + WHERE is_ignored = false + ORDER BY timestamp ASC, property ASC + ) adjusted_event_values + WHERE adjusted_value != 0 -- adjusted_value = 0 does not impact the total + GROUP BY #{joined_group_names}, property, operation_type, timestamp + SQL + + ctes_sql = grouped_events_cte_sql.merge!( + "same_day_ignored" => same_day_ignored, + "event_values" => event_values + ) + + with_ctes(ctes_sql, <<-SQL) + SELECT + #{joined_group_names}, + coalesce(SUM(period_ratio), 0) as aggregation + FROM ( + SELECT + (#{grouped_period_ratio_sql}) AS period_ratio, + #{joined_group_names} + FROM event_values + ) cumulated_ratios + GROUP BY #{joined_group_names} + SQL + end + + # NOTE: Not used in production, only for debug purpose to check the computed values before aggregation + # Returns an array of event's timestamp, property, operation type and operation value + # Example: + # [ + # ["2023-03-16T00:00:00.000Z", "001", "add", 1], + # ["2023-03-17T00:00:00.000Z", "001", "add", 0], + # ["2023-03-17T10:00:00.000Z", "002", "remove", 0], + # ["2023-03-18T00:00:00.000Z", "001", "remove", -1], + # ["2023-03-19T00:00:00.000Z", "002", "add", 1] + # ] + def breakdown_query + with_ctes(events_cte_sql, <<-SQL) + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql}, + lagInFrame(operation_type, 1) OVER (PARTITION BY property ORDER BY timestamp ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) + FROM events + ORDER BY timestamp ASC, property ASC + SQL + end + + def prorated_breakdown_query(with_remove: false) + # Only ignore remove events if they are NOT the last event of the day + same_day_ignored = <<-SQL + SELECT + property, + operation_type, + timestamp, + #{ignore_remove_events_sql} AS is_ignored + FROM ( + SELECT + timestamp, + property, + operation_type, + -- Check if this is the last event of the day for this property + timestamp = MAX(timestamp) OVER (PARTITION BY property, toDate(timestamp, :timezone)) AS is_last_event_of_day + FROM events + ORDER BY timestamp ASC, property ASC + ) as e + SQL + + event_values = <<-SQL + SELECT + property, + operation_type, + timestamp + FROM ( + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql} AS adjusted_value + FROM same_day_ignored + WHERE is_ignored = false + ORDER BY timestamp ASC, property ASC + ) adjusted_event_values + WHERE adjusted_value != 0 -- adjusted_value = 0 does not impact the total + GROUP BY property, timestamp, operation_type + SQL + + ctes_sql = events_cte_sql.merge!( + "same_day_ignored" => same_day_ignored, + "event_values" => event_values + ) + + with_ctes(ctes_sql, <<-SQL) + SELECT + prorated_value, + timestamp, + property, + operation_type + FROM ( + SELECT + (#{period_ratio_sql}) AS prorated_value, + timestamp, + property, + operation_type + FROM event_values + ) prorated_breakdown + #{"WHERE prorated_value != 0" unless with_remove} + ORDER BY timestamp ASC, property ASC + SQL + end + + private + + attr_reader :store + + delegate :arel_table, + :with_ctes, + :charges_duration, + :events_cte_queries, + :grouped_arel_columns, + :operation_type_sql, + to: :store + + def joined_group_names + _, names = grouped_arel_columns + + if names.is_a?(Array) + return names.join(", ") + end + + names + end + + def events_cte_sql + # NOTE: Common table expression returning event's timestamp, property name and operation type. + events_cte_queries( + ordered: true, + select: [ + arel_table[:timestamp].as("timestamp"), + arel_table[:value].as("property"), + Arel::Nodes::NamedFunction.new( + "coalesce", + [ + Arel::Nodes::NamedFunction.new("NULLIF", [ + Arel::Nodes::SqlLiteral.new(operation_type_sql), + Arel::Nodes::SqlLiteral.new("''") + ]), + Arel::Nodes::SqlLiteral.new("'add'") + ] + ).as("operation_type") + ], + deduplicated_columns: %w[value sorted_properties] + ) + end + + def grouped_events_cte_sql + groups, _ = grouped_arel_columns + + events_cte_queries( + ordered: true, + select: groups + [ + arel_table[:timestamp].as("timestamp"), + arel_table[:value].as("property"), + Arel::Nodes::NamedFunction.new( + "coalesce", + [ + Arel::Nodes::NamedFunction.new("NULLIF", [ + Arel::Nodes::SqlLiteral.new(operation_type_sql), + Arel::Nodes::SqlLiteral.new("''") + ]), + Arel::Nodes::SqlLiteral.new("'add'") + ] + ).as("operation_type") + ], + deduplicated_columns: %w[value sorted_properties] + ) + end + + def operation_value_sql + # NOTE: Returns 1 for relevant addition, -1 for relevant removal + # If property already added, another addition returns 0 ; it returns 1 otherwise + # If property already removed or not yet present, another removal returns 0 ; it returns -1 otherwise + <<-SQL + if ( + operation_type = 'add', + (if( + (lagInFrame(operation_type, 1) OVER (PARTITION BY property ORDER BY timestamp ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING)) = 'add', + toDecimal32(0, 0), + toDecimal32(1, 0) + )), + (if( + (lagInFrame(operation_type, 1, 'remove') OVER (PARTITION BY property ORDER BY timestamp ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING)) = 'remove', + toDecimal32(0, 0), + toDecimal32(-1, 0) + )) + ) + SQL + end + + def grouped_operation_value_sql + # NOTE: Returns 1 for relevant addition, -1 for relevant removal + # If property already added, another addition returns 0 ; it returns 1 otherwise + # If property already removed or not yet present, another removal returns 0 ; it returns -1 otherwise + <<-SQL + if ( + operation_type = 'add', + (if( + (lagInFrame(operation_type, 1) OVER (PARTITION BY #{joined_group_names}, property ORDER BY timestamp ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING)) = 'add', + toDecimal32(0, 0), + toDecimal32(1, 0) + )) + , + (if( + (lagInFrame(operation_type, 1, 'remove') OVER (PARTITION BY #{joined_group_names}, property ORDER BY timestamp ASC ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING)) = 'remove', + toDecimal32(0, 0), + toDecimal32(-1, 0) + )) + ) + SQL + end + + def period_ratio_sql + <<-SQL + if( + operation_type = 'add', + ( + toDecimal64( + -- inclusive day count in customer TZ, same as PG + date_diff( + 'days', + toDate( + toTimezone( + if( + timestamp < toDateTime64(:from_datetime, 3, 'UTC'), + toDateTime64(:from_datetime, 3, 'UTC'), + timestamp + ), + :timezone + ) + ), + toDate( + toTimezone( + if( + -- if next event is before the period start, clamp to :from_datetime (no +1 day), + -- else add 1 day to make the range inclusive, just like PG does. + (leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 3, 'UTC')) + OVER (PARTITION BY property ORDER BY timestamp ASC + ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) + ) < toDateTime64(:from_datetime, 3, 'UTC'), + toDateTime64(:from_datetime, 3, 'UTC'), + addDays( + (leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 3, 'UTC')) + OVER (PARTITION BY property ORDER BY timestamp ASC + ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) + ), + 1 + ) + ), + :timezone + ) + ) + ), + :decimal_date_scale) + / #{charges_duration || 1} + ), + 0 + ) + SQL + end + + def grouped_period_ratio_sql + <<-SQL + if( + operation_type = 'add', + ( + toDecimal64( + date_diff( + 'days', + toDate( + toTimezone( + if( + timestamp < toDateTime64(:from_datetime, 3, 'UTC'), + toDateTime64(:from_datetime, 3, 'UTC'), + timestamp + ), + :timezone + ) + ), + toDate( + toTimezone( + if( + (leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 3, 'UTC')) + OVER (PARTITION BY #{joined_group_names}, property ORDER BY timestamp ASC + ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) + ) < toDateTime64(:from_datetime, 3, 'UTC'), + toDateTime64(:from_datetime, 3, 'UTC'), + addDays( + (leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 3, 'UTC')) + OVER (PARTITION BY #{joined_group_names}, property ORDER BY timestamp ASC + ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING) + ), + 1 + ) + ), + :timezone + ) + ) + ), + :decimal_date_scale) + / #{charges_duration || 1} + ), + 0 + ) + SQL + end + + def ignore_remove_events_sql + # NOTE: Only NOT ignore remove events if they are the last event of the day + <<-SQL + CASE + -- Never ignore add events + WHEN operation_type = 'add' THEN false + -- Only ignore remove events if they are NOT the last event of the day + WHEN operation_type = 'remove' AND NOT is_last_event_of_day THEN true + ELSE false + END + SQL + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse/weighted_sum_query.rb b/app/services/events/stores/clickhouse/weighted_sum_query.rb new file mode 100644 index 0000000..2654c42 --- /dev/null +++ b/app/services/events/stores/clickhouse/weighted_sum_query.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +module Events + module Stores + module Clickhouse + class WeightedSumQuery + def initialize(store:) + @store = store + end + + def query + with_ctes(events_cte_sql, <<-SQL) + SELECT sum(period_ratio) as aggregation + FROM ( + SELECT (#{period_ratio_sql}) as period_ratio + FROM events_data + ) cumulated_ratios + SQL + end + + def grouped_query(initial_values:) + with_ctes(grouped_events_cte_sql(initial_values), <<-SQL) + SELECT + #{joined_group_names}, + SUM(period_ratio) as aggregation + FROM ( + SELECT + #{joined_group_names}, + (#{grouped_period_ratio_sql}) AS period_ratio + FROM events_data + ) cumulated_ratios + GROUP BY #{joined_group_names} + SQL + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def breakdown_query + with_ctes(events_cte_sql, <<-SQL) + SELECT + timestamp, + difference, + SUM(difference) OVER (ORDER BY timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumul, + date_diff('seconds', timestamp, leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 5, 'UTC')) OVER (ORDER BY timestamp ASC ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)) AS second_duration, + (#{period_ratio_sql}) AS period_ratio + FROM events_data + ORDER BY timestamp ASC + SQL + end + + private + + attr_reader :store + + delegate :arel_table, + :with_ctes, + :charges_duration, + :events_cte_queries, + :grouped_arel_columns, + :grouped_by_columns, + to: :store + + def group_names + _, names = grouped_arel_columns + + if names.is_a?(Array) + return names + end + + names.to_s.split(",") + end + + def joined_group_names + group_names.join(", ") + end + + def events_cte_sql + events_cte = events_cte_queries( + ordered: true, + select: [ + arel_table[:timestamp].as("timestamp"), + arel_table[:decimal_value].as("difference") + ], + deduplicated_columns: %w[decimal_value] + ) + + events_data = <<~SQL + (#{initial_value_sql}) + UNION ALL + (#{events_cte["events"]}) + UNION ALL + (#{end_of_period_value_sql}) + SQL + + events_cte.except!("events").merge!("events_data" => events_data) + end + + def initial_value_sql + <<-SQL + SELECT + toDateTime64(:from_datetime, 5, 'UTC') as timestamp, + toDecimal128(:initial_value, :decimal_scale) as difference + SQL + end + + def end_of_period_value_sql + <<-SQL + SELECT + toDateTime64(:to_datetime, 5, 'UTC') as timestamp, + toDecimal128(0, :decimal_scale) as difference + SQL + end + + def period_ratio_sql + <<-SQL + if( + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + date_diff('seconds', timestamp, leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 5, 'UTC')) OVER (ORDER BY timestamp ASC ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)) > 0, + + -- NOTE: cumulative sum from previous events in the period + (SUM(difference) OVER (ORDER BY timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) + * + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + date_diff('seconds', timestamp, leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 5, 'UTC')) OVER (ORDER BY timestamp ASC ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)) + / + -- NOTE: full duration of the period + #{charges_duration.days.to_i} + , + -- NOTE: duration was null so usage is null + 0 + ) + SQL + end + + def grouped_events_cte_sql(initial_values) + groups, _ = grouped_arel_columns + events_cte = events_cte_queries( + ordered: true, + select: groups + [ + arel_table[:timestamp].as("timestamp"), + arel_table[:decimal_value].as("difference") + ], + deduplicated_columns: %w[decimal_value] + ) + + events_data = <<-SQL + (#{grouped_initial_value_sql(initial_values)}) + UNION ALL + (#{events_cte["events"]}) + UNION ALL + (#{grouped_end_of_period_value_sql(initial_values)}) + SQL + + events_cte.except!("events").merge!("events_data" => events_data) + end + + def grouped_initial_value_sql(initial_values) + values = initial_values.map do |initial_value| + groups = grouped_by_columns(initial_value[:groups]) + + [ + groups, + "toDateTime64(:from_datetime, 5, 'UTC')", + "toDecimal128(#{initial_value[:value]}, :decimal_scale)" + ].flatten.join(", ") + end + + <<-SQL + SELECT + #{Array.new(store.grouped_by_count) { |index| "tuple.#{index + 1} AS #{group_names[index]}" }.join(", ")}, + tuple.#{store.grouped_by_count + 1} AS timestamp, + tuple.#{store.grouped_by_count + 2} AS difference + FROM ( SELECT arrayJoin([#{values.map { "tuple(#{it})" }.join(", ")}]) AS tuple ) + SQL + end + + def grouped_end_of_period_value_sql(initial_values) + values = initial_values.map do |initial_value| + groups = grouped_by_columns(initial_value[:groups]) + + [ + groups, + "toDateTime64(:to_datetime, 5, 'UTC')", + "toDecimal32(0, 0)" + ].flatten.join(", ") + end + + <<-SQL + SELECT + #{Array.new(store.grouped_by_count) { |index| "tuple.#{index + 1} AS #{group_names[index]}" }.join(", ")}, + tuple.#{store.grouped_by_count + 1} AS timestamp, + tuple.#{store.grouped_by_count + 2} AS difference + FROM ( SELECT arrayJoin([#{values.map { "tuple(#{it})" }.join(", ")}]) AS tuple ) + SQL + end + + def grouped_period_ratio_sql + <<-SQL + if( + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + date_diff('seconds', timestamp, leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 5, 'UTC')) OVER (PARTITION BY #{joined_group_names} ORDER BY timestamp ASC ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)) > 0, + + -- NOTE: cumulative sum from previous events in the period + (SUM(difference) OVER (PARTITION BY #{joined_group_names} ORDER BY timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) + * + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + date_diff('seconds', timestamp, leadInFrame(timestamp, 1, toDateTime64(:to_datetime, 5, 'UTC')) OVER (PARTITION BY #{joined_group_names} ORDER BY timestamp ASC ROWS BETWEEN CURRENT ROW AND UNBOUNDED FOLLOWING)) + / + -- NOTE: full duration of the period + #{charges_duration.days.to_i} + , + -- NOTE: duration was null so usage is null + 0 + ) + SQL + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse_enriched_store.rb b/app/services/events/stores/clickhouse_enriched_store.rb new file mode 100644 index 0000000..4619dd4 --- /dev/null +++ b/app/services/events/stores/clickhouse_enriched_store.rb @@ -0,0 +1,843 @@ +# frozen_string_literal: true + +module Events + module Stores + class ClickhouseEnrichedStore < BaseStore + include Events::Stores::Utils::QueryHelpers + include Events::Stores::Utils::ClickhouseSqlHelpers + + DEDUP_KEY_COLUMNS = %w[charge_id charge_filter_id external_subscription_id organization_id timestamp transaction_id].freeze + DEDUP_FALLBACK_KEY_COLUMNS = %w[external_subscription_id organization_id timestamp transaction_id].freeze + + def events(force_from: false, ordered: false) + Events::Stores::Utils::ClickhouseConnection.with_retry do + order_clause = ordered ? "ORDER BY timestamp ASC" : "" + + sql = with_ctes( + events_cte_queries( + deduplicated_columns: %w[decimal_value value properties precise_total_amount_cents code external_subscription_id], + force_from: + ), + <<-SQL + SELECT * + FROM events + #{order_clause} + SQL + ) + + ::Clickhouse::EventsEnrichedExpanded.find_by_sql(sql) + end + end + + def events_cte_queries(**args) + return events_cte_queries_with_deduplication(**args) if deduplicate + + events_cte_queries_without_deduplication(**args) + end + + def events_cte_queries_without_deduplication(force_from: false, ordered: false, select: arel_table[Arel.star], deduplicated_columns: [], to_datetime: nil) + effective_to = to_datetime || applicable_to_datetime + + if needs_code_based_fallback?(force_from:) + current_query = charge_id_based_query(from_datetime: subscription.started_at, to_datetime: effective_to) + fallback_query = code_based_fallback_query(from_datetime: (from_datetime if force_from)) + + current_sql = current_query.project(select).to_sql + fallback_sql = fallback_query.project(select).to_sql + " ORDER BY enriched_at DESC LIMIT 1 BY transaction_id, timestamp" + + return {"events" => "(#{current_sql}) UNION ALL (#{fallback_sql})"} + end + + query = charge_id_based_query( + from_datetime: (from_datetime if force_from || use_from_boundary), + to_datetime: effective_to + ) + query = query.order(arel_table[:timestamp].desc, arel_table[:value].asc) if ordered + + {"events" => query.project(select).to_sql} + end + + def events_cte_queries_with_deduplication(force_from: false, ordered: false, select: arel_table[Arel.star], deduplicated_columns: [], to_datetime: nil) + # Ensure presence of one of value or decimal_value for the ordering + order_column = deduplicated_columns.include?("decimal_value") ? "decimal_value" : "value" + deduplicated_columns << order_column if ordered + + effective_to = to_datetime.presence || applicable_to_datetime.presence + + # Should we include recurring events from previous subscription by relying on the code instead of the charge_id + use_fallback = needs_code_based_fallback?(force_from:) + current_from = use_fallback ? subscription.started_at : (from_datetime if force_from || use_from_boundary) + + ctes = { + "latest_enriched_current" => latest_enriched_current_sql(from_datetime: current_from, to_datetime: effective_to) + } + second_passes = [deduplicated_events_sql(from_datetime: current_from, to_datetime: effective_to, deduplicated_columns:)] + + if use_fallback + fallback_from = (from_datetime if force_from) + ctes["latest_enriched_fallback"] = latest_enriched_fallback_sql(from_datetime: fallback_from) + second_passes << code_based_fallback_dedup_sql(from_datetime: fallback_from, deduplicated_columns:) + end + + ctes["events_enriched_expanded"] = (second_passes.size == 1) ? second_passes.first : second_passes.map { "(#{it})" }.join(" UNION ALL ") + + query = Arel::Table.new(:events_enriched_expanded) + query = query.order(arel_table[:timestamp].desc, arel_table[order_column.to_sym]) if ordered + + ctes["events"] = query.project(select).to_sql + ctes + end + + # ClickHouse cannot guarantee that events_enriched_expanded will be deduplicated all the time, + # so we deduplicate at query time using a two-pass strategy: + # 1. `latest_enriched_current_sql` groups events by their dedup key and gets the latest enriched_at. + # 2. `deduplicated_events_sql` filters `latest_enriched_current` and uses `INNER ANY JOIN` to + # fetch the requested columns from events_enriched_expanded. `ANY JOIN` returns at most one + # matching row, so duplicated enriched_at are filtered at the join layer. + # This replaces a previous implementation with `argMax` which caused ClickHouse OOM on large subscriptions. + def latest_enriched_current_sql(from_datetime:, to_datetime:) + <<~SQL.squish + SELECT #{DEDUP_KEY_COLUMNS.join(", ")}, max(enriched_at) AS max_enriched_at + FROM events_enriched_expanded + WHERE #{charge_id_based_where_sql(from_datetime:, to_datetime:, include_grouped_by_values: false)} + GROUP BY #{DEDUP_KEY_COLUMNS.join(", ")} + SQL + end + + def deduplicated_events_sql(from_datetime:, to_datetime:, deduplicated_columns: []) + extra_columns = dedup_selected_columns(deduplicated_columns) + selected_columns = (DEDUP_KEY_COLUMNS.map { "l.#{it}" } + extra_columns).join(", ") + join_conditions = (DEDUP_KEY_COLUMNS.map { "e.#{it} = l.#{it}" } + ["e.enriched_at = l.max_enriched_at"]).join(" AND ") + + <<~SQL.squish + SELECT #{selected_columns} + FROM latest_enriched_current AS l + INNER ANY JOIN events_enriched_expanded AS e ON #{join_conditions} + WHERE #{charge_id_based_where_sql(from_datetime:, to_datetime:, alias_prefix: "e")} + SQL + end + + # Code-based fallback for recurring charges. + # First pass: groups events on the smaller dedup key (no charge_id/charge_filter_id) + # because fallback events come from a previous subscription's charges, so charge_ids are irrelevant. + # Matching/ignored filters and grouped_by_values are deferred to the second pass to keep + # the dedup grouping based on the base columns only (org, code, subscription, timestamp, transaction). + def latest_enriched_fallback_sql(from_datetime:) + <<~SQL.squish + SELECT #{DEDUP_FALLBACK_KEY_COLUMNS.join(", ")}, max(enriched_at) AS max_enriched_at + FROM events_enriched_expanded + WHERE #{code_based_fallback_where_sql(from_datetime:, include_grouped_by_values: false, include_filters: false)} + GROUP BY #{DEDUP_FALLBACK_KEY_COLUMNS.join(", ")} + SQL + end + + # Code-based fallback second pass for events before subscription.started_at. + # Projects dummy charge_id/charge_filter_id for UNION compatibility with deduplicated_events_sql. + def code_based_fallback_dedup_sql(from_datetime:, deduplicated_columns: []) + extra_columns = dedup_selected_columns(deduplicated_columns) + selected_columns = ( + ["'' AS charge_id", "'' AS charge_filter_id"] + + DEDUP_FALLBACK_KEY_COLUMNS.map { "l.#{it}" } + + extra_columns + ).join(", ") + join_conditions = (DEDUP_FALLBACK_KEY_COLUMNS.map { "e.#{it} = l.#{it}" } + ["e.enriched_at = l.max_enriched_at"]).join(" AND ") + + <<~SQL.squish + SELECT #{selected_columns} + FROM latest_enriched_fallback AS l + INNER ANY JOIN events_enriched_expanded AS e ON #{join_conditions} + WHERE #{code_based_fallback_where_sql(from_datetime:, alias_prefix: "e")} + SQL + end + + # DEPRECATED: This method will be replaced by distinct_charges_and_filters + # to filter the charge and filters in a billing period. + # See app/services/events/billing_period_filter_service.rb:42 + def distinct_codes + Events::Stores::Utils::ClickhouseConnection.with_retry do + ::Clickhouse::EventsEnrichedExpanded + .where(external_subscription_id: subscription.external_id) + .where(organization_id: subscription.organization_id) + .where(timestamp: from_datetime..applicable_to_datetime) + .pluck("DISTINCT(code)") + end + end + + def distinct_charges_and_filters + Events::Stores::Utils::ClickhouseConnection.with_retry do + ::Clickhouse::EventsEnrichedExpanded + .where(external_subscription_id: subscription.external_id) + .where(organization_id: subscription.organization_id) + .where(timestamp: from_datetime..to_datetime) + .distinct + .pluck("charge_id", Arel.sql("nullIf(charge_filter_id, '')")) + end + end + + def events_values(limit: nil, force_from: false, exclude_event: false) + Utils::ClickhouseConnection.connection_with_retry do |connection| + table = Arel::Table.new("events") + query = table.order(table[:timestamp].asc) + + if exclude_event + query = query.where(table[:transaction_id].not_eq(filters[:event].transaction_id)) + end + + query = query.take(limit) if limit + + sql = with_ctes(events_cte_queries( + deduplicated_columns: %w[decimal_value], + force_from: + ), query.project(table[:decimal_value]).to_sql) + + connection.select_values(sql) + end + end + + def prorated_events_values(total_duration) + ratio = duration_ratio_sql( + "events_enriched_expanded.timestamp", to_datetime, total_duration, timezone + ) + + Utils::ClickhouseConnection.connection_with_retry do |connection| + table = Arel::Table.new("events") + query = table.order(table[:timestamp].asc) + + sql = with_ctes(events_cte_queries( + select: [ + arel_table[:timestamp], + Arel::Nodes::InfixOperation.new( + "*", + arel_table[:decimal_value], + Arel::Nodes::Grouping.new(Arel::Nodes::SqlLiteral.new(ratio.to_s)) + ).as("prorated_value") + ], + deduplicated_columns: %w[decimal_value] + ), query.project(table[:prorated_value]).to_sql) + + connection.select_values(sql) + end + end + + def last_event + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value properties]), <<-SQL) + SELECT * + FROM events + ORDER BY timestamp DESC + LIMIT 1 + SQL + + attributes = connection.select_one(sql) + break if attributes.nil? + + ::Clickhouse::EventsEnrichedExpanded.new(attributes) + end + end + + def grouped_last_event + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: [arel_table[:sorted_grouped_by], arel_table[:decimal_value].as("property"), arel_table[:timestamp]], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + DISTINCT ON (sorted_grouped_by) sorted_grouped_by as groups, + events.timestamp, + property as value + FROM events + ORDER BY sorted_grouped_by, timestamp DESC + SQL + + prepare_grouped_result(connection.select_all(sql)) + end + end + + def count + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[value]), <<-SQL) + SELECT count() + FROM events + SQL + + connection.select_value(sql).to_i + end + end + + def grouped_count(_columns = grouped_by) + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[value], select: [arel_table[:sorted_grouped_by]]), <<-SQL) + SELECT + sorted_grouped_by as groups, + toDecimal32(count(), 0) as value + FROM events + GROUP BY sorted_grouped_by + SQL + + prepare_grouped_result(connection.select_all(sql)) + end + end + + def unique_count + result = Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.query), + {decimal_date_scale: DECIMAL_DATE_SCALE} + ] + ) + connection.select_one(sql) + end + + result["aggregation"] + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def unique_count_breakdown + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + + connection.select_all( + ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.breakdown_query), + {decimal_date_scale: DECIMAL_DATE_SCALE} + ] + ) + ).rows + end + end + + def prorated_unique_count + result = Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.prorated_query), + { + from_datetime:, + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE, + timezone: customer.applicable_timezone + } + ] + ) + connection.select_one(sql) + end + + result["aggregation"] + end + + def prorated_unique_count_breakdown(with_remove: false) + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.prorated_breakdown_query(with_remove:)), + { + from_datetime:, + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE, + timezone: customer.applicable_timezone + } + ] + ) + + connection.select_all(sql).to_a + end + end + + def grouped_unique_count(_columns = grouped_by) + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_query), + { + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE + } + ] + ) + + prepare_grouped_result(connection.select_all(sql), groups_key: :grouped_by, value_key: :aggregation) + end + end + + def grouped_prorated_unique_count + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_prorated_query), + { + from_datetime:, + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE, + timezone: customer.applicable_timezone + } + ] + ) + prepare_grouped_result(connection.select_all(sql), groups_key: :grouped_by, value_key: :aggregation) + end + end + + def active_unique_property?(event) + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries( + deduplicated_columns: %w[value properties], + to_datetime: event.timestamp - 0.001.seconds, + ordered: true + ), <<-SQL) + SELECT properties + FROM events + WHERE value = ? + ORDER BY timestamp DESC + LIMIT 1 + SQL + + previous_properties = connection.select_one( + ActiveRecord::Base.sanitize_sql_for_conditions([sql, event.properties[aggregation_property].to_s]) + ) + return false if previous_properties.nil? + + operation_type = previous_properties.dig("properties", "operation_type") + operation_type.nil? || operation_type == "add" + end + end + + def max + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value]), <<-SQL) + SELECT max(events.decimal_value) + FROM events + SQL + + connection.select_value(sql) + end + end + + def grouped_max(_columns = grouped_by) + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries( + deduplicated_columns: %w[decimal_value], + select: [arel_table[:sorted_grouped_by], arel_table[:decimal_value]] + ), <<-SQL) + SELECT + sorted_grouped_by as groups, + MAX(events.decimal_value) as value + FROM events + GROUP BY sorted_grouped_by + SQL + + prepare_grouped_result(connection.select_all(sql)) + end + end + + def last + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value]), <<-SQL) + SELECT decimal_value + FROM events + ORDER BY timestamp DESC + LIMIT 1 + SQL + + connection.select_value(sql) + end + end + + def grouped_last(_columns = grouped_by) + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value]), <<-SQL) + SELECT + DISTINCT ON (sorted_grouped_by) sorted_grouped_by as groups, + events.decimal_value as value + FROM events + ORDER BY sorted_grouped_by, timestamp DESC + SQL + + prepare_grouped_result(connection.select_all(sql)) + end + end + + def sum + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value]), <<-SQL) + SELECT sum(events.decimal_value) + FROM events + SQL + + connection.select_value(sql) || 0 + end + end + + def grouped_sum(_columns = grouped_by) + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value]), <<-SQL) + SELECT + sorted_grouped_by as groups, + sum(events.decimal_value) as value + FROM events + GROUP BY sorted_grouped_by + SQL + + prepare_grouped_result(connection.select_all(sql)) + end + end + + def sum_precise_total_amount_cents + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[precise_total_amount_cents]), <<-SQL) + SELECT COALESCE(sum(events.precise_total_amount_cents), 0) + FROM events + SQL + + connection.select_value(sql) + end + end + + def grouped_sum_precise_total_amount_cents + Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[precise_total_amount_cents]), <<-SQL) + SELECT + sorted_grouped_by as groups, + sum(events.precise_total_amount_cents) as value + FROM events + GROUP BY sorted_grouped_by + SQL + + prepare_grouped_result(connection.select_all(sql)) + end + end + + def prorated_sum(period_duration:, persisted_duration: nil) + ratio = if persisted_duration + persisted_duration.fdiv(period_duration) + else + duration_ratio_sql( + "events_enriched_expanded.timestamp", to_datetime, period_duration, timezone + ) + end + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: [ + Arel::Nodes::InfixOperation.new( + "*", + arel_table[:decimal_value], + Arel::Nodes::Grouping.new(Arel::Nodes::SqlLiteral.new(ratio.to_s)) + ).as("prorated_value") + ], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT sum(events.prorated_value) + FROM events + SQL + + connection.select_value(sql) + end + end + + def grouped_prorated_sum(period_duration:, persisted_duration: nil) + ratio = if persisted_duration + persisted_duration.fdiv(period_duration) + else + duration_ratio_sql( + "events_enriched_expanded.timestamp", to_datetime, period_duration, timezone + ) + end + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: [arel_table[:sorted_grouped_by]] + [ + Arel::Nodes::InfixOperation.new( + "*", + arel_table[:decimal_value], + Arel::Nodes::Grouping.new(Arel::Nodes::SqlLiteral.new(ratio.to_s)) + ).as("prorated_value") + ], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + sorted_grouped_by as groups, + sum(events.prorated_value) as value + FROM events + GROUP BY sorted_grouped_by + SQL + + prepare_grouped_result(connection.select_all(sql)) + end + end + + def sum_date_breakdown + date_field = date_in_customer_timezone_sql("events_enriched_expanded.timestamp", timezone) + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: [ + Arel::Nodes::NamedFunction.new( + "toDate", + [Arel::Nodes::SqlLiteral.new(date_field)] + ).as("day"), + arel_table[:decimal_value].as("property") + ], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + events.day, + sum(events.property) AS day_sum + FROM events + GROUP BY events.day + ORDER BY events.day asc + SQL + + connection.select_all(Arel.sql(sql)).rows.map do |row| + {date: row.first.to_date, value: row.last} + end + end + end + + def weighted_sum(initial_value: 0) + result = Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::WeightedSumQuery.new(store: self) + + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.query), + { + from_datetime:, + to_datetime: to_datetime.ceil, + decimal_scale: DECIMAL_SCALE, + initial_value: initial_value || 0 + } + ] + ) + + connection.select_one(sql) + end + + BigDecimal(result["aggregation"].presence || 0) + end + + def grouped_weighted_sum(_columns = grouped_by, initial_values: []) + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Clickhouse::WeightedSumQuery.new(store: self) + + # NOTE: build the list of initial values for each groups + # from the events in the period + formatted_initial_values = grouped_count.map do |group| + value = 0 + previous_group = initial_values.find { |g| g[:groups] == group[:groups] } + value = previous_group[:value] if previous_group + {groups: group[:groups], value:} + end + + # NOTE: add the initial values for groups that are not in the events + initial_values.each do |initial_value| + next if formatted_initial_values.find { |g| g[:groups] == initial_value[:groups] } + + formatted_initial_values << initial_value + end + return [] if formatted_initial_values.empty? + + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_query(initial_values: formatted_initial_values)), + { + from_datetime:, + to_datetime: to_datetime.ceil, + decimal_scale: DECIMAL_SCALE + } + ] + ) + + prepare_grouped_result(connection.select_all(sql), decimal: true, groups_key: :grouped_by, value_key: :aggregation) + end + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def weighted_sum_breakdown(initial_value: 0) + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::WeightedSumQuery.new(store: self) + + rows = connection.select_all( + ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.breakdown_query), + { + from_datetime:, + to_datetime: to_datetime.ceil, + decimal_scale: DECIMAL_SCALE, + initial_value: initial_value || 0 + } + ] + ) + ).rows + # `date_diff` actually returns an `Int64` and ActiveRecord transform that into a `String`. If we cast the + # result in a `Int32`, then we get the result as `Integer`: + # ```ruby + # lago-api(staging)> Clickhouse::BaseRecord.connection.select_one("SELECT 1::Int64") + # => {"CAST('1', 'Int64')" => "1"} + # lago-api(staging)> Clickhouse::BaseRecord.connection.select_one("SELECT 1::Int32") + # => {"CAST('1', 'Int32')" => 1} + # ``` + # To keep consistency with the PG implementation, we call `#to_i` on the value. + rows.map do |(timestamp, difference, cumul, second_duration, period_ratio)| + [timestamp, difference, cumul, second_duration.to_i, period_ratio] + end + end + end + + def arel_table + @arel_table ||= ::Clickhouse::EventsEnrichedExpanded.arel_table + end + + def grouped_arel_columns + [ + [arel_table[:sorted_grouped_by].as("grouped_by")], + group_names + ] + end + + def group_names + [joined_group_names] + end + + def joined_group_names + "grouped_by" + end + + def grouped_by_columns(values) + map_values = values.map { |g, v| [quote(g), quote(v || "")] } + "map(#{map_values.flatten.join(", ")})" + end + + def grouped_by_count + 1 + end + + def operation_type_sql + "events_enriched_expanded.sorted_properties['operation_type']" + end + + def dedup_selected_columns(deduplicated_columns) + columns = deduplicated_columns.dup + columns << "sorted_grouped_by" if grouped_by.present? || grouped_by_values.present? + + columns.uniq.reject { DEDUP_KEY_COLUMNS.include?(it) }.map { "e.#{it}" } + end + + def charge_id_based_where_sql(from_datetime:, to_datetime:, alias_prefix: nil, include_grouped_by_values: true) + prefix = alias_prefix ? "#{alias_prefix}." : "" + + conditions = [ + sql_condition("#{prefix}organization_id = ?", subscription.organization_id), + sql_condition("#{prefix}code = ?", code), + sql_condition("#{prefix}external_subscription_id = ?", subscription.external_id), + sql_condition("#{prefix}charge_id = ?", charge_id), + sql_condition("#{prefix}charge_filter_id = ?", charge_filter_id || "") + ] + + conditions << sql_condition("#{prefix}timestamp >= ?", from_datetime) if from_datetime + conditions << sql_condition("#{prefix}timestamp <= ?", to_datetime) if to_datetime + conditions << grouped_by_values_sql_condition(prefix) if include_grouped_by_values && grouped_by_values? + + conditions.join(" AND ") + end + + def code_based_fallback_where_sql(from_datetime:, alias_prefix: nil, include_grouped_by_values: true, include_filters: true) + prefix = alias_prefix ? "#{alias_prefix}." : "" + + conditions = [ + sql_condition("#{prefix}organization_id = ?", subscription.organization_id), + sql_condition("#{prefix}code = ?", code), + sql_condition("#{prefix}external_subscription_id = ?", subscription.external_id), + sql_condition("#{prefix}timestamp < ?", subscription.started_at) + ] + + conditions << sql_condition("#{prefix}timestamp >= ?", from_datetime) if from_datetime + + if include_filters + matching_filters.each do |key, values| + conditions << sql_condition("#{prefix}sorted_properties[?] IN (?)", key.to_s, values.map(&:to_s)) + end + + ignored_clauses = ignored_filters.filter_map do |filters| + next if filters.empty? + + inner = filters.filter_map do |key, values| + next if values.empty? + + sql_condition("(coalesce(#{prefix}sorted_properties[?], '') IN (?))", key.to_s, values.map(&:to_s)) + end.join(" AND ") + inner.presence + end + conditions << "NOT (#{ignored_clauses.map { "(#{it})" }.join(" OR ")})" if ignored_clauses.any? + end + + conditions << grouped_by_values_via_properties_sql_condition(prefix) if include_grouped_by_values && grouped_by_values? + + conditions.join(" AND ") + end + + def grouped_by_values_sql_condition(prefix) + map_args = grouped_by_values + .sort_by { |k, _| k } + .flat_map { |k, v| [quote(k), quote(v.presence || "")] } + .join(", ") + "#{prefix}sorted_grouped_by = map(#{map_args})" + end + + # Fallback events come from a previous subscription's charges, so `sorted_grouped_by` + # reflects that charge's configuration and may not match the current grouped_by keys. + # Filter against `sorted_properties` directly to stay charge-agnostic. + def grouped_by_values_via_properties_sql_condition(prefix) + grouped_by_values.map do |key, value| + if value.present? + sql_condition("#{prefix}sorted_properties[?] = ?", key.to_s, value.to_s) + else + sql_condition("coalesce(#{prefix}sorted_properties[?], '') = ''", key.to_s) + end + end.join(" AND ") + end + + def needs_code_based_fallback?(force_from:) + return false if subscription.previous_subscription_id.blank? + return false if use_from_boundary + + effective_from = from_datetime if force_from + effective_from.nil? || effective_from < subscription.started_at + end + + def charge_id_based_query(from_datetime:, to_datetime:) + arel_table.where(Arel.sql(charge_id_based_where_sql(from_datetime:, to_datetime:))) + end + + # Fallback query filtering by code + properties instead of charge_id/charge_filter_id. + # Used for events before subscription.started_at (from a previous subscription's charges). + def code_based_fallback_query(from_datetime:) + arel_table.where(Arel.sql(code_based_fallback_where_sql(from_datetime:))) + end + + def prepare_grouped_result(result, decimal: false, groups_key: :groups, value_key: :value) + result.to_ary.map do |row| + row.symbolize_keys.tap do |r| + r[:groups] = r[groups_key].transform_values(&:presence) + r[:value] = decimal ? BigDecimal(r[value_key].presence || 0) : r[value_key] + r.slice!(:groups, :value, :timestamp) + end + end + end + end + end +end diff --git a/app/services/events/stores/clickhouse_store.rb b/app/services/events/stores/clickhouse_store.rb new file mode 100644 index 0000000..3b3f352 --- /dev/null +++ b/app/services/events/stores/clickhouse_store.rb @@ -0,0 +1,846 @@ +# frozen_string_literal: true + +module Events + module Stores + class ClickhouseStore < BaseStore + include Events::Stores::Utils::QueryHelpers + include Events::Stores::Utils::ClickhouseSqlHelpers + + # Give ClickHouse time to consume and merge the `events_enriched` event + # processed on the events processor side + CLICKHOUSE_MERGE_DELAY = 15.seconds + + DEDUP_KEY_COLUMNS = %w[code organization_id external_subscription_id transaction_id timestamp].freeze + + def events(force_from: false, ordered: false) + Events::Stores::Utils::ClickhouseConnection.with_retry do + scope = if deduplicate + events_from = (from_datetime if force_from || use_from_boundary) + events_to = (applicable_to_datetime if applicable_to_datetime) + + deduplicated_subquery = <<~SQL.squish + WITH latest_enriched AS (#{latest_enriched_sql(from_datetime: events_from, to_datetime: events_to)}) + #{deduplicated_events_sql( + from_datetime: events_from, + to_datetime: events_to, + deduplicated_columns: %w[value decimal_value properties precise_total_amount_cents] + )} + SQL + + ::Clickhouse::EventsEnriched.from("(#{deduplicated_subquery}) AS events_enriched") + else + query = ::Clickhouse::EventsEnriched + .where(external_subscription_id: subscription.external_id) + .where(organization_id: subscription.organization_id) + .where(code:) + + query = query.where("events_enriched.timestamp >= ?", from_datetime) if force_from || use_from_boundary + query = query.where("events_enriched.timestamp <= ?", applicable_to_datetime) if applicable_to_datetime + query + end + + scope = scope.order(timestamp: :asc) if ordered + scope = apply_grouped_by_values(scope) if grouped_by_values? + filters_scope(scope) + end + end + + def events_cte_queries(**args) + return events_cte_queries_with_deduplication(**args) if deduplicate + + events_cte_queries_without_deduplication(**args) + end + + def events_cte_queries_without_deduplication(force_from: false, ordered: false, select: arel_table[Arel.star], deduplicated_columns: []) + query = arel_table.where( + arel_table[:external_subscription_id].eq(subscription.external_id) + .and(arel_table[:organization_id].eq(subscription.organization.id) + .and(arel_table[:code].eq(code))) + ) + + query = query.order(arel_table[:timestamp].desc, arel_table[:value].asc) if ordered + + query = with_timestamp_boundaries( + query, + (from_datetime if force_from || use_from_boundary), + applicable_to_datetime + ) + + query = arel_filters_scope(query) + query = apply_arel_grouped_by_values(query) if grouped_by_values? + + {"events" => query.project(select).to_sql} + end + + def events_cte_queries_with_deduplication(force_from: false, ordered: false, select: arel_table[Arel.star], deduplicated_columns: []) + # Ensure presence of one of value or decimal_value for the ordering + order_column = deduplicated_columns.include?("decimal_value") ? "decimal_value" : "value" + deduplicated_columns << order_column if ordered + + events_from = (from_datetime if force_from || use_from_boundary) + events_to = (applicable_to_datetime if applicable_to_datetime) + + query = arel_table + query = query.order(arel_table[:timestamp].desc, arel_table[order_column]) if ordered + + query = apply_arel_grouped_by_values(query) if grouped_by_values? + query = arel_filters_scope(query) + + { + "latest_enriched" => latest_enriched_sql(from_datetime: events_from, to_datetime: events_to), + "events_enriched" => deduplicated_events_sql(from_datetime: events_from, to_datetime: events_to, deduplicated_columns:), + "events" => query.project(select).to_sql + } + end + + # ClickHouse cannot guarantee that events_enriched will be deduplicated all the time, + # so we deduplicate at query time using a two-pass strategy: + # 1. `latest_enriched_sql` groups events by their dedup key and gets the latest enriched_at. + # 2. `deduplicated_events_sql` filters `latest_enriched` and uses `INNER ANY JOIN` to + # fetch the requested columns from events_enriched. `ANY JOIN` returns at most one + # matching row, so duplicated enriched_at are filtered at the join layer + # This replaces a previous implementation with `argMax` which caused ClickHouse OOM on large subscriptions. + def latest_enriched_sql(from_datetime:, to_datetime:) + <<~SQL.squish + SELECT #{DEDUP_KEY_COLUMNS.join(", ")}, max(enriched_at) AS max_enriched_at + FROM events_enriched + WHERE #{deduplicated_events_where_sql(from_datetime:, to_datetime:)} + GROUP BY #{DEDUP_KEY_COLUMNS.join(", ")} + SQL + end + + def deduplicated_events_sql(from_datetime:, to_datetime:, deduplicated_columns: []) + columns = deduplicated_columns.dup + + # Grouping and filtering is made based on the properties + if grouped_by.present? || grouped_by_values? || matching_filters.present? || ignored_filters.present? + columns << "properties" + end + + picked_columns = columns.uniq.map { "e.#{it}" } + selected_columns = (DEDUP_KEY_COLUMNS.map { "l.#{it}" } + picked_columns).join(", ") + join_conditions = (DEDUP_KEY_COLUMNS.map { "e.#{it} = l.#{it}" } + ["e.enriched_at = l.max_enriched_at"]).join(" AND ") + + <<~SQL.squish + SELECT #{selected_columns} + FROM latest_enriched AS l + INNER ANY JOIN events_enriched AS e ON #{join_conditions} + WHERE #{deduplicated_events_where_sql(from_datetime:, to_datetime:, alias_prefix: "e")} + SQL + end + + def deduplicated_events_where_sql(from_datetime:, to_datetime:, alias_prefix: nil) + prefix = alias_prefix ? "#{alias_prefix}." : "" + + conditions = [ + ActiveRecord::Base.sanitize_sql_for_conditions( + [ + "#{prefix}organization_id = ? AND #{prefix}code = ? AND #{prefix}external_subscription_id = ?", + subscription.organization_id, + code, + subscription.external_id + ] + ) + ] + + conditions << ActiveRecord::Base.sanitize_sql_for_conditions(["#{prefix}timestamp >= ?", from_datetime]) if from_datetime + conditions << ActiveRecord::Base.sanitize_sql_for_conditions(["#{prefix}timestamp <= ?", to_datetime]) if to_datetime + conditions.join(" AND ") + end + + def distinct_codes + Events::Stores::Utils::ClickhouseConnection.with_retry do + ::Clickhouse::EventsEnriched + .where(external_subscription_id: subscription.external_id) + .where(organization_id: subscription.organization.id) + .where("events_enriched.timestamp >= ?", from_datetime) + .where("events_enriched.timestamp <= ?", applicable_to_datetime) + .pluck("DISTINCT(code)") + end + end + + def distinct_charges_and_filters + # Implementation relies directly on the events_enriched_expanded table, + # so we delegate the implementation to the ClickhouseEnrichedStore + Events::Stores::ClickhouseEnrichedStore.new( + subscription:, + boundaries: + ).distinct_charges_and_filters + end + + def events_values(limit: nil, force_from: false, exclude_event: false) + Events::Stores::Utils::ClickhouseConnection.with_retry do + scope = events(force_from:, ordered: true) + + scope = scope.where("events_enriched.transaction_id != ?", filters[:event].transaction_id) if exclude_event + scope = scope.limit(limit) if limit + + scope.pluck("events_enriched.decimal_value") + end + end + + def last_event + Events::Stores::Utils::ClickhouseConnection.with_retry { events(ordered: true).last } + end + + def grouped_last_event + groups, group_names = grouped_arel_columns + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: groups + [arel_table[:decimal_value].as("property"), arel_table[:timestamp]], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + DISTINCT ON (#{group_names}) #{group_names}, + events.timestamp, + property + FROM events + ORDER BY #{group_names}, events.timestamp DESC + SQL + + prepare_grouped_result(connection.select_all(sql).rows, timestamp: true) + end + end + + def prorated_events_values(total_duration) + ratio_sql = duration_ratio_sql( + "events_enriched.timestamp", to_datetime, total_duration, timezone + ) + + Events::Stores::Utils::ClickhouseConnection.with_retry do + events(ordered: true).pluck(Arel.sql("events_enriched.decimal_value * (#{ratio_sql})")) + end + end + + def count + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[value]), <<-SQL) + SELECT count() + FROM events + SQL + + connection.select_value(sql).to_i + end + end + + def grouped_count(columns = grouped_by) + groups, column_names = grouped_arel_columns(columns) + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: groups + [arel_table[:transaction_id]], + deduplicated_columns: %w[value properties] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + #{column_names}, + toDecimal32(count(), 0) + FROM events + GROUP BY #{column_names} + SQL + + prepare_grouped_result(connection.select_all(sql).rows, columns: columns) + end + end + + # NOTE: check if an event created before the current on belongs to an active (as in present and not removed) + # unique property + def active_unique_property?(event) + previous_event = Events::Stores::Utils::ClickhouseConnection.with_retry do + events + .where("events_enriched.properties[?] = ?", aggregation_property, event.properties[aggregation_property]) + .where("events_enriched.timestamp < ?", event.timestamp) + .order(timestamp: :desc) + .first + end + + previous_event && ( + previous_event.properties["operation_type"].nil? || + previous_event.properties["operation_type"] == "add" + ) + end + + def unique_count + result = Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.query), + {decimal_date_scale: DECIMAL_DATE_SCALE} + ] + ) + connection.select_one(sql) + end + + result["aggregation"] + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def unique_count_breakdown + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + + connection.select_all( + ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.breakdown_query), + {decimal_date_scale: DECIMAL_DATE_SCALE} + ] + ) + ).rows + end + end + + def prorated_unique_count + result = Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.prorated_query), + { + from_datetime:, + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE, + timezone: customer.applicable_timezone + } + ] + ) + connection.select_one(sql) + end + + result["aggregation"] + end + + def prorated_unique_count_breakdown(with_remove: false) + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.prorated_breakdown_query(with_remove:)), + { + from_datetime:, + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE, + timezone: customer.applicable_timezone + } + ] + ) + + connection.select_all(sql).to_a + end + end + + def grouped_unique_count(columns = grouped_by) + duplicated_unique_count_store = dup + duplicated_unique_count_store.grouped_by = columns + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: duplicated_unique_count_store) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_query), + { + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE + } + ] + ) + + prepare_grouped_result(connection.select_all(sql).rows, columns: columns) + end + end + + def grouped_prorated_unique_count + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::UniqueCountQuery.new(store: self) + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_prorated_query), + { + from_datetime:, + to_datetime:, + decimal_date_scale: DECIMAL_DATE_SCALE, + timezone: customer.applicable_timezone + } + ] + ) + prepare_grouped_result(connection.select_all(sql).rows) + end + end + + def max + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value]), <<-SQL) + SELECT max(events.decimal_value) + FROM events + SQL + + connection.select_value(sql) + end + end + + def grouped_max(columns = grouped_by) + groups, column_names = grouped_arel_columns(columns) + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: groups + [arel_table[:decimal_value].as("property"), arel_table[:timestamp]], + deduplicated_columns: %w[decimal_value properties] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + #{column_names}, + MAX(property) + FROM events + GROUP BY #{column_names} + SQL + + prepare_grouped_result(connection.select_all(sql).rows, columns: columns) + end + end + + def last + value = Events::Stores::Utils::ClickhouseConnection.with_retry do + events(ordered: true).last&.properties&.[](aggregation_property) + end + + return value unless value + + BigDecimal(value) + end + + def grouped_last(columns = grouped_by) + groups, column_names = grouped_arel_columns(columns) + distinct_on_names = grouped_by.present? ? grouped_arel_columns.last : nil + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: groups + [arel_table[:decimal_value].as("property"), arel_table[:timestamp]], + deduplicated_columns: %w[decimal_value properties] + ) + + sql = if distinct_on_names + with_ctes(ctes_sql, <<-SQL) + SELECT + DISTINCT ON (#{distinct_on_names}) #{column_names}, + property + FROM events + ORDER BY #{distinct_on_names}, events.timestamp DESC + SQL + else + with_ctes(ctes_sql, <<-SQL) + SELECT + #{column_names}, + property + FROM events + ORDER BY events.timestamp DESC + LIMIT 1 + SQL + end + + prepare_grouped_result(connection.select_all(sql).rows, columns: columns) + end + end + + def sum_precise_total_amount_cents + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[precise_total_amount_cents]), <<-SQL) + SELECT COALESCE(SUM(events.precise_total_amount_cents), 0) + FROM events + SQL + + connection.select_value(sql) + end + end + + def grouped_sum_precise_total_amount_cents + groups, group_names = grouped_arel_columns + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: groups + [arel_table[:precise_total_amount_cents].as("precise_total_amount_cents")], + deduplicated_columns: %w[precise_total_amount_cents] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + #{group_names}, + sum(events.precise_total_amount_cents) + FROM events + GROUP BY #{group_names} + SQL + + prepare_grouped_result(connection.select_all(sql).rows) + end + end + + def sum + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + sql = with_ctes(events_cte_queries(deduplicated_columns: %w[decimal_value]), <<-SQL) + SELECT sum(events.decimal_value) + FROM events + SQL + + connection.select_value(sql) || 0 + end + end + + def grouped_sum(columns = grouped_by) + groups, column_names = grouped_arel_columns(columns) + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: groups + [arel_table[:decimal_value].as("property")], + deduplicated_columns: %w[decimal_value properties] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + #{column_names}, + sum(events.property) + FROM events + GROUP BY #{column_names} + SQL + + prepare_grouped_result(connection.select_all(sql).rows, columns: columns) + end + end + + def prorated_sum(period_duration:, persisted_duration: nil) + ratio = if persisted_duration + persisted_duration.fdiv(period_duration) + else + duration_ratio_sql( + "events_enriched.timestamp", to_datetime, period_duration, timezone + ) + end + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: [ + Arel::Nodes::InfixOperation.new( + "*", + arel_table[:decimal_value], + Arel::Nodes::Grouping.new(Arel::Nodes::SqlLiteral.new(ratio.to_s)) + ).as("prorated_value") + ], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT sum(events.prorated_value) + FROM events + SQL + + connection.select_value(sql) + end + end + + def grouped_prorated_sum(period_duration:, persisted_duration: nil) + groups, group_names = grouped_arel_columns + + ratio = if persisted_duration + persisted_duration.fdiv(period_duration) + else + duration_ratio_sql("events_enriched.timestamp", to_datetime, period_duration, timezone) + end + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: groups + [ + Arel::Nodes::InfixOperation.new( + "*", + arel_table[:decimal_value], + Arel::Nodes::Grouping.new(Arel::Nodes::SqlLiteral.new(ratio.to_s)) + ).as("prorated_value") + ], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + #{group_names}, + sum(events.prorated_value) + FROM events + GROUP BY #{group_names} + SQL + + prepare_grouped_result(connection.select_all(sql).rows) + end + end + + def sum_date_breakdown + date_field = date_in_customer_timezone_sql("events_enriched.timestamp", timezone) + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + ctes_sql = events_cte_queries( + select: [ + Arel::Nodes::NamedFunction.new( + "toDate", + [Arel::Nodes::SqlLiteral.new(date_field)] + ).as("day"), + arel_table[:decimal_value].as("property") + ], + deduplicated_columns: %w[decimal_value] + ) + + sql = with_ctes(ctes_sql, <<-SQL) + SELECT + events.day, + sum(events.property) AS day_sum + FROM events + GROUP BY events.day + ORDER BY events.day asc + SQL + + connection.select_all(Arel.sql(sql)).rows.map do |row| + {date: row.first.to_date, value: row.last} + end + end + end + + def weighted_sum(initial_value: 0) + result = Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::WeightedSumQuery.new(store: self) + + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.query), + { + from_datetime:, + to_datetime: to_datetime.ceil, + decimal_scale: DECIMAL_SCALE, + initial_value: initial_value || 0 + } + ] + ) + + connection.select_one(sql) + end + + BigDecimal(result["aggregation"].presence || 0) + end + + def grouped_weighted_sum(columns = grouped_by, initial_value: 0, initial_values: []) + duplicated_weighted_sum_store = dup + duplicated_weighted_sum_store.grouped_by = columns + + baseline_initial_values = if initial_values.present? + initial_values + elsif initial_value.to_d.nonzero? + [{groups: {}, value: initial_value}] + else + [] + end + + formatted_initial_values = duplicated_weighted_sum_store.formatted_weighted_sum_initial_values(baseline_initial_values) + return [] if formatted_initial_values.empty? + + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Clickhouse::WeightedSumQuery.new(store: duplicated_weighted_sum_store) + + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_query(initial_values: formatted_initial_values)), + { + from_datetime:, + to_datetime: to_datetime.ceil, + decimal_scale: DECIMAL_SCALE + } + ] + ) + + prepare_grouped_result(connection.select_all(sql).rows, decimal: true, columns: columns) + end + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def weighted_sum_breakdown(initial_value: 0) + Events::Stores::Utils::ClickhouseConnection.connection_with_retry do |connection| + query = Events::Stores::Clickhouse::WeightedSumQuery.new(store: self) + + rows = connection.select_all( + ActiveRecord::Base.sanitize_sql_for_conditions( + [ + sanitize_colon(query.breakdown_query), + { + from_datetime:, + to_datetime: to_datetime.ceil, + decimal_scale: DECIMAL_SCALE, + initial_value: initial_value || 0 + } + ] + ) + ).rows + # `date_diff` actually returns an `Int64` and ActiveRecord transform that into a `String`. If we cast the + # result in a `Int32`, then we get the result as `Integer`: + # ```ruby + # lago-api(staging)> Clickhouse::BaseRecord.connection.select_one("SELECT 1::Int64") + # => {"CAST('1', 'Int64')" => "1"} + # lago-api(staging)> Clickhouse::BaseRecord.connection.select_one("SELECT 1::Int32") + # => {"CAST('1', 'Int32')" => 1} + # ``` + # To keep consistency with the PG implementation, we call `#to_i` on the value. + rows.map do |(timestamp, difference, cumul, second_duration, period_ratio)| + [timestamp, difference, cumul, second_duration.to_i, period_ratio] + end + end + end + + def with_timestamp_boundaries(query, from_datetime, to_datetime) + query = query.where(arel_table[:timestamp].gteq(from_datetime)) if from_datetime + query = query.where(arel_table[:timestamp].lteq(to_datetime)) if to_datetime + query + end + + def filters_scope(scope) + matching_filters.each do |key, values| + scope = scope.where("events_enriched.properties[?] IN (?)", key.to_s, values) + end + + conditions = ignored_filters.filter_map do |filters| + next if filters.empty? + + clause = filters.filter_map do |key, values| + next if values.empty? + + ActiveRecord::Base.sanitize_sql_for_conditions( + ["(coalesce(events_enriched.properties[?], '') IN (?))", key.to_s, values.map(&:to_s)] + ) + end.join(" AND ") + clause.presence + end + sql = conditions.map { "(#{it})" }.join(" OR ") + scope = scope.where.not(sql) if sql.present? + + scope + end + + def arel_filters_scope(scope) + matching_filters.each do |key, values| + scope = scope.where( + Arel::Nodes::SqlLiteral.new(sanitized_property_name(key.to_s)).in(values.map(&:to_s)) + ) + end + + conditions = ignored_filters.filter_map do |filters| + next if filters.empty? + + clause = filters.filter_map do |key, values| + next if values.empty? + + ActiveRecord::Base.sanitize_sql_for_conditions( + ["(coalesce(events_enriched.properties[?], '') IN (?))", key.to_s, values.map(&:to_s)] + ) + end.join(" AND ") + clause.presence + end + sql = conditions.map { "(#{it})" }.join(" OR ") + scope = scope.where(Arel::Nodes::Not.new(Arel::Nodes::SqlLiteral.new(sql))) if conditions.present? + + scope + end + + def apply_grouped_by_values(scope) + grouped_by_values.each do |grouped_by, grouped_by_value| + scope = if grouped_by_value.present? + scope.where("events_enriched.properties[?] = ?", grouped_by, grouped_by_value) + else + scope.where("COALESCE(events_enriched.properties[?], '') = ''", grouped_by) + end + end + + scope + end + + def apply_arel_grouped_by_values(query) + grouped_by_values.each do |grouped_by, grouped_by_value| + query = if grouped_by_value.present? + query.where(Arel::Nodes::SqlLiteral.new(sanitized_property_name(grouped_by)).eq(grouped_by_value)) + else + query.where( + Arel::Nodes::NamedFunction.new( + "COALESCE", + [ + Arel::Nodes::SqlLiteral.new(sanitized_property_name(grouped_by)), + Arel::Nodes::SqlLiteral.new("''") + ] + ).eq(Arel::Nodes::SqlLiteral.new("''")) + ) + end + end + + query + end + + def sanitized_property_name(property = aggregation_property) + ActiveRecord::Base.sanitize_sql_for_conditions( + ["events_enriched.properties[?]", property] + ) + end + + # NOTE: returns the values for each groups + # The result format will be an array of hash with the format: + # [{ groups: { 'cloud' => 'aws', 'region' => 'us_east_1' }, value: 12.9 }, ...] + def prepare_grouped_result(rows, timestamp: false, decimal: false, columns: grouped_by) + rows.map do |row| + last_group = timestamp ? -2 : -1 + groups = row.flatten[...last_group].map(&:presence) + + result = { + groups: columns.each_with_object({}).with_index { |(g, r), i| r.merge!(g => groups[i]) }, + value: decimal ? BigDecimal(row.last.presence || 0) : row.last + } + + result[:timestamp] = row[-2] if timestamp + + result + end + end + + def arel_table + @arel_table ||= ::Clickhouse::EventsEnriched.arel_table + end + + def grouped_arel_columns(columns = grouped_by) + names = Array.new(columns.count) { |i| "g_#{i}" } + [ + columns.map.with_index { |col, i| Arel::Nodes::SqlLiteral.new(sanitized_property_name(col)).as("g_#{i}") }, + names.join(", ") + ] + end + + def grouped_by_columns(values) + grouped_by.map { |g| quote(values[g] || "") } + end + + delegate :count, to: :grouped_by, prefix: true + + def operation_type_sql + "events_enriched.sorted_properties['operation_type']" + end + + def formatted_weighted_sum_initial_values(initial_values) + formatted_initial_values = grouped_count.map do |group| + value = 0 + previous_group = initial_values.find { |g| g[:groups] == group[:groups] } + value = previous_group[:value] if previous_group + {groups: group[:groups], value:} + end + + initial_values.each do |initial_value| + next if formatted_initial_values.find { |g| g[:groups] == initial_value[:groups] } + + formatted_initial_values << initial_value + end + + formatted_initial_values + end + end + end +end diff --git a/app/services/events/stores/postgres/unique_count_query.rb b/app/services/events/stores/postgres/unique_count_query.rb new file mode 100644 index 0000000..ec51df6 --- /dev/null +++ b/app/services/events/stores/postgres/unique_count_query.rb @@ -0,0 +1,443 @@ +# frozen_string_literal: true + +module Events + module Stores + module Postgres + class UniqueCountQuery + def initialize(store:) + @store = store + end + + def query + # NOTE: First sum calculates all operation values for a specific property + # (for instance 2 relevant additions with 1 relevant removal [0, 1, 0, -1, 1] returns 1) + # The next sum combines all properties into a single result + <<-SQL + #{events_cte_sql}, + event_values AS ( + SELECT + property, + SUM(adjusted_value) AS sum_adjusted_value + FROM ( + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql} AS adjusted_value + FROM events_data + ORDER BY timestamp ASC + ) adjusted_event_values + GROUP BY property + ) + + SELECT COALESCE(SUM(sum_adjusted_value), 0) AS aggregation FROM event_values + SQL + end + + def prorated_query + <<-SQL + #{events_cte_sql}, + -- ignore if for remove event there is a following add event the same day that nullifies this one + same_day_ignored AS ( + SELECT + property, + operation_type, + timestamp, + #{ignore_remove_events_sql} AS is_ignored + FROM events_data as e + ), + -- Check if the operation type is the same as previous, so it nullifies this one + event_values AS ( + SELECT + property, + operation_type, + timestamp + FROM ( + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql} AS adjusted_value + FROM same_day_ignored + WHERE is_ignored = false + ORDER BY timestamp ASC + ) adjusted_event_values + WHERE adjusted_value != 0 -- adjusted_value = 0 does not impact the total + GROUP BY property, operation_type, timestamp + ) + + SELECT COALESCE(SUM(period_ratio), 0) as aggregation + FROM ( + SELECT (#{period_ratio_sql}) AS period_ratio + FROM event_values + ) cumulated_ratios + SQL + end + + def grouped_query + <<-SQL + #{grouped_events_cte_sql}, + + event_values AS ( + SELECT + #{group_names.join(", ")}, + property, + SUM(adjusted_value) AS sum_adjusted_value + FROM ( + SELECT + timestamp, + property, + operation_type, + #{group_names.join(", ")}, + #{grouped_operation_value_sql} AS adjusted_value + FROM events_data + ORDER BY timestamp ASC + ) adjusted_event_values + GROUP BY #{group_names.join(", ")}, property + ) + + SELECT + #{group_names.join(", ")}, + COALESCE(SUM(sum_adjusted_value), 0) as aggregation + FROM event_values + GROUP BY #{group_names.join(", ")} + SQL + end + + def grouped_prorated_query + <<-SQL + #{grouped_events_cte_sql}, + -- ignore if for remove event there is a following add event the same day (for grouped events) that nullifies this one + same_day_ignored AS ( + SELECT + #{group_names.join(", ")}, + property, + operation_type, + timestamp, + #{ignore_remove_grouped_events_sql} AS is_ignored + FROM ( + SELECT + #{group_names.join(", ")}, + property, + operation_type, + timestamp + FROM events_data + ORDER BY timestamp ASC + ) as e + ), + -- Check if the operation type is the same as previous, so it nullifies this one + event_values AS ( + SELECT + #{group_names.join(", ")}, + property, + operation_type, + timestamp + FROM ( + SELECT + timestamp, + property, + operation_type, + #{group_names.join(", ")}, + #{grouped_operation_value_sql} AS adjusted_value + FROM same_day_ignored + WHERE is_ignored = false + ORDER BY timestamp ASC + ) adjusted_event_values + WHERE adjusted_value != 0 -- adjusted_value = 0 does not impact the total + GROUP BY #{group_names.join(", ")}, property, operation_type, timestamp + ORDER BY timestamp ASC + ) + + SELECT + #{group_names.join(", ")}, + COALESCE(SUM(period_ratio), 0) as aggregation + FROM ( + SELECT + (#{grouped_period_ratio_sql}) AS period_ratio, + #{group_names.join(", ")} + FROM event_values + ) cumulated_ratios + GROUP BY #{group_names.join(", ")} + SQL + end + + # NOTE: Not used in production, only for debug purpose to check the computed values before aggregation + # Returns an array of event's timestamp, property, operation type and operation value + # Example: + # [ + # ["2023-03-16T00:00:00.000Z", "001", "add", 1], + # ["2023-03-17T00:00:00.000Z", "001", "add", 0], + # ["2023-03-17T10:00:00.000Z", "002", "remove", 0], + # ["2023-03-18T00:00:00.000Z", "001", "remove", -1], + # ["2023-03-19T00:00:00.000Z", "002", "add", 1] + # ] + def breakdown_query + <<-SQL + #{events_cte_sql} + + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql} + FROM events_data + ORDER BY timestamp ASC + SQL + end + + def prorated_breakdown_query(with_remove: false) + <<-SQL + #{events_cte_sql}, + -- ignore if for remove event there is a following add event the same day that nullifies this one + same_day_ignored AS ( + SELECT + property, + operation_type, + timestamp, + #{ignore_remove_events_sql} AS is_ignored + FROM events_data as e + ), + -- Check if the operation type is repeated, so it nullifies this one at the same day + event_values AS ( + SELECT + property, + operation_type, + timestamp + FROM ( + SELECT + timestamp, + property, + operation_type, + #{operation_value_sql} AS adjusted_value + FROM same_day_ignored + WHERE is_ignored = false + ORDER BY timestamp ASC + ) adjusted_event_values + WHERE adjusted_value != 0 -- adjusted_value = 0 does not impact the total + GROUP BY property, timestamp, operation_type + ) + + SELECT + prorated_value, + timestamp, + property, + operation_type + FROM ( + SELECT + (#{period_ratio_sql}) AS prorated_value, + timestamp, + property, + operation_type + FROM event_values + ) prorated_breakdown + #{"WHERE prorated_value != 0" unless with_remove} + ORDER BY timestamp ASC, property ASC + SQL + end + + private + + attr_reader :store + + delegate :events, :charges_duration, :sanitized_property_name, :operation_type_sql, to: :store + + def events_cte_sql + # NOTE: Common table expression returning event's timestamp, property name and operation type. + <<-SQL + WITH events_data AS (#{ + events(ordered: true) + .select( + "timestamp, \ + #{sanitized_property_name} AS property, \ + #{operation_type_sql} AS operation_type" + ).to_sql + }) + SQL + end + + def grouped_events_cte_sql + groups = store.grouped_by.map.with_index do |group, index| + "#{sanitized_property_name(group)} AS g_#{index}" + end + + <<-SQL + WITH events_data AS (#{ + events(ordered: true) + .select( + "#{groups.join(", ")}, \ + timestamp, \ + #{sanitized_property_name} AS property, \ + #{operation_type_sql} AS operation_type" + ).to_sql + }) + SQL + end + + def operation_value_sql + # NOTE: Returns 1 for relevant addition, -1 for relevant removal + # If property already added, another addition returns 0 ; it returns 1 otherwise + # If property already removed or not yet present, another removal returns 0 ; it returns -1 otherwise + <<-SQL + CASE + WHEN LAG(operation_type, 1, 'remove') OVER (PARTITION BY property ORDER BY timestamp) = operation_type + THEN 0 -- NOTE: if the first ever operation is a remove, it's not relevant; note that it's "remove" if not found, so we ignore "empty" remove + ELSE CASE WHEN operation_type = 'add' THEN 1 ELSE -1 END + END + SQL + end + + def grouped_operation_value_sql + # NOTE: Returns 1 for relevant addition, -1 for relevant removal + # If property already added, another addition returns 0 ; it returns 1 otherwise + # If property already removed or not yet present, another removal returns 0 ; it returns -1 otherwise + <<-SQL + CASE + WHEN LAG(operation_type, 1, 'remove') OVER (PARTITION BY #{group_names.join(", ")}, property ORDER BY timestamp) = operation_type + THEN 0 -- NOTE: if the first ever operation is a remove, it's not relevant; note that it's "remove" if not found, so we ignore "empty" remove + ELSE CASE WHEN operation_type = 'add' THEN 1 ELSE -1 END + END + SQL + end + + def period_ratio_sql + <<-SQL + CASE WHEN operation_type = 'add' + THEN + -- NOTE: duration in seconds between current event and next one - using end of period as final boundaries + ( + ( + DATE(( + -- NOTE: if following event is older than the start of the period, we use the start of the period as the reference + CASE WHEN (LEAD(timestamp, 1, :to_datetime) OVER (PARTITION BY property ORDER BY timestamp)) < :from_datetime + THEN :from_datetime + ELSE LEAD(timestamp, 1, :to_datetime) OVER (PARTITION BY property ORDER BY timestamp) + interval '1' day + END + )::timestamptz AT TIME ZONE :timezone) + - DATE(( + -- NOTE: if events is older than the start of the period, we use the start of the period as the reference + CASE WHEN timestamp < :from_datetime THEN :from_datetime ELSE timestamp END + )::timestamptz AT TIME ZONE :timezone) + )::numeric + ) + / + -- NOTE: full duration of the period + #{charges_duration || 1}::numeric + ELSE + 0 -- NOTE: duration was null so usage is null + END + SQL + end + + def grouped_period_ratio_sql + <<-SQL + CASE WHEN operation_type = 'add' + THEN + -- NOTE: duration in seconds between current event and next one - using end of period as final boundaries + ( + ( + DATE(( + -- NOTE: if following event is older than the start of the period, we use the start of the period as the reference + CASE WHEN (LEAD(timestamp, 1, :to_datetime) OVER (PARTITION BY #{group_names.join(", ")}, property ORDER BY timestamp)) < :from_datetime + THEN :from_datetime + ELSE LEAD(timestamp, 1, :to_datetime) OVER (PARTITION BY #{group_names.join(", ")}, property ORDER BY timestamp) + END + )::timestamptz AT TIME ZONE :timezone) + - DATE(( + -- NOTE: if events is older than the start of the period, we use the start of the period as the reference + CASE WHEN timestamp < :from_datetime THEN :from_datetime ELSE timestamp END + )::timestamptz AT TIME ZONE :timezone) + )::numeric + + 1 + ) + / + -- NOTE: full duration of the period + #{charges_duration || 1}::numeric + ELSE + 0 -- NOTE: duration was null so usage is null + END + SQL + end + + def ignore_remove_events_sql + <<-SQL + CASE + -- we do not ignore ADDs, if they are duplicated they'll be cleaned by adjusted value calculation + WHEN operation_type = 'add' THEN false + -- if there is a next event the same day is the opposite operation type, this should be ignored + WHEN #{existing_event_opposite_operation_type_sql} THEN true + ELSE false + END + SQL + end + + def ignore_remove_grouped_events_sql + <<-SQL + CASE + -- we do not ignore ADDs, if they are duplicated they'll be cleaned by adjusted value calculation + WHEN operation_type = 'add' THEN false + -- if the next event the same day is the opposite operation type, it should be ignored + WHEN #{existing_grouped_event_opposite_operation_type_sql} THEN true + ELSE false + END + SQL + end + + # IS_IGNORED logic for prorated aggregation desired behaviour is: + # 27th property add + # 27th property remove + # 27th property add + + # 28th property add (operation is 0, so it's already filtered by previous query) + # 28th property remove + # --- end of unit 0, prorated 2 days + # 30th property add + # --the result of 30 is 1 -> prorated 1 day + # for this we want to have only 2 events: 27th-28th and 30th to 30th + + # summary table: + # 27th property add not_ignore + # 27th property remove ignore + # 27th property add ignore + # 28th property remove not_ignore + # 30th property add not_ignore + # So the rule is: + # -- for the same day, we look at next event. if it's opposite of current, current can be ignored + # -- we look at previous not ignored event. if the operation type matches. we can ignore current + def existing_event_opposite_operation_type_sql + <<-SQL + ( + SELECT + 1 + FROM events_data next_event + WHERE next_event.property = e.property + AND DATE((next_event.timestamp)::timestamptz AT TIME ZONE :timezone) = DATE((e.timestamp)::timestamptz AT TIME ZONE :timezone) + AND next_event.operation_type <> e.operation_type + AND next_event.timestamp > e.timestamp + LIMIT 1 + ) = 1 + SQL + end + + def existing_grouped_event_opposite_operation_type_sql + <<-SQL + ( + SELECT + 1 + FROM events_data next_event + WHERE next_event.property = e.property + AND #{group_names.map { |name| "next_event.#{name} = e.#{name}" }.join(" AND ")} + AND DATE((next_event.timestamp)::timestamptz AT TIME ZONE :timezone) = DATE((e.timestamp)::timestamptz AT TIME ZONE :timezone) + AND next_event.operation_type <> e.operation_type + AND next_event.timestamp > e.timestamp + LIMIT 1 + ) = 1 + SQL + end + + def group_names + @group_names ||= store.grouped_by.map.with_index { |_, index| "g_#{index}" } + end + end + end + end +end diff --git a/app/services/events/stores/postgres/weighted_sum_query.rb b/app/services/events/stores/postgres/weighted_sum_query.rb new file mode 100644 index 0000000..9168197 --- /dev/null +++ b/app/services/events/stores/postgres/weighted_sum_query.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +module Events + module Stores + module Postgres + class WeightedSumQuery + def initialize(store:) + @store = store + end + + def query + <<-SQL + #{events_cte_sql} + + SELECT SUM(period_ratio) as aggregation + FROM ( + SELECT (#{period_ratio_sql}) AS period_ratio + FROM events_data + ) cumulated_ratios + SQL + end + + def grouped_query(initial_values:) + <<-SQL + #{grouped_events_cte_sql(initial_values)} + + SELECT + #{group_names}, + SUM(period_ratio) as aggregation + FROM ( + SELECT + #{group_names}, + (#{grouped_period_ratio_sql}) AS period_ratio + FROM events_data + ) cumulated_ratios + GROUP BY #{group_names} + SQL + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def breakdown_query + <<-SQL + #{events_cte_sql} + + SELECT + timestamp, + difference, + SUM(difference) OVER (ORDER BY timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cumul, + EXTRACT(epoch FROM lead(timestamp, 1, :to_datetime) OVER (ORDER BY timestamp) - timestamp) AS second_duration, + (#{period_ratio_sql}) AS period_ratio + FROM events_data + ORDER BY timestamp ASC + SQL + end + + private + + attr_reader :store + + delegate :events, :charges_duration, :sanitized_property_name, :created_at_ordering_column, to: :store + + def events_cte_sql + <<-SQL + WITH events_data AS ( + (#{initial_value_sql}) + UNION + (#{ + events(ordered: true) + .select("timestamp, (#{sanitized_property_name})::numeric AS difference, #{created_at_ordering_column}") + .to_sql + }) + UNION + (#{end_of_period_value_sql}) + ) + SQL + end + + def initial_value_sql + <<-SQL + SELECT * + FROM ( + VALUES (timestamp without time zone :from_datetime, :initial_value, timestamp without time zone :from_datetime) + ) AS t(timestamp, difference, created_at) + SQL + end + + def end_of_period_value_sql + <<-SQL + SELECT * + FROM ( + VALUES (timestamp without time zone :to_datetime, 0, timestamp without time zone :to_datetime) + ) AS t(timestamp, difference, created_at) + SQL + end + + def period_ratio_sql + <<-SQL + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + CASE WHEN EXTRACT(EPOCH FROM LEAD(timestamp, 1, :to_datetime) OVER (ORDER BY timestamp) - timestamp) = 0 + THEN + 0 -- NOTE: duration was null so usage is null + ELSE + -- NOTE: cumulative sum from previous events in the period + (SUM(difference) OVER (ORDER BY timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) + * + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + EXTRACT(EPOCH FROM LEAD(timestamp, 1, :to_datetime) OVER (ORDER BY timestamp) - timestamp) + / + -- NOTE: full duration of the period + #{charges_duration.days.to_i} + END + SQL + end + + def grouped_events_cte_sql(initial_values) + groups = store.grouped_by.map.with_index do |group, index| + "#{sanitized_property_name(group)} AS g_#{index}" + end + + <<-SQL + WITH events_data AS ( + (#{grouped_initial_value_sql(initial_values)}) + UNION + (#{ + events(ordered: true) + .select("#{groups.join(", ")}, timestamp, (#{sanitized_property_name})::numeric AS difference, #{created_at_ordering_column}") + .to_sql + }) + UNION + (#{grouped_end_of_period_value_sql(initial_values)}) + ) + SQL + end + + def grouped_initial_value_sql(initial_values) + values = initial_values.map do |initial_value| + groups = store.grouped_by.map do |g| + if initial_value[:groups][g] + "'#{ActiveRecord::Base.sanitize_sql_for_conditions(initial_value[:groups][g])}'" + else + "NULL" + end + end + + [ + groups, + "timestamp without time zone :from_datetime", + initial_value[:value], + "timestamp without time zone :from_datetime" + ].flatten.join(", ") + end + + <<-SQL + SELECT * + FROM ( + VALUES #{values.map { "(#{it})" }.join(", ")} + ) AS t(#{group_names}, timestamp, difference, created_at) + SQL + end + + def grouped_end_of_period_value_sql(initial_values) + values = initial_values.map do |initial_value| + groups = store.grouped_by.map do |g| + if initial_value[:groups][g] + "'#{ActiveRecord::Base.sanitize_sql_for_conditions(initial_value[:groups][g])}'" + else + "NULL" + end + end + + [ + groups, + "timestamp without time zone :from_datetime", + 0, + "timestamp without time zone :from_datetime" + ].flatten.join(", ") + end + + <<-SQL + SELECT * + FROM ( + VALUES #{values.map { "(#{it})" }.join(", ")} + ) AS t(#{group_names}, timestamp, difference, created_at) + SQL + end + + def grouped_period_ratio_sql + <<-SQL + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + CASE WHEN EXTRACT(EPOCH FROM LEAD(timestamp, 1, :to_datetime) OVER (PARTITION BY #{group_names} ORDER BY timestamp) - timestamp) = 0 + THEN + 0 -- NOTE: duration was null so usage is null + ELSE + -- NOTE: cumulative sum from previous events in the period + (SUM(difference) OVER (PARTITION BY #{group_names} ORDER BY timestamp ASC ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW)) + * + -- NOTE: duration in seconds between current event and next one - or end of period if next event is null + EXTRACT(EPOCH FROM LEAD(timestamp, 1, :to_datetime) OVER (PARTITION BY #{group_names} ORDER BY timestamp) - timestamp) + / + -- NOTE: full duration of the period + #{charges_duration.days.to_i} + END + SQL + end + + def group_names + @group_names ||= store.grouped_by.map.with_index { |_, index| "g_#{index}" }.join(", ") + end + end + end + end +end diff --git a/app/services/events/stores/postgres_store.rb b/app/services/events/stores/postgres_store.rb new file mode 100644 index 0000000..88f52a9 --- /dev/null +++ b/app/services/events/stores/postgres_store.rb @@ -0,0 +1,484 @@ +# frozen_string_literal: true + +module Events + module Stores + class PostgresStore < BaseStore + def events(force_from: false, ordered: false) + scope = Event.where(external_subscription_id: subscription.external_id) + .where(organization_id: subscription.organization.id) + .where(code:) + + scope = scope.order(timestamp: :asc) if ordered + + scope = scope.from_datetime(from_datetime) if force_from || use_from_boundary + scope = scope.to_datetime(applicable_to_datetime) if applicable_to_datetime + + if numeric_property + scope = scope.where(presence_condition) + .where(numeric_condition) + end + + scope = apply_grouped_by_values(scope) if grouped_by_values? + filters_scope(scope) + end + + def distinct_codes + Event.where(external_subscription_id: subscription.external_id) + .where(organization_id: subscription.organization.id) + .from_datetime(from_datetime) + .to_datetime(applicable_to_datetime) + .pluck("DISTINCT(code)") + end + + def distinct_charges_and_filters + EnrichedEvent.where(organization_id: subscription.organization_id) + .where(subscription_id: subscription.id) + .where(timestamp: from_datetime..to_datetime) + .distinct + .pluck(:charge_id, :charge_filter_id) + end + + def events_values(limit: nil, force_from: false, exclude_event: false) + field_name = sanitized_property_name + field_name = "(#{field_name})::numeric" if numeric_property + + scope = events(force_from:, ordered: true) + scope = scope.where.not(transaction_id: filters[:event].transaction_id) if exclude_event + scope = scope.limit(limit) if limit + + scope.pluck(Arel.sql(field_name)) + end + + def last_event + events(ordered: true).last + end + + def grouped_last_event + groups = sanitized_grouped_by + + sql = events + .order(Arel.sql((groups + ["events.timestamp DESC, created_at DESC"]).join(", "))) + .select( + [ + "DISTINCT ON (#{groups.join(", ")}) #{groups.join(", ")}", + "events.timestamp", + "(#{sanitized_property_name})::numeric AS value" + ].join(", ") + ) + .to_sql + + prepare_grouped_result(select_all(sql).rows, timestamp: true) + end + + def prorated_events_values(total_duration) + ratio_sql = duration_ratio_sql("events.timestamp", to_datetime, total_duration) + + events(ordered: true).pluck(Arel.sql("(#{sanitized_property_name})::numeric * (#{ratio_sql})::numeric")) + end + + def grouped_count(columns = grouped_by) + results = events + .group(columns.map { sanitized_property_name(it) }) + .count + .map { |group, value| [group, value].flatten } + + prepare_grouped_result(results, columns: columns) + end + + # NOTE: check if an event created before the current on belongs to an active (as in present and not removed) + # unique property + def active_unique_property?(event) + previous_event = events.where.not(id: event.id) + .where("events.properties @> ?", {aggregation_property => event.properties[aggregation_property]}.to_json) + .where("events.timestamp < ?", event.timestamp) + .order(timestamp: :desc) + .first + + previous_event && ( + previous_event.properties["operation_type"].nil? || + previous_event.properties["operation_type"] == "add" + ) + end + + def unique_count + query = Events::Stores::Postgres::UniqueCountQuery.new(store: self) + sql = sanitize_sql_for_conditions([query.query]) + result = select_one(sql) + + result["aggregation"] + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def unique_count_breakdown + query = Events::Stores::Postgres::UniqueCountQuery.new(store: self) + select_all( + sanitize_sql_for_conditions([query.breakdown_query]) + ).rows + end + + def prorated_unique_count + query = Events::Stores::Postgres::UniqueCountQuery.new(store: self) + sql = sanitize_sql_for_conditions( + [ + sanitize_colon(query.prorated_query), + { + from_datetime:, + to_datetime:, + timezone: customer.applicable_timezone + } + ] + ) + result = select_one(sql) + + result["aggregation"] + end + + def prorated_unique_count_breakdown(with_remove: false) + query = Events::Stores::Postgres::UniqueCountQuery.new(store: self) + sql = sanitize_sql_for_conditions( + [ + sanitize_colon(query.prorated_breakdown_query(with_remove:)), + { + from_datetime:, + to_datetime:, + timezone: customer.applicable_timezone + } + ] + ) + select_all(sql).to_a + end + + def grouped_unique_count(columns = grouped_by) + # NOTE: Important to use a dup to avoid mutate the current object (self) to associate the columns + duplicated_unique_count_store = dup + duplicated_unique_count_store.grouped_by = columns + + query = Events::Stores::Postgres::UniqueCountQuery.new(store: duplicated_unique_count_store) + + sql = sanitize_sql_for_conditions( + [query.grouped_query] + ) + + prepare_grouped_result(select_all(sql).rows, columns: columns) + end + + def grouped_prorated_unique_count + query = Events::Stores::Postgres::UniqueCountQuery.new(store: self) + sql = sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_prorated_query), + { + from_datetime:, + to_datetime:, + timezone: customer.applicable_timezone + } + ] + ) + prepare_grouped_result(select_all(sql).rows) + end + + def max + events.maximum("(#{sanitized_property_name})::numeric") + end + + def grouped_max(columns = grouped_by) + results = events + .group(columns.map { sanitized_property_name(it) }) + .maximum("(#{sanitized_property_name})::numeric") + .map { |group, value| [group, value].flatten } + + prepare_grouped_result(results, columns: columns) + end + + def last + events.order(timestamp: :desc, created_at: :desc).first&.properties&.[](aggregation_property) + end + + def grouped_last(columns = grouped_by) + sanitized_columns = columns.map { sanitized_property_name(it) } + distinct_on_columns = grouped_by.present? ? grouped_by.map { sanitized_property_name(it) } : [] + + sql = if distinct_on_columns.empty? + events + .order(Arel.sql("events.timestamp DESC, created_at DESC")) + .select("#{sanitized_columns.join(", ")}, (#{sanitized_property_name})::numeric AS value") + .limit(1) + .to_sql + else + events + .order(Arel.sql((distinct_on_columns + ["events.timestamp DESC, created_at DESC"]).join(", "))) + .select( + "DISTINCT ON (#{distinct_on_columns.join(", ")}) #{sanitized_columns.join(", ")}, (#{sanitized_property_name})::numeric AS value" + ) + .to_sql + end + + prepare_grouped_result(select_all(sql).rows, columns: columns) + end + + def sum_precise_total_amount_cents + events.sum(:precise_total_amount_cents) + end + + def grouped_sum_precise_total_amount_cents + results = events + .group(sanitized_grouped_by) + .sum(:precise_total_amount_cents) + .map { |group, value| [group, value].flatten } + + prepare_grouped_result(results) + end + + def sum + events.sum("(#{sanitized_property_name})::numeric") + end + + def grouped_sum(columns = grouped_by) + results = events + .group(columns.map { sanitized_property_name(it) }) + .sum("(#{sanitized_property_name})::numeric") + .map { |group, value| [group, value].flatten } + + prepare_grouped_result(results, columns: columns) + end + + def prorated_sum(period_duration:, persisted_duration: nil) + ratio = if persisted_duration + persisted_duration.fdiv(period_duration) + else + duration_ratio_sql("events.timestamp", to_datetime, period_duration) + end + + sql = <<-SQL + SUM( + (#{sanitized_property_name})::numeric * (#{ratio})::numeric + ) AS sum_result + SQL + + connection.execute(Arel.sql(events.select(sql).to_sql)).first["sum_result"] + end + + def grouped_prorated_sum(period_duration:, persisted_duration: nil) + ratio = if persisted_duration + persisted_duration.fdiv(period_duration) + else + duration_ratio_sql("events.timestamp", to_datetime, period_duration) + end + + sum_sql = <<-SQL + #{sanitized_grouped_by.join(", ")}, + SUM( + (#{sanitized_property_name})::numeric * (#{ratio})::numeric + ) AS sum_result + SQL + + sql = events + .group(sanitized_grouped_by) + .select(sum_sql) + .to_sql + + prepare_grouped_result(select_all(sql).rows) + end + + def sum_date_breakdown + date_field = ::Utils::Timezone.date_in_customer_timezone_sql(customer, "events.timestamp") + + events.group(Arel.sql("DATE(#{date_field})")) + .order(Arel.sql("DATE(#{date_field}) ASC")) + .pluck(Arel.sql("DATE(#{date_field}) AS date, SUM((#{sanitized_property_name})::numeric)")) + .map do |row| + {date: row.first.to_date, value: row.last} + end + end + + def weighted_sum(initial_value: 0) + query = Events::Stores::Postgres::WeightedSumQuery.new(store: self) + + sql = sanitize_sql_for_conditions( + [ + sanitize_colon(query.query), + { + from_datetime:, + to_datetime: to_datetime.ceil, + initial_value: initial_value || 0 + } + ] + ) + + result = select_one(sql) + result["aggregation"] + end + + def grouped_weighted_sum(columns = grouped_by, initial_value: 0, initial_values: []) + # NOTE: Important to use a dup to avoid mutate the current object (self) to associate the columns + duplicated_weighted_sum_store = dup + duplicated_weighted_sum_store.grouped_by = columns + + baseline_initial_values = if initial_values.present? + initial_values + elsif initial_value.to_d.nonzero? + [{groups: {}, value: initial_value}] + else + [] + end + + query = Events::Stores::Postgres::WeightedSumQuery.new(store: duplicated_weighted_sum_store) + + formatted_initial_values = duplicated_weighted_sum_store.formatted_weighted_sum_initial_values(baseline_initial_values) + return [] if formatted_initial_values.empty? + + sql = sanitize_sql_for_conditions( + [ + sanitize_colon(query.grouped_query(initial_values: formatted_initial_values)), + { + from_datetime:, + to_datetime: to_datetime.ceil + } + ] + ) + + prepare_grouped_result(select_all(sql).rows, columns: columns) + end + + def formatted_weighted_sum_initial_values(initial_values) + # NOTE: build the list of initial values for each groups + # from the events in the period + formatted_initial_values = grouped_count.map do |group| + value = 0 + previous_group = initial_values.find { |g| g[:groups] == group[:groups] } + value = previous_group[:value] if previous_group + {groups: group[:groups], value:} + end + + # NOTE: add the initial values for groups that are not in the events + initial_values.each do |initial_value| + next if formatted_initial_values.find { |g| g[:groups] == initial_value[:groups] } + + formatted_initial_values << initial_value + end + + formatted_initial_values + end + + # NOTE: not used in production, only for debug purpose to check the computed values before aggregation + def weighted_sum_breakdown(initial_value: 0) + query = Events::Stores::Postgres::WeightedSumQuery.new(store: self) + select_all( + sanitize_sql_for_conditions( + [ + sanitize_colon(query.breakdown_query), + { + from_datetime:, + to_datetime: to_datetime.ceil, + initial_value: initial_value || 0 + } + ] + ) + ).rows + end + + def filters_scope(scope) + matching_filters.each do |key, values| + scope = scope.where( + "events.properties ->> ? IN (?)", + key.to_s, + values.map(&:to_s) + ) + end + + conditions = ignored_filters.filter_map do |filters| + next if filters.empty? + + clause = filters.filter_map do |key, values| + next if values.empty? + + sanitize_sql_for_conditions( + ["(coalesce(events.properties ->> ?, '') IN (?))", key.to_s, values.map(&:to_s)] + ) + end.join(" AND ") + clause.presence + end + sql = conditions.map { "(#{it})" }.join(" OR ") + scope = scope.where.not(sql) if sql.present? + + scope + end + + def apply_grouped_by_values(scope) + grouped_by_values.each do |grouped_by, grouped_by_value| + scope = if grouped_by_value.present? + scope.where("events.properties @> ?", {grouped_by.to_s => grouped_by_value.to_s}.to_json) + else + scope.where( + sanitize_sql_for_conditions(["COALESCE(events.properties->>?, '') = ''", grouped_by]) + ) + end + end + + scope + end + + def sanitized_property_name(property = aggregation_property) + sanitize_sql_for_conditions( + ["events.properties->>?", property] + ) + end + + def presence_condition + "events.properties::jsonb ? '#{sanitize_sql_for_conditions(aggregation_property)}'" + end + + def numeric_condition + # NOTE: ensure property value is a numeric value + "#{sanitized_property_name} ~ '^-?\\d+(\\.\\d+)?$'" + end + + def sanitized_grouped_by + grouped_by.map { sanitized_property_name(it) } + end + + delegate :connection, to: :Event + + delegate :select_all, to: :connection + delegate :select_one, to: :connection + + delegate :sanitize_sql_for_conditions, to: :"ActiveRecord::Base" + + # NOTE: Compute pro-rata of the duration in days between the datetimes over the duration of the billing period + # Dates are in customer timezone to make sure the duration is good + def duration_ratio_sql(from, to, duration) + from_in_timezone = ::Utils::Timezone.date_in_customer_timezone_sql(customer, from) + to_in_timezone = ::Utils::Timezone.date_in_customer_timezone_sql(customer, to) + + "((DATE(#{to_in_timezone}) - DATE(#{from_in_timezone}))::numeric + 1) / #{duration}::numeric" + end + + # NOTE: returns the values for each groups + # The result format will be an array of hash with the format: + # [{ groups: { 'cloud' => 'aws', 'region' => 'us_east_1' }, value: 12.9 }, ...] + def prepare_grouped_result(rows, timestamp: false, columns: grouped_by) + rows.map do |row| + last_group = timestamp ? -2 : -1 + groups = row[...last_group].map(&:presence) + + result = { + groups: columns.each_with_object({}).with_index { |(g, r), i| r.merge!(g => groups[i]) }, + value: row.last + } + + result[:timestamp] = row[-2] if timestamp + + result + end + end + + def operation_type_sql + "COALESCE(events.properties->>'operation_type', 'add')" + end + + def created_at_ordering_column + "events.created_at" + end + end + end +end diff --git a/app/services/events/stores/store_factory.rb b/app/services/events/stores/store_factory.rb new file mode 100644 index 0000000..fdc91b2 --- /dev/null +++ b/app/services/events/stores/store_factory.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Events + module Stores + class StoreFactory + class << self + def supports_clickhouse? + ENV["LAGO_CLICKHOUSE_ENABLED"].present? + end + end + + def self.store_class(organization:) + event_store = Events::Stores::PostgresStore + + if supports_clickhouse? && organization.clickhouse_events_store? + event_store = Events::Stores::ClickhouseStore + + if organization.feature_flag_enabled?(:enriched_events_aggregation) + event_store = Events::Stores::ClickhouseEnrichedStore + end + end + + event_store + end + + def self.new_instance(organization:, **kwargs) + store_class(organization: organization).new(**kwargs) + end + end + end +end diff --git a/app/services/events/stores/utils/clickhouse_benchmark.rb b/app/services/events/stores/utils/clickhouse_benchmark.rb new file mode 100644 index 0000000..95cfcb4 --- /dev/null +++ b/app/services/events/stores/utils/clickhouse_benchmark.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "benchmark" + +module Events + module Stores + module Utils + # Generic ClickHouse SQL performance benchmarking. + # + # Given a hash of { label => sql_string }, this service runs each query N times in + # randomized order, tags each run via log_comment, then pulls server-side + # metrics (query_duration_ms, memory_usage, read_rows, read_bytes, + # result_rows) from system.query_log. + # Returns medians as a Hash and prints a comparison table. + # + # It's a console-only utility, no specs. + # + # Usage: + # + # Events::Stores::Utils::ClickhouseBenchmark.compare( + # { + # "argmax" => "SELECT ...", + # "two_pass" => "WITH latest AS (...) ..." + # }, + # repetitions: 5, + # cold_run: false + # ) + # + # cold_run: if true, disables the uncompressed cache for tagged queries + # only via SETTINGS use_uncompressed_cache = 0. + class ClickhouseBenchmark + include Events::Stores::Utils::ClickhouseSqlHelpers + + TAG_PREFIX = "ch_bench" + + def self.compare(queries, repetitions: 5, cold_run: false) + new( + queries:, + repetitions:, + cold_run: + ).compare + end + + def initialize(queries:, repetitions:, cold_run:) + @queries = queries + @repetitions = repetitions + @cold_run = cold_run + @run_id = SecureRandom.uuid + end + + def compare + wall_times = execute_repetitions + flush_query_log + server_rows = fetch_query_log_rows + + metrics = build_metrics(wall_times, server_rows) + print_table(metrics) + metrics + end + + private + + attr_reader :queries, :repetitions, :cold_run, :run_id + + def execute_repetitions + wall = queries.each_key.with_object({}) { |label, h| h[label] = [] } + + repetitions.times do |rep_idx| + queries.to_a.shuffle.each do |label, sql| + tag = tag_for(label, rep_idx) + tagged_sql = with_settings(sql, tag) + + elapsed = Benchmark.realtime do + ClickhouseConnection.connection_with_retry do |connection| + connection.select_all(tagged_sql).to_a + end + end + + wall[label] << (elapsed * 1000).to_i + end + end + + wall + end + + def tag_for(label, rep_idx) + "#{TAG_PREFIX}_#{run_id}_#{sanitize_label(label)}_#{rep_idx}" + end + + def sanitize_label(label) + label.to_s.gsub(/[^a-zA-Z0-9]+/, "_") + end + + def with_settings(sql, tag) + settings = ["log_comment = #{quote(tag)}"] + settings << "use_uncompressed_cache = 0" if cold_run + + settings_sql = settings.join(", ") + + if sql.match?(/\bSETTINGS\b/i) + "#{sql}, #{settings_sql}" + else + "#{sql} SETTINGS #{settings_sql}" + end + end + + def flush_query_log + ClickhouseConnection.connection_with_retry do |connection| + connection.execute("SYSTEM FLUSH LOGS") + end + rescue => e + warn "SYSTEM FLUSH LOGS failed (#{e.class}: #{e.message}); falling back to sleep." + sleep 8 + end + + def fetch_query_log_rows + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + "SELECT log_comment, query_duration_ms, memory_usage, read_rows, read_bytes, result_rows " \ + "FROM system.query_log " \ + "WHERE type = 'QueryFinish' AND log_comment LIKE ?", + "#{TAG_PREFIX}_#{run_id}_%" + ] + ) + + ClickhouseConnection.connection_with_retry do |connection| + connection.select_all(sql).to_a + end + rescue => e + warn "Could not read system.query_log (#{e.class}: #{e.message}); server metrics unavailable." + [] + end + + def build_metrics(wall_times, server_rows) + grouped = server_rows.group_by { |row| parse_label_key(row["log_comment"]) } + + queries.each_key.with_object({}) do |label, out| + key = sanitize_label(label) + runs = grouped[key] || [] + + out[label] = { + wall_ms_median: median(wall_times[label]), + duration_ms_median: median(runs.map { |r| r["query_duration_ms"].to_i }), + memory_usage_median: median(runs.map { |r| r["memory_usage"].to_i }), + read_rows: runs.first&.dig("read_rows").to_i, + read_bytes: runs.first&.dig("read_bytes").to_i, + result_rows: runs.first&.dig("result_rows").to_i, + wall_ms_runs: wall_times[label], + server_runs: runs + } + end + end + + def parse_label_key(log_comment) + return nil if log_comment.nil? + + match = log_comment.match(/\A#{TAG_PREFIX}_[0-9a-f-]+_(.+)_\d+\z/o) + match && match[1] + end + + def median(values) + return 0 if values.blank? + + sorted = values.sort + mid = sorted.size / 2 + if sorted.size.odd? + sorted[mid] + else + (sorted[mid - 1] + sorted[mid]) / 2 + end + end + + # rubocop:disable Rails/Output + def print_table(metrics) + puts "" + cold_suffix = cold_run ? " [cold: use_uncompressed_cache=0]" : "" + puts "Repetitions: #{repetitions} (median reported)#{cold_suffix}" + puts "" + + label_width = [metrics.keys.map { |k| k.to_s.length }.max || 0, 18].max + header = format( + "%-#{label_width}s | Server ms | Wall ms | Peak mem | Rows read | Bytes read", + "Approach" + ) + puts header + puts "-" * header.length + + metrics.each do |label, m| + puts format( + "%-#{label_width}s | %10d | %10d | %12s | %12s | %12s", + label, + m[:duration_ms_median], + m[:wall_ms_median], + format_bytes(m[:memory_usage_median]), + format_number(m[:read_rows]), + format_bytes(m[:read_bytes]) + ) + end + puts "" + end + # rubocop:enable Rails/Output + + def format_bytes(bytes) + bytes = bytes.to_i + return "0 B" if bytes.zero? + + units = %w[B KiB MiB GiB TiB] + exp = (Math.log(bytes) / Math.log(1024)).floor + exp = [exp, units.size - 1].min + format("%.1f %s", bytes.to_f / (1024**exp), units[exp]) + end + + def format_number(n) + n.to_i.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\1,').reverse + end + end + end + end +end diff --git a/app/services/events/stores/utils/clickhouse_connection.rb b/app/services/events/stores/utils/clickhouse_connection.rb new file mode 100644 index 0000000..f5989ae --- /dev/null +++ b/app/services/events/stores/utils/clickhouse_connection.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Events + module Stores + module Utils + class ClickhouseConnection + MAX_RETRIES = 3 + + def self.with_retry(&) + attempts = 0 + + begin + attempts += 1 + + yield + rescue Errno::ECONNRESET, ActiveRecord::ActiveRecordError, NoMethodError + if attempts < MAX_RETRIES + sleep(0.05) + retry + end + + raise + end + end + + def self.connection_with_retry(&) + attempts = 0 + + begin + attempts += 1 + ::Clickhouse::BaseRecord.with_connection(&) + rescue Errno::ECONNRESET, ActiveRecord::ActiveRecordError, NoMethodError + if attempts < MAX_RETRIES + sleep(0.05) + retry + end + raise + end + end + end + end + end +end diff --git a/app/services/events/stores/utils/clickhouse_sql_helpers.rb b/app/services/events/stores/utils/clickhouse_sql_helpers.rb new file mode 100644 index 0000000..bb21bbf --- /dev/null +++ b/app/services/events/stores/utils/clickhouse_sql_helpers.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Events + module Stores + module Utils + module ClickhouseSqlHelpers + DECIMAL_SCALE = 26 + DECIMAL_DATE_SCALE = 10 + + # NOTE: Compute pro-rata of the duration in days between the datetimes over the duration of the billing period + # Dates are in customer timezone to make sure the duration is good + def duration_ratio_sql(from, to, duration, timezone) + from_in_timezone = date_in_customer_timezone_sql(from, timezone) + to_in_timezone = date_in_customer_timezone_sql(to, timezone) + + "(date_diff('days', #{from_in_timezone}, #{to_in_timezone}) + 1) / #{duration}" + end + + def date_in_customer_timezone_sql(date_value, timezone) + sql = if date_value.is_a?(String) + # NOTE: date is a table field name, example: events_enriched.timestamp + "toTimezone(#{date_value}, :timezone)" + else + "toTimezone(toDateTime64(:date, 5, 'UTC'), :timezone)" + end + + ActiveRecord::Base.sanitize_sql_for_conditions( + [sql, {date: date_value, timezone:}] + ) + end + + def quote(value) + ::Clickhouse::BaseRecord.connection.quote(value) + end + + def sql_condition(template, *values) + ActiveRecord::Base.sanitize_sql_for_conditions([template, *values]) + end + end + end + end +end diff --git a/app/services/events/stores/utils/query_helpers.rb b/app/services/events/stores/utils/query_helpers.rb new file mode 100644 index 0000000..0eaaed3 --- /dev/null +++ b/app/services/events/stores/utils/query_helpers.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Events + module Stores + module Utils + module QueryHelpers + def with_ctes(ctes, query) + <<-SQL + WITH #{ctes.map { |name, sql| "#{name} AS (#{sql})" }.join(",\n")} + + #{query} + SQL + end + end + end + end +end diff --git a/app/services/events/validate_creation_service.rb b/app/services/events/validate_creation_service.rb new file mode 100644 index 0000000..93b813c --- /dev/null +++ b/app/services/events/validate_creation_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Events + class ValidateCreationService < BaseService + def initialize(organization:, event_params:, customer:, subscriptions: []) + @organization = organization + @event_params = event_params + @customer = customer + @subscriptions = subscriptions + + super + end + + def call + return missing_subscription_error if event_params[:external_subscription_id].blank? + return missing_subscription_error if subscriptions.empty? + + if subscriptions.pluck(:external_id).exclude?(event_params[:external_subscription_id]) + return missing_subscription_error + end + + return invalid_timestamp_error unless valid_timestamp? + return transaction_id_error unless valid_transaction_id? + return invalid_code_error unless valid_code? + return invalid_properties_error unless valid_properties? + + result + end + + private + + attr_reader :organization, :event_params, :customer, :subscriptions + + def valid_timestamp? + return true if event_params[:timestamp].blank? + + # timestamp is a number of seconds + valid_number?(event_params[:timestamp]) + end + + def valid_transaction_id? + return false if event_params[:transaction_id].blank? + + !Event.where( + organization_id: organization.id, + transaction_id: event_params[:transaction_id], + external_subscription_id: subscriptions.first.external_id + ).exists? + end + + def valid_code? + billable_metric.present? + end + + # This validation checks only field_name value since it is important for aggregation DB query integrity. + # Other checks are performed later and presented in debugger + def valid_properties? + return true unless billable_metric.max_agg? || billable_metric.sum_agg? || billable_metric.latest_agg? + + valid_number?((event_params[:properties] || {})[billable_metric.field_name.to_sym]) + end + + def valid_number?(value) + true if value.nil? || Float(value) + rescue ArgumentError + false + end + + def missing_subscription_error + result.not_found_failure!(resource: "subscription") + end + + def transaction_id_error + result.validation_failure!(errors: {transaction_id: ["value_is_missing_or_already_exists"]}) + end + + def invalid_code_error + result.not_found_failure!(resource: "billable_metric") + end + + def invalid_properties_error + result.validation_failure!(errors: {properties: ["value_is_not_valid_number"]}) + end + + def invalid_timestamp_error + result.validation_failure!(errors: {timestamp: ["invalid_format"]}) + end + + def billable_metric + @billable_metric ||= organization.billable_metrics.find_by(code: event_params[:code]) + end + end +end diff --git a/app/services/fees/apply_provider_taxes_service.rb b/app/services/fees/apply_provider_taxes_service.rb new file mode 100644 index 0000000..4dba183 --- /dev/null +++ b/app/services/fees/apply_provider_taxes_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Fees + class ApplyProviderTaxesService < BaseService + def initialize(fee:, fee_taxes:) + @fee = fee + @fee_taxes = fee_taxes + + super + end + + def call + result.applied_taxes = [] + return result if fee.applied_taxes.any? + + applied_taxes_amount_cents = 0 + applied_precise_taxes_amount_cents = 0.to_d + applied_taxes_rate = 0 + taxes_base_rate = taxes_base_rate(fee_taxes.tax_breakdown.first) + + fee_taxes.tax_breakdown.each do |tax| + tax_rate = tax.rate.to_f * 100 + + applied_tax = Fee::AppliedTax.new( + organization_id: fee.organization_id, + tax_description: tax.type, + tax_code: tax.name.parameterize(separator: "_"), + tax_name: tax.name, + tax_rate: tax_rate, + amount_currency: fee.amount_currency + ) + fee.applied_taxes << applied_tax + + tax_amount_cents = (fee.sub_total_excluding_taxes_amount_cents * taxes_base_rate * tax_rate).fdiv(100) + tax_precise_amount_cents = (fee.sub_total_excluding_taxes_precise_amount_cents * taxes_base_rate * tax_rate).fdiv(100.to_d) + + applied_tax.amount_cents = tax_amount_cents.round + applied_tax.precise_amount_cents = tax_precise_amount_cents + applied_tax.save! if fee.persisted? + + applied_taxes_amount_cents += tax_amount_cents + applied_precise_taxes_amount_cents += tax_precise_amount_cents + applied_taxes_rate += tax_rate + + result.applied_taxes << applied_tax + end + + fee.taxes_amount_cents = applied_taxes_amount_cents.round + fee.taxes_precise_amount_cents = applied_precise_taxes_amount_cents + fee.taxes_rate = applied_taxes_rate + fee.taxes_base_rate = taxes_base_rate + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :fee, :fee_taxes + + def taxes_base_rate(tax) + return 1 unless tax + + tax_rate = tax.rate.to_f * 100 + tax_amount_cents = (fee.sub_total_excluding_taxes_amount_cents * tax_rate).fdiv(100) + + if tax.tax_amount < tax_amount_cents + tax.tax_amount.fdiv(tax_amount_cents) + else + 1 + end + end + end +end diff --git a/app/services/fees/apply_provider_taxes_to_standalone_fees_service.rb b/app/services/fees/apply_provider_taxes_to_standalone_fees_service.rb new file mode 100644 index 0000000..33d97d9 --- /dev/null +++ b/app/services/fees/apply_provider_taxes_to_standalone_fees_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Fees + class ApplyProviderTaxesToStandaloneFeesService < BaseService + Result = BaseResult + + def initialize(customer:, fees:, currency:) + @customer = customer + @fees = fees + @currency = currency + + super + end + + def call + taxes_result = Integrations::Aggregator::Taxes::Invoices::CreateService.call( + invoice: fake_invoice, fees: + ) + return result unless taxes_result.success? + + fees.each do |fee| + item_id = fee.id || fee.item_id + fee_taxes = taxes_result.fees.find { |item| item.item_id == item_id } + + Fees::ApplyProviderTaxesService.call!(fee:, fee_taxes:) + end + + result + end + + private + + attr_reader :customer, :fees, :currency + + FakeInvoice = Data.define(:id, :issuing_date, :currency, :customer, :billing_entity) + + def fake_invoice + FakeInvoice.new( + id: SecureRandom.uuid, + issuing_date: Time.current.in_time_zone(customer.applicable_timezone).to_date, + currency:, + customer:, + billing_entity: customer.billing_entity + ) + end + end +end diff --git a/app/services/fees/apply_taxes_service.rb b/app/services/fees/apply_taxes_service.rb new file mode 100644 index 0000000..7492ace --- /dev/null +++ b/app/services/fees/apply_taxes_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Fees + class ApplyTaxesService < BaseService + def initialize(fee:, tax_codes: nil, customer: nil, plan: nil) + @fee = fee + @tax_codes = tax_codes + @customer = customer || fee.invoice&.customer || fee.subscription.customer + @plan = plan || fee.subscription&.plan + + super + end + + def call + result.applied_taxes = [] + return result if fee.applied_taxes.any? + + applied_taxes_amount_cents = 0 + applied_precise_taxes_amount_cents = 0.to_d + applied_taxes_rate = 0 + + applicable_taxes.each do |tax| + applied_tax = Fee::AppliedTax.new( + organization_id: fee.organization_id, + fee:, + tax:, + tax_description: tax.description, + tax_code: tax.code, + tax_name: tax.name, + tax_rate: tax.rate, + amount_currency: fee.amount_currency + ) + fee.applied_taxes << applied_tax + + tax_amount_cents = (fee.sub_total_excluding_taxes_amount_cents * tax.rate).fdiv(100) + tax_precise_amount_cents = (fee.sub_total_excluding_taxes_precise_amount_cents * tax.rate).fdiv(100.to_d) + + applied_tax.amount_cents = tax_amount_cents.round + applied_tax.precise_amount_cents = tax_precise_amount_cents + applied_tax.save! if fee.persisted? + + applied_taxes_amount_cents += tax_amount_cents + applied_precise_taxes_amount_cents += tax_precise_amount_cents + applied_taxes_rate += tax.rate + + result.applied_taxes << applied_tax + end + + fee.taxes_amount_cents = applied_taxes_amount_cents.round + fee.taxes_precise_amount_cents = applied_precise_taxes_amount_cents + fee.taxes_rate = applied_taxes_rate + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :fee, :tax_codes, :customer, :plan + + def applicable_taxes + # organization.taxes - are all taxes created on the organization + return customer.organization.taxes.where(code: tax_codes) if tax_codes + return fee.add_on.taxes if fee.add_on? && fee.add_on.taxes.any? + return fee.charge.taxes if fee.charge? && fee.charge.taxes.any? + return fee.fixed_charge.taxes if fee.fixed_charge? && fee.fixed_charge.taxes.any? + return fee.invoiceable.taxes if fee.commitment? && fee.invoiceable.taxes.any? + if (fee.charge? || fee.subscription? || fee.commitment? || fee.fixed_charge?) && plan.taxes.any? + return plan.taxes + end + return customer.taxes if customer.taxes.any? + + # billing_entity.taxes - are the default taxes applied on the billing entity + customer.billing_entity.taxes + end + end +end diff --git a/app/services/fees/build_pay_in_advance_fixed_charge_service.rb b/app/services/fees/build_pay_in_advance_fixed_charge_service.rb new file mode 100644 index 0000000..25a5f54 --- /dev/null +++ b/app/services/fees/build_pay_in_advance_fixed_charge_service.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module Fees + class BuildPayInAdvanceFixedChargeService < BaseService + Result = BaseResult[:fee] + + def initialize(subscription:, fixed_charge:, fixed_charge_event:, timestamp:) + @subscription = subscription + @fixed_charge = fixed_charge + @fixed_charge_event = fixed_charge_event + @timestamp = timestamp + @organization = subscription.organization + @currency = subscription.plan.amount.currency + + super + end + + def call + # Calculate boundaries for the current billing period + boundaries = calculate_boundaries + + # Find already paid units for this fixed charge in the current billing period + already_paid_units = find_already_paid_units(boundaries) + + # Calculate delta (new units - already paid) + new_units = fixed_charge_event.units + delta_units = new_units - already_paid_units + + # If delta is negative or zero (decrease), create a zero-amount fee + # We don't refund pay-in-advance, but we still generate an invoice to document the change + if delta_units <= 0 + fee = build_zero_amount_fee(boundaries) + result.fee = fee + return result + end + + # Calculate the fee for the delta units (positive increase) + fee = build_delta_fee(delta_units, boundaries) + result.fee = fee + result + end + + private + + attr_reader :subscription, :fixed_charge, :fixed_charge_event, :timestamp, :organization, :currency + + def calculate_boundaries + dates = Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + + BillingPeriodBoundaries.new( + from_datetime: dates[:fixed_charges_from_datetime], + to_datetime: dates[:fixed_charges_to_datetime], + charges_from_datetime: nil, + charges_to_datetime: nil, + fixed_charges_from_datetime: dates[:fixed_charges_from_datetime], + fixed_charges_to_datetime: dates[:fixed_charges_to_datetime], + timestamp: Time.zone.at(timestamp), + charges_duration: nil, + fixed_charges_duration: dates[:fixed_charges_duration] + ) + end + + def find_already_paid_units(boundaries) + # Find fees for this fixed charge that have already been paid in this billing period + existing_fees = Fee.where( + organization:, + subscription:, + fixed_charge: [fixed_charge, fixed_charge.parent], + fee_type: :fixed_charge + ).where( + "properties->>'fixed_charges_from_datetime' = ?", + boundaries.fixed_charges_from_datetime.iso8601(3) + ).where( + "properties->>'fixed_charges_to_datetime' = ?", + boundaries.fixed_charges_to_datetime.iso8601(3) + ) + + # Sum up all the units that have been billed for this period + existing_fees.sum(:units).to_d + end + + def build_delta_fee(delta_units, boundaries) + proration_coefficient = if fixed_charge.prorated? + days = (boundaries.fixed_charges_to_datetime.to_date - Time.zone.at(timestamp).to_date + 1) + days / boundaries.fixed_charges_duration.to_f + else + 1 + end + + # Apply the charge model to calculate the amount for delta units + amount_result = calculate_amount_for_units(delta_units * proration_coefficient) + + rounded_amount = amount_result[:amount].round(currency.exponent) + amount_cents = rounded_amount * currency.subunit_to_unit + precise_amount_cents = amount_result[:amount] * currency.subunit_to_unit.to_d + unit_amount_cents = delta_units.positive? ? (amount_cents / delta_units).round : 0 + precise_unit_amount = delta_units.positive? ? (amount_result[:amount] / delta_units) : BigDecimal("0") + + Fee.new( + organization:, + billing_entity_id: subscription.customer.billing_entity_id, + subscription:, + fixed_charge:, + amount_cents:, + precise_amount_cents:, + amount_currency: currency, + fee_type: :fixed_charge, + invoiceable_type: "FixedCharge", + invoiceable: fixed_charge, + units: delta_units, + total_aggregated_units: delta_units, + properties: boundaries.to_h, + payment_status: :pending, + taxes_amount_cents: 0, + taxes_precise_amount_cents: BigDecimal("0"), + unit_amount_cents:, + precise_unit_amount:, + amount_details: {}, + pay_in_advance: true + ) + end + + def calculate_amount_for_units(units) + # Create a mock aggregation result for the charge model + aggregation_result = BaseService::Result.new + aggregation_result.aggregation = units + aggregation_result.full_units_number = units + aggregation_result.count = 1 + + charge_model_result = ChargeModels::Factory.new_instance( + chargeable: fixed_charge, + aggregation_result:, + properties: fixed_charge.properties, + period_ratio: 1.0, + calculate_projected_usage: false + ).apply + + {amount: charge_model_result.amount, unit_amount: charge_model_result.unit_amount} + end + + def build_zero_amount_fee(boundaries) + Fee.new( + organization:, + billing_entity_id: subscription.customer.billing_entity_id, + subscription:, + fixed_charge:, + amount_cents: 0, + precise_amount_cents: BigDecimal("0"), + amount_currency: currency, + fee_type: :fixed_charge, + invoiceable_type: "FixedCharge", + invoiceable: fixed_charge, + units: 0, + total_aggregated_units: 0, + properties: boundaries.to_h, + payment_status: :pending, + taxes_amount_cents: 0, + taxes_precise_amount_cents: BigDecimal("0"), + unit_amount_cents: 0, + precise_unit_amount: BigDecimal("0"), + amount_details: {}, + pay_in_advance: true + ) + end + end +end diff --git a/app/services/fees/charge_service.rb b/app/services/fees/charge_service.rb new file mode 100644 index 0000000..6a37166 --- /dev/null +++ b/app/services/fees/charge_service.rb @@ -0,0 +1,497 @@ +# frozen_string_literal: true + +module Fees + class ChargeService < BaseService + # optional params: + # | usage_filters - UsageFilters + # - context (:current_usage, :invoice_preview, :recurring) - to be moved in usage_filters + # | results_adjustments - to be implemented as a class to pass the following inside: + # - with_zero_units_filters + # - calculate_projected_usage + # - apply_taxes + # - filtered_aggregations + def initialize( + invoice:, + charge:, + subscription:, + boundaries:, + context: nil, + cache_middleware: nil, + filtered_aggregations: nil, + apply_taxes: false, + calculate_projected_usage: false, + with_zero_units_filters: true, + usage_filters: UsageFilters::NONE + ) + @invoice = invoice + @charge = charge + @subscription = subscription + @boundaries = boundaries + @currency = subscription.plan.amount.currency + @apply_taxes = apply_taxes + @calculate_projected_usage = calculate_projected_usage + @with_zero_units_filters = with_zero_units_filters + @context = context + @current_usage = context == :current_usage + @cache_middleware = cache_middleware || Subscriptions::ChargeCacheMiddleware.new( + subscription:, charge:, to_datetime: boundaries.charges_to_datetime, cache: false + ) + + # Allow the service to ignore events aggregation + @filtered_aggregations = filtered_aggregations + @usage_filters = usage_filters + + super(nil) + end + + def call + return result if !current_usage && already_billed? + + init_fees + return result if current_usage + + if invoice.nil? || !invoice.progressive_billing? + init_true_up_fee + end + return result unless result.success? + + ActiveRecord::Base.transaction do + result.fees.reject! { |f| !should_persist_fee?(f, result.fees) } + next if context == :invoice_preview + + result.fees.each do |fee| + fee.save! + + next unless invoice&.draft? && fee.true_up_parent_fee.nil? && adjusted_fee( + charge_filter: fee.charge_filter, + grouped_by: fee.grouped_by + ) + + adjusted_fee(charge_filter: fee.charge_filter, grouped_by: fee.grouped_by).update!(fee:) + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :invoice, :charge, :subscription, :boundaries, :context, :current_usage, :currency, :cache_middleware, + :filtered_aggregations, :apply_taxes, :calculate_projected_usage, :with_zero_units_filters, :usage_filters + + delegate :billable_metric, to: :charge + delegate :organization, to: :subscription + delegate :plan, to: :subscription + + def init_fees + result.fees = [] + + return init_charge_fees(properties: charge.properties) unless charge.filters.any? + + # NOTE: Create a fee for each filters defined on the charge. + charge.filters.each do |charge_filter| + init_charge_fees(properties: charge_filter.properties, charge_filter:) + end + + # NOTE: Create a fee for events not matching any filters. + charge_filter = ChargeFilter.new(charge:, properties: {"pricing_group_keys" => charge.pricing_group_keys}) + init_charge_fees(properties: charge.properties, charge_filter:) + end + + def init_charge_fees(properties:, charge_filter: nil) + fees = cache_middleware.call(charge_filter:) do + aggregation_result = aggregator(charge_filter:).aggregate(options: options(properties)) + + unless aggregation_result.success? + result.fail_with_error!(aggregation_result.error) + return [] + end + + if billable_metric.recurring? + persist_recurring_value(aggregation_result.aggregations || [aggregation_result], charge_filter) + end + + charge_model_result = apply_charge_model(aggregation_result:, properties:) + unless charge_model_result.success? + result.fail_with_error!(charge_model_result.error) + return [] + end + + charge_fees = fees_from_charge_model_result( + charge_model_result, + properties:, + charge_filter:, + breakdowns: aggregation_result.breakdowns + ) + + filter_non_persistable_fees_for_caching(charge_fees) + end + + if fees.empty? && skip_caching_of_non_persistable_fee? + fees = hydrate_non_persistable_fees(properties:, charge_filter:) + end + + # Preserve preloaded associations on all fees (including cached ones) to avoid N+1 queries + fees.each do |fee| + fee.association(:billable_metric).target = billable_metric + fee.association(:charge_filter).target = charge_filter if charge_filter&.id + end + + result.fees.concat(fees.compact) + end + + def skip_caching_of_non_persistable_fee? + current_usage && organization.feature_flag_enabled?(:non_persistable_charge_cache_optimization) + end + + def hydrate_non_persistable_fees(properties:, charge_filter:) + zero_aggregation = aggregator(charge_filter:).empty_results + + charge_model_result = ChargeModels::Factory.new_instance( + chargeable: charge, + aggregation_result: zero_aggregation, + properties:, + period_ratio: calculate_period_ratio, + calculate_projected_usage: + ).apply + + fees_from_charge_model_result(charge_model_result, properties:, charge_filter:, breakdowns: []) + end + + def fees_from_charge_model_result(charge_model_result, properties:, charge_filter:, breakdowns:) + charge_model_result.grouped_results.map do |amount_result| + # TODO: check if this is still needed as we now skip certain zero units fees + next if current_usage && charge_filter && amount_result.units.zero? && !with_zero_units_filters + + fee = init_fee(amount_result, properties:, charge_filter:) + next if fee.nil? + + build_breakdowns_for_fee(fee:, breakdowns:) + + fee + end.compact + end + + def build_breakdowns_for_fee(fee:, breakdowns:) + Array(breakdowns).each do |breakdown| + if fee.grouped_by.empty? + presentation_by = breakdown[:groups] + else + next unless fee.grouped_by.all? { |k, v| breakdown[:groups][k] == v } + presentation_by = breakdown[:groups].reject { |k, _| fee.grouped_by.key?(k) } + end + + fee.presentation_breakdowns.build( + presentation_by:, + units: breakdown[:value], + organization_id: charge.organization_id + ) + end + end + + def filter_non_persistable_fees_for_caching(charge_fees) + return charge_fees unless skip_caching_of_non_persistable_fee? + + charge_fees.filter { |f| should_persist_fee?(f, charge_fees) } + end + + def init_fee(amount_result, properties:, charge_filter:) + # NOTE: Build fee for case when there is adjusted fee and units or amount has been adjusted. + # Base fee creation flow handles case when only name has been adjusted + if !current_usage && invoice&.draft? && (adjusted = adjusted_fee( + charge_filter:, + grouped_by: amount_result.grouped_by + )) && !adjusted.adjusted_display_name? + adjustement_result = Fees::InitFromAdjustedChargeFeeService.call( + adjusted_fee: adjusted, + boundaries:, + properties: + ) + return result.fail_with_error!(adjustement_result.error) unless adjustement_result.success? + + result.fees << adjustement_result.fee + return + end + + # Prevent trying to create a fee with negative units or amount. + if amount_result.units.negative? || amount_result.amount.negative? + amount_result.amount = amount_result.unit_amount = BigDecimal(0) + amount_result.full_units_number = amount_result.units = BigDecimal(0) + end + + # NOTE: amount_result should be a BigDecimal, we need to round it + # to the currency decimals and transform it into currency cents + if charge.applied_pricing_unit + pricing_unit_usage = PricingUnitUsage.build_from_fiat_amounts( + amount: amount_result.amount, + unit_amount: amount_result.unit_amount, + applied_pricing_unit: charge.applied_pricing_unit + ) + + amount_cents, precise_amount_cents, unit_amount_cents, precise_unit_amount = pricing_unit_usage + .to_fiat_currency_cents(currency) + .values_at(:amount_cents, :precise_amount_cents, :unit_amount_cents, :precise_unit_amount) + else + pricing_unit_usage = nil + rounded_amount = amount_result.amount.round(currency.exponent) + amount_cents = rounded_amount * currency.subunit_to_unit + precise_amount_cents = amount_result.amount * currency.subunit_to_unit.to_d + unit_amount_cents = amount_result.unit_amount * currency.subunit_to_unit + precise_unit_amount = amount_result.unit_amount + end + + units = if current_usage && (charge.pay_in_advance? || charge.prorated?) + amount_result.current_usage_units + elsif charge.prorated? + amount_result.full_units_number.nil? ? amount_result.units : amount_result.full_units_number + else + amount_result.units + end + + new_fee = Fee.new( + invoice:, + organization_id: organization.id, + billing_entity_id: subscription.customer.billing_entity_id, + subscription:, + charge:, + amount_cents:, + precise_amount_cents:, + amount_currency: currency, + fee_type: :charge, + invoiceable_type: "Charge", + invoiceable: charge, + units:, + total_aggregated_units: amount_result.total_aggregated_units || units, + properties: filtered_for_charge_boundaries(boundaries.to_h), + events_count: amount_result.count, + payment_status: :pending, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + unit_amount_cents:, + precise_unit_amount:, + amount_details: amount_result.amount_details, + grouped_by: amount_result.grouped_by || {}, + charge_filter: charge_filter&.persisted? ? charge_filter : nil, + pricing_unit_usage: + ) + + unless charge.invoiceable? + new_fee.pay_in_advance = charge.pay_in_advance? + end + + if !current_usage && (adjusted = adjusted_fee(charge_filter:, grouped_by: amount_result.grouped_by))&.adjusted_display_name? + new_fee.invoice_display_name = adjusted.invoice_display_name + end + + if apply_taxes + taxes_result = Fees::ApplyTaxesService.call(fee: new_fee) + taxes_result.raise_if_error! + end + + new_fee + end + + def should_persist_fee?(fee, fees) + return true if context == :recurring + return true if fee.units != 0 || fee.amount_cents != 0 || fee.events_count != 0 + return true if adjusted_fee(charge_filter: fee.charge_filter, grouped_by: fee.grouped_by).present? + return true if fee.true_up_parent_fee.present? + + fees.any? { |f| f.true_up_parent_fee == fee } + end + + def adjusted_fee(charge_filter:, grouped_by:) + @adjusted_fee ||= {} + + key = [ + charge_filter&.id, + (grouped_by || {}).map do |k, v| + "#{k}-#{v}" + end.sort.join("|") + ].compact.join("|") + key = "default" if key.blank? + + return @adjusted_fee[key] if @adjusted_fee.key?(key) + + scope = AdjustedFee + .where(invoice:, subscription:, charge:, charge_filter:, fee_type: :charge) + .where("(properties->>'charges_from_datetime')::timestamptz = ?", boundaries.charges_from_datetime&.iso8601(3)) + .where("(properties->>'charges_to_datetime')::timestamptz = ?", boundaries.charges_to_datetime&.iso8601(3)) + + scope = if grouped_by.present? + scope.where(grouped_by:) + else + scope.where(grouped_by: {}) + end + + @adjusted_fee[key] = scope.first + end + + def init_true_up_fee + fee = result.fees.find { |f| f.charge_filter_id.nil? } + + if charge.applied_pricing_unit + used_amount_cents = result.fees.map(&:pricing_unit_usage).sum(&:amount_cents) + used_precise_amount_cents = result.fees.map(&:pricing_unit_usage).sum(&:precise_amount_cents) + else + used_amount_cents = result.fees.sum(&:amount_cents) + used_precise_amount_cents = result.fees.sum(&:precise_amount_cents) + end + + true_up_fee = Fees::CreateTrueUpService.call(fee:, used_amount_cents:, used_precise_amount_cents:).true_up_fee + result.fees << true_up_fee if true_up_fee + end + + def apply_charge_model(aggregation_result:, properties:) + ChargeModels::Factory.new_instance( + chargeable: charge, + aggregation_result:, + properties:, + period_ratio: calculate_period_ratio, + calculate_projected_usage: + ).apply + end + + def options(properties) + { + free_units_per_events: properties["free_units_per_events"].to_i, + free_units_per_total_aggregation: BigDecimal(properties["free_units_per_total_aggregation"] || 0), + is_current_usage: current_usage, + is_pay_in_advance: charge.pay_in_advance? + } + end + + def already_billed? + existing_fees = if invoice + invoice.fees.where(charge_id: charge.id, subscription_id: subscription.id) + else + Fee.where( + charge_id: charge.id, + subscription_id: subscription.id, + invoice_id: nil, + pay_in_advance_event_id: nil + ).where( + "(properties->>'charges_from_datetime')::timestamptz = ?", boundaries.charges_from_datetime&.iso8601(3) + ).where( + "(properties->>'charges_to_datetime')::timestamptz = ?", boundaries.charges_to_datetime&.iso8601(3) + ) + end + + return false if existing_fees.blank? + + result.fees = existing_fees + true + end + + def aggregator(charge_filter:) + aggregate = true + aggregate = filtered_aggregations.include?(charge_filter&.id) unless filtered_aggregations.nil? + + BillableMetrics::AggregationFactory.new_instance( + charge:, + current_usage:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: boundaries.max_timestamp + }, + filters: aggregation_filters(charge_filter:), + bypass_aggregation: !aggregate + ) + end + + def persist_recurring_value(aggregation_results, charge_filter) + return if current_usage + + # NOTE: Only weighted sum and custom aggregations are setting this value + return unless aggregation_results.first&.recurring_updated_at + + result.cached_aggregations ||= [] + + # NOTE: persist current recurring value for next period + aggregation_results.each do |aggregation_result| + result.cached_aggregations << CachedAggregation.find_or_initialize_by( + organization_id: billable_metric.organization_id, + external_subscription_id: subscription.external_id, + charge_id: charge.id, + charge_filter_id: charge_filter&.id, + grouped_by: aggregation_result.grouped_by || {}, + timestamp: aggregation_result.recurring_updated_at + ) do |aggregation| + aggregation.current_aggregation = aggregation_result.total_aggregated_units || aggregation_result.aggregation + aggregation.current_amount = aggregation_result.custom_aggregation&.[](:amount) + aggregation.save! + end + end + end + + def grouped_by_keys(charge_filter: nil) + model = charge_filter.presence || charge + grouped_by_keys = model.pricing_group_keys&.dup || [] + if charge.accepts_target_wallet && !grouped_by_keys.include?("target_wallet_code") + grouped_by_keys << "target_wallet_code" + end + grouped_by_keys if grouped_by_keys.present? && !usage_filters.skip_grouping + end + + def aggregation_filters(charge_filter: nil) + filters = {charge_id: charge.id} + + grouped_by_keys = grouped_by_keys(charge_filter:) + filters[:grouped_by] = grouped_by_keys if grouped_by_keys.present? + + presentation_group_keys_values = charge.presentation_group_keys_values + if presentation_group_keys_values.present? + filters[:presentation_by] = presentation_group_keys_values & (usage_filters.filter_by_presentation || presentation_group_keys_values) + end + + if charge_filter.present? + result = ChargeFilters::MatchingAndIgnoredService.call(charge:, filter: charge_filter) + filters[:charge_filter] = charge_filter + filters[:matching_filters] = result.matching_filters + filters[:ignored_filters] = result.ignored_filters + end + + if usage_filters.filter_by_group.present? + # when pricing group keys on a charge are "workspace" and "user", and filter_by_group is {"workspace" => ["A"]}, + # we want to remove the grouping keys "workspace", but keep the grouping key "user", so the usage will still be granular within the workspace + usage_filters.filter_by_group.keys.each { |key| filters[:grouped_by]&.delete(key) } + filters[:matching_filters] ||= {} + # expected matching_filters format is { "workspace" => ["A", "B"], "user" => ["U1", "U2"] } + filters[:matching_filters].merge!(usage_filters.filter_by_group) + end + + filters + end + + def calculate_period_ratio + from_date = boundaries.charges_from_datetime.to_date + to_date = boundaries.charges_to_datetime.to_date + current_date = Time.current.to_date + + total_days = (to_date - from_date).to_i + 1 + + charges_duration = boundaries.charges_duration || total_days + + return 1.0 if current_date >= to_date + return 0.0 if current_date < from_date + + days_passed = (current_date - from_date).to_i + 1 + + ratio = days_passed.fdiv(charges_duration) + ratio.clamp(0.0, 1.0) + end + + def filtered_for_charge_boundaries(boundaries) + properties = boundaries.to_h + properties["fixed_charges_from_datetime"] = nil + properties["fixed_charges_to_datetime"] = nil + properties["fixed_charges_duration"] = nil + properties + end + end +end diff --git a/app/services/fees/commitments/minimum/create_service.rb b/app/services/fees/commitments/minimum/create_service.rb new file mode 100644 index 0000000..8a72d84 --- /dev/null +++ b/app/services/fees/commitments/minimum/create_service.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +module Fees + module Commitments + module Minimum + class CreateService < BaseService + def initialize(invoice_subscription:) + @invoice_subscription = invoice_subscription + @minimum_commitment = invoice_subscription.subscription.plan.minimum_commitment + + super + end + + def call + return result if invoice_has_minimum_commitment_fee? || !minimum_commitment + + # For pay in advance plans, we reconcile the PREVIOUS billing period. + # On the first invoice, there's no previous period to reconcile, so skip fee creation. + return result if pay_in_advance_first_period? + + true_up_fee_result = ::Commitments::Minimum::CalculateTrueUpFeeService + .new_instance(invoice_subscription:).call + + currency = invoice.total_amount.currency + precise_unit_amount = true_up_fee_result.amount_cents / currency.subunit_to_unit.to_f + + new_fee = Fee.new( + invoice:, + organization_id: organization.id, + billing_entity_id: invoice.billing_entity_id, + subscription:, + fee_type: :commitment, + invoiceable_type: "Commitment", + invoiceable_id: minimum_commitment.id, + amount_cents: true_up_fee_result.amount_cents, + precise_amount_cents: true_up_fee_result.precise_amount_cents, + unit_amount_cents: true_up_fee_result.amount_cents, + amount_currency: subscription.plan.amount_currency, + invoice_display_name: minimum_commitment.invoice_name, + units: 1, + precise_unit_amount:, + taxes_amount_cents: 0, + properties: commitment_boundaries + ) + + new_fee.save! + result.fee = new_fee + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :minimum_commitment, :invoice_subscription + + delegate :invoice, :subscription, to: :invoice_subscription + delegate :organization, to: :invoice + + def invoice_has_minimum_commitment_fee? + invoice.fees.commitment.where(subscription:).any? { |fee| fee.invoiceable.minimum_commitment? } + end + + def pay_in_advance_first_period? + subscription.plan.pay_in_advance? && !reconciliation_invoice_subscription + end + + # Returns the invoice_subscription that represents the period being reconciled. + # - For pay in arrears: the current invoice_subscription + # - For pay in advance: the previous invoice_subscription (nil on first period) + def reconciliation_invoice_subscription + return @reconciliation_invoice_subscription if defined?(@reconciliation_invoice_subscription) + + @reconciliation_invoice_subscription = if subscription.plan.pay_in_advance? + invoice_subscription.previous_invoice_subscription + else + invoice_subscription + end + end + + # Returns the billing period boundaries that the commitment fee covers. + # These boundaries come directly from the invoice_subscription that represents + # the period being reconciled, not from computed dates. + def commitment_boundaries + return {} unless reconciliation_invoice_subscription + + { + "from_datetime" => reconciliation_invoice_subscription.from_datetime, + "to_datetime" => reconciliation_invoice_subscription.to_datetime + } + end + end + end + end +end diff --git a/app/services/fees/create_pay_in_advance_service.rb b/app/services/fees/create_pay_in_advance_service.rb new file mode 100644 index 0000000..c074a7b --- /dev/null +++ b/app/services/fees/create_pay_in_advance_service.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +module Fees + class CreatePayInAdvanceService < BaseService + Result = BaseResult[:fees, :invoice_id] + + def initialize(charge:, event:, billing_at: nil, estimate: false) + @charge = charge + @event = Events::CommonFactory.new_instance(source: event) + @billing_at = billing_at || @event.timestamp + + @estimate = estimate + raise ArgumentError, "estimate must be true if event if not persisted" if !@event.persisted && !estimate + + super + end + + def call + fees = [] + + ActiveRecord::Base.transaction(**isolation_mode) do + fees << if charge.filters.any? + init_charge_filter_fee + else + init_fee(properties:) + end + end + + ActiveRecord::Base.transaction do + result.fees = persist_fees(fees.compact) + + if !charge.invoiceable? && customer_provider_taxation? + Fees::ApplyProviderTaxesToStandaloneFeesService.call!( + customer:, fees: result.fees, currency: subscription.plan.amount_currency + ) + end + end + + deliver_webhooks + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :charge, :event, :billing_at, :estimate + + delegate :billable_metric, to: :charge + delegate :subscription, to: :event + + def filter + @filter ||= ChargeFilters::EventMatchingService.call(charge:, event:).charge_filter + end + + def init_fee(properties:, charge_filter: nil) + aggregation_result = aggregate(properties:, charge_filter:) + + cache_aggregation_result(aggregation_result:, charge_filter:) + + charge_model_result = apply_charge_model(aggregation_result:, properties:) + + if charge.applied_pricing_unit + pricing_unit_usage = PricingUnitUsage.build_from_fiat_amounts( + amount: charge_model_result.amount / charge.pricing_unit.subunit_to_unit.to_d, + unit_amount: charge_model_result.unit_amount, + applied_pricing_unit: charge.applied_pricing_unit + ) + + amount_cents, precise_amount_cents, unit_amount_cents, precise_unit_amount = pricing_unit_usage + .to_fiat_currency_cents(subscription.plan.amount.currency) + .values_at(:amount_cents, :precise_amount_cents, :unit_amount_cents, :precise_unit_amount) + else + pricing_unit_usage = nil + amount_cents = charge_model_result.amount + precise_amount_cents = charge_model_result.precise_amount + unit_amount_cents = charge_model_result.unit_amount * subscription.plan.amount.currency.subunit_to_unit + precise_unit_amount = charge_model_result.unit_amount + end + + Fee.new( + subscription:, + charge:, + organization_id: customer.organization_id, + billing_entity_id: customer.billing_entity_id, + amount_cents:, + precise_amount_cents:, + amount_currency: subscription.plan.amount_currency, + fee_type: :charge, + invoiceable: charge, + units: charge_model_result.units, + total_aggregated_units: charge_model_result.units, + properties: boundaries.to_h, + events_count: charge_model_result.count, + charge_filter_id: charge_filter&.id, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + payment_status: :pending, + pay_in_advance: true, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + unit_amount_cents:, + precise_unit_amount:, + grouped_by: format_grouped_by, + amount_details: charge_model_result.amount_details || {}, + pricing_unit_usage: + ) + end + + def init_charge_filter_fee + init_fee(properties:, charge_filter: filter || ChargeFilter.new(charge:)) + end + + def persist_fees(fees) + fees.map do |fee| + # Non-invoiceable fees are regrouped later by AdvanceChargesService which + # aggregates pre-existing fee taxes. They must have taxes applied now because + # there is no ComputeTaxesAndTotalsService step for them. + # Provider-taxed customers get taxes via apply_provider_taxes after persist. + # Invoiceable fees get taxes applied later via ComputeTaxesAndTotalsService. + if !charge.invoiceable? && !customer_provider_taxation? + Fees::ApplyTaxesService.call!(fee:) + end + + fee.save! unless estimate + fee + end + end + + def date_service + @date_service ||= Subscriptions::DatesService.new_instance( + subscription, + billing_at, + current_usage: true + ) + end + + def properties + @properties ||= filter&.properties || charge.properties + end + + def boundaries + @boundaries ||= BillingPeriodBoundaries.new( + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + charges_duration: date_service.charges_duration_in_days, + timestamp: billing_at + ) + end + + def aggregate(properties:, charge_filter: nil) + Charges::PayInAdvanceAggregationService.call!( + charge:, boundaries:, properties:, event:, charge_filter: + ) + end + + def apply_charge_model(aggregation_result:, properties:) + Charges::ApplyPayInAdvanceChargeModelService.call!( + charge:, aggregation_result:, properties: + ) + end + + def deliver_webhooks + return if estimate + + result.fees.each { |f| SendWebhookJob.perform_later("fee.created", f) } + end + + def cache_aggregation_result(aggregation_result:, charge_filter:) + return unless aggregation_result.current_aggregation.present? || + aggregation_result.max_aggregation.present? || + aggregation_result.max_aggregation_with_proration.present? + + CachedAggregation.create!( + organization_id: event.organization_id, + event_transaction_id: event.transaction_id, + timestamp: billing_at, + external_subscription_id: event.external_subscription_id, + charge_id: charge.id, + charge_filter_id: charge_filter&.id, + current_aggregation: aggregation_result.current_aggregation, + current_amount: aggregation_result.current_amount, + max_aggregation: aggregation_result.max_aggregation, + max_aggregation_with_proration: aggregation_result.max_aggregation_with_proration, + grouped_by: format_grouped_by + ) + end + + def format_grouped_by + grouped_by = properties["pricing_group_keys"].presence || properties["grouped_by"] || [] + grouped_by << "target_wallet_code" if charge.accepts_target_wallet && event.properties["target_wallet_code"].present? + return {} if grouped_by.blank? + + grouped_by.index_with { event.properties[it] } + end + + def customer + @customer ||= subscription.customer + end + + def customer_provider_taxation? + return @customer_provider_taxation if defined?(@customer_provider_taxation) + + @customer_provider_taxation = customer.tax_customer.present? + end + + def isolation_mode + # NOTE: this is only to avoid failure with spec scnearios + return {} if ActiveRecord::Base.connection.transaction_open? + + {isolation: :repeatable_read} + end + end +end diff --git a/app/services/fees/create_true_up_service.rb b/app/services/fees/create_true_up_service.rb new file mode 100644 index 0000000..30482b0 --- /dev/null +++ b/app/services/fees/create_true_up_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Fees + class CreateTrueUpService < BaseService + def initialize(fee:, used_amount_cents:, used_precise_amount_cents:) + @fee = fee + @used_amount_cents = used_amount_cents + @used_precise_amount_cents = used_precise_amount_cents + @boundaries = BillingPeriodBoundaries.from_fee(fee) + + super + end + + def call + return result unless fee + return result if used_amount_cents >= prorated_min_amount_cents + + if charge.applied_pricing_unit + amount_cents, precise_amount_cents, unit_amount_cents, precise_unit_amount = pricing_unit_usage + .to_fiat_currency_cents(charge.plan.amount.currency) + .values_at(:amount_cents, :precise_amount_cents, :unit_amount_cents, :precise_unit_amount) + else + amount_cents = (prorated_min_amount_cents - used_amount_cents).round + precise_amount_cents = prorated_min_amount_cents - used_precise_amount_cents + unit_amount_cents = amount_cents + precise_unit_amount = precise_amount_cents / charge.plan.amount.currency.subunit_to_unit + end + + true_up_fee = fee.dup + true_up_fee.assign_attributes( + amount_cents:, + precise_amount_cents:, + units: 1, + total_aggregated_units: 1, + events_count: 0, + charge_filter_id: nil, + true_up_parent_fee: fee, + unit_amount_cents:, + precise_unit_amount:, + pricing_unit_usage: + ) + + result.true_up_fee = true_up_fee + result + end + + private + + attr_reader :fee, :used_amount_cents, :used_precise_amount_cents, :boundaries + + delegate :charge, :subscription, to: :fee + + def prorated_min_amount_cents + # NOTE: number of days between beginning of the period and the termination date + from_datetime = boundaries.charges_from_datetime.to_time + to_datetime = boundaries.charges_to_datetime.to_time + number_of_day_to_bill = subscription.date_diff_with_timezone(from_datetime, to_datetime) + + charge.min_amount_cents.fdiv(boundaries.charges_duration) * number_of_day_to_bill + end + + def pricing_unit_usage + return @pricing_unit_usage if defined?(@pricing_unit_usage) + + unless charge.applied_pricing_unit + @pricing_unit_usage = nil + return + end + + amount_cents = prorated_min_amount_cents - used_amount_cents + precise_amount_cents = prorated_min_amount_cents - used_precise_amount_cents + + @pricing_unit_usage = PricingUnitUsage.build_from_fiat_amounts( + amount: amount_cents / charge.pricing_unit.subunit_to_unit.to_d, + unit_amount: precise_amount_cents / charge.pricing_unit.subunit_to_unit.to_d, + applied_pricing_unit: charge.applied_pricing_unit + ) + end + end +end diff --git a/app/services/fees/destroy_service.rb b/app/services/fees/destroy_service.rb new file mode 100644 index 0000000..07a5c76 --- /dev/null +++ b/app/services/fees/destroy_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Fees + class DestroyService < BaseService + def initialize(fee:) + @fee = fee + + super + end + + def call + return result.not_found_failure!(resource: "fee") unless fee + return result.not_allowed_failure!(code: "invoiced_fee") if fee.invoice_id + + fee.discard! + + result.fee = fee + result + end + + private + + attr_reader :fee + end +end diff --git a/app/services/fees/estimate_instant/base_service.rb b/app/services/fees/estimate_instant/base_service.rb new file mode 100644 index 0000000..1bf6bc7 --- /dev/null +++ b/app/services/fees/estimate_instant/base_service.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Fees + module EstimateInstant + class BaseService < ::BaseService + def initialize(organization:, subscription:) + @organization = organization + @subscription = subscription + + super + end + + private + + attr_reader :subscription, :organization + delegate :customer, to: :subscription + + def estimate_charge_fees(charge, event) + charge_filter = ChargeFilters::EventMatchingService.call(charge:, event:).charge_filter + properties = charge_filter&.properties || charge.properties + + # Todo: perhaps this should live in its own service + Events::CalculateExpressionService.call(organization:, event:) + billable_metric = charge.billable_metric + base_unit = 0 + # in case for the aggregations we do not use field_name, we count each event as 1 unit + base_unit = 1 if charge.billable_metric.field_name.nil? + units = BigDecimal(event.properties[charge.billable_metric.field_name] || base_unit) + units = BillableMetrics::Aggregations::ApplyRoundingService.call!(billable_metric:, units:).units + + estimate_result = estimate_class(charge).call!(properties:, units:) + + amount = estimate_result.amount + # NOTE: amount_result should be a BigDecimal, we need to round it + # to the currency decimals and transform it into currency cents + rounded_amount = amount.round(currency.exponent) + amount_cents = rounded_amount * currency.subunit_to_unit + unit_amount = rounded_amount.zero? ? BigDecimal("0") : rounded_amount / units + unit_amount_cents = unit_amount * currency.subunit_to_unit + + # construct payload directly + { + lago_id: nil, + lago_charge_id: charge.id, + lago_charge_filter_id: charge_filter&.id, + lago_invoice_id: nil, + lago_true_up_fee_id: nil, + lago_true_up_parent_fee_id: nil, + lago_subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + lago_customer_id: customer.id, + external_customer_id: customer.external_id, + item: { + type: "charge", + code: billable_metric.code, + name: billable_metric.name, + description: billable_metric.description, + invoice_display_name: charge.invoice_display_name.presence || billable_metric.name, + filters: charge_filter&.to_h, + filter_invoice_display_name: charge_filter&.display_name, + lago_item_id: billable_metric.id, + item_type: BillableMetric.name, + grouped_by: {} + }, + pay_in_advance: true, + invoiceable: charge.invoiceable, + amount_cents:, + amount_currency: currency.iso_code, + precise_amount: amount, + precise_total_amount: amount, + taxes_amount_cents: 0, + taxes_precise_amount: 0, + taxes_rate: 0, + total_amount_cents: amount_cents, + total_amount_currency: currency.iso_code, + units: units, + description: nil, + precise_unit_amount: unit_amount_cents, + precise_coupons_amount_cents: "0.0", + events_count: 1, + payment_status: "pending", + created_at: nil, + succeeded_at: nil, + failed_at: nil, + refunded_at: nil, + amount_details: nil, + event_transaction_id: event.transaction_id + } + end + + def estimate_class(charge) + if charge.percentage? + Charges::EstimateInstant::PercentageService + elsif charge.standard? + Charges::EstimateInstant::StandardService + end + end + + def currency + @currency ||= subscription.plan.amount.currency + end + end + end +end diff --git a/app/services/fees/estimate_instant/batch_pay_in_advance_service.rb b/app/services/fees/estimate_instant/batch_pay_in_advance_service.rb new file mode 100644 index 0000000..852b731 --- /dev/null +++ b/app/services/fees/estimate_instant/batch_pay_in_advance_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Fees + module EstimateInstant + class BatchPayInAdvanceService < BaseService + def initialize(organization:, external_subscription_id:, events:) + @organization = organization + @timestamp = Time.current + + @events = events.map do |e| + Event.new( + organization_id: organization.id, + code: e[:code], + external_subscription_id: e[:external_subscription_id], + properties: e[:properties] || {}, + transaction_id: e[:transaction_id] || SecureRandom.uuid, + timestamp: + ) + end + + subscription = + organization.subscriptions.where(external_id: external_subscription_id) + .where("date_trunc('millisecond', started_at::timestamp) <= ?::timestamp", timestamp) + .where( + "terminated_at IS NULL OR date_trunc('millisecond', terminated_at::timestamp) >= ?", + timestamp + ) + .order("terminated_at DESC NULLS FIRST, started_at DESC") + .first + + super(organization:, subscription:) + end + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + + if charges.none? + return result.single_validation_failure!(field: :code, error_code: "does_not_match_an_instant_charge") + end + + fees = [] + + events.each do |event| + # find all charges that match this event + matched_charges = charges.select { |c| c.billable_metric.code == event.code } + next unless matched_charges + fees += matched_charges.map { |charge| estimate_charge_fees(charge, event) } + end + + result.fees = fees + result + end + + private + + attr_reader :events, :timestamp + + def charges + @charges ||= subscription + .plan + .charges + .merge(Charge.percentage.or(Charge.standard)) + .pay_in_advance + .includes(:billable_metric) + end + end + end +end diff --git a/app/services/fees/estimate_instant/pay_in_advance_service.rb b/app/services/fees/estimate_instant/pay_in_advance_service.rb new file mode 100644 index 0000000..7cf1adc --- /dev/null +++ b/app/services/fees/estimate_instant/pay_in_advance_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Fees + module EstimateInstant + class PayInAdvanceService < BaseService + def initialize(organization:, params:) + @event = Event.new( + organization_id: organization.id, + code: params[:code], + external_subscription_id: params[:external_subscription_id], + properties: params[:properties] || {}, + transaction_id: params[:transaction_id] || SecureRandom.uuid, + timestamp: Time.current + ) + + super(organization:, subscription: event.subscription) + end + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + + if charges.none? + return result.single_validation_failure!(field: :code, error_code: "does_not_match_an_instant_charge") + end + + fees = charges.map { |charge| estimate_charge_fees(charge, event) } + + result.fees = fees + result + end + + private + + attr_reader :event + + def charges + @charges ||= subscription + .plan + .charges + .merge(Charge.percentage.or(Charge.standard)) + .pay_in_advance + .joins(:billable_metric) + .where(billable_metric: {code: event.code}) + end + end + end +end diff --git a/app/services/fees/estimate_pay_in_advance_service.rb b/app/services/fees/estimate_pay_in_advance_service.rb new file mode 100644 index 0000000..6b306ff --- /dev/null +++ b/app/services/fees/estimate_pay_in_advance_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Fees + class EstimatePayInAdvanceService < BaseService + def initialize(organization:, params:) + @organization = organization + # NOTE: validation is shared with event creation and is expecting a transaction_id + @event_params = params.merge(transaction_id: SecureRandom.uuid) + + super + end + + def call + validation_result = Events::ValidateCreationService.call(organization:, event_params:, customer:, subscriptions:) + return validation_result unless validation_result.success? + + if charges.none? + return result.single_validation_failure!(field: :code, error_code: "does_not_match_an_instant_charge") + end + + fees = [] + + ApplicationRecord.transaction do + charges.each { |charge| fees += estimated_charge_fees(charge) } + + # NOTE: make sure the event and fees are not persisted in database + raise ActiveRecord::Rollback + end + + fees.each { |f| f.pay_in_advance_event_id = nil } + + apply_taxes(fees) + + result.fees = fees + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :organization, :event_params + + def event + return @event if @event + + @event = Events::Common.new( + id: nil, + organization_id: organization.id, + code: event_params[:code], + external_subscription_id: subscriptions.first&.external_id, + properties: event_params[:properties] || {}, + transaction_id: SecureRandom.uuid, + timestamp: Time.current, + precise_total_amount_cents: event_params[:precise_total_amount_cents] || 0, + persisted: false + ) + + expression_result = Events::CalculateExpressionService.call(organization:, event: @event) + result.validation_failure!(errors: expression_result.error.message) unless expression_result.success? + result.raise_if_error! + + @event + end + + def customer + return @customer if @customer + + @customer = if event_params[:external_subscription_id] + organization.subscriptions.find_by(external_id: event_params[:external_subscription_id])&.customer + else + Customer.find_by(external_id: event_params[:external_customer_id], organization_id: organization.id) + end + end + + def subscriptions + return @subscriptions if defined? @subscriptions + + timestamp = Time.current + subscriptions = organization.subscriptions.where(external_id: event_params[:external_subscription_id]) + + @subscriptions = subscriptions + .where("date_trunc('second', started_at::timestamp) <= ?", timestamp) + .where("terminated_at IS NULL OR date_trunc('second', terminated_at::timestamp) >= ?", timestamp) + .order("terminated_at DESC NULLS FIRST, started_at DESC") + end + + def charges + @charges ||= subscriptions.first + .plan + .charges + .pay_in_advance + .joins(:billable_metric) + .where(billable_metric: {code: event.code}) + end + + def estimated_charge_fees(charge) + Fees::CreatePayInAdvanceService.call!(charge:, event:, estimate: true).fees + end + + def apply_taxes(fees) + if customer&.tax_customer.present? + Fees::ApplyProviderTaxesToStandaloneFeesService.call!( + customer:, fees:, currency: subscriptions.first.plan.amount_currency + ) + else + fees.each { |fee| Fees::ApplyTaxesService.call!(fee:) } + end + end + end +end diff --git a/app/services/fees/fixed_charge_service.rb b/app/services/fees/fixed_charge_service.rb new file mode 100644 index 0000000..c3ffa0e --- /dev/null +++ b/app/services/fees/fixed_charge_service.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +module Fees + class FixedChargeService < BaseService + Result = BaseResult[:fee] + + def initialize( + invoice:, + fixed_charge:, + subscription:, + boundaries:, + apply_taxes: false, + context: nil + ) + @invoice = invoice + @fixed_charge = fixed_charge + @subscription = subscription + @organization = subscription.organization + @boundaries = readjust_boundaries(boundaries) + @currency = subscription.plan.amount.currency + @apply_taxes = apply_taxes + @context = context + @current_usage = context == :current_usage + + super(nil) + end + + def call + return result if already_billed? + + init_fee + return result if result.failure? + return result if current_usage + + if context != :invoice_preview && should_persist_fee? + result.fee.save! + + # Update adjusted fee with the new fee_id + if invoice&.draft? && adjusted_fee + adjusted_fee.update!(fee: result.fee) + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :fixed_charge, :subscription, :boundaries, :apply_taxes, :context, :current_usage, :currency, :organization + + def already_billed? + # Check if fee exists on current invoice + return true if invoice.fees.fixed_charge.exists?(fixed_charge_id: fixed_charge.id) + + # For pay_in_advance fixed charges, check if already billed in a previous invoice + # for the same billing period (prevents double-billing when trial ends) + return false unless fixed_charge.pay_in_advance? + + fixed_charge + .fees + .where(subscription:) + .joins(:invoice).where.not(invoices: {status: :voided}) + .where( + "date_trunc('second', (properties->>'fixed_charges_from_datetime')::timestamptz) = date_trunc('second', ?::timestamptz)", + boundaries[:fixed_charges_from_datetime]&.iso8601(3) + ) + .where( + "date_trunc('second', (properties->>'fixed_charges_to_datetime')::timestamptz) = date_trunc('second', ?::timestamptz)", + boundaries[:fixed_charges_to_datetime]&.iso8601(3) + ) + .exists? + end + + def init_fee + # NOTE: Build fee for case when there is adjusted fee and units or amount has been adjusted. + # Base fee creation flow handles case when only name has been adjusted + if !current_usage && invoice&.draft? && adjusted_fee && !adjusted_fee.adjusted_display_name? + return init_adjusted_fee + end + + amount_result = apply_aggregation_and_charge_model + + # Prevent trying to create a fee with negative units or amount. + if amount_result.units.negative? || amount_result.amount.negative? + amount_result.amount = amount_result.unit_amount = BigDecimal(0) + amount_result.full_units_number = amount_result.units = amount_result.total_aggregated_units = BigDecimal(0) + end + + # TODO: add pricing units + pricing_unit_usage = nil + rounded_amount = amount_result.amount.round(currency.exponent) + amount_cents = rounded_amount * currency.subunit_to_unit + precise_amount_cents = amount_result.amount * currency.subunit_to_unit.to_d + unit_amount_cents = amount_result.unit_amount * currency.subunit_to_unit + precise_unit_amount = amount_result.unit_amount + + units = amount_result.full_units_number + + if first_prorated_paid_in_advance_charge_billed_in_prev_subscription? + already_paid_fee = find_already_paid_fee_for_the_fixed_charge(boundaries) + if already_paid_fee + current_period_duration_days = ((boundaries[:fixed_charges_to_datetime] - boundaries[:fixed_charges_from_datetime]) / 1.day.in_seconds).ceil + # note: previous pay in advance FC fee was issued at the event of the timestamp, so that's when we received this event, and since when + # the proration is started, despite from-to boundaries are taking into account the whole + already_paid_fee_prorated_days = ((already_paid_fee.properties["fixed_charges_to_datetime"].to_time - + already_paid_fee.properties["timestamp"].to_time) / 1.day.in_seconds).ceil + # if previous fee was prorated for x days out of n, current is prorated for y days out of n, + # we need to find coefficient of proration for current period: + # prorated_for_current_period = already_paid_fee.amount_cents / x * y + # we devide by prev proration length to find price of one day, and mutiply by the current period length + prorated_for_current_period = (already_paid_fee.amount_cents * current_period_duration_days.to_f / already_paid_fee_prorated_days).round + amount_cents -= prorated_for_current_period + precise_amount_cents -= prorated_for_current_period.to_d + + amount_cents = 0 if amount_cents < 0 + precise_amount_cents = 0.0 if precise_amount_cents < 0 + end + end + + new_fee = Fee.new( + invoice:, + organization_id: organization.id, + billing_entity_id: subscription.customer.billing_entity_id, + subscription:, + fixed_charge:, + amount_cents:, + precise_amount_cents:, + amount_currency: currency, + fee_type: :fixed_charge, + invoiceable_type: "FixedCharge", + invoiceable: fixed_charge, + units:, + total_aggregated_units: amount_result.total_aggregated_units || units, + properties: boundaries.to_h, + payment_status: :pending, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + unit_amount_cents:, + precise_unit_amount:, + amount_details: amount_result.amount_details, + pricing_unit_usage:, + pay_in_advance: fixed_charge.pay_in_advance? + ) + + if adjusted_fee&.adjusted_display_name? + new_fee.invoice_display_name = adjusted_fee.invoice_display_name + end + + if apply_taxes + taxes_result = Fees::ApplyTaxesService.call(fee: new_fee) + taxes_result.raise_if_error! + end + + result.fee = new_fee + end + + def init_adjusted_fee + adjustment_result = Fees::InitFromAdjustedFixedChargeFeeService.call( + adjusted_fee:, + boundaries:, + properties: fixed_charge.properties + ) + return result.fail_with_error!(adjustment_result.error) unless adjustment_result.success? + + result.fee = adjustment_result.fee + result + end + + def apply_aggregation_and_charge_model + aggregation_result = aggregator.call + + ChargeModels::Factory.new_instance( + chargeable: fixed_charge, + aggregation_result:, + properties: fixed_charge.properties, + period_ratio: calculate_period_ratio, + calculate_projected_usage: false + ).apply + end + + def aggregator + if context == :invoice_preview && !subscription.persisted? + return FixedChargeEvents::Aggregations::PreviewAggregationService.new( + fixed_charge:, + subscription:, + boundaries: + ) + end + + if fixed_charge.prorated? + return FixedChargeEvents::Aggregations::ProratedAggregationService.new(fixed_charge:, subscription:, boundaries:) + end + + FixedChargeEvents::Aggregations::SimpleAggregationService.new(fixed_charge:, subscription:, boundaries:) + end + + def calculate_period_ratio + from_date = boundaries["fixed_charges_from_datetime"].to_date + to_date = boundaries["fixed_charges_to_datetime"].to_date + current_date = Time.current.to_date + + total_days = (to_date - from_date).to_i + 1 + charges_duration = boundaries["fixed_charges_duration"] || total_days + + return 1.0 if current_date >= to_date + return 0.0 if current_date < from_date + + days_passed = (current_date - from_date).to_i + 1 + + ratio = days_passed.fdiv(charges_duration) + ratio.clamp(0.0, 1.0) + end + + def should_persist_fee? + return true if context == :recurring + return true if result.fee.units != 0 || result.fee.amount_cents != 0 + return true if adjusted_fee.present? + + false + end + + def adjusted_fee + return @adjusted_fee if defined?(@adjusted_fee) + + @adjusted_fee = AdjustedFee + .where(invoice:, subscription:, fixed_charge:, fee_type: :fixed_charge) + .where("(properties->>'fixed_charges_from_datetime')::timestamptz = ?", boundaries[:fixed_charges_from_datetime]&.iso8601(3)) + .where("(properties->>'fixed_charges_to_datetime')::timestamptz = ?", boundaries[:fixed_charges_to_datetime]&.iso8601(3)) + .first + end + + # Note: boundaries are taken from the subscription and they do not consider some fixed_charges being pay_in_advance + def readjust_boundaries(boundaries) + properties = boundaries.to_h + properties["charges_from_datetime"] = nil + properties["charges_to_datetime"] = nil + properties["charges_duration"] = nil + + return properties if !fixed_charge.pay_in_advance? + timestamp = boundaries.timestamp + in_advance_dates = Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + + properties["fixed_charges_from_datetime"] = in_advance_dates[:fixed_charges_from_datetime] + properties["fixed_charges_to_datetime"] = in_advance_dates[:fixed_charges_to_datetime] + properties["fixed_charges_duration"] = in_advance_dates[:fixed_charges_duration] + properties + end + + # if we have a prorated paid in advance fixed charge, and we're upgrading to a new plan with the same add_on, + # there is an existing fee paid for the full month, but at the moment of upgrade, the new price should applied, + # so we need to deduct the prorated for the rest of the billing period amount that was already paid from the new price. + def first_prorated_paid_in_advance_charge_billed_in_prev_subscription? + return false unless fixed_charge.pay_in_advance? + return false unless fixed_charge.prorated? + return false unless subscription.previous_subscription + return false if subscription.invoices.count > 1 + fixed_charge.matching_fixed_charge_prev_subscription(subscription).present? + end + + def find_already_paid_fee_for_the_fixed_charge(current_fee_boundaries) + prev_fixed_charge = fixed_charge.matching_fixed_charge_prev_subscription(subscription) + Fee.where( + organization: organization, + billing_entity: subscription.customer.billing_entity, + fixed_charge: prev_fixed_charge + ).where( + "(properties->>'fixed_charges_from_datetime')::timestamptz <= ? AND (properties->>'fixed_charges_to_datetime')::timestamptz >= ?", + current_fee_boundaries[:fixed_charges_from_datetime], + # in the DB we store timestamp with 3 digits of milliseconds, timestamp of boundaries has 9, so we need to floor it + current_fee_boundaries[:fixed_charges_to_datetime].floor(3) + ).order(created_at: :desc).first + end + end +end diff --git a/app/services/fees/init_from_adjusted_charge_fee_service.rb b/app/services/fees/init_from_adjusted_charge_fee_service.rb new file mode 100644 index 0000000..89ae9e8 --- /dev/null +++ b/app/services/fees/init_from_adjusted_charge_fee_service.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +module Fees + class InitFromAdjustedChargeFeeService < ::BaseService + Result = BaseResult[:fee] + + def initialize(adjusted_fee:, boundaries:, properties:) + @adjusted_fee = adjusted_fee + @boundaries = boundaries + @properties = properties + + super + end + + def call + if adjusted_fee.adjusted_units? && amount_result.failure? + return result.fail_with_error!(amount_result.error) + end + + result.fee = init_adjusted_fee + result + end + + private + + attr_reader :adjusted_fee, :boundaries, :properties + + delegate :invoice, :subscription, to: :adjusted_fee + + def init_adjusted_fee + currency = invoice.total_amount.currency + units = adjusted_fee.units + amount_details = adjusted_fee.adjusted_units? ? amount_result.amount_details : {} + + if charge.applied_pricing_unit + amount_cents, precise_amount_cents, unit_amount_cents, precise_unit_amount = pricing_unit_usage + .to_fiat_currency_cents(currency) + .values_at(:amount_cents, :precise_amount_cents, :unit_amount_cents, :precise_unit_amount) + elsif adjusted_fee.adjusted_units? + rounded_amount = amount_result.amount.round(currency.exponent) + precise_amount_cents = amount_result.amount * currency.subunit_to_unit.to_d + amount_cents = rounded_amount * currency.subunit_to_unit + unit_amount_cents = amount_result.unit_amount * currency.subunit_to_unit + precise_unit_amount = amount_result.unit_amount + else + unit_precise_amount_cents = adjusted_fee.unit_precise_amount_cents + unit_amount_cents = unit_precise_amount_cents.round + precise_amount_cents = units * unit_precise_amount_cents + amount_cents = precise_amount_cents.round + precise_unit_amount = unit_precise_amount_cents / currency.subunit_to_unit + end + + Fee.new( + invoice:, + organization_id: invoice.organization_id, + billing_entity_id: invoice.billing_entity_id, + subscription:, + charge:, + amount_cents:, + precise_amount_cents:, + amount_currency: currency, + fee_type: :charge, + invoiceable_type: "Charge", + invoiceable: charge, + units:, + total_aggregated_units: units, + properties: boundaries.to_h, + events_count: 0, + payment_status: :pending, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + unit_amount_cents:, + precise_unit_amount:, + amount_details:, + invoice_display_name: adjusted_fee.invoice_display_name, + grouped_by: adjusted_fee.grouped_by, + charge_filter_id: charge_filter&.id, + pricing_unit_usage: + ) + end + + def amount_result + return @amount_result if defined?(@amount_result) + + aggregation_result = BaseService::Result.new + aggregation_result.aggregation = adjusted_fee.units + aggregation_result.current_usage_units = adjusted_fee.units + aggregation_result.full_units_number = adjusted_fee.units + aggregation_result.count = 0 + + if charge.dynamic? + aggregation_result.precise_total_amount_cents = 0 + end + + @amount_result = ChargeModels::Factory + .new_instance(chargeable: charge, aggregation_result:, properties:) + .apply + end + + def pricing_unit_usage + return @pricing_unit_usage if defined?(@pricing_unit_usage) + + unless charge.applied_pricing_unit + @pricing_unit_usage = nil + return + end + + if adjusted_fee.adjusted_units? + amount = amount_result.amount + unit_amount = amount_result.unit_amount + else + precise_amount_cents = adjusted_fee.units * adjusted_fee.unit_precise_amount_cents + amount = precise_amount_cents / charge.pricing_unit.subunit_to_unit.to_d + unit_amount = adjusted_fee.unit_precise_amount_cents / charge.pricing_unit.subunit_to_unit.to_d + end + + @pricing_unit_usage = PricingUnitUsage.build_from_fiat_amounts( + amount:, + unit_amount:, + applied_pricing_unit: charge.applied_pricing_unit + ) + end + + def charge + return adjusted_fee.charge if adjusted_fee.charge + return adjusted_fee.charge_with_discarded if invoice.voided_invoice_id.present? + + nil + end + + def charge_filter + return adjusted_fee.charge_filter if adjusted_fee.charge_filter + return adjusted_fee.charge_filter_with_discarded if invoice.voided_invoice_id.present? + + nil + end + end +end diff --git a/app/services/fees/init_from_adjusted_fixed_charge_fee_service.rb b/app/services/fees/init_from_adjusted_fixed_charge_fee_service.rb new file mode 100644 index 0000000..a53451c --- /dev/null +++ b/app/services/fees/init_from_adjusted_fixed_charge_fee_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Fees + class InitFromAdjustedFixedChargeFeeService < ::BaseService + Result = BaseResult[:fee] + + def initialize(adjusted_fee:, boundaries:, properties:) + @adjusted_fee = adjusted_fee + @boundaries = boundaries + @properties = properties + + super + end + + def call + if adjusted_fee.adjusted_units? && amount_result.failure? + return result.fail_with_error!(amount_result.error) + end + + result.fee = init_adjusted_fee + result + end + + private + + attr_reader :adjusted_fee, :boundaries, :properties + + delegate :invoice, :subscription, to: :adjusted_fee + + def init_adjusted_fee + currency = invoice.total_amount.currency + units = adjusted_fee.units + amount_details = adjusted_fee.adjusted_units? ? amount_result.amount_details : {} + + if adjusted_fee.adjusted_units? + rounded_amount = amount_result.amount.round(currency.exponent) + precise_amount_cents = amount_result.amount * currency.subunit_to_unit.to_d + amount_cents = rounded_amount * currency.subunit_to_unit + unit_amount_cents = amount_result.unit_amount * currency.subunit_to_unit + precise_unit_amount = amount_result.unit_amount + else + unit_precise_amount_cents = adjusted_fee.unit_precise_amount_cents + unit_amount_cents = unit_precise_amount_cents.round + precise_amount_cents = units * unit_precise_amount_cents + amount_cents = precise_amount_cents.round + precise_unit_amount = unit_precise_amount_cents / currency.subunit_to_unit + end + + Fee.new( + invoice:, + organization_id: invoice.organization_id, + billing_entity_id: invoice.billing_entity_id, + subscription:, + fixed_charge:, + amount_cents:, + precise_amount_cents:, + amount_currency: currency, + fee_type: :fixed_charge, + invoiceable: fixed_charge, + units:, + total_aggregated_units: units, + properties: boundaries.to_h, + events_count: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + unit_amount_cents:, + precise_unit_amount:, + amount_details:, + invoice_display_name: adjusted_fee.invoice_display_name + ) + end + + def amount_result + return @amount_result if defined?(@amount_result) + + aggregation_result = BaseService::Result.new + aggregation_result.aggregation = adjusted_fee.units + aggregation_result.current_usage_units = adjusted_fee.units + aggregation_result.full_units_number = adjusted_fee.units + aggregation_result.count = 0 + + @amount_result = ChargeModels::Factory + .new_instance(chargeable: fixed_charge, aggregation_result:, properties:) + .apply + end + + def fixed_charge + return adjusted_fee.fixed_charge if adjusted_fee.fixed_charge + return adjusted_fee.fixed_charge_with_discarded if invoice.voided_invoice_id.present? + + nil + end + end +end diff --git a/app/services/fees/one_off_service.rb b/app/services/fees/one_off_service.rb new file mode 100644 index 0000000..5e93e43 --- /dev/null +++ b/app/services/fees/one_off_service.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +module Fees + class OneOffService < BaseService + def initialize(invoice:, fees:) + @invoice = invoice + @fees = fees + + super(nil) + end + + def call + fees_result = [] + + ActiveRecord::Base.transaction do + fees.each do |fee| + add_on = add_on(identifier: fee[add_on_identifier]) + + result.not_found_failure!(resource: "add_on").raise_if_error! unless add_on + result.single_validation_failure!(field: :boundaries, error_code: "values_are_invalid").raise_if_error! unless valid_boundaries?(fee) + + unit_amount_cents = fee[:unit_amount_cents] || add_on.amount_cents + units = fee[:units]&.to_f || 1 + tax_codes = fee[:tax_codes] + + fee = Fee.new( + invoice:, + organization_id: invoice.organization_id, + billing_entity_id: invoice.billing_entity_id, + add_on:, + invoice_display_name: fee[:invoice_display_name].presence, + description: fee[:description] || add_on.description, + unit_amount_cents:, + amount_cents: (unit_amount_cents * units).round, + precise_amount_cents: unit_amount_cents * units.to_d, + amount_currency: invoice.currency, + fee_type: :add_on, + invoiceable_type: "AddOn", + invoiceable: add_on, + units:, + payment_status: :pending, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + properties: { + from_datetime: from_datetime(fee), + to_datetime: to_datetime(fee), + timestamp: Time.current + } + ) + fee.precise_unit_amount = fee.unit_amount.to_f + + # Apply explicit payload taxes only when there is no tax provider. + # Provider taxes take precedence and are handled async by ComputeTaxesAndTotalsService. + # Explicit tax_codes must be applied here because they are ephemeral payload data. + # Derived taxes (no tax_codes, no provider) are applied later by ComputeAmountsFromFees. + if tax_codes.present? && !customer_provider_taxation? + taxes_result = Fees::ApplyTaxesService.call(fee:, tax_codes:) + taxes_result.raise_if_error! + end + + fee.save! + + fees_result << fee + end + end + + result.fees = fees_result + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :invoice, :fees + + delegate :customer, :organization, to: :invoice + + def add_on(identifier:) + finder = api_context? ? :code : :id + + invoice.organization.add_ons.find_by(finder => identifier) + end + + def add_on_identifier + api_context? ? :add_on_code : :add_on_id + end + + def customer_provider_taxation? + return @customer_provider_taxation if defined?(@customer_provider_taxation) + + @customer_provider_taxation = customer.tax_customer.present? + end + + def valid_boundaries?(fee) + return true if fee[:from_datetime].nil? && fee[:to_datetime].nil? + + fee[:from_datetime] && + fee[:to_datetime] && + Utils::Datetime.valid_format?(fee[:from_datetime]) && + Utils::Datetime.valid_format?(fee[:to_datetime]) && + from_datetime(fee) <= to_datetime(fee) + end + + def from_datetime(fee) + if fee[:from_datetime].is_a?(String) + DateTime.iso8601(fee[:from_datetime]) + else + fee[:from_datetime] || Time.current + end + end + + def to_datetime(fee) + if fee[:to_datetime].is_a?(String) + DateTime.iso8601(fee[:to_datetime]) + else + fee[:to_datetime] || Time.current + end + end + end +end diff --git a/app/services/fees/paid_credit_service.rb b/app/services/fees/paid_credit_service.rb new file mode 100644 index 0000000..70cf24c --- /dev/null +++ b/app/services/fees/paid_credit_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Fees + class PaidCreditService < BaseService + def initialize(invoice:, wallet_transaction:, customer:) + @invoice = invoice + @customer = customer + @wallet_transaction = wallet_transaction + super(nil) + end + + def create + return result if already_billed? + + amount_cents = wallet_transaction.amount_cents + precise_amount_cents = amount_cents.to_d + unit_amount_cents = wallet_transaction.unit_amount_cents + + new_fee = Fee.new( + invoice:, + organization_id: invoice.organization_id, + billing_entity_id: invoice.billing_entity_id, + fee_type: :credit, + invoiceable_type: "WalletTransaction", + invoiceable: wallet_transaction, + amount_cents:, + precise_amount_cents:, + amount_currency: wallet_transaction.wallet.currency, + unit_amount_cents:, + units: wallet_transaction.credit_amount, + payment_status: :pending, + + # NOTE: No taxes should be applied on as it can be considered as an advance + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d + ) + new_fee.precise_unit_amount = new_fee.unit_amount.to_f + new_fee.save! + + result.fee = new_fee + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :wallet_transaction, :customer + delegate :organization, to: :customer + + def already_billed? + existing_fee = invoice.fees.find_by(invoiceable_id: wallet_transaction.id, invoiceable_type: "WalletTransaction") + return false unless existing_fee + + result.fee = existing_fee + true + end + end +end diff --git a/app/services/fees/projection_service.rb b/app/services/fees/projection_service.rb new file mode 100644 index 0000000..e0a3586 --- /dev/null +++ b/app/services/fees/projection_service.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +module Fees + class ProjectionService < ::BaseService + Result = BaseResult[:projected_amount_cents, :projected_units, :projected_pricing_unit_amount_cents] + + def initialize(fees:) + @fees = fees + @first_fee = fees.first + + @charge = @first_fee&.charge + @subscription = @first_fee&.subscription + @charge_filter = @first_fee&.charge_filter + @from_datetime = Time.zone.parse(@first_fee&.properties&.dig("from_datetime")) + @to_datetime = Time.zone.parse(@first_fee&.properties&.dig("to_datetime")) + @charges_duration_in_days = @first_fee&.properties&.dig("charges_duration") + @currency = @first_fee&.amount&.currency + @properties_for_charge_model = @charge_filter&.properties&.presence || @charge&.properties + + super(nil) + end + + def call + result = Result.new + + if charge&.billable_metric&.recurring? + current_amount_cents = fees.sum(&:amount_cents) + current_units = fees.sum { |f| BigDecimal(f.units) } + current_pricing_unit_amount_cents = fees.sum { |f| f.pricing_unit_usage&.amount_cents || 0 } + current_pricing_unit_amount_cents = nil if current_pricing_unit_amount_cents.zero? + + result.projected_amount_cents = current_amount_cents + result.projected_units = current_units + result.projected_pricing_unit_amount_cents = current_pricing_unit_amount_cents + return result + end + + if fees.blank? || !(period_ratio > 0 && period_ratio <= 1) + result.projected_amount_cents = BigDecimal(0) + result.projected_units = BigDecimal(0) + result.projected_pricing_unit_amount_cents = BigDecimal(0) + return result + end + + aggregation_result = run_aggregation + return result.fail_with_error!(aggregation_result.error) unless aggregation_result.success? + + charge_model_result = ChargeModels::Factory.new_instance( + chargeable: charge, + aggregation_result:, + properties: properties_for_charge_model, + period_ratio:, + calculate_projected_usage: true + ).apply + + return result.fail_with_error!(charge_model_result.error) unless charge_model_result.success? + + if charge_model_result.try(:grouped_results) + target_group_result = charge_model_result.grouped_results.find do |group_result| + group_result.grouped_by == first_fee.grouped_by + end + charge_model_result = target_group_result if target_group_result + end + + result.projected_amount_cents = calculate_projected_amount_cents(charge_model_result) + result.projected_units = charge_model_result.projected_units&.negative? ? BigDecimal(0) : charge_model_result.projected_units + result.projected_pricing_unit_amount_cents = calculate_projected_pricing_unit_amount_cents(charge_model_result) + result + end + + private + + attr_reader :fees, :first_fee, :charge, :subscription, :charge_filter, + :from_datetime, :to_datetime, :charges_duration_in_days, + :currency, :properties_for_charge_model + + def period_ratio + return @period_ratio if defined?(@period_ratio) + + current_time = Time.current + return @period_ratio = 1.0 if current_time >= to_datetime + return @period_ratio = 0.0 if current_time < from_datetime + + total_days = Utils::Datetime.date_diff_with_timezone( + from_datetime, + to_datetime, + subscription.customer.applicable_timezone + ) + + days_passed = Utils::Datetime.date_diff_with_timezone( + from_datetime, + current_time, + subscription.customer.applicable_timezone + ) + + ratio = days_passed.fdiv(total_days) + @period_ratio = ratio.clamp(0.0, 1.0) + end + + def run_aggregation + boundaries = { + from_datetime: from_datetime, + to_datetime: to_datetime, + charges_duration: charges_duration_in_days + } + + aggregator = BillableMetrics::AggregationFactory.new_instance( + charge: charge, + subscription: subscription, + boundaries: boundaries, + filters: aggregation_filters, + current_usage: true + ) + + aggregator.aggregate(options: {is_current_usage: true}) + end + + def aggregation_filters + local_charge_filter = charge_filter + + if local_charge_filter.nil? && charge.filters.any? + local_charge_filter = ChargeFilter.new(charge: charge) + end + + filters = {charge_id: charge.id} + model = local_charge_filter.presence || charge + filters[:grouped_by] = model.pricing_group_keys if model.pricing_group_keys.present? + + if local_charge_filter.present? + result = ChargeFilters::MatchingAndIgnoredService.call(charge: charge, filter: local_charge_filter) + filters[:charge_filter] = local_charge_filter + filters[:matching_filters] = result.matching_filters + filters[:ignored_filters] = result.ignored_filters + end + + filters + end + + def calculate_projected_amount_cents(amount_result) + return 0 unless amount_result.projected_amount + + return 0 if amount_result.projected_amount.negative? + + rounded_projected_amount = amount_result.projected_amount.round(currency.exponent) + rounded_projected_amount * currency.subunit_to_unit + end + + def calculate_projected_pricing_unit_amount_cents(amount_result) + return nil unless charge.applied_pricing_unit + return nil unless amount_result.projected_amount + + projected_pricing_unit_usage = PricingUnitUsage.build_from_fiat_amounts( + amount: amount_result.projected_amount, + unit_amount: amount_result.unit_amount, + applied_pricing_unit: charge.applied_pricing_unit + ) + projected_pricing_unit_usage.to_fiat_currency_cents(currency)[:amount_cents] + end + end +end diff --git a/app/services/fees/subscription_service.rb b/app/services/fees/subscription_service.rb new file mode 100644 index 0000000..1b2356f --- /dev/null +++ b/app/services/fees/subscription_service.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +module Fees + class SubscriptionService < BaseService + def initialize(invoice:, subscription:, boundaries:, context: nil) + @invoice = invoice + @subscription = subscription + @boundaries = boundaries + @context = context + + super(nil) + end + + def call + return result if already_billed? + + new_precise_amount_cents = compute_amount + new_amount_cents = new_precise_amount_cents.round + new_fee = initialize_fee(new_amount_cents, new_precise_amount_cents) + + result.fee = new_fee + return result if context == :preview + + ActiveRecord::Base.transaction do + new_fee.save! + adjusted_fee.update!(fee: new_fee) if invoice.draft? && adjusted_fee + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :subscription, :boundaries, :context + + delegate :customer, to: :invoice + delegate :previous_subscription, :plan, to: :subscription + + def initialize_fee(new_amount_cents, new_precise_amount_cents) + base_fee = Fee.new( + invoice:, + organization_id: invoice.organization_id, + billing_entity_id: invoice.billing_entity_id, + subscription:, + amount_cents: new_amount_cents, + precise_amount_cents: new_precise_amount_cents.to_d, + amount_currency: plan.amount_currency, + fee_type: :subscription, + invoiceable_type: "Subscription", + invoiceable: subscription, + units: 1, + properties: boundaries.to_h, + payment_status: :pending, + taxes_amount_cents: 0, + unit_amount_cents: new_amount_cents, + amount_details: {plan_amount_cents: plan.amount_cents} + ) + base_fee.precise_unit_amount = base_fee.unit_amount.to_f + + return base_fee if !invoice.draft? || !adjusted_fee + + if adjusted_fee.adjusted_display_name? + base_fee.invoice_display_name = adjusted_fee.invoice_display_name + + return base_fee + end + + units = adjusted_fee.units + unit_precise_amount_cents = adjusted_fee.unit_precise_amount_cents + amount_cents = adjusted_fee.adjusted_units? ? (units * new_amount_cents) : (units * unit_precise_amount_cents).round + + precise_amount_cents = if adjusted_fee.adjusted_units? + (units * new_precise_amount_cents) + else + (units * unit_precise_amount_cents) + end + + base_fee.amount_cents = amount_cents.round + base_fee.precise_amount_cents = precise_amount_cents + base_fee.units = units + precise_unit_amount_cents = adjusted_fee.adjusted_units? ? new_amount_cents : unit_precise_amount_cents + base_fee.unit_amount_cents = precise_unit_amount_cents.round + base_fee.precise_unit_amount = precise_unit_amount_cents / invoice.total_amount.currency.subunit_to_unit + base_fee.invoice_display_name = adjusted_fee.invoice_display_name + + base_fee + end + + def adjusted_fee + return @adjusted_fee if defined? @adjusted_fee + + @adjusted_fee = AdjustedFee + .where(invoice:, subscription:, fee_type: :subscription) + .where("properties->>'from_datetime' = ?", boundaries.from_datetime&.iso8601(3)) + .where("properties->>'to_datetime' = ?", boundaries.to_datetime&.iso8601(3)) + .first + end + + def already_billed? + existing_fee = invoice.fees.subscription.find_by(subscription_id: subscription.id) + return false unless existing_fee + + result.fee = existing_fee + true + end + + def compute_amount + # NOTE: bill for the last time a subscription that was upgraded + return terminated_amount if should_compute_terminated_amount? + + # NOTE: bill for the first time a subscription created after an upgrade + return upgraded_amount if should_compute_upgraded_amount? + + # NOTE: bill a subscription on a full period + return full_period_amount if should_use_full_amount? + + # NOTE: bill a subscription for the first time (or after downgrade) + first_subscription_amount + end + + def should_compute_terminated_amount? + return false unless subscription.terminated? + return false if subscription.plan.pay_in_advance? + + subscription.upgraded? || subscription.next_subscription.nil? + end + + def should_compute_upgraded_amount? + return false unless subscription.previous_subscription_id? + return false if subscription.invoices.count > 1 + + subscription.previous_subscription.upgraded? + end + + # NOTE: Subscription has already been billed once and is not terminated + # or when it is payed in advance on an anniversary base + def should_use_full_amount? + # First condition covers case when plan is pay in advance and on anniversary base. + # This case is used for the first subscription invoice since following cases will cover recurring invoices. + # However, we should not bill full amount if subscription is downgraded since in that case, first invoice + # should be prorated (this part is covered with first_subscription_amount method). + return true if plan.pay_in_advance? && subscription.anniversary? && !subscription.previous_subscription_id + return true if subscription.fees.subscription.where("created_at < ?", invoice.created_at).exists? + return true if subscription.started_in_past? && plan.pay_in_advance? + + if subscription.started_in_past? && + subscription.started_at < date_service(subscription).previous_beginning_of_period + return true + end + + false + end + + def first_subscription_amount + from_datetime = boundaries.from_datetime + to_datetime = boundaries.to_datetime + + if plan.has_trial? + # NOTE: amount is 0 if trial cover the full period + return 0 if subscription.trial_end_date >= to_datetime + + # NOTE: from_date is the trial end date if it happens during the period + if (subscription.trial_end_date > from_datetime) && (subscription.trial_end_date < to_datetime) + from_datetime = subscription.initial_started_at + plan.trial_period.days + end + end + + # NOTE: Number of days of the first period since subscription creation + days_to_bill = Utils::Datetime.date_diff_with_timezone( + from_datetime, + to_datetime, + customer.applicable_timezone + ) + days_to_bill * single_day_price(subscription) + end + + # NOTE: When terminating a pay in arrerar subscription, we need to + # bill the number of used day of the terminated subscription. + # + # The amount to bill is computed with: + # **nb_day** = number of days between beggining of the period and the termination date + # **day_cost** = (plan amount_cents / full period duration) + # amount_to_bill = (nb_day * day_cost) + def terminated_amount + from_datetime = boundaries.from_datetime + to_datetime = boundaries.to_datetime + + if plan.has_trial? + # NOTE: amount is 0 if trial cover the full period + return 0 if subscription.trial_end_datetime >= to_datetime + + # NOTE: from_date is the trial end date if it happens during the period + if (subscription.trial_end_datetime > from_datetime) && (subscription.trial_end_datetime < to_datetime) + from_datetime = subscription.trial_end_datetime + end + end + + # NOTE: number of days between beginning of the period and the termination date + number_of_day_to_bill = subscription.date_diff_with_timezone(from_datetime, to_datetime) + + # Remove later customer timezone fix while passing optional_from_date + # single_day_price method should return correct amount even without the timezone fix since + # date service should not calculate single_day_price based on difference between dates but more as a + # difference between date-times + number_of_day_to_bill * + single_day_price( + subscription, + optional_from_date: from_datetime.in_time_zone(customer.applicable_timezone).to_date + ) + end + + def upgraded_amount + from_datetime = boundaries.from_datetime + to_datetime = boundaries.to_datetime + + if plan.has_trial? + return 0 if subscription.trial_end_datetime >= to_datetime + + # NOTE: from_date is the trial end date if it happens during the period + if (subscription.trial_end_datetime > from_datetime) && (subscription.trial_end_datetime < to_datetime) + from_datetime = subscription.trial_end_datetime + end + end + + # NOTE: number of days between the upgrade and the end of the period + number_of_day_to_bill = Utils::Datetime.date_diff_with_timezone( + from_datetime, + to_datetime, + customer.applicable_timezone + ) + + # NOTE: Subscription is upgraded from another plan + # We only bill the days between the upgrade date and the end of the period + # A credit note will apply automatically the amount of days from previous plan that were not consumed + # + # The amount to bill is computed with: + # **nb_day** = number of days between upgrade and the end of the period + # **day_cost** = (plan amount_cents / full period duration) + # amount_to_bill = (nb_day * day_cost) + number_of_day_to_bill * single_day_price(subscription) + end + + def full_period_amount + from_datetime = boundaries.from_datetime + to_datetime = boundaries.to_datetime + + if plan.has_trial? + # NOTE: amount is 0 if trial cover the full period + return 0 if subscription.trial_end_datetime >= to_datetime + + # NOTE: from_date is the trial end date if it happens during the period + # for this case, we should not apply the full period amount + # but the prorata between the trial end date end the invoice to_date + if (subscription.trial_end_datetime > from_datetime) && (subscription.trial_end_datetime < to_datetime) + number_of_day_to_bill = Utils::Datetime.date_diff_with_timezone( + subscription.trial_end_datetime, + to_datetime, + customer.applicable_timezone + ) + + return number_of_day_to_bill * single_day_price( + subscription, + optional_from_date: from_datetime.in_time_zone(customer.applicable_timezone).to_date + ) + end + end + + plan.amount_cents + end + + def date_service(subscription) + Subscriptions::DatesService.new_instance(subscription, Time.zone.at(boundaries.timestamp)) + end + + # NOTE: cost of a single day in a period + def single_day_price(target_subscription, optional_from_date: nil) + date_service(target_subscription).single_day_price( + optional_from_date: + ) + end + end +end diff --git a/app/services/fees/update_service.rb b/app/services/fees/update_service.rb new file mode 100644 index 0000000..c8c3b4f --- /dev/null +++ b/app/services/fees/update_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Fees + class UpdateService < BaseService + def initialize(fee:, params:) + @fee = fee + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "fee") if fee.nil? + + if params.key?(:payment_status) + # Once a fee is attached to an invoice, the payment status is irrelevant, it must be the same as the invoice + return result.not_allowed_failure!(code: "invoiced_fee") if fee.invoice_id + + unless valid_payment_status?(params[:payment_status]) + return result.single_validation_failure!( + field: :payment_status, + error_code: "value_is_invalid" + ) + end + + update_payment_status(params[:payment_status]) + end + + fee.save! + + result.fee = fee + result + end + + private + + attr_reader :fee, :params + + def valid_payment_status?(payment_status) + Fee::PAYMENT_STATUS.include?(payment_status&.to_sym) + end + + def update_payment_status(payment_status) + fee.payment_status = payment_status + + # NOTE: A fee can go from pending to failed to pending to succeeded. + # We only want the timestamp associated with the current status to be set. + fee.succeeded_at = nil + fee.failed_at = nil + fee.refunded_at = nil + + case payment_status.to_sym + when :succeeded + fee.succeeded_at = Time.current + when :failed + fee.failed_at = Time.current + when :refunded + fee.refunded_at = Time.current + end + end + end +end diff --git a/app/services/fixed_charge_events/aggregations/base_service.rb b/app/services/fixed_charge_events/aggregations/base_service.rb new file mode 100644 index 0000000..d1c9a2a --- /dev/null +++ b/app/services/fixed_charge_events/aggregations/base_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module FixedChargeEvents + module Aggregations + class BaseService < BaseService + Result = BaseResult[:count, :aggregation, :current_usage_units, :full_units_number, :total_aggregated_units, :aggregator, :grouped_by] + PerEventAggregationResult = BaseResult[:event_aggregation] + + def initialize(fixed_charge:, subscription:, boundaries:) + @fixed_charge = fixed_charge + @subscription = subscription + @customer = subscription.customer + @from_datetime = boundaries["fixed_charges_from_datetime"] + @to_datetime = boundaries["fixed_charges_to_datetime"] + @charges_duration = boundaries["fixed_charges_duration"] + + super(nil) + result.aggregator = self + end + + def call + raise NotImplementedError + end + + def per_event_aggregation + PerEventAggregationResult.new.tap do |result| + result.event_aggregation = compute_per_event_aggregation + end + end + + private + + attr_reader :fixed_charge, :subscription, :from_datetime, :to_datetime, :customer, :charges_duration + + def base_events + @events ||= FixedChargeEvent.where(fixed_charge: [fixed_charge, fixed_charge.parent], subscription:) + end + + def events_in_range + @events_in_range ||= begin + events_in_period_ids = base_events.where("timestamp >= ? AND timestamp < ?", from_datetime, to_datetime).ids + last_event_before_range_id = base_events.where("timestamp < ?", from_datetime).order(created_at: :desc).limit(1).ids + + FixedChargeEvent.where(id: events_in_period_ids + last_event_before_range_id) + .order(created_at: :asc) + end + end + end + end +end diff --git a/app/services/fixed_charge_events/aggregations/preview_aggregation_service.rb b/app/services/fixed_charge_events/aggregations/preview_aggregation_service.rb new file mode 100644 index 0000000..4f1309e --- /dev/null +++ b/app/services/fixed_charge_events/aggregations/preview_aggregation_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module FixedChargeEvents + module Aggregations + class PreviewAggregationService < BaseService + def call + units = if fixed_charge.prorated? + calculate_prorated_units + else + fixed_charge.units + end + + result.aggregation = units + result.full_units_number = fixed_charge.units + result + end + + private + + def calculate_prorated_units + from_date = from_datetime.to_date + to_date = to_datetime.to_date + + billing_period_days = (to_date - from_date).to_i + 1 + full_period_days = charges_duration || billing_period_days + + return fixed_charge.units if billing_period_days >= full_period_days + + # Prorate units based on the ratio of billing period to full period + fixed_charge.units * (billing_period_days.to_f / full_period_days) + end + end + end +end diff --git a/app/services/fixed_charge_events/aggregations/prorated_aggregation_service.rb b/app/services/fixed_charge_events/aggregations/prorated_aggregation_service.rb new file mode 100644 index 0000000..8bdee2a --- /dev/null +++ b/app/services/fixed_charge_events/aggregations/prorated_aggregation_service.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +module FixedChargeEvents + module Aggregations + class ProratedAggregationService < BaseService + PerEventAggregationResult = BaseResult[:event_aggregation, :event_prorated_aggregation] + + def call + sql = ActiveRecord::Base.sanitize_sql_for_conditions( + [ + prorated_query, + { + from_datetime:, + to_datetime:, + to_datetime_excluded: to_datetime + 1.day, + timezone: customer.applicable_timezone + } + ] + ) + sql_result = ActiveRecord::Base.connection.select_one(sql) + result.aggregation = sql_result["aggregation"] + result.full_units_number = events_in_range.last.try(:units) || 0 + result + end + + # we need this for prorated charge_model to be correctly applied + def per_event_aggregation(grouped_by_values: nil) + prorated_units_count = result.aggregation + full_units_count = result.full_units_number + PerEventAggregationResult.new.tap do |result| + result.event_aggregation = [full_units_count] + result.event_prorated_aggregation = [prorated_units_count] + end + end + + private + + # in this query we: + # 1. select event's created at, event's timestamp, event's units for all events with timestamp in this period + + # last event that was created before the period with timestamp before the period + # (so the one that was "active" before first event IN this billing_period) with fixed_charge_events_cte_sql + # 2. then we filter out events, that were created "for later" - Event1: created_at: 05.01, timestamp: 01.02, + # but can be ignored because of events, created after: Event2: created_at: 20.01, timestamp: 20.01. + # 3. for each event we calculate weighted_units = units * period_ratio, where period_ratio is + # how long this event was "effective" in this period comparing to the full duration of the period. + # 4. we sum up all weighted_units to get the final aggregation. + def prorated_query + <<-SQL + #{fixed_charge_events_cte_sql}, + fixed_charge_events_ignored AS ( + SELECT * FROM ( + SELECT *, + CASE WHEN #{later_event_earlier_timestamp_sql} THEN true ELSE false END as is_ignored_event + FROM fixed_charge_events_data + ) cumulated_ratios + WHERE is_ignored_event = false + ) + + SELECT COALESCE(SUM(weighted_units), 0) AS aggregation + FROM ( + SELECT CASE WHEN (#{period_ratio_sql} * units) < 0 THEN 0 ELSE ROUND(#{period_ratio_sql} * units, 6) END AS weighted_units + FROM fixed_charge_events_ignored + ) cumulated_ratios + SQL + end + + # this query is used to debug the prorated aggregation. instead of returning sum of weighted units for all events, + # it returns for each event: the weighted units, start date of this event being "effective" and end date of this period, also units. + def debug_query + <<-SQL + #{fixed_charge_events_cte_sql}, + fixed_charge_events_ignored AS ( + SELECT * FROM ( + SELECT *, + CASE WHEN #{later_event_earlier_timestamp_sql} THEN true ELSE false END as is_ignored_event + FROM fixed_charge_events_data + ) cumulated_ratios + WHERE is_ignored_event = false + ) + + SELECT weighted_units, period_start, period_end, units + FROM ( + SELECT CASE WHEN (#{period_ratio_sql} * units) < 0 THEN 0 ELSE (#{period_ratio_sql} * units) END AS weighted_units, + #{period_start} AS period_start, + #{period_end} AS period_end, + units + FROM fixed_charge_events_ignored + ) cumulated_ratios + SQL + end + + def fixed_charge_events_cte_sql + # NOTE: Common table expression returning event's timestamp, units + <<-SQL + WITH fixed_charge_events_data AS (#{ + events_in_range + .select( + "timestamp, \ + created_at, \ + units" + ).to_sql + }) + SQL + end + + def later_event_earlier_timestamp_sql + <<-SQL + ( + SELECT + 1 + FROM fixed_charge_events_data next_event + WHERE next_event.timestamp < fixed_charge_events_data.timestamp + AND next_event.created_at > fixed_charge_events_data.created_at + LIMIT 1 + ) = 1 + SQL + end + + def period_ratio_sql + <<-SQL + ( + ( + -- define the end of the period + #{period_end} + -- define the start of the period + - #{period_start} + )::numeric + ) + / + -- NOTE: full duration of the period + #{charges_duration || 1}::numeric + SQL + end + + def period_end + <<-SQL + DATE(( + -- NOTE: if following event is older than the start of the period, we use the start of the period as the reference + CASE WHEN (LEAD(timestamp, 1, :to_datetime_excluded) OVER (ORDER BY created_at)) < :from_datetime + THEN :from_datetime + ELSE LEAD(timestamp, 1, :to_datetime_excluded ) OVER (ORDER BY created_at) + END + )::timestamptz AT TIME ZONE :timezone) + SQL + end + + def period_start + <<-SQL + DATE(( + -- NOTE: if events is older than the start of the period, we use the start of the period as the reference + CASE WHEN timestamp < :from_datetime THEN :from_datetime ELSE timestamp END + )::timestamptz AT TIME ZONE :timezone) + SQL + end + end + end +end diff --git a/app/services/fixed_charge_events/aggregations/simple_aggregation_service.rb b/app/services/fixed_charge_events/aggregations/simple_aggregation_service.rb new file mode 100644 index 0000000..c89291e --- /dev/null +++ b/app/services/fixed_charge_events/aggregations/simple_aggregation_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module FixedChargeEvents + module Aggregations + class SimpleAggregationService < BaseService + def call + result.aggregation = events_in_range.last.try(:units) || 0 + result.full_units_number = events_in_range.last.try(:units) || 0 + result + end + end + end +end diff --git a/app/services/fixed_charge_events/create_service.rb b/app/services/fixed_charge_events/create_service.rb new file mode 100644 index 0000000..27a9222 --- /dev/null +++ b/app/services/fixed_charge_events/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module FixedChargeEvents + class CreateService < BaseService + Result = BaseResult[:fixed_charge_event] + + def initialize(subscription:, fixed_charge:, timestamp: Time.current) + @organization = subscription&.organization + @subscription = subscription + @fixed_charge = fixed_charge + @timestamp = timestamp + super + end + + def call + fixed_charge_event = FixedChargeEvent.create!( + organization:, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: + ) + + result.fixed_charge_event = fixed_charge_event + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :subscription, :fixed_charge, :timestamp + end +end diff --git a/app/services/fixed_charges/apply_taxes_service.rb b/app/services/fixed_charges/apply_taxes_service.rb new file mode 100644 index 0000000..6d2e3ed --- /dev/null +++ b/app/services/fixed_charges/apply_taxes_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module FixedCharges + class ApplyTaxesService < BaseService + Result = BaseResult[:applied_taxes] + + def initialize(fixed_charge:, tax_codes:) + @fixed_charge = fixed_charge + @tax_codes = tax_codes + + super + end + + def call + return result.not_found_failure!(resource: "fixed_charge") unless fixed_charge + return result.not_found_failure!(resource: "tax") if (tax_codes - taxes.pluck(:code)).present? + + fixed_charge.applied_taxes.where( + tax_id: fixed_charge.taxes.where.not(code: tax_codes).pluck(:id) + ).destroy_all + + result.applied_taxes = tax_codes.map do |tax_code| + fixed_charge.applied_taxes + .create_with(organization_id: fixed_charge.organization_id) + .find_or_create_by!(tax: taxes.find_by(code: tax_code)) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :fixed_charge, :tax_codes + + def taxes + @taxes ||= fixed_charge.plan.organization.taxes.where(code: tax_codes) + end + end +end diff --git a/app/services/fixed_charges/cascade_child_plan_update_service.rb b/app/services/fixed_charges/cascade_child_plan_update_service.rb new file mode 100644 index 0000000..ed2f163 --- /dev/null +++ b/app/services/fixed_charges/cascade_child_plan_update_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module FixedCharges + class CascadeChildPlanUpdateService < BaseService + Result = BaseResult[:plan] + UnknownActionError = Class.new(StandardError) + + def initialize(plan:, cascade_fixed_charges_payload:, timestamp:) + @plan = plan + @cascade_fixed_charges_payload = cascade_fixed_charges_payload.map(&:deep_symbolize_keys) + @timestamp = timestamp.to_i + + super + end + + def call + return result if cascade_fixed_charges_payload.empty? + + ActiveRecord::Base.transaction do + # skip touching to avoid deadlocks + Plan.no_touching do + cascade_fixed_charges_payload.each do |payload| + case payload[:action]&.to_sym + when :create + FixedCharges::CreateService.call!(plan:, params: payload, timestamp:) + + when :update + fixed_charge = plan.fixed_charges.find_by!(parent_id: payload[:id]) + + old_parent = FixedCharge.new(payload[:old_parent_attrs]) + + FixedCharges::UpdateService.call!( + fixed_charge:, + params: payload, + timestamp:, + cascade_options: { + cascade: true, + equal_properties: old_parent.equal_properties?(fixed_charge) + }, + trigger_billing: false + ) + else + raise UnknownActionError, "Unknown action #{payload[:action]} for fixed charge cascade" + end + end + end + end + + jobs = [] + if plan.fixed_charges.pay_in_advance.exists? + plan.subscriptions.active.find_each do |subscription| + jobs << Invoices::CreatePayInAdvanceFixedChargesJob.new( + subscription, + timestamp + ) + end + end + + after_commit do + ActiveJob.perform_all_later(jobs) + end + + result.plan = plan + result + rescue UnknownActionError => e + result.fail_with_error!(e.message) + rescue ActiveRecord::RecordNotFound => e + result.not_found_failure!(resource: e.model.underscore) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :plan, :cascade_fixed_charges_payload, :timestamp + end +end diff --git a/app/services/fixed_charges/cascade_updatable.rb b/app/services/fixed_charges/cascade_updatable.rb new file mode 100644 index 0000000..069108f --- /dev/null +++ b/app/services/fixed_charges/cascade_updatable.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module FixedCharges + module CascadeUpdatable + extend ActiveSupport::Concern + + private + + def trigger_cascade(old_parent_attrs: nil) + return unless cascade_updates + return unless fixed_charge.children.exists? + + FixedCharges::UpdateChildrenJob.perform_later( + params: build_cascade_params.deep_stringify_keys, + old_parent_attrs: old_parent_attrs || fixed_charge.attributes + ) + end + + def build_cascade_params + { + code: fixed_charge.code, + charge_model: fixed_charge.charge_model, + properties: fixed_charge.properties, + units: fixed_charge.units + } + end + end +end diff --git a/app/services/fixed_charges/create_children_service.rb b/app/services/fixed_charges/create_children_service.rb new file mode 100644 index 0000000..0da6852 --- /dev/null +++ b/app/services/fixed_charges/create_children_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module FixedCharges + class CreateChildrenService < BaseService + Result = BaseResult[:fixed_charge] + + def initialize(child_ids:, fixed_charge:, payload:) + @fixed_charge = fixed_charge + @payload = payload.deep_symbolize_keys + @child_ids = child_ids + super + end + + def call + return result.not_found_failure!(resource: "fixed_charge") unless fixed_charge + + ActiveRecord::Base.transaction do + # skip touching to avoid deadlocks + Plan.no_touching do + plan.children.where(id: child_ids).find_each do |child| + create_params = if payload[:code].present? + payload + else + payload.merge(code: fixed_charge.code) + end + FixedCharges::CreateService.call!(plan: child, params: create_params.merge(parent_id: fixed_charge.id)) + end + end + end + + result.fixed_charge = fixed_charge + result + end + + private + + attr_reader :fixed_charge, :payload, :child_ids + + delegate :plan, to: :fixed_charge + end +end diff --git a/app/services/fixed_charges/create_service.rb b/app/services/fixed_charges/create_service.rb new file mode 100644 index 0000000..4e2e1a6 --- /dev/null +++ b/app/services/fixed_charges/create_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module FixedCharges + class CreateService < BaseService + Result = BaseResult[:fixed_charge] + + def initialize(plan:, params:, timestamp: Time.current.to_i, cascade_updates: false) + @plan = plan + @params = params + @timestamp = timestamp.to_i + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "plan") unless plan + + ActiveRecord::Base.transaction do + fixed_charge = plan.fixed_charges.new( + organization_id: plan.organization_id, + add_on_id: add_on.id, + code: params[:code], + invoice_display_name: params[:invoice_display_name], + charge_model: params[:charge_model], + parent_id: params[:parent_id], + pay_in_advance: params[:pay_in_advance] || false, + prorated: params[:prorated] || false, + units: params[:units] || 0 + ) + + properties = params[:properties].presence || ChargeModels::BuildDefaultPropertiesService.call(fixed_charge.charge_model) + fixed_charge.properties = ChargeModels::FilterPropertiesService.call( + chargeable: fixed_charge, + properties: + ).properties + + fixed_charge.save! + + if params[:tax_codes] + taxes_result = FixedCharges::ApplyTaxesService.call(fixed_charge:, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + FixedCharges::EmitEventsService.call!( + fixed_charge:, + apply_units_immediately: !!params[:apply_units_immediately], + timestamp: + ) + + result.fixed_charge = fixed_charge + end + + if cascade_updates && result.success? && plan.children.exists? + FixedCharges::CreateChildrenJob.perform_later(fixed_charge: result.fixed_charge, payload: params) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.single_validation_failure!(field: :code, error_code: "value_already_exist") + rescue ActiveRecord::RecordNotFound => e + result.not_found_failure!(resource: e.model.underscore) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :plan, :params, :timestamp, :cascade_updates + + delegate :organization, to: :plan + + def add_on + @add_on ||= if params[:add_on_id].present? + organization.add_ons.find(params[:add_on_id]) + elsif params[:add_on_code].present? + organization.add_ons.find_by!(code: params[:add_on_code]) + else + raise ArgumentError, "Either add_on_id or add_on_code must be provided" + end + end + end +end diff --git a/app/services/fixed_charges/destroy_children_service.rb b/app/services/fixed_charges/destroy_children_service.rb new file mode 100644 index 0000000..adcfaed --- /dev/null +++ b/app/services/fixed_charges/destroy_children_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module FixedCharges + class DestroyChildrenService < BaseService + Result = BaseResult[:fixed_charge] + + def initialize(fixed_charge) + @fixed_charge = fixed_charge + super + end + + def call + return result unless fixed_charge + return result unless fixed_charge.discarded? + + ActiveRecord::Base.transaction do + # skip touching to avoid deadlocks + Plan.no_touching do + fixed_charge.children.joins(plan: :subscriptions).where(subscriptions: {status: %w[active pending]}).distinct.find_each do |child_fixed_charge| + FixedCharges::DestroyService.call!(fixed_charge: child_fixed_charge) + end + end + end + + result.fixed_charge = fixed_charge + result + end + + private + + attr_reader :fixed_charge + end +end diff --git a/app/services/fixed_charges/destroy_service.rb b/app/services/fixed_charges/destroy_service.rb new file mode 100644 index 0000000..932d0f0 --- /dev/null +++ b/app/services/fixed_charges/destroy_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module FixedCharges + class DestroyService < BaseService + Result = BaseResult[:fixed_charge] + + def initialize(fixed_charge:, cascade_updates: false) + @fixed_charge = fixed_charge + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "fixed_charge") unless fixed_charge + + fixed_charge.discard! + result.fixed_charge = fixed_charge + + if cascade_updates && fixed_charge.children.exists? + FixedCharges::DestroyChildrenJob.perform_later(fixed_charge.id) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + rescue Discard::RecordNotDiscarded => e + result.service_failure!(code: "fixed_charge_already_deleted", message: e.message) + end + + private + + attr_reader :fixed_charge, :cascade_updates + end +end diff --git a/app/services/fixed_charges/emit_events_service.rb b/app/services/fixed_charges/emit_events_service.rb new file mode 100644 index 0000000..52bb695 --- /dev/null +++ b/app/services/fixed_charges/emit_events_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module FixedCharges + class EmitEventsService < BaseService + Result = BaseResult[:fixed_charge_events] + + def initialize(fixed_charge:, subscription: nil, apply_units_immediately: false, timestamp: Time.current.to_i) + @fixed_charge = fixed_charge + @subscription = subscription + @apply_units_immediately = !!apply_units_immediately + @timestamp = Time.zone.at(timestamp.to_i) + super + end + + def call + result.fixed_charge_events = subscriptions.map do |subscription| + ::FixedChargeEvents::CreateService.call!( + subscription:, + fixed_charge:, + timestamp: apply_units_immediately ? timestamp : next_billing_period(subscription) + ).fixed_charge_event + end + + result + end + + private + + attr_reader :fixed_charge, :subscription, :apply_units_immediately, :timestamp + + def subscriptions + # When a specific subscription is provided, emit event for that subscription only + # This handles cases like plan overrides where the subscription hasn't been updated yet + # otherwise, emit events for all active subscriptions on the plan + if subscription + # Emit events for active and incomplete subscriptions + # Pending subscriptions will have events created when they activate + (subscription.active? || subscription.incomplete?) ? [subscription] : [] + else + fixed_charge.plan.subscriptions.where(status: %i[active incomplete]) + end + end + + def next_billing_period(subscription) + ::Subscriptions::DatesService.new_instance(subscription, timestamp, current_usage: true).fixed_charges_to_datetime + 1.second + end + end +end diff --git a/app/services/fixed_charges/generate_code_service.rb b/app/services/fixed_charges/generate_code_service.rb new file mode 100644 index 0000000..dc4d5d4 --- /dev/null +++ b/app/services/fixed_charges/generate_code_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module FixedCharges + class GenerateCodeService < BaseService + Result = BaseResult[:code] + + def initialize(plan:, add_on:) + @plan = plan + @add_on = add_on + + super + end + + def call + result.code = generate_unique_code + result + end + + private + + attr_reader :plan, :add_on + + def generate_unique_code + base_code = add_on.code + + return base_code unless plan.fixed_charges.parents.exists?(code: base_code) + + existing_suffixes = plan.fixed_charges.parents + .where("code ~ ?", "^#{Regexp.escape(base_code)}_\\d+$") + .pluck(:code) + .map { |code| code.delete_prefix("#{base_code}_").to_i } + + next_suffix = (existing_suffixes.max || 1) + 1 + + "#{base_code}_#{next_suffix}" + end + end +end diff --git a/app/services/fixed_charges/override_service.rb b/app/services/fixed_charges/override_service.rb new file mode 100644 index 0000000..9756367 --- /dev/null +++ b/app/services/fixed_charges/override_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module FixedCharges + class OverrideService < BaseService + Result = BaseResult[:fixed_charge] + + def initialize(fixed_charge:, params:, subscription: nil) + @fixed_charge = fixed_charge + @params = params + @subscription = subscription + + super + end + + def call + return result unless License.premium? + return result.forbidden_failure!(code: "cannot_override_charge_model") if params[:charge_model] && fixed_charge.charge_model != params[:charge_model] + + ActiveRecord::Base.transaction do + new_fixed_charge = fixed_charge.dup.tap do |c| + if params.key?(:properties) + properties = params[:properties].presence + c.properties = ChargeModels::FilterPropertiesService.call( + chargeable: fixed_charge, + properties: + ).properties + end + c.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + c.units = params[:units] if params.key?(:units) + c.parent_id = fixed_charge.id + c.plan_id = params[:plan_id] + end + new_fixed_charge.save! + + FixedCharges::EmitEventsService.call!( + fixed_charge: new_fixed_charge, + subscription:, + apply_units_immediately: !!params[:apply_units_immediately] + ) + + if params.key?(:tax_codes) + taxes_result = FixedCharges::ApplyTaxesService.call(fixed_charge: new_fixed_charge, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + result.fixed_charge = new_fixed_charge + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :fixed_charge, :params, :subscription + end +end diff --git a/app/services/fixed_charges/update_children_service.rb b/app/services/fixed_charges/update_children_service.rb new file mode 100644 index 0000000..b05af06 --- /dev/null +++ b/app/services/fixed_charges/update_children_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module FixedCharges + class UpdateChildrenService < BaseService + Result = BaseResult[:fixed_charge] + + def initialize(fixed_charge:, params:, old_parent_attrs:, child_ids:) + @fixed_charge = fixed_charge + @params = params + @old_parent = FixedCharge.new(old_parent_attrs) + @child_ids = child_ids + + super + end + + def call + return result unless fixed_charge + + ActiveRecord::Base.transaction do + Plan.no_touching do + fixed_charge.children.where(id: child_ids).find_each do |child_fixed_charge| + FixedCharges::UpdateService.call!( + fixed_charge: child_fixed_charge, + params:, + timestamp: Time.current.to_i, + cascade_options: { + cascade: true, + equal_properties: old_parent.equal_properties?(child_fixed_charge) + }, + trigger_billing: false + ) + end + end + end + + result.fixed_charge = fixed_charge + result + end + + private + + attr_reader :fixed_charge, :params, :old_parent, :child_ids + end +end diff --git a/app/services/fixed_charges/update_service.rb b/app/services/fixed_charges/update_service.rb new file mode 100644 index 0000000..b8bdb48 --- /dev/null +++ b/app/services/fixed_charges/update_service.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module FixedCharges + class UpdateService < BaseService + include CascadeUpdatable + + Result = BaseResult[:fixed_charge] + + def initialize(fixed_charge:, params:, timestamp:, cascade_options: {}, trigger_billing: true, cascade_updates: false) + @fixed_charge = fixed_charge + @params = params.to_h.deep_symbolize_keys + @cascade_options = cascade_options + @cascade = cascade_options[:cascade] + @timestamp = timestamp + @trigger_billing = trigger_billing + @cascade_updates = cascade_updates + + super + end + + def call + return result.not_found_failure!(resource: "fixed_charge") unless fixed_charge + return result if cascade && fixed_charge.charge_model != params[:charge_model] + + old_parent_attrs = fixed_charge.attributes.deep_dup + + ActiveRecord::Base.transaction do + # Note: when updating a fixed_charge, we can't update pay_in_advance and prorated, + fixed_charge.charge_model = params[:charge_model] unless plan.attached_to_subscriptions? + fixed_charge.invoice_display_name = params[:invoice_display_name] unless cascade + fixed_charge.code = params[:code] if cascade && params[:code].present? + + if !cascade || cascade_options[:equal_properties] + fixed_charge.units = params[:units] + properties = params.delete(:properties).presence || ChargeModels::BuildDefaultPropertiesService.call( + params[:charge_model] + ) + fixed_charge.properties = ChargeModels::FilterPropertiesService.call(chargeable: fixed_charge, properties:).properties + end + + fixed_charge.save! + result.fixed_charge = fixed_charge + + if fixed_charge.units_previously_changed? + FixedCharges::EmitEventsService.call!( + fixed_charge:, + apply_units_immediately: params[:apply_units_immediately], + timestamp: + ) + + if trigger_billing && params[:apply_units_immediately] && fixed_charge.pay_in_advance? + plan.subscriptions.active.find_each do |subscription| + after_commit do + Invoices::CreatePayInAdvanceFixedChargesJob.perform_later(subscription, timestamp) + end + end + end + end + + unless cascade || plan.attached_to_subscriptions? + code = params.delete(:code) + fixed_charge.code = code if code.present? + fixed_charge.save! + + if (tax_codes = params.delete(:tax_codes)) + taxes_result = FixedCharges::ApplyTaxesService.call(fixed_charge:, tax_codes:) + taxes_result.raise_if_error! + end + end + end + + trigger_cascade(old_parent_attrs:) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.single_validation_failure!(field: :code, error_code: "value_already_exist") + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :fixed_charge, :params, :cascade_options, :cascade, :timestamp, :trigger_billing, :cascade_updates + + delegate :plan, to: :fixed_charge + end +end diff --git a/app/services/idempotency_records/create_service.rb b/app/services/idempotency_records/create_service.rb new file mode 100644 index 0000000..4651d54 --- /dev/null +++ b/app/services/idempotency_records/create_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IdempotencyRecords + class CreateService < BaseService + Result = BaseResult[:idempotency_record] + + def initialize(idempotency_key:, resource: nil) + @idempotency_key = idempotency_key + @resource = resource + + super + end + + def call + ApplicationRecord.transaction do + idempotency_record = IdempotencyRecord.create!( + organization_id: resource&.organization_id, + idempotency_key: idempotency_key, + resource: resource + ) + + result.idempotency_record = idempotency_record + end + result + rescue ActiveRecord::RecordNotUnique + # Return an error when a record with this idempotency key already exists + result.single_validation_failure!( + field: :idempotency_key, + error_code: "already_exists" + ) + end + + private + + attr_reader :idempotency_key, :resource + end +end diff --git a/app/services/idempotency_records/key_service.rb b/app/services/idempotency_records/key_service.rb new file mode 100644 index 0000000..bcb87cd --- /dev/null +++ b/app/services/idempotency_records/key_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module IdempotencyRecords + class KeyService < BaseService + Result = BaseResult[:idempotency_key] + + # WARNING: changing this value is very dangerous! + # only do this if you really have to. + # Uniqueness for existing values can no longer be enforced once this is changed + KEY_VERSION = "v1" + SEPARATOR = "|" + + def initialize(**key_parts) + @key_parts = key_parts + + super() + end + + def call + string_to_digest = key_parts.sort.map { |k, v| "#{k}#{v}" }.join(SEPARATOR) + result.idempotency_key = Digest::SHA256.digest("#{KEY_VERSION}#{SEPARATOR}#{string_to_digest}") + result + end + + private + + attr_reader :key_parts + end +end diff --git a/app/services/inbound_webhooks/create_service.rb b/app/services/inbound_webhooks/create_service.rb new file mode 100644 index 0000000..450baba --- /dev/null +++ b/app/services/inbound_webhooks/create_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module InboundWebhooks + class CreateService < BaseService + def initialize(organization_id:, webhook_source:, payload:, event_type:, code: nil, signature: nil) + @organization_id = organization_id + @webhook_source = webhook_source + @code = code + @payload = payload + @signature = signature + @event_type = event_type + + super + end + + def call + return validate_payload_result unless validate_payload_result.success? + + inbound_webhook = InboundWebhook.create!( + organization_id:, + source: webhook_source, + code:, + payload:, + signature:, + event_type: + ) + + InboundWebhooks::ProcessJob.perform_later(inbound_webhook:) + + result.inbound_webhook = inbound_webhook + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization_id, :webhook_source, :code, :payload, :signature, :event_type + + def validate_payload_result + @validate_payload_result ||= InboundWebhooks::ValidatePayloadService.call( + organization_id:, + code:, + payload:, + signature:, + webhook_source: + ) + end + end +end diff --git a/app/services/inbound_webhooks/process_service.rb b/app/services/inbound_webhooks/process_service.rb new file mode 100644 index 0000000..d4c8a7f --- /dev/null +++ b/app/services/inbound_webhooks/process_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module InboundWebhooks + class ProcessService < BaseService + WEBHOOK_HANDLER_SERVICES = { + stripe: PaymentProviders::Stripe::HandleIncomingWebhookService, + moneyhash: PaymentProviders::Moneyhash::HandleIncomingWebhookService + } + + def initialize(inbound_webhook:) + @inbound_webhook = inbound_webhook + + super + end + + def call + return result if within_processing_window? + return result if inbound_webhook.failed? + return result if inbound_webhook.succeeded? + + inbound_webhook.processing! + + handler_result = handler_service_klass.call(inbound_webhook:) + + unless handler_result.success? + inbound_webhook.failed! + return handler_result + end + + inbound_webhook.succeeded! + + result.inbound_webhook = inbound_webhook + result + rescue + inbound_webhook.failed! + raise + end + + private + + attr_reader :inbound_webhook + + def handler_service_klass + WEBHOOK_HANDLER_SERVICES.fetch(webhook_source) do + raise NameError, "Invalid inbound webhook source: #{webhook_source}" + end + end + + def webhook_source + inbound_webhook.source.to_sym + end + + def within_processing_window? + inbound_webhook.processing? && inbound_webhook.processing_at > InboundWebhook::WEBHOOK_PROCESSING_WINDOW.ago + end + end +end diff --git a/app/services/inbound_webhooks/validate_payload_service.rb b/app/services/inbound_webhooks/validate_payload_service.rb new file mode 100644 index 0000000..07df3f3 --- /dev/null +++ b/app/services/inbound_webhooks/validate_payload_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module InboundWebhooks + class ValidatePayloadService < BaseService + WEBHOOK_SOURCES = { + stripe: PaymentProviders::Stripe::ValidateIncomingWebhookService, + moneyhash: PaymentProviders::Moneyhash::ValidateIncomingWebhookService + } + + def initialize(organization_id:, code:, payload:, webhook_source:, signature:) + @organization_id = organization_id + @code = code + @payload = payload + @signature = signature + @webhook_source = webhook_source&.to_sym + + super + end + + def call + return result.service_failure!(code: "webhook_error", message: "Invalid webhook source") unless webhook_source_valid? + return payment_provider_result unless payment_provider_result.success? + + validate_webhook_payload_result + end + + private + + attr_reader :organization_id, :code, :payload, :signature, :webhook_source + + def webhook_source_valid? + WEBHOOK_SOURCES.include?(webhook_source) + end + + def validate_webhook_payload_result + WEBHOOK_SOURCES[webhook_source].call( + payload:, + signature:, + payment_provider: + ) + end + + def payment_provider + payment_provider_result.payment_provider + end + + def payment_provider_result + @payment_provider_result ||= PaymentProviders::FindService.call( + organization_id:, + code:, + payment_provider_type: webhook_source.to_s + ) + end + end +end diff --git a/app/services/integration_collection_mappings/create_service.rb b/app/services/integration_collection_mappings/create_service.rb new file mode 100644 index 0000000..0f699b1 --- /dev/null +++ b/app/services/integration_collection_mappings/create_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class CreateService < BaseService + attr_reader :params + + def initialize(params:) + @params = params + + super + end + + def call + integration = Integrations::BaseIntegration.find_by(id: params[:integration_id]) + + return result.not_found_failure!(resource: "integration") unless integration + + if params[:billing_entity_id] + billing_entity = integration.organization.billing_entities.find_by(id: params[:billing_entity_id]) + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + end + + integration_collection_mapping = IntegrationCollectionMappings::Factory.new_instance(integration:).new( + organization_id: params[:organization_id], + integration_id: params[:integration_id], + mapping_type: params[:mapping_type], + billing_entity_id: params[:billing_entity_id] + ) + + integration_collection_mapping.organization = integration.organization + integration_collection_mapping.external_id = params[:external_id] if params.key?(:external_id) + if params.key?(:external_account_code) + integration_collection_mapping.external_account_code = params[:external_account_code] + end + integration_collection_mapping.external_name = params[:external_name] if params.key?(:external_name) + integration_collection_mapping.tax_nexus = params[:tax_nexus] if params.key?(:tax_nexus) + integration_collection_mapping.tax_code = params[:tax_code] if params.key?(:tax_code) + integration_collection_mapping.tax_type = params[:tax_type] if params.key?(:tax_type) + integration_collection_mapping.currencies = params[:currencies] if params.key?(:currencies) + + integration_collection_mapping.save! + + result.integration_collection_mapping = integration_collection_mapping + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end +end diff --git a/app/services/integration_collection_mappings/destroy_service.rb b/app/services/integration_collection_mappings/destroy_service.rb new file mode 100644 index 0000000..572412a --- /dev/null +++ b/app/services/integration_collection_mappings/destroy_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class DestroyService < BaseService + def initialize(integration_collection_mapping:) + @integration_collection_mapping = integration_collection_mapping + + super + end + + def call + return result.not_found_failure!(resource: "integration_collection_mapping") unless integration_collection_mapping + + integration_collection_mapping.destroy! + + result.integration_collection_mapping = integration_collection_mapping + result + end + + private + + attr_reader :integration_collection_mapping + end +end diff --git a/app/services/integration_collection_mappings/factory.rb b/app/services/integration_collection_mappings/factory.rb new file mode 100644 index 0000000..0026b82 --- /dev/null +++ b/app/services/integration_collection_mappings/factory.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class Factory + def self.new_instance(integration:) + service_class(integration) + end + + def self.service_class(integration) + case integration&.type&.to_s + when "Integrations::NetsuiteIntegration" + IntegrationCollectionMappings::NetsuiteCollectionMapping + when "Integrations::AnrokIntegration" + IntegrationCollectionMappings::AnrokCollectionMapping + when "Integrations::AvalaraIntegration" + IntegrationCollectionMappings::AvalaraCollectionMapping + when "Integrations::XeroIntegration" + IntegrationCollectionMappings::XeroCollectionMapping + else + raise(NotImplementedError) + end + end + end +end diff --git a/app/services/integration_collection_mappings/update_service.rb b/app/services/integration_collection_mappings/update_service.rb new file mode 100644 index 0000000..fb6ea59 --- /dev/null +++ b/app/services/integration_collection_mappings/update_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module IntegrationCollectionMappings + class UpdateService < BaseService + def initialize(integration_collection_mapping:, params:) + @integration_collection_mapping = integration_collection_mapping + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration_collection_mapping") unless integration_collection_mapping + + integration_collection_mapping.external_id = params[:external_id] if params.key?(:external_id) + if params.key?(:external_account_code) + integration_collection_mapping.external_account_code = params[:external_account_code] + end + integration_collection_mapping.external_name = params[:external_name] if params.key?(:external_name) + integration_collection_mapping.tax_nexus = params[:tax_nexus] if params.key?(:tax_nexus) + integration_collection_mapping.tax_code = params[:tax_code] if params.key?(:tax_code) + integration_collection_mapping.tax_type = params[:tax_type] if params.key?(:tax_type) + integration_collection_mapping.currencies = params[:currencies] if params.key?(:currencies) + + integration_collection_mapping.save! + + result.integration_collection_mapping = integration_collection_mapping + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration_collection_mapping, :params + end +end diff --git a/app/services/integration_customers/anrok_service.rb b/app/services/integration_customers/anrok_service.rb new file mode 100644 index 0000000..a026070 --- /dev/null +++ b/app/services/integration_customers/anrok_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class AnrokService < ::BaseService + def initialize(integration:, customer:, subsidiary_id:, **params) + @customer = customer + @subsidiary_id = subsidiary_id + @integration = integration + @params = params&.with_indifferent_access + + super(nil) + end + + def create + # For Anrok real customer sync happens with the first document sync. In the meantime, + # integration customer object needs to be stored on Lago side + new_integration_customer = IntegrationCustomers::BaseCustomer.create!( + organization_id: integration.organization_id, + integration:, + customer:, + type: "IntegrationCustomers::AnrokCustomer", + sync_with_provider: true + ) + + result.integration_customer = new_integration_customer + result + end + + private + + attr_reader :integration, :customer, :subsidiary_id, :params + end +end diff --git a/app/services/integration_customers/avalara_service.rb b/app/services/integration_customers/avalara_service.rb new file mode 100644 index 0000000..e65eb73 --- /dev/null +++ b/app/services/integration_customers/avalara_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class AvalaraService < ::BaseService + Result = BaseResult[:integration_customer] + + def initialize(integration:, customer:, subsidiary_id:, **params) + @customer = customer + @subsidiary_id = subsidiary_id + @integration = integration + @params = params&.with_indifferent_access + + super(nil) + end + + def create + create_result = Integrations::Aggregator::Contacts::CreateService.call( + integration:, + customer:, + subsidiary_id: nil + ) + + return create_result if create_result.error || create_result.contact_id.nil? + + new_integration_customer = IntegrationCustomers::BaseCustomer.create!( + organization_id: integration.organization_id, + integration:, + customer:, + external_customer_id: create_result.contact_id, + type: "IntegrationCustomers::AvalaraCustomer", + sync_with_provider: true + ) + + result.integration_customer = new_integration_customer + result + end + + private + + attr_reader :integration, :customer, :subsidiary_id, :params + end +end diff --git a/app/services/integration_customers/base_service.rb b/app/services/integration_customers/base_service.rb new file mode 100644 index 0000000..170df25 --- /dev/null +++ b/app/services/integration_customers/base_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class BaseService < BaseService + def initialize(params:, integration:) + @params = params + @integration = integration + + super + end + + def call + result.not_found_failure!(resource: "integration") unless integration + result + end + + private + + attr_reader :params, :integration + + def sync_with_provider + @sync_with_provider ||= ActiveModel::Type::Boolean.new.cast(params[:sync_with_provider]) + end + + def customer_type + @customer_type ||= IntegrationCustomers::BaseCustomer.customer_type(params[:integration_type]) + end + + def subsidiary_id + @subsidiary_id ||= params[:subsidiary_id] + end + + def targeted_object + @targeted_object ||= params[:targeted_object] + end + + def external_customer_id + @external_customer_id ||= params[:external_customer_id] + end + end +end diff --git a/app/services/integration_customers/create_or_update_batch_service.rb b/app/services/integration_customers/create_or_update_batch_service.rb new file mode 100644 index 0000000..546837d --- /dev/null +++ b/app/services/integration_customers/create_or_update_batch_service.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class CreateOrUpdateBatchService < ::BaseService + SYNC_INTEGRATIONS = ["Integrations::SalesforceIntegration"].freeze + def initialize(integration_customers:, customer:, new_customer:) + @integration_customers = integration_customers&.map { |c| c.to_h.deep_symbolize_keys } + @customer = customer + @new_customer = new_customer + + super(nil) + end + + def call + return if integration_customers.nil? || customer.nil? + return if customer.partner_account? + + sanitize_integration_customers + + integration_customers.each do |int_customer_params| + @integration_customer_params = int_customer_params + + next unless integration + next if skip_creating_integration_customer? + + if create_integration_customer? + handle_creation + elsif update_integration_customer? + handle_update + end + end + end + + private + + attr_reader :integration_customer_params, :customer, :new_customer, :integration_customers + + def create_integration_customer? + (new_customer && integration_customer_params[:sync_with_provider]) || + (new_customer && integration_customer_params[:external_customer_id]) || + (!new_customer && integration_customer.nil? && integration_customer_params[:sync_with_provider]) || + (!new_customer && integration_customer.nil? && integration_customer_params[:external_customer_id]) + end + + def update_integration_customer? + !new_customer && integration_customer + end + + def handle_creation + # salesforce don't need to reach a provider so it can be done sync + if SYNC_INTEGRATIONS.include? integration&.type + IntegrationCustomers::CreateJob.perform_now( + integration_customer_params: integration_customer_params, + integration:, + customer: + ) + else + IntegrationCustomers::CreateJob.perform_later( + integration_customer_params: integration_customer_params, + integration:, + customer: + ) + end + end + + def handle_update + if SYNC_INTEGRATIONS.include? integration&.type + IntegrationCustomers::UpdateJob.perform_now( + integration_customer_params: integration_customer_params, + integration:, + integration_customer: + ) + else + IntegrationCustomers::UpdateJob.perform_later( + integration_customer_params: integration_customer_params, + integration:, + integration_customer: + ) + end + end + + def sanitize_integration_customers + updated_int_customers = integration_customers.reject { |m| m[:id].nil? }.map { |m| m[:id] } + not_needed_ids = customer.integration_customers.pluck(:id) - updated_int_customers + + customer.integration_customers.where(id: not_needed_ids).destroy_all + end + + def skip_creating_integration_customer? + integration_customer.nil? && + integration_customer_params[:sync_with_provider].blank? && + integration_customer_params[:external_customer_id].blank? + end + + def integration + type = Integrations::BaseIntegration.integration_type(integration_customer_params[:integration_type]) + + return @integration if defined?(@integration) && @integration&.type == type + + return nil unless integration_customer_params && + integration_customer_params[:integration_type] && + integration_customer_params[:integration_code] + + code = integration_customer_params[:integration_code] + + @integration = Integrations::BaseIntegration.find_by(type:, code:, organization: customer.organization) + end + + def integration_customer + type = IntegrationCustomers::BaseCustomer.customer_type(integration_customer_params[:integration_type]) + + return @integration_customer if defined?(@integration_customer) && @integration_customer&.type == type + + @integration_customer = IntegrationCustomers::BaseCustomer.find_by(integration:, customer:) + end + end +end diff --git a/app/services/integration_customers/create_service.rb b/app/services/integration_customers/create_service.rb new file mode 100644 index 0000000..60c0705 --- /dev/null +++ b/app/services/integration_customers/create_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class CreateService < BaseService + def initialize(params:, integration:, customer:) + @customer = customer + super(params:, integration:) + end + + def call + result = super + return result if result.error + + res = if external_customer_id.present? + link_customer! + elsif sync_with_provider + sync_customer! + end + return res if res&.error + + result + rescue ActiveRecord::RecordNotUnique + # Avoid raising on race conditions when multiple requests are made at the same time + result.integration_customer = IntegrationCustomers::BaseCustomer.find_by( + customer:, integration:, type: customer_type + ) + result + end + + private + + attr_reader :customer + + def sync_customer! + integration_customer_service = IntegrationCustomers::Factory.new_instance( + integration:, customer:, subsidiary_id:, **params + ) + + return result unless integration_customer_service + + sync_result = integration_customer_service.create + + return sync_result if sync_result.error + + result.integration_customer = sync_result.integration_customer + result + end + + def link_customer! + sync_with_provider = integration&.type&.to_s == "Integrations::SalesforceIntegration" + + new_integration_customer = IntegrationCustomers::BaseCustomer.create!( + organization_id: integration.organization_id, + integration:, + customer:, + external_customer_id: params[:external_customer_id], + type: customer_type, + sync_with_provider: sync_with_provider + ) + + if integration&.type&.to_s == "Integrations::NetsuiteIntegration" + new_integration_customer.subsidiary_id = subsidiary_id + new_integration_customer.save! + end + + if integration&.type&.to_s == "Integrations::HubspotIntegration" + new_integration_customer.targeted_object = targeted_object + new_integration_customer.save! + end + + result.integration_customer = new_integration_customer + result + end + end +end diff --git a/app/services/integration_customers/factory.rb b/app/services/integration_customers/factory.rb new file mode 100644 index 0000000..4a82319 --- /dev/null +++ b/app/services/integration_customers/factory.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class Factory + def self.new_instance(integration:, customer:, subsidiary_id:, **params) + service_class(integration).new(integration:, customer:, subsidiary_id:, **params) + end + + def self.service_class(integration) + case integration&.type&.to_s + when "Integrations::NetsuiteIntegration" + IntegrationCustomers::NetsuiteService + when "Integrations::AnrokIntegration" + IntegrationCustomers::AnrokService + when "Integrations::AvalaraIntegration" + IntegrationCustomers::AvalaraService + when "Integrations::XeroIntegration" + IntegrationCustomers::XeroService + when "Integrations::HubspotIntegration" + IntegrationCustomers::HubspotService + when "Integrations::SalesforceIntegration" + IntegrationCustomers::SalesforceService + else + raise(NotImplementedError) + end + end + end +end diff --git a/app/services/integration_customers/hubspot_service.rb b/app/services/integration_customers/hubspot_service.rb new file mode 100644 index 0000000..1a75ec0 --- /dev/null +++ b/app/services/integration_customers/hubspot_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class HubspotService < ::BaseService + def initialize(integration:, customer:, subsidiary_id:, **params) + @customer = customer + @subsidiary_id = subsidiary_id + @integration = integration + @params = params&.with_indifferent_access + + super(nil) + end + + def create + create_result = create_service_class.call( + integration:, + customer:, + subsidiary_id: nil + ) + + return create_result if create_result.error + + new_integration_customer = IntegrationCustomers::BaseCustomer.create!( + organization_id: integration.organization_id, + integration:, + customer:, + external_customer_id: create_result.contact_id, + email: create_result.email, + type: "IntegrationCustomers::HubspotCustomer", + sync_with_provider: true, + targeted_object: + ) + + result.integration_customer = new_integration_customer + result + end + + private + + attr_reader :integration, :customer, :subsidiary_id, :params + + def create_service_class + @create_service_class ||= if targeted_object == "contacts" + Integrations::Aggregator::Contacts::CreateService + else + Integrations::Aggregator::Companies::CreateService + end + end + + def targeted_object + @targeted_object ||= + params[:targeted_object].presence || + ((customer.customer_type == "individual") ? "contacts" : nil) || + ((customer.customer_type == "company") ? "companies" : nil) || + integration.default_targeted_object + end + end +end diff --git a/app/services/integration_customers/netsuite_service.rb b/app/services/integration_customers/netsuite_service.rb new file mode 100644 index 0000000..0a06d9a --- /dev/null +++ b/app/services/integration_customers/netsuite_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class NetsuiteService < ::BaseService + def initialize(integration:, customer:, subsidiary_id:, **params) + @customer = customer + @subsidiary_id = subsidiary_id + @integration = integration + @params = params&.with_indifferent_access + + super(nil) + end + + def create + if existing_integration_customer.present? + result.integration_customer = existing_integration_customer + return result + end + + create_result = Integrations::Aggregator::Contacts::CreateService.call(integration:, customer:, subsidiary_id:) + return create_result if create_result.error + + new_integration_customer = IntegrationCustomers::BaseCustomer.create!( + organization_id: integration.organization_id, + integration:, + customer:, + external_customer_id: create_result.contact_id, + type: "IntegrationCustomers::NetsuiteCustomer", + subsidiary_id:, + sync_with_provider: true + ) + + result.integration_customer = new_integration_customer + result + end + + private + + attr_reader :integration, :customer, :subsidiary_id, :params + + def existing_integration_customer + @existing_integration_customer ||= IntegrationCustomers::BaseCustomer.find_by( + customer:, + integration:, + type: "IntegrationCustomers::NetsuiteCustomer" + ) + end + end +end diff --git a/app/services/integration_customers/salesforce_service.rb b/app/services/integration_customers/salesforce_service.rb new file mode 100644 index 0000000..83bfe9c --- /dev/null +++ b/app/services/integration_customers/salesforce_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class SalesforceService < ::BaseService + def initialize(integration:, customer:, subsidiary_id:, **params) + @customer = customer + @subsidiary_id = subsidiary_id + @integration = integration + @params = params&.with_indifferent_access + + super(nil) + end + + def create + new_integration_customer = IntegrationCustomers::BaseCustomer.create!( + organization_id: integration.organization_id, + integration:, + customer:, + type: "IntegrationCustomers::SalesforceCustomer", + sync_with_provider: true + ) + + result.integration_customer = new_integration_customer + result + end + + private + + attr_reader :integration, :customer, :subsidiary_id, :params + end +end diff --git a/app/services/integration_customers/update_service.rb b/app/services/integration_customers/update_service.rb new file mode 100644 index 0000000..076b8d0 --- /dev/null +++ b/app/services/integration_customers/update_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class UpdateService < BaseService + def initialize(params:, integration:, integration_customer:) + @integration_customer = integration_customer + super(params:, integration:) + end + + def call + result = super + return result if result.error + + return result if integration_customer.type == "IntegrationCustomers::AnrokCustomer" + return result if integration_customer.type == "IntegrationCustomers::SalesforceCustomer" + return result.not_found_failure!(resource: "integration_customer") unless integration_customer + + integration_customer.external_customer_id = external_customer_id if external_customer_id.present? + integration_customer.targeted_object = targeted_object if targeted_object.present? + integration_customer.save! + + if integration_customer.external_customer_id.present? + update_result = update_service_class.call(integration:, integration_customer:) + return update_result unless update_result.success? + end + + result.integration_customer = integration_customer + result + end + + private + + attr_reader :integration_customer + + delegate :customer, to: :integration_customer + + def update_service_class + @update_service_class ||= if integration_customer.type != "IntegrationCustomers::HubspotCustomer" + Integrations::Aggregator::Contacts::UpdateService + elsif integration_customer.targeted_object == "contacts" + Integrations::Aggregator::Contacts::UpdateService + else + Integrations::Aggregator::Companies::UpdateService + end + end + end +end diff --git a/app/services/integration_customers/xero_service.rb b/app/services/integration_customers/xero_service.rb new file mode 100644 index 0000000..a48351a --- /dev/null +++ b/app/services/integration_customers/xero_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module IntegrationCustomers + class XeroService < ::BaseService + def initialize(integration:, customer:, subsidiary_id:, **params) + @customer = customer + @subsidiary_id = subsidiary_id + @integration = integration + @params = params&.with_indifferent_access + + super(nil) + end + + def create + create_result = Integrations::Aggregator::Contacts::CreateService.call( + integration:, + customer:, + subsidiary_id: nil + ) + + return create_result if create_result.error + + new_integration_customer = IntegrationCustomers::BaseCustomer.create!( + organization_id: integration.organization_id, + integration:, + customer:, + external_customer_id: create_result.contact_id, + type: "IntegrationCustomers::XeroCustomer", + sync_with_provider: true + ) + + result.integration_customer = new_integration_customer + result + end + + private + + attr_reader :integration, :customer, :subsidiary_id, :params + end +end diff --git a/app/services/integration_mappings/create_service.rb b/app/services/integration_mappings/create_service.rb new file mode 100644 index 0000000..d0aea1c --- /dev/null +++ b/app/services/integration_mappings/create_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module IntegrationMappings + class CreateService < BaseService + def call(**args) + integration = Integrations::BaseIntegration.find_by(id: args[:integration_id]) + + return result.not_found_failure!(resource: "integration") unless integration + + if (billing_entity_id = args[:billing_entity_id]) + billing_entity = integration.organization.billing_entities.find_by(id: billing_entity_id) + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + end + + integration_mapping = IntegrationMappings::Factory.new_instance(integration:).new( + organization_id: integration.organization_id, + integration_id: args[:integration_id], + mappable_id: args[:mappable_id], + mappable_type: args[:mappable_type], + billing_entity_id: billing_entity_id + ) + + integration_mapping.external_id = args[:external_id] if args.key?(:external_id) + integration_mapping.external_account_code = args[:external_account_code] if args.key?(:external_account_code) + integration_mapping.external_name = args[:external_name] if args.key?(:external_name) + + integration_mapping.save! + + result.integration_mapping = integration_mapping + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end +end diff --git a/app/services/integration_mappings/destroy_service.rb b/app/services/integration_mappings/destroy_service.rb new file mode 100644 index 0000000..d01191a --- /dev/null +++ b/app/services/integration_mappings/destroy_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module IntegrationMappings + class DestroyService < BaseService + def initialize(integration_mapping:) + @integration_mapping = integration_mapping + + super + end + + def call + return result.not_found_failure!(resource: "integration_mapping") unless integration_mapping + + integration_mapping.destroy! + + result.integration_mapping = integration_mapping + result + end + + private + + attr_reader :integration_mapping + end +end diff --git a/app/services/integration_mappings/factory.rb b/app/services/integration_mappings/factory.rb new file mode 100644 index 0000000..9fe8f01 --- /dev/null +++ b/app/services/integration_mappings/factory.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module IntegrationMappings + class Factory + def self.new_instance(integration:) + service_class(integration) + end + + def self.service_class(integration) + case integration&.type&.to_s + when "Integrations::NetsuiteIntegration" + IntegrationMappings::NetsuiteMapping + when "Integrations::AnrokIntegration" + IntegrationMappings::AnrokMapping + when "Integrations::AvalaraIntegration" + IntegrationMappings::AvalaraMapping + when "Integrations::XeroIntegration" + IntegrationMappings::XeroMapping + else + raise(NotImplementedError) + end + end + end +end diff --git a/app/services/integration_mappings/update_service.rb b/app/services/integration_mappings/update_service.rb new file mode 100644 index 0000000..91c8654 --- /dev/null +++ b/app/services/integration_mappings/update_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module IntegrationMappings + class UpdateService < BaseService + def initialize(integration_mapping:, params:) + @integration_mapping = integration_mapping + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration_mapping") unless integration_mapping + + integration_mapping.external_id = params[:external_id] if params.key?(:external_id) + integration_mapping.external_account_code = params[:external_account_code] if params.key?(:external_account_code) + integration_mapping.external_name = params[:external_name] if params.key?(:external_name) + + integration_mapping.save! + + result.integration_mapping = integration_mapping + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration_mapping, :params + end +end diff --git a/app/services/integrations/aggregator/account_information_service.rb b/app/services/integrations/aggregator/account_information_service.rb new file mode 100644 index 0000000..efc289a --- /dev/null +++ b/app/services/integrations/aggregator/account_information_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class AccountInformationService < BaseService + def action_path + "v1/account-information" + end + + def call + throttle!(:hubspot) + + response = http_client.get(headers:) + + result.account_information = OpenStruct.new(response) + result + end + + private + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + end + end +end diff --git a/app/services/integrations/aggregator/accounts_service.rb b/app/services/integrations/aggregator/accounts_service.rb new file mode 100644 index 0000000..5320f50 --- /dev/null +++ b/app/services/integrations/aggregator/accounts_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class AccountsService < BaseService + LIMIT = 450 + MAX_SUBSEQUENT_REQUESTS = 15 + + def action_path + "v1/#{provider}/accounts" + end + + def call + @cursor = nil + @items = [] + + ActiveRecord::Base.transaction do + integration.integration_items.where(item_type: :account).destroy_all + + MAX_SUBSEQUENT_REQUESTS.times do |_i| + response = http_client.get(headers:, params:) + + handle_accounts(response["records"]) + @cursor = response["next_cursor"] + + break if cursor.blank? + end + end + result.accounts = items + + result + end + + private + + attr_reader :cursor, :items + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def handle_accounts(new_items) + new_items.each do |item| + integration_item = IntegrationItem.new( + organization_id: integration.organization_id, + integration:, + external_id: item["id"], + external_account_code: item["code"], + external_name: item["name"], + item_type: :account + ) + + integration_item.save! + + @items << integration_item + end + end + + def params + return {limit: LIMIT} if cursor.blank? + + {limit: LIMIT, cursor:} + end + end + end +end diff --git a/app/services/integrations/aggregator/bad_gateway_error.rb b/app/services/integrations/aggregator/bad_gateway_error.rb new file mode 100644 index 0000000..eec75db --- /dev/null +++ b/app/services/integrations/aggregator/bad_gateway_error.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class BadGatewayError < LagoHttpClient::HttpError + def initialize(body, uri) + super(502, body, uri) + end + end + end +end diff --git a/app/services/integrations/aggregator/base_payload.rb b/app/services/integrations/aggregator/base_payload.rb new file mode 100644 index 0000000..c6f708e --- /dev/null +++ b/app/services/integrations/aggregator/base_payload.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class BasePayload + class Failure < BaseService::FailedResult + attr_reader :code + + def initialize(result, code:) + @code = code + + super(result, code) + end + end + + def initialize(integration:, billing_entity:) + @integration = integration + @billing_entity = billing_entity + end + + def billable_metric_item(fee) + lookup_mapping("BillableMetric", fee.billable_metric.id) + end + + def add_on_item(fee) + lookup_mapping("AddOn", fee.add_on_id) + end + + def fixed_charge_item(fee) + lookup_mapping("AddOn", fee.fixed_charge_add_on.id) + end + + def account_item + lookup_collection_mapping(:account) + end + + def tax_item + lookup_collection_mapping(:tax, with_fallback_item: false) + end + + def commitment_item + lookup_collection_mapping(:minimum_commitment) + end + + def subscription_item + lookup_collection_mapping(:subscription_fee) + end + + def coupon_item + lookup_collection_mapping(:coupon) + end + + def credit_item + lookup_collection_mapping(:prepaid_credit) + end + + def credit_note_item + lookup_collection_mapping(:credit_note) + end + + def amount(amount_cents, resource:) + currency = resource.total_amount.currency + + amount_cents.round.fdiv(currency.subunit_to_unit) + end + + private + + attr_reader :integration, :billing_entity + + def fallback_item(scope) + mappings = integration.integration_collection_mappings + fallback_items = mappings.filter { |mapping| mapping.mapping_type.to_sym == :fallback_item } + if scope == :billing_entity && billing_entity + return fallback_items.find { |mapping| mapping.billing_entity_id == billing_entity.id } + end + + fallback_items.find { |mapping| mapping.billing_entity_id.nil? } + end + + def lookup_collection_mapping(mapping_type, with_fallback_item: true) + mappings = integration.integration_collection_mappings + matching_mappings = mappings.filter { |mapping| mapping.mapping_type.to_sym == mapping_type.to_sym } + billing_entity_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id == billing_entity.id } + organization_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id.nil? } + if with_fallback_item + return billing_entity_mapping || + fallback_item(:billing_entity) || + organization_mapping || + fallback_item(:organization) + end + + billing_entity_mapping || + organization_mapping + end + + def lookup_mapping(mappable_type, mappable_id) + mappings = integration.integration_mappings + matching_mappings = mappings.filter { |mapping| mapping.mappable_type == mappable_type && mapping.mappable_id == mappable_id } + billing_entity_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id == billing_entity.id } + organization_mapping = matching_mappings.find { |mapping| mapping.billing_entity_id.nil? } + billing_entity_mapping || + fallback_item(:billing_entity) || + organization_mapping || + fallback_item(:organization) + end + + def tax_item_complete? + tax_item&.tax_nexus.present? && tax_item&.tax_type.present? && tax_item&.tax_code.present? + end + + def formatted_date(date) + date&.strftime("%Y-%m-%d") + end + end + end +end diff --git a/app/services/integrations/aggregator/base_service.rb b/app/services/integrations/aggregator/base_service.rb new file mode 100644 index 0000000..3ebd400 --- /dev/null +++ b/app/services/integrations/aggregator/base_service.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "lago_http_client" + +module Integrations + module Aggregator + class BaseService < BaseService + BASE_URL = "https://api.nango.dev/" + REQUEST_LIMIT_ERROR_CODE = "SSS_REQUEST_LIMIT_EXCEEDED" + BAD_GATEWAY_ERROR = "502 Bad Gateway" + + def initialize(integration:, options: {}) + @integration = integration + @options = options + + super + end + + def action_path + raise NotImplementedError + end + + private + + attr_reader :integration, :options + + # NOTE: Extend it with other providers if needed + def provider + case integration.type + when "Integrations::NetsuiteIntegration" + "netsuite" + when "Integrations::XeroIntegration" + "xero" + when "Integrations::AnrokIntegration" + "anrok" + when "Integrations::AvalaraIntegration" + "avalara" + when "Integrations::HubspotIntegration" + "hubspot" + end + end + + def provider_key + case integration.type + when "Integrations::NetsuiteIntegration" + "netsuite-tba" + when "Integrations::XeroIntegration" + "xero" + when "Integrations::AnrokIntegration" + "anrok" + when "Integrations::AvalaraIntegration" + Rails.env.production? ? "avalara" : "avalara-sandbox" + when "Integrations::HubspotIntegration" + "hubspot" + end + end + + def throttle!(*providers) + providers.each do |provider_name| + if provider == provider_name.to_s + raise BaseService::ThrottlingError.new(provider_name:) \ + unless Throttling.for(provider_name.to_sym).check(:client, throttle_key) + end + end + end + + def throttle_key + # Hubspot and Xero calls are throttled globally, others are throttled per api key or client id + case provider + when "netsuite" + Digest::SHA2.hexdigest(integration.client_id) + when "anrok" + Digest::SHA2.hexdigest(integration.api_key) + else + provider.to_s + end + end + + def http_client + LagoHttpClient::Client.new(endpoint_url, retries_on: [OpenSSL::SSL::SSLError]) + end + + def endpoint_url + "#{BASE_URL}#{action_path}" + end + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}" + } + end + + def deliver_error_webhook(customer:, code:, message:) + SendWebhookJob.perform_later( + error_webhook_code, + customer, + provider:, + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + def deliver_integration_error_webhook(integration:, code:, message:) + SendWebhookJob.perform_later( + "integration.provider_error", + integration, + provider:, + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + def deliver_tax_error_webhook(customer:, code:, message:) + SendWebhookJob.perform_later( + "customer.tax_provider_error", + customer, + provider:, + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + def secret_key + ENV["NANGO_SECRET_KEY"] + end + + def error_webhook_code + case provider + when "hubspot" + "customer.crm_provider_error" + when "avalara" + "customer.tax_provider_error" + else + "customer.accounting_provider_error" + end + end + + def code(error) + json = error.json_message + safe_dig_str(json, "type") || + safe_dig_str(json, "error", "payload", "name") || + safe_dig_str(json, "error", "payload", "error", "code") || + safe_dig_str(json, "error", "code") || + "unexpected_error" + end + + def message(error) + json = error.json_message + safe_dig_str(json, "payload", "message") || + safe_dig_str(json, "error", "payload", "message") || + safe_dig_str(json, "error", "payload", "error") || + safe_dig_str(json, "error", "payload", "error", "message") || + safe_dig_str(json, "error", "message") || + json.to_json + end + + # Safe dig method for nested hashes as #dig breaks on non-Hash values + def safe_dig_str(obj, *keys) + value = keys.reduce(obj) { |o, k| o.is_a?(Hash) ? o[k].presence : nil } + value.is_a?(String) ? value : nil + end + + def request_limit_error?(http_error) + http_error.error_body.include?(REQUEST_LIMIT_ERROR_CODE) + end + + def bad_gateway_error?(http_error) + http_error.error_code.to_s == "502" || + http_error.error_body.include?(BAD_GATEWAY_ERROR) + end + + def parse_response(response) + JSON.parse(response.body) + rescue JSON::ParserError + if response.body.include?(BAD_GATEWAY_ERROR) + # NOTE: Sometimes, Anrok is responding with an HTTP 200 with a payload containing a 502 error... + raise(Integrations::Aggregator::BadGatewayError.new(response.body, http_client.uri)) + end + + raise + end + end + end +end diff --git a/app/services/integrations/aggregator/companies/base_service.rb b/app/services/integrations/aggregator/companies/base_service.rb new file mode 100644 index 0000000..9b7c269 --- /dev/null +++ b/app/services/integrations/aggregator/companies/base_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Companies + class BaseService < Integrations::Aggregator::Contacts::BaseService + def action_path + "v1/#{provider}/companies" + end + + private + + def process_hash_result(body) + contact = body["succeededCompanies"]&.first + contact_id = contact&.dig("id") + email = contact&.dig("email") + + if contact_id + result.contact_id = contact_id + result.email = email if email.present? + else + message = if body.key?("failedCompanies") + body["failedCompanies"].first["validation_errors"].map { |error| error["Message"] }.join(". ") + else + body.dig("error", "payload", "message") + end + + code = "Validation error" + + deliver_error_webhook(customer:, code:, message:) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/companies/create_service.rb b/app/services/integrations/aggregator/companies/create_service.rb new file mode 100644 index 0000000..e052cac --- /dev/null +++ b/app/services/integrations/aggregator/companies/create_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Companies + class CreateService < BaseService + def initialize(integration:, customer:, subsidiary_id:) + @customer = customer + @subsidiary_id = subsidiary_id + + super(integration:) + end + + def call + Integrations::Hubspot::Companies::DeployPropertiesService.call(integration:) + + throttle!(:hubspot) + + response = http_client.post_with_response(params, headers) + body = JSON.parse(response.body) + + if body.is_a?(Hash) + process_hash_result(body) + else + process_string_result(body) + end + + return result unless result.contact_id + + deliver_success_webhook(customer:, webhook_code:) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result.service_failure!(code:, message:) + end + + private + + attr_reader :customer, :subsidiary_id + + def params + Integrations::Aggregator::Companies::Payloads::Factory.new_instance( + integration:, + integration_customer: nil, + customer:, + subsidiary_id: + ).create_body + end + end + end + end +end diff --git a/app/services/integrations/aggregator/companies/payloads/factory.rb b/app/services/integrations/aggregator/companies/payloads/factory.rb new file mode 100644 index 0000000..ba83565 --- /dev/null +++ b/app/services/integrations/aggregator/companies/payloads/factory.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Companies + module Payloads + class Factory + def self.new_instance(integration:, customer:, integration_customer:, subsidiary_id:) + case integration.type.to_s + when "Integrations::HubspotIntegration" + Integrations::Aggregator::Companies::Payloads::Hubspot.new( + integration:, + customer:, + integration_customer:, + subsidiary_id: + ) + else + raise(NotImplementedError) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/companies/payloads/hubspot.rb b/app/services/integrations/aggregator/companies/payloads/hubspot.rb new file mode 100644 index 0000000..d428c9b --- /dev/null +++ b/app/services/integrations/aggregator/companies/payloads/hubspot.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Companies + module Payloads + class Hubspot < Integrations::Aggregator::Contacts::Payloads::BasePayload + def create_body + { + "properties" => { + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => customer_url + }.merge( + { + "name" => customer.name, + "domain" => clean_url(customer.url) + }.compact_blank + ) + } + end + + def update_body + { + "companyId" => integration_customer.external_customer_id, + "input" => { + "properties" => { + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => customer_url + }.merge( + { + "name" => customer.name, + "domain" => clean_url(customer.url) + }.compact_blank + ) + } + } + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/companies/update_service.rb b/app/services/integrations/aggregator/companies/update_service.rb new file mode 100644 index 0000000..74ca991 --- /dev/null +++ b/app/services/integrations/aggregator/companies/update_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Companies + class UpdateService < BaseService + def initialize(integration:, integration_customer:) + @integration_customer = integration_customer + + raise ArgumentError, "Integration customer is not a company" if customer.customer_type_individual? + + super(integration:) + end + + def call + Integrations::Hubspot::Companies::DeployPropertiesService.call(integration:) + + throttle!(:hubspot) + + response = http_client.put_with_response(params, headers) + body = JSON.parse(response.body) + + if body.is_a?(Hash) + process_hash_result(body) + else + process_string_result(body) + end + + return result unless result.contact_id + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result.service_failure!(code:, message:) + end + + delegate :customer, to: :integration_customer + + private + + attr_reader :integration_customer, :subsidiary_id + + def params + Integrations::Aggregator::Companies::Payloads::Factory.new_instance( + integration:, + integration_customer:, + customer:, + subsidiary_id: + ).update_body + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/base_service.rb b/app/services/integrations/aggregator/contacts/base_service.rb new file mode 100644 index 0000000..a56e98b --- /dev/null +++ b/app/services/integrations/aggregator/contacts/base_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + class BaseService < Integrations::Aggregator::BaseService + def action_path + "v1/#{provider}/contacts" + end + + private + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def deliver_success_webhook(customer:, webhook_code:) + SendWebhookJob.perform_later( + webhook_code, + customer + ) + end + + def process_hash_result(body) + contact = body["succeededContacts"]&.first + contact_id = contact&.dig("id") + email = contact&.dig("email") + + if contact_id + result.contact_id = contact_id + result.email = email if email.present? + else + message = if body.key?("failedContacts") + body["failedContacts"].first["validation_errors"].map { |error| error["Message"] }.join(". ") + else + body.dig("error", "payload", "message") + end + + code = "Validation error" + + deliver_error_webhook(customer:, code:, message:) + end + end + + def process_string_result(body) + result.contact_id = body + end + + def webhook_code + case provider + when "hubspot" + "customer.crm_provider_created" + else + "customer.accounting_provider_created" + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/create_service.rb b/app/services/integrations/aggregator/contacts/create_service.rb new file mode 100644 index 0000000..7d678d9 --- /dev/null +++ b/app/services/integrations/aggregator/contacts/create_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + class CreateService < BaseService + def initialize(integration:, customer:, subsidiary_id:) + @customer = customer + @subsidiary_id = subsidiary_id + + super(integration:) + end + + def call + Integrations::Hubspot::Contacts::DeployPropertiesService.call(integration:) + + throttle!(:anrok, :hubspot, :netsuite, :xero) + + response = http_client.post_with_response(params, headers) + body = JSON.parse(response.body) + + if body.is_a?(Hash) + process_hash_result(body) + else + process_string_result(body) + end + + return result unless result.contact_id + + deliver_success_webhook(customer:, webhook_code:) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result.service_failure!(code:, message:) + end + + private + + attr_reader :customer, :subsidiary_id + + def params + Integrations::Aggregator::Contacts::Payloads::Factory.new_instance( + integration:, + integration_customer: nil, + customer:, + subsidiary_id: + ).create_body + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/payloads/anrok.rb b/app/services/integrations/aggregator/contacts/payloads/anrok.rb new file mode 100644 index 0000000..42970bc --- /dev/null +++ b/app/services/integrations/aggregator/contacts/payloads/anrok.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + module Payloads + class Anrok < BasePayload + def create_body + [ + { + "name" => customer.display_name(prefer_legal_name: false), + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => email, + "phone" => phone + } + ] + end + + def update_body + [ + { + "id" => integration_customer.external_customer_id, + "name" => customer.display_name(prefer_legal_name: false), + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => email, + "phone" => phone + } + ] + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/payloads/avalara.rb b/app/services/integrations/aggregator/contacts/payloads/avalara.rb new file mode 100644 index 0000000..1508577 --- /dev/null +++ b/app/services/integrations/aggregator/contacts/payloads/avalara.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + module Payloads + class Avalara < BasePayload + def create_body + [ + { + "company_id" => integration.company_id&.to_i, + "external_id" => customer.id, + "name" => name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "country" => customer.shipping_country || customer.country, + "state" => customer.shipping_state || customer.state, + "tax_number" => customer.tax_identification_number + } + ] + end + + def update_body + [ + { + "company_id" => integration.company_id&.to_i, + "external_id" => customer.id, + "name" => name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "country" => customer.shipping_country || customer.country, + "state" => customer.shipping_state || customer.state, + "tax_number" => customer.tax_identification_number + } + ] + end + + private + + def name + return customer.name if customer.name.present? + + "#{customer.firstname} #{customer.lastname}".strip + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/payloads/base_payload.rb b/app/services/integrations/aggregator/contacts/payloads/base_payload.rb new file mode 100644 index 0000000..8d1a2fa --- /dev/null +++ b/app/services/integrations/aggregator/contacts/payloads/base_payload.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + module Payloads + class BasePayload < Integrations::Aggregator::BasePayload + def initialize(integration:, customer:, integration_customer: nil, subsidiary_id: nil) + super(integration:, billing_entity: customer.billing_entity) + + @customer = customer + @integration_customer = integration_customer + @subsidiary_id = subsidiary_id + end + + def create_body + [ + { + "name" => customer.name, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => email, + "phone" => phone + }.merge(contact_names) + ] + end + + def update_body + [ + { + "id" => integration_customer.external_customer_id, + "name" => customer.name, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => email, + "phone" => phone + }.merge(contact_names) + ] + end + + private + + attr_reader :customer, :integration_customer, :subsidiary_id + + def contact_names + {"firstname" => customer.firstname, "lastname" => customer.lastname}.compact_blank + end + + def email + customer.email.to_s.split(",").first&.strip + end + + def phone + customer.phone.to_s.split(",").first&.strip + end + + def customer_url + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{customer.organization.slug}/customer/", customer.id).to_s + end + + def clean_url(url) + url = "http://#{url}" unless /\Ahttps?:\/\//.match?(url) + + uri = URI.parse(url.to_s) + uri.host + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/payloads/factory.rb b/app/services/integrations/aggregator/contacts/payloads/factory.rb new file mode 100644 index 0000000..b006bc6 --- /dev/null +++ b/app/services/integrations/aggregator/contacts/payloads/factory.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + module Payloads + class Factory + def self.new_instance(integration:, customer:, integration_customer:, subsidiary_id:) + case integration.type.to_s + when "Integrations::NetsuiteIntegration" + Integrations::Aggregator::Contacts::Payloads::Netsuite.new( + integration:, + customer:, + integration_customer:, + subsidiary_id: + ) + when "Integrations::XeroIntegration" + Integrations::Aggregator::Contacts::Payloads::Xero.new( + integration:, + customer:, + integration_customer:, + subsidiary_id: + ) + when "Integrations::AnrokIntegration" + Integrations::Aggregator::Contacts::Payloads::Anrok.new( + integration:, + customer:, + integration_customer:, + subsidiary_id: + ) + when "Integrations::AvalaraIntegration" + Integrations::Aggregator::Contacts::Payloads::Avalara.new( + integration:, + customer:, + integration_customer:, + subsidiary_id: + ) + when "Integrations::HubspotIntegration" + Integrations::Aggregator::Contacts::Payloads::Hubspot.new( + integration:, + customer:, + integration_customer:, + subsidiary_id: + ) + else + raise(NotImplementedError) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/payloads/hubspot.rb b/app/services/integrations/aggregator/contacts/payloads/hubspot.rb new file mode 100644 index 0000000..b509610 --- /dev/null +++ b/app/services/integrations/aggregator/contacts/payloads/hubspot.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + module Payloads + class Hubspot < BasePayload + def create_body + { + "properties" => { + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_customer_link" => customer_url + }.merge( + { + "email" => customer.email, + "firstname" => customer.firstname, + "lastname" => customer.lastname, + "phone" => customer.phone, + "company" => customer.legal_name, + "website" => clean_url(customer.url) + }.compact_blank + ) + } + end + + def update_body + { + "contactId" => integration_customer.external_customer_id, + "input" => { + "properties" => { + "email" => customer.email, + "firstname" => customer.firstname, + "lastname" => customer.lastname, + "phone" => customer.phone, + "company" => customer.legal_name, + "website" => clean_url(customer.url) + }.compact_blank + } + } + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/payloads/netsuite.rb b/app/services/integrations/aggregator/contacts/payloads/netsuite.rb new file mode 100644 index 0000000..0662169 --- /dev/null +++ b/app/services/integrations/aggregator/contacts/payloads/netsuite.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + module Payloads + class Netsuite < BasePayload + BASE_LIMIT = 30 + ADDR1_LIMIT = 150 + CITY_LIMIT = 50 + + def create_body + { + "type" => "customer", # Fixed value + "isDynamic" => true, # Fixed value + "columns" => { + "isperson" => isperson, + "subsidiary" => subsidiary_id, + "custentity_lago_id" => customer.id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_url, + "email" => email, + "phone" => phone, + "entityid" => customer.external_id, + "autoname" => false # fixed value + }.merge(names), + "options" => { + "ignoreMandatoryFields" => false # Fixed value + } + }.merge(include_lines? ? {"lines" => lines} : {}) + end + + def update_body + { + "type" => "customer", + "recordId" => integration_customer.external_customer_id, + "columns" => { + "isperson" => isperson, + "subsidiary" => integration_customer.subsidiary_id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_url, + "email" => email, + "phone" => phone, + "entityid" => customer.external_id, + "autoname" => false # fixed value + }.merge(names), + "options" => { + "isDynamic" => false + } + } + end + + private + + def names + # customer_type might be nil -> in that case it's a company so we better check for an individual type here + return {"companyname" => customer.name} unless customer.customer_type_individual? + + names_hash = { + "firstname" => customer.firstname&.first(BASE_LIMIT), + "lastname" => customer.lastname&.first(BASE_LIMIT) + } + + customer.name.present? ? names_hash.merge("companyname" => customer.name) : names_hash + end + + def isperson + customer.customer_type_individual? ? "T" : "F" + end + + def include_lines? + !integration.legacy_script && !customer.empty_billing_and_shipping_address? + end + + def lines + if customer.same_billing_and_shipping_address? + [ + { + "lineItems" => [ + { + "defaultshipping" => true, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1&.first(ADDR1_LIMIT), + "addr2" => customer.address_line2, + "city" => customer.city&.first(CITY_LIMIT), + "zip" => customer.zipcode, + "state" => customer.state&.first(BASE_LIMIT), + "country" => customer.country + } + } + ], + "sublistId" => "addressbook" + } + ] + else + [ + { + "lineItems" => [ + { + "defaultshipping" => false, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1&.first(ADDR1_LIMIT), + "addr2" => customer.address_line2, + "city" => customer.city&.first(CITY_LIMIT), + "zip" => customer.zipcode, + "state" => customer.state&.first(BASE_LIMIT), + "country" => customer.country + } + }, + { + "defaultshipping" => true, + "defaultbilling" => false, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.shipping_address_line1&.first(ADDR1_LIMIT), + "addr2" => customer.shipping_address_line2, + "city" => customer.shipping_city&.first(CITY_LIMIT), + "zip" => customer.shipping_zipcode, + "state" => customer.shipping_state&.first(BASE_LIMIT), + "country" => customer.shipping_country + } + } + ], + "sublistId" => "addressbook" + } + ] + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/payloads/xero.rb b/app/services/integrations/aggregator/contacts/payloads/xero.rb new file mode 100644 index 0000000..ab79852 --- /dev/null +++ b/app/services/integrations/aggregator/contacts/payloads/xero.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + module Payloads + class Xero < BasePayload + end + end + end + end +end diff --git a/app/services/integrations/aggregator/contacts/update_service.rb b/app/services/integrations/aggregator/contacts/update_service.rb new file mode 100644 index 0000000..fd2c20d --- /dev/null +++ b/app/services/integrations/aggregator/contacts/update_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Contacts + class UpdateService < BaseService + def initialize(integration:, integration_customer:) + @integration_customer = integration_customer + + super(integration:) + end + + def call + Integrations::Hubspot::Contacts::DeployPropertiesService.call(integration:) + + throttle!(:anrok, :hubspot, :netsuite, :xero) + + response = http_client.put_with_response(params, headers) + body = JSON.parse(response.body) + + if body.is_a?(Hash) + process_hash_result(body) + else + process_string_result(body) + end + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result.service_failure!(code:, message:) + end + + delegate :customer, to: :integration_customer + + private + + attr_reader :integration_customer, :subsidiary_id + + def params + Integrations::Aggregator::Contacts::Payloads::Factory.new_instance( + integration:, + integration_customer:, + customer:, + subsidiary_id: + ).update_body + end + end + end + end +end diff --git a/app/services/integrations/aggregator/credit_notes/create_service.rb b/app/services/integrations/aggregator/credit_notes/create_service.rb new file mode 100644 index 0000000..41b79de --- /dev/null +++ b/app/services/integrations/aggregator/credit_notes/create_service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + class CreateService < Integrations::Aggregator::Invoices::BaseService + def initialize(credit_note:) + @credit_note = credit_note + + super(invoice:) + end + + def action_path + "v1/#{provider}/creditnotes" + end + + def call + return result unless integration + return result unless integration.sync_credit_notes + return result unless credit_note.finalized? + return result if payload.integration_credit_note + + throttle!(:anrok, :netsuite, :xero) + + response = http_client.post_with_response(payload.body, headers) + body = JSON.parse(response.body) + + if body.is_a?(Hash) + process_hash_result(body) + else + process_string_result(body) + end + + return result unless result.external_id + + IntegrationResource.create!( + organization_id: integration.organization_id, + integration:, + external_id: result.external_id, + syncable_id: credit_note.id, + syncable_type: "CreditNote", + resource_type: :credit_note + ) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + return result unless [500, 424].include?(e.error_code.to_i) + + raise e + rescue Integrations::Aggregator::BasePayload::Failure => e + deliver_error_webhook(customer:, code: e.code, message: e.code.humanize) + result + end + + def call_async + return result.not_found_failure!(resource: "credit_note") unless credit_note + + ::Integrations::Aggregator::CreditNotes::CreateJob.perform_later(credit_note:) + + result.credit_note_id = credit_note.id + result + end + + private + + attr_reader :credit_note + + delegate :customer, :invoice, to: :credit_note, allow_nil: true + + def payload + Integrations::Aggregator::CreditNotes::Payloads::Factory.new_instance( + integration_customer:, + credit_note: + ) + end + + def process_hash_result(body) + external_id = body["succeededCreditNotes"]&.first.try(:[], "id") + + if external_id + result.external_id = external_id + else + message = body["failedCreditNotes"].first["validation_errors"].map { |error| error["Message"] }.join(". ") + code = "Validation error" + + deliver_error_webhook(customer:, code:, message:) + end + end + + def process_string_result(body) + result.external_id = body + end + end + end + end +end diff --git a/app/services/integrations/aggregator/credit_notes/payloads/anrok.rb b/app/services/integrations/aggregator/credit_notes/payloads/anrok.rb new file mode 100644 index 0000000..7b2e2ed --- /dev/null +++ b/app/services/integrations/aggregator/credit_notes/payloads/anrok.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + module Payloads + class Anrok < BasePayload + end + end + end + end +end diff --git a/app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb b/app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb new file mode 100644 index 0000000..89d20e8 --- /dev/null +++ b/app/services/integrations/aggregator/credit_notes/payloads/base_payload.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + module Payloads + class BasePayload < Integrations::Aggregator::BasePayload + def initialize(integration_customer:, credit_note:) + super(integration: integration_customer.integration, billing_entity: credit_note.customer.billing_entity) + + @credit_note = credit_note + @integration_customer = integration_customer + end + + def body + [ + { + "external_contact_id" => integration_customer.external_customer_id, + "status" => "AUTHORISED", + "issuing_date" => credit_note.issuing_date.to_time.utc.iso8601, + "number" => credit_note.number, + "currency" => credit_note.currency, + "type" => "ACCRECCREDIT", + "fees" => credit_note_items_with_adjusted_taxes(credit_note_items) + coupons + } + ] + end + + def integration_credit_note + @integration_credit_note ||= + IntegrationResource.find_by(integration:, syncable: credit_note, resource_type: "credit_note") + end + + private + + attr_reader :integration_customer, :credit_note + + def credit_note_items + @credit_note_items ||= credit_note.items.map { |credit_note_item| item(credit_note_item) } + end + + def credit_note_items_with_adjusted_taxes(credit_note_items) + taxes_amount_cents_sum = credit_note_items.sum { |f| f["taxes_amount_cents"] } + + return credit_note_items if taxes_amount_cents_sum == credit_note.taxes_amount_cents + + adjusted_first_tax = false + + credit_note_items.map do |credit_note_item| + if credit_note_item["taxes_amount_cents"] > 0 && !adjusted_first_tax + credit_note_item["taxes_amount_cents"] += credit_note.taxes_amount_cents - taxes_amount_cents_sum + adjusted_first_tax = true + end + + credit_note_item + end + end + + def item(credit_note_item) + fee = credit_note_item.fee + + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.credit? + credit_item + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + + unless mapped_item + raise Integrations::Aggregator::BasePayload::Failure.new(nil, code: "invalid_mapping") + end + + precise_unit_amount = credit_note_item.amount_cents + + { + "external_id" => mapped_item.external_id, + "description" => fee.subscription? ? "Subscription" : fee.invoice_name, + "units" => (precise_unit_amount > 0) ? 1 : 0, + "precise_unit_amount" => amount(precise_unit_amount, resource: credit_note_item.credit_note), + "account_code" => mapped_item.external_account_code, + "taxes_amount_cents" => amount(taxes_amount_cents(credit_note_item), resource: credit_note_item.credit_note) + } + end + + def taxes_amount_cents(credit_note_item) + credit_note_item.amount_cents * credit_note_item.credit_note.taxes_rate + end + + def coupons + output = [] + + coupons_amount_cents = credit_note.coupons_adjustment_amount_cents + if coupons_amount_cents > 0 + + output << { + "external_id" => coupon_item.external_id, + "description" => "Coupons", + "units" => 1, + "precise_unit_amount" => -amount(coupons_amount_cents, resource: credit_note), + "taxes_amount_cents" => 0, + "account_code" => coupon_item.external_account_code + } + end + + output + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/credit_notes/payloads/factory.rb b/app/services/integrations/aggregator/credit_notes/payloads/factory.rb new file mode 100644 index 0000000..83cbaf3 --- /dev/null +++ b/app/services/integrations/aggregator/credit_notes/payloads/factory.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + module Payloads + class Factory + def self.new_instance(integration_customer:, credit_note:) + case integration_customer&.integration&.type&.to_s + when "Integrations::NetsuiteIntegration" + Integrations::Aggregator::CreditNotes::Payloads::Netsuite.new(integration_customer:, credit_note:) + when "Integrations::XeroIntegration" + Integrations::Aggregator::CreditNotes::Payloads::Xero.new(integration_customer:, credit_note:) + when "Integrations::AnrokIntegration" + Integrations::Aggregator::CreditNotes::Payloads::Anrok.new(integration_customer:, credit_note:) + else + raise(NotImplementedError) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/credit_notes/payloads/netsuite.rb b/app/services/integrations/aggregator/credit_notes/payloads/netsuite.rb new file mode 100644 index 0000000..651aabe --- /dev/null +++ b/app/services/integrations/aggregator/credit_notes/payloads/netsuite.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + module Payloads + class Netsuite < BasePayload + def body + result = { + "type" => "creditmemo", + "isDynamic" => true, + "columns" => columns, + "lines" => [ + { + "sublistId" => "item", + "lineItems" => credit_note_items + coupons + } + ], + "options" => { + "ignoreMandatoryFields" => false, + "fullCreditNotePayload" => { + "credit_note_payload" => ::V1::CreditNoteSerializer.new( + credit_note, + root_name: "credit_note", + includes: [:items, :applied_taxes, :error_details, customer: [:integration_customers]] + ).serialize + } + } + } + + if tax_item_complete? + result["taxdetails"] = [ + { + "sublistId" => "taxdetails", + "lineItems" => tax_line_items_with_adjusted_taxes + coupon_taxes + } + ] + end + + result + end + + private + + def credit_note_items + items.map { |credit_note_item| item(credit_note_item) } + end + + def tax_line_items + items.map { |credit_note_item| tax_line_item(credit_note_item) } + end + + def items + @items ||= credit_note.items.order(created_at: :asc) + end + + def columns + result = { + "tranid" => credit_note.number, + "entity" => integration_customer.external_customer_id, + "taxregoverride" => true, + "taxdetailsoverride" => true, + "otherrefnum" => credit_note.number, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_id" => credit_note.id, + "tranId" => credit_note.id + } + + if tax_item&.tax_nexus.present? + result["nexus"] = tax_item.tax_nexus + end + + result + end + + def item(credit_note_item) + fee = credit_note_item.fee + + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.credit? + credit_item + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + + unless mapped_item + raise Integrations::Aggregator::BasePayload::Failure.new(nil, code: "invalid_mapping") + end + + { + "item" => mapped_item.external_id, + "account" => mapped_item.external_account_code, + "quantity" => 1, + "rate" => amount(credit_note_item.amount_cents, resource: credit_note_item.credit_note), + "taxdetailsreference" => credit_note_item.id, + "description" => credit_note_item.fee.item_name + } + end + + def tax_line_item(credit_note_item) + { + "taxdetailsreference" => credit_note_item.id, + "taxamount" => amount(taxes_amount(credit_note_item), resource: credit_note_item.credit_note), + "taxbasis" => 1, + "taxrate" => credit_note_item.fee.taxes_rate, + "taxtype" => tax_item.tax_type, + "taxcode" => tax_item.tax_code + } + end + + def tax_line_items_with_adjusted_taxes + taxes_amount_cents_sum = tax_line_items.sum { |f| f["taxamount"].to_d } + + return tax_line_items if taxes_amount_cents_sum == credit_note.taxes_amount_cents + + adjusted_first_tax = false + + tax_line_items.map do |credit_note_item| + if credit_note_item["taxamount"] > 0 && !adjusted_first_tax + amount = amount(credit_note.taxes_amount_cents, resource: credit_note) + credit_note_item["taxamount"] += amount - taxes_amount_cents_sum + adjusted_first_tax = true + end + + credit_note_item + end + end + + def taxes_amount(credit_note_item) + subunit_to_unit = credit_note_item.amount.currency.subunit_to_unit.to_d + amount = credit_note_item.amount_cents.fdiv(subunit_to_unit) * credit_note_item.credit_note.taxes_rate + amount.round(2) + end + + def coupons + output = [] + + if credit_note.coupons_adjustment_amount_cents > 0 + output << { + "item" => coupon_item&.external_id, + "account" => coupon_item&.external_account_code, + "quantity" => 1, + "rate" => -amount(credit_note.coupons_adjustment_amount_cents, resource: credit_note), + "taxdetailsreference" => "coupon_item", + "description" => credit_note.invoice.credits.coupon_kind.map(&:item_name).join(",") + } + end + + output + end + + def coupon_taxes + output = [] + + if credit_note.coupons_adjustment_amount_cents > 0 + output << { + "taxbasis" => 1, + "taxamount" => 0, + "taxrate" => credit_note.taxes_rate, + "taxtype" => tax_item.tax_type, + "taxcode" => tax_item.tax_code, + "taxdetailsreference" => "coupon_item" + } + end + + output + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/credit_notes/payloads/xero.rb b/app/services/integrations/aggregator/credit_notes/payloads/xero.rb new file mode 100644 index 0000000..75b015e --- /dev/null +++ b/app/services/integrations/aggregator/credit_notes/payloads/xero.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module CreditNotes + module Payloads + class Xero < BasePayload + private + + def item(credit_note_item) + item = super + item["item_code"] = item.delete("external_id") + item + end + + def coupons + coupons = super + coupons.each do |coupon| + coupon["item_code"] = coupon.delete("external_id") + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/custom_object_service.rb b/app/services/integrations/aggregator/custom_object_service.rb new file mode 100644 index 0000000..596f29a --- /dev/null +++ b/app/services/integrations/aggregator/custom_object_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class CustomObjectService < BaseService + def initialize(integration:, name:) + @name = name + super(integration:) + end + + def action_path + "v1/#{provider}/custom-object" + end + + def call + throttle!(:hubspot) + + response = http_client.get(headers:, body:) + + result.custom_object = OpenStruct.new(response) + result + rescue LagoHttpClient::HttpError => e + result.service_failure!(code: e.error_code, message: e.message) + end + + private + + attr_reader :name + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def body + { + "name" => name + } + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/base_service.rb b/app/services/integrations/aggregator/invoices/base_service.rb new file mode 100644 index 0000000..69c1cc7 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/base_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + class BaseService < Integrations::Aggregator::BaseService + def initialize(invoice:) + @invoice = invoice + + super(integration:) + end + + private + + attr_reader :invoice + + delegate :customer, to: :invoice, allow_nil: true + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def integration + return nil unless integration_customer + + integration_customer&.integration + end + + def integration_customer + @integration_customer ||= customer&.integration_customers&.accounting_kind&.first + end + + def payload + Integrations::Aggregator::Invoices::Payloads::Factory.new_instance( + integration_customer:, + invoice: + ) + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/create_service.rb b/app/services/integrations/aggregator/invoices/create_service.rb new file mode 100644 index 0000000..b014e84 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/create_service.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + class CreateService < BaseService + INVALID_LOGIN_ATTEMPT = "INVALID_LOGIN_ATTEMPT" + + def action_path + "v1/#{provider}/invoices" + end + + def call + return result unless integration + return result unless integration.sync_invoices + return result unless invoice.finalized? + return result if payload.integration_invoice + + throttle!(:anrok, :netsuite, :xero) + + response = http_client.post_with_response(payload.body, headers) + body = JSON.parse(response.body) + + if body.is_a?(Hash) + process_hash_result(body) + else + process_string_result(body) + end + + Rails.logger.info "Response body: #{body}" + Rails.logger.info "External ID: #{result.external_id}" + + return result unless result.external_id + + Rails.logger.info "Creating integration resource with external ID: #{result.external_id}" + + IntegrationResource.create!( + organization_id: integration.organization_id, + integration:, + external_id: result.external_id, + syncable_id: invoice.id, + syncable_type: "Invoice", + resource_type: :invoice + ) + + Rails.logger.info "Integration resource created. external ID: #{result.external_id}, invoice ID: #{invoice.id}" + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + raise e if retryable_error?(e) + + result.non_retryable_failure!(code:, message:) + rescue Integrations::Aggregator::BasePayload::Failure => e + deliver_error_webhook(customer:, code: e.code, message: e.code.humanize) + result.non_retryable_failure!(code: e.code, message: e.code.humanize) + end + + def call_async + return result.not_found_failure!(resource: "invoice") unless invoice + + ::Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) + + result.invoice_id = invoice.id + result + end + + private + + def process_hash_result(body) + external_id = body["succeededInvoices"]&.first.try(:[], "id") + + if external_id + result.external_id = external_id + else + message = body["failedInvoices"].first["validation_errors"].map { |error| error["Message"] }.join(". ") + code = "Validation error" + + deliver_error_webhook(customer:, code:, message:) + end + end + + def process_string_result(body) + result.external_id = body + end + + def retryable_error?(http_error) + server_error = http_error.error_code.to_i >= 500 || http_error.error_code.to_i == 424 + server_error && !invalid_login_attempt_error?(http_error) + end + + def invalid_login_attempt_error?(http_error) + http_error.error_body.include?(INVALID_LOGIN_ATTEMPT) + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/hubspot/base_service.rb b/app/services/integrations/aggregator/invoices/hubspot/base_service.rb new file mode 100644 index 0000000..2b88283 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/hubspot/base_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Hubspot + class BaseService < Integrations::Aggregator::Invoices::BaseService + def action_path + "v1/#{provider}/records" + end + + private + + def integration_customer + @integration_customer ||= customer&.integration_customers&.hubspot_kind&.first + end + + def payload + Integrations::Aggregator::Invoices::Payloads::Factory.new_instance(integration_customer:, invoice:) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/hubspot/create_customer_association_service.rb b/app/services/integrations/aggregator/invoices/hubspot/create_customer_association_service.rb new file mode 100644 index 0000000..f37db9e --- /dev/null +++ b/app/services/integrations/aggregator/invoices/hubspot/create_customer_association_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Hubspot + class CreateCustomerAssociationService < BaseService + def action_path + "v1/#{provider}/association" + end + + def call + return result if !integration || !integration.sync_invoices || !payload.integration_invoice + + Integrations::Hubspot::Invoices::DeployObjectService.call(integration:) + + throttle!(:hubspot) + + http_client.put_with_response(payload.customer_association_body, headers) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + raise e + rescue Integrations::Aggregator::BasePayload::Failure => e + deliver_error_webhook(customer:, code: e.code, message: e.code.humanize) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/hubspot/create_service.rb b/app/services/integrations/aggregator/invoices/hubspot/create_service.rb new file mode 100644 index 0000000..4f6c285 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/hubspot/create_service.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Hubspot + class CreateService < BaseService + def call + return result unless integration + return result unless integration.sync_invoices + return result unless invoice.finalized? + return result if payload.integration_invoice + + Integrations::Hubspot::Invoices::DeployPropertiesService.call(integration:) + + throttle!(:hubspot) + + response = http_client.post_with_response(payload.create_body, headers) + body = JSON.parse(response.body) + + result.external_id = body["id"] + return result unless result.external_id + + IntegrationResource.create!( + organization_id: integration.organization_id, + integration:, + external_id: result.external_id, + syncable_id: invoice.id, + syncable_type: "Invoice", + resource_type: :invoice + ) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result + end + + def call_async + return result.not_found_failure!(resource: "invoice") unless invoice + + ::Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) + + result.invoice_id = invoice.id + result + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/hubspot/update_service.rb b/app/services/integrations/aggregator/invoices/hubspot/update_service.rb new file mode 100644 index 0000000..617399b --- /dev/null +++ b/app/services/integrations/aggregator/invoices/hubspot/update_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Hubspot + class UpdateService < BaseService + def call + return result unless integration + return result unless integration.sync_invoices + return result unless payload.integration_invoice + + Integrations::Hubspot::Invoices::DeployPropertiesService.call(integration:) + + throttle!(:hubspot) + + response = http_client.put_with_response(payload.update_body, headers) + body = JSON.parse(response.body) + + result.external_id = body["id"] + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result + end + + def call_async + return result.not_found_failure!(resource: "invoice") unless invoice + + ::Integrations::Aggregator::Invoices::Hubspot::UpdateJob.perform_later(invoice:) + + result.invoice_id = invoice.id + result + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/payloads/anrok.rb b/app/services/integrations/aggregator/invoices/payloads/anrok.rb new file mode 100644 index 0000000..d81a73b --- /dev/null +++ b/app/services/integrations/aggregator/invoices/payloads/anrok.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Payloads + class Anrok < BasePayload + def initialize(integration_customer:, invoice:) + super + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/payloads/base_payload.rb b/app/services/integrations/aggregator/invoices/payloads/base_payload.rb new file mode 100644 index 0000000..cc6fb23 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/payloads/base_payload.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Payloads + class BasePayload < Integrations::Aggregator::BasePayload + def initialize(integration_customer:, invoice:) + super(integration: integration_customer.integration, billing_entity: integration_customer.customer.billing_entity) + + @invoice = invoice + @integration_customer = integration_customer + end + + def body + [ + { + "external_contact_id" => integration_customer.external_customer_id, + "status" => "AUTHORISED", + "issuing_date" => invoice.issuing_date.to_time.utc.iso8601, + "payment_due_date" => invoice.payment_due_date.to_time.utc.iso8601, + "number" => invoice.number, + "currency" => invoice.currency, + "type" => "ACCREC", + "fees" => (tax_adjusted_fee_items + discounts) + } + ] + end + + def integration_invoice + @integration_invoice ||= + IntegrationResource.find_by(integration:, syncable: invoice, resource_type: "invoice") + end + + private + + attr_reader :integration_customer, :invoice + attr_accessor :remaining_taxes_amount_cents + + def fees + @fees ||= if invoice.fees.where("amount_cents > ?", 0).exists? + invoice.fees.where("amount_cents > ?", 0).order(created_at: :asc) + else + invoice.fees.order(created_at: :asc) + end + end + + def fee_items + fees.map { |fee| item(fee) } + end + + def tax_adjusted_fee_items + remaining_taxes_amount_cents = invoice.taxes_amount_cents - fee_items.sum { |f| f["taxes_amount_cents"] }.round + + fee_items.map do |fee| + # If no coupon fix the tax rounding issue here + if remaining_taxes_amount_cents.to_i.abs > 0 && + invoice.coupons_amount_cents == 0 && + fee["taxes_amount_cents"] > remaining_taxes_amount_cents.to_i.abs + fee["taxes_amount_cents"] = fee["taxes_amount_cents"] + remaining_taxes_amount_cents.to_i + remaining_taxes_amount_cents = 0 + end + + fee + end + end + + def item(fee) + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.credit? + credit_item + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + + unless mapped_item + raise Integrations::Aggregator::BasePayload::Failure.new(nil, code: "invalid_mapping") + end + + { + "external_id" => mapped_item.external_id, + "description" => fee.subscription? ? "Subscription" : fee.charge_filter&.display_name || fee.invoice_name, + "units" => fee.units, + "precise_unit_amount" => fee.precise_unit_amount, + "account_code" => mapped_item.external_account_code, + "taxes_amount_cents" => fee.taxes_amount_cents + } + end + + def taxes_amount_cents(fee) + fee.amount_cents * fee.taxes_rate + end + + def discounts + output = [] + + if invoice.coupons_amount_cents > 0 + tax_diff_amount_cents = invoice.taxes_amount_cents - fees.sum { |f| f["taxes_amount_cents"] } + + output << { + "external_id" => coupon_item.external_id, + "description" => "Coupons", + "units" => 1, + "precise_unit_amount" => -amount(invoice.coupons_amount_cents, resource: invoice), + "taxes_amount_cents" => -(tax_diff_amount_cents || 0).abs, + "account_code" => coupon_item.external_account_code + } + end + + if credit_item && invoice.prepaid_credit_amount_cents > 0 + output << { + "external_id" => credit_item.external_id, + "description" => "Prepaid credit", + "units" => 1, + "precise_unit_amount" => -amount(invoice.prepaid_credit_amount_cents, resource: invoice), + "taxes_amount_cents" => 0, + "account_code" => credit_item.external_account_code + } + end + + if credit_item && invoice.progressive_billing_credit_amount_cents > 0 + output << { + "external_id" => credit_item.external_id, + "description" => "Usage already billed", + "units" => 1, + "precise_unit_amount" => -amount(invoice.progressive_billing_credit_amount_cents, resource: invoice), + "taxes_amount_cents" => 0, + "account_code" => credit_item.external_account_code + } + end + + if credit_note_item && invoice.credit_notes_amount_cents > 0 + output << { + "external_id" => credit_note_item.external_id, + "description" => "Credit note", + "units" => 1, + "precise_unit_amount" => -amount(invoice.credit_notes_amount_cents, resource: invoice), + "taxes_amount_cents" => 0, + "account_code" => credit_note_item.external_account_code + } + end + + output + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/payloads/factory.rb b/app/services/integrations/aggregator/invoices/payloads/factory.rb new file mode 100644 index 0000000..07f2af6 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/payloads/factory.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Payloads + class Factory + def self.new_instance(integration_customer:, invoice:) + case integration_customer&.integration&.type&.to_s + when "Integrations::NetsuiteIntegration" + Integrations::Aggregator::Invoices::Payloads::Netsuite.new(integration_customer:, invoice:) + when "Integrations::XeroIntegration" + Integrations::Aggregator::Invoices::Payloads::Xero.new(integration_customer:, invoice:) + when "Integrations::AnrokIntegration" + Integrations::Aggregator::Invoices::Payloads::Anrok.new(integration_customer:, invoice:) + when "Integrations::HubspotIntegration" + Integrations::Aggregator::Invoices::Payloads::Hubspot.new(integration_customer:, invoice:) + else + raise(NotImplementedError) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/payloads/hubspot.rb b/app/services/integrations/aggregator/invoices/payloads/hubspot.rb new file mode 100644 index 0000000..42c18a8 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/payloads/hubspot.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Payloads + class Hubspot < BasePayload + def create_body + unless invoice.file_url + raise Integrations::Aggregator::BasePayload::Failure.new(nil, code: "invoice.file_url missing") + end + + { + "objectType" => integration.invoices_object_type_id, + "input" => { + "associations" => [], + "properties" => { + "lago_invoice_id" => invoice.id, + "lago_invoice_number" => invoice.number, + "lago_invoice_issuing_date" => formatted_date(invoice.issuing_date), + "lago_invoice_payment_due_date" => formatted_date(invoice.payment_due_date), + "lago_invoice_payment_overdue" => invoice.payment_overdue, + "lago_invoice_type" => invoice.invoice_type, + "lago_invoice_status" => invoice.status, + "lago_invoice_payment_status" => invoice.payment_status, + "lago_invoice_currency" => invoice.currency, + "lago_invoice_total_amount" => total_amount, + "lago_invoice_total_due_amount" => total_due_amount, + "lago_invoice_subtotal_excluding_taxes" => subtotal_excluding_taxes, + "lago_invoice_file_url" => invoice.file_url + } + } + } + end + + def update_body + unless invoice.file_url + raise Integrations::Aggregator::BasePayload::Failure.new(nil, code: "invoice.file_url missing") + end + + { + "objectId" => integration_invoice.external_id, + "objectType" => integration.invoices_object_type_id, + "input" => { + "properties" => { + "lago_invoice_id" => invoice.id, + "lago_invoice_number" => invoice.number, + "lago_invoice_issuing_date" => formatted_date(invoice.issuing_date), + "lago_invoice_payment_due_date" => formatted_date(invoice.payment_due_date), + "lago_invoice_payment_overdue" => invoice.payment_overdue, + "lago_invoice_type" => invoice.invoice_type, + "lago_invoice_status" => invoice.status, + "lago_invoice_payment_status" => invoice.payment_status, + "lago_invoice_currency" => invoice.currency, + "lago_invoice_total_amount" => total_amount, + "lago_invoice_total_due_amount" => total_due_amount, + "lago_invoice_subtotal_excluding_taxes" => subtotal_excluding_taxes, + "lago_invoice_file_url" => invoice.file_url + } + } + } + end + + def customer_association_body + { + "objectType" => integration.reload.invoices_object_type_id, + "objectId" => integration_invoice.external_id, + "toObjectType" => integration_customer.object_type, + "toObjectId" => integration_customer.external_customer_id, + "input" => [] + } + end + + private + + def total_amount + amount(invoice.total_amount_cents, resource: invoice) + end + + def total_due_amount + amount(invoice.total_due_amount_cents, resource: invoice) + end + + def subtotal_excluding_taxes + amount(invoice.sub_total_excluding_taxes_amount_cents, resource: invoice) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/payloads/netsuite.rb b/app/services/integrations/aggregator/invoices/payloads/netsuite.rb new file mode 100644 index 0000000..8943883 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/payloads/netsuite.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Payloads + class Netsuite < BasePayload + MAX_DECIMALS = 15 + NS_QUANTITY_LIMIT = 10_000_000_000 + + def body + result = { + "type" => "invoice", + "isDynamic" => true, + "columns" => columns, + "lines" => [ + { + "sublistId" => "item", + "lineItems" => fee_items + discounts + } + ], + "options" => { + "ignoreMandatoryFields" => false, + "fullInvoicePayload" => { + "invoice_payload" => ::V1::InvoiceSerializer.new( + invoice, + root_name: "invoice", + includes: %i[customer integration_customers billing_periods subscriptions fees credits metadata applied_taxes] + ).serialize + } + } + } + + if tax_item_complete? + result["taxdetails"] = [ + { + "sublistId" => "taxdetails", + "lineItems" => tax_line_items + discount_taxes + } + ] + end + + result + end + + private + + def columns + result = { + "tranid" => invoice.number, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_invoice_link" => invoice_url, + "trandate" => issuing_date, + "duedate" => due_date, + "taxdetailsoverride" => true, + "custbody_lago_id" => invoice.id, + "entity" => integration_customer.external_customer_id, + "lago_plan_codes" => invoice.invoice_subscriptions.map(&:subscription).map(&:plan).map(&:code).join(",") + } + + mapped_currency = netsuite_currency_for(currency: invoice.currency) + if mapped_currency.present? + result["currency"] = mapped_currency.to_s + end + + if tax_item&.tax_nexus.present? + result["nexus"] = tax_item.tax_nexus + end + + result["taxregoverride"] = true + + result + end + + def tax_line_items + fees.map { |fee| tax_line_item(fee) } + end + + def tax_line_item(fee) + { + "taxdetailsreference" => fee.id, + "taxamount" => amount(fee.taxes_amount_cents, resource: invoice), + "taxbasis" => 1, + "taxrate" => fee.taxes_rate, + "taxtype" => tax_item.tax_type, + "taxcode" => tax_item.tax_code + } + end + + def invoice_url + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{invoice.customer.organization.slug}/customer/#{invoice.customer.id}/", "invoice/#{invoice.id}/overview").to_s + end + + def due_date + invoice.payment_due_date&.strftime("%-m/%-d/%Y") + end + + def issuing_date + invoice.issuing_date&.strftime("%-m/%-d/%Y") + end + + def item(fee) + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.credit? + credit_item + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + + unless mapped_item + raise Integrations::Aggregator::BasePayload::Failure.new(nil, code: "invalid_mapping") + end + + from_property = fee.charge? ? "charges_from_datetime" : "from_datetime" + to_property = fee.charge? ? "charges_to_datetime" : "to_datetime" + + quantity_value = limited_rate(fee.units) + unit_rate_value = limited_rate(fee.precise_unit_amount) + line_amount_value = limited_rate(amount(fee.amount_cents, resource: invoice)) + + if quantity_value.respond_to?(:abs) && quantity_value.abs >= NS_QUANTITY_LIMIT + quantity_value = 1 + unit_rate_value = line_amount_value + end + + { + "item" => mapped_item.external_id, + "account" => mapped_item.external_account_code, + "quantity" => quantity_value, + "rate" => unit_rate_value, + "amount" => line_amount_value, + "taxdetailsreference" => fee.id, + "custcol_service_period_date_from" => fee.properties[from_property]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => fee.properties[to_property]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => fee.item_name, + "item_source" => fee.item_source + } + end + + def netsuite_currency_for(currency:) + mapping = IntegrationCollectionMappings::NetsuiteCollectionMapping.find_by( + integration_id: integration_customer.integration_id, + mapping_type: :currencies + ) + mapping&.currencies&.dig(currency) + end + + def discounts + output = [] + + if coupon_item && invoice.coupons_amount_cents > 0 + output << { + "item" => coupon_item.external_id, + "account" => coupon_item.external_account_code, + "quantity" => 1, + "rate" => -amount(invoice.coupons_amount_cents, resource: invoice), + "taxdetailsreference" => "coupon_item", + "description" => invoice.credits.coupon_kind.map(&:item_name).join(","), + "item_source" => "coupons" + } + end + + if credit_item && invoice.prepaid_credit_amount_cents > 0 + output << { + "item" => credit_item.external_id, + "account" => credit_item.external_account_code, + "quantity" => 1, + "rate" => -amount(invoice.prepaid_credit_amount_cents, resource: invoice), + "taxdetailsreference" => "credit_item", + "description" => "Prepaid credits", + "item_source" => "prepaid_credits" + } + end + + if credit_item && invoice.progressive_billing_credit_amount_cents > 0 + output << { + "item" => credit_item.external_id, + "account" => credit_item.external_account_code, + "quantity" => 1, + "rate" => -amount(invoice.progressive_billing_credit_amount_cents, resource: invoice), + "taxdetailsreference" => "credit_item_progressive_billing", + "description" => invoice.credits.progressive_billing_invoice_kind.map(&:item_name).join(","), + "item_source" => "progressive_billing_credits" + } + end + + if credit_note_item && invoice.credit_notes_amount_cents > 0 + output << { + "item" => credit_note_item.external_id, + "account" => credit_note_item.external_account_code, + "quantity" => 1, + "rate" => -amount(invoice.credit_notes_amount_cents, resource: invoice), + "taxdetailsreference" => "credit_note_item", + "description" => invoice.credits.credit_note_kind.map(&:item_name).join(","), + "item_source" => "credit_note_credits" + } + end + + output + end + + def discount_taxes + output = [] + + if invoice.coupons_amount_cents > 0 + tax_diff_amount_cents = invoice.taxes_amount_cents - fees.sum { |f| f["taxes_amount_cents"] } + + output << { + "taxbasis" => 1, + "taxamount" => amount(tax_diff_amount_cents, resource: invoice), + "taxrate" => invoice.taxes_rate, + "taxtype" => tax_item.tax_type, + "taxcode" => tax_item.tax_code, + "taxdetailsreference" => "coupon_item" + } + end + + if credit_item && invoice.prepaid_credit_amount_cents > 0 + output << { + "taxbasis" => 1, + "taxamount" => 0, + "taxrate" => invoice.taxes_rate, + "taxtype" => tax_item.tax_type, + "taxcode" => tax_item.tax_code, + "taxdetailsreference" => "credit_item" + } + end + + if credit_item && invoice.progressive_billing_credit_amount_cents > 0 + output << { + "taxbasis" => 1, + "taxamount" => 0, + "taxrate" => invoice.taxes_rate, + "taxtype" => tax_item.tax_type, + "taxcode" => tax_item.tax_code, + "taxdetailsreference" => "credit_item_progressive_billing" + } + end + + if credit_note_item && invoice.credit_notes_amount_cents > 0 + output << { + "taxbasis" => 1, + "taxamount" => 0, + "taxrate" => invoice.taxes_rate, + "taxtype" => tax_item.tax_type, + "taxcode" => tax_item.tax_code, + "taxdetailsreference" => "credit_note_item" + } + end + + output + end + + def limited_rate(precise_unit_amount) + unit_amount_str = precise_unit_amount.to_s + + return precise_unit_amount if unit_amount_str.length <= MAX_DECIMALS + + decimal_position = unit_amount_str.index(".") + + return precise_unit_amount unless decimal_position + + precise_unit_amount.round(MAX_DECIMALS - 1 - decimal_position) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/invoices/payloads/xero.rb b/app/services/integrations/aggregator/invoices/payloads/xero.rb new file mode 100644 index 0000000..0965842 --- /dev/null +++ b/app/services/integrations/aggregator/invoices/payloads/xero.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Invoices + module Payloads + class Xero < BasePayload + def initialize(integration_customer:, invoice:) + super + end + + def item(fee) + base_item = super + base_item["item_code"] = base_item.delete("external_id") + + if fee.precise_unit_amount.round(2) != fee.precise_unit_amount + base_item["units"] = 1 + base_item["precise_unit_amount"] = amount(fee.amount_cents, resource: invoice) + end + + base_item + end + + def discounts + discounts = super + + discounts.each do |discount| + discount["item_code"] = discount.delete("external_id") + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/items_service.rb b/app/services/integrations/aggregator/items_service.rb new file mode 100644 index 0000000..e6495b9 --- /dev/null +++ b/app/services/integrations/aggregator/items_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class ItemsService < BaseService + LIMIT = 450 + MAX_SUBSEQUENT_REQUESTS = 15 + + def action_path + "v1/#{provider}/items" + end + + def call + @cursor = "" + @items = [] + fetched_items = [] + + ActiveRecord::Base.transaction do + integration.integration_items.where(item_type: :standard).destroy_all + + MAX_SUBSEQUENT_REQUESTS.times do + response = http_client.get(headers:, params:) + fetched_items.concat(response["records"]) + @cursor = response["next_cursor"] + + break if cursor.blank? + end + + handle_items(deduplicate_items(fetched_items)) + end + + result.items = items + result + end + + private + + attr_reader :cursor, :items + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def handle_items(new_items) + new_items.each do |item| + integration_item = IntegrationItem.new( + organization_id: integration.organization_id, + integration:, + external_id: item[integration.external_id_key], + external_account_code: item["account_code"], + external_name: item["name"], + item_type: :standard + ) + + integration_item.save! + + @items << integration_item + end + end + + def params + { + limit: LIMIT + }.merge(cursor.present? ? {cursor:} : {}) + end + + # Nango uses incremental sync to synchronize Xero items which means it doesn't not take deleted record into + # account and therefore stores items with duplicate `item_code` (which is the external_id_key for xero items). + # This method deduplicates items based on the `item_code` and keeps the most recently modified one based on the + # `last_modified_at` field in `_nango_metadata`. + # + # Note that this will have no impact on other integrations as they rely on `id` field as `external_id_key`. So + # if Nango uses incremental sync for those integrations, we may retrieve deleted items. + def deduplicate_items(items) + items + .group_by { |item| item[integration.external_id_key] } + .map do |_external_id, duplicates| + duplicates.max_by { |item| item.dig("_nango_metadata", "last_modified_at") || "" } + end + end + end + end +end diff --git a/app/services/integrations/aggregator/out_of_memory_error.rb b/app/services/integrations/aggregator/out_of_memory_error.rb new file mode 100644 index 0000000..9e9ef78 --- /dev/null +++ b/app/services/integrations/aggregator/out_of_memory_error.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class OutOfMemoryError < StandardError + end + end +end diff --git a/app/services/integrations/aggregator/payments/create_service.rb b/app/services/integrations/aggregator/payments/create_service.rb new file mode 100644 index 0000000..92a7257 --- /dev/null +++ b/app/services/integrations/aggregator/payments/create_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Payments + class CreateService < Integrations::Aggregator::Invoices::BaseService + def initialize(payment:) + @payment = payment + + super(invoice:) + end + + def action_path + "v1/#{provider}/payments" + end + + def call + return result unless integration + return result unless integration.sync_payments + return result unless invoice.finalized? + return result if payload.integration_payment + + throttle!(:netsuite, :xero) + + response = http_client.post_with_response(payload.body, headers) + body = JSON.parse(response.body) + + if body.is_a?(Hash) + process_hash_result(body) + else + process_string_result(body) + end + + return result unless result.external_id + + IntegrationResource.create!( + organization_id: integration.organization_id, + integration:, + external_id: result.external_id, + syncable_id: payment.id, + syncable_type: "Payment", + resource_type: :payment + ) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + return result unless [500, 424].include?(e.error_code.to_i) + + raise e + end + + def call_async + return result.not_found_failure!(resource: "payment") unless payment + + ::Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) + + result.payment_id = payment.id + result + end + + private + + attr_reader :payment + + delegate :customer, to: :payment, allow_nil: true + + def invoice + payment&.payable + end + + def payload + Integrations::Aggregator::Payments::Payloads::Factory.new_instance(integration:, payment:) + end + + def process_hash_result(body) + external_id = body["succeededPayment"]&.first.try(:[], "id") + + if external_id + result.external_id = external_id + else + message = body["failedPayments"].first["validation_errors"].map { |error| error["Message"] }.join(". ") + code = "Validation error" + + deliver_error_webhook(customer:, code:, message:) + end + end + + def process_string_result(body) + result.external_id = body + end + end + end + end +end diff --git a/app/services/integrations/aggregator/payments/payloads/base_payload.rb b/app/services/integrations/aggregator/payments/payloads/base_payload.rb new file mode 100644 index 0000000..f0c640c --- /dev/null +++ b/app/services/integrations/aggregator/payments/payloads/base_payload.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Payments + module Payloads + class BasePayload < Integrations::Aggregator::BasePayload + def initialize(integration:, payment:) + super(integration:, billing_entity: payment.payable.customer.billing_entity) + + @payment = payment + end + + def body + [ + { + "invoice_id" => integration_invoice.external_id, + "account_code" => account_item&.external_account_code, + "date" => payment.created_at.utc.iso8601, + "amount_cents" => payment.amount_cents + } + ] + end + + def integration_payment + @integration_payment ||= + IntegrationResource.find_by(integration:, syncable: payment, resource_type: "payment") + end + + private + + attr_reader :payment + + def invoice + payment.payable + end + + def integration_invoice + integration_resource = + invoice.integration_resources + .where(integration:, resource_type: "invoice", syncable_type: "Invoice").first + + unless integration_resource + raise Integrations::Aggregator::BasePayload::Failure.new(nil, code: "invoice_missing") + end + + integration_resource + end + + def integration_customer + @integration_customer ||= invoice.customer&.integration_customers&.accounting_kind&.first + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/payments/payloads/factory.rb b/app/services/integrations/aggregator/payments/payloads/factory.rb new file mode 100644 index 0000000..0219f04 --- /dev/null +++ b/app/services/integrations/aggregator/payments/payloads/factory.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Payments + module Payloads + class Factory + def self.new_instance(integration:, payment:) + case integration.type.to_s + when "Integrations::NetsuiteIntegration" + Integrations::Aggregator::Payments::Payloads::Netsuite.new(integration:, payment:) + when "Integrations::XeroIntegration" + Integrations::Aggregator::Payments::Payloads::Xero.new(integration:, payment:) + else + raise(NotImplementedError) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/payments/payloads/netsuite.rb b/app/services/integrations/aggregator/payments/payloads/netsuite.rb new file mode 100644 index 0000000..e6730dd --- /dev/null +++ b/app/services/integrations/aggregator/payments/payloads/netsuite.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Payments + module Payloads + class Netsuite < BasePayload + def body + { + "isDynamic" => true, + "columns" => { + "customer" => integration_customer.external_customer_id, + "payment" => amount(payment.amount_cents, resource: invoice) + }, + "options" => { + "ignoreMandatoryFields" => false + }, + "type" => "customerpayment", + "lines" => [ + { + "lineItems" => [ + { + "amount" => amount(payment.amount_cents, resource: invoice), + "apply" => true, + "doc" => integration_invoice.external_id + } + ], + "sublistId" => "apply" + } + ] + } + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/payments/payloads/xero.rb b/app/services/integrations/aggregator/payments/payloads/xero.rb new file mode 100644 index 0000000..4c03ebe --- /dev/null +++ b/app/services/integrations/aggregator/payments/payloads/xero.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Payments + module Payloads + class Xero < BasePayload + end + end + end + end +end diff --git a/app/services/integrations/aggregator/request_limit_error.rb b/app/services/integrations/aggregator/request_limit_error.rb new file mode 100644 index 0000000..f40457c --- /dev/null +++ b/app/services/integrations/aggregator/request_limit_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class RequestLimitError < StandardError + def initialize(http_error) + @http_error = http_error + super(http_error.message) + end + + attr_reader :http_error + end + end +end diff --git a/app/services/integrations/aggregator/send_restlet_endpoint_service.rb b/app/services/integrations/aggregator/send_restlet_endpoint_service.rb new file mode 100644 index 0000000..b9ca15e --- /dev/null +++ b/app/services/integrations/aggregator/send_restlet_endpoint_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class SendRestletEndpointService < BaseService + def action_path + "connection/#{integration.connection_id}/metadata" + end + + def call + return unless integration.type == "Integrations::NetsuiteIntegration" + return unless integration.script_endpoint_url + + payload = { + restletEndpoint: integration.script_endpoint_url + } + + response = http_client.post_with_response(payload, headers) + result.response = response + + result + end + + private + + def headers + { + "Provider-Config-Key" => "netsuite-tba", + "Authorization" => "Bearer #{secret_key}" + } + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/base_service.rb b/app/services/integrations/aggregator/subscriptions/base_service.rb new file mode 100644 index 0000000..9302267 --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/base_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + class BaseService < Integrations::Aggregator::BaseService + def initialize(subscription:) + @subscription = subscription + + super(integration:) + end + + private + + attr_reader :subscription + + delegate :customer, to: :subscription, allow_nil: true + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def integration + return nil unless integration_customer + + integration_customer&.integration + end + + def integration_customer + @integration_customer ||= customer&.integration_customers&.accounting_kind&.first + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/hubspot/base_service.rb b/app/services/integrations/aggregator/subscriptions/hubspot/base_service.rb new file mode 100644 index 0000000..498c8ab --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/hubspot/base_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Hubspot + class BaseService < Integrations::Aggregator::Subscriptions::BaseService + def action_path + "v1/#{provider}/records" + end + + private + + def integration_customer + @integration_customer ||= customer&.integration_customers&.hubspot_kind&.first + end + + def payload + Integrations::Aggregator::Subscriptions::Payloads::Factory.new_instance( + integration_customer:, + subscription: + ) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/hubspot/create_customer_association_service.rb b/app/services/integrations/aggregator/subscriptions/hubspot/create_customer_association_service.rb new file mode 100644 index 0000000..98c98b3 --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/hubspot/create_customer_association_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Hubspot + class CreateCustomerAssociationService < BaseService + def action_path + "v1/#{provider}/association" + end + + def call + return result if !integration || !integration.sync_subscriptions || !payload.integration_subscription + + Integrations::Hubspot::Subscriptions::DeployObjectService.call(integration:) + + throttle!(:hubspot) + + http_client.put_with_response(payload.customer_association_body, headers) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + raise e + rescue Integrations::Aggregator::BasePayload::Failure => e + deliver_error_webhook(customer:, code: e.code, message: e.code.humanize) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/hubspot/create_service.rb b/app/services/integrations/aggregator/subscriptions/hubspot/create_service.rb new file mode 100644 index 0000000..54e8499 --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/hubspot/create_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Hubspot + class CreateService < BaseService + def call + return result unless integration + return result unless integration.sync_subscriptions + + throttle!(:hubspot) + + Integrations::Hubspot::Subscriptions::DeployPropertiesService.call(integration:) + + response = http_client.post_with_response(payload.create_body, headers) + body = JSON.parse(response.body) + + result.external_id = body["id"] + return result unless result.external_id + + IntegrationResource.create!( + organization_id: integration.organization_id, + integration:, + external_id: result.external_id, + syncable_id: subscription.id, + syncable_type: "Subscription", + resource_type: :subscription + ) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result + end + + def call_async + return result.not_found_failure!(resource: "subscription") unless subscription + + ::Integrations::Aggregator::Subscriptions::Hubspot::CreateJob.perform_later(subscription:) + + result.subscription_id = subscription.id + result + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/hubspot/update_service.rb b/app/services/integrations/aggregator/subscriptions/hubspot/update_service.rb new file mode 100644 index 0000000..e261f6b --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/hubspot/update_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Hubspot + class UpdateService < BaseService + def call + return result unless integration + return result unless integration.sync_subscriptions + return result unless payload.integration_subscription + + throttle!(:hubspot) + + Integrations::Hubspot::Subscriptions::DeployPropertiesService.call(integration:) + + response = http_client.put_with_response(payload.update_body, headers) + body = JSON.parse(response.body) + + result.external_id = body["id"] + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + deliver_error_webhook(customer:, code:, message:) + + result + end + + def call_async + return result.not_found_failure!(resource: "subscription") unless subscription + + ::Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription:) + + result.subscription_id = subscription.id + result + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/payloads/base_payload.rb b/app/services/integrations/aggregator/subscriptions/payloads/base_payload.rb new file mode 100644 index 0000000..d12a1cb --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/payloads/base_payload.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Payloads + class BasePayload < Integrations::Aggregator::BasePayload + def initialize(integration_customer:, subscription:) + super(integration: integration_customer.integration, billing_entity: subscription.customer.billing_entity) + + @subscription = subscription + @integration_customer = integration_customer + end + + def integration_subscription + @integration_subscription ||= + IntegrationResource.find_by(integration:, syncable: subscription, resource_type: "subscription") + end + + private + + attr_reader :integration_customer, :subscription + + def subscription_url + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{integration_customer.customer.organization.slug}/customer/#{integration_customer.customer.id}/subscription/#{subscription.id}/overview").to_s + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/payloads/factory.rb b/app/services/integrations/aggregator/subscriptions/payloads/factory.rb new file mode 100644 index 0000000..080ddff --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/payloads/factory.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Payloads + class Factory + def self.new_instance(integration_customer:, subscription:) + case integration_customer&.integration&.type&.to_s + when "Integrations::HubspotIntegration" + Integrations::Aggregator::Subscriptions::Payloads::Hubspot.new(integration_customer:, subscription:) + else + raise(NotImplementedError) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subscriptions/payloads/hubspot.rb b/app/services/integrations/aggregator/subscriptions/payloads/hubspot.rb new file mode 100644 index 0000000..a44b4f9 --- /dev/null +++ b/app/services/integrations/aggregator/subscriptions/payloads/hubspot.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Subscriptions + module Payloads + class Hubspot < Integrations::Aggregator::Subscriptions::Payloads::BasePayload + def create_body + { + "objectType" => integration.subscriptions_object_type_id, + "input" => { + "associations" => [], + "properties" => { + "lago_subscription_id" => subscription.id, + "lago_external_subscription_id" => subscription.external_id, + "lago_billing_time" => subscription.billing_time, + "lago_subscription_name" => subscription.name, + "lago_subscription_plan_code" => subscription.plan.code, + "lago_subscription_status" => subscription.status, + "lago_subscription_created_at" => formatted_date(subscription.created_at), + "lago_subscription_started_at" => formatted_date(subscription.started_at), + "lago_subscription_ending_at" => formatted_date(subscription.ending_at), + "lago_subscription_at" => formatted_date(subscription.subscription_at), + "lago_subscription_terminated_at" => formatted_date(subscription.terminated_at), + "lago_subscription_trial_ended_at" => formatted_date(subscription.trial_ended_at), + "lago_subscription_link" => subscription_url + } + } + } + end + + def update_body + { + "objectId" => integration_subscription.external_id, + "objectType" => integration.subscriptions_object_type_id, + "input" => { + "properties" => { + "lago_subscription_id" => subscription.id, + "lago_external_subscription_id" => subscription.external_id, + "lago_billing_time" => subscription.billing_time, + "lago_subscription_name" => subscription.name, + "lago_subscription_plan_code" => subscription.plan.code, + "lago_subscription_status" => subscription.status, + "lago_subscription_created_at" => formatted_date(subscription.created_at), + "lago_subscription_started_at" => formatted_date(subscription.started_at), + "lago_subscription_ending_at" => formatted_date(subscription.ending_at), + "lago_subscription_at" => formatted_date(subscription.subscription_at), + "lago_subscription_terminated_at" => formatted_date(subscription.terminated_at), + "lago_subscription_trial_ended_at" => formatted_date(subscription.trial_ended_at), + "lago_subscription_link" => subscription_url + } + } + } + end + + def customer_association_body + { + "objectType" => integration.subscriptions_object_type_id, + "objectId" => integration_subscription.external_id, + "toObjectType" => integration_customer.object_type, + "toObjectId" => integration_customer.external_customer_id, + "input" => [] + } + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/subsidiaries_service.rb b/app/services/integrations/aggregator/subsidiaries_service.rb new file mode 100644 index 0000000..226f7d4 --- /dev/null +++ b/app/services/integrations/aggregator/subsidiaries_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class SubsidiariesService < BaseService + Subsidiary = Data.define(:external_id, :external_name) + + def action_path + "v1/#{provider}/subsidiaries" + end + + def call + response = http_client.get(headers:) + + result.subsidiaries = handle_subsidiaries(response["records"]) + + result + end + + private + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def handle_subsidiaries(subsidiaries) + subsidiaries.map do |subsidiary| + Subsidiary.new(external_id: subsidiary["id"], external_name: subsidiary["name"]) + end + end + end + end +end diff --git a/app/services/integrations/aggregator/sync_service.rb b/app/services/integrations/aggregator/sync_service.rb new file mode 100644 index 0000000..36b2bee --- /dev/null +++ b/app/services/integrations/aggregator/sync_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + class SyncService < BaseService + def action_path + "sync/trigger" + end + + def call + payload = { + provider_config_key: provider_key, + syncs: sync_list + } + + response = http_client.post_with_response(payload, headers) + result.response = response + + result + end + + private + + # NOTE: Extend it with other providers if needed + def sync_list + list = case integration.type + when "Integrations::NetsuiteIntegration" + { + subsidiaries: "netsuite-subsidiaries-sync" + } + when "Integrations::XeroIntegration" + { + accounts: "xero-accounts-sync", + items: "xero-items-sync", + contacts: "xero-contacts-sync" + } + end + + return [list[:items]] if options[:only_items] + return [list[:accounts]] if options[:only_accounts] + + list.values + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/avalara/fetch_company_id_service.rb b/app/services/integrations/aggregator/taxes/avalara/fetch_company_id_service.rb new file mode 100644 index 0000000..3e44ec0 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/avalara/fetch_company_id_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Avalara + class FetchCompanyIdService < Integrations::Aggregator::BaseService + def action_path + "v1/#{provider}/companies" + end + + def call + throttle!(:avalara) + + response = http_client.post_with_response(payload, headers) + body = JSON.parse(response.body) + + received_company = body["companies"]&.first + + if received_company.blank? + code = "company_not_found" + message = "Company cannot be found in Avalara based on the provided code" + + deliver_integration_error_webhook(integration:, code:, message:) + + return result.service_failure!(code:, message:) + end + + result.company = received_company + + result + rescue LagoHttpClient::HttpError => e + code = code(e) + message = message(e) + + deliver_integration_error_webhook(integration:, code:, message:) + + result.service_failure!(code:, message:) + end + + private + + def payload + [ + { + "company_code" => integration.company_code + } + ] + end + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/base_service.rb b/app/services/integrations/aggregator/taxes/base_service.rb new file mode 100644 index 0000000..1bbd0d8 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/base_service.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + class BaseService < Integrations::Aggregator::BaseService + SPECIAL_TAXATION_TYPES = %w[exempt notCollecting productNotTaxed jurisNotTaxed jurisHasNoTax].freeze + CUSTOMER_ADDRESS_INVALID = "customerAddressCouldNotResolve" + + def initialize + super(integration:) + end + + private + + def integration + return nil unless integration_customer + + integration_customer&.integration + end + + def integration_customer + @integration_customer ||= begin + int_customers = customer.integration_customers + int_customers.find { |ic| ic.tax_kind? } + end + end + + def headers + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{secret_key}", + "Provider-Config-Key" => provider_key + } + end + + def assign_external_customer_id + return unless result.success? + return if integration_customer.external_customer_id + + integration_customer.update!(external_customer_id: customer.external_id) + end + + def process_response(body) + fees = body["succeededInvoices"]&.first.try(:[], "fees") + + if fees + result.fees = fees.map do |fee| + taxes_to_pay = fee["tax_amount_cents"] + + TaxResult.new( + item_key: fee["item_key"], + item_id: fee["item_id"], + item_code: fee["item_code"], + amount_cents: fee["amount_cents"], + tax_amount_cents: taxes_to_pay, + tax_breakdown: tax_breakdown(fee["tax_breakdown"], taxes_to_pay) + ) + end + result.succeeded_id = body["succeededInvoices"].first["id"] + else + code, message = retrieve_error_details(body["failedInvoices"].first["validation_errors"]) + + # Temp fix for the API limit issue + message = "API limit" if message == "Internal server error: resource contention." + + unless message.include?("API limit") + deliver_tax_error_webhook(customer:, code:, message:) if customer.persisted? # Do not send this webhook in preview mode + end + + result.service_failure!(code:, message:) + end + end + + def tax_breakdown(breakdown, taxes_to_pay) + breakdown.map do |b| + if SPECIAL_TAXATION_TYPES.include?(b["type"]) + TaxResult::TaxBreakdownItem.new( + name: humanize_tax_name(b["reason"].presence || b["type"]), + rate: "0.00", + tax_amount: 0, + type: b["type"] + ) + elsif b["rate"] + # If there are taxes, that client shouldn't pay, we nullify the taxes + if taxes_to_pay.zero? && b["tax_amount"].positive? + TaxResult::TaxBreakdownItem.new( + name: "Tax", + rate: "0.00", + tax_amount: 0, + type: "tax" + ) + else + TaxResult::TaxBreakdownItem.new( + name: b["name"], + rate: b["rate"], + tax_amount: b["tax_amount"], + type: b["type"] + ) + end + else + TaxResult::TaxBreakdownItem.new( + name: humanize_tax_name(b["reason"].presence || b["type"] || "unknown_taxation"), + rate: "0.00", + tax_amount: 0, + type: b["type"] || "unknown_taxation" + ) + end + end + end + + def retrieve_error_details(validation_error) + if validation_error.is_a?(Hash) + code = validation_error["type"] + message = "Service failure" + return [code, message] + end + + code = "validationError" + message = validation_error + [code, message] + end + + def humanize_tax_name(camelized_name) + camelized_name.underscore.humanize + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/credit_notes/create_service.rb b/app/services/integrations/aggregator/taxes/credit_notes/create_service.rb new file mode 100644 index 0000000..527cd50 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/credit_notes/create_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module CreditNotes + class CreateService < Integrations::Aggregator::Taxes::BaseService + def initialize(credit_note:) + @credit_note = credit_note + + super() + end + + def action_path + "v1/#{provider}/finalized_invoices" + end + + def call + return result unless integration + return result unless ::Integrations::BaseIntegration::INTEGRATION_TAX_TYPES.include?(integration.type) + + response = http_client.post_with_response(payload, headers) + body = JSON.parse(response.body) + + process_response(body) + assign_external_customer_id + create_integration_resource if result.succeeded_id + + result + rescue LagoHttpClient::HttpError => e + code = code(e) + message = message(e) + + result.service_failure!(code:, message:) + end + + private + + attr_reader :credit_note + + delegate :customer, to: :credit_note, allow_nil: true + + def payload + Integrations::Aggregator::Taxes::CreditNotes::Payloads::Factory.new_instance( + integration:, + customer:, + integration_customer:, + credit_note: + ).body + end + + def create_integration_resource + IntegrationResource.create!( + organization_id: integration.organization_id, + syncable_id: credit_note.id, + syncable_type: "CreditNote", + external_id: result.succeeded_id, + integration_id: integration.id, + resource_type: "credit_note" + ) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/credit_notes/payloads/anrok.rb b/app/services/integrations/aggregator/taxes/credit_notes/payloads/anrok.rb new file mode 100644 index 0000000..b1a0427 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/credit_notes/payloads/anrok.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module CreditNotes + module Payloads + class Anrok < BasePayload + def initialize(integration:, customer:, integration_customer:, credit_note:) + super(integration:, billing_entity: customer.billing_entity) + + @customer = customer + @integration_customer = integration_customer + @credit_note = credit_note + end + + def body + [ + { + "id" => "cn_#{credit_note.id}", + "issuing_date" => credit_note.issuing_date, + "currency" => credit_note.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id || customer.external_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number + }, + "fees" => credit_note.items.order(created_at: :asc).map { |item| cn_item(item) }, + "tax_date" => credit_note.invoice.issuing_date + } + ] + end + + def cn_item(item) + fee = item.fee + + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on_id.present? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + mapped_item ||= OpenStruct.new + + { + "item_id" => fee.item_id, + "item_code" => mapped_item.external_id, + "amount_cents" => item.sub_total_excluding_taxes_amount_cents.round * -1 + } + end + + private + + attr_reader :customer, :integration_customer, :credit_note + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/credit_notes/payloads/avalara.rb b/app/services/integrations/aggregator/taxes/credit_notes/payloads/avalara.rb new file mode 100644 index 0000000..a3044b5 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/credit_notes/payloads/avalara.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module CreditNotes + module Payloads + class Avalara < BasePayload + def initialize(integration:, customer:, integration_customer:, credit_note:) + super(integration:, billing_entity: customer.billing_entity) + + @customer = customer + @integration_customer = integration_customer + @credit_note = credit_note + @billing_entity = customer.billing_entity + end + + def body + [ + { + "id" => "cn_#{credit_note.id}", + "type" => "returnInvoice", + "issuing_date" => credit_note.issuing_date, + "currency" => credit_note.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "region" => customer.shipping_state || customer.state, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number + }, + "billing_entity" => { + "address_line_1" => billing_entity&.address_line1, + "city" => billing_entity&.city, + "zip" => billing_entity&.zipcode, + "region" => billing_entity&.state, + "country" => billing_entity&.country + }, + "fees" => credit_note.items.order(created_at: :asc).map { |item| cn_item(item) } + } + ] + end + + def cn_item(item) + fee = item.fee + + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on_id.present? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + mapped_item ||= OpenStruct.new + + { + "item_id" => fee.item_id, + "item_code" => mapped_item.external_id, + "unit" => fee.units, + "amount" => item_amount(item, fee) + } + end + + private + + attr_reader :customer, :integration_customer, :credit_note, :billing_entity + + def item_amount(item, fee) + amount = (item.sub_total_excluding_taxes_amount_cents.round * -1).fdiv(fee.amount.currency.subunit_to_unit) + + amount.to_s + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/credit_notes/payloads/factory.rb b/app/services/integrations/aggregator/taxes/credit_notes/payloads/factory.rb new file mode 100644 index 0000000..da01642 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/credit_notes/payloads/factory.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module CreditNotes + module Payloads + class Factory + def self.new_instance(integration:, customer:, integration_customer:, credit_note:) + case integration.type.to_s + when "Integrations::AnrokIntegration" + Integrations::Aggregator::Taxes::CreditNotes::Payloads::Anrok.new( + integration:, + customer:, + integration_customer:, + credit_note: + ) + when "Integrations::AvalaraIntegration" + Integrations::Aggregator::Taxes::CreditNotes::Payloads::Avalara.new( + integration:, + customer:, + integration_customer:, + credit_note: + ) + else + raise(NotImplementedError) + end + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/base_service.rb b/app/services/integrations/aggregator/taxes/invoices/base_service.rb new file mode 100644 index 0000000..3c9cb13 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/base_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class BaseService < Integrations::Aggregator::Taxes::BaseService + def initialize(invoice:, fees: nil) + @invoice = invoice + @fees = fees || invoice.fees + + super() + end + + private + + attr_reader :invoice, :fees + + delegate :customer, to: :invoice, allow_nil: true + + def process_void_response(body) + invoice_id = body["succeededInvoices"]&.first.try(:[], "id") + + if invoice_id + result.invoice_id = invoice_id + else + code, message = retrieve_error_details(body["failedInvoices"].first["validation_errors"]) + deliver_tax_error_webhook(customer:, code:, message:) + + result.service_failure!(code:, message:) + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb b/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb new file mode 100644 index 0000000..01bc88e --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/create_draft_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class CreateDraftService < BaseService + def action_path + "v1/#{provider}/draft_invoices" + end + + def call + return result unless integration + return result unless ::Integrations::BaseIntegration::INTEGRATION_TAX_TYPES.include?(integration.type) + + throttle!(:anrok, :avalara) + + response = http_client.post_with_response(payload, headers) + body = parse_response(response) + + process_response(body) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + raise Integrations::Aggregator::BadGatewayError.new(e.error_body, e.uri) if bad_gateway_error?(e) + + code = code(e) + message = message(e) + + result.service_failure!(code:, message:) + end + + private + + def payload + Integrations::Aggregator::Taxes::Invoices::Payloads::Factory.new_instance( + integration:, + invoice:, + customer:, + integration_customer:, + fees: + ).body + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/create_service.rb b/app/services/integrations/aggregator/taxes/invoices/create_service.rb new file mode 100644 index 0000000..1299845 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/create_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class CreateService < BaseService + def action_path + "v1/#{provider}/finalized_invoices" + end + + def call + return result unless integration + return result unless ::Integrations::BaseIntegration::INTEGRATION_TAX_TYPES.include?(integration.type) + + throttle!(:anrok, :avalara) + + response = http_client.post_with_response(payload, headers) + body = parse_response(response) + + process_response(body) + assign_external_customer_id + create_integration_resource if integration.type.to_s == "Integrations::AvalaraIntegration" && result.succeeded_id + + result + rescue LagoHttpClient::HttpError => e + raise Integrations::Aggregator::RequestLimitError(e) if request_limit_error?(e) + raise Integrations::Aggregator::BadGatewayError.new(e.error_body, e.uri) if bad_gateway_error?(e) + + code = code(e) + message = message(e) + + result.service_failure!(code:, message:) + end + + private + + def payload + payload_body = Integrations::Aggregator::Taxes::Invoices::Payloads::Factory.new_instance( + integration:, + invoice:, + customer:, + integration_customer:, + fees: + ).body + + invoice_data = payload_body.first + invoice_data["id"] = invoice.id + if integration.type.to_s == "Integrations::AvalaraIntegration" + invoice_data["type"] = invoice.voided? ? "returnInvoice" : "salesInvoice" + end + + [invoice_data] + end + + def create_integration_resource + IntegrationResource.create!( + organization_id: integration.organization_id, + syncable_id: invoice.id, + syncable_type: "Invoice", + external_id: result.succeeded_id, + integration_id: integration.id, + resource_type: :invoice + ) + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/negate_service.rb b/app/services/integrations/aggregator/taxes/invoices/negate_service.rb new file mode 100644 index 0000000..120dd39 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/negate_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class NegateService < BaseService + def action_path + "v1/#{provider}/negate_invoices" + end + + def call + return result unless integration + return result unless integration.type == "Integrations::AnrokIntegration" + + response = http_client.post_with_response(payload, headers) + body = JSON.parse(response.body) + + process_void_response(body) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + result.service_failure!(code:, message:) + end + + private + + def payload + [ + { + "id" => invoice.id, + "voided_id" => "#{invoice.id}_voided" + } + ] + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/payloads/anrok.rb b/app/services/integrations/aggregator/taxes/invoices/payloads/anrok.rb new file mode 100644 index 0000000..09dd8e8 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/payloads/anrok.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + module Payloads + class Anrok < BasePayload + def initialize(integration:, customer:, invoice:, integration_customer:, fees: []) + super(integration:, billing_entity: customer.billing_entity) + + @customer = customer + @integration_customer = integration_customer + @invoice = invoice + @fees = fees + end + + def body + [ + { + "issuing_date" => issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id || customer.external_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number + }, + "fees" => fees.map { |fee| fee_item(fee) }, + "tax_date" => issuing_date + } + ] + end + + def fee_item(fee) + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on_id.present? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + mapped_item ||= empty_struct + + { + "item_key" => fee.item_key, + "item_id" => fee.id || fee.item_id, + "item_code" => mapped_item.external_id, + "amount_cents" => fee.sub_total_excluding_taxes_amount_cents&.to_i + } + end + + private + + attr_reader :customer, :integration_customer, :invoice, :fees + + def empty_struct + @empty_struct ||= OpenStruct.new + end + + def issuing_date + # NOTE: Anrok API requires issuing date to be 30 days in the future at most. + [invoice.issuing_date, 30.days.from_now.to_date].min + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/payloads/avalara.rb b/app/services/integrations/aggregator/taxes/invoices/payloads/avalara.rb new file mode 100644 index 0000000..672ba46 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/payloads/avalara.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + module Payloads + class Avalara < BasePayload + def initialize(integration:, customer:, invoice:, integration_customer:, fees: []) + super(integration:, billing_entity: customer.billing_entity) + + @customer = customer + @integration_customer = integration_customer + @invoice = invoice + @fees = fees + end + + def body + [ + { + "issuing_date" => invoice.issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "region" => customer.shipping_state || customer.state, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number + }, + "billing_entity" => { + "address_line_1" => billing_entity&.address_line1, + "city" => billing_entity&.city, + "zip" => billing_entity&.zipcode, + "region" => billing_entity&.state, + "country" => billing_entity&.country + }, + "fees" => fees.map { |fee| fee_item(fee) } + } + ] + end + + def fee_item(fee) + mapped_item = if fee.charge? + billable_metric_item(fee) + elsif fee.add_on_id.present? + add_on_item(fee) + elsif fee.fixed_charge? + fixed_charge_item(fee) + elsif fee.commitment? + commitment_item + elsif fee.subscription? + subscription_item + end + mapped_item ||= empty_struct + + { + "item_key" => fee.item_key, + "item_id" => fee.id || fee.item_id, + "item_code" => mapped_item.external_id, + "unit" => fee.units, + "amount" => item_amount(fee) + } + end + + private + + attr_reader :customer, :integration_customer, :invoice, :fees + + def empty_struct + @empty_struct ||= OpenStruct.new + end + + def item_amount(fee) + amount = fee.sub_total_excluding_taxes_amount_cents&.to_i&.fdiv(subunit_to_unit(fee)) + + amount *= -1 if invoice.voided? + + amount.to_s + end + + def subunit_to_unit(fee) + if fee.is_a?(Fee) + fee.amount.currency.subunit_to_unit + else + amount_cents = fee.amount_cents || fee.sub_total_excluding_taxes_amount_cents + Fee.new(amount_currency: invoice.currency, amount_cents:).amount.currency.subunit_to_unit + end + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/payloads/factory.rb b/app/services/integrations/aggregator/taxes/invoices/payloads/factory.rb new file mode 100644 index 0000000..ab1bfb9 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/payloads/factory.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + module Payloads + class Factory + def self.new_instance(integration:, invoice:, customer:, integration_customer:, fees:) + case integration.type.to_s + when "Integrations::AnrokIntegration" + Integrations::Aggregator::Taxes::Invoices::Payloads::Anrok.new( + integration:, + invoice:, + customer:, + integration_customer:, + fees: + ) + when "Integrations::AvalaraIntegration" + Integrations::Aggregator::Taxes::Invoices::Payloads::Avalara.new( + integration:, + invoice:, + customer:, + integration_customer:, + fees: + ) + else + raise(NotImplementedError) + end + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/invoices/void_service.rb b/app/services/integrations/aggregator/taxes/invoices/void_service.rb new file mode 100644 index 0000000..37b911e --- /dev/null +++ b/app/services/integrations/aggregator/taxes/invoices/void_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + module Invoices + class VoidService < BaseService + def action_path + "v1/#{provider}/void_invoices" + end + + def call + return result unless integration + return result unless ::Integrations::BaseIntegration::INTEGRATION_TAX_TYPES.include?(integration.type) + + response = http_client.post_with_response(payload, headers) + body = JSON.parse(response.body) + + process_void_response(body) + + result + rescue LagoHttpClient::HttpError => e + raise RequestLimitError(e) if request_limit_error?(e) + + code = code(e) + message = message(e) + + result.service_failure!(code:, message:) + end + + private + + def payload + case integration.type.to_s + when "Integrations::AvalaraIntegration" + [ + { + "company_code" => integration.company_code, + "id" => invoice.id + } + ] + else + [ + { + "id" => invoice.id + } + ] + end + end + end + end + end + end +end diff --git a/app/services/integrations/aggregator/taxes/tax_result.rb b/app/services/integrations/aggregator/taxes/tax_result.rb new file mode 100644 index 0000000..5f22b86 --- /dev/null +++ b/app/services/integrations/aggregator/taxes/tax_result.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Integrations + module Aggregator + module Taxes + TaxResult = Data.define( + :item_key, + :item_id, + :item_code, + :amount_cents, + :tax_amount_cents, + :tax_breakdown + ) + + TaxResult::TaxBreakdownItem = Data.define( + :name, + :rate, + :tax_amount, + :type + ) + end + end +end diff --git a/app/services/integrations/anrok/create_service.rb b/app/services/integrations/anrok/create_service.rb new file mode 100644 index 0000000..ecc8c84 --- /dev/null +++ b/app/services/integrations/anrok/create_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Integrations + module Anrok + class CreateService < Integrations::CreateService + def call(**args) + organization = Organization.find_by(id: args[:organization_id]) + + return result.forbidden_failure! unless License.premium? + + integration = Integrations::AnrokIntegration.new( + organization:, + name: args[:name], + code: args[:code], + connection_id: args[:connection_id], + api_key: args[:api_key] + ) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end + end +end diff --git a/app/services/integrations/anrok/update_service.rb b/app/services/integrations/anrok/update_service.rb new file mode 100644 index 0000000..5e71dd6 --- /dev/null +++ b/app/services/integrations/anrok/update_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Integrations + module Anrok + class UpdateService < Integrations::UpdateService + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + return result.forbidden_failure! unless License.premium? + + integration.name = params[:name] if params.key?(:name) + integration.code = params[:code] if params.key?(:code) + integration.api_key = params[:api_key] if params.key?(:api_key) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/app/services/integrations/avalara/create_service.rb b/app/services/integrations/avalara/create_service.rb new file mode 100644 index 0000000..cbb5c18 --- /dev/null +++ b/app/services/integrations/avalara/create_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Integrations + module Avalara + class CreateService < Integrations::CreateService + Result = BaseResult[:integration] + + def initialize(params:) + @params = params + + super + end + + def call + organization = Organization.find_by(id: params[:organization_id]) + + unless organization.avalara_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration = Integrations::AvalaraIntegration.new( + organization:, + name: params[:name], + code: params[:code], + company_code: params[:company_code], + connection_id: params[:connection_id], + account_id: params[:account_id], + license_key: params[:license_key] + ) + + integration.save! + + Integrations::Avalara::FetchCompanyIdJob.perform_later(integration:) + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :params + end + end +end diff --git a/app/services/integrations/avalara/fetch_company_id_service.rb b/app/services/integrations/avalara/fetch_company_id_service.rb new file mode 100644 index 0000000..8fc95bc --- /dev/null +++ b/app/services/integrations/avalara/fetch_company_id_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Integrations + module Avalara + class FetchCompanyIdService < BaseService + def initialize(integration:) + @integration = integration + super + end + + def call + return result unless integration.type == "Integrations::AvalaraIntegration" + return result if integration.company_id.present? + + provider_result = Integrations::Aggregator::Taxes::Avalara::FetchCompanyIdService.call(integration:) + + integration.update!(company_id: provider_result.company["id"]) if provider_result.success? + + provider_result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration + end + end +end diff --git a/app/services/integrations/avalara/update_service.rb b/app/services/integrations/avalara/update_service.rb new file mode 100644 index 0000000..ec8ac26 --- /dev/null +++ b/app/services/integrations/avalara/update_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + module Avalara + class UpdateService < Integrations::UpdateService + Result = BaseResult[:integration] + + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + unless integration.organization.avalara_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration.name = params[:name] if params.key?(:name) + integration.code = params[:code] if params.key?(:code) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/app/services/integrations/create_service.rb b/app/services/integrations/create_service.rb new file mode 100644 index 0000000..7d5ad29 --- /dev/null +++ b/app/services/integrations/create_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Integrations + class CreateService < BaseService + # Guarantees security logging for integration creation. + # Subclasses are unaware of the logging — the only requirement + # is that `result.integration` is set upon the successful `call`. + module SecurityLogging + def call(...) # rubocop:disable Lago/ServiceCall + super.tap { |r| register_security_log(r.integration) if r.success? } + end + end + + def self.inherited(subclass) + super + subclass.prepend(SecurityLogging) + end + + private + + def register_security_log(integration) + Utils::SecurityLog.produce( + organization: integration.organization, + log_type: "integration", + log_event: "integration.created", + resources: {integration_name: integration.name, integration_type: integration.provider_key} + ) + end + end +end diff --git a/app/services/integrations/destroy_service.rb b/app/services/integrations/destroy_service.rb new file mode 100644 index 0000000..489a8b9 --- /dev/null +++ b/app/services/integrations/destroy_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Integrations + class DestroyService < BaseService + def initialize(integration:) + @integration = integration + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + integration.destroy! + + result.integration = integration + register_security_log(integration) + result + end + + private + + attr_reader :integration + + def register_security_log(integration) + Utils::SecurityLog.produce( + organization: integration.organization, + log_type: "integration", + log_event: "integration.deleted", + resources: {integration_name: integration.name, integration_type: integration.provider_key} + ) + end + end +end diff --git a/app/services/integrations/hubspot/companies/deploy_properties_service.rb b/app/services/integrations/hubspot/companies/deploy_properties_service.rb new file mode 100644 index 0000000..38a1a91 --- /dev/null +++ b/app/services/integrations/hubspot/companies/deploy_properties_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Companies + class DeployPropertiesService < Integrations::Aggregator::BaseService + VERSION = 1 + + def action_path + "v1/hubspot/properties" + end + + def call + return result unless integration.type == "Integrations::HubspotIntegration" + return result if integration.companies_properties_version == VERSION + + throttle!(:hubspot) + + response = http_client.post_with_response(payload, headers) + ActiveRecord::Base.transaction do + integration.settings = integration.reload.settings + integration.companies_properties_version = VERSION + integration.save! + end + result.response = response + result + rescue LagoHttpClient::HttpError => e + message = message(e) + deliver_integration_error_webhook(integration:, code: "integration_error", message:) + result + end + + private + + def headers + { + "Provider-Config-Key" => "hubspot", + "Authorization" => "Bearer #{secret_key}", + "Connection-Id" => integration.connection_id + } + end + + def payload + { + objectType: "companies", + inputs: [ + { + groupName: "companyinformation", + name: "lago_customer_id", + label: "Lago Customer Id", + type: "string", + fieldType: "text", + displayOrder: -1, + hasUniqueValue: true, + searchableInGlobalSearch: true, + formField: true + }, + { + groupName: "companyinformation", + name: "lago_customer_external_id", + label: "Lago Customer External Id", + type: "string", + fieldType: "text", + displayOrder: -1, + searchableInGlobalSearch: true, + formField: true + }, + { + groupName: "companyinformation", + name: "lago_billing_email", + label: "Lago Billing Email", + type: "string", + fieldType: "text", + searchableInGlobalSearch: true, + formField: true + }, + { + groupName: "companyinformation", + name: "lago_tax_identification_number", + label: "Lago Tax Identification Number", + type: "string", + fieldType: "text", + searchableInGlobalSearch: true, + formField: true + }, + { + groupName: "companyinformation", + name: "lago_customer_link", + label: "Lago Customer Link", + type: "string", + fieldType: "text", + searchableInGlobalSearch: true, + formField: true + } + ] + }.freeze + end + end + end + end +end diff --git a/app/services/integrations/hubspot/contacts/deploy_properties_service.rb b/app/services/integrations/hubspot/contacts/deploy_properties_service.rb new file mode 100644 index 0000000..d49ad8f --- /dev/null +++ b/app/services/integrations/hubspot/contacts/deploy_properties_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Contacts + class DeployPropertiesService < Integrations::Aggregator::BaseService + VERSION = 1 + + def action_path + "v1/hubspot/properties" + end + + def call + return result unless integration.type == "Integrations::HubspotIntegration" + return result if integration.contacts_properties_version == VERSION + + throttle!(:hubspot) + + response = http_client.post_with_response(payload, headers) + ActiveRecord::Base.transaction do + integration.settings = integration.reload.settings + integration.contacts_properties_version = VERSION + integration.save! + end + result.response = response + result + rescue LagoHttpClient::HttpError => e + message = message(e) + deliver_integration_error_webhook(integration:, code: "integration_error", message:) + result + end + + private + + def headers + { + "Provider-Config-Key" => "hubspot", + "Authorization" => "Bearer #{secret_key}", + "Connection-Id" => integration.connection_id + } + end + + def payload + { + objectType: "contacts", + inputs: [ + { + groupName: "contactinformation", + name: "lago_customer_id", + label: "Lago Customer Id", + type: "string", + fieldType: "text", + displayOrder: -1, + hasUniqueValue: true, + searchableInGlobalSearch: true, + formField: true + }, + { + groupName: "contactinformation", + name: "lago_customer_external_id", + label: "Lago Customer External Id", + type: "string", + fieldType: "text", + displayOrder: -1, + hasUniqueValue: true, + searchableInGlobalSearch: true, + formField: true + }, + { + groupName: "contactinformation", + name: "lago_billing_email", + label: "Lago Billing Email", + type: "string", + fieldType: "text", + searchableInGlobalSearch: true, + formField: true + }, + { + groupName: "contactinformation", + name: "lago_customer_link", + label: "Lago Customer Link", + type: "string", + fieldType: "text", + searchableInGlobalSearch: true, + formField: true + } + ] + }.freeze + end + end + end + end +end diff --git a/app/services/integrations/hubspot/create_service.rb b/app/services/integrations/hubspot/create_service.rb new file mode 100644 index 0000000..acb4f55 --- /dev/null +++ b/app/services/integrations/hubspot/create_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + class CreateService < Integrations::CreateService + attr_reader :params + + def initialize(params:) + @params = params + + super + end + + def call + organization = Organization.find_by(id: params[:organization_id]) + + unless organization.hubspot_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration = Integrations::HubspotIntegration.new( + organization:, + name: params[:name], + code: params[:code], + connection_id: params[:connection_id], + default_targeted_object: params[:default_targeted_object], + sync_invoices: ActiveModel::Type::Boolean.new.cast(params[:sync_invoices]), + sync_subscriptions: ActiveModel::Type::Boolean.new.cast(params[:sync_subscriptions]) + ) + + integration.save! + + if integration.type == "Integrations::HubspotIntegration" + Integrations::Aggregator::SyncCustomObjectsAndPropertiesJob.perform_later(integration:) + Integrations::Hubspot::SavePortalIdJob.perform_later(integration:) + end + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end + end +end diff --git a/app/services/integrations/hubspot/invoices/deploy_object_service.rb b/app/services/integrations/hubspot/invoices/deploy_object_service.rb new file mode 100644 index 0000000..a6642fc --- /dev/null +++ b/app/services/integrations/hubspot/invoices/deploy_object_service.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Invoices + class DeployObjectService < Integrations::Aggregator::BaseService + VERSION = 1 + + def action_path + "v1/hubspot/object" + end + + def call + return result unless integration.type == "Integrations::HubspotIntegration" + if integration.invoices_properties_version == VERSION && integration.invoices_object_type_id.present? + return result + end + + custom_object_result = Integrations::Aggregator::CustomObjectService.call(integration:, name: "LagoInvoices") + if custom_object_result.success? + save_object_type_id(custom_object_result.custom_object&.objectTypeId) + return result + end + + throttle!(:hubspot) + + response = http_client.post_with_response(payload, headers) + ActiveRecord::Base.transaction do + save_object_type_id(JSON.parse(response.body)["objectTypeId"]) + end + result.response = response + result + rescue LagoHttpClient::HttpError => e + message = message(e) + deliver_integration_error_webhook(integration:, code: "integration_error", message:) + result + end + + private + + def save_object_type_id(object_type_id) + integration.settings = integration.reload.settings + integration.invoices_object_type_id = object_type_id + integration.invoices_properties_version = VERSION + integration.save! + end + + def headers + { + "Provider-Config-Key" => "hubspot", + "Authorization" => "Bearer #{secret_key}", + "Connection-Id" => integration.connection_id + } + end + + def payload + { + name: "LagoInvoices", + description: "Invoices issued by Lago billing engine", + requiredProperties: [ + "lago_invoice_id" + ], + labels: { + singular: "LagoInvoice", + plural: "LagoInvoices" + }, + primaryDisplayProperty: "lago_invoice_number", + secondaryDisplayProperties: %w[lago_invoice_status lago_invoice_id], + searchableProperties: %w[lago_invoice_number lago_invoice_id], + properties: [ + { + name: "lago_invoice_id", + label: "Lago Invoice Id", + type: "string", + fieldType: "text", + hasUniqueValue: true, + searchableInGlobalSearch: true + }, + { + name: "lago_invoice_number", + label: "Lago Invoice Number", + type: "string", + fieldType: "text", + searchableInGlobalSearch: true + }, + { + name: "lago_invoice_issuing_date", + label: "Lago Invoice Issuing Date", + type: "date", + fieldType: "date" + }, + { + name: "lago_invoice_payment_due_date", + label: "Lago Invoice Payment Due Date", + type: "date", + fieldType: "date" + }, + { + name: "lago_invoice_payment_overdue", + label: "Lago Invoice Payment Overdue", + groupName: "LagoInvoices", + type: "bool", + fieldType: "booleancheckbox", + options: [ + { + label: "True", + value: "true" + }, + { + label: "False", + value: "false" + } + ] + }, + { + name: "lago_invoice_type", + label: "Lago Invoice Type", + type: "string", + fieldType: "text" + }, + { + name: "lago_invoice_status", + label: "Lago Invoice Status", + type: "string", + fieldType: "text" + }, + { + name: "lago_invoice_payment_status", + label: "Lago Invoice Payment Status", + type: "string", + fieldType: "text" + }, + { + name: "lago_invoice_currency", + label: "Lago Invoice Currency", + type: "string", + fieldType: "text" + }, + { + name: "lago_invoice_total_amount", + label: "Lago Invoice Total Amount", + type: "number", + fieldType: "number" + }, + { + name: "lago_invoice_subtotal_excluding_taxes", + label: "Lago Invoice Subtotal Excluding Taxes", + type: "number", + fieldType: "number" + }, + { + name: "lago_invoice_file_url", + label: "Lago Invoice File URL", + type: "string", + fieldType: "file" + } + ], + associatedObjects: %w[COMPANY CONTACT] + } + end + end + end + end +end diff --git a/app/services/integrations/hubspot/invoices/deploy_properties_service.rb b/app/services/integrations/hubspot/invoices/deploy_properties_service.rb new file mode 100644 index 0000000..86156a4 --- /dev/null +++ b/app/services/integrations/hubspot/invoices/deploy_properties_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Invoices + class DeployPropertiesService < Integrations::Aggregator::BaseService + VERSION = 2 + + def action_path + "v1/hubspot/properties" + end + + def call + return result unless integration.type == "Integrations::HubspotIntegration" + return result if integration.invoices_properties_version == VERSION + + throttle!(:hubspot) + + response = http_client.post_with_response(payload, headers) + ActiveRecord::Base.transaction do + integration.settings = integration.reload.settings + integration.invoices_properties_version = VERSION + integration.save! + end + result.response = response + result + rescue LagoHttpClient::HttpError => e + message = message(e) + deliver_integration_error_webhook(integration:, code: "integration_error", message:) + result + end + + private + + def headers + { + "Provider-Config-Key" => "hubspot", + "Authorization" => "Bearer #{secret_key}", + "Connection-Id" => integration.connection_id + } + end + + def payload + { + objectType: integration.invoices_object_type_id, + inputs: [ + { + groupName: "lagoinvoices_information", + name: "lago_invoice_total_due_amount", + label: "Lago Invoice Total Due Amount", + type: "number", + fieldType: "number" + } + ] + } + end + end + end + end +end diff --git a/app/services/integrations/hubspot/save_portal_id_service.rb b/app/services/integrations/hubspot/save_portal_id_service.rb new file mode 100644 index 0000000..d1d06c3 --- /dev/null +++ b/app/services/integrations/hubspot/save_portal_id_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + class SavePortalIdService < BaseService + def initialize(integration:) + @integration = integration + super + end + + def call + return result unless integration.type == "Integrations::HubspotIntegration" + return result if integration.portal_id.present? + + account_information_result = Integrations::Aggregator::AccountInformationService.call(integration:) + + integration.update!(portal_id: account_information_result.account_information.id) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration + end + end +end diff --git a/app/services/integrations/hubspot/subscriptions/deploy_object_service.rb b/app/services/integrations/hubspot/subscriptions/deploy_object_service.rb new file mode 100644 index 0000000..a092cc8 --- /dev/null +++ b/app/services/integrations/hubspot/subscriptions/deploy_object_service.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Subscriptions + class DeployObjectService < Integrations::Aggregator::BaseService + VERSION = 1 + + def action_path + "v1/hubspot/object" + end + + def call + return result unless integration.type == "Integrations::HubspotIntegration" + if integration.subscriptions_properties_version == VERSION && + integration.subscriptions_object_type_id.present? + return result + end + + custom_object_result = Integrations::Aggregator::CustomObjectService.call(integration:, name: "LagoSubscriptions") + if custom_object_result.success? + save_object_type_id(custom_object_result.custom_object&.objectTypeId) + return result + end + + throttle!(:hubspot) + + response = http_client.post_with_response(payload, headers) + ActiveRecord::Base.transaction do + save_object_type_id(JSON.parse(response.body)["objectTypeId"]) + end + result.response = response + result + rescue LagoHttpClient::HttpError => e + message = message(e) + deliver_integration_error_webhook(integration:, code: "integration_error", message:) + result + end + + private + + def save_object_type_id(object_type_id) + integration.settings = integration.reload.settings + integration.subscriptions_object_type_id = object_type_id + integration.subscriptions_properties_version = VERSION + integration.save! + end + + def headers + { + "Provider-Config-Key" => "hubspot", + "Authorization" => "Bearer #{secret_key}", + "Connection-Id" => integration.connection_id + } + end + + def payload + { + secondaryDisplayProperties: [ + "lago_external_subscription_id" + ], + requiredProperties: [ + "lago_subscription_id" + ], + searchableProperties: %w[lago_subscription_id lago_external_subscription_id], + name: "LagoSubscriptions", + associatedObjects: %w[COMPANY CONTACT], + properties: [ + { + name: "lago_subscription_id", + label: "Lago Subscription Id", + type: "string", + fieldType: "text", + hasUniqueValue: true, + searchableInGlobalSearch: true + }, + { + name: "lago_external_subscription_id", + label: "Lago External Subscription Id", + type: "string", + fieldType: "text", + searchableInGlobalSearch: true + }, + { + name: "lago_subscription_name", + label: "Lago Subscription Name", + type: "string", + fieldType: "text" + }, + { + name: "lago_subscription_plan_code", + label: "Lago Subscription Plan Code", + type: "string", + fieldType: "text" + }, + { + name: "lago_subscription_status", + label: "Lago Subscription Status", + type: "string", + fieldType: "text" + }, + { + name: "lago_subscription_created_at", + label: "Lago Subscription Created At", + type: "date", + fieldType: "date" + }, + { + name: "lago_subscription_started_at", + label: "Lago Subscription Started At", + type: "date", + fieldType: "date" + }, + { + name: "lago_subscription_ending_at", + label: "Lago Subscription Ending At", + type: "date", + fieldType: "date" + }, + { + name: "lago_subscription_at", + label: "Lago Subscription At", + type: "date", + fieldType: "date" + }, + { + name: "lago_subscription_terminated_at", + label: "Lago Subscription Terminated At", + type: "date", + fieldType: "date" + }, + { + name: "lago_subscription_trial_ended_at", + label: "Lago Subscription Trial Ended At", + type: "date", + fieldType: "date" + }, + { + name: "lago_billing_time", + label: "Lago Billing Time", + type: "enumeration", + fieldType: "radio", + displayOrder: -1, + hasUniqueValue: false, + searchableInGlobalSearch: true, + formField: true, + options: [ + { + label: "Calendar", + value: "calendar", + displayOrder: 1 + }, + { + label: "Anniversary", + value: "anniversary", + displayOrder: 2 + } + ] + } + ], + labels: { + singular: "LagoSubscription", + plural: "LagoSubscriptions" + }, + primaryDisplayProperty: "lago_subscription_id", + description: "string" + } + end + end + end + end +end diff --git a/app/services/integrations/hubspot/subscriptions/deploy_properties_service.rb b/app/services/integrations/hubspot/subscriptions/deploy_properties_service.rb new file mode 100644 index 0000000..e5e604f --- /dev/null +++ b/app/services/integrations/hubspot/subscriptions/deploy_properties_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + module Subscriptions + class DeployPropertiesService < Integrations::Aggregator::BaseService + VERSION = 2 + + def action_path + "v1/hubspot/properties" + end + + def call + return unless integration.type == "Integrations::HubspotIntegration" + return result if integration.subscriptions_properties_version == VERSION + + throttle!(:hubspot) + + response = http_client.post_with_response(payload, headers) + ActiveRecord::Base.transaction do + integration.settings = integration.reload.settings + integration.subscriptions_properties_version = VERSION + integration.save! + end + result.response = response + result + rescue LagoHttpClient::HttpError => e + message = message(e) + deliver_integration_error_webhook(integration:, code: "integration_error", message:) + result + end + + private + + def headers + { + "Provider-Config-Key" => "hubspot", + "Authorization" => "Bearer #{secret_key}", + "Connection-Id" => integration.connection_id + } + end + + def payload + { + objectType: integration.subscriptions_object_type_id, + inputs: [ + { + groupName: "lagosubscriptions_information", + name: "lago_subscription_link", + label: "Lago Subscription Link", + type: "string", + fieldType: "text" + } + ] + } + end + end + end + end +end diff --git a/app/services/integrations/hubspot/update_service.rb b/app/services/integrations/hubspot/update_service.rb new file mode 100644 index 0000000..09d3260 --- /dev/null +++ b/app/services/integrations/hubspot/update_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Integrations + module Hubspot + class UpdateService < Integrations::UpdateService + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + unless integration.organization.hubspot_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration.name = params[:name] if params.key?(:name) + integration.code = params[:code] if params.key?(:code) + integration.default_targeted_object = params[:default_targeted_object] if params.key?(:default_targeted_object) + integration.sync_invoices = params[:sync_invoices] if params.key?(:sync_invoices) + integration.sync_subscriptions = params[:sync_subscriptions] if params.key?(:sync_subscriptions) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/app/services/integrations/netsuite/create_service.rb b/app/services/integrations/netsuite/create_service.rb new file mode 100644 index 0000000..67c7978 --- /dev/null +++ b/app/services/integrations/netsuite/create_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Integrations + module Netsuite + class CreateService < Integrations::CreateService + attr_reader :params + + def initialize(params:) + @params = params + + super + end + + def call + organization = Organization.find_by(id: params[:organization_id]) + + unless organization.netsuite_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration = Integrations::NetsuiteIntegration.new( + organization:, + name: params[:name], + code: params[:code], + client_id: params[:client_id], + client_secret: params[:client_secret], + account_id: params[:account_id], + token_id: params[:token_id], + token_secret: params[:token_secret], + connection_id: params[:connection_id], + script_endpoint_url: params[:script_endpoint_url], + sync_credit_notes: ActiveModel::Type::Boolean.new.cast(params[:sync_credit_notes]), + sync_invoices: ActiveModel::Type::Boolean.new.cast(params[:sync_invoices]), + sync_payments: ActiveModel::Type::Boolean.new.cast(params[:sync_payments]) + ) + + integration.save! + + if integration.type == "Integrations::NetsuiteIntegration" + Integrations::Aggregator::SendRestletEndpointJob.perform_later(integration:) + Integrations::Aggregator::PerformSyncJob.set(wait: 2.seconds).perform_later(integration:, sync_items: false) + end + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end + end +end diff --git a/app/services/integrations/netsuite/update_service.rb b/app/services/integrations/netsuite/update_service.rb new file mode 100644 index 0000000..735df19 --- /dev/null +++ b/app/services/integrations/netsuite/update_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Integrations + module Netsuite + class UpdateService < Integrations::UpdateService + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + unless integration.organization.netsuite_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + old_script_url = integration.script_endpoint_url + + integration.name = params[:name] if params.key?(:name) + integration.code = params[:code] if params.key?(:code) + integration.script_endpoint_url = params[:script_endpoint_url] if params.key?(:script_endpoint_url) + integration.sync_credit_notes = params[:sync_credit_notes] if params.key?(:sync_credit_notes) + integration.sync_invoices = params[:sync_invoices] if params.key?(:sync_invoices) + integration.sync_payments = params[:sync_payments] if params.key?(:sync_payments) + + integration.save! + + if integration.type == "Integrations::NetsuiteIntegration" && integration.script_endpoint_url != old_script_url + Integrations::Aggregator::SendRestletEndpointJob.perform_later(integration:) + Integrations::Aggregator::PerformSyncJob.set(wait: 2.seconds).perform_later(integration:, sync_items: false) + end + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/app/services/integrations/okta/create_service.rb b/app/services/integrations/okta/create_service.rb new file mode 100644 index 0000000..579d12d --- /dev/null +++ b/app/services/integrations/okta/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Integrations + module Okta + class CreateService < Integrations::CreateService + def call(**args) # rubocop:disable Cops/ServiceCallCop + organization = Organization.find_by(id: args[:organization_id]) + + unless organization.okta_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration = Integrations::OktaIntegration.new( + organization:, + name: "Okta Integration", + code: "okta", + client_id: args[:client_id], + client_secret: args[:client_secret], + domain: args[:domain], + organization_name: args[:organization_name], + host: args[:host] + ) + + integration.save! + organization.enable_okta_authentication! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end + end +end diff --git a/app/services/integrations/okta/destroy_service.rb b/app/services/integrations/okta/destroy_service.rb new file mode 100644 index 0000000..46401cc --- /dev/null +++ b/app/services/integrations/okta/destroy_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Integrations + module Okta + class DestroyService < Integrations::DestroyService + def call + return result.not_found_failure!(resource: "integration") unless integration + return result.not_allowed_failure!(code: "enabled_authentication_methods_required") unless can_destroy? + + ActiveRecord::Base.transaction do + result = super + + if result.success? + organization = result.integration.organization + organization.disable_okta_authentication! if organization.okta_authentication_enabled? + end + + result + end + end + + private + + def can_destroy? + (integration.organization.authentication_methods - [Organizations::AuthenticationMethods::OKTA]).size >= 1 + end + end + end +end diff --git a/app/services/integrations/okta/update_service.rb b/app/services/integrations/okta/update_service.rb new file mode 100644 index 0000000..5c1cffe --- /dev/null +++ b/app/services/integrations/okta/update_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Integrations + module Okta + class UpdateService < Integrations::UpdateService + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + unless integration.organization.okta_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration.client_id = params[:client_id] if params.key?(:client_id) + integration.client_secret = params[:client_secret] if params.key?(:client_secret) + integration.domain = params[:domain] if params.key?(:domain) + integration.organization_name = params[:organization_name] if params.key?(:organization_name) + integration.host = params[:host] if params.key?(:host) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/app/services/integrations/salesforce/create_service.rb b/app/services/integrations/salesforce/create_service.rb new file mode 100644 index 0000000..34de288 --- /dev/null +++ b/app/services/integrations/salesforce/create_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Integrations + module Salesforce + class CreateService < Integrations::CreateService + attr_reader :params + + def initialize(params:) + @params = params + + super + end + + def call + organization = Organization.find_by(id: params[:organization_id]) + + unless organization.salesforce_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration = Integrations::SalesforceIntegration.new( + organization:, + name: params[:name], + code: params[:code], + instance_id: params[:instance_id] + ) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end + end +end diff --git a/app/services/integrations/salesforce/invoices/sync_service.rb b/app/services/integrations/salesforce/invoices/sync_service.rb new file mode 100644 index 0000000..f791e78 --- /dev/null +++ b/app/services/integrations/salesforce/invoices/sync_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Integrations + module Salesforce + module Invoices + class SyncService < BaseService + def initialize(invoice) + @invoice = invoice + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + SendWebhookJob.perform_later("invoice.resynced", invoice) + result.invoice_id = invoice.id + result + end + + private + + attr_reader :invoice + end + end + end +end diff --git a/app/services/integrations/salesforce/update_service.rb b/app/services/integrations/salesforce/update_service.rb new file mode 100644 index 0000000..cffecfe --- /dev/null +++ b/app/services/integrations/salesforce/update_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Integrations + module Salesforce + class UpdateService < Integrations::UpdateService + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + unless integration.organization.salesforce_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration.name = params[:name] if params.key?(:name) + integration.code = params[:code] if params.key?(:code) + integration.instance_id = params[:instance_id] if params.key?(:instance_id) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/app/services/integrations/update_service.rb b/app/services/integrations/update_service.rb new file mode 100644 index 0000000..edb392c --- /dev/null +++ b/app/services/integrations/update_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Integrations + class UpdateService < BaseService + # Guarantees security logging for integration updates. + # Subclasses are unaware of the logging — the only requirement + # is that `result.integration` is set upon the successful `call`. + module SecurityLogging + def call(...) # rubocop:disable Lago/ServiceCall + super.tap { |r| register_security_log(r.integration) if r.success? } + end + end + + def self.inherited(subclass) + super + subclass.prepend(SecurityLogging) + end + + private + + def register_security_log(integration) + diff = integration.previous_changes.except("updated_at", "secrets") + .to_h.transform_keys(&:to_sym) + .transform_values { |v| {deleted: v[0], added: v[1]}.compact } + + Utils::SecurityLog.produce( + organization: integration.organization, + log_type: "integration", + log_event: "integration.updated", + resources: {integration_name: integration.name, integration_type: integration.provider_key, **diff} + ) + end + end +end diff --git a/app/services/integrations/xero/create_service.rb b/app/services/integrations/xero/create_service.rb new file mode 100644 index 0000000..bf425d3 --- /dev/null +++ b/app/services/integrations/xero/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Integrations + module Xero + class CreateService < Integrations::CreateService + def call(**args) # rubocop:disable Cops/ServiceCallCop + organization = Organization.find_by(id: args[:organization_id]) + + unless organization.xero_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration = Integrations::XeroIntegration.new( + organization:, + name: args[:name], + code: args[:code], + connection_id: args[:connection_id], + sync_credit_notes: ActiveModel::Type::Boolean.new.cast(args[:sync_credit_notes]), + sync_invoices: ActiveModel::Type::Boolean.new.cast(args[:sync_invoices]), + sync_payments: ActiveModel::Type::Boolean.new.cast(args[:sync_payments]) + ) + + integration.save! + + Integrations::Aggregator::PerformSyncJob.set(wait: 2.seconds).perform_later(integration:) + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end + end +end diff --git a/app/services/integrations/xero/update_service.rb b/app/services/integrations/xero/update_service.rb new file mode 100644 index 0000000..bf412dd --- /dev/null +++ b/app/services/integrations/xero/update_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Integrations + module Xero + class UpdateService < Integrations::UpdateService + def initialize(integration:, params:) + @integration = integration + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "integration") unless integration + + unless integration.organization.xero_enabled? + return result.not_allowed_failure!(code: "premium_integration_missing") + end + + integration.name = params[:name] if params.key?(:name) + integration.code = params[:code] if params.key?(:code) + integration.sync_credit_notes = params[:sync_credit_notes] if params.key?(:sync_credit_notes) + integration.sync_invoices = params[:sync_invoices] if params.key?(:sync_invoices) + integration.sync_payments = params[:sync_payments] if params.key?(:sync_payments) + + integration.save! + + result.integration = integration + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :integration, :params + end + end +end diff --git a/app/services/invites/accept_service.rb b/app/services/invites/accept_service.rb new file mode 100644 index 0000000..f7e1aff --- /dev/null +++ b/app/services/invites/accept_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Invites + class AcceptService < BaseService + def call(**args) + invite = args[:invite] || Invite.find_by(token: args[:token], status: :pending) + return result.not_found_failure!(resource: "invite") unless invite + unless invite.organization.authentication_methods.include?(args[:login_method]) + return result.single_validation_failure!(error_code: "login_method_not_authorized", field: args[:login_method]) + end + + result = ActiveRecord::Base.transaction do + result = UsersService.new.register_from_invite(invite, args[:password]) + result.token = generate_token(result.user, login_method: args[:login_method]) + invite.recipient = result.membership + invite.mark_as_accepted! + result + end + + # Skip log for new users: invite acceptance covers this event + UserDevices::RegisterService.call!(user: result.user, skip_log: result.user&.previously_new_record?) + result + end + + private + + def generate_token(user, **extra_auth) + Auth::TokenService.encode(user:, **extra_auth) + rescue => e + result.service_failure!(code: "token_encoding_error", message: e.message) + end + end +end diff --git a/app/services/invites/create_service.rb b/app/services/invites/create_service.rb new file mode 100644 index 0000000..a63e2ff --- /dev/null +++ b/app/services/invites/create_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Invites + class CreateService < BaseService + def initialize(args) + @args = args + super + end + + def call + return result.forbidden_failure!(code: "cannot_grant_admin") if granting_admin_without_being_admin? + return result unless valid?(args) + + result.invite = Invite.create!( + organization_id: args[:current_organization].id, + email: args[:email], + token: generate_token, + roles: args[:roles] + ) + + result.invite_url = build_invite_url(result.invite.token) + register_security_log + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :args + + def register_security_log + Utils::SecurityLog.produce( + organization: args[:current_organization], + log_type: "user", + log_event: "user.invited", + resources: {invitee_email: result.invite.email} + ) + end + + def generate_token + token = SecureRandom.hex(20) + + return generate_token if Invite.exists?(token:) + + token + end + + def valid?(args) + Invites::ValidateService.new(result, **args).valid? + end + + def granting_admin_without_being_admin? + return false if args[:skip_admin_check] # NOTE: used by system-level callers that operate without a user (e.g. Admin::OrganizationsController) + return false unless args[:roles]&.include?("admin") + + acting_membership = args[:current_organization].memberships.active.find_by(user: args[:user]) + !acting_membership&.admin? + end + + def build_invite_url(token) + frontend_url = ENV.fetch("LAGO_FRONT_URL", "http://localhost:3000") + "#{frontend_url}/invitation/#{token}" + end + end +end diff --git a/app/services/invites/revoke_service.rb b/app/services/invites/revoke_service.rb new file mode 100644 index 0000000..e02eba4 --- /dev/null +++ b/app/services/invites/revoke_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Invites + class RevokeService < BaseService + def initialize(invite) + @invite = invite + super + end + + def call + return result.not_found_failure!(resource: "invite") unless invite + return result.not_found_failure!(resource: "invite") unless invite.pending? + + invite.mark_as_revoked! + + result.invite = invite + result + end + + private + + attr_reader :invite + end +end diff --git a/app/services/invites/update_service.rb b/app/services/invites/update_service.rb new file mode 100644 index 0000000..4b38ede --- /dev/null +++ b/app/services/invites/update_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module Invites + class UpdateService < BaseService + def initialize(user:, invite:, params:) + @user = user + @invite = invite + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "invite") unless invite + return result.forbidden_failure!(code: "cannot_update_accepted_invite") if invite.accepted? + return result.forbidden_failure!(code: "cannot_update_revoked_invite") if invite.revoked? + return result.forbidden_failure!(code: "cannot_grant_admin") if granting_admin_without_being_admin? + return result unless valid_roles? + + invite.update!(roles: params[:roles]) + + result.invite = invite + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :user, :invite, :params + + def granting_admin_without_being_admin? + return false unless params[:roles]&.include?("admin") + + acting_membership = invite.organization.memberships.active.find_by(user:) + !acting_membership&.admin? + end + + def valid_roles? + roles = params[:roles] + if roles.blank? + result.single_validation_failure!(field: :roles, error_code: "invalid_role") + return false + end + + organization_id = invite.organization_id + found = Role.with_code(*roles).with_organization(organization_id).pluck(:code) + missed = roles - found + return true if missed.empty? + + result.single_validation_failure!(field: :roles, error_code: "invalid_role") + false + end + end +end diff --git a/app/services/invites/validate_service.rb b/app/services/invites/validate_service.rb new file mode 100644 index 0000000..eb20106 --- /dev/null +++ b/app/services/invites/validate_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Invites + class ValidateService < BaseValidator + def valid? + valid_invite? + valid_user? + valid_roles? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_invite? + return true unless args[:current_organization].invites.pending.exists?(email: args[:email]) + + add_error(field: :invite, error_code: "invite_already_exists") + end + + def valid_user? + return true unless Membership.joins(:user) + .where(organization_id: args[:current_organization].id) + .where(users: {email: args[:email]}) + .active + .exists? + + add_error(field: :email, error_code: "email_already_used") + end + + def valid_roles? + roles = args[:roles] + return add_error(field: :roles, error_code: "invalid_role") if roles.blank? + + organization_id = args[:current_organization]&.id + found = Role.with_code(*roles).with_organization(organization_id).pluck(:code) + missed = roles - found + return true if missed.empty? + + add_error(field: :roles, error_code: "invalid_role") + end + end +end diff --git a/app/services/invoice_custom_sections/attach_to_resource_service.rb b/app/services/invoice_custom_sections/attach_to_resource_service.rb new file mode 100644 index 0000000..7f14bb5 --- /dev/null +++ b/app/services/invoice_custom_sections/attach_to_resource_service.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module InvoiceCustomSections + class AttachToResourceService < BaseService + def initialize(resource:, params:) + super + + @resource = resource + @params = params + end + + def call + return result unless params.key?(:invoice_custom_section) + + ActiveRecord::Base.transaction do + if skip_flag.nil? + handle_implicit_skip_flag + else + handle_explicit_skip_flag + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :resource, :params + + def skip_sections? + skip_flag == true + end + + def skip_flag + params.dig(:invoice_custom_section, :skip_invoice_custom_sections) + end + + def sections_param + key = api_context? ? :invoice_custom_section_codes : :invoice_custom_section_ids + + params.dig(:invoice_custom_section, key) + end + + def handle_explicit_skip_flag + if skip_sections? + resource.update!(skip_invoice_custom_sections: true) + resource.applied_invoice_custom_sections.destroy_all + else + resource.update!(skip_invoice_custom_sections: false) + attach_sections unless sections_param.nil? + end + end + + def handle_implicit_skip_flag + return if resource.skip_invoice_custom_sections + + attach_sections unless sections_param.nil? + end + + def attach_sections + existing_section_ids = resource.applied_invoice_custom_sections.pluck(:invoice_custom_section_id) + new_section_ids = invoice_custom_sections.pluck(:id) + + invoice_custom_sections.each do |section| + next if existing_section_ids.include?(section.id) + + resource.applied_invoice_custom_sections.create!( + invoice_custom_section: section, + organization: resource.organization + ) + end + + remove_obsolete_sections(existing_section_ids, new_section_ids) + end + + def remove_obsolete_sections(existing_ids, new_ids) + obsolete_ids = existing_ids - new_ids + + resource.applied_invoice_custom_sections.where(invoice_custom_section_id: obsolete_ids).destroy_all if obsolete_ids.any? + end + + def invoice_custom_sections + return @invoice_custom_sections if defined?(@invoice_custom_sections) + return @invoice_custom_sections = [] if section_identifiers.blank? + + identifier = api_context? ? :code : :id + @invoice_custom_sections = + resource.organization.invoice_custom_sections.where(identifier => section_identifiers) + end + + def section_identifiers + return @section_identifiers if defined?(@section_identifiers) + return @section_identifiers = [] if sections_param.blank? + + key = api_context? ? :invoice_custom_section_codes : :invoice_custom_section_ids + @section_identifiers = params.dig(:invoice_custom_section, key)&.compact&.uniq || [] + end + end +end diff --git a/app/services/invoice_custom_sections/create_service.rb b/app/services/invoice_custom_sections/create_service.rb new file mode 100644 index 0000000..11ef4e3 --- /dev/null +++ b/app/services/invoice_custom_sections/create_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module InvoiceCustomSections + class CreateService < BaseService + Result = BaseResult[:invoice_custom_section] + + def initialize(organization:, create_params:) + @organization = organization + @create_params = create_params + super + end + + def call + invoice_custom_section = organization.invoice_custom_sections.create!(create_params) + result.invoice_custom_section = invoice_custom_section + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :create_params + end +end diff --git a/app/services/invoice_custom_sections/deselect_all_service.rb b/app/services/invoice_custom_sections/deselect_all_service.rb new file mode 100644 index 0000000..416a7ba --- /dev/null +++ b/app/services/invoice_custom_sections/deselect_all_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module InvoiceCustomSections + class DeselectAllService < BaseService + Result = BaseResult[:invoice_custom_section] + + def initialize(section:) + @section = section + super + end + + def call + section.billing_entity_applied_invoice_custom_sections.destroy_all + section.customer_applied_invoice_custom_sections.destroy_all + + result.invoice_custom_section = section + result + end + + private + + attr_reader :section + end +end diff --git a/app/services/invoice_custom_sections/destroy_service.rb b/app/services/invoice_custom_sections/destroy_service.rb new file mode 100644 index 0000000..1e4524b --- /dev/null +++ b/app/services/invoice_custom_sections/destroy_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module InvoiceCustomSections + class DestroyService < BaseService + Result = BaseResult[:invoice_custom_section] + + def initialize(invoice_custom_section:) + @invoice_custom_section = invoice_custom_section + super + end + + def call + return result.not_found_failure!(resource: "invoice_custom_section") unless invoice_custom_section + + ActiveRecord::Base.transaction do + invoice_custom_section.discard + DeselectAllService.call!(section: invoice_custom_section) + result.invoice_custom_section = invoice_custom_section + result + end + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice_custom_section + end +end diff --git a/app/services/invoice_custom_sections/funding_instructions_formatter_service.rb b/app/services/invoice_custom_sections/funding_instructions_formatter_service.rb new file mode 100644 index 0000000..5d7c45f --- /dev/null +++ b/app/services/invoice_custom_sections/funding_instructions_formatter_service.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module InvoiceCustomSections + class FundingInstructionsFormatterService < BaseService + def initialize(funding_data:, locale:) + @funding_data = funding_data + @locale = locale + super + end + + def call + I18n.with_locale(locale) do + lines = [] + t = ->(key) { I18n.t("invoice.#{key}") } + + lines << t.call(:bank_transfer_info) + lines << "" + + case funding_data[:type] + when "us_bank_transfer" then format_us_bank_transfer(lines, t) + when "mx_bank_transfer" then lines << format_mx_bank_transfer(t) + when "jp_bank_transfer" then lines << format_jp_bank_transfer(t) + when "gb_bank_transfer" then lines << format_gb_bank_transfer(t) + when "eu_bank_transfer" then lines << format_eu_bank_transfer(t) + else + result.service_failure!( + code: "unsupported_funding_type", + message: "Funding type '#{funding_data[:type]}' is not supported" + ) + end + + result.details = lines.join("\n") + result + end + end + + private + + attr_reader :funding_data, :locale + + def format_us_bank_transfer(lines, t) + addresses = funding_data[:financial_addresses] || [] + + addresses.each do |address| + type = address[:type]&.to_sym + details = address[type] || {} + + block = case type + when :aba + <<~TEXT + US ACH, Domestic Wire + #{t.call(:bank_name)}: #{details_or_default(details[:bank_name])} + #{t.call(:account_number)}: #{details_or_default(details[:account_number])} + #{t.call(:routing_number)}: #{details_or_default(details[:routing_number])} + TEXT + when :swift + <<~TEXT + SWIFT + #{t.call(:bank_name)}: #{details_or_default(details[:bank_name])} + #{t.call(:account_number)}: #{details_or_default(details[:account_number])} + #{t.call(:swift_code)}: #{details_or_default(details[:swift_code])} + TEXT + end + + lines << block.strip if block + lines << "" if block + end + end + + def format_mx_bank_transfer(t) + details = extract_details(:mx_bank_transfer) + <<~TEXT.strip + #{t.call(:clabe)}: #{details_or_default(details[:clabe])} + #{t.call(:bank_name)}: #{details_or_default(details[:bank_name])} + #{t.call(:bank_code)}: #{details_or_default(details[:bank_code])} + TEXT + end + + def format_jp_bank_transfer(t) + details = extract_details(:jp_bank_transfer) + <<~TEXT.strip + #{t.call(:bank_code)}: #{details_or_default(details[:bank_code])} + #{t.call(:bank_name)}: #{details_or_default(details[:bank_name])} + #{t.call(:branch_code)}: #{details_or_default(details[:branch_code])} + #{t.call(:branch_name)}: #{details_or_default(details[:branch_name])} + #{t.call(:account_type)}: #{details_or_default(details[:account_type])} + #{t.call(:account_number)}: #{details_or_default(details[:account_number])} + #{t.call(:account_holder_name)}: #{details_or_default(details[:account_holder_name])} + TEXT + end + + def format_gb_bank_transfer(t) + details = extract_details(:sort_code) + <<~TEXT.strip + #{t.call(:account_number)}: #{details_or_default(details[:account_number])} + #{t.call(:sort_code)}: #{details_or_default(details[:sort_code])} + #{t.call(:account_holder_name)}: #{details_or_default(details[:account_holder_name])} + TEXT + end + + def format_eu_bank_transfer(t) + details = extract_details(:iban) + <<~TEXT.strip + #{t.call(:bic)}: #{details_or_default(details[:bic])} + #{t.call(:iban)}: #{details_or_default(details[:iban])} + #{t.call(:country)}: #{details_or_default(details[:country])} + #{t.call(:account_holder_name)}: #{details_or_default(details[:account_holder_name])} + TEXT + end + + def extract_details(key) + funding_data[:financial_addresses]&.first&.dig(key) || {} + end + + def details_or_default(value) + value.presence || "-" + end + end +end diff --git a/app/services/invoice_custom_sections/update_service.rb b/app/services/invoice_custom_sections/update_service.rb new file mode 100644 index 0000000..a027350 --- /dev/null +++ b/app/services/invoice_custom_sections/update_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module InvoiceCustomSections + class UpdateService < BaseService + Result = BaseResult[:invoice_custom_section] + + def initialize(invoice_custom_section:, update_params:) + @update_params = update_params + @invoice_custom_section = invoice_custom_section + super + end + + def call + return result.not_found_failure!(resource: "invoice_custom_section") unless invoice_custom_section + + invoice_custom_section.update!(update_params) + result.invoice_custom_section = invoice_custom_section + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice_custom_section, :update_params + end +end diff --git a/app/services/invoice_settlements/create_service.rb b/app/services/invoice_settlements/create_service.rb new file mode 100644 index 0000000..1d928ec --- /dev/null +++ b/app/services/invoice_settlements/create_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module InvoiceSettlements + class CreateService < BaseService + Result = BaseResult[:invoice_settlement] + + def initialize(invoice:, amount_cents:, amount_currency:, source_credit_note: nil, source_payment: nil) + @invoice = invoice + @amount_cents = amount_cents + @amount_currency = amount_currency + @source_credit_note = source_credit_note + @source_payment = source_payment + + super + end + + def call + validate_single_source! + + ActiveRecord::Base.transaction do + invoice_settlement = InvoiceSettlement.create!( + organization_id: invoice.organization_id, + billing_entity_id: invoice.billing_entity_id, + target_invoice: invoice, + source_credit_note: source_credit_note, + source_payment: source_payment, + settlement_type: settlement_type, + amount_cents: amount_cents, + amount_currency: amount_currency + ) + + result.invoice_settlement = invoice_settlement + + mark_invoice_as_paid! if invoice_fully_settled? + end + + result + end + + private + + attr_reader :invoice, :amount_cents, :amount_currency, :source_credit_note, :source_payment + + def validate_single_source! + if source_credit_note.nil? && source_payment.nil? + raise ArgumentError, "Must provide either source_credit_note or source_payment" + end + + if source_credit_note.present? && source_payment.present? + raise ArgumentError, "Cannot provide both source_credit_note and source_payment" + end + end + + def settlement_type + source_credit_note.present? ? :credit_note : :payment + end + + def invoice_fully_settled? + invoice.total_due_amount_cents <= 0 + end + + def mark_invoice_as_paid! + Invoices::UpdateService.call!( + invoice: invoice, + params: {payment_status: :succeeded}, + webhook_notification: true + ) + end + end +end diff --git a/app/services/invoices/advance_charges_service.rb b/app/services/invoices/advance_charges_service.rb new file mode 100644 index 0000000..0ab6bdd --- /dev/null +++ b/app/services/invoices/advance_charges_service.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +module Invoices + class AdvanceChargesService < BaseService + Result = BaseResult[:invoice] + + def initialize(initial_subscriptions:, billing_at:) + @initial_subscriptions = initial_subscriptions + @billing_at = billing_at + + @customer = initial_subscriptions&.first&.customer + @organization = customer&.organization + @currency = initial_subscriptions&.first&.plan&.amount_currency + + super + end + + def call + return result unless has_charges_with_statement? + + return result if subscriptions.empty? + + invoice = create_group_invoice + + if invoice && !invoice.closed? + SendWebhookJob.perform_later("invoice.created", invoice) + Utils::ActivityLog.produce(invoice, "invoice.created") + create_manual_payment(invoice) + Invoices::GenerateDocumentsJob.perform_later(invoice:, notify: false) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Utils::SegmentTrack.invoice_created(invoice) + end + + result.invoice = invoice + + result + end + + private + + attr_accessor :initial_subscriptions, :billing_at, :customer, :organization, :currency + + # Apply the charges_to_datetime upper-bound only for regular periodic billing + # (i.e., no upgrade/downgrade/termination context). We consider it regular when + # every initial subscription is active AND has no pending next subscription AND + # is not being terminated. + def apply_charges_to_datetime_condition? + initial_subscriptions.all? do |s| + s.active? && s.next_subscription.nil? && !s.terminated? + end + end + + def filter_charges_to_datetime(relation) + return relation unless apply_charges_to_datetime_condition? + + relation.where("(properties ->> 'charges_to_datetime') IS NULL OR (properties ->> 'charges_to_datetime')::timestamp <= ?", billing_at) + end + + def subscriptions + return [] unless customer + + # NOTE: filter all active/terminated subscriptions having non-invoiceable (in advance) fees not yet attached to an invoice + @subscriptions ||= customer.subscriptions + .where( + id: Fee.joins(:subscription) + .where(invoice_id: nil, payment_status: :succeeded) + .where("succeeded_at <= ?", billing_at) + .then { |rel| filter_charges_to_datetime(rel) } + .where(subscriptions: { + customer_id: customer.id, + external_id: initial_subscriptions.pluck(:external_id).uniq, + status: [:active, :terminated] + }) + .select("DISTINCT(subscriptions.id)") + ) + end + + def has_charges_with_statement? + plan_ids = subscriptions.pluck(:plan_id) + Charge.where(plan_id: plan_ids, pay_in_advance: true, invoiceable: false, regroup_paid_fees: :invoice).any? + end + + def create_manual_payment(invoice) + amount_cents = invoice.total_amount_cents + reference = I18n.t("invoice.charges_paid_in_advance") + created_at = invoice.created_at + + params = {invoice_id: invoice.id, amount_cents:, reference:, created_at:} + + ::Payments::ManualCreateJob.perform_later(organization:, params:) + end + + def create_group_invoice + invoice = nil + + ActiveRecord::Base.transaction do + invoice = create_generating_invoice + invoice.invoice_subscriptions.each do |is| + is.subscription.fees + .where(invoice: nil, payment_status: :succeeded) + .where("succeeded_at <= ?", is.timestamp) + .then { |rel| filter_charges_to_datetime(rel) } + .update_all(invoice_id: invoice.id) # rubocop:disable Rails/SkipsModelValidations + end + + if invoice.fees.empty? + invoice = nil + raise ActiveRecord::Rollback + end + + # NOTE: We don't want to use Invoices::ComputeAmountsFromFees here + # because it would recompute taxes from pre-tax values. All Fees are already paid + # this invoice should show how much taxes were paid in total. + Invoices::AggregateAmountsAndTaxesFromFees.call!(invoice:) + + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + invoice.payment_status = :succeeded + Invoices::TransitionToFinalStatusService.call(invoice:) + + invoice.save! + end + + invoice + end + + def create_generating_invoice + # TODO: seems that skip_charges here might be deleted. Performed one test locally - worked without any additional charges. + # Following the code - also did not find calling any service that would use this skip_charges + invoice_result = Invoices::CreateGeneratingService.call( + customer:, + invoice_type: :advance_charges, + currency:, + datetime: billing_at, # this is an int we need to convert it + skip_charges: true + ) do |invoice| + Invoices::CreateAdvanceChargesInvoiceSubscriptionService.call!( + invoice:, + subscriptions_with_fees: subscriptions, + all_subscriptions: subscriptions + initial_subscriptions, + timestamp: billing_at + ) + end + + invoice_result.raise_if_error! + + invoice_result.invoice + end + end +end diff --git a/app/services/invoices/aggregate_amounts_and_taxes_from_fees.rb b/app/services/invoices/aggregate_amounts_and_taxes_from_fees.rb new file mode 100644 index 0000000..f08848d --- /dev/null +++ b/app/services/invoices/aggregate_amounts_and_taxes_from_fees.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Invoices + class AggregateAmountsAndTaxesFromFees < BaseService + Result = BaseResult[:invoice] + + def initialize(invoice:) + @invoice = invoice + + super + end + + # NOTE: progressive billing, coupons and credit notes are not supported here + def call + unless invoice.advance_charges? + return result.service_failure!(code: "invalid_invoice", message: "type of invoice must be `advance_charges`") + end + + result.invoice = invoice + return result if invoice.fees.empty? + + invoice.fees_amount_cents = invoice.fees.sum(&:amount_cents) + invoice.taxes_amount_cents = invoice.fees.sum(&:taxes_amount_cents) + invoice.total_amount_cents = invoice.fees_amount_cents + invoice.taxes_amount_cents + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents + invoice.sub_total_including_taxes_amount_cents = invoice.sub_total_excluding_taxes_amount_cents + invoice.taxes_amount_cents + + # Note: This field is populated for consistency but probably shouldn't be use + invoice.taxes_rate = if invoice.fees_amount_cents.zero? + 0 + else + (invoice.taxes_amount_cents.to_f * 100 / invoice.fees_amount_cents).round(2) + end + + invoice.applied_taxes = invoice.fees.includes(:applied_taxes).flat_map(&:applied_taxes).group_by(&:tax_id).map do |tax_id, applied_taxes| + t = applied_taxes.first + Invoice::AppliedTax.new( + organization: invoice.organization, + tax_id: tax_id, + tax_name: t.tax_name, + tax_code: t.tax_code, + tax_description: t.tax_description, + tax_rate: t.tax_rate, + amount_currency: t.amount_currency, + + amount_cents: applied_taxes.sum(&:amount_cents), + fees_amount_cents: applied_taxes.sum { |at| at.fee.sub_total_excluding_taxes_amount_cents }, + taxable_base_amount_cents: applied_taxes.sum { |at| at.fee.taxes_base_rate * at.fee.sub_total_excluding_taxes_amount_cents } + ) + end + + result + end + + private + + attr_reader :invoice + end +end diff --git a/app/services/invoices/apply_invoice_custom_sections_service.rb b/app/services/invoices/apply_invoice_custom_sections_service.rb new file mode 100644 index 0000000..3067624 --- /dev/null +++ b/app/services/invoices/apply_invoice_custom_sections_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Invoices + class ApplyInvoiceCustomSectionsService < BaseService + def initialize(invoice:, resource: nil, custom_section_ids: []) + @invoice = invoice + @customer = invoice.customer + @resource = resource + @custom_section_ids = custom_section_ids + + super() + end + + def call + result.applied_sections = [] + return result if skip_custom_sections? + + applicable_sections.each do |custom_section| + invoice.applied_invoice_custom_sections.create!( + organization_id: invoice.organization_id, + code: custom_section.code, + details: custom_section.details, + display_name: custom_section.display_name, + name: custom_section.name + ) + end + result.applied_sections = invoice.applied_invoice_custom_sections + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :customer, :resource, :custom_section_ids + + def skip_custom_sections? + return false if resource_has_custom_sections? + return true if resource&.skip_invoice_custom_sections + return false if custom_section_ids.present? + + customer.skip_invoice_custom_sections + end + + def applicable_sections + manual_sections = if custom_section_ids.present? + organization.invoice_custom_sections.where(id: custom_section_ids) + elsif resource_has_custom_sections? + resource.selected_invoice_custom_sections + else + customer.configurable_invoice_custom_sections + end + + manual_sections | customer.system_generated_invoice_custom_sections + end + + def resource_has_custom_sections? + return false unless resource + return false unless resource.respond_to?(:selected_invoice_custom_sections) + return false if resource.skip_invoice_custom_sections + + resource.selected_invoice_custom_sections.any? + end + + def organization + @organization ||= invoice.organization + end + end +end diff --git a/app/services/invoices/apply_provider_taxes_service.rb b/app/services/invoices/apply_provider_taxes_service.rb new file mode 100644 index 0000000..7481a05 --- /dev/null +++ b/app/services/invoices/apply_provider_taxes_service.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Invoices + class ApplyProviderTaxesService < BaseService + def initialize(invoice:, provider_taxes: nil) + @invoice = invoice + @provider_taxes = provider_taxes || fetch_provider_taxes_result.fees + + super + end + + def call + result.applied_taxes = [] + applied_taxes_amount_cents = 0 + taxes_rate = 0 + + applicable_taxes.values.each do |tax| + tax_rate = tax.rate.to_f * 100 + + applied_tax = invoice.applied_taxes.new( + organization: invoice.organization, + tax_description: tax.type, + tax_code: tax.name.parameterize(separator: "_"), + tax_name: tax.name, + tax_rate: tax_rate, + amount_currency: invoice.currency + ) + invoice.applied_taxes << applied_tax + + tax_amount_cents = compute_tax_amount_cents(tax) + applied_tax.fees_amount_cents = fees_amount_cents(tax) + applied_tax.taxable_base_amount_cents = taxable_base_amount_cents(tax)&.round + applied_tax.amount_cents = tax_amount_cents.round + + # NOTE: when applied on user current usage, the invoice is + # not created in DB + applied_tax.save! if invoice.persisted? + + applied_taxes_amount_cents += tax_amount_cents + taxes_rate += pro_rated_taxes_rate(tax) + + result.applied_taxes << applied_tax + end + + invoice.taxes_amount_cents = applied_taxes_amount_cents.round + invoice.taxes_rate = taxes_rate.round(5) + result.invoice = invoice + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :provider_taxes + + def applicable_taxes + return @applicable_taxes if defined? @applicable_taxes + + output = {} + provider_taxes.each do |fee_taxes| + fee_taxes.tax_breakdown.each do |tax| + key = calculate_key(tax) + + next if output[key] + + output[key] = tax + end + end + + @applicable_taxes = output + + @applicable_taxes + end + + def indexed_fees + @indexed_fees ||= invoice.fees.each_with_object({}) do |fee, applied_taxes| + fee.applied_taxes.each do |applied_tax| + tax = OpenStruct.new( + name: applied_tax.tax_name, + rate: applied_tax.tax_rate, + type: applied_tax.tax_description + ) + key = calculate_key(tax) + + applied_taxes[key] ||= [] + applied_taxes[key] << fee + end + end + end + + def compute_tax_amount_cents(tax) + key = calculate_key(tax) + + indexed_fees[key] + .sum { |fee| fee.sub_total_excluding_taxes_amount_cents * fee.taxes_base_rate * tax.rate.to_f } + end + + def pro_rated_taxes_rate(tax) + tax_rate = tax.rate.is_a?(String) ? tax.rate.to_f * 100 : tax.rate + + fees_rate = if invoice.sub_total_excluding_taxes_amount_cents.positive? + fees_amount_cents(tax).fdiv(invoice.sub_total_excluding_taxes_amount_cents) + else + # NOTE: when invoice have a 0 amount. The prorata is on the number of fees + key = calculate_key(tax) + indexed_fees[key].count.fdiv(invoice.fees.count) + end + + fees_rate * tax_rate + end + + def fees_amount_cents(tax) + key = calculate_key(tax) + + indexed_fees[key].sum(&:sub_total_excluding_taxes_amount_cents) + end + + def taxable_base_amount_cents(tax) + key = calculate_key(tax) + + indexed_fees[key].sum { |fee| fee.sub_total_excluding_taxes_amount_cents * fee.taxes_base_rate } + end + + def fetch_provider_taxes_result + taxes_result = if invoice.draft? || invoice.advance_charges? + Integrations::Aggregator::Taxes::Invoices::CreateDraftService.call(invoice:) + else + Integrations::Aggregator::Taxes::Invoices::CreateService.call(invoice:) + end + taxes_result.raise_if_error! + end + + def calculate_key(tax) + tax_rate = tax.rate.is_a?(String) ? tax.rate.to_f * 100 : tax.rate + + "#{tax.type}-#{tax.name.parameterize(separator: "_")}-#{tax_rate}" + end + end +end diff --git a/app/services/invoices/apply_taxes_service.rb b/app/services/invoices/apply_taxes_service.rb new file mode 100644 index 0000000..9108ca3 --- /dev/null +++ b/app/services/invoices/apply_taxes_service.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module Invoices + class ApplyTaxesService < BaseService + def initialize(invoice:) + @invoice = invoice + + super + end + + def call + result.applied_taxes = [] + applied_taxes_amount_cents = 0 + taxes_rate = 0 + + applicable_taxes.each do |tax| + applied_tax = invoice.applied_taxes.new( + organization:, + tax:, + tax_description: tax.description, + tax_code: tax.code, + tax_name: tax.name, + tax_rate: tax.rate, + amount_currency: invoice.currency + ) + invoice.applied_taxes << applied_tax + + tax_amount_cents = compute_tax_amount_cents(tax) + applied_tax.fees_amount_cents = fees_amount_cents(tax) + applied_tax.amount_cents = tax_amount_cents.round + + # NOTE: when applied on user current usage, the invoice is + # not created in DB + applied_tax.save! if invoice.persisted? + + applied_taxes_amount_cents += tax_amount_cents + taxes_rate += pro_rated_taxes_rate(tax) + + result.applied_taxes << applied_tax + end + + invoice.taxes_amount_cents = applied_taxes_amount_cents.round + invoice.taxes_rate = taxes_rate.round(5) + result.invoice = invoice + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice + + delegate :organization, to: :invoice + + # Note: taxes, applied on the fees might be created on organization, but selected for specific add-on, for example + # so not applied on the billing_entity + def applicable_taxes + organization.taxes.where(id: indexed_fees.keys) + end + + # NOTE: indexes the invoice fees by taxes. + # Example output will be: { tax1 => [fee1, fee2], tax2 => [fee2] } + def indexed_fees + @indexed_fees ||= invoice.fees.each_with_object({}) do |fee, applied_taxes| + fee.applied_taxes.each do |applied_tax| + applied_taxes[applied_tax.tax_id] ||= [] + applied_taxes[applied_tax.tax_id] << fee + end + end + end + + # NOTE: Because coupons are applied before VAT, + # we have to take the coupons amount pro-rated at fee level into account + def compute_tax_amount_cents(tax) + indexed_fees[tax.id] + .sum { |fee| fee.sub_total_excluding_taxes_amount_cents * tax.rate } + .fdiv(100) + end + + # NOTE: Tax might not be applied to all fees of the invoice. + # In order to compute the invoice#taxes_rate, we have to apply + # a pro-rata of the fees attached to the tax on the invoices#fees_amount_cents + def pro_rated_taxes_rate(tax) + fees_rate = if invoice.sub_total_excluding_taxes_amount_cents.positive? + fees_amount_cents(tax).fdiv(invoice.sub_total_excluding_taxes_amount_cents) + else + # NOTE: when invoice have a 0 amount. The prorata is on the number of fees + indexed_fees[tax.id].count.fdiv(invoice.fees.count) + end + + fees_rate * tax.rate + end + + def fees_amount_cents(tax) + indexed_fees[tax.id].sum(&:sub_total_excluding_taxes_amount_cents) + end + end +end diff --git a/app/services/invoices/calculate_fees_service.rb b/app/services/invoices/calculate_fees_service.rb new file mode 100644 index 0000000..6d37b3b --- /dev/null +++ b/app/services/invoices/calculate_fees_service.rb @@ -0,0 +1,420 @@ +# frozen_string_literal: true + +module Invoices + class CalculateFeesService < BaseService + def initialize(invoice:, recurring: false, context: nil) + @invoice = invoice + @timestamp = invoice.invoice_subscriptions.first&.timestamp + + # NOTE: Billed automatically by the recurring billing process + # It is used to prevent double billing on billing day + @recurring = recurring + + @context = context + + super + end + + def call + ActiveRecord::Base.transaction do + invoice.invoice_subscriptions.each do |invoice_subscription| + subscription = invoice_subscription.subscription + date_service = Subscriptions::TerminatedDatesService.new( + subscription:, + invoice:, + date_service: date_service(subscription) + ).call + + boundaries = BillingPeriodBoundaries.new( + from_datetime: invoice_subscription.from_datetime, + to_datetime: invoice_subscription.to_datetime, + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime, + fixed_charges_from_datetime: invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: invoice_subscription.fixed_charges_to_datetime, + timestamp: invoice_subscription.timestamp, + charges_duration: date_service.charges_duration_in_days, + fixed_charges_duration: date_service.fixed_charges_duration_in_days + ) + + create_subscription_fee(subscription, boundaries) if should_create_subscription_fee?(subscription, boundaries) + create_charges_fees(subscription, boundaries) if should_create_charge_fees?(subscription) + create_fixed_charge_fees(subscription, boundaries) if should_create_fixed_charge_fees?(subscription, boundaries) + create_recurring_non_invoiceable_fees(subscription, boundaries) if should_create_recurring_non_invoiceable_fees?(subscription) + create_minimum_commitment_true_up_fee(invoice_subscription) if should_create_minimum_commitment_true_up_fee?(invoice_subscription) + end + + invoice.fees_amount_cents = invoice.fees.sum(:amount_cents) + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees.sum(:amount_cents) - + invoice.coupons_amount_cents + + Credits::ProgressiveBillingService.call(invoice:) + Credits::AppliedCouponsService.call(invoice:) if should_create_coupon_credit? + + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:, finalizing: finalizing_invoice?) + return totals_result if !totals_result.success? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) # rubocop:disable Rails/TransactionExitStatement + + totals_result.raise_if_error! + + create_credit_note_credit if should_create_credit_note_credit? + create_applied_prepaid_credit if should_create_applied_prepaid_credit? + + invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded + invoice.save! + + result.invoice = invoice.reload + result.non_invoiceable_fees ||= [] + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :invoice, :subscriptions, :timestamp, :recurring, :context + + delegate :customer, :currency, to: :invoice + + def issuing_date + timestamp.in_time_zone(customer.applicable_timezone).to_date + end + + def date_service(subscription) + Subscriptions::DatesService.new_instance( + subscription, + timestamp, + current_usage: subscription.terminated? && subscription.upgraded? + ) + end + + def create_minimum_commitment_true_up_fee(invoice_subscription) + minimum_commitment_result = Fees::Commitments::Minimum::CreateService.call(invoice_subscription:) + minimum_commitment_result.raise_if_error! + end + + def create_subscription_fee(subscription, boundaries) + fee_result = Fees::SubscriptionService.call(invoice:, subscription:, boundaries:) + fee_result.raise_if_error! + end + + def charge_boundaries_valid?(boundaries) + # TODO: Investigate why invalid boundaries are even possible + return false if boundaries.charges_from_datetime.blank? || boundaries.charges_to_datetime.blank? + + boundaries.charges_from_datetime < boundaries.charges_to_datetime + end + + def fixed_charge_boundaries_valid?(boundaries) + return false if boundaries.fixed_charges_from_datetime.blank? || boundaries.fixed_charges_to_datetime.blank? + + boundaries.fixed_charges_from_datetime <= boundaries.fixed_charges_to_datetime + end + + def create_charges_fees(subscription, boundaries) + return unless charge_boundaries_valid?(boundaries) + + filters = event_filters(subscription, boundaries).charges + + subscription + .plan + .charges + .includes(:taxes, billable_metric: :organization, filters: {values: :billable_metric_filter}) + .joins(:billable_metric) + .where(invoiceable: true) + .where + .not(pay_in_advance: true, billable_metric: {recurring: false}) + .find_each do |charge| + next if should_not_create_charge_fee?(charge, subscription) + + Fees::ChargeService.call!( + invoice:, + charge:, + subscription:, + boundaries:, + context:, + filtered_aggregations: filters[charge.id] || [] + ) + end + end + + def should_not_create_charge_fee?(charge, subscription) + if charge.pay_in_advance? + condition = charge.billable_metric.recurring? && + subscription.terminated? && + (subscription.upgraded? || subscription.next_subscription.nil?) + + return condition + end + + return false if charge.prorated? + + charge.billable_metric.recurring? && + subscription.terminated? && + subscription.upgraded? && + charge.included_in_next_subscription?(subscription) + end + + def create_fixed_charge_fees(subscription, boundaries) + return unless fixed_charge_boundaries_valid?(boundaries) + + subscription.fixed_charges.find_each do |fixed_charge| + next unless should_create_fixed_charge_fee?(fixed_charge, subscription) + + Fees::FixedChargeService.call!( + invoice:, + fixed_charge:, + subscription:, + boundaries:, + context: + ) + end + end + + # In current PR we just always create the fixed charges. In the upcoming we'll handle upgrade/downgrade/termination scenarios + def should_create_fixed_charge_fee?(fixed_charge, subscription) + # when "starting" invoice - it's only for pay_in_advance fees + if !fixed_charge.pay_in_advance? && subscription.invoice_subscriptions.count == 1 && + subscription.invoice_subscriptions.order(:created_at).last.subscription_starting? + return false + end + # for terminated subscription we do not chage pay_in_advance fees + if fixed_charge.pay_in_advance? && subscription.terminated? + return false + end + + true + end + + def should_create_recurring_non_invoiceable_fees?(subscription) + return false if invoice.skip_charges + + # NOTE: The subscription was just updated, we do not want to create the recurring fees. + # The fees paid in advance in the previous plan are valid until the next renewal, even if there is an upgrade + # Without this condition, it will simply create a zero-fee. + # See: spec/scenarios/fees/recurring_fee_upgrade_spec.rb + if subscription.previous_subscription&.terminated_at&.to_date == timestamp.to_date && + subscription.started_at&.to_date == timestamp.to_date + return false + end + + true + end + + def create_recurring_non_invoiceable_fees(subscription, boundaries) + result.non_invoiceable_fees = [] + + subscription + .plan + .charges + .includes(:taxes, billable_metric: :organization, filters: {values: :billable_metric_filter}) + .joins(:billable_metric) + .where( + charges: { + invoiceable: false, pay_in_advance: true + }, + billable_metrics: { + recurring: true + } + ) + .find_each do |charge| + next if should_not_create_charge_fee?(charge, subscription) + + fee_result = Fees::ChargeService.call!( + invoice: nil, + charge:, + subscription:, + context: :recurring, + boundaries:, + apply_taxes: invoice.customer.tax_customer.blank? + ) + + result.non_invoiceable_fees.concat(fee_result.fees) + end + end + + def should_create_minimum_commitment_true_up_fee?(invoice_subscription) + subscription = invoice_subscription.subscription + + return false if subscription.plan.pay_in_advance? && !invoice_subscription.previous_invoice_subscription + return false unless should_create_yearly_subscription_fee?(subscription) + return false unless should_create_semiannual_subscription_fee?(subscription) + + calculate_true_up_fee_result = Commitments::Minimum::CalculateTrueUpFeeService + .new_instance(invoice_subscription:).call + + return false if calculate_true_up_fee_result.amount_cents.zero? + + subscription.active? || + ( + subscription.terminated? && + ( + subscription.plan.pay_in_arrears? || + subscription.terminated_at >= invoice.created_at || + calculate_true_up_fee_result.amount_cents.positive? + ) + ) + end + + def should_create_subscription_fee?(subscription, boundaries) + # NOTE: When plan is pay in advance we generate an invoice upon subscription creation + # We want to prevent creating subscription fee if subscription creation already happened on billing day + fee_exists = subscription.fees + .subscription + .includes(:invoice) + .where(created_at: issuing_date.beginning_of_day..issuing_date.end_of_day) + .where.not(invoice_id: invoice.id) + .where.not(invoice_id: invoice.voided_invoice_id) + .any? + + return false if subscription.plan.pay_in_advance? && fee_exists + return false unless should_create_yearly_subscription_fee?(subscription) + return false unless should_create_semiannual_subscription_fee?(subscription) + return false if in_trial_period_not_ending_today?(subscription, boundaries.timestamp) + # now we have a case, where we bill a subscription on the first day, but it's not a pay_in_advance plan - it includes pay_in_advance fixed_charges + return false if billing_advance_fixed_charges_on_first_invoice?(subscription) + + # NOTE: When a subscription is terminated we still need to charge the subscription + # fee if the plan is in pay in arrears, otherwise this fee will never + # be created. + subscription.active? || subscription.incomplete? || + (subscription.terminated? && subscription.plan.pay_in_arrears?) || + (subscription.terminated? && subscription.terminated_at > invoice.created_at) + end + + def should_create_semiannual_subscription_fee?(subscription) + return true unless subscription.plan.semiannual? + + # NOTE: we do not want to create a subscription fee for plans with bill_charges_monthly activated + # But we want to keep the subscription charge when it has to proceed + # Cases when we want to charge a subscription: + # - Plan is pay in advance, we're at the beginning of the period or subscription has never been billed and not started in the past + # - Plan is pay in arrear and we're at the beginning of the period + + if subscription.plan.pay_in_advance? && !subscription.started_in_past? + return date_service(subscription).first_month_in_semiannual_period? || !subscription.already_billed? + end + + if subscription.plan.pay_in_advance? && subscription.started_in_past? + return !date_service(subscription).first_month_in_first_semiannual_period? && date_service(subscription).first_month_in_semiannual_period? + end + + if subscription.plan.pay_in_arrears? + return subscription.terminated? || date_service(subscription).first_month_in_semiannual_period? + end + + false + end + + def should_create_yearly_subscription_fee?(subscription) + return true unless subscription.plan.yearly? + + # NOTE: we do not want to create a subscription fee for plans with bill_charges_monthly activated + # But we want to keep the subscription charge when it has to proceed + # Cases when we want to charge a subscription: + # - Plan is pay in advance, we're at the beginning of the period or subscription has never been billed and not started in the past + # - Plan is pay in arrear and we're at the beginning of the period + + if subscription.plan.pay_in_advance? && !subscription.started_in_past? + return date_service(subscription).first_month_in_yearly_period? || !subscription.already_billed? + end + + if subscription.plan.pay_in_advance? && subscription.started_in_past? + return !date_service(subscription).first_month_in_first_yearly_period? && date_service(subscription).first_month_in_yearly_period? + end + + if subscription.plan.pay_in_arrears? + return subscription.terminated? || date_service(subscription).first_month_in_yearly_period? + end + + false + end + + def should_create_charge_fees?(subscription) + return false if invoice.skip_charges + + # We should take a look at charges if subscription is created in the past and if it is not upgrade + return true if subscription.plan.pay_in_advance? && + subscription.started_in_past? && + subscription.previous_subscription.nil? + + true + end + + def should_create_fixed_charge_fees?(subscription, boundaries) + # NOTE: When a subscription is terminated we still need to charge the fixed_charges + # fee if the fixed_charge is pay in arrears, otherwise this fee will never + # be created. + subscription.active? || subscription.incomplete? || + (subscription.terminated? && subscription.plan.fixed_charges.pay_in_arrears.any?) || + (subscription.terminated? && subscription.terminated_at > invoice.created_at) + end + + def should_create_credit_note_credit? + !not_in_finalizing_process? + end + + def should_create_coupon_credit? + return false if not_in_finalizing_process? + return false unless invoice.fees_amount_cents&.positive? + + true + end + + def should_create_applied_prepaid_credit? + return false if not_in_finalizing_process? + + invoice.total_amount_cents&.positive? + end + + def create_credit_note_credit + credit_result = Credits::CreditNoteService.new(invoice:).call + credit_result.raise_if_error! + + refresh_amounts(credit_amount_cents: credit_result.credits.sum(&:amount_cents)) if credit_result.credits + end + + def create_applied_prepaid_credit + prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice:) + refresh_amounts(credit_amount_cents: prepaid_credit_result.prepaid_credit_amount_cents) + end + + # NOTE: Since credit impact the invoice amount, we need to recompute the amount and the VAT amount + def refresh_amounts(credit_amount_cents:) + invoice.total_amount_cents -= credit_amount_cents + end + + def not_in_finalizing_process? + !finalizing_invoice? + end + + def in_trial_period_not_ending_today?(subscription, timestamp) + return false unless subscription.in_trial_period? + + tz = subscription.customer.applicable_timezone + + timestamp.in_time_zone(tz).to_date != subscription.trial_end_datetime.in_time_zone(tz).to_date + end + + def billing_advance_fixed_charges_on_first_invoice?(subscription) + return false if subscription.invoice_subscriptions.count > 1 + return false unless subscription.invoice_subscriptions.order(:created_at).last.subscription_starting? + return false if subscription.plan.fixed_charges.pay_in_advance.empty? + return false if subscription.plan.pay_in_advance? + # at this point we have an invoice for starting subscription (billed first time), where plan + # is not paid in advance and there are some fixed_charges that are paid_in_advance + true + end + + def finalizing_invoice? + context == :finalize || Invoice::GENERATED_INVOICE_STATUSES.include?(invoice.status) + end + + def event_filters(subscription, boundaries) + Events::BillingPeriodFilterService.call!( + subscription:, boundaries: + ) + end + end +end diff --git a/app/services/invoices/compute_amounts_from_fees.rb b/app/services/invoices/compute_amounts_from_fees.rb new file mode 100644 index 0000000..9229e45 --- /dev/null +++ b/app/services/invoices/compute_amounts_from_fees.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Invoices + class ComputeAmountsFromFees < BaseService + def initialize(invoice:, provider_taxes: nil) + @invoice = invoice + @provider_taxes = provider_taxes + + super + end + + def call + if should_apply_fee_taxes? + invoice.fees.each do |fee| + if provider_taxes && customer_provider_taxation? && invoice.should_apply_provider_tax? + Fees::ApplyProviderTaxesService.call!(fee:, fee_taxes: fee_taxes(fee)) + else + Fees::ApplyTaxesService.call!(fee:) + end + + fee.save! + end + end + + invoice.fees_amount_cents = invoice.fees.sum(:amount_cents) + invoice.coupons_amount_cents = invoice.credits.coupon_kind.sum(:amount_cents) + + invoice.sub_total_excluding_taxes_amount_cents = ( + invoice.fees_amount_cents - invoice.progressive_billing_credit_amount_cents - invoice.coupons_amount_cents + ) + + if customer_provider_taxation? && invoice.should_apply_provider_tax? + Invoices::ApplyProviderTaxesService.call!(invoice:, provider_taxes:) + else + Invoices::ApplyTaxesService.call!(invoice:) + end + + invoice.sub_total_including_taxes_amount_cents = ( + invoice.sub_total_excluding_taxes_amount_cents + invoice.taxes_amount_cents + ) + invoice.total_amount_cents = ( + invoice.sub_total_including_taxes_amount_cents - invoice.credit_notes_amount_cents + ) + + result.invoice = invoice + result + end + + private + + attr_reader :invoice, :provider_taxes + + def customer_provider_taxation? + @customer_provider_taxation ||= invoice.customer.tax_customer + end + + def fee_taxes(fee) + provider_taxes.find { |item| item.item_id == fee.id } + end + + def should_apply_fee_taxes? + return false if invoice.advance_charges? + + true + end + end +end diff --git a/app/services/invoices/compute_taxes_and_totals_service.rb b/app/services/invoices/compute_taxes_and_totals_service.rb new file mode 100644 index 0000000..c7218fe --- /dev/null +++ b/app/services/invoices/compute_taxes_and_totals_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Invoices + class ComputeTaxesAndTotalsService < BaseService + def initialize(invoice:, finalizing: true) + @invoice = invoice + @finalizing = finalizing + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + + # Tax provider takes precedence - VIES is irrelevant for these customers + if customer_provider_taxation? && invoice.should_apply_provider_tax? + set_pending_tax_status! + after_commit { Invoices::ProviderTaxes::PullTaxesAndApplyJob.perform_later(invoice:) } + return result.unknown_tax_failure!(code: "tax_error", message: "unknown taxes") + end + + vies_result = Invoices::EnsureCompletedViesCheckService.call(invoice:, finalizing:) + return vies_result if vies_result.failure? + + # Apply local taxes + Invoices::ComputeAmountsFromFees.call(invoice:) + + result.invoice = invoice + result + end + + private + + attr_reader :invoice, :finalizing + + def set_pending_tax_status! + invoice.status = (invoice.subscription_gated? ? :open : :pending) if finalizing + invoice.tax_status = :pending + invoice.save! + end + + def customer_provider_taxation? + @customer_provider_taxation ||= invoice.customer.tax_customer + end + end +end diff --git a/app/services/invoices/create_advance_charges_invoice_subscription_service.rb b/app/services/invoices/create_advance_charges_invoice_subscription_service.rb new file mode 100644 index 0000000..f1234a7 --- /dev/null +++ b/app/services/invoices/create_advance_charges_invoice_subscription_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Invoices + class CreateAdvanceChargesInvoiceSubscriptionService < BaseService + Result = BaseResult + + def initialize(invoice:, timestamp:, subscriptions_with_fees:, all_subscriptions:) + @invoice = invoice + @timestamp = timestamp + @subscriptions_with_fees = subscriptions_with_fees + @all_subscriptions = all_subscriptions + + super + end + + # Since the `advance_charges` invoice only have charges by design, + # we apply the `charges_(from|to)_date for both charges and subscriptions period + # See https://github.com/getlago/lago-api/pull/3327 for details + def call + latest_subscription = all_subscriptions.max_by(&:started_at) + boundaries = calculate_boundaries(latest_subscription) + + subscriptions_with_fees.each do |subscription| + invoice.invoice_subscriptions << InvoiceSubscription.create!( + organization: subscription.organization, + invoice:, + subscription:, + timestamp:, + from_datetime: boundaries[:from], + to_datetime: boundaries[:to], + charges_from_datetime: boundaries[:from], + charges_to_datetime: boundaries[:to], + recurring: false, + invoicing_reason: :in_advance_charge_periodic + ) + end + + result + end + + private + + attr_reader :invoice, :timestamp, :subscriptions_with_fees, :all_subscriptions + + def calculate_boundaries(subscription) + date_service = Subscriptions::DatesService.new_instance(subscription, timestamp, current_usage: false) + + { + from: date_service.charges_from_datetime, + to: date_service.charges_to_datetime + } + end + end +end diff --git a/app/services/invoices/create_generating_service.rb b/app/services/invoices/create_generating_service.rb new file mode 100644 index 0000000..bb810b2 --- /dev/null +++ b/app/services/invoices/create_generating_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Invoices + class CreateGeneratingService < BaseService + def initialize(customer:, invoice_type:, datetime:, currency:, charge_in_advance: false, skip_charges: false, invoice_id: nil, invoicing_reason: nil, subscription_gated: false, billing_entity: nil) # rubocop:disable Metrics/ParameterLists + @customer = customer + @invoice_type = invoice_type + @currency = currency + @datetime = datetime + @charge_in_advance = charge_in_advance + @skip_charges = skip_charges + @invoice_id = invoice_id + @recurring = invoicing_reason&.to_sym == :subscription_periodic + @subscription_gated = subscription_gated + @billing_entity = billing_entity + + super + end + + def call + return result.forbidden_failure! if customer.partner_account? && !organization.revenue_share_enabled? + + ActiveRecord::Base.transaction do + invoice = Invoice.create!( + id: invoice_id || SecureRandom.uuid, + organization:, + billing_entity: billing_entity || customer.billing_entity, + customer:, + invoice_type:, + currency:, + timezone: customer.applicable_timezone, + status: :generating, + issuing_date:, + expected_finalization_date:, + payment_due_date:, + net_payment_term: customer.applicable_net_payment_term, + skip_charges:, + self_billed: customer.partner_account? + ) + result.invoice = invoice + + yield invoice if block_given? + end + + result + end + + private + + attr_accessor :customer, :invoice_type, :currency, :datetime, :charge_in_advance, :skip_charges, :invoice_id, :recurring, :subscription_gated, :billing_entity + + delegate :organization, to: :customer + + # NOTE: accounting date must be in customer timezone + def issuing_date + date = datetime.in_time_zone(customer.applicable_timezone).to_date + return date if !grace_period? || charge_in_advance + + issuing_date_service = Invoices::IssuingDateService.new(customer_settings: customer, recurring:) + date + issuing_date_service.issuing_date_adjustment.days + end + + def expected_finalization_date + date = datetime.in_time_zone(customer.applicable_timezone).to_date + return date if !grace_period? || charge_in_advance + + date + customer.applicable_invoice_grace_period.days + end + + def grace_period? + return false if subscription_gated + + invoice_type.to_sym == :subscription + end + + def payment_due_date + (issuing_date + customer.applicable_net_payment_term.days).to_date + end + end +end diff --git a/app/services/invoices/create_invoice_subscription_service.rb b/app/services/invoices/create_invoice_subscription_service.rb new file mode 100644 index 0000000..9e46af0 --- /dev/null +++ b/app/services/invoices/create_invoice_subscription_service.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +module Invoices + class CreateInvoiceSubscriptionService < BaseService + def initialize(invoice:, subscriptions:, timestamp:, invoicing_reason:, refresh: false) + @invoice = invoice + @subscriptions = subscriptions + @timestamp = timestamp + @invoicing_reason = invoicing_reason + @recurring = invoicing_reason.to_sym == :subscription_periodic + @refresh = refresh + + super + end + + def call + if duplicated_invoices? + return result.service_failure!( + code: "duplicated_invoices", + message: "Invoice subscription already exists with the boundaries" + ) + end + + result.invoice_subscriptions = [] + + impacted_subscriptions.each do |subscription| + subscription_boundaries = subscriptions_boundaries[subscription.id] + boundaries = termination_boundaries(subscription, subscription_boundaries) + + result.invoice_subscriptions << InvoiceSubscription.create!( + organization: subscription.organization, + invoice:, + subscription:, + timestamp: boundaries.timestamp, + from_datetime: boundaries.from_datetime, + to_datetime: boundaries.to_datetime, + charges_from_datetime: boundaries.charges_from_datetime, + charges_to_datetime: boundaries.charges_to_datetime, + fixed_charges_from_datetime: boundaries.fixed_charges_from_datetime, + fixed_charges_to_datetime: boundaries.fixed_charges_to_datetime, + recurring: invoicing_reason.to_sym == :subscription_periodic, + invoicing_reason: invoicing_reason_for_subscription(subscription) + ) + end + + result + end + + private + + attr_accessor :invoice, :subscriptions, :timestamp, :invoicing_reason, :recurring, :refresh + + def datetime + @datetime ||= Time.zone.at(timestamp) + end + + def impacted_subscriptions + @impacted_subscriptions ||= if refresh + subscriptions + elsif recurring + subscriptions.select(&:active?).uniq(&:id) + else + subscriptions.uniq(&:id) + end + end + + def duplicated_invoices? + return false unless recurring + + subscriptions_boundaries.any? do |subscription_id, boundaries| + subscription = Subscription.includes(:plan).find(subscription_id) + + InvoiceSubscription.matching?(subscription, boundaries) + end + end + + def subscriptions_boundaries + @subscriptions_boundaries ||= impacted_subscriptions.each_with_object({}) do |subscription, boundaries| + boundaries[subscription.id] = calculate_boundaries(subscription) + end + end + + def calculate_boundaries(subscription) + ds = date_service(subscription) + + BillingPeriodBoundaries.new( + from_datetime: ds.from_datetime, + to_datetime: ds.to_datetime, + charges_from_datetime: ds.charges_from_datetime, + charges_to_datetime: ds.charges_to_datetime, + charges_duration: ds.charges_duration_in_days, + fixed_charges_from_datetime: ds.fixed_charges_from_datetime, + fixed_charges_to_datetime: ds.fixed_charges_to_datetime, + fixed_charges_duration: ds.fixed_charges_duration_in_days, + timestamp: datetime + ) + end + + def date_service(subscription) + current_usage = invoicing_reason.to_sym == :progressive_billing + current_usage ||= subscription.terminated_at?(datetime) && subscription.upgraded? + + Subscriptions::DatesService.new_instance(subscription, datetime, current_usage:) + end + + # This method calculates boundaries for terminated subscription. If termination is happening on billing date + # new boundaries will be calculated only if there is no invoice subscription object for previous period. + # Basically, we will bill regular subscription amount for previous period. + # If subscription is happening on any other day, method is returning boundaries only for the used dates in + # current period + def termination_boundaries(subscription, boundaries) + return boundaries unless subscription.terminated? && subscription.next_subscription.nil? + + # First we need to ensure that termination date is not started_at date. In that case boundaries are correct + # and we should bill only one day. If this is not the case we should proceed. + return boundaries if (datetime - 1.day) < subscription.started_at + + # Date service has various checks for terminated subscriptions. We want to avoid it and fetch boundaries + # for current usage (current period) but when subscription was active (one day ago) + duplicate = subscription.dup.tap { |s| s.status = :active } + + dates_service = Subscriptions::DatesService.new_instance(duplicate, datetime - 1.day, current_usage: true) + return boundaries if datetime < dates_service.charges_to_datetime + return boundaries unless (datetime - dates_service.charges_to_datetime) < 1.day + + # We should calculate boundaries as if subscription was not terminated + ds = Subscriptions::DatesService.new_instance(duplicate, datetime, current_usage: false) + + previous_period_boundaries = BillingPeriodBoundaries.new( + from_datetime: ds.from_datetime, + to_datetime: ds.to_datetime, + charges_from_datetime: ds.charges_from_datetime, + charges_to_datetime: ds.charges_to_datetime, + fixed_charges_from_datetime: ds.fixed_charges_from_datetime, + fixed_charges_to_datetime: ds.fixed_charges_to_datetime, + timestamp: datetime, + charges_duration: ds.charges_duration_in_days, + fixed_charges_duration: ds.fixed_charges_duration_in_days + ) + + InvoiceSubscription.matching?(subscription, previous_period_boundaries) ? boundaries : previous_period_boundaries + end + + def invoicing_reason_for_subscription(subscription) + # NOTE: upgrading is used as a not persisted reason as it means + # one subscription starting and a second one terminating + return invoicing_reason if invoicing_reason.to_sym != :upgrading + return :subscription_terminating if subscription.terminated_at?(timestamp) + + :subscription_starting + end + end +end diff --git a/app/services/invoices/create_one_off_service.rb b/app/services/invoices/create_one_off_service.rb new file mode 100644 index 0000000..2dd8700 --- /dev/null +++ b/app/services/invoices/create_one_off_service.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +module Invoices + class CreateOneOffService < BaseService + def initialize(customer:, currency:, fees:, timestamp:, skip_psp: false, voided_invoice_id: nil, payment_method_params: nil, invoice_custom_section: {}, billing_entity_id: nil, billing_entity_code: nil) + @customer = customer + @currency = currency || customer&.currency + @fees = fees + @timestamp = timestamp + @skip_psp = skip_psp || false + @voided_invoice_id = voided_invoice_id + @payment_method_params = payment_method_params + @invoice_custom_section = invoice_custom_section + @billing_entity_id = billing_entity_id + @billing_entity_code = billing_entity_code + + super(nil) + end + + activity_loggable( + action: "invoice.one_off_created", + record: -> { result.invoice }, + condition: -> { result.invoice&.finalized? } + ) + + def call + return result.not_found_failure!(resource: "customer") unless customer + return result.not_found_failure!(resource: "fees") if fees.blank? + return result.not_found_failure!(resource: "add_on") unless add_ons.count == add_on_identifiers.count + return result unless valid_payment_method? + + resolve_billing_entity + return result unless result.success? + + tax_deferred = false + + ActiveRecord::Base.transaction do + Customers::UpdateCurrencyService + .call(customer:, currency:) + .raise_if_error! + + create_generating_invoice + + result.invoice = invoice + + create_one_off_fees(invoice) + + invoice.fees_amount_cents = invoice.fees.sum(:amount_cents) + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents + + invoice.payment_method = payment_method + invoice.skip_automatic_payment = skip_psp + + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:) + if totals_result.failure? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) + tax_deferred = true + next + end + totals_result.raise_if_error! + + unless skip_custom_sections? + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:, custom_section_ids: invoice_custom_section_ids) + end + + invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded + Invoices::TransitionToFinalStatusService.call(invoice:) + invoice.voided_invoice_id = voided_invoice_id if voided_invoice_id.present? + invoice.save! + end + + return result if tax_deferred + + unless invoice.closed? + Utils::SegmentTrack.invoice_created(invoice) + SendWebhookJob.perform_later("invoice.one_off_created", invoice) + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:, payment_method_params:) unless invoice.skip_automatic_payment? + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue Sequenced::SequenceError + raise + rescue BaseService::FailedResult => e + e.result + rescue => e + result.fail_with_error!(e) + end + + private + + attr_accessor :timestamp, :currency, :customer, :fees, :invoice, :skip_psp, :voided_invoice_id, :payment_method_params, :invoice_custom_section + attr_reader :billing_entity_id, :billing_entity_code, :billing_entity + + def create_generating_invoice + invoice_result = Invoices::CreateGeneratingService.call( + customer:, + invoice_type: :one_off, + currency:, + datetime: Time.zone.at(timestamp), + billing_entity: + ) + invoice_result.raise_if_error! + + @invoice = invoice_result.invoice + end + + def resolve_billing_entity + if multi_entity_enabled? && billing_entity_id.present? + @billing_entity = customer.organization.billing_entities.find_by(id: billing_entity_id) + result.not_found_failure!(resource: "billing_entity") if @billing_entity.nil? + elsif multi_entity_enabled? && billing_entity_code.present? + @billing_entity = customer.organization.billing_entities.find_by(code: billing_entity_code) + result.not_found_failure!(resource: "billing_entity") if @billing_entity.nil? + else + @billing_entity = customer.billing_entity + end + end + + def multi_entity_enabled? + customer.organization.feature_flag_enabled?(:multi_entity_billing) + end + + def create_one_off_fees(invoice) + Fees::OneOffService.call!(invoice:, fees:) + end + + def should_deliver_email? + License.premium? && invoice.billing_entity.email_settings.include?("invoice.finalized") + end + + def add_ons + finder = api_context? ? :code : :id + + customer.organization.add_ons.where(finder => add_on_identifiers) + end + + def add_on_identifiers + identifier = api_context? ? :add_on_code : :add_on_id + + fees.pluck(identifier).uniq + end + + def valid_payment_method? + result.payment_method = payment_method + + PaymentMethods::ValidateService.new(result, payment_method: payment_method_params).valid? + end + + def payment_method + return @payment_method if defined? @payment_method + return nil if payment_method_params.blank? || payment_method_params[:payment_method_id].blank? + + @payment_method = customer.payment_methods.find_by(id: payment_method_params[:payment_method_id]) + end + + def invoice_custom_section_ids + return @invoice_custom_section_ids if defined?(@invoice_custom_section_ids) + return @invoice_custom_section_ids = [] if section_identifiers.blank? + + identifier = api_context? ? :code : :id + @invoice_custom_section_ids = + customer.organization.invoice_custom_sections.where(identifier => section_identifiers).pluck(:id) + end + + def section_identifiers + return nil unless invoice_custom_section + + key = api_context? ? :invoice_custom_section_codes : :invoice_custom_section_ids + + invoice_custom_section[key]&.compact&.uniq + end + + def skip_custom_sections? + return false unless invoice_custom_section + return false if invoice_custom_section[:skip_invoice_custom_sections].nil? + + invoice_custom_section[:skip_invoice_custom_sections] + end + end +end diff --git a/app/services/invoices/create_pay_in_advance_charge_service.rb b/app/services/invoices/create_pay_in_advance_charge_service.rb new file mode 100644 index 0000000..bbc4c5e --- /dev/null +++ b/app/services/invoices/create_pay_in_advance_charge_service.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Invoices + class CreatePayInAdvanceChargeService < BaseService + Result = BaseResult[:invoice, :invoice_id] + + def initialize(charge:, event:, timestamp:) + @charge = charge + @event = Events::CommonFactory.new_instance(source: event) + @timestamp = timestamp + + super + end + + def call + fee_result = generate_fees + fees = fee_result.fees + return result if fees.none? + + tax_deferred = false + + ApplicationRecord.transaction do + create_generating_invoice + fees.each { |f| f.update!(invoice:) } + + invoice.fees_amount_cents = invoice.fees.sum(:amount_cents) + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents + Credits::AppliedCouponsService.call(invoice:) if invoice.fees_amount_cents&.positive? + + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:) + if totals_result.failure? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) + tax_deferred = true + next + end + totals_result.raise_if_error! + + create_credit_note_credit + create_applied_prepaid_credit if should_create_applied_prepaid_credit? + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded + Invoices::TransitionToFinalStatusService.call(invoice:) + invoice.save! + end + + result.invoice = invoice + + if tax_deferred + deliver_fee_webhooks + return result + end + + unless invoice.closed? + Utils::SegmentTrack.invoice_created(invoice) + deliver_webhooks + Utils::ActivityLog.produce(invoice, "invoice.created") + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue Sequenced::SequenceError, ActiveRecord::StaleObjectError, Customers::FailedToAcquireLock + raise + rescue => e + result.fail_with_error!(e) + end + + private + + attr_accessor :timestamp, :charge, :event, :invoice + + delegate :subscription, to: :event + delegate :customer, to: :subscription + + def create_generating_invoice + invoice_result = Invoices::CreateGeneratingService.call( + customer:, + invoice_type: :subscription, + currency: subscription.plan_amount_currency, + datetime: Time.zone.at(timestamp), + charge_in_advance: true, + invoice_id: result.invoice_id + ) do |invoice| + Invoices::CreateInvoiceSubscriptionService + .call(invoice:, subscriptions: [subscription], timestamp:, invoicing_reason: :in_advance_charge) + .raise_if_error! + end + invoice_result.raise_if_error! + @invoice = invoice_result.invoice + end + + def generate_fees + Fees::CreatePayInAdvanceService.call!(charge:, event:, estimate: true).tap do |fee_result| + result.invoice_id = fee_result.invoice_id + end + end + + def deliver_webhooks + deliver_fee_webhooks + SendWebhookJob.perform_later("invoice.created", invoice) + end + + def deliver_fee_webhooks + invoice.fees.each { |f| SendWebhookJob.perform_later("fee.created", f) } + end + + def should_deliver_email? + License.premium? && customer.billing_entity.email_settings.include?("invoice.finalized") + end + + def should_create_applied_prepaid_credit? + invoice.total_amount_cents&.positive? + end + + def create_credit_note_credit + credit_result = Credits::CreditNoteService.new(invoice:).call + credit_result.raise_if_error! + + refresh_amounts(credit_amount_cents: credit_result.credits.sum(&:amount_cents)) if credit_result.credits + end + + def create_applied_prepaid_credit + prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice:) + refresh_amounts(credit_amount_cents: prepaid_credit_result.prepaid_credit_amount_cents) + end + + def refresh_amounts(credit_amount_cents:) + invoice.total_amount_cents -= credit_amount_cents + end + end +end diff --git a/app/services/invoices/create_pay_in_advance_fixed_charges_service.rb b/app/services/invoices/create_pay_in_advance_fixed_charges_service.rb new file mode 100644 index 0000000..1142f1e --- /dev/null +++ b/app/services/invoices/create_pay_in_advance_fixed_charges_service.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module Invoices + class CreatePayInAdvanceFixedChargesService < BaseService + Result = BaseResult[:invoice] + + def initialize(subscription:, timestamp:) + @subscription = subscription + @timestamp = timestamp + @customer = subscription.customer + @organization = subscription.organization + + super + end + + def call + return result unless subscription.active? || subscription.gated? + return result if fixed_charge_events.empty? + + # Calculate fees for all fixed charge events + fees = calculate_all_fees + # Invoice without fees should be created if there are no fees to bill + # return result if fees.empty? + + tax_deferred = false + + ActiveRecord::Base.transaction do + create_generating_invoice + invoice.status = :open if subscription.gated? + fees.each do |fee| + fee.invoice = invoice + fee.save! + end + + invoice.fees_amount_cents = invoice.fees.sum(:amount_cents) + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents + Credits::AppliedCouponsService.call(invoice:) if invoice.fees_amount_cents&.positive? + + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:) + if totals_result.failure? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) + tax_deferred = true + next + end + totals_result.raise_if_error! + + create_credit_note_credit + create_applied_prepaid_credit if should_create_applied_prepaid_credit? + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + skip_payment_gating_for_zero_amount if subscription.payment_gated? && invoice.total_amount_cents.zero? && !invoice.tax_pending? + + invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded + Invoices::TransitionToFinalStatusService.call(invoice:) + invoice.save! + end + + result.invoice = invoice + + if tax_deferred + deliver_fee_webhooks + return result + end + + if subscription.gated? + Invoices::Payments::CreateService.call_async(invoice:) + elsif !invoice.closed? + Utils::SegmentTrack.invoice_created(invoice) + deliver_webhooks + Utils::ActivityLog.produce(invoice, "invoice.created") + Invoices::GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue Sequenced::SequenceError, ActiveRecord::StaleObjectError, Customers::FailedToAcquireLock + raise + rescue => e + result.fail_with_error!(e) + end + + private + + attr_reader :subscription, :timestamp, :customer, :organization + attr_accessor :invoice + + def skip_payment_gating_for_zero_amount + Subscriptions::ActivationRules::Payment::EvaluateService.call!( + rule: subscription.activation_rules.payment.sole, + status: :satisfied + ) + Subscriptions::ActivationRules::ResolveSubscriptionStatusService.call!(subscription:) + end + + def fixed_charge_events + @fixed_charge_events ||= subscription + .fixed_charge_events + .where( + fixed_charge: subscription.fixed_charges.pay_in_advance, + timestamp: Time.zone.at(timestamp) + ) + end + + def calculate_all_fees + fees = [] + + fixed_charge_events.each do |event| + fixed_charge = event.fixed_charge + next unless fixed_charge.pay_in_advance? + + fee_result = Fees::BuildPayInAdvanceFixedChargeService.call!( + subscription:, + fixed_charge:, + fixed_charge_event: event, + timestamp: + ) + + fees << fee_result.fee if fee_result.fee + end + + fees + end + + def create_generating_invoice + invoice_result = Invoices::CreateGeneratingService.call( + customer:, + invoice_type: :subscription, + currency: subscription.plan_amount_currency, + datetime: Time.zone.at(timestamp), + charge_in_advance: true + ) do |inv| + Invoices::CreateInvoiceSubscriptionService + .call(invoice: inv, subscriptions: [subscription], timestamp:, invoicing_reason: :in_advance_charge) + .raise_if_error! + end + invoice_result.raise_if_error! + + @invoice = invoice_result.invoice + end + + def deliver_webhooks + deliver_fee_webhooks + SendWebhookJob.perform_later("invoice.created", invoice) + end + + def deliver_fee_webhooks + invoice.fees.each { |f| SendWebhookJob.perform_later("fee.created", f) } + end + + def should_deliver_email? + License.premium? && customer.billing_entity.email_settings.include?("invoice.finalized") + end + + def wallets + @wallets ||= customer.wallets.active.includes(:wallet_targets) + .with_positive_balance.in_application_order + end + + def should_create_applied_prepaid_credit? + return false unless invoice.total_amount_cents&.positive? + + wallets.any? + end + + def create_credit_note_credit + credit_result = Credits::CreditNoteService.new(invoice:).call + credit_result.raise_if_error! + + refresh_amounts(credit_amount_cents: credit_result.credits.sum(&:amount_cents)) if credit_result.credits + end + + def create_applied_prepaid_credit + prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice:) + + refresh_amounts(credit_amount_cents: prepaid_credit_result.prepaid_credit_amount_cents) + end + + def refresh_amounts(credit_amount_cents:) + invoice.total_amount_cents -= credit_amount_cents + end + end +end diff --git a/app/services/invoices/customer_usage_service.rb b/app/services/invoices/customer_usage_service.rb new file mode 100644 index 0000000..07d08ed --- /dev/null +++ b/app/services/invoices/customer_usage_service.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +module Invoices + class CustomerUsageService < BaseService + def initialize( + customer:, + subscription:, + timestamp: Time.current, + apply_taxes: true, + with_cache: true, + max_timestamp: nil, + calculate_projected_usage: false, + with_zero_units_filters: true, + usage_filters: UsageFilters::NONE + ) + super + + @apply_taxes = apply_taxes + @customer = customer + @subscription = subscription + @timestamp = timestamp # To not set this value if without disabling the cache + @with_cache = with_cache + @calculate_projected_usage = calculate_projected_usage + @with_zero_units_filters = with_zero_units_filters + @usage_filters = usage_filters + + # NOTE: used to force charges_to_datetime boundary + @max_timestamp = max_timestamp + end + + def self.with_external_ids(customer_external_id:, external_subscription_id:, organization_id:, apply_taxes: true, + calculate_projected_usage: false, usage_filters: UsageFilters::NONE) + customer = Customer.find_by!(external_id: customer_external_id, organization_id:) + subscription = customer&.active_subscriptions&.find_by(external_id: external_subscription_id) + new(customer:, subscription:, apply_taxes:, calculate_projected_usage:, usage_filters:) + rescue ActiveRecord::RecordNotFound + result.not_found_failure!(resource: "customer") + end + + def self.with_ids(organization_id:, customer_id:, subscription_id:, apply_taxes: true, calculate_projected_usage: false) + customer = Customer.find_by(id: customer_id, organization_id:) + subscription = customer&.active_subscriptions&.find_by(id: subscription_id) + new(customer:, subscription:, apply_taxes:, calculate_projected_usage:) + rescue ActiveRecord::RecordNotFound + result.not_found_failure!(resource: "customer") + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + return result.not_allowed_failure!(code: "no_active_subscription") if subscription.blank? + return result.not_allowed_failure!(code: "full_usage_not_allowed") if usage_filters.full_usage && !querying_full_usage_allowed + return result.not_found_failure!(resource: "charge") if charges.empty? && usage_filters.has_charge_filter? + + result.usage = compute_usage + result.invoice = invoice + result + rescue BaseService::ThrottlingError => error + result.too_many_provider_requests_failure!(provider_name: error.provider_name, error:) + end + + private + + attr_reader :customer, :invoice, :subscription, :timestamp, :apply_taxes, :with_cache, :max_timestamp, :calculate_projected_usage, :with_zero_units_filters + attr_reader :usage_filters + + delegate :plan, to: :subscription + delegate :billing_entity, to: :customer + + def charges + return @charges if defined?(@charges) + + charges = subscription + .plan + .charges + .joins(:billable_metric) + .includes(:taxes, billable_metric: :organization, filters: {values: :billable_metric_filter}) + if usage_filters.filter_by_charge_id.present? + charges = charges.where(id: usage_filters.filter_by_charge_id) + elsif usage_filters.filter_by_charge_code.present? + charges = charges.where(code: usage_filters.filter_by_charge_code) + elsif usage_filters.filter_by_metric_code.present? + charges = charges.where(billable_metrics: {code: usage_filters.filter_by_metric_code}) + end + @charges = charges + end + + # NOTE: Since computing customer usage could take some time as it as to + # loop over a lot of records in database, the result is stored in a cache store. + # - Each charge result is stored in its own fragmented cache + # - The cache expiration is set to the end of the billing period + # - Cache will be automatically cleared if a new event is sent for a specific charge + def compute_usage + @invoice = Invoice.new( + organization:, + billing_entity:, + customer:, + issuing_date: boundaries.issuing_date, + currency: plan.amount_currency + ) + + invoice.fees = compute_charge_fees + + if apply_taxes && customer_provider_taxation? + compute_amounts_with_provider_taxes + elsif apply_taxes + compute_amounts + else + compute_amounts_without_tax + end + + format_usage + end + + def organization + @organization ||= subscription.organization + end + + def compute_charge_fees + fees = [] + filters = event_filters(subscription, boundaries).charges + charges.find_each { |c| fees += charge_usage(c, filters[c.id] || []) } + return fees if usage_filters.has_charge_filter? + + fees.sort_by { |f| f.billable_metric.name.downcase } + end + + def charge_usage(charge, applied_filters) + cache_middleware = Subscriptions::ChargeCacheMiddleware.new( + subscription:, + charge:, + to_datetime: boundaries.charges_to_datetime, + cache: cache_applicable? + ) + + applied_boundaries = boundaries + applied_boundaries = boundaries.dup.tap { it.max_timestamp = max_timestamp } if max_timestamp + if usage_filters.filter_by_group.present? + cache_middleware = nil + end + + Fees::ChargeService + .call!( + invoice:, + charge:, + subscription:, + boundaries: applied_boundaries, + context: :current_usage, + cache_middleware:, + calculate_projected_usage:, + with_zero_units_filters:, + filtered_aggregations: applied_filters, + usage_filters: + ) + .fees + end + + def boundaries + return @boundaries if @boundaries.present? + + from = usage_filters.full_usage ? subscription.started_at : date_service.from_datetime + charges_from = usage_filters.full_usage ? subscription.started_at : date_service.charges_from_datetime + + @boundaries = BillingPeriodBoundaries.new( + from_datetime: from, + to_datetime: date_service.to_datetime, + charges_from_datetime: charges_from, + charges_to_datetime: date_service.charges_to_datetime, + issuing_date: date_service.next_end_of_period, + charges_duration: date_service.charges_duration_in_days, + timestamp: + ) + end + + def date_service + @date_service ||= Subscriptions::DatesService.new_instance(subscription, timestamp, current_usage: true) + end + + # NOTE: The charge cache key does not include from_datetime, so when full_usage + # shifts the boundaries back to subscription.started_at, the cache would + # return stale current-period data. Disable cache in that case. + # When started_at matches the current period boundary, the aggregation + # window is identical and the cache is safe to use. + def cache_applicable? + return with_cache unless usage_filters.full_usage + + with_cache && subscription.started_at == date_service.charges_from_datetime + end + + def compute_amounts + invoice.fees_amount_cents = invoice.fees.sum(&:amount_cents) + plan = subscription.plan + + invoice.fees.each do |fee| + taxes_result = Fees::ApplyTaxesService.call(fee:, customer:, plan:) + taxes_result.raise_if_error! + end + + taxes_result = Invoices::ApplyTaxesService.call(invoice:) + taxes_result.raise_if_error! + + invoice.total_amount_cents = invoice.fees_amount_cents + invoice.taxes_amount_cents + end + + def compute_amounts_without_tax + invoice.fees_amount_cents = invoice.fees.sum(&:amount_cents) + invoice.taxes_amount_cents = 0 + invoice.taxes_rate = 0 + invoice.total_amount_cents = invoice.fees_amount_cents + end + + def compute_amounts_with_provider_taxes + invoice.fees_amount_cents = invoice.fees.sum(&:amount_cents) + + taxes_result = Integrations::Aggregator::Taxes::Invoices::CreateDraftService.call(invoice:, fees: invoice.fees) + + return result.validation_failure!(errors: {tax_error: [taxes_result.error.message]}) unless taxes_result.success? + + result.fees_taxes = taxes_result.fees + + invoice.fees.each do |fee| + fee_taxes = result.fees_taxes.find do |item| + item.item_key == fee.item_key + end + + res = Fees::ApplyProviderTaxesService.call(fee:, fee_taxes:) + res.raise_if_error! + end + + res = Invoices::ApplyProviderTaxesService.call(invoice:, provider_taxes: result.fees_taxes) + res.raise_if_error! + + invoice.total_amount_cents = invoice.fees_amount_cents + invoice.taxes_amount_cents + end + + def format_usage + SubscriptionUsage.new( + from_datetime: boundaries.charges_from_datetime.iso8601, + to_datetime: boundaries.charges_to_datetime.iso8601, + issuing_date: invoice.issuing_date.iso8601, + currency: invoice.currency, + amount_cents: invoice.fees_amount_cents, + total_amount_cents: invoice.total_amount_cents, + taxes_amount_cents: invoice.taxes_amount_cents, + fees: invoice.fees + ) + end + + def customer_provider_taxation? + @customer_provider_taxation ||= invoice.customer.tax_customer + end + + def event_filters(subscription, boundaries) + Events::BillingPeriodFilterService.call!( + subscription:, boundaries: + ) + end + + def querying_full_usage_allowed + return false unless organization.granular_lifetime_usage_enabled? + + any_filter_present = usage_filters.has_charge_filter? || usage_filters.filter_by_group.present? + subscription_has_prorated_charges = charges.where(prorated: true).exists? + + # full usage is only allowed for subscriptions without prorated charges + # and only when filtering by charge or by group + !subscription_has_prorated_charges && any_filter_present + end + end +end diff --git a/app/services/invoices/ensure_completed_vies_check_service.rb b/app/services/invoices/ensure_completed_vies_check_service.rb new file mode 100644 index 0000000..117509d --- /dev/null +++ b/app/services/invoices/ensure_completed_vies_check_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Invoices + class EnsureCompletedViesCheckService < BaseService + def initialize(invoice:, finalizing: true) + @invoice = invoice + @customer = invoice&.customer + @finalizing = finalizing + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + + # VIES is irrelevant for provider tax customers + return result if customer_provider_taxation? + + # Check if VIES validation is pending + return result unless customer.vies_check_in_progress? + + invoice.status = (invoice.subscription_gated? ? :open : :pending) if finalizing + invoice.tax_status = :pending + invoice.save! + + result.unknown_tax_failure!(code: "vies_check_pending", message: "VIES validation pending") + end + + private + + attr_reader :invoice, :customer, :finalizing + + def customer_provider_taxation? + customer.tax_customer.present? + end + end +end diff --git a/app/services/invoices/finalize_batch_service.rb b/app/services/invoices/finalize_batch_service.rb new file mode 100644 index 0000000..87b6248 --- /dev/null +++ b/app/services/invoices/finalize_batch_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Invoices + class FinalizeBatchService < BaseService + def initialize(organization:) + @organization = organization + + super + end + + def call_async + Invoices::FinalizeAllJob.perform_later(organization:, invoice_ids: invoices.ids) + + result.invoices = invoices + + result + end + + def call(invoice_ids) + processed_invoices = [] + Invoice.where(id: invoice_ids).find_each do |invoice| + result = Invoices::RefreshDraftAndFinalizeService.new(invoice:).call + + return result unless result.success? + + processed_invoices << result.invoice + end + + result.invoices = processed_invoices.compact + + result + end + + private + + attr_reader :organization + + def invoices + @invoices ||= organization.invoices.where(status: :draft) + end + end +end diff --git a/app/services/invoices/finalize_open_credit_service.rb b/app/services/invoices/finalize_open_credit_service.rb new file mode 100644 index 0000000..4c7de96 --- /dev/null +++ b/app/services/invoices/finalize_open_credit_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Invoices + class FinalizeOpenCreditService < BaseService + def initialize(invoice:) + @invoice = invoice + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.nil? + + result.invoice = invoice + return result if invoice.finalized? + + ActiveRecord::Base.transaction do + invoice.issuing_date = today_in_tz + invoice.payment_due_date = today_in_tz + Invoices::FinalizeService.call!(invoice: invoice) + end + + SendWebhookJob.perform_later("invoice.paid_credit_added", result.invoice) + Utils::ActivityLog.produce(invoice, "invoice.paid_credit_added") + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Utils::SegmentTrack.invoice_created(result.invoice) + + result + end + + private + + attr_accessor :invoice, :result + + def today_in_tz + @today_in_tz ||= Time.current.in_time_zone(invoice.customer.applicable_timezone).to_date + end + + def should_deliver_email? + License.premium? && + invoice.billing_entity.email_settings.include?("invoice.finalized") + end + end +end diff --git a/app/services/invoices/finalize_pending_vies_invoice_service.rb b/app/services/invoices/finalize_pending_vies_invoice_service.rb new file mode 100644 index 0000000..9329388 --- /dev/null +++ b/app/services/invoices/finalize_pending_vies_invoice_service.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +module Invoices + class FinalizePendingViesInvoiceService < BaseService + Result = BaseResult[:invoice] + + def initialize(invoice:) + @invoice = invoice + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result if !invoice.pending? && !invoice.subscription_gated? + return result unless invoice.tax_pending? + return result if customer.tax_customer + return result if customer.vies_check_in_progress? + + ActiveRecord::Base.transaction do + invoice.issuing_date = issuing_date + invoice.payment_due_date = payment_due_date + + Invoices::ComputeAmountsFromFees.call(invoice:) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + create_credit_note_credit if should_create_credit_note_credit? + create_applied_prepaid_credit if should_create_applied_prepaid_credit? + + invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded + invoice.tax_status = "succeeded" + + skip_payment_gating_for_zero_amount if invoice.subscription_payment_gated? && invoice.total_amount_cents.zero? + + Invoices::TransitionToFinalStatusService.call(invoice:) + + invoice.save! + invoice.reload + + result.invoice = invoice + end + + if invoice.subscription_gated? + after_commit { create_payment } + elsif invoice.finalized? + after_commit do + SendWebhookJob.perform_later(webhook_type, invoice) + Utils::ActivityLog.produce(invoice, webhook_type) + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + create_payment + Utils::SegmentTrack.invoice_created(invoice) + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice + + def skip_payment_gating_for_zero_amount + gated = invoice.subscriptions.find(&:payment_gated?) + return unless gated + + Subscriptions::ActivationRules::Payment::EvaluateService.call!( + rule: gated.activation_rules.payment.sole, + status: :satisfied + ) + Subscriptions::ActivationRules::ResolveSubscriptionStatusService.call!(subscription: gated) + end + + def customer + @customer ||= invoice.customer + end + + def issuing_date + @issuing_date ||= if issuing_date_keep_anchor? + invoice.issuing_date + else + Time.current.in_time_zone(customer.applicable_timezone).to_date + end + end + + def issuing_date_keep_anchor? + invoice.invoice_subscriptions.first&.recurring? && + customer.applicable_subscription_invoice_issuing_date_adjustment == "keep_anchor" + end + + def payment_due_date + @payment_due_date ||= issuing_date + customer.applicable_net_payment_term.days + end + + def should_create_credit_note_credit? + !invoice.one_off? + end + + def should_create_applied_prepaid_credit? + return false if invoice.one_off? + + invoice.total_amount_cents&.positive? + end + + def create_credit_note_credit + credit_result = Credits::CreditNoteService.new(invoice:).call! + invoice.total_amount_cents -= credit_result.credits.sum(&:amount_cents) if credit_result.credits + end + + def create_applied_prepaid_credit + prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice:) + invoice.total_amount_cents -= prepaid_credit_result.prepaid_credit_amount_cents + end + + def should_deliver_email? + License.premium? && + invoice.billing_entity.email_settings.include?("invoice.finalized") + end + + def webhook_type + invoice.one_off? ? "invoice.one_off_created" : "invoice.created" + end + + def create_payment + return if invoice.skip_automatic_payment? + + payment_method_params = if invoice.payment_method_id.present? + {payment_method_id: invoice.payment_method_id} + else + {} + end + + Invoices::Payments::CreateService.call_async(invoice:, payment_method_params:) + end + end +end diff --git a/app/services/invoices/finalize_service.rb b/app/services/invoices/finalize_service.rb new file mode 100644 index 0000000..7a2dca7 --- /dev/null +++ b/app/services/invoices/finalize_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Invoices + class FinalizeService < BaseService + def initialize(invoice:) + @invoice = invoice + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.nil? + + if invoice.finalized? + result.invoice = invoice + return result + end + + invoice.finalized! + + result.invoice = invoice + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice + end +end diff --git a/app/services/invoices/generate_pdf_service.rb b/app/services/invoices/generate_pdf_service.rb new file mode 100644 index 0000000..a5d4020 --- /dev/null +++ b/app/services/invoices/generate_pdf_service.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +module Invoices + class GeneratePdfService < BaseService + def initialize(invoice:, context: nil) + @invoice = invoice + @context = context + + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.blank? + return result.not_allowed_failure!(code: "is_draft") if invoice.draft? + + if should_generate_pdf? + generate_pdf + SendWebhookJob.perform_later("invoice.generated", invoice) + Utils::ActivityLog.produce(invoice, "invoice.generated") + end + + result.invoice = invoice + result + end + + def render_html + Utils::PdfGenerator.new(template:, context: invoice).render_html + end + + private + + attr_reader :invoice, :context + + def generate_pdf + I18n.with_locale(invoice.customer.preferred_document_locale) do + pdf_file = build_pdf_file + xml_file = attach_facturx(pdf_file) if should_generate_facturx_einvoice_xml? + attach_pdf_to_invoice(pdf_file) + invoice.save! + ensure + cleanup_tempfiles(pdf_file, xml_file) + end + end + + def build_pdf_file + pdf_content = Utils::PdfGenerator.call(template:, context: invoice).io.read + + pdf_file = Tempfile.new([invoice.number, ".pdf"]) + pdf_file.binmode + pdf_file.write(pdf_content) + pdf_file.flush + + pdf_file + end + + def attach_facturx(pdf_file) + xml_file = Tempfile.new([invoice.number, ".xml"]) + xml_file.write(EInvoices::Invoices::FacturX::CreateService.call(invoice:).xml) + xml_file.flush + + Utils::PdfAttachmentService.call(file: pdf_file, attachment: xml_file) + xml_file + end + + def attach_pdf_to_invoice(pdf_file) + invoice.file.attach( + io: File.open(pdf_file.path), + filename: "#{invoice.number}.pdf", + content_type: "application/pdf" + ) + end + + def cleanup_tempfiles(pdf_file, xml_file) + pdf_file&.unlink + xml_file&.unlink + end + + def template + if invoice.self_billed? + "invoices/v#{invoice.version_number}/self_billed" + + elsif invoice.one_off? + return "invoices/v3/one_off" if invoice.version_number < 4 + + "invoices/v#{invoice.version_number}/one_off" + elsif charge? + return "invoices/v3/charge" if invoice.version_number < 4 + + "invoices/v#{invoice.version_number}/charge" + elsif fixed_charge? + "invoices/v#{invoice.version_number}/fixed_charge" + else + "invoices/v#{invoice.version_number}" + end + end + + def should_generate_pdf? + return false if ActiveModel::Type::Boolean.new.cast(ENV["LAGO_DISABLE_PDF_GENERATION"]) + + context == "admin" || invoice.file.blank? + end + + def should_generate_facturx_einvoice_xml? + invoice.billing_entity.einvoicing && BillingEntity::EINVOICING_COUNTRIES.include?(invoice.billing_entity.country.try(:upcase)) + end + + def charge? + invoice.fees.present? && invoice.fees.all?(&:pay_in_advance?) && invoice.fees.all?(&:charge?) + end + + def fixed_charge? + invoice.fees.present? && invoice.fees.all?(&:pay_in_advance?) && invoice.fees.all?(&:fixed_charge?) + end + end +end diff --git a/app/services/invoices/generate_xml_service.rb b/app/services/invoices/generate_xml_service.rb new file mode 100644 index 0000000..8783f98 --- /dev/null +++ b/app/services/invoices/generate_xml_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Invoices + class GenerateXmlService < BaseService + def initialize(invoice:, context: nil) + @invoice = invoice + @context = context + + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.blank? + return result.not_allowed_failure!(code: "is_draft") if invoice.draft? + + if should_generate_xml? + generate_xml + end + + result.invoice = invoice + result + end + + private + + attr_reader :invoice, :context + + def generate_xml + I18n.with_locale(invoice.customer.preferred_document_locale) do + xml_file = build_xml_file + attach_xml_to_invoice(xml_file) + invoice.save! + ensure + cleanup_tempfiles(xml_file) + end + end + + def build_xml_file + xml_file = Tempfile.new([invoice.number, ".xml"]) + xml_file.write(EInvoices::Invoices::Ubl::CreateService.call(invoice:).xml) + xml_file.flush + + xml_file + end + + def attach_xml_to_invoice(xml_file) + invoice.xml_file.attach( + io: File.open(xml_file.path), + filename: "#{invoice.number}.xml", + content_type: "application/xml" + ) + end + + def cleanup_tempfiles(xml_file) + xml_file&.unlink + end + + def should_generate_xml? + return true if context == "admin" + + invoice.xml_file.blank? && e_invoicing_enabled? + end + + def e_invoicing_enabled? + invoice.billing_entity.einvoicing && BillingEntity::EINVOICING_COUNTRIES.include?(invoice.billing_entity.country.try(:upcase)) + end + end +end diff --git a/app/services/invoices/issuing_date_service.rb b/app/services/invoices/issuing_date_service.rb new file mode 100644 index 0000000..89869a2 --- /dev/null +++ b/app/services/invoices/issuing_date_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Invoices + class IssuingDateService + def initialize(customer_settings:, billing_entity_settings: nil, recurring: false) + @customer_settings = customer_settings + @billing_entity_settings = billing_entity_settings || customer_settings.try(:billing_entity) || {} + @recurring = recurring + end + + def issuing_date_adjustment + return grace_period unless recurring + + send("#{anchor}_#{adjustment}") + end + + def grace_period + customer_settings[:invoice_grace_period] || billing_entity_settings[:invoice_grace_period] || 0 + end + + private + + attr_reader :customer_settings, :billing_entity_settings, :recurring + + def current_period_end_keep_anchor + -1 + end + + def current_period_end_align_with_finalization_date + # Fall back to the current period end date if the grace period is zero + grace_period.zero? ? -1 : grace_period + end + + def next_period_start_keep_anchor + 0 + end + + def next_period_start_align_with_finalization_date + grace_period + end + + def anchor + customer_settings[:subscription_invoice_issuing_date_anchor] || billing_entity_settings[:subscription_invoice_issuing_date_anchor] + end + + def adjustment + customer_settings[:subscription_invoice_issuing_date_adjustment] || billing_entity_settings[:subscription_invoice_issuing_date_adjustment] + end + end +end diff --git a/app/services/invoices/lose_dispute_service.rb b/app/services/invoices/lose_dispute_service.rb new file mode 100644 index 0000000..a96b21c --- /dev/null +++ b/app/services/invoices/lose_dispute_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Invoices + class LoseDisputeService < BaseService + def initialize(invoice:, payment_dispute_lost_at: nil, reason: nil) + @invoice = invoice + @payment_dispute_lost_at = payment_dispute_lost_at.presence || DateTime.current + @reason = reason + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.nil? + + result.invoice = invoice + + invoice.mark_as_dispute_lost!(payment_dispute_lost_at) + + SendWebhookJob.perform_later("invoice.payment_dispute_lost", result.invoice, provider_error: reason) + Invoices::ProviderTaxes::VoidJob.perform_later(invoice:) + Integrations::Aggregator::Invoices::Hubspot::UpdateJob.perform_later(invoice:) if invoice.should_update_hubspot_invoice? + + result + rescue ActiveRecord::RecordInvalid => _e + result.not_allowed_failure!(code: "not_disputable") + end + + private + + attr_reader :invoice, :payment_dispute_lost_at, :reason + end +end diff --git a/app/services/invoices/metadata/update_service.rb b/app/services/invoices/metadata/update_service.rb new file mode 100644 index 0000000..4b722b5 --- /dev/null +++ b/app/services/invoices/metadata/update_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Invoices + module Metadata + class UpdateService < BaseService + def initialize(invoice:, params:) + @invoice = invoice + @params = params + super + end + + def call + created_metadata_ids = [] + + hash_metadata = params.map { |m| m.to_h.deep_symbolize_keys } + hash_metadata.each do |payload_metadata| + metadata = invoice.metadata.find_by(id: payload_metadata[:id]) + + if metadata + metadata.update!(payload_metadata) + + next + end + + created_metadata = create_metadata(payload_metadata) + created_metadata_ids.push(created_metadata.id) + end + + # NOTE: Delete metadata that are no more linked to the invoice + sanitize_metadata(hash_metadata, created_metadata_ids) + + result.invoice = invoice + result + end + + private + + attr_reader :invoice, :params + + def create_metadata(payload) + invoice.metadata.create!( + organization_id: invoice.organization_id, + key: payload[:key], + value: payload[:value] + ) + end + + def sanitize_metadata(args_metadata, created_metadata_ids) + updated_metadata_ids = args_metadata.reject { |m| m[:id].nil? }.map { |m| m[:id] } + not_needed_ids = invoice.metadata.pluck(:id) - updated_metadata_ids - created_metadata_ids + + invoice.metadata.where(id: not_needed_ids).destroy_all + end + end + end +end diff --git a/app/services/invoices/paid_credit_service.rb b/app/services/invoices/paid_credit_service.rb new file mode 100644 index 0000000..a449920 --- /dev/null +++ b/app/services/invoices/paid_credit_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Invoices + class PaidCreditService < BaseService + def initialize(wallet_transaction:, timestamp:, invoice: nil) + @customer = wallet_transaction.wallet.customer + @wallet_transaction = wallet_transaction + @timestamp = timestamp + + # NOTE: In case of retry when the creation process failed, + # and if the generating invoice was persisted, + # the process can be retried without creating a new invoice + @invoice = invoice + + super + end + + def call + create_generating_invoice unless invoice + result.invoice = invoice + + wallet_transaction.update!(invoice: result.invoice) + + ActiveRecord::Base.transaction do + create_credit_fee(invoice) + compute_amounts(invoice) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + if License.premium? && wallet_transaction.invoice_requires_successful_payment? + invoice.open! + else + Invoices::FinalizeService.call!(invoice: invoice) + end + end + + if invoice.finalized? + Utils::SegmentTrack.invoice_created(result.invoice) + SendWebhookJob.perform_later("invoice.paid_credit_added", result.invoice) + Utils::ActivityLog.produce(invoice, "invoice.paid_credit_added") + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + end + + create_payment(result.invoice) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue Sequenced::SequenceError + raise + rescue => e + result.fail_with_error!(e) + end + + private + + attr_accessor :customer, :timestamp, :wallet_transaction, :invoice + + def currency + @currency ||= wallet_transaction.wallet.currency + end + + def create_generating_invoice + invoice_result = Invoices::CreateGeneratingService.call( + customer:, + invoice_type: :credit, + currency:, + datetime: Time.zone.at(timestamp) + ) + invoice_result.raise_if_error! + + @invoice = invoice_result.invoice + end + + def compute_amounts(invoice) + fee_amounts = invoice.fees.select(:amount_cents, :taxes_amount_cents) + + invoice.currency = currency + invoice.fees_amount_cents = fee_amounts.sum(:amount_cents) + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents + invoice.taxes_amount_cents = fee_amounts.sum(:taxes_amount_cents) + invoice.sub_total_including_taxes_amount_cents = ( + invoice.sub_total_excluding_taxes_amount_cents + invoice.taxes_amount_cents + ) + invoice.total_amount_cents = invoice.sub_total_including_taxes_amount_cents + end + + def create_credit_fee(invoice) + fee_result = Fees::PaidCreditService + .new(invoice:, wallet_transaction:, customer:).create + + fee_result.raise_if_error! + end + + def create_payment(invoice) + Invoices::Payments::CreateService.call_async(invoice:) + end + + def should_deliver_email? + License.premium? && + customer.billing_entity.email_settings.include?("invoice.finalized") + end + end +end diff --git a/app/services/invoices/payments/adyen_service.rb b/app/services/invoices/payments/adyen_service.rb new file mode 100644 index 0000000..4c777bb --- /dev/null +++ b/app/services/invoices/payments/adyen_service.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class AdyenService < BaseService + include Lago::Adyen::ErrorHandlable + include Customers::PaymentProviderFinder + + PROVIDER_NAME = "Adyen" + + def initialize(invoice = nil) + @invoice = invoice + + super + end + + def update_payment_status(provider_payment_id:, status:, metadata: {}) + payment = if metadata[:payment_type] == "one-time" + create_payment(provider_payment_id:, metadata:) + else + Payment.find_by(provider_payment_id:) + end + return result.not_found_failure!(resource: "adyen_payment") unless payment + + result.payment = payment + result.invoice = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + deliver_webhook if payable_payment_status.to_sym == :succeeded + + update_invoice_payment_status(payment_status: payable_payment_status) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + def generate_payment_url(payment_intent) + res = client.checkout.payment_links_api.payment_links( + Lago::Adyen::Params.new(payment_url_params(payment_intent)).to_h, + headers: {"Idempotency-Key" => payment_intent.id} + ) + + adyen_success, adyen_error = handle_adyen_response(res) + result.service_failure!(code: adyen_error.code, message: adyen_error.msg) unless adyen_success + + return result unless result.success? + + result.payment_url = res.response["url"] + + result + rescue Adyen::AdyenError => e + result.third_party_failure!(third_party: PROVIDER_NAME, error_code: e.code, error_message: e.msg) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def create_payment(provider_payment_id:, metadata:) + @invoice = Invoice.find(metadata[:lago_invoice_id]) + + increment_payment_attempts + + Payment.new( + organization_id: invoice.organization_id, + payable: invoice, + customer:, + payment_provider_id: adyen_payment_provider.id, + payment_provider_customer_id: customer.adyen_customer.id, + amount_cents: invoice.total_due_amount_cents, + amount_currency: invoice.currency.upcase, + provider_payment_id: + ) + end + + def client + @client ||= Adyen::Client.new( + api_key: adyen_payment_provider.api_key, + env: adyen_payment_provider.environment, + live_url_prefix: adyen_payment_provider.live_prefix + ) + end + + def success_redirect_url + adyen_payment_provider.success_redirect_url.presence || ::PaymentProviders::AdyenProvider::SUCCESS_REDIRECT_URL + end + + def adyen_payment_provider + @adyen_payment_provider ||= payment_provider(customer) + end + + def payment_url_params(payment_intent) + prms = { + reference: invoice.number, + amount: { + value: invoice.total_due_amount_cents, + currency: invoice.currency.upcase + }, + merchantAccount: adyen_payment_provider.merchant_account, + returnUrl: success_redirect_url, + shopperReference: customer.external_id, + storePaymentMethodMode: "enabled", + recurringProcessingModel: "UnscheduledCardOnFile", + expiresAt: payment_intent.expires_at.iso8601, + metadata: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type, + payment_type: "one-time" + } + } + prms[:shopperEmail] = customer.email if customer.email + prms + end + + def update_invoice_payment_status(payment_status:, deliver_webhook: true) + params = { + payment_status:, + ready_for_payment_processing: payment_status.to_sym != :succeeded + } + + if payment_status.to_sym == :succeeded + total_paid_amount_cents = invoice.payments.where(payable_payment_status: :succeeded).sum(:amount_cents) + params[:total_paid_amount_cents] = total_paid_amount_cents + end + + result = Invoices::UpdateService.call( + invoice:, + params:, + webhook_notification: deliver_webhook + ) + result.raise_if_error! + end + + def increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + end + + def deliver_webhook + SendWebhookJob.perform_later("payment.succeeded", result.payment) + end + + def deliver_error_webhook(adyen_error) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: customer.adyen_customer.provider_customer_id, + provider_error: { + message: adyen_error.msg, + error_code: adyen_error.code + } + }) + end + end + end +end diff --git a/app/services/invoices/payments/cashfree_service.rb b/app/services/invoices/payments/cashfree_service.rb new file mode 100644 index 0000000..6f33de2 --- /dev/null +++ b/app/services/invoices/payments/cashfree_service.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class CashfreeService < BaseService + include Customers::PaymentProviderFinder + + PROVIDER_NAME = "Cashfree" + + def initialize(invoice = nil) + @invoice = invoice + + super + end + + def update_payment_status(organization_id:, status:, cashfree_payment:) + payment = if cashfree_payment.metadata[:payment_type] == "one-time" + create_payment(cashfree_payment) + else + Payment.find_by(provider_payment_id: cashfree_payment.id) + end + return result.not_found_failure!(resource: "cashfree_payment") unless payment + + result.payment = payment + result.invoice = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + deliver_webhook if payable_payment_status.to_sym == :succeeded + + update_invoice_payment_status(payment_status: payable_payment_status) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + def generate_payment_url(payment_intent) + payment_link_response = create_payment_link(payment_url_params(payment_intent)) + result.payment_url = JSON.parse(payment_link_response.body)["link_url"] + + result + rescue LagoHttpClient::HttpError => e + result.third_party_failure!(third_party: PROVIDER_NAME, error_code: e.error_code, error_message: e.error_body) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def create_payment(cashfree_payment) + @invoice = Invoice.find_by(id: cashfree_payment.metadata[:lago_invoice_id]) + + increment_payment_attempts + + Payment.new( + organization_id: @invoice.organization_id, + payable: @invoice, + customer:, + payment_provider_id: cashfree_payment_provider.id, + payment_provider_customer_id: customer.cashfree_customer.id, + amount_cents: @invoice.total_due_amount_cents, + amount_currency: @invoice.currency, + provider_payment_id: cashfree_payment.id + ) + end + + def increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + end + + def client + @client ||= LagoHttpClient::Client.new(::PaymentProviders::CashfreeProvider::BASE_URL) + end + + def create_payment_link(body) + client.post_with_response(body, { + "accept" => "application/json", + "content-type" => "application/json", + "x-client-id" => cashfree_payment_provider.client_id, + "x-client-secret" => cashfree_payment_provider.client_secret, + "x-api-version" => ::PaymentProviders::CashfreeProvider::API_VERSION + }) + end + + def success_redirect_url + cashfree_payment_provider.success_redirect_url.presence || ::PaymentProviders::CashfreeProvider::SUCCESS_REDIRECT_URL + end + + def cashfree_payment_provider + @cashfree_payment_provider ||= payment_provider(customer) + end + + def payment_url_params(payment_intent) + { + customer_details: { + customer_phone: customer.phone || "9999999999", + customer_email: customer.email, + customer_name: customer.name + }, + link_notify: { + send_sms: false, + send_email: false + }, + link_meta: { + upi_intent: true, + return_url: success_redirect_url + }, + link_notes: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + payment_type: "one-time" + }, + link_id: "#{SecureRandom.uuid}.#{invoice.payment_attempts}", + link_amount: invoice.total_due_amount_cents / 100.to_f, + link_currency: invoice.currency.upcase, + link_purpose: invoice.id, + link_expiry_time: payment_intent.expires_at.iso8601, + link_partial_payments: false, + link_auto_reminders: false + } + end + + def update_invoice_payment_status(payment_status:, deliver_webhook: true) + @invoice = result.invoice + + params = { + payment_status:, + ready_for_payment_processing: payment_status.to_sym != :succeeded + } + + if payment_status.to_sym == :succeeded + total_paid_amount_cents = invoice.payments.where(payable_payment_status: :succeeded).sum(:amount_cents) + params[:total_paid_amount_cents] = total_paid_amount_cents + end + + result = Invoices::UpdateService.call( + invoice:, + params:, + webhook_notification: deliver_webhook + ) + result.raise_if_error! + end + + def deliver_webhook + SendWebhookJob.perform_later("payment.succeeded", result.payment) + end + + def deliver_error_webhook(cashfree_error) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: customer.cashfree_customer.id, + provider_error: { + message: cashfree_error.error_body, + error_code: cashfree_error.error_code + } + }) + end + end + end +end diff --git a/app/services/invoices/payments/connection_error.rb b/app/services/invoices/payments/connection_error.rb new file mode 100644 index 0000000..b18b60f --- /dev/null +++ b/app/services/invoices/payments/connection_error.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class ConnectionError < StandardError + def initialize(initial_error) + @initial_error = initial_error + super(initial_error.message) + end + + attr_reader :initial_error + end + end +end diff --git a/app/services/invoices/payments/create_service.rb b/app/services/invoices/payments/create_service.rb new file mode 100644 index 0000000..c6e5dd8 --- /dev/null +++ b/app/services/invoices/payments/create_service.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class CreateService < BaseService + include Customers::PaymentProviderFinder + + def initialize(invoice:, payment_provider: nil, payment_method_params: {}) + @invoice = invoice + @provider = payment_provider&.to_sym + @payment_method_params = payment_method_params + + super + end + + def call + result.invoice = invoice + return result unless should_process_payment? + + unless invoice.total_amount_cents.positive? + update_invoice_payment_status(payment_status: :succeeded) + return result + end + + if processing_payment + # Payment is being processed, return the existing payment + # Status will be updated via webhooks + result.payment = processing_payment + return result + end + + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + + payment ||= Payment.create_with( + organization_id: invoice.organization_id, + payment_provider_id: current_payment_provider.id, + payment_provider_customer_id: current_payment_provider_customer.id, + amount_cents: invoice.total_due_amount_cents, + amount_currency: invoice.currency, + status: "pending", + customer_id: invoice.customer_id + ).find_or_create_by!( + payable: invoice, + payable_payment_status: "pending" + ) + + if multiple_payment_methods_enabled? + payment.payment_method_id = determine_payment_method&.id + payment.save! + end + + result.payment = payment + + payment_result = ::PaymentProviders::CreatePaymentFactory.new_instance( + provider:, + payment:, + reference: payment_reference, + metadata: { + lago_invoice_id: invoice.id, + lago_customer_id: invoice.customer_id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + ).call! + + payment_status = payment_result.payment.payable_payment_status + update_invoice_payment_status(payment_status:) + + Integrations::Aggregator::Payments::CreateJob.perform_later(payment:) if result.payment.should_sync_payment? + + result + rescue BaseService::ServiceFailure => e + result.payment = e.result.payment + + deliver_error_webhook(e) unless skip_error_webhook?(e) + + update_invoice_payment_status(payment_status: e.result.payment.payable_payment_status) + + raise RetriableError if e.result.should_retry + + # Some errors should be investigated and need to be raised + raise if e.result.reraise + + result + end + + def call_async + return result unless provider + + Invoices::Payments::CreateJob.perform_after_commit(invoice:, payment_provider: provider, payment_method_params:) + + result.payment_provider = provider + result + end + + private + + attr_reader :invoice, :payment, :payment_method_params + + delegate :customer, to: :invoice + + def provider + @provider ||= invoice.customer.payment_provider&.to_sym + end + + def multiple_payment_methods_enabled? + customer.organization.feature_flag_enabled?(:multiple_payment_methods) + end + + def should_process_payment? + return false if invoice.self_billed? + return false if invoice.payment_succeeded? || invoice.voided? + return false if current_payment_provider.blank? + + if multiple_payment_methods_enabled? + current_payment_provider_customer&.provider_customer_id && determine_payment_method.present? + else + current_payment_provider_customer&.provider_customer_id + end + end + + def current_payment_provider + @current_payment_provider ||= payment_provider(customer) + end + + def current_payment_provider_customer + @current_payment_provider_customer ||= customer.payment_provider_customers + .find_by(payment_provider_id: current_payment_provider.id) + end + + def update_invoice_payment_status(payment_status:) + params = { + # NOTE: A proper `processing` payment status should be introduced for invoices + payment_status: (payment_status.to_s == "processing") ? :pending : payment_status, + ready_for_payment_processing: %w[pending failed].include?(payment_status.to_s) + } + + if payment_status.to_s == "succeeded" + total_paid_amount_cents = invoice.payments.where(payable_payment_status: :succeeded).sum(:amount_cents) + params[:total_paid_amount_cents] = total_paid_amount_cents + end + + Invoices::UpdateService.call!( + invoice:, + params:, + webhook_notification: payment_status.to_sym == :succeeded + ) + end + + def skip_error_webhook?(e) + return true if e.result.payment.payable_payment_status&.to_sym == :pending + + [ + ::PaymentProviders::StripeProvider::AMOUNT_TOO_SMALL_ERROR_CODE, + ::PaymentProviders::StripeProvider::NEED_3DS_ERROR_CODE + ].include?(e.result.error_code) + end + + def deliver_error_webhook(e) + payment_result = e.result + + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: current_payment_provider_customer.provider_customer_id, + provider_error: { + message: payment_result.error_message, + error_code: payment_result.error_code + }, + error_details: e.original_error ? V1::Errors::ErrorSerializerFactory.new_instance(e.original_error).serialize : {} + }) + end + + def payment_reference + if invoice.subscription_gated? + "#{invoice.billing_entity.name} - Invoice #{invoice.id}" + else + "#{invoice.billing_entity.name} - Invoice #{invoice.number}" + end + end + + def processing_payment + @processing_payment ||= Payment.find_by( + payable: invoice, + payment_provider_id: current_payment_provider.id, + payment_provider_customer_id: current_payment_provider_customer.id, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency, + payable_payment_status: "processing" + ) + end + + # NOTE: Returns PaymentMethod object or nil + # nil means: skip automatic payment (manual type or no payment method configured) + # payment_method_params takes precedence (used for retry with override) + def determine_payment_method + @determine_payment_method ||= PaymentMethods::DetermineService.call( + invoice:, customer:, payment_method_params: + ).payment_method + end + end + end +end diff --git a/app/services/invoices/payments/deliver_error_webhook_service.rb b/app/services/invoices/payments/deliver_error_webhook_service.rb new file mode 100644 index 0000000..aa926df --- /dev/null +++ b/app/services/invoices/payments/deliver_error_webhook_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class DeliverErrorWebhookService < BaseService + def initialize(invoice, params) + @invoice = invoice + @params = params + end + + def call_async + if invoice.credit? && (invoice.open? || invoice.visible?) + wallet_transaction = invoice.fees.credit.first.invoiceable + SendWebhookJob.perform_later("wallet_transaction.payment_failure", wallet_transaction, params) + Utils::ActivityLog.produce(wallet_transaction, "wallet_transaction.payment_failure") + end + + if invoice.visible? + Utils::ActivityLog.produce(invoice, "invoice.payment_failure") + SendWebhookJob.perform_later("invoice.payment_failure", invoice, params) + end + + result + end + + private + + attr_reader :invoice, :params + end + end +end diff --git a/app/services/invoices/payments/flutterwave_service.rb b/app/services/invoices/payments/flutterwave_service.rb new file mode 100644 index 0000000..b9d94ec --- /dev/null +++ b/app/services/invoices/payments/flutterwave_service.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class FlutterwaveService < BaseService + include Customers::PaymentProviderFinder + + PROVIDER_NAME = "Flutterwave" + + def initialize(invoice = nil) + @invoice = invoice + + super + end + + def update_payment_status(organization_id:, status:, flutterwave_payment:) + payment = if flutterwave_payment.metadata[:payment_type] == "one-time" + create_payment(flutterwave_payment) + else + Payment.find_by(provider_payment_id: flutterwave_payment.id) + end + return result.not_found_failure!(resource: "flutterwave_payment") unless payment + + result.payment = payment + result.invoice = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + deliver_webhook if payable_payment_status.to_sym == :succeeded + + update_invoice_payment_status(payment_status: payable_payment_status) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + def generate_payment_url(payment_intent) + result.payment_url = payment_url + result + rescue LagoHttpClient::HttpError => e + result.third_party_failure!(third_party: PROVIDER_NAME, error_code: e.error_code, error_message: e.error_body) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def payment_url + response = create_checkout_session + parsed_response = JSON.parse(response.body) + + parsed_response["data"]["link"] + end + + def create_checkout_session + body = { + amount: Money.from_cents(invoice.total_amount_cents, invoice.currency).to_f, + tx_ref: invoice.id, + currency: invoice.currency.upcase, + redirect_url: success_redirect_url, + customer: customer_params, + customizations: customizations_params, + configuration: configuration_params, + meta: meta_params + } + http_client.post_with_response(body, headers) + end + + def customer_params + { + email: customer.email, + phone_number: customer.phone || "", + name: customer.name || customer.email + } + end + + def customizations_params + { + title: "#{organization.name} - Invoice Payment", + description: "Payment for Invoice ##{invoice.number}", + logo: organization.logo_url + }.compact + end + + def configuration_params + { + session_duration: 30 + } + end + + def meta_params + { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + lago_organization_id: organization.id, + lago_invoice_number: invoice.number, + payment_type: "one-time" + } + end + + def success_redirect_url + flutterwave_payment_provider.success_redirect_url.presence || + ::PaymentProviders::FlutterwaveProvider::SUCCESS_REDIRECT_URL + end + + def flutterwave_payment_provider + @flutterwave_payment_provider ||= payment_provider(customer) + end + + def headers + { + "Authorization" => "Bearer #{flutterwave_payment_provider.secret_key}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def http_client + @http_client ||= LagoHttpClient::Client.new("#{flutterwave_payment_provider.api_url}/payments") + end + + def create_payment(flutterwave_payment) + @invoice = Invoice.find_by(id: flutterwave_payment.metadata[:lago_invoice_id]) + + increment_payment_attempts + + Payment.new( + organization_id: @invoice.organization_id, + payable: @invoice, + customer:, + payment_provider_id: flutterwave_payment_provider.id, + payment_provider_customer_id: customer.flutterwave_customer.id, + amount_cents: @invoice.total_due_amount_cents, + amount_currency: @invoice.currency, + provider_payment_id: flutterwave_payment.id + ) + end + + def increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + end + + def update_invoice_payment_status(payment_status:, deliver_webhook: true) + @invoice = result.invoice + + params = { + payment_status:, + ready_for_payment_processing: payment_status.to_sym != :succeeded + } + + if payment_status.to_sym == :succeeded + total_paid_amount_cents = invoice.payments.where(payable_payment_status: :succeeded).sum(:amount_cents) + params[:total_paid_amount_cents] = total_paid_amount_cents + end + + result = Invoices::UpdateService.call( + invoice:, + params:, + webhook_notification: deliver_webhook + ) + result.raise_if_error! + end + + def deliver_webhook + SendWebhookJob.perform_later("payment.succeeded", result.payment) + end + + def deliver_error_webhook(flutterwave_error) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: customer.flutterwave_customer&.provider_customer_id, + provider_error: { + message: flutterwave_error.error_body, + error_code: flutterwave_error.error_code + } + }) + end + end + end +end diff --git a/app/services/invoices/payments/generate_payment_url_service.rb b/app/services/invoices/payments/generate_payment_url_service.rb new file mode 100644 index 0000000..ae38cf7 --- /dev/null +++ b/app/services/invoices/payments/generate_payment_url_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class GeneratePaymentUrlService < BaseService + Result = BaseResult[:payment_url] + + include Customers::PaymentProviderFinder + + def initialize(invoice:) + @invoice = invoice + @provider = invoice&.customer&.payment_provider&.to_s + + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.blank? + return result.single_validation_failure!(error_code: "no_linked_payment_provider") unless provider + return result.single_validation_failure!(error_code: "invalid_payment_provider") if provider == "gocardless" + + if invoice.payment_succeeded? || invoice.voided? || invoice.draft? + return result.single_validation_failure!(error_code: "invalid_invoice_status_or_payment_status") + end + + if current_payment_provider.blank? + return result.single_validation_failure!(error_code: "missing_payment_provider") + end + + if !current_payment_provider_customer || + current_payment_provider_customer.provider_customer_id.blank? && current_payment_provider_customer&.require_provider_payment_id? + return result.single_validation_failure!(error_code: "missing_payment_provider_customer") + end + + payment_intent = PaymentIntents::FetchService.call!(invoice:).payment_intent + + result.payment_url = payment_intent.payment_url + result + rescue BaseService::ThirdPartyFailure => e + deliver_error_webhook(e) + + e.result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :invoice, :provider + + delegate :customer, to: :invoice + + def current_payment_provider_customer + @current_payment_provider_customer ||= customer.payment_provider_customers + .find_by(payment_provider_id: current_payment_provider.id) + end + + def current_payment_provider + @current_payment_provider ||= payment_provider(customer) + end + + def deliver_error_webhook(payment_url_failure) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: current_payment_provider_customer.provider_customer_id, + provider_error: { + message: payment_url_failure.error_message, + error_code: payment_url_failure.error_code + } + }) + end + end + end +end diff --git a/app/services/invoices/payments/gocardless_service.rb b/app/services/invoices/payments/gocardless_service.rb new file mode 100644 index 0000000..c39b626 --- /dev/null +++ b/app/services/invoices/payments/gocardless_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class GocardlessService < BaseService + include Customers::PaymentProviderFinder + + def initialize(invoice = nil) + @invoice = invoice + + super + end + + def update_payment_status(provider_payment_id:, status:) + payment = Payment.find_by(provider_payment_id:) + return result.not_found_failure!(resource: "gocardless_payment") unless payment + + result.payment = payment + result.invoice = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + deliver_webhook if payable_payment_status.to_sym == :succeeded + + update_invoice_payment_status(payment_status: payable_payment_status) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def update_invoice_payment_status(payment_status:, deliver_webhook: true) + params = { + payment_status:, + ready_for_payment_processing: payment_status.to_sym != :succeeded + } + + if payment_status.to_sym == :succeeded + total_paid_amount_cents = result.invoice.payments.where(payable_payment_status: :succeeded).sum(:amount_cents) + params[:total_paid_amount_cents] = total_paid_amount_cents + end + + update_invoice_result = Invoices::UpdateService.call( + invoice: result.invoice, + params:, + webhook_notification: deliver_webhook + ) + update_invoice_result.raise_if_error! + end + + def deliver_webhook + SendWebhookJob.perform_later("payment.succeeded", result.payment) + end + end + end +end diff --git a/app/services/invoices/payments/mark_overdue_service.rb b/app/services/invoices/payments/mark_overdue_service.rb new file mode 100644 index 0000000..2399679 --- /dev/null +++ b/app/services/invoices/payments/mark_overdue_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class MarkOverdueService < BaseService + def initialize(invoice:) + @invoice = invoice + + super + end + + activity_loggable( + action: "invoice.payment_overdue", + record: -> { invoice } + ) + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result.not_allowed_failure!(code: "invoice_not_finalized") unless invoice.finalized? + return result.not_allowed_failure!(code: "invoice_payment_already_succeeded") if invoice.payment_succeeded? + return result.not_allowed_failure!(code: "invoice_due_date_in_future") if invoice.payment_due_date > Time.current + return result.not_allowed_failure!(code: "invoice_dispute_lost") if invoice.payment_dispute_lost_at + + invoice.update!(payment_overdue: true) + + result.invoice = invoice + + SendWebhookJob.perform_later("invoice.payment_overdue", invoice) + result + end + + private + + attr_reader :invoice + end + end +end diff --git a/app/services/invoices/payments/moneyhash_service.rb b/app/services/invoices/payments/moneyhash_service.rb new file mode 100644 index 0000000..7e0c331 --- /dev/null +++ b/app/services/invoices/payments/moneyhash_service.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class MoneyhashService < BaseService + include Customers::PaymentProviderFinder + + def initialize(invoice = nil) + @invoice = invoice + + super(nil) + end + + def update_payment_status(organization_id:, provider_payment_id:, status:, metadata: {}) + payment_obj = Payment.find_or_initialize_by(provider_payment_id: provider_payment_id) + payment = if payment_obj.persisted? + payment_obj + else + create_payment(provider_payment_id:, metadata:) + end + + return handle_missing_payment(organization_id, metadata) unless payment + + result.payment = payment + result.invoice = payment.payable + return result if payment.payable.payment_succeeded? + + payment_status = payment.payment_provider.determine_payment_status(status) + payable_payment_status = payment.payment_provider.payable_payment_status(status) + + payment.update!(status: payment_status, payable_payment_status:) + + deliver_webhook if payable_payment_status.to_sym == :succeeded + + update_invoice_payment_status(payment_status: payable_payment_status, processing: payment_status == :processing) + + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + def generate_payment_url(payment_intent) + return result unless should_process_payment? + + response = client.post_with_response( + payment_url_params(payment_intent), + headers.merge!("X-Idempotency-Key" => payment_intent.id) + ) + moneyhash_result = JSON.parse(response.body) + + return result unless moneyhash_result + + moneyhash_result_data = moneyhash_result["data"] + result.payment_url = "#{moneyhash_result_data["embed_url"]}?lago_request=generate_payment_url" + result + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + result.service_failure!(code: e.error_code, message: e.message) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def handle_missing_payment(organization_id, metadata) + return result unless metadata&.key?("lago_payable_id") + + invoice = Invoice.find_by(id: metadata["lago_payable_id"], organization_id:) + return result if invoice.nil? + return result if invoice.payment_failed? + + result.not_found_failure!(resource: "moneyhash_payment") + end + + def update_invoice_payment_status(payment_status:, deliver_webhook: true, processing: false) + result = Invoices::UpdateService.call( + invoice: invoice.presence || @result.invoice, + params: { + payment_status:, + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ) + result.raise_if_error! + end + + def deliver_webhook + SendWebhookJob.perform_later("payment.succeeded", result.payment) + end + + def increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + end + + def create_payment(provider_payment_id:, metadata:) + @invoice ||= Invoice.find_by(id: metadata["lago_payable_id"]) + unless @invoice + result.not_found_failure!(resource: "invoice") + return + end + increment_payment_attempts + Payment.new( + organization_id: @invoice.organization_id, + payable: invoice, + customer:, + payment_provider_id: moneyhash_payment_provider.id, + payment_provider_customer_id: customer.moneyhash_customer.id, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency&.upcase, + provider_payment_id: + ) + end + + def should_process_payment? + return false if invoice.payment_succeeded? || invoice.voided? + return false if moneyhash_payment_provider.blank? + + customer&.moneyhash_customer&.provider_customer_id + end + + def client + @client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def headers + { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(customer) + end + + def payment_url_params(payment_intent) + params = { + amount: invoice.total_due_amount_cents.div(100).to_f, + amount_currency: invoice.currency.upcase, + flow_id: moneyhash_payment_provider.flow_id, + billing_data: invoice.customer.moneyhash_customer.mh_billing_data, + customer: invoice.customer.moneyhash_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + merchant_initiated: false, + expires_after_seconds: (payment_intent.expires_at - Time.current).to_i, + custom_fields: { + # payable + lago_payable_id: invoice.id, + lago_payable_type: invoice.class.name, + lago_payable_invoice_type: invoice.invoice_type, + # mit flag + lago_mit: false, + # service + lago_mh_service: "Invoices::Payments::MoneyhashService", + # request + lago_request: "generate_payment_url" + } + } + + params[:custom_fields].merge!(customer.moneyhash_customer.mh_custom_fields) + + # Include subscription data for subscription invoices + if invoice.invoice_type == "subscription" + params[:custom_fields].merge!( + lago_plan_id: invoice.subscriptions&.first&.plan_id.to_s, + lago_subscription_external_id: invoice.subscriptions&.first&.external_id.to_s + ) + end + + # Tokenize card if the customer doesn't have a saved one + if customer.moneyhash_customer.provider_customer_id.blank? + params.merge!( + tokenize_card: true, + payment_type: "UNSCHEDULED", + recurring_data: { + agreement_id: customer.id + } + ) + end + + params + end + + def deliver_error_webhook(moneyhash_error) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: customer.moneyhash_customer.provider_customer_id, + provider_error: { + message: moneyhash_error.message, + error_code: moneyhash_error.error_code + } + }) + end + end + end +end diff --git a/app/services/invoices/payments/payment_providers/factory.rb b/app/services/invoices/payments/payment_providers/factory.rb new file mode 100644 index 0000000..1883e05 --- /dev/null +++ b/app/services/invoices/payments/payment_providers/factory.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Invoices + module Payments + module PaymentProviders + class Factory + def self.new_instance(invoice:) + service_class(invoice.customer&.payment_provider).new(invoice) + end + + def self.service_class(payment_provider) + case payment_provider&.to_s + when "stripe" + Invoices::Payments::StripeService + when "adyen" + Invoices::Payments::AdyenService + when "gocardless" + Invoices::Payments::GocardlessService + when "cashfree" + Invoices::Payments::CashfreeService + when "flutterwave" + Invoices::Payments::FlutterwaveService + when "moneyhash" + Invoices::Payments::MoneyhashService + else + raise(NotImplementedError) + end + end + end + end + end +end diff --git a/app/services/invoices/payments/rate_limit_error.rb b/app/services/invoices/payments/rate_limit_error.rb new file mode 100644 index 0000000..5c73e6e --- /dev/null +++ b/app/services/invoices/payments/rate_limit_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class RateLimitError < StandardError + def initialize(initial_error) + @initial_error = initial_error + + super(initial_error.message) + end + + attr_reader :initial_error + end + end +end diff --git a/app/services/invoices/payments/retry_batch_service.rb b/app/services/invoices/payments/retry_batch_service.rb new file mode 100644 index 0000000..a9eb1b8 --- /dev/null +++ b/app/services/invoices/payments/retry_batch_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class RetryBatchService < BaseService + def initialize(organization_id:) + @organization_id = organization_id + + super + end + + def call_async + Invoices::Payments::RetryAllJob.perform_later(organization_id:, invoice_ids: invoices.ids) + + result.invoices = invoices + + result + end + + def call(invoice_ids) + processed_invoices = [] + Invoice.where(id: invoice_ids).find_each do |invoice| + result = Invoices::Payments::RetryService.new(invoice:).call + + return result unless result.success? + + processed_invoices << result.invoice + end + + result.invoices = processed_invoices.compact + + result + end + + private + + attr_reader :organization_id + + def invoices + return @invoices if defined? @invoices + + @invoices = begin + invoices = organization.invoices.where(payment_status: %w[pending failed]) + invoices = invoices.where(ready_for_payment_processing: true) + invoices = invoices.where(status: "finalized") + + invoices + end + end + + def organization + @organization ||= Organization.find_by!(id: organization_id) + end + end + end +end diff --git a/app/services/invoices/payments/retry_service.rb b/app/services/invoices/payments/retry_service.rb new file mode 100644 index 0000000..c86fbf2 --- /dev/null +++ b/app/services/invoices/payments/retry_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class RetryService < BaseService + def initialize(invoice:, payment_method_params: {}) + @invoice = invoice + @payment_method_params = payment_method_params + + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.blank? + + if invoice.draft? || invoice.voided? || invoice.payment_succeeded? + return result.not_allowed_failure!(code: "invalid_status") + end + + unless invoice.ready_for_payment_processing? + return result.not_allowed_failure!(code: "payment_processor_is_currently_handling_payment") + end + + create_result = Invoices::Payments::CreateService.call_async(invoice:, payment_method_params:) + deliver_webhook if create_result.payment_provider.nil? + + result.invoice = invoice + + result + end + + private + + attr_reader :invoice, :payment_method_params + + delegate :customer, to: :invoice + + def deliver_webhook + SendWebhookJob.perform_later( + "invoice.payment_failure", invoice, error_details: {code: "customer_must_have_payment_provider"} + ) + end + end + end +end diff --git a/app/services/invoices/payments/stripe_service.rb b/app/services/invoices/payments/stripe_service.rb new file mode 100644 index 0000000..8073284 --- /dev/null +++ b/app/services/invoices/payments/stripe_service.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +module Invoices + module Payments + class StripeService < BaseService + include Customers::PaymentProviderFinder + + PROVIDER_NAME = "Stripe" + + def initialize(invoice = nil) + @invoice = invoice + + super + end + + def update_payment_status(organization_id:, status:, stripe_payment:) + payment = Payment.find_by(provider_payment_id: stripe_payment.id) + return result if payment&.payable&.organization_id.present? && payment.payable.organization_id != organization_id + + if !payment && stripe_payment.metadata[:payment_type] == "one-time" + payment = create_payment(stripe_payment) + end + + unless payment + handle_missing_payment(organization_id, stripe_payment) + return result unless result.payment + + payment = result.payment + end + + result.payment = payment + result.invoice = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.error_code = stripe_payment.error_code if stripe_payment.error_code + payment.save! + + deliver_webhook if payable_payment_status.to_sym == :succeeded + + if status.to_s == "failed" && result.invoice.payments.excluding(result.payment).where(status: :requires_action).any? + # We don't update the invoice status because it's likely the webhook of a failed payment + # but there is already a retry in progress with 3DSecure authentication + else + update_invoice_payment_status( + payment_status: payable_payment_status, + processing: status == "processing" + ) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + def generate_payment_url(payment_intent) + res = ::Stripe::Checkout::Session.create( + payment_url_payload(payment_intent), + { + api_key: stripe_api_key, + idempotency_key: "payment-intent-#{payment_intent.id}" + } + ) + + result.payment_url = res["url"] + + result + rescue ::Stripe::CardError, ::Stripe::InvalidRequestError, ::Stripe::AuthenticationError, Stripe::PermissionError => e + result.third_party_failure!(third_party: PROVIDER_NAME, error_code: e.code, error_message: e.message) + end + + private + + attr_accessor :invoice + + delegate :organization, :customer, to: :invoice + + def create_payment(stripe_payment, invoice: nil) + @invoice = invoice || Invoice.find_by(id: stripe_payment.metadata[:lago_invoice_id]) + unless @invoice + result.not_found_failure!(resource: "invoice") + return + end + + increment_payment_attempts + + payment = Payment.find_or_initialize_by( + organization: @invoice.organization, + payable: @invoice, + customer:, + payment_provider_id: stripe_payment_provider.id, + payment_provider_customer_id: customer.stripe_customer.id, + amount_cents: @invoice.total_due_amount_cents, + amount_currency: @invoice.currency, + status: "pending" + ) + + status = payment.payment_provider&.determine_payment_status(stripe_payment.status) + status = (status.to_sym == :pending) ? :processing : status + + payment.provider_payment_id = stripe_payment.id + payment.status = stripe_payment.status + payment.payable_payment_status = status + payment.save! + payment + end + + def success_redirect_url + stripe_payment_provider.success_redirect_url.presence || + ::PaymentProviders::StripeProvider::SUCCESS_REDIRECT_URL + end + + def stripe_api_key + stripe_payment_provider.secret_key + end + + def payment_url_payload(payment_intent) + { + line_items: [ + { + quantity: 1, + price_data: { + currency: invoice.currency.downcase, + unit_amount: invoice.total_due_amount_cents, + product_data: { + name: invoice.number + } + } + } + ], + mode: "payment", + success_url: success_redirect_url, + customer: customer.stripe_customer.provider_customer_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + expires_at: payment_intent.expires_at.to_i, + payment_intent_data: { + description:, + setup_future_usage: setup_future_usage? ? "off_session" : nil, + metadata: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type, + payment_type: "one-time" + } + } + } + end + + def description + "#{organization.name} - Invoice #{invoice.number}" + end + + def update_invoice_payment_status(payment_status:, deliver_webhook: true, processing: false) + params = { + payment_status:, + # NOTE: A proper `processing` payment status should be introduced for invoices + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + } + + if payment_status.to_sym == :succeeded + total_paid_amount_cents = (invoice.presence || @result.invoice).payments.where(payable_payment_status: :succeeded).sum(:amount_cents) + params[:total_paid_amount_cents] = total_paid_amount_cents + end + + result = Invoices::UpdateService.call( + invoice: invoice.presence || @result.invoice, + params:, + webhook_notification: deliver_webhook + ) + result.raise_if_error! + end + + def increment_payment_attempts + invoice.update!(payment_attempts: invoice.payment_attempts + 1) + end + + def deliver_webhook + SendWebhookJob.perform_later("payment.succeeded", result.payment) + end + + def deliver_error_webhook(stripe_error) + DeliverErrorWebhookService.call_async(invoice, { + provider_customer_id: customer.stripe_customer.provider_customer_id, + provider_error: { + message: stripe_error.message, + error_code: stripe_error.code + } + }) + end + + def handle_missing_payment(organization_id, stripe_payment) + # NOTE: Payment was not initiated by lago + return result unless stripe_payment.metadata&.key?(:lago_invoice_id) + + # NOTE: Invoice does not belong to this lago organization + # It means the same Stripe secret key is used for multiple organizations + invoice = Invoice.find_by(id: stripe_payment.metadata[:lago_invoice_id], organization_id:) + return result if invoice.nil? + + # NOTE: Invoice exists but payment status is failed + return result if invoice.payment_failed? + + # NOTE: For some reason payment is missing in the database... (killed sidekiq job, etc.) + # We have to recreate it from the received data + result.payment = create_payment(stripe_payment, invoice:) + result + end + + # NOTE: Due to RBI limitation, all indians payment should be "on session". See: https://docs.stripe.com/india-recurring-payments + # crypto payments don't support 'off_session' + def setup_future_usage? + return false if customer.country == "IN" + return false if customer.stripe_customer.provider_payment_methods.include?("crypto") + + true + end + + def stripe_payment_provider + @stripe_payment_provider ||= payment_provider(customer) + end + end + end +end diff --git a/app/services/invoices/preview/build_subscription_service.rb b/app/services/invoices/preview/build_subscription_service.rb new file mode 100644 index 0000000..a2fa508 --- /dev/null +++ b/app/services/invoices/preview/build_subscription_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Invoices + module Preview + class BuildSubscriptionService < BaseService + Result = BaseResult[:subscriptions] + + def initialize(customer:, params:) + @customer = customer + @params = params.presence || {} + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + return result.not_found_failure!(resource: "plan") unless plan + + result.subscriptions = [build_subscription] + result + end + + private + + attr_reader :customer, :params + + delegate :organization, to: :customer + + def build_subscription + Subscription.new( + organization_id: organization.id, + customer:, + plan:, + subscription_at: params[:subscription_at].presence || Time.current, + started_at: params[:subscription_at].presence || Time.current, + billing_time:, + created_at: params[:subscription_at].presence || Time.current, + updated_at: Time.current + ) + end + + def billing_time + if Subscription::BILLING_TIME.include?(params[:billing_time]&.to_sym) + params[:billing_time] + else + "calendar" + end + end + + def plan + @plan ||= organization.plans.parents.find_by(code: params[:plan_code]) + end + end + end +end diff --git a/app/services/invoices/preview/credits_service.rb b/app/services/invoices/preview/credits_service.rb new file mode 100644 index 0000000..dd6fa5a --- /dev/null +++ b/app/services/invoices/preview/credits_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Invoices + module Preview + class CreditsService < BaseService + Result = BaseResult[:credits] + + def initialize(invoice:, terminated_subscription: nil) + @invoice = invoice + @terminated_subscription = terminated_subscription + super + end + + def call + result.credits = credits_from_terminated_subscription + persisted_credits + result + end + + private + + attr_accessor :invoice, :terminated_subscription + + def persisted_credits + Credits::CreditNoteService + .call!(invoice:, context: :preview) + .credits + end + + def credits_from_terminated_subscription + return [] unless terminated_subscription&.plan&.pay_in_advance? + + credit_note = generate_credit_note + return [] unless credit_note + + credit_amount = [credit_note.balance_amount_cents, invoice.total_amount_cents].min + return [] unless credit_amount.positive? + + credit = Credit.new( + invoice:, + organization_id: invoice.organization_id, + credit_note:, + amount_cents: credit_amount, + amount_currency: invoice.currency, + before_taxes: false + ) + + invoice.credit_notes_amount_cents += credit.amount_cents + [credit] + end + + def generate_credit_note + return unless terminated_subscription.plan.pay_in_advance? + + CreditNotes::CreateFromTermination.call!( + subscription: terminated_subscription, + reason: "order_cancellation", + context: :preview + ).credit_note + end + end + end +end diff --git a/app/services/invoices/preview/find_subscriptions_service.rb b/app/services/invoices/preview/find_subscriptions_service.rb new file mode 100644 index 0000000..8e1555c --- /dev/null +++ b/app/services/invoices/preview/find_subscriptions_service.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Invoices + module Preview + class FindSubscriptionsService < BaseService + Result = BaseResult[:subscriptions] + + def initialize(subscriptions:) + @subscriptions = subscriptions + super + end + + def call + return result.not_found_failure!(resource: "subscription") if subscriptions.empty? + + result.subscriptions = subscriptions.flat_map do |subscription| + if subscription.downgraded? + sub = adjusted_subscription(subscription) + + [ + sub, + (sub.next_subscription if sub.next_subscription.plan.pay_in_advance?) + ].compact + elsif subscriptions.size == 1 && subscription.pending? + # We need to activate subscription at future billing time. We are also making + # duplicate of subscription in order to re-use calculation for non-existing + # future subscription, since we already have this flow covered and invoice + # calculation is the same + duplicate = subscription.dup + + duplicate.assign_attributes( + status: :active, + started_at: subscription.subscription_at, + created_at: subscription.subscription_at + ) + + duplicate + else + subscription + end + end + + result + end + + private + + attr_reader :subscriptions + + def adjusted_subscription(subscription) + subscription.terminated_at = rotation_date(subscription) + subscription.status = :terminated + + subscription.next_subscription.assign_attributes( + status: :active, + started_at: rotation_date(subscription) + ) + + subscription + end + + def rotation_date(subscription) + @rotation_date ||= Subscriptions::DatesService + .new_instance(subscription, Time.current, current_usage: true) + .end_of_period + 1.day + end + end + end +end diff --git a/app/services/invoices/preview/subscription_plan_change_service.rb b/app/services/invoices/preview/subscription_plan_change_service.rb new file mode 100644 index 0000000..db1fa92 --- /dev/null +++ b/app/services/invoices/preview/subscription_plan_change_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module Invoices + module Preview + class SubscriptionPlanChangeService < BaseService + Result = BaseResult[:subscriptions] + + def initialize(current_subscription:, target_plan_code:) + @current_subscription = current_subscription + @target_plan_code = target_plan_code + super + end + + def call + return result.not_found_failure!(resource: "subscription") unless current_subscription + return result.not_found_failure!(resource: "plan") unless target_plan + + if target_plan.id == current_subscription.plan_id + return result.single_validation_failure!( + error_code: "new_plan_should_be_different_from_existing_plan" + ) + end + + result.subscriptions = [ + terminated_current_subscription, + (new_subscription if target_plan.pay_in_advance?) + ].compact + + result + end + + private + + attr_reader :current_subscription, :target_plan_code + + delegate :organization, :customer, to: :current_subscription + + def terminated_current_subscription + current_subscription.terminated_at = termination_date + current_subscription.status = :terminated + + current_subscription.next_subscriptions.build( + **new_subscription.attributes + ) + + current_subscription + end + + def new_subscription + @new_subscription ||= Subscription.new( + organization_id: organization.id, + customer:, + plan: target_plan, + name: target_plan.name, + external_id: current_subscription.external_id, + previous_subscription_id: current_subscription.id, + subscription_at: current_subscription.subscription_at, + billing_time: current_subscription.billing_time, + ending_at: current_subscription.ending_at, + status: :active, + started_at: upgrade? ? Time.current : termination_date, + created_at: Time.current + ) + end + + def termination_date + @termination_date ||= if upgrade? + Time.current + else + Subscriptions::DatesService + .new_instance(current_subscription, Time.current, current_usage: true) + .end_of_period + 1.day + end + end + + def upgrade? + target_plan.yearly_amount_cents >= current_subscription.plan.yearly_amount_cents + end + + def target_plan + @target_plan ||= organization.plans.parents.find_by(code: target_plan_code) + end + end + end +end diff --git a/app/services/invoices/preview/subscription_termination_service.rb b/app/services/invoices/preview/subscription_termination_service.rb new file mode 100644 index 0000000..5a67e4e --- /dev/null +++ b/app/services/invoices/preview/subscription_termination_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Invoices + module Preview + class SubscriptionTerminationService < BaseService + Result = BaseResult[:subscriptions] + + def initialize(current_subscription:, terminated_at:) + @current_subscription = current_subscription + @terminated_at = terminated_at + super + end + + def call + return result.not_found_failure!(resource: "subscription") unless current_subscription + + unless parsed_terminated_at + return result.single_validation_failure!( + error_code: "invalid_timestamp", + field: :terminated_at + ) + end + + if parsed_terminated_at.past? + return result.single_validation_failure!( + error_code: "cannot_be_in_past", + field: :terminated_at + ) + end + + current_subscription.assign_attributes( + status: :terminated, + terminated_at: + ) + + result.subscriptions = [current_subscription] + result + end + + private + + attr_reader :current_subscription, :terminated_at + + def parsed_terminated_at + return unless Utils::Datetime.valid_format?(terminated_at, format: :any) + + Time.zone.parse(terminated_at) + end + end + end +end diff --git a/app/services/invoices/preview/subscriptions_service.rb b/app/services/invoices/preview/subscriptions_service.rb new file mode 100644 index 0000000..a09a39c --- /dev/null +++ b/app/services/invoices/preview/subscriptions_service.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module Invoices + module Preview + class SubscriptionsService < BaseService + Result = BaseResult[:subscriptions] + + def initialize(organization:, customer:, params:) + @organization = organization + @customer = customer + @params = params + super + end + + def call + return result.not_found_failure!(resource: "organization") unless organization + return result.not_found_failure!(resource: "customer") unless customer + + if context != :proposal && customer.new_record? + return result.single_validation_failure!( + error_code: "must_be_persisted", + field: :customer + ) + end + + if [:termination, :plan_change].include?(context) + if customer_subscriptions.size > 1 + return result.single_validation_failure!( + error_code: "only_one_subscription_allowed_for_#{context}", + field: :subscriptions + ) + end + end + + case context + when :termination + SubscriptionTerminationService.call( + current_subscription:, + terminated_at: + ) + when :plan_change + SubscriptionPlanChangeService.call( + current_subscription:, + target_plan_code: + ) + when :proposal + BuildSubscriptionService.call( + customer:, + params: + ) + when :projection + FindSubscriptionsService.call( + subscriptions: customer_subscriptions + ) + end + end + + private + + attr_reader :params, :organization, :customer + + def context + return @context if defined?(@context) + + @context = if external_ids.none? + :proposal # Preview for non-existing subscription + elsif terminated_at + :termination + elsif target_plan_code + :plan_change + else + :projection # Preview for existing subscriptions including their next subscriptions + end + end + + def customer_subscriptions + return @customer_subscriptions if defined?(@customer_subscriptions) + + scope = customer.subscriptions.where(external_id: external_ids) + + if external_ids.size == 1 && scope.count == 1 && subscription_starting_in_future?(scope.first) + @customer_subscriptions = scope + + return @customer_subscriptions + end + + @customer_subscriptions = scope.active + end + + def current_subscription + @current_subscription ||= customer_subscriptions.first + end + + def terminated_at + terminated_at = params.dig(:subscriptions, :terminated_at) + + return terminated_at if terminated_at + + if customer_subscriptions&.size == 1 && subscription_ending_in_current_period?(current_subscription) + current_subscription.ending_at.iso8601 + end + end + + def external_ids + Array(params.dig(:subscriptions, :external_ids)) + end + + def target_plan_code + params.dig(:subscriptions, :plan_code) + end + + def subscription_ending_in_current_period?(subscription) + return false unless subscription&.ending_at + + next_billing_day = Subscriptions::DatesService + .new_instance(subscription, Time.current, current_usage: true) + .end_of_period + 1.day + + subscription.ending_at.in_time_zone(customer.applicable_timezone) <= next_billing_day + end + + def subscription_starting_in_future?(subscription) + return false unless subscription&.subscription_at + return false unless subscription&.pending? + return false unless subscription&.previous_subscription.nil? + + subscription.subscription_at > Time.current + end + end + end +end diff --git a/app/services/invoices/preview_context_service.rb b/app/services/invoices/preview_context_service.rb new file mode 100644 index 0000000..2c2d108 --- /dev/null +++ b/app/services/invoices/preview_context_service.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Invoices + class PreviewContextService < BaseService + Result = BaseResult[:customer, :subscriptions, :applied_coupons] + + def initialize(organization:, billing_entity:, params:) + @organization = organization + @billing_entity = billing_entity + @params = params.presence || {} + super + end + + def call + result.customer = find_or_build_customer + result.applied_coupons = find_or_build_applied_coupons + + subscriptions_service = ::Invoices::Preview::SubscriptionsService.call( + organization: organization, + customer: result.customer, + params: subscription_params + ) + + if subscriptions_service.success? + result.subscriptions = subscriptions_service.subscriptions + else + result.fail_with_error!(subscriptions_service.error) + end + + result + rescue ActiveRecord::RecordNotFound => exception + result.not_found_failure!(resource: exception.model.demodulize.underscore) + result + end + + private + + attr_reader :params, :organization, :billing_entity + + def subscription_params + params.slice(:billing_time, :plan_code, :subscription_at, :subscriptions) + end + + def find_or_build_customer + customer_params = params[:customer] || {} + + customer = if customer_params.key?(:external_id) + organization.customers.find_by!(external_id: customer_params[:external_id]) + else + organization.customers.new(created_at: Time.current, updated_at: Time.current, billing_entity:) + end + + customer.assign_attributes( + **customer_params.slice( + :name, + :tax_identification_number, + :currency, + :timezone, + :address_line1, + :address_line2, + :city, + :zipcode, + :state, + :country + ), + shipping_address_line1: customer_params.dig(:shipping_address, :address_line1), + shipping_address_line2: customer_params.dig(:shipping_address, :address_line2), + shipping_city: customer_params.dig(:shipping_address, :city), + shipping_zipcode: customer_params.dig(:shipping_address, :zipcode), + shipping_state: customer_params.dig(:shipping_address, :state), + shipping_country: customer_params.dig(:shipping_address, :country) + ) + + Array(customer_params[:integration_customers]).map do |integration_params| + build_customer_integration(customer, integration_params) + end + + customer + end + + def build_customer_integration(customer, attrs) + integration_class = integration_type(attrs[:integration_type]).constantize + integration = integration_class.find_by!(code: attrs[:integration_code], organization:) + type = IntegrationCustomers::BaseCustomer.customer_type(attrs[:integration_type]).constantize + + customer.integration_customers.build(integration:, type:) + end + + def find_or_build_applied_coupons + applied_coupons = result.customer + .applied_coupons.active + .joins(:coupon) + .order("coupons.limited_billable_metrics DESC, coupons.limited_plans DESC, applied_coupons.created_at ASC") + .presence + + applied_coupons || Array(params[:coupons]).map do |coupon_attr| + coupon = coupon_attr.key?(:code) && organization.coupons.find_by(code: coupon_attr[:code]) + coupon || Coupon.new(coupon_attr) + end.map do |coupon| + AppliedCoupon.new( + id: SecureRandom.uuid, + coupon:, + customer: result.customer, + organization:, + amount_cents: coupon.amount_cents, + amount_currency: coupon.amount_currency, + percentage_rate: coupon.percentage_rate, + frequency: coupon.frequency, + frequency_duration: coupon.frequency_duration, + frequency_duration_remaining: coupon.frequency_duration + ) + end + end + + def integration_type(type) + case type + when "anrok" + "Integrations::AnrokIntegration" + when "avalara" + "Integrations::AvalaraIntegration" + when "xero" + "Integrations::XeroIntegration" + when "hubspot" + "Integrations::HubspotIntegration" + when "salesforce" + "Integrations::SalesforceIntegration" + else + raise(NotImplementedError) + end + end + end +end diff --git a/app/services/invoices/preview_service.rb b/app/services/invoices/preview_service.rb new file mode 100644 index 0000000..49f72bb --- /dev/null +++ b/app/services/invoices/preview_service.rb @@ -0,0 +1,365 @@ +# frozen_string_literal: true + +module Invoices + class PreviewService < BaseService + Result = BaseResult[:subscriptions, :invoice, :fees_taxes] + + def initialize(customer:, subscriptions:, applied_coupons: []) + @customer = customer + @subscriptions = subscriptions + @applied_coupons = applied_coupons + @first_subscription = subscriptions.first + @persisted_subscriptions = subscriptions.any?(&:persisted?) + @subscription_context = fetch_context + + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "customer") unless customer + return result.not_found_failure!(resource: "subscription") if subscriptions.empty? + return result.not_allowed_failure!(code: "premium_integration_missing") if persisted_subscriptions && !organization.preview_enabled? + return result unless currencies_aligned? + return result unless billing_times_aligned? + + @invoice = Invoice.new( + organization:, + billing_entity:, + customer:, + invoice_type: :subscription, + currency: first_subscription.plan.amount_currency, + timezone: customer.applicable_timezone, + issuing_date:, + payment_due_date:, + net_payment_term: customer.applicable_net_payment_term, + created_at: Time.current, + updated_at: Time.current + ) + invoice.credits = [] + invoice.subscriptions = subscriptions + + add_subscription_fees + add_charge_fees + add_fixed_charge_fees + compute_tax_and_totals + + result.invoice = invoice + result.subscriptions = subscriptions + result + end + + private + + attr_accessor :customer, :subscriptions, :invoice, :applied_coupons, :first_subscription, :persisted_subscriptions, :subscription_context + delegate :organization, to: :customer + delegate :billing_entity, to: :customer + + def fetch_context + return :terminated if subscriptions.any?(&:terminated?) + + :default + end + + def currencies_aligned? + subscription_currencies = subscriptions.filter_map { |s| s.plan&.amount_currency } + + if subscription_currencies.uniq.count > 1 + result.single_validation_failure!(error_code: "subscription_currencies_does_not_match") + return false + end + + if customer.currency && customer.currency != subscription_currencies.first + unless organization.feature_flag_enabled?(:multi_currency) + result.single_validation_failure!(error_code: "customer_currency_does_not_match") + return false + end + end + + true + end + + def billing_times_aligned? + return true if subscriptions.size == 1 + + if end_of_periods.map { |e| e.to_date.to_s }.uniq.count > 1 + result.single_validation_failure!(error_code: "billing_periods_does_not_match") + return false + end + + true + end + + def end_of_periods + @end_of_periods ||= subscriptions.map do |subscription| + Subscriptions::DatesService + .new_instance(subscription, Time.current, current_usage: true) + .end_of_period + end + end + + def boundaries(subscription) + date_service = Subscriptions::DatesService.new_instance( + subscription, + billing_time, + current_usage: subscription.persisted? && subscription.terminated? && subscription.upgraded? + ) + + boundaries = BillingPeriodBoundaries.new( + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime, + fixed_charges_duration: date_service.fixed_charges_duration_in_days, + timestamp: billing_time, + charges_duration: date_service.charges_duration_in_days + ) + + subscription.adjusted_boundaries(billing_time, boundaries) + end + + def billing_time + return @billing_time if defined? @billing_time + + @billing_time = if subscription_context == :terminated + first_subscription.terminated_at + elsif persisted_subscriptions + end_of_periods.first + 1.day + elsif first_subscription.plan.pay_in_advance? + first_subscription.subscription_at + else + ds = Subscriptions::DatesService.new_instance(first_subscription, first_subscription.subscription_at, current_usage: true) + ds.end_of_period + 1.day + end + end + + def issuing_date + return @issuing_date if defined?(@issuing_date) + + terminated = subscription_context == :terminated + recurring = !terminated && (first_subscription.persisted? || !first_subscription.plan.pay_in_advance?) + + date = billing_time.in_time_zone(customer.applicable_timezone).to_date + issuing_date_service = Invoices::IssuingDateService.new(customer_settings: customer, recurring:) + + @issuing_date = date + issuing_date_service.issuing_date_adjustment.days + end + + def payment_due_date + (issuing_date + customer.applicable_net_payment_term.days).to_date + end + + def add_subscription_fees + invoice.fees = subscriptions + .filter { |subscription| should_create_subscription_fee?(subscription) } + .map do |subscription| + Fees::SubscriptionService.call!( + invoice:, + subscription:, + boundaries: boundaries(subscription), + context: :preview + ).fee + end + end + + def add_charge_fees + subscriptions.select(&:persisted?).map do |subscription| + boundaries = boundaries(subscription) + + charges = [] + subscription.plan.charges.joins(:billable_metric) + .includes(:taxes, billable_metric: :organization, filters: {values: :billable_metric_filter}) + .where(invoiceable: true) + .where + .not(pay_in_advance: true, billable_metric: {recurring: false}).find_each do |c| + next if should_not_create_charge_fee?(c, subscription) + charges << c + end + + context = OpenTelemetry::Context.current + + invoice.fees << Parallel.flat_map(charges, in_threads: ENV["LAGO_PARALLEL_THREADS_COUNT"]&.to_i || 0) do |charge| + OpenTelemetry::Context.with_current(context) do + ActiveRecord::Base.connection_pool.with_connection do + cache_middleware = Subscriptions::ChargeCacheMiddleware.new( + subscription:, + charge:, + to_datetime: boundaries.charges_to_datetime + ) + + Fees::ChargeService + .call!(invoice:, charge:, subscription:, boundaries:, context: :invoice_preview, cache_middleware:) + .fees + end + end + end + end + end + + def add_fixed_charge_fees + subscriptions.each do |subscription| + boundaries = boundaries(subscription) + + next unless fixed_charge_boundaries_valid?(boundaries) + + fixed_charges = if subscription.persisted? + subscription.fixed_charges + else + subscription.plan.fixed_charges.kept + end + + fixed_charges.find_each do |fixed_charge| + next unless should_create_fixed_charge_fee?(fixed_charge, subscription) + + fee_result = Fees::FixedChargeService.call( + invoice:, + fixed_charge:, + subscription:, + boundaries:, + context: :invoice_preview + ) + + invoice.fees << fee_result.fee if fee_result.success? && fee_result.fee + end + end + end + + def compute_tax_and_totals + invoice.fees_amount_cents = invoice.fees.sum(&:amount_cents) + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents + + if invoice.fees_amount_cents&.positive? && applied_coupons.present? + Coupons::PreviewService.call(invoice:, applied_coupons:) + end + + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents - invoice.coupons_amount_cents + + if provider_taxation? && invoice.fees.any? + apply_provider_taxes + elsif invoice.fees.any? + apply_taxes + end + + invoice.sub_total_including_taxes_amount_cents = ( + invoice.sub_total_excluding_taxes_amount_cents + invoice.taxes_amount_cents + ) + + invoice.total_amount_cents = ( + invoice.sub_total_including_taxes_amount_cents - invoice.credit_notes_amount_cents + ) + + create_credit_note_credits + create_applied_prepaid_credits + end + + def create_credit_note_credits + terminated_subscription = subscriptions.none?(&:downgraded?) ? subscriptions.find(&:terminated?) : nil + credits = Preview::CreditsService.call!(invoice:, terminated_subscription:).credits + + invoice.credits << credits + invoice.total_amount_cents -= credits.sum(&:amount_cents) + end + + def create_applied_prepaid_credits + return unless customer.persisted? + return unless invoice.total_amount_cents&.positive? + + wallets_transactions = Credits::AllocatePrepaidCreditsByWalletsService.call!(invoice:).wallet_transactions + amount_cents = wallets_transactions.sum { |_k, v| v } + + invoice.prepaid_credit_amount_cents += amount_cents + invoice.total_amount_cents -= amount_cents + end + + def apply_taxes + invoice.fees.each do |fee| + taxes_result = Fees::ApplyTaxesService.call(fee:) + taxes_result.raise_if_error! + end + + taxes_result = Invoices::ApplyTaxesService.call(invoice:) + taxes_result.raise_if_error! + end + + def apply_provider_taxes + taxes_result = Integrations::Aggregator::Taxes::Invoices::CreateDraftService.call(invoice:, fees: invoice.fees) + + if taxes_result.success? + result.fees_taxes = taxes_result.fees + invoice.fees.each do |fee| + fee_taxes = result.fees_taxes.find { |item| item.item_key == fee.item_key } + + res = Fees::ApplyProviderTaxesService.call(fee:, fee_taxes:) + res.raise_if_error! + end + + res = Invoices::ApplyProviderTaxesService.call(invoice:, provider_taxes: result.fees_taxes) + res.raise_if_error! + else + apply_zero_tax + end + rescue BaseService::ThrottlingError, Net::OpenTimeout, Net::ReadTimeout, OpenSSL::SSL::SSLError + apply_zero_tax + end + + def apply_zero_tax + invoice.taxes_amount_cents = 0 + invoice.taxes_rate = 0 + end + + def provider_taxation? + customer.integration_customers.find { |ic| ic.tax_kind? } + end + + def should_create_subscription_fee?(subscription) + return true if subscription_context == :default + + subscription.terminated? == subscription.plan.pay_in_arrears? + end + + def should_not_create_charge_fee?(charge, subscription) + return false if subscription_context == :default + + if charge.pay_in_advance? + condition = charge.billable_metric.recurring? && + subscription.terminated? && + (subscription.upgraded? || subscription.next_subscription.nil?) + + return condition + end + + return false if charge.prorated? + + charge.billable_metric.recurring? && + subscription.terminated? && + subscription.upgraded? && + charge.included_in_next_subscription?(subscription) + end + + def fixed_charge_boundaries_valid?(boundaries) + return false if boundaries.fixed_charges_from_datetime.nil? + return false if boundaries.fixed_charges_to_datetime.nil? + + boundaries.fixed_charges_from_datetime <= boundaries.fixed_charges_to_datetime + end + + def should_create_fixed_charge_fee?(fixed_charge, subscription) + return false if fixed_charge.pay_in_advance? && subscription.terminated? + + if !fixed_charge.pay_in_advance? && is_starting_subscription?(subscription) + return false + end + + true + end + + def is_starting_subscription?(subscription) + return false unless subscription.persisted? + + subscription.invoice_subscriptions.count == 1 && + subscription.invoice_subscriptions.order(:created_at).last.subscription_starting? + end + end +end diff --git a/app/services/invoices/progressive_billing_service.rb b/app/services/invoices/progressive_billing_service.rb new file mode 100644 index 0000000..32b1727 --- /dev/null +++ b/app/services/invoices/progressive_billing_service.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module Invoices + class ProgressiveBillingService < BaseService + Result = BaseResult[:invoice] + + def initialize(sorted_usage_thresholds:, lifetime_usage:, timestamp: Time.current) + @sorted_usage_thresholds = sorted_usage_thresholds + @lifetime_usage = lifetime_usage + @timestamp = timestamp + + super + end + + def call + Idempotency.transaction do + create_generating_invoice + create_fees + create_applied_usage_thresholds + + Idempotency.unique!(invoice, + organization_id: lifetime_usage.organization_id, + external_subscription_id: subscription.external_id, + # this is required to be here for recurring thresholds. as we'll not have credits across billing periods, this is not enough information for uniqueness otherwise. + invoiced_usage: lifetime_usage.invoiced_usage_amount_cents, + threshold_amount: sorted_usage_thresholds.last.amount_cents) + + invoice.fees_amount_cents = invoice.fees.sum(:amount_cents) + invoice.sub_total_excluding_taxes_amount_cents = invoice.fees_amount_cents + + credits = Credits::ProgressiveBillingService.call(invoice:).credits + if credits.any? && sorted_usage_thresholds.last.recurring? + Idempotency.unique!(invoice, previous_progressive_billing_invoice_id: credits.first.progressive_billing_invoice_id) + end + Credits::AppliedCouponsService.call(invoice:) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice:) + next if !totals_result.success? && totals_result.error.is_a?(BaseService::UnknownTaxFailure) + + totals_result.raise_if_error! + + create_credit_note_credit + create_applied_prepaid_credit + + invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded + Invoices::FinalizeService.call!(invoice: invoice) + end + + if invoice.finalized? + Utils::SegmentTrack.invoice_created(invoice) + SendWebhookJob.perform_later("invoice.created", invoice) + Utils::ActivityLog.produce(invoice, "invoice.created") + Invoices::GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:) + end + + result.invoice = invoice + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue Sequenced::SequenceError, ActiveRecord::StaleObjectError, Customers::FailedToAcquireLock + raise + rescue => e + result.fail_with_error!(e) + end + + private + + attr_accessor :sorted_usage_thresholds, :lifetime_usage, :timestamp, :invoice + + delegate :subscription, to: :lifetime_usage + + def create_generating_invoice + invoice_result = CreateGeneratingService.call( + customer: subscription.customer, + invoice_type: :progressive_billing, + currency: sorted_usage_thresholds.first.currency, + datetime: Time.zone.at(timestamp) + ) do |invoice| + CreateInvoiceSubscriptionService + .call(invoice:, subscriptions: [subscription], timestamp:, invoicing_reason: :progressive_billing) + .raise_if_error! + end + invoice_result.raise_if_error! + + @invoice = invoice_result.invoice + end + + def create_fees + filters = event_filters(subscription, boundaries).charges + + charges.find_each do |charge| + Fees::ChargeService.call!( + invoice:, + charge:, + subscription:, + context: :finalize, + boundaries:, + filtered_aggregations: filters[charge.id] || [] + ) + end + end + + def charges + subscription + .plan + .charges + .includes(:taxes, billable_metric: :organization, filters: {values: :billable_metric_filter}) + .where(invoiceable: true) + .where(pay_in_advance: false) + end + + def boundaries + return @boundaries if defined?(@boundaries) + + invoice_subscription = invoice.invoice_subscriptions.first + date_service = Subscriptions::DatesService.new_instance( + subscription, + timestamp, + current_usage: true + ) + + @boundaries = BillingPeriodBoundaries.new( + from_datetime: invoice_subscription.from_datetime, + to_datetime: invoice_subscription.to_datetime, + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime, + timestamp: timestamp, + charges_duration: date_service.charges_duration_in_days + ) + end + + def create_applied_usage_thresholds + sorted_usage_thresholds.each do |usage_threshold| + AppliedUsageThreshold.create!( + organization_id: lifetime_usage.organization_id, + invoice:, + usage_threshold:, + lifetime_usage_amount_cents: lifetime_usage.total_amount_cents + ) + end + end + + def should_deliver_email? + License.premium? && subscription.billing_entity.email_settings.include?("invoice.finalized") + end + + def create_credit_note_credit + credit_result = Credits::CreditNoteService.call(invoice:).raise_if_error! + + invoice.total_amount_cents -= credit_result.credits.sum(&:amount_cents) if credit_result.credits + end + + def create_applied_prepaid_credit + return unless invoice.total_amount_cents.positive? + + prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice:) + invoice.total_amount_cents -= prepaid_credit_result.prepaid_credit_amount_cents + end + + def event_filters(subscription, boundaries) + Events::BillingPeriodFilterService.call!( + subscription:, boundaries: + ) + end + end +end diff --git a/app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb b/app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb new file mode 100644 index 0000000..86cba98 --- /dev/null +++ b/app/services/invoices/provider_taxes/pull_taxes_and_apply_service.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +module Invoices + module ProviderTaxes + class PullTaxesAndApplyService < BaseService + def initialize(invoice:) + @invoice = invoice + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result.not_found_failure!(resource: "integration_customer") unless customer.tax_customer + return result unless invoice.pending? || invoice.draft? || invoice.subscription_gated? + return result unless invoice.tax_pending? + + invoice.error_details.tax_error.discard_all # rubocop:disable Lago/DiscardAll + taxes_result = if invoice.draft? + Integrations::Aggregator::Taxes::Invoices::CreateDraftService.call(invoice:, fees: invoice.fees) + else + Integrations::Aggregator::Taxes::Invoices::CreateService.call(invoice:, fees: invoice.fees) + end + + unless taxes_result.success? + create_error_detail(taxes_result.error) + invoice.tax_status = "failed" + invoice.status = "failed" unless invoice.draft? + invoice.save! + + return result + end + + provider_taxes = taxes_result.fees + + ActiveRecord::Base.transaction do + invoice.reload + return result if invoice.finalized? || invoice.voided? || invoice.closed? + + unless invoice.draft? + invoice.issuing_date = issuing_date + invoice.payment_due_date = payment_due_date + end + + Invoices::ComputeAmountsFromFees.call(invoice:, provider_taxes:) + + create_credit_note_credit if should_create_credit_note_credit? + create_applied_prepaid_credit if should_create_applied_prepaid_credit? + + invoice.payment_status = invoice.total_amount_cents.positive? ? :pending : :succeeded + invoice.tax_status = "succeeded" + + skip_payment_gating_for_zero_amount if invoice.subscription_payment_gated? && invoice.total_amount_cents.zero? + + Invoices::TransitionToFinalStatusService.call(invoice:) unless invoice.draft? + + invoice.save! + invoice.reload + + result.invoice = invoice + end + + if invoice.subscription_gated? + Invoices::Payments::CreateService.call_async(invoice:) + elsif invoice.finalized? + SendWebhookJob.perform_later("invoice.created", invoice) + Utils::ActivityLog.produce(invoice, "invoice.created") + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:) + Utils::SegmentTrack.invoice_created(invoice) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::InvalidForeignKey + # NOTE: A draft invoice has been refreshed while the taxes were applied + raise unless invoice.draft? + result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_accessor :invoice + + def skip_payment_gating_for_zero_amount + gated = invoice.subscriptions.find(&:payment_gated?) + return unless gated + + Subscriptions::ActivationRules::Payment::EvaluateService.call!( + rule: gated.activation_rules.payment.sole, + status: :satisfied + ) + Subscriptions::ActivationRules::ResolveSubscriptionStatusService.call!(subscription: gated) + end + + def should_deliver_email? + License.premium? && + invoice.billing_entity.email_settings.include?("invoice.finalized") + end + + def should_create_credit_note_credit? + return false if invoice.draft? + + !invoice.one_off? + end + + def should_create_applied_prepaid_credit? + return false if invoice.draft? + return false if invoice.one_off? + + invoice.total_amount_cents&.positive? + end + + def create_credit_note_credit + credit_result = Credits::CreditNoteService.new(invoice:).call + credit_result.raise_if_error! + + invoice.total_amount_cents -= credit_result.credits.sum(&:amount_cents) if credit_result.credits + end + + def create_applied_prepaid_credit + prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice:) + invoice.total_amount_cents -= prepaid_credit_result.prepaid_credit_amount_cents + end + + def issuing_date + @issuing_date ||= + if issuing_date_keep_anchor? + invoice.issuing_date + else + Time.current.in_time_zone(customer.applicable_timezone).to_date + end + end + + def issuing_date_keep_anchor? + invoice.invoice_subscriptions.first&.recurring? && + customer.applicable_subscription_invoice_issuing_date_adjustment == "keep_anchor" + end + + def payment_due_date + @payment_due_date ||= issuing_date + customer.applicable_net_payment_term.days + end + + def customer + @customer ||= invoice.customer + end + + def create_error_detail(error) + error_result = ErrorDetails::CreateService.call( + owner: invoice, + organization: invoice.organization, + params: { + error_code: :tax_error, + details: { + tax_error: error.code + }.tap do |details| + details[:tax_error_message] = error.error_message if error.error_message.present? + end + } + ) + error_result.raise_if_error! + end + end + end +end diff --git a/app/services/invoices/provider_taxes/void_service.rb b/app/services/invoices/provider_taxes/void_service.rb new file mode 100644 index 0000000..989891b --- /dev/null +++ b/app/services/invoices/provider_taxes/void_service.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +module Invoices + module ProviderTaxes + class VoidService < BaseService + def initialize(invoice:) + @invoice = invoice + + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.blank? + return result.not_allowed_failure!(code: "status_not_voided") unless invoice.voided? + + invoice.error_details.tax_voiding_error.discard_all # rubocop:disable Lago/DiscardAll + + tax_result = Integrations::Aggregator::Taxes::Invoices::VoidService.new(invoice:).call + + if frozen_transaction?(tax_result) + negate_result = perform_invoice_negate + + unless negate_result.success? + return result.validation_failure!(errors: {tax_error: [negate_result.error.code]}) + end + elsif locked_transaction?(tax_result) + refund_result = perform_invoice_refund + + unless refund_result.success? + return result.validation_failure!(errors: {tax_error: [refund_result.error.code]}) + end + elsif !tax_result.success? + create_error_detail(tax_result.error.code) + + return result.validation_failure!(errors: {tax_error: [tax_result.error.code]}) + end + + result.invoice = invoice + + result + end + + private + + attr_reader :invoice + + delegate :customer, to: :invoice + + def perform_invoice_negate + negate_result = Integrations::Aggregator::Taxes::Invoices::NegateService.new(invoice:).call + + create_error_detail(negate_result.error.code) unless negate_result.success? + + negate_result + end + + def perform_invoice_refund + refund_result = Integrations::Aggregator::Taxes::Invoices::CreateService.new(invoice:).call + + create_error_detail(refund_result.error.code) unless refund_result.success? + + refund_result + end + + def create_error_detail(code) + error_result = ErrorDetails::CreateService.call( + owner: invoice, + organization: invoice.organization, + params: { + error_code: :tax_voiding_error, + details: { + tax_voiding_error: code + } + } + ) + error_result.raise_if_error! + end + + # transactionFrozenForFiling error means that tax is already reported to the tax authority + # We should call negate action instead + def frozen_transaction?(tax_result) + return false unless invoice.customer.anrok_customer + + !tax_result.success? && tax_result.error.code == "transactionFrozenForFiling" + end + + def locked_transaction?(tax_result) + return false unless invoice.customer.avalara_customer + + !tax_result.success? && tax_result.error.code == "CannotModifyLockedTransaction" + end + end + end +end diff --git a/app/services/invoices/refresh_draft_and_finalize_service.rb b/app/services/invoices/refresh_draft_and_finalize_service.rb new file mode 100644 index 0000000..ec56505 --- /dev/null +++ b/app/services/invoices/refresh_draft_and_finalize_service.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +module Invoices + class RefreshDraftAndFinalizeService < BaseService + def initialize(invoice:) + @invoice = invoice + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.nil? + return result.forbidden_failure! unless invoice.subscription? + return result unless invoice.draft? + drafted_issuing_date = invoice.issuing_date + + ActiveRecord::Base.transaction do + invoice.issuing_date = issuing_date + refresh_result = Invoices::RefreshDraftService.call(invoice:, context: :finalize) + if invoice.tax_pending? + # When we need to fetch taxes, the invoice isn't finalized until taxes are pulled. + # So we can't show the final issuing/payment due dates yet. + # We'll set those in Inovoices::ProviderTaxes::PullTaxesAndApplyService + # once the taxes are successfully pulled. + invoice.update!(issuing_date: drafted_issuing_date) + # rubocop:disable Rails/TransactionExitStatement + return refresh_result + # rubocop:enable Rails/TransactionExitStatement + end + refresh_result.raise_if_error! + + invoice.payment_due_date = payment_due_date + Invoices::TransitionToFinalStatusService.call(invoice:) + invoice.save! + + invoice.credit_notes.each(&:finalized!) + end + + result.invoice = invoice.reload + after_commit do + clear_invoice_generation_errors(invoice) + unless invoice.closed? + SendWebhookJob.perform_later("invoice.created", invoice) + Utils::ActivityLog.produce(invoice, "invoice.created") + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:) + Utils::SegmentTrack.invoice_created(invoice) + end + + invoice.credit_notes.each do |credit_note| + track_credit_note_created(credit_note) + SendWebhookJob.perform_later("credit_note.created", credit_note) + Utils::ActivityLog.produce(credit_note, "credit_note.created") + CreditNotes::GenerateDocumentsJob.perform_later(credit_note) + end + end + + result + end + + private + + attr_accessor :invoice, :result + + def issuing_date + @issuing_date ||= + if issuing_date_keep_anchor? + invoice.issuing_date + else + Time.current.in_time_zone(invoice.customer.applicable_timezone).to_date + end + end + + def issuing_date_keep_anchor? + invoice.invoice_subscriptions.first&.recurring? && + invoice.customer.applicable_subscription_invoice_issuing_date_adjustment == "keep_anchor" + end + + def payment_due_date + @payment_due_date ||= issuing_date + invoice.customer.applicable_net_payment_term.days + end + + def track_credit_note_created(credit_note) + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "credit_note_issued", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + invoice_id: credit_note.invoice_id, + credit_note_method: "credit" + } + ) + end + + def should_deliver_email? + License.premium? && + invoice.billing_entity.email_settings.include?("invoice.finalized") + end + + def clear_invoice_generation_errors(invoice) + invoice_error = invoice.error_details.invoice_generation_error.last + return if invoice_error.blank? + + delete_generating_sequence_number_error(invoice_error) + end + + def delete_generating_sequence_number_error(invoice_error) + backtrace = invoice_error.details["backtrace"]&.first || "" + return unless backtrace.include?("generate_organization_sequential_id") || backtrace.include?("generate_billing_entity_sequential_id") + + invoice_error.delete + end + end +end diff --git a/app/services/invoices/refresh_draft_service.rb b/app/services/invoices/refresh_draft_service.rb new file mode 100644 index 0000000..64ce1dd --- /dev/null +++ b/app/services/invoices/refresh_draft_service.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +module Invoices + class RefreshDraftService < BaseService + def initialize(invoice:, context: :refresh) + @invoice = invoice + @subscription_ids = invoice.subscriptions.pluck(:id) + @context = context + @invoice_subscriptions = invoice.invoice_subscriptions + + # NOTE: Recurring status (meaning billed automatically from the recurring billing process) + # should be kept to prevent double billing on billing day + @recurring = invoice_subscriptions.first&.recurring || false + + # NOTE: upgrading is used as a not persisted reasong as it means + # one subscription starting and a second one terminating + @invoicing_reason = if @recurring + :subscription_periodic + elsif invoice_subscriptions.count == 1 + invoice_subscriptions.first&.invoicing_reason&.to_sym || :upgrading + else + :upgrading + end + + super + end + + def call + return result.forbidden_failure! unless invoice.subscription? + + result.invoice = invoice + return result unless invoice.draft? + + ActiveRecord::Base.transaction do + invoice.update!(ready_to_be_refreshed: false) if invoice.ready_to_be_refreshed? + old_total_amount_cents = invoice.total_amount_cents + + old_fee_values = invoice_credit_note_items.map do |item| + {credit_note_item_id: item.id, fee_amount_cents: item.fee&.amount_cents} + end + cn_subscription_ids = invoice.credit_notes.map do |cn| + {credit_note_id: cn.id, subscription_id: cn.fees.pick(:subscription_id)} + end + timestamp = fetch_timestamp + + reset_invoice_values + + Invoices::CreateInvoiceSubscriptionService.call( + invoice:, + subscriptions: Subscription.find(subscription_ids), + timestamp:, + invoicing_reason:, + refresh: true + ).raise_if_error! + + calculate_result = Invoices::CalculateFeesService.call( + invoice: invoice.reload, + recurring:, + context: + ) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + invoice.credit_notes.each do |credit_note| + subscription_id = cn_subscription_ids.find { |h| h[:credit_note_id] == credit_note.id }[:subscription_id] + fee = invoice.fees.subscription.find_by(subscription_id:) + CreditNotes::RefreshDraftService.call(credit_note:, fee:, old_fee_values:) + end + + calculate_result.raise_if_error! unless tax_error?(calculate_result.error) + + if old_total_amount_cents != invoice.total_amount_cents + flag_lifetime_usage_for_refresh + invoice.customer.flag_wallets_for_refresh + end + + # NOTE: In case of a refresh the same day of the termination. + invoice.fees.update_all(created_at: invoice.created_at) # rubocop:disable Rails/SkipsModelValidations + + return result if tax_error?(calculate_result.error) # rubocop:disable Rails/TransactionExitStatement + + if invoice.should_update_hubspot_invoice? + Integrations::Aggregator::Invoices::Hubspot::UpdateJob.perform_later(invoice: invoice.reload) + end + end + + result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_accessor :invoice, :subscription_ids, :invoicing_reason, :recurring, :context, :invoice_subscriptions + + def fetch_timestamp + timestamp = invoice_subscriptions.first&.timestamp + return timestamp if timestamp + + fee = invoice.fees.first + # NOTE: Adding 1 second because of to_i rounding. + return invoice.created_at + 1.second unless fee&.properties&.[]("timestamp") + + DateTime.parse(fee.properties["timestamp"]) + end + + def invoice_credit_note_items + CreditNoteItem + .joins(:credit_note) + .where(credit_note: {invoice_id: invoice.id}) + .includes(:fee) + end + + def flag_lifetime_usage_for_refresh + LifetimeUsages::FlagRefreshFromInvoiceService.call(invoice:).raise_if_error! + end + + def tax_error?(error) + error&.is_a?(BaseService::UnknownTaxFailure) + end + + def reset_invoice_values + invoice.credit_notes.each { |cn| cn.items.update_all(fee_id: nil) } # rubocop:disable Rails/SkipsModelValidations + invoice.fees.destroy_all + invoice_subscriptions.destroy_all + invoice.applied_taxes.destroy_all + invoice.error_details.discard_all # rubocop:disable Lago/DiscardAll + invoice.applied_invoice_custom_sections.destroy_all + invoice.credits.progressive_billing_invoice_kind.destroy_all + + invoice.taxes_amount_cents = 0 + invoice.total_amount_cents = 0 + invoice.taxes_rate = 0 + invoice.fees_amount_cents = 0 + invoice.sub_total_excluding_taxes_amount_cents = 0 + invoice.sub_total_including_taxes_amount_cents = 0 + invoice.progressive_billing_credit_amount_cents = 0 + + invoice.save! + end + end +end diff --git a/app/services/invoices/regenerate_from_voided_service.rb b/app/services/invoices/regenerate_from_voided_service.rb new file mode 100644 index 0000000..bb0ef0b --- /dev/null +++ b/app/services/invoices/regenerate_from_voided_service.rb @@ -0,0 +1,258 @@ +# frozen_string_literal: true + +module Invoices + class RegenerateFromVoidedService < BaseService + Result = BaseResult[:invoice] + + def initialize(voided_invoice:, fees_params:) + @voided_invoice = voided_invoice + @fees_params = fees_params + @regenerated_invoice = nil + super + end + + activity_loggable( + action: "invoice.regenerated", + record: -> { voided_invoice } + ) + + def call + return result.not_found_failure!(resource: "invoice") unless voided_invoice + + ActiveRecord::Base.transaction do + create_regenerated_invoice + create_invoice_subscriptions + process_fees + adjust_fees + Invoices::ApplyInvoiceCustomSectionsService.call!(invoice: regenerated_invoice) + regenerated_invoice.fees_amount_cents = regenerated_invoice.fees.sum(:amount_cents) + regenerated_invoice.sub_total_excluding_taxes_amount_cents = regenerated_invoice.fees.sum(:amount_cents) + + # apply taxes credits and coupons + Credits::ProgressiveBillingService.call!(invoice: regenerated_invoice) + Credits::AppliedCouponsService.call!(invoice: regenerated_invoice) if should_create_coupon_credit? + totals_result = Invoices::ComputeTaxesAndTotalsService.call(invoice: regenerated_invoice, finalizing: true) + + # We intentionally return early from the transaction block if tax computation fails this is an async call, + # we still want to persist the regenerated invoice in its current state it will be finalized later when + # we get the taxes back check Invoices::ProviderTaxes::PullTaxesAndApplyJob.perform_later(invoice:) + if !totals_result.success? && regenerated_invoice.tax_status == "pending" + result.invoice = regenerated_invoice + return result # rubocop:disable Rails/TransactionExitStatement + end + + create_credit_note_credit if should_create_credit_note_credit? + create_applied_prepaid_credit if should_create_applied_prepaid_credit? + regenerated_invoice.payment_status = regenerated_invoice.total_amount_cents.positive? ? :pending : :succeeded + Invoices::TransitionToFinalStatusService.call!(invoice: regenerated_invoice) + regenerated_invoice.save! + end + + result.invoice = regenerated_invoice + + call_invoice_finalization_jobs(regenerated_invoice) + + result + end + + private + + attr_accessor :regenerated_invoice + attr_reader :voided_invoice, :fees_params + + delegate :customer, to: :voided_invoice + + def should_create_credit_note_credit? + return false unless regenerated_invoice.total_amount_cents&.positive? + + true + end + + def should_create_coupon_credit? + return false unless regenerated_invoice.fees_amount_cents&.positive? + + true + end + + def should_create_applied_prepaid_credit? + regenerated_invoice.total_amount_cents&.positive? && wallets.any? + end + + def wallets + @wallets ||= voided_invoice.customer.wallets.active.with_positive_balance + end + + def create_applied_prepaid_credit + prepaid_credit_result = Credits::AppliedPrepaidCreditsService.call!(invoice: regenerated_invoice) + refresh_amounts(credit_amount_cents: prepaid_credit_result.prepaid_credit_amount_cents) + end + + def create_credit_note_credit + credit_result = Credits::CreditNoteService.new(invoice: regenerated_invoice).call! + + refresh_amounts(credit_amount_cents: credit_result.credits.sum(&:amount_cents)) if credit_result.credits + end + + def refresh_amounts(credit_amount_cents:) + regenerated_invoice.total_amount_cents -= credit_amount_cents + end + + def adjust_fees + subunit = regenerated_invoice.total_amount.currency.subunit_to_unit + + regenerated_invoice.fees.each do |fee| + adjusted_fee = fee.adjusted_fee + next unless adjusted_fee + + if fee.fee_type == "charge" + properties = fee.charge_filter&.properties || fee.charge.properties + + result = Fees::InitFromAdjustedChargeFeeService.call!( + adjusted_fee:, + boundaries: fee.properties, + properties: + ) + + updated = result.fee + fee.assign_attributes( + updated.attributes.slice( + "invoice_display_name", + "charge_id", + "subscription_id", + "units", + "unit_amount_cents", + "precise_unit_amount", + "amount_cents", + "precise_amount_cents", + "amount_details", + "charge_filter" + ) + ) + elsif fee.fee_type == "fixed_charge" + result = Fees::InitFromAdjustedFixedChargeFeeService.call!( + adjusted_fee:, + boundaries: fee.properties, + properties: fee.fixed_charge.properties + ) + + updated = result.fee + fee.assign_attributes( + updated.attributes.slice( + "invoice_display_name", + "fixed_charge_id", + "subscription_id", + "units", + "unit_amount_cents", + "precise_unit_amount", + "amount_cents", + "precise_amount_cents", + "amount_details" + ) + ) + else + fee.invoice_display_name = adjusted_fee.invoice_display_name if adjusted_fee.invoice_display_name.present? + fee.charge_id = adjusted_fee.charge_id if adjusted_fee.charge_id.present? + fee.subscription_id = adjusted_fee.subscription_id if adjusted_fee.subscription_id.present? + fee.units = adjusted_fee.units if adjusted_fee.units.present? + + units = fee.units.to_d + + if adjusted_fee.adjusted_units? + unit_cents = fee.unit_amount_cents + amount_cents = (units * unit_cents).round + precise_unit_amount = unit_cents.to_f / subunit + else + unit_cents = adjusted_fee.unit_precise_amount_cents + amount_cents = (units * unit_cents).round + precise_unit_amount = unit_cents / subunit + end + + fee.unit_amount_cents = unit_cents.round + fee.precise_unit_amount = precise_unit_amount + fee.amount_cents = amount_cents + fee.precise_amount_cents = units * unit_cents + end + + fee.save! + end + end + + def process_fees + fees_params.each do |fee_params| + if fee_params[:id].present? + voided_fee = voided_invoice.fees.find_by(id: fee_params[:id]) + dep_fee = duplicate_fee(voided_fee) if voided_fee + end + + adjusted_fee_params = { + invoice_display_name: fee_params[:invoice_display_name], + units: fee_params[:units], + charge_id: fee_params[:charge_id], + charge_filter_id: fee_params[:charge_filter_id], + subscription_id: fee_params[:subscription_id] + } + adjusted_fee_params[:unit_precise_amount] = fee_params[:unit_amount_cents] if fee_params[:unit_amount_cents].present? + adjusted_fee_params[:fee_id] = dep_fee.id if dep_fee + + AdjustedFees::CreateService.call( + invoice: regenerated_invoice, + params: adjusted_fee_params, + regenerating_voided: true + ) + end + end + + def duplicate_fee(voided_fee) + dup_fee = voided_fee.dup + dup_fee.invoice = regenerated_invoice + dup_fee.payment_status = :pending + dup_fee.taxes_amount_cents = 0 + dup_fee.taxes_precise_amount_cents = 0 + dup_fee.precise_coupons_amount_cents = 0 + dup_fee.taxes_base_rate = 0 + dup_fee.taxes_rate = 0 + dup_fee.original_fee = voided_fee.original_fee || voided_fee + dup_fee.save! + dup_fee + end + + def create_invoice_subscriptions + voided_invoice.invoice_subscriptions.each do |subscription| + subscription.update!(regenerated_invoice_id: regenerated_invoice.id) + + subscription.dup.tap do |dup| + dup.invoice = regenerated_invoice + dup.regenerated_invoice_id = nil + dup.save! + end + end + end + + def create_regenerated_invoice + @regenerated_invoice = Invoices::CreateGeneratingService.call!( + customer: voided_invoice.customer, + invoice_type: voided_invoice.invoice_type, + currency: voided_invoice.currency, + datetime: voided_invoice.created_at + ).invoice.tap do |invoice| + invoice.update!(voided_invoice_id: voided_invoice.id) + end + end + + def call_invoice_finalization_jobs(invoice) + return if invoice.closed? + + Utils::SegmentTrack.invoice_created(invoice) + SendWebhookJob.perform_later("invoice.created", invoice) + Utils::ActivityLog.produce(invoice, "invoice.created") + GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:) + end + + def should_deliver_email? + License.premium? && customer.billing_entity.email_settings.include?("invoice.finalized") + end + end +end diff --git a/app/services/invoices/retry_batch_service.rb b/app/services/invoices/retry_batch_service.rb new file mode 100644 index 0000000..ca9e638 --- /dev/null +++ b/app/services/invoices/retry_batch_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Invoices + class RetryBatchService < BaseService + def initialize(organization:) + @organization = organization + + super + end + + def call_async + Invoices::RetryAllJob.perform_later(organization:, invoice_ids: invoices.ids) + + result.invoices = invoices + + result + end + + def call(invoice_ids) + processed_invoices = [] + Invoice.where(id: invoice_ids).find_each do |invoice| + result = Invoices::RetryService.new(invoice:).call + + return result unless result.success? + + processed_invoices << result.invoice + end + + result.invoices = processed_invoices.compact + + result + end + + private + + attr_reader :organization + + def invoices + @invoices ||= organization.invoices.where(status: :failed) + end + end +end diff --git a/app/services/invoices/retry_service.rb b/app/services/invoices/retry_service.rb new file mode 100644 index 0000000..db32b19 --- /dev/null +++ b/app/services/invoices/retry_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Invoices + class RetryService < BaseService + def initialize(invoice:) + @invoice = invoice + + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result.not_allowed_failure!(code: "invalid_status") unless invoice.failed? + + invoice.status = invoice.subscriptions.any?(&:gated?) ? :open : :pending + invoice.tax_status = "pending" + invoice.save! + + Invoices::ProviderTaxes::PullTaxesAndApplyJob.perform_later(invoice:) + + result.invoice = invoice + result + end + + private + + attr_accessor :invoice + end +end diff --git a/app/services/invoices/subscription_service.rb b/app/services/invoices/subscription_service.rb new file mode 100644 index 0000000..cf8cf9c --- /dev/null +++ b/app/services/invoices/subscription_service.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +module Invoices + class SubscriptionService < BaseService + def initialize(subscriptions:, timestamp:, invoicing_reason:, invoice: nil, skip_charges: false) + @subscriptions = subscriptions + @timestamp = timestamp + @invoicing_reason = invoicing_reason + @recurring = invoicing_reason.to_sym == :subscription_periodic + + @customer = subscriptions&.first&.customer + @currency = subscriptions&.first&.plan&.amount_currency + + # NOTE: In case of retry when the creation process failed, + # and if the generating invoice was persisted, + # the process can be retried without creating a new invoice + @invoice = invoice + @skip_charges = skip_charges + + super + end + + def call + return result if active_subscriptions.empty? && recurring + + create_generating_invoice unless invoice + invoice.status = :open if subscription_gated? + result.invoice = invoice + + fee_result = ActiveRecord::Base.transaction do + context = grace_period? ? :draft : :finalize + fee_result = Invoices::CalculateFeesService.call( + invoice:, + recurring:, + context: + ) + Invoices::ApplyInvoiceCustomSectionsService.call(invoice:) + + skip_payment_gating_for_zero_amount if subscription_payment_gated? && invoice.total_amount_cents.zero? && !invoice.tax_pending? + + set_invoice_generated_status unless invoice.pending? + invoice.save! + + # NOTE: We don't want to raise error and corrupt DB commit if there is tax error. + # In that case we want fees to stay attached to the invoice. There is retry action that will enable users + # to finalize invoice + fee_result.raise_if_error! unless tax_error?(fee_result) + invoice.reload + + flag_lifetime_usage_for_refresh + customer.flag_wallets_for_refresh if grace_period? + fee_result + end + result.non_invoiceable_fees = fee_result.non_invoiceable_fees + + # non-invoiceable fees are created the first time, regardless of grace period. + # Whenever the invoice is refreshed, the fees are not created again. (see `Fees::ChargeService.already_billed?`) + # The webhook are sent whenever non-invoiceable fees are found in result. + result.non_invoiceable_fees&.each do |fee| + SendWebhookJob.perform_after_commit("fee.created", fee) + end + + fill_daily_usage + + if tax_error?(fee_result) + if grace_period? + SendWebhookJob.perform_after_commit("invoice.drafted", invoice) + Utils::ActivityLog.produce_after_commit(invoice, "invoice.drafted") + end + + return result + end + + if subscription_gated? + Invoices::Payments::CreateService.call_async(invoice:) + elsif grace_period? + SendWebhookJob.perform_after_commit("invoice.drafted", invoice) + Utils::ActivityLog.produce_after_commit(invoice, "invoice.drafted") + else + unless invoice.closed? # we dont need to send the webhooks if the invoice was closed ( skip 0 invoice setting ) + SendWebhookJob.perform_after_commit("invoice.created", invoice) + Utils::ActivityLog.produce_after_commit(invoice, "invoice.created") + GenerateDocumentsJob.perform_after_commit(invoice:, notify: should_deliver_finalized_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_after_commit(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_after_commit(invoice:) if invoice.should_sync_hubspot_invoice? + Invoices::Payments::CreateService.call_async(invoice:) + Utils::SegmentTrack.invoice_created(invoice) + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + return result if invoicing_reason.to_sym == :subscription_periodic + + raise + rescue BaseService::ServiceFailure => e + raise unless e.code.to_s == "duplicated_invoices" + raise unless invoicing_reason.to_sym == :subscription_periodic + + result + rescue ActiveRecord::StaleObjectError, Customers::FailedToAcquireLock + raise + rescue => e + result.fail_with_error!(e) + end + + private + + attr_accessor :subscriptions, + :timestamp, + :invoicing_reason, + :recurring, + :customer, + :currency, + :invoice, + :skip_charges + + def active_subscriptions + @active_subscriptions ||= subscriptions.select(&:active?) + end + + def subscription_gated? + subscriptions.any?(&:gated?) + end + + def subscription_payment_gated? + subscriptions.any?(&:payment_gated?) + end + + def skip_payment_gating_for_zero_amount + gated = subscriptions.find(&:payment_gated?) + Subscriptions::ActivationRules::Payment::EvaluateService.call!( + rule: gated.activation_rules.payment.sole, + status: :satisfied + ) + Subscriptions::ActivationRules::ResolveSubscriptionStatusService.call!(subscription: gated) + end + + def create_generating_invoice + invoice_result = Invoices::CreateGeneratingService.call( + customer:, + invoice_type: :subscription, + invoicing_reason:, + currency:, + datetime: Time.zone.at(timestamp), + skip_charges: + ) do |invoice| + Invoices::CreateInvoiceSubscriptionService + .call(invoice:, subscriptions:, timestamp:, invoicing_reason:) + .raise_if_error! + end + + invoice_result.raise_if_error! + + @invoice = invoice_result.invoice + end + + def grace_period? + return false if subscription_gated? + + @grace_period ||= customer.applicable_invoice_grace_period.positive? + end + + def set_invoice_generated_status + return invoice.status = :draft if grace_period? + + Invoices::TransitionToFinalStatusService.call(invoice:) + end + + def should_deliver_finalized_email? + License.premium? && + customer.billing_entity.email_settings.include?("invoice.finalized") + end + + def flag_lifetime_usage_for_refresh + LifetimeUsages::FlagRefreshFromInvoiceService.call(invoice:).raise_if_error! + end + + def tax_error?(fee_result) + return false if fee_result.success? + + fee_result.error.is_a?(BaseService::UnknownTaxFailure) + end + + USAGE_TRACKABLE_REASONS = %i[subscription_periodic subscription_terminating].freeze + def fill_daily_usage + return unless invoice.organization.revenue_analytics_enabled? + + subscriptions = invoice + .invoice_subscriptions + .select { |is| USAGE_TRACKABLE_REASONS.include?(is.invoicing_reason.to_sym) } + .map(&:subscription) + return if subscriptions.blank? + + after_commit do + DailyUsages::FillFromInvoiceJob.perform_later(invoice:, subscriptions:) + end + end + end +end diff --git a/app/services/invoices/sync_salesforce_id_service.rb b/app/services/invoices/sync_salesforce_id_service.rb new file mode 100644 index 0000000..71af5dc --- /dev/null +++ b/app/services/invoices/sync_salesforce_id_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Invoices + class SyncSalesforceIdService < BaseService + def initialize(invoice:, params:) + @invoice = invoice + @params = params + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.nil? + return result.not_found_failure!(resource: "integration") unless integration + + integration_resource = IntegrationResource.find_or_initialize_by( + integration:, + external_id: params[:external_id], + syncable_id: invoice.id, + syncable_type: "Invoice", + resource_type: :invoice + ) { it.organization_id = invoice.organization_id } + + if integration_resource.new_record? + integration_resource.save! + end + + result.invoice = invoice + result + end + + private + + attr_reader :invoice, :params + + def integration + type = Integrations::BaseIntegration.integration_type("salesforce") + return @integration if defined?(@integration) && @integration&.type == type + code = params[:integration_code] + @integration = Integrations::BaseIntegration.find_by(type:, code:, organization: invoice.organization) + end + end +end diff --git a/app/services/invoices/transition_to_final_status_service.rb b/app/services/invoices/transition_to_final_status_service.rb new file mode 100644 index 0000000..d432e2e --- /dev/null +++ b/app/services/invoices/transition_to_final_status_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Invoices + class TransitionToFinalStatusService < BaseService + def initialize(invoice:) + @invoice = invoice + @customer = @invoice.customer + @billing_entity = @customer.billing_entity + super + end + + def call + result.invoice = invoice + + # Keep open for payment-gated invoices awaiting payment or tax resolution. + # Tax pending matters because totals are not yet computed — falling through + # would treat the invoice as zero-amount and finalize it prematurely. + return result if invoice.subscription_gated? && (invoice.total_amount_cents.positive? || invoice.tax_pending?) + + if should_finalize_invoice? + Invoices::FinalizeService.call!(invoice: invoice) + else + invoice.status = :closed + end + + result + end + + def should_finalize_invoice? + return true unless invoice.fees_amount_cents.zero? + customer_setting = customer.finalize_zero_amount_invoice + if customer_setting == "inherit" + billing_entity.finalize_zero_amount_invoice + else + customer_setting == "finalize" + end + end + + private + + attr_reader :invoice, :customer, :billing_entity + end +end diff --git a/app/services/invoices/update_all_invoice_issuing_date_from_billing_entity_service.rb b/app/services/invoices/update_all_invoice_issuing_date_from_billing_entity_service.rb new file mode 100644 index 0000000..4ccb25b --- /dev/null +++ b/app/services/invoices/update_all_invoice_issuing_date_from_billing_entity_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Invoices + class UpdateAllInvoiceIssuingDateFromBillingEntityService < BaseService + def initialize(billing_entity:, previous_issuing_date_settings:) + @billing_entity = billing_entity + @previous_issuing_date_settings = previous_issuing_date_settings + + super + end + + def call + billing_entity.invoices.draft.find_each do |invoice| + Invoices::UpdateIssuingDateFromBillingEntityJob.perform_later(invoice, previous_issuing_date_settings) + end + + result + end + + private + + attr_reader :billing_entity, :previous_issuing_date_settings + end +end diff --git a/app/services/invoices/update_issuing_date_from_billing_entity_service.rb b/app/services/invoices/update_issuing_date_from_billing_entity_service.rb new file mode 100644 index 0000000..096956a --- /dev/null +++ b/app/services/invoices/update_issuing_date_from_billing_entity_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module Invoices + class UpdateIssuingDateFromBillingEntityService < BaseService + Result = BaseResult[:invoice] + + def initialize(invoice:, previous_issuing_date_settings:) + @invoice = invoice + @previous_issuing_date_settings = previous_issuing_date_settings + super + end + + def call + result.invoice = invoice + return result unless invoice.draft? + + invoice.issuing_date = invoice.issuing_date + issuing_date_adjustment.days + invoice.expected_finalization_date = invoice.expected_finalization_date + grace_period_adjustment + invoice.applied_grace_period = invoice.customer.applicable_invoice_grace_period + invoice.payment_due_date = invoice.issuing_date + invoice.customer.applicable_net_payment_term.days + invoice.save! + + result + end + + private + + attr_reader :invoice, :previous_issuing_date_settings + + def issuing_date_adjustment + new_issuing_date_adjustment = new_issuing_date_service.issuing_date_adjustment + old_issuing_date_adjustment = old_issuing_date_service.issuing_date_adjustment + + new_issuing_date_adjustment - old_issuing_date_adjustment + end + + def grace_period_adjustment + new_grace_period = new_issuing_date_service.grace_period + old_grace_period = old_issuing_date_service.grace_period + + new_grace_period - old_grace_period + end + + def old_issuing_date_service + Invoices::IssuingDateService.new( + customer_settings: invoice.customer, + billing_entity_settings: previous_issuing_date_settings, + recurring: + ) + end + + def new_issuing_date_service + Invoices::IssuingDateService.new( + customer_settings: invoice.customer, + billing_entity_settings: invoice.billing_entity, + recurring: + ) + end + + def recurring + invoice.invoice_subscriptions.first&.recurring? + end + end +end diff --git a/app/services/invoices/update_service.rb b/app/services/invoices/update_service.rb new file mode 100644 index 0000000..6e66ca6 --- /dev/null +++ b/app/services/invoices/update_service.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +module Invoices + class UpdateService < BaseService + def initialize(invoice:, params:, webhook_notification: false) + @invoice = invoice + @params = params + @webhook_notification = webhook_notification + + super + end + + def call + return result.not_found_failure!(resource: "invoice") if invoice.nil? + return result.not_allowed_failure!(code: "metadata_on_draft_invoice") if invoice.draft? && params[:metadata] + + if params.key?(:payment_status) && !valid_payment_status?(params[:payment_status]) + return result.single_validation_failure!( + field: :payment_status, + error_code: "value_is_invalid" + ) + end + + unless valid_metadata_count?(metadata: params[:metadata]) + return result.single_validation_failure!( + field: :metadata, + error_code: "invalid_count" + ) + end + + old_payment_status = invoice.payment_status + invoice.payment_status = params[:payment_status] if params.key?(:payment_status) + + if invoice.draft? && (old_payment_status != invoice.payment_status) + return result.not_allowed_failure!(code: "payment_status_update_on_draft_invoice") + end + + if params.key?(:ready_for_payment_processing) && !invoice.voided? + invoice.ready_for_payment_processing = params[:ready_for_payment_processing] + end + + if params.key?(:total_paid_amount_cents) && params[:total_paid_amount_cents].present? + invoice.total_paid_amount_cents = params[:total_paid_amount_cents] + end + + ActiveRecord::Base.transaction do + if invoice.payment_overdue? && invoice.payment_succeeded? + invoice.payment_overdue = false + + if invoice.payment_requests.where.not(dunning_campaign_id: nil).exists? + invoice.customer.reset_dunning_campaign! + end + end + + invoice.save! + + Invoices::Metadata::UpdateService.call(invoice:, params: params[:metadata]) if params[:metadata] + end + + schedule_post_processing_jobs(old_payment_status) + + result.invoice = invoice + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice, :params, :webhook_notification + + def schedule_post_processing_jobs(old_payment_status) + if params.key?(:payment_status) + handle_prepaid_credits(params[:payment_status]) + handle_payment_gated_activation(params[:payment_status]) + update_fees_payment_status + if old_payment_status != params[:payment_status] && invoice.visible? + deliver_webhook + log_activity + end + end + update_hubspot_invoice if invoice.should_update_hubspot_invoice? + end + + def update_fees_payment_status + Invoices::UpdateFeesPaymentStatusJob.perform_after_commit(invoice) + end + + def update_hubspot_invoice + Integrations::Aggregator::Invoices::Hubspot::UpdateJob.perform_after_commit(invoice:) + end + + def log_activity + Utils::ActivityLog.produce_after_commit(invoice, "invoice.payment_status_updated") + end + + def valid_payment_status?(payment_status) + Invoice::PAYMENT_STATUS.include?(payment_status&.to_sym) + end + + def handle_prepaid_credits(payment_status) + return unless invoice.invoice_type&.to_sym == :credit + return unless %i[succeeded failed].include?(payment_status.to_sym) + + Invoices::PrepaidCreditJob.perform_after_commit(invoice, payment_status.to_sym) + end + + def handle_payment_gated_activation(payment_status) + return unless invoice.subscription_gated? + return unless %i[succeeded failed].include?(payment_status.to_sym) + + subscription = invoice.subscriptions.find(&:incomplete?) + return unless subscription + + Subscriptions::ActivationRules::Payment::ResolveJob + .perform_after_commit(subscription, invoice, payment_status.to_sym) + end + + def valid_metadata_count?(metadata:) + return true if metadata.blank? + return true if metadata.count <= ::Metadata::InvoiceMetadata::COUNT_PER_INVOICE + + false + end + + def deliver_webhook + return unless webhook_notification + + SendWebhookJob.perform_after_commit("invoice.payment_status_updated", invoice) + end + end +end diff --git a/app/services/invoices/void_service.rb b/app/services/invoices/void_service.rb new file mode 100644 index 0000000..da52dff --- /dev/null +++ b/app/services/invoices/void_service.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +module Invoices + class VoidService < BaseService + def initialize(invoice:, params: {}) + @invoice = invoice + @params = params + @generate_credit_note = ActiveModel::Type::Boolean.new.cast(params[:generate_credit_note]) + @refund_amount = params[:refund_amount].to_i + @credit_amount = params[:credit_amount].to_i + super + end + + activity_loggable( + action: "invoice.voided", + record: -> { invoice } + ) + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + return result.not_allowed_failure!(code: "not_voidable") if invoice.voided? + return result.forbidden_failure! unless generate_credit_note_allowed? + unless valid_credit_note_amounts? + return result.single_validation_failure!( + field: :credit_refund_amount, + error_code: "total_amount_exceeds_invoice_amount" + ) + end + + ActiveRecord::Base.transaction do + invoice.void! + flag_lifetime_usage_for_refresh + + invoice.credits.each do |credit| + AppliedCoupons::RecreditService.call!(credit:) if credit.applied_coupon_id.present? + end + + # when generate_credit_note, we count the wallet value on the creditable value + # so we don't need to recredit the wallet + if generate_credit_note + create_credit_notes! + else + invoice.wallet_transactions.outbound.each do |wallet_transaction| + WalletTransactions::RecreditService.call!(wallet_transaction:) if wallet_transaction.wallet.active? + end + end + end + + unless invoice.voided? + return result.service_failure!(code: "void_operation_failed", message: "Failed to void the invoice") + end + + result.invoice = invoice + SendWebhookJob.perform_later("invoice.voided", result.invoice) + Invoices::ProviderTaxes::VoidJob.perform_later(invoice:) + Integrations::Aggregator::Invoices::Hubspot::UpdateJob.perform_later(invoice:) if invoice.should_update_hubspot_invoice? + + result + rescue AASM::InvalidTransition => _e + result.not_allowed_failure!(code: "not_voidable") + end + + private + + attr_reader :invoice, :params, :generate_credit_note, :credit_amount, :refund_amount + + def generate_credit_note_allowed? + return true unless generate_credit_note + License.premium? + end + + def flag_lifetime_usage_for_refresh + LifetimeUsages::FlagRefreshFromInvoiceService.call(invoice:).raise_if_error! + end + + def valid_credit_note_amounts? + return true unless generate_credit_note + + return false if credit_amount > invoice.creditable_amount_cents + return false if refund_amount > invoice.refundable_amount_cents + return false if (credit_amount + refund_amount) > invoice.creditable_amount_cents + + true + end + + def create_credit_notes! + total_amount = credit_amount + refund_amount + + unless total_amount.zero? + estimate_result = estimate_credit_note_for_target_credit(invoice: invoice, target_credit_cents: total_amount) + + result = CreditNotes::CreateService.call!( + invoice: invoice, + reason: :other, + description: "Credit note created due to voided invoice #{invoice.id}", + credit_amount_cents: credit_amount, + refund_amount_cents: refund_amount, + items: estimate_result + ) + end + + remaining_amount = invoice.reload.creditable_amount_cents.round + if remaining_amount.positive? + estimate_result = estimate_credit_note_for_target_credit(invoice: invoice, target_credit_cents: remaining_amount) + estimate_result = CreditNotes::EstimateService.call!(invoice: invoice, items: estimate_result) + + credit_note_to_void = CreditNotes::CreateService.call!( + invoice: invoice, + reason: :other, + description: "Credit note created due to voided invoice #{invoice.id}", + credit_amount_cents: estimate_result.credit_note.credit_amount_cents, + items: estimate_result.credit_note.items.map { |item| {fee_id: item.fee_id, amount_cents: item.amount_cents} } + ) + + CreditNotes::VoidService.call!(credit_note: credit_note_to_void.credit_note) + end + + result + end + + def estimate_credit_note_for_target_credit(invoice:, target_credit_cents:) + base_total = invoice.sub_total_including_taxes_amount_cents.to_f + ratio = target_credit_cents.to_f / base_total + + invoice.fees.map do |fee| + { + fee_id: fee.id, + amount_cents: (fee.amount_cents * ratio) + } + end + end + end +end diff --git a/app/services/lifetime_usages/calculate_service.rb b/app/services/lifetime_usages/calculate_service.rb new file mode 100644 index 0000000..3d6d25b --- /dev/null +++ b/app/services/lifetime_usages/calculate_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module LifetimeUsages + class CalculateService < BaseService + def initialize(lifetime_usage:, current_usage: nil) + @lifetime_usage = lifetime_usage + @current_usage = current_usage + super + end + + def call + result.lifetime_usage = lifetime_usage + + # clear boolean flags without recalculating if the subscription is not active. + if !lifetime_usage.subscription.active? + lifetime_usage.update!(recalculate_current_usage: false, recalculate_invoiced_usage: false) + return result + end + + if lifetime_usage.recalculate_invoiced_usage + lifetime_usage.invoiced_usage_amount_cents = calculate_invoiced_usage_amount_cents + lifetime_usage.recalculate_invoiced_usage = false + lifetime_usage.invoiced_usage_amount_refreshed_at = Time.current + end + + lifetime_usage.current_usage_amount_cents = calculate_current_usage_amount_cents + lifetime_usage.recalculate_current_usage = false + lifetime_usage.current_usage_amount_refreshed_at = Time.current + + lifetime_usage.save! + + result + end + + private + + delegate :subscription, :organization, to: :lifetime_usage + + def calculate_invoiced_usage_amount_cents + subscription_ids = organization.subscriptions + .where(external_id: subscription.external_id, subscription_at: subscription.subscription_at) + .where(canceled_at: nil) + .select(:id) + + invoices = organization.invoices.subscription + .where(status: %i[finalized draft]) + .joins(:invoice_subscriptions) + .where(invoice_subscriptions: {subscription_id: subscription_ids}) + invoices.sum { |invoice| invoice.fees.charge.sum(:amount_cents) } + end + + def calculate_current_usage_amount_cents + current_usage.amount_cents + end + + def current_usage + @current_usage ||= Invoices::CustomerUsageService.call( + customer: subscription.customer, + subscription: subscription, + apply_taxes: false + ).usage + end + + attr_accessor :lifetime_usage + end +end diff --git a/app/services/lifetime_usages/check_thresholds_service.rb b/app/services/lifetime_usages/check_thresholds_service.rb new file mode 100644 index 0000000..d76c067 --- /dev/null +++ b/app/services/lifetime_usages/check_thresholds_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module LifetimeUsages + class CheckThresholdsService < BaseService + Result = BaseResult[:invoice] + + def initialize(lifetime_usage:) + @lifetime_usage = lifetime_usage + + super + end + + def call + return result unless subscription.active? + + passed_thresholds = LifetimeUsages::UsageThresholds::CheckService.call!(lifetime_usage:, progressive_billed_amount:).passed_thresholds + + if passed_thresholds.any? + invoice_result = Invoices::ProgressiveBillingService.call(sorted_usage_thresholds: passed_thresholds, lifetime_usage:) + # If there is tax error, invoice is marked as failed and it can be retried manually + invoice_result.raise_if_error! unless tax_error?(invoice_result) + result.invoice = invoice_result.invoice + # We want to send the webhook after the invoice is generated, because the job might have been scheduled multiple times + passed_thresholds.each do |usage_threshold| + SendWebhookJob.perform_later("subscription.usage_threshold_reached", subscription, usage_threshold:) + end + end + + result + end + + private + + attr_reader :lifetime_usage + delegate :subscription, to: :lifetime_usage + + def progressive_billed_amount + Subscriptions::ProgressiveBilledAmount.call!(subscription:).progressive_billed_amount + end + + def tax_error?(result) + return false if result.success? + + result.error.is_a?(BaseService::UnknownTaxFailure) + end + end +end diff --git a/app/services/lifetime_usages/find_last_and_next_thresholds_service.rb b/app/services/lifetime_usages/find_last_and_next_thresholds_service.rb new file mode 100644 index 0000000..0a677d0 --- /dev/null +++ b/app/services/lifetime_usages/find_last_and_next_thresholds_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module LifetimeUsages + class FindLastAndNextThresholdsService < BaseService + def initialize(lifetime_usage:) + @lifetime_usage = lifetime_usage + + super + end + + def call + completion_result = LifetimeUsages::UsageThresholdsCompletionService.call(lifetime_usage:).raise_if_error! + + index = completion_result.usage_thresholds.rindex { |h| h[:reached_at].present? } + passed_threshold = nil + next_threshold = nil + + if index + passed_threshold = completion_result.usage_thresholds[index] + next_threshold = completion_result.usage_thresholds[index + 1] + else + next_threshold = completion_result.usage_thresholds.first + end + + result.last_threshold_amount_cents = passed_threshold&.[](:amount_cents) + result.next_threshold_amount_cents = next_threshold&.[](:amount_cents) + result.next_threshold_ratio = next_threshold&.[](:completion_ratio) + result + end + + private + + attr_reader :lifetime_usage + end +end diff --git a/app/services/lifetime_usages/flag_refresh_from_invoice_service.rb b/app/services/lifetime_usages/flag_refresh_from_invoice_service.rb new file mode 100644 index 0000000..91600ad --- /dev/null +++ b/app/services/lifetime_usages/flag_refresh_from_invoice_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module LifetimeUsages + class FlagRefreshFromInvoiceService < BaseService + def initialize(invoice:) + @invoice = invoice + super + end + + def call + return result unless invoice.subscription? + return result unless should_flag_refresh_from_invoice? + + result.lifetime_usages = [] + + invoice.subscriptions.each do |subscription| + lifetime_usage = subscription.lifetime_usage + lifetime_usage ||= subscription.build_lifetime_usage(organization: subscription.organization) + lifetime_usage.recalculate_invoiced_usage = true + lifetime_usage.save! + + result.lifetime_usages << lifetime_usage + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :invoice + + def should_flag_refresh_from_invoice? + invoice.organization.lifetime_usage_enabled? || invoice.subscriptions.any?(&:has_progressive_billing?) + end + end +end diff --git a/app/services/lifetime_usages/flag_refresh_from_plan_update_service.rb b/app/services/lifetime_usages/flag_refresh_from_plan_update_service.rb new file mode 100644 index 0000000..99ad1b9 --- /dev/null +++ b/app/services/lifetime_usages/flag_refresh_from_plan_update_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module LifetimeUsages + class FlagRefreshFromPlanUpdateService < BaseService + Result = BaseResult[:updated_lifetime_usages] + + def initialize(plan:) + @plan = plan + super + end + + def call + result.updated_lifetime_usages = LifetimeUsage + .where(subscription_id: plan.subscriptions.active.select(:id)) + .update_all(recalculate_invoiced_usage: true) # rubocop:disable Rails/SkipsModelValidations + result + end + + private + + attr_reader :plan + end +end diff --git a/app/services/lifetime_usages/update_service.rb b/app/services/lifetime_usages/update_service.rb new file mode 100644 index 0000000..0906380 --- /dev/null +++ b/app/services/lifetime_usages/update_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module LifetimeUsages + class UpdateService < BaseService + def initialize(lifetime_usage:, params:) + @lifetime_usage = lifetime_usage + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "lifetime_usage") unless lifetime_usage + + lifetime_usage.update!(historical_usage_amount_cents: params[:external_historical_usage_amount_cents]) + + result.lifetime_usage = lifetime_usage + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :lifetime_usage, :params + end +end diff --git a/app/services/lifetime_usages/usage_thresholds/check_service.rb b/app/services/lifetime_usages/usage_thresholds/check_service.rb new file mode 100644 index 0000000..f8531cf --- /dev/null +++ b/app/services/lifetime_usages/usage_thresholds/check_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module LifetimeUsages + module UsageThresholds + class CheckService < BaseService + Result = BaseResult[:passed_thresholds] + + def initialize(lifetime_usage:, progressive_billed_amount: 0) + @lifetime_usage = lifetime_usage + @progressive_billed_amount = progressive_billed_amount + @thresholds = lifetime_usage.subscription.applicable_usage_thresholds + super + end + + def call + result.passed_thresholds = [] + return result unless thresholds.any? + + fixed_thresholds = thresholds.not_recurring.order(:amount_cents) + # There is only 1 recurring threshold, `first` will return it or nil + recurring_threshold = thresholds.recurring.first + + # Calculate the actual current usage, we need to substract the already progressively billed amount + # as we might be passing the recurring threshold multiple times per period + actual_current_usage = lifetime_usage.current_usage_amount_cents - progressive_billed_amount + # we can end up in a situation where this goes below zero, in that case no thresholds are passed + return result if actual_current_usage.negative? + invoiced_usage = lifetime_usage.historical_usage_amount_cents + lifetime_usage.invoiced_usage_amount_cents + progressive_billed_amount + + # Get the largest threshold amount + # in case there are no fixed_thresholds, this will return nil which to_i will convert to 0 + largest_threshold_amount = fixed_thresholds.maximum(:amount_cents).to_i + total_usage = invoiced_usage + actual_current_usage + + # First check the fixed thresholds + if invoiced_usage < largest_threshold_amount + # we're below some thresholds, filter out those that we've already invoiced. + # and keep those that we've passed based on total_usage. + result.passed_thresholds += fixed_thresholds.select do |threshold| + threshold.amount_cents > invoiced_usage && threshold.amount_cents <= total_usage + end + if recurring_threshold + if total_usage - largest_threshold_amount >= recurring_threshold.amount_cents + result.passed_thresholds << recurring_threshold + end + end + elsif recurring_threshold + recurring_remainder = invoiced_usage % recurring_threshold.amount_cents + + if actual_current_usage + recurring_remainder >= recurring_threshold.amount_cents + result.passed_thresholds << recurring_threshold + end + end + + result + end + + private + + attr_reader :lifetime_usage, :thresholds, :progressive_billed_amount + end + end +end diff --git a/app/services/lifetime_usages/usage_thresholds_completion_service.rb b/app/services/lifetime_usages/usage_thresholds_completion_service.rb new file mode 100644 index 0000000..b795278 --- /dev/null +++ b/app/services/lifetime_usages/usage_thresholds_completion_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module LifetimeUsages + class UsageThresholdsCompletionService < BaseService + def initialize(lifetime_usage:) + @lifetime_usage = lifetime_usage + @usage_thresholds = lifetime_usage.subscription.applicable_usage_thresholds + + super + end + + def call + result.usage_thresholds = [] + return result unless usage_thresholds.any? + + largest_non_recurring_threshold_amount_cents = usage_thresholds.not_recurring.order(amount_cents: :desc).first&.amount_cents || 0 + recurring_threshold = usage_thresholds.recurring.first + + # split non-recurring thresholds into 2 groups: passed and not passed + passed_thresholds, not_passed_thresholds = usage_thresholds.not_recurring.order(amount_cents: :asc).partition do |threshold| + threshold.amount_cents <= lifetime_usage.total_amount_cents + end + + subscription_ids = organization.subscriptions + .where(external_id: subscription.external_id, subscription_at: subscription.subscription_at) + .where(canceled_at: nil) + .ids + + # add all passed thresholds to the result, completion rate is 100% + passed_thresholds.each do |threshold| + # fallback to Time.current if the invoice is not yet generated + reached_at = AppliedUsageThreshold + .where(usage_threshold: threshold) + .joins(invoice: :invoice_subscriptions) + .where(invoice_subscriptions: {subscription_id: subscription_ids}).maximum(:created_at) || Time.current + + add_usage_threshold threshold, threshold.amount_cents, 1.0, reached_at + end + + last_passed_threshold_amount = passed_thresholds.last&.amount_cents || 0 + + # If we have a not-passed threshold that means we can ignore the recurring one + # if not_passed_thresholds is empty, we need to check the recurring one. + if not_passed_thresholds.empty? + if recurring_threshold + add_recurring_threshold(recurring_threshold, last_passed_threshold_amount, subscription_ids) + end + else + threshold = not_passed_thresholds.shift + add_usage_threshold threshold, threshold.amount_cents, (lifetime_usage.total_amount_cents - last_passed_threshold_amount).fdiv(threshold.amount_cents - last_passed_threshold_amount), nil + + not_passed_thresholds.each do |threshold| + add_usage_threshold threshold, threshold.amount_cents, 0.0, nil + end + + # add recurring at the end if it's there + if recurring_threshold + add_usage_threshold recurring_threshold, largest_non_recurring_threshold_amount_cents + recurring_threshold.amount_cents, 0.0, nil + end + end + + result + end + + private + + attr_reader :lifetime_usage, :usage_thresholds + delegate :organization, :subscription, to: :lifetime_usage + + def add_usage_threshold(usage_threshold, amount_cents, completion_ratio, reached_at) + result.usage_thresholds << { + usage_threshold:, + amount_cents:, + completion_ratio:, + reached_at: + } + end + + def add_recurring_threshold(recurring_threshold, last_passed_threshold_amount, subscription_ids) + recurring_remainder = (last_passed_threshold_amount + lifetime_usage.total_amount_cents) % recurring_threshold.amount_cents + + applied_thresholds = AppliedUsageThreshold + .where(usage_threshold: recurring_threshold) + .joins(invoice: :invoice_subscriptions) + .where(invoice_subscriptions: {subscription_id: subscription_ids}) + .order(lifetime_usage_amount_cents: :asc) + + occurence = (lifetime_usage.total_amount_cents - last_passed_threshold_amount) / recurring_threshold.amount_cents + occurence.times do |i| + amount_cents = last_passed_threshold_amount + ((i + 1) * recurring_threshold.amount_cents) + reached_at = applied_thresholds.find { |applied| applied.lifetime_usage_amount_cents >= amount_cents }&.created_at || Time.current + + add_usage_threshold recurring_threshold, amount_cents, 1.0, reached_at + end + add_usage_threshold recurring_threshold, lifetime_usage.total_amount_cents - recurring_remainder + recurring_threshold.amount_cents, recurring_remainder.fdiv(recurring_threshold.amount_cents), nil + end + end +end diff --git a/app/services/memberships/create_service.rb b/app/services/memberships/create_service.rb new file mode 100644 index 0000000..7904be9 --- /dev/null +++ b/app/services/memberships/create_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Memberships + class CreateService < ::BaseService + def initialize(user:, organization:) + @user = user + @organization = organization + + super + end + + def call + return result.not_found_failure!(resource: "user") unless user + return result.not_found_failure!(resource: "organization") unless organization + + result.membership = Membership.create!(user:, organization:) + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :user, :organization + end +end diff --git a/app/services/memberships/revoke_service.rb b/app/services/memberships/revoke_service.rb new file mode 100644 index 0000000..b88ed93 --- /dev/null +++ b/app/services/memberships/revoke_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Memberships + class RevokeService < BaseService + def initialize(user:, membership:) + @user = user + @membership = membership + + super + end + + def call + return result.not_found_failure!(resource: "membership") unless membership + return result.not_allowed_failure!(code: "cannot_revoke_own_membership") if user.id == membership.user.id + return result.not_allowed_failure!(code: "last_admin") if membership.admin? && membership.organization.admin_membership_roles.count == 1 + + membership.mark_as_revoked! + register_security_log + + result.membership = membership + result + end + + private + + attr_reader :user, :membership + + def register_security_log + Utils::SecurityLog.produce( + organization: membership.organization, + log_type: "user", + log_event: "user.deleted", + user: user, + resources: {email: membership.user.email} + ) + end + end +end diff --git a/app/services/memberships/update_service.rb b/app/services/memberships/update_service.rb new file mode 100644 index 0000000..dc2029e --- /dev/null +++ b/app/services/memberships/update_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Memberships + class UpdateService < BaseService + Result = BaseResult[:membership] + + def initialize(user:, membership:, params:) + @user = user + @membership = membership + @params = params + + super + end + + def call + ActiveRecord::Base.transaction do + return result.not_found_failure!(resource: "membership") unless membership + return result.not_found_failure!(resource: "role") if new_roles.blank? + return result.forbidden_failure!(code: "cannot_grant_admin") if granting_admin_without_being_admin? + return result.not_allowed_failure!(code: "last_admin") if last_admin_demotion? + + roles_to_remove = old_roles - new_roles + (new_roles - old_roles).each { |role| MembershipRole.create!(organization:, membership:, role:) } + MembershipRole.where(membership:, role: roles_to_remove).discard_all! if roles_to_remove.present? + end + + register_security_log + + result.membership = membership.reload + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :user, :membership, :params + + def register_security_log + old_codes = old_roles.map(&:code) + new_codes = new_roles.map(&:code) + entry = {} + deleted = old_codes - new_codes + added = new_codes - old_codes + entry[:deleted] = deleted if deleted.present? + entry[:added] = added if added.present? + + Utils::SecurityLog.produce( + organization: organization, + log_type: "user", + log_event: "user.role_edited", + resources: { + email: membership.user.email, + roles: entry + } + ) + end + + def organization + @organization ||= membership.organization + end + + def new_roles + @new_roles ||= Role.with_code(*params[:roles]).with_organization(membership.organization_id) + end + + def old_roles + @old_roles ||= membership.roles + end + + def acting_membership + @acting_membership ||= organization.memberships.active.find_by(user:) + end + + def granting_admin_without_being_admin? + new_roles.any?(&:admin?) && !acting_membership&.admin? + end + + def last_admin_demotion? + membership.admin? && new_roles.none?(&:admin?) && organization.admin_membership_roles.count == 1 + end + end +end diff --git a/app/services/metadata/delete_item_key_service.rb b/app/services/metadata/delete_item_key_service.rb new file mode 100644 index 0000000..ac3bc9f --- /dev/null +++ b/app/services/metadata/delete_item_key_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Metadata + # Remove a key from an existing metadata. + # Return an error result if the metadata has already been deleted. + class DeleteItemKeyService < BaseService + Result = BaseResult[:item, :metadata_changed] + + # @option [Metadata::MetadataItem] :item The metadata item to modify + # @option [#to_s] :key The key of the metadata item to delete + def initialize(item:, key:) + @item = item + @key = key.to_s + + super() + end + + def call + item.update!(value: item.value.to_h.except(key)) + + result.item = item + result.metadata_changed = item.previous_changes.any? + result + rescue ActiveRecord::RecordNotFound + result.not_found_failure!(resource: "metadata_item") + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :item, :key + end +end diff --git a/app/services/metadata/update_item_service.rb b/app/services/metadata/update_item_service.rb new file mode 100644 index 0000000..369e4f1 --- /dev/null +++ b/app/services/metadata/update_item_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Metadata + # Updates the metadata of the record with new content, creates it if absent, or deletes it. + # + # It behaves differently based on the `partial` flag and the content of the old and new value: + # ``` + # ----------+-----------+---------+------------------------- + # old value | new value | partial | action + # ----------+-----------+---------+------------------------- + # nil | nil | any | no-op + # nil | non-nil | any | set new value + # non-nil | nil | false | delete metadata item + # non-nil | nil | true | no-op + # non-nil | non-nil | false | replace with new value + # non-nil | non-nil | true | merge new value + # ----------+-----------+---------+------------------------- + # ``` + # + # The service can change the metadata in the database, + # and its `result` contains the updated metadata item + # to check if the operation was successful. + class UpdateItemService < BaseService + Result = BaseResult[:metadata, :metadata_changed] + + # @param [ActiveRecord::Base] owner The record whose metadata is to be updated + # @option [#to_h] :value The new content of the metadata item + # @option [Boolean] :partial Whether to merge the existing content (replace by default) + def initialize(owner:, value:, partial: false) + @value = value + @owner = owner + @partial = partial + + super() + end + + def call + if create_metadata? + owner.create_metadata!(organization_id:, value:) + elsif replace_metadata? + metadata.update!(value:) + elsif merge_metadata? + metadata.update!(value: metadata.value.merge(value)) + elsif delete_metadata? + metadata.destroy! + end + + result.metadata = metadata + result.metadata_changed = (metadata&.previous_changes&.any? || metadata&.destroyed?).present? + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :owner, :value, :partial + delegate :metadata, :organization_id, to: :owner + + def create_metadata? + owner.metadata.blank? && !value.nil? && (value.present? || !partial) + end + + def replace_metadata? + owner.metadata.present? && !partial && !value.nil? + end + + def merge_metadata? + owner.metadata.present? && partial && value.present? + end + + def delete_metadata? + owner.metadata.present? && !partial && value.nil? + end + end +end diff --git a/app/services/middlewares/activity_log_middleware.rb b/app/services/middlewares/activity_log_middleware.rb new file mode 100644 index 0000000..a80905f --- /dev/null +++ b/app/services/middlewares/activity_log_middleware.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Middlewares + class ActivityLogMiddleware < BaseMiddleware + def call(&block) + if produce_activity_log? + log_kwargs = {after_commit:}.compact + + case action + when /updated/ + Utils::ActivityLog.produce(record, action, **log_kwargs) { call_next(&block) } + + else + call_next(&block).tap do |result| + Utils::ActivityLog.produce(record, action, **log_kwargs) { result } + end + end + else + call_next(&block) + end + end + + def produce_activity_log? + return false if kwargs.nil? + + service_instance.instance_exec(&kwargs[:condition]) + end + + def action + kwargs[:action] + end + + def after_commit + kwargs[:after_commit] + end + + def record + service_instance.instance_exec(&kwargs[:record]) + end + end +end diff --git a/app/services/middlewares/already_added_error.rb b/app/services/middlewares/already_added_error.rb new file mode 100644 index 0000000..1293041 --- /dev/null +++ b/app/services/middlewares/already_added_error.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Middlewares + class AlreadyAddedError < StandardError + def initialize(middleware_class, service_class) + super("Middleware #{middleware_class} is already present on #{service_class}") + end + end +end diff --git a/app/services/middlewares/base_middleware.rb b/app/services/middlewares/base_middleware.rb new file mode 100644 index 0000000..390e348 --- /dev/null +++ b/app/services/middlewares/base_middleware.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Middlewares + class BaseMiddleware + def initialize(service_instance, next_middleware, *args, **kwargs) + @service_instance = service_instance + @next_middleware = next_middleware + @args = args + @kwargs = kwargs + end + + def call(&block) + before_call + + result = call_next(&block) + + after_call(result) + + result + rescue => e + handle_error(e) + + raise + end + + attr_reader :service_instance, :next_middleware, :args, :kwargs + + private + + def call_next(&block) + @next_middleware.call(&block) + end + + def before_call + # Override this method in subclasses + end + + def after_call(result) + # Override this method in subclasses + end + + def handle_error(error) + # Override this method in subclasses + end + end +end diff --git a/app/services/middlewares/datadog_middleware.rb b/app/services/middlewares/datadog_middleware.rb new file mode 100644 index 0000000..67b1b30 --- /dev/null +++ b/app/services/middlewares/datadog_middleware.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Middlewares + class DatadogMiddleware < BaseMiddleware + def before_call + @span = Datadog::Tracing.trace("service.call", service: service_name, resource: service_instance.class.name) + end + + def after_call(result) + return if @span.nil? + + if result.success? + @span.set_tag("result.status", "success") + else + @span.set_tag("result.status", "failure") + @span.record_exception(result.error) + end + + @span.finish + end + + def handle_error(error) + return if @span.nil? + + @span.set_tag("result.status", "failure") + @span.record_exception(error) + @span.finish + end + + private + + def service_name + @service_name ||= ENV["DD_SERVICE_NAME"] || "lago-api" + end + end +end diff --git a/app/services/middlewares/log_tracer_middleware.rb b/app/services/middlewares/log_tracer_middleware.rb new file mode 100644 index 0000000..2aa2a1b --- /dev/null +++ b/app/services/middlewares/log_tracer_middleware.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Middlewares + class LogTracerMiddleware < BaseMiddleware + def call(&block) + LagoTracer.in_span("#{service_instance.class.name}#call") do + call_next(&block) + end + end + end +end diff --git a/app/services/order_forms/premium.rb b/app/services/order_forms/premium.rb new file mode 100644 index 0000000..0014f1b --- /dev/null +++ b/app/services/order_forms/premium.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module OrderForms + module Premium + extend ActiveSupport::Concern + + private + + def order_forms_enabled?(organization) + License.premium? && organization.feature_flag_enabled?(:order_forms) + end + end +end diff --git a/app/services/organizations/create_service.rb b/app/services/organizations/create_service.rb new file mode 100644 index 0000000..47f094d --- /dev/null +++ b/app/services/organizations/create_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Organizations + class CreateService < BaseService + def initialize(params) + @params = params + super + end + + def call + organization = Organization.new( + params.slice(:name, :document_numbering, :premium_integrations) + ) + + ActiveRecord::Base.transaction do + # TODO: remove when we do not support document_numbering per organization + organization.save! + organization.api_keys.create! + if params[:document_numbering] + params[:document_numbering] = "per_billing_entity" if params[:document_numbering] == "per_organization" + end + + # NOTE: ensure first billing entity has the same id as the organization to ease the migration to multi entities. + params[:id] = organization.id + + params[:code] = params[:name]&.parameterize(separator: "_") if params[:code].blank? + BillingEntities::CreateService.call(organization: organization, params: params) + end + + result.organization = organization + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :params + end +end diff --git a/app/services/organizations/update_service.rb b/app/services/organizations/update_service.rb new file mode 100644 index 0000000..1ecb361 --- /dev/null +++ b/app/services/organizations/update_service.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Organizations + class UpdateService < BaseService + Result = BaseResult[:organization] + + def initialize(organization:, params:, user: nil) + @organization = organization + @params = params + @user = user + + super(nil) + end + + def call + organization.email = params[:email] if params.key?(:email) + organization.legal_name = params[:legal_name] if params.key?(:legal_name) + organization.legal_number = params[:legal_number] if params.key?(:legal_number) + if params.key?(:tax_identification_number) + organization.tax_identification_number = params[:tax_identification_number] + end + organization.address_line1 = params[:address_line1] if params.key?(:address_line1) + organization.address_line2 = params[:address_line2] if params.key?(:address_line2) + organization.zipcode = params[:zipcode] if params.key?(:zipcode) + organization.city = params[:city] if params.key?(:city) + organization.state = params[:state] if params.key?(:state) + organization.country = params[:country]&.upcase if params.key?(:country) + organization.default_currency = params[:default_currency]&.upcase if params.key?(:default_currency) + organization.document_number_prefix = params[:document_number_prefix] if params.key?(:document_number_prefix) + organization.slug = params[:slug]&.strip&.downcase if params.key?(:slug) + organization.finalize_zero_amount_invoice = params[:finalize_zero_amount_invoice] if params.key?(:finalize_zero_amount_invoice) + organization.net_payment_term = params[:net_payment_term] if params.key?(:net_payment_term) + organization.document_numbering = params[:document_numbering] if params.key?(:document_numbering) + if params.key?(:authentication_methods) + deletions = organization.authentication_methods - params[:authentication_methods] + additions = params[:authentication_methods] - organization.authentication_methods + organization.authentication_methods = params[:authentication_methods] + + if organization.authentication_methods_changed? && user + after_commit do + OrganizationMailer.with( + organization:, + user:, + additions:, + deletions: + ).authentication_methods_updated.deliver_later + end + end + end + + billing = params[:billing_configuration]&.to_h || {} + organization.invoice_footer = billing[:invoice_footer] if billing.key?(:invoice_footer) + organization.document_locale = billing[:document_locale] if billing.key?(:document_locale) + + ActiveRecord::Base.transaction do + handle_eu_tax_management(params[:eu_tax_management]) if params.key?(:eu_tax_management) + + if params.key?(:webhook_url) + webhook_endpoint = organization.webhook_endpoints.first_or_initialize + webhook_endpoint.update!(webhook_url: params[:webhook_url]) + end + + # TODO: only updates the organization grace period, + # it does not update related invoices payment due date, etc + # this is handled at the billing_entity level. + # Remove it when fully migrated to billing_entity. + if License.premium? && billing.key?(:invoice_grace_period) + organization.invoice_grace_period = billing[:invoice_grace_period] + end + + assign_premium_attributes + handle_base64_logo if params.key?(:logo) + + organization.save! + update_billing_entity_result = + BillingEntities::UpdateService.call(billing_entity: organization.default_billing_entity, params: params) + update_billing_entity_result.raise_if_error! + end + + ApiKeys::CacheService.expire_all_cache(organization) + + result.organization = organization + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :organization, :params, :user + + def assign_premium_attributes + return unless License.premium? + + organization.timezone = params[:timezone] if params.key?(:timezone) + organization.email_settings = params[:email_settings] if params.key?(:email_settings) + end + + def handle_base64_logo + return if params[:logo].blank? + + base64_data = params[:logo].split(",") + data = base64_data.second + decoded_base_64_data = Base64.decode64(data) + + # NOTE: data:image/png;base64, should give image/png content_type + content_type = base64_data.first.split(";").first.split(":").second + + organization.logo.attach( + io: StringIO.new(decoded_base_64_data), + filename: "logo", + content_type: + ) + end + + def handle_eu_tax_management(eu_tax_management) + # Note: Actual EU tax management is handled in the billing_entity update service + organization.eu_tax_management = eu_tax_management + + return unless eu_tax_management + + unless organization.eu_vat_eligible? + result.single_validation_failure!(error_code: "org_must_be_in_eu", field: :eu_tax_management) + .raise_if_error! + end + end + end +end diff --git a/app/services/password_resets/create_service.rb b/app/services/password_resets/create_service.rb new file mode 100644 index 0000000..0267896 --- /dev/null +++ b/app/services/password_resets/create_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module PasswordResets + class CreateService < BaseService + def initialize(user:) + @user = user + + super + end + + def call + return result.not_found_failure!(resource: "user") if user.blank? + + password_reset = PasswordReset.create!( + user:, + token: SecureRandom.hex(20), + expire_at: Time.current + 30.minutes + ) + + PasswordResetMailer.with(password_reset:).requested.deliver_later + register_security_log + + result.id = password_reset.id + + result + end + + private + + attr_reader :user + + def register_security_log + user.memberships.active.each do |membership| + Utils::SecurityLog.produce( + organization: membership.organization, + log_type: "user", + log_event: "user.password_reset_requested", + user: user, + resources: {email: user.email} + ) + end + end + end +end diff --git a/app/services/password_resets/reset_service.rb b/app/services/password_resets/reset_service.rb new file mode 100644 index 0000000..fe8768d --- /dev/null +++ b/app/services/password_resets/reset_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module PasswordResets + class ResetService < BaseService + def initialize(token:, new_password:) + @token = token + @new_password = new_password + + super + end + + def call + if new_password.blank? + return result.single_validation_failure!(field: :new_password, error_code: "missing_password") + end + return result.single_validation_failure!(field: :token, error_code: "missing_token") if token.blank? + + password_reset = PasswordReset.where("expire_at > ?", Time.current).find_by(token:) + return result.not_found_failure!(resource: "password_reset") if password_reset.blank? + + user = password_reset.user + + result = ActiveRecord::Base.transaction do + user.password = new_password + user.save! + + UsersService + .new + .login(user.email, new_password) + .tap { password_reset.destroy! } + end + + register_security_log(user) + result + end + + private + + attr_reader :token, :new_password + + def register_security_log(user) + user.memberships.active.each do |membership| + Utils::SecurityLog.produce( + organization: membership.organization, + log_type: "user", + log_event: "user.password_edited", + user: user, + resources: {email: user.email} + ) + end + end + end +end diff --git a/app/services/payment_intents/fetch_service.rb b/app/services/payment_intents/fetch_service.rb new file mode 100644 index 0000000..ef474be --- /dev/null +++ b/app/services/payment_intents/fetch_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module PaymentIntents + class FetchService < BaseService + Result = BaseResult[:payment_intent] + + def initialize(invoice:) + @invoice = invoice + super + end + + def call + return result.not_found_failure!(resource: "invoice") unless invoice + + PaymentIntent.awaiting_expiration.find_by(invoice:)&.expired! + payment_intent = PaymentIntent.non_expired.find_or_create_by!(invoice:, organization: invoice.organization) + + if payment_intent.payment_url.blank? + payment_url_result = Invoices::Payments::PaymentProviders::Factory + .new_instance(invoice:) + .generate_payment_url(payment_intent) + + payment_url_result.raise_if_error! + + if payment_url_result.payment_url.blank? + return result.single_validation_failure!(error_code: "payment_provider_error") + end + + payment_intent.update!(payment_url: payment_url_result.payment_url) + end + + result.payment_intent = payment_intent + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :invoice + end +end diff --git a/app/services/payment_methods/create_from_provider_service.rb b/app/services/payment_methods/create_from_provider_service.rb new file mode 100644 index 0000000..ed4ea63 --- /dev/null +++ b/app/services/payment_methods/create_from_provider_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module PaymentMethods + class CreateFromProviderService < BaseService + def initialize(customer:, params:, provider_method_id:, payment_provider_id: nil, payment_provider_customer: nil, details: nil) + @customer = customer + @params = params || {} + @provider_method_id = provider_method_id + @payment_provider_id = payment_provider_id + @payment_provider_customer = payment_provider_customer + @details = details + + super + end + + def call + return result.not_found_failure!(resource: "customer") unless customer + + payment_method = customer.payment_methods.build.tap do |payment_method| + payment_method.organization = customer.organization + payment_method.payment_provider_customer = payment_provider_customer + payment_method.provider_method_type = provider_method_type + payment_method.provider_method_id = provider_method_id + payment_method.payment_provider_id = payment_provider_id + payment_method.details = details if details.present? + end + payment_method.save! + PaymentMethods::SetAsDefaultService.call(payment_method:) + + result.payment_method = payment_method + result + end + + private + + attr_accessor :customer, :params, :provider_method_id, :payment_provider_id, :payment_provider_customer, :details + + def provider_method_type + if (provider_payment_methods = params[:provider_payment_methods]).present? + Array.wrap(provider_payment_methods).first + else + "card" + end + end + end +end diff --git a/app/services/payment_methods/destroy_service.rb b/app/services/payment_methods/destroy_service.rb new file mode 100644 index 0000000..7ff5a9a --- /dev/null +++ b/app/services/payment_methods/destroy_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module PaymentMethods + class DestroyService < BaseService + def initialize(payment_method:) + @payment_method = payment_method + + super + end + + def call + return result.not_found_failure!(resource: "payment_method") unless payment_method + + payment_method.is_default = false + payment_method.discard! + + result.payment_method = payment_method + result + end + + private + + attr_reader :payment_method + end +end diff --git a/app/services/payment_methods/determine_service.rb b/app/services/payment_methods/determine_service.rb new file mode 100644 index 0000000..4f6992d --- /dev/null +++ b/app/services/payment_methods/determine_service.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module PaymentMethods + class DetermineService < BaseService + Result = BaseResult[:payment_method] + + def initialize(invoice:, customer:, payment_method_params:) + @invoice = invoice + @customer = customer + @payment_method_params = payment_method_params + + super + end + + def call + result.payment_method = if payment_method_params.present? + determine_override_payment_method + else + determine_invoice_payment_method + end + + result + end + + private + + attr_reader :invoice, :customer, :payment_method_params + + def determine_override_payment_method + return nil if payment_method_params[:payment_method_type] == "manual" + + if payment_method_params[:payment_method_id].present? + customer.payment_methods.find_by(id: payment_method_params[:payment_method_id]) + else + customer.default_payment_method + end + end + + def determine_invoice_payment_method + case invoice.invoice_type + when "subscription", "advance_charges", "progressive_billing" + determine_subscription_payment_method + when "credit" + determine_credit_payment_method + else + customer.default_payment_method + end + end + + def determine_subscription_payment_method + subscription = invoice.invoice_subscriptions.first&.subscription + return nil unless subscription + + return nil if subscription.payment_method_type == "manual" + + if subscription.payment_method_id.present? + return customer.payment_methods.find_by(id: subscription.payment_method_id) + end + + customer.default_payment_method + end + + def determine_credit_payment_method + wallet_transaction = invoice.wallet_transactions.first + return nil unless wallet_transaction + + return nil if wallet_transaction.payment_method_type == "manual" + + if wallet_transaction.payment_method_id.present? + return customer.payment_methods.find_by(id: wallet_transaction.payment_method_id) + end + + if wallet_transaction.source.to_s.in?(%w[interval threshold]) + rule = wallet_transaction.wallet.recurring_transaction_rules.active.first + return nil if rule&.payment_method_type == "manual" + return customer.payment_methods.find_by(id: rule.payment_method_id) if rule&.payment_method_id.present? + end + + wallet = wallet_transaction.wallet + return nil if wallet.payment_method_type == "manual" + + if wallet.payment_method_id.present? + return customer.payment_methods.find_by(id: wallet.payment_method_id) + end + + customer.default_payment_method + end + end +end diff --git a/app/services/payment_methods/find_or_create_from_provider_service.rb b/app/services/payment_methods/find_or_create_from_provider_service.rb new file mode 100644 index 0000000..9a04555 --- /dev/null +++ b/app/services/payment_methods/find_or_create_from_provider_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module PaymentMethods + class FindOrCreateFromProviderService < BaseService + Result = BaseResult[:payment_method] + + def initialize(customer:, payment_provider_customer:, provider_method_id:, params: {}, set_as_default: false) + @customer = customer + @payment_provider_customer = payment_provider_customer + @provider_method_id = provider_method_id + @params = params + @set_as_default = set_as_default + + super + end + + def call + return result unless provider_method_id + + payment_method = find_payment_method || create_from_provider + + SetAsDefaultService.call(payment_method:) if set_as_default? + + result.payment_method = payment_method + result + rescue ActiveRecord::RecordNotUnique + result.payment_method = find_payment_method + result + end + + private + + attr_reader :customer, :payment_provider_customer, :provider_method_id, :params, :set_as_default + alias_method :set_as_default?, :set_as_default + + def find_payment_method + PaymentMethod.find_by( + customer:, + payment_provider_customer:, + provider_method_id: + ) + end + + def create_from_provider + CreateFromProviderService.call( + customer:, + params: {provider_payment_methods: params[:provider_payment_methods]}, + provider_method_id:, + payment_provider_id: payment_provider_customer.payment_provider_id, + payment_provider_customer:, + details: params[:details] + ).payment_method + end + end +end diff --git a/app/services/payment_methods/set_as_default_service.rb b/app/services/payment_methods/set_as_default_service.rb new file mode 100644 index 0000000..2b0e268 --- /dev/null +++ b/app/services/payment_methods/set_as_default_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PaymentMethods + class SetAsDefaultService < BaseService + Result = BaseResult[:payment_method] + + def initialize(payment_method:) + @payment_method = payment_method + + super + end + + def call + return result.not_found_failure!(resource: "payment_method") unless payment_method + if payment_method.is_default? + result.payment_method = payment_method + return result + end + + ActiveRecord::Base.transaction do + payment_method.customer.payment_methods.where.not(id: payment_method.id).update_all(is_default: false) # rubocop:disable Rails/SkipsModelValidations + payment_method.update!(is_default: true) + end + + result.payment_method = payment_method + + result + end + + private + + attr_reader :payment_method + end +end diff --git a/app/services/payment_methods/update_details_service.rb b/app/services/payment_methods/update_details_service.rb new file mode 100644 index 0000000..01b2ecd --- /dev/null +++ b/app/services/payment_methods/update_details_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module PaymentMethods + class UpdateDetailsService < BaseService + def initialize(payment_method:, insert: {}, delete: {}) + @payment_method = payment_method + @insert = insert.with_indifferent_access + @delete = delete.with_indifferent_access + + super + end + + def call + return result.not_found_failure!(resource: "payment_method") unless payment_method + + payment_method.details.merge!(insert) + payment_method.details.except!(*delete.keys) + + payment_method.save! + + result.payment_method = payment_method + result + end + + private + + attr_accessor :payment_method, :insert, :delete + end +end diff --git a/app/services/payment_methods/validate_service.rb b/app/services/payment_methods/validate_service.rb new file mode 100644 index 0000000..c5e9cf4 --- /dev/null +++ b/app/services/payment_methods/validate_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module PaymentMethods + class ValidateService < BaseValidator + def valid? + return true unless args[:payment_method] + + valid_payment_method_attributes? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_payment_method_attributes? + return true if args[:payment_method].blank? + return true if args[:payment_method][:payment_method_type].blank? && args[:payment_method][:payment_method_id].blank? + return true if args[:payment_method][:payment_method_id].nil? && args[:payment_method][:payment_method_type].to_s == "provider" + return true if result.payment_method && args[:payment_method][:payment_method_type].to_s == "provider" + return true if result.payment_method.nil? && args[:payment_method][:payment_method_type].to_s == "manual" + + add_error(field: :payment_method, error_code: "invalid_payment_method") + end + end +end diff --git a/app/services/payment_provider_customers/adyen_service.rb b/app/services/payment_provider_customers/adyen_service.rb new file mode 100644 index 0000000..4047e85 --- /dev/null +++ b/app/services/payment_provider_customers/adyen_service.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class AdyenService < BaseService + include Lago::Adyen::ErrorHandlable + include Customers::PaymentProviderFinder + + def initialize(adyen_customer = nil) + @adyen_customer = adyen_customer + + super(nil) + end + + def create + result.adyen_customer = adyen_customer + return result if adyen_customer.provider_customer_id? + + checkout_url_result = generate_checkout_url + return result unless checkout_url_result.success? + + result.checkout_url = checkout_url_result.checkout_url + result + rescue Adyen::AuthenticationError + # NOTE: Authentication errors will be sent to the account owner with a webhook. + # Since nothing can be done on Lago's side, we should not raise the error. + # TODO: Flag the error on the PaymentProvider instance. + result + end + + def update + result + end + + def generate_checkout_url(send_webhook: true) + return result.not_found_failure!(resource: "adyen_payment_provider") unless adyen_payment_provider + + res = client.checkout.payment_links_api.payment_links(Lago::Adyen::Params.new(payment_link_params).to_h) + adyen_success, adyen_error = handle_adyen_response(res) + result.service_failure!(code: adyen_error.code, message: adyen_error.msg) unless adyen_success + return result unless result.success? + + result.checkout_url = res.response["url"] + + if send_webhook + SendWebhookJob.perform_later( + "customer.checkout_url_generated", + customer, + checkout_url: result.checkout_url + ) + end + + result + rescue Adyen::AdyenError => e + deliver_error_webhook(e) + + raise + end + + def preauthorise(organization, event) + shopper_reference = shopper_reference_from_event(event) + payment_method_id = event.dig("additionalData", "recurring.recurringDetailReference") + + @adyen_customer = PaymentProviderCustomers::AdyenCustomer + .joins(:customer) + .where(customers: {external_id: shopper_reference, organization_id: organization.id}) + .first + + return handle_missing_customer(shopper_reference) unless adyen_customer + + if event["success"] == "true" + adyen_customer.update!(payment_method_id:, provider_customer_id: shopper_reference) + + if organization.feature_flag_enabled?(:multiple_payment_methods) + handle_payment_methods(payment_method_id) + end + + SendWebhookJob.perform_later("customer.payment_provider_created", customer) + else + deliver_error_webhook(Adyen::AdyenError.new(nil, nil, event["reason"], event["eventCode"])) + end + + result.adyen_customer = adyen_customer + result + end + + private + + attr_accessor :adyen_customer + + delegate :customer, to: :adyen_customer + + def organization + @organization ||= customer.organization + end + + def adyen_payment_provider + @adyen_payment_provider ||= payment_provider(customer) + end + + def client + @client ||= Adyen::Client.new( + api_key: adyen_payment_provider.api_key, + env: adyen_payment_provider.environment, + live_url_prefix: adyen_payment_provider.live_prefix + ) + end + + def shopper_reference_from_event(event) + event.dig("additionalData", "shopperReference") || + event.dig("additionalData", "recurring.shopperReference") + end + + def payment_link_params + prms = { + reference: "authorization customer #{customer.external_id}", + amount: { + value: 0, # pre-authorization + currency: customer.currency.presence || customer.organization_default_currency + }, + merchantAccount: adyen_payment_provider.merchant_account, + returnUrl: success_redirect_url, + shopperReference: customer.external_id, + storePaymentMethodMode: "enabled", + recurringProcessingModel: "UnscheduledCardOnFile", + expiresAt: Time.current + 69.days + } + prms[:shopperEmail] = customer.email&.strip&.split(",")&.first if customer.email + prms + end + + def success_redirect_url + adyen_payment_provider.success_redirect_url.presence || PaymentProviders::AdyenProvider::SUCCESS_REDIRECT_URL + end + + def deliver_error_webhook(adyen_error) + SendWebhookJob.perform_later( + "customer.payment_provider_error", + customer, + provider_error: { + message: adyen_error.request&.dig("msg") || adyen_error.msg, + error_code: adyen_error.request&.dig("code") || adyen_error.code + } + ) + end + + def handle_missing_customer(shopper_reference) + # NOTE: Adyen customer was not created from lago + return result unless shopper_reference + + # NOTE: Customer does not belong to this lago instance + return result if Customer.find_by(external_id: shopper_reference).nil? + + result.not_found_failure!(resource: "adyen_customer") + end + + def handle_payment_methods(payment_method_id) + PaymentMethods::FindOrCreateFromProviderService.call( + customer:, + payment_provider_customer: adyen_customer, + provider_method_id: payment_method_id, + set_as_default: true + ) + # race condition for multiple calls while creating the PM + rescue ActiveRecord::RecordNotUnique + end + end +end diff --git a/app/services/payment_provider_customers/cashfree_service.rb b/app/services/payment_provider_customers/cashfree_service.rb new file mode 100644 index 0000000..83dfc0c --- /dev/null +++ b/app/services/payment_provider_customers/cashfree_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class CashfreeService < BaseService + include Customers::PaymentProviderFinder + + def initialize(cashfree_customer = nil) + @cashfree_customer = cashfree_customer + + super(nil) + end + + def create + result.cashfree_customer = cashfree_customer + result + end + + def update + result + end + + def generate_checkout_url(send_webhook: true) + result.not_allowed_failure!(code: "feature_not_supported") + end + + private + + attr_accessor :cashfree_customer + + delegate :customer, to: :cashfree_customer + end +end diff --git a/app/services/payment_provider_customers/factory.rb b/app/services/payment_provider_customers/factory.rb new file mode 100644 index 0000000..dc6ad7b --- /dev/null +++ b/app/services/payment_provider_customers/factory.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class Factory + def self.new_instance(provider_customer:) + service_class(provider_customer).new(provider_customer) + end + + def self.service_class(provider_customer) + case provider_customer&.class.to_s + when "PaymentProviderCustomers::StripeCustomer" + PaymentProviderCustomers::StripeService + when "PaymentProviderCustomers::GocardlessCustomer" + PaymentProviderCustomers::GocardlessService + when "PaymentProviderCustomers::CashfreeCustomer" + PaymentProviderCustomers::CashfreeService + when "PaymentProviderCustomers::FlutterwaveCustomer" + PaymentProviderCustomers::FlutterwaveService + when "PaymentProviderCustomers::AdyenCustomer" + PaymentProviderCustomers::AdyenService + when "PaymentProviderCustomers::MoneyhashCustomer" + PaymentProviderCustomers::MoneyhashService + else + raise(NotImplementedError) + end + end + end +end diff --git a/app/services/payment_provider_customers/flutterwave_service.rb b/app/services/payment_provider_customers/flutterwave_service.rb new file mode 100644 index 0000000..2117a8d --- /dev/null +++ b/app/services/payment_provider_customers/flutterwave_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class FlutterwaveService < BaseService + include Customers::PaymentProviderFinder + + def initialize(flutterwave_customer = nil) + @flutterwave_customer = flutterwave_customer + + super(nil) + end + + def create + result.flutterwave_customer = flutterwave_customer + result + end + + def update + result + end + + def generate_checkout_url(send_webhook: true) + result.not_allowed_failure!(code: "feature_not_supported") + end + + private + + attr_accessor :flutterwave_customer + + delegate :customer, to: :flutterwave_customer + end +end diff --git a/app/services/payment_provider_customers/gocardless_service.rb b/app/services/payment_provider_customers/gocardless_service.rb new file mode 100644 index 0000000..ba15777 --- /dev/null +++ b/app/services/payment_provider_customers/gocardless_service.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class GocardlessService < BaseService + include Customers::PaymentProviderFinder + + def initialize(gocardless_customer = nil) + @gocardless_customer = gocardless_customer + + super(nil) + end + + def create + result.gocardless_customer = gocardless_customer + return result if gocardless_customer.provider_customer_id? + + gocardless_result = create_gocardless_customer + + gocardless_customer.update!( + provider_customer_id: gocardless_result.id + ) + + deliver_success_webhook + PaymentProviderCustomers::GocardlessCheckoutUrlJob.perform_later(gocardless_customer) + + result.gocardless_customer = gocardless_customer + result + end + + def update + result + end + + def generate_checkout_url(send_webhook: true) + billing_request = create_billing_request(gocardless_customer.provider_customer_id) + billing_request_flow = create_billing_request_flow(billing_request.id) + + result.checkout_url = billing_request_flow.authorisation_url + + if send_webhook + SendWebhookJob.perform_later( + "customer.checkout_url_generated", + customer, + checkout_url: result.checkout_url + ) + end + + result + end + + private + + attr_accessor :gocardless_customer + + delegate :customer, to: :gocardless_customer + + def organization + @organization ||= customer.organization + end + + def gocardless_payment_provider + @gocardless_payment_provider ||= payment_provider(customer) + end + + def client + @client || GoCardlessPro::Client.new( + access_token: gocardless_payment_provider.access_token, + environment: gocardless_payment_provider.environment + ) + end + + def create_gocardless_customer + customer_params = { + email: customer.email&.strip&.split(",")&.first, + company_name: customer.name.presence, + given_name: customer.firstname.presence, + family_name: customer.lastname.presence + }.compact + + client.customers.create(params: customer_params) + rescue GoCardlessPro::Error => e + deliver_error_webhook(e) + + raise + end + + def deliver_success_webhook + SendWebhookJob.perform_later( + "customer.payment_provider_created", + customer + ) + end + + def deliver_error_webhook(gocardless_error) + SendWebhookJob.perform_later( + "customer.payment_provider_error", + customer, + provider_error: { + message: gocardless_error.message, + error_code: gocardless_error.code + } + ) + end + + def create_billing_request(gocardless_customer_id) + client.billing_requests.create( + params: { + mandate_request: { + scheme: "bacs" + }, + links: { + customer: gocardless_customer_id + } + } + ) + rescue GoCardlessPro::Error => e + deliver_error_webhook(e) + + raise + end + + def create_billing_request_flow(billing_request_id) + client.billing_request_flows.create( + params: { + redirect_uri: success_redirect_url, + exit_uri: success_redirect_url, + links: { + billing_request: billing_request_id + } + } + ) + rescue GoCardlessPro::Error => e + deliver_error_webhook(e) + + raise + end + + def success_redirect_url + gocardless_payment_provider.success_redirect_url.presence || + PaymentProviders::GocardlessProvider::SUCCESS_REDIRECT_URL + end + end +end diff --git a/app/services/payment_provider_customers/moneyhash_service.rb b/app/services/payment_provider_customers/moneyhash_service.rb new file mode 100644 index 0000000..3c9440d --- /dev/null +++ b/app/services/payment_provider_customers/moneyhash_service.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class MoneyhashService < BaseService + include Customers::PaymentProviderFinder + + def initialize(moneyhash_customer = nil) + @moneyhash_customer = moneyhash_customer + + super(nil) + end + + def create + result.moneyhash_customer = moneyhash_customer + return result if moneyhash_customer.provider_customer_id? + moneyhash_result = create_moneyhash_customer + + return result if !result.success? + + provider_customer_id = begin + moneyhash_result["data"]["id"] + rescue + "" + end + + moneyhash_customer.update!( + provider_customer_id: provider_customer_id + ) + deliver_success_webhook + result.moneyhash_customer = moneyhash_customer + checkout_url_result = generate_checkout_url + return result unless checkout_url_result.success? + result.checkout_url = checkout_url_result.checkout_url + result + end + + def update + result + end + + def generate_checkout_url(send_webhook: true) + return result.not_found_failure!(resource: "moneyhash_payment_provider") unless moneyhash_payment_provider + return result.not_found_failure!(resource: "moneyhash_customer") unless moneyhash_customer + + response = checkout_url_client.post_with_response(checkout_url_params, headers) + moneyhash_result = JSON.parse(response.body) + + return result unless moneyhash_result + + result.checkout_url = "#{moneyhash_result["data"]["embed_url"]}?lago_request=generate_checkout_url" + + if send_webhook + SendWebhookJob.perform_now( + "customer.checkout_url_generated", + customer, + checkout_url: result.checkout_url + ) + end + result + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + result.service_failure!(code: e.error_code, message: e.message) + end + + def update_payment_method(organization_id:, customer_id:, payment_method_id:, metadata: {}, card_details: {}) + moneyhash_customer = PaymentProviderCustomers::MoneyhashCustomer.find_by(customer_id: customer_id) + return handle_missing_customer(organization_id, metadata) unless moneyhash_customer + + moneyhash_customer.payment_method_id = payment_method_id + moneyhash_customer.save! + + if moneyhash_customer.organization.feature_flag_enabled?(:multiple_payment_methods) + find_or_create_result = PaymentMethods::FindOrCreateFromProviderService.call( + customer: moneyhash_customer.customer, + payment_provider_customer: moneyhash_customer, + provider_method_id: payment_method_id, + params: {provider_payment_methods: ["card"]}, + set_as_default: true + ) + result.payment_method = find_or_create_result.payment_method + + if card_details.present? && result.payment_method.present? + PaymentMethods::UpdateDetailsService.call( + payment_method: result.payment_method, + insert: card_details + ) + end + end + + result.moneyhash_customer = moneyhash_customer + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + def delete_payment_method(organization_id:, customer_id:, payment_method_id:, metadata: {}) + moneyhash_customer = PaymentProviderCustomers::MoneyhashCustomer.find_by(customer_id: customer_id) + return handle_missing_customer(organization_id, metadata) unless moneyhash_customer + + if moneyhash_customer.payment_method_id == payment_method_id + moneyhash_customer.payment_method_id = nil + moneyhash_customer.save! + end + + if moneyhash_customer.organization.feature_flag_enabled?(:multiple_payment_methods) + payment_method = moneyhash_customer.customer.payment_methods.find_by(provider_method_id: payment_method_id) + PaymentMethods::DestroyService.call(payment_method:) if payment_method + end + + result.moneyhash_customer = moneyhash_customer + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :moneyhash_customer + + delegate :customer, to: :moneyhash_customer + + def customers_client + @customers_client || LagoHttpClient::Client.new("#{PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/customers/") + end + + def checkout_url_client + @checkout_url_client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def api_key + moneyhash_payment_provider.secret_key + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(customer) + end + + def create_moneyhash_customer + customer_params = { + type: customer&.customer_type&.upcase, + first_name: customer&.firstname, + last_name: customer&.lastname, + email: customer&.email, + phone_number: customer&.phone, + tax_id: customer&.tax_identification_number&.to_i&.to_s, + address: [customer&.address_line1, customer&.address_line2].compact.join(" "), + contact_person_name: (customer&.name.presence || [customer&.firstname, customer&.lastname].compact.join(" ")).presence, + company_name: customer&.legal_name, + custom_fields: { + # service + lago_mh_service: "PaymentProviderCustomers::MoneyhashService", + # request + lago_request: "create_moneyhash_customer" + } + }.compact + + customer_params[:custom_fields].merge!(moneyhash_customer.mh_custom_fields) + + response = customers_client.post_with_response(customer_params, headers) + JSON.parse(response.body) + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + nil + end + + def deliver_error_webhook(moneyhash_error) + SendWebhookJob.perform_later( + "customer.payment_provider_error", + customer, + provider_error: { + message: moneyhash_error.message, + error_code: moneyhash_error.error_code + } + ) + end + + def deliver_success_webhook + SendWebhookJob.perform_later( + "customer.payment_provider_created", + customer + ) + end + + def headers + { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + end + + def checkout_url_params + params = { + amount: 5.0, + amount_currency: customer.currency.presence || customer.organization_default_currency, + flow_id: moneyhash_payment_provider.flow_id, + billing_data: moneyhash_customer.mh_billing_data, + customer: moneyhash_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + merchant_initiated: false, + tokenize_card: true, + payment_type: "UNSCHEDULED", + recurring_data: { + agreement_id: moneyhash_customer.customer_id + }, + custom_fields: { + # mit flag + lago_mit: false, + # service + lago_mh_service: "PaymentProviderCustomers::MoneyhashService", + # request + lago_request: "generate_checkout_url" + } + } + + params[:custom_fields].merge!(moneyhash_customer.mh_custom_fields) + + params + end + + def handle_missing_customer(organization_id, metadata) + # NOTE: this is a silent failure, we return result directly if lago_customer_id is not present or Customer is not found + return result unless metadata&.key?("lago_customer_id") + return result if Customer.find_by(id: metadata["lago_customer_id"], organization_id:).nil? + + # fail only when certain that moneyhash customer is not found (after finding the customer) + result.not_found_failure!(resource: "moneyhash_customer") + end + end +end diff --git a/app/services/payment_provider_customers/stripe/check_payment_method_service.rb b/app/services/payment_provider_customers/stripe/check_payment_method_service.rb new file mode 100644 index 0000000..7a402e6 --- /dev/null +++ b/app/services/payment_provider_customers/stripe/check_payment_method_service.rb @@ -0,0 +1,47 @@ +# frozen_String_literal: true + +module PaymentProviderCustomers + module Stripe + class CheckPaymentMethodService < BaseService + Result = BaseResult[:payment_method] + + def initialize(stripe_customer:, payment_method_id:) + @stripe_customer = stripe_customer + @payment_method_id = payment_method_id + + super + end + + def call + payment_method = ::Stripe::Customer + .new(id: stripe_customer.provider_customer_id) + .retrieve_payment_method(payment_method_id, {}, {api_key:}) + + result.payment_method = payment_method + result + rescue ::Stripe::InvalidRequestError + # NOTE: The payment method is no longer valid + stripe_customer.update!(payment_method_id: nil) + + if customer.organization.feature_flag_enabled?(:multiple_payment_methods) + payment_method = customer.payment_methods.find_by(provider_method_id: payment_method_id) + PaymentMethods::DestroyService.call(payment_method:) + end + + result.single_validation_failure!(field: :payment_method_id, error_code: "value_is_invalid") + end + + private + + attr_reader :stripe_customer, :payment_method_id + + def api_key + stripe_customer.payment_provider.secret_key + end + + def customer + @customer ||= stripe_customer.customer + end + end + end +end diff --git a/app/services/payment_provider_customers/stripe/retrieve_latest_payment_method_service.rb b/app/services/payment_provider_customers/stripe/retrieve_latest_payment_method_service.rb new file mode 100644 index 0000000..759b261 --- /dev/null +++ b/app/services/payment_provider_customers/stripe/retrieve_latest_payment_method_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + module Stripe + class RetrieveLatestPaymentMethodService < BaseService + Result = BaseResult[:payment_method_id] + + def initialize(provider_customer:) + @provider_customer = provider_customer + super + end + + def call + # First, we try to get the customer default payment method + # We swallow all errors to run the second solution + payment_method_id = begin + customer = ::Stripe::Customer.retrieve(provider_customer.provider_customer_id, request_options) + customer["invoice_settings"]["default_payment_method"] + rescue + nil + end + + # If no default, we'll try to get the latest card + # We also swallow all errors because this is "best effort". If no payment method is found, the caller service will handle it + if payment_method_id.blank? + payment_method_id = begin + # We use limit: 10 just in case for some (wrong) reason the customer has a very high number of payment method + list = ::Stripe::Customer.list_payment_methods(provider_customer.provider_customer_id, {limit: 10}, request_options) + list.data.filter { it.type == "card" }.max_by { it.created }.id + rescue + nil + end + end + + result.payment_method_id = payment_method_id + result + end + + private + + attr_reader :provider_customer + + def request_options + { + api_key: + } + end + + def api_key + provider_customer.payment_provider.secret_key + end + end + end +end diff --git a/app/services/payment_provider_customers/stripe/sync_funding_instructions_service.rb b/app/services/payment_provider_customers/stripe/sync_funding_instructions_service.rb new file mode 100644 index 0000000..73d01dc --- /dev/null +++ b/app/services/payment_provider_customers/stripe/sync_funding_instructions_service.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + module Stripe + class SyncFundingInstructionsService < BaseService + Result = BaseResult[:funding_instructions] + + def initialize(stripe_customer) + @stripe_customer = stripe_customer + super + end + + def call + return result unless eligible_for_funding_instructions? + funding_instructions = fetch_funding_instructions + create_invoice_section_with_funding_info(funding_instructions) + result + rescue ::Stripe::StripeError => e + result.service_failure!(code: "stripe_error", message: e.message) + end + + private + + attr_reader :stripe_customer + delegate :customer, to: :stripe_customer + + def create_invoice_section_with_funding_info(funding_instructions) + section = find_or_create_invoice_section(funding_instructions) + return unless section + + section_ids = customer.selected_invoice_custom_sections.ids | [section.id] + Customers::ManageInvoiceCustomSectionsService.call( + customer: customer, + skip_invoice_custom_sections: false, + section_ids: section_ids + ) + end + + def find_or_create_invoice_section(funding_instructions) + existing_section = customer.organization.system_generated_invoice_custom_sections.find_by(code: funding_instructions_code) + return existing_section if existing_section + + formatted_details = InvoiceCustomSections::FundingInstructionsFormatterService.call( + funding_data: funding_instructions.bank_transfer.to_hash, + locale: preferred_locale + ).details + + created = InvoiceCustomSections::CreateService.call( + organization: customer.organization, + create_params: { + code: funding_instructions_code, + name: "Funding Instructions", + display_name: I18n.t("invoice.pay_with_bank_transfer", locale: preferred_locale), + details: formatted_details, + section_type: :system_generated + } + ) + + created.invoice_custom_section + end + + def fetch_funding_instructions + ::Stripe::Customer.create_funding_instructions( + stripe_customer.provider_customer_id, + { + funding_type: "bank_transfer", + bank_transfer: funding_type_payload, + currency: customer_currency + }, + {api_key: stripe_api_key} + ) + end + + def funding_type_payload + return eu_bank_transfer_payload if customer_currency == "eur" + + { + "usd" => {type: "us_bank_transfer"}, + "gbp" => {type: "gb_bank_transfer"}, + "jpy" => {type: "jp_bank_transfer"}, + "mxn" => {type: "mx_bank_transfer"} + }[customer_currency] + end + + def eu_bank_transfer_payload + customer_country = customer.country&.upcase + billing_entity_country = customer.billing_entity.country&.upcase + + country = + if PaymentProviders::StripeProvider::SUPPORTED_EU_BANK_TRANSFER_COUNTRIES.include?(customer_country) + customer_country + elsif PaymentProviders::StripeProvider::SUPPORTED_EU_BANK_TRANSFER_COUNTRIES.include?(billing_entity_country) + billing_entity_country + else + result.service_failure!( + code: "missing_country", + message: "No country found for customer or organization supported for EU bank transfer payload" + ).raise_if_error! + end + + { + type: "eu_bank_transfer", + eu_bank_transfer: {country: country} + } + end + + def customer_currency + currency = customer.currency || customer.organization.default_currency + currency.downcase + end + + def funding_instructions_code + "funding_instructions_#{customer.id}" + end + + def preferred_locale + customer.preferred_document_locale + end + + def stripe_api_key + stripe_customer.payment_provider.secret_key + end + + def eligible_for_funding_instructions? + stripe_customer.provider_customer_id.present? && + stripe_customer.provider_payment_methods&.include?("customer_balance") && + !customer.system_generated_invoice_custom_sections.exists?(code: "funding_instructions_#{customer.id}") + end + end + end +end diff --git a/app/services/payment_provider_customers/stripe/update_payment_method_service.rb b/app/services/payment_provider_customers/stripe/update_payment_method_service.rb new file mode 100644 index 0000000..a1fbb85 --- /dev/null +++ b/app/services/payment_provider_customers/stripe/update_payment_method_service.rb @@ -0,0 +1,66 @@ +# frozen_String_literal: true + +module PaymentProviderCustomers + module Stripe + class UpdatePaymentMethodService < BaseService + def initialize(stripe_customer:, payment_method_id:, payment_method_details: {}) + @stripe_customer = stripe_customer + @payment_method_id = payment_method_id + @payment_method_details = payment_method_details + + super + end + + def call + return result.not_found_failure!(resource: "stripe_customer") unless stripe_customer + return result.service_failure!(code: :deleted_customer, message: "Customer associated to this stripe customer was deleted") if deleted_customer + + stripe_customer.payment_method_id = payment_method_id + stripe_customer.save! + + if stripe_customer.organization.feature_flag_enabled?(:multiple_payment_methods) + find_or_create_result = PaymentMethods::FindOrCreateFromProviderService.call( + customer:, + payment_provider_customer: stripe_customer, + provider_method_id: payment_method_id, + params: { + provider_payment_methods: stripe_customer.provider_payment_methods, + details: payment_method_details + }, + set_as_default: true + ) + result.payment_method = find_or_create_result.payment_method + end + + reprocess_pending_invoices + + result.stripe_customer = stripe_customer + result + end + + private + + attr_reader :stripe_customer, :payment_method_id, :payment_method_details + + def customer + @customer ||= stripe_customer.customer + end + + def deleted_customer + customer.nil? && + Customer.unscoped.where(organization_id: stripe_customer.organization_id, id: stripe_customer.customer_id).where.not(deleted_at: nil).count > 0 + end + + def reprocess_pending_invoices + invoices = customer.invoices + .payment_pending + .where(ready_for_payment_processing: true) + .where(status: "finalized") + + invoices.find_each do |invoice| + Invoices::Payments::CreateJob.perform_later(invoice:, payment_provider: :stripe) + end + end + end + end +end diff --git a/app/services/payment_provider_customers/stripe_service.rb b/app/services/payment_provider_customers/stripe_service.rb new file mode 100644 index 0000000..1b7c68c --- /dev/null +++ b/app/services/payment_provider_customers/stripe_service.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class StripeService < BaseService + include Customers::PaymentProviderFinder + + def initialize(stripe_customer = nil) + @stripe_customer = stripe_customer + + super(nil) + end + + def create + return result unless customer + + result.stripe_customer = stripe_customer + return result if stripe_customer.provider_customer_id? || !stripe_payment_provider + + stripe_result = create_stripe_customer + return result if !stripe_result || !result.success? + + stripe_customer.update!( + provider_customer_id: stripe_result.id + ) + + deliver_success_webhook + sync_funding_instructions + if payment_methods_require_setup? + PaymentProviderCustomers::StripeCheckoutUrlJob.perform_after_commit(stripe_customer) + end + + result.stripe_customer = stripe_customer + result + end + + def update + return result if !stripe_payment_provider || stripe_customer.provider_customer_id.blank? + + ::Stripe::Customer.update(stripe_customer.provider_customer_id, stripe_update_payload, {api_key:}) + sync_funding_instructions + result + rescue ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e + deliver_error_webhook(e) + + result.third_party_failure!(third_party: "Stripe", error_code: e.code, error_message: e.message) + rescue ::Stripe::AuthenticationError => e + deliver_error_webhook(e) + + message = ["Stripe authentication failed.", e.message.presence].compact.join(" ") + result.unauthorized_failure!(message:) + end + + def delete_payment_method(organization_id:, stripe_customer_id:, payment_method_id:, metadata: {}) + @stripe_customer = PaymentProviderCustomers::StripeCustomer + .joins(:customer) + .where(customers: {organization_id:}) + .find_by(provider_customer_id: stripe_customer_id) + return handle_missing_customer(organization_id, metadata) unless stripe_customer + + # NOTE: check if payment_method was the default one + stripe_customer.payment_method_id = nil if stripe_customer.payment_method_id == payment_method_id + + if customer.organization.feature_flag_enabled?(:multiple_payment_methods) + payment_method = customer.payment_methods.find_by(provider_method_id: payment_method_id) + if payment_method + destroy_result = PaymentMethods::DestroyService.call(payment_method:) + result.payment_method = destroy_result.payment_method + end + end + + result.stripe_customer = stripe_customer + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + def generate_checkout_url(send_webhook: true) + return result unless customer # NOTE: Customer is nil when deleted. + return result if customer.organization.webhook_endpoints.none? && send_webhook && payment_provider(customer) + + unless payment_methods_require_setup? + return result.single_validation_failure!( + field: :provider_payment_methods, + error_code: "no_payment_methods_to_setup_available" + ) + end + + res = ::Stripe::Checkout::Session.create(checkout_link_params, {api_key:}) + + result.checkout_url = res["url"] + + if send_webhook + SendWebhookJob.perform_later("customer.checkout_url_generated", customer, checkout_url: result.checkout_url) + end + + result + rescue ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e + deliver_error_webhook(e) + result.third_party_failure!(third_party: "Stripe", error_code: e.code, error_message: e.message) + rescue ::Stripe::AuthenticationError => e + deliver_error_webhook(e) + + message = ["Stripe authentication failed.", e.message.presence].compact.join(" ") + result.unauthorized_failure!(message:) + end + + private + + attr_accessor :stripe_customer + + delegate :customer, to: :stripe_customer + + def payment_methods_require_setup? + stripe_customer.provider_payment_methods_require_setup? + end + + def organization + customer.organization + end + + def api_key + stripe_payment_provider.secret_key + end + + def name + customer.name.presence || [customer.firstname, customer.lastname].compact.join(" ") + end + + def checkout_link_params + { + success_url: success_redirect_url, + mode: "setup", + payment_method_types: stripe_customer.provider_payment_methods_with_setup, + customer: stripe_customer.provider_customer_id + } + end + + def success_redirect_url + stripe_payment_provider.success_redirect_url.presence || + PaymentProviders::StripeProvider::SUCCESS_REDIRECT_URL + end + + def create_stripe_customer + ::Stripe::Customer.create( + stripe_create_payload, + { + api_key:, + idempotency_key: [customer.id, customer.updated_at.to_i].join("-") + } + ) + rescue ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e + deliver_error_webhook(e) + nil + rescue ::Stripe::AuthenticationError => e + deliver_error_webhook(e) + + message = ["Stripe authentication failed.", e.message.presence].compact.join(" ") + result.unauthorized_failure!(message:) + rescue ::Stripe::IdempotencyError + stripe_customers = ::Stripe::Customer.list({email: customer.email}, {api_key:}) + return stripe_customers.first if stripe_customers.count == 1 + + # NOTE: Multiple stripe customers with the same email, + # re-raise to fix the issue + raise + end + + def stripe_create_payload + { + address: { + city: customer.city, + country: customer.country, + line1: customer.address_line1, + line2: customer.address_line2, + postal_code: customer.zipcode, + state: customer.state + }, + email: customer.email&.strip&.split(",")&.first, + name:, + metadata: { + lago_customer_id: customer.id, + customer_id: customer.external_id + }, + phone: customer.phone + } + end + + def stripe_update_payload + { + address: { + city: customer.city, + country: customer.country, + line1: customer.address_line1, + line2: customer.address_line2, + postal_code: customer.zipcode, + state: customer.state + }, + email: customer.email&.strip&.split(",")&.first, + name:, + phone: customer.phone + } + end + + def deliver_success_webhook + SendWebhookJob.perform_later( + "customer.payment_provider_created", + customer + ) + end + + def deliver_error_webhook(stripe_error) + SendWebhookJob.perform_later( + "customer.payment_provider_error", + customer, + provider_error: { + message: stripe_error.message, + error_code: stripe_error.code + } + ) + end + + def handle_missing_customer(organization_id, metadata) + # NOTE: Stripe customer was not created from lago + return result unless metadata&.key?(:lago_customer_id) + + # NOTE: Customer does not belong to this lago instance or + # exists but does not belong to the organizations + # (Happens when the Stripe API key is shared between organizations) + return result if Customer.find_by(id: metadata[:lago_customer_id], organization_id:).nil? + + result.not_found_failure!(resource: "stripe_customer") + end + + def sync_funding_instructions + return if stripe_customer.provider_customer_id.blank? + return unless stripe_customer.provider_payment_methods&.include?("customer_balance") + + PaymentProviderCustomers::StripeSyncFundingInstructionsJob.perform_later(stripe_customer) + end + + def stripe_payment_provider + @stripe_payment_provider ||= payment_provider(customer) + end + end +end diff --git a/app/services/payment_provider_customers/update_service.rb b/app/services/payment_provider_customers/update_service.rb new file mode 100644 index 0000000..636c086 --- /dev/null +++ b/app/services/payment_provider_customers/update_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module PaymentProviderCustomers + class UpdateService < BaseService + attr_reader :customer + + def initialize(customer) + @customer = customer + + super(nil) + end + + def call + result = PaymentProviderCustomers::Factory.new_instance(provider_customer: customer.provider_customer).update + result.raise_if_error! + result + end + end +end diff --git a/app/services/payment_providers/adyen/customers/create_service.rb b/app/services/payment_providers/adyen/customers/create_service.rb new file mode 100644 index 0000000..e0876ed --- /dev/null +++ b/app/services/payment_providers/adyen/customers/create_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module PaymentProviders + module Adyen + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::AdyenCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::AdyenCustomer.new( + customer_id: customer.id, + payment_provider_id: payment_provider_id, + organization_id: organization.id + ) + + if params.key?(:provider_customer_id) + provider_customer.provider_customer_id = params[:provider_customer_id].presence + end + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer.save! + + result.provider_customer = provider_customer + + if should_create_provider_customer? + create_customer_on_provider_service(async) + elsif should_generate_checkout_url? + generate_checkout_url(async) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + + def create_customer_on_provider_service(async) + return PaymentProviderCustomers::AdyenCreateJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::AdyenCreateJob.perform_now(result.provider_customer) + end + + def generate_checkout_url(async) + return PaymentProviderCustomers::AdyenCheckoutUrlJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::AdyenCheckoutUrlJob.perform_now(result.provider_customer) + end + + def should_create_provider_customer? + # NOTE: the customer does not exists on the service provider + # and the customer id was not removed from the customer + # customer sync with provider setting is set to true + !result.provider_customer.provider_customer_id? && + !result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.sync_with_provider.present? + end + + def should_generate_checkout_url? + !result.provider_customer.id_previously_changed?(from: nil) && # it was not created but updated + result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.provider_customer_id? && + result.provider_customer.sync_with_provider.blank? + end + end + end + end +end diff --git a/app/services/payment_providers/adyen/handle_event_service.rb b/app/services/payment_providers/adyen/handle_event_service.rb new file mode 100644 index 0000000..2df70cf --- /dev/null +++ b/app/services/payment_providers/adyen/handle_event_service.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module PaymentProviders + module Adyen + class HandleEventService < BaseService + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::AdyenService, + "PaymentRequest" => PaymentRequests::Payments::AdyenService + }.freeze + + def initialize(organization:, event_json:) + @organization = organization + @event_json = event_json + + super + end + + def call + if PaymentProviders::AdyenProvider::IGNORED_WEBHOOK_EVENTS.include?(event["eventCode"]) + return result + end + + unless PaymentProviders::AdyenProvider::WEBHOOKS_EVENTS.include?(event["eventCode"]) + return result.service_failure!( + code: "webhook_error", + message: "Invalid adyen event code: #{event["eventCode"]}" + ) + end + + case event["eventCode"] + when "AUTHORISATION" + amount = event.dig("amount", "value") + payment_type = event.dig("additionalData", "metadata.payment_type") + + if payment_type == "one-time" + update_result = update_payment_status(payment_type) + return update_result.raise_if_error! + end + + return result if amount != 0 + + service = PaymentProviderCustomers::AdyenService.new + + result = service.preauthorise(organization, event) + result.raise_if_error! + when "REFUND" + service = CreditNotes::Refunds::AdyenService.new + + provider_refund_id = event["pspReference"] + status = (event["success"] == "true") ? :succeeded : :failed + + result = service.update_status(provider_refund_id:, status:) + result.raise_if_error! + when "CHARGEBACK" + PaymentProviders::Adyen::Webhooks::ChargebackService.call( + organization_id: organization.id, + event_json: + ) + when "REFUND_FAILED" + return result if event["success"] != "true" + + service = CreditNotes::Refunds::AdyenService.new + + provider_refund_id = event["pspReference"] + + result = service.update_status(provider_refund_id:, status: :failed) + result.raise_if_error! + end + end + + private + + attr_reader :organization, :event_json + + def event + @event ||= JSON.parse(event_json) + end + + def update_payment_status(payment_type) + provider_payment_id = event["pspReference"] + status = (event["success"] == "true") ? "succeeded" : "failed" + metadata = { + payment_type:, + lago_invoice_id: event.dig("additionalData", "metadata.lago_invoice_id"), + lago_payable_id: event.dig("additionalData", "metadata.lago_payable_id"), + lago_payable_type: event.dig("additionalData", "metadata.lago_payable_type") + } + + payment_service_klass(metadata) + .new.update_payment_status(provider_payment_id:, status:, metadata:) + end + + def payment_service_klass(metadata) + payable_type = metadata[:lago_payable_type] || "Invoice" + + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type) do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + end + end +end diff --git a/app/services/payment_providers/adyen/handle_incoming_webhook_service.rb b/app/services/payment_providers/adyen/handle_incoming_webhook_service.rb new file mode 100644 index 0000000..046cc07 --- /dev/null +++ b/app/services/payment_providers/adyen/handle_incoming_webhook_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module PaymentProviders + module Adyen + class HandleIncomingWebhookService < BaseService + def initialize(organization_id:, body:, code: nil) + @organization_id = organization_id + @body = body + @code = code + + super + end + + def call + organization = Organization.find_by(id: organization_id) + return result.service_failure!(code: "webhook_error", message: "Organization not found") unless organization + + payment_provider_result = PaymentProviders::FindService.call( + organization_id:, + code:, + payment_provider_type: "adyen" + ) + return handle_payment_provider_failure(payment_provider_result) unless payment_provider_result.success? + + validator = ::Adyen::Utils::HmacValidator.new + hmac_key = payment_provider_result.payment_provider.hmac_key + + if hmac_key && !validator.valid_notification_hmac?(body, hmac_key) + return result.service_failure!(code: "webhook_error", message: "Invalid signature") + end + + PaymentProviders::Adyen::HandleEventJob.perform_later(organization:, event_json: body.to_json) + + result.event = body + result + end + + private + + attr_reader :organization_id, :body, :code + + def handle_payment_provider_failure(payment_provider_result) + return payment_provider_result unless payment_provider_result.error.is_a?(BaseService::ServiceFailure) + + result.service_failure!(code: "webhook_error", message: payment_provider_result.error.error_message) + end + end + end +end diff --git a/app/services/payment_providers/adyen/payments/create_service.rb b/app/services/payment_providers/adyen/payments/create_service.rb new file mode 100644 index 0000000..51d36d0 --- /dev/null +++ b/app/services/payment_providers/adyen/payments/create_service.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module PaymentProviders + module Adyen + module Payments + class CreateService < BaseService + def initialize(payment:, reference:, metadata:) + @payment = payment + @reference = reference + @metadata = metadata + @invoice = payment.payable + @provider_customer = payment.payment_provider_customer + + super + end + + def call + result.payment = payment + + adyen_result = create_adyen_payment + + if adyen_result.status > 400 + return prepare_failed_result(::Adyen::AdyenError.new( + nil, nil, adyen_result.response["message"], adyen_result.response["errorType"] + )) + end + + payment.provider_payment_id = adyen_result.response["pspReference"] + payment.status = adyen_result.response["resultCode"] + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.save! + + result.payment = payment + result + rescue ::Adyen::AuthenticationError, ::Adyen::ValidationError => e + prepare_failed_result(e) + rescue ::Adyen::AdyenError => e + prepare_failed_result(e, reraise: true) + rescue Faraday::ConnectionFailed => e + # Allow auto-retry with idempotency key + raise Invoices::Payments::ConnectionError, e + end + + private + + attr_reader :payment, :reference, :metadata, :invoice, :provider_customer + + delegate :payment_provider, :customer, to: :provider_customer + + def client + @client ||= ::Adyen::Client.new( + api_key: payment_provider.api_key, + env: payment_provider.environment, + live_url_prefix: payment_provider.live_prefix + ) + end + + def success_redirect_url + payment_provider.success_redirect_url.presence || ::PaymentProviders::AdyenProvider::SUCCESS_REDIRECT_URL + end + + def update_payment_method_id + result = client.checkout.payments_api.payment_methods( + Lago::Adyen::Params.new(payment_method_params).to_h + ).response + + payment_method_id = result["storedPaymentMethods"]&.first&.dig("id") + + if payment_method_id + provider_customer.update!(payment_method_id:) + end + end + + def create_adyen_payment + update_payment_method_id + + client.checkout.payments_api.payments( + Lago::Adyen::Params.new(payment_params).to_h, + headers: {"idempotency-key" => "payment-#{payment.id}"} + ) + end + + def payment_method_params + { + merchantAccount: payment_provider.merchant_account, + shopperReference: provider_customer.provider_customer_id + } + end + + def payment_params + prms = { + amount: { + currency: payment.amount_currency.upcase, + value: payment.amount_cents + }, + reference: reference, + paymentMethod: { + type: "scheme", + storedPaymentMethodId: payment_method_id + }, + shopperReference: provider_customer.provider_customer_id, + merchantAccount: payment_provider.merchant_account, + shopperInteraction: "ContAuth", + recurringProcessingModel: "UnscheduledCardOnFile" + } + prms[:shopperEmail] = customer.email if customer.email + prms + end + + def payment_method_id + if payment.organization.feature_flag_enabled?(:multiple_payment_methods) + payment.payment_method&.provider_method_id + else + provider_customer.payment_method_id + end + end + + def prepare_failed_result(error, reraise: false) + result.error_message = error.msg + result.error_code = error.code + result.reraise = reraise + + payment.update!(status: :failed, payable_payment_status: :failed) + + result.service_failure!(code: "adyen_error", message: "#{error.code}: #{error.msg}") + end + end + end + end +end diff --git a/app/services/payment_providers/adyen/webhooks/base_service.rb b/app/services/payment_providers/adyen/webhooks/base_service.rb new file mode 100644 index 0000000..ed86882 --- /dev/null +++ b/app/services/payment_providers/adyen/webhooks/base_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module PaymentProviders + module Adyen + module Webhooks + class BaseService < BaseService + def initialize(organization_id:, event_json:) + @organization = Organization.find(organization_id) + @event_json = event_json + + super + end + + private + + attr_reader :organization, :event_json + + def event + @event ||= JSON.parse(event_json)["notificationItems"].first&.dig("NotificationRequestItem") + end + end + end + end +end diff --git a/app/services/payment_providers/adyen/webhooks/chargeback_service.rb b/app/services/payment_providers/adyen/webhooks/chargeback_service.rb new file mode 100644 index 0000000..9f14c4d --- /dev/null +++ b/app/services/payment_providers/adyen/webhooks/chargeback_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module PaymentProviders + module Adyen + module Webhooks + class ChargebackService < BaseService + def call + status = event["additionalData"]["disputeStatus"] + reason = event["reason"] + provider_payment_id = event["pspReference"] + + payment = Payment.find_by(provider_payment_id:) + return result.not_found_failure!(resource: "adyen_payment") unless payment + + if status == "Lost" && event["success"] == "true" + return ::Payments::LoseDisputeService.call(payment:, payment_dispute_lost_at:, reason:) + end + + result + end + + private + + def payment_dispute_lost_at + Time.zone.parse(event["eventDate"]) + end + end + end + end +end diff --git a/app/services/payment_providers/adyen_service.rb b/app/services/payment_providers/adyen_service.rb new file mode 100644 index 0000000..fec6a69 --- /dev/null +++ b/app/services/payment_providers/adyen_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module PaymentProviders + class AdyenService < BaseService + def create_or_update(**args) + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization].id, + code: args[:code], + id: args[:id], + payment_provider_type: "adyen" + ) + + adyen_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::AdyenProvider.new( + organization_id: args[:organization].id, + code: args[:code] + ) + end + + # api_key = adyen_provider.api_key + old_code = adyen_provider.code + + adyen_provider.api_key = args[:api_key] if args.key?(:api_key) + adyen_provider.code = args[:code] if args.key?(:code) + adyen_provider.name = args[:name] if args.key?(:name) + adyen_provider.merchant_account = args[:merchant_account] if args.key?(:merchant_account) + adyen_provider.live_prefix = args[:live_prefix] if args.key?(:live_prefix) + adyen_provider.hmac_key = args[:hmac_key] if args.key?(:hmac_key) + adyen_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) + adyen_provider.save! + + if payment_provider_code_changed?(adyen_provider, old_code, args) + adyen_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + end + + result.adyen_provider = adyen_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end +end diff --git a/app/services/payment_providers/base_service.rb b/app/services/payment_providers/base_service.rb new file mode 100644 index 0000000..9b25fa9 --- /dev/null +++ b/app/services/payment_providers/base_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module PaymentProviders + class BaseService < BaseService + # Guarantees security logging for payment provider creation and updates. + # Subclasses are unaware of the logging — the only requirement + # is that `result.{type}_provider` is set upon the successful `create_or_update`. + # + # TODO: Once payment provider services migrate to the standard `call` pattern, + # this can be refactored into a `BaseService` around-middleware. + module SecurityLogging + def create_or_update(...) + super.tap { |r| register_security_log(extract_provider(r)) if r.success? } + end + end + + def self.inherited(subclass) + super + subclass.prepend(SecurityLogging) + end + + private + + def payment_provider_code_changed?(payment_provider, old_code, args) + payment_provider.persisted? && args.key?(:code) && old_code != args[:code] + end + + def integration_type + self.class.name.demodulize.delete_suffix("Service").underscore + end + + def extract_provider(result) + result.send("#{integration_type}_provider") + end + + def register_security_log(provider) + event = provider.previous_changes.key?("id") ? "created" : "updated" + + resources = {integration_name: provider.name, integration_type:} + + if event == "updated" + diff = provider.previous_changes.except("updated_at", "secrets") + .to_h.transform_keys(&:to_sym) + .transform_values { |v| {deleted: v[0], added: v[1]}.compact } + resources.merge!(diff) + end + + Utils::SecurityLog.produce( + organization: provider.organization, + log_type: "integration", + log_event: "integration.#{event}", + resources: + ) + end + end +end diff --git a/app/services/payment_providers/cashfree/customers/create_service.rb b/app/services/payment_providers/cashfree/customers/create_service.rb new file mode 100644 index 0000000..5b0cbb4 --- /dev/null +++ b/app/services/payment_providers/cashfree/customers/create_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::CashfreeCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::CashfreeCustomer.new( + customer_id: customer.id, + payment_provider_id:, + organization_id: organization.id + ) + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer.save! + + result.provider_customer = provider_customer + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + end + end + end +end diff --git a/app/services/payment_providers/cashfree/handle_event_service.rb b/app/services/payment_providers/cashfree/handle_event_service.rb new file mode 100644 index 0000000..7af2a16 --- /dev/null +++ b/app/services/payment_providers/cashfree/handle_event_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + class HandleEventService < BaseService + EVENT_MAPPING = { + "PAYMENT_LINK_EVENT" => PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService + }.freeze + + def initialize(organization:, event_json:) + @organization = organization + @event_json = event_json + + super + end + + def call + EVENT_MAPPING[event["type"]].call!(organization_id: organization.id, event_json:) + + result + end + + private + + attr_reader :organization, :event_json + + def event + @event ||= JSON.parse(event_json) + end + end + end +end diff --git a/app/services/payment_providers/cashfree/handle_incoming_webhook_service.rb b/app/services/payment_providers/cashfree/handle_incoming_webhook_service.rb new file mode 100644 index 0000000..22bbc01 --- /dev/null +++ b/app/services/payment_providers/cashfree/handle_incoming_webhook_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + class HandleIncomingWebhookService < BaseService + Result = BaseResult[:event] + def initialize(organization_id:, body:, timestamp:, signature:, code: nil) + @organization_id = organization_id + @body = body + @timestamp = timestamp + @signature = signature + @code = code + + super + end + + def call + organization = Organization.find_by(id: organization_id) + + payment_provider_result = PaymentProviders::FindService.call( + organization_id:, + code:, + payment_provider_type: "cashfree" + ) + + return payment_provider_result unless payment_provider_result.success? + + secret_key = payment_provider_result.payment_provider.client_secret + data = "#{timestamp}#{body}" + gen_signature = Base64.strict_encode64(OpenSSL::HMAC.digest("sha256", secret_key, data)) + + unless gen_signature == signature + return result.service_failure!(code: "webhook_error", message: "Invalid signature") + end + + PaymentProviders::Cashfree::HandleEventJob.perform_later(organization:, event: body) + + result.event = body + result + end + + private + + attr_reader :organization_id, :body, :timestamp, :signature, :code + end + end +end diff --git a/app/services/payment_providers/cashfree/payments/create_service.rb b/app/services/payment_providers/cashfree/payments/create_service.rb new file mode 100644 index 0000000..47a497a --- /dev/null +++ b/app/services/payment_providers/cashfree/payments/create_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Payments + class CreateService < BaseService + include ::Customers::PaymentProviderFinder + + def initialize(payment:) + @payment = payment + @invoice = payment.payable + @provider_customer = payment.payment_provider_customer + + super + end + + def call + result.payment = payment + + # NOTE: No need to register the payment with Cashfree Payments for the Payment Link feature. + # Simply create a single `Payment` record and update it upon receiving the webhook, which works perfectly fine. + + result + end + + private + + attr_reader :payment, :invoice, :provider_customer + + delegate :payment_provider, :customer, to: :provider_customer + end + end + end +end diff --git a/app/services/payment_providers/cashfree/webhooks/base_service.rb b/app/services/payment_providers/cashfree/webhooks/base_service.rb new file mode 100644 index 0000000..fb60539 --- /dev/null +++ b/app/services/payment_providers/cashfree/webhooks/base_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Webhooks + class BaseService < BaseService + def initialize(organization_id:, event_json:) + @organization = Organization.find(organization_id) + @event_json = event_json + + super + end + + private + + attr_reader :organization, :event_json + + def event + @event ||= JSON.parse(event_json) + end + end + end + end +end diff --git a/app/services/payment_providers/cashfree/webhooks/payment_link_event_service.rb b/app/services/payment_providers/cashfree/webhooks/payment_link_event_service.rb new file mode 100644 index 0000000..3e43143 --- /dev/null +++ b/app/services/payment_providers/cashfree/webhooks/payment_link_event_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module PaymentProviders + module Cashfree + module Webhooks + class PaymentLinkEventService < BaseService + LINK_STATUS_ACTIONS = %w[PAID].freeze + + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::CashfreeService, + "PaymentRequest" => PaymentRequests::Payments::CashfreeService + }.freeze + + def call + return result unless LINK_STATUS_ACTIONS.include?(link_status) + return result if provider_payment_id.nil? + + payment_service_class.new.update_payment_status( + organization_id: organization.id, + status: link_status, + cashfree_payment: PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: provider_payment_id, + status: link_status, + metadata: event.dig("data", "link_notes").to_h.symbolize_keys || {} + ) + ).raise_if_error! + end + + private + + def payment_service_class + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type || "Invoice") do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + + def link_status + @link_status ||= event.dig("data", "link_status") + end + + def provider_payment_id + @provider_payment_id ||= event.dig("data", "link_notes", "lago_invoice_id") || event.dig("data", "link_notes", "lago_payable_id") + end + + def payable_type + @payable_type ||= event.dig("data", "link_notes", "lago_payable_type") + end + end + end + end +end diff --git a/app/services/payment_providers/cashfree_service.rb b/app/services/payment_providers/cashfree_service.rb new file mode 100644 index 0000000..9d9fe1e --- /dev/null +++ b/app/services/payment_providers/cashfree_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module PaymentProviders + class CashfreeService < BaseService + LINK_STATUS_ACTIONS = %w[PAID].freeze + PAYMENT_ACTIONS = %w[SUCCESS FAILED USER_DROPPED CANCELLED VOID PENDING FLAGGED NOT_ATTEMPTED].freeze + # REFUND_ACTIONS = %w[created funds_returned paid refund_settled failed].freeze + + def create_or_update(**args) + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization].id, + code: args[:code], + id: args[:id], + payment_provider_type: "cashfree" + ) + + cashfree_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::CashfreeProvider.new( + organization_id: args[:organization].id, + code: args[:code] + ) + end + + old_code = cashfree_provider.code + + cashfree_provider.client_id = args[:client_id] if args.key?(:client_id) + cashfree_provider.client_secret = args[:client_secret] if args.key?(:client_secret) + cashfree_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) + cashfree_provider.code = args[:code] if args.key?(:code) + cashfree_provider.name = args[:name] if args.key?(:name) + cashfree_provider.save! + + if payment_provider_code_changed?(cashfree_provider, old_code, args) + cashfree_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + end + + result.cashfree_provider = cashfree_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end +end diff --git a/app/services/payment_providers/create_customer_factory.rb b/app/services/payment_providers/create_customer_factory.rb new file mode 100644 index 0000000..3a070a0 --- /dev/null +++ b/app/services/payment_providers/create_customer_factory.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module PaymentProviders + class CreateCustomerFactory + def self.new_instance(provider:, customer:, payment_provider_id:, params:, async: true) + service_class(provider:).new(customer:, payment_provider_id:, params:, async:) + end + + def self.service_class(provider:) + case provider + when "adyen" + PaymentProviders::Adyen::Customers::CreateService + when "cashfree" + PaymentProviders::Cashfree::Customers::CreateService + when "flutterwave" + PaymentProviders::Flutterwave::Customers::CreateService + when "gocardless" + PaymentProviders::Gocardless::Customers::CreateService + when "stripe" + PaymentProviders::Stripe::Customers::CreateService + when "moneyhash" + PaymentProviders::Moneyhash::Customers::CreateService + end + end + end +end diff --git a/app/services/payment_providers/create_payment_factory.rb b/app/services/payment_providers/create_payment_factory.rb new file mode 100644 index 0000000..9d67e9d --- /dev/null +++ b/app/services/payment_providers/create_payment_factory.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module PaymentProviders + class CreatePaymentFactory + def self.new_instance(provider:, payment:, reference:, metadata:) + service_class(provider:).new(payment:, reference:, metadata:) + end + + def self.service_class(provider:) + case provider.to_sym + when :adyen + PaymentProviders::Adyen::Payments::CreateService + when :cashfree + PaymentProviders::Cashfree::Payments::CreateService + when :gocardless + PaymentProviders::Gocardless::Payments::CreateService + when :stripe + PaymentProviders::Stripe::Payments::CreateService + when :moneyhash + PaymentProviders::Moneyhash::Payments::CreateService + end + end + end +end diff --git a/app/services/payment_providers/destroy_service.rb b/app/services/payment_providers/destroy_service.rb new file mode 100644 index 0000000..6a551bd --- /dev/null +++ b/app/services/payment_providers/destroy_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module PaymentProviders + class DestroyService < BaseService + def initialize(payment_provider) + @payment_provider = payment_provider + + super + end + + def call + return result.not_found_failure!(resource: "payment_provider") unless payment_provider + + customer_ids = payment_provider.customer_ids + + ActiveRecord::Base.transaction do + payment_provider.payment_provider_customers.update_all(payment_provider_id: nil) # rubocop:disable Rails/SkipsModelValidations + payment_provider.discard! + + Customer.where(id: customer_ids).update_all(payment_provider: nil, payment_provider_code: nil) # rubocop:disable Rails/SkipsModelValidations + end + + # TODO: Create job to unregister webhook + + result.payment_provider = payment_provider + register_security_log + result + end + + private + + attr_reader :payment_provider + + def register_security_log + Utils::SecurityLog.produce( + organization: payment_provider.organization, + log_type: "integration", + log_event: "integration.deleted", + resources: { + integration_name: payment_provider.name, + integration_type: payment_provider.class.name.demodulize.delete_suffix("Provider").underscore + } + ) + end + end +end diff --git a/app/services/payment_providers/find_service.rb b/app/services/payment_providers/find_service.rb new file mode 100644 index 0000000..36e031b --- /dev/null +++ b/app/services/payment_providers/find_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module PaymentProviders + class FindService < BaseService + attr_reader :id, :code, :organization_id, :payment_provider_type, :scope + + def initialize(organization_id:, code: nil, id: nil, payment_provider_type: nil) + @id = id + @code = code + @organization_id = organization_id + @payment_provider_type = payment_provider_type + @scope = PaymentProviders::BaseProvider.where(organization_id:) + + if payment_provider_type.present? + @scope = @scope.where(type: "PaymentProviders::#{payment_provider_type.classify}Provider") + end + + super(nil) + end + + def call + if id.present? && (payment_provider = scope.find_by(id:)) + result.payment_provider = payment_provider + return result + end + + if code.blank? && scope.count > 1 + return result.service_failure!( + code: "payment_provider_code_missing", + message: "Payment provider code is missing" + ) + end + + @scope = scope.where(code:) if code.present? + + unless scope.exists? + return result.service_failure!(code: "payment_provider_not_found", message: "Payment provider not found") + end + + result.payment_provider = scope.first + result + end + end +end diff --git a/app/services/payment_providers/flutterwave/customers/create_service.rb b/app/services/payment_providers/flutterwave/customers/create_service.rb new file mode 100644 index 0000000..ea83bd2 --- /dev/null +++ b/app/services/payment_providers/flutterwave/customers/create_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module PaymentProviders + module Flutterwave + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::FlutterwaveCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::FlutterwaveCustomer.new( + customer_id: customer.id, + payment_provider_id:, + organization_id: organization.id + ) + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer.save! + + result.provider_customer = provider_customer + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + end + end + end +end diff --git a/app/services/payment_providers/flutterwave/handle_event_service.rb b/app/services/payment_providers/flutterwave/handle_event_service.rb new file mode 100644 index 0000000..f0fd9ce --- /dev/null +++ b/app/services/payment_providers/flutterwave/handle_event_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module PaymentProviders + module Flutterwave + class HandleEventService < BaseService + EVENT_MAPPING = { + "charge.completed" => PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService + }.freeze + + def initialize(organization:, event_json:) + @organization = organization + @event_json = event_json + + super + end + + def call + event_type = event["event"] + service_class = EVENT_MAPPING[event_type] + + return result unless service_class + + begin + service_class.call!(organization_id: organization.id, event_json:) + rescue => e + Rails.logger.error("Flutterwave event processing error: #{e.message}") + end + + result + end + + private + + attr_reader :organization, :event_json + + def event + @event ||= JSON.parse(event_json) + end + end + end +end diff --git a/app/services/payment_providers/flutterwave/handle_incoming_webhook_service.rb b/app/services/payment_providers/flutterwave/handle_incoming_webhook_service.rb new file mode 100644 index 0000000..bbcd7da --- /dev/null +++ b/app/services/payment_providers/flutterwave/handle_incoming_webhook_service.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module PaymentProviders + module Flutterwave + class HandleIncomingWebhookService < BaseService + Result = BaseResult[:event] + def initialize(organization_id:, body:, secret:, code: nil) + @organization_id = organization_id + @body = body + @secret = secret + @code = code + + super + end + + def call + payment_provider_result = PaymentProviders::FindService.call( + organization_id:, + code:, + payment_provider_type: "flutterwave" + ) + return payment_provider_result unless payment_provider_result.success? + + webhook_secret = payment_provider_result.payment_provider.webhook_secret + return result.service_failure!(code: "webhook_error", message: "Webhook secret is missing") if webhook_secret.blank? + + unless webhook_secret == secret + return result.service_failure!(code: "webhook_error", message: "Invalid webhook secret") + end + + PaymentProviders::Flutterwave::HandleEventJob.perform_later( + organization: payment_provider_result.payment_provider.organization, + event: body + ) + + result.event = body + result + end + + private + + attr_reader :organization_id, :body, :secret, :code + end + end +end diff --git a/app/services/payment_providers/flutterwave/webhooks/charge_completed_service.rb b/app/services/payment_providers/flutterwave/webhooks/charge_completed_service.rb new file mode 100644 index 0000000..e8f5f2c --- /dev/null +++ b/app/services/payment_providers/flutterwave/webhooks/charge_completed_service.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +module PaymentProviders + module Flutterwave + module Webhooks + class ChargeCompletedService < BaseService + SUCCESS_STATUSES = %w[successful].freeze + + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::FlutterwaveService, + "PaymentRequest" => PaymentRequests::Payments::FlutterwaveService + }.freeze + + def initialize(organization_id:, event_json:) + @organization_id = organization_id + @event_json = event_json + super + end + + def call + return result unless SUCCESS_STATUSES.include?(transaction_status) + return result if provider_payment_id.nil? + + # Validate payable_type first to raise NameError for invalid types + payment_service_class + + verified_transaction = verify_transaction + return result unless verified_transaction + + payable = find_payable + return result unless payable + + payment_service_class.new(payable:).update_payment_status( + organization_id:, + status: verified_transaction[:status], + flutterwave_payment: PaymentProviders::FlutterwaveProvider::FlutterwavePayment.new( + id: provider_payment_id, + status: verified_transaction[:status], + metadata: build_metadata(verified_transaction) + ) + ).raise_if_error! + + result + end + + private + + attr_reader :organization_id, :event_json + + def event + @event ||= JSON.parse(event_json) + end + + def transaction_data + @transaction_data ||= event["data"] + end + + def transaction_status + @transaction_status ||= transaction_data["status"] + end + + def provider_payment_id + @provider_payment_id ||= transaction_data.dig("meta", "lago_invoice_id") || + transaction_data.dig("meta", "lago_payable_id") || + transaction_data["tx_ref"] + end + + def payable_type + @payable_type ||= transaction_data.dig("meta", "lago_payable_type") || "Invoice" + end + + def payment_service_class + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type || "Invoice") do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + + def find_payable + case payable_type + when "Invoice" + Invoice.find_by(id: provider_payment_id) + when "PaymentRequest" + PaymentRequest.find_by(id: provider_payment_id) + end + end + + def verify_transaction + Organization.find(organization_id) + payment_provider_result = PaymentProviders::FindService.call( + organization_id:, + payment_provider_type: "flutterwave" + ) + + return nil unless payment_provider_result.success? + + payment_provider = payment_provider_result.payment_provider + + begin + verification_url = "#{payment_provider.api_url}/transactions/#{transaction_data["id"]}/verify" + client = LagoHttpClient::Client.new(verification_url) + + response = client.get( + headers: headers(payment_provider) + ) + + if response["status"] == "success" && response["data"]["status"] == "successful" + { + id: response["data"]["id"], + status: response["data"]["status"], + amount: response["data"]["amount"], + currency: response["data"]["currency"], + customer: response["data"]["customer"], + reference: response["data"]["tx_ref"] + } + else + Rails.logger.warn("Flutterwave transaction verification failed: #{response}") + nil + end + rescue LagoHttpClient::HttpError => e + Rails.logger.error("Error verifying Flutterwave transaction: #{e.message}") + nil + end + end + + def build_metadata(verified_transaction) + { + lago_invoice_id: provider_payment_id, + lago_payable_type: payable_type, + flutterwave_transaction_id: verified_transaction[:id], + flw_ref: verified_transaction[:reference], + reference: verified_transaction[:reference], + amount: verified_transaction[:amount], + currency: verified_transaction[:currency], + payment_type: "one-time" + } + end + + def headers(payment_provider) + { + "Authorization" => "Bearer #{payment_provider.secret_key}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + end + end + end +end diff --git a/app/services/payment_providers/flutterwave_service.rb b/app/services/payment_providers/flutterwave_service.rb new file mode 100644 index 0000000..91d9b59 --- /dev/null +++ b/app/services/payment_providers/flutterwave_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module PaymentProviders + class FlutterwaveService < BaseService + def create_or_update(**args) + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization].id, + code: args[:code], + id: args[:id], + payment_provider_type: "flutterwave" + ) + + flutterwave_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::FlutterwaveProvider.new( + organization_id: args[:organization].id, + code: args[:code] + ) + end + + old_code = flutterwave_provider.code + + flutterwave_provider.secret_key = args[:secret_key] if args.key?(:secret_key) + flutterwave_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) + flutterwave_provider.code = args[:code] if args.key?(:code) + flutterwave_provider.name = args[:name] if args.key?(:name) + flutterwave_provider.save! + if payment_provider_code_changed?(flutterwave_provider, old_code, args) + flutterwave_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + end + + result.flutterwave_provider = flutterwave_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end +end diff --git a/app/services/payment_providers/gocardless/customers/create_service.rb b/app/services/payment_providers/gocardless/customers/create_service.rb new file mode 100644 index 0000000..74fff53 --- /dev/null +++ b/app/services/payment_providers/gocardless/customers/create_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::GocardlessCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::GocardlessCustomer.new( + customer_id: customer.id, + payment_provider_id:, + organization_id: organization.id + ) + + if params.key?(:provider_customer_id) + provider_customer.provider_customer_id = params[:provider_customer_id].presence + end + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer.save! + + result.provider_customer = provider_customer + + if should_create_provider_customer? + create_customer_on_provider_service(async) + elsif should_generate_checkout_url? + generate_checkout_url(async) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + + def create_customer_on_provider_service(async) + return PaymentProviderCustomers::GocardlessCreateJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::GocardlessCreateJob.perform_now(result.provider_customer) + end + + def generate_checkout_url(async) + return PaymentProviderCustomers::GocardlessCheckoutUrlJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::GocardlessCheckoutUrlJob.perform_now(result.provider_customer) + end + + def should_create_provider_customer? + # NOTE: the customer does not exists on the service provider + # and the customer id was not removed from the customer + # customer sync with provider setting is set to true + !result.provider_customer.provider_customer_id? && + !result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.sync_with_provider.present? + end + + def should_generate_checkout_url? + !result.provider_customer.id_previously_changed?(from: nil) && # it was not created but updated + result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.provider_customer_id? && + result.provider_customer.sync_with_provider.blank? + end + end + end + end +end diff --git a/app/services/payment_providers/gocardless/handle_event_service.rb b/app/services/payment_providers/gocardless/handle_event_service.rb new file mode 100644 index 0000000..d8caef7 --- /dev/null +++ b/app/services/payment_providers/gocardless/handle_event_service.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + class HandleEventService < BaseService + PAYMENT_ACTIONS = %w[paid_out failed cancelled customer_approval_denied charged_back].freeze + REFUND_ACTIONS = %w[created funds_returned paid refund_settled failed].freeze + MANDATE_CREATED_ACTIONS = %w[created].freeze + MANDATE_CANCELLED_ACTIONS = %w[cancelled].freeze + + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::GocardlessService, + "PaymentRequest" => PaymentRequests::Payments::GocardlessService + }.freeze + + def initialize(payment_provider:, event_json:) + @payment_provider = payment_provider + @event_json = event_json + + super + end + + def call + case event.resource_type + when "payments" + if PAYMENT_ACTIONS.include?(event.action) + payment_service_klass(event) + .new.update_payment_status( + provider_payment_id: event.links.payment, + status: event.action + ).raise_if_error! + end + when "refunds" + if REFUND_ACTIONS.include?(event.action) + CreditNotes::Refunds::GocardlessService + .new.update_status( + provider_refund_id: event.links.refund, + status: event.action, + metadata: event.metadata + ).raise_if_error! + end + when "mandates" + if MANDATE_CREATED_ACTIONS.include?(event.action) + PaymentProviders::Gocardless::Webhooks::MandateCreatedService.call( + payment_provider:, + mandate_id: event.links.mandate + ).raise_if_error! + elsif MANDATE_CANCELLED_ACTIONS.include?(event.action) && api_originated_event?(event) + PaymentProviders::Gocardless::Webhooks::MandateCancelledService.call( + payment_provider:, + mandate_id: event.links.mandate + ).raise_if_error! + end + end + + result + rescue BaseService::NotFoundFailure => e + Rails.logger.warn("GoCardless resource not found: #{e.message}. JSON: #{event_json}") + BaseService::Result.new # NOTE: Prevents error from being re-raised + end + + private + + attr_reader :payment_provider, :event_json + + def event + @event ||= GoCardlessPro::Resources::Event.new(JSON.parse(event_json)) + end + + def payment_service_klass(event) + payable_type = event.metadata["lago_payable_type"] || "Invoice" + + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type) do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + + def api_originated_event?(event) + return false unless event.details + + event.details["origin"] == "api" + end + end + end +end diff --git a/app/services/payment_providers/gocardless/handle_incoming_webhook_service.rb b/app/services/payment_providers/gocardless/handle_incoming_webhook_service.rb new file mode 100644 index 0000000..227fbad --- /dev/null +++ b/app/services/payment_providers/gocardless/handle_incoming_webhook_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + class HandleIncomingWebhookService < BaseService + def initialize(organization_id:, body:, signature:, code: nil) + @organization_id = organization_id + @body = body + @signature = signature + @code = code + + super + end + + def call + payment_provider_result = PaymentProviders::FindService.call( + organization_id:, + code:, + payment_provider_type: "gocardless" + ) + return payment_provider_result unless payment_provider_result.success? + + result.events = GoCardlessPro::Webhook.parse( + request_body: body, + signature_header: signature, + webhook_endpoint_secret: payment_provider_result.payment_provider&.webhook_secret + ) + + result.events.each do |event| + PaymentProviders::Gocardless::HandleEventJob.perform_later( + organization: payment_provider_result.payment_provider.organization, + payment_provider: payment_provider_result.payment_provider, + event_json: event.to_json + ) + end + + result + rescue JSON::ParserError + result.service_failure!(code: "webhook_error", message: "Invalid payload") + rescue GoCardlessPro::Webhook::InvalidSignatureError + result.service_failure!(code: "webhook_error", message: "Invalid signature") + end + + private + + attr_reader :organization_id, :body, :signature, :code + end + end +end diff --git a/app/services/payment_providers/gocardless/payments/create_service.rb b/app/services/payment_providers/gocardless/payments/create_service.rb new file mode 100644 index 0000000..2b428f7 --- /dev/null +++ b/app/services/payment_providers/gocardless/payments/create_service.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + module Payments + class CreateService < BaseService + class MandateNotFoundError < StandardError + DEFAULT_MESSAGE = "No mandate available for payment" + ERROR_CODE = "no_mandate_error" + + def initialize(msg = DEFAULT_MESSAGE) + super + end + + def code + ERROR_CODE + end + end + + def initialize(payment:, reference:, metadata:) + @payment = payment + @reference = reference + @metadata = metadata + @invoice = payment.payable + @provider_customer = payment.payment_provider_customer + + super + end + + def call + result.payment = payment + + gocardless_result = create_gocardless_payment + + payment.provider_payment_id = gocardless_result.id + payment.status = gocardless_result.status + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.save! + + result.payment = payment + result + rescue GoCardlessPro::ValidationError => e + prepare_failed_result(e) + rescue MandateNotFoundError, GoCardlessPro::Error => e + prepare_failed_result(e, reraise: true) + end + + attr_reader :payment, :reference, :metadata, :invoice, :provider_customer + + delegate :payment_provider, :customer, to: :provider_customer + + def client + @client ||= GoCardlessPro::Client.new( + access_token: payment_provider.access_token, + environment: payment_provider.environment + ) + end + + def mandate_id + if customer.organization.feature_flag_enabled?(:multiple_payment_methods) + mandate_id_from_payment_method || fetch_mandate_from_api + else + fetch_mandate_from_api + end + end + + def mandate_id_from_payment_method + payment&.payment_method&.provider_method_id + end + + def fetch_mandate_from_api + result = client.mandates.list( + params: { + customer: provider_customer.provider_customer_id, + status: %w[pending_customer_approval pending_submission submitted active] + } + ) + + mandate = result&.records&.first + + raise MandateNotFoundError unless mandate + + provider_customer.provider_mandate_id = mandate.id + provider_customer.save! + + mandate.id + end + + def create_gocardless_payment + client.payments.create( + params: { + amount: payment.amount_cents, + currency: payment.amount_currency.upcase, + retry_if_possible: false, + metadata: metadata.except(:invoice_type), + links: { + mandate: mandate_id + } + }, + headers: { + "Idempotency-Key" => "payment-#{payment.id}" + } + ) + end + + def prepare_failed_result(error, reraise: false) + result.error_message = error.message + result.error_code = error.code + result.reraise = reraise + + payment.update!(status: :failed, payable_payment_status: :failed) + + result.service_failure!(code: "gocardless_error", message: "#{error.code}: #{error.message}") + end + end + end + end +end diff --git a/app/services/payment_providers/gocardless/webhooks/mandate_cancelled_service.rb b/app/services/payment_providers/gocardless/webhooks/mandate_cancelled_service.rb new file mode 100644 index 0000000..349c115 --- /dev/null +++ b/app/services/payment_providers/gocardless/webhooks/mandate_cancelled_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + module Webhooks + class MandateCancelledService < BaseService + Result = BaseResult[:gocardless_customer, :payment_method] + + def initialize(payment_provider:, mandate_id:) + @payment_provider = payment_provider + @mandate_id = mandate_id + + super + end + + def call + payment_method = find_payment_method_by_mandate + + return result unless payment_method + return result unless payment_provider.organization.feature_flag_enabled?(:multiple_payment_methods) + + gocardless_customer = payment_method.payment_provider_customer + result.gocardless_customer = gocardless_customer + + if gocardless_customer&.provider_mandate_id == mandate_id + gocardless_customer.provider_mandate_id = nil + gocardless_customer.save! + end + + destroy_result = PaymentMethods::DestroyService.call(payment_method:) + result.payment_method = destroy_result.payment_method + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :payment_provider, :mandate_id + + def find_payment_method_by_mandate + PaymentMethod + .where(organization_id: payment_provider.organization_id, payment_provider:) + .find_by(provider_method_id: mandate_id) + end + end + end + end +end diff --git a/app/services/payment_providers/gocardless/webhooks/mandate_created_service.rb b/app/services/payment_providers/gocardless/webhooks/mandate_created_service.rb new file mode 100644 index 0000000..953c628 --- /dev/null +++ b/app/services/payment_providers/gocardless/webhooks/mandate_created_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module PaymentProviders + module Gocardless + module Webhooks + class MandateCreatedService < BaseService + Result = BaseResult[:payment_method] + + def initialize(payment_provider:, mandate_id:) + @payment_provider = payment_provider + @mandate_id = mandate_id + + super + end + + def call + mandate = fetch_mandate + return result unless mandate + + gocardless_customer = find_gocardless_customer(mandate.links.customer) + return result unless gocardless_customer + return result unless payment_provider.organization.feature_flag_enabled?(:multiple_payment_methods) + + create_payment_method(gocardless_customer, mandate) + + result + end + + private + + attr_reader :payment_provider, :mandate_id + + def fetch_mandate + client.mandates.get(mandate_id) + rescue GoCardlessPro::Error + nil + end + + def find_gocardless_customer(provider_customer_id) + PaymentProviderCustomers::GocardlessCustomer.find_by( + organization: payment_provider.organization, + provider_customer_id: + ) + end + + def create_payment_method(gocardless_customer, mandate) + gocardless_customer.provider_mandate_id = mandate.id + gocardless_customer.save! + + result.payment_method = PaymentMethods::FindOrCreateFromProviderService.call( + customer: gocardless_customer.customer, + payment_provider_customer: gocardless_customer, + provider_method_id: mandate.id, + params: { + provider_payment_methods: gocardless_customer.provider_payment_methods + }, + set_as_default: true + ).payment_method + end + + def client + @client ||= GoCardlessPro::Client.new( + access_token: payment_provider.access_token, + environment: payment_provider.environment + ) + end + end + end + end +end diff --git a/app/services/payment_providers/gocardless_service.rb b/app/services/payment_providers/gocardless_service.rb new file mode 100644 index 0000000..dd9227a --- /dev/null +++ b/app/services/payment_providers/gocardless_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module PaymentProviders + class GocardlessService < BaseService + REDIRECT_URI = "#{ENV["LAGO_OAUTH_PROXY_URL"]}/gocardless/callback".freeze + + def create_or_update(**args) + access_token = if args[:access_code].present? + oauth.auth_code.get_token(args[:access_code], redirect_uri: REDIRECT_URI)&.token + end + + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization].id, + code: args[:code], + id: args[:id], + payment_provider_type: "gocardless" + ) + + gocardless_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::GocardlessProvider.new( + organization_id: args[:organization].id, + code: args[:code] + ) + end + + old_code = gocardless_provider.code + + gocardless_provider.access_token = access_token if access_token + gocardless_provider.webhook_secret = SecureRandom.alphanumeric(50) if gocardless_provider.webhook_secret.blank? + gocardless_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) + gocardless_provider.code = args[:code] if args.key?(:code) + gocardless_provider.name = args[:name] if args.key?(:name) + gocardless_provider.save! + + if payment_provider_code_changed?(gocardless_provider, old_code, args) + gocardless_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + end + + result.gocardless_provider = gocardless_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue OAuth2::Error => e + result.service_failure!(code: "internal_error", message: e.description) + end + + private + + def oauth + OAuth2::Client.new( + ENV["GOCARDLESS_CLIENT_ID"], + ENV["GOCARDLESS_CLIENT_SECRET"], + site: PaymentProviders::GocardlessProvider.auth_site, + authorize_url: "/oauth/authorize", + token_url: "/oauth/access_token", + auth_scheme: :request_body + ) + end + end +end diff --git a/app/services/payment_providers/moneyhash/base_service.rb b/app/services/payment_providers/moneyhash/base_service.rb new file mode 100644 index 0000000..a92b359 --- /dev/null +++ b/app/services/payment_providers/moneyhash/base_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + class BaseService < BaseService + def initialize(payment_provider) + @payment_provider = payment_provider + + super + end + + protected + + attr_reader :payment_provider + + delegate :organization, :organization_id, to: :payment_provider + + def api_key + payment_provider.api_key + end + + def deliver_error_webhook(action:, error:) + SendWebhookJob.perform_later( + "payment_provider.error", + payment_provider, + provider_error: { + source: "moneyhash", + action: action, + message: error.message, + code: error.code + } + ) + end + end + end +end diff --git a/app/services/payment_providers/moneyhash/customers/create_service.rb b/app/services/payment_providers/moneyhash/customers/create_service.rb new file mode 100644 index 0000000..b502da1 --- /dev/null +++ b/app/services/payment_providers/moneyhash/customers/create_service.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::MoneyhashCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::MoneyhashCustomer.new( + customer_id: customer.id, + payment_provider_id:, + organization_id: organization.id + ) + + if params.key?(:provider_customer_id) + provider_customer.provider_customer_id = params[:provider_customer_id].presence + end + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer.save! + + result.provider_customer = provider_customer + + if should_create_provider_customer? + create_customer_on_provider_service(async) + elsif should_generate_checkout_url? + generate_checkout_url(async) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + + def create_customer_on_provider_service(async) + return PaymentProviderCustomers::MoneyhashCreateJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::MoneyhashCreateJob.perform_now(result.provider_customer) + end + + def generate_checkout_url(async) + return PaymentProviderCustomers::MoneyhashCheckoutUrlJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::MoneyhashCheckoutUrlJob.perform_now(result.provider_customer) + end + + def should_create_provider_customer? + # NOTE: the customer does not exists on the service provider + # and the customer id was not removed from the customer + # customer sync with provider setting is set to true + !result.provider_customer.provider_customer_id? && + !result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.sync_with_provider.present? + end + + def should_generate_checkout_url? + !result.provider_customer.id_previously_changed?(from: nil) && # it was not created but updated + result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.provider_customer_id? && + result.provider_customer.sync_with_provider.blank? + end + end + end + end +end diff --git a/app/services/payment_providers/moneyhash/handle_event_service.rb b/app/services/payment_providers/moneyhash/handle_event_service.rb new file mode 100644 index 0000000..1d8073f --- /dev/null +++ b/app/services/payment_providers/moneyhash/handle_event_service.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + class HandleEventService < BaseService + INTENT_WEBHOOKS_EVENTS = %w[intent.processed intent.time_expired].freeze + TRANSACTION_WEBHOOKS_EVENTS = %w[transaction.purchase.failed transaction.purchase.pending_authentication transaction.purchase.successful].freeze + CARD_WEBHOOKS_EVENTS = %w[card_token.created card_token.updated card_token.deleted].freeze + ALLOWED_WEBHOOK_EVENTS = (INTENT_WEBHOOKS_EVENTS + TRANSACTION_WEBHOOKS_EVENTS + CARD_WEBHOOKS_EVENTS).freeze + + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::MoneyhashService, + "PaymentRequest" => PaymentRequests::Payments::MoneyhashService + }.freeze + + def initialize(organization:, event_json:) + @event_json = event_json + @organization = organization + + super + end + + def call + unless ALLOWED_WEBHOOK_EVENTS.include?(event_code) + return result.service_failure!( + code: "webhook_error", + message: "Invalid moneyhash event code: #{event_code}" + ) + end + + event_handlers.fetch(event_code, method(:default_handler)).call + end + + private + + attr_reader :organization, :event_json + + def event_code + @event_code ||= event_json["type"] + end + + def event_handlers + { + "intent.processed" => method(:handle_intent_event), + "intent.time_expired" => method(:handle_intent_event), + "transaction.purchase.failed" => method(:handle_transaction_event), + "transaction.purchase.pending_authentication" => method(:handle_transaction_event), + "transaction.purchase.successful" => method(:handle_transaction_event), + "card_token.created" => method(:handle_card_event), + "card_token.updated" => method(:handle_card_event), + "card_token.deleted" => method(:handle_card_event) + } + end + + def payment_service_klass(event_json) + payable_type = event_json.dig("intent", "custom_fields", "lago_payable_type") || "Invoice" + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type) do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + + def handle_intent_event + if INTENT_WEBHOOKS_EVENTS.include?(event_code) + payment_service_klass(@event_json) + .new.update_payment_status( + organization_id: @organization.id, + provider_payment_id: @event_json.dig("data", "intent_id"), + status: event_to_payment_status(event_code), + metadata: @event_json.dig("data", "intent", "custom_fields") + ).raise_if_error! + end + end + + def handle_transaction_event + if TRANSACTION_WEBHOOKS_EVENTS.include?(event_code) + payment_service_klass(@event_json) + .new.update_payment_status( + organization_id: @organization.id, + provider_payment_id: @event_json.dig("intent", "id"), + status: event_to_payment_status(event_code), + metadata: @event_json.dig("intent", "custom_fields") + ).raise_if_error! + end + end + + def handle_card_event + case event_code + when "card_token.deleted" + handle_card_token_deleted + when "card_token.created", "card_token.updated" + handle_card_token_created_or_updated + end + end + + def handle_card_token_deleted + PaymentProviderCustomers::MoneyhashService.new + .delete_payment_method( + organization_id: organization.id, + customer_id: card_token.dig("custom_fields", "lago_customer_id"), + payment_method_id: card_token["id"], + metadata: card_token["custom_fields"] + ).raise_if_error! + end + + def handle_card_token_created_or_updated + PaymentProviderCustomers::MoneyhashService.new + .update_payment_method( + organization_id: organization.id, + customer_id: card_token.dig("custom_fields", "lago_customer_id"), + payment_method_id: card_token["id"], + metadata: card_token["custom_fields"], + card_details: extract_card_details + ).raise_if_error! + end + + def card_token + @card_token ||= event_json.dig("data", "card_token") + end + + def event_to_payment_status(event_code) + # MH's event -> MH's payment status + case event_code + when "intent.processed", "transaction.purchase.successful" + "SUCCESSFUL" + when "intent.time_expired", "transaction.purchase.failed" + "FAILED" + when "transaction.purchase.pending_authentication" + "PENDING" + end + end + + def default_handler + result.service_failure!( + code: "webhook_error", + message: "No handler for event code: #{event_code}" + ) + end + + def extract_card_details + return {} unless card_token + + PaymentMethods::CardDetails.new( + type: card_token["type"], + last4: card_token["last_4"], + brand: card_token["brand"], + expiration_month: card_token["expiry_month"], + expiration_year: card_token["expiry_year"], + card_holder_name: card_token["card_holder_name"], + issuer: card_token["issuer"] + ).to_h + end + end + end +end diff --git a/app/services/payment_providers/moneyhash/handle_incoming_webhook_service.rb b/app/services/payment_providers/moneyhash/handle_incoming_webhook_service.rb new file mode 100644 index 0000000..eef79f3 --- /dev/null +++ b/app/services/payment_providers/moneyhash/handle_incoming_webhook_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + class HandleIncomingWebhookService < BaseService + extend Forwardable + + def initialize(inbound_webhook:) + @inbound_webhook = inbound_webhook + + super + end + + def call + organization = Organization.find_by(id: @inbound_webhook.organization_id) + return result.service_failure!(code: "webhook_error", message: "Organization not found") unless organization + + payment_provider_result = PaymentProviders::FindService.call( + organization_id: @inbound_webhook.organization_id, + code: @inbound_webhook.code, + payment_provider_type: "moneyhash" + ) + + return handle_payment_provider_failure(payment_provider_result) unless payment_provider_result.success? + + PaymentProviders::Moneyhash::HandleEventJob.perform_later(organization:, event_json: @inbound_webhook.payload) + result.event = @inbound_webhook.payload + result + end + + private + + def_delegators :@inbound_webhook, :organization, :payload + + def handle_payment_provider_failure(payment_provider_result) + return payment_provider_result unless payment_provider_result.error.is_a?(BaseService::ServiceFailure) + result.service_failure!(code: "webhook_error", message: payment_provider_result.error.error_message) + end + end + end +end diff --git a/app/services/payment_providers/moneyhash/payments/create_service.rb b/app/services/payment_providers/moneyhash/payments/create_service.rb new file mode 100644 index 0000000..b237a99 --- /dev/null +++ b/app/services/payment_providers/moneyhash/payments/create_service.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module PaymentProviders + module Moneyhash + module Payments + class CreateService < BaseService + include ::Customers::PaymentProviderFinder + + def initialize(payment:, reference:, metadata:) + @payment = payment + @invoice = payment.payable + @provider_customer = payment.payment_provider_customer + + super + end + + def call + result.payment = payment + + moneyhash_result = create_moneyhash_payment + + payment.provider_payment_id = moneyhash_result.dig("data", "id") + payment.status = moneyhash_result.dig("data", "status") || moneyhash_result.dig("data", "active_transaction", "status") || "PENDING" + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.save! + + result.payment = payment + result + end + + private + + attr_reader :payment, :invoice, :provider_customer + + delegate :customer, to: :invoice + + def create_moneyhash_payment + payment_params = { + amount: payment.amount_cents.div(100).to_f, + amount_currency: payment.amount_currency.upcase, + flow_id: moneyhash_payment_provider.flow_id, + billing_data: provider_customer.mh_billing_data, + customer: provider_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + payment_type: "UNSCHEDULED", + merchant_initiated: true, + recurring_data: { + agreement_id: customer.id + }, + card_token: moneyhash_payment_method_id, + custom_fields: { + # plan/subscription + lago_plan_id: invoice.subscriptions&.first&.plan_id.to_s, + lago_subscription_external_id: invoice.subscriptions&.first&.external_id.to_s, + # payable + lago_payable_id: invoice.id, + lago_payable_type: invoice.class.name, + lago_payable_invoice_type: invoice.invoice_type.to_s, + # mit flag + lago_mit: true, + # service + lago_mh_service: "PaymentProviders::Moneyhash::Payments::CreateService", + # request + lago_request: "invoice_automatic_payment" + } + } + + payment_params[:custom_fields].merge!(provider_customer.mh_custom_fields) + + headers = { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + + response = client.post_with_response(payment_params, headers) + JSON.parse(response.body) + rescue LagoHttpClient::HttpError => e + prepare_failed_result(e, reraise: true) + end + + def client + @client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(provider_customer.customer) + end + + def moneyhash_payment_method_id + if payment.organization.feature_flag_enabled?(:multiple_payment_methods) + payment.payment_method&.provider_method_id + else + provider_customer.payment_method_id + end + end + + def prepare_failed_result(error, reraise: false) + result.error_message = error.error_body + result.error_code = error.error_code + result.reraise = reraise + + payment.update!(status: :failed, payable_payment_status: :failed) + + result.service_failure!(code: "moneyhash_error", message: "#{error.error_code}: #{error.error_body}") + end + end + end + end +end diff --git a/app/services/payment_providers/moneyhash/validate_incoming_webhook_service.rb b/app/services/payment_providers/moneyhash/validate_incoming_webhook_service.rb new file mode 100644 index 0000000..ce9896b --- /dev/null +++ b/app/services/payment_providers/moneyhash/validate_incoming_webhook_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "openssl" +require "base64" + +module PaymentProviders + module Moneyhash + class ValidateIncomingWebhookService < BaseService + def initialize(payload:, signature:, payment_provider:) + @payload = payload + @signature = signature + @provider = payment_provider + + super + end + + def call + # extract timestamp and v3_signature from signature + timestamp, v3_signature = signature.split(",").each_with_object({}) do |part, hash| + key, value = part.split("=") + hash[key] = value if %w[t v3].include?(key) + end.values_at("t", "v3") + + # validate signature + secret = webhook_secret + decoded_body = Base64.strict_encode64(payload.to_json) + + to_sign = "#{decoded_body}#{timestamp}" + + calculated_signature = OpenSSL::HMAC.hexdigest("SHA256", secret, to_sign) + + if calculated_signature != v3_signature + result.service_failure!(code: "webhook_error", message: "Invalid signature") + end + + result + end + + private + + attr_reader :payload, :signature, :provider + + def webhook_secret + provider.signature_key + end + end + end +end diff --git a/app/services/payment_providers/moneyhash_service.rb b/app/services/payment_providers/moneyhash_service.rb new file mode 100644 index 0000000..42d3a35 --- /dev/null +++ b/app/services/payment_providers/moneyhash_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module PaymentProviders + class MoneyhashService < BaseService + def create_or_update(**args) + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization].id, + code: args[:code], + id: args[:id], + payment_provider_type: "moneyhash" + ) + + @moneyhash_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::MoneyhashProvider.new( + organization_id: args[:organization].id, + code: args[:code] + ) + end + + old_code = moneyhash_provider.code + moneyhash_provider.api_key = args[:api_key] if args.key?(:api_key) + moneyhash_provider.code = args[:code] if args.key?(:code) + moneyhash_provider.name = args[:name] if args.key?(:name) + moneyhash_provider.flow_id = args[:flow_id] if args.key?(:flow_id) + + if moneyhash_provider.signature_key.blank? + signature_result = get_signature_key + return signature_result unless signature_result.success? + moneyhash_provider.signature_key = signature_result.signature_key + end + + moneyhash_provider.save(validate: false) + + if payment_provider_code_changed?(moneyhash_provider, old_code, args) + moneyhash_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + end + + result.moneyhash_provider = moneyhash_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + attr_reader :moneyhash_provider + + private + + def get_signature_key + response = LagoHttpClient::Client.new( + "#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1/organizations/get-webhook-signature-key/" + ).get( + headers: { + "X-Api-Key" => moneyhash_provider.api_key + } + ) + result.signature_key = response["data"]["webhook_signature_secret"] + result + rescue LagoHttpClient::HttpError => e + result.service_failure!(code: "moneyhash_error", message: "#{e.error_code}: #{e.error_body}") + end + end +end diff --git a/app/services/payment_providers/stripe/base_service.rb b/app/services/payment_providers/stripe/base_service.rb new file mode 100644 index 0000000..15248f9 --- /dev/null +++ b/app/services/payment_providers/stripe/base_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class BaseService < BaseService + def initialize(payment_provider) + @payment_provider = payment_provider + + super + end + + protected + + attr_reader :payment_provider + + delegate :organization, :organization_id, to: :payment_provider + + def api_key + payment_provider.secret_key + end + + def deliver_error_webhook(action:, error:) + SendWebhookJob.perform_later( + "payment_provider.error", + payment_provider, + provider_error: { + source: "stripe", + action: action, + message: error.message, + code: error.code + } + ) + end + + def webhook_endpoint_shared_params + { + url: webhook_endpoint_destination, + enabled_events: PaymentProviders::StripeProvider::WEBHOOKS_EVENTS + } + end + + def webhook_endpoint_destination + URI.join( + ENV["LAGO_API_URL"], + "webhooks/stripe/#{organization_id}?code=#{URI.encode_www_form_component(payment_provider.code)}" + ) + end + end + end +end diff --git a/app/services/payment_providers/stripe/customers/create_service.rb b/app/services/payment_providers/stripe/customers/create_service.rb new file mode 100644 index 0000000..2332784 --- /dev/null +++ b/app/services/payment_providers/stripe/customers/create_service.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Customers + class CreateService < BaseService + def initialize(customer:, payment_provider_id:, params:, async: true) + @customer = customer + @payment_provider_id = payment_provider_id + @params = params || {} + @async = async + + super + end + + def call + provider_customer = PaymentProviderCustomers::StripeCustomer.find_by(customer_id: customer.id) + provider_customer ||= PaymentProviderCustomers::StripeCustomer.new( + customer_id: customer.id, + payment_provider_id:, + organization_id: organization.id + ) + + if params.key?(:provider_customer_id) + provider_customer.provider_customer_id = params[:provider_customer_id].presence + end + + if params.key?(:sync_with_provider) + provider_customer.sync_with_provider = params[:sync_with_provider].presence + end + + provider_customer = handle_provider_payment_methods(provider_customer:, params:) + provider_customer.save! + + result.provider_customer = provider_customer + + if should_create_provider_customer? + create_customer_on_provider_service(async) + elsif should_generate_checkout_url? + generate_checkout_url(async) + end + + if should_fetch_payment_method? + FetchDefaultPaymentMethodJob.perform_later(provider_customer) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_accessor :customer, :payment_provider_id, :params, :async + + delegate :organization, to: :customer + + def handle_provider_payment_methods(provider_customer:, params:) + provider_payment_methods = (params || {})[:provider_payment_methods] + + if provider_customer.persisted? + provider_customer.provider_payment_methods = provider_payment_methods if provider_payment_methods.present? + else + provider_customer.provider_payment_methods = provider_payment_methods.presence || %w[card] + end + + provider_customer + end + + def create_customer_on_provider_service(async) + return PaymentProviderCustomers::StripeCreateJob.perform_later(result.provider_customer) if async + + PaymentProviderCustomers::StripeCreateJob.perform_now(result.provider_customer) + end + + def generate_checkout_url(async) + return PaymentProviderCustomers::StripeCheckoutUrlJob.perform_after_commit(result.provider_customer) if async + + PaymentProviderCustomers::StripeCheckoutUrlJob.perform_now(result.provider_customer) + end + + def should_create_provider_customer? + # NOTE: the customer does not exists on the service provider + # and the customer id was not removed from the customer + # customer sync with provider setting is set to true + !result.provider_customer.provider_customer_id? && + !result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.sync_with_provider.present? + end + + def should_generate_checkout_url? + !result.provider_customer.id_previously_changed?(from: nil) && # it was not created but updated + result.provider_customer.provider_customer_id_previously_changed? && + result.provider_customer.provider_customer_id? && + result.provider_customer.sync_with_provider.blank? && + result.provider_customer.provider_payment_methods_require_setup? + end + + def should_fetch_payment_method? + !should_create_provider_customer? && + customer.organization.feature_flag_enabled?(:multiple_payment_methods) && + customer.payment_methods.empty? && + result.provider_customer.provider_customer_id? + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/customers/fetch_default_payment_method_service.rb b/app/services/payment_providers/stripe/customers/fetch_default_payment_method_service.rb new file mode 100644 index 0000000..99d0bea --- /dev/null +++ b/app/services/payment_providers/stripe/customers/fetch_default_payment_method_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Customers + class FetchDefaultPaymentMethodService < BaseService + Result = BaseResult[:payment_method] + + def initialize(provider_customer:) + @provider_customer = provider_customer + + super + end + + def call + return result unless provider_customer.provider_customer_id? + + payment_method_id = PaymentProviderCustomers::Stripe::RetrieveLatestPaymentMethodService.call!( + provider_customer: + ).payment_method_id + + return result unless payment_method_id + + payment_method = PaymentMethods::FindOrCreateFromProviderService.call( + customer: provider_customer.customer, + payment_provider_customer: provider_customer, + provider_method_id: payment_method_id, + params: { + provider_payment_methods: provider_customer.provider_payment_methods, + details: payment_method_details(payment_method_id:) + } + ).payment_method + + result.payment_method = payment_method + + result + end + + private + + attr_reader :provider_customer + + def payment_method_details(payment_method_id:) + pm = ::Stripe::PaymentMethod.retrieve( + payment_method_id, + {api_key: provider_customer.payment_provider.secret_key} + ) + + if pm.type == "card" + PaymentMethods::CardDetails.new( + type: pm.type, + last4: pm.card&.last4, + brand: pm.card&.display_brand, + expiration_month: pm.card&.exp_month, + expiration_year: pm.card&.exp_year, + card_holder_name: nil, + issuer: nil + ).to_h + else + {type: pm.type} + end + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/handle_event_service.rb b/app/services/payment_providers/stripe/handle_event_service.rb new file mode 100644 index 0000000..be62683 --- /dev/null +++ b/app/services/payment_providers/stripe/handle_event_service.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class HandleEventService < BaseService + EVENT_MAPPING = { + "setup_intent.succeeded" => PaymentProviders::Stripe::Webhooks::SetupIntentSucceededService, + "payment_intent.succeeded" => PaymentProviders::Stripe::Webhooks::PaymentIntentSucceededService, + "payment_intent.payment_failed" => PaymentProviders::Stripe::Webhooks::PaymentIntentPaymentFailedService, + "customer.updated" => PaymentProviders::Stripe::Webhooks::CustomerUpdatedService, + "charge.dispute.closed" => PaymentProviders::Stripe::Webhooks::ChargeDisputeClosedService, + "payment_intent.canceled" => PaymentProviders::Stripe::Webhooks::PaymentIntentPaymentFailedService + }.freeze + + def initialize(organization:, event_json:) + @organization = organization + @event_json = event_json + + super + end + + def call + unless PaymentProviders::StripeProvider::WEBHOOKS_EVENTS.include?(event.type) + Rails.logger.warn("Unexpected stripe event type: #{event.type}") + return result + end + + if EVENT_MAPPING[event.type].present? + EVENT_MAPPING[event.type].call( + organization_id: organization.id, + event: + ).raise_if_error! + + return result + end + + case event.type + when "payment_method.detached" + PaymentProviderCustomers::StripeService + .new + .delete_payment_method( + organization_id: organization.id, + stripe_customer_id: event.data.object.customer || event.data.previous_attributes.customer, + payment_method_id: event.data.object.id, + metadata: event.data.object.metadata.to_h.symbolize_keys + ).raise_if_error! + when "charge.refund.updated" + CreditNotes::Refunds::StripeService + .new.update_status( + provider_refund_id: event.data.object.id, + status: event.data.object.status, + metadata: event.data.object.metadata.to_h.symbolize_keys + ) + end + rescue BaseService::NotFoundFailure => e + # NOTE: Error with stripe sandbox should be ignord + raise if event.livemode + + Rails.logger.warn("Stripe resource not found: #{e.message}. JSON: #{event_json}") + BaseService::Result.new # NOTE: Prevents error from being re-raised + end + + private + + attr_reader :organization, :body, :event_json + + def event + @event ||= ::Stripe::Event.construct_from(JSON.parse(event_json)) + end + end + end +end diff --git a/app/services/payment_providers/stripe/handle_incoming_webhook_service.rb b/app/services/payment_providers/stripe/handle_incoming_webhook_service.rb new file mode 100644 index 0000000..d24f469 --- /dev/null +++ b/app/services/payment_providers/stripe/handle_incoming_webhook_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class HandleIncomingWebhookService < BaseService + extend Forwardable + + def initialize(inbound_webhook:) + @inbound_webhook = inbound_webhook + + super + end + + def call + PaymentProviders::Stripe::HandleEventJob.perform_later( + organization:, + event: stripe_event.to_json + ) + + result.event = stripe_event + result + rescue JSON::ParserError + result.service_failure!(code: "webhook_error", message: "Invalid payload") + end + + private + + def_delegators :@inbound_webhook, :organization, :payload + + def stripe_event + @stripe_event ||= ::Stripe::Event.construct_from(json_payload) + end + + def json_payload + JSON.parse(payload, symbolize_names: true) + end + end + end +end diff --git a/app/services/payment_providers/stripe/payments/authorize_service.rb b/app/services/payment_providers/stripe/payments/authorize_service.rb new file mode 100644 index 0000000..0431b1b --- /dev/null +++ b/app/services/payment_providers/stripe/payments/authorize_service.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Payments + class AuthorizeService < BaseService + Result = BaseResult[:stripe_payment_intent] + + def initialize(amount:, currency:, provider_customer:, payment_method:, unique_id:, metadata: {}) + @amount = amount + @currency = currency + @provider_customer = provider_customer + @payment_method = payment_method + @unique_id = unique_id + @metadata = metadata + + super(provider_customer.payment_provider) + end + + def call + find_provider_method_id + + if payment_method_id.nil? + return result.single_validation_failure!( + field: :payment_method_id, + error_code: "customer_has_no_payment_method" + ) + end + + payment_intent = create_payment_intent + + result.stripe_payment_intent = payment_intent + + result + rescue ::Stripe::StripeError => e + result.provider_failure!(provider: payment_provider, error: e) + ensure + if payment_intent.present? + PaymentProviders::CancelPaymentAuthorizationJob.perform_later( + payment_provider: provider_customer.payment_provider, id: payment_intent.id + ) + end + end + + private + + def find_provider_method_id + @payment_method_id = if payment_method.present? + payment_method.provider_method_id + elsif provider_customer.payment_method_id.present? + provider_customer.payment_method_id + else + PaymentProviderCustomers::Stripe::RetrieveLatestPaymentMethodService.call!(provider_customer:).payment_method_id + end + end + + def create_payment_intent + ::Stripe::PaymentIntent.create( + { + amount:, + currency: currency.downcase, + confirm: true, + payment_method_options: { + card: { + capture_method: "manual" + } + }, + customer: provider_customer.provider_customer_id, + payment_method: payment_method_id, + description: "Pre-authorization for subscription", + metadata:, + return_url: payment_provider.success_redirect_url, + automatic_payment_methods: { + enabled: true, + allow_redirects: "never" + } + }, + { + api_key:, + idempotency_key: "auth-#{provider_customer.id}-#{unique_id}" + } + ) + end + + attr_reader :amount, :currency, :provider_customer, :payment_method, :payment_method_id, :unique_id, :metadata + end + end + end +end diff --git a/app/services/payment_providers/stripe/payments/create_service.rb b/app/services/payment_providers/stripe/payments/create_service.rb new file mode 100644 index 0000000..ecd3867 --- /dev/null +++ b/app/services/payment_providers/stripe/payments/create_service.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Payments + class CreateService < BaseService + def initialize(payment:, reference:, metadata:) + @payment = payment + @reference = reference + @metadata = metadata + @invoice = payment.payable + @provider_customer = payment.payment_provider_customer + super + end + + def call + result.payment = payment + + stripe_result = create_payment_intent + + payment.provider_payment_id = stripe_result.id + payment.status = stripe_result.status + payment.payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.provider_payment_data = stripe_result.next_action if stripe_result.status == "requires_action" + payment.save! + + handle_requires_action(payment) if payment.status == "requires_action" + + result.payment = payment + result + + # TODO: global refactor of the error handling + # identified processing errors should mark it as failed to allow reprocess via a new payment + # other should be reprocessed + rescue ::Stripe::AuthenticationError, ::Stripe::CardError, ::Stripe::InvalidRequestError, ::Stripe::PermissionError => e + case e.code + when StripeProvider::AMOUNT_TOO_SMALL_ERROR_CODE + # NOTE: Do not mark the invoice as failed if the amount is too small for Stripe + # For now we keep it as pending, the user can still update it manually + prepare_failed_result(e, payable_payment_status: :pending) + when StripeProvider::NEED_3DS_ERROR_CODE + prepare_failed_result(e, should_retry: payment_provider.supports_3ds) + else + prepare_failed_result(e) + end + rescue ::Stripe::IdempotencyError => e + prepare_failed_result(e, payable_payment_status: :pending) + rescue ::Stripe::RateLimitError => e + # Allow auto-retry with idempotency key + raise Invoices::Payments::RateLimitError, e + rescue ::Stripe::APIConnectionError => e + # Allow auto-retry with idempotency key + raise Invoices::Payments::ConnectionError, e + rescue ::Stripe::StripeError => e + prepare_failed_result(e, reraise: true) + end + + private + + attr_reader :payment, :reference, :metadata, :invoice, :provider_customer + + delegate :payment_provider, to: :provider_customer + + def payable_had_authentication_error? + @had_authentication_error ||= payment.payable.payments.where(error_code: StripeProvider::NEED_3DS_ERROR_CODE).exists? + end + + def handle_requires_action(payment) + SendWebhookJob.perform_later("payment.requires_action", payment) + end + + def stripe_payment_method + payment_method_id = if invoice.organization.feature_flag_enabled?(:multiple_payment_methods) + payment&.payment_method&.provider_method_id + else + provider_customer.payment_method_id + end + + if payment_method_id + # NOTE: Check if payment method still exists + check_result = PaymentProviderCustomers::Stripe::CheckPaymentMethodService.call( + stripe_customer: provider_customer, + payment_method_id: + ) + return check_result.payment_method.id if check_result.success? + end + + # NOTE: Retrieve list of existing payment_methods + payment_method = ::Stripe::Customer.list_payment_methods( + provider_customer.provider_customer_id, + {}, + {api_key: payment_provider.secret_key} + ).first + + if invoice.organization.feature_flag_enabled?(:multiple_payment_methods) + # TODO: Double check + # payment.payment_method.update!(provider_method_id: payment_method&.id) if payment.payment_method + else + provider_customer.update!(payment_method_id: payment_method&.id) + end + + payment_method&.id + end + + def update_payment_method_id + stripe_customer = ::Stripe::Customer.retrieve( + provider_customer.provider_customer_id, + {api_key: payment_provider.secret_key} + ) + + # TODO: stripe customer should be updated/deleted + # TODO: deliver error webhook + # TODO(payment): update payment status + return if stripe_customer.deleted? + + if (payment_method_id = stripe_customer.invoice_settings.default_payment_method || stripe_customer.default_source) + provider_customer.update!(payment_method_id:) + end + end + + def create_payment_intent + update_payment_method_id + + ::Stripe::PaymentIntent.create( + payment_intent_payload, + { + api_key: payment_provider.secret_key, + idempotency_key: "payment-#{payment.id}" + } + ) + end + + def enriched_metadata + metadata.merge( + { + lago_payment_id: payment.id, + lago_payable_id: payment.payable_id, + lago_payable_type: payment.payable_type, + lago_customer_id: payment.payable.customer_id, + lago_organization_id: payment.payable.organization_id, + lago_billing_entity_id: payment.payable.billing_entity.id + } + ) + end + + def payment_intent_payload + payload = { + amount: payment.amount_cents, + currency: payment.amount_currency.downcase, + customer: provider_customer.provider_customer_id, + payment_method_types: provider_customer.provider_payment_methods, + confirm: true, + off_session: off_session?, + return_url: success_redirect_url, + error_on_requires_action: error_on_requires_action?, + description: reference, + metadata: enriched_metadata + } + + if provider_customer.provider_payment_methods == ["customer_balance"] + payload.merge!(customer_balance_fields) + else + payload[:payment_method] = stripe_payment_method + end + + # NOTE: if the payable had 3ds errors before, if so, we remove the off_session flag to handle 3ds + # ideally, we want to ensure that the error happened with the same payment method + if payable_had_authentication_error? + payload.delete :off_session + payload.delete :error_on_requires_action + end + + payload + end + + def customer_balance_fields + { + payment_method_data: {type: "customer_balance"}, + payment_method_options: { + customer_balance: { + funding_type: "bank_transfer", + bank_transfer: bank_transfer_type + } + } + } + end + + def bank_transfer_type + currency = payment.amount_currency.downcase + return handle_eu_bank_transfer if currency == "eur" + + transfer_types = { + "usd" => {type: "us_bank_transfer"}, + "gbp" => {type: "gb_bank_transfer"}, + "jpy" => {type: "jp_bank_transfer"}, + "mxn" => {type: "mx_bank_transfer"} + } + transfer_types[currency] + end + + def handle_eu_bank_transfer + customer_country = payment.customer.country&.upcase + billing_entity_country = payment.customer.billing_entity.country&.upcase + + country = + if PaymentProviders::StripeProvider::SUPPORTED_EU_BANK_TRANSFER_COUNTRIES.include?(customer_country) + customer_country + elsif PaymentProviders::StripeProvider::SUPPORTED_EU_BANK_TRANSFER_COUNTRIES.include?(billing_entity_country) + billing_entity_country + else + result.service_failure!( + code: "missing_country", + message: "No country found for customer or organization supported for EU bank transfer payload" + ).raise_if_error! + end + + { + type: "eu_bank_transfer", + eu_bank_transfer: {country: country} + } + end + + def success_redirect_url + payment_provider.success_redirect_url.presence || ::PaymentProviders::StripeProvider::SUCCESS_REDIRECT_URL + end + + # NOTE: Due to RBI limitation, all indians payment should be off_session + # to permit 3D secure authentication + # https://docs.stripe.com/india-recurring-payments + def off_session? + return false if invoice.customer.country == "IN" + return false if provider_customer.provider_payment_methods == ["customer_balance"] + + true + end + + # NOTE: Same as off_session? + def error_on_requires_action? + invoice.customer.country != "IN" + end + + def prepare_failed_result(error, reraise: false, payment_status: :failed, payable_payment_status: :failed, should_retry: false) + result.error_message = error.message + result.error_code = error.code + result.reraise = reraise + result.should_retry = should_retry + + # stripe may return us a Stripe::CardError error if payment_intent was created, but it's processing failed, in this case error would contain payment_intent id + payment.update!( + status: :failed, + payable_payment_status:, + provider_payment_id: error.error&.payment_intent&.id, + error_code: error.code + ) + + result.service_failure!(code: "stripe_error", message: error.message, error:) + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/refresh_webhook_service.rb b/app/services/payment_providers/stripe/refresh_webhook_service.rb new file mode 100644 index 0000000..482e631 --- /dev/null +++ b/app/services/payment_providers/stripe/refresh_webhook_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class RefreshWebhookService < BaseService + Result = BaseResult + + def call + ::Stripe::WebhookEndpoint.update( + payment_provider.webhook_id, + webhook_endpoint_shared_params, + {api_key:} + ) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ::Stripe::AuthenticationError => e + deliver_error_webhook(action: "payment_provider.register_webhook", error: e) + result + rescue ::Stripe::InvalidRequestError => e + # Note: Since we're updating an existing endpoint, it shouldn't happen + raise if e.message != "You have reached the maximum of 16 test webhook endpoints." + + deliver_error_webhook(action: "payment_provider.register_webhook", error: e) + result + end + end + end +end diff --git a/app/services/payment_providers/stripe/register_webhook_service.rb b/app/services/payment_providers/stripe/register_webhook_service.rb new file mode 100644 index 0000000..63fe9ff --- /dev/null +++ b/app/services/payment_providers/stripe/register_webhook_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class RegisterWebhookService < BaseService + def initialize(payment_provider, version: ::Stripe.api_version) + @version = version + super(payment_provider) + end + + def call + params = webhook_endpoint_shared_params + params[:api_version] = version + + stripe_webhook = ::Stripe::WebhookEndpoint.create( + params, + {api_key:} + ) + + payment_provider.update!( + webhook_id: stripe_webhook.id, + webhook_secret: stripe_webhook.secret + ) + + result.payment_provider = payment_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ::Stripe::AuthenticationError, ::Stripe::PermissionError => e + deliver_error_webhook(action: "payment_provider.register_webhook", error: e) + result + rescue ::Stripe::InvalidRequestError => e + raise if e.message != "You have reached the maximum of 16 test webhook endpoints." + + deliver_error_webhook(action: "payment_provider.register_webhook", error: e) + result + end + + private + + attr_reader :version + end + end +end diff --git a/app/services/payment_providers/stripe/validate_incoming_webhook_service.rb b/app/services/payment_providers/stripe/validate_incoming_webhook_service.rb new file mode 100644 index 0000000..c0a544c --- /dev/null +++ b/app/services/payment_providers/stripe/validate_incoming_webhook_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + class ValidateIncomingWebhookService < BaseService + def initialize(payload:, signature:, payment_provider:) + @payload = payload + @signature = signature + @provider = payment_provider + + super + end + + def call + ::Stripe::Webhook::Signature.verify_header( + payload, + signature, + webhook_secret, + tolerance: ::Stripe::Webhook::DEFAULT_TOLERANCE + ) + + result + rescue ::Stripe::SignatureVerificationError + result.service_failure!(code: "webhook_error", message: "Invalid signature") + end + + private + + attr_reader :payload, :signature, :provider + + def webhook_secret + provider.webhook_secret + end + end + end +end diff --git a/app/services/payment_providers/stripe/webhooks/base_service.rb b/app/services/payment_providers/stripe/webhooks/base_service.rb new file mode 100644 index 0000000..85a8b20 --- /dev/null +++ b/app/services/payment_providers/stripe/webhooks/base_service.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Webhooks + class BaseService < BaseService + def initialize(organization_id:, event:) + @organization = Organization.find(organization_id) + @event = event + + super + end + + private + + attr_reader :organization, :event + + PAYMENT_SERVICE_CLASS_MAP = { + "Invoice" => Invoices::Payments::StripeService, + "PaymentRequest" => PaymentRequests::Payments::StripeService + }.freeze + + def metadata + @metadata ||= event.data.object.metadata.to_h.symbolize_keys + end + + def handle_missing_customer + return result if stripe_customer_created_outside_lago? + + # NOTE: Lago customer either: + # - does not exist + # - exists but does not belong to the organization (Happens when the Stripe API key is shared between organizations) + # - exists but was updated to be linked to another stripe customer + return result if metadata_does_not_match_lago_customer? + + result.not_found_failure!(resource: "stripe_customer") + end + + def metadata_does_not_match_lago_customer? + lago_customer = Customer.find_by(id: metadata[:lago_customer_id], organization_id: organization.id) + + lago_customer.nil? || linked_to_another_stripe_customer?(lago_customer) + end + + def linked_to_another_stripe_customer?(lago_customer) + lago_customer.stripe_customer.present? + end + + def stripe_customer_created_outside_lago? + metadata.nil? || !metadata.key?(:lago_customer_id) + end + + # TODO: Move this to a proper factory + def payment_service_klass + payable_type = metadata[:lago_payable_type] || "Invoice" + + PAYMENT_SERVICE_CLASS_MAP.fetch(payable_type) do + raise NameError, "Invalid lago_payable_type: #{payable_type}" + end + end + + def update_payment_status!(status) + payment_service_klass.new.update_payment_status( + organization_id: organization.id, + status:, + stripe_payment: PaymentProviders::StripeProvider::StripePayment.new( + id: event.data.object.id, + status: event.data.object.status, + metadata:, + error_code: event.data.object.to_hash.dig(:last_payment_error, :code) + ) + ).raise_if_error! + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/webhooks/charge_dispute_closed_service.rb b/app/services/payment_providers/stripe/webhooks/charge_dispute_closed_service.rb new file mode 100644 index 0000000..05bf6ac --- /dev/null +++ b/app/services/payment_providers/stripe/webhooks/charge_dispute_closed_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Webhooks + class ChargeDisputeClosedService < BaseService + def call + status = event.data.object.status + reason = event.data.object.reason + provider_payment_id = event.data.object.payment_intent + + payment = Payment.find_by(provider_payment_id:) + return result unless payment + + if status == "lost" + return ::Payments::LoseDisputeService.call(payment:, payment_dispute_lost_at:, reason:) + end + + result + end + + private + + def payment_dispute_lost_at + Time.zone.at(event.created) + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/webhooks/customer_updated_service.rb b/app/services/payment_providers/stripe/webhooks/customer_updated_service.rb new file mode 100644 index 0000000..f278094 --- /dev/null +++ b/app/services/payment_providers/stripe/webhooks/customer_updated_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Webhooks + class CustomerUpdatedService < BaseService + def call + unless stripe_customer + return result if deleted_stripe_customer.present? + return handle_missing_customer + end + + PaymentProviderCustomers::Stripe::UpdatePaymentMethodService.call( + stripe_customer:, + payment_method_id: payment_method_id + ) + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + def stripe_customer_id + event.data.object.id + end + + def stripe_customer_scope + PaymentProviderCustomers::StripeCustomer + .by_provider_id_from_organization(organization.id, stripe_customer_id) + end + + def stripe_customer + @stripe_customer ||= stripe_customer_scope.first + end + + def deleted_stripe_customer + stripe_customer_scope.with_discarded.discarded.first + end + + def payment_method_id + event.data.object.invoice_settings.default_payment_method || event.data.object.default_source + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/webhooks/payment_intent_payment_failed_service.rb b/app/services/payment_providers/stripe/webhooks/payment_intent_payment_failed_service.rb new file mode 100644 index 0000000..6b02da4 --- /dev/null +++ b/app/services/payment_providers/stripe/webhooks/payment_intent_payment_failed_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Webhooks + class PaymentIntentPaymentFailedService < BaseService + def call + update_payment_status! "failed" + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/webhooks/payment_intent_succeeded_service.rb b/app/services/payment_providers/stripe/webhooks/payment_intent_succeeded_service.rb new file mode 100644 index 0000000..e11a1f8 --- /dev/null +++ b/app/services/payment_providers/stripe/webhooks/payment_intent_succeeded_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Webhooks + class PaymentIntentSucceededService < BaseService + def call + @result = update_payment_status! "succeeded" + + if result.payment + ::Payments::SetPaymentMethodAndCreateReceiptJob.perform_later( + payment: result.payment, + provider_payment_method_id: event.data.object.payment_method + ) + end + + result + end + end + end + end +end diff --git a/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb b/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb new file mode 100644 index 0000000..b31b9f5 --- /dev/null +++ b/app/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module PaymentProviders + module Stripe + module Webhooks + class SetupIntentSucceededService < BaseService + include ::Customers::PaymentProviderFinder + + def call + return result if stripe_customer_id.nil? + return handle_missing_customer unless stripe_customer + return result unless valid_payment_method? + + update_stripe_customer_default_payment_method + result.payment_method_id = payment_method_id + + PaymentProviderCustomers::Stripe::UpdatePaymentMethodService.call( + stripe_customer:, + payment_method_id: payment_method_id, + payment_method_details: + ).raise_if_error! + + result.stripe_customer = stripe_customer + result + rescue ::Stripe::PermissionError => e + result.service_failure!(code: "stripe_error", message: e.message) + end + + private + + def stripe_customer + @stripe_customer ||= PaymentProviderCustomers::StripeCustomer + .by_provider_id_from_organization(organization.id, stripe_customer_id) + .first + end + + def stripe_customer_id + event.data.object.customer + end + + def payment_method_id + event.data.object.payment_method + end + + def valid_payment_method? + stripe_payment_method.customer.present? + end + + def stripe_payment_method + @stripe_payment_method ||= ::Stripe::PaymentMethod.retrieve( + payment_method_id, + {api_key: stripe_payment_provider.secret_key} + ) + end + + def payment_method_details + card = stripe_payment_method.try(:card) + + PaymentMethods::CardDetails.new( + type: stripe_payment_method.type, + last4: card&.last4, + brand: card&.display_brand, + expiration_month: card&.exp_month, + expiration_year: card&.exp_year, + card_holder_name: nil, + issuer: nil + ).to_h + end + + def update_stripe_customer_default_payment_method + ::Stripe::Customer.update( + stripe_customer_id, + {invoice_settings: {default_payment_method: payment_method_id}}, + {api_key: stripe_payment_provider.secret_key} + ) + end + + def customer + @customer ||= stripe_customer.customer + end + + def stripe_payment_provider + @stripe_payment_provider ||= payment_provider(customer) + end + end + end + end +end diff --git a/app/services/payment_providers/stripe_service.rb b/app/services/payment_providers/stripe_service.rb new file mode 100644 index 0000000..286dca5 --- /dev/null +++ b/app/services/payment_providers/stripe_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module PaymentProviders + class StripeService < BaseService + # TODO: Split into 2 dedicated `PaymentProviders::Stripe::(Create|Update)Service` + def create_or_update(**args) + payment_provider_result = PaymentProviders::FindService.call( + organization_id: args[:organization_id], + code: args[:code], + id: args[:id], + payment_provider_type: "stripe" + ) + + stripe_provider = if payment_provider_result.success? + payment_provider_result.payment_provider + else + PaymentProviders::StripeProvider.new( + organization_id: args[:organization_id], + code: args[:code] + ) + end + + is_new = stripe_provider.new_record? + old_code = stripe_provider.code + + stripe_provider.secret_key = args[:secret_key] if args.key?(:secret_key) && is_new + stripe_provider.code = args[:code] if args.key?(:code) + stripe_provider.name = args[:name] if args.key?(:name) + stripe_provider.success_redirect_url = args[:success_redirect_url] if args.key?(:success_redirect_url) + stripe_provider.supports_3ds = args[:supports_3ds] if args.key?(:supports_3ds) + stripe_provider.save! + + if is_new + PaymentProviders::Stripe::RegisterWebhookJob.perform_later(stripe_provider) + end + + if payment_provider_code_changed?(stripe_provider, old_code, args) + stripe_provider.customers.update_all(payment_provider_code: args[:code]) # rubocop:disable Rails/SkipsModelValidations + # Until this job is processed, the webhook endpoint will return 400 error + PaymentProviders::Stripe::RefreshWebhookJob.perform_later(stripe_provider) + end + + result.stripe_provider = stripe_provider + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + end +end diff --git a/app/services/payment_receipts/create_service.rb b/app/services/payment_receipts/create_service.rb new file mode 100644 index 0000000..1eb6931 --- /dev/null +++ b/app/services/payment_receipts/create_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module PaymentReceipts + class CreateService < BaseService + Result = BaseResult[:payment_receipt] + + def initialize(payment:) + @payment = payment + @organization = payment&.payable&.organization + @billing_entity = payment&.payable&.billing_entity + super + end + + def call + return result.not_found_failure!(resource: "payment") unless payment + return result.forbidden_failure! unless organization.issue_receipts_enabled? + return result if payment.payable.customer.partner_account? + return result if payment.payable_payment_status.to_s != "succeeded" + + if payment.payment_receipt + result.payment_receipt = payment.payment_receipt + return result + end + + result.payment_receipt = PaymentReceipt.create!(payment:, organization:, billing_entity:) + + SendWebhookJob.perform_later("payment_receipt.created", result.payment_receipt) + Utils::ActivityLog.produce(result.payment_receipt, "payment_receipt.created") + GenerateDocumentsJob.perform_later(payment_receipt: result.payment_receipt, notify: should_deliver_email?) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.payment_receipt = payment.reload.payment_receipt + result + end + + private + + attr_reader :payment, :organization, :billing_entity + + def should_deliver_email? + License.premium? && billing_entity.email_settings.include?("payment_receipt.created") + end + end +end diff --git a/app/services/payment_receipts/files_not_ready_error.rb b/app/services/payment_receipts/files_not_ready_error.rb new file mode 100644 index 0000000..48ef58c --- /dev/null +++ b/app/services/payment_receipts/files_not_ready_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module PaymentReceipts + class FilesNotReadyError < StandardError; end +end diff --git a/app/services/payment_receipts/generate_pdf_service.rb b/app/services/payment_receipts/generate_pdf_service.rb new file mode 100644 index 0000000..45be221 --- /dev/null +++ b/app/services/payment_receipts/generate_pdf_service.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +module PaymentReceipts + class GeneratePdfService < BaseService + def initialize(payment_receipt:, context: nil) + @payment_receipt = payment_receipt + @context = context + + super + end + + def call + return result.not_found_failure!(resource: "payment_receipt") if payment_receipt.blank? + + if should_generate_pdf? + generate_pdf + SendWebhookJob.perform_later("payment_receipt.generated", payment_receipt) + Utils::ActivityLog.produce(payment_receipt, "payment_receipt.generated") + end + + result.payment_receipt = payment_receipt + result + end + + def render_html + Utils::PdfGenerator.new(template:, context: payment_receipt).render_html + end + + private + + attr_reader :payment_receipt, :context + + def generate_pdf + I18n.with_locale(payment_receipt.payment.customer.preferred_document_locale) do + pdf_file = build_pdf_file + xml_file = attach_facturx(pdf_file) if should_generate_facturx_einvoice_xml? + attach_pdf_to_payment_receipt(pdf_file) + payment_receipt.save! + ensure + cleanup_tempfiles(pdf_file, xml_file) + end + end + + def build_pdf_file + pdf_content = Utils::PdfGenerator.call(template:, context: payment_receipt).io.read + + pdf_file = Tempfile.new([payment_receipt.number, ".pdf"]) + pdf_file.binmode + pdf_file.write(pdf_content) + pdf_file.flush + + pdf_file + end + + def attach_facturx(pdf_file) + xml_file = Tempfile.new([payment_receipt.number, ".xml"]) + xml_file.write(EInvoices::Payments::FacturX::CreateService.call(payment: payment_receipt.payment).xml) + xml_file.flush + + Utils::PdfAttachmentService.call(file: pdf_file, attachment: xml_file) + xml_file + end + + def attach_pdf_to_payment_receipt(pdf_file) + payment_receipt.file.attach( + io: File.open(pdf_file.path), + filename: "#{payment_receipt.number}.pdf", + content_type: "application/pdf" + ) + end + + def should_generate_facturx_einvoice_xml? + payment_receipt.billing_entity.einvoicing && BillingEntity::EINVOICING_COUNTRIES.include?(payment_receipt.billing_entity.country.try(:upcase)) + end + + def should_generate_pdf? + return false if ActiveModel::Type::Boolean.new.cast(ENV["LAGO_DISABLE_PDF_GENERATION"]) + + context == "admin" || payment_receipt.file.blank? + end + + def cleanup_tempfiles(pdf_file, xml_file) + pdf_file&.unlink + xml_file&.unlink + end + + def template + "payment_receipts/v1" + end + end +end diff --git a/app/services/payment_receipts/generate_xml_service.rb b/app/services/payment_receipts/generate_xml_service.rb new file mode 100644 index 0000000..a41d8cf --- /dev/null +++ b/app/services/payment_receipts/generate_xml_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module PaymentReceipts + class GenerateXmlService < BaseService + def initialize(payment_receipt:, context: nil) + @payment_receipt = payment_receipt + @context = context + + super + end + + def call + return result.not_found_failure!(resource: "payment_receipt") if payment_receipt.blank? + + if should_generate_xml? + generate_xml + end + + result.payment_receipt = payment_receipt + result + end + + private + + attr_reader :payment_receipt, :context + + def generate_xml + I18n.with_locale(payment_receipt.customer.preferred_document_locale) do + xml_file = build_xml_file + attach_xml_to_payment_receipt(xml_file) + payment_receipt.save! + ensure + cleanup_tempfiles(xml_file) + end + end + + def build_xml_file + xml_file = Tempfile.new([payment_receipt.number, ".xml"]) + xml_file.write(EInvoices::Payments::Ubl::CreateService.call(payment: payment_receipt.payment).xml) + xml_file.flush + + xml_file + end + + def attach_xml_to_payment_receipt(xml_file) + payment_receipt.xml_file.attach( + io: File.open(xml_file.path), + filename: "#{payment_receipt.number}.xml", + content_type: "application/xml" + ) + end + + def cleanup_tempfiles(xml_file) + xml_file&.unlink + end + + def should_generate_xml? + return true if context == "admin" + + payment_receipt.xml_file.blank? && e_invoicing_enabled? + end + + def e_invoicing_enabled? + payment_receipt.billing_entity.einvoicing && BillingEntity::EINVOICING_COUNTRIES.include?(payment_receipt.billing_entity.country.try(:upcase)) + end + end +end diff --git a/app/services/payment_requests/create_service.rb b/app/services/payment_requests/create_service.rb new file mode 100644 index 0000000..14da108 --- /dev/null +++ b/app/services/payment_requests/create_service.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module PaymentRequests + class CreateService < BaseService + def initialize(organization:, params:, dunning_campaign: nil) + @organization = organization + @params = params + @dunning_campaign = dunning_campaign + + super + end + + def call + check_preconditions + return result if result.error + + payment_request = ActiveRecord::Base.transaction do + request = customer.payment_requests.create!( + organization:, + dunning_campaign:, + amount_cents: total_amount_cents, + amount_currency: currency, + email: + ) + + invoices.each do |invoice| + PaymentRequest::AppliedInvoice.create!( + payment_request: request, + invoice: invoice, + organization_id: invoice.organization_id + ) + end + + request + end + + after_commit do + SendWebhookJob.perform_later("payment_request.created", payment_request) + Utils::ActivityLog.produce(payment_request, "payment_request.created") + + payment_result = PaymentRequests::Payments::CreateService.call_async(payable: payment_request, payment_method_params: payment_method) + PaymentRequestMailer.with(payment_request:).requested.deliver_later unless payment_result.success? + end + + result.payment_request = payment_request + + result + end + + private + + attr_reader :organization, :params, :dunning_campaign + + def total_amount_cents + invoices.sum("total_amount_cents - total_paid_amount_cents") - + CreditNote.finalized.where(invoice_id: invoices.select(:id)).sum(:offset_amount_cents) + end + + def check_preconditions + # NOTE: Prevent creation of payment request if: + # - the organization is not premium + # - the customer does not exist + # - there are no invoices + # - the invoices are not overdue + # - the invoices have different currencies + # - the invoices are not ready for payment processing + + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "customer") unless customer + return result.not_found_failure!(resource: "invoice") if invoices.empty? + + if invoices.exists?(payment_overdue: false) + return result.not_allowed_failure!(code: "invoices_not_overdue") + end + + if invoices.pluck(:currency).uniq.size > 1 + return result.not_allowed_failure!(code: "invoices_have_different_currencies") + end + + if invoices.exists?(ready_for_payment_processing: false) + result.not_allowed_failure!(code: "invoices_not_ready_for_payment_processing") + end + end + + def customer + @customer ||= organization.customers.find_by(external_id: params[:external_customer_id]) + end + + def invoices + @invoices ||= customer.invoices.where(id: params[:lago_invoice_ids]) + end + + def email + @email ||= params[:email] || customer.email + end + + def payment_method + @payment_method ||= params[:payment_method]&.to_h || {} + end + + def currency + @currency ||= invoices.first.currency + end + end +end diff --git a/app/services/payment_requests/payments/adyen_service.rb b/app/services/payment_requests/payments/adyen_service.rb new file mode 100644 index 0000000..972a2f5 --- /dev/null +++ b/app/services/payment_requests/payments/adyen_service.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class AdyenService < BaseService + include Lago::Adyen::ErrorHandlable + include Customers::PaymentProviderFinder + include Updatable + + PROVIDER_NAME = "Adyen" + + def initialize(payable = nil) + @payable = payable + + super(nil) + end + + def generate_payment_url + result_url = client.checkout.payment_links_api.payment_links( + Lago::Adyen::Params.new(payment_url_params).to_h + ) + + adyen_success, adyen_error = handle_adyen_response(result_url) + return result.service_failure!(code: adyen_error.code, message: adyen_error.msg) unless adyen_success + + result.payment_url = result_url.response["url"] + + result + rescue Adyen::AdyenError => e + result.third_party_failure!(third_party: PROVIDER_NAME, error_code: e.code, error_message: e.msg) + end + + def update_payment_status(provider_payment_id:, status:, metadata: {}) + payment = if metadata[:payment_type] == "one-time" + create_payment(provider_payment_id:, metadata:) + else + Payment.find_by(provider_payment_id:) + end + return result.not_found_failure!(resource: "adyen_payment") unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + update_invoices_paid_amount_cents(payment_status: payable_payment_status) + reset_customer_dunning_campaign_status(payable_payment_status) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def client + @client ||= Adyen::Client.new( + api_key: adyen_payment_provider.api_key, + env: adyen_payment_provider.environment, + live_url_prefix: adyen_payment_provider.live_prefix + ) + end + + def adyen_payment_provider + @adyen_payment_provider ||= payment_provider(customer) + end + + def payment_url_params + prms = { + reference: "Overdue invoices", + amount: { + value: payable.total_amount_cents, + currency: payable.currency.upcase + }, + merchantAccount: adyen_payment_provider.merchant_account, + returnUrl: success_redirect_url, + shopperReference: customer.external_id, + storePaymentMethodMode: "enabled", + recurringProcessingModel: "UnscheduledCardOnFile", + expiresAt: Time.current + 70.days, # max link TTL + metadata: { + lago_customer_id: customer.id, + lago_payable_id: payable.id, + lago_payable_type: payable.class.name, + payment_type: "one-time" + } + } + prms[:shopperEmail] = customer.email if customer.email + prms + end + + def success_redirect_url + adyen_payment_provider.success_redirect_url.presence || ::PaymentProviders::AdyenProvider::SUCCESS_REDIRECT_URL + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true) + payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice:, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def payment_status_succeeded?(payment_status) + payment_status.to_sym == :succeeded + end + + def create_payment(provider_payment_id:, metadata:) + @payable = PaymentRequest.find(metadata[:lago_payable_id]) + + payable.increment_payment_attempts! + + Payment.new( + organization_id: payable.organization_id, + payable:, + customer:, + payment_provider_id: adyen_payment_provider.id, + payment_provider_customer_id: customer.adyen_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency.upcase, + provider_payment_id: + ) + end + + def deliver_error_webhook(adyen_error) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: customer.adyen_customer.provider_customer_id, + provider_error: { + message: adyen_error.msg, + error_code: adyen_error.code + } + }) + end + + def reset_customer_dunning_campaign_status(payment_status) + return unless payment_status_succeeded?(payment_status) + return unless payable.try(:dunning_campaign) + + customer.reset_dunning_campaign! + end + end + end +end diff --git a/app/services/payment_requests/payments/cashfree_service.rb b/app/services/payment_requests/payments/cashfree_service.rb new file mode 100644 index 0000000..c3dc473 --- /dev/null +++ b/app/services/payment_requests/payments/cashfree_service.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class CashfreeService < BaseService + include Customers::PaymentProviderFinder + include Updatable + + PENDING_STATUSES = %w[PARTIALLY_PAID].freeze + SUCCESS_STATUSES = %w[PAID].freeze + FAILED_STATUSES = %w[EXPIRED CANCELLED].freeze + + PROVIDER_NAME = "Cashfree" + + def initialize(payable = nil) + @payable = payable + + super + end + + def generate_payment_url + payment_link_response = create_payment_link(payment_url_params) + result.payment_url = JSON.parse(payment_link_response.body)["link_url"] + + result + rescue LagoHttpClient::HttpError => e + result.third_party_failure!(third_party: PROVIDER_NAME, error_code: e.error_code, error_message: e.error_body) + end + + def update_payment_status(organization_id:, status:, cashfree_payment:) + payment = if cashfree_payment.metadata[:payment_type] == "one-time" + create_payment(cashfree_payment) + else + Payment.find_by(provider_payment_id: cashfree_payment.id) + end + return result.not_found_failure!(resource: "cashfree_payment") unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + update_invoices_paid_amount_cents(payment_status: payable_payment_status) + reset_customer_dunning_campaign_status(payable_payment_status) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def cashfree_payment_provider + @cashfree_payment_provider ||= payment_provider(customer) + end + + def client + @client ||= LagoHttpClient::Client.new(::PaymentProviders::CashfreeProvider::BASE_URL) + end + + def create_payment_link(body) + client.post_with_response(body, { + "accept" => "application/json", + "content-type" => "application/json", + "x-client-id" => cashfree_payment_provider.client_id, + "x-client-secret" => cashfree_payment_provider.client_secret, + "x-api-version" => ::PaymentProviders::CashfreeProvider::API_VERSION + }) + end + + def success_redirect_url + cashfree_payment_provider.success_redirect_url.presence || ::PaymentProviders::CashfreeProvider::SUCCESS_REDIRECT_URL + end + + def payment_url_params + { + customer_details: { + customer_phone: customer.phone || "9999999999", + customer_email: customer.email, + customer_name: customer.name + }, + link_notify: { + send_sms: false, + send_email: false + }, + link_meta: { + upi_intent: true, + return_url: success_redirect_url + }, + link_notes: { + lago_customer_id: customer.id, + lago_payable_id: payable.id, + lago_payable_type: payable.class.name, + payment_issuing_date: payable.created_at.iso8601, + payment_type: "one-time" + }, + link_id: "#{SecureRandom.uuid}.#{payable.payment_attempts}", + link_amount: payable.total_amount_cents / 100.to_f, + link_currency: payable.currency.upcase, + link_purpose: payable.id, + link_expiry_time: (Time.current + 10.minutes).iso8601, + link_partial_payments: false, + link_auto_reminders: false + } + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true) + payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice:, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def payment_status_succeeded?(payment_status) + payment_status.to_sym == :succeeded + end + + def create_payment(cashfree_payment) + @payable = PaymentRequest.find(cashfree_payment.metadata[:lago_payable_id]) + + payable.increment_payment_attempts! + + Payment.new( + organization_id: payable.organization_id, + payable:, + customer:, + payment_provider_id: cashfree_payment_provider.id, + payment_provider_customer_id: customer.cashfree_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency.upcase, + provider_payment_id: cashfree_payment.id + ) + end + + def deliver_error_webhook(cashfree_error) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: customer.cashfree_customer.id, + provider_error: { + message: cashfree_error.error_body, + error_code: cashfree_error.error_code + } + }) + end + + def reset_customer_dunning_campaign_status(payment_status) + return unless payment_status_succeeded?(payment_status) + return unless payable.try(:dunning_campaign) + + customer.reset_dunning_campaign! + end + end + end +end diff --git a/app/services/payment_requests/payments/create_service.rb b/app/services/payment_requests/payments/create_service.rb new file mode 100644 index 0000000..be14b34 --- /dev/null +++ b/app/services/payment_requests/payments/create_service.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class CreateService < BaseService + include Customers::PaymentProviderFinder + include Updatable + + def initialize(payable:, payment_provider: nil, payment_method_params: {}) + @payable = payable + @provider = payment_provider&.to_sym + @payment_method_params = payment_method_params + + super + end + + def call + return result.not_found_failure!(resource: "payment_provider") unless provider + + result.payable = payable + return result unless should_process_payment? + + unless payable.total_amount_cents.positive? + update_payable_payment_status(payment_status: :succeeded) + return result + end + + if processing_payment + # Payment is being processed, return the existing payment + # Status will be updated via webhooks + result.payment = processing_payment + return result + end + + payable.increment_payment_attempts! + + payment ||= Payment.create_with( + organization_id: payable.organization_id, + payment_provider_id: current_payment_provider.id, + payment_provider_customer_id: current_payment_provider_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency, + status: "pending", + customer_id: payable.customer_id + ).find_or_create_by!( + payable:, + payable_payment_status: "pending" + ) + + if organization.feature_flag_enabled?(:multiple_payment_methods) + payment.payment_method_id = determine_payment_method&.id + payment.save! + end + + result.payment = payment + + payment_result = ::PaymentProviders::CreatePaymentFactory.new_instance( + provider:, + payment:, + reference: "#{payable.billing_entity.name} - Overdue invoices", + metadata: { + lago_customer_id: payable.customer_id, + lago_payable_id: payable.id, + lago_payable_type: payable.class.name + } + ).call! + + update_payable_payment_status(payment_status: payment_result.payment.payable_payment_status) + update_invoices_payment_status(payment_status: payment_result.payment.payable_payment_status) + update_invoices_paid_amount_cents(payment_status: payment_result.payment.payable_payment_status) + + PaymentReceipts::CreateJob.perform_later(payment) if payment.payable.organization.issue_receipts_enabled? + + PaymentRequestMailer.with(payment_request: payable).requested.deliver_later if payable.payment_failed? + + result + rescue BaseService::ServiceFailure => e + PaymentRequestMailer.with(payment_request: payable).requested.deliver_later + result.payment = e.result.payment + deliver_error_webhook(e.result) + update_payable_payment_status(payment_status: e.result.payment.payable_payment_status) + + # Some errors should be investigated and need to be raised + raise if e.result.reraise + + result + end + + def call_async + return result.not_found_failure!(resource: "payment_provider") unless provider + + PaymentRequests::Payments::CreateJob.perform_later(payable:, payment_provider: provider, payment_method_params:) + + result.payment_provider = provider + result + end + + private + + attr_reader :payable, :payment_method_params + + delegate :customer, :organization, to: :payable + + def provider + @provider ||= payable.customer.payment_provider&.to_sym + end + + def should_process_payment? + return false if payable.payment_succeeded? + return false if current_payment_provider.blank? + return false unless current_payment_provider_customer&.provider_customer_id + + payable.invoices.all?(&:ready_for_payment_processing) + end + + def current_payment_provider + @current_payment_provider ||= payment_provider(customer) + end + + def current_payment_provider_customer + @current_payment_provider_customer ||= customer.payment_provider_customers + .find_by(payment_provider_id: current_payment_provider.id) + end + + def update_payable_payment_status(payment_status:) + PaymentRequests::UpdateService.call!( + payable: payable, + params: { + # NOTE: A proper `processing` payment status should be introduced for invoices + payment_status: (payment_status.to_s == "processing") ? :pending : payment_status, + ready_for_payment_processing: payment_status.to_sym == :failed + }, + webhook_notification: payment_status.to_sym == :succeeded + ) + end + + def update_invoices_payment_status(payment_status:) + payable.invoices.each do |invoice| + Invoices::UpdateService.call!( + invoice:, + params: { + # NOTE: A proper `processing` payment status should be introduced for invoices + payment_status: (payment_status.to_s == "processing") ? :pending : payment_status, + ready_for_payment_processing: payment_status.to_sym == :failed + }, + webhook_notification: payment_status.to_sym == :succeeded + ) + end + end + + def deliver_error_webhook(payment_result) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: current_payment_provider_customer.provider_customer_id, + provider_error: { + message: payment_result.error_message, + error_code: payment_result.error_code + } + }) + end + + def processing_payment + @processing_payment ||= Payment.find_by( + payable_id: payable.id, + payment_provider_id: current_payment_provider.id, + payment_provider_customer_id: current_payment_provider_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency, + payable_payment_status: "processing" + ) + end + + def determine_payment_method + @determine_payment_method ||= if payment_method_params.present? + determine_override_payment_method + else + customer.default_payment_method + end + end + + def determine_override_payment_method + return nil if payment_method_params[:payment_method_type] == "manual" + + if payment_method_params[:payment_method_id].present? + customer.payment_methods.find_by(id: payment_method_params[:payment_method_id]) + else + customer.default_payment_method + end + end + end + end +end diff --git a/app/services/payment_requests/payments/deliver_error_webhook_service.rb b/app/services/payment_requests/payments/deliver_error_webhook_service.rb new file mode 100644 index 0000000..6967489 --- /dev/null +++ b/app/services/payment_requests/payments/deliver_error_webhook_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class DeliverErrorWebhookService < BaseService + def initialize(payment_request, params) + @payment_request = payment_request + @params = params + end + + def call_async + SendWebhookJob.perform_later("payment_request.payment_failure", payment_request, params) + + result + end + + private + + attr_reader :payment_request, :params + end + end +end diff --git a/app/services/payment_requests/payments/flutterwave_service.rb b/app/services/payment_requests/payments/flutterwave_service.rb new file mode 100644 index 0000000..8ffd474 --- /dev/null +++ b/app/services/payment_requests/payments/flutterwave_service.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class FlutterwaveService < BaseService + include Customers::PaymentProviderFinder + include Updatable + + def initialize(payable = nil) + @payable = payable + + super(nil) + end + + def call + result.payment_url = payment_url + result + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + + result.service_failure!(code: "action_script_runtime_error", message: e.message) + end + + def update_payment_status(organization_id:, status:, flutterwave_payment:) + payment = if flutterwave_payment.metadata[:payment_type] == "one-time" + create_payment(flutterwave_payment) + else + Payment.find_by(provider_payment_id: flutterwave_payment.id) + end + return result.not_found_failure!(resource: "flutterwave_payment") unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + update_invoices_paid_amount_cents(payment_status: payable_payment_status) + reset_customer_dunning_campaign_status(payable_payment_status) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :payable + + delegate :organization, :customer, to: :payable + + def payment_url + response = create_checkout_session + + response["data"]["link"] + end + + def create_checkout_session + body = { + amount: Money.from_cents(payable.total_amount_cents, payable.currency).to_f, + tx_ref: "lago_payment_request_#{payable.id}", + currency: payable.currency.upcase, + redirect_url: success_redirect_url, + customer: customer_params, + customizations: customizations_params, + configuration: configuration_params, + meta: meta_params + } + http_client.post_with_response(body, headers) + end + + def customer_params + { + email: customer.email, + phone_number: customer.phone || "", + name: customer.name || customer.email + } + end + + def customizations_params + { + title: "#{organization.name} - Payment Request", + description: "Payment for invoices: #{invoice_numbers}", + logo: organization.logo_url + }.compact + end + + def configuration_params + { + session_duration: 30 + } + end + + def meta_params + { + lago_customer_id: customer.id, + lago_payment_request_id: payable.id, + lago_organization_id: organization.id, + lago_invoice_ids: payable.invoices.pluck(:id).join(",") + } + end + + def invoice_numbers + payable.invoices.pluck(:number).join(", ") + end + + def success_redirect_url + flutterwave_payment_provider.success_redirect_url.presence || + PaymentProviders::FlutterwaveProvider::SUCCESS_REDIRECT_URL + end + + def flutterwave_payment_provider + @flutterwave_payment_provider ||= payment_provider(customer) + end + + def headers + { + "Authorization" => "Bearer #{flutterwave_payment_provider.secret_key}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + end + + def http_client + @http_client ||= LagoHttpClient::Client.new(flutterwave_payment_provider.api_url) + end + + def deliver_error_webhook(http_error) + return unless payable.organization.webhook_endpoints.any? + SendWebhookJob.perform_later( + "payment_request.payment_failure", + payable, + provider_customer_id: flutterwave_customer&.provider_customer_id, + provider_error: { + message: http_error.message, + error_code: http_error.error_code + } + ) + end + + def flutterwave_customer + @flutterwave_customer ||= customer.flutterwave_customer + end + + def create_payment(flutterwave_payment) + @payable = PaymentRequest.find(flutterwave_payment.metadata[:lago_payable_id]) + + payable.increment_payment_attempts! + + Payment.new( + organization_id: payable.organization_id, + payable:, + customer:, + payment_provider_id: flutterwave_payment_provider.id, + payment_provider_customer_id: customer.flutterwave_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency.upcase, + provider_payment_id: flutterwave_payment.id + ) + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true) + payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice:, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def payment_status_succeeded?(payment_status) + payment_status.to_sym == :succeeded + end + + def reset_customer_dunning_campaign_status(payment_status) + return unless payment_status_succeeded?(payment_status) + return unless payable.try(:dunning_campaign) + + customer.reset_dunning_campaign! + end + end + end +end diff --git a/app/services/payment_requests/payments/generate_payment_url_service.rb b/app/services/payment_requests/payments/generate_payment_url_service.rb new file mode 100644 index 0000000..cc73743 --- /dev/null +++ b/app/services/payment_requests/payments/generate_payment_url_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class GeneratePaymentUrlService < BaseService + include Customers::PaymentProviderFinder + + PROVIDER_GOCARDLESS = "gocardless" + + def initialize(payable:) + @payable = payable + @provider = payable.customer.payment_provider.to_s + + super + end + + def call + return result.single_validation_failure!(error_code: "no_linked_payment_provider") if provider.blank? + return result.single_validation_failure!(error_code: "invalid_payment_provider") if gocardless_provider? + return result.single_validation_failure!(error_code: "invalid_payment_status") if payable.payment_succeeded? + + if current_payment_provider.blank? + return result.single_validation_failure!(error_code: "missing_payment_provider") + end + + if !current_payment_provider_customer || + current_payment_provider_customer.provider_customer_id.blank? && current_payment_provider_customer&.require_provider_payment_id? + return result.single_validation_failure!(error_code: "missing_payment_provider_customer") + end + + payment_url_result = PaymentRequests::Payments::PaymentProviders::Factory.new_instance(payable:).generate_payment_url + payment_url_result.raise_if_error! + + return result.single_validation_failure!(error_code: "payment_provider_error") if payment_url_result.payment_url.blank? + + payment_url_result + rescue BaseService::ThirdPartyFailure => e + deliver_error_webhook(e) + + e.result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :payable, :provider + + delegate :customer, to: :payable + + def gocardless_provider? + provider == PROVIDER_GOCARDLESS + end + + def current_payment_provider_customer + @current_payment_provider_customer ||= customer.payment_provider_customers + .find_by(payment_provider_id: current_payment_provider.id) + end + + def current_payment_provider + @current_payment_provider ||= payment_provider(customer) + end + + def deliver_error_webhook(payment_url_result) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: current_payment_provider_customer.provider_customer_id, + provider_error: { + message: payment_url_result.error_message, + error_code: payment_url_result.error_code + } + }) + end + end + end +end diff --git a/app/services/payment_requests/payments/gocardless_service.rb b/app/services/payment_requests/payments/gocardless_service.rb new file mode 100644 index 0000000..ff9cce3 --- /dev/null +++ b/app/services/payment_requests/payments/gocardless_service.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class GocardlessService < BaseService + include Customers::PaymentProviderFinder + include Updatable + + class MandateNotFoundError < StandardError + DEFAULT_MESSAGE = "No mandate available for payment" + ERROR_CODE = "no_mandate_error" + + def initialize(msg = DEFAULT_MESSAGE) + super + end + + def code + ERROR_CODE + end + end + + def initialize(payable = nil) + @payable = payable + + super(nil) + end + + def update_payment_status(provider_payment_id:, status:) + payment = Payment.find_by(provider_payment_id:) + return result.not_found_failure!(resource: "gocardless_payment") unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + update_payable_payment_status(payment_status: payable_payment_status) + update_invoices_payment_status(payment_status: payable_payment_status) + update_invoices_paid_amount_cents(payment_status: payable_payment_status) + reset_customer_dunning_campaign_status(payable_payment_status) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def client + @client ||= GoCardlessPro::Client.new( + access_token: gocardless_payment_provider.access_token, + environment: gocardless_payment_provider.environment + ) + end + + def gocardless_payment_provider + @gocardless_payment_provider ||= payment_provider(customer) + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true) + result.payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice:, + params: { + payment_status:, + ready_for_payment_processing: !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def payment_status_succeeded?(payment_status) + payment_status.to_sym == :succeeded + end + + def reset_customer_dunning_campaign_status(payment_status) + return unless payment_status_succeeded?(payment_status) + return unless payable.try(:dunning_campaign) + + customer.reset_dunning_campaign! + end + end + end +end diff --git a/app/services/payment_requests/payments/moneyhash_service.rb b/app/services/payment_requests/payments/moneyhash_service.rb new file mode 100644 index 0000000..8748665 --- /dev/null +++ b/app/services/payment_requests/payments/moneyhash_service.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class MoneyhashService < BaseService + include Customers::PaymentProviderFinder + + def initialize(payable = nil) + @payable = payable + + super(nil) + end + + def create + result.payable = payable + return result.not_found_failure!(resource: "moneyhash_customer") if customer&.moneyhash_customer&.provider_customer_id.blank? + return result.not_found_failure!(resource: "payment_method") if moneyhash_payment_method_id.nil? + return result unless should_process_payment? + + unless payable.total_amount_cents.positive? + update_payable_payment_status(payment_status: :succeeded) + return result + end + + payable.increment_payment_attempts! + + moneyhash_result = create_moneyhash_payment + return result unless moneyhash_result + mh_status = moneyhash_result.dig("data", "status") + + payment = Payment.new( + organization_id: payable.organization_id, + payable: payable, + customer:, + payment_provider_id: moneyhash_payment_provider.id, + payment_provider_customer_id: customer.moneyhash_customer.id, + amount_cents: payable.amount_cents, + amount_currency: payable.currency&.upcase, + provider_payment_id: moneyhash_result.dig("data", "id"), + status: moneyhash_payment_provider.determine_payment_status(mh_status) + ) + + payment.save! + + payable_payment_status = moneyhash_payment_provider.payable_payment_status(mh_status) + + update_payable_payment_status( + payment_status: payable_payment_status, + processing: payment.status == "pending" + ) + update_invoices_payment_status( + payment_status: payable_payment_status, + processing: payment.status == "pending" + ) + result.payment = payment + result.payable_payment_status = payable_payment_status + result + end + + def update_payment_status(organization_id:, provider_payment_id:, status:, metadata: {}) + payment_obj = Payment.find_or_initialize_by(provider_payment_id: provider_payment_id) + payment = if payment_obj.persisted? + payment_obj + else + create_payment(provider_payment_id:, metadata:) + end + + return handle_missing_payment(organization_id, metadata) unless payment + + result.payment = payment + result.payable = payment.payable + return result if payment.payable.payment_succeeded? + payment.update!(status: moneyhash_payment_provider.determine_payment_status(status)) + + processing = status == "pending" + payment_status = moneyhash_payment_provider.payable_payment_status(status) + update_payable_payment_status(payment_status:, processing:) + update_invoices_payment_status(payment_status:, processing:) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + result + rescue BaseService::FailedResult => e + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + result.fail_with_error!(e) + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def handle_missing_payment(organization_id, metadata) + return result unless metadata&.key?("lago_payable_id") + payment_request = PaymentRequest.find_by(id: metadata["lago_payable_id"], organization_id:) + return result unless payment_request + return result if payment_request.payment_failed? + + result.not_found_failure!(resource: "moneyhash_payment") + end + + def create_payment(provider_payment_id:, metadata:) + @payable = payable || PaymentRequest.find_by(id: metadata["lago_payable_id"]) + + unless payable + result.not_found_failure!(resource: "payment_request") + return + end + + payable.increment_payment_attempts! + + Payment.new( + organization_id: payable.organization_id, + payable:, + customer:, + payment_provider_id: moneyhash_payment_provider.id, + payment_provider_customer_id: customer.moneyhash_customer.id, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency&.upcase, + provider_payment_id: + ) + end + + def moneyhash_payment_method_id + if organization.feature_flag_enabled?(:multiple_payment_methods) + customer.default_payment_method&.provider_method_id + else + customer.moneyhash_customer.payment_method_id + end + end + + def should_process_payment? + return false if payable.payment_succeeded? + return false if moneyhash_payment_provider.blank? + + !!customer&.moneyhash_customer&.provider_customer_id + end + + def client + @client || LagoHttpClient::Client.new("#{::PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + end + + def headers + { + "Content-Type" => "application/json", + "x-Api-Key" => moneyhash_payment_provider.api_key + } + end + + def moneyhash_payment_provider + @moneyhash_payment_provider ||= payment_provider(customer) + end + + def create_moneyhash_payment + payment_params = { + amount: payable.total_amount_cents.div(100).to_f, + amount_currency: payable.currency.upcase, + flow_id: moneyhash_payment_provider.flow_id, + billing_data: customer.moneyhash_customer.mh_billing_data, + customer: customer.moneyhash_customer.provider_customer_id, + webhook_url: moneyhash_payment_provider.webhook_end_point, + merchant_initiated: true, + payment_type: "UNSCHEDULED", + card_token: moneyhash_payment_method_id, + recurring_data: { + agreement_id: customer.id + }, + custom_fields: { + # plan/subscription + lago_plan_id: payable&.invoices&.first&.subscriptions&.first&.plan_id.to_s, + lago_subscription_external_id: payable&.invoices&.first&.subscriptions&.first&.external_id.to_s, + # payable + lago_payable_id: payable.id, + lago_payable_type: payable.class.name, + # mit flag + lago_mit: true, + # service + lago_mh_service: "PaymentRequests::Payments::MoneyhashService", + # request + lago_request: "create_payment_request" + } + } + + payment_params[:custom_fields].merge!(payable.customer.moneyhash_customer.mh_custom_fields) + + response = client.post_with_response(payment_params, headers) + JSON.parse(response.body) + rescue LagoHttpClient::HttpError => e + deliver_error_webhook(e) + update_payable_payment_status(payment_status: :failed, deliver_webhook: false) + nil + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true, processing: false) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true, processing: false) + result.payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice: invoice, + params: { + payment_status:, + ready_for_payment_processing: !processing && payment_status.to_sym != :succeeded + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def deliver_error_webhook(moneyhash_error) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: customer.moneyhash_customer.provider_customer_id, + provider_error: { + message: moneyhash_error.message, + error_code: moneyhash_error.error_code + } + }) + end + end + end +end diff --git a/app/services/payment_requests/payments/payment_providers/factory.rb b/app/services/payment_requests/payments/payment_providers/factory.rb new file mode 100644 index 0000000..ebea15e --- /dev/null +++ b/app/services/payment_requests/payments/payment_providers/factory.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + module PaymentProviders + class Factory + def self.new_instance(payable:) + service_class(payable.customer&.payment_provider).new(payable) + end + + def self.service_class(payment_provider) + case payment_provider&.to_s + when "stripe" + PaymentRequests::Payments::StripeService + when "adyen" + PaymentRequests::Payments::AdyenService + when "cashfree" + PaymentRequests::Payments::CashfreeService + when "flutterwave" + PaymentRequests::Payments::FlutterwaveService + when "gocardless" + PaymentRequests::Payments::GocardlessService + when "moneyhash" + PaymentRequests::Payments::MoneyhashService + else + raise(NotImplementedError) + end + end + end + end + end +end diff --git a/app/services/payment_requests/payments/stripe_service.rb b/app/services/payment_requests/payments/stripe_service.rb new file mode 100644 index 0000000..f62c5c8 --- /dev/null +++ b/app/services/payment_requests/payments/stripe_service.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + class StripeService < BaseService + include Customers::PaymentProviderFinder + include Updatable + + PROVIDER_NAME = "Stripe" + + def initialize(payable = nil) + @payable = payable + + super(nil) + end + + def generate_payment_url + result_url = ::Stripe::Checkout::Session.create( + payment_url_payload, + { + api_key: stripe_api_key + } + ) + + result.payment_url = result_url["url"] + + result + rescue ::Stripe::CardError, ::Stripe::InvalidRequestError, ::Stripe::AuthenticationError, Stripe::PermissionError => e + result.third_party_failure!(third_party: PROVIDER_NAME, error_code: e.code, error_message: e.message) + end + + def update_payment_status(organization_id:, status:, stripe_payment:) + payment = Payment.find_by(provider_payment_id: stripe_payment.id) + return result if payment&.payable&.organization_id.present? && payment.payable.organization_id != organization_id + + if !payment && stripe_payment.metadata[:payment_type] == "one-time" + payment = create_payment(stripe_payment) + end + + payment ||= handle_missing_payment(organization_id, stripe_payment) + + return result unless payment + + if payment.payable.payment_succeeded? + if payment.persisted? + result.payment = payment + result.payable = payment.payable + end + + return result + end + + processing = status == "processing" + payment.status = status + + payable_payment_status = payment.payment_provider&.determine_payment_status(payment.status) + payment.payable_payment_status = payable_payment_status + payment.save! + + result.payment = payment + result.payable = payment.payable + + update_payable_payment_status(payment_status: payable_payment_status, processing:) + update_invoices_payment_status(payment_status: payable_payment_status, processing:) + update_invoices_paid_amount_cents(payment_status: payable_payment_status) + reset_customer_dunning_campaign_status(payable_payment_status) + + PaymentRequestMailer.with(payment_request: payment.payable).requested.deliver_later if result.payable.payment_failed? + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + # NOTE: Another writer (a parallel webhook worker, or PaymentProviders::Stripe::Payments::CreateService) + # committed the Payment first. Return the persisted row so the + # caller can still enqueue downstream side effects (e.g. SetPaymentMethodAndCreateReceiptJob). + payment = Payment.find_by(provider_payment_id: stripe_payment.id) + if payment + result.payment = payment + result.payable = payment.payable + end + result + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_accessor :payable + + delegate :organization, :customer, to: :payable + + def success_redirect_url + stripe_payment_provider.success_redirect_url.presence || + ::PaymentProviders::StripeProvider::SUCCESS_REDIRECT_URL + end + + def stripe_api_key + stripe_payment_provider.secret_key + end + + def description + desc = "#{customer.billing_entity.name} - Overdue invoices" + + if payable.invoices.one? + "#{desc}: #{payable.invoices.first.number}" + else + desc + end + end + + def update_payable_payment_status(payment_status:, deliver_webhook: true, processing: false) + UpdateService.call( + payable: result.payable, + params: { + payment_status:, + # NOTE: A proper `processing` payment status should be introduced for payment_requests + ready_for_payment_processing: !processing && !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + + def update_invoices_payment_status(payment_status:, deliver_webhook: true, processing: false) + result.payable.invoices.each do |invoice| + Invoices::UpdateService.call( + invoice: invoice, + params: { + payment_status:, + # NOTE: A proper `processing` payment status should be introduced for invoices + ready_for_payment_processing: !processing && !payment_status_succeeded?(payment_status) + }, + webhook_notification: deliver_webhook + ).raise_if_error! + end + end + + def line_items + payable.invoices.map do |invoice| + { + quantity: 1, + price_data: { + currency: invoice.currency.downcase, + unit_amount: invoice.total_due_amount_cents, + product_data: {name: invoice.number} + } + } + end + end + + def payment_url_payload + { + line_items: line_items, + mode: "payment", + success_url: success_redirect_url, + customer: customer.stripe_customer.provider_customer_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + payment_intent_data: { + description:, + metadata: { + lago_customer_id: customer.id, + lago_payable_id: payable.id, + lago_payable_type: payable.class.name, + payment_type: "one-time" + } + } + } + end + + def handle_missing_payment(organization_id, stripe_payment) + # NOTE: Payment was not initiated by lago + return unless stripe_payment.metadata&.key?(:lago_payable_id) + + # NOTE: Payment Request does not belong to this lago organization + # It means the same Stripe secret key is used for multiple organizations + payment_request = PaymentRequest.find_by(id: stripe_payment.metadata[:lago_payable_id], organization_id:) + return unless payment_request + + # NOTE: Payment Request exists but payment status is failed + return if payment_request.payment_failed? + + # NOTE: For some reason payment is missing in the database... (killed sidekiq job, etc.) + # We have to recreate it from the received data + create_payment(stripe_payment, payable: payment_request) + end + + def create_payment(stripe_payment, payable: nil) + @payable = payable || PaymentRequest.find_by(id: stripe_payment.metadata[:lago_payable_id]) + + unless @payable + result.not_found_failure!(resource: "payment_request") + return + end + + @payable.increment_payment_attempts! + + Payment.new( + organization_id: @payable.organization_id, + payable: @payable, + customer:, + payment_provider_id: stripe_payment_provider.id, + payment_provider_customer_id: customer.stripe_customer.id, + amount_cents: @payable.total_amount_cents, + amount_currency: @payable.currency, + provider_payment_id: stripe_payment.id + ) + end + + def deliver_error_webhook(stripe_error) + DeliverErrorWebhookService.call_async(payable, { + provider_customer_id: customer.stripe_customer.provider_customer_id, + provider_error: { + message: stripe_error.message, + error_code: stripe_error.code + } + }) + end + + def stripe_payment_provider + @stripe_payment_provider ||= payment_provider(customer) + end + + def payment_status_succeeded?(payment_status) + payment_status.to_sym == :succeeded + end + + def reset_customer_dunning_campaign_status(payment_status) + return unless payment_status_succeeded?(payment_status) + return unless payable.try(:dunning_campaign) + + customer.reset_dunning_campaign! + end + end + end +end diff --git a/app/services/payment_requests/payments/updatable.rb b/app/services/payment_requests/payments/updatable.rb new file mode 100644 index 0000000..d35cd40 --- /dev/null +++ b/app/services/payment_requests/payments/updatable.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module PaymentRequests + module Payments + module Updatable + extend ActiveSupport::Concern + + private + + def update_invoices_paid_amount_cents(payment_status:) + return if !payable || payment_status.to_sym != :succeeded + + payable.invoices.each do |invoice| + Invoices::UpdateService.call!(invoice:, params: { + total_paid_amount_cents: total_paid_amount_cents(invoice) + }) + end + end + + def total_paid_amount_cents(invoice) + invoice.total_paid_amount_cents + invoice.total_due_amount_cents + end + end + end +end diff --git a/app/services/payment_requests/update_service.rb b/app/services/payment_requests/update_service.rb new file mode 100644 index 0000000..901c8cd --- /dev/null +++ b/app/services/payment_requests/update_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module PaymentRequests + class UpdateService < BaseService + def initialize(payable:, params:, webhook_notification: false) + @payable = payable + @params = params + @webhook_notification = webhook_notification + + super + end + + def call + return result.not_found_failure!(resource: "payment_request") unless payable + + if params.key?(:payment_status) && !valid_payment_status?(params[:payment_status]) + return result.single_validation_failure!( + field: :payment_status, + error_code: "value_is_invalid" + ) + end + + payable.payment_status = params[:payment_status] if params.key?(:payment_status) + payable.ready_for_payment_processing = params[:ready_for_payment_processing] if params.key?(:ready_for_payment_processing) + payable.save! + + if payable.saved_change_to_payment_status? + deliver_webhook if webhook_notification + end + + result.payable = payable + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :payable, :params, :webhook_notification + + def valid_payment_status?(payment_status) + PaymentRequest::PAYMENT_STATUS.include?(payment_status&.to_sym) + end + + def deliver_webhook + return unless webhook_notification + + SendWebhookJob.perform_later("payment_request.payment_status_updated", payable) + end + end +end diff --git a/app/services/payments/lose_dispute_service.rb b/app/services/payments/lose_dispute_service.rb new file mode 100644 index 0000000..17dcb4b --- /dev/null +++ b/app/services/payments/lose_dispute_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Payments + class LoseDisputeService < BaseService + def initialize(payment:, payment_dispute_lost_at: nil, reason: nil) + @payment = payment + @payable = payment&.payable + @payment_dispute_lost_at = payment_dispute_lost_at.presence || Time.current + @reason = reason + super + end + + def call + return result.not_found_failure!(resource: "payment") if payment.nil? + return result.not_found_failure!(resource: "payable") if payable.nil? + + result.payment = payment + invoices = payment.invoices + + ActiveRecord::Base.transaction do + invoices.each do |invoice| + invoice.mark_as_dispute_lost!(payment_dispute_lost_at) + + after_commit do + SendWebhookJob.perform_later("invoice.payment_dispute_lost", invoice, provider_error: reason) + Invoices::ProviderTaxes::VoidJob.perform_later(invoice:) + if invoice.should_update_hubspot_invoice? + Integrations::Aggregator::Invoices::Hubspot::UpdateJob.perform_later(invoice:) + end + end + end + end + + result.invoices = invoices + result + rescue ActiveRecord::RecordInvalid => _e + result.not_allowed_failure!(code: "not_disputable") + end + + private + + attr_reader :payment, :payable, :payment_dispute_lost_at, :reason + end +end diff --git a/app/services/payments/manual_create_service.rb b/app/services/payments/manual_create_service.rb new file mode 100644 index 0000000..03bea8c --- /dev/null +++ b/app/services/payments/manual_create_service.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Payments + class ManualCreateService < BaseService + def initialize(organization:, params:) + @organization = organization + @params = params + super + end + + activity_loggable( + action: "payment.recorded", + record: -> { result.payment } + ) + + def call + check_preconditions + return result if result.error + + amount_cents = params[:amount_cents] + + ActiveRecord::Base.transaction do + payment = invoice.payments.create!( + organization_id: invoice.organization_id, + customer_id: invoice.customer_id, + amount_cents:, + reference: params[:reference], + amount_currency: invoice.currency, + status: "succeeded", + payable_payment_status: "succeeded", + payment_type: :manual, + created_at: parsed_paid_at + ) + result.payment = payment + + total_paid_amount_cents = invoice.payments.where(payable_payment_status: :succeeded).sum(:amount_cents) + total_applied_from_credit_note = invoice.invoice_settlements.where(settlement_type: :credit_note).sum(:amount_cents) + update_params = {total_paid_amount_cents: total_paid_amount_cents} + if (total_paid_amount_cents + total_applied_from_credit_note) == invoice.total_amount_cents + update_params[:payment_status] = "succeeded" + end + Invoices::UpdateService.call!(invoice:, params: update_params, webhook_notification: true) + end + + after_commit do + PaymentReceipts::CreateJob.perform_later(result.payment) if organization.issue_receipts_enabled? + + if result.payment&.should_sync_payment? + Integrations::Aggregator::Payments::CreateJob.perform_later(payment: result.payment) + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :params + + def parsed_paid_at + return nil if params[:paid_at].blank? + + Time.zone.parse(params[:paid_at]) + end + + def invoice + @invoice ||= organization.invoices.find_by(id: params[:invoice_id]) + end + + def check_preconditions + return result.single_validation_failure!(error_code: "value_is_mandatory", field: "invoice_id") if params[:invoice_id].blank? + return result.single_validation_failure!(error_code: "invalid_value", field: "amount_cents") unless valid_amount_cents? + return result.not_found_failure!(resource: "invoice") unless invoice + return result if invoice.invoice_type == "advance_charges" + + return result.forbidden_failure! if !License.premium? + return result.forbidden_failure! unless invoice.allow_manual_payment? + + result.single_validation_failure!(error_code: "invalid_date", field: "paid_at") unless valid_paid_at? + end + + def valid_paid_at? + params[:paid_at].blank? || Utils::Datetime.valid_format?(params[:paid_at], format: :any) + end + + def valid_amount_cents? + params[:amount_cents].is_a?(Integer) && params[:amount_cents] > 0 + end + end +end diff --git a/app/services/payments/set_payment_method_data_service.rb b/app/services/payments/set_payment_method_data_service.rb new file mode 100644 index 0000000..4d1e8bf --- /dev/null +++ b/app/services/payments/set_payment_method_data_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Payments + class SetPaymentMethodDataService < BaseService + Result = BaseResult[:payment] + + def initialize(payment:, provider_payment_method_id:) + @payment = payment + @payment_provider = payment.payment_provider + @provider_payment_method_id = provider_payment_method_id + + super + end + + def call + if payment.provider_payment_method_id == provider_payment_method_id + result.payment = payment + return result + end + + data = case payment_provider.type + when PaymentProviders::StripeProvider.to_s + retrieve_stripe_payment_method_data + else + raise NotImplementedError, "Service not implemented for #{payment_provider.payment_type}" + end + + id = data.delete(:id) + payment.update!(provider_payment_method_id: id, provider_payment_method_data: data) + + if payment.payment_method && payment.organization.feature_flag_enabled?(:multiple_payment_methods) + payment.payment_method.update!(details: data) + end + + result.payment = payment + result + end + + private + + attr_reader :payment, :payment_provider, :provider_payment_method_id + + def retrieve_stripe_payment_method_data + pm = ::Stripe::PaymentMethod.retrieve(provider_payment_method_id, { + api_key: payment_provider.secret_key + }) + + data = { + id: provider_payment_method_id, + type: pm.type + } + + if pm.respond_to?(:card) && pm.card + data[:last4] = pm.card.last4 + data[:brand] = pm.card.display_brand + data[:expiration_month] = pm.card.exp_month + data[:expiration_year] = pm.card.exp_year + end + + data + end + end +end diff --git a/app/services/plans/apply_taxes_service.rb b/app/services/plans/apply_taxes_service.rb new file mode 100644 index 0000000..dee86dd --- /dev/null +++ b/app/services/plans/apply_taxes_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Plans + class ApplyTaxesService < BaseService + Result = BaseResult[:applied_taxes] + + def initialize(plan:, tax_codes:) + @plan = plan + @tax_codes = tax_codes + + super + end + + def call + return result.not_found_failure!(resource: "plan") unless plan + return result.not_found_failure!(resource: "tax") if (tax_codes - taxes.pluck(:code)).present? + + plan.applied_taxes.where( + tax_id: plan.taxes.where.not(code: tax_codes).pluck(:id) + ).destroy_all + + result.applied_taxes = tax_codes.map do |tax_code| + plan.applied_taxes + .create_with(organization: plan.organization) + .find_or_create_by!(tax: taxes.find_by(code: tax_code)) + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :plan, :tax_codes + + def taxes + @taxes ||= plan.organization.taxes.where(code: tax_codes) + end + end +end diff --git a/app/services/plans/chargeables_validation_service.rb b/app/services/plans/chargeables_validation_service.rb new file mode 100644 index 0000000..19ee380 --- /dev/null +++ b/app/services/plans/chargeables_validation_service.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +module Plans + class ChargeablesValidationService < BaseService + Result = BaseResult + + def initialize(organization:, charges: nil, fixed_charges: nil) + @organization = organization + @charges = charges + @fixed_charges = fixed_charges + super + end + + def call + return result unless should_validate? + + validate_billable_metrics + validate_add_ons + + result + end + + private + + attr_reader :organization, :charges, :fixed_charges + + def should_validate? + charges.present? || fixed_charges.present? + end + + def validate_billable_metrics + return if charges.blank? + + metric_ids = charges.map { |c| c[:billable_metric_id] }.compact.uniq + return if metric_ids.blank? + + if organization.billable_metrics.where(id: metric_ids).count != metric_ids.count + result.not_found_failure!(resource: "billable_metrics") + end + end + + def validate_add_ons + return if fixed_charges.blank? + + validate_add_on_ids + validate_add_on_codes + end + + def validate_add_on_ids + add_on_ids = fixed_charges.map { |c| c[:add_on_id] }.uniq.compact + return if add_on_ids.blank? + + if organization.add_ons.where(id: add_on_ids).count != add_on_ids.count + result.not_found_failure!(resource: "add_ons") + end + end + + def validate_add_on_codes + add_on_codes = fixed_charges.map { |c| c[:add_on_code] }.uniq.compact + return if add_on_codes.blank? + + if organization.add_ons.where(code: add_on_codes).count != add_on_codes.count + result.not_found_failure!(resource: "add_ons") + end + end + end +end diff --git a/app/services/plans/create_service.rb b/app/services/plans/create_service.rb new file mode 100644 index 0000000..e02ead4 --- /dev/null +++ b/app/services/plans/create_service.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +module Plans + class CreateService < BaseService + Result = BaseResult[:plan] + + def initialize(args) + @args = args + super + end + + activity_loggable( + action: "plan.created", + record: -> { result.plan } + ) + + def call + plan = Plan.new( + organization_id: args[:organization_id], + name: args[:name], + invoice_display_name: args[:invoice_display_name], + code: args[:code], + description: args[:description], + interval: args[:interval]&.to_sym, + pay_in_advance: args[:pay_in_advance], + amount_cents: args[:amount_cents], + amount_currency: args[:amount_currency], + trial_period: args[:trial_period], + bill_charges_monthly: bill_charges_monthly(args), + bill_fixed_charges_monthly: bill_fixed_charges_monthly(args) + ) + + chargeables_validation_result = Plans::ChargeablesValidationService.call( + organization: plan.organization, + charges: args[:charges], + fixed_charges: args[:fixed_charges] + ) + return chargeables_validation_result if chargeables_validation_result.failure? + + ActiveRecord::Base.transaction do + plan.save! + create_metadata(plan, args[:metadata]) if !args[:metadata].nil? + + if args[:tax_codes] + taxes_result = Plans::ApplyTaxesService.call(plan:, tax_codes: args[:tax_codes]) + taxes_result.raise_if_error! + end + + if args[:usage_thresholds].present? && plan.organization.progressive_billing_enabled? + UsageThresholds::UpdateService.call!(model: plan, usage_thresholds_params: args[:usage_thresholds], partial: false) + end + + if args[:charges].present? + args[:charges].each do |charge_params| + Charges::CreateService.call!(plan:, params: charge_params_with_code(plan, charge_params)) + end + end + + if args[:fixed_charges].present? + args[:fixed_charges].each do |fixed_charge_args| + FixedCharges::CreateService.call!(plan:, params: fixed_charge_params_with_code(plan, fixed_charge_args)) + end + end + + if args[:minimum_commitment].present? && License.premium? + minimum_commitment = args[:minimum_commitment] + new_commitment = create_commitment(plan, minimum_commitment, :minimum_commitment) + if minimum_commitment[:tax_codes].present? + taxes_result = Commitments::ApplyTaxesService.call( + commitment: new_commitment, + tax_codes: minimum_commitment[:tax_codes] + ) + taxes_result.raise_if_error! + end + end + end + + result.plan = plan + track_plan_created(plan) + SendWebhookJob.perform_after_commit("plan.created", plan) + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :args + + def create_commitment(plan, args, commitment_type) + Commitment.create!( + organization_id: plan.organization_id, + plan:, + commitment_type:, + invoice_display_name: args[:invoice_display_name], + amount_cents: args[:amount_cents] + ) + end + + def create_usage_threshold(plan, args) + usage_threshold = plan.usage_thresholds.new( + organization_id: plan.organization_id, + threshold_display_name: args[:threshold_display_name], + amount_cents: args[:amount_cents], + recurring: args[:recurring] || false + ) + + usage_threshold.save! + end + + def create_metadata(plan, metadata_value) + plan.create_metadata!( + organization_id: plan.organization_id, + value: metadata_value + ) + end + + def bill_charges_monthly(args) + return nil unless charges_billable_monthly?(args) + + args[:bill_charges_monthly] || false + end + + def bill_fixed_charges_monthly(args) + return nil unless charges_billable_monthly?(args) + + args[:bill_fixed_charges_monthly] || false + end + + def charges_billable_monthly?(args) + interval = args[:interval]&.to_sym + + %i[yearly semiannual].include?(interval) + end + + def charge_params_with_code(plan, charge_params) + return charge_params if charge_params[:code].present? + + billable_metric = plan.organization.billable_metrics.find_by(id: charge_params[:billable_metric_id]) + return charge_params unless billable_metric + + charge_params.merge(code: Charges::GenerateCodeService.call(plan:, billable_metric:).code) + end + + def fixed_charge_params_with_code(plan, fixed_charge_params) + return fixed_charge_params if fixed_charge_params[:code].present? + + add_on = plan.organization.add_ons.find_by(id: fixed_charge_params[:add_on_id]) + return fixed_charge_params unless add_on + + fixed_charge_params.merge(code: FixedCharges::GenerateCodeService.call(plan:, add_on:).code) + end + + def track_plan_created(plan) + count_by_charge_model = plan.charges.group(:charge_model).count + count_by_fixed_charge_model = plan.fixed_charges.group(:charge_model).count + + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "plan_created", + properties: { + code: plan.code, + name: plan.name, + invoice_display_name: plan.invoice_display_name, + description: plan.description, + plan_interval: plan.interval, + plan_amount_cents: plan.amount_cents, + plan_period: plan.pay_in_advance ? "advance" : "arrears", + trial: plan.trial_period, + nb_charges: plan.charges.count, + nb_standard_charges: count_by_charge_model["standard"] || 0, + nb_percentage_charges: count_by_charge_model["percentage"] || 0, + nb_graduated_charges: count_by_charge_model["graduated"] || 0, + nb_package_charges: count_by_charge_model["package"] || 0, + nb_fixed_charges: plan.fixed_charges.count, + nb_standard_fixed_charges: count_by_fixed_charge_model["standard"] || 0, + nb_graduated_fixed_charges: count_by_fixed_charge_model["graduated"] || 0, + nb_volume_fixed_charges: count_by_fixed_charge_model["volume"] || 0, + organization_id: plan.organization_id, + parent_id: plan.parent_id + } + ) + end + end +end diff --git a/app/services/plans/destroy_service.rb b/app/services/plans/destroy_service.rb new file mode 100644 index 0000000..60abb17 --- /dev/null +++ b/app/services/plans/destroy_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Plans + class DestroyService < BaseService + Result = BaseResult[:plan] + + def initialize(plan:) + @plan = plan + super + end + + activity_loggable( + action: "plan.deleted", + record: -> { plan } + ) + + def call + return result.not_found_failure!(resource: "plan") unless plan + + # NOTE: Terminate active subscriptions. + plan.subscriptions.active.find_each do |subscription| + Subscriptions::TerminateService.call(subscription:, async: false) + end + + # NOTE: Cancel pending subscription to make sure they won't be activated. + plan.subscriptions.pending.find_each(&:mark_as_canceled!) + + # NOTE: Finalize all draft invoices. + invoices = Invoice.draft.joins(:plans).where(plans: {id: plan.id}).distinct + invoices.find_each { |invoice| Invoices::RefreshDraftAndFinalizeService.call(invoice:) } + + # rubocop:disable Rails/SkipsModelValidations + plan.entitlement_values.update_all(deleted_at: Time.current) + plan.entitlements.update_all(deleted_at: Time.current) + # rubocop:enable Rails/SkipsModelValidations + + plan.pending_deletion = false + plan.discard! + + result.plan = plan + result + rescue Discard::RecordNotDiscarded + @plan = Plan.with_discarded.find_by(id: plan.id) + raise unless plan.discarded? + + result.plan = plan + result + end + + private + + attr_reader :plan + end +end diff --git a/app/services/plans/override_service.rb b/app/services/plans/override_service.rb new file mode 100644 index 0000000..3359405 --- /dev/null +++ b/app/services/plans/override_service.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Plans + class OverrideService < BaseService + Result = BaseResult[:plan] + + def initialize(plan:, params:, subscription: nil) + @plan = plan + @params = params + @subscription = subscription + + super + end + + def call + return result.forbidden_failure! unless License.premium? + + ActiveRecord::Base.transaction do + new_plan = plan.dup.tap do |p| + p.amount_cents = params[:amount_cents] if params.key?(:amount_cents) + p.amount_currency = params[:amount_currency] if params.key?(:amount_currency) + p.description = params[:description] if params.key?(:description) + p.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + p.name = params[:name] if params.key?(:name) + p.trial_period = params[:trial_period] if params.key?(:trial_period) + p.parent_id = plan.id + end + new_plan.save! + + if params[:tax_codes] + taxes_result = Plans::ApplyTaxesService.call(plan: new_plan, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + charges_params_by_id = (params[:charges] || []).index_by { |p| p[:id] } + fixed_charges_params_by_id = (params[:fixed_charges] || []).index_by { |p| p[:id] } + + plan.charges.find_each do |charge| + charge_params = (charges_params_by_id[charge.id] || {}).merge(plan_id: new_plan.id) + Charges::OverrideService.call(charge:, params: charge_params) + end + + plan.fixed_charges.find_each do |fixed_charge| + fixed_charge_params = (fixed_charges_params_by_id[fixed_charge.id] || {}).merge(plan_id: new_plan.id) + FixedCharges::OverrideService.call(fixed_charge:, params: fixed_charge_params, subscription:) + end + + if params[:usage_thresholds].present? && + License.premium? && + plan.organization.progressive_billing_enabled? + + UsageThresholds::OverrideService.call(usage_thresholds_params: params[:usage_thresholds], new_plan: new_plan) + end + + if params[:minimum_commitment].present? && License.premium? + commitment = Commitment.new( + organization_id: new_plan.organization_id, plan: new_plan, commitment_type: "minimum_commitment" + ) + minimum_commitment_params = params[:minimum_commitment].merge(plan_id: new_plan.id) + + commitment_override_result = Commitments::OverrideService.call(commitment:, params: minimum_commitment_params) + commitment_override_result.raise_if_error! + end + + result.plan = new_plan + track_plan_created(new_plan) + result + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :plan, :params, :subscription + + def track_plan_created(plan) + count_by_charge_model = plan.charges.group(:charge_model).count + fixed_charges_count_by_charge_model = plan.fixed_charges.group(:charge_model).count + + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "plan_created", + properties: { + code: plan.code, + name: plan.name, + invoice_display_name: plan.invoice_display_name, + description: plan.description, + plan_interval: plan.interval, + plan_amount_cents: plan.amount_cents, + plan_period: plan.pay_in_advance ? "advance" : "arrears", + trial: plan.trial_period, + nb_charges: plan.charges.count, + nb_fixed_charges: plan.fixed_charges.count, + nb_standard_charges: count_by_charge_model["standard"] || 0, + nb_percentage_charges: count_by_charge_model["percentage"] || 0, + nb_graduated_charges: count_by_charge_model["graduated"] || 0, + nb_package_charges: count_by_charge_model["package"] || 0, + nb_standard_fixed_charges: fixed_charges_count_by_charge_model["standard"] || 0, + nb_graduated_fixed_charges: fixed_charges_count_by_charge_model["graduated"] || 0, + nb_volume_fixed_charges: fixed_charges_count_by_charge_model["volume"] || 0, + organization_id: plan.organization_id, + parent_id: plan.parent_id + } + ) + end + end +end diff --git a/app/services/plans/prepare_destroy_service.rb b/app/services/plans/prepare_destroy_service.rb new file mode 100644 index 0000000..a2c6186 --- /dev/null +++ b/app/services/plans/prepare_destroy_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Plans + class PrepareDestroyService < BaseService + Result = BaseResult[:plan] + + def initialize(plan:) + @plan = plan + super + end + + def call + return result.not_found_failure!(resource: "plan") unless plan + + ActiveRecord::Base.transaction do + plan.update!(pending_deletion: true) + plan.children.update_all(pending_deletion: true) # rubocop:disable Rails/SkipsModelValidations + Plans::DestroyJob.perform_later(plan) + end + + SendWebhookJob.perform_after_commit("plan.deleted", plan) + + result.plan = plan + result + end + + private + + attr_reader :plan + end +end diff --git a/app/services/plans/update_amount_service.rb b/app/services/plans/update_amount_service.rb new file mode 100644 index 0000000..0783ee0 --- /dev/null +++ b/app/services/plans/update_amount_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Plans + class UpdateAmountService < BaseService + Result = BaseResult[:plan] + + def initialize(plan:, amount_cents:, expected_amount_cents:) + @plan = plan + @amount_cents = amount_cents + @expected_amount_cents = expected_amount_cents + + super + end + + def call + return result.not_found_failure!(resource: "plan") unless plan + + result.plan = plan + return result if plan.amount_cents != expected_amount_cents + + plan.amount_cents = amount_cents + + ActiveRecord::Base.transaction do + plan.save! + process_pending_subscriptions + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :plan, :amount_cents, :expected_amount_cents + + def process_pending_subscriptions + Subscription.where(plan:, status: :pending).find_each do |subscription| + next unless subscription.previous_subscription + + if plan.yearly_amount_cents >= subscription.previous_subscription.plan.yearly_amount_cents + Subscriptions::PlanUpgradeService.call( + current_subscription: subscription.previous_subscription, + plan: plan, + params: {name: subscription.name} + ).raise_if_error! + end + end + end + end +end diff --git a/app/services/plans/update_service.rb b/app/services/plans/update_service.rb new file mode 100644 index 0000000..8986b72 --- /dev/null +++ b/app/services/plans/update_service.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +module Plans + class UpdateService < BaseService + Result = BaseResult[:plan] + + def initialize(plan:, params:, partial_metadata: false) + @plan = plan + @params = params + @timestamp = Time.current.to_i + @partial_metadata = partial_metadata + super + end + + activity_loggable( + action: "plan.updated", + record: -> { plan }, + condition: -> { plan&.parent_id.nil? } + ) + + def call + return result.not_found_failure!(resource: "plan") unless plan + + old_amount_cents = plan.amount_cents + + plan.name = params[:name] if params.key?(:name) + plan.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + plan.description = params[:description] if params.key?(:description) + plan.amount_cents = params[:amount_cents] if params.key?(:amount_cents) + + # NOTE: If plan is attached to subscriptions the editable attributes are: + # name, invoice_display_name, description, amount_cents + unless plan.attached_to_subscriptions? + plan.code = params[:code] if params.key?(:code) + plan.interval = params[:interval].to_sym if params.key?(:interval) + plan.pay_in_advance = params[:pay_in_advance] if params.key?(:pay_in_advance) + plan.amount_currency = params[:amount_currency] if params.key?(:amount_currency) + plan.trial_period = params[:trial_period] if params.key?(:trial_period) + plan.bill_charges_monthly = bill_charges_monthly? + plan.bill_fixed_charges_monthly = bill_fixed_charges_monthly? + end + + chargeables_validation_result = Plans::ChargeablesValidationService.call( + organization: plan.organization, + charges: params[:charges], + fixed_charges: params[:fixed_charges] + ) + return chargeables_validation_result if chargeables_validation_result.failure? + + ActiveRecord::Base.transaction do + plan.save! + update_metadata! + + if params[:tax_codes] + taxes_result = Plans::ApplyTaxesService.call(plan:, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + process_charges(plan, params[:charges]) if params[:charges] + process_fixed_charges if params[:fixed_charges] + + if params.key?(:usage_thresholds) && License.premium? + Plans::UpdateUsageThresholdsService.call(plan:, usage_thresholds_params: params[:usage_thresholds]) + end + + process_minimum_commitment(plan, params[:minimum_commitment]) if params[:minimum_commitment] && License.premium? + + if old_amount_cents != plan.amount_cents + process_downgraded_subscriptions + process_pending_subscriptions + end + end + + cascade_subscription_fee_update(old_amount_cents) + + plan.invoices.draft.update_all(ready_to_be_refreshed: true) # rubocop:disable Rails/SkipsModelValidations + + SendWebhookJob.perform_after_commit("plan.updated", plan) + result.plan = plan.reload + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :plan, :params, :timestamp, :partial_metadata + + delegate :organization, to: :plan + + def update_metadata! + return unless params.key?(:metadata) + + value = params[:metadata]&.then { |m| m.respond_to?(:to_unsafe_h) ? m.to_unsafe_h : m.to_h } + result = Metadata::UpdateItemService.call!(owner: plan, value:, partial: partial_metadata.present?) + @metadata_changed = result.metadata_changed + end + + def bill_charges_monthly? + return unless billable_monthly? + + params[:bill_charges_monthly] || false + end + + def bill_fixed_charges_monthly? + return unless billable_monthly? + + params[:bill_fixed_charges_monthly] || false + end + + def billable_monthly? + @billable_monthly ||= params[:interval]&.to_sym == :yearly || params[:interval]&.to_sym == :semiannual + end + + def cascade_needed? + cascade? && plan.children.present? + end + + def cascade_subscription_fee_update(old_amount_cents) + return unless cascade_needed? + return if old_amount_cents == plan.amount_cents + + plan.children.where(amount_cents: old_amount_cents).find_each do |p| + Plans::UpdateAmountJob.perform_later(plan: p, amount_cents: plan.amount_cents, expected_amount_cents: old_amount_cents) + end + end + + def cascade_charge_creation(charge, payload_charge) + return unless cascade_needed? + + Charges::CreateChildrenJob.perform_later(charge:, payload: payload_charge) + end + + def cascade_charge_removal(charge) + return unless cascade_needed? + + Charges::DestroyChildrenJob.perform_later(charge.id) + end + + def cascade_charge_update(charge, payload_charge) + return unless cascade_needed? + + old_parent_attrs = charge.attributes + old_parent_filters_attrs = charge.filters.map(&:attributes) + old_parent_applied_pricing_unit_attrs = charge.applied_pricing_unit&.attributes + + Charges::UpdateChildrenJob.perform_later( + params: payload_charge.deep_stringify_keys, + old_parent_attrs:, + old_parent_filters_attrs:, + old_parent_applied_pricing_unit_attrs: + ) + end + + def cascade_fixed_charge_removal(fixed_charge) + return unless cascade_needed? + + FixedCharges::DestroyChildrenJob.perform_later(fixed_charge.id) + end + + def cascade? + ActiveModel::Type::Boolean.new.cast(params[:cascade_updates]) + end + + def process_minimum_commitment(plan, params) + if params.present? + minimum_commitment = plan.minimum_commitment || + Commitment.new(organization_id: plan.organization_id, plan:, commitment_type: "minimum_commitment") + + minimum_commitment.amount_cents = params[:amount_cents] if params.key?(:amount_cents) + minimum_commitment.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + minimum_commitment.save! + end + plan.minimum_commitment.destroy! if params.blank? && plan.minimum_commitment + + if params[:tax_codes] + taxes_result = Commitments::ApplyTaxesService.call( + commitment: minimum_commitment, + tax_codes: params[:tax_codes] + ) + taxes_result.raise_if_error! + end + + minimum_commitment + end + + def process_charges(plan, params_charges) + created_charges_ids = [] + + hash_charges = params_charges.map { |c| c.to_h.deep_symbolize_keys } + hash_charges.each do |payload_charge| + charge = plan.charges.find_by(id: payload_charge[:id]) + + if charge + cascade_charge_update(charge, payload_charge) + Charges::UpdateService.call!(charge:, params: payload_charge) + + next + end + + create_charge_result = Charges::CreateService.call!(plan:, params: charge_params_with_code(payload_charge)) + + after_commit { cascade_charge_creation(create_charge_result.charge, payload_charge) } + created_charges_ids.push(create_charge_result.charge.id) + end + + # NOTE: Delete charges that are no more linked to the plan + sanitize_charges(plan, hash_charges, created_charges_ids) + end + + def sanitize_charges(plan, args_charges, created_charges_ids) + args_charges_ids = args_charges.map { |c| c[:id] }.compact + charges_ids = plan.charges.pluck(:id) - args_charges_ids - created_charges_ids + plan.charges.where(id: charges_ids).find_each do |charge| + after_commit { cascade_charge_removal(charge) } + Charges::DestroyService.call(charge:) + end + end + + def process_fixed_charges + cascade_fixed_charges_payload = [] + created_fixed_charges_ids = [] + + hash_fixed_charges = params[:fixed_charges].map { |c| c.to_h.deep_symbolize_keys } + hash_fixed_charges.each do |payload_fixed_charge| + fixed_charge = plan.fixed_charges.find_by(id: payload_fixed_charge[:id]) + + if fixed_charge + cascade_fixed_charges_payload << payload_fixed_charge.merge( + old_parent_attrs: fixed_charge.attributes, + action: :update + ) + FixedCharges::UpdateService.call!(fixed_charge:, params: payload_fixed_charge, timestamp:, trigger_billing: false) + + next + end + + create_fixed_charge_result = FixedCharges::CreateService.call!(plan:, params: fixed_charge_params_with_code(payload_fixed_charge), timestamp:) + + cascade_fixed_charges_payload << payload_fixed_charge.merge( + parent_id: create_fixed_charge_result.fixed_charge.id, + code: create_fixed_charge_result.fixed_charge.code, + action: :create + ) + + created_fixed_charges_ids.push(create_fixed_charge_result.fixed_charge.id) + end + + # NOTE: Delete fixed_charges that are no more linked to the plan + sanitize_fixed_charges(plan, hash_fixed_charges, created_fixed_charges_ids) + + trigger_pay_in_advance_billing if plan.fixed_charges.pay_in_advance.exists? + + cascade_fixed_charges(cascade_fixed_charges_payload) + end + + def cascade_fixed_charges(cascade_fixed_charges_payload) + return unless cascade_needed? + return unless plan.children.exists? + + FixedCharges::CascadePlanUpdateJob.perform_later( + plan: plan, + cascade_fixed_charges_payload:, + timestamp: + ) + end + + def trigger_pay_in_advance_billing + plan.subscriptions.active.find_each do |subscription| + after_commit do + Invoices::CreatePayInAdvanceFixedChargesJob.perform_later( + subscription, + timestamp + ) + end + end + end + + def sanitize_fixed_charges(plan, args_fixed_charges, created_fixed_charges_ids) + args_fixed_charges_ids = args_fixed_charges.map { |c| c[:id] }.compact + fixed_charges_ids = plan.fixed_charges.pluck(:id) - args_fixed_charges_ids - created_fixed_charges_ids + plan.fixed_charges.where(id: fixed_charges_ids).find_each do |fixed_charge| + after_commit { cascade_fixed_charge_removal(fixed_charge) } + FixedCharges::DestroyService.call(fixed_charge:) + end + end + + def charge_params_with_code(charge_params) + return charge_params if charge_params[:code].present? + + billable_metric = organization.billable_metrics.find_by(id: charge_params[:billable_metric_id]) + return charge_params unless billable_metric + + charge_params.merge(code: Charges::GenerateCodeService.call(plan:, billable_metric:).code) + end + + def fixed_charge_params_with_code(fixed_charge_params) + return fixed_charge_params if fixed_charge_params[:code].present? + + add_on = organization.add_ons.find_by(id: fixed_charge_params[:add_on_id]) + return fixed_charge_params unless add_on + + fixed_charge_params.merge(code: FixedCharges::GenerateCodeService.call(plan:, add_on:).code) + end + + # NOTE: We should remove pending subscriptions + # if plan has been downgraded but amount cents became less than downgraded value. This pending subscription + # is not relevant in this case and downgrade should be ignored + def process_downgraded_subscriptions + return unless plan.subscriptions.active.exists? + + Subscription.where(previous_subscription: plan.subscriptions.active, status: :pending).find_each do |sub| + sub.mark_as_canceled! if plan.amount_cents < sub.plan.amount_cents + end + end + + # NOTE: If new plan yearly amount is higher than its value before the update + # and there are pending subscriptions for the plan, + # this is a plan upgrade, old subscription must be terminated and billed + # new subscription with updated plan must be activated inmediately. + def process_pending_subscriptions + Subscription.where(plan:, status: :pending).find_each do |subscription| + next unless subscription.previous_subscription + + if plan.yearly_amount_cents >= subscription.previous_subscription.plan.yearly_amount_cents + Subscriptions::PlanUpgradeService.call( + current_subscription: subscription.previous_subscription, + plan: plan, + params: {name: subscription.name} + ).raise_if_error! + end + end + end + end +end diff --git a/app/services/plans/update_usage_thresholds_service.rb b/app/services/plans/update_usage_thresholds_service.rb new file mode 100644 index 0000000..0c5d706 --- /dev/null +++ b/app/services/plans/update_usage_thresholds_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Plans + class UpdateUsageThresholdsService < BaseService + Result = BaseResult[:plan] + + def initialize(plan:, usage_thresholds_params:) + @plan = plan + @usage_thresholds_params = usage_thresholds_params + super + end + + def call + result.plan = plan + return result unless plan.organization.progressive_billing_enabled? + + ActiveRecord::Base.transaction do + UsageThresholds::UpdateService.call!(model: plan, usage_thresholds_params:, partial: false) + end + + plan.usage_thresholds.reload + LifetimeUsages::FlagRefreshFromPlanUpdateJob.perform_after_commit(plan) if plan.usage_thresholds.size > 0 + + result + end + + private + + attr_reader :plan, :usage_thresholds_params + end +end diff --git a/app/services/pricing_units/create_service.rb b/app/services/pricing_units/create_service.rb new file mode 100644 index 0000000..55b61fb --- /dev/null +++ b/app/services/pricing_units/create_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module PricingUnits + class CreateService < BaseService + Result = BaseResult[:pricing_unit] + + def initialize(params) + @params = params + super + end + + def call + return result.forbidden_failure! unless License.premium? + + pricing_unit = PricingUnit.create!( + params.slice(:organization, :name, :code, :short_name, :description) + ) + + result.pricing_unit = pricing_unit + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :params + end +end diff --git a/app/services/pricing_units/update_service.rb b/app/services/pricing_units/update_service.rb new file mode 100644 index 0000000..c642020 --- /dev/null +++ b/app/services/pricing_units/update_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module PricingUnits + class UpdateService < BaseService + Result = BaseResult[:pricing_unit] + + def initialize(pricing_unit:, params:) + @pricing_unit = pricing_unit + @params = params + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "pricing_unit") unless pricing_unit + + pricing_unit.update!( + params.slice(:name, :short_name, :description) + ) + + result.pricing_unit = pricing_unit + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :pricing_unit, :params + end +end diff --git a/app/services/quote_versions/approve_service.rb b/app/services/quote_versions/approve_service.rb new file mode 100644 index 0000000..79d6312 --- /dev/null +++ b/app/services/quote_versions/approve_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module QuoteVersions + class ApproveService < BaseService + include OrderForms::Premium + + attr_reader :quote_version + + Result = BaseResult[:quote_version] + + def initialize(quote_version:) + @quote_version = quote_version + super + end + + def call + return result.not_found_failure!(resource: "quote_version") unless quote_version + return result.forbidden_failure! unless order_forms_enabled?(quote_version.organization) + return result.not_allowed_failure!(code: "inappropriate_state") unless approvable? + + quote_version.update!( + status: :approved, + approved_at: Time.current + ) + + # TODO: OrderForms::CreateService.call + # TODO: SendWebhookJob.perform_after_commit("quote_version.approved", quote_version) + + result.quote_version = quote_version + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + def approvable? + quote_version.draft? + end + end +end diff --git a/app/services/quote_versions/clone_service.rb b/app/services/quote_versions/clone_service.rb new file mode 100644 index 0000000..77ae09d --- /dev/null +++ b/app/services/quote_versions/clone_service.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module QuoteVersions + class CloneService < BaseService + include OrderForms::Premium + + class CloneError < StandardError + attr_reader :source_result + + def initialize(source_result:) + @source_result = source_result + super("QuoteVersion clone failed: #{source_result&.error&.message}") + end + end + + attr_reader :quote_version + + Result = BaseResult[:quote_version] + + def initialize(quote_version:) + @quote_version = quote_version + super + end + + def call + return result.not_found_failure!(resource: "quote_version") unless quote_version + return result.forbidden_failure! unless order_forms_enabled?(quote_version.organization) + return result.forbidden_failure!(code: "inappropriate_state") unless clonable? + + cloned = QuoteVersion.transaction do + void!(quote_version:) + create_next_version(quote_version:) + end + + # TODO: SendWebhookJob.perform_after_commit("quote_version.cloned", cloned) + + result.quote_version = cloned + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.forbidden_failure!(code: "active_version_exists") + rescue CloneError => e + result.service_failure!(code: "clone_failed", message: e.message, error: e) + end + + private + + def clonable? + return false if quote_version.quote.versions.where(status: :approved).exists? + + active_draft = quote_version.quote.versions.where(status: :draft).first + active_draft.nil? || active_draft.id == quote_version.id + end + + def create_next_version(quote_version:) + quote_version.dup.tap do |cloned| + cloned.status = :draft + cloned.sequential_id = nil + cloned.share_token = nil # regenerated on save + cloned.void_reason = nil + cloned.voided_at = nil + cloned.approved_at = nil + cloned.save! + end + end + + def void!(quote_version:) + return if quote_version.voided? + + void_result = QuoteVersions::VoidService.new( + quote_version: quote_version, + reason: :superseded + ).call + + raise CloneError.new(source_result: void_result) unless void_result&.success? + end + end +end diff --git a/app/services/quote_versions/create_service.rb b/app/services/quote_versions/create_service.rb new file mode 100644 index 0000000..89b4bab --- /dev/null +++ b/app/services/quote_versions/create_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module QuoteVersions + class CreateService < BaseService + include OrderForms::Premium + + attr_reader :quote, :params + + Result = BaseResult[:quote_version] + + def initialize(quote:, params: {}) + @quote = quote + @params = params + super + end + + def call + return result.not_found_failure!(resource: "quote") unless quote + return result.forbidden_failure! unless order_forms_enabled?(quote.organization) + return result.forbidden_failure!(code: "active_version_exists") if active_version_exists? + + quote_version = quote.versions.create!( + organization: quote.organization, + **params.slice(:billing_items, :content) + ) + + result.quote_version = quote_version + + # TODO: SendWebhookJob.perform_after_commit("quote_version.created", quote_version) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique + result.forbidden_failure!(code: "active_version_exists") + end + + private + + def active_version_exists? + quote.versions.where(status: %w[draft approved]).exists? + end + end +end diff --git a/app/services/quote_versions/update_service.rb b/app/services/quote_versions/update_service.rb new file mode 100644 index 0000000..b470405 --- /dev/null +++ b/app/services/quote_versions/update_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module QuoteVersions + class UpdateService < BaseService + include OrderForms::Premium + + attr_reader :quote_version, :params + + Result = BaseResult[:quote_version] + + def initialize(quote_version:, params:) + @quote_version = quote_version + @params = params + super + end + + def call + return result.not_found_failure!(resource: "quote_version") unless quote_version + return result.forbidden_failure! unless order_forms_enabled?(quote_version.organization) + return result.not_allowed_failure!(code: "inappropriate_state") unless editable? + + quote_version.update!(params.slice(:billing_items, :content)) + result.quote_version = quote_version + + # TODO: SendWebhookJob.perform_after_commit("quote_version.updated", quote_version) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + def editable? + quote_version.draft? + end + end +end diff --git a/app/services/quote_versions/void_service.rb b/app/services/quote_versions/void_service.rb new file mode 100644 index 0000000..70412de --- /dev/null +++ b/app/services/quote_versions/void_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module QuoteVersions + class VoidService < BaseService + include OrderForms::Premium + + attr_reader :quote_version, :reason + + Result = BaseResult[:quote_version] + + def initialize(quote_version:, reason:) + @quote_version = quote_version + @reason = reason + super + end + + def call + return result.not_found_failure!(resource: "quote_version") unless quote_version + return result.forbidden_failure! unless order_forms_enabled?(quote_version.organization) + return result.single_validation_failure!(field: :void_reason, error_code: "invalid") unless valid_reason? + return result.not_allowed_failure!(code: "inappropriate_state") unless voidable? + + quote_version.update!( + status: :voided, + void_reason: reason, + voided_at: Time.current, + share_token: nil, + approved_at: nil + ) + + # TODO: SendWebhookJob.perform_after_commit("quote_version.voided", quote_version) + + result.quote_version = quote_version + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + def voidable? + quote_version.draft? + end + + def valid_reason? + return false if reason.blank? + + QuoteVersion::VOID_REASONS.has_key?(reason.to_s.to_sym) + end + end +end diff --git a/app/services/quotes/create_service.rb b/app/services/quotes/create_service.rb new file mode 100644 index 0000000..186504f --- /dev/null +++ b/app/services/quotes/create_service.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Quotes + class CreateService < BaseService + include OrderForms::Premium + + attr_reader :organization, :customer, :subscription, :params, :owners + + Result = BaseResult[:quote] + + def initialize(organization:, customer:, subscription: nil, params: {}) + @organization = organization + @customer = customer + @subscription = subscription + @params = params + @owners = normalize_owners(owners: params[:owners]) + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "organization") unless organization + return result.not_found_failure!(resource: "customer") unless customer + return result.not_found_failure!(resource: "subscription") if subscription_required? && subscription.blank? + return result.not_found_failure!(resource: "subscription") if subscription.present? && !subscription_belongs_to_quote_scope? + return result.forbidden_failure! unless order_forms_enabled?(organization) + return result.single_validation_failure!(field: :owners, error_code: "invalid") unless valid_owners? + + Quote.transaction do + quote = organization.quotes.create!( + customer:, + subscription:, + **params.slice(:order_type) + ) + initialize_version!(quote:) + add_owners!(quote:) + result.quote = quote + end + + # TODO: SendWebhookJob.perform_after_commit("quote.created", quote) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + def subscription_required? + params[:order_type].to_s == "subscription_amendment" + end + + def subscription_belongs_to_quote_scope? + subscription.organization_id == organization.id && subscription.customer_id == customer.id + end + + def valid_owners? + return true if owners.blank? + + known = organization.memberships.active.where(user_id: owners).pluck(:user_id) + (owners - known).empty? + end + + def initialize_version!(quote:) + QuoteVersions::CreateService.call!( + quote: quote, + params: params.slice(:billing_items, :content) + ) + end + + def add_owners!(quote:) + return if owners.blank? + + owners.each do |user_id| + quote.quote_owners.create!(organization:, user_id:) + end + end + + def normalize_owners(owners:) + return [] if owners.blank? + return owners.map(&:to_s).uniq if owners.is_a?(Array) + + [owners.to_s] + end + end +end diff --git a/app/services/quotes/update_service.rb b/app/services/quotes/update_service.rb new file mode 100644 index 0000000..7f97343 --- /dev/null +++ b/app/services/quotes/update_service.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Quotes + class UpdateService < BaseService + include OrderForms::Premium + + attr_reader :quote, :params, :owners + + Result = BaseResult[:quote] + + def initialize(quote:, params:) + @quote = quote + @params = params + @owners = normalize_owners(owners: params[:owners]) + super + end + + def call + return result.not_found_failure!(resource: "quote") unless quote + return result.forbidden_failure! unless order_forms_enabled?(quote.organization) + return result.single_validation_failure!(field: :owners, error_code: "invalid") unless valid_owners? + + sync_owners!(quote:) if params.has_key?(:owners) + + # TODO: SendWebhookJob.perform_after_commit("quote.updated", quote) + + result.quote = quote.reload + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + def valid_owners? + return true if owners.blank? + + known = quote.organization.memberships.active.where(user_id: owners).pluck(:user_id) + (owners - known).empty? + end + + def normalize_owners(owners:) + return [] if owners.blank? + return owners.map(&:to_s).uniq if owners.is_a?(Array) + + [owners.to_s] + end + + def sync_owners!(quote:) + QuoteOwner.transaction do + current_owners = quote.owner_ids + + owners_to_remove = current_owners - owners + quote.quote_owners.where(user_id: owners_to_remove).delete_all if owners_to_remove.any? + + owners_to_add = owners - current_owners + owners_to_add.each do |user_id| + quote.quote_owners.create!( + organization_id: quote.organization_id, + user_id: user_id + ) + end + end + end + end +end diff --git a/app/services/result.rb b/app/services/result.rb new file mode 100644 index 0000000..6228f35 --- /dev/null +++ b/app/services/result.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Result + attr_reader :error + + def initialize + super + @failure = false + @error = nil + end + + def failure? + failure + end + + def success? + !failure + end + + def fail_with_error!(error) + @failure = true + @error = error + + self + end + + def not_found_failure!(resource:) + fail_with_error!(BaseService::NotFoundFailure.new(self, resource:)) + end + + def not_allowed_failure!(code:) + fail_with_error!(BaseService::MethodNotAllowedFailure.new(self, code:)) + end + + def record_validation_failure!(record:) + validation_failure!(errors: record.errors.messages) + end + + def validation_failure!(errors:) + fail_with_error!(BaseService::ValidationFailure.new(self, messages: errors)) + end + + def single_validation_failure!(error_code:, field: :base) + validation_failure!(errors: {field.to_sym => [error_code]}) + end + + def service_failure!(code:, message:, error: nil) + fail_with_error!(BaseService::ServiceFailure.new(self, code:, error_message: message, original_error: error)) + end + + def non_retryable_failure!(code:, message:) + fail_with_error!(BaseService::NonRetryableFailure.new(self, code:, error_message: message)) + end + + def unknown_tax_failure!(code:, message:) + fail_with_error!(BaseService::UnknownTaxFailure.new(self, code:, error_message: message)) + end + + def forbidden_failure!(code: "feature_unavailable") + fail_with_error!(BaseService::ForbiddenFailure.new(self, code:)) + end + + def unauthorized_failure!(message: "unauthorized") + fail_with_error!(BaseService::UnauthorizedFailure.new(self, message:)) + end + + def provider_failure!(provider:, error:) + fail_with_error!(BaseService::ProviderFailure.new(self, provider:, error:)) + end + + def third_party_failure!(third_party:, error_code:, error_message:) + fail_with_error!(BaseService::ThirdPartyFailure.new(self, third_party:, error_code:, error_message:)) + end + + def too_many_provider_requests_failure!(provider_name:, error:) + fail_with_error!(BaseService::TooManyProviderRequestsFailure.new(self, provider_name:, error:)) + end + + def raise_if_error! + return self if success? + + raise(error) + end + + private + + attr_accessor :failure +end diff --git a/app/services/roles/create_service.rb b/app/services/roles/create_service.rb new file mode 100644 index 0000000..d00a0b0 --- /dev/null +++ b/app/services/roles/create_service.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Roles + class CreateService < BaseService + Result = BaseResult[:role] + + def initialize(organization:, code:, name:, permissions:, description: nil) + @organization = organization + @code = code + @name = name + @description = description + @permissions = permissions + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.forbidden_failure!(code: "premium_integration_missing") unless organization.custom_roles_enabled? + + role = organization.roles.create!( + code:, + name:, + description:, + permissions: + ) + + register_security_log(role) + + result.role = role + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :code, :name, :description, :permissions + + def register_security_log(role) + Utils::SecurityLog.produce( + organization: organization, + log_type: "role", + log_event: "role.created", + resources: {role_code: role.code, permissions: role.permissions} + ) + end + end +end diff --git a/app/services/roles/destroy_service.rb b/app/services/roles/destroy_service.rb new file mode 100644 index 0000000..6a72c53 --- /dev/null +++ b/app/services/roles/destroy_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Roles + class DestroyService < BaseService + Result = BaseResult[:role] + + def initialize(role:) + @role = role + super + end + + def call + return result.not_found_failure!(resource: "role") unless role + return result.forbidden_failure!(code: "predefined_role") if predefined_role? + return result.forbidden_failure!(code: "role_assigned_to_members") if role.active_memberships.exists? + + role.discard! + + register_security_log + + result.role = role + result + end + + private + + attr_reader :role + + def predefined_role? + role.organization_id.nil? + end + + def register_security_log + Utils::SecurityLog.produce( + organization: role.organization, + log_type: "role", + log_event: "role.deleted", + resources: {role_code: role.code} + ) + end + end +end diff --git a/app/services/roles/update_service.rb b/app/services/roles/update_service.rb new file mode 100644 index 0000000..7d51f25 --- /dev/null +++ b/app/services/roles/update_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Roles + class UpdateService < BaseService + Result = BaseResult[:role] + + def initialize(role:, params:) + @role = role + @params = params + super + end + + def call + return result.not_found_failure!(resource: "role") unless role + return result.forbidden_failure!(code: "predefined_role") if predefined_role? + + role.update!(params.slice(:name, :description, :permissions).compact) + + register_security_log + + result.role = role + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :role, :params + + def predefined_role? + role.organization_id.nil? + end + + def register_security_log + diff = role.previous_changes.slice("name", "description").to_h + .transform_keys(&:to_sym) + .transform_values { |v| {deleted: v[0], added: v[1]}.compact } + + if role.previous_changes.key?("permissions") + old_perms, new_perms = role.previous_changes["permissions"] + entry = {} + deleted = old_perms - new_perms + added = new_perms - old_perms + entry[:deleted] = deleted if deleted.present? + entry[:added] = added if added.present? + diff[:permissions] = entry if entry.present? + end + + Utils::SecurityLog.produce( + organization: role.organization, + log_type: "role", + log_event: "role.updated", + resources: {role_code: role.code, **diff} + ) + end + end +end diff --git a/app/services/subscriptions/activate_all_pending_service.rb b/app/services/subscriptions/activate_all_pending_service.rb new file mode 100644 index 0000000..7e68297 --- /dev/null +++ b/app/services/subscriptions/activate_all_pending_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Subscriptions + class ActivateAllPendingService < BaseService + Result = BaseResult + + def initialize(timestamp:) + @timestamp = Time.zone.at(timestamp) + + super + end + + def call + Subscription + .joins(customer: :billing_entity) + .pending + .where(previous_subscription: nil) + .where( + "DATE(subscriptions.subscription_at#{at_time_zone}) <= " \ + "DATE(?#{at_time_zone})", + timestamp + ) + .find_each do |subscription| + ActivateService.call!(subscription:, timestamp:) + end + + result + end + + private + + attr_reader :timestamp + end +end diff --git a/app/services/subscriptions/activate_service.rb b/app/services/subscriptions/activate_service.rb new file mode 100644 index 0000000..4de5778 --- /dev/null +++ b/app/services/subscriptions/activate_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Subscriptions + class ActivateService < BaseService + Result = BaseResult[:subscription] + + def initialize(subscription:, timestamp: Time.current, during_creation: false) + @subscription = subscription + @timestamp = timestamp + @during_creation = during_creation + super + end + + def call + return result if subscription.active? + return result if subscription.gated? + + if subscription.pending? + activate_from_pending + elsif subscription.incomplete? + activate_from_incomplete + end + + result.subscription = subscription + result + end + + private + + attr_reader :subscription, :timestamp, :during_creation + + def activate_from_pending + ActivationRules::EvaluateService.call!(subscription:) + + if subscription.pending_rules? + gate_subscription + else + activate_with_side_effects + end + end + + def activate_from_incomplete + return if subscription.activation_rules.rejected.exists? + + subscription.mark_as_active!(timestamp) + + after_commit do + bill_subscription if subscription.activation_rules.payment.none? + notify_started + end + end + + def gate_subscription + subscription.mark_as_incomplete! + + EmitFixedChargeEventsService.call!( + subscriptions: [subscription], + timestamp: subscription.started_at + 1.second + ) + + after_commit do + bill_subscription(skip_charges: true) if subscription.payment_gated? + + SendWebhookJob.perform_later("subscription.incomplete", subscription) + Utils::ActivityLog.produce(subscription, "subscription.incomplete") + end + end + + def activate_with_side_effects + subscription.mark_as_active!(timestamp) + + EmitFixedChargeEventsService.call!( + subscriptions: [subscription], + timestamp: subscription.started_at + 1.second + ) + + after_commit do + bill_subscription(skip_charges: true) + notify_started + end + end + + def notify_started + SendWebhookJob.perform_later("subscription.started", subscription) + Utils::ActivityLog.produce(subscription, "subscription.started") + + # Skip Hubspot UpdateJob when activating during subscription creation — + # CreateService fires Hubspot::CreateJob after this, which captures the + # active state and avoids a redundant Update that would race with Create. + return if during_creation + + if subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription:) + end + end + + def bill_subscription(skip_charges: false) + if subscription.plan.pay_in_advance? && !subscription.in_trial_period? + BillSubscriptionJob.perform_later( + [subscription], + timestamp.to_i, + invoicing_reason: :subscription_starting, + skip_charges: + ) + elsif subscription.fixed_charges.pay_in_advance.any? + Invoices::CreatePayInAdvanceFixedChargesJob.perform_later( + subscription, + subscription.started_at + 1.second + ) + end + end + end +end diff --git a/app/services/subscriptions/activation_rules/apply_service.rb b/app/services/subscriptions/activation_rules/apply_service.rb new file mode 100644 index 0000000..98bfab4 --- /dev/null +++ b/app/services/subscriptions/activation_rules/apply_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + class ApplyService < BaseService + Result = BaseResult[:activation_rules] + + def initialize(subscription:, activation_rules:) + @subscription = subscription + @activation_rules = activation_rules + + super + end + + def call + return result if activation_rules.nil? + return result.single_validation_failure!(field: :activation_rules, error_code: "subscription_not_pending") unless subscription.pending? + + subscription.activation_rules.destroy_all + + activation_rules.each do |rule_params| + subscription.activation_rules.create!( + organization_id: subscription.organization_id, + status: :inactive, + **rule_params.to_h.with_indifferent_access.slice(:type, :timeout_hours) + ) + end + + result.activation_rules = subscription.activation_rules.reload + result + end + + private + + attr_reader :subscription, :activation_rules + end + end +end diff --git a/app/services/subscriptions/activation_rules/evaluate_service.rb b/app/services/subscriptions/activation_rules/evaluate_service.rb new file mode 100644 index 0000000..bea1e13 --- /dev/null +++ b/app/services/subscriptions/activation_rules/evaluate_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + class EvaluateService < BaseService + Result = BaseResult[:subscription, :rules] + + def initialize(subscription:) + @subscription = subscription + super + end + + def call + result.rules = [] + + subscription.activation_rules.each do |rule| + rule.evaluate! + result.rules << rule + end + + result.subscription = subscription + result + end + + private + + attr_reader :subscription + end + end +end diff --git a/app/services/subscriptions/activation_rules/payment/evaluate_service.rb b/app/services/subscriptions/activation_rules/payment/evaluate_service.rb new file mode 100644 index 0000000..3ab7194 --- /dev/null +++ b/app/services/subscriptions/activation_rules/payment/evaluate_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + module Payment + class EvaluateService < BaseService + Result = BaseResult[:rule] + + def initialize(rule:, status: nil) + @rule = rule + @status = status + super + end + + def call + case rule.status.to_sym + when :inactive + evaluate_inactive_rule + when :pending + transition_pending_rule + end + + result.rule = rule + result + end + + private + + attr_reader :rule, :status + + def evaluate_inactive_rule + if rule.applicable? + rule.expires_at = compute_expires_at + rule.pending! + else + rule.not_applicable! + end + end + + def transition_pending_rule + raise ArgumentError, "status required to transition a pending rule" if status.blank? + + rule.public_send(:"#{status}!") + end + + def compute_expires_at + return nil if rule.timeout_hours.zero? + + Time.current + rule.timeout_hours.hours + end + end + end + end +end diff --git a/app/services/subscriptions/activation_rules/payment/resolve_service.rb b/app/services/subscriptions/activation_rules/payment/resolve_service.rb new file mode 100644 index 0000000..82be776 --- /dev/null +++ b/app/services/subscriptions/activation_rules/payment/resolve_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + module Payment + class ResolveService < BaseService + Result = BaseResult + + def initialize(subscription:, invoice:, payment_status:) + @subscription = subscription + @invoice = invoice + @payment_status = payment_status.to_sym + super + end + + def call + subscription.with_lock do + case payment_status + when :succeeded + handle_success + when :failed + handle_failure + end + end + + result + end + + private + + attr_reader :subscription, :invoice, :payment_status + + def handle_success + return unless subscription.incomplete? && invoice.open? && invoice.subscription? + + EvaluateService.call!(rule: payment_rule, status: :satisfied) + Invoices::FinalizeService.call!(invoice:) + ActivationRules::ResolveSubscriptionStatusService.call!(subscription:) + + after_commit do + SendWebhookJob.perform_later("invoice.created", invoice) + Utils::ActivityLog.produce(invoice, "invoice.created") + Invoices::GenerateDocumentsJob.perform_later(invoice:, notify: should_deliver_email?) + Integrations::Aggregator::Invoices::CreateJob.perform_later(invoice:) if invoice.should_sync_invoice? + Integrations::Aggregator::Invoices::Hubspot::CreateJob.perform_later(invoice:) if invoice.should_sync_hubspot_invoice? + Utils::SegmentTrack.invoice_created(invoice) + end + end + + def handle_failure + return unless subscription.incomplete? && invoice.open? && invoice.subscription? + + EvaluateService.call!(rule: payment_rule, status: :failed) + invoice.closed! + ActivationRules::ResolveSubscriptionStatusService.call!(subscription:) + subscription.update!(cancelation_reason: :payment_failed) + end + + def payment_rule + @payment_rule ||= subscription.activation_rules.payment.sole + end + + def should_deliver_email? + License.premium? && + invoice.billing_entity.email_settings.include?("invoice.finalized") + end + end + end + end +end diff --git a/app/services/subscriptions/activation_rules/payment/validate_service.rb b/app/services/subscriptions/activation_rules/payment/validate_service.rb new file mode 100644 index 0000000..426d38f --- /dev/null +++ b/app/services/subscriptions/activation_rules/payment/validate_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + module Payment + class ValidateService < BaseValidator + def valid? + valid_timeout_hours? + valid_payment_method? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_timeout_hours? + return true unless args[:rule].key?(:timeout_hours) + return true if args[:rule][:timeout_hours].is_a?(Integer) && args[:rule][:timeout_hours] >= 0 + + add_error(field: :timeout_hours, error_code: "value_must_be_positive_or_zero") + end + + def valid_payment_method? + return true if args[:payment_method].present? && args[:payment_method][:payment_method_type] == PaymentMethod::PAYMENT_METHOD_TYPES[:provider] + return true if args[:payment_method].blank? && args[:subscription]&.payment_method_type == PaymentMethod::PAYMENT_METHOD_TYPES[:provider] + return true if args[:payment_method].blank? && args[:subscription].nil? && args[:customer]&.payment_provider.present? + + return add_error(field: :payment_method, error_code: "no_linked_payment_provider") if args[:payment_method].blank? && args[:subscription].nil? + add_error(field: :payment_method, error_code: "invalid_for_payment_activation_rules") + end + end + end + end +end diff --git a/app/services/subscriptions/activation_rules/resolve_subscription_status_service.rb b/app/services/subscriptions/activation_rules/resolve_subscription_status_service.rb new file mode 100644 index 0000000..51baa2e --- /dev/null +++ b/app/services/subscriptions/activation_rules/resolve_subscription_status_service.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + class ResolveSubscriptionStatusService < BaseService + Result = BaseResult[:subscription] + + def initialize(subscription:) + @subscription = subscription + super + end + + def call + return result.tap { result.subscription = subscription } unless subscription.incomplete? + + if all_rules_satisfied? + Subscriptions::ActivateService.call!(subscription:) + elsif any_rule_failed? + subscription.mark_as_canceled! + SendWebhookJob.perform_after_commit("subscription.canceled", subscription) + Utils::ActivityLog.produce_after_commit(subscription, "subscription.canceled") + end + + result.subscription = subscription + result + end + + private + + attr_reader :subscription + + def all_rules_satisfied? + subscription.activation_rules.where.not(status: Subscription::ActivationRule::FULFILLED_STATUSES).none? + end + + def any_rule_failed? + subscription.activation_rules.rejected.exists? + end + end + end +end diff --git a/app/services/subscriptions/activation_rules/validate_service.rb b/app/services/subscriptions/activation_rules/validate_service.rb new file mode 100644 index 0000000..39f8fff --- /dev/null +++ b/app/services/subscriptions/activation_rules/validate_service.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Subscriptions + module ActivationRules + class ValidateService < BaseValidator + def valid? + valid_activation_rules_format? + valid_subscription_status? + valid_rules? unless errors[:activation_rules]&.include?("invalid_format") + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_activation_rules_format? + return true if args[:activation_rules].is_a?(Array) + + add_error(field: :activation_rules, error_code: "invalid_format") + end + + def valid_subscription_status? + return true unless args[:subscription_type] == "update" + return true if args[:subscription].pending? + + add_error(field: :activation_rules, error_code: "subscription_not_pending") + end + + def valid_rules? + return true if args[:activation_rules].blank? + + args[:activation_rules].each do |rule| + next unless valid_rule_type?(rule) + + validate_specific_rule(rule) + end + + duplicated_rule_types? + end + + def duplicated_rule_types? + types = args[:activation_rules].map { |rule| rule[:type].to_s } + return true if types.uniq.size == types.size + + add_error(field: :activation_rules, error_code: "duplicated_type") + end + + def valid_rule_type?(rule) + return true if Subscription::ActivationRule::STI_MAPPING.key?(rule[:type].to_s) + + add_error(field: :activation_rules, error_code: "invalid_type") + end + + def validate_specific_rule(rule) + validator = case rule[:type].to_s + when "payment" + Payment::ValidateService.new( + result, + rule:, + payment_method: args[:payment_method], + subscription: args[:subscription], + customer: args[:customer] + ) + end + + return true if validator.nil? + return true if validator.valid? + + validator.errors.each do |field, codes| + codes.each { |code| add_error(field:, error_code: code) } + end + + false + end + end + end +end diff --git a/app/services/subscriptions/charge_cache_middleware.rb b/app/services/subscriptions/charge_cache_middleware.rb new file mode 100644 index 0000000..ba10ec6 --- /dev/null +++ b/app/services/subscriptions/charge_cache_middleware.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Subscriptions + class ChargeCacheMiddleware + EMPTY_ARRAY = [].freeze + + def initialize(subscription:, charge:, to_datetime:, cache: true) + @subscription = subscription + @charge = charge + @to_datetime = to_datetime + @cache = cache + end + + def call(charge_filter:) + return yield unless cache + + json = Subscriptions::ChargeCacheService.call(subscription:, charge:, charge_filter:, expires_in: cache_expiration) do + yield + .map do |fee| + fee.attributes.merge( + "pricing_unit_usage" => fee.pricing_unit_usage&.attributes, + "presentation_breakdowns" => fee.presentation_breakdowns.map(&:attributes) + ) + end + .to_json + end + + JSON.parse(json).map do |j| + pricing_unit_usage = if j["pricing_unit_usage"].present? + PricingUnitUsage.new(j["pricing_unit_usage"].slice(*PricingUnitUsage.column_names)) + end + + fee = Fee.new( + **j.slice(*Fee.column_names), + pricing_unit_usage: + ) + + j.fetch("presentation_breakdowns", EMPTY_ARRAY).each do |breakdown| + fee.presentation_breakdowns.build( + breakdown.slice(*PresentationBreakdown.column_names) + ) + end + + fee + end + end + + private + + attr_reader :subscription, :charge, :to_datetime, :cache + + def cache_expiration + return 0 unless to_datetime + + [(to_datetime - Time.current).to_i.seconds, 0].max + end + end +end diff --git a/app/services/subscriptions/charge_cache_service.rb b/app/services/subscriptions/charge_cache_service.rb new file mode 100644 index 0000000..a01582d --- /dev/null +++ b/app/services/subscriptions/charge_cache_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Subscriptions + class ChargeCacheService < CacheService + CACHE_KEY_VERSION = "1" + + def self.expire_for_subscription(subscription) + subscription.plan.charges.includes(:filters) + .find_each { expire_for_subscription_charge(subscription:, charge: it) } + end + + def self.expire_for_subscription_charge(subscription:, charge:) + charge.filters.each do |filter| + expire_cache(subscription:, charge:, charge_filter: filter) + end + + expire_cache(subscription:, charge:) + end + + def initialize(subscription:, charge:, charge_filter: nil, expires_in: nil) + @subscription = subscription + @charge = charge + @charge_filter = charge_filter + + super(expires_in:) + end + + # IMPORTANT + # when making changes here, please make sure to bump the cache key so old values are immediately invalidated! + def cache_key + [ + "charge-usage", + CACHE_KEY_VERSION, + charge.id, + subscription.id, + charge.updated_at.iso8601, + charge_filter&.id, + charge_filter&.updated_at&.iso8601 + ].compact.join("/") + end + + private + + attr_reader :subscription, :charge, :charge_filter + end +end diff --git a/app/services/subscriptions/charge_filters/create_service.rb b/app/services/subscriptions/charge_filters/create_service.rb new file mode 100644 index 0000000..2be3d4a --- /dev/null +++ b/app/services/subscriptions/charge_filters/create_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Subscriptions + module ChargeFilters + class CreateService < BaseService + include Concerns::PlanOverrideConcern + include Concerns::ChargeOverrideConcern + + Result = BaseResult[:charge_filter] + + def initialize(subscription:, charge:, params:) + @subscription = subscription + @charge = charge + @params = params + + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_found_failure!(resource: "charge") unless charge + return result.single_validation_failure!(field: :values, error_code: "value_is_mandatory") if params[:values].blank? + + ActiveRecord::Base.transaction do + target_plan = ensure_plan_override + target_charge = find_or_create_charge_override(target_plan) + + sorted_values = params[:values].sort + existing = target_charge.filters.find { |f| f.to_h.sort == sorted_values } + return result.single_validation_failure!(field: :values, error_code: "value_already_exists") if existing + + create_result = ::ChargeFilters::CreateService.call!( + charge: target_charge, + params: + ) + + result.charge_filter = create_result.charge_filter + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :charge, :params + end + end +end diff --git a/app/services/subscriptions/charge_filters/destroy_service.rb b/app/services/subscriptions/charge_filters/destroy_service.rb new file mode 100644 index 0000000..c0c1281 --- /dev/null +++ b/app/services/subscriptions/charge_filters/destroy_service.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Subscriptions + module ChargeFilters + class DestroyService < BaseService + include Concerns::PlanOverrideConcern + include Concerns::ChargeOverrideConcern + + Result = BaseResult[:charge_filter] + + def initialize(subscription:, charge:, charge_filter:) + @subscription = subscription + @charge = charge + @charge_filter = charge_filter + + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_found_failure!(resource: "charge") unless charge + return result.not_found_failure!(resource: "charge_filter") unless charge_filter + + ActiveRecord::Base.transaction do + target_plan = ensure_plan_override + target_charge = find_or_create_charge_override(target_plan) + target_filter = find_filter_on_charge(target_charge) + + return result.not_found_failure!(resource: "charge_filter") unless target_filter + + destroy_result = ::ChargeFilters::DestroyService.call!(charge_filter: target_filter) + + result.charge_filter = destroy_result.charge_filter + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :charge, :charge_filter + + def find_filter_on_charge(target_charge) + filter_values_hash = charge_filter.to_h + target_charge.filters.find { |f| f.to_h.sort == filter_values_hash.sort } + end + end + end +end diff --git a/app/services/subscriptions/charge_filters/update_or_override_service.rb b/app/services/subscriptions/charge_filters/update_or_override_service.rb new file mode 100644 index 0000000..bd54376 --- /dev/null +++ b/app/services/subscriptions/charge_filters/update_or_override_service.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Subscriptions + module ChargeFilters + class UpdateOrOverrideService < BaseService + include Concerns::PlanOverrideConcern + include Concerns::ChargeOverrideConcern + + Result = BaseResult[:charge_filter] + + def initialize(subscription:, charge:, charge_filter:, params:) + @subscription = subscription + @charge = charge + @charge_filter = charge_filter + @params = params + + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_found_failure!(resource: "charge") unless charge + return result.not_found_failure!(resource: "charge_filter") unless charge_filter + + ActiveRecord::Base.transaction do + target_plan = ensure_plan_override + target_charge = find_or_create_charge_override(target_plan) + target_filter = find_or_create_filter_override(target_charge) + + result.charge_filter = target_filter + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :charge, :charge_filter, :params + + def find_or_create_filter_override(target_charge) + filter_values_hash = charge_filter.to_h + existing_filter = find_filter_by_values(target_charge, filter_values_hash) + + if existing_filter + update_filter(existing_filter, target_charge) + else + create_filter_override(target_charge, filter_values_hash) + end + end + + def find_filter_by_values(target_charge, filter_values_hash) + target_charge.filters.find { |f| f.to_h.sort == filter_values_hash.sort } + end + + def create_filter_override(target_charge, filter_values_hash) + create_result = ::ChargeFilters::CreateService.call!( + charge: target_charge, + params: { + values: filter_values_hash, + properties: params.key?(:properties) ? params[:properties] : charge_filter.properties, + invoice_display_name: params.key?(:invoice_display_name) ? params[:invoice_display_name] : charge_filter.invoice_display_name + } + ) + create_result.charge_filter + end + + def update_filter(existing_filter, target_charge) + existing_filter.properties = filtered_properties(target_charge) if params.key?(:properties) + existing_filter.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + existing_filter.save! + + existing_filter.reload + end + + def filtered_properties(target_charge) + ChargeModels::FilterPropertiesService.call( + chargeable: target_charge, + properties: params[:properties] + ).properties + end + end + end +end diff --git a/app/services/subscriptions/concerns/charge_override_concern.rb b/app/services/subscriptions/concerns/charge_override_concern.rb new file mode 100644 index 0000000..0f21b31 --- /dev/null +++ b/app/services/subscriptions/concerns/charge_override_concern.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Subscriptions + module Concerns + module ChargeOverrideConcern + extend ActiveSupport::Concern + + private + + def find_or_create_charge_override(target_plan) + parent_charge = find_parent_charge + existing_override = target_plan.charges.find_by(parent_id: parent_charge.id) + + if existing_override + existing_override + else + override_result = Charges::OverrideService.call!( + charge: parent_charge, + params: {plan_id: target_plan.id} + ) + override_result.charge + end + end + + def find_parent_charge + if charge.parent_id + charge.parent + else + charge + end + end + end + end +end diff --git a/app/services/subscriptions/concerns/plan_override_concern.rb b/app/services/subscriptions/concerns/plan_override_concern.rb new file mode 100644 index 0000000..929dba3 --- /dev/null +++ b/app/services/subscriptions/concerns/plan_override_concern.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Subscriptions + module Concerns + module PlanOverrideConcern + extend ActiveSupport::Concern + + private + + def ensure_plan_override + current_plan = subscription.plan + + if current_plan.parent_id + current_plan + else + override_result = Plans::OverrideService.call!( + plan: current_plan, + params: {}, + subscription: + ) + subscription.update!(plan: override_result.plan) + override_result.plan + end + end + end + end +end diff --git a/app/services/subscriptions/consume_subscription_refreshed_queue_service.rb b/app/services/subscriptions/consume_subscription_refreshed_queue_service.rb new file mode 100644 index 0000000..ab2f29a --- /dev/null +++ b/app/services/subscriptions/consume_subscription_refreshed_queue_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Subscriptions + class ConsumeSubscriptionRefreshedQueueService < BaseService + Result = BaseResult + + REDIS_STORE_NAME = "subscription_refreshed_v2" + BATCH_SIZE = 100 + PROCESSING_TIMEOUT = 1.minute + + # Events-processor writes to a sorted set with ZADD, using the event timestamp as score + # and a bucketed member key (org_id:sub_id|bucket). Members are only eligible for consumption + # once their score has aged past SUBSCRIPTION_BUCKET_DURATION. + SUBSCRIPTION_BUCKET_DURATION = 10 + + def call + return result if ENV["LAGO_REDIS_STORE_URL"].blank? + + start_time = Time.current + + loop do + if Time.current - start_time > PROCESSING_TIMEOUT + break + end + + threshold = (Time.current - SUBSCRIPTION_BUCKET_DURATION).to_i + values = redis_client.zrangebyscore(REDIS_STORE_NAME, "-inf", threshold, limit: [0, BATCH_SIZE]) + break if values.blank? + + values.each do |value| + # Extract the subscription_id from the bucketed member key (org_id:sub_id|bucket) + subscription_id = value.split("|").first.split(":").last + + Subscriptions::FlagRefreshedJob.perform_later(subscription_id) + end + + redis_client.zrem(REDIS_STORE_NAME, values) + end + + result + end + + private + + def redis_client + return @redis_client if defined? @redis_client + + url = if ENV["LAGO_REDIS_STORE_URL"].start_with?(/rediss?:\/\//) + ENV["LAGO_REDIS_STORE_URL"] + else + "redis://#{ENV["LAGO_REDIS_STORE_URL"]}" + end + + config = { + url:, + timeout: 5.0, + reconnect_attempts: 3 + } + + config[:password] = ENV["LAGO_REDIS_STORE_PASSWORD"] if ENV["LAGO_REDIS_STORE_PASSWORD"].present? + config[:db] = ENV["LAGO_REDIS_STORE_DB"] if ENV["LAGO_REDIS_STORE_DB"].present? + + if ENV["LAGO_REDIS_STORE_SSL"].present? || ENV["LAGO_REDIS_STORE_URL"].start_with?("rediss:") + config[:ssl] = true + end + + if ENV["LAGO_REDIS_STORE_DISABLE_SSL_VERIFY"].present? + config[:ssl_params] = {verify_mode: OpenSSL::SSL::VERIFY_NONE} + end + + @redis_client ||= Redis.new(config) + end + end +end diff --git a/app/services/subscriptions/create_service.rb b/app/services/subscriptions/create_service.rb new file mode 100644 index 0000000..1ed31fd --- /dev/null +++ b/app/services/subscriptions/create_service.rb @@ -0,0 +1,306 @@ +# frozen_string_literal: true + +module Subscriptions + class CreateService < BaseService + Result = BaseResult[:subscription, :payment_method] + + def initialize(customer:, plan:, params:) + super + + @customer = customer + @plan = plan + @params = params + + @name = params[:name].to_s.strip + @subscription_at = params[:subscription_at] || Time.current + @billing_time = params[:billing_time] + @external_id = params[:external_id].to_s.strip + @plan_overrides = params[:plan_overrides].to_h.with_indifferent_access + end + + def call + return result unless valid?( + customer:, + plan:, + subscription_at:, + ending_at: params[:ending_at], + payment_method: params[:payment_method], + activation_rules: params[:activation_rules], + subscription_type: + ) + return result.forbidden_failure! if !License.premium? && params.key?(:plan_overrides) + return result.validation_failure!(errors: {external_customer_id: ["value_is_mandatory"]}) if params[:external_customer_id].blank? && api_context? + + # TODO: Remove check we stop supporting `plan_overrides.usage_thresholds` + if params[:usage_thresholds].present? && plan_overrides[:usage_thresholds].present? + return result.validation_failure!(errors: { + "plan_overrides.usage_thresholds": ["incompatible_params"], + usage_thresholds: ["incompatible_params"] + }) + end + + plan.amount_currency = plan_overrides[:amount_currency] if plan_overrides[:amount_currency] + plan.amount_cents = plan_overrides[:amount_cents] if plan_overrides[:amount_cents] + + # NOTE: in API, it's possible to create a subscription for a new customer + customer.save! if api_context? + + ActiveRecord::Base.transaction do + Customers::UpdateCurrencyService + .call(customer:, currency: plan.amount_currency) + .raise_if_error! + + customer.with_lock do + if customer.subscriptions.incomplete + .exists?(["id = ? OR external_id = ?", params[:subscription_id], external_id]) + result.validation_failure!(errors: {subscription: ["subscription_incomplete"]}) + result.raise_if_error! + end + + @current_subscription = editable_subscriptions + .find_by("id = ? OR external_id = ?", params[:subscription_id], external_id) + + subscription = handle_subscription + + if params[:usage_thresholds].present? + UpdateUsageThresholdsService.call!(subscription:, usage_thresholds_params: params[:usage_thresholds], partial: false) + end + InvoiceCustomSections::AttachToResourceService.call(resource: subscription, params:) unless downgrade? + + result.subscription = subscription + end + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ArgumentError + result.validation_failure!(errors: {billing_time: ["value_is_invalid"]}) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :customer, + :plan, + :params, + :name, + :subscription_at, + :billing_time, + :external_id, + :current_subscription, + :plan_overrides + + def valid?(args) + result.payment_method = payment_method + + Subscriptions::ValidateService.new(result, **args).valid? + end + + def handle_subscription + return upgrade_subscription if upgrade? + return downgrade_subscription if downgrade? + + current_subscription || create_subscription + end + + def upgrade? + return false unless current_subscription + return false if plan.id == current_subscription.plan.id + + plan.yearly_amount_cents >= current_subscription.plan.yearly_amount_cents + end + + def downgrade? + return false unless current_subscription + return false if plan.id == current_subscription.plan.id + + plan.yearly_amount_cents < current_subscription.plan.yearly_amount_cents + end + + def create_subscription + new_subscription = Subscription.new( + organization_id: customer.organization_id, + customer:, + plan: params.key?(:plan_overrides) ? override_plan(plan) : plan, + subscription_at:, + name:, + external_id:, + billing_time: billing_time || :calendar, + ending_at: params[:ending_at], + progressive_billing_disabled: params[:progressive_billing_disabled] || false, + billing_entity: resolve_billing_entity + ) + + if params.key?(:payment_method) + new_subscription.payment_method_type = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) + new_subscription.payment_method_id = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) + end + + timezone = customer.applicable_timezone + today = Time.current.in_time_zone(timezone).to_date + subscription_date = new_subscription.subscription_at.in_time_zone(timezone).to_date + + if subscription_date == today + handle_today_subscription(new_subscription) + elsif subscription_date < today + handle_past_subscription(new_subscription) + else + handle_future_subscription(new_subscription) + end + + if new_subscription.should_sync_hubspot_subscription? + after_commit { Integrations::Aggregator::Subscriptions::Hubspot::CreateJob.perform_later(subscription: new_subscription) } + end + + new_subscription + end + + def handle_today_subscription(new_subscription) + new_subscription.pending! + apply_activation_rules(new_subscription) + ActivateService.call!(subscription: new_subscription, during_creation: true) + end + + def handle_past_subscription(new_subscription) + new_subscription.mark_as_active!(new_subscription.subscription_at) + + EmitFixedChargeEventsService.call!( + subscriptions: [new_subscription], + timestamp: new_subscription.started_at + 1.second + ) + + after_commit do + SendWebhookJob.perform_later("subscription.started", new_subscription) + Utils::ActivityLog.produce(new_subscription, "subscription.started") + end + end + + def handle_future_subscription(new_subscription) + new_subscription.pending! + apply_activation_rules(new_subscription) + end + + def upgrade_subscription + PlanUpgradeService.call!(current_subscription:, plan:, params:).subscription + end + + def downgrade_subscription + if current_subscription.starting_in_the_future? + update_pending_subscription + + return current_subscription + end + + cancel_pending_subscription if pending_subscription? + + # NOTE: When downgrading a subscription, we keep the current one active + # until the next billing day. The new subscription will become active at this date + new_sub = current_subscription.next_subscriptions.create!( + organization_id: customer.organization_id, + customer:, + plan: params.key?(:plan_overrides) ? override_plan(plan) : plan, + name:, + external_id: current_subscription.external_id, + subscription_at: current_subscription.subscription_at, + status: :pending, + billing_time: current_subscription.billing_time, + ending_at: params.key?(:ending_at) ? params[:ending_at] : current_subscription.ending_at, + progressive_billing_disabled: params[:progressive_billing_disabled] || false + ) + + if params.key?(:payment_method) + new_sub.payment_method_type = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) + new_sub.payment_method_id = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) + new_sub.save! + end + + InvoiceCustomSections::AttachToResourceService.call(resource: new_sub, params:) + + after_commit do + SendWebhookJob.perform_later("subscription.updated", current_subscription) + Utils::ActivityLog.produce(current_subscription, "subscription.updated") + end + + current_subscription + end + + def pending_subscription? + return false unless current_subscription&.next_subscription + + current_subscription.next_subscription.pending? + end + + def cancel_pending_subscription + current_subscription.next_subscription.mark_as_canceled! + end + + def subscription_type + return "downgrade" if downgrade? + return "upgrade" if upgrade? + + "create" + end + + def currency_missmatch?(old_plan, new_plan) + return false unless old_plan + + old_plan.amount_currency != new_plan.amount_currency + end + + def update_pending_subscription + current_subscription.plan = plan + current_subscription.name = name if name.present? + current_subscription.save! + + if current_subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription: current_subscription) + end + end + + def apply_activation_rules(subscription) + return unless params[:activation_rules]&.present? + + Subscriptions::ActivationRules::ApplyService.call!( + subscription:, + activation_rules: params[:activation_rules] + ) + end + + def editable_subscriptions + return Subscription.none unless customer + + @editable_subscriptions ||= customer.subscriptions.active + .or(customer.subscriptions.starting_in_the_future) + .order(started_at: :desc) + end + + def override_plan(plan) + Plans::OverrideService.call(plan:, params: params[:plan_overrides].to_h.with_indifferent_access).plan + end + + def payment_method + return @payment_method if defined? @payment_method + return nil if params[:payment_method].blank? || params[:payment_method][:payment_method_id].blank? + + @payment_method = PaymentMethod.find_by(id: params[:payment_method][:payment_method_id], organization_id: customer.organization_id) + end + + # nil here means "inherit from customer.billing_entity at billing time" + def resolve_billing_entity + return unless customer.organization.feature_flag_enabled?(:multi_entity_billing) + + attrs = if params[:billing_entity_id].present? + {id: params[:billing_entity_id]} + elsif params[:billing_entity_code].present? + {code: params[:billing_entity_code]} + end + return unless attrs + + customer.organization.billing_entities.find_by!(attrs) + rescue ActiveRecord::RecordNotFound + result.not_found_failure!(resource: "billing_entity").raise_if_error! + end + end +end diff --git a/app/services/subscriptions/dates/monthly_service.rb b/app/services/subscriptions/dates/monthly_service.rb new file mode 100644 index 0000000..177c8e0 --- /dev/null +++ b/app/services/subscriptions/dates/monthly_service.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module Subscriptions + module Dates + class MonthlyService < Subscriptions::DatesService + def compute_from_date(date = base_date) + if plan.pay_in_advance? || terminated_pay_in_arrears? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_month + end + + subscription.anniversary? ? previous_anniversary_day(date) : date.beginning_of_month + end + + def compute_charges_from_date + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_month + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_month if calendar? + + previous_anniversary_day(base_date) + end + + def compute_charges_to_date + return compute_charges_from_date.end_of_month if calendar? + + compute_to_date(compute_charges_from_date) + end + + def compute_duration(from_date:) + return Time.days_in_month(from_date.month, from_date.year) if calendar? + + next_month_date = compute_to_date(from_date) + (next_month_date.to_date + 1.day - from_date.to_date).to_i + end + + alias_method :compute_charges_duration, :compute_duration + alias_method :compute_fixed_charges_duration, :compute_charges_duration + alias_method :compute_fixed_charges_from_date, :compute_charges_from_date + alias_method :compute_fixed_charges_to_date, :compute_charges_to_date + + private + + def compute_base_date + # NOTE: if subscription anniversary is on last day of month and current month days count + # is less than month anniversary day count, we need to use the last day of the previous month + if subscription.anniversary? && last_day_of_month?(billing_date) && (billing_date.day < subscription_at.day) + if (billing_date - 1.month).end_of_month.day >= subscription_at.day + return (billing_date - 1.month).change(day: subscription_at.day) + end + + return (billing_date - 1.month).end_of_month + end + + billing_date - 1.month + end + + def compute_to_date(from_date = compute_from_date) + return from_date.end_of_month if subscription.calendar? || subscription_at.day == 1 + + year = from_date.year + month = from_date.month + 1 + day = subscription_at.day - 1 + + if month > 12 + month = 1 + year += 1 + end + + date = build_date(year, month, day) + + # NOTE: if subscription anniversary day is higher than the current last day of the month, + # subscription period, will end on the previous end of day + return date - 1.day if last_day_of_month?(date) && subscription_at.day > date.day + + date + end + + def compute_next_end_of_period + return billing_date.end_of_month if calendar? + + year = billing_date.year + month = billing_date.month + day = subscription_at.day + + # NOTE: we need the last day of the period, and not the first of the next one + result_date = build_date(year, month, day) - 1.day + return result_date if result_date >= billing_date + + month += 1 + if month > 12 + month = 1 + year += 1 + end + + build_date(year, month, day) - 1.day + end + + def compute_previous_beginning_of_period(date) + return date.beginning_of_month if calendar? + + previous_anniversary_day(date) + end + + def previous_anniversary_day(date) + year = nil + month = nil + + # NOTE: if subscription anniversary day is higher than the current last day of the month, + # anniversary day is on the current day + day = if subscription.anniversary? && last_day_of_month?(date) && (date.day < subscription_at.day) + date.day + else + subscription_at.day + end + + if date.day < day + year = (date.month == 1) ? date.year - 1 : date.year + month = (date.month == 1) ? 12 : date.month - 1 + else + year = date.year + month = date.month + end + + build_date(year, month, day) + end + end + end +end diff --git a/app/services/subscriptions/dates/quarterly_service.rb b/app/services/subscriptions/dates/quarterly_service.rb new file mode 100644 index 0000000..04e52d5 --- /dev/null +++ b/app/services/subscriptions/dates/quarterly_service.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +module Subscriptions + module Dates + class QuarterlyService < Subscriptions::DatesService + private + + def compute_from_date(date = base_date) + if plan.pay_in_advance? || terminated_pay_in_arrears? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_quarter + end + + subscription.anniversary? ? previous_anniversary_day(date) : date.beginning_of_quarter + end + + def compute_charges_from_date + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_quarter + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_quarter if calendar? + + previous_anniversary_day(base_date) + end + + def compute_charges_to_date + return compute_charges_from_date.end_of_quarter if calendar? + + compute_to_date(compute_charges_from_date) + end + + def compute_duration(from_date:) + next_to_date = compute_to_date(from_date) + + (next_to_date.to_date + 1.day - from_date.to_date).to_i + end + + alias_method :compute_charges_duration, :compute_duration + alias_method :compute_fixed_charges_duration, :compute_charges_duration + alias_method :compute_fixed_charges_from_date, :compute_charges_from_date + alias_method :compute_fixed_charges_to_date, :compute_charges_to_date + + def compute_base_date + # NOTE: if subscription anniversary is on last day of month and current month days count + # is less than month anniversary day count, we need to use the last day of the previous month + if subscription.anniversary? && last_day_of_month?(billing_date) && (billing_date.day < subscription_at.day) + if (billing_date - 3.months).end_of_month.day >= subscription_at.day + return (billing_date - 3.months).end_of_month.change(day: subscription_at.day) + end + + return (billing_date - 3.months).end_of_month + end + + billing_date - 3.months + end + + def compute_to_date(from_date = compute_from_date) + if subscription.calendar? || (subscription_at.day == 1 && [1, 4, 7, 10].include?(subscription_at.month)) + return from_date.end_of_quarter + end + + year = from_date.year + month = from_date.month + 3 + day = subscription_at.day - 1 + + if month > 12 + month = (month % 12).zero? ? 12 : (month % 12) + year += 1 + end + + date = build_date(year, month, day) + + # NOTE: if subscription anniversary day is higher than the current last day of the month, + # subscription period, will end on the previous end of day + return date - 1.day if last_day_of_month?(date) && subscription_at.day > date.day + + date + end + + def compute_next_end_of_period + return billing_date.end_of_quarter if calendar? + + year = billing_date.year + month = billing_date.month + day = subscription_at.day + + # NOTE: we need the last day of the period, and not the first of the next one + result_date = build_date(year, month, day) - 1.day + return result_date if result_date >= billing_date + + month += 3 + if month > 12 + month = (month % 12).zero? ? 12 : (month % 12) + year += 1 + end + + build_date(year, month, day) - 1.day + end + + def compute_previous_beginning_of_period(date) + return date.beginning_of_quarter if calendar? + + previous_anniversary_day(date) + end + + def previous_anniversary_day(date) + year = nil + month = nil + + # NOTE: if subscription anniversary day is higher than the current last day of the month, + # anniversary day is on the current day + day = if subscription.anniversary? && last_day_of_month?(date) && (date.day < subscription_at.day) + date.day + else + subscription_at.day + end + + billing_months = [ + (subscription_at.month % 12).zero? ? 12 : (subscription_at.month % 12), + ((subscription_at.month + 3) % 12).zero? ? 12 : ((subscription_at.month + 3) % 12), + ((subscription_at.month + 6) % 12).zero? ? 12 : ((subscription_at.month + 6) % 12), + ((subscription_at.month + 9) % 12).zero? ? 12 : ((subscription_at.month + 9) % 12) + ].sort + + # This is the case when we terminate subscription on On February 10 but anniversary date is on + # 5 of March. In that case we need to fetch billing period in previous year + if should_find_billing_date_in_previous_year?(date, billing_months, day) + year = date.year - 1 + month = billing_months[3] + day = Time.days_in_month(month, year) if last_day_of_month?(subscription_at) + # In case of termination that is in the middle of the year, previous period anniversary date has to be returned + elsif should_find_previous_billing_date?(date, billing_months, day) + year = date.year + month = billing_months.reverse.find { |m| m < date.month } + day = Time.days_in_month(month, year) if last_day_of_month?(subscription_at) + else + year = date.year + month = date.month + end + + build_date(year, month, day) + end + + def should_find_billing_date_in_previous_year?(date, billing_months, day) + return true if date.month < billing_months[0] + + (date.month == billing_months[0]) && should_find_previous_billing_date?(date, billing_months, day) + end + + def should_find_previous_billing_date?(date, billing_months, day) + return false if last_day_of_month?(date) && last_day_of_month?(subscription_at) + + return true if date.day < day && terminated_pay_in_arrears? + return true if (date.day + 1) < day && last_day_of_month?(subscription_at) + return true if date.day < day && !last_day_of_month?(subscription_at) + return true if billing_months.exclude?(date.month) + + false + end + end + end +end diff --git a/app/services/subscriptions/dates/semiannual_service.rb b/app/services/subscriptions/dates/semiannual_service.rb new file mode 100644 index 0000000..8b377f4 --- /dev/null +++ b/app/services/subscriptions/dates/semiannual_service.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +module Subscriptions + module Dates + class SemiannualService < Subscriptions::DatesService + def first_month_in_semiannual_period? + return billing_date.month == 1 || billing_date.month == 7 if calendar? + + start_month = subscription_at.month + second_half_month = (start_month <= 6) ? start_month + 6 : start_month - 6 + [start_month, second_half_month].include?(billing_from_date.month) + end + + def first_month_in_first_semiannual_period? + return (billing_date.month == 1 || billing_date.month == 7) && billing_date.year == subscription_at.year if calendar? + + billing_from_date.month == subscription_at.month && billing_from_date.year == subscription_at.year + end + + private + + # When computing current usage (not billing), boundaries are always needed. + # if bill_charges_monthly=true, charge boundaries should be filled + # else if bill_FIXED_charges_monthly=true, charge boundaries should be filled only for the first month of the period + # For semiannual plans with not billing charges and fixed charges monthly, + # boundaries are always filled + def should_fill_charges_boundaries? + return true if current_usage + return true if plan.bill_charges_monthly? + + return first_month_in_semiannual_period? if plan.bill_fixed_charges_monthly? + + true + end + + # if bill_fixed_charges_monthly=true, fixed charge boundaries should be filled + # if bill_charges_monthly=true, fixed charge boundaries should be filled only for the first month of the period + # For semiannual plans with not billing charges and fixed charges mothly, + # boundaries are always filled + def should_fill_fixed_charges_boundaries? + return true if plan.bill_fixed_charges_monthly? + + return first_month_in_semiannual_period? if plan.bill_charges_monthly? + + true + end + + def monthly_service + @monthly_service ||= Subscriptions::Dates::MonthlyService.new(subscription, billing_date, current_usage) + end + + def billing_from_date + @billing_from_date ||= monthly_service.compute_from_date(billing_date) + end + + def compute_from_date(date = base_date) + if plan.pay_in_advance? || terminated_pay_in_arrears? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_half_year + end + + subscription.anniversary? ? previous_anniversary_day(date) : date.beginning_of_half_year + end + + def compute_charges_from_date + return monthly_service.compute_charges_from_date if plan.bill_charges_monthly + + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_half_year + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_half_year if calendar? + + previous_anniversary_day(base_date) + end + + def compute_charges_to_date + return monthly_service.compute_charges_to_date if plan.bill_charges_monthly + return compute_charges_from_date.end_of_half_year if calendar? + + compute_to_date(compute_charges_from_date) + end + + def compute_fixed_charges_from_date + return monthly_service.compute_fixed_charges_from_date if plan.bill_fixed_charges_monthly + + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_half_year + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_half_year if calendar? + + previous_anniversary_day(base_date) + end + + def compute_fixed_charges_to_date + return monthly_service.compute_fixed_charges_to_date if plan.bill_fixed_charges_monthly + return compute_fixed_charges_from_date.end_of_half_year if calendar? + + compute_to_date(compute_fixed_charges_from_date) + end + + def compute_duration(from_date:) + next_to_date = compute_to_date(from_date) + + (next_to_date.to_date + 1.day - from_date.to_date).to_i + end + + def compute_charges_duration(from_date:) + return monthly_service.compute_charges_duration(from_date:) if plan.bill_charges_monthly + + compute_duration(from_date:) + end + + def compute_fixed_charges_duration(from_date:) + return monthly_service.compute_fixed_charges_duration(from_date:) if plan.bill_fixed_charges_monthly + + compute_duration(from_date:) + end + + def compute_base_date + # NOTE: if subscription anniversary is on last day of month and current month days count + # is less than month anniversary day count, we need to use the last day of the previous month + if subscription.anniversary? && last_day_of_month?(billing_date) && (billing_date.day < subscription_at.day) + if (billing_date - 6.months).end_of_month.day >= subscription_at.day + return (billing_date - 6.months).end_of_month.change(day: subscription_at.day) + end + + return (billing_date - 6.months).end_of_month + end + + billing_date - 6.months + end + + def compute_to_date(from_date = compute_from_date) + if subscription.calendar? || (subscription_at.day == 1 && [1, 7].include?(subscription_at.month)) + return from_date.end_of_half_year + end + + year = from_date.year + month = from_date.month + 6 + day = subscription_at.day - 1 + + if month > 12 + month = (month % 12).zero? ? 12 : (month % 12) + year += 1 + end + + date = build_date(year, month, day) + + # NOTE: if subscription anniversary day is higher than the current last day of the month, + # subscription period, will end on the previous end of day + return date - 1.day if last_day_of_month?(date) && subscription_at.day > date.day + + date + end + + def compute_next_end_of_period + return billing_date.end_of_half_year if calendar? + + year = billing_date.year + month = billing_date.month + day = subscription_at.day + + # NOTE: we need the last day of the period, and not the first of the next one + result_date = build_date(year, month, day) - 1.day + return result_date if result_date >= billing_date + + month += 6 + if month > 12 + month = (month % 12).zero? ? 12 : (month % 12) + year += 1 + end + + build_date(year, month, day) - 1.day + end + + def compute_previous_beginning_of_period(date) + return date.beginning_of_half_year if calendar? + + previous_anniversary_day(date) + end + + def previous_anniversary_day(date) + year = nil + month = nil + + # NOTE: if subscription anniversary day is higher than the current last day of the month, + # anniversary day is on the current day + day = if subscription.anniversary? && last_day_of_month?(date) && (date.day < subscription_at.day) + date.day + else + subscription_at.day + end + + billing_months = [ + (subscription_at.month % 12).zero? ? 12 : (subscription_at.month % 12), + ((subscription_at.month + 6) % 12).zero? ? 12 : ((subscription_at.month + 6) % 12) + ].sort + + # This is the case when we terminate subscription on On February 10 but anniversary date is on + # 5 of March. In that case we need to fetch billing period in previous year + if should_find_billing_date_in_previous_year?(date, billing_months, day) + year = date.year - 1 + month = billing_months[1] + day = Time.days_in_month(month, year) if last_day_of_month?(subscription_at) + # In case of termination that is in the middle of the year, previous period anniversary date has to be returned + elsif should_find_previous_billing_date?(date, billing_months, day) + year = date.year + month = billing_months.rfind { |m| m < date.month } + day = Time.days_in_month(month, year) if last_day_of_month?(subscription_at) + else + year = date.year + month = date.month + end + + build_date(year, month, day) + end + + def should_find_billing_date_in_previous_year?(date, billing_months, day) + return true if date.month < billing_months[0] + + (date.month == billing_months[0]) && should_find_previous_billing_date?(date, billing_months, day) + end + + def should_find_previous_billing_date?(date, billing_months, day) + return false if last_day_of_month?(date) && last_day_of_month?(subscription_at) + + return true if date.day < day && terminated_pay_in_arrears? + return true if (date.day + 1) < day && last_day_of_month?(subscription_at) + return true if date.day < day && !last_day_of_month?(subscription_at) + return true if billing_months.exclude?(date.month) + + false + end + end + end +end diff --git a/app/services/subscriptions/dates/weekly_service.rb b/app/services/subscriptions/dates/weekly_service.rb new file mode 100644 index 0000000..4e5bac6 --- /dev/null +++ b/app/services/subscriptions/dates/weekly_service.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Subscriptions + module Dates + class WeeklyService < Subscriptions::DatesService + WEEK_DURATION = 7 + + private + + def compute_base_date + billing_date - 1.week + end + + def compute_from_date + if plan.pay_in_advance? || terminated_pay_in_arrears? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_week + end + + subscription.anniversary? ? previous_anniversary_day(base_date) : base_date.beginning_of_week + end + + def compute_to_date(from_date = compute_from_date) + return from_date.end_of_week if calendar? + + from_date + 6.days + end + + def compute_charges_from_date + # NOTE: when subscription is terminated, we must bill on the current period + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_week + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_week if calendar? + + previous_anniversary_day(base_date) + end + + def compute_charges_to_date + return compute_charges_from_date.end_of_week if calendar? + + compute_charges_from_date + 6.days + end + + def compute_next_end_of_period + return billing_date.end_of_week if calendar? + return billing_date if billing_date.wday == (subscription_at - 1.day).wday + + # NOTE: we need the last day of the period, and not the first of the next one + billing_date.next_occurring(subscription_day_name) - 1.day + end + + def compute_previous_beginning_of_period(date) + return date.beginning_of_week if calendar? + + previous_anniversary_day(date) + end + + def previous_anniversary_day(date) + return date if date.wday == subscription_at.wday + + date.prev_occurring(subscription_day_name) + end + + def subscription_day_name + @subscription_day_name ||= subscription_at.strftime("%A").downcase.to_sym + end + + def compute_duration(*) + WEEK_DURATION + end + + alias_method :compute_charges_duration, :compute_duration + alias_method :compute_fixed_charges_duration, :compute_charges_duration + alias_method :compute_fixed_charges_from_date, :compute_charges_from_date + alias_method :compute_fixed_charges_to_date, :compute_charges_to_date + end + end +end diff --git a/app/services/subscriptions/dates/yearly_service.rb b/app/services/subscriptions/dates/yearly_service.rb new file mode 100644 index 0000000..f49a4dc --- /dev/null +++ b/app/services/subscriptions/dates/yearly_service.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +module Subscriptions + module Dates + class YearlyService < Subscriptions::DatesService + def first_month_in_yearly_period? + return billing_date.month == 1 if calendar? + + monthly_service.compute_from_date(billing_date).month == subscription_at.month + end + + def first_month_in_first_yearly_period? + return billing_date.month == 1 && billing_date.year == subscription_at.year if calendar? + + billing_from_date = monthly_service.compute_from_date(billing_date) + billing_from_date.month == subscription_at.month && billing_from_date.year == subscription_at.year + end + + private + + # When computing current usage (not billing), boundaries are always needed. + # if bill_charges_monthly=true, charge boundaries should be filled + # if bill_FIXED_charges_monthly=true, charge boundaries should be filled only for the first month of the period + # For yearly plans with bill_charges_monthly=false, and bill_fixed_charges_monthly=false, + # boundaries are always filled + def should_fill_charges_boundaries? + return true if current_usage + return true if plan.bill_charges_monthly? + + return first_month_in_yearly_period? if plan.bill_fixed_charges_monthly? + + true + end + + # if bill_fixed_charges_monthly=true, fixed charge boundaries should be filled + # if bill_charges_monthly=true, fixed charge boundaries should be filled only for the first month of the period + # For yearly plans with bill_charges_monthly=false, and bill_fixed_charges_monthly=false, + # boundaries are always filled + def should_fill_fixed_charges_boundaries? + return true if plan.bill_fixed_charges_monthly? + + return first_month_in_yearly_period? if plan.bill_charges_monthly? + + true + end + + def compute_base_date + billing_date - 1.year + end + + def monthly_service + @monthly_service ||= Subscriptions::Dates::MonthlyService.new(subscription, billing_date, current_usage) + end + + def compute_from_date + if plan.pay_in_advance? || terminated_pay_in_arrears? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_year + end + + subscription.anniversary? ? previous_anniversary_day(base_date) : base_date.beginning_of_year + end + + def compute_to_date(from_date = compute_from_date) + return from_date.end_of_year if subscription.calendar? || subscription_at.yday == 1 + + year = from_date.year + 1 + month = from_date.month + day = subscription_at.day - 1 + + build_date(year, month, day) + end + + def compute_charges_from_date + return monthly_service.compute_charges_from_date if plan.bill_charges_monthly + + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_year + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_year if calendar? + + previous_anniversary_day(base_date) + end + + def compute_charges_to_date + return monthly_service.compute_charges_to_date if plan.bill_charges_monthly + return compute_charges_from_date.end_of_year if calendar? + + compute_to_date(compute_charges_from_date) + end + + def compute_fixed_charges_from_date + return monthly_service.compute_fixed_charges_from_date if plan.bill_fixed_charges_monthly + + if terminated? + return subscription.anniversary? ? previous_anniversary_day(billing_date) : billing_date.beginning_of_year + end + + return compute_from_date if plan.pay_in_arrears? + return base_date.beginning_of_year if calendar? + + previous_anniversary_day(base_date) + end + + def compute_fixed_charges_to_date + return monthly_service.compute_fixed_charges_to_date if plan.bill_fixed_charges_monthly + return compute_fixed_charges_from_date.end_of_year if calendar? + + compute_to_date(compute_fixed_charges_from_date) + end + + def compute_next_end_of_period + return billing_date.end_of_year if calendar? + + year = billing_date.year + month = subscription_at.month + day = subscription_at.day + + # NOTE: we need the last day of the period, and not the first of the next one + result_date = build_date(year, month, day) - 1.day + return result_date if result_date >= billing_date + + build_date(year + 1, month, day) - 1.day + end + + def compute_previous_beginning_of_period(date) + return date.beginning_of_year if calendar? + + previous_anniversary_day(date) + end + + def previous_anniversary_day(date) + year = period_started_in_last_year?(date) ? (date.year - 1) : date.year + month = subscription_at.month + day = subscription_at.day + + build_date(year, month, day) + end + + def compute_duration(from_date:) + return Time.days_in_year(from_date.year) if calendar? + + year = from_date.year + # NOTE: if after February we must check if next year is a leap year + year += 1 if from_date.month > 2 + + Time.days_in_year(year) + end + + def compute_charges_duration(from_date:) + return monthly_service.compute_charges_duration(from_date:) if plan.bill_charges_monthly + + compute_duration(from_date:) + end + + def compute_fixed_charges_duration(from_date:) + return monthly_service.compute_fixed_charges_duration(from_date:) if plan.bill_fixed_charges_monthly + + compute_duration(from_date:) + end + + def period_started_in_last_year?(date) + return true if date.month < subscription_at.month + return true if (date.month == subscription_at.month) && (date.day < subscription_at.day) + + false + end + end + end +end diff --git a/app/services/subscriptions/dates_service.rb b/app/services/subscriptions/dates_service.rb new file mode 100644 index 0000000..2b25b96 --- /dev/null +++ b/app/services/subscriptions/dates_service.rb @@ -0,0 +1,340 @@ +# frozen_string_literal: true + +module Subscriptions + class DatesService + def self.new_instance(subscription, billing_at, current_usage: false) + klass = case subscription.plan.interval&.to_sym + when :weekly + Subscriptions::Dates::WeeklyService + when :monthly + Subscriptions::Dates::MonthlyService + when :yearly + Subscriptions::Dates::YearlyService + when :quarterly + Subscriptions::Dates::QuarterlyService + when :semiannual + Subscriptions::Dates::SemiannualService + else + raise(NotImplementedError) + end + + klass.new(subscription, billing_at, current_usage) + end + + # Note: For context, the notion of `(from|to)_datetime` vs `charges_(from|to)_datetime` was introduced BEFORE + # pay in advance charges were introduced. Pay in Advance charges should mostly use `(from|to)_datetime` range. + # The boundaries might need a third range, like `in_advance_charges_(from|to)_datetime` for instance. + # Ideally, we should also store the dates on EACH FEE. + def self.charge_pay_in_advance_interval(timestamp, subscription) + date_service = new_instance( + subscription, + Time.zone.at(timestamp), + current_usage: true + ) + + { + charges_from_date: date_service.charges_from_datetime&.to_date, + charges_to_date: date_service.charges_to_datetime&.to_date + } + end + + def self.fixed_charge_pay_in_advance_interval(timestamp, subscription) + date_service = new_instance( + subscription, + Time.zone.at(timestamp), + current_usage: true + ) + + { + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime, + fixed_charges_duration: date_service.fixed_charges_duration_in_days + } + end + + def initialize(subscription, billing_at, current_usage) + @subscription = subscription + + # NOTE: Billing time should usually be the end of the billing period + 1 day + # When subscription is terminated, it is the termination day + @billing_at = billing_at + @current_usage = current_usage + end + + def from_datetime + return @from_datetime if @from_datetime + return unless subscription.started_at + + @from_datetime = customer_timezone_shift(compute_from_date) + + # NOTE: On first billing period, subscription might start after the computed start of period + # ie: if we bill on beginning of period, and user registered on the 15th, the invoice should + # start on the 15th (subscription date) and not on the 1st + if @from_datetime < subscription.started_at + @from_datetime = subscription.started_at.in_time_zone(customer.applicable_timezone).beginning_of_day.utc + end + + @from_datetime + end + + def to_datetime + return @to_datetime if @to_datetime + return unless subscription.started_at + + @to_datetime = customer_timezone_shift(compute_to_date, end_of_day: true) + terminated_at = subscription.terminated_at&.to_time&.round + + if subscription.terminated_at?(billing_at) && @to_datetime > terminated_at + @to_datetime = terminated_at + end + + @to_datetime = subscription.started_at if @to_datetime < subscription.started_at + @to_datetime + end + + def charges_from_datetime + return unless subscription.started_at + return unless should_fill_charges_boundaries? + + datetime = customer_timezone_shift(compute_charges_from_date) + + # NOTE: If customer applicable timezone changes during a billing period, there is a risk to double count events + # or to miss some. To prevent it, we have to ensure that invoice bounds does not overlap or that there is no + # hole between a charges_from_datetime and the charges_to_datetime of the previous period + if timezone_has_changed? && previous_charge_to_datetime + new_datetime = previous_charge_to_datetime + 1.second + + # NOTE: Ensure that the invoice is really the previous one + # 26 hours is the maximum time difference between two places in the world + datetime = new_datetime if ((datetime.in_time_zone - new_datetime.in_time_zone) / 1.hour).abs < 26 + end + + datetime = subscription.started_at if datetime < subscription.started_at + + datetime + end + + def charges_to_datetime + return unless subscription.started_at + return unless should_fill_charges_boundaries? + + datetime = customer_timezone_shift(compute_charges_to_date, end_of_day: true) + datetime = subscription.terminated_at if subscription.terminated? && subscription.terminated_at <= datetime + datetime = subscription.started_at if datetime < subscription.started_at + + datetime + end + + def fixed_charges_from_datetime + return unless subscription.started_at + return unless should_fill_fixed_charges_boundaries? + + datetime = customer_timezone_shift(compute_fixed_charges_from_date) + + # NOTE: If customer applicable timezone changes during a billing period, there is a risk to double count events + # or to miss some. To prevent it, we have to ensure that invoice bounds does not overlap or that there is no + # hole between a fixed_charges_from_datetime and the fixed_charges_to_datetime of the previous period + if timezone_has_changed? && previous_fixed_charge_to_datetime + new_datetime = previous_fixed_charge_to_datetime + 1.second + + # NOTE: Ensure that the invoice is really the previous one + # 26 hours is the maximum time difference between two places in the world + datetime = new_datetime if ((datetime.in_time_zone - new_datetime.in_time_zone) / 1.hour).abs < 26 + end + + datetime = subscription.started_at if datetime < subscription.started_at + + datetime + end + + def fixed_charges_to_datetime + return unless subscription.started_at + return unless should_fill_fixed_charges_boundaries? + + datetime = customer_timezone_shift(compute_fixed_charges_to_date, end_of_day: true) + datetime = subscription.terminated_at if subscription.terminated? && subscription.terminated_at <= datetime + datetime = subscription.started_at if datetime < subscription.started_at + + datetime + end + + def next_end_of_period + end_utc = compute_next_end_of_period + customer_timezone_shift(end_utc, end_of_day: true) + end + + def end_of_period + end_utc = compute_to_date + customer_timezone_shift(end_utc, end_of_day: true) + end + + # NOTE: Retrieve the beginning of the previous period based on the billing date + def previous_beginning_of_period(current_period: false) + date = base_date + date = billing_date if current_period + + beginning_utc = compute_previous_beginning_of_period(date) + customer_timezone_shift(beginning_utc) + end + + def single_day_price(optional_from_date: nil, plan_amount_cents: nil) + duration = compute_duration(from_date: optional_from_date || compute_from_date) + (plan_amount_cents || plan.amount_cents).fdiv(duration.to_i) + end + + def charges_duration_in_days + compute_charges_duration(from_date: compute_charges_from_date) + end + + def fixed_charges_duration_in_days + compute_fixed_charges_duration(from_date: compute_fixed_charges_from_date) + end + + private + + attr_accessor :subscription, :billing_at, :current_usage + + delegate :plan, :calendar?, :customer, to: :subscription + + # Determines if charges should be billed this cycle + # general approach is: yes, some exceptions are for yearly/semiannual plans with monthly charges/fixed_charges + def should_fill_charges_boundaries? + true + end + + # Determines if fixed charges should be billed this cycle + # general approach is: yes, some exceptions are for yearly/semiannual plans with monthly charges/fixed_charges + def should_fill_fixed_charges_boundaries? + true + end + + def billing_date + @billing_date ||= billing_at.in_time_zone(customer.applicable_timezone).to_date + end + + def base_date + @base_date ||= current_usage ? billing_date : compute_base_date + end + + def subscription_at + subscription.subscription_at.in_time_zone(customer.applicable_timezone) + end + + # NOTE: This method converts a DAY epress in the customer timezone into a proper UTC datetime + # Example: `2024-03-01` in `America/New_York` will be converted to `2024-03-01T05:00:00 UTC` + def customer_timezone_shift(date, end_of_day: false) + result = date.in_time_zone(customer.applicable_timezone) + result = result.end_of_day if end_of_day + result.utc + end + + def last_invoice_subscription + @last_invoice_subscription ||= subscription + .invoice_subscriptions + .order_by_charges_to_datetime + .first + end + + def timezone_has_changed? + return false if last_invoice_subscription.blank? + + last_invoice_subscription.invoice.timezone != customer.applicable_timezone + end + + def previous_charge_to_datetime + return if last_invoice_subscription.blank? + + last_invoice_subscription.charges_to_datetime + end + + def previous_fixed_charge_to_datetime + return if last_invoice_subscription.blank? + + last_invoice_subscription.fixed_charges_to_datetime + end + + def terminated_pay_in_arrears? + # NOTE: In case of termination or upgrade when we are terminating old plan (paying in arrear), + # we should take to the beginning of the billing period + subscription.terminated_at?(billing_at) && plan.pay_in_arrears? && !subscription.downgraded? + end + + def terminated? + subscription.terminated_at?(billing_at) && !subscription.next_subscription + end + + # NOTE: Handle leap years and anniversary date > 28 + def build_date(year, month, day) + if day.zero? + day = 31 + month -= 1 + + if month.zero? + month = 12 + year -= 1 + end + end + + days_count_in_month = Time.days_in_month(month, year) + day = days_count_in_month if days_count_in_month < day + + Date.new(year, month, day) + end + + def last_day_of_month?(date) + date.day == date.end_of_month.day + end + + def compute_base_date + raise(NotImplementedError) + end + + def compute_from_date + raise(NotImplementedError) + end + + def compute_to_date + raise(NotImplementedError) + end + + def compute_charges_from_date + raise(NotImplementedError) + end + + def compute_charges_to_date + raise(NotImplementedError) + end + + def compute_fixed_charges_from_date + raise(NotImplementedError) + end + + def compute_fixed_charges_to_date + raise(NotImplementedError) + end + + def compute_next_end_of_period + raise(NotImplementedError) + end + + def first_month_in_yearly_period? + false + end + + def first_month_in_first_yearly_period? + false + end + + def first_month_in_semiannual_period? + false + end + + def first_month_in_first_semiannual_period? + false + end + + def compute_duration(from_date:) + raise(NotImplementedError) + end + end +end diff --git a/app/services/subscriptions/emit_fixed_charge_events_service.rb b/app/services/subscriptions/emit_fixed_charge_events_service.rb new file mode 100644 index 0000000..c9ae367 --- /dev/null +++ b/app/services/subscriptions/emit_fixed_charge_events_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Subscriptions + class EmitFixedChargeEventsService < BaseService + Result = BaseResult + + def initialize(subscriptions:, timestamp: Time.current) + @subscriptions = subscriptions + @timestamp = timestamp + super + end + + def call + subscriptions.each do |subscription| + emitted_fixed_charge_ids = already_emitted_fixed_charge_ids(subscription) + + subscription.fixed_charges.find_each do |fixed_charge| + next if emitted_fixed_charge_ids.include?(fixed_charge.id) + + ::FixedChargeEvents::CreateService.call!( + subscription:, + fixed_charge:, + timestamp: + ) + end + end + + result + end + + private + + attr_reader :subscriptions, :timestamp + + def applicable_timezone + subscriptions.first.customer.applicable_timezone + end + + def already_emitted_fixed_charge_ids(subscription) + return Set.new unless timestamp + + FixedChargeEvent + .where(subscription:) + .where( + "DATE(fixed_charge_events.timestamp AT TIME ZONE ?) = DATE(? AT TIME ZONE ?)", + applicable_timezone, timestamp, applicable_timezone + ) + .pluck(:fixed_charge_id) + .to_set + end + end +end diff --git a/app/services/subscriptions/flag_refreshed_service.rb b/app/services/subscriptions/flag_refreshed_service.rb new file mode 100644 index 0000000..33681bc --- /dev/null +++ b/app/services/subscriptions/flag_refreshed_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Subscriptions + class FlagRefreshedService < BaseService + Result = BaseResult[:subscription_id] + + def initialize(subscription_id) + @subscription_id = subscription_id + super + end + + def call + customer = subscription.customer + customer.flag_wallets_for_refresh + date = Time.current.in_time_zone(customer.applicable_timezone).to_date + UsageMonitoring::TrackSubscriptionActivityService.call(subscription:, date:) + + result.subscription_id = subscription_id + result + end + + private + + attr_reader :subscription_id + + def subscription + @subscription ||= Subscription.find(subscription_id) + end + end +end diff --git a/app/services/subscriptions/free_trial_billing_service.rb b/app/services/subscriptions/free_trial_billing_service.rb new file mode 100644 index 0000000..9afcf41 --- /dev/null +++ b/app/services/subscriptions/free_trial_billing_service.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +module Subscriptions + class FreeTrialBillingService < BaseService + Result = BaseResult + + def initialize(timestamp: Time.current) + @timestamp = timestamp + + super + end + + def call + ending_trial_subscriptions.each do |subscription| + if !subscription.was_already_billed_today && + !already_billed_on_day_one?(subscription) + + if subscription.plan.pay_in_advance? + BillSubscriptionJob.perform_later( + [subscription], + timestamp, + invoicing_reason: :subscription_starting, + skip_charges: true + ) + end + end + + subscription.update!(trial_ended_at: subscription.trial_end_utc_date_from_query) + + SendWebhookJob.perform_later("subscription.trial_ended", subscription) + + if subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription:) + end + end + + result + end + + private + + attr_reader :timestamp + + # This is to avoid billing at the end of the trial if the customer was billed at the beginning + # It's only for users who started billing customer AND upgraded their lago with this feature + # during the customer trial period + # Unfortunately, this introduces an N+1 query + def already_billed_on_day_one?(subscription) + Fee.subscription.where( + invoice_id: subscription.invoice_subscriptions.select("invoices.id").joins(:invoice).where( + "invoices.invoice_type" => :subscription, + "invoices.status" => %i[draft finalized], + :timestamp => subscription.started_at.all_day + ) + ).any? + end + + def ending_trial_subscriptions + sql = <<-SQL + WITH + initial_started_at AS (#{initial_started_at}), + already_billed_today AS (#{already_billed_today}) + SELECT DISTINCT + plans.pay_in_advance AS plan_pay_in_advance, + already_billed_today.invoiced_count > 0 AS was_already_billed_today, + #{trial_end_date} as trial_end_utc_date_from_query, + subscriptions.* + FROM + subscriptions + INNER JOIN plans ON subscriptions.plan_id = plans.id + INNER JOIN initial_started_at ON initial_started_at.external_id = subscriptions.external_id + INNER JOIN customers ON subscriptions.customer_id = customers.id + INNER JOIN billing_entities ON customers.billing_entity_id = billing_entities.id + LEFT JOIN already_billed_today ON already_billed_today.subscription_id = subscriptions.id + WHERE + subscriptions.status = 1 + AND plans.trial_period > 0 + AND subscriptions.trial_ended_at IS NULL + AND #{trial_end_date + at_time_zone} <= '#{timestamp}'#{at_time_zone} + SQL + + Subscription.find_by_sql([sql, {timestamp:}]) + end + + def initial_started_at + <<-SQL + SELECT + external_id, + FIRST_VALUE(started_at) OVER (PARTITION BY external_id ORDER BY started_at) AS initial_started_at + FROM + subscriptions + SQL + end + + def trial_end_date + <<-SQL + (initial_started_at + plans.trial_period * INTERVAL '1 day') + SQL + end + + def already_billed_today + <<-SQL + SELECT + invoice_subscriptions.subscription_id, + COUNT(invoice_subscriptions.id) AS invoiced_count + FROM invoice_subscriptions + INNER JOIN subscriptions AS sub ON invoice_subscriptions.subscription_id = sub.id + INNER JOIN customers AS cus ON sub.customer_id = cus.id + INNER JOIN billing_entities ON cus.billing_entity_id = billing_entities.id + WHERE invoice_subscriptions.recurring = 't' + AND invoice_subscriptions.timestamp IS NOT NULL + AND DATE( + (invoice_subscriptions.timestamp)#{at_time_zone(customer: "cus", billing_entity: "billing_entities")} + ) = DATE('#{timestamp}'#{at_time_zone(customer: "cus", billing_entity: "billing_entities")}) + GROUP BY invoice_subscriptions.subscription_id + SQL + end + end +end diff --git a/app/services/subscriptions/organization_billing_service.rb b/app/services/subscriptions/organization_billing_service.rb new file mode 100644 index 0000000..2a78c7e --- /dev/null +++ b/app/services/subscriptions/organization_billing_service.rb @@ -0,0 +1,583 @@ +# frozen_string_literal: true + +module Subscriptions + class OrganizationBillingService < BaseService + Result = BaseResult + + def initialize(organization:, billing_at: Time.current) + @organization = organization + @today = billing_at + + super + end + + def call + billable_subscriptions.group_by(&:customer_id).each do |_customer_id, customer_subscriptions| + billing_subscriptions = [] + customer_subscriptions.each do |subscription| + if subscription.next_subscription&.pending? + # NOTE: In case of downgrade, subscription remain active until the end of the period, + # a next subscription is pending, the current one must be terminated + Subscriptions::TerminateJob.perform_later(subscription, today.to_i) + else + billing_subscriptions << subscription + end + end + + next if billing_subscriptions.empty? + + subscription_groups = group_by_payment_method(billing_subscriptions) + subscription_groups = group_by_currency(subscription_groups) + subscription_groups = group_by_billing_entity(subscription_groups) + + subscription_groups.each do |subscriptions| + BillSubscriptionJob.perform_later( + subscriptions, + today.to_i, + invoicing_reason: :subscription_periodic + ) + end + + BillNonInvoiceableFeesJob.perform_later(billing_subscriptions, today) + end + + result + end + + private + + attr_reader :today, :organization + + # NOTE: Retrieve list of subscriptions that should be billed today + def billable_subscriptions + sql = <<-SQL + WITH + billable_subscriptions AS ( + -- Calendar subscriptions + (#{weekly_calendar}) + UNION + (#{monthly_calendar}) + UNION + (#{quarterly_calendar}) + UNION + (#{semiannual_with_monthly_charges_calendar}) + UNION + (#{semiannual_with_monthly_fixed_charges_calendar}) + UNION + (#{semiannual_calendar}) + UNION + (#{yearly_with_monthly_charges_calendar}) + UNION + (#{yearly_with_monthly_fixed_charges_calendar}) + UNION + (#{yearly_calendar}) + UNION + -- Anniversary subscriptions + (#{weekly_anniversary}) + UNION + (#{monthly_anniversary}) + UNION + (#{quarterly_anniversary}) + UNION + (#{semiannual_with_monthly_charges_anniversary}) + UNION + (#{semiannual_with_monthly_fixed_charges_anniversary}) + UNION + (#{semiannual_anniversary}) + UNION + (#{yearly_with_monthly_charges_anniversary}) + UNION + (#{yearly_with_monthly_fixed_charges_anniversary}) + UNION + (#{yearly_anniversary}) + ), + -- Filter subscriptions already billed today (in customer's applicable timezone) + already_billed_today AS (#{already_billed_today}) + + SELECT DISTINCT(subscriptions.*) + FROM subscriptions + INNER JOIN billable_subscriptions ON billable_subscriptions.subscription_id = subscriptions.id + INNER JOIN customers ON customers.id = subscriptions.customer_id + INNER JOIN organizations ON organizations.id = customers.organization_id + INNER JOIN billing_entities ON billing_entities.id = customers.billing_entity_id + LEFT JOIN already_billed_today ON already_billed_today.subscription_id = subscriptions.id + WHERE + organizations.id = '#{organization.id}' + + -- Exclude subscriptions already billed today + AND already_billed_today.invoiced_count IS NULL + + -- Do not bill subscriptions that have started _after_ :today (excludes subscriptions starting today! and also importantly invoices that might have started after this service is run) + AND DATE(subscriptions.started_at#{at_time_zone}) < DATE(:today#{at_time_zone}) + -- Do not bill subscriptions that were not created yet + and DATE(subscriptions.created_at) <= Date(:today) + AND ( + subscriptions.ending_at IS NULL OR + DATE(subscriptions.ending_at#{at_time_zone}) != DATE(:today#{at_time_zone}) + ) + GROUP BY subscriptions.id + SQL + + Subscription.find_by_sql([sql, {today:}]) + end + + def base_subscription_scope(billing_time: nil, interval: nil, conditions: nil) + <<-SQL + SELECT subscriptions.id AS subscription_id + FROM subscriptions + INNER JOIN plans ON plans.id = subscriptions.plan_id + INNER JOIN customers ON customers.id = subscriptions.customer_id + INNER JOIN billing_entities ON billing_entities.id = customers.billing_entity_id + INNER JOIN organizations ON organizations.id = customers.organization_id + WHERE subscriptions.status = #{Subscription.statuses[:active]} + AND organizations.id = '#{organization.id}' + AND subscriptions.billing_time = #{Subscription.billing_times[billing_time]} + AND plans.interval = #{Plan.intervals[interval]} + AND #{conditions.join(" AND ")} + GROUP BY subscriptions.id + SQL + end + + # NOTE: For weekly interval we send invoices on Monday (ISODOW = 1) + def weekly_calendar + base_subscription_scope( + billing_time: :calendar, + interval: :weekly, + conditions: ["EXTRACT(ISODOW FROM (:today#{at_time_zone})) = 1"] + ) + end + + # NOTE: Billed monthly on 1st day of the month + def monthly_calendar + base_subscription_scope( + billing_time: :calendar, + interval: :monthly, + conditions: ["DATE_PART('day', (:today#{at_time_zone})) = 1"] + ) + end + + # NOTE: Billed quarterly on 1st day of the January, April, July and October + def quarterly_calendar + billing_month = <<-SQL + (DATE_PART('month', (:today#{at_time_zone})) IN (1, 4, 7, 10)) + SQL + + billing_day = <<-SQL + (DATE_PART('day', (:today#{at_time_zone})) = 1) + SQL + + base_subscription_scope( + billing_time: :calendar, + interval: :quarterly, + conditions: [billing_month, billing_day] + ) + end + + # NOTE: Bill charges monthly for yearly plans on 1st day of the month + def yearly_with_monthly_charges_calendar + base_subscription_scope( + billing_time: :calendar, + interval: :yearly, + conditions: [ + "DATE_PART('day', (:today#{at_time_zone})) = 1", + "plans.bill_charges_monthly = 't'" + ] + ) + end + + # NOTE: Bill fixed charges monthly for yearly plans on 1st day of the month + # Only when charges are NOT billed monthly (otherwise yearly_with_monthly_charges_calendar handles it) + def yearly_with_monthly_fixed_charges_calendar + base_subscription_scope( + billing_time: :calendar, + interval: :yearly, + conditions: [ + "DATE_PART('day', (:today#{at_time_zone})) = 1", + "plans.bill_fixed_charges_monthly = 't'", + "(plans.bill_charges_monthly = 'f' OR plans.bill_charges_monthly IS NULL)" + ] + ) + end + + # NOTE: Billed yearly on first day of the year + def yearly_calendar + base_subscription_scope( + billing_time: :calendar, + interval: :yearly, + conditions: [ + "DATE_PART('month', (:today#{at_time_zone})) = 1", + "DATE_PART('day', (:today#{at_time_zone})) = 1" + ] + ) + end + + # NOTE: Billed twice a year on 1st day of the January and July + def semiannual_calendar + billing_month = <<-SQL + (DATE_PART('month', (:today#{at_time_zone})) IN (1, 7)) + SQL + + billing_day = <<-SQL + (DATE_PART('day', (:today#{at_time_zone})) = 1) + SQL + + base_subscription_scope( + billing_time: :calendar, + interval: :semiannual, + conditions: [billing_month, billing_day] + ) + end + + # NOTE: Bill charges monthly for semiannual plans on 1st day of the month + def semiannual_with_monthly_charges_calendar + base_subscription_scope( + billing_time: :calendar, + interval: :semiannual, + conditions: [ + "DATE_PART('day', (:today#{at_time_zone})) = 1", + "plans.bill_charges_monthly = 't'" + ] + ) + end + + # NOTE: Bill fixed charges monthly for semiannual plans on 1st day of the month + # Only when charges are NOT billed monthly (otherwise semiannual_with_monthly_charges_calendar handles it) + def semiannual_with_monthly_fixed_charges_calendar + base_subscription_scope( + billing_time: :calendar, + interval: :semiannual, + conditions: [ + "DATE_PART('day', (:today#{at_time_zone})) = 1", + "plans.bill_fixed_charges_monthly = 't'", + "(plans.bill_charges_monthly = 'f' OR plans.bill_charges_monthly IS NULL)" + ] + ) + end + + def weekly_anniversary + base_subscription_scope( + billing_time: :anniversary, + interval: :weekly, + conditions: [ + "EXTRACT(ISODOW FROM (subscriptions.subscription_at#{at_time_zone})) = + EXTRACT(ISODOW FROM (:today#{at_time_zone}))" + ] + ) + end + + def monthly_anniversary + base_subscription_scope( + billing_time: :anniversary, + interval: :monthly, + conditions: [<<-SQL] + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + ) + end + + # NOTE: Billed quarterly on anniversary date + def quarterly_anniversary + billing_day = <<-SQL + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + billing_month = <<-SQL + ( + -- We need to avoid zero and instead of it use 12. E.g.: (3 + 9) % 12 = 0 -> 12 + CASE WHEN MOD(CAST(DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) AS INTEGER), 3) = 0 + THEN + (DATE_PART('month', :today#{at_time_zone}) IN (3, 6, 9, 12)) + ELSE ( + DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) + 3 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) + 6 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) + 9 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + ) + END + ) + SQL + + base_subscription_scope( + billing_time: :anniversary, + interval: :quarterly, + conditions: [billing_month, billing_day] + ) + end + + def yearly_anniversary + billing_month = <<-SQL + -- Ensure we are on the billing month + DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) = DATE_PART('month', :today#{at_time_zone}) + SQL + + billing_day = <<-SQL + -- Check if we are not in a leap year when today is february the 28th + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + CASE WHEN ( + DATE_PART('month', :today#{at_time_zone}) = 2 + AND DATE_PART('day', :today#{at_time_zone}) = 28 + AND DATE_PART('day', (#{end_of_month})) = 28 + ) + THEN + -- If not a leap year, we have to tale february the 29th into account + ARRAY[28, 29] + ELSE + -- Otherwise, we just need the current day + ARRAY[DATE_PART('day', :today#{at_time_zone})] + END + ) + SQL + + base_subscription_scope( + billing_time: :anniversary, + interval: :yearly, + conditions: [billing_month, billing_day] + ) + end + + def yearly_with_monthly_charges_anniversary + billing_day = <<-SQL + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + base_subscription_scope( + billing_time: :anniversary, + interval: :yearly, + conditions: [ + "plans.bill_charges_monthly = 't'", + billing_day + ] + ) + end + + # NOTE: Bill fixed charges monthly for yearly plans on anniversary day + # Only when charges are NOT billed monthly (otherwise yearly_with_monthly_charges_anniversary handles it) + def yearly_with_monthly_fixed_charges_anniversary + billing_day = <<-SQL + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + base_subscription_scope( + billing_time: :anniversary, + interval: :yearly, + conditions: [ + "plans.bill_fixed_charges_monthly = 't'", + "(plans.bill_charges_monthly = 'f' OR plans.bill_charges_monthly IS NULL)", + billing_day + ] + ) + end + + def semiannual_anniversary + billing_day = <<-SQL + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + billing_month = <<-SQL + ( + -- We need to avoid zero and instead of it use 12. E.g.: (3 + 9) % 12 = 0 -> 12 + CASE WHEN MOD(CAST(DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) AS INTEGER), 6) = 0 + THEN + (DATE_PART('month', :today#{at_time_zone}) IN (6, 12)) + ELSE ( + DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (subscriptions.subscription_at#{at_time_zone})) + 6 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + ) + END + ) + SQL + + base_subscription_scope( + billing_time: :anniversary, + interval: :semiannual, + conditions: [billing_month, billing_day] + ) + end + + def semiannual_with_monthly_charges_anniversary + billing_day = <<-SQL + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + base_subscription_scope( + billing_time: :anniversary, + interval: :semiannual, + conditions: [ + "plans.bill_charges_monthly = 't'", + billing_day + ] + ) + end + + # NOTE: Bill fixed charges monthly for semiannual plans on anniversary day + # Only when charges are NOT billed monthly (otherwise semiannual_with_monthly_charges_anniversary handles it) + def semiannual_with_monthly_fixed_charges_anniversary + billing_day = <<-SQL + DATE_PART('day', (subscriptions.subscription_at#{at_time_zone})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + base_subscription_scope( + billing_time: :anniversary, + interval: :semiannual, + conditions: [ + "plans.bill_fixed_charges_monthly = 't'", + "(plans.bill_charges_monthly = 'f' OR plans.bill_charges_monthly IS NULL)", + billing_day + ] + ) + end + + def end_of_month + <<-SQL + (DATE_TRUNC('month', :today#{at_time_zone}) + INTERVAL '1 month - 1 day')::date + SQL + end + + def already_billed_today + <<-SQL + SELECT + invoice_subscriptions.subscription_id, + COUNT(invoice_subscriptions.id) AS invoiced_count + FROM invoice_subscriptions + INNER JOIN subscriptions AS sub ON invoice_subscriptions.subscription_id = sub.id + INNER JOIN customers AS cus ON sub.customer_id = cus.id + INNER JOIN billing_entities ON cus.billing_entity_id = billing_entities.id + INNER JOIN organizations AS org ON cus.organization_id = org.id + WHERE invoice_subscriptions.recurring = 't' + AND org.id = '#{organization.id}' + AND invoice_subscriptions.timestamp IS NOT NULL + AND DATE( + (invoice_subscriptions.timestamp)#{at_time_zone(customer: "cus", billing_entity: "billing_entities")} + ) = DATE(:today#{at_time_zone(customer: "cus", billing_entity: "billing_entities")}) + GROUP BY invoice_subscriptions.subscription_id + SQL + end + + def group_by_currency(subscription_groups) + return subscription_groups unless organization.feature_flag_enabled?(:multi_currency) + + subscription_groups.flat_map do |subscriptions| + subscriptions.group_by { |sub| sub.plan.amount_currency }.values + end + end + + def group_by_billing_entity(subscription_groups) + return subscription_groups unless organization.feature_flag_enabled?(:multi_entity_billing) + + subscription_groups.flat_map do |subscriptions| + subscriptions.group_by { |sub| sub.billing_entity_id || sub.customer.billing_entity_id }.values + end + end + + # NOTE: Returns array of subscription groups + # - Groups subscriptions by their EFFECTIVE payment method (resolved, not raw) + # - If payment_method_id is nil, resolves to customer's default payment method + # - If all subscriptions resolve to the same payment method, returns single group + # + # Examples (assuming customer default is pm_1): + # - [nil, provider] + [nil, provider] → single group (both resolve to pm_1) + # - [nil, provider] + [nil, manual] → two groups (different type) + # - [pm_1, provider] + [nil, provider] → single group (both resolve to pm_1) + # - [pm_1, provider] + [pm_2, provider] → two groups (different resolved id) + def group_by_payment_method(subscriptions) + return [subscriptions] unless organization.feature_flag_enabled?(:multiple_payment_methods) + return [subscriptions] if subscriptions.size <= 1 + + customer = subscriptions.first.customer + default_payment_method = customer.default_payment_method + + resolved_keys = subscriptions.map { |s| resolve_payment_method_key(s, default_payment_method) }.uniq + + if resolved_keys.size == 1 + return [subscriptions] + end + + subscriptions.group_by { |s| resolve_payment_method_key(s, default_payment_method) }.values + end + + # NOTE: Returns the effective payment method key for grouping + # - If subscription has explicit payment_method_id, use it + # - If nil, inherit from customer's default payment method + def resolve_payment_method_key(subscription, default_payment_method) + if subscription.payment_method_id.present? + [subscription.payment_method_id, subscription.payment_method_type] + elsif subscription.payment_method_type == "manual" + [nil, "manual"] + elsif default_payment_method.present? + [default_payment_method.id, "provider"] + else + [nil, subscription.payment_method_type] + end + end + end +end diff --git a/app/services/subscriptions/plan_upgrade_service.rb b/app/services/subscriptions/plan_upgrade_service.rb new file mode 100644 index 0000000..5563d0c --- /dev/null +++ b/app/services/subscriptions/plan_upgrade_service.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +module Subscriptions + class PlanUpgradeService < BaseService + Result = BaseResult[:subscription] + + def initialize(current_subscription:, plan:, params:) + @current_subscription = current_subscription + @plan = plan + + @params = params + @name = params[:name].to_s.strip + super + end + + def call + if current_subscription.starting_in_the_future? + update_pending_subscription + + result.subscription = current_subscription + return result + end + + new_subscription = new_subscription_with_overrides + + ActiveRecord::Base.transaction do + cancel_pending_subscription if pending_subscription? + + # Group subscriptions for billing + billable_subscriptions = billable_subscriptions(new_subscription) + + # Terminate current subscription as part of the upgrade process + Subscriptions::TerminateService.call( + subscription: current_subscription, + upgrade: true + ) + + new_subscription.mark_as_active! + + EmitFixedChargeEventsService.call!( + subscriptions: [new_subscription], + timestamp: new_subscription.started_at + 1.second + ) + + after_commit do + SendWebhookJob.perform_later("subscription.started", new_subscription) + Utils::ActivityLog.produce(new_subscription, "subscription.started") + end + + bill_subscriptions(billable_subscriptions) if billable_subscriptions.any? + end + + result.subscription = new_subscription + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + result.fail_with_error!(e) + end + + private + + attr_reader :current_subscription, :plan, :params, :name + + def new_subscription_with_overrides + new_subscription = Subscription.new( + organization_id: current_subscription.customer.organization_id, + customer: current_subscription.customer, + plan: params.key?(:plan_overrides) ? override_plan : plan, + name:, + external_id: current_subscription.external_id, + previous_subscription_id: current_subscription.id, + subscription_at: current_subscription.subscription_at, + billing_time: current_subscription.billing_time, + ending_at: params.key?(:ending_at) ? params[:ending_at] : current_subscription.ending_at + ) + + if params.key?(:payment_method) + new_subscription.payment_method_type = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) + new_subscription.payment_method_id = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) + end + + new_subscription + end + + def update_pending_subscription + current_subscription.plan = plan + current_subscription.name = name if name.present? + current_subscription.save! + + if current_subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription: current_subscription) + end + end + + def override_plan + Plans::OverrideService.call(plan:, params: params[:plan_overrides].to_h.with_indifferent_access).plan + end + + def cancel_pending_subscription + current_subscription.next_subscription.mark_as_canceled! + end + + def pending_subscription? + return false unless current_subscription.next_subscription + + current_subscription.next_subscription.pending? + end + + def billable_subscriptions(new_subscription) + billable_subscriptions = if current_subscription.starting_in_the_future? + [] + elsif current_subscription.pending? + [] + elsif !current_subscription.terminated? + [current_subscription] + end.to_a + + has_billable_fixed_charges = new_subscription.fixed_charges.pay_in_advance.any? + plan_billable = new_subscription.plan.pay_in_advance? && !new_subscription.in_trial_period? + + billable_subscriptions << new_subscription if has_billable_fixed_charges || plan_billable + + billable_subscriptions + end + + def bill_subscriptions(billable_subscriptions) + after_commit do + billing_at = Time.current + 1.second + BillSubscriptionJob.perform_later(billable_subscriptions, billing_at.to_i, invoicing_reason: :upgrading) + BillNonInvoiceableFeesJob.perform_later(billable_subscriptions, billing_at) + end + end + end +end diff --git a/app/services/subscriptions/progressive_billed_amount.rb b/app/services/subscriptions/progressive_billed_amount.rb new file mode 100644 index 0000000..dada442 --- /dev/null +++ b/app/services/subscriptions/progressive_billed_amount.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Subscriptions + class ProgressiveBilledAmount < BaseService + Result = BaseResult[ + :progressive_billed_amount, + :progressive_billing_invoice, + :to_credit_amount, + :total_billed_amount_cents, + :invoice_subscriptions + ] + + def initialize(subscription:, timestamp: Time.current, include_generating_invoices: false) + @subscription = subscription + @timestamp = timestamp + @include_generating_invoices = include_generating_invoices + + super + end + + def call + result.progressive_billed_amount = 0 + result.total_billed_amount_cents = 0 + result.progressive_billing_invoice = nil + result.to_credit_amount = 0 + + # Note: we might be refreshing balance while applying credits on generating invoice. + # in this case this invoice should be included + invoices_scope = if include_generating_invoices + Invoice.finalized.or(Invoice.failed).or(Invoice.generating) + else + Invoice.finalized.or(Invoice.failed) + end + invoice_subscriptions = InvoiceSubscription + .where("charges_to_datetime > ?", timestamp) + .where("charges_from_datetime <= ?", timestamp) + .joins(:invoice) + .merge(Invoice.progressive_billing) + .merge(invoices_scope) + .where(subscription: subscription) + .order("invoices.issuing_date" => :desc, "invoices.created_at" => :desc) + + result.invoice_subscriptions = invoice_subscriptions + return result if invoice_subscriptions.blank? + + # Note: included in scope generating invoice won't have values, so we have to iterate through the fees, + # but progressively billed fees include previously progressively paid fees, so we need to get + # sub_total_excluding_taxes_amount_cents and taxes_amount_cents from fees to get the exact billed amount + total_billed_amount_cents = invoice_subscriptions.sum do |invoice_subscription| + invoice_subscription.invoice.fees.sum(&:taxes_amount_cents) + + invoice_subscription.invoice.fees.sum(&:sub_total_excluding_taxes_amount_cents) + end + result.total_billed_amount_cents = total_billed_amount_cents + + invoice_subscription = invoice_subscriptions.first + invoice = invoice_subscription.invoice + result.progressive_billing_invoice = invoice + result.progressive_billed_amount = result.progressive_billing_invoice.fees_amount_cents + + result.to_credit_amount = invoice.fees_amount_cents + result.to_credit_amount -= invoice.coupons_amount_cents + result.to_credit_amount -= invoice.progressive_billing_credits.sum(:amount_cents) + result.to_credit_amount -= invoice.credit_notes.where(credit_status: ["available", "consumed"]).sum(:credit_amount_cents) + + # if for some reason this goes below zero, it should be zero. + result.to_credit_amount = 0 if result.to_credit_amount.negative? + + result + end + + private + + attr_reader :subscription, :timestamp, :include_generating_invoices + end +end diff --git a/app/services/subscriptions/terminate_service.rb b/app/services/subscriptions/terminate_service.rb new file mode 100644 index 0000000..f474e5d --- /dev/null +++ b/app/services/subscriptions/terminate_service.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +module Subscriptions + class TerminateService < BaseService + Result = BaseResult[:subscription] + + def initialize(subscription:, async: true, upgrade: false, on_termination_credit_note: subscription&.on_termination_credit_note, on_termination_invoice: subscription&.on_termination_invoice) + @subscription = subscription + @async = async + @upgrade = upgrade + @on_termination_credit_note = on_termination_credit_note.blank? ? :credit : on_termination_credit_note.to_sym + @on_termination_invoice = on_termination_invoice.blank? ? :generate : on_termination_invoice.to_sym + + super + end + + def call + return result.not_found_failure!(resource: "subscription") if subscription.blank? + return result.not_allowed_failure!(code: "subscription_incomplete") if subscription.incomplete? + + ActiveRecord::Base.transaction do + if subscription.pending? + previous = subscription.previous_subscription + subscription.mark_as_canceled! + + if previous + SendWebhookJob.perform_after_commit("subscription.updated", previous) + Utils::ActivityLog.produce_after_commit(previous, "subscription.updated") + end + elsif !subscription.terminated? + subscription.mark_as_terminated! + update_on_termination_actions! + + if subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_after_commit(subscription:) + end + + if generate_credit_note_for_unconsumed_subscription? + # NOTE: As subscription was payed in advance and terminated before the end of the period, + # we have to create a credit note for the days that were not consumed. + # Depending on the termination behaviour, we will optionally refund the portion of the unconsumed + # subscription that was already paid. + + CreditNotes::CreateFromTermination.call!( + subscription:, + reason: "order_cancellation", + upgrade: upgrade, + on_termination: on_termination_credit_note + ) + end + + # NOTE: We should bill subscription and generate invoice for all cases except for the upgrade + # For upgrade we will create only one invoice for termination charges and for in advance charges + # It is handled in subscriptions/create_service.rb + bill_subscription unless upgrade + end + + cancel_next_subscription + end + + SendWebhookJob.perform_after_commit("subscription.terminated", subscription) + Utils::ActivityLog.produce_after_commit(subscription, "subscription.terminated") + + result.subscription = subscription + result + rescue BaseService::FailedResult => e + e.result + end + + # NOTE: Called to terminate a downgraded subscription + def terminate_and_start_next(timestamp:) + next_subscription = subscription.next_subscription + return result unless next_subscription + return result unless next_subscription.pending? + + rotation_date = Time.zone.at(timestamp) + + ActiveRecord::Base.transaction do + subscription.mark_as_terminated!(rotation_date) + + if subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(subscription:) + end + + next_subscription.mark_as_active!(rotation_date) + + EmitFixedChargeEventsService.call!( + subscriptions: [next_subscription], + timestamp: next_subscription.started_at + 1.second + ) + + if next_subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_later(next_subscription) + end + end + + # NOTE: Create an invoice for the terminated subscription + # if it has not been billed yet + # or only for the charges if subscription was billed in advance + # Also, add new pay in advance plan inside if applicable + billable_subscriptions = if next_subscription.plan.pay_in_advance? || next_subscription.fixed_charges.pay_in_advance.any? + [subscription, next_subscription] + else + [subscription] + end + BillSubscriptionJob.perform_later(billable_subscriptions, timestamp, invoicing_reason: :upgrading) + BillNonInvoiceableFeesJob.perform_later([subscription], rotation_date) # Ignore next subscription since there can't be events + + SendWebhookJob.perform_later("subscription.terminated", subscription) + Utils::ActivityLog.produce(subscription, "subscription.terminated") + SendWebhookJob.perform_later("subscription.started", next_subscription) + Utils::ActivityLog.produce(next_subscription, "subscription.started") + + result.subscription = next_subscription + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :subscription, :async, :upgrade, :on_termination_credit_note, :on_termination_invoice + + def cancel_next_subscription + next_subscription = subscription.next_subscription + return if next_subscription.nil? + + next_subscription.mark_as_canceled! + + if next_subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_after_commit(subscription: next_subscription) + end + end + + def bill_subscription + if bill_in_arrears_fees? + if async + BillSubscriptionJob.perform_after_commit( + [subscription], + subscription.terminated_at, + invoicing_reason: :subscription_terminating + ) + else + BillSubscriptionJob.perform_now( + [subscription], + subscription.terminated_at, + invoicing_reason: :subscription_terminating + ) + end + end + + # We always bill pay-in-advance non-invoiceable charges unless it's an upgrade. + if async + BillNonInvoiceableFeesJob.perform_after_commit([subscription], subscription.terminated_at) + else + BillNonInvoiceableFeesJob.perform_now([subscription], subscription.terminated_at) + end + end + + # NOTE: If subscription is terminated automatically by setting ending_at, there is a chance that this service will + # be called before invoice for the period has been generated. + # In that case we do not want to issue a credit note. + def pay_in_advance_invoice_issued? + # Subscription duplicate is used in this logic so that special cases used for terminated subscription + # can be avoided in boundaries calculation + duplicate = subscription.dup.tap { |s| s.status = :active } + beginning_of_period = beginning_of_period(duplicate) + + # If this is first period, pay in advance invoice is issued with creating subscription + return true if beginning_of_period < duplicate.started_at + + dates_service = Subscriptions::DatesService.new_instance( + duplicate, + beginning_of_period, + current_usage: false + ) + + boundaries = BillingPeriodBoundaries.new( + from_datetime: dates_service.from_datetime, + to_datetime: dates_service.to_datetime, + charges_from_datetime: dates_service.charges_from_datetime, + charges_to_datetime: dates_service.charges_to_datetime, + charges_duration: dates_service.charges_duration_in_days, + timestamp: beginning_of_period + ) + + InvoiceSubscription.matching?(subscription, boundaries, recurring: false) + end + + def beginning_of_period(subscription_dup) + dates_service = Subscriptions::DatesService.new_instance( + subscription_dup, + subscription.terminated_at, + current_usage: false + ) + + dates_service.previous_beginning_of_period(current_period: true).to_datetime + end + + def generate_credit_note_for_unconsumed_subscription? + pay_in_advance? && + pay_in_advance_invoice_issued? && + on_termination_credit_note.in?(%i[credit refund offset]) + end + + def pay_in_advance? + subscription.plan.pay_in_advance? + end + + def pay_in_arrears? + !pay_in_advance? + end + + def bill_in_arrears_fees? + on_termination_invoice == :generate + end + + def update_on_termination_actions! + params = {} + params[:on_termination_credit_note] = on_termination_credit_note if pay_in_advance? && subscription.on_termination_credit_note != on_termination_credit_note + params[:on_termination_invoice] = on_termination_invoice if subscription.on_termination_invoice != on_termination_invoice + return if params.empty? + + Subscriptions::UpdateService.call!(subscription:, params:) + end + end +end diff --git a/app/services/subscriptions/terminated_dates_service.rb b/app/services/subscriptions/terminated_dates_service.rb new file mode 100644 index 0000000..9165d95 --- /dev/null +++ b/app/services/subscriptions/terminated_dates_service.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Subscriptions + class TerminatedDatesService + def initialize(subscription:, invoice:, date_service:, match_invoice_subscription: true) + @subscription = subscription + @timestamp = invoice.invoice_subscriptions.first&.timestamp + @date_service = date_service + @match_invoice_subscription = match_invoice_subscription + end + + def call + return date_service if !subscription.terminated? || subscription.next_subscription.present? + + # First we need to ensure that termination date is not started_at date. In that case boundaries are correct + # and we should bill only one day. If this is not the case we should proceed. + return date_service if (timestamp - 1.day) < subscription.started_at + + # Date service has various checks for terminated subscriptions. We want to avoid it and fetch boundaries + # for current usage (current period) but when subscription was active (one day ago) + duplicate = subscription.dup.tap { |s| s.status = :active } + new_dates_service = Subscriptions::DatesService.new_instance(duplicate, timestamp - 1.day, current_usage: true) + + if (new_date_service_charges_to_datetime = new_dates_service.charges_to_datetime) + return date_service if timestamp < new_date_service_charges_to_datetime + return date_service if (timestamp - new_date_service_charges_to_datetime) >= 1.day + end + + if (new_date_service_fixed_charges_to_datetime = new_dates_service.fixed_charges_to_datetime) + return date_service if timestamp < new_date_service_fixed_charges_to_datetime + return date_service if (timestamp - new_date_service_fixed_charges_to_datetime) >= 1.day + end + + # We should calculate boundaries as if subscription was not terminated + new_dates_service = Subscriptions::DatesService.new_instance(duplicate, timestamp, current_usage: false) + + return new_dates_service unless match_invoice_subscription + + matching_invoice_subscription?(subscription, new_dates_service) ? date_service : new_dates_service + end + + private + + attr_reader :subscription, :timestamp, :date_service, :match_invoice_subscription + + def matching_invoice_subscription?(subscription, date_service) + base_query = InvoiceSubscription + .where(subscription_id: subscription.id) + .recurring + .where(from_datetime: date_service.from_datetime) + .where(to_datetime: date_service.to_datetime) + + if subscription.plan.charges_billed_in_monthly_split_intervals? + base_query = base_query + .where(charges_from_datetime: date_service.charges_from_datetime) + .where(charges_to_datetime: date_service.charges_to_datetime) + end + + if subscription.plan.fixed_charges_billed_in_monthly_split_intervals? + base_query = base_query + .where(fixed_charges_from_datetime: date_service.fixed_charges_from_datetime) + .where(fixed_charges_to_datetime: date_service.fixed_charges_to_datetime) + end + + base_query.exists? + end + end +end diff --git a/app/services/subscriptions/update_or_override_charge_service.rb b/app/services/subscriptions/update_or_override_charge_service.rb new file mode 100644 index 0000000..31e7ddf --- /dev/null +++ b/app/services/subscriptions/update_or_override_charge_service.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Subscriptions + class UpdateOrOverrideChargeService < BaseService + include Concerns::PlanOverrideConcern + include Concerns::ChargeOverrideConcern + + Result = BaseResult[:charge] + + def initialize(subscription:, charge:, params:) + @subscription = subscription + @charge = charge + @params = params + + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_found_failure!(resource: "charge") unless charge + + ActiveRecord::Base.transaction do + target_plan = ensure_plan_override + target_charge = find_or_update_charge_override(target_plan) + + result.charge = target_charge + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :charge, :params + + def find_or_update_charge_override(target_plan) + parent_charge = find_parent_charge + existing_override = target_plan.charges.find_by(parent_id: parent_charge.id) + + if existing_override + update_charge_override(existing_override) + else + create_charge_override(parent_charge, target_plan) + end + end + + def create_charge_override(parent_charge, target_plan) + override_result = Charges::OverrideService.call!( + charge: parent_charge, + params: params.merge(plan_id: target_plan.id) + ) + override_result.charge + end + + def update_charge_override(existing_charge) + existing_charge.properties = params[:properties] if params.key?(:properties) + existing_charge.min_amount_cents = params[:min_amount_cents] if params.key?(:min_amount_cents) + existing_charge.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + existing_charge.save! + + if params.key?(:filters) + filters_result = ::ChargeFilters::CreateOrUpdateBatchService.call( + charge: existing_charge, + filters_params: params[:filters] + ) + filters_result.raise_if_error! + end + + if params.key?(:applied_pricing_unit) && existing_charge.applied_pricing_unit + existing_charge.applied_pricing_unit.update!( + conversion_rate: params[:applied_pricing_unit][:conversion_rate] + ) + end + + if params.key?(:tax_codes) + taxes_result = Charges::ApplyTaxesService.call(charge: existing_charge, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + existing_charge.reload + end + end +end diff --git a/app/services/subscriptions/update_or_override_fixed_charge_service.rb b/app/services/subscriptions/update_or_override_fixed_charge_service.rb new file mode 100644 index 0000000..68de5ed --- /dev/null +++ b/app/services/subscriptions/update_or_override_fixed_charge_service.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Subscriptions + class UpdateOrOverrideFixedChargeService < BaseService + include Concerns::PlanOverrideConcern + + Result = BaseResult[:fixed_charge] + + def initialize(subscription:, fixed_charge:, params:) + @subscription = subscription + @fixed_charge = fixed_charge + @params = params + + super + end + + def call + return result.forbidden_failure! unless License.premium? + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_found_failure!(resource: "fixed_charge") unless fixed_charge + + ActiveRecord::Base.transaction do + target_plan = ensure_plan_override + target_fixed_charge = find_or_create_fixed_charge_override(target_plan) + + result.fixed_charge = target_fixed_charge + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :fixed_charge, :params + + def find_or_create_fixed_charge_override(target_plan) + parent_fixed_charge = find_parent_fixed_charge + existing_override = target_plan.fixed_charges.find_by(parent_id: parent_fixed_charge.id) + + if existing_override + update_fixed_charge_override(existing_override) + else + create_fixed_charge_override(parent_fixed_charge, target_plan) + end + end + + def find_parent_fixed_charge + if fixed_charge.parent_id + fixed_charge.parent + else + fixed_charge + end + end + + def create_fixed_charge_override(parent_fixed_charge, target_plan) + override_result = FixedCharges::OverrideService.call!( + fixed_charge: parent_fixed_charge, + params: params.merge(plan_id: target_plan.id), + subscription: + ) + override_result.fixed_charge + end + + def update_fixed_charge_override(existing_fixed_charge) + if params.key?(:properties) + existing_fixed_charge.properties = ChargeModels::FilterPropertiesService.call( + chargeable: existing_fixed_charge, + properties: params[:properties].presence + ).properties + end + existing_fixed_charge.invoice_display_name = params[:invoice_display_name] if params.key?(:invoice_display_name) + existing_fixed_charge.units = params[:units] if params.key?(:units) + existing_fixed_charge.save! + + FixedCharges::EmitEventsService.call!( + fixed_charge: existing_fixed_charge, + subscription:, + apply_units_immediately: !!params[:apply_units_immediately] + ) + + if params.key?(:tax_codes) + taxes_result = FixedCharges::ApplyTaxesService.call(fixed_charge: existing_fixed_charge, tax_codes: params[:tax_codes]) + taxes_result.raise_if_error! + end + + existing_fixed_charge.reload + end + end +end diff --git a/app/services/subscriptions/update_service.rb b/app/services/subscriptions/update_service.rb new file mode 100644 index 0000000..e8f6521 --- /dev/null +++ b/app/services/subscriptions/update_service.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +module Subscriptions + class UpdateService < BaseService + Result = BaseResult[:subscription, :payment_method] + + def initialize(subscription:, params:) + @subscription = subscription + @params = params + super + end + + activity_loggable( + action: "subscription.updated", + record: -> { subscription }, + condition: -> { !subscription&.starting_in_the_future? }, + after_commit: true + ) + + def call + return result.not_found_failure!(resource: "subscription") unless subscription + return result.not_allowed_failure!(code: "subscription_incomplete") if subscription.incomplete? + + unless valid?( + customer: subscription.customer, + plan: subscription.plan, + subscription_at: params.key?(:subscription_at) ? params[:subscription_at] : subscription.subscription_at, + ending_at: params[:ending_at], + on_termination_credit_note: params[:on_termination_credit_note], + on_termination_invoice: params[:on_termination_invoice], + payment_method: params[:payment_method], + activation_rules: params[:activation_rules], + subscription_type: "update", + subscription: + ) + return result + end + + # TODO: Remove check we stop supporting `plan_overrides.usage_thresholds` + if params[:usage_thresholds].present? && params.dig(:plan_overrides, :usage_thresholds).present? + return result.validation_failure!(errors: { + "plan_overrides.usage_thresholds": ["incompatible_params"], + usage_thresholds: ["incompatible_params"] + }) + end + + return result.forbidden_failure! if !License.premium? && params.key?(:plan_overrides) + + ActiveRecord::Base.transaction do + subscription.name = params[:name] if params.key?(:name) + subscription.ending_at = params[:ending_at] if params.key?(:ending_at) + subscription.progressive_billing_disabled = params[:progressive_billing_disabled] if params.key?(:progressive_billing_disabled) + + if pay_in_advance? && params.key?(:on_termination_credit_note) + subscription.on_termination_credit_note = params[:on_termination_credit_note] + end + + if params.key?(:on_termination_invoice) + subscription.on_termination_invoice = params[:on_termination_invoice] + end + + if params.key?(:payment_method) + subscription.payment_method_type = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) + subscription.payment_method_id = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) + end + + subscription.plan = handle_plan_override.plan if params.key?(:plan_overrides) + + if params.key?(:usage_thresholds) + UpdateUsageThresholdsService.call!(subscription:, usage_thresholds_params: params[:usage_thresholds], partial: false) + end + + if params.key?(:activation_rules) && !subscription_at_changing_to_past? + Subscriptions::ActivationRules::ApplyService.call!( + subscription:, + activation_rules: params[:activation_rules] + ) + end + + if subscription.starting_in_the_future? && params.key?(:subscription_at) + subscription.subscription_at = params[:subscription_at] + + process_subscription_at_change(subscription) + else + subscription.save! + + if subscription.active? && subscription.fixed_charges.pay_in_advance.any? && subscription.plan_id_previously_changed? + Invoices::CreatePayInAdvanceFixedChargesJob.perform_after_commit(subscription, Time.current.to_i) + end + + SendWebhookJob.perform_after_commit("subscription.updated", subscription) + + if subscription.should_sync_hubspot_subscription? + Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob.perform_after_commit(subscription:) + end + end + + InvoiceCustomSections::AttachToResourceService.call(resource: subscription, params:) + end + + result.subscription = subscription + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :params + + def pay_in_advance? + subscription.plan.pay_in_advance? + end + + def subscription_at_changing_to_past? + return false unless subscription.starting_in_the_future? + return false unless params.key?(:subscription_at) + + DateTime.parse(params[:subscription_at]).to_date < Date.current + end + + def process_subscription_at_change(subscription) + if subscription.subscription_at.future? || (subscription.subscription_at.today? && subscription.activation_rules.any?) + subscription.pending! + return + end + + if subscription.activation_rules.any? + Subscriptions::ActivationRules::ApplyService.call!( + subscription:, + activation_rules: [] + ) + end + + subscription.mark_as_active!(subscription.subscription_at) + + EmitFixedChargeEventsService.call!( + subscriptions: [subscription], + timestamp: subscription.started_at + 1.second + ) + + if subscription.subscription_at.today? + if subscription.plan.pay_in_advance? + BillSubscriptionJob.perform_after_commit([subscription], Time.current.to_i, invoicing_reason: :subscription_starting) + elsif subscription.fixed_charges.pay_in_advance.any? + Invoices::CreatePayInAdvanceFixedChargesJob.perform_after_commit(subscription, subscription.started_at + 1.second) + end + end + end + + def handle_plan_override + current_plan = subscription.plan + + if current_plan.parent_id + Plans::UpdateService.call!( + plan: current_plan, + params: params[:plan_overrides].to_h.with_indifferent_access + ) + else + Plans::OverrideService.call!( + plan: current_plan, + params: params[:plan_overrides].to_h.with_indifferent_access, + subscription: + ) + end + end + + def valid?(args) + result.payment_method = payment_method + + Subscriptions::ValidateService.new(result, **args).valid? + end + + def payment_method + return @payment_method if defined? @payment_method + return nil if params[:payment_method].blank? || params[:payment_method][:payment_method_id].blank? + + @payment_method = PaymentMethod.find_by(id: params[:payment_method][:payment_method_id], organization_id: subscription.organization_id) + end + end +end diff --git a/app/services/subscriptions/update_usage_thresholds_service.rb b/app/services/subscriptions/update_usage_thresholds_service.rb new file mode 100644 index 0000000..6b6fb10 --- /dev/null +++ b/app/services/subscriptions/update_usage_thresholds_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Subscriptions + class UpdateUsageThresholdsService < BaseService + Result = BaseResult + + def initialize(subscription:, usage_thresholds_params:, partial:) + @subscription = subscription + @usage_thresholds_params = usage_thresholds_params + @partial = partial + super + end + + def call + return result unless subscription.organization.progressive_billing_enabled? + + ActiveRecord::Base.transaction do + UsageThresholds::UpdateService.call!(model: subscription, usage_thresholds_params:, partial:) + # NOTE: Once we attach UT to the subscription, we should delete all UT attached to the plan override + plan.usage_thresholds.update_all(deleted_at: Time.current) if plan.is_child? # rubocop:disable Rails/SkipsModelValidations + end + + subscription.usage_thresholds.reload + subscription&.lifetime_usage&.update recalculate_invoiced_usage: true if subscription.usage_thresholds.size > 0 + + result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :subscription, :usage_thresholds_params, :partial + delegate :plan, to: :subscription + end +end diff --git a/app/services/subscriptions/validate_service.rb b/app/services/subscriptions/validate_service.rb new file mode 100644 index 0000000..b856fed --- /dev/null +++ b/app/services/subscriptions/validate_service.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +module Subscriptions + class ValidateService < BaseValidator + def valid? + return false unless valid_customer? + return false unless valid_plan? + + valid_subscription_at? + valid_ending_at? + valid_on_termination_credit_note? + valid_on_termination_invoice? + valid_payment_method? + valid_activation_rules? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_customer? + return true if args[:customer] + + result.not_found_failure!(resource: "customer") + + false + end + + def valid_plan? + return true if args[:plan] + + result.not_found_failure!(resource: "plan") + + false + end + + def valid_subscription_at? + return true if Utils::Datetime.valid_format?(args[:subscription_at]) + + add_error(field: :subscription_at, error_code: "invalid_date") + + false + end + + def valid_ending_at? + return true if args[:ending_at].blank? + + if Utils::Datetime.valid_format?(args[:ending_at]) && + Utils::Datetime.valid_format?(args[:subscription_at]) && + ending_at.to_date > Time.current.to_date && + ending_at.to_date > subscription_at.to_date + return true + end + + add_error(field: :ending_at, error_code: "invalid_date") + false + end + + def valid_on_termination_credit_note? + return true if args[:on_termination_credit_note].blank? + + return true if Subscription::ON_TERMINATION_CREDIT_NOTES.include?(args[:on_termination_credit_note].to_sym) + + add_error(field: :on_termination_credit_note, error_code: "invalid_value") + false + end + + def valid_on_termination_invoice? + return true if args[:on_termination_invoice].blank? + + return true if Subscription::ON_TERMINATION_INVOICES.include?(args[:on_termination_invoice].to_sym) + + add_error(field: :on_termination_invoice, error_code: "invalid_value") + false + end + + def ending_at + @ending_at ||= if args[:ending_at].is_a?(String) + DateTime.iso8601(args[:ending_at]) + else + args[:ending_at] + end + end + + def subscription_at + @subscription_at ||= if args[:subscription_at].is_a?(String) + DateTime.iso8601(args[:subscription_at]) + else + args[:subscription_at] + end + end + + def valid_payment_method? + return true if args[:payment_method].blank? + return true if PaymentMethods::ValidateService.new(result, **args).valid? + + add_error(field: :payment_method, error_code: "invalid_payment_method") + + false + end + + def valid_activation_rules? + return true unless args[:activation_rules] + + validator = Subscriptions::ActivationRules::ValidateService.new( + result, + activation_rules: args[:activation_rules], + subscription: args[:subscription], + subscription_type: args[:subscription_type], + payment_method: args[:payment_method], + customer: args[:customer] + ) + return true if validator.valid? + + validator.errors.each do |field, codes| + codes.each { |code| add_error(field:, error_code: code) } + end + + false + end + end +end diff --git a/app/services/taxes/auto_generate_service.rb b/app/services/taxes/auto_generate_service.rb new file mode 100644 index 0000000..aca1c76 --- /dev/null +++ b/app/services/taxes/auto_generate_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Taxes + class AutoGenerateService < BaseService + Result = BaseResult + + def initialize(organization:) + @organization = organization + super + end + + def call + LagoEuVat::Rate.country_codes.each do |country_code| + create_country_tax(country_code) + end + + create_generic_taxes + + result + end + + private + + attr_reader :organization + + def create_country_tax(country_code) + country_taxes = LagoEuVat::Rate.country_rates(country_code:) + + country_rates = country_taxes[:rates] + tax_code = "lago_eu_#{country_code.downcase}_standard" + tax_name = "#{country_code.upcase} Standard" + create_tax(tax_code, tax_name, country_rates["standard"]) + + country_exceptions = country_taxes[:exceptions] + return if country_exceptions.blank? + + country_exceptions.each do |exception| + exception_code = exception["name"].parameterize.underscore + tax_code = "lago_eu_#{country_code.downcase}_exception_#{exception_code}" + tax_name = "#{country_code.upcase} #{exception["name"]} Standard" + create_tax(tax_code, tax_name, exception["standard"]) + end + end + + def create_generic_taxes + create_tax("lago_eu_reverse_charge", "Reverse Charge", 0.0) + create_tax("lago_eu_tax_exempt", "Tax Exempt", 0.0) + end + + def create_tax(tax_code, tax_name, rate) + tax = organization.taxes.find_or_initialize_by( + code: tax_code + ) + + tax.name = tax_name + tax.rate = rate + tax.description = "Generated By Lago EU VAT management" + tax.auto_generated = true + + tax.save! + end + end +end diff --git a/app/services/taxes/create_service.rb b/app/services/taxes/create_service.rb new file mode 100644 index 0000000..c8971fb --- /dev/null +++ b/app/services/taxes/create_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Taxes + class CreateService < BaseService + def initialize(organization:, params:) + @organization = organization + @params = params + + super + end + + def call + tax = organization.taxes.new( + name: params[:name], + code: params[:code], + rate: params[:rate], + description: params[:description] + ) + + tax.applied_to_organization = params[:applied_to_organization] if params.key?(:applied_to_organization) + tax.save! + + apply_taxes_on_billing_entity if params[:applied_to_organization] + + result.tax = tax + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :params + + def apply_taxes_on_billing_entity + billing_entity = organization.default_billing_entity + if params[:applied_to_organization] + BillingEntities::Taxes::ApplyTaxesService.call(billing_entity:, tax_codes: [params[:code]]) + end + end + end +end diff --git a/app/services/taxes/destroy_service.rb b/app/services/taxes/destroy_service.rb new file mode 100644 index 0000000..b5f7a3f --- /dev/null +++ b/app/services/taxes/destroy_service.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Taxes + class DestroyService < BaseService + Result = BaseResult[:tax] + + def initialize(tax:) + @tax = tax + + super + end + + def call + return result.not_found_failure!(resource: "tax") unless tax + + result.tax = tax + + return result if tax.discarded? + + # NOTE: we must retrieve the list of draft invoice before proceeding to destroy + # as we need the applied_tax relation + draft_invoice_ids + + ActiveRecord::Base.transaction do + tax.billing_entities.each do |billing_entity| + BillingEntities::Taxes::RemoveTaxesService.call!(billing_entity:, tax_codes: [tax.code]) + end + + tax.applied_taxes.delete_all + tax.draft_fee_taxes.delete_all + tax.draft_invoice_taxes.delete_all + tax.credit_notes_taxes.delete_all + tax.add_ons_taxes.delete_all + tax.plans_taxes.delete_all + tax.charges_taxes.delete_all + tax.commitments_taxes.delete_all + tax.fixed_charges_taxes.delete_all + + tax.deleted_at = Time.current + tax.save! + end + + Invoice.where(id: draft_invoice_ids).update_all(ready_to_be_refreshed: true) # rubocop:disable Rails/SkipsModelValidations + + result + end + + private + + attr_reader :tax + + def draft_invoice_ids + @draft_invoice_ids ||= tax.organization.invoices + .where(customer_id: tax.applicable_customers.select(:id)) + .draft + .pluck(:id) + end + end +end diff --git a/app/services/taxes/update_service.rb b/app/services/taxes/update_service.rb new file mode 100644 index 0000000..bbe0450 --- /dev/null +++ b/app/services/taxes/update_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Taxes + class UpdateService < BaseService + def initialize(tax:, params:) + @tax = tax + @params = params + + super + end + + def call + return result.not_found_failure!(resource: "tax") unless tax + + customer_ids = tax.applicable_customers.select(:id).to_a + + tax.name = params[:name] if params.key?(:name) + tax.code = params[:code] if params.key?(:code) + tax.rate = params[:rate] if params.key?(:rate) + tax.description = params[:description] if params.key?(:description) + tax.applied_to_organization = params[:applied_to_organization] if params.key?(:applied_to_organization) + tax.save! + + manage_taxes_on_billing_entity if params.key?(:applied_to_organization) + + customer_ids = (customer_ids + tax.reload.applicable_customers.select(:id)).uniq + draft_invoices = tax.organization.invoices.where(customer_id: customer_ids).draft + draft_invoices.update_all(ready_to_be_refreshed: true) # rubocop:disable Rails/SkipsModelValidations + + result.tax = tax + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :tax, :params + + def manage_taxes_on_billing_entity + billing_entity = tax.organization.default_billing_entity + if tax.applied_to_organization + BillingEntities::Taxes::ApplyTaxesService.call(billing_entity:, tax_codes: [tax.code]) + else + BillingEntities::Taxes::RemoveTaxesService.call(billing_entity:, tax_codes: [tax.code]) + end + end + end +end diff --git a/app/services/usage_monitoring/alerts/create_batch_service.rb b/app/services/usage_monitoring/alerts/create_batch_service.rb new file mode 100644 index 0000000..f32da98 --- /dev/null +++ b/app/services/usage_monitoring/alerts/create_batch_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module UsageMonitoring + module Alerts + class CreateBatchService < BaseService + Result = BaseResult[:alerts, :errors] + + def initialize(organization:, alertable:, alerts_params:) + @organization = organization + @alertable = alertable + @alerts_params = alerts_params + super + end + + def call + return result.not_found_failure!(resource: "organization") unless organization + return result.not_found_failure!(resource: "alertable") unless alertable + + if alerts_params.blank? + return result.single_validation_failure!(error_code: "no_alerts", field: :alerts) + end + + result.alerts = [] + result.errors = {} + + ActiveRecord::Base.transaction do + alerts_params.each_with_index do |alert_params, index| + ActiveRecord::Base.transaction(requires_new: true) do + create_result = CreateAlertService.call( + organization:, + alertable:, + params: alert_params.to_h + ) + + if create_result.success? + result.alerts << create_result.alert + else + error_details = {} + error_details[:params] = alert_params + error_details[:errors] = create_result.error&.message + result.errors[index] = error_details + raise ActiveRecord::Rollback + end + end + end + + raise ActiveRecord::Rollback if result.errors.any? + end + + if result.errors.any? + result.alerts = [] + return result.validation_failure!(errors: result.errors) + end + + result + end + + private + + attr_reader :organization, :alertable, :alerts_params + end + end +end diff --git a/app/services/usage_monitoring/alerts/destroy_all_service.rb b/app/services/usage_monitoring/alerts/destroy_all_service.rb new file mode 100644 index 0000000..a8b9444 --- /dev/null +++ b/app/services/usage_monitoring/alerts/destroy_all_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module UsageMonitoring + module Alerts + class DestroyAllService < BaseService + Result = BaseResult[:alerts] + + def initialize(alertable:) + @alertable = alertable + super + end + + def call + return result.not_found_failure!(resource: "alertable") unless alertable + + alert_ids = alertable.alerts.ids + + ActiveRecord::Base.transaction do + AlertThreshold.where(usage_monitoring_alert_id: alert_ids).delete_all + Alert.where(id: alert_ids).update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + result + end + + private + + attr_reader :organization, :alertable + end + end +end diff --git a/app/services/usage_monitoring/base_service.rb b/app/services/usage_monitoring/base_service.rb new file mode 100644 index 0000000..0b53b6d --- /dev/null +++ b/app/services/usage_monitoring/base_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module UsageMonitoring + class BaseService < ::BaseService + private + + def prepare_thresholds(thresholds, organization_id) + thresholds.map do |threshold_params| + { + organization_id:, + code: nil, + recurring: false + }.merge(threshold_params.to_h) + end + end + end +end diff --git a/app/services/usage_monitoring/concerns/create_or_update_concern.rb b/app/services/usage_monitoring/concerns/create_or_update_concern.rb new file mode 100644 index 0000000..9256fed --- /dev/null +++ b/app/services/usage_monitoring/concerns/create_or_update_concern.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module UsageMonitoring + module Concerns + module CreateOrUpdateConcern + extend ActiveSupport::Concern + + def find_billable_metric_from_params! + if params[:billable_metric] + params[:billable_metric] + elsif params[:billable_metric_id] + organization.billable_metrics.find_by!(id: params[:billable_metric_id]) + elsif params[:billable_metric_code] + organization.billable_metrics.find_by!(code: params[:billable_metric_code]) + end + rescue ActiveRecord::RecordNotFound + result.not_found_failure!(resource: "billable_metric") + end + + def duplicate_threshold_values?(thresholds) + threshold_keys = thresholds.map { |t| [t[:value], ActiveModel::Type::Boolean.new.cast(t[:recurring]) || false] } + threshold_keys.size != threshold_keys.uniq.size + end + + def all_threshold_values_present?(thresholds) + thresholds.none? { it[:value].nil? } + end + + def all_threshold_values_numeric?(thresholds) + thresholds.all? { |t| valid_numeric_value?(t[:value]) } + end + + def all_recurring_threshold_values_positive?(thresholds) + thresholds.all? do |t| + recurring = ActiveModel::Type::Boolean.new.cast(t[:recurring]) + value = ActiveModel::Type::Decimal.new.cast(t[:value]) + + !recurring || value.positive? + end + end + + def valid_numeric_value?(value) + case value + when Numeric + true + when String + return false if value.blank? + + Float(value) + true + else + false + end + rescue ArgumentError + false + end + end + end +end diff --git a/app/services/usage_monitoring/create_alert_service.rb b/app/services/usage_monitoring/create_alert_service.rb new file mode 100644 index 0000000..bf14287 --- /dev/null +++ b/app/services/usage_monitoring/create_alert_service.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module UsageMonitoring + class CreateAlertService < BaseService + include ::UsageMonitoring::Concerns::CreateOrUpdateConcern + + Result = BaseResult[:alert] + + def initialize(organization:, alertable:, params:) + @organization = organization + @alertable = alertable + @params = params + super + end + + def call + if params[:alert_type].in?(%w[lifetime_usage_amount]) && !organization.using_lifetime_usage? + return result.single_validation_failure!(field: :alert_type, error_code: "feature_not_available") + end + + if params[:alert_type].in?(%w[billable_metric_lifetime_usage_units]) && !organization.granular_lifetime_usage_enabled? + return result.single_validation_failure!(field: :alert_type, error_code: "feature_not_available") + end + + if params[:alert_type].blank? + return result.validation_failure!(errors: {alert_type: %w[value_is_mandatory value_is_invalid]}) + end + + unless Alert::STI_MAPPING.key?(params[:alert_type]) + return result.single_validation_failure!(field: :alert_type, error_code: "invalid_type") + end + + if alertable.is_a?(Wallet) && !Alert::WALLET_TYPES.include?(params[:alert_type]) + return result.single_validation_failure!(field: :alert_type, error_code: "invalid_type") + end + + if alertable.is_a?(Subscription) && Alert::WALLET_TYPES.include?(params[:alert_type]) + return result.single_validation_failure!(field: :alert_type, error_code: "invalid_type") + end + + if params[:thresholds].blank? + return result.single_validation_failure!(field: :thresholds, error_code: "value_is_mandatory") + end + + if params[:thresholds].size > AlertThreshold::SOFT_LIMIT + return result.single_validation_failure!(field: :thresholds, error_code: "too_many_thresholds") + end + + if duplicate_threshold_values?(params[:thresholds]) + return result.single_validation_failure!(field: :thresholds, error_code: "duplicate_threshold_values") + end + + if !all_threshold_values_present?(params[:thresholds]) + return result.single_validation_failure!(field: "thresholds:value", error_code: "value_is_mandatory") + end + + if !all_threshold_values_numeric?(params[:thresholds]) + return result.single_validation_failure!(field: "thresholds:value", error_code: "value_is_invalid") + end + + if !all_recurring_threshold_values_positive?(params[:thresholds]) + return result.single_validation_failure!(field: "thresholds:value", error_code: "recurring_value_is_negative") + end + + billable_metric = find_billable_metric_from_params! + return result unless result.success? + + ActiveRecord::Base.transaction do + alert = Alert.new( + organization:, + subscription_external_id: subscription&.external_id, + wallet: wallet, + billable_metric:, + alert_type: params[:alert_type].to_s, + name: params[:name], + code: params[:code], + direction: direction_for_alert + ) + + alertable.with_lock do + # Lock alertable to prevent any changes to it and avoid it becoming stale + # as we set previous_value to the alertable metric when the alert + # direction is :decreasing + alert.previous_value = alert.find_value(alertable) if alert.decreasing? + alert.save! + end + + alert.thresholds.create!(prepare_thresholds(params[:thresholds], organization.id)) + + result.alert = alert + end + + track_subscription_activity if subscription + process_wallet_alerts if wallet + + result + rescue KeyError + result.single_validation_failure!(field: :alert_type, error_code: "invalid_type") + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique => e + if e.message.include?("idx_alerts_code_unique_per_subscription") || e.message.include?("idx_alerts_code_unique_per_wallet") + result.single_validation_failure!(field: :code, error_code: "value_already_exist") + else + # Only one alert per [alert_type, billable_metric] pair is allowed. + result.single_validation_failure!(field: :base, error_code: "alert_already_exists") + end + end + + private + + attr_reader :organization, :alertable, :params + + def subscription + alertable if alertable.is_a?(Subscription) + end + + def wallet + alertable if alertable.is_a?(Wallet) + end + + def direction_for_alert + Alert::WALLET_TYPES.include?(params[:alert_type]) ? "decreasing" : "increasing" + end + + def track_subscription_activity + return unless License.premium? + return unless subscription.active? + + UsageMonitoring::SubscriptionActivity.insert_all( # rubocop:disable Rails/SkipsModelValidations + [{organization_id: organization.id, subscription_id: subscription.id}], + unique_by: :idx_subscription_unique + ) + end + + def process_wallet_alerts + return unless License.premium? + return unless wallet.active? + + UsageMonitoring::ProcessWalletAlertsJob.perform_later(wallet) + end + end +end diff --git a/app/services/usage_monitoring/destroy_alert_service.rb b/app/services/usage_monitoring/destroy_alert_service.rb new file mode 100644 index 0000000..24ae926 --- /dev/null +++ b/app/services/usage_monitoring/destroy_alert_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module UsageMonitoring + class DestroyAlertService < BaseService + Result = BaseResult[:alert] + + def initialize(alert:) + @alert = alert + super + end + + def call + return result.not_found_failure!(resource: "alert") unless alert + + ActiveRecord::Base.transaction do + alert.thresholds.delete_all + alert.discard! + end + + result.alert = alert + result + end + + private + + attr_reader :alert + end +end diff --git a/app/services/usage_monitoring/process_alert_service.rb b/app/services/usage_monitoring/process_alert_service.rb new file mode 100644 index 0000000..aaa0925 --- /dev/null +++ b/app/services/usage_monitoring/process_alert_service.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessAlertService < BaseService + Result = BaseResult[:alert] + + def initialize(alert:, current_metrics:, alertable:) + @alert = alert + @alertable = alertable + @current_metrics = current_metrics + super + end + + def call + now = Time.current + current = alert.find_value(current_metrics) + + # NOTE: If the alert is set for a billable metric which is not part of any charges of the plan + return result if current.nil? + + crossed_threshold_values = alert.find_thresholds_crossed(current) + + ActiveRecord::Base.transaction do + if crossed_threshold_values.present? + triggered_alert = TriggeredAlert.create!( + alert:, + organization: alert.organization, + subscription:, + wallet:, + current_value: current, + previous_value: alert.previous_value, + crossed_thresholds: alert.formatted_crossed_thresholds(crossed_threshold_values), + triggered_at: now + ) + + after_commit { SendWebhookJob.perform_later("alert.triggered", triggered_alert) } + end + + alert.previous_value = current + alert.last_processed_at = now + alert.save! + end + + result.alert = alert + result + end + + private + + attr_reader :alert, :alertable, :current_metrics + + def subscription + alertable if alertable.is_a?(Subscription) + end + + def wallet + alertable if alertable.is_a?(Wallet) + end + end +end diff --git a/app/services/usage_monitoring/process_all_subscription_activities_service.rb b/app/services/usage_monitoring/process_all_subscription_activities_service.rb new file mode 100644 index 0000000..e3454a4 --- /dev/null +++ b/app/services/usage_monitoring/process_all_subscription_activities_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessAllSubscriptionActivitiesService < BaseService + Result = BaseResult + + def call + # NOTE: If we need to handle different delays per organization, this would be done here. + # This is also where we should report metrics + # That's why it's a dedicated service and not just done in the job + + scope = SubscriptionActivity.where(enqueued: false) + + dedicated_org_ids = Utils::DedicatedWorkerConfig.organization_ids + scope = scope.where.not(organization_id: dedicated_org_ids) if dedicated_org_ids.any? + + scope.distinct.pluck(:organization_id).each do |organization_id| + ProcessOrganizationSubscriptionActivitiesJob.perform_later(organization_id) + end + + result + end + end +end diff --git a/app/services/usage_monitoring/process_lifetime_usage_alert_service.rb b/app/services/usage_monitoring/process_lifetime_usage_alert_service.rb new file mode 100644 index 0000000..ca071e1 --- /dev/null +++ b/app/services/usage_monitoring/process_lifetime_usage_alert_service.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessLifetimeUsageAlertService < BaseService + Result = BaseResult + + def initialize(alert:, subscription: nil) + @alert = alert + @subscription = subscription + super + end + + def call + return result unless alert.alert_type == "billable_metric_lifetime_usage_units" + return result unless subscription + + charge_ids = subscription.plan.charges.where(billable_metric_id: alert.billable_metric_id).ids + return result if charge_ids.empty? + + usage_filters = UsageFilters.new(full_usage: true, filter_by_charge_id: charge_ids) + usage_for_charges_result = ::Invoices::CustomerUsageService.call!( + customer: subscription.customer, + subscription:, + apply_taxes: false, + with_cache: true, + usage_filters: + ) + + ProcessAlertService.call(alert:, alertable: subscription, current_metrics: usage_for_charges_result.usage) + + result + end + + private + + attr_reader :alert, :organization, :subscription + end +end diff --git a/app/services/usage_monitoring/process_organization_subscription_activities_service.rb b/app/services/usage_monitoring/process_organization_subscription_activities_service.rb new file mode 100644 index 0000000..f006573 --- /dev/null +++ b/app/services/usage_monitoring/process_organization_subscription_activities_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessOrganizationSubscriptionActivitiesService < BaseService + Result = BaseResult[:nb_jobs_enqueued] + + BATCH_SIZE = 500 + + def initialize(organization:) + @organization = organization + super() + end + + def call + nb_jobs_enqueued = 0 + + # NOTE: If we need to handle different delays per organization, this would be done here. + + queue_name = if dedicated? + Utils::DedicatedWorkerConfig::DEDICATED_ALERTS_QUEUE.to_s + else + ProcessSubscriptionActivityJob.queue_name + end + + organization.subscription_activities.where(enqueued: false).select(:id).in_batches(of: BATCH_SIZE) do |batch| + jobs = batch.map do |sa| + ProcessSubscriptionActivityJob.new(sa.id).tap { |j| j.queue_name = queue_name } + end + + ActiveRecord::Base.transaction do + batch.update_all(enqueued: true, enqueued_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + after_commit { ActiveJob.perform_all_later(jobs) } + end + + nb_jobs_enqueued += jobs.size + end + + result.nb_jobs_enqueued = nb_jobs_enqueued + result + end + + private + + attr_reader :organization + + def dedicated? + Utils::DedicatedWorkerConfig.enabled_for?(organization.id) + end + end +end diff --git a/app/services/usage_monitoring/process_subscription_activity_service.rb b/app/services/usage_monitoring/process_subscription_activity_service.rb new file mode 100644 index 0000000..6dbb4f3 --- /dev/null +++ b/app/services/usage_monitoring/process_subscription_activity_service.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessSubscriptionActivityService < BaseService + Result = BaseResult + + def initialize(subscription_activity:) + @subscription_activity = subscription_activity + @subscription = subscription_activity.subscription + super + end + + def call + exception_to_raise = nil + lifetime_usage = find_or_create_lifetime_usage + + # NOTE: We would typically have one jobs for progressive billing and one job for Alerting + # but in order to reduce calls to `current_usage`, we do both in the same job. + # A simple rescue is added so ensure we process alert even if progressive billing breaks + # The subscription_activity is deleted if something raise. + # We believe it's a good tradeoff because they should rarely raise but this can change in the future + + begin + # Note we rely on the jobs rather than on the services to take advantage of the job's uniqueness strategy + if organization.using_lifetime_usage? + LifetimeUsages::RecalculateAndCheckJob.perform_now(lifetime_usage, current_usage:) + end + rescue => e + exception_to_raise = e + end + + alerts = Alert.where( + subscription_external_id: subscription.external_id, + organization_id: subscription_activity.organization_id + ).includes(:thresholds).load + + alerts.each do |alert| + case alert.alert_type + when "lifetime_usage_amount" + ProcessAlertService.call(alert:, alertable: subscription, current_metrics: lifetime_usage) + when *Alert::BILLABLE_METRIC_LIFETIME_USAGE_TYPES + UsageMonitoring::ProcessLifetimeUsageAlertJob.set(wait: 5.minutes).perform_later(alert:, subscription:) + when *Alert::CURRENT_USAGE_TYPES + ProcessAlertService.call(alert:, alertable: subscription, current_metrics: current_usage) + end + rescue => e + exception_to_raise ||= e + end + + subscription_activity.delete + + if exception_to_raise + raise exception_to_raise + end + + result + end + + private + + delegate :organization, to: :subscription + + attr_reader :subscription_activity, :subscription + + def current_usage + @current_usage ||= ::Invoices::CustomerUsageService.call( + customer: subscription.customer, + subscription:, + apply_taxes: false, # Never use taxes for alerting + with_cache: true + ).usage + end + + def find_or_create_lifetime_usage + subscription.lifetime_usage || subscription.create_lifetime_usage!(organization:) + end + end +end diff --git a/app/services/usage_monitoring/process_wallet_alerts_service.rb b/app/services/usage_monitoring/process_wallet_alerts_service.rb new file mode 100644 index 0000000..813b729 --- /dev/null +++ b/app/services/usage_monitoring/process_wallet_alerts_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module UsageMonitoring + class ProcessWalletAlertsService < BaseService + Result = BaseResult + + def initialize(wallet:) + @wallet = wallet + super + end + + def call + return result unless wallet.alerts.any? + + wallet.alerts.using_wallet.includes(:thresholds).find_each do |alert| + ProcessAlertService.call(alert:, alertable: wallet, current_metrics: wallet) + end + + result + end + + private + + attr_reader :wallet + end +end diff --git a/app/services/usage_monitoring/track_subscription_activity_service.rb b/app/services/usage_monitoring/track_subscription_activity_service.rb new file mode 100644 index 0000000..ab2dee7 --- /dev/null +++ b/app/services/usage_monitoring/track_subscription_activity_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module UsageMonitoring + class TrackSubscriptionActivityService < BaseService + Result = BaseResult + + # NOTE: The organization can be passed to avoid loading it from the subscription + # If not passed, it's lazy loaded from the subscription + def initialize(subscription:, date:, organization: nil) + @subscription = subscription + @organization = organization + @date = date + super + end + + def call + return result unless License.premium? + return result unless subscription.active? + if subscription.last_received_event_on != date + subscription.update(last_received_event_on: date) + end + + return result unless need_lifetime_usage? || has_alerts? + + UsageMonitoring::SubscriptionActivity.insert_all( # rubocop:disable Rails/SkipsModelValidations + [{organization_id: organization.id, subscription_id: subscription.id}], + unique_by: :idx_subscription_unique + ) + + result + end + + private + + attr_reader :subscription, :date + + def organization + @organization ||= subscription.organization + end + + def need_lifetime_usage? + return true if organization.lifetime_usage_enabled? + + organization.progressive_billing_enabled? && subscription.has_progressive_billing? + end + + def has_alerts? + Alert.where(subscription_external_id: subscription.external_id).any? + end + end +end diff --git a/app/services/usage_monitoring/update_alert_service.rb b/app/services/usage_monitoring/update_alert_service.rb new file mode 100644 index 0000000..2104985 --- /dev/null +++ b/app/services/usage_monitoring/update_alert_service.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module UsageMonitoring + class UpdateAlertService < BaseService + include ::UsageMonitoring::Concerns::CreateOrUpdateConcern + + Result = BaseResult[:alert] + + def initialize(alert:, params:) + @alert = alert + @params = params + super + end + + def call + return result.not_found_failure!(resource: "alert") unless alert + + if params.has_key?(:thresholds) && params[:thresholds].size > AlertThreshold::SOFT_LIMIT + return result.single_validation_failure!(field: :thresholds, error_code: "too_many_thresholds") + end + + if params[:thresholds].present? + if duplicate_threshold_values?(params[:thresholds]) + return result.single_validation_failure!(field: :thresholds, error_code: "duplicate_threshold_values") + end + + if !all_threshold_values_present?(params[:thresholds]) + return result.single_validation_failure!(field: "thresholds:value", error_code: "value_is_mandatory") + end + + if !all_threshold_values_numeric?(params[:thresholds]) + return result.single_validation_failure!(field: "thresholds:value", error_code: "value_is_invalid") + end + + if !all_recurring_threshold_values_positive?(params[:thresholds]) + return result.single_validation_failure!(field: "thresholds:value", error_code: "recurring_value_is_negative") + end + end + + result.alert = alert + + billable_metric = find_billable_metric_from_params! + return result unless result.success? + + ActiveRecord::Base.transaction do + alert.name = params[:name] if params.key?(:name) + alert.code = params[:code] if params.key?(:code) + alert.billable_metric = billable_metric if billable_metric + alert.save! + + if params[:thresholds].present? + alert.thresholds.delete_all + alert.thresholds.create!(prepare_thresholds(params[:thresholds], alert.organization_id)) + end + end + + track_subscription_activity if alert.subscription_external_id? + process_wallet_alerts if alert.wallet_id? + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue ActiveRecord::RecordNotUnique => e + if e.message.include?("idx_alerts_code_unique_per_subscription") + result.single_validation_failure!(field: :code, error_code: "value_already_exist") + else + # Only one alert per [alert_type, billable_metric] pair is allowed. + result.single_validation_failure!(field: :base, error_code: "alert_already_exists") + end + end + + private + + attr_reader :alert, :params + delegate :organization, to: :alert + + def track_subscription_activity + return unless alert.subscription_external_id? + active_subscription = organization.subscriptions.active + .find_by(external_id: alert.subscription_external_id) + return unless active_subscription + return unless License.premium? + + UsageMonitoring::SubscriptionActivity.insert_all( # rubocop:disable Rails/SkipsModelValidations + [{organization_id: organization.id, subscription_id: active_subscription.id}], + unique_by: :idx_subscription_unique + ) + end + + def process_wallet_alerts + return unless alert.wallet_id? + return unless License.premium? + return unless alert.wallet&.active? + + UsageMonitoring::ProcessWalletAlertsJob.perform_later(alert.wallet) + end + end +end diff --git a/app/services/usage_thresholds/override_service.rb b/app/services/usage_thresholds/override_service.rb new file mode 100644 index 0000000..ff8bc41 --- /dev/null +++ b/app/services/usage_thresholds/override_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module UsageThresholds + class OverrideService < BaseService + def initialize(usage_thresholds_params:, new_plan:) + @usage_thresholds_params = usage_thresholds_params + @new_plan = new_plan + + super + end + + def call + ActiveRecord::Base.transaction do + usage_thresholds_params.each do |params| + usage_threshold = new_plan.usage_thresholds.new( + organization_id: new_plan.organization_id, + plan_id: new_plan.id, + threshold_display_name: params[:threshold_display_name], + amount_cents: params[:amount_cents], + recurring: params[:recurring] || false + ) + + usage_threshold.save! + end + end + + result.usage_thresholds = new_plan.usage_thresholds + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :usage_thresholds_params, :new_plan + end +end diff --git a/app/services/usage_thresholds/update_service.rb b/app/services/usage_thresholds/update_service.rb new file mode 100644 index 0000000..465055d --- /dev/null +++ b/app/services/usage_thresholds/update_service.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module UsageThresholds + class UpdateService < BaseService + Result = BaseResult + + def initialize(model:, usage_thresholds_params:, partial:) + @model = model + @usage_thresholds_params = sanitize_params(usage_thresholds_params) + @partial = partial + + super + end + + def call + return result if usage_thresholds_params.empty? && partial? + + return result.single_validation_failure!(error_code: "missing_amount_cents", field: :usage_thresholds) if missing_amount_cents? + return result.single_validation_failure!(error_code: "duplicated_values", field: :usage_thresholds) if duplicated_amount_cents? + return result.single_validation_failure!(error_code: "multiple_recurring_thresholds", field: :usage_thresholds) if multiple_recurring_thresholds? + + ActiveRecord::Base.transaction do + delete_all_thresholds if full? + + update_recurring_threshold + update_or_create_thresholds + end + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :model, :usage_thresholds_params, :partial + alias_method :partial?, :partial + + def full? + !partial + end + + def sanitize_params(usage_thresholds_params) + usage_thresholds_params.map do |p| + h = p.to_h.deep_symbolize_keys.slice(:threshold_display_name, :amount_cents, :recurring) + h[:recurring] ||= false + h + end + end + + def missing_amount_cents? + usage_thresholds_params.any? { |p| p[:amount_cents].blank? } + end + + def duplicated_amount_cents? + grouped = usage_thresholds_params.group_by { |p| [p[:amount_cents], p[:recurring]] } + grouped.any? { |_, v| v.size > 1 } + end + + def multiple_recurring_thresholds? + usage_thresholds_params.count { |p| p[:recurring] } > 1 + end + + def delete_all_thresholds + model.usage_thresholds.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + def update_recurring_threshold + recurring_params = usage_thresholds_params.find { |p| p[:recurring] } + return unless recurring_params + + existing_threshold = model.usage_thresholds.find { |t| t.recurring } + + if existing_threshold + existing_threshold.update!( + amount_cents: recurring_params[:amount_cents], + threshold_display_name: recurring_params[:threshold_display_name] + ) + else + create_threshold(recurring_params, recurring: true) + end + end + + def update_or_create_thresholds + usage_thresholds_params.reject { |p| p[:recurring] }.each do |threshold_params| + existing_threshold = model.usage_thresholds.find { |t| t.amount_cents == threshold_params[:amount_cents] && !t.recurring } + + if existing_threshold + update_threshold(existing_threshold, threshold_params) + else + create_threshold(threshold_params) + end + end + end + + def update_threshold(threshold, params) + threshold.threshold_display_name = params[:threshold_display_name] if params.key?(:threshold_display_name) + threshold.save! + end + + def create_threshold(params, recurring: false) + model.usage_thresholds.create!( + organization: model.organization, + threshold_display_name: params[:threshold_display_name], + amount_cents: params[:amount_cents], + recurring: + ) + end + end +end diff --git a/app/services/user_devices/register_service.rb b/app/services/user_devices/register_service.rb new file mode 100644 index 0000000..f467440 --- /dev/null +++ b/app/services/user_devices/register_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module UserDevices + class RegisterService < BaseService + Result = BaseResult + + # @option user [User] the user to match the device for + # @option skip_log [Boolean] whether to skip logging of new device (default: false) + def initialize(user:, skip_log: false) + @user = user + @skip_log = skip_log + super + end + + def call + device_info = CurrentContext.device_info + return result unless device_info + + fingerprint = Digest::SHA256.hexdigest(device_info[:user_agent]) + device = user.user_devices.find_or_initialize_by(fingerprint:) + @skip_log ||= !device.new_record? + device.update!( + browser: device_info[:browser], + os: device_info[:os], + device_type: device_info[:device_type], + last_logged_at: Time.current, + last_ip_address: device_info[:ip_address] + ) + register_security_logs unless skip_log + + result + end + + private + + attr_reader :user, :skip_log + + def register_security_logs + user.active_organizations.select(&:security_logs_enabled?).each do |organization| + Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.new_device_logged_in", + user: + ) + end + end + end +end diff --git a/app/services/users_service.rb b/app/services/users_service.rb new file mode 100644 index 0000000..68873a1 --- /dev/null +++ b/app/services/users_service.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +class UsersService < BaseService + def login(email, password) + # NOTE: Null byte injection. Prevent 500 errors. + if email.include?("\u0000") || password.include?("\u0000") + return result.single_validation_failure!(error_code: "incorrect_login_or_password") + end + + result.user = User.find_by(email:)&.authenticate(password) + + unless result.user.present? && result.user.memberships.active.any? + return result.single_validation_failure!(error_code: "incorrect_login_or_password") + end + + unless result.user.active_organizations.pluck(:authentication_methods).flatten.uniq.include?(Organizations::AuthenticationMethods::EMAIL_PASSWORD) + return result.single_validation_failure!( + error_code: "login_method_not_authorized", + field: Organizations::AuthenticationMethods::EMAIL_PASSWORD + ) + end + + result.token = generate_token if result.user + + # NOTE: We're tracking the first membership linked to the user. + membership = result.user.memberships.active.first + SegmentIdentifyJob.perform_later(membership_id: "membership/#{membership.id}") + + UserDevices::RegisterService.call!(user: result.user) + + result + end + + def register(email, password, organization_name) + if ENV.fetch("LAGO_DISABLE_SIGNUP", "false") == "true" + return result.not_allowed_failure!(code: "signup_disabled") + end + + if User.exists?(email:) + result.single_validation_failure!(field: :email, error_code: "user_already_exists") + + return result + end + + ActiveRecord::Base.transaction do + result.user = User.create!(email:, password:) + + result.organization = Organizations::CreateService + .call(name: organization_name, document_numbering: "per_organization") + .raise_if_error! + .organization + + result.membership = Membership.create!( + user: result.user, + organization: result.organization + ) + + MembershipRole.create!( + organization: result.organization, + membership: result.membership, + role: Role.admins.first! + ) + + result.token = generate_token + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + SegmentIdentifyJob.perform_later(membership_id: "membership/#{result.membership.id}") + track_organization_registered(result.organization, result.membership) + + register_security_log(result) + + # Skip log: user.signed_up already covers signup + UserDevices::RegisterService.call!(user: result.user, skip_log: true) + + result + end + + def register_from_invite(invite, password) + ActiveRecord::Base.transaction do + user = User.find_or_initialize_by(email: invite.email) + + if user.new_record? + user.password = password + user.save! + elsif user.memberships.active.none? + user.update!(password:) + end + + result.user = user + result.organization = invite.organization + + result.membership = Membership.create!( + user: result.user, + organization: result.organization + ) + + invite.roles.each do |role_code| + role = Role.with_code(role_code).with_organization(invite.organization_id).first! + MembershipRole.create!( + organization: result.organization, + membership: result.membership, + role: + ) + end + + result.token = generate_token + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + result + end + + private + + def generate_token + Auth::TokenService.encode(user: result.user, login_method: Organizations::AuthenticationMethods::EMAIL_PASSWORD) + rescue => e + result.service_failure!(code: "token_encoding_error", message: e.message) + end + + def track_organization_registered(organization, membership) + SegmentTrackJob.perform_later( + membership_id: "membership/#{membership.id}", + event: "organization_registered", + properties: { + organization_name: organization.name, + organization_id: organization.id + } + ) + end + + def register_security_log(result) + Utils::SecurityLog.produce( + organization: result.organization, + log_type: "user", + log_event: "user.signed_up", + user: result.user, + resources: { + email: result.user.email, + roles: result.membership.roles.map(&:code) + }, + skip_organization_check: true + ) + end +end diff --git a/app/services/utils/activity_log.rb b/app/services/utils/activity_log.rb new file mode 100644 index 0000000..0501c01 --- /dev/null +++ b/app/services/utils/activity_log.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +module Utils + class ActivityLog + IGNORED_FIELDS = %i[updated_at].freeze + IGNORED_EXTERNAL_CUSTOMER_ID_CLASSES = %w[BillableMetric Coupon Plan BillingEntity Entitlement::Feature].freeze + MAX_SERIALIZED_FEES = 25 + MAX_SERIALIZED_CHARGES = 50 + MAX_SERIALIZED_CHARGE_FILTERS = 100 + + SERIALIZED_INCLUDED_OBJECTS = { + billing_entity: %i[taxes], + credit_note: %i[items applied_taxes error_details], + customer: %i[taxes integration_customers applicable_invoice_custom_sections], + invoice: %i[customer integration_customers billing_periods subscriptions fees credits metadata applied_taxes error_details applied_invoice_custom_sections], + plan: %i[charges usage_thresholds taxes minimum_commitment], + subscription: %i[plan], + wallet: %i[recurring_transaction_rules] + }.freeze + + def self.produce(*, **, &) + new(*, **, &).produce + end + + def self.available? + ENV["LAGO_CLICKHOUSE_ENABLED"].present? && + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"].present? && + ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"].present? + end + + # This method is used to produce an activity log after a commit. + # + # It is meant to avoid race-conditions where a asynchronous post-processing run before changes are commited to the DB. + def self.produce_after_commit(object, activity_type, activity_id: nil, &) + kwargs = {after_commit: true, activity_id:}.compact + produce(object, activity_type, **kwargs, &) + end + + def initialize(object, activity_type, activity_id: SecureRandom.uuid, after_commit: false, &block) + @object = object + @activity_type = activity_type + @activity_id = activity_id + @block = block + @after_commit = after_commit + end + + def produce + return block.call if object.nil? && block + + changes = {} + if block + before_attrs = object_serialized + result = block.call + return result if result.failure? + + # NOTE: This will cause any unsaved changes to be lost. So if `object` is used in `result`, the service `result` + # will not contain the unsaved changes. + object.reload + after_attrs = object_serialized + + changes = before_attrs.each_with_object({}) do |(key, before), result| + after = after_attrs[key] + result[key] = [before, after] if before != after + end + end + + run_maybe_after_commit { produce_with_diff(changes) } + + block ? result : nil + end + + private + + attr_reader :object, :activity_type, :activity_id, :block, :after_commit + + def run_maybe_after_commit(&block) + if after_commit + AfterCommitEverywhere.after_commit(&block) + else + yield + end + end + + def produce_with_diff(changes) + return unless self.class.available? + + current_time = Time.current.iso8601[...-1] + Karafka.producer.produce_async( + topic: ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"], + key: "#{organization_id}--#{activity_id}", + payload: { + activity_source:, + api_key_id: CurrentContext.api_key_id, + user_id: user_id, + activity_type:, + activity_id:, + logged_at: current_time, + created_at: current_time, + resource_id: resource.id, + resource_type: resource.class.name, + organization_id: organization_id, + activity_object: object_serialized, + activity_object_changes: object_changes(changes), + external_customer_id: external_customer_id, + external_subscription_id: external_subscription_id + }.to_json + ) + rescue WaterDrop::Errors::MessageInvalidError => e + raise if ENV["SENTRY_DSN"].blank? + + # Avoid raising error up to the end-user + Sentry.capture_exception(e) + end + + def activity_source + return "front" if CurrentContext.source == "graphql" + + CurrentContext.source || "system" + end + + def user_id + return nil if CurrentContext.api_key_id.present? + return nil if CurrentContext.membership.blank? + + Membership.find_by(organization_id:, id: CurrentContext.membership.split("/").last)&.user_id + end + + def object_serialized + serializer = "V1::#{object.class.name}Serializer".constantize + root_name = object.class.name.underscore.to_sym + + serializer.new(object, root_name:, includes: serializer_includes(root_name)).serialize + end + + def serializer_includes(root_name) + case root_name + when :invoice + if object.fees.count > MAX_SERIALIZED_FEES + SERIALIZED_INCLUDED_OBJECTS[:invoice] - [:fees] + else + SERIALIZED_INCLUDED_OBJECTS[:invoice] + end + when :plan + if has_many_charges_or_filters?(object) + SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges] + else + SERIALIZED_INCLUDED_OBJECTS[:plan] + end + when :subscription + if has_many_charges_or_filters?(object.plan) + [{plan: SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges]}] + else + SERIALIZED_INCLUDED_OBJECTS[:subscription] + end + else + SERIALIZED_INCLUDED_OBJECTS[root_name] || [] + end + end + + def object_changes(changes) + return {} unless activity_type.include?("updated") + + changes.except(*IGNORED_FIELDS) + end + + def organization_id + case object.class.name + when "AppliedCoupon" + object.coupon.organization_id + else + object.organization_id + end + end + + def resource + case object.class.name + when "Payment" + object.payable + when "AppliedCoupon" + object.coupon + when "WalletTransaction" + object.wallet + else + object + end + end + + def external_customer_id + return nil if IGNORED_EXTERNAL_CUSTOMER_ID_CLASSES.include?(object.class.name) + return object.external_id if object.is_a?(Customer) + + object.customer&.external_id + end + + def external_subscription_id + return nil unless object.is_a?(Subscription) + + object.external_id + end + + def has_many_charges_or_filters?(plan) + plan.charges.count > MAX_SERIALIZED_CHARGES || plan.charge_filters.count > MAX_SERIALIZED_CHARGE_FILTERS + end + end +end diff --git a/app/services/utils/api_log.rb b/app/services/utils/api_log.rb new file mode 100644 index 0000000..0535d3f --- /dev/null +++ b/app/services/utils/api_log.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +module Utils + class ApiLog + def self.produce(*, **, &) + new(*, **, &).produce + end + + def self.available? + ENV["LAGO_CLICKHOUSE_ENABLED"].present? && + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"].present? && + ENV["LAGO_KAFKA_API_LOGS_TOPIC"].present? + end + + def initialize(request, response, organization:, &block) + @request = request + @response = response + @organization = organization + @request_id = request.request_id.presence || SecureRandom.uuid + @block = block + end + + def produce + return unless self.class.available? + + current_time = Time.current.iso8601[...-1] + Karafka.producer.produce_async( + topic: ENV["LAGO_KAFKA_API_LOGS_TOPIC"], + key:, + payload: { + **payload, + logged_at: current_time, + created_at: current_time + }.to_json + ) + end + + private + + attr_reader :request, :response, :organization, :request_id, :block + + def key + "#{organization.id}--#{request_id}" + end + + def payload + { + request_id:, + organization_id: organization.id, + api_key_id: CurrentContext.api_key_id, + api_version:, + **request_data, + **response_data + } + end + + def request_data + { + client: request.user_agent, + request_body: request.params.except(:controller, :action, :format), + request_path: request.path, + request_origin: request.base_url, + http_method: request.method_symbol + } + end + + def response_data + { + request_response: response.body.present? ? JSON.parse(response.body) : nil, + http_status: response.status + } + end + + def api_version + request.path.match(/\/api\/(?v\d+)\/.*/)[:version] + end + end +end diff --git a/app/services/utils/datetime.rb b/app/services/utils/datetime.rb new file mode 100644 index 0000000..782d062 --- /dev/null +++ b/app/services/utils/datetime.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Utils + class Datetime + def self.valid_format?(datetime, format: :iso8601) + return true if datetime.respond_to?(:strftime) + return false unless datetime.is_a?(String) + + case format + when :any + Time.zone.parse(datetime).present? + else + Time.zone.iso8601(datetime).present? + end + rescue ArgumentError + false + end + + def self.future_date?(datetime) + return true if datetime.is_a?(ActiveSupport::TimeWithZone) && datetime.future? + return false unless valid_format?(datetime, format: :any) + + parsed_date = Time.zone.parse(datetime.to_s) + parsed_date&.future? || false + end + + def self.date_diff_with_timezone(from_datetime, to_datetime, timezone) + from = from_datetime + from = Time.zone.parse(from.to_s) unless from.is_a?(ActiveSupport::TimeWithZone) + + to = to_datetime + to = Time.zone.parse(to.to_s) unless to.is_a?(ActiveSupport::TimeWithZone) + to_in_time = to.in_time_zone(timezone) + to += 1.second if to_in_time == to_in_time.beginning_of_day # To make sure we do not miss a day + + from_offset = from.in_time_zone(timezone).utc_offset + to_offset = to.in_time_zone(timezone).utc_offset + offset = from_offset - to_offset + + (to - from - offset).fdiv(1.day).ceil + end + end +end diff --git a/app/services/utils/dedicated_worker_config.rb b/app/services/utils/dedicated_worker_config.rb new file mode 100644 index 0000000..aa6752f --- /dev/null +++ b/app/services/utils/dedicated_worker_config.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Utils + module DedicatedWorkerConfig + DEDICATED_WALLETS_QUEUE = :dedicated_wallets + DEDICATED_ALERTS_QUEUE = :dedicated_alerts + + ORGANIZATION_IDS = ENV["LAGO_DEDICATED_WORKER_ORG_IDS"].to_s.split(",").map(&:strip).reject(&:empty?).each(&:downcase).freeze + + def self.refresh_interval + interval = ENV["LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS"].presence.to_i + (interval.positive? ? interval : 5).seconds + end + + def self.organization_ids + ORGANIZATION_IDS + end + + def self.enabled_for?(organization_id) + return false if organization_id.blank? + + ORGANIZATION_IDS.include?(organization_id.downcase.to_s) + end + + def self.any? + ORGANIZATION_IDS.any? + end + end +end diff --git a/app/services/utils/device_info.rb b/app/services/utils/device_info.rb new file mode 100644 index 0000000..0627d88 --- /dev/null +++ b/app/services/utils/device_info.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Utils + # Parses device metadata from an HTTP request User-Agent header. + class DeviceInfo + def self.parse(request) + user_agent = request&.user_agent + return if user_agent.blank? + + client = DeviceDetector.new(user_agent) + { + user_agent:, + ip_address: request.remote_ip, + browser: "#{client.name} #{client.full_version}".strip, + os: client.os_name, + device_type: client.device_type || "desktop" + } + end + end +end diff --git a/app/services/utils/email_activity_log.rb b/app/services/utils/email_activity_log.rb new file mode 100644 index 0000000..9fbb85b --- /dev/null +++ b/app/services/utils/email_activity_log.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +module Utils + class EmailActivityLog + ACTIVITY_TYPE = "email.sent" + TOPIC = ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"] + AVAILABLE = ENV["LAGO_CLICKHOUSE_ENABLED"].present? && + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"].present? && + TOPIC.present? + BODY_PREVIEW_LENGTH = 500 + + def self.produce(document:, message:, organization_id: nil, resend: false, user_id: nil, api_key_id: nil, error: nil) + new(document:, message:, organization_id:, resend:, user_id:, api_key_id:, error:).produce + end + + def initialize(document:, message:, organization_id: nil, resend: false, user_id: nil, api_key_id: nil, error: nil) + @document = document + @organization_id = organization_id || document&.organization_id + @message = message + @resend = resend + @user_id = user_id + @api_key_id = api_key_id + @error = error + @activity_id = SecureRandom.uuid + @current_time = Time.current.iso8601[...-1] + end + + def produce + enqueue_task if AVAILABLE && message.present? && organization_id.present? + rescue => e + Rails.logger.error("Failed to produce email activity log: #{e.message}") + nil + end + + private + + attr_reader :document, :organization_id, :message, :user_id, :api_key_id, :error, :activity_id, :current_time + + def status + @status ||= if error + "failed" + elsif @resend + "resent" + else + "sent" + end + end + + def enqueue_task + payload = { + activity_source:, + api_key_id:, + user_id:, + activity_type: ACTIVITY_TYPE, + activity_id:, + logged_at: current_time, + created_at: current_time, + resource_id: resource.id, + resource_type: resource.class.name, + organization_id:, + activity_object:, + activity_object_changes: {}, + external_customer_id:, + external_subscription_id: nil + } + Karafka.producer.produce_async( + topic: TOPIC, + key: "#{organization_id}--#{activity_id}", + payload: payload.to_json + ) + end + + def activity_source + return "api" if api_key_id + return "front" if user_id + + "system" + end + + def activity_object + result = { + status:, + email: email_metadata.to_json, + document: document_reference.to_json + } + result[:error] = error_info.to_json if error + result + end + + def email_metadata + { + subject: message.subject, + to: Array(message.to), + cc: Array(message.cc), + bcc: Array(message.bcc), + body_preview: extract_body_preview + } + end + + def extract_body_preview + raw = ((message.text_part || message.html_part)&.body || message.body)&.decoded.to_s + sanitize(raw).truncate(BODY_PREVIEW_LENGTH) + end + + def sanitize(html) + spaced = html.gsub("<", " <") + Rails::Html::FullSanitizer.new.sanitize(spaced).to_s.gsub(/\s+/, " ").strip + end + + def document_reference + if document.present? + { + type: document.class.name, + number: document_number, + lago_id: document.id + } + end + end + + def document_number + case document + when Invoice, CreditNote, PaymentReceipt + document.number + end + end + + def error_info + { + class: error.class.name, + message: error.message + } + end + + def resource + document + end + + def external_customer_id + document&.customer&.external_id + end + end +end diff --git a/app/services/utils/entitlement.rb b/app/services/utils/entitlement.rb new file mode 100644 index 0000000..b9a7de2 --- /dev/null +++ b/app/services/utils/entitlement.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Utils + class Entitlement + def self.privilege_code_is_duplicated?(privileges_params) + return false if privileges_params.blank? + + seen = Set.new + privileges_params.any? { !seen.add?(it[:code]) } + end + + def self.cast_value(value, type) + return nil if value.nil? + + case type + when "integer" + value.to_i + when "boolean" + ActiveModel::Type::Boolean.new.cast(value) + else + value + end + end + + def self.same_value?(type, value1, value2) + cast_value(value1, type) == cast_value(value2, type) + end + + def self.convert_gql_input_to_params(entitlements) + Array.wrap(entitlements).map do |ent| + [ + ent.feature_code, + ent.privileges&.map { [it.privilege_code, it.value] }.to_h + ] + end.to_h + end + end +end diff --git a/app/services/utils/money_with_precision.rb b/app/services/utils/money_with_precision.rb new file mode 100644 index 0000000..a8d7343 --- /dev/null +++ b/app/services/utils/money_with_precision.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Utils + class MoneyWithPrecision < Money + self.default_infinite_precision = true + end +end diff --git a/app/services/utils/pdf_attachment_service.rb b/app/services/utils/pdf_attachment_service.rb new file mode 100644 index 0000000..d9d1c73 --- /dev/null +++ b/app/services/utils/pdf_attachment_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Utils + class PdfAttachmentService < BaseService + def initialize(file:, attachment:) + @file = file + @attachment = attachment + + super + end + + def call + return result.not_found_failure!(resource: "file") unless File.file?(file) + return result.not_allowed_failure!(code: "not_a_pdf_file") unless file.path.downcase.ends_with?(".pdf") + return result.not_found_failure!(resource: "attachment") unless File.file?(attachment) + + success = Kernel.system("pdfcpu", "attach", "add", file.path, attachment.path) + + if success + result.file = file + else + result.third_party_failure!(third_party: "pdfcpu", error_code: "failed", error_message: "") + end + + result + end + + private + + attr_reader :file, :attachment + end +end diff --git a/app/services/utils/pdf_generator.rb b/app/services/utils/pdf_generator.rb new file mode 100644 index 0000000..fe1ddd7 --- /dev/null +++ b/app/services/utils/pdf_generator.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Utils + class PdfGenerator < BaseService + include ActiveSupport::NumberHelper + + def initialize(template:, context:) + @template = template + @context = context + + super(nil) + end + + def call + result.io = StringIO.new(render_pdf) + result + end + + def render_html + Slim::Template.new(template_file).render(context) + end + + private + + attr_reader :template, :context + + def template_file + Rails.root.join("app/views/templates/#{template}.slim") + end + + def pdf_url + URI.join(ENV["LAGO_PDF_URL"], "/forms/chromium/convert/html").to_s + end + + def render_pdf + http_client = LagoHttpClient::Client.new(pdf_url, read_timeout: 300) + + response = http_client.post_multipart_file( + file1: prepare_http_files(render_html, "text/html", "index.html"), + file2: prepare_http_files( + File.read(Rails.root.join("public/assets/images/", SlimHelper::PDF_LOGO_FILENAME)), + "image/png", + SlimHelper::PDF_LOGO_FILENAME + ), + scale: "1.28", + marginTop: "0.42", + marginBottom: "0.42", + marginLeft: "0.42", + marginRight: "0.42" + ) + + response.body.force_encoding("UTF-8") + end + + def prepare_http_files(content, type, name) + UploadIO.new(StringIO.new(content), type, name) + end + end +end diff --git a/app/services/utils/security_log.rb b/app/services/utils/security_log.rb new file mode 100644 index 0000000..7684241 --- /dev/null +++ b/app/services/utils/security_log.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Utils + # Produces security log events to Kafka for ClickHouse consumption. + # + # Security logs track user and system configuration changes: + # user management, role changes, API key rotations, webhook configuration. + # + # Unlike Activity Logs, Security Logs: + # - Do not track customer/subscription data + # - Use flat resources map instead of polymorphic resource + # - Require per-org premium integration (not just global `License.premium?`) + # - Are collected ONLY for cloud Premium organizations + class SecurityLog + # Produces a security log event to Kafka. + # + # @param organization [Organization] the organization context + # @param log_type [String] event category (e.g. "user", or "api_key") + # @param log_event [String] specific event (e.g. "user.invited") + # @param user [User, nil] the user who performed the action (nil for API key operations) + # @param api_key [ApiKey, nil] the API key used for the action + # @param resources [Hash] additional context (e.g., {invitee_email: "..."}) + # @param device_info [Hash] device metadata for login events + # @return [Boolean] true if log was produced, false otherwise + def self.produce(...) + new(...).produce + end + + # Checks if security logging infrastructure is available. + # + # @return [Boolean] true if ClickHouse, Kafka and topic are configured + def self.available? + ENV["LAGO_CLICKHOUSE_ENABLED"].present? && + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"].present? && + topic.present? + end + + def self.topic + ENV["LAGO_KAFKA_SECURITY_LOGS_TOPIC"] + end + + def initialize( + organization:, + log_type:, + log_event:, + user: nil, + api_key: nil, + resources: nil, + device_info: nil, + skip_organization_check: false + ) + @organization = organization + @skip_organization_check = skip_organization_check + @log_type = log_type + @log_event = log_event + @user_id = resolve_user_id(user) + @api_key_id = api_key&.id + @resources = resources.to_h.stringify_keys + @device_info = (device_info || CurrentContext.device_info).to_h.stringify_keys + @current_time = Time.current.iso8601[...-1] + @log_id = SecureRandom.uuid + @key = "#{@organization.id}--#{@log_id}" + end + + def produce + return false unless self.class.available? + return false unless @skip_organization_check || @organization.security_logs_enabled? + + Karafka.producer.produce_async( + topic: self.class.topic, + key: @key, + payload: { + organization_id: @organization.id, + user_id: @user_id, + api_key_id: @api_key_id, + log_id: @log_id, + log_type: @log_type, + log_event: @log_event, + device_info: @device_info, + resources: @resources, + logged_at: @current_time, + created_at: @current_time + }.to_json + ) + + true + end + + private + + def resolve_user_id(user) + return user.id if user.present? + return if CurrentContext.api_key_id.present? + return if CurrentContext.membership.blank? + + Membership.find_by( + organization_id: @organization.id, + id: CurrentContext.membership.split("/").last + )&.user_id + end + end +end diff --git a/app/services/utils/segment_track.rb b/app/services/utils/segment_track.rb new file mode 100644 index 0000000..7394940 --- /dev/null +++ b/app/services/utils/segment_track.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Utils + class SegmentTrack + class << self + def invoice_created(invoice) + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + def refund_status_changed(status, credit_note_id, organization_id) + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: organization_id, + credit_note_id: credit_note_id, + refund_status: status + } + ) + end + end + end +end diff --git a/app/services/utils/timezone.rb b/app/services/utils/timezone.rb new file mode 100644 index 0000000..9a63641 --- /dev/null +++ b/app/services/utils/timezone.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Utils + class Timezone + def self.date_in_customer_timezone_sql(customer, value) + sanitized_field_name = if value.is_a?(String) + ActiveRecord::Base.sanitize_sql_for_conditions(value) + else + "'#{value}'" + end + sanitized_timezone = ActiveRecord::Base.sanitize_sql_for_conditions(customer.applicable_timezone) + + "(#{sanitized_field_name})::timestamptz AT TIME ZONE '#{sanitized_timezone}'" + end + + def self.at_time_zone_sql(customer: "customers", billing_entity: "billing_entities") + <<-SQL + ::timestamptz AT TIME ZONE COALESCE(#{customer}.timezone, #{billing_entity}.timezone, 'UTC') + SQL + end + end +end diff --git a/app/services/validators/decimal_amount_service.rb b/app/services/validators/decimal_amount_service.rb new file mode 100644 index 0000000..e4eee6f --- /dev/null +++ b/app/services/validators/decimal_amount_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Validators + class DecimalAmountService + def self.valid_amount?(amount) + new(amount).valid_amount? + end + + def self.valid_positive_amount?(amount) + new(amount).valid_positive_amount? + end + + def initialize(amount) + @amount = amount + end + + def valid_amount? + return false unless valid_decimal? + + decimal_amount.zero? || decimal_amount.positive? + end + + def valid_positive_amount? + return false unless valid_decimal? + + BigDecimal(amount).positive? + end + + def valid_decimal? + # NOTE: as we want to be the more precise with decimals, we only + # accept amount that are in string to avoid float bad parsing + # and use BigDecimal as a source of truth when computing amounts + return false unless amount.is_a?(String) + + @decimal_amount ||= BigDecimal(amount) + + decimal_amount.present? && + decimal_amount.finite? + # NOTE: If BigDecimal can't parse the amount, it will trigger + # an ArgumentError is the type is not a numeric, ei: 'foo' + # a TypeError is the amount is nil + rescue ArgumentError, TypeError + false + end + + private + + attr_reader :amount, :decimal_amount + end +end diff --git a/app/services/validators/expiration_date_validator.rb b/app/services/validators/expiration_date_validator.rb new file mode 100644 index 0000000..17ee0ee --- /dev/null +++ b/app/services/validators/expiration_date_validator.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Validators + module ExpirationDateValidator + def self.valid?(expiration_at) + return true if expiration_at.blank? + + Utils::Datetime.valid_format?(expiration_at, format: :any) && + Utils::Datetime.future_date?(expiration_at) + end + end +end diff --git a/app/services/validators/metadata_validator.rb b/app/services/validators/metadata_validator.rb new file mode 100644 index 0000000..599e975 --- /dev/null +++ b/app/services/validators/metadata_validator.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Validators + class MetadataValidator + DEFAULT_CONFIG = { + max_keys: 5, + max_key_length: 20, + max_value_length: 100 + }.freeze + + attr_reader :metadata, :errors, :config + + def initialize(metadata, config = {}) + @metadata = metadata || [] + @errors = {} + @config = DEFAULT_CONFIG.merge(config) + end + + def valid? + validate_type + return true if metadata.empty? && errors.empty? + + validate_size + metadata.each { |item| validate_item(item) } + + errors.empty? + end + + private + + def validate_type + errors[:metadata] = "invalid_type" unless metadata.is_a?(Array) + end + + def validate_size + errors[:metadata] = "too_many_keys" if metadata.size > config[:max_keys] + end + + def validate_item(item) + if item.is_a?(Array) || item.is_a?(String) || item.nil? + errors[:metadata] = "invalid_key_value_pair" + return + end + + item = item.to_h if item.respond_to?(:to_h) + return errors[:metadata] = "invalid_key_value_pair" unless item.is_a?(Hash) + + item = item.transform_keys(&:to_sym) + unless item.keys.sort == [:key, :value] && item[:key].present? && item[:value].present? + errors[:metadata] = "invalid_key_value_pair" + return + end + + validate_key_length(item[:key]) + validate_value_length(item[:value]) + validate_structure(item[:value]) + end + + def validate_key_length(key) + errors[:metadata] = "key_too_long" if key.length > config[:max_key_length] + end + + def validate_value_length(value) + errors[:metadata] = "value_too_long" if value.is_a?(String) && value.length > config[:max_value_length] + end + + def validate_structure(value) + errors[:metadata] = "nested_structure_not_allowed" if value.is_a?(Hash) || value.is_a?(Array) + end + end +end diff --git a/app/services/validators/range_bounds_validator.rb b/app/services/validators/range_bounds_validator.rb new file mode 100644 index 0000000..8f12be1 --- /dev/null +++ b/app/services/validators/range_bounds_validator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Validators + module RangeBoundsValidator + def valid_bounds?(range, index, next_from_value) + from = BigDecimal(range[:from_value].to_s) + next_from = BigDecimal(next_from_value.to_s) + valid_from = from == next_from || from == next_from + 1 + + valid_from && ( + index == (ranges.size - 1) && range[:to_value].nil? || + index < (ranges.size - 1) && BigDecimal((range[:to_value] || 0).to_s) > from + ) + end + end +end diff --git a/app/services/validators/wallet_transaction_amount_limits_validator.rb b/app/services/validators/wallet_transaction_amount_limits_validator.rb new file mode 100644 index 0000000..29fa5f1 --- /dev/null +++ b/app/services/validators/wallet_transaction_amount_limits_validator.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Validators + class WalletTransactionAmountLimitsValidator + def initialize(result, wallet:, credits_amount:, ignore_validation: false, field_name: :paid_credits) + @result = result + @wallet = wallet + # NOTE: credits_amount must be a string to be able to use ::Validators::DecimalAmountService + @credits_amount = credits_amount + @ignore_validation = ActiveModel::Type::Boolean.new.cast(ignore_validation) + @field_name = field_name + end + + def raise_if_invalid! + return if valid? + result.raise_if_error! # NOTE: if you didn't set the error in result, this won't raise if `valid?` is false + end + + def valid? + return false unless valid_paid_credits_amount? + return true if ignore_validation + return true if paid_top_up_min_amount_cents.blank? && paid_top_up_max_amount_cents.blank? + + wallet_credit = WalletCredit.new( + wallet:, + credit_amount: BigDecimal(credits_amount).floor(5) + ) + + if paid_top_up_min_amount_cents && wallet_credit.amount_cents < paid_top_up_min_amount_cents + result.single_validation_failure!(error_code: "amount_below_minimum", field: field_name) + elsif paid_top_up_max_amount_cents && wallet_credit.amount_cents > paid_top_up_max_amount_cents + result.single_validation_failure!(error_code: "amount_above_maximum", field: field_name) + end + + result.success? + end + + private + + attr_reader :result, :wallet, :credits_amount, :ignore_validation, :field_name + delegate :paid_top_up_min_amount_cents, :paid_top_up_max_amount_cents, to: :wallet + + def valid_paid_credits_amount? + return true if ::Validators::DecimalAmountService.new(credits_amount).valid_positive_amount? + + result.single_validation_failure!(error_code: "invalid_amount", field: field_name) + false + end + end +end diff --git a/app/services/wallet_transactions/create_from_params_service.rb b/app/services/wallet_transactions/create_from_params_service.rb new file mode 100644 index 0000000..a1c8a59 --- /dev/null +++ b/app/services/wallet_transactions/create_from_params_service.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +module WalletTransactions + class CreateFromParamsService < ::BaseService + MAX_WALLET_UPDATE_ATTEMPTS = 5 + + Result = BaseResult[:current_wallet, :wallet_transactions, :payment_method] + + def initialize(organization:, params:) + @organization = organization + @params = params + + @update_attempts = 0 + + super + end + + def call + @update_attempts += 1 + + # Normalize metadata + params[:metadata] = [] if params[:metadata] == {} + return result unless valid? # NOTE: validator sets result.current_wallet + + wallet_transactions = [] + @source = params[:source] || :manual + @metadata = params[:metadata] || [] + @priority = params[:priority] || 50 + invoice_requires_successful_payment = if params.key?(:invoice_requires_successful_payment) + ActiveModel::Type::Boolean.new.cast(params[:invoice_requires_successful_payment]) + end + invoice_requires_successful_payment = result.current_wallet.invoice_requires_successful_payment if invoice_requires_successful_payment.nil? + wallet = result.current_wallet + + ActiveRecord::Base.transaction do + if params[:paid_credits] + transaction = handle_paid_credits( + wallet:, + credits_amount: BigDecimal(params[:paid_credits]).floor(5), + invoice_requires_successful_payment: + ) + wallet_transactions << transaction + end + + if params[:granted_credits] + transaction = handle_granted_credits( + wallet:, + credits_amount: BigDecimal(params[:granted_credits]).floor(5), + reset_consumed_credits: ActiveModel::Type::Boolean.new.cast(params[:reset_consumed_credits]), + voided_invoice_id: params[:voided_invoice_id] + ) + wallet_transactions << transaction + end + + if params[:voided_credits] + wallet_transactions << handle_voided_credits(wallet) + end + + if params[:invoice_custom_section] + wallet_transactions.compact.each do |wt| + InvoiceCustomSections::AttachToResourceService.call(resource: wt, params:) + end + end + end + + transactions = wallet_transactions.compact + + transactions.each do |wt| + wt.reload + SendWebhookJob.perform_later("wallet_transaction.created", wt) + Utils::ActivityLog.produce(wt, "wallet_transaction.created") + end + + result.wallet_transactions = transactions + result + rescue BaseService::FailedResult + result + rescue ActiveRecord::StaleObjectError + if @update_attempts <= MAX_WALLET_UPDATE_ATTEMPTS + sleep(rand(0.1..0.5)) + result.current_wallet.reload # Make sure the wallet is reloaded before retrying + retry + end + + raise + end + + private + + attr_reader :organization, :params, :source, :metadata, :priority + + def name + params[:name].presence + end + + def handle_voided_credits(wallet) + credit_amount = BigDecimal(params[:voided_credits]).floor(5) + wallet_credit = WalletCredit.new(wallet:, credit_amount:, invoiceable: false) + void_params = params.to_h.symbolize_keys.slice(:metadata, :source, :priority).merge(name:) + WalletTransactions::VoidService.call!(wallet:, wallet_credit:, **void_params).wallet_transaction + end + + def handle_paid_credits(wallet:, credits_amount:, invoice_requires_successful_payment:) + return if credits_amount.zero? + + Validators::WalletTransactionAmountLimitsValidator.new( + result, + wallet:, + credits_amount: credits_amount.to_s, + ignore_validation: params[:ignore_paid_top_up_limits] + ).raise_if_invalid! + + wallet_credit = WalletCredit.new(wallet:, credit_amount: credits_amount) + wallet_transaction = WalletTransactions::CreateService.call!( + wallet:, + wallet_credit:, + transaction_type: :inbound, + status: :pending, + source:, + transaction_status: :purchased, + invoice_requires_successful_payment:, + metadata:, + priority:, + name:, + payment_method: params[:payment_method] + ).wallet_transaction + + BillPaidCreditJob.perform_after_commit(wallet_transaction, Time.current.to_i) + + wallet_transaction + end + + def handle_granted_credits(wallet:, credits_amount:, reset_consumed_credits: false, voided_invoice_id: nil) + return if credits_amount.zero? + + wallet_credit = WalletCredit.new(wallet:, credit_amount: credits_amount, invoiceable: false) + ActiveRecord::Base.transaction do + wallet_transaction = WalletTransactions::CreateService.call!( + wallet:, + wallet_credit:, + transaction_type: :inbound, + status: :settled, + settled_at: Time.current, + source:, + transaction_status: :granted, + metadata:, + priority:, + name:, + voided_invoice_id: + ).wallet_transaction + + Wallets::Balance::IncreaseService.new( + wallet:, + wallet_transaction:, + reset_consumed_credits: + ).call + + wallet_transaction + end + end + + def valid? + result.payment_method = payment_method + + validate_params = params.merge(organization: organization) + WalletTransactions::ValidateService.new(result, **validate_params).valid? + end + + def payment_method + return @payment_method if defined? @payment_method + return nil if params[:payment_method].blank? || params[:payment_method][:payment_method_id].blank? + + @payment_method = PaymentMethod.find_by(id: params[:payment_method][:payment_method_id], organization_id: organization.id) + end + end +end diff --git a/app/services/wallet_transactions/create_service.rb b/app/services/wallet_transactions/create_service.rb new file mode 100644 index 0000000..60e1f9a --- /dev/null +++ b/app/services/wallet_transactions/create_service.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module WalletTransactions + class CreateService < BaseService + Result = BaseResult[:wallet_transaction] + + def initialize(wallet:, wallet_credit:, **transaction_params) + @wallet = wallet + @wallet_credit = wallet_credit + @transaction_params = transaction_params + + super + end + + def call + transaction = wallet.wallet_transactions.create!( + **transaction_params.slice( + :credit_note_id, + :invoice_id, + :invoice_requires_successful_payment, + :name, + :priority, + :settled_at, + :source, + :status, + :transaction_type, + :transaction_status, + :voided_invoice_id + ), + organization_id: wallet.organization_id, + amount:, + credit_amount:, + metadata: transaction_params[:metadata] || [], + remaining_amount_cents: initial_remaining_amount_cents + ) + + if transaction_params[:payment_method].present? + transaction.payment_method_type = transaction_params[:payment_method][:payment_method_type] if transaction_params[:payment_method].key?(:payment_method_type) + transaction.payment_method_id = transaction_params[:payment_method][:payment_method_id] if transaction_params[:payment_method].key?(:payment_method_id) + transaction.save! + end + + result.wallet_transaction = transaction + + result + end + + private + + attr_reader :wallet, :wallet_credit, :transaction_params + + delegate :credit_amount, :amount, to: :wallet_credit + + def initial_remaining_amount_cents + return nil unless wallet.traceable? + return nil unless transaction_params[:transaction_type]&.to_sym == :inbound + return nil unless transaction_params[:transaction_status]&.to_sym == :granted + + wallet_credit.amount_cents + end + end +end diff --git a/app/services/wallet_transactions/mark_as_failed_service.rb b/app/services/wallet_transactions/mark_as_failed_service.rb new file mode 100644 index 0000000..dfc3a9a --- /dev/null +++ b/app/services/wallet_transactions/mark_as_failed_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module WalletTransactions + class MarkAsFailedService < BaseService + def initialize(wallet_transaction:) + @wallet_transaction = wallet_transaction + super + end + + activity_loggable( + action: "wallet_transaction.updated", + record: -> { wallet_transaction } + ) + + def call + return result unless wallet_transaction + return result if wallet_transaction.failed? + # note: if a wallet transaction is settled, but they mark payment as failed, they need to void credits manually + return result if wallet_transaction.settled? + + wallet_transaction.mark_as_failed! + after_commit { SendWebhookJob.perform_later("wallet_transaction.updated", wallet_transaction) } + + result.wallet_transaction = wallet_transaction + result + end + + private + + attr_reader :wallet_transaction + end +end diff --git a/app/services/wallet_transactions/payments/generate_payment_url_service.rb b/app/services/wallet_transactions/payments/generate_payment_url_service.rb new file mode 100644 index 0000000..46af38b --- /dev/null +++ b/app/services/wallet_transactions/payments/generate_payment_url_service.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module WalletTransactions + module Payments + class GeneratePaymentUrlService < BaseService + def initialize(wallet_transaction:) + @wallet_transaction = wallet_transaction + super + end + + def call + return result.not_found_failure!(resource: "wallet_transaction") unless wallet_transaction + + unless wallet_transaction.purchased? + return result.single_validation_failure!(error_code: "wallet_transaction_not_purchased") + end + + if wallet_transaction.settled? + return result.single_validation_failure!(error_code: "wallet_transaction_already_settled") + end + + unless invoice + return result.single_validation_failure!(error_code: "wallet_transaction_has_no_attached_invoice") + end + + ::Invoices::Payments::GeneratePaymentUrlService.call(invoice:) + end + + private + + attr_reader :wallet_transaction + + delegate :invoice, to: :wallet_transaction, allow_nil: true + end + end +end diff --git a/app/services/wallet_transactions/recredit_service.rb b/app/services/wallet_transactions/recredit_service.rb new file mode 100644 index 0000000..c2bcceb --- /dev/null +++ b/app/services/wallet_transactions/recredit_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module WalletTransactions + class RecreditService < BaseService + def initialize(wallet_transaction:) + @wallet_transaction = wallet_transaction + @wallet = wallet_transaction.wallet + @customer = @wallet.customer + + super + end + + def call + result.wallet_transaction = wallet_transaction + + return result.not_allowed_failure!(code: "wallet_not_active") unless wallet.active? + + transaction_result = WalletTransactions::CreateFromParamsService.call( + organization: customer.organization, + params: { + wallet_id: wallet.id, + granted_credits: wallet_transaction.credit_amount.to_s, + reset_consumed_credits: true, + voided_invoice_id: wallet_transaction.invoice_id + } + ) + + return transaction_result unless transaction_result.success? + + result + end + + private + + attr_reader :wallet_transaction, :wallet, :customer + end +end diff --git a/app/services/wallet_transactions/settle_service.rb b/app/services/wallet_transactions/settle_service.rb new file mode 100644 index 0000000..1f6699e --- /dev/null +++ b/app/services/wallet_transactions/settle_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module WalletTransactions + class SettleService < BaseService + def initialize(wallet_transaction:) + super(nil) + + @wallet_transaction = wallet_transaction + end + + activity_loggable( + action: "wallet_transaction.updated", + record: -> { wallet_transaction } + ) + + def call + updates = {status: :settled, settled_at: Time.current} + + if wallet_transaction.inbound? && wallet_transaction.wallet.traceable? + updates[:remaining_amount_cents] = wallet_transaction.amount_cents + end + + wallet_transaction.update!(updates) + after_commit { SendWebhookJob.perform_later("wallet_transaction.updated", wallet_transaction) } + + result.wallet_transaction = wallet_transaction + result + end + + private + + attr_reader :wallet_transaction + end +end diff --git a/app/services/wallet_transactions/track_consumption_service.rb b/app/services/wallet_transactions/track_consumption_service.rb new file mode 100644 index 0000000..4516da9 --- /dev/null +++ b/app/services/wallet_transactions/track_consumption_service.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module WalletTransactions + class TrackConsumptionService < BaseService + Result = BaseResult + + def initialize(outbound_wallet_transaction:, inbound_wallet_transaction_id: nil) + @outbound_wallet_transaction = outbound_wallet_transaction + @inbound_wallet_transaction_id = inbound_wallet_transaction_id + + super + end + + def call + ActiveRecord::Base.transaction do + if inbound_wallet_transaction_id.present? + consume_from_specific_inbound + else + consume_by_priority + end + end + + result + end + + private + + attr_reader :outbound_wallet_transaction, :inbound_wallet_transaction_id + + delegate :wallet, to: :outbound_wallet_transaction + + def consume_from_specific_inbound + inbound = wallet.wallet_transactions.inbound.find(inbound_wallet_transaction_id) + amount_cents = outbound_wallet_transaction.amount_cents + + if amount_cents > inbound.remaining_amount_cents + return result.single_validation_failure!( + field: :amount_cents, + error_code: "exceeds_remaining_transaction_amount" + ) + end + + create_consumption(inbound, amount_cents) + end + + def consume_by_priority + amount_cents = outbound_wallet_transaction.amount_cents + inbounds = available_inbounds.to_a + available_amount = inbounds.sum(&:remaining_amount_cents) + + if amount_cents > available_amount + return result.single_validation_failure!( + field: :amount_cents, + error_code: "exceeds_available_amount" + ) + end + + amount_left = amount_cents + + inbounds.each do |inbound| + break if amount_left <= 0 + + consume_amount = [inbound.remaining_amount_cents, amount_left].min + + create_consumption(inbound, consume_amount) + amount_left -= consume_amount + end + end + + def create_consumption(inbound, amount_cents) + WalletTransactionConsumption.create!( + organization: wallet.organization, + inbound_wallet_transaction: inbound, + outbound_wallet_transaction: outbound_wallet_transaction, + consumed_amount_cents: amount_cents + ) + + # this raises a DB error if the remaining_amount_cents goes below zero + inbound.decrement!(:remaining_amount_cents, amount_cents) # rubocop:disable Rails/SkipsModelValidations + end + + def available_inbounds + wallet.wallet_transactions.available_inbound.in_consumption_order + end + end +end diff --git a/app/services/wallet_transactions/validate_service.rb b/app/services/wallet_transactions/validate_service.rb new file mode 100644 index 0000000..30ab280 --- /dev/null +++ b/app/services/wallet_transactions/validate_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module WalletTransactions + class ValidateService < BaseValidator + def valid? + valid_wallet? + valid_paid_credits_amount? if args[:paid_credits] + valid_granted_credits_amount? if args[:granted_credits] + valid_voided_credits_amount? if args[:voided_credits] && result.current_wallet + valid_metadata? if args[:metadata] + valid_name? if args[:name] + valid_payment_method? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + MAX_AMOUNT = 10**25 - 1 + private_constant :MAX_AMOUNT + + def valid_amount?(amount) + ::Validators::DecimalAmountService.new(amount).valid_amount? && + BigDecimal(amount).between?(0, MAX_AMOUNT) + end + + def valid_wallet? + scope = args[:customer].presence || args[:organization].presence || Organization.find_by(id: args[:organization_id]) + + result.current_wallet = scope.wallets.find_by(id: args[:wallet_id]) + + return add_error(field: :wallet_id, error_code: "wallet_not_found") unless result.current_wallet + return add_error(field: :wallet_id, error_code: "wallet_is_terminated") if result.current_wallet.terminated? + + true + end + + def valid_paid_credits_amount? + return true if valid_amount?(args[:paid_credits]) + + add_error(field: :paid_credits, error_code: "invalid_paid_credits") + add_error(field: :paid_credits, error_code: "invalid_amount") + end + + def valid_granted_credits_amount? + return true if valid_amount?(args[:granted_credits]) + + add_error(field: :granted_credits, error_code: "invalid_granted_credits") + add_error(field: :granted_credits, error_code: "invalid_amount") + end + + def valid_voided_credits_amount? + voided_credits = args[:voided_credits] + unless valid_amount?(voided_credits) + add_error(field: :voided_credits, error_code: "invalid_voided_credits") + add_error(field: :voided_credits, error_code: "invalid_amount") + return false + end + + if BigDecimal(voided_credits) > result.current_wallet.credits_balance + return add_error(field: :voided_credits, error_code: "insufficient_credits") + end + + true + end + + def valid_metadata? + validator = ::Validators::MetadataValidator.new(args[:metadata]) + unless validator.valid? + validator.errors.each do |field, error_code| + add_error(field: field, error_code: error_code) + end + return false + end + + true + end + + def valid_name? + name = args[:name] + + return true if name.blank? + + if !name.is_a?(String) + add_error(field: :name, error_code: "invalid_value") + return false + end + + if name.length > 255 + add_error(field: :name, error_code: "too_long") + return false + end + + false + end + + def valid_payment_method? + return true if args[:payment_method].blank? + return true if PaymentMethods::ValidateService.new(result, **args).valid? + + add_error(field: :payment_method, error_code: "invalid_payment_method") + + false + end + end +end diff --git a/app/services/wallet_transactions/void_service.rb b/app/services/wallet_transactions/void_service.rb new file mode 100644 index 0000000..dce251b --- /dev/null +++ b/app/services/wallet_transactions/void_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module WalletTransactions + class VoidService < BaseService + Result = BaseResult[:wallet_transaction] + + def initialize(wallet:, wallet_credit:, inbound_wallet_transaction: nil, **transaction_params) + @wallet = wallet + @wallet_credit = wallet_credit + @inbound_wallet_transaction = inbound_wallet_transaction + @transaction_params = transaction_params.slice( + :source, + :metadata, + :priority, + :credit_note_id, + :name + ) + + super + end + + def call + return result if wallet_credit.credit_amount.zero? + return result unless valid? + + ActiveRecord::Base.transaction do + Customers::LockService.call(customer:, scope: :prepaid_credit) do + wallet.reload + wallet_transaction = CreateService.call!( + wallet:, + wallet_credit:, + transaction_type: :outbound, + status: :settled, + settled_at: Time.current, + transaction_status: :voided, + **transaction_params + ).wallet_transaction + + if wallet.traceable? + TrackConsumptionService.call!( + outbound_wallet_transaction: wallet_transaction, + inbound_wallet_transaction_id: inbound_wallet_transaction&.id + ) + end + + Wallets::Balance::DecreaseService.new(wallet:, wallet_transaction:).call + result.wallet_transaction = wallet_transaction + end + end + + result + end + + private + + attr_reader :wallet, :wallet_credit, :inbound_wallet_transaction, :transaction_params + delegate :customer, to: :wallet + + def valid? + return true unless wallet.traceable? + return true unless inbound_wallet_transaction + + if wallet_credit.amount_cents > inbound_wallet_transaction.remaining_amount_cents + result.single_validation_failure!( + field: :amount_cents, + error_code: "exceeds_remaining_transaction_amount" + ) + return false + end + + true + end + end +end diff --git a/app/services/wallets/apply_paid_credits_service.rb b/app/services/wallets/apply_paid_credits_service.rb new file mode 100644 index 0000000..98233d5 --- /dev/null +++ b/app/services/wallets/apply_paid_credits_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Wallets + class ApplyPaidCreditsService < BaseService + Result = BaseResult[:wallet_transaction] + + def initialize(wallet_transaction:) + @wallet_transaction = wallet_transaction + super + end + + def call + return result unless wallet_transaction + return result if wallet_transaction.status == "settled" + + ActiveRecord::Base.transaction do + WalletTransactions::SettleService.new(wallet_transaction:).call + Wallets::Balance::IncreaseService + .new(wallet: wallet_transaction.wallet, wallet_transaction: wallet_transaction).call + end + + result.wallet_transaction = wallet_transaction + result + end + + private + + attr_reader :wallet_transaction + end +end diff --git a/app/services/wallets/balance/decrease_service.rb b/app/services/wallets/balance/decrease_service.rb new file mode 100644 index 0000000..718c638 --- /dev/null +++ b/app/services/wallets/balance/decrease_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Wallets + module Balance + class DecreaseService < BaseService + Result = BaseResult[:wallet] + + def initialize(wallet:, wallet_transaction:, skip_refresh: false) + @wallet = wallet.reload + @wallet_transaction = wallet_transaction + @skip_refresh = skip_refresh + + super + end + + def call + transaction_credits_amount = wallet_transaction.credit_amount + currency = wallet.currency_for_balance + + wallet.update!( + balance_cents: wallet.balance_cents - wallet_transaction.amount_cents, + credits_balance: wallet.credits_balance - transaction_credits_amount, + last_balance_sync_at: Time.zone.now, + consumed_credits: wallet.consumed_credits + transaction_credits_amount, + consumed_amount_cents: ((wallet.consumed_credits + transaction_credits_amount) * wallet.rate_amount * currency.subunit_to_unit).floor, + last_consumed_credit_at: Time.current + ) + + unless skip_refresh + Customers::RefreshWalletsService.call( + customer: wallet.customer, + include_generating_invoices: true + ) + end + + after_commit do + SendWebhookJob.perform_later("wallet.updated", wallet) + UsageMonitoring::ProcessWalletAlertsJob.perform_later(wallet) + end + + result.wallet = wallet + result + end + + private + + attr_reader :wallet, :wallet_transaction, :skip_refresh + end + end +end diff --git a/app/services/wallets/balance/increase_service.rb b/app/services/wallets/balance/increase_service.rb new file mode 100644 index 0000000..a6f9efa --- /dev/null +++ b/app/services/wallets/balance/increase_service.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module Wallets + module Balance + class IncreaseService < BaseService + Result = BaseResult[:wallet] + + def initialize(wallet:, wallet_transaction:, reset_consumed_credits: false) + super + + @wallet = wallet + @wallet_transaction = wallet_transaction + @reset_consumed_credits = reset_consumed_credits + end + + def call + transaction_credits_amount = wallet_transaction.credit_amount + transaction_amount_cents = wallet_transaction.amount_cents + + currency = wallet.currency_for_balance + update_params = { + balance_cents: wallet.balance_cents + transaction_amount_cents, + credits_balance: wallet.credits_balance + transaction_credits_amount, + last_balance_sync_at: Time.current + } + + if reset_consumed_credits + update_params[:consumed_credits] = [0.0, wallet.consumed_credits - transaction_credits_amount].max + update_params[:consumed_amount_cents] = [0, ((wallet.consumed_credits - transaction_credits_amount) * wallet.rate_amount * currency.subunit_to_unit).floor].max + end + + wallet.update!(update_params) + + # we only need to update all wallets when there is usage applied. In case we're increasing balance of only one wallet, + # only this wallet will be affected and needs recalculation + Customers::RefreshWalletsService.call(customer: wallet.customer, target_wallet_ids: [wallet.id]) + + after_commit do + SendWebhookJob.perform_later("wallet.updated", wallet) + UsageMonitoring::ProcessWalletAlertsJob.perform_later(wallet) + end + + result.wallet = wallet + result + end + + private + + attr_reader :wallet, :wallet_transaction, :reset_consumed_credits + end + end +end diff --git a/app/services/wallets/balance/refresh_ongoing_usage_service.rb b/app/services/wallets/balance/refresh_ongoing_usage_service.rb new file mode 100644 index 0000000..539add8 --- /dev/null +++ b/app/services/wallets/balance/refresh_ongoing_usage_service.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +module Wallets + module Balance + class RefreshOngoingUsageService < BaseService + Result = BaseResult[:wallet] + + def initialize(wallet:, usage_amount_cents:, current_usage_fees:, draft_invoices_fees:, progressive_billing_fees:, pay_in_advance_fees:, skip_single_wallet_update: false) + @wallet = wallet + @usage_amount_cents = usage_amount_cents + @skip_single_wallet_update = skip_single_wallet_update + @current_usage_fees = current_usage_fees + @draft_invoices_fees = draft_invoices_fees + @progressive_billing_fees = progressive_billing_fees + @pay_in_advance_fees = pay_in_advance_fees + + super + end + + def call + @total_usage_amount_cents = calculate_total_usage_with_limitation + @total_billed_usage_amount_cents = calculate_total_billed_usage_amount_cents + + # Before this service is called, the wallet is already loaded in the memory. If while calculating current usage we received + # a pay_in_advance_fee, wallet will be updated by Wallets::Balance::DecreaseService and current wallet version will throw an + # `Attempted to update a stale object` error. To avoid this, we reload the wallet before updating it. + wallet.reload + update_params = wallet_update_params + + Wallets::Balance::UpdateOngoingService.call(wallet:, update_params:, skip_single_wallet_update:).raise_if_error! + + result.wallet = wallet + result + end + + private + + attr_reader :wallet, :total_usage_amount_cents, :total_billed_usage_amount_cents, :usage_amount_cents, :skip_single_wallet_update, + :current_usage_fees, :draft_invoices_fees, :progressive_billing_fees, :pay_in_advance_fees + + delegate :customer, to: :wallet + + def calculate_total_billed_usage_amount_cents + billed_progressive_invoices_amount_cents + + billed_pay_in_advance_amount_cents + end + + def billed_progressive_invoices_amount_cents + progressive_billing_fees.sum do |fee| + fee.taxes_amount_cents + fee.sub_total_excluding_taxes_amount_cents + end + end + + def draft_invoices_total_amount_cents + draft_invoices_fees.sum do |fee| + fee.amount_cents + fee.taxes_amount_cents - fee.precise_coupons_amount_cents + end + end + + def billed_pay_in_advance_amount_cents + # Invoice that is returned from CustomerUsageService includes the taxes in total_usage + # so if the fees ae already paid, we should exclude fees AND their taxes + pay_in_advance_fees.sum { |fee| fee.amount_cents + fee.taxes_amount_cents } + end + + def calculate_total_usage_with_limitation + current_usage_fees.sum { |fee| fee.amount_cents + fee.taxes_amount_cents } + end + + def wallet_update_params + params = { + ongoing_usage_balance_cents:, + credits_ongoing_usage_balance:, + ongoing_balance_cents:, + credits_ongoing_balance: + } + + if !wallet.depleted_ongoing_balance? && ongoing_balance_cents <= 0 + params[:depleted_ongoing_balance] = true + elsif wallet.depleted_ongoing_balance? && ongoing_balance_cents.positive? + params[:depleted_ongoing_balance] = false + end + + params + end + + def currency + @currency ||= wallet.ongoing_balance.currency + end + + def ongoing_usage_balance_cents + @ongoing_usage_balance_cents ||= total_usage_amount_cents + + draft_invoices_total_amount_cents - + total_billed_usage_amount_cents + end + + def credits_ongoing_usage_balance + ongoing_usage_balance_cents.to_f.fdiv(currency.subunit_to_unit).fdiv(wallet.rate_amount) + end + + def ongoing_balance_cents + @ongoing_balance_cents ||= wallet.balance_cents - ongoing_usage_balance_cents + end + + def credits_ongoing_balance + ongoing_balance_cents.to_f.fdiv(currency.subunit_to_unit).fdiv(wallet.rate_amount) + end + end + end +end diff --git a/app/services/wallets/balance/update_ongoing_service.rb b/app/services/wallets/balance/update_ongoing_service.rb new file mode 100644 index 0000000..57bbc29 --- /dev/null +++ b/app/services/wallets/balance/update_ongoing_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Wallets + module Balance + class UpdateOngoingService < BaseService + Result = BaseResult[:wallet] + + def initialize(wallet:, update_params:, skip_single_wallet_update: false) + super + + @wallet = wallet + update_params[:last_ongoing_balance_sync_at] = Time.current unless skip_single_wallet_update + @update_params = update_params + end + + def call + wallet.update!(update_params) + + after_commit do + if update_params[:depleted_ongoing_balance] == true + SendWebhookJob.perform_later("wallet.depleted_ongoing_balance", wallet) + end + + ::Wallets::ThresholdTopUpService.call(wallet:) + UsageMonitoring::ProcessWalletAlertsJob.perform_later(wallet) + end + + result.wallet = wallet + result + end + + private + + attr_reader :wallet, :update_params + end + end +end diff --git a/app/services/wallets/build_allocation_rules_service.rb b/app/services/wallets/build_allocation_rules_service.rb new file mode 100644 index 0000000..e917200 --- /dev/null +++ b/app/services/wallets/build_allocation_rules_service.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +# app/services/wallets/build_allocation_rules_service.rb +module Wallets + class BuildAllocationRulesService < BaseService + Result = BaseResult[:allocation_rules] + + def initialize(customer:) + @customer = customer + super + end + + ## + # Build allocation hash by priority of all the wallets + # if wallet is in the first position of the hash means is the one with most priority + # example of the result + # ( we store the wallets ids and billable metric ids, used names on the example to easy reading) + # + # wallet1: + # unrestricted + # wallet2: + # targets: bm1 + # wallet3: + # type: charges + # wallet4: + # unrestricted + # wallet5: + # targets: bm2 + # wallet6: + # type: charges + + # bm_map = bm1: [wallet1 ,wallet2 wallet3, wallet4, wallet6] + # bm2: [wallet1, wallet3, wallet4, wallet5, wallet6] + # type_map = charges: [wallet1, wallet3, wallet4 wallet6] + # unrestricted = [wallet1, wallet4] + def call + bm_map = Hash.new { |h, k| h[k] = [] } + type_map = Hash.new { |h, k| h[k] = [] } + unrestricted = [] + + wallets.each do |wallet| + if wallet.wallet_targets.any? + handle_billable_metric_wallet(wallet, bm_map, type_map, unrestricted) + elsif wallet.allowed_fee_types.present? + handle_fee_type_wallet(wallet, bm_map, type_map, unrestricted) + else + handle_unrestricted_wallet(wallet, bm_map, type_map, unrestricted) + end + end + + result.allocation_rules = {bm_map:, type_map:, unrestricted:} + result + end + + private + + attr_reader :customer + + def add_unique(array, id) + array << id unless array.include?(id) + end + + # Add multiple items uniquely to target array + def add_all_unique(target_array, source_array) + source_array.each { |id| add_unique(target_array, id) } + end + + def wallets + @wallets ||= customer.wallets.active.in_application_order + .includes(wallet_targets: :billable_metric) + end + + def handle_billable_metric_wallet(wallet, bm_map, type_map, unrestricted) + wallet.wallet_targets.each do |wallet_target| + metric_wallets = bm_map[wallet_target.billable_metric_id] + + # add what we already can have from the higher priority arrays + add_all_unique(metric_wallets, type_map["charge"]) + add_all_unique(metric_wallets, unrestricted) + add_unique(metric_wallets, wallet.id) + end + end + + def handle_fee_type_wallet(wallet, bm_map, type_map, unrestricted) + Array(wallet.allowed_fee_types).each do |fee_type| + fee_type_wallets = type_map[fee_type] + # add what we already can have from the higher priority arrays + add_all_unique(fee_type_wallets, unrestricted) + add_unique(fee_type_wallets, wallet.id) + + # Charge fee types also apply to all billable metric mappings + bm_map.each_value { |wallets| add_unique(wallets, wallet.id) } if fee_type == "charge" + end + end + + def handle_unrestricted_wallet(wallet, bm_map, type_map, unrestricted) + # Unrestricted wallets apply to everything + add_unique(unrestricted, wallet.id) + bm_map.each_value { |wallets| add_unique(wallets, wallet.id) } + type_map.each_value { |wallets| add_unique(wallets, wallet.id) } + end + end +end diff --git a/app/services/wallets/create_interval_wallet_transactions_service.rb b/app/services/wallets/create_interval_wallet_transactions_service.rb new file mode 100644 index 0000000..b808f50 --- /dev/null +++ b/app/services/wallets/create_interval_wallet_transactions_service.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +module Wallets + class CreateIntervalWalletTransactionsService < BaseService + Result = BaseResult + + def call + recurring_transaction_rules.each do |rule| + paid_credits = rule.compute_paid_credits(ongoing_balance: rule.wallet.credits_ongoing_balance) + granted_credits = rule.compute_granted_credits + + next if rule.target? && paid_credits.zero? && granted_credits.zero? + + WalletTransactions::CreateJob.perform_later( + organization_id: rule.wallet.organization.id, + params: { + wallet_id: rule.wallet.id, + paid_credits: paid_credits.to_s, + granted_credits: granted_credits.to_s, + source: :interval, + invoice_requires_successful_payment: rule.invoice_requires_successful_payment?, + metadata: rule.transaction_metadata, + name: rule.transaction_name + } + ) + end + + result + end + + private + + def today + @today ||= Time.current + end + + # NOTE: Retrieve list of recurring_transaction_rules that should create wallet transactions today + def recurring_transaction_rules + sql = <<-SQL + WITH + pending_recurring_rules AS ( + -- Anniversary rules + (#{weekly_anniversary}) + UNION + (#{monthly_anniversary}) + UNION + (#{quarterly_anniversary}) + UNION + (#{semiannual_anniversary}) + UNION + (#{yearly_anniversary}) + ), + -- Filter wallets which rules are already applied today (in customer's applicable timezone) + already_applied_today AS (#{already_applied_today}) + + SELECT DISTINCT(recurring_transaction_rules.*) + FROM recurring_transaction_rules + INNER JOIN pending_recurring_rules ON pending_recurring_rules.rule_id = recurring_transaction_rules.id + INNER JOIN wallets ON wallets.id = recurring_transaction_rules.wallet_id + INNER JOIN customers ON customers.id = wallets.customer_id + INNER JOIN billing_entities ON billing_entities.id = customers.billing_entity_id + LEFT JOIN already_applied_today ON already_applied_today.wallet_id = wallets.id + WHERE + -- Exclude top-ups already applied today + already_applied_today.top_up_count IS NULL + -- Do not take into account wallets that are created today + AND DATE(wallets.created_at#{at_time_zone}) != DATE(:today#{at_time_zone}) + GROUP BY recurring_transaction_rules.id + SQL + + RecurringTransactionRule.find_by_sql([sql, {today:}]) + end + + def base_recurring_transaction_rule_scope(interval: nil, conditions: nil) + <<-SQL + SELECT recurring_transaction_rules.id AS rule_id + FROM recurring_transaction_rules + INNER JOIN wallets ON wallets.id = recurring_transaction_rules.wallet_id + INNER JOIN customers ON customers.id = wallets.customer_id + INNER JOIN billing_entities ON billing_entities.id = customers.billing_entity_id + WHERE wallets.status = #{Wallet.statuses[:active]} + AND recurring_transaction_rules.status = #{RecurringTransactionRule.statuses[:active]} + AND recurring_transaction_rules.trigger = #{RecurringTransactionRule.triggers[:interval]} + AND recurring_transaction_rules.interval = #{RecurringTransactionRule.intervals[interval]} + AND #{wallet_started_at} <= :today + AND (recurring_transaction_rules.expiration_at IS NULL + OR recurring_transaction_rules.expiration_at > '#{Time.current.utc.strftime("%Y-%m-%d %H:%M:%S")}') + AND #{conditions.join(" AND ")} + GROUP BY recurring_transaction_rules.id + SQL + end + + def weekly_anniversary + base_recurring_transaction_rule_scope( + interval: :weekly, + conditions: [ + "EXTRACT(ISODOW FROM (#{wallet_started_at})) = + EXTRACT(ISODOW FROM (:today#{at_time_zone}))" + ] + ) + end + + def monthly_anniversary + base_recurring_transaction_rule_scope( + interval: :monthly, + conditions: [<<-SQL] + DATE_PART('day', (#{wallet_started_at})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + ) + end + + # NOTE: Billed quarterly on anniversary date + def quarterly_anniversary + billing_day = <<-SQL + DATE_PART('day', (#{wallet_started_at})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + billing_month = <<-SQL + ( + -- We need to avoid zero and instead of it use 12. E.g.: (3 + 9) % 12 = 0 -> 12 + CASE WHEN MOD(CAST(DATE_PART('month', (#{wallet_started_at})) AS INTEGER), 3) = 0 + THEN + (DATE_PART('month', :today#{at_time_zone}) IN (3, 6, 9, 12)) + ELSE ( + DATE_PART('month', (#{wallet_started_at})) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (#{wallet_started_at})) + 3 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (#{wallet_started_at})) + 6 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (#{wallet_started_at})) + 9 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + ) + END + ) + SQL + + base_recurring_transaction_rule_scope( + interval: :quarterly, + conditions: [billing_month, billing_day] + ) + end + + # NOTE: Billed semiannually on anniversary date + def semiannual_anniversary + billing_day = <<-SQL + DATE_PART('day', (#{wallet_started_at})) = ANY ( + -- Check if today is the last day of the month + CASE WHEN DATE_PART('day', (#{end_of_month})) = DATE_PART('day', :today#{at_time_zone}) + THEN + -- If so and if it counts less than 31 days, we need to take all days up to 31 into account + (SELECT ARRAY(SELECT generate_series(DATE_PART('day', :today#{at_time_zone})::integer, 31))) + ELSE + -- Otherwise, we just need the current day + (SELECT ARRAY[DATE_PART('day', :today#{at_time_zone})]) + END + ) + SQL + + billing_month = <<-SQL + ( + -- We need to avoid zero and instead of it use 12. E.g.: (3 + 9) % 12 = 0 -> 12 + CASE WHEN MOD(CAST(DATE_PART('month', (#{wallet_started_at})) AS INTEGER), 6) = 0 + THEN + (DATE_PART('month', :today#{at_time_zone}) IN (6, 12)) + ELSE ( + DATE_PART('month', (#{wallet_started_at})) = DATE_PART('month', :today#{at_time_zone}) + OR MOD(CAST(DATE_PART('month', (#{wallet_started_at})) + 6 AS INTEGER), 12) = DATE_PART('month', :today#{at_time_zone}) + ) + END + ) + SQL + + base_recurring_transaction_rule_scope( + interval: :semiannual, + conditions: [billing_month, billing_day] + ) + end + + def yearly_anniversary + billing_month = <<-SQL + -- Ensure we are on the billing month + DATE_PART('month', (#{wallet_started_at})) = DATE_PART('month', :today#{at_time_zone}) + SQL + + billing_day = <<-SQL + -- Check if we are not in a leap year when today is february the 28th + DATE_PART('day', (#{wallet_started_at})) = ANY ( + CASE WHEN ( + DATE_PART('month', :today#{at_time_zone}) = 2 + AND DATE_PART('day', :today#{at_time_zone}) = 28 + AND DATE_PART('day', (#{end_of_month})) = 28 + ) + THEN + -- If not a leap year, we have to tale february the 29th into account + ARRAY[28, 29] + ELSE + -- Otherwise, we just need the current day + ARRAY[DATE_PART('day', :today#{at_time_zone})] + END + ) + SQL + + base_recurring_transaction_rule_scope( + interval: :yearly, + conditions: [billing_month, billing_day] + ) + end + + def end_of_month + <<-SQL + (DATE_TRUNC('month', :today#{at_time_zone}) + INTERVAL '1 month - 1 day')::date + SQL + end + + def wallet_started_at + <<-SQL + COALESCE( + recurring_transaction_rules.started_at#{at_time_zone}, + wallets.created_at#{at_time_zone} + ) + SQL + end + + def already_applied_today + <<-SQL + SELECT + wallet_transactions.wallet_id, + COUNT(wallet_transactions.id) AS top_up_count + FROM wallet_transactions + INNER JOIN wallets AS wal ON wallet_transactions.wallet_id = wal.id + INNER JOIN customers AS cus ON wal.customer_id = cus.id + INNER JOIN billing_entities ON cus.billing_entity_id = billing_entities.id + WHERE wallet_transactions.source = #{WalletTransaction.sources[:interval]} + AND wallet_transactions.transaction_type = #{WalletTransaction.transaction_types[:inbound]} + AND DATE( + (wallet_transactions.created_at)#{at_time_zone(customer: "cus", billing_entity: "billing_entities")} + ) = DATE(:today#{at_time_zone(customer: "cus", billing_entity: "billing_entities")}) + GROUP BY wallet_transactions.wallet_id + SQL + end + end +end diff --git a/app/services/wallets/create_service.rb b/app/services/wallets/create_service.rb new file mode 100644 index 0000000..cbdf46f --- /dev/null +++ b/app/services/wallets/create_service.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +module Wallets + class CreateService < BaseService + Result = BaseResult[:wallet, :billable_metric_identifiers, :billable_metrics, :payment_method] + + def initialize(params:) + @params = params + super + end + + activity_loggable( + action: "wallet.created", + record: -> { result.wallet } + ) + + def call + result.billable_metric_identifiers = billable_metric_identifiers + result.billable_metrics = billable_metrics + result.payment_method = payment_method + + return result unless valid? + + code = params[:code] + + # only adjust generated code if it's taken + if code.blank? + code = params[:name].to_s.parameterize(separator: "_").presence || "default" + code_taken = Wallet.where(organization_id:, customer_id: customer.id, status: "active", code: code).exists? + code += "_#{Time.current.to_i}" if code_taken + end + + attributes = { + organization_id:, + customer_id: customer.id, + name: params[:name], + code: code, + rate_amount: params[:rate_amount], + expiration_at: params[:expiration_at], + status: :active, + paid_top_up_min_amount_cents: params[:paid_top_up_min_amount_cents], + paid_top_up_max_amount_cents: params[:paid_top_up_max_amount_cents], + traceable: traceable? + } + + attributes[:priority] = params[:priority] if params[:priority] + + if params.key?(:invoice_requires_successful_payment) + attributes[:invoice_requires_successful_payment] = ActiveModel::Type::Boolean.new.cast(params[:invoice_requires_successful_payment]) || false + end + + if params.key?(:applies_to) + attributes[:allowed_fee_types] = params[:applies_to][:fee_types] if params[:applies_to].key?(:fee_types) + end + + if params.key?(:payment_method) + attributes[:payment_method_type] = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) + attributes[:payment_method_id] = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) + end + + if organization_flag_enabled?(:multi_entity_billing) && (params[:billing_entity_id].present? || params[:billing_entity_code].present?) + return result.not_found_failure!(resource: "billing_entity") unless billing_entity + + attributes[:billing_entity_id] = billing_entity.id + end + + wallet = Wallet.new(attributes) + + ActiveRecord::Base.transaction do + if currency.present? && (!organization_flag_enabled?(:multi_currency) || customer.currency.blank?) + Customers::UpdateCurrencyService.call!(customer: customer, currency:) + end + + wallet.currency = organization_flag_enabled?(:multi_currency) ? (currency || wallet.customer.currency) : wallet.customer.currency + wallet.save! + + validate_wallet_initial_amount! wallet + + if params[:recurring_transaction_rules].present? + Wallets::RecurringTransactionRules::CreateService.call!(wallet:, wallet_params: params) + end + + if params[:invoice_custom_section].present? + InvoiceCustomSections::AttachToResourceService.call(resource: wallet, params:) + end + + billable_metrics.each do |bm| + WalletTarget.create!(wallet:, billable_metric: bm, organization_id:) + end + + create_metadata(wallet, params[:metadata]) if !params[:metadata].nil? + + customer.flag_wallets_for_refresh + end + + result.wallet = wallet + + SendWebhookJob.perform_after_commit("wallet.created", wallet) + + schedule_top_up(wallet) + + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :params + + def schedule_top_up(wallet) + return unless positive_amount?(paid_credits) || positive_amount?(granted_credits) + + WalletTransactions::CreateJob.perform_after_commit( + organization_id:, + params: { + wallet_id: wallet.id, + paid_credits: paid_credits, + granted_credits: granted_credits, + source: :manual, + metadata: params[:transaction_metadata], + name: params[:transaction_name], + priority: params[:transaction_priority], + ignore_paid_top_up_limits: params[:ignore_paid_top_up_limits_on_creation] + } + ) + end + + def positive_amount?(amount) + amount && BigDecimal(amount).positive? + end + + def paid_credits + params[:paid_credits] + end + + def granted_credits + params[:granted_credits] + end + + def customer + params[:customer] + end + + def organization_id + params[:organization_id] + end + + def currency + params[:currency] + end + + def organization_flag_enabled?(flag) + customer.organization.feature_flag_enabled?(flag) + end + + def valid? + Wallets::ValidateService.new(result, **params).valid? + end + + def validate_wallet_initial_amount!(wallet) + return unless positive_paid_credit_amount? + + Validators::WalletTransactionAmountLimitsValidator.new( + result, + wallet:, + credits_amount: paid_credits, + ignore_validation: params[:ignore_paid_top_up_limits_on_creation] + ).raise_if_invalid! + end + + def positive_paid_credit_amount? + BigDecimal(paid_credits).positive? + rescue ArgumentError, TypeError + false + end + + def billable_metric_identifiers + return [] if params[:applies_to].blank? + + key = api_context? ? :billable_metric_codes : :billable_metric_ids + + return [] if params[:applies_to][key].blank? + + params[:applies_to][key]&.compact&.uniq + end + + def billable_metrics + return @billable_metrics if defined?(@billable_metrics) + return [] if billable_metric_identifiers.blank? + + @billable_metrics = if api_context? + BillableMetric.where(code: billable_metric_identifiers, organization_id:) + else + BillableMetric.where(id: billable_metric_identifiers, organization_id:) + end + end + + def payment_method + return @payment_method if defined? @payment_method + return nil if params[:payment_method].blank? || params[:payment_method][:payment_method_id].blank? + + @payment_method = PaymentMethod.find_by(id: params[:payment_method][:payment_method_id], organization_id:) + end + + def billing_entity + return @billing_entity if defined? @billing_entity + + scope = customer.organization.billing_entities + @billing_entity = if params[:billing_entity_id].present? + scope.find_by(id: params[:billing_entity_id]) + elsif params[:billing_entity_code].present? + scope.find_by(code: params[:billing_entity_code]) + end + end + + def create_metadata(wallet, metadata_value) + Metadata::UpdateItemService.new( + owner: wallet, + value: metadata_value, + partial: false + ).call + end + + def traceable? + customer.wallets.active.where(traceable: false).none? + end + end +end diff --git a/app/services/wallets/find_applicable_on_fees_service.rb b/app/services/wallets/find_applicable_on_fees_service.rb new file mode 100644 index 0000000..f495aee --- /dev/null +++ b/app/services/wallets/find_applicable_on_fees_service.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +module Wallets + class FindApplicableOnFeesService < BaseService + Result = BaseResult[:top_priority_wallet] + + def initialize(allocation_rules:, fee:, customer_id:, fee_targeting_wallets_enabled: nil) + @allocation_rules = allocation_rules + @fee = fee + @customer_id = customer_id + @fee_targeting_wallets_enabled = fee_targeting_wallets_enabled + super + end + + def call + # Priority 1: Check for target_wallet_code in fee.grouped_by + if fee_targeting_wallets_enabled && fee.charge&.accepts_target_wallet + target_wallet_code = fee.grouped_by&.dig("target_wallet_code") + if target_wallet_code.present? + targeted_wallet = find_wallet_by_code(target_wallet_code) + return result_with([targeted_wallet.id]) if targeted_wallet + end + end + + bm_id = fee.charge&.billable_metric_id + + bm_wallets = allocation_rules[:bm_map][bm_id] + return result_with(bm_wallets) if bm_wallets&.any? + + type_wallets = allocation_rules[:type_map][fee.fee_type] + return result_with(type_wallets) if type_wallets&.any? + + unrestricted_wallets = allocation_rules[:unrestricted] + return result_with(unrestricted_wallets) if unrestricted_wallets&.any? + + result_with([]) + end + + private + + attr_reader :allocation_rules, :fee, :customer_id, :fee_targeting_wallets_enabled + def find_wallet_by_code(code) + return nil unless customer_id + + Wallet.active.find_by(organization_id: fee.organization_id, customer_id:, code:) + end + + def result_with(wallets) + result.top_priority_wallet = wallets.first + result + end + end +end diff --git a/app/services/wallets/recurring_transaction_rules/create_service.rb b/app/services/wallets/recurring_transaction_rules/create_service.rb new file mode 100644 index 0000000..0eedf00 --- /dev/null +++ b/app/services/wallets/recurring_transaction_rules/create_service.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +module Wallets + module RecurringTransactionRules + class CreateService < BaseService + Result = BaseResult[:recurring_transaction_rule, :payment_method] + + def initialize(wallet:, wallet_params:) + @wallet = wallet + @wallet_params = wallet_params + + super + end + + def call + return unless License.premium? + return result unless valid_payment_method? + + if method == "fixed" && rule_params[:paid_credits].nil? && rule_params[:granted_credits].nil? + paid_credits = wallet_params[:paid_credits] + granted_credits = wallet_params[:granted_credits] + end + + attributes = { + organization_id: wallet.organization_id, + paid_credits: rule_params[:paid_credits] || paid_credits || 0.0, + granted_credits: rule_params[:granted_credits] || granted_credits || 0.0, + threshold_credits: rule_params[:threshold_credits] || 0.0, + interval: rule_params[:interval], + method:, + started_at: rule_params[:started_at], + expiration_at: rule_params[:expiration_at], + target_ongoing_balance: rule_params[:target_ongoing_balance], + trigger: rule_params[:trigger].to_s, + transaction_metadata: rule_params[:transaction_metadata] || [], + transaction_name: rule_params[:transaction_name].presence + } + + if rule_params.key? :ignore_paid_top_up_limits + attributes[:ignore_paid_top_up_limits] = ActiveModel::Type::Boolean.new.cast(rule_params[:ignore_paid_top_up_limits]) + end + + if rule_params.key?(:payment_method) + attributes[:payment_method_type] = rule_params[:payment_method][:payment_method_type] if rule_params[:payment_method].key?(:payment_method_type) + attributes[:payment_method_id] = rule_params[:payment_method][:payment_method_id] if rule_params[:payment_method].key?(:payment_method_id) + end + + attributes[:invoice_requires_successful_payment] = if rule_params.key?(:invoice_requires_successful_payment) + ActiveModel::Type::Boolean.new.cast(rule_params[:invoice_requires_successful_payment]) + else + wallet.invoice_requires_successful_payment? + end + + validate_paid_credits!( + credits_amount: attributes[:paid_credits], + ignore_validation: attributes[:ignore_paid_top_up_limits] + ) + + rule = wallet.recurring_transaction_rules.create!(attributes) + + if rule_params.key? :invoice_custom_section + InvoiceCustomSections::AttachToResourceService.call(resource: rule, params: rule_params) + end + + result.recurring_transaction_rule = rule + result + rescue BaseService::FailedResult + result + end + + private + + attr_reader :wallet, :wallet_params + + def rule_params + @rule_params ||= wallet_params[:recurring_transaction_rules].first + end + + def method + @method ||= rule_params[:method] || "fixed" + end + + def validate_paid_credits!(credits_amount:, ignore_validation:) + return if method != "fixed" || BigDecimal(credits_amount).floor(5).zero? + + validator = Validators::WalletTransactionAmountLimitsValidator.new( + result, + wallet:, + credits_amount:, + ignore_validation: + ) + + unless validator.valid? + result.single_validation_failure!(field: :recurring_transaction_rules, error_code: "invalid_recurring_rule") + result.raise_if_error! + end + end + + def valid_payment_method? + result.payment_method = payment_method + + PaymentMethods::ValidateService.new(result, **rule_params).valid? + end + + def payment_method + return @payment_method if defined? @payment_method + return nil if rule_params[:payment_method].blank? || rule_params[:payment_method][:payment_method_id].blank? + + @payment_method = PaymentMethod.find_by(id: rule_params[:payment_method][:payment_method_id], organization_id: wallet.organization_id) + end + end + end +end diff --git a/app/services/wallets/recurring_transaction_rules/terminate_service.rb b/app/services/wallets/recurring_transaction_rules/terminate_service.rb new file mode 100644 index 0000000..041136f --- /dev/null +++ b/app/services/wallets/recurring_transaction_rules/terminate_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Wallets + module RecurringTransactionRules + class TerminateService < BaseService + Result = BaseResult[:recurring_transaction_rule] + + def initialize(recurring_transaction_rule:) + @recurring_transaction_rule = recurring_transaction_rule + super + end + + def call + return result.not_found_failure!(resource: "recurring_transaction_rule") unless recurring_transaction_rule + + unless recurring_transaction_rule.terminated? + recurring_transaction_rule.mark_as_terminated! + end + + result.recurring_transaction_rule = recurring_transaction_rule + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :recurring_transaction_rule + end + end +end diff --git a/app/services/wallets/recurring_transaction_rules/update_service.rb b/app/services/wallets/recurring_transaction_rules/update_service.rb new file mode 100644 index 0000000..bb34f1e --- /dev/null +++ b/app/services/wallets/recurring_transaction_rules/update_service.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +module Wallets + module RecurringTransactionRules + Result = BaseResult[:wallet, :payment_method] + + class UpdateService < BaseService + def initialize(wallet:, params:) + @wallet = wallet + @params = params + + super + end + + def call + return result unless valid_payment_methods? + + created_recurring_rules_ids = [] + + hash_recurring_rules.each do |payload_rule| + lago_id = payload_rule[:lago_id] + rule_attributes = payload_rule.except(:lago_id) + # Normalize transaction_name to nil if empty + rule_attributes[:transaction_name] = rule_attributes[:transaction_name].presence if rule_attributes.key?(:transaction_name) + + %i[paid_credits granted_credits threshold_credits].each do |credit_attr| + rule_attributes[credit_attr] = 0.0 if rule_attributes.key?(credit_attr) && rule_attributes[credit_attr].nil? + end + + if rule_attributes.key?(:payment_method) + rule_attributes[:payment_method_type] = rule_attributes[:payment_method][:payment_method_type] if rule_attributes[:payment_method].key?(:payment_method_type) + rule_attributes[:payment_method_id] = rule_attributes[:payment_method][:payment_method_id] if rule_attributes[:payment_method].key?(:payment_method_id) + rule_attributes.delete(:payment_method) + end + + recurring_rule = wallet.recurring_transaction_rules.active.find_by(id: lago_id) + + if rule_attributes.key?(:invoice_custom_section) + invoice_custom_section = { + invoice_custom_section: rule_attributes.delete(:invoice_custom_section) + } + end + + if recurring_rule + if invoice_custom_section.present? + InvoiceCustomSections::AttachToResourceService.call( + resource: recurring_rule, + params: invoice_custom_section + ) + end + + recurring_rule.update!(rule_attributes) + else + unless rule_attributes.key?(:invoice_requires_successful_payment) + rule_attributes[:invoice_requires_successful_payment] = wallet.invoice_requires_successful_payment + end + + created_recurring_rule = wallet.recurring_transaction_rules.create!( + rule_attributes.merge(organization_id: wallet.organization_id) + ) + + if invoice_custom_section.present? + InvoiceCustomSections::AttachToResourceService.call( + resource: created_recurring_rule, + params: invoice_custom_section + ) + end + + created_recurring_rules_ids.push(created_recurring_rule.id) + end + end + + # NOTE: Delete recurring_rules that are no more linked to the wallet + sanitize_recurring_rules(hash_recurring_rules, created_recurring_rules_ids) + + result.wallet = wallet + result + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :wallet, :params + + def sanitize_recurring_rules(args_recurring_rules, created_recurring_rules_ids) + updated_recurring_rules_ids = args_recurring_rules.reject { |m| m[:lago_id].nil? }.map { |m| m[:lago_id] } + not_needed_ids = + wallet.recurring_transaction_rules.pluck(:id) - updated_recurring_rules_ids - created_recurring_rules_ids + + wallet.recurring_transaction_rules.where(id: not_needed_ids).find_each do |recurring_transaction_rule| + Wallets::RecurringTransactionRules::TerminateService.call(recurring_transaction_rule:) + end + end + + def hash_recurring_rules + @hash_recurring_rules ||= params.map { |m| m.to_h.deep_symbolize_keys } + end + + def valid_payment_methods? + hash_recurring_rules.each do |payload_rule| + pm_result = BaseService::Result.new + pm_result.payment_method = payment_method(payload_rule) + + unless PaymentMethods::ValidateService.new(pm_result, **payload_rule).valid? + result.single_validation_failure!(field: :payment_method, error_code: "invalid_payment_method") + + return false + end + end + + true + end + + def payment_method(rule_params) + return nil if rule_params[:payment_method].blank? || rule_params[:payment_method][:payment_method_id].blank? + + PaymentMethod.find_by(id: rule_params[:payment_method][:payment_method_id], organization_id: wallet.organization_id) + end + end + end +end diff --git a/app/services/wallets/recurring_transaction_rules/validate_service.rb b/app/services/wallets/recurring_transaction_rules/validate_service.rb new file mode 100644 index 0000000..58243be --- /dev/null +++ b/app/services/wallets/recurring_transaction_rules/validate_service.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Wallets + module RecurringTransactionRules + class ValidateService < BaseService + def initialize(params:) + @params = params + super + end + + def call + return false unless valid_trigger? + return false unless valid_method? + return false unless valid_credits? + return false unless valid_metadata? + return false unless valid_expiration_at? + + true + end + + private + + attr_reader :params + + def trigger + @trigger ||= params[:trigger]&.to_s + end + + def method + @method ||= params[:method]&.to_s + end + + def valid_trigger? + valid_interval_trigger? || valid_threshold_trigger? + end + + def valid_interval_trigger? + trigger == "interval" && RecurringTransactionRule.intervals.key?(params[:interval]) + end + + def valid_threshold_trigger? + trigger == "threshold" && ::Validators::DecimalAmountService.new(params[:threshold_credits]).valid_decimal? + end + + def valid_method? + return valid_decimal?(params[:target_ongoing_balance]) if method == "target" + + true + end + + def valid_credits? + return true unless params[:paid_credits] || params[:granted_credits] + + params[:paid_credits] && valid_decimal?(params[:paid_credits]) || + params[:granted_credits] && valid_decimal?(params[:granted_credits]) + end + + def valid_decimal?(value) + ::Validators::DecimalAmountService.new(value).valid_decimal? + end + + def valid_metadata? + ::Validators::MetadataValidator.new(params[:transaction_metadata]).valid? + end + + def valid_expiration_at? + return true if Validators::ExpirationDateValidator.valid?(params[:expiration_at]) + + false + end + end + end +end diff --git a/app/services/wallets/terminate_service.rb b/app/services/wallets/terminate_service.rb new file mode 100644 index 0000000..e80afbd --- /dev/null +++ b/app/services/wallets/terminate_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Wallets + class TerminateService < BaseService + Result = BaseResult[:wallet] + + def initialize(wallet:) + @wallet = wallet + super + end + + def call + return result.not_found_failure!(resource: "wallet") unless wallet + unless wallet.terminated? + ActiveRecord::Base.transaction do + wallet.mark_as_terminated! + wallet.recurring_transaction_rules.find_each do |recurring_transaction_rule| + Wallets::RecurringTransactionRules::TerminateService.call(recurring_transaction_rule: recurring_transaction_rule) + end + wallet.customer.flag_wallets_for_refresh + SendWebhookJob.perform_after_commit("wallet.terminated", wallet) + end + end + + result.wallet = wallet + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :wallet + end +end diff --git a/app/services/wallets/threshold_top_up_service.rb b/app/services/wallets/threshold_top_up_service.rb new file mode 100644 index 0000000..765633e --- /dev/null +++ b/app/services/wallets/threshold_top_up_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module Wallets + class ThresholdTopUpService < BaseService + Result = BaseResult + + def initialize(wallet:) + @wallet = wallet + super + end + + def call + return result if rule.nil? + return result if wallet.credits_ongoing_balance > rule.threshold_credits + return result if (pending_transactions_amount + wallet.credits_ongoing_balance) > rule.threshold_credits + + WalletTransactions::CreateJob.set(wait: 2.seconds).perform_later( + organization_id: wallet.organization.id, + params: { + wallet_id: wallet.id, + paid_credits: rule.compute_paid_credits(ongoing_balance: wallet.credits_ongoing_balance).to_s, + granted_credits: rule.compute_granted_credits.to_s, + source: :threshold, + invoice_requires_successful_payment: rule.invoice_requires_successful_payment?, + metadata: rule.transaction_metadata, + name: rule.transaction_name, + ignore_paid_top_up_limits: rule.ignore_paid_top_up_limits? + }, + unique_transaction: true + ) + + result + end + + private + + attr_reader :wallet + + def rule + @rule ||= wallet.recurring_transaction_rules.active.where(trigger: :threshold).first + end + + def pending_transactions_amount + @pending_transactions_amount ||= wallet.wallet_transactions.pending.sum(:amount) + end + end +end diff --git a/app/services/wallets/update_service.rb b/app/services/wallets/update_service.rb new file mode 100644 index 0000000..352a351 --- /dev/null +++ b/app/services/wallets/update_service.rb @@ -0,0 +1,181 @@ +# frozen_string_literal: true + +module Wallets + class UpdateService < BaseService + Result = BaseResult[:wallet, :billable_metrics, :billable_metric_identifiers, :payment_method] + + def initialize(wallet:, params:, partial_metadata: false) + @wallet = wallet + @params = params + @partial_metadata = partial_metadata + + super + end + + activity_loggable( + action: "wallet.updated", + record: -> { wallet } + ) + + def call + return result.not_found_failure!(resource: "wallet") unless wallet + return result unless valid_expiration_at?(expiration_at: params[:expiration_at]) + return result unless valid_recurring_transaction_rules? + return result unless valid_limitations? + return result unless valid_payment_method? + + ActiveRecord::Base.transaction do + wallet.name = params[:name] if params.key?(:name) + wallet.code = params[:code] if params[:code] + wallet.priority = params[:priority] if params[:priority] + wallet.expiration_at = params[:expiration_at] if params.key?(:expiration_at) + unless params[:invoice_requires_successful_payment].nil? + wallet.invoice_requires_successful_payment = ActiveModel::Type::Boolean.new.cast(params[:invoice_requires_successful_payment]) + end + wallet.paid_top_up_min_amount_cents = params[:paid_top_up_min_amount_cents] if params.key?(:paid_top_up_min_amount_cents) + wallet.paid_top_up_max_amount_cents = params[:paid_top_up_max_amount_cents] if params.key?(:paid_top_up_max_amount_cents) + if params[:recurring_transaction_rules] && License.premium? + Wallets::RecurringTransactionRules::UpdateService.call!(wallet:, params: params[:recurring_transaction_rules]) + end + + wallet.recurring_transaction_rules.find_each { |rule| validate_rule!(rule:) } + + if params.key?(:applies_to) + wallet.allowed_fee_types = params[:applies_to][:fee_types] if params[:applies_to].key?(:fee_types) + end + + if params.key?(:payment_method) + wallet.payment_method_type = params[:payment_method][:payment_method_type] if params[:payment_method].key?(:payment_method_type) + wallet.payment_method_id = params[:payment_method][:payment_method_id] if params[:payment_method].key?(:payment_method_id) + end + + process_billable_metrics + + wallet.save! + + update_metadata! + end + + InvoiceCustomSections::AttachToResourceService.call(resource: wallet, params:) + SendWebhookJob.perform_later("wallet.updated", wallet) + Customers::RefreshWalletsService.call(customer: wallet.customer) if needs_refresh? + + result.wallet = wallet + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + rescue BaseService::FailedResult => e + e.result + end + + private + + attr_reader :wallet, :params, :partial_metadata + + def validate_rule!(rule:) + return unless rule.fixed? + + credit_amount = rule.paid_credits + return if credit_amount.nil? || credit_amount.zero? + + validator = Validators::WalletTransactionAmountLimitsValidator.new( + result, + wallet:, + credits_amount: credit_amount.to_s, + ignore_validation: rule.ignore_paid_top_up_limits + ) + + unless validator.valid? + result.single_validation_failure!(field: :recurring_transaction_rules, error_code: "invalid_recurring_rule") + result.raise_if_error! + end + end + + def valid_recurring_transaction_rules? + Wallets::ValidateRecurringTransactionRulesService.new(result, **params).valid? + end + + def valid_expiration_at?(expiration_at:) + return true if Validators::ExpirationDateValidator.valid?(expiration_at) + + result.single_validation_failure!(field: :expiration_at, error_code: "invalid_date") + false + end + + def valid_limitations? + result.billable_metrics = billable_metrics + result.billable_metric_identifiers = billable_metric_identifiers + Wallets::ValidateLimitationsService.new(result, **params).valid? + end + + def valid_payment_method? + result.payment_method = payment_method + PaymentMethods::ValidateService.new(result, **params).valid? + end + + def process_billable_metrics + # In case of adding new type of limitation in wallet_targets, query from below should use compact to avoid nil values in the array + existing_wallet_billable_metric_ids = wallet.wallet_targets.pluck(:billable_metric_id) + + billable_metrics.each do |bm| + next if existing_wallet_billable_metric_ids.include?(bm.id) + + WalletTarget.create!(wallet:, billable_metric: bm, organization_id: wallet.organization_id) + @wallet_targets_changed = true + end + + sanitize_wallet_billable_metrics(existing_wallet_billable_metric_ids) if existing_wallet_billable_metric_ids.present? + end + + def sanitize_wallet_billable_metrics(existing_wallet_billable_metric_ids) + not_needed_wallet_target_ids = existing_wallet_billable_metric_ids - billable_metrics.pluck(:id) + not_needed_wallet_target_ids.each do |wallet_billable_metric_id| + target = WalletTarget.find_by(wallet:, billable_metric_id: wallet_billable_metric_id, organization: wallet.organization) + next unless target + + target.destroy! + @wallet_targets_changed = true + end + end + + def needs_refresh? + return true if @wallet_targets_changed + + (wallet.saved_changes.keys & Wallet::REFRESH_RELEVANT_ATTRIBUTES).any? + end + + def billable_metric_identifiers + return [] if params[:applies_to].blank? + + key = api_context? ? :billable_metric_codes : :billable_metric_ids + + return [] if params[:applies_to][key].blank? + + params[:applies_to][key]&.compact&.uniq + end + + def billable_metrics + return @billable_metrics if defined?(@billable_metrics) + return [] if billable_metric_identifiers.blank? + + @billable_metrics = if api_context? + BillableMetric.where(code: billable_metric_identifiers, organization_id: wallet.organization_id) + else + BillableMetric.where(id: billable_metric_identifiers, organization_id: wallet.organization_id) + end + end + + def payment_method + return @payment_method if defined? @payment_method + return nil if params[:payment_method].blank? || params[:payment_method][:payment_method_id].blank? + + @payment_method = PaymentMethod.find_by(id: params[:payment_method][:payment_method_id], organization_id: wallet.organization_id) + end + + def update_metadata! + return unless params.key?(:metadata) + + Metadata::UpdateItemService.call!(owner: wallet, value: params[:metadata], partial: partial_metadata.present?) + end + end +end diff --git a/app/services/wallets/validate_limitations_service.rb b/app/services/wallets/validate_limitations_service.rb new file mode 100644 index 0000000..00e821e --- /dev/null +++ b/app/services/wallets/validate_limitations_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Wallets + class ValidateLimitationsService < BaseValidator + def valid? + return true unless args[:applies_to] + + valid_allowed_fee_types? + valid_billable_metrics? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_allowed_fee_types? + fee_types = args[:applies_to][:fee_types] + + return true if fee_types.blank? + + valid_types = Fee.fee_types.keys + incoming = Array(fee_types).map(&:to_s) + invalid = incoming - valid_types + + add_error(field: :allowed_fee_types, error_code: "invalid_fee_types") if invalid.any? + end + + def valid_billable_metrics? + return true if args[:applies_to][:billable_metric_ids].blank? && args[:applies_to][:billable_metric_codes].blank? + return true if result.billable_metrics.length == result.billable_metric_identifiers.length + + add_error(field: :billable_metrics, error_code: "invalid_identifier") + end + end +end diff --git a/app/services/wallets/validate_recurring_transaction_rules_service.rb b/app/services/wallets/validate_recurring_transaction_rules_service.rb new file mode 100644 index 0000000..332bfc0 --- /dev/null +++ b/app/services/wallets/validate_recurring_transaction_rules_service.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Wallets + class ValidateRecurringTransactionRulesService < BaseValidator + def valid? + return true unless args[:recurring_transaction_rules] + + valid_transaction_rules_number? + valid_transaction_rules? + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def valid_transaction_rules_number? + return true if args[:recurring_transaction_rules].count.zero? || args[:recurring_transaction_rules].count == 1 + + add_error(field: :recurring_transaction_rules, error_code: "invalid_number_of_recurring_rules") + end + + def valid_transaction_rules? + return true if args[:recurring_transaction_rules].count.zero? + + unless Wallets::RecurringTransactionRules::ValidateService.call(params: args[:recurring_transaction_rules].first) + add_error(field: :recurring_transaction_rules, error_code: "invalid_recurring_rule") + end + end + end +end diff --git a/app/services/wallets/validate_service.rb b/app/services/wallets/validate_service.rb new file mode 100644 index 0000000..a369a97 --- /dev/null +++ b/app/services/wallets/validate_service.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +module Wallets + class ValidateService < BaseValidator + MAXIMUM_WALLETS_PER_CUSTOMER = 6 + + def valid? + valid_organization_id? + valid_customer? + valid_paid_credits_amount? if args[:paid_credits] + valid_granted_credits_amount? if args[:granted_credits] + valid_expiration_at? if args[:expiration_at] + valid_recurring_transaction_rules? if args[:recurring_transaction_rules].present? + valid_metadata? if args[:transaction_metadata] + valid_limitations? if args[:applies_to] + valid_wallet_limit? + valid_payment_method? if args[:payment_method] + + if errors? + result.validation_failure!(errors:) + return false + end + + true + end + + private + + def customer + args[:customer] + end + + def organization_id + args[:organization_id] + end + + def valid_organization_id? + if organization_id.blank? + add_error(field: :organization_id, error_code: "blank") + return false + end + + return true if customer.nil? || customer.organization_id == organization_id + + add_error(field: :organization_id, error_code: "invalid") + end + + def valid_wallet_limit? + return true unless customer + customer_allowed_wallets = customer.organization.maximum_wallets_per_customer || MAXIMUM_WALLETS_PER_CUSTOMER + + if customer.wallets.active.count >= customer_allowed_wallets + return add_error(field: :customer, error_code: "wallet_limit_reached") + end + + true + end + + def valid_customer? + if customer.nil? + return add_error(field: :customer, error_code: "customer_not_found") + end + + true + end + + def valid_paid_credits_amount? + return true if ::Validators::DecimalAmountService.new(args[:paid_credits]).valid_amount? + + add_error(field: :paid_credits, error_code: "invalid_paid_credits") + add_error(field: :paid_credits, error_code: "invalid_amount") + end + + def valid_granted_credits_amount? + return true if ::Validators::DecimalAmountService.new(args[:granted_credits]).valid_amount? + + add_error(field: :granted_credits, error_code: "invalid_granted_credits") + add_error(field: :granted_credits, error_code: "invalid_amount") + end + + def valid_expiration_at? + return true if Validators::ExpirationDateValidator.valid?(args[:expiration_at]) + + add_error(field: :expiration_at, error_code: "invalid_date") + end + + def valid_recurring_transaction_rules? + if args[:recurring_transaction_rules].count > 1 + return add_error(field: :recurring_transaction_rules, error_code: "invalid_number_of_recurring_rules") + end + + unless Wallets::RecurringTransactionRules::ValidateService.call(params: args[:recurring_transaction_rules].first) + add_error(field: :recurring_transaction_rules, error_code: "invalid_recurring_rule") + end + end + + def valid_metadata? + validator = ::Validators::MetadataValidator.new(args[:transaction_metadata]) + unless validator.valid? + validator.errors.each do |field, error_code| + add_error(field: field, error_code: error_code) + end + return false + end + + true + end + + def valid_limitations? + limitation_result = BaseService::Result.new + limitation_result.billable_metrics = result.billable_metrics + limitation_result.billable_metric_identifiers = result.billable_metric_identifiers + + return true if Wallets::ValidateLimitationsService.new(limitation_result, **args).valid? + + add_error(field: :applies_to, error_code: "invalid_limitations") + end + + def valid_payment_method? + pm_result = BaseService::Result.new + pm_result.payment_method = result.payment_method + + return true if PaymentMethods::ValidateService.new(pm_result, **args).valid? + + add_error(field: :payment_method, error_code: "invalid_payment_method") + end + end +end diff --git a/app/services/webhook_endpoints/create_service.rb b/app/services/webhook_endpoints/create_service.rb new file mode 100644 index 0000000..8123150 --- /dev/null +++ b/app/services/webhook_endpoints/create_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module WebhookEndpoints + class CreateService < BaseService + def initialize(organization:, params:) + @organization = organization + @params = params + + super + end + + def call + webhook_endpoint = organization.webhook_endpoints.new( + webhook_url: params[:webhook_url], + signature_algo: params[:signature_algo]&.to_sym || :jwt, + name: params[:name], + event_types: params[:event_types] + ) + + webhook_endpoint.save! + + result.webhook_endpoint = webhook_endpoint + track_webhook_webdpoint_created(result.webhook_endpoint) + register_security_log(webhook_endpoint) + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :organization, :params + + def register_security_log(webhook_endpoint) + Utils::SecurityLog.produce( + organization: organization, + log_type: "webhook_endpoint", + log_event: "webhook_endpoint.created", + resources: {webhook_url: webhook_endpoint.webhook_url, signature_algo: webhook_endpoint.signature_algo} + ) + end + + def track_webhook_webdpoint_created(webhook_endpoint) + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "webhook_endpoint_created", + properties: { + webhook_endpoint_id: webhook_endpoint.id, + organization_id: webhook_endpoint.organization_id, + webhook_url: webhook_endpoint.webhook_url + } + ) + end + end +end diff --git a/app/services/webhook_endpoints/destroy_service.rb b/app/services/webhook_endpoints/destroy_service.rb new file mode 100644 index 0000000..91498f5 --- /dev/null +++ b/app/services/webhook_endpoints/destroy_service.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module WebhookEndpoints + class DestroyService < BaseService + def initialize(webhook_endpoint:) + @webhook_endpoint = webhook_endpoint + + super + end + + def call + return result.not_found_failure!(resource: "webhook_endpoint") unless webhook_endpoint + + webhook_endpoint.destroy! + track_webhook_endpoint_deleted + register_security_log + + result.webhook_endpoint = webhook_endpoint + result + end + + private + + attr_reader :webhook_endpoint + + def register_security_log + Utils::SecurityLog.produce( + organization: webhook_endpoint.organization, + log_type: "webhook_endpoint", + log_event: "webhook_endpoint.deleted", + resources: {webhook_url: webhook_endpoint.webhook_url, signature_algo: webhook_endpoint.signature_algo} + ) + end + + def track_webhook_endpoint_deleted + SegmentTrackJob.perform_later( + membership_id: CurrentContext.membership, + event: "webhook_endpoint_deleted", + properties: { + webhook_endpoint_id: webhook_endpoint.id, + organization_id: webhook_endpoint.organization_id, + webhook_url: webhook_endpoint.webhook_url + } + ) + end + end +end diff --git a/app/services/webhook_endpoints/update_service.rb b/app/services/webhook_endpoints/update_service.rb new file mode 100644 index 0000000..e8622a2 --- /dev/null +++ b/app/services/webhook_endpoints/update_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module WebhookEndpoints + class UpdateService < BaseService + def initialize(id:, organization:, params:) + @id = id + @organization = organization + @params = params + + super + end + + def call + webhook_endpoint = organization.webhook_endpoints.find_by(id:) + + return result.not_found_failure!(resource: "webhook_endpoint") if webhook_endpoint.blank? + + webhook_endpoint.webhook_url = params[:webhook_url] if params.key?(:webhook_url) + webhook_endpoint.signature_algo = params[:signature_algo]&.to_sym if params.key?(:signature_algo) + webhook_endpoint.name = params[:name] if params.key?(:name) + webhook_endpoint.event_types = params[:event_types] if params.key?(:event_types) + webhook_endpoint.save! + + register_security_log(webhook_endpoint) + + result.webhook_endpoint = webhook_endpoint + result + rescue ActiveRecord::RecordInvalid => e + result.record_validation_failure!(record: e.record) + end + + private + + attr_reader :id, :organization, :params + + def register_security_log(webhook_endpoint) + diff = webhook_endpoint.previous_changes.slice("webhook_url", "signature_algo").to_h + .transform_keys(&:to_sym) + .transform_values { |v| {deleted: v[0], added: v[1]}.compact } + + Utils::SecurityLog.produce( + organization: webhook_endpoint.organization, + log_type: "webhook_endpoint", + log_event: "webhook_endpoint.updated", + resources: {webhook_url: webhook_endpoint.webhook_url, **diff} + ) + end + end +end diff --git a/app/services/webhooks/base_service.rb b/app/services/webhooks/base_service.rb new file mode 100644 index 0000000..647fee0 --- /dev/null +++ b/app/services/webhooks/base_service.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "lago_http_client" + +module Webhooks + # NOTE: Abstract Service, should not be used directly + class BaseService + def initialize(object:, options: {}) + @object = object + @options = options&.with_indifferent_access + end + + def call + return if current_organization.webhook_endpoints.none? + + payload = { + :webhook_type => webhook_type, + :object_type => object_type, + :organization_id => current_organization.id, + object_type => object_serializer.serialize + } + + # TODO: Wrap in transaction so we create all webhook models or none + # Ensure the http jobs are dispatched after the transaction is committed + current_organization.webhook_endpoints.each do |webhook_endpoint| + next unless subscribed?(webhook_endpoint) + + webhook = create_webhook(webhook_endpoint, payload) + SendHttpWebhookJob.perform_later(webhook) + rescue ActiveRecord::InvalidForeignKey + # The webhook endpoint was deleted while the transaction was in progress + Rails.logger.error("SendWebhookJob failed for deleted webhook endpoint #{webhook_endpoint.id}") + next + end + end + + private + + attr_reader :object, :options + + def subscribed?(webhook_endpoint) + return true if webhook_endpoint.event_types.nil? + webhook_endpoint.event_types.include?(webhook_type) + end + + def object_serializer + # Empty + end + + def current_organization + @current_organization ||= object.organization + end + + def webhook_type + # Empty + end + + def object_type + # Empty + end + + def create_webhook(webhook_endpoint, payload) + webhook = Webhook.new(webhook_endpoint:) + webhook.organization_id = current_organization&.id + webhook.webhook_type = webhook_type + webhook.endpoint = webhook_endpoint.webhook_url + # Question: When can this be a hash? + webhook.object_id = object.is_a?(Hash) ? object.fetch(:id, nil) : object&.id + webhook.object_type = object.is_a?(Hash) ? object.fetch(:class, nil) : object&.class&.to_s + webhook.payload = payload + webhook.pending! + webhook + end + end +end diff --git a/app/services/webhooks/credit_notes/created_service.rb b/app/services/webhooks/credit_notes/created_service.rb new file mode 100644 index 0000000..a853fa7 --- /dev/null +++ b/app/services/webhooks/credit_notes/created_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module CreditNotes + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::CreditNoteSerializer.new( + object, + root_name: "credit_note", + includes: %i[customer items applied_taxes] + ) + end + + def webhook_type + "credit_note.created" + end + + def object_type + "credit_note" + end + end + end +end diff --git a/app/services/webhooks/credit_notes/generated_service.rb b/app/services/webhooks/credit_notes/generated_service.rb new file mode 100644 index 0000000..cb57110 --- /dev/null +++ b/app/services/webhooks/credit_notes/generated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module CreditNotes + class GeneratedService < Webhooks::BaseService + private + + def object_serializer + ::V1::CreditNoteSerializer.new( + object, + root_name: "credit_note", + includes: %i[customer] + ) + end + + def webhook_type + "credit_note.generated" + end + + def object_type + "credit_note" + end + end + end +end diff --git a/app/services/webhooks/credit_notes/payment_provider_refund_failure_service.rb b/app/services/webhooks/credit_notes/payment_provider_refund_failure_service.rb new file mode 100644 index 0000000..d61e716 --- /dev/null +++ b/app/services/webhooks/credit_notes/payment_provider_refund_failure_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Webhooks + module CreditNotes + class PaymentProviderRefundFailureService < Webhooks::BaseService + private + + alias_method :credit_note, :object + + def object_serializer + ::V1::CreditNotes::PaymentProviderRefundErrorSerializer.new( + credit_note, + root_name: object_type, + provider_error: options[:provider_error], + provider_customer_id: options[:provider_customer_id] + ) + end + + def webhook_type + "credit_note.refund_failure" + end + + def object_type + "credit_note_payment_provider_refund_error" + end + end + end +end diff --git a/app/services/webhooks/customers/created_service.rb b/app/services/webhooks/customers/created_service.rb new file mode 100644 index 0000000..c310448 --- /dev/null +++ b/app/services/webhooks/customers/created_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Customers + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::CustomerSerializer.new( + object, + root_name: "customer", + includes: %i[integration_customers] + ) + end + + def webhook_type + "customer.created" + end + + def object_type + "customer" + end + end + end +end diff --git a/app/services/webhooks/customers/updated_service.rb b/app/services/webhooks/customers/updated_service.rb new file mode 100644 index 0000000..c2a333a --- /dev/null +++ b/app/services/webhooks/customers/updated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Customers + class UpdatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::CustomerSerializer.new( + object, + root_name: "customer", + includes: %i[integration_customers] + ) + end + + def webhook_type + "customer.updated" + end + + def object_type + "customer" + end + end + end +end diff --git a/app/services/webhooks/customers/vies_check_service.rb b/app/services/webhooks/customers/vies_check_service.rb new file mode 100644 index 0000000..5191b4c --- /dev/null +++ b/app/services/webhooks/customers/vies_check_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Webhooks + module Customers + class ViesCheckService < Webhooks::BaseService + private + + def current_organization + @current_organization ||= Organization.find(object.organization_id) + end + + def object_serializer + ::V1::CustomerSerializer.new( + object, + root_name: "customer", + includes: %i[vies_check], + vies_check: options[:vies_check] + ) + end + + def webhook_type + "customer.vies_check" + end + + def object_type + "customer" + end + end + end +end diff --git a/app/services/webhooks/dunning_campaigns/finished_service.rb b/app/services/webhooks/dunning_campaigns/finished_service.rb new file mode 100644 index 0000000..03d32dc --- /dev/null +++ b/app/services/webhooks/dunning_campaigns/finished_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module DunningCampaigns + class FinishedService < Webhooks::BaseService + private + + def object_serializer + ::V1::DunningCampaignFinishedSerializer.new( + object, + root_name: object_type, + dunning_campaign_code: options[:dunning_campaign_code] + ) + end + + def webhook_type + "dunning_campaign.finished" + end + + def object_type + "dunning_campaign" + end + end + end +end diff --git a/app/services/webhooks/events/error_service.rb b/app/services/webhooks/events/error_service.rb new file mode 100644 index 0000000..4064393 --- /dev/null +++ b/app/services/webhooks/events/error_service.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Webhooks + module Events + class ErrorService < Webhooks::BaseService + EventError = Data.define(:error, :event) + + private + + def current_organization + @current_organization ||= Organization.find(object.organization_id) + end + + def object_serializer + ::V1::EventErrorSerializer.new( + EventError.new( + error: options[:error], + event: object + ), + root_name: "event_error" + ) + end + + def webhook_type + "event.error" + end + + def object_type + "event_error" + end + end + end +end diff --git a/app/services/webhooks/events/validation_errors_service.rb b/app/services/webhooks/events/validation_errors_service.rb new file mode 100644 index 0000000..764f341 --- /dev/null +++ b/app/services/webhooks/events/validation_errors_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Webhooks + module Events + class ValidationErrorsService < Webhooks::BaseService + private + + def current_organization + object + end + + def object_serializer + ::V1::EventsValidationErrorsSerializer.new( + options[:errors] || {}, + root_name: "events_errors" + ) + end + + def webhook_type + "events.errors" + end + + def object_type + "events_errors" + end + end + end +end diff --git a/app/services/webhooks/features/created_service.rb b/app/services/webhooks/features/created_service.rb new file mode 100644 index 0000000..39b8986 --- /dev/null +++ b/app/services/webhooks/features/created_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Webhooks + module Features + class CreatedService < Webhooks::BaseService + private + + def current_organization + @current_organization ||= object.organization + end + + def object_serializer + ::V1::Entitlement::FeatureSerializer.new( + object, + root_name: "feature" + ) + end + + def webhook_type + "feature.created" + end + + def object_type + "feature" + end + end + end +end diff --git a/app/services/webhooks/features/deleted_service.rb b/app/services/webhooks/features/deleted_service.rb new file mode 100644 index 0000000..7986a5c --- /dev/null +++ b/app/services/webhooks/features/deleted_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Webhooks + module Features + class DeletedService < Webhooks::BaseService + private + + def current_organization + @current_organization ||= object.organization + end + + def object_serializer + ::V1::Entitlement::FeatureSerializer.new( + object, + root_name: "feature" + ) + end + + def webhook_type + "feature.deleted" + end + + def object_type + "feature" + end + end + end +end diff --git a/app/services/webhooks/features/updated_service.rb b/app/services/webhooks/features/updated_service.rb new file mode 100644 index 0000000..8402330 --- /dev/null +++ b/app/services/webhooks/features/updated_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Webhooks + module Features + class UpdatedService < Webhooks::BaseService + private + + def current_organization + @current_organization ||= object.organization + end + + def object_serializer + ::V1::Entitlement::FeatureSerializer.new( + object, + root_name: "feature" + ) + end + + def webhook_type + "feature.updated" + end + + def object_type + "feature" + end + end + end +end diff --git a/app/services/webhooks/fees/pay_in_advance_created_service.rb b/app/services/webhooks/fees/pay_in_advance_created_service.rb new file mode 100644 index 0000000..4952e02 --- /dev/null +++ b/app/services/webhooks/fees/pay_in_advance_created_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Webhooks + module Fees + class PayInAdvanceCreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::FeeSerializer.new( + object, + root_name: "fee" + ) + end + + def webhook_type + "fee.created" + end + + def object_type + "fee" + end + end + end +end diff --git a/app/services/webhooks/integrations/accounting_customer_created_service.rb b/app/services/webhooks/integrations/accounting_customer_created_service.rb new file mode 100644 index 0000000..7395593 --- /dev/null +++ b/app/services/webhooks/integrations/accounting_customer_created_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + class AccountingCustomerCreatedService < CustomerCreatedService + private + + def webhook_type + "customer.accounting_provider_created" + end + end + end +end diff --git a/app/services/webhooks/integrations/accounting_customer_error_service.rb b/app/services/webhooks/integrations/accounting_customer_error_service.rb new file mode 100644 index 0000000..bf5a793 --- /dev/null +++ b/app/services/webhooks/integrations/accounting_customer_error_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + class AccountingCustomerErrorService < CustomerErrorService + private + + def webhook_type + "customer.accounting_provider_error" + end + + def object_type + "accounting_provider_customer_error" + end + end + end +end diff --git a/app/services/webhooks/integrations/crm_customer_created_service.rb b/app/services/webhooks/integrations/crm_customer_created_service.rb new file mode 100644 index 0000000..cd3412d --- /dev/null +++ b/app/services/webhooks/integrations/crm_customer_created_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + class CrmCustomerCreatedService < CustomerCreatedService + private + + def webhook_type + "customer.crm_provider_created" + end + end + end +end diff --git a/app/services/webhooks/integrations/crm_customer_error_service.rb b/app/services/webhooks/integrations/crm_customer_error_service.rb new file mode 100644 index 0000000..8318612 --- /dev/null +++ b/app/services/webhooks/integrations/crm_customer_error_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + class CrmCustomerErrorService < CustomerErrorService + private + + def webhook_type + "customer.crm_provider_error" + end + + def object_type + "crm_provider_customer_error" + end + end + end +end diff --git a/app/services/webhooks/integrations/customer_created_service.rb b/app/services/webhooks/integrations/customer_created_service.rb new file mode 100644 index 0000000..5cd9e3b --- /dev/null +++ b/app/services/webhooks/integrations/customer_created_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + class CustomerCreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::CustomerSerializer.new( + object, + root_name: object_type, + includes: %i[integration_customers] + ) + end + + def object_type + "customer" + end + end + end +end diff --git a/app/services/webhooks/integrations/customer_error_service.rb b/app/services/webhooks/integrations/customer_error_service.rb new file mode 100644 index 0000000..1b0481c --- /dev/null +++ b/app/services/webhooks/integrations/customer_error_service.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + class CustomerErrorService < Webhooks::BaseService + private + + def object_serializer + ::V1::Integrations::CustomerErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error], + provider: options[:provider], + provider_code: options[:provider_code] + ) + end + end + end +end diff --git a/app/services/webhooks/integrations/provider_error_service.rb b/app/services/webhooks/integrations/provider_error_service.rb new file mode 100644 index 0000000..0965c4c --- /dev/null +++ b/app/services/webhooks/integrations/provider_error_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + class ProviderErrorService < Webhooks::BaseService + private + + def object_serializer + ::V1::Integrations::ProviderErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error], + provider: options[:provider], + provider_code: options[:provider_code] + ) + end + + def webhook_type + "integration.provider_error" + end + + def object_type + "provider_error" + end + end + end +end diff --git a/app/services/webhooks/integrations/taxes/error_service.rb b/app/services/webhooks/integrations/taxes/error_service.rb new file mode 100644 index 0000000..232e9c8 --- /dev/null +++ b/app/services/webhooks/integrations/taxes/error_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + module Taxes + class ErrorService < Webhooks::BaseService + private + + def object_serializer + ::V1::Integrations::Taxes::CustomerErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error], + provider: options[:provider], + provider_code: options[:provider_code] + ) + end + + def webhook_type + "customer.tax_provider_error" + end + + def object_type + "tax_provider_customer_error" + end + end + end + end +end diff --git a/app/services/webhooks/integrations/taxes/fee_error_service.rb b/app/services/webhooks/integrations/taxes/fee_error_service.rb new file mode 100644 index 0000000..e9d8f07 --- /dev/null +++ b/app/services/webhooks/integrations/taxes/fee_error_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Webhooks + module Integrations + module Taxes + class FeeErrorService < Webhooks::BaseService + private + + def object_serializer + ::V1::Integrations::Taxes::FeeErrorSerializer.new( + object, + root_name: object_type, + event_transaction_id: options[:event_transaction_id], + lago_charge_id: options[:lago_charge_id], + provider_error: options[:provider_error] + ) + end + + def webhook_type + "fee.tax_provider_error" + end + + def object_type + "tax_provider_fee_error" + end + end + end + end +end diff --git a/app/services/webhooks/invoices/created_service.rb b/app/services/webhooks/invoices/created_service.rb new file mode 100644 index 0000000..6139d8f --- /dev/null +++ b/app/services/webhooks/invoices/created_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer subscriptions billing_periods fees credits applied_taxes applied_invoice_custom_sections] + ) + end + + def webhook_type + "invoice.created" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/drafted_service.rb b/app/services/webhooks/invoices/drafted_service.rb new file mode 100644 index 0000000..51e7af6 --- /dev/null +++ b/app/services/webhooks/invoices/drafted_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class DraftedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer subscriptions billing_periods fees credits applied_taxes error_details] + ) + end + + def webhook_type + "invoice.drafted" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/generated_service.rb b/app/services/webhooks/invoices/generated_service.rb new file mode 100644 index 0000000..069413b --- /dev/null +++ b/app/services/webhooks/invoices/generated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class GeneratedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer] + ) + end + + def webhook_type + "invoice.generated" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/one_off_created_service.rb b/app/services/webhooks/invoices/one_off_created_service.rb new file mode 100644 index 0000000..47ed6de --- /dev/null +++ b/app/services/webhooks/invoices/one_off_created_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class OneOffCreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer fees applied_taxes applied_invoice_custom_sections] + ) + end + + def webhook_type + "invoice.one_off_created" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/paid_credit_added_service.rb b/app/services/webhooks/invoices/paid_credit_added_service.rb new file mode 100644 index 0000000..0aee3de --- /dev/null +++ b/app/services/webhooks/invoices/paid_credit_added_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class PaidCreditAddedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer fees applied_taxes applied_invoice_custom_sections] + ) + end + + def webhook_type + "invoice.paid_credit_added" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/payment_dispute_lost_service.rb b/app/services/webhooks/invoices/payment_dispute_lost_service.rb new file mode 100644 index 0000000..897e2a8 --- /dev/null +++ b/app/services/webhooks/invoices/payment_dispute_lost_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class PaymentDisputeLostService < Webhooks::BaseService + private + + def object_serializer + ::V1::Invoices::PaymentDisputeLostSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error] + ) + end + + def webhook_type + "invoice.payment_dispute_lost" + end + + def object_type + "payment_dispute_lost" + end + end + end +end diff --git a/app/services/webhooks/invoices/payment_overdue_service.rb b/app/services/webhooks/invoices/payment_overdue_service.rb new file mode 100644 index 0000000..e28978a --- /dev/null +++ b/app/services/webhooks/invoices/payment_overdue_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class PaymentOverdueService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer fees applied_taxes] + ) + end + + def webhook_type + "invoice.payment_overdue" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/payment_status_updated_service.rb b/app/services/webhooks/invoices/payment_status_updated_service.rb new file mode 100644 index 0000000..ec6a9cb --- /dev/null +++ b/app/services/webhooks/invoices/payment_status_updated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class PaymentStatusUpdatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer fees] + ) + end + + def webhook_type + "invoice.payment_status_updated" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/resynced_service.rb b/app/services/webhooks/invoices/resynced_service.rb new file mode 100644 index 0000000..bb18ad7 --- /dev/null +++ b/app/services/webhooks/invoices/resynced_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class ResyncedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer billing_periods integration_customers subscriptions fees credits applied_taxes] + ) + end + + def webhook_type + "invoice.resynced" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/invoices/voided_service.rb b/app/services/webhooks/invoices/voided_service.rb new file mode 100644 index 0000000..ef0ae45 --- /dev/null +++ b/app/services/webhooks/invoices/voided_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Invoices + class VoidedService < Webhooks::BaseService + private + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice", + includes: %i[customer billing_periods subscriptions fees credits applied_taxes] + ) + end + + def webhook_type + "invoice.voided" + end + + def object_type + "invoice" + end + end + end +end diff --git a/app/services/webhooks/payment_providers/customer_checkout_service.rb b/app/services/webhooks/payment_providers/customer_checkout_service.rb new file mode 100644 index 0000000..f7fddee --- /dev/null +++ b/app/services/webhooks/payment_providers/customer_checkout_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentProviders + class CustomerCheckoutService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentProviders::CustomerCheckoutSerializer.new( + object, + root_name: object_type, + checkout_url: options[:checkout_url] + ) + end + + def webhook_type + "customer.checkout_url_generated" + end + + def object_type + "payment_provider_customer_checkout_url" + end + end + end +end diff --git a/app/services/webhooks/payment_providers/customer_created_service.rb b/app/services/webhooks/payment_providers/customer_created_service.rb new file mode 100644 index 0000000..f0a49e4 --- /dev/null +++ b/app/services/webhooks/payment_providers/customer_created_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentProviders + class CustomerCreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::CustomerSerializer.new( + object, + root_name: object_type + ) + end + + def webhook_type + "customer.payment_provider_created" + end + + def object_type + "customer" + end + end + end +end diff --git a/app/services/webhooks/payment_providers/customer_error_service.rb b/app/services/webhooks/payment_providers/customer_error_service.rb new file mode 100644 index 0000000..f3047c5 --- /dev/null +++ b/app/services/webhooks/payment_providers/customer_error_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentProviders + class CustomerErrorService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentProviders::CustomerErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error] + ) + end + + def webhook_type + "customer.payment_provider_error" + end + + def object_type + "payment_provider_customer_error" + end + end + end +end diff --git a/app/services/webhooks/payment_providers/error_service.rb b/app/services/webhooks/payment_providers/error_service.rb new file mode 100644 index 0000000..1be1b9d --- /dev/null +++ b/app/services/webhooks/payment_providers/error_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentProviders + class ErrorService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentProviders::ErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error] + ) + end + + def webhook_type + "payment_provider.error" + end + + def object_type + "payment_provider_error" + end + end + end +end diff --git a/app/services/webhooks/payment_providers/invoice_payment_failure_service.rb b/app/services/webhooks/payment_providers/invoice_payment_failure_service.rb new file mode 100644 index 0000000..fc0f040 --- /dev/null +++ b/app/services/webhooks/payment_providers/invoice_payment_failure_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentProviders + class InvoicePaymentFailureService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentProviders::InvoicePaymentErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error], + provider_customer_id: options[:provider_customer_id], + error_details: options[:error_details] + ) + end + + def webhook_type + "invoice.payment_failure" + end + + def object_type + "payment_provider_invoice_payment_error" + end + end + end +end diff --git a/app/services/webhooks/payment_providers/payment_request_payment_failure_service.rb b/app/services/webhooks/payment_providers/payment_request_payment_failure_service.rb new file mode 100644 index 0000000..3d30b4b --- /dev/null +++ b/app/services/webhooks/payment_providers/payment_request_payment_failure_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentProviders + class PaymentRequestPaymentFailureService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentProviders::PaymentRequestPaymentErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error], + provider_customer_id: options[:provider_customer_id] + ) + end + + def webhook_type + "payment_request.payment_failure" + end + + def object_type + "payment_provider_payment_request_payment_error" + end + end + end +end diff --git a/app/services/webhooks/payment_providers/wallet_transaction_payment_failure_service.rb b/app/services/webhooks/payment_providers/wallet_transaction_payment_failure_service.rb new file mode 100644 index 0000000..478df4d --- /dev/null +++ b/app/services/webhooks/payment_providers/wallet_transaction_payment_failure_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentProviders + class WalletTransactionPaymentFailureService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentProviders::WalletTransactionPaymentErrorSerializer.new( + object, + root_name: object_type, + provider_error: options[:provider_error], + provider_customer_id: options[:provider_customer_id] + ) + end + + def webhook_type + "wallet_transaction.payment_failure" + end + + def object_type + "payment_provider_wallet_transaction_payment_error" + end + end + end +end diff --git a/app/services/webhooks/payment_receipts/created_service.rb b/app/services/webhooks/payment_receipts/created_service.rb new file mode 100644 index 0000000..5623277 --- /dev/null +++ b/app/services/webhooks/payment_receipts/created_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentReceipts + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentReceiptSerializer.new( + object, + root_name: "payment_receipt" + ) + end + + def webhook_type + "payment_receipt.created" + end + + def object_type + "payment_receipt" + end + end + end +end diff --git a/app/services/webhooks/payment_receipts/generated_service.rb b/app/services/webhooks/payment_receipts/generated_service.rb new file mode 100644 index 0000000..7d0b48d --- /dev/null +++ b/app/services/webhooks/payment_receipts/generated_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentReceipts + class GeneratedService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentReceiptSerializer.new( + object, + root_name: "payment_receipt" + ) + end + + def webhook_type + "payment_receipt.generated" + end + + def object_type + "payment_receipt" + end + end + end +end diff --git a/app/services/webhooks/payment_requests/created_service.rb b/app/services/webhooks/payment_requests/created_service.rb new file mode 100644 index 0000000..57616ef --- /dev/null +++ b/app/services/webhooks/payment_requests/created_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentRequests + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentRequestSerializer.new( + object, + root_name: "payment_request", + includes: %i[customer invoices] + ) + end + + def webhook_type + "payment_request.created" + end + + def object_type + "payment_request" + end + end + end +end diff --git a/app/services/webhooks/payment_requests/payment_status_updated_service.rb b/app/services/webhooks/payment_requests/payment_status_updated_service.rb new file mode 100644 index 0000000..80caaab --- /dev/null +++ b/app/services/webhooks/payment_requests/payment_status_updated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module PaymentRequests + class PaymentStatusUpdatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentRequestSerializer.new( + object, + root_name: "payment_request", + includes: %i[customer invoices] + ) + end + + def webhook_type + "payment_request.payment_status_updated" + end + + def object_type + "payment_request" + end + end + end +end diff --git a/app/services/webhooks/payments/requires_action_service.rb b/app/services/webhooks/payments/requires_action_service.rb new file mode 100644 index 0000000..8d155f8 --- /dev/null +++ b/app/services/webhooks/payments/requires_action_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Webhooks + module Payments + class RequiresActionService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentSerializer.new( + object, + root_name: object_type + ) + end + + def webhook_type + "payment.requires_action" + end + + def object_type + "payment" + end + end + end +end diff --git a/app/services/webhooks/payments/succeeded_service.rb b/app/services/webhooks/payments/succeeded_service.rb new file mode 100644 index 0000000..7f14157 --- /dev/null +++ b/app/services/webhooks/payments/succeeded_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Payments + class SucceededService < Webhooks::BaseService + private + + def object_serializer + ::V1::PaymentSerializer.new( + object, + root_name: object_type, + includes: %i[payment_method] + ) + end + + def webhook_type + "payment.succeeded" + end + + def object_type + "payment" + end + end + end +end diff --git a/app/services/webhooks/plans/created_service.rb b/app/services/webhooks/plans/created_service.rb new file mode 100644 index 0000000..f58b8ed --- /dev/null +++ b/app/services/webhooks/plans/created_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Plans + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::PlanSerializer.new( + object, + root_name: "plan", + includes: %i[charges usage_thresholds taxes minimum_commitment entitlements] + ) + end + + def webhook_type + "plan.created" + end + + def object_type + "plan" + end + end + end +end diff --git a/app/services/webhooks/plans/deleted_service.rb b/app/services/webhooks/plans/deleted_service.rb new file mode 100644 index 0000000..729cfcc --- /dev/null +++ b/app/services/webhooks/plans/deleted_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Plans + class DeletedService < Webhooks::BaseService + private + + def object_serializer + ::V1::PlanSerializer.new( + object, + root_name: "plan", + includes: %i[charges usage_thresholds taxes minimum_commitment] + ) + end + + def webhook_type + "plan.deleted" + end + + def object_type + "plan" + end + end + end +end diff --git a/app/services/webhooks/plans/updated_service.rb b/app/services/webhooks/plans/updated_service.rb new file mode 100644 index 0000000..ba9e1b8 --- /dev/null +++ b/app/services/webhooks/plans/updated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Plans + class UpdatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::PlanSerializer.new( + object, + root_name: "plan", + includes: %i[charges usage_thresholds taxes minimum_commitment entitlements] + ) + end + + def webhook_type + "plan.updated" + end + + def object_type + "plan" + end + end + end +end diff --git a/app/services/webhooks/retry_service.rb b/app/services/webhooks/retry_service.rb new file mode 100644 index 0000000..2baed27 --- /dev/null +++ b/app/services/webhooks/retry_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + class RetryService < ::BaseService + def initialize(webhook:) + @webhook = webhook + + super + end + + def call + return result.not_found_failure!(resource: "webhook") unless webhook + return result.not_allowed_failure!(code: "is_succeeded") if webhook.succeeded? + + SendHttpWebhookJob.perform_later(webhook) + + result.webhook = webhook + result + end + + private + + attr_reader :webhook + end +end diff --git a/app/services/webhooks/send_http_service.rb b/app/services/webhooks/send_http_service.rb new file mode 100644 index 0000000..e67e571 --- /dev/null +++ b/app/services/webhooks/send_http_service.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Webhooks + class SendHttpService < ::BaseService + Result = BaseResult + + def initialize(webhook:) + @webhook = webhook + + super + end + + def call + webhook.endpoint = webhook.webhook_endpoint.webhook_url + + response = http_client.post_with_response(webhook.payload, webhook.generate_headers) + + mark_webhook_as_succeeded(response) + + result + rescue LagoHttpClient::HttpError, + Net::OpenTimeout, + Net::ReadTimeout, + Net::HTTPBadResponse, + Errno::ECONNRESET, + Errno::ECONNREFUSED, + Errno::EPIPE, + Errno::EHOSTUNREACH, + OpenSSL::SSL::SSLError, + SocketError, + EOFError => e + + retrying = ((webhook.retries + 1) < retry_limit) + mark_webhook_as_unsuccessful(error: e, retrying:) + SendHttpWebhookJob.set(wait: wait_value).perform_later(webhook) if retrying + + result + end + + private + + attr_reader :webhook + + def http_client + @http_client ||= LagoHttpClient::Client.new( + webhook.webhook_endpoint.webhook_url, + read_timeout: timeout_seconds, + write_timeout: timeout_seconds, + open_timeout: timeout_seconds + ) + end + + def timeout_seconds + ENV.fetch("LAGO_WEBHOOK_TIMEOUT_SECONDS", 30).to_i + end + + def retry_limit + ENV.fetch("LAGO_WEBHOOK_ATTEMPTS", 3).to_i + end + + def mark_webhook_as_succeeded(response) + webhook.http_status = response&.code&.to_i + webhook.response = response&.body.presence || {} + webhook.status = :succeeded + webhook.save! + end + + def mark_webhook_as_unsuccessful(error:, retrying:) + if error.is_a?(LagoHttpClient::HttpError) + webhook.http_status = error.error_code + webhook.response = error.error_body + else + webhook.response = error.message + end + webhook.retries += 1 + webhook.last_retried_at = Time.zone.now + webhook.status = retrying ? :retrying : :failed + webhook.save! + end + + def wait_value + # NOTE: This is based on the Rails Active Job wait algorithm + executions = webhook.retries + ((executions**4) + (Kernel.rand * (executions**4) * 0.15)) + 2 + end + end +end diff --git a/app/services/webhooks/subscriptions/canceled_service.rb b/app/services/webhooks/subscriptions/canceled_service.rb new file mode 100644 index 0000000..194d4c1 --- /dev/null +++ b/app/services/webhooks/subscriptions/canceled_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class CanceledService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer] + ) + end + + def webhook_type + "subscription.canceled" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/subscriptions/incomplete_service.rb b/app/services/webhooks/subscriptions/incomplete_service.rb new file mode 100644 index 0000000..bc946c5 --- /dev/null +++ b/app/services/webhooks/subscriptions/incomplete_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class IncompleteService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer] + ) + end + + def webhook_type + "subscription.incomplete" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/subscriptions/started_service.rb b/app/services/webhooks/subscriptions/started_service.rb new file mode 100644 index 0000000..6159dae --- /dev/null +++ b/app/services/webhooks/subscriptions/started_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class StartedService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer entitlements] + ) + end + + def webhook_type + "subscription.started" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/subscriptions/terminated_service.rb b/app/services/webhooks/subscriptions/terminated_service.rb new file mode 100644 index 0000000..f2de629 --- /dev/null +++ b/app/services/webhooks/subscriptions/terminated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class TerminatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer] + ) + end + + def webhook_type + "subscription.terminated" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/subscriptions/termination_alert_service.rb b/app/services/webhooks/subscriptions/termination_alert_service.rb new file mode 100644 index 0000000..6ae0a5a --- /dev/null +++ b/app/services/webhooks/subscriptions/termination_alert_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class TerminationAlertService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer] + ) + end + + def webhook_type + "subscription.termination_alert" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/subscriptions/trial_ended_service.rb b/app/services/webhooks/subscriptions/trial_ended_service.rb new file mode 100644 index 0000000..6390818 --- /dev/null +++ b/app/services/webhooks/subscriptions/trial_ended_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class TrialEndedService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer] + ) + end + + def webhook_type + "subscription.trial_ended" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/subscriptions/updated_service.rb b/app/services/webhooks/subscriptions/updated_service.rb new file mode 100644 index 0000000..dfbabac --- /dev/null +++ b/app/services/webhooks/subscriptions/updated_service.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class UpdatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer entitlements] + ) + end + + def webhook_type + "subscription.updated" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/subscriptions/usage_thresholds_reached_service.rb b/app/services/webhooks/subscriptions/usage_thresholds_reached_service.rb new file mode 100644 index 0000000..7e932a2 --- /dev/null +++ b/app/services/webhooks/subscriptions/usage_thresholds_reached_service.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Webhooks + module Subscriptions + class UsageThresholdsReachedService < Webhooks::BaseService + private + + def object_serializer + ::V1::SubscriptionSerializer.new( + object, + root_name: "subscription", + includes: %i[plan customer usage_threshold applicable_usage_thresholds], + usage_threshold: options[:usage_threshold] + ) + end + + def webhook_type + "subscription.usage_threshold_reached" + end + + def object_type + "subscription" + end + end + end +end diff --git a/app/services/webhooks/usage_monitoring/alert_triggered_service.rb b/app/services/webhooks/usage_monitoring/alert_triggered_service.rb new file mode 100644 index 0000000..0313274 --- /dev/null +++ b/app/services/webhooks/usage_monitoring/alert_triggered_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Webhooks + module UsageMonitoring + class AlertTriggeredService < Webhooks::BaseService + private + + def object_serializer + ::V1::UsageMonitoring::TriggeredAlertSerializer.new( + object, + root_name: object_type + ) + end + + def webhook_type + "alert.triggered" + end + + def object_type + "triggered_alert" + end + end + end +end diff --git a/app/services/webhooks/wallet_transactions/created_service.rb b/app/services/webhooks/wallet_transactions/created_service.rb new file mode 100644 index 0000000..63ed5c8 --- /dev/null +++ b/app/services/webhooks/wallet_transactions/created_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Webhooks + module WalletTransactions + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::WalletTransactionSerializer.new(object, root_name: "wallet_transaction", includes: %i[wallet]) + end + + def webhook_type + "wallet_transaction.created" + end + + def object_type + "wallet_transaction" + end + end + end +end diff --git a/app/services/webhooks/wallet_transactions/updated_service.rb b/app/services/webhooks/wallet_transactions/updated_service.rb new file mode 100644 index 0000000..17fa32e --- /dev/null +++ b/app/services/webhooks/wallet_transactions/updated_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Webhooks + module WalletTransactions + class UpdatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::WalletTransactionSerializer.new(object, root_name: "wallet_transaction", includes: %i[wallet]) + end + + def webhook_type + "wallet_transaction.updated" + end + + def object_type + "wallet_transaction" + end + end + end +end diff --git a/app/services/webhooks/wallets/created_service.rb b/app/services/webhooks/wallets/created_service.rb new file mode 100644 index 0000000..974b71c --- /dev/null +++ b/app/services/webhooks/wallets/created_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Webhooks + module Wallets + class CreatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::WalletSerializer.new(object, root_name: "wallet", includes: %i[recurring_transaction_rules]) + end + + def webhook_type + "wallet.created" + end + + def object_type + "wallet" + end + end + end +end diff --git a/app/services/webhooks/wallets/depleted_ongoing_balance_service.rb b/app/services/webhooks/wallets/depleted_ongoing_balance_service.rb new file mode 100644 index 0000000..8d4a9bf --- /dev/null +++ b/app/services/webhooks/wallets/depleted_ongoing_balance_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Webhooks + module Wallets + class DepletedOngoingBalanceService < Webhooks::BaseService + private + + def object_serializer + ::V1::WalletSerializer.new(object, root_name: "wallet") + end + + def webhook_type + "wallet.depleted_ongoing_balance" + end + + def object_type + "wallet" + end + end + end +end diff --git a/app/services/webhooks/wallets/terminated_service.rb b/app/services/webhooks/wallets/terminated_service.rb new file mode 100644 index 0000000..cc7f02a --- /dev/null +++ b/app/services/webhooks/wallets/terminated_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Webhooks + module Wallets + class TerminatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::WalletSerializer.new(object, root_name: "wallet", includes: %i[recurring_transaction_rules]) + end + + def webhook_type + "wallet.terminated" + end + + def object_type + "wallet" + end + end + end +end diff --git a/app/services/webhooks/wallets/updated_service.rb b/app/services/webhooks/wallets/updated_service.rb new file mode 100644 index 0000000..65c089c --- /dev/null +++ b/app/services/webhooks/wallets/updated_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Webhooks + module Wallets + class UpdatedService < Webhooks::BaseService + private + + def object_serializer + ::V1::WalletSerializer.new(object, root_name: "wallet", includes: %i[recurring_transaction_rules]) + end + + def webhook_type + "wallet.updated" + end + + def object_type + "wallet" + end + end + end +end diff --git a/app/support/dotted_hash.rb b/app/support/dotted_hash.rb new file mode 100644 index 0000000..60e1d4e --- /dev/null +++ b/app/support/dotted_hash.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class DottedHash < Hash + attr_reader :separator + + def initialize(hash = {}, separator: ".") + super() + @separator = separator + to_dotted_hash(hash, recursive_key: "") + end + + private + + def to_dotted_hash(hash, recursive_key: "") + hash.each do |k, v| + key = recursive_key + k.to_s + if v.is_a?(Hash) + to_dotted_hash(v, recursive_key: key + separator) + else + self[key] = v + end + end + end +end diff --git a/app/support/email_sanitizer.rb b/app/support/email_sanitizer.rb new file mode 100644 index 0000000..4a93010 --- /dev/null +++ b/app/support/email_sanitizer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module EmailSanitizer + def self.call(email) + return email if email.blank? + + email + .gsub(Regex::DASH_LOOKALIKES_CHARS, "-") + .gsub(Regex::INVISIBLE_CHARS, "") + .strip + end +end diff --git a/app/support/idempotency.rb b/app/support/idempotency.rb new file mode 100644 index 0000000..7f2add0 --- /dev/null +++ b/app/support/idempotency.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +# Usage: +# +# # Execute an operation idempotently +# Idempotency.transaction do +# Idempotency.unique!(invoice, date: invoice.date, customer_id: invoice.customer_id) + +# # Perform your business logic here +# result = perform_operation +# end +class Idempotency + # Thread-local storage for the current transaction + thread_mattr_accessor :current_transaction + + class IdempotencyError < StandardError; end + + # Represents a transaction context for an idempotent operation + class Transaction + attr_accessor :idempotent_resources + + def initialize + @idempotent_resources = Hash.new { |k, v| k[v] = {} } + end + + def ensure_idempotent! + idempotent_resources.each do |resource, values| + # generate the idempotency key for this resource + idempotency_key = IdempotencyRecords::KeyService.call!(**values).idempotency_key + + # try and generate a resource + result = IdempotencyRecords::CreateService.call( + idempotency_key:, + resource: + ) + if result.failure? + msg = "Idempotency key already exists for resource [#{resource.to_gid}] based on #{values.inspect}." + Rails.logger.warn(msg) + raise IdempotencyError.new(msg) + end + end + end + + # Validates that at least one component has been added + def valid? + !idempotent_resources.empty? + end + end + + # Executes a block within an idempotent transaction. + # use Idempotency.unique! to mark resources as unique + # + # This method wraps the execution in a database transaction to ensure + # atomicity of the operations performed within the block. + # + # @yield A block that contains idempotent operations + # @return [Object] The result of the block or the existing resource if the operation is idempotent + # @raise [Exception] If an error occurs during the block execution + # @raise [ArgumentError] If no components are added to generate an idempotency key + def self.transaction + # Create a new transaction context + self.current_transaction = Transaction.new + + if ApplicationRecord.connection.open_transactions > 0 + raise ArgumentError, "An idempotent_transaction cannot be created in a transaction. (#{ApplicationRecord.connection.open_transactions} open transactions)" + end + + # Ensure the transaction context is cleaned up even if an exception occurs + ActiveRecord::Base.transaction do + # Execute the block first to collect components + begin + original_return = yield + transaction_completed = true + rescue => e + transaction_completed = true + raise e + ensure + raise IdempotencyError.new("You've returned early from an Idempotency transaction, please use `next` instead") unless transaction_completed + end + + # Validate that at least one component was added + unless current_transaction.valid? + raise ArgumentError, "At least one resource must be added" + end + + current_transaction.ensure_idempotent! + + original_return + ensure + # Clean up the transaction context + self.current_transaction = nil + end + end + + # Adds a resource to the idempotency key generation. + # This method can only be called within an Idempotency.transaction block. + # + # @param resource [Object] Which resource we're guaranteeing uniqueness for + # @raise [ArgumentError] If called outside of a transaction block + def self.unique!(resource, **values) + raise ArgumentError, "Idempotency.unique! can only be called within an idempotent_transaction block" unless current_transaction + raise ArgumentError, "Idempotency.unique! expects keyword arguments" if values.empty? + + current_transaction.idempotent_resources[resource].merge!(values) + end +end diff --git a/app/support/regex.rb b/app/support/regex.rb new file mode 100644 index 0000000..e265c9f --- /dev/null +++ b/app/support/regex.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Regex + UUID = /\A[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\z/ + + # Taken from RFC5322 regex: https://www.regextester.com/115911 + EMAIL = %r{ + \A(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)\])\Z + }ix + + INVISIBLE_CHARS = /[\u200B\u200C\u200D\u00A0\u200E\u200F\uFEFF]/ + DASH_LOOKALIKES_CHARS = /[\u2012\u2013\u2014\u2015\u2043\u2212]/ + + # Matches ISO8601 datetime strings, e.g., "2022-09-05T12:23:12Z" or "2022-09-05T12:23:12+00:00" + ISO8601_DATETIME = /\A\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})\z/ +end diff --git a/app/support/retriable_error.rb b/app/support/retriable_error.rb new file mode 100644 index 0000000..e93e389 --- /dev/null +++ b/app/support/retriable_error.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +class RetriableError < StandardError; end diff --git a/app/support/timezones.rb b/app/support/timezones.rb new file mode 100644 index 0000000..ce1b929 --- /dev/null +++ b/app/support/timezones.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Timezones + # Fixes a desync between the timezone names and the timezone identifiers + # in rails and ruby + MAPPING = ActiveSupport::TimeZone::MAPPING.merge({ + "Yangon" => "Asia/Yangon", + "Kyiv" => "Europe/Kyiv", + "Greenland" => "America/Nuuk" + }).except("Rangoon") + + class << self + def all + MAPPING.each_with_object([]) do |(_, zone), result| + result << ActiveSupport::TimeZone.new(zone) + end + end + end +end diff --git a/app/validators/adyen_url_validator.rb b/app/validators/adyen_url_validator.rb new file mode 100644 index 0000000..9c5e8d7 --- /dev/null +++ b/app/validators/adyen_url_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AdyenUrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :url_invalid) unless url_valid?(value) + end + + private + + def url_valid?(url) + url =~ %r{.+://.+} + end +end diff --git a/app/validators/country_code_validator.rb b/app/validators/country_code_validator.rb new file mode 100644 index 0000000..910c73e --- /dev/null +++ b/app/validators/country_code_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CountryCodeValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :country_code_invalid) unless valid?(value) + end + + protected + + def valid?(value) + value && ISO3166::Country.new(value).present? + end +end diff --git a/app/validators/email_array_validator.rb b/app/validators/email_array_validator.rb new file mode 100644 index 0000000..48bcf05 --- /dev/null +++ b/app/validators/email_array_validator.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class EmailArrayValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + unless value.is_a? Array + record.errors.add(attribute, "must_be_an_array") + return + end + + value.each_with_index do |email, index| + unless valid? email + record.errors.add(attribute, "invalid_email_format[#{index},#{email}]") + end + end + end + + protected + + def valid?(value) + value&.match(Regex::EMAIL) + end +end diff --git a/app/validators/email_validator.rb b/app/validators/email_validator.rb new file mode 100644 index 0000000..0a0356b --- /dev/null +++ b/app/validators/email_validator.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class EmailValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :invalid_email_format) unless valid?(value) + end + + protected + + def valid?(value) + return false if value.blank? + + # `-1` to keep empty emails after the last comma e.g. "user@domain.com,," + emails = value.split(",", -1).map(&:strip) + + emails.all? { |email| email.match?(Regex::EMAIL) } + end +end diff --git a/app/validators/image_validator.rb b/app/validators/image_validator.rb new file mode 100644 index 0000000..ce6afea --- /dev/null +++ b/app/validators/image_validator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ImageValidator < ActiveModel::EachValidator + def validate_each(record, attribute, _value) + record.errors.add(attribute, :invalid_size) unless valid_size?(record, attribute) + record.errors.add(attribute, :invalid_content_type) unless valid_extension?(record, attribute) + end + + protected + + def valid_size?(record, attribute) + record.__send__(attribute).blob.byte_size <= options[:max_size] + end + + def valid_extension?(record, attribute) + content_type = record.__send__(attribute).blob.content_type + options[:authorized_content_type].include?(content_type) + end +end diff --git a/app/validators/language_code_validator.rb b/app/validators/language_code_validator.rb new file mode 100644 index 0000000..0e0fd0e --- /dev/null +++ b/app/validators/language_code_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class LanguageCodeValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :language_code_invalid) unless valid?(value) + end + + protected + + def valid?(value) + value && I18n.available_locales.include?(value.to_sym) + end +end diff --git a/app/validators/timezone_validator.rb b/app/validators/timezone_validator.rb new file mode 100644 index 0000000..52eb66f --- /dev/null +++ b/app/validators/timezone_validator.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class TimezoneValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :invalid_timezone) unless valid?(value) + end + + protected + + def valid?(value) + value == "UTC" || Timezones::MAPPING.value?(value) + end +end diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb new file mode 100644 index 0000000..f2d0bd7 --- /dev/null +++ b/app/validators/url_validator.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class UrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + record.errors.add(attribute, :url_invalid) unless url_valid?(value) + end + + private + + def url_valid?(url) + url = URI.parse(url) + url.host.present? && (url.is_a?(URI::HTTP) || url.is_a?(URI::HTTPS)) + rescue + false + end +end diff --git a/app/views/api_key_mailer/created.slim b/app/views/api_key_mailer/created.slim new file mode 100644 index 0000000..fdddb46 --- /dev/null +++ b/app/views/api_key_mailer/created.slim @@ -0,0 +1,35 @@ +div style='margin-bottom: 32px;width: 80px;height: 24px;' + svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24" + g clip-path="url(#a)" + g fill="#19212E" clip-path="url(#b)" + path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z" + g clip-path="url(#c)" + g fill="#19212E" clip-path="url(#d)" + path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z" + path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6" + path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3" + defs + clippath#a + path fill="#fff" d="M0 0h80v24H0z" + clippath#b + path fill="#fff" d="M27.5 1.263H80V24H27.5z" + clippath#c + path fill="#fff" d="M0 0h20v20.21H0z" + clippath#d + path fill="#fff" d="M0 0h20v20.21H0z" + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.greetings') + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.key_has_been_created', organization_name: @organization_name) + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.change_notice') + +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.created.access_warning') + +div style='width: 100%;height: 1px;background-color: #D9DEE7;margin-bottom: 32px;' +div style="color: #66758F;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;" + = I18n.t('email.api_key.created.email_info') diff --git a/app/views/api_key_mailer/destroyed.slim b/app/views/api_key_mailer/destroyed.slim new file mode 100644 index 0000000..b0d84ba --- /dev/null +++ b/app/views/api_key_mailer/destroyed.slim @@ -0,0 +1,35 @@ +div style='margin-bottom: 32px;width: 80px;height: 24px;' + svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24" + g clip-path="url(#a)" + g fill="#19212E" clip-path="url(#b)" + path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z" + g clip-path="url(#c)" + g fill="#19212E" clip-path="url(#d)" + path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z" + path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6" + path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3" + defs + clippath#a + path fill="#fff" d="M0 0h80v24H0z" + clippath#b + path fill="#fff" d="M27.5 1.263H80V24H27.5z" + clippath#c + path fill="#fff" d="M0 0h20v20.21H0z" + clippath#d + path fill="#fff" d="M0 0h20v20.21H0z" + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.destroyed.greetings') + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.destroyed.key_has_been_destroyed', organization_name: @organization_name) + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.destroyed.change_notice') + +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.destroyed.access_warning') + +div style='width: 100%;height: 1px;background-color: #D9DEE7;margin-bottom: 32px;' +div style="color: #66758F;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;" + = I18n.t('email.api_key.destroyed.email_info') diff --git a/app/views/api_key_mailer/rotated.slim b/app/views/api_key_mailer/rotated.slim new file mode 100644 index 0000000..8ee03dc --- /dev/null +++ b/app/views/api_key_mailer/rotated.slim @@ -0,0 +1,35 @@ +div style='margin-bottom: 32px;width: 80px;height: 24px;' + svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24" + g clip-path="url(#a)" + g fill="#19212E" clip-path="url(#b)" + path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z" + g clip-path="url(#c)" + g fill="#19212E" clip-path="url(#d)" + path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z" + path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6" + path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3" + defs + clippath#a + path fill="#fff" d="M0 0h80v24H0z" + clippath#b + path fill="#fff" d="M27.5 1.263H80V24H27.5z" + clippath#c + path fill="#fff" d="M0 0h20v20.21H0z" + clippath#d + path fill="#fff" d="M0 0h20v20.21H0z" + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.rotated.greetings') + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.rotated.key_has_been_rotated', organization_name: @organization_name) + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.rotated.change_notice') + +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.api_key.rotated.access_warning') + +div style='width: 100%;height: 1px;background-color: #D9DEE7;margin-bottom: 32px;' +div style="color: #66758F;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;" + = I18n.t('email.api_key.rotated.email_info') diff --git a/app/views/credit_note_mailer/created.slim b/app/views/credit_note_mailer/created.slim new file mode 100644 index 0000000..5e56dd4 --- /dev/null +++ b/app/views/credit_note_mailer/created.slim @@ -0,0 +1,53 @@ +table cellpadding="0" cellspacing="0" style="margin: auto; padding-bottom: 24px" + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: center;" + = I18n.t('email.credit_note.created.credit_note_from', billing_entity_name: @billing_entity.name) + tr + td style="color: #19212e; font-size: 32px; font-weight: 700; line-height: 40px; letter-spacing: 0em; text-align: center;" + = @credit_note.total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: center;" + - formatted_date = I18n.l(@credit_note.issuing_date, format: :default) + + - if [@credit_note.credited?, @credit_note.refunded?, @credit_note.has_offset?].count(true) > 1 + = I18n.t("email.credit_note.created.issued_notice", date: formatted_date) + - elsif @credit_note.credited? + = I18n.t("email.credit_note.created.credited_notice", date: formatted_date) + - elsif @credit_note.refunded? + = I18n.t("email.credit_note.created.refunded_notice", date: formatted_date) + - elsif @credit_note.has_offset? + = I18n.t('email.credit_note.created.offset_invoice_notice', invoice_number: @credit_note.invoice.number, date: formatted_date) + +table cellpadding="0" cellspacing="0" style="width: 100%; padding: 24px 0; border-top: 1px solid #d9dee7; border-bottom: 1px solid #d9dee7;" + tr + td + table cellpadding="0" cellspacing="0" style="width: 100%" + tr + tr + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; color: #66758f; white-space: nowrap; padding-bottom: 4px;" + = I18n.t('email.credit_note.created.credit_note_number') + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding-bottom: 4px;" + = @credit_note.number + tr + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; color: #66758f; white-space: nowrap; padding-bottom: 4px;" + = I18n.t('email.credit_note.created.invoice_number') + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding-bottom: 4px;" + = @credit_note.invoice.number + tr + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; color: #66758f; white-space: nowrap;" + = I18n.t('email.credit_note.created.issue_date') + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap;" + = I18n.l(@credit_note.issuing_date, format: :default) + +- if @pdfs_enabled + table cellpadding="0" cellspacing="0" style="width: 100%; padding: 24px 0; border-bottom: 1px solid #d9dee7;" + tr + td + table cellpadding="0" cellspacing="0" style="margin: auto" + tr + td style="padding-right: 8px;" + svg height="16px" width="16px" fill="#006CFA" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" style="padding-top: 4px" + path d="M12.705 10.27C12.5176 10.0838 12.2642 9.97921 12 9.97921C11.7358 9.97921 11.4824 10.0838 11.295 10.27L8.99999 12.75V1C8.99999 0.734784 8.89463 0.48043 8.7071 0.292893C8.51956 0.105357 8.26521 0 7.99999 0C7.73477 0 7.48042 0.105357 7.29288 0.292893C7.10535 0.48043 6.99999 0.734784 6.99999 1V12.755L4.70499 10.255C4.51763 10.0688 4.26417 9.96421 3.99999 9.96421C3.7358 9.96421 3.48235 10.0688 3.29499 10.255C3.19898 10.3482 3.12265 10.4597 3.07053 10.583C3.0184 10.7062 2.99155 10.8387 2.99155 10.9725C2.99155 11.1063 3.0184 11.2388 3.07053 11.362C3.12265 11.4853 3.19898 11.5968 3.29499 11.69L6.93999 15.54C7.22124 15.8209 7.60249 15.9787 7.99999 15.9787C8.39749 15.9787 8.77874 15.8209 9.05999 15.54L12.705 11.69C12.7987 11.597 12.8731 11.4864 12.9239 11.3646C12.9746 11.2427 13.0008 11.112 13.0008 10.98C13.0008 10.848 12.9746 10.7173 12.9239 10.5954C12.8731 10.4736 12.7987 10.363 12.705 10.27Z" + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; color: #19212e;" + a style="text-decoration: none" href=@credit_note.file_url + = I18n.t('email.credit_note.created.download') diff --git a/app/views/data_export_mailer/completed.slim b/app/views/data_export_mailer/completed.slim new file mode 100644 index 0000000..ced8afa --- /dev/null +++ b/app/views/data_export_mailer/completed.slim @@ -0,0 +1,37 @@ +div style='margin-bottom: 32px;width: 80px;height: 24px;' + svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24" + g clip-path="url(#a)" + g fill="#19212E" clip-path="url(#b)" + path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z" + g clip-path="url(#c)" + g fill="#19212E" clip-path="url(#d)" + path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z" + path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6" + path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3" + defs + clippath#a + path fill="#fff" d="M0 0h80v24H0z" + clippath#b + path fill="#fff" d="M27.5 1.263H80V24H27.5z" + clippath#c + path fill="#fff" d="M0 0h20v20.21H0z" + clippath#d + path fill="#fff" d="M0 0h20v20.21H0z" +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.data_export.completed.greetings') +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.data_export.completed.intro', resource_type: @resource_type) +table style='margin-bottom: 32px' width="100%" cellspacing="0" cellpadding="0" + tr + td + table cellspacing="0" cellpadding="0" + tr + td style="border-radius: 12px;" bgcolor="#006CFA" + a href="#{@data_export.file_url}" download="#{@data_export.filename}" style="padding: 10px 16px;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;font-weight: 400;font-style: normal;line-height: 24px;" + = I18n.t('email.data_export.completed.main_cta_label') +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.data_export.completed.fallback_text') +div style='font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;' + = I18n.t('email.data_export.completed.thanks') +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.data_export.completed.lago_team') diff --git a/app/views/helpers/charge_display_helper.rb b/app/views/helpers/charge_display_helper.rb new file mode 100644 index 0000000..50b9922 --- /dev/null +++ b/app/views/helpers/charge_display_helper.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ChargeDisplayHelper + def self.format_min_amount(charge) + if charge.applied_pricing_unit + MoneyHelper.format_pricing_unit( + charge.min_amount_cents.to_d / 100, + charge.pricing_unit + ) + else + money = Money.from_cents(charge.min_amount_cents, charge.plan.amount.currency) + MoneyHelper.format(money) + end + end +end diff --git a/app/views/helpers/fee_boundaries_helper.rb b/app/views/helpers/fee_boundaries_helper.rb new file mode 100644 index 0000000..803a34a --- /dev/null +++ b/app/views/helpers/fee_boundaries_helper.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +class FeeBoundariesHelper + class BillingPeriod + def initialize(from_datetime:, to_datetime:) + @from_datetime = parse_datetime(from_datetime) + @to_datetime = parse_datetime(to_datetime) + end + + attr_reader :from_datetime, :to_datetime + + def to_grouping_key + [from_datetime&.to_date, to_datetime&.to_date] + end + + def ==(other) + to_grouping_key == other.to_grouping_key + end + + def eql?(other) + self == other + end + + def <=>(other) + [from_datetime, to_datetime] <=> [other.from_datetime, other.to_datetime] + end + + delegate :hash, to: :to_grouping_key + + def parse_datetime(value) + return value if value.is_a?(Time) || value.is_a?(DateTime) + return nil if value.blank? + + Time.zone.parse(value.to_s) + end + end + + class GroupedFees + def initialize(billing_period:, subscription_fee:, fixed_charge_fees:, charge_fees:, commitment_fee:) + @billing_period = billing_period + @subscription_fee = subscription_fee + @fixed_charge_fees = fixed_charge_fees + @charge_fees = charge_fees + @commitment_fee = commitment_fee + end + + attr_reader :billing_period, :subscription_fee, :fixed_charge_fees, :charge_fees, :commitment_fee + + def has_any_fees? + subscription_fee.present? || + fixed_charge_fees.any? || + charge_fees.any? || + commitment_fee.present? + end + + def has_displayable_charges? + # Filter out true-up fees and zero-unit fees for display purposes + charge_fees.any? { |f| f.true_up_parent_fee.nil? && f.units.positive? } + end + + def has_displayable_fixed_charges? + fixed_charge_fees.any? { |f| f.units.positive? } + end + end + + def self.billing_period_for(fee, invoice_subscription:) + case fee.fee_type.to_sym + when :subscription + subscription_fee_billing_period(fee, invoice_subscription) + when :charge + charge_fee_billing_period(fee, invoice_subscription) + when :fixed_charge + fixed_charge_fee_billing_period(fee, invoice_subscription) + when :commitment + commitment_fee_billing_period(fee, invoice_subscription) + else + # Fallback to invoice_subscription boundaries + fallback_billing_period(invoice_subscription) + end + end + + def self.group_fees_by_billing_period(fees, invoice_subscription:) + # Categorize fees by type and billing period + periods_with_fees = Hash.new do |h, k| + h[k] = {subscription: nil, fixed_charges: [], charges: [], commitment: nil} + end + + fees.each do |fee| + period = billing_period_for(fee, invoice_subscription:) + + case fee.fee_type.to_sym + when :subscription + periods_with_fees[period][:subscription] = fee + when :fixed_charge + periods_with_fees[period][:fixed_charges] << fee + when :charge + periods_with_fees[period][:charges] << fee + when :commitment + periods_with_fees[period][:commitment] = fee + end + end + + periods_with_fees.map do |period, fee_groups| + GroupedFees.new( + billing_period: period, + subscription_fee: fee_groups[:subscription], + fixed_charge_fees: sort_fees_alphabetically(fee_groups[:fixed_charges]), + charge_fees: sort_fees_alphabetically(fee_groups[:charges]), + commitment_fee: fee_groups[:commitment] + ) + end.sort_by(&:billing_period) + end + + def self.format_billing_period(billing_period, customer:) + timezone = customer.applicable_timezone + from_date = billing_period.from_datetime&.in_time_zone(timezone)&.to_date + to_date = billing_period.to_datetime&.in_time_zone(timezone)&.to_date + + I18n.t( + "invoice.fees_from_to_date", + from_date: I18n.l(from_date, format: :default), + to_date: I18n.l(to_date, format: :default) + ) + end + + def self.subscription_fee_billing_period(fee, invoice_subscription) + from = fee.properties&.dig("from_datetime") + to = fee.properties&.dig("to_datetime") + + if from && to + BillingPeriod.new(from_datetime: from, to_datetime: to) + else + fallback_billing_period(invoice_subscription) + end + end + + def self.charge_fee_billing_period(fee, invoice_subscription) + boundaries = fee.date_boundaries + from = boundaries[:from_date] + to = boundaries[:to_date] + + if from && to + BillingPeriod.new(from_datetime: from, to_datetime: to) + else + BillingPeriod.new( + from_datetime: invoice_subscription.charges_from_datetime, + to_datetime: invoice_subscription.charges_to_datetime + ) + end + end + + def self.fixed_charge_fee_billing_period(fee, invoice_subscription) + from = fee.properties&.dig("fixed_charges_from_datetime") + to = fee.properties&.dig("fixed_charges_to_datetime") + + if from && to + BillingPeriod.new(from_datetime: from, to_datetime: to) + else + BillingPeriod.new( + from_datetime: invoice_subscription.fixed_charges_from_datetime, + to_datetime: invoice_subscription.fixed_charges_to_datetime + ) + end + end + + def self.commitment_fee_billing_period(fee, invoice_subscription) + from = fee.properties&.dig("from_datetime") + to = fee.properties&.dig("to_datetime") + + if from.present? && to.present? + BillingPeriod.new(from_datetime: from, to_datetime: to) + else + # For legacy commitment fees without properties, derive from invoice subscription + derive_commitment_billing_period(fee, invoice_subscription) + end + end + + def self.derive_commitment_billing_period(fee, invoice_subscription) + subscription = invoice_subscription.subscription + + # For pay in advance plans, commitment reconciles the PREVIOUS period + # For pay in arrears plans, commitment reconciles the CURRENT period + target_invoice_subscription = if subscription.plan.pay_in_advance? + invoice_subscription.previous_invoice_subscription + else + invoice_subscription + end + + BillingPeriod.new( + from_datetime: target_invoice_subscription.from_datetime, + to_datetime: target_invoice_subscription.to_datetime + ) + end + + def self.fallback_billing_period(invoice_subscription) + BillingPeriod.new( + from_datetime: invoice_subscription.from_datetime, + to_datetime: invoice_subscription.to_datetime + ) + end + + def self.sort_fees_alphabetically(fees) + fees.sort_by { |f| f.invoice_sorting_clause.to_s.downcase } + end + + private_class_method :subscription_fee_billing_period, + :charge_fee_billing_period, + :fixed_charge_fee_billing_period, + :commitment_fee_billing_period, + :derive_commitment_billing_period, + :fallback_billing_period, + :sort_fees_alphabetically +end diff --git a/app/views/helpers/fee_display_helper.rb b/app/views/helpers/fee_display_helper.rb new file mode 100644 index 0000000..bb80349 --- /dev/null +++ b/app/views/helpers/fee_display_helper.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class FeeDisplayHelper + def self.grouped_by_display(fee) + return "" if !fee.charge? || fee.grouped_by.values.compact.blank? + + " • #{fee.grouped_by.values.compact.join(" • ")}" + end + + def self.should_display_subscription_fee?(invoice_subscription) + return false if invoice_subscription.blank? + return false if invoice_subscription.invoice.progressive_billing? + return true if invoice_subscription.charge_amount_cents.zero? + + invoice_subscription.subscription_amount_cents.positive? + end + + def self.format_precise_unit_amount(fee) + amount = if fee.pricing_unit_usage + fee.pricing_unit_usage.precise_unit_amount + else + fee.precise_unit_amount + end + + format_with_precision(fee, amount) + end + + def self.format_with_precision(fee, amount) + casted_amount = BigDecimal(amount) + + if fee.pricing_unit_usage + MoneyHelper.format_pricing_unit_with_precision( + casted_amount, + fee.pricing_unit_usage.currency + ) + else + MoneyHelper.format_with_precision(casted_amount, fee.currency) + end + end + + def self.format_as_currency(fee, amount) + if fee.pricing_unit_usage + MoneyHelper.format_pricing_unit( + BigDecimal(amount), + fee.pricing_unit_usage.currency + ) + else + money = amount.to_money(fee.currency) + MoneyHelper.format(money) + end + end + + def self.format_amount(fee) + if fee.pricing_unit_usage + MoneyHelper.format_pricing_unit( + fee.pricing_unit_usage.amount_cents.to_d / 100, + fee.pricing_unit_usage.currency + ) + else + MoneyHelper.format(fee.amount) + end + end +end diff --git a/app/views/helpers/interval_helper.rb b/app/views/helpers/interval_helper.rb new file mode 100644 index 0000000..7672012 --- /dev/null +++ b/app/views/helpers/interval_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class IntervalHelper + def self.interval_name(interval) + case interval.to_sym + when :weekly + I18n.t("invoice.week") + when :monthly + I18n.t("invoice.month") + when :yearly + I18n.t("invoice.year") + when :quarterly + I18n.t("invoice.quarter") + when :semiannual + I18n.t("invoice.half_year") + end + end +end diff --git a/app/views/helpers/line_break_helper.rb b/app/views/helpers/line_break_helper.rb new file mode 100644 index 0000000..3ec7fdd --- /dev/null +++ b/app/views/helpers/line_break_helper.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class LineBreakHelper + def self.break_lines(text) + escaped_text = ERB::Util.html_escape(text.to_s) + escaped_text.split("\n").reject(&:blank?).join("
").html_safe # rubocop:disable Rails/OutputSafety + end +end diff --git a/app/views/helpers/money_helper.rb b/app/views/helpers/money_helper.rb new file mode 100644 index 0000000..5ab1dbb --- /dev/null +++ b/app/views/helpers/money_helper.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class MoneyHelper + SYMBOLS_CURRENCIES = %w[$ € £ ¥].freeze + DEFAULT_STUB_CURRENCY = "USD" + + def self.format(money) + money&.format( + format: currency_format(money&.currency), + decimal_mark: I18n.t("money.decimal_mark"), + thousands_separator: I18n.t("money.thousands_separator") + ) + end + + def self.format_with_precision(amount_cents, currency) + amount_cents = normalize_precision(amount_cents) + money = Utils::MoneyWithPrecision.from_amount(amount_cents, currency) + format(money) + end + + def self.format_pricing_unit(amount_cents, currency) + format_with_custom_currency(amount_cents, currency.short_name) + end + + def self.format_pricing_unit_with_precision(amount_cents, currency) + amount_cents = normalize_precision(amount_cents) + format_with_custom_currency(amount_cents, currency.short_name) + end + + def self.currency_format(money_currency) + if SYMBOLS_CURRENCIES.include?(money_currency&.symbol) + I18n.t("money.format") + else + I18n.t("money.custom_format", iso_code: money_currency&.iso_code) + end + end + + def self.normalize_precision(amount_cents) + if amount_cents < 1 + BigDecimal("%.6g" % amount_cents) + else + amount_cents.round(6) + end + end + + def self.format_with_custom_currency(amount, currency_code) + stub = Utils::MoneyWithPrecision.from_amount(amount, DEFAULT_STUB_CURRENCY) + stub.format( + format: "%n #{currency_code}", + decimal_mark: I18n.t("money.decimal_mark"), + thousands_separator: I18n.t("money.thousands_separator") + ) + end +end diff --git a/app/views/helpers/rounding_helper.rb b/app/views/helpers/rounding_helper.rb new file mode 100644 index 0000000..a1a26d9 --- /dev/null +++ b/app/views/helpers/rounding_helper.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RoundingHelper + def self.round_decimal_part(num, decimal_sig_figs = 6) + return "0" if num.zero? + return BigDecimal("%.#{decimal_sig_figs}g" % num).to_s if num.abs < 1 + + rounded = BigDecimal(num.to_s).round(decimal_sig_figs) + rounded.frac.zero? ? rounded.to_i.to_s : rounded.to_s + end +end diff --git a/app/views/helpers/slim_helper.rb b/app/views/helpers/slim_helper.rb new file mode 100644 index 0000000..a2241c8 --- /dev/null +++ b/app/views/helpers/slim_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class SlimHelper + PDF_LOGO_FILENAME = "lago-logo-invoice.png" + + def self.render(path, context, **locals) + Slim::Template.new do + File.read( + Rails.root.join("app/views/#{path}.slim"), + encoding: "UTF-8" + ) + end.render(context, **locals) + end +end diff --git a/app/views/helpers/tax_helper.rb b/app/views/helpers/tax_helper.rb new file mode 100644 index 0000000..1e8973d --- /dev/null +++ b/app/views/helpers/tax_helper.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class TaxHelper + def self.applied_taxes(object) + template = if object.nil? + 'div = "0.0%"' + else + <<~SLIM_TEMPLATE + - (applied_taxes.present? ? applied_taxes.order(tax_rate: :desc).pluck(:tax_rate) : [0.0]).each do |tax| + div = tax.to_s + "%" + SLIM_TEMPLATE + end + + Slim::Template.new { template }.render(object) + end +end diff --git a/app/views/invoice_mailer/created.slim b/app/views/invoice_mailer/created.slim new file mode 100644 index 0000000..5ed29ab --- /dev/null +++ b/app/views/invoice_mailer/created.slim @@ -0,0 +1,38 @@ +table cellpadding="0" cellspacing="0" style="margin: auto; padding-bottom: 24px" + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: center;" + = I18n.t('email.invoice.finalized.invoice_from', billing_entity_name: @billing_entity.name) + tr + td style="color: #19212e; font-size: 32px; font-weight: 700; line-height: 40px; letter-spacing: 0em; text-align: center;" + = @invoice.total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: center;" + = I18n.t('email.invoice.finalized.issued_on', date: I18n.l(@invoice.issuing_date, format: :default)) +table cellpadding="0" cellspacing="0" style="width: 100%; padding: 24px 0; border-top: 1px solid #d9dee7; border-bottom: 1px solid #d9dee7;" + tr + td + table cellpadding="0" cellspacing="0" style="width: 100%" + tr + tr + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; color: #66758f; white-space: nowrap; padding-bottom: 4px;" + = I18n.t('email.invoice.finalized.invoice_number') + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding-bottom: 4px;" + = @invoice.number + tr + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; color: #66758f; white-space: nowrap;" + = I18n.t('email.invoice.finalized.issue_date') + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap;" + = I18n.l(@invoice.issuing_date, format: :default) + +- if @pdfs_enabled + table cellpadding="0" cellspacing="0" style="width: 100%; padding: 24px 0; border-bottom: 1px solid #d9dee7;" + tr + td + table cellpadding="0" cellspacing="0" style="margin: auto" + tr + td style="padding-right: 8px;" + svg height="16px" width="16px" fill="#006CFA" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" style="padding-top: 4px" + path d="M12.705 10.27C12.5176 10.0838 12.2642 9.97921 12 9.97921C11.7358 9.97921 11.4824 10.0838 11.295 10.27L8.99999 12.75V1C8.99999 0.734784 8.89463 0.48043 8.7071 0.292893C8.51956 0.105357 8.26521 0 7.99999 0C7.73477 0 7.48042 0.105357 7.29288 0.292893C7.10535 0.48043 6.99999 0.734784 6.99999 1V12.755L4.70499 10.255C4.51763 10.0688 4.26417 9.96421 3.99999 9.96421C3.7358 9.96421 3.48235 10.0688 3.29499 10.255C3.19898 10.3482 3.12265 10.4597 3.07053 10.583C3.0184 10.7062 2.99155 10.8387 2.99155 10.9725C2.99155 11.1063 3.0184 11.2388 3.07053 11.362C3.12265 11.4853 3.19898 11.5968 3.29499 11.69L6.93999 15.54C7.22124 15.8209 7.60249 15.9787 7.99999 15.9787C8.39749 15.9787 8.77874 15.8209 9.05999 15.54L12.705 11.69C12.7987 11.597 12.8731 11.4864 12.9239 11.3646C12.9746 11.2427 13.0008 11.112 13.0008 10.98C13.0008 10.848 12.9746 10.7173 12.9239 10.5954C12.8731 10.4736 12.7987 10.363 12.705 10.27Z" + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; color: #19212e;" + a style="text-decoration: none" href=@invoice.file_url + = I18n.t('email.invoice.finalized.download') diff --git a/app/views/layouts/mailer.slim b/app/views/layouts/mailer.slim new file mode 100644 index 0000000..24c4839 --- /dev/null +++ b/app/views/layouts/mailer.slim @@ -0,0 +1,77 @@ +html + head + meta http-equiv="Content-Type" content="text/html; charset=utf-8" + meta charset="utf-8" + meta name="viewport" content="width=device-width,initial-scale=1" + meta name="x-apple-disable-message-reformatting" + css: + table td { + mso-line-height-rule: exactly; + } + table, + td { + font-family: Helvetica Neue, Helvetica, Arial; + } + .main-table { + margin: auto; + max-width: 600px; + min-width: 600px; + } + + @media screen and (max-width: 776px) { + .main-table { + max-width: 360px; + min-width: 360px; + } + } + a img { + border: none; + } + a[x-apple-data-detectors] { + color: inherit !important; + text-decoration: none !important; + } + a, + a:visited, + a:hover, + a:active { + color: inherit; + } + body style="margin: 0; padding: 0; word-spacing: normal; background-color: #f3f4f6; -webkit-text-size-adjust: 100%; -ms-text-size-adjust: 100%;" + table style="width: 100%; border: 0; margin: 0; padding: 0" cellspacing="0" cellpadding="0" + tr + td + table.main-table cellpadding="0" cellspacing="0" style="padding: 64px 0" + - if @organization.present? + tr + td + table cellpadding="0" cellspacing="0" style="margin: auto; padding-bottom: 32px" + tr + - if @organization.logo.present? + td valign="middle" style="vertical-align: middle; padding-right: 12px" + a style="text-decoration: none" href="" + img src=@organization.logo_url valign="middle" style="vertical-align: middle; height: 32px; width: 32px; border-radius: 8px;" + td style="height: 32px; font-size: 20px; font-weight: 700; line-height: 28px; letter-spacing: 0em;" + = @organization.name + tr + td + table cellpadding="0" cellspacing="0" style="width: 100%; background-color: #fff; border: 1px solid #d9dee7; border-radius: 12px; padding: 32px;" + tr + td + = yield + - if @organization.present? && @organization.email.present? + table cellpadding="0" cellspacing="0" style="margin: auto; padding-top: 24px" + tr + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: center; color: #66758f;" + = I18n.t('email.questions') + |   + a style="text-decoration: none; color: #006cfa" href="mailto:#{@organization.email}" + = @organization.email + + - if @show_lago_logo + table cellpadding="0" cellspacing="0" style="margin: auto; padding-top: 32px" + tr + td style="font-size: 12px; font-weight: 400; line-height: 16px; letter-spacing: 0em; text-align: center; color: #8c95a6; padding-right: 4px;" + = I18n.t('email.powered_by') + td style="padding-right: 4px; padding-top: 2px" + img src="#{@lago_logo_url}" style="width: 40px; height: 12px;" width="40" height="12" alt="Lago Logo" diff --git a/app/views/organization_mailer/authentication_methods_updated.slim b/app/views/organization_mailer/authentication_methods_updated.slim new file mode 100644 index 0000000..250d403 --- /dev/null +++ b/app/views/organization_mailer/authentication_methods_updated.slim @@ -0,0 +1,38 @@ +div style='margin-bottom: 32px;width: 80px;height: 24px;' + svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24" + g clip-path="url(#a)" + g fill="#19212E" clip-path="url(#b)" + path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z" + g clip-path="url(#c)" + g fill="#19212E" clip-path="url(#d)" + path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z" + path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6" + path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3" + defs + clippath#a + path fill="#fff" d="M0 0h80v24H0z" + clippath#b + path fill="#fff" d="M27.5 1.263H80V24H27.5z" + clippath#c + path fill="#fff" d="M0 0h20v20.21H0z" + clippath#d + path fill="#fff" d="M0 0h20v20.21H0z" + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.organization.authentication_methods_updated.changes', user_email: @user.email) + +ul + - if @deletions.present? + li style='margin-bottom: 12px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.organization.authentication_methods_updated.disabled', login_method: @deletions.map{ |s| s.tr('_', ' ').titleize }.to_sentence) + + - if @additions.present? + li style='margin-bottom: 12px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.organization.authentication_methods_updated.enabled', login_method: @additions.map{ |s| s.tr('_', ' ').titleize }.to_sentence) + +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('email.organization.authentication_methods_updated.reasoning') + +div style='width: 100%;height: 1px;background-color: #D9DEE7;margin-bottom: 32px;' +div style="color: #66758F;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;" + = I18n.t('email.organization.authentication_methods_updated.email_info') diff --git a/app/views/password_reset_mailer/requested.slim b/app/views/password_reset_mailer/requested.slim new file mode 100644 index 0000000..d583fed --- /dev/null +++ b/app/views/password_reset_mailer/requested.slim @@ -0,0 +1,42 @@ +div style='margin-bottom: 32px;width: 80px;height: 24px;' + svg xmlns="http://www.w3.org/2000/svg" fill="none" viewbox="0 0 80 24" + g clip-path="url(#a)" + g fill="#19212E" clip-path="url(#b)" + path d="M69.85 18.161a5.529 5.529 0 0 1-2.324-2.326c-.557-1.003-.819-2.158-.819-3.463 0-1.305.279-2.46.819-3.463.54-1.004 1.326-1.773 2.324-2.326 1.015-.535 2.178-.82 3.504-.82s2.488.268 3.503.82a5.625 5.625 0 0 1 2.325 2.326c.54 1.004.818 2.158.818 3.463 0 1.322-.278 2.476-.819 3.48a5.678 5.678 0 0 1-2.324 2.309c-1.015.535-2.177.82-3.503.82s-2.505-.268-3.504-.82Zm5.795-3.095c.557-.686.852-1.59.852-2.677 0-1.104-.279-1.991-.852-2.677-.572-.686-1.326-1.037-2.291-1.037-.95 0-1.703.351-2.276 1.037-.573.686-.851 1.573-.851 2.677s.278 2.008.851 2.677c.573.686 1.326 1.037 2.276 1.037.965 0 1.719-.351 2.291-1.037ZM65.512 5.931v12.515c0 1.69-.556 3.044-1.637 4.048-1.097 1.004-2.8 1.506-5.108 1.506-1.784 0-3.224-.401-4.321-1.204-1.097-.804-1.686-1.941-1.768-3.413h3.487c.163.619.49 1.087.965 1.422.475.334 1.114.502 1.9.502.982 0 1.751-.252 2.291-.753.54-.502.819-1.255.819-2.226v-1.355c-.917 1.188-2.227 1.774-3.896 1.774-1.114 0-2.112-.268-2.996-.803-.884-.536-1.572-1.289-2.08-2.276-.507-.97-.752-2.124-.752-3.43 0-1.288.245-2.425.753-3.413a5.367 5.367 0 0 1 2.095-2.275c.884-.535 1.9-.803 3.012-.803 1.654 0 2.963.653 3.93 1.958L62.5 5.93h3.012Zm-4.24 8.984c.557-.669.835-1.539.835-2.61 0-1.087-.278-1.974-.835-2.66-.556-.686-1.31-1.02-2.242-1.02-.934 0-1.687.334-2.243 1.02-.573.67-.852 1.556-.852 2.644 0 1.087.279 1.974.852 2.643.573.67 1.31 1.004 2.242 1.004.934-.017 1.687-.351 2.243-1.02ZM51.22 5.931v12.9h-3.077l-.294-1.807c-1 1.288-2.309 1.924-3.93 1.924-1.113 0-2.111-.268-2.995-.803-.884-.536-1.572-1.305-2.08-2.31-.507-1.003-.752-2.158-.752-3.496 0-1.305.245-2.46.753-3.463A5.5 5.5 0 0 1 40.94 6.55c.884-.535 1.9-.82 3.012-.82.852 0 1.605.168 2.26.502a4.659 4.659 0 0 1 1.62 1.372l.344-1.706h3.045v.033Zm-4.24 9.152c.557-.67.836-1.573.836-2.677 0-1.121-.279-2.024-.835-2.71-.557-.686-1.31-1.038-2.243-1.038-.933 0-1.686.352-2.243 1.038-.573.686-.85 1.589-.85 2.71 0 1.104.277 1.99.85 2.677.573.686 1.31 1.02 2.243 1.02.933 0 1.686-.35 2.243-1.02ZM27.5 18.83V1.263h3.683v14.338h6.827v3.23H27.5Z" + g clip-path="url(#c)" + g fill="#19212E" clip-path="url(#d)" + path d="M19.875 11.693a9.973 9.973 0 0 1-2.804 5.558c-1.517 1.534-3.41 2.508-5.5 2.833 0-.018-.017-.036-.017-.054v-.018c-.018-.036-.018-.054-.036-.108l-.054-.163a5.625 5.625 0 0 1-.196-.83v-.018c0-.036-.018-.072-.018-.108v-.018c0-.036-.018-.072-.018-.09v-.036c0-.036-.018-.072-.018-.127-.018-.09-.018-.18-.018-.288v-.127c0-.108-.017-.216-.017-.343 0-3.572 2.892-6.496 6.428-6.496H18.071c.09 0 .197.018.286.036.036 0 .09 0 .143.018.036 0 .071.018.09.018h.017c.036 0 .072 0 .107.018h.018c.286.055.554.127.84.217l.16.054c.036.018.054.018.09.036h.017c0 .018.018.036.036.036Z" + path d="M20 10.213h-.018c-.16-.054-.321-.09-.482-.144-.018 0-.036-.018-.071-.018a3.26 3.26 0 0 0-.447-.09h-.018c-.035 0-.089-.018-.125-.018h-.035c-.054 0-.108-.018-.143-.018-.054 0-.125-.018-.161-.018-.107-.018-.232-.018-.34-.036h-.589c-4.339 0-7.857 3.554-7.857 7.94 0 .126 0 .252.018.396v.09c0 .037 0 .073.018.109.018.108.018.235.036.343 0 .054.018.108.018.162 0 .054.017.108.017.145.018.072.018.126.036.18.054.343.143.686.25 1.029v.018a9.768 9.768 0 0 1-6.09-2.003V17.847c0-7.561 6.09-13.715 13.572-13.715H18.018c1.321 1.697 2 3.844 1.982 6.082Z" opacity=".6" + path d="M16.732 2.617c-.071 0-.16.018-.232.018-.125 0-.232.018-.357.036-.036 0-.09 0-.125.018-.036 0-.054 0-.09.018a7.867 7.867 0 0 0-.642.09c-.161.018-.304.054-.465.072-.089.018-.196.036-.285.054-.107.018-.215.054-.34.072-.035.019-.089.019-.125.037-.071.018-.16.036-.232.054-.035 0-.053.018-.089.018-.09.018-.179.036-.25.072-.036.018-.09.018-.125.036-.071.018-.125.036-.196.054-.054.018-.108.036-.143.054-.09.018-.161.054-.232.072-.018 0-.036.019-.054.019-.107.036-.214.072-.321.126-.125.036-.233.09-.34.126a.656.656 0 0 0-.196.09c-.072.018-.125.055-.197.073-.107.054-.232.09-.339.144-.196.09-.393.18-.59.289-.25.126-.481.252-.731.397-.072.054-.161.09-.232.144-.09.054-.179.108-.286.18-.09.055-.179.109-.25.163-.072.054-.16.108-.232.162l-.215.163c-.178.126-.339.252-.5.379-.071.054-.125.108-.196.162-.107.09-.232.199-.34.289-.089.072-.178.162-.267.234-.072.054-.125.127-.197.18-.214.217-.428.416-.642.65a1.79 1.79 0 0 0-.179.199c-.09.09-.16.18-.232.27-.107.109-.197.235-.286.343-.053.073-.107.127-.16.199-.126.162-.25.343-.376.505l-.16.217c-.054.072-.107.162-.161.234-.054.09-.107.163-.16.253-.054.09-.126.18-.18.289-.053.072-.089.162-.142.234-.143.235-.268.487-.393.722a9.036 9.036 0 0 0-.286.595c-.053.109-.107.217-.143.343-.017.054-.053.127-.071.199-.036.072-.054.144-.09.198-.053.109-.089.235-.124.343-.036.108-.072.217-.125.325 0 .018-.018.036-.018.054-.036.072-.054.163-.072.235-.017.054-.035.108-.053.144-.018.072-.036.127-.054.199-.018.036-.018.09-.035.126-.018.09-.054.18-.072.253 0 .018-.018.054-.018.09-.018.072-.035.162-.053.234 0 .037-.018.073-.036.127-.018.108-.054.216-.071.343a8.66 8.66 0 0 0-.054.288 4.253 4.253 0 0 0-.071.47c-.036.216-.054.432-.09.65 0 .035 0 .053-.018.09 0 .035 0 .072-.017.126-.018.126-.018.234-.036.36 0 .073-.018.163-.018.235C.946 15.068.035 12.722 0 10.232A10.1 10.1 0 0 1 2.75 3.14c.054-.072.125-.126.179-.18l.178-.181C4.982.992 7.43 0 10 0h.125a9.965 9.965 0 0 1 6.607 2.617Z" opacity=".3" + defs + clippath#a + path fill="#fff" d="M0 0h80v24H0z" + clippath#b + path fill="#fff" d="M27.5 1.263H80V24H27.5z" + clippath#c + path fill="#fff" d="M0 0h20v20.21H0z" + clippath#d + path fill="#fff" d="M0 0h20v20.21H0z" +div style='margin-bottom: 24px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('password_reset.greetings') +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('password_reset.intro') +table style='margin-bottom: 32px' width="100%" cellspacing="0" cellpadding="0" + tr + td + table cellspacing="0" cellpadding="0" + tr + td style="border-radius: 12px;" bgcolor="#006CFA" + a href="#{@reset_url}" target="_blank" style="padding: 10px 16px;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;font-weight: 400;font-style: normal;line-height: 24px;" + = I18n.t('password_reset.main_cta_label') +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('password_reset.fallback_text') + a href="#{@forgot_url}" target="_blank" style="color: #006CFA;text-decoration: none;" + = @forgot_url +div style='font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;' + = I18n.t('password_reset.thanks') +div style='margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;' + = I18n.t('password_reset.lago_team') +div style='width: 100%;height: 1px;background-color: #D9DEE7;margin-bottom: 32px;' +div style="color: #66758F;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;" + = I18n.t('password_reset.email_info') diff --git a/app/views/payment_receipt_mailer/created.slim b/app/views/payment_receipt_mailer/created.slim new file mode 100644 index 0000000..c203034 --- /dev/null +++ b/app/views/payment_receipt_mailer/created.slim @@ -0,0 +1,55 @@ +table cellpadding="0" cellspacing="0" style="margin: auto; padding-bottom: 24px" + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: center;" + = I18n.t('email.payment_receipt.created.payment_receipt_from', billing_entity_name: @billing_entity.name) + tr + td style="margin-bottom: 16px;font-style: normal;font-weight: 700;font-size: 32px;line-height: 40px;color: #19212E; text-align: center;" + = MoneyHelper.format(@payment_receipt.payment.amount) + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: center;" + = I18n.t('payment_receipt.paid_on', + date: I18n.l(@payment_receipt.payment.created_at.to_date, format: :default), + total_due_amount: MoneyHelper.format(@total_due_amount)) + + +table style="width: 100%; border-collapse: collapse; border-top: 1px solid #d9dee7;" + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; white-space: nowrap; padding: 24px 0 4px;" + = I18n.t("payment_receipt.number") + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding: 24px 0 4px;" + = @payment_receipt.number + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; white-space: nowrap; padding: 4px 0;" + = I18n.t("payment_receipt.payment_date") + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding: 4px 0;" + = I18n.l(@payment_receipt.payment.created_at.to_date, format: :default) + tr + td style="color: #66758f; font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; white-space: nowrap; padding: 4px 0;" + = I18n.t("invoice.total_paid_amount") + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding: 4px 0;" + = MoneyHelper.format(@payment_receipt.payment.amount) + +table style="width: 100%; border-collapse: collapse; border-bottom: 1px solid #d9dee7; margin-top: 32px;" + tr + td style="font-size: 14px; font-weight: bold; line-height: 20px; letter-spacing: 0em; text-align: left; color: #19212E; white-space: nowrap; padding: 0;" + = I18n.t("email.invoice.finalized.invoice_number") + td style="font-size: 14px; font-weight: bold; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212E; white-space: nowrap; padding: 0;" + = I18n.t("invoice.total") + - @invoices.each do |invoice| + tr style="border-bottom: 1px solid #d9dee7;" + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; white-space: nowrap; padding: 8px 0;" + = link_to invoice.number, invoice.file_url, {style: "text-decoration: none; color: #006cfa"} + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding: 8px 0;" + = MoneyHelper.format(invoice.total_amount) + +table cellpadding="0" cellspacing="0" style="width: 100%; padding: 24px 0; border-bottom: 1px solid #d9dee7;" + tr + td + table cellpadding="0" cellspacing="0" style="margin: auto" + tr + td style="padding-right: 8px;" + svg height="16px" width="16px" fill="#006CFA" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" style="padding-top: 4px" + path d="M12.705 10.27C12.5176 10.0838 12.2642 9.97921 12 9.97921C11.7358 9.97921 11.4824 10.0838 11.295 10.27L8.99999 12.75V1C8.99999 0.734784 8.89463 0.48043 8.7071 0.292893C8.51956 0.105357 8.26521 0 7.99999 0C7.73477 0 7.48042 0.105357 7.29288 0.292893C7.10535 0.48043 6.99999 0.734784 6.99999 1V12.755L4.70499 10.255C4.51763 10.0688 4.26417 9.96421 3.99999 9.96421C3.7358 9.96421 3.48235 10.0688 3.29499 10.255C3.19898 10.3482 3.12265 10.4597 3.07053 10.583C3.0184 10.7062 2.99155 10.8387 2.99155 10.9725C2.99155 11.1063 3.0184 11.2388 3.07053 11.362C3.12265 11.4853 3.19898 11.5968 3.29499 11.69L6.93999 15.54C7.22124 15.8209 7.60249 15.9787 7.99999 15.9787C8.39749 15.9787 8.77874 15.8209 9.05999 15.54L12.705 11.69C12.7987 11.597 12.8731 11.4864 12.9239 11.3646C12.9746 11.2427 13.0008 11.112 13.0008 10.98C13.0008 10.848 12.9746 10.7173 12.9239 10.5954C12.8731 10.4736 12.7987 10.363 12.705 10.27Z" + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; color: #19212e;" + a style="text-decoration: none" target="_blank" href=@payment_receipt.file_url + = I18n.t('payment_receipt.download_receipt') diff --git a/app/views/payment_request_mailer/requested.slim b/app/views/payment_request_mailer/requested.slim new file mode 100644 index 0000000..61e884f --- /dev/null +++ b/app/views/payment_request_mailer/requested.slim @@ -0,0 +1,43 @@ +div style="margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;" + = I18n.t("email.payment_request.requested.hello", customer_name: @customer.display_name) +div style="margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;" + = I18n.t("email.payment_request.requested.reminder_overdue_balance", billing_entity_name: @billing_entity.name) +div style="margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;" + = I18n.t("email.payment_request.requested.total_amount_due", amount: MoneyHelper.format(@payment_request.amount)) +div style="margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;" + = I18n.t("email.payment_request.requested.payment_terms", count: @customer.applicable_net_payment_term) +div style="margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;" + = I18n.t("email.payment_request.requested.already_paid") +div style="margin-bottom: 32px;font-style: normal;font-weight: 400;font-size: 16px;line-height: 24px;color: #19212E;" + = I18n.t("email.payment_request.requested.thank_you") + +div style="padding-top: 32px;font-style: normal;font-weight: 400;font-size: 14px;line-height: 20px;color: #66758F;border-top: 1px solid #d9dee7;" + = I18n.t("email.payment_request.requested.remaining_amount") +div style="margin-bottom: 16px;font-style: normal;font-weight: 700;font-size: 32px;line-height: 40px;color: #19212E;" + = MoneyHelper.format(@payment_request.amount) + +- if @payment_url + table style="margin-bottom: 32px" width="100%" cellspacing="0" cellpadding="0" + tr + td + table cellspacing="0" cellpadding="0" + tr + td style="border-radius: 12px;" bgcolor="#006CFA" + = link_to I18n.t("email.payment_request.requested.pay_amount"), @payment_url, id: "payment_link", style: "padding: 10px 16px;font-size: 16px; color: #ffffff;text-decoration: none;font-weight:bold;display: inline-block;font-weight: 400;font-style: normal;line-height: 24px;" + +table style="width: 100%; border-collapse: collapse;" + tr + tr style="border-bottom: 1px solid #d9dee7;" + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; color: #66758F; white-space: nowrap; padding: 16px 0;" + = I18n.t("email.credit_note.created.invoice_number") + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #66758F; white-space: nowrap; padding: 16px 0;" + = I18n.t("invoice.total_due_amount") + - @invoices.each do |invoice| + tr style="border-bottom: 1px solid #d9dee7;" + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: left; padding-right: 16px; white-space: nowrap; padding: 16px 0;" + - if @pdfs_enabled + = link_to invoice.number, invoice.file_url, {style: "text-decoration: none; color: #006cfa"} + - else + = invoice.number + td style="font-size: 14px; font-weight: 400; line-height: 20px; letter-spacing: 0em; text-align: right; color: #19212e; white-space: nowrap; padding: 16px 0;" + = MoneyHelper.format(invoice.total_due_amount) diff --git a/app/views/templates/credit_notes/_details.slim b/app/views/templates/credit_notes/_details.slim new file mode 100644 index 0000000..6c3b846 --- /dev/null +++ b/app/views/templates/credit_notes/_details.slim @@ -0,0 +1,109 @@ +.credit-note-resume.mb-24.overflow-auto + table.credit-note-resume-table width="100%" + tr + td.body-3 = I18n.t('credit_note.item') + - unless for_credit_invoice? + td.body-3 = I18n.t('credit_note.tax_rate') + td.body-3 = I18n.t('credit_note.amount') + + - if for_credit_invoice? + tr + td.body-1 = I18n.t('credit_note.prepaid_credits_for_wallet', wallet_name: invoice.associated_active_wallet&.name) + td.body-2 width="20%" = MoneyHelper.format(refund_amount) + - else + - subscription_ids.each do |subscription_id| + - if subscription_id.present? + - if subscription_item(subscription_id).amount.positive? + tr + td.body-1 width="60%" + | #{I18n.t('credit_note.subscription')} - #{Subscription.find_by(id: subscription_id)&.invoice_name} + td.body-2 width="20%" == TaxHelper.applied_taxes(subscription_item(subscription_id)) + td.body-2 width="20%" = MoneyHelper.format(subscription_item(subscription_id).amount) + + - subscription_fixed_charge_items(subscription_id).each do |item| + tr + td.body-1 width="60%" = item.fee.invoice_name + FeeDisplayHelper.grouped_by_display(item.fee) + td.body-2 width="20%" == TaxHelper.applied_taxes(item) + td.body-2 width="20%" = MoneyHelper.format(item.amount) + + - subscription_charge_items(subscription_id).where(fees: { true_up_parent_fee: nil }).group_by { |i| i.fee.charge_id }.each do |_charge_id, items| + - item = items.first + - if items.all? { |i| i.fee.charge_filter_id? } + - items.each do |item| + tr + td.body-1 = item.fee.invoice_name + FeeDisplayHelper.grouped_by_display(item.fee) + ' • ' + item.fee.filter_display_name(separator: ' • ') + td.body-2 width="20%" == TaxHelper.applied_taxes(item) + td.body-2 width="20%" = MoneyHelper.format(item.amount) + - items.select { |i| i.fee.true_up_fee.present? }.each do |item| + - if true_up_item = subscription_charge_items(subscription_id).find_by(fee: item.fee.true_up_fee) + tr + td.body-1 width="60%" = I18n.t('invoice.true_up_metric', metric: true_up_item.fee.invoice_name) + td.body-2 width="20%" == TaxHelper.applied_taxes(true_up_item) + td.body-2 width="20%" = MoneyHelper.format(true_up_item.amount) + - else + tr + td.body-1 width="60%" = item.fee.invoice_name + FeeDisplayHelper.grouped_by_display(item.fee) + td.body-2 width="20%" == TaxHelper.applied_taxes(item) + td.body-2 width="20%" = MoneyHelper.format(item.amount) + - if item.fee.true_up_fee.present? + - if true_up_item = subscription_charge_items(subscription_id).find_by(fee: item.fee.true_up_fee) + tr + td.body-1 width="60%" = I18n.t('invoice.true_up_metric', metric: true_up_item.fee.invoice_name) + td.body-2 width="20%" == TaxHelper.applied_taxes(true_up_item) + td.body-2 width="20%" = MoneyHelper.format(true_up_item.amount) + - else + - add_on_items.each do |item| + tr + td.body-1 + - if item.fee.true_up_parent_fee_id? + | #{I18n.t('invoice.true_up_metric', metric: item.fee.true_up_parent_fee.invoice_name)} + - else + | #{item.fee.invoice_name} + + td.body-2 width="20%" == TaxHelper.applied_taxes(item) + td.body-2 width="20%" = MoneyHelper.format(item.amount) + + table.total-table width="100%" + - if coupons_adjustment_amount_cents.positive? + tr + td.body-2 + td.body-2 width="70%" = I18n.t('credit_note.coupon_adjustment') + td.body-2 width="30%" + | -#{MoneyHelper.format(coupons_adjustment_amount)} + - unless for_credit_invoice? + tr + td.body-2 + td.body-2 width="70%" = I18n.t('credit_note.sub_total_without_tax') + td.body-2 width="30%" = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + td.body-2 + td.body-2 + = I18n.t('credit_note.tax', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.base_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + - unless for_credit_invoice? + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + - if has_offset? + tr + td.body-2 + td.body-2 = I18n.t('credit_note.offset_invoice', invoice_number: invoice.number) + td.body-2 = MoneyHelper.format(offset_amount) + - if credited? + tr + td.body-2 + td.body-2 = I18n.t('credit_note.credited_on_customer_balance') + td.body-2 = MoneyHelper.format(credit_amount) + - if refunded? + tr + td.body-2 + td.body-2 = I18n.t('credit_note.refunded') + td.body-2 = MoneyHelper.format(refund_amount) + tr + td.body-2 + td.body-1 = I18n.t('credit_note.total') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/credit_notes/_eu_tax_management.slim b/app/views/templates/credit_notes/_eu_tax_management.slim new file mode 100644 index 0000000..6c98133 --- /dev/null +++ b/app/views/templates/credit_notes/_eu_tax_management.slim @@ -0,0 +1,11 @@ +- if billing_entity.eu_tax_management.present? + - if applied_taxes.present? + - applied_tax_codes = applied_taxes.pluck(:tax_code) + p.body-3.mb-24 + - if applied_tax_codes.include?('lago_eu_tax_exempt') + - if billing_entity.country == 'FR' + = I18n.t('invoice.taxes.fr_tax_exempt') + - else + = I18n.t('invoice.taxes.tax_exempt') + - if applied_tax_codes.include?('lago_eu_reverse_charge') + = I18n.t('invoice.taxes.reverse_charge') diff --git a/app/views/templates/credit_notes/_powered_by_logo.slim b/app/views/templates/credit_notes/_powered_by_logo.slim new file mode 100644 index 0000000..b37a9aa --- /dev/null +++ b/app/views/templates/credit_notes/_powered_by_logo.slim @@ -0,0 +1,5 @@ +- unless organization.remove_branding_watermark_enabled? + .powered-by + span.body-2 + | #{I18n.t('credit_note.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" diff --git a/app/views/templates/credit_notes/_styles.slim b/app/views/templates/credit_notes/_styles.slim new file mode 100644 index 0000000..ec22ae5 --- /dev/null +++ b/app/views/templates/credit_notes/_styles.slim @@ -0,0 +1,286 @@ +css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-8 { + margin-bottom: 8px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .credit-note-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .credit-note-information-column { + float: left; + width: 50%; + } + .credit-note-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .credit-note-information-table tr td:last-child { + width: 55%; + } + .credit-note-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .credit-note-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .credit-note-resume-table tr { + border-bottom: 1px solid #D9DEE7; + } + .credit-note-resume-table tr td { + padding-top: 8px; + padding-bottom: 8px; + } + .credit-note-resume-table tr td:last-child { + text-align: right; + } + .credit-note-resume table { + border-collapse: collapse; + } + + .credit-note-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .credit-note-resume .total-table td:first-child { + width: 50%; + } + .credit-note-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid #D9DEE7; + text-align: left; + width: 35%; + } + .credit-note-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid #D9DEE7; + text-align: right; + width: 35%; + } + .credit-note-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 35%; + } + .credit-note-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 15%; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } diff --git a/app/views/templates/credit_notes/credit_note.slim b/app/views/templates/credit_notes/credit_note.slim new file mode 100644 index 0000000..461f4d5 --- /dev/null +++ b/app/views/templates/credit_notes/credit_note.slim @@ -0,0 +1,105 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Credit note + body + == SlimHelper.render('templates/credit_notes/_styles', self) + + .wrapper + .mb-24 + h1.credit-note-title = I18n.t('credit_note.document_name') + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .credit-note-information-column + table.credit-note-information-table + tr + td.body-1 = I18n.t('credit_note.credit_note_number') + td.body-2 = number + tr + td.body-1 = I18n.t('credit_note.invoice_number') + td.body-2 = invoice.number + tr + td.body-1 = I18n.t('credit_note.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + .credit-note-information-column + table.credit-note-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('credit_note.credit_from') + .body-2 + - if billing_entity.legal_name.present? + = billing_entity.legal_name + - else + = billing_entity.name + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('credit_note.credit_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + .body-2 = customer.address_line1 + .body-2 = customer.address_line2 + .body-2 + span + = customer.zipcode + - if customer.zipcode.present? && customer.city.present? + span + | ,   + span + = customer.city + .body-2 = customer.state + .body-2 = ISO3166::Country.new(customer.country)&.common_name + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-8 = MoneyHelper.format(total_amount) + - formatted_date = I18n.l(issuing_date, format: :default) + + - if [credited?, refunded?, has_offset?].count(true) > 1 + .body-1 + = I18n.t("credit_note.issued_notice", issuing_date: formatted_date) + - elsif credited? + .body-1 + = I18n.t("credit_note.credited_notice", issuing_date: formatted_date) + - elsif refunded? + .body-1 + = I18n.t("credit_note.refunded_notice", issuing_date: formatted_date) + - elsif has_offset? + .body-1 + = I18n.t('credit_note.offset_invoice_notice', invoice_number: invoice.number, issuing_date: formatted_date) + + == SlimHelper.render('templates/credit_notes/_details', self) + == SlimHelper.render('templates/credit_notes/_eu_tax_management', self) + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + == SlimHelper.render('templates/credit_notes/_powered_by_logo', self) diff --git a/app/views/templates/credit_notes/self_billed.slim b/app/views/templates/credit_notes/self_billed.slim new file mode 100644 index 0000000..bf88539 --- /dev/null +++ b/app/views/templates/credit_notes/self_billed.slim @@ -0,0 +1,105 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Self billing credit note + body + == SlimHelper.render('templates/credit_notes/_styles', self) + + .wrapper + .mb-24 + h1.credit-note-title = I18n.t('credit_note.document_name') + + .mb-24.overflow-auto + .credit-note-information-column + table.credit-note-information-table + tr + td.body-1 = I18n.t('credit_note.credit_note_number') + td.body-2 = number + tr + td.body-1 = I18n.t('credit_note.invoice_number') + td.body-2 = invoice.number + tr + td.body-1 = I18n.t('credit_note.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + .credit-note-information-column + table.credit-note-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('credit_note.credit_from') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + .body-2 = customer.address_line1 + .body-2 = customer.address_line2 + .body-2 + span + = customer.zipcode + - if customer.zipcode.present? && customer.city.present? + span + | ,   + span + = customer.city + .body-2 = customer.state + .body-2 = ISO3166::Country.new(customer.country)&.common_name + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .billing-information-column + .body-1 = I18n.t('credit_note.credit_to') + .body-2 + - if billing_entity.legal_name.present? + = billing_entity.legal_name + - else + = billing_entity.name + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + + .mb-24 + h2.title-2.mb-8 = MoneyHelper.format(total_amount) + - formatted_date = I18n.l(issuing_date, format: :default) + + - if [credited?, refunded?, has_offset?].count(true) > 1 + .body-1 + = I18n.t("credit_note.issued_notice", issuing_date: formatted_date) + - elsif credited? + .body-1 + = I18n.t("credit_note.credited_notice", issuing_date: formatted_date) + - elsif refunded? + .body-1 + = I18n.t("credit_note.refunded_notice", issuing_date: formatted_date) + - elsif has_offset? + .body-1 + = I18n.t('credit_note.offset_invoice_notice', invoice_number: invoice.number, issuing_date: formatted_date) + + == SlimHelper.render('templates/credit_notes/_details', self) + == SlimHelper.render('templates/credit_notes/_eu_tax_management', self) + + p.body-3.mb-24 = LineBreakHelper.break_lines(I18n.t("credit_note.self_billed.footer")) + + == SlimHelper.render('templates/credit_notes/_powered_by_logo', self) diff --git a/app/views/templates/invoices/v1.slim b/app/views/templates/invoices/v1.slim new file mode 100644 index 0000000..a072a8b --- /dev/null +++ b/app/views/templates/invoices/v1.slim @@ -0,0 +1,599 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + .prepaid-amount { + font-size: 10px; + font-family: Inter; + color: #008559; + font-weight: 400; + line-height: 16px; + } + + .mb-8 { + margin-bottom: 8px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr td { + padding-bottom: 12px; + } + .invoice-resume-table tr td:last-child { + text-align: right; + } + .invoice-resume-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .invoice-resume .total-table tr:first-child td { + padding-top: 24px; + } + .invoice-resume .total-table tr td { + padding-bottom: 12px; + text-align: right; + } + .invoice-resume .total-table tr td:first-of-type { + text-align: left; + padding-left: 50%; + } + + .invoice-details-title { + page-break-before: always; + } + + .subscription-details-table tr td:last-child { + text-align: right; + } + .subscription-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .subscription-details table { + border-collapse: collapse; + } + .subscription-details .total-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .subscription-details .total-table tr:first-child td { + padding-top: 24px; + } + .subscription-details .total-table tr td { + text-align: right; + } + + .charge-details-table tr td { + padding-bottom: 12px; + } + .charge-details-table tr td:last-child { + text-align: right; + } + .charge-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .charge-details table { + border-collapse: collapse; + } + .charge-details .total-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .charge-details .total-table tr:first-child td { + padding-top: 24px; + } + .charge-details .total-table tr td { + text-align: right; + } + .charge-details-resume .total-table tr td:first-of-type { + text-align: left; + padding-left: 50%; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details-table tr td { + padding-bottom: 12px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + .body-2 = customer.address_line1 + .body-2 = customer.address_line2 + .body-2 + span + = customer.zipcode + - if customer.zipcode.present? && customer.city.present? + span + | ,   + span + = customer.city + .body-2 = customer.state + .body-2 = ISO3166::Country.new(customer.country)&.common_name + .body-2 = customer.email + + .mb-24 + h2.title-2.mb-8 = total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + .body-1 = I18n.t('invoice.due_date', date: I18n.l(issuing_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + table.invoice-resume-table width="100%" + tr + td width="70%" + .body-1 + - if add_on? + | #{fees.first.add_on.invoice_name} + - elsif credit? + = I18n.t('invoice.prepaid_credits_with_value', wallet_name: fees.first.invoiceable.wallet.name) + .body-3 + = I18n.t('invoice.total_credits_with_value', credit_amount: fees.first.invoiceable.credit_amount) + - elsif subscription? + = I18n.t('invoice.all_subscriptions') + - if add_on? || credit? + td.body-1 style="text-align: right;" width="30%" + = fees.first&.amount&.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - elsif subscription? + td.body-1 style="text-align: right;" width="30%" + = subscription_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if fees.charge.any? + tr + td width="70%" + .body-1 = I18n.t('invoice.all_usage_based_fees') + td.body-1 style="text-align: right;" width="30%" + = charge_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if credits.coupon_kind.any? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td width="70%" + .body-1 #{credit.invoice_coupon_display_name} + td.body-1 style="text-align: right; color: #008559;" width="30%" + = credit.amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + table.total-table width="100%" + tr + td.body-2 width="70%" = I18n.t('invoice.sub_total') + td.body-2 width="30%" = sub_total_excluding_taxes_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if subscription? && wallet_transactions.exists? + tr + td.body-2 width="70%" = I18n.t('invoice.prepaid_credits') + td.prepaid-amount width="30%" = prepaid_credit_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + tr + td.body-2 = I18n.t('invoice.tax') + td.body-2 = taxes_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + tr + td.body-1 = I18n.t('invoice.total_due') + td.body-1 = total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + .powered-by + span.body-2 + | #{I18n.t('invoice.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" + + - if subscription? + - subscriptions.each do |subscription| + h2.invoice-details-title.title-2.mb-24 = I18n.t('invoice.details', resource: subscription.invoice_name) + .body-1 = I18n.t('invoice.subscription') + .mb-24.body-3 + | #{I18n.t('invoice.date_from')} #{I18n.l(invoice_subscription(subscription.id).from_datetime_in_customer_timezone&.to_date, format: :default)} #{I18n.t('invoice.date_to')} #{I18n.l(invoice_subscription(subscription.id).to_datetime_in_customer_timezone&.to_date, format: :default)} + + .invoice-resume.mb-24.overflow-auto + table.invoice-resume-table width="100%" + tr + td width="70%" + .body-1 + = I18n.t('invoice.subscription_interval', plan_interval: I18n.t("invoice.#{subscription.plan.interval}"), plan_name: subscription.plan.invoice_name) + td.body-1 style="text-align: right;" width="30%" + = subscription_fees(subscription.id).subscription.first&.amount&.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + table.total-table width="100%" + tr + td.body-2 width="70%" = I18n.t('invoice.sub_total') + td.body-1 width="30%" = invoice_subscription(subscription.id).subscription_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + - if subscription? && subscription_fees(subscription.id).charge.any? + .body-1 = I18n.t('invoice.usage_based_fees') + .mb-24.body-3 + = I18n.t('invoice.list_of_charges', from: I18n.l(invoice_subscription(subscription.id).charges_from_datetime_in_customer_timezone&.to_date, format: :default), to: I18n.l(invoice_subscription(subscription.id).charges_to_datetime_in_customer_timezone&.to_date, format: :default)) + .charge-details.mb-24 + table.charge-details-table width="100%" + - subscription_fees(subscription.id).charge.group_by(&:charge_id).each do |_charge_id, fees| + - fee = fees.first + - if fees.all? { |f| f.group_id? } && fees.sum(&:units) > 0 + tr + td width="70%" + .body-1 = fee.invoice_name + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_unit_interval', events_count: fees.sum(&:events_count), units: fees.sum(&:units)) + - else + = I18n.t('invoice.total_unit', units: fees.sum(&:units)) + td width="30%" + + - fees.select { |f| f.units.positive? }.each do |fee| + tr + td width="70%" style="padding-left: 16px;" + .body-1 = fee.filter_display_name + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_unit_interval', events_count: fee.events_count, units: fee.units) + - else + = I18n.t('invoice.total_unit', units: fee.units) + td.body-1 width="30%" = fee.amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - else + tr + td width="70%" + .body-1 = fee.invoice_name + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_unit_interval', events_count: fees.sum(&:events_count), units: fees.sum(&:units)) + - else + = I18n.t('invoice.total_unit', units: fees.sum(&:units)) + td.body-1 width="30%" = fees.sum(&:amount).format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + .charge-details-resume + table.total-table width="100%" + tr + td.body-2 width="70%" = I18n.t('invoice.sub_total') + td.body-1 width="30%" = invoice_subscription(subscription.id).charge_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + table.total-table width="100%" + tr + td.body-2 width="70%" = I18n.t('invoice.total') + td.body-1 width="30%" = invoice_subscription(subscription.id).total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + - recurring_fees(subscription.id).group_by(&:charge_id).each do |_charge_id, fees| + - if fees.sum(&:units) > 0 + h2.invoice-details-title.title-2.mb-24 = I18n.t('invoice.details', resource: subscription.invoice_name) + + - if fees.all? { |f| f.group_id? } + - fees.select { |f| f.units.positive? }.each do |fee| + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown_of', fee_filter_display_name: fee.filter_display_name(separator: ' • ')) + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - recurring_breakdown(fee).each do |breakdown| + tr + td.body-3 width="15%" = I18n.l(breakdown.date, format: :default) + td.body-1 width="65%" + - if breakdown.action.to_sym == :add + | +#{breakdown.count} #{fee.invoice_name} + - elsif breakdown.action.to_sym == :remove + | -#{breakdown.count} #{fee.invoice_name} + - else + | +/-#{breakdown.count} #{fee.invoice_name} + td.body-3 width="20%" + = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + - else + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown') + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - fees.each do |fee| + - recurring_breakdown(fee).each do |breakdown| + tr + td.body-3 width="15%" = breakdown.date.strftime('%b %d, %Y') + td.body-1 width="65%" + - if breakdown.action.to_sym == :add + | +#{breakdown.count} #{fee.invoice_name} + - elsif breakdown.action.to_sym == :remove + | -#{breakdown.count} #{fee.invoice_name} + - else + | +/-#{breakdown.count} #{fee.invoice_name} + td.body-3 width="20%" + = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + + .alert.body-3 = I18n.t('invoice.notice') diff --git a/app/views/templates/invoices/v2.slim b/app/views/templates/invoices/v2.slim new file mode 100644 index 0000000..95b0c71 --- /dev/null +++ b/app/views/templates/invoices/v2.slim @@ -0,0 +1,620 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + .prepaid-amount { + font-size: 10px; + font-family: Inter; + color: #008559; + font-weight: 400; + line-height: 16px; + } + + .mb-8 { + margin-bottom: 8px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr td { + padding-bottom: 12px; + } + .invoice-resume-table tr td:last-child { + text-align: right; + } + .invoice-resume-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .invoice-resume .total-table tr:first-child td { + padding-top: 24px; + } + .invoice-resume .total-table tr td { + padding-bottom: 12px; + text-align: right; + } + .invoice-resume .total-table tr td:first-of-type { + text-align: left; + padding-left: 50%; + } + + .invoice-details-title { + page-break-before: always; + } + + .subscription-details-table tr td:last-child { + text-align: right; + } + .subscription-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .subscription-details table { + border-collapse: collapse; + } + .subscription-details .total-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .subscription-details .total-table tr:first-child td { + padding-top: 24px; + } + .subscription-details .total-table tr td { + text-align: right; + } + + .charge-details-table tr td { + padding-bottom: 12px; + } + .charge-details-table tr td:last-child { + text-align: right; + } + .charge-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .charge-details table { + border-collapse: collapse; + } + .charge-details .total-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + .charge-details .total-table tr:first-child td { + padding-top: 24px; + } + .charge-details .total-table tr td { + text-align: right; + } + .charge-details-resume .total-table tr td:first-of-type { + text-align: left; + padding-left: 50%; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details-table tr td { + padding-bottom: 12px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + .body-2 = customer.address_line1 + .body-2 = customer.address_line2 + .body-2 + span + = customer.zipcode + - if customer.zipcode.present? && customer.city.present? + span + | ,   + span + = customer.city + .body-2 = customer.state + .body-2 = ISO3166::Country.new(customer.country)&.common_name + .body-2 = customer.email + + .mb-24 + h2.title-2.mb-8 = total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + .body-1 = I18n.t('invoice.due_date', date: I18n.l(issuing_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + table.invoice-resume-table width="100%" + tr + td width="70%" + .body-1 + - if add_on? + | #{fees.first.add_on.invoice_name} + - elsif credit? + = I18n.t('invoice.prepaid_credits_with_value', wallet_name: fees.first.invoiceable.wallet.name) + .body-3 + = I18n.t('invoice.total_credits_with_value', credit_amount: fees.first.invoiceable.credit_amount) + - elsif subscription? + = I18n.t('invoice.all_subscriptions') + - if add_on? || credit? + td.body-1 style="text-align: right;" width="30%" + = fees.first&.amount&.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - elsif subscription? + td.body-1 style="text-align: right;" width="30%" + = subscription_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if fees.charge.any? + tr + td width="70%" + .body-1 = I18n.t('invoice.all_usage_based_fees') + td.body-1 style="text-align: right;" width="30%" + = charge_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + table.total-table width="100%" + - unless credit? + tr + td.body-2 width="70%" = I18n.t('invoice.sub_total_without_tax') + td.body-2 width="30%" = sub_total_excluding_taxes_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + tr + td.body-2 #{I18n.t('invoice.tax')} (#{taxes_rate || 0}%) + td.body-2 = taxes_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + tr + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = sub_total_including_taxes_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if credits.credit_note_kind.any? + tr + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 style="text-align: right; color: #008559;" + = credit_notes_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if credits.coupon_kind.any? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 style="text-align: right; color: #008559;" + = credit.amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if subscription? && wallet_transactions.exists? + tr + td.body-2 width="70%" = I18n.t('invoice.prepaid_credits') + td.prepaid-amount width="30%" = prepaid_credit_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + tr + td.body-1 width="70%" = I18n.t('invoice.total_due') + td.body-1 width="30%" = total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + .powered-by + span.body-2 + | #{I18n.t('invoice.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" + + - if subscription? + - subscriptions.each do |subscription| + h2.invoice-details-title.title-2.mb-24 = I18n.t('invoice.details', resource: subscription.invoice_name) + .body-1 = I18n.t('invoice.subscription') + .mb-24.body-3 + | #{I18n.t('invoice.date_from')} #{I18n.l(invoice_subscription(subscription.id).from_datetime_in_customer_timezone&.to_date, format: :default)} #{I18n.t('invoice.date_to')} #{I18n.l(invoice_subscription(subscription.id).to_datetime_in_customer_timezone&.to_date, format: :default)} + + .invoice-resume.mb-24.overflow-auto + table.invoice-resume-table width="100%" + tr + td width="70%" + .body-1 + = I18n.t('invoice.subscription_interval', plan_interval: I18n.t("invoice.#{subscription.plan.interval}"), plan_name: subscription.plan.invoice_name) + td.body-1 style="text-align: right;" width="30%" + = subscription_fees(subscription.id).subscription.first&.amount&.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + table.total-table width="100%" + tr + td.body-2 width="70%" = I18n.t('invoice.sub_total') + td.body-1 width="30%" = invoice_subscription(subscription.id).subscription_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + - if subscription? && subscription_fees(subscription.id).charge.any? + .body-1 = I18n.t('invoice.usage_based_fees') + .mb-24.body-3 + = I18n.t('invoice.list_of_charges', from: I18n.l(invoice_subscription(subscription.id).charges_from_datetime_in_customer_timezone&.to_date, format: :default), to: I18n.l(invoice_subscription(subscription.id).charges_to_datetime_in_customer_timezone&.to_date, format: :default)) + .charge-details.mb-24 + table.charge-details-table width="100%" + - subscription_fees(subscription.id).charge.where(true_up_parent_fee: nil).group_by(&:charge_id).each do |_charge_id, fees| + - fee = fees.first + - if fees.all? { |f| f.group_id? } && fees.sum(&:units) > 0 + tr + td width="70%" + .body-1 = fee.invoice_name + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_unit_interval', events_count: fees.sum(&:events_count), units: fees.sum(&:units)) + - else + = I18n.t('invoice.total_unit', units: fees.sum(&:units)) + td width="30%" + + - fees.select { |f| f.units.positive? }.each do |fee| + tr + td width="70%" style="padding-left: 16px;" + .body-1 = fee.filter_display_name(separator: ' • ') + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_unit_interval', events_count: fee.events_count, units: fee.units) + - else + = I18n.t('invoice.total_unit', units: fee.units) + td.body-1 width="30%" = fee.amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if fee.true_up_fee.present? + tr + td width="70%" + .body-1 = I18n.t('invoice.true_up_metric', metric: fee.invoice_name) + .body-3 = I18n.t('invoice.true_up_details', min_amount: ChargeDisplayHelper.format_min_amount(fee.charge)) + td.body-1 width="30%" = fee.true_up_fee.amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - else + tr + td width="70%" + .body-1 = fee.invoice_name + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_unit_interval', events_count: fees.sum(&:events_count), units: fees.sum(&:units)) + - else + = I18n.t('invoice.total_unit', units: fees.sum(&:units)) + td.body-1 width="30%" = fees.sum(&:amount).format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + - if fee.true_up_fee.present? + tr + td width="70%" + .body-1 = I18n.t('invoice.true_up_metric', metric: fee.invoice_name) + .body-3 = I18n.t('invoice.true_up_details', min_amount: ChargeDisplayHelper.format_min_amount(fee.charge)) + td.body-1 width="30%" = fee.true_up_fee.amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + .charge-details-resume + table.total-table width="100%" + tr + td.body-2 width="70%" = I18n.t('invoice.sub_total') + td.body-1 width="30%" = invoice_subscription(subscription.id).charge_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + table.total-table width="100%" + tr + td.body-2 width="70%" = I18n.t('invoice.total') + td.body-1 width="30%" = invoice_subscription(subscription.id).total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + + - recurring_fees(subscription.id).group_by(&:charge_id).each do |_charge_id, fees| + - if fees.sum(&:units) > 0 + h2.invoice-details-title.title-2.mb-24 = I18n.t('invoice.details', resource: subscription.invoice_name) + + - if fees.all? { |f| f.group_id? } + - fees.select { |f| f.units.positive? }.each do |fee| + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown_of', fee_filter_display_name: fee.filter_display_name(separator: ' • ')) + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - recurring_breakdown(fee).each do |breakdown| + tr + td.body-3 width="15%" = I18n.l(breakdown.date, format: :default) + td.body-1 width="65%" + - if breakdown.action.to_sym == :add + | +#{breakdown.count} #{fee.invoice_name} + - elsif breakdown.action.to_sym == :remove + | -#{breakdown.count} #{fee.invoice_name} + - else + | +/-#{breakdown.count} #{fee.invoice_name} + td.body-3 width="20%" + = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + - else + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown') + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - fees.each do |fee| + - recurring_breakdown(fee).each do |breakdown| + tr + td.body-3 width="15%" = breakdown.date.strftime('%b %d, %Y') + td.body-1 width="65%" + - if breakdown.action.to_sym == :add + | +#{breakdown.count} #{fee.invoice_name} + - elsif breakdown.action.to_sym == :remove + | -#{breakdown.count} #{fee.invoice_name} + - else + | +/-#{breakdown.count} #{fee.invoice_name} + td.body-3 width="20%" + = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + + .alert.body-3 = I18n.t('invoice.notice') diff --git a/app/views/templates/invoices/v3.slim b/app/views/templates/invoices/v3.slim new file mode 100644 index 0000000..e901181 --- /dev/null +++ b/app/views/templates/invoices/v3.slim @@ -0,0 +1,439 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr:first-child td { + color: #66758F; + } + .invoice-resume-table td { + padding-top: 8px; + padding-bottom: 8px; + } + .invoice-resume-table td:first-child { + width: 50%; + } + .invoice-resume-table td:nth-child(2) { + width: 14%; + } + .invoice-resume-table td:nth-child(3) { + width: 18%; + } + .invoice-resume-table td:nth-child(4) { + width: 18%; + } + .invoice-resume-table tr td:last-child { + text-align: right; + } + .invoice-resume-table tr { + border-bottom: 1px solid #D9DEE7; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid #D9DEE7; + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid #D9DEE7; + text-align: right; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details { + margin-top: -15px; + } + .breakdown-details-table tr td { + padding-bottom: 8px; + padding-top: 8px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr td { + border-bottom: 1px solid #d9dee7; + } + .breakdown-details-table tr:first-child td { + border-top: 1px solid #d9dee7; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + .body-2 = customer.address_line1 + .body-2 = customer.address_line2 + .body-2 + span + = customer.zipcode + - if customer.zipcode.present? && customer.city.present? + span + | ,   + span + = customer.city + .body-2 = customer.state + .body-2 = ISO3166::Country.new(customer.country)&.common_name + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = MoneyHelper.format(total_amount) + .body-1 = I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + - if credit? + == SlimHelper.render('templates/invoices/v3/_credit', self) + - elsif subscriptions.count == 1 + == SlimHelper.render('templates/invoices/v3/_subscription_details', self) + - else + == SlimHelper.render('templates/invoices/v3/_subscriptions_summary', self) + + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v3/_custom_sections', self) + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + .powered-by + span.body-2 + | #{I18n.t('invoice.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" + + - if subscriptions.count > 1 + == SlimHelper.render('templates/invoices/v3/_subscription_details', self) diff --git a/app/views/templates/invoices/v3/_credit.slim b/app/views/templates/invoices/v3/_credit.slim new file mode 100644 index 0000000..8e8c8b2 --- /dev/null +++ b/app/views/templates/invoices/v3/_credit.slim @@ -0,0 +1,18 @@ +- invoiceable = fees.first.invoiceable +table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.unit') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.amount_without_tax') + tr + td.body-1 = I18n.t('invoice.prepaid_credits_with_value', wallet_name: invoiceable.wallet.name) + td.body-2 = invoiceable.credit_amount + td.body-2 = invoiceable.wallet.rate_amount + td.body-2 = MoneyHelper.format(fees.first.amount) + +table.total-table width="100%" + tr + td.body-2 + td.body-1 = I18n.t('invoice.total_due') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v3/_custom_sections.slim b/app/views/templates/invoices/v3/_custom_sections.slim new file mode 100644 index 0000000..d6e6063 --- /dev/null +++ b/app/views/templates/invoices/v3/_custom_sections.slim @@ -0,0 +1,15 @@ +css: + .invoice-custom-section { + margin-top: 24px; + border-bottom: 1px solid #D9DEE7; + } + + .invoice-custom-section p.section-name { + margin-bottom: 8px; + } + +.invoice-custom-sections.body-3.mb-24 + - applied_invoice_custom_sections.each do |section| + .invoice-custom-section + p.body-1.section-name = section.display_name + p.body-3.mb-24 = LineBreakHelper.break_lines(section.details) diff --git a/app/views/templates/invoices/v3/_subscription_details.slim b/app/views/templates/invoices/v3/_subscription_details.slim new file mode 100644 index 0000000..4f334e9 --- /dev/null +++ b/app/views/templates/invoices/v3/_subscription_details.slim @@ -0,0 +1,254 @@ +- if subscription? + - subscriptions.each do |subscription| + - if subscriptions.count > 1 + h2.title-2.mb-24 class="#{'invoice-details-title' if subscriptions.count > 1}" = I18n.t('invoice.details', resource: subscription.invoice_name) + + / Subscription fee section + .invoice-resume.overflow-auto class="#{'mb-24' if subscription_fees(subscription.id).charge.any?}" + table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.fees_from_to_date', from_date: I18n.l(invoice_subscription(subscription.id).from_datetime_in_customer_timezone&.to_date, format: :default), to_date: I18n.l(invoice_subscription(subscription.id).to_datetime_in_customer_timezone&.to_date, format: :default)) + td.body-2 = I18n.t('invoice.unit') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount_without_tax') + tr + td.body-1 = I18n.t('invoice.subscription_interval', plan_interval: I18n.t("invoice.#{subscription.plan.interval}"), plan_name: subscription.plan.invoice_name) + td.body-2 = 1 + td.body-2 == TaxHelper.applied_taxes(invoice_subscription(subscription.id).subscription_fee) + td.body-2 = MoneyHelper.format(invoice_subscription(subscription.id).subscription_amount) + + / Charge fees section for subsctiption invoice + - if subscription? && subscription_fees(subscription.id).charge.any? + + / Charges payed in arrears OR charges and plan payed in advance + - if (subscription.plan.charges.where(pay_in_advance: false).any? || (subscription.plan.charges.where(pay_in_advance: true).any? && subscription.plan.pay_in_advance?)) + .invoice-resume.overflow-auto + table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.fees_from_to_date', from_date: I18n.l(invoice_subscription(subscription.id).charges_from_datetime_in_customer_timezone&.to_date, format: :default), to_date: I18n.l(invoice_subscription(subscription.id).charges_to_datetime_in_customer_timezone&.to_date, format: :default)) + td.body-2 = I18n.t('invoice.unit') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount_without_tax') + + / Loop over all top level fees + - subscription_fees(subscription.id).charge.where(true_up_parent_fee: nil).group_by(&:charge_id).each do |_charge_id, fees| + - fee = fees.first + - next if fee.charge.pay_in_advance? && !fee.charge.plan.pay_in_advance? + + / Fees for groups + - if fees.all? { |f| f.group_id? } && fees.sum(&:units) > 0 + tr + td + .body-1 = fee.invoice_name + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_events', count: fees.sum(&:events_count)) + td + td + td + - fees.select { |f| f.units.positive? }.each do |fee| + tr + td style="padding-left: 16px;" + .body-1 = fee.filter_display_name(separator: ' • ') + - if fee.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(subscription.plan.interval)) + - if fee.charge.percentage? + .body-3 = I18n.t('invoice.total_events', count: fee.events_count) + td + .body-2 = fee.units + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = MoneyHelper.format(fee.amount) + + / True up fees attached to the fee + - fees.select { |f| f.true_up_fee.present? }.each do |fee| + == SlimHelper.render('templates/invoices/v3/_true_up_fee', fee) + + / Fees without group + - else + tr + td + .body-1 = fee.invoice_name + - if fee.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(subscription.plan.interval)) + - if fee.charge.percentage? + .body-3 = I18n.t('invoice.total_events', count: fee.events_count) + td + .body-2 = fees.sum(&:units) + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = MoneyHelper.format(fees.sum(&:amount)) + + / True up fees attached to the fee + - if fee.true_up_fee.present? + == SlimHelper.render('templates/invoices/v3/_true_up_fee', fee) + + / Charges payed in advance on payed in arrears plan + - if subscription.plan.charges.where(pay_in_advance: true).any? && !subscription.plan.pay_in_advance? + .invoice-resume.overflow-auto + table.invoice-resume-table width="100%" + tr + - pay_in_advance_interval = ::Subscriptions::DatesService.charge_pay_in_advance_interval(invoice_subscription(subscription.id).timestamp, subscription) + td.body-2 = I18n.t('invoice.fees_from_to_date', from_date: I18n.l(pay_in_advance_interval[:charges_from_date], format: :default), to_date: I18n.l(pay_in_advance_interval[:charges_to_date], format: :default)) + td.body-2 = I18n.t('invoice.unit') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount_without_tax') + + / Loop over all top level fees + - subscription_fees(subscription.id).charge.where(true_up_parent_fee: nil).group_by(&:charge_id).each do |_charge_id, fees| + - fee = fees.first + - next unless fee.charge.pay_in_advance? + + / Fees for groups + - if fees.all? { |f| f.group_id? } && fees.sum(&:units) > 0 + tr + td + .body-1 = fee.invoice_name + .body-3 + - if fee.charge.percentage? + = I18n.t('invoice.total_events', count: fees.sum(&:events_count)) + td + td + td + - fees.select { |f| f.units.positive? }.each do |fee| + tr + td style="padding-left: 16px;" + .body-1 = fee.filter_display_name(separator: ' • ') + - if fee.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(subscription.plan.interval)) + - if fee.charge.percentage? + .body-3 = I18n.t('invoice.total_events', count: fee.events_count) + td + .body-2 = fee.units + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = MoneyHelper.format(fee.amount) + + / True up fees attached to the fee + - fees.select { |f| f.true_up_fee.present? }.each do |fee| + == SlimHelper.render('templates/invoices/v3/_true_up_fee', fee) + + / Fees without group + - else + tr + td + .body-1 = fee.invoice_name + - if fee.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(subscription.plan.interval)) + - if fee.charge.percentage? + .body-3 = I18n.t('invoice.total_events', count: fee.events_count) + td + .body-2 = fees.sum(&:units) + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = MoneyHelper.format(fees.sum(&:amount)) + + / True up fees attached to the fee + - if fee.true_up_fee.present? + == SlimHelper.render('templates/invoices/v3/_true_up_fee', fee) + + + / Total section + .invoice-resume.overflow-auto + table.total-table width="100%" + - if subscriptions.count == 1 + - unless credit? + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.fees_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + - if subscription? && wallet_transactions.exists? + tr + td.body-2 + td.body-2 = I18n.t('invoice.prepaid_credits') + td.body-2 = '-' + MoneyHelper.format(prepaid_credit_amount) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total_due') + td.body-1 + = MoneyHelper.format(total_amount) + - else + tr + td.body-2 + td.body-1 = I18n.t('invoice.total') + td.body-1 + = MoneyHelper.format(invoice_subscription(subscription.id).total_amount) + + / Recuring fees breakdown + - if subscription? && subscription_fees(subscription.id).charge.any? + .invoice-resume.mb-24.overflow-auto + - recurring_fees(subscription.id).group_by(&:charge_id).each do |_charge_id, fees| + - next unless fees.sum(&:units) > 0 + + h2.invoice-details-title.title-2.mb-24 = I18n.t('invoice.details', resource: subscription.invoice_name) + + - number_of_days_in_period = 0 + + / Fees for groups + - if fees.all? { |f| f.group_id? } + - fees.select { |f| f.units.positive? }.each do |fee| + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown_of', fee_filter_display_name: fee.filter_display_name(separator: ' • ')) + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - recurring_breakdown(fee).each do |breakdown| + - number_of_days_in_period = breakdown.total_duration + tr + td width="20%" + .body-1 = I18n.l(breakdown.date, format: :default) + .body-3 = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + td.body-1 width="80%" + - if breakdown.action.to_sym == :add + | +#{breakdown.amount} #{fee.item_name} + - elsif breakdown.action.to_sym == :remove + | #{breakdown.amount.negative? ? '' : '-'}#{breakdown.amount} #{fee.item_name} + - else + | +/-#{breakdown.amount} #{fee.item_name} + + / Fees without group + - else + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown') + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - fees.each do |fee| + - recurring_breakdown(fee).each do |breakdown| + - number_of_days_in_period = breakdown.total_duration + tr + td width="20%" + .body-1 = breakdown.date.strftime('%b %d, %Y') + .body-3 = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + td.body-1 width="80%" + - if breakdown.action.to_sym == :add + | +#{breakdown.amount} #{fee.item_name} + - elsif breakdown.action.to_sym == :remove + | #{breakdown.amount.negative? ? '' : '-'}#{breakdown.amount} #{fee.item_name} + - else + | +/-#{breakdown.amount} #{fee.item_name} + + - if fees.first.charge.prorated? + .alert.body-3 = I18n.t('invoice.notice_prorated', days_in_month: number_of_days_in_period) + - else + .alert.body-3 = I18n.t('invoice.notice_full') diff --git a/app/views/templates/invoices/v3/_subscriptions_summary.slim b/app/views/templates/invoices/v3/_subscriptions_summary.slim new file mode 100644 index 0000000..796446c --- /dev/null +++ b/app/views/templates/invoices/v3/_subscriptions_summary.slim @@ -0,0 +1,50 @@ +table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.amount_without_tax') + - subscriptions.each do |subscription| + tr + td.body-1 = I18n.t('invoice.subscription_interval', plan_interval: I18n.t("invoice.#{subscription.plan.interval}"), plan_name: subscription.plan.invoice_name) + td.body-2 = MoneyHelper.format(invoice_subscription(subscription.id).total_amount) + +table.total-table width="100%" + - unless credit? + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.fees_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + - if subscription? && wallet_transactions.exists? + tr + td.body-2 + td.body-2 = I18n.t('invoice.prepaid_credits') + td.body-2 = '-' + MoneyHelper.format(prepaid_credit_amount) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total_due') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v3/_true_up_fee.slim b/app/views/templates/invoices/v3/_true_up_fee.slim new file mode 100644 index 0000000..c6793f0 --- /dev/null +++ b/app/views/templates/invoices/v3/_true_up_fee.slim @@ -0,0 +1,7 @@ +tr + td + .body-1 = I18n.t('invoice.true_up_metric', metric: true_up_fee.true_up_parent_fee.invoice_name) + .body-3 = I18n.t('invoice.true_up_details', min_amount: ChargeDisplayHelper.format_min_amount(charge)) + td.body-2 = true_up_fee.units + td.body-2 == TaxHelper.applied_taxes(true_up_fee) + td.body-2 = MoneyHelper.format(true_up_fee.amount) diff --git a/app/views/templates/invoices/v3/charge.slim b/app/views/templates/invoices/v3/charge.slim new file mode 100644 index 0000000..b38cae7 --- /dev/null +++ b/app/views/templates/invoices/v3/charge.slim @@ -0,0 +1,484 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr:first-child td { + color: #66758F; + } + .invoice-resume-table td { + padding-top: 8px; + padding-bottom: 8px; + } + .invoice-resume-table td:first-child { + width: 50%; + } + .invoice-resume-table td:nth-child(2) { + width: 14%; + } + .invoice-resume-table td:nth-child(3) { + width: 18%; + } + .invoice-resume-table td:nth-child(4) { + width: 18%; + } + .invoice-resume-table tr td:last-child { + text-align: right; + } + .invoice-resume-table tr { + border-bottom: 1px solid #D9DEE7; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid #D9DEE7; + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid #D9DEE7; + text-align: right; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details-table tr td { + padding-bottom: 12px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + .body-2 = customer.address_line1 + .body-2 = customer.address_line2 + .body-2 + span + = customer.zipcode + - if customer.zipcode.present? && customer.city.present? + span + | ,   + span + = customer.city + .body-2 = customer.state + .body-2 = ISO3166::Country.new(customer.country)&.common_name + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = MoneyHelper.format(total_amount) + .body-1 = advance_charges? ? I18n.t('invoice.already_paid') : I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.unit') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount_without_tax') + - fees.each do |fee| + tr + - if fee.charge.prorated? + - pay_in_advance_range = charge_pay_in_advance_proration_range(fee, invoice_subscription(fee.subscription.id).timestamp) + td + .body-1 = fee.invoice_name + .body-3 = I18n.t('invoice.breakdown_for_days', breakdown_duration: pay_in_advance_range[:number_of_days], breakdown_total_duration: pay_in_advance_range[:period_duration]) + - else + td + .body-1 = fee.invoice_name + td.body-2 = fee.units + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = MoneyHelper.format(fee.amount) + + table.total-table width="100%" + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.fees_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + - if wallet_transactions.exists? + tr + td.body-2 + td.body-2 = I18n.t('invoice.prepaid_credits') + td.body-2 = '-' + MoneyHelper.format(prepaid_credit_amount) + tr + td.body-2 + td.body-1 = advance_charges? ? I18n.t('invoice.already_paid') : I18n.t('invoice.total_due') + td.body-1 = MoneyHelper.format(total_amount) + + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v3/_custom_sections', self) + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + .powered-by + span.body-2 + | #{I18n.t('invoice.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" diff --git a/app/views/templates/invoices/v3/one_off.slim b/app/views/templates/invoices/v3/one_off.slim new file mode 100644 index 0000000..e970b78 --- /dev/null +++ b/app/views/templates/invoices/v3/one_off.slim @@ -0,0 +1,466 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr:first-child td { + color: #66758F; + } + .invoice-resume-table td { + padding-top: 8px; + padding-bottom: 8px; + } + .invoice-resume-table td:first-child { + width: 40%; + } + .invoice-resume-table td:nth-child(2) { + width: 10%; + } + .invoice-resume-table td:nth-child(3) { + width: 18%; + } + .invoice-resume-table td:nth-child(4) { + width: 14%; + } + .invoice-resume-table tr td:last-child { + width: 18%; + text-align: right; + } + .invoice-resume-table tr { + border-bottom: 1px solid #D9DEE7; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid #D9DEE7; + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid #D9DEE7; + text-align: right; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details-table tr td { + padding-bottom: 12px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + .body-2 = customer.address_line1 + .body-2 = customer.address_line2 + .body-2 + span + = customer.zipcode + - if customer.zipcode.present? && customer.city.present? + span + | ,   + span + = customer.city + .body-2 = customer.state + .body-2 = ISO3166::Country.new(customer.country)&.common_name + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + .body-1 = I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.unit') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount_without_tax') + - if one_off? + - fees.each do |fee| + tr + td + .body-1 = fee.invoice_name + .body-3 = fee.description + td.body-2 = fee.units + td.body-2 = MoneyHelper.format(fee.unit_amount) + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = MoneyHelper.format(fee.amount) + + table.total-table width="100%" + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.fees_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total_due') + td.body-1 = MoneyHelper.format(total_amount) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v3/_custom_sections', self) + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + .powered-by + span.body-2 + | #{I18n.t('invoice.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" diff --git a/app/views/templates/invoices/v4.slim b/app/views/templates/invoices/v4.slim new file mode 100644 index 0000000..637ceaa --- /dev/null +++ b/app/views/templates/invoices/v4.slim @@ -0,0 +1,486 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + :root { + --border-color: #D9DEE7; + } + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + .mt-24 { + margin-top: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td { + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr td { + padding-top: 4px; + padding-bottom: 4px; + text-align: right; + word-wrap: break-word; + } + .invoice-resume-table td:first-child { + width: 50%; + text-align: left; + } + .invoice-resume-table td:nth-child(2) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(3) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(4) { + width: 12.5%; + } + .invoice-resume-table td:nth-child(5) { + width: 12.5%; + max-width: 10vw; + } + + .invoice-resume-table tr.first_child td { + color: #66758F; + } + + .invoice-resume-table tr.charge-name td { + padding-bottom: 0; + color: #19212e; + } + + .invoice-resume-table tr.details td { + color: #66758F; + padding-top: 4px; + padding-bottom: 4px; + } + + .invoice-resume-table tr.details td:first-child { + padding-left: 8px; + } + + .invoice-resume-table tr.details.subtotal td { + color: #19212e; + } + + .invoice-resume-table tr.fee:first-child { + border-top: none; + } + /* Each first tr representing fee draws border above it */ + .invoice-resume-table tr.fee { + border-top: 1px solid var(--border-color); + } + .invoice-resume-table tr:last-child { + border-bottom: 1px solid var(--border-color); + } + + .invoice-resume-table tr.fee td { + padding-top: 8px; + } + /* If tr has next element tr.fee means that current fee info ended and we need bigger padding */ + .invoice-resume-table tr:has(+ tr.fee) td { + padding-bottom: 8px; + } + .invoice-resume-table tr:last-child td { + padding-bottom: 8px; + } + + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid var(--border-color); + text-align: left; + width: 35%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid var(--border-color); + text-align: right; + width: 15%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details { + margin-top: -15px; + } + .breakdown-details-table tr td { + padding-bottom: 8px; + padding-top: 8px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr td { + border-bottom: 1px solid var(--border-color); + } + .breakdown-details-table tr:first-child td { + border-top: 1px solid var(--border-color); + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + == SlimHelper.render('templates/invoices/v4/_customer_address', self) + .body-2 = customer.email&.gsub(/,\s*/, ', ') + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = MoneyHelper.format(total_amount) + .body-1 = I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + - if credit? + == SlimHelper.render('templates/invoices/v4/_credit', self) + - elsif progressive_billing? + == SlimHelper.render('templates/invoices/v4/_progressive_billing_details', self) + - elsif subscriptions.count == 1 + == SlimHelper.render('templates/invoices/v4/_subscription_details', self) + - else + == SlimHelper.render('templates/invoices/v4/_subscriptions_summary', self) + + == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + + - if progressive_billing? + p.body-3.mb-24 + - applied_usage_threshold = applied_usage_thresholds.order(created_at: :asc).last + = I18n.t('invoice.reached_usage_threshold', usage_amount: MoneyHelper.format(applied_usage_threshold.lifetime_usage_amount), threshold_amount: MoneyHelper.format(applied_usage_threshold.passed_threshold_amount)) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + == SlimHelper.render('templates/invoices/v4/_powered_by_logo', self) + + - if subscriptions.count > 1 + == SlimHelper.render('templates/invoices/v4/_subscription_details', self) diff --git a/app/views/templates/invoices/v4/_charge.slim b/app/views/templates/invoices/v4/_charge.slim new file mode 100644 index 0000000..284bf8a --- /dev/null +++ b/app/views/templates/invoices/v4/_charge.slim @@ -0,0 +1,110 @@ +table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.units') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount') + - fees.order(:succeeded_at, :created_at).each do |fee| + - if fee.fixed_charge? + - next + - if fee.charge.percentage? && fee.amount_details.present? + - if fee.basic_rate_percentage? + tr.fee + td.body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + td.body-2 = fee.amount_details['paid_units'] + td.body-2 = fee.amount_details['rate'] + '%' + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = FeeDisplayHelper.format_as_currency(fee, fee.amount_details['per_unit_total_amount']) + - else + tr.charge-name.fee + td.body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + - if fee.charge_filter_id? + = ' • ' + fee.filter_display_name(separator: ' • ') + - if fee.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(fee.subscription.plan.interval)) + - if fee.succeeded_at.present? + .body-3 = I18n.l(fee.succeeded_at.to_date, format: :default) + ' • ' + I18n.t('invoice.total_events', count: fee.events_count) + - else + .body-3 = I18n.t('invoice.total_events', count: fee.events_count) + - if fee.charge.prorated? + .body-3 = I18n.t('invoice.fee_prorated') + td.body-2 + td.body-2 + td.body-2 + td.body-2 + == SlimHelper.render('templates/invoices/v4/_charge_percentage', fee) + - else + tr.fee + - if !fee.charge.invoiceable? # TODO: edit with `invoicing_strategy` + td + .body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + - if fee.charge_filter_id? + = ' • ' + fee.filter_display_name(separator: ' • ') + - succeeded_at_date = fee.succeeded_at&.in_time_zone(customer.applicable_timezone)&.to_date + - if succeeded_at_date + .body-3 = I18n.l(succeeded_at_date, format: :default) + - elsif fee.charge.prorated? + - pay_in_advance_range = charge_pay_in_advance_proration_range(fee, invoice_subscription(fee.subscription.id).timestamp) + td + .body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + - if fee.charge_filter_id? + = ' • ' + fee.filter_display_name(separator: ' • ') + .body-3 = I18n.t('invoice.breakdown_for_days', breakdown_duration: pay_in_advance_range[:number_of_days], breakdown_total_duration: pay_in_advance_range[:period_duration]) + - else + td.body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + - if fee.charge_filter_id? + = ' • ' + fee.filter_display_name(separator: ' • ') + td.body-2 = RoundingHelper.round_decimal_part(fee.units) + td.body-2 = FeeDisplayHelper.format_precise_unit_amount(fee) + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = FeeDisplayHelper.format_amount(fee) + + == SlimHelper.render('templates/invoices/v4/_conversion_row', fee) + +table.total-table width="100%" + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + - if applied_tax.applied_on_whole_invoice? + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_only.' + applied_tax.tax_code) + td.body-2 + - else + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.taxable_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + == SlimHelper.render('templates/invoices/v4/_prepaid_credits', self) + tr + td.body-2 + td.body-1 = advance_charges? ? I18n.t('invoice.already_paid') : I18n.t('invoice.total') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v4/_charge_percentage.slim b/app/views/templates/invoices/v4/_charge_percentage.slim new file mode 100644 index 0000000..572f679 --- /dev/null +++ b/app/views/templates/invoices/v4/_charge_percentage.slim @@ -0,0 +1,41 @@ +- if amount_details['free_units'].to_f.positive? || amount_details['free_events'].to_f.positive? + / Free units per transaction + tr.details + td.body-2 = I18n.t('invoice.percentage.free_units_per_transaction', count: 1) + td.body-2 = amount_details['free_units'] + td.body-2 = MoneyHelper.format(0.to_money(amount_currency)) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = MoneyHelper.format(0.to_money(amount_currency)) +/ Percentage rate on amount +- if amount_details['paid_units'].to_f.positive? + tr.details + td.body-2 = I18n.t('invoice.percentage.percentage_rate_on_amount') + td.body-2 = amount_details['paid_units'] + td.body-2 = amount_details['rate'] + '%' + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['per_unit_total_amount']) +- if amount_details['fixed_fee_total_amount'].to_f.positive? + / Fixed fee per transaction + tr.details + td.body-2 = I18n.t('invoice.percentage.fee_per_transaction') + td.body-2 = amount_details['paid_events'] + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['fixed_fee_total_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['fixed_fee_total_amount']) +- unless amount_details['min_max_adjustment_total_amount'].to_f.zero? + / Adjustment for min/max per transaction + tr.details + td.body-2 = I18n.t('invoice.percentage.adjustment_per_transaction') + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['min_max_adjustment_total_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['min_max_adjustment_total_amount']) +/ Sub total +tr.details.subtotal + td.body-2 = I18n.t('invoice.sub_total') + td.body-2 + td.body-2 + td.body-2 + td.body-2 = FeeDisplayHelper.format_amount(self) + +== SlimHelper.render('templates/invoices/v4/_conversion_row', self) diff --git a/app/views/templates/invoices/v4/_conversion_row.slim b/app/views/templates/invoices/v4/_conversion_row.slim new file mode 100644 index 0000000..62a34c2 --- /dev/null +++ b/app/views/templates/invoices/v4/_conversion_row.slim @@ -0,0 +1,11 @@ +- fee = self +- if fee.pricing_unit_usage + - name = fee.pricing_unit_usage.short_name + - fiat_counterpart = MoneyHelper.format_with_precision(fee.pricing_unit_usage.conversion_rate, fee.charge.plan.amount_currency) + + tr.details.subtotal + td.body-2 = "#{I18n.t("invoice.conversion_rate")}: 1 #{name} = #{fiat_counterpart}" + td.body-2 + td.body-2 + td.body-2 + td.body-2 = MoneyHelper.format(fee.amount) diff --git a/app/views/templates/invoices/v4/_credit.slim b/app/views/templates/invoices/v4/_credit.slim new file mode 100644 index 0000000..2b3d66e --- /dev/null +++ b/app/views/templates/invoices/v4/_credit.slim @@ -0,0 +1,23 @@ +- invoiceable = fees.first.invoiceable +table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.units') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.amount') + tr + - if invoiceable.name.present? + td.body-1 = invoiceable.name + - elsif invoiceable.wallet.name.present? + td.body-1 = I18n.t('invoice.prepaid_credits_with_value', wallet_name: invoiceable.wallet.name) + - else + td.body-1 = I18n.t('invoice.prepaid_credits') + td.body-2 = invoiceable.credit_amount + td.body-2 = invoiceable.wallet.rate_amount + td.body-2 = MoneyHelper.format(fees.first.amount) + +table.total-table width="100%" + tr + td.body-2 + td.body-1 = I18n.t('invoice.total') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v4/_custom_sections.slim b/app/views/templates/invoices/v4/_custom_sections.slim new file mode 100644 index 0000000..1938150 --- /dev/null +++ b/app/views/templates/invoices/v4/_custom_sections.slim @@ -0,0 +1,15 @@ +css: + .invoice-custom-section { + margin-top: 24px; + border-bottom: 1px solid #D9DEE7; + } + + .invoice-custom-section p.section-name { + margin-bottom: 8px; + } + +.invoice-custom-sections.body-3.mb-24 + - applied_invoice_custom_sections.each do |section| + .invoice-custom-section + p.body-1.section-name = section.display_name + p.body-3.mb-24 = LineBreakHelper.break_lines(section.details) diff --git a/app/views/templates/invoices/v4/_customer_address.slim b/app/views/templates/invoices/v4/_customer_address.slim new file mode 100644 index 0000000..82647da --- /dev/null +++ b/app/views/templates/invoices/v4/_customer_address.slim @@ -0,0 +1,12 @@ += Addressing::DefaultFormatter.new.format( \ + Addressing::Address.new( \ + address_line1: customer.address_line1 || "", + address_line2: customer.address_line2 || "", + locality: customer.city || "", + postal_code: customer.zipcode || "", + administrative_area: customer.state || "", + country_code: customer.country || "", + locale: customer.document_locale \ + ), + { locale: customer.document_locale || customer.billing_entity.document_locale, html_tag: "div", html_attributes: { class: "body-2" } } \ +).html_safe \ No newline at end of file diff --git a/app/views/templates/invoices/v4/_default_fee.slim b/app/views/templates/invoices/v4/_default_fee.slim new file mode 100644 index 0000000..2513311 --- /dev/null +++ b/app/views/templates/invoices/v4/_default_fee.slim @@ -0,0 +1,19 @@ +- fee = self +tr.fee + td + .body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + - if fee.charge_filter_id? + = ' • ' + fee.filter_display_name(separator: ' • ') + - if fee.billable_metric&.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(fee.subscription.plan.interval)) + - if fee.charge&.percentage? + .body-3 = I18n.t('invoice.total_events', count: fee.events_count) + - if fee.charge&.prorated? || fee.fixed_charge&.prorated? + .body-3 = I18n.t('invoice.fee_prorated') + td.body-2 = RoundingHelper.round_decimal_part(fee.units) + td.body-2 = FeeDisplayHelper.format_precise_unit_amount(fee) + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = FeeDisplayHelper.format_amount(fee) + +== SlimHelper.render('templates/invoices/v4/_conversion_row', fee) diff --git a/app/views/templates/invoices/v4/_default_fee_with_filters.slim b/app/views/templates/invoices/v4/_default_fee_with_filters.slim new file mode 100644 index 0000000..c1bb7e4 --- /dev/null +++ b/app/views/templates/invoices/v4/_default_fee_with_filters.slim @@ -0,0 +1,15 @@ +tr.fee + td + .body-1 = self.invoice_name + FeeDisplayHelper.grouped_by_display(self) + ' • ' + self.filter_display_name(separator: ' • ') + - if self.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(self.subscription.plan.interval)) + - if self.charge.percentage? + .body-3 = I18n.t('invoice.total_events', count: self.events_count) + - if self.charge.prorated? + .body-3 = I18n.t('invoice.fee_prorated') + td.body-2 = RoundingHelper.round_decimal_part(self.units) + td.body-2 = FeeDisplayHelper.format_precise_unit_amount(self) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_amount(self) + +== SlimHelper.render('templates/invoices/v4/_conversion_row', self) diff --git a/app/views/templates/invoices/v4/_eu_tax_management.slim b/app/views/templates/invoices/v4/_eu_tax_management.slim new file mode 100644 index 0000000..5a3f54a --- /dev/null +++ b/app/views/templates/invoices/v4/_eu_tax_management.slim @@ -0,0 +1,11 @@ +- if billing_entity.eu_tax_management.present? + - if applied_taxes.present? + - applied_tax_codes = applied_taxes.pluck(:tax_code) + p.body-3.mb-24 + - if applied_tax_codes.include?('lago_eu_tax_exempt') + - if billing_entity.country == 'FR' + = I18n.t('invoice.taxes.fr_tax_exempt') + - else + = I18n.t('invoice.taxes.tax_exempt') + - if applied_tax_codes.include?('lago_eu_reverse_charge') + = I18n.t('invoice.taxes.reverse_charge') \ No newline at end of file diff --git a/app/views/templates/invoices/v4/_fee_with_filters.slim b/app/views/templates/invoices/v4/_fee_with_filters.slim new file mode 100644 index 0000000..4cbb71a --- /dev/null +++ b/app/views/templates/invoices/v4/_fee_with_filters.slim @@ -0,0 +1,27 @@ +tr.charge-name.fee + td.body-1 + = self.invoice_name + FeeDisplayHelper.grouped_by_display(self) + ' • ' + self.filter_display_name(separator: ' • ') + - if self.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(self.subscription.plan.interval)) + - if self.charge.percentage? + .body-3 = I18n.t('invoice.total_events', count: self.events_count) + - if self.charge.prorated? + .body-3 = I18n.t('invoice.fee_prorated') + td.body-2 + td.body-2 + td.body-2 + td.body-2 + +- case self.charge.charge_model.to_sym +- when :graduated_percentage + == SlimHelper.render('templates/invoices/v4/_graduated_percentage', self) +- when :graduated + == SlimHelper.render('templates/invoices/v4/_graduated', self) +- when :percentage + == SlimHelper.render('templates/invoices/v4/_percentage', self) +- when :volume + == SlimHelper.render('templates/invoices/v4/_volume', self) +- when :package + == SlimHelper.render('templates/invoices/v4/_package', self) + +== SlimHelper.render('templates/invoices/v4/_conversion_row', self) diff --git a/app/views/templates/invoices/v4/_fees_without_filters.slim b/app/views/templates/invoices/v4/_fees_without_filters.slim new file mode 100644 index 0000000..f56b5d5 --- /dev/null +++ b/app/views/templates/invoices/v4/_fees_without_filters.slim @@ -0,0 +1,38 @@ +- fee = self + +- if fee.amount.zero? || fee.amount_details.blank? + == SlimHelper.render('templates/invoices/v4/_default_fee', fee) +- else + tr.charge-name.fee + td.body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + - if fee.charge_filter_id? + = ' • ' + fee.filter_display_name(separator: ' • ') + - if fee.billable_metric.weighted_sum_agg? + .body-3 = I18n.t('invoice.units_prorated_per_period', period: IntervalHelper.interval_name(fee.subscription.plan.interval)) + - if fee.charge.percentage? + .body-3 = I18n.t('invoice.total_events', count: fee.events_count) + - if fee.charge.prorated? + .body-3 = I18n.t('invoice.fee_prorated') + td.body-2 + td.body-2 + td.body-2 + td.body-2 + + - case fee.charge.charge_model.to_sym + - when :graduated_percentage + == SlimHelper.render('templates/invoices/v4/_graduated_percentage', fee) + - when :graduated + == SlimHelper.render('templates/invoices/v4/_graduated', fee) + - when :percentage + == SlimHelper.render('templates/invoices/v4/_percentage', fee) + - when :volume + == SlimHelper.render('templates/invoices/v4/_volume', fee) + - when :package + == SlimHelper.render('templates/invoices/v4/_package', fee) + + == SlimHelper.render('templates/invoices/v4/_conversion_row', fee) + +/ True up fees attached to the fee +- if fee.true_up_fee.present? + == SlimHelper.render('templates/invoices/v4/_true_up_fee', fee) diff --git a/app/views/templates/invoices/v4/_fixed_charge.slim b/app/views/templates/invoices/v4/_fixed_charge.slim new file mode 100644 index 0000000..aef684a --- /dev/null +++ b/app/views/templates/invoices/v4/_fixed_charge.slim @@ -0,0 +1,62 @@ +- invoice_subscription = invoice_subscriptions.first +- fixed_charge_fees = fees.fixed_charge.order(:succeeded_at, :created_at) +- grouped_fees = FeeBoundariesHelper.group_fees_by_billing_period(fixed_charge_fees, invoice_subscription:) + +== SlimHelper.render('templates/invoices/v4/_subscription_name', self, subscription: invoice_subscription.subscription, page_break: false) + +- grouped_fees.each_with_index do |fee_group, group_index| + - next unless fee_group.fixed_charge_fees.any? + - billing_period = fee_group.billing_period + + .invoice-resume.overflow-auto class="#{'mt-24' if group_index > 0}" + table.invoice-resume-table width="100%" + tr.first_child + td.body-2 = FeeBoundariesHelper.format_billing_period(billing_period, customer:) + td.body-2 = I18n.t('invoice.units') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount') + - fee_group.fixed_charge_fees.each do |fee| + == SlimHelper.render('templates/invoices/v4/_fixed_charge_fee', fee) + +table.total-table width="100%" + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + - if applied_tax.applied_on_whole_invoice? + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_only.' + applied_tax.tax_code) + td.body-2 + - else + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.taxable_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + == SlimHelper.render('templates/invoices/v4/_prepaid_credits', self) + tr + td.body-2 + td.body-1 = advance_charges? ? I18n.t('invoice.already_paid') : I18n.t('invoice.total') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v4/_fixed_charge_fee.slim b/app/views/templates/invoices/v4/_fixed_charge_fee.slim new file mode 100644 index 0000000..0892549 --- /dev/null +++ b/app/views/templates/invoices/v4/_fixed_charge_fee.slim @@ -0,0 +1,19 @@ +- fee = self +- if fee.amount.zero? || fee.amount_details.blank? + == SlimHelper.render('templates/invoices/v4/_default_fee', fee) +- else + tr.fixed-charge-name.fee + td.body-1 + = fee.invoice_name + FeeDisplayHelper.grouped_by_display(fee) + - if fee.fixed_charge.prorated? + .body-3 = I18n.t('invoice.fee_prorated') + td.body-2 + td.body-2 + td.body-2 + td.body-2 + + - case fee.fixed_charge.charge_model.to_sym + - when :graduated + == SlimHelper.render('templates/invoices/v4/_graduated', fee) + - when :volume + == SlimHelper.render('templates/invoices/v4/_volume', fee) \ No newline at end of file diff --git a/app/views/templates/invoices/v4/_graduated.slim b/app/views/templates/invoices/v4/_graduated.slim new file mode 100644 index 0000000..31c1fa2 --- /dev/null +++ b/app/views/templates/invoices/v4/_graduated.slim @@ -0,0 +1,65 @@ +- ranges = amount_details['graduated_ranges'] +- first_range = ranges[0]['to_value'].nil? ? nil : ranges[0] +- last_range = ranges.find { |r| r['to_value'].nil? } +- next_ranges = ranges.select { |r| r['from_value'] != 0 && !r['to_value'].nil? } + +- if first_range + / First graduated range + tr.details + td.body-2 = I18n.t('invoice.graduated.fee_per_unit_for_the_first', to: first_range['to_value']) + td.body-2 = first_range['units'] + td.body-2 = FeeDisplayHelper.format_with_precision(self, first_range['per_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, first_range['per_unit_total_amount']) + +- next_ranges.each do |range| + tr.details + td.body-2 = I18n.t('invoice.graduated.fee_per_unit_for_the_next', from: range['from_value'], to: range['to_value']) + td.body-2 = range['units'] + td.body-2 = FeeDisplayHelper.format_with_precision(self, range['per_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, range['per_unit_total_amount']) + +- if last_range + / Last graduated range + tr.details + td.body-2 = I18n.t('invoice.graduated.fee_per_unit_for_the_last', from: last_range['from_value']) + td.body-2 = last_range['units'] + td.body-2 = FeeDisplayHelper.format_with_precision(self, last_range['per_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, last_range['per_unit_total_amount']) + +- if first_range && first_range['flat_unit_amount'].to_f.positive? + / First flat fee + tr.details + td.body-2 = I18n.t('invoice.graduated.flat_fee_for_the_first', to: first_range['to_value']) + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, first_range['flat_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, first_range['flat_unit_amount']) + +- next_ranges.each do |range| + - if range['flat_unit_amount'].to_f.positive? + tr.details + td.body-2 = I18n.t('invoice.graduated.flat_fee_for_the_next', from: range['from_value'], to: range['to_value']) + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, range['flat_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, range['flat_unit_amount']) + +- if last_range && last_range['flat_unit_amount'].to_f.positive? + / Last flat fee + tr.details + td.body-2 = I18n.t('invoice.graduated.flat_fee_for_the_last', from: last_range['from_value']) + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, last_range['flat_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, last_range['flat_unit_amount']) + +/ Sub total +tr.details.subtotal + td.body-2 = I18n.t('invoice.sub_total') + td.body-2 + td.body-2 + td.body-2 + td.body-2 = FeeDisplayHelper.format_amount(self) diff --git a/app/views/templates/invoices/v4/_graduated_percentage.slim b/app/views/templates/invoices/v4/_graduated_percentage.slim new file mode 100644 index 0000000..c5f6c12 --- /dev/null +++ b/app/views/templates/invoices/v4/_graduated_percentage.slim @@ -0,0 +1,65 @@ +- ranges = amount_details['graduated_percentage_ranges'] +- first_range = ranges[0]['to_value'].nil? ? nil : ranges[0] +- last_range = ranges.find { |r| r['to_value'].nil? } +- next_ranges = ranges.select { |r| r['from_value'] != 0 && !r['to_value'].nil? } + +- if first_range + / First graduated range + tr.details + td.body-2 = I18n.t('invoice.graduated.fee_per_unit_for_the_first', to: first_range['to_value']) + td.body-2 = first_range['units'] + td.body-2 = first_range['rate'] + '%' + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, first_range['per_unit_total_amount']) + +- next_ranges.each do |range| + tr.details + td.body-2 = I18n.t('invoice.graduated.fee_per_unit_for_the_next', from: range['from_value'], to: range['to_value']) + td.body-2 = range['units'] + td.body-2 = range['rate'] + '%' + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, range['per_unit_total_amount']) + +- if last_range + / Last graduated range + tr.details + td.body-2 = I18n.t('invoice.graduated.fee_per_unit_for_the_last', from: last_range['from_value']) + td.body-2 = last_range['units'] + td.body-2 = last_range['rate'] + '%' + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, last_range['per_unit_total_amount']) + +- if first_range && first_range['flat_unit_amount'].to_f.positive? + / First flat fee + tr.details + td.body-2 = I18n.t('invoice.graduated.flat_fee_for_the_first', to: first_range['to_value']) + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, first_range['flat_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, first_range['flat_unit_amount']) + +- next_ranges.each do |range| + - if range['flat_unit_amount'].to_f.positive? + tr.details + td.body-2 = I18n.t('invoice.graduated.flat_fee_for_the_next', from: range['from_value'], to: range['to_value']) + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, range['flat_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, range['flat_unit_amount']) + +- if last_range && last_range['flat_unit_amount'].to_f.positive? + / Last flat fee + tr.details + td.body-2 = I18n.t('invoice.graduated.flat_fee_for_the_last', from: last_range['from_value']) + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, last_range['flat_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, last_range['flat_unit_amount']) + +/ Sub total +tr.details.subtotal + td.body-2 = I18n.t('invoice.sub_total') + td.body-2 + td.body-2 + td.body-2 + td.body-2 = FeeDisplayHelper.format_amount(self) diff --git a/app/views/templates/invoices/v4/_one_off.slim b/app/views/templates/invoices/v4/_one_off.slim new file mode 100644 index 0000000..363d2bc --- /dev/null +++ b/app/views/templates/invoices/v4/_one_off.slim @@ -0,0 +1,49 @@ +table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.units') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount') + - if one_off? + - fees.ordered_by_period.each do |fee| + tr + td + .body-1 = fee.invoice_name + .body-3 = fee.description + - if fee.properties["from_datetime"] && fee.properties["to_datetime"] + .body-3 #{I18n.t('invoice.from_to_date', from_date: I18n.l(fee.properties["from_datetime"]&.to_date, format: :default), to_date: I18n.l(fee.properties["to_datetime"]&.to_date, format: :default))} + td.body-2 = RoundingHelper.round_decimal_part(fee.units) + td.body-2 = MoneyHelper.format(fee.unit_amount) + td.body-2 == TaxHelper.applied_taxes(fee) + td.body-2 = FeeDisplayHelper.format_amount(fee) + +table.total-table width="100%" + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + - if applied_tax.applied_on_whole_invoice? + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_only.' + applied_tax.tax_code) + td.body-2 + - else + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.taxable_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v4/_package.slim b/app/views/templates/invoices/v4/_package.slim new file mode 100644 index 0000000..6c8610e --- /dev/null +++ b/app/views/templates/invoices/v4/_package.slim @@ -0,0 +1,22 @@ +- if amount_details['free_units'].to_f.positive? + / Free units for the first + tr.details + td.body-2 = I18n.t('invoice.package.free_units_for_the_first', count: amount_details['free_units']) + td.body-2 = amount_details['free_units'] + td.body-2 = MoneyHelper.format(0.to_money(amount_currency)) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = MoneyHelper.format(0.to_money(amount_currency)) +/ Fee per package +tr.details + td.body-2 = I18n.t('invoice.package.fee_per_package') + td.body-2 = amount_details['paid_units'] + td.body-2 = I18n.t('invoice.package.fee_per_package_unit_price', amount: FeeDisplayHelper.format_with_precision(self, amount_details['per_package_unit_amount']), package_size: amount_details['per_package_size']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_amount(self) +/ Sub total +tr.details.subtotal + td.body-2 = I18n.t('invoice.sub_total') + td.body-2 + td.body-2 + td.body-2 + td.body-2 = FeeDisplayHelper.format_amount(self) diff --git a/app/views/templates/invoices/v4/_percentage.slim b/app/views/templates/invoices/v4/_percentage.slim new file mode 100644 index 0000000..021b915 --- /dev/null +++ b/app/views/templates/invoices/v4/_percentage.slim @@ -0,0 +1,38 @@ +- if amount_details['free_units'].to_f.positive? + / Free units per transaction + tr.details + td.body-2 = I18n.t('invoice.percentage.free_units_per_transaction', count: amount_details['free_events']) + td.body-2 = amount_details['free_units'] + td.body-2 = MoneyHelper.format(0.to_money(amount_currency)) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = MoneyHelper.format(0.to_money(amount_currency)) +/ Percentage rate on amount +tr.details + td.body-2 = I18n.t('invoice.percentage.percentage_rate_on_amount') + td.body-2 = amount_details['paid_units'] + td.body-2 = amount_details['rate'] + '%' + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['per_unit_total_amount']) +- if amount_details['fixed_fee_unit_amount'].to_f.positive? + / Fixed fee per transaction + tr.details + td.body-2 = I18n.t('invoice.percentage.fee_per_transaction') + td.body-2 = amount_details['paid_events'] + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['fixed_fee_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['fixed_fee_total_amount']) +- unless amount_details['min_max_adjustment_total_amount'].to_f.zero? + / Adjustment for min/max per transaction + tr.details + td.body-2 = I18n.t('invoice.percentage.adjustment_per_transaction') + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['min_max_adjustment_total_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['min_max_adjustment_total_amount']) +/ Sub total +tr.details.subtotal + td.body-2 = I18n.t('invoice.sub_total') + td.body-2 + td.body-2 + td.body-2 + td.body-2 = FeeDisplayHelper.format_amount(self) diff --git a/app/views/templates/invoices/v4/_powered_by_logo.slim b/app/views/templates/invoices/v4/_powered_by_logo.slim new file mode 100644 index 0000000..9fb2291 --- /dev/null +++ b/app/views/templates/invoices/v4/_powered_by_logo.slim @@ -0,0 +1,5 @@ +- unless organization.remove_branding_watermark_enabled? + .powered-by + span.body-2 + | #{I18n.t('invoice.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" diff --git a/app/views/templates/invoices/v4/_prepaid_credits.slim b/app/views/templates/invoices/v4/_prepaid_credits.slim new file mode 100644 index 0000000..0d288b3 --- /dev/null +++ b/app/views/templates/invoices/v4/_prepaid_credits.slim @@ -0,0 +1,18 @@ +/ Prepaid credits section - shows breakdown if available, otherwise shows total +- if (subscription? || progressive_billing?) && wallet_transactions.exists? + - if prepaid_granted_credit_amount_cents.present? || prepaid_purchased_credit_amount_cents.present? + - if prepaid_granted_credit_amount_cents&.positive? + tr + td.body-2 + td.body-2 = I18n.t('invoice.free_credits') + td.body-2 = '-' + MoneyHelper.format(prepaid_granted_credit_amount) + - if prepaid_purchased_credit_amount_cents&.positive? + tr + td.body-2 + td.body-2 = I18n.t('invoice.prepaid_credits') + td.body-2 = '-' + MoneyHelper.format(prepaid_purchased_credit_amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.prepaid_credits') + td.body-2 = '-' + MoneyHelper.format(prepaid_credit_amount) diff --git a/app/views/templates/invoices/v4/_progressive_billing_details.slim b/app/views/templates/invoices/v4/_progressive_billing_details.slim new file mode 100644 index 0000000..df972e1 --- /dev/null +++ b/app/views/templates/invoices/v4/_progressive_billing_details.slim @@ -0,0 +1,90 @@ +- subscription = subscriptions.first +- invoice_subscription = invoice_subscription(subscription.id) + +/ Subscription fee section +.invoice-resume.overflow-auto + table.invoice-resume-table width="100%" + tr.first_child + td.body-2 = I18n.t('invoice.fees_from_to_date', from_date: I18n.l(invoice_subscription.charges_from_datetime_in_customer_timezone&.to_date, format: :default), to_date: I18n.l(issuing_date, format: :default)) + td.body-2 = I18n.t('invoice.units') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount') + +/ Charge fees section for subscription invoice +- if subscription_fees(subscription.id).charge.any? + / Charges payed in arrears OR charges and plan payed in advance + - if subscription.plan.charges.any? + .invoice-resume.overflow-auto + table.invoice-resume-table width="100%" + + / Loop over all top level fees + - subscription_fees(subscription.id).charge.positive_units.where(true_up_parent_fee: nil).joins(charge: :billable_metric).sort_by { |f| f.invoice_sorting_clause }.group_by(&:charge_id).each do |_charge_id, fees| + - fee = fees.first + - next if fee.charge.pay_in_advance? + + / Fees for filters + - if fees.all? { |f| f.charge_filter_id? } && fees.sum(&:units) > 0 + - fees.select { |f| f.units.positive? }.each do |fee| + - if fee.amount_details.blank? + == SlimHelper.render('templates/invoices/v4/_default_fee_with_filters', fee) + - else + == SlimHelper.render('templates/invoices/v4/_fee_with_filters', fee) + + / True up fees attached to the fee + - fees.select { |f| f.true_up_fee.present? }.each do |fee| + == SlimHelper.render('templates/invoices/v4/_true_up_fee', fee) + + / Fees without filters + - else + - fees.sort_by { |f| f.invoice_sorting_clause }.each do |fee| + == SlimHelper.render('templates/invoices/v4/_fees_without_filters', fee) + +/ Total section +.invoice-resume.overflow-auto + table.total-table width="100%" + - if progressive_billing_credit_amount.positive? + tr + td.body-2 + td.body-2 = I18n.t('invoice.progressive_billing_credit') + td.body-2 = '-' + MoneyHelper.format(progressive_billing_credit_amount) + + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + td.body-2 + - if applied_tax.applied_on_whole_invoice? + td.body-2 = I18n.t('invoice.tax_name_only.' + applied_tax.tax_code) + - else + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.taxable_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + == SlimHelper.render('templates/invoices/v4/_prepaid_credits', self) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total') + td.body-1 + = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v4/_subscription_details.slim b/app/views/templates/invoices/v4/_subscription_details.slim new file mode 100644 index 0000000..d850367 --- /dev/null +++ b/app/views/templates/invoices/v4/_subscription_details.slim @@ -0,0 +1,195 @@ +- if subscription? + - sorted_subscriptions.each do |subscription| + - invoice_subscription = invoice_subscription(subscription.id) + == SlimHelper.render('templates/invoices/v4/_subscription_name', self, subscription: subscription, page_break: subscriptions.count > 1) + + / Group all fees by their billing period (with eager loading and DB-level filtering to avoid N+1 queries) + - base_fees = subscription_fees(subscription.id).includes(:true_up_parent_fee, :true_up_fee, :charge_filter, charge: :billable_metric, fixed_charge: :add_on) + / Include: subscription, commitment, fixed_charge with positive units, charge with positive units (excluding true_up fees), + / and charge fees that are parents of true_up fees (even with zero units) + - filtered_fees = base_fees.where(fee_type: [:subscription, :commitment]).or(base_fees.fixed_charge.positive_units).or(base_fees.charge.positive_units.where(true_up_parent_fee: nil)).or(base_fees.charge.where(id: base_fees.charge.select(:true_up_parent_fee_id).where.not(true_up_parent_fee_id: nil))) + - grouped_fees = FeeBoundariesHelper.group_fees_by_billing_period(filtered_fees, invoice_subscription:) + + / Render each billing period group + - grouped_fees.each_with_index do |fee_group, group_index| + - next unless fee_group.has_any_fees? + - billing_period = fee_group.billing_period + + / Billing period section + .invoice-resume.overflow-auto class="#{'mt-24' if group_index > 0}" + table.invoice-resume-table width="100%" + / Period header row + tr.first_child + td.body-2 = FeeBoundariesHelper.format_billing_period(billing_period, customer:) + td.body-2 = I18n.t('invoice.units') + td.body-2 = I18n.t('invoice.unit_price') + td.body-2 = I18n.t('invoice.tax_rate') + td.body-2 = I18n.t('invoice.amount') + + / 1. Subscription fee (if present in this period) + - if fee_group.subscription_fee && FeeDisplayHelper.should_display_subscription_fee?(invoice_subscription) + - subscription_fee = fee_group.subscription_fee + tr.fee + - if subscription_fee.invoice_display_name.present? + td.body-1 = subscription_fee.invoice_display_name + - else + td.body-1 = I18n.t('invoice.subscription_interval', plan_interval: I18n.t("invoice.#{subscription.plan.interval}"), plan_name: subscription.plan.invoice_name) + td.body-2 = subscription_fee&.units || 1 + td.body-2 = MoneyHelper.format(subscription_fee&.unit_amount || 0) + td.body-2 == TaxHelper.applied_taxes(subscription_fee) + td.body-2 = MoneyHelper.format(subscription_fee.amount) + + / 2. Fixed charge fees (alphabetically ordered, already filtered for positive units) + - fee_group.fixed_charge_fees.each do |fee| + == SlimHelper.render('templates/invoices/v4/_fixed_charge_fee', fee) + + / 3. Charge fees (alphabetically ordered, already filtered for positive units and no true-up parent) + - fee_group.charge_fees.group_by(&:charge_id).each do |_charge_id, fees| + / Fees for filters + - if fees.all? { |f| f.charge_filter_id? } && fees.sum(&:units) > 0 + - fees.each do |fee| + - if fee.amount_details.blank? + == SlimHelper.render('templates/invoices/v4/_default_fee_with_filters', fee) + - else + == SlimHelper.render('templates/invoices/v4/_fee_with_filters', fee) + + / True up fees attached to the fee + - fees.select { |f| f.true_up_fee.present? }.each do |fee| + == SlimHelper.render('templates/invoices/v4/_true_up_fee', fee) + + / Fees without filters + - else + - fees.each do |fee| + == SlimHelper.render('templates/invoices/v4/_fees_without_filters', fee) + + / 4. Minimum commitment fee (if present in this period) + - if fee_group.commitment_fee + - commitment_fee = fee_group.commitment_fee + tr.fee + td.body-1 = commitment_fee.invoice_name + td.body-2 = 1 + td.body-2 = MoneyHelper.format(commitment_fee.amount) + td.body-2 == TaxHelper.applied_taxes(commitment_fee) + td.body-2 = MoneyHelper.format(commitment_fee.amount) + + / Total section + .invoice-resume.overflow-auto + table.total-table width="100%" + - if subscriptions.count == 1 + - unless credit? + - if progressive_billing_credit_amount_cents.positive? + tr + td.body-2 + td.body-2 = I18n.t('invoice.progressive_billing_credit') + td.body-2 = '-' + MoneyHelper.format(progressive_billing_credit_amount) + + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + td.body-2 + - if applied_tax.applied_on_whole_invoice? + td.body-2 = I18n.t('invoice.tax_name_only.' + applied_tax.tax_code) + td.body-2 + - else + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.taxable_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + == SlimHelper.render('templates/invoices/v4/_prepaid_credits', self) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total') + td.body-1 + = MoneyHelper.format(total_amount) + - else + - if progressive_billing_credit_amount_cents.positive? + - credits = progressive_billing_credits_for_subscription(invoice_subscription.subscription).all + - if credits.present? + tr + td.body-2 + td.body-2 = I18n.t('invoice.progressive_billing_credit') + td.body-2 = '-' + MoneyHelper.format(credits.sum(&:amount)) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total') + td.body-1 + = MoneyHelper.format(invoice_subscription.total_amount) + + / Recurring fees breakdown + - if grouped_fees.any? { |fg| fg.charge_fees.any? } + .invoice-resume.mb-24.overflow-auto + - recurring_fees(subscription.id).group_by(&:charge_id).each do |_charge_id, fees| + - next unless fees.sum(&:units) > 0 + + h2.invoice-details-title.title-2.mb-24 = I18n.t('invoice.details', resource: subscription.invoice_name) + + - number_of_days_in_period = 30 + + / Fees for filters + - if fees.all? { |f| f.charge_filter_id? } + - fees.select { |f| f.units.positive? }.each do |fee| + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown_of', fee_filter_display_name: fee.filter_display_name(separator: ' • ')) + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - recurring_breakdown(fee).each do |breakdown| + - number_of_days_in_period = breakdown.total_duration + tr + td width="20%" + .body-1 = I18n.l(breakdown.date, format: :default) + .body-3 = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + td.body-1 width="80%" + - if breakdown.action.to_sym == :add + | +#{breakdown.amount} #{fee.item_name} + - elsif breakdown.action.to_sym == :remove + | #{breakdown.amount.negative? ? '' : '-'}#{breakdown.amount} #{fee.item_name} + - else + | +/-#{breakdown.amount} #{fee.item_name} + + / Fees without group + - else + .body-3 = fees.first.invoice_name + .body-1.mb-24 = I18n.t('invoice.breakdown') + .breakdown-details.mb-24 + table.breakdown-details-table width="100%" + - fees.each do |fee| + - recurring_breakdown(fee).each do |breakdown| + - number_of_days_in_period = breakdown.total_duration + tr + td width="20%" + .body-1 = breakdown.date.strftime('%b %d, %Y') + .body-3 = I18n.t('invoice.breakdown_for_days', breakdown_duration: breakdown.duration, breakdown_total_duration: breakdown.total_duration) + td.body-1 width="80%" + - if breakdown.action.to_sym == :add + | +#{breakdown.amount} #{fee.item_name} + - elsif breakdown.action.to_sym == :remove + | #{breakdown.amount.negative? ? '' : '-'}#{breakdown.amount} #{fee.item_name} + - else + | +/-#{breakdown.amount} #{fee.item_name} + + - if fees.first.charge.prorated? + .alert.body-3 = I18n.t('invoice.notice_prorated', days_in_month: number_of_days_in_period) + - else + .alert.body-3 = I18n.t('invoice.notice_full') diff --git a/app/views/templates/invoices/v4/_subscription_name.slim b/app/views/templates/invoices/v4/_subscription_name.slim new file mode 100644 index 0000000..c787a14 --- /dev/null +++ b/app/views/templates/invoices/v4/_subscription_name.slim @@ -0,0 +1 @@ +h2.title-2.mb-24 class="#{'invoice-details-title' if page_break}" = I18n.t('invoice.details', resource: subscription.invoice_name) diff --git a/app/views/templates/invoices/v4/_subscriptions_summary.slim b/app/views/templates/invoices/v4/_subscriptions_summary.slim new file mode 100644 index 0000000..5da49bf --- /dev/null +++ b/app/views/templates/invoices/v4/_subscriptions_summary.slim @@ -0,0 +1,62 @@ +table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.item') + td.body-2 = I18n.t('invoice.amount') + - sorted_subscriptions.each do |subscription| + tr + td.body-1 = subscription.invoice_name + td.body-2 = MoneyHelper.format(invoice_subscription(subscription.id).total_amount) + - commitment_fee = invoice_subscription(subscription.id).commitment_fee + - if commitment_fee + tr + td.body-1 = commitment_fee.invoice_name + td.body-2 = MoneyHelper.format(commitment_fee.amount) + +table.total-table width="100%" + - unless credit? + - if progressive_billing_credit_amount_cents.positive? + tr + td.body-2 + td.body-2 = I18n.t('invoice.progressive_billing_credit') + td.body-2 = '-' + MoneyHelper.format(progressive_billing_credit_amount) + + - if coupons_amount_cents.positive? + - credits.coupon_kind.order(created_at: :asc).each do |credit| + tr + td.body-2 + td.body-2 #{credit.invoice_coupon_display_name} + td.body-2 = '-' + MoneyHelper.format(credit.amount) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_without_tax') + td.body-2 = MoneyHelper.format(sub_total_excluding_taxes_amount) + - if applied_taxes.present? + - applied_taxes.order(tax_rate: :desc).each do |applied_tax| + tr + - if applied_tax.applied_on_whole_invoice? + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_only.' + applied_tax.tax_code) + td.body-2 + - else + td.body-2 + td.body-2 = I18n.t('invoice.tax_name', name: applied_tax.tax_name, rate: applied_tax.tax_rate, amount: MoneyHelper.format(applied_tax.taxable_amount)) + td.body-2 = MoneyHelper.format(applied_tax.amount) + - else + tr + td.body-2 + td.body-2 = I18n.t('invoice.tax_name_with_details', name: 'Tax', rate: 0) + td.body-2 = MoneyHelper.format(0.to_money(currency)) + tr + td.body-2 + td.body-2 = I18n.t('invoice.sub_total_with_tax') + td.body-2 = MoneyHelper.format(sub_total_including_taxes_amount) + - if credits.credit_note_kind.any? + tr + td.body-2 + td.body-2 = I18n.t('invoice.credit_notes') + td.body-2 = '-' + MoneyHelper.format(credit_notes_amount) + == SlimHelper.render('templates/invoices/v4/_prepaid_credits', self) + tr + td.body-2 + td.body-1 = I18n.t('invoice.total') + td.body-1 = MoneyHelper.format(total_amount) diff --git a/app/views/templates/invoices/v4/_true_up_fee.slim b/app/views/templates/invoices/v4/_true_up_fee.slim new file mode 100644 index 0000000..48c4a0a --- /dev/null +++ b/app/views/templates/invoices/v4/_true_up_fee.slim @@ -0,0 +1,10 @@ +tr.fee + td + .body-1 = I18n.t('invoice.true_up_metric', metric: true_up_fee.true_up_parent_fee.invoice_name) + .body-3 = I18n.t('invoice.true_up_details', min_amount: ChargeDisplayHelper.format_min_amount(charge)) + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_amount(true_up_fee) + td.body-2 == TaxHelper.applied_taxes(true_up_fee) + td.body-2 = FeeDisplayHelper.format_amount(true_up_fee) + +== SlimHelper.render('templates/invoices/v4/_conversion_row', true_up_fee) diff --git a/app/views/templates/invoices/v4/_volume.slim b/app/views/templates/invoices/v4/_volume.slim new file mode 100644 index 0000000..e0ea690 --- /dev/null +++ b/app/views/templates/invoices/v4/_volume.slim @@ -0,0 +1,22 @@ +/ Fee per unit +tr.details + td.body-2 = I18n.t('invoice.volume.fee_per_unit') + td.body-2 = RoundingHelper.round_decimal_part(units) + td.body-2 = FeeDisplayHelper.format_with_precision(self, amount_details['per_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['per_unit_total_amount']) +- if amount_details['flat_unit_amount'].to_f.positive? + / Flat fee for all units + tr.details + td.body-2 = I18n.t('invoice.volume.flat_fee_for_all_units') + td.body-2 = 1 + td.body-2 = FeeDisplayHelper.format_as_currency(self, amount_details['flat_unit_amount']) + td.body-2 == TaxHelper.applied_taxes(self) + td.body-2 = MoneyHelper.format(amount_details['flat_unit_amount'].to_money(amount_currency)) +/ Sub total +tr.details.subtotal + td.body-2 = I18n.t('invoice.sub_total') + td.body-2 + td.body-2 + td.body-2 + td.body-2 = FeeDisplayHelper.format_amount(self) diff --git a/app/views/templates/invoices/v4/charge.slim b/app/views/templates/invoices/v4/charge.slim new file mode 100644 index 0000000..9cbd414 --- /dev/null +++ b/app/views/templates/invoices/v4/charge.slim @@ -0,0 +1,470 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td { + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr:first-child td { + color: #66758F; + } + + .invoice-resume-table tr td { + padding-top: 4px; + padding-bottom: 4px; + text-align: right; + word-wrap: break-word; + } + .invoice-resume-table td:first-child { + width: 50%; + text-align: left; + } + .invoice-resume-table td:nth-child(2) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(3) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(4) { + width: 12.5%; + } + .invoice-resume-table td:nth-child(5) { + width: 12.5%; + max-width: 10vw; + } + + .invoice-resume-table tr.first_child td { + color: #66758F; + } + + .invoice-resume-table tr.charge-name td { + padding-bottom: 0; + color: #19212e; + } + + .invoice-resume-table tr.details td { + color: #66758F; + padding-top: 4px; + padding-bottom: 4px; + } + + .invoice-resume-table tr.details td:first-child { + padding-left: 8px; + } + + .invoice-resume-table tr.details.subtotal td { + color: #19212e; + } + + .invoice-resume-table tr.fee:first-child { + border-top: none; + } + + /* Each first tr representing fee draws border above it */ + .invoice-resume-table tr.fee { + border-top: 1px solid #D9DEE7; + } + + .invoice-resume-table tr:last-child { + border-bottom: 1px solid #D9DEE7; + } + + .invoice-resume-table tr.fee td { + padding-top: 8px; + } + + /* If tr has next element tr.fee means that current fee info ended and we need bigger padding */ + .invoice-resume-table tr:has(+ tr.fee) td { + padding-bottom: 8px; + } + + .invoice-resume-table tr:last-child td { + padding-bottom: 8px; + } + + .invoice-resume table { + border-collapse: collapse; + } + + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid #D9DEE7; + text-align: left; + width: 35%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid #D9DEE7; + text-align: right; + width: 15%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details-table tr td { + padding-bottom: 12px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + == SlimHelper.render('templates/invoices/v4/_customer_address', self) + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = MoneyHelper.format(total_amount) + .body-1 = advance_charges? ? I18n.t('invoice.already_paid') : I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + - invoice_subscription = invoice_subscriptions.first + == SlimHelper.render('templates/invoices/v4/_subscription_name', self, subscription: invoice_subscription.subscription, page_break: false) + == SlimHelper.render('templates/invoices/v4/_charge', self) + + == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + == SlimHelper.render('templates/invoices/v4/_powered_by_logo', self) diff --git a/app/views/templates/invoices/v4/fixed_charge.slim b/app/views/templates/invoices/v4/fixed_charge.slim new file mode 100644 index 0000000..abed5d8 --- /dev/null +++ b/app/views/templates/invoices/v4/fixed_charge.slim @@ -0,0 +1,468 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td { + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr:first-child td { + color: #66758F; + } + + .invoice-resume-table tr td { + padding-top: 4px; + padding-bottom: 4px; + text-align: right; + word-wrap: break-word; + } + .invoice-resume-table td:first-child { + width: 50%; + text-align: left; + } + .invoice-resume-table td:nth-child(2) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(3) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(4) { + width: 12.5%; + } + .invoice-resume-table td:nth-child(5) { + width: 12.5%; + max-width: 10vw; + } + + .invoice-resume-table tr.first_child td { + color: #66758F; + } + + .invoice-resume-table tr.charge-name td { + padding-bottom: 0; + color: #19212e; + } + + .invoice-resume-table tr.details td { + color: #66758F; + padding-top: 4px; + padding-bottom: 4px; + } + + .invoice-resume-table tr.details td:first-child { + padding-left: 8px; + } + + .invoice-resume-table tr.details.subtotal td { + color: #19212e; + } + + .invoice-resume-table tr.fee:first-child { + border-top: none; + } + + /* Each first tr representing fee draws border above it */ + .invoice-resume-table tr.fee { + border-top: 1px solid #D9DEE7; + } + + .invoice-resume-table tr:last-child { + border-bottom: 1px solid #D9DEE7; + } + + .invoice-resume-table tr.fee td { + padding-top: 8px; + } + + /* If tr has next element tr.fee means that current fee info ended and we need bigger padding */ + .invoice-resume-table tr:has(+ tr.fee) td { + padding-bottom: 8px; + } + + .invoice-resume-table tr:last-child td { + padding-bottom: 8px; + } + + .invoice-resume table { + border-collapse: collapse; + } + + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid #D9DEE7; + text-align: left; + width: 35%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid #D9DEE7; + text-align: right; + width: 15%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details-table tr td { + padding-bottom: 12px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + == SlimHelper.render('templates/invoices/v4/_customer_address', self) + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = MoneyHelper.format(total_amount) + .body-1 = advance_charges? ? I18n.t('invoice.already_paid') : I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + == SlimHelper.render('templates/invoices/v4/_fixed_charge', self) + + == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + == SlimHelper.render('templates/invoices/v4/_powered_by_logo', self) diff --git a/app/views/templates/invoices/v4/one_off.slim b/app/views/templates/invoices/v4/one_off.slim new file mode 100644 index 0000000..35aade2 --- /dev/null +++ b/app/views/templates/invoices/v4/one_off.slim @@ -0,0 +1,416 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr:first-child td { + color: #66758F; + } + .invoice-resume-table td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume-table td:first-child { + width: 40%; + text-align: left; + } + .invoice-resume-table td:nth-child(2) { + width: 10%; + } + .invoice-resume-table td:nth-child(3) { + width: 18%; + } + .invoice-resume-table td:nth-child(4) { + width: 14%; + } + .invoice-resume-table tr td:last-child { + width: 18%; + text-align: right; + } + .invoice-resume-table tr { + border-bottom: 1px solid #D9DEE7; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid #D9DEE7; + text-align: left; + width: 35%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid #D9DEE7; + text-align: right; + width: 15%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details-table tr td { + padding-bottom: 12px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr:last-child td { + border-bottom: 1px solid #d9dee7; + padding-bottom: 24px; + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + - if billing_entity.logo.present? + img.header-logo src="data:#{billing_entity.logo.content_type};base64,#{billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + == SlimHelper.render('templates/invoices/v4/_customer_address', self) + .body-2 = customer.email + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = total_amount.format(format: I18n.t('money.format'), decimal_mark: I18n.t('money.decimal_mark'), thousands_separator: I18n.t('money.thousands_separator')) + .body-1 = I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + == SlimHelper.render('templates/invoices/v4/_one_off', self) + + == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + + p.body-3.mb-24 = LineBreakHelper.break_lines(billing_entity.invoice_footer) + + == SlimHelper.render('templates/invoices/v4/_powered_by_logo', self) diff --git a/app/views/templates/invoices/v4/self_billed.slim b/app/views/templates/invoices/v4/self_billed.slim new file mode 100644 index 0000000..c81a3d1 --- /dev/null +++ b/app/views/templates/invoices/v4/self_billed.slim @@ -0,0 +1,469 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title Self billing invoice + body + css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + :root { + --border-color: #D9DEE7; + } + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + .mt-24 { + margin-top: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr.first_child td { + color: #66758F; + } + .invoice-resume-table tr { + border-bottom: 1px solid var(--border-color); + } + .invoice-resume-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + word-wrap: break-word; + } + .invoice-resume-table td:first-child { + width: 50%; + text-align: left; + } + .invoice-resume-table td:nth-child(2) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(3) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(4) { + width: 12.5%; + } + .invoice-resume-table td:nth-child(5) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid var(--border-color); + text-align: left; + width: 35%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid var(--border-color); + text-align: right; + width: 15%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details { + margin-top: -15px; + } + .breakdown-details-table tr td { + padding-bottom: 8px; + padding-top: 8px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr td { + border-bottom: 1px solid var(--border-color); + } + .breakdown-details-table tr:first-child td { + border-top: 1px solid var(--border-color); + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + .invoice-resume-table tr.details, .invoice-resume-table tr.charge-name { + border: none; + } + .invoice-resume-table tr.charge-name td { + padding-bottom: 0; + color: #19212e; + } + .invoice-resume-table tr.details td { + color: #66758F; + padding-top: 4px; + padding-bottom: 4px; + } + .invoice-resume-table tr.details td:first-child { + padding-left: 8px; + } + .invoice-resume-table tr.details.subtotal td { + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); + color: #19212e; + } + + .wrapper + .mb-24 + h1.invoice-title = document_invoice_name + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 = number + tr + td.body-1 = I18n.t('invoice.issue_date') + td.body-2 = I18n.l(issuing_date, format: :default) + tr + td.body-1 = I18n.t('invoice.payment_term') + td.body-2 = I18n.t('invoice.payment_term_days', net_payment_term:) + .invoice-information-column + table.invoice-information-table + - if customer.metadata.displayable.any? + - customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + // Issuer (partner) information + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 = customer.display_name + - if customer.legal_number.present? + .body-2 #{customer.legal_number} + == SlimHelper.render('templates/invoices/v4/_customer_address', self) + .body-2 = customer.email&.gsub(/,\s*/, ', ') + - if customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: customer.tax_identification_number) + + // Recipient (billing_entity) information + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 + - if billing_entity.legal_name.present? + | #{billing_entity.legal_name} + - else + | #{billing_entity.name} + - if billing_entity.legal_number.present? + .body-2 #{billing_entity.legal_number} + .body-2 = billing_entity.address_line1 + .body-2 = billing_entity.address_line2 + .body-2 + span + = billing_entity.zipcode + - if billing_entity.zipcode.present? && billing_entity.city.present? + span + | ,   + span + = billing_entity.city + - if billing_entity.state.present? + .body-2 = billing_entity.state + .body-2 = ISO3166::Country.new(billing_entity.country)&.common_name + .body-2 = billing_entity.email + - if billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: billing_entity.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = MoneyHelper.format(total_amount) + .body-1 = I18n.t('invoice.due_date', date: I18n.l(payment_due_date, format: :default)) + + .invoice-resume.mb-24.overflow-auto + - if one_off? + == SlimHelper.render('templates/invoices/v4/_one_off', self) + - elsif credit? + == SlimHelper.render('templates/invoices/v4/_credit', self) + - elsif progressive_billing? + == SlimHelper.render('templates/invoices/v4/_progressive_billing_details', self) + - elsif subscriptions.count == 1 + == SlimHelper.render('templates/invoices/v4/_subscription_details', self) + - else + == SlimHelper.render('templates/invoices/v4/_subscriptions_summary', self) + + == SlimHelper.render('templates/invoices/v4/_eu_tax_management', self) + + - if progressive_billing? + p.body-3.mb-24 + - applied_usage_threshold = applied_usage_thresholds.order(created_at: :asc).last + = I18n.t('invoice.reached_usage_threshold', usage_amount: MoneyHelper.format(applied_usage_threshold.lifetime_usage_amount), threshold_amount: MoneyHelper.format(applied_usage_threshold.passed_threshold_amount)) + + - if applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', self) + + p.body-3.mb-24 = LineBreakHelper.break_lines(I18n.t("invoice.self_billed.footer")) + + == SlimHelper.render('templates/invoices/v4/_powered_by_logo', self) + + - if subscriptions.count > 1 + == SlimHelper.render('templates/invoices/v4/_subscription_details', self) diff --git a/app/views/templates/payment_receipts/v1.slim b/app/views/templates/payment_receipts/v1.slim new file mode 100644 index 0000000..852c98c --- /dev/null +++ b/app/views/templates/payment_receipts/v1.slim @@ -0,0 +1,140 @@ +doctype html +html + head + meta charset='UTF-8' + meta http-equiv='X-UA-Compatible' content='IE=edge' + meta name='viewport' content='width=device-width, initial-scale=1.0' + title =I18n.t('payment_receipt.document_name') + body + == SlimHelper.render('templates/payment_receipts/v1/_styles', self) + + - @billing_entity = payment.payable.billing_entity + - @customer = payment.payable.customer + + .wrapper + .mb-24 + h1.invoice-title = I18n.t('payment_receipt.document_name') + - if @billing_entity.logo.present? + img.header-logo src="data:#{@billing_entity.logo.content_type};base64,#{@billing_entity.base64_logo}" + + .mb-24.overflow-auto + .invoice-information-column + table.invoice-information-table + tr + td.body-1 = I18n.t('payment_receipt.number') + td.body-2 = number + - if payment.payable.is_a?(Invoice) + tr + td.body-1 = I18n.t('invoice.invoice_number') + td.body-2 + a style="text-decoration: none" target="_blank" href=payment.payable.file_url + = payment.payable.number + -if payment.method_display_name.present? + tr + td.body-1 = I18n.t('payment_receipt.payment_method') + td.body-2 = payment.method_display_name + tr + td.body-1 = I18n.t('payment_receipt.payment_date') + td.body-2 = I18n.l(payment.created_at.to_date, format: :default) + .invoice-information-column + table.invoice-information-table + - if @customer.metadata.displayable.any? + - @customer.metadata.displayable.order(created_at: :asc).each do |metadata| + tr + td.body-1 = metadata.key + td.body-2 = metadata.value + + .mb-24.overflow-auto + .billing-information-column + .body-1 = I18n.t('invoice.bill_from') + .body-2 + - if @billing_entity.legal_name.present? + | #{@billing_entity.legal_name} + - else + | #{@billing_entity.name} + - if @billing_entity.legal_number.present? + .body-2 #{@billing_entity.legal_number} + .body-2 = @billing_entity.address_line1 + .body-2 = @billing_entity.address_line2 + .body-2 + span + = @billing_entity.zipcode + - if @billing_entity.zipcode.present? && @billing_entity.city.present? + span + | ,   + span + = @billing_entity.city + - if @billing_entity.state.present? + .body-2 = @billing_entity.state + .body-2 = ISO3166::Country.new(@billing_entity.country)&.common_name + .body-2 = @billing_entity.email + - if @billing_entity.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: @billing_entity.tax_identification_number) + .billing-information-column + .body-1 = I18n.t('invoice.bill_to') + .body-2 = @customer.display_name + - if @customer.legal_number.present? + .body-2 #{@customer.legal_number} + .body-2 = @customer.address_line1 + .body-2 = @customer.address_line2 + .body-2 + span + = @customer.zipcode + - if @customer.zipcode.present? && @customer.city.present? + span + | ,   + span + = @customer.city + .body-2 = @customer.state + .body-2 = ISO3166::Country.new(@customer.country)&.common_name + .body-2 = @customer.email&.gsub(/,\s*/, ', ') + - if @customer.tax_identification_number.present? + .body-2 = I18n.t('invoice.tax_identification_number', tax_identification_number: @customer.tax_identification_number) + + .mb-24 + h2.title-2.mb-4 = MoneyHelper.format(payment.amount) + + - @total_due_amount = payment.payable.is_a?(Invoice) ? payment.payable.total_due_amount : payment.payable.amount - payment.amount + + .body-1 = I18n.t('payment_receipt.paid_on', + date: I18n.l(payment.created_at.to_date, format: :default), + total_due_amount: MoneyHelper.format(@total_due_amount)) + + - if payment.payable.is_a?(Invoice) + .invoice-resume.mb-24.overflow-auto + - if payment.payable.credit? + == SlimHelper.render('templates/invoices/v4/_credit', payment.payable) + - elsif payment.payable.one_off? + == SlimHelper.render('templates/invoices/v4/_one_off', payment.payable) + - elsif payment.payable.advance_charges? + == SlimHelper.render('templates/invoices/v4/_charge', payment.payable) + - elsif payment.payable.progressive_billing? + == SlimHelper.render('templates/invoices/v4/_progressive_billing_details', payment.payable) + - elsif payment.payable.subscriptions.count == 1 + == SlimHelper.render('templates/invoices/v4/_subscription_details', payment.payable) + - else + == SlimHelper.render('templates/invoices/v4/_subscriptions_summary', payment.payable) + + == SlimHelper.render('templates/invoices/v4/_eu_tax_management', payment.payable) + + - if payment.payable.progressive_billing? + p.body-3.mb-24 + - applied_usage_threshold = payment.payable.applied_usage_thresholds.order(created_at: :asc).last + = I18n.t('invoice.reached_usage_threshold', usage_amount: MoneyHelper.format(applied_usage_threshold.lifetime_usage_amount), threshold_amount: MoneyHelper.format(applied_usage_threshold.passed_threshold_amount)) + + - if payment.payable.applied_invoice_custom_sections.present? + == SlimHelper.render('templates/invoices/v4/_custom_sections', payment.payable) + + p.body-3.mb-24 = LineBreakHelper.break_lines(@billing_entity.invoice_footer) + + == SlimHelper.render('templates/invoices/v4/_powered_by_logo', payment.payable) + + - if payment.payable.subscriptions.count > 1 + == SlimHelper.render('templates/invoices/v4/_subscription_details', payment.payable) + + - else + == SlimHelper.render('templates/payment_receipts/v1/_payment_request', self) + + p.body-3.mb-24 = LineBreakHelper.break_lines(@billing_entity.invoice_footer) + + == SlimHelper.render('templates/payment_receipts/v1/_powered_by_logo', self) diff --git a/app/views/templates/payment_receipts/v1/_payment_request.slim b/app/views/templates/payment_receipts/v1/_payment_request.slim new file mode 100644 index 0000000..fe17ecc --- /dev/null +++ b/app/views/templates/payment_receipts/v1/_payment_request.slim @@ -0,0 +1,11 @@ +.invoice-resume.mb-24.overflow-auto + table.invoice-resume-table width="100%" + tr + td.body-2 = I18n.t('invoice.invoice_number') + td.body-2 = I18n.t('invoice.amount') + - payment.payable.invoices.each do |invoice| + tr + td.body-1 + a style="text-decoration: none" target="_blank" href=invoice.file_url + = invoice.number + td.body-2 = MoneyHelper.format(invoice.total_amount) diff --git a/app/views/templates/payment_receipts/v1/_powered_by_logo.slim b/app/views/templates/payment_receipts/v1/_powered_by_logo.slim new file mode 100644 index 0000000..9fb2291 --- /dev/null +++ b/app/views/templates/payment_receipts/v1/_powered_by_logo.slim @@ -0,0 +1,5 @@ +- unless organization.remove_branding_watermark_enabled? + .powered-by + span.body-2 + | #{I18n.t('invoice.powered_by')}   + img src="#{::SlimHelper::PDF_LOGO_FILENAME}" alt="Lago Logo" diff --git a/app/views/templates/payment_receipts/v1/_styles.slim b/app/views/templates/payment_receipts/v1/_styles.slim new file mode 100644 index 0000000..534e468 --- /dev/null +++ b/app/views/templates/payment_receipts/v1/_styles.slim @@ -0,0 +1,365 @@ +css: + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 100; + font-display: swap; + src: local("Inter-Thin"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 100; + font-display: swap; + src: local("Inter-ThinItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLight"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 200; + font-display: swap; + src: local("Inter-ExtraLightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: local("Inter-Light"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 300; + font-display: swap; + src: local("Inter-LightItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: local("Inter-Regular"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 400; + font-display: swap; + src: local("Inter-Italic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 500; + font-display: swap; + src: local("Inter-Medium"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 500; + font-display: swap; + src: local("Inter-MediumItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 600; + font-display: swap; + src: local("Inter-SemiBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 700; + font-display: swap; + src: local("Inter-Bold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 700; + font-display: swap; + src: local("Inter-BoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBold"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 800; + font-display: swap; + src: local("Inter-ExtraBoldItalic"); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 900; + font-display: swap; + src: local("Inter-Black"); + } + @font-face { + font-family: 'Inter'; + font-style: italic; + font-weight: 900; + font-display: swap; + src: local("Inter-BlackItalic"); + } + + + /* ----------------------- variable ----------------------- */ + :root { + --border-color: #D9DEE7; + } + + @font-face { + font-family: 'Inter var'; + font-style: normal; + font-weight: 100 900; + font-display: swap; + src: local('Inter-roman') format('woff2'); + font-named-instance: 'Regular'; + } + + @font-face { + font-family: 'Inter var'; + font-style: italic; + font-weight: 100 900; + font-display: swap; + src: local('Inter-italic') format('woff2'); + font-named-instance: 'Italic'; + } + h1, h2, p { margin: 0; padding: 0; } + html { font-family: Inter, sans-serif; } + h1 { color: #19212e; font-weight: 700; font-size: 24px; line-height: 32px; } + h2 { + color: #19212e; + font-weight: 700; + font-size: 18px; + line-height: 24px; + } + .body-1 { + color: #19212e; + font-weight: 600; + font-size: 10px; + line-height: 16px; + } + .body-2 { + color: #19212e; + font-weight: 400; + font-size: 10px; + line-height: 16px; + } + .body-3 { + color: #66758f; + font-weight: 400; + font-size: 9px; + line-height: 16px; + } + + .mb-4 { + margin-bottom: 4px; + } + .mb-24 { + margin-bottom: 24px; + } + .mt-24 { + margin-top: 24px; + } + + .overflow-auto { + overflow: auto; + } + tr { + break-inside: avoid; + } + + .invoice-title { + display: inline; + } + .header-logo { + float: right; + max-height: 32px; + } + + .invoice-information-column { + float: left; + width: 50%; + } + .invoice-information-table tr td:first-child { + padding: 0 16px 0 0; + } + .invoice-information-table tr td:last-child { + width: 55%; + } + .invoice-information-table, tr td{ + text-wrap: normal; + word-wrap: break-word; + vertical-align: top; + } + .invoice-information-table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + } + + .billing-information-column { + float: left; + width: 50%; + } + + .invoice-resume-table tr.first_child td { + color: #66758F; + } + .invoice-resume-table tr { + border-bottom: 1px solid var(--border-color); + } + .invoice-resume-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + word-wrap: break-word; + } + .invoice-resume-table td:first-child { + width: 50%; + text-align: left; + } + .invoice-resume-table td:nth-child(2) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(3) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume-table td:nth-child(4) { + width: 12.5%; + } + .invoice-resume-table td:nth-child(5) { + width: 12.5%; + max-width: 10vw; + } + .invoice-resume table { + border-collapse: collapse; + } + .invoice-resume .total-table tr td { + padding-top: 8px; + padding-bottom: 8px; + text-align: right; + } + .invoice-resume .total-table td:first-child { + width: 50%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(2) { + border-bottom: 1px solid var(--border-color); + text-align: left; + width: 35%; + } + .invoice-resume .total-table tr:not(:last-child) td:nth-child(3) { + border-bottom: 1px solid var(--border-color); + text-align: right; + width: 15%; + } + .invoice-resume .total-table tr:last-child td:nth-child(2) { + text-align: left; + width: 25%; + } + .invoice-resume .total-table tr:last-child td:nth-child(3) { + text-align: right; + width: 25%; + } + + .invoice-details-title { + page-break-before: always; + } + + .breakdown-details table { + border-collapse: collapse; + } + .breakdown-details { + margin-top: -15px; + } + .breakdown-details-table tr td { + padding-bottom: 8px; + padding-top: 8px; + } + .breakdown-details-table tr td:last-child { + text-align: right; + } + .breakdown-details-table tr td { + border-bottom: 1px solid var(--border-color); + } + .breakdown-details-table tr:first-child td { + border-top: 1px solid var(--border-color); + } + + .powered-by { + width: 100%; + text-align: right; + } + .powered-by span { + color: #8c95a6; + } + .powered-by img { + width: 37px; + height: 11px; + vertical-align: middle; + margin-top: 2px; + } + .alert { + display: flex; + flex-direction: row; + align-items: center; + padding: 16px; + gap: 16px; + background: #F3F4F6; + border-radius: 12px; + } + .invoice-resume-table tr.details, .invoice-resume-table tr.charge-name { + border: none; + } + .invoice-resume-table tr.charge-name td { + padding-bottom: 0; + color: #19212e; + } + .invoice-resume-table tr.details td { + color: #66758F; + padding-top: 4px; + padding-bottom: 4px; + } + .invoice-resume-table tr.details td:first-child { + padding-left: 8px; + } + .invoice-resume-table tr.details.subtotal td { + padding-bottom: 8px; + border-bottom: 1px solid var(--border-color); + color: #19212e; + } diff --git a/benchmarks/device_info_benchmark.rb b/benchmarks/device_info_benchmark.rb new file mode 100644 index 0000000..9f6066e --- /dev/null +++ b/benchmarks/device_info_benchmark.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "benchmark" +require "ostruct" + +request = OpenStruct.new( + user_agent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1", + remote_ip: "192.168.1.1" +) + +n = 10_000 + +Benchmark.bm(20) do |x| + x.report("DeviceInfo.parse (#{n} times)") { n.times { Utils::DeviceInfo.parse(request) } } +end diff --git a/bin/bundle b/bin/bundle new file mode 100755 index 0000000..a71368e --- /dev/null +++ b/bin/bundle @@ -0,0 +1,114 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# +# This file was generated by Bundler. +# +# The application 'bundle' is installed as part of a gem, and +# this file is here to facilitate running it. +# + +require "rubygems" + +m = Module.new do + module_function + + def invoked_as_script? + File.expand_path($0) == File.expand_path(__FILE__) + end + + def env_var_version + ENV["BUNDLER_VERSION"] + end + + def cli_arg_version + return unless invoked_as_script? # don't want to hijack other binstubs + return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` + bundler_version = nil + update_index = nil + ARGV.each_with_index do |a, i| + if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN + bundler_version = a + end + next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ + bundler_version = $1 + update_index = i + end + bundler_version + end + + def gemfile + gemfile = ENV["BUNDLE_GEMFILE"] + return gemfile if gemfile && !gemfile.empty? + + File.expand_path("../../Gemfile", __FILE__) + end + + def lockfile + lockfile = + case File.basename(gemfile) + when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) + else "#{gemfile}.lock" + end + File.expand_path(lockfile) + end + + def lockfile_version + return unless File.file?(lockfile) + lockfile_contents = File.read(lockfile) + return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ + Regexp.last_match(1) + end + + def bundler_version + @bundler_version ||= + env_var_version || cli_arg_version || + lockfile_version + end + + def bundler_requirement + return "#{Gem::Requirement.default}.a" unless bundler_version + + bundler_gem_version = Gem::Version.new(bundler_version) + + requirement = bundler_gem_version.approximate_recommendation + + return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new("2.7.0") + + requirement += ".a" if bundler_gem_version.prerelease? + + requirement + end + + def load_bundler! + ENV["BUNDLE_GEMFILE"] ||= gemfile + + activate_bundler + end + + def activate_bundler + gem_error = activation_error_handling do + gem "bundler", bundler_requirement + end + return if gem_error.nil? + require_error = activation_error_handling do + require "bundler/version" + end + return if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION)) + warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`" + exit 42 + end + + def activation_error_handling + yield + nil + rescue StandardError, LoadError => e + e + end +end + +m.load_bundler! + +if m.invoked_as_script? + load Gem.bin_path("bundler", "bundle") +end diff --git a/bin/rails b/bin/rails new file mode 100755 index 0000000..efc0377 --- /dev/null +++ b/bin/rails @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +APP_PATH = File.expand_path("../config/application", __dir__) +require_relative "../config/boot" +require "rails/commands" diff --git a/bin/rake b/bin/rake new file mode 100755 index 0000000..4fbf10b --- /dev/null +++ b/bin/rake @@ -0,0 +1,4 @@ +#!/usr/bin/env ruby +require_relative "../config/boot" +require "rake" +Rake.application.run diff --git a/bin/setup b/bin/setup new file mode 100755 index 0000000..7b64c29 --- /dev/null +++ b/bin/setup @@ -0,0 +1,33 @@ +#!/usr/bin/env ruby +require "fileutils" + +APP_ROOT = File.expand_path("..", __dir__) +APP_NAME = "lago-api" + +def system!(*args) + system(*args, exception: true) +end + +FileUtils.chdir APP_ROOT do + # This script is a way to set up or update your development environment automatically. + # This script is idempotent, so that you can run it at any time and get an expectable outcome. + # Add necessary setup steps to this file. + + puts "== Installing dependencies ==" + system! "gem install bundler --conservative" + system("bundle check") || system!("bundle install") + + # puts "\n== Copying sample files ==" + # unless File.exist?("config/database.yml") + # FileUtils.cp "config/database.yml.sample", "config/database.yml" + # end + + puts "\n== Preparing database ==" + system! "bin/rails db:prepare" + + puts "\n== Removing old logs and tempfiles ==" + system! "bin/rails log:clear tmp:clear" + + puts "\n== Restarting application server ==" + system! "bin/rails restart" +end diff --git a/ci/clickhouse/config.xml b/ci/clickhouse/config.xml new file mode 100644 index 0000000..93da6e3 --- /dev/null +++ b/ci/clickhouse/config.xml @@ -0,0 +1,22 @@ + + + debug + /var/log/clickhouse-server/clickhouse-server.log + /var/log/clickhouse-server/clickhouse-server.err.log + 1000M + 3 + 1 + + clickhouse_dev + 0.0.0.0 + 8123 + 9000 + + + + /var/lib/clickhouse/access/ + + + diff --git a/clock.rb b/clock.rb new file mode 100644 index 0000000..c4ae17a --- /dev/null +++ b/clock.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require "clockwork" +require "./config/boot" +require "./config/environment" + +module Clockwork + handler do |job, time| + puts "Running #{job} at #{time}" # rubocop:disable Rails/Output + end + + error_handler do |error| + Rails.logger.error(error.message) + Rails.logger.error(error.backtrace.join("\n")) + + Sentry.capture_exception(error) + end + + # NOTE: All clocks run every hour to take customer timezones into account + + every(5.minutes, "schedule:activate_subscriptions") do + Clock::ActivateSubscriptionsJob + .set(sentry: {"slug" => "lago_activate_subscriptions", "cron" => "*/5 * * * *"}) + .perform_later + end + + every(5.minutes, "schedule:refresh_draft_invoices") do + Clock::RefreshDraftInvoicesJob + .set(sentry: {"slug" => "lago_refresh_draft_invoices", "cron" => "*/5 * * * *"}) + .perform_later + end + + subscription_activity_processing_interval = ENV["LAGO_SUBSCRIPTION_ACTIVITY_PROCESSING_INTERVAL_SECONDS"].presence || 1.minute + every(subscription_activity_processing_interval.to_i.seconds, "schedule:process_subscription_activity") do + Clock::ProcessAllSubscriptionActivitiesJob + .set(sentry: {"slug" => "lago_process_subscription_activity", "cron" => "#{subscription_activity_processing_interval} interval"}) + .perform_later + end + + if Utils::DedicatedWorkerConfig.any? + every(Utils::DedicatedWorkerConfig.refresh_interval, "schedule:process_dedicated_orgs_subscription_activities") do + Clock::ProcessDedicatedOrgsSubscriptionActivitiesJob.perform_later + end + end + + lifetime_usage_refresh_interval = ENV["LAGO_LIFETIME_USAGE_REFRESH_INTERVAL_SECONDS"].presence || 5.minutes + every(lifetime_usage_refresh_interval.to_i.seconds, "schedule:refresh_lifetime_usages") do + unless ENV["LAGO_DISABLE_LIFETIME_USAGE_REFRESH"] == "true" + Clock::RefreshLifetimeUsagesJob + .set(sentry: {"slug" => "lago_refresh_lifetime_usages", "cron" => "#{lifetime_usage_refresh_interval} interval"}) + .perform_later + end + end + + if ENV["LAGO_MEMCACHE_SERVERS"].present? || ENV["LAGO_REDIS_CACHE_URL"].present? + unless ENV["LAGO_DISABLE_WALLET_REFRESH"] == "true" + every(5.minutes, "schedule:refresh_wallets_ongoing_balance") do + Clock::RefreshWalletsOngoingBalanceJob + .set(sentry: {"slug" => "lago_refresh_wallets_ongoing_balance", "cron" => "*/5 * * * *"}) + .perform_later + end + + if Utils::DedicatedWorkerConfig.any? + every(Utils::DedicatedWorkerConfig.refresh_interval, "schedule:refresh_dedicated_org_wallets") do + Clock::RefreshDedicatedOrgWalletsOngoingBalanceJob.perform_later + end + end + end + end + + every(1.hour, "schedule:terminate_ended_subscriptions", at: "*:05") do + Clock::TerminateEndedSubscriptionsJob + .set(sentry: {"slug" => "lago_terminate_ended_subscriptions", "cron" => "5 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:bill_customers", at: "*:10") do + Clock::SubscriptionsBillerJob + .set(sentry: {"slug" => "lago_bill_customers", "cron" => "10 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:api_keys_track_usage", at: "*:15") do + Clock::ApiKeys::TrackUsageJob + .set(sentry: {"slug" => "lago_api_keys_track_usage", "cron" => "15 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:retry_generating_subscription_invoices", at: "*:30") do + Clock::RetryGeneratingSubscriptionInvoicesJob + .set(sentry: {"slug" => "lago_retry_invoices", "cron" => "30 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:finalize_invoices", at: "*:20") do + Clock::FinalizeInvoicesJob + .set(sentry: {"slug" => "lago_finalize_invoices", "cron" => "20 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:mark_invoices_as_payment_overdue", at: "*:25") do + Clock::MarkInvoicesAsPaymentOverdueJob + .set(sentry: {"slug" => "lago_mark_invoices_as_payment_overdue", "cron" => "25 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:terminate_coupons", at: "*:30") do + Clock::TerminateCouponsJob + .set(sentry: {"slug" => "lago_terminate_coupons", "cron" => "30 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:bill_ended_trial_subscriptions", at: "*:35") do + Clock::FreeTrialSubscriptionsBillerJob + .set(sentry: {"slug" => "lago_bill_ended_trial_subscriptions", "cron" => "35 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:terminate_wallets", at: "*:45") do + Clock::TerminateWalletsJob + .set(sentry: {"slug" => "lago_terminate_wallets", "cron" => "45 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:termination_alert", at: "*:50") do + Clock::SubscriptionsToBeTerminatedJob + .set(sentry: {"slug" => "lago_termination_alert", "cron" => "50 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:terminate_expired_wallet_transaction_rules", at: "*:50") do + Clock::TerminateRecurringTransactionRulesJob + .set(sentry: {"slug" => "lago_terminate_expired_wallet_transaction_rules", "cron" => "50 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:top_up_wallet_interval_credits", at: "*:55") do + Clock::CreateIntervalWalletTransactionsJob + .set(sentry: {"slug" => "lago_top_up_wallet_interval_credits", "cron" => "55 */1 * * *"}) + .perform_later + end + + every(1.day, "schedule:clean_webhooks", at: "01:00") do + Clock::WebhooksCleanupJob + .set(sentry: {"slug" => "lago_clean_webhooks", "cron" => "0 1 * * *"}) + .perform_later + end + + every(1.day, "schedule:clean_inbound_webhooks", at: "01:10") do + Clock::InboundWebhooksCleanupJob + .set(sentry: {"slug" => "lago_clean_inbound_webhooks", "cron" => "5 1 * * *"}) + .perform_later + end + + unless ActiveModel::Type::Boolean.new.cast(ENV["LAGO_DISABLE_EVENTS_VALIDATION"]) + every(1.hour, "schedule:post_validate_events", at: "*:05") do + Clock::EventsValidationJob + .set(sentry: {"slug" => "lago_post_validate_events", "cron" => "5 */1 * * *"}) + .perform_later + rescue => e + Sentry.capture_exception(e) + end + end + + every(1.hour, "schedule:compute_daily_usage", at: "*:15") do + Clock::ComputeAllDailyUsagesJob + .set(sentry: {"slug" => "lago_compute_daily_usage", "cron" => "15 */1 * * *"}) + .perform_later + end + + every(1.hour, "schedule:process_dunning_campaigns", at: "*:45") do + Clock::ProcessDunningCampaignsJob + .set(sentry: {"slug" => "lago_process_dunning_campaigns", "cron" => "45 */1 * * *"}) + .perform_later + end + + every(15.minutes, "schedule:retry_failed_invoices") do + Clock::RetryFailedInvoicesJob + .set(sentry: {"slug" => "lago_retry_failed_invoices", "cron" => "*/15 * * * *"}) + .perform_later + end + + every(15.minutes, "schedule:retry_inbound_webhooks") do + Clock::InboundWebhooksRetryJob + .set(sentry: {"slug" => "lago_retry_inbound_webhooks", "cron" => "*/15 * * * *"}) + .perform_later + end + + # NOTE: Enable wallets and lifetime usage refresh from the events-processor + if ENV["LAGO_REDIS_STORE_URL"].present? && ENV["LAGO_CLICKHOUSE_ENABLED"].present? + every(10.seconds, "schedule:refresh_flagged_subscriptions") do + Clock::ConsumeSubscriptionRefreshedQueueJob + .set(sentry: {"slug" => "lago_refresh_flagged_subscriptions"}) + .perform_later + end + end +end diff --git a/config.ru b/config.ru new file mode 100644 index 0000000..2e03084 --- /dev/null +++ b/config.ru @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +# This file is used by Rack-based servers to start the application. + +require_relative "config/environment" + +run Rails.application +Rails.application.load_server diff --git a/config/application.rb b/config/application.rb new file mode 100644 index 0000000..d3f3a07 --- /dev/null +++ b/config/application.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require_relative "boot" + +require "rails/all" +require "ostruct" + +Bundler.require(*Rails.groups) + +module LagoApi + class Application < Rails::Application + config.load_defaults 8.0 + + # Disable YJIT as we are not ready yet + config.yjit = false + + # TODO: Should be turned to false + config.add_autoload_paths_to_load_path = true + # config.autoload_lib(ignore: %w[task]) + config.eager_load_paths += %W[ + #{config.root}/lib + #{config.root}/lib/lago_http_client + #{config.root}/lib/lago_mcp_client + #{config.root}/lib/lago_utils + #{config.root}/lib/lago_eu_vat + #{config.root}/app/views/helpers + #{config.root}/app/support + ] + + config.api_only = true + config.active_job.queue_adapter = :sidekiq + + # Configuration for active record encryption + config.active_record.encryption.hash_digest_class = OpenSSL::Digest::SHA1 + config.active_record.encryption.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"] || ENV["LAGO_ENCRYPTION_PRIMARY_KEY"] + config.active_record.encryption.deterministic_key = ENV["ENCRYPTION_DETERMINISTIC_KEY"] || ENV["LAGO_ENCRYPTION_DETERMINISTIC_KEY"] + config.active_record.encryption.key_derivation_salt = ENV["ENCRYPTION_KEY_DERIVATION_SALT"] || ENV["LAGO_ENCRYPTION_KEY_DERIVATION_SALT"] + config.active_record.schema_format = :sql + + ActiveRecord::Tasks::DatabaseTasks.structure_dump_flags = [ + "--clean", + "--if-exists", + "--no-comments", + "--no-publications", + "--exclude-table=enriched_events_p*" + ] + + config.i18n.load_path += Dir[Rails.root.join("config/locales/**/*.{rb,yml}")] + config.i18n.available_locales = %i[en fr nb de it es sv pt-BR zh-TW] + config.i18n.default_locale = :en + + config.generators do |g| + g.orm(:active_record, primary_key_type: :uuid) + end + + config.active_support.cache_format_version = 7.1 + + config.api_key_cache_ttl = 1.hour + end +end + +require_relative "../lib/active_job/uniqueness/strategies/until_executed_patch" +require_relative "../lib/redlock/client_patch" +require_relative "../lib/active_job/logging" +require_relative "../lib/active_job/json_log_subscriber" diff --git a/config/boot.rb b/config/boot.rb new file mode 100644 index 0000000..aef6d03 --- /dev/null +++ b/config/boot.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) + +require "bundler/setup" # Set up gems listed in the Gemfile. +require "bootsnap/setup" # Speed up boot time by caching expensive operations. diff --git a/config/cable.yml b/config/cable.yml new file mode 100644 index 0000000..202737a --- /dev/null +++ b/config/cable.yml @@ -0,0 +1,17 @@ +development: + adapter: redis + url: <%= ENV.fetch("LAGO_REDIS_CABLE_URL", ENV.fetch("REDIS_URL", "redis://localhost:6379/1")) %> + channel_prefix: lago_development + +test: + adapter: test + +staging: + adapter: redis + url: <%= ENV.fetch("LAGO_REDIS_CABLE_URL", ENV.fetch("REDIS_URL", "redis://localhost:6379/1")) %> + channel_prefix: lago_staging + +production: + adapter: redis + url: <%= ENV.fetch("LAGO_REDIS_CABLE_URL", ENV.fetch("REDIS_URL", "redis://localhost:6379/1")) %> + channel_prefix: lago_production diff --git a/config/database.yml b/config/database.yml new file mode 100644 index 0000000..d060a11 --- /dev/null +++ b/config/database.yml @@ -0,0 +1,101 @@ +default: &default + adapter: postgresql + +development: + primary: + <<: *default + host: db + username: lago + password: changeme + database: lago + pool: <%= ENV.fetch('DATABASE_POOL', 5) %> + port: 5432 + events: + <<: *default + host: db + username: lago + password: changeme + database: lago + pool: <%= ENV.fetch('DATABASE_POOL', 5) %> + port: 5432 + clickhouse: + adapter: clickhouse + database: default + host: clickhouse + pool: <%= ENV.fetch('DATABASE_POOL', 5) %> + port: 8123 + username: default + password: default + migrations_paths: db/clickhouse_migrate + debug: true + database_tasks: <% if ENV['LAGO_CLICKHOUSE_MIGRATIONS_ENABLED'].present? %> true <% else %> false <% end %> + schema_dump: false + +test: + primary: + <<: *default + url: <%= ENV['DATABASE_TEST_URL'].presence || ENV['DATABASE_URL'] %> + schema_dump: <% if ENV['LAGO_DISABLE_SCHEMA_DUMP'].present? %> false <% else %> structure.sql <% end %> + events: + <<: *default + url: <%= ENV['DATABASE_TEST_URL'].presence || ENV['DATABASE_URL'] %> + schema_dump: false + clickhouse: + adapter: clickhouse + database: <%= ENV.fetch('LAGO_CLICKHOUSE_DATABASE', 'default_test') %> + host: <%= ENV.fetch('LAGO_CLICKHOUSE_HOST', 'clickhouse') %> + port: <%= ENV.fetch('LAGO_CLICKHOUSE_PORT', 8123) %> + username: <%= ENV.fetch('LAGO_CLICKHOUSE_USERNAME', 'default') %> + password: <%= ENV.fetch('LAGO_CLICKHOUSE_PASSWORD', 'default') %> + migrations_paths: db/clickhouse_migrate + debug: true + database_tasks: <% if ENV['LAGO_CLICKHOUSE_MIGRATIONS_ENABLED'].present? %> true <% else %> false <% end %> + schema_dump: false + +staging: + primary: + <<: *default + url: <%= ENV['DATABASE_URL'] %> + pool: <%= ENV.fetch('DATABASE_POOL', 10) %> + events: + <<: *default + url: <%= ENV['DATABASE_URL'] %> + pool: <%= ENV.fetch('DATABASE_POOL', 10) %> + database_tasks: false + clickhouse: + adapter: clickhouse + database: <%= ENV['LAGO_CLICKHOUSE_DATABASE'] %> + host: <%= ENV['LAGO_CLICKHOUSE_HOST'] %> + port: <%= ENV.fetch('LAGO_CLICKHOUSE_PORT', 8123) %> + username: <%= ENV['LAGO_CLICKHOUSE_USERNAME'] %> + password: <%= ENV['LAGO_CLICKHOUSE_PASSWORD'] %> + migrations_paths: db/clickhouse_migrate + debug: false + database_tasks: <% if ENV['LAGO_CLICKHOUSE_MIGRATIONS_ENABLED'].present? %> true <% else %> false <% end %> + +production: + primary: + <<: *default + url: <%= ENV['DATABASE_URL'] %> + pool: <%= ENV.fetch('DATABASE_POOL', 10) %> + prepared_statements: <%= ENV.fetch('DATABASE_PREPARED_STATEMENTS', true) %> + schema_search_path: <%= ENV.fetch('POSTGRES_SCHEMA', 'public') %> + events: + <<: *default + url: <%= ENV['DATABASE_URL'] %> + pool: <%= ENV.fetch('DATABASE_POOL', 10) %> + prepared_statements: <%= ENV.fetch('DATABASE_PREPARED_STATEMENTS', true) %> + schema_search_path: <%= ENV.fetch('POSTGRES_SCHEMA', 'public') %> + database_tasks: false + clickhouse: + adapter: clickhouse + database: <%= ENV['LAGO_CLICKHOUSE_DATABASE'] %> + host: <%= ENV['LAGO_CLICKHOUSE_HOST'] %> + port: <%= ENV.fetch('LAGO_CLICKHOUSE_PORT', 8123) %> + pool: <%= ENV.fetch('DATABASE_POOL', 10) %> + username: <%= ENV['LAGO_CLICKHOUSE_USERNAME'] %> + password: <%= ENV['LAGO_CLICKHOUSE_PASSWORD'] %> + ssl: <%= ENV.fetch('LAGO_CLICKHOUSE_SSL', false) %> + migrations_paths: db/clickhouse_migrate + debug: false + database_tasks: <% if ENV['LAGO_CLICKHOUSE_MIGRATIONS_ENABLED'].present? %> true <% else %> false <% end %> diff --git a/config/environment.rb b/config/environment.rb new file mode 100644 index 0000000..7df99e8 --- /dev/null +++ b/config/environment.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +# Load the Rails application. +require_relative "application" + +# Initialize the Rails application. +Rails.application.initialize! diff --git a/config/environments/development.rb b/config/environments/development.rb new file mode 100644 index 0000000..206a09e --- /dev/null +++ b/config/environments/development.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" + +Rails.application.configure do + config.after_initialize do + Bullet.enable = true + Bullet.rails_logger = true + end + + config.autoload_paths += %W[ + #{config.root}/dev + ] + + # Settings specified here will take precedence over those in config/application.rb. + config.middleware.use(ActionDispatch::Cookies) + config.middleware.use(ActionDispatch::Session::CookieStore, key: "_lago_dev") + config.middleware.use(Rack::MethodOverride) + + config.action_cable.disable_request_forgery_protection = true + config.action_cable.allowed_request_origins = [ENV["LAGO_API_URL"]] + + config.enable_reloading = true + config.eager_load = false + config.consider_all_requests_local = true + config.server_timing = true + + cache_store_config = {url: ENV["LAGO_REDIS_CACHE_URL"], db: ENV.fetch("LAGO_REDIS_CACHE_DB", 0)} + if ENV["LAGO_REDIS_CACHE_PASSWORD"].present? + cache_store_config = cache_store_config.merge({password: ENV["LAGO_REDIS_CACHE_PASSWORD"]}) + end + config.cache_store = :redis_cache_store, cache_store_config + + config.action_controller.perform_caching = false + + config.active_storage.service = if ENV["LAGO_USE_AWS_S3"].present? && ENV["LAGO_USE_AWS_S3"] == "true" + if ENV["LAGO_AWS_S3_ENDPOINT"].present? + :amazon_compatible_endpoint + else + :amazon + end + else + :local + end + + config.active_support.deprecation = :log + config.active_support.disallowed_deprecation = :raise + config.active_support.disallowed_deprecation_warnings = [] + config.active_record.migration_error = :page_load + config.active_record.verbose_query_logs = true + config.active_job.verbose_enqueue_logs = true + + config.logger = ActiveSupport::Logger.new($stdout) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + + config.action_view.annotate_rendered_view_with_filenames = true + config.action_controller.raise_on_missing_callback_actions = true + + config.hosts << "api.lago.dev" + config.hosts << "api" + + config.license_url = ENV.fetch("LAGO_LICENSE_URL", "http://license:3000") + config.api_key_cache_ttl = 10.seconds + + config.action_mailer.perform_caching = false + config.action_mailer.perform_deliveries = true + config.action_mailer.raise_delivery_errors = true + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: "mailhog", + port: 1025 + } + config.action_mailer.preview_paths << Rails.root.join("spec/mailers/previews").to_s + + Dotenv.load +end diff --git a/config/environments/production.rb b/config/environments/production.rb new file mode 100644 index 0000000..475188b --- /dev/null +++ b/config/environments/production.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" +require "opentelemetry/sdk" + +Rails.application.configure do + config.middleware.use(ActionDispatch::Cookies) + config.middleware.use(ActionDispatch::Session::CookieStore, key: "_lago_production") + + config.enable_reloading = false + config.eager_load = true + config.consider_all_requests_local = false + config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? + + config.active_storage.service = if ENV["LAGO_USE_AWS_S3"].present? && ENV["LAGO_USE_AWS_S3"] == "true" + if ENV["LAGO_AWS_S3_ENDPOINT"].present? && !ENV["LAGO_AWS_S3_ENDPOINT"].empty? + :amazon_compatible_endpoint + else + :amazon + end + elsif ENV["LAGO_USE_GCS"].present? && ENV["LAGO_USE_GCS"] == "true" + :google + else + :local + end + + config.log_level = if ENV["LAGO_LOG_LEVEL"].present? && ENV["LAGO_LOG_LEVEL"] != "" + ENV["LAGO_LOG_LEVEL"].downcase.to_sym + else + :info + end + + config.assume_ssl = !ActiveModel::Type::Boolean.new.cast(ENV["LAGO_DISABLE_SSL"]) + config.force_ssl = false + + config.action_cable.disable_request_forgery_protection = true + config.action_cable.allowed_request_origins = [ENV["LAGO_API_URL"]] + + config.action_mailer.perform_caching = false + config.i18n.fallbacks = true + config.active_support.report_deprecations = false + + if ENV["RAILS_LOG_TO_STDOUT"].present? && ENV["RAILS_LOG_TO_STDOUT"] == "true" + logger = ActiveSupport::Logger.new($stdout) + config.logger = logger + end + + # Do not dump schema after migrations. + config.active_record.dump_schema_after_migration = false + config.active_record.attributes_for_inspect = [:id] + + if ENV["LAGO_MEMCACHE_SERVERS"].present? + config.cache_store = :mem_cache_store, ENV["LAGO_MEMCACHE_SERVERS"].split(",") + + elsif ENV["LAGO_REDIS_CACHE_URL"].present? + cache_store_config = { + url: ENV["LAGO_REDIS_CACHE_URL"], + ssl_params: { + verify_mode: OpenSSL::SSL::VERIFY_NONE + }, + pool: {size: ENV.fetch("LAGO_REDIS_CACHE_POOL_SIZE", 5)}, + error_handler: lambda { |method:, returning:, exception:| + Rails.logger.warn(exception.message) + + Sentry.capture_exception(exception, level: :warning) + } + } + + if ENV["LAGO_REDIS_CACHE_PASSWORD"].present? && !ENV["LAGO_REDIS_CACHE_PASSWORD"].empty? + cache_store_config = cache_store_config.merge({password: ENV["LAGO_REDIS_CACHE_PASSWORD"]}) + end + + config.cache_store = :redis_cache_store, cache_store_config + end + + config.license_url = if ENV["LAGO_CLOUD"] == "true" && ENV["RAILS_ENV"] == "staging" + "http://license-web.default.svc.cluster.local" + else + "https://license.getlago.com" + end + + if ENV["LAGO_SMTP_ADDRESS"].present? && !ENV["LAGO_SMTP_ADDRESS"].empty? + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV["LAGO_SMTP_ADDRESS"], + port: ENV["LAGO_SMTP_PORT"], + domain: ENV["LAGO_SMTP_DOMAIN"], + user_name: ENV["LAGO_SMTP_USERNAME"], + password: ENV["LAGO_SMTP_PASSWORD"], + authentication: "login", + enable_starttls_auto: true + } + end +end diff --git a/config/environments/staging.rb b/config/environments/staging.rb new file mode 100644 index 0000000..ae8a081 --- /dev/null +++ b/config/environments/staging.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" +require "opentelemetry/sdk" + +Rails.application.configure do + # Used for GraphiQL + config.middleware.use(ActionDispatch::Cookies) + config.middleware.use(ActionDispatch::Session::CookieStore, key: "_lago_staging") + config.middleware.use(Rack::MethodOverride) + + config.cache_classes = true + config.eager_load = true + config.consider_all_requests_local = false + config.public_file_server.enabled = ENV["RAILS_SERVE_STATIC_FILES"].present? + + config.active_storage.service = if ENV["LAGO_USE_AWS_S3"].present? + if ENV["LAGO_AWS_S3_ENDPOINT"].present? + :amazon_compatible_endpoint + else + :amazon + end + else + :local + end + + config.log_level = if ENV["LAGO_LOG_LEVEL"].present? && ENV["LAGO_LOG_LEVEL"] != "" + ENV["LAGO_LOG_LEVEL"].downcase.to_sym + else + :info + end + + config.action_cable.disable_request_forgery_protection = true + config.action_cable.allowed_request_origins = [ENV["LAGO_API_URL"]] + + config.action_mailer.perform_caching = false + config.i18n.fallbacks = true + config.active_support.report_deprecations = false + + if ENV["RAILS_LOG_TO_STDOUT"].present? + config.logger = ActiveSupport::Logger.new($stdout) + .tap { |logger| logger.formatter = ::Logger::Formatter.new } + end + + config.active_record.dump_schema_after_migration = false + + config.license_url = "http://license-staging-web.default.svc.cluster.local" + + if ENV["LAGO_MEMCACHE_SERVERS"].present? + config.cache_store = :mem_cache_store, ENV["LAGO_MEMCACHE_SERVERS"].split(",") + + elsif ENV["LAGO_REDIS_CACHE_URL"].present? + config.cache_store = + :redis_cache_store, + { + url: ENV["LAGO_REDIS_CACHE_URL"], + pool: {size: ENV.fetch("LAGO_REDIS_CACHE_POOL_SIZE", 5)}, + error_handler: lambda { |method:, returning:, exception:| + Rails.logger.warn(exception.message) + + Sentry.capture_exception(exception, level: :warning) + } + } + end + + if ENV["LAGO_SMTP_ADDRESS"].present? && !ENV["LAGO_SMTP_ADDRESS"].empty? + config.action_mailer.perform_deliveries = true + config.action_mailer.raise_delivery_errors = true + config.action_mailer.delivery_method = :smtp + config.action_mailer.smtp_settings = { + address: ENV["LAGO_SMTP_ADDRESS"], + port: ENV["LAGO_SMTP_PORT"] + } + end + + OpenTelemetry::SDK.configure(&:use_all) if ENV["OTEL_EXPORTER"].present? +end diff --git a/config/environments/test.rb b/config/environments/test.rb new file mode 100644 index 0000000..fa65063 --- /dev/null +++ b/config/environments/test.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "active_support/core_ext/integer/time" + +Rails.application.configure do + config.enable_reloading = false + config.eager_load = ENV["CI"].present? + + config.public_file_server.enabled = true + config.public_file_server.headers = { + "Cache-Control" => "public, max-age=#{1.hour.to_i}" + } + + if ENV["CI"].present? + config.logger = Logger.new(nil) + config.log_level = :fatal + end + + config.consider_all_requests_local = true + config.action_controller.perform_caching = false + config.cache_store = :null_store + config.action_dispatch.show_exceptions = :rescuable + config.action_controller.allow_forgery_protection = false + config.active_storage.service = :test + + config.action_mailer.perform_caching = false + config.action_mailer.delivery_method = :test + config.action_mailer.default_url_options = {host: "www.example.com"} + + config.active_support.deprecation = :stderr + config.active_support.disallowed_deprecation = :raise + config.active_support.disallowed_deprecation_warnings = [] + + config.action_controller.raise_on_missing_callback_actions = true + + config.active_record.encryption.primary_key = "test" + config.active_record.encryption.deterministic_key = "test" + config.active_record.encryption.key_derivation_salt = "test" + + # Ensures we raise an error when we call `scope.order(...).find_each` as it could potentially + # log a lot of warnings on production. + config.active_record.error_on_ignored_order = true + + config.active_job.queue_adapter = :test + config.license_url = "http://license.lago" + + Dotenv.load + + if ENV["LAGO_REDIS_CACHE_URL"].present? + redis_store_config = { + url: ENV["LAGO_REDIS_CACHE_URL"], + ssl_params: {verify_mode: OpenSSL::SSL::VERIFY_NONE} + } + config.cache_store = :redis_cache_store, redis_store_config + end + config.cache_store = :null_store + + # Set default API URL for test environment + ENV["LAGO_API_URL"] ||= "http://localhost:3000" + + config.after_initialize do + Bullet.bullet_logger = true + Bullet.raise = true # raise an error if n+1 query occurs + end +end diff --git a/config/i18n-tasks.yml b/config/i18n-tasks.yml new file mode 100644 index 0000000..1270ddc --- /dev/null +++ b/config/i18n-tasks.yml @@ -0,0 +1,153 @@ +# i18n-tasks finds and manages missing and unused translations: https://github.com/glebm/i18n-tasks + +# The "main" locale. +base_locale: en +## All available locales are inferred from the data by default. Alternatively, specify them explicitly: +# locales: [es, fr] +## Reporting locale, default: en. Available: en, ru. +# internal_locale: en + +# Read and write translations. +data: + ## Translations are read from the file system. Supported format: YAML, JSON. + ## Provide a custom adapter: + # adapter: I18n::Tasks::Data::FileSystem + + # Locale files or `File.find` patterns where translations are read from: + read: + ## Default: + - config/locales/%{locale}.yml + ## More files: + - config/locales/%{locale}/*.yml + + # Locale files to write new keys to, based on a list of key pattern => file rules. Matched from top to bottom: + # `i18n-tasks normalize -p` will force move the keys according to these rules + write: + ## For example, write devise and simple form keys to their respective files: + # - ['{devise, simple_form}.*', 'config/locales/\1.%{locale}.yml'] + ## Catch-all default: + # - config/locales/%{locale}.yml + + # External locale data (e.g. gems). + # This data is not considered unused and is never written to. + external: + ## Example (replace %#= with %=): + # - "<%#= %x[bundle info vagrant --path].chomp %>/templates/locales/%{locale}.yml" + + ## Specify the router (see Readme for details). Valid values: conservative_router, pattern_router, or a custom class. + # router: conservative_router + + yaml: + write: + # do not wrap lines at 80 characters + line_width: -1 + + ## Pretty-print JSON: + # json: + # write: + # indent: ' ' + # space: ' ' + # object_nl: "\n" + # array_nl: "\n" + +# Find translate calls +search: + ## Paths or `File.find` patterns to search in: + # paths: + # - app/ + + ## Root directories for relative keys resolution. + # relative_roots: + # - app/controllers + # - app/helpers + # - app/mailers + # - app/presenters + # - app/views + + ## Directories where method names which should not be part of a relative key resolution. + # By default, if a relative translation is used inside a method, the name of the method will be considered part of the resolved key. + # Directories listed here will not consider the name of the method part of the resolved key + # + # relative_exclude_method_name_paths: + # - + + ## Files or `File.fnmatch` patterns to exclude from search. Some files are always excluded regardless of this setting: + ## *.jpg *.jpeg *.png *.gif *.svg *.ico *.eot *.otf *.ttf *.woff *.woff2 *.pdf *.css *.sass *.scss *.less + ## *.yml *.json *.zip *.tar.gz *.swf *.flv *.mp3 *.wav *.flac *.webm *.mp4 *.ogg *.opus *.webp *.map *.xlsx + exclude: + - app/assets/images + - app/assets/fonts + - app/assets/videos + - app/assets/builds + + ## Alternatively, the only files or `File.fnmatch patterns` to search in `paths`: + ## If specified, this settings takes priority over `exclude`, but `exclude` still applies. + # only: ["*.rb", "*.html.slim"] + + ## If `strict` is `false`, guess usages such as t("categories.#{category}.title"). The default is `true`. + # strict: true + + ## Allows adding ast_matchers for finding translations using the AST-scanners + ## The available matchers are: + ## - RailsModelMatcher + ## Matches ActiveRecord translations like + ## User.human_attribute_name(:email) and User.model_name.human + ## + ## To implement your own, please see `I18n::Tasks::Scanners::AstMatchers::BaseMatcher`. + <%# I18n::Tasks.add_ast_matcher('I18n::Tasks::Scanners::AstMatchers::RailsModelMatcher') %> + + ## Multiple scanners can be used. Their results are merged. + ## The options specified above are passed down to each scanner. Per-scanner options can be specified as well. + ## See this example of a custom scanner: https://github.com/glebm/i18n-tasks/wiki/A-custom-scanner-example + +## Translation Services +# translation: +# # Google Translate +# # Get an API key and set billing info at https://code.google.com/apis/console to use Google Translate +# google_translate_api_key: "AbC-dEf5" +# # DeepL Pro Translate +# # Get an API key and subscription at https://www.deepl.com/pro to use DeepL Pro +# deepl_api_key: "48E92789-57A3-466A-9959-1A1A1A1A1A1A" +# # deepl_host: "https://api.deepl.com" +# # deepl_version: "v2" + +## Do not consider these keys missing: +# ignore_missing: +# - 'errors.messages.{accepted,blank,invalid,too_short,too_long}' +# - '{devise,simple_form}.*' + +## Consider these keys used: +# ignore_unused: +# - 'activerecord.attributes.*' +# - '{devise,kaminari,will_paginate}.*' +# - 'simple_form.{yes,no}' +# - 'simple_form.{placeholders,hints,labels}.*' +# - 'simple_form.{error_notification,required}.:' + +## Exclude these keys from the `i18n-tasks eq-base' report: +# ignore_eq_base: +# all: +# - common.ok +# fr,es: +# - common.brand + +## Exclude these keys from the `i18n-tasks check-consistent-interpolations` report: +# ignore_inconsistent_interpolations: +# - 'activerecord.attributes.*' + +## Ignore these keys completely: +# ignore: +# - kaminari.* + +## Sometimes, it isn't possible for i18n-tasks to match the key correctly, +## e.g. in case of a relative key defined in a helper method. +## In these cases you can use the built-in PatternMapper to map patterns to keys, e.g.: +# +# <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', +# only: %w(*.html.haml *.html.slim), +# patterns: [['= title\b', '.page_title']] %> +# +# The PatternMapper can also match key literals via a special %{key} interpolation, e.g.: +# +# <%# I18n::Tasks.add_scanner 'I18n::Tasks::Scanners::PatternMapper', +# patterns: [['\bSpree\.t[( ]\s*%{key}', 'spree.%{key}']] %> diff --git a/config/initializers/aasm.rb b/config/initializers/aasm.rb new file mode 100644 index 0000000..07dbc0c --- /dev/null +++ b/config/initializers/aasm.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +AASM::Configuration.hide_warnings = true diff --git a/config/initializers/active_job_uniqueness.rb b/config/initializers/active_job_uniqueness.rb new file mode 100644 index 0000000..d0690b0 --- /dev/null +++ b/config/initializers/active_job_uniqueness.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "lago/redis_config_builder" + +ActiveJob::Uniqueness.configure do |config| + config.lock_ttl = 1.hour + + config.redlock_options = { + retry_count: 0, + redis_timeout: 5 + } + + redis_config = Lago::RedisConfigBuilder.new + .with_options(reconnect_attempts: 4) + .sidekiq + + client = if redis_config.key?(:sentinels) + RedisClient.sentinel(**redis_config).new_client + else + RedisClient.new(**redis_config) + end + + config.redlock_servers = [client] +end diff --git a/config/initializers/analytics_ruby.rb b/config/initializers/analytics_ruby.rb new file mode 100644 index 0000000..cc637a8 --- /dev/null +++ b/config/initializers/analytics_ruby.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +unless ENV["LAGO_DISABLE_SEGMENT"] == "true" + class SegmentError < StandardError + attr_reader :status, :error_message, :message + + def initialize(status, error_message) + @status = status + @error_message = error_message + @message = "Status: #{status}, Message: #{error_message}" + + super + end + end + + Segment::Analytics::Logging.logger = Logger.new(nil) + + SEGMENT_CLIENT = Segment::Analytics.new( + { + write_key: ENV.fetch("SEGMENT_WRITE_KEY", "changeme"), + on_error: proc { |status, msg| defined?(Sentry) && Sentry.capture_exception(SegmentError.new(status, msg)) }, + stub: Rails.env.development? || Rails.env.test? + } + ) +end diff --git a/config/initializers/console.rb b/config/initializers/console.rb new file mode 100644 index 0000000..d601fb1 --- /dev/null +++ b/config/initializers/console.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +# rubocop:disable Rails/Output +Rails.application.console do + if Rails.env.development? + def gavin + @gavin ||= hooli.users.find_by email: "gavin@hooli.com" + end + + def hooli + @hooli ||= Organization.find_by name: "Hooli" + end + + def delete_hooli_webhooks + hooli.webhook_endpoints.map do |endpoint| + endpoint.webhooks.delete_all + end.sum + end + end + + def find(id) + model = if /^gid/.match?(id) + GlobalID::Locator.locate(id) + elsif Regex::EMAIL.match?(id) + User.find_by email: id + else + raise "Don't know how to resolve this ¯\\_(ツ)_/¯. Please provide a valid email or Global ID." + end + puts "Organization: #{model.organization&.name}" + model + end + + def retry_generating_invoice(invoice) + Invoices::SubscriptionService.new( + subscriptions: invoice.subscriptions, + timestamp: invoice.invoice_subscriptions.first.timestamp, + invoicing_reason: :subscription_periodic, + invoice: invoice, + skip_charges: invoice.skip_charges + ).call + end + + def deadjobs_summary + Sidekiq::DeadSet.new.map { it.args[0]["job_class"] }.tally + end + + def enable_premium_integration!(org_id, integration_name) + org = Organization.find(org_id) + if org.premium_integrations.exclude?(integration_name) + org.premium_integrations << integration_name + org.save! + end + org.reload.premium_integrations + end + + def current_usage(subscription, apply_taxes: false, with_cache: false, **kwargs) + Invoices::CustomerUsageService.call!( + customer: subscription.customer, + subscription: subscription, + apply_taxes:, + with_cache:, + **kwargs + ).usage + end + + def enable_all_premium_integrations!(org_id) + org = Organization.find(org_id) + org.update! premium_integrations: Organization::PREMIUM_INTEGRATIONS + org.reload.premium_integrations + end + + def hard_delete_invoice(id) + invoice = Invoice.find(id) + puts "Going to hard delete invoice from org `#{invoice.organization.name}` (id: #{invoice.id})" + + puts "Press any key to confirm deletion or CTRL+C to cancel." + c = $stdin.getch + + if c == "\u0003" + puts "Deletion cancelled." + return invoice + end + + puts "Deleting invoice #{invoice.id}..." + ActiveRecord::Base.transaction do + invoice.invoice_subscriptions.destroy_all + invoice.credit_notes.destroy_all + invoice.fees.each { |f| f.true_up_fee&.destroy! } + invoice.fees.destroy_all + invoice.taxes.destroy_all + invoice.credits.destroy_all + invoice.applied_invoice_custom_sections.destroy_all + invoice.payments.destroy_all + invoice.destroy! + end + + begin + invoice.reload + puts "Invoice #{id} could not be deleted. Please try again." + rescue ActiveRecord::RecordNotFound + puts "Invoice #{id} has been successfully deleted." + end + end + + def create_organization(org_name:, email:) + organization = Organizations::CreateService + .call(name: org_name, document_numbering: "per_organization") + .raise_if_error! + .organization + + result = Invites::CreateService.call( + current_organization: organization, + email: email, + roles: %w[admin], + skip_admin_check: true + ) + + puts "Organization `#{org_name}` created with admin invite: #{result.invite_url}" + {organization:, invite_url: result.invite_url} + end + + # Often this procedure is called "regenerate invoice" + def delete_invoice_pdf(id) + inv = Invoice.find(id) + puts "Going to delete invoice pdf from org `#{inv.organization.name}` (id: #{inv.id})" + unless inv.finalized? + puts "Invoice is not finalized. Skipping." + return + end + + inv.file&.destroy + end + + def find_dead_jobs_by_job_name_and_error(job_name, error_class) + ds = Sidekiq::DeadSet.new + + ds.select do |job| + job.item["wrapped"].include?(job_name) && job.item["error_class"] == error_class + end + end + + def clear_dead_termination_jobs + jobs = find_dead_jobs_by_job_name_and_error("BillSubscriptionJob", "RecordNotUnique") + # Count the number of filtered jobs + puts "Total BillSubscription jobs with error_class 'RecordNotUnique': #{jobs.count}" + + to_be_deleted = 0 + not_terminated = 0 + no_invoice = 0 + + # Iterate over the jobs + jobs.each do |job| + job_args = job.item["args"].first["arguments"] + + # Extract subscription ID from job arguments + subscription_id = job_args[0][0]["_aj_globalid"].split("Subscription/").last + invoicing_reason = job_args[2]["invoicing_reason"]["value"] + invoicing_reason = :subscription_terminating if invoicing_reason == "upgrading" + + # Find the last invoice related to this subscription + invoice = InvoiceSubscription.where(invoicing_reason:, subscription_id:).order(:created_at).last&.invoice + + # Check if the invoice has been generated and get its status + if invoice + if (invoice.status == "closed" && invoice.fees_amount_cents.zero?) || invoice.status == "finalized" + subscription = Subscription.find(subscription_id) + if subscription.terminated? + # Remove the dead job if everything seems correct + job.delete + to_be_deleted += 1 + else + not_terminated += 1 + end + else + # puts "Subscription #{subscription_id} is not terminated. Keeping job in dead set." + not_terminated += 1 + end + else + # puts "No invoice found for Subscription #{subscription_id}. Keeping job in dead set." + no_invoice += 1 + end + end + + puts "Summary:" + puts "Jobs to be deleted: " + to_be_deleted.to_s + puts "Subscriptions not terminated: " + not_terminated.to_s + puts "Subscriptions with no invoice: " + no_invoice.to_s + end +end +# rubocop:enable Rails/Output diff --git a/config/initializers/cors.rb b/config/initializers/cors.rb new file mode 100644 index 0000000..0c15324 --- /dev/null +++ b/config/initializers/cors.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Read more: https://github.com/cyu/rack-cors + +Rails.application.config.middleware.insert_before(0, Rack::Cors) do + allow do + if ENV.key?("LAGO_FRONT_URL") + uri = URI(ENV["LAGO_FRONT_URL"]) + + frontend_origin = if uri.port.in?([80, 443]) + uri.host + else + [uri.host, uri.port].join(":") + end + + origins frontend_origin + elsif ENV.key?("LAGO_DOMAIN") + origins ENV["LAGO_DOMAIN"] + elsif Rails.env.development? + origins "app.lago.dev", "api", "lago.ngrok.dev" + end + + resource "*", + headers: :any, + methods: %i[get post put patch delete options head], + expose: ["x-lago-token"] + end +end diff --git a/config/initializers/countries.rb b/config/initializers/countries.rb new file mode 100644 index 0000000..f845169 --- /dev/null +++ b/config/initializers/countries.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +# NOTE: Kosovo does not have an ISO 3166-1 alpha-2 code yet and as such +# is not included in the "countries" gem. +# See related issue: https://github.com/countries/countries/issues/793 +# +# As it was requested by multiple customers, we are registering as it an +# available country until the ISO 3166-1 alpha-2 code is assigned. +ISO3166::Data.register( + alpha2: "XK", + alpha3: "XKX", + continent: "Europe", + country_code: "383", + currency_code: "EUR", + distance_unit: "KM", + gec: "KV", + geo: { + latitude: 42.5833, + longitude: 21.0001, + max_latitude: 43.139, + max_longitude: 21.835, + min_latitude: 41.877, + min_longitude: 19.949, + bounds: { + northeast: { + lat: 41.877, + lng: 19.949 + }, + southwest: { + lat: 43.139, + lng: 21.835 + } + } + }, + international_prefix: "00", + ioc: "KOS", + iso_long_name: "Republic of Kosovo", + iso_short_name: "Kosovo", + languages_official: ["sq", "sr"], + languages_spoken: ["sq", "sr"], + nationality: "Kosovar", + postal_code: true, + postal_code_format: "\\d{5}", + region: "Europe", + start_of_week: "monday", + subregion: "Southern Europe", + unofficial_names: ["Kosovo", "Kosova", "Косово"], + world_region: "EMEA", + translations: { + "en" => "Kosovo" + } +) diff --git a/config/initializers/datadog.rb b/config/initializers/datadog.rb new file mode 100644 index 0000000..99ebcbf --- /dev/null +++ b/config/initializers/datadog.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +if ENV["DD_AGENT_HOST"] + require "lago_utils" + require "datadog/auto_instrument" + + Datadog.configure do |c| + c.tracing.instrument :rails + c.tracing.instrument :sidekiq + c.tracing.instrument :graphql + c.tracing.instrument :http + c.tracing.instrument :pg + c.tracing.instrument :redis + + c.env = ENV["DD_ENV"] || Rails.env + c.service = ENV["DD_SERVICE_NAME"] || "lago-api" + c.version = LagoUtils::Version.call(default: Rails.env).number + + c.tracing.sampling.default_rate = ENV["DD_TRACE_SAMPLE_RATE"]&.to_f || 1.0 + end +end diff --git a/config/initializers/filter_parameter_logging.rb b/config/initializers/filter_parameter_logging.rb new file mode 100644 index 0000000..aad15d5 --- /dev/null +++ b/config/initializers/filter_parameter_logging.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# Be sure to restart your server when you modify this file. + +# Configure parameters to be partially matched (e.g. passw matches password) and filtered from the log file. +# Use this to limit dissemination of sensitive information. +# See the ActiveSupport::ParameterFilter documentation for supported notations and behaviors. +Rails.application.config.filter_parameters += [ + :passw, :email, :secret, :token, :_key, :crypt, :salt, :certificate, :otp, :ssn, :secret_key +] diff --git a/config/initializers/graphiql.rb b/config/initializers/graphiql.rb new file mode 100644 index 0000000..b484ac8 --- /dev/null +++ b/config/initializers/graphiql.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +GraphiQL::Rails.config.header_editor_enabled = true if Rails.env.development? diff --git a/config/initializers/half_year.rb b/config/initializers/half_year.rb new file mode 100644 index 0000000..999f020 --- /dev/null +++ b/config/initializers/half_year.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +require "date_and_time/half_year_calculations" + +DateAndTime::Calculations.prepend DateAndTime::HalfYearCalculations diff --git a/config/initializers/httplog.rb b/config/initializers/httplog.rb new file mode 100644 index 0000000..b22296d --- /dev/null +++ b/config/initializers/httplog.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +return unless defined? HttpLog + +HttpLog.configure do |config| + config.enabled = true + config.color = :yellow + + config.url_denylist_pattern = /clickhouse:8123/ +end diff --git a/config/initializers/license.rb b/config/initializers/license.rb new file mode 100644 index 0000000..1aa7bae --- /dev/null +++ b/config/initializers/license.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "lago_utils" + +License = LagoUtils::License.new(Rails.application.config.license_url) + +License.verify unless Rails.env.test? diff --git a/config/initializers/lograge.rb b/config/initializers/lograge.rb new file mode 100644 index 0000000..0a41796 --- /dev/null +++ b/config/initializers/lograge.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +Rails.application.configure do + config.lograge.enabled = true + config.lograge.formatter = Lograge::Formatters::Json.new + config.colorize_logging = Rails.env.development? + + config.lograge.ignore_actions = ["ApplicationController#health"] + + config.lograge.custom_options = lambda do |event| + # If ENV[OTEL_EXPORTER] is not set, the span context will have all zero values. + span = OpenTelemetry::Trace.current_span + + { + level: event.payload[:level], + ddsource: "ruby", + params: (event.payload[:params] || {}).reject { |k| %w[controller action].include?(k) }, + organization_id: event.payload[:organization_id], + trace_id: span.context.hex_trace_id, + span_id: span.context.hex_span_id + } + end +end diff --git a/config/initializers/migration_extensions.rb b/config/initializers/migration_extensions.rb new file mode 100644 index 0000000..05f7ecb --- /dev/null +++ b/config/initializers/migration_extensions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +ActiveSupport.on_load(:active_record) do + require Rails.root.join("lib/migrations/extension_helper") + + ActiveRecord::Migration.include(Migrations::ExtensionHelper) +end diff --git a/config/initializers/money.rb b/config/initializers/money.rb new file mode 100644 index 0000000..c169a62 --- /dev/null +++ b/config/initializers/money.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +MoneyRails.configure do |config| + config.default_currency = :eur + config.locale_backend = :i18n + config.rounding_mode = BigDecimal::ROUND_HALF_UP +end diff --git a/config/initializers/open_telemetry.rb b/config/initializers/open_telemetry.rb new file mode 100644 index 0000000..1a7b8d5 --- /dev/null +++ b/config/initializers/open_telemetry.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "opentelemetry/sdk" +require "opentelemetry/instrumentation/all" + +OpenTelemetry::SDK.configure(&:use_all) if ENV["OTEL_EXPORTER"].present? + +LagoTracer = OpenTelemetry.tracer_provider.tracer("lago") diff --git a/config/initializers/paper_trail.rb b/config/initializers/paper_trail.rb new file mode 100644 index 0000000..dfdde91 --- /dev/null +++ b/config/initializers/paper_trail.rb @@ -0,0 +1,4 @@ +# frozen_string_literal: true + +PaperTrail.config.version_limit = 5 +PaperTrail.serializer = PaperTrail::Serializers::JSON diff --git a/config/initializers/rsa_keys.rb b/config/initializers/rsa_keys.rb new file mode 100644 index 0000000..db7a766 --- /dev/null +++ b/config/initializers/rsa_keys.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +KEY_DIR = Rails.root.join("config/keys") +PRIVATE_KEY_PATH = KEY_DIR.join("private.pem") + +private_key_string = + if File.exist?(PRIVATE_KEY_PATH) + File.read(PRIVATE_KEY_PATH) + else + Base64.decode64(ENV["LAGO_RSA_PRIVATE_KEY"]) + end + +if private_key_string.blank? + abort("Error: Private key is blank, you must provide a private key to start the application. Exiting...") # rubocop:disable Rails/Exit +end + +RsaPrivateKey = OpenSSL::PKey::RSA.new(private_key_string) +RsaPublicKey = RsaPrivateKey.public_key diff --git a/config/initializers/sentry.rb b/config/initializers/sentry.rb new file mode 100644 index 0000000..1d6541d --- /dev/null +++ b/config/initializers/sentry.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +if ENV["SENTRY_DSN"].present? + Sentry.init do |config| + config.dsn = ENV["SENTRY_DSN"] + config.release = LagoUtils::Version.call(default: Rails.env).number + config.breadcrumbs_logger = %i[active_support_logger http_logger] + config.traces_sample_rate = 0 + config.traces_sample_rate = ENV["SENTRY_TRACES_SAMPLE_RATE"].to_f + config.environment = ENV["SENTRY_ENVIRONMENT"] || Rails.env + end +end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb new file mode 100644 index 0000000..d1390d9 --- /dev/null +++ b/config/initializers/sidekiq.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +begin + require "sidekiq-pro" +rescue LoadError + if ENV["LAGO_SIDEKIQ_PRO_REQUIRED"] == "true" + raise "Sidekiq Pro is required. Please make sure it's properly installed." + end + Rails.logger.info "Sidekiq Pro is not installed. Reliability features will not be available." +end + +require "socket" +require "sidekiq/middleware/current_attributes" +require "lago/redis_config_builder" + +LIVENESS_PORT = 8080 + +redis_config = Lago::RedisConfigBuilder.new + .with_options(pool_timeout: 5, timeout: 5) + .sidekiq + +if ENV["LAGO_SIDEKIQ_WEB"] == "true" + require "sidekiq/web" + require "sidekiq/prometheus/exporter" + + Sidekiq::Web.use(ActionDispatch::Cookies) + Sidekiq::Web.use(ActionDispatch::Session::CookieStore, key: "_interslice_session") +end + +def configure_sidekiq_pro_metrics(config) + statsd_endpoint = ENV.fetch("LAGO_SIDEKIQ_STATSD_ENDPOINT", nil) + if statsd_endpoint.nil? + Rails.logger.warn "LAGO_SIDEKIQ_STATSD_ENDPOINT not set, Sidekiq Pro metrics will not be reported" + return + end + + statsd_host, statsd_port = statsd_endpoint.split(":") + if statsd_host.empty? || statsd_port.nil? || statsd_port.empty? + Rails.logger.error "LAGO_SIDEKIQ_STATSD_ENDPOINT invalid format, expected host:port, got: #{statsd_endpoint}" + return + end + + require "datadog/statsd" + + config.dogstatsd = -> { + Datadog::Statsd.new(statsd_host, statsd_port.to_i, + tags: ["env:#{config[:environment]}", "service:sidekiq"], + + namespace: Rails.application.name) + } + + config.server_middleware do |chain| + require "sidekiq/middleware/server/statsd" + chain.add Sidekiq::Middleware::Server::Statsd + end +end + +Sidekiq.configure_server do |config| + if Sidekiq.pro? + # Super fetch is only available in Sidekiq Pro. See https://github.com/sidekiq/sidekiq/wiki/Reliability. + config.super_fetch! + # https://github.com/sidekiq/sidekiq/wiki/Pro-Metrics#enabling-metrics + # As of Sidekiq Pro 8.0, this is the recommended Statsd tag/namespace configuration. + # Read more about global tags: https://docs.datadoghq.com/getting_started/tagging/unified_service_tagging/ + configure_sidekiq_pro_metrics(config) + end + config.redis = redis_config + config.logger = nil + config.average_scheduled_poll_interval = ENV.fetch("LAGO_SIDEKIQ_AVERAGE_SCHEDULED_POLL_INTERVAL", 5).to_f + config[:max_retries] = 0 + config[:dead_max_jobs] = ENV.fetch("LAGO_SIDEKIQ_MAX_DEAD_JOBS", 100_000).to_i + config.on(:startup) do + Sidekiq.logger.info "Starting liveness server on #{LIVENESS_PORT}" + Thread.start do # rubocop:disable ThreadSafety/NewThread + server = TCPServer.new("0.0.0.0", LIVENESS_PORT) + loop do + Thread.start(server.accept) do |socket| # rubocop:disable ThreadSafety/NewThread + request = socket.gets + sidekiq_response = ::Sidekiq.redis { |r| r.ping } + + if sidekiq_response.eql?("PONG") + response = "Live!\n" + socket.print "HTTP/1.1 200 OK\r\n" \ + "Content-Type: text/plain\r\n" \ + "Content-Length: #{response.bytesize}\r\n" \ + "Connection: close\r\n" + else + response = "Sidekiq is not ready: Sidekiq.redis.ping returned #{request.inspect} instead of PONG\n" + Sidekiq.logger.error(response) + socket.print "HTTP/1.1 404 OK\r\n" \ + "Content-Type: text/plain\r\n" \ + "Content-Length: #{response.bytesize}\r\n" \ + "Connection: close\r\n" + end + socket.print "\r\n" + socket.print response + socket.close + rescue + response = "Sidekiq is not ready\n" + Sidekiq.logger.error(response) + socket.print "HTTP/1.1 404 OK\r\n" \ + "Content-Type: text/plain\r\n" \ + "Content-Length: #{response.bytesize}\r\n" \ + "Connection: close\r\n" + socket.print "\r\n" + socket.print response + socket.close + end + end + end + end + + if Rails.env.development? && ENV["SIDEKIQ_PROFILING_ENABLED"] == "true" + config.server_middleware do |chain| + chain.prepend(Sidekiq::ProfilingMiddleware, dir: "tmp/profiling") + end + end +end + +Sidekiq.configure_client do |config| + config.redis = redis_config + config.logger = Sidekiq::Logger.new($stdout) + config.logger.formatter = Sidekiq::Logger::Formatters::JSON.new +end + +Sidekiq::CurrentAttributes.persist("CurrentContext") diff --git a/config/initializers/stripe.rb b/config/initializers/stripe.rb new file mode 100644 index 0000000..016cb0e --- /dev/null +++ b/config/initializers/stripe.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +Stripe.api_version = ENV.fetch("STRIPE_API_VERSION", "2025-04-30.basil") + +# Lago uses the key from PaymentProvider.secret_key because each org should have their own keys +# In development, we always use our sandbox key, and in the console we might need to use +# the Stripe client directly (ex: ::Stripe::Customer.retrieve('cus_xxx')) which throws +# a "No API key provided." error. +# The key should never be set outside of development env +if Rails.env.development? + Stripe.api_key = ENV["STRIPE_API_KEY"] +end diff --git a/config/initializers/throttling.rb b/config/initializers/throttling.rb new file mode 100644 index 0000000..73570f4 --- /dev/null +++ b/config/initializers/throttling.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "sidekiq/throttled" +require "sidekiq/throttled/web" + +## +# Configuration of 'sidekiq-throttled' gem +# +# This is the limit of concurrent API calls for Xero and Netsuite +Sidekiq::Throttled::Registry.add(:concurrency_limit, concurrency: {limit: 5}) + +## +# Configuration of 'throttling' gem +# +Throttling.storage = Rails.cache +Throttling.logger = Rails.logger + +# Limits per integration and per API key +Throttling.limits = { + hubspot: { # Rate limit: 110 requests per 10 seconds + tensecondly: { + limit: 110, + period: 10 + } + }, + xero: { + minutely: { # Rate limit: 60 requests per minute + limit: 60, + period: 60 + }, + daily: { + limit: 5000, # Rate limit: 5000 requests per day + period: 86400 + } + }, + netsuite: { # Rate limit: 10 requests per second + secondly: { + limit: 10, + period: 1 + } + }, + anrok: { # Rate limit: 10 requests per second + secondly: { + # this mutation can bypass the limit of 10, so lets set it to 9 + # app/graphql/mutations/integrations/anrok/fetch_draft_invoice_taxes.rb + limit: 9, + period: 1 + } + }, + avalara: { # Rate limit: 10 requests per second + secondly: { + limit: 10, + period: 1 + } + } +} + +# Examples of how to use the throttling gem +# Throttling.for(:hubspot).check(:client, 'hubspot') +# Throttling.for(:xero).check(:client, 'xero') +# Throttling.for(:netsuite).check(:client, integration.client_id) +# Throttling.for(:anrok).check(:client, integration.api_key) diff --git a/config/initializers/version.rb b/config/initializers/version.rb new file mode 100644 index 0000000..2549e25 --- /dev/null +++ b/config/initializers/version.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +LAGO_VERSION = LagoUtils::Version.call(default: Rails.env) diff --git a/config/initializers/yabeda.rb b/config/initializers/yabeda.rb new file mode 100644 index 0000000..350cc18 --- /dev/null +++ b/config/initializers/yabeda.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "yabeda" + +# https://github.com/yabeda-rb/yabeda-prometheus?tab=readme-ov-file#multi-process-server-support +Prometheus::Client.config.data_store = Prometheus::Client::DataStores::DirectFileStore.new( + dir: "/tmp/prometheus/" +) + +Yabeda::Rails.config.ignore_actions = ["ApplicationController#health"] +Yabeda::Rails.config.buckets = [0.05, 0.1, 0.25, 0.5, 1, 5] + +Yabeda.configure do + default_tag :service, ENV["OTEL_SERVICE_NAME"] || "lago-api" + default_tag :environment, Rails.env + default_tag :version, ENV["LAGO_VERSION"] || "unknown" +end diff --git a/config/initializers/zeitwerk.rb b/config/initializers/zeitwerk.rb new file mode 100644 index 0000000..a5d4a59 --- /dev/null +++ b/config/initializers/zeitwerk.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Rails.autoloaders.main.ignore( + "lib/generators" +) diff --git a/config/locales/de.yml b/config/locales/de.yml new file mode 100644 index 0000000..b6edab1 --- /dev/null +++ b/config/locales/de.yml @@ -0,0 +1,58 @@ +--- +de: + date: + abbr_day_names: + - So + - Mo + - Di + - Mi + - Do + - Fr + - Sa + abbr_month_names: + - + - Jan. + - Feb. + - Mär. + - Apr. + - Mai + - Jun. + - Jul. + - Aug. + - Sep. + - Okt. + - Nov. + - Dez. + day_names: + - Sonntag + - Montag + - Dienstag + - Mittwoch + - Donnerstag + - Freitag + - Samstag + formats: + default: "%d. %b %Y" + month_names: + - + - Januar + - Februar + - März + - April + - Mai + - Juni + - Juli + - August + - September + - Oktober + - November + - Dezember + order: + - :year + - :month + - :day + money: + custom_format: "%{iso_code} %n" + decimal_mark: "," + format: "%u%n" + thousands_separator: "." diff --git a/config/locales/de/commitment.yml b/config/locales/de/commitment.yml new file mode 100644 index 0000000..ee446c6 --- /dev/null +++ b/config/locales/de/commitment.yml @@ -0,0 +1,5 @@ +--- +de: + commitment: + minimum: + name: Mindestverpflichtung diff --git a/config/locales/de/credit_note.yml b/config/locales/de/credit_note.yml new file mode 100644 index 0000000..14d345e --- /dev/null +++ b/config/locales/de/credit_note.yml @@ -0,0 +1,28 @@ +--- +de: + credit_note: + amount: Betrag (ohne Steuern) + coupon_adjustment: Coupons + credit_from: Von + credit_note_number: Gutschriftnummer + credit_to: Gutschrift für + credited_notice: Gutschrift auf Kundenguthaben am %{issuing_date} + credited_on_customer_balance: Auf Kundenguthaben gutgeschrieben + document_name: Gutschrift + invoice_number: Rechnungsnummer + issue_date: Ausgabedatum + issued_notice: Ausgestellt am %{issuing_date} + item: Artikel + offset_invoice: Verrechnung auf Rechnung %{invoice_number} + offset_invoice_notice: Rechnung %{invoice_number} am %{issuing_date} ausgeglichen + powered_by: Bereitgestellt von + prepaid_credits_for_wallet: Prepaid-Guthaben - %{wallet_name} + refunded: Erstattet + refunded_notice: Erstattung am %{issuing_date} + self_billed: + footer: Diese Gutschriftrechnung wurde vom Kunden im Namen des Partners mit dessen Zustimmung erstellt. Der Partner hat zugestimmt, keine eigene Rechnung für diese Transaktion auszustellen. + sub_total_without_tax: Zwischensumme (ohne Steuern) + subscription: Abonnement + tax: "%{name} (%{rate}% on %{amount})" + tax_rate: Tax rate + total: Insgesamt diff --git a/config/locales/de/email.yml b/config/locales/de/email.yml new file mode 100644 index 0000000..bb2026d --- /dev/null +++ b/config/locales/de/email.yml @@ -0,0 +1,44 @@ +--- +de: + email: + credit_note: + created: + credit_note_from: Gutschriftsbeleg von %{billing_entity_name} + credit_note_number: Gutschriftsbelegnummer + credited_notice: Gutschrift auf Kundenkonto am %{date} + download: Gutschriftsbeleg herunterladen, um Details anzuzeigen + invoice_number: Rechnungsnummer + issue_date: Ausstellungsdatum + issued_notice: Ausgestellt am %{date} + offset_invoice_notice: Rechnung %{invoice_number} am %{date} ausgeglichen + refunded_notice: Zurückerstattet am %{date} + subject: 'Dein Gutschriftsbeleg von %{billing_entity_name} #%{credit_note_number}' + invoice: + finalized: + download: Rechnung herunterladen, um Details anzuzeigen + due_date: Gesamtbetrag fällig am %{date} + invoice_from: Rechnung von %{billing_entity_name} + invoice_number: Rechnungsnummer + issue_date: Ausstellungsdatum + issued_on: ausgestellt am %{date} + subject: 'Deine Rechnung von %{billing_entity_name} #%{invoice_number}' + payment_receipt: + created: + payment_receipt_from: Zahlungsquittung von %{billing_entity_name} + subject: 'Ihre Zahlungsquittung von %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: Wenn Sie die Zahlung bereits vorgenommen haben, ignorieren Sie bitte diese E-Mail. + hello: Hallo %{customer_name}, + pay_amount: Restbetrag bezahlen + payment_terms: + one: Unsere vertraglich vereinbarten Zahlungsbedingungen sind %{count} Tag. + other: Unsere vertraglich vereinbarten Zahlungsbedingungen sind %{count} Tage. + zero: Unsere vertraglich vereinbarten Zahlungsbedingungen erfordern die Zahlung bei Rechnungserstellung. + remaining_amount: Restbetrag zu zahlen + reminder_overdue_balance: Dies ist eine Erinnerung des Finanzteams von %{billing_entity_name}, dass einige Rechnungen überfällig sind. + subject: Ihr überfälliger Saldo von %{billing_entity_name} + thank_you: Danke. + total_amount_due: Der insgesamt fällige Betrag beträgt %{amount}. + powered_by: Bereitgestellt von + questions: Fragen? Kontaktiere uns unter diff --git a/config/locales/de/invoice.yml b/config/locales/de/invoice.yml new file mode 100644 index 0000000..4dc6864 --- /dev/null +++ b/config/locales/de/invoice.yml @@ -0,0 +1,138 @@ +--- +de: + invoice: + account_holder_name: Kontoinhaber + account_number: Kontonummer + account_type: Kontotyp + all_subscriptions: Alle Abonnements + all_usage_based_fees: Alle nutzungsabhängigen Gebühren + already_paid: Bereits bezahlt + amount: Betrag + amount_with_tax: Betrag (inkl. Steuern) + amount_without_tax: Betrag (ohne Steuern) + bank_code: Bankleitzahl + bank_name: Bankname + bank_transfer_info: Banküberweisungen können mehrere Werktage zur Bearbeitung benötigen. Um per Banküberweisung zu bezahlen, überweisen Sie den Betrag unter Verwendung der folgenden Bankdaten. + bic: BIC + bill_from: Von + bill_to: An + branch_code: Filialnummer + branch_name: Filialname + breakdown: Aufschlüsselung + breakdown_for_days: "%{breakdown_duration} für %{breakdown_total_duration} Tage" + breakdown_of: Aufschlüsselung für %{fee_filter_display_name} + clabe: CLABE + conversion_rate: Umrechnungskurs + country: Land + coupons: Coupons + credit_notes: Gutschriften + date_from: Vom + date_to: Bis + details: "%{resource} Details" + document_name: Rechnung + document_tax_name: Steuerrechnung + due_date: Fälligkeit %{date} + e_invoicing: + credit_card: Kreditkartenzahlung + credit_card_information: Kreditkartenzahlung erhalten am %{date} + discount_reason: Rabatt %{tax_rate} Anteil + payment_information: "%{payment_label} von %{currency} %{amount} angewendet" + standard_payment: Standardzahlung + fee_prorated: Die Gebühr wird anteilig nach Nutzungstagen berechnet, der angezeigte Stückpreis ist ein Durchschnitt + fees_from_to_date: Gebühren vom %{from_date} bis %{to_date} + free_credits: Kostenloses Guthaben + from_to_date: Vom %{from_date} bis %{to_date} + graduated: + fee_per_unit_for_the_first: Gebühr pro Einheit für die ersten %{to} + fee_per_unit_for_the_last: Gebühr pro Einheit für %{from} und höher + fee_per_unit_for_the_next: Gebühr pro Einheit für die nächsten %{from} bis %{to} + flat_fee_for_the_first: Pauschalgebühr für die ersten %{to} + flat_fee_for_the_last: Pauschalgebühr für %{from} und höher + flat_fee_for_the_next: Pauschalgebühr für die nächsten %{from} bis %{to} + half_year: Halbjahr + iban: IBAN + invoice_number: Rechnungsnummer + issue_date: Ausgabedatum + item: Artikel + list_of_charges: Liste der Gebühren vom %{from} bis %{to} + month: monat + monthly: Monatlich + notice_full: Unabhängig davon, wann ein Ereignis empfangen wird, wird die Einheit nicht anteilig berechnet, wir berechnen den vollen Preis. + notice_prorated: Wenn eine Einheit während des Abrechnungszeitraums hinzugefügt oder entfernt wird, berechnen wir den anteiligen Preis basierend auf dem Verhältnis der verbleibenden oder genutzten Tage zur Gesamtzahl der Tage im Plan. Zum Beispiel, wenn noch 15 Tage im Plan verbleiben, multiplizieren wir den Preis mit 15/%{days_in_month}, und wenn eine Einheit für 10 Tage genutzt wurde, multiplizieren wir den Preis mit 10/%{days_in_month}. + package: + fee_per_package: Gebühr pro Paket + fee_per_package_unit_price: "%{amount} pro %{package_size}" + free_units_for_the_first: Gebühr pro Einheit für die ersten %{count} + paid_invoice: Bezahlte Rechnung + paid_tax_invoice: Bezahlte Steuerrechnung + pay_with_bank_transfer: Zahlung per Banküberweisung + payment_term: Zahlungsfrist + payment_term_days: "%{net_payment_term} Tage" + percentage: + adjustment_per_transaction: Anpassung für Min/Max pro Transaktion + fee_per_transaction: Feste Gebühr pro Transaktion + free_units_per_transaction: + one: Freie Einheiten für 1 Transaktion + other: Freie Einheiten für %{count} Transaktionen + percentage_rate_on_amount: Satz auf den Betrag + powered_by: Bereitgestellt von + prepaid_credit_invoice: Anzahlungsrechnung + prepaid_credits: Prepaid-Guthaben + prepaid_credits_with_value: Prepaid-Guthaben - %{wallet_name} + progressive_billing_credit: Nutzung bereits abgerechnet + quarter: quartal + quarterly: Vierteljährlich + reached_usage_threshold: Diese progressive Rechnung wird erstellt, da Ihre kumulierte Nutzung %{usage_amount} erreicht hat und den Schwellenwert von %{threshold_amount} überschritten hat. + routing_number: Routing-Nummer + see_breakdown: Siehe Aufschlüsselung für Gesamtübersicht + self_billed: + document_name: Gutschriftrechnung + footer: Diese Gutschriftrechnung wurde vom Kunden im Namen des Partners mit dessen Zustimmung erstellt. Der Partner hat zugestimmt, keine eigene Rechnung für diese Transaktion auszustellen. + semiannual: Halbjährlich + sort_code: Sortiercode + sub_total: Zwischensumme + sub_total_with_tax: Zwischensumme (inkl. Steuern) + sub_total_without_tax: Zwischensumme (ohne Steuern) + subscription: Abonnement + subscription_interval: "%{plan_interval}es Abonnement - %{plan_name}" + swift_code: SWIFT-Code + tax: Steuern + tax_identification_number: 'Steuer ID: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% on %{amount})" + tax_name_only: + customer_exempt: Der Kunde ist von der Umsatzsteuer befreit + juris_has_no_tax: Keine Steuer + juris_not_taxed: Keine Steuer + not_collecting: Keine Steuer + reverse_charge: Reverse-Charge-Verfahren - Steuerschuldnerschaft auf den Kunden übertragen + transaction_exempt: Der Kunde ist von der Umsatzsteuer befreit + unknown_taxation: Unbekannt + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Steuersatz + taxes: + fr_tax_exempt: Kunde ist gemäß Artikel 259-1 des französischen Allgemeinen Steuergesetzbuchs von der Steuer befreit. + reverse_charge: Steuerschuld auf Kunden übertragen. + tax_exempt: Kunde ist von der Steuer befreit. + total: Gesamt + total_credits: Gesamtguthaben + total_credits_with_value: 'Gesamtguthaben: %{credit_amount} Credits' + total_due: Insgesamt fällig + total_due_amount: Betrag fällig + total_events: 'Ereignisse insgesamt: %{count}' + total_paid_amount: Betrag bezahlt + total_unit: 'Gesamte Einheiten: %{units}' + total_unit_interval: 'Gesamte Einheiten: %{events_count} Ereignisse für %{units}' + true_up_details: Mindestausgabe von %{min_amount} anteilig an den Nutzungstagen + true_up_metric: "%{metric} - Kosten für die Anpassung" + unit: Einheit + unit_price: Stückpreis + units: Einheiten + units_prorated_per_period: Einheiten anteilig pro Sekunde pro %{period} + usage_based_fees: Nutzungsabhängige Gebühren + volume: + fee_per_unit: Gebühr pro Einheit + flat_fee_for_all_units: Pauschalgebühr für alle Einheiten + week: woche + weekly: Wöchentlich + year: jahr + yearly: Jährlich diff --git a/config/locales/de/payment_receipt.yml b/config/locales/de/payment_receipt.yml new file mode 100644 index 0000000..ac7d099 --- /dev/null +++ b/config/locales/de/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +de: + payment_receipt: + document_name: Quittung + download_invoice: Rechnung herunterladen + download_receipt: Quittung herunterladen + number: Quittungsnummer + paid_on: 'Bezahlt am %{date} (Restbetrag: %{total_due_amount})' + payment_date: Bezahlt am + payment_method: Zahlungsmethode diff --git a/config/locales/de/webhook_endpoint.yml b/config/locales/de/webhook_endpoint.yml new file mode 100644 index 0000000..8b9167d --- /dev/null +++ b/config/locales/de/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +de: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: Maximum number of webhook endpoints was reached diff --git a/config/locales/en.yml b/config/locales/en.yml new file mode 100644 index 0000000..c66eae9 --- /dev/null +++ b/config/locales/en.yml @@ -0,0 +1,80 @@ +--- +en: + activerecord: + errors: + messages: + blank: value_is_mandatory + cannot_be_empty: cannot_be_empty + country_code_invalid: not_a_valid_country_code + country_must_be_present: country_must_be_present + country_not_supported: country_not_supported + exclusion: value_is_reserved + feature_unavailable: feature_unavailable + graduated_percentage_requires_premium_license: graduated_percentage_requires_premium_license + greater_than: value_is_out_of_range + greater_than_or_equal_to: value_is_out_of_range + inclusion: value_is_invalid + invalid: value_is_invalid + invalid_aggregation_type_or_charge_model: invalid_aggregation_type_or_charge_model + invalid_amount: invalid_amount + invalid_billable_metric_or_charge_model: invalid_billable_metric_or_charge_model + invalid_charge_model: invalid_charge_model + invalid_content_type: invalid_content_type + invalid_date: invalid_date + invalid_date_range: invalid_date_range + invalid_email_format: invalid_email_format + invalid_expression: invalid_expression + invalid_fixed_amount: invalid_fixed_amount + invalid_flat_amount: invalid_flat_amount + invalid_format: invalid_format + invalid_free_units: invalid_free_units + invalid_free_units_per_events: invalid_free_units_per_events + invalid_free_units_per_total_aggregation: invalid_free_units_per_total_aggregation + invalid_graduated_ranges: invalid_graduated_ranges + invalid_organization: invalid_organization + invalid_owner: invalid_owner + invalid_package_size: invalid_package_size + invalid_per_unit_amount: invalid_per_unit_amount + invalid_rate: invalid_rate + invalid_size: invalid_size + invalid_timezone: invalid_timezone + invalid_void_reason: invalid_void_reason + invalid_volume_ranges: invalid_volume_ranges + language_code_invalid: not_a_valid_language_code + less_than_or_equal_to: value_is_out_of_range + missing_graduated_ranges: missing_graduated_ranges + missing_volume_ranges: missing_volume_ranges + modification_not_allowed: modification_not_allowed + must_be_array: must_be_array + must_be_greater_than_or_equal_min: must_be_greater_than_or_equal_min + must_be_true_unless_pay_in_advance: must_be_true_unless_pay_in_advance + not_compatible_with_aggregation_type: not_compatible_with_aggregation_type + not_compatible_with_pay_in_advance: not_compatible_with_pay_in_advance + one_of_plan_or_subscription_required: one_of_plan_or_subscription_required + only_compatible_with_pay_in_advance_and_non_invoiceable: only_compatible_with_pay_in_advance_and_non_invoiceable + payment_request_is_already_succeeded: payment_request_is_already_succeeded + present: value_must_be_blank + required: relation_must_exist + taken: value_already_exist + too_long: value_is_too_long + too_short: value_is_too_short + unsupported_value: unsupported_value + url_invalid: url_is_invalid + value_already_exist: value_already_exist + models: + api_key: + attributes: + permissions: + forbidden_keys: 'contains forbidden keys: %{keys}' + webhook_endpoint: + attributes: + event_types: + invalid_types: 'contains invalid types: %{invalid_types}' + date: + formats: + default: "%b %d, %Y" + money: + custom_format: "%{iso_code} %n" + decimal_mark: "." + format: "%u%n" + thousands_separator: "," diff --git a/config/locales/en/commitment.yml b/config/locales/en/commitment.yml new file mode 100644 index 0000000..79a40ec --- /dev/null +++ b/config/locales/en/commitment.yml @@ -0,0 +1,5 @@ +--- +en: + commitment: + minimum: + name: Minimum commitment diff --git a/config/locales/en/credit_note.yml b/config/locales/en/credit_note.yml new file mode 100644 index 0000000..7acc07c --- /dev/null +++ b/config/locales/en/credit_note.yml @@ -0,0 +1,28 @@ +--- +en: + credit_note: + amount: Amount (excl. tax) + coupon_adjustment: Coupons + credit_from: From + credit_note_number: Credit note number + credit_to: Credit to + credited_notice: Credited on customer balance on %{issuing_date} + credited_on_customer_balance: Credited on customer balance + document_name: Credit note + invoice_number: Invoice number + issue_date: Issue date + issued_notice: Issued on %{issuing_date} + item: Item + offset_invoice: Offset on invoice %{invoice_number} + offset_invoice_notice: Offset invoice %{invoice_number} on %{issuing_date} + powered_by: Powered by + prepaid_credits_for_wallet: Prepaid credits - %{wallet_name} + refunded: Refunded + refunded_notice: Refunded on %{issuing_date} + self_billed: + footer: This credit note on a self-billing invoice was issued by the client on behalf of the partner, with their consent. The partner has agreed not to issue their own credit note for this transaction. + sub_total_without_tax: Sub total (excl. tax) + subscription: Subscription + tax: "%{name} (%{rate}% on %{amount})" + tax_rate: Tax rate + total: Total diff --git a/config/locales/en/email.yml b/config/locales/en/email.yml new file mode 100644 index 0000000..7e5aaca --- /dev/null +++ b/config/locales/en/email.yml @@ -0,0 +1,85 @@ +--- +en: + email: + api_key: + created: + access_warning: However, if you did not authorize this change, please reset your password and delete the API key immediately to protect your account. + change_notice: If someone from your team initiated this, you can start using this API key to automate your billing process. + email_info: You're receiving this email because a new API key has been created in your Lago instance, and you have admin privileges. + greetings: Hello, + key_has_been_created: A new API key has been successfully created for your organization, %{organization_name}. + subject: A new Lago API key has been created + destroyed: + access_warning: If you did not authorize this request, please reset your password immediately to secure your account. + change_notice: If someone from your team initiated this change, no further action is required. + email_info: You’re receiving this email because an API key has been deleted in your Lago instance, and you have admin privileges. + greetings: Hello, + key_has_been_destroyed: An API key has been deleted for your organization, %{organization_name}. + subject: A Lago API key has been deleted + rotated: + access_warning: If you did not authorize this request, please reset your password and roll your API key immediately to secure your account. + change_notice: If someone from your team initiated this change, no further action is required. To keep your billing running smoothly, update your app to reference the new token as soon as possible. + email_info: You’re receiving this email because an API key has been rotated in your Lago instance, and you have admin privileges. + greetings: Hello, + key_has_been_rotated: Your %{organization_name}'s API key has been rotated! + subject: Your Lago API key has been rolled + credit_note: + created: + credit_note_from: Credit Note from %{billing_entity_name} + credit_note_number: Credit note number + credited_notice: Credited on customer balance on %{date} + download: Download credit note for details + invoice_number: Invoice number + issue_date: Issue date + issued_notice: Issued on %{date} + offset_invoice_notice: Offset invoice %{invoice_number} on %{date} + refunded_notice: Refunded on %{date} + subject: 'Your credit note from %{billing_entity_name} #%{credit_note_number}' + data_export: + completed: + fallback_text: If the link has expired, please generate a new one from your Dashboard. + greetings: Hello + intro: Your %{resource_type} export is ready! You can download it using the link below, which will be available for 7 days. + lago_team: The Lago Team + main_cta_label: Download export + subject: Your Lago %{resource_type} export is ready! + thanks: Thanks, + invoice: + finalized: + download: Download invoice for details + due_date: total due %{date} + invoice_from: Invoice from %{billing_entity_name} + invoice_number: Invoice Number + issue_date: Issue Date + issued_on: issued on %{date} + subject: 'Your Invoice from %{billing_entity_name} #%{invoice_number}' + organization: + authentication_methods_updated: + changes: The user %{user_email} made the following changes to authentication methods. + disabled: Disabled %{login_method} + email_info: If you weren't expecting this change, please review your authentication settings. + enabled: Enabled %{login_method} + reasoning: You're receiving this message because you're an admin of the organization. + subject: Login method updated in your Lago workspace + password_reset: + subject: Reset your Lago password + payment_receipt: + created: + payment_receipt_from: Payment receipt from %{billing_entity_name} + subject: 'Your payment receipt from %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: If you have already made the payment, please disregard this email. + hello: Hello %{customer_name}, + pay_amount: Pay balance + payment_terms: + one: Our contractually agreed payment terms is %{count} day. + other: Our contractually agreed payment terms are %{count} days. + zero: Our contractually agreed payment terms require payment upon invoice generation. + remaining_amount: Remaining amount to pay + reminder_overdue_balance: This is a reminder from the %{billing_entity_name} finance team that some invoices are overdue. + subject: Your overdue balance from %{billing_entity_name} + thank_you: Thank you. + total_amount_due: The total amount due is %{amount}. + powered_by: Powered by + questions: Questions? Contact us at diff --git a/config/locales/en/invoice.yml b/config/locales/en/invoice.yml new file mode 100644 index 0000000..f47b8e8 --- /dev/null +++ b/config/locales/en/invoice.yml @@ -0,0 +1,139 @@ +--- +en: + invoice: + account_holder_name: Account holder name + account_number: Account number + account_type: Account type + all_subscriptions: All Subscriptions + all_usage_based_fees: All usage based fees + already_paid: Already paid + amount: Amount + amount_with_tax: Amount (incl. tax) + amount_without_tax: Amount (excl. tax) + bank_code: Bank code + bank_name: Bank name + bank_transfer_info: Bank transfers may take several business days to process. To pay via bank transfer, transfer funds using the following bank information. + bic: BIC + bill_from: From + bill_to: Bill to + branch_code: Branch code + branch_name: Branch name + breakdown: Breakdown + breakdown_for_days: Used %{breakdown_duration} out of %{breakdown_total_duration} days + breakdown_of: Breakdown of %{fee_filter_display_name} + charges_paid_in_advance: Charges paid in advance + clabe: CLABE + conversion_rate: Conversion rate + country: Country + coupons: Coupons + credit_notes: Credit notes + date_from: From + date_to: to + details: "%{resource} details" + document_name: Invoice + document_tax_name: Tax invoice + due_date: Due %{date} + e_invoicing: + credit_card: Credit Card Payment + credit_card_information: Credit card payment received on %{date} + discount_reason: Discount %{tax_rate} portion + payment_information: "%{payment_label} of %{currency} %{amount} applied" + standard_payment: Standard payment + fee_prorated: The fee is prorated on days of usage, the displayed unit price is an average + fees_from_to_date: Fees from %{from_date} to %{to_date} + free_credits: Free credits + from_to_date: From %{from_date} to %{to_date} + graduated: + fee_per_unit_for_the_first: Fee per unit for the first %{to} + fee_per_unit_for_the_last: Fee per unit for %{from} and above + fee_per_unit_for_the_next: Fee per unit for the next %{from} to %{to} + flat_fee_for_the_first: Flat fee for first %{to} + flat_fee_for_the_last: Flat fee for %{from} and above + flat_fee_for_the_next: Flat fee for the next %{from} to %{to} + half_year: half-year + iban: IBAN + invoice_number: Invoice Number + issue_date: Issue Date + item: Item + list_of_charges: List of charges used from %{from} to %{to} + month: month + monthly: Monthly + notice_full: Regardless of when an event is received, the unit is not prorated, we charge the full price. + notice_prorated: If a unit is added or removed during the monthly plan, we calculate the prorated price based on the ratio of days remaining or used to the total number of days in the plan. For example, if 15 days are left in a %{days_in_month}-day period, we charge 15/%{days_in_month} of the period price. If a unit was used for 10 days, we charge 10/%{days_in_month} of the period price. + package: + fee_per_package: Fee per package + fee_per_package_unit_price: "%{amount} per %{package_size}" + free_units_for_the_first: Free units for the first %{count} + paid_invoice: Paid invoice + paid_tax_invoice: Paid tax invoice + pay_with_bank_transfer: Pay with a bank transfer + payment_term: Payment term + payment_term_days: "%{net_payment_term} days" + percentage: + adjustment_per_transaction: Adjustment for min/max per transaction + fee_per_transaction: Fixed fee per transaction + free_units_per_transaction: + one: Free units for 1 transaction + other: Free units for %{count} transactions + percentage_rate_on_amount: Rate on the amount + powered_by: Powered by + prepaid_credit_invoice: Advance invoice + prepaid_credits: Prepaid credits + prepaid_credits_with_value: Prepaid credits - %{wallet_name} + progressive_billing_credit: Usage already billed + quarter: quarter + quarterly: Quarterly + reached_usage_threshold: This progressive billing is generated because your cumulative usage has reached %{usage_amount}, exceeding the %{threshold_amount} threshold. + routing_number: Routing number + see_breakdown: See breakdown for total unit + self_billed: + document_name: Self-billing invoice + footer: This self-billing invoice was issued by the client on behalf of the partner, with their consent. The partner has agreed not to issue their own invoice for this transaction. + semiannual: Semiannual + sort_code: Sort code + sub_total: Subtotal + sub_total_with_tax: Subtotal (incl. tax) + sub_total_without_tax: Subtotal (excl. tax) + subscription: Subscription + subscription_interval: "%{plan_interval} subscription - %{plan_name}" + swift_code: SWIFT code + tax: Tax + tax_identification_number: 'Tax ID: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% on %{amount})" + tax_name_only: + customer_exempt: Customer is tax exempt + juris_has_no_tax: No tax + juris_not_taxed: No tax + not_collecting: No tax + reverse_charge: Reverse charge - Tax liability shifted to customer + transaction_exempt: Customer is tax exempt + unknown_taxation: Unknown + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Tax rate + taxes: + fr_tax_exempt: Customer is tax exempt according to article 259-1 of the French Tax General Code. + reverse_charge: Reverse charge - Tax liability shifted to customer. + tax_exempt: Customer is tax exempt. + total: Total + total_credits: Total credits + total_credits_with_value: 'Total credits: %{credit_amount} credits' + total_due: Total due + total_due_amount: Amount due + total_events: 'Total events: %{count}' + total_paid_amount: Amount paid + total_unit: 'Total unit: %{units}' + total_unit_interval: 'Total unit: %{events_count} events for %{units}' + true_up_details: Minimum spend of %{min_amount} prorated on days of usage + true_up_metric: "%{metric} - True-up" + unit: Unit + unit_price: Unit price + units: Units + units_prorated_per_period: Units prorated per second per %{period} + usage_based_fees: Usage based fees + volume: + fee_per_unit: Fee per unit + flat_fee_for_all_units: Flat fee for all units + week: week + weekly: Weekly + year: year + yearly: Yearly diff --git a/config/locales/en/password_reset.yml b/config/locales/en/password_reset.yml new file mode 100644 index 0000000..b296d0c --- /dev/null +++ b/config/locales/en/password_reset.yml @@ -0,0 +1,10 @@ +--- +en: + password_reset: + email_info: You're receiving this email because a password reset was requested for your account. + fallback_text: 'If you don''t use this link within 30 minutes, it will expire. To get a new password reset link, visit: ' + greetings: Hello + intro: 'We heard that you lost your Lago password. Sorry about that! But don''t worry! You can use the following button to reset your password:' + lago_team: The Lago Team + main_cta_label: Reset your password + thanks: Thanks, diff --git a/config/locales/en/payment_receipt.yml b/config/locales/en/payment_receipt.yml new file mode 100644 index 0000000..7510ff6 --- /dev/null +++ b/config/locales/en/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +en: + payment_receipt: + document_name: Receipt + download_invoice: Download invoice + download_receipt: Download receipt + number: Receipt number + paid_on: 'Paid on %{date} (Remaining to pay: %{total_due_amount})' + payment_date: Paid on + payment_method: Payment method diff --git a/config/locales/en/webhook_endpoint.yml b/config/locales/en/webhook_endpoint.yml new file mode 100644 index 0000000..0cbc909 --- /dev/null +++ b/config/locales/en/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +en: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: Maximum number of webhook endpoints was reached diff --git a/config/locales/es.yml b/config/locales/es.yml new file mode 100644 index 0000000..02821cb --- /dev/null +++ b/config/locales/es.yml @@ -0,0 +1,58 @@ +--- +es: + date: + abbr_day_names: + - Dom + - Lun + - Mar + - Mié + - Jue + - Vie + - Sáb + abbr_month_names: + - + - ene. + - feb. + - mar. + - abr. + - may. + - jun. + - jul. + - ago. + - sep. + - oct. + - nov. + - dic. + day_names: + - Domingo + - Lunes + - Martes + - Miércoles + - Jueves + - Viernes + - Sábado + formats: + default: "%-d de %b de %Y" + month_names: + - + - Enero + - Febrero + - Marzo + - Abril + - Mayo + - Junio + - Julio + - Agosto + - Septiembre + - Octubre + - Noviembre + - Diciembre + order: + - :year + - :month + - :day + money: + custom_format: "%{iso_code} %n" + decimal_mark: "." + format: "%u%n" + thousands_separator: "," diff --git a/config/locales/es/commitment.yml b/config/locales/es/commitment.yml new file mode 100644 index 0000000..6ef2b0a --- /dev/null +++ b/config/locales/es/commitment.yml @@ -0,0 +1,5 @@ +--- +es: + commitment: + minimum: + name: Compromiso mínimo diff --git a/config/locales/es/credit_note.yml b/config/locales/es/credit_note.yml new file mode 100644 index 0000000..ec21859 --- /dev/null +++ b/config/locales/es/credit_note.yml @@ -0,0 +1,28 @@ +--- +es: + credit_note: + amount: Total (excl. impuestos) + coupon_adjustment: Descuento + credit_from: Desde + credit_note_number: Número de nota de crédito + credit_to: Crédito a + credited_notice: Acreditado en el saldo del cliente el %{issuing_date} + credited_on_customer_balance: Acreditado en el saldo del cliente + document_name: Nota de crédito + invoice_number: Número de factura + issue_date: Fecha de emisión + issued_notice: Emitida el %{issuing_date} + item: Item + offset_invoice: Compensación en la factura %{invoice_number} + offset_invoice_notice: Factura %{invoice_number} compensada el %{issuing_date} + powered_by: Generada por + prepaid_credits_for_wallet: Créditos prepagados - %{wallet_name} + refunded: Reembolso + refunded_notice: Reembolso hecho el %{issuing_date} + self_billed: + footer: Esta nota de crédito sobre una factura de autoliquidación ha sido emitida por el cliente en nombre del socio, con su consentimiento. El socio ha aceptado no emitir su propia nota de crédito para esta transacción. + sub_total_without_tax: Subtotal (impuestos excl.) + subscription: Suscripción + tax: "%{name} (%{rate}% sobre %{amount})" + tax_rate: Tasas de impuestos + total: Total diff --git a/config/locales/es/email.yml b/config/locales/es/email.yml new file mode 100644 index 0000000..1ef6f0b --- /dev/null +++ b/config/locales/es/email.yml @@ -0,0 +1,44 @@ +--- +es: + email: + credit_note: + created: + credit_note_from: Nota de crédito de %{billing_entity_name} + credit_note_number: Número de nota de crédito + credited_notice: Acreditado en el saldo del cliente el %{date} + download: Descargar nota de crédito para más detalles + invoice_number: Número de factura + issue_date: Fecha de emisión + issued_notice: Emitida el %{date} + offset_invoice_notice: Factura %{invoice_number} compensada el %{date} + refunded_notice: Reembolso hecho el %{date} + subject: 'Tu nota de crédito de %{billing_entity_name} #%{credit_note_number}' + invoice: + finalized: + download: Descargar factura para más detalles + due_date: Vence el %{date} + invoice_from: Factura de %{billing_entity_name} + invoice_number: Número de factura + issue_date: Fecha de emisión + issued_on: emitida el %{date} + subject: 'Tu factura de %{billing_entity_name} #%{invoice_number}' + payment_receipt: + created: + payment_receipt_from: Recibo de pago de %{billing_entity_name} + subject: 'Tu recibo de pago de %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: Si ya ha realizado el pago, por favor ignore este correo electrónico. + hello: Hola %{customer_name}, + pay_amount: Pagar el saldo + payment_terms: + one: Nuestros términos de pago acordados contractualmente son %{count} día. + other: Nuestros términos de pago acordados contractualmente son %{count} días. + zero: Nuestros términos de pago acordados contractualmente requieren el pago al generar la factura. + remaining_amount: Monto restante a pagar + reminder_overdue_balance: Este es un recordatorio del equipo financiero de %{billing_entity_name} de que algunas facturas están vencidas. + subject: Su saldo vencido de %{billing_entity_name} + thank_you: Gracias. + total_amount_due: El monto total adeudado es de %{amount}. + powered_by_lago: Powered by + questions: "¿Preguntas? Contáctanos en" diff --git a/config/locales/es/invoice.yml b/config/locales/es/invoice.yml new file mode 100644 index 0000000..9d3ce2d --- /dev/null +++ b/config/locales/es/invoice.yml @@ -0,0 +1,136 @@ +--- +es: + invoice: + account_holder_name: Nombre del titular de la cuenta + account_number: Número de cuenta + account_type: Tipo de cuenta + all_subscriptions: Todos los suscripciones + all_usage_based_fees: Todos los cargos basados en el uso + already_paid: Ya pagado + amount: Total (excl. impuestos) + amount_with_tax: Total (impuestos incl.) + amount_without_tax: Total (impuestos excl.) + bank_code: Código bancario + bank_name: Nombre del banco + bank_transfer_info: Las transferencias bancarias pueden tardar varios días hábiles en procesarse. Para pagar mediante transferencia bancaria, transfiere fondos utilizando la siguiente información bancaria. + bic: BIC + bill_from: Emitida por + bill_to: Factura destinada a + branch_code: Código de sucursal + branch_name: Nombre de la sucursal + breakdown: Desglose + breakdown_for_days: Utilizados %{breakdown_duration} de %{breakdown_total_duration} días + breakdown_of: Desglose de %{fee_filter_display_name} + clabe: CLABE + conversion_rate: Tipo de cambio + country: País + credit_notes: Notas de crédito + date_from: Tarifas desde + date_to: hasta + details: Detalles de %{resource} + document_name: Factura + document_tax_name: Factura fiscal + due_date: Vence el %{date} + e_invoicing: + credit_card: Pago con tarjeta de crédito + credit_card_information: Pago con tarjeta de crédito recibido el %{date} + discount_reason: Descuento del %{tax_rate} + payment_information: "%{payment_label} de %{currency} %{amount} aplicado" + standard_payment: Pago estándar + fee_prorated: La tarifa se prorratea según los días de uso, el precio unitario mostrado es un promedio + fees_from_to_date: Cargos desde %{from_date} hasta %{to_date} + free_credits: Créditos gratuitos + from_to_date: Desde %{from_date} hasta %{to_date} + graduated: + fee_per_unit_for_the_first: Tarifa por unidad para las %{to} primeras + fee_per_unit_for_the_last: Tarifa por unidad para %{from} y superiores + fee_per_unit_for_the_next: Tarifa por unidad para las siguientes de %{from} a %{to} + flat_fee_for_the_first: Tarifa fija para las %{to} primeras + flat_fee_for_the_last: Tarifa fija para %{from} y superiores + flat_fee_for_the_next: Tarifa fija para las siguientes de %{from} a %{to} + half_year: semestre + iban: IBAN + invoice_number: Número de la factura + issue_date: Fecha de emisión + item: Ítem + list_of_charges: Lista de cargos desde %{from} hasta %{to} + month: mes + monthly: Mensual + notice_full: Independientemente de cuándo se reciba un evento, la unidad no se prorratea, cobramos el precio completo. + notice_prorated: Si se agrega o se elimina una unidad durante el plan mensual, calculamos el precio prorrateado en función de la proporción de días restantes o utilizados con respecto al número total de días en el plan. Por ejemplo, si quedan 15 días en el plan, multiplicamos el precio por 15/%{days_in_month}, y si una unidad se usó durante 10 días, multiplicamos el precio por 10/%{days_in_month}. + package: + fee_per_package: Tarifa por paquete + fee_per_package_unit_price: "%{amount} por %{package_size}" + free_units_for_the_first: Unidades gratuitas para las %{count} primeras + paid_invoice: Factura pagada + paid_tax_invoice: Factura fiscal pagada + pay_with_bank_transfer: Paga con transferencia bancaria + payment_term: Plazo de pago neto + payment_term_days: "%{net_payment_term} días" + percentage: + adjustment_per_transaction: Ajuste para mín./máx. por transacción + fee_per_transaction: Tasa fija por transacción + free_units_per_transaction: + one: Unidades gratuitas para 1 transacción + other: Unidades gratuitas para %{count} transacciónes + percentage_rate_on_amount: Tarifa sobre el monto + powered_by: Generada por + prepaid_credit_invoice: Factura anticipada + prepaid_credits: Créditos prepagados + prepaid_credits_with_value: Créditos prepagados - %{wallet_name} + progressive_billing_credit: Uso ya facturado + quarter: trimestre + quarterly: Trimestral + reached_usage_threshold: Esta facturación progresiva se genera porque su uso acumulado ha alcanzado los %{usage_amount}, superando el umbral de %{threshold_amount}. + routing_number: Número de ruta + see_breakdown: Consulte el desglose a continuación + self_billed: + document_name: Factura de autoliquidación + footer: Esta factura de autoliquidación ha sido emitida por el cliente en nombre del socio, con su consentimiento. El socio ha aceptado no emitir su propia factura para esta transacción. + semiannual: Semestral + sort_code: Código de clasificación + sub_total: Subtotal + sub_total_with_tax: Subtotal (impuestos incl.) + sub_total_without_tax: Subtotal (impuestos excl.) + subscription: Suscripción + subscription_interval: Suscripción %{plan_interval} - %{plan_name} + swift_code: Código SWIFT + tax: Impuesto + tax_identification_number: 'ID del impuesto: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% sobre %{amount})" + tax_name_only: + customer_exempt: El cliente está exento de impuestos + juris_has_no_tax: Sin impuesto + juris_not_taxed: Sin impuesto + not_collecting: Sin impuesto + reverse_charge: Inversión del sujeto pasivo - Responsabilidad fiscal trasladada al cliente + transaction_exempt: El cliente está exento de impuestos + unknown_taxation: Desconocido + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Tasas de impuestos + taxes: + fr_tax_exempt: El cliente está exento de impuestos según el artículo 259-1 del Código General de Impuestos francés. + reverse_charge: Responsabilidad fiscal transferida al cliente. + tax_exempt: El cliente está exento de impuestos. + total: Total + total_credits_with_value: 'Total de créditos con valor: %{credit_amount} créditos' + total_due: Total debido + total_due_amount: Importe debido + total_events: 'Eventos totales: %{count}' + total_paid_amount: Monto pagado + total_unit: 'Total de unidades: %{units}' + total_unit_interval: 'Total de unidades: %{events_count} evento(s) para %{units}' + true_up_details: Gasto mínimo de %{min_amount} prorrateado en días de uso + true_up_metric: "%{metric} • Ajuste real" + unit: Unidad + unit_price: Precio unitario + units: Unidades + units_prorated_per_period: Unidades prorrateadas por segundo por %{period} + usage_based_fees: Cargos basados en el uso + volume: + fee_per_unit: Tarifa por unidad + flat_fee_for_all_units: Tarifa plana para todas las unidades + week: semana + weekly: Semanal + year: año + yearly: Anual diff --git a/config/locales/es/payment_receipt.yml b/config/locales/es/payment_receipt.yml new file mode 100644 index 0000000..cf8bd80 --- /dev/null +++ b/config/locales/es/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +es: + payment_receipt: + document_name: Recibo + download_invoice: Descargar factura + download_receipt: Descargar recibo + number: Número de recibo + paid_on: 'Pagado el %{date} (Pendiente de pago: %{total_due_amount})' + payment_date: Pagado el + payment_method: Método de pago diff --git a/config/locales/es/webhook_endpoint.yml b/config/locales/es/webhook_endpoint.yml new file mode 100644 index 0000000..aabb9dd --- /dev/null +++ b/config/locales/es/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +es: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: Maximum number of webhook endpoints was reached diff --git a/config/locales/fr.yml b/config/locales/fr.yml new file mode 100644 index 0000000..4e0be7b --- /dev/null +++ b/config/locales/fr.yml @@ -0,0 +1,58 @@ +--- +fr: + date: + abbr_day_names: + - Dim + - Lun + - Mar + - Mer + - Jeu + - Ven + - Sam + abbr_month_names: + - + - janv. + - févr. + - mars + - avr. + - mai + - juin + - juill. + - août + - sept. + - oct. + - nov. + - déc. + day_names: + - Dimanche + - Lundi + - Mardi + - Mercredi + - Jeudi + - Vendredi + - Samedi + formats: + default: "%-d %b %Y" + month_names: + - + - Janvier + - Février + - Mars + - Avril + - Mai + - Juin + - Juillet + - Août + - Septembre + - Octobre + - Novembre + - Décembre + order: + - :year + - :month + - :day + money: + custom_format: "%n %{iso_code}" + decimal_mark: "," + format: "%n %u" + thousands_separator: " " diff --git a/config/locales/fr/commitment.yml b/config/locales/fr/commitment.yml new file mode 100644 index 0000000..6a48980 --- /dev/null +++ b/config/locales/fr/commitment.yml @@ -0,0 +1,5 @@ +--- +fr: + commitment: + minimum: + name: Engagement minimum diff --git a/config/locales/fr/credit_note.yml b/config/locales/fr/credit_note.yml new file mode 100644 index 0000000..15e368c --- /dev/null +++ b/config/locales/fr/credit_note.yml @@ -0,0 +1,28 @@ +--- +fr: + credit_note: + amount: Montant (HT) + coupon_adjustment: Coupons + credit_from: De + credit_note_number: Nº d'avoir + credit_to: Crédité à + credited_notice: Crédité sur le solde du client le %{issuing_date} + credited_on_customer_balance: Crédit sur le solde du client + document_name: Avoir + invoice_number: Nº de facture + issue_date: Date d'émission + issued_notice: Émise le %{issuing_date} + item: Article + offset_invoice: Compensation sur la facture %{invoice_number} + offset_invoice_notice: Facture %{invoice_number} compensée le %{issuing_date} + powered_by: Généré par + prepaid_credits_for_wallet: Crédit(s) prépayé(s) - %{wallet_name} + refunded: Remboursement + refunded_notice: Remboursé le %{issuing_date} + self_billed: + footer: Cette avoir sur une facture d'autofacturation a été émise par le client au nom du partenaire, avec son consentement. Le partenaire a convenu de ne pas établir sa propre note de crédit pour cette transaction. + sub_total_without_tax: Sous total (HT) + subscription: Souscription + tax: "%{name} (%{rate}% sur %{amount})" + tax_rate: Taux de taxe + total: Total diff --git a/config/locales/fr/email.yml b/config/locales/fr/email.yml new file mode 100644 index 0000000..f049090 --- /dev/null +++ b/config/locales/fr/email.yml @@ -0,0 +1,44 @@ +--- +fr: + email: + credit_note: + created: + credit_note_from: Votre avoir de %{billing_entity_name} + credit_note_number: Avoir nº + credited_notice: Crédité sur le solde du client le %{date} + download: Télécharger l'avoir pour plus de détails + invoice_number: Facture nº + issue_date: Date d'émission + issued_notice: Émis le %{date} + offset_invoice_notice: Facture %{invoice_number} compensée le %{date} + refunded_notice: Remboursé le %{date} + subject: Votre avoir nº %{credit_note_number} de %{billing_entity_name} + invoice: + finalized: + download: Télécharger la facture pour plus de détails + due_date: Date d'émission le %{date} + invoice_from: Facture de %{billing_entity_name} + invoice_number: Nº de facture + issue_date: Date d'émission + issued_on: émise le %{date} + subject: Votre facture nº %{invoice_number} de %{billing_entity_name} + payment_receipt: + created: + payment_receipt_from: Reçu de paiement de %{billing_entity_name} + subject: 'Votre reçu de paiement de %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: Si vous avez déjà réglé ces factures, veuillez ignorer cet e-mail. + hello: Bonjour %{customer_name}, + pay_amount: Payer le solde + payment_terms: + one: Nos conditions de paiement convenues contractuellement sont de %{count} jour. + other: Nos conditions de paiement convenues contractuellement sont de %{count} jours. + zero: Nos conditions de paiement définies contractuellement exigent le paiement à la création de la facture. + remaining_amount: Montant restant à payer + reminder_overdue_balance: Ceci est un rappel de l'équipe financière de %{billing_entity_name} que certaines de vos factures sont arrivées à échéance et non réglées. + subject: Votre solde impayé pour %{billing_entity_name} + thank_you: Merci. + total_amount_due: Le montant total dû est de %{amount}. + powered_by: Généré par + questions: Questions? Contactez nous à diff --git a/config/locales/fr/invoice.yml b/config/locales/fr/invoice.yml new file mode 100644 index 0000000..c87c39d --- /dev/null +++ b/config/locales/fr/invoice.yml @@ -0,0 +1,138 @@ +--- +fr: + invoice: + account_holder_name: Nom du titulaire du compte + account_number: Numéro de compte + account_type: Type de compte + all_subscriptions: Tous les abonnements + all_usage_based_fees: Tous les frais de consommation + already_paid: Déjà payé + amount: Montant + amount_with_tax: Montant (TTC) + amount_without_tax: Montant (HT) + bank_code: Code banque + bank_name: Nom de la banque + bank_transfer_info: Les virements bancaires peuvent prendre plusieurs jours ouvrables pour être traités. Pour payer par virement bancaire, transférez des fonds en utilisant les informations bancaires suivantes. + bic: BIC + bill_from: De + bill_to: Facturé à + branch_code: Code guichet + branch_name: Nom de l'agence + breakdown: Détails de consommation + breakdown_for_days: "%{breakdown_duration} sur %{breakdown_total_duration} jours" + breakdown_of: Détails de consommation de %{fee_filter_display_name} + clabe: CLABE + conversion_rate: Taux de conversion + country: Pays + coupons: Réduction(s) + credit_notes: Avoir(s) + date_from: Du + date_to: au + details: Détails de %{resource} + document_name: Facture + document_tax_name: Facture fiscale + due_date: Date d'échéance le %{date} + e_invoicing: + credit_card: Paiement par carte de crédit + credit_card_information: Paiement par carte de crédit reçu le %{date} + discount_reason: Remise %{tax_rate} part + payment_information: "%{payment_label} de %{currency} %{amount} appliqué" + standard_payment: Paiement standard + fee_prorated: Les frais sont calculés au prorata des jours d'utilisation, le prix unitaire affiché est une moyenne + fees_from_to_date: Frais de conso. du %{from_date} au %{to_date} + free_credits: Crédits gratuits + from_to_date: Du %{from_date} au %{to_date} + graduated: + fee_per_unit_for_the_first: Frais par unité pour les %{to} premières + fee_per_unit_for_the_last: Frais par unité pour %{from} et plus + fee_per_unit_for_the_next: Frais par unité pour les suivantes de %{from} à %{to} + flat_fee_for_the_first: Frais fixes pour les %{to} premières + flat_fee_for_the_last: Frais fixes pour %{from} et plus + flat_fee_for_the_next: Frais fixes pour les suivantes de %{from} à %{to} + half_year: semestre + iban: IBAN + invoice_number: Nº de facture + issue_date: Date d'émission + item: Article + list_of_charges: Liste des frais de consommation du %{from} au %{to} + month: mois + monthly: mensuel + notice_full: Peu importe quand l’évènement de consommation est reçu, l'unité n'est pas proratisée et nous facturons le prix complet. + notice_prorated: Si une unité est ajoutée ou supprimée pendant la période de facturation, nous calculons le prix au prorata en fonction du ratio des jours restants ou utilisés. Par exemple, s'il reste 15 jours dans le plan, nous multiplions le prix par 15/%{days_in_month}, et si une unité a été utilisée pendant 10 jours, nous multiplions le prix par 10/%{days_in_month}. + package: + fee_per_package: Frais par tranche + fee_per_package_unit_price: "%{amount} par tranche de %{package_size}" + free_units_for_the_first: Unités gratuites pour les premiers %{count} + paid_invoice: Facture payée + paid_tax_invoice: Facture fiscale payée + pay_with_bank_transfer: Payer par virement bancaire + payment_term: Délai de paiement + payment_term_days: "%{net_payment_term} jours" + percentage: + adjustment_per_transaction: Ajustement pour min/max par transaction + fee_per_transaction: Frais fixes par transaction + free_units_per_transaction: + one: Unités gratuites pour 1 transaction + other: Unités gratuites pour %{count} transactions + percentage_rate_on_amount: Taux sur le montant + powered_by: Document généré par + prepaid_credit_invoice: Facture d'acompte + prepaid_credits: Crédit(s) prépayé(s) + prepaid_credits_with_value: Crédit(s) prépayé(s) - %{wallet_name} + progressive_billing_credit: Usage déjà facturé + quarter: trimestre + quarterly: trimestriellement + reached_usage_threshold: Cette facturation progressive est générée car votre usage cumulé a atteint %{usage_amount}, dépassant le seuil de %{threshold_amount}. + routing_number: Numéro de routage + see_breakdown: Consultez le détail ci-après + self_billed: + document_name: Facture d'autofacturation + footer: Cette facture d'autofacturation a été émise par le client au nom du partenaire, avec son consentement. Le partenaire a accepté de ne pas établir sa propre facture pour cette transaction. + semiannual: Semestriel + sort_code: Code de tri + sub_total: Sous total + sub_total_with_tax: Sous total (TTC) + sub_total_without_tax: Sous total (HT) + subscription: Abonnement + subscription_interval: Abonnement %{plan_interval} - %{plan_name} + swift_code: Code SWIFT + tax: TVA + tax_identification_number: 'ID fiscal: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% sur %{amount})" + tax_name_only: + customer_exempt: Le client est exonéré de taxe + juris_has_no_tax: Taxe non applicable + juris_not_taxed: Taxe non applicable + not_collecting: Pas de taxe + reverse_charge: Autoliquidation - Responsabilité fiscale transférée au client + transaction_exempt: Le client est exonéré de taxe + unknown_taxation: Inconnu + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Taux de taxe + taxes: + fr_tax_exempt: Le client est exonéré de taxe – art. 259-1 du CGI. + reverse_charge: Taxe soumise à autoliquidation. + tax_exempt: Le client est exonéré de taxe. + total: Total + total_credits: Total crédits + total_credits_with_value: 'Nombre total de crédits: %{credit_amount} crédits' + total_due: Total dû + total_due_amount: Montant dû + total_events: 'Nombre total d''événements: %{count}' + total_paid_amount: Montant payé + total_unit: 'Nombre total d''unités: %{units}' + total_unit_interval: 'Nombre total d''unités: %{events_count} événement(s) pour %{units}' + true_up_details: Dépense minimale de %{min_amount} au prorata des jours d'utilisation + true_up_metric: "%{metric} - Frais d'ajustement" + unit: Unité + unit_price: Prix unitaire + units: Unités + units_prorated_per_period: Unités proratisées par seconde par %{period} + usage_based_fees: Frais de consommation + volume: + fee_per_unit: Frais par unité + flat_fee_for_all_units: Frais fixes pour toutes les unités + week: semaine + weekly: hebdomadaire + year: année + yearly: annuel diff --git a/config/locales/fr/payment_receipt.yml b/config/locales/fr/payment_receipt.yml new file mode 100644 index 0000000..50e2e74 --- /dev/null +++ b/config/locales/fr/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +fr: + payment_receipt: + document_name: Reçu de paiement + download_invoice: Télécharger la facture + download_receipt: Télécharger le reçu de paiement + number: Numéro de reçu + paid_on: 'Payé le %{date} (Reste à payer: %{total_due_amount})' + payment_date: Payé le + payment_method: Mode de paiement diff --git a/config/locales/fr/webhook_endpoint.yml b/config/locales/fr/webhook_endpoint.yml new file mode 100644 index 0000000..ea62d2b --- /dev/null +++ b/config/locales/fr/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +fr: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: Maximum number of webhook endpoints was reached diff --git a/config/locales/it.yml b/config/locales/it.yml new file mode 100644 index 0000000..74adeac --- /dev/null +++ b/config/locales/it.yml @@ -0,0 +1,58 @@ +--- +it: + date: + abbr_day_names: + - Dom + - Lun + - Mar + - Mer + - Gio + - Ven + - Sab + abbr_month_names: + - + - gen + - feb + - mar + - apr + - mag + - giu + - lug + - ago + - set + - ott + - nov + - dic + day_names: + - Domenica + - Lunedì + - Martedì + - Mercoledì + - Giovedì + - Venerdì + - Sabato + formats: + default: "%d %b %Y" + month_names: + - + - Gennaio + - Febbraio + - Marzo + - Aprile + - Maggio + - Giugno + - Luglio + - Agosto + - Settembre + - Ottobre + - Novembre + - Dicembre + order: + - :year + - :month + - :day + money: + custom_format: "%n %{iso_code}" + decimal_mark: "," + format: "%n %u" + thousands_separator: "." diff --git a/config/locales/it/commitment.yml b/config/locales/it/commitment.yml new file mode 100644 index 0000000..ea1c588 --- /dev/null +++ b/config/locales/it/commitment.yml @@ -0,0 +1,5 @@ +--- +it: + commitment: + minimum: + name: Impegno minimo diff --git a/config/locales/it/credit_note.yml b/config/locales/it/credit_note.yml new file mode 100644 index 0000000..6b46885 --- /dev/null +++ b/config/locales/it/credit_note.yml @@ -0,0 +1,28 @@ +--- +it: + credit_note: + amount: Importo (escl. tasse) + coupon_adjustment: Correzione coupon + credit_from: Da + credit_note_number: Numero Nota di Credito + credit_to: Credito a + credited_notice: Accreditato sul saldo cliente il %{issuing_date} + credited_on_customer_balance: Accreditato sul saldo cliente + document_name: Nota di Credito + invoice_number: Numero Fattura + issue_date: Data Emissione + issued_notice: Emessa il %{issuing_date} + item: Articolo + offset_invoice: Compensazione sulla fattura %{invoice_number} + offset_invoice_notice: Fattura %{invoice_number} compensata il %{issuing_date} + powered_by: Elaborato da + prepaid_credits_for_wallet: Crediti prepagati - %{wallet_name} + refunded: Rimborsato + refunded_notice: Rimborsato il %{issuing_date} + self_billed: + footer: Questa fattura di autofatturazione è stata emessa dal cliente per conto del partner, con il suo consenso. Il partner ha accettato di non emettere una propria fattura per questa transazione. + sub_total_without_tax: Subtotale (escl. tasse) + subscription: Abbonamento + tax: "%{name} (%{rate}% su %{amount})" + tax_rate: Tasso fiscale + total: Totale diff --git a/config/locales/it/email.yml b/config/locales/it/email.yml new file mode 100644 index 0000000..7a74758 --- /dev/null +++ b/config/locales/it/email.yml @@ -0,0 +1,44 @@ +--- +it: + email: + credit_note: + created: + credit_note_from: Nota di credito da %{billing_entity_name} + credit_note_number: Numero Nota di Credito + credited_notice: Accreditato sul saldo cliente il %{date} + download: Scarica la nota di credito per ulteriori dettagli + invoice_number: Numero Fattura + issue_date: Data Emissione + issued_notice: Emessa il %{date} + offset_invoice_notice: Fattura %{invoice_number} compensata il %{date} + refunded_notice: Rimborsato il %{date} + subject: 'La tua nota di credito da %{billing_entity_name} #%{credit_note_number}' + invoice: + finalized: + download: Scarica la fattura per ulteriori dettagli + due_date: Totale dovuto il %{date} + invoice_from: Fattura da %{billing_entity_name} + invoice_number: Numero Fattura + issue_date: Data Emissione + issued_on: emessa il %{date} + subject: 'La tua fattura da %{billing_entity_name} #%{invoice_number}' + payment_receipt: + created: + payment_receipt_from: Ricevuta di pagamento da %{billing_entity_name} + subject: 'La tua ricevuta di pagamento da %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: Se hai già effettuato il pagamento, ti preghiamo di ignorare questa email. + hello: Ciao %{customer_name}, + pay_amount: Paga il saldo + payment_terms: + one: I nostri termini di pagamento contrattualmente concordati sono %{count} giorno. + other: I nostri termini di pagamento contrattualmente concordati sono %{count} giorni. + zero: I nostri termini di pagamento contrattualmente concordati richiedono il pagamento al momento della generazione della fattura. + remaining_amount: Importo residuo da pagare + reminder_overdue_balance: Questo è un promemoria del team finanziario di %{billing_entity_name} che alcune fatture sono in ritardo. + subject: Il tuo saldo in ritardo da %{billing_entity_name} + thank_you: Grazie. + total_amount_due: L'importo totale dovuto è di %{amount}. + powered_by: Elaborato da + questions: Domande? Contattaci all'indirizzo diff --git a/config/locales/it/invoice.yml b/config/locales/it/invoice.yml new file mode 100644 index 0000000..b4b07ca --- /dev/null +++ b/config/locales/it/invoice.yml @@ -0,0 +1,138 @@ +--- +it: + invoice: + account_holder_name: Nome del titolare del conto + account_number: Numero di conto + account_type: Tipo di conto + all_subscriptions: Tutti gli abbonamenti + all_usage_based_fees: Tutte le tariffe basate sull'utilizzo + already_paid: Già pagato + amount: Importo + amount_with_tax: Importo (incl. tasse) + amount_without_tax: Importo (escl. tasse) + bank_code: Codice banca + bank_name: Nome della banca + bank_transfer_info: I bonifici bancari possono richiedere diversi giorni lavorativi per essere elaborati. Per pagare tramite bonifico bancario, trasferisci i fondi utilizzando le seguenti informazioni bancarie. + bic: BIC + bill_from: Da + bill_to: Fatturare a + branch_code: Codice filiale + branch_name: Nome filiale + breakdown: Ripartizione + breakdown_for_days: Utilizzati %{breakdown_duration} su %{breakdown_total_duration} giorni + breakdown_of: Ripartizione di %{fee_filter_display_name} + clabe: CLABE + conversion_rate: Tasso di conversione + country: Paese + coupons: Coupon + credit_notes: Note di Credito + date_from: Dal + date_to: al + details: Dettagli %{resource} + document_name: Fattura + document_tax_name: Fattura fiscale + due_date: Scadenza %{date} + e_invoicing: + credit_card: Pagamento con carta di credito + credit_card_information: Pagamento con carta di credito ricevuto il %{date} + discount_reason: Sconto %{tax_rate} parte + payment_information: "%{payment_label} di %{currency} %{amount} applicato" + standard_payment: Pagamento standard + fee_prorated: La tariffa è calcolata in base ai giorni di utilizzo, il prezzo unitario visualizzato è una media + fees_from_to_date: Tariffe dal %{from_date} al %{to_date} + free_credits: Crediti gratuiti + from_to_date: Dal %{from_date} al %{to_date} + graduated: + fee_per_unit_for_the_first: Tariffa per unità per le prime %{to} + fee_per_unit_for_the_last: Tariffa per unità per %{from} e oltre + fee_per_unit_for_the_next: Tariffa per unità per le seguenti da %{from} a %{to} + flat_fee_for_the_first: Tariffa fissa per le prime %{to} + flat_fee_for_the_last: Tariffa fissa per %{from} e oltre + flat_fee_for_the_next: Tariffa fissa per le seguenti da %{from} a %{to} + half_year: semestre + iban: IBAN + invoice_number: Numero Fattura + issue_date: Data Emissione + item: Articolo + list_of_charges: Elenco degli addebiti utilizzati dal %{from} al %{to} + month: mese + monthly: Mensile + notice_full: Indipendentemente da quando un evento viene ricevuto, l'unità non è prorata, addebitiamo il prezzo intero. + notice_prorated: Se un'unità viene aggiunta o rimossa durante il piano mensile, calcoliamo il prezzo proporzionato in base al rapporto tra i giorni rimanenti o utilizzati e il numero totale di giorni nel piano. Ad esempio, se restano 15 giorni nel piano, moltiplichiamo il prezzo per 15/%{days_in_month}, e se un'unità è stata utilizzata per 10 giorni, moltiplichiamo il prezzo per 10/%{days_in_month}. + package: + fee_per_package: Tassa per pacchetto + fee_per_package_unit_price: "%{amount} per %{package_size}" + free_units_for_the_first: Unità gratuite per il primo %{count} + paid_invoice: Fattura pagata + paid_tax_invoice: Fattura fiscale pagata + pay_with_bank_transfer: Paga con bonifico bancario + payment_term: Termine di pagamento + payment_term_days: "%{net_payment_term} giorni" + percentage: + adjustment_per_transaction: Adeguamento per min/max per transazione + fee_per_transaction: Tassa fissa per transazione + free_units_per_transaction: + one: Unità gratuite per 1 transazione + other: Unità gratuite per %{count} transazioni + percentage_rate_on_amount: Tariffa sull'importo + powered_by: Elaborato da + prepaid_credit_invoice: Fattura anticipata + prepaid_credits: Crediti prepagati + prepaid_credits_with_value: Crediti prepagati - %{wallet_name} + progressive_billing_credit: Uso già fatturato + quarter: trimestre + quarterly: Trimestrale + reached_usage_threshold: Questa fatturazione progressiva è generata poiché il tuo utilizzo cumulato ha raggiunto %{usage_amount}, superando la soglia di %{threshold_amount}. + routing_number: Numero di routing + see_breakdown: Vedere la ripartizione per l'unità totale + self_billed: + document_name: Fattura di autofatturazione + footer: Questa fattura di autofatturazione è stata emessa dal cliente per conto del partner, con il suo consenso. Il partner ha accettato di non emettere una propria fattura per questa transazione. + semiannual: Semestrale + sort_code: Codice di ordinamento + sub_total: Subtotale + sub_total_with_tax: Subtotale (incl. tasse) + sub_total_without_tax: Subtotale (escl. tasse) + subscription: Abbonamento + subscription_interval: Abbonamento %{plan_interval} - %{plan_name} + swift_code: Codice SWIFT + tax: Tasse + tax_identification_number: 'Identificativo fiscale: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% su %{amount})" + tax_name_only: + customer_exempt: Il cliente è esente da tasse + juris_has_no_tax: Nessuna tassa + juris_not_taxed: Nessuna tassa + not_collecting: Nessuna tassa + reverse_charge: Inversione contabile - Responsabilità fiscale trasferita al cliente + transaction_exempt: Il cliente è esente da tasse + unknown_taxation: Sconosciuto + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Aliquota fiscale + taxes: + fr_tax_exempt: Il cliente è esente da tasse secondo l'articolo 259-1 del Codice Generale delle Imposte francese. + reverse_charge: Responsabilità fiscale trasferita al cliente. + tax_exempt: Il cliente è esente da tasse. + total: Totale + total_credits: Crediti totali + total_credits_with_value: 'Crediti totali: %{credit_amount} crediti' + total_due: Totale dovuto + total_due_amount: Importo dovuto + total_events: 'Eventi totali: %{count}' + total_paid_amount: Importo pagato + total_unit: 'Unità totale: %{units}' + total_unit_interval: 'Unità totale: %{events_count} eventi per %{units}' + true_up_details: Spesa minima di %{min_amount} proporzionata sui giorni di utilizzo + true_up_metric: "%{metric} - Allineamento" + unit: Unità + unit_price: Prezzo unitario + units: Unità + units_prorated_per_period: Unità proporzionate al secondo per %{period} + usage_based_fees: Tariffe basate sull'utilizzo + volume: + fee_per_unit: Tassa per unità + flat_fee_for_all_units: Canone forfettario per tutte le unità + week: settimana + weekly: Settimanale + year: anno + yearly: Annuale diff --git a/config/locales/it/payment_receipt.yml b/config/locales/it/payment_receipt.yml new file mode 100644 index 0000000..ab8ade2 --- /dev/null +++ b/config/locales/it/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +it: + payment_receipt: + document_name: Ricevuta + download_invoice: Scarica fattura + download_receipt: Scarica ricevuta + number: Numero della ricevuta + paid_on: 'Pagato il %{date} (Rimanente da pagare: %{total_due_amount})' + payment_date: Pagato il + payment_method: Mode de paiement diff --git a/config/locales/it/webhook_endpoint.yml b/config/locales/it/webhook_endpoint.yml new file mode 100644 index 0000000..5effc33 --- /dev/null +++ b/config/locales/it/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +it: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: È stato raggiunto il numero massimo di endpoint webhook diff --git a/config/locales/nb.yml b/config/locales/nb.yml new file mode 100644 index 0000000..f5cb8df --- /dev/null +++ b/config/locales/nb.yml @@ -0,0 +1,58 @@ +--- +nb: + date: + abbr_day_names: + - Søn + - Man + - Tir + - Ons + - Tor + - Fre + - Lør + abbr_month_names: + - + - jan. + - feb. + - mars + - apr. + - mai + - juni + - juli + - aug. + - sep. + - okt. + - nov. + - des. + day_names: + - Søndag + - Mandag + - Tirsdag + - Onsdag + - Torsdag + - Fredag + - Lørdag + formats: + default: "%d. %b %Y" + month_names: + - + - Januar + - Februar + - Mars + - April + - Mai + - Juni + - Juli + - August + - September + - Oktober + - November + - Desember + order: + - :year + - :month + - :day + money: + custom_format: "%{iso_code} %n" + decimal_mark: "," + format: "%u%n" + thousands_separator: " " diff --git a/config/locales/nb/commitment.yml b/config/locales/nb/commitment.yml new file mode 100644 index 0000000..453da3b --- /dev/null +++ b/config/locales/nb/commitment.yml @@ -0,0 +1,5 @@ +--- +nb: + commitment: + minimum: + name: Norge Minimumsforpliktelse diff --git a/config/locales/nb/credit_note.yml b/config/locales/nb/credit_note.yml new file mode 100644 index 0000000..17534cb --- /dev/null +++ b/config/locales/nb/credit_note.yml @@ -0,0 +1,28 @@ +--- +nb: + credit_note: + amount: Beløp (ekskl. MVA) + coupon_adjustment: Kuponger + credit_from: Fra + credit_note_number: Kreditnota nummer + credit_to: Til + credited_notice: Kreditert til kontobalanse %{issuing_date} + credited_on_customer_balance: Kreditert til kontobalanse + document_name: Kreditnota + invoice_number: Fakturanummer + issue_date: Dato + issued_notice: Utstedt den %{issuing_date} + item: Vare + offset_invoice: Utligning på faktura %{invoice_number} + offset_invoice_notice: Faktura %{invoice_number} utlignet den %{issuing_date} + powered_by: Fakturering drevet av + prepaid_credits_for_wallet: Forhåndsbetalte kreditter - %{wallet_name} + refunded: Refundert + refunded_notice: Refundert %{issuing_date} + self_billed: + footer: Denne egenfakturaen ble utstedt av kunden på vegne av partneren, med deres samtykke. Partneren har gått med på å ikke utstede sin egen faktura for denne transaksjonen. + sub_total_without_tax: Sub total (ekskl. MVA) + subscription: Abonnement + tax: "%{name} (%{rate}% on %{amount})" + tax_rate: Tax rate + total: Beløp diff --git a/config/locales/nb/email.yml b/config/locales/nb/email.yml new file mode 100644 index 0000000..0d2f482 --- /dev/null +++ b/config/locales/nb/email.yml @@ -0,0 +1,44 @@ +--- +nb: + email: + credit_note: + created: + credit_note_from: Kreditnota fra %{billing_entity_name} + credit_note_number: Kreditnota nummer + credited_notice: Kreditert til kontobalanse %{date} + download: Last ned kreditnota for detaljer + invoice_number: Faktura nummer + issue_date: Dato + issued_notice: Utstedt den %{date} + offset_invoice_notice: Faktura %{invoice_number} utlignet den %{date} + refunded_notice: Refundert %{date} + subject: 'Kreditnota din fra %{billing_entity_name} #%{credit_note_number}' + invoice: + finalized: + download: Last ned faktura for detaljer + due_date: Forfallsdato %{date} + invoice_from: Faktura fra %{billing_entity_name} + invoice_number: Fakturanummer + issue_date: Dato + issued_on: utstedt den %{date} + subject: 'Faktura fra %{billing_entity_name} #%{invoice_number}' + payment_receipt: + created: + payment_receipt_from: Betalingskvittering fra %{billing_entity_name} + subject: 'Din betalingskvittering fra %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: Hvis du allerede har foretatt betalingen, vennligst se bort fra denne e-posten. + hello: Hei %{customer_name}, + pay_amount: Betal saldoen + payment_terms: + one: Våre avtalte betalingsbetingelser er %{count} dag. + other: Våre avtalte betalingsbetingelser er %{count} dager. + zero: Våre avtalte betalingsbetingelser krever betaling ved fakturautstedelse. + remaining_amount: Gjenstående beløp å betale + reminder_overdue_balance: Dette er en påminnelse fra økonomiavdelingen i %{billing_entity_name} om at noen fakturaer er forfalt. + subject: Din forfalte saldo fra %{billing_entity_name} + thank_you: Takk. + total_amount_due: Totalbeløpet som forfaller er %{amount}. + powered_by: Fakturering drevet av + questions: Har du spørsmål? Kontakt oss på diff --git a/config/locales/nb/invoice.yml b/config/locales/nb/invoice.yml new file mode 100644 index 0000000..c9466fe --- /dev/null +++ b/config/locales/nb/invoice.yml @@ -0,0 +1,138 @@ +--- +nb: + invoice: + account_holder_name: Kontoinnehaver + account_number: Kontonummer + account_type: Kontotype + all_subscriptions: Alle Abonnement + all_usage_based_fees: Alle bruksbaserte kostnader + already_paid: Allerede betalt + amount: Beløp + amount_with_tax: Beløp (inkl. MVA) + amount_without_tax: Beløp (ekskl. MVA) + bank_code: Bankkode + bank_name: Banknavn + bank_transfer_info: Bankoverføringer kan ta flere virkedager å behandle. For å betale med bankoverføring, overfør midler ved å bruke følgende bankinformasjon. + bic: BIC + bill_from: Fra + bill_to: Til + branch_code: Filialkode + branch_name: Filialnavn + breakdown: Oversikt + breakdown_for_days: "%{breakdown_duration} av %{breakdown_total_duration} dager" + breakdown_of: Fordeling av %{fee_filter_display_name} + clabe: CLABE + conversion_rate: Valutakurs + country: Land + coupons: Kuponger + credit_notes: Kreditnotaer + date_from: Fra + date_to: til + details: "%{resource} detaljer" + document_name: Faktura + document_tax_name: Skattefaktura + due_date: Forfallsdato %{date} + e_invoicing: + credit_card: Kredittkortbetaling + credit_card_information: Kredittkortbetaling mottatt %{date} + discount_reason: Rabatt %{tax_rate} andel + payment_information: "%{payment_label} på %{currency} %{amount} brukt" + standard_payment: Standardbetaling + fee_prorated: Avgiften beregnes forholdsmessig etter brukerdager, den viste enhetsprisen er et gjennomsnitt + fees_from_to_date: Gebyrer fra %{from_date} til %{to_date} + free_credits: Gratis kreditter + from_to_date: Fra %{from_date} til %{to_date} + graduated: + fee_per_unit_for_the_first: Avgift per enhet for de første %{to} + fee_per_unit_for_the_last: Avgift per enhet for %{from} og mer + fee_per_unit_for_the_next: Avgift per enhet for de neste %{from} til %{to} + flat_fee_for_the_first: Fast avgift for de første %{to} + flat_fee_for_the_last: Fast avgift for %{from} og mer + flat_fee_for_the_next: Fast avgift for de neste %{from} til %{to} + half_year: halvår + iban: IBAN + invoice_number: Fakturanummer + issue_date: Dato + item: Vare + list_of_charges: Oversikt over kostnader fra %{from} til %{to} + month: måned + monthly: Månedlig + notice_full: Uavhengig av når en hendelse mottas, blir enheten ikke forholdsmessig priset, vi belaster full pris. + notice_prorated: Hvis en enhet legges til eller fjernes i løpet av faktureringsperioden, beregner vi den proporsjonale prisen basert på forholdet mellom gjenværende eller brukte dager og det totale antallet dager i planen. For eksempel, hvis det er 15 dager igjen i planen, multipliserer vi prisen med 15/%{days_in_month}, og hvis en enhet ble brukt i 10 dager, multipliserer vi prisen med 10/%{days_in_month}. + package: + fee_per_package: Gebyr per pakke + fee_per_package_unit_price: "%{amount} per %{package_size}" + free_units_for_the_first: Gratis enheter for de første %{count} + paid_invoice: Betalt faktura + paid_tax_invoice: Betalt skattefaktura + pay_with_bank_transfer: Betal med bankoverføring + payment_term: Betalingsperiode + payment_term_days: "%{net_payment_term} dager" + percentage: + adjustment_per_transaction: Justering for min/maks per transaksjon + fee_per_transaction: Fast avgift per transaksjon + free_units_per_transaction: + one: Gratis enheter for 1 transaksjon + other: Gratis enheter for %{count} transaksjoner + percentage_rate_on_amount: Satsen på beløpet + powered_by: Fakturering drevet av + prepaid_credit_invoice: Forskuddsfaktura + prepaid_credits: Forskuddsbetalte kreditter + prepaid_credits_with_value: Forhåndsbetalte kreditter - %{wallet_name} + progressive_billing_credit: Bruk allerede fakturert + quarter: kvartal + quarterly: Kvartalsvis + reached_usage_threshold: Denne progressive faktureringen er generert fordi din akkumulerte bruk har nådd %{usage_amount}, og overskredet terskelen på %{threshold_amount}. + routing_number: Routing-nummer + see_breakdown: Se oversikt for antall enheter + self_billed: + document_name: Egenfakturering + footer: Denne egenfakturaen ble utstedt av kunden på vegne av partneren, med deres samtykke. Partneren har gått med på å ikke utstede sin egen faktura for denne transaksjonen. + semiannual: Halvårlig + sort_code: Sorteringskode + sub_total: Sub total + sub_total_with_tax: Sub total (inkl. MVA) + sub_total_without_tax: Sub total (ekskl. MVA) + subscription: Abonnement + subscription_interval: "%{plan_interval} abonnement - %{plan_name}" + swift_code: SWIFT-kode + tax: Merverdiavgift + tax_identification_number: 'Skatte-ID: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% on %{amount})" + tax_name_only: + customer_exempt: Kunden er fritatt for avgift + juris_has_no_tax: Ingen skatt + juris_not_taxed: Ingen skatt + not_collecting: Ingen skatt + reverse_charge: Omvendt avgiftsplikt - Skatteplikt overført til kunden + transaction_exempt: Kunden er fritatt for avgift + unknown_taxation: Ukjent + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Skattesats + taxes: + fr_tax_exempt: Kunden er skattefri i henhold til artikkel 259-1 i den franske skatteforvaltningsloven. + reverse_charge: Skatteansvar overført til kunden. + tax_exempt: Kunden er skattefri. + total: Totalt + total_credits: Antall kreditter + total_credits_with_value: 'Antall kreditter: %{credit_amount} kreditter' + total_due: Å betale + total_due_amount: Skyldig beløp + total_events: 'Totalt antall hendelser: %{count}' + total_paid_amount: Betalt beløp + total_unit: 'Antall enheter: %{units}' + total_unit_interval: 'Antall enheter: %{events_count} hendelser for %{units}' + true_up_details: Minimumsutgift på %{min_amount} beregnet etter antall brukte dager + true_up_metric: "%{metric} - Tilpasningskostnader" + unit: Enhet + unit_price: Enhetspris + units: Enheter + units_prorated_per_period: Enheter proratert per sekund per %{period} + usage_based_fees: Bruksbaserte kostnader + volume: + fee_per_unit: Gebyr per enhet + flat_fee_for_all_units: Fast avgift for alle enheter + week: uke + weekly: Ukentlig + year: år + yearly: Årlig diff --git a/config/locales/nb/payment_receipt.yml b/config/locales/nb/payment_receipt.yml new file mode 100644 index 0000000..2a82fea --- /dev/null +++ b/config/locales/nb/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +nb: + payment_receipt: + document_name: Kvittering + download_invoice: Last ned faktura + download_receipt: Last ned kvittering + number: Kvitteringsnummer + paid_on: 'Betalt den %{date} (Gjenstående å betale: %{total_due_amount})' + payment_date: Betalt den + payment_method: Betalingsmetode diff --git a/config/locales/nb/webhook_endpoint.yml b/config/locales/nb/webhook_endpoint.yml new file mode 100644 index 0000000..a17a470 --- /dev/null +++ b/config/locales/nb/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +nb: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: Maximum number of webhook endpoints was reached diff --git a/config/locales/pt-BR.yml b/config/locales/pt-BR.yml new file mode 100644 index 0000000..5eeae6e --- /dev/null +++ b/config/locales/pt-BR.yml @@ -0,0 +1,58 @@ +--- +pt-BR: + date: + abbr_day_names: + - Dom + - Seg + - Ter + - Qua + - Qui + - Sex + - Sáb + abbr_month_names: + - + - jan. + - fev. + - mar. + - abr. + - mai. + - jun. + - jul. + - ago. + - set. + - out. + - nov. + - dez. + day_names: + - Domingo + - Segunda-feira + - Terça-feira + - Quarta-feira + - Quinta-feira + - Sexta-feira + - Sábado + formats: + default: "%d/%m/%Y" + month_names: + - + - Janeiro + - Fevereiro + - Março + - Abril + - Maio + - Junho + - Julho + - Agosto + - Setembro + - Outubro + - Novembro + - Dezembro + order: + - :day + - :month + - :year + money: + custom_format: "%{iso_code} %n" + decimal_mark: "," + format: "%u %n" + thousands_separator: "." diff --git a/config/locales/pt-BR/commitment.yml b/config/locales/pt-BR/commitment.yml new file mode 100644 index 0000000..7e57994 --- /dev/null +++ b/config/locales/pt-BR/commitment.yml @@ -0,0 +1,5 @@ +--- +pt-BR: + commitment: + minimum: + name: Comprometimento mínimo diff --git a/config/locales/pt-BR/credit_note.yml b/config/locales/pt-BR/credit_note.yml new file mode 100644 index 0000000..f967fb6 --- /dev/null +++ b/config/locales/pt-BR/credit_note.yml @@ -0,0 +1,28 @@ +--- +pt-BR: + credit_note: + amount: Valor (excl. imposto) + coupon_adjustment: Cupons + credit_from: De + credit_note_number: Número da nota de crédito + credit_to: Crédito para + credited_notice: Creditado no saldo do cliente em %{issuing_date} + credited_on_customer_balance: Creditado no saldo do cliente + document_name: Nota de crédito + invoice_number: Número da fatura + issue_date: Data de emissão + issued_notice: Emitida em %{issuing_date} + item: Item + offset_invoice: Compensação na fatura %{invoice_number} + offset_invoice_notice: Fatura %{invoice_number} compensada em %{issuing_date} + powered_by: Desenvolvido por + prepaid_credits_for_wallet: Créditos pré-pagos - %{wallet_name} + refunded: Reembolsado + refunded_notice: Reembolsado em %{issuing_date} + self_billed: + footer: Esta nota de crédito em uma fatura de autofaturamento foi emitida pelo cliente em nome do parceiro, com seu consentimento. O parceiro concordou em não emitir sua própria nota de crédito para esta transação. + sub_total_without_tax: Subtotal (excl. imposto) + subscription: Assinatura + tax: "%{name} (%{rate}% sobre %{amount})" + tax_rate: Taxa de imposto + total: Total diff --git a/config/locales/pt-BR/email.yml b/config/locales/pt-BR/email.yml new file mode 100644 index 0000000..37a4a7f --- /dev/null +++ b/config/locales/pt-BR/email.yml @@ -0,0 +1,77 @@ +--- +pt-BR: + email: + api_key: + created: + access_warning: No entanto, se você não autorizou esta alteração, por favor redefina sua senha e exclua a API Key imediatamente para proteger sua conta. + change_notice: Se alguém da sua equipe iniciou isso, você pode começar a usar esta API Key para automatizar seu processo de faturamento. + email_info: Você está recebendo este email porque uma nova API Key foi criada na sua instância do Lago, e você tem privilégios de administrador. + greetings: Olá, + key_has_been_created: Uma nova API Key foi criada com sucesso para sua organização, %{organization_name}. + subject: Uma nova API Key do Lago foi criada + destroyed: + access_warning: Se você não autorizou esta solicitação, por favor redefina sua senha imediatamente para proteger sua conta. + change_notice: Se alguém da sua equipe iniciou esta alteração, nenhuma ação adicional é necessária. + email_info: Você está recebendo este email porque uma API Key foi excluída na sua instância do Lago, e você tem privilégios de administrador. + greetings: Olá, + key_has_been_destroyed: Uma API Key foi excluída para sua organização, %{organization_name}. + subject: Uma API Key do Lago foi excluída + rotated: + access_warning: Se você não autorizou esta solicitação, por favor redefina sua senha e role sua API Key imediatamente para proteger sua conta. + change_notice: Se alguém da sua equipe iniciou esta alteração, nenhuma ação adicional é necessária. Para manter seu faturamento funcionando suavemente, atualize seu aplicativo para referenciar o novo token o mais rápido possível. + email_info: Você está recebendo este email porque uma API Key foi rotacionada na sua instância do Lago, e você tem privilégios de administrador. + greetings: Olá, + key_has_been_rotated: A API Key da %{organization_name} foi rotacionada! + subject: Sua API Key do Lago foi rotacionada + credit_note: + created: + credit_note_from: Nota de Crédito de %{billing_entity_name} + credit_note_number: Número da nota de crédito + credited_notice: Creditado no saldo do cliente em %{date} + download: Baixar nota de crédito para detalhes + invoice_number: Número da fatura + issue_date: Data de emissão + issued_notice: Emitida em %{date} + offset_invoice_notice: Fatura %{invoice_number} compensada em %{date} + refunded_notice: Reembolsado em %{date} + subject: 'Sua nota de crédito de %{billing_entity_name} #%{credit_note_number}' + data_export: + completed: + fallback_text: Se o link expirou, por favor gere um novo no seu Dashboard. + greetings: Olá + intro: Sua exportação de %{resource_type} está pronta! Você pode baixá-la usando o link abaixo, que estará disponível por 7 dias. + lago_team: A Equipe Lago + main_cta_label: Baixar exportação + subject: Sua exportação de %{resource_type} do Lago está pronta! + thanks: Obrigado, + invoice: + finalized: + download: Baixar fatura para detalhes + due_date: total devido %{date} + invoice_from: Fatura de %{billing_entity_name} + invoice_number: Número da Fatura + issue_date: Data de Emissão + issued_on: emitida em %{date} + subject: 'Sua Fatura de %{billing_entity_name} #%{invoice_number}' + password_reset: + subject: Redefinir sua senha do Lago + payment_receipt: + created: + payment_receipt_from: Recibo de pagamento de %{billing_entity_name} + subject: 'Seu recibo de pagamento de %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: Se você já fez o pagamento, por favor desconsidere este email. + hello: Olá %{customer_name}, + pay_amount: Pagar saldo + payment_terms: + one: Nossos termos de pagamento contratualmente acordados são de %{count} dia. + other: Nossos termos de pagamento contratualmente acordados são de %{count} dias. + zero: Nossos termos de pagamento contratualmente acordados exigem pagamento na geração da fatura. + remaining_amount: Valor restante a pagar + reminder_overdue_balance: Este é um lembrete da equipe financeira de %{billing_entity_name} de que algumas faturas estão em atraso. + subject: Seu saldo em atraso de %{billing_entity_name} + thank_you: Obrigado. + total_amount_due: O valor total devido é %{amount}. + powered_by: Desenvolvido por + questions: Dúvidas? Entre em contato conosco em diff --git a/config/locales/pt-BR/invoice.yml b/config/locales/pt-BR/invoice.yml new file mode 100644 index 0000000..09e2d03 --- /dev/null +++ b/config/locales/pt-BR/invoice.yml @@ -0,0 +1,138 @@ +--- +pt-BR: + invoice: + account_holder_name: Nome do titular da conta + account_number: Número da conta + account_type: Tipo de conta + all_subscriptions: Todas as Assinaturas + all_usage_based_fees: Todas as taxas baseadas em uso + already_paid: Já pago + amount: Valor + amount_with_tax: Valor (incl. imposto) + amount_without_tax: Valor (excl. imposto) + bank_code: Código do banco + bank_name: Nome do banco + bank_transfer_info: Transferências bancárias podem levar alguns dias úteis para serem processadas. Para pagar via transferência bancária, transfira os fundos usando as seguintes informações bancárias. + bic: BIC + bill_from: De + bill_to: Faturar para + branch_code: Código da agência + branch_name: Nome da agência + breakdown: Detalhamento + breakdown_for_days: Usado %{breakdown_duration} de %{breakdown_total_duration} dias + breakdown_of: Detalhamento de %{fee_filter_display_name} + charges_paid_in_advance: Cobranças pagas antecipadamente + clabe: CLABE + country: País + coupons: Cupons + credit_notes: Notas de crédito + date_from: De + date_to: até + details: Detalhes de %{resource} + document_name: Fatura + document_tax_name: Nota fiscal + due_date: Vencimento %{date} + e_invoicing: + credit_card: Pagamento com cartão de crédito + credit_card_information: Pago com cartão de crédito em %{date} + discount_reason: Desconto da parte de %{tax_rate} + payment_information: "%{payment_label} de %{currency} %{amount} aplicado" + standard_payment: Pagamento padrão + fee_prorated: A taxa é proporcional aos dias de uso, o preço unitário exibido é uma média + fees_from_to_date: Taxas de %{from_date} até %{to_date} + free_credits: Créditos gratuitos + from_to_date: De %{from_date} até %{to_date} + graduated: + fee_per_unit_for_the_first: Taxa por unidade para os primeiros %{to} + fee_per_unit_for_the_last: Taxa por unidade para %{from} e acima + fee_per_unit_for_the_next: Taxa por unidade para os próximos %{from} até %{to} + flat_fee_for_the_first: Taxa fixa para os primeiros %{to} + flat_fee_for_the_last: Taxa fixa para %{from} e acima + flat_fee_for_the_next: Taxa fixa para os próximos %{from} até %{to} + half_year: semestre + iban: IBAN + invoice_number: Número da Fatura + issue_date: Data de Emissão + item: Item + list_of_charges: Lista de cobranças usadas de %{from} até %{to} + month: mês + monthly: Mensal + notice_full: Independentemente de quando um evento é recebido, a unidade não é proporcional, cobramos o preço total. + notice_prorated: Se uma unidade é adicionada ou removida durante o plano mensal, calculamos o preço proporcional com base na proporção de dias restantes ou usados em relação ao número total de dias no plano. Por exemplo, se restam 15 dias no plano, multiplicamos o preço por 15/%{days_in_month}, e se uma unidade foi usada por 10 dias, multiplicamos o preço por 10/%{days_in_month}. + package: + fee_per_package: Taxa por pacote + fee_per_package_unit_price: "%{amount} por %{package_size}" + free_units_for_the_first: Unidades gratuitas para os primeiros %{count} + paid_invoice: Fatura paga + paid_tax_invoice: Nota fiscal paga + pay_with_bank_transfer: Pagar com transferência bancária + payment_term: Prazo de pagamento + payment_term_days: "%{net_payment_term} dias" + percentage: + adjustment_per_transaction: Ajuste para mín/máx por transação + fee_per_transaction: Taxa fixa por transação + free_units_per_transaction: + one: Unidades gratuitas para 1 transação + other: Unidades gratuitas para %{count} transações + percentage_rate_on_amount: Taxa sobre o valor + powered_by: Desenvolvido por + prepaid_credit_invoice: Fatura antecipada + prepaid_credits: Créditos pré-pagos + prepaid_credits_with_value: Créditos pré-pagos - %{wallet_name} + progressive_billing_credit: Uso já faturado + quarter: trimestre + quarterly: Trimestral + reached_usage_threshold: Esta cobrança progressiva é gerada porque seu uso cumulativo atingiu %{usage_amount}, excedendo o limite de %{threshold_amount}. + routing_number: Número de roteamento + see_breakdown: Ver detalhamento para unidade total + self_billed: + document_name: Fatura de autofaturamento + footer: Esta fatura de autofaturamento foi emitida pelo cliente em nome do parceiro, com seu consentimento. O parceiro concordou em não emitir sua própria fatura para esta transação. + semiannual: Semestral + sort_code: Código de classificação + sub_total: Subtotal + sub_total_with_tax: Subtotal (incl. imposto) + sub_total_without_tax: Subtotal (excl. imposto) + subscription: Assinatura + subscription_interval: Assinatura %{plan_interval} - %{plan_name} + swift_code: Código SWIFT + tax: Imposto + tax_identification_number: 'ID do Imposto: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% sobre %{amount})" + tax_name_only: + customer_exempt: Cliente isento de impostos + juris_has_no_tax: Sem imposto + juris_not_taxed: Sem imposto + not_collecting: Sem imposto + reverse_charge: Cobrança reversa - Responsabilidade fiscal transferida para o cliente + transaction_exempt: Cliente isento de impostos + unknown_taxation: Desconhecido + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Taxa de imposto + taxes: + fr_tax_exempt: Cliente isento de impostos de acordo com o artigo 259-1 do Código Geral Tributário Francês. + reverse_charge: Cobrança reversa - Responsabilidade fiscal transferida para o cliente. + tax_exempt: Cliente isento de impostos. + total: Total + total_credits: Total de créditos + total_credits_with_value: 'Total de créditos: %{credit_amount} créditos' + total_due: Total devido + total_due_amount: Valor devido + total_events: 'Total de eventos: %{count}' + total_paid_amount: Valor pago + total_unit: 'Unidade total: %{units}' + total_unit_interval: 'Unidade total: %{events_count} eventos para %{units}' + true_up_details: Gasto mínimo de %{min_amount} proporcional aos dias de uso + true_up_metric: "%{metric} - Ajuste" + unit: Unidade + unit_price: Preço unitário + units: Unidades + units_prorated_per_period: Unidades proporcionais por segundo por %{period} + usage_based_fees: Taxas baseadas em uso + volume: + fee_per_unit: Taxa por unidade + flat_fee_for_all_units: Taxa fixa para todas as unidades + week: semana + weekly: Semanal + year: ano + yearly: Anual diff --git a/config/locales/pt-BR/password_reset.yml b/config/locales/pt-BR/password_reset.yml new file mode 100644 index 0000000..dc116e9 --- /dev/null +++ b/config/locales/pt-BR/password_reset.yml @@ -0,0 +1,10 @@ +--- +pt-BR: + password_reset: + email_info: Você está recebendo este email porque uma redefinição de senha foi solicitada para sua conta. + fallback_text: 'Se você não usar este link dentro de 30 minutos, ele expirará. Para obter um novo link de redefinição de senha, visite: ' + greetings: Olá + intro: 'Soubemos que você perdeu sua senha do Lago. Lamentamos isso! Mas não se preocupe! Você pode usar o botão a seguir para redefinir sua senha:' + lago_team: A Equipe Lago + main_cta_label: Redefinir sua senha + thanks: Obrigado, diff --git a/config/locales/pt-BR/payment_receipt.yml b/config/locales/pt-BR/payment_receipt.yml new file mode 100644 index 0000000..5cd13ad --- /dev/null +++ b/config/locales/pt-BR/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +pt-BR: + payment_receipt: + document_name: Recibo + download_invoice: Baixar fatura + download_receipt: Baixar recibo + number: Número do recibo + paid_on: 'Pago em %{date} (Restante a pagar: %{total_due_amount})' + payment_date: Pago em + payment_method: Método de pagamento diff --git a/config/locales/pt-BR/webhook_endpoint.yml b/config/locales/pt-BR/webhook_endpoint.yml new file mode 100644 index 0000000..37e4483 --- /dev/null +++ b/config/locales/pt-BR/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +pt-BR: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: Número máximo de endpoints de webhook foi atingido diff --git a/config/locales/sv.yml b/config/locales/sv.yml new file mode 100644 index 0000000..0e92c86 --- /dev/null +++ b/config/locales/sv.yml @@ -0,0 +1,58 @@ +--- +sv: + date: + abbr_day_names: + - sön + - mån + - tis + - ons + - tor + - fre + - lör + abbr_month_names: + - + - jan. + - feb. + - mars + - apr. + - maj + - juni + - juli + - aug. + - sep. + - okt. + - nov. + - dec. + day_names: + - söndag + - måndag + - tisdag + - onsdag + - torsdag + - fredag + - lördag + formats: + default: "%d %b %Y" + month_names: + - + - januari + - februari + - mars + - april + - maj + - juni + - juli + - augusti + - september + - oktober + - november + - december + order: + - :year + - :month + - :day + money: + custom_format: "%n %{iso_code}" + decimal_mark: "," + format: "%n %u" + thousands_separator: " " diff --git a/config/locales/sv/commitment.yml b/config/locales/sv/commitment.yml new file mode 100644 index 0000000..a814f8b --- /dev/null +++ b/config/locales/sv/commitment.yml @@ -0,0 +1,5 @@ +--- +sv: + commitment: + minimum: + name: Sverige Minsta åtagande diff --git a/config/locales/sv/credit_note.yml b/config/locales/sv/credit_note.yml new file mode 100644 index 0000000..5386397 --- /dev/null +++ b/config/locales/sv/credit_note.yml @@ -0,0 +1,28 @@ +--- +sv: + credit_note: + amount: Belopp (exkl. moms) + coupon_adjustment: Rabatt + credit_from: Avsändare + credit_note_number: Kreditfakturanummer + credit_to: Mottagare + credited_notice: Krediterad till kundens förbetalda kontobalans den %{issuing_date} + credited_on_customer_balance: Krediterad till kundens förbetalda kontobalans + document_name: Kreditfaktura + invoice_number: Fakturanummer + issue_date: Fakturadatum + issued_notice: Utfärdad den %{issuing_date} + item: Artikel + offset_invoice: Kvittning på faktura %{invoice_number} + offset_invoice_notice: Faktura %{invoice_number} kvittad den %{issuing_date} + powered_by: Genererad av + prepaid_credits_for_wallet: Förbetald kontobalans - %{wallet_name} + refunded: Återbetald + refunded_notice: Återbetald den %{issuing_date} + self_billed: + footer: Denna självfaktura har utfärdats av kunden för partnerns räkning, med deras samtycke. Partnern har gått med på att inte utfärda en egen faktura för denna transaktion. + sub_total_without_tax: Delsumma (exkl. moms) + subscription: Prenumeration + tax: "%{name} (%{rate}% på %{amount})" + tax_rate: Momssats + total: Att betala diff --git a/config/locales/sv/email.yml b/config/locales/sv/email.yml new file mode 100644 index 0000000..e18f7d8 --- /dev/null +++ b/config/locales/sv/email.yml @@ -0,0 +1,44 @@ +--- +sv: + email: + credit_note: + created: + credit_note_from: Kreditfaktura från %{billing_entity_name} + credit_note_number: Kreditfakturanummer + credited_notice: Krediterad till kundens förbetalda kontobalans den %{date} + download: Ladda ner kreditfakturan för ytterligare information + invoice_number: Fakturanummer + issue_date: Fakturadatum + issued_notice: Utfärdad den %{date} + offset_invoice_notice: Faktura %{invoice_number} kvittad den %{date} + refunded_notice: Återbetald den %{date} + subject: 'Din kreditfaktura från %{billing_entity_name} #%{credit_note_number}' + invoice: + finalized: + download: Ladda ner fakturan för ytterligare information + due_date: Förfaller %{date} + invoice_from: Faktura från %{billing_entity_name} + invoice_number: Fakturanummer + issue_date: Fakturadatum + issued_on: utfärdad den %{date} + subject: 'Din faktura från %{billing_entity_name} #%{invoice_number}' + payment_receipt: + created: + payment_receipt_from: Betalningskvitto från %{billing_entity_name} + subject: 'Ditt betalningskvitto från %{billing_entity_name} #%{payment_receipt_number}' + payment_request: + requested: + already_paid: Om du redan har gjort betalningen, vänligen bortse från detta mejl. + hello: Hej %{customer_name}, + pay_amount: Betala saldot + payment_terms: + one: Våra avtalsenliga betalningsvillkor är %{count} dag. + other: Våra avtalsenliga betalningsvillkor är %{count} dagar. + zero: Våra avtalsenliga betalningsvillkor kräver betalning vid fakturautställning. + remaining_amount: Återstående belopp att betala + reminder_overdue_balance: Detta är en påminnelse från ekonomiavdelningen på %{billing_entity_name} om att vissa fakturor är förfallna. + subject: Din förfallna saldo från %{billing_entity_name} + thank_you: Tack. + total_amount_due: Det totala förfallna beloppet är %{amount}. + powered_by_lago: Powered by + questions: Frågor? Kontakta oss på diff --git a/config/locales/sv/invoice.yml b/config/locales/sv/invoice.yml new file mode 100644 index 0000000..d540c9d --- /dev/null +++ b/config/locales/sv/invoice.yml @@ -0,0 +1,136 @@ +--- +sv: + invoice: + account_holder_name: Kontoinnehavarens namn + account_number: Kontonummer + account_type: Kontotyp + all_subscriptions: Alla prenumerationer + all_usage_based_fees: Alla avgifter baserade på användning + already_paid: Redan betalt + amount: Belopp (exkl. moms) + amount_with_tax: Belopp (inkl. moms) + amount_without_tax: Belopp (exkl. moms) + bank_code: Bankkod + bank_name: Banknamn + bank_transfer_info: Banköverföringar kan ta flera arbetsdagar att behandla. För att betala med banköverföring, överför medel med följande bankinformation. + bic: BIC + bill_from: Avsändare + bill_to: Mottagare + branch_code: Filialkod + branch_name: Filialnamn + breakdown: Specifikation + breakdown_for_days: Använt %{breakdown_duration} av %{breakdown_total_duration} dagar + breakdown_of: Specifikation för %{fee_filter_display_name} + clabe: CLABE + conversion_rate: Växelkurs + country: Land + credit_notes: Kreditfakturor + date_from: Avser period + date_to: till och med + details: "%{resource} specifikation”" + document_name: Faktura + document_tax_name: Skattefaktura + due_date: Förfaller %{date} + e_invoicing: + credit_card: Kreditkortsbetalning + credit_card_information: Kreditkortsbetalning mottagen %{date} + discount_reason: Rabatt %{tax_rate} andel + payment_information: "%{payment_label} på %{currency} %{amount} tillämpad" + standard_payment: Standardbetaling + fee_prorated: Avgiften är proraterad för användningsdagar, det visade enhetspriset är ett genomsnitt + fees_from_to_date: Avgifter från %{from_date} till %{to_date} + free_credits: Gratis krediter + from_to_date: Från %{from_date} till %{to_date} + graduated: + fee_per_unit_for_the_first: Avgift per enhet för de första %{to} + fee_per_unit_for_the_last: Avgift per enhet för %{from} och högre + fee_per_unit_for_the_next: Avgift per enhet för de nästa %{from} till %{to} + flat_fee_for_the_first: Fast avgift för de första %{to} + flat_fee_for_the_last: Fast avgift för %{from} och högre + flat_fee_for_the_next: Fast avgift för de nästa %{from} till %{to} + half_year: halvår + iban: IBAN + invoice_number: Fakturanummer + issue_date: Fakturadatum + item: Artikel + list_of_charges: Lista över avgifter från %{from} till %{to} + month: månad + monthly: Månadsvis + notice_full: Oavsett när en händelse tas emot är enheten inte proportionell, fullt pris debiteras fullt pris. + notice_prorated: Skulle en produkt läggas till eller tas bort under månadsperioden beräknar vi det justerade priset baserat på förhållandet mellan kvarvarande eller använda dagar till det totala antalet dagar i perioden. Skulle det till exempel vara 15 dagar kvar i perioden, multipliceras priset med 15/%{days_in_month}, om en produkt nyttjas i 10 dagar, multipliceras priset med 10/%{days_in_month}. + package: + fee_per_package: Avgift per paket + fee_per_package_unit_price: "%{amount} per %{package_size}" + free_units_for_the_first: Gratis enheter för de första %{count} + paid_invoice: Betald faktura + paid_tax_invoice: Betald skattefaktura + pay_with_bank_transfer: Betala med banköverföring + payment_term: Betalningsvillkor + payment_term_days: "%{net_payment_term} dagar" + percentage: + adjustment_per_transaction: Justering för min/max per transaktion + fee_per_transaction: Fast avgift per transaktion + free_units_per_transaction: + one: Gratis enheter för 1 transaktion + other: Gratis enheter för %{count} transaktioner + percentage_rate_on_amount: Satsen på beloppet + powered_by: Genererad av + prepaid_credit_invoice: Förskottsfaktura + prepaid_credits: Förbetald kontobalans + prepaid_credits_with_value: Förbetald kontobalans - %{wallet_name} + progressive_billing_credit: Redan fakturerad användning + quarter: kvartal + quarterly: Kvartalsvis + reached_usage_threshold: Denna progressiva fakturering skapas eftersom din ackumulerade användning har nått %{usage_amount} och överstiger %{threshold_amount} tröskeln. + routing_number: Routingnummer + see_breakdown: Se uppdelning nedan + self_billed: + document_name: Självfakturering + footer: Denna självfaktura har utfärdats av kunden för partnerns räkning, med deras samtycke. Partnern har gått med på att inte utfärda en egen faktura för denna transaktion. + semiannual: Halvårsvis + sort_code: Sorteringskod + sub_total: Delsumma + sub_total_with_tax: Delsumma (inkl. moms) + sub_total_without_tax: Delsumma (exkl. moms) + subscription: Prenumeration + subscription_interval: "%{plan_interval} prenumeration - %{plan_name}" + swift_code: SWIFT-kod + tax: Skatt + tax_identification_number: 'Skatte ID: %{tax_identification_number}' + tax_name: "%{name} (%{rate}% på %{amount})" + tax_name_only: + customer_exempt: Kunden är befriad från moms + juris_has_no_tax: Ingen skatt + juris_not_taxed: Ingen skatt + not_collecting: Ingen skatt + reverse_charge: Omvänd skattskyldighet - Skatteskyldighet överförd till kunden + transaction_exempt: Kunden är befriad från moms + unknown_taxation: Okänd + tax_name_with_details: "%{name} (%{rate}%)" + tax_rate: Momssats + taxes: + fr_tax_exempt: Kunden är skattebefriad enligt artikel 259-1 i den franska skatteförvaltningskoden. + reverse_charge: Skattskyldighet överförd till kunden. + tax_exempt: Kunden är skattebefriad. + total: Totalt + total_credits_with_value: 'Totalt antal krediter med värde: %{credit_amount} krediter' + total_due: Att betala + total_due_amount: Förfallobelopp + total_events: 'Totala händelser: %{count}' + total_paid_amount: Betalt belopp + total_unit: 'Totalt antal enheter: %{units}' + total_unit_interval: 'Totalt antal enheter: %{events_count} händelse(r) för %{units}' + true_up_details: Minsta utgift på %{min_amount} fördelat på användningsdagar + true_up_metric: "%{metric} • Justering" + unit: Antal + unit_price: Styckpris + units: Enheter + units_prorated_per_period: Proportionella enheter per sekund per %{period} + usage_based_fees: Avgifter baserade på användning + volume: + fee_per_unit: Avgift per enhet + flat_fee_for_all_units: Fast avgift för alla enheter + week: vecka + weekly: Veckovis + year: år + yearly: Årsvis diff --git a/config/locales/sv/payment_receipt.yml b/config/locales/sv/payment_receipt.yml new file mode 100644 index 0000000..806a7df --- /dev/null +++ b/config/locales/sv/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +sv: + payment_receipt: + document_name: Kvitto + download_invoice: Ladda ner faktura + download_receipt: Ladda ner kvitto + number: Kvittonummer + paid_on: 'Betald den %{date} (Kvar att betala: %{total_due_amount})' + payment_date: Betald den + payment_method: Betalningsmetod diff --git a/config/locales/sv/webhook_endpoint.yml b/config/locales/sv/webhook_endpoint.yml new file mode 100644 index 0000000..0323178 --- /dev/null +++ b/config/locales/sv/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +sv: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: Maximum number of webhook endpoints was reached diff --git a/config/locales/zh-TW.yml b/config/locales/zh-TW.yml new file mode 100644 index 0000000..dbd7802 --- /dev/null +++ b/config/locales/zh-TW.yml @@ -0,0 +1,57 @@ +--- +zh-TW: + date: + abbr_day_names: + - 週日 + - 週一 + - 週二 + - 週三 + - 週四 + - 週五 + - 週六 + abbr_month_names: + - + - 一月 + - 二月 + - 三月 + - 四月 + - 五月 + - 六月 + - 七月 + - 八月 + - 九月 + - 十月 + - 十一月 + - 十二月 + day_names: + - 星期日 + - 星期一 + - 星期二 + - 星期三 + - 星期四 + - 星期五 + - 星期六 + formats: + default: "%Y年%-m月%-d日" + month_names: + - + - 一月 + - 二月 + - 三月 + - 四月 + - 五月 + - 六月 + - 七月 + - 八月 + - 九月 + - 十月 + - 十一月 + - 十二月 + order: + - :year + - :month + - :day + money: + decimal_mark: "." + format: "%u%n" + thousands_separator: "," diff --git a/config/locales/zh-TW/commitment.yml b/config/locales/zh-TW/commitment.yml new file mode 100644 index 0000000..474a4cc --- /dev/null +++ b/config/locales/zh-TW/commitment.yml @@ -0,0 +1,5 @@ +--- +zh-TW: + commitment: + minimum: + name: 最低承諾 diff --git a/config/locales/zh-TW/credit_note.yml b/config/locales/zh-TW/credit_note.yml new file mode 100644 index 0000000..5d37446 --- /dev/null +++ b/config/locales/zh-TW/credit_note.yml @@ -0,0 +1,28 @@ +--- +zh-TW: + credit_note: + amount: 金額(未稅) + coupon_adjustment: 優惠券 + credit_from: 來自 + credit_note_number: 折讓單號碼 + credit_to: 折讓至 + credited_notice: 已於 %{issuing_date} 入帳至客戶餘額 + credited_on_customer_balance: 已入帳至客戶餘額 + document_name: 折讓單 + invoice_number: 發票號碼 + issue_date: 開立日期 + issued_notice: 開立於 %{issuing_date} + item: 項目 + offset_invoice: 發票 %{invoice_number} 的抵銷 + offset_invoice_notice: 發票 %{invoice_number} 已於 %{issuing_date} 抵銷 + powered_by: 技術提供 + prepaid_credits_for_wallet: 預付點數 - %{wallet_name} + refunded: 已退款 + refunded_notice: 已於 %{issuing_date} 退款 + self_billed: + footer: 本自行開立發票所對應之折讓單係由客戶在合作夥伴同意下,代表合作夥伴開立。合作夥伴已同意不再為此交易自行開立折讓單。 + sub_total_without_tax: 小計(未稅) + subscription: 訂閱 + tax: "%{name}(%{amount} 的 %{rate}%)" + tax_rate: 稅率 + total: 總計 diff --git a/config/locales/zh-TW/email.yml b/config/locales/zh-TW/email.yml new file mode 100644 index 0000000..552a886 --- /dev/null +++ b/config/locales/zh-TW/email.yml @@ -0,0 +1,85 @@ +--- +zh-TW: + email: + api_key: + created: + access_warning: 若您未授權此變更,請立即重設您的密碼並刪除該 API 金鑰,以保護您的帳戶安全。 + change_notice: 若此變更是由您團隊中的成員發起,您現在即可開始使用此 API 金鑰來自動化您的計費流程。 + email_info: 您會收到此電子郵件,是因為在您的 Lago 執行個體中建立了一個新的 API 金鑰,而您具有管理員權限。 + greetings: 您好, + key_has_been_created: 已成功為您的組織 %{organization_name} 建立新的 API 金鑰。 + subject: 已建立新的 Lago API 金鑰 + destroyed: + access_warning: 若您未授權此請求,請立即重設您的密碼以確保帳戶安全。 + change_notice: 若此變更是由您團隊中的成員發起,則無需採取任何進一步行動。 + email_info: 您會收到此電子郵件,是因為在您的 Lago 執行個體中刪除了一個 API 金鑰,而您具有管理員權限。 + greetings: 您好, + key_has_been_destroyed: 已為您的組織 %{organization_name} 刪除一個 API 金鑰。 + subject: 已刪除 Lago API 金鑰 + rotated: + access_warning: 若您未授權此請求,請立即重設您的密碼並輪替您的 API 金鑰,以確保帳戶安全。 + change_notice: 若此變更是由您團隊中的成員發起,則無需採取任何進一步行動。為確保計費順暢運作,請盡快更新您的應用程式以使用新的權杖。 + email_info: 您會收到此電子郵件,是因為在您的 Lago 執行個體中已輪替一個 API 金鑰,而您具有管理員權限。 + greetings: 您好, + key_has_been_rotated: 您的組織 %{organization_name} 的 API 金鑰已完成輪替! + subject: 您的 Lago API 金鑰已完成輪替 + credit_note: + created: + credit_note_from: 來自 %{billing_entity_name} 的折讓單 + credit_note_number: 折讓單號碼 + credited_notice: 已於 %{date} 入帳至客戶餘額 + download: 下載折讓單以查看詳細資訊 + invoice_number: 發票號碼 + issue_date: 開立日期 + issued_notice: 開立於 %{date} + offset_invoice_notice: 發票 %{invoice_number} 已於 %{date} 抵銷 + refunded_notice: 已於 %{date} 退款 + subject: '您來自 %{billing_entity_name} 的折讓單 #%{credit_note_number}' + data_export: + completed: + fallback_text: 若連結已過期,請從您的控制台重新產生一個新的連結。 + greetings: 您好 + intro: 您的 %{resource_type} 匯出檔已準備完成!您可使用下方連結下載,連結有效期限為 7 天。 + lago_team: Lago 團隊 + main_cta_label: 下載匯出檔 + subject: 您的 Lago %{resource_type} 匯出檔已準備完成! + thanks: 謝謝, + invoice: + finalized: + download: 下載發票以查看詳細資訊 + due_date: 應付總額 %{date} + invoice_from: 來自 %{billing_entity_name} 的發票 + invoice_number: 發票號碼 + issue_date: 開立日期 + issued_on: 於 %{date} 開立 + subject: '您來自 %{billing_entity_name} 的發票 #%{invoice_number}' + organization: + authentication_methods_updated: + changes: 使用者 %{user_email} 對登入驗證方式進行了以下變更。 + disabled: 已停用 %{login_method} + email_info: 若您未預期會有此變更,請檢視您的驗證設定。 + enabled: 已啟用 %{login_method} + reasoning: 您會收到此訊息,是因為您是該組織的管理員。 + subject: 您的 Lago 工作區登入方式已更新 + password_reset: + subject: 重設您的 Lago 密碼 + payment_receipt: + created: + payment_receipt_from: 來自 %{billing_entity_name} 的付款收據 + subject: '您來自 %{billing_entity_name} 的付款收據 #%{payment_receipt_number}' + payment_request: + requested: + already_paid: 若您已完成付款,請忽略此電子郵件。 + hello: 您好 %{customer_name}, + pay_amount: 支付餘額 + payment_terms: + one: 依合約約定,我們的付款期限為 %{count} 天。 + other: 依合約約定,我們的付款期限為 %{count} 天。 + zero: 依合約約定,需於發票產生時立即付款。 + remaining_amount: 尚需支付金額 + reminder_overdue_balance: 這是來自 %{billing_entity_name} 財務團隊的提醒,部分發票已逾期。 + subject: 您在 %{billing_entity_name} 的逾期應付金額 + thank_you: 謝謝您。 + total_amount_due: 應付總金額為 %{amount}。 + powered_by: 技術提供 + questions: 有任何問題嗎?請透過以下方式聯絡我們: diff --git a/config/locales/zh-TW/invoice.yml b/config/locales/zh-TW/invoice.yml new file mode 100644 index 0000000..44c5c28 --- /dev/null +++ b/config/locales/zh-TW/invoice.yml @@ -0,0 +1,138 @@ +--- +zh-TW: + invoice: + account_holder_name: 帳戶持有人姓名 + account_number: 帳號 + account_type: 帳戶類型 + all_subscriptions: 所有訂閱 + all_usage_based_fees: 所有依使用量計費的費用 + already_paid: 已付款 + amount: 金額 + amount_with_tax: 金額(含稅) + amount_without_tax: 金額(未稅) + bank_code: 銀行代碼 + bank_name: 銀行名稱 + bank_transfer_info: 銀行轉帳可能需要數個工作天處理。若要以銀行轉帳付款,請使用以下銀行資訊進行轉帳。 + bic: BIC + bill_from: 來自 + bill_to: 帳單開立給 + branch_code: 分行代碼 + branch_name: 分行名稱 + breakdown: 明細 + breakdown_for_days: 已使用 %{breakdown_duration} 天(共 %{breakdown_total_duration} 天) + breakdown_of: "%{fee_filter_display_name} 明細" + charges_paid_in_advance: 預先支付的費用 + clabe: CLABE + conversion_rate: 轉換匯率 + country: 國家 + coupons: 優惠券 + credit_notes: 折讓單 + date_from: 自 + date_to: 至 + details: "%{resource} 詳細資訊" + document_name: 發票 + document_tax_name: 稅務發票 + due_date: 繳款期限 %{date} + e_invoicing: + credit_card: 信用卡付款 + credit_card_information: 已於 %{date} 收到信用卡付款 + discount_reason: 折扣 %{tax_rate} 部分 + payment_information: 已套用 %{currency} %{amount} 的%{payment_label} + standard_payment: 標準付款 + fee_prorated: 費用依使用天數按比例計算,顯示的單價為平均值 + fees_from_to_date: 費用期間:%{from_date} 至 %{to_date} + from_to_date: "%{from_date} 至 %{to_date}" + graduated: + fee_per_unit_for_the_first: 前 %{to} 單位的每單位費用 + fee_per_unit_for_the_last: "%{from} 以上的每單位費用" + fee_per_unit_for_the_next: 接下來 %{from} 至 %{to} 的每單位費用 + flat_fee_for_the_first: 前 %{to} 的固定費用 + flat_fee_for_the_last: "%{from} 以上的固定費用" + flat_fee_for_the_next: 接下來 %{from} 至 %{to} 的固定費用 + half_year: 半年 + iban: IBAN + invoice_number: 發票號碼 + issue_date: 開立日期 + item: 項目 + list_of_charges: "%{from} 至 %{to} 使用的費用清單" + month: 月 + monthly: 每月 + notice_full: 無論事件於何時接收,單位皆不按比例計費,將收取全額費用。 + notice_prorated: 若在每月方案期間新增或移除單位,我們將依剩餘或已使用天數占方案總天數的比例計算按比例費用。例如,在 %{days_in_month} 天的期間內若剩餘 15 天,將收取期間價格的 15/%{days_in_month}。若某單位使用了 10 天,則收取期間價格的 10/%{days_in_month}。 + package: + fee_per_package: 每套件費用 + fee_per_package_unit_price: 每 %{package_size} %{amount} + free_units_for_the_first: 前 %{count} 的免費單位 + paid_invoice: 已付款發票 + paid_tax_invoice: 已付款稅務發票 + pay_with_bank_transfer: 使用銀行轉帳付款 + payment_term: 付款條件 + payment_term_days: "%{net_payment_term} 天" + percentage: + adjustment_per_transaction: 每筆交易的最低/最高調整 + fee_per_transaction: 每筆交易的固定費用 + free_units_per_transaction: + one: 1 筆交易的免費單位 + other: "%{count} 筆交易的免費單位" + percentage_rate_on_amount: 金額的費率 + powered_by: 技術提供 + prepaid_credit_invoice: 預收發票 + prepaid_credits: 預付點數 + prepaid_credits_with_value: 預付點數 - %{wallet_name} + progressive_billing_credit: 已計費的使用量 + quarter: 季 + quarterly: 每季 + reached_usage_threshold: 此累進計費因您的累積使用量已達到 %{usage_amount},超過 %{threshold_amount} 的門檻而產生。 + routing_number: 銀行路由號碼 + see_breakdown: 查看總單位明細 + self_billed: + document_name: 自行開立發票 + footer: 本自行開立發票係由客戶在合作夥伴同意下,代表合作夥伴開立。合作夥伴已同意不再為此交易自行開立發票。 + semiannual: 每半年 + sort_code: 排序代碼 + sub_total: 小計 + sub_total_with_tax: 小計(含稅) + sub_total_without_tax: 小計(未稅) + subscription: 訂閱 + subscription_interval: "%{plan_interval} 訂閱 - %{plan_name}" + swift_code: SWIFT 代碼 + tax: 稅金 + tax_identification_number: 統一編號:%{tax_identification_number} + tax_name: "%{name}(%{amount} 的 %{rate}%)" + tax_name_only: + customer_exempt: 客戶免稅 + juris_has_no_tax: 無稅 + juris_not_taxed: 無稅 + not_collecting: 無稅 + reverse_charge: 反向課稅 - 稅負轉由客戶承擔 + transaction_exempt: 客戶免稅 + unknown_taxation: 未知 + tax_name_with_details: "%{name}(%{rate}%)" + tax_rate: 稅率 + taxes: + fr_tax_exempt: 依據法國稅法總則第 259-1 條,客戶免稅。 + reverse_charge: 反向課稅 - 稅負轉由客戶承擔。 + tax_exempt: 客戶免稅。 + total: 總計 + total_credits: 點數總計 + total_credits_with_value: 點數總計:%{credit_amount} 點 + total_due: 應付總額 + total_due_amount: 應付金額 + total_events: 事件總數:%{count} + total_paid_amount: 已付金額 + total_unit: 單位總數:%{units} + total_unit_interval: 單位總數:%{events_count} 個事件,共 %{units} + true_up_details: 最低消費 %{min_amount},依使用天數按比例計算 + true_up_metric: "%{metric} - 補差額" + unit: 單位 + unit_price: 單價 + units: 單位 + units_prorated_per_period: 每 %{period} 每秒按比例計算的單位 + usage_based_fees: 依使用量計費的費用 + volume: + fee_per_unit: 每單位費用 + flat_fee_for_all_units: 所有單位的固定費用 + week: 週 + weekly: 每週 + year: 年 + yearly: 每年 diff --git a/config/locales/zh-TW/password_reset.yml b/config/locales/zh-TW/password_reset.yml new file mode 100644 index 0000000..63ef841 --- /dev/null +++ b/config/locales/zh-TW/password_reset.yml @@ -0,0 +1,10 @@ +--- +zh-TW: + password_reset: + email_info: 您會收到此電子郵件,是因為有人為您的帳戶申請了重設密碼。 + fallback_text: 若您未在 30 分鐘內使用此連結,連結將會失效。若要取得新的重設密碼連結,請前往: + greetings: 您好 + intro: 我們得知您遺失了您的 Lago 密碼,對此感到抱歉!不過請別擔心!您可以使用下方的按鈕來重設您的密碼: + lago_team: Lago 團隊 + main_cta_label: 重設您的密碼 + thanks: 謝謝, diff --git a/config/locales/zh-TW/payment_receipt.yml b/config/locales/zh-TW/payment_receipt.yml new file mode 100644 index 0000000..4737821 --- /dev/null +++ b/config/locales/zh-TW/payment_receipt.yml @@ -0,0 +1,10 @@ +--- +zh-TW: + payment_receipt: + document_name: 收據 + download_invoice: 下載發票 + download_receipt: 下載收據 + number: 收據號碼 + paid_on: 已於 %{date} 付款(尚需支付:%{total_due_amount}) + payment_date: 付款日期 + payment_method: 付款方式 diff --git a/config/locales/zh-TW/webhook_endpoint.yml b/config/locales/zh-TW/webhook_endpoint.yml new file mode 100644 index 0000000..316d5fa --- /dev/null +++ b/config/locales/zh-TW/webhook_endpoint.yml @@ -0,0 +1,9 @@ +--- +zh-TW: + activerecord: + errors: + models: + webhook_endpoint: + attributes: + base: + exceeded_limit: 已達到 Webhook 端點的最大數量限制 diff --git a/config/newrelic.yml b/config/newrelic.yml new file mode 100644 index 0000000..9a36de1 --- /dev/null +++ b/config/newrelic.yml @@ -0,0 +1,31 @@ +common: &default_settings + license_key: <%= ENV['NEW_RELIC_KEY'] %> + app_name: api + + distributed_tracing: + enabled: true + + log_level: info + + application_logging: + enabled: true + forwarding: + enabled: true + max_samples_stored: 10000 + metrics: + enabled: true + local_decorating: + enabled: false + +development: + monitor_mode: false + +test: + monitor_mode: false + +staging: + <<: *default_settings + app_name: api (Staging) + +production: + <<: *default_settings diff --git a/config/permissions.yml b/config/permissions.yml new file mode 100644 index 0000000..5da3d63 --- /dev/null +++ b/config/permissions.yml @@ -0,0 +1,229 @@ +# The list of permissions to be associated to predefined roles. +# In the leafs of the tree we keep the array of names of predefined roles. +--- +addons: + view: + - finance + - manager + create: + update: + delete: +ai_conversations: + view: + create: +audit_logs: + view: +authentication_methods: + view: + update: +analytics: + view: + - finance +billable_metrics: + view: + create: + update: + delete: +billing_entities: + view: + - finance + - manager + create: + - finance + update: + - finance + delete: + - finance +charges: + create: + update: + delete: +coupons: + view: + - finance + - manager + create: + update: + delete: + attach: + - manager + detach: + - manager +credit_notes: + view: + - finance + - manager + create: + - finance + - manager + update: + - finance + - manager + void: + - finance + - manager + export: + - finance + - manager + send: + - finance + - manager +customers: + view: + - finance + - manager + create: + - manager + update: + - manager + delete: + - manager +data_api: + view: + - finance +developers: + manage: + keys: + manage: +dunning_campaigns: + view: + - finance + - manager + create: + - finance + update: + - finance + delete: + - finance +features: + view: + create: + update: + delete: +invoices: + view: + - finance + - manager + send: + - finance + - manager + create: + - finance + - manager + update: + - finance + - manager + void: + - finance + - manager + export: + - finance + - manager +invoice_custom_sections: + view: + - finance + create: + - finance + update: + - finance + delete: + - finance +quotes: + view: + - finance + - manager + create: + - manager + update: + - manager + approve: + - manager + clone: + - manager + void: + - manager +organization: + view: + - finance + update: + - finance + invoices: + view: + - finance + update: + - finance + taxes: + view: + update: + emails: + view: + update: + integrations: + view: + - finance + create: + - finance + update: + - finance + delete: + - finance + members: + view: + create: + update: + delete: +payments: + view: + - finance + - manager + create: + - finance + - manager +payment_methods: + view: + create: + update: + delete: +payment_receipts: + view: + - finance + - manager + send: + - finance + - manager +plans: + view: + - finance + - manager + create: + update: + delete: +pricing_units: + view: + - finance + create: + update: +roles: + view: + create: + update: + delete: +security_logs: + view: +subscriptions: + view: + - finance + - manager + create: + - manager + update: + - manager +wallets: + create: + - manager + update: + - manager + top_up: + - manager + terminate: + - manager diff --git a/config/puma.rb b/config/puma.rb new file mode 100644 index 0000000..cd01f62 --- /dev/null +++ b/config/puma.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +# Puma can serve each request in a thread from an internal thread pool. +# The `threads` method setting takes two numbers: a minimum and maximum. +# Any libraries that use thread pools should be configured to match +# the maximum value specified for Puma. Default is set to 5 threads for minimum +# and maximum; this matches the default thread size of Active Record. +# +max_threads_count = ENV.fetch("RAILS_MAX_THREADS", 3) +min_threads_count = ENV.fetch("RAILS_MIN_THREADS", 0) +threads min_threads_count, max_threads_count + +# Specifies the `worker_timeout` threshold that Puma will use to wait before +# terminating a worker in development environments. +# +worker_timeout 3600 if ENV.fetch("RAILS_ENV", "development") == "development" +worker_timeout 12 if ENV.fetch("RAILS_ENV", "production") == "production" + +worker_shutdown_timeout 30 +on_worker_boot do + $shutdown_requested = false # rubocop:disable Style/GlobalVars +end + +on_worker_shutdown do + $shutdown_requested = true # rubocop:disable Style/GlobalVars + sleep 5 # let k8s remove from endpoints +end + +# Specifies the `port` that Puma will listen on to receive requests; default is 3000. +# +port ENV.fetch("PORT", 3000) + +# Specifies the `environment` that Puma will run in. +# +environment ENV.fetch("RAILS_ENV", "development") + +# Specifies the `pidfile` that Puma will use. +pidfile ENV.fetch("PIDFILE", "tmp/pids/server.pid") + +# Specifies the number of `workers` to boot in clustered mode. +# Workers are forked web server processes. If using threads and workers together +# the concurrency of the application would be max `threads` * `workers`. +# Workers do not work on JRuby or Windows (both of which do not support +# processes). +# +workers ENV.fetch("WEB_CONCURRENCY", 0) + +# Ensure we flush and close Karafka producer when puma is shutting down +if ENV.fetch("WEB_CONCURRENCY", 0).to_i > 0 + on_worker_shutdown do + ::Karafka.producer.close + end +else + on_stopped do + ::Karafka.producer.close + end +end + +# Use the `preload_app!` method when specifying a `workers` number. +# This directive tells Puma to first boot the application and load code +# before forking the application. This takes advantage of Copy On Write +# process behavior so workers use less memory. +# +preload_app! if ENV["WEB_CONCURRENCY"].present? + +# Allow puma to be restarted by `bin/rails restart` command. +plugin :tmp_restart + +# Activate Yabeda +activate_control_app +plugin :yabeda diff --git a/config/routes.rb b/config/routes.rb new file mode 100644 index 0000000..53b361b --- /dev/null +++ b/config/routes.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +Rails.application.routes.draw do + if ENV["LAGO_SIDEKIQ_WEB"] == "true" + mount Sidekiq::Web, at: "/sidekiq" if defined?(Sidekiq::Web) + mount Sidekiq::Prometheus::Exporter, at: "/sidekiq/prometheus/metrics" if defined? Sidekiq::Prometheus::Exporter + end + mount Karafka::Web::App, at: "/karafka" if ENV["LAGO_KARAFKA_WEB"] + mount GraphiQL::Rails::Engine, at: "/graphiql", graphql_path: "/graphql" if Rails.env.development? + mount Yabeda::Prometheus::Exporter, at: "/metrics" + mount ActionCable.server, at: "/cable" + + post "/graphql", to: "graphql#execute" + + # Health Check status + get "/health", to: "application#health" + get "/ready", to: "application#ready" + + namespace :data_api do + namespace :v1 do + resources :charges, only: [] do + post :forecasted_usage_amount, on: :member + post :bulk_forecasted_usage_amount, on: :collection + end + end + end + + namespace :api do + namespace :v1 do + resources :activity_logs, param: :activity_id, only: %i[index show] + resources :api_logs, param: :request_id, only: %i[index show] + resources :security_logs, param: :log_id, only: %i[index show] + + namespace :analytics do + get :gross_revenue, to: "gross_revenues#index", as: :gross_revenue + get :invoiced_usage, to: "invoiced_usages#index", as: :invoiced_usage + get :invoice_collection, to: "invoice_collections#index", as: :invoice_collection + get :mrr, to: "mrrs#index", as: :mrr + get :overdue_balance, to: "overdue_balances#index", as: :overdue_balance + end + + get "analytics/usage", to: "data_api/usages#index", as: :usage + + resources :billing_entities, param: :code, only: %i[index show update create] + + resources :customers, param: :external_id, only: %i[create index show destroy] do + get :portal_url + + get :current_usage, to: "customers/usage#current" + get :projected_usage, to: "customers/projected_usage#current" + get :past_usage, to: "customers/usage#past" + + post :checkout_url + + scope module: :customers do + resources :applied_coupons, only: %i[index destroy] + resources :credit_notes, only: %i[index] + resources :invoices, only: %i[index] + resources :payments, only: %i[index] + resources :payment_requests, only: %i[index] + resources :subscriptions, only: %i[index] + resources :wallets, only: %i[create update show index], param: :code do + scope module: :wallets do + resources :alerts, only: %i[create index update show destroy], param: :code do + collection do + delete "/", action: :destroy_all + end + end + resource :metadata, only: %i[create update destroy] do + delete ":key", action: :destroy_key, on: :member + end + end + end + delete "/wallets/:code", to: "wallets#terminate" + resources :payment_methods, only: %i[index destroy] do + put :set_as_default, on: :member + end + end + end + + resources :subscriptions, only: %i[create update show index], param: :external_id do + resource :lifetime_usage, only: %i[show update], controller: "subscriptions/lifetime_usages" + resources :alerts, only: %i[create index update show destroy], param: :code, controller: "subscriptions/alerts" do + collection do + delete "/", action: :destroy_all + end + end + resources :entitlements, only: %i[index destroy], param: :code, code: /.*/, controller: "subscriptions/entitlements" do + resources :privileges, only: %i[destroy], param: :code, code: /.*/, controller: "subscriptions/entitlements/privileges" + end + patch :entitlements, to: "subscriptions/entitlements#update" + resources :fixed_charges, only: %i[index show update], param: :code, code: /.*/, controller: "subscriptions/fixed_charges" + resources :charges, only: %i[index show update], param: :code, code: /.*/, controller: "subscriptions/charges" do + resources :filters, only: %i[index show create update destroy], controller: "subscriptions/charges/filters" + end + end + delete "/subscriptions/:external_id", to: "subscriptions#terminate", as: :terminate + + resources :add_ons, param: :code, code: /.*/ + resources :billable_metrics, param: :code, code: /.*/ do + post :evaluate_expression, on: :collection + end + + resources :features, param: :code, code: /.*/, only: %i[index show create update destroy] do + scope module: :features do + resources :privileges, only: %i[destroy], param: :code + end + end + + resources :coupons, param: :code, code: /.*/ + resources :credit_notes, only: %i[create update show index] do + post :download, on: :member, action: :download_pdf + post :download_pdf, on: :member + post :download_xml, on: :member + post :resend_email, on: :member + put :void, on: :member + post :estimate, on: :collection + scope module: :credit_notes do + resource :metadata, only: %i[create update destroy] do + delete ":key", action: :destroy_key, on: :member + end + end + end + get :events_enriched, to: "events#index_enriched" + resources :events, only: %i[create show index], constraints: {id: /[^\/]+/} do + post :estimate_fees, on: :collection + post :estimate_instant_fees, on: :collection + post :batch_estimate_instant_fees, on: :collection + end + resources :applied_coupons, only: %i[create index] + resources :fees, only: %i[show update index destroy] + resources :invoices, only: %i[create update show index] do + post :download, on: :member, action: :download_pdf + post :download_pdf, on: :member + post :download_xml, on: :member + post :resend_email, on: :member + post :void, on: :member + post :lose_dispute, on: :member + post :retry, on: :member + post :retry_payment, on: :member + post :payment_url, on: :member + post :preview, on: :collection + put :refresh, on: :member + put :finalize, on: :member + put :sync_salesforce_id, on: :member + end + resources :payment_receipts, only: %i[index show] do + post :resend_email, on: :member + end + resources :payment_requests, only: %i[create index show] + resources :payments, only: %i[create index show] + resources :plans, param: :code, code: /.*/ do + resources :charges, only: %i[index show create update destroy], param: :code, code: /.*/, controller: "plans/charges" do + resources :filters, only: %i[index show create update destroy], controller: "plans/charges/filters" + end + resources :fixed_charges, only: %i[index show create update destroy], param: :code, code: /.*/, controller: "plans/fixed_charges" + resources :entitlements, only: %i[index show create destroy], param: :code, code: /.*/, controller: "plans/entitlements" do + resources :privileges, only: %i[destroy], param: :code, code: /.*/, controller: "plans/entitlements/privileges" + end + patch :entitlements, to: "plans/entitlements#update" + scope module: :plans do + resource :metadata, only: %i[create update destroy] do + delete ":key", action: :destroy_key, on: :member + end + end + end + resources :taxes, param: :code, code: /.*/ + resources :wallet_transactions, only: %i[create show] do + post :payment_url, on: :member + get :consumptions, on: :member + get :fundings, on: :member + end + get "/wallets/:id/wallet_transactions", to: "wallet_transactions#index" + resources :wallets, only: %i[create update show index] do + scope module: :wallets do + resource :metadata, only: %i[create update destroy] do + delete ":key", action: :destroy_key, on: :member + end + end + end + delete "/wallets/:id", to: "wallets#terminate" + post "/events/batch", to: "events#batch" + + get "/organizations", to: "organizations#show" + put "/organizations", to: "organizations#update" + get "/organizations/grpc_token", to: "organizations#grpc_token" + + resources :webhook_endpoints, only: %i[create index show destroy update] + resources :webhooks, only: %i[] do + get :public_key, on: :collection + get :json_public_key, on: :collection + end + end + end + resources :webhooks, only: [] do + post "stripe/:organization_id", to: "webhooks#stripe", on: :collection, as: :stripe + + post "cashfree/:organization_id", to: "webhooks#cashfree", on: :collection, as: :cashfree + post "flutterwave/:organization_id", to: "webhooks#flutterwave", on: :collection, as: :flutterwave + post "gocardless/:organization_id", to: "webhooks#gocardless", on: :collection, as: :gocardless + post "adyen/:organization_id", to: "webhooks#adyen", on: :collection, as: :adyen + post "moneyhash/:organization_id", to: "webhooks#moneyhash", on: :collection, as: :moneyhash + end + + namespace :admin do + resources :memberships, only: %i[create] + resources :organizations, only: %i[update create] + resources :invoices do + post :regenerate, on: :member + end + end + + if Rails.env.development? + namespace :dev_tools do + get "/invoices/:id", to: "invoices#show" + get "/payment_receipts/:id", to: "payment_receipts#show" + end + end + + match "*unmatched" => "application#not_found", + :via => %i[get post put delete patch], + :constraints => lambda { |req| + req.path.exclude?("rails/active_storage") + } +end diff --git a/config/sidekiq/sidekiq.yml b/config/sidekiq/sidekiq.yml new file mode 100644 index 0000000..60add20 --- /dev/null +++ b/config/sidekiq/sidekiq.yml @@ -0,0 +1,19 @@ +concurrency: 10 +timeout: 25 +retry: 1 +queues: + - high_priority + - default + - mailers + - clock + - providers + - webhook + - invoices + - integrations + - low_priority + - long_running + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_ai_agent.yml b/config/sidekiq/sidekiq_ai_agent.yml new file mode 100644 index 0000000..75cad5e --- /dev/null +++ b/config/sidekiq/sidekiq_ai_agent.yml @@ -0,0 +1,9 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - ai_agent +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_analytics.yml b/config/sidekiq/sidekiq_analytics.yml new file mode 100644 index 0000000..22ab0e4 --- /dev/null +++ b/config/sidekiq/sidekiq_analytics.yml @@ -0,0 +1,10 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - analytics + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_billing.yml b/config/sidekiq/sidekiq_billing.yml new file mode 100644 index 0000000..2ce22a5 --- /dev/null +++ b/config/sidekiq/sidekiq_billing.yml @@ -0,0 +1,11 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - billing + - billing_low_priority + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 5) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_clock.yml b/config/sidekiq/sidekiq_clock.yml new file mode 100644 index 0000000..de9f9d7 --- /dev/null +++ b/config/sidekiq/sidekiq_clock.yml @@ -0,0 +1,10 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - clock_worker + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 5) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_dedicated.yml b/config/sidekiq/sidekiq_dedicated.yml new file mode 100644 index 0000000..d78ca68 --- /dev/null +++ b/config/sidekiq/sidekiq_dedicated.yml @@ -0,0 +1,11 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - ["dedicated_alerts", 1] + - ["dedicated_wallets", 1] + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_events.yml b/config/sidekiq/sidekiq_events.yml new file mode 100644 index 0000000..337f990 --- /dev/null +++ b/config/sidekiq/sidekiq_events.yml @@ -0,0 +1,10 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - events + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_payments.yml b/config/sidekiq/sidekiq_payments.yml new file mode 100644 index 0000000..1921cd0 --- /dev/null +++ b/config/sidekiq/sidekiq_payments.yml @@ -0,0 +1,10 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - payments + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_pdfs.yml b/config/sidekiq/sidekiq_pdfs.yml new file mode 100644 index 0000000..f610e43 --- /dev/null +++ b/config/sidekiq/sidekiq_pdfs.yml @@ -0,0 +1,10 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - pdfs + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/sidekiq/sidekiq_webhook.yml b/config/sidekiq/sidekiq_webhook.yml new file mode 100644 index 0000000..4f1bddb --- /dev/null +++ b/config/sidekiq/sidekiq_webhook.yml @@ -0,0 +1,10 @@ +concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +timeout: 25 +retry: 1 +queues: + - webhook_worker + +production: + concurrency: <%= ENV.fetch('SIDEKIQ_CONCURRENCY', 10) %> +staging: + concurrency: 10 diff --git a/config/storage.yml b/config/storage.yml new file mode 100644 index 0000000..55a54c3 --- /dev/null +++ b/config/storage.yml @@ -0,0 +1,32 @@ +test: + service: Disk + root: <%= Rails.root.join("tmp/storage") %> + +local: + service: Disk + root: <%= Rails.root.join("storage") %> + +amazon: + service: S3 + access_key_id: <%= ENV['LAGO_AWS_S3_ACCESS_KEY_ID'] %> + secret_access_key: <%= ENV['LAGO_AWS_S3_SECRET_ACCESS_KEY'] %> + region: <%= ENV['LAGO_AWS_S3_REGION'] %> + bucket: <%= ENV['LAGO_AWS_S3_BUCKET'] %> + +amazon_compatible_endpoint: + service: S3 + access_key_id: <%= ENV['LAGO_AWS_S3_ACCESS_KEY_ID'] %> + secret_access_key: <%= ENV['LAGO_AWS_S3_SECRET_ACCESS_KEY'] %> + endpoint: <%= ENV['LAGO_AWS_S3_ENDPOINT'] %> + bucket: <%= ENV['LAGO_AWS_S3_BUCKET'] %> + region: <%= ENV['LAGO_AWS_S3_REGION'] %> + force_path_style: <%= ENV.fetch('LAGO_AWS_S3_PATH_STYLE', false) %> + ssl_verify_peer: <%= ENV.fetch('LAGO_AWS_S3_SSL_VERIFY', true) %> + +google: + service: GCS + credentials: <%= ENV.fetch('LAGO_GCS_KEYFILE_JSON_PATH', Rails.root.join('gcs_keyfile.json')) %> + iam: <%= ENV.fetch('LAGO_GCS_IAM', false) %> + gsa_email: <%= ENV.fetch('LAGO_GCS_GSA_EMAIL', nil) %> + project: <%= ENV['LAGO_GCS_PROJECT'] %> + bucket: <%= ENV['LAGO_GCS_BUCKET'] %> \ No newline at end of file diff --git a/config/versions.yml b/config/versions.yml new file mode 100644 index 0000000..d3970d2 --- /dev/null +++ b/config/versions.yml @@ -0,0 +1,13 @@ +versions: + - version: 1.5.0 + migrations: [] + - version: 1.5.1 + migrations: [] + - version: 1.6.0 + migrations: [] + - version: 1.6.1 + migrations: + - 20240603095841 + - 20240628083830 + - version: 1.7.0 + migrations: [] diff --git a/config/webhook_event_types.yml b/config/webhook_event_types.yml new file mode 100644 index 0000000..87aadce --- /dev/null +++ b/config/webhook_event_types.yml @@ -0,0 +1,305 @@ +alert_triggered: + name: alert.triggered + description: One or more thresholds defined in the alert were crossed + category: Alerts + deprecated: false +customer_created: + name: customer.created + description: A new customer has been created + category: Customers + deprecated: false +customer_updated: + name: customer.updated + description: A customer has been updated + category: Customers + deprecated: false +customer_accounting_provider_created: + name: customer.accounting_provider_created + description: A customer was created on an accouting integration + category: Customers + deprecated: false +customer_accounting_provider_error: + name: customer.accounting_provider_error + description: An error was encountered while syncing a customer to an accounting provider + category: Customers + deprecated: false +customer_crm_provider_created: + name: customer.crm_provider_created + description: A customer has been created in the CRM provider + category: Customers + deprecated: false +customer_crm_provider_error: + name: customer.crm_provider_error + description: An error was encountered while syncing a customer to a CRM provider + category: Customers + deprecated: false +customer_payment_provider_created: + name: customer.payment_provider_created + description: A customer has been created on a payment provider + category: Customers + deprecated: false +customer_payment_provider_error: + name: customer.payment_provider_error + description: An error was encountered while syncing a customer to a payment provider + category: Customers + deprecated: false +customer_checkout_url_generated: + name: customer.checkout_url_generated + description: A checkout URL was generated for a customer + category: Customers + deprecated: false +customer_tax_provider_error: + name: customer.tax_provider_error + description: An error was encountered while fetching taxes for a customer on a tax provider + category: Customers + deprecated: false +customer_vies_check: + name: customer.vies_check + description: VIES VAT number has been checked for a customer + category: Customers + deprecated: false +credit_note_created: + name: credit_note.created + description: A new credit note has been created + category: Credit Notes + deprecated: false +credit_note_generated: + name: credit_note.generated + description: A new credit note PDF has been generated + category: Credit Notes + deprecated: false +credit_note_provider_refund_failure: + name: credit_note.provider_refund_failure + description: The refund of a credit note has failed on a payment provider + category: Credit Notes + deprecated: false +dunning_campaign_finished: + name: dunning_campaign.finished + description: The dunning campaign has been completed for a customer + category: Dunning Campaigns + deprecated: false +event_error: + name: event.error + description: An error has been detected on an event + category: Event Ingestion + deprecated: true +events_errors: + name: events.errors + description: Errors were encountered while post-processing some events + category: Event Ingestion + deprecated: false +feature_created: + name: feature.created + description: A new feature has been created + category: Features + deprecated: false +feature_updated: + name: feature.updated + description: A feature has been updated + category: Features + deprecated: false +feature_deleted: + name: feature.deleted + description: A feature has been deleted + category: Features + deprecated: false +fee_created: + name: fee.created + description: A pay in advance fee has been created + category: Subscriptions and Fees + deprecated: false +fee_tax_provider_error: + name: fee.tax_provider_error + description: An error was encountered while fetching taxes for a fee on a tax provider + category: Subscriptions and Fees + deprecated: false +invoice_created: + name: invoice.created + description: A new invoice has been emitted + category: Invoices + deprecated: false +invoice_one_off_created: + name: invoice.one_off_created + description: A new one off invoice has been emitted + category: Invoices + deprecated: false +invoice_paid_credit_added: + name: invoice.paid_credit_added + description: A new prepaid credit invoice has been emitted + category: Invoices + deprecated: false +invoice_generated: + name: invoice.generated + description: A new invoice PDF has been generated + category: Invoices + deprecated: false +invoice_drafted: + name: invoice.drafted + description: A new draft invoice has been emitted + category: Invoices + deprecated: false +invoice_voided: + name: invoice.voided + description: An invoice has been voided + category: Invoices + deprecated: false +invoice_payment_dispute_lost: + name: invoice.payment_dispute_lost + description: A payment dispute has been lost for an invoice payment + category: Invoices + deprecated: false +invoice_payment_status_updated: + name: invoice.payment_status_updated + description: The payment status of an invoice has been updated + category: Invoices + deprecated: false +invoice_payment_overdue: + name: invoice.payment_overdue + description: An invoice payment is overdue + category: Invoices + deprecated: false +invoice_payment_failure: + name: invoice.payment_failure + description: A payment attempt for an invoice has failed on a payment provider + category: Invoices + deprecated: false +invoice_resynced: + name: invoice.resynced + description: An invoice has been resynced with salesforce + category: Invoices + deprecated: false +integration_provider_error: + name: integration.provider_error + description: An error was encountered while processing data on an integration + category: Integrations + deprecated: false +payment_succeeded: + name: payment.succeeded + description: A payment has been successfully processed by the payment provider + category: Payments + deprecated: false +payment_requires_action: + name: payment.requires_action + description: An action is required to process a payment + category: Payments + deprecated: false +payment_provider_error: + name: payment_provider.error + description: An error was raised by a payment provider + category: Payments + deprecated: false +payment_receipt_created: + name: payment_receipt.created + description: A new payment receipt has been created + category: Payment Receipts + deprecated: false +payment_receipt_generated: + name: payment_receipt.generated + description: A new payment receipt PDF has been generated + category: Payment Receipts + deprecated: false +payment_request_created: + name: payment_request.created + description: A new payment request has been created + category: Payments + deprecated: false +payment_request_payment_failure: + name: payment_request.payment_failure + description: A payment attempt for a payment request has failed on a payment provider + category: Payments + deprecated: false +payment_request_payment_status_updated: + name: payment_request.payment_status_updated + description: The payment status of a payment request has been updated + category: Payments + deprecated: false +plan_created: + name: plan.created + description: A new plan has been created + category: Plans + deprecated: false +plan_updated: + name: plan.updated + description: A plan has been updated + category: Plans + deprecated: false +plan_deleted: + name: plan.deleted + description: A plan has been deleted + category: Plans + deprecated: false +subscription_canceled: + name: subscription.canceled + description: A subscription has been canceled + category: Subscriptions and Fees + deprecated: false +subscription_incomplete: + name: subscription.incomplete + description: A subscription is awaiting activation rule resolution before becoming active + category: Subscriptions and Fees + deprecated: false +subscription_terminated: + name: subscription.terminated + description: A subscription has been terminated + category: Subscriptions and Fees + deprecated: false +subscription_started: + name: subscription.started + description: An subscription has started + category: Subscriptions and Fees + deprecated: false +subscription_updated: + name: subscription.updated + description: A subscription has been updated + category: Subscriptions and Fees + deprecated: false +subscription_termination_alert: + name: subscription.termination_alert + description: A subscription will be terminated in the future + category: Subscriptions and Fees + deprecated: false +subscription_trial_ended: + name: subscription.trial_ended + description: A subscription trial period has ended + category: Subscriptions and Fees + deprecated: false +subscription_usage_threshold_reached: + name: subscription.usage_threshold_reached + description: A usage threshold has been reached by a subscription + category: Subscriptions and Fees + deprecated: false +wallet_created: + name: wallet.created + description: A new wallet has been created + category: Wallets and Credits + deprecated: false +wallet_depleted_ongoing_balance: + name: wallet.depleted_ongoing_balance + description: The balance of a wallet has been depleted + category: Wallets and Credits + deprecated: false +wallet_terminated: + name: wallet.terminated + description: A wallet has been terminated + category: Wallets and Credits + deprecated: false +wallet_updated: + name: wallet.updated + description: A wallet has been updated + category: Wallets and Credits + deprecated: false +wallet_transaction_created: + name: wallet_transaction.created + description: A new wallet transaction has been created + category: Wallets and Credits + deprecated: false +wallet_transaction_updated: + name: wallet_transaction.updated + description: A wallet transaction has been updated + category: Wallets and Credits + deprecated: false +wallet_transaction_payment_failure: + name: wallet_transaction.payment_failure + description: A payment attempt for a wallet transaction has failed on a payment provider + category: Wallets and Credits + deprecated: false diff --git a/db/clickhouse_migrate/20231024084411_create_events_raw.rb b/db/clickhouse_migrate/20231024084411_create_events_raw.rb new file mode 100644 index 0000000..1385883 --- /dev/null +++ b/db/clickhouse_migrate/20231024084411_create_events_raw.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateEventsRaw < ActiveRecord::Migration[7.0] + def change + options = <<-SQL + MergeTree + ORDER BY (organization_id, external_subscription_id, code, transaction_id, timestamp) + SQL + + create_table :events_raw, id: false, options: do |t| + t.string :organization_id, null: false + t.string :external_customer_id, null: false + t.string :external_subscription_id, null: false + t.string :transaction_id, null: false + t.datetime :timestamp, null: false, precision: 3 + t.string :code, null: false + t.string :properties, map: true, null: false + t.decimal :precise_total_amount_cents, precision: 40, scale: 15 + t.datetime :ingested_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20231026124912_create_events_raw_queue.rb b/db/clickhouse_migrate/20231026124912_create_events_raw_queue.rb new file mode 100644 index 0000000..003f28d --- /dev/null +++ b/db/clickhouse_migrate/20231026124912_create_events_raw_queue.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class CreateEventsRawQueue < ActiveRecord::Migration[7.0] + def change + options = <<-SQL + Kafka() + SETTINGS + kafka_broker_list = '#{ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"]}', + kafka_topic_list = '#{ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"]}', + kafka_group_name = '#{ENV["LAGO_KAFKA_CLICKHOUSE_CONSUMER_GROUP"]}', + kafka_format = 'JSONEachRow' + SQL + + create_table :events_raw_queue, id: false, options: do |t| + t.string :organization_id, null: false + t.string :external_customer_id, null: false + t.string :external_subscription_id, null: false + t.string :transaction_id, null: false + t.string :timestamp, null: false + t.string :code, null: false + t.string :properties, null: false + t.decimal :precise_total_amount_cents, precision: 40, scale: 15 + t.datetime :ingested_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20231030163703_create_events_raw_mv.rb b/db/clickhouse_migrate/20231030163703_create_events_raw_mv.rb new file mode 100644 index 0000000..3a03952 --- /dev/null +++ b/db/clickhouse_migrate/20231030163703_create_events_raw_mv.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: false + +class CreateEventsRawMv < ActiveRecord::Migration[7.0] + def change + sql = <<-SQL + SELECT + organization_id, + external_customer_id, + external_subscription_id, + transaction_id, + toDateTime64(timestamp, 3) as timestamp, + code, + JSONExtract(properties, 'Map(String, String)') as properties, + precise_total_amount_cents, + ingested_at + FROM events_raw_queue + SQL + + create_view :events_raw_mv, materialized: true, as: sql, to: "events_raw" + end +end diff --git a/db/clickhouse_migrate/20240705080709_create_events_enriched.rb b/db/clickhouse_migrate/20240705080709_create_events_enriched.rb new file mode 100644 index 0000000..aef5760 --- /dev/null +++ b/db/clickhouse_migrate/20240705080709_create_events_enriched.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class CreateEventsEnriched < ActiveRecord::Migration[7.1] + def change + options = <<-SQL + ReplacingMergeTree(timestamp) + PRIMARY KEY ( + organization_id, + code, + external_subscription_id, + toDate(timestamp) + ) + ORDER BY ( + organization_id, + code, + external_subscription_id, + toDate(timestamp), + timestamp, + transaction_id + ) + SQL + + create_table :events_enriched, id: false, options: do |t| + t.string :organization_id, null: false + t.string :external_subscription_id, null: false + t.string :code, null: false + t.datetime :timestamp, null: false, precision: 3 + t.string :transaction_id, null: false + t.string :properties, map: true, null: false + t.string :sorted_properties, map: true, null: false, default: -> { "mapSort(properties)" } + t.string :value + t.decimal :decimal_value, precision: 38, scale: 26, default: -> { "toDecimal128OrZero(value, 26)" } + t.datetime :enriched_at, null: false, precision: 3, default: -> { "now()" } + t.decimal :precise_total_amount_cents, precision: 40, scale: 15 + end + end +end diff --git a/db/clickhouse_migrate/20240705084952_create_events_enriched_queue.rb b/db/clickhouse_migrate/20240705084952_create_events_enriched_queue.rb new file mode 100644 index 0000000..fb68e96 --- /dev/null +++ b/db/clickhouse_migrate/20240705084952_create_events_enriched_queue.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class CreateEventsEnrichedQueue < ActiveRecord::Migration[7.1] + def change + options = <<-SQL + Kafka() + SETTINGS + kafka_broker_list = '#{ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"]}', + kafka_topic_list = '#{ENV["LAGO_KAFKA_ENRICHED_EVENTS_TOPIC"]}', + kafka_group_name = '#{ENV["LAGO_KAFKA_CLICKHOUSE_CONSUMER_GROUP"]}', + kafka_format = 'JSONEachRow'; + SQL + + create_table :events_enriched_queue, id: false, options: do |t| + t.string :organization_id, null: false + t.string :external_subscription_id, null: false + t.string :code, null: false + t.string :timestamp, null: false + t.string :transaction_id, null: false + t.string :properties, null: false + t.string :value + t.decimal :precise_total_amount_cents, precision: 40, scale: 15 + end + end +end diff --git a/db/clickhouse_migrate/20240705085501_create_events_enriched_mv.rb b/db/clickhouse_migrate/20240705085501_create_events_enriched_mv.rb new file mode 100644 index 0000000..4b0d17a --- /dev/null +++ b/db/clickhouse_migrate/20240705085501_create_events_enriched_mv.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: false + +class CreateEventsEnrichedMv < ActiveRecord::Migration[7.1] + def change + sql = <<~SQL + SELECT + organization_id, + external_subscription_id, + transaction_id, + toDateTime64(timestamp, 3) AS timestamp, + code, + JSONExtract(properties, 'Map(String, String)') AS properties, + value, + precise_total_amount_cents + FROM events_enriched_queue + SQL + + create_view :events_enriched_mv, materialized: true, as: sql, to: "events_enriched" + end +end diff --git a/db/clickhouse_migrate/20250416103745_create_activity_logs.rb b/db/clickhouse_migrate/20250416103745_create_activity_logs.rb new file mode 100644 index 0000000..24d1ed7 --- /dev/null +++ b/db/clickhouse_migrate/20250416103745_create_activity_logs.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateActivityLogs < ActiveRecord::Migration[7.1] + def change + options = <<-SQL + ReplacingMergeTree(logged_at) + PRIMARY KEY (organization_id, activity_id, logged_at) + ORDER BY (organization_id, activity_id, logged_at) + SQL + + create_table :activity_logs, id: false, options: do |t| + t.string :organization_id, null: false + t.string :user_id + t.string :api_key_id + + t.string :external_customer_id + t.string :external_subscription_id + + t.string :activity_id, null: false + t.string :activity_type, null: false + t.enum :activity_source, value: {api: 1, front: 2, system: 3}, null: false + t.string :activity_object, map: true + t.string :activity_object_changes, map: true + + t.string :resource_id, null: false + t.string :resource_type, null: false + + t.datetime :logged_at, null: false, precision: 3 + t.datetime :created_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20250416104012_create_activity_logs_queue.rb b/db/clickhouse_migrate/20250416104012_create_activity_logs_queue.rb new file mode 100644 index 0000000..5cd8897 --- /dev/null +++ b/db/clickhouse_migrate/20250416104012_create_activity_logs_queue.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class CreateActivityLogsQueue < ActiveRecord::Migration[7.0] + def change + options = <<-SQL + Kafka() + SETTINGS + kafka_broker_list = '#{ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"]}', + kafka_topic_list = '#{ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"]}', + kafka_group_name = '#{ENV["LAGO_KAFKA_CLICKHOUSE_CONSUMER_GROUP"]}', + kafka_format = 'JSONEachRow' + SQL + + create_table :activity_logs_queue, id: false, options: do |t| + t.string :organization_id, null: false + t.string :user_id + t.string :api_key_id + + t.string :external_customer_id + t.string :external_subscription_id + + t.string :activity_id, null: false + t.string :activity_type, null: false + t.enum :activity_source, value: {api: 1, front: 2, system: 3}, null: false + t.string :activity_object, map: true + t.string :activity_object_changes, map: true + + t.string :resource_id, null: false + t.string :resource_type, null: false + + t.datetime :logged_at, null: false, precision: 3 + t.datetime :created_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20250416104534_create_activity_logs_mv.rb b/db/clickhouse_migrate/20250416104534_create_activity_logs_mv.rb new file mode 100644 index 0000000..2cbbbf3 --- /dev/null +++ b/db/clickhouse_migrate/20250416104534_create_activity_logs_mv.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false + +class CreateActivityLogsMv < ActiveRecord::Migration[7.0] + def change + sql = <<-SQL + SELECT + organization_id, + user_id, + api_key_id, + external_customer_id, + external_subscription_id, + activity_id, + resource_id, + resource_type, + activity_object, + activity_object_changes, + activity_type, + activity_source, + logged_at, + created_at + FROM activity_logs_queue + SQL + + create_view :activity_logs_mv, materialized: true, as: sql, to: "activity_logs" + end +end diff --git a/db/clickhouse_migrate/20250605162945_create_api_logs.rb b/db/clickhouse_migrate/20250605162945_create_api_logs.rb new file mode 100644 index 0000000..5b49bd8 --- /dev/null +++ b/db/clickhouse_migrate/20250605162945_create_api_logs.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class CreateApiLogs < ActiveRecord::Migration[7.1] + def change + options = <<-SQL + MergeTree + PRIMARY KEY (organization_id, api_key_id, request_id, logged_at) + ORDER BY (organization_id, api_key_id, request_id, logged_at) + SQL + + create_table :api_logs, id: false, options: do |t| + t.string :request_id, null: false + t.string :organization_id, null: false + t.string :api_key_id, null: false + t.string :api_version, null: false + + t.string :client, null: false + t.string :request_body, null: false, map: true + t.string :request_response, map: true + t.string :request_path, null: false + t.string :request_origin, null: false + t.enum :http_method, value: {get: 1, post: 2, put: 3, delete: 4}, null: false + t.integer :http_status, null: false + + t.datetime :logged_at, null: false, precision: 3 + t.datetime :created_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20250605171311_create_api_logs_queue.rb b/db/clickhouse_migrate/20250605171311_create_api_logs_queue.rb new file mode 100644 index 0000000..893a88b --- /dev/null +++ b/db/clickhouse_migrate/20250605171311_create_api_logs_queue.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +class CreateApiLogsQueue < ActiveRecord::Migration[7.0] + def change + options = <<-SQL + Kafka() + SETTINGS + kafka_broker_list = '#{ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"]}', + kafka_topic_list = '#{ENV["LAGO_KAFKA_API_LOGS_TOPIC"]}', + kafka_group_name = '#{ENV["LAGO_KAFKA_CLICKHOUSE_CONSUMER_GROUP"]}', + kafka_format = 'JSONEachRow' + SQL + + create_table :api_logs_queue, id: false, options: do |t| + t.string :request_id, null: false + t.string :organization_id, null: false + t.string :api_key_id, null: false + t.string :api_version, null: false + + t.string :client, null: false + t.string :request_body, null: false, map: true + t.string :request_response, map: true + t.string :request_path, null: false + t.string :request_origin, null: false + t.enum :http_method, value: {get: 1, post: 2, put: 3, delete: 4}, null: false + t.integer :http_status, null: false + + t.datetime :logged_at, null: false, precision: 3 + t.datetime :created_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20250605171534_create_api_logs_mv.rb b/db/clickhouse_migrate/20250605171534_create_api_logs_mv.rb new file mode 100644 index 0000000..210aede --- /dev/null +++ b/db/clickhouse_migrate/20250605171534_create_api_logs_mv.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: false + +class CreateApiLogsMv < ActiveRecord::Migration[7.0] + def change + sql = <<-SQL + SELECT + request_id, + organization_id, + api_key_id, + api_version, + client, + request_body, + request_response, + request_path, + request_origin, + http_method, + http_status, + logged_at, + created_at + FROM api_logs_queue + SQL + + create_view :api_logs_mv, materialized: true, as: sql, to: "api_logs" + end +end diff --git a/db/clickhouse_migrate/20250814090557_create_events_enriched_expanded.rb b/db/clickhouse_migrate/20250814090557_create_events_enriched_expanded.rb new file mode 100644 index 0000000..e1493aa --- /dev/null +++ b/db/clickhouse_migrate/20250814090557_create_events_enriched_expanded.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class CreateEventsEnrichedExpanded < ActiveRecord::Migration[8.0] + def change + options = <<-SQL + ReplacingMergeTree(timestamp) + PRIMARY KEY ( + organization_id, + code, + external_subscription_id, + charge_id, + charge_filter_id, + toDate(timestamp) + ) + ORDER BY ( + organization_id, + code, + external_subscription_id, + charge_id, + charge_filter_id, + toDate(timestamp), + timestamp, + transaction_id + ) + SQL + + create_table :events_enriched_expanded, id: false, options: do |t| + t.string :organization_id, null: false + t.string :external_subscription_id, null: false + t.string :code, null: false + t.datetime :timestamp, null: false, precision: 3 + t.string :transaction_id, null: false + t.json :properties, null: false + t.string :sorted_properties, map: true, null: false, default: -> { "mapSort(JSONExtract(properties::String, 'Map(String, String)'))" } + t.string :value + t.decimal :decimal_value, precision: 38, scale: 26, default: -> { "toDecimal128OrZero(value, 26)" } + t.datetime :enriched_at, null: false, precision: 3, default: -> { "now()" } + t.decimal :precise_total_amount_cents, precision: 40, scale: 15 + t.string :subscription_id, null: false, default: -> { "''" } + t.string :plan_id, null: false, default: -> { "''" } + t.string :charge_id, null: false, default: -> { "''" } + t.datetime :charge_version + t.string :charge_filter_id, null: false, default: -> { "''" } + t.datetime :charge_filter_version + t.string :aggregation_type, null: false + t.json :grouped_by, null: false + t.string :sorted_grouped_by, map: true, null: false, default: -> { "mapSort(JSONExtract(grouped_by::String, 'Map(String, String)'))" } + end + end +end diff --git a/db/clickhouse_migrate/20250814124830_create_events_enriched_expanded_queue.rb b/db/clickhouse_migrate/20250814124830_create_events_enriched_expanded_queue.rb new file mode 100644 index 0000000..fae2a0d --- /dev/null +++ b/db/clickhouse_migrate/20250814124830_create_events_enriched_expanded_queue.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +class CreateEventsEnrichedExpandedQueue < ActiveRecord::Migration[8.0] + def change + options = <<-SQL + Kafka() + SETTINGS + kafka_broker_list = '#{ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"]}', + kafka_topic_list = '#{ENV["LAGO_KAFKA_ENRICHED_EVENTS_EXPANDED_TOPIC"]}', + kafka_group_name = '#{ENV["LAGO_KAFKA_CLICKHOUSE_CONSUMER_GROUP"]}', + kafka_format = 'JSONEachRow'; + SQL + + create_table :events_enriched_expanded_queue, id: false, options: do |t| + t.string :organization_id, null: false + t.string :external_subscription_id, null: false + t.string :transaction_id, null: false + t.string :code, null: false + t.string :aggregation_type + t.string :subscription_id + t.string :plan_id + t.string :properties, null: false + t.decimal :precise_total_amount_cents, precision: 40, scale: 15 + t.string :value + t.string :timestamp, null: false + t.string :charge_id + t.string :charge_updated_at + t.string :charge_filter_id + t.string :charge_filter_updated_at + t.string :grouped_by + end + end +end diff --git a/db/clickhouse_migrate/20250814125620_create_events_enriched_expanded_mv.rb b/db/clickhouse_migrate/20250814125620_create_events_enriched_expanded_mv.rb new file mode 100644 index 0000000..a5d8992 --- /dev/null +++ b/db/clickhouse_migrate/20250814125620_create_events_enriched_expanded_mv.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: false + +class CreateEventsEnrichedExpandedMv < ActiveRecord::Migration[8.0] + def change + sql = <<-SQL + SELECT + organization_id, + external_subscription_id, + code, + toDateTime64(timestamp, 3) AS timestamp, + transaction_id, + properties, + value, + precise_total_amount_cents, + subscription_id, + plan_id, + coalesce(charge_id, '') AS charge_id, + toDateTime64(parseDateTimeBestEffortOrNull(charge_updated_at), 3) AS charge_version, + coalesce(charge_filter_id, '') AS charge_filter_id, + toDateTime64(parseDateTimeBestEffortOrNull(charge_filter_updated_at), 3) AS charge_filter_version, + aggregation_type, + coalesce(grouped_by, '{}') AS grouped_by + FROM events_enriched_expanded_queue; + SQL + + create_view :events_enriched_expanded_mv, materialized: true, as: sql, to: "events_enriched_expanded" + end +end diff --git a/db/clickhouse_migrate/20250814130828_create_events_aggregated.rb b/db/clickhouse_migrate/20250814130828_create_events_aggregated.rb new file mode 100644 index 0000000..aeadcaa --- /dev/null +++ b/db/clickhouse_migrate/20250814130828_create_events_aggregated.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: false + +class CreateEventsAggregated < ActiveRecord::Migration[8.0] + def change + options = <<-SQL + AggregatingMergeTree + ORDER BY ( + organization_id, + code, + started_at, + external_subscription_id, + subscription_id, + charge_id, + charge_filter_id, + grouped_by + ) + SQL + + create_table :events_aggregated, id: false, options: do |t| + t.string :organization_id, null: false + t.string :code, null: false + + t.datetime :started_at, null: false, precision: 3 + + t.string :external_subscription_id, null: false + t.string :subscription_id, null: false + t.string :plan_id, null: false + t.string :charge_id, null: false + t.string :charge_filter_id, null: false, default: -> { "''" } + t.string :grouped_by, null: false + t.column :precise_total_amount_cents_sum_state, "AggregateFunction(sum, Decimal(40, 15))", null: false + # Multiple aggregation states for different charge models + # Only one will be populated based on the charge's aggregation type + t.column :sum_state, "AggregateFunction(sum, Decimal(38, 26))", null: false + t.column :count_state, "AggregateFunction(count, UInt64)", null: false + t.column :max_state, "AggregateFunction(max, Decimal(38, 26))", null: false + # Latest aggregation using argMax - stores the latest value based on timestamp + # argMax(value, timestamp) returns the value corresponding to the maximum timestamp + t.column :latest_state, "AggregateFunction(argMax, Decimal(38, 26), DateTime64(3))", null: false + t.datetime :aggregated_at, null: false, precision: 3, default: -> { "now()" } + end + end +end diff --git a/db/clickhouse_migrate/20250814134106_create_events_aggregated_mv.rb b/db/clickhouse_migrate/20250814134106_create_events_aggregated_mv.rb new file mode 100644 index 0000000..e83eb06 --- /dev/null +++ b/db/clickhouse_migrate/20250814134106_create_events_aggregated_mv.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: false + +class CreateEventsAggregatedMv < ActiveRecord::Migration[8.0] + def change + sql = <<-SQL + SELECT + organization_id, + code, + toStartOfMinute(timestamp) AS started_at, + external_subscription_id, + subscription_id, + plan_id, + charge_id, + charge_filter_id, + toJSONString(sorted_grouped_by) as grouped_by, + -- Aggregate states based on aggregation type + sumState(coalesce(precise_total_amount_cents, toDecimal128(0, 15))) AS precise_total_amount_cents_sum_state, + if(aggregation_type = 'sum', sumState(coalesce(decimal_value, 0)), sumState(toDecimal128(0, 26))) AS sum_state, + if(aggregation_type = 'count', countState(), countStateIf(false)) AS count_state, + if(aggregation_type = 'max', maxState(coalesce(decimal_value, 0)), maxState(toDecimal128(0, 26))) AS max_state, + if(aggregation_type = 'latest', argMaxState(coalesce(decimal_value, 0), timestamp), argMaxState(toDecimal128(0, 26), toDateTime64('1970-01-01', 3))) AS latest_state + FROM events_enriched_expanded + WHERE decimal_value IS NOT NULL + AND subscription_id IS NOT NULL + AND plan_id IS NOT NULL + AND charge_id <> '' + GROUP BY + organization_id, + code, + toStartOfMinute(timestamp), + external_subscription_id, + subscription_id, + plan_id, + charge_id, + charge_filter_id, + sorted_grouped_by, + aggregation_type + SQL + + create_view :events_aggregated_mv, materialized: true, as: sql, to: "events_aggregated" + end +end diff --git a/db/clickhouse_migrate/20251110100317_create_events_dead_letter.rb b/db/clickhouse_migrate/20251110100317_create_events_dead_letter.rb new file mode 100644 index 0000000..9bcd968 --- /dev/null +++ b/db/clickhouse_migrate/20251110100317_create_events_dead_letter.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateEventsDeadLetter < ActiveRecord::Migration[8.0] + def change + options = <<-SQL + MergeTree + ORDER BY (organization_id, external_subscription_id, code, transaction_id, timestamp, ingested_at) + SQL + + create_table :events_dead_letter, id: false, options: do |t| + t.string :organization_id, null: false + t.string :external_subscription_id, null: false + t.string :code, null: false + t.string :transaction_id, null: false + t.datetime :timestamp, null: false, precision: 3 + t.datetime :ingested_at, null: false, precision: 3 + t.datetime :failed_at, null: false, precision: 3 + t.json :event, null: false + t.string :initial_error_message, null: false + t.string :error_code, null: false + t.string :error_message, null: false + end + end +end diff --git a/db/clickhouse_migrate/20251110130723_create_events_dead_letter_queue.rb b/db/clickhouse_migrate/20251110130723_create_events_dead_letter_queue.rb new file mode 100644 index 0000000..f8cbf76 --- /dev/null +++ b/db/clickhouse_migrate/20251110130723_create_events_dead_letter_queue.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateEventsDeadLetterQueue < ActiveRecord::Migration[8.0] + def change + options = <<-SQL + Kafka() + SETTINGS + kafka_broker_list = '#{ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"]}', + kafka_topic_list = '#{ENV["LAGO_KAFKA_EVENTS_DEAD_LETTER_TOPIC"]}', + kafka_group_name = '#{ENV["LAGO_KAFKA_CLICKHOUSE_CONSUMER_GROUP"]}', + kafka_format = 'JSONEachRow' + SQL + + create_table :events_dead_letter_queue, id: false, options: do |t| + t.string :event, null: false + t.string :initial_error_message, null: false + t.string :error_code, null: false + t.string :error_message, null: false + t.string :failed_at, null: false + end + end +end diff --git a/db/clickhouse_migrate/20251110131635_create_events_dead_letter_mv.rb b/db/clickhouse_migrate/20251110131635_create_events_dead_letter_mv.rb new file mode 100644 index 0000000..a24471c --- /dev/null +++ b/db/clickhouse_migrate/20251110131635_create_events_dead_letter_mv.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: false + +class CreateEventsDeadLetterMv < ActiveRecord::Migration[8.0] + def change + sql = <<-SQL + SELECT + JSONExtractString(event, 'organization_id') AS organization_id, + JSONExtractString(event, 'external_subscription_id') AS external_subscription_id, + JSONExtractString(event, 'code') AS code, + JSONExtractString(event, 'transaction_id') AS transaction_id, + toDateTime64(JSONExtractString(event, 'timestamp'), 3) AS timestamp, + toDateTime64(JSONExtractString(event, 'ingested_at'), 3) AS ingested_at, + toDateTime64(parseDateTime64BestEffort(failed_at), 3) as failed_at, + event, + error_code, + error_message, + initial_error_message + FROM events_dead_letter_queue + SQL + + create_view :events_dead_letter_mv, materialized: true, as: sql, to: "events_dead_letter", if_not_exists: true + end +end diff --git a/db/clickhouse_migrate/20251202134733_drop_events_aggregated.rb b/db/clickhouse_migrate/20251202134733_drop_events_aggregated.rb new file mode 100644 index 0000000..8ef71cf --- /dev/null +++ b/db/clickhouse_migrate/20251202134733_drop_events_aggregated.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class DropEventsAggregated < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute "DROP VIEW IF EXISTS events_aggregated_mv" + end + + drop_table :events_aggregated + end +end diff --git a/db/clickhouse_migrate/20260202135506_create_security_logs.rb b/db/clickhouse_migrate/20260202135506_create_security_logs.rb new file mode 100644 index 0000000..bc1f0fb --- /dev/null +++ b/db/clickhouse_migrate/20260202135506_create_security_logs.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class CreateSecurityLogs < ActiveRecord::Migration[8.0] + def change + options = <<-SQL + ReplacingMergeTree(logged_at) + PRIMARY KEY (organization_id, log_id, logged_at) + ORDER BY (organization_id, log_id, logged_at) + SQL + + create_table :security_logs, id: false, options: do |t| + t.string :organization_id, null: false + t.string :user_id + t.string :api_key_id + + t.string :log_id, null: false + t.string :log_type, null: false + t.string :log_event, null: false + + t.string :device_info, map: true + t.string :resources, map: true + + t.datetime :logged_at, null: false, precision: 3 + t.datetime :created_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20260202135507_create_security_logs_queue.rb b/db/clickhouse_migrate/20260202135507_create_security_logs_queue.rb new file mode 100644 index 0000000..7974222 --- /dev/null +++ b/db/clickhouse_migrate/20260202135507_create_security_logs_queue.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class CreateSecurityLogsQueue < ActiveRecord::Migration[8.0] + def change + options = <<-SQL + Kafka() + SETTINGS + kafka_broker_list = '#{ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"]}', + kafka_topic_list = '#{ENV["LAGO_KAFKA_SECURITY_LOGS_TOPIC"]}', + kafka_group_name = '#{ENV["LAGO_KAFKA_CLICKHOUSE_CONSUMER_GROUP"]}', + kafka_format = 'JSONEachRow' + SQL + + create_table :security_logs_queue, id: false, options: do |t| + t.string :organization_id, null: false + t.string :user_id + t.string :api_key_id + + t.string :log_id, null: false + t.string :log_type, null: false + t.string :log_event, null: false + + t.string :device_info, map: true + t.string :resources, map: true + + t.datetime :logged_at, null: false, precision: 3 + t.datetime :created_at, null: false, precision: 3 + end + end +end diff --git a/db/clickhouse_migrate/20260202135508_create_security_logs_mv.rb b/db/clickhouse_migrate/20260202135508_create_security_logs_mv.rb new file mode 100644 index 0000000..f06b2c8 --- /dev/null +++ b/db/clickhouse_migrate/20260202135508_create_security_logs_mv.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false + +class CreateSecurityLogsMv < ActiveRecord::Migration[8.0] + def up + sql = <<-SQL + SELECT + organization_id, + user_id, + api_key_id, + log_id, + log_type, + log_event, + device_info, + resources, + logged_at, + created_at + FROM security_logs_queue + SQL + + create_view :security_logs_mv, materialized: true, as: sql, to: "security_logs" + end + + def down + drop_table :security_logs_mv, if_exists: true + end +end diff --git a/db/clickhouse_migrate/20260430075848_update_events_dead_letter_mv.rb b/db/clickhouse_migrate/20260430075848_update_events_dead_letter_mv.rb new file mode 100644 index 0000000..cdd3468 --- /dev/null +++ b/db/clickhouse_migrate/20260430075848_update_events_dead_letter_mv.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class UpdateEventsDeadLetterMv < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute <<~SQL + ALTER TABLE events_dead_letter_mv MODIFY QUERY + SELECT + JSONExtractString(event, 'organization_id') AS organization_id, + JSONExtractString(event, 'external_subscription_id') AS external_subscription_id, + JSONExtractString(event, 'code') AS code, + JSONExtractString(event, 'transaction_id') AS transaction_id, + COALESCE( + toDateTime64OrNull(JSONExtractString(event, 'timestamp'), 3), + toDateTime64( + toFloat64OrNull(JSONExtractString(event, 'timestamp')), 3 + ), + toDateTime64(JSONExtractString(event, 'ingested_at'), 3) + ) AS timestamp, + toDateTime64(JSONExtractString(event, 'ingested_at'), 3) AS ingested_at, + toDateTime64(parseDateTime64BestEffort(failed_at), 3) as failed_at, + event, + error_code, + error_message, + initial_error_message + FROM events_dead_letter_queue; + SQL + end + end +end diff --git a/db/clickhouse_migrate/cloud/01_events_raw.sql b/db/clickhouse_migrate/cloud/01_events_raw.sql new file mode 100644 index 0000000..1f5f979 --- /dev/null +++ b/db/clickhouse_migrate/cloud/01_events_raw.sql @@ -0,0 +1,15 @@ +CREATE TABLE default.events_raw +( + `organization_id` String, + `external_customer_id` String, + `external_subscription_id` String, + `transaction_id` String, + `timestamp` DateTime64(3), + `code` String, + `properties` Map(String, String), + `ingested_at` DateTime(3), + `precise_total_amount_cents` Nullable(Decimal(40, 15)) +) +ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') +ORDER BY (organization_id, external_subscription_id, code, transaction_id, timestamp) +SETTINGS index_granularity = 8192 diff --git a/db/clickhouse_migrate/cloud/02_events_enriched.sql b/db/clickhouse_migrate/cloud/02_events_enriched.sql new file mode 100644 index 0000000..c003131 --- /dev/null +++ b/db/clickhouse_migrate/cloud/02_events_enriched.sql @@ -0,0 +1,18 @@ +CREATE TABLE default.events_enriched +( + `organization_id` String, + `external_subscription_id` String, + `code` String, + `timestamp` DateTime64(3), + `transaction_id` String, + `properties` Map(String, String), + `sorted_properties` Map(String, String) DEFAULT mapSort(properties), + `enriched_at` DateTime64(3) DEFAULT now(), + `value` Nullable(String), + `decimal_value` Nullable(Decimal(38, 26)) DEFAULT toDecimal128OrZero(value, 26), + `precise_total_amount_cents` Nullable(Decimal(40, 15)) +) +ENGINE = SharedReplacingMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}', timestamp) +PRIMARY KEY (organization_id, code, external_subscription_id, toDate(timestamp)) +ORDER BY (organization_id, code, external_subscription_id, toDate(timestamp), timestamp, transaction_id) +SETTINGS index_granularity = 8192 diff --git a/db/clickhouse_migrate/cloud/03_activity_logs.sql b/db/clickhouse_migrate/cloud/03_activity_logs.sql new file mode 100644 index 0000000..91ad32a --- /dev/null +++ b/db/clickhouse_migrate/cloud/03_activity_logs.sql @@ -0,0 +1,21 @@ +CREATE TABLE default.activity_logs +( + `organization_id` String, + `user_id` Nullable(String), + `api_key_id` Nullable(String), + `external_customer_id` Nullable(String), + `external_subscription_id` Nullable(String), + `activity_id` String, + `activity_type` String, + `activity_source` Enum8('api' = 1, 'front' = 2, 'system' = 3), + `activity_object` Map(String, Nullable(String)), + `activity_object_changes` Map(String, Nullable(String)), + `resource_id` String, + `resource_type` String, + `logged_at` DateTime64(3), + `created_at` DateTime64(3) +) +ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') +PRIMARY KEY (organization_id, activity_type, activity_id, logged_at) +ORDER BY (organization_id, activity_type, activity_id, logged_at) +SETTINGS index_granularity = 8192 diff --git a/db/clickhouse_migrate/cloud/04_api_logs.sql b/db/clickhouse_migrate/cloud/04_api_logs.sql new file mode 100644 index 0000000..60c5b0e --- /dev/null +++ b/db/clickhouse_migrate/cloud/04_api_logs.sql @@ -0,0 +1,20 @@ +CREATE TABLE default.api_logs +( + `request_id` String, + `organization_id` String, + `api_key_id` String, + `api_version` String, + `client` String, + `request_body` Map(String, String), + `request_response` Map(String, Nullable(String)), + `request_path` String, + `request_origin` String, + `http_method` Enum8('get' = 1, 'post' = 2, 'put' = 3, 'delete' = 4), + `http_status` UInt32, + `logged_at` DateTime64(3), + `created_at` DateTime64(3) +) +ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') +PRIMARY KEY (organization_id, api_key_id, request_id, logged_at) +ORDER BY (organization_id, api_key_id, request_id, logged_at) +SETTINGS index_granularity = 8192 diff --git a/db/clickhouse_migrate/cloud/05_events_enriched_expanded.sql b/db/clickhouse_migrate/cloud/05_events_enriched_expanded.sql new file mode 100644 index 0000000..e1b4be0 --- /dev/null +++ b/db/clickhouse_migrate/cloud/05_events_enriched_expanded.sql @@ -0,0 +1,29 @@ +SET enable_json_type = 1; + +CREATE TABLE events_enriched_expanded +( + `organization_id` String, + `external_subscription_id` String, + `code` String, + `timestamp` DateTime64(3), + `transaction_id` String, + `properties` JSON, + `sorted_properties` Map(String, String) DEFAULT mapSort(JSONExtract(CAST(properties, 'String'), 'Map(String, String)')), + `value` Nullable(String), + `decimal_value` Nullable(Decimal(38, 26)) DEFAULT toDecimal128OrZero(value, 26), + `enriched_at` DateTime64(3) DEFAULT now(), + `precise_total_amount_cents` Nullable(Decimal(40, 15)), + `subscription_id` String DEFAULT '', + `plan_id` String DEFAULT '', + `charge_id` String DEFAULT '', + `charge_version` Nullable(DateTime), + `charge_filter_id` String DEFAULT '', + `charge_filter_version` Nullable(DateTime), + `aggregation_type` String, + `grouped_by` JSON, + `sorted_grouped_by` Map(String, String) DEFAULT mapSort(JSONExtract(CAST(grouped_by, 'String'), 'Map(String, String)')) +) +ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') +PRIMARY KEY (organization_id, code, external_subscription_id, charge_id, charge_filter_id, toDate(timestamp)) +ORDER BY (organization_id, code, external_subscription_id, charge_id, charge_filter_id, toDate(timestamp), timestamp, transaction_id) +SETTINGS index_granularity = 8192 diff --git a/db/clickhouse_migrate/cloud/06_events_dead_letter.sql b/db/clickhouse_migrate/cloud/06_events_dead_letter.sql new file mode 100644 index 0000000..eaac5ac --- /dev/null +++ b/db/clickhouse_migrate/cloud/06_events_dead_letter.sql @@ -0,0 +1,17 @@ +CREATE TABLE default.events_dead_letter +( + `organization_id` String, + `external_subscription_id` String, + `code` String, + `transaction_id` String, + `timestamp` DateTime64(3), + `ingested_at` DateTime64(3), + `failed_at` DateTime64(3), + `event` JSON, + `initial_error_message` String, + `error_code` String, + `error_message` String +) +ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') +ORDER BY (organization_id, external_subscription_id, code, transaction_id, timestamp, ingested_at) +SETTINGS index_granularity = 8192 diff --git a/db/clickhouse_migrate/cloud/07_events_dead_letter_queue.sql b/db/clickhouse_migrate/cloud/07_events_dead_letter_queue.sql new file mode 100644 index 0000000..f5294a8 --- /dev/null +++ b/db/clickhouse_migrate/cloud/07_events_dead_letter_queue.sql @@ -0,0 +1,11 @@ +CREATE TABLE default.events_dead_letter_queue +( + `failed_at` DateTime64(3), + `event` JSON, + `initial_error_message` String, + `error_code` String, + `error_message` String +) +ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') +ORDER BY (failed_at, initial_error_message, error_code, error_message) +SETTINGS index_granularity = 8192 diff --git a/db/clickhouse_migrate/cloud/08_events_dead_letter_mv.sql b/db/clickhouse_migrate/cloud/08_events_dead_letter_mv.sql new file mode 100644 index 0000000..32644d7 --- /dev/null +++ b/db/clickhouse_migrate/cloud/08_events_dead_letter_mv.sql @@ -0,0 +1,31 @@ +CREATE MATERIALIZED VIEW events_dead_letter_mv TO events_dead_letter +( + `organization_id` String, + `external_subscription_id` String, + `code` String, + `transaction_id` String, + `timestamp` DateTime, + `ingested_at` DateTime, + `failed_at` DateTime, + `event` JSON, + `initial_error_message` String, + `error_code` String, + `error_message` String +) +AS SELECT + event.organization_id AS organization_id, + event.external_subscription_id AS external_subscription_id, + event.code AS code, + event.transaction_id AS transaction_id, + coalesce( + toDateTime64OrNull(toString(event.timestamp), 3), + toDateTime64(toFloat64OrNull(event.timestamp), 3), + toDateTime64(event.ingested_at, 3) + ) AS timestamp, + toDateTime64(event.ingested_at, 3) AS ingested_at, + failed_at, + event, + error_code, + error_message, + initial_error_message +FROM events_dead_letter_queue diff --git a/db/clickhouse_migrate/cloud/09_security_logs.sql b/db/clickhouse_migrate/cloud/09_security_logs.sql new file mode 100644 index 0000000..30f79e8 --- /dev/null +++ b/db/clickhouse_migrate/cloud/09_security_logs.sql @@ -0,0 +1,20 @@ +CREATE TABLE default.security_logs +( + `organization_id` String, + `user_id` Nullable(String), + `api_key_id` Nullable(String), + + `log_id` String, + `log_type` String, + `log_event` String, + + `device_info` Map(String, Nullable(String)), + `resources` Map(String, Nullable(String)), + + `logged_at` DateTime64(3), + `created_at` DateTime64(3) +) +ENGINE = SharedMergeTree('/clickhouse/tables/{uuid}/{shard}', '{replica}') +PRIMARY KEY (organization_id, log_id, logged_at) +ORDER BY (organization_id, log_id, logged_at) +SETTINGS index_granularity = 8192 diff --git a/db/migrate/20250122112050_initial_migration.rb b/db/migrate/20250122112050_initial_migration.rb new file mode 100644 index 0000000..acd4fc7 --- /dev/null +++ b/db/migrate/20250122112050_initial_migration.rb @@ -0,0 +1,3014 @@ +# frozen_string_literal: true + +class InitialMigration < ActiveRecord::Migration[7.1] + def up + safety_assured do + ## Ensure the database is empty before proceeding + unless connection.select_value("SELECT count(*) FROM schema_migrations WHERE version = '20250120151959';").zero? + raise "You are migrating from an old LAGO version, please follow the migration guide (https://getlago.com/docs/guide/migration/migration-to-v1.29.0) before proceeding." + end + + ## Populate the schema_migrations table with the all squashed versions + sql = <<-SQL + CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + + CREATE EXTENSION IF NOT EXISTS unaccent WITH SCHEMA public; + + CREATE TYPE public.billable_metric_rounding_function AS ENUM ( + 'round', + 'floor', + 'ceil' + ); + + CREATE TYPE public.billable_metric_weighted_interval AS ENUM ( + 'seconds' + ); + + CREATE TYPE public.customer_account_type AS ENUM ( + 'customer', + 'partner' + ); + + CREATE TYPE public.customer_type AS ENUM ( + 'company', + 'individual' + ); + + CREATE TYPE public.inbound_webhook_status AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed' + ); + + CREATE TYPE public.payment_payable_payment_status AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed' + ); + + CREATE TYPE public.payment_type AS ENUM ( + 'provider', + 'manual' + ); + + CREATE TYPE public.subscription_invoicing_reason AS ENUM ( + 'subscription_starting', + 'subscription_periodic', + 'subscription_terminating', + 'in_advance_charge', + 'in_advance_charge_periodic', + 'progressive_billing' + ); + + CREATE TYPE public.tax_status AS ENUM ( + 'pending', + 'succeeded', + 'failed' + ); + + + SET default_tablespace = ''; + + SET default_table_access_method = heap; + CREATE TABLE public.active_storage_attachments ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + record_type character varying NOT NULL, + blob_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + record_id uuid + ); + + CREATE TABLE public.active_storage_blobs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + key character varying NOT NULL, + filename character varying NOT NULL, + content_type character varying, + metadata text, + service_name character varying NOT NULL, + byte_size bigint NOT NULL, + checksum character varying, + created_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.active_storage_variant_records ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + blob_id uuid NOT NULL, + variation_digest character varying NOT NULL + ); + + CREATE TABLE public.add_ons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description character varying, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + invoice_display_name character varying + ); + + CREATE TABLE public.add_ons_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + add_on_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.adjusted_fees ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + fee_id uuid, + invoice_id uuid NOT NULL, + subscription_id uuid, + charge_id uuid, + invoice_display_name character varying, + fee_type integer, + adjusted_units boolean DEFAULT false NOT NULL, + adjusted_amount boolean DEFAULT false NOT NULL, + units numeric DEFAULT 0.0 NOT NULL, + unit_amount_cents bigint DEFAULT 0 NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + group_id uuid, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid, + unit_precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL + ); + + CREATE TABLE public.api_keys ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + value character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + expires_at timestamp(6) without time zone, + last_used_at timestamp(6) without time zone, + name character varying, + permissions jsonb NOT NULL + ); + + CREATE TABLE public.applied_add_ons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + add_on_id uuid NOT NULL, + customer_id uuid NOT NULL, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.applied_coupons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + coupon_id uuid NOT NULL, + customer_id uuid NOT NULL, + status integer DEFAULT 0 NOT NULL, + amount_cents bigint, + amount_currency character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + terminated_at timestamp without time zone, + percentage_rate numeric(10,5), + frequency integer DEFAULT 0 NOT NULL, + frequency_duration integer, + frequency_duration_remaining integer + ); + + CREATE TABLE public.applied_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + display_name character varying, + details character varying, + invoice_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.applied_usage_thresholds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + usage_threshold_id uuid NOT NULL, + invoice_id uuid NOT NULL, + lifetime_usage_amount_cents bigint DEFAULT 0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.billable_metric_filters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + billable_metric_id uuid NOT NULL, + key character varying NOT NULL, + "values" character varying[] DEFAULT '{}'::character varying[] NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone + ); + + CREATE TABLE public.billable_metrics ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description character varying, + properties jsonb DEFAULT '{}'::jsonb, + aggregation_type integer NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + field_name character varying, + deleted_at timestamp(6) without time zone, + recurring boolean DEFAULT false NOT NULL, + weighted_interval public.billable_metric_weighted_interval, + custom_aggregator text, + expression character varying, + rounding_function public.billable_metric_rounding_function, + rounding_precision integer + ); + + + -- + -- Name: billable_metrics_grouped_charges; Type: VIEW; Schema: public; Owner: - + -- + + CREATE VIEW public.billable_metrics_grouped_charges AS + SELECT + NULL::uuid AS organization_id, + NULL::character varying AS code, + NULL::integer AS aggregation_type, + NULL::character varying AS field_name, + NULL::uuid AS plan_id, + NULL::uuid AS charge_id, + NULL::boolean AS pay_in_advance, + NULL::jsonb AS grouped_by, + NULL::uuid AS charge_filter_id, + NULL::json AS filters, + NULL::jsonb AS filters_grouped_by; + + CREATE TABLE public.cached_aggregations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + event_id uuid, + "timestamp" timestamp(6) without time zone NOT NULL, + external_subscription_id character varying NOT NULL, + charge_id uuid NOT NULL, + group_id uuid, + current_aggregation numeric, + max_aggregation numeric, + max_aggregation_with_proration numeric, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid, + current_amount numeric, + event_transaction_id character varying + ); + + CREATE TABLE public.charge_filter_values ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_filter_id uuid NOT NULL, + billable_metric_filter_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + "values" character varying[] DEFAULT '{}'::character varying[] NOT NULL + ); + + CREATE TABLE public.charge_filters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_id uuid NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + invoice_display_name character varying + ); + + CREATE TABLE public.charges ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + billable_metric_id uuid, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + plan_id uuid, + amount_currency character varying, + charge_model integer DEFAULT 0 NOT NULL, + properties jsonb DEFAULT '"{}"'::jsonb NOT NULL, + deleted_at timestamp(6) without time zone, + pay_in_advance boolean DEFAULT false NOT NULL, + min_amount_cents bigint DEFAULT 0 NOT NULL, + invoiceable boolean DEFAULT true NOT NULL, + prorated boolean DEFAULT false NOT NULL, + invoice_display_name character varying, + regroup_paid_fees integer, + parent_id uuid + ); + + CREATE TABLE public.charges_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.commitments ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + plan_id uuid NOT NULL, + commitment_type integer NOT NULL, + amount_cents bigint NOT NULL, + invoice_display_name character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.commitments_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + commitment_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.coupon_targets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + coupon_id uuid NOT NULL, + plan_id uuid, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + billable_metric_id uuid + ); + + CREATE TABLE public.coupons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying, + status integer DEFAULT 0 NOT NULL, + terminated_at timestamp(6) without time zone, + amount_cents bigint, + amount_currency character varying, + expiration integer NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + coupon_type integer DEFAULT 0 NOT NULL, + percentage_rate numeric(10,5), + frequency integer DEFAULT 0 NOT NULL, + frequency_duration integer, + expiration_at timestamp(6) without time zone, + reusable boolean DEFAULT true NOT NULL, + limited_plans boolean DEFAULT false NOT NULL, + deleted_at timestamp(6) without time zone, + limited_billable_metrics boolean DEFAULT false NOT NULL, + description text + ); + + CREATE TABLE public.credit_note_items ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + credit_note_id uuid NOT NULL, + fee_id uuid, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + precise_amount_cents numeric(30,5) NOT NULL + ); + + CREATE TABLE public.credit_notes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + invoice_id uuid NOT NULL, + sequential_id integer NOT NULL, + number character varying NOT NULL, + credit_amount_cents bigint DEFAULT 0 NOT NULL, + credit_amount_currency character varying NOT NULL, + credit_status integer, + balance_amount_cents bigint DEFAULT 0 NOT NULL, + balance_amount_currency character varying DEFAULT '0'::character varying NOT NULL, + reason integer NOT NULL, + file character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + total_amount_cents bigint DEFAULT 0 NOT NULL, + total_amount_currency character varying NOT NULL, + refund_amount_cents bigint DEFAULT 0 NOT NULL, + refund_amount_currency character varying, + refund_status integer, + voided_at timestamp(6) without time zone, + description text, + taxes_amount_cents bigint DEFAULT 0 NOT NULL, + refunded_at timestamp(6) without time zone, + issuing_date date NOT NULL, + status integer DEFAULT 1 NOT NULL, + coupons_adjustment_amount_cents bigint DEFAULT 0 NOT NULL, + precise_coupons_adjustment_amount_cents numeric(30,5) DEFAULT 0.0 NOT NULL, + precise_taxes_amount_cents numeric(30,5) DEFAULT 0.0 NOT NULL, + taxes_rate double precision DEFAULT 0.0 NOT NULL + ); + + CREATE TABLE public.credit_notes_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + credit_note_id uuid NOT NULL, + tax_id uuid, + tax_description character varying, + tax_code character varying NOT NULL, + tax_name character varying NOT NULL, + tax_rate double precision DEFAULT 0.0 NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + base_amount_cents bigint DEFAULT 0 NOT NULL + ); + + CREATE TABLE public.credits ( + invoice_id uuid, + applied_coupon_id uuid, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + credit_note_id uuid, + before_taxes boolean DEFAULT false NOT NULL, + id uuid DEFAULT gen_random_uuid() NOT NULL, + progressive_billing_invoice_id uuid + ); + + CREATE TABLE public.customer_metadata ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + key character varying NOT NULL, + value character varying NOT NULL, + display_in_invoice boolean DEFAULT false NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + external_id character varying NOT NULL, + name character varying, + organization_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + country character varying, + address_line1 character varying, + address_line2 character varying, + state character varying, + zipcode character varying, + email character varying, + city character varying, + url character varying, + phone character varying, + logo_url character varying, + legal_name character varying, + legal_number character varying, + vat_rate double precision, + payment_provider character varying, + slug character varying, + sequential_id bigint, + currency character varying, + invoice_grace_period integer, + timezone character varying, + deleted_at timestamp(6) without time zone, + document_locale character varying, + tax_identification_number character varying, + net_payment_term integer, + external_salesforce_id character varying, + payment_provider_code character varying, + shipping_address_line1 character varying, + shipping_address_line2 character varying, + shipping_city character varying, + shipping_zipcode character varying, + shipping_state character varying, + shipping_country character varying, + finalize_zero_amount_invoice integer DEFAULT 0 NOT NULL, + firstname character varying, + lastname character varying, + customer_type public.customer_type, + applied_dunning_campaign_id uuid, + exclude_from_dunning_campaign boolean DEFAULT false NOT NULL, + last_dunning_campaign_attempt integer DEFAULT 0 NOT NULL, + last_dunning_campaign_attempt_at timestamp without time zone, + skip_invoice_custom_sections boolean DEFAULT false NOT NULL, + account_type public.customer_account_type DEFAULT 'customer'::public.customer_account_type NOT NULL, + CONSTRAINT check_customers_on_invoice_grace_period CHECK ((invoice_grace_period >= 0)), + CONSTRAINT check_customers_on_net_payment_term CHECK ((net_payment_term >= 0)) + ); + + CREATE TABLE public.customers_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.daily_usages ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid NOT NULL, + subscription_id uuid NOT NULL, + external_subscription_id character varying NOT NULL, + from_datetime timestamp(6) without time zone NOT NULL, + to_datetime timestamp(6) without time zone NOT NULL, + usage jsonb DEFAULT '"{}"'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + refreshed_at timestamp(6) without time zone NOT NULL, + usage_diff jsonb DEFAULT '"{}"'::jsonb NOT NULL, + usage_date date + ); + + CREATE TABLE public.data_export_parts ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + index integer, + data_export_id uuid NOT NULL, + object_ids uuid[] NOT NULL, + completed boolean DEFAULT false NOT NULL, + csv_lines text, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.data_exports ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + format integer, + resource_type character varying NOT NULL, + resource_query jsonb DEFAULT '{}'::jsonb, + status integer DEFAULT 0 NOT NULL, + expires_at timestamp without time zone, + started_at timestamp without time zone, + completed_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + membership_id uuid, + organization_id uuid + ); + + CREATE TABLE public.dunning_campaign_thresholds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + dunning_campaign_id uuid NOT NULL, + currency character varying NOT NULL, + amount_cents bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp without time zone + ); + + CREATE TABLE public.dunning_campaigns ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description text, + applied_to_organization boolean DEFAULT false NOT NULL, + days_between_attempts integer DEFAULT 1 NOT NULL, + max_attempts integer DEFAULT 1 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp without time zone + ); + + CREATE TABLE public.error_details ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + owner_type character varying NOT NULL, + owner_id uuid NOT NULL, + organization_id uuid NOT NULL, + details jsonb DEFAULT '{}'::jsonb NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + error_code integer DEFAULT 0 NOT NULL + ); + + CREATE TABLE public.events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid, + transaction_id character varying NOT NULL, + code character varying NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + "timestamp" timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + subscription_id uuid, + deleted_at timestamp(6) without time zone, + external_customer_id character varying, + external_subscription_id character varying, + precise_total_amount_cents numeric(40,15) + ) + WITH (autovacuum_vacuum_scale_factor='0.005'); + + CREATE TABLE public.fees ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid, + charge_id uuid, + subscription_id uuid, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + taxes_amount_cents bigint NOT NULL, + taxes_rate double precision DEFAULT 0.0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + units numeric DEFAULT 0.0 NOT NULL, + applied_add_on_id uuid, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + fee_type integer, + invoiceable_type character varying, + invoiceable_id uuid, + events_count integer, + group_id uuid, + pay_in_advance_event_id uuid, + payment_status integer DEFAULT 0 NOT NULL, + succeeded_at timestamp(6) without time zone, + failed_at timestamp(6) without time zone, + refunded_at timestamp(6) without time zone, + true_up_parent_fee_id uuid, + add_on_id uuid, + description character varying, + unit_amount_cents bigint DEFAULT 0 NOT NULL, + pay_in_advance boolean DEFAULT false NOT NULL, + precise_coupons_amount_cents numeric(30,5) DEFAULT 0.0 NOT NULL, + total_aggregated_units numeric, + invoice_display_name character varying, + precise_unit_amount numeric(30,15) DEFAULT 0.0 NOT NULL, + amount_details jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + pay_in_advance_event_transaction_id character varying, + deleted_at timestamp(6) without time zone, + precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL, + taxes_precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL, + taxes_base_rate double precision DEFAULT 1.0 NOT NULL, + organization_id uuid + ); + + CREATE TABLE public.fees_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + fee_id uuid NOT NULL, + tax_id uuid, + tax_description character varying, + tax_code character varying NOT NULL, + tax_name character varying NOT NULL, + tax_rate double precision DEFAULT 0.0 NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL + ); + + CREATE TABLE public.group_properties ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_id uuid NOT NULL, + group_id uuid NOT NULL, + "values" jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + invoice_display_name character varying + ); + + CREATE TABLE public.groups ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + billable_metric_id uuid NOT NULL, + parent_group_id uuid, + key character varying NOT NULL, + value character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone + ); + + CREATE TABLE public.inbound_webhooks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source character varying NOT NULL, + event_type character varying NOT NULL, + payload jsonb NOT NULL, + status public.inbound_webhook_status DEFAULT 'pending'::public.inbound_webhook_status NOT NULL, + organization_id uuid NOT NULL, + code character varying, + signature character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + processing_at timestamp without time zone + ); + + CREATE TABLE public.integration_collection_mappings ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + mapping_type integer NOT NULL, + type character varying NOT NULL, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.integration_customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + customer_id uuid NOT NULL, + external_customer_id character varying, + type character varying NOT NULL, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.integration_items ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + item_type integer NOT NULL, + external_id character varying NOT NULL, + external_account_code character varying, + external_name character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.integration_mappings ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + mappable_type character varying NOT NULL, + mappable_id uuid NOT NULL, + type character varying NOT NULL, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.integration_resources ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + syncable_type character varying NOT NULL, + syncable_id uuid NOT NULL, + external_id character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + integration_id uuid, + resource_type integer DEFAULT 0 NOT NULL + ); + + CREATE TABLE public.integrations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + type character varying NOT NULL, + secrets character varying, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.invites ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + membership_id uuid, + email character varying NOT NULL, + token character varying NOT NULL, + status integer DEFAULT 0 NOT NULL, + accepted_at timestamp(6) without time zone, + revoked_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + role integer DEFAULT 0 NOT NULL + ); + + CREATE TABLE public.invoice_custom_section_selections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_custom_section_id uuid NOT NULL, + organization_id uuid, + customer_id uuid, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description character varying, + display_name character varying, + details character varying, + organization_id uuid NOT NULL, + deleted_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.invoice_errors ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + backtrace text, + invoice json, + subscriptions json, + error json, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.invoice_metadata ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + key character varying NOT NULL, + value character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.invoice_subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + subscription_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + recurring boolean, + "timestamp" timestamp(6) without time zone, + from_datetime timestamp(6) without time zone, + to_datetime timestamp(6) without time zone, + charges_from_datetime timestamp(6) without time zone, + charges_to_datetime timestamp(6) without time zone, + invoicing_reason public.subscription_invoicing_reason + ); + + CREATE TABLE public.invoices ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + issuing_date date, + taxes_amount_cents bigint DEFAULT 0 NOT NULL, + total_amount_cents bigint DEFAULT 0 NOT NULL, + invoice_type integer DEFAULT 0 NOT NULL, + payment_status integer DEFAULT 0 NOT NULL, + number character varying DEFAULT ''::character varying NOT NULL, + sequential_id integer, + file character varying, + customer_id uuid, + taxes_rate double precision DEFAULT 0.0 NOT NULL, + status integer DEFAULT 1 NOT NULL, + timezone character varying DEFAULT 'UTC'::character varying NOT NULL, + payment_attempts integer DEFAULT 0 NOT NULL, + ready_for_payment_processing boolean DEFAULT true NOT NULL, + organization_id uuid NOT NULL, + version_number integer DEFAULT 4 NOT NULL, + currency character varying, + fees_amount_cents bigint DEFAULT 0 NOT NULL, + coupons_amount_cents bigint DEFAULT 0 NOT NULL, + credit_notes_amount_cents bigint DEFAULT 0 NOT NULL, + prepaid_credit_amount_cents bigint DEFAULT 0 NOT NULL, + sub_total_excluding_taxes_amount_cents bigint DEFAULT 0 NOT NULL, + sub_total_including_taxes_amount_cents bigint DEFAULT 0 NOT NULL, + payment_due_date date, + net_payment_term integer DEFAULT 0 NOT NULL, + voided_at timestamp(6) without time zone, + organization_sequential_id integer DEFAULT 0 NOT NULL, + ready_to_be_refreshed boolean DEFAULT false NOT NULL, + payment_dispute_lost_at timestamp(6) without time zone DEFAULT NULL::timestamp without time zone, + skip_charges boolean DEFAULT false NOT NULL, + payment_overdue boolean DEFAULT false, + negative_amount_cents bigint DEFAULT 0 NOT NULL, + progressive_billing_credit_amount_cents bigint DEFAULT 0 NOT NULL, + tax_status public.tax_status, + total_paid_amount_cents bigint DEFAULT 0 NOT NULL, + self_billed boolean DEFAULT false NOT NULL, + CONSTRAINT check_organizations_on_net_payment_term CHECK ((net_payment_term >= 0)) + ); + + CREATE TABLE public.invoices_payment_requests ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + payment_request_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.invoices_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + tax_id uuid, + tax_description character varying, + tax_code character varying NOT NULL, + tax_name character varying NOT NULL, + tax_rate double precision DEFAULT 0.0 NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + fees_amount_cents bigint DEFAULT 0 NOT NULL, + taxable_base_amount_cents bigint DEFAULT 0 NOT NULL + ); + + CREATE MATERIALIZED VIEW public.last_hour_events_mv AS + WITH billable_metric_filters AS ( + SELECT billable_metrics_1.organization_id AS bm_organization_id, + billable_metrics_1.id AS bm_id, + billable_metrics_1.code AS bm_code, + filters.key AS filter_key, + filters."values" AS filter_values + FROM (public.billable_metrics billable_metrics_1 + JOIN public.billable_metric_filters filters ON ((filters.billable_metric_id = billable_metrics_1.id))) + WHERE ((billable_metrics_1.deleted_at IS NULL) AND (filters.deleted_at IS NULL)) + ) + SELECT events.organization_id, + events.transaction_id, + events.properties, + billable_metrics.code AS billable_metric_code, + (billable_metrics.aggregation_type <> 0) AS field_name_mandatory, + (billable_metrics.aggregation_type = ANY (ARRAY[1, 2, 5, 6])) AS numeric_field_mandatory, + (events.properties ->> (billable_metrics.field_name)::text) AS field_value, + ((events.properties ->> (billable_metrics.field_name)::text) ~ '^-?\\d+(\\.\\d+)?$'::text) AS is_numeric_field_value, + (events.properties ? (billable_metric_filters.filter_key)::text) AS has_filter_keys, + ((events.properties ->> (billable_metric_filters.filter_key)::text) = ANY ((billable_metric_filters.filter_values)::text[])) AS has_valid_filter_values + FROM ((public.events + LEFT JOIN public.billable_metrics ON ((((billable_metrics.code)::text = (events.code)::text) AND (events.organization_id = billable_metrics.organization_id)))) + LEFT JOIN billable_metric_filters ON ((billable_metrics.id = billable_metric_filters.bm_id))) + WHERE ((events.deleted_at IS NULL) AND (events.created_at >= (date_trunc('hour'::text, now()) - '01:00:00'::interval)) AND (events.created_at < date_trunc('hour'::text, now())) AND (billable_metrics.deleted_at IS NULL)) + WITH NO DATA; + + CREATE TABLE public.lifetime_usages ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + subscription_id uuid NOT NULL, + current_usage_amount_cents bigint DEFAULT 0 NOT NULL, + invoiced_usage_amount_cents bigint DEFAULT 0 NOT NULL, + recalculate_current_usage boolean DEFAULT false NOT NULL, + recalculate_invoiced_usage boolean DEFAULT false NOT NULL, + current_usage_amount_refreshed_at timestamp without time zone, + invoiced_usage_amount_refreshed_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + historical_usage_amount_cents bigint DEFAULT 0 NOT NULL + ); + + CREATE TABLE public.memberships ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + user_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + role integer DEFAULT 0 NOT NULL, + status integer DEFAULT 0 NOT NULL, + revoked_at timestamp(6) without time zone + ); + + CREATE TABLE public.organizations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + api_key character varying, + webhook_url character varying, + vat_rate double precision DEFAULT 0.0 NOT NULL, + country character varying, + address_line1 character varying, + address_line2 character varying, + state character varying, + zipcode character varying, + email character varying, + city character varying, + logo character varying, + legal_name character varying, + legal_number character varying, + invoice_footer text, + invoice_grace_period integer DEFAULT 0 NOT NULL, + timezone character varying DEFAULT 'UTC'::character varying NOT NULL, + document_locale character varying DEFAULT 'en'::character varying NOT NULL, + email_settings character varying[] DEFAULT '{}'::character varying[] NOT NULL, + tax_identification_number character varying, + net_payment_term integer DEFAULT 0 NOT NULL, + default_currency character varying DEFAULT 'USD'::character varying NOT NULL, + document_numbering integer DEFAULT 0 NOT NULL, + document_number_prefix character varying, + eu_tax_management boolean DEFAULT false, + clickhouse_aggregation boolean DEFAULT false NOT NULL, + premium_integrations character varying[] DEFAULT '{}'::character varying[] NOT NULL, + custom_aggregation boolean DEFAULT false, + finalize_zero_amount_invoice boolean DEFAULT true NOT NULL, + clickhouse_events_store boolean DEFAULT false NOT NULL, + hmac_key character varying NOT NULL, + CONSTRAINT check_organizations_on_invoice_grace_period CHECK ((invoice_grace_period >= 0)), + CONSTRAINT check_organizations_on_net_payment_term CHECK ((net_payment_term >= 0)) + ); + + CREATE TABLE public.password_resets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + token character varying NOT NULL, + expire_at timestamp(6) without time zone NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.payment_provider_customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + payment_provider_id uuid, + type character varying NOT NULL, + provider_customer_id character varying, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone + ); + + CREATE TABLE public.payment_providers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + type character varying NOT NULL, + secrets character varying, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + code character varying NOT NULL, + name character varying NOT NULL, + deleted_at timestamp(6) without time zone + ); + + CREATE TABLE public.payment_requests ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + email character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL, + payment_status integer DEFAULT 0 NOT NULL, + payment_attempts integer DEFAULT 0 NOT NULL, + ready_for_payment_processing boolean DEFAULT true NOT NULL, + dunning_campaign_id uuid + ); + + CREATE TABLE public.payments ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid, + payment_provider_id uuid, + payment_provider_customer_id uuid, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + provider_payment_id character varying, + status character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + payable_type character varying DEFAULT 'Invoice'::character varying NOT NULL, + payable_id uuid, + provider_payment_data jsonb DEFAULT '{}'::jsonb, + payable_payment_status public.payment_payable_payment_status, + payment_type public.payment_type DEFAULT 'provider'::public.payment_type NOT NULL, + reference character varying + ); + + CREATE TABLE public.plans ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + code character varying NOT NULL, + "interval" integer NOT NULL, + description character varying, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + trial_period double precision, + pay_in_advance boolean DEFAULT false NOT NULL, + bill_charges_monthly boolean, + parent_id uuid, + deleted_at timestamp(6) without time zone, + pending_deletion boolean DEFAULT false NOT NULL, + invoice_display_name character varying + ); + + CREATE TABLE public.plans_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + plan_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.quantified_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + external_subscription_id character varying NOT NULL, + external_id character varying, + added_at timestamp(6) without time zone NOT NULL, + removed_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + billable_metric_id uuid, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + deleted_at timestamp(6) without time zone, + group_id uuid, + organization_id uuid NOT NULL, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid + ); + + CREATE TABLE public.recurring_transaction_rules ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + wallet_id uuid NOT NULL, + trigger integer DEFAULT 0 NOT NULL, + paid_credits numeric(30,5) DEFAULT 0.0 NOT NULL, + granted_credits numeric(30,5) DEFAULT 0.0 NOT NULL, + threshold_credits numeric(30,5) DEFAULT 0.0, + "interval" integer DEFAULT 0, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + method integer DEFAULT 0 NOT NULL, + target_ongoing_balance numeric(30,5), + started_at timestamp(6) without time zone, + invoice_requires_successful_payment boolean DEFAULT false NOT NULL, + transaction_metadata jsonb DEFAULT '[]'::jsonb + ); + + CREATE TABLE public.refunds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + payment_id uuid NOT NULL, + credit_note_id uuid NOT NULL, + payment_provider_id uuid, + payment_provider_customer_id uuid NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + status character varying NOT NULL, + provider_refund_id character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + plan_id uuid NOT NULL, + status integer NOT NULL, + canceled_at timestamp without time zone, + terminated_at timestamp without time zone, + started_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + previous_subscription_id uuid, + name character varying, + external_id character varying NOT NULL, + billing_time integer DEFAULT 0 NOT NULL, + subscription_at timestamp(6) without time zone, + ending_at timestamp(6) without time zone, + trial_ended_at timestamp(6) without time zone + ); + + CREATE TABLE public.taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + description character varying, + code character varying NOT NULL, + name character varying NOT NULL, + rate double precision DEFAULT 0.0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + applied_to_organization boolean DEFAULT false NOT NULL, + auto_generated boolean DEFAULT false NOT NULL + ); + + CREATE TABLE public.usage_thresholds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + plan_id uuid NOT NULL, + threshold_display_name character varying, + amount_cents bigint NOT NULL, + recurring boolean DEFAULT false NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone + ); + + CREATE TABLE public.users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + email character varying, + password_digest character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL + ); + + CREATE TABLE public.versions ( + id bigint NOT NULL, + item_type character varying NOT NULL, + item_id character varying NOT NULL, + event character varying NOT NULL, + whodunnit character varying, + object jsonb, + object_changes jsonb, + created_at timestamp(6) without time zone, + lago_version character varying + ); + + + -- + -- Name: versions_id_seq; Type: SEQUENCE; Schema: public; Owner: - + -- + + CREATE SEQUENCE public.versions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + + -- + -- Name: versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - + -- + + ALTER SEQUENCE public.versions_id_seq OWNED BY public.versions.id; + + CREATE TABLE public.wallet_transactions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + wallet_id uuid NOT NULL, + transaction_type integer NOT NULL, + status integer NOT NULL, + amount numeric(30,5) DEFAULT 0.0 NOT NULL, + credit_amount numeric(30,5) DEFAULT 0.0 NOT NULL, + settled_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + invoice_id uuid, + source integer DEFAULT 0 NOT NULL, + transaction_status integer DEFAULT 0 NOT NULL, + invoice_requires_successful_payment boolean DEFAULT false NOT NULL, + metadata jsonb DEFAULT '[]'::jsonb, + credit_note_id uuid + ); + + CREATE TABLE public.wallets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + status integer NOT NULL, + name character varying, + rate_amount numeric(30,5) DEFAULT 0.0 NOT NULL, + credits_balance numeric(30,5) DEFAULT 0.0 NOT NULL, + consumed_credits numeric(30,5) DEFAULT 0.0 NOT NULL, + expiration_at timestamp without time zone, + last_balance_sync_at timestamp without time zone, + last_consumed_credit_at timestamp without time zone, + terminated_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + balance_cents bigint DEFAULT 0 NOT NULL, + balance_currency character varying NOT NULL, + consumed_amount_cents bigint DEFAULT 0 NOT NULL, + consumed_amount_currency character varying NOT NULL, + ongoing_balance_cents bigint DEFAULT 0 NOT NULL, + ongoing_usage_balance_cents bigint DEFAULT 0 NOT NULL, + credits_ongoing_balance numeric(30,5) DEFAULT 0.0 NOT NULL, + credits_ongoing_usage_balance numeric(30,5) DEFAULT 0.0 NOT NULL, + depleted_ongoing_balance boolean DEFAULT false NOT NULL, + invoice_requires_successful_payment boolean DEFAULT false NOT NULL, + lock_version integer DEFAULT 0 NOT NULL, + ready_to_be_refreshed boolean DEFAULT false NOT NULL + ); + + CREATE TABLE public.webhook_endpoints ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + webhook_url character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + signature_algo integer DEFAULT 0 NOT NULL + ); + + CREATE TABLE public.webhooks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + object_id uuid, + object_type character varying, + status integer DEFAULT 0 NOT NULL, + retries integer DEFAULT 0 NOT NULL, + http_status integer, + endpoint character varying, + webhook_type character varying, + payload json, + response json, + last_retried_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + webhook_endpoint_id uuid + ); + + ALTER TABLE ONLY public.versions ALTER COLUMN id SET DEFAULT nextval('public.versions_id_seq'::regclass); + + + ALTER TABLE ONLY public.active_storage_attachments + ADD CONSTRAINT active_storage_attachments_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.active_storage_blobs + ADD CONSTRAINT active_storage_blobs_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.active_storage_variant_records + ADD CONSTRAINT active_storage_variant_records_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.add_ons + ADD CONSTRAINT add_ons_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.add_ons_taxes + ADD CONSTRAINT add_ons_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT adjusted_fees_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.applied_add_ons + ADD CONSTRAINT applied_add_ons_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.applied_coupons + ADD CONSTRAINT applied_coupons_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.applied_invoice_custom_sections + ADD CONSTRAINT applied_invoice_custom_sections_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.applied_usage_thresholds + ADD CONSTRAINT applied_usage_thresholds_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.billable_metric_filters + ADD CONSTRAINT billable_metric_filters_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.billable_metrics + ADD CONSTRAINT billable_metrics_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.cached_aggregations + ADD CONSTRAINT cached_aggregations_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.charge_filter_values + ADD CONSTRAINT charge_filter_values_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.charge_filters + ADD CONSTRAINT charge_filters_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.charges + ADD CONSTRAINT charges_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.charges_taxes + ADD CONSTRAINT charges_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.commitments + ADD CONSTRAINT commitments_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.commitments_taxes + ADD CONSTRAINT commitments_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT coupon_targets_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.coupons + ADD CONSTRAINT coupons_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.credit_note_items + ADD CONSTRAINT credit_note_items_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.credit_notes + ADD CONSTRAINT credit_notes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.credit_notes_taxes + ADD CONSTRAINT credit_notes_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.credits + ADD CONSTRAINT credits_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.customer_metadata + ADD CONSTRAINT customer_metadata_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.customers + ADD CONSTRAINT customers_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.customers_taxes + ADD CONSTRAINT customers_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT daily_usages_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.data_export_parts + ADD CONSTRAINT data_export_parts_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT data_exports_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.dunning_campaign_thresholds + ADD CONSTRAINT dunning_campaign_thresholds_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.dunning_campaigns + ADD CONSTRAINT dunning_campaigns_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.error_details + ADD CONSTRAINT error_details_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.events + ADD CONSTRAINT events_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fees_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.fees_taxes + ADD CONSTRAINT fees_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.group_properties + ADD CONSTRAINT group_properties_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.groups + ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.inbound_webhooks + ADD CONSTRAINT inbound_webhooks_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.integration_collection_mappings + ADD CONSTRAINT integration_collection_mappings_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.integration_customers + ADD CONSTRAINT integration_customers_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.integration_items + ADD CONSTRAINT integration_items_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.integration_mappings + ADD CONSTRAINT integration_mappings_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.integration_resources + ADD CONSTRAINT integration_resources_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.integrations + ADD CONSTRAINT integrations_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invites + ADD CONSTRAINT invites_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoice_custom_section_selections + ADD CONSTRAINT invoice_custom_section_selections_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoice_custom_sections + ADD CONSTRAINT invoice_custom_sections_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoice_errors + ADD CONSTRAINT invoice_errors_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoice_metadata + ADD CONSTRAINT invoice_metadata_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT invoice_subscriptions_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoices_payment_requests + ADD CONSTRAINT invoices_payment_requests_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoices + ADD CONSTRAINT invoices_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.invoices_taxes + ADD CONSTRAINT invoices_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.lifetime_usages + ADD CONSTRAINT lifetime_usages_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.memberships + ADD CONSTRAINT memberships_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.organizations + ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.password_resets + ADD CONSTRAINT password_resets_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.payment_provider_customers + ADD CONSTRAINT payment_provider_customers_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.payment_providers + ADD CONSTRAINT payment_providers_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT payment_requests_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.plans + ADD CONSTRAINT plans_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.plans_taxes + ADD CONSTRAINT plans_taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.quantified_events + ADD CONSTRAINT quantified_events_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.recurring_transaction_rules + ADD CONSTRAINT recurring_transaction_rules_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.refunds + ADD CONSTRAINT refunds_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT subscriptions_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.taxes + ADD CONSTRAINT taxes_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.usage_thresholds + ADD CONSTRAINT usage_thresholds_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.versions + ADD CONSTRAINT versions_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT wallet_transactions_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.wallets + ADD CONSTRAINT wallets_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.webhook_endpoints + ADD CONSTRAINT webhook_endpoints_pkey PRIMARY KEY (id); + + + ALTER TABLE ONLY public.webhooks + ADD CONSTRAINT webhooks_pkey PRIMARY KEY (id); + + CREATE UNIQUE INDEX idx_on_amount_cents_plan_id_recurring_888044d66b ON public.usage_thresholds USING btree (amount_cents, plan_id, recurring) WHERE (deleted_at IS NULL); + + CREATE UNIQUE INDEX idx_on_dunning_campaign_id_currency_fbf233b2ae ON public.dunning_campaign_thresholds USING btree (dunning_campaign_id, currency) WHERE (deleted_at IS NULL); + + CREATE INDEX idx_on_invoice_custom_section_id_7edbcef7b5 ON public.invoice_custom_section_selections USING btree (invoice_custom_section_id); + + CREATE UNIQUE INDEX idx_on_invoice_id_payment_request_id_aa550779a4 ON public.invoices_payment_requests USING btree (invoice_id, payment_request_id); + + CREATE INDEX idx_on_organization_id_deleted_at_225e3f789d ON public.invoice_custom_sections USING btree (organization_id, deleted_at); + + CREATE INDEX idx_on_organization_id_external_subscription_id_df3a30d96d ON public.daily_usages USING btree (organization_id, external_subscription_id); + + CREATE UNIQUE INDEX idx_on_pay_in_advance_event_transaction_id_charge_i_16302ca167 ON public.fees USING btree (pay_in_advance_event_transaction_id, charge_id, charge_filter_id) WHERE ((created_at > '2025-01-21 00:00:00'::timestamp without time zone) AND (pay_in_advance_event_transaction_id IS NOT NULL) AND (pay_in_advance = true)); + + CREATE INDEX idx_on_timestamp_charge_id_external_subscription_id ON public.cached_aggregations USING btree ("timestamp", charge_id, external_subscription_id); + + CREATE UNIQUE INDEX idx_on_usage_threshold_id_invoice_id_cb82cdf163 ON public.applied_usage_thresholds USING btree (usage_threshold_id, invoice_id); + + CREATE INDEX index_active_charge_filter_values ON public.charge_filter_values USING btree (charge_filter_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_active_charge_filters ON public.charge_filters USING btree (charge_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_active_metric_filters ON public.billable_metric_filters USING btree (billable_metric_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_active_storage_attachments_on_blob_id ON public.active_storage_attachments USING btree (blob_id); + + CREATE UNIQUE INDEX index_active_storage_attachments_uniqueness ON public.active_storage_attachments USING btree (record_type, record_id, name, blob_id); + + CREATE UNIQUE INDEX index_active_storage_blobs_on_key ON public.active_storage_blobs USING btree (key); + + CREATE UNIQUE INDEX index_active_storage_variant_records_uniqueness ON public.active_storage_variant_records USING btree (blob_id, variation_digest); + + CREATE INDEX index_add_ons_on_deleted_at ON public.add_ons USING btree (deleted_at); + + CREATE INDEX index_add_ons_on_organization_id ON public.add_ons USING btree (organization_id); + + CREATE UNIQUE INDEX index_add_ons_on_organization_id_and_code ON public.add_ons USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + CREATE INDEX index_add_ons_taxes_on_add_on_id ON public.add_ons_taxes USING btree (add_on_id); + + CREATE UNIQUE INDEX index_add_ons_taxes_on_add_on_id_and_tax_id ON public.add_ons_taxes USING btree (add_on_id, tax_id); + + CREATE INDEX index_add_ons_taxes_on_tax_id ON public.add_ons_taxes USING btree (tax_id); + + CREATE INDEX index_adjusted_fees_on_charge_filter_id ON public.adjusted_fees USING btree (charge_filter_id); + + CREATE INDEX index_adjusted_fees_on_charge_id ON public.adjusted_fees USING btree (charge_id); + + CREATE INDEX index_adjusted_fees_on_fee_id ON public.adjusted_fees USING btree (fee_id); + + CREATE INDEX index_adjusted_fees_on_group_id ON public.adjusted_fees USING btree (group_id); + + CREATE INDEX index_adjusted_fees_on_invoice_id ON public.adjusted_fees USING btree (invoice_id); + + CREATE INDEX index_adjusted_fees_on_subscription_id ON public.adjusted_fees USING btree (subscription_id); + + CREATE INDEX index_api_keys_on_organization_id ON public.api_keys USING btree (organization_id); + + CREATE UNIQUE INDEX index_api_keys_on_value ON public.api_keys USING btree (value); + + CREATE INDEX index_applied_add_ons_on_add_on_id ON public.applied_add_ons USING btree (add_on_id); + + CREATE INDEX index_applied_add_ons_on_add_on_id_and_customer_id ON public.applied_add_ons USING btree (add_on_id, customer_id); + + CREATE INDEX index_applied_add_ons_on_customer_id ON public.applied_add_ons USING btree (customer_id); + + CREATE INDEX index_applied_coupons_on_coupon_id ON public.applied_coupons USING btree (coupon_id); + + CREATE INDEX index_applied_coupons_on_customer_id ON public.applied_coupons USING btree (customer_id); + + CREATE INDEX index_applied_invoice_custom_sections_on_invoice_id ON public.applied_invoice_custom_sections USING btree (invoice_id); + + CREATE INDEX index_applied_usage_thresholds_on_invoice_id ON public.applied_usage_thresholds USING btree (invoice_id); + + CREATE INDEX index_applied_usage_thresholds_on_usage_threshold_id ON public.applied_usage_thresholds USING btree (usage_threshold_id); + + CREATE INDEX index_billable_metric_filters_on_billable_metric_id ON public.billable_metric_filters USING btree (billable_metric_id); + + CREATE INDEX index_billable_metric_filters_on_deleted_at ON public.billable_metric_filters USING btree (deleted_at); + + CREATE INDEX index_billable_metrics_on_deleted_at ON public.billable_metrics USING btree (deleted_at); + + CREATE INDEX index_billable_metrics_on_org_id_and_code_and_expr ON public.billable_metrics USING btree (organization_id, code, expression) WHERE ((expression IS NOT NULL) AND ((expression)::text <> ''::text)); + + CREATE INDEX index_billable_metrics_on_organization_id ON public.billable_metrics USING btree (organization_id); + + CREATE UNIQUE INDEX index_billable_metrics_on_organization_id_and_code ON public.billable_metrics USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + CREATE INDEX index_cached_aggregations_on_charge_id ON public.cached_aggregations USING btree (charge_id); + + CREATE INDEX index_cached_aggregations_on_event_id ON public.cached_aggregations USING btree (event_id); + + CREATE INDEX index_cached_aggregations_on_event_transaction_id ON public.cached_aggregations USING btree (organization_id, event_transaction_id); + + CREATE INDEX index_cached_aggregations_on_external_subscription_id ON public.cached_aggregations USING btree (external_subscription_id); + + CREATE INDEX index_cached_aggregations_on_group_id ON public.cached_aggregations USING btree (group_id); + + CREATE INDEX index_cached_aggregations_on_organization_id ON public.cached_aggregations USING btree (organization_id); + + CREATE INDEX index_charge_filter_values_on_billable_metric_filter_id ON public.charge_filter_values USING btree (billable_metric_filter_id); + + CREATE INDEX index_charge_filter_values_on_charge_filter_id ON public.charge_filter_values USING btree (charge_filter_id); + + CREATE INDEX index_charge_filter_values_on_deleted_at ON public.charge_filter_values USING btree (deleted_at); + + CREATE INDEX index_charge_filters_on_charge_id ON public.charge_filters USING btree (charge_id); + + CREATE INDEX index_charge_filters_on_deleted_at ON public.charge_filters USING btree (deleted_at); + + CREATE INDEX index_charges_on_billable_metric_id ON public.charges USING btree (billable_metric_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_charges_on_deleted_at ON public.charges USING btree (deleted_at); + + CREATE INDEX index_charges_on_parent_id ON public.charges USING btree (parent_id); + + CREATE INDEX index_charges_on_plan_id ON public.charges USING btree (plan_id); + + CREATE INDEX index_charges_taxes_on_charge_id ON public.charges_taxes USING btree (charge_id); + + CREATE UNIQUE INDEX index_charges_taxes_on_charge_id_and_tax_id ON public.charges_taxes USING btree (charge_id, tax_id); + + CREATE INDEX index_charges_taxes_on_tax_id ON public.charges_taxes USING btree (tax_id); + + CREATE UNIQUE INDEX index_commitments_on_commitment_type_and_plan_id ON public.commitments USING btree (commitment_type, plan_id); + + CREATE INDEX index_commitments_on_plan_id ON public.commitments USING btree (plan_id); + + CREATE INDEX index_commitments_taxes_on_commitment_id ON public.commitments_taxes USING btree (commitment_id); + + CREATE INDEX index_commitments_taxes_on_tax_id ON public.commitments_taxes USING btree (tax_id); + + CREATE INDEX index_coupon_targets_on_billable_metric_id ON public.coupon_targets USING btree (billable_metric_id); + + CREATE INDEX index_coupon_targets_on_coupon_id ON public.coupon_targets USING btree (coupon_id); + + CREATE INDEX index_coupon_targets_on_deleted_at ON public.coupon_targets USING btree (deleted_at); + + CREATE INDEX index_coupon_targets_on_plan_id ON public.coupon_targets USING btree (plan_id); + + CREATE INDEX index_coupons_on_deleted_at ON public.coupons USING btree (deleted_at); + + CREATE INDEX index_coupons_on_organization_id ON public.coupons USING btree (organization_id); + + CREATE UNIQUE INDEX index_coupons_on_organization_id_and_code ON public.coupons USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + CREATE INDEX index_credit_note_items_on_credit_note_id ON public.credit_note_items USING btree (credit_note_id); + + CREATE INDEX index_credit_note_items_on_fee_id ON public.credit_note_items USING btree (fee_id); + + CREATE INDEX index_credit_notes_on_customer_id ON public.credit_notes USING btree (customer_id); + + CREATE INDEX index_credit_notes_on_invoice_id ON public.credit_notes USING btree (invoice_id); + + CREATE INDEX index_credit_notes_taxes_on_credit_note_id ON public.credit_notes_taxes USING btree (credit_note_id); + + CREATE UNIQUE INDEX index_credit_notes_taxes_on_credit_note_id_and_tax_code ON public.credit_notes_taxes USING btree (credit_note_id, tax_code); + + CREATE INDEX index_credit_notes_taxes_on_tax_code ON public.credit_notes_taxes USING btree (tax_code); + + CREATE INDEX index_credit_notes_taxes_on_tax_id ON public.credit_notes_taxes USING btree (tax_id); + + CREATE INDEX index_credits_on_applied_coupon_id ON public.credits USING btree (applied_coupon_id); + + CREATE INDEX index_credits_on_credit_note_id ON public.credits USING btree (credit_note_id); + + CREATE INDEX index_credits_on_invoice_id ON public.credits USING btree (invoice_id); + + CREATE INDEX index_credits_on_progressive_billing_invoice_id ON public.credits USING btree (progressive_billing_invoice_id); + + CREATE INDEX index_customer_metadata_on_customer_id ON public.customer_metadata USING btree (customer_id); + + CREATE UNIQUE INDEX index_customer_metadata_on_customer_id_and_key ON public.customer_metadata USING btree (customer_id, key); + + CREATE INDEX index_customers_on_account_type ON public.customers USING btree (account_type); + + CREATE INDEX index_customers_on_applied_dunning_campaign_id ON public.customers USING btree (applied_dunning_campaign_id); + + CREATE INDEX index_customers_on_deleted_at ON public.customers USING btree (deleted_at); + + CREATE UNIQUE INDEX index_customers_on_external_id_and_organization_id ON public.customers USING btree (external_id, organization_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_customers_on_organization_id ON public.customers USING btree (organization_id); + + CREATE INDEX index_customers_taxes_on_customer_id ON public.customers_taxes USING btree (customer_id); + + CREATE UNIQUE INDEX index_customers_taxes_on_customer_id_and_tax_id ON public.customers_taxes USING btree (customer_id, tax_id); + + CREATE INDEX index_customers_taxes_on_tax_id ON public.customers_taxes USING btree (tax_id); + + CREATE INDEX index_daily_usages_on_customer_id ON public.daily_usages USING btree (customer_id); + + CREATE INDEX index_daily_usages_on_organization_id ON public.daily_usages USING btree (organization_id); + + CREATE INDEX index_daily_usages_on_subscription_id ON public.daily_usages USING btree (subscription_id); + + CREATE INDEX index_daily_usages_on_usage_date ON public.daily_usages USING btree (usage_date); + + CREATE INDEX index_data_export_parts_on_data_export_id ON public.data_export_parts USING btree (data_export_id); + + CREATE INDEX index_data_exports_on_membership_id ON public.data_exports USING btree (membership_id); + + CREATE INDEX index_data_exports_on_organization_id ON public.data_exports USING btree (organization_id); + + CREATE INDEX index_dunning_campaign_thresholds_on_deleted_at ON public.dunning_campaign_thresholds USING btree (deleted_at); + + CREATE INDEX index_dunning_campaign_thresholds_on_dunning_campaign_id ON public.dunning_campaign_thresholds USING btree (dunning_campaign_id); + + CREATE INDEX index_dunning_campaigns_on_deleted_at ON public.dunning_campaigns USING btree (deleted_at); + + CREATE INDEX index_dunning_campaigns_on_organization_id ON public.dunning_campaigns USING btree (organization_id); + + CREATE UNIQUE INDEX index_dunning_campaigns_on_organization_id_and_code ON public.dunning_campaigns USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + CREATE INDEX index_error_details_on_deleted_at ON public.error_details USING btree (deleted_at); + + CREATE INDEX index_error_details_on_error_code ON public.error_details USING btree (error_code); + + CREATE INDEX index_error_details_on_organization_id ON public.error_details USING btree (organization_id); + + CREATE INDEX index_error_details_on_owner ON public.error_details USING btree (owner_type, owner_id); + + CREATE INDEX index_events_on_customer_id ON public.events USING btree (customer_id); + + CREATE INDEX index_events_on_deleted_at ON public.events USING btree (deleted_at); + + CREATE INDEX index_events_on_external_subscription_id_and_code_and_timestamp ON public.events USING btree (organization_id, external_subscription_id, code, "timestamp") WHERE (deleted_at IS NULL); + + CREATE INDEX index_events_on_external_subscription_id_precise_amount ON public.events USING btree (external_subscription_id, code, "timestamp") INCLUDE (organization_id, precise_total_amount_cents) WHERE ((deleted_at IS NULL) AND (precise_total_amount_cents IS NOT NULL)); + + CREATE INDEX index_events_on_external_subscription_id_with_included ON public.events USING btree (external_subscription_id, code, "timestamp") INCLUDE (organization_id, properties) WHERE (deleted_at IS NULL); + + CREATE INDEX index_events_on_organization_id ON public.events USING btree (organization_id); + + CREATE INDEX index_events_on_organization_id_and_code ON public.events USING btree (organization_id, code); + + CREATE INDEX index_events_on_organization_id_and_code_and_created_at ON public.events USING btree (organization_id, code, created_at) WHERE (deleted_at IS NULL); + + CREATE INDEX index_events_on_organization_id_and_timestamp ON public.events USING btree (organization_id, "timestamp") WHERE (deleted_at IS NULL); + + CREATE INDEX index_events_on_properties ON public.events USING gin (properties jsonb_path_ops); + + CREATE INDEX index_events_on_subscription_id ON public.events USING btree (subscription_id); + + CREATE INDEX index_events_on_subscription_id_and_code_and_timestamp ON public.events USING btree (subscription_id, code, "timestamp") WHERE (deleted_at IS NULL); + + CREATE INDEX index_fees_on_add_on_id ON public.fees USING btree (add_on_id); + + CREATE INDEX index_fees_on_applied_add_on_id ON public.fees USING btree (applied_add_on_id); + + CREATE INDEX index_fees_on_charge_filter_id ON public.fees USING btree (charge_filter_id); + + CREATE INDEX index_fees_on_charge_id ON public.fees USING btree (charge_id); + + CREATE INDEX index_fees_on_charge_id_and_invoice_id ON public.fees USING btree (charge_id, invoice_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_fees_on_deleted_at ON public.fees USING btree (deleted_at); + + CREATE INDEX index_fees_on_group_id ON public.fees USING btree (group_id); + + CREATE INDEX index_fees_on_invoice_id ON public.fees USING btree (invoice_id); + + CREATE INDEX index_fees_on_invoiceable ON public.fees USING btree (invoiceable_type, invoiceable_id); + + CREATE INDEX index_fees_on_organization_id ON public.fees USING btree (organization_id); + + CREATE INDEX index_fees_on_pay_in_advance_event_transaction_id ON public.fees USING btree (pay_in_advance_event_transaction_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_fees_on_subscription_id ON public.fees USING btree (subscription_id); + + CREATE INDEX index_fees_on_true_up_parent_fee_id ON public.fees USING btree (true_up_parent_fee_id); + + CREATE INDEX index_fees_taxes_on_fee_id ON public.fees_taxes USING btree (fee_id); + + CREATE UNIQUE INDEX index_fees_taxes_on_fee_id_and_tax_id ON public.fees_taxes USING btree (fee_id, tax_id) WHERE ((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone)); + + CREATE INDEX index_fees_taxes_on_tax_id ON public.fees_taxes USING btree (tax_id); + + CREATE INDEX index_group_properties_on_charge_id ON public.group_properties USING btree (charge_id); + + CREATE UNIQUE INDEX index_group_properties_on_charge_id_and_group_id ON public.group_properties USING btree (charge_id, group_id); + + CREATE INDEX index_group_properties_on_deleted_at ON public.group_properties USING btree (deleted_at); + + CREATE INDEX index_group_properties_on_group_id ON public.group_properties USING btree (group_id); + + CREATE INDEX index_groups_on_billable_metric_id ON public.groups USING btree (billable_metric_id); + + CREATE INDEX index_groups_on_billable_metric_id_and_parent_group_id ON public.groups USING btree (billable_metric_id, parent_group_id); + + CREATE INDEX index_groups_on_deleted_at ON public.groups USING btree (deleted_at); + + CREATE INDEX index_groups_on_parent_group_id ON public.groups USING btree (parent_group_id); + + CREATE INDEX index_inbound_webhooks_on_organization_id ON public.inbound_webhooks USING btree (organization_id); + + CREATE INDEX index_inbound_webhooks_on_status_and_created_at ON public.inbound_webhooks USING btree (status, created_at) WHERE (status = 'pending'::public.inbound_webhook_status); + + CREATE INDEX index_inbound_webhooks_on_status_and_processing_at ON public.inbound_webhooks USING btree (status, processing_at) WHERE (status = 'processing'::public.inbound_webhook_status); + + CREATE UNIQUE INDEX index_int_collection_mappings_on_mapping_type_and_int_id ON public.integration_collection_mappings USING btree (mapping_type, integration_id); + + CREATE UNIQUE INDEX index_int_items_on_external_id_and_int_id_and_type ON public.integration_items USING btree (external_id, integration_id, item_type); + + CREATE INDEX index_integration_collection_mappings_on_integration_id ON public.integration_collection_mappings USING btree (integration_id); + + CREATE INDEX index_integration_customers_on_customer_id ON public.integration_customers USING btree (customer_id); + + CREATE UNIQUE INDEX index_integration_customers_on_customer_id_and_type ON public.integration_customers USING btree (customer_id, type); + + CREATE INDEX index_integration_customers_on_external_customer_id ON public.integration_customers USING btree (external_customer_id); + + CREATE INDEX index_integration_customers_on_integration_id ON public.integration_customers USING btree (integration_id); + + CREATE INDEX index_integration_items_on_integration_id ON public.integration_items USING btree (integration_id); + + CREATE INDEX index_integration_mappings_on_integration_id ON public.integration_mappings USING btree (integration_id); + + CREATE INDEX index_integration_mappings_on_mappable ON public.integration_mappings USING btree (mappable_type, mappable_id); + + CREATE INDEX index_integration_resources_on_integration_id ON public.integration_resources USING btree (integration_id); + + CREATE INDEX index_integration_resources_on_syncable ON public.integration_resources USING btree (syncable_type, syncable_id); + + CREATE UNIQUE INDEX index_integrations_on_code_and_organization_id ON public.integrations USING btree (code, organization_id); + + CREATE INDEX index_integrations_on_organization_id ON public.integrations USING btree (organization_id); + + CREATE INDEX index_invites_on_membership_id ON public.invites USING btree (membership_id); + + CREATE INDEX index_invites_on_organization_id ON public.invites USING btree (organization_id); + + CREATE UNIQUE INDEX index_invites_on_token ON public.invites USING btree (token); + + CREATE INDEX index_invoice_custom_section_selections_on_customer_id ON public.invoice_custom_section_selections USING btree (customer_id); + + CREATE INDEX index_invoice_custom_section_selections_on_organization_id ON public.invoice_custom_section_selections USING btree (organization_id); + + CREATE INDEX index_invoice_custom_sections_on_organization_id ON public.invoice_custom_sections USING btree (organization_id); + + CREATE UNIQUE INDEX index_invoice_custom_sections_on_organization_id_and_code ON public.invoice_custom_sections USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + CREATE INDEX index_invoice_metadata_on_invoice_id ON public.invoice_metadata USING btree (invoice_id); + + CREATE UNIQUE INDEX index_invoice_metadata_on_invoice_id_and_key ON public.invoice_metadata USING btree (invoice_id, key); + + CREATE INDEX index_invoice_subscriptions_boundaries ON public.invoice_subscriptions USING btree (subscription_id, from_datetime, to_datetime); + + CREATE UNIQUE INDEX index_invoice_subscriptions_on_charges_from_and_to_datetime ON public.invoice_subscriptions USING btree (subscription_id, charges_from_datetime, charges_to_datetime) WHERE ((created_at >= '2023-06-09 00:00:00'::timestamp without time zone) AND (recurring IS TRUE)); + + CREATE INDEX index_invoice_subscriptions_on_invoice_id ON public.invoice_subscriptions USING btree (invoice_id); + + CREATE UNIQUE INDEX index_invoice_subscriptions_on_invoice_id_and_subscription_id ON public.invoice_subscriptions USING btree (invoice_id, subscription_id) WHERE (created_at >= '2023-11-23 00:00:00'::timestamp without time zone); + + CREATE INDEX index_invoice_subscriptions_on_subscription_id ON public.invoice_subscriptions USING btree (subscription_id); + + CREATE INDEX index_invoices_on_customer_id ON public.invoices USING btree (customer_id); + + CREATE UNIQUE INDEX index_invoices_on_customer_id_and_sequential_id ON public.invoices USING btree (customer_id, sequential_id); + + CREATE INDEX index_invoices_on_issuing_date ON public.invoices USING btree (issuing_date); + + CREATE INDEX index_invoices_on_number ON public.invoices USING btree (number); + + CREATE INDEX index_invoices_on_organization_id ON public.invoices USING btree (organization_id); + + CREATE INDEX index_invoices_on_payment_overdue ON public.invoices USING btree (payment_overdue); + + CREATE INDEX index_invoices_on_ready_to_be_refreshed ON public.invoices USING btree (ready_to_be_refreshed) WHERE (ready_to_be_refreshed = true); + + CREATE INDEX index_invoices_on_self_billed ON public.invoices USING btree (self_billed); + + CREATE INDEX index_invoices_on_sequential_id ON public.invoices USING btree (sequential_id); + + CREATE INDEX index_invoices_on_status ON public.invoices USING btree (status); + + CREATE INDEX index_invoices_payment_requests_on_invoice_id ON public.invoices_payment_requests USING btree (invoice_id); + + CREATE INDEX index_invoices_payment_requests_on_payment_request_id ON public.invoices_payment_requests USING btree (payment_request_id); + + CREATE INDEX index_invoices_taxes_on_invoice_id ON public.invoices_taxes USING btree (invoice_id); + + CREATE UNIQUE INDEX index_invoices_taxes_on_invoice_id_and_tax_id ON public.invoices_taxes USING btree (invoice_id, tax_id) WHERE ((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone)); + + CREATE INDEX index_invoices_taxes_on_tax_id ON public.invoices_taxes USING btree (tax_id); + + CREATE INDEX index_lifetime_usages_on_organization_id ON public.lifetime_usages USING btree (organization_id); + + CREATE INDEX index_lifetime_usages_on_recalculate_current_usage ON public.lifetime_usages USING btree (recalculate_current_usage) WHERE ((deleted_at IS NULL) AND (recalculate_current_usage = true)); + + CREATE INDEX index_lifetime_usages_on_recalculate_invoiced_usage ON public.lifetime_usages USING btree (recalculate_invoiced_usage) WHERE ((deleted_at IS NULL) AND (recalculate_invoiced_usage = true)); + + CREATE UNIQUE INDEX index_lifetime_usages_on_subscription_id ON public.lifetime_usages USING btree (subscription_id); + + CREATE INDEX index_memberships_on_organization_id ON public.memberships USING btree (organization_id); + + CREATE INDEX index_memberships_on_user_id ON public.memberships USING btree (user_id); + + CREATE UNIQUE INDEX index_memberships_on_user_id_and_organization_id ON public.memberships USING btree (user_id, organization_id) WHERE (revoked_at IS NULL); + + CREATE UNIQUE INDEX index_organizations_on_api_key ON public.organizations USING btree (api_key); + + CREATE UNIQUE INDEX index_organizations_on_hmac_key ON public.organizations USING btree (hmac_key); + + CREATE UNIQUE INDEX index_password_resets_on_token ON public.password_resets USING btree (token); + + CREATE INDEX index_password_resets_on_user_id ON public.password_resets USING btree (user_id); + + CREATE UNIQUE INDEX index_payment_provider_customers_on_customer_id_and_type ON public.payment_provider_customers USING btree (customer_id, type) WHERE (deleted_at IS NULL); + + CREATE INDEX index_payment_provider_customers_on_payment_provider_id ON public.payment_provider_customers USING btree (payment_provider_id); + + CREATE INDEX index_payment_provider_customers_on_provider_customer_id ON public.payment_provider_customers USING btree (provider_customer_id); + + CREATE UNIQUE INDEX index_payment_providers_on_code_and_organization_id ON public.payment_providers USING btree (code, organization_id) WHERE (deleted_at IS NULL); + + CREATE INDEX index_payment_providers_on_organization_id ON public.payment_providers USING btree (organization_id); + + CREATE INDEX index_payment_requests_on_customer_id ON public.payment_requests USING btree (customer_id); + + CREATE INDEX index_payment_requests_on_dunning_campaign_id ON public.payment_requests USING btree (dunning_campaign_id); + + CREATE INDEX index_payment_requests_on_organization_id ON public.payment_requests USING btree (organization_id); + + CREATE INDEX index_payments_on_invoice_id ON public.payments USING btree (invoice_id); + + CREATE UNIQUE INDEX index_payments_on_payable_id_and_payable_type ON public.payments USING btree (payable_id, payable_type) WHERE (payable_payment_status = ANY (ARRAY['pending'::public.payment_payable_payment_status, 'processing'::public.payment_payable_payment_status])); + + CREATE INDEX index_payments_on_payable_type_and_payable_id ON public.payments USING btree (payable_type, payable_id); + + CREATE INDEX index_payments_on_payment_provider_customer_id ON public.payments USING btree (payment_provider_customer_id); + + CREATE INDEX index_payments_on_payment_provider_id ON public.payments USING btree (payment_provider_id); + + CREATE INDEX index_payments_on_payment_type ON public.payments USING btree (payment_type); + + CREATE INDEX index_plans_on_created_at ON public.plans USING btree (created_at); + + CREATE INDEX index_plans_on_deleted_at ON public.plans USING btree (deleted_at); + + CREATE INDEX index_plans_on_organization_id ON public.plans USING btree (organization_id); + + CREATE UNIQUE INDEX index_plans_on_organization_id_and_code ON public.plans USING btree (organization_id, code) WHERE ((deleted_at IS NULL) AND (parent_id IS NULL)); + + CREATE INDEX index_plans_on_parent_id ON public.plans USING btree (parent_id); + + CREATE INDEX index_plans_taxes_on_plan_id ON public.plans_taxes USING btree (plan_id); + + CREATE UNIQUE INDEX index_plans_taxes_on_plan_id_and_tax_id ON public.plans_taxes USING btree (plan_id, tax_id); + + CREATE INDEX index_plans_taxes_on_tax_id ON public.plans_taxes USING btree (tax_id); + + CREATE INDEX index_quantified_events_on_billable_metric_id ON public.quantified_events USING btree (billable_metric_id); + + CREATE INDEX index_quantified_events_on_charge_filter_id ON public.quantified_events USING btree (charge_filter_id); + + CREATE INDEX index_quantified_events_on_deleted_at ON public.quantified_events USING btree (deleted_at); + + CREATE INDEX index_quantified_events_on_external_id ON public.quantified_events USING btree (external_id); + + CREATE INDEX index_quantified_events_on_group_id ON public.quantified_events USING btree (group_id); + + CREATE INDEX index_quantified_events_on_organization_id ON public.quantified_events USING btree (organization_id); + + CREATE INDEX index_recurring_transaction_rules_on_started_at ON public.recurring_transaction_rules USING btree (started_at); + + CREATE INDEX index_recurring_transaction_rules_on_wallet_id ON public.recurring_transaction_rules USING btree (wallet_id); + + CREATE INDEX index_refunds_on_credit_note_id ON public.refunds USING btree (credit_note_id); + + CREATE INDEX index_refunds_on_payment_id ON public.refunds USING btree (payment_id); + + CREATE INDEX index_refunds_on_payment_provider_customer_id ON public.refunds USING btree (payment_provider_customer_id); + + CREATE INDEX index_refunds_on_payment_provider_id ON public.refunds USING btree (payment_provider_id); + + CREATE INDEX index_search_quantified_events ON public.quantified_events USING btree (organization_id, external_subscription_id, billable_metric_id); + + CREATE INDEX index_subscriptions_on_customer_id ON public.subscriptions USING btree (customer_id); + + CREATE INDEX index_subscriptions_on_external_id ON public.subscriptions USING btree (external_id); + + CREATE INDEX index_subscriptions_on_plan_id ON public.subscriptions USING btree (plan_id); + + CREATE INDEX index_subscriptions_on_previous_subscription_id_and_status ON public.subscriptions USING btree (previous_subscription_id, status); + + CREATE INDEX index_subscriptions_on_started_at ON public.subscriptions USING btree (started_at); + + CREATE INDEX index_subscriptions_on_started_at_and_ending_at ON public.subscriptions USING btree (started_at, ending_at); + + CREATE INDEX index_subscriptions_on_status ON public.subscriptions USING btree (status); + + CREATE UNIQUE INDEX index_taxes_on_code_and_organization_id ON public.taxes USING btree (code, organization_id); + + CREATE INDEX index_taxes_on_organization_id ON public.taxes USING btree (organization_id); + + CREATE INDEX index_timestamp_filter_lookup ON public.cached_aggregations USING btree (organization_id, "timestamp", charge_id, charge_filter_id); + + CREATE INDEX index_timestamp_group_lookup ON public.cached_aggregations USING btree (organization_id, "timestamp", charge_id, group_id); + + CREATE INDEX index_timestamp_lookup ON public.cached_aggregations USING btree (organization_id, "timestamp", charge_id); + + CREATE UNIQUE INDEX index_unique_applied_to_organization_per_organization ON public.dunning_campaigns USING btree (organization_id) WHERE ((applied_to_organization = true) AND (deleted_at IS NULL)); + + CREATE UNIQUE INDEX index_unique_starting_subscription_invoice ON public.invoice_subscriptions USING btree (subscription_id, invoicing_reason) WHERE (invoicing_reason = 'subscription_starting'::public.subscription_invoicing_reason); + + CREATE UNIQUE INDEX index_unique_terminating_subscription_invoice ON public.invoice_subscriptions USING btree (subscription_id, invoicing_reason) WHERE (invoicing_reason = 'subscription_terminating'::public.subscription_invoicing_reason); + + CREATE UNIQUE INDEX index_unique_transaction_id ON public.events USING btree (organization_id, external_subscription_id, transaction_id); + + CREATE INDEX index_usage_thresholds_on_plan_id ON public.usage_thresholds USING btree (plan_id); + + CREATE UNIQUE INDEX index_usage_thresholds_on_plan_id_and_recurring ON public.usage_thresholds USING btree (plan_id, recurring) WHERE ((recurring IS TRUE) AND (deleted_at IS NULL)); + + CREATE INDEX index_versions_on_item_type_and_item_id ON public.versions USING btree (item_type, item_id); + + CREATE INDEX index_wallet_transactions_on_credit_note_id ON public.wallet_transactions USING btree (credit_note_id); + + CREATE INDEX index_wallet_transactions_on_invoice_id ON public.wallet_transactions USING btree (invoice_id); + + CREATE INDEX index_wallet_transactions_on_wallet_id ON public.wallet_transactions USING btree (wallet_id); + + CREATE INDEX index_wallets_on_customer_id ON public.wallets USING btree (customer_id); + + CREATE INDEX index_wallets_on_ready_to_be_refreshed ON public.wallets USING btree (ready_to_be_refreshed) WHERE ready_to_be_refreshed; + + CREATE INDEX index_webhook_endpoints_on_organization_id ON public.webhook_endpoints USING btree (organization_id); + + CREATE UNIQUE INDEX index_webhook_endpoints_on_webhook_url_and_organization_id ON public.webhook_endpoints USING btree (webhook_url, organization_id); + + CREATE INDEX index_webhooks_on_webhook_endpoint_id ON public.webhooks USING btree (webhook_endpoint_id); + + CREATE OR REPLACE VIEW public.billable_metrics_grouped_charges AS + SELECT billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id AS charge_id, + charges.pay_in_advance, + CASE + WHEN (charges.charge_model = 0) THEN (charges.properties -> 'grouped_by'::text) + ELSE NULL::jsonb + END AS grouped_by, + charge_filters.id AS charge_filter_id, + json_object_agg(billable_metric_filters.key, COALESCE(charge_filter_values."values", '{}'::character varying[]) ORDER BY billable_metric_filters.key) FILTER (WHERE (billable_metric_filters.key IS NOT NULL)) AS filters, + CASE + WHEN (charges.charge_model = 0) THEN (charge_filters.properties -> 'grouped_by'::text) + ELSE NULL::jsonb + END AS filters_grouped_by + FROM ((((public.billable_metrics + JOIN public.charges ON ((charges.billable_metric_id = billable_metrics.id))) + LEFT JOIN public.charge_filters ON ((charge_filters.charge_id = charges.id))) + LEFT JOIN public.charge_filter_values ON ((charge_filter_values.charge_filter_id = charge_filters.id))) + LEFT JOIN public.billable_metric_filters ON ((charge_filter_values.billable_metric_filter_id = billable_metric_filters.id))) + WHERE ((billable_metrics.deleted_at IS NULL) AND (charges.deleted_at IS NULL) AND (charge_filters.deleted_at IS NULL) AND (charge_filter_values.deleted_at IS NULL) AND (billable_metric_filters.deleted_at IS NULL)) + GROUP BY billable_metrics.organization_id, billable_metrics.code, billable_metrics.aggregation_type, billable_metrics.field_name, charges.plan_id, charges.id, charge_filters.id; + + ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_01a4c0c7db FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_085d1cc97b FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + ALTER TABLE ONLY public.add_ons_taxes + ADD CONSTRAINT fk_rails_08dfe87131 FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); + + ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT fk_rails_0bb6dcc01f FOREIGN KEY (coupon_id) REFERENCES public.coupons(id); + + ALTER TABLE ONLY public.customers_taxes + ADD CONSTRAINT fk_rails_0d2be3d72c FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.invoices + ADD CONSTRAINT fk_rails_0d349e632f FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.integration_customers + ADD CONSTRAINT fk_rails_0e464363cb FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.applied_invoice_custom_sections + ADD CONSTRAINT fk_rails_10428ecad2 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT fk_rails_12d29bc654 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT fk_rails_1454058c96 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + ALTER TABLE ONLY public.customer_metadata + ADD CONSTRAINT fk_rails_195153290d FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_1db0057d9b FOREIGN KEY (applied_coupon_id) REFERENCES public.applied_coupons(id); + + ALTER TABLE ONLY public.webhooks + ADD CONSTRAINT fk_rails_20cc0de4c7 FOREIGN KEY (webhook_endpoint_id) REFERENCES public.webhook_endpoints(id); + + ALTER TABLE ONLY public.plans + ADD CONSTRAINT fk_rails_216ac8a975 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.webhook_endpoints + ADD CONSTRAINT fk_rails_21808fa528 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.cached_aggregations + ADD CONSTRAINT fk_rails_21eb389927 FOREIGN KEY (group_id) REFERENCES public.groups(id); + + ALTER TABLE ONLY public.invoices_taxes + ADD CONSTRAINT fk_rails_22af6c6d28 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.taxes + ADD CONSTRAINT fk_rails_23975f5a47 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.credit_notes_taxes + ADD CONSTRAINT fk_rails_25232a0ec3 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_25267b0e17 FOREIGN KEY (payment_id) REFERENCES public.payments(id); + + ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_2561c00887 FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_257af22645 FOREIGN KEY (true_up_parent_fee_id) REFERENCES public.fees(id); + + ALTER TABLE ONLY public.payment_providers + ADD CONSTRAINT fk_rails_26be2f764d FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.charge_filters + ADD CONSTRAINT fk_rails_27b55b8574 FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + ALTER TABLE ONLY public.wallets + ADD CONSTRAINT fk_rails_2b35eef34b FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_2dc6171f57 FOREIGN KEY (payment_provider_id) REFERENCES public.payment_providers(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_2ea4db3a4c FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT fk_rails_2fb2147151 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_2fd7ee65e6 FOREIGN KEY (progressive_billing_invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_310fcb3585 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT fk_rails_32600e5a72 FOREIGN KEY (dunning_campaign_id) REFERENCES public.dunning_campaigns(id); + + ALTER TABLE ONLY public.lifetime_usages + ADD CONSTRAINT fk_rails_348acbd245 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_34ab152115 FOREIGN KEY (applied_add_on_id) REFERENCES public.applied_add_ons(id); + + ALTER TABLE ONLY public.groups + ADD CONSTRAINT fk_rails_34b5ee1894 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id) ON DELETE CASCADE; + + ALTER TABLE ONLY public.inbound_webhooks + ADD CONSTRAINT fk_rails_36cda06530 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.quantified_events + ADD CONSTRAINT fk_rails_3926855f12 FOREIGN KEY (group_id) REFERENCES public.groups(id); + + ALTER TABLE ONLY public.invoices + ADD CONSTRAINT fk_rails_3a303bf667 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.group_properties + ADD CONSTRAINT fk_rails_3acf9e789c FOREIGN KEY (charge_id) REFERENCES public.charges(id) ON DELETE CASCADE; + + ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT fk_rails_3c7c3920c0 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.charges + ADD CONSTRAINT fk_rails_3cfe1d68d7 FOREIGN KEY (parent_id) REFERENCES public.charges(id); + + ALTER TABLE ONLY public.integration_collection_mappings + ADD CONSTRAINT fk_rails_3d568ff9de FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + ALTER TABLE ONLY public.invoices_payment_requests + ADD CONSTRAINT fk_rails_3ec3563cf3 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_3f7be5debc FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + ALTER TABLE ONLY public.charges_taxes + ADD CONSTRAINT fk_rails_3ff27d7624 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.credit_notes + ADD CONSTRAINT fk_rails_4117574b51 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.payment_provider_customers + ADD CONSTRAINT fk_rails_50d46d3679 FOREIGN KEY (payment_provider_id) REFERENCES public.payment_providers(id); + + ALTER TABLE ONLY public.commitments + ADD CONSTRAINT fk_rails_51ac39a0c6 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_521b5240ed FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.password_resets + ADD CONSTRAINT fk_rails_526379cd99 FOREIGN KEY (user_id) REFERENCES public.users(id); + + ALTER TABLE ONLY public.applied_usage_thresholds + ADD CONSTRAINT fk_rails_52b72c9b0e FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.customers + ADD CONSTRAINT fk_rails_58234c715e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT fk_rails_5a43da571b FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.error_details + ADD CONSTRAINT fk_rails_5c21eece29 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.credit_notes + ADD CONSTRAINT fk_rails_5cb67dee79 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_6023b3f2dd FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); + + ALTER TABLE ONLY public.credit_notes_taxes + ADD CONSTRAINT fk_rails_626209b8d2 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_rails_62d18ea517 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT fk_rails_63d3df128b FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + ALTER TABLE ONLY public.memberships + ADD CONSTRAINT fk_rails_64267aab58 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT fk_rails_66eb6b32c1 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.integration_resources + ADD CONSTRAINT fk_rails_67d4eb3c92 FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + ALTER TABLE ONLY public.invoice_custom_section_selections + ADD CONSTRAINT fk_rails_6b1e3d1159 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.dunning_campaigns + ADD CONSTRAINT fk_rails_6c720a8ccd FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_6d465e6b10 FOREIGN KEY (group_id) REFERENCES public.groups(id); + + ALTER TABLE ONLY public.invoices_taxes + ADD CONSTRAINT fk_rails_6e148ccbb1 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT fk_rails_73d83e23b6 FOREIGN KEY (membership_id) REFERENCES public.memberships(id); + + ALTER TABLE ONLY public.fees_taxes + ADD CONSTRAINT fk_rails_745b4ca7dd FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_75577c354e FOREIGN KEY (payment_provider_customer_id) REFERENCES public.payment_provider_customers(id); + + ALTER TABLE ONLY public.integrations + ADD CONSTRAINT fk_rails_755d734f25 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.groups + ADD CONSTRAINT fk_rails_7886e1bc34 FOREIGN KEY (parent_group_id) REFERENCES public.groups(id); + + ALTER TABLE ONLY public.applied_add_ons + ADD CONSTRAINT fk_rails_7995206484 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.billable_metric_filters + ADD CONSTRAINT fk_rails_7a0704ce72 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT fk_rails_7aab96f30e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_7b324610ad FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + ALTER TABLE ONLY public.invoice_custom_sections + ADD CONSTRAINT fk_rails_7c0e340dbd FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.charge_filter_values + ADD CONSTRAINT fk_rails_7da558cadc FOREIGN KEY (charge_filter_id) REFERENCES public.charge_filters(id); + + ALTER TABLE ONLY public.billable_metrics + ADD CONSTRAINT fk_rails_7e8a2f26e5 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.charges + ADD CONSTRAINT fk_rails_7eb0484711 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + ALTER TABLE ONLY public.add_ons + ADD CONSTRAINT fk_rails_81e3b6abba FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_rails_84f4587409 FOREIGN KEY (payment_provider_id) REFERENCES public.payment_providers(id); + + ALTER TABLE ONLY public.payment_provider_customers + ADD CONSTRAINT fk_rails_86676be631 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT fk_rails_88349fc20a FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.add_ons_taxes + ADD CONSTRAINT fk_rails_89e1020aca FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.invoice_metadata + ADD CONSTRAINT fk_rails_8bb5b094c4 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.commitments_taxes + ADD CONSTRAINT fk_rails_8fa6f0d920 FOREIGN KEY (commitment_id) REFERENCES public.commitments(id); + + ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT fk_rails_90d93bd016 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + ALTER TABLE ONLY public.data_export_parts + ADD CONSTRAINT fk_rails_9298b8fdad FOREIGN KEY (data_export_id) REFERENCES public.data_exports(id); + + ALTER TABLE ONLY public.customers + ADD CONSTRAINT fk_rails_94cc21031f FOREIGN KEY (applied_dunning_campaign_id) REFERENCES public.dunning_campaigns(id); + + ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_98980b326b FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.memberships + ADD CONSTRAINT fk_rails_99326fb65d FOREIGN KEY (user_id) REFERENCES public.users(id); + + ALTER TABLE ONLY public.active_storage_variant_records + ADD CONSTRAINT fk_rails_993965df05 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id); + + ALTER TABLE ONLY public.applied_usage_thresholds + ADD CONSTRAINT fk_rails_9c08b43701 FOREIGN KEY (usage_threshold_id) REFERENCES public.usage_thresholds(id); + + ALTER TABLE ONLY public.plans_taxes + ADD CONSTRAINT fk_rails_9c704027e2 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.applied_add_ons + ADD CONSTRAINT fk_rails_9c8e276cc0 FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); + + ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_9ea6759859 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + ALTER TABLE ONLY public.credit_note_items + ADD CONSTRAINT fk_rails_9f22076477 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + ALTER TABLE ONLY public.invoice_custom_section_selections + ADD CONSTRAINT fk_rails_9ff1d277f3 FOREIGN KEY (invoice_custom_section_id) REFERENCES public.invoice_custom_sections(id); + + ALTER TABLE ONLY public.group_properties + ADD CONSTRAINT fk_rails_a2d2cb3819 FOREIGN KEY (group_id) REFERENCES public.groups(id) ON DELETE CASCADE; + + ALTER TABLE ONLY public.charges + ADD CONSTRAINT fk_rails_a710519346 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + ALTER TABLE ONLY public.integration_items + ADD CONSTRAINT fk_rails_a9dc2ea536 FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + ALTER TABLE ONLY public.commitments_taxes + ADD CONSTRAINT fk_rails_aaa12f7d3e FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.charges_taxes + ADD CONSTRAINT fk_rails_ac146c9541 FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT fk_rails_b07fc711f7 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_b50dc82c1e FOREIGN KEY (group_id) REFERENCES public.groups(id); + + ALTER TABLE ONLY public.lifetime_usages + ADD CONSTRAINT fk_rails_ba128983c2 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.plans_taxes + ADD CONSTRAINT fk_rails_bacde7a063 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + ALTER TABLE ONLY public.dunning_campaign_thresholds + ADD CONSTRAINT fk_rails_bf1f386f75 FOREIGN KEY (dunning_campaign_id) REFERENCES public.dunning_campaigns(id); + + ALTER TABLE ONLY public.charge_filter_values + ADD CONSTRAINT fk_rails_bf661ef73d FOREIGN KEY (billable_metric_filter_id) REFERENCES public.billable_metric_filters(id); + + ALTER TABLE ONLY public.active_storage_attachments + ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id); + + ALTER TABLE ONLY public.invites + ADD CONSTRAINT fk_rails_c71f4b2026 FOREIGN KEY (membership_id) REFERENCES public.memberships(id); + + ALTER TABLE ONLY public.usage_thresholds + ADD CONSTRAINT fk_rails_caeb5a3949 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + ALTER TABLE ONLY public.plans + ADD CONSTRAINT fk_rails_cbf700aeb8 FOREIGN KEY (parent_id) REFERENCES public.plans(id); + + ALTER TABLE ONLY public.integration_mappings + ADD CONSTRAINT fk_rails_cc318ad1ff FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_d07bc24ce3 FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_d9ffb8b4a1 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.invites + ADD CONSTRAINT fk_rails_dd342449a6 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.invoice_custom_section_selections + ADD CONSTRAINT fk_rails_dd7e076158 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT fk_rails_de6b3c3138 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + ALTER TABLE ONLY public.credit_note_items + ADD CONSTRAINT fk_rails_dea748e529 FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + ALTER TABLE ONLY public.customers_taxes + ADD CONSTRAINT fk_rails_e86903e081 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.recurring_transaction_rules + ADD CONSTRAINT fk_rails_e8bac9c5bb FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + ALTER TABLE ONLY public.integration_customers + ADD CONSTRAINT fk_rails_ea80151038 FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_eaca9421be FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + ALTER TABLE ONLY public.invoices_payment_requests + ADD CONSTRAINT fk_rails_ed387e0992 FOREIGN KEY (payment_request_id) REFERENCES public.payment_requests(id); + + ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT fk_rails_f228550fda FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.quantified_events + ADD CONSTRAINT fk_rails_f510acb495 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + ALTER TABLE ONLY public.fees_taxes + ADD CONSTRAINT fk_rails_f98413d404 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_fd399a23d3 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + + SET search_path TO "$user", public; + + INSERT INTO "schema_migrations" (version) VALUES + ('20250120151959'), + ('20250114172823'), + ('20250114163522'), + ('20250103124802'), + ('20241227161927'), + ('20241227154337'), + ('20241224142141'), + ('20241224141116'), + ('20241223154437'), + ('20241223144027'), + ('20241220160748'), + ('20241220095049'), + ('20241220084758'), + ('20241219152909'), + ('20241219145642'), + ('20241219122151'), + ('20241217120924'), + ('20241216140931'), + ('20241216110525'), + ('20241213182343'), + ('20241213142739'), + ('20241128132010'), + ('20241128091634'), + ('20241126141853'), + ('20241126103448'), + ('20241126102447'), + ('20241125194753'), + ('20241122141158'), + ('20241122140603'), + ('20241122134430'), + ('20241122111534'), + ('20241122105327'), + ('20241122105133'), + ('20241122104537'), + ('20241120094557'), + ('20241120090305'), + ('20241120085057'), + ('20241119114948'), + ('20241119110219'), + ('20241118165935'), + ('20241118103032'), + ('20241113181629'), + ('20241108103702'), + ('20241107093418'), + ('20241106104515'), + ('20241101151559'), + ('20241031123415'), + ('20241031102231'), + ('20241031095225'), + ('20241030123528'), + ('20241029141351'), + ('20241025081408'), + ('20241024082941'), + ('20241022144437'), + ('20241021140054'), + ('20241021095706'), + ('20241018112637'), + ('20241017082601'), + ('20241016133129'), + ('20241016104211'), + ('20241015132635'), + ('20241014093451'), + ('20241014000100'), + ('20241011123621'), + ('20241011123148'), + ('20241010055733'), + ('20241008080209'), + ('20241007092701'), + ('20241007083747'), + ('20241001112117'), + ('20241001105523'), + ('20240924114730'), + ('20240920091133'), + ('20240920084727'), + ('20240917145042'), + ('20240917144243'), + ('20240910111203'), + ('20240910093646'), + ('20240906170048'), + ('20240906154644'), + ('20240829093425'), + ('20240823092643'), + ('20240822142524'), + ('20240822082727'), + ('20240822080031'), + ('20240821174724'), + ('20240821172352'), + ('20240821093145'), + ('20240820125840'), + ('20240820090312'), + ('20240819092354'), + ('20240816075711'), + ('20240814144137'), + ('20240813121307'), + ('20240813095718'), + ('20240812130655'), + ('20240808132042'), + ('20240808085506'), + ('20240808080611'), + ('20240807113700'), + ('20240807100609'), + ('20240807072052'), + ('20240802115017'), + ('20240801142242'), + ('20240801134833'), + ('20240801134832'), + ('20240729154334'), + ('20240729152352'), + ('20240729151049'), + ('20240729134020'), + ('20240729133823'), + ('20240723150304'), + ('20240723150221'), + ('20240722201341'), + ('20240718105718'), + ('20240718080929'), + ('20240716154636'), + ('20240716153753'), + ('20240712090133'), + ('20240711094255'), + ('20240711091155'), + ('20240708195226'), + ('20240708081356'), + ('20240706204557'), + ('20240705125619'), + ('20240703061352'), + ('20240702081109'), + ('20240701184757'), + ('20240701083355'), + ('20240628083830'), + ('20240628083654'), + ('20240626094521'), + ('20240625090742'), + ('20240619082054'), + ('20240611074215'), + ('20240607095208'), + ('20240607095155'), + ('20240604141208'), + ('20240603095841'), + ('20240603080144'), + ('20240530123427'), + ('20240522105942'), + ('20240521143531'), + ('20240520115450'), + ('20240514081110'), + ('20240514072741'), + ('20240506085424'), + ('20240502095122'), + ('20240502075803'), + ('20240430133150'), + ('20240430100120'), + ('20240429141108'), + ('20240426143059'), + ('20240425131701'), + ('20240425082113'), + ('20240424124802'), + ('20240424110420'), + ('20240423155113'), + ('20240419085012'), + ('20240419071607'), + ('20240415122310'), + ('20240412133335'), + ('20240412085450'), + ('20240411114759'), + ('20240404123257'), + ('20240403084644'), + ('20240329112415'), + ('20240328153701'), + ('20240328075919'), + ('20240327071539'), + ('20240314172008'), + ('20240314170211'), + ('20240314165306'), + ('20240314163426'), + ('20240312141641'), + ('20240311091817'), + ('20240308150801'), + ('20240308104003'), + ('20240305164449'), + ('20240305093058'), + ('20240301133006'), + ('20240227161430'), + ('20240205160647'), + ('20240129155938'), + ('20240125080718'), + ('20240123104811'), + ('20240118141022'), + ('20240118140703'), + ('20240118135350'), + ('20240115130517'), + ('20240115102012'), + ('20240115094827'), + ('20240112091706'), + ('20240111155133'), + ('20240111151140'), + ('20240111140424'), + ('20240104152816'), + ('20240103125624'), + ('20231220140936'), + ('20231220115621'), + ('20231219121735'), + ('20231218170631'), + ('20231214133638'), + ('20231214103653'), + ('20231207095229'), + ('20231205153156'), + ('20231204151512'), + ('20231204131333'), + ('20231201091348'), + ('20231130085817'), + ('20231129145100'), + ('20231128092231'), + ('20231123105540'), + ('20231123095209'), + ('20231117123744'), + ('20231114092154'), + ('20231109154934'), + ('20231109141829'), + ('20231107110809'), + ('20231106145424'), + ('20231103144201'), + ('20231102154537'), + ('20231102141929'), + ('20231102085146'), + ('20231101080314'), + ('20231027144605'), + ('20231020091031'), + ('20231017082921'), + ('20231016115055'), + ('20231010090849'), + ('20231010085938'), + ('20231001070407'), + ('20230926144126'), + ('20230926132500'), + ('20230922064617'), + ('20230920083133'), + ('20230918090426'), + ('20230915135256'), + ('20230915120854'), + ('20230915073205'), + ('20230913123123'), + ('20230912082112'), + ('20230912082057'), + ('20230912082000'), + ('20230911185900'), + ('20230911083923'), + ('20230907153404'), + ('20230907064335'), + ('20230905081225'), + ('20230830120517'), + ('20230828085627'), + ('20230821135235'), + ('20230817092555'), + ('20230816091053'), + ('20230811120622'), + ('20230811081854'), + ('20230808144739'), + ('20230731135721'), + ('20230731095510'), + ('20230727163611'), + ('20230726171737'), + ('20230726165711'), + ('20230721073114'), + ('20230720204311'), + ('20230719100256'), + ('20230717090135'), + ('20230713122526'), + ('20230705213846'), + ('20230704150108'), + ('20230704144027'), + ('20230704112230'), + ('20230629100018'), + ('20230627080605'), + ('20230626124005'), + ('20230626123648'), + ('20230620211201'), + ('20230619101701'), + ('20230615183805'), + ('20230614191603'), + ('20230608154821'), + ('20230608133543'), + ('20230608085013'), + ('20230606164458'), + ('20230606085050'), + ('20230602090325'), + ('20230529093955'), + ('20230525154612'), + ('20230525122232'), + ('20230525120005'), + ('20230524130637'), + ('20230523140656'), + ('20230523094557'), + ('20230522113810'), + ('20230522093423'), + ('20230522091400'), + ('20230517093556'), + ('20230511124419'), + ('20230510113501'), + ('20230505093030'), + ('20230503143229'), + ('20230425130239'), + ('20230424210224'), + ('20230424154516'), + ('20230424150952'), + ('20230424092207'), + ('20230424091446'), + ('20230421094757'), + ('20230420120806'), + ('20230420114754'), + ('20230419123538'), + ('20230418151450'), + ('20230417140356'), + ('20230417131515'), + ('20230417122020'), + ('20230417094339'), + ('20230414130437'), + ('20230414074225'), + ('20230411085545'), + ('20230411083336'), + ('20230403094044'), + ('20230403093407'), + ('20230328161507'), + ('20230327134418'), + ('20230323112252'), + ('20230313145506'), + ('20230307131524'), + ('20230301122720'), + ('20230227145104'), + ('20230221102035'), + ('20230221070501'), + ('20230216145442'), + ('20230216140543'), + ('20230214145444'), + ('20230214100638'), + ('20230207110702'), + ('20230206143214'), + ('20230203132157'), + ('20230202163249'), + ('20230202150407'), + ('20230202110407'), + ('20230131152047'), + ('20230131144740'), + ('20230127140904'), + ('20230126103454'), + ('20230125104957'), + ('20230118100324'), + ('20230109095957'), + ('20230106152449'), + ('20230105094302'), + ('20230102150636'), + ('20221226091020'), + ('20221222164226'), + ('20221219111209'), + ('20221216154033'), + ('20221212153810'), + ('20221208142739'), + ('20221208140608'), + ('20221206094412'), + ('20221205112007'), + ('20221202130126'), + ('20221129133433'), + ('20221128132620'), + ('20221125111605'), + ('20221122163328'), + ('20221118093903'), + ('20221118084547'), + ('20221115160325'), + ('20221115155550'), + ('20221115135840'), + ('20221115110223'), + ('20221115100834'), + ('20221114102649'), + ('20221110151027'), + ('20221107151038'), + ('20221031144907'), + ('20221031141549'), + ('20221028160705'), + ('20221028124549'), + ('20221028091920'), + ('20221024090308'), + ('20221021135946'), + ('20221021135428'), + ('20221020093745'), + ('20221018144521'), + ('20221013140147'), + ('20221011133055'), + ('20221011083520'), + ('20221010142031'), + ('20221010083509'), + ('20221007075812'), + ('20221004092737'), + ('20220930143002'), + ('20220930134327'), + ('20220930123935'), + ('20220923092906'), + ('20220922105251'), + ('20220921095507'), + ('20220919133338'), + ('20220916131538'), + ('20220915092730'), + ('20220906130714'), + ('20220906065059'), + ('20220905142834'), + ('20220905095529'), + ('20220831113537'), + ('20220829094054'), + ('20220825051923'), + ('20220824113131'), + ('20220823145421'), + ('20220823135203'), + ('20220818151052'), + ('20220818141616'), + ('20220817095619'), + ('20220817092945'), + ('20220816120137'), + ('20220811155332'), + ('20220809083243'), + ('20220807210117'), + ('20220801101144'), + ('20220729062203'), + ('20220729055309'), + ('20220728144707'), + ('20220727161448'), + ('20220727132848'), + ('20220725152220'), + ('20220722123417'), + ('20220721150658'), + ('20220718124337'), + ('20220718083657'), + ('20220713171816'), + ('20220705155228'), + ('20220704145333'), + ('20220629133308'), + ('20220621153030'), + ('20220621090834'), + ('20220620150551'), + ('20220620141910'), + ('20220617124108'), + ('20220614110841'), + ('20220613130634'), + ('20220610143942'), + ('20220610134535'), + ('20220609080806'), + ('20220607082458'), + ('20220602145819'), + ('20220601150058'), + ('20220530091046'), + ('20220526101535'), + ('20220525122759'); + SQL + + execute sql + end + end + + def down + end +end diff --git a/db/migrate/20250122130735_change_unique_index_in_payments.rb b/db/migrate/20250122130735_change_unique_index_in_payments.rb new file mode 100644 index 0000000..22b2986 --- /dev/null +++ b/db/migrate/20250122130735_change_unique_index_in_payments.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ChangeUniqueIndexInPayments < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + remove_index :payments, %i[payable_id payable_type] + + add_index :payments, + %i[payable_id payable_type], + where: "payable_payment_status in ('pending', 'processing') and payment_type = 'provider'", + unique: true, + algorithm: :concurrently + end + + def down + remove_index :payments, %i[payable_id payable_type] + + add_index :payments, + %i[payable_id payable_type], + where: "payable_payment_status in ('pending', 'processing')", + unique: true, + algorithm: :concurrently + end +end diff --git a/db/migrate/20250205184611_add_index_on_invoices_org_seq_id.rb b/db/migrate/20250205184611_add_index_on_invoices_org_seq_id.rb new file mode 100644 index 0000000..c908506 --- /dev/null +++ b/db/migrate/20250205184611_add_index_on_invoices_org_seq_id.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddIndexOnInvoicesOrgSeqId < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_index :invoices, [:organization_id, :organization_sequential_id], + order: {organization_sequential_id: :desc}, + algorithm: :concurrently, + if_not_exists: true, + include: %i[self_billed] + end +end diff --git a/db/migrate/20250207094842_add_applied_grace_period_to_invoices.rb b/db/migrate/20250207094842_add_applied_grace_period_to_invoices.rb new file mode 100644 index 0000000..dde0ac8 --- /dev/null +++ b/db/migrate/20250207094842_add_applied_grace_period_to_invoices.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAppliedGracePeriodToInvoices < ActiveRecord::Migration[7.1] + def change + add_column :invoices, :applied_grace_period, :integer + end +end diff --git a/db/migrate/20250207142402_backfill_applied_grace_period_on_draft_invoices.rb b/db/migrate/20250207142402_backfill_applied_grace_period_on_draft_invoices.rb new file mode 100644 index 0000000..0f0e9ee --- /dev/null +++ b/db/migrate/20250207142402_backfill_applied_grace_period_on_draft_invoices.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class BackfillAppliedGracePeriodOnDraftInvoices < ActiveRecord::Migration[7.1] + def up + update_query = <<~SQL + with inv as ( + select invoices.id, COALESCE(customers.invoice_grace_period, organizations.invoice_grace_period) as grace_period + from invoices + INNER JOIN organizations ON organizations.id = invoices.organization_id + INNER JOIN customers ON customers.id = invoices.customer_id + where status = 0 -- draft + ) + update invoices + set applied_grace_period = inv.grace_period + from inv + where invoices.id = inv.id + SQL + + safety_assured { execute(update_query) } + end +end diff --git a/db/migrate/20250212123207_backfill_invoices_and_payments.rb b/db/migrate/20250212123207_backfill_invoices_and_payments.rb new file mode 100644 index 0000000..e34006e --- /dev/null +++ b/db/migrate/20250212123207_backfill_invoices_and_payments.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +class BackfillInvoicesAndPayments < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + update_invoices + update_payments + end + + def down + end + + private + + # Inline model and constants definitions to avoid future dependency issues + class Invoice < ApplicationRecord + self.table_name = "invoices" + PAYMENT_STATUS = {pending: 0, succeeded: 1, failed: 2}.freeze + end + + class Payment < ApplicationRecord + self.table_name = "payments" + belongs_to :payment_provider, optional: true + + PAYABLE_PAYMENT_STATUS = { + pending: "pending", + processing: "processing", + succeeded: "succeeded", + failed: "failed" + }.freeze + end + + class PaymentProvider < ApplicationRecord + self.table_name = "payment_providers" + end + + def update_invoices + Invoice.where(payment_status: Invoice::PAYMENT_STATUS[:succeeded]) + .update_all("total_paid_amount_cents = total_amount_cents") # rubocop:disable Rails/SkipsModelValidations + end + + def update_payments + provider_statuses = { + "PaymentProviders::AdyenProvider" => { + processing: %w[AuthorisedPending Received], + succeeded: %w[Authorised SentForSettle SettleScheduled Settled Refunded], + failed: %w[Cancelled CaptureFailed Error Expired Refused] + }, + "PaymentProviders::CashfreeProvider" => { + processing: %w[PARTIALLY_PAID], + succeeded: %w[PAID], + failed: %w[EXPIRED CANCELLED] + }, + "PaymentProviders::GocardlessProvider" => { + processing: %w[pending_customer_approval pending_submission submitted confirmed], + succeeded: %w[paid_out], + failed: %w[cancelled customer_approval_denied failed charged_back] + }, + "PaymentProviders::StripeProvider" => { + processing: %w[processing requires_capture requires_action requires_confirmation], + succeeded: %w[succeeded], + failed: %w[canceled requires_payment_method] + } + } + + provider_statuses.each do |provider_type, statuses| + update_payment_status(provider_type, statuses[:processing], Payment::PAYABLE_PAYMENT_STATUS[:processing]) + update_payment_status(provider_type, statuses[:succeeded], Payment::PAYABLE_PAYMENT_STATUS[:succeeded]) + update_payment_status(provider_type, statuses[:failed], Payment::PAYABLE_PAYMENT_STATUS[:failed]) + end + end + + def update_payment_status(provider_type, statuses, new_status) + # some payments providers are already deleted but we still need to change the payment + Payment.left_joins(:payment_provider) + .where("payment_providers.type = ? OR payment_providers.id IS NULL", provider_type) + .where(payable_payment_status: nil, status: statuses) + .update_all(payable_payment_status: new_status) # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/db/migrate/20250214091021_add_index_to_in_advance_charges.rb b/db/migrate/20250214091021_add_index_to_in_advance_charges.rb new file mode 100644 index 0000000..eeaa4b9 --- /dev/null +++ b/db/migrate/20250214091021_add_index_to_in_advance_charges.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddIndexToInAdvanceCharges < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_index :charges, + [:billable_metric_id], + where: "deleted_at IS NULL AND pay_in_advance = TRUE", + algorithm: :concurrently, + name: "index_charges_pay_in_advance" + end +end diff --git a/db/migrate/20250217152051_migrate_invoice_error_to_error_detail.rb b/db/migrate/20250217152051_migrate_invoice_error_to_error_detail.rb new file mode 100644 index 0000000..a4ae1d9 --- /dev/null +++ b/db/migrate/20250217152051_migrate_invoice_error_to_error_detail.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class MigrateInvoiceErrorToErrorDetail < ActiveRecord::Migration[7.1] + class InvoiceError < ApplicationRecord; end + + class ErrorDetail < ApplicationRecord + belongs_to :owner, polymorphic: true + end + + def change + InvoiceError.find_each do |ie| + invoice = Invoice.find(ie.id) + ErrorDetail.create( + error_code: "invoice_generation_error", + owner: invoice, + organization_id: invoice.organization_id, + created_at: ie.created_at, + updated_at: ie.updated_at, + details: { + error: ie.error, + backtrace: ie.backtrace, + invoice: ie.invoice, + subscriptions: ie.subscriptions + } + ) + end + end +end diff --git a/db/migrate/20250218165958_drop_invoice_error_table.rb b/db/migrate/20250218165958_drop_invoice_error_table.rb new file mode 100644 index 0000000..642be5f --- /dev/null +++ b/db/migrate/20250218165958_drop_invoice_error_table.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class DropInvoiceErrorTable < ActiveRecord::Migration[7.1] + def up + drop_table :invoice_errors + end + + def down + create_table :invoice_errors, id: :uuid do |t| + t.text :backtrace + t.json :invoice + t.json :subscriptions + t.json :error + + t.timestamps + end + end +end diff --git a/db/migrate/20250219124948_create_billing_entities.rb b/db/migrate/20250219124948_create_billing_entities.rb new file mode 100644 index 0000000..ee04979 --- /dev/null +++ b/db/migrate/20250219124948_create_billing_entities.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +class CreateBillingEntities < ActiveRecord::Migration[7.1] + def change + create_enum :entity_document_numbering, %w[per_customer per_billing_entity] + + create_table :billing_entities, id: :uuid do |t| + t.references :organization, type: :uuid, null: false, foreign_key: true + + # address + t.string :address_line1 + t.string :address_line2 + t.string :city + t.string :country + t.string :zipcode + t.string :state + t.string :timezone, default: "UTC", null: false + + # currency and locale + t.string :default_currency, default: "USD", null: false + t.string :document_locale, default: "en", null: false + + # invoice settings + t.string :document_number_prefix + t.enum :document_numbering, enum_type: "entity_document_numbering", null: false, default: "per_customer" + t.boolean :finalize_zero_amount_invoice, default: true, null: false + t.text :invoice_footer + t.integer :invoice_grace_period, default: 0, null: false + t.integer :net_payment_term, default: 0, null: false + + # entity settings + t.string :email + t.string :email_settings, array: true, default: [], null: false + t.boolean :eu_tax_management, default: false + t.string :legal_name + t.string :legal_number + t.string :logo + t.string :name, null: false + t.string :code, null: false + t.string :tax_identification_number + t.float :vat_rate, default: 0.0, null: false + + t.boolean :is_default, default: false, null: false + t.index [:organization_id], + unique: true, + where: "is_default = TRUE AND archived_at IS NULL AND deleted_at IS NULL", + name: "unique_default_billing_entity_per_organization" + + t.datetime :archived_at + t.datetime :deleted_at + t.timestamps + end + end +end diff --git a/db/migrate/20250219152213_add_references_to_billing_entities.rb b/db/migrate/20250219152213_add_references_to_billing_entities.rb new file mode 100644 index 0000000..8c07b56 --- /dev/null +++ b/db/migrate/20250219152213_add_references_to_billing_entities.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddReferencesToBillingEntities < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_reference :billing_entities, :applied_dunning_campaign, index: {algorithm: :concurrently}, type: :uuid + + add_reference :customers, :billing_entity, index: {algorithm: :concurrently}, type: :uuid + add_reference :invoices, :billing_entity, index: {algorithm: :concurrently}, type: :uuid + add_reference :invoice_custom_section_selections, :billing_entity, index: {algorithm: :concurrently}, type: :uuid + add_reference :fees, :billing_entity, index: {algorithm: :concurrently}, type: :uuid + + # will be populated with the part to create billing_entities + add_column :invoices, :billing_entity_sequential_id, :integer, default: 0 + + add_index :invoices, [:organization_id, :billing_entity_sequential_id], + order: {billing_entity_sequential_id: :desc}, + algorithm: :concurrently, + if_not_exists: true, + include: %i[self_billed] + end +end diff --git a/db/migrate/20250219164502_create_billing_entities_taxes.rb b/db/migrate/20250219164502_create_billing_entities_taxes.rb new file mode 100644 index 0000000..4d9f5be --- /dev/null +++ b/db/migrate/20250219164502_create_billing_entities_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateBillingEntitiesTaxes < ActiveRecord::Migration[7.1] + def change + create_table :billing_entities_taxes, id: :uuid do |t| + t.references :billing_entity, null: false, foreign_key: true, type: :uuid + t.references :tax, null: false, foreign_key: true, type: :uuid + + t.index %i[billing_entity_id tax_id], unique: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250219205535_fix_invoices_with_incorrect_total_paid_amount.rb b/db/migrate/20250219205535_fix_invoices_with_incorrect_total_paid_amount.rb new file mode 100644 index 0000000..049ffec --- /dev/null +++ b/db/migrate/20250219205535_fix_invoices_with_incorrect_total_paid_amount.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class FixInvoicesWithIncorrectTotalPaidAmount < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + def up + safety_assured do + execute <<~SQL + UPDATE invoices + SET total_paid_amount_cents = 0 + WHERE id IN ( + SELECT invoices.id + FROM invoices + LEFT JOIN payments ON invoices.id = payments.payable_id + LEFT JOIN invoices_payment_requests ON invoices.id = invoices_payment_requests.invoice_id + WHERE payments.id IS NULL + AND invoices_payment_requests.id IS NULL + AND total_amount_cents > 0 + AND invoice_type <> 4 + AND total_amount_cents = total_paid_amount_cents + AND payment_status = 1 + ); + SQL + end + end + + def down + end +end diff --git a/db/migrate/20250220085848_add_unique_index_to_provider_payment_id.rb b/db/migrate/20250220085848_add_unique_index_to_provider_payment_id.rb new file mode 100644 index 0000000..ecef807 --- /dev/null +++ b/db/migrate/20250220085848_add_unique_index_to_provider_payment_id.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +class AddUniqueIndexToProviderPaymentId < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + safety_assured do + duplicates = Payment + .select(:payment_provider_id, :provider_payment_id) + .where.not(provider_payment_id: nil) + .group(:payment_provider_id, :provider_payment_id) + .having("COUNT(*) > 1") + .pluck(:payment_provider_id, :provider_payment_id) + + duplicates.each do |duplicate| + payments = Payment.where( + payment_provider_id: duplicate.first, + provider_payment_id: duplicate.last + ) + payments_with_refund = payments.where.associated(:refunds).pluck(:id) + + if payments_with_refund.count > 0 + payments.where.not(id: payments_with_refund).delete_all + else + id = payments.order(created_at: :asc).first.id + payments.where.not(id:).delete_all + end + end + + execute <<-SQL + UPDATE invoices i + SET total_paid_amount_cents = total_amount_cents + WHERE total_paid_amount_cents > total_amount_cents; + SQL + + add_index :payments, + %i[provider_payment_id payment_provider_id], + unique: true, + where: "provider_payment_id IS NOT NULL", + algorithm: :concurrently + end + end + + def down + safety_assured do + remove_index :payments, %i[provider_payment_id payment_provider_id] + end + end +end diff --git a/db/migrate/20250220180112_create_payment_receipts.rb b/db/migrate/20250220180112_create_payment_receipts.rb new file mode 100644 index 0000000..5faa536 --- /dev/null +++ b/db/migrate/20250220180112_create_payment_receipts.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreatePaymentReceipts < ActiveRecord::Migration[7.1] + def change + create_table :payment_receipts, id: :uuid do |t| + t.string :number, null: false + t.references :payment, null: false, foreign_key: true, index: {unique: true}, type: :uuid + t.references :organization, null: false, foreign_key: true, type: :uuid + + t.timestamps + end + end +end diff --git a/db/migrate/20250220180113_add_payment_receipt_counter_to_customers.rb b/db/migrate/20250220180113_add_payment_receipt_counter_to_customers.rb new file mode 100644 index 0000000..ddf2766 --- /dev/null +++ b/db/migrate/20250220180113_add_payment_receipt_counter_to_customers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPaymentReceiptCounterToCustomers < ActiveRecord::Migration[7.1] + def change + add_column :customers, :payment_receipt_counter, :bigint, default: 0, null: false + end +end diff --git a/db/migrate/20250220180114_create_before_payment_receipt_insert_trigger.rb b/db/migrate/20250220180114_create_before_payment_receipt_insert_trigger.rb new file mode 100644 index 0000000..da3d774 --- /dev/null +++ b/db/migrate/20250220180114_create_before_payment_receipt_insert_trigger.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +class CreateBeforePaymentReceiptInsertTrigger < ActiveRecord::Migration[7.1] + def up + safety_assured do + execute <<-SQL + CREATE OR REPLACE FUNCTION set_payment_receipt_number() + RETURNS trigger AS $$ + DECLARE + cust_id uuid; + next_payment_receipt integer; + document_number_prefix character varying; + BEGIN + IF NEW.number IS NULL THEN + SELECT i.customer_id INTO cust_id + FROM invoices i + INNER JOIN payments p ON (p.payable_id = i.id AND p.payable_type = 'Invoice') + WHERE p.id = NEW.payment_id; + + IF cust_id IS NULL THEN + SELECT pr.customer_id INTO cust_id + FROM payment_requests pr + LEFT JOIN payments p ON (p.payable_id = pr.id AND p.payable_type = 'PaymentRequest') + WHERE p.id = NEW.payment_id; + END IF; + + SELECT c.slug INTO document_number_prefix + FROM customers c + WHERE c.id = cust_id; + + -- Atomically increment the customer's payment receipt counter and get the new value + UPDATE customers + SET payment_receipt_counter = payment_receipt_counter + 1 + WHERE id = cust_id + RETURNING payment_receipt_counter INTO next_payment_receipt; + + -- Construct the payment receipt number using the customer id and the new counter value + NEW.number := document_number_prefix || '-RCPT-' || LPAD(next_payment_receipt::text, GREATEST(6, LENGTH(next_payment_receipt::text)), '0'); + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + SQL + + execute <<-SQL + CREATE TRIGGER before_payment_receipt_insert + BEFORE INSERT ON payment_receipts + FOR EACH ROW + EXECUTE FUNCTION set_payment_receipt_number(); + SQL + end + end + + def down + execute <<-SQL + DROP TRIGGER IF EXISTS before_payment_receipt_insert ON payment_receipts; + SQL + + execute <<-SQL + DROP FUNCTION IF EXISTS set_payment_receipt_number; + SQL + end +end diff --git a/db/migrate/20250220223944_add_provider_payment_method_data_to_payments.rb b/db/migrate/20250220223944_add_provider_payment_method_data_to_payments.rb new file mode 100644 index 0000000..1f12e69 --- /dev/null +++ b/db/migrate/20250220223944_add_provider_payment_method_data_to_payments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddProviderPaymentMethodDataToPayments < ActiveRecord::Migration[7.1] + def change + add_column :payments, :provider_payment_method_data, :jsonb, null: false, default: {} + end +end diff --git a/db/migrate/20250227091909_remove_is_default_from_billing_entity.rb b/db/migrate/20250227091909_remove_is_default_from_billing_entity.rb new file mode 100644 index 0000000..d141557 --- /dev/null +++ b/db/migrate/20250227091909_remove_is_default_from_billing_entity.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemoveIsDefaultFromBillingEntity < ActiveRecord::Migration[7.1] + def change + safety_assured do + remove_column :billing_entities, :is_default, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20250227155522_fix_metadata_in_wallet_transactions_and_recurring_rules.rb b/db/migrate/20250227155522_fix_metadata_in_wallet_transactions_and_recurring_rules.rb new file mode 100644 index 0000000..a98dbcd --- /dev/null +++ b/db/migrate/20250227155522_fix_metadata_in_wallet_transactions_and_recurring_rules.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class FixMetadataInWalletTransactionsAndRecurringRules < ActiveRecord::Migration[7.1] + def up + safety_assured do + execute <<-SQL + UPDATE wallet_transactions + SET metadata = '[]'::jsonb + WHERE metadata = '{}'::jsonb; + SQL + + execute <<-SQL + UPDATE recurring_transaction_rules + SET transaction_metadata = '[]'::jsonb + WHERE transaction_metadata = '{}'::jsonb; + SQL + end + end + + def down + safety_assured do + execute <<-SQL + UPDATE wallet_transactions + SET metadata = '{}'::jsonb + WHERE metadata = '[]'::jsonb; + SQL + + execute <<-SQL + UPDATE recurring_transaction_rules + SET transaction_metadata = '{}'::jsonb + WHERE transaction_metadata = '[]'::jsonb; + SQL + end + end +end diff --git a/db/migrate/20250303104151_add_code_uniqueness_index_to_billing_entities.rb b/db/migrate/20250303104151_add_code_uniqueness_index_to_billing_entities.rb new file mode 100644 index 0000000..4245e05 --- /dev/null +++ b/db/migrate/20250303104151_add_code_uniqueness_index_to_billing_entities.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddCodeUniquenessIndexToBillingEntities < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def change + add_index :billing_entities, [:code, :organization_id], + unique: true, + where: "deleted_at IS NULL AND archived_at IS NULL", + algorithm: :concurrently + end +end diff --git a/db/migrate/20250304163656_create_billing_entity_per_each_organization.rb b/db/migrate/20250304163656_create_billing_entity_per_each_organization.rb new file mode 100644 index 0000000..ec7382e --- /dev/null +++ b/db/migrate/20250304163656_create_billing_entity_per_each_organization.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +class CreateBillingEntityPerEachOrganization < ActiveRecord::Migration[7.2] + class Organization < ApplicationRecord + has_many :billing_entities + has_one :applied_dunning_campaign, -> { where(applied_to_organization: true) }, class_name: "DunningCampaign" + + DOCUMENT_NUMBERINGS = { + per_customer: 0, + per_organization: 1 + }.freeze + attribute :document_numbering, :integer, default: 0 + enum :document_numbering, DOCUMENT_NUMBERINGS + end + + class BillingEntity < ApplicationRecord + belongs_to :organization + + DOCUMENT_NUMBERINGS = { + per_customer: "per_customer", + per_billing_entity: "per_billing_entity" + }.freeze + enum :document_numbering, DOCUMENT_NUMBERINGS + end + + def up + Organization.find_each do |organization| + BillingEntity.create!( + id: organization.id, + organization_id: organization.id, + name: organization.name, + code: organization.name.parameterize(separator: "_"), + address_line1: organization.address_line1, + address_line2: organization.address_line2, + city: organization.city, + country: organization.country, + zipcode: organization.zipcode, + state: organization.state, + timezone: organization.timezone, + + # currency and locale + default_currency: organization.default_currency, + document_locale: organization.document_locale, + + # invoice settings + document_number_prefix: organization.document_number_prefix, + document_numbering: organization.per_organization? ? "per_billing_entity" : "per_customer", + finalize_zero_amount_invoice: organization.finalize_zero_amount_invoice, + invoice_footer: organization.invoice_footer, + invoice_grace_period: organization.invoice_grace_period, + + # entity settings + email: organization.email, + email_settings: organization.email_settings, + eu_tax_management: organization.eu_tax_management, + legal_name: organization.legal_name, + legal_number: organization.legal_number, + logo: organization.logo, + tax_identification_number: organization.tax_identification_number, + vat_rate: organization.vat_rate, + applied_dunning_campaign_id: organization.applied_dunning_campaign&.id + ) + end + end + + def down + BillingEntity.delete_all + end +end diff --git a/db/migrate/20250310213734_add_expiration_and_termination_to_recurring_transaction_rules.rb b/db/migrate/20250310213734_add_expiration_and_termination_to_recurring_transaction_rules.rb new file mode 100644 index 0000000..567f289 --- /dev/null +++ b/db/migrate/20250310213734_add_expiration_and_termination_to_recurring_transaction_rules.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class AddExpirationAndTerminationToRecurringTransactionRules < ActiveRecord::Migration[7.1] + disable_ddl_transaction! + + def up + safety_assured do + change_table :recurring_transaction_rules, bulk: true do |t| + t.datetime :expiration_at + t.datetime :terminated_at + t.integer :status + end + end + + safety_assured do + RecurringTransactionRule.in_batches.update_all(status: 0) # rubocop:disable Rails/SkipsModelValidations + end + + change_column_default :recurring_transaction_rules, :status, 0 + + add_index :recurring_transaction_rules, :expiration_at, algorithm: :concurrently + end + + def down + safety_assured do + remove_index :recurring_transaction_rules, column: :expiration_at + end + + safety_assured do + change_table :recurring_transaction_rules, bulk: true do |t| + t.remove :expiration_at, :terminated_at, :status + end + end + end +end diff --git a/db/migrate/20250318093216_add_failed_at_to_wallet_transactions.rb b/db/migrate/20250318093216_add_failed_at_to_wallet_transactions.rb new file mode 100644 index 0000000..b1f4734 --- /dev/null +++ b/db/migrate/20250318093216_add_failed_at_to_wallet_transactions.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddFailedAtToWalletTransactions < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + def change + add_column :wallet_transactions, :failed_at, :datetime + end +end diff --git a/db/migrate/20250318175216_mark_pending_wallet_transactions_as_failed.rb b/db/migrate/20250318175216_mark_pending_wallet_transactions_as_failed.rb new file mode 100644 index 0000000..10b0dae --- /dev/null +++ b/db/migrate/20250318175216_mark_pending_wallet_transactions_as_failed.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +class MarkPendingWalletTransactionsAsFailed < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + # Update transactions using the latest `updated_at` from failed payments (if available) + safety_assured do + execute <<~SQL.squish + UPDATE wallet_transactions wt + SET status = 2, + failed_at = ( + SELECT MAX(p.updated_at) + FROM fees f + INNER JOIN invoices i ON i.id = f.invoice_id + INNER JOIN payments p ON p.payable_id = i.id + WHERE f.invoiceable_id = wt.id + AND f.invoiceable_type = 'WalletTransaction' + AND i.payment_status = 2 + AND p.payable_payment_status = 'failed' + ) + WHERE wt.status = 0 + AND EXISTS ( + SELECT 1 + FROM fees f + INNER JOIN invoices i ON i.id = f.invoice_id + INNER JOIN payments p ON p.payable_id = i.id + WHERE f.invoiceable_id = wt.id + AND f.invoiceable_type = 'WalletTransaction' + AND i.payment_status = 2 + AND p.payable_payment_status = 'failed' + ); + SQL + end + + # Then update transactions using `invoices.updated_at` if no failed payment exists + safety_assured do + execute <<~SQL.squish + UPDATE wallet_transactions wt + SET status = 2, + failed_at = ( + SELECT MAX(i.updated_at) + FROM fees f + INNER JOIN invoices i ON i.id = f.invoice_id + WHERE f.invoiceable_id = wt.id + AND f.invoiceable_type = 'WalletTransaction' + AND i.payment_status = 2 + AND NOT EXISTS ( + SELECT 1 + FROM payments p + WHERE p.payable_id = i.id + ) + ) + WHERE wt.status = 0 + AND EXISTS ( + SELECT 1 + FROM fees f + INNER JOIN invoices i ON i.id = f.invoice_id + WHERE f.invoiceable_id = wt.id + AND f.invoiceable_type = 'WalletTransaction' + AND i.payment_status = 2 + AND NOT EXISTS ( + SELECT 1 + FROM payments p + WHERE p.payable_id = i.id + ) + ); + SQL + end + end + + def down + # do nothing + end +end diff --git a/db/migrate/20250324122757_add_email_bcc_to_dunning_campaign.rb b/db/migrate/20250324122757_add_email_bcc_to_dunning_campaign.rb new file mode 100644 index 0000000..c07a714 --- /dev/null +++ b/db/migrate/20250324122757_add_email_bcc_to_dunning_campaign.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEmailBccToDunningCampaign < ActiveRecord::Migration[7.2] + def change + add_column :dunning_campaigns, :bcc_emails, :string, array: true, default: [] + end +end diff --git a/db/migrate/20250324125056_add_provider_payment_method_id_to_payments.rb b/db/migrate/20250324125056_add_provider_payment_method_id_to_payments.rb new file mode 100644 index 0000000..eb7a4c0 --- /dev/null +++ b/db/migrate/20250324125056_add_provider_payment_method_id_to_payments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddProviderPaymentMethodIdToPayments < ActiveRecord::Migration[7.2] + def change + add_column :payments, :provider_payment_method_id, :string, null: true + end +end diff --git a/db/migrate/20250325145324_assign_customers_to_billing_entities.rb b/db/migrate/20250325145324_assign_customers_to_billing_entities.rb new file mode 100644 index 0000000..c747065 --- /dev/null +++ b/db/migrate/20250325145324_assign_customers_to_billing_entities.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class AssignCustomersToBillingEntities < ActiveRecord::Migration[7.2] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + class Customer < ApplicationRecord + self.ignored_columns = [] + + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def change + # NOTE: ensure first billing entity has the same id as the organization to ease the migration to multi entities. + BillingEntity.where("id != organization_id").find_in_batches(batch_size: 1000) do |batch| + BillingEntity.where(id: batch.pluck(:id)) + .update_all("id = organization_id") # rubocop:disable Rails/SkipsModelValidations + end + + # NOTE: Update all customers to have the same billing_entity_id as organization_id + Customer.where("billing_entity_id != organization_id").or(Customer.where(billing_entity_id: nil)).find_in_batches(batch_size: 1000) do |batch| + Customer.where(id: batch.pluck(:id)) + .update_all("billing_entity_id = organization_id") # rubocop:disable Rails/SkipsModelValidations + end + end +end diff --git a/db/migrate/20250325162648_assign_invoices_to_billing_entities.rb b/db/migrate/20250325162648_assign_invoices_to_billing_entities.rb new file mode 100644 index 0000000..dad1c21 --- /dev/null +++ b/db/migrate/20250325162648_assign_invoices_to_billing_entities.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AssignInvoicesToBillingEntities < ActiveRecord::Migration[7.2] + def change + Invoice.find_in_batches(batch_size: 1000) do |batch| + Invoice.where(id: batch.pluck(:id)) + .update_all("billing_entity_id = organization_id") # rubocop:disable Rails/SkipsModelValidations + end + end +end diff --git a/db/migrate/20250327130155_remove_default_billing_entity_sequential_id_on_invoices.rb b/db/migrate/20250327130155_remove_default_billing_entity_sequential_id_on_invoices.rb new file mode 100644 index 0000000..b614f39 --- /dev/null +++ b/db/migrate/20250327130155_remove_default_billing_entity_sequential_id_on_invoices.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class RemoveDefaultBillingEntitySequentialIdOnInvoices < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + change_column_default :invoices, :billing_entity_sequential_id, from: 0, to: nil + + Invoice.in_batches(of: 1000) do |batch| + batch.update_all( # rubocop:disable Rails/SkipsModelValidations + "billing_entity_sequential_id = CASE WHEN organization_sequential_id = 0 THEN NULL ELSE organization_sequential_id END" + ) + end + end +end diff --git a/db/migrate/20250327130156_change_invoices_index_on_billing_entity_sequential_id.rb b/db/migrate/20250327130156_change_invoices_index_on_billing_entity_sequential_id.rb new file mode 100644 index 0000000..d8d5ba3 --- /dev/null +++ b/db/migrate/20250327130156_change_invoices_index_on_billing_entity_sequential_id.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class ChangeInvoicesIndexOnBillingEntitySequentialId < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + remove_index :invoices, [:organization_id, :billing_entity_sequential_id], + order: {billing_entity_sequential_id: :desc}, + algorithm: :concurrently, + include: %i[self_billed], + if_exists: true + + add_index :invoices, [:billing_entity_id, :billing_entity_sequential_id], + order: {billing_entity_sequential_id: :desc}, + algorithm: :concurrently, + include: %i[self_billed], + unique: true, + if_not_exists: true + end +end diff --git a/db/migrate/20250402113844_fill_missing_invoice_id_on_wallet_transactions.rb b/db/migrate/20250402113844_fill_missing_invoice_id_on_wallet_transactions.rb new file mode 100644 index 0000000..785b85d --- /dev/null +++ b/db/migrate/20250402113844_fill_missing_invoice_id_on_wallet_transactions.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class FillMissingInvoiceIdOnWalletTransactions < ActiveRecord::Migration[7.2] + def up + safety_assured do + execute <<~SQL.squish + UPDATE wallet_transactions wt + SET invoice_id = ( + SELECT f.invoice_id + FROM fees f + WHERE f.invoiceable_id = wt.id + AND f.invoiceable_type = 'WalletTransaction' + AND f.invoice_id IS NOT null + LIMIT 1 + ) + WHERE wt.invoice_id IS null; + SQL + end + end + + def down + # no action needed + end +end diff --git a/db/migrate/20250402135038_assign_discarded_customers_to_billing_entities.rb b/db/migrate/20250402135038_assign_discarded_customers_to_billing_entities.rb new file mode 100644 index 0000000..10a8f3c --- /dev/null +++ b/db/migrate/20250402135038_assign_discarded_customers_to_billing_entities.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AssignDiscardedCustomersToBillingEntities < ActiveRecord::Migration[7.2] + class Customer < ApplicationRecord + self.ignored_columns = [] + + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def up + Customer.where("billing_entity_id != organization_id").or(Customer.where(billing_entity_id: nil)).find_in_batches(batch_size: 1000) do |batch| + Customer.where(id: batch.pluck(:id)) + .update_all("billing_entity_id = organization_id") # rubocop:disable Rails/SkipsModelValidations + end + end + + def down + end +end diff --git a/db/migrate/20250402150920_add_billing_entity_id_not_null_check_constraint.rb b/db/migrate/20250402150920_add_billing_entity_id_not_null_check_constraint.rb new file mode 100644 index 0000000..aa4634c --- /dev/null +++ b/db/migrate/20250402150920_add_billing_entity_id_not_null_check_constraint.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddBillingEntityIdNotNullCheckConstraint < ActiveRecord::Migration[7.2] + def change + add_check_constraint :invoices, "billing_entity_id IS NOT NULL", name: "invoices_billing_entity_id_null", validate: false + end +end diff --git a/db/migrate/20250402150959_validate_add_non_null_to_invoices_billing_entity_id.rb b/db/migrate/20250402150959_validate_add_non_null_to_invoices_billing_entity_id.rb new file mode 100644 index 0000000..427416d --- /dev/null +++ b/db/migrate/20250402150959_validate_add_non_null_to_invoices_billing_entity_id.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ValidateAddNonNullToInvoicesBillingEntityId < ActiveRecord::Migration[7.2] + def up + validate_check_constraint :invoices, name: "invoices_billing_entity_id_null" + change_column_null :invoices, :billing_entity_id, false + remove_check_constraint :invoices, name: "invoices_billing_entity_id_null" + end + + def down + add_check_constraint :invoices, "billing_entity_id IS NOT NULL", name: "invoices_billing_entity_id_null", validate: false + change_column_null :invoices, :billing_entity_id, true + end +end diff --git a/db/migrate/20250402151113_add_foreign_key_constraints_to_invoices_billing_entity_id.rb b/db/migrate/20250402151113_add_foreign_key_constraints_to_invoices_billing_entity_id.rb new file mode 100644 index 0000000..02cd0e7 --- /dev/null +++ b/db/migrate/20250402151113_add_foreign_key_constraints_to_invoices_billing_entity_id.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddForeignKeyConstraintsToInvoicesBillingEntityId < ActiveRecord::Migration[7.2] + def up + # Add foreign key without validation first + add_foreign_key :invoices, :billing_entities, validate: false + end + + def down + remove_foreign_key :invoices, :billing_entities + end +end diff --git a/db/migrate/20250402151747_validate_foreign_key_billing_entities_on_invoices.rb b/db/migrate/20250402151747_validate_foreign_key_billing_entities_on_invoices.rb new file mode 100644 index 0000000..8eeae8a --- /dev/null +++ b/db/migrate/20250402151747_validate_foreign_key_billing_entities_on_invoices.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ValidateForeignKeyBillingEntitiesOnInvoices < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + # Validate foreign key in a separate transaction + validate_foreign_key :invoices, :billing_entities + end +end diff --git a/db/migrate/20250402151900_add_billing_entity_id_not_null_check_constraint_to_customers.rb b/db/migrate/20250402151900_add_billing_entity_id_not_null_check_constraint_to_customers.rb new file mode 100644 index 0000000..04140c2 --- /dev/null +++ b/db/migrate/20250402151900_add_billing_entity_id_not_null_check_constraint_to_customers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddBillingEntityIdNotNullCheckConstraintToCustomers < ActiveRecord::Migration[7.2] + def change + add_check_constraint :customers, "billing_entity_id IS NOT NULL", name: "customers_billing_entity_id_null", validate: false + end +end diff --git a/db/migrate/20250402151930_validate_add_non_null_to_customers_billing_entity_id.rb b/db/migrate/20250402151930_validate_add_non_null_to_customers_billing_entity_id.rb new file mode 100644 index 0000000..b8226fe --- /dev/null +++ b/db/migrate/20250402151930_validate_add_non_null_to_customers_billing_entity_id.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ValidateAddNonNullToCustomersBillingEntityId < ActiveRecord::Migration[7.2] + def up + validate_check_constraint :customers, name: "customers_billing_entity_id_null" + change_column_null :customers, :billing_entity_id, false + remove_check_constraint :customers, name: "customers_billing_entity_id_null" + end + + def down + add_check_constraint :customers, "billing_entity_id IS NOT NULL", name: "customers_billing_entity_id_null", validate: false + change_column_null :customers, :billing_entity_id, true + end +end diff --git a/db/migrate/20250402152000_add_foreign_key_constraints_to_customers_billing_entity_id.rb b/db/migrate/20250402152000_add_foreign_key_constraints_to_customers_billing_entity_id.rb new file mode 100644 index 0000000..5406e02 --- /dev/null +++ b/db/migrate/20250402152000_add_foreign_key_constraints_to_customers_billing_entity_id.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddForeignKeyConstraintsToCustomersBillingEntityId < ActiveRecord::Migration[7.2] + def up + # Add foreign key without validation first + add_foreign_key :customers, :billing_entities, validate: false + end + + def down + remove_foreign_key :customers, :billing_entities + end +end diff --git a/db/migrate/20250402152030_validate_foreign_key_billing_entities_on_customers.rb b/db/migrate/20250402152030_validate_foreign_key_billing_entities_on_customers.rb new file mode 100644 index 0000000..249ee5b --- /dev/null +++ b/db/migrate/20250402152030_validate_foreign_key_billing_entities_on_customers.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ValidateForeignKeyBillingEntitiesOnCustomers < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + # Validate foreign key in a separate transaction + validate_foreign_key :customers, :billing_entities + end +end diff --git a/db/migrate/20250402152100_add_billing_entity_id_not_null_check_constraint_to_fees.rb b/db/migrate/20250402152100_add_billing_entity_id_not_null_check_constraint_to_fees.rb new file mode 100644 index 0000000..b88bb43 --- /dev/null +++ b/db/migrate/20250402152100_add_billing_entity_id_not_null_check_constraint_to_fees.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddBillingEntityIdNotNullCheckConstraintToFees < ActiveRecord::Migration[7.2] + def change + add_check_constraint :fees, "billing_entity_id IS NOT NULL", name: "fees_billing_entity_id_null", validate: false + end +end diff --git a/db/migrate/20250402152130_validate_add_non_null_to_fees_billing_entity_id.rb b/db/migrate/20250402152130_validate_add_non_null_to_fees_billing_entity_id.rb new file mode 100644 index 0000000..46fb8b2 --- /dev/null +++ b/db/migrate/20250402152130_validate_add_non_null_to_fees_billing_entity_id.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ValidateAddNonNullToFeesBillingEntityId < ActiveRecord::Migration[7.2] + def up + validate_check_constraint :fees, name: "fees_billing_entity_id_null" + change_column_null :fees, :billing_entity_id, false + remove_check_constraint :fees, name: "fees_billing_entity_id_null" + end + + def down + add_check_constraint :fees, "billing_entity_id IS NOT NULL", name: "fees_billing_entity_id_null", validate: false + change_column_null :fees, :billing_entity_id, true + end +end diff --git a/db/migrate/20250402152200_add_foreign_key_constraints_to_fees_billing_entity_id.rb b/db/migrate/20250402152200_add_foreign_key_constraints_to_fees_billing_entity_id.rb new file mode 100644 index 0000000..35a624a --- /dev/null +++ b/db/migrate/20250402152200_add_foreign_key_constraints_to_fees_billing_entity_id.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddForeignKeyConstraintsToFeesBillingEntityId < ActiveRecord::Migration[7.2] + def up + # Add foreign key without validation first + add_foreign_key :fees, :billing_entities, validate: false + end + + def down + remove_foreign_key :fees, :billing_entities + end +end diff --git a/db/migrate/20250402152230_validate_foreign_key_billing_entities_on_fees.rb b/db/migrate/20250402152230_validate_foreign_key_billing_entities_on_fees.rb new file mode 100644 index 0000000..393269d --- /dev/null +++ b/db/migrate/20250402152230_validate_foreign_key_billing_entities_on_fees.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class ValidateForeignKeyBillingEntitiesOnFees < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + # Validate foreign key in a separate transaction + validate_foreign_key :fees, :billing_entities + end +end diff --git a/db/migrate/20250403093628_ensure_organization_last_invoice_got_organization_sequential_id.rb b/db/migrate/20250403093628_ensure_organization_last_invoice_got_organization_sequential_id.rb new file mode 100644 index 0000000..b897e3b --- /dev/null +++ b/db/migrate/20250403093628_ensure_organization_last_invoice_got_organization_sequential_id.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class EnsureOrganizationLastInvoiceGotOrganizationSequentialId < ActiveRecord::Migration[7.2] + def change + DatabaseMigrations::FixInvoicesOrganizationSequentialIdJob.perform_later + end +end diff --git a/db/migrate/20250403110833_add_section_type_to_invoice_custom_sections.rb b/db/migrate/20250403110833_add_section_type_to_invoice_custom_sections.rb new file mode 100644 index 0000000..edc4d54 --- /dev/null +++ b/db/migrate/20250403110833_add_section_type_to_invoice_custom_sections.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +class AddSectionTypeToInvoiceCustomSections < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + create_enum :invoice_custom_section_type, %w[manual system_generated] + + safety_assured do + change_table :invoice_custom_sections, bulk: true do |t| + t.column :section_type, :enum, enum_type: "invoice_custom_section_type", null: true + end + end + + # Backfill all existing rows as manual + InvoiceCustomSection.unscoped.in_batches(of: 10_000).update_all(section_type: "manual") # rubocop:disable Rails/SkipsModelValidations + + safety_assured do + execute <<~SQL + ALTER TABLE invoice_custom_sections ALTER COLUMN section_type SET DEFAULT 'manual'; + SQL + execute <<~SQL + ALTER TABLE invoice_custom_sections ALTER COLUMN section_type SET NOT NULL; + SQL + end + end + + def down + change_table :invoice_custom_sections, bulk: true do |t| + t.remove :section_type + end + + drop_enum :invoice_custom_section_type + end +end diff --git a/db/migrate/20250407000001_create_customers_export_view.rb b/db/migrate/20250407000001_create_customers_export_view.rb new file mode 100644 index 0000000..82185a2 --- /dev/null +++ b/db/migrate/20250407000001_create_customers_export_view.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateCustomersExportView < ActiveRecord::Migration[7.0] + def change + create_view :exports_customers, version: 1 + create_view :exports_billable_metrics, version: 1 + create_view :exports_plans, version: 1 + create_view :exports_applied_coupons, version: 1 + create_view :exports_invoices, version: 1 + create_view :exports_invoices_taxes, version: 1 + create_view :exports_charges, version: 1 + create_view :exports_wallets, version: 1 + create_view :exports_wallet_transactions, version: 1 + create_view :exports_coupons, version: 1 + create_view :exports_taxes, version: 1 + create_view :exports_credit_notes_taxes, version: 1 + create_view :exports_credit_notes, version: 1 + create_view :exports_fees_taxes, version: 1 + create_view :exports_fees, version: 1 + create_view :exports_subscriptions, version: 1 + end +end diff --git a/db/migrate/20250407202459_add_section_type_index_to_invoice_custom_sections.rb b/db/migrate/20250407202459_add_section_type_index_to_invoice_custom_sections.rb new file mode 100644 index 0000000..2fbe8cf --- /dev/null +++ b/db/migrate/20250407202459_add_section_type_index_to_invoice_custom_sections.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddSectionTypeIndexToInvoiceCustomSections < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + add_index :invoice_custom_sections, :section_type, algorithm: :concurrently + end + + def down + remove_index :invoice_custom_sections, column: :section_type + end +end diff --git a/db/migrate/20250408121522_ensure_all_billing_entities_have_invoice_sequential_id.rb b/db/migrate/20250408121522_ensure_all_billing_entities_have_invoice_sequential_id.rb new file mode 100644 index 0000000..bf1bc81 --- /dev/null +++ b/db/migrate/20250408121522_ensure_all_billing_entities_have_invoice_sequential_id.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class EnsureAllBillingEntitiesHaveInvoiceSequentialId < ActiveRecord::Migration[7.2] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def change + BillingEntity.find_each do |billing_entity| + last_billing_entity_sequential_id = billing_entity.invoices.non_self_billed.with_generated_number.maximum(:billing_entity_sequential_id) || 0 + invoices_count = billing_entity.invoices.non_self_billed.with_generated_number.count + + next if last_billing_entity_sequential_id == invoices_count + + last_invoice = billing_entity.invoices.non_self_billed.with_generated_number.where(billing_entity_sequential_id: nil).order(created_at: :desc).limit(1) + last_invoice.update_all(billing_entity_sequential_id: invoices_count) # rubocop:disable Rails/SkipsModelValidations + end + end +end diff --git a/db/migrate/20250409100421_add_finalized_at_timestamp_to_invoices.rb b/db/migrate/20250409100421_add_finalized_at_timestamp_to_invoices.rb new file mode 100644 index 0000000..65e0471 --- /dev/null +++ b/db/migrate/20250409100421_add_finalized_at_timestamp_to_invoices.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddFinalizedAtTimestampToInvoices < ActiveRecord::Migration[7.2] + def change + add_column :invoices, :finalized_at, :timestamp + end +end diff --git a/db/migrate/20250409140652_add_organization_id_not_null_check_constraint_to_fees.rb b/db/migrate/20250409140652_add_organization_id_not_null_check_constraint_to_fees.rb new file mode 100644 index 0000000..79e7352 --- /dev/null +++ b/db/migrate/20250409140652_add_organization_id_not_null_check_constraint_to_fees.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdNotNullCheckConstraintToFees < ActiveRecord::Migration[7.2] + def change + add_check_constraint :fees, "organization_id IS NOT NULL", name: "fees_organization_id_null", validate: false + end +end diff --git a/db/migrate/20250409140720_validate_add_non_null_to_fees_organization_id.rb b/db/migrate/20250409140720_validate_add_non_null_to_fees_organization_id.rb new file mode 100644 index 0000000..fcb3a5e --- /dev/null +++ b/db/migrate/20250409140720_validate_add_non_null_to_fees_organization_id.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class ValidateAddNonNullToFeesOrganizationId < ActiveRecord::Migration[7.2] + def up + validate_check_constraint :fees, name: "fees_organization_id_null" + change_column_null :fees, :organization_id, false + remove_check_constraint :fees, name: "fees_organization_id_null" + end + + def down + add_check_constraint :fees, "organization_id IS NOT NULL", name: "fees_organization_id_null", validate: false + end +end diff --git a/db/migrate/20250411074202_drop_index_events_on_subscription_id.rb b/db/migrate/20250411074202_drop_index_events_on_subscription_id.rb new file mode 100644 index 0000000..ebac81f --- /dev/null +++ b/db/migrate/20250411074202_drop_index_events_on_subscription_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropIndexEventsOnSubscriptionId < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + remove_index :events, name: :index_events_on_subscription_id, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20250411110825_drop_index_events_on_external_subscription_id_and_code_and_timestamp.rb b/db/migrate/20250411110825_drop_index_events_on_external_subscription_id_and_code_and_timestamp.rb new file mode 100644 index 0000000..19ed17c --- /dev/null +++ b/db/migrate/20250411110825_drop_index_events_on_external_subscription_id_and_code_and_timestamp.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropIndexEventsOnExternalSubscriptionIdAndCodeAndTimestamp < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + remove_index :events, name: :index_events_on_external_subscription_id_and_code_and_timestamp, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20250411110934_drop_index_events_on_subscription_id_and_code_and_timestamp.rb b/db/migrate/20250411110934_drop_index_events_on_subscription_id_and_code_and_timestamp.rb new file mode 100644 index 0000000..714d342 --- /dev/null +++ b/db/migrate/20250411110934_drop_index_events_on_subscription_id_and_code_and_timestamp.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropIndexEventsOnSubscriptionIdAndCodeAndTimestamp < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + remove_index :events, name: :index_events_on_subscription_id_and_code_and_timestamp, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20250411112117_drop_index_events_on_organization_id_and_code_and_created_at.rb b/db/migrate/20250411112117_drop_index_events_on_organization_id_and_code_and_created_at.rb new file mode 100644 index 0000000..3486e0b --- /dev/null +++ b/db/migrate/20250411112117_drop_index_events_on_organization_id_and_code_and_created_at.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropIndexEventsOnOrganizationIdAndCodeAndCreatedAt < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + remove_index :events, name: :index_events_on_organization_id_and_code_and_created_at, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20250411152022_migrate_applied_taxes_to_billing_entities.rb b/db/migrate/20250411152022_migrate_applied_taxes_to_billing_entities.rb new file mode 100644 index 0000000..47c8183 --- /dev/null +++ b/db/migrate/20250411152022_migrate_applied_taxes_to_billing_entities.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class MigrateAppliedTaxesToBillingEntities < ActiveRecord::Migration[7.2] + def up + applicable_taxes = Tax.unscoped.where(applied_to_organization: true).pluck(:id, :organization_id) + + timestamp = Time.current + rows = applicable_taxes.map do |tax_id, organization_id| + { + billing_entity_id: organization_id, + tax_id: tax_id, + created_at: timestamp, + updated_at: timestamp, + deleted_at: nil + } + end + + # rubocop:disable Rails/SkipsModelValidations + BillingEntity::AppliedTax.insert_all( + rows, + unique_by: :index_billing_entities_taxes_on_billing_entity_id_and_tax_id + ) + # rubocop:enable Rails/SkipsModelValidations + end +end diff --git a/db/migrate/20250414091130_create_idempotency_records.rb b/db/migrate/20250414091130_create_idempotency_records.rb new file mode 100644 index 0000000..c7fb6aa --- /dev/null +++ b/db/migrate/20250414091130_create_idempotency_records.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class CreateIdempotencyRecords < ActiveRecord::Migration[7.2] + def change + create_table :idempotency_records, id: :uuid do |t| + t.binary :idempotency_key, null: false + t.uuid :resource_id + t.string :resource_type + + t.timestamps + end + + add_index :idempotency_records, [:resource_type, :resource_id] + add_index :idempotency_records, [:idempotency_key], unique: true + end +end diff --git a/db/migrate/20250414121455_drop_cached_aggregations_group_indexes.rb b/db/migrate/20250414121455_drop_cached_aggregations_group_indexes.rb new file mode 100644 index 0000000..3209c48 --- /dev/null +++ b/db/migrate/20250414121455_drop_cached_aggregations_group_indexes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DropCachedAggregationsGroupIndexes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + remove_index :cached_aggregations, name: :index_cached_aggregations_on_group_id + remove_index :cached_aggregations, name: :index_timestamp_group_lookup + end +end diff --git a/db/migrate/20250414122643_drop_cached_aggregation_timestamp_lookup.rb b/db/migrate/20250414122643_drop_cached_aggregation_timestamp_lookup.rb new file mode 100644 index 0000000..7fc46b2 --- /dev/null +++ b/db/migrate/20250414122643_drop_cached_aggregation_timestamp_lookup.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropCachedAggregationTimestampLookup < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + remove_index :cached_aggregations, name: :index_timestamp_lookup + end +end diff --git a/db/migrate/20250414122904_change_cached_aggregation_lookup.rb b/db/migrate/20250414122904_change_cached_aggregation_lookup.rb new file mode 100644 index 0000000..e1d49d2 --- /dev/null +++ b/db/migrate/20250414122904_change_cached_aggregation_lookup.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class ChangeCachedAggregationLookup < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def up + safety_assured do + add_index( + :cached_aggregations, + [:external_subscription_id, :charge_id, :timestamp], + include: [:organization_id, :grouped_by], + name: :idx_aggregation_lookup, + algorithm: :concurrently + ) + end + + remove_index :cached_aggregations, name: :index_timestamp_filter_lookup + end +end diff --git a/db/migrate/20250415143607_enqueue_update_all_eu_taxes_job.rb b/db/migrate/20250415143607_enqueue_update_all_eu_taxes_job.rb new file mode 100644 index 0000000..0119b64 --- /dev/null +++ b/db/migrate/20250415143607_enqueue_update_all_eu_taxes_job.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class EnqueueUpdateAllEuTaxesJob < ActiveRecord::Migration[7.1] + def up + Taxes::UpdateAllEuTaxesJob.perform_later + end + + def down + end +end diff --git a/db/migrate/20250416125600_create_payment_intents.rb b/db/migrate/20250416125600_create_payment_intents.rb new file mode 100644 index 0000000..94784f5 --- /dev/null +++ b/db/migrate/20250416125600_create_payment_intents.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreatePaymentIntents < ActiveRecord::Migration[7.2] + def change + create_table :payment_intents, id: :uuid do |t| + t.references :invoice, null: false, index: true, type: :uuid + t.references :organization, null: false, index: true, type: :uuid + t.string :payment_url + t.integer :status, default: 0, null: false + t.datetime :expires_at, null: false + + t.timestamps + + t.index ["invoice_id", "status"], where: "(status = 0)", unique: true + end + end +end diff --git a/db/migrate/20250424135624_add_organization_id_to_charges.rb b/db/migrate/20250424135624_add_organization_id_to_charges.rb new file mode 100644 index 0000000..fb4be75 --- /dev/null +++ b/db/migrate/20250424135624_add_organization_id_to_charges.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCharges < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :charges, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250424140359_add_organization_id_fk_to_charges.rb b/db/migrate/20250424140359_add_organization_id_fk_to_charges.rb new file mode 100644 index 0000000..2686121 --- /dev/null +++ b/db/migrate/20250424140359_add_organization_id_fk_to_charges.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCharges < ActiveRecord::Migration[7.2] + def change + add_foreign_key :charges, :organizations, validate: false + end +end diff --git a/db/migrate/20250424140537_validate_charges_organizations_foreign_key.rb b/db/migrate/20250424140537_validate_charges_organizations_foreign_key.rb new file mode 100644 index 0000000..2694e7c --- /dev/null +++ b/db/migrate/20250424140537_validate_charges_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateChargesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :charges, :organizations + end +end diff --git a/db/migrate/20250425102306_add_organization_to_add_ons_taxes.rb b/db/migrate/20250425102306_add_organization_to_add_ons_taxes.rb new file mode 100644 index 0000000..4e50ff4 --- /dev/null +++ b/db/migrate/20250425102306_add_organization_to_add_ons_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationToAddOnsTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :add_ons_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250425102447_add_organization_id_fk_to_add_ons_taxes.rb b/db/migrate/20250425102447_add_organization_id_fk_to_add_ons_taxes.rb new file mode 100644 index 0000000..6e9c6ee --- /dev/null +++ b/db/migrate/20250425102447_add_organization_id_fk_to_add_ons_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToAddOnsTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :add_ons_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250425102555_validates_add_ons_taxes_organizations_foreign_key.rb b/db/migrate/20250425102555_validates_add_ons_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..df3f2d2 --- /dev/null +++ b/db/migrate/20250425102555_validates_add_ons_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidatesAddOnsTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :add_ons_taxes, :organizations + end +end diff --git a/db/migrate/20250425122510_add_organization_id_to_subscriptions.rb b/db/migrate/20250425122510_add_organization_id_to_subscriptions.rb new file mode 100644 index 0000000..f72e184 --- /dev/null +++ b/db/migrate/20250425122510_add_organization_id_to_subscriptions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToSubscriptions < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :subscriptions, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250425122641_add_organization_id_fk_to_subscription.rb b/db/migrate/20250425122641_add_organization_id_fk_to_subscription.rb new file mode 100644 index 0000000..01a211a --- /dev/null +++ b/db/migrate/20250425122641_add_organization_id_fk_to_subscription.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToSubscription < ActiveRecord::Migration[7.2] + def change + add_foreign_key :subscriptions, :organizations, validate: false + end +end diff --git a/db/migrate/20250425122705_validate_subscriptions_organizations_foreign_key.rb b/db/migrate/20250425122705_validate_subscriptions_organizations_foreign_key.rb new file mode 100644 index 0000000..7f36065 --- /dev/null +++ b/db/migrate/20250425122705_validate_subscriptions_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateSubscriptionsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :subscriptions, :organizations + end +end diff --git a/db/migrate/20250425123733_add_organization_id_to_adjusted_fees.rb b/db/migrate/20250425123733_add_organization_id_to_adjusted_fees.rb new file mode 100644 index 0000000..d3e6fe1 --- /dev/null +++ b/db/migrate/20250425123733_add_organization_id_to_adjusted_fees.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddOrganizationIdToAdjustedFees < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + def change + add_reference :adjusted_fees, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250425124100_add_organization_id_fk_to_adjusted_fees.rb b/db/migrate/20250425124100_add_organization_id_fk_to_adjusted_fees.rb new file mode 100644 index 0000000..1d0f7f8 --- /dev/null +++ b/db/migrate/20250425124100_add_organization_id_fk_to_adjusted_fees.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToAdjustedFees < ActiveRecord::Migration[7.2] + def change + add_foreign_key :adjusted_fees, :organizations, validate: false + end +end diff --git a/db/migrate/20250425124305_validate_adjusted_fees_organizations_foreign_key.rb b/db/migrate/20250425124305_validate_adjusted_fees_organizations_foreign_key.rb new file mode 100644 index 0000000..7a54234 --- /dev/null +++ b/db/migrate/20250425124305_validate_adjusted_fees_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateAdjustedFeesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :adjusted_fees, :organizations + end +end diff --git a/db/migrate/20250425124804_add_organization_id_to_wallets.rb b/db/migrate/20250425124804_add_organization_id_to_wallets.rb new file mode 100644 index 0000000..8d03c22 --- /dev/null +++ b/db/migrate/20250425124804_add_organization_id_to_wallets.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToWallets < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :wallets, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250425124826_add_organization_id_fk_to_wallets.rb b/db/migrate/20250425124826_add_organization_id_fk_to_wallets.rb new file mode 100644 index 0000000..2bc9152 --- /dev/null +++ b/db/migrate/20250425124826_add_organization_id_fk_to_wallets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToWallets < ActiveRecord::Migration[7.2] + def change + add_foreign_key :wallets, :organizations, validate: false + end +end diff --git a/db/migrate/20250425124942_validate_wallets_organizations_foreign_key.rb b/db/migrate/20250425124942_validate_wallets_organizations_foreign_key.rb new file mode 100644 index 0000000..59ee709 --- /dev/null +++ b/db/migrate/20250425124942_validate_wallets_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateWalletsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :wallets, :organizations + end +end diff --git a/db/migrate/20250425130332_add_organization_id_to_wallet_transactions.rb b/db/migrate/20250425130332_add_organization_id_to_wallet_transactions.rb new file mode 100644 index 0000000..4dd52da --- /dev/null +++ b/db/migrate/20250425130332_add_organization_id_to_wallet_transactions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToWalletTransactions < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :wallet_transactions, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250425130345_add_organization_id_fk_to_wallet_transactions.rb b/db/migrate/20250425130345_add_organization_id_fk_to_wallet_transactions.rb new file mode 100644 index 0000000..f720b10 --- /dev/null +++ b/db/migrate/20250425130345_add_organization_id_fk_to_wallet_transactions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToWalletTransactions < ActiveRecord::Migration[7.2] + def change + add_foreign_key :wallet_transactions, :organizations, validate: false + end +end diff --git a/db/migrate/20250425130412_validate_wallets_transactions_organization_foreign_key.rb b/db/migrate/20250425130412_validate_wallets_transactions_organization_foreign_key.rb new file mode 100644 index 0000000..93c0925 --- /dev/null +++ b/db/migrate/20250425130412_validate_wallets_transactions_organization_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateWalletsTransactionsOrganizationForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :wallet_transactions, :organizations + end +end diff --git a/db/migrate/20250425132247_add_organization_id_to_applied_coupons.rb b/db/migrate/20250425132247_add_organization_id_to_applied_coupons.rb new file mode 100644 index 0000000..8d71484 --- /dev/null +++ b/db/migrate/20250425132247_add_organization_id_to_applied_coupons.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToAppliedCoupons < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :applied_coupons, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250425132724_add_organization_id_to_payments.rb b/db/migrate/20250425132724_add_organization_id_to_payments.rb new file mode 100644 index 0000000..bb39d60 --- /dev/null +++ b/db/migrate/20250425132724_add_organization_id_to_payments.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToPayments < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :payments, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250425132757_add_organization_id_fk_to_paymants.rb b/db/migrate/20250425132757_add_organization_id_fk_to_paymants.rb new file mode 100644 index 0000000..c0a92ce --- /dev/null +++ b/db/migrate/20250425132757_add_organization_id_fk_to_paymants.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToPaymants < ActiveRecord::Migration[7.2] + def change + add_foreign_key :payments, :organizations, validate: false + end +end diff --git a/db/migrate/20250425132821_validate_payments_organizations_foreign_key.rb b/db/migrate/20250425132821_validate_payments_organizations_foreign_key.rb new file mode 100644 index 0000000..2653148 --- /dev/null +++ b/db/migrate/20250425132821_validate_payments_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidatePaymentsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :payments, :organizations + end +end diff --git a/db/migrate/20250425134826_add_organization_id_fk_to_applied_coupons.rb b/db/migrate/20250425134826_add_organization_id_fk_to_applied_coupons.rb new file mode 100644 index 0000000..268207e --- /dev/null +++ b/db/migrate/20250425134826_add_organization_id_fk_to_applied_coupons.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToAppliedCoupons < ActiveRecord::Migration[7.2] + def change + add_foreign_key :applied_coupons, :organizations, validate: false + end +end diff --git a/db/migrate/20250425134911_validate_applied_coupons_organizations_foreign_key.rb b/db/migrate/20250425134911_validate_applied_coupons_organizations_foreign_key.rb new file mode 100644 index 0000000..0c8fbed --- /dev/null +++ b/db/migrate/20250425134911_validate_applied_coupons_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateAppliedCouponsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :applied_coupons, :organizations + end +end diff --git a/db/migrate/20250428111042_ensure_organization_last_invoice_got_organization_sequential_id_retry.rb b/db/migrate/20250428111042_ensure_organization_last_invoice_got_organization_sequential_id_retry.rb new file mode 100644 index 0000000..763a322 --- /dev/null +++ b/db/migrate/20250428111042_ensure_organization_last_invoice_got_organization_sequential_id_retry.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class EnsureOrganizationLastInvoiceGotOrganizationSequentialIdRetry < ActiveRecord::Migration[7.2] + def change + DatabaseMigrations::FixInvoicesOrganizationSequentialIdJob.perform_later + end +end diff --git a/db/migrate/20250428130107_add_organization_id_to_webhooks.rb b/db/migrate/20250428130107_add_organization_id_to_webhooks.rb new file mode 100644 index 0000000..853858b --- /dev/null +++ b/db/migrate/20250428130107_add_organization_id_to_webhooks.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToWebhooks < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :webhooks, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250428130129_add_organization_id_fk_to_webhooks.rb b/db/migrate/20250428130129_add_organization_id_fk_to_webhooks.rb new file mode 100644 index 0000000..82d6411 --- /dev/null +++ b/db/migrate/20250428130129_add_organization_id_fk_to_webhooks.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToWebhooks < ActiveRecord::Migration[7.2] + def change + add_foreign_key :webhooks, :organizations, validate: false + end +end diff --git a/db/migrate/20250428130148_validate_webhooks_organizations_foreign_key.rb b/db/migrate/20250428130148_validate_webhooks_organizations_foreign_key.rb new file mode 100644 index 0000000..5714fd8 --- /dev/null +++ b/db/migrate/20250428130148_validate_webhooks_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateWebhooksOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :webhooks, :organizations + end +end diff --git a/db/migrate/20250428140111_add_organization_id_to_usage_thresholds.rb b/db/migrate/20250428140111_add_organization_id_to_usage_thresholds.rb new file mode 100644 index 0000000..866b6d2 --- /dev/null +++ b/db/migrate/20250428140111_add_organization_id_to_usage_thresholds.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToUsageThresholds < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :usage_thresholds, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250428140126_add_organization_id_fk_to_usage_thresholds.rb b/db/migrate/20250428140126_add_organization_id_fk_to_usage_thresholds.rb new file mode 100644 index 0000000..c72ec19 --- /dev/null +++ b/db/migrate/20250428140126_add_organization_id_fk_to_usage_thresholds.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToUsageThresholds < ActiveRecord::Migration[7.2] + def change + add_foreign_key :usage_thresholds, :organizations, validate: false + end +end diff --git a/db/migrate/20250428140148_validate_usage_thresholds_organizations_foreign_key.rb b/db/migrate/20250428140148_validate_usage_thresholds_organizations_foreign_key.rb new file mode 100644 index 0000000..689f9ae --- /dev/null +++ b/db/migrate/20250428140148_validate_usage_thresholds_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateUsageThresholdsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :usage_thresholds, :organizations + end +end diff --git a/db/migrate/20250428154444_add_organization_id_to_plans_taxes.rb b/db/migrate/20250428154444_add_organization_id_to_plans_taxes.rb new file mode 100644 index 0000000..c727fb2 --- /dev/null +++ b/db/migrate/20250428154444_add_organization_id_to_plans_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToPlansTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :plans_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250428154500_add_organization_id_fk_to_plans_taxes.rb b/db/migrate/20250428154500_add_organization_id_fk_to_plans_taxes.rb new file mode 100644 index 0000000..3316af5 --- /dev/null +++ b/db/migrate/20250428154500_add_organization_id_fk_to_plans_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToPlansTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :plans_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250428154519_validate_plans_taxes_organizations_foreign_key.rb b/db/migrate/20250428154519_validate_plans_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..e7dd747 --- /dev/null +++ b/db/migrate/20250428154519_validate_plans_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidatePlansTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :plans_taxes, :organizations + end +end diff --git a/db/migrate/20250429100148_create_usage_monitoring_subscription_activities.rb b/db/migrate/20250429100148_create_usage_monitoring_subscription_activities.rb new file mode 100644 index 0000000..913251a --- /dev/null +++ b/db/migrate/20250429100148_create_usage_monitoring_subscription_activities.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateUsageMonitoringSubscriptionActivities < ActiveRecord::Migration[7.2] + def change + create_table :usage_monitoring_subscription_activities, id: :bigserial do |t| # rubocop:disable Rails/CreateTableWithTimestamps + t.references :organization, type: :uuid, foreign_key: true, null: false, index: true + t.references :subscription, type: :uuid, foreign_key: true, null: false, index: { + unique: true, name: :idx_subscription_unique + } + t.boolean :enqueued, default: false, null: false + t.datetime :inserted_at, default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime :enqueued_at + + t.index [:organization_id, :enqueued], name: :idx_enqueued_per_organization + end + end +end diff --git a/db/migrate/20250429100149_add_organization_id_to_invoice_subscriptions.rb b/db/migrate/20250429100149_add_organization_id_to_invoice_subscriptions.rb new file mode 100644 index 0000000..b98c959 --- /dev/null +++ b/db/migrate/20250429100149_add_organization_id_to_invoice_subscriptions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToInvoiceSubscriptions < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :invoice_subscriptions, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250429100150_add_organization_id_fk_to_invoice_subscriptions.rb b/db/migrate/20250429100150_add_organization_id_fk_to_invoice_subscriptions.rb new file mode 100644 index 0000000..0cedd1c --- /dev/null +++ b/db/migrate/20250429100150_add_organization_id_fk_to_invoice_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToInvoiceSubscriptions < ActiveRecord::Migration[7.2] + def change + add_foreign_key :invoice_subscriptions, :organizations, validate: false + end +end diff --git a/db/migrate/20250429100151_validate_invoice_subscriptions_organizations_foreign_key.rb b/db/migrate/20250429100151_validate_invoice_subscriptions_organizations_foreign_key.rb new file mode 100644 index 0000000..186d193 --- /dev/null +++ b/db/migrate/20250429100151_validate_invoice_subscriptions_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateInvoiceSubscriptionsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :invoice_subscriptions, :organizations + end +end diff --git a/db/migrate/20250429100152_add_organization_id_to_payment_provider_customers.rb b/db/migrate/20250429100152_add_organization_id_to_payment_provider_customers.rb new file mode 100644 index 0000000..4e1e537 --- /dev/null +++ b/db/migrate/20250429100152_add_organization_id_to_payment_provider_customers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToPaymentProviderCustomers < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :payment_provider_customers, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250429100153_add_organization_id_fk_to_payment_provider_customers.rb b/db/migrate/20250429100153_add_organization_id_fk_to_payment_provider_customers.rb new file mode 100644 index 0000000..9d837c8 --- /dev/null +++ b/db/migrate/20250429100153_add_organization_id_fk_to_payment_provider_customers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToPaymentProviderCustomers < ActiveRecord::Migration[7.2] + def change + add_foreign_key :payment_provider_customers, :organizations, validate: false + end +end diff --git a/db/migrate/20250429100154_validate_payment_provider_customers_organizations_foreign_key.rb b/db/migrate/20250429100154_validate_payment_provider_customers_organizations_foreign_key.rb new file mode 100644 index 0000000..37a679a --- /dev/null +++ b/db/migrate/20250429100154_validate_payment_provider_customers_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidatePaymentProviderCustomersOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :payment_provider_customers, :organizations + end +end diff --git a/db/migrate/20250429150114_add_organization_id_to_invoices_taxes.rb b/db/migrate/20250429150114_add_organization_id_to_invoices_taxes.rb new file mode 100644 index 0000000..b695c56 --- /dev/null +++ b/db/migrate/20250429150114_add_organization_id_to_invoices_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToInvoicesTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :invoices_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250429150128_add_organization_id_fk_to_invoices_taxes.rb b/db/migrate/20250429150128_add_organization_id_fk_to_invoices_taxes.rb new file mode 100644 index 0000000..7b24a89 --- /dev/null +++ b/db/migrate/20250429150128_add_organization_id_fk_to_invoices_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToInvoicesTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :invoices_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250429150146_validate_invoices_taxes_organizations_foreign_key.rb b/db/migrate/20250429150146_validate_invoices_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..f890a39 --- /dev/null +++ b/db/migrate/20250429150146_validate_invoices_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateInvoicesTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :invoices_taxes, :organizations + end +end diff --git a/db/migrate/20250505125308_add_organization_id_to_idempotency_records.rb b/db/migrate/20250505125308_add_organization_id_to_idempotency_records.rb new file mode 100644 index 0000000..c533f31 --- /dev/null +++ b/db/migrate/20250505125308_add_organization_id_to_idempotency_records.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToIdempotencyRecords < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :idempotency_records, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250505125335_add_organization_id_fk_to_idempotency_records.rb b/db/migrate/20250505125335_add_organization_id_fk_to_idempotency_records.rb new file mode 100644 index 0000000..b3fd962 --- /dev/null +++ b/db/migrate/20250505125335_add_organization_id_fk_to_idempotency_records.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToIdempotencyRecords < ActiveRecord::Migration[7.2] + def change + add_foreign_key :idempotency_records, :organizations, validate: false + end +end diff --git a/db/migrate/20250505125354_validate_idempotency_records_organizations_foreign_key.rb b/db/migrate/20250505125354_validate_idempotency_records_organizations_foreign_key.rb new file mode 100644 index 0000000..2847d0a --- /dev/null +++ b/db/migrate/20250505125354_validate_idempotency_records_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateIdempotencyRecordsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :idempotency_records, :organizations + end +end diff --git a/db/migrate/20250505135819_add_organization_id_to_charge_filters.rb b/db/migrate/20250505135819_add_organization_id_to_charge_filters.rb new file mode 100644 index 0000000..6d8a257 --- /dev/null +++ b/db/migrate/20250505135819_add_organization_id_to_charge_filters.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToChargeFilters < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :charge_filters, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250505135820_add_organization_id_fk_to_charge_filters.rb b/db/migrate/20250505135820_add_organization_id_fk_to_charge_filters.rb new file mode 100644 index 0000000..e9de6c3 --- /dev/null +++ b/db/migrate/20250505135820_add_organization_id_fk_to_charge_filters.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToChargeFilters < ActiveRecord::Migration[7.2] + def change + add_foreign_key :charge_filters, :organizations, validate: false + end +end diff --git a/db/migrate/20250505135821_validate_charge_filters_organizations_foreign_key.rb b/db/migrate/20250505135821_validate_charge_filters_organizations_foreign_key.rb new file mode 100644 index 0000000..afeabbb --- /dev/null +++ b/db/migrate/20250505135821_validate_charge_filters_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateChargeFiltersOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :charge_filters, :organizations + end +end diff --git a/db/migrate/20250505140926_add_organization_id_to_charges_taxes.rb b/db/migrate/20250505140926_add_organization_id_to_charges_taxes.rb new file mode 100644 index 0000000..68ff8a9 --- /dev/null +++ b/db/migrate/20250505140926_add_organization_id_to_charges_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToChargesTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :charges_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250505140927_add_organization_id_fk_to_charges_taxes.rb b/db/migrate/20250505140927_add_organization_id_fk_to_charges_taxes.rb new file mode 100644 index 0000000..e3d4455 --- /dev/null +++ b/db/migrate/20250505140927_add_organization_id_fk_to_charges_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToChargesTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :charges_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250505140928_validate_charges_taxes_organizations_foreign_key.rb b/db/migrate/20250505140928_validate_charges_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..d7689ae --- /dev/null +++ b/db/migrate/20250505140928_validate_charges_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateChargesTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :charges_taxes, :organizations + end +end diff --git a/db/migrate/20250505142219_add_organization_id_to_credits.rb b/db/migrate/20250505142219_add_organization_id_to_credits.rb new file mode 100644 index 0000000..0027337 --- /dev/null +++ b/db/migrate/20250505142219_add_organization_id_to_credits.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCredits < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :credits, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250505142220_add_organization_id_fk_to_credits.rb b/db/migrate/20250505142220_add_organization_id_fk_to_credits.rb new file mode 100644 index 0000000..f224309 --- /dev/null +++ b/db/migrate/20250505142220_add_organization_id_fk_to_credits.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCredits < ActiveRecord::Migration[7.2] + def change + add_foreign_key :credits, :organizations, validate: false + end +end diff --git a/db/migrate/20250505142221_validate_credits_organizations_foreign_key.rb b/db/migrate/20250505142221_validate_credits_organizations_foreign_key.rb new file mode 100644 index 0000000..cfcf461 --- /dev/null +++ b/db/migrate/20250505142221_validate_credits_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCreditsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :credits, :organizations + end +end diff --git a/db/migrate/20250505161357_add_organization_id_to_credit_notes.rb b/db/migrate/20250505161357_add_organization_id_to_credit_notes.rb new file mode 100644 index 0000000..9d8f4be --- /dev/null +++ b/db/migrate/20250505161357_add_organization_id_to_credit_notes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCreditNotes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :credit_notes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250505161358_add_organization_id_fk_to_credit_notes.rb b/db/migrate/20250505161358_add_organization_id_fk_to_credit_notes.rb new file mode 100644 index 0000000..4852264 --- /dev/null +++ b/db/migrate/20250505161358_add_organization_id_fk_to_credit_notes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCreditNotes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :credit_notes, :organizations, validate: false + end +end diff --git a/db/migrate/20250505161359_validate_credit_notes_organizations_foreign_key.rb b/db/migrate/20250505161359_validate_credit_notes_organizations_foreign_key.rb new file mode 100644 index 0000000..609b0c4 --- /dev/null +++ b/db/migrate/20250505161359_validate_credit_notes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCreditNotesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :credit_notes, :organizations + end +end diff --git a/db/migrate/20250506084020_add_organization_id_to_applied_invoice_custom_sections.rb b/db/migrate/20250506084020_add_organization_id_to_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..e408672 --- /dev/null +++ b/db/migrate/20250506084020_add_organization_id_to_applied_invoice_custom_sections.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToAppliedInvoiceCustomSections < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :applied_invoice_custom_sections, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250506084021_add_organization_id_fk_to_applied_invoice_custom_sections.rb b/db/migrate/20250506084021_add_organization_id_fk_to_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..010d545 --- /dev/null +++ b/db/migrate/20250506084021_add_organization_id_fk_to_applied_invoice_custom_sections.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToAppliedInvoiceCustomSections < ActiveRecord::Migration[7.2] + def change + add_foreign_key :applied_invoice_custom_sections, :organizations, validate: false + end +end diff --git a/db/migrate/20250506084022_validate_applied_invoice_custom_sections_organizations_foreign_key.rb b/db/migrate/20250506084022_validate_applied_invoice_custom_sections_organizations_foreign_key.rb new file mode 100644 index 0000000..1c3c366 --- /dev/null +++ b/db/migrate/20250506084022_validate_applied_invoice_custom_sections_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateAppliedInvoiceCustomSectionsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :applied_invoice_custom_sections, :organizations + end +end diff --git a/db/migrate/20250506084827_add_organization_id_to_applied_usage_thresholds.rb b/db/migrate/20250506084827_add_organization_id_to_applied_usage_thresholds.rb new file mode 100644 index 0000000..777b753 --- /dev/null +++ b/db/migrate/20250506084827_add_organization_id_to_applied_usage_thresholds.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToAppliedUsageThresholds < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :applied_usage_thresholds, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250506084828_add_organization_id_fk_to_applied_usage_thresholds.rb b/db/migrate/20250506084828_add_organization_id_fk_to_applied_usage_thresholds.rb new file mode 100644 index 0000000..3a20130 --- /dev/null +++ b/db/migrate/20250506084828_add_organization_id_fk_to_applied_usage_thresholds.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToAppliedUsageThresholds < ActiveRecord::Migration[7.2] + def change + add_foreign_key :applied_usage_thresholds, :organizations, validate: false + end +end diff --git a/db/migrate/20250506084829_validate_applied_usage_thresholds_organizations_foreign_key.rb b/db/migrate/20250506084829_validate_applied_usage_thresholds_organizations_foreign_key.rb new file mode 100644 index 0000000..7545de8 --- /dev/null +++ b/db/migrate/20250506084829_validate_applied_usage_thresholds_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateAppliedUsageThresholdsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :applied_usage_thresholds, :organizations + end +end diff --git a/db/migrate/20250506085758_add_organization_id_to_customer_metadata.rb b/db/migrate/20250506085758_add_organization_id_to_customer_metadata.rb new file mode 100644 index 0000000..54b0a5b --- /dev/null +++ b/db/migrate/20250506085758_add_organization_id_to_customer_metadata.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCustomerMetadata < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :customer_metadata, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250506085759_add_organization_id_fk_to_customer_metadata.rb b/db/migrate/20250506085759_add_organization_id_fk_to_customer_metadata.rb new file mode 100644 index 0000000..7c8bcc4 --- /dev/null +++ b/db/migrate/20250506085759_add_organization_id_fk_to_customer_metadata.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCustomerMetadata < ActiveRecord::Migration[7.2] + def change + add_foreign_key :customer_metadata, :organizations, validate: false + end +end diff --git a/db/migrate/20250506085760_validate_customer_metadata_organizations_foreign_key.rb b/db/migrate/20250506085760_validate_customer_metadata_organizations_foreign_key.rb new file mode 100644 index 0000000..e7d5e6a --- /dev/null +++ b/db/migrate/20250506085760_validate_customer_metadata_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCustomerMetadataOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :customer_metadata, :organizations + end +end diff --git a/db/migrate/20250506115437_add_organization_id_to_billable_metric_filters.rb b/db/migrate/20250506115437_add_organization_id_to_billable_metric_filters.rb new file mode 100644 index 0000000..81f7652 --- /dev/null +++ b/db/migrate/20250506115437_add_organization_id_to_billable_metric_filters.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToBillableMetricFilters < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :billable_metric_filters, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250506115438_add_organization_id_fk_to_billable_metric_filters.rb b/db/migrate/20250506115438_add_organization_id_fk_to_billable_metric_filters.rb new file mode 100644 index 0000000..a477392 --- /dev/null +++ b/db/migrate/20250506115438_add_organization_id_fk_to_billable_metric_filters.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToBillableMetricFilters < ActiveRecord::Migration[7.2] + def change + add_foreign_key :billable_metric_filters, :organizations, validate: false + end +end diff --git a/db/migrate/20250506115439_validate_billable_metric_filters_organizations_foreign_key.rb b/db/migrate/20250506115439_validate_billable_metric_filters_organizations_foreign_key.rb new file mode 100644 index 0000000..41b0c30 --- /dev/null +++ b/db/migrate/20250506115439_validate_billable_metric_filters_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateBillableMetricFiltersOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :billable_metric_filters, :organizations + end +end diff --git a/db/migrate/20250506121530_add_organization_id_to_fees_taxes.rb b/db/migrate/20250506121530_add_organization_id_to_fees_taxes.rb new file mode 100644 index 0000000..3becd11 --- /dev/null +++ b/db/migrate/20250506121530_add_organization_id_to_fees_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToFeesTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :fees_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250506121531_add_organization_id_fk_to_fees_taxes.rb b/db/migrate/20250506121531_add_organization_id_fk_to_fees_taxes.rb new file mode 100644 index 0000000..7e3a1f5 --- /dev/null +++ b/db/migrate/20250506121531_add_organization_id_fk_to_fees_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToFeesTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :fees_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250506121532_validate_fees_taxes_organizations_foreign_key.rb b/db/migrate/20250506121532_validate_fees_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..f380611 --- /dev/null +++ b/db/migrate/20250506121532_validate_fees_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateFeesTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :fees_taxes, :organizations + end +end diff --git a/db/migrate/20250506144000_add_organization_id_to_invoices_payment_requests.rb b/db/migrate/20250506144000_add_organization_id_to_invoices_payment_requests.rb new file mode 100644 index 0000000..4208d74 --- /dev/null +++ b/db/migrate/20250506144000_add_organization_id_to_invoices_payment_requests.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToInvoicesPaymentRequests < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :invoices_payment_requests, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250506144001_add_organization_id_fk_to_invoices_payment_requests.rb b/db/migrate/20250506144001_add_organization_id_fk_to_invoices_payment_requests.rb new file mode 100644 index 0000000..b9d22e7 --- /dev/null +++ b/db/migrate/20250506144001_add_organization_id_fk_to_invoices_payment_requests.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToInvoicesPaymentRequests < ActiveRecord::Migration[7.2] + def change + add_foreign_key :invoices_payment_requests, :organizations, validate: false + end +end diff --git a/db/migrate/20250506144002_validate_invoices_payment_requests_organizations_foreign_key.rb b/db/migrate/20250506144002_validate_invoices_payment_requests_organizations_foreign_key.rb new file mode 100644 index 0000000..8cbf1fb --- /dev/null +++ b/db/migrate/20250506144002_validate_invoices_payment_requests_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateInvoicesPaymentRequestsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :invoices_payment_requests, :organizations + end +end diff --git a/db/migrate/20250506145849_add_organization_id_to_invoice_metadata.rb b/db/migrate/20250506145849_add_organization_id_to_invoice_metadata.rb new file mode 100644 index 0000000..056d2fb --- /dev/null +++ b/db/migrate/20250506145849_add_organization_id_to_invoice_metadata.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToInvoiceMetadata < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :invoice_metadata, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250506145850_add_organization_id_fk_to_invoice_metadata.rb b/db/migrate/20250506145850_add_organization_id_fk_to_invoice_metadata.rb new file mode 100644 index 0000000..0ff66c2 --- /dev/null +++ b/db/migrate/20250506145850_add_organization_id_fk_to_invoice_metadata.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToInvoiceMetadata < ActiveRecord::Migration[7.2] + def change + add_foreign_key :invoice_metadata, :organizations, validate: false + end +end diff --git a/db/migrate/20250506145851_validate_invoice_metadata_organizations_foreign_key.rb b/db/migrate/20250506145851_validate_invoice_metadata_organizations_foreign_key.rb new file mode 100644 index 0000000..088475f --- /dev/null +++ b/db/migrate/20250506145851_validate_invoice_metadata_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateInvoiceMetadataOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :invoice_metadata, :organizations + end +end diff --git a/db/migrate/20250506170753_update_net_payment_term_on_billing_entity.rb b/db/migrate/20250506170753_update_net_payment_term_on_billing_entity.rb new file mode 100644 index 0000000..5977215 --- /dev/null +++ b/db/migrate/20250506170753_update_net_payment_term_on_billing_entity.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class UpdateNetPaymentTermOnBillingEntity < ActiveRecord::Migration[8.0] + class Organization < ApplicationRecord + has_many :billing_entities + has_one :default_billing_entity, -> { active.order(created_at: :asc) }, class_name: "BillingEntity" + end + + class BillingEntity < ApplicationRecord + belongs_to :organization + scope :active, -> { where(archived_at: nil).order(created_at: :asc) } + end + + def up + Organization.where.not(net_payment_term: 0).find_each do |organization| + organization.default_billing_entity.update!(net_payment_term: organization.net_payment_term) + end + end + + def down + end +end diff --git a/db/migrate/20250507110137_fix_billing_entity_document_numbering_prefix.rb b/db/migrate/20250507110137_fix_billing_entity_document_numbering_prefix.rb new file mode 100644 index 0000000..a55cfd9 --- /dev/null +++ b/db/migrate/20250507110137_fix_billing_entity_document_numbering_prefix.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class FixBillingEntityDocumentNumberingPrefix < ActiveRecord::Migration[8.0] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def up + # rubocop:disable Rails/SkipsModelValidations + BillingEntity.unscoped.update_all(<<~SQL) + document_number_prefix = organizations.document_number_prefix + FROM organizations + WHERE organizations.id = billing_entities.organization_id + AND billing_entities.document_number_prefix != organizations.document_number_prefix + AND billing_entities.id = organizations.id + SQL + # rubocop:enable Rails/SkipsModelValidations + end + + def down + end +end diff --git a/db/migrate/20250507154910_update_nil_eu_tax_management_on_billing_entities.rb b/db/migrate/20250507154910_update_nil_eu_tax_management_on_billing_entities.rb new file mode 100644 index 0000000..37258de --- /dev/null +++ b/db/migrate/20250507154910_update_nil_eu_tax_management_on_billing_entities.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class UpdateNilEuTaxManagementOnBillingEntities < ActiveRecord::Migration[8.0] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def change + # rubocop:disable Rails/SkipsModelValidations + BillingEntity.where(eu_tax_management: nil).update_all(eu_tax_management: false) + # rubocop:enable Rails/SkipsModelValidations + end +end diff --git a/db/migrate/20250512081332_add_lock_version_to_wallet_transactions.rb b/db/migrate/20250512081332_add_lock_version_to_wallet_transactions.rb new file mode 100644 index 0000000..d92eb3d --- /dev/null +++ b/db/migrate/20250512081332_add_lock_version_to_wallet_transactions.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddLockVersionToWalletTransactions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :wallet_transactions, :lock_version, :integer, default: 0, null: false + end +end diff --git a/db/migrate/20250512122606_add_organization_id_to_billing_entities_taxes.rb b/db/migrate/20250512122606_add_organization_id_to_billing_entities_taxes.rb new file mode 100644 index 0000000..4a060a0 --- /dev/null +++ b/db/migrate/20250512122606_add_organization_id_to_billing_entities_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToBillingEntitiesTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :billing_entities_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250512122607_add_organization_id_fk_to_billing_entities_taxes.rb b/db/migrate/20250512122607_add_organization_id_fk_to_billing_entities_taxes.rb new file mode 100644 index 0000000..3e0d535 --- /dev/null +++ b/db/migrate/20250512122607_add_organization_id_fk_to_billing_entities_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToBillingEntitiesTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :billing_entities_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250512122608_validate_billing_entities_taxes_organizations_foreign_key.rb b/db/migrate/20250512122608_validate_billing_entities_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..9ba18e7 --- /dev/null +++ b/db/migrate/20250512122608_validate_billing_entities_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateBillingEntitiesTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :billing_entities_taxes, :organizations + end +end diff --git a/db/migrate/20250512123539_add_organization_id_to_credit_notes_taxes.rb b/db/migrate/20250512123539_add_organization_id_to_credit_notes_taxes.rb new file mode 100644 index 0000000..7fb1acf --- /dev/null +++ b/db/migrate/20250512123539_add_organization_id_to_credit_notes_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCreditNotesTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :credit_notes_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250512123540_add_organization_id_fk_to_credit_notes_taxes.rb b/db/migrate/20250512123540_add_organization_id_fk_to_credit_notes_taxes.rb new file mode 100644 index 0000000..984338c --- /dev/null +++ b/db/migrate/20250512123540_add_organization_id_fk_to_credit_notes_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCreditNotesTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :credit_notes_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250512123541_validate_credit_notes_taxes_organizations_foreign_key.rb b/db/migrate/20250512123541_validate_credit_notes_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..890138e --- /dev/null +++ b/db/migrate/20250512123541_validate_credit_notes_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCreditNotesTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :credit_notes_taxes, :organizations + end +end diff --git a/db/migrate/20250512130614_add_organization_id_to_customers_taxes.rb b/db/migrate/20250512130614_add_organization_id_to_customers_taxes.rb new file mode 100644 index 0000000..55701b3 --- /dev/null +++ b/db/migrate/20250512130614_add_organization_id_to_customers_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCustomersTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :customers_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250512130615_add_organization_id_fk_to_customers_taxes.rb b/db/migrate/20250512130615_add_organization_id_fk_to_customers_taxes.rb new file mode 100644 index 0000000..38f2918 --- /dev/null +++ b/db/migrate/20250512130615_add_organization_id_fk_to_customers_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCustomersTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :customers_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250512130616_validate_customers_taxes_organizations_foreign_key.rb b/db/migrate/20250512130616_validate_customers_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..be1b90f --- /dev/null +++ b/db/migrate/20250512130616_validate_customers_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCustomersTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :customers_taxes, :organizations + end +end diff --git a/db/migrate/20250512142912_add_organization_id_to_data_export_parts.rb b/db/migrate/20250512142912_add_organization_id_to_data_export_parts.rb new file mode 100644 index 0000000..d4145e3 --- /dev/null +++ b/db/migrate/20250512142912_add_organization_id_to_data_export_parts.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToDataExportParts < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :data_export_parts, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250512142913_add_organization_id_fk_to_data_export_parts.rb b/db/migrate/20250512142913_add_organization_id_fk_to_data_export_parts.rb new file mode 100644 index 0000000..fc40f8e --- /dev/null +++ b/db/migrate/20250512142913_add_organization_id_fk_to_data_export_parts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToDataExportParts < ActiveRecord::Migration[7.2] + def change + add_foreign_key :data_export_parts, :organizations, validate: false + end +end diff --git a/db/migrate/20250512142914_validate_data_export_parts_organizations_foreign_key.rb b/db/migrate/20250512142914_validate_data_export_parts_organizations_foreign_key.rb new file mode 100644 index 0000000..ebad900 --- /dev/null +++ b/db/migrate/20250512142914_validate_data_export_parts_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateDataExportPartsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :data_export_parts, :organizations + end +end diff --git a/db/migrate/20250512144218_add_organization_id_to_dunning_campaign_thresholds.rb b/db/migrate/20250512144218_add_organization_id_to_dunning_campaign_thresholds.rb new file mode 100644 index 0000000..d9ea6db --- /dev/null +++ b/db/migrate/20250512144218_add_organization_id_to_dunning_campaign_thresholds.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToDunningCampaignThresholds < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :dunning_campaign_thresholds, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250512144219_add_organization_id_fk_to_dunning_campaign_thresholds.rb b/db/migrate/20250512144219_add_organization_id_fk_to_dunning_campaign_thresholds.rb new file mode 100644 index 0000000..703dfa1 --- /dev/null +++ b/db/migrate/20250512144219_add_organization_id_fk_to_dunning_campaign_thresholds.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToDunningCampaignThresholds < ActiveRecord::Migration[7.2] + def change + add_foreign_key :dunning_campaign_thresholds, :organizations, validate: false + end +end diff --git a/db/migrate/20250512144220_validate_dunning_campaign_thresholds_organizations_foreign_key.rb b/db/migrate/20250512144220_validate_dunning_campaign_thresholds_organizations_foreign_key.rb new file mode 100644 index 0000000..2923b97 --- /dev/null +++ b/db/migrate/20250512144220_validate_dunning_campaign_thresholds_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateDunningCampaignThresholdsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :dunning_campaign_thresholds, :organizations + end +end diff --git a/db/migrate/20250512151246_add_organization_id_to_coupon_targets.rb b/db/migrate/20250512151246_add_organization_id_to_coupon_targets.rb new file mode 100644 index 0000000..54d2671 --- /dev/null +++ b/db/migrate/20250512151246_add_organization_id_to_coupon_targets.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCouponTargets < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :coupon_targets, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250512151247_add_organization_id_fk_to_coupon_targets.rb b/db/migrate/20250512151247_add_organization_id_fk_to_coupon_targets.rb new file mode 100644 index 0000000..7c31b79 --- /dev/null +++ b/db/migrate/20250512151247_add_organization_id_fk_to_coupon_targets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCouponTargets < ActiveRecord::Migration[7.2] + def change + add_foreign_key :coupon_targets, :organizations, validate: false + end +end diff --git a/db/migrate/20250512151248_validate_coupon_targets_organizations_foreign_key.rb b/db/migrate/20250512151248_validate_coupon_targets_organizations_foreign_key.rb new file mode 100644 index 0000000..d7b27cc --- /dev/null +++ b/db/migrate/20250512151248_validate_coupon_targets_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCouponTargetsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :coupon_targets, :organizations + end +end diff --git a/db/migrate/20250513132423_add_organization_id_to_commitments.rb b/db/migrate/20250513132423_add_organization_id_to_commitments.rb new file mode 100644 index 0000000..426ad80 --- /dev/null +++ b/db/migrate/20250513132423_add_organization_id_to_commitments.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCommitments < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :commitments, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250513132424_add_organization_id_fk_to_commitments.rb b/db/migrate/20250513132424_add_organization_id_fk_to_commitments.rb new file mode 100644 index 0000000..b4f0f25 --- /dev/null +++ b/db/migrate/20250513132424_add_organization_id_fk_to_commitments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCommitments < ActiveRecord::Migration[7.2] + def change + add_foreign_key :commitments, :organizations, validate: false + end +end diff --git a/db/migrate/20250513132425_validate_commitments_organizations_foreign_key.rb b/db/migrate/20250513132425_validate_commitments_organizations_foreign_key.rb new file mode 100644 index 0000000..8566fac --- /dev/null +++ b/db/migrate/20250513132425_validate_commitments_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCommitmentsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :commitments, :organizations + end +end diff --git a/db/migrate/20250513144352_add_organization_id_to_commitments_taxes.rb b/db/migrate/20250513144352_add_organization_id_to_commitments_taxes.rb new file mode 100644 index 0000000..80e94f9 --- /dev/null +++ b/db/migrate/20250513144352_add_organization_id_to_commitments_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCommitmentsTaxes < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :commitments_taxes, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250513144353_add_organization_id_fk_to_commitments_taxes.rb b/db/migrate/20250513144353_add_organization_id_fk_to_commitments_taxes.rb new file mode 100644 index 0000000..4bc1e6d --- /dev/null +++ b/db/migrate/20250513144353_add_organization_id_fk_to_commitments_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCommitmentsTaxes < ActiveRecord::Migration[7.2] + def change + add_foreign_key :commitments_taxes, :organizations, validate: false + end +end diff --git a/db/migrate/20250513144354_validate_commitments_taxes_organizations_foreign_key.rb b/db/migrate/20250513144354_validate_commitments_taxes_organizations_foreign_key.rb new file mode 100644 index 0000000..3fb3a98 --- /dev/null +++ b/db/migrate/20250513144354_validate_commitments_taxes_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCommitmentsTaxesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :commitments_taxes, :organizations + end +end diff --git a/db/migrate/20250513151258_add_organization_id_to_credit_note_items.rb b/db/migrate/20250513151258_add_organization_id_to_credit_note_items.rb new file mode 100644 index 0000000..0709c18 --- /dev/null +++ b/db/migrate/20250513151258_add_organization_id_to_credit_note_items.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToCreditNoteItems < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :credit_note_items, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250513151259_add_organization_id_fk_to_credit_note_items.rb b/db/migrate/20250513151259_add_organization_id_fk_to_credit_note_items.rb new file mode 100644 index 0000000..ebb3ad6 --- /dev/null +++ b/db/migrate/20250513151259_add_organization_id_fk_to_credit_note_items.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToCreditNoteItems < ActiveRecord::Migration[7.2] + def change + add_foreign_key :credit_note_items, :organizations, validate: false + end +end diff --git a/db/migrate/20250513151260_validate_credit_note_items_organizations_foreign_key.rb b/db/migrate/20250513151260_validate_credit_note_items_organizations_foreign_key.rb new file mode 100644 index 0000000..1000295 --- /dev/null +++ b/db/migrate/20250513151260_validate_credit_note_items_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateCreditNoteItemsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :credit_note_items, :organizations + end +end diff --git a/db/migrate/20250513152805_add_organization_id_to_integration_resources.rb b/db/migrate/20250513152805_add_organization_id_to_integration_resources.rb new file mode 100644 index 0000000..85cb7b4 --- /dev/null +++ b/db/migrate/20250513152805_add_organization_id_to_integration_resources.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToIntegrationResources < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :integration_resources, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250513152806_add_organization_id_fk_to_integration_resources.rb b/db/migrate/20250513152806_add_organization_id_fk_to_integration_resources.rb new file mode 100644 index 0000000..379261f --- /dev/null +++ b/db/migrate/20250513152806_add_organization_id_fk_to_integration_resources.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToIntegrationResources < ActiveRecord::Migration[7.2] + def change + add_foreign_key :integration_resources, :organizations, validate: false + end +end diff --git a/db/migrate/20250513152807_validate_integration_resources_organizations_foreign_key.rb b/db/migrate/20250513152807_validate_integration_resources_organizations_foreign_key.rb new file mode 100644 index 0000000..b6c18ba --- /dev/null +++ b/db/migrate/20250513152807_validate_integration_resources_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateIntegrationResourcesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :integration_resources, :organizations + end +end diff --git a/db/migrate/20250513153628_add_organization_id_to_integration_customers.rb b/db/migrate/20250513153628_add_organization_id_to_integration_customers.rb new file mode 100644 index 0000000..28e2504 --- /dev/null +++ b/db/migrate/20250513153628_add_organization_id_to_integration_customers.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToIntegrationCustomers < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :integration_customers, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250513153629_add_organization_id_fk_to_integration_customers.rb b/db/migrate/20250513153629_add_organization_id_fk_to_integration_customers.rb new file mode 100644 index 0000000..b83f56a --- /dev/null +++ b/db/migrate/20250513153629_add_organization_id_fk_to_integration_customers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToIntegrationCustomers < ActiveRecord::Migration[7.2] + def change + add_foreign_key :integration_customers, :organizations, validate: false + end +end diff --git a/db/migrate/20250513153630_validate_integration_customers_organizations_foreign_key.rb b/db/migrate/20250513153630_validate_integration_customers_organizations_foreign_key.rb new file mode 100644 index 0000000..462f410 --- /dev/null +++ b/db/migrate/20250513153630_validate_integration_customers_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateIntegrationCustomersOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :integration_customers, :organizations + end +end diff --git a/db/migrate/20250515083649_add_billing_entity_id_to_payment_receipts.rb b/db/migrate/20250515083649_add_billing_entity_id_to_payment_receipts.rb new file mode 100644 index 0000000..21946a5 --- /dev/null +++ b/db/migrate/20250515083649_add_billing_entity_id_to_payment_receipts.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddBillingEntityIdToPaymentReceipts < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :payment_receipts, :billing_entity, type: :uuid, null: true, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250515083802_populate_payment_receipts_billing_entity_id.rb b/db/migrate/20250515083802_populate_payment_receipts_billing_entity_id.rb new file mode 100644 index 0000000..b187202 --- /dev/null +++ b/db/migrate/20250515083802_populate_payment_receipts_billing_entity_id.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class PopulatePaymentReceiptsBillingEntityId < ActiveRecord::Migration[8.0] + def change + PaymentReceipt.where(billing_entity_id: nil).update_all("billing_entity_id = organization_id") # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/db/migrate/20250515083935_set_payment_receipts_not_null_constraint_on_billing_entity_id.rb b/db/migrate/20250515083935_set_payment_receipts_not_null_constraint_on_billing_entity_id.rb new file mode 100644 index 0000000..1908b50 --- /dev/null +++ b/db/migrate/20250515083935_set_payment_receipts_not_null_constraint_on_billing_entity_id.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class SetPaymentReceiptsNotNullConstraintOnBillingEntityId < ActiveRecord::Migration[8.0] + def change + add_check_constraint :payment_receipts, "billing_entity_id IS NOT NULL", name: "payment_receipts_billing_entity_id_null", validate: false + end +end diff --git a/db/migrate/20250515085230_validate_set_payment_receipts_not_null_constraint_on_billing_entity_id.rb b/db/migrate/20250515085230_validate_set_payment_receipts_not_null_constraint_on_billing_entity_id.rb new file mode 100644 index 0000000..517fe24 --- /dev/null +++ b/db/migrate/20250515085230_validate_set_payment_receipts_not_null_constraint_on_billing_entity_id.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ValidateSetPaymentReceiptsNotNullConstraintOnBillingEntityId < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :payment_receipts, name: "payment_receipts_billing_entity_id_null" + change_column_null :payment_receipts, :billing_entity_id, false + remove_check_constraint :payment_receipts, name: "payment_receipts_billing_entity_id_null" + end + + def down + add_check_constraint :payment_receipts, "billing_entity_id IS NOT NULL", name: "payment_receipts_billing_entity_id_null", validate: false + change_column_null :payment_receipts, :billing_entity_id, true + end +end diff --git a/db/migrate/20250516084025_rebuild_invoice_index_on_billing_entity_sequential_id.rb b/db/migrate/20250516084025_rebuild_invoice_index_on_billing_entity_sequential_id.rb new file mode 100644 index 0000000..f4fc641 --- /dev/null +++ b/db/migrate/20250516084025_rebuild_invoice_index_on_billing_entity_sequential_id.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RebuildInvoiceIndexOnBillingEntitySequentialId < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + remove_index :invoices, + [:billing_entity_id, :billing_entity_sequential_id], + if_exists: true + + add_index :invoices, + [:billing_entity_id, :billing_entity_sequential_id], + order: {billing_entity_sequential_id: :desc}, + algorithm: :concurrently, + include: %i[self_billed], + if_not_exists: true + end + + def down + end +end diff --git a/db/migrate/20250516095313_add_organization_id_to_integration_items.rb b/db/migrate/20250516095313_add_organization_id_to_integration_items.rb new file mode 100644 index 0000000..79e74ef --- /dev/null +++ b/db/migrate/20250516095313_add_organization_id_to_integration_items.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToIntegrationItems < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :integration_items, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250516095314_add_organization_id_fk_to_integration_items.rb b/db/migrate/20250516095314_add_organization_id_fk_to_integration_items.rb new file mode 100644 index 0000000..b92fac2 --- /dev/null +++ b/db/migrate/20250516095314_add_organization_id_fk_to_integration_items.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToIntegrationItems < ActiveRecord::Migration[7.2] + def change + add_foreign_key :integration_items, :organizations, validate: false + end +end diff --git a/db/migrate/20250516095315_validate_integration_items_organizations_foreign_key.rb b/db/migrate/20250516095315_validate_integration_items_organizations_foreign_key.rb new file mode 100644 index 0000000..0f11f8f --- /dev/null +++ b/db/migrate/20250516095315_validate_integration_items_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateIntegrationItemsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :integration_items, :organizations + end +end diff --git a/db/migrate/20250516100024_add_organization_id_to_integration_mappings.rb b/db/migrate/20250516100024_add_organization_id_to_integration_mappings.rb new file mode 100644 index 0000000..5a55abb --- /dev/null +++ b/db/migrate/20250516100024_add_organization_id_to_integration_mappings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToIntegrationMappings < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :integration_mappings, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250516100025_add_organization_id_fk_to_integration_mappings.rb b/db/migrate/20250516100025_add_organization_id_fk_to_integration_mappings.rb new file mode 100644 index 0000000..fb4e638 --- /dev/null +++ b/db/migrate/20250516100025_add_organization_id_fk_to_integration_mappings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToIntegrationMappings < ActiveRecord::Migration[7.2] + def change + add_foreign_key :integration_mappings, :organizations, validate: false + end +end diff --git a/db/migrate/20250516100026_validate_integration_mappings_organizations_foreign_key.rb b/db/migrate/20250516100026_validate_integration_mappings_organizations_foreign_key.rb new file mode 100644 index 0000000..850bfe0 --- /dev/null +++ b/db/migrate/20250516100026_validate_integration_mappings_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateIntegrationMappingsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :integration_mappings, :organizations + end +end diff --git a/db/migrate/20250516115755_add_organization_id_to_charge_filter_values.rb b/db/migrate/20250516115755_add_organization_id_to_charge_filter_values.rb new file mode 100644 index 0000000..5419c5b --- /dev/null +++ b/db/migrate/20250516115755_add_organization_id_to_charge_filter_values.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToChargeFilterValues < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :charge_filter_values, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250516115756_add_organization_id_fk_to_charge_filter_values.rb b/db/migrate/20250516115756_add_organization_id_fk_to_charge_filter_values.rb new file mode 100644 index 0000000..d286b53 --- /dev/null +++ b/db/migrate/20250516115756_add_organization_id_fk_to_charge_filter_values.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToChargeFilterValues < ActiveRecord::Migration[7.2] + def change + add_foreign_key :charge_filter_values, :organizations, validate: false + end +end diff --git a/db/migrate/20250516115757_validate_charge_filter_values_organizations_foreign_key.rb b/db/migrate/20250516115757_validate_charge_filter_values_organizations_foreign_key.rb new file mode 100644 index 0000000..d794001 --- /dev/null +++ b/db/migrate/20250516115757_validate_charge_filter_values_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateChargeFilterValuesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :charge_filter_values, :organizations + end +end diff --git a/db/migrate/20250517100023_create_pricing_units.rb b/db/migrate/20250517100023_create_pricing_units.rb new file mode 100644 index 0000000..4c1166d --- /dev/null +++ b/db/migrate/20250517100023_create_pricing_units.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreatePricingUnits < ActiveRecord::Migration[7.2] + def change + create_table :pricing_units, id: :uuid do |t| + t.string :name, null: false + t.string :code, null: false + t.string :short_name, null: false + t.text :description + + t.references :organization, null: false, foreign_key: true, type: :uuid + + t.timestamps + end + + add_index :pricing_units, [:code, :organization_id], unique: true + end +end diff --git a/db/migrate/20250519084647_add_organization_id_to_recurring_transaction_rules.rb b/db/migrate/20250519084647_add_organization_id_to_recurring_transaction_rules.rb new file mode 100644 index 0000000..a4c6b63 --- /dev/null +++ b/db/migrate/20250519084647_add_organization_id_to_recurring_transaction_rules.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToRecurringTransactionRules < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :recurring_transaction_rules, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250519084648_add_organization_id_fk_to_recurring_transaction_rules.rb b/db/migrate/20250519084648_add_organization_id_fk_to_recurring_transaction_rules.rb new file mode 100644 index 0000000..bfc37a7 --- /dev/null +++ b/db/migrate/20250519084648_add_organization_id_fk_to_recurring_transaction_rules.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToRecurringTransactionRules < ActiveRecord::Migration[7.2] + def change + add_foreign_key :recurring_transaction_rules, :organizations, validate: false + end +end diff --git a/db/migrate/20250519084649_validate_recurring_transaction_rules_organizations_foreign_key.rb b/db/migrate/20250519084649_validate_recurring_transaction_rules_organizations_foreign_key.rb new file mode 100644 index 0000000..e3d49b8 --- /dev/null +++ b/db/migrate/20250519084649_validate_recurring_transaction_rules_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateRecurringTransactionRulesOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :recurring_transaction_rules, :organizations + end +end diff --git a/db/migrate/20250519085909_add_organization_id_to_integration_collection_mappings.rb b/db/migrate/20250519085909_add_organization_id_to_integration_collection_mappings.rb new file mode 100644 index 0000000..c80c6f5 --- /dev/null +++ b/db/migrate/20250519085909_add_organization_id_to_integration_collection_mappings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToIntegrationCollectionMappings < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :integration_collection_mappings, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250519085910_add_organization_id_fk_to_integration_collection_mappings.rb b/db/migrate/20250519085910_add_organization_id_fk_to_integration_collection_mappings.rb new file mode 100644 index 0000000..3dae124 --- /dev/null +++ b/db/migrate/20250519085910_add_organization_id_fk_to_integration_collection_mappings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToIntegrationCollectionMappings < ActiveRecord::Migration[7.2] + def change + add_foreign_key :integration_collection_mappings, :organizations, validate: false + end +end diff --git a/db/migrate/20250519085911_validate_integration_collection_mappings_organizations_foreign_key.rb b/db/migrate/20250519085911_validate_integration_collection_mappings_organizations_foreign_key.rb new file mode 100644 index 0000000..e34e3d1 --- /dev/null +++ b/db/migrate/20250519085911_validate_integration_collection_mappings_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateIntegrationCollectionMappingsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :integration_collection_mappings, :organizations + end +end diff --git a/db/migrate/20250519092051_add_organization_id_to_refunds.rb b/db/migrate/20250519092051_add_organization_id_to_refunds.rb new file mode 100644 index 0000000..2c867dd --- /dev/null +++ b/db/migrate/20250519092051_add_organization_id_to_refunds.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdToRefunds < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :refunds, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250519092052_add_organization_id_fk_to_refunds.rb b/db/migrate/20250519092052_add_organization_id_fk_to_refunds.rb new file mode 100644 index 0000000..08bf9c4 --- /dev/null +++ b/db/migrate/20250519092052_add_organization_id_fk_to_refunds.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkToRefunds < ActiveRecord::Migration[7.2] + def change + add_foreign_key :refunds, :organizations, validate: false + end +end diff --git a/db/migrate/20250519092053_validate_refunds_organizations_foreign_key.rb b/db/migrate/20250519092053_validate_refunds_organizations_foreign_key.rb new file mode 100644 index 0000000..e93ae87 --- /dev/null +++ b/db/migrate/20250519092053_validate_refunds_organizations_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateRefundsOrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :refunds, :organizations + end +end diff --git a/db/migrate/20250520080000_create_usage_monitoring_alerts.rb b/db/migrate/20250520080000_create_usage_monitoring_alerts.rb new file mode 100644 index 0000000..02b90cb --- /dev/null +++ b/db/migrate/20250520080000_create_usage_monitoring_alerts.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +class CreateUsageMonitoringAlerts < ActiveRecord::Migration[7.2] + def change + create_enum :usage_monitoring_alert_types, %w[usage_amount billable_metric_usage_amount] + + create_table :usage_monitoring_alerts, id: :uuid do |t| + t.references :organization, type: :uuid, foreign_key: true, null: false, index: true + t.string :subscription_external_id, null: false, index: true + t.references :billable_metric, type: :uuid, foreign_key: true, null: true, index: true + t.enum :alert_type, enum_type: "usage_monitoring_alert_types", null: false + t.numeric :previous_value, precision: 30, scale: 5, null: false, default: 0 + t.datetime :last_processed_at + t.string :name + t.string :code, null: false + t.datetime :deleted_at + t.timestamps + + t.index %w[subscription_external_id organization_id alert_type], + unique: true, + name: "idx_alerts_unique_per_type_per_subscription", + where: "(billable_metric_id IS NULL AND deleted_at IS NULL)" + t.index %w[subscription_external_id organization_id alert_type billable_metric_id], + unique: true, + name: "idx_alerts_unique_per_type_per_subscription_with_bm", + where: "(billable_metric_id IS NOT NULL AND deleted_at IS NULL)" + t.index %w[code subscription_external_id organization_id], + unique: true, + name: "idx_alerts_code_unique_per_subscription", + where: "(deleted_at IS NULL)" + end + + create_table :usage_monitoring_alert_thresholds, id: :uuid do |t| + t.references :organization, type: :uuid, foreign_key: true, null: false, index: true + t.references :usage_monitoring_alert, type: :uuid, foreign_key: true, null: false, index: true + t.numeric :value, precision: 30, scale: 5, null: false + t.string :code + t.boolean :recurring, null: false, default: false + t.timestamps + + t.index %w[usage_monitoring_alert_id recurring], unique: true, where: "recurring is true" + end + + create_table :usage_monitoring_triggered_alerts, id: :uuid do |t| + t.references :organization, type: :uuid, foreign_key: true, null: false, index: true + t.references :usage_monitoring_alert, type: :uuid, foreign_key: true, null: false, index: true + t.references :subscription, type: :uuid, foreign_key: true, null: false, index: true + + t.numeric :current_value, precision: 30, scale: 5, null: false + t.numeric :previous_value, precision: 30, scale: 5, null: false + t.jsonb :crossed_thresholds, default: {} + + t.datetime :triggered_at, null: false + t.timestamps + end + end +end diff --git a/db/migrate/20250520143628_update_exports_views_with_organization_id.rb b/db/migrate/20250520143628_update_exports_views_with_organization_id.rb new file mode 100644 index 0000000..601b8dd --- /dev/null +++ b/db/migrate/20250520143628_update_exports_views_with_organization_id.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class UpdateExportsViewsWithOrganizationId < ActiveRecord::Migration[8.0] + def change + update_view :exports_applied_coupons, version: 2, revert_to_version: 1 + update_view :exports_charges, version: 2, revert_to_version: 1 + update_view :exports_credit_notes_taxes, version: 2, revert_to_version: 1 + update_view :exports_credit_notes, version: 2, revert_to_version: 1 + update_view :exports_fees_taxes, version: 2, revert_to_version: 1 + update_view :exports_invoices_taxes, version: 2, revert_to_version: 1 + update_view :exports_subscriptions, version: 2, revert_to_version: 1 + update_view :exports_wallet_transactions, version: 2, revert_to_version: 1 + update_view :exports_wallets, version: 2, revert_to_version: 1 + end +end diff --git a/db/migrate/20250520155108_add_billable_metric_usage_units_alert_type_to_enum.rb b/db/migrate/20250520155108_add_billable_metric_usage_units_alert_type_to_enum.rb new file mode 100644 index 0000000..956c816 --- /dev/null +++ b/db/migrate/20250520155108_add_billable_metric_usage_units_alert_type_to_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddBillableMetricUsageUnitsAlertTypeToEnum < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + add_enum_value :usage_monitoring_alert_types, "billable_metric_usage_units", if_not_exists: true + end + end + + def down + end +end diff --git a/db/migrate/20250520170402_add_lifetime_usage_amount_alert_type_to_enum.rb b/db/migrate/20250520170402_add_lifetime_usage_amount_alert_type_to_enum.rb new file mode 100644 index 0000000..3b41a34 --- /dev/null +++ b/db/migrate/20250520170402_add_lifetime_usage_amount_alert_type_to_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddLifetimeUsageAmountAlertTypeToEnum < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + add_enum_value :usage_monitoring_alert_types, "lifetime_usage_amount", if_not_exists: true + end + end + + def down + end +end diff --git a/db/migrate/20250521095733_create_customers_invoice_custom_sections.rb b/db/migrate/20250521095733_create_customers_invoice_custom_sections.rb new file mode 100644 index 0000000..89f1f31 --- /dev/null +++ b/db/migrate/20250521095733_create_customers_invoice_custom_sections.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateCustomersInvoiceCustomSections < ActiveRecord::Migration[8.0] + def change + create_table :customers_invoice_custom_sections, id: :uuid do |t| + t.belongs_to :organization, null: false, foreign_key: true, type: :uuid + t.belongs_to :billing_entity, null: false, foreign_key: true, type: :uuid + t.belongs_to :customer, null: false, foreign_key: true, type: :uuid + t.belongs_to :invoice_custom_section, null: false, foreign_key: true, type: :uuid + + t.timestamps + + t.index %i[billing_entity_id customer_id invoice_custom_section_id], + unique: true + end + end +end diff --git a/db/migrate/20250521104239_create_billing_entities_invoice_custom_sections.rb b/db/migrate/20250521104239_create_billing_entities_invoice_custom_sections.rb new file mode 100644 index 0000000..a43cb3a --- /dev/null +++ b/db/migrate/20250521104239_create_billing_entities_invoice_custom_sections.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateBillingEntitiesInvoiceCustomSections < ActiveRecord::Migration[8.0] + def change + create_table :billing_entities_invoice_custom_sections, id: :uuid do |t| + t.belongs_to :organization, null: false, foreign_key: true, type: :uuid + t.belongs_to :billing_entity, null: false, foreign_key: true, type: :uuid + t.belongs_to :invoice_custom_section, null: false, foreign_key: true, type: :uuid + + t.timestamps + + t.index [:billing_entity_id, :invoice_custom_section_id], unique: true + end + end +end diff --git a/db/migrate/20250521135607_create_customer_applied_invoice_custom_section_records.rb b/db/migrate/20250521135607_create_customer_applied_invoice_custom_section_records.rb new file mode 100644 index 0000000..06d3263 --- /dev/null +++ b/db/migrate/20250521135607_create_customer_applied_invoice_custom_section_records.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +class CreateCustomerAppliedInvoiceCustomSectionRecords < ActiveRecord::Migration[8.0] + class InvoiceCustomSectionSelection < ApplicationRecord; end + + def up + Customer::AppliedInvoiceCustomSection.insert_all( # rubocop:disable Rails/SkipsModelValidations + InvoiceCustomSectionSelection + .joins("LEFT JOIN customers ON customers.id = invoice_custom_section_selections.customer_id") + .where("invoice_custom_section_selections.customer_id IS NOT NULL AND customers.id IS NOT NULL AND customers.deleted_at IS NULL") + .select( + "invoice_custom_section_selections.id", + "invoice_custom_section_selections.customer_id", + "invoice_custom_section_selections.invoice_custom_section_id", + "invoice_custom_section_selections.created_at", + "invoice_custom_section_selections.updated_at", + "customers.organization_id", + "customers.billing_entity_id" + ) + .map do |selection| + { + id: selection.id, + organization_id: selection.organization_id, + billing_entity_id: selection.billing_entity_id, + customer_id: selection.customer_id, + invoice_custom_section_id: selection.invoice_custom_section_id, + created_at: selection.created_at, + updated_at: selection.updated_at + } + end + ) + end + + def down + Customer::AppliedInvoiceCustomSection.delete_all + end +end diff --git a/db/migrate/20250521151540_create_billing_entity_invoice_custom_sections_records.rb b/db/migrate/20250521151540_create_billing_entity_invoice_custom_sections_records.rb new file mode 100644 index 0000000..95a431f --- /dev/null +++ b/db/migrate/20250521151540_create_billing_entity_invoice_custom_sections_records.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class CreateBillingEntityInvoiceCustomSectionsRecords < ActiveRecord::Migration[8.0] + class InvoiceCustomSectionSelection < ApplicationRecord + belongs_to :organization, optional: true + end + + class Organization < ApplicationRecord + has_one :default_billing_entity, -> { active.order(created_at: :asc) }, class_name: "BillingEntity" + end + + class BillingEntity < ApplicationRecord + scope :active, -> { where(archived_at: nil).order(created_at: :asc) } + end + + class BillingEntity::AppliedInvoiceCustomSection < ApplicationRecord + self.table_name = "billing_entities_invoice_custom_sections" + end + + def up + BillingEntity::AppliedInvoiceCustomSection.insert_all( # rubocop:disable Rails/SkipsModelValidations + InvoiceCustomSectionSelection + .where.not(organization_id: nil) + .includes(organization: :default_billing_entity) + .map do |selection| + { + id: selection.id, + organization_id: selection.organization_id, + billing_entity_id: selection.organization.default_billing_entity.id, + invoice_custom_section_id: selection.invoice_custom_section_id, + created_at: selection.created_at, + updated_at: selection.updated_at + } + end + ) + end + + def down + BillingEntity::AppliedInvoiceCustomSection.delete_all + end +end diff --git a/db/migrate/20250522134155_create_exports_daily_usages.rb b/db/migrate/20250522134155_create_exports_daily_usages.rb new file mode 100644 index 0000000..b5cfe68 --- /dev/null +++ b/db/migrate/20250522134155_create_exports_daily_usages.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsDailyUsages < ActiveRecord::Migration[8.0] + def change + create_view :exports_daily_usages + end +end diff --git a/db/migrate/20250526111147_add_allowed_fee_types_to_wallets.rb b/db/migrate/20250526111147_add_allowed_fee_types_to_wallets.rb new file mode 100644 index 0000000..6e94995 --- /dev/null +++ b/db/migrate/20250526111147_add_allowed_fee_types_to_wallets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAllowedFeeTypesToWallets < ActiveRecord::Migration[8.0] + def change + add_column :wallets, :allowed_fee_types, :string, array: true, null: false, default: [] + end +end diff --git a/db/migrate/20250526130953_populate_billing_entity_applied_dunning_campaign.rb b/db/migrate/20250526130953_populate_billing_entity_applied_dunning_campaign.rb new file mode 100644 index 0000000..22c08a7 --- /dev/null +++ b/db/migrate/20250526130953_populate_billing_entity_applied_dunning_campaign.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class PopulateBillingEntityAppliedDunningCampaign < ActiveRecord::Migration[8.0] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def up + # rubocop:disable Rails/SkipsModelValidations + DunningCampaign.where(applied_to_organization: true).find_each do |dunning_campaign| + BillingEntity.where(id: dunning_campaign.organization_id).update_all(applied_dunning_campaign_id: dunning_campaign.id) + end + # rubocop:enable Rails/SkipsModelValidations + end + + def down + end +end diff --git a/db/migrate/20250526133152_add_foreign_key_on_billing_entities_applied_dunning_campaign_id.rb b/db/migrate/20250526133152_add_foreign_key_on_billing_entities_applied_dunning_campaign_id.rb new file mode 100644 index 0000000..4a9b7c4 --- /dev/null +++ b/db/migrate/20250526133152_add_foreign_key_on_billing_entities_applied_dunning_campaign_id.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddForeignKeyOnBillingEntitiesAppliedDunningCampaignId < ActiveRecord::Migration[8.0] + def change + add_foreign_key :billing_entities, :dunning_campaigns, column: :applied_dunning_campaign_id, on_delete: :nullify, validate: false + end +end diff --git a/db/migrate/20250526133654_drop_clickhouse_aggregation_from_organizations.rb b/db/migrate/20250526133654_drop_clickhouse_aggregation_from_organizations.rb new file mode 100644 index 0000000..6126b18 --- /dev/null +++ b/db/migrate/20250526133654_drop_clickhouse_aggregation_from_organizations.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropClickhouseAggregationFromOrganizations < ActiveRecord::Migration[8.0] + def change + safety_assured do + remove_column :organizations, :clickhouse_aggregation, :boolean, default: false, null: false + end + end +end diff --git a/db/migrate/20250526134136_validate_added_foreign_key_on_applied_dunning_campaign_at_billing_entities.rb b/db/migrate/20250526134136_validate_added_foreign_key_on_applied_dunning_campaign_at_billing_entities.rb new file mode 100644 index 0000000..5d4616e --- /dev/null +++ b/db/migrate/20250526134136_validate_added_foreign_key_on_applied_dunning_campaign_at_billing_entities.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateAddedForeignKeyOnAppliedDunningCampaignAtBillingEntities < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :billing_entities, :dunning_campaigns, column: :applied_dunning_campaign_id + end +end diff --git a/db/migrate/20250528133222_drop_invoice_custom_section_selections.rb b/db/migrate/20250528133222_drop_invoice_custom_section_selections.rb new file mode 100644 index 0000000..7d325a8 --- /dev/null +++ b/db/migrate/20250528133222_drop_invoice_custom_section_selections.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class DropInvoiceCustomSectionSelections < ActiveRecord::Migration[8.0] + def up + drop_table :invoice_custom_section_selections + end +end diff --git a/db/migrate/20250530112903_add_precise_credit_notes_amount_cents_to_fees.rb b/db/migrate/20250530112903_add_precise_credit_notes_amount_cents_to_fees.rb new file mode 100644 index 0000000..86e7348 --- /dev/null +++ b/db/migrate/20250530112903_add_precise_credit_notes_amount_cents_to_fees.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPreciseCreditNotesAmountCentsToFees < ActiveRecord::Migration[8.0] + def change + add_column :fees, :precise_credit_notes_amount_cents, :decimal, precision: 30, scale: 5, null: false, default: 0 + end +end diff --git a/db/migrate/20250602075710_replace_alert_type_enum.rb b/db/migrate/20250602075710_replace_alert_type_enum.rb new file mode 100644 index 0000000..1706b19 --- /dev/null +++ b/db/migrate/20250602075710_replace_alert_type_enum.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ReplaceAlertTypeEnum < ActiveRecord::Migration[8.0] + def change + rename_enum_value :usage_monitoring_alert_types, from: "usage_amount", to: "current_usage_amount" + rename_enum_value :usage_monitoring_alert_types, from: "billable_metric_usage_amount", to: "billable_metric_current_usage_amount" + rename_enum_value :usage_monitoring_alert_types, from: "billable_metric_usage_units", to: "billable_metric_current_usage_units" + end +end diff --git a/db/migrate/20250602145535_create_flat_filters.rb b/db/migrate/20250602145535_create_flat_filters.rb new file mode 100644 index 0000000..e3d2221 --- /dev/null +++ b/db/migrate/20250602145535_create_flat_filters.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateFlatFilters < ActiveRecord::Migration[8.0] + def change + create_view :flat_filters + end +end diff --git a/db/migrate/20250609121102_create_applied_pricing_units.rb b/db/migrate/20250609121102_create_applied_pricing_units.rb new file mode 100644 index 0000000..72db720 --- /dev/null +++ b/db/migrate/20250609121102_create_applied_pricing_units.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateAppliedPricingUnits < ActiveRecord::Migration[8.0] + def change + create_table :applied_pricing_units, id: :uuid do |t| + t.references :pricing_unit, null: false, foreign_key: true, type: :uuid + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :pricing_unitable, null: false, type: :uuid, polymorphic: true + + t.decimal :conversion_rate, precision: 40, scale: 15, default: "0.0", null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20250610063400_create_pricing_unit_usages.rb b/db/migrate/20250610063400_create_pricing_unit_usages.rb new file mode 100644 index 0000000..8c8003e --- /dev/null +++ b/db/migrate/20250610063400_create_pricing_unit_usages.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class CreatePricingUnitUsages < ActiveRecord::Migration[8.0] + def change + create_table :pricing_unit_usages, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :fee, null: false, foreign_key: true, type: :uuid + t.references :pricing_unit, null: false, foreign_key: true, type: :uuid + + t.string :short_name, null: false + t.bigint :amount_cents, null: false + t.decimal :precise_amount_cents, precision: 40, scale: 15, default: 0.0, null: false + t.bigint :unit_amount_cents, default: 0, null: false + t.decimal :conversion_rate, precision: 40, scale: 15, default: 0.0, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20250610173034_add_voided_invoice_id_to_invoices.rb b/db/migrate/20250610173034_add_voided_invoice_id_to_invoices.rb new file mode 100644 index 0000000..688e6b2 --- /dev/null +++ b/db/migrate/20250610173034_add_voided_invoice_id_to_invoices.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddVoidedInvoiceIdToInvoices < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :invoices, :voided_invoice, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20250611072251_add_last_ongoing_balance_sync_at_to_wallets.rb b/db/migrate/20250611072251_add_last_ongoing_balance_sync_at_to_wallets.rb new file mode 100644 index 0000000..a19cb39 --- /dev/null +++ b/db/migrate/20250611072251_add_last_ongoing_balance_sync_at_to_wallets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddLastOngoingBalanceSyncAtToWallets < ActiveRecord::Migration[8.0] + def change + add_column :wallets, :last_ongoing_balance_sync_at, :timestamp, null: true, default: nil + end +end diff --git a/db/migrate/20250611083925_fix_stale_billing_entity_sequential_id_to_be_uniq.rb b/db/migrate/20250611083925_fix_stale_billing_entity_sequential_id_to_be_uniq.rb new file mode 100644 index 0000000..2096a8a --- /dev/null +++ b/db/migrate/20250611083925_fix_stale_billing_entity_sequential_id_to_be_uniq.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +class FixStaleBillingEntitySequentialIdToBeUniq < ActiveRecord::Migration[8.0] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + class Invoice < ApplicationRecord + scope :non_self_billed, -> { where.not(self_billed: true) } + scope :with_generated_number, -> { where(status: %w[finalized voided]) } + end + + def up + # BillingEntities::ChangeInvoiceNumberingService -- if we switch from per_customer + # to per_billing_entity, we'll recalculate the billing_entity_sequential_id + BillingEntity.where(document_numbering: "per_customer").find_each do |billing_entity| + # find invoices with duplicated billing_entity_sequential_id + duplicates = Invoice.where(billing_entity_id: billing_entity.id) + .non_self_billed.with_generated_number + .where.not(billing_entity_sequential_id: nil) + .group(:billing_entity_sequential_id) + .having("COUNT(*) > 1") + .pluck(:billing_entity_sequential_id) + next if duplicates.empty? + + # update the billing_entity_sequential_id to NULL for the duplicated invoices + # rubocop:disable Rails/SkipsModelValidations + Invoice.where(billing_entity_id: billing_entity.id) + .non_self_billed.with_generated_number + .where(billing_entity_sequential_id: duplicates) + .update_all("billing_entity_sequential_id = NULL") + # rubocop:enable Rails/SkipsModelValidations + end + + BillingEntity.where(document_numbering: "per_billing_entity").find_each do |billing_entity| + # group invoices by billing_entity_sequential_id and find groups with more than 1 invoice + duplicates = Invoice.where(billing_entity_id: billing_entity.id) + .non_self_billed.with_generated_number + .where.not(billing_entity_sequential_id: nil) + .group(:billing_entity_sequential_id) + .having("COUNT(*) > 1") + .pluck(:billing_entity_sequential_id) + next if duplicates.empty? + + invoices_count = Invoice.where(billing_entity_id: billing_entity.id, billing_entity_sequential_id: duplicates).count + latest_invoice = Invoice.where(billing_entity_id: billing_entity.id, billing_entity_sequential_id: duplicates).order(:created_at).last + Rails.logger.info "Found #{duplicates.count} duplicates for billing_entity: #{billing_entity.name}; Affected invoices: #{invoices_count}; Latest invoice: (#{latest_invoice.created_at})" + + # find the highest billing_entity_sequential_id for the billing_entity + existing_max_number = Invoice.where(billing_entity_id: billing_entity.id) + .non_self_billed.with_generated_number + .maximum(:billing_entity_sequential_id) + + if duplicates.max >= existing_max_number + Rails.logger.warn("-" * 80) + Rails.logger.warn "billing_entity: #{billing_entity.name}" + Rails.logger.warn "WARNING: DUPLICATED LATEST BILLING_ENTITY_SEQUENTIAL_ID: #{duplicates.max} >= #{existing_max_number}" + Rails.logger.warn("-" * 80) + next + end + + # for each duplicate, set the billing_entity_sequential_id to NULL + # rubocop:disable Rails/SkipsModelValidations + Invoice.where(billing_entity_id: billing_entity.id, billing_entity_sequential_id: duplicates) + .update_all("billing_entity_sequential_id = NULL") + # rubocop:enable Rails/SkipsModelValidations + end + end + + def down + # No down migration needed + end +end diff --git a/db/migrate/20250619143820_migrate_charge_grouped_by.rb b/db/migrate/20250619143820_migrate_charge_grouped_by.rb new file mode 100644 index 0000000..fc796ea --- /dev/null +++ b/db/migrate/20250619143820_migrate_charge_grouped_by.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class MigrateChargeGroupedBy < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + execute <<-SQL + UPDATE charges + SET properties = properties - 'grouped_by' || jsonb_build_object('pricing_group_keys', properties->'grouped_by') + WHERE properties->'grouped_by' IS NOT NULL + AND properties->'pricing_group_keys' IS NULL; + SQL + end + end + + def down + end +end diff --git a/db/migrate/20250619144939_migrate_charge_filter_grouped_by.rb b/db/migrate/20250619144939_migrate_charge_filter_grouped_by.rb new file mode 100644 index 0000000..df7af99 --- /dev/null +++ b/db/migrate/20250619144939_migrate_charge_filter_grouped_by.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class MigrateChargeFilterGroupedBy < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + execute <<-SQL + UPDATE charge_filters + SET properties = properties - 'grouped_by' || jsonb_build_object('pricing_group_keys', properties->'grouped_by') + WHERE properties->'grouped_by' IS NOT NULL + AND properties->'pricing_group_keys' IS NULL; + SQL + end + end + + def down + end +end diff --git a/db/migrate/20250626175249_refresh_stripe_webhooks.rb b/db/migrate/20250626175249_refresh_stripe_webhooks.rb new file mode 100644 index 0000000..a28c635 --- /dev/null +++ b/db/migrate/20250626175249_refresh_stripe_webhooks.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class RefreshStripeWebhooks < ActiveRecord::Migration[7.1] + # Stripe calls are external; avoid wrapping in a DB transaction. + disable_ddl_transaction! + + def up + PaymentProviders::StripeProvider.find_each do |stripe_provider| + next unless stripe_provider.secret_key + + PaymentProviders::Stripe::RefreshWebhookJob.perform_later(stripe_provider) + end + end +end diff --git a/db/migrate/20250627084430_organization_id_check_constaint_on_applied_usage_thresholds.rb b/db/migrate/20250627084430_organization_id_check_constaint_on_applied_usage_thresholds.rb new file mode 100644 index 0000000..5572bbd --- /dev/null +++ b/db/migrate/20250627084430_organization_id_check_constaint_on_applied_usage_thresholds.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnAppliedUsageThresholds < ActiveRecord::Migration[8.0] + def change + add_check_constraint :applied_usage_thresholds, + "organization_id IS NOT NULL", + name: "applied_usage_thresholds_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627084852_not_null_organization_id_on_applied_usage_thresholds.rb b/db/migrate/20250627084852_not_null_organization_id_on_applied_usage_thresholds.rb new file mode 100644 index 0000000..9c2a708 --- /dev/null +++ b/db/migrate/20250627084852_not_null_organization_id_on_applied_usage_thresholds.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnAppliedUsageThresholds < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :applied_usage_thresholds, name: "applied_usage_thresholds_organization_id_null" + change_column_null :applied_usage_thresholds, :organization_id, false + remove_check_constraint :applied_usage_thresholds, name: "applied_usage_thresholds_organization_id_null" + end + + def down + add_check_constraint :applied_usage_thresholds, "organization_id IS NOT NULL", name: "applied_usage_thresholds_organization_id_null", validate: false + change_column_null :applied_usage_thresholds, :organization_id, true + end +end diff --git a/db/migrate/20250627091010_organization_id_check_constaint_on_billable_metric_filters.rb b/db/migrate/20250627091010_organization_id_check_constaint_on_billable_metric_filters.rb new file mode 100644 index 0000000..4840ee0 --- /dev/null +++ b/db/migrate/20250627091010_organization_id_check_constaint_on_billable_metric_filters.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnBillableMetricFilters < ActiveRecord::Migration[8.0] + def change + add_check_constraint :billable_metric_filters, + "organization_id IS NOT NULL", + name: "billable_metric_filters_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627091011_not_null_organization_id_on_billable_metric_filters.rb b/db/migrate/20250627091011_not_null_organization_id_on_billable_metric_filters.rb new file mode 100644 index 0000000..1464a56 --- /dev/null +++ b/db/migrate/20250627091011_not_null_organization_id_on_billable_metric_filters.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnBillableMetricFilters < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :billable_metric_filters, name: "billable_metric_filters_organization_id_null" + change_column_null :billable_metric_filters, :organization_id, false + remove_check_constraint :billable_metric_filters, name: "billable_metric_filters_organization_id_null" + end + + def down + add_check_constraint :billable_metric_filters, "organization_id IS NOT NULL", name: "billable_metric_filters_organization_id_null", validate: false + change_column_null :billable_metric_filters, :organization_id, true + end +end diff --git a/db/migrate/20250627091212_organization_id_check_constaint_on_billing_entities_taxes.rb b/db/migrate/20250627091212_organization_id_check_constaint_on_billing_entities_taxes.rb new file mode 100644 index 0000000..d805f1d --- /dev/null +++ b/db/migrate/20250627091212_organization_id_check_constaint_on_billing_entities_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnBillingEntitiesTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :billing_entities_taxes, + "organization_id IS NOT NULL", + name: "billing_entities_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627091213_not_null_organization_id_on_billing_entities_taxes.rb b/db/migrate/20250627091213_not_null_organization_id_on_billing_entities_taxes.rb new file mode 100644 index 0000000..0485ebd --- /dev/null +++ b/db/migrate/20250627091213_not_null_organization_id_on_billing_entities_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnBillingEntitiesTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :billing_entities_taxes, name: "billing_entities_taxes_organization_id_null" + change_column_null :billing_entities_taxes, :organization_id, false + remove_check_constraint :billing_entities_taxes, name: "billing_entities_taxes_organization_id_null" + end + + def down + add_check_constraint :billing_entities_taxes, "organization_id IS NOT NULL", name: "billing_entities_taxes_organization_id_null", validate: false + change_column_null :billing_entities_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250627123958_organization_id_check_constaint_on_charge_filter_values.rb b/db/migrate/20250627123958_organization_id_check_constaint_on_charge_filter_values.rb new file mode 100644 index 0000000..6fbb3fa --- /dev/null +++ b/db/migrate/20250627123958_organization_id_check_constaint_on_charge_filter_values.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnChargeFilterValues < ActiveRecord::Migration[8.0] + def change + add_check_constraint :charge_filter_values, + "organization_id IS NOT NULL", + name: "charge_filter_values_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627123959_not_null_organization_id_on_charge_filter_values.rb b/db/migrate/20250627123959_not_null_organization_id_on_charge_filter_values.rb new file mode 100644 index 0000000..56c9993 --- /dev/null +++ b/db/migrate/20250627123959_not_null_organization_id_on_charge_filter_values.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnChargeFilterValues < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :charge_filter_values, name: "charge_filter_values_organization_id_null" + change_column_null :charge_filter_values, :organization_id, false + remove_check_constraint :charge_filter_values, name: "charge_filter_values_organization_id_null" + end + + def down + add_check_constraint :charge_filter_values, "organization_id IS NOT NULL", name: "charge_filter_values_organization_id_null", validate: false + change_column_null :charge_filter_values, :organization_id, true + end +end diff --git a/db/migrate/20250627124007_organization_id_check_constaint_on_charge_filters.rb b/db/migrate/20250627124007_organization_id_check_constaint_on_charge_filters.rb new file mode 100644 index 0000000..0ec7493 --- /dev/null +++ b/db/migrate/20250627124007_organization_id_check_constaint_on_charge_filters.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnChargeFilters < ActiveRecord::Migration[8.0] + def change + add_check_constraint :charge_filters, + "organization_id IS NOT NULL", + name: "charge_filters_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124008_not_null_organization_id_on_charge_filters.rb b/db/migrate/20250627124008_not_null_organization_id_on_charge_filters.rb new file mode 100644 index 0000000..3cebd1c --- /dev/null +++ b/db/migrate/20250627124008_not_null_organization_id_on_charge_filters.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnChargeFilters < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :charge_filters, name: "charge_filters_organization_id_null" + change_column_null :charge_filters, :organization_id, false + remove_check_constraint :charge_filters, name: "charge_filters_organization_id_null" + end + + def down + add_check_constraint :charge_filters, "organization_id IS NOT NULL", name: "charge_filters_organization_id_null", validate: false + change_column_null :charge_filters, :organization_id, true + end +end diff --git a/db/migrate/20250627124016_organization_id_check_constaint_on_charges.rb b/db/migrate/20250627124016_organization_id_check_constaint_on_charges.rb new file mode 100644 index 0000000..6c54ff8 --- /dev/null +++ b/db/migrate/20250627124016_organization_id_check_constaint_on_charges.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCharges < ActiveRecord::Migration[8.0] + def change + add_check_constraint :charges, + "organization_id IS NOT NULL", + name: "charges_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124017_not_null_organization_id_on_charges.rb b/db/migrate/20250627124017_not_null_organization_id_on_charges.rb new file mode 100644 index 0000000..b723ee0 --- /dev/null +++ b/db/migrate/20250627124017_not_null_organization_id_on_charges.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCharges < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :charges, name: "charges_organization_id_null" + change_column_null :charges, :organization_id, false + remove_check_constraint :charges, name: "charges_organization_id_null" + end + + def down + add_check_constraint :charges, "organization_id IS NOT NULL", name: "charges_organization_id_null", validate: false + change_column_null :charges, :organization_id, true + end +end diff --git a/db/migrate/20250627124022_organization_id_check_constaint_on_charges_taxes.rb b/db/migrate/20250627124022_organization_id_check_constaint_on_charges_taxes.rb new file mode 100644 index 0000000..f34c75b --- /dev/null +++ b/db/migrate/20250627124022_organization_id_check_constaint_on_charges_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnChargesTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :charges_taxes, + "organization_id IS NOT NULL", + name: "charges_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124023_not_null_organization_id_on_charges_taxes.rb b/db/migrate/20250627124023_not_null_organization_id_on_charges_taxes.rb new file mode 100644 index 0000000..25d3d0e --- /dev/null +++ b/db/migrate/20250627124023_not_null_organization_id_on_charges_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnChargesTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :charges_taxes, name: "charges_taxes_organization_id_null" + change_column_null :charges_taxes, :organization_id, false + remove_check_constraint :charges_taxes, name: "charges_taxes_organization_id_null" + end + + def down + add_check_constraint :charges_taxes, "organization_id IS NOT NULL", name: "charges_taxes_organization_id_null", validate: false + change_column_null :charges_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250627124028_organization_id_check_constaint_on_commitments.rb b/db/migrate/20250627124028_organization_id_check_constaint_on_commitments.rb new file mode 100644 index 0000000..25db80d --- /dev/null +++ b/db/migrate/20250627124028_organization_id_check_constaint_on_commitments.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCommitments < ActiveRecord::Migration[8.0] + def change + add_check_constraint :commitments, + "organization_id IS NOT NULL", + name: "commitments_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124029_not_null_organization_id_on_commitments.rb b/db/migrate/20250627124029_not_null_organization_id_on_commitments.rb new file mode 100644 index 0000000..27b4641 --- /dev/null +++ b/db/migrate/20250627124029_not_null_organization_id_on_commitments.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCommitments < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :commitments, name: "commitments_organization_id_null" + change_column_null :commitments, :organization_id, false + remove_check_constraint :commitments, name: "commitments_organization_id_null" + end + + def down + add_check_constraint :commitments, "organization_id IS NOT NULL", name: "commitments_organization_id_null", validate: false + change_column_null :commitments, :organization_id, true + end +end diff --git a/db/migrate/20250627124033_organization_id_check_constaint_on_commitments_taxes.rb b/db/migrate/20250627124033_organization_id_check_constaint_on_commitments_taxes.rb new file mode 100644 index 0000000..d52871c --- /dev/null +++ b/db/migrate/20250627124033_organization_id_check_constaint_on_commitments_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCommitmentsTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :commitments_taxes, + "organization_id IS NOT NULL", + name: "commitments_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124034_not_null_organization_id_on_commitments_taxes.rb b/db/migrate/20250627124034_not_null_organization_id_on_commitments_taxes.rb new file mode 100644 index 0000000..ca4a47f --- /dev/null +++ b/db/migrate/20250627124034_not_null_organization_id_on_commitments_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCommitmentsTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :commitments_taxes, name: "commitments_taxes_organization_id_null" + change_column_null :commitments_taxes, :organization_id, false + remove_check_constraint :commitments_taxes, name: "commitments_taxes_organization_id_null" + end + + def down + add_check_constraint :commitments_taxes, "organization_id IS NOT NULL", name: "commitments_taxes_organization_id_null", validate: false + change_column_null :commitments_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250627124039_organization_id_check_constaint_on_coupon_targets.rb b/db/migrate/20250627124039_organization_id_check_constaint_on_coupon_targets.rb new file mode 100644 index 0000000..3a8b076 --- /dev/null +++ b/db/migrate/20250627124039_organization_id_check_constaint_on_coupon_targets.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCouponTargets < ActiveRecord::Migration[8.0] + def change + add_check_constraint :coupon_targets, + "organization_id IS NOT NULL", + name: "coupon_targets_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124040_not_null_organization_id_on_coupon_targets.rb b/db/migrate/20250627124040_not_null_organization_id_on_coupon_targets.rb new file mode 100644 index 0000000..263568c --- /dev/null +++ b/db/migrate/20250627124040_not_null_organization_id_on_coupon_targets.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCouponTargets < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :coupon_targets, name: "coupon_targets_organization_id_null" + change_column_null :coupon_targets, :organization_id, false + remove_check_constraint :coupon_targets, name: "coupon_targets_organization_id_null" + end + + def down + add_check_constraint :coupon_targets, "organization_id IS NOT NULL", name: "coupon_targets_organization_id_null", validate: false + change_column_null :coupon_targets, :organization_id, true + end +end diff --git a/db/migrate/20250627124048_organization_id_check_constaint_on_credit_note_items.rb b/db/migrate/20250627124048_organization_id_check_constaint_on_credit_note_items.rb new file mode 100644 index 0000000..96ac8c2 --- /dev/null +++ b/db/migrate/20250627124048_organization_id_check_constaint_on_credit_note_items.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCreditNoteItems < ActiveRecord::Migration[8.0] + def change + add_check_constraint :credit_note_items, + "organization_id IS NOT NULL", + name: "credit_note_items_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124049_not_null_organization_id_on_credit_note_items.rb b/db/migrate/20250627124049_not_null_organization_id_on_credit_note_items.rb new file mode 100644 index 0000000..2bdc33e --- /dev/null +++ b/db/migrate/20250627124049_not_null_organization_id_on_credit_note_items.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCreditNoteItems < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :credit_note_items, name: "credit_note_items_organization_id_null" + change_column_null :credit_note_items, :organization_id, false + remove_check_constraint :credit_note_items, name: "credit_note_items_organization_id_null" + end + + def down + add_check_constraint :credit_note_items, "organization_id IS NOT NULL", name: "credit_note_items_organization_id_null", validate: false + change_column_null :credit_note_items, :organization_id, true + end +end diff --git a/db/migrate/20250627124055_organization_id_check_constaint_on_credit_notes.rb b/db/migrate/20250627124055_organization_id_check_constaint_on_credit_notes.rb new file mode 100644 index 0000000..6ba0754 --- /dev/null +++ b/db/migrate/20250627124055_organization_id_check_constaint_on_credit_notes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCreditNotes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :credit_notes, + "organization_id IS NOT NULL", + name: "credit_notes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124056_not_null_organization_id_on_credit_notes.rb b/db/migrate/20250627124056_not_null_organization_id_on_credit_notes.rb new file mode 100644 index 0000000..2fd0b8f --- /dev/null +++ b/db/migrate/20250627124056_not_null_organization_id_on_credit_notes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCreditNotes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :credit_notes, name: "credit_notes_organization_id_null" + change_column_null :credit_notes, :organization_id, false + remove_check_constraint :credit_notes, name: "credit_notes_organization_id_null" + end + + def down + add_check_constraint :credit_notes, "organization_id IS NOT NULL", name: "credit_notes_organization_id_null", validate: false + change_column_null :credit_notes, :organization_id, true + end +end diff --git a/db/migrate/20250627124118_organization_id_check_constaint_on_credit_notes_taxes.rb b/db/migrate/20250627124118_organization_id_check_constaint_on_credit_notes_taxes.rb new file mode 100644 index 0000000..a6fbb45 --- /dev/null +++ b/db/migrate/20250627124118_organization_id_check_constaint_on_credit_notes_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCreditNotesTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :credit_notes_taxes, + "organization_id IS NOT NULL", + name: "credit_notes_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124119_not_null_organization_id_on_credit_notes_taxes.rb b/db/migrate/20250627124119_not_null_organization_id_on_credit_notes_taxes.rb new file mode 100644 index 0000000..410db98 --- /dev/null +++ b/db/migrate/20250627124119_not_null_organization_id_on_credit_notes_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCreditNotesTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :credit_notes_taxes, name: "credit_notes_taxes_organization_id_null" + change_column_null :credit_notes_taxes, :organization_id, false + remove_check_constraint :credit_notes_taxes, name: "credit_notes_taxes_organization_id_null" + end + + def down + add_check_constraint :credit_notes_taxes, "organization_id IS NOT NULL", name: "credit_notes_taxes_organization_id_null", validate: false + change_column_null :credit_notes_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250627124129_organization_id_check_constaint_on_credits.rb b/db/migrate/20250627124129_organization_id_check_constaint_on_credits.rb new file mode 100644 index 0000000..821d7d2 --- /dev/null +++ b/db/migrate/20250627124129_organization_id_check_constaint_on_credits.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCredits < ActiveRecord::Migration[8.0] + def change + add_check_constraint :credits, + "organization_id IS NOT NULL", + name: "credits_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124130_not_null_organization_id_on_credits.rb b/db/migrate/20250627124130_not_null_organization_id_on_credits.rb new file mode 100644 index 0000000..b3b1457 --- /dev/null +++ b/db/migrate/20250627124130_not_null_organization_id_on_credits.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCredits < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :credits, name: "credits_organization_id_null" + change_column_null :credits, :organization_id, false + remove_check_constraint :credits, name: "credits_organization_id_null" + end + + def down + add_check_constraint :credits, "organization_id IS NOT NULL", name: "credits_organization_id_null", validate: false + change_column_null :credits, :organization_id, true + end +end diff --git a/db/migrate/20250627124143_organization_id_check_constaint_on_customer_metadata.rb b/db/migrate/20250627124143_organization_id_check_constaint_on_customer_metadata.rb new file mode 100644 index 0000000..bba47cd --- /dev/null +++ b/db/migrate/20250627124143_organization_id_check_constaint_on_customer_metadata.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCustomerMetadata < ActiveRecord::Migration[8.0] + def change + add_check_constraint :customer_metadata, + "organization_id IS NOT NULL", + name: "customer_metadata_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124144_not_null_organization_id_on_customer_metadata.rb b/db/migrate/20250627124144_not_null_organization_id_on_customer_metadata.rb new file mode 100644 index 0000000..2b8211d --- /dev/null +++ b/db/migrate/20250627124144_not_null_organization_id_on_customer_metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCustomerMetadata < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :customer_metadata, name: "customer_metadata_organization_id_null" + change_column_null :customer_metadata, :organization_id, false + remove_check_constraint :customer_metadata, name: "customer_metadata_organization_id_null" + end + + def down + add_check_constraint :customer_metadata, "organization_id IS NOT NULL", name: "customer_metadata_organization_id_null", validate: false + change_column_null :customer_metadata, :organization_id, true + end +end diff --git a/db/migrate/20250627124152_organization_id_check_constaint_on_customers_taxes.rb b/db/migrate/20250627124152_organization_id_check_constaint_on_customers_taxes.rb new file mode 100644 index 0000000..9b8c616 --- /dev/null +++ b/db/migrate/20250627124152_organization_id_check_constaint_on_customers_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnCustomersTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :customers_taxes, + "organization_id IS NOT NULL", + name: "customers_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627124153_not_null_organization_id_on_customers_taxes.rb b/db/migrate/20250627124153_not_null_organization_id_on_customers_taxes.rb new file mode 100644 index 0000000..1ac0cf1 --- /dev/null +++ b/db/migrate/20250627124153_not_null_organization_id_on_customers_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnCustomersTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :customers_taxes, name: "customers_taxes_organization_id_null" + change_column_null :customers_taxes, :organization_id, false + remove_check_constraint :customers_taxes, name: "customers_taxes_organization_id_null" + end + + def down + add_check_constraint :customers_taxes, "organization_id IS NOT NULL", name: "customers_taxes_organization_id_null", validate: false + change_column_null :customers_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250627134915_organization_id_check_constaint_on_data_export_parts.rb b/db/migrate/20250627134915_organization_id_check_constaint_on_data_export_parts.rb new file mode 100644 index 0000000..ee69588 --- /dev/null +++ b/db/migrate/20250627134915_organization_id_check_constaint_on_data_export_parts.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnDataExportParts < ActiveRecord::Migration[8.0] + def change + add_check_constraint :data_export_parts, + "organization_id IS NOT NULL", + name: "data_export_parts_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627134916_not_null_organization_id_on_data_export_parts.rb b/db/migrate/20250627134916_not_null_organization_id_on_data_export_parts.rb new file mode 100644 index 0000000..59b659d --- /dev/null +++ b/db/migrate/20250627134916_not_null_organization_id_on_data_export_parts.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnDataExportParts < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :data_export_parts, name: "data_export_parts_organization_id_null" + change_column_null :data_export_parts, :organization_id, false + remove_check_constraint :data_export_parts, name: "data_export_parts_organization_id_null" + end + + def down + add_check_constraint :data_export_parts, "organization_id IS NOT NULL", name: "data_export_parts_organization_id_null", validate: false + change_column_null :data_export_parts, :organization_id, true + end +end diff --git a/db/migrate/20250627134925_organization_id_check_constaint_on_data_exports.rb b/db/migrate/20250627134925_organization_id_check_constaint_on_data_exports.rb new file mode 100644 index 0000000..062d7a7 --- /dev/null +++ b/db/migrate/20250627134925_organization_id_check_constaint_on_data_exports.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnDataExports < ActiveRecord::Migration[8.0] + def change + add_check_constraint :data_exports, + "organization_id IS NOT NULL", + name: "data_exports_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627134926_not_null_organization_id_on_data_exports.rb b/db/migrate/20250627134926_not_null_organization_id_on_data_exports.rb new file mode 100644 index 0000000..0b982a8 --- /dev/null +++ b/db/migrate/20250627134926_not_null_organization_id_on_data_exports.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnDataExports < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :data_exports, name: "data_exports_organization_id_null" + change_column_null :data_exports, :organization_id, false + remove_check_constraint :data_exports, name: "data_exports_organization_id_null" + end + + def down + add_check_constraint :data_exports, "organization_id IS NOT NULL", name: "data_exports_organization_id_null", validate: false + change_column_null :data_exports, :organization_id, true + end +end diff --git a/db/migrate/20250627134932_organization_id_check_constaint_on_dunning_campaign_thresholds.rb b/db/migrate/20250627134932_organization_id_check_constaint_on_dunning_campaign_thresholds.rb new file mode 100644 index 0000000..f237c78 --- /dev/null +++ b/db/migrate/20250627134932_organization_id_check_constaint_on_dunning_campaign_thresholds.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnDunningCampaignThresholds < ActiveRecord::Migration[8.0] + def change + add_check_constraint :dunning_campaign_thresholds, + "organization_id IS NOT NULL", + name: "dunning_campaign_thresholds_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250627134933_not_null_organization_id_on_dunning_campaign_thresholds.rb b/db/migrate/20250627134933_not_null_organization_id_on_dunning_campaign_thresholds.rb new file mode 100644 index 0000000..93ffc2b --- /dev/null +++ b/db/migrate/20250627134933_not_null_organization_id_on_dunning_campaign_thresholds.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnDunningCampaignThresholds < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :dunning_campaign_thresholds, name: "dunning_campaign_thresholds_organization_id_null" + change_column_null :dunning_campaign_thresholds, :organization_id, false + remove_check_constraint :dunning_campaign_thresholds, name: "dunning_campaign_thresholds_organization_id_null" + end + + def down + add_check_constraint :dunning_campaign_thresholds, "organization_id IS NOT NULL", name: "dunning_campaign_thresholds_organization_id_null", validate: false + change_column_null :dunning_campaign_thresholds, :organization_id, true + end +end diff --git a/db/migrate/20250630180000_create_entitlement_features_and_privileges.rb b/db/migrate/20250630180000_create_entitlement_features_and_privileges.rb new file mode 100644 index 0000000..f75f106 --- /dev/null +++ b/db/migrate/20250630180000_create_entitlement_features_and_privileges.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class CreateEntitlementFeaturesAndPrivileges < ActiveRecord::Migration[8.0] + def change + create_enum :entitlement_privilege_value_types, %w[integer string boolean select] + + create_table :entitlement_features, id: :uuid do |t| + t.references :organization, type: :uuid, foreign_key: true, null: false, index: true + t.string :code, null: false + t.string :name + t.string :description + t.datetime :deleted_at + t.timestamps + + t.index %w[code organization_id], + name: "idx_features_code_unique_per_organization", + unique: true, + where: "deleted_at IS NULL" + end + + create_table :entitlement_privileges, id: :uuid do |t| + t.references :organization, type: :uuid, foreign_key: true, null: false, index: true + t.references :entitlement_feature, type: :uuid, foreign_key: true, null: false, index: true + t.string :code, null: false + t.string :name + t.enum :value_type, enum_type: "entitlement_privilege_value_types", null: false + # NOTE: NOT NULL see: db/migrate/20250722094047_change_privilege_config_to_not_null.rb + t.jsonb :config, default: {} + t.datetime :deleted_at + t.timestamps + + t.index %w[code entitlement_feature_id], + name: "idx_privileges_code_unique_per_feature", + unique: true + # where: "deleted_at IS NULL" forgotten but added in db/migrate/20250717092012_fix_privilege_index.rb + end + end +end diff --git a/db/migrate/20250701133139_add_authentication_methods_to_organizations.rb b/db/migrate/20250701133139_add_authentication_methods_to_organizations.rb new file mode 100644 index 0000000..0072043 --- /dev/null +++ b/db/migrate/20250701133139_add_authentication_methods_to_organizations.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddAuthenticationMethodsToOrganizations < ActiveRecord::Migration[8.0] + def change + add_column :organizations, + :authentication_methods, + :string, + array: true, + null: false, + default: %w[email_password google_oauth] + end +end diff --git a/db/migrate/20250701141017_set_okta_authentication_method_to_premium_organizations.rb b/db/migrate/20250701141017_set_okta_authentication_method_to_premium_organizations.rb new file mode 100644 index 0000000..1b15274 --- /dev/null +++ b/db/migrate/20250701141017_set_okta_authentication_method_to_premium_organizations.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class SetOktaAuthenticationMethodToPremiumOrganizations < ActiveRecord::Migration[8.0] + # rubocop:disable Rails/SkipsModelValidations + def change + Organization.with_okta_support.update_all("authentication_methods = ARRAY['email_password', 'google_oauth', 'okta']") + end + # rubocop:enable Rails/SkipsModelValidations +end diff --git a/db/migrate/20250703133126_update_index_on_events.rb b/db/migrate/20250703133126_update_index_on_events.rb new file mode 100644 index 0000000..b2fa86e --- /dev/null +++ b/db/migrate/20250703133126_update_index_on_events.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class UpdateIndexOnEvents < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + remove_index :events, + column: [:organization_id, :timestamp], + algorithm: :concurrently, + if_exists: true + + add_index :events, + [:external_subscription_id, :organization_id, :code, :timestamp], + algorithm: :concurrently, + name: "idx_events_on_external_sub_id_and_org_id_and_code_and_timestamp", + using: :btree, + if_not_exists: true, + where: "deleted_at IS NULL" + end + end + + def down + safety_assured do + remove_index :events, + column: [:external_subscription_id, :organization_id, :code, :timestamp], + algorithm: :concurrently, + if_exists: true + + add_index :events, + [:organization_id, :timestamp], + algorithm: :concurrently, + using: :btree, + if_not_exists: true, + where: "deleted_at IS NULL" + end + end +end diff --git a/db/migrate/20250704800001_create_plan_entitlement.rb b/db/migrate/20250704800001_create_plan_entitlement.rb new file mode 100644 index 0000000..5c580df --- /dev/null +++ b/db/migrate/20250704800001_create_plan_entitlement.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CreatePlanEntitlement < ActiveRecord::Migration[8.0] + def change + create_table :entitlement_entitlements, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :entitlement_feature, null: false, foreign_key: true, type: :uuid + t.references :plan, null: false, foreign_key: true, type: :uuid + t.datetime :deleted_at + t.timestamps + + t.index %w[entitlement_feature_id plan_id], + name: "idx_on_entitlement_feature_id_plan_id_c45949ea26", + unique: true, + where: "deleted_at IS NULL" + end + + create_table :entitlement_entitlement_values, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :entitlement_privilege, null: false, foreign_key: true, type: :uuid + t.references :entitlement_entitlement, null: false, foreign_key: true, type: :uuid + t.string :value, null: false + t.datetime :deleted_at + t.timestamps + + t.index %w[entitlement_privilege_id entitlement_entitlement_id], + unique: true, + where: "deleted_at IS NULL" + end + end +end diff --git a/db/migrate/20250707081825_organization_id_check_constaint_on_integration_items.rb b/db/migrate/20250707081825_organization_id_check_constaint_on_integration_items.rb new file mode 100644 index 0000000..9c36dff --- /dev/null +++ b/db/migrate/20250707081825_organization_id_check_constaint_on_integration_items.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnIntegrationItems < ActiveRecord::Migration[8.0] + def change + add_check_constraint :integration_items, + "organization_id IS NOT NULL", + name: "integration_items_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707081826_not_null_organization_id_on_integration_items.rb b/db/migrate/20250707081826_not_null_organization_id_on_integration_items.rb new file mode 100644 index 0000000..c5b1236 --- /dev/null +++ b/db/migrate/20250707081826_not_null_organization_id_on_integration_items.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnIntegrationItems < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :integration_items, name: "integration_items_organization_id_null" + change_column_null :integration_items, :organization_id, false + remove_check_constraint :integration_items, name: "integration_items_organization_id_null" + end + + def down + add_check_constraint :integration_items, "organization_id IS NOT NULL", name: "integration_items_organization_id_null", validate: false + change_column_null :integration_items, :organization_id, true + end +end diff --git a/db/migrate/20250707081836_organization_id_check_constaint_on_integration_resources.rb b/db/migrate/20250707081836_organization_id_check_constaint_on_integration_resources.rb new file mode 100644 index 0000000..67bb0fa --- /dev/null +++ b/db/migrate/20250707081836_organization_id_check_constaint_on_integration_resources.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnIntegrationResources < ActiveRecord::Migration[8.0] + def change + add_check_constraint :integration_resources, + "organization_id IS NOT NULL", + name: "integration_resources_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707081837_not_null_organization_id_on_integration_resources.rb b/db/migrate/20250707081837_not_null_organization_id_on_integration_resources.rb new file mode 100644 index 0000000..bee1888 --- /dev/null +++ b/db/migrate/20250707081837_not_null_organization_id_on_integration_resources.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnIntegrationResources < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :integration_resources, name: "integration_resources_organization_id_null" + change_column_null :integration_resources, :organization_id, false + remove_check_constraint :integration_resources, name: "integration_resources_organization_id_null" + end + + def down + add_check_constraint :integration_resources, "organization_id IS NOT NULL", name: "integration_resources_organization_id_null", validate: false + change_column_null :integration_resources, :organization_id, true + end +end diff --git a/db/migrate/20250707081910_organization_id_check_constaint_on_invoice_subscriptions.rb b/db/migrate/20250707081910_organization_id_check_constaint_on_invoice_subscriptions.rb new file mode 100644 index 0000000..1e86338 --- /dev/null +++ b/db/migrate/20250707081910_organization_id_check_constaint_on_invoice_subscriptions.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnInvoiceSubscriptions < ActiveRecord::Migration[8.0] + def change + add_check_constraint :invoice_subscriptions, + "organization_id IS NOT NULL", + name: "invoice_subscriptions_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707081911_not_null_organization_id_on_invoice_subscriptions.rb b/db/migrate/20250707081911_not_null_organization_id_on_invoice_subscriptions.rb new file mode 100644 index 0000000..772e3e1 --- /dev/null +++ b/db/migrate/20250707081911_not_null_organization_id_on_invoice_subscriptions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnInvoiceSubscriptions < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :invoice_subscriptions, name: "invoice_subscriptions_organization_id_null" + change_column_null :invoice_subscriptions, :organization_id, false + remove_check_constraint :invoice_subscriptions, name: "invoice_subscriptions_organization_id_null" + end + + def down + add_check_constraint :invoice_subscriptions, "organization_id IS NOT NULL", name: "invoice_subscriptions_organization_id_null", validate: false + change_column_null :invoice_subscriptions, :organization_id, true + end +end diff --git a/db/migrate/20250707082435_organization_id_check_constaint_on_payments.rb b/db/migrate/20250707082435_organization_id_check_constaint_on_payments.rb new file mode 100644 index 0000000..8d27ed0 --- /dev/null +++ b/db/migrate/20250707082435_organization_id_check_constaint_on_payments.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnPayments < ActiveRecord::Migration[8.0] + def change + add_check_constraint :payments, + "organization_id IS NOT NULL", + name: "payments_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707082436_not_null_organization_id_on_payments.rb b/db/migrate/20250707082436_not_null_organization_id_on_payments.rb new file mode 100644 index 0000000..2b8a25f --- /dev/null +++ b/db/migrate/20250707082436_not_null_organization_id_on_payments.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnPayments < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :payments, name: "payments_organization_id_null" + change_column_null :payments, :organization_id, false + remove_check_constraint :payments, name: "payments_organization_id_null" + end + + def down + add_check_constraint :payments, "organization_id IS NOT NULL", name: "payments_organization_id_null", validate: false + change_column_null :payments, :organization_id, true + end +end diff --git a/db/migrate/20250707082509_organization_id_check_constaint_on_recurring_transaction_rules.rb b/db/migrate/20250707082509_organization_id_check_constaint_on_recurring_transaction_rules.rb new file mode 100644 index 0000000..ebb2be8 --- /dev/null +++ b/db/migrate/20250707082509_organization_id_check_constaint_on_recurring_transaction_rules.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnRecurringTransactionRules < ActiveRecord::Migration[8.0] + def change + add_check_constraint :recurring_transaction_rules, + "organization_id IS NOT NULL", + name: "recurring_transaction_rules_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707082510_not_null_organization_id_on_recurring_transaction_rules.rb b/db/migrate/20250707082510_not_null_organization_id_on_recurring_transaction_rules.rb new file mode 100644 index 0000000..f81c3c1 --- /dev/null +++ b/db/migrate/20250707082510_not_null_organization_id_on_recurring_transaction_rules.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnRecurringTransactionRules < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :recurring_transaction_rules, name: "recurring_transaction_rules_organization_id_null" + change_column_null :recurring_transaction_rules, :organization_id, false + remove_check_constraint :recurring_transaction_rules, name: "recurring_transaction_rules_organization_id_null" + end + + def down + add_check_constraint :recurring_transaction_rules, "organization_id IS NOT NULL", name: "recurring_transaction_rules_organization_id_null", validate: false + change_column_null :recurring_transaction_rules, :organization_id, true + end +end diff --git a/db/migrate/20250707082520_organization_id_check_constaint_on_refunds.rb b/db/migrate/20250707082520_organization_id_check_constaint_on_refunds.rb new file mode 100644 index 0000000..c5d2cf3 --- /dev/null +++ b/db/migrate/20250707082520_organization_id_check_constaint_on_refunds.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnRefunds < ActiveRecord::Migration[8.0] + def change + add_check_constraint :refunds, + "organization_id IS NOT NULL", + name: "refunds_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707082521_not_null_organization_id_on_refunds.rb b/db/migrate/20250707082521_not_null_organization_id_on_refunds.rb new file mode 100644 index 0000000..c83fd85 --- /dev/null +++ b/db/migrate/20250707082521_not_null_organization_id_on_refunds.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnRefunds < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :refunds, name: "refunds_organization_id_null" + change_column_null :refunds, :organization_id, false + remove_check_constraint :refunds, name: "refunds_organization_id_null" + end + + def down + add_check_constraint :refunds, "organization_id IS NOT NULL", name: "refunds_organization_id_null", validate: false + change_column_null :refunds, :organization_id, true + end +end diff --git a/db/migrate/20250707083159_organization_id_check_constaint_on_wallets.rb b/db/migrate/20250707083159_organization_id_check_constaint_on_wallets.rb new file mode 100644 index 0000000..38e2365 --- /dev/null +++ b/db/migrate/20250707083159_organization_id_check_constaint_on_wallets.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnWallets < ActiveRecord::Migration[8.0] + def change + add_check_constraint :wallets, + "organization_id IS NOT NULL", + name: "wallets_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707083160_not_null_organization_id_on_wallets.rb b/db/migrate/20250707083160_not_null_organization_id_on_wallets.rb new file mode 100644 index 0000000..623c1fd --- /dev/null +++ b/db/migrate/20250707083160_not_null_organization_id_on_wallets.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnWallets < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :wallets, name: "wallets_organization_id_null" + change_column_null :wallets, :organization_id, false + remove_check_constraint :wallets, name: "wallets_organization_id_null" + end + + def down + add_check_constraint :wallets, "organization_id IS NOT NULL", name: "wallets_organization_id_null", validate: false + change_column_null :wallets, :organization_id, true + end +end diff --git a/db/migrate/20250707083210_organization_id_check_constaint_on_wallet_transactions.rb b/db/migrate/20250707083210_organization_id_check_constaint_on_wallet_transactions.rb new file mode 100644 index 0000000..6e80d94 --- /dev/null +++ b/db/migrate/20250707083210_organization_id_check_constaint_on_wallet_transactions.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnWalletTransactions < ActiveRecord::Migration[8.0] + def change + add_check_constraint :wallet_transactions, + "organization_id IS NOT NULL", + name: "wallet_transactions_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707083211_not_null_organization_id_on_wallet_transactions.rb b/db/migrate/20250707083211_not_null_organization_id_on_wallet_transactions.rb new file mode 100644 index 0000000..ba8d1c7 --- /dev/null +++ b/db/migrate/20250707083211_not_null_organization_id_on_wallet_transactions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnWalletTransactions < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :wallet_transactions, name: "wallet_transactions_organization_id_null" + change_column_null :wallet_transactions, :organization_id, false + remove_check_constraint :wallet_transactions, name: "wallet_transactions_organization_id_null" + end + + def down + add_check_constraint :wallet_transactions, "organization_id IS NOT NULL", name: "wallet_transactions_organization_id_null", validate: false + change_column_null :wallet_transactions, :organization_id, true + end +end diff --git a/db/migrate/20250707083221_organization_id_check_constaint_on_webhooks.rb b/db/migrate/20250707083221_organization_id_check_constaint_on_webhooks.rb new file mode 100644 index 0000000..6936901 --- /dev/null +++ b/db/migrate/20250707083221_organization_id_check_constaint_on_webhooks.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnWebhooks < ActiveRecord::Migration[8.0] + def change + add_check_constraint :webhooks, + "organization_id IS NOT NULL", + name: "webhooks_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707083222_not_null_organization_id_on_webhooks.rb b/db/migrate/20250707083222_not_null_organization_id_on_webhooks.rb new file mode 100644 index 0000000..95ccc38 --- /dev/null +++ b/db/migrate/20250707083222_not_null_organization_id_on_webhooks.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnWebhooks < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :webhooks, name: "webhooks_organization_id_null" + change_column_null :webhooks, :organization_id, false + remove_check_constraint :webhooks, name: "webhooks_organization_id_null" + end + + def down + add_check_constraint :webhooks, "organization_id IS NOT NULL", name: "webhooks_organization_id_null", validate: false + change_column_null :webhooks, :organization_id, true + end +end diff --git a/db/migrate/20250707085614_organization_id_check_constaint_on_fees_taxes.rb b/db/migrate/20250707085614_organization_id_check_constaint_on_fees_taxes.rb new file mode 100644 index 0000000..881ff95 --- /dev/null +++ b/db/migrate/20250707085614_organization_id_check_constaint_on_fees_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnFeesTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :fees_taxes, + "organization_id IS NOT NULL", + name: "fees_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707085615_not_null_organization_id_on_fees_taxes.rb b/db/migrate/20250707085615_not_null_organization_id_on_fees_taxes.rb new file mode 100644 index 0000000..b1820df --- /dev/null +++ b/db/migrate/20250707085615_not_null_organization_id_on_fees_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnFeesTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :fees_taxes, name: "fees_taxes_organization_id_null" + change_column_null :fees_taxes, :organization_id, false + remove_check_constraint :fees_taxes, name: "fees_taxes_organization_id_null" + end + + def down + add_check_constraint :fees_taxes, "organization_id IS NOT NULL", name: "fees_taxes_organization_id_null", validate: false + change_column_null :fees_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250707085633_organization_id_check_constaint_on_plans_taxes.rb b/db/migrate/20250707085633_organization_id_check_constaint_on_plans_taxes.rb new file mode 100644 index 0000000..d1f38f0 --- /dev/null +++ b/db/migrate/20250707085633_organization_id_check_constaint_on_plans_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnPlansTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :plans_taxes, + "organization_id IS NOT NULL", + name: "plans_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707085634_not_null_organization_id_on_plans_taxes.rb b/db/migrate/20250707085634_not_null_organization_id_on_plans_taxes.rb new file mode 100644 index 0000000..a00ad27 --- /dev/null +++ b/db/migrate/20250707085634_not_null_organization_id_on_plans_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnPlansTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :plans_taxes, name: "plans_taxes_organization_id_null" + change_column_null :plans_taxes, :organization_id, false + remove_check_constraint :plans_taxes, name: "plans_taxes_organization_id_null" + end + + def down + add_check_constraint :plans_taxes, "organization_id IS NOT NULL", name: "plans_taxes_organization_id_null", validate: false + change_column_null :plans_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250707085650_organization_id_check_constaint_on_add_ons_taxes.rb b/db/migrate/20250707085650_organization_id_check_constaint_on_add_ons_taxes.rb new file mode 100644 index 0000000..a3fe14e --- /dev/null +++ b/db/migrate/20250707085650_organization_id_check_constaint_on_add_ons_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnAddOnsTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :add_ons_taxes, + "organization_id IS NOT NULL", + name: "add_ons_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707085651_not_null_organization_id_on_add_ons_taxes.rb b/db/migrate/20250707085651_not_null_organization_id_on_add_ons_taxes.rb new file mode 100644 index 0000000..d581518 --- /dev/null +++ b/db/migrate/20250707085651_not_null_organization_id_on_add_ons_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnAddOnsTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :add_ons_taxes, name: "add_ons_taxes_organization_id_null" + change_column_null :add_ons_taxes, :organization_id, false + remove_check_constraint :add_ons_taxes, name: "add_ons_taxes_organization_id_null" + end + + def down + add_check_constraint :add_ons_taxes, "organization_id IS NOT NULL", name: "add_ons_taxes_organization_id_null", validate: false + change_column_null :add_ons_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250707085724_organization_id_check_constaint_on_invoices_taxes.rb b/db/migrate/20250707085724_organization_id_check_constaint_on_invoices_taxes.rb new file mode 100644 index 0000000..574a802 --- /dev/null +++ b/db/migrate/20250707085724_organization_id_check_constaint_on_invoices_taxes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnInvoicesTaxes < ActiveRecord::Migration[8.0] + def change + add_check_constraint :invoices_taxes, + "organization_id IS NOT NULL", + name: "invoices_taxes_organization_id_null", + validate: false + end +end diff --git a/db/migrate/20250707085725_not_null_organization_id_on_invoices_taxes.rb b/db/migrate/20250707085725_not_null_organization_id_on_invoices_taxes.rb new file mode 100644 index 0000000..09a3fae --- /dev/null +++ b/db/migrate/20250707085725_not_null_organization_id_on_invoices_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnInvoicesTaxes < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :invoices_taxes, name: "invoices_taxes_organization_id_null" + change_column_null :invoices_taxes, :organization_id, false + remove_check_constraint :invoices_taxes, name: "invoices_taxes_organization_id_null" + end + + def down + add_check_constraint :invoices_taxes, "organization_id IS NOT NULL", name: "invoices_taxes_organization_id_null", validate: false + change_column_null :invoices_taxes, :organization_id, true + end +end diff --git a/db/migrate/20250707090313_organization_id_check_constaint_on_integration_mappings.rb b/db/migrate/20250707090313_organization_id_check_constaint_on_integration_mappings.rb new file mode 100644 index 0000000..a9f7ca9 --- /dev/null +++ b/db/migrate/20250707090313_organization_id_check_constaint_on_integration_mappings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnIntegrationMappings < ActiveRecord::Migration[8.0] + def change + add_check_constraint :integration_mappings, + "organization_id IS NOT NULL", + name: "integration_mappings_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707090314_not_null_organization_id_on_integration_mappings.rb b/db/migrate/20250707090314_not_null_organization_id_on_integration_mappings.rb new file mode 100644 index 0000000..7dc8fea --- /dev/null +++ b/db/migrate/20250707090314_not_null_organization_id_on_integration_mappings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnIntegrationMappings < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :integration_mappings, name: "integration_mappings_organization_id_not_null" + change_column_null :integration_mappings, :organization_id, false + remove_check_constraint :integration_mappings, name: "integration_mappings_organization_id_not_null" + end + + def down + add_check_constraint :integration_mappings, "organization_id IS NOT NULL", name: "integration_mappings_organization_id_not_null", validate: false + change_column_null :integration_mappings, :organization_id, true + end +end diff --git a/db/migrate/20250707090328_organization_id_check_constaint_on_integration_customers.rb b/db/migrate/20250707090328_organization_id_check_constaint_on_integration_customers.rb new file mode 100644 index 0000000..e65ad84 --- /dev/null +++ b/db/migrate/20250707090328_organization_id_check_constaint_on_integration_customers.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnIntegrationCustomers < ActiveRecord::Migration[8.0] + def change + add_check_constraint :integration_customers, + "organization_id IS NOT NULL", + name: "integration_customers_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707090329_not_null_organization_id_on_integration_customers.rb b/db/migrate/20250707090329_not_null_organization_id_on_integration_customers.rb new file mode 100644 index 0000000..793f2fc --- /dev/null +++ b/db/migrate/20250707090329_not_null_organization_id_on_integration_customers.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnIntegrationCustomers < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :integration_customers, name: "integration_customers_organization_id_not_null" + change_column_null :integration_customers, :organization_id, false + remove_check_constraint :integration_customers, name: "integration_customers_organization_id_not_null" + end + + def down + add_check_constraint :integration_customers, "organization_id IS NOT NULL", name: "integration_customers_organization_id_not_null", validate: false + change_column_null :integration_customers, :organization_id, true + end +end diff --git a/db/migrate/20250707090347_organization_id_check_constaint_on_integration_collection_mappings.rb b/db/migrate/20250707090347_organization_id_check_constaint_on_integration_collection_mappings.rb new file mode 100644 index 0000000..b943d31 --- /dev/null +++ b/db/migrate/20250707090347_organization_id_check_constaint_on_integration_collection_mappings.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnIntegrationCollectionMappings < ActiveRecord::Migration[8.0] + def change + add_check_constraint :integration_collection_mappings, + "organization_id IS NOT NULL", + name: "integration_collection_mappings_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707090348_not_null_organization_id_on_integration_collection_mappings.rb b/db/migrate/20250707090348_not_null_organization_id_on_integration_collection_mappings.rb new file mode 100644 index 0000000..6610350 --- /dev/null +++ b/db/migrate/20250707090348_not_null_organization_id_on_integration_collection_mappings.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnIntegrationCollectionMappings < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :integration_collection_mappings, name: "integration_collection_mappings_organization_id_not_null" + change_column_null :integration_collection_mappings, :organization_id, false + remove_check_constraint :integration_collection_mappings, name: "integration_collection_mappings_organization_id_not_null" + end + + def down + add_check_constraint :integration_collection_mappings, "organization_id IS NOT NULL", name: "integration_collection_mappings_organization_id_not_null", validate: false + change_column_null :integration_collection_mappings, :organization_id, true + end +end diff --git a/db/migrate/20250707094900_organization_id_check_constaint_on_invoice_metadata.rb b/db/migrate/20250707094900_organization_id_check_constaint_on_invoice_metadata.rb new file mode 100644 index 0000000..0646f12 --- /dev/null +++ b/db/migrate/20250707094900_organization_id_check_constaint_on_invoice_metadata.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnInvoiceMetadata < ActiveRecord::Migration[8.0] + def change + add_check_constraint :invoice_metadata, + "organization_id IS NOT NULL", + name: "invoice_metadata_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707094901_not_null_organization_id_on_invoice_metadata.rb b/db/migrate/20250707094901_not_null_organization_id_on_invoice_metadata.rb new file mode 100644 index 0000000..809df25 --- /dev/null +++ b/db/migrate/20250707094901_not_null_organization_id_on_invoice_metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnInvoiceMetadata < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :invoice_metadata, name: "invoice_metadata_organization_id_not_null" + change_column_null :invoice_metadata, :organization_id, false + remove_check_constraint :invoice_metadata, name: "invoice_metadata_organization_id_not_null" + end + + def down + add_check_constraint :invoice_metadata, "organization_id IS NOT NULL", name: "invoice_metadata_organization_id_not_null", validate: false + change_column_null :invoice_metadata, :organization_id, true + end +end diff --git a/db/migrate/20250707094931_organization_id_check_constaint_on_invoices_payment_requests.rb b/db/migrate/20250707094931_organization_id_check_constaint_on_invoices_payment_requests.rb new file mode 100644 index 0000000..ff7e97f --- /dev/null +++ b/db/migrate/20250707094931_organization_id_check_constaint_on_invoices_payment_requests.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnInvoicesPaymentRequests < ActiveRecord::Migration[8.0] + def change + add_check_constraint :invoices_payment_requests, + "organization_id IS NOT NULL", + name: "invoices_payment_requests_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707094932_not_null_organization_id_on_invoices_payment_requests.rb b/db/migrate/20250707094932_not_null_organization_id_on_invoices_payment_requests.rb new file mode 100644 index 0000000..e0affd6 --- /dev/null +++ b/db/migrate/20250707094932_not_null_organization_id_on_invoices_payment_requests.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnInvoicesPaymentRequests < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :invoices_payment_requests, name: "invoices_payment_requests_organization_id_not_null" + change_column_null :invoices_payment_requests, :organization_id, false + remove_check_constraint :invoices_payment_requests, name: "invoices_payment_requests_organization_id_not_null" + end + + def down + add_check_constraint :invoices_payment_requests, "organization_id IS NOT NULL", name: "invoices_payment_requests_organization_id_not_null", validate: false + change_column_null :invoices_payment_requests, :organization_id, true + end +end diff --git a/db/migrate/20250707095223_organization_id_check_constaint_on_usage_thresholds.rb b/db/migrate/20250707095223_organization_id_check_constaint_on_usage_thresholds.rb new file mode 100644 index 0000000..3bd568b --- /dev/null +++ b/db/migrate/20250707095223_organization_id_check_constaint_on_usage_thresholds.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnUsageThresholds < ActiveRecord::Migration[8.0] + def change + add_check_constraint :usage_thresholds, + "organization_id IS NOT NULL", + name: "usage_thresholds_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707095224_not_null_organization_id_on_usage_thresholds.rb b/db/migrate/20250707095224_not_null_organization_id_on_usage_thresholds.rb new file mode 100644 index 0000000..a7c11bb --- /dev/null +++ b/db/migrate/20250707095224_not_null_organization_id_on_usage_thresholds.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnUsageThresholds < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :usage_thresholds, name: "usage_thresholds_organization_id_not_null" + change_column_null :usage_thresholds, :organization_id, false + remove_check_constraint :usage_thresholds, name: "usage_thresholds_organization_id_not_null" + end + + def down + add_check_constraint :usage_thresholds, "organization_id IS NOT NULL", name: "usage_thresholds_organization_id_not_null", validate: false + change_column_null :usage_thresholds, :organization_id, true + end +end diff --git a/db/migrate/20250707095955_organization_id_check_constaint_on_subscriptions.rb b/db/migrate/20250707095955_organization_id_check_constaint_on_subscriptions.rb new file mode 100644 index 0000000..c331fbc --- /dev/null +++ b/db/migrate/20250707095955_organization_id_check_constaint_on_subscriptions.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnSubscriptions < ActiveRecord::Migration[8.0] + def change + add_check_constraint :subscriptions, + "organization_id IS NOT NULL", + name: "subscriptions_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707095956_not_null_organization_id_on_subscriptions.rb b/db/migrate/20250707095956_not_null_organization_id_on_subscriptions.rb new file mode 100644 index 0000000..da5e8db --- /dev/null +++ b/db/migrate/20250707095956_not_null_organization_id_on_subscriptions.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnSubscriptions < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :subscriptions, name: "subscriptions_organization_id_not_null" + change_column_null :subscriptions, :organization_id, false + remove_check_constraint :subscriptions, name: "subscriptions_organization_id_not_null" + end + + def down + add_check_constraint :subscriptions, "organization_id IS NOT NULL", name: "subscriptions_organization_id_not_null", validate: false + change_column_null :subscriptions, :organization_id, true + end +end diff --git a/db/migrate/20250707100010_migrate_deleted_payment_provider_customers.rb b/db/migrate/20250707100010_migrate_deleted_payment_provider_customers.rb new file mode 100644 index 0000000..756e5ab --- /dev/null +++ b/db/migrate/20250707100010_migrate_deleted_payment_provider_customers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class MigrateDeletedPaymentProviderCustomers < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute <<~SQL.squish + UPDATE payment_provider_customers + SET organization_id = (SELECT organization_id FROM customers WHERE customers.id = customer_id) + WHERE organization_id IS NULL + SQL + end + end +end diff --git a/db/migrate/20250707100012_organization_id_check_constaint_on_adjusted_fees.rb b/db/migrate/20250707100012_organization_id_check_constaint_on_adjusted_fees.rb new file mode 100644 index 0000000..37d8d87 --- /dev/null +++ b/db/migrate/20250707100012_organization_id_check_constaint_on_adjusted_fees.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnAdjustedFees < ActiveRecord::Migration[8.0] + def change + add_check_constraint :adjusted_fees, + "organization_id IS NOT NULL", + name: "adjusted_fees_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707100013_not_null_organization_id_on_adjusted_fees.rb b/db/migrate/20250707100013_not_null_organization_id_on_adjusted_fees.rb new file mode 100644 index 0000000..04f9661 --- /dev/null +++ b/db/migrate/20250707100013_not_null_organization_id_on_adjusted_fees.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnAdjustedFees < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :adjusted_fees, name: "adjusted_fees_organization_id_not_null" + change_column_null :adjusted_fees, :organization_id, false + remove_check_constraint :adjusted_fees, name: "adjusted_fees_organization_id_not_null" + end + + def down + add_check_constraint :adjusted_fees, "organization_id IS NOT NULL", name: "adjusted_fees_organization_id_not_null", validate: false + change_column_null :adjusted_fees, :organization_id, true + end +end diff --git a/db/migrate/20250707100025_organization_id_check_constaint_on_applied_coupons.rb b/db/migrate/20250707100025_organization_id_check_constaint_on_applied_coupons.rb new file mode 100644 index 0000000..56e1d35 --- /dev/null +++ b/db/migrate/20250707100025_organization_id_check_constaint_on_applied_coupons.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnAppliedCoupons < ActiveRecord::Migration[8.0] + def change + add_check_constraint :applied_coupons, + "organization_id IS NOT NULL", + name: "applied_coupons_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707100026_not_null_organization_id_on_applied_coupons.rb b/db/migrate/20250707100026_not_null_organization_id_on_applied_coupons.rb new file mode 100644 index 0000000..e4ff199 --- /dev/null +++ b/db/migrate/20250707100026_not_null_organization_id_on_applied_coupons.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnAppliedCoupons < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :applied_coupons, name: "applied_coupons_organization_id_not_null" + change_column_null :applied_coupons, :organization_id, false + remove_check_constraint :applied_coupons, name: "applied_coupons_organization_id_not_null" + end + + def down + add_check_constraint :applied_coupons, "organization_id IS NOT NULL", name: "applied_coupons_organization_id_not_null", validate: false + change_column_null :applied_coupons, :organization_id, true + end +end diff --git a/db/migrate/20250707100101_organization_id_check_constaint_on_payment_provider_customers.rb b/db/migrate/20250707100101_organization_id_check_constaint_on_payment_provider_customers.rb new file mode 100644 index 0000000..2036572 --- /dev/null +++ b/db/migrate/20250707100101_organization_id_check_constaint_on_payment_provider_customers.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnPaymentProviderCustomers < ActiveRecord::Migration[8.0] + def change + add_check_constraint :payment_provider_customers, + "organization_id IS NOT NULL", + name: "payment_provider_customers_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707100102_not_null_organization_id_on_payment_provider_customers.rb b/db/migrate/20250707100102_not_null_organization_id_on_payment_provider_customers.rb new file mode 100644 index 0000000..74e3801 --- /dev/null +++ b/db/migrate/20250707100102_not_null_organization_id_on_payment_provider_customers.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnPaymentProviderCustomers < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :payment_provider_customers, name: "payment_provider_customers_organization_id_not_null" + change_column_null :payment_provider_customers, :organization_id, false + remove_check_constraint :payment_provider_customers, name: "payment_provider_customers_organization_id_not_null" + end + + def down + add_check_constraint :payment_provider_customers, "organization_id IS NOT NULL", name: "payment_provider_customers_organization_id_not_null", validate: false + change_column_null :payment_provider_customers, :organization_id, true + end +end diff --git a/db/migrate/20250707113717_organization_id_check_constaint_on_applied_invoice_custom_sections.rb b/db/migrate/20250707113717_organization_id_check_constaint_on_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..227e26f --- /dev/null +++ b/db/migrate/20250707113717_organization_id_check_constaint_on_applied_invoice_custom_sections.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOnAppliedInvoiceCustomSections < ActiveRecord::Migration[8.0] + def change + add_check_constraint :applied_invoice_custom_sections, + "organization_id IS NOT NULL", + name: "applied_invoice_custom_sections_organization_id_not_null", + validate: false + end +end diff --git a/db/migrate/20250707113718_not_null_organization_id_on_applied_invoice_custom_sections.rb b/db/migrate/20250707113718_not_null_organization_id_on_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..3481b12 --- /dev/null +++ b/db/migrate/20250707113718_not_null_organization_id_on_applied_invoice_custom_sections.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOnAppliedInvoiceCustomSections < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :applied_invoice_custom_sections, name: "applied_invoice_custom_sections_organization_id_not_null" + change_column_null :applied_invoice_custom_sections, :organization_id, false + remove_check_constraint :applied_invoice_custom_sections, name: "applied_invoice_custom_sections_organization_id_not_null" + end + + def down + add_check_constraint :applied_invoice_custom_sections, "organization_id IS NOT NULL", name: "applied_invoice_custom_sections_organization_id_not_null", validate: false + change_column_null :applied_invoice_custom_sections, :organization_id, true + end +end diff --git a/db/migrate/20250708094414_add_in_advance_index_to_charge.rb b/db/migrate/20250708094414_add_in_advance_index_to_charge.rb new file mode 100644 index 0000000..88d3bef --- /dev/null +++ b/db/migrate/20250708094414_add_in_advance_index_to_charge.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddInAdvanceIndexToCharge < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :charges, + [:plan_id, :billable_metric_id, :pay_in_advance], + algorithm: :concurrently, + using: :btree, + where: "deleted_at IS NULL", + if_not_exists: true + end +end diff --git a/db/migrate/20250709082136_add_privilege_value_type_default.rb b/db/migrate/20250709082136_add_privilege_value_type_default.rb new file mode 100644 index 0000000..4bb8aa5 --- /dev/null +++ b/db/migrate/20250709082136_add_privilege_value_type_default.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPrivilegeValueTypeDefault < ActiveRecord::Migration[8.0] + def change + change_column_default :entitlement_privileges, :value_type, from: nil, to: "string" + end +end diff --git a/db/migrate/20250709085218_create_wallet_targets.rb b/db/migrate/20250709085218_create_wallet_targets.rb new file mode 100644 index 0000000..b6f1030 --- /dev/null +++ b/db/migrate/20250709085218_create_wallet_targets.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateWalletTargets < ActiveRecord::Migration[8.0] + def change + create_table :wallet_targets, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid, index: true + t.references :wallet, null: false, foreign_key: true, type: :uuid, index: true + t.references :billable_metric, null: false, foreign_key: true, type: :uuid, index: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250709171329_add_on_termination_credit_note_to_subscriptions.rb b/db/migrate/20250709171329_add_on_termination_credit_note_to_subscriptions.rb new file mode 100644 index 0000000..79a0460 --- /dev/null +++ b/db/migrate/20250709171329_add_on_termination_credit_note_to_subscriptions.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddOnTerminationCreditNoteToSubscriptions < ActiveRecord::Migration[8.0] + def change + create_enum :subscription_on_termination_credit_note, %w[credit skip] + add_column :subscriptions, :on_termination_credit_note, :enum, enum_type: "subscription_on_termination_credit_note", default: nil + end +end diff --git a/db/migrate/20250710102337_add_customer_id_to_payments.rb b/db/migrate/20250710102337_add_customer_id_to_payments.rb new file mode 100644 index 0000000..4595883 --- /dev/null +++ b/db/migrate/20250710102337_add_customer_id_to_payments.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddCustomerIdToPayments < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :payments, :customer, null: true, index: {algorithm: :concurrently}, type: :uuid + + add_check_constraint :payments, + "customer_id IS NOT NULL", + name: "payments_customer_id_null", + validate: false + end +end diff --git a/db/migrate/20250712000000_add_refund_to_subscription_on_termination_credit_note.rb b/db/migrate/20250712000000_add_refund_to_subscription_on_termination_credit_note.rb new file mode 100644 index 0000000..6830ba7 --- /dev/null +++ b/db/migrate/20250712000000_add_refund_to_subscription_on_termination_credit_note.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddRefundToSubscriptionOnTerminationCreditNote < ActiveRecord::Migration[7.1] + def up + add_enum_value :subscription_on_termination_credit_note, "refund", if_not_exists: true + end + + def down + # No rollback needed as removing enum values is not supported + end +end diff --git a/db/migrate/20250714131519_add_precise_unit_amount_to_pricing_unit_usages.rb b/db/migrate/20250714131519_add_precise_unit_amount_to_pricing_unit_usages.rb new file mode 100644 index 0000000..2a2c818 --- /dev/null +++ b/db/migrate/20250714131519_add_precise_unit_amount_to_pricing_unit_usages.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddPreciseUnitAmountToPricingUnitUsages < ActiveRecord::Migration[8.0] + def change + add_column :pricing_unit_usages, + :precise_unit_amount, + :decimal, + precision: 30, + scale: 15, + default: 0.0, + null: false + end +end diff --git a/db/migrate/20250715124108_fixed_charge_charge_model.rb b/db/migrate/20250715124108_fixed_charge_charge_model.rb new file mode 100644 index 0000000..32d00c8 --- /dev/null +++ b/db/migrate/20250715124108_fixed_charge_charge_model.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class FixedChargeChargeModel < ActiveRecord::Migration[8.0] + def change + create_enum :fixed_charge_charge_model, %w[standard graduated volume] + end +end diff --git a/db/migrate/20250716123425_add_bill_fixed_charges_monthly_to_plan.rb b/db/migrate/20250716123425_add_bill_fixed_charges_monthly_to_plan.rb new file mode 100644 index 0000000..86d2c75 --- /dev/null +++ b/db/migrate/20250716123425_add_bill_fixed_charges_monthly_to_plan.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddBillFixedChargesMonthlyToPlan < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :plans, :bill_fixed_charges_monthly, :boolean, default: false + add_index :plans, :bill_fixed_charges_monthly, algorithm: :concurrently, where: "deleted_at IS NULL AND bill_fixed_charges_monthly IS true" + end +end diff --git a/db/migrate/20250716132649_add_regenerated_invoice_id_and_index_to_invoice_subscriptions.rb b/db/migrate/20250716132649_add_regenerated_invoice_id_and_index_to_invoice_subscriptions.rb new file mode 100644 index 0000000..a20e579 --- /dev/null +++ b/db/migrate/20250716132649_add_regenerated_invoice_id_and_index_to_invoice_subscriptions.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddRegeneratedInvoiceIdAndIndexToInvoiceSubscriptions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :invoice_subscriptions, + :regenerated_invoice, + index: {algorithm: :concurrently}, + type: :uuid + + add_index :invoice_subscriptions, + [:subscription_id, :invoicing_reason], + unique: true, + name: :index_unique_terminating_invoice_subscription, + where: "invoicing_reason = 'subscription_terminating' AND regenerated_invoice_id IS NULL", + algorithm: :concurrently + end +end diff --git a/db/migrate/20250716132759_remove_old_terminating_subscription_invoice_index.rb b/db/migrate/20250716132759_remove_old_terminating_subscription_invoice_index.rb new file mode 100644 index 0000000..e3e2664 --- /dev/null +++ b/db/migrate/20250716132759_remove_old_terminating_subscription_invoice_index.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class RemoveOldTerminatingSubscriptionInvoiceIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + remove_index :invoice_subscriptions, + name: :index_unique_terminating_subscription_invoice, + algorithm: :concurrently + end + + def down + add_index :invoice_subscriptions, + [:subscription_id, :invoicing_reason], + unique: true, + name: :index_unique_terminating_subscription_invoice, + where: "invoicing_reason = 'subscription_terminating'", + algorithm: :concurrently + end +end diff --git a/db/migrate/20250716142613_add_foreign_key_to_regenerated_invoice_id.rb b/db/migrate/20250716142613_add_foreign_key_to_regenerated_invoice_id.rb new file mode 100644 index 0000000..23906dc --- /dev/null +++ b/db/migrate/20250716142613_add_foreign_key_to_regenerated_invoice_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddForeignKeyToRegeneratedInvoiceId < ActiveRecord::Migration[8.0] + def change + add_foreign_key :invoice_subscriptions, :invoices, + column: :regenerated_invoice_id, + validate: false + end +end diff --git a/db/migrate/20250716143358_validate_foreign_key_on_regenerated_invoice_id.rb b/db/migrate/20250716143358_validate_foreign_key_on_regenerated_invoice_id.rb new file mode 100644 index 0000000..0f2e21e --- /dev/null +++ b/db/migrate/20250716143358_validate_foreign_key_on_regenerated_invoice_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ValidateForeignKeyOnRegeneratedInvoiceId < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + validate_foreign_key :invoice_subscriptions, :invoices + end +end diff --git a/db/migrate/20250716150049_create_fixed_charges.rb b/db/migrate/20250716150049_create_fixed_charges.rb new file mode 100644 index 0000000..d881bf6 --- /dev/null +++ b/db/migrate/20250716150049_create_fixed_charges.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateFixedCharges < ActiveRecord::Migration[8.0] + def change + create_table :fixed_charges, id: :uuid do |t| + t.belongs_to :organization, null: false, foreign_key: true, type: :uuid + t.belongs_to :plan, null: false, foreign_key: true, type: :uuid + t.belongs_to :add_on, null: false, foreign_key: true, type: :uuid + t.belongs_to :parent, type: :uuid, index: true + + t.enum :charge_model, enum_type: "fixed_charge_charge_model", null: false, default: "standard" + t.jsonb :properties, null: false, default: {} + t.string :invoice_display_name + t.boolean :pay_in_advance, default: false, null: false + t.boolean :prorated, default: false, null: false + t.decimal :units, precision: 30, scale: 10, null: false, default: 0.0 + t.datetime :deleted_at, index: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250717071548_create_subscription_fixed_charge_units_overrides.rb b/db/migrate/20250717071548_create_subscription_fixed_charge_units_overrides.rb new file mode 100644 index 0000000..e0058ed --- /dev/null +++ b/db/migrate/20250717071548_create_subscription_fixed_charge_units_overrides.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateSubscriptionFixedChargeUnitsOverrides < ActiveRecord::Migration[8.0] + def change + create_table :subscription_fixed_charge_units_overrides, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :billing_entity, null: false, foreign_key: true, type: :uuid + t.references :subscription, null: false, foreign_key: true, type: :uuid + t.references :fixed_charge, null: false, foreign_key: true, type: :uuid + + t.decimal :units, precision: 30, scale: 10, null: false, default: 0.0 + t.datetime :deleted_at, index: true + + t.index [:subscription_id, :fixed_charge_id], + unique: true, + where: "deleted_at IS NULL" + + t.timestamps + end + end +end diff --git a/db/migrate/20250717092012_fix_privilege_index.rb b/db/migrate/20250717092012_fix_privilege_index.rb new file mode 100644 index 0000000..b01e067 --- /dev/null +++ b/db/migrate/20250717092012_fix_privilege_index.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class FixPrivilegeIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + remove_index :entitlement_privileges, %w[code entitlement_feature_id], + name: :idx_privileges_code_unique_per_feature, + unique: true, + algorithm: :concurrently, + if_exists: true + + add_index :entitlement_privileges, %w[code entitlement_feature_id], + name: "idx_privileges_code_unique_per_feature", + unique: true, + where: "deleted_at IS NULL", + algorithm: :concurrently + end +end diff --git a/db/migrate/20250717140238_add_fixed_charge_id_to_fees.rb b/db/migrate/20250717140238_add_fixed_charge_id_to_fees.rb new file mode 100644 index 0000000..2536a6a --- /dev/null +++ b/db/migrate/20250717140238_add_fixed_charge_id_to_fees.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddFixedChargeIdToFees < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :fees, :fixed_charge, type: :uuid, index: {algorithm: :concurrently} + add_foreign_key :fees, :fixed_charges, column: :fixed_charge_id, validate: false + end +end diff --git a/db/migrate/20250717142942_validate_fk_on_fixed_charge_id_for_fees.rb b/db/migrate/20250717142942_validate_fk_on_fixed_charge_id_for_fees.rb new file mode 100644 index 0000000..1f8d9f1 --- /dev/null +++ b/db/migrate/20250717142942_validate_fk_on_fixed_charge_id_for_fees.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ValidateFkOnFixedChargeIdForFees < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + validate_foreign_key :fees, :fixed_charges + end +end diff --git a/db/migrate/20250718140450_create_exports_invoice_subscriptions.rb b/db/migrate/20250718140450_create_exports_invoice_subscriptions.rb new file mode 100644 index 0000000..bc85686 --- /dev/null +++ b/db/migrate/20250718140450_create_exports_invoice_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsInvoiceSubscriptions < ActiveRecord::Migration[8.0] + def change + create_view :exports_invoice_subscriptions + end +end diff --git a/db/migrate/20250718174008_create_exports_payments.rb b/db/migrate/20250718174008_create_exports_payments.rb new file mode 100644 index 0000000..af83478 --- /dev/null +++ b/db/migrate/20250718174008_create_exports_payments.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsPayments < ActiveRecord::Migration[8.0] + def change + create_view :exports_payments + end +end diff --git a/db/migrate/20250721090704_update_flat_filters_to_version_2.rb b/db/migrate/20250721090704_update_flat_filters_to_version_2.rb new file mode 100644 index 0000000..96de656 --- /dev/null +++ b/db/migrate/20250721090704_update_flat_filters_to_version_2.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateFlatFiltersToVersion2 < ActiveRecord::Migration[8.0] + def change + update_view :flat_filters, version: 2, revert_to_version: 1 + end +end diff --git a/db/migrate/20250721091802_create_exports_payment_requests.rb b/db/migrate/20250721091802_create_exports_payment_requests.rb new file mode 100644 index 0000000..1d25260 --- /dev/null +++ b/db/migrate/20250721091802_create_exports_payment_requests.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsPaymentRequests < ActiveRecord::Migration[8.0] + def change + create_view :exports_payment_requests + end +end diff --git a/db/migrate/20250721150000_add_subscription_external_id_to_entitlements.rb b/db/migrate/20250721150000_add_subscription_external_id_to_entitlements.rb new file mode 100644 index 0000000..70ac9a7 --- /dev/null +++ b/db/migrate/20250721150000_add_subscription_external_id_to_entitlements.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class AddSubscriptionExternalIdToEntitlements < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + # Adding foreign key blocks writes, but the feature isn't released yet. Table is empty. + add_reference :entitlement_entitlements, :subscription, + foreign_key: true, + index: {algorithm: :concurrently}, + type: :uuid, + if_not_exists: true + end + + remove_index :entitlement_entitlements, + name: "idx_on_entitlement_feature_id_plan_id_c45949ea26", + column: %w[entitlement_feature_id plan_id], + unique: true, + where: "(deleted_at IS NULL)", + algorithm: :concurrently, + if_exists: true + + change_column_null :entitlement_entitlements, :plan_id, true + + add_index :entitlement_entitlements, %w[entitlement_feature_id plan_id], + unique: true, + where: "deleted_at IS NULL", + name: "idx_unique_feature_per_plan", + algorithm: :concurrently, + if_not_exists: true + + add_index :entitlement_entitlements, %w[entitlement_feature_id subscription_id], + unique: true, + where: "deleted_at IS NULL", + name: "idx_unique_feature_per_subscription", + algorithm: :concurrently, + if_not_exists: true + + safety_assured do + # Adding a check constraint key blocks reads and writes while every row is checked, + # but the feature isn't released yet. Table is empty. + add_check_constraint :entitlement_entitlements, + "(plan_id IS NOT NULL) != (subscription_id IS NOT NULL)", + name: "entitlement_check_exactly_one_parent", + validate: true, + if_not_exists: true + end + end +end diff --git a/db/migrate/20250721150001_create_entitlement_subscription_feature_removals.rb b/db/migrate/20250721150001_create_entitlement_subscription_feature_removals.rb new file mode 100644 index 0000000..86eb191 --- /dev/null +++ b/db/migrate/20250721150001_create_entitlement_subscription_feature_removals.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateEntitlementSubscriptionFeatureRemovals < ActiveRecord::Migration[8.0] + def change + create_table :entitlement_subscription_feature_removals, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :entitlement_feature, null: false, foreign_key: true, type: :uuid + t.references :subscription, null: false, foreign_key: true, type: :uuid + t.datetime :deleted_at, index: true + t.timestamps + + t.index [:subscription_id, :entitlement_feature_id], unique: true, where: "deleted_at IS NULL" + end + end +end diff --git a/db/migrate/20250721150002_create_entitlement_subscription_entitlements_view.rb b/db/migrate/20250721150002_create_entitlement_subscription_entitlements_view.rb new file mode 100644 index 0000000..749c79f --- /dev/null +++ b/db/migrate/20250721150002_create_entitlement_subscription_entitlements_view.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +class CreateEntitlementSubscriptionEntitlementsView < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute <<~SQL + CREATE OR REPLACE VIEW entitlement_subscription_entitlements_view AS + WITH + subscription_entitlements AS ( + SELECT + fe.entitlement_feature_id, + fe.plan_id, + fe.subscription_id, + fev.deleted_at AS deleted_at, + fev.id, + fev.entitlement_privilege_id, + fev.entitlement_entitlement_id, + fev.value + FROM + entitlement_entitlement_values fev + JOIN entitlement_entitlements fe ON fe.id = fev.entitlement_entitlement_id + WHERE + fev.deleted_at IS NULL + ), + all_values AS ( + SELECT + ep.entitlement_feature_id, + COALESCE(ep.entitlement_privilege_id, es.entitlement_privilege_id) AS entitlement_privilege_id, + ep.entitlement_entitlement_id AS plan_entitlement_id, + es.entitlement_entitlement_id AS override_entitlement_id, + ep.id AS plan_entitlement_values_id, + es.id AS override_entitlement_values_id, + ep.value AS plan_value, + es.value AS override_value + FROM + subscription_entitlements ep + FULL OUTER JOIN subscription_entitlements es ON ep.entitlement_privilege_id = es.entitlement_privilege_id + AND ep.plan_id IS NOT NULL + AND es.subscription_id IS NOT NULL + WHERE + ( + ep.plan_id IS NOT NULL + OR es.subscription_id IS NOT NULL + ) + AND ep.deleted_at IS NULL + AND es.deleted_at IS NULL + ) + SELECT + f.id AS entitlement_feature_id, + f.organization_id AS organization_id, + f.code AS feature_code, + f.name AS feature_name, + f.description AS feature_description, + f.deleted_at AS feature_deleted_at, + pri.id AS entitlement_privilege_id, + pri.code AS privilege_code, + pri.name AS privilege_name, + pri.value_type AS privilege_value_type, + pri.config AS privilege_config, + pri.deleted_at AS privilege_deleted_at, + fe.plan_id AS plan_id, + fe.subscription_id AS subscription_id, + (sfr.id IS NOT NULL) AS removed, + av.plan_entitlement_id, + av.override_entitlement_id, + av.plan_entitlement_values_id, + av.override_entitlement_values_id, + av.plan_value AS privilege_plan_value, + av.override_value AS privilege_override_value + FROM + entitlement_entitlements fe + LEFT JOIN entitlement_subscription_feature_removals sfr ON fe.entitlement_feature_id = sfr.entitlement_feature_id AND sfr.deleted_at IS NULL + LEFT JOIN all_values av ON COALESCE(av.override_entitlement_id, av.plan_entitlement_id) = fe.id + LEFT JOIN entitlement_features f ON f.id = fe.entitlement_feature_id + LEFT JOIN entitlement_privileges pri ON pri.id = av.entitlement_privilege_id; + SQL + end + end + + def down + safety_assured do + execute "DROP VIEW entitlement_subscription_entitlements_view" + end + end +end diff --git a/db/migrate/20250721192051_add_on_termination_invoice_to_subscriptions.rb b/db/migrate/20250721192051_add_on_termination_invoice_to_subscriptions.rb new file mode 100644 index 0000000..e53d295 --- /dev/null +++ b/db/migrate/20250721192051_add_on_termination_invoice_to_subscriptions.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class AddOnTerminationInvoiceToSubscriptions < ActiveRecord::Migration[8.0] + def change + create_enum( + :subscription_on_termination_invoice, + %w[generate skip] + ) + add_column( + :subscriptions, + :on_termination_invoice, + :enum, + enum_type: "subscription_on_termination_invoice", + default: "generate", + null: false + ) + end +end diff --git a/db/migrate/20250721211820_update_exports_fees_to_version_2.rb b/db/migrate/20250721211820_update_exports_fees_to_version_2.rb new file mode 100644 index 0000000..b3736ba --- /dev/null +++ b/db/migrate/20250721211820_update_exports_fees_to_version_2.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsFeesToVersion2 < ActiveRecord::Migration[8.0] + def change + update_view :exports_fees, version: 2, revert_to_version: 1 + end +end diff --git a/db/migrate/20250721212307_update_exports_credit_notes_to_version_3.rb b/db/migrate/20250721212307_update_exports_credit_notes_to_version_3.rb new file mode 100644 index 0000000..066a61b --- /dev/null +++ b/db/migrate/20250721212307_update_exports_credit_notes_to_version_3.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsCreditNotesToVersion3 < ActiveRecord::Migration[8.0] + def change + update_view :exports_credit_notes, version: 3, revert_to_version: 2 + end +end diff --git a/db/migrate/20250721220908_update_exports_customers_to_version_2.rb b/db/migrate/20250721220908_update_exports_customers_to_version_2.rb new file mode 100644 index 0000000..15c0fae --- /dev/null +++ b/db/migrate/20250721220908_update_exports_customers_to_version_2.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsCustomersToVersion2 < ActiveRecord::Migration[8.0] + def change + update_view :exports_customers, version: 2, revert_to_version: 1 + end +end diff --git a/db/migrate/20250722094047_change_privilege_config_to_not_null.rb b/db/migrate/20250722094047_change_privilege_config_to_not_null.rb new file mode 100644 index 0000000..dbb9904 --- /dev/null +++ b/db/migrate/20250722094047_change_privilege_config_to_not_null.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ChangePrivilegeConfigToNotNull < ActiveRecord::Migration[8.0] + def change + safety_assured do + change_column_null :entitlement_privileges, :config, false + end + end +end diff --git a/db/migrate/20250724104251_update_entitlement_subscription_entitlements_view.rb b/db/migrate/20250724104251_update_entitlement_subscription_entitlements_view.rb new file mode 100644 index 0000000..ce4e0d2 --- /dev/null +++ b/db/migrate/20250724104251_update_entitlement_subscription_entitlements_view.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateEntitlementSubscriptionEntitlementsView < ActiveRecord::Migration[8.0] + def change + update_view :entitlement_subscription_entitlements_view, version: 2, revert_to_version: 1 + end +end diff --git a/db/migrate/20250731144632_add_scoped_index_to_starting_invoice_subscription.rb b/db/migrate/20250731144632_add_scoped_index_to_starting_invoice_subscription.rb new file mode 100644 index 0000000..1cdc975 --- /dev/null +++ b/db/migrate/20250731144632_add_scoped_index_to_starting_invoice_subscription.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddScopedIndexToStartingInvoiceSubscription < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :invoice_subscriptions, + [:subscription_id, :invoicing_reason], + unique: true, + name: :index_unique_starting_invoice_subscription, + where: "invoicing_reason = 'subscription_starting' AND regenerated_invoice_id IS NULL", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20250731145640_remove_old_starting_invoice_subscription_index.rb b/db/migrate/20250731145640_remove_old_starting_invoice_subscription_index.rb new file mode 100644 index 0000000..bb46f3a --- /dev/null +++ b/db/migrate/20250731145640_remove_old_starting_invoice_subscription_index.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveOldStartingInvoiceSubscriptionIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + if index_exists?(:invoice_subscriptions, nil, name: :index_unique_starting_subscription_invoice) + remove_index :invoice_subscriptions, name: :index_unique_starting_subscription_invoice + end + end + + def down + add_index :invoice_subscriptions, + [:subscription_id, :invoicing_reason], + unique: true, + name: :index_unique_starting_subscription_invoice, + where: "invoicing_reason = 'subscription_starting'", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20250801072722_create_fixed_charges_taxes.rb b/db/migrate/20250801072722_create_fixed_charges_taxes.rb new file mode 100644 index 0000000..cfa2b8f --- /dev/null +++ b/db/migrate/20250801072722_create_fixed_charges_taxes.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateFixedChargesTaxes < ActiveRecord::Migration[8.0] + def change + create_table :fixed_charges_taxes, id: :uuid do |t| + t.references :fixed_charge, type: :uuid, null: false, foreign_key: true + t.references :tax, type: :uuid, null: false, foreign_key: true + t.references :organization, type: :uuid, null: false, foreign_key: true + + t.index [:fixed_charge_id, :tax_id], unique: true + t.timestamps + end + end +end diff --git a/db/migrate/20250806173900_add_scoped_index_to_charges_from_and_to_datetime.rb b/db/migrate/20250806173900_add_scoped_index_to_charges_from_and_to_datetime.rb new file mode 100644 index 0000000..842d3c3 --- /dev/null +++ b/db/migrate/20250806173900_add_scoped_index_to_charges_from_and_to_datetime.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddScopedIndexToChargesFromAndToDatetime < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :invoice_subscriptions, + [:subscription_id, :charges_from_datetime, :charges_to_datetime], + unique: true, + name: :index_uniq_invoice_subscriptions_on_charges_from_to_datetime, + where: "created_at >= '2023-06-09 00:00:00' AND recurring IS TRUE AND regenerated_invoice_id IS NULL", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20250806174150_remove_old_scoped_charges_from_to_datetime_index.rb b/db/migrate/20250806174150_remove_old_scoped_charges_from_to_datetime_index.rb new file mode 100644 index 0000000..3dd0f82 --- /dev/null +++ b/db/migrate/20250806174150_remove_old_scoped_charges_from_to_datetime_index.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class RemoveOldScopedChargesFromToDatetimeIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + if index_exists?(:invoice_subscriptions, [:subscription_id, :charges_from_datetime, :charges_to_datetime], name: :index_invoice_subscriptions_on_charges_from_and_to_datetime) + remove_index :invoice_subscriptions, name: :index_invoice_subscriptions_on_charges_from_and_to_datetime + end + end + + def down + add_index :invoice_subscriptions, + [:subscription_id, :charges_from_datetime, :charges_to_datetime], + unique: true, + name: :index_invoice_subscriptions_on_charges_from_and_to_datetime, + where: "created_at >= '2023-06-09 00:00:00' AND recurring IS TRUE", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20250812082721_update_flat_filters_to_version_3.rb b/db/migrate/20250812082721_update_flat_filters_to_version_3.rb new file mode 100644 index 0000000..2227fc9 --- /dev/null +++ b/db/migrate/20250812082721_update_flat_filters_to_version_3.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateFlatFiltersToVersion3 < ActiveRecord::Migration[8.0] + def change + update_view :flat_filters, version: 3, revert_to_version: 2 + end +end diff --git a/db/migrate/20250812132802_add_audit_logs_period_to_organizations.rb b/db/migrate/20250812132802_add_audit_logs_period_to_organizations.rb new file mode 100644 index 0000000..c1de3de --- /dev/null +++ b/db/migrate/20250812132802_add_audit_logs_period_to_organizations.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddAuditLogsPeriodToOrganizations < ActiveRecord::Migration[8.0] + def change + add_column :organizations, + :audit_logs_period, + :integer, + default: 30 + end +end diff --git a/db/migrate/20250813172434_add_priority_to_wallets.rb b/db/migrate/20250813172434_add_priority_to_wallets.rb new file mode 100644 index 0000000..a11497b --- /dev/null +++ b/db/migrate/20250813172434_add_priority_to_wallets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPriorityToWallets < ActiveRecord::Migration[8.0] + def change + add_column :wallets, :priority, :integer, default: 50 + end +end diff --git a/db/migrate/20250813174100_backfill_and_enforce_wallet_priority.rb b/db/migrate/20250813174100_backfill_and_enforce_wallet_priority.rb new file mode 100644 index 0000000..c1afdd8 --- /dev/null +++ b/db/migrate/20250813174100_backfill_and_enforce_wallet_priority.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class BackfillAndEnforceWalletPriority < ActiveRecord::Migration[8.0] + disable_ddl_transaction! # allow constraint validation outside transaction + + def up + Wallet.unscoped.where(priority: nil).update_all(priority: 50) # rubocop:disable Rails/SkipsModelValidations + + # this is for the safe migrations gem + add_check_constraint :wallets, "priority IS NOT NULL", + name: "wallets_priority_not_null", + validate: false + validate_check_constraint :wallets, name: "wallets_priority_not_null" + # this is for the safe migrations gem + change_column_null :wallets, :priority, false + remove_check_constraint :wallets, name: "wallets_priority_not_null" # this is for the safe migrations gem + end + + def down + change_column_null :wallets, :priority, true + change_column_default :wallets, :priority, nil + end +end diff --git a/db/migrate/20250818154000_add_priority_to_wallet_transactions.rb b/db/migrate/20250818154000_add_priority_to_wallet_transactions.rb new file mode 100644 index 0000000..629950b --- /dev/null +++ b/db/migrate/20250818154000_add_priority_to_wallet_transactions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPriorityToWalletTransactions < ActiveRecord::Migration[8.0] + def change + add_column :wallet_transactions, :priority, :integer, default: 50, null: false + end +end diff --git a/db/migrate/20250820200921_remove_entitlement_subscription_entitlements_view.rb b/db/migrate/20250820200921_remove_entitlement_subscription_entitlements_view.rb new file mode 100644 index 0000000..a5eda63 --- /dev/null +++ b/db/migrate/20250820200921_remove_entitlement_subscription_entitlements_view.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class RemoveEntitlementSubscriptionEntitlementsView < ActiveRecord::Migration[8.0] + def change + drop_view :entitlement_subscription_entitlements_view, revert_to_version: 2 + end +end diff --git a/db/migrate/20250821094638_update_timezones.rb b/db/migrate/20250821094638_update_timezones.rb new file mode 100644 index 0000000..a66c96c --- /dev/null +++ b/db/migrate/20250821094638_update_timezones.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +class UpdateTimezones < ActiveRecord::Migration[8.0] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + class Customer < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def change + mapping = { + "Asia/Rangoon" => "Asia/Yangon", + "Europe/Kiev" => "Europe/Kyiv", + "America/Godthab" => "America/Nuuk" + } + + mapping.each do |old_timezone, new_timezone| + # rubocop:disable Rails/SkipsModelValidations + Organization.where(timezone: old_timezone).update_all(timezone: new_timezone) + Customer.where(timezone: old_timezone).update_all(timezone: new_timezone) + Invoice.where(timezone: old_timezone).update_all(timezone: new_timezone) + BillingEntity.where(timezone: old_timezone).update_all(timezone: new_timezone) + # rubocop:enable Rails/SkipsModelValidations + end + end +end diff --git a/db/migrate/20250822100111_add_privilege_removal.rb b/db/migrate/20250822100111_add_privilege_removal.rb new file mode 100644 index 0000000..cb700b5 --- /dev/null +++ b/db/migrate/20250822100111_add_privilege_removal.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +class AddPrivilegeRemoval < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + # Adding foreign key blocks writes, but the feature isn't released yet. Table is empty. + add_reference :entitlement_subscription_feature_removals, :entitlement_privilege, + foreign_key: true, + index: {algorithm: :concurrently}, + type: :uuid, + if_not_exists: true + end + + remove_index :entitlement_subscription_feature_removals, + name: "idx_on_subscription_id_entitlement_feature_id_02bee9883b", + column: [:subscription_id, :entitlement_feature_id], + unique: true, + where: "(deleted_at IS NULL)", + algorithm: :concurrently, + if_exists: true + + change_column_null :entitlement_subscription_feature_removals, :entitlement_feature_id, true + + add_index :entitlement_subscription_feature_removals, [:subscription_id, :entitlement_feature_id], + unique: true, + where: "deleted_at IS NULL", + name: "idx_unique_feature_removal_per_subscription", + algorithm: :concurrently, + if_not_exists: true + + add_index :entitlement_subscription_feature_removals, [:subscription_id, :entitlement_privilege_id], + unique: true, + where: "deleted_at IS NULL", + name: "idx_unique_privilege_removal_per_subscription", + algorithm: :concurrently, + if_not_exists: true + + safety_assured do + # Adding a check constraint, blocks reads and writes while every row is checked, + # but the feature isn't released yet. Table is empty. + add_check_constraint :entitlement_subscription_feature_removals, + "(entitlement_feature_id IS NOT NULL) != (entitlement_privilege_id IS NOT NULL)", + name: "check_exactly_one_feature_or_privilege_removal", + validate: true, + if_not_exists: true + end + end +end diff --git a/db/migrate/20250826081205_create_fixed_charge_events.rb b/db/migrate/20250826081205_create_fixed_charge_events.rb new file mode 100644 index 0000000..68f3bc5 --- /dev/null +++ b/db/migrate/20250826081205_create_fixed_charge_events.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateFixedChargeEvents < ActiveRecord::Migration[8.0] + def change + create_table :fixed_charge_events, id: :uuid do |t| + t.belongs_to :organization, null: false, foreign_key: true, type: :uuid + t.belongs_to :subscription, null: false, foreign_key: true, type: :uuid + t.belongs_to :fixed_charge, null: false, foreign_key: true, type: :uuid + + t.decimal :units, precision: 30, scale: 10, null: false, default: 0.0 + t.datetime :timestamp + t.datetime :deleted_at, index: true + + t.timestamps + end + end +end diff --git a/db/migrate/20250828142848_add_duplicated_to_fees.rb b/db/migrate/20250828142848_add_duplicated_to_fees.rb new file mode 100644 index 0000000..583b53a --- /dev/null +++ b/db/migrate/20250828142848_add_duplicated_to_fees.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddDuplicatedToFees < ActiveRecord::Migration[8.0] + def change + add_column :fees, :duplicated_in_advance, :boolean, default: false + end +end diff --git a/db/migrate/20250828144553_populate_duplicated_in_advance_fees.rb b/db/migrate/20250828144553_populate_duplicated_in_advance_fees.rb new file mode 100644 index 0000000..fd15ef8 --- /dev/null +++ b/db/migrate/20250828144553_populate_duplicated_in_advance_fees.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class PopulateDuplicatedInAdvanceFees < ActiveRecord::Migration[8.0] + def up + sql = <<~SQL + SELECT organization_id, pay_in_advance_event_transaction_id, charge_id, charge_filter_id + FROM fees + WHERE fees.deleted_at IS NULL + AND fees.pay_in_advance = TRUE + AND fees.pay_in_advance_event_transaction_id IS NOT NULL + GROUP BY pay_in_advance_event_transaction_id, charge_id, charge_filter_id, organization_id + HAVING COUNT(*) > 1 + SQL + + ActiveRecord::Base.connection.select_all(sql).rows.each do |row| + Fee.where( + organization_id: row[0], + pay_in_advance_event_transaction_id: row[1], + charge_id: row[2], + charge_filter_id: row[3], + pay_in_advance: true + ).update_all(duplicated_in_advance: true) # rubocop:disable Rails/SkipsModelValidations + end + end +end diff --git a/db/migrate/20250828153138_add_in_advance_index_to_fees.rb b/db/migrate/20250828153138_add_in_advance_index_to_fees.rb new file mode 100644 index 0000000..b508016 --- /dev/null +++ b/db/migrate/20250828153138_add_in_advance_index_to_fees.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class AddInAdvanceIndexToFees < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + add_index :fees, + [:pay_in_advance_event_transaction_id, :charge_id], + unique: true, + name: :idx_pay_in_advance_duplication_guard_charge, + where: "deleted_at IS NULL AND charge_filter_id IS NULL AND pay_in_advance_event_transaction_id IS NOT NULL AND pay_in_advance = true AND duplicated_in_advance = false", + algorithm: :concurrently, + if_not_exists: true + + add_index :fees, + [:pay_in_advance_event_transaction_id, :charge_id, :charge_filter_id], + unique: true, + name: :idx_pay_in_advance_duplication_guard_charge_filter, + where: "deleted_at IS NULL AND charge_filter_id IS NOT NULL AND pay_in_advance_event_transaction_id IS NOT NULL AND pay_in_advance = true AND duplicated_in_advance = false", + algorithm: :concurrently, + if_not_exists: true + + remove_index :fees, name: :idx_on_pay_in_advance_event_transaction_id_charge_i_16302ca167 + end + + def down + add_index :fees, + [:pay_in_advance_event_transaction_id, :charge_id, :charge_filter_id], + unique: true, + name: :idx_on_pay_in_advance_event_transaction_id_charge_i_16302ca167, + where: "created_at > '2025-01-21 00:00:00'::timestamp without time zone AND pay_in_advance_event_transaction_id IS NOT NULL AND pay_in_advance = true", + algorithm: :concurrently, + if_not_exists: true + + remove_index :fees, name: :idx_pay_in_advance_duplication_guard_charge + remove_index :fees, name: :idx_pay_in_advance_duplication_guard_charge_filter + end +end diff --git a/db/migrate/20250901141844_update_document_locale_for_pt_br.rb b/db/migrate/20250901141844_update_document_locale_for_pt_br.rb new file mode 100644 index 0000000..9b11e38 --- /dev/null +++ b/db/migrate/20250901141844_update_document_locale_for_pt_br.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class UpdateDocumentLocaleForPtBr < ActiveRecord::Migration[8.0] + class BillingEntity < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + class Customer < ApplicationRecord + attribute :subscription_invoice_issuing_date_anchor, :string, default: "next_period_start" + attribute :subscription_invoice_issuing_date_adjustment, :string, default: "keep_anchor" + end + + def up + Organization.where(document_locale: "pt_BR").update(document_locale: "pt-BR") + BillingEntity.where(document_locale: "pt_BR").update(document_locale: "pt-BR") + Customer.where(document_locale: "pt_BR").update(document_locale: "pt-BR") + end + + def down + Organization.where(document_locale: "pt-BR").update(document_locale: "pt_BR") + BillingEntity.where(document_locale: "pt-BR").update(document_locale: "pt_BR") + Customer.where(document_locale: "pt-BR").update(document_locale: "pt_BR") + end +end diff --git a/db/migrate/20250901143217_create_ai_conversations.rb b/db/migrate/20250901143217_create_ai_conversations.rb new file mode 100644 index 0000000..0d16e1a --- /dev/null +++ b/db/migrate/20250901143217_create_ai_conversations.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class CreateAiConversations < ActiveRecord::Migration[8.0] + def change + create_table :ai_conversations, id: :uuid do |t| + t.references :organization, type: :uuid, null: false, foreign_key: true + t.references :membership, type: :uuid, null: false, foreign_key: true + + t.string :name, null: false + t.string :mistral_conversation_id + t.timestamps + end + end +end diff --git a/db/migrate/20250903165724_add_name_to_wallet_transactions.rb b/db/migrate/20250903165724_add_name_to_wallet_transactions.rb new file mode 100644 index 0000000..6ebd2f5 --- /dev/null +++ b/db/migrate/20250903165724_add_name_to_wallet_transactions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddNameToWalletTransactions < ActiveRecord::Migration[8.0] + def change + add_column :wallet_transactions, :name, :string, limit: 255 + end +end diff --git a/db/migrate/20250908085959_add_wallet_min_max_limits.rb b/db/migrate/20250908085959_add_wallet_min_max_limits.rb new file mode 100644 index 0000000..76b3464 --- /dev/null +++ b/db/migrate/20250908085959_add_wallet_min_max_limits.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddWalletMinMaxLimits < ActiveRecord::Migration[8.0] + def change + add_column :wallets, :paid_top_up_min_amount_cents, :integer, null: true + add_column :wallets, :paid_top_up_max_amount_cents, :integer, null: true + add_column :recurring_transaction_rules, :ignore_paid_top_up_limits, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20250909125858_add_transaction_name_to_recurring_transaction_rules.rb b/db/migrate/20250909125858_add_transaction_name_to_recurring_transaction_rules.rb new file mode 100644 index 0000000..9479de5 --- /dev/null +++ b/db/migrate/20250909125858_add_transaction_name_to_recurring_transaction_rules.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTransactionNameToRecurringTransactionRules < ActiveRecord::Migration[8.0] + def change + add_column :recurring_transaction_rules, :transaction_name, :string, limit: 255 + end +end diff --git a/db/migrate/20250911111448_add_fixed_charges_boundaries_to_invoice_subscriptions.rb b/db/migrate/20250911111448_add_fixed_charges_boundaries_to_invoice_subscriptions.rb new file mode 100644 index 0000000..3c85665 --- /dev/null +++ b/db/migrate/20250911111448_add_fixed_charges_boundaries_to_invoice_subscriptions.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddFixedChargesBoundariesToInvoiceSubscriptions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :invoice_subscriptions, :fixed_charges_from_datetime, :datetime + add_column :invoice_subscriptions, :fixed_charges_to_datetime, :datetime + + add_index( + :invoice_subscriptions, + %i[ + subscription_id + fixed_charges_from_datetime + fixed_charges_to_datetime + ], + unique: true, + name: :index_uniq_invoice_subscriptions_on_fixed_charges_boundaries, + where: "recurring IS TRUE AND regenerated_invoice_id IS NULL", + algorithm: :concurrently + ) + end +end diff --git a/db/migrate/20250911124033_allow_null_taxes_for_applied_taxes.rb b/db/migrate/20250911124033_allow_null_taxes_for_applied_taxes.rb new file mode 100644 index 0000000..4c6ea00 --- /dev/null +++ b/db/migrate/20250911124033_allow_null_taxes_for_applied_taxes.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AllowNullTaxesForAppliedTaxes < ActiveRecord::Migration[8.0] + def change + safety_assured do + [:invoices_taxes, :fees_taxes].each do |table| + remove_foreign_key table, :taxes + change_column_null table, :tax_id, true + add_foreign_key table, :taxes, on_delete: :nullify + end + end + end +end diff --git a/db/migrate/20250912081524_change_wallet_min_max_to_big_int.rb b/db/migrate/20250912081524_change_wallet_min_max_to_big_int.rb new file mode 100644 index 0000000..66fd6f4 --- /dev/null +++ b/db/migrate/20250912081524_change_wallet_min_max_to_big_int.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ChangeWalletMinMaxToBigInt < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + remove_column :wallets, :paid_top_up_min_amount_cents, :integer + remove_column :wallets, :paid_top_up_max_amount_cents, :integer + end + + add_column :wallets, :paid_top_up_min_amount_cents, :bigint, null: true + add_column :wallets, :paid_top_up_max_amount_cents, :bigint, null: true + end +end diff --git a/db/migrate/20250915100607_add_subscription_with_timestamp_index_to_invoice_subscriptions.rb b/db/migrate/20250915100607_add_subscription_with_timestamp_index_to_invoice_subscriptions.rb new file mode 100644 index 0000000..bbce3b4 --- /dev/null +++ b/db/migrate/20250915100607_add_subscription_with_timestamp_index_to_invoice_subscriptions.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class AddSubscriptionWithTimestampIndexToInvoiceSubscriptions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + INDEX_NAME = "idx_invoice_subscriptions_on_subscription_with_timestamps" + + def up + safety_assured do + execute <<-SQL + CREATE INDEX CONCURRENTLY IF NOT EXISTS #{INDEX_NAME} + ON invoice_subscriptions + USING btree ( + subscription_id, + COALESCE(to_datetime, created_at) DESC + ); + SQL + end + end + + def down + execute <<-SQL + DROP INDEX CONCURRENTLY IF EXISTS #{INDEX_NAME}; + SQL + end +end diff --git a/db/migrate/20250919124037_add_deleted_at_to_taxes.rb b/db/migrate/20250919124037_add_deleted_at_to_taxes.rb new file mode 100644 index 0000000..8fcccc5 --- /dev/null +++ b/db/migrate/20250919124037_add_deleted_at_to_taxes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddDeletedAtToTaxes < ActiveRecord::Migration[8.0] + def change + add_column :taxes, :deleted_at, :datetime + end +end diff --git a/db/migrate/20250919124523_add_deleted_at_to_taxes_unique_code_indexes.rb b/db/migrate/20250919124523_add_deleted_at_to_taxes_unique_code_indexes.rb new file mode 100644 index 0000000..c6b3180 --- /dev/null +++ b/db/migrate/20250919124523_add_deleted_at_to_taxes_unique_code_indexes.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddDeletedAtToTaxesUniqueCodeIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :taxes, [:code, :organization_id], + unique: true, + where: "deleted_at IS NULL", + name: "idx_unique_tax_code_per_organization", + algorithm: :concurrently, + if_not_exists: true + + remove_index :taxes, [:code, :organization_id], + name: "index_taxes_on_code_and_organization_id", + unique: true, + algorithm: :concurrently, + if_exists: true + end +end diff --git a/db/migrate/20250926185510_create_customer_snapshots.rb b/db/migrate/20250926185510_create_customer_snapshots.rb new file mode 100644 index 0000000..f7736db --- /dev/null +++ b/db/migrate/20250926185510_create_customer_snapshots.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class CreateCustomerSnapshots < ActiveRecord::Migration[8.0] + def change + create_table :customer_snapshots, id: :uuid do |t| + t.references :invoice, null: false, foreign_key: true, type: :uuid, index: false + t.references :organization, null: false, foreign_key: true, type: :uuid + + t.string :display_name + t.string :firstname + t.string :lastname + t.string :email + t.string :phone + t.string :url + t.string :tax_identification_number + t.string :applicable_timezone + t.string :address_line1 + t.string :address_line2 + t.string :city + t.string :state + t.string :zipcode + t.string :country + t.string :legal_name + t.string :legal_number + t.string :shipping_address_line1 + t.string :shipping_address_line2 + t.string :shipping_city + t.string :shipping_state + t.string :shipping_zipcode + t.string :shipping_country + + t.datetime :deleted_at, index: true + + t.timestamps + + t.index :invoice_id, + unique: true, + where: "deleted_at IS NULL" + end + end +end diff --git a/db/migrate/20251003171653_add_billing_entity_to_integration_collection_mappings.rb b/db/migrate/20251003171653_add_billing_entity_to_integration_collection_mappings.rb new file mode 100644 index 0000000..844b672 --- /dev/null +++ b/db/migrate/20251003171653_add_billing_entity_to_integration_collection_mappings.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class AddBillingEntityToIntegrationCollectionMappings < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :integration_collection_mappings, + :billing_entity, + type: :uuid, + null: true, + index: {algorithm: :concurrently} + + add_foreign_key :integration_collection_mappings, :billing_entities, on_delete: :cascade, validate: false + add_index :integration_collection_mappings, + [:mapping_type, :integration_id, :billing_entity_id], + where: "billing_entity_id IS NOT NULL", + unique: true, + algorithm: :concurrently, + name: "index_int_collection_mappings_unique_billing_entity_is_not_null" + add_index :integration_collection_mappings, + [:mapping_type, :integration_id, :organization_id], + where: "billing_entity_id IS NULL", + unique: true, + algorithm: :concurrently, + name: "index_int_collection_mappings_unique_billing_entity_is_null" + remove_index :integration_collection_mappings, [:mapping_type, :integration_id], name: "index_int_collection_mappings_on_mapping_type_and_int_id" + end +end diff --git a/db/migrate/20251003171658_validate_billing_entity_foreign_key_on_integration_collection_mappings.rb b/db/migrate/20251003171658_validate_billing_entity_foreign_key_on_integration_collection_mappings.rb new file mode 100644 index 0000000..e4fbe8d --- /dev/null +++ b/db/migrate/20251003171658_validate_billing_entity_foreign_key_on_integration_collection_mappings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateBillingEntityForeignKeyOnIntegrationCollectionMappings < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :integration_collection_mappings, :billing_entities + end +end diff --git a/db/migrate/20251007082809_add_billing_entity_to_integration_mappings.rb b/db/migrate/20251007082809_add_billing_entity_to_integration_mappings.rb new file mode 100644 index 0000000..9f30cc1 --- /dev/null +++ b/db/migrate/20251007082809_add_billing_entity_to_integration_mappings.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddBillingEntityToIntegrationMappings < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :integration_mappings, :billing_entity_id, :uuid, null: true + + add_foreign_key :integration_mappings, :billing_entities, on_delete: :cascade, validate: false + + # Add unique indexes for billing entity mappings and organization-wide mappings + add_index :integration_mappings, [:mappable_type, :mappable_id, :integration_id, :billing_entity_id], + where: "billing_entity_id IS NOT NULL", + unique: true, + algorithm: :concurrently, + name: "index_integration_mappings_unique_billing_entity_id_is_not_null" + + add_index :integration_mappings, [:mappable_type, :mappable_id, :integration_id, :organization_id], + where: "billing_entity_id IS NULL", + unique: true, + algorithm: :concurrently, + name: "index_integration_mappings_unique_billing_entity_id_is_null" + end +end diff --git a/db/migrate/20251007082822_validate_billing_entity_foreign_key_on_integration_mappings.rb b/db/migrate/20251007082822_validate_billing_entity_foreign_key_on_integration_mappings.rb new file mode 100644 index 0000000..0b9ef86 --- /dev/null +++ b/db/migrate/20251007082822_validate_billing_entity_foreign_key_on_integration_mappings.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateBillingEntityForeignKeyOnIntegrationMappings < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :integration_mappings, :billing_entities + end +end diff --git a/db/migrate/20251007103421_drop_customer_snapshots.rb b/db/migrate/20251007103421_drop_customer_snapshots.rb new file mode 100644 index 0000000..c60000f --- /dev/null +++ b/db/migrate/20251007103421_drop_customer_snapshots.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class DropCustomerSnapshots < ActiveRecord::Migration[8.0] + def up + drop_table :customer_snapshots + end +end diff --git a/db/migrate/20251007160309_create_payment_methods.rb b/db/migrate/20251007160309_create_payment_methods.rb new file mode 100644 index 0000000..184494f --- /dev/null +++ b/db/migrate/20251007160309_create_payment_methods.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreatePaymentMethods < ActiveRecord::Migration[8.0] + def change + create_table :payment_methods, id: :uuid do |t| + t.references :organization, type: :uuid, null: false, foreign_key: true + t.references :customer, type: :uuid, null: false, foreign_key: true + t.references :payment_provider, type: :uuid, foreign_key: true + t.references :payment_provider_customer, type: :uuid, foreign_key: true + + t.string :provider_method_id, null: false + t.string :provider_method_type, null: true, index: true + t.boolean :is_default, default: false, null: false + t.datetime :deleted_at + t.jsonb :details, null: false, default: {} + + t.timestamps + + t.index [:customer_id], where: "is_default = TRUE AND deleted_at IS NULL", name: "unique_default_payment_method_per_customer", unique: true + end + end +end diff --git a/db/migrate/20251010073504_fix_charges_invoiceable.rb b/db/migrate/20251010073504_fix_charges_invoiceable.rb new file mode 100644 index 0000000..a883278 --- /dev/null +++ b/db/migrate/20251010073504_fix_charges_invoiceable.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class FixChargesInvoiceable < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute <<~SQL.squish + UPDATE charges + SET invoiceable = TRUE + WHERE invoiceable = FALSE + AND pay_in_advance = FALSE; + SQL + end + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/migrate/20251010092830_add_fixed_charge_id_to_adjusted_fees.rb b/db/migrate/20251010092830_add_fixed_charge_id_to_adjusted_fees.rb new file mode 100644 index 0000000..696d0ea --- /dev/null +++ b/db/migrate/20251010092830_add_fixed_charge_id_to_adjusted_fees.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddFixedChargeIdToAdjustedFees < ActiveRecord::Migration[8.0] + def change + add_column :adjusted_fees, :fixed_charge_id, :uuid + add_foreign_key :adjusted_fees, :fixed_charges, column: :fixed_charge_id, validate: false + end +end diff --git a/db/migrate/20251013101230_remove_uniqueness_index_on_fixed_charges_boundaries_for_invoice_subscription.rb b/db/migrate/20251013101230_remove_uniqueness_index_on_fixed_charges_boundaries_for_invoice_subscription.rb new file mode 100644 index 0000000..cb50568 --- /dev/null +++ b/db/migrate/20251013101230_remove_uniqueness_index_on_fixed_charges_boundaries_for_invoice_subscription.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class RemoveUniquenessIndexOnFixedChargesBoundariesForInvoiceSubscription < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + def up + safety_assured do + remove_index :invoice_subscriptions, name: :index_uniq_invoice_subscriptions_on_fixed_charges_boundaries + add_index :invoice_subscriptions, + [:subscription_id, :fixed_charges_from_datetime, :fixed_charges_to_datetime], + name: :index_invoice_subscriptions_on_fixed_charges_boundaries, + where: "recurring IS TRUE AND regenerated_invoice_id IS NULL", + algorithm: :concurrently + end + end +end diff --git a/db/migrate/20251020073334_update_index_on_cached_aggregations.rb b/db/migrate/20251020073334_update_index_on_cached_aggregations.rb new file mode 100644 index 0000000..be3f268 --- /dev/null +++ b/db/migrate/20251020073334_update_index_on_cached_aggregations.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class UpdateIndexOnCachedAggregations < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + add_index( + :cached_aggregations, + [:event_transaction_id, :external_subscription_id, :charge_id, :timestamp], + include: [:organization_id, :grouped_by], + name: :idx_aggregation_lookup_with_transaction_id, + algorithm: :concurrently + ) + end + end +end diff --git a/db/migrate/20251020074349_remove_old_index_on_cached_aggregations.rb b/db/migrate/20251020074349_remove_old_index_on_cached_aggregations.rb new file mode 100644 index 0000000..ec5829b --- /dev/null +++ b/db/migrate/20251020074349_remove_old_index_on_cached_aggregations.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class RemoveOldIndexOnCachedAggregations < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + remove_index :cached_aggregations, name: :idx_on_timestamp_charge_id_external_subscription_id + remove_index :cached_aggregations, name: :index_cached_aggregations_on_organization_id + end + end + + def down + safety_assured do + add_index :cached_aggregations, + %i[timestamp charge_id external_subscription_id], + algorithm: :concurrently, + name: :idx_on_timestamp_charge_id_external_subscription_id + + add_index :cached_aggregations, + %i[organization_id], + algorithm: :concurrently, + name: :index_cached_aggregations_on_organization_id + end + end +end diff --git a/db/migrate/20251020090137_remove_event_id_from_cached_aggregation.rb b/db/migrate/20251020090137_remove_event_id_from_cached_aggregation.rb new file mode 100644 index 0000000..7155878 --- /dev/null +++ b/db/migrate/20251020090137_remove_event_id_from_cached_aggregation.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class RemoveEventIdFromCachedAggregation < ActiveRecord::Migration[8.0] + def up + # This migration has been removed to allow safe upgrade for self hosted instances + # It will be replaced with an other migration after a first phase of cleanup to make sure that no + # code relies on the event_id column anymore + unless Rails.env.production? + safety_assured do + remove_column :cached_aggregations, :event_id + end + end + end +end diff --git a/db/migrate/20251020142629_add_index_on_cached_aggregation_created_at.rb b/db/migrate/20251020142629_add_index_on_cached_aggregation_created_at.rb new file mode 100644 index 0000000..54b0bf6 --- /dev/null +++ b/db/migrate/20251020142629_add_index_on_cached_aggregation_created_at.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +class AddIndexOnCachedAggregationCreatedAt < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + remove_index :cached_aggregations, name: :idx_aggregation_lookup_with_transaction_id, if_exists: true, algorithm: :concurrently + + safety_assured do + add_index( + :cached_aggregations, + [:organization_id, :external_subscription_id, :charge_id, :timestamp, :created_at], + order: {timestamp: :desc, created_at: :desc}, + include: [:grouped_by, :charge_filter_id, :event_transaction_id], + name: :idx_cached_aggregation_filtered_lookup, + algorithm: :concurrently + ) + end + end + + def down + safety_assured do + add_index( + :cached_aggregations, + [:event_transaction_id, :external_subscription_id, :charge_id, :timestamp], + include: [:organization_id, :grouped_by], + name: :idx_aggregation_lookup_with_transaction_id, + algorithm: :concurrently + ) + + remove_index :cached_aggregations, name: :idx_cached_agg_comprehensive, algorithm: :concurrently + end + end +end diff --git a/db/migrate/20251021073412_add_event_code_index.rb b/db/migrate/20251021073412_add_event_code_index.rb new file mode 100644 index 0000000..b8ce9ee --- /dev/null +++ b/db/migrate/20251021073412_add_event_code_index.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddEventCodeIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index( + :events, + [:external_subscription_id, :organization_id, :timestamp], + include: [:code], + name: :idx_events_for_distinct_codes, + where: "deleted_at IS NULL", + algorithm: :concurrently, + if_not_exists: true + ) + end +end diff --git a/db/migrate/20251021083946_delete_unused_events_indexes.rb b/db/migrate/20251021083946_delete_unused_events_indexes.rb new file mode 100644 index 0000000..9889232 --- /dev/null +++ b/db/migrate/20251021083946_delete_unused_events_indexes.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class DeleteUnusedEventsIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + remove_index :events, name: :index_events_on_external_subscription_id_with_included, if_exists: true, algorithm: :concurrently + remove_index :events, name: :index_events_on_properties, if_exists: true, algorithm: :concurrently + remove_index :events, name: :index_events_on_customer_id, if_exists: true, algorithm: :concurrently + remove_index :events, name: :index_events_on_deleted_at, if_exists: true, algorithm: :concurrently + remove_index :events, name: :index_events_on_external_subscription_id_precise_amount, if_exists: true, algorithm: :concurrently + end +end diff --git a/db/migrate/20251021105732_update_exports_customers_to_version_3.rb b/db/migrate/20251021105732_update_exports_customers_to_version_3.rb new file mode 100644 index 0000000..3c8126b --- /dev/null +++ b/db/migrate/20251021105732_update_exports_customers_to_version_3.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsCustomersToVersion3 < ActiveRecord::Migration[8.0] + def change + update_view :exports_customers, version: 3, revert_to_version: 2 + end +end diff --git a/db/migrate/20251021114023_update_exports_customers_to_version_4.rb b/db/migrate/20251021114023_update_exports_customers_to_version_4.rb new file mode 100644 index 0000000..72d0c7f --- /dev/null +++ b/db/migrate/20251021114023_update_exports_customers_to_version_4.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsCustomersToVersion4 < ActiveRecord::Migration[8.0] + def change + update_view :exports_customers, version: 4, revert_to_version: 3 + end +end diff --git a/db/migrate/20251022104121_update_exports_customers_to_version_5.rb b/db/migrate/20251022104121_update_exports_customers_to_version_5.rb new file mode 100644 index 0000000..dd00a62 --- /dev/null +++ b/db/migrate/20251022104121_update_exports_customers_to_version_5.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsCustomersToVersion5 < ActiveRecord::Migration[8.0] + def change + update_view :exports_customers, version: 5, revert_to_version: 4 + end +end diff --git a/db/migrate/20251023153834_add_payment_method_to_subscriptions.rb b/db/migrate/20251023153834_add_payment_method_to_subscriptions.rb new file mode 100644 index 0000000..0a1f5d6 --- /dev/null +++ b/db/migrate/20251023153834_add_payment_method_to_subscriptions.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddPaymentMethodToSubscriptions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + create_enum :payment_method_types, %w[provider manual] + + safety_assured do + change_table :subscriptions, bulk: true do |t| + t.references :payment_method, type: :uuid, null: true, index: {algorithm: :concurrently} + t.enum :payment_method_type, enum_type: "payment_method_types", default: "provider", null: false + end + end + add_foreign_key :subscriptions, :payment_methods, validate: false + end +end diff --git a/db/migrate/20251023154123_add_payment_method_to_recurring_transaction_rules.rb b/db/migrate/20251023154123_add_payment_method_to_recurring_transaction_rules.rb new file mode 100644 index 0000000..86d187a --- /dev/null +++ b/db/migrate/20251023154123_add_payment_method_to_recurring_transaction_rules.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddPaymentMethodToRecurringTransactionRules < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + change_table :recurring_transaction_rules, bulk: true do |t| + t.references :payment_method, type: :uuid, null: true, index: {algorithm: :concurrently} + t.enum :payment_method_type, enum_type: "payment_method_types", default: "provider", null: false + end + end + add_foreign_key :recurring_transaction_rules, :payment_methods, validate: false + end +end diff --git a/db/migrate/20251023154344_add_payment_method_to_wallets.rb b/db/migrate/20251023154344_add_payment_method_to_wallets.rb new file mode 100644 index 0000000..9e08db9 --- /dev/null +++ b/db/migrate/20251023154344_add_payment_method_to_wallets.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddPaymentMethodToWallets < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + change_table :wallets, bulk: true do |t| + t.references :payment_method, type: :uuid, null: true, index: {algorithm: :concurrently} + t.enum :payment_method_type, enum_type: "payment_method_types", default: "provider", null: false + end + end + add_foreign_key :wallets, :payment_methods, validate: false + end +end diff --git a/db/migrate/20251024130659_add_e_invoincing_to_billing_entities.rb b/db/migrate/20251024130659_add_e_invoincing_to_billing_entities.rb new file mode 100644 index 0000000..80c7d7c --- /dev/null +++ b/db/migrate/20251024130659_add_e_invoincing_to_billing_entities.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEInvoincingToBillingEntities < ActiveRecord::Migration[8.0] + def change + add_column :billing_entities, :einvoicing, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20251024200950_add_e_invoicing_xm_lto_invoices.rb b/db/migrate/20251024200950_add_e_invoicing_xm_lto_invoices.rb new file mode 100644 index 0000000..10a642e --- /dev/null +++ b/db/migrate/20251024200950_add_e_invoicing_xm_lto_invoices.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEInvoicingXmLtoInvoices < ActiveRecord::Migration[8.0] + def change + add_column :invoices, :xml_file, :string + end +end diff --git a/db/migrate/20251029140035_add_e_invoicing_xml_to_credit_notes.rb b/db/migrate/20251029140035_add_e_invoicing_xml_to_credit_notes.rb new file mode 100644 index 0000000..d4b0b1b --- /dev/null +++ b/db/migrate/20251029140035_add_e_invoicing_xml_to_credit_notes.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEInvoicingXmlToCreditNotes < ActiveRecord::Migration[8.0] + def change + add_column :credit_notes, :xml_file, :string + end +end diff --git a/db/migrate/20251031112354_create_pending_vies_checks.rb b/db/migrate/20251031112354_create_pending_vies_checks.rb new file mode 100644 index 0000000..e460970 --- /dev/null +++ b/db/migrate/20251031112354_create_pending_vies_checks.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class CreatePendingViesChecks < ActiveRecord::Migration[8.0] + def change + create_table :pending_vies_checks, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid, index: true + t.references :billing_entity, null: false, foreign_key: true, type: :uuid, index: true + t.references :customer, null: false, foreign_key: true, type: :uuid, index: {unique: true} + t.integer :attempts_count, default: 0, null: false + t.datetime :last_attempt_at + t.string :tax_identification_number + t.string :last_error_type + t.text :last_error_message + + t.timestamps + end + end +end diff --git a/db/migrate/20251106072629_add_subscription_invoice_issuing_date_anchor_and_adjustment_to_billing_entity.rb b/db/migrate/20251106072629_add_subscription_invoice_issuing_date_anchor_and_adjustment_to_billing_entity.rb new file mode 100644 index 0000000..787e7d2 --- /dev/null +++ b/db/migrate/20251106072629_add_subscription_invoice_issuing_date_anchor_and_adjustment_to_billing_entity.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddSubscriptionInvoiceIssuingDateAnchorAndAdjustmentToBillingEntity < ActiveRecord::Migration[8.0] + def change + create_enum :subscription_invoice_issuing_date_anchors, %w[current_period_end next_period_start] + create_enum :subscription_invoice_issuing_date_adjustments, %w[keep_anchor align_with_finalization_date] + + add_column :billing_entities, + :subscription_invoice_issuing_date_anchor, + :enum, + enum_type: :subscription_invoice_issuing_date_anchors, + default: "next_period_start", + null: false + + add_column :billing_entities, + :subscription_invoice_issuing_date_adjustment, + :enum, + enum_type: :subscription_invoice_issuing_date_adjustments, + default: "align_with_finalization_date", + null: false + end +end diff --git a/db/migrate/20251106091730_add_index_events_on_created_at.rb b/db/migrate/20251106091730_add_index_events_on_created_at.rb new file mode 100644 index 0000000..06c8b15 --- /dev/null +++ b/db/migrate/20251106091730_add_index_events_on_created_at.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddIndexEventsOnCreatedAt < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index( + :events, + :created_at, + name: :index_events_on_created_at, + where: "deleted_at IS NULL", + algorithm: :concurrently, + if_not_exists: true + ) + end +end diff --git a/db/migrate/20251106092231_update_events_lookup_index.rb b/db/migrate/20251106092231_update_events_lookup_index.rb new file mode 100644 index 0000000..5815b43 --- /dev/null +++ b/db/migrate/20251106092231_update_events_lookup_index.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class UpdateEventsLookupIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + add_index( + :events, + %w[external_subscription_id organization_id code timestamp], + include: [:properties], + name: :idx_events_billing_lookup, + where: "deleted_at IS NULL", + algorithm: :concurrently, + if_not_exists: true + ) + end + end +end diff --git a/db/migrate/20251106093323_remove_events_unused_index.rb b/db/migrate/20251106093323_remove_events_unused_index.rb new file mode 100644 index 0000000..defc549 --- /dev/null +++ b/db/migrate/20251106093323_remove_events_unused_index.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class RemoveEventsUnusedIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + remove_index( + :events, + name: :idx_events_on_external_sub_id_and_org_id_and_code_and_timestamp, + algorithm: :concurrently, + if_exists: true + ) + end +end diff --git a/db/migrate/20251107102548_add_subscription_invoice_issuing_date_anchor_and_adjustments_to_customer.rb b/db/migrate/20251107102548_add_subscription_invoice_issuing_date_anchor_and_adjustments_to_customer.rb new file mode 100644 index 0000000..434ebe2 --- /dev/null +++ b/db/migrate/20251107102548_add_subscription_invoice_issuing_date_anchor_and_adjustments_to_customer.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class AddSubscriptionInvoiceIssuingDateAnchorAndAdjustmentsToCustomer < ActiveRecord::Migration[8.0] + def change + add_column :customers, + :subscription_invoice_issuing_date_anchor, + :enum, + enum_type: :subscription_invoice_issuing_date_anchors, + null: true + + add_column :customers, + :subscription_invoice_issuing_date_adjustment, + :enum, + enum_type: :subscription_invoice_issuing_date_adjustments, + null: true + end +end diff --git a/db/migrate/20251110191233_update_exports_customers_to_version_6.rb b/db/migrate/20251110191233_update_exports_customers_to_version_6.rb new file mode 100644 index 0000000..9240c59 --- /dev/null +++ b/db/migrate/20251110191233_update_exports_customers_to_version_6.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsCustomersToVersion6 < ActiveRecord::Migration[8.0] + def change + update_view :exports_customers, version: 6, revert_to_version: 5 + end +end diff --git a/db/migrate/20251112112544_add_payment_method_to_wallet_transactions.rb b/db/migrate/20251112112544_add_payment_method_to_wallet_transactions.rb new file mode 100644 index 0000000..a272f58 --- /dev/null +++ b/db/migrate/20251112112544_add_payment_method_to_wallet_transactions.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddPaymentMethodToWalletTransactions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + change_table :wallet_transactions, bulk: true do |t| + t.references :payment_method, type: :uuid, null: true, index: {algorithm: :concurrently} + t.enum :payment_method_type, enum_type: "payment_method_types", default: "provider", null: false + end + end + add_foreign_key :wallet_transactions, :payment_methods, validate: false + end +end diff --git a/db/migrate/20251121113600_add_awaiting_wallet_refresh_to_customers.rb b/db/migrate/20251121113600_add_awaiting_wallet_refresh_to_customers.rb new file mode 100644 index 0000000..749b1fc --- /dev/null +++ b/db/migrate/20251121113600_add_awaiting_wallet_refresh_to_customers.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddAwaitingWalletRefreshToCustomers < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :customers, :awaiting_wallet_refresh, :boolean, default: false, null: false + add_index :customers, :awaiting_wallet_refresh, algorithm: :concurrently + end +end diff --git a/db/migrate/20251121143459_add_error_code_to_payments.rb b/db/migrate/20251121143459_add_error_code_to_payments.rb new file mode 100644 index 0000000..dabaf1f --- /dev/null +++ b/db/migrate/20251121143459_add_error_code_to_payments.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddErrorCodeToPayments < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :payments, :error_code, :string + + add_index :payments, + %i[payable_id payable_type error_code], + algorithm: :concurrently + end +end diff --git a/db/migrate/20251125174110_update_exports_customers_to_version_7.rb b/db/migrate/20251125174110_update_exports_customers_to_version_7.rb new file mode 100644 index 0000000..2bd7c49 --- /dev/null +++ b/db/migrate/20251125174110_update_exports_customers_to_version_7.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsCustomersToVersion7 < ActiveRecord::Migration[8.0] + def change + update_view :exports_customers, version: 7, revert_to_version: 6 + end +end diff --git a/db/migrate/20251126134516_update_exports_charges_to_version_3.rb b/db/migrate/20251126134516_update_exports_charges_to_version_3.rb new file mode 100644 index 0000000..a09c27c --- /dev/null +++ b/db/migrate/20251126134516_update_exports_charges_to_version_3.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsChargesToVersion3 < ActiveRecord::Migration[8.0] + def change + update_view :exports_charges, version: 3, revert_to_version: 2 + end +end diff --git a/db/migrate/20251126135708_create_exports_billing_entities.rb b/db/migrate/20251126135708_create_exports_billing_entities.rb new file mode 100644 index 0000000..3f75aab --- /dev/null +++ b/db/migrate/20251126135708_create_exports_billing_entities.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsBillingEntities < ActiveRecord::Migration[8.0] + def change + create_view :exports_billing_entities + end +end diff --git a/db/migrate/20251126145839_create_subscriptions_invoice_custom_sections.rb b/db/migrate/20251126145839_create_subscriptions_invoice_custom_sections.rb new file mode 100644 index 0000000..74d3310 --- /dev/null +++ b/db/migrate/20251126145839_create_subscriptions_invoice_custom_sections.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateSubscriptionsInvoiceCustomSections < ActiveRecord::Migration[8.0] + def change + create_table :subscriptions_invoice_custom_sections, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid, index: true + t.references :subscription, null: false, foreign_key: true, type: :uuid, index: true + t.references :invoice_custom_section, null: false, foreign_key: true, type: :uuid, index: true + + t.timestamps + + t.index %i[subscription_id invoice_custom_section_id], + unique: true, + name: "index_subscriptions_invoice_custom_sections_unique" + end + end +end diff --git a/db/migrate/20251126164626_create_wallets_invoice_custom_sections.rb b/db/migrate/20251126164626_create_wallets_invoice_custom_sections.rb new file mode 100644 index 0000000..75b62e7 --- /dev/null +++ b/db/migrate/20251126164626_create_wallets_invoice_custom_sections.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateWalletsInvoiceCustomSections < ActiveRecord::Migration[8.0] + def change + create_table :wallets_invoice_custom_sections, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid, index: true + t.references :wallet, null: false, foreign_key: true, type: :uuid, index: true + t.references :invoice_custom_section, null: false, foreign_key: true, type: :uuid, index: true + + t.timestamps + + t.index %i[wallet_id invoice_custom_section_id], + unique: true, + name: "index_wallets_invoice_custom_sections_unique" + end + end +end diff --git a/db/migrate/20251126165406_create_recurring_transaction_rules_invoice_custom_sections.rb b/db/migrate/20251126165406_create_recurring_transaction_rules_invoice_custom_sections.rb new file mode 100644 index 0000000..8028004 --- /dev/null +++ b/db/migrate/20251126165406_create_recurring_transaction_rules_invoice_custom_sections.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateRecurringTransactionRulesInvoiceCustomSections < ActiveRecord::Migration[8.0] + def change + create_table :recurring_transaction_rules_invoice_custom_sections, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid, index: true + t.references :recurring_transaction_rule, null: false, foreign_key: true, type: :uuid, index: true + t.references :invoice_custom_section, null: false, foreign_key: true, type: :uuid, index: true + + t.timestamps + + t.index %i[recurring_transaction_rule_id invoice_custom_section_id], + unique: true, + name: "index_rtr_invoice_custom_sections_unique" + end + end +end diff --git a/db/migrate/20251126170127_create_wallet_transactions_invoice_custom_sections.rb b/db/migrate/20251126170127_create_wallet_transactions_invoice_custom_sections.rb new file mode 100644 index 0000000..4ea88d8 --- /dev/null +++ b/db/migrate/20251126170127_create_wallet_transactions_invoice_custom_sections.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateWalletTransactionsInvoiceCustomSections < ActiveRecord::Migration[8.0] + def change + create_table :wallet_transactions_invoice_custom_sections, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid, index: true + t.references :wallet_transaction, null: false, foreign_key: true, type: :uuid, index: true + t.references :invoice_custom_section, null: false, foreign_key: true, type: :uuid, index: true + + t.timestamps + + t.index %i[wallet_transaction_id invoice_custom_section_id], + unique: true, + name: "index_wt_invoice_custom_sections_unique" + end + end +end diff --git a/db/migrate/20251126171210_add_skip_invoice_custom_sections_flags.rb b/db/migrate/20251126171210_add_skip_invoice_custom_sections_flags.rb new file mode 100644 index 0000000..c7d2dbd --- /dev/null +++ b/db/migrate/20251126171210_add_skip_invoice_custom_sections_flags.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddSkipInvoiceCustomSectionsFlags < ActiveRecord::Migration[8.0] + def change + add_column :subscriptions, :skip_invoice_custom_sections, :boolean, default: false, null: false + add_column :recurring_transaction_rules, :skip_invoice_custom_sections, :boolean, default: false, null: false + add_column :wallet_transactions, :skip_invoice_custom_sections, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20251127123135_create_exports_usage_thresholds.rb b/db/migrate/20251127123135_create_exports_usage_thresholds.rb new file mode 100644 index 0000000..5f25148 --- /dev/null +++ b/db/migrate/20251127123135_create_exports_usage_thresholds.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsUsageThresholds < ActiveRecord::Migration[8.0] + def change + create_view :exports_usage_thresholds + end +end diff --git a/db/migrate/20251127145819_update_exports_usage_thresholds_to_version_2.rb b/db/migrate/20251127145819_update_exports_usage_thresholds_to_version_2.rb new file mode 100644 index 0000000..4bf3d67 --- /dev/null +++ b/db/migrate/20251127145819_update_exports_usage_thresholds_to_version_2.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsUsageThresholdsToVersion2 < ActiveRecord::Migration[8.0] + def change + update_view :exports_usage_thresholds, version: 2, revert_to_version: 1 + end +end diff --git a/db/migrate/20251128102055_add_skip_invoice_custom_sections_to_wallets.rb b/db/migrate/20251128102055_add_skip_invoice_custom_sections_to_wallets.rb new file mode 100644 index 0000000..8a98342 --- /dev/null +++ b/db/migrate/20251128102055_add_skip_invoice_custom_sections_to_wallets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddSkipInvoiceCustomSectionsToWallets < ActiveRecord::Migration[8.0] + def change + add_column :wallets, :skip_invoice_custom_sections, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20251201084648_add_expected_finalization_date_to_invoices.rb b/db/migrate/20251201084648_add_expected_finalization_date_to_invoices.rb new file mode 100644 index 0000000..8ead0b6 --- /dev/null +++ b/db/migrate/20251201084648_add_expected_finalization_date_to_invoices.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddExpectedFinalizationDateToInvoices < ActiveRecord::Migration[8.0] + def change + add_column :invoices, :expected_finalization_date, :date + end +end diff --git a/db/migrate/20251201094057_create_item_metadata.rb b/db/migrate/20251201094057_create_item_metadata.rb new file mode 100644 index 0000000..d29204d --- /dev/null +++ b/db/migrate/20251201094057_create_item_metadata.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateItemMetadata < ActiveRecord::Migration[8.0] + def change + create_table :item_metadata, id: :uuid do |t| + t.references :organization, + null: false, + type: :uuid, + foreign_key: {on_delete: :cascade}, + comment: "Reference to the organization" + t.string :owner_type, null: false, comment: "Polymorphic owner type" + t.uuid :owner_id, null: false, comment: "Polymorphic owner id" + t.jsonb :value, null: false, default: {}, comment: "item_metadata key-value pairs" + t.timestamps + + t.check_constraint "jsonb_typeof(value) = 'object'", name: "item_metadata_value_must_be_json_object" + + t.index [:owner_type, :owner_id], unique: true + t.index :value, name: "index_item_metadata_on_value", using: :gin + end + end +end diff --git a/db/migrate/20251202141759_remove_organization_clickhouse_live_aggregation.rb b/db/migrate/20251202141759_remove_organization_clickhouse_live_aggregation.rb new file mode 100644 index 0000000..b79ea14 --- /dev/null +++ b/db/migrate/20251202141759_remove_organization_clickhouse_live_aggregation.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RemoveOrganizationClickhouseLiveAggregation < ActiveRecord::Migration[8.0] + def change + organizations = Organization.where("? = ANY(premium_integrations)", "clickhouse_live_aggregation") + + organizations.find_each do |organization| + organization.update!(premium_integrations: organization.premium_integrations - ["clickhouse_live_aggregation"]) + end + end +end diff --git a/db/migrate/20251204101451_add_pre_filter_events_to_organizations.rb b/db/migrate/20251204101451_add_pre_filter_events_to_organizations.rb new file mode 100644 index 0000000..1ec3fbf --- /dev/null +++ b/db/migrate/20251204101451_add_pre_filter_events_to_organizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddPreFilterEventsToOrganizations < ActiveRecord::Migration[8.0] + def change + add_column :organizations, :pre_filter_events, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20251204142205_add_indexes_to_webhooks.rb b/db/migrate/20251204142205_add_indexes_to_webhooks.rb new file mode 100644 index 0000000..0376e98 --- /dev/null +++ b/db/migrate/20251204142205_add_indexes_to_webhooks.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class AddIndexesToWebhooks < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index( + :webhooks, + [:webhook_endpoint_id, :updated_at, :created_at], + name: :index_webhooks_on_endpoint_and_timestamps, + algorithm: :concurrently + ) + + add_index( + :webhooks, + [:webhook_endpoint_id, :status, :updated_at], + name: :index_webhooks_on_endpoint_status_and_timestamps, + algorithm: :concurrently + ) + end +end diff --git a/db/migrate/20251210133225_add_subscription_id_to_usage_threshold.rb b/db/migrate/20251210133225_add_subscription_id_to_usage_threshold.rb new file mode 100644 index 0000000..ebdc447 --- /dev/null +++ b/db/migrate/20251210133225_add_subscription_id_to_usage_threshold.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddSubscriptionIdToUsageThreshold < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + add_reference :usage_thresholds, :subscription, + foreign_key: true, + index: {algorithm: :concurrently}, + type: :uuid, + if_not_exists: true + end + end +end diff --git a/db/migrate/20251210133246_change_indexes_for_usage_thresholds.rb b/db/migrate/20251210133246_change_indexes_for_usage_thresholds.rb new file mode 100644 index 0000000..ced40ba --- /dev/null +++ b/db/migrate/20251210133246_change_indexes_for_usage_thresholds.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +class ChangeIndexesForUsageThresholds < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + remove_index :usage_thresholds, + name: "idx_on_amount_cents_plan_id_recurring_888044d66b", + column: %w[amount_cents plan_id recurring], + unique: true, + where: "(deleted_at IS NULL)", + algorithm: :concurrently, + if_exists: true + + remove_index :usage_thresholds, + name: "index_usage_thresholds_on_plan_id_and_recurring", + column: %w[plan_id recurring], + unique: true, + where: "((recurring IS TRUE) AND (deleted_at IS NULL))", + algorithm: :concurrently, + if_exists: true + + change_column_null :usage_thresholds, :plan_id, true + + add_index :usage_thresholds, %w[amount_cents plan_id recurring], + unique: true, + where: "deleted_at IS NULL AND plan_id IS NOT NULL", + name: "idx_usage_thresholds_on_amount_plan_recurring", + algorithm: :concurrently, + if_not_exists: true + + add_index :usage_thresholds, %w[amount_cents subscription_id recurring], + unique: true, + where: "deleted_at IS NULL AND subscription_id IS NOT NULL", + name: "idx_usage_thresholds_on_amount_subscription_recurring", + algorithm: :concurrently, + if_not_exists: true + + add_index :usage_thresholds, %w[plan_id recurring], + unique: true, + where: "recurring IS TRUE AND deleted_at IS NULL AND plan_id IS NOT NULL", + name: "idx_usage_thresholds_plan_recurring", + algorithm: :concurrently, + if_not_exists: true + + add_index :usage_thresholds, %w[subscription_id recurring], + unique: true, + where: "recurring IS TRUE AND deleted_at IS NULL AND subscription_id IS NOT NULL", + name: "idx_usage_thresholds_subscription_recurring", + algorithm: :concurrently, + if_not_exists: true + + safety_assured do + add_check_constraint :usage_thresholds, + "(plan_id IS NOT NULL) != (subscription_id IS NOT NULL)", + name: "usage_thresholds_check_exactly_one_parent", + validate: true, + if_not_exists: true + end + end +end diff --git a/db/migrate/20251210151531_update_exports_wallet_transactions_to_version_3.rb b/db/migrate/20251210151531_update_exports_wallet_transactions_to_version_3.rb new file mode 100644 index 0000000..b6d657b --- /dev/null +++ b/db/migrate/20251210151531_update_exports_wallet_transactions_to_version_3.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsWalletTransactionsToVersion3 < ActiveRecord::Migration[8.0] + def change + update_view :exports_wallet_transactions, version: 3, revert_to_version: 2 + end +end diff --git a/db/migrate/20251211154309_migrate_wallet_ready_to_be_refreshed.rb b/db/migrate/20251211154309_migrate_wallet_ready_to_be_refreshed.rb new file mode 100644 index 0000000..af46e43 --- /dev/null +++ b/db/migrate/20251211154309_migrate_wallet_ready_to_be_refreshed.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +class MigrateWalletReadyToBeRefreshed < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute <<~SQL.squish + UPDATE customers + SET awaiting_wallet_refresh = TRUE + FROM wallets + WHERE wallets.customer_id = customers.id + AND wallets.ready_to_be_refreshed = TRUE + AND wallets.status = 0; + SQL + + execute <<~SQL.squish + UPDATE wallets + SET ready_to_be_refreshed = FALSE + WHERE wallets.ready_to_be_refreshed = TRUE; + SQL + end + end + + def down + safety_assured do + execute <<~SQL.squish + UPDATE wallets + SET ready_to_be_refreshed = TRUE + FROM customers + WHERE wallets.customer_id = customers.id + AND customers.awaiting_wallet_refresh = TRUE; + SQL + + execute <<~SQL.squish + UPDATE customers + SET awaiting_wallet_refresh = FALSE + WHERE customers.awaiting_wallet_refresh = TRUE; + SQL + end + end +end diff --git a/db/migrate/20251216100247_add_disable_progressive_billing_on_subscription.rb b/db/migrate/20251216100247_add_disable_progressive_billing_on_subscription.rb new file mode 100644 index 0000000..5e58c60 --- /dev/null +++ b/db/migrate/20251216100247_add_disable_progressive_billing_on_subscription.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddDisableProgressiveBillingOnSubscription < ActiveRecord::Migration[8.0] + def change + add_column :subscriptions, :progressive_billing_disabled, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20251219115429_create_exports_integration_customers.rb b/db/migrate/20251219115429_create_exports_integration_customers.rb new file mode 100644 index 0000000..7befc49 --- /dev/null +++ b/db/migrate/20251219115429_create_exports_integration_customers.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsIntegrationCustomers < ActiveRecord::Migration[8.0] + def change + create_view :exports_integration_customers + end +end diff --git a/db/migrate/20251221174251_create_roles.rb b/db/migrate/20251221174251_create_roles.rb new file mode 100644 index 0000000..89a45ce --- /dev/null +++ b/db/migrate/20251221174251_create_roles.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +class CreateRoles < ActiveRecord::Migration[8.0] + def up + create_table :roles, id: :uuid do |t| + t.references :organization, type: :uuid + t.string :code, null: false + t.boolean :admin, null: false, default: false + t.string :permissions, array: true, null: false, default: [] + t.string :name, null: false + t.string :description + t.timestamps + t.datetime :deleted_at + + t.check_constraint "name ~ '^.{1,100}$'", name: "name_is_valid" + t.check_constraint "code ~ '^[a-z0-9_]{1,100}$'", name: "code_is_valid" + t.check_constraint "length(description) <= 255", name: "description_max_length" + t.check_constraint "organization_id IS NOT NULL OR cardinality(permissions) = 0", name: "predefined_role_cannot_have_permissions" + t.check_constraint "organization_id IS NULL OR cardinality(permissions) > 0", name: "custom_role_should_have_permissions" + t.check_constraint "NOT (permissions::text ~ '([\\{,]:|::|:[,\\}])') AND NOT ('' = ANY(permissions))", name: "permissions_has_no_empty_parts" + + t.index :admin, + unique: true, + where: "admin AND deleted_at IS NULL", + name: "index_roles_by_unique_admin" + t.index "organization_id NULLS FIRST, code", + unique: true, + where: "deleted_at IS NULL", + name: "index_roles_by_code_per_organization" + end + + safety_assured do + execute <<~SQL.squish + INSERT INTO roles (admin, code, name, description, permissions, created_at, updated_at) + VALUES + ( + true, + 'admin', + 'Admin', + 'Administrator having all permissions', + ARRAY[]::text[], + now(), + now() + ), + ( + false, + 'finance', + 'Finance', + 'Finance role with permissions to manage financial data', + ARRAY[]::text[], + now(), + now() + ), + ( + false, + 'manager', + 'Manager', + 'The predefined manager role', + ARRAY[]::text[], + now(), + now() + ); + + CREATE FUNCTION ensure_role_consistency() RETURNS TRIGGER AS $$ + BEGIN + IF OLD.organization_id IS NULL THEN + RAISE EXCEPTION 'Predefined role cannot be modified'; + ELSIF OLD.organization_id IS DISTINCT FROM NEW.organization_id THEN + RAISE EXCEPTION 'Custom role cannot be moved to another organization'; + ELSIF OLD.code IS DISTINCT FROM NEW.code THEN + RAISE EXCEPTION 'The code of the role cannot be changed'; + ELSIF NEW.permissions != OLD.permissions THEN + NEW.permissions := ARRAY(SELECT DISTINCT unnest(NEW.permissions) ORDER BY 1); + END IF; + + RETURN NEW; + END; + $$ LANGUAGE plpgsql; + + CREATE TRIGGER ensure_consistency + BEFORE UPDATE ON roles + FOR EACH ROW EXECUTE FUNCTION ensure_role_consistency(); + SQL + end + end + + def down + drop_table :roles + + safety_assured { execute "DROP FUNCTION IF EXISTS ensure_role_consistency();" } + end +end diff --git a/db/migrate/20251221174733_create_membership_roles.rb b/db/migrate/20251221174733_create_membership_roles.rb new file mode 100644 index 0000000..8790a98 --- /dev/null +++ b/db/migrate/20251221174733_create_membership_roles.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +class CreateMembershipRoles < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + unless index_exists?(:memberships, [:id, :organization_id], name: "index_memberships_by_id_and_organization") + add_index :memberships, [:id, :organization_id], unique: true, name: "index_memberships_by_id_and_organization", algorithm: :concurrently + end + + unless table_exists?(:membership_roles) + create_table :membership_roles, id: :uuid do |t| + t.uuid :organization_id, null: false + t.uuid :membership_id, null: false + t.references :role, index: true, foreign_key: true, type: :uuid, null: false + t.datetime :deleted_at + t.timestamps + + t.index [:membership_id, :role_id], + unique: true, + where: "deleted_at IS NULL", + name: "index_membership_roles_uniqueness" + t.index [:membership_id, :organization_id], + where: "deleted_at IS NULL", + name: "index_membership_roles_by_membership_and_organization" + + t.foreign_key :memberships, + column: [:membership_id, :organization_id], + primary_key: [:id, :organization_id], + name: "membership_role_membership_fk" + end + end + end + + def down + drop_table :membership_roles + + if index_exists?(:memberships, [:id, :organization_id], name: "index_memberships_by_id_and_organization") + remove_index :memberships, name: "index_memberships_by_id_and_organization" + end + end +end diff --git a/db/migrate/20251221174938_add_roles_to_invites.rb b/db/migrate/20251221174938_add_roles_to_invites.rb new file mode 100644 index 0000000..b369a15 --- /dev/null +++ b/db/migrate/20251221174938_add_roles_to_invites.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class AddRolesToInvites < ActiveRecord::Migration[8.0] + # Disable DDL transaction to enable null check constraint addition without locking the table for long. + disable_ddl_transaction! + + def up + safety_assured do + change_table :invites, bulk: true do |t| + t.string :roles, array: true, default: [], null: false + end + + # backfill existing data + execute <<~SQL.squish + UPDATE invites SET roles = ARRAY[ + CASE role + WHEN 0 THEN 'admin' + WHEN 1 THEN 'manager' + WHEN 2 THEN 'finance' + END + ] + SQL + end + end + + def down + safety_assured { remove_column(:invites, :roles) } + end +end diff --git a/db/migrate/20251221174946_populate_membership_roles_from_memberships.rb b/db/migrate/20251221174946_populate_membership_roles_from_memberships.rb new file mode 100644 index 0000000..495952f --- /dev/null +++ b/db/migrate/20251221174946_populate_membership_roles_from_memberships.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +class PopulateMembershipRolesFromMemberships < ActiveRecord::Migration[8.0] + def up + safety_assured do + execute <<~SQL.squish + INSERT INTO membership_roles (id, organization_id, membership_id, role_id, created_at, updated_at) + SELECT + gen_random_uuid(), + m.organization_id, + m.id, + CASE m.role + WHEN 0 THEN (SELECT id FROM roles WHERE admin) + WHEN 1 THEN (SELECT id FROM roles WHERE name = 'Manager' AND organization_id IS NULL) + WHEN 2 THEN (SELECT id FROM roles WHERE name = 'Finance' AND organization_id IS NULL) + END, + NOW(), + NOW() + FROM memberships m + LEFT JOIN membership_roles mr ON mr.membership_id = m.id AND mr.deleted_at IS NULL + WHERE mr.id IS NULL + ON CONFLICT DO NOTHING; + SQL + end + end + + def down + end +end diff --git a/db/migrate/20251222151015_make_memberships_role_optional.rb b/db/migrate/20251222151015_make_memberships_role_optional.rb new file mode 100644 index 0000000..1ebf18e --- /dev/null +++ b/db/migrate/20251222151015_make_memberships_role_optional.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class MakeMembershipsRoleOptional < ActiveRecord::Migration[8.0] + def up + safety_assured do + change_column_null :memberships, :role, true + change_column_default :memberships, :role, from: 0, to: nil + end + end + + def down + safety_assured do + execute "UPDATE memberships SET role = 0 WHERE role IS NULL;" + change_column_default :memberships, :role, from: nil, to: 0 + change_column_null :memberships, :role, false + end + end +end diff --git a/db/migrate/20251222151519_make_invites_role_optional.rb b/db/migrate/20251222151519_make_invites_role_optional.rb new file mode 100644 index 0000000..645c136 --- /dev/null +++ b/db/migrate/20251222151519_make_invites_role_optional.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class MakeInvitesRoleOptional < ActiveRecord::Migration[8.0] + def up + safety_assured do + change_column_null :invites, :role, true + change_column_default :invites, :role, from: 0, to: nil + end + end + + def down + safety_assured do + execute "UPDATE invites SET role = 0 WHERE role IS NULL;" + change_column_default :invites, :role, from: nil, to: 0 + change_column_null :invites, :role, false + end + end +end diff --git a/db/migrate/20251222163416_add_payment_method_to_payments.rb b/db/migrate/20251222163416_add_payment_method_to_payments.rb new file mode 100644 index 0000000..2a80bc3 --- /dev/null +++ b/db/migrate/20251222163416_add_payment_method_to_payments.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddPaymentMethodToPayments < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :payments, :payment_method, null: true, type: :uuid, index: {algorithm: :concurrently} + add_foreign_key :payments, :payment_methods, validate: false + end +end diff --git a/db/migrate/20251224152732_create_wallet_transaction_consumptions.rb b/db/migrate/20251224152732_create_wallet_transaction_consumptions.rb new file mode 100644 index 0000000..0dab252 --- /dev/null +++ b/db/migrate/20251224152732_create_wallet_transaction_consumptions.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class CreateWalletTransactionConsumptions < ActiveRecord::Migration[8.0] + def change + create_table :wallet_transaction_consumptions, id: :uuid do |t| + t.references :organization, + foreign_key: true, + type: :uuid, + null: false + t.references :inbound_wallet_transaction, + foreign_key: {to_table: :wallet_transactions}, + type: :uuid, + null: false + t.references :outbound_wallet_transaction, + foreign_key: {to_table: :wallet_transactions}, + type: :uuid, + null: false + t.bigint :consumed_amount_cents, null: false + + t.timestamps + end + + add_index :wallet_transaction_consumptions, + [:inbound_wallet_transaction_id, :outbound_wallet_transaction_id], + unique: true, + name: "idx_wallet_tx_consumptions_inbound_outbound" + end +end diff --git a/db/migrate/20251224152733_add_remaining_amount_cents_to_wallet_transactions.rb b/db/migrate/20251224152733_add_remaining_amount_cents_to_wallet_transactions.rb new file mode 100644 index 0000000..fb659f8 --- /dev/null +++ b/db/migrate/20251224152733_add_remaining_amount_cents_to_wallet_transactions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddRemainingAmountCentsToWalletTransactions < ActiveRecord::Migration[8.0] + def change + add_column :wallet_transactions, :remaining_amount_cents, :bigint + + add_check_constraint :wallet_transactions, + "remaining_amount_cents >= 0 OR remaining_amount_cents IS NULL", + name: "remaining_amount_cents_non_negative", + validate: false + end +end diff --git a/db/migrate/20251224152734_add_traceable_to_wallets.rb b/db/migrate/20251224152734_add_traceable_to_wallets.rb new file mode 100644 index 0000000..e251a9c --- /dev/null +++ b/db/migrate/20251224152734_add_traceable_to_wallets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTraceableToWallets < ActiveRecord::Migration[8.0] + def change + add_column :wallets, :traceable, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20251224152736_validate_remaining_amount_cents_constraint.rb b/db/migrate/20251224152736_validate_remaining_amount_cents_constraint.rb new file mode 100644 index 0000000..56272a5 --- /dev/null +++ b/db/migrate/20251224152736_validate_remaining_amount_cents_constraint.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateRemainingAmountCentsConstraint < ActiveRecord::Migration[8.0] + def change + validate_check_constraint :wallet_transactions, name: "remaining_amount_cents_non_negative" + end +end diff --git a/db/migrate/20251224152737_add_available_inbound_index_to_wallet_transactions.rb b/db/migrate/20251224152737_add_available_inbound_index_to_wallet_transactions.rb new file mode 100644 index 0000000..6e85865 --- /dev/null +++ b/db/migrate/20251224152737_add_available_inbound_index_to_wallet_transactions.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddAvailableInboundIndexToWalletTransactions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :wallet_transactions, + "wallet_id, priority, (CASE WHEN transaction_status = 1 THEN 0 ELSE 1 END), created_at", + where: "remaining_amount_cents > 0 AND transaction_type = 0 AND status = 1", + name: "idx_wallet_transactions_available_inbound", + algorithm: :concurrently + end +end diff --git a/db/migrate/20251226145247_add_code_to_charges.rb b/db/migrate/20251226145247_add_code_to_charges.rb new file mode 100644 index 0000000..1ffda2b --- /dev/null +++ b/db/migrate/20251226145247_add_code_to_charges.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddCodeToCharges < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :charges, :code, :string + add_column :fixed_charges, :code, :string + + add_index :charges, + [:plan_id, :code], + unique: true, + where: "deleted_at IS NULL AND parent_id IS NULL", + name: "index_charges_on_plan_id_and_code", + algorithm: :concurrently + + add_index :fixed_charges, + [:plan_id, :code], + unique: true, + where: "deleted_at IS NULL AND parent_id IS NULL", + name: "index_fixed_charges_on_plan_id_and_code", + algorithm: :concurrently + end +end diff --git a/db/migrate/20251229153718_add_code_not_null_check_constraint_to_charges.rb b/db/migrate/20251229153718_add_code_not_null_check_constraint_to_charges.rb new file mode 100644 index 0000000..209d46c --- /dev/null +++ b/db/migrate/20251229153718_add_code_not_null_check_constraint_to_charges.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddCodeNotNullCheckConstraintToCharges < ActiveRecord::Migration[8.0] + def change + add_check_constraint :charges, "code IS NOT NULL", name: "charges_code_not_null", validate: false, if_not_exists: true + add_check_constraint :fixed_charges, "code IS NOT NULL", name: "fixed_charges_code_not_null", validate: false, if_not_exists: true + end +end diff --git a/db/migrate/20251229153734_validate_charges_code_not_null.rb b/db/migrate/20251229153734_validate_charges_code_not_null.rb new file mode 100644 index 0000000..5334d63 --- /dev/null +++ b/db/migrate/20251229153734_validate_charges_code_not_null.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class ValidateChargesCodeNotNull < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + validate_check_constraint :charges, name: "charges_code_not_null" + change_column_null :charges, :code, false + remove_check_constraint :charges, name: "charges_code_not_null" + + validate_check_constraint :fixed_charges, name: "fixed_charges_code_not_null" + change_column_null :fixed_charges, :code, false + remove_check_constraint :fixed_charges, name: "fixed_charges_code_not_null" + end +end diff --git a/db/migrate/20251230154408_add_prepaid_credit_breakdown_to_invoices.rb b/db/migrate/20251230154408_add_prepaid_credit_breakdown_to_invoices.rb new file mode 100644 index 0000000..c1fb092 --- /dev/null +++ b/db/migrate/20251230154408_add_prepaid_credit_breakdown_to_invoices.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddPrepaidCreditBreakdownToInvoices < ActiveRecord::Migration[8.0] + def change + add_column :invoices, :prepaid_granted_credit_amount_cents, :bigint + add_column :invoices, :prepaid_purchased_credit_amount_cents, :bigint + end +end diff --git a/db/migrate/20251231162838_make_fixed_charges_boundaries_index_unique.rb b/db/migrate/20251231162838_make_fixed_charges_boundaries_index_unique.rb new file mode 100644 index 0000000..c10652f --- /dev/null +++ b/db/migrate/20251231162838_make_fixed_charges_boundaries_index_unique.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +class MakeFixedChargesBoundariesIndexUnique < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + # Fix duplicates: nullify fixed_charges boundaries for all records that have duplicates + # on (subscription_id, fixed_charges_from_datetime, fixed_charges_to_datetime). + # We can do it because: + # 1) we're introducing fixed charges and no invoices have fixed_charges yet, + # 2) duplicates are happening because of charges billed monthly with yearly / semiannual subscriptions, + # which anyway should have nulls for fixed charges boundaries. + # in total we now have 38 duplicates + safety_assured do + execute <<~SQL + UPDATE invoice_subscriptions + SET fixed_charges_from_datetime = NULL, + fixed_charges_to_datetime = NULL + WHERE (subscription_id, fixed_charges_from_datetime, fixed_charges_to_datetime) IN ( + SELECT subscription_id, fixed_charges_from_datetime, fixed_charges_to_datetime + FROM invoice_subscriptions + WHERE fixed_charges_from_datetime IS NOT NULL + AND fixed_charges_to_datetime IS NOT NULL + AND recurring = TRUE + AND regenerated_invoice_id IS NULL + GROUP BY subscription_id, fixed_charges_from_datetime, fixed_charges_to_datetime + HAVING COUNT(*) > 1 + ) + AND recurring = TRUE + AND regenerated_invoice_id IS NULL + SQL + end + + # Remove the existing non-unique index + remove_index :invoice_subscriptions, + name: :index_invoice_subscriptions_on_fixed_charges_boundaries, + if_exists: true + + # Remove the unique index if it already exists (in case migration ran partially before) + remove_index :invoice_subscriptions, + name: :index_uniq_invoice_subscriptions_on_fixed_charges_boundaries, + if_exists: true + + # Add unique index (only for non-NULL fixed_charges boundaries) + add_index :invoice_subscriptions, + [:subscription_id, :fixed_charges_from_datetime, :fixed_charges_to_datetime], + unique: true, + where: "fixed_charges_from_datetime IS NOT NULL AND recurring IS TRUE AND regenerated_invoice_id IS NULL", + name: :index_uniq_invoice_subscriptions_on_fixed_charges_boundaries, + algorithm: :concurrently + end + + def down + remove_index :invoice_subscriptions, + name: :index_uniq_invoice_subscriptions_on_fixed_charges_boundaries, + if_exists: true + + add_index :invoice_subscriptions, + [:subscription_id, :fixed_charges_from_datetime, :fixed_charges_to_datetime], + where: "recurring IS TRUE AND regenerated_invoice_id IS NULL", + name: :index_invoice_subscriptions_on_fixed_charges_boundaries, + algorithm: :concurrently + end +end diff --git a/db/migrate/20260105144123_add_enable_clickhouse_deduplication_to_organizations.rb b/db/migrate/20260105144123_add_enable_clickhouse_deduplication_to_organizations.rb new file mode 100644 index 0000000..f9c104c --- /dev/null +++ b/db/migrate/20260105144123_add_enable_clickhouse_deduplication_to_organizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddEnableClickhouseDeduplicationToOrganizations < ActiveRecord::Migration[8.0] + def change + add_column :organizations, :clickhouse_deduplication_enabled, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260106120601_create_exports_entitlement_entitlements.rb b/db/migrate/20260106120601_create_exports_entitlement_entitlements.rb new file mode 100644 index 0000000..205ccf8 --- /dev/null +++ b/db/migrate/20260106120601_create_exports_entitlement_entitlements.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsEntitlementEntitlements < ActiveRecord::Migration[8.0] + def change + create_view :exports_entitlement_entitlements + end +end diff --git a/db/migrate/20260106120832_create_exports_entitlement_features.rb b/db/migrate/20260106120832_create_exports_entitlement_features.rb new file mode 100644 index 0000000..abb6949 --- /dev/null +++ b/db/migrate/20260106120832_create_exports_entitlement_features.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsEntitlementFeatures < ActiveRecord::Migration[8.0] + def change + create_view :exports_entitlement_features + end +end diff --git a/db/migrate/20260109092932_setup_partman.rb b/db/migrate/20260109092932_setup_partman.rb new file mode 100644 index 0000000..95747e8 --- /dev/null +++ b/db/migrate/20260109092932_setup_partman.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class SetupPartman < ActiveRecord::Migration[8.0] + def up + safety_assured do + unless pg_extension_present?("pg_partman") + Rails.logger.debug "pg_partman extension is not available on this PostgreSQL server, skipping..." + return + end + + execute <<~SQL + CREATE SCHEMA IF NOT EXISTS partman; + CREATE EXTENSION IF NOT EXISTS pg_partman SCHEMA partman; + SQL + end + end +end diff --git a/db/migrate/20260109110146_create_enriched_events.rb b/db/migrate/20260109110146_create_enriched_events.rb new file mode 100644 index 0000000..528f625 --- /dev/null +++ b/db/migrate/20260109110146_create_enriched_events.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +class CreateEnrichedEvents < ActiveRecord::Migration[8.0] + def up + safety_assured do + options = if pg_extension_present?("pg_partman") + "PARTITION BY RANGE (timestamp)" + else + Rails.logger.debug "pg_partman extension is not available on this PostgreSQL server, skipping partitioning" + "" + end + + create_table :enriched_events, id: false, primary_key: %i[id timestamp], options: do |t| + t.uuid :id, null: false, default: -> { "gen_random_uuid()" } + t.uuid :organization_id, null: false + t.uuid :event_id, null: false, index: true + t.string :transaction_id, null: false + t.string :external_subscription_id, null: false + t.string :code, null: false + t.datetime :timestamp, null: false + t.uuid :subscription_id, null: false + t.uuid :plan_id, null: false + t.uuid :charge_id, null: false + t.uuid :charge_filter_id + t.jsonb :properties, null: false, default: {} + t.jsonb :grouped_by, null: false, default: {} + t.string :value, null: true + t.decimal :decimal_value, precision: 40, scale: 15, null: false, default: 0.0 + t.datetime :enriched_at, null: false + + t.index %i[organization_id subscription_id charge_id charge_filter_id timestamp], name: "idx_billing_on_enriched_events" + t.index %i[organization_id external_subscription_id code timestamp], name: "idx_lookup_on_enriched_events" + t.index %i[organization_id external_subscription_id transaction_id timestamp charge_id], unique: true, name: "idx_unique_on_enriched_events" + end + end + end + + def down + drop_table :enriched_events + end +end diff --git a/db/migrate/20260109132143_partition_enriched_events.rb b/db/migrate/20260109132143_partition_enriched_events.rb new file mode 100644 index 0000000..265ce88 --- /dev/null +++ b/db/migrate/20260109132143_partition_enriched_events.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class PartitionEnrichedEvents < ActiveRecord::Migration[8.0] + def up + safety_assured do + unless pg_extension_present?("pg_partman") + Rails.logger.debug "pg_partman extension is not available on this PostgreSQL server, skipping..." + return + end + + execute <<~SQL + SELECT partman.create_parent( + p_parent_table := 'public.enriched_events', + p_control := 'timestamp', + p_interval := '1 month', + p_type := 'range', + p_premake := 3, -- Create 3 months ahead + p_start_partition := '2024-12-01' + ) + SQL + + execute <<~SQL + UPDATE partman.part_config + SET infinite_time_partitions = true, + retention = '14 months', -- Handle yearly plan with large grace periods + retention_keep_table = true + WHERE parent_table = 'public.enriched_events'; + SQL + end + end +end diff --git a/db/migrate/20260112140805_add_feature_flags_to_organizations.rb b/db/migrate/20260112140805_add_feature_flags_to_organizations.rb new file mode 100644 index 0000000..6d0064b --- /dev/null +++ b/db/migrate/20260112140805_add_feature_flags_to_organizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddFeatureFlagsToOrganizations < ActiveRecord::Migration[8.0] + def change + add_column :organizations, :feature_flags, :string, array: true, default: [], null: false + end +end diff --git a/db/migrate/20260113102028_create_exports_entitlement_entitlement_values.rb b/db/migrate/20260113102028_create_exports_entitlement_entitlement_values.rb new file mode 100644 index 0000000..9eadff6 --- /dev/null +++ b/db/migrate/20260113102028_create_exports_entitlement_entitlement_values.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsEntitlementEntitlementValues < ActiveRecord::Migration[8.0] + def change + create_view :exports_entitlement_entitlement_values + end +end diff --git a/db/migrate/20260114153728_create_exports_item_metadata.rb b/db/migrate/20260114153728_create_exports_item_metadata.rb new file mode 100644 index 0000000..0c1f196 --- /dev/null +++ b/db/migrate/20260114153728_create_exports_item_metadata.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsItemMetadata < ActiveRecord::Migration[8.0] + def change + create_view :exports_item_metadata + end +end diff --git a/db/migrate/20260115164124_add_offset_value_to_subscription_on_termination_credit_note_enum.rb b/db/migrate/20260115164124_add_offset_value_to_subscription_on_termination_credit_note_enum.rb new file mode 100644 index 0000000..b39d5e9 --- /dev/null +++ b/db/migrate/20260115164124_add_offset_value_to_subscription_on_termination_credit_note_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddOffsetValueToSubscriptionOnTerminationCreditNoteEnum < ActiveRecord::Migration[8.0] + def up + add_enum_value :subscription_on_termination_credit_note, "offset", if_not_exists: true + end + + def down + end +end diff --git a/db/migrate/20260116110125_create_invoice_settlements.rb b/db/migrate/20260116110125_create_invoice_settlements.rb new file mode 100644 index 0000000..db46ccb --- /dev/null +++ b/db/migrate/20260116110125_create_invoice_settlements.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class CreateInvoiceSettlements < ActiveRecord::Migration[8.0] + def change + create_enum :invoice_settlement_settlement_type, ["payment", "credit_note"] + + create_table :invoice_settlements, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :billing_entity, null: false, foreign_key: true, type: :uuid + t.references :target_invoice, null: false, foreign_key: {to_table: :invoices}, type: :uuid + t.enum :settlement_type, enum_type: :invoice_settlement_settlement_type, null: false + + t.references :source_payment, foreign_key: {to_table: :payments}, type: :uuid + t.references :source_credit_note, foreign_key: {to_table: :credit_notes}, type: :uuid + + t.bigint :amount_cents, null: false + t.string :amount_currency, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20260116121015_remove_role_from_memberships.rb b/db/migrate/20260116121015_remove_role_from_memberships.rb new file mode 100644 index 0000000..67673ca --- /dev/null +++ b/db/migrate/20260116121015_remove_role_from_memberships.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemoveRoleFromMemberships < ActiveRecord::Migration[8.0] + def change + safety_assured do + remove_column :memberships, :role, :integer + end + end +end diff --git a/db/migrate/20260116121019_remove_role_from_invites.rb b/db/migrate/20260116121019_remove_role_from_invites.rb new file mode 100644 index 0000000..4f13a61 --- /dev/null +++ b/db/migrate/20260116121019_remove_role_from_invites.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class RemoveRoleFromInvites < ActiveRecord::Migration[8.0] + def change + safety_assured do + remove_column :invites, :role, :integer + end + end +end diff --git a/db/migrate/20260116162519_add_offset_amount_to_credit_notes.rb b/db/migrate/20260116162519_add_offset_amount_to_credit_notes.rb new file mode 100644 index 0000000..8082ffc --- /dev/null +++ b/db/migrate/20260116162519_add_offset_amount_to_credit_notes.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddOffsetAmountToCreditNotes < ActiveRecord::Migration[8.0] + def change + add_column :credit_notes, :offset_amount_cents, :bigint, default: 0, null: false + add_column :credit_notes, :offset_amount_currency, :string + end +end diff --git a/db/migrate/20260119162712_drop_subscription_fixed_charge_units_overrides.rb b/db/migrate/20260119162712_drop_subscription_fixed_charge_units_overrides.rb new file mode 100644 index 0000000..dad598c --- /dev/null +++ b/db/migrate/20260119162712_drop_subscription_fixed_charge_units_overrides.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class DropSubscriptionFixedChargeUnitsOverrides < ActiveRecord::Migration[8.0] + def up + drop_table :subscription_fixed_charge_units_overrides + end + + def down + create_table :subscription_fixed_charge_units_overrides, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :billing_entity, null: false, foreign_key: true, type: :uuid + t.references :subscription, null: false, foreign_key: true, type: :uuid + t.references :fixed_charge, null: false, foreign_key: true, type: :uuid + + t.decimal :units, precision: 30, scale: 10, null: false, default: 0.0 + t.datetime :deleted_at, index: true + + t.index [:subscription_id, :fixed_charge_id], + unique: true, + where: "deleted_at IS NULL" + + t.timestamps + end + end +end diff --git a/db/migrate/20260120195822_add_code_to_wallets.rb b/db/migrate/20260120195822_add_code_to_wallets.rb new file mode 100644 index 0000000..55353c0 --- /dev/null +++ b/db/migrate/20260120195822_add_code_to_wallets.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddCodeToWallets < ActiveRecord::Migration[8.0] + def change + add_column :wallets, :code, :string + end +end diff --git a/db/migrate/20260121111431_add_indexes_to_wallets_code.rb b/db/migrate/20260121111431_add_indexes_to_wallets_code.rb new file mode 100644 index 0000000..be3bd32 --- /dev/null +++ b/db/migrate/20260121111431_add_indexes_to_wallets_code.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddIndexesToWalletsCode < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + add_index :wallets, [:customer_id, :code], + unique: true, + name: "index_uniq_wallet_code_per_customer", + algorithm: :concurrently + end + end + + def down + safety_assured do + remove_index :wallets, [:customer_id, :code], + name: "index_uniq_wallet_code_per_customer", + algorithm: :concurrently + end + end +end diff --git a/db/migrate/20260121112929_add_max_wallets_to_organizations.rb b/db/migrate/20260121112929_add_max_wallets_to_organizations.rb new file mode 100644 index 0000000..3cc5ff7 --- /dev/null +++ b/db/migrate/20260121112929_add_max_wallets_to_organizations.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddMaxWalletsToOrganizations < ActiveRecord::Migration[8.0] + def change + add_column :organizations, :max_wallets, :integer, null: true + end +end diff --git a/db/migrate/20260123102257_add_accepts_wallet_target_to_charges.rb b/db/migrate/20260123102257_add_accepts_wallet_target_to_charges.rb new file mode 100644 index 0000000..34078dc --- /dev/null +++ b/db/migrate/20260123102257_add_accepts_wallet_target_to_charges.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAcceptsWalletTargetToCharges < ActiveRecord::Migration[8.0] + def change + add_column :charges, :accepts_target_wallet, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20260123102258_add_index_on_charges_accepts_wallet_target.rb b/db/migrate/20260123102258_add_index_on_charges_accepts_wallet_target.rb new file mode 100644 index 0000000..68aa8f3 --- /dev/null +++ b/db/migrate/20260123102258_add_index_on_charges_accepts_wallet_target.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddIndexOnChargesAcceptsWalletTarget < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :charges, :accepts_target_wallet, + name: "index_charges_on_accepts_target_wallet", + where: "accepts_target_wallet = true", + algorithm: :concurrently + end +end diff --git a/db/migrate/20260127114700_create_exports_invoice_settlements.rb b/db/migrate/20260127114700_create_exports_invoice_settlements.rb new file mode 100644 index 0000000..19933d2 --- /dev/null +++ b/db/migrate/20260127114700_create_exports_invoice_settlements.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsInvoiceSettlements < ActiveRecord::Migration[8.0] + def change + create_view :exports_invoice_settlements + end +end diff --git a/db/migrate/20260127150611_add_wallet_to_usage_monitoring_alerts.rb b/db/migrate/20260127150611_add_wallet_to_usage_monitoring_alerts.rb new file mode 100644 index 0000000..090c68f --- /dev/null +++ b/db/migrate/20260127150611_add_wallet_to_usage_monitoring_alerts.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddWalletToUsageMonitoringAlerts < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :usage_monitoring_alerts, :wallet, type: :uuid, index: {algorithm: :concurrently} + add_reference :usage_monitoring_triggered_alerts, :wallet, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20260127150612_add_wallet_foreign_keys_to_usage_monitoring_alerts.rb b/db/migrate/20260127150612_add_wallet_foreign_keys_to_usage_monitoring_alerts.rb new file mode 100644 index 0000000..6ec97d2 --- /dev/null +++ b/db/migrate/20260127150612_add_wallet_foreign_keys_to_usage_monitoring_alerts.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddWalletForeignKeysToUsageMonitoringAlerts < ActiveRecord::Migration[8.0] + def change + add_foreign_key :usage_monitoring_alerts, :wallets, validate: false + add_foreign_key :usage_monitoring_triggered_alerts, :wallets, validate: false + end +end diff --git a/db/migrate/20260127150613_validate_wallet_foreign_keys_on_usage_monitoring_alerts.rb b/db/migrate/20260127150613_validate_wallet_foreign_keys_on_usage_monitoring_alerts.rb new file mode 100644 index 0000000..ba4cb11 --- /dev/null +++ b/db/migrate/20260127150613_validate_wallet_foreign_keys_on_usage_monitoring_alerts.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ValidateWalletForeignKeysOnUsageMonitoringAlerts < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :usage_monitoring_alerts, :wallets + validate_foreign_key :usage_monitoring_triggered_alerts, :wallets + end +end diff --git a/db/migrate/20260127150624_make_subscription_nullable_on_usage_monitoring_alerts.rb b/db/migrate/20260127150624_make_subscription_nullable_on_usage_monitoring_alerts.rb new file mode 100644 index 0000000..6d5b0f2 --- /dev/null +++ b/db/migrate/20260127150624_make_subscription_nullable_on_usage_monitoring_alerts.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class MakeSubscriptionNullableOnUsageMonitoringAlerts < ActiveRecord::Migration[8.0] + def change + change_column_null :usage_monitoring_alerts, :subscription_external_id, true + change_column_null :usage_monitoring_triggered_alerts, :subscription_id, true + end +end diff --git a/db/migrate/20260127150640_add_xor_constraints_and_wallet_indexes_to_usage_monitoring_alerts.rb b/db/migrate/20260127150640_add_xor_constraints_and_wallet_indexes_to_usage_monitoring_alerts.rb new file mode 100644 index 0000000..a4806fd --- /dev/null +++ b/db/migrate/20260127150640_add_xor_constraints_and_wallet_indexes_to_usage_monitoring_alerts.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class AddXorConstraintsAndWalletIndexesToUsageMonitoringAlerts < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_check_constraint :usage_monitoring_alerts, + "(subscription_external_id IS NOT NULL) <> (wallet_id IS NOT NULL)", + name: "chk_alerts_subscription_xor_wallet", + validate: false + + add_check_constraint :usage_monitoring_triggered_alerts, + "(subscription_id IS NOT NULL) <> (wallet_id IS NOT NULL)", + name: "chk_triggered_alerts_subscription_xor_wallet", + validate: false + + add_index :usage_monitoring_alerts, + %w[wallet_id organization_id alert_type], + unique: true, + name: "idx_alerts_unique_per_type_per_wallet", + where: "(billable_metric_id IS NULL AND deleted_at IS NULL)", + algorithm: :concurrently + end +end diff --git a/db/migrate/20260127150713_validate_xor_constraints_on_usage_monitoring_alerts.rb b/db/migrate/20260127150713_validate_xor_constraints_on_usage_monitoring_alerts.rb new file mode 100644 index 0000000..080138b --- /dev/null +++ b/db/migrate/20260127150713_validate_xor_constraints_on_usage_monitoring_alerts.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class ValidateXorConstraintsOnUsageMonitoringAlerts < ActiveRecord::Migration[8.0] + def change + validate_check_constraint :usage_monitoring_alerts, name: "chk_alerts_subscription_xor_wallet" + validate_check_constraint :usage_monitoring_triggered_alerts, name: "chk_triggered_alerts_subscription_xor_wallet" + end +end diff --git a/db/migrate/20260127163159_add_wallet_types_to_usage_monitoring_alert_types_enum.rb b/db/migrate/20260127163159_add_wallet_types_to_usage_monitoring_alert_types_enum.rb new file mode 100644 index 0000000..ddd209c --- /dev/null +++ b/db/migrate/20260127163159_add_wallet_types_to_usage_monitoring_alert_types_enum.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddWalletTypesToUsageMonitoringAlertTypesEnum < ActiveRecord::Migration[8.0] + def change + add_enum_value :usage_monitoring_alert_types, "wallet_balance_amount", if_not_exists: true + add_enum_value :usage_monitoring_alert_types, "wallet_credits_balance", if_not_exists: true + end +end diff --git a/db/migrate/20260128073308_add_direction_to_usage_monitoring_alerts.rb b/db/migrate/20260128073308_add_direction_to_usage_monitoring_alerts.rb new file mode 100644 index 0000000..ce6e150 --- /dev/null +++ b/db/migrate/20260128073308_add_direction_to_usage_monitoring_alerts.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddDirectionToUsageMonitoringAlerts < ActiveRecord::Migration[8.0] + def change + create_enum :usage_monitoring_alert_direction, %w[increasing decreasing] + + add_column :usage_monitoring_alerts, :direction, :enum, + enum_type: :usage_monitoring_alert_direction, + default: "increasing", + null: false + end +end diff --git a/db/migrate/20260129105200_update_exports_invoice_settlements_to_version_2.rb b/db/migrate/20260129105200_update_exports_invoice_settlements_to_version_2.rb new file mode 100644 index 0000000..a4511ae --- /dev/null +++ b/db/migrate/20260129105200_update_exports_invoice_settlements_to_version_2.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateExportsInvoiceSettlementsToVersion2 < ActiveRecord::Migration[8.0] + def change + update_view :exports_invoice_settlements, version: 2, revert_to_version: 1 + end +end diff --git a/db/migrate/20260129145352_drop_properties_from_enriched_events.rb b/db/migrate/20260129145352_drop_properties_from_enriched_events.rb new file mode 100644 index 0000000..4564df6 --- /dev/null +++ b/db/migrate/20260129145352_drop_properties_from_enriched_events.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class DropPropertiesFromEnrichedEvents < ActiveRecord::Migration[8.0] + def up + safety_assured do + remove_column :enriched_events, :properties + end + end +end diff --git a/db/migrate/20260202134958_add_index_on_customer_sequential_id.rb b/db/migrate/20260202134958_add_index_on_customer_sequential_id.rb new file mode 100644 index 0000000..cd4e1bd --- /dev/null +++ b/db/migrate/20260202134958_add_index_on_customer_sequential_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddIndexOnCustomerSequentialId < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :customers, :sequential_id, algorithm: :concurrently + end +end diff --git a/db/migrate/20260202150723_add_event_types_and_name_to_webhook_endpoints.rb b/db/migrate/20260202150723_add_event_types_and_name_to_webhook_endpoints.rb new file mode 100644 index 0000000..aee3b2a --- /dev/null +++ b/db/migrate/20260202150723_add_event_types_and_name_to_webhook_endpoints.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddEventTypesAndNameToWebhookEndpoints < ActiveRecord::Migration[8.0] + def change + add_column :webhook_endpoints, :event_types, :string, array: true + add_column :webhook_endpoints, :name, :string + end +end diff --git a/db/migrate/20260202155431_remove_organization_zero_amount_fees_premium_integration.rb b/db/migrate/20260202155431_remove_organization_zero_amount_fees_premium_integration.rb new file mode 100644 index 0000000..efae43b --- /dev/null +++ b/db/migrate/20260202155431_remove_organization_zero_amount_fees_premium_integration.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class RemoveOrganizationZeroAmountFeesPremiumIntegration < ActiveRecord::Migration[8.0] + def change + organizations = Organization.where("? = ANY(premium_integrations)", "zero_amount_fees") + + organizations.find_each do |organization| + organization.update!(premium_integrations: organization.premium_integrations - ["zero_amount_fees"]) + end + end +end diff --git a/db/migrate/20260203145512_add_voided_invoice_id_to_wallet_transactions.rb b/db/migrate/20260203145512_add_voided_invoice_id_to_wallet_transactions.rb new file mode 100644 index 0000000..39acec5 --- /dev/null +++ b/db/migrate/20260203145512_add_voided_invoice_id_to_wallet_transactions.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddVoidedInvoiceIdToWalletTransactions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :wallet_transactions, + :voided_invoice, + type: :uuid, + index: {algorithm: :concurrently} + end +end diff --git a/db/migrate/20260203145801_add_voided_invoice_foreign_key_to_wallet_transactions.rb b/db/migrate/20260203145801_add_voided_invoice_foreign_key_to_wallet_transactions.rb new file mode 100644 index 0000000..4e9c863 --- /dev/null +++ b/db/migrate/20260203145801_add_voided_invoice_foreign_key_to_wallet_transactions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddVoidedInvoiceForeignKeyToWalletTransactions < ActiveRecord::Migration[8.0] + def change + add_foreign_key :wallet_transactions, :invoices, column: :voided_invoice_id, validate: false + end +end diff --git a/db/migrate/20260203145809_validate_voided_invoice_foreign_key_on_wallet_transactions.rb b/db/migrate/20260203145809_validate_voided_invoice_foreign_key_on_wallet_transactions.rb new file mode 100644 index 0000000..f6b585b --- /dev/null +++ b/db/migrate/20260203145809_validate_voided_invoice_foreign_key_on_wallet_transactions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateVoidedInvoiceForeignKeyOnWalletTransactions < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :wallet_transactions, :invoices, column: :voided_invoice_id + end +end diff --git a/db/migrate/20260204130807_add_unique_index_to_payment_methods.rb b/db/migrate/20260204130807_add_unique_index_to_payment_methods.rb new file mode 100644 index 0000000..2691e70 --- /dev/null +++ b/db/migrate/20260204130807_add_unique_index_to_payment_methods.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddUniqueIndexToPaymentMethods < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :payment_methods, + [:payment_provider_customer_id, :provider_method_id], + unique: true, + name: "index_payment_methods_on_provider_customer_and_provider_method", + algorithm: :concurrently + end +end diff --git a/db/migrate/20260204153734_create_organization_id_and_sequential_id_index_on_customers.rb b/db/migrate/20260204153734_create_organization_id_and_sequential_id_index_on_customers.rb new file mode 100644 index 0000000..eca37b1 --- /dev/null +++ b/db/migrate/20260204153734_create_organization_id_and_sequential_id_index_on_customers.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class CreateOrganizationIdAndSequentialIdIndexOnCustomers < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :customers, [:organization_id, :sequential_id], + name: "index_customers_on_organization_id_and_sequential_id", + algorithm: :concurrently, + using: :btree, + if_not_exists: true + end +end diff --git a/db/migrate/20260209103526_add_indexes_to_wallet.rb b/db/migrate/20260209103526_add_indexes_to_wallet.rb new file mode 100644 index 0000000..53d5dea --- /dev/null +++ b/db/migrate/20260209103526_add_indexes_to_wallet.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddIndexesToWallet < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + def change + add_index :wallets, [:organization_id, :customer_id], + name: "index_wallets_on_organization_id_and_customer_id", + algorithm: :concurrently, + using: :btree, + if_not_exists: true + end +end diff --git a/db/migrate/20260209103920_add_index_on_external_id_to_customer.rb b/db/migrate/20260209103920_add_index_on_external_id_to_customer.rb new file mode 100644 index 0000000..0b944e0 --- /dev/null +++ b/db/migrate/20260209103920_add_index_on_external_id_to_customer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddIndexOnExternalIdToCustomer < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + def change + add_index :customers, [:organization_id, :external_id], + name: "index_customers_on_external_id", + algorithm: :concurrently, + using: :btree, + if_not_exists: true + end +end diff --git a/db/migrate/20260216115709_change_code_uniqueness_on_wallets.rb b/db/migrate/20260216115709_change_code_uniqueness_on_wallets.rb new file mode 100644 index 0000000..a2b7442 --- /dev/null +++ b/db/migrate/20260216115709_change_code_uniqueness_on_wallets.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class ChangeCodeUniquenessOnWallets < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + def change + safety_assured do + remove_index :wallets, [:customer_id, :code], + unique: true, + name: "index_uniq_wallet_code_per_customer", + algorithm: :concurrently, + if_exists: true + + add_index :wallets, [:customer_id, :code], + unique: true, + where: "status = 0", + name: "index_uniq_wallet_code_per_customer", + algorithm: :concurrently, + if_not_exists: true + end + end +end diff --git a/db/migrate/20260218102426_add_last_event_received_on_to_subscriptions.rb b/db/migrate/20260218102426_add_last_event_received_on_to_subscriptions.rb new file mode 100644 index 0000000..c97b903 --- /dev/null +++ b/db/migrate/20260218102426_add_last_event_received_on_to_subscriptions.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class AddLastEventReceivedOnToSubscriptions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :subscriptions, :last_received_event_on, :date + add_index :subscriptions, :last_received_event_on, + name: "index_subscriptions_on_last_received_event_on", + algorithm: :concurrently, + if_not_exists: true + # this will be dropped after we do the backfill after the OSS release + add_index :subscriptions, :id, + name: "index_subscriptions_on_last_received_event_on_null", + where: "last_received_event_on IS NULL", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20260219083335_update_flat_filters_to_version_4.rb b/db/migrate/20260219083335_update_flat_filters_to_version_4.rb new file mode 100644 index 0000000..9e989c3 --- /dev/null +++ b/db/migrate/20260219083335_update_flat_filters_to_version_4.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class UpdateFlatFiltersToVersion4 < ActiveRecord::Migration[8.0] + def change + update_view :flat_filters, version: 4, revert_to_version: 3 + end +end diff --git a/db/migrate/20260219102644_add_more_enrichment_to_enriched_events.rb b/db/migrate/20260219102644_add_more_enrichment_to_enriched_events.rb new file mode 100644 index 0000000..45e5bc1 --- /dev/null +++ b/db/migrate/20260219102644_add_more_enrichment_to_enriched_events.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddMoreEnrichmentToEnrichedEvents < ActiveRecord::Migration[8.0] + def change + add_column :enriched_events, :operation_type, :string, null: true + add_column :enriched_events, :precise_total_amount_cents, :decimal, precision: 40, scale: 15 + add_column :enriched_events, :target_wallet_code, :string, null: true + end +end diff --git a/db/migrate/20260219130831_create_user_devices.rb b/db/migrate/20260219130831_create_user_devices.rb new file mode 100644 index 0000000..95bb061 --- /dev/null +++ b/db/migrate/20260219130831_create_user_devices.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +class CreateUserDevices < ActiveRecord::Migration[8.0] + def change + create_table :user_devices, id: :uuid do |t| + t.references :user, type: :uuid, null: false, foreign_key: true, index: false + t.string :fingerprint, null: false + t.string :browser + t.string :os + t.string :device_type + t.datetime :last_logged_at, null: false + t.string :last_ip_address + t.timestamps + end + add_index :user_devices, [:user_id, :fingerprint], unique: true + end +end diff --git a/db/migrate/20260220131101_add_deleted_at_condition_to_index_payment_methods_on_provider_customer_and_provider_method.rb b/db/migrate/20260220131101_add_deleted_at_condition_to_index_payment_methods_on_provider_customer_and_provider_method.rb new file mode 100644 index 0000000..9846af9 --- /dev/null +++ b/db/migrate/20260220131101_add_deleted_at_condition_to_index_payment_methods_on_provider_customer_and_provider_method.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class AddDeletedAtConditionToIndexPaymentMethodsOnProviderCustomerAndProviderMethod < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + remove_index :payment_methods, + name: :index_payment_methods_on_provider_customer_and_provider_method, + algorithm: :concurrently + + add_index :payment_methods, + [:payment_provider_customer_id, :provider_method_id], + unique: true, + name: :index_payment_methods_on_provider_customer_and_provider_method, + where: "deleted_at IS NULL", + algorithm: :concurrently + end + + def down + remove_index :payment_methods, + name: :index_payment_methods_on_provider_customer_and_provider_method, + algorithm: :concurrently, + if_exists: true + + add_index :payment_methods, + [:payment_provider_customer_id, :provider_method_id], + unique: true, + name: :index_payment_methods_on_provider_customer_and_provider_method, + algorithm: :concurrently + end +end diff --git a/db/migrate/20260224134805_add_wallet_ongoing_balance_types_to_usage_monitoring_alert_types_enum.rb b/db/migrate/20260224134805_add_wallet_ongoing_balance_types_to_usage_monitoring_alert_types_enum.rb new file mode 100644 index 0000000..0a5d9ad --- /dev/null +++ b/db/migrate/20260224134805_add_wallet_ongoing_balance_types_to_usage_monitoring_alert_types_enum.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddWalletOngoingBalanceTypesToUsageMonitoringAlertTypesEnum < ActiveRecord::Migration[8.0] + def change + add_enum_value :usage_monitoring_alert_types, "wallet_ongoing_balance_amount", if_not_exists: true + add_enum_value :usage_monitoring_alert_types, "wallet_credits_ongoing_balance", if_not_exists: true + end +end diff --git a/db/migrate/20260227184913_add_billable_metric_lifetime_usage_units_alert_type.rb b/db/migrate/20260227184913_add_billable_metric_lifetime_usage_units_alert_type.rb new file mode 100644 index 0000000..0c258fd --- /dev/null +++ b/db/migrate/20260227184913_add_billable_metric_lifetime_usage_units_alert_type.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddBillableMetricLifetimeUsageUnitsAlertType < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + add_enum_value :usage_monitoring_alert_types, "billable_metric_lifetime_usage_units", if_not_exists: true + end + end + + def down + end +end diff --git a/db/migrate/20260304074158_add_quotes.rb b/db/migrate/20260304074158_add_quotes.rb new file mode 100644 index 0000000..b0cbbb7 --- /dev/null +++ b/db/migrate/20260304074158_add_quotes.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +class AddQuotes < ActiveRecord::Migration[8.0] + def change + create_enum :quote_status, %w[draft approved voided] + create_enum :quote_void_reason, %w[manual superseded cascade_of_expired cascade_of_voided] + create_enum :quote_order_type, %w[subscription_creation subscription_amendment one_off] + + create_table :quotes, id: :uuid do |t| + t.references :organization, + null: false, + foreign_key: true, + index: false, # covered by the composite unique index below + type: :uuid + t.references :customer, + null: false, + foreign_key: true, + type: :uuid + t.references :subscription, + foreign_key: true, + type: :uuid + t.string :number, null: false + t.integer :sequential_id, null: false + t.enum :order_type, + enum_type: :quote_order_type, + null: false + t.timestamps + + # constraints and indices + t.check_constraint "sequential_id > 0", + name: "quotes_constraint_sequential_id_positive" + t.index [:organization_id, :sequential_id], + unique: true, + name: "index_unique_quotes_on_organization_sequential_id" + t.index [:organization_id, :number], + unique: true, + name: "index_unique_quotes_on_organization_number" + end + + create_table :quote_versions, id: :uuid do |t| + # identity + t.references :organization, + null: false, + foreign_key: true, + type: :uuid + t.references :quote, + null: false, + foreign_key: true, + type: :uuid + t.integer :sequential_id, null: false # acts as version number + # lifecycle + t.enum :status, + enum_type: :quote_status, + null: false, + default: "draft" + t.datetime :approved_at + t.datetime :voided_at + t.enum :void_reason, enum_type: :quote_void_reason + # content + t.jsonb :billing_items + t.text :content + t.string :share_token + t.timestamps + + # constraints and indices + t.check_constraint "sequential_id > 0", + name: "quote_versions_constraint_sequential_id_positive" + t.check_constraint "(status = 'voided') = (void_reason IS NOT NULL AND voided_at IS NOT NULL)", + name: "quote_versions_constraint_void_fields_match_status" + t.check_constraint "(status = 'approved') = (approved_at IS NOT NULL)", + name: "quote_versions_constraint_approved_at_matches_status" + t.index [:quote_id, :sequential_id], + unique: true, + name: "index_unique_quote_versions_on_quote_sequential_id" + t.index :quote_id, + unique: true, + where: "status IN ('draft', 'approved')", + name: "index_unique_quote_versions_on_quote_active_status" + t.index :share_token, + unique: true, + name: "index_unique_quote_versions_on_share_token" + end + + create_table :quote_owners do |t| + t.references :organization, + null: false, + foreign_key: true, + type: :uuid + t.references :quote, + null: false, + foreign_key: true, + index: false, # covered by the composite unique index below + type: :uuid + t.references :user, + null: false, + foreign_key: true, + type: :uuid + t.timestamps + + t.index [:quote_id, :user_id], + unique: true, + name: "index_unique_quote_owners_on_quote_user" + end + end +end diff --git a/db/migrate/20260305100007_create_exports_wallet_transaction_consumptions.rb b/db/migrate/20260305100007_create_exports_wallet_transaction_consumptions.rb new file mode 100644 index 0000000..c6321a1 --- /dev/null +++ b/db/migrate/20260305100007_create_exports_wallet_transaction_consumptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsWalletTransactionConsumptions < ActiveRecord::Migration[8.0] + def change + create_view :exports_wallet_transaction_consumptions + end +end diff --git a/db/migrate/20260305161302_drop_redundant_invoices_indexes.rb b/db/migrate/20260305161302_drop_redundant_invoices_indexes.rb new file mode 100644 index 0000000..d027399 --- /dev/null +++ b/db/migrate/20260305161302_drop_redundant_invoices_indexes.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +class DropRedundantInvoicesIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + # Prefix of idx_on_billing_entity_id_billing_entity_sequential_id + remove_index :invoices, name: :index_invoices_on_billing_entity_id, algorithm: :concurrently, if_exists: true + + # Prefix of idx_on_organization_id_organization_sequential_id + remove_index :invoices, name: :index_invoices_on_organization_id, algorithm: :concurrently, if_exists: true + + # Low selectivity (~7 enum values), btree index ineffective for filtering + remove_index :invoices, name: :index_invoices_on_status, algorithm: :concurrently, if_exists: true + + # Boolean column, low selectivity, btree index ineffective for filtering + remove_index :invoices, name: :index_invoices_on_payment_overdue, algorithm: :concurrently, if_exists: true + + # Boolean column, low selectivity, btree index ineffective for filtering + remove_index :invoices, name: :index_invoices_on_self_billed, algorithm: :concurrently, if_exists: true + + # Never queried alone, only meaningful with organization_id or customer_id + remove_index :invoices, name: :index_invoices_on_sequential_id, algorithm: :concurrently, if_exists: true + + # Prefix of index_invoices_on_customer_id_and_sequential_id (UNIQUE) + remove_index :invoices, name: :index_invoices_on_customer_id, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20260305161303_drop_redundant_customers_indexes.rb b/db/migrate/20260305161303_drop_redundant_customers_indexes.rb new file mode 100644 index 0000000..3584483 --- /dev/null +++ b/db/migrate/20260305161303_drop_redundant_customers_indexes.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class DropRedundantCustomersIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + # Prefix of index_customers_on_organization_id_and_sequential_id + remove_index :customers, name: :index_customers_on_organization_id, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20260305165936_replace_invoices_issuing_date_index_with_composite.rb b/db/migrate/20260305165936_replace_invoices_issuing_date_index_with_composite.rb new file mode 100644 index 0000000..877632e --- /dev/null +++ b/db/migrate/20260305165936_replace_invoices_issuing_date_index_with_composite.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +class ReplaceInvoicesIssuingDateIndexWithComposite < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def up + safety_assured do + # All issuing_date queries are scoped to organization_id; + # the ordering matches the default sort in the InvoicesQuery + add_index :invoices, [:organization_id, :issuing_date, :created_at, :id], + name: :index_invoices_by_cursor, + order: {issuing_date: :desc, created_at: :desc, id: :asc}, + algorithm: :concurrently, + if_not_exists: true + end + + remove_index :invoices, name: :index_invoices_on_issuing_date, algorithm: :concurrently, if_exists: true + end + + def down + add_index :invoices, :issuing_date, algorithm: :concurrently, if_not_exists: true + + remove_index name: :index_invoices_by_cursor, algorithm: :concurrently, if_exists: true + end +end diff --git a/db/migrate/20260306115902_add_index_on_invoices_organization_id_status.rb b/db/migrate/20260306115902_add_index_on_invoices_organization_id_status.rb new file mode 100644 index 0000000..6e9dd38 --- /dev/null +++ b/db/migrate/20260306115902_add_index_on_invoices_organization_id_status.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddIndexOnInvoicesOrganizationIdStatus < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :invoices, [:organization_id, :status], + name: :idx_invoices_organization_id_status, + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20260311121245_add_unique_sequential_id_indexes.rb b/db/migrate/20260311121245_add_unique_sequential_id_indexes.rb new file mode 100644 index 0000000..facc736 --- /dev/null +++ b/db/migrate/20260311121245_add_unique_sequential_id_indexes.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class AddUniqueSequentialIdIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + # Add unique index, then remove the old non-unique one + add_index :customers, [:organization_id, :sequential_id], + unique: true, + where: "sequential_id IS NOT NULL", + algorithm: :concurrently, + name: :index_customers_on_org_id_and_sequential_id_unique, + if_not_exists: true + + remove_index :customers, [:organization_id, :sequential_id], + name: :index_customers_on_organization_id_and_sequential_id, + algorithm: :concurrently, + if_exists: true + + add_index :credit_notes, [:invoice_id, :sequential_id], + unique: true, + algorithm: :concurrently, + name: :index_credit_notes_on_invoice_id_and_sequential_id, + if_not_exists: true + end +end diff --git a/db/migrate/20260317130654_create_subscription_activation_rule_statuses.rb b/db/migrate/20260317130654_create_subscription_activation_rule_statuses.rb new file mode 100644 index 0000000..6a1407c --- /dev/null +++ b/db/migrate/20260317130654_create_subscription_activation_rule_statuses.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class CreateSubscriptionActivationRuleStatuses < ActiveRecord::Migration[8.0] + def up + create_enum :subscription_activation_rule_statuses, + %w[inactive pending satisfied declined failed expired not_applicable] + end + + def down + drop_enum :subscription_activation_rule_statuses + end +end diff --git a/db/migrate/20260317132544_create_subscription_activation_rule_types.rb b/db/migrate/20260317132544_create_subscription_activation_rule_types.rb new file mode 100644 index 0000000..ab282b4 --- /dev/null +++ b/db/migrate/20260317132544_create_subscription_activation_rule_types.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateSubscriptionActivationRuleTypes < ActiveRecord::Migration[8.0] + def up + create_enum :subscription_activation_rule_types, %w[payment] + end + + def down + drop_enum :subscription_activation_rule_types + end +end diff --git a/db/migrate/20260317132747_create_subscription_cancelation_reasons.rb b/db/migrate/20260317132747_create_subscription_cancelation_reasons.rb new file mode 100644 index 0000000..fcb2d89 --- /dev/null +++ b/db/migrate/20260317132747_create_subscription_cancelation_reasons.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class CreateSubscriptionCancelationReasons < ActiveRecord::Migration[8.0] + def up + create_enum :subscription_cancelation_reasons, %w[payment_failed timeout] + end + + def down + drop_enum :subscription_cancelation_reasons + end +end diff --git a/db/migrate/20260317132911_create_subscription_activation_rules.rb b/db/migrate/20260317132911_create_subscription_activation_rules.rb new file mode 100644 index 0000000..00ec204 --- /dev/null +++ b/db/migrate/20260317132911_create_subscription_activation_rules.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +class CreateSubscriptionActivationRules < ActiveRecord::Migration[8.0] + def change + create_table :subscription_activation_rules, id: :uuid do |t| + t.references :organization, type: :uuid, null: false, foreign_key: true, index: true + t.references :subscription, type: :uuid, null: false, foreign_key: true, index: false + t.column :type, :subscription_activation_rule_types, null: false + t.integer :timeout_hours, null: false, default: 0 + t.column :status, :subscription_activation_rule_statuses, null: false, default: "inactive" + t.datetime :expires_at + + t.timestamps + end + + add_index :subscription_activation_rules, [:subscription_id, :type], unique: true + add_index :subscription_activation_rules, [:status, :expires_at], + where: "status = 'pending' AND expires_at IS NOT NULL", + name: "index_activation_rules_pending_with_expiry" + end +end diff --git a/db/migrate/20260317134100_add_cancelation_reason_to_subscriptions.rb b/db/migrate/20260317134100_add_cancelation_reason_to_subscriptions.rb new file mode 100644 index 0000000..a0236f7 --- /dev/null +++ b/db/migrate/20260317134100_add_cancelation_reason_to_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddCancelationReasonToSubscriptions < ActiveRecord::Migration[8.0] + def change + add_column :subscriptions, :cancelation_reason, :subscription_cancelation_reasons + end +end diff --git a/db/migrate/20260319103035_add_payment_method_id_and_skip_psp_to_invoices.rb b/db/migrate/20260319103035_add_payment_method_id_and_skip_psp_to_invoices.rb new file mode 100644 index 0000000..cbc0a4e --- /dev/null +++ b/db/migrate/20260319103035_add_payment_method_id_and_skip_psp_to_invoices.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddPaymentMethodIdAndSkipPspToInvoices < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_column :invoices, :payment_method_id, :uuid + add_column :invoices, :skip_automatic_payment, :boolean + add_index :invoices, :payment_method_id, algorithm: :concurrently + end +end diff --git a/db/migrate/20260319125125_add_index_on_webhooks_organization_endpoint_type_updated.rb b/db/migrate/20260319125125_add_index_on_webhooks_organization_endpoint_type_updated.rb new file mode 100644 index 0000000..def51ed --- /dev/null +++ b/db/migrate/20260319125125_add_index_on_webhooks_organization_endpoint_type_updated.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddIndexOnWebhooksOrganizationEndpointTypeUpdated < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + safety_assured do + add_index :webhooks, + [:organization_id, :webhook_endpoint_id, :webhook_type, :updated_at], + name: :index_webhooks_for_query, + if_not_exists: true, + algorithm: :concurrently + end + end +end diff --git a/db/migrate/20260324124033_add_foreign_key_on_invoices_payment_method.rb b/db/migrate/20260324124033_add_foreign_key_on_invoices_payment_method.rb new file mode 100644 index 0000000..8b3113e --- /dev/null +++ b/db/migrate/20260324124033_add_foreign_key_on_invoices_payment_method.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddForeignKeyOnInvoicesPaymentMethod < ActiveRecord::Migration[8.0] + def change + add_foreign_key :invoices, :payment_methods, column: :payment_method_id, validate: false + end +end diff --git a/db/migrate/20260325150808_validate_invoices_payment_method_foreign_key.rb b/db/migrate/20260325150808_validate_invoices_payment_method_foreign_key.rb new file mode 100644 index 0000000..2161085 --- /dev/null +++ b/db/migrate/20260325150808_validate_invoices_payment_method_foreign_key.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class ValidateInvoicesPaymentMethodForeignKey < ActiveRecord::Migration[8.0] + def change + validate_foreign_key :invoices, :payment_methods + end +end diff --git a/db/migrate/20260326130631_add_incompleted_at_to_subscriptions.rb b/db/migrate/20260326130631_add_incompleted_at_to_subscriptions.rb new file mode 100644 index 0000000..567851b --- /dev/null +++ b/db/migrate/20260326130631_add_incompleted_at_to_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddIncompletedAtToSubscriptions < ActiveRecord::Migration[8.0] + def change + add_column :subscriptions, :incompleted_at, :datetime + end +end diff --git a/db/migrate/20260327140626_add_unique_index_on_commitments_taxes_by_commitment_id_and_tax_id.rb b/db/migrate/20260327140626_add_unique_index_on_commitments_taxes_by_commitment_id_and_tax_id.rb new file mode 100644 index 0000000..8855f20 --- /dev/null +++ b/db/migrate/20260327140626_add_unique_index_on_commitments_taxes_by_commitment_id_and_tax_id.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddUniqueIndexOnCommitmentsTaxesByCommitmentIdAndTaxId < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :commitments_taxes, [:commitment_id, :tax_id], unique: true, algorithm: :concurrently, if_not_exists: true + end +end diff --git a/db/migrate/20260331103301_add_activated_at_to_subscriptions.rb b/db/migrate/20260331103301_add_activated_at_to_subscriptions.rb new file mode 100644 index 0000000..2d00f46 --- /dev/null +++ b/db/migrate/20260331103301_add_activated_at_to_subscriptions.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddActivatedAtToSubscriptions < ActiveRecord::Migration[8.0] + def change + add_column :subscriptions, :activated_at, :datetime + end +end diff --git a/db/migrate/20260331122448_add_webhooks_cleanup_index.rb b/db/migrate/20260331122448_add_webhooks_cleanup_index.rb new file mode 100644 index 0000000..1b61a7a --- /dev/null +++ b/db/migrate/20260331122448_add_webhooks_cleanup_index.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddWebhooksCleanupIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :webhooks, + :updated_at, + name: :index_webhooks_on_updated_at_for_cleanup, + algorithm: :concurrently, + include: [:id], + if_not_exists: true + end +end diff --git a/db/migrate/20260403184747_add_index_on_webhooks_object_type_object_id_webhook_type.rb b/db/migrate/20260403184747_add_index_on_webhooks_object_type_object_id_webhook_type.rb new file mode 100644 index 0000000..ffc45eb --- /dev/null +++ b/db/migrate/20260403184747_add_index_on_webhooks_object_type_object_id_webhook_type.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class AddIndexOnWebhooksObjectTypeObjectIdWebhookType < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :webhooks, [:object_type, :object_id, :webhook_type], + name: "index_webhooks_on_object_type_and_object_id_and_webhook_type", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20260403184752_add_index_on_subscriptions_ending_at_active.rb b/db/migrate/20260403184752_add_index_on_subscriptions_ending_at_active.rb new file mode 100644 index 0000000..800b68c --- /dev/null +++ b/db/migrate/20260403184752_add_index_on_subscriptions_ending_at_active.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddIndexOnSubscriptionsEndingAtActive < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :subscriptions, :ending_at, + where: "status = 1 AND ending_at IS NOT NULL", + name: "index_subscriptions_on_ending_at_active", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20260407091845_add_presentation_breakdowns.rb b/db/migrate/20260407091845_add_presentation_breakdowns.rb new file mode 100644 index 0000000..69590d6 --- /dev/null +++ b/db/migrate/20260407091845_add_presentation_breakdowns.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddPresentationBreakdowns < ActiveRecord::Migration[8.0] + def change + create_table :presentation_breakdowns, id: :uuid do |t| + t.references :organization, null: false, foreign_key: true, type: :uuid + t.references :fee, null: false, foreign_key: true, type: :uuid, index: {unique: true} + + t.jsonb :presentation_by, null: false, default: [] + t.decimal :units, precision: 30, scale: 10, null: false, default: 0.0 + + t.timestamps + end + end +end diff --git a/db/migrate/20260409151451_create_enriched_store_migrations.rb b/db/migrate/20260409151451_create_enriched_store_migrations.rb new file mode 100644 index 0000000..9daabe3 --- /dev/null +++ b/db/migrate/20260409151451_create_enriched_store_migrations.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class CreateEnrichedStoreMigrations < ActiveRecord::Migration[8.0] + def up + create_enum :enriched_store_migration_status, %w[pending checking processing enabling completed failed] + + create_table :enriched_store_migrations, id: :uuid do |t| + t.references :organization, type: :uuid, null: false, foreign_key: true, index: {unique: true} + t.enum :status, enum_type: "enriched_store_migration_status", null: false, default: "pending" + t.datetime :started_at + t.datetime :completed_at + t.text :error_message + + t.timestamps + end + end + + def down + safety_assured do + drop_table :enriched_store_migrations + drop_enum :enriched_store_migration_status + end + end +end diff --git a/db/migrate/20260409161142_create_enriched_store_subscription_migrations.rb b/db/migrate/20260409161142_create_enriched_store_subscription_migrations.rb new file mode 100644 index 0000000..cbec21b --- /dev/null +++ b/db/migrate/20260409161142_create_enriched_store_subscription_migrations.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +class CreateEnrichedStoreSubscriptionMigrations < ActiveRecord::Migration[8.0] + def up + create_enum :enriched_store_sub_migration_status, %w[ + pending comparing reprocessing waiting_for_enrichment + deduplicating dedup_paused validating completed failed + ] + + create_table :enriched_store_subscription_migrations, id: :uuid do |t| + t.references :enriched_store_migration, type: :uuid, null: false, foreign_key: true + t.references :subscription, type: :uuid, null: false, foreign_key: true + t.references :organization, type: :uuid, null: false, foreign_key: true, index: true + t.enum :status, enum_type: "enriched_store_sub_migration_status", null: false, default: "pending" + t.jsonb :billable_metric_codes, default: [] + t.integer :events_reprocessed_count, default: 0 + t.integer :duplicates_removed_count, default: 0 + t.jsonb :dedup_pending_queries, default: [] + t.jsonb :comparison_results, default: {} + t.text :error_message + t.datetime :started_at + t.datetime :completed_at + t.integer :attempts, default: 0 + + t.timestamps + end + + add_index :enriched_store_subscription_migrations, + [:enriched_store_migration_id, :subscription_id], + unique: true, + name: "idx_enriched_store_sub_migrations_on_migration_and_subscription" + end + + def down + drop_table :enriched_store_subscription_migrations + drop_enum :enriched_store_sub_migration_status + end +end diff --git a/db/migrate/20260415160654_add_index_on_invoices_payment_due_date.rb b/db/migrate/20260415160654_add_index_on_invoices_payment_due_date.rb new file mode 100644 index 0000000..5d75568 --- /dev/null +++ b/db/migrate/20260415160654_add_index_on_invoices_payment_due_date.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class AddIndexOnInvoicesPaymentDueDate < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :invoices, :payment_due_date, + where: "status = 1 + AND payment_status <> 1 + AND payment_overdue = false + AND payment_dispute_lost_at IS NULL", + name: :index_invoices_on_payment_due_date, + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20260416111922_add_slug_to_organizations.rb b/db/migrate/20260416111922_add_slug_to_organizations.rb new file mode 100644 index 0000000..94b3d28 --- /dev/null +++ b/db/migrate/20260416111922_add_slug_to_organizations.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddSlugToOrganizations < ActiveRecord::Migration[8.0] + def up + safety_assured do + add_column :organizations, :slug, :string + change_column_default :organizations, :slug, from: nil, to: -> { "'org-' || substr(md5(random()::text), 1, 8)" } + end + end + + def down + remove_column :organizations, :slug # rubocop:disable Lago/NoDropColumnOrTable + end +end diff --git a/db/migrate/20260416111923_backfill_organization_slugs.rb b/db/migrate/20260416111923_backfill_organization_slugs.rb new file mode 100644 index 0000000..6a46d08 --- /dev/null +++ b/db/migrate/20260416111923_backfill_organization_slugs.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +class BackfillOrganizationSlugs < ActiveRecord::Migration[8.0] + RESERVED_SLUGS = %w[ + auth login sign-up forgot-password reset-password invitation + customer-portal 404 forbidden api admin graphql webhooks google okta + settings new design-system devtool + customers customer plans plan invoices invoice subscriptions + coupons coupon add-ons add-on billable-metrics billable-metric + credit-notes analytics analytics-v2 forecasts payments payment + features feature tax webhook api-keys create update duplicate + ].freeze + + def up + Organization.unscoped.find_each do |org| + candidate = generate_slug_for(org.name) + candidate = resolve_collision(candidate) + org.update_column(:slug, candidate) # rubocop:disable Rails/SkipsModelValidations + end + end + + def down + # No-op: slugs will be removed when the column is dropped + end + + private + + def generate_slug_for(name) + candidate = ActiveSupport::Inflector.transliterate(name.to_s) + .parameterize + .tr("_", "-") + .gsub(/-{2,}/, "-") + .truncate(40, omission: "") + .gsub(/\A-|-\z/, "") + + if candidate.length < 3 || candidate.match?(/\A\d+\z/) || RESERVED_SLUGS.include?(candidate) + generate_random_slug + else + candidate + end + end + + def resolve_collision(slug) + return slug unless slug_taken?(slug) + + if slug.start_with?("org-") && slug.length == 9 + generate_random_slug + else + loop do + candidate = "#{slug.truncate(36, omission: "").delete_suffix("-")}-#{SecureRandom.alphanumeric(3).downcase}" + return candidate unless slug_taken?(candidate) + end + end + end + + def generate_random_slug + loop do + candidate = "org-#{SecureRandom.alphanumeric(5).downcase}" + return candidate unless slug_taken?(candidate) + end + end + + def slug_taken?(slug) + Organization.unscoped.where(slug: slug).exists? + end +end diff --git a/db/migrate/20260416124232_finalize_slug_on_organizations.rb b/db/migrate/20260416124232_finalize_slug_on_organizations.rb new file mode 100644 index 0000000..8ccaa5d --- /dev/null +++ b/db/migrate/20260416124232_finalize_slug_on_organizations.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class FinalizeSlugOnOrganizations < ActiveRecord::Migration[8.0] + def up + safety_assured do + change_column_null :organizations, :slug, false + change_column_default :organizations, :slug, from: -> { "'org-' || substr(md5(random()::text), 1, 8)" }, to: nil + end + end + + def down + change_column_default :organizations, :slug, from: nil, to: -> { "'org-' || substr(md5(random()::text), 1, 8)" } + change_column_null :organizations, :slug, true + end +end diff --git a/db/migrate/20260416124233_add_unique_index_on_organizations_slug.rb b/db/migrate/20260416124233_add_unique_index_on_organizations_slug.rb new file mode 100644 index 0000000..32a3a68 --- /dev/null +++ b/db/migrate/20260416124233_add_unique_index_on_organizations_slug.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddUniqueIndexOnOrganizationsSlug < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :organizations, :slug, unique: true, algorithm: :concurrently, if_not_exists: true + end +end diff --git a/db/migrate/20260420114717_change_presentation_breakdowns_units_decimal.rb b/db/migrate/20260420114717_change_presentation_breakdowns_units_decimal.rb new file mode 100644 index 0000000..5d14f99 --- /dev/null +++ b/db/migrate/20260420114717_change_presentation_breakdowns_units_decimal.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class ChangePresentationBreakdownsUnitsDecimal < ActiveRecord::Migration[8.0] + def up + safety_assured do + remove_column :presentation_breakdowns, :units # rubocop:disable Lago/NoDropColumnOrTable + end + add_column :presentation_breakdowns, :units, :decimal, null: false, default: 0 + end + + def down + safety_assured do + remove_column :presentation_breakdowns, :units # rubocop:disable Lago/NoDropColumnOrTable + end + + add_column :presentation_breakdowns, :units, :decimal, precision: 30, scale: 10, null: false, default: 0 + end +end diff --git a/db/migrate/20260421013319_add_original_fee_id_to_fees.rb b/db/migrate/20260421013319_add_original_fee_id_to_fees.rb new file mode 100644 index 0000000..493361f --- /dev/null +++ b/db/migrate/20260421013319_add_original_fee_id_to_fees.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddOriginalFeeIdToFees < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :fees, :original_fee, type: :uuid, index: {algorithm: :concurrently} + add_foreign_key :fees, :fees, column: :original_fee_id, validate: false + end +end diff --git a/db/migrate/20260421021503_validate_fees_original_fee_id_foreign_key.rb b/db/migrate/20260421021503_validate_fees_original_fee_id_foreign_key.rb new file mode 100644 index 0000000..9c7b1e4 --- /dev/null +++ b/db/migrate/20260421021503_validate_fees_original_fee_id_foreign_key.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class ValidateFeesOriginalFeeIdForeignKey < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + validate_foreign_key :fees, column: :original_fee_id + end +end diff --git a/db/migrate/20260421103557_update_pay_in_advance_duplication_guard_indexes.rb b/db/migrate/20260421103557_update_pay_in_advance_duplication_guard_indexes.rb new file mode 100644 index 0000000..0d11bab --- /dev/null +++ b/db/migrate/20260421103557_update_pay_in_advance_duplication_guard_indexes.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# Given the fees table is large, this migration builds the new indexes under +# temporary names first, then drops the old ones, then renames. +# This keeps a unique constraint enforcing the guard at all times, +# avoiding a window where duplicate pay-in-advance fees could be inserted and +# later cause the concurrent build to fail and leave an INVALID index behind. +class UpdatePayInAdvanceDuplicationGuardIndexes < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + CHARGE_INDEX = :idx_pay_in_advance_duplication_guard_charge + CHARGE_FILTER_INDEX = :idx_pay_in_advance_duplication_guard_charge_filter + CHARGE_INDEX_NEW = :idx_pay_in_advance_duplication_guard_charge_new + CHARGE_FILTER_INDEX_NEW = :idx_pay_in_advance_duplication_guard_charge_filter_new + + def up + add_index :fees, + [:pay_in_advance_event_transaction_id, :charge_id], + unique: true, + name: CHARGE_INDEX_NEW, + where: "deleted_at IS NULL AND charge_filter_id IS NULL AND pay_in_advance_event_transaction_id IS NOT NULL AND pay_in_advance = true AND duplicated_in_advance = false AND original_fee_id IS NULL", + algorithm: :concurrently, + if_not_exists: true + + add_index :fees, + [:pay_in_advance_event_transaction_id, :charge_id, :charge_filter_id], + unique: true, + name: CHARGE_FILTER_INDEX_NEW, + where: "deleted_at IS NULL AND charge_filter_id IS NOT NULL AND pay_in_advance_event_transaction_id IS NOT NULL AND pay_in_advance = true AND duplicated_in_advance = false AND original_fee_id IS NULL", + algorithm: :concurrently, + if_not_exists: true + + remove_index :fees, name: CHARGE_INDEX, if_exists: true, algorithm: :concurrently + remove_index :fees, name: CHARGE_FILTER_INDEX, if_exists: true, algorithm: :concurrently + + safety_assured do + execute "ALTER INDEX #{CHARGE_INDEX_NEW} RENAME TO #{CHARGE_INDEX}" + execute "ALTER INDEX #{CHARGE_FILTER_INDEX_NEW} RENAME TO #{CHARGE_FILTER_INDEX}" + end + end + + def down + add_index :fees, + [:pay_in_advance_event_transaction_id, :charge_id], + unique: true, + name: CHARGE_INDEX_NEW, + where: "deleted_at IS NULL AND charge_filter_id IS NULL AND pay_in_advance_event_transaction_id IS NOT NULL AND pay_in_advance = true AND duplicated_in_advance = false", + algorithm: :concurrently, + if_not_exists: true + + add_index :fees, + [:pay_in_advance_event_transaction_id, :charge_id, :charge_filter_id], + unique: true, + name: CHARGE_FILTER_INDEX_NEW, + where: "deleted_at IS NULL AND charge_filter_id IS NOT NULL AND pay_in_advance_event_transaction_id IS NOT NULL AND pay_in_advance = true AND duplicated_in_advance = false", + algorithm: :concurrently, + if_not_exists: true + + remove_index :fees, name: CHARGE_INDEX, if_exists: true, algorithm: :concurrently + remove_index :fees, name: CHARGE_FILTER_INDEX, if_exists: true, algorithm: :concurrently + + safety_assured do + execute "ALTER INDEX #{CHARGE_INDEX_NEW} RENAME TO #{CHARGE_INDEX}" + execute "ALTER INDEX #{CHARGE_FILTER_INDEX_NEW} RENAME TO #{CHARGE_FILTER_INDEX}" + end + end +end diff --git a/db/migrate/20260421123920_remove_presentation_breakdowns_unique_fee_index.rb b/db/migrate/20260421123920_remove_presentation_breakdowns_unique_fee_index.rb new file mode 100644 index 0000000..7129333 --- /dev/null +++ b/db/migrate/20260421123920_remove_presentation_breakdowns_unique_fee_index.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class RemovePresentationBreakdownsUniqueFeeIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + remove_index :presentation_breakdowns, :fee_id, name: "index_presentation_breakdowns_on_fee_id", algorithm: :concurrently, if_exists: true + add_index :presentation_breakdowns, :fee_id, name: "index_presentation_breakdowns_on_fee_id", algorithm: :concurrently, if_not_exists: true + end +end diff --git a/db/migrate/20260424170418_add_events_organization_created_at_index.rb b/db/migrate/20260424170418_add_events_organization_created_at_index.rb new file mode 100644 index 0000000..bfbc124 --- /dev/null +++ b/db/migrate/20260424170418_add_events_organization_created_at_index.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddEventsOrganizationCreatedAtIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :events, + [:organization_id, :created_at], + order: {created_at: :desc}, + where: "deleted_at IS NULL", + name: "index_events_on_organization_id_and_created_at", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/migrate/20260429123434_add_billing_entity_to_wallets.rb b/db/migrate/20260429123434_add_billing_entity_to_wallets.rb new file mode 100644 index 0000000..9132ea8 --- /dev/null +++ b/db/migrate/20260429123434_add_billing_entity_to_wallets.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddBillingEntityToWallets < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :wallets, :billing_entity, type: :uuid, null: true, + index: {algorithm: :concurrently} + add_foreign_key :wallets, :billing_entities, validate: false + end +end diff --git a/db/migrate/20260429133747_add_billing_entity_to_subscriptions.rb b/db/migrate/20260429133747_add_billing_entity_to_subscriptions.rb new file mode 100644 index 0000000..545998d --- /dev/null +++ b/db/migrate/20260429133747_add_billing_entity_to_subscriptions.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddBillingEntityToSubscriptions < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_reference :subscriptions, :billing_entity, type: :uuid, null: true, + index: {algorithm: :concurrently} + add_foreign_key :subscriptions, :billing_entities, validate: false + end +end diff --git a/db/migrate/20260430102813_create_exports_usage_monitoring_triggered_alerts.rb b/db/migrate/20260430102813_create_exports_usage_monitoring_triggered_alerts.rb new file mode 100644 index 0000000..454f0af --- /dev/null +++ b/db/migrate/20260430102813_create_exports_usage_monitoring_triggered_alerts.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsUsageMonitoringTriggeredAlerts < ActiveRecord::Migration[8.0] + def change + create_view :exports_usage_monitoring_triggered_alerts + end +end diff --git a/db/migrate/20260430102814_create_exports_usage_monitoring_alert_thresholds.rb b/db/migrate/20260430102814_create_exports_usage_monitoring_alert_thresholds.rb new file mode 100644 index 0000000..f9cf831 --- /dev/null +++ b/db/migrate/20260430102814_create_exports_usage_monitoring_alert_thresholds.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class CreateExportsUsageMonitoringAlertThresholds < ActiveRecord::Migration[8.0] + def change + create_view :exports_usage_monitoring_alert_thresholds + end +end diff --git a/db/migrate/20260504134804_add_events_organization_transaction_id_index.rb b/db/migrate/20260504134804_add_events_organization_transaction_id_index.rb new file mode 100644 index 0000000..e662219 --- /dev/null +++ b/db/migrate/20260504134804_add_events_organization_transaction_id_index.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class AddEventsOrganizationTransactionIdIndex < ActiveRecord::Migration[8.0] + disable_ddl_transaction! + + def change + add_index :events, + [:organization_id, :transaction_id], + where: "deleted_at IS NULL", + name: "index_events_on_organization_id_and_transaction_id", + algorithm: :concurrently, + if_not_exists: true + end +end diff --git a/db/seeds.rb b/db/seeds.rb new file mode 100644 index 0000000..957ac32 --- /dev/null +++ b/db/seeds.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +Dir[Rails.root.join("db/seeds/*.rb")].sort.each do |seed| + load seed +end diff --git a/db/seeds/01_base.rb b/db/seeds/01_base.rb new file mode 100644 index 0000000..b5dc210 --- /dev/null +++ b/db/seeds/01_base.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require "faker" +require "factory_bot_rails" + +# Enable premium features for seeding +License.instance_variable_set(:@premium, true) + +# Ensure predefined roles exist (normally created by migration) +Role.find_or_create_by!(code: "admin", organization_id: nil) do |role| + role.admin = true + role.name = "Admin" + role.description = "Administrator having all permissions" +end + +Role.find_or_create_by!(code: "finance", organization_id: nil) do |role| + role.name = "Finance" + role.description = "Finance role with permissions to manage financial data" +end + +Role.find_or_create_by!(code: "manager", organization_id: nil) do |role| + role.name = "Manager" + role.description = "The predefined manager role" +end + +# NOTE: create users and an organization +user = User.create_with(password: "ILoveLago") + .find_or_create_by(email: "gavin@hooli.com") + +dinesh = User.create_with(password: "ILoveLago") + .find_or_create_by(email: "dinesh@hooli.com") + +organizations_data = [ + { + name: "Hooli", + id: "11111111-2222-3333-4444-555555555555", + api_key: "lago_key-hooli-1234567890", + clickhouse_events_store: false + } +] +if ENV["LAGO_CLICKHOUSE_ENABLED"] == "true" + organizations_data << { + name: "Hooli Clickhouse", + id: "22222222-3333-4444-5555-666666666666", + api_key: "lago_key-hooli-clickhouse-1234567890", + clickhouse_events_store: true + } +end + +organizations_data.each do |org_data| + name, id, api_key, clickhouse_events_store = org_data.values_at(:name, :id, :api_key, :clickhouse_events_store) + organization = Organization.find_by(name:) + if organization.nil? + organization = Organization.create!(id:, name:, clickhouse_events_store:) + end + organization.update!({ + premium_integrations: Organization::PREMIUM_INTEGRATIONS, + invoice_footer: "Hooli is a fictional company." + }) + billing_entity = BillingEntity.find_or_create_by!(organization:, name: "Hooli", code: "hooli") + billing_entity.update!( + email: "gavin@hooli.com", + email_settings: BillingEntity::EMAIL_SETTINGS + ) + membership = Membership.find_or_create_by!(user:, organization:) + + # Ensure the membership has an admin role in the new roles system + admin_role = Role.find_by!(admin: true) + MembershipRole.find_or_create_by!(membership:, organization:, role: admin_role) + + # Second user with finance role + dinesh_membership = Membership.find_or_create_by!(user: dinesh, organization:) + finance_role = Role.find_by!(code: "finance") + MembershipRole.find_or_create_by!(membership: dinesh_membership, organization:, role: finance_role) + + # Custom role combining finance and manager permissions + accountant_permissions = Permission::DATA + .select { |_, roles| roles.include?("finance") || roles.include?("manager") }.keys + Role.find_or_create_by!(code: "accountant", organization:) do |role| + role.name = "Accountant" + role.description = "Custom role combining finance and manager permissions" + role.permissions = accountant_permissions + end + + # Anrok integration + unless Integrations::AnrokIntegration.exists?(organization:, code: "anrok") + Integrations::AnrokIntegration.create!( + organization:, + code: "anrok", + name: "Anrok Integration", + secrets: {connection_id: SecureRandom.uuid, api_key: SecureRandom.uuid}.to_json + ) + end + + if Rails.env.development? + # In development, we create a webhook endpoint to the local webhook-tester service. + WebhookEndpoint.find_or_create_by!(organization:, webhook_url: "http://webhook/#{organization.id}") + end + organization.api_keys.destroy_all + organization.api_keys.create!(name: "Expired Key", expires_at: 1.day.ago, last_used_at: 36.hours.ago, permissions: {"customer" => ["read", "write"]}) + k = organization.api_keys.create!(name: "Hooli Key", permissions: ApiKey.default_permissions) + k.update_columns(value: api_key) # rubocop:disable Rails/SkipsModelValidations + + # == BillableMetrics + + sum_bm = BillableMetric.find_by(organization:, code: "sum_bm") || BillableMetrics::CreateService.call!( + organization_id: organization.id, + aggregation_type: "sum_agg", + name: "Sum BM", + code: "sum_bm", + field_name: "custom_field" + ).billable_metric + + count_bm = BillableMetric.find_by(organization:, code: "count_bm") || BillableMetrics::CreateService.call!( + organization_id: organization.id, + aggregation_type: "count_agg", + name: "Count BM", + code: "count_bm" + ).billable_metric + + # == Taxes + + unless Tax.exists?(organization:, code: "lago_eu_fr_standard") + Taxes::CreateService.call!( + organization:, + params: { + name: "FR Standard", + code: "lago_eu_fr_standard", + description: "FR Standard", + rate: 20 + } + ) + end + + # == Addons + + unless AddOn.exists?(organization:, code: "setup_fee") + AddOns::CreateService.call!( + organization_id: organization.id, + name: "Setup Fee", + code: "setup_fee", + description: "Fee for setting up the subscription", + amount_cents: 100_00, + amount_currency: "EUR", + tax_codes: ["lago_eu_fr_standard"] + ) + end + + unless AddOn.exists?(organization:, code: "setup_fee") + AddOns::CreateService.call!( + organization_id: organization.id, + name: "Hour of Premium Support", + code: "support_hour", + description: "One hour of support from our experts", + amount_cents: 84_99, + amount_currency: "EUR", + tax_codes: ["lago_eu_fr_standard"] + ) + end + + # == Coupons + + unless Coupon.exists?(organization:, code: "20_percent_off") + Coupons::CreateService.call!( + organization_id: organization.id, + name: "20% off", + code: "20_percent_off", + coupon_type: "percentage", + percentage_rate: 20, + frequency: "forever", + expiration: "no_expiration" + ) + end + + unless Coupon.exists?(organization:, code: "10_euro_off") + Coupons::CreateService.call!( + organization_id: organization.id, + name: "10€ off", + code: "10_euro_off", + coupon_type: "fixed_amount", + amount_cents: 1000, + amount_currency: "EUR", + frequency: "forever", + expiration: "no_expiration" + ) + end + + # == Plans + + unless Plan.exists?(organization:, code: "standard_plan") + Plans::CreateService.call!( + organization_id: organization.id, + name: "Standard Plan", + code: "standard_plan", + interval: "monthly", + pay_in_advance: true, + amount_cents: 19_99, + amount_currency: "EUR", + tax_codes: ["lago_eu_fr_standard"], + charges: [ + { + billable_metric_id: sum_bm.id, + charge_model: "standard", + amount_currency: "EUR", + pay_in_advance: false, + properties: { + amount: 100.to_s + } + }, + { + billable_metric_id: count_bm.id, + charge_model: "standard", + amount_currency: "EUR", + pay_in_advance: false, + properties: { + amount: 499.to_s + } + } + ] + ) + end + + unless Plan.exists?(organization:, code: "premium_plan") + Plans::CreateService.call!( + organization_id: organization.id, + name: "Premium Plan", + code: "premium_plan", + interval: "monthly", + pay_in_advance: true, + amount_cents: 100_00, + amount_currency: "EUR", + tax_codes: ["lago_eu_fr_standard"], + charges: [ + { + billable_metric_id: sum_bm.id, + charge_model: "standard", + amount_currency: "EUR", + pay_in_advance: false, + properties: { + amount: 30.to_s + } + }, + { + billable_metric_id: count_bm.id, + charge_model: "standard", + amount_currency: "EUR", + pay_in_advance: false, + properties: { + amount: 399.to_s + } + } + ] + ) + end + + unless PricingUnit.exists?(organization:, code: "xyz") + PricingUnits::CreateService.call!( + organization:, + name: "xyz", + code: "xyz", + short_name: "XYZ" + ) + end +end diff --git a/db/seeds/02_john_doe.rb b/db/seeds/02_john_doe.rb new file mode 100644 index 0000000..9319edd --- /dev/null +++ b/db/seeds/02_john_doe.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +# == John Doe + +# NOTE: If hooli is not found, run 01_base.rb first +organization = Organization.find_by!(name: "Hooli") +billing_entity = organization.default_billing_entity +plan = Plan.find_by!(code: "premium_plan") + +customer_external_id = "cust_john-doe" +sub_external_id = "sub_john-doe-main" +started_at = 6.months.ago +currency = "EUR" +addon = AddOn.find_by!(code: "setup_fee") + +john_doe = Customer.create_with( + name: "John Doe", + country: "FR", + address_line1: Faker::Address.street_address, + address_line2: Faker::Address.secondary_address, + zipcode: Faker::Address.zip_code, + email: "john.doe@example.com", + city: Faker::Address.city, + url: Faker::Internet.url, + phone: Faker::PhoneNumber.phone_number, + logo_url: Faker::Internet.url, + legal_number: Faker::Company.duns_number, + currency:, + created_at: started_at +).find_or_create_by!( + organization:, + billing_entity:, + external_id: customer_external_id +) + +sub = john_doe.subscriptions.active.find_by(external_id: sub_external_id) +unless sub + sub = Subscriptions::CreateService.call!( + customer: john_doe, + plan:, + params: { + name: "Main Subscription", + billing_time: :calendar, + subscription_at: started_at, + started_at: started_at, + external_id: sub_external_id + } + ).subscription + sub.update(created_at: started_at) +end + +unless john_doe.wallets.active.exists? + params = { + organization_id: organization.id, + customer: john_doe, + name: "Main wallet", + rate_amount: "3", + paid_top_up_min_amount_cents: 12_00 + } + + if License.premium? + params[:recurring_transaction_rules] = [ + { + granted_credits: "10", + interval: :weekly, + method: :fixed, + expiration_at: 1.year.from_now, + trigger: :interval, + transaction_metadata: [{key: :origin, value: :seeder}], + transaction_name: "10 credits for free 🎁" + } + ] + end + + Wallets::CreateService.call!(params:) + + john_doe.wallets.create!( + organization:, + customer: john_doe, + name: "Terminated wallet", + rate_amount: "1", + status: :terminated, + terminated_at: 1.week.ago, + currency: + ) +end + +# == one-off invoice with credit note + +one_off = Invoices::CreateOneOffService.call!( + customer: john_doe, + currency:, + fees: [{ + add_on_id: addon.id, + name: addon.name, + units: 2, + unit_amount_cents: addon.amount_cents, + tax_codes: ["lago_eu_fr_standard"] + }], + timestamp: started_at + 5.days, + skip_psp: true +).invoice + +if License.premium? + CreditNotes::CreateService.call!( + invoice: one_off, + credit_amount_cents: 48_00, + description: "Generated by seeders", + items: [ + fee_id: one_off.fees.sole.id, amount_cents: 40_00 + ] + ) +end + +# == second subscriptions with plan override + +sub_2_external_id = sub_external_id + "-2" +started_at_2 = started_at + 4.days + 1.hour + 13.minutes +sub2 = john_doe.subscriptions.active.find_by(external_id: sub_2_external_id) +unless sub2 + sub = Subscriptions::CreateService.call!( + customer: john_doe, + plan:, + params: { + name: "Subscription With Plan Override", + billing_time: :calendar, + subscription_at: started_at_2, + started_at: started_at_2, + external_id: sub_2_external_id, + plan_overrides: { + name: "Premium with Override", + description: "This plan is used to test the override functionality", + amount_cents: 211_00 + } + } + ).subscription + sub.update(created_at: started_at_2) +end diff --git a/db/seeds/05_entitlements.rb b/db/seeds/05_entitlements.rb new file mode 100644 index 0000000..e2ec646 --- /dev/null +++ b/db/seeds/05_entitlements.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +return unless License.premium? + +# NOTE: If hooli is not found, run 01_base.rb first +organization = Organization.find_by!(name: "Hooli") +plan = Plan.find_by!(code: "premium_plan") +sub = Subscription.find_by!(external_id: "sub_john-doe-main") + +def clean_up_feature!(feature) + Entitlement::SubscriptionFeatureRemoval.where(feature: feature).with_discarded.delete_all + Entitlement::SubscriptionFeatureRemoval.where(privilege: feature.privileges.with_discarded).with_discarded.delete_all + Entitlement::EntitlementValue.where(privilege: feature.privileges.with_discarded).with_discarded.delete_all + Entitlement::Entitlement.where(feature: feature).with_discarded.delete_all + feature.privileges.with_discarded.delete_all +end + +# == seats - feature with privilege and subscription overrides +seats = Entitlement::Feature.create_with( + name: "Number of seats", + description: "Number of users of the account" +).find_or_create_by!(organization:, code: "seats") + +clean_up_feature!(seats) + +max = seats.privileges.create!(organization:, code: "max", name: "Maximum", value_type: "integer", created_at: 20.minutes.ago) +max_admins = seats.privileges.create!(organization:, code: "max_admins", name: "Max Admins", value_type: "integer", created_at: 10.minutes.ago) +root = seats.privileges.create!(organization:, code: "root", name: "Allow root user", value_type: "boolean", created_at: 15.minutes.ago) + +fe = Entitlement::Entitlement.create!(organization:, feature: seats, plan:, created_at: 1.hour.ago) +Entitlement::EntitlementValue.create!(organization:, entitlement: fe, privilege: max, value: 20) +Entitlement::EntitlementValue.create!(organization:, entitlement: fe, privilege: max_admins, value: 3_000, deleted_at: Time.current) +Entitlement::EntitlementValue.create!(organization:, entitlement: fe, privilege: max_admins, value: 3, created_at: 8.minutes.ago) +fe_sub = Entitlement::Entitlement.create!(organization:, feature: seats, subscription_id: sub.id) + +# Subscription override for max, root does not exist in the plan +Entitlement::EntitlementValue.create!(organization:, entitlement: fe_sub, privilege: max, value: 99) +Entitlement::EntitlementValue.create!(organization:, entitlement: fe_sub, privilege: root, value: true) + +# == analytics_api - Feature in the plan, without any privilege +analytics_api_feature = Entitlement::Feature.create_with( + name: "Analytics API", + description: "Access to all analytics data via REST API" +).find_or_create_by!(organization:, code: "analytics_api") + +clean_up_feature!(analytics_api_feature) + +Entitlement::Entitlement.create!(organization:, feature: analytics_api_feature, plan:, created_at: 1.year.ago) + +# == acls - Feature was in the plan but deleted, and in subscription but deleted +acls = Entitlement::Feature.create_with( + name: "Granular permissions" +).find_or_create_by!(organization:, code: "acls") + +clean_up_feature!(acls) + +Entitlement::Entitlement.create!(organization:, feature: acls, plan:, deleted_at: Time.current) +# Entitlement::Entitlement.create!(organization:, feature: acls, subscription: sub, deleted_at: Time.current) + +# == salesforce - Feature not in the plan but added to the subscription +salesforce = Entitlement::Feature.create_with( + name: "Salesforce Integration" +).find_or_create_by!(organization:, code: "salesforce") + +clean_up_feature!(salesforce) + +Entitlement::Entitlement.create!(organization:, feature: salesforce, subscription_id: sub.id) + +# == support - Feature attached to the plan but removed from the subscription +support = Entitlement::Feature.create_with( + name: "Premium Support" +).find_or_create_by!(organization:, code: "premium_support") + +clean_up_feature!(support) + +Entitlement::Entitlement.create!(organization:, feature: support, plan:) +Entitlement::Entitlement.create!(organization:, feature: support, subscription_id: sub.id) +Entitlement::SubscriptionFeatureRemoval.create!(organization:, feature: support, subscription_id: sub.id) + +# == sso - Feature with Select and ALL PRIVILEGE OVERRIDDEN ("empty line" added) +sso = Entitlement::Feature.create_with( + name: "SSO" +).find_or_create_by!(organization:, code: "sso") + +clean_up_feature!(sso) + +provider = sso.privileges.create!(organization:, + code: "provider", + name: "Provider Name", + value_type: "select", + config: {select_options: %w[okta ad google custom]}) + +fe = Entitlement::Entitlement.create!(organization:, feature: sso, plan:, created_at: 10.days.ago) +Entitlement::EntitlementValue.create!(organization:, entitlement: fe, privilege: provider, value: "okta") + +fe_sub = Entitlement::Entitlement.create!(organization:, feature: sso, subscription: sub, created_at: 1.day.ago) +Entitlement::EntitlementValue.create!(organization:, entitlement: fe_sub, privilege: provider, value: "google") + +Entitlement::SubscriptionFeatureRemoval.create!(organization:, feature: sso, deleted_at: Time.current, subscription_id: sub.id) diff --git a/db/seeds/06_alerting.rb b/db/seeds/06_alerting.rb new file mode 100644 index 0000000..10c9c54 --- /dev/null +++ b/db/seeds/06_alerting.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +# NOTE: If hooli is not found, run 01_base.rb first +organization = Organization.find_by!(name: "Hooli") +sum_bm = BillableMetric.find_by!(organization:, code: "sum_bm") +subscription = Subscription.find_by!(external_id: "sub_john-doe-main") + +existing_alerts = UsageMonitoring::Alert.where(organization:, subscription_external_id: subscription.external_id) +UsageMonitoring::TriggeredAlert.where(alert: existing_alerts).delete_all +UsageMonitoring::AlertThreshold.where(alert: existing_alerts).delete_all +existing_alerts.delete_all + +UsageMonitoring::CreateAlertService.call!(organization:, alertable: subscription, params: { + alert_type: "current_usage_amount", + code: "default", + name: "Default Alert", + thresholds: [ + {code: "warn", value: 80_00}, + {code: "alert", value: 100_00}, + {code: "panic", value: 33_00, recurring: true} + ] +}) + +if License.premium? + alert = UsageMonitoring::CreateAlertService.call!(organization:, alertable: subscription, params: { + alert_type: "lifetime_usage_amount", + code: "total", + thresholds: [ + {code: "info", value: 1000_00} + ] + }).alert + + triggered_alert = UsageMonitoring::TriggeredAlert.create!(alert:, organization:, subscription:, + current_value: 51, + previous_value: 8, + crossed_thresholds: [ + {code: nil, value: 10, recurring: false}, {code: :warn, value: 25, recurring: false}, {code: :alert, value: 50, recurring: false} + ], + triggered_at: 2.months.ago) + SendWebhookJob.perform_later("alert.triggered", triggered_alert) + + triggered_alert = UsageMonitoring::TriggeredAlert.create!(alert:, organization:, subscription:, + current_value: 88, + previous_value: 234, + crossed_thresholds: [ + {code: :alert, value: 100, recurring: false}, {code: :alert, value: 150, recurring: true}, {code: :alert, value: 200, recurring: true} + ], + triggered_at: 11.days.ago) + SendWebhookJob.perform_later("alert.triggered", triggered_alert) +end + +bm_alert = UsageMonitoring::CreateAlertService.call(organization:, alertable: subscription, params: { + alert_type: "billable_metric_current_usage_amount", + billable_metric: sum_bm, + code: "ops", + name: "Operations Alert", + thresholds: [ + {value: 50_00}, + {value: 10_00, recurring: true} + ] +}).alert + +triggered_alert = UsageMonitoring::TriggeredAlert.create!(alert: bm_alert, organization:, subscription:, + current_value: 8, + previous_value: 0, + crossed_thresholds: [ + {code: nil, value: 5, recurring: false} + ], + triggered_at: 4.days.ago) +SendWebhookJob.perform_later("alert.triggered", triggered_alert) diff --git a/db/seeds/07_security_logs.rb b/db/seeds/07_security_logs.rb new file mode 100644 index 0000000..3652f22 --- /dev/null +++ b/db/seeds/07_security_logs.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +return unless License.premium? +return if ENV["LAGO_CLICKHOUSE_ENABLED"].blank? + +topic = Utils::SecurityLog.topic +return if topic.blank? + +existing = Karafka::Admin.cluster_info.topics.map { |t| t[:topic_name] } +unless existing.include?(topic) + Karafka::Admin.create_topic(topic, 1, 1) +end + +organization = Organization.find_by!(name: "Hooli") +user = organization.memberships.first!.user +device_info = { + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ip_address: "192.168.1.42", + browser: "Chrome 131.0.0.0", + os: "Mac", + device_type: "desktop" +} + +Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.signed_up", + user:, + device_info:, + skip_organization_check: true +) + +Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.new_device_logged_in", + user:, + device_info: +) + +Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.deleted", + user:, + device_info:, + resources: {email: "dinesh@hooli.com"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.invited", + user:, + device_info:, + resources: {invitee_email: "invited@example.com"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.role_edited", + user:, + device_info:, + resources: {email: "dinesh@hooli.com", roles: {deleted: %w[admin], added: %w[finance]}} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.password_reset_requested", + user:, + device_info:, + resources: {email: "gavin@hooli.com"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "user", + log_event: "user.password_edited", + user:, + device_info:, + resources: {email: "gavin@hooli.com"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "role", + log_event: "role.created", + user:, + device_info:, + resources: {role_code: "accountant", permissions: %w[customers:view invoices:view invoices:create]} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "role", + log_event: "role.updated", + user:, + device_info:, + resources: {role_code: "accountant", permissions: {added: %w[invoices:view invoices:create]}} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "role", + log_event: "role.deleted", + user:, + device_info:, + resources: {role_code: "hr_manager"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "api_key", + log_event: "api_key.created", + user:, + device_info:, + resources: {name: "Hooli Key", value_ending: "7890", permissions: ApiKey.default_permissions} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "api_key", + log_event: "api_key.updated", + user:, + device_info:, + resources: {name: "Hooli Key", value_ending: "7890", permissions: {add_on: {deleted: %w[write]}}} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "api_key", + log_event: "api_key.deleted", + user:, + device_info:, + resources: {name: "Expired Key", value_ending: "4321"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "api_key", + log_event: "api_key.rotated", + user:, + device_info:, + resources: {name: "Hooli Key", value_ending: {deleted: "7890", added: "5678"}} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "webhook_endpoint", + log_event: "webhook_endpoint.created", + user:, + device_info:, + resources: {webhook_url: "https://webhook.example.com/#{organization.id}"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "webhook_endpoint", + log_event: "webhook_endpoint.updated", + user:, + device_info:, + resources: {webhook_url: {deleted: "https://webhook.example.com/old", added: "https://webhook.example.com/#{organization.id}"}} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "webhook_endpoint", + log_event: "webhook_endpoint.deleted", + user:, + device_info:, + resources: {webhook_url: "https://webhook.example.com/#{organization.id}"} +) + +api_key = organization.api_keys.first +Utils::SecurityLog.produce( + organization:, + log_type: "webhook_endpoint", + log_event: "webhook_endpoint.deleted", + api_key:, + resources: {webhook_url: "https://webhook.example.com/api-deleted"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "export", + log_event: "export.created", + user:, + device_info:, + resources: {export_type: "invoices"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "integration", + log_event: "integration.created", + user:, + device_info:, + resources: {integration_name: "Netsuite Production", integration_type: "netsuite"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "integration", + log_event: "integration.updated", + user:, + device_info:, + resources: {integration_name: "Netsuite Production", integration_type: "netsuite", name: {deleted: "Netsuite Old", added: "Netsuite Production"}} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "integration", + log_event: "integration.deleted", + user:, + device_info:, + resources: {integration_name: "Okta Production", integration_type: "okta"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "billing_entity", + log_event: "billing_entity.created", + user:, + device_info:, + resources: {billing_entity_name: "Hooli", billing_entity_code: "hooli"} +) + +Utils::SecurityLog.produce( + organization:, + log_type: "billing_entity", + log_event: "billing_entity.updated", + user:, + device_info:, + resources: {billing_entity_name: "Hooli", billing_entity_code: "hooli", name: {deleted: "Hooli Old", added: "Hooli"}} +) diff --git a/db/seeds/08_progressive_billing.rb b/db/seeds/08_progressive_billing.rb new file mode 100644 index 0000000..4db3555 --- /dev/null +++ b/db/seeds/08_progressive_billing.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +subscription = Subscription.find_by!(external_id: "sub_john-doe-main") + +UsageThresholds::UpdateService.call!( + model: subscription.plan, + usage_thresholds_params: [{ + amount_cents: 120_00, + threshold_display_name: "Initial Threshold" + }, { + amount_cents: 1_000_00, + threshold_display_name: "Recurring Threshold", + recurring: true + }], + partial: false +) + +Subscriptions::UpdateUsageThresholdsService.call!( + subscription:, + usage_thresholds_params: [{ + amount_cents: 400_00, + threshold_display_name: "Initial Threshold" + }, { + amount_cents: 800_00 + }, { + amount_cents: 2000_00, + recurring: true + }], + partial: false +) diff --git a/db/seeds/20_subscriptions.rb b/db/seeds/20_subscriptions.rb new file mode 100644 index 0000000..9fb2289 --- /dev/null +++ b/db/seeds/20_subscriptions.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# NOTE: If hooli is not found, run 01_base.rb first +organization = Organization.find_by!(name: "Hooli") +billing_entity = organization.default_billing_entity +plan = Plan.find_by!(code: "standard_plan") + +# Create and customers with subscriptions to add "noise" to the system +5.times do |i| + customer = Customer.create_with( + name: Faker::TvShows::SiliconValley.character, + country: Faker::Address.country_code, + address_line1: Faker::Address.street_address, + address_line2: Faker::Address.secondary_address, + zipcode: Faker::Address.zip_code, + email: Faker::Internet.email, + city: Faker::Address.city, + legal_name: Faker::Company.name, + legal_number: Faker::Company.duns_number, + currency: "EUR" + ).find_or_create_by!( + organization:, + billing_entity:, + external_id: "cust_#{i + 1}" + ) + + subscription_at = 6.months.ago + + Subscription.create_with( + organization:, + started_at: subscription_at, + subscription_at:, + status: :active, + billing_time: :calendar, + created_at: subscription_at + ).find_or_create_by!( + customer:, + external_id: "sub_#{i + 1}", + plan: + ) +end diff --git a/db/seeds/21_events.rb b/db/seeds/21_events.rb new file mode 100644 index 0000000..84d60a6 --- /dev/null +++ b/db/seeds/21_events.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +# NOTE: If hooli is not found, run 01_base.rb first +@organization = Organization.find_by!(name: "Hooli") +@customer = Customer.find_by!(external_id: "cust_john-doe") +@sub = Subscription.find_by!(external_id: "sub_john-doe-main") +sum_bm = BillableMetric.find_by!(organization: @organization, code: "sum_bm") +count_bm = BillableMetric.find_by!(organization: @organization, code: "count_bm") + +def create_event(code:, time:) + Event.create!( + external_customer_id: @customer.external_id, + external_subscription_id: @sub.external_id, + organization_id: @organization.id, + transaction_id: "tr_#{SecureRandom.hex}", + timestamp: time - rand(0..12).seconds, + created_at: time, + code: code, + properties: { + custom_field: 10 + }, + metadata: { + user_agent: "Lago Python v0.1.5", + ip_address: Faker::Internet.ip_v4_address + } + ) +end + +# NOTE: Assigns valid events +6.times do |offset| + 5.times do + create_event(code: sum_bm.code, time: (offset.month + rand(1..20).days).ago) + end + + 2.times do + create_event(code: count_bm.code, time: (offset.month + rand(1..20).days).ago) + end +end + +# NOTE: Assigns events missing custom property +5.times do + event = create_event(code: sum_bm.code, time: rand(1..20).days.ago) + event.properties = {} + event.save! +end + +# NOTE: Assigns events with invalid code +5.times do + create_event(code: "foo", time: rand(1..20).days.ago) +end diff --git a/db/seeds/50_invoices.rb b/db/seeds/50_invoices.rb new file mode 100644 index 0000000..446ebd2 --- /dev/null +++ b/db/seeds/50_invoices.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +if Invoice.count.zero? + Subscription.all.find_each do |subscription| + invoice_count = (Time.current - subscription.subscription_at).fdiv(1.month).round + + (1..invoice_count).each do |offset| + Invoices::SubscriptionService.call( + subscriptions: [subscription], + timestamp: subscription.subscription_at + offset.months, + invoicing_reason: :subscription_periodic + ) + end + end +end diff --git a/db/seeds/60_email_activity_logs.rb b/db/seeds/60_email_activity_logs.rb new file mode 100644 index 0000000..4c767ed --- /dev/null +++ b/db/seeds/60_email_activity_logs.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +return unless ENV["LAGO_CLICKHOUSE_ENABLED"] == "true" + +require "factory_bot_rails" + +organization = Organization.find_by!(name: "Hooli") +membership = organization.memberships.first +api_key = organization.api_keys.first + +invoices = Invoice.where(organization:).order(:created_at).limit(3).to_a +credit_note = CreditNote.find_by(organization:) + +# Skip if no documents exist +return if invoices.empty? + +# Create payment receipt if needed +payment_receipt = PaymentReceipt.joins(payment: :customer) + .where(customers: {organization_id: organization.id}).first +if payment_receipt.nil? && invoices[0] + payment = FactoryBot.create( + :payment, + payable: invoices[0], + organization:, + customer: invoices[0].customer, + status: "succeeded" + ) + payment_receipt = FactoryBot.create( + :payment_receipt, + payment:, + organization: + ) +end + +def document_number(document) + document.number +end + +# Build activity_object for ClickHouse Map(String, Nullable(String)) type. +# Nested objects must be serialized to JSON strings for direct writes. +# (When writing via Kafka, ClickHouse auto-converts nested JSON objects to strings.) +# Note: document_type is duplicated at top level for easier querying in seeds. +def email_activity_object(document:, status:, error: nil) + result = { + "status" => status, + "document_type" => document.class.name, + "email" => { + subject: "Your #{document.class.name.underscore.humanize} ##{document_number(document)}", + to: [document.customer.email].compact, + cc: [], + bcc: [], + body_preview: "Dear #{document.customer.name}, please find attached..." + }.to_json, + "document" => { + type: document.class.name, + number: document_number(document), + lago_id: document.id + }.to_json + } + result["error"] = error.to_json if error + result +end + +# Check if seed record already exists by unique combination of parameters. +# Uses: activity_source, status, document_type (all from activity_object). +def email_log_exists?(organization:, activity_source:, status:, document_type:) + Clickhouse::ActivityLog + .where(organization:, activity_type: "email.sent", activity_source:) + .where("activity_object['status'] = ?", status) + .where("activity_object['document_type'] = ?", document_type) + .exists? +end + +# 1. System-triggered email (automatic invoice sending) +if invoices[0] && !email_log_exists?(organization:, activity_source: "system", document_type: "Invoice", status: "sent") + FactoryBot.create( + :clickhouse_activity_log, + organization:, + resource: invoices[0], + external_customer_id: invoices[0].customer.external_id, + user_id: nil, + api_key_id: nil, + activity_type: "email.sent", + activity_source: "system", + logged_at: 2.days.ago, + activity_object: email_activity_object(document: invoices[0], status: "sent") + ) +end + +# 2. API-triggered email (via API request) +if invoices[1] && !email_log_exists?(organization:, activity_source: "api", document_type: "Invoice", status: "sent") + FactoryBot.create( + :clickhouse_activity_log, + organization:, + resource: invoices[1], + external_customer_id: invoices[1].customer.external_id, + user_id: nil, + api_key_id: api_key&.id, + activity_type: "email.sent", + activity_source: "api", + logged_at: 1.day.ago, + activity_object: email_activity_object(document: invoices[1], status: "sent") + ) +end + +# 3. UI-triggered resend (user resends from frontend) +if invoices[0] && !email_log_exists?(organization:, activity_source: "front", document_type: "Invoice", status: "resent") + FactoryBot.create( + :clickhouse_activity_log, + organization:, + resource: invoices[0], + external_customer_id: invoices[0].customer.external_id, + user_id: membership&.user_id, + api_key_id: nil, + activity_type: "email.sent", + activity_source: "front", + logged_at: 1.day.ago, + activity_object: email_activity_object(document: invoices[0], status: "resent") + ) +end + +# 4. Failed email attempt (system) +if invoices[2] && !email_log_exists?(organization:, activity_source: "system", document_type: "Invoice", status: "failed") + FactoryBot.create( + :clickhouse_activity_log, + organization:, + resource: invoices[2], + external_customer_id: invoices[2].customer.external_id, + user_id: nil, + api_key_id: nil, + activity_type: "email.sent", + activity_source: "system", + logged_at: 12.hours.ago, + activity_object: email_activity_object( + document: invoices[2], + status: "failed", + error: {class: "Net::SMTPServerBusy", message: "454 Too many connections"} + ) + ) +end + +# 5. Credit note email (if exists) +if credit_note && !email_log_exists?(organization:, activity_source: "system", document_type: "CreditNote", status: "sent") + FactoryBot.create( + :clickhouse_activity_log, + organization:, + resource: credit_note, + external_customer_id: credit_note.customer.external_id, + user_id: nil, + api_key_id: nil, + activity_type: "email.sent", + activity_source: "system", + logged_at: 6.hours.ago, + activity_object: email_activity_object(document: credit_note, status: "sent") + ) +end + +# 6. Payment receipt email (if exists) +if payment_receipt && !email_log_exists?(organization:, activity_source: "system", document_type: "PaymentReceipt", status: "sent") + FactoryBot.create( + :clickhouse_activity_log, + organization:, + resource: payment_receipt, + external_customer_id: payment_receipt.customer.external_id, + user_id: nil, + api_key_id: nil, + activity_type: "email.sent", + activity_source: "system", + logged_at: 3.hours.ago, + activity_object: email_activity_object(document: payment_receipt, status: "sent") + ) +end diff --git a/db/seeds/70_order_forms.rb b/db/seeds/70_order_forms.rb new file mode 100644 index 0000000..5b90ca7 --- /dev/null +++ b/db/seeds/70_order_forms.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +# NOTE: If hooli is not found, run 01_base.rb first +@organization = Organization.find_by!(name: "Hooli") +@customer = Customer.find_by!(external_id: "cust_john-doe") + +def create_quote(organization:, customer:, **params) + quote = ::Quote.new( + organization: organization, + customer: customer, + **params + ) + quote.save! + quote +end + +def create_quote_version(quote:, **params) + quote_version = ::QuoteVersion.new( + quote: quote, + organization: quote.organization, + **params + ) + quote_version.save! + quote_version +end + +# Create a chain of quotes +def create_quote_chain(organization:, customer:, versions_count: 3) + quote = create_quote( + organization: organization, + customer: customer, + order_type: :subscription_creation + ) + owners = User.where(email: ["gavin@hooli.com", "dinesh@hooli.com"]) + owners.each do |user| + QuoteOwner.create!( + quote: quote, + user: user, + organization: organization + ) + end + Quote.transaction do + (1..versions_count).each do |version| + last_version = version == versions_count + quote_version = create_quote_version( + quote:, + status: (last_version ? :draft : :voided), + void_reason: (last_version ? nil : :manual), + voided_at: (last_version ? nil : Time.current) + ) + quote.update!(current_version: quote_version) if last_version + end + end +end + +# Add a draft quote per each customer +def create_draft_quote_for_each_customer(organization:) + (1..5).each do |i| + customer = Customer.find_by!(external_id: "cust_#{i}") + quote = create_quote( + organization: organization, + customer: customer, + order_type: :one_off + ) + quote_version = create_quote_version( + quote: quote + ) + quote.update!(current_version: quote_version) + end +end + +create_quote_chain(organization: @organization, customer: @customer) +create_draft_quote_for_each_customer(organization: @organization) diff --git a/db/structure.sql b/db/structure.sql new file mode 100644 index 0000000..7fe2315 --- /dev/null +++ b/db/structure.sql @@ -0,0 +1,13212 @@ +SET statement_timeout = 0; +SET lock_timeout = 0; +SET idle_in_transaction_session_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SELECT pg_catalog.set_config('search_path', '', false); +SET check_function_bodies = false; +SET xmloption = content; +SET client_min_messages = warning; +SET row_security = off; + +ALTER TABLE IF EXISTS ONLY public.membership_roles DROP CONSTRAINT IF EXISTS membership_role_membership_fk; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_ff75b29299; +ALTER TABLE IF EXISTS ONLY public.presentation_breakdowns DROP CONSTRAINT IF EXISTS fk_rails_ff548a9f4c; +ALTER TABLE IF EXISTS ONLY public.fixed_charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_fea16bf2e7; +ALTER TABLE IF EXISTS ONLY public.dunning_campaign_thresholds DROP CONSTRAINT IF EXISTS fk_rails_fd84cdb7c6; +ALTER TABLE IF EXISTS ONLY public.subscription_activation_rules DROP CONSTRAINT IF EXISTS fk_rails_fd60209637; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_fd399a23d3; +ALTER TABLE IF EXISTS ONLY public.wallet_targets DROP CONSTRAINT IF EXISTS fk_rails_fbd2b9fccb; +ALTER TABLE IF EXISTS ONLY public.fees_taxes DROP CONSTRAINT IF EXISTS fk_rails_f98413d404; +ALTER TABLE IF EXISTS ONLY public.billing_entities DROP CONSTRAINT IF EXISTS fk_rails_f66617edcb; +ALTER TABLE IF EXISTS ONLY public.payment_receipts DROP CONSTRAINT IF EXISTS fk_rails_f53ff93138; +ALTER TABLE IF EXISTS ONLY public.quantified_events DROP CONSTRAINT IF EXISTS fk_rails_f510acb495; +ALTER TABLE IF EXISTS ONLY public.invoice_subscriptions DROP CONSTRAINT IF EXISTS fk_rails_f435d13904; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_f375d320ad; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions DROP CONSTRAINT IF EXISTS fk_rails_f32b205d44; +ALTER TABLE IF EXISTS ONLY public.enriched_store_subscription_migrations DROP CONSTRAINT IF EXISTS fk_rails_f232478e56; +ALTER TABLE IF EXISTS ONLY public.payment_requests DROP CONSTRAINT IF EXISTS fk_rails_f228550fda; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alert_thresholds DROP CONSTRAINT IF EXISTS fk_rails_f18cd04d51; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_eeb6a32be1; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_triggered_alerts DROP CONSTRAINT IF EXISTS fk_rails_ee2b6f04d9; +ALTER TABLE IF EXISTS ONLY public.invoices_payment_requests DROP CONSTRAINT IF EXISTS fk_rails_ed387e0992; +ALTER TABLE IF EXISTS ONLY public.payment_provider_customers DROP CONSTRAINT IF EXISTS fk_rails_ecb466254b; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_eaca9421be; +ALTER TABLE IF EXISTS ONLY public.integration_customers DROP CONSTRAINT IF EXISTS fk_rails_ea80151038; +ALTER TABLE IF EXISTS ONLY public.fixed_charges DROP CONSTRAINT IF EXISTS fk_rails_e95f72749e; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules DROP CONSTRAINT IF EXISTS fk_rails_e8bac9c5bb; +ALTER TABLE IF EXISTS ONLY public.plans_taxes DROP CONSTRAINT IF EXISTS fk_rails_e88403f4b9; +ALTER TABLE IF EXISTS ONLY public.customers_taxes DROP CONSTRAINT IF EXISTS fk_rails_e86903e081; +ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_e744efbe51; +ALTER TABLE IF EXISTS ONLY public.charge_filters DROP CONSTRAINT IF EXISTS fk_rails_e711e8089e; +ALTER TABLE IF EXISTS ONLY public.user_devices DROP CONSTRAINT IF EXISTS fk_rails_e700a96826; +ALTER TABLE IF EXISTS ONLY public.integration_mappings DROP CONSTRAINT IF EXISTS fk_rails_e4a58fbcac; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_triggered_alerts DROP CONSTRAINT IF EXISTS fk_rails_e3cf54daac; +ALTER TABLE IF EXISTS ONLY public.integration_collection_mappings DROP CONSTRAINT IF EXISTS fk_rails_e148d17c1f; +ALTER TABLE IF EXISTS ONLY public.customer_metadata DROP CONSTRAINT IF EXISTS fk_rails_dfac602b2c; +ALTER TABLE IF EXISTS ONLY public.credit_note_items DROP CONSTRAINT IF EXISTS fk_rails_dea748e529; +ALTER TABLE IF EXISTS ONLY public.quotes DROP CONSTRAINT IF EXISTS fk_rails_de7694c307; +ALTER TABLE IF EXISTS ONLY public.coupon_targets DROP CONSTRAINT IF EXISTS fk_rails_de6b3c3138; +ALTER TABLE IF EXISTS ONLY public.invites DROP CONSTRAINT IF EXISTS fk_rails_dd342449a6; +ALTER TABLE IF EXISTS ONLY public.enriched_store_subscription_migrations DROP CONSTRAINT IF EXISTS fk_rails_dc444f5f29; +ALTER TABLE IF EXISTS ONLY public.customers_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_db9140d0fd; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_d9ffb8b4a1; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alerts DROP CONSTRAINT IF EXISTS fk_rails_d9ea200904; +ALTER TABLE IF EXISTS ONLY public.integration_resources DROP CONSTRAINT IF EXISTS fk_rails_d9448a540b; +ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS fk_rails_d9342a8ca7; +ALTER TABLE IF EXISTS ONLY public.entitlement_privileges DROP CONSTRAINT IF EXISTS fk_rails_d648e28d9f; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlements DROP CONSTRAINT IF EXISTS fk_rails_d53f825a88; +ALTER TABLE IF EXISTS ONLY public.idempotency_records DROP CONSTRAINT IF EXISTS fk_rails_d4f02c82b2; +ALTER TABLE IF EXISTS ONLY public.wallet_transaction_consumptions DROP CONSTRAINT IF EXISTS fk_rails_d4abfdb375; +ALTER TABLE IF EXISTS ONLY public.payments DROP CONSTRAINT IF EXISTS fk_rails_d384ec1ebf; +ALTER TABLE IF EXISTS ONLY public.quote_versions DROP CONSTRAINT IF EXISTS fk_rails_d2d917b73a; +ALTER TABLE IF EXISTS ONLY public.item_metadata DROP CONSTRAINT IF EXISTS fk_rails_d0b1714507; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions DROP CONSTRAINT IF EXISTS fk_rails_d07bc24ce3; +ALTER TABLE IF EXISTS ONLY public.integration_customers DROP CONSTRAINT IF EXISTS fk_rails_ce2c63d69f; +ALTER TABLE IF EXISTS ONLY public.pricing_units DROP CONSTRAINT IF EXISTS fk_rails_cd99351ee3; +ALTER TABLE IF EXISTS ONLY public.integration_mappings DROP CONSTRAINT IF EXISTS fk_rails_cc318ad1ff; +ALTER TABLE IF EXISTS ONLY public.plans DROP CONSTRAINT IF EXISTS fk_rails_cbf700aeb8; +ALTER TABLE IF EXISTS ONLY public.usage_thresholds DROP CONSTRAINT IF EXISTS fk_rails_caeb5a3949; +ALTER TABLE IF EXISTS ONLY public.entitlement_subscription_feature_removals DROP CONSTRAINT IF EXISTS fk_rails_c9183c59d9; +ALTER TABLE IF EXISTS ONLY public.payment_methods DROP CONSTRAINT IF EXISTS fk_rails_c8606f586b; +ALTER TABLE IF EXISTS ONLY public.subscriptions_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_c82f03a405; +ALTER TABLE IF EXISTS ONLY public.invites DROP CONSTRAINT IF EXISTS fk_rails_c71f4b2026; +ALTER TABLE IF EXISTS ONLY public.customers_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_c64033bcb0; +ALTER TABLE IF EXISTS ONLY public.payment_methods DROP CONSTRAINT IF EXISTS fk_rails_c60c12efbd; +ALTER TABLE IF EXISTS ONLY public.pricing_unit_usages DROP CONSTRAINT IF EXISTS fk_rails_c545103d57; +ALTER TABLE IF EXISTS ONLY public.active_storage_attachments DROP CONSTRAINT IF EXISTS fk_rails_c3b3935057; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions DROP CONSTRAINT IF EXISTS fk_rails_c29bf4ff0f; +ALTER TABLE IF EXISTS ONLY public.enriched_store_migrations DROP CONSTRAINT IF EXISTS fk_rails_c04bd1a196; +ALTER TABLE IF EXISTS ONLY public.customers DROP CONSTRAINT IF EXISTS fk_rails_bff25bb1bb; +ALTER TABLE IF EXISTS ONLY public.charge_filter_values DROP CONSTRAINT IF EXISTS fk_rails_bf661ef73d; +ALTER TABLE IF EXISTS ONLY public.dunning_campaign_thresholds DROP CONSTRAINT IF EXISTS fk_rails_bf1f386f75; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_subscription_activities DROP CONSTRAINT IF EXISTS fk_rails_bda048a8d9; +ALTER TABLE IF EXISTS ONLY public.plans_taxes DROP CONSTRAINT IF EXISTS fk_rails_bacde7a063; +ALTER TABLE IF EXISTS ONLY public.applied_coupons DROP CONSTRAINT IF EXISTS fk_rails_bacb46d2a3; +ALTER TABLE IF EXISTS ONLY public.lifetime_usages DROP CONSTRAINT IF EXISTS fk_rails_ba128983c2; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_b974dac270; +ALTER TABLE IF EXISTS ONLY public.presentation_breakdowns DROP CONSTRAINT IF EXISTS fk_rails_b8f3cabc8e; +ALTER TABLE IF EXISTS ONLY public.subscription_activation_rules DROP CONSTRAINT IF EXISTS fk_rails_b749d2045d; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlements DROP CONSTRAINT IF EXISTS fk_rails_b61aa73940; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_b50dc82c1e; +ALTER TABLE IF EXISTS ONLY public.entitlement_subscription_feature_removals DROP CONSTRAINT IF EXISTS fk_rails_b3864df641; +ALTER TABLE IF EXISTS ONLY public.billing_entities_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_b283a89721; +ALTER TABLE IF EXISTS ONLY public.daily_usages DROP CONSTRAINT IF EXISTS fk_rails_b07fc711f7; +ALTER TABLE IF EXISTS ONLY public.pricing_unit_usages DROP CONSTRAINT IF EXISTS fk_rails_aea6422e6a; +ALTER TABLE IF EXISTS ONLY public.charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_ac146c9541; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_subscription_activities DROP CONSTRAINT IF EXISTS fk_rails_ab16de0b32; +ALTER TABLE IF EXISTS ONLY public.commitments_taxes DROP CONSTRAINT IF EXISTS fk_rails_aaa12f7d3e; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlement_values DROP CONSTRAINT IF EXISTS fk_rails_aa34dd5db6; +ALTER TABLE IF EXISTS ONLY public.fixed_charges DROP CONSTRAINT IF EXISTS fk_rails_aa04ceacf6; +ALTER TABLE IF EXISTS ONLY public.integration_items DROP CONSTRAINT IF EXISTS fk_rails_a9dc2ea536; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_a7f20c73bb; +ALTER TABLE IF EXISTS ONLY public.charges DROP CONSTRAINT IF EXISTS fk_rails_a710519346; +ALTER TABLE IF EXISTS ONLY public.group_properties DROP CONSTRAINT IF EXISTS fk_rails_a2d2cb3819; +ALTER TABLE IF EXISTS ONLY public.quotes DROP CONSTRAINT IF EXISTS fk_rails_a1ab65f1f7; +ALTER TABLE IF EXISTS ONLY public.credit_note_items DROP CONSTRAINT IF EXISTS fk_rails_9f22076477; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions DROP CONSTRAINT IF EXISTS fk_rails_9ea6759859; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_9e3f99b7a2; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alerts DROP CONSTRAINT IF EXISTS fk_rails_9d8812945e; +ALTER TABLE IF EXISTS ONLY public.applied_add_ons DROP CONSTRAINT IF EXISTS fk_rails_9c8e276cc0; +ALTER TABLE IF EXISTS ONLY public.plans_taxes DROP CONSTRAINT IF EXISTS fk_rails_9c704027e2; +ALTER TABLE IF EXISTS ONLY public.applied_usage_thresholds DROP CONSTRAINT IF EXISTS fk_rails_9c08b43701; +ALTER TABLE IF EXISTS ONLY public.active_storage_variant_records DROP CONSTRAINT IF EXISTS fk_rails_993965df05; +ALTER TABLE IF EXISTS ONLY public.memberships DROP CONSTRAINT IF EXISTS fk_rails_99326fb65d; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_98980b326b; +ALTER TABLE IF EXISTS ONLY public.fixed_charge_events DROP CONSTRAINT IF EXISTS fk_rails_9881e28151; +ALTER TABLE IF EXISTS ONLY public.pending_vies_checks DROP CONSTRAINT IF EXISTS fk_rails_96fc54cd9a; +ALTER TABLE IF EXISTS ONLY public.entitlement_subscription_feature_removals DROP CONSTRAINT IF EXISTS fk_rails_95df3194c5; +ALTER TABLE IF EXISTS ONLY public.customers DROP CONSTRAINT IF EXISTS fk_rails_94cc21031f; +ALTER TABLE IF EXISTS ONLY public.data_export_parts DROP CONSTRAINT IF EXISTS fk_rails_9298b8fdad; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_91802dc891; +ALTER TABLE IF EXISTS ONLY public.invoice_subscriptions DROP CONSTRAINT IF EXISTS fk_rails_90d93bd016; +ALTER TABLE IF EXISTS ONLY public.data_export_parts DROP CONSTRAINT IF EXISTS fk_rails_909197908c; +ALTER TABLE IF EXISTS ONLY public.fixed_charge_events DROP CONSTRAINT IF EXISTS fk_rails_90302b3ca3; +ALTER TABLE IF EXISTS ONLY public.commitments_taxes DROP CONSTRAINT IF EXISTS fk_rails_8fa6f0d920; +ALTER TABLE IF EXISTS ONLY public.applied_pricing_units DROP CONSTRAINT IF EXISTS fk_rails_8e0c3d0c5b; +ALTER TABLE IF EXISTS ONLY public.usage_thresholds DROP CONSTRAINT IF EXISTS fk_rails_8df9bf2b6c; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alerts DROP CONSTRAINT IF EXISTS fk_rails_8c18828b53; +ALTER TABLE IF EXISTS ONLY public.fixed_charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_8c09ee2428; +ALTER TABLE IF EXISTS ONLY public.invoice_metadata DROP CONSTRAINT IF EXISTS fk_rails_8bb5b094c4; +ALTER TABLE IF EXISTS ONLY public.add_ons_taxes DROP CONSTRAINT IF EXISTS fk_rails_89e1020aca; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlement_values DROP CONSTRAINT IF EXISTS fk_rails_8887954ec7; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_885dc100ef; +ALTER TABLE IF EXISTS ONLY public.invoice_subscriptions DROP CONSTRAINT IF EXISTS fk_rails_88349fc20a; +ALTER TABLE IF EXISTS ONLY public.wallets_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_87bc3bd4cb; +ALTER TABLE IF EXISTS ONLY public.payment_provider_customers DROP CONSTRAINT IF EXISTS fk_rails_86676be631; +ALTER TABLE IF EXISTS ONLY public.wallet_transaction_consumptions DROP CONSTRAINT IF EXISTS fk_rails_85b9e72931; +ALTER TABLE IF EXISTS ONLY public.payments DROP CONSTRAINT IF EXISTS fk_rails_84f4587409; +ALTER TABLE IF EXISTS ONLY public.payment_methods DROP CONSTRAINT IF EXISTS fk_rails_84a67e8b40; +ALTER TABLE IF EXISTS ONLY public.wallet_targets DROP CONSTRAINT IF EXISTS fk_rails_81eedc32c0; +ALTER TABLE IF EXISTS ONLY public.add_ons DROP CONSTRAINT IF EXISTS fk_rails_81e3b6abba; +ALTER TABLE IF EXISTS ONLY public.entitlement_features DROP CONSTRAINT IF EXISTS fk_rails_81d8b323cf; +ALTER TABLE IF EXISTS ONLY public.charges DROP CONSTRAINT IF EXISTS fk_rails_7eb0484711; +ALTER TABLE IF EXISTS ONLY public.billable_metrics DROP CONSTRAINT IF EXISTS fk_rails_7e8a2f26e5; +ALTER TABLE IF EXISTS ONLY public.charge_filter_values DROP CONSTRAINT IF EXISTS fk_rails_7da558cadc; +ALTER TABLE IF EXISTS ONLY public.wallet_targets DROP CONSTRAINT IF EXISTS fk_rails_7d0e61668f; +ALTER TABLE IF EXISTS ONLY public.subscriptions_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_7c63dd13f0; +ALTER TABLE IF EXISTS ONLY public.invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_7c0e340dbd; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_7b324610ad; +ALTER TABLE IF EXISTS ONLY public.api_keys DROP CONSTRAINT IF EXISTS fk_rails_7aab96f30e; +ALTER TABLE IF EXISTS ONLY public.billable_metric_filters DROP CONSTRAINT IF EXISTS fk_rails_7a0704ce72; +ALTER TABLE IF EXISTS ONLY public.applied_add_ons DROP CONSTRAINT IF EXISTS fk_rails_7995206484; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions DROP CONSTRAINT IF EXISTS fk_rails_78f6642ddf; +ALTER TABLE IF EXISTS ONLY public.groups DROP CONSTRAINT IF EXISTS fk_rails_7886e1bc34; +ALTER TABLE IF EXISTS ONLY public.credit_notes_taxes DROP CONSTRAINT IF EXISTS fk_rails_77f2d4440d; +ALTER TABLE IF EXISTS ONLY public.refunds DROP CONSTRAINT IF EXISTS fk_rails_778360c382; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_775eb0ecd8; +ALTER TABLE IF EXISTS ONLY public.quote_owners DROP CONSTRAINT IF EXISTS fk_rails_7734750af9; +ALTER TABLE IF EXISTS ONLY public.commitments DROP CONSTRAINT IF EXISTS fk_rails_76ceb88c74; +ALTER TABLE IF EXISTS ONLY public.integrations DROP CONSTRAINT IF EXISTS fk_rails_755d734f25; +ALTER TABLE IF EXISTS ONLY public.refunds DROP CONSTRAINT IF EXISTS fk_rails_75577c354e; +ALTER TABLE IF EXISTS ONLY public.fixed_charge_events DROP CONSTRAINT IF EXISTS fk_rails_752665cc51; +ALTER TABLE IF EXISTS ONLY public.fees_taxes DROP CONSTRAINT IF EXISTS fk_rails_745b4ca7dd; +ALTER TABLE IF EXISTS ONLY public.data_exports DROP CONSTRAINT IF EXISTS fk_rails_73d83e23b6; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alert_thresholds DROP CONSTRAINT IF EXISTS fk_rails_710f37148d; +ALTER TABLE IF EXISTS ONLY public.subscriptions_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_6eb8abe6cb; +ALTER TABLE IF EXISTS ONLY public.pending_vies_checks DROP CONSTRAINT IF EXISTS fk_rails_6e238f3bfc; +ALTER TABLE IF EXISTS ONLY public.invoices_taxes DROP CONSTRAINT IF EXISTS fk_rails_6e148ccbb1; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_6d465e6b10; +ALTER TABLE IF EXISTS ONLY public.dunning_campaigns DROP CONSTRAINT IF EXISTS fk_rails_6c720a8ccd; +ALTER TABLE IF EXISTS ONLY public.billing_entities_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_699cd1384f; +ALTER TABLE IF EXISTS ONLY public.customers_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_68754484c0; +ALTER TABLE IF EXISTS ONLY public.integration_resources DROP CONSTRAINT IF EXISTS fk_rails_67d4eb3c92; +ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_66eb6b32c1; +ALTER TABLE IF EXISTS ONLY public.fixed_charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_665ae33492; +ALTER TABLE IF EXISTS ONLY public.billing_entities_taxes DROP CONSTRAINT IF EXISTS fk_rails_651eadaaa4; +ALTER TABLE IF EXISTS ONLY public.integration_collection_mappings DROP CONSTRAINT IF EXISTS fk_rails_650fccfc41; +ALTER TABLE IF EXISTS ONLY public.membership_roles DROP CONSTRAINT IF EXISTS fk_rails_65053e240e; +ALTER TABLE IF EXISTS ONLY public.memberships DROP CONSTRAINT IF EXISTS fk_rails_64267aab58; +ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_63d3df128b; +ALTER TABLE IF EXISTS ONLY public.pricing_unit_usages DROP CONSTRAINT IF EXISTS fk_rails_63ca8e33c5; +ALTER TABLE IF EXISTS ONLY public.applied_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_63ac282e70; +ALTER TABLE IF EXISTS ONLY public.invoice_metadata DROP CONSTRAINT IF EXISTS fk_rails_63683837a2; +ALTER TABLE IF EXISTS ONLY public.payments DROP CONSTRAINT IF EXISTS fk_rails_62d18ea517; +ALTER TABLE IF EXISTS ONLY public.credit_notes_taxes DROP CONSTRAINT IF EXISTS fk_rails_626209b8d2; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_6023b3f2dd; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules DROP CONSTRAINT IF EXISTS fk_rails_5efea6fe31; +ALTER TABLE IF EXISTS ONLY public.fixed_charges DROP CONSTRAINT IF EXISTS fk_rails_5e06da3c18; +ALTER TABLE IF EXISTS ONLY public.credit_notes DROP CONSTRAINT IF EXISTS fk_rails_5cb67dee79; +ALTER TABLE IF EXISTS ONLY public.credit_note_items DROP CONSTRAINT IF EXISTS fk_rails_5cb2f24c3d; +ALTER TABLE IF EXISTS ONLY public.payment_receipts DROP CONSTRAINT IF EXISTS fk_rails_5c2e0b6d34; +ALTER TABLE IF EXISTS ONLY public.error_details DROP CONSTRAINT IF EXISTS fk_rails_5c21eece29; +ALTER TABLE IF EXISTS ONLY public.quotes DROP CONSTRAINT IF EXISTS fk_rails_5bb40a7bae; +ALTER TABLE IF EXISTS ONLY public.add_ons_taxes DROP CONSTRAINT IF EXISTS fk_rails_5ade8984b1; +ALTER TABLE IF EXISTS ONLY public.invoice_settlements DROP CONSTRAINT IF EXISTS fk_rails_5a4b906a16; +ALTER TABLE IF EXISTS ONLY public.data_exports DROP CONSTRAINT IF EXISTS fk_rails_5a43da571b; +ALTER TABLE IF EXISTS ONLY public.customers DROP CONSTRAINT IF EXISTS fk_rails_58234c715e; +ALTER TABLE IF EXISTS ONLY public.charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_56b7167125; +ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_56b3626631; +ALTER TABLE IF EXISTS ONLY public.credits DROP CONSTRAINT IF EXISTS fk_rails_5628a713de; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlement_values DROP CONSTRAINT IF EXISTS fk_rails_533b639bac; +ALTER TABLE IF EXISTS ONLY public.applied_usage_thresholds DROP CONSTRAINT IF EXISTS fk_rails_52b72c9b0e; +ALTER TABLE IF EXISTS ONLY public.password_resets DROP CONSTRAINT IF EXISTS fk_rails_526379cd99; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules DROP CONSTRAINT IF EXISTS fk_rails_52370612ae; +ALTER TABLE IF EXISTS ONLY public.credits DROP CONSTRAINT IF EXISTS fk_rails_521b5240ed; +ALTER TABLE IF EXISTS ONLY public.commitments DROP CONSTRAINT IF EXISTS fk_rails_51ac39a0c6; +ALTER TABLE IF EXISTS ONLY public.billable_metric_filters DROP CONSTRAINT IF EXISTS fk_rails_51077e7c0e; +ALTER TABLE IF EXISTS ONLY public.payment_provider_customers DROP CONSTRAINT IF EXISTS fk_rails_50d46d3679; +ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS fk_rails_4ff087c52e; +ALTER TABLE IF EXISTS ONLY public.billing_entities DROP CONSTRAINT IF EXISTS fk_rails_4aa58496c3; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_49fcc221b0; +ALTER TABLE IF EXISTS ONLY public.charges DROP CONSTRAINT IF EXISTS fk_rails_4934f27a06; +ALTER TABLE IF EXISTS ONLY public.webhooks DROP CONSTRAINT IF EXISTS fk_rails_49212d501e; +ALTER TABLE IF EXISTS ONLY public.integration_items DROP CONSTRAINT IF EXISTS fk_rails_47d8081062; +ALTER TABLE IF EXISTS ONLY public.quote_owners DROP CONSTRAINT IF EXISTS fk_rails_45230f8485; +ALTER TABLE IF EXISTS ONLY public.credit_notes DROP CONSTRAINT IF EXISTS fk_rails_4117574b51; +ALTER TABLE IF EXISTS ONLY public.credit_notes DROP CONSTRAINT IF EXISTS fk_rails_41088c7d45; +ALTER TABLE IF EXISTS ONLY public.charges_taxes DROP CONSTRAINT IF EXISTS fk_rails_3ff27d7624; +ALTER TABLE IF EXISTS ONLY public.refunds DROP CONSTRAINT IF EXISTS fk_rails_3f7be5debc; +ALTER TABLE IF EXISTS ONLY public.invoices_payment_requests DROP CONSTRAINT IF EXISTS fk_rails_3ec3563cf3; +ALTER TABLE IF EXISTS ONLY public.entitlement_privileges DROP CONSTRAINT IF EXISTS fk_rails_3e4df02771; +ALTER TABLE IF EXISTS ONLY public.integration_collection_mappings DROP CONSTRAINT IF EXISTS fk_rails_3d568ff9de; +ALTER TABLE IF EXISTS ONLY public.charges DROP CONSTRAINT IF EXISTS fk_rails_3cfe1d68d7; +ALTER TABLE IF EXISTS ONLY public.daily_usages DROP CONSTRAINT IF EXISTS fk_rails_3c7c3920c0; +ALTER TABLE IF EXISTS ONLY public.wallet_transaction_consumptions DROP CONSTRAINT IF EXISTS fk_rails_3c786cd3e3; +ALTER TABLE IF EXISTS ONLY public.invoice_settlements DROP CONSTRAINT IF EXISTS fk_rails_3b7dad8e9c; +ALTER TABLE IF EXISTS ONLY public.group_properties DROP CONSTRAINT IF EXISTS fk_rails_3acf9e789c; +ALTER TABLE IF EXISTS ONLY public.payments DROP CONSTRAINT IF EXISTS fk_rails_3ab959bfc4; +ALTER TABLE IF EXISTS ONLY public.invoices DROP CONSTRAINT IF EXISTS fk_rails_3a303bf667; +ALTER TABLE IF EXISTS ONLY public.quantified_events DROP CONSTRAINT IF EXISTS fk_rails_3926855f12; +ALTER TABLE IF EXISTS ONLY public.inbound_webhooks DROP CONSTRAINT IF EXISTS fk_rails_36cda06530; +ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS fk_rails_364213cc3e; +ALTER TABLE IF EXISTS ONLY public.charge_filter_values DROP CONSTRAINT IF EXISTS fk_rails_3640b4a66a; +ALTER TABLE IF EXISTS ONLY public.groups DROP CONSTRAINT IF EXISTS fk_rails_34b5ee1894; +ALTER TABLE IF EXISTS ONLY public.wallets_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_34b4e489e6; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_34ab152115; +ALTER TABLE IF EXISTS ONLY public.lifetime_usages DROP CONSTRAINT IF EXISTS fk_rails_348acbd245; +ALTER TABLE IF EXISTS ONLY public.customers_taxes DROP CONSTRAINT IF EXISTS fk_rails_33d169382f; +ALTER TABLE IF EXISTS ONLY public.payment_requests DROP CONSTRAINT IF EXISTS fk_rails_32600e5a72; +ALTER TABLE IF EXISTS ONLY public.credits DROP CONSTRAINT IF EXISTS fk_rails_310fcb3585; +ALTER TABLE IF EXISTS ONLY public.invoices DROP CONSTRAINT IF EXISTS fk_rails_309d3a4412; +ALTER TABLE IF EXISTS ONLY public.wallets_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_3092f5f2e0; +ALTER TABLE IF EXISTS ONLY public.invoice_settlements DROP CONSTRAINT IF EXISTS fk_rails_2ffeff5323; +ALTER TABLE IF EXISTS ONLY public.credits DROP CONSTRAINT IF EXISTS fk_rails_2fd7ee65e6; +ALTER TABLE IF EXISTS ONLY public.payment_requests DROP CONSTRAINT IF EXISTS fk_rails_2fb2147151; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_2ea4db3a4c; +ALTER TABLE IF EXISTS ONLY public.refunds DROP CONSTRAINT IF EXISTS fk_rails_2dc6171f57; +ALTER TABLE IF EXISTS ONLY public.ai_conversations DROP CONSTRAINT IF EXISTS fk_rails_2c06a74f41; +ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS fk_rails_2b35eef34b; +ALTER TABLE IF EXISTS ONLY public.usage_thresholds DROP CONSTRAINT IF EXISTS fk_rails_2908dd8de5; +ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS fk_rails_28077d4aa2; +ALTER TABLE IF EXISTS ONLY public.charge_filters DROP CONSTRAINT IF EXISTS fk_rails_27b55b8574; +ALTER TABLE IF EXISTS ONLY public.payment_providers DROP CONSTRAINT IF EXISTS fk_rails_26be2f764d; +ALTER TABLE IF EXISTS ONLY public.billing_entities_taxes DROP CONSTRAINT IF EXISTS fk_rails_268c288aaa; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_257af22645; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS fk_rails_2561c00887; +ALTER TABLE IF EXISTS ONLY public.invoice_settlements DROP CONSTRAINT IF EXISTS fk_rails_2539663124; +ALTER TABLE IF EXISTS ONLY public.refunds DROP CONSTRAINT IF EXISTS fk_rails_25267b0e17; +ALTER TABLE IF EXISTS ONLY public.credit_notes_taxes DROP CONSTRAINT IF EXISTS fk_rails_25232a0ec3; +ALTER TABLE IF EXISTS ONLY public.invoices_payment_requests DROP CONSTRAINT IF EXISTS fk_rails_2496c105ed; +ALTER TABLE IF EXISTS ONLY public.taxes DROP CONSTRAINT IF EXISTS fk_rails_23975f5a47; +ALTER TABLE IF EXISTS ONLY public.applied_pricing_units DROP CONSTRAINT IF EXISTS fk_rails_22bb2c0770; +ALTER TABLE IF EXISTS ONLY public.invoices_taxes DROP CONSTRAINT IF EXISTS fk_rails_22af6c6d28; +ALTER TABLE IF EXISTS ONLY public.commitments_taxes DROP CONSTRAINT IF EXISTS fk_rails_2259c88f26; +ALTER TABLE IF EXISTS ONLY public.cached_aggregations DROP CONSTRAINT IF EXISTS fk_rails_21eb389927; +ALTER TABLE IF EXISTS ONLY public.webhook_endpoints DROP CONSTRAINT IF EXISTS fk_rails_21808fa528; +ALTER TABLE IF EXISTS ONLY public.plans DROP CONSTRAINT IF EXISTS fk_rails_216ac8a975; +ALTER TABLE IF EXISTS ONLY public.customers_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_20f157fa49; +ALTER TABLE IF EXISTS ONLY public.webhooks DROP CONSTRAINT IF EXISTS fk_rails_20cc0de4c7; +ALTER TABLE IF EXISTS ONLY public.credits DROP CONSTRAINT IF EXISTS fk_rails_1db0057d9b; +ALTER TABLE IF EXISTS ONLY public.applied_usage_thresholds DROP CONSTRAINT IF EXISTS fk_rails_1d112bf8a0; +ALTER TABLE IF EXISTS ONLY public.billing_entities_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_19c47827ba; +ALTER TABLE IF EXISTS ONLY public.customer_metadata DROP CONSTRAINT IF EXISTS fk_rails_195153290d; +ALTER TABLE IF EXISTS ONLY public.coupon_targets DROP CONSTRAINT IF EXISTS fk_rails_189f2a3949; +ALTER TABLE IF EXISTS ONLY public.quote_owners DROP CONSTRAINT IF EXISTS fk_rails_1811b32fcd; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlements DROP CONSTRAINT IF EXISTS fk_rails_173327f0dc; +ALTER TABLE IF EXISTS ONLY public.invoice_subscriptions DROP CONSTRAINT IF EXISTS fk_rails_150139409e; +ALTER TABLE IF EXISTS ONLY public.coupon_targets DROP CONSTRAINT IF EXISTS fk_rails_1454058c96; +ALTER TABLE IF EXISTS ONLY public.invoices_taxes DROP CONSTRAINT IF EXISTS fk_rails_142809fee1; +ALTER TABLE IF EXISTS ONLY public.daily_usages DROP CONSTRAINT IF EXISTS fk_rails_12d29bc654; +ALTER TABLE IF EXISTS ONLY public.entitlement_subscription_feature_removals DROP CONSTRAINT IF EXISTS fk_rails_123667657c; +ALTER TABLE IF EXISTS ONLY public.quote_versions DROP CONSTRAINT IF EXISTS fk_rails_10ee148d0d; +ALTER TABLE IF EXISTS ONLY public.applied_invoice_custom_sections DROP CONSTRAINT IF EXISTS fk_rails_10428ecad2; +ALTER TABLE IF EXISTS ONLY public.fees_taxes DROP CONSTRAINT IF EXISTS fk_rails_103e187859; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_triggered_alerts DROP CONSTRAINT IF EXISTS fk_rails_0f807322b1; +ALTER TABLE IF EXISTS ONLY public.integration_mappings DROP CONSTRAINT IF EXISTS fk_rails_0f762162b0; +ALTER TABLE IF EXISTS ONLY public.integration_customers DROP CONSTRAINT IF EXISTS fk_rails_0e464363cb; +ALTER TABLE IF EXISTS ONLY public.ai_conversations DROP CONSTRAINT IF EXISTS fk_rails_0da056ac92; +ALTER TABLE IF EXISTS ONLY public.invoices DROP CONSTRAINT IF EXISTS fk_rails_0d349e632f; +ALTER TABLE IF EXISTS ONLY public.customers_taxes DROP CONSTRAINT IF EXISTS fk_rails_0d2be3d72c; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlements DROP CONSTRAINT IF EXISTS fk_rails_0c9773c34d; +ALTER TABLE IF EXISTS ONLY public.coupon_targets DROP CONSTRAINT IF EXISTS fk_rails_0bb6dcc01f; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_triggered_alerts DROP CONSTRAINT IF EXISTS fk_rails_0baa7bd751; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_0934890b24; +ALTER TABLE IF EXISTS ONLY public.add_ons_taxes DROP CONSTRAINT IF EXISTS fk_rails_08dfe87131; +ALTER TABLE IF EXISTS ONLY public.enriched_store_subscription_migrations DROP CONSTRAINT IF EXISTS fk_rails_08d9dce6d1; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fk_rails_085d1cc97b; +ALTER TABLE IF EXISTS ONLY public.billing_entities_taxes DROP CONSTRAINT IF EXISTS fk_rails_07b21049f2; +ALTER TABLE IF EXISTS ONLY public.invoices DROP CONSTRAINT IF EXISTS fk_rails_06b7046ec3; +ALTER TABLE IF EXISTS ONLY public.invoice_settlements DROP CONSTRAINT IF EXISTS fk_rails_04388258ff; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions DROP CONSTRAINT IF EXISTS fk_rails_01a4c0c7db; +ALTER TABLE IF EXISTS ONLY public.pending_vies_checks DROP CONSTRAINT IF EXISTS fk_rails_019e2289e5; +ALTER TABLE IF EXISTS ONLY public.payment_methods DROP CONSTRAINT IF EXISTS fk_rails_00e7a45b0b; +DROP TRIGGER IF EXISTS ensure_consistency ON public.roles; +DROP TRIGGER IF EXISTS before_payment_receipt_insert ON public.payment_receipts; +CREATE OR REPLACE VIEW public.flat_filters AS +SELECT + NULL::uuid AS organization_id, + NULL::character varying AS billable_metric_code, + NULL::uuid AS plan_id, + NULL::uuid AS charge_id, + NULL::timestamp(6) without time zone AS charge_updated_at, + NULL::uuid AS charge_filter_id, + NULL::timestamp(6) without time zone AS charge_filter_updated_at, + NULL::jsonb AS filters, + NULL::jsonb AS properties, + NULL::jsonb AS pricing_group_keys, + NULL::boolean AS pay_in_advance, + NULL::boolean AS accepts_target_wallet; +CREATE OR REPLACE VIEW public.billable_metrics_grouped_charges AS +SELECT + NULL::uuid AS organization_id, + NULL::character varying AS code, + NULL::integer AS aggregation_type, + NULL::character varying AS field_name, + NULL::uuid AS plan_id, + NULL::uuid AS charge_id, + NULL::boolean AS pay_in_advance, + NULL::jsonb AS grouped_by, + NULL::uuid AS charge_filter_id, + NULL::json AS filters, + NULL::jsonb AS filters_grouped_by; +DROP INDEX IF EXISTS public.unique_default_payment_method_per_customer; +DROP INDEX IF EXISTS public.index_wt_invoice_custom_sections_unique; +DROP INDEX IF EXISTS public.index_webhooks_on_webhook_endpoint_id; +DROP INDEX IF EXISTS public.index_webhooks_on_updated_at_for_cleanup; +DROP INDEX IF EXISTS public.index_webhooks_on_organization_id; +DROP INDEX IF EXISTS public.index_webhooks_on_object_type_and_object_id_and_webhook_type; +DROP INDEX IF EXISTS public.index_webhooks_on_endpoint_status_and_timestamps; +DROP INDEX IF EXISTS public.index_webhooks_on_endpoint_and_timestamps; +DROP INDEX IF EXISTS public.index_webhooks_for_query; +DROP INDEX IF EXISTS public.index_webhook_endpoints_on_webhook_url_and_organization_id; +DROP INDEX IF EXISTS public.index_webhook_endpoints_on_organization_id; +DROP INDEX IF EXISTS public.index_wallets_on_ready_to_be_refreshed; +DROP INDEX IF EXISTS public.index_wallets_on_payment_method_id; +DROP INDEX IF EXISTS public.index_wallets_on_organization_id_and_customer_id; +DROP INDEX IF EXISTS public.index_wallets_on_organization_id; +DROP INDEX IF EXISTS public.index_wallets_on_customer_id; +DROP INDEX IF EXISTS public.index_wallets_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_wallets_invoice_custom_sections_unique; +DROP INDEX IF EXISTS public.index_wallets_invoice_custom_sections_on_wallet_id; +DROP INDEX IF EXISTS public.index_wallets_invoice_custom_sections_on_organization_id; +DROP INDEX IF EXISTS public.index_wallet_transactions_on_wallet_id; +DROP INDEX IF EXISTS public.index_wallet_transactions_on_voided_invoice_id; +DROP INDEX IF EXISTS public.index_wallet_transactions_on_payment_method_id; +DROP INDEX IF EXISTS public.index_wallet_transactions_on_organization_id; +DROP INDEX IF EXISTS public.index_wallet_transactions_on_invoice_id; +DROP INDEX IF EXISTS public.index_wallet_transactions_on_credit_note_id; +DROP INDEX IF EXISTS public.index_wallet_transaction_consumptions_on_organization_id; +DROP INDEX IF EXISTS public.index_wallet_targets_on_wallet_id; +DROP INDEX IF EXISTS public.index_wallet_targets_on_organization_id; +DROP INDEX IF EXISTS public.index_wallet_targets_on_billable_metric_id; +DROP INDEX IF EXISTS public.index_versions_on_item_type_and_item_id; +DROP INDEX IF EXISTS public.index_user_devices_on_user_id_and_fingerprint; +DROP INDEX IF EXISTS public.index_usage_thresholds_on_subscription_id; +DROP INDEX IF EXISTS public.index_usage_thresholds_on_plan_id; +DROP INDEX IF EXISTS public.index_usage_thresholds_on_organization_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_triggered_alerts_on_wallet_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_triggered_alerts_on_subscription_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_triggered_alerts_on_organization_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_alerts_on_wallet_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_alerts_on_subscription_external_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_alerts_on_organization_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_alerts_on_billable_metric_id; +DROP INDEX IF EXISTS public.index_usage_monitoring_alert_thresholds_on_organization_id; +DROP INDEX IF EXISTS public.index_unique_transaction_id; +DROP INDEX IF EXISTS public.index_unique_terminating_invoice_subscription; +DROP INDEX IF EXISTS public.index_unique_starting_invoice_subscription; +DROP INDEX IF EXISTS public.index_unique_quotes_on_organization_sequential_id; +DROP INDEX IF EXISTS public.index_unique_quotes_on_organization_number; +DROP INDEX IF EXISTS public.index_unique_quote_versions_on_share_token; +DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_sequential_id; +DROP INDEX IF EXISTS public.index_unique_quote_versions_on_quote_active_status; +DROP INDEX IF EXISTS public.index_unique_quote_owners_on_quote_user; +DROP INDEX IF EXISTS public.index_unique_applied_to_organization_per_organization; +DROP INDEX IF EXISTS public.index_uniq_wallet_code_per_customer; +DROP INDEX IF EXISTS public.index_uniq_invoice_subscriptions_on_fixed_charges_boundaries; +DROP INDEX IF EXISTS public.index_uniq_invoice_subscriptions_on_charges_from_to_datetime; +DROP INDEX IF EXISTS public.index_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_subscriptions_on_status; +DROP INDEX IF EXISTS public.index_subscriptions_on_started_at_and_ending_at; +DROP INDEX IF EXISTS public.index_subscriptions_on_started_at; +DROP INDEX IF EXISTS public.index_subscriptions_on_previous_subscription_id_and_status; +DROP INDEX IF EXISTS public.index_subscriptions_on_plan_id; +DROP INDEX IF EXISTS public.index_subscriptions_on_payment_method_id; +DROP INDEX IF EXISTS public.index_subscriptions_on_organization_id; +DROP INDEX IF EXISTS public.index_subscriptions_on_last_received_event_on_null; +DROP INDEX IF EXISTS public.index_subscriptions_on_last_received_event_on; +DROP INDEX IF EXISTS public.index_subscriptions_on_external_id; +DROP INDEX IF EXISTS public.index_subscriptions_on_ending_at_active; +DROP INDEX IF EXISTS public.index_subscriptions_on_customer_id; +DROP INDEX IF EXISTS public.index_subscriptions_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_subscriptions_invoice_custom_sections_unique; +DROP INDEX IF EXISTS public.index_subscriptions_invoice_custom_sections_on_subscription_id; +DROP INDEX IF EXISTS public.index_subscriptions_invoice_custom_sections_on_organization_id; +DROP INDEX IF EXISTS public.index_subscription_activation_rules_on_organization_id; +DROP INDEX IF EXISTS public.index_search_quantified_events; +DROP INDEX IF EXISTS public.index_rtr_invoice_custom_sections_unique; +DROP INDEX IF EXISTS public.index_roles_on_organization_id; +DROP INDEX IF EXISTS public.index_roles_by_unique_admin; +DROP INDEX IF EXISTS public.index_roles_by_code_per_organization; +DROP INDEX IF EXISTS public.index_refunds_on_payment_provider_id; +DROP INDEX IF EXISTS public.index_refunds_on_payment_provider_customer_id; +DROP INDEX IF EXISTS public.index_refunds_on_payment_id; +DROP INDEX IF EXISTS public.index_refunds_on_organization_id; +DROP INDEX IF EXISTS public.index_refunds_on_credit_note_id; +DROP INDEX IF EXISTS public.index_recurring_transaction_rules_on_wallet_id; +DROP INDEX IF EXISTS public.index_recurring_transaction_rules_on_started_at; +DROP INDEX IF EXISTS public.index_recurring_transaction_rules_on_payment_method_id; +DROP INDEX IF EXISTS public.index_recurring_transaction_rules_on_organization_id; +DROP INDEX IF EXISTS public.index_recurring_transaction_rules_on_expiration_at; +DROP INDEX IF EXISTS public.index_quotes_on_subscription_id; +DROP INDEX IF EXISTS public.index_quotes_on_customer_id; +DROP INDEX IF EXISTS public.index_quote_versions_on_quote_id; +DROP INDEX IF EXISTS public.index_quote_versions_on_organization_id; +DROP INDEX IF EXISTS public.index_quote_owners_on_user_id; +DROP INDEX IF EXISTS public.index_quote_owners_on_organization_id; +DROP INDEX IF EXISTS public.index_quantified_events_on_organization_id; +DROP INDEX IF EXISTS public.index_quantified_events_on_group_id; +DROP INDEX IF EXISTS public.index_quantified_events_on_external_id; +DROP INDEX IF EXISTS public.index_quantified_events_on_deleted_at; +DROP INDEX IF EXISTS public.index_quantified_events_on_charge_filter_id; +DROP INDEX IF EXISTS public.index_quantified_events_on_billable_metric_id; +DROP INDEX IF EXISTS public.index_pricing_units_on_organization_id; +DROP INDEX IF EXISTS public.index_pricing_units_on_code_and_organization_id; +DROP INDEX IF EXISTS public.index_pricing_unit_usages_on_pricing_unit_id; +DROP INDEX IF EXISTS public.index_pricing_unit_usages_on_organization_id; +DROP INDEX IF EXISTS public.index_pricing_unit_usages_on_fee_id; +DROP INDEX IF EXISTS public.index_presentation_breakdowns_on_organization_id; +DROP INDEX IF EXISTS public.index_presentation_breakdowns_on_fee_id; +DROP INDEX IF EXISTS public.index_plans_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_plans_taxes_on_plan_id_and_tax_id; +DROP INDEX IF EXISTS public.index_plans_taxes_on_plan_id; +DROP INDEX IF EXISTS public.index_plans_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_plans_on_parent_id; +DROP INDEX IF EXISTS public.index_plans_on_organization_id_and_code; +DROP INDEX IF EXISTS public.index_plans_on_organization_id; +DROP INDEX IF EXISTS public.index_plans_on_deleted_at; +DROP INDEX IF EXISTS public.index_plans_on_created_at; +DROP INDEX IF EXISTS public.index_plans_on_bill_fixed_charges_monthly; +DROP INDEX IF EXISTS public.index_pending_vies_checks_on_organization_id; +DROP INDEX IF EXISTS public.index_pending_vies_checks_on_customer_id; +DROP INDEX IF EXISTS public.index_pending_vies_checks_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_payments_on_provider_payment_id_and_payment_provider_id; +DROP INDEX IF EXISTS public.index_payments_on_payment_type; +DROP INDEX IF EXISTS public.index_payments_on_payment_provider_id; +DROP INDEX IF EXISTS public.index_payments_on_payment_provider_customer_id; +DROP INDEX IF EXISTS public.index_payments_on_payment_method_id; +DROP INDEX IF EXISTS public.index_payments_on_payable_type_and_payable_id; +DROP INDEX IF EXISTS public.index_payments_on_payable_id_and_payable_type_and_error_code; +DROP INDEX IF EXISTS public.index_payments_on_payable_id_and_payable_type; +DROP INDEX IF EXISTS public.index_payments_on_organization_id; +DROP INDEX IF EXISTS public.index_payments_on_invoice_id; +DROP INDEX IF EXISTS public.index_payments_on_customer_id; +DROP INDEX IF EXISTS public.index_payment_requests_on_organization_id; +DROP INDEX IF EXISTS public.index_payment_requests_on_dunning_campaign_id; +DROP INDEX IF EXISTS public.index_payment_requests_on_customer_id; +DROP INDEX IF EXISTS public.index_payment_receipts_on_payment_id; +DROP INDEX IF EXISTS public.index_payment_receipts_on_organization_id; +DROP INDEX IF EXISTS public.index_payment_receipts_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_payment_providers_on_organization_id; +DROP INDEX IF EXISTS public.index_payment_providers_on_code_and_organization_id; +DROP INDEX IF EXISTS public.index_payment_provider_customers_on_provider_customer_id; +DROP INDEX IF EXISTS public.index_payment_provider_customers_on_payment_provider_id; +DROP INDEX IF EXISTS public.index_payment_provider_customers_on_organization_id; +DROP INDEX IF EXISTS public.index_payment_provider_customers_on_customer_id_and_type; +DROP INDEX IF EXISTS public.index_payment_methods_on_provider_method_type; +DROP INDEX IF EXISTS public.index_payment_methods_on_provider_customer_and_provider_method; +DROP INDEX IF EXISTS public.index_payment_methods_on_payment_provider_id; +DROP INDEX IF EXISTS public.index_payment_methods_on_payment_provider_customer_id; +DROP INDEX IF EXISTS public.index_payment_methods_on_organization_id; +DROP INDEX IF EXISTS public.index_payment_methods_on_customer_id; +DROP INDEX IF EXISTS public.index_payment_intents_on_organization_id; +DROP INDEX IF EXISTS public.index_payment_intents_on_invoice_id_and_status; +DROP INDEX IF EXISTS public.index_payment_intents_on_invoice_id; +DROP INDEX IF EXISTS public.index_password_resets_on_user_id; +DROP INDEX IF EXISTS public.index_password_resets_on_token; +DROP INDEX IF EXISTS public.index_organizations_on_slug; +DROP INDEX IF EXISTS public.index_organizations_on_hmac_key; +DROP INDEX IF EXISTS public.index_organizations_on_api_key; +DROP INDEX IF EXISTS public.index_memberships_on_user_id_and_organization_id; +DROP INDEX IF EXISTS public.index_memberships_on_user_id; +DROP INDEX IF EXISTS public.index_memberships_on_organization_id; +DROP INDEX IF EXISTS public.index_memberships_by_id_and_organization; +DROP INDEX IF EXISTS public.index_membership_roles_uniqueness; +DROP INDEX IF EXISTS public.index_membership_roles_on_role_id; +DROP INDEX IF EXISTS public.index_membership_roles_by_membership_and_organization; +DROP INDEX IF EXISTS public.index_lifetime_usages_on_subscription_id; +DROP INDEX IF EXISTS public.index_lifetime_usages_on_recalculate_invoiced_usage; +DROP INDEX IF EXISTS public.index_lifetime_usages_on_recalculate_current_usage; +DROP INDEX IF EXISTS public.index_lifetime_usages_on_organization_id; +DROP INDEX IF EXISTS public.index_item_metadata_on_value; +DROP INDEX IF EXISTS public.index_item_metadata_on_owner_type_and_owner_id; +DROP INDEX IF EXISTS public.index_item_metadata_on_organization_id; +DROP INDEX IF EXISTS public.index_invoices_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_invoices_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_invoices_taxes_on_invoice_id_and_tax_id; +DROP INDEX IF EXISTS public.index_invoices_taxes_on_invoice_id; +DROP INDEX IF EXISTS public.index_invoices_payment_requests_on_payment_request_id; +DROP INDEX IF EXISTS public.index_invoices_payment_requests_on_organization_id; +DROP INDEX IF EXISTS public.index_invoices_payment_requests_on_invoice_id; +DROP INDEX IF EXISTS public.index_invoices_on_voided_invoice_id; +DROP INDEX IF EXISTS public.index_invoices_on_ready_to_be_refreshed; +DROP INDEX IF EXISTS public.index_invoices_on_payment_method_id; +DROP INDEX IF EXISTS public.index_invoices_on_payment_due_date; +DROP INDEX IF EXISTS public.index_invoices_on_number; +DROP INDEX IF EXISTS public.index_invoices_on_customer_id_and_sequential_id; +DROP INDEX IF EXISTS public.index_invoices_by_cursor; +DROP INDEX IF EXISTS public.index_invoice_subscriptions_on_subscription_id; +DROP INDEX IF EXISTS public.index_invoice_subscriptions_on_regenerated_invoice_id; +DROP INDEX IF EXISTS public.index_invoice_subscriptions_on_organization_id; +DROP INDEX IF EXISTS public.index_invoice_subscriptions_on_invoice_id_and_subscription_id; +DROP INDEX IF EXISTS public.index_invoice_subscriptions_on_invoice_id; +DROP INDEX IF EXISTS public.index_invoice_subscriptions_boundaries; +DROP INDEX IF EXISTS public.index_invoice_settlements_on_target_invoice_id; +DROP INDEX IF EXISTS public.index_invoice_settlements_on_source_payment_id; +DROP INDEX IF EXISTS public.index_invoice_settlements_on_source_credit_note_id; +DROP INDEX IF EXISTS public.index_invoice_settlements_on_organization_id; +DROP INDEX IF EXISTS public.index_invoice_settlements_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_invoice_metadata_on_organization_id; +DROP INDEX IF EXISTS public.index_invoice_metadata_on_invoice_id_and_key; +DROP INDEX IF EXISTS public.index_invoice_metadata_on_invoice_id; +DROP INDEX IF EXISTS public.index_invoice_custom_sections_on_section_type; +DROP INDEX IF EXISTS public.index_invoice_custom_sections_on_organization_id_and_code; +DROP INDEX IF EXISTS public.index_invoice_custom_sections_on_organization_id; +DROP INDEX IF EXISTS public.index_invites_on_token; +DROP INDEX IF EXISTS public.index_invites_on_organization_id; +DROP INDEX IF EXISTS public.index_invites_on_membership_id; +DROP INDEX IF EXISTS public.index_integrations_on_organization_id; +DROP INDEX IF EXISTS public.index_integrations_on_code_and_organization_id; +DROP INDEX IF EXISTS public.index_integration_resources_on_syncable; +DROP INDEX IF EXISTS public.index_integration_resources_on_organization_id; +DROP INDEX IF EXISTS public.index_integration_resources_on_integration_id; +DROP INDEX IF EXISTS public.index_integration_mappings_unique_billing_entity_id_is_null; +DROP INDEX IF EXISTS public.index_integration_mappings_unique_billing_entity_id_is_not_null; +DROP INDEX IF EXISTS public.index_integration_mappings_on_organization_id; +DROP INDEX IF EXISTS public.index_integration_mappings_on_mappable; +DROP INDEX IF EXISTS public.index_integration_mappings_on_integration_id; +DROP INDEX IF EXISTS public.index_integration_items_on_organization_id; +DROP INDEX IF EXISTS public.index_integration_items_on_integration_id; +DROP INDEX IF EXISTS public.index_integration_customers_on_organization_id; +DROP INDEX IF EXISTS public.index_integration_customers_on_integration_id; +DROP INDEX IF EXISTS public.index_integration_customers_on_external_customer_id; +DROP INDEX IF EXISTS public.index_integration_customers_on_customer_id_and_type; +DROP INDEX IF EXISTS public.index_integration_customers_on_customer_id; +DROP INDEX IF EXISTS public.index_integration_collection_mappings_on_organization_id; +DROP INDEX IF EXISTS public.index_integration_collection_mappings_on_integration_id; +DROP INDEX IF EXISTS public.index_integration_collection_mappings_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_int_items_on_external_id_and_int_id_and_type; +DROP INDEX IF EXISTS public.index_int_collection_mappings_unique_billing_entity_is_null; +DROP INDEX IF EXISTS public.index_int_collection_mappings_unique_billing_entity_is_not_null; +DROP INDEX IF EXISTS public.index_inbound_webhooks_on_status_and_processing_at; +DROP INDEX IF EXISTS public.index_inbound_webhooks_on_status_and_created_at; +DROP INDEX IF EXISTS public.index_inbound_webhooks_on_organization_id; +DROP INDEX IF EXISTS public.index_idempotency_records_on_resource_type_and_resource_id; +DROP INDEX IF EXISTS public.index_idempotency_records_on_organization_id; +DROP INDEX IF EXISTS public.index_idempotency_records_on_idempotency_key; +DROP INDEX IF EXISTS public.index_groups_on_parent_group_id; +DROP INDEX IF EXISTS public.index_groups_on_deleted_at; +DROP INDEX IF EXISTS public.index_groups_on_billable_metric_id_and_parent_group_id; +DROP INDEX IF EXISTS public.index_groups_on_billable_metric_id; +DROP INDEX IF EXISTS public.index_group_properties_on_group_id; +DROP INDEX IF EXISTS public.index_group_properties_on_deleted_at; +DROP INDEX IF EXISTS public.index_group_properties_on_charge_id_and_group_id; +DROP INDEX IF EXISTS public.index_group_properties_on_charge_id; +DROP INDEX IF EXISTS public.index_fixed_charges_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_fixed_charges_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_fixed_charges_taxes_on_fixed_charge_id_and_tax_id; +DROP INDEX IF EXISTS public.index_fixed_charges_taxes_on_fixed_charge_id; +DROP INDEX IF EXISTS public.index_fixed_charges_on_plan_id_and_code; +DROP INDEX IF EXISTS public.index_fixed_charges_on_plan_id; +DROP INDEX IF EXISTS public.index_fixed_charges_on_parent_id; +DROP INDEX IF EXISTS public.index_fixed_charges_on_organization_id; +DROP INDEX IF EXISTS public.index_fixed_charges_on_deleted_at; +DROP INDEX IF EXISTS public.index_fixed_charges_on_add_on_id; +DROP INDEX IF EXISTS public.index_fixed_charge_events_on_subscription_id; +DROP INDEX IF EXISTS public.index_fixed_charge_events_on_organization_id; +DROP INDEX IF EXISTS public.index_fixed_charge_events_on_fixed_charge_id; +DROP INDEX IF EXISTS public.index_fixed_charge_events_on_deleted_at; +DROP INDEX IF EXISTS public.index_fees_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_fees_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_fees_taxes_on_fee_id_and_tax_id; +DROP INDEX IF EXISTS public.index_fees_taxes_on_fee_id; +DROP INDEX IF EXISTS public.index_fees_on_true_up_parent_fee_id; +DROP INDEX IF EXISTS public.index_fees_on_subscription_id; +DROP INDEX IF EXISTS public.index_fees_on_pay_in_advance_event_transaction_id; +DROP INDEX IF EXISTS public.index_fees_on_original_fee_id; +DROP INDEX IF EXISTS public.index_fees_on_organization_id; +DROP INDEX IF EXISTS public.index_fees_on_invoiceable; +DROP INDEX IF EXISTS public.index_fees_on_invoice_id; +DROP INDEX IF EXISTS public.index_fees_on_group_id; +DROP INDEX IF EXISTS public.index_fees_on_fixed_charge_id; +DROP INDEX IF EXISTS public.index_fees_on_deleted_at; +DROP INDEX IF EXISTS public.index_fees_on_charge_id_and_invoice_id; +DROP INDEX IF EXISTS public.index_fees_on_charge_id; +DROP INDEX IF EXISTS public.index_fees_on_charge_filter_id; +DROP INDEX IF EXISTS public.index_fees_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_fees_on_applied_add_on_id; +DROP INDEX IF EXISTS public.index_fees_on_add_on_id; +DROP INDEX IF EXISTS public.index_events_on_organization_id_and_transaction_id; +DROP INDEX IF EXISTS public.index_events_on_organization_id_and_created_at; +DROP INDEX IF EXISTS public.index_events_on_organization_id_and_code; +DROP INDEX IF EXISTS public.index_events_on_organization_id; +DROP INDEX IF EXISTS public.index_events_on_created_at; +DROP INDEX IF EXISTS public.index_error_details_on_owner; +DROP INDEX IF EXISTS public.index_error_details_on_organization_id; +DROP INDEX IF EXISTS public.index_error_details_on_error_code; +DROP INDEX IF EXISTS public.index_error_details_on_deleted_at; +DROP INDEX IF EXISTS public.index_entitlement_subscription_feature_removals_on_deleted_at; +DROP INDEX IF EXISTS public.index_entitlement_privileges_on_organization_id; +DROP INDEX IF EXISTS public.index_entitlement_privileges_on_entitlement_feature_id; +DROP INDEX IF EXISTS public.index_entitlement_features_on_organization_id; +DROP INDEX IF EXISTS public.index_entitlement_entitlements_on_subscription_id; +DROP INDEX IF EXISTS public.index_entitlement_entitlements_on_plan_id; +DROP INDEX IF EXISTS public.index_entitlement_entitlements_on_organization_id; +DROP INDEX IF EXISTS public.index_entitlement_entitlements_on_entitlement_feature_id; +DROP INDEX IF EXISTS public.index_entitlement_entitlement_values_on_organization_id; +DROP INDEX IF EXISTS public.index_enriched_store_migrations_on_organization_id; +DROP INDEX IF EXISTS public.index_dunning_campaigns_on_organization_id_and_code; +DROP INDEX IF EXISTS public.index_dunning_campaigns_on_organization_id; +DROP INDEX IF EXISTS public.index_dunning_campaigns_on_deleted_at; +DROP INDEX IF EXISTS public.index_dunning_campaign_thresholds_on_organization_id; +DROP INDEX IF EXISTS public.index_dunning_campaign_thresholds_on_dunning_campaign_id; +DROP INDEX IF EXISTS public.index_dunning_campaign_thresholds_on_deleted_at; +DROP INDEX IF EXISTS public.index_data_exports_on_organization_id; +DROP INDEX IF EXISTS public.index_data_exports_on_membership_id; +DROP INDEX IF EXISTS public.index_data_export_parts_on_organization_id; +DROP INDEX IF EXISTS public.index_data_export_parts_on_data_export_id; +DROP INDEX IF EXISTS public.index_daily_usages_on_usage_date; +DROP INDEX IF EXISTS public.index_daily_usages_on_subscription_id; +DROP INDEX IF EXISTS public.index_daily_usages_on_organization_id; +DROP INDEX IF EXISTS public.index_daily_usages_on_customer_id; +DROP INDEX IF EXISTS public.index_customers_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_customers_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_customers_taxes_on_customer_id_and_tax_id; +DROP INDEX IF EXISTS public.index_customers_taxes_on_customer_id; +DROP INDEX IF EXISTS public.index_customers_on_sequential_id; +DROP INDEX IF EXISTS public.index_customers_on_org_id_and_sequential_id_unique; +DROP INDEX IF EXISTS public.index_customers_on_external_id_and_organization_id; +DROP INDEX IF EXISTS public.index_customers_on_external_id; +DROP INDEX IF EXISTS public.index_customers_on_deleted_at; +DROP INDEX IF EXISTS public.index_customers_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_customers_on_awaiting_wallet_refresh; +DROP INDEX IF EXISTS public.index_customers_on_applied_dunning_campaign_id; +DROP INDEX IF EXISTS public.index_customers_on_account_type; +DROP INDEX IF EXISTS public.index_customers_invoice_custom_sections_on_organization_id; +DROP INDEX IF EXISTS public.index_customers_invoice_custom_sections_on_customer_id; +DROP INDEX IF EXISTS public.index_customers_invoice_custom_sections_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_customer_metadata_on_organization_id; +DROP INDEX IF EXISTS public.index_customer_metadata_on_customer_id_and_key; +DROP INDEX IF EXISTS public.index_customer_metadata_on_customer_id; +DROP INDEX IF EXISTS public.index_credits_on_progressive_billing_invoice_id; +DROP INDEX IF EXISTS public.index_credits_on_organization_id; +DROP INDEX IF EXISTS public.index_credits_on_invoice_id; +DROP INDEX IF EXISTS public.index_credits_on_credit_note_id; +DROP INDEX IF EXISTS public.index_credits_on_applied_coupon_id; +DROP INDEX IF EXISTS public.index_credit_notes_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_credit_notes_taxes_on_tax_code; +DROP INDEX IF EXISTS public.index_credit_notes_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_credit_notes_taxes_on_credit_note_id_and_tax_code; +DROP INDEX IF EXISTS public.index_credit_notes_taxes_on_credit_note_id; +DROP INDEX IF EXISTS public.index_credit_notes_on_organization_id; +DROP INDEX IF EXISTS public.index_credit_notes_on_invoice_id_and_sequential_id; +DROP INDEX IF EXISTS public.index_credit_notes_on_invoice_id; +DROP INDEX IF EXISTS public.index_credit_notes_on_customer_id; +DROP INDEX IF EXISTS public.index_credit_note_items_on_organization_id; +DROP INDEX IF EXISTS public.index_credit_note_items_on_fee_id; +DROP INDEX IF EXISTS public.index_credit_note_items_on_credit_note_id; +DROP INDEX IF EXISTS public.index_coupons_on_organization_id_and_code; +DROP INDEX IF EXISTS public.index_coupons_on_organization_id; +DROP INDEX IF EXISTS public.index_coupons_on_deleted_at; +DROP INDEX IF EXISTS public.index_coupon_targets_on_plan_id; +DROP INDEX IF EXISTS public.index_coupon_targets_on_organization_id; +DROP INDEX IF EXISTS public.index_coupon_targets_on_deleted_at; +DROP INDEX IF EXISTS public.index_coupon_targets_on_coupon_id; +DROP INDEX IF EXISTS public.index_coupon_targets_on_billable_metric_id; +DROP INDEX IF EXISTS public.index_commitments_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_commitments_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_commitments_taxes_on_commitment_id_and_tax_id; +DROP INDEX IF EXISTS public.index_commitments_taxes_on_commitment_id; +DROP INDEX IF EXISTS public.index_commitments_on_plan_id; +DROP INDEX IF EXISTS public.index_commitments_on_organization_id; +DROP INDEX IF EXISTS public.index_commitments_on_commitment_type_and_plan_id; +DROP INDEX IF EXISTS public.index_charges_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_charges_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_charges_taxes_on_charge_id_and_tax_id; +DROP INDEX IF EXISTS public.index_charges_taxes_on_charge_id; +DROP INDEX IF EXISTS public.index_charges_pay_in_advance; +DROP INDEX IF EXISTS public.index_charges_on_plan_id_and_code; +DROP INDEX IF EXISTS public.index_charges_on_plan_id; +DROP INDEX IF EXISTS public.index_charges_on_parent_id; +DROP INDEX IF EXISTS public.index_charges_on_organization_id; +DROP INDEX IF EXISTS public.index_charges_on_deleted_at; +DROP INDEX IF EXISTS public.index_charges_on_billable_metric_id; +DROP INDEX IF EXISTS public.index_charges_on_accepts_target_wallet; +DROP INDEX IF EXISTS public.index_charge_filters_on_organization_id; +DROP INDEX IF EXISTS public.index_charge_filters_on_deleted_at; +DROP INDEX IF EXISTS public.index_charge_filters_on_charge_id; +DROP INDEX IF EXISTS public.index_charge_filter_values_on_organization_id; +DROP INDEX IF EXISTS public.index_charge_filter_values_on_deleted_at; +DROP INDEX IF EXISTS public.index_charge_filter_values_on_charge_filter_id; +DROP INDEX IF EXISTS public.index_charge_filter_values_on_billable_metric_filter_id; +DROP INDEX IF EXISTS public.index_cached_aggregations_on_external_subscription_id; +DROP INDEX IF EXISTS public.index_cached_aggregations_on_event_transaction_id; +DROP INDEX IF EXISTS public.index_cached_aggregations_on_charge_id; +DROP INDEX IF EXISTS public.index_billing_entities_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_billing_entities_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_billing_entities_taxes_on_billing_entity_id_and_tax_id; +DROP INDEX IF EXISTS public.index_billing_entities_taxes_on_billing_entity_id; +DROP INDEX IF EXISTS public.index_billing_entities_on_organization_id; +DROP INDEX IF EXISTS public.index_billing_entities_on_code_and_organization_id; +DROP INDEX IF EXISTS public.index_billing_entities_on_applied_dunning_campaign_id; +DROP INDEX IF EXISTS public.index_billable_metrics_on_organization_id_and_code; +DROP INDEX IF EXISTS public.index_billable_metrics_on_organization_id; +DROP INDEX IF EXISTS public.index_billable_metrics_on_org_id_and_code_and_expr; +DROP INDEX IF EXISTS public.index_billable_metrics_on_deleted_at; +DROP INDEX IF EXISTS public.index_billable_metric_filters_on_organization_id; +DROP INDEX IF EXISTS public.index_billable_metric_filters_on_deleted_at; +DROP INDEX IF EXISTS public.index_billable_metric_filters_on_billable_metric_id; +DROP INDEX IF EXISTS public.index_applied_usage_thresholds_on_usage_threshold_id; +DROP INDEX IF EXISTS public.index_applied_usage_thresholds_on_organization_id; +DROP INDEX IF EXISTS public.index_applied_usage_thresholds_on_invoice_id; +DROP INDEX IF EXISTS public.index_applied_pricing_units_on_pricing_unitable; +DROP INDEX IF EXISTS public.index_applied_pricing_units_on_pricing_unit_id; +DROP INDEX IF EXISTS public.index_applied_pricing_units_on_organization_id; +DROP INDEX IF EXISTS public.index_applied_invoice_custom_sections_on_organization_id; +DROP INDEX IF EXISTS public.index_applied_invoice_custom_sections_on_invoice_id; +DROP INDEX IF EXISTS public.index_applied_coupons_on_organization_id; +DROP INDEX IF EXISTS public.index_applied_coupons_on_customer_id; +DROP INDEX IF EXISTS public.index_applied_coupons_on_coupon_id; +DROP INDEX IF EXISTS public.index_applied_add_ons_on_customer_id; +DROP INDEX IF EXISTS public.index_applied_add_ons_on_add_on_id_and_customer_id; +DROP INDEX IF EXISTS public.index_applied_add_ons_on_add_on_id; +DROP INDEX IF EXISTS public.index_api_keys_on_value; +DROP INDEX IF EXISTS public.index_api_keys_on_organization_id; +DROP INDEX IF EXISTS public.index_ai_conversations_on_organization_id; +DROP INDEX IF EXISTS public.index_ai_conversations_on_membership_id; +DROP INDEX IF EXISTS public.index_adjusted_fees_on_subscription_id; +DROP INDEX IF EXISTS public.index_adjusted_fees_on_organization_id; +DROP INDEX IF EXISTS public.index_adjusted_fees_on_invoice_id; +DROP INDEX IF EXISTS public.index_adjusted_fees_on_group_id; +DROP INDEX IF EXISTS public.index_adjusted_fees_on_fee_id; +DROP INDEX IF EXISTS public.index_adjusted_fees_on_charge_id; +DROP INDEX IF EXISTS public.index_adjusted_fees_on_charge_filter_id; +DROP INDEX IF EXISTS public.index_add_ons_taxes_on_tax_id; +DROP INDEX IF EXISTS public.index_add_ons_taxes_on_organization_id; +DROP INDEX IF EXISTS public.index_add_ons_taxes_on_add_on_id_and_tax_id; +DROP INDEX IF EXISTS public.index_add_ons_taxes_on_add_on_id; +DROP INDEX IF EXISTS public.index_add_ons_on_organization_id_and_code; +DROP INDEX IF EXISTS public.index_add_ons_on_organization_id; +DROP INDEX IF EXISTS public.index_add_ons_on_deleted_at; +DROP INDEX IF EXISTS public.index_active_storage_variant_records_uniqueness; +DROP INDEX IF EXISTS public.index_active_storage_blobs_on_key; +DROP INDEX IF EXISTS public.index_active_storage_attachments_uniqueness; +DROP INDEX IF EXISTS public.index_active_storage_attachments_on_blob_id; +DROP INDEX IF EXISTS public.index_active_metric_filters; +DROP INDEX IF EXISTS public.index_active_charge_filters; +DROP INDEX IF EXISTS public.index_active_charge_filter_values; +DROP INDEX IF EXISTS public.index_activation_rules_pending_with_expiry; +DROP INDEX IF EXISTS public.idx_wallet_tx_consumptions_inbound_outbound; +DROP INDEX IF EXISTS public.idx_wallet_transactions_available_inbound; +DROP INDEX IF EXISTS public.idx_usage_thresholds_subscription_recurring; +DROP INDEX IF EXISTS public.idx_usage_thresholds_plan_recurring; +DROP INDEX IF EXISTS public.idx_usage_thresholds_on_amount_subscription_recurring; +DROP INDEX IF EXISTS public.idx_usage_thresholds_on_amount_plan_recurring; +DROP INDEX IF EXISTS public.idx_unique_tax_code_per_organization; +DROP INDEX IF EXISTS public.idx_unique_privilege_removal_per_subscription; +DROP INDEX IF EXISTS public.idx_unique_feature_removal_per_subscription; +DROP INDEX IF EXISTS public.idx_unique_feature_per_subscription; +DROP INDEX IF EXISTS public.idx_unique_feature_per_plan; +DROP INDEX IF EXISTS public.idx_subscription_unique; +DROP INDEX IF EXISTS public.idx_privileges_code_unique_per_feature; +DROP INDEX IF EXISTS public.idx_pay_in_advance_duplication_guard_charge_filter; +DROP INDEX IF EXISTS public.idx_pay_in_advance_duplication_guard_charge; +DROP INDEX IF EXISTS public.idx_on_wallet_transaction_id_ac2826109e; +DROP INDEX IF EXISTS public.idx_on_usage_threshold_id_invoice_id_cb82cdf163; +DROP INDEX IF EXISTS public.idx_on_usage_monitoring_alert_id_recurring_756a2a370d; +DROP INDEX IF EXISTS public.idx_on_usage_monitoring_alert_id_78eb24d06c; +DROP INDEX IF EXISTS public.idx_on_usage_monitoring_alert_id_4290c95dec; +DROP INDEX IF EXISTS public.idx_on_subscription_id_type_8feb7b9623; +DROP INDEX IF EXISTS public.idx_on_subscription_id_b41afd08e0; +DROP INDEX IF EXISTS public.idx_on_subscription_id_295edd8bb3; +DROP INDEX IF EXISTS public.idx_on_recurring_transaction_rule_id_fba3d39cca; +DROP INDEX IF EXISTS public.idx_on_plan_id_billable_metric_id_pay_in_advance_4a205974cb; +DROP INDEX IF EXISTS public.idx_on_outbound_wallet_transaction_id_cf6ff733c6; +DROP INDEX IF EXISTS public.idx_on_organization_id_organization_sequential_id_2387146f54; +DROP INDEX IF EXISTS public.idx_on_organization_id_external_subscription_id_df3a30d96d; +DROP INDEX IF EXISTS public.idx_on_organization_id_e73219f079; +DROP INDEX IF EXISTS public.idx_on_organization_id_deleted_at_225e3f789d; +DROP INDEX IF EXISTS public.idx_on_organization_id_ccdf05cbfe; +DROP INDEX IF EXISTS public.idx_on_organization_id_83703a45f4; +DROP INDEX IF EXISTS public.idx_on_organization_id_7020c3c43a; +DROP INDEX IF EXISTS public.idx_on_organization_id_376a587b04; +DROP INDEX IF EXISTS public.idx_on_organization_id_2be2ef98ea; +DROP INDEX IF EXISTS public.idx_on_invoice_id_payment_request_id_aa550779a4; +DROP INDEX IF EXISTS public.idx_on_invoice_custom_section_id_d8b9068730; +DROP INDEX IF EXISTS public.idx_on_invoice_custom_section_id_ccb39e9622; +DROP INDEX IF EXISTS public.idx_on_invoice_custom_section_id_b381df5bb5; +DROP INDEX IF EXISTS public.idx_on_invoice_custom_section_id_aca4661c33; +DROP INDEX IF EXISTS public.idx_on_invoice_custom_section_id_5f37496c8c; +DROP INDEX IF EXISTS public.idx_on_invoice_custom_section_id_50c2a2e7c0; +DROP INDEX IF EXISTS public.idx_on_inbound_wallet_transaction_id_e54d00758d; +DROP INDEX IF EXISTS public.idx_on_entitlement_privilege_id_entitlement_entitle_9d0542eb1a; +DROP INDEX IF EXISTS public.idx_on_entitlement_privilege_id_9946ccf514; +DROP INDEX IF EXISTS public.idx_on_entitlement_privilege_id_6a228dc433; +DROP INDEX IF EXISTS public.idx_on_entitlement_feature_id_821ae72311; +DROP INDEX IF EXISTS public.idx_on_entitlement_entitlement_id_48c0b3356a; +DROP INDEX IF EXISTS public.idx_on_enriched_store_migration_id_e409c5dc43; +DROP INDEX IF EXISTS public.idx_on_dunning_campaign_id_currency_fbf233b2ae; +DROP INDEX IF EXISTS public.idx_on_billing_entity_id_invoice_custom_section_id_bd78c547d3; +DROP INDEX IF EXISTS public.idx_on_billing_entity_id_customer_id_invoice_custom_e7aada65cb; +DROP INDEX IF EXISTS public.idx_on_billing_entity_id_billing_entity_sequential__bd26b2e655; +DROP INDEX IF EXISTS public.idx_on_billing_entity_id_724373e5ae; +DROP INDEX IF EXISTS public.idx_invoices_organization_id_status; +DROP INDEX IF EXISTS public.idx_invoice_subscriptions_on_subscription_with_timestamps; +DROP INDEX IF EXISTS public.idx_features_code_unique_per_organization; +DROP INDEX IF EXISTS public.idx_events_for_distinct_codes; +DROP INDEX IF EXISTS public.idx_events_billing_lookup; +DROP INDEX IF EXISTS public.idx_enriched_store_sub_migrations_on_migration_and_subscription; +DROP INDEX IF EXISTS public.idx_enqueued_per_organization; +DROP INDEX IF EXISTS public.idx_cached_aggregation_filtered_lookup; +DROP INDEX IF EXISTS public.idx_alerts_unique_per_type_per_wallet; +DROP INDEX IF EXISTS public.idx_alerts_unique_per_type_per_subscription_with_bm; +DROP INDEX IF EXISTS public.idx_alerts_unique_per_type_per_subscription; +DROP INDEX IF EXISTS public.idx_alerts_code_unique_per_subscription; +DROP INDEX IF EXISTS public.idx_aggregation_lookup; +DROP INDEX IF EXISTS public.idx_billing_on_enriched_events; +DROP INDEX IF EXISTS public.idx_lookup_on_enriched_events; +DROP INDEX IF EXISTS public.idx_unique_on_enriched_events; +DROP INDEX IF EXISTS public.index_enriched_events_on_event_id; +ALTER TABLE IF EXISTS ONLY public.webhooks DROP CONSTRAINT IF EXISTS webhooks_pkey; +ALTER TABLE IF EXISTS ONLY public.webhook_endpoints DROP CONSTRAINT IF EXISTS webhook_endpoints_pkey; +ALTER TABLE IF EXISTS ONLY public.wallets DROP CONSTRAINT IF EXISTS wallets_pkey; +ALTER TABLE IF EXISTS ONLY public.wallets_invoice_custom_sections DROP CONSTRAINT IF EXISTS wallets_invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions DROP CONSTRAINT IF EXISTS wallet_transactions_pkey; +ALTER TABLE IF EXISTS ONLY public.wallet_transactions_invoice_custom_sections DROP CONSTRAINT IF EXISTS wallet_transactions_invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.wallet_transaction_consumptions DROP CONSTRAINT IF EXISTS wallet_transaction_consumptions_pkey; +ALTER TABLE IF EXISTS ONLY public.wallet_targets DROP CONSTRAINT IF EXISTS wallet_targets_pkey; +ALTER TABLE IF EXISTS ONLY public.versions DROP CONSTRAINT IF EXISTS versions_pkey; +ALTER TABLE IF EXISTS ONLY public.users DROP CONSTRAINT IF EXISTS users_pkey; +ALTER TABLE IF EXISTS ONLY public.user_devices DROP CONSTRAINT IF EXISTS user_devices_pkey; +ALTER TABLE IF EXISTS ONLY public.usage_thresholds DROP CONSTRAINT IF EXISTS usage_thresholds_pkey; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_triggered_alerts DROP CONSTRAINT IF EXISTS usage_monitoring_triggered_alerts_pkey; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_subscription_activities DROP CONSTRAINT IF EXISTS usage_monitoring_subscription_activities_pkey; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alerts DROP CONSTRAINT IF EXISTS usage_monitoring_alerts_pkey; +ALTER TABLE IF EXISTS ONLY public.usage_monitoring_alert_thresholds DROP CONSTRAINT IF EXISTS usage_monitoring_alert_thresholds_pkey; +ALTER TABLE IF EXISTS ONLY public.taxes DROP CONSTRAINT IF EXISTS taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.subscriptions DROP CONSTRAINT IF EXISTS subscriptions_pkey; +ALTER TABLE IF EXISTS ONLY public.subscriptions_invoice_custom_sections DROP CONSTRAINT IF EXISTS subscriptions_invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.subscription_activation_rules DROP CONSTRAINT IF EXISTS subscription_activation_rules_pkey; +ALTER TABLE IF EXISTS ONLY public.schema_migrations DROP CONSTRAINT IF EXISTS schema_migrations_pkey; +ALTER TABLE IF EXISTS ONLY public.roles DROP CONSTRAINT IF EXISTS roles_pkey; +ALTER TABLE IF EXISTS ONLY public.refunds DROP CONSTRAINT IF EXISTS refunds_pkey; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules DROP CONSTRAINT IF EXISTS recurring_transaction_rules_pkey; +ALTER TABLE IF EXISTS ONLY public.recurring_transaction_rules_invoice_custom_sections DROP CONSTRAINT IF EXISTS recurring_transaction_rules_invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.quotes DROP CONSTRAINT IF EXISTS quotes_pkey; +ALTER TABLE IF EXISTS ONLY public.quote_versions DROP CONSTRAINT IF EXISTS quote_versions_pkey; +ALTER TABLE IF EXISTS ONLY public.quote_owners DROP CONSTRAINT IF EXISTS quote_owners_pkey; +ALTER TABLE IF EXISTS ONLY public.quantified_events DROP CONSTRAINT IF EXISTS quantified_events_pkey; +ALTER TABLE IF EXISTS ONLY public.pricing_units DROP CONSTRAINT IF EXISTS pricing_units_pkey; +ALTER TABLE IF EXISTS ONLY public.pricing_unit_usages DROP CONSTRAINT IF EXISTS pricing_unit_usages_pkey; +ALTER TABLE IF EXISTS ONLY public.presentation_breakdowns DROP CONSTRAINT IF EXISTS presentation_breakdowns_pkey; +ALTER TABLE IF EXISTS ONLY public.plans_taxes DROP CONSTRAINT IF EXISTS plans_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.plans DROP CONSTRAINT IF EXISTS plans_pkey; +ALTER TABLE IF EXISTS ONLY public.pending_vies_checks DROP CONSTRAINT IF EXISTS pending_vies_checks_pkey; +ALTER TABLE IF EXISTS ONLY public.payments DROP CONSTRAINT IF EXISTS payments_pkey; +ALTER TABLE IF EXISTS public.payments DROP CONSTRAINT IF EXISTS payments_customer_id_null; +ALTER TABLE IF EXISTS ONLY public.payment_requests DROP CONSTRAINT IF EXISTS payment_requests_pkey; +ALTER TABLE IF EXISTS ONLY public.payment_receipts DROP CONSTRAINT IF EXISTS payment_receipts_pkey; +ALTER TABLE IF EXISTS ONLY public.payment_providers DROP CONSTRAINT IF EXISTS payment_providers_pkey; +ALTER TABLE IF EXISTS ONLY public.payment_provider_customers DROP CONSTRAINT IF EXISTS payment_provider_customers_pkey; +ALTER TABLE IF EXISTS ONLY public.payment_methods DROP CONSTRAINT IF EXISTS payment_methods_pkey; +ALTER TABLE IF EXISTS ONLY public.payment_intents DROP CONSTRAINT IF EXISTS payment_intents_pkey; +ALTER TABLE IF EXISTS ONLY public.password_resets DROP CONSTRAINT IF EXISTS password_resets_pkey; +ALTER TABLE IF EXISTS ONLY public.organizations DROP CONSTRAINT IF EXISTS organizations_pkey; +ALTER TABLE IF EXISTS ONLY public.memberships DROP CONSTRAINT IF EXISTS memberships_pkey; +ALTER TABLE IF EXISTS ONLY public.membership_roles DROP CONSTRAINT IF EXISTS membership_roles_pkey; +ALTER TABLE IF EXISTS ONLY public.lifetime_usages DROP CONSTRAINT IF EXISTS lifetime_usages_pkey; +ALTER TABLE IF EXISTS ONLY public.item_metadata DROP CONSTRAINT IF EXISTS item_metadata_pkey; +ALTER TABLE IF EXISTS ONLY public.invoices_taxes DROP CONSTRAINT IF EXISTS invoices_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.invoices DROP CONSTRAINT IF EXISTS invoices_pkey; +ALTER TABLE IF EXISTS ONLY public.invoices_payment_requests DROP CONSTRAINT IF EXISTS invoices_payment_requests_pkey; +ALTER TABLE IF EXISTS ONLY public.invoice_subscriptions DROP CONSTRAINT IF EXISTS invoice_subscriptions_pkey; +ALTER TABLE IF EXISTS ONLY public.invoice_settlements DROP CONSTRAINT IF EXISTS invoice_settlements_pkey; +ALTER TABLE IF EXISTS ONLY public.invoice_metadata DROP CONSTRAINT IF EXISTS invoice_metadata_pkey; +ALTER TABLE IF EXISTS ONLY public.invoice_custom_sections DROP CONSTRAINT IF EXISTS invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.invites DROP CONSTRAINT IF EXISTS invites_pkey; +ALTER TABLE IF EXISTS ONLY public.integrations DROP CONSTRAINT IF EXISTS integrations_pkey; +ALTER TABLE IF EXISTS ONLY public.integration_resources DROP CONSTRAINT IF EXISTS integration_resources_pkey; +ALTER TABLE IF EXISTS ONLY public.integration_mappings DROP CONSTRAINT IF EXISTS integration_mappings_pkey; +ALTER TABLE IF EXISTS ONLY public.integration_items DROP CONSTRAINT IF EXISTS integration_items_pkey; +ALTER TABLE IF EXISTS ONLY public.integration_customers DROP CONSTRAINT IF EXISTS integration_customers_pkey; +ALTER TABLE IF EXISTS ONLY public.integration_collection_mappings DROP CONSTRAINT IF EXISTS integration_collection_mappings_pkey; +ALTER TABLE IF EXISTS ONLY public.inbound_webhooks DROP CONSTRAINT IF EXISTS inbound_webhooks_pkey; +ALTER TABLE IF EXISTS ONLY public.idempotency_records DROP CONSTRAINT IF EXISTS idempotency_records_pkey; +ALTER TABLE IF EXISTS ONLY public.groups DROP CONSTRAINT IF EXISTS groups_pkey; +ALTER TABLE IF EXISTS ONLY public.group_properties DROP CONSTRAINT IF EXISTS group_properties_pkey; +ALTER TABLE IF EXISTS ONLY public.fixed_charges_taxes DROP CONSTRAINT IF EXISTS fixed_charges_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.fixed_charges DROP CONSTRAINT IF EXISTS fixed_charges_pkey; +ALTER TABLE IF EXISTS ONLY public.fixed_charge_events DROP CONSTRAINT IF EXISTS fixed_charge_events_pkey; +ALTER TABLE IF EXISTS ONLY public.fees_taxes DROP CONSTRAINT IF EXISTS fees_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.fees DROP CONSTRAINT IF EXISTS fees_pkey; +ALTER TABLE IF EXISTS ONLY public.events DROP CONSTRAINT IF EXISTS events_pkey; +ALTER TABLE IF EXISTS ONLY public.error_details DROP CONSTRAINT IF EXISTS error_details_pkey; +ALTER TABLE IF EXISTS ONLY public.entitlement_subscription_feature_removals DROP CONSTRAINT IF EXISTS entitlement_subscription_feature_removals_pkey; +ALTER TABLE IF EXISTS ONLY public.entitlement_privileges DROP CONSTRAINT IF EXISTS entitlement_privileges_pkey; +ALTER TABLE IF EXISTS ONLY public.entitlement_features DROP CONSTRAINT IF EXISTS entitlement_features_pkey; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlements DROP CONSTRAINT IF EXISTS entitlement_entitlements_pkey; +ALTER TABLE IF EXISTS ONLY public.entitlement_entitlement_values DROP CONSTRAINT IF EXISTS entitlement_entitlement_values_pkey; +ALTER TABLE IF EXISTS ONLY public.enriched_store_subscription_migrations DROP CONSTRAINT IF EXISTS enriched_store_subscription_migrations_pkey; +ALTER TABLE IF EXISTS ONLY public.enriched_store_migrations DROP CONSTRAINT IF EXISTS enriched_store_migrations_pkey; +ALTER TABLE IF EXISTS ONLY public.dunning_campaigns DROP CONSTRAINT IF EXISTS dunning_campaigns_pkey; +ALTER TABLE IF EXISTS ONLY public.dunning_campaign_thresholds DROP CONSTRAINT IF EXISTS dunning_campaign_thresholds_pkey; +ALTER TABLE IF EXISTS ONLY public.data_exports DROP CONSTRAINT IF EXISTS data_exports_pkey; +ALTER TABLE IF EXISTS ONLY public.data_export_parts DROP CONSTRAINT IF EXISTS data_export_parts_pkey; +ALTER TABLE IF EXISTS ONLY public.daily_usages DROP CONSTRAINT IF EXISTS daily_usages_pkey; +ALTER TABLE IF EXISTS ONLY public.customers_taxes DROP CONSTRAINT IF EXISTS customers_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.customers DROP CONSTRAINT IF EXISTS customers_pkey; +ALTER TABLE IF EXISTS ONLY public.customers_invoice_custom_sections DROP CONSTRAINT IF EXISTS customers_invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.customer_metadata DROP CONSTRAINT IF EXISTS customer_metadata_pkey; +ALTER TABLE IF EXISTS ONLY public.credits DROP CONSTRAINT IF EXISTS credits_pkey; +ALTER TABLE IF EXISTS ONLY public.credit_notes_taxes DROP CONSTRAINT IF EXISTS credit_notes_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.credit_notes DROP CONSTRAINT IF EXISTS credit_notes_pkey; +ALTER TABLE IF EXISTS ONLY public.credit_note_items DROP CONSTRAINT IF EXISTS credit_note_items_pkey; +ALTER TABLE IF EXISTS ONLY public.coupons DROP CONSTRAINT IF EXISTS coupons_pkey; +ALTER TABLE IF EXISTS ONLY public.coupon_targets DROP CONSTRAINT IF EXISTS coupon_targets_pkey; +ALTER TABLE IF EXISTS ONLY public.commitments_taxes DROP CONSTRAINT IF EXISTS commitments_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.commitments DROP CONSTRAINT IF EXISTS commitments_pkey; +ALTER TABLE IF EXISTS ONLY public.charges_taxes DROP CONSTRAINT IF EXISTS charges_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.charges DROP CONSTRAINT IF EXISTS charges_pkey; +ALTER TABLE IF EXISTS ONLY public.charge_filters DROP CONSTRAINT IF EXISTS charge_filters_pkey; +ALTER TABLE IF EXISTS ONLY public.charge_filter_values DROP CONSTRAINT IF EXISTS charge_filter_values_pkey; +ALTER TABLE IF EXISTS ONLY public.cached_aggregations DROP CONSTRAINT IF EXISTS cached_aggregations_pkey; +ALTER TABLE IF EXISTS ONLY public.billing_entities_taxes DROP CONSTRAINT IF EXISTS billing_entities_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.billing_entities DROP CONSTRAINT IF EXISTS billing_entities_pkey; +ALTER TABLE IF EXISTS ONLY public.billing_entities_invoice_custom_sections DROP CONSTRAINT IF EXISTS billing_entities_invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.billable_metrics DROP CONSTRAINT IF EXISTS billable_metrics_pkey; +ALTER TABLE IF EXISTS ONLY public.billable_metric_filters DROP CONSTRAINT IF EXISTS billable_metric_filters_pkey; +ALTER TABLE IF EXISTS ONLY public.ar_internal_metadata DROP CONSTRAINT IF EXISTS ar_internal_metadata_pkey; +ALTER TABLE IF EXISTS ONLY public.applied_usage_thresholds DROP CONSTRAINT IF EXISTS applied_usage_thresholds_pkey; +ALTER TABLE IF EXISTS ONLY public.applied_pricing_units DROP CONSTRAINT IF EXISTS applied_pricing_units_pkey; +ALTER TABLE IF EXISTS ONLY public.applied_invoice_custom_sections DROP CONSTRAINT IF EXISTS applied_invoice_custom_sections_pkey; +ALTER TABLE IF EXISTS ONLY public.applied_coupons DROP CONSTRAINT IF EXISTS applied_coupons_pkey; +ALTER TABLE IF EXISTS ONLY public.applied_add_ons DROP CONSTRAINT IF EXISTS applied_add_ons_pkey; +ALTER TABLE IF EXISTS ONLY public.api_keys DROP CONSTRAINT IF EXISTS api_keys_pkey; +ALTER TABLE IF EXISTS ONLY public.ai_conversations DROP CONSTRAINT IF EXISTS ai_conversations_pkey; +ALTER TABLE IF EXISTS ONLY public.adjusted_fees DROP CONSTRAINT IF EXISTS adjusted_fees_pkey; +ALTER TABLE IF EXISTS ONLY public.add_ons_taxes DROP CONSTRAINT IF EXISTS add_ons_taxes_pkey; +ALTER TABLE IF EXISTS ONLY public.add_ons DROP CONSTRAINT IF EXISTS add_ons_pkey; +ALTER TABLE IF EXISTS ONLY public.active_storage_variant_records DROP CONSTRAINT IF EXISTS active_storage_variant_records_pkey; +ALTER TABLE IF EXISTS ONLY public.active_storage_blobs DROP CONSTRAINT IF EXISTS active_storage_blobs_pkey; +ALTER TABLE IF EXISTS ONLY public.active_storage_attachments DROP CONSTRAINT IF EXISTS active_storage_attachments_pkey; +ALTER TABLE IF EXISTS public.versions ALTER COLUMN id DROP DEFAULT; +ALTER TABLE IF EXISTS public.usage_monitoring_subscription_activities ALTER COLUMN id DROP DEFAULT; +ALTER TABLE IF EXISTS public.quote_owners ALTER COLUMN id DROP DEFAULT; +DROP TABLE IF EXISTS public.webhooks; +DROP TABLE IF EXISTS public.webhook_endpoints; +DROP TABLE IF EXISTS public.wallets_invoice_custom_sections; +DROP TABLE IF EXISTS public.wallet_transactions_invoice_custom_sections; +DROP TABLE IF EXISTS public.wallet_targets; +DROP SEQUENCE IF EXISTS public.versions_id_seq; +DROP TABLE IF EXISTS public.versions; +DROP TABLE IF EXISTS public.users; +DROP TABLE IF EXISTS public.user_devices; +DROP SEQUENCE IF EXISTS public.usage_monitoring_subscription_activities_id_seq; +DROP TABLE IF EXISTS public.usage_monitoring_subscription_activities; +DROP TABLE IF EXISTS public.usage_monitoring_alerts; +DROP TABLE IF EXISTS public.subscriptions_invoice_custom_sections; +DROP TABLE IF EXISTS public.subscription_activation_rules; +DROP TABLE IF EXISTS public.schema_migrations; +DROP TABLE IF EXISTS public.roles; +DROP TABLE IF EXISTS public.refunds; +DROP TABLE IF EXISTS public.recurring_transaction_rules_invoice_custom_sections; +DROP TABLE IF EXISTS public.recurring_transaction_rules; +DROP TABLE IF EXISTS public.quotes; +DROP TABLE IF EXISTS public.quote_versions; +DROP SEQUENCE IF EXISTS public.quote_owners_id_seq; +DROP TABLE IF EXISTS public.quote_owners; +DROP TABLE IF EXISTS public.quantified_events; +DROP TABLE IF EXISTS public.pricing_units; +DROP TABLE IF EXISTS public.pricing_unit_usages; +DROP TABLE IF EXISTS public.presentation_breakdowns; +DROP TABLE IF EXISTS public.pending_vies_checks; +DROP TABLE IF EXISTS public.payment_receipts; +DROP TABLE IF EXISTS public.payment_providers; +DROP TABLE IF EXISTS public.payment_methods; +DROP TABLE IF EXISTS public.payment_intents; +DROP TABLE IF EXISTS public.password_resets; +DROP TABLE IF EXISTS public.memberships; +DROP TABLE IF EXISTS public.membership_roles; +DROP TABLE IF EXISTS public.lifetime_usages; +DROP MATERIALIZED VIEW IF EXISTS public.last_hour_events_mv; +DROP TABLE IF EXISTS public.invoice_custom_sections; +DROP TABLE IF EXISTS public.invites; +DROP TABLE IF EXISTS public.integrations; +DROP TABLE IF EXISTS public.integration_resources; +DROP TABLE IF EXISTS public.integration_mappings; +DROP TABLE IF EXISTS public.integration_items; +DROP TABLE IF EXISTS public.integration_collection_mappings; +DROP TABLE IF EXISTS public.inbound_webhooks; +DROP TABLE IF EXISTS public.idempotency_records; +DROP TABLE IF EXISTS public.groups; +DROP TABLE IF EXISTS public.group_properties; +DROP VIEW IF EXISTS public.flat_filters; +DROP TABLE IF EXISTS public.fixed_charges_taxes; +DROP TABLE IF EXISTS public.fixed_charges; +DROP TABLE IF EXISTS public.fixed_charge_events; +DROP VIEW IF EXISTS public.exports_wallets; +DROP TABLE IF EXISTS public.wallets; +DROP VIEW IF EXISTS public.exports_wallet_transactions; +DROP TABLE IF EXISTS public.wallet_transactions; +DROP VIEW IF EXISTS public.exports_wallet_transaction_consumptions; +DROP TABLE IF EXISTS public.wallet_transaction_consumptions; +DROP VIEW IF EXISTS public.exports_usage_thresholds; +DROP TABLE IF EXISTS public.usage_thresholds; +DROP VIEW IF EXISTS public.exports_usage_monitoring_triggered_alerts; +DROP TABLE IF EXISTS public.usage_monitoring_triggered_alerts; +DROP VIEW IF EXISTS public.exports_usage_monitoring_alert_thresholds; +DROP TABLE IF EXISTS public.usage_monitoring_alert_thresholds; +DROP VIEW IF EXISTS public.exports_taxes; +DROP TABLE IF EXISTS public.taxes; +DROP VIEW IF EXISTS public.exports_subscriptions; +DROP VIEW IF EXISTS public.exports_plans; +DROP TABLE IF EXISTS public.plans_taxes; +DROP VIEW IF EXISTS public.exports_payments; +DROP VIEW IF EXISTS public.exports_payment_requests; +DROP TABLE IF EXISTS public.payments; +DROP TABLE IF EXISTS public.payment_requests; +DROP TABLE IF EXISTS public.invoices_payment_requests; +DROP VIEW IF EXISTS public.exports_item_metadata; +DROP TABLE IF EXISTS public.item_metadata; +DROP VIEW IF EXISTS public.exports_invoices_taxes; +DROP TABLE IF EXISTS public.invoices_taxes; +DROP VIEW IF EXISTS public.exports_invoices; +DROP TABLE IF EXISTS public.invoices; +DROP TABLE IF EXISTS public.invoice_metadata; +DROP VIEW IF EXISTS public.exports_invoice_subscriptions; +DROP TABLE IF EXISTS public.invoice_subscriptions; +DROP VIEW IF EXISTS public.exports_invoice_settlements; +DROP TABLE IF EXISTS public.invoice_settlements; +DROP VIEW IF EXISTS public.exports_integration_customers; +DROP TABLE IF EXISTS public.integration_customers; +DROP VIEW IF EXISTS public.exports_fees_taxes; +DROP TABLE IF EXISTS public.fees_taxes; +DROP VIEW IF EXISTS public.exports_fees; +DROP TABLE IF EXISTS public.subscriptions; +DROP TABLE IF EXISTS public.plans; +DROP TABLE IF EXISTS public.fees; +DROP VIEW IF EXISTS public.exports_entitlement_features; +DROP VIEW IF EXISTS public.exports_entitlement_entitlements; +DROP VIEW IF EXISTS public.exports_entitlement_entitlement_values; +DROP VIEW IF EXISTS public.exports_daily_usages; +DROP VIEW IF EXISTS public.exports_customers; +DROP TABLE IF EXISTS public.payment_provider_customers; +DROP TABLE IF EXISTS public.organizations; +DROP VIEW IF EXISTS public.exports_credit_notes_taxes; +DROP VIEW IF EXISTS public.exports_credit_notes; +DROP VIEW IF EXISTS public.exports_coupons; +DROP VIEW IF EXISTS public.exports_charges; +DROP VIEW IF EXISTS public.exports_billing_entities; +DROP VIEW IF EXISTS public.exports_billable_metrics; +DROP VIEW IF EXISTS public.exports_applied_coupons; +DROP TABLE IF EXISTS public.events; +DROP TABLE IF EXISTS public.error_details; +DROP TABLE IF EXISTS public.entitlement_subscription_feature_removals; +DROP TABLE IF EXISTS public.entitlement_privileges; +DROP TABLE IF EXISTS public.entitlement_features; +DROP TABLE IF EXISTS public.entitlement_entitlements; +DROP TABLE IF EXISTS public.entitlement_entitlement_values; +DROP TABLE IF EXISTS public.enriched_store_subscription_migrations; +DROP TABLE IF EXISTS public.enriched_store_migrations; +DROP TABLE IF EXISTS public.enriched_events_default; +DROP TABLE IF EXISTS public.enriched_events; +DROP TABLE IF EXISTS public.dunning_campaigns; +DROP TABLE IF EXISTS public.dunning_campaign_thresholds; +DROP TABLE IF EXISTS public.data_exports; +DROP TABLE IF EXISTS public.data_export_parts; +DROP TABLE IF EXISTS public.daily_usages; +DROP TABLE IF EXISTS public.customers_taxes; +DROP TABLE IF EXISTS public.customers_invoice_custom_sections; +DROP TABLE IF EXISTS public.customers; +DROP TABLE IF EXISTS public.customer_metadata; +DROP TABLE IF EXISTS public.credits; +DROP TABLE IF EXISTS public.credit_notes_taxes; +DROP TABLE IF EXISTS public.credit_notes; +DROP TABLE IF EXISTS public.credit_note_items; +DROP TABLE IF EXISTS public.coupons; +DROP TABLE IF EXISTS public.coupon_targets; +DROP TABLE IF EXISTS public.commitments_taxes; +DROP TABLE IF EXISTS public.commitments; +DROP TABLE IF EXISTS public.charges_taxes; +DROP TABLE IF EXISTS public.charges; +DROP TABLE IF EXISTS public.charge_filters; +DROP TABLE IF EXISTS public.charge_filter_values; +DROP TABLE IF EXISTS public.cached_aggregations; +DROP TABLE IF EXISTS public.billing_entities_taxes; +DROP TABLE IF EXISTS public.billing_entities_invoice_custom_sections; +DROP TABLE IF EXISTS public.billing_entities; +DROP VIEW IF EXISTS public.billable_metrics_grouped_charges; +DROP TABLE IF EXISTS public.billable_metrics; +DROP TABLE IF EXISTS public.billable_metric_filters; +DROP TABLE IF EXISTS public.ar_internal_metadata; +DROP TABLE IF EXISTS public.applied_usage_thresholds; +DROP TABLE IF EXISTS public.applied_pricing_units; +DROP TABLE IF EXISTS public.applied_invoice_custom_sections; +DROP TABLE IF EXISTS public.applied_coupons; +DROP TABLE IF EXISTS public.applied_add_ons; +DROP TABLE IF EXISTS public.api_keys; +DROP TABLE IF EXISTS public.ai_conversations; +DROP TABLE IF EXISTS public.adjusted_fees; +DROP TABLE IF EXISTS public.add_ons_taxes; +DROP TABLE IF EXISTS public.add_ons; +DROP TABLE IF EXISTS public.active_storage_variant_records; +DROP TABLE IF EXISTS public.active_storage_blobs; +DROP TABLE IF EXISTS public.active_storage_attachments; +DROP TABLE IF EXISTS partman.template_public_enriched_events; +DROP FUNCTION IF EXISTS public.set_payment_receipt_number(); +DROP FUNCTION IF EXISTS public.ensure_role_consistency(); +DROP TYPE IF EXISTS public.usage_monitoring_alert_types; +DROP TYPE IF EXISTS public.usage_monitoring_alert_direction; +DROP TYPE IF EXISTS public.tax_status; +DROP TYPE IF EXISTS public.subscription_on_termination_invoice; +DROP TYPE IF EXISTS public.subscription_on_termination_credit_note; +DROP TYPE IF EXISTS public.subscription_invoicing_reason; +DROP TYPE IF EXISTS public.subscription_invoice_issuing_date_anchors; +DROP TYPE IF EXISTS public.subscription_invoice_issuing_date_adjustments; +DROP TYPE IF EXISTS public.subscription_cancelation_reasons; +DROP TYPE IF EXISTS public.subscription_activation_rule_types; +DROP TYPE IF EXISTS public.subscription_activation_rule_statuses; +DROP TYPE IF EXISTS public.quote_void_reason; +DROP TYPE IF EXISTS public.quote_status; +DROP TYPE IF EXISTS public.quote_order_type; +DROP TYPE IF EXISTS public.payment_type; +DROP TYPE IF EXISTS public.payment_payable_payment_status; +DROP TYPE IF EXISTS public.payment_method_types; +DROP TYPE IF EXISTS public.invoice_settlement_settlement_type; +DROP TYPE IF EXISTS public.invoice_custom_section_type; +DROP TYPE IF EXISTS public.inbound_webhook_status; +DROP TYPE IF EXISTS public.fixed_charge_charge_model; +DROP TYPE IF EXISTS public.entity_document_numbering; +DROP TYPE IF EXISTS public.entitlement_privilege_value_types; +DROP TYPE IF EXISTS public.enriched_store_sub_migration_status; +DROP TYPE IF EXISTS public.enriched_store_migration_status; +DROP TYPE IF EXISTS public.customer_type; +DROP TYPE IF EXISTS public.customer_account_type; +DROP TYPE IF EXISTS public.billable_metric_weighted_interval; +DROP TYPE IF EXISTS public.billable_metric_rounding_function; +DROP EXTENSION IF EXISTS unaccent; +DROP EXTENSION IF EXISTS pgcrypto; +DROP EXTENSION IF EXISTS pg_partman; +DROP SCHEMA IF EXISTS partman; +-- +-- Name: partman; Type: SCHEMA; Schema: -; Owner: - +-- + +CREATE SCHEMA partman; + + +-- +-- Name: pg_partman; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pg_partman WITH SCHEMA partman; + + +-- +-- Name: pgcrypto; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS pgcrypto WITH SCHEMA public; + + +-- +-- Name: unaccent; Type: EXTENSION; Schema: -; Owner: - +-- + +CREATE EXTENSION IF NOT EXISTS unaccent WITH SCHEMA public; + + +-- +-- Name: billable_metric_rounding_function; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.billable_metric_rounding_function AS ENUM ( + 'round', + 'floor', + 'ceil' +); + + +-- +-- Name: billable_metric_weighted_interval; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.billable_metric_weighted_interval AS ENUM ( + 'seconds' +); + + +-- +-- Name: customer_account_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.customer_account_type AS ENUM ( + 'customer', + 'partner' +); + + +-- +-- Name: customer_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.customer_type AS ENUM ( + 'company', + 'individual' +); + + +-- +-- Name: enriched_store_migration_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.enriched_store_migration_status AS ENUM ( + 'pending', + 'checking', + 'processing', + 'enabling', + 'completed', + 'failed' +); + + +-- +-- Name: enriched_store_sub_migration_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.enriched_store_sub_migration_status AS ENUM ( + 'pending', + 'comparing', + 'reprocessing', + 'waiting_for_enrichment', + 'deduplicating', + 'dedup_paused', + 'validating', + 'completed', + 'failed' +); + + +-- +-- Name: entitlement_privilege_value_types; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.entitlement_privilege_value_types AS ENUM ( + 'integer', + 'string', + 'boolean', + 'select' +); + + +-- +-- Name: entity_document_numbering; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.entity_document_numbering AS ENUM ( + 'per_customer', + 'per_billing_entity' +); + + +-- +-- Name: fixed_charge_charge_model; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.fixed_charge_charge_model AS ENUM ( + 'standard', + 'graduated', + 'volume' +); + + +-- +-- Name: inbound_webhook_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.inbound_webhook_status AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed' +); + + +-- +-- Name: invoice_custom_section_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.invoice_custom_section_type AS ENUM ( + 'manual', + 'system_generated' +); + + +-- +-- Name: invoice_settlement_settlement_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.invoice_settlement_settlement_type AS ENUM ( + 'payment', + 'credit_note' +); + + +-- +-- Name: payment_method_types; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.payment_method_types AS ENUM ( + 'provider', + 'manual' +); + + +-- +-- Name: payment_payable_payment_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.payment_payable_payment_status AS ENUM ( + 'pending', + 'processing', + 'succeeded', + 'failed' +); + + +-- +-- Name: payment_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.payment_type AS ENUM ( + 'provider', + 'manual' +); + + +-- +-- Name: quote_order_type; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.quote_order_type AS ENUM ( + 'subscription_creation', + 'subscription_amendment', + 'one_off' +); + + +-- +-- Name: quote_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.quote_status AS ENUM ( + 'draft', + 'approved', + 'voided' +); + + +-- +-- Name: quote_void_reason; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.quote_void_reason AS ENUM ( + 'manual', + 'superseded', + 'cascade_of_expired', + 'cascade_of_voided' +); + + +-- +-- Name: subscription_activation_rule_statuses; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_activation_rule_statuses AS ENUM ( + 'inactive', + 'pending', + 'satisfied', + 'declined', + 'failed', + 'expired', + 'not_applicable' +); + + +-- +-- Name: subscription_activation_rule_types; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_activation_rule_types AS ENUM ( + 'payment' +); + + +-- +-- Name: subscription_cancelation_reasons; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_cancelation_reasons AS ENUM ( + 'payment_failed', + 'timeout' +); + + +-- +-- Name: subscription_invoice_issuing_date_adjustments; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_invoice_issuing_date_adjustments AS ENUM ( + 'keep_anchor', + 'align_with_finalization_date' +); + + +-- +-- Name: subscription_invoice_issuing_date_anchors; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_invoice_issuing_date_anchors AS ENUM ( + 'current_period_end', + 'next_period_start' +); + + +-- +-- Name: subscription_invoicing_reason; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_invoicing_reason AS ENUM ( + 'subscription_starting', + 'subscription_periodic', + 'subscription_terminating', + 'in_advance_charge', + 'in_advance_charge_periodic', + 'progressive_billing' +); + + +-- +-- Name: subscription_on_termination_credit_note; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_on_termination_credit_note AS ENUM ( + 'credit', + 'skip', + 'refund', + 'offset' +); + + +-- +-- Name: subscription_on_termination_invoice; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.subscription_on_termination_invoice AS ENUM ( + 'generate', + 'skip' +); + + +-- +-- Name: tax_status; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.tax_status AS ENUM ( + 'pending', + 'succeeded', + 'failed' +); + + +-- +-- Name: usage_monitoring_alert_direction; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.usage_monitoring_alert_direction AS ENUM ( + 'increasing', + 'decreasing' +); + + +-- +-- Name: usage_monitoring_alert_types; Type: TYPE; Schema: public; Owner: - +-- + +CREATE TYPE public.usage_monitoring_alert_types AS ENUM ( + 'current_usage_amount', + 'billable_metric_current_usage_amount', + 'billable_metric_current_usage_units', + 'lifetime_usage_amount', + 'wallet_balance_amount', + 'wallet_credits_balance', + 'wallet_ongoing_balance_amount', + 'wallet_credits_ongoing_balance', + 'billable_metric_lifetime_usage_units' +); + + +-- +-- Name: ensure_role_consistency(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.ensure_role_consistency() RETURNS trigger + LANGUAGE plpgsql + AS $$ BEGIN IF OLD.organization_id IS NULL THEN RAISE EXCEPTION 'Predefined role cannot be modified'; ELSIF OLD.organization_id IS DISTINCT FROM NEW.organization_id THEN RAISE EXCEPTION 'Custom role cannot be moved to another organization'; ELSIF OLD.code IS DISTINCT FROM NEW.code THEN RAISE EXCEPTION 'The code of the role cannot be changed'; ELSIF NEW.permissions != OLD.permissions THEN NEW.permissions := ARRAY(SELECT DISTINCT unnest(NEW.permissions) ORDER BY 1); END IF; RETURN NEW; END; $$; + + +-- +-- Name: set_payment_receipt_number(); Type: FUNCTION; Schema: public; Owner: - +-- + +CREATE FUNCTION public.set_payment_receipt_number() RETURNS trigger + LANGUAGE plpgsql + AS $$ + DECLARE + cust_id uuid; + next_payment_receipt integer; + document_number_prefix character varying; + BEGIN + IF NEW.number IS NULL THEN + SELECT i.customer_id INTO cust_id + FROM invoices i + INNER JOIN payments p ON (p.payable_id = i.id AND p.payable_type = 'Invoice') + WHERE p.id = NEW.payment_id; + + IF cust_id IS NULL THEN + SELECT pr.customer_id INTO cust_id + FROM payment_requests pr + LEFT JOIN payments p ON (p.payable_id = pr.id AND p.payable_type = 'PaymentRequest') + WHERE p.id = NEW.payment_id; + END IF; + + SELECT c.slug INTO document_number_prefix + FROM customers c + WHERE c.id = cust_id; + + -- Atomically increment the customer's payment receipt counter and get the new value + UPDATE customers + SET payment_receipt_counter = payment_receipt_counter + 1 + WHERE id = cust_id + RETURNING payment_receipt_counter INTO next_payment_receipt; + + -- Construct the payment receipt number using the customer id and the new counter value + NEW.number := document_number_prefix || '-RCPT-' || LPAD(next_payment_receipt::text, GREATEST(6, LENGTH(next_payment_receipt::text)), '0'); + END IF; + RETURN NEW; + END; + $$; + + +SET default_tablespace = ''; + +SET default_table_access_method = heap; + +-- +-- Name: template_public_enriched_events; Type: TABLE; Schema: partman; Owner: - +-- + +CREATE TABLE partman.template_public_enriched_events ( + id uuid NOT NULL, + organization_id uuid NOT NULL, + event_id uuid NOT NULL, + transaction_id character varying NOT NULL, + external_subscription_id character varying NOT NULL, + code character varying NOT NULL, + "timestamp" timestamp(6) without time zone NOT NULL, + subscription_id uuid NOT NULL, + plan_id uuid NOT NULL, + charge_id uuid NOT NULL, + charge_filter_id uuid, + properties jsonb NOT NULL, + grouped_by jsonb NOT NULL, + value character varying, + decimal_value numeric(40,15) NOT NULL, + enriched_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: active_storage_attachments; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.active_storage_attachments ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + record_type character varying NOT NULL, + blob_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + record_id uuid +); + + +-- +-- Name: active_storage_blobs; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.active_storage_blobs ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + key character varying NOT NULL, + filename character varying NOT NULL, + content_type character varying, + metadata text, + service_name character varying NOT NULL, + byte_size bigint NOT NULL, + checksum character varying, + created_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: active_storage_variant_records; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.active_storage_variant_records ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + blob_id uuid NOT NULL, + variation_digest character varying NOT NULL +); + + +-- +-- Name: add_ons; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.add_ons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description character varying, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + invoice_display_name character varying +); + + +-- +-- Name: add_ons_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.add_ons_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + add_on_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: adjusted_fees; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.adjusted_fees ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + fee_id uuid, + invoice_id uuid NOT NULL, + subscription_id uuid, + charge_id uuid, + invoice_display_name character varying, + fee_type integer, + adjusted_units boolean DEFAULT false NOT NULL, + adjusted_amount boolean DEFAULT false NOT NULL, + units numeric DEFAULT 0.0 NOT NULL, + unit_amount_cents bigint DEFAULT 0 NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + group_id uuid, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid, + unit_precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL, + organization_id uuid NOT NULL, + fixed_charge_id uuid +); + + +-- +-- Name: ai_conversations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.ai_conversations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + membership_id uuid NOT NULL, + name character varying NOT NULL, + mistral_conversation_id character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: api_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_keys ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + value character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + expires_at timestamp(6) without time zone, + last_used_at timestamp(6) without time zone, + name character varying, + permissions jsonb NOT NULL +); + + +-- +-- Name: applied_add_ons; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.applied_add_ons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + add_on_id uuid NOT NULL, + customer_id uuid NOT NULL, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: applied_coupons; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.applied_coupons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + coupon_id uuid NOT NULL, + customer_id uuid NOT NULL, + status integer DEFAULT 0 NOT NULL, + amount_cents bigint, + amount_currency character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + terminated_at timestamp without time zone, + percentage_rate numeric(10,5), + frequency integer DEFAULT 0 NOT NULL, + frequency_duration integer, + frequency_duration_remaining integer, + organization_id uuid NOT NULL +); + + +-- +-- Name: applied_invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.applied_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + display_name character varying, + details character varying, + invoice_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: applied_pricing_units; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.applied_pricing_units ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + pricing_unit_id uuid NOT NULL, + organization_id uuid NOT NULL, + pricing_unitable_type character varying NOT NULL, + pricing_unitable_id uuid NOT NULL, + conversion_rate numeric(40,15) DEFAULT 0.0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: applied_usage_thresholds; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.applied_usage_thresholds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + usage_threshold_id uuid NOT NULL, + invoice_id uuid NOT NULL, + lifetime_usage_amount_cents bigint DEFAULT 0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: ar_internal_metadata; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.ar_internal_metadata ( + key character varying NOT NULL, + value character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: billable_metric_filters; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.billable_metric_filters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + billable_metric_id uuid NOT NULL, + key character varying NOT NULL, + "values" character varying[] DEFAULT '{}'::character varying[] NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + organization_id uuid NOT NULL +); + + +-- +-- Name: billable_metrics; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.billable_metrics ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description character varying, + properties jsonb DEFAULT '{}'::jsonb, + aggregation_type integer NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + field_name character varying, + deleted_at timestamp(6) without time zone, + recurring boolean DEFAULT false NOT NULL, + weighted_interval public.billable_metric_weighted_interval, + custom_aggregator text, + expression character varying, + rounding_function public.billable_metric_rounding_function, + rounding_precision integer +); + + +-- +-- Name: billable_metrics_grouped_charges; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.billable_metrics_grouped_charges AS +SELECT + NULL::uuid AS organization_id, + NULL::character varying AS code, + NULL::integer AS aggregation_type, + NULL::character varying AS field_name, + NULL::uuid AS plan_id, + NULL::uuid AS charge_id, + NULL::boolean AS pay_in_advance, + NULL::jsonb AS grouped_by, + NULL::uuid AS charge_filter_id, + NULL::json AS filters, + NULL::jsonb AS filters_grouped_by; + + +-- +-- Name: billing_entities; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.billing_entities ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + address_line1 character varying, + address_line2 character varying, + city character varying, + country character varying, + zipcode character varying, + state character varying, + timezone character varying DEFAULT 'UTC'::character varying NOT NULL, + default_currency character varying DEFAULT 'USD'::character varying NOT NULL, + document_locale character varying DEFAULT 'en'::character varying NOT NULL, + document_number_prefix character varying, + document_numbering public.entity_document_numbering DEFAULT 'per_customer'::public.entity_document_numbering NOT NULL, + finalize_zero_amount_invoice boolean DEFAULT true NOT NULL, + invoice_footer text, + invoice_grace_period integer DEFAULT 0 NOT NULL, + net_payment_term integer DEFAULT 0 NOT NULL, + email character varying, + email_settings character varying[] DEFAULT '{}'::character varying[] NOT NULL, + eu_tax_management boolean DEFAULT false, + legal_name character varying, + legal_number character varying, + logo character varying, + name character varying NOT NULL, + code character varying NOT NULL, + tax_identification_number character varying, + vat_rate double precision DEFAULT 0.0 NOT NULL, + archived_at timestamp(6) without time zone, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + applied_dunning_campaign_id uuid, + einvoicing boolean DEFAULT false NOT NULL, + subscription_invoice_issuing_date_anchor public.subscription_invoice_issuing_date_anchors DEFAULT 'next_period_start'::public.subscription_invoice_issuing_date_anchors NOT NULL, + subscription_invoice_issuing_date_adjustment public.subscription_invoice_issuing_date_adjustments DEFAULT 'align_with_finalization_date'::public.subscription_invoice_issuing_date_adjustments NOT NULL +); + + +-- +-- Name: billing_entities_invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.billing_entities_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + billing_entity_id uuid NOT NULL, + invoice_custom_section_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: billing_entities_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.billing_entities_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + billing_entity_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: cached_aggregations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.cached_aggregations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + "timestamp" timestamp(6) without time zone NOT NULL, + external_subscription_id character varying NOT NULL, + charge_id uuid NOT NULL, + group_id uuid, + current_aggregation numeric, + max_aggregation numeric, + max_aggregation_with_proration numeric, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid, + current_amount numeric, + event_transaction_id character varying +); + + +-- +-- Name: charge_filter_values; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.charge_filter_values ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_filter_id uuid NOT NULL, + billable_metric_filter_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + "values" character varying[] DEFAULT '{}'::character varying[] NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: charge_filters; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.charge_filters ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_id uuid NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + invoice_display_name character varying, + organization_id uuid NOT NULL +); + + +-- +-- Name: charges; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.charges ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + billable_metric_id uuid, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + plan_id uuid, + amount_currency character varying, + charge_model integer DEFAULT 0 NOT NULL, + properties jsonb DEFAULT '"{}"'::jsonb NOT NULL, + deleted_at timestamp(6) without time zone, + pay_in_advance boolean DEFAULT false NOT NULL, + min_amount_cents bigint DEFAULT 0 NOT NULL, + invoiceable boolean DEFAULT true NOT NULL, + prorated boolean DEFAULT false NOT NULL, + invoice_display_name character varying, + regroup_paid_fees integer, + parent_id uuid, + organization_id uuid NOT NULL, + code character varying NOT NULL, + accepts_target_wallet boolean DEFAULT false NOT NULL +); + + +-- +-- Name: charges_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.charges_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: commitments; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.commitments ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + plan_id uuid NOT NULL, + commitment_type integer NOT NULL, + amount_cents bigint NOT NULL, + invoice_display_name character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: commitments_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.commitments_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + commitment_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: coupon_targets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.coupon_targets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + coupon_id uuid NOT NULL, + plan_id uuid, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + billable_metric_id uuid, + organization_id uuid NOT NULL +); + + +-- +-- Name: coupons; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.coupons ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying, + status integer DEFAULT 0 NOT NULL, + terminated_at timestamp(6) without time zone, + amount_cents bigint, + amount_currency character varying, + expiration integer NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + coupon_type integer DEFAULT 0 NOT NULL, + percentage_rate numeric(10,5), + frequency integer DEFAULT 0 NOT NULL, + frequency_duration integer, + expiration_at timestamp(6) without time zone, + reusable boolean DEFAULT true NOT NULL, + limited_plans boolean DEFAULT false NOT NULL, + deleted_at timestamp(6) without time zone, + limited_billable_metrics boolean DEFAULT false NOT NULL, + description text +); + + +-- +-- Name: credit_note_items; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.credit_note_items ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + credit_note_id uuid NOT NULL, + fee_id uuid, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + precise_amount_cents numeric(30,5) NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: credit_notes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.credit_notes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + invoice_id uuid NOT NULL, + sequential_id integer NOT NULL, + number character varying NOT NULL, + credit_amount_cents bigint DEFAULT 0 NOT NULL, + credit_amount_currency character varying NOT NULL, + credit_status integer, + balance_amount_cents bigint DEFAULT 0 NOT NULL, + balance_amount_currency character varying DEFAULT '0'::character varying NOT NULL, + reason integer NOT NULL, + file character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + total_amount_cents bigint DEFAULT 0 NOT NULL, + total_amount_currency character varying NOT NULL, + refund_amount_cents bigint DEFAULT 0 NOT NULL, + refund_amount_currency character varying, + refund_status integer, + voided_at timestamp(6) without time zone, + description text, + taxes_amount_cents bigint DEFAULT 0 NOT NULL, + refunded_at timestamp(6) without time zone, + issuing_date date NOT NULL, + status integer DEFAULT 1 NOT NULL, + coupons_adjustment_amount_cents bigint DEFAULT 0 NOT NULL, + precise_coupons_adjustment_amount_cents numeric(30,5) DEFAULT 0.0 NOT NULL, + precise_taxes_amount_cents numeric(30,5) DEFAULT 0.0 NOT NULL, + taxes_rate double precision DEFAULT 0.0 NOT NULL, + organization_id uuid NOT NULL, + xml_file character varying, + offset_amount_cents bigint DEFAULT 0 NOT NULL, + offset_amount_currency character varying +); + + +-- +-- Name: credit_notes_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.credit_notes_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + credit_note_id uuid NOT NULL, + tax_id uuid, + tax_description character varying, + tax_code character varying NOT NULL, + tax_name character varying NOT NULL, + tax_rate double precision DEFAULT 0.0 NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + base_amount_cents bigint DEFAULT 0 NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: credits; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.credits ( + invoice_id uuid, + applied_coupon_id uuid, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + credit_note_id uuid, + before_taxes boolean DEFAULT false NOT NULL, + id uuid DEFAULT gen_random_uuid() NOT NULL, + progressive_billing_invoice_id uuid, + organization_id uuid NOT NULL +); + + +-- +-- Name: customer_metadata; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.customer_metadata ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + key character varying NOT NULL, + value character varying NOT NULL, + display_in_invoice boolean DEFAULT false NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: customers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + external_id character varying NOT NULL, + name character varying, + organization_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + country character varying, + address_line1 character varying, + address_line2 character varying, + state character varying, + zipcode character varying, + email character varying, + city character varying, + url character varying, + phone character varying, + logo_url character varying, + legal_name character varying, + legal_number character varying, + vat_rate double precision, + payment_provider character varying, + slug character varying, + sequential_id bigint, + currency character varying, + invoice_grace_period integer, + timezone character varying, + deleted_at timestamp(6) without time zone, + document_locale character varying, + tax_identification_number character varying, + net_payment_term integer, + external_salesforce_id character varying, + payment_provider_code character varying, + shipping_address_line1 character varying, + shipping_address_line2 character varying, + shipping_city character varying, + shipping_zipcode character varying, + shipping_state character varying, + shipping_country character varying, + finalize_zero_amount_invoice integer DEFAULT 0 NOT NULL, + firstname character varying, + lastname character varying, + customer_type public.customer_type, + applied_dunning_campaign_id uuid, + exclude_from_dunning_campaign boolean DEFAULT false NOT NULL, + last_dunning_campaign_attempt integer DEFAULT 0 NOT NULL, + last_dunning_campaign_attempt_at timestamp without time zone, + skip_invoice_custom_sections boolean DEFAULT false NOT NULL, + account_type public.customer_account_type DEFAULT 'customer'::public.customer_account_type NOT NULL, + billing_entity_id uuid NOT NULL, + payment_receipt_counter bigint DEFAULT 0 NOT NULL, + subscription_invoice_issuing_date_anchor public.subscription_invoice_issuing_date_anchors, + subscription_invoice_issuing_date_adjustment public.subscription_invoice_issuing_date_adjustments, + awaiting_wallet_refresh boolean DEFAULT false NOT NULL, + CONSTRAINT check_customers_on_invoice_grace_period CHECK ((invoice_grace_period >= 0)), + CONSTRAINT check_customers_on_net_payment_term CHECK ((net_payment_term >= 0)) +); + + +-- +-- Name: customers_invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.customers_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + billing_entity_id uuid NOT NULL, + customer_id uuid NOT NULL, + invoice_custom_section_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: customers_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.customers_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: daily_usages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.daily_usages ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid NOT NULL, + subscription_id uuid NOT NULL, + external_subscription_id character varying NOT NULL, + from_datetime timestamp(6) without time zone NOT NULL, + to_datetime timestamp(6) without time zone NOT NULL, + usage jsonb DEFAULT '"{}"'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + refreshed_at timestamp(6) without time zone NOT NULL, + usage_diff jsonb DEFAULT '"{}"'::jsonb NOT NULL, + usage_date date +); + + +-- +-- Name: data_export_parts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.data_export_parts ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + index integer, + data_export_id uuid NOT NULL, + object_ids uuid[] NOT NULL, + completed boolean DEFAULT false NOT NULL, + csv_lines text, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: data_exports; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.data_exports ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + format integer, + resource_type character varying NOT NULL, + resource_query jsonb DEFAULT '{}'::jsonb, + status integer DEFAULT 0 NOT NULL, + expires_at timestamp without time zone, + started_at timestamp without time zone, + completed_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + membership_id uuid, + organization_id uuid NOT NULL +); + + +-- +-- Name: dunning_campaign_thresholds; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dunning_campaign_thresholds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + dunning_campaign_id uuid NOT NULL, + currency character varying NOT NULL, + amount_cents bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp without time zone, + organization_id uuid NOT NULL +); + + +-- +-- Name: dunning_campaigns; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.dunning_campaigns ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description text, + applied_to_organization boolean DEFAULT false NOT NULL, + days_between_attempts integer DEFAULT 1 NOT NULL, + max_attempts integer DEFAULT 1 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp without time zone, + bcc_emails character varying[] DEFAULT '{}'::character varying[] +); + + +-- +-- Name: enriched_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.enriched_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + event_id uuid NOT NULL, + transaction_id character varying NOT NULL, + external_subscription_id character varying NOT NULL, + code character varying NOT NULL, + "timestamp" timestamp(6) without time zone NOT NULL, + subscription_id uuid NOT NULL, + plan_id uuid NOT NULL, + charge_id uuid NOT NULL, + charge_filter_id uuid, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + value character varying, + decimal_value numeric(40,15) DEFAULT 0.0 NOT NULL, + enriched_at timestamp(6) without time zone NOT NULL, + operation_type character varying, + precise_total_amount_cents numeric(40,15), + target_wallet_code character varying +) +PARTITION BY RANGE ("timestamp"); + + +-- +-- Name: enriched_events_default; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.enriched_events_default ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + event_id uuid NOT NULL, + transaction_id character varying NOT NULL, + external_subscription_id character varying NOT NULL, + code character varying NOT NULL, + "timestamp" timestamp(6) without time zone NOT NULL, + subscription_id uuid NOT NULL, + plan_id uuid NOT NULL, + charge_id uuid NOT NULL, + charge_filter_id uuid, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + value character varying, + decimal_value numeric(40,15) DEFAULT 0.0 NOT NULL, + enriched_at timestamp(6) without time zone NOT NULL, + operation_type character varying, + precise_total_amount_cents numeric(40,15), + target_wallet_code character varying +); + + +-- +-- Name: enriched_store_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.enriched_store_migrations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + status public.enriched_store_migration_status DEFAULT 'pending'::public.enriched_store_migration_status NOT NULL, + started_at timestamp(6) without time zone, + completed_at timestamp(6) without time zone, + error_message text, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: enriched_store_subscription_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.enriched_store_subscription_migrations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + enriched_store_migration_id uuid NOT NULL, + subscription_id uuid NOT NULL, + organization_id uuid NOT NULL, + status public.enriched_store_sub_migration_status DEFAULT 'pending'::public.enriched_store_sub_migration_status NOT NULL, + billable_metric_codes jsonb DEFAULT '[]'::jsonb, + events_reprocessed_count integer DEFAULT 0, + duplicates_removed_count integer DEFAULT 0, + dedup_pending_queries jsonb DEFAULT '[]'::jsonb, + comparison_results jsonb DEFAULT '{}'::jsonb, + error_message text, + started_at timestamp(6) without time zone, + completed_at timestamp(6) without time zone, + attempts integer DEFAULT 0, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: entitlement_entitlement_values; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.entitlement_entitlement_values ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + entitlement_privilege_id uuid NOT NULL, + entitlement_entitlement_id uuid NOT NULL, + value character varying NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: entitlement_entitlements; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.entitlement_entitlements ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + entitlement_feature_id uuid NOT NULL, + plan_id uuid, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + subscription_id uuid, + CONSTRAINT entitlement_check_exactly_one_parent CHECK (((plan_id IS NOT NULL) <> (subscription_id IS NOT NULL))) +); + + +-- +-- Name: entitlement_features; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.entitlement_features ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + code character varying NOT NULL, + name character varying, + description character varying, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: entitlement_privileges; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.entitlement_privileges ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + entitlement_feature_id uuid NOT NULL, + code character varying NOT NULL, + name character varying, + value_type public.entitlement_privilege_value_types DEFAULT 'string'::public.entitlement_privilege_value_types NOT NULL, + config jsonb DEFAULT '{}'::jsonb NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: entitlement_subscription_feature_removals; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.entitlement_subscription_feature_removals ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + entitlement_feature_id uuid, + subscription_id uuid NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + entitlement_privilege_id uuid, + CONSTRAINT check_exactly_one_feature_or_privilege_removal CHECK (((entitlement_feature_id IS NOT NULL) <> (entitlement_privilege_id IS NOT NULL))) +); + + +-- +-- Name: error_details; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.error_details ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + owner_type character varying NOT NULL, + owner_id uuid NOT NULL, + organization_id uuid NOT NULL, + details jsonb DEFAULT '{}'::jsonb NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + error_code integer DEFAULT 0 NOT NULL +); + + +-- +-- Name: events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid, + transaction_id character varying NOT NULL, + code character varying NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + "timestamp" timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + subscription_id uuid, + deleted_at timestamp(6) without time zone, + external_customer_id character varying, + external_subscription_id character varying, + precise_total_amount_cents numeric(40,15) +) +WITH (autovacuum_vacuum_scale_factor='0.005'); + + +-- +-- Name: exports_applied_coupons; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_applied_coupons AS + SELECT ac.organization_id, + ac.id AS lago_id, + ac.coupon_id AS lago_coupon_id, + ac.customer_id AS lago_customer_id, + CASE ac.status + WHEN 0 THEN 'active'::text + WHEN 1 THEN 'terminated'::text + ELSE NULL::text + END AS status, + ac.amount_cents, + CASE ac.frequency + WHEN 0 THEN NULL::bigint + WHEN 1 THEN NULL::bigint + ELSE + CASE + WHEN (cp.coupon_type = 1) THEN NULL::bigint + ELSE (ac.amount_cents - ( SELECT (sum(cr.amount_cents))::bigint AS sum + FROM public.credits cr + WHERE (cr.applied_coupon_id = ac.id))) + END + END AS amount_cents_remaining, + ac.amount_currency, + ac.percentage_rate, + CASE ac.frequency + WHEN 0 THEN 'once'::text + WHEN 1 THEN 'recurring'::text + WHEN 2 THEN 'forever'::text + ELSE NULL::text + END AS frequency, + ac.frequency_duration, + ac.frequency_duration_remaining, + ac.created_at, + ac.terminated_at, + ac.updated_at, + ( SELECT json_agg(json_build_object('lago_id', cr.id, 'amount_cents', cr.amount_cents, 'amount_currency', cr.amount_currency, 'before_taxes', cr.before_taxes)) AS json_agg + FROM public.credits cr + WHERE (cr.applied_coupon_id = ac.id)) AS credits + FROM (public.applied_coupons ac + LEFT JOIN public.coupons cp ON ((cp.id = ac.coupon_id))); + + +-- +-- Name: exports_billable_metrics; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_billable_metrics AS + SELECT bm.organization_id, + bm.id AS lago_id, + bm.name, + bm.code, + bm.description, + CASE bm.aggregation_type + WHEN 0 THEN 'count_agg'::text + WHEN 1 THEN 'sum_agg'::text + WHEN 2 THEN 'max_agg'::text + WHEN 3 THEN 'unique_count_agg'::text + WHEN 5 THEN 'weighted_sum_agg'::text + WHEN 6 THEN 'latest_agg'::text + WHEN 7 THEN 'custom_agg'::text + ELSE 'unknown'::text + END AS aggregation_type, + (bm.weighted_interval)::text AS weighted_interval, + bm.recurring, + (bm.rounding_function)::text AS rounding_function, + bm.rounding_precision, + bm.created_at, + bm.updated_at, + bm.field_name, + bm.expression, + COALESCE(( SELECT json_agg(json_build_object('key', bmf.key, 'values', bmf."values")) AS json_agg + FROM public.billable_metric_filters bmf + WHERE ((bmf.billable_metric_id = bm.id) AND (bmf.deleted_at IS NULL))), '[]'::json) AS filters + FROM public.billable_metrics bm + WHERE (bm.deleted_at IS NULL); + + +-- +-- Name: exports_billing_entities; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_billing_entities AS + SELECT be.organization_id, + be.id AS lago_id, + be.code, + be.name, + be.legal_name, + be.legal_number, + be.email, + be.address_line1, + be.address_line2, + be.city, + be.zipcode, + be.state, + be.country, + be.vat_rate, + be.timezone, + be.created_at, + be.updated_at, + be.archived_at, + be.deleted_at + FROM public.billing_entities be; + + +-- +-- Name: exports_charges; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_charges AS + SELECT c.organization_id, + c.id AS lago_id, + c.billable_metric_id AS lago_billable_metric_id, + c.plan_id AS lago_plan_id, + c.invoice_display_name, + c.created_at, + c.updated_at, + c.deleted_at, + CASE c.charge_model + WHEN 0 THEN 'standard'::text + WHEN 1 THEN 'graduated'::text + WHEN 2 THEN 'package'::text + WHEN 3 THEN 'percentage'::text + WHEN 4 THEN 'volume'::text + WHEN 5 THEN 'graduated_percentage'::text + WHEN 6 THEN 'custom'::text + WHEN 7 THEN 'dynamic'::text + ELSE NULL::text + END AS charge_model, + c.invoiceable, + CASE c.regroup_paid_fees + WHEN 0 THEN 'invoice'::text + ELSE NULL::text + END AS regroup_paid_fees, + c.pay_in_advance, + c.prorated, + c.min_amount_cents, + c.properties, + ( SELECT json_agg(json_build_object('invoice_display_name', cf.invoice_display_name, 'properties', cf.properties, 'values', ( SELECT json_agg(json_build_object(cfcv.billable_metric_filter_id, cfcv."values")) AS json_agg + FROM public.charge_filter_values cfcv + WHERE (cfcv.charge_filter_id = cf.id)))) AS json_agg + FROM public.charge_filters cf + WHERE (cf.charge_id = c.id)) AS charge_filters + FROM public.charges c; + + +-- +-- Name: exports_coupons; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_coupons AS + SELECT cp.organization_id, + cp.id AS lago_id, + cp.name, + cp.code, + cp.description, + CASE cp.coupon_type + WHEN 0 THEN 'fixed_amount'::text + WHEN 1 THEN 'percentage'::text + ELSE NULL::text + END AS coupon_type, + cp.amount_cents, + cp.amount_currency, + cp.percentage_rate, + CASE cp.frequency + WHEN 0 THEN 'once'::text + WHEN 1 THEN 'recurring'::text + WHEN 2 THEN 'forever'::text + ELSE NULL::text + END AS frequency, + cp.frequency_duration, + cp.reusable, + cp.limited_plans, + cp.limited_billable_metrics, + to_json(ARRAY( SELECT cpt.plan_id + FROM public.coupon_targets cpt + WHERE ((cpt.coupon_id = cp.id) AND (cpt.plan_id IS NOT NULL)))) AS lago_plan_ids, + to_json(ARRAY( SELECT cpt.billable_metric_id + FROM public.coupon_targets cpt + WHERE ((cpt.coupon_id = cp.id) AND (cpt.billable_metric_id IS NOT NULL)))) AS lago_billable_metrics_ids, + cp.created_at, + CASE cp.expiration + WHEN 0 THEN 'no_expiration'::text + WHEN 1 THEN 'time_limit'::text + ELSE NULL::text + END AS expiration, + cp.expiration_at, + cp.terminated_at, + cp.updated_at + FROM public.coupons cp; + + +-- +-- Name: exports_credit_notes; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_credit_notes AS + SELECT cn.organization_id, + cn.id AS lago_id, + cn.sequential_id, + cn.number, + cn.invoice_id AS lago_invoice_id, + cn.issuing_date, + CASE cn.credit_status + WHEN 0 THEN 'available'::text + WHEN 1 THEN 'consumed'::text + WHEN 2 THEN 'voided'::text + ELSE NULL::text + END AS credit_status, + CASE cn.refund_status + WHEN 0 THEN 'pending'::text + WHEN 1 THEN 'succeeded'::text + WHEN 2 THEN 'failed'::text + ELSE NULL::text + END AS refund_status, + CASE cn.reason + WHEN 0 THEN 'duplicated_charge'::text + WHEN 1 THEN 'product_unsatisfactory'::text + WHEN 2 THEN 'order_change'::text + WHEN 3 THEN 'order_cancellation'::text + WHEN 4 THEN 'fraudulent_charge'::text + WHEN 5 THEN 'other'::text + ELSE NULL::text + END AS reason, + cn.description, + cn.total_amount_currency AS currency, + cn.total_amount_cents, + cn.taxes_amount_cents, + (round(((( SELECT (sum(ci.precise_amount_cents))::bigint AS sum + FROM public.credit_note_items ci + WHERE (ci.credit_note_id = cn.id)))::numeric - cn.precise_coupons_adjustment_amount_cents)))::bigint AS sub_total_excluding_taxes_amount_cents, + cn.balance_amount_cents, + cn.credit_amount_cents, + cn.refund_amount_cents, + cn.coupons_adjustment_amount_cents, + cn.taxes_rate, + cn.created_at, + cn.updated_at, + cn.refunded_at, + ( SELECT json_agg(json_build_object('lago_id', ci.id, 'amount_cents', ci.amount_cents, 'amount_currency', ci.amount_currency, 'lago_fee_id', ci.fee_id)) AS json_agg + FROM public.credit_note_items ci + WHERE (ci.credit_note_id = cn.id)) AS items, + ( SELECT json_agg(json_build_object('lago_id', ed.id, 'error_code', ed.error_code, 'details', ed.details)) AS json_agg + FROM public.error_details ed + WHERE (ed.owner_id = cn.id)) AS error_details + FROM public.credit_notes cn; + + +-- +-- Name: exports_credit_notes_taxes; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_credit_notes_taxes AS + SELECT cnt.organization_id, + cnt.id AS lago_id, + cnt.tax_id AS lago_tax_id, + cnt.credit_note_id AS lago_credit_note_id, + cnt.tax_name, + cnt.tax_code, + cnt.tax_rate, + cnt.tax_description, + cnt.base_amount_cents, + cnt.amount_cents, + cnt.amount_currency, + cnt.created_at, + cnt.updated_at + FROM public.credit_notes_taxes cnt; + + +-- +-- Name: organizations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.organizations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + api_key character varying, + webhook_url character varying, + vat_rate double precision DEFAULT 0.0 NOT NULL, + country character varying, + address_line1 character varying, + address_line2 character varying, + state character varying, + zipcode character varying, + email character varying, + city character varying, + logo character varying, + legal_name character varying, + legal_number character varying, + invoice_footer text, + invoice_grace_period integer DEFAULT 0 NOT NULL, + timezone character varying DEFAULT 'UTC'::character varying NOT NULL, + document_locale character varying DEFAULT 'en'::character varying NOT NULL, + email_settings character varying[] DEFAULT '{}'::character varying[] NOT NULL, + tax_identification_number character varying, + net_payment_term integer DEFAULT 0 NOT NULL, + default_currency character varying DEFAULT 'USD'::character varying NOT NULL, + document_numbering integer DEFAULT 0 NOT NULL, + document_number_prefix character varying, + eu_tax_management boolean DEFAULT false, + premium_integrations character varying[] DEFAULT '{}'::character varying[] NOT NULL, + custom_aggregation boolean DEFAULT false, + finalize_zero_amount_invoice boolean DEFAULT true NOT NULL, + clickhouse_events_store boolean DEFAULT false NOT NULL, + hmac_key character varying NOT NULL, + authentication_methods character varying[] DEFAULT '{email_password,google_oauth}'::character varying[] NOT NULL, + audit_logs_period integer DEFAULT 30, + pre_filter_events boolean DEFAULT false NOT NULL, + clickhouse_deduplication_enabled boolean DEFAULT false NOT NULL, + feature_flags character varying[] DEFAULT '{}'::character varying[] NOT NULL, + max_wallets integer, + slug character varying NOT NULL, + CONSTRAINT check_organizations_on_invoice_grace_period CHECK ((invoice_grace_period >= 0)), + CONSTRAINT check_organizations_on_net_payment_term CHECK ((net_payment_term >= 0)) +); + + +-- +-- Name: payment_provider_customers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.payment_provider_customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + payment_provider_id uuid, + type character varying NOT NULL, + provider_customer_id character varying, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + organization_id uuid NOT NULL +); + + +-- +-- Name: exports_customers; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_customers AS + SELECT c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + (c.account_type)::text AS account_type, + c.name, + c.firstname, + c.lastname, + (c.customer_type)::text AS customer_type, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.deleted_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC'::character varying) AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + CASE c.finalize_zero_amount_invoice + WHEN 0 THEN 'inherit'::text + WHEN 1 THEN 'skip'::text + WHEN 2 THEN 'finalize'::text + ELSE NULL::text + END AS finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + '{}'::json AS metadata, + '[]'::json AS lago_taxes_ids + FROM ((public.customers c + LEFT JOIN public.organizations o ON ((o.id = c.organization_id))) + LEFT JOIN public.payment_provider_customers ppc ON (((ppc.customer_id = c.id) AND (ppc.deleted_at IS NULL)))); + + +-- +-- Name: exports_daily_usages; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_daily_usages AS + SELECT du.organization_id, + du.id AS lago_id, + du.from_datetime, + du.to_datetime, + du.refreshed_at, + du.usage_date, + du.usage AS daily_usage, + du.usage_diff AS daily_usage_diff, + du.created_at, + du.updated_at, + du.customer_id AS lago_customer_id, + du.subscription_id AS lago_subscription_id, + du.external_subscription_id + FROM public.daily_usages du; + + +-- +-- Name: exports_entitlement_entitlement_values; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_entitlement_entitlement_values AS + SELECT ev.id AS lago_id, + ev.organization_id, + ev.entitlement_entitlement_id AS lago_entitlement_entitlement_id, + ev.entitlement_privilege_id AS lago_entitlement_privilege_id, + ev.value, + ev.deleted_at, + ev.created_at, + ev.updated_at + FROM public.entitlement_entitlement_values ev; + + +-- +-- Name: exports_entitlement_entitlements; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_entitlement_entitlements AS + SELECT ee.id AS lago_id, + ee.organization_id, + ee.entitlement_feature_id AS lago_entitlement_feature_id, + ee.plan_id AS lago_plan_id, + ee.subscription_id AS lago_subscription_id, + ee.deleted_at, + ee.created_at, + ee.updated_at + FROM public.entitlement_entitlements ee; + + +-- +-- Name: exports_entitlement_features; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_entitlement_features AS + SELECT ef.id AS lago_id, + ef.organization_id, + ef.code, + ef.name, + ef.description, + ef.deleted_at, + ef.created_at, + ef.updated_at + FROM public.entitlement_features ef; + + +-- +-- Name: fees; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.fees ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid, + charge_id uuid, + subscription_id uuid, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + taxes_amount_cents bigint NOT NULL, + taxes_rate double precision DEFAULT 0.0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + units numeric DEFAULT 0.0 NOT NULL, + applied_add_on_id uuid, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + fee_type integer, + invoiceable_type character varying, + invoiceable_id uuid, + events_count integer, + group_id uuid, + pay_in_advance_event_id uuid, + payment_status integer DEFAULT 0 NOT NULL, + succeeded_at timestamp(6) without time zone, + failed_at timestamp(6) without time zone, + refunded_at timestamp(6) without time zone, + true_up_parent_fee_id uuid, + add_on_id uuid, + description character varying, + unit_amount_cents bigint DEFAULT 0 NOT NULL, + pay_in_advance boolean DEFAULT false NOT NULL, + precise_coupons_amount_cents numeric(30,5) DEFAULT 0.0 NOT NULL, + total_aggregated_units numeric, + invoice_display_name character varying, + precise_unit_amount numeric(30,15) DEFAULT 0.0 NOT NULL, + amount_details jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + pay_in_advance_event_transaction_id character varying, + deleted_at timestamp(6) without time zone, + precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL, + taxes_precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL, + taxes_base_rate double precision DEFAULT 1.0 NOT NULL, + organization_id uuid NOT NULL, + billing_entity_id uuid NOT NULL, + precise_credit_notes_amount_cents numeric(30,5) DEFAULT 0.0 NOT NULL, + fixed_charge_id uuid, + duplicated_in_advance boolean DEFAULT false, + original_fee_id uuid +); + + +-- +-- Name: plans; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.plans ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + code character varying NOT NULL, + "interval" integer NOT NULL, + description character varying, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + trial_period double precision, + pay_in_advance boolean DEFAULT false NOT NULL, + bill_charges_monthly boolean, + parent_id uuid, + deleted_at timestamp(6) without time zone, + pending_deletion boolean DEFAULT false NOT NULL, + invoice_display_name character varying, + bill_fixed_charges_monthly boolean DEFAULT false +); + + +-- +-- Name: subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + plan_id uuid NOT NULL, + status integer NOT NULL, + canceled_at timestamp without time zone, + terminated_at timestamp without time zone, + started_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + previous_subscription_id uuid, + name character varying, + external_id character varying NOT NULL, + billing_time integer DEFAULT 0 NOT NULL, + subscription_at timestamp(6) without time zone, + ending_at timestamp(6) without time zone, + trial_ended_at timestamp(6) without time zone, + organization_id uuid NOT NULL, + on_termination_credit_note public.subscription_on_termination_credit_note, + on_termination_invoice public.subscription_on_termination_invoice DEFAULT 'generate'::public.subscription_on_termination_invoice NOT NULL, + payment_method_id uuid, + payment_method_type public.payment_method_types DEFAULT 'provider'::public.payment_method_types NOT NULL, + skip_invoice_custom_sections boolean DEFAULT false NOT NULL, + progressive_billing_disabled boolean DEFAULT false NOT NULL, + last_received_event_on date, + cancelation_reason public.subscription_cancelation_reasons, + incompleted_at timestamp(6) without time zone, + activated_at timestamp(6) without time zone, + billing_entity_id uuid +); + + +-- +-- Name: exports_fees; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_fees AS + SELECT f.organization_id, + f.id AS lago_id, + f.charge_id AS lago_charge_id, + f.charge_filter_id AS lago_charge_filter_id, + f.invoice_id AS lago_invoice_id, + f.subscription_id AS lago_subscription_id, + c.id AS lago_customer_id, + json_build_object('type', + CASE f.fee_type + WHEN 0 THEN 'charge'::text + WHEN 1 THEN 'add_on'::text + WHEN 2 THEN 'subscription'::text + WHEN 3 THEN 'credit'::text + WHEN 4 THEN 'commitment'::text + ELSE 'unknown'::text + END, 'code', + CASE f.fee_type + WHEN 0 THEN bm.code + WHEN 1 THEN ao.code + WHEN 3 THEN 'credit'::character varying + ELSE p.code + END, 'name', + CASE f.fee_type + WHEN 0 THEN bm.name + WHEN 1 THEN ao.name + WHEN 3 THEN 'credit'::character varying + ELSE p.name + END, 'description', + CASE f.fee_type + WHEN 0 THEN bm.description + WHEN 1 THEN ao.description + WHEN 3 THEN 'credit'::character varying + ELSE p.description + END, 'invoice_display_name', COALESCE(f.invoice_display_name, + CASE f.fee_type + WHEN 0 THEN COALESCE(ch.invoice_display_name, bm.name) + WHEN 1 THEN COALESCE(ao.invoice_display_name, ao.name) + WHEN 3 THEN 'credit'::character varying + ELSE p.invoice_display_name + END), 'filters', ( SELECT json_agg(json_build_object('id', cf.id, 'charge_id', cf.charge_id, 'properties', cf.properties, 'invoice_display_name', cf.invoice_display_name)) AS json_agg + FROM public.charge_filters cf + WHERE (cf.charge_id = f.charge_id)), 'lago_item_id', + CASE f.fee_type + WHEN 0 THEN bm.id + WHEN 1 THEN ao.id + WHEN 3 THEN f.invoiceable_id + ELSE f.subscription_id + END, 'item_type', + CASE f.fee_type + WHEN 0 THEN 'billable_metric'::text + WHEN 1 THEN 'add_on'::text + WHEN 3 THEN 'wallet_transaction'::text + ELSE 'subscription'::text + END, 'grouped_by', f.grouped_by) AS item, + f.pay_in_advance, + f.amount_cents, + ch.invoiceable, + f.taxes_amount_cents, + f.taxes_precise_amount_cents, + f.taxes_rate, + (f.amount_cents + f.taxes_amount_cents) AS total_amount_cents, + f.amount_currency AS currency, + f.units, + f.description, + f.precise_amount_cents, + f.precise_unit_amount, + f.precise_coupons_amount_cents, + (f.precise_amount_cents + f.taxes_precise_amount_cents) AS precise_total_amount_cents, + f.precise_credit_notes_amount_cents, + f.events_count, + CASE f.payment_status + WHEN 0 THEN 'pending'::text + WHEN 1 THEN 'succeeded'::text + WHEN 2 THEN 'failed'::text + WHEN 3 THEN 'refunded'::text + ELSE 'unknown'::text + END AS payment_status, + f.created_at, + f.succeeded_at, + f.failed_at, + f.refunded_at, + f.amount_details, + f.updated_at, + CASE f.fee_type + WHEN 0 THEN (((f.properties ->> 'charges_from_datetime'::text))::timestamp with time zone)::text + ELSE (((f.properties ->> 'from_datetime'::text))::timestamp with time zone)::text + END AS from_date, + CASE f.fee_type + WHEN 0 THEN (((f.properties ->> 'charges_to_datetime'::text))::timestamp with time zone)::text + ELSE (((f.properties ->> 'to_datetime'::text))::timestamp with time zone)::text + END AS to_date + FROM ((((((public.fees f + LEFT JOIN public.subscriptions s ON ((f.subscription_id = s.id))) + LEFT JOIN public.customers c ON ((s.customer_id = c.id))) + LEFT JOIN public.charges ch ON ((f.charge_id = ch.id))) + LEFT JOIN public.billable_metrics bm ON ((ch.billable_metric_id = bm.id))) + LEFT JOIN public.add_ons ao ON ((f.add_on_id = ao.id))) + LEFT JOIN public.plans p ON ((s.plan_id = p.id))); + + +-- +-- Name: fees_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.fees_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + fee_id uuid NOT NULL, + tax_id uuid, + tax_description character varying, + tax_code character varying NOT NULL, + tax_name character varying NOT NULL, + tax_rate double precision DEFAULT 0.0 NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: exports_fees_taxes; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_fees_taxes AS + SELECT ft.organization_id, + ft.id AS lago_id, + ft.fee_id AS lago_fee_id, + ft.tax_id AS lago_tax_id, + ft.tax_name, + ft.tax_code, + ft.tax_rate, + ft.tax_description, + ft.amount_cents, + ft.amount_currency, + ft.created_at, + ft.updated_at + FROM public.fees_taxes ft; + + +-- +-- Name: integration_customers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.integration_customers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + customer_id uuid NOT NULL, + external_customer_id character varying, + type character varying NOT NULL, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: exports_integration_customers; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_integration_customers AS + SELECT ic.id AS lago_id, + ic.organization_id, + ic.customer_id AS lago_customer_id, + ic.integration_id AS lago_integration_id, + ic.external_customer_id, + ic.type, + ic.settings, + ic.created_at, + ic.updated_at + FROM public.integration_customers ic; + + +-- +-- Name: invoice_settlements; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoice_settlements ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + billing_entity_id uuid NOT NULL, + target_invoice_id uuid NOT NULL, + settlement_type public.invoice_settlement_settlement_type NOT NULL, + source_payment_id uuid, + source_credit_note_id uuid, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: exports_invoice_settlements; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_invoice_settlements AS + SELECT ins.id AS lago_id, + ins.organization_id, + ins.billing_entity_id AS lago_billing_entity_id, + ins.target_invoice_id AS lago_target_invoice_id, + (ins.settlement_type)::text AS settlement_type, + ins.source_payment_id AS lago_source_payment_id, + ins.source_credit_note_id AS lago_source_credit_note_id, + ins.amount_cents, + ins.amount_currency, + ins.created_at, + ins.updated_at + FROM public.invoice_settlements ins; + + +-- +-- Name: invoice_subscriptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoice_subscriptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + subscription_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + recurring boolean, + "timestamp" timestamp(6) without time zone, + from_datetime timestamp(6) without time zone, + to_datetime timestamp(6) without time zone, + charges_from_datetime timestamp(6) without time zone, + charges_to_datetime timestamp(6) without time zone, + invoicing_reason public.subscription_invoicing_reason, + organization_id uuid NOT NULL, + regenerated_invoice_id uuid, + fixed_charges_from_datetime timestamp(6) without time zone, + fixed_charges_to_datetime timestamp(6) without time zone +); + + +-- +-- Name: exports_invoice_subscriptions; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_invoice_subscriptions AS + SELECT ins.organization_id, + ins.id AS lago_id, + ins.invoice_id AS lago_invoice_id, + ins.regenerated_invoice_id AS lago_regenerated_invoice_id, + ins.subscription_id AS lago_subscription_id, + ins.created_at, + ins.updated_at, + ins.from_datetime, + ins.to_datetime, + ins.charges_from_datetime, + ins.charges_to_datetime, + ins."timestamp", + (ins.invoicing_reason)::text AS invoicing_reason + FROM public.invoice_subscriptions ins; + + +-- +-- Name: invoice_metadata; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoice_metadata ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + key character varying NOT NULL, + value character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: invoices; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoices ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + issuing_date date, + taxes_amount_cents bigint DEFAULT 0 NOT NULL, + total_amount_cents bigint DEFAULT 0 NOT NULL, + invoice_type integer DEFAULT 0 NOT NULL, + payment_status integer DEFAULT 0 NOT NULL, + number character varying DEFAULT ''::character varying NOT NULL, + sequential_id integer, + file character varying, + customer_id uuid, + taxes_rate double precision DEFAULT 0.0 NOT NULL, + status integer DEFAULT 1 NOT NULL, + timezone character varying DEFAULT 'UTC'::character varying NOT NULL, + payment_attempts integer DEFAULT 0 NOT NULL, + ready_for_payment_processing boolean DEFAULT true NOT NULL, + organization_id uuid NOT NULL, + version_number integer DEFAULT 4 NOT NULL, + currency character varying, + fees_amount_cents bigint DEFAULT 0 NOT NULL, + coupons_amount_cents bigint DEFAULT 0 NOT NULL, + credit_notes_amount_cents bigint DEFAULT 0 NOT NULL, + prepaid_credit_amount_cents bigint DEFAULT 0 NOT NULL, + sub_total_excluding_taxes_amount_cents bigint DEFAULT 0 NOT NULL, + sub_total_including_taxes_amount_cents bigint DEFAULT 0 NOT NULL, + payment_due_date date, + net_payment_term integer DEFAULT 0 NOT NULL, + voided_at timestamp(6) without time zone, + organization_sequential_id integer DEFAULT 0 NOT NULL, + ready_to_be_refreshed boolean DEFAULT false NOT NULL, + payment_dispute_lost_at timestamp(6) without time zone DEFAULT NULL::timestamp without time zone, + skip_charges boolean DEFAULT false NOT NULL, + payment_overdue boolean DEFAULT false, + negative_amount_cents bigint DEFAULT 0 NOT NULL, + progressive_billing_credit_amount_cents bigint DEFAULT 0 NOT NULL, + tax_status public.tax_status, + total_paid_amount_cents bigint DEFAULT 0 NOT NULL, + self_billed boolean DEFAULT false NOT NULL, + applied_grace_period integer, + billing_entity_id uuid NOT NULL, + billing_entity_sequential_id integer, + finalized_at timestamp without time zone, + voided_invoice_id uuid, + xml_file character varying, + expected_finalization_date date, + prepaid_granted_credit_amount_cents bigint, + prepaid_purchased_credit_amount_cents bigint, + payment_method_id uuid, + skip_automatic_payment boolean, + CONSTRAINT check_organizations_on_net_payment_term CHECK ((net_payment_term >= 0)) +); + + +-- +-- Name: exports_invoices; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_invoices AS + SELECT i.organization_id, + i.id AS lago_id, + i.sequential_id, + i.customer_id, + i.number, + (i.issuing_date)::timestamp with time zone AS issuing_date, + (i.payment_due_date)::timestamp with time zone AS payment_due_date, + i.net_payment_term, + CASE i.invoice_type + WHEN 0 THEN 'subscription'::text + WHEN 1 THEN 'add_on'::text + WHEN 2 THEN 'credit'::text + WHEN 3 THEN 'one_off'::text + WHEN 4 THEN 'advance_charges'::text + WHEN 5 THEN 'progressive_billing'::text + ELSE NULL::text + END AS invoice_type, + CASE i.status + WHEN 0 THEN 'draft'::text + WHEN 1 THEN 'finalized'::text + WHEN 2 THEN 'voided'::text + WHEN 3 THEN 'generating'::text + WHEN 4 THEN 'failed'::text + WHEN 5 THEN 'open'::text + WHEN 6 THEN 'close'::text + WHEN 7 THEN 'pending'::text + ELSE NULL::text + END AS status, + CASE i.payment_status + WHEN 0 THEN 'pending'::text + WHEN 1 THEN 'succeeded'::text + WHEN 2 THEN 'failed'::text + ELSE NULL::text + END AS payment_status, + (i.payment_dispute_lost_at)::timestamp with time zone AS payment_dispute_lost_at, + i.payment_overdue, + i.currency, + i.fees_amount_cents, + i.taxes_amount_cents, + i.progressive_billing_credit_amount_cents, + i.coupons_amount_cents, + i.credit_notes_amount_cents, + i.sub_total_excluding_taxes_amount_cents, + i.sub_total_including_taxes_amount_cents, + i.total_amount_cents, + (i.total_amount_cents - i.total_paid_amount_cents) AS total_due_amount_cents, + i.prepaid_credit_amount_cents, + i.version_number, + i.created_at, + i.updated_at, + i.voided_at, + ( SELECT json_agg(json_build_object('lago_id', m_1.id, 'key', m_1.key, 'value', m_1.value, 'created_at', m_1.created_at)) AS json_agg + FROM public.invoice_metadata m_1 + WHERE (m_1.invoice_id = i.id)) AS metadata, + ( SELECT json_agg(json_build_object('lago_id', ed.id, 'error_code', ed.error_code, 'details', ed.details)) AS json_agg + FROM public.error_details ed + WHERE (ed.owner_id = i.id)) AS error_details + FROM (public.invoices i + LEFT JOIN public.invoice_metadata m ON ((i.id = m.invoice_id))) + WHERE (i.status = ANY (ARRAY[0, 1, 2, 4, 7])); + + +-- +-- Name: invoices_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoices_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + tax_id uuid, + tax_description character varying, + tax_code character varying NOT NULL, + tax_name character varying NOT NULL, + tax_rate double precision DEFAULT 0.0 NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + fees_amount_cents bigint DEFAULT 0 NOT NULL, + taxable_base_amount_cents bigint DEFAULT 0 NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: exports_invoices_taxes; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_invoices_taxes AS + SELECT it.organization_id, + it.id AS lago_id, + it.invoice_id AS lago_invoice_id, + it.tax_id AS lago_tax_id, + it.tax_name, + it.tax_code, + it.tax_rate, + it.tax_description, + it.amount_cents, + it.amount_currency, + it.fees_amount_cents, + it.created_at, + it.updated_at + FROM public.invoices_taxes it; + + +-- +-- Name: item_metadata; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.item_metadata ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + owner_type character varying NOT NULL, + owner_id uuid NOT NULL, + value jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + CONSTRAINT item_metadata_value_must_be_json_object CHECK ((jsonb_typeof(value) = 'object'::text)) +); + + +-- +-- Name: exports_item_metadata; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_item_metadata AS + SELECT im.id AS lago_id, + im.organization_id, + im.owner_type, + im.owner_id AS lago_owner_id, + im.value, + im.created_at, + im.updated_at + FROM public.item_metadata im; + + +-- +-- Name: invoices_payment_requests; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoices_payment_requests ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + payment_request_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: payment_requests; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.payment_requests ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + email character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL, + payment_status integer DEFAULT 0 NOT NULL, + payment_attempts integer DEFAULT 0 NOT NULL, + ready_for_payment_processing boolean DEFAULT true NOT NULL, + dunning_campaign_id uuid +); + + +-- +-- Name: payments; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.payments ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid, + payment_provider_id uuid, + payment_provider_customer_id uuid, + amount_cents bigint NOT NULL, + amount_currency character varying NOT NULL, + provider_payment_id character varying, + status character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + payable_type character varying DEFAULT 'Invoice'::character varying NOT NULL, + payable_id uuid, + provider_payment_data jsonb DEFAULT '{}'::jsonb, + payable_payment_status public.payment_payable_payment_status, + payment_type public.payment_type DEFAULT 'provider'::public.payment_type NOT NULL, + reference character varying, + provider_payment_method_data jsonb DEFAULT '{}'::jsonb NOT NULL, + provider_payment_method_id character varying, + organization_id uuid NOT NULL, + customer_id uuid, + error_code character varying, + payment_method_id uuid +); + + +-- +-- Name: exports_payment_requests; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_payment_requests AS + SELECT pr.organization_id, + pr.id AS lago_id, + pr.customer_id AS lago_customer_id, + pr.payment_attempts, + pr.amount_cents, + pr.amount_currency, + pr.email, + pr.ready_for_payment_processing, + CASE pr.payment_status + WHEN 0 THEN 'pending'::text + WHEN 1 THEN 'succeeded'::text + WHEN 2 THEN 'failed'::text + ELSE NULL::text + END AS payment_status, + to_json(ARRAY( SELECT p.id + FROM public.payments p + WHERE ((p.payable_id = pr.id) AND ((p.payable_type)::text = 'PaymentRequest'::text)) + ORDER BY p.created_at)) AS payment_ids, + to_json(ARRAY( SELECT apr.invoice_id + FROM public.invoices_payment_requests apr + WHERE (apr.payment_request_id = pr.id) + ORDER BY apr.created_at)) AS invoice_ids, + pr.created_at, + pr.updated_at + FROM public.payment_requests pr; + + +-- +-- Name: exports_payments; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_payments AS + SELECT p.organization_id, + p.id AS lago_id, + p.amount_cents, + p.amount_currency, + (p.payable_payment_status)::text AS payment_status, + (p.payment_type)::text AS payment_type, + p.reference, + p.provider_payment_id AS external_payment_id, + (p.created_at)::timestamp with time zone AS created_at, + (p.updated_at)::timestamp with time zone AS updated_at, + CASE + WHEN ((p.payable_type)::text = 'Invoice'::text) THEN to_json(ARRAY[p.payable_id]) + WHEN ((p.payable_type)::text = 'PaymentRequest'::text) THEN to_json(ARRAY( SELECT ai.invoice_id + FROM public.invoices_payment_requests ai + WHERE (ai.payment_request_id = p.payable_id) + ORDER BY ai.created_at)) + ELSE to_json(ARRAY[]::uuid[]) + END AS invoice_ids + FROM public.payments p; + + +-- +-- Name: plans_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.plans_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + plan_id uuid NOT NULL, + tax_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: exports_plans; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_plans AS + SELECT p.organization_id, + p.id AS lago_id, + p.name, + p.invoice_display_name, + p.created_at, + p.updated_at, + p.code, + CASE p."interval" + WHEN 0 THEN 'weekly'::text + WHEN 1 THEN 'monthly'::text + WHEN 2 THEN 'yearly'::text + WHEN 3 THEN 'quarterly'::text + ELSE NULL::text + END AS plan_interval, + p.description, + p.amount_cents, + p.amount_currency, + p.trial_period, + p.pay_in_advance, + p.bill_charges_monthly, + p.parent_id, + to_json(ARRAY( SELECT pt.tax_id AS lago_tax_id + FROM public.plans_taxes pt + WHERE (pt.plan_id = p.id))) AS lago_taxes_ids + FROM public.plans p + WHERE (p.deleted_at IS NULL); + + +-- +-- Name: exports_subscriptions; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_subscriptions AS + SELECT s.organization_id, + s.id AS lago_id, + s.external_id, + s.customer_id AS lago_customer_id, + s.name, + s.plan_id AS lago_plan_id, + CASE s.status + WHEN 0 THEN 'pending'::text + WHEN 1 THEN 'active'::text + WHEN 2 THEN 'terminated'::text + WHEN 3 THEN 'canceled'::text + ELSE NULL::text + END AS status, + CASE s.billing_time + WHEN 0 THEN 'calendar'::text + WHEN 1 THEN 'anniversary'::text + ELSE NULL::text + END AS billing_time, + s.subscription_at, + s.started_at, + s.trial_ended_at, + s.ending_at, + s.terminated_at, + s.canceled_at, + s.created_at, + s.updated_at, + to_json(ARRAY( SELECT ns.id + FROM public.subscriptions ns + WHERE (ns.previous_subscription_id = s.id))) AS lago_next_subscriptions_id, + s.previous_subscription_id AS lago_previous_subscription_id + FROM public.subscriptions s; + + +-- +-- Name: taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + description character varying, + code character varying NOT NULL, + name character varying NOT NULL, + rate double precision DEFAULT 0.0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + applied_to_organization boolean DEFAULT false NOT NULL, + auto_generated boolean DEFAULT false NOT NULL, + deleted_at timestamp(6) without time zone +); + + +-- +-- Name: exports_taxes; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_taxes AS + SELECT tx.organization_id, + tx.id AS lago_id, + tx.name, + tx.code, + tx.rate, + tx.description, + tx.applied_to_organization, + tx.created_at, + tx.updated_at + FROM public.taxes tx; + + +-- +-- Name: usage_monitoring_alert_thresholds; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.usage_monitoring_alert_thresholds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + usage_monitoring_alert_id uuid NOT NULL, + value numeric(30,5) NOT NULL, + code character varying, + recurring boolean DEFAULT false NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: exports_usage_monitoring_alert_thresholds; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_usage_monitoring_alert_thresholds AS + SELECT ath.id AS lago_id, + ath.organization_id, + ath.usage_monitoring_alert_id AS lago_alert_id, + ath.value, + ath.code, + ath.recurring, + ath.created_at, + ath.updated_at + FROM public.usage_monitoring_alert_thresholds ath; + + +-- +-- Name: usage_monitoring_triggered_alerts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.usage_monitoring_triggered_alerts ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + usage_monitoring_alert_id uuid NOT NULL, + subscription_id uuid, + current_value numeric(30,5) NOT NULL, + previous_value numeric(30,5) NOT NULL, + crossed_thresholds jsonb DEFAULT '{}'::jsonb, + triggered_at timestamp(6) without time zone NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + wallet_id uuid, + CONSTRAINT chk_triggered_alerts_subscription_xor_wallet CHECK (((subscription_id IS NOT NULL) <> (wallet_id IS NOT NULL))) +); + + +-- +-- Name: exports_usage_monitoring_triggered_alerts; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_usage_monitoring_triggered_alerts AS + SELECT ta.id AS lago_id, + ta.organization_id, + ta.usage_monitoring_alert_id AS lago_alert_id, + ta.subscription_id AS lago_subscription_id, + ta.wallet_id AS lago_wallet_id, + ta.current_value, + ta.previous_value, + ta.crossed_thresholds, + ta.triggered_at, + ta.created_at, + ta.updated_at + FROM public.usage_monitoring_triggered_alerts ta; + + +-- +-- Name: usage_thresholds; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.usage_thresholds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + plan_id uuid, + threshold_display_name character varying, + amount_cents bigint NOT NULL, + recurring boolean DEFAULT false NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + organization_id uuid NOT NULL, + subscription_id uuid, + CONSTRAINT usage_thresholds_check_exactly_one_parent CHECK (((plan_id IS NOT NULL) <> (subscription_id IS NOT NULL))) +); + + +-- +-- Name: exports_usage_thresholds; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_usage_thresholds AS + SELECT ut.organization_id, + ut.plan_id AS lago_plan_id, + ut.id AS lago_id, + ut.amount_cents, + ut.recurring, + ut.threshold_display_name, + ut.created_at, + ut.updated_at, + ut.deleted_at + FROM public.usage_thresholds ut; + + +-- +-- Name: wallet_transaction_consumptions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallet_transaction_consumptions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + inbound_wallet_transaction_id uuid NOT NULL, + outbound_wallet_transaction_id uuid NOT NULL, + consumed_amount_cents bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: exports_wallet_transaction_consumptions; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_wallet_transaction_consumptions AS + SELECT wtc.id AS lago_id, + wtc.organization_id, + wtc.inbound_wallet_transaction_id AS lago_inbound_wallet_transaction_id, + wtc.outbound_wallet_transaction_id AS lago_outbound_wallet_transaction_id, + wtc.consumed_amount_cents, + wtc.created_at, + wtc.updated_at + FROM public.wallet_transaction_consumptions wtc; + + +-- +-- Name: wallet_transactions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallet_transactions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + wallet_id uuid NOT NULL, + transaction_type integer NOT NULL, + status integer NOT NULL, + amount numeric(30,5) DEFAULT 0.0 NOT NULL, + credit_amount numeric(30,5) DEFAULT 0.0 NOT NULL, + settled_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + invoice_id uuid, + source integer DEFAULT 0 NOT NULL, + transaction_status integer DEFAULT 0 NOT NULL, + invoice_requires_successful_payment boolean DEFAULT false NOT NULL, + metadata jsonb DEFAULT '[]'::jsonb, + credit_note_id uuid, + failed_at timestamp(6) without time zone, + organization_id uuid NOT NULL, + lock_version integer DEFAULT 0 NOT NULL, + priority integer DEFAULT 50 NOT NULL, + name character varying(255), + payment_method_id uuid, + payment_method_type public.payment_method_types DEFAULT 'provider'::public.payment_method_types NOT NULL, + skip_invoice_custom_sections boolean DEFAULT false NOT NULL, + remaining_amount_cents bigint, + voided_invoice_id uuid, + CONSTRAINT remaining_amount_cents_non_negative CHECK (((remaining_amount_cents >= 0) OR (remaining_amount_cents IS NULL))) +); + + +-- +-- Name: exports_wallet_transactions; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_wallet_transactions AS + SELECT wt.organization_id, + wt.id AS lago_id, + wt.wallet_id AS lago_wallet_id, + CASE wt.status + WHEN 0 THEN 'pending'::text + WHEN 1 THEN 'settled'::text + WHEN 2 THEN 'failed'::text + ELSE NULL::text + END AS status, + CASE wt.source + WHEN 0 THEN 'manual'::text + WHEN 1 THEN 'interval'::text + WHEN 2 THEN 'threshold'::text + ELSE NULL::text + END AS source, + CASE wt.transaction_status + WHEN 0 THEN 'purchased'::text + WHEN 1 THEN 'granted'::text + WHEN 2 THEN 'voided'::text + WHEN 3 THEN 'invoiced'::text + ELSE NULL::text + END AS transaction_status, + CASE wt.transaction_type + WHEN 0 THEN 'inbound'::text + WHEN 1 THEN 'outbound'::text + ELSE NULL::text + END AS transaction_type, + wt.amount, + wt.credit_amount, + wt.settled_at, + wt.failed_at, + wt.created_at, + wt.updated_at, + wt.invoice_requires_successful_payment, + wt.metadata, + wt.invoice_id AS lago_invoice_id + FROM public.wallet_transactions wt; + + +-- +-- Name: wallets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + customer_id uuid NOT NULL, + status integer NOT NULL, + name character varying, + rate_amount numeric(30,5) DEFAULT 0.0 NOT NULL, + credits_balance numeric(30,5) DEFAULT 0.0 NOT NULL, + consumed_credits numeric(30,5) DEFAULT 0.0 NOT NULL, + expiration_at timestamp without time zone, + last_balance_sync_at timestamp without time zone, + last_consumed_credit_at timestamp without time zone, + terminated_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + balance_cents bigint DEFAULT 0 NOT NULL, + balance_currency character varying NOT NULL, + consumed_amount_cents bigint DEFAULT 0 NOT NULL, + consumed_amount_currency character varying NOT NULL, + ongoing_balance_cents bigint DEFAULT 0 NOT NULL, + ongoing_usage_balance_cents bigint DEFAULT 0 NOT NULL, + credits_ongoing_balance numeric(30,5) DEFAULT 0.0 NOT NULL, + credits_ongoing_usage_balance numeric(30,5) DEFAULT 0.0 NOT NULL, + depleted_ongoing_balance boolean DEFAULT false NOT NULL, + invoice_requires_successful_payment boolean DEFAULT false NOT NULL, + lock_version integer DEFAULT 0 NOT NULL, + ready_to_be_refreshed boolean DEFAULT false NOT NULL, + organization_id uuid NOT NULL, + allowed_fee_types character varying[] DEFAULT '{}'::character varying[] NOT NULL, + last_ongoing_balance_sync_at timestamp without time zone, + priority integer DEFAULT 50 NOT NULL, + paid_top_up_min_amount_cents bigint, + paid_top_up_max_amount_cents bigint, + payment_method_id uuid, + payment_method_type public.payment_method_types DEFAULT 'provider'::public.payment_method_types NOT NULL, + skip_invoice_custom_sections boolean DEFAULT false NOT NULL, + traceable boolean DEFAULT false NOT NULL, + code character varying, + billing_entity_id uuid +); + + +-- +-- Name: exports_wallets; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.exports_wallets AS + SELECT w.organization_id, + w.id AS lago_id, + w.customer_id AS lago_customer_id, + CASE w.status + WHEN 0 THEN 'active'::text + WHEN 1 THEN 'terminated'::text + ELSE NULL::text + END AS status, + w.balance_currency AS currency, + w.name, + w.rate_amount, + w.credits_balance, + w.credits_ongoing_balance, + w.credits_ongoing_usage_balance, + w.balance_cents, + w.ongoing_balance_cents, + w.ongoing_usage_balance_cents, + w.consumed_credits, + w.created_at, + w.updated_at, + w.terminated_at, + w.last_balance_sync_at, + w.last_consumed_credit_at, + w.invoice_requires_successful_payment + FROM public.wallets w; + + +-- +-- Name: fixed_charge_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.fixed_charge_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + subscription_id uuid NOT NULL, + fixed_charge_id uuid NOT NULL, + units numeric(30,10) DEFAULT 0.0 NOT NULL, + "timestamp" timestamp(6) without time zone, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: fixed_charges; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.fixed_charges ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + plan_id uuid NOT NULL, + add_on_id uuid NOT NULL, + parent_id uuid, + charge_model public.fixed_charge_charge_model DEFAULT 'standard'::public.fixed_charge_charge_model NOT NULL, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + invoice_display_name character varying, + pay_in_advance boolean DEFAULT false NOT NULL, + prorated boolean DEFAULT false NOT NULL, + units numeric(30,10) DEFAULT 0.0 NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + code character varying NOT NULL +); + + +-- +-- Name: fixed_charges_taxes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.fixed_charges_taxes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + fixed_charge_id uuid NOT NULL, + tax_id uuid NOT NULL, + organization_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: flat_filters; Type: VIEW; Schema: public; Owner: - +-- + +CREATE VIEW public.flat_filters AS +SELECT + NULL::uuid AS organization_id, + NULL::character varying AS billable_metric_code, + NULL::uuid AS plan_id, + NULL::uuid AS charge_id, + NULL::timestamp(6) without time zone AS charge_updated_at, + NULL::uuid AS charge_filter_id, + NULL::timestamp(6) without time zone AS charge_filter_updated_at, + NULL::jsonb AS filters, + NULL::jsonb AS properties, + NULL::jsonb AS pricing_group_keys, + NULL::boolean AS pay_in_advance, + NULL::boolean AS accepts_target_wallet; + + +-- +-- Name: group_properties; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.group_properties ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + charge_id uuid NOT NULL, + group_id uuid NOT NULL, + "values" jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + invoice_display_name character varying +); + + +-- +-- Name: groups; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.groups ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + billable_metric_id uuid NOT NULL, + parent_group_id uuid, + key character varying NOT NULL, + value character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone +); + + +-- +-- Name: idempotency_records; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.idempotency_records ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + idempotency_key bytea NOT NULL, + resource_id uuid, + resource_type character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid +); + + +-- +-- Name: inbound_webhooks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.inbound_webhooks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + source character varying NOT NULL, + event_type character varying NOT NULL, + payload jsonb NOT NULL, + status public.inbound_webhook_status DEFAULT 'pending'::public.inbound_webhook_status NOT NULL, + organization_id uuid NOT NULL, + code character varying, + signature character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + processing_at timestamp without time zone +); + + +-- +-- Name: integration_collection_mappings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.integration_collection_mappings ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + mapping_type integer NOT NULL, + type character varying NOT NULL, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL, + billing_entity_id uuid +); + + +-- +-- Name: integration_items; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.integration_items ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + item_type integer NOT NULL, + external_id character varying NOT NULL, + external_account_code character varying, + external_name character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: integration_mappings; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.integration_mappings ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + integration_id uuid NOT NULL, + mappable_type character varying NOT NULL, + mappable_id uuid NOT NULL, + type character varying NOT NULL, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL, + billing_entity_id uuid +); + + +-- +-- Name: integration_resources; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.integration_resources ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + syncable_type character varying NOT NULL, + syncable_id uuid NOT NULL, + external_id character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + integration_id uuid, + resource_type integer DEFAULT 0 NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: integrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.integrations ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + type character varying NOT NULL, + secrets character varying, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: invites; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invites ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + membership_id uuid, + email character varying NOT NULL, + token character varying NOT NULL, + status integer DEFAULT 0 NOT NULL, + accepted_at timestamp(6) without time zone, + revoked_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + roles character varying[] DEFAULT '{}'::character varying[] NOT NULL +); + + +-- +-- Name: invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + description character varying, + display_name character varying, + details character varying, + organization_id uuid NOT NULL, + deleted_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + section_type public.invoice_custom_section_type DEFAULT 'manual'::public.invoice_custom_section_type NOT NULL +); + + +-- +-- Name: last_hour_events_mv; Type: MATERIALIZED VIEW; Schema: public; Owner: - +-- + +CREATE MATERIALIZED VIEW public.last_hour_events_mv AS + WITH billable_metric_filters AS ( + SELECT billable_metrics_1.organization_id AS bm_organization_id, + billable_metrics_1.id AS bm_id, + billable_metrics_1.code AS bm_code, + filters.key AS filter_key, + filters."values" AS filter_values + FROM (public.billable_metrics billable_metrics_1 + JOIN public.billable_metric_filters filters ON ((filters.billable_metric_id = billable_metrics_1.id))) + WHERE ((billable_metrics_1.deleted_at IS NULL) AND (filters.deleted_at IS NULL)) + ) + SELECT events.organization_id, + events.transaction_id, + events.properties, + billable_metrics.code AS billable_metric_code, + (billable_metrics.aggregation_type <> 0) AS field_name_mandatory, + (billable_metrics.aggregation_type = ANY (ARRAY[1, 2, 5, 6])) AS numeric_field_mandatory, + (events.properties ->> (billable_metrics.field_name)::text) AS field_value, + ((events.properties ->> (billable_metrics.field_name)::text) ~ '^-?\d+(\.\d+)?$'::text) AS is_numeric_field_value, + (events.properties ? (billable_metric_filters.filter_key)::text) AS has_filter_keys, + ((events.properties ->> (billable_metric_filters.filter_key)::text) = ANY ((billable_metric_filters.filter_values)::text[])) AS has_valid_filter_values + FROM ((public.events + LEFT JOIN public.billable_metrics ON ((((billable_metrics.code)::text = (events.code)::text) AND (events.organization_id = billable_metrics.organization_id)))) + LEFT JOIN billable_metric_filters ON ((billable_metrics.id = billable_metric_filters.bm_id))) + WHERE ((events.deleted_at IS NULL) AND (events.created_at >= (date_trunc('hour'::text, now()) - '01:00:00'::interval)) AND (events.created_at < date_trunc('hour'::text, now())) AND (billable_metrics.deleted_at IS NULL)) + WITH NO DATA; + + +-- +-- Name: lifetime_usages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.lifetime_usages ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + subscription_id uuid NOT NULL, + current_usage_amount_cents bigint DEFAULT 0 NOT NULL, + invoiced_usage_amount_cents bigint DEFAULT 0 NOT NULL, + recalculate_current_usage boolean DEFAULT false NOT NULL, + recalculate_invoiced_usage boolean DEFAULT false NOT NULL, + current_usage_amount_refreshed_at timestamp without time zone, + invoiced_usage_amount_refreshed_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + historical_usage_amount_cents bigint DEFAULT 0 NOT NULL +); + + +-- +-- Name: membership_roles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.membership_roles ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + membership_id uuid NOT NULL, + role_id uuid NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: memberships; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.memberships ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + user_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + status integer DEFAULT 0 NOT NULL, + revoked_at timestamp(6) without time zone +); + + +-- +-- Name: password_resets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.password_resets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + token character varying NOT NULL, + expire_at timestamp(6) without time zone NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: payment_intents; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.payment_intents ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + invoice_id uuid NOT NULL, + organization_id uuid NOT NULL, + payment_url character varying, + status integer DEFAULT 0 NOT NULL, + expires_at timestamp(6) without time zone NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: payment_methods; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.payment_methods ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid NOT NULL, + payment_provider_id uuid, + payment_provider_customer_id uuid, + provider_method_id character varying NOT NULL, + provider_method_type character varying, + is_default boolean DEFAULT false NOT NULL, + deleted_at timestamp(6) without time zone, + details jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: payment_providers; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.payment_providers ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + type character varying NOT NULL, + secrets character varying, + settings jsonb DEFAULT '{}'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + code character varying NOT NULL, + name character varying NOT NULL, + deleted_at timestamp(6) without time zone +); + + +-- +-- Name: payment_receipts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.payment_receipts ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + number character varying NOT NULL, + payment_id uuid NOT NULL, + organization_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + billing_entity_id uuid NOT NULL +); + + +-- +-- Name: pending_vies_checks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pending_vies_checks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + billing_entity_id uuid NOT NULL, + customer_id uuid NOT NULL, + attempts_count integer DEFAULT 0 NOT NULL, + last_attempt_at timestamp(6) without time zone, + tax_identification_number character varying, + last_error_type character varying, + last_error_message text, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: presentation_breakdowns; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.presentation_breakdowns ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + fee_id uuid NOT NULL, + presentation_by jsonb DEFAULT '[]'::jsonb NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + units numeric DEFAULT 0.0 NOT NULL +); + + +-- +-- Name: pricing_unit_usages; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pricing_unit_usages ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + fee_id uuid NOT NULL, + pricing_unit_id uuid NOT NULL, + short_name character varying NOT NULL, + amount_cents bigint NOT NULL, + precise_amount_cents numeric(40,15) DEFAULT 0.0 NOT NULL, + unit_amount_cents bigint DEFAULT 0 NOT NULL, + conversion_rate numeric(40,15) DEFAULT 0.0 NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + precise_unit_amount numeric(30,15) DEFAULT 0.0 NOT NULL +); + + +-- +-- Name: pricing_units; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.pricing_units ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + name character varying NOT NULL, + code character varying NOT NULL, + short_name character varying NOT NULL, + description text, + organization_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: quantified_events; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.quantified_events ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + external_subscription_id character varying NOT NULL, + external_id character varying, + added_at timestamp(6) without time zone NOT NULL, + removed_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + billable_metric_id uuid, + properties jsonb DEFAULT '{}'::jsonb NOT NULL, + deleted_at timestamp(6) without time zone, + group_id uuid, + organization_id uuid NOT NULL, + grouped_by jsonb DEFAULT '{}'::jsonb NOT NULL, + charge_filter_id uuid +); + + +-- +-- Name: quote_owners; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.quote_owners ( + id bigint NOT NULL, + organization_id uuid NOT NULL, + quote_id uuid NOT NULL, + user_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: quote_owners_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.quote_owners_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: quote_owners_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.quote_owners_id_seq OWNED BY public.quote_owners.id; + + +-- +-- Name: quote_versions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.quote_versions ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + quote_id uuid NOT NULL, + sequential_id integer NOT NULL, + status public.quote_status DEFAULT 'draft'::public.quote_status NOT NULL, + approved_at timestamp(6) without time zone, + voided_at timestamp(6) without time zone, + void_reason public.quote_void_reason, + billing_items jsonb, + content text, + share_token character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + CONSTRAINT quote_versions_constraint_approved_at_matches_status CHECK (((status = 'approved'::public.quote_status) = (approved_at IS NOT NULL))), + CONSTRAINT quote_versions_constraint_sequential_id_positive CHECK ((sequential_id > 0)), + CONSTRAINT quote_versions_constraint_void_fields_match_status CHECK (((status = 'voided'::public.quote_status) = ((void_reason IS NOT NULL) AND (voided_at IS NOT NULL)))) +); + + +-- +-- Name: quotes; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.quotes ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + customer_id uuid NOT NULL, + subscription_id uuid, + number character varying NOT NULL, + sequential_id integer NOT NULL, + order_type public.quote_order_type NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + CONSTRAINT quotes_constraint_sequential_id_positive CHECK ((sequential_id > 0)) +); + + +-- +-- Name: recurring_transaction_rules; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.recurring_transaction_rules ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + wallet_id uuid NOT NULL, + trigger integer DEFAULT 0 NOT NULL, + paid_credits numeric(30,5) DEFAULT 0.0 NOT NULL, + granted_credits numeric(30,5) DEFAULT 0.0 NOT NULL, + threshold_credits numeric(30,5) DEFAULT 0.0, + "interval" integer DEFAULT 0, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + method integer DEFAULT 0 NOT NULL, + target_ongoing_balance numeric(30,5), + started_at timestamp(6) without time zone, + invoice_requires_successful_payment boolean DEFAULT false NOT NULL, + transaction_metadata jsonb DEFAULT '[]'::jsonb, + expiration_at timestamp(6) without time zone, + terminated_at timestamp(6) without time zone, + status integer DEFAULT 0, + organization_id uuid NOT NULL, + ignore_paid_top_up_limits boolean DEFAULT false NOT NULL, + transaction_name character varying(255), + payment_method_id uuid, + payment_method_type public.payment_method_types DEFAULT 'provider'::public.payment_method_types NOT NULL, + skip_invoice_custom_sections boolean DEFAULT false NOT NULL +); + + +-- +-- Name: recurring_transaction_rules_invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.recurring_transaction_rules_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + recurring_transaction_rule_id uuid NOT NULL, + invoice_custom_section_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: refunds; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.refunds ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + payment_id uuid NOT NULL, + credit_note_id uuid NOT NULL, + payment_provider_id uuid, + payment_provider_customer_id uuid NOT NULL, + amount_cents bigint DEFAULT 0 NOT NULL, + amount_currency character varying NOT NULL, + status character varying NOT NULL, + provider_refund_id character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + organization_id uuid NOT NULL +); + + +-- +-- Name: roles; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.roles ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid, + code character varying NOT NULL, + admin boolean DEFAULT false NOT NULL, + permissions character varying[] DEFAULT '{}'::character varying[] NOT NULL, + name character varying NOT NULL, + description character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + deleted_at timestamp(6) without time zone, + CONSTRAINT code_is_valid CHECK (((code)::text ~ '^[a-z0-9_]{1,100}$'::text)), + CONSTRAINT custom_role_should_have_permissions CHECK (((organization_id IS NULL) OR (cardinality(permissions) > 0))), + CONSTRAINT description_max_length CHECK ((length((description)::text) <= 255)), + CONSTRAINT name_is_valid CHECK (((name)::text ~ '^.{1,100}$'::text)), + CONSTRAINT permissions_has_no_empty_parts CHECK (((NOT ((permissions)::text ~ '([\{,]:|::|:[,\}])'::text)) AND (NOT (''::text = ANY ((permissions)::text[]))))), + CONSTRAINT predefined_role_cannot_have_permissions CHECK (((organization_id IS NOT NULL) OR (cardinality(permissions) = 0))) +); + + +-- +-- Name: schema_migrations; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.schema_migrations ( + version character varying NOT NULL +); + + +-- +-- Name: subscription_activation_rules; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subscription_activation_rules ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + subscription_id uuid NOT NULL, + type public.subscription_activation_rule_types NOT NULL, + timeout_hours integer DEFAULT 0 NOT NULL, + status public.subscription_activation_rule_statuses DEFAULT 'inactive'::public.subscription_activation_rule_statuses NOT NULL, + expires_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: subscriptions_invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.subscriptions_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + subscription_id uuid NOT NULL, + invoice_custom_section_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: usage_monitoring_alerts; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.usage_monitoring_alerts ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + subscription_external_id character varying, + billable_metric_id uuid, + alert_type public.usage_monitoring_alert_types NOT NULL, + previous_value numeric(30,5) DEFAULT 0.0 NOT NULL, + last_processed_at timestamp(6) without time zone, + name character varying, + code character varying NOT NULL, + deleted_at timestamp(6) without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + wallet_id uuid, + direction public.usage_monitoring_alert_direction DEFAULT 'increasing'::public.usage_monitoring_alert_direction NOT NULL, + CONSTRAINT chk_alerts_subscription_xor_wallet CHECK (((subscription_external_id IS NOT NULL) <> (wallet_id IS NOT NULL))) +); + + +-- +-- Name: usage_monitoring_subscription_activities; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.usage_monitoring_subscription_activities ( + id bigint NOT NULL, + organization_id uuid NOT NULL, + subscription_id uuid NOT NULL, + enqueued boolean DEFAULT false NOT NULL, + inserted_at timestamp(6) without time zone DEFAULT CURRENT_TIMESTAMP NOT NULL, + enqueued_at timestamp(6) without time zone +); + + +-- +-- Name: usage_monitoring_subscription_activities_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.usage_monitoring_subscription_activities_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: usage_monitoring_subscription_activities_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.usage_monitoring_subscription_activities_id_seq OWNED BY public.usage_monitoring_subscription_activities.id; + + +-- +-- Name: user_devices; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.user_devices ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + user_id uuid NOT NULL, + fingerprint character varying NOT NULL, + browser character varying, + os character varying, + device_type character varying, + last_logged_at timestamp(6) without time zone NOT NULL, + last_ip_address character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.users ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + email character varying, + password_digest character varying, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: versions; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.versions ( + id bigint NOT NULL, + item_type character varying NOT NULL, + item_id character varying NOT NULL, + event character varying NOT NULL, + whodunnit character varying, + object jsonb, + object_changes jsonb, + created_at timestamp(6) without time zone, + lago_version character varying +); + + +-- +-- Name: versions_id_seq; Type: SEQUENCE; Schema: public; Owner: - +-- + +CREATE SEQUENCE public.versions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +-- +-- Name: versions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: - +-- + +ALTER SEQUENCE public.versions_id_seq OWNED BY public.versions.id; + + +-- +-- Name: wallet_targets; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallet_targets ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + wallet_id uuid NOT NULL, + billable_metric_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: wallet_transactions_invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallet_transactions_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + wallet_transaction_id uuid NOT NULL, + invoice_custom_section_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: wallets_invoice_custom_sections; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.wallets_invoice_custom_sections ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + wallet_id uuid NOT NULL, + invoice_custom_section_id uuid NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL +); + + +-- +-- Name: webhook_endpoints; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.webhook_endpoints ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + organization_id uuid NOT NULL, + webhook_url character varying NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + signature_algo integer DEFAULT 0 NOT NULL, + event_types character varying[], + name character varying +); + + +-- +-- Name: webhooks; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.webhooks ( + id uuid DEFAULT gen_random_uuid() NOT NULL, + object_id uuid, + object_type character varying, + status integer DEFAULT 0 NOT NULL, + retries integer DEFAULT 0 NOT NULL, + http_status integer, + endpoint character varying, + webhook_type character varying, + payload json, + response json, + last_retried_at timestamp without time zone, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + webhook_endpoint_id uuid, + organization_id uuid NOT NULL +); + + +-- +-- Name: enriched_events_default; Type: TABLE ATTACH; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enriched_events ATTACH PARTITION public.enriched_events_default DEFAULT; + + +-- +-- Name: quote_owners id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_owners ALTER COLUMN id SET DEFAULT nextval('public.quote_owners_id_seq'::regclass); + + +-- +-- Name: usage_monitoring_subscription_activities id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_subscription_activities ALTER COLUMN id SET DEFAULT nextval('public.usage_monitoring_subscription_activities_id_seq'::regclass); + + +-- +-- Name: versions id; Type: DEFAULT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.versions ALTER COLUMN id SET DEFAULT nextval('public.versions_id_seq'::regclass); + + +-- +-- Name: active_storage_attachments active_storage_attachments_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.active_storage_attachments + ADD CONSTRAINT active_storage_attachments_pkey PRIMARY KEY (id); + + +-- +-- Name: active_storage_blobs active_storage_blobs_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.active_storage_blobs + ADD CONSTRAINT active_storage_blobs_pkey PRIMARY KEY (id); + + +-- +-- Name: active_storage_variant_records active_storage_variant_records_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.active_storage_variant_records + ADD CONSTRAINT active_storage_variant_records_pkey PRIMARY KEY (id); + + +-- +-- Name: add_ons add_ons_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.add_ons + ADD CONSTRAINT add_ons_pkey PRIMARY KEY (id); + + +-- +-- Name: add_ons_taxes add_ons_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.add_ons_taxes + ADD CONSTRAINT add_ons_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: adjusted_fees adjusted_fees_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT adjusted_fees_pkey PRIMARY KEY (id); + + +-- +-- Name: ai_conversations ai_conversations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ai_conversations + ADD CONSTRAINT ai_conversations_pkey PRIMARY KEY (id); + + +-- +-- Name: api_keys api_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT api_keys_pkey PRIMARY KEY (id); + + +-- +-- Name: applied_add_ons applied_add_ons_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_add_ons + ADD CONSTRAINT applied_add_ons_pkey PRIMARY KEY (id); + + +-- +-- Name: applied_coupons applied_coupons_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_coupons + ADD CONSTRAINT applied_coupons_pkey PRIMARY KEY (id); + + +-- +-- Name: applied_invoice_custom_sections applied_invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_invoice_custom_sections + ADD CONSTRAINT applied_invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: applied_pricing_units applied_pricing_units_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_pricing_units + ADD CONSTRAINT applied_pricing_units_pkey PRIMARY KEY (id); + + +-- +-- Name: applied_usage_thresholds applied_usage_thresholds_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_usage_thresholds + ADD CONSTRAINT applied_usage_thresholds_pkey PRIMARY KEY (id); + + +-- +-- Name: ar_internal_metadata ar_internal_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ar_internal_metadata + ADD CONSTRAINT ar_internal_metadata_pkey PRIMARY KEY (key); + + +-- +-- Name: billable_metric_filters billable_metric_filters_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billable_metric_filters + ADD CONSTRAINT billable_metric_filters_pkey PRIMARY KEY (id); + + +-- +-- Name: billable_metrics billable_metrics_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billable_metrics + ADD CONSTRAINT billable_metrics_pkey PRIMARY KEY (id); + + +-- +-- Name: billing_entities_invoice_custom_sections billing_entities_invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_invoice_custom_sections + ADD CONSTRAINT billing_entities_invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: billing_entities billing_entities_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities + ADD CONSTRAINT billing_entities_pkey PRIMARY KEY (id); + + +-- +-- Name: billing_entities_taxes billing_entities_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_taxes + ADD CONSTRAINT billing_entities_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: cached_aggregations cached_aggregations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cached_aggregations + ADD CONSTRAINT cached_aggregations_pkey PRIMARY KEY (id); + + +-- +-- Name: charge_filter_values charge_filter_values_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charge_filter_values + ADD CONSTRAINT charge_filter_values_pkey PRIMARY KEY (id); + + +-- +-- Name: charge_filters charge_filters_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charge_filters + ADD CONSTRAINT charge_filters_pkey PRIMARY KEY (id); + + +-- +-- Name: charges charges_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges + ADD CONSTRAINT charges_pkey PRIMARY KEY (id); + + +-- +-- Name: charges_taxes charges_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges_taxes + ADD CONSTRAINT charges_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: commitments commitments_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.commitments + ADD CONSTRAINT commitments_pkey PRIMARY KEY (id); + + +-- +-- Name: commitments_taxes commitments_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.commitments_taxes + ADD CONSTRAINT commitments_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: coupon_targets coupon_targets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT coupon_targets_pkey PRIMARY KEY (id); + + +-- +-- Name: coupons coupons_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.coupons + ADD CONSTRAINT coupons_pkey PRIMARY KEY (id); + + +-- +-- Name: credit_note_items credit_note_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_note_items + ADD CONSTRAINT credit_note_items_pkey PRIMARY KEY (id); + + +-- +-- Name: credit_notes credit_notes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes + ADD CONSTRAINT credit_notes_pkey PRIMARY KEY (id); + + +-- +-- Name: credit_notes_taxes credit_notes_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes_taxes + ADD CONSTRAINT credit_notes_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: credits credits_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credits + ADD CONSTRAINT credits_pkey PRIMARY KEY (id); + + +-- +-- Name: customer_metadata customer_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customer_metadata + ADD CONSTRAINT customer_metadata_pkey PRIMARY KEY (id); + + +-- +-- Name: customers_invoice_custom_sections customers_invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_invoice_custom_sections + ADD CONSTRAINT customers_invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: customers customers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers + ADD CONSTRAINT customers_pkey PRIMARY KEY (id); + + +-- +-- Name: customers_taxes customers_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_taxes + ADD CONSTRAINT customers_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: daily_usages daily_usages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT daily_usages_pkey PRIMARY KEY (id); + + +-- +-- Name: data_export_parts data_export_parts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_export_parts + ADD CONSTRAINT data_export_parts_pkey PRIMARY KEY (id); + + +-- +-- Name: data_exports data_exports_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT data_exports_pkey PRIMARY KEY (id); + + +-- +-- Name: dunning_campaign_thresholds dunning_campaign_thresholds_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dunning_campaign_thresholds + ADD CONSTRAINT dunning_campaign_thresholds_pkey PRIMARY KEY (id); + + +-- +-- Name: dunning_campaigns dunning_campaigns_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dunning_campaigns + ADD CONSTRAINT dunning_campaigns_pkey PRIMARY KEY (id); + + +-- +-- Name: enriched_store_migrations enriched_store_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enriched_store_migrations + ADD CONSTRAINT enriched_store_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: enriched_store_subscription_migrations enriched_store_subscription_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enriched_store_subscription_migrations + ADD CONSTRAINT enriched_store_subscription_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: entitlement_entitlement_values entitlement_entitlement_values_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlement_values + ADD CONSTRAINT entitlement_entitlement_values_pkey PRIMARY KEY (id); + + +-- +-- Name: entitlement_entitlements entitlement_entitlements_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlements + ADD CONSTRAINT entitlement_entitlements_pkey PRIMARY KEY (id); + + +-- +-- Name: entitlement_features entitlement_features_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_features + ADD CONSTRAINT entitlement_features_pkey PRIMARY KEY (id); + + +-- +-- Name: entitlement_privileges entitlement_privileges_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_privileges + ADD CONSTRAINT entitlement_privileges_pkey PRIMARY KEY (id); + + +-- +-- Name: entitlement_subscription_feature_removals entitlement_subscription_feature_removals_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_subscription_feature_removals + ADD CONSTRAINT entitlement_subscription_feature_removals_pkey PRIMARY KEY (id); + + +-- +-- Name: error_details error_details_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.error_details + ADD CONSTRAINT error_details_pkey PRIMARY KEY (id); + + +-- +-- Name: events events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.events + ADD CONSTRAINT events_pkey PRIMARY KEY (id); + + +-- +-- Name: fees fees_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fees_pkey PRIMARY KEY (id); + + +-- +-- Name: fees_taxes fees_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees_taxes + ADD CONSTRAINT fees_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: fixed_charge_events fixed_charge_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charge_events + ADD CONSTRAINT fixed_charge_events_pkey PRIMARY KEY (id); + + +-- +-- Name: fixed_charges fixed_charges_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges + ADD CONSTRAINT fixed_charges_pkey PRIMARY KEY (id); + + +-- +-- Name: fixed_charges_taxes fixed_charges_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges_taxes + ADD CONSTRAINT fixed_charges_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: group_properties group_properties_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_properties + ADD CONSTRAINT group_properties_pkey PRIMARY KEY (id); + + +-- +-- Name: groups groups_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.groups + ADD CONSTRAINT groups_pkey PRIMARY KEY (id); + + +-- +-- Name: idempotency_records idempotency_records_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.idempotency_records + ADD CONSTRAINT idempotency_records_pkey PRIMARY KEY (id); + + +-- +-- Name: inbound_webhooks inbound_webhooks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.inbound_webhooks + ADD CONSTRAINT inbound_webhooks_pkey PRIMARY KEY (id); + + +-- +-- Name: integration_collection_mappings integration_collection_mappings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_collection_mappings + ADD CONSTRAINT integration_collection_mappings_pkey PRIMARY KEY (id); + + +-- +-- Name: integration_customers integration_customers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_customers + ADD CONSTRAINT integration_customers_pkey PRIMARY KEY (id); + + +-- +-- Name: integration_items integration_items_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_items + ADD CONSTRAINT integration_items_pkey PRIMARY KEY (id); + + +-- +-- Name: integration_mappings integration_mappings_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_mappings + ADD CONSTRAINT integration_mappings_pkey PRIMARY KEY (id); + + +-- +-- Name: integration_resources integration_resources_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_resources + ADD CONSTRAINT integration_resources_pkey PRIMARY KEY (id); + + +-- +-- Name: integrations integrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integrations + ADD CONSTRAINT integrations_pkey PRIMARY KEY (id); + + +-- +-- Name: invites invites_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invites + ADD CONSTRAINT invites_pkey PRIMARY KEY (id); + + +-- +-- Name: invoice_custom_sections invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_custom_sections + ADD CONSTRAINT invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: invoice_metadata invoice_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_metadata + ADD CONSTRAINT invoice_metadata_pkey PRIMARY KEY (id); + + +-- +-- Name: invoice_settlements invoice_settlements_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_settlements + ADD CONSTRAINT invoice_settlements_pkey PRIMARY KEY (id); + + +-- +-- Name: invoice_subscriptions invoice_subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT invoice_subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: invoices_payment_requests invoices_payment_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_payment_requests + ADD CONSTRAINT invoices_payment_requests_pkey PRIMARY KEY (id); + + +-- +-- Name: invoices invoices_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT invoices_pkey PRIMARY KEY (id); + + +-- +-- Name: invoices_taxes invoices_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_taxes + ADD CONSTRAINT invoices_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: item_metadata item_metadata_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.item_metadata + ADD CONSTRAINT item_metadata_pkey PRIMARY KEY (id); + + +-- +-- Name: lifetime_usages lifetime_usages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lifetime_usages + ADD CONSTRAINT lifetime_usages_pkey PRIMARY KEY (id); + + +-- +-- Name: membership_roles membership_roles_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.membership_roles + ADD CONSTRAINT membership_roles_pkey PRIMARY KEY (id); + + +-- +-- Name: memberships memberships_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.memberships + ADD CONSTRAINT memberships_pkey PRIMARY KEY (id); + + +-- +-- Name: organizations organizations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.organizations + ADD CONSTRAINT organizations_pkey PRIMARY KEY (id); + + +-- +-- Name: password_resets password_resets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.password_resets + ADD CONSTRAINT password_resets_pkey PRIMARY KEY (id); + + +-- +-- Name: payment_intents payment_intents_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_intents + ADD CONSTRAINT payment_intents_pkey PRIMARY KEY (id); + + +-- +-- Name: payment_methods payment_methods_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_methods + ADD CONSTRAINT payment_methods_pkey PRIMARY KEY (id); + + +-- +-- Name: payment_provider_customers payment_provider_customers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_provider_customers + ADD CONSTRAINT payment_provider_customers_pkey PRIMARY KEY (id); + + +-- +-- Name: payment_providers payment_providers_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_providers + ADD CONSTRAINT payment_providers_pkey PRIMARY KEY (id); + + +-- +-- Name: payment_receipts payment_receipts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_receipts + ADD CONSTRAINT payment_receipts_pkey PRIMARY KEY (id); + + +-- +-- Name: payment_requests payment_requests_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT payment_requests_pkey PRIMARY KEY (id); + + +-- +-- Name: payments payments_customer_id_null; Type: CHECK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE public.payments + ADD CONSTRAINT payments_customer_id_null CHECK ((customer_id IS NOT NULL)) NOT VALID; + + +-- +-- Name: payments payments_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT payments_pkey PRIMARY KEY (id); + + +-- +-- Name: pending_vies_checks pending_vies_checks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pending_vies_checks + ADD CONSTRAINT pending_vies_checks_pkey PRIMARY KEY (id); + + +-- +-- Name: plans plans_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.plans + ADD CONSTRAINT plans_pkey PRIMARY KEY (id); + + +-- +-- Name: plans_taxes plans_taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.plans_taxes + ADD CONSTRAINT plans_taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: presentation_breakdowns presentation_breakdowns_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.presentation_breakdowns + ADD CONSTRAINT presentation_breakdowns_pkey PRIMARY KEY (id); + + +-- +-- Name: pricing_unit_usages pricing_unit_usages_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_unit_usages + ADD CONSTRAINT pricing_unit_usages_pkey PRIMARY KEY (id); + + +-- +-- Name: pricing_units pricing_units_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_units + ADD CONSTRAINT pricing_units_pkey PRIMARY KEY (id); + + +-- +-- Name: quantified_events quantified_events_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quantified_events + ADD CONSTRAINT quantified_events_pkey PRIMARY KEY (id); + + +-- +-- Name: quote_owners quote_owners_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_owners + ADD CONSTRAINT quote_owners_pkey PRIMARY KEY (id); + + +-- +-- Name: quote_versions quote_versions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_versions + ADD CONSTRAINT quote_versions_pkey PRIMARY KEY (id); + + +-- +-- Name: quotes quotes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quotes + ADD CONSTRAINT quotes_pkey PRIMARY KEY (id); + + +-- +-- Name: recurring_transaction_rules_invoice_custom_sections recurring_transaction_rules_invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules_invoice_custom_sections + ADD CONSTRAINT recurring_transaction_rules_invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: recurring_transaction_rules recurring_transaction_rules_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules + ADD CONSTRAINT recurring_transaction_rules_pkey PRIMARY KEY (id); + + +-- +-- Name: refunds refunds_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.refunds + ADD CONSTRAINT refunds_pkey PRIMARY KEY (id); + + +-- +-- Name: roles roles_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.roles + ADD CONSTRAINT roles_pkey PRIMARY KEY (id); + + +-- +-- Name: schema_migrations schema_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.schema_migrations + ADD CONSTRAINT schema_migrations_pkey PRIMARY KEY (version); + + +-- +-- Name: subscription_activation_rules subscription_activation_rules_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscription_activation_rules + ADD CONSTRAINT subscription_activation_rules_pkey PRIMARY KEY (id); + + +-- +-- Name: subscriptions_invoice_custom_sections subscriptions_invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions_invoice_custom_sections + ADD CONSTRAINT subscriptions_invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: subscriptions subscriptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT subscriptions_pkey PRIMARY KEY (id); + + +-- +-- Name: taxes taxes_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.taxes + ADD CONSTRAINT taxes_pkey PRIMARY KEY (id); + + +-- +-- Name: usage_monitoring_alert_thresholds usage_monitoring_alert_thresholds_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_alert_thresholds + ADD CONSTRAINT usage_monitoring_alert_thresholds_pkey PRIMARY KEY (id); + + +-- +-- Name: usage_monitoring_alerts usage_monitoring_alerts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_alerts + ADD CONSTRAINT usage_monitoring_alerts_pkey PRIMARY KEY (id); + + +-- +-- Name: usage_monitoring_subscription_activities usage_monitoring_subscription_activities_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_subscription_activities + ADD CONSTRAINT usage_monitoring_subscription_activities_pkey PRIMARY KEY (id); + + +-- +-- Name: usage_monitoring_triggered_alerts usage_monitoring_triggered_alerts_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_triggered_alerts + ADD CONSTRAINT usage_monitoring_triggered_alerts_pkey PRIMARY KEY (id); + + +-- +-- Name: usage_thresholds usage_thresholds_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_thresholds + ADD CONSTRAINT usage_thresholds_pkey PRIMARY KEY (id); + + +-- +-- Name: user_devices user_devices_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_devices + ADD CONSTRAINT user_devices_pkey PRIMARY KEY (id); + + +-- +-- Name: users users_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.users + ADD CONSTRAINT users_pkey PRIMARY KEY (id); + + +-- +-- Name: versions versions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.versions + ADD CONSTRAINT versions_pkey PRIMARY KEY (id); + + +-- +-- Name: wallet_targets wallet_targets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_targets + ADD CONSTRAINT wallet_targets_pkey PRIMARY KEY (id); + + +-- +-- Name: wallet_transaction_consumptions wallet_transaction_consumptions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transaction_consumptions + ADD CONSTRAINT wallet_transaction_consumptions_pkey PRIMARY KEY (id); + + +-- +-- Name: wallet_transactions_invoice_custom_sections wallet_transactions_invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions_invoice_custom_sections + ADD CONSTRAINT wallet_transactions_invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: wallet_transactions wallet_transactions_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT wallet_transactions_pkey PRIMARY KEY (id); + + +-- +-- Name: wallets_invoice_custom_sections wallets_invoice_custom_sections_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets_invoice_custom_sections + ADD CONSTRAINT wallets_invoice_custom_sections_pkey PRIMARY KEY (id); + + +-- +-- Name: wallets wallets_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT wallets_pkey PRIMARY KEY (id); + + +-- +-- Name: webhook_endpoints webhook_endpoints_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.webhook_endpoints + ADD CONSTRAINT webhook_endpoints_pkey PRIMARY KEY (id); + + +-- +-- Name: webhooks webhooks_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.webhooks + ADD CONSTRAINT webhooks_pkey PRIMARY KEY (id); + + +-- +-- Name: index_enriched_events_on_event_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_enriched_events_on_event_id ON ONLY public.enriched_events USING btree (event_id); + + +-- +-- Name: enriched_events_default_event_id_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX enriched_events_default_event_id_idx ON public.enriched_events_default USING btree (event_id); + + +-- +-- Name: idx_unique_on_enriched_events; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_unique_on_enriched_events ON ONLY public.enriched_events USING btree (organization_id, external_subscription_id, transaction_id, "timestamp", charge_id); + + +-- +-- Name: enriched_events_default_organization_id_external_subscript_idx1; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX enriched_events_default_organization_id_external_subscript_idx1 ON public.enriched_events_default USING btree (organization_id, external_subscription_id, transaction_id, "timestamp", charge_id); + + +-- +-- Name: idx_lookup_on_enriched_events; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_lookup_on_enriched_events ON ONLY public.enriched_events USING btree (organization_id, external_subscription_id, code, "timestamp"); + + +-- +-- Name: enriched_events_default_organization_id_external_subscripti_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX enriched_events_default_organization_id_external_subscripti_idx ON public.enriched_events_default USING btree (organization_id, external_subscription_id, code, "timestamp"); + + +-- +-- Name: idx_billing_on_enriched_events; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_billing_on_enriched_events ON ONLY public.enriched_events USING btree (organization_id, subscription_id, charge_id, charge_filter_id, "timestamp"); + + +-- +-- Name: enriched_events_default_organization_id_subscription_id_cha_idx; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX enriched_events_default_organization_id_subscription_id_cha_idx ON public.enriched_events_default USING btree (organization_id, subscription_id, charge_id, charge_filter_id, "timestamp"); + + +-- +-- Name: idx_aggregation_lookup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_aggregation_lookup ON public.cached_aggregations USING btree (external_subscription_id, charge_id, "timestamp") INCLUDE (organization_id, grouped_by); + + +-- +-- Name: idx_alerts_code_unique_per_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_alerts_code_unique_per_subscription ON public.usage_monitoring_alerts USING btree (code, subscription_external_id, organization_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_alerts_unique_per_type_per_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_alerts_unique_per_type_per_subscription ON public.usage_monitoring_alerts USING btree (subscription_external_id, organization_id, alert_type) WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)); + + +-- +-- Name: idx_alerts_unique_per_type_per_subscription_with_bm; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_alerts_unique_per_type_per_subscription_with_bm ON public.usage_monitoring_alerts USING btree (subscription_external_id, organization_id, alert_type, billable_metric_id) WHERE ((billable_metric_id IS NOT NULL) AND (deleted_at IS NULL)); + + +-- +-- Name: idx_alerts_unique_per_type_per_wallet; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_alerts_unique_per_type_per_wallet ON public.usage_monitoring_alerts USING btree (wallet_id, organization_id, alert_type) WHERE ((billable_metric_id IS NULL) AND (deleted_at IS NULL)); + + +-- +-- Name: idx_cached_aggregation_filtered_lookup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_cached_aggregation_filtered_lookup ON public.cached_aggregations USING btree (organization_id, external_subscription_id, charge_id, "timestamp" DESC, created_at DESC) INCLUDE (grouped_by, charge_filter_id, event_transaction_id); + + +-- +-- Name: idx_enqueued_per_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_enqueued_per_organization ON public.usage_monitoring_subscription_activities USING btree (organization_id, enqueued); + + +-- +-- Name: idx_enriched_store_sub_migrations_on_migration_and_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_enriched_store_sub_migrations_on_migration_and_subscription ON public.enriched_store_subscription_migrations USING btree (enriched_store_migration_id, subscription_id); + + +-- +-- Name: idx_events_billing_lookup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_events_billing_lookup ON public.events USING btree (external_subscription_id, organization_id, code, "timestamp") INCLUDE (properties) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_events_for_distinct_codes; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_events_for_distinct_codes ON public.events USING btree (external_subscription_id, organization_id, "timestamp") INCLUDE (code) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_features_code_unique_per_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_features_code_unique_per_organization ON public.entitlement_features USING btree (code, organization_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_invoice_subscriptions_on_subscription_with_timestamps; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_invoice_subscriptions_on_subscription_with_timestamps ON public.invoice_subscriptions USING btree (subscription_id, COALESCE(to_datetime, created_at) DESC); + + +-- +-- Name: idx_invoices_organization_id_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_invoices_organization_id_status ON public.invoices USING btree (organization_id, status); + + +-- +-- Name: idx_on_billing_entity_id_724373e5ae; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_billing_entity_id_724373e5ae ON public.billing_entities_invoice_custom_sections USING btree (billing_entity_id); + + +-- +-- Name: idx_on_billing_entity_id_billing_entity_sequential__bd26b2e655; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_billing_entity_id_billing_entity_sequential__bd26b2e655 ON public.invoices USING btree (billing_entity_id, billing_entity_sequential_id DESC) INCLUDE (self_billed); + + +-- +-- Name: idx_on_billing_entity_id_customer_id_invoice_custom_e7aada65cb; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_billing_entity_id_customer_id_invoice_custom_e7aada65cb ON public.customers_invoice_custom_sections USING btree (billing_entity_id, customer_id, invoice_custom_section_id); + + +-- +-- Name: idx_on_billing_entity_id_invoice_custom_section_id_bd78c547d3; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_billing_entity_id_invoice_custom_section_id_bd78c547d3 ON public.billing_entities_invoice_custom_sections USING btree (billing_entity_id, invoice_custom_section_id); + + +-- +-- Name: idx_on_dunning_campaign_id_currency_fbf233b2ae; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_dunning_campaign_id_currency_fbf233b2ae ON public.dunning_campaign_thresholds USING btree (dunning_campaign_id, currency) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_on_enriched_store_migration_id_e409c5dc43; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_enriched_store_migration_id_e409c5dc43 ON public.enriched_store_subscription_migrations USING btree (enriched_store_migration_id); + + +-- +-- Name: idx_on_entitlement_entitlement_id_48c0b3356a; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_entitlement_entitlement_id_48c0b3356a ON public.entitlement_entitlement_values USING btree (entitlement_entitlement_id); + + +-- +-- Name: idx_on_entitlement_feature_id_821ae72311; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_entitlement_feature_id_821ae72311 ON public.entitlement_subscription_feature_removals USING btree (entitlement_feature_id); + + +-- +-- Name: idx_on_entitlement_privilege_id_6a228dc433; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_entitlement_privilege_id_6a228dc433 ON public.entitlement_entitlement_values USING btree (entitlement_privilege_id); + + +-- +-- Name: idx_on_entitlement_privilege_id_9946ccf514; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_entitlement_privilege_id_9946ccf514 ON public.entitlement_subscription_feature_removals USING btree (entitlement_privilege_id); + + +-- +-- Name: idx_on_entitlement_privilege_id_entitlement_entitle_9d0542eb1a; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_entitlement_privilege_id_entitlement_entitle_9d0542eb1a ON public.entitlement_entitlement_values USING btree (entitlement_privilege_id, entitlement_entitlement_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_on_inbound_wallet_transaction_id_e54d00758d; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_inbound_wallet_transaction_id_e54d00758d ON public.wallet_transaction_consumptions USING btree (inbound_wallet_transaction_id); + + +-- +-- Name: idx_on_invoice_custom_section_id_50c2a2e7c0; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_invoice_custom_section_id_50c2a2e7c0 ON public.recurring_transaction_rules_invoice_custom_sections USING btree (invoice_custom_section_id); + + +-- +-- Name: idx_on_invoice_custom_section_id_5f37496c8c; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_invoice_custom_section_id_5f37496c8c ON public.customers_invoice_custom_sections USING btree (invoice_custom_section_id); + + +-- +-- Name: idx_on_invoice_custom_section_id_aca4661c33; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_invoice_custom_section_id_aca4661c33 ON public.wallets_invoice_custom_sections USING btree (invoice_custom_section_id); + + +-- +-- Name: idx_on_invoice_custom_section_id_b381df5bb5; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_invoice_custom_section_id_b381df5bb5 ON public.wallet_transactions_invoice_custom_sections USING btree (invoice_custom_section_id); + + +-- +-- Name: idx_on_invoice_custom_section_id_ccb39e9622; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_invoice_custom_section_id_ccb39e9622 ON public.billing_entities_invoice_custom_sections USING btree (invoice_custom_section_id); + + +-- +-- Name: idx_on_invoice_custom_section_id_d8b9068730; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_invoice_custom_section_id_d8b9068730 ON public.subscriptions_invoice_custom_sections USING btree (invoice_custom_section_id); + + +-- +-- Name: idx_on_invoice_id_payment_request_id_aa550779a4; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_invoice_id_payment_request_id_aa550779a4 ON public.invoices_payment_requests USING btree (invoice_id, payment_request_id); + + +-- +-- Name: idx_on_organization_id_2be2ef98ea; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_2be2ef98ea ON public.enriched_store_subscription_migrations USING btree (organization_id); + + +-- +-- Name: idx_on_organization_id_376a587b04; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_376a587b04 ON public.usage_monitoring_subscription_activities USING btree (organization_id); + + +-- +-- Name: idx_on_organization_id_7020c3c43a; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_7020c3c43a ON public.entitlement_subscription_feature_removals USING btree (organization_id); + + +-- +-- Name: idx_on_organization_id_83703a45f4; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_83703a45f4 ON public.billing_entities_invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: idx_on_organization_id_ccdf05cbfe; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_ccdf05cbfe ON public.wallet_transactions_invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: idx_on_organization_id_deleted_at_225e3f789d; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_deleted_at_225e3f789d ON public.invoice_custom_sections USING btree (organization_id, deleted_at); + + +-- +-- Name: idx_on_organization_id_e73219f079; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_e73219f079 ON public.recurring_transaction_rules_invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: idx_on_organization_id_external_subscription_id_df3a30d96d; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_external_subscription_id_df3a30d96d ON public.daily_usages USING btree (organization_id, external_subscription_id); + + +-- +-- Name: idx_on_organization_id_organization_sequential_id_2387146f54; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_organization_id_organization_sequential_id_2387146f54 ON public.invoices USING btree (organization_id, organization_sequential_id DESC) INCLUDE (self_billed); + + +-- +-- Name: idx_on_outbound_wallet_transaction_id_cf6ff733c6; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_outbound_wallet_transaction_id_cf6ff733c6 ON public.wallet_transaction_consumptions USING btree (outbound_wallet_transaction_id); + + +-- +-- Name: idx_on_plan_id_billable_metric_id_pay_in_advance_4a205974cb; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_plan_id_billable_metric_id_pay_in_advance_4a205974cb ON public.charges USING btree (plan_id, billable_metric_id, pay_in_advance) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_on_recurring_transaction_rule_id_fba3d39cca; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_recurring_transaction_rule_id_fba3d39cca ON public.recurring_transaction_rules_invoice_custom_sections USING btree (recurring_transaction_rule_id); + + +-- +-- Name: idx_on_subscription_id_295edd8bb3; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_subscription_id_295edd8bb3 ON public.entitlement_subscription_feature_removals USING btree (subscription_id); + + +-- +-- Name: idx_on_subscription_id_b41afd08e0; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_subscription_id_b41afd08e0 ON public.enriched_store_subscription_migrations USING btree (subscription_id); + + +-- +-- Name: idx_on_subscription_id_type_8feb7b9623; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_subscription_id_type_8feb7b9623 ON public.subscription_activation_rules USING btree (subscription_id, type); + + +-- +-- Name: idx_on_usage_monitoring_alert_id_4290c95dec; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_usage_monitoring_alert_id_4290c95dec ON public.usage_monitoring_triggered_alerts USING btree (usage_monitoring_alert_id); + + +-- +-- Name: idx_on_usage_monitoring_alert_id_78eb24d06c; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_usage_monitoring_alert_id_78eb24d06c ON public.usage_monitoring_alert_thresholds USING btree (usage_monitoring_alert_id); + + +-- +-- Name: idx_on_usage_monitoring_alert_id_recurring_756a2a370d; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_usage_monitoring_alert_id_recurring_756a2a370d ON public.usage_monitoring_alert_thresholds USING btree (usage_monitoring_alert_id, recurring) WHERE (recurring IS TRUE); + + +-- +-- Name: idx_on_usage_threshold_id_invoice_id_cb82cdf163; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_on_usage_threshold_id_invoice_id_cb82cdf163 ON public.applied_usage_thresholds USING btree (usage_threshold_id, invoice_id); + + +-- +-- Name: idx_on_wallet_transaction_id_ac2826109e; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_on_wallet_transaction_id_ac2826109e ON public.wallet_transactions_invoice_custom_sections USING btree (wallet_transaction_id); + + +-- +-- Name: idx_pay_in_advance_duplication_guard_charge; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_pay_in_advance_duplication_guard_charge ON public.fees USING btree (pay_in_advance_event_transaction_id, charge_id) WHERE ((deleted_at IS NULL) AND (charge_filter_id IS NULL) AND (pay_in_advance_event_transaction_id IS NOT NULL) AND (pay_in_advance = true) AND (duplicated_in_advance = false) AND (original_fee_id IS NULL)); + + +-- +-- Name: idx_pay_in_advance_duplication_guard_charge_filter; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_pay_in_advance_duplication_guard_charge_filter ON public.fees USING btree (pay_in_advance_event_transaction_id, charge_id, charge_filter_id) WHERE ((deleted_at IS NULL) AND (charge_filter_id IS NOT NULL) AND (pay_in_advance_event_transaction_id IS NOT NULL) AND (pay_in_advance = true) AND (duplicated_in_advance = false) AND (original_fee_id IS NULL)); + + +-- +-- Name: idx_privileges_code_unique_per_feature; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_privileges_code_unique_per_feature ON public.entitlement_privileges USING btree (code, entitlement_feature_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_subscription_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_subscription_unique ON public.usage_monitoring_subscription_activities USING btree (subscription_id); + + +-- +-- Name: idx_unique_feature_per_plan; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_unique_feature_per_plan ON public.entitlement_entitlements USING btree (entitlement_feature_id, plan_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_unique_feature_per_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_unique_feature_per_subscription ON public.entitlement_entitlements USING btree (entitlement_feature_id, subscription_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_unique_feature_removal_per_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_unique_feature_removal_per_subscription ON public.entitlement_subscription_feature_removals USING btree (subscription_id, entitlement_feature_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_unique_privilege_removal_per_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_unique_privilege_removal_per_subscription ON public.entitlement_subscription_feature_removals USING btree (subscription_id, entitlement_privilege_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_unique_tax_code_per_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_unique_tax_code_per_organization ON public.taxes USING btree (code, organization_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: idx_usage_thresholds_on_amount_plan_recurring; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_usage_thresholds_on_amount_plan_recurring ON public.usage_thresholds USING btree (amount_cents, plan_id, recurring) WHERE ((deleted_at IS NULL) AND (plan_id IS NOT NULL)); + + +-- +-- Name: idx_usage_thresholds_on_amount_subscription_recurring; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_usage_thresholds_on_amount_subscription_recurring ON public.usage_thresholds USING btree (amount_cents, subscription_id, recurring) WHERE ((deleted_at IS NULL) AND (subscription_id IS NOT NULL)); + + +-- +-- Name: idx_usage_thresholds_plan_recurring; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_usage_thresholds_plan_recurring ON public.usage_thresholds USING btree (plan_id, recurring) WHERE ((recurring IS TRUE) AND (deleted_at IS NULL) AND (plan_id IS NOT NULL)); + + +-- +-- Name: idx_usage_thresholds_subscription_recurring; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_usage_thresholds_subscription_recurring ON public.usage_thresholds USING btree (subscription_id, recurring) WHERE ((recurring IS TRUE) AND (deleted_at IS NULL) AND (subscription_id IS NOT NULL)); + + +-- +-- Name: idx_wallet_transactions_available_inbound; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_wallet_transactions_available_inbound ON public.wallet_transactions USING btree (wallet_id, priority, ( +CASE + WHEN (transaction_status = 1) THEN 0 + ELSE 1 +END), created_at) WHERE ((remaining_amount_cents > 0) AND (transaction_type = 0) AND (status = 1)); + + +-- +-- Name: idx_wallet_tx_consumptions_inbound_outbound; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX idx_wallet_tx_consumptions_inbound_outbound ON public.wallet_transaction_consumptions USING btree (inbound_wallet_transaction_id, outbound_wallet_transaction_id); + + +-- +-- Name: index_activation_rules_pending_with_expiry; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_activation_rules_pending_with_expiry ON public.subscription_activation_rules USING btree (status, expires_at) WHERE ((status = 'pending'::public.subscription_activation_rule_statuses) AND (expires_at IS NOT NULL)); + + +-- +-- Name: index_active_charge_filter_values; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_active_charge_filter_values ON public.charge_filter_values USING btree (charge_filter_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_active_charge_filters; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_active_charge_filters ON public.charge_filters USING btree (charge_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_active_metric_filters; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_active_metric_filters ON public.billable_metric_filters USING btree (billable_metric_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_active_storage_attachments_on_blob_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_active_storage_attachments_on_blob_id ON public.active_storage_attachments USING btree (blob_id); + + +-- +-- Name: index_active_storage_attachments_uniqueness; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_active_storage_attachments_uniqueness ON public.active_storage_attachments USING btree (record_type, record_id, name, blob_id); + + +-- +-- Name: index_active_storage_blobs_on_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_active_storage_blobs_on_key ON public.active_storage_blobs USING btree (key); + + +-- +-- Name: index_active_storage_variant_records_uniqueness; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_active_storage_variant_records_uniqueness ON public.active_storage_variant_records USING btree (blob_id, variation_digest); + + +-- +-- Name: index_add_ons_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_add_ons_on_deleted_at ON public.add_ons USING btree (deleted_at); + + +-- +-- Name: index_add_ons_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_add_ons_on_organization_id ON public.add_ons USING btree (organization_id); + + +-- +-- Name: index_add_ons_on_organization_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_add_ons_on_organization_id_and_code ON public.add_ons USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_add_ons_taxes_on_add_on_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_add_ons_taxes_on_add_on_id ON public.add_ons_taxes USING btree (add_on_id); + + +-- +-- Name: index_add_ons_taxes_on_add_on_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_add_ons_taxes_on_add_on_id_and_tax_id ON public.add_ons_taxes USING btree (add_on_id, tax_id); + + +-- +-- Name: index_add_ons_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_add_ons_taxes_on_organization_id ON public.add_ons_taxes USING btree (organization_id); + + +-- +-- Name: index_add_ons_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_add_ons_taxes_on_tax_id ON public.add_ons_taxes USING btree (tax_id); + + +-- +-- Name: index_adjusted_fees_on_charge_filter_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_adjusted_fees_on_charge_filter_id ON public.adjusted_fees USING btree (charge_filter_id); + + +-- +-- Name: index_adjusted_fees_on_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_adjusted_fees_on_charge_id ON public.adjusted_fees USING btree (charge_id); + + +-- +-- Name: index_adjusted_fees_on_fee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_adjusted_fees_on_fee_id ON public.adjusted_fees USING btree (fee_id); + + +-- +-- Name: index_adjusted_fees_on_group_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_adjusted_fees_on_group_id ON public.adjusted_fees USING btree (group_id); + + +-- +-- Name: index_adjusted_fees_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_adjusted_fees_on_invoice_id ON public.adjusted_fees USING btree (invoice_id); + + +-- +-- Name: index_adjusted_fees_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_adjusted_fees_on_organization_id ON public.adjusted_fees USING btree (organization_id); + + +-- +-- Name: index_adjusted_fees_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_adjusted_fees_on_subscription_id ON public.adjusted_fees USING btree (subscription_id); + + +-- +-- Name: index_ai_conversations_on_membership_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ai_conversations_on_membership_id ON public.ai_conversations USING btree (membership_id); + + +-- +-- Name: index_ai_conversations_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_ai_conversations_on_organization_id ON public.ai_conversations USING btree (organization_id); + + +-- +-- Name: index_api_keys_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_api_keys_on_organization_id ON public.api_keys USING btree (organization_id); + + +-- +-- Name: index_api_keys_on_value; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_api_keys_on_value ON public.api_keys USING btree (value); + + +-- +-- Name: index_applied_add_ons_on_add_on_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_add_ons_on_add_on_id ON public.applied_add_ons USING btree (add_on_id); + + +-- +-- Name: index_applied_add_ons_on_add_on_id_and_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_add_ons_on_add_on_id_and_customer_id ON public.applied_add_ons USING btree (add_on_id, customer_id); + + +-- +-- Name: index_applied_add_ons_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_add_ons_on_customer_id ON public.applied_add_ons USING btree (customer_id); + + +-- +-- Name: index_applied_coupons_on_coupon_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_coupons_on_coupon_id ON public.applied_coupons USING btree (coupon_id); + + +-- +-- Name: index_applied_coupons_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_coupons_on_customer_id ON public.applied_coupons USING btree (customer_id); + + +-- +-- Name: index_applied_coupons_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_coupons_on_organization_id ON public.applied_coupons USING btree (organization_id); + + +-- +-- Name: index_applied_invoice_custom_sections_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_invoice_custom_sections_on_invoice_id ON public.applied_invoice_custom_sections USING btree (invoice_id); + + +-- +-- Name: index_applied_invoice_custom_sections_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_invoice_custom_sections_on_organization_id ON public.applied_invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: index_applied_pricing_units_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_pricing_units_on_organization_id ON public.applied_pricing_units USING btree (organization_id); + + +-- +-- Name: index_applied_pricing_units_on_pricing_unit_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_pricing_units_on_pricing_unit_id ON public.applied_pricing_units USING btree (pricing_unit_id); + + +-- +-- Name: index_applied_pricing_units_on_pricing_unitable; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_pricing_units_on_pricing_unitable ON public.applied_pricing_units USING btree (pricing_unitable_type, pricing_unitable_id); + + +-- +-- Name: index_applied_usage_thresholds_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_usage_thresholds_on_invoice_id ON public.applied_usage_thresholds USING btree (invoice_id); + + +-- +-- Name: index_applied_usage_thresholds_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_usage_thresholds_on_organization_id ON public.applied_usage_thresholds USING btree (organization_id); + + +-- +-- Name: index_applied_usage_thresholds_on_usage_threshold_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_applied_usage_thresholds_on_usage_threshold_id ON public.applied_usage_thresholds USING btree (usage_threshold_id); + + +-- +-- Name: index_billable_metric_filters_on_billable_metric_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billable_metric_filters_on_billable_metric_id ON public.billable_metric_filters USING btree (billable_metric_id); + + +-- +-- Name: index_billable_metric_filters_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billable_metric_filters_on_deleted_at ON public.billable_metric_filters USING btree (deleted_at); + + +-- +-- Name: index_billable_metric_filters_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billable_metric_filters_on_organization_id ON public.billable_metric_filters USING btree (organization_id); + + +-- +-- Name: index_billable_metrics_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billable_metrics_on_deleted_at ON public.billable_metrics USING btree (deleted_at); + + +-- +-- Name: index_billable_metrics_on_org_id_and_code_and_expr; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billable_metrics_on_org_id_and_code_and_expr ON public.billable_metrics USING btree (organization_id, code, expression) WHERE ((expression IS NOT NULL) AND ((expression)::text <> ''::text)); + + +-- +-- Name: index_billable_metrics_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billable_metrics_on_organization_id ON public.billable_metrics USING btree (organization_id); + + +-- +-- Name: index_billable_metrics_on_organization_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_billable_metrics_on_organization_id_and_code ON public.billable_metrics USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_billing_entities_on_applied_dunning_campaign_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billing_entities_on_applied_dunning_campaign_id ON public.billing_entities USING btree (applied_dunning_campaign_id); + + +-- +-- Name: index_billing_entities_on_code_and_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_billing_entities_on_code_and_organization_id ON public.billing_entities USING btree (code, organization_id) WHERE ((deleted_at IS NULL) AND (archived_at IS NULL)); + + +-- +-- Name: index_billing_entities_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billing_entities_on_organization_id ON public.billing_entities USING btree (organization_id); + + +-- +-- Name: index_billing_entities_taxes_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billing_entities_taxes_on_billing_entity_id ON public.billing_entities_taxes USING btree (billing_entity_id); + + +-- +-- Name: index_billing_entities_taxes_on_billing_entity_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_billing_entities_taxes_on_billing_entity_id_and_tax_id ON public.billing_entities_taxes USING btree (billing_entity_id, tax_id); + + +-- +-- Name: index_billing_entities_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billing_entities_taxes_on_organization_id ON public.billing_entities_taxes USING btree (organization_id); + + +-- +-- Name: index_billing_entities_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_billing_entities_taxes_on_tax_id ON public.billing_entities_taxes USING btree (tax_id); + + +-- +-- Name: index_cached_aggregations_on_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_cached_aggregations_on_charge_id ON public.cached_aggregations USING btree (charge_id); + + +-- +-- Name: index_cached_aggregations_on_event_transaction_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_cached_aggregations_on_event_transaction_id ON public.cached_aggregations USING btree (organization_id, event_transaction_id); + + +-- +-- Name: index_cached_aggregations_on_external_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_cached_aggregations_on_external_subscription_id ON public.cached_aggregations USING btree (external_subscription_id); + + +-- +-- Name: index_charge_filter_values_on_billable_metric_filter_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charge_filter_values_on_billable_metric_filter_id ON public.charge_filter_values USING btree (billable_metric_filter_id); + + +-- +-- Name: index_charge_filter_values_on_charge_filter_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charge_filter_values_on_charge_filter_id ON public.charge_filter_values USING btree (charge_filter_id); + + +-- +-- Name: index_charge_filter_values_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charge_filter_values_on_deleted_at ON public.charge_filter_values USING btree (deleted_at); + + +-- +-- Name: index_charge_filter_values_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charge_filter_values_on_organization_id ON public.charge_filter_values USING btree (organization_id); + + +-- +-- Name: index_charge_filters_on_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charge_filters_on_charge_id ON public.charge_filters USING btree (charge_id); + + +-- +-- Name: index_charge_filters_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charge_filters_on_deleted_at ON public.charge_filters USING btree (deleted_at); + + +-- +-- Name: index_charge_filters_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charge_filters_on_organization_id ON public.charge_filters USING btree (organization_id); + + +-- +-- Name: index_charges_on_accepts_target_wallet; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_on_accepts_target_wallet ON public.charges USING btree (accepts_target_wallet) WHERE (accepts_target_wallet = true); + + +-- +-- Name: index_charges_on_billable_metric_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_on_billable_metric_id ON public.charges USING btree (billable_metric_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_charges_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_on_deleted_at ON public.charges USING btree (deleted_at); + + +-- +-- Name: index_charges_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_on_organization_id ON public.charges USING btree (organization_id); + + +-- +-- Name: index_charges_on_parent_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_on_parent_id ON public.charges USING btree (parent_id); + + +-- +-- Name: index_charges_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_on_plan_id ON public.charges USING btree (plan_id); + + +-- +-- Name: index_charges_on_plan_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_charges_on_plan_id_and_code ON public.charges USING btree (plan_id, code) WHERE ((deleted_at IS NULL) AND (parent_id IS NULL)); + + +-- +-- Name: index_charges_pay_in_advance; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_pay_in_advance ON public.charges USING btree (billable_metric_id) WHERE ((deleted_at IS NULL) AND (pay_in_advance = true)); + + +-- +-- Name: index_charges_taxes_on_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_taxes_on_charge_id ON public.charges_taxes USING btree (charge_id); + + +-- +-- Name: index_charges_taxes_on_charge_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_charges_taxes_on_charge_id_and_tax_id ON public.charges_taxes USING btree (charge_id, tax_id); + + +-- +-- Name: index_charges_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_taxes_on_organization_id ON public.charges_taxes USING btree (organization_id); + + +-- +-- Name: index_charges_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_charges_taxes_on_tax_id ON public.charges_taxes USING btree (tax_id); + + +-- +-- Name: index_commitments_on_commitment_type_and_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_commitments_on_commitment_type_and_plan_id ON public.commitments USING btree (commitment_type, plan_id); + + +-- +-- Name: index_commitments_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_commitments_on_organization_id ON public.commitments USING btree (organization_id); + + +-- +-- Name: index_commitments_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_commitments_on_plan_id ON public.commitments USING btree (plan_id); + + +-- +-- Name: index_commitments_taxes_on_commitment_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_commitments_taxes_on_commitment_id ON public.commitments_taxes USING btree (commitment_id); + + +-- +-- Name: index_commitments_taxes_on_commitment_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_commitments_taxes_on_commitment_id_and_tax_id ON public.commitments_taxes USING btree (commitment_id, tax_id); + + +-- +-- Name: index_commitments_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_commitments_taxes_on_organization_id ON public.commitments_taxes USING btree (organization_id); + + +-- +-- Name: index_commitments_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_commitments_taxes_on_tax_id ON public.commitments_taxes USING btree (tax_id); + + +-- +-- Name: index_coupon_targets_on_billable_metric_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_coupon_targets_on_billable_metric_id ON public.coupon_targets USING btree (billable_metric_id); + + +-- +-- Name: index_coupon_targets_on_coupon_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_coupon_targets_on_coupon_id ON public.coupon_targets USING btree (coupon_id); + + +-- +-- Name: index_coupon_targets_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_coupon_targets_on_deleted_at ON public.coupon_targets USING btree (deleted_at); + + +-- +-- Name: index_coupon_targets_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_coupon_targets_on_organization_id ON public.coupon_targets USING btree (organization_id); + + +-- +-- Name: index_coupon_targets_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_coupon_targets_on_plan_id ON public.coupon_targets USING btree (plan_id); + + +-- +-- Name: index_coupons_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_coupons_on_deleted_at ON public.coupons USING btree (deleted_at); + + +-- +-- Name: index_coupons_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_coupons_on_organization_id ON public.coupons USING btree (organization_id); + + +-- +-- Name: index_coupons_on_organization_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_coupons_on_organization_id_and_code ON public.coupons USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_credit_note_items_on_credit_note_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_note_items_on_credit_note_id ON public.credit_note_items USING btree (credit_note_id); + + +-- +-- Name: index_credit_note_items_on_fee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_note_items_on_fee_id ON public.credit_note_items USING btree (fee_id); + + +-- +-- Name: index_credit_note_items_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_note_items_on_organization_id ON public.credit_note_items USING btree (organization_id); + + +-- +-- Name: index_credit_notes_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_notes_on_customer_id ON public.credit_notes USING btree (customer_id); + + +-- +-- Name: index_credit_notes_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_notes_on_invoice_id ON public.credit_notes USING btree (invoice_id); + + +-- +-- Name: index_credit_notes_on_invoice_id_and_sequential_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_credit_notes_on_invoice_id_and_sequential_id ON public.credit_notes USING btree (invoice_id, sequential_id); + + +-- +-- Name: index_credit_notes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_notes_on_organization_id ON public.credit_notes USING btree (organization_id); + + +-- +-- Name: index_credit_notes_taxes_on_credit_note_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_notes_taxes_on_credit_note_id ON public.credit_notes_taxes USING btree (credit_note_id); + + +-- +-- Name: index_credit_notes_taxes_on_credit_note_id_and_tax_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_credit_notes_taxes_on_credit_note_id_and_tax_code ON public.credit_notes_taxes USING btree (credit_note_id, tax_code); + + +-- +-- Name: index_credit_notes_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_notes_taxes_on_organization_id ON public.credit_notes_taxes USING btree (organization_id); + + +-- +-- Name: index_credit_notes_taxes_on_tax_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_notes_taxes_on_tax_code ON public.credit_notes_taxes USING btree (tax_code); + + +-- +-- Name: index_credit_notes_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credit_notes_taxes_on_tax_id ON public.credit_notes_taxes USING btree (tax_id); + + +-- +-- Name: index_credits_on_applied_coupon_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credits_on_applied_coupon_id ON public.credits USING btree (applied_coupon_id); + + +-- +-- Name: index_credits_on_credit_note_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credits_on_credit_note_id ON public.credits USING btree (credit_note_id); + + +-- +-- Name: index_credits_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credits_on_invoice_id ON public.credits USING btree (invoice_id); + + +-- +-- Name: index_credits_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credits_on_organization_id ON public.credits USING btree (organization_id); + + +-- +-- Name: index_credits_on_progressive_billing_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_credits_on_progressive_billing_invoice_id ON public.credits USING btree (progressive_billing_invoice_id); + + +-- +-- Name: index_customer_metadata_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customer_metadata_on_customer_id ON public.customer_metadata USING btree (customer_id); + + +-- +-- Name: index_customer_metadata_on_customer_id_and_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_customer_metadata_on_customer_id_and_key ON public.customer_metadata USING btree (customer_id, key); + + +-- +-- Name: index_customer_metadata_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customer_metadata_on_organization_id ON public.customer_metadata USING btree (organization_id); + + +-- +-- Name: index_customers_invoice_custom_sections_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_invoice_custom_sections_on_billing_entity_id ON public.customers_invoice_custom_sections USING btree (billing_entity_id); + + +-- +-- Name: index_customers_invoice_custom_sections_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_invoice_custom_sections_on_customer_id ON public.customers_invoice_custom_sections USING btree (customer_id); + + +-- +-- Name: index_customers_invoice_custom_sections_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_invoice_custom_sections_on_organization_id ON public.customers_invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: index_customers_on_account_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_on_account_type ON public.customers USING btree (account_type); + + +-- +-- Name: index_customers_on_applied_dunning_campaign_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_on_applied_dunning_campaign_id ON public.customers USING btree (applied_dunning_campaign_id); + + +-- +-- Name: index_customers_on_awaiting_wallet_refresh; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_on_awaiting_wallet_refresh ON public.customers USING btree (awaiting_wallet_refresh); + + +-- +-- Name: index_customers_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_on_billing_entity_id ON public.customers USING btree (billing_entity_id); + + +-- +-- Name: index_customers_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_on_deleted_at ON public.customers USING btree (deleted_at); + + +-- +-- Name: index_customers_on_external_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_on_external_id ON public.customers USING btree (organization_id, external_id); + + +-- +-- Name: index_customers_on_external_id_and_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_customers_on_external_id_and_organization_id ON public.customers USING btree (external_id, organization_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_customers_on_org_id_and_sequential_id_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_customers_on_org_id_and_sequential_id_unique ON public.customers USING btree (organization_id, sequential_id) WHERE (sequential_id IS NOT NULL); + + +-- +-- Name: index_customers_on_sequential_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_on_sequential_id ON public.customers USING btree (sequential_id); + + +-- +-- Name: index_customers_taxes_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_taxes_on_customer_id ON public.customers_taxes USING btree (customer_id); + + +-- +-- Name: index_customers_taxes_on_customer_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_customers_taxes_on_customer_id_and_tax_id ON public.customers_taxes USING btree (customer_id, tax_id); + + +-- +-- Name: index_customers_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_taxes_on_organization_id ON public.customers_taxes USING btree (organization_id); + + +-- +-- Name: index_customers_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_customers_taxes_on_tax_id ON public.customers_taxes USING btree (tax_id); + + +-- +-- Name: index_daily_usages_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_daily_usages_on_customer_id ON public.daily_usages USING btree (customer_id); + + +-- +-- Name: index_daily_usages_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_daily_usages_on_organization_id ON public.daily_usages USING btree (organization_id); + + +-- +-- Name: index_daily_usages_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_daily_usages_on_subscription_id ON public.daily_usages USING btree (subscription_id); + + +-- +-- Name: index_daily_usages_on_usage_date; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_daily_usages_on_usage_date ON public.daily_usages USING btree (usage_date); + + +-- +-- Name: index_data_export_parts_on_data_export_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_data_export_parts_on_data_export_id ON public.data_export_parts USING btree (data_export_id); + + +-- +-- Name: index_data_export_parts_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_data_export_parts_on_organization_id ON public.data_export_parts USING btree (organization_id); + + +-- +-- Name: index_data_exports_on_membership_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_data_exports_on_membership_id ON public.data_exports USING btree (membership_id); + + +-- +-- Name: index_data_exports_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_data_exports_on_organization_id ON public.data_exports USING btree (organization_id); + + +-- +-- Name: index_dunning_campaign_thresholds_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_dunning_campaign_thresholds_on_deleted_at ON public.dunning_campaign_thresholds USING btree (deleted_at); + + +-- +-- Name: index_dunning_campaign_thresholds_on_dunning_campaign_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_dunning_campaign_thresholds_on_dunning_campaign_id ON public.dunning_campaign_thresholds USING btree (dunning_campaign_id); + + +-- +-- Name: index_dunning_campaign_thresholds_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_dunning_campaign_thresholds_on_organization_id ON public.dunning_campaign_thresholds USING btree (organization_id); + + +-- +-- Name: index_dunning_campaigns_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_dunning_campaigns_on_deleted_at ON public.dunning_campaigns USING btree (deleted_at); + + +-- +-- Name: index_dunning_campaigns_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_dunning_campaigns_on_organization_id ON public.dunning_campaigns USING btree (organization_id); + + +-- +-- Name: index_dunning_campaigns_on_organization_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_dunning_campaigns_on_organization_id_and_code ON public.dunning_campaigns USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_enriched_store_migrations_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_enriched_store_migrations_on_organization_id ON public.enriched_store_migrations USING btree (organization_id); + + +-- +-- Name: index_entitlement_entitlement_values_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_entitlement_values_on_organization_id ON public.entitlement_entitlement_values USING btree (organization_id); + + +-- +-- Name: index_entitlement_entitlements_on_entitlement_feature_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_entitlements_on_entitlement_feature_id ON public.entitlement_entitlements USING btree (entitlement_feature_id); + + +-- +-- Name: index_entitlement_entitlements_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_entitlements_on_organization_id ON public.entitlement_entitlements USING btree (organization_id); + + +-- +-- Name: index_entitlement_entitlements_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_entitlements_on_plan_id ON public.entitlement_entitlements USING btree (plan_id); + + +-- +-- Name: index_entitlement_entitlements_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_entitlements_on_subscription_id ON public.entitlement_entitlements USING btree (subscription_id); + + +-- +-- Name: index_entitlement_features_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_features_on_organization_id ON public.entitlement_features USING btree (organization_id); + + +-- +-- Name: index_entitlement_privileges_on_entitlement_feature_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_privileges_on_entitlement_feature_id ON public.entitlement_privileges USING btree (entitlement_feature_id); + + +-- +-- Name: index_entitlement_privileges_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_privileges_on_organization_id ON public.entitlement_privileges USING btree (organization_id); + + +-- +-- Name: index_entitlement_subscription_feature_removals_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_entitlement_subscription_feature_removals_on_deleted_at ON public.entitlement_subscription_feature_removals USING btree (deleted_at); + + +-- +-- Name: index_error_details_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_error_details_on_deleted_at ON public.error_details USING btree (deleted_at); + + +-- +-- Name: index_error_details_on_error_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_error_details_on_error_code ON public.error_details USING btree (error_code); + + +-- +-- Name: index_error_details_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_error_details_on_organization_id ON public.error_details USING btree (organization_id); + + +-- +-- Name: index_error_details_on_owner; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_error_details_on_owner ON public.error_details USING btree (owner_type, owner_id); + + +-- +-- Name: index_events_on_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_events_on_created_at ON public.events USING btree (created_at) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_events_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_events_on_organization_id ON public.events USING btree (organization_id); + + +-- +-- Name: index_events_on_organization_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_events_on_organization_id_and_code ON public.events USING btree (organization_id, code); + + +-- +-- Name: index_events_on_organization_id_and_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_events_on_organization_id_and_created_at ON public.events USING btree (organization_id, created_at DESC) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_events_on_organization_id_and_transaction_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_events_on_organization_id_and_transaction_id ON public.events USING btree (organization_id, transaction_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_fees_on_add_on_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_add_on_id ON public.fees USING btree (add_on_id); + + +-- +-- Name: index_fees_on_applied_add_on_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_applied_add_on_id ON public.fees USING btree (applied_add_on_id); + + +-- +-- Name: index_fees_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_billing_entity_id ON public.fees USING btree (billing_entity_id); + + +-- +-- Name: index_fees_on_charge_filter_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_charge_filter_id ON public.fees USING btree (charge_filter_id); + + +-- +-- Name: index_fees_on_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_charge_id ON public.fees USING btree (charge_id); + + +-- +-- Name: index_fees_on_charge_id_and_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_charge_id_and_invoice_id ON public.fees USING btree (charge_id, invoice_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_fees_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_deleted_at ON public.fees USING btree (deleted_at); + + +-- +-- Name: index_fees_on_fixed_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_fixed_charge_id ON public.fees USING btree (fixed_charge_id); + + +-- +-- Name: index_fees_on_group_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_group_id ON public.fees USING btree (group_id); + + +-- +-- Name: index_fees_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_invoice_id ON public.fees USING btree (invoice_id); + + +-- +-- Name: index_fees_on_invoiceable; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_invoiceable ON public.fees USING btree (invoiceable_type, invoiceable_id); + + +-- +-- Name: index_fees_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_organization_id ON public.fees USING btree (organization_id); + + +-- +-- Name: index_fees_on_original_fee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_original_fee_id ON public.fees USING btree (original_fee_id); + + +-- +-- Name: index_fees_on_pay_in_advance_event_transaction_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_pay_in_advance_event_transaction_id ON public.fees USING btree (pay_in_advance_event_transaction_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_fees_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_subscription_id ON public.fees USING btree (subscription_id); + + +-- +-- Name: index_fees_on_true_up_parent_fee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_on_true_up_parent_fee_id ON public.fees USING btree (true_up_parent_fee_id); + + +-- +-- Name: index_fees_taxes_on_fee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_taxes_on_fee_id ON public.fees_taxes USING btree (fee_id); + + +-- +-- Name: index_fees_taxes_on_fee_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_fees_taxes_on_fee_id_and_tax_id ON public.fees_taxes USING btree (fee_id, tax_id) WHERE ((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone)); + + +-- +-- Name: index_fees_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_taxes_on_organization_id ON public.fees_taxes USING btree (organization_id); + + +-- +-- Name: index_fees_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fees_taxes_on_tax_id ON public.fees_taxes USING btree (tax_id); + + +-- +-- Name: index_fixed_charge_events_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charge_events_on_deleted_at ON public.fixed_charge_events USING btree (deleted_at); + + +-- +-- Name: index_fixed_charge_events_on_fixed_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charge_events_on_fixed_charge_id ON public.fixed_charge_events USING btree (fixed_charge_id); + + +-- +-- Name: index_fixed_charge_events_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charge_events_on_organization_id ON public.fixed_charge_events USING btree (organization_id); + + +-- +-- Name: index_fixed_charge_events_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charge_events_on_subscription_id ON public.fixed_charge_events USING btree (subscription_id); + + +-- +-- Name: index_fixed_charges_on_add_on_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_on_add_on_id ON public.fixed_charges USING btree (add_on_id); + + +-- +-- Name: index_fixed_charges_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_on_deleted_at ON public.fixed_charges USING btree (deleted_at); + + +-- +-- Name: index_fixed_charges_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_on_organization_id ON public.fixed_charges USING btree (organization_id); + + +-- +-- Name: index_fixed_charges_on_parent_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_on_parent_id ON public.fixed_charges USING btree (parent_id); + + +-- +-- Name: index_fixed_charges_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_on_plan_id ON public.fixed_charges USING btree (plan_id); + + +-- +-- Name: index_fixed_charges_on_plan_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_fixed_charges_on_plan_id_and_code ON public.fixed_charges USING btree (plan_id, code) WHERE ((deleted_at IS NULL) AND (parent_id IS NULL)); + + +-- +-- Name: index_fixed_charges_taxes_on_fixed_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_taxes_on_fixed_charge_id ON public.fixed_charges_taxes USING btree (fixed_charge_id); + + +-- +-- Name: index_fixed_charges_taxes_on_fixed_charge_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_fixed_charges_taxes_on_fixed_charge_id_and_tax_id ON public.fixed_charges_taxes USING btree (fixed_charge_id, tax_id); + + +-- +-- Name: index_fixed_charges_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_taxes_on_organization_id ON public.fixed_charges_taxes USING btree (organization_id); + + +-- +-- Name: index_fixed_charges_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_fixed_charges_taxes_on_tax_id ON public.fixed_charges_taxes USING btree (tax_id); + + +-- +-- Name: index_group_properties_on_charge_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_group_properties_on_charge_id ON public.group_properties USING btree (charge_id); + + +-- +-- Name: index_group_properties_on_charge_id_and_group_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_group_properties_on_charge_id_and_group_id ON public.group_properties USING btree (charge_id, group_id); + + +-- +-- Name: index_group_properties_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_group_properties_on_deleted_at ON public.group_properties USING btree (deleted_at); + + +-- +-- Name: index_group_properties_on_group_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_group_properties_on_group_id ON public.group_properties USING btree (group_id); + + +-- +-- Name: index_groups_on_billable_metric_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_groups_on_billable_metric_id ON public.groups USING btree (billable_metric_id); + + +-- +-- Name: index_groups_on_billable_metric_id_and_parent_group_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_groups_on_billable_metric_id_and_parent_group_id ON public.groups USING btree (billable_metric_id, parent_group_id); + + +-- +-- Name: index_groups_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_groups_on_deleted_at ON public.groups USING btree (deleted_at); + + +-- +-- Name: index_groups_on_parent_group_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_groups_on_parent_group_id ON public.groups USING btree (parent_group_id); + + +-- +-- Name: index_idempotency_records_on_idempotency_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_idempotency_records_on_idempotency_key ON public.idempotency_records USING btree (idempotency_key); + + +-- +-- Name: index_idempotency_records_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_idempotency_records_on_organization_id ON public.idempotency_records USING btree (organization_id); + + +-- +-- Name: index_idempotency_records_on_resource_type_and_resource_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_idempotency_records_on_resource_type_and_resource_id ON public.idempotency_records USING btree (resource_type, resource_id); + + +-- +-- Name: index_inbound_webhooks_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_inbound_webhooks_on_organization_id ON public.inbound_webhooks USING btree (organization_id); + + +-- +-- Name: index_inbound_webhooks_on_status_and_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_inbound_webhooks_on_status_and_created_at ON public.inbound_webhooks USING btree (status, created_at) WHERE (status = 'pending'::public.inbound_webhook_status); + + +-- +-- Name: index_inbound_webhooks_on_status_and_processing_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_inbound_webhooks_on_status_and_processing_at ON public.inbound_webhooks USING btree (status, processing_at) WHERE (status = 'processing'::public.inbound_webhook_status); + + +-- +-- Name: index_int_collection_mappings_unique_billing_entity_is_not_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_int_collection_mappings_unique_billing_entity_is_not_null ON public.integration_collection_mappings USING btree (mapping_type, integration_id, billing_entity_id) WHERE (billing_entity_id IS NOT NULL); + + +-- +-- Name: index_int_collection_mappings_unique_billing_entity_is_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_int_collection_mappings_unique_billing_entity_is_null ON public.integration_collection_mappings USING btree (mapping_type, integration_id, organization_id) WHERE (billing_entity_id IS NULL); + + +-- +-- Name: index_int_items_on_external_id_and_int_id_and_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_int_items_on_external_id_and_int_id_and_type ON public.integration_items USING btree (external_id, integration_id, item_type); + + +-- +-- Name: index_integration_collection_mappings_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_collection_mappings_on_billing_entity_id ON public.integration_collection_mappings USING btree (billing_entity_id); + + +-- +-- Name: index_integration_collection_mappings_on_integration_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_collection_mappings_on_integration_id ON public.integration_collection_mappings USING btree (integration_id); + + +-- +-- Name: index_integration_collection_mappings_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_collection_mappings_on_organization_id ON public.integration_collection_mappings USING btree (organization_id); + + +-- +-- Name: index_integration_customers_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_customers_on_customer_id ON public.integration_customers USING btree (customer_id); + + +-- +-- Name: index_integration_customers_on_customer_id_and_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_integration_customers_on_customer_id_and_type ON public.integration_customers USING btree (customer_id, type); + + +-- +-- Name: index_integration_customers_on_external_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_customers_on_external_customer_id ON public.integration_customers USING btree (external_customer_id); + + +-- +-- Name: index_integration_customers_on_integration_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_customers_on_integration_id ON public.integration_customers USING btree (integration_id); + + +-- +-- Name: index_integration_customers_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_customers_on_organization_id ON public.integration_customers USING btree (organization_id); + + +-- +-- Name: index_integration_items_on_integration_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_items_on_integration_id ON public.integration_items USING btree (integration_id); + + +-- +-- Name: index_integration_items_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_items_on_organization_id ON public.integration_items USING btree (organization_id); + + +-- +-- Name: index_integration_mappings_on_integration_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_mappings_on_integration_id ON public.integration_mappings USING btree (integration_id); + + +-- +-- Name: index_integration_mappings_on_mappable; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_mappings_on_mappable ON public.integration_mappings USING btree (mappable_type, mappable_id); + + +-- +-- Name: index_integration_mappings_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_mappings_on_organization_id ON public.integration_mappings USING btree (organization_id); + + +-- +-- Name: index_integration_mappings_unique_billing_entity_id_is_not_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_integration_mappings_unique_billing_entity_id_is_not_null ON public.integration_mappings USING btree (mappable_type, mappable_id, integration_id, billing_entity_id) WHERE (billing_entity_id IS NOT NULL); + + +-- +-- Name: index_integration_mappings_unique_billing_entity_id_is_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_integration_mappings_unique_billing_entity_id_is_null ON public.integration_mappings USING btree (mappable_type, mappable_id, integration_id, organization_id) WHERE (billing_entity_id IS NULL); + + +-- +-- Name: index_integration_resources_on_integration_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_resources_on_integration_id ON public.integration_resources USING btree (integration_id); + + +-- +-- Name: index_integration_resources_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_resources_on_organization_id ON public.integration_resources USING btree (organization_id); + + +-- +-- Name: index_integration_resources_on_syncable; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integration_resources_on_syncable ON public.integration_resources USING btree (syncable_type, syncable_id); + + +-- +-- Name: index_integrations_on_code_and_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_integrations_on_code_and_organization_id ON public.integrations USING btree (code, organization_id); + + +-- +-- Name: index_integrations_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_integrations_on_organization_id ON public.integrations USING btree (organization_id); + + +-- +-- Name: index_invites_on_membership_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invites_on_membership_id ON public.invites USING btree (membership_id); + + +-- +-- Name: index_invites_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invites_on_organization_id ON public.invites USING btree (organization_id); + + +-- +-- Name: index_invites_on_token; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_invites_on_token ON public.invites USING btree (token); + + +-- +-- Name: index_invoice_custom_sections_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_custom_sections_on_organization_id ON public.invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: index_invoice_custom_sections_on_organization_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_invoice_custom_sections_on_organization_id_and_code ON public.invoice_custom_sections USING btree (organization_id, code) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_invoice_custom_sections_on_section_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_custom_sections_on_section_type ON public.invoice_custom_sections USING btree (section_type); + + +-- +-- Name: index_invoice_metadata_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_metadata_on_invoice_id ON public.invoice_metadata USING btree (invoice_id); + + +-- +-- Name: index_invoice_metadata_on_invoice_id_and_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_invoice_metadata_on_invoice_id_and_key ON public.invoice_metadata USING btree (invoice_id, key); + + +-- +-- Name: index_invoice_metadata_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_metadata_on_organization_id ON public.invoice_metadata USING btree (organization_id); + + +-- +-- Name: index_invoice_settlements_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_settlements_on_billing_entity_id ON public.invoice_settlements USING btree (billing_entity_id); + + +-- +-- Name: index_invoice_settlements_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_settlements_on_organization_id ON public.invoice_settlements USING btree (organization_id); + + +-- +-- Name: index_invoice_settlements_on_source_credit_note_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_settlements_on_source_credit_note_id ON public.invoice_settlements USING btree (source_credit_note_id); + + +-- +-- Name: index_invoice_settlements_on_source_payment_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_settlements_on_source_payment_id ON public.invoice_settlements USING btree (source_payment_id); + + +-- +-- Name: index_invoice_settlements_on_target_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_settlements_on_target_invoice_id ON public.invoice_settlements USING btree (target_invoice_id); + + +-- +-- Name: index_invoice_subscriptions_boundaries; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_subscriptions_boundaries ON public.invoice_subscriptions USING btree (subscription_id, from_datetime, to_datetime); + + +-- +-- Name: index_invoice_subscriptions_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_subscriptions_on_invoice_id ON public.invoice_subscriptions USING btree (invoice_id); + + +-- +-- Name: index_invoice_subscriptions_on_invoice_id_and_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_invoice_subscriptions_on_invoice_id_and_subscription_id ON public.invoice_subscriptions USING btree (invoice_id, subscription_id) WHERE (created_at >= '2023-11-23 00:00:00'::timestamp without time zone); + + +-- +-- Name: index_invoice_subscriptions_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_subscriptions_on_organization_id ON public.invoice_subscriptions USING btree (organization_id); + + +-- +-- Name: index_invoice_subscriptions_on_regenerated_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_subscriptions_on_regenerated_invoice_id ON public.invoice_subscriptions USING btree (regenerated_invoice_id); + + +-- +-- Name: index_invoice_subscriptions_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoice_subscriptions_on_subscription_id ON public.invoice_subscriptions USING btree (subscription_id); + + +-- +-- Name: index_invoices_by_cursor; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_by_cursor ON public.invoices USING btree (organization_id, issuing_date DESC, created_at DESC, id); + + +-- +-- Name: index_invoices_on_customer_id_and_sequential_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_invoices_on_customer_id_and_sequential_id ON public.invoices USING btree (customer_id, sequential_id); + + +-- +-- Name: index_invoices_on_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_on_number ON public.invoices USING btree (number); + + +-- +-- Name: index_invoices_on_payment_due_date; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_on_payment_due_date ON public.invoices USING btree (payment_due_date) WHERE ((status = 1) AND (payment_status <> 1) AND (payment_overdue = false) AND (payment_dispute_lost_at IS NULL)); + + +-- +-- Name: index_invoices_on_payment_method_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_on_payment_method_id ON public.invoices USING btree (payment_method_id); + + +-- +-- Name: index_invoices_on_ready_to_be_refreshed; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_on_ready_to_be_refreshed ON public.invoices USING btree (ready_to_be_refreshed) WHERE (ready_to_be_refreshed = true); + + +-- +-- Name: index_invoices_on_voided_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_on_voided_invoice_id ON public.invoices USING btree (voided_invoice_id); + + +-- +-- Name: index_invoices_payment_requests_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_payment_requests_on_invoice_id ON public.invoices_payment_requests USING btree (invoice_id); + + +-- +-- Name: index_invoices_payment_requests_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_payment_requests_on_organization_id ON public.invoices_payment_requests USING btree (organization_id); + + +-- +-- Name: index_invoices_payment_requests_on_payment_request_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_payment_requests_on_payment_request_id ON public.invoices_payment_requests USING btree (payment_request_id); + + +-- +-- Name: index_invoices_taxes_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_taxes_on_invoice_id ON public.invoices_taxes USING btree (invoice_id); + + +-- +-- Name: index_invoices_taxes_on_invoice_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_invoices_taxes_on_invoice_id_and_tax_id ON public.invoices_taxes USING btree (invoice_id, tax_id) WHERE ((tax_id IS NOT NULL) AND (created_at >= '2023-09-12 00:00:00'::timestamp without time zone)); + + +-- +-- Name: index_invoices_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_taxes_on_organization_id ON public.invoices_taxes USING btree (organization_id); + + +-- +-- Name: index_invoices_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_invoices_taxes_on_tax_id ON public.invoices_taxes USING btree (tax_id); + + +-- +-- Name: index_item_metadata_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_item_metadata_on_organization_id ON public.item_metadata USING btree (organization_id); + + +-- +-- Name: index_item_metadata_on_owner_type_and_owner_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_item_metadata_on_owner_type_and_owner_id ON public.item_metadata USING btree (owner_type, owner_id); + + +-- +-- Name: index_item_metadata_on_value; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_item_metadata_on_value ON public.item_metadata USING gin (value); + + +-- +-- Name: index_lifetime_usages_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_lifetime_usages_on_organization_id ON public.lifetime_usages USING btree (organization_id); + + +-- +-- Name: index_lifetime_usages_on_recalculate_current_usage; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_lifetime_usages_on_recalculate_current_usage ON public.lifetime_usages USING btree (recalculate_current_usage) WHERE ((deleted_at IS NULL) AND (recalculate_current_usage = true)); + + +-- +-- Name: index_lifetime_usages_on_recalculate_invoiced_usage; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_lifetime_usages_on_recalculate_invoiced_usage ON public.lifetime_usages USING btree (recalculate_invoiced_usage) WHERE ((deleted_at IS NULL) AND (recalculate_invoiced_usage = true)); + + +-- +-- Name: index_lifetime_usages_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_lifetime_usages_on_subscription_id ON public.lifetime_usages USING btree (subscription_id); + + +-- +-- Name: index_membership_roles_by_membership_and_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_membership_roles_by_membership_and_organization ON public.membership_roles USING btree (membership_id, organization_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_membership_roles_on_role_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_membership_roles_on_role_id ON public.membership_roles USING btree (role_id); + + +-- +-- Name: index_membership_roles_uniqueness; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_membership_roles_uniqueness ON public.membership_roles USING btree (membership_id, role_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_memberships_by_id_and_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_memberships_by_id_and_organization ON public.memberships USING btree (id, organization_id); + + +-- +-- Name: index_memberships_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_memberships_on_organization_id ON public.memberships USING btree (organization_id); + + +-- +-- Name: index_memberships_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_memberships_on_user_id ON public.memberships USING btree (user_id); + + +-- +-- Name: index_memberships_on_user_id_and_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_memberships_on_user_id_and_organization_id ON public.memberships USING btree (user_id, organization_id) WHERE (revoked_at IS NULL); + + +-- +-- Name: index_organizations_on_api_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_organizations_on_api_key ON public.organizations USING btree (api_key); + + +-- +-- Name: index_organizations_on_hmac_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_organizations_on_hmac_key ON public.organizations USING btree (hmac_key); + + +-- +-- Name: index_organizations_on_slug; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_organizations_on_slug ON public.organizations USING btree (slug); + + +-- +-- Name: index_password_resets_on_token; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_password_resets_on_token ON public.password_resets USING btree (token); + + +-- +-- Name: index_password_resets_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_password_resets_on_user_id ON public.password_resets USING btree (user_id); + + +-- +-- Name: index_payment_intents_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_intents_on_invoice_id ON public.payment_intents USING btree (invoice_id); + + +-- +-- Name: index_payment_intents_on_invoice_id_and_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_payment_intents_on_invoice_id_and_status ON public.payment_intents USING btree (invoice_id, status) WHERE (status = 0); + + +-- +-- Name: index_payment_intents_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_intents_on_organization_id ON public.payment_intents USING btree (organization_id); + + +-- +-- Name: index_payment_methods_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_methods_on_customer_id ON public.payment_methods USING btree (customer_id); + + +-- +-- Name: index_payment_methods_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_methods_on_organization_id ON public.payment_methods USING btree (organization_id); + + +-- +-- Name: index_payment_methods_on_payment_provider_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_methods_on_payment_provider_customer_id ON public.payment_methods USING btree (payment_provider_customer_id); + + +-- +-- Name: index_payment_methods_on_payment_provider_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_methods_on_payment_provider_id ON public.payment_methods USING btree (payment_provider_id); + + +-- +-- Name: index_payment_methods_on_provider_customer_and_provider_method; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_payment_methods_on_provider_customer_and_provider_method ON public.payment_methods USING btree (payment_provider_customer_id, provider_method_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_payment_methods_on_provider_method_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_methods_on_provider_method_type ON public.payment_methods USING btree (provider_method_type); + + +-- +-- Name: index_payment_provider_customers_on_customer_id_and_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_payment_provider_customers_on_customer_id_and_type ON public.payment_provider_customers USING btree (customer_id, type) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_payment_provider_customers_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_provider_customers_on_organization_id ON public.payment_provider_customers USING btree (organization_id); + + +-- +-- Name: index_payment_provider_customers_on_payment_provider_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_provider_customers_on_payment_provider_id ON public.payment_provider_customers USING btree (payment_provider_id); + + +-- +-- Name: index_payment_provider_customers_on_provider_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_provider_customers_on_provider_customer_id ON public.payment_provider_customers USING btree (provider_customer_id); + + +-- +-- Name: index_payment_providers_on_code_and_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_payment_providers_on_code_and_organization_id ON public.payment_providers USING btree (code, organization_id) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_payment_providers_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_providers_on_organization_id ON public.payment_providers USING btree (organization_id); + + +-- +-- Name: index_payment_receipts_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_receipts_on_billing_entity_id ON public.payment_receipts USING btree (billing_entity_id); + + +-- +-- Name: index_payment_receipts_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_receipts_on_organization_id ON public.payment_receipts USING btree (organization_id); + + +-- +-- Name: index_payment_receipts_on_payment_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_payment_receipts_on_payment_id ON public.payment_receipts USING btree (payment_id); + + +-- +-- Name: index_payment_requests_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_requests_on_customer_id ON public.payment_requests USING btree (customer_id); + + +-- +-- Name: index_payment_requests_on_dunning_campaign_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_requests_on_dunning_campaign_id ON public.payment_requests USING btree (dunning_campaign_id); + + +-- +-- Name: index_payment_requests_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payment_requests_on_organization_id ON public.payment_requests USING btree (organization_id); + + +-- +-- Name: index_payments_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_customer_id ON public.payments USING btree (customer_id); + + +-- +-- Name: index_payments_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_invoice_id ON public.payments USING btree (invoice_id); + + +-- +-- Name: index_payments_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_organization_id ON public.payments USING btree (organization_id); + + +-- +-- Name: index_payments_on_payable_id_and_payable_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_payments_on_payable_id_and_payable_type ON public.payments USING btree (payable_id, payable_type) WHERE ((payable_payment_status = ANY (ARRAY['pending'::public.payment_payable_payment_status, 'processing'::public.payment_payable_payment_status])) AND (payment_type = 'provider'::public.payment_type)); + + +-- +-- Name: index_payments_on_payable_id_and_payable_type_and_error_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_payable_id_and_payable_type_and_error_code ON public.payments USING btree (payable_id, payable_type, error_code); + + +-- +-- Name: index_payments_on_payable_type_and_payable_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_payable_type_and_payable_id ON public.payments USING btree (payable_type, payable_id); + + +-- +-- Name: index_payments_on_payment_method_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_payment_method_id ON public.payments USING btree (payment_method_id); + + +-- +-- Name: index_payments_on_payment_provider_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_payment_provider_customer_id ON public.payments USING btree (payment_provider_customer_id); + + +-- +-- Name: index_payments_on_payment_provider_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_payment_provider_id ON public.payments USING btree (payment_provider_id); + + +-- +-- Name: index_payments_on_payment_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_payments_on_payment_type ON public.payments USING btree (payment_type); + + +-- +-- Name: index_payments_on_provider_payment_id_and_payment_provider_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_payments_on_provider_payment_id_and_payment_provider_id ON public.payments USING btree (provider_payment_id, payment_provider_id) WHERE (provider_payment_id IS NOT NULL); + + +-- +-- Name: index_pending_vies_checks_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pending_vies_checks_on_billing_entity_id ON public.pending_vies_checks USING btree (billing_entity_id); + + +-- +-- Name: index_pending_vies_checks_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_pending_vies_checks_on_customer_id ON public.pending_vies_checks USING btree (customer_id); + + +-- +-- Name: index_pending_vies_checks_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pending_vies_checks_on_organization_id ON public.pending_vies_checks USING btree (organization_id); + + +-- +-- Name: index_plans_on_bill_fixed_charges_monthly; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_on_bill_fixed_charges_monthly ON public.plans USING btree (bill_fixed_charges_monthly) WHERE ((deleted_at IS NULL) AND (bill_fixed_charges_monthly IS TRUE)); + + +-- +-- Name: index_plans_on_created_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_on_created_at ON public.plans USING btree (created_at); + + +-- +-- Name: index_plans_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_on_deleted_at ON public.plans USING btree (deleted_at); + + +-- +-- Name: index_plans_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_on_organization_id ON public.plans USING btree (organization_id); + + +-- +-- Name: index_plans_on_organization_id_and_code; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_plans_on_organization_id_and_code ON public.plans USING btree (organization_id, code) WHERE ((deleted_at IS NULL) AND (parent_id IS NULL)); + + +-- +-- Name: index_plans_on_parent_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_on_parent_id ON public.plans USING btree (parent_id); + + +-- +-- Name: index_plans_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_taxes_on_organization_id ON public.plans_taxes USING btree (organization_id); + + +-- +-- Name: index_plans_taxes_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_taxes_on_plan_id ON public.plans_taxes USING btree (plan_id); + + +-- +-- Name: index_plans_taxes_on_plan_id_and_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_plans_taxes_on_plan_id_and_tax_id ON public.plans_taxes USING btree (plan_id, tax_id); + + +-- +-- Name: index_plans_taxes_on_tax_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_plans_taxes_on_tax_id ON public.plans_taxes USING btree (tax_id); + + +-- +-- Name: index_presentation_breakdowns_on_fee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_presentation_breakdowns_on_fee_id ON public.presentation_breakdowns USING btree (fee_id); + + +-- +-- Name: index_presentation_breakdowns_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_presentation_breakdowns_on_organization_id ON public.presentation_breakdowns USING btree (organization_id); + + +-- +-- Name: index_pricing_unit_usages_on_fee_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pricing_unit_usages_on_fee_id ON public.pricing_unit_usages USING btree (fee_id); + + +-- +-- Name: index_pricing_unit_usages_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pricing_unit_usages_on_organization_id ON public.pricing_unit_usages USING btree (organization_id); + + +-- +-- Name: index_pricing_unit_usages_on_pricing_unit_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pricing_unit_usages_on_pricing_unit_id ON public.pricing_unit_usages USING btree (pricing_unit_id); + + +-- +-- Name: index_pricing_units_on_code_and_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_pricing_units_on_code_and_organization_id ON public.pricing_units USING btree (code, organization_id); + + +-- +-- Name: index_pricing_units_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_pricing_units_on_organization_id ON public.pricing_units USING btree (organization_id); + + +-- +-- Name: index_quantified_events_on_billable_metric_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quantified_events_on_billable_metric_id ON public.quantified_events USING btree (billable_metric_id); + + +-- +-- Name: index_quantified_events_on_charge_filter_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quantified_events_on_charge_filter_id ON public.quantified_events USING btree (charge_filter_id); + + +-- +-- Name: index_quantified_events_on_deleted_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quantified_events_on_deleted_at ON public.quantified_events USING btree (deleted_at); + + +-- +-- Name: index_quantified_events_on_external_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quantified_events_on_external_id ON public.quantified_events USING btree (external_id); + + +-- +-- Name: index_quantified_events_on_group_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quantified_events_on_group_id ON public.quantified_events USING btree (group_id); + + +-- +-- Name: index_quantified_events_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quantified_events_on_organization_id ON public.quantified_events USING btree (organization_id); + + +-- +-- Name: index_quote_owners_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quote_owners_on_organization_id ON public.quote_owners USING btree (organization_id); + + +-- +-- Name: index_quote_owners_on_user_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quote_owners_on_user_id ON public.quote_owners USING btree (user_id); + + +-- +-- Name: index_quote_versions_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quote_versions_on_organization_id ON public.quote_versions USING btree (organization_id); + + +-- +-- Name: index_quote_versions_on_quote_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quote_versions_on_quote_id ON public.quote_versions USING btree (quote_id); + + +-- +-- Name: index_quotes_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quotes_on_customer_id ON public.quotes USING btree (customer_id); + + +-- +-- Name: index_quotes_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_quotes_on_subscription_id ON public.quotes USING btree (subscription_id); + + +-- +-- Name: index_recurring_transaction_rules_on_expiration_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recurring_transaction_rules_on_expiration_at ON public.recurring_transaction_rules USING btree (expiration_at); + + +-- +-- Name: index_recurring_transaction_rules_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recurring_transaction_rules_on_organization_id ON public.recurring_transaction_rules USING btree (organization_id); + + +-- +-- Name: index_recurring_transaction_rules_on_payment_method_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recurring_transaction_rules_on_payment_method_id ON public.recurring_transaction_rules USING btree (payment_method_id); + + +-- +-- Name: index_recurring_transaction_rules_on_started_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recurring_transaction_rules_on_started_at ON public.recurring_transaction_rules USING btree (started_at); + + +-- +-- Name: index_recurring_transaction_rules_on_wallet_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_recurring_transaction_rules_on_wallet_id ON public.recurring_transaction_rules USING btree (wallet_id); + + +-- +-- Name: index_refunds_on_credit_note_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_refunds_on_credit_note_id ON public.refunds USING btree (credit_note_id); + + +-- +-- Name: index_refunds_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_refunds_on_organization_id ON public.refunds USING btree (organization_id); + + +-- +-- Name: index_refunds_on_payment_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_refunds_on_payment_id ON public.refunds USING btree (payment_id); + + +-- +-- Name: index_refunds_on_payment_provider_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_refunds_on_payment_provider_customer_id ON public.refunds USING btree (payment_provider_customer_id); + + +-- +-- Name: index_refunds_on_payment_provider_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_refunds_on_payment_provider_id ON public.refunds USING btree (payment_provider_id); + + +-- +-- Name: index_roles_by_code_per_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_roles_by_code_per_organization ON public.roles USING btree (organization_id NULLS FIRST, code) WHERE (deleted_at IS NULL); + + +-- +-- Name: index_roles_by_unique_admin; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_roles_by_unique_admin ON public.roles USING btree (admin) WHERE (admin AND (deleted_at IS NULL)); + + +-- +-- Name: index_roles_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_roles_on_organization_id ON public.roles USING btree (organization_id); + + +-- +-- Name: index_rtr_invoice_custom_sections_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_rtr_invoice_custom_sections_unique ON public.recurring_transaction_rules_invoice_custom_sections USING btree (recurring_transaction_rule_id, invoice_custom_section_id); + + +-- +-- Name: index_search_quantified_events; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_search_quantified_events ON public.quantified_events USING btree (organization_id, external_subscription_id, billable_metric_id); + + +-- +-- Name: index_subscription_activation_rules_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscription_activation_rules_on_organization_id ON public.subscription_activation_rules USING btree (organization_id); + + +-- +-- Name: index_subscriptions_invoice_custom_sections_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_invoice_custom_sections_on_organization_id ON public.subscriptions_invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: index_subscriptions_invoice_custom_sections_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_invoice_custom_sections_on_subscription_id ON public.subscriptions_invoice_custom_sections USING btree (subscription_id); + + +-- +-- Name: index_subscriptions_invoice_custom_sections_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_subscriptions_invoice_custom_sections_unique ON public.subscriptions_invoice_custom_sections USING btree (subscription_id, invoice_custom_section_id); + + +-- +-- Name: index_subscriptions_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_billing_entity_id ON public.subscriptions USING btree (billing_entity_id); + + +-- +-- Name: index_subscriptions_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_customer_id ON public.subscriptions USING btree (customer_id); + + +-- +-- Name: index_subscriptions_on_ending_at_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_ending_at_active ON public.subscriptions USING btree (ending_at) WHERE ((status = 1) AND (ending_at IS NOT NULL)); + + +-- +-- Name: index_subscriptions_on_external_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_external_id ON public.subscriptions USING btree (external_id); + + +-- +-- Name: index_subscriptions_on_last_received_event_on; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_last_received_event_on ON public.subscriptions USING btree (last_received_event_on); + + +-- +-- Name: index_subscriptions_on_last_received_event_on_null; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_last_received_event_on_null ON public.subscriptions USING btree (id) WHERE (last_received_event_on IS NULL); + + +-- +-- Name: index_subscriptions_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_organization_id ON public.subscriptions USING btree (organization_id); + + +-- +-- Name: index_subscriptions_on_payment_method_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_payment_method_id ON public.subscriptions USING btree (payment_method_id); + + +-- +-- Name: index_subscriptions_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_plan_id ON public.subscriptions USING btree (plan_id); + + +-- +-- Name: index_subscriptions_on_previous_subscription_id_and_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_previous_subscription_id_and_status ON public.subscriptions USING btree (previous_subscription_id, status); + + +-- +-- Name: index_subscriptions_on_started_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_started_at ON public.subscriptions USING btree (started_at); + + +-- +-- Name: index_subscriptions_on_started_at_and_ending_at; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_started_at_and_ending_at ON public.subscriptions USING btree (started_at, ending_at); + + +-- +-- Name: index_subscriptions_on_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_subscriptions_on_status ON public.subscriptions USING btree (status); + + +-- +-- Name: index_taxes_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_taxes_on_organization_id ON public.taxes USING btree (organization_id); + + +-- +-- Name: index_uniq_invoice_subscriptions_on_charges_from_to_datetime; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_uniq_invoice_subscriptions_on_charges_from_to_datetime ON public.invoice_subscriptions USING btree (subscription_id, charges_from_datetime, charges_to_datetime) WHERE ((created_at >= '2023-06-09 00:00:00'::timestamp without time zone) AND (recurring IS TRUE) AND (regenerated_invoice_id IS NULL)); + + +-- +-- Name: index_uniq_invoice_subscriptions_on_fixed_charges_boundaries; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_uniq_invoice_subscriptions_on_fixed_charges_boundaries ON public.invoice_subscriptions USING btree (subscription_id, fixed_charges_from_datetime, fixed_charges_to_datetime) WHERE ((fixed_charges_from_datetime IS NOT NULL) AND (recurring IS TRUE) AND (regenerated_invoice_id IS NULL)); + + +-- +-- Name: index_uniq_wallet_code_per_customer; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_uniq_wallet_code_per_customer ON public.wallets USING btree (customer_id, code) WHERE (status = 0); + + +-- +-- Name: index_unique_applied_to_organization_per_organization; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_applied_to_organization_per_organization ON public.dunning_campaigns USING btree (organization_id) WHERE ((applied_to_organization = true) AND (deleted_at IS NULL)); + + +-- +-- Name: index_unique_quote_owners_on_quote_user; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_quote_owners_on_quote_user ON public.quote_owners USING btree (quote_id, user_id); + + +-- +-- Name: index_unique_quote_versions_on_quote_active_status; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_quote_versions_on_quote_active_status ON public.quote_versions USING btree (quote_id) WHERE (status = ANY (ARRAY['draft'::public.quote_status, 'approved'::public.quote_status])); + + +-- +-- Name: index_unique_quote_versions_on_quote_sequential_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_quote_versions_on_quote_sequential_id ON public.quote_versions USING btree (quote_id, sequential_id); + + +-- +-- Name: index_unique_quote_versions_on_share_token; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_quote_versions_on_share_token ON public.quote_versions USING btree (share_token); + + +-- +-- Name: index_unique_quotes_on_organization_number; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_quotes_on_organization_number ON public.quotes USING btree (organization_id, number); + + +-- +-- Name: index_unique_quotes_on_organization_sequential_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_quotes_on_organization_sequential_id ON public.quotes USING btree (organization_id, sequential_id); + + +-- +-- Name: index_unique_starting_invoice_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_starting_invoice_subscription ON public.invoice_subscriptions USING btree (subscription_id, invoicing_reason) WHERE ((invoicing_reason = 'subscription_starting'::public.subscription_invoicing_reason) AND (regenerated_invoice_id IS NULL)); + + +-- +-- Name: index_unique_terminating_invoice_subscription; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_terminating_invoice_subscription ON public.invoice_subscriptions USING btree (subscription_id, invoicing_reason) WHERE ((invoicing_reason = 'subscription_terminating'::public.subscription_invoicing_reason) AND (regenerated_invoice_id IS NULL)); + + +-- +-- Name: index_unique_transaction_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_unique_transaction_id ON public.events USING btree (organization_id, external_subscription_id, transaction_id); + + +-- +-- Name: index_usage_monitoring_alert_thresholds_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_alert_thresholds_on_organization_id ON public.usage_monitoring_alert_thresholds USING btree (organization_id); + + +-- +-- Name: index_usage_monitoring_alerts_on_billable_metric_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_alerts_on_billable_metric_id ON public.usage_monitoring_alerts USING btree (billable_metric_id); + + +-- +-- Name: index_usage_monitoring_alerts_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_alerts_on_organization_id ON public.usage_monitoring_alerts USING btree (organization_id); + + +-- +-- Name: index_usage_monitoring_alerts_on_subscription_external_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_alerts_on_subscription_external_id ON public.usage_monitoring_alerts USING btree (subscription_external_id); + + +-- +-- Name: index_usage_monitoring_alerts_on_wallet_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_alerts_on_wallet_id ON public.usage_monitoring_alerts USING btree (wallet_id); + + +-- +-- Name: index_usage_monitoring_triggered_alerts_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_triggered_alerts_on_organization_id ON public.usage_monitoring_triggered_alerts USING btree (organization_id); + + +-- +-- Name: index_usage_monitoring_triggered_alerts_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_triggered_alerts_on_subscription_id ON public.usage_monitoring_triggered_alerts USING btree (subscription_id); + + +-- +-- Name: index_usage_monitoring_triggered_alerts_on_wallet_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_monitoring_triggered_alerts_on_wallet_id ON public.usage_monitoring_triggered_alerts USING btree (wallet_id); + + +-- +-- Name: index_usage_thresholds_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_thresholds_on_organization_id ON public.usage_thresholds USING btree (organization_id); + + +-- +-- Name: index_usage_thresholds_on_plan_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_thresholds_on_plan_id ON public.usage_thresholds USING btree (plan_id); + + +-- +-- Name: index_usage_thresholds_on_subscription_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_usage_thresholds_on_subscription_id ON public.usage_thresholds USING btree (subscription_id); + + +-- +-- Name: index_user_devices_on_user_id_and_fingerprint; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_user_devices_on_user_id_and_fingerprint ON public.user_devices USING btree (user_id, fingerprint); + + +-- +-- Name: index_versions_on_item_type_and_item_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_versions_on_item_type_and_item_id ON public.versions USING btree (item_type, item_id); + + +-- +-- Name: index_wallet_targets_on_billable_metric_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_targets_on_billable_metric_id ON public.wallet_targets USING btree (billable_metric_id); + + +-- +-- Name: index_wallet_targets_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_targets_on_organization_id ON public.wallet_targets USING btree (organization_id); + + +-- +-- Name: index_wallet_targets_on_wallet_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_targets_on_wallet_id ON public.wallet_targets USING btree (wallet_id); + + +-- +-- Name: index_wallet_transaction_consumptions_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_transaction_consumptions_on_organization_id ON public.wallet_transaction_consumptions USING btree (organization_id); + + +-- +-- Name: index_wallet_transactions_on_credit_note_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_transactions_on_credit_note_id ON public.wallet_transactions USING btree (credit_note_id); + + +-- +-- Name: index_wallet_transactions_on_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_transactions_on_invoice_id ON public.wallet_transactions USING btree (invoice_id); + + +-- +-- Name: index_wallet_transactions_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_transactions_on_organization_id ON public.wallet_transactions USING btree (organization_id); + + +-- +-- Name: index_wallet_transactions_on_payment_method_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_transactions_on_payment_method_id ON public.wallet_transactions USING btree (payment_method_id); + + +-- +-- Name: index_wallet_transactions_on_voided_invoice_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_transactions_on_voided_invoice_id ON public.wallet_transactions USING btree (voided_invoice_id); + + +-- +-- Name: index_wallet_transactions_on_wallet_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallet_transactions_on_wallet_id ON public.wallet_transactions USING btree (wallet_id); + + +-- +-- Name: index_wallets_invoice_custom_sections_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_invoice_custom_sections_on_organization_id ON public.wallets_invoice_custom_sections USING btree (organization_id); + + +-- +-- Name: index_wallets_invoice_custom_sections_on_wallet_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_invoice_custom_sections_on_wallet_id ON public.wallets_invoice_custom_sections USING btree (wallet_id); + + +-- +-- Name: index_wallets_invoice_custom_sections_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_wallets_invoice_custom_sections_unique ON public.wallets_invoice_custom_sections USING btree (wallet_id, invoice_custom_section_id); + + +-- +-- Name: index_wallets_on_billing_entity_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_on_billing_entity_id ON public.wallets USING btree (billing_entity_id); + + +-- +-- Name: index_wallets_on_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_on_customer_id ON public.wallets USING btree (customer_id); + + +-- +-- Name: index_wallets_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_on_organization_id ON public.wallets USING btree (organization_id); + + +-- +-- Name: index_wallets_on_organization_id_and_customer_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_on_organization_id_and_customer_id ON public.wallets USING btree (organization_id, customer_id); + + +-- +-- Name: index_wallets_on_payment_method_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_on_payment_method_id ON public.wallets USING btree (payment_method_id); + + +-- +-- Name: index_wallets_on_ready_to_be_refreshed; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_wallets_on_ready_to_be_refreshed ON public.wallets USING btree (ready_to_be_refreshed) WHERE ready_to_be_refreshed; + + +-- +-- Name: index_webhook_endpoints_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhook_endpoints_on_organization_id ON public.webhook_endpoints USING btree (organization_id); + + +-- +-- Name: index_webhook_endpoints_on_webhook_url_and_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_webhook_endpoints_on_webhook_url_and_organization_id ON public.webhook_endpoints USING btree (webhook_url, organization_id); + + +-- +-- Name: index_webhooks_for_query; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhooks_for_query ON public.webhooks USING btree (organization_id, webhook_endpoint_id, webhook_type, updated_at); + + +-- +-- Name: index_webhooks_on_endpoint_and_timestamps; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhooks_on_endpoint_and_timestamps ON public.webhooks USING btree (webhook_endpoint_id, updated_at, created_at); + + +-- +-- Name: index_webhooks_on_endpoint_status_and_timestamps; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhooks_on_endpoint_status_and_timestamps ON public.webhooks USING btree (webhook_endpoint_id, status, updated_at); + + +-- +-- Name: index_webhooks_on_object_type_and_object_id_and_webhook_type; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhooks_on_object_type_and_object_id_and_webhook_type ON public.webhooks USING btree (object_type, object_id, webhook_type); + + +-- +-- Name: index_webhooks_on_organization_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhooks_on_organization_id ON public.webhooks USING btree (organization_id); + + +-- +-- Name: index_webhooks_on_updated_at_for_cleanup; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhooks_on_updated_at_for_cleanup ON public.webhooks USING btree (updated_at) INCLUDE (id); + + +-- +-- Name: index_webhooks_on_webhook_endpoint_id; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX index_webhooks_on_webhook_endpoint_id ON public.webhooks USING btree (webhook_endpoint_id); + + +-- +-- Name: index_wt_invoice_custom_sections_unique; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX index_wt_invoice_custom_sections_unique ON public.wallet_transactions_invoice_custom_sections USING btree (wallet_transaction_id, invoice_custom_section_id); + + +-- +-- Name: unique_default_payment_method_per_customer; Type: INDEX; Schema: public; Owner: - +-- + +CREATE UNIQUE INDEX unique_default_payment_method_per_customer ON public.payment_methods USING btree (customer_id) WHERE ((is_default = true) AND (deleted_at IS NULL)); + + +-- +-- Name: enriched_events_default_event_id_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.index_enriched_events_on_event_id ATTACH PARTITION public.enriched_events_default_event_id_idx; + + +-- +-- Name: enriched_events_default_organization_id_external_subscript_idx1; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.idx_unique_on_enriched_events ATTACH PARTITION public.enriched_events_default_organization_id_external_subscript_idx1; + + +-- +-- Name: enriched_events_default_organization_id_external_subscripti_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.idx_lookup_on_enriched_events ATTACH PARTITION public.enriched_events_default_organization_id_external_subscripti_idx; + + +-- +-- Name: enriched_events_default_organization_id_subscription_id_cha_idx; Type: INDEX ATTACH; Schema: public; Owner: - +-- + +ALTER INDEX public.idx_billing_on_enriched_events ATTACH PARTITION public.enriched_events_default_organization_id_subscription_id_cha_idx; + + +-- +-- Name: billable_metrics_grouped_charges _RETURN; Type: RULE; Schema: public; Owner: - +-- + +CREATE OR REPLACE VIEW public.billable_metrics_grouped_charges AS + SELECT billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id AS charge_id, + charges.pay_in_advance, + CASE + WHEN (charges.charge_model = 0) THEN (charges.properties -> 'grouped_by'::text) + ELSE NULL::jsonb + END AS grouped_by, + charge_filters.id AS charge_filter_id, + json_object_agg(billable_metric_filters.key, COALESCE(charge_filter_values."values", '{}'::character varying[]) ORDER BY billable_metric_filters.key) FILTER (WHERE (billable_metric_filters.key IS NOT NULL)) AS filters, + CASE + WHEN (charges.charge_model = 0) THEN (charge_filters.properties -> 'grouped_by'::text) + ELSE NULL::jsonb + END AS filters_grouped_by + FROM ((((public.billable_metrics + JOIN public.charges ON ((charges.billable_metric_id = billable_metrics.id))) + LEFT JOIN public.charge_filters ON ((charge_filters.charge_id = charges.id))) + LEFT JOIN public.charge_filter_values ON ((charge_filter_values.charge_filter_id = charge_filters.id))) + LEFT JOIN public.billable_metric_filters ON ((charge_filter_values.billable_metric_filter_id = billable_metric_filters.id))) + WHERE ((billable_metrics.deleted_at IS NULL) AND (charges.deleted_at IS NULL) AND (charge_filters.deleted_at IS NULL) AND (charge_filter_values.deleted_at IS NULL) AND (billable_metric_filters.deleted_at IS NULL)) + GROUP BY billable_metrics.organization_id, billable_metrics.code, billable_metrics.aggregation_type, billable_metrics.field_name, charges.plan_id, charges.id, charge_filters.id; + + +-- +-- Name: flat_filters _RETURN; Type: RULE; Schema: public; Owner: - +-- + +CREATE OR REPLACE VIEW public.flat_filters AS + SELECT billable_metrics.organization_id, + billable_metrics.code AS billable_metric_code, + charges.plan_id, + charges.id AS charge_id, + charges.updated_at AS charge_updated_at, + charge_filters.id AS charge_filter_id, + charge_filters.updated_at AS charge_filter_updated_at, + CASE + WHEN (charge_filters.id IS NOT NULL) THEN jsonb_object_agg(COALESCE(billable_metric_filters.key, ''::character varying), + CASE + WHEN ((charge_filter_values."values")::text[] && ARRAY['__ALL_FILTER_VALUES__'::text]) THEN billable_metric_filters."values" + ELSE charge_filter_values."values" + END) + ELSE NULL::jsonb + END AS filters, + COALESCE(charge_filters.properties, charges.properties) AS properties, + (COALESCE(charge_filters.properties, charges.properties) -> 'pricing_group_keys'::text) AS pricing_group_keys, + charges.pay_in_advance, + charges.accepts_target_wallet + FROM ((((public.billable_metrics + JOIN public.charges ON ((charges.billable_metric_id = billable_metrics.id))) + LEFT JOIN public.charge_filters ON ((charge_filters.charge_id = charges.id))) + LEFT JOIN public.charge_filter_values ON ((charge_filter_values.charge_filter_id = charge_filters.id))) + LEFT JOIN public.billable_metric_filters ON ((billable_metric_filters.id = charge_filter_values.billable_metric_filter_id))) + WHERE ((billable_metrics.deleted_at IS NULL) AND (charges.deleted_at IS NULL) AND (charge_filters.deleted_at IS NULL) AND (charge_filter_values.deleted_at IS NULL) AND (billable_metric_filters.deleted_at IS NULL)) + GROUP BY billable_metrics.organization_id, billable_metrics.code, charges.plan_id, charges.id, charges.updated_at, charge_filters.id, charge_filters.updated_at; + + +-- +-- Name: payment_receipts before_payment_receipt_insert; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER before_payment_receipt_insert BEFORE INSERT ON public.payment_receipts FOR EACH ROW EXECUTE FUNCTION public.set_payment_receipt_number(); + + +-- +-- Name: roles ensure_consistency; Type: TRIGGER; Schema: public; Owner: - +-- + +CREATE TRIGGER ensure_consistency BEFORE UPDATE ON public.roles FOR EACH ROW EXECUTE FUNCTION public.ensure_role_consistency(); + + +-- +-- Name: payment_methods fk_rails_00e7a45b0b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_methods + ADD CONSTRAINT fk_rails_00e7a45b0b FOREIGN KEY (payment_provider_id) REFERENCES public.payment_providers(id); + + +-- +-- Name: pending_vies_checks fk_rails_019e2289e5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pending_vies_checks + ADD CONSTRAINT fk_rails_019e2289e5 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: wallet_transactions fk_rails_01a4c0c7db; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_01a4c0c7db FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: invoice_settlements fk_rails_04388258ff; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_settlements + ADD CONSTRAINT fk_rails_04388258ff FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: invoices fk_rails_06b7046ec3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT fk_rails_06b7046ec3 FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: billing_entities_taxes fk_rails_07b21049f2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_taxes + ADD CONSTRAINT fk_rails_07b21049f2 FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: fees fk_rails_085d1cc97b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_085d1cc97b FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + +-- +-- Name: enriched_store_subscription_migrations fk_rails_08d9dce6d1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enriched_store_subscription_migrations + ADD CONSTRAINT fk_rails_08d9dce6d1 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: add_ons_taxes fk_rails_08dfe87131; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.add_ons_taxes + ADD CONSTRAINT fk_rails_08dfe87131 FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); + + +-- +-- Name: fees fk_rails_0934890b24; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_0934890b24 FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: usage_monitoring_triggered_alerts fk_rails_0baa7bd751; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_triggered_alerts + ADD CONSTRAINT fk_rails_0baa7bd751 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: coupon_targets fk_rails_0bb6dcc01f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT fk_rails_0bb6dcc01f FOREIGN KEY (coupon_id) REFERENCES public.coupons(id); + + +-- +-- Name: entitlement_entitlements fk_rails_0c9773c34d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlements + ADD CONSTRAINT fk_rails_0c9773c34d FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: customers_taxes fk_rails_0d2be3d72c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_taxes + ADD CONSTRAINT fk_rails_0d2be3d72c FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: invoices fk_rails_0d349e632f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT fk_rails_0d349e632f FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: ai_conversations fk_rails_0da056ac92; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ai_conversations + ADD CONSTRAINT fk_rails_0da056ac92 FOREIGN KEY (membership_id) REFERENCES public.memberships(id); + + +-- +-- Name: integration_customers fk_rails_0e464363cb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_customers + ADD CONSTRAINT fk_rails_0e464363cb FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: integration_mappings fk_rails_0f762162b0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_mappings + ADD CONSTRAINT fk_rails_0f762162b0 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: usage_monitoring_triggered_alerts fk_rails_0f807322b1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_triggered_alerts + ADD CONSTRAINT fk_rails_0f807322b1 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: fees_taxes fk_rails_103e187859; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees_taxes + ADD CONSTRAINT fk_rails_103e187859 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: applied_invoice_custom_sections fk_rails_10428ecad2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_invoice_custom_sections + ADD CONSTRAINT fk_rails_10428ecad2 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: quote_versions fk_rails_10ee148d0d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_versions + ADD CONSTRAINT fk_rails_10ee148d0d FOREIGN KEY (quote_id) REFERENCES public.quotes(id); + + +-- +-- Name: entitlement_subscription_feature_removals fk_rails_123667657c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_subscription_feature_removals + ADD CONSTRAINT fk_rails_123667657c FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: daily_usages fk_rails_12d29bc654; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT fk_rails_12d29bc654 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: invoices_taxes fk_rails_142809fee1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_taxes + ADD CONSTRAINT fk_rails_142809fee1 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: coupon_targets fk_rails_1454058c96; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT fk_rails_1454058c96 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + +-- +-- Name: invoice_subscriptions fk_rails_150139409e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT fk_rails_150139409e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: entitlement_entitlements fk_rails_173327f0dc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlements + ADD CONSTRAINT fk_rails_173327f0dc FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: quote_owners fk_rails_1811b32fcd; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_owners + ADD CONSTRAINT fk_rails_1811b32fcd FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: coupon_targets fk_rails_189f2a3949; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT fk_rails_189f2a3949 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: customer_metadata fk_rails_195153290d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customer_metadata + ADD CONSTRAINT fk_rails_195153290d FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: billing_entities_invoice_custom_sections fk_rails_19c47827ba; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_invoice_custom_sections + ADD CONSTRAINT fk_rails_19c47827ba FOREIGN KEY (invoice_custom_section_id) REFERENCES public.invoice_custom_sections(id); + + +-- +-- Name: applied_usage_thresholds fk_rails_1d112bf8a0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_usage_thresholds + ADD CONSTRAINT fk_rails_1d112bf8a0 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: credits fk_rails_1db0057d9b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_1db0057d9b FOREIGN KEY (applied_coupon_id) REFERENCES public.applied_coupons(id); + + +-- +-- Name: webhooks fk_rails_20cc0de4c7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.webhooks + ADD CONSTRAINT fk_rails_20cc0de4c7 FOREIGN KEY (webhook_endpoint_id) REFERENCES public.webhook_endpoints(id); + + +-- +-- Name: customers_invoice_custom_sections fk_rails_20f157fa49; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_invoice_custom_sections + ADD CONSTRAINT fk_rails_20f157fa49 FOREIGN KEY (invoice_custom_section_id) REFERENCES public.invoice_custom_sections(id); + + +-- +-- Name: plans fk_rails_216ac8a975; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.plans + ADD CONSTRAINT fk_rails_216ac8a975 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: webhook_endpoints fk_rails_21808fa528; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.webhook_endpoints + ADD CONSTRAINT fk_rails_21808fa528 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: cached_aggregations fk_rails_21eb389927; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.cached_aggregations + ADD CONSTRAINT fk_rails_21eb389927 FOREIGN KEY (group_id) REFERENCES public.groups(id); + + +-- +-- Name: commitments_taxes fk_rails_2259c88f26; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.commitments_taxes + ADD CONSTRAINT fk_rails_2259c88f26 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: invoices_taxes fk_rails_22af6c6d28; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_taxes + ADD CONSTRAINT fk_rails_22af6c6d28 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: applied_pricing_units fk_rails_22bb2c0770; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_pricing_units + ADD CONSTRAINT fk_rails_22bb2c0770 FOREIGN KEY (pricing_unit_id) REFERENCES public.pricing_units(id); + + +-- +-- Name: taxes fk_rails_23975f5a47; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.taxes + ADD CONSTRAINT fk_rails_23975f5a47 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: invoices_payment_requests fk_rails_2496c105ed; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_payment_requests + ADD CONSTRAINT fk_rails_2496c105ed FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: credit_notes_taxes fk_rails_25232a0ec3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes_taxes + ADD CONSTRAINT fk_rails_25232a0ec3 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + +-- +-- Name: refunds fk_rails_25267b0e17; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_25267b0e17 FOREIGN KEY (payment_id) REFERENCES public.payments(id); + + +-- +-- Name: invoice_settlements fk_rails_2539663124; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_settlements + ADD CONSTRAINT fk_rails_2539663124 FOREIGN KEY (source_payment_id) REFERENCES public.payments(id); + + +-- +-- Name: adjusted_fees fk_rails_2561c00887; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_2561c00887 FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + +-- +-- Name: fees fk_rails_257af22645; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_257af22645 FOREIGN KEY (true_up_parent_fee_id) REFERENCES public.fees(id); + + +-- +-- Name: billing_entities_taxes fk_rails_268c288aaa; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_taxes + ADD CONSTRAINT fk_rails_268c288aaa FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: payment_providers fk_rails_26be2f764d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_providers + ADD CONSTRAINT fk_rails_26be2f764d FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: charge_filters fk_rails_27b55b8574; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charge_filters + ADD CONSTRAINT fk_rails_27b55b8574 FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + +-- +-- Name: wallets fk_rails_28077d4aa2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT fk_rails_28077d4aa2 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: usage_thresholds fk_rails_2908dd8de5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_thresholds + ADD CONSTRAINT fk_rails_2908dd8de5 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: wallets fk_rails_2b35eef34b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT fk_rails_2b35eef34b FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: ai_conversations fk_rails_2c06a74f41; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.ai_conversations + ADD CONSTRAINT fk_rails_2c06a74f41 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: refunds fk_rails_2dc6171f57; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_2dc6171f57 FOREIGN KEY (payment_provider_id) REFERENCES public.payment_providers(id); + + +-- +-- Name: fees fk_rails_2ea4db3a4c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_2ea4db3a4c FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: payment_requests fk_rails_2fb2147151; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT fk_rails_2fb2147151 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: credits fk_rails_2fd7ee65e6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_2fd7ee65e6 FOREIGN KEY (progressive_billing_invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: invoice_settlements fk_rails_2ffeff5323; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_settlements + ADD CONSTRAINT fk_rails_2ffeff5323 FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: wallets_invoice_custom_sections fk_rails_3092f5f2e0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets_invoice_custom_sections + ADD CONSTRAINT fk_rails_3092f5f2e0 FOREIGN KEY (invoice_custom_section_id) REFERENCES public.invoice_custom_sections(id); + + +-- +-- Name: invoices fk_rails_309d3a4412; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT fk_rails_309d3a4412 FOREIGN KEY (payment_method_id) REFERENCES public.payment_methods(id); + + +-- +-- Name: credits fk_rails_310fcb3585; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_310fcb3585 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + +-- +-- Name: payment_requests fk_rails_32600e5a72; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT fk_rails_32600e5a72 FOREIGN KEY (dunning_campaign_id) REFERENCES public.dunning_campaigns(id); + + +-- +-- Name: customers_taxes fk_rails_33d169382f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_taxes + ADD CONSTRAINT fk_rails_33d169382f FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: lifetime_usages fk_rails_348acbd245; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lifetime_usages + ADD CONSTRAINT fk_rails_348acbd245 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: fees fk_rails_34ab152115; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_34ab152115 FOREIGN KEY (applied_add_on_id) REFERENCES public.applied_add_ons(id); + + +-- +-- Name: wallets_invoice_custom_sections fk_rails_34b4e489e6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets_invoice_custom_sections + ADD CONSTRAINT fk_rails_34b4e489e6 FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: groups fk_rails_34b5ee1894; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.groups + ADD CONSTRAINT fk_rails_34b5ee1894 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id) ON DELETE CASCADE; + + +-- +-- Name: charge_filter_values fk_rails_3640b4a66a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charge_filter_values + ADD CONSTRAINT fk_rails_3640b4a66a FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: subscriptions fk_rails_364213cc3e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT fk_rails_364213cc3e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: inbound_webhooks fk_rails_36cda06530; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.inbound_webhooks + ADD CONSTRAINT fk_rails_36cda06530 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: quantified_events fk_rails_3926855f12; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quantified_events + ADD CONSTRAINT fk_rails_3926855f12 FOREIGN KEY (group_id) REFERENCES public.groups(id); + + +-- +-- Name: invoices fk_rails_3a303bf667; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices + ADD CONSTRAINT fk_rails_3a303bf667 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: payments fk_rails_3ab959bfc4; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_rails_3ab959bfc4 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: group_properties fk_rails_3acf9e789c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_properties + ADD CONSTRAINT fk_rails_3acf9e789c FOREIGN KEY (charge_id) REFERENCES public.charges(id) ON DELETE CASCADE; + + +-- +-- Name: invoice_settlements fk_rails_3b7dad8e9c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_settlements + ADD CONSTRAINT fk_rails_3b7dad8e9c FOREIGN KEY (target_invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: wallet_transaction_consumptions fk_rails_3c786cd3e3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transaction_consumptions + ADD CONSTRAINT fk_rails_3c786cd3e3 FOREIGN KEY (inbound_wallet_transaction_id) REFERENCES public.wallet_transactions(id); + + +-- +-- Name: daily_usages fk_rails_3c7c3920c0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT fk_rails_3c7c3920c0 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: charges fk_rails_3cfe1d68d7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges + ADD CONSTRAINT fk_rails_3cfe1d68d7 FOREIGN KEY (parent_id) REFERENCES public.charges(id); + + +-- +-- Name: integration_collection_mappings fk_rails_3d568ff9de; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_collection_mappings + ADD CONSTRAINT fk_rails_3d568ff9de FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + +-- +-- Name: entitlement_privileges fk_rails_3e4df02771; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_privileges + ADD CONSTRAINT fk_rails_3e4df02771 FOREIGN KEY (entitlement_feature_id) REFERENCES public.entitlement_features(id); + + +-- +-- Name: invoices_payment_requests fk_rails_3ec3563cf3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_payment_requests + ADD CONSTRAINT fk_rails_3ec3563cf3 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: refunds fk_rails_3f7be5debc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_3f7be5debc FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + +-- +-- Name: charges_taxes fk_rails_3ff27d7624; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges_taxes + ADD CONSTRAINT fk_rails_3ff27d7624 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: credit_notes fk_rails_41088c7d45; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes + ADD CONSTRAINT fk_rails_41088c7d45 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: credit_notes fk_rails_4117574b51; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes + ADD CONSTRAINT fk_rails_4117574b51 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: quote_owners fk_rails_45230f8485; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_owners + ADD CONSTRAINT fk_rails_45230f8485 FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: integration_items fk_rails_47d8081062; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_items + ADD CONSTRAINT fk_rails_47d8081062 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: webhooks fk_rails_49212d501e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.webhooks + ADD CONSTRAINT fk_rails_49212d501e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: charges fk_rails_4934f27a06; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges + ADD CONSTRAINT fk_rails_4934f27a06 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: recurring_transaction_rules_invoice_custom_sections fk_rails_49fcc221b0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules_invoice_custom_sections + ADD CONSTRAINT fk_rails_49fcc221b0 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: billing_entities fk_rails_4aa58496c3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities + ADD CONSTRAINT fk_rails_4aa58496c3 FOREIGN KEY (applied_dunning_campaign_id) REFERENCES public.dunning_campaigns(id) ON DELETE SET NULL; + + +-- +-- Name: wallets fk_rails_4ff087c52e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT fk_rails_4ff087c52e FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id) NOT VALID; + + +-- +-- Name: payment_provider_customers fk_rails_50d46d3679; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_provider_customers + ADD CONSTRAINT fk_rails_50d46d3679 FOREIGN KEY (payment_provider_id) REFERENCES public.payment_providers(id); + + +-- +-- Name: billable_metric_filters fk_rails_51077e7c0e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billable_metric_filters + ADD CONSTRAINT fk_rails_51077e7c0e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: commitments fk_rails_51ac39a0c6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.commitments + ADD CONSTRAINT fk_rails_51ac39a0c6 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: credits fk_rails_521b5240ed; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_521b5240ed FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: recurring_transaction_rules fk_rails_52370612ae; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules + ADD CONSTRAINT fk_rails_52370612ae FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: password_resets fk_rails_526379cd99; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.password_resets + ADD CONSTRAINT fk_rails_526379cd99 FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: applied_usage_thresholds fk_rails_52b72c9b0e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_usage_thresholds + ADD CONSTRAINT fk_rails_52b72c9b0e FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: entitlement_entitlement_values fk_rails_533b639bac; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlement_values + ADD CONSTRAINT fk_rails_533b639bac FOREIGN KEY (entitlement_entitlement_id) REFERENCES public.entitlement_entitlements(id); + + +-- +-- Name: credits fk_rails_5628a713de; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credits + ADD CONSTRAINT fk_rails_5628a713de FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: subscriptions fk_rails_56b3626631; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT fk_rails_56b3626631 FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id) NOT VALID; + + +-- +-- Name: charges_taxes fk_rails_56b7167125; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges_taxes + ADD CONSTRAINT fk_rails_56b7167125 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: customers fk_rails_58234c715e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers + ADD CONSTRAINT fk_rails_58234c715e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: data_exports fk_rails_5a43da571b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT fk_rails_5a43da571b FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: invoice_settlements fk_rails_5a4b906a16; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_settlements + ADD CONSTRAINT fk_rails_5a4b906a16 FOREIGN KEY (source_credit_note_id) REFERENCES public.credit_notes(id); + + +-- +-- Name: add_ons_taxes fk_rails_5ade8984b1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.add_ons_taxes + ADD CONSTRAINT fk_rails_5ade8984b1 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: quotes fk_rails_5bb40a7bae; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quotes + ADD CONSTRAINT fk_rails_5bb40a7bae FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: error_details fk_rails_5c21eece29; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.error_details + ADD CONSTRAINT fk_rails_5c21eece29 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: payment_receipts fk_rails_5c2e0b6d34; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_receipts + ADD CONSTRAINT fk_rails_5c2e0b6d34 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: credit_note_items fk_rails_5cb2f24c3d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_note_items + ADD CONSTRAINT fk_rails_5cb2f24c3d FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: credit_notes fk_rails_5cb67dee79; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes + ADD CONSTRAINT fk_rails_5cb67dee79 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: fixed_charges fk_rails_5e06da3c18; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges + ADD CONSTRAINT fk_rails_5e06da3c18 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: recurring_transaction_rules fk_rails_5efea6fe31; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules + ADD CONSTRAINT fk_rails_5efea6fe31 FOREIGN KEY (payment_method_id) REFERENCES public.payment_methods(id) NOT VALID; + + +-- +-- Name: fees fk_rails_6023b3f2dd; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_6023b3f2dd FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); + + +-- +-- Name: credit_notes_taxes fk_rails_626209b8d2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes_taxes + ADD CONSTRAINT fk_rails_626209b8d2 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: payments fk_rails_62d18ea517; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_rails_62d18ea517 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: invoice_metadata fk_rails_63683837a2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_metadata + ADD CONSTRAINT fk_rails_63683837a2 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: applied_invoice_custom_sections fk_rails_63ac282e70; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_invoice_custom_sections + ADD CONSTRAINT fk_rails_63ac282e70 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: pricing_unit_usages fk_rails_63ca8e33c5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_unit_usages + ADD CONSTRAINT fk_rails_63ca8e33c5 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: subscriptions fk_rails_63d3df128b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT fk_rails_63d3df128b FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: memberships fk_rails_64267aab58; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.memberships + ADD CONSTRAINT fk_rails_64267aab58 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: membership_roles fk_rails_65053e240e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.membership_roles + ADD CONSTRAINT fk_rails_65053e240e FOREIGN KEY (role_id) REFERENCES public.roles(id); + + +-- +-- Name: integration_collection_mappings fk_rails_650fccfc41; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_collection_mappings + ADD CONSTRAINT fk_rails_650fccfc41 FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id) ON DELETE CASCADE; + + +-- +-- Name: billing_entities_taxes fk_rails_651eadaaa4; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_taxes + ADD CONSTRAINT fk_rails_651eadaaa4 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: fixed_charges_taxes fk_rails_665ae33492; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges_taxes + ADD CONSTRAINT fk_rails_665ae33492 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: subscriptions fk_rails_66eb6b32c1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT fk_rails_66eb6b32c1 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: integration_resources fk_rails_67d4eb3c92; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_resources + ADD CONSTRAINT fk_rails_67d4eb3c92 FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + +-- +-- Name: customers_invoice_custom_sections fk_rails_68754484c0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_invoice_custom_sections + ADD CONSTRAINT fk_rails_68754484c0 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: billing_entities_invoice_custom_sections fk_rails_699cd1384f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_invoice_custom_sections + ADD CONSTRAINT fk_rails_699cd1384f FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: dunning_campaigns fk_rails_6c720a8ccd; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dunning_campaigns + ADD CONSTRAINT fk_rails_6c720a8ccd FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: adjusted_fees fk_rails_6d465e6b10; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_6d465e6b10 FOREIGN KEY (group_id) REFERENCES public.groups(id); + + +-- +-- Name: invoices_taxes fk_rails_6e148ccbb1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_taxes + ADD CONSTRAINT fk_rails_6e148ccbb1 FOREIGN KEY (tax_id) REFERENCES public.taxes(id) ON DELETE SET NULL; + + +-- +-- Name: pending_vies_checks fk_rails_6e238f3bfc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pending_vies_checks + ADD CONSTRAINT fk_rails_6e238f3bfc FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: subscriptions_invoice_custom_sections fk_rails_6eb8abe6cb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions_invoice_custom_sections + ADD CONSTRAINT fk_rails_6eb8abe6cb FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: usage_monitoring_alert_thresholds fk_rails_710f37148d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_alert_thresholds + ADD CONSTRAINT fk_rails_710f37148d FOREIGN KEY (usage_monitoring_alert_id) REFERENCES public.usage_monitoring_alerts(id); + + +-- +-- Name: data_exports fk_rails_73d83e23b6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_exports + ADD CONSTRAINT fk_rails_73d83e23b6 FOREIGN KEY (membership_id) REFERENCES public.memberships(id); + + +-- +-- Name: fees_taxes fk_rails_745b4ca7dd; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees_taxes + ADD CONSTRAINT fk_rails_745b4ca7dd FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + +-- +-- Name: fixed_charge_events fk_rails_752665cc51; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charge_events + ADD CONSTRAINT fk_rails_752665cc51 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: refunds fk_rails_75577c354e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_75577c354e FOREIGN KEY (payment_provider_customer_id) REFERENCES public.payment_provider_customers(id); + + +-- +-- Name: integrations fk_rails_755d734f25; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integrations + ADD CONSTRAINT fk_rails_755d734f25 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: commitments fk_rails_76ceb88c74; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.commitments + ADD CONSTRAINT fk_rails_76ceb88c74 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: quote_owners fk_rails_7734750af9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_owners + ADD CONSTRAINT fk_rails_7734750af9 FOREIGN KEY (quote_id) REFERENCES public.quotes(id); + + +-- +-- Name: fees fk_rails_775eb0ecd8; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_775eb0ecd8 FOREIGN KEY (original_fee_id) REFERENCES public.fees(id); + + +-- +-- Name: refunds fk_rails_778360c382; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.refunds + ADD CONSTRAINT fk_rails_778360c382 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: credit_notes_taxes fk_rails_77f2d4440d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_notes_taxes + ADD CONSTRAINT fk_rails_77f2d4440d FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: groups fk_rails_7886e1bc34; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.groups + ADD CONSTRAINT fk_rails_7886e1bc34 FOREIGN KEY (parent_group_id) REFERENCES public.groups(id); + + +-- +-- Name: wallet_transactions fk_rails_78f6642ddf; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_78f6642ddf FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: applied_add_ons fk_rails_7995206484; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_add_ons + ADD CONSTRAINT fk_rails_7995206484 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: billable_metric_filters fk_rails_7a0704ce72; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billable_metric_filters + ADD CONSTRAINT fk_rails_7a0704ce72 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + +-- +-- Name: api_keys fk_rails_7aab96f30e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT fk_rails_7aab96f30e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: adjusted_fees fk_rails_7b324610ad; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_7b324610ad FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + +-- +-- Name: invoice_custom_sections fk_rails_7c0e340dbd; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_custom_sections + ADD CONSTRAINT fk_rails_7c0e340dbd FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: subscriptions_invoice_custom_sections fk_rails_7c63dd13f0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions_invoice_custom_sections + ADD CONSTRAINT fk_rails_7c63dd13f0 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: wallet_targets fk_rails_7d0e61668f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_targets + ADD CONSTRAINT fk_rails_7d0e61668f FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: charge_filter_values fk_rails_7da558cadc; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charge_filter_values + ADD CONSTRAINT fk_rails_7da558cadc FOREIGN KEY (charge_filter_id) REFERENCES public.charge_filters(id); + + +-- +-- Name: billable_metrics fk_rails_7e8a2f26e5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billable_metrics + ADD CONSTRAINT fk_rails_7e8a2f26e5 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: charges fk_rails_7eb0484711; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges + ADD CONSTRAINT fk_rails_7eb0484711 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: entitlement_features fk_rails_81d8b323cf; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_features + ADD CONSTRAINT fk_rails_81d8b323cf FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: add_ons fk_rails_81e3b6abba; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.add_ons + ADD CONSTRAINT fk_rails_81e3b6abba FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: wallet_targets fk_rails_81eedc32c0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_targets + ADD CONSTRAINT fk_rails_81eedc32c0 FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: payment_methods fk_rails_84a67e8b40; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_methods + ADD CONSTRAINT fk_rails_84a67e8b40 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: payments fk_rails_84f4587409; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_rails_84f4587409 FOREIGN KEY (payment_provider_id) REFERENCES public.payment_providers(id); + + +-- +-- Name: wallet_transaction_consumptions fk_rails_85b9e72931; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transaction_consumptions + ADD CONSTRAINT fk_rails_85b9e72931 FOREIGN KEY (outbound_wallet_transaction_id) REFERENCES public.wallet_transactions(id); + + +-- +-- Name: payment_provider_customers fk_rails_86676be631; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_provider_customers + ADD CONSTRAINT fk_rails_86676be631 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: wallets_invoice_custom_sections fk_rails_87bc3bd4cb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets_invoice_custom_sections + ADD CONSTRAINT fk_rails_87bc3bd4cb FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: invoice_subscriptions fk_rails_88349fc20a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT fk_rails_88349fc20a FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: adjusted_fees fk_rails_885dc100ef; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_885dc100ef FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: entitlement_entitlement_values fk_rails_8887954ec7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlement_values + ADD CONSTRAINT fk_rails_8887954ec7 FOREIGN KEY (entitlement_privilege_id) REFERENCES public.entitlement_privileges(id); + + +-- +-- Name: add_ons_taxes fk_rails_89e1020aca; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.add_ons_taxes + ADD CONSTRAINT fk_rails_89e1020aca FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: invoice_metadata fk_rails_8bb5b094c4; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_metadata + ADD CONSTRAINT fk_rails_8bb5b094c4 FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: fixed_charges_taxes fk_rails_8c09ee2428; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges_taxes + ADD CONSTRAINT fk_rails_8c09ee2428 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: usage_monitoring_alerts fk_rails_8c18828b53; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_alerts + ADD CONSTRAINT fk_rails_8c18828b53 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + +-- +-- Name: usage_thresholds fk_rails_8df9bf2b6c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_thresholds + ADD CONSTRAINT fk_rails_8df9bf2b6c FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: applied_pricing_units fk_rails_8e0c3d0c5b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_pricing_units + ADD CONSTRAINT fk_rails_8e0c3d0c5b FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: commitments_taxes fk_rails_8fa6f0d920; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.commitments_taxes + ADD CONSTRAINT fk_rails_8fa6f0d920 FOREIGN KEY (commitment_id) REFERENCES public.commitments(id); + + +-- +-- Name: fixed_charge_events fk_rails_90302b3ca3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charge_events + ADD CONSTRAINT fk_rails_90302b3ca3 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: data_export_parts fk_rails_909197908c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_export_parts + ADD CONSTRAINT fk_rails_909197908c FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: invoice_subscriptions fk_rails_90d93bd016; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT fk_rails_90d93bd016 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: adjusted_fees fk_rails_91802dc891; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_91802dc891 FOREIGN KEY (fixed_charge_id) REFERENCES public.fixed_charges(id) NOT VALID; + + +-- +-- Name: data_export_parts fk_rails_9298b8fdad; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.data_export_parts + ADD CONSTRAINT fk_rails_9298b8fdad FOREIGN KEY (data_export_id) REFERENCES public.data_exports(id); + + +-- +-- Name: customers fk_rails_94cc21031f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers + ADD CONSTRAINT fk_rails_94cc21031f FOREIGN KEY (applied_dunning_campaign_id) REFERENCES public.dunning_campaigns(id); + + +-- +-- Name: entitlement_subscription_feature_removals fk_rails_95df3194c5; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_subscription_feature_removals + ADD CONSTRAINT fk_rails_95df3194c5 FOREIGN KEY (entitlement_privilege_id) REFERENCES public.entitlement_privileges(id); + + +-- +-- Name: pending_vies_checks fk_rails_96fc54cd9a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pending_vies_checks + ADD CONSTRAINT fk_rails_96fc54cd9a FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: fixed_charge_events fk_rails_9881e28151; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charge_events + ADD CONSTRAINT fk_rails_9881e28151 FOREIGN KEY (fixed_charge_id) REFERENCES public.fixed_charges(id); + + +-- +-- Name: adjusted_fees fk_rails_98980b326b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_98980b326b FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: memberships fk_rails_99326fb65d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.memberships + ADD CONSTRAINT fk_rails_99326fb65d FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: active_storage_variant_records fk_rails_993965df05; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.active_storage_variant_records + ADD CONSTRAINT fk_rails_993965df05 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id); + + +-- +-- Name: applied_usage_thresholds fk_rails_9c08b43701; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_usage_thresholds + ADD CONSTRAINT fk_rails_9c08b43701 FOREIGN KEY (usage_threshold_id) REFERENCES public.usage_thresholds(id); + + +-- +-- Name: plans_taxes fk_rails_9c704027e2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.plans_taxes + ADD CONSTRAINT fk_rails_9c704027e2 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: applied_add_ons fk_rails_9c8e276cc0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_add_ons + ADD CONSTRAINT fk_rails_9c8e276cc0 FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); + + +-- +-- Name: usage_monitoring_alerts fk_rails_9d8812945e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_alerts + ADD CONSTRAINT fk_rails_9d8812945e FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: wallet_transactions_invoice_custom_sections fk_rails_9e3f99b7a2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions_invoice_custom_sections + ADD CONSTRAINT fk_rails_9e3f99b7a2 FOREIGN KEY (invoice_custom_section_id) REFERENCES public.invoice_custom_sections(id); + + +-- +-- Name: wallet_transactions fk_rails_9ea6759859; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_9ea6759859 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + +-- +-- Name: credit_note_items fk_rails_9f22076477; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_note_items + ADD CONSTRAINT fk_rails_9f22076477 FOREIGN KEY (credit_note_id) REFERENCES public.credit_notes(id); + + +-- +-- Name: quotes fk_rails_a1ab65f1f7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quotes + ADD CONSTRAINT fk_rails_a1ab65f1f7 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: group_properties fk_rails_a2d2cb3819; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.group_properties + ADD CONSTRAINT fk_rails_a2d2cb3819 FOREIGN KEY (group_id) REFERENCES public.groups(id) ON DELETE CASCADE; + + +-- +-- Name: charges fk_rails_a710519346; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges + ADD CONSTRAINT fk_rails_a710519346 FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + +-- +-- Name: recurring_transaction_rules_invoice_custom_sections fk_rails_a7f20c73bb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules_invoice_custom_sections + ADD CONSTRAINT fk_rails_a7f20c73bb FOREIGN KEY (invoice_custom_section_id) REFERENCES public.invoice_custom_sections(id); + + +-- +-- Name: integration_items fk_rails_a9dc2ea536; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_items + ADD CONSTRAINT fk_rails_a9dc2ea536 FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + +-- +-- Name: fixed_charges fk_rails_aa04ceacf6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges + ADD CONSTRAINT fk_rails_aa04ceacf6 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: entitlement_entitlement_values fk_rails_aa34dd5db6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlement_values + ADD CONSTRAINT fk_rails_aa34dd5db6 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: commitments_taxes fk_rails_aaa12f7d3e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.commitments_taxes + ADD CONSTRAINT fk_rails_aaa12f7d3e FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: usage_monitoring_subscription_activities fk_rails_ab16de0b32; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_subscription_activities + ADD CONSTRAINT fk_rails_ab16de0b32 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: charges_taxes fk_rails_ac146c9541; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charges_taxes + ADD CONSTRAINT fk_rails_ac146c9541 FOREIGN KEY (charge_id) REFERENCES public.charges(id); + + +-- +-- Name: pricing_unit_usages fk_rails_aea6422e6a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_unit_usages + ADD CONSTRAINT fk_rails_aea6422e6a FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + +-- +-- Name: daily_usages fk_rails_b07fc711f7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.daily_usages + ADD CONSTRAINT fk_rails_b07fc711f7 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: billing_entities_invoice_custom_sections fk_rails_b283a89721; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities_invoice_custom_sections + ADD CONSTRAINT fk_rails_b283a89721 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: entitlement_subscription_feature_removals fk_rails_b3864df641; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_subscription_feature_removals + ADD CONSTRAINT fk_rails_b3864df641 FOREIGN KEY (entitlement_feature_id) REFERENCES public.entitlement_features(id); + + +-- +-- Name: fees fk_rails_b50dc82c1e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_b50dc82c1e FOREIGN KEY (group_id) REFERENCES public.groups(id); + + +-- +-- Name: entitlement_entitlements fk_rails_b61aa73940; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlements + ADD CONSTRAINT fk_rails_b61aa73940 FOREIGN KEY (entitlement_feature_id) REFERENCES public.entitlement_features(id); + + +-- +-- Name: subscription_activation_rules fk_rails_b749d2045d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscription_activation_rules + ADD CONSTRAINT fk_rails_b749d2045d FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: presentation_breakdowns fk_rails_b8f3cabc8e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.presentation_breakdowns + ADD CONSTRAINT fk_rails_b8f3cabc8e FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + +-- +-- Name: wallet_transactions_invoice_custom_sections fk_rails_b974dac270; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions_invoice_custom_sections + ADD CONSTRAINT fk_rails_b974dac270 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: lifetime_usages fk_rails_ba128983c2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.lifetime_usages + ADD CONSTRAINT fk_rails_ba128983c2 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: applied_coupons fk_rails_bacb46d2a3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.applied_coupons + ADD CONSTRAINT fk_rails_bacb46d2a3 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: plans_taxes fk_rails_bacde7a063; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.plans_taxes + ADD CONSTRAINT fk_rails_bacde7a063 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: usage_monitoring_subscription_activities fk_rails_bda048a8d9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_subscription_activities + ADD CONSTRAINT fk_rails_bda048a8d9 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: dunning_campaign_thresholds fk_rails_bf1f386f75; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dunning_campaign_thresholds + ADD CONSTRAINT fk_rails_bf1f386f75 FOREIGN KEY (dunning_campaign_id) REFERENCES public.dunning_campaigns(id); + + +-- +-- Name: charge_filter_values fk_rails_bf661ef73d; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charge_filter_values + ADD CONSTRAINT fk_rails_bf661ef73d FOREIGN KEY (billable_metric_filter_id) REFERENCES public.billable_metric_filters(id); + + +-- +-- Name: customers fk_rails_bff25bb1bb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers + ADD CONSTRAINT fk_rails_bff25bb1bb FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: enriched_store_migrations fk_rails_c04bd1a196; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enriched_store_migrations + ADD CONSTRAINT fk_rails_c04bd1a196 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: wallet_transactions fk_rails_c29bf4ff0f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_c29bf4ff0f FOREIGN KEY (payment_method_id) REFERENCES public.payment_methods(id) NOT VALID; + + +-- +-- Name: active_storage_attachments fk_rails_c3b3935057; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.active_storage_attachments + ADD CONSTRAINT fk_rails_c3b3935057 FOREIGN KEY (blob_id) REFERENCES public.active_storage_blobs(id); + + +-- +-- Name: pricing_unit_usages fk_rails_c545103d57; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_unit_usages + ADD CONSTRAINT fk_rails_c545103d57 FOREIGN KEY (pricing_unit_id) REFERENCES public.pricing_units(id); + + +-- +-- Name: payment_methods fk_rails_c60c12efbd; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_methods + ADD CONSTRAINT fk_rails_c60c12efbd FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: customers_invoice_custom_sections fk_rails_c64033bcb0; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_invoice_custom_sections + ADD CONSTRAINT fk_rails_c64033bcb0 FOREIGN KEY (customer_id) REFERENCES public.customers(id); + + +-- +-- Name: invites fk_rails_c71f4b2026; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invites + ADD CONSTRAINT fk_rails_c71f4b2026 FOREIGN KEY (membership_id) REFERENCES public.memberships(id); + + +-- +-- Name: subscriptions_invoice_custom_sections fk_rails_c82f03a405; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions_invoice_custom_sections + ADD CONSTRAINT fk_rails_c82f03a405 FOREIGN KEY (invoice_custom_section_id) REFERENCES public.invoice_custom_sections(id); + + +-- +-- Name: payment_methods fk_rails_c8606f586b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_methods + ADD CONSTRAINT fk_rails_c8606f586b FOREIGN KEY (payment_provider_customer_id) REFERENCES public.payment_provider_customers(id); + + +-- +-- Name: entitlement_subscription_feature_removals fk_rails_c9183c59d9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_subscription_feature_removals + ADD CONSTRAINT fk_rails_c9183c59d9 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: usage_thresholds fk_rails_caeb5a3949; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_thresholds + ADD CONSTRAINT fk_rails_caeb5a3949 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: plans fk_rails_cbf700aeb8; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.plans + ADD CONSTRAINT fk_rails_cbf700aeb8 FOREIGN KEY (parent_id) REFERENCES public.plans(id); + + +-- +-- Name: integration_mappings fk_rails_cc318ad1ff; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_mappings + ADD CONSTRAINT fk_rails_cc318ad1ff FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + +-- +-- Name: pricing_units fk_rails_cd99351ee3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.pricing_units + ADD CONSTRAINT fk_rails_cd99351ee3 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: integration_customers fk_rails_ce2c63d69f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_customers + ADD CONSTRAINT fk_rails_ce2c63d69f FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: wallet_transactions fk_rails_d07bc24ce3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_d07bc24ce3 FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: item_metadata fk_rails_d0b1714507; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.item_metadata + ADD CONSTRAINT fk_rails_d0b1714507 FOREIGN KEY (organization_id) REFERENCES public.organizations(id) ON DELETE CASCADE; + + +-- +-- Name: quote_versions fk_rails_d2d917b73a; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quote_versions + ADD CONSTRAINT fk_rails_d2d917b73a FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: payments fk_rails_d384ec1ebf; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payments + ADD CONSTRAINT fk_rails_d384ec1ebf FOREIGN KEY (payment_method_id) REFERENCES public.payment_methods(id) NOT VALID; + + +-- +-- Name: wallet_transaction_consumptions fk_rails_d4abfdb375; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transaction_consumptions + ADD CONSTRAINT fk_rails_d4abfdb375 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: idempotency_records fk_rails_d4f02c82b2; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.idempotency_records + ADD CONSTRAINT fk_rails_d4f02c82b2 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: entitlement_entitlements fk_rails_d53f825a88; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_entitlements + ADD CONSTRAINT fk_rails_d53f825a88 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: entitlement_privileges fk_rails_d648e28d9f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.entitlement_privileges + ADD CONSTRAINT fk_rails_d648e28d9f FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: wallets fk_rails_d9342a8ca7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallets + ADD CONSTRAINT fk_rails_d9342a8ca7 FOREIGN KEY (payment_method_id) REFERENCES public.payment_methods(id) NOT VALID; + + +-- +-- Name: integration_resources fk_rails_d9448a540b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_resources + ADD CONSTRAINT fk_rails_d9448a540b FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: usage_monitoring_alerts fk_rails_d9ea200904; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_alerts + ADD CONSTRAINT fk_rails_d9ea200904 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: fees fk_rails_d9ffb8b4a1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_d9ffb8b4a1 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: customers_invoice_custom_sections fk_rails_db9140d0fd; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_invoice_custom_sections + ADD CONSTRAINT fk_rails_db9140d0fd FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id); + + +-- +-- Name: enriched_store_subscription_migrations fk_rails_dc444f5f29; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enriched_store_subscription_migrations + ADD CONSTRAINT fk_rails_dc444f5f29 FOREIGN KEY (enriched_store_migration_id) REFERENCES public.enriched_store_migrations(id); + + +-- +-- Name: invites fk_rails_dd342449a6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invites + ADD CONSTRAINT fk_rails_dd342449a6 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: coupon_targets fk_rails_de6b3c3138; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.coupon_targets + ADD CONSTRAINT fk_rails_de6b3c3138 FOREIGN KEY (plan_id) REFERENCES public.plans(id); + + +-- +-- Name: quotes fk_rails_de7694c307; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quotes + ADD CONSTRAINT fk_rails_de7694c307 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: credit_note_items fk_rails_dea748e529; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.credit_note_items + ADD CONSTRAINT fk_rails_dea748e529 FOREIGN KEY (fee_id) REFERENCES public.fees(id); + + +-- +-- Name: customer_metadata fk_rails_dfac602b2c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customer_metadata + ADD CONSTRAINT fk_rails_dfac602b2c FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: integration_collection_mappings fk_rails_e148d17c1f; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_collection_mappings + ADD CONSTRAINT fk_rails_e148d17c1f FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: usage_monitoring_triggered_alerts fk_rails_e3cf54daac; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_triggered_alerts + ADD CONSTRAINT fk_rails_e3cf54daac FOREIGN KEY (usage_monitoring_alert_id) REFERENCES public.usage_monitoring_alerts(id); + + +-- +-- Name: integration_mappings fk_rails_e4a58fbcac; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_mappings + ADD CONSTRAINT fk_rails_e4a58fbcac FOREIGN KEY (billing_entity_id) REFERENCES public.billing_entities(id) ON DELETE CASCADE; + + +-- +-- Name: user_devices fk_rails_e700a96826; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.user_devices + ADD CONSTRAINT fk_rails_e700a96826 FOREIGN KEY (user_id) REFERENCES public.users(id); + + +-- +-- Name: charge_filters fk_rails_e711e8089e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.charge_filters + ADD CONSTRAINT fk_rails_e711e8089e FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: subscriptions fk_rails_e744efbe51; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscriptions + ADD CONSTRAINT fk_rails_e744efbe51 FOREIGN KEY (payment_method_id) REFERENCES public.payment_methods(id) NOT VALID; + + +-- +-- Name: customers_taxes fk_rails_e86903e081; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.customers_taxes + ADD CONSTRAINT fk_rails_e86903e081 FOREIGN KEY (tax_id) REFERENCES public.taxes(id); + + +-- +-- Name: plans_taxes fk_rails_e88403f4b9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.plans_taxes + ADD CONSTRAINT fk_rails_e88403f4b9 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: recurring_transaction_rules fk_rails_e8bac9c5bb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules + ADD CONSTRAINT fk_rails_e8bac9c5bb FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: fixed_charges fk_rails_e95f72749e; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges + ADD CONSTRAINT fk_rails_e95f72749e FOREIGN KEY (add_on_id) REFERENCES public.add_ons(id); + + +-- +-- Name: integration_customers fk_rails_ea80151038; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.integration_customers + ADD CONSTRAINT fk_rails_ea80151038 FOREIGN KEY (integration_id) REFERENCES public.integrations(id); + + +-- +-- Name: fees fk_rails_eaca9421be; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_eaca9421be FOREIGN KEY (invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: payment_provider_customers fk_rails_ecb466254b; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_provider_customers + ADD CONSTRAINT fk_rails_ecb466254b FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: invoices_payment_requests fk_rails_ed387e0992; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoices_payment_requests + ADD CONSTRAINT fk_rails_ed387e0992 FOREIGN KEY (payment_request_id) REFERENCES public.payment_requests(id); + + +-- +-- Name: usage_monitoring_triggered_alerts fk_rails_ee2b6f04d9; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_triggered_alerts + ADD CONSTRAINT fk_rails_ee2b6f04d9 FOREIGN KEY (wallet_id) REFERENCES public.wallets(id); + + +-- +-- Name: recurring_transaction_rules_invoice_custom_sections fk_rails_eeb6a32be1; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.recurring_transaction_rules_invoice_custom_sections + ADD CONSTRAINT fk_rails_eeb6a32be1 FOREIGN KEY (recurring_transaction_rule_id) REFERENCES public.recurring_transaction_rules(id); + + +-- +-- Name: usage_monitoring_alert_thresholds fk_rails_f18cd04d51; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.usage_monitoring_alert_thresholds + ADD CONSTRAINT fk_rails_f18cd04d51 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: payment_requests fk_rails_f228550fda; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_requests + ADD CONSTRAINT fk_rails_f228550fda FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: enriched_store_subscription_migrations fk_rails_f232478e56; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.enriched_store_subscription_migrations + ADD CONSTRAINT fk_rails_f232478e56 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: wallet_transactions fk_rails_f32b205d44; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions + ADD CONSTRAINT fk_rails_f32b205d44 FOREIGN KEY (voided_invoice_id) REFERENCES public.invoices(id); + + +-- +-- Name: fees fk_rails_f375d320ad; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees + ADD CONSTRAINT fk_rails_f375d320ad FOREIGN KEY (fixed_charge_id) REFERENCES public.fixed_charges(id); + + +-- +-- Name: invoice_subscriptions fk_rails_f435d13904; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.invoice_subscriptions + ADD CONSTRAINT fk_rails_f435d13904 FOREIGN KEY (regenerated_invoice_id) REFERENCES public.invoices(id) NOT VALID; + + +-- +-- Name: quantified_events fk_rails_f510acb495; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.quantified_events + ADD CONSTRAINT fk_rails_f510acb495 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: payment_receipts fk_rails_f53ff93138; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.payment_receipts + ADD CONSTRAINT fk_rails_f53ff93138 FOREIGN KEY (payment_id) REFERENCES public.payments(id); + + +-- +-- Name: billing_entities fk_rails_f66617edcb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.billing_entities + ADD CONSTRAINT fk_rails_f66617edcb FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: fees_taxes fk_rails_f98413d404; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fees_taxes + ADD CONSTRAINT fk_rails_f98413d404 FOREIGN KEY (tax_id) REFERENCES public.taxes(id) ON DELETE SET NULL; + + +-- +-- Name: wallet_targets fk_rails_fbd2b9fccb; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_targets + ADD CONSTRAINT fk_rails_fbd2b9fccb FOREIGN KEY (billable_metric_id) REFERENCES public.billable_metrics(id); + + +-- +-- Name: adjusted_fees fk_rails_fd399a23d3; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.adjusted_fees + ADD CONSTRAINT fk_rails_fd399a23d3 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: subscription_activation_rules fk_rails_fd60209637; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.subscription_activation_rules + ADD CONSTRAINT fk_rails_fd60209637 FOREIGN KEY (subscription_id) REFERENCES public.subscriptions(id); + + +-- +-- Name: dunning_campaign_thresholds fk_rails_fd84cdb7c6; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.dunning_campaign_thresholds + ADD CONSTRAINT fk_rails_fd84cdb7c6 FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: fixed_charges_taxes fk_rails_fea16bf2e7; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.fixed_charges_taxes + ADD CONSTRAINT fk_rails_fea16bf2e7 FOREIGN KEY (fixed_charge_id) REFERENCES public.fixed_charges(id); + + +-- +-- Name: presentation_breakdowns fk_rails_ff548a9f4c; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.presentation_breakdowns + ADD CONSTRAINT fk_rails_ff548a9f4c FOREIGN KEY (organization_id) REFERENCES public.organizations(id); + + +-- +-- Name: wallet_transactions_invoice_custom_sections fk_rails_ff75b29299; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.wallet_transactions_invoice_custom_sections + ADD CONSTRAINT fk_rails_ff75b29299 FOREIGN KEY (wallet_transaction_id) REFERENCES public.wallet_transactions(id); + + +-- +-- Name: membership_roles membership_role_membership_fk; Type: FK CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.membership_roles + ADD CONSTRAINT membership_role_membership_fk FOREIGN KEY (membership_id, organization_id) REFERENCES public.memberships(id, organization_id); + + +-- +-- PostgreSQL database dump complete +-- + +SET search_path TO "$user", public; + +INSERT INTO "schema_migrations" (version) VALUES +('20260504134804'), +('20260430102814'), +('20260430102813'), +('20260429133747'), +('20260429123434'), +('20260424170418'), +('20260421123920'), +('20260421103557'), +('20260421021503'), +('20260421013319'), +('20260420114717'), +('20260416124233'), +('20260416124232'), +('20260416111923'), +('20260416111922'), +('20260415160654'), +('20260409161142'), +('20260409151451'), +('20260407091845'), +('20260403184752'), +('20260403184747'), +('20260331122448'), +('20260331103301'), +('20260327140626'), +('20260326130631'), +('20260325150808'), +('20260324124033'), +('20260319125125'), +('20260319103035'), +('20260317134100'), +('20260317132911'), +('20260317132747'), +('20260317132544'), +('20260317130654'), +('20260311121245'), +('20260306115902'), +('20260305165936'), +('20260305161303'), +('20260305161302'), +('20260305100007'), +('20260304074158'), +('20260227184913'), +('20260224134805'), +('20260220131101'), +('20260219130831'), +('20260219102644'), +('20260219083335'), +('20260218102426'), +('20260216115709'), +('20260209103920'), +('20260209103526'), +('20260204153734'), +('20260204130807'), +('20260203145809'), +('20260203145801'), +('20260203145512'), +('20260202155431'), +('20260202150723'), +('20260202134958'), +('20260129145352'), +('20260129105200'), +('20260128073308'), +('20260127163159'), +('20260127150713'), +('20260127150640'), +('20260127150624'), +('20260127150613'), +('20260127150612'), +('20260127150611'), +('20260127114700'), +('20260123102258'), +('20260123102257'), +('20260121112929'), +('20260121111431'), +('20260120195822'), +('20260119162712'), +('20260116162519'), +('20260116121019'), +('20260116121015'), +('20260116110125'), +('20260115164124'), +('20260114153728'), +('20260113102028'), +('20260112140805'), +('20260109132143'), +('20260109110146'), +('20260109092932'), +('20260106120832'), +('20260106120601'), +('20260105144123'), +('20251231162838'), +('20251230154408'), +('20251229153734'), +('20251229153718'), +('20251226145247'), +('20251224152737'), +('20251224152736'), +('20251224152734'), +('20251224152733'), +('20251224152732'), +('20251222163416'), +('20251222151519'), +('20251222151015'), +('20251221174946'), +('20251221174938'), +('20251221174733'), +('20251221174251'), +('20251219115429'), +('20251216100247'), +('20251211154309'), +('20251210151531'), +('20251210133246'), +('20251210133225'), +('20251204142205'), +('20251204101451'), +('20251202141759'), +('20251201094057'), +('20251201084648'), +('20251128102055'), +('20251127145819'), +('20251127123135'), +('20251126171210'), +('20251126170127'), +('20251126165406'), +('20251126164626'), +('20251126145839'), +('20251126135708'), +('20251126134516'), +('20251125174110'), +('20251121143459'), +('20251121113600'), +('20251112112544'), +('20251110191233'), +('20251107102548'), +('20251106093323'), +('20251106092231'), +('20251106091730'), +('20251106072629'), +('20251031112354'), +('20251029140035'), +('20251024200950'), +('20251024130659'), +('20251023154344'), +('20251023154123'), +('20251023153834'), +('20251022104121'), +('20251021114023'), +('20251021105732'), +('20251021083946'), +('20251021073412'), +('20251020142629'), +('20251020090137'), +('20251020074349'), +('20251020073334'), +('20251013101230'), +('20251010092830'), +('20251010073504'), +('20251007160309'), +('20251007103421'), +('20251007082822'), +('20251007082809'), +('20251003171658'), +('20251003171653'), +('20250926185510'), +('20250919124523'), +('20250919124037'), +('20250915100607'), +('20250912081524'), +('20250911124033'), +('20250911111448'), +('20250909125858'), +('20250908085959'), +('20250903165724'), +('20250901143217'), +('20250901141844'), +('20250828153138'), +('20250828144553'), +('20250828142848'), +('20250826081205'), +('20250822100111'), +('20250821094638'), +('20250820200921'), +('20250818154000'), +('20250813174100'), +('20250813172434'), +('20250812132802'), +('20250812082721'), +('20250806174150'), +('20250806173900'), +('20250801072722'), +('20250731145640'), +('20250731144632'), +('20250724104251'), +('20250722094047'), +('20250721220908'), +('20250721212307'), +('20250721211820'), +('20250721192051'), +('20250721150002'), +('20250721150001'), +('20250721150000'), +('20250721091802'), +('20250721090704'), +('20250718174008'), +('20250718140450'), +('20250717142942'), +('20250717140238'), +('20250717092012'), +('20250717071548'), +('20250716150049'), +('20250716143358'), +('20250716142613'), +('20250716132759'), +('20250716132649'), +('20250716123425'), +('20250715124108'), +('20250714131519'), +('20250712000000'), +('20250710102337'), +('20250709171329'), +('20250709085218'), +('20250709082136'), +('20250708094414'), +('20250707113718'), +('20250707113717'), +('20250707100102'), +('20250707100101'), +('20250707100026'), +('20250707100025'), +('20250707100013'), +('20250707100012'), +('20250707100010'), +('20250707095956'), +('20250707095955'), +('20250707095224'), +('20250707095223'), +('20250707094932'), +('20250707094931'), +('20250707094901'), +('20250707094900'), +('20250707090348'), +('20250707090347'), +('20250707090329'), +('20250707090328'), +('20250707090314'), +('20250707090313'), +('20250707085725'), +('20250707085724'), +('20250707085651'), +('20250707085650'), +('20250707085634'), +('20250707085633'), +('20250707085615'), +('20250707085614'), +('20250707083222'), +('20250707083221'), +('20250707083211'), +('20250707083210'), +('20250707083160'), +('20250707083159'), +('20250707082521'), +('20250707082520'), +('20250707082510'), +('20250707082509'), +('20250707082436'), +('20250707082435'), +('20250707081911'), +('20250707081910'), +('20250707081837'), +('20250707081836'), +('20250707081826'), +('20250707081825'), +('20250704800001'), +('20250703133126'), +('20250701141017'), +('20250701133139'), +('20250630180000'), +('20250627134933'), +('20250627134932'), +('20250627134926'), +('20250627134925'), +('20250627134916'), +('20250627134915'), +('20250627124153'), +('20250627124152'), +('20250627124144'), +('20250627124143'), +('20250627124130'), +('20250627124129'), +('20250627124119'), +('20250627124118'), +('20250627124056'), +('20250627124055'), +('20250627124049'), +('20250627124048'), +('20250627124040'), +('20250627124039'), +('20250627124034'), +('20250627124033'), +('20250627124029'), +('20250627124028'), +('20250627124023'), +('20250627124022'), +('20250627124017'), +('20250627124016'), +('20250627124008'), +('20250627124007'), +('20250627123959'), +('20250627123958'), +('20250627091213'), +('20250627091212'), +('20250627091011'), +('20250627091010'), +('20250627084852'), +('20250627084430'), +('20250626175249'), +('20250619144939'), +('20250619143820'), +('20250611083925'), +('20250611072251'), +('20250610173034'), +('20250610063400'), +('20250609121102'), +('20250602145535'), +('20250602075710'), +('20250530112903'), +('20250528133222'), +('20250526134136'), +('20250526133654'), +('20250526133152'), +('20250526130953'), +('20250526111147'), +('20250522134155'), +('20250521151540'), +('20250521135607'), +('20250521104239'), +('20250521095733'), +('20250520170402'), +('20250520155108'), +('20250520143628'), +('20250520080000'), +('20250519092053'), +('20250519092052'), +('20250519092051'), +('20250519085911'), +('20250519085910'), +('20250519085909'), +('20250519084649'), +('20250519084648'), +('20250519084647'), +('20250517100023'), +('20250516115757'), +('20250516115756'), +('20250516115755'), +('20250516100026'), +('20250516100025'), +('20250516100024'), +('20250516095315'), +('20250516095314'), +('20250516095313'), +('20250516084025'), +('20250515085230'), +('20250515083935'), +('20250515083802'), +('20250515083649'), +('20250513153630'), +('20250513153629'), +('20250513153628'), +('20250513152807'), +('20250513152806'), +('20250513152805'), +('20250513151260'), +('20250513151259'), +('20250513151258'), +('20250513144354'), +('20250513144353'), +('20250513144352'), +('20250513132425'), +('20250513132424'), +('20250513132423'), +('20250512151248'), +('20250512151247'), +('20250512151246'), +('20250512144220'), +('20250512144219'), +('20250512144218'), +('20250512142914'), +('20250512142913'), +('20250512142912'), +('20250512130616'), +('20250512130615'), +('20250512130614'), +('20250512123541'), +('20250512123540'), +('20250512123539'), +('20250512122608'), +('20250512122607'), +('20250512122606'), +('20250512081332'), +('20250507154910'), +('20250507110137'), +('20250506170753'), +('20250506145851'), +('20250506145850'), +('20250506145849'), +('20250506144002'), +('20250506144001'), +('20250506144000'), +('20250506121532'), +('20250506121531'), +('20250506121530'), +('20250506115439'), +('20250506115438'), +('20250506115437'), +('20250506085760'), +('20250506085759'), +('20250506085758'), +('20250506084829'), +('20250506084828'), +('20250506084827'), +('20250506084022'), +('20250506084021'), +('20250506084020'), +('20250505161359'), +('20250505161358'), +('20250505161357'), +('20250505142221'), +('20250505142220'), +('20250505142219'), +('20250505140928'), +('20250505140927'), +('20250505140926'), +('20250505135821'), +('20250505135820'), +('20250505135819'), +('20250505125354'), +('20250505125335'), +('20250505125308'), +('20250429150146'), +('20250429150128'), +('20250429150114'), +('20250429100154'), +('20250429100153'), +('20250429100152'), +('20250429100151'), +('20250429100150'), +('20250429100149'), +('20250429100148'), +('20250428154519'), +('20250428154500'), +('20250428154444'), +('20250428140148'), +('20250428140126'), +('20250428140111'), +('20250428130148'), +('20250428130129'), +('20250428130107'), +('20250428111042'), +('20250425134911'), +('20250425134826'), +('20250425132821'), +('20250425132757'), +('20250425132724'), +('20250425132247'), +('20250425130412'), +('20250425130345'), +('20250425130332'), +('20250425124942'), +('20250425124826'), +('20250425124804'), +('20250425124305'), +('20250425124100'), +('20250425123733'), +('20250425122705'), +('20250425122641'), +('20250425122510'), +('20250425102555'), +('20250425102447'), +('20250425102306'), +('20250424140537'), +('20250424140359'), +('20250424135624'), +('20250416125600'), +('20250415143607'), +('20250414122904'), +('20250414122643'), +('20250414121455'), +('20250414091130'), +('20250411152022'), +('20250411112117'), +('20250411110934'), +('20250411110825'), +('20250411074202'), +('20250409140720'), +('20250409140652'), +('20250409100421'), +('20250408121522'), +('20250407202459'), +('20250407000001'), +('20250403110833'), +('20250403093628'), +('20250402152230'), +('20250402152200'), +('20250402152130'), +('20250402152100'), +('20250402152030'), +('20250402152000'), +('20250402151930'), +('20250402151900'), +('20250402151747'), +('20250402151113'), +('20250402150959'), +('20250402150920'), +('20250402135038'), +('20250402113844'), +('20250327130156'), +('20250327130155'), +('20250325162648'), +('20250325145324'), +('20250324125056'), +('20250324122757'), +('20250318175216'), +('20250318093216'), +('20250310213734'), +('20250304163656'), +('20250303104151'), +('20250227155522'), +('20250227091909'), +('20250220223944'), +('20250220180114'), +('20250220180113'), +('20250220180112'), +('20250220085848'), +('20250219205535'), +('20250219164502'), +('20250219152213'), +('20250219124948'), +('20250218165958'), +('20250217152051'), +('20250214091021'), +('20250212123207'), +('20250207142402'), +('20250207094842'), +('20250205184611'), +('20250122130735'), +('20250122112050'), +('20250120151959'), +('20250114172823'), +('20250114163522'), +('20250103124802'), +('20241227161927'), +('20241227154337'), +('20241224142141'), +('20241224141116'), +('20241223154437'), +('20241223144027'), +('20241220160748'), +('20241220095049'), +('20241220084758'), +('20241219152909'), +('20241219145642'), +('20241219122151'), +('20241217120924'), +('20241216140931'), +('20241216110525'), +('20241213182343'), +('20241213142739'), +('20241128132010'), +('20241128091634'), +('20241126141853'), +('20241126103448'), +('20241126102447'), +('20241125194753'), +('20241122141158'), +('20241122140603'), +('20241122134430'), +('20241122111534'), +('20241122105327'), +('20241122105133'), +('20241122104537'), +('20241120094557'), +('20241120090305'), +('20241120085057'), +('20241119114948'), +('20241119110219'), +('20241118165935'), +('20241118103032'), +('20241113181629'), +('20241108103702'), +('20241107093418'), +('20241106104515'), +('20241101151559'), +('20241031123415'), +('20241031102231'), +('20241031095225'), +('20241030123528'), +('20241029141351'), +('20241025081408'), +('20241024082941'), +('20241022144437'), +('20241021140054'), +('20241021095706'), +('20241018112637'), +('20241017082601'), +('20241016133129'), +('20241016104211'), +('20241015132635'), +('20241014093451'), +('20241014000100'), +('20241011123621'), +('20241011123148'), +('20241010055733'), +('20241008080209'), +('20241007092701'), +('20241007083747'), +('20241001112117'), +('20241001105523'), +('20240924114730'), +('20240920091133'), +('20240920084727'), +('20240917145042'), +('20240917144243'), +('20240910111203'), +('20240910093646'), +('20240906170048'), +('20240906154644'), +('20240829093425'), +('20240823092643'), +('20240822142524'), +('20240822082727'), +('20240822080031'), +('20240821174724'), +('20240821172352'), +('20240821093145'), +('20240820125840'), +('20240820090312'), +('20240819092354'), +('20240816075711'), +('20240814144137'), +('20240813121307'), +('20240813095718'), +('20240812130655'), +('20240808132042'), +('20240808085506'), +('20240808080611'), +('20240807113700'), +('20240807100609'), +('20240807072052'), +('20240802115017'), +('20240801142242'), +('20240801134833'), +('20240801134832'), +('20240729154334'), +('20240729152352'), +('20240729151049'), +('20240729134020'), +('20240729133823'), +('20240723150304'), +('20240723150221'), +('20240722201341'), +('20240718105718'), +('20240718080929'), +('20240716154636'), +('20240716153753'), +('20240712090133'), +('20240711094255'), +('20240711091155'), +('20240708195226'), +('20240708081356'), +('20240706204557'), +('20240705125619'), +('20240703061352'), +('20240702081109'), +('20240701184757'), +('20240701083355'), +('20240628083830'), +('20240628083654'), +('20240626094521'), +('20240625090742'), +('20240619082054'), +('20240611074215'), +('20240607095208'), +('20240607095155'), +('20240604141208'), +('20240603095841'), +('20240603080144'), +('20240530123427'), +('20240522105942'), +('20240521143531'), +('20240520115450'), +('20240514081110'), +('20240514072741'), +('20240506085424'), +('20240502095122'), +('20240502075803'), +('20240430133150'), +('20240430100120'), +('20240429141108'), +('20240426143059'), +('20240425131701'), +('20240425082113'), +('20240424124802'), +('20240424110420'), +('20240423155113'), +('20240419085012'), +('20240419071607'), +('20240415122310'), +('20240412133335'), +('20240412085450'), +('20240411114759'), +('20240404123257'), +('20240403084644'), +('20240329112415'), +('20240328153701'), +('20240328075919'), +('20240327071539'), +('20240314172008'), +('20240314170211'), +('20240314165306'), +('20240314163426'), +('20240312141641'), +('20240311091817'), +('20240308150801'), +('20240308104003'), +('20240305164449'), +('20240305093058'), +('20240301133006'), +('20240227161430'), +('20240205160647'), +('20240129155938'), +('20240125080718'), +('20240123104811'), +('20240118141022'), +('20240118140703'), +('20240118135350'), +('20240115130517'), +('20240115102012'), +('20240115094827'), +('20240112091706'), +('20240111155133'), +('20240111151140'), +('20240111140424'), +('20240104152816'), +('20240103125624'), +('20231220140936'), +('20231220115621'), +('20231219121735'), +('20231218170631'), +('20231214133638'), +('20231214103653'), +('20231207095229'), +('20231205153156'), +('20231204151512'), +('20231204131333'), +('20231201091348'), +('20231130085817'), +('20231129145100'), +('20231128092231'), +('20231123105540'), +('20231123095209'), +('20231117123744'), +('20231114092154'), +('20231109154934'), +('20231109141829'), +('20231107110809'), +('20231106145424'), +('20231103144201'), +('20231102154537'), +('20231102141929'), +('20231102085146'), +('20231101080314'), +('20231027144605'), +('20231020091031'), +('20231017082921'), +('20231016115055'), +('20231010090849'), +('20231010085938'), +('20231001070407'), +('20230926144126'), +('20230926132500'), +('20230922064617'), +('20230920083133'), +('20230918090426'), +('20230915135256'), +('20230915120854'), +('20230915073205'), +('20230913123123'), +('20230912082112'), +('20230912082057'), +('20230912082000'), +('20230911185900'), +('20230911083923'), +('20230907153404'), +('20230907064335'), +('20230905081225'), +('20230830120517'), +('20230828085627'), +('20230821135235'), +('20230817092555'), +('20230816091053'), +('20230811120622'), +('20230811081854'), +('20230808144739'), +('20230731135721'), +('20230731095510'), +('20230727163611'), +('20230726171737'), +('20230726165711'), +('20230721073114'), +('20230720204311'), +('20230719100256'), +('20230717090135'), +('20230713122526'), +('20230705213846'), +('20230704150108'), +('20230704144027'), +('20230704112230'), +('20230629100018'), +('20230627080605'), +('20230626124005'), +('20230626123648'), +('20230620211201'), +('20230619101701'), +('20230615183805'), +('20230614191603'), +('20230608154821'), +('20230608133543'), +('20230608085013'), +('20230606164458'), +('20230606085050'), +('20230602090325'), +('20230529093955'), +('20230525154612'), +('20230525122232'), +('20230525120005'), +('20230524130637'), +('20230523140656'), +('20230523094557'), +('20230522113810'), +('20230522093423'), +('20230522091400'), +('20230517093556'), +('20230511124419'), +('20230510113501'), +('20230505093030'), +('20230503143229'), +('20230425130239'), +('20230424210224'), +('20230424154516'), +('20230424150952'), +('20230424092207'), +('20230424091446'), +('20230421094757'), +('20230420120806'), +('20230420114754'), +('20230419123538'), +('20230418151450'), +('20230417140356'), +('20230417131515'), +('20230417122020'), +('20230417094339'), +('20230414130437'), +('20230414074225'), +('20230411085545'), +('20230411083336'), +('20230403094044'), +('20230403093407'), +('20230328161507'), +('20230327134418'), +('20230323112252'), +('20230313145506'), +('20230307131524'), +('20230301122720'), +('20230227145104'), +('20230221102035'), +('20230221070501'), +('20230216145442'), +('20230216140543'), +('20230214145444'), +('20230214100638'), +('20230207110702'), +('20230206143214'), +('20230203132157'), +('20230202163249'), +('20230202150407'), +('20230202110407'), +('20230131152047'), +('20230131144740'), +('20230127140904'), +('20230126103454'), +('20230125104957'), +('20230118100324'), +('20230109095957'), +('20230106152449'), +('20230105094302'), +('20230102150636'), +('20221226091020'), +('20221222164226'), +('20221219111209'), +('20221216154033'), +('20221212153810'), +('20221208142739'), +('20221208140608'), +('20221206094412'), +('20221205112007'), +('20221202130126'), +('20221129133433'), +('20221128132620'), +('20221125111605'), +('20221122163328'), +('20221118093903'), +('20221118084547'), +('20221115160325'), +('20221115155550'), +('20221115135840'), +('20221115110223'), +('20221115100834'), +('20221114102649'), +('20221110151027'), +('20221107151038'), +('20221031144907'), +('20221031141549'), +('20221028160705'), +('20221028124549'), +('20221028091920'), +('20221024090308'), +('20221021135946'), +('20221021135428'), +('20221020093745'), +('20221018144521'), +('20221013140147'), +('20221011133055'), +('20221011083520'), +('20221010142031'), +('20221010083509'), +('20221007075812'), +('20221004092737'), +('20220930143002'), +('20220930134327'), +('20220930123935'), +('20220923092906'), +('20220922105251'), +('20220921095507'), +('20220919133338'), +('20220916131538'), +('20220915092730'), +('20220906130714'), +('20220906065059'), +('20220905142834'), +('20220905095529'), +('20220831113537'), +('20220829094054'), +('20220825051923'), +('20220824113131'), +('20220823145421'), +('20220823135203'), +('20220818151052'), +('20220818141616'), +('20220817095619'), +('20220817092945'), +('20220816120137'), +('20220811155332'), +('20220809083243'), +('20220807210117'), +('20220801101144'), +('20220729062203'), +('20220729055309'), +('20220728144707'), +('20220727161448'), +('20220727132848'), +('20220725152220'), +('20220722123417'), +('20220721150658'), +('20220718124337'), +('20220718083657'), +('20220713171816'), +('20220705155228'), +('20220704145333'), +('20220629133308'), +('20220621153030'), +('20220621090834'), +('20220620150551'), +('20220620141910'), +('20220617124108'), +('20220614110841'), +('20220613130634'), +('20220610143942'), +('20220610134535'), +('20220609080806'), +('20220607082458'), +('20220602145819'), +('20220601150058'), +('20220530091046'), +('20220526101535'), +('20220525122759'); diff --git a/db/views/billable_metrics_grouped_charges_v01.sql b/db/views/billable_metrics_grouped_charges_v01.sql new file mode 100644 index 0000000..9cf1b40 --- /dev/null +++ b/db/views/billable_metrics_grouped_charges_v01.sql @@ -0,0 +1,42 @@ +select + billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id as charge_id, + charge_filters.id as charge_filter_id, + json_object_agg( + billable_metric_filters.key, + coalesce(charge_filter_values.values, '{}') + order by billable_metric_filters.key asc + ) FILTER (WHERE billable_metric_filters.key IS NOT NULL) AS filters, + ( + case when charges.charge_model = 0 -- Standard + then + coalesce(charge_filters.properties->'grouped_by', charges.properties->'grouped_by') + else + null + end + ) AS grouped_by + +from billable_metrics + inner join charges on charges.billable_metric_id = billable_metrics.id + left join charge_filters on charge_filters.charge_id = charges.id + left join charge_filter_values on charge_filter_values.charge_filter_id = charge_filters.id + left join billable_metric_filters on charge_filter_values.billable_metric_filter_id = billable_metric_filters.id +where + billable_metrics.deleted_at is null + and charges.deleted_at is null + and charges.pay_in_advance = false + and charge_filters.deleted_at is null + and charge_filter_values.deleted_at is null + and billable_metric_filters.deleted_at is null +group by + billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id, + charge_filters.id diff --git a/db/views/billable_metrics_grouped_charges_v02.sql b/db/views/billable_metrics_grouped_charges_v02.sql new file mode 100644 index 0000000..e96f8aa --- /dev/null +++ b/db/views/billable_metrics_grouped_charges_v02.sql @@ -0,0 +1,50 @@ +select + billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id as charge_id, + ( + case when charges.charge_model = 0 -- Standard + then + charges.properties->'grouped_by' + else + null + end + ) as grouped_by, + charge_filters.id as charge_filter_id, + json_object_agg( + billable_metric_filters.key, + coalesce(charge_filter_values.values, '{}') + order by billable_metric_filters.key asc + ) FILTER (WHERE billable_metric_filters.key IS NOT NULL) AS filters, + ( + case when charges.charge_model = 0 -- Standard + then + charge_filters.properties->'grouped_by' + else + null + end + ) AS filters_grouped_by + +from billable_metrics + inner join charges on charges.billable_metric_id = billable_metrics.id + left join charge_filters on charge_filters.charge_id = charges.id + left join charge_filter_values on charge_filter_values.charge_filter_id = charge_filters.id + left join billable_metric_filters on charge_filter_values.billable_metric_filter_id = billable_metric_filters.id +where + billable_metrics.deleted_at is null + and charges.deleted_at is null + and charges.pay_in_advance = false + and charge_filters.deleted_at is null + and charge_filter_values.deleted_at is null + and billable_metric_filters.deleted_at is null +group by + billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id, + charge_filters.id diff --git a/db/views/billable_metrics_grouped_charges_v03.sql b/db/views/billable_metrics_grouped_charges_v03.sql new file mode 100644 index 0000000..5962faa --- /dev/null +++ b/db/views/billable_metrics_grouped_charges_v03.sql @@ -0,0 +1,50 @@ +select + billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id as charge_id, + charges.pay_in_advance, + ( + case when charges.charge_model = 0 -- Standard + then + charges.properties->'grouped_by' + else + null + end + ) as grouped_by, + charge_filters.id as charge_filter_id, + json_object_agg( + billable_metric_filters.key, + coalesce(charge_filter_values.values, '{}') + order by billable_metric_filters.key asc + ) FILTER (WHERE billable_metric_filters.key IS NOT NULL) AS filters, + ( + case when charges.charge_model = 0 -- Standard + then + charge_filters.properties->'grouped_by' + else + null + end + ) AS filters_grouped_by + +from billable_metrics + inner join charges on charges.billable_metric_id = billable_metrics.id + left join charge_filters on charge_filters.charge_id = charges.id + left join charge_filter_values on charge_filter_values.charge_filter_id = charge_filters.id + left join billable_metric_filters on charge_filter_values.billable_metric_filter_id = billable_metric_filters.id +where + billable_metrics.deleted_at is null + and charges.deleted_at is null + and charge_filters.deleted_at is null + and charge_filter_values.deleted_at is null + and billable_metric_filters.deleted_at is null +group by + billable_metrics.organization_id, + billable_metrics.code, + billable_metrics.aggregation_type, + billable_metrics.field_name, + charges.plan_id, + charges.id, + charge_filters.id diff --git a/db/views/entitlement_subscription_entitlements_view_v01.sql b/db/views/entitlement_subscription_entitlements_view_v01.sql new file mode 100644 index 0000000..89cb4c1 --- /dev/null +++ b/db/views/entitlement_subscription_entitlements_view_v01.sql @@ -0,0 +1,68 @@ +WITH + subscription_entitlements AS ( + SELECT + fe.entitlement_feature_id, + fe.plan_id, + fe.subscription_id, + fev.deleted_at AS deleted_at, + fev.id, + fev.entitlement_privilege_id, + fev.entitlement_entitlement_id, + fev.value + FROM + entitlement_entitlement_values fev + JOIN entitlement_entitlements fe ON fe.id = fev.entitlement_entitlement_id + WHERE + fev.deleted_at IS NULL + ), + all_values AS ( + SELECT + ep.entitlement_feature_id, + COALESCE(ep.entitlement_privilege_id, es.entitlement_privilege_id) AS entitlement_privilege_id, + ep.entitlement_entitlement_id AS plan_entitlement_id, + es.entitlement_entitlement_id AS override_entitlement_id, + ep.id AS plan_entitlement_values_id, + es.id AS override_entitlement_values_id, + ep.value AS plan_value, + es.value AS override_value + FROM + subscription_entitlements ep + FULL OUTER JOIN subscription_entitlements es ON ep.entitlement_privilege_id = es.entitlement_privilege_id + AND ep.plan_id IS NOT NULL + AND es.subscription_id IS NOT NULL + WHERE + ( + ep.plan_id IS NOT NULL + OR es.subscription_id IS NOT NULL + ) + AND ep.deleted_at IS NULL + AND es.deleted_at IS NULL + ) +SELECT + f.id AS entitlement_feature_id, + f.organization_id AS organization_id, + f.code AS feature_code, + f.name AS feature_name, + f.description AS feature_description, + f.deleted_at AS feature_deleted_at, + pri.id AS entitlement_privilege_id, + pri.code AS privilege_code, + pri.name AS privilege_name, + pri.value_type AS privilege_value_type, + pri.config AS privilege_config, + pri.deleted_at AS privilege_deleted_at, + fe.plan_id AS plan_id, + fe.subscription_id AS subscription_id, + (sfr.id IS NOT NULL) AS removed, + av.plan_entitlement_id, + av.override_entitlement_id, + av.plan_entitlement_values_id, + av.override_entitlement_values_id, + av.plan_value AS privilege_plan_value, + av.override_value AS privilege_override_value +FROM + entitlement_entitlements fe + LEFT JOIN entitlement_subscription_feature_removals sfr ON fe.entitlement_feature_id = sfr.entitlement_feature_id AND sfr.deleted_at IS NULL + LEFT JOIN all_values av ON COALESCE(av.override_entitlement_id, av.plan_entitlement_id) = fe.id + LEFT JOIN entitlement_features f ON f.id = fe.entitlement_feature_id + LEFT JOIN entitlement_privileges pri ON pri.id = av.entitlement_privilege_id; diff --git a/db/views/entitlement_subscription_entitlements_view_v02.sql b/db/views/entitlement_subscription_entitlements_view_v02.sql new file mode 100644 index 0000000..cc9d823 --- /dev/null +++ b/db/views/entitlement_subscription_entitlements_view_v02.sql @@ -0,0 +1,69 @@ +WITH + subscription_entitlements AS ( + SELECT + fe.entitlement_feature_id, + fe.plan_id, + fe.subscription_id, + fev.deleted_at AS deleted_at, + fev.id, + fev.entitlement_privilege_id, + fev.entitlement_entitlement_id, + fev.value + FROM + entitlement_entitlement_values fev + JOIN entitlement_entitlements fe ON fe.id = fev.entitlement_entitlement_id + WHERE + fev.deleted_at IS NULL AND fe.deleted_at IS NULL + ), + all_values AS ( + SELECT + ep.entitlement_feature_id, + COALESCE(ep.entitlement_privilege_id, es.entitlement_privilege_id) AS entitlement_privilege_id, + ep.entitlement_entitlement_id AS plan_entitlement_id, + es.entitlement_entitlement_id AS override_entitlement_id, + ep.id AS plan_entitlement_values_id, + es.id AS override_entitlement_values_id, + ep.value AS plan_value, + es.value AS override_value + FROM + subscription_entitlements ep + FULL OUTER JOIN subscription_entitlements es ON ep.entitlement_privilege_id = es.entitlement_privilege_id + AND ep.plan_id IS NOT NULL + AND es.subscription_id IS NOT NULL + WHERE + ( + ep.plan_id IS NOT NULL + OR es.subscription_id IS NOT NULL + ) + AND ep.deleted_at IS NULL + AND es.deleted_at IS NULL + ) +SELECT + f.id AS entitlement_feature_id, + f.organization_id AS organization_id, + f.code AS feature_code, + f.name AS feature_name, + f.description AS feature_description, + f.deleted_at AS feature_deleted_at, + pri.id AS entitlement_privilege_id, + pri.code AS privilege_code, + pri.name AS privilege_name, + pri.value_type AS privilege_value_type, + pri.config AS privilege_config, + pri.deleted_at AS privilege_deleted_at, + fe.plan_id AS plan_id, + fe.subscription_id AS subscription_id, + (sfr.id IS NOT NULL) AS removed, + av.plan_entitlement_id, + av.override_entitlement_id, + av.plan_entitlement_values_id, + av.override_entitlement_values_id, + av.plan_value AS privilege_plan_value, + av.override_value AS privilege_override_value +FROM + entitlement_entitlements fe + LEFT JOIN entitlement_subscription_feature_removals sfr ON fe.entitlement_feature_id = sfr.entitlement_feature_id AND sfr.deleted_at IS NULL + LEFT JOIN all_values av ON COALESCE(av.override_entitlement_id, av.plan_entitlement_id) = fe.id + LEFT JOIN entitlement_features f ON f.id = fe.entitlement_feature_id + LEFT JOIN entitlement_privileges pri ON pri.id = av.entitlement_privilege_id +WHERE fe.deleted_at IS NULL; diff --git a/db/views/exports_applied_coupons_v01.sql b/db/views/exports_applied_coupons_v01.sql new file mode 100644 index 0000000..cedc331 --- /dev/null +++ b/db/views/exports_applied_coupons_v01.sql @@ -0,0 +1,50 @@ +SELECT + cp.organization_id, + ac.id AS lago_id, + ac.coupon_id AS lago_coupon_id, + ac.customer_id AS lago_customer_id, + CASE ac.status + WHEN 0 THEN 'active' + WHEN 1 THEN 'terminated' + END AS status, + ac.amount_cents, + CASE ac.frequency + WHEN 0 THEN null + WHEN 1 THEN null + ELSE + CASE + WHEN cp.coupon_type = 1 THEN NULL -- coupon is percentage + ELSE + ac.amount_cents - ( + SELECT SUM(cr.amount_cents)::bigint + FROM credits AS cr + WHERE cr.applied_coupon_id = ac.id + ) + END + END AS amount_cents_remaining, + ac.amount_currency, + ac.percentage_rate, + CASE ac.frequency + WHEN 0 THEN 'once' + WHEN 1 THEN 'recurring' + WHEN 2 THEN 'forever' + END AS frequency, + ac.frequency_duration, + ac.frequency_duration_remaining, + ac.created_at, + ac.terminated_at, + ac.updated_at, + ( + SELECT json_agg( + json_build_object( + 'lago_id', cr.id, + 'amount_cents', cr.amount_cents, + 'amount_currency', cr.amount_currency, + 'before_taxes', cr.before_taxes + ) + ) + FROM credits AS cr + WHERE cr.applied_coupon_id = ac.id + ) AS credits +FROM applied_coupons AS ac +LEFT JOIN coupons AS cp ON cp.id = ac.coupon_id; diff --git a/db/views/exports_applied_coupons_v02.sql b/db/views/exports_applied_coupons_v02.sql new file mode 100644 index 0000000..2502b2e --- /dev/null +++ b/db/views/exports_applied_coupons_v02.sql @@ -0,0 +1,50 @@ +SELECT + ac.organization_id, + ac.id AS lago_id, + ac.coupon_id AS lago_coupon_id, + ac.customer_id AS lago_customer_id, + CASE ac.status + WHEN 0 THEN 'active' + WHEN 1 THEN 'terminated' + END AS status, + ac.amount_cents, + CASE ac.frequency + WHEN 0 THEN null + WHEN 1 THEN null + ELSE + CASE + WHEN cp.coupon_type = 1 THEN NULL -- coupon is percentage + ELSE + ac.amount_cents - ( + SELECT SUM(cr.amount_cents)::bigint + FROM credits AS cr + WHERE cr.applied_coupon_id = ac.id + ) + END + END AS amount_cents_remaining, + ac.amount_currency, + ac.percentage_rate, + CASE ac.frequency + WHEN 0 THEN 'once' + WHEN 1 THEN 'recurring' + WHEN 2 THEN 'forever' + END AS frequency, + ac.frequency_duration, + ac.frequency_duration_remaining, + ac.created_at, + ac.terminated_at, + ac.updated_at, + ( + SELECT json_agg( + json_build_object( + 'lago_id', cr.id, + 'amount_cents', cr.amount_cents, + 'amount_currency', cr.amount_currency, + 'before_taxes', cr.before_taxes + ) + ) + FROM credits AS cr + WHERE cr.applied_coupon_id = ac.id + ) AS credits +FROM applied_coupons AS ac +LEFT JOIN coupons AS cp ON cp.id = ac.coupon_id; diff --git a/db/views/exports_billable_metrics_v01.sql b/db/views/exports_billable_metrics_v01.sql new file mode 100644 index 0000000..6413d30 --- /dev/null +++ b/db/views/exports_billable_metrics_v01.sql @@ -0,0 +1,40 @@ +SELECT + bm.organization_id, + bm.id AS lago_id, + bm.name, + bm.code, + bm.description, + CASE bm.aggregation_type + WHEN 0 THEN 'count_agg' + WHEN 1 THEN 'sum_agg' + WHEN 2 THEN 'max_agg' + WHEN 3 THEN 'unique_count_agg' + WHEN 5 THEN 'weighted_sum_agg' + WHEN 6 THEN 'latest_agg' + WHEN 7 THEN 'custom_agg' + ELSE 'unknown' + END AS aggregation_type, + bm.weighted_interval::text, + bm.recurring, + bm.rounding_function::text, + bm.rounding_precision, + bm.created_at, + bm.updated_at, + bm.field_name, + bm.expression, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'key', bmf.key, + 'values', bmf.values + ) + ) + FROM billable_metric_filters AS bmf + WHERE bmf.billable_metric_id = bm.id + AND bmf.deleted_at IS NULL + ), + '[]'::json + ) AS filters +FROM billable_metrics AS bm +WHERE bm.deleted_at IS NULL; diff --git a/db/views/exports_billing_entities_v01.sql b/db/views/exports_billing_entities_v01.sql new file mode 100644 index 0000000..9c2d96e --- /dev/null +++ b/db/views/exports_billing_entities_v01.sql @@ -0,0 +1,21 @@ +SELECT + organization_id, + id AS lago_id, + code, + name, + legal_name, + legal_number, + email, + address_line1, + address_line2, + city, + zipcode, + state, + country, + vat_rate, + timezone, + created_at, + updated_at, + archived_at, + deleted_at +FROM billing_entities AS be; \ No newline at end of file diff --git a/db/views/exports_charges_v01.sql b/db/views/exports_charges_v01.sql new file mode 100644 index 0000000..88831d7 --- /dev/null +++ b/db/views/exports_charges_v01.sql @@ -0,0 +1,54 @@ +SELECT + p.organization_id, + c.id AS lago_id, + c.billable_metric_id AS lago_billable_metric_id, + c.invoice_display_name, + c.created_at, + c.updated_at, + CASE c.charge_model + WHEN 0 THEN 'standard' + WHEN 1 THEN 'graduated' + WHEN 2 THEN 'package' + WHEN 3 THEN 'percentage' + WHEN 4 THEN 'volume' + WHEN 5 THEN 'graduated_percentage' + WHEN 6 THEN 'custom' + WHEN 7 THEN 'dynamic' + END AS charge_model, + c.invoiceable, + CASE c.regroup_paid_fees + WHEN 0 THEN 'invoice' + END AS regroup_paid_fees, + c.pay_in_advance, + c.prorated, + c.min_amount_cents, + c.properties, + ( + SELECT + json_agg ( + json_build_object ( + 'invoice_display_name', + cf.invoice_display_name, + 'properties', + cf.properties, + 'values', + ( + SELECT + json_agg ( + json_build_object (cfcv.billable_metric_filter_id, cfcv.values) + ) + FROM + charge_filter_values AS cfcv + WHERE + cfcv.charge_filter_id = cf.id + ) + ) + ) + FROM + charge_filters AS cf + WHERE + cf.charge_id = c.id + ) AS charge_filters +FROM + charges AS c + LEFT JOIN plans AS p ON p.id = c.plan_id; diff --git a/db/views/exports_charges_v02.sql b/db/views/exports_charges_v02.sql new file mode 100644 index 0000000..a80f72f --- /dev/null +++ b/db/views/exports_charges_v02.sql @@ -0,0 +1,53 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billable_metric_id AS lago_billable_metric_id, + c.invoice_display_name, + c.created_at, + c.updated_at, + CASE c.charge_model + WHEN 0 THEN 'standard' + WHEN 1 THEN 'graduated' + WHEN 2 THEN 'package' + WHEN 3 THEN 'percentage' + WHEN 4 THEN 'volume' + WHEN 5 THEN 'graduated_percentage' + WHEN 6 THEN 'custom' + WHEN 7 THEN 'dynamic' + END AS charge_model, + c.invoiceable, + CASE c.regroup_paid_fees + WHEN 0 THEN 'invoice' + END AS regroup_paid_fees, + c.pay_in_advance, + c.prorated, + c.min_amount_cents, + c.properties, + ( + SELECT + json_agg ( + json_build_object ( + 'invoice_display_name', + cf.invoice_display_name, + 'properties', + cf.properties, + 'values', + ( + SELECT + json_agg ( + json_build_object (cfcv.billable_metric_filter_id, cfcv.values) + ) + FROM + charge_filter_values AS cfcv + WHERE + cfcv.charge_filter_id = cf.id + ) + ) + ) + FROM + charge_filters AS cf + WHERE + cf.charge_id = c.id + ) AS charge_filters +FROM + charges AS c; diff --git a/db/views/exports_charges_v03.sql b/db/views/exports_charges_v03.sql new file mode 100644 index 0000000..82b5f81 --- /dev/null +++ b/db/views/exports_charges_v03.sql @@ -0,0 +1,55 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billable_metric_id AS lago_billable_metric_id, + c.plan_id AS lago_plan_id, + c.invoice_display_name, + c.created_at, + c.updated_at, + c.deleted_at, + CASE c.charge_model + WHEN 0 THEN 'standard' + WHEN 1 THEN 'graduated' + WHEN 2 THEN 'package' + WHEN 3 THEN 'percentage' + WHEN 4 THEN 'volume' + WHEN 5 THEN 'graduated_percentage' + WHEN 6 THEN 'custom' + WHEN 7 THEN 'dynamic' + END AS charge_model, + c.invoiceable, + CASE c.regroup_paid_fees + WHEN 0 THEN 'invoice' + END AS regroup_paid_fees, + c.pay_in_advance, + c.prorated, + c.min_amount_cents, + c.properties, + ( + SELECT + json_agg ( + json_build_object ( + 'invoice_display_name', + cf.invoice_display_name, + 'properties', + cf.properties, + 'values', + ( + SELECT + json_agg ( + json_build_object (cfcv.billable_metric_filter_id, cfcv.values) + ) + FROM + charge_filter_values AS cfcv + WHERE + cfcv.charge_filter_id = cf.id + ) + ) + ) + FROM + charge_filters AS cf + WHERE + cf.charge_id = c.id + ) AS charge_filters +FROM + charges AS c; diff --git a/db/views/exports_coupons_v01.sql b/db/views/exports_coupons_v01.sql new file mode 100644 index 0000000..d833f65 --- /dev/null +++ b/db/views/exports_coupons_v01.sql @@ -0,0 +1,54 @@ +SELECT + cp.organization_id, + cp.id AS lago_id, + cp.name, + cp.code, + cp.description, + CASE cp.coupon_type + WHEN 0 THEN 'fixed_amount' + WHEN 1 THEN 'percentage' + END AS coupon_type, + cp.amount_cents, + cp.amount_currency, + cp.percentage_rate, + CASE cp.frequency + WHEN 0 THEN 'once' + WHEN 1 THEN 'recurring' + WHEN 2 THEN 'forever' + END as frequency, + cp.frequency_duration, + cp.reusable, + cp.limited_plans, + cp.limited_billable_metrics, + to_json ( + ARRAY( + SELECT + cpt.plan_id + FROM + coupon_targets AS cpt + WHERE + cpt.coupon_id = cp.id + AND cpt.plan_id IS NOT NULL + ) + ) AS lago_plan_ids, + to_json ( + ARRAY( + SELECT + cpt.billable_metric_id + FROM + coupon_targets AS cpt + WHERE + cpt.coupon_id = cp.id + AND cpt.billable_metric_id IS NOT NULL + ) + ) AS lago_billable_metrics_ids, + cp.created_at, + CASE cp.expiration + WHEN 0 then 'no_expiration' + WHEN 1 then 'time_limit' + END AS expiration, + cp.expiration_at, + cp.terminated_at, + cp.updated_at +FROM + coupons AS cp; diff --git a/db/views/exports_credit_notes_taxes_v01.sql b/db/views/exports_credit_notes_taxes_v01.sql new file mode 100644 index 0000000..c47eaa9 --- /dev/null +++ b/db/views/exports_credit_notes_taxes_v01.sql @@ -0,0 +1,17 @@ +SELECT + c.organization_id, + cn.id AS lago_id, + cnt.tax_id AS lago_tax_id, + cnt.credit_note_id AS lago_credit_note_id, + cnt.tax_name, + cnt.tax_code, + cnt.tax_rate, + cnt.tax_description, + cnt.base_amount_cents, + cnt.amount_cents, + cnt.amount_currency, + cnt.created_at, + cnt.updated_at +FROM credit_notes_taxes AS cnt +LEFT JOIN credit_notes AS cn ON cn.id = cnt.credit_note_id +LEFT JOIN customers AS c ON c.id = cn.customer_id; diff --git a/db/views/exports_credit_notes_taxes_v02.sql b/db/views/exports_credit_notes_taxes_v02.sql new file mode 100644 index 0000000..7fa3ed5 --- /dev/null +++ b/db/views/exports_credit_notes_taxes_v02.sql @@ -0,0 +1,15 @@ +SELECT + cnt.organization_id, + cnt.id AS lago_id, + cnt.tax_id AS lago_tax_id, + cnt.credit_note_id AS lago_credit_note_id, + cnt.tax_name, + cnt.tax_code, + cnt.tax_rate, + cnt.tax_description, + cnt.base_amount_cents, + cnt.amount_cents, + cnt.amount_currency, + cnt.created_at, + cnt.updated_at +FROM credit_notes_taxes AS cnt; diff --git a/db/views/exports_credit_notes_v01.sql b/db/views/exports_credit_notes_v01.sql new file mode 100644 index 0000000..7afc6e5 --- /dev/null +++ b/db/views/exports_credit_notes_v01.sql @@ -0,0 +1,85 @@ +SELECT + c.organization_id, + cn.id AS lago_id, + cn.sequential_id, + cn.number, + cn.invoice_id AS lago_invoice_id, + cn.issuing_date, + CASE cn.credit_status + WHEN 0 THEN 'available' + WHEN 1 THEN 'consumed' + WHEN 2 THEN 'voided' + END AS credit_status, + CASE cn.refund_status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'succeeded' + WHEN 2 THEN 'failed' + END AS refund_status, + CASE cn.reason + WHEN 0 then 'duplicated_charge' + WHEN 1 then 'product_unsatisfactory' + WHEN 2 then 'order_change' + when 3 then 'order_cancellation' + when 4 then 'fraudulent_charge' + when 5 then 'other' + END as reason, + cn.description, + cn.total_amount_currency AS currency, + cn.total_amount_cents, + cn.taxes_amount_cents, + ROUND( + ( + SELECT + SUM(ci.precise_amount_cents)::bigint + FROM + credit_note_items AS ci + WHERE + ci.credit_note_id = cn.id + ) - cn.precise_coupons_adjustment_amount_cents + )::bigint AS sub_total_excluding_taxes_amount_cents, + cn.balance_amount_cents, + cn.credit_amount_cents, + cn.refund_amount_cents, + cn.coupons_adjustment_amount_cents, + cn.taxes_rate, + cn.created_at, + cn.updated_at, + ( + SELECT + json_agg ( + json_build_object ( + 'lago_id', + ci.id, + 'amount_cents', + ci.amount_cents, + 'amount_currency', + ci.amount_currency, + 'lago_fee_id', + ci.fee_id + ) + ) + FROM + credit_note_items AS ci + WHERE + ci.credit_note_id = cn.id + ) AS items, + ( + SELECT + json_agg ( + json_build_object ( + 'lago_id', + ed.id, + 'error_code', + ed.error_code, + 'details', + ed.details + ) + ) + FROM + error_details AS ed + WHERE + ed.owner_id = cn.id + ) AS error_details +FROM + credit_notes AS cn + LEFT JOIN customers AS c ON c.id = cn.customer_id; diff --git a/db/views/exports_credit_notes_v02.sql b/db/views/exports_credit_notes_v02.sql new file mode 100644 index 0000000..c250c3a --- /dev/null +++ b/db/views/exports_credit_notes_v02.sql @@ -0,0 +1,84 @@ +SELECT + cn.organization_id, + cn.id AS lago_id, + cn.sequential_id, + cn.number, + cn.invoice_id AS lago_invoice_id, + cn.issuing_date, + CASE cn.credit_status + WHEN 0 THEN 'available' + WHEN 1 THEN 'consumed' + WHEN 2 THEN 'voided' + END AS credit_status, + CASE cn.refund_status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'succeeded' + WHEN 2 THEN 'failed' + END AS refund_status, + CASE cn.reason + WHEN 0 then 'duplicated_charge' + WHEN 1 then 'product_unsatisfactory' + WHEN 2 then 'order_change' + when 3 then 'order_cancellation' + when 4 then 'fraudulent_charge' + when 5 then 'other' + END as reason, + cn.description, + cn.total_amount_currency AS currency, + cn.total_amount_cents, + cn.taxes_amount_cents, + ROUND( + ( + SELECT + SUM(ci.precise_amount_cents)::bigint + FROM + credit_note_items AS ci + WHERE + ci.credit_note_id = cn.id + ) - cn.precise_coupons_adjustment_amount_cents + )::bigint AS sub_total_excluding_taxes_amount_cents, + cn.balance_amount_cents, + cn.credit_amount_cents, + cn.refund_amount_cents, + cn.coupons_adjustment_amount_cents, + cn.taxes_rate, + cn.created_at, + cn.updated_at, + ( + SELECT + json_agg ( + json_build_object ( + 'lago_id', + ci.id, + 'amount_cents', + ci.amount_cents, + 'amount_currency', + ci.amount_currency, + 'lago_fee_id', + ci.fee_id + ) + ) + FROM + credit_note_items AS ci + WHERE + ci.credit_note_id = cn.id + ) AS items, + ( + SELECT + json_agg ( + json_build_object ( + 'lago_id', + ed.id, + 'error_code', + ed.error_code, + 'details', + ed.details + ) + ) + FROM + error_details AS ed + WHERE + ed.owner_id = cn.id + ) AS error_details +FROM + credit_notes AS cn; diff --git a/db/views/exports_credit_notes_v03.sql b/db/views/exports_credit_notes_v03.sql new file mode 100644 index 0000000..3808de6 --- /dev/null +++ b/db/views/exports_credit_notes_v03.sql @@ -0,0 +1,85 @@ +SELECT + cn.organization_id, + cn.id AS lago_id, + cn.sequential_id, + cn.number, + cn.invoice_id AS lago_invoice_id, + cn.issuing_date, + CASE cn.credit_status + WHEN 0 THEN 'available' + WHEN 1 THEN 'consumed' + WHEN 2 THEN 'voided' + END AS credit_status, + CASE cn.refund_status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'succeeded' + WHEN 2 THEN 'failed' + END AS refund_status, + CASE cn.reason + WHEN 0 THEN 'duplicated_charge' + WHEN 1 THEN 'product_unsatisfactory' + WHEN 2 THEN 'order_change' + WHEN 3 THEN 'order_cancellation' + WHEN 4 THEN 'fraudulent_charge' + WHEN 5 THEN 'other' + END as reason, + cn.description, + cn.total_amount_currency AS currency, + cn.total_amount_cents, + cn.taxes_amount_cents, + ROUND( + ( + SELECT + SUM(ci.precise_amount_cents)::bigint + FROM + credit_note_items AS ci + WHERE + ci.credit_note_id = cn.id + ) - cn.precise_coupons_adjustment_amount_cents + )::bigint AS sub_total_excluding_taxes_amount_cents, + cn.balance_amount_cents, + cn.credit_amount_cents, + cn.refund_amount_cents, + cn.coupons_adjustment_amount_cents, + cn.taxes_rate, + cn.created_at, + cn.updated_at, + cn.refunded_at, + ( + SELECT + json_agg ( + json_build_object ( + 'lago_id', + ci.id, + 'amount_cents', + ci.amount_cents, + 'amount_currency', + ci.amount_currency, + 'lago_fee_id', + ci.fee_id + ) + ) + FROM + credit_note_items AS ci + WHERE + ci.credit_note_id = cn.id + ) AS items, + ( + SELECT + json_agg ( + json_build_object ( + 'lago_id', + ed.id, + 'error_code', + ed.error_code, + 'details', + ed.details + ) + ) + FROM + error_details AS ed + WHERE + ed.owner_id = cn.id + ) AS error_details +FROM + credit_notes AS cn; diff --git a/db/views/exports_customers_v01.sql b/db/views/exports_customers_v01.sql new file mode 100644 index 0000000..98482e6 --- /dev/null +++ b/db/views/exports_customers_v01.sql @@ -0,0 +1,72 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + c.account_type::text, + c.name, + c.firstname, + c.lastname, + c.customer_type::text, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC') AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + case c.finalize_zero_amount_invoice + WHEN 0 then 'inherit' + WHEN 1 then 'skip' + WHEN 2 then 'finalize' + END as finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', cm.id, + 'key', cm.key, + 'value', cm.value, + 'display_in_invoice', cm.display_in_invoice + ) + ) + FROM customer_metadata cm + WHERE cm.customer_id = c.id + ), + '[]'::json + ) AS metadata, + to_json( + ARRAY( + SELECT ct.tax_id AS lago_tax_id + FROM customers_taxes AS ct + WHERE ct.customer_id = c.id + ) + ) AS lago_taxes_ids +FROM customers c +LEFT JOIN organizations o ON o.id = c.organization_id +LEFT JOIN payment_provider_customers ppc ON ppc.customer_id = c.id + AND ppc.deleted_at IS NULL +WHERE c.deleted_at IS NULL; diff --git a/db/views/exports_customers_v02.sql b/db/views/exports_customers_v02.sql new file mode 100644 index 0000000..d73b011 --- /dev/null +++ b/db/views/exports_customers_v02.sql @@ -0,0 +1,71 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + c.account_type::text, + c.name, + c.firstname, + c.lastname, + c.customer_type::text, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC') AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + CASE c.finalize_zero_amount_invoice + WHEN 0 THEN 'inherit' + WHEN 1 THEN 'skip' + WHEN 2 THEN 'finalize' + END AS finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + COALESCE( + ( + SELECT json_agg( + json_build_object( + 'id', cm.id, + 'key', cm.key, + 'value', cm.value, + 'display_in_invoice', cm.display_in_invoice + ) + ) + FROM customer_metadata cm + WHERE cm.customer_id = c.id + ), + '[]'::json + ) AS metadata, + to_json( + ARRAY( + SELECT ct.tax_id AS lago_tax_id + FROM customers_taxes AS ct + WHERE ct.customer_id = c.id + ) + ) AS lago_taxes_ids +FROM customers c +LEFT JOIN organizations o ON o.id = c.organization_id +LEFT JOIN payment_provider_customers ppc ON ppc.customer_id = c.id + AND ppc.deleted_at IS NULL; \ No newline at end of file diff --git a/db/views/exports_customers_v03.sql b/db/views/exports_customers_v03.sql new file mode 100644 index 0000000..b454069 --- /dev/null +++ b/db/views/exports_customers_v03.sql @@ -0,0 +1,56 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + c.account_type::text, + c.name, + c.firstname, + c.lastname, + c.customer_type::text, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC') AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + CASE c.finalize_zero_amount_invoice + WHEN 0 THEN 'inherit' + WHEN 1 THEN 'skip' + WHEN 2 THEN 'finalize' + END AS finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + to_json( + ARRAY( + SELECT ct.tax_id AS lago_tax_id + FROM customers_taxes AS ct + WHERE ct.customer_id = c.id + ) + ) AS lago_taxes_ids +FROM customers c +LEFT JOIN organizations o ON o.id = c.organization_id +LEFT JOIN payment_provider_customers ppc ON ppc.customer_id = c.id + AND ppc.deleted_at IS NULL; \ No newline at end of file diff --git a/db/views/exports_customers_v04.sql b/db/views/exports_customers_v04.sql new file mode 100644 index 0000000..c8984ee --- /dev/null +++ b/db/views/exports_customers_v04.sql @@ -0,0 +1,57 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + c.account_type::text, + c.name, + c.firstname, + c.lastname, + c.customer_type::text, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC') AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + CASE c.finalize_zero_amount_invoice + WHEN 0 THEN 'inherit' + WHEN 1 THEN 'skip' + WHEN 2 THEN 'finalize' + END AS finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + NULL AS metadata, + to_json( + ARRAY( + SELECT ct.tax_id AS lago_tax_id + FROM customers_taxes AS ct + WHERE ct.customer_id = c.id + ) + ) AS lago_taxes_ids +FROM customers c +LEFT JOIN organizations o ON o.id = c.organization_id +LEFT JOIN payment_provider_customers ppc ON ppc.customer_id = c.id + AND ppc.deleted_at IS NULL; \ No newline at end of file diff --git a/db/views/exports_customers_v05.sql b/db/views/exports_customers_v05.sql new file mode 100644 index 0000000..989b613 --- /dev/null +++ b/db/views/exports_customers_v05.sql @@ -0,0 +1,57 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + c.account_type::text, + c.name, + c.firstname, + c.lastname, + c.customer_type::text, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC') AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + CASE c.finalize_zero_amount_invoice + WHEN 0 THEN 'inherit' + WHEN 1 THEN 'skip' + WHEN 2 THEN 'finalize' + END AS finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + '{}'::json AS metadata, + to_json( + ARRAY( + SELECT ct.tax_id AS lago_tax_id + FROM customers_taxes AS ct + WHERE ct.customer_id = c.id + ) + ) AS lago_taxes_ids +FROM customers c +LEFT JOIN organizations o ON o.id = c.organization_id +LEFT JOIN payment_provider_customers ppc ON ppc.customer_id = c.id + AND ppc.deleted_at IS NULL; \ No newline at end of file diff --git a/db/views/exports_customers_v06.sql b/db/views/exports_customers_v06.sql new file mode 100644 index 0000000..4f6a97c --- /dev/null +++ b/db/views/exports_customers_v06.sql @@ -0,0 +1,51 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + c.account_type::text, + c.name, + c.firstname, + c.lastname, + c.customer_type::text, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC') AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + CASE c.finalize_zero_amount_invoice + WHEN 0 THEN 'inherit' + WHEN 1 THEN 'skip' + WHEN 2 THEN 'finalize' + END AS finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + '{}'::json AS metadata, + '[]'::json AS lago_taxes_ids +FROM customers c +LEFT JOIN organizations o ON o.id = c.organization_id +LEFT JOIN payment_provider_customers ppc ON ppc.customer_id = c.id + AND ppc.deleted_at IS NULL; \ No newline at end of file diff --git a/db/views/exports_customers_v07.sql b/db/views/exports_customers_v07.sql new file mode 100644 index 0000000..d9c09c4 --- /dev/null +++ b/db/views/exports_customers_v07.sql @@ -0,0 +1,52 @@ +SELECT + c.organization_id, + c.id AS lago_id, + c.billing_entity_id, + c.external_id, + c.account_type::text, + c.name, + c.firstname, + c.lastname, + c.customer_type::text, + c.sequential_id, + c.slug, + c.created_at, + c.updated_at, + c.deleted_at, + c.country, + c.address_line1, + c.address_line2, + c.state, + c.zipcode, + c.email, + c.city, + c.url, + c.phone, + c.legal_name, + c.legal_number, + c.currency, + c.tax_identification_number, + c.timezone, + COALESCE(c.timezone, o.timezone, 'UTC') AS applicable_timezone, + c.net_payment_term, + c.external_salesforce_id, + CASE c.finalize_zero_amount_invoice + WHEN 0 THEN 'inherit' + WHEN 1 THEN 'skip' + WHEN 2 THEN 'finalize' + END AS finalize_zero_amount_invoice, + c.skip_invoice_custom_sections, + c.payment_provider, + c.payment_provider_code, + c.invoice_grace_period, + c.vat_rate, + COALESCE(c.invoice_grace_period, o.invoice_grace_period) AS applicable_invoice_grace_period, + c.document_locale, + ppc.provider_customer_id, + ppc.settings AS provider_settings, + '{}'::json AS metadata, + '[]'::json AS lago_taxes_ids +FROM customers c +LEFT JOIN organizations o ON o.id = c.organization_id +LEFT JOIN payment_provider_customers ppc ON ppc.customer_id = c.id + AND ppc.deleted_at IS NULL; \ No newline at end of file diff --git a/db/views/exports_daily_usages_v01.sql b/db/views/exports_daily_usages_v01.sql new file mode 100644 index 0000000..a447261 --- /dev/null +++ b/db/views/exports_daily_usages_v01.sql @@ -0,0 +1,15 @@ +SELECT + du.organization_id, + du.id AS lago_id, + du.from_datetime, + du.to_datetime, + du.refreshed_at, + du.usage_date, + du.usage AS daily_usage, + du.usage_diff AS daily_usage_diff, + du.created_at, + du.updated_at, + du.customer_id AS lago_customer_id, + du.subscription_id AS lago_subscription_id, + du.external_subscription_id +FROM daily_usages AS du; \ No newline at end of file diff --git a/db/views/exports_entitlement_entitlement_values_v01.sql b/db/views/exports_entitlement_entitlement_values_v01.sql new file mode 100644 index 0000000..738d23b --- /dev/null +++ b/db/views/exports_entitlement_entitlement_values_v01.sql @@ -0,0 +1,10 @@ +SELECT + ev.id AS lago_id, + ev.organization_id, + ev.entitlement_entitlement_id AS lago_entitlement_entitlement_id, + ev.entitlement_privilege_id AS lago_entitlement_privilege_id, + ev.value, + ev.deleted_at, + ev.created_at, + ev.updated_at +FROM entitlement_entitlement_values AS ev; diff --git a/db/views/exports_entitlement_entitlements_v01.sql b/db/views/exports_entitlement_entitlements_v01.sql new file mode 100644 index 0000000..d7bc15f --- /dev/null +++ b/db/views/exports_entitlement_entitlements_v01.sql @@ -0,0 +1,10 @@ +SELECT + ee.id AS lago_id, + ee.organization_id, + ee.entitlement_feature_id AS lago_entitlement_feature_id, + ee.plan_id AS lago_plan_id, + ee.subscription_id AS lago_subscription_id, + ee.deleted_at, + ee.created_at, + ee.updated_at +FROM entitlement_entitlements AS ee; diff --git a/db/views/exports_entitlement_features_v01.sql b/db/views/exports_entitlement_features_v01.sql new file mode 100644 index 0000000..18032b1 --- /dev/null +++ b/db/views/exports_entitlement_features_v01.sql @@ -0,0 +1,10 @@ +SELECT + ef.id AS lago_id, + ef.organization_id, + ef.code, + ef.name, + ef.description, + ef.deleted_at, + ef.created_at, + ef.updated_at +FROM entitlement_features AS ef; diff --git a/db/views/exports_fees_taxes_v01.sql b/db/views/exports_fees_taxes_v01.sql new file mode 100644 index 0000000..0e88994 --- /dev/null +++ b/db/views/exports_fees_taxes_v01.sql @@ -0,0 +1,15 @@ +SELECT + f.organization_id, + ft.id AS lago_id, + ft.fee_id AS lago_fee_id, + ft.tax_id AS lago_tax_id, + ft.tax_name, + ft.tax_code, + ft.tax_rate, + ft.tax_description, + ft.amount_cents, + ft.amount_currency, + ft.created_at, + ft.updated_at +FROM fees_taxes AS ft +LEFT JOIN fees AS f ON f.id = ft.fee_id; diff --git a/db/views/exports_fees_taxes_v02.sql b/db/views/exports_fees_taxes_v02.sql new file mode 100644 index 0000000..bc73b68 --- /dev/null +++ b/db/views/exports_fees_taxes_v02.sql @@ -0,0 +1,14 @@ +SELECT + ft.organization_id, + ft.id AS lago_id, + ft.fee_id AS lago_fee_id, + ft.tax_id AS lago_tax_id, + ft.tax_name, + ft.tax_code, + ft.tax_rate, + ft.tax_description, + ft.amount_cents, + ft.amount_currency, + ft.created_at, + ft.updated_at +FROM fees_taxes AS ft; diff --git a/db/views/exports_fees_v01.sql b/db/views/exports_fees_v01.sql new file mode 100644 index 0000000..5631506 --- /dev/null +++ b/db/views/exports_fees_v01.sql @@ -0,0 +1,117 @@ +SELECT + f.organization_id, + f.id AS lago_id, + f.charge_id AS lago_charge_id, + f.charge_filter_id AS lago_charge_filter_id, + f.invoice_id AS lago_invoice_id, + f.subscription_id AS lago_subscription_id, + c.id AS lago_customer_id, + json_build_object( + 'type', CASE f.fee_type + WHEN 0 THEN 'charge' -- Assuming 0 maps to :charge + WHEN 1 THEN 'add_on' -- Assuming 1 maps to :add_on + WHEN 2 THEN 'subscription' -- Assuming 2 maps to :subscription + WHEN 3 THEN 'credit' -- Assuming 3 maps to :credit + WHEN 4 THEN 'commitment' -- Assuming 4 maps to :commitment + ELSE 'unknown' + END, + 'code', CASE f.fee_type + WHEN 0 THEN bm.code -- 0 is charge + WHEN 1 THEN ao.code -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.code -- everything else is subscription + END, + 'name', CASE f.fee_type + WHEN 0 THEN bm.name -- 0 is charge + WHEN 1 THEN ao.name -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.name -- everything else is subscription + END, + 'description', CASE f.fee_type + WHEN 0 THEN bm.description -- 0 is charge + WHEN 1 THEN ao.description -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.description -- everything else is subscription + END, + 'invoice_display_name', COALESCE( + f.invoice_display_name, + CASE f.fee_type + WHEN 0 THEN COALESCE( + ch.invoice_display_name, + bm.name + ) -- 0 is charge + WHEN 1 THEN COALESCE(ao.invoice_display_name, ao.name) -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.invoice_display_name -- everything else is subscription + END + ), + 'filters', ( + SELECT json_agg( + json_build_object( + 'id', cf.id, + 'charge_id', cf.charge_id, + 'properties', cf.properties, + 'invoice_display_name', cf.invoice_display_name + ) + ) + FROM charge_filters AS cf + WHERE cf.charge_id = f.charge_id + ), + 'lago_item_id', CASE f.fee_type + WHEN 0 THEN bm.id -- 0 is charge + WHEN 1 THEN ao.id -- 1 is add_on + WHEN 3 THEN invoiceable_id -- 3 is credit + ELSE f.subscription_id -- everything else is subscription + END, + 'item_type', CASE f.fee_type + WHEN 0 THEN 'billable_metric' -- 0 is charge + WHEN 1 THEN 'add_on' -- 1 is add_on + WHEN 3 THEN 'wallet_transaction' -- 3 is credit + ELSE 'subscription' -- everything else is subscription + END, + 'grouped_by', f.grouped_by + ) AS item, + f.pay_in_advance, + f.amount_cents, + ch.invoiceable AS invoiceable, + f.taxes_amount_cents, + f.taxes_precise_amount_cents, + f.taxes_rate, + f.amount_cents + f.taxes_amount_cents AS total_amount_cents, + f.amount_currency AS currency, + f.units, + f.description, + f.precise_amount_cents, + f.precise_unit_amount, + f.precise_coupons_amount_cents, + f.precise_amount_cents + f.taxes_precise_amount_cents AS precise_total_amount_cents, + f.events_count, + -- payment-status + CASE f.payment_status + WHEN 0 THEN 'pending' -- Assuming 0 maps to :pending + WHEN 1 THEN 'succeeded' -- Assuming 1 maps to :succeeded + WHEN 2 THEN 'failed' -- Assuming 2 maps to :failed + WHEN 3 THEN 'refunded' -- Assuming 3 maps to :refunded + ELSE 'unknown' + END AS payment_status, + f.created_at, + f.succeeded_at, + f.failed_at, + f.refunded_at, + f.amount_details, + f.updated_at, + CASE f.fee_type + WHEN 0 THEN (f.properties->>'charges_from_datetime')::timestamptz::text + ELSE (f.properties->>'from_datetime')::timestamptz::text + END AS from_date, + CASE f.fee_type + WHEN 0 THEN (f.properties->>'charges_to_datetime')::timestamptz::text + ELSE (f.properties->>'to_datetime')::timestamptz::text + END AS to_date +FROM fees AS f +LEFT JOIN subscriptions AS s ON f.subscription_id = s.id +LEFT JOIN customers AS c ON s.customer_id = c.id +LEFT JOIN charges AS ch ON f.charge_id = ch.id +LEFT JOIN billable_metrics AS bm ON ch.billable_metric_id = bm.id +LEFT JOIN add_ons AS ao ON f.add_on_id = ao.id +LEFT JOIN plans AS p ON s.plan_id = p.id \ No newline at end of file diff --git a/db/views/exports_fees_v02.sql b/db/views/exports_fees_v02.sql new file mode 100644 index 0000000..ea3ea15 --- /dev/null +++ b/db/views/exports_fees_v02.sql @@ -0,0 +1,118 @@ +SELECT + f.organization_id, + f.id AS lago_id, + f.charge_id AS lago_charge_id, + f.charge_filter_id AS lago_charge_filter_id, + f.invoice_id AS lago_invoice_id, + f.subscription_id AS lago_subscription_id, + c.id AS lago_customer_id, + json_build_object( + 'type', CASE f.fee_type + WHEN 0 THEN 'charge' -- Assuming 0 maps to :charge + WHEN 1 THEN 'add_on' -- Assuming 1 maps to :add_on + WHEN 2 THEN 'subscription' -- Assuming 2 maps to :subscription + WHEN 3 THEN 'credit' -- Assuming 3 maps to :credit + WHEN 4 THEN 'commitment' -- Assuming 4 maps to :commitment + ELSE 'unknown' + END, + 'code', CASE f.fee_type + WHEN 0 THEN bm.code -- 0 is charge + WHEN 1 THEN ao.code -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.code -- everything else is subscription + END, + 'name', CASE f.fee_type + WHEN 0 THEN bm.name -- 0 is charge + WHEN 1 THEN ao.name -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.name -- everything else is subscription + END, + 'description', CASE f.fee_type + WHEN 0 THEN bm.description -- 0 is charge + WHEN 1 THEN ao.description -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.description -- everything else is subscription + END, + 'invoice_display_name', COALESCE( + f.invoice_display_name, + CASE f.fee_type + WHEN 0 THEN COALESCE( + ch.invoice_display_name, + bm.name + ) -- 0 is charge + WHEN 1 THEN COALESCE(ao.invoice_display_name, ao.name) -- 1 is add_on + WHEN 3 THEN 'credit' -- 3 is credit + ELSE p.invoice_display_name -- everything else is subscription + END + ), + 'filters', ( + SELECT json_agg( + json_build_object( + 'id', cf.id, + 'charge_id', cf.charge_id, + 'properties', cf.properties, + 'invoice_display_name', cf.invoice_display_name + ) + ) + FROM charge_filters AS cf + WHERE cf.charge_id = f.charge_id + ), + 'lago_item_id', CASE f.fee_type + WHEN 0 THEN bm.id -- 0 is charge + WHEN 1 THEN ao.id -- 1 is add_on + WHEN 3 THEN f.invoiceable_id -- 3 is credit + ELSE f.subscription_id -- everything else is subscription + END, + 'item_type', CASE f.fee_type + WHEN 0 THEN 'billable_metric' -- 0 is charge + WHEN 1 THEN 'add_on' -- 1 is add_on + WHEN 3 THEN 'wallet_transaction' -- 3 is credit + ELSE 'subscription' -- everything else is subscription + END, + 'grouped_by', f.grouped_by + ) AS item, + f.pay_in_advance, + f.amount_cents, + ch.invoiceable AS invoiceable, + f.taxes_amount_cents, + f.taxes_precise_amount_cents, + f.taxes_rate, + f.amount_cents + f.taxes_amount_cents AS total_amount_cents, + f.amount_currency AS currency, + f.units, + f.description, + f.precise_amount_cents, + f.precise_unit_amount, + f.precise_coupons_amount_cents, + f.precise_amount_cents + f.taxes_precise_amount_cents AS precise_total_amount_cents, + f.precise_credit_notes_amount_cents, + f.events_count, + -- payment-status + CASE f.payment_status + WHEN 0 THEN 'pending' -- Assuming 0 maps to :pending + WHEN 1 THEN 'succeeded' -- Assuming 1 maps to :succeeded + WHEN 2 THEN 'failed' -- Assuming 2 maps to :failed + WHEN 3 THEN 'refunded' -- Assuming 3 maps to :refunded + ELSE 'unknown' + END AS payment_status, + f.created_at, + f.succeeded_at, + f.failed_at, + f.refunded_at, + f.amount_details, + f.updated_at, + CASE f.fee_type + WHEN 0 THEN (f.properties->>'charges_from_datetime')::timestamptz::text + ELSE (f.properties->>'from_datetime')::timestamptz::text + END AS from_date, + CASE f.fee_type + WHEN 0 THEN (f.properties->>'charges_to_datetime')::timestamptz::text + ELSE (f.properties->>'to_datetime')::timestamptz::text + END AS to_date +FROM fees AS f +LEFT JOIN subscriptions AS s ON f.subscription_id = s.id +LEFT JOIN customers AS c ON s.customer_id = c.id +LEFT JOIN charges AS ch ON f.charge_id = ch.id +LEFT JOIN billable_metrics AS bm ON ch.billable_metric_id = bm.id +LEFT JOIN add_ons AS ao ON f.add_on_id = ao.id +LEFT JOIN plans AS p ON s.plan_id = p.id \ No newline at end of file diff --git a/db/views/exports_integration_customers_v01.sql b/db/views/exports_integration_customers_v01.sql new file mode 100644 index 0000000..668b610 --- /dev/null +++ b/db/views/exports_integration_customers_v01.sql @@ -0,0 +1,11 @@ +SELECT + ic.id AS lago_id, + ic.organization_id, + ic.customer_id as lago_customer_id, + ic.integration_id AS lago_integration_id, + ic.external_customer_id, + ic.type, + ic.settings, + ic.created_at, + ic.updated_at +FROM integration_customers AS ic; diff --git a/db/views/exports_invoice_settlements_v01.sql b/db/views/exports_invoice_settlements_v01.sql new file mode 100644 index 0000000..04d6d2f --- /dev/null +++ b/db/views/exports_invoice_settlements_v01.sql @@ -0,0 +1,13 @@ +SELECT + ins.id AS lago_id, + ins.organization_id, + ins.billing_entity_id AS lago_billing_entity_id, + ins.target_invoice_id AS lago_target_invoice_id, + ins.settlement_type, + ins.source_payment_id AS lago_source_payment_id, + ins.source_credit_note_id AS lago_source_credit_note_id, + ins.amount_cents, + ins.amount_currency, + ins.created_at, + ins.updated_at +FROM invoice_settlements AS ins; diff --git a/db/views/exports_invoice_settlements_v02.sql b/db/views/exports_invoice_settlements_v02.sql new file mode 100644 index 0000000..6dc9685 --- /dev/null +++ b/db/views/exports_invoice_settlements_v02.sql @@ -0,0 +1,13 @@ +SELECT + ins.id AS lago_id, + ins.organization_id, + ins.billing_entity_id AS lago_billing_entity_id, + ins.target_invoice_id AS lago_target_invoice_id, + ins.settlement_type::text, + ins.source_payment_id AS lago_source_payment_id, + ins.source_credit_note_id AS lago_source_credit_note_id, + ins.amount_cents, + ins.amount_currency, + ins.created_at, + ins.updated_at +FROM invoice_settlements AS ins; diff --git a/db/views/exports_invoice_subscriptions_v01.sql b/db/views/exports_invoice_subscriptions_v01.sql new file mode 100644 index 0000000..9d89465 --- /dev/null +++ b/db/views/exports_invoice_subscriptions_v01.sql @@ -0,0 +1,15 @@ +SELECT + ins.organization_id, + ins.id AS lago_id, + ins.invoice_id AS lago_invoice_id, + ins.regenerated_invoice_id AS lago_regenerated_invoice_id, + ins.subscription_id AS lago_subscription_id, + ins.created_at, + ins.updated_at, + ins.from_datetime, + ins.to_datetime, + ins.charges_from_datetime, + ins.charges_to_datetime, + ins.timestamp, + ins.invoicing_reason::text as invoicing_reason +FROM invoice_subscriptions AS ins; diff --git a/db/views/exports_invoices_taxes_v01.sql b/db/views/exports_invoices_taxes_v01.sql new file mode 100644 index 0000000..cf3c9ca --- /dev/null +++ b/db/views/exports_invoices_taxes_v01.sql @@ -0,0 +1,16 @@ +SELECT + t.organization_id, + it.id AS lago_id, + it.invoice_id AS lago_invoice_id, + it.tax_id AS lago_tax_id, + it.tax_name, + it.tax_code, + it.tax_rate, + it.tax_description, + it.amount_cents, + it.amount_currency, + it.fees_amount_cents, + it.created_at, + it.updated_at +FROM invoices_taxes AS it +LEFT JOIN taxes AS t ON it.tax_id = t.id; diff --git a/db/views/exports_invoices_taxes_v02.sql b/db/views/exports_invoices_taxes_v02.sql new file mode 100644 index 0000000..9d59e01 --- /dev/null +++ b/db/views/exports_invoices_taxes_v02.sql @@ -0,0 +1,15 @@ +SELECT + it.organization_id, + it.id AS lago_id, + it.invoice_id AS lago_invoice_id, + it.tax_id AS lago_tax_id, + it.tax_name, + it.tax_code, + it.tax_rate, + it.tax_description, + it.amount_cents, + it.amount_currency, + it.fees_amount_cents, + it.created_at, + it.updated_at +FROM invoices_taxes AS it; diff --git a/db/views/exports_invoices_v01.sql b/db/views/exports_invoices_v01.sql new file mode 100644 index 0000000..ea40e7a --- /dev/null +++ b/db/views/exports_invoices_v01.sql @@ -0,0 +1,75 @@ +SELECT + i.organization_id, + i.id AS lago_id, + i.sequential_id, + i.customer_id, + i.number, + i.issuing_date::timestamptz AS issuing_date, + i.payment_due_date::timestamptz AS payment_due_date, + i.net_payment_term, + CASE i.invoice_type + WHEN 0 THEN 'subscription' + WHEN 1 THEN 'add_on' + WHEN 2 THEN 'credit' + WHEN 3 THEN 'one_off' + WHEN 4 THEN 'advance_charges' + WHEN 5 THEN 'progressive_billing' + END AS invoice_type, + CASE i.status + WHEN 0 THEN 'draft' + WHEN 1 THEN 'finalized' + WHEN 2 THEN 'voided' + WHEN 3 THEN 'generating' + WHEN 4 THEN 'failed' + WHEN 5 THEN 'open' + WHEN 6 THEN 'close' + WHEN 7 THEN 'pending' + END AS status, + CASE i.payment_status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'succeeded' + WHEN 2 THEN 'failed' + END AS payment_status, + i.payment_dispute_lost_at::timestamptz AS payment_dispute_lost_at, + i.payment_overdue, + i.currency, + i.fees_amount_cents, + i.taxes_amount_cents, + i.progressive_billing_credit_amount_cents, + i.coupons_amount_cents, + i.credit_notes_amount_cents, + i.sub_total_excluding_taxes_amount_cents, + i.sub_total_including_taxes_amount_cents, + i.total_amount_cents, + i.total_amount_cents - i.total_paid_amount_cents AS total_due_amount_cents, + i.prepaid_credit_amount_cents, + i.version_number, + i.created_at, + i.updated_at, + i.voided_at, + ( + SELECT json_agg( + json_build_object( + 'lago_id', m.id, + 'key', m.key, + 'value', m.value, + 'created_at', m.created_at + ) + ) + FROM invoice_metadata AS m + WHERE m.invoice_id = i.id + ) AS metadata, + ( + SELECT json_agg( + json_build_object( + 'lago_id', ed.id, + 'error_code', ed.error_code, + 'details', ed.details + ) + ) + FROM error_details AS ed + WHERE ed.owner_id = i.id + ) AS error_details +FROM invoices AS i +LEFT JOIN invoice_metadata AS m ON i.id = m.invoice_id +WHERE i.status IN (0, 1, 2, 4, 7); diff --git a/db/views/exports_item_metadata_v01.sql b/db/views/exports_item_metadata_v01.sql new file mode 100644 index 0000000..01b7a99 --- /dev/null +++ b/db/views/exports_item_metadata_v01.sql @@ -0,0 +1,9 @@ +SELECT + im.id AS lago_id, + im.organization_id, + im.owner_type, + im.owner_id AS lago_owner_id, + im.value, + im.created_at, + im.updated_at +FROM item_metadata AS im; diff --git a/db/views/exports_payment_requests_v01.sql b/db/views/exports_payment_requests_v01.sql new file mode 100644 index 0000000..cbd0cad --- /dev/null +++ b/db/views/exports_payment_requests_v01.sql @@ -0,0 +1,33 @@ +SELECT + pr.organization_id, + pr.id AS lago_id, + pr.customer_id AS lago_customer_id, + pr.payment_attempts, + pr.amount_cents, + pr.amount_currency, + pr.email, + pr.ready_for_payment_processing, + CASE pr.payment_status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'succeeded' + WHEN 2 THEN 'failed' + END AS payment_status, + to_json( + ARRAY( + SELECT p.id + FROM payments AS p + WHERE p.payable_id = pr.id AND p.payable_type = 'PaymentRequest' + ORDER BY p.created_at + ) + ) AS payment_ids, + to_json( + ARRAY( + SELECT apr.invoice_id + FROM invoices_payment_requests AS apr + WHERE apr.payment_request_id = pr.id + ORDER BY apr.created_at + ) + ) AS invoice_ids, + pr.created_at, + pr.updated_at +FROM payment_requests AS pr; \ No newline at end of file diff --git a/db/views/exports_payments_v01.sql b/db/views/exports_payments_v01.sql new file mode 100644 index 0000000..842df15 --- /dev/null +++ b/db/views/exports_payments_v01.sql @@ -0,0 +1,25 @@ +SELECT + p.organization_id, + p.id AS lago_id, + p.amount_cents, + p.amount_currency, + p.payable_payment_status::text AS payment_status, + p.payment_type::text AS payment_type, + p.reference, + p.provider_payment_id AS external_payment_id, + p.created_at::timestamptz AS created_at, + p.updated_at::timestamptz AS updated_at, + CASE + WHEN p.payable_type = 'Invoice' THEN + to_json(ARRAY[p.payable_id]) + WHEN p.payable_type = 'PaymentRequest' THEN + to_json(ARRAY( + SELECT ai.invoice_id + FROM invoices_payment_requests ai + WHERE ai.payment_request_id = p.payable_id + ORDER BY ai.created_at + )) + ELSE + to_json(ARRAY[]::uuid[]) + END AS invoice_ids +FROM payments AS p; diff --git a/db/views/exports_plans_v01.sql b/db/views/exports_plans_v01.sql new file mode 100644 index 0000000..5740c54 --- /dev/null +++ b/db/views/exports_plans_v01.sql @@ -0,0 +1,35 @@ +SELECT + p.organization_id, + p.id AS lago_id, + p.name, + p.invoice_display_name, + p.created_at, + p.updated_at, + p.code, + CASE p.interval + WHEN 0 then 'weekly' + WHEN 1 then 'monthly' + WHEN 2 then 'yearly' + WHEN 3 then 'quarterly' + END AS plan_interval, + p.description, + p.amount_cents, + p.amount_currency, + p.trial_period, + p.pay_in_advance, + p.bill_charges_monthly, + p.parent_id, + to_json ( + ARRAY( + SELECT + pt.tax_id AS lago_tax_id + FROM + plans_taxes AS pt + WHERE + pt.plan_id = p.id + ) + ) AS lago_taxes_ids +FROM + plans AS p +WHERE + p.deleted_at IS NULL; diff --git a/db/views/exports_subscriptions_v01.sql b/db/views/exports_subscriptions_v01.sql new file mode 100644 index 0000000..07bf949 --- /dev/null +++ b/db/views/exports_subscriptions_v01.sql @@ -0,0 +1,39 @@ +SELECT + c.organization_id, + s.id AS lago_id, + s.external_id, + s.customer_id AS lago_customer_id, + s.name, + s.plan_id AS lago_plan_id, + CASE s.status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'active' + WHEN 2 THEN 'terminated' + WHEN 3 THEN 'canceled' + END AS status, + CASE s.billing_time + WHEN 0 THEN 'calendar' + WHEN 1 THEN 'anniversary' + END AS billing_time, + s.subscription_at, + s.started_at, + s.trial_ended_at, + s.ending_at, + s.terminated_at, + s.canceled_at, + s.created_at, + s.updated_at, + to_json ( + ARRAY( + SELECT + ns.id + FROM + subscriptions AS ns + WHERE + ns.previous_subscription_id = s.id + ) + ) AS lago_next_subscriptions_id, + s.previous_subscription_id AS lago_previous_subscription_id +FROM + subscriptions AS s + LEFT JOIN customers AS c ON s.customer_id = c.id; diff --git a/db/views/exports_subscriptions_v02.sql b/db/views/exports_subscriptions_v02.sql new file mode 100644 index 0000000..4d963dc --- /dev/null +++ b/db/views/exports_subscriptions_v02.sql @@ -0,0 +1,38 @@ +SELECT + s.organization_id, + s.id AS lago_id, + s.external_id, + s.customer_id AS lago_customer_id, + s.name, + s.plan_id AS lago_plan_id, + CASE s.status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'active' + WHEN 2 THEN 'terminated' + WHEN 3 THEN 'canceled' + END AS status, + CASE s.billing_time + WHEN 0 THEN 'calendar' + WHEN 1 THEN 'anniversary' + END AS billing_time, + s.subscription_at, + s.started_at, + s.trial_ended_at, + s.ending_at, + s.terminated_at, + s.canceled_at, + s.created_at, + s.updated_at, + to_json ( + ARRAY( + SELECT + ns.id + FROM + subscriptions AS ns + WHERE + ns.previous_subscription_id = s.id + ) + ) AS lago_next_subscriptions_id, + s.previous_subscription_id AS lago_previous_subscription_id +FROM + subscriptions AS s; diff --git a/db/views/exports_taxes_v01.sql b/db/views/exports_taxes_v01.sql new file mode 100644 index 0000000..96fa210 --- /dev/null +++ b/db/views/exports_taxes_v01.sql @@ -0,0 +1,11 @@ +SELECT + tx.organization_id, + tx.id AS lago_id, + tx.name, + tx.code, + tx.rate, + tx.description, + tx.applied_to_organization, + tx.created_at, + tx.updated_at +FROM taxes AS tx; diff --git a/db/views/exports_usage_monitoring_alert_thresholds_v01.sql b/db/views/exports_usage_monitoring_alert_thresholds_v01.sql new file mode 100644 index 0000000..fd6d15d --- /dev/null +++ b/db/views/exports_usage_monitoring_alert_thresholds_v01.sql @@ -0,0 +1,10 @@ +SELECT + ath.id AS lago_id, + ath.organization_id, + ath.usage_monitoring_alert_id AS lago_alert_id, + ath.value, + ath.code, + ath.recurring, + ath.created_at, + ath.updated_at +FROM usage_monitoring_alert_thresholds AS ath; diff --git a/db/views/exports_usage_monitoring_triggered_alerts_v01.sql b/db/views/exports_usage_monitoring_triggered_alerts_v01.sql new file mode 100644 index 0000000..148e30d --- /dev/null +++ b/db/views/exports_usage_monitoring_triggered_alerts_v01.sql @@ -0,0 +1,13 @@ +SELECT + ta.id AS lago_id, + ta.organization_id, + ta.usage_monitoring_alert_id AS lago_alert_id, + ta.subscription_id AS lago_subscription_id, + ta.wallet_id AS lago_wallet_id, + ta.current_value, + ta.previous_value, + ta.crossed_thresholds, + ta.triggered_at, + ta.created_at, + ta.updated_at +FROM usage_monitoring_triggered_alerts AS ta; diff --git a/db/views/exports_usage_thresholds_v01.sql b/db/views/exports_usage_thresholds_v01.sql new file mode 100644 index 0000000..f3f5eba --- /dev/null +++ b/db/views/exports_usage_thresholds_v01.sql @@ -0,0 +1,11 @@ +SELECT + organization_id, + plan_id AS lago_plan_id, + id AS lago_id, + amount_cents, + recurring, + threshold_display_name, + created_at, + updated_at + deleted_at +FROM usage_thresholds AS ut; diff --git a/db/views/exports_usage_thresholds_v02.sql b/db/views/exports_usage_thresholds_v02.sql new file mode 100644 index 0000000..beee41d --- /dev/null +++ b/db/views/exports_usage_thresholds_v02.sql @@ -0,0 +1,11 @@ +SELECT + organization_id, + plan_id AS lago_plan_id, + id AS lago_id, + amount_cents, + recurring, + threshold_display_name, + created_at, + updated_at, + deleted_at +FROM usage_thresholds AS ut; diff --git a/db/views/exports_wallet_transaction_consumptions_v01.sql b/db/views/exports_wallet_transaction_consumptions_v01.sql new file mode 100644 index 0000000..bb21e75 --- /dev/null +++ b/db/views/exports_wallet_transaction_consumptions_v01.sql @@ -0,0 +1,9 @@ +SELECT + wtc.id AS lago_id, + wtc.organization_id, + wtc.inbound_wallet_transaction_id AS lago_inbound_wallet_transaction_id, + wtc.outbound_wallet_transaction_id AS lago_outbound_wallet_transaction_id, + wtc.consumed_amount_cents, + wtc.created_at, + wtc.updated_at +FROM wallet_transaction_consumptions AS wtc; diff --git a/db/views/exports_wallet_transactions_v01.sql b/db/views/exports_wallet_transactions_v01.sql new file mode 100644 index 0000000..090110d --- /dev/null +++ b/db/views/exports_wallet_transactions_v01.sql @@ -0,0 +1,35 @@ +SELECT + c.organization_id, + wt.id AS lago_id, + wt.wallet_id AS lago_wallet_id, + CASE wt.status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'settled' + WHEN 2 THEN 'failed' + END AS status, + CASE wt.source + WHEN 0 THEN 'manual' + WHEN 1 THEN 'interval' + WHEN 2 THEN 'threshold' + END AS source, + CASE wt.transaction_status + WHEN 0 THEN 'purchased' + WHEN 1 THEN 'granted' + WHEN 2 THEN 'voided' + WHEN 3 THEN 'invoiced' + END AS transaction_status, + CASE wt.transaction_type + WHEN 0 THEN 'inbound' + WHEN 1 THEN 'outbound' + END AS transaction_type, + wt.amount, + wt.credit_amount, + wt.settled_at, + wt.failed_at, + wt.created_at, + wt.updated_at, + wt.invoice_requires_successful_payment, + wt.metadata +FROM wallet_transactions AS wt +LEFT JOIN wallets AS w ON wt.wallet_id = w.id +LEFT JOIN customers AS c ON c.id = w.customer_id; diff --git a/db/views/exports_wallet_transactions_v02.sql b/db/views/exports_wallet_transactions_v02.sql new file mode 100644 index 0000000..a279ad6 --- /dev/null +++ b/db/views/exports_wallet_transactions_v02.sql @@ -0,0 +1,33 @@ +SELECT + wt.organization_id, + wt.id AS lago_id, + wt.wallet_id AS lago_wallet_id, + CASE wt.status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'settled' + WHEN 2 THEN 'failed' + END AS status, + CASE wt.source + WHEN 0 THEN 'manual' + WHEN 1 THEN 'interval' + WHEN 2 THEN 'threshold' + END AS source, + CASE wt.transaction_status + WHEN 0 THEN 'purchased' + WHEN 1 THEN 'granted' + WHEN 2 THEN 'voided' + WHEN 3 THEN 'invoiced' + END AS transaction_status, + CASE wt.transaction_type + WHEN 0 THEN 'inbound' + WHEN 1 THEN 'outbound' + END AS transaction_type, + wt.amount, + wt.credit_amount, + wt.settled_at, + wt.failed_at, + wt.created_at, + wt.updated_at, + wt.invoice_requires_successful_payment, + wt.metadata +FROM wallet_transactions AS wt; diff --git a/db/views/exports_wallet_transactions_v03.sql b/db/views/exports_wallet_transactions_v03.sql new file mode 100644 index 0000000..b5ea460 --- /dev/null +++ b/db/views/exports_wallet_transactions_v03.sql @@ -0,0 +1,34 @@ +SELECT + wt.organization_id, + wt.id AS lago_id, + wt.wallet_id AS lago_wallet_id, + CASE wt.status + WHEN 0 THEN 'pending' + WHEN 1 THEN 'settled' + WHEN 2 THEN 'failed' + END AS status, + CASE wt.source + WHEN 0 THEN 'manual' + WHEN 1 THEN 'interval' + WHEN 2 THEN 'threshold' + END AS source, + CASE wt.transaction_status + WHEN 0 THEN 'purchased' + WHEN 1 THEN 'granted' + WHEN 2 THEN 'voided' + WHEN 3 THEN 'invoiced' + END AS transaction_status, + CASE wt.transaction_type + WHEN 0 THEN 'inbound' + WHEN 1 THEN 'outbound' + END AS transaction_type, + wt.amount, + wt.credit_amount, + wt.settled_at, + wt.failed_at, + wt.created_at, + wt.updated_at, + wt.invoice_requires_successful_payment, + wt.metadata, + wt.invoice_id AS lago_invoice_id +FROM wallet_transactions AS wt; diff --git a/db/views/exports_wallets_v01.sql b/db/views/exports_wallets_v01.sql new file mode 100644 index 0000000..295c631 --- /dev/null +++ b/db/views/exports_wallets_v01.sql @@ -0,0 +1,26 @@ +SELECT + c.organization_id, + w.id AS lago_id, + w.customer_id AS lago_customer_id, + CASE w.status + WHEN 0 THEN 'active' + WHEN 1 THEN 'terminated' + END AS status, + w.balance_currency AS currency, + w.name, + w.rate_amount, + w.credits_balance, + w.credits_ongoing_balance, + w.credits_ongoing_usage_balance, + w.balance_cents, + w.ongoing_balance_cents, + w.ongoing_usage_balance_cents, + w.consumed_credits, + w.created_at, + w.updated_at, + w.terminated_at, + w.last_balance_sync_at, + w.last_consumed_credit_at, + w.invoice_requires_successful_payment +FROM wallets AS w +LEFT JOIN customers AS c ON c.id = w.customer_id; diff --git a/db/views/exports_wallets_v02.sql b/db/views/exports_wallets_v02.sql new file mode 100644 index 0000000..7b20c5b --- /dev/null +++ b/db/views/exports_wallets_v02.sql @@ -0,0 +1,25 @@ +SELECT + w.organization_id, + w.id AS lago_id, + w.customer_id AS lago_customer_id, + CASE w.status + WHEN 0 THEN 'active' + WHEN 1 THEN 'terminated' + END AS status, + w.balance_currency AS currency, + w.name, + w.rate_amount, + w.credits_balance, + w.credits_ongoing_balance, + w.credits_ongoing_usage_balance, + w.balance_cents, + w.ongoing_balance_cents, + w.ongoing_usage_balance_cents, + w.consumed_credits, + w.created_at, + w.updated_at, + w.terminated_at, + w.last_balance_sync_at, + w.last_consumed_credit_at, + w.invoice_requires_successful_payment +FROM wallets AS w; diff --git a/db/views/flat_filters_v01.sql b/db/views/flat_filters_v01.sql new file mode 100644 index 0000000..11b3cb6 --- /dev/null +++ b/db/views/flat_filters_v01.sql @@ -0,0 +1,41 @@ +-- This view is used on events_processor side to check filters matching on events received for organization using the Clickhouse store +-- It then allows us to expire the usage chage directly within the events events_processor service +SELECT + billable_metrics.organization_id AS organization_id, + billable_metrics.code AS billable_metric_code, + charges.plan_id AS plan_id, + charges.id AS charge_id, + charges.updated_at AS charge_updated_at, + charge_filters.id AS charge_filter_id, + charge_filters.updated_at AS charge_filter_updated_at, + CASE WHEN charge_filters.id IS NOT NULL + THEN + jsonb_object_agg( + COALESCE(billable_metric_filters.key, ''), + CASE + WHEN charge_filter_values.values::text[] && ARRAY['__ALL_FILTER_VALUES__'] + THEN billable_metric_filters.values + ELSE charge_filter_values.values + end + ) + ELSE NULL + END AS filters +FROM billable_metrics + INNER JOIN charges ON charges.billable_metric_id = billable_metrics.id + LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id + LEFT JOIN charge_filter_values ON charge_filter_values.charge_filter_id = charge_filters.id + LEFT JOIN billable_metric_filters ON billable_metric_filters.id = charge_filter_values.billable_metric_filter_id +WHERE + billable_metrics.deleted_at IS NULL + AND charges.deleted_at IS NULL + AND charge_filters.deleted_at IS NULL + AND charge_filter_values.deleted_at IS NULL + AND billable_metric_filters.deleted_at IS NULL +GROUP BY + billable_metrics.organization_id, + billable_metrics.code, + charges.plan_id, + charges.id, + charges.updated_at, + charge_filters.id, + charge_filters.updated_at diff --git a/db/views/flat_filters_v02.sql b/db/views/flat_filters_v02.sql new file mode 100644 index 0000000..be886cf --- /dev/null +++ b/db/views/flat_filters_v02.sql @@ -0,0 +1,43 @@ +-- This view is used on events_processor side to check filters matching on events received for organization using the Clickhouse store +-- It then allows us to expire the usage chage directly within the events events_processor service +SELECT + billable_metrics.organization_id AS organization_id, + billable_metrics.code AS billable_metric_code, + charges.plan_id AS plan_id, + charges.id AS charge_id, + charges.updated_at AS charge_updated_at, + charge_filters.id AS charge_filter_id, + charge_filters.updated_at AS charge_filter_updated_at, + CASE WHEN charge_filters.id IS NOT NULL + THEN + jsonb_object_agg( + COALESCE(billable_metric_filters.key, ''), + CASE + WHEN charge_filter_values.values::text[] && ARRAY['__ALL_FILTER_VALUES__'] + THEN billable_metric_filters.values + ELSE charge_filter_values.values + end + ) + ELSE NULL + END AS filters, + COALESCE(charge_filters.properties, charges.properties) AS properties, + COALESCE(charge_filters.properties, charges.properties)->'pricing_group_keys' AS pricing_group_keys +FROM billable_metrics + INNER JOIN charges ON charges.billable_metric_id = billable_metrics.id + LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id + LEFT JOIN charge_filter_values ON charge_filter_values.charge_filter_id = charge_filters.id + LEFT JOIN billable_metric_filters ON billable_metric_filters.id = charge_filter_values.billable_metric_filter_id +WHERE + billable_metrics.deleted_at IS NULL + AND charges.deleted_at IS NULL + AND charge_filters.deleted_at IS NULL + AND charge_filter_values.deleted_at IS NULL + AND billable_metric_filters.deleted_at IS NULL +GROUP BY + billable_metrics.organization_id, + billable_metrics.code, + charges.plan_id, + charges.id, + charges.updated_at, + charge_filters.id, + charge_filters.updated_at diff --git a/db/views/flat_filters_v03.sql b/db/views/flat_filters_v03.sql new file mode 100644 index 0000000..ae21af0 --- /dev/null +++ b/db/views/flat_filters_v03.sql @@ -0,0 +1,44 @@ +-- This view is used on events_processor side to check filters matching on events received for organization using the Clickhouse store +-- It then allows us to expire the usage chage directly within the events events_processor service +SELECT + billable_metrics.organization_id AS organization_id, + billable_metrics.code AS billable_metric_code, + charges.plan_id AS plan_id, + charges.id AS charge_id, + charges.updated_at AS charge_updated_at, + charge_filters.id AS charge_filter_id, + charge_filters.updated_at AS charge_filter_updated_at, + CASE WHEN charge_filters.id IS NOT NULL + THEN + jsonb_object_agg( + COALESCE(billable_metric_filters.key, ''), + CASE + WHEN charge_filter_values.values::text[] && ARRAY['__ALL_FILTER_VALUES__'] + THEN billable_metric_filters.values + ELSE charge_filter_values.values + end + ) + ELSE NULL + END AS filters, + COALESCE(charge_filters.properties, charges.properties) AS properties, + COALESCE(charge_filters.properties, charges.properties)->'pricing_group_keys' AS pricing_group_keys, + charges.pay_in_advance AS pay_in_advance +FROM billable_metrics + INNER JOIN charges ON charges.billable_metric_id = billable_metrics.id + LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id + LEFT JOIN charge_filter_values ON charge_filter_values.charge_filter_id = charge_filters.id + LEFT JOIN billable_metric_filters ON billable_metric_filters.id = charge_filter_values.billable_metric_filter_id +WHERE + billable_metrics.deleted_at IS NULL + AND charges.deleted_at IS NULL + AND charge_filters.deleted_at IS NULL + AND charge_filter_values.deleted_at IS NULL + AND billable_metric_filters.deleted_at IS NULL +GROUP BY + billable_metrics.organization_id, + billable_metrics.code, + charges.plan_id, + charges.id, + charges.updated_at, + charge_filters.id, + charge_filters.updated_at diff --git a/db/views/flat_filters_v04.sql b/db/views/flat_filters_v04.sql new file mode 100644 index 0000000..99a4bd5 --- /dev/null +++ b/db/views/flat_filters_v04.sql @@ -0,0 +1,45 @@ +-- This view is used on events_processor side to check filters matching on events received for organization using the Clickhouse store +-- It then allows us to expire the usage chage directly within the events events_processor service +SELECT + billable_metrics.organization_id AS organization_id, + billable_metrics.code AS billable_metric_code, + charges.plan_id AS plan_id, + charges.id AS charge_id, + charges.updated_at AS charge_updated_at, + charge_filters.id AS charge_filter_id, + charge_filters.updated_at AS charge_filter_updated_at, + CASE WHEN charge_filters.id IS NOT NULL + THEN + jsonb_object_agg( + COALESCE(billable_metric_filters.key, ''), + CASE + WHEN charge_filter_values.values::text[] && ARRAY['__ALL_FILTER_VALUES__'] + THEN billable_metric_filters.values + ELSE charge_filter_values.values + end + ) + ELSE NULL + END AS filters, + COALESCE(charge_filters.properties, charges.properties) AS properties, + COALESCE(charge_filters.properties, charges.properties)->'pricing_group_keys' AS pricing_group_keys, + charges.pay_in_advance AS pay_in_advance, + charges.accepts_target_wallet AS accepts_target_wallet +FROM billable_metrics + INNER JOIN charges ON charges.billable_metric_id = billable_metrics.id + LEFT JOIN charge_filters ON charge_filters.charge_id = charges.id + LEFT JOIN charge_filter_values ON charge_filter_values.charge_filter_id = charge_filters.id + LEFT JOIN billable_metric_filters ON billable_metric_filters.id = charge_filter_values.billable_metric_filter_id +WHERE + billable_metrics.deleted_at IS NULL + AND charges.deleted_at IS NULL + AND charge_filters.deleted_at IS NULL + AND charge_filter_values.deleted_at IS NULL + AND billable_metric_filters.deleted_at IS NULL +GROUP BY + billable_metrics.organization_id, + billable_metrics.code, + charges.plan_id, + charges.id, + charges.updated_at, + charge_filters.id, + charge_filters.updated_at diff --git a/db/views/last_hour_events_mv_v01.sql b/db/views/last_hour_events_mv_v01.sql new file mode 100644 index 0000000..d3e94bf --- /dev/null +++ b/db/views/last_hour_events_mv_v01.sql @@ -0,0 +1,43 @@ +WITH billable_metric_groups AS ( + SELECT + billable_metrics.id AS bm_id, + billable_metrics.code bm_code, + COUNT(parent_groups.id) AS parent_group_count, + array_agg(parent_groups.key) AS parent_group_keys, + COUNT(child_groups.id) AS child_group_count, + array_agg(child_groups.key) AS child_group_keys + FROM billable_metrics + LEFT JOIN groups AS parent_groups + ON parent_groups.billable_metric_id = billable_metrics.id + AND parent_groups.parent_group_id IS NULL + LEFT JOIN groups AS child_groups + ON child_groups.billable_metric_id = billable_metrics.id + AND child_groups.parent_group_id IS NOT NULL + WHERE billable_metrics.deleted_at IS NULL + GROUP BY billable_metrics.id, billable_metrics.code +) + +SELECT + events.organization_id, + events.transaction_id, + events.timestamp, + events.properties, + billable_metrics.code AS billable_metric_code, + billable_metrics.aggregation_type != 0 AS field_name_mandatory, + billable_metrics.aggregation_type IN (1,2,5,6) AS numeric_field_mandatory, + events.properties ->> billable_metrics.field_name::text AS field_value, + events.properties ->> billable_metrics.field_name::text ~ '^\d+(\.\d+)?$' AS is_numeric_field_value, + COALESCE(billable_metric_groups.parent_group_count, 0) > 0 AS parent_group_mandatory, + events.properties ?| billable_metric_groups.parent_group_keys AS has_parent_group_key, + COALESCE(billable_metric_groups.child_group_count, 0) > 0 AS child_group_mandatory, + events.properties ?| billable_metric_groups.child_group_keys AS has_child_group_key +FROM + events + LEFT JOIN billable_metrics ON billable_metrics.code = events.code + AND events.organization_id = billable_metrics.organization_id + LEFT JOIN billable_metric_groups ON billable_metrics.id = billable_metric_groups.bm_id +WHERE + events.deleted_at IS NULL + AND events.created_at >= date_trunc('hour', NOW()) - INTERVAL '1 hour' + AND events.created_at < date_trunc('hour', NOW()) + AND billable_metrics.deleted_at IS NULL diff --git a/db/views/last_hour_events_mv_v02.sql b/db/views/last_hour_events_mv_v02.sql new file mode 100644 index 0000000..7b9e4b3 --- /dev/null +++ b/db/views/last_hour_events_mv_v02.sql @@ -0,0 +1,43 @@ +WITH billable_metric_groups AS ( + SELECT + billable_metrics.id AS bm_id, + billable_metrics.code bm_code, + COUNT(parent_groups.id) AS parent_group_count, + array_agg(parent_groups.key) AS parent_group_keys, + COUNT(child_groups.id) AS child_group_count, + array_agg(child_groups.key) AS child_group_keys + FROM billable_metrics + LEFT JOIN groups AS parent_groups + ON parent_groups.billable_metric_id = billable_metrics.id + AND parent_groups.parent_group_id IS NULL + LEFT JOIN groups AS child_groups + ON child_groups.billable_metric_id = billable_metrics.id + AND child_groups.parent_group_id IS NOT NULL + WHERE billable_metrics.deleted_at IS NULL + GROUP BY billable_metrics.id, billable_metrics.code +) + +SELECT + events.organization_id, + events.transaction_id, + events.timestamp, + events.properties, + billable_metrics.code AS billable_metric_code, + billable_metrics.aggregation_type != 0 AS field_name_mandatory, + billable_metrics.aggregation_type IN (1,2,5,6) AS numeric_field_mandatory, + events.properties ->> billable_metrics.field_name::text AS field_value, + events.properties ->> billable_metrics.field_name::text ~ '^-?\d+(\.\d+)?$' AS is_numeric_field_value, + COALESCE(billable_metric_groups.parent_group_count, 0) > 0 AS parent_group_mandatory, + events.properties ?| billable_metric_groups.parent_group_keys AS has_parent_group_key, + COALESCE(billable_metric_groups.child_group_count, 0) > 0 AS child_group_mandatory, + events.properties ?| billable_metric_groups.child_group_keys AS has_child_group_key +FROM + events + LEFT JOIN billable_metrics ON billable_metrics.code = events.code + AND events.organization_id = billable_metrics.organization_id + LEFT JOIN billable_metric_groups ON billable_metrics.id = billable_metric_groups.bm_id +WHERE + events.deleted_at IS NULL + AND events.created_at >= date_trunc('hour', NOW()) - INTERVAL '1 hour' + AND events.created_at < date_trunc('hour', NOW()) + AND billable_metrics.deleted_at IS NULL diff --git a/db/views/last_hour_events_mv_v03.sql b/db/views/last_hour_events_mv_v03.sql new file mode 100644 index 0000000..f725493 --- /dev/null +++ b/db/views/last_hour_events_mv_v03.sql @@ -0,0 +1,63 @@ +WITH billable_metric_groups AS ( + SELECT + billable_metrics.organization_id as bm_organization_id, + billable_metrics.id AS bm_id, + billable_metrics.code AS bm_code, + COUNT(parent_groups.id) AS parent_group_count, + array_agg(parent_groups.key) AS parent_group_keys, + COUNT(child_groups.id) AS child_group_count, + array_agg(child_groups.key) AS child_group_keys + FROM billable_metrics + LEFT JOIN groups AS parent_groups + ON parent_groups.billable_metric_id = billable_metrics.id + AND parent_groups.parent_group_id IS NULL + AND parent_groups.deleted_at IS NULL + LEFT JOIN groups AS child_groups + ON child_groups.billable_metric_id = billable_metrics.id + AND child_groups.parent_group_id IS NOT NULL + AND child_groups.deleted_at IS NULL + WHERE billable_metrics.deleted_at IS NULL + GROUP BY billable_metrics.id, billable_metrics.code +), +billable_metric_filters as ( + SELECT + billable_metrics.organization_id as bm_organization_id, + billable_metrics.id AS bm_id, + billable_metrics.code AS bm_code, + filters.key AS filter_key, + filters.values AS filter_values + FROM billable_metrics + INNER JOIN billable_metric_filters filters + ON filters.billable_metric_id = billable_metrics.id + WHERE + billable_metrics.deleted_at IS NULL + AND filters.deleted_at IS NULL +) + + +SELECT + events.organization_id, + events.transaction_id, + events.properties, + billable_metrics.code AS billable_metric_code, + billable_metrics.aggregation_type != 0 AS field_name_mandatory, + billable_metrics.aggregation_type IN (1,2,5,6) AS numeric_field_mandatory, + events.properties ->> billable_metrics.field_name::text AS field_value, + events.properties ->> billable_metrics.field_name::text ~ '^-?\d+(\.\d+)?$' AS is_numeric_field_value, + COALESCE(billable_metric_groups.parent_group_count, 0) > 0 AS parent_group_mandatory, + events.properties ?| billable_metric_groups.parent_group_keys AS has_parent_group_key, + COALESCE(billable_metric_groups.child_group_count, 0) > 0 AS child_group_mandatory, + events.properties ?| billable_metric_groups.child_group_keys AS has_child_group_key, + events.properties ? billable_metric_filters.filter_key as has_filter_keys, + (events.properties ->> billable_metric_filters.filter_key) = ANY (billable_metric_filters.filter_values) as has_valid_filter_values +FROM + events + LEFT JOIN billable_metrics ON billable_metrics.code = events.code + AND events.organization_id = billable_metrics.organization_id + LEFT JOIN billable_metric_groups ON billable_metrics.id = billable_metric_groups.bm_id + LEFT JOIN billable_metric_filters ON billable_metrics.id = billable_metric_filters.bm_id +WHERE + events.deleted_at IS NULL + AND events.created_at >= date_trunc('hour', NOW()) - INTERVAL '1 hour' + AND events.created_at < date_trunc('hour', NOW()) + AND billable_metrics.deleted_at IS NULL diff --git a/db/views/last_hour_events_mv_v04.sql b/db/views/last_hour_events_mv_v04.sql new file mode 100644 index 0000000..f571095 --- /dev/null +++ b/db/views/last_hour_events_mv_v04.sql @@ -0,0 +1,37 @@ +WITH billable_metric_filters as ( + SELECT + billable_metrics.organization_id as bm_organization_id, + billable_metrics.id AS bm_id, + billable_metrics.code AS bm_code, + filters.key AS filter_key, + filters.values AS filter_values + FROM billable_metrics + INNER JOIN billable_metric_filters filters + ON filters.billable_metric_id = billable_metrics.id + WHERE + billable_metrics.deleted_at IS NULL + AND filters.deleted_at IS NULL +) + + +SELECT + events.organization_id, + events.transaction_id, + events.properties, + billable_metrics.code AS billable_metric_code, + billable_metrics.aggregation_type != 0 AS field_name_mandatory, + billable_metrics.aggregation_type IN (1,2,5,6) AS numeric_field_mandatory, + events.properties ->> billable_metrics.field_name::text AS field_value, + events.properties ->> billable_metrics.field_name::text ~ '^-?\d+(\.\d+)?$' AS is_numeric_field_value, + events.properties ? billable_metric_filters.filter_key as has_filter_keys, + (events.properties ->> billable_metric_filters.filter_key) = ANY (billable_metric_filters.filter_values) as has_valid_filter_values +FROM + events + LEFT JOIN billable_metrics ON billable_metrics.code = events.code + AND events.organization_id = billable_metrics.organization_id + LEFT JOIN billable_metric_filters ON billable_metrics.id = billable_metric_filters.bm_id +WHERE + events.deleted_at IS NULL + AND events.created_at >= date_trunc('hour', NOW()) - INTERVAL '1 hour' + AND events.created_at < date_trunc('hour', NOW()) + AND billable_metrics.deleted_at IS NULL diff --git a/dev/cops/discard_all_cop.rb b/dev/cops/discard_all_cop.rb new file mode 100644 index 0000000..22a110f --- /dev/null +++ b/dev/cops/discard_all_cop.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rubocop" + +module Cops + class DiscardAllCop < ::RuboCop::Cop::Base + MSG = "Avoid using `discard_all`. Use `update_all(deleted_at: Time.current)` instead." + + def_node_matcher :discard_all_call?, <<~PATTERN + (send _ :discard_all ...) + PATTERN + + def self.badge + @badge ||= ::RuboCop::Cop::Badge.for("Lago/DiscardAll") # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + def on_send(node) + return unless discard_all_call?(node) + + add_offense(node) + end + end +end diff --git a/dev/cops/lago_premium_around_cop.rb b/dev/cops/lago_premium_around_cop.rb new file mode 100644 index 0000000..e44e185 --- /dev/null +++ b/dev/cops/lago_premium_around_cop.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rubocop" + +module Cops + class LagoPremiumAroundCop < ::RuboCop::Cop::Base + include ::RuboCop::Cop::RangeHelp + extend ::RuboCop::Cop::AutoCorrector + + MSG = "Use `:premium` metadata on the context/describe block instead of `around { |test| lago_premium!(&test) }`." + + # Matches: lago_premium!(&var) + def_node_matcher :lago_premium_call_with_block_pass?, <<~PATTERN + (send nil? :lago_premium! (block_pass (lvar $_var_name))) + PATTERN + + def self.badge + @badge ||= ::RuboCop::Cop::Badge.for("Lago/LagoPremiumAround") # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + def on_send(node) + var_name = lago_premium_call_with_block_pass?(node) + return unless var_name + + around_block = find_parent_around_block(node) + return unless around_block + + around_var = around_block.arguments.first&.name + return unless around_var == var_name + + add_offense(node) do |corrector| + if around_block.body == node + remove_around_block(corrector, around_block) + else + corrector.replace(node, "#{var_name}.run") + end + add_premium_metadata_to_parent(corrector, around_block) + end + end + + private + + def remove_around_block(corrector, node) + range = range_by_whole_lines(node.source_range, include_final_newline: true) + + # Also remove trailing blank line if present + source = node.source_range.source_buffer.source + next_pos = range.end_pos + if next_pos < source.length + next_newline = source.index("\n", next_pos) + if next_newline + next_line = source[next_pos...next_newline] + if next_line.strip.empty? + range = range.resize(range.size + next_newline - next_pos + 1) + end + end + end + + corrector.remove(range) + end + + def add_premium_metadata_to_parent(corrector, node) + parent = find_parent_example_group(node) + return unless parent + return if has_premium_metadata?(parent) + + send_node = parent.send_node + args = send_node.arguments + + # Insert before hash arguments to maintain valid Ruby syntax, + # otherwise insert after the last argument + hash_arg = args.find(&:hash_type?) + if hash_arg + corrector.insert_before(hash_arg, ":premium, ") + else + corrector.insert_after(send_node.last_argument, ", :premium") + end + end + + def find_parent_around_block(node) + node.each_ancestor(:block).find do |ancestor| + ancestor.method_name == :around && ancestor.send_node.receiver.nil? + end + end + + def find_parent_example_group(node) + node.each_ancestor(:block).find do |ancestor| + %i[context describe shared_examples shared_examples_for].include?(ancestor.method_name) + end + end + + def has_premium_metadata?(block_node) + send_node = block_node.send_node + send_node.arguments.any? do |arg| + (arg.sym_type? && arg.value == :premium) || + (arg.hash_type? && arg.pairs.any? { |pair| pair.key.sym_type? && pair.key.value == :premium }) + end + end + end +end diff --git a/dev/cops/no_drop_column_or_table_cop.rb b/dev/cops/no_drop_column_or_table_cop.rb new file mode 100644 index 0000000..4bdc8e2 --- /dev/null +++ b/dev/cops/no_drop_column_or_table_cop.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rubocop" + +module Cops + class NoDropColumnOrTableCop < ::RuboCop::Cop::Base + MSG = "Dropping columns or tables is disabled due to the risks involved. " \ + "See docs/dropping_columns_and_tables.md for more information." + + FORBIDDEN_METHODS = %i[remove_column drop_table remove_columns].freeze + + # Matches direct calls like `remove_column :users, :email` + def_node_matcher :forbidden_migration_method?, <<~PATTERN + (send nil? {#{FORBIDDEN_METHODS.map { |m| ":#{m}" }.join(" ")}} ...) + PATTERN + + # Matches calls on a receiver like `t.remove_column :email` inside change_table blocks + def_node_matcher :forbidden_table_method?, <<~PATTERN + (send _ {#{FORBIDDEN_METHODS.map { |m| ":#{m}" }.join(" ")}} ...) + PATTERN + + def self.badge + @badge ||= ::RuboCop::Cop::Badge.for("Lago/NoDropColumnOrTable") # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + def on_send(node) + return unless forbidden_migration_method?(node) || forbidden_table_method?(node) + + add_offense(node) + end + end +end diff --git a/dev/cops/service_call_cop.rb b/dev/cops/service_call_cop.rb new file mode 100644 index 0000000..7707cc6 --- /dev/null +++ b/dev/cops/service_call_cop.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rubocop" + +module Cops + class ServiceCallCop < ::RuboCop::Cop::Base + def_node_matcher :base_service_subclass?, <<~PATTERN + (const {nil? cbase} :BaseService) + PATTERN + + def_node_matcher :call_method?, <<~PATTERN + (def :call ...) + PATTERN + + MSG = "Subclasses of Baseservice should have #call without arguments" + + def self.badge + @badge ||= ::RuboCop::Cop::Badge.for("Lago::ServiceCall") # rubocop:disable ThreadSafety/ClassInstanceVariable + end + + def on_def(node) + return unless inherits_base_service?(node) + return unless call_method?(node) + return unless node.arguments? + return if node.block_argument? && node.arguments.size == 1 + + add_offense(node) + end + alias_method :on_defs, :on_def + + private + + def inherits_base_service?(node) + node.each_ancestor(:class).any? { |class_node| base_service_subclass?(class_node.parent_class) } + end + end +end diff --git a/dev/sidekiq/profiling_middleware.rb b/dev/sidekiq/profiling_middleware.rb new file mode 100644 index 0000000..23a9cd0 --- /dev/null +++ b/dev/sidekiq/profiling_middleware.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "vernier" + +module Sidekiq + # ProfilingMiddleware is a Sidekiq server middleware that profiles the execution of each Sidekiq job using the Vernier profiler. + # + # See docs/profiling.md for more information. + class ProfilingMiddleware + def initialize(options = {}) + @dir = options.fetch(:dir, "tmp/profiling") + end + + def call(_instance, hash, queue, &block) + job_dir = "#{dir}/#{hash["wrapped"] || hash["class"]}" + FileUtils.mkdir_p(job_dir) + + file_path = "#{job_dir}/#{Time.at(hash["enqueued_at"]).iso8601}-#{hash["jid"]}.json" + + result = nil + + Vernier.profile(out: file_path) do + result = yield + end + + result + end + + private + + attr_reader :dir + end +end diff --git a/docs/dropping_columns_and_tables.md b/docs/dropping_columns_and_tables.md new file mode 100644 index 0000000..7ec73d8 --- /dev/null +++ b/docs/dropping_columns_and_tables.md @@ -0,0 +1,77 @@ +# Dropping Columns and Tables + +Dropping columns or tables in migrations is **not recommended** and will be flagged by our custom RuboCop cop `Lago/NoDropColumnOrTable`. + +## Why is dropping columns or tables problematic ? + +Dropping columns or tables can lead to significant issues in a production environment and requires careful planning and coordination, especially in an open source context. + +### 1. Two-step migration complexity + +Safely removing a column requires a two-step process: + +1. **First deploy**: Remove all code references to the column and add the column to `ignored_columns` in the model +2. **Second deploy**: Actually drop the column from the database + +This two-step process is error-prone because: + +- If code still references the column when it's dropped, the application will crash during deployment +- Rolling back becomes complex if issues arise between the two deploys +- It requires careful coordination between code changes and database changes + +### 2. Open source release constraints + +As an open source application, this two-step migration has additional implications: + +- **Two separate OSS releases** are required (one to ignore the column, one to drop it) +- **Migration documentation** must be provided to users explaining the upgrade path: + - Users running self-hosted instances need clear instructions on when and how to upgrade + - Skipping versions becomes risky if column drops are not properly documented + +## How to properly drop a column or table + +If you absolutely need to drop a column or table, follow this process: + +### Step 1: Deprecate the column or table (Release N) + +1. Remove all code references to the column or table +2. For columns drop, add the column to `ignored_columns` in the model: + + ```ruby + class User < ApplicationRecord + # TODO: Remove after version X.Y.Z + self.ignored_columns += %w[deprecated_column_name] + end + ``` + +3. Create a specific release including this change +4. Document this in the release notes + +### Step 2: Drop the column or table (Release N+1 or later) + +1. Create a dedicated migration commit that: + + - **Contains ONLY the column or table drop** - no other code changes + - **May contain multiple column or table drops** if they were all properly deprecated in previous releases + - **Documents the version** that removed the references to the column/table and eventually introduced the `ignored_columns` entry + - Add the migration to the exclusion list in the `.rubocop.yml` + + Example commit message: + + ```md + chore(db): drop deprecated columns + + ## Context + + These columns were deprecated in previous version and have been in + `ignored_columns` since then. + + ## Description + + Drop the following columns: + - `users.deprecated_column_name` (ignored since vX.Y.Z) + - `subscriptions.old_status` (ignored since vX.Y.Z) + ``` + +2. Create a specific release including this change +3. Add a release note documenting the column or table drops and the upgrade path from the previous release. You can find such an example here: . diff --git a/docs/profiling.md b/docs/profiling.md new file mode 100644 index 0000000..517f9ed --- /dev/null +++ b/docs/profiling.md @@ -0,0 +1,52 @@ +# Profiling + +## Overview + +In development environment, we can use [Vernier](https://github.com/jhawthorn/vernier) to profile the execution of Sidekiq jobs. + +## Configuration + +To enable profiling, set the `SIDEKIQ_PROFILING_ENABLED` environment variable to `true` in your `.env` file. + +```bash +# lago/.env.development +SIDEKIQ_PROFILING_ENABLED=true +``` + +Then restart the worker process: + +```bash +lago down api-worker +lago up -d api-worker +``` + +## Profiling jobs + +Once the worker process is restarted, all jobs with be profiled. The profiling results will be saved in the `tmp/profiling` directory with the following format: `tmp/profiling/{job_class}/{timestamp}-{job_id}.json`. + +## Profiling results + +The profiling results are in JSON format and can be analyzed using the [`profile-viewer`](https://rubygems.org/gems/profile-viewer) gem. + +First install the gem: + +```bash +gem install profile-viewer +``` + +Then open the profiling result in your browser: + +```bash +profile-viewer tmp/profiling/WalletTransactions::CreateJob/2025-11-19T15:25:18+00:00-9c5e70f60126f7248c8e224b.json +``` + +## Recommendations + +When profiling, it is recommended to disable class reloading in the `config/environment/development.rb` file to avoid unnecessary slowdowns and noises in the profiling results: + +```ruby +# lago-api/config/environment/development.rb +config.enable_reloading = false +``` + +Then restart the worker process. diff --git a/karafka.rb b/karafka.rb new file mode 100644 index 0000000..db2d150 --- /dev/null +++ b/karafka.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +class KarafkaApp < Karafka::App + setup do |config| + config.kafka = { + "bootstrap.servers": ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] + } + + if ENV["LAGO_KAFKA_SECURITY_PROTOCOL"].present? + config.kafka = config.kafka.merge({"security.protocol": ENV["LAGO_KAFKA_SECURITY_PROTOCOL"]}) + end + + if ENV["LAGO_KAFKA_SASL_MECHANISMS"].present? + config.kafka = config.kafka.merge({"sasl.mechanisms": ENV["LAGO_KAFKA_SASL_MECHANISMS"]}) + end + + if ENV["LAGO_KAFKA_USERNAME"].present? + config.kafka = config.kafka.merge({"sasl.username": ENV["LAGO_KAFKA_USERNAME"]}) + end + + if ENV["LAGO_KAFKA_PASSWORD"].present? + config.kafka = config.kafka.merge({"sasl.password": ENV["LAGO_KAFKA_PASSWORD"]}) + end + + config.client_id = "Lago" + # Recreate consumers with each batch. This will allow Rails code reload to work in the + # development mode. Otherwise Karafka process would not be aware of code changes + config.consumer_persistence = !Rails.env.development? + + config.monitor = Karafka::LagoMonitor.new + end + + Karafka.monitor.subscribe(Karafka::Instrumentation::LoggerListener.new) + + Karafka.monitor.subscribe "error.occurred" do |event| + Sentry.capture_exception(event[:error]) + end + + if ENV["LAGO_KAFKA_EVENTS_CHARGED_IN_ADVANCE_TOPIC"].present? + routes.draw do + consumer_group :lago_events_charged_in_advance_consumer do + topic ENV["LAGO_KAFKA_EVENTS_CHARGED_IN_ADVANCE_TOPIC"] do + consumer EventsChargedInAdvanceConsumer + + dead_letter_queue(topic: "unprocessed_events", max_retries: 1, independent: true, dispatch_method: :produce_sync) + end + end + end + end +end + +Karafka::Process.tags.add(:application_name, "lago-api") + +Karafka::Web.setup do |config| + # Set this to false in all apps except one + config.processing.active = ENV["LAGO_KARAFKA_PROCESSING"] if ENV["LAGO_KARAFKA_PROCESSING"].present? + config.ui.sessions.secret = ENV["LAGO_KARAFKA_WEB_SECRET"] if ENV["LAGO_KARAFKA_WEB_SECRET"].present? +end + +Karafka::Web.enable! if ENV["LAGO_KARAFKA_WEB"].present? diff --git a/lib/active_job/json_log_subscriber.rb b/lib/active_job/json_log_subscriber.rb new file mode 100644 index 0000000..630bd51 --- /dev/null +++ b/lib/active_job/json_log_subscriber.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require "active_support/subscriber" +require "active_job/log_subscriber" + +module ActiveJob + class JsonLogSubscriber < ActiveSupport::LogSubscriber # :nodoc: + # rubocop:disable ThreadSafety/ClassAndModuleAttributes + class_attribute :backtrace_cleaner, default: ActiveSupport::BacktraceCleaner.new + # rubocop:enable ThreadSafety/ClassAndModuleAttributes + + def enqueue(event) + job = event.payload[:job] + ex = event.payload[:exception_object] || job.enqueue_error + + if ex + enqueue_error(job, ex) + elsif event.payload[:aborted] + info do + { + level: "info", + event: "enqueue", + status: "aborted", + job: job.class.name, + queue: job.queue_name + }.to_json + end + else + enqueue_success(job) + end + end + subscribe_log_level :enqueue, :info + + def enqueue_all(event) + jobs = event.payload[:jobs] + ex = event.payload[:exception_object] + + jobs.each do |job| + job_ex = ex || job.enqueue_error + if job_ex + enqueue_error(job, job_ex) + else + extra = job.scheduled_at ? {enqueued_at: scheduled_at(job)} : {} + enqueue_success(job, **extra) + end + end + end + + def enqueue_at(event) + job = event.payload[:job] + ex = event.payload[:exception_object] || job.enqueue_error + + if ex + enqueue_error(job, ex) + elsif event.payload[:aborted] + info do + { + level: "info", + event: "enqueue", + status: "aborted", + job: job.class.name, + queue: job.queue_name + }.to_json + end + else + enqueue_success(job, enqueued_at: scheduled_at(job)) + end + end + subscribe_log_level :enqueue_at, :info + + def perform_start(event) + info do + job = event.payload[:job] + + message = { + level: "info", + event: "perform", + status: "start", + job: job.class.name, + job_id: job.job_id, + arguments: args_info(job), + queue: job.queue_name + } + + job.enqueued_at ? message.merge(enqueued_at: job.enqueued_at.utc).to_json : message.to_json + end + end + subscribe_log_level :perform_start, :info + + def perform(event) + job = event.payload[:job] + ex = event.payload[:exception_object] + + if ex + error do + { + level: "error", + event: "perform", + status: "error", + job: job.class.name, + duration: event.duration.round(2), + job_id: job.job_id, + queue: job.queue_name, + exception: { + class: ex.class.name, + message: ex.message + } + }.to_json + end + elsif event.payload[:aborted] + info do + { + level: "info", + event: "perform", + status: "aborted", + job: job.class.name, + duration: event.duration.round(2), + job_id: job.job_id, + queue: job.queue_name + }.to_json + end + else + info do + { + level: "info", + event: "perform", + status: "success", + job: job.class.name, + duration: event.duration.round(2), + job_id: job.job_id, + queue: job.queue_name + }.to_json + end + end + end + subscribe_log_level :perform, :info + + def enqueue_retry(event) + job = event.payload[:job] + ex = event.payload[:error] + wait = event.payload[:wait] + + info do + if ex + { + level: "error", + event: "retry", + status: "error", + job: job.class.name, + job_id: job.job_id, + execution: job.executions, + wait: wait.to_i, + exception: { + class: ex.class.name, + message: ex.message + } + }.to_json + else + { + level: "info", + event: "retry", + status: "success", + job: job.class.name, + job_id: job.job_id, + execution: job.executions, + wait: wait.to_i + }.to_json + end + end + end + subscribe_log_level :enqueue_retry, :info + + def retry_stopped(event) + job = event.payload[:job] + ex = event.payload[:error] + + error do + { + level: "error", + event: "retry", + status: "stopped", + job: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + retries: job.executions, + exception: { + class: ex.class.name, + message: ex.message + } + }.to_json + end + end + subscribe_log_level :retry_stopped, :error + + def discard(event) + job = event.payload[:job] + ex = event.payload[:error] + + error do + { + level: "error", + event: "discard", + status: "error", + job: job.class.name, + job_id: job.job_id, + exception: { + class: ex.class.name, + message: ex.message + } + }.to_json + end + end + subscribe_log_level :discard, :error + + private + + def args_info(job) + if job.class.log_arguments? && job.arguments.any? + job.arguments.map { |arg| format(arg).inspect }.join(", ") + else + {} + end + end + + def format(arg) + case arg + when Hash + arg.transform_values { |value| format(value) } + when Array + arg.map { |value| format(value) } + when GlobalID::Identification + # rubocop:disable Style/RescueModifier + arg.to_global_id rescue arg + # rubocop:enable Style/RescueModifier + else + arg + end + end + + def scheduled_at(job) + Time.at(job.scheduled_at).utc + end + + def enqueue_error(job, ex) + error do + { + level: "error", + event: "enqueue", + status: "error", + job: job.class.name, + queue: job.queue_name, + exception: { + class: ex.class.name, + message: ex.message + } + }.to_json + end + end + + def enqueue_success(job, **extra) + info do + { + level: "info", + event: "enqueue", + status: "success", + job: job.class.name, + job_id: job.job_id, + queue: job.queue_name, + arguments: args_info(job), + **extra + }.to_json + end + end + end +end + +ActiveJob::LogSubscriber.detach_from :active_job +ActiveJob::JsonLogSubscriber.attach_to :active_job diff --git a/lib/active_job/logging.rb b/lib/active_job/logging.rb new file mode 100644 index 0000000..74fbbfe --- /dev/null +++ b/lib/active_job/logging.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "active_support/tagged_logging" +require "active_support/logger" + +module ActiveJob + module Logging # :nodoc: + extend ActiveSupport::Concern + + included do + # rubocop:disable ThreadSafety/ClassAndModuleAttributes + cattr_accessor :logger, default: ActiveSupport::Logger.new($stdout) + class_attribute :log_arguments, instance_accessor: false, default: true + # rubocop:enable ThreadSafety/ClassAndModuleAttributes + end + end +end diff --git a/lib/active_job/uniqueness/strategies/until_executed_patch.rb b/lib/active_job/uniqueness/strategies/until_executed_patch.rb new file mode 100644 index 0000000..1dbc221 --- /dev/null +++ b/lib/active_job/uniqueness/strategies/until_executed_patch.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "active_job/uniqueness/strategies/until_executed" + +# https://github.com/veeqo/activejob-uniqueness/issues/75 +# retry_on does not work with until_executed strategy + +module ActiveJob + module Uniqueness + module Strategies + module UntilExecutedPatch + def before_enqueue + return if lock(resource: lock_key, ttl: lock_ttl) + # We're retrying the job, so we don't need to lock again + return if job.executions > 0 + + handle_conflict(resource: lock_key, on_conflict: on_conflict) + abort_job + end + end + end + end +end + +ActiveJob::Uniqueness::Strategies::UntilExecuted.prepend( + ActiveJob::Uniqueness::Strategies::UntilExecutedPatch +) diff --git a/lib/current_context.rb b/lib/current_context.rb new file mode 100644 index 0000000..4609420 --- /dev/null +++ b/lib/current_context.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class CurrentContext < ActiveSupport::CurrentAttributes + attribute :membership, :source, :email, :api_key_id, :device_info +end diff --git a/lib/date_and_time/half_year_calculations.rb b/lib/date_and_time/half_year_calculations.rb new file mode 100644 index 0000000..7aa5b3b --- /dev/null +++ b/lib/date_and_time/half_year_calculations.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module DateAndTime + module HalfYearCalculations + def beginning_of_half_year + # If month is 1–6 → snap to 1 (January) + # If month is 7–12 → snap to 7 (July) + first_half_year_month = (month <= 6) ? 1 : 7 + beginning_of_month.change(month: first_half_year_month) + end + alias_method :at_beginning_of_half_year, :beginning_of_half_year + + def end_of_half_year + last_half_year_month = (month <= 6) ? 6 : 12 + beginning_of_month.change(month: last_half_year_month).end_of_month + end + alias_method :at_end_of_half_year, :end_of_half_year + end +end diff --git a/lib/generators/not_null_organization_id/not_null_organization_id_generator.rb b/lib/generators/not_null_organization_id/not_null_organization_id_generator.rb new file mode 100644 index 0000000..058cae9 --- /dev/null +++ b/lib/generators/not_null_organization_id/not_null_organization_id_generator.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdGenerator < Rails::Generators::NamedBase + include Rails::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + desc "This generator creates the migrations to add the not null constraint on the organization_id column of a database table" + + def self.next_migration_number(dirname) + next_migration_number = current_migration_number(dirname) + 1 + ActiveRecord::Migration.next_migration_number(next_migration_number) + end + + def create_migrations + migration_template "organization_id_check_constaint.rb.erb", "db/migrate/organization_id_check_constaint_on_#{file_name}.rb" + migration_template "not_null_organization_id.rb.erb", "db/migrate/not_null_organization_id_on_#{file_name}.rb" + end +end diff --git a/lib/generators/not_null_organization_id/templates/not_null_organization_id.rb.erb b/lib/generators/not_null_organization_id/templates/not_null_organization_id.rb.erb new file mode 100644 index 0000000..0f20f39 --- /dev/null +++ b/lib/generators/not_null_organization_id/templates/not_null_organization_id.rb.erb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class NotNullOrganizationIdOn<%= class_name %> < ActiveRecord::Migration[8.0] + def up + validate_check_constraint :<%= file_name %>, name: "<%= file_name %>_organization_id_not_null" + change_column_null :<%= file_name %>, :organization_id, false + remove_check_constraint :<%= file_name %>, name: "<%= file_name %>_organization_id_not_null" + end + + def down + add_check_constraint :<%= file_name %>, "organization_id IS NOT NULL", name: "<%= file_name %>_organization_id_not_null", validate: false + change_column_null :<%= file_name %>, :organization_id, true + end +end diff --git a/lib/generators/not_null_organization_id/templates/organization_id_check_constaint.rb.erb b/lib/generators/not_null_organization_id/templates/organization_id_check_constaint.rb.erb new file mode 100644 index 0000000..9c4d325 --- /dev/null +++ b/lib/generators/not_null_organization_id/templates/organization_id_check_constaint.rb.erb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class OrganizationIdCheckConstaintOn<%= class_name %> < ActiveRecord::Migration[8.0] + def change + add_check_constraint :<%= file_name %>, + "organization_id IS NOT NULL", + name: "<%= file_name %>_organization_id_not_null", + validate: false + end +end diff --git a/lib/generators/organization_id_generator/organization_id_generator_generator.rb b/lib/generators/organization_id_generator/organization_id_generator_generator.rb new file mode 100644 index 0000000..b4a6a34 --- /dev/null +++ b/lib/generators/organization_id_generator/organization_id_generator_generator.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +class OrganizationIdGeneratorGenerator < Rails::Generators::NamedBase + include Rails::Generators::Migration + + source_root File.expand_path("templates", __dir__) + + desc "This generator creates the migrations and job to add the organization_id column to a database table" + + def self.next_migration_number(dirname) + next_migration_number = current_migration_number(dirname) + 1 + ActiveRecord::Migration.next_migration_number(next_migration_number) + end + + def create_migrations + migration_template "add_organization_id_migration.rb.erb", "db/migrate/add_organization_id_to_#{file_name}.rb" + migration_template "add_organization_id_fk_migration.rb.erb", "db/migrate/add_organization_id_fk_to_#{file_name}.rb" + migration_template "validate_organization_foreign_key_migration.rb.erb", "db/migrate/validate_#{file_name}_organizations_foreign_key.rb" + end + + def create_job + template "job_template.rb.erb", "app/jobs/database_migrations/populate_#{file_name}_with_organization_job.rb" + end +end diff --git a/lib/generators/organization_id_generator/templates/add_organization_id_fk_migration.rb.erb b/lib/generators/organization_id_generator/templates/add_organization_id_fk_migration.rb.erb new file mode 100644 index 0000000..4d367a7 --- /dev/null +++ b/lib/generators/organization_id_generator/templates/add_organization_id_fk_migration.rb.erb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddOrganizationIdFkTo<%= class_name %> < ActiveRecord::Migration[7.2] + def change + add_foreign_key :<%= file_name %>, :organizations, validate: false + end +end diff --git a/lib/generators/organization_id_generator/templates/add_organization_id_migration.rb.erb b/lib/generators/organization_id_generator/templates/add_organization_id_migration.rb.erb new file mode 100644 index 0000000..b110e5b --- /dev/null +++ b/lib/generators/organization_id_generator/templates/add_organization_id_migration.rb.erb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddOrganizationIdTo<%= class_name %> < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_reference :<%= file_name %>, :organization, type: :uuid, index: {algorithm: :concurrently} + end +end diff --git a/lib/generators/organization_id_generator/templates/job_template.rb.erb b/lib/generators/organization_id_generator/templates/job_template.rb.erb new file mode 100644 index 0000000..49d3942 --- /dev/null +++ b/lib/generators/organization_id_generator/templates/job_template.rb.erb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module DatabaseMigrations + class Populate<%= class_name %>WithOrganizationJob < ApplicationJob + queue_as :low_priority + unique :until_executed + + BATCH_SIZE = 1000 + + def perform(batch_number = 1) + batch = <%= class_name.singularize %>.unscoped + .where(organization_id: nil) + .limit(BATCH_SIZE) + + if batch.exists? + # rubocop:disable Rails/SkipsModelValidations + batch.update_all("organization_id = (SELECT organization_id FROM _CHANGE_ME_ WHERE _CHANGE_ME_.id = <%= file_name %>._CHANGE_ME__id)") + # rubocop:enable Rails/SkipsModelValidations + + # Queue the next batch + self.class.perform_later(batch_number + 1) + else + Rails.logger.info("Finished the execution") + end + end + + def lock_key_arguments + [arguments] + end + end +end diff --git a/lib/generators/organization_id_generator/templates/validate_organization_foreign_key_migration.rb.erb b/lib/generators/organization_id_generator/templates/validate_organization_foreign_key_migration.rb.erb new file mode 100644 index 0000000..a51b99c --- /dev/null +++ b/lib/generators/organization_id_generator/templates/validate_organization_foreign_key_migration.rb.erb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class Validate<%= class_name %>OrganizationsForeignKey < ActiveRecord::Migration[7.2] + def change + validate_foreign_key :<%= file_name %>, :organizations + end +end diff --git a/lib/karafka/lago_monitor.rb b/lib/karafka/lago_monitor.rb new file mode 100644 index 0000000..b9df273 --- /dev/null +++ b/lib/karafka/lago_monitor.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class Karafka::LagoMonitor < ::Karafka::Instrumentation::Monitor + TRACEABLE_EVENTS = %w[ + consumer.consumed + ].freeze + + def instrument(event_id, payload = EMPTY_HASH, &block) + return super unless TRACEABLE_EVENTS.include?(event_id) + + LagoTracer.in_span("karafka.#{payload[:caller].class}") { super } + end +end diff --git a/lib/lago/adyen/error_handlable.rb b/lib/lago/adyen/error_handlable.rb new file mode 100644 index 0000000..096b614 --- /dev/null +++ b/lib/lago/adyen/error_handlable.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Lago + module Adyen + module ErrorHandlable + def handle_adyen_response(res) + return [true, nil] if res.status < 400 + + code = res.response["errorType"] + message = res.response["message"] + + error = ::Adyen::AdyenError.new(nil, nil, message, code) + deliver_error_webhook(error) + + [false, error] + end + end + end +end diff --git a/lib/lago/adyen/params.rb b/lib/lago/adyen/params.rb new file mode 100644 index 0000000..7c860b6 --- /dev/null +++ b/lib/lago/adyen/params.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Lago + module Adyen + class Params + attr_reader :params + + def initialize(params = {}) + @params = params.to_h + end + + def to_h + default_params.merge(params) + end + + private + + def default_params + { + applicationInfo: { + externalPlatform: { + name: "Lago", + integrator: "Lago" + }, + merchantApplication: { + name: "Lago" + } + } + } + end + end + end +end diff --git a/lib/lago/redis_config_builder.rb b/lib/lago/redis_config_builder.rb new file mode 100644 index 0000000..7ca5a9d --- /dev/null +++ b/lib/lago/redis_config_builder.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "active_support/core_ext/object/blank" + +module Lago + # Builds a Redis configuration hash from environment variables. + # + # Base config includes URL (REDIS_URL), SSL params, password (REDIS_PASSWORD), + # and optional Sentinel support (LAGO_REDIS_SIDEKIQ_SENTINELS, LAGO_REDIS_SIDEKIQ_MASTER_NAME). + # + # Use `with_options` to merge consumer-specific options before calling `sidekiq`. + # + # @example Sidekiq initializer + # Lago::RedisConfigBuilder.new + # .with_options(pool_timeout: 5, timeout: 5) + # .sidekiq + # + # @example ActiveJob uniqueness initializer + # Lago::RedisConfigBuilder.new + # .with_options(reconnect_attempts: 4) + # .sidekiq + class RedisConfigBuilder + def initialize + @extra_options = {} + end + + def with_options(options) + @extra_options = extra_options.merge!(options) + self + end + + def sidekiq + redis_config = { + url:, + ssl_params: { + verify_mode: OpenSSL::SSL::VERIFY_NONE + } + }.compact + + add_sentinels(redis_config) + add_password(redis_config) + + redis_config.merge(extra_options) + end + + private + + attr_reader :extra_options + + def add_sentinels(config) + sentinels = ENV["LAGO_REDIS_SIDEKIQ_SENTINELS"].presence + return unless sentinels + + config[:sentinels] = parse_sentinels(sentinels) + config[:role] = :master + config[:name] = ENV.fetch("LAGO_REDIS_SIDEKIQ_MASTER_NAME", "master").presence || "master" + end + + def url + ENV["REDIS_URL"].presence + end + + def add_password(config) + password = ENV["REDIS_PASSWORD"].presence + return unless password + + config[:password] = password + end + + def parse_sentinels(sentinels) + sentinels.split(",").map do |sentinel| + host, port = sentinel.split(":") + host = host&.strip + port = port&.strip + config = {host:} + if port.present? + begin + config[:port] = Integer(port) + rescue ArgumentError + raise ArgumentError, "Invalid Redis sentinel port #{port.inspect} in #{sentinel.inspect}" + end + end + config + end + end + end +end diff --git a/lib/lago_eu_vat/lago_eu_vat.rb b/lib/lago_eu_vat/lago_eu_vat.rb new file mode 100644 index 0000000..fb02349 --- /dev/null +++ b/lib/lago_eu_vat/lago_eu_vat.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "json" + +require "lago_eu_vat/rate" + +module LagoEuVat; end diff --git a/lib/lago_eu_vat/lago_eu_vat/eu_vat_rates.json b/lib/lago_eu_vat/lago_eu_vat/eu_vat_rates.json new file mode 100644 index 0000000..0a8f297 --- /dev/null +++ b/lib/lago_eu_vat/lago_eu_vat/eu_vat_rates.json @@ -0,0 +1,601 @@ +{ + "details": "https://github.com/ibericode/vat-rates", + "version": 4, + "items": { + "ES": [ + { + "effective_from": "0000-01-01", + "rates": { + "super_reduced": 4, + "reduced": 10, + "standard": 21 + }, + "exceptions": [ + { + "name": "Canary Islands", + "postcode": "(35\\d{3}|38\\d{3})", + "standard": 0 + }, + { + "name": "Ceuta", + "postcode": "(5100[1-5]|5107[0-1]|51081)", + "standard": 0 + }, + { + "name": "Melilla", + "postcode": "(5200[0-6]|5207[0-1]|52081)", + "standard": 0 + } + ] + } + ], + "BG": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced": 9, + "standard": 20 + } + } + ], + "HU": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 18, + "standard": 27 + } + } + ], + "LV": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 12, + "standard": 21 + } + } + ], + "PL": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 8, + "standard": 23 + } + } + ], + "CZ": [ + { + "effective_from": "2024-01-01", + "rates": { + "reduced": 12, + "standard": 21 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 10, + "reduced2": 15, + "standard": 21 + } + } + ], + "MT": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 7, + "standard": 18 + } + } + ], + "IT": [ + { + "effective_from": "0000-01-01", + "rates": { + "super_reduced": 4, + "reduced1": 5, + "reduced2": 10, + "standard": 22 + }, + "exceptions": [ + { + "name": "Campione d'Italia", + "postcode": "22061", + "standard": 0 + }, + { + "name": "Livigno", + "postcode": "23041", + "standard": 0 + } + ] + } + ], + "SI": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 9.5, + "standard": 22 + } + } + ], + "IE": [ + { + "effective_from": "2021-03-01", + "rates": { + "super_reduced": 4.8, + "reduced1": 9, + "reduced2": 13.5, + "standard": 23, + "parking": 13.5 + } + }, + { + "effective_from": "2020-09-01", + "rates": { + "super_reduced": 4.8, + "reduced1": 9, + "reduced2": 13.5, + "standard": 21, + "parking": 13.5 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "super_reduced": 4.8, + "reduced1": 9, + "reduced2": 13.5, + "standard": 23, + "parking": 13.5 + } + } + ], + "SE": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 6, + "reduced2": 12, + "standard": 25 + } + } + ], + "DK": [ + { + "effective_from": "0000-01-01", + "rates": { + "standard": 25 + } + } + ], + "FI": [ + { + "effective_from": "2024-09-01", + "rates": { + "reduced1": 10, + "reduced2": 14, + "standard": 25.5 + }, + "exceptions": [ + { + "name": "Aland Islands", + "postcode": "22\\d{3,}", + "standard": 0 + } + ] + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 10, + "reduced2": 14, + "standard": 24 + } + } + ], + "CY": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 9, + "standard": 19 + } + } + ], + "LU": [ + { + "effective_from": "2024-01-01", + "rates": { + "super_reduced": 3, + "reduced1": 8, + "standard": 17, + "parking": 14 + } + }, + { + "effective_from": "2023-01-01", + "rates": { + "super_reduced": 3, + "reduced1": 7, + "standard": 16, + "parking": 13 + } + }, + { + "effective_from": "2016-01-01", + "rates": { + "super_reduced": 3, + "reduced1": 8, + "standard": 17, + "parking": 13 + } + }, + { + "effective_from": "2015-01-01", + "rates": { + "super_reduced": 3, + "reduced1": 8, + "reduced2": 14, + "standard": 17, + "parking": 12 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "super_reduced": 3, + "reduced1": 6, + "reduced2": 12, + "standard": 15, + "parking": 12 + } + } + ], + "RO": [ + { + "effective_from": "2025-08-01", + "rates": { + "reduced1": 5, + "reduced2": 9, + "standard": 21 + } + }, + { + "effective_from": "2017-01-01", + "rates": { + "reduced1": 5, + "reduced2": 9, + "standard": 19 + } + }, + { + "effective_from": "2016-01-01", + "rates": { + "reduced1": 5, + "reduced2": 9, + "standard": 20 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 9, + "standard": 24 + } + } + ], + "EE": [ + { + "effective_from": "2025-07-01", + "rates": { + "reduced": 13, + "standard": 24 + } + }, + { + "effective_from": "2025-01-01", + "rates": { + "reduced": 13, + "standard": 22 + } + }, + { + "effective_from": "2024-01-01", + "rates": { + "reduced": 9, + "standard": 22 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced": 9, + "standard": 20 + } + } + ], + "GR": [ + { + "effective_from": "2016-06-01", + "rates": { + "reduced1": 6, + "reduced2": 13, + "standard": 24 + }, + "exceptions": [ + { + "name": "Mount Athos", + "postcode": "63086", + "standard": 0 + } + ] + }, + { + "effective_from": "2016-01-01", + "rates": { + "reduced1": 6, + "reduced2": 13.5, + "standard": 23 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 6.5, + "reduced2": 13, + "standard": 23 + } + } + ], + "LT": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 9, + "standard": 21 + } + } + ], + "FR": [ + { + "effective_from": "2014-01-01", + "rates": { + "super_reduced": 2.1, + "reduced1": 5.5, + "reduced2": 10, + "standard": 20 + }, + "exceptions": [ + { + "name": "Guadeloupe", + "postcode": "971\\d{2,}", + "standard": 8.5 + }, + { + "name": "Martinique", + "postcode": "972\\d{2,}", + "standard": 8.5 + }, + { + "name": "Guyane", + "postcode": "973\\d{2,}", + "standard": 0 + }, + { + "name": "Reunion", + "postcode": "974\\d{2,}", + "standard": 8.5 + }, + { + "name": "Mayotte", + "postcode": "976\\d{2,}", + "standard": 0 + } + ] + }, + { + "effective_from": "2012-01-01", + "rates": { + "super_reduced": 2.1, + "reduced1": 5.5, + "reduced2": 7, + "standard": 19.6 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "super_reduced": 2.1, + "reduced1": 5.5, + "standard": 19.6 + } + } + ], + "HR": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 5, + "reduced2": 13, + "standard": 25 + } + } + ], + "BE": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 6, + "reduced2": 12, + "standard": 21, + "parking": 12 + } + } + ], + "NL": [ + { + "effective_from": "2019-01-01", + "rates": { + "reduced": 9, + "standard": 21 + } + }, + { + "effective_from": "2012-10-01", + "rates": { + "reduced": 6, + "standard": 21 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced": 6, + "standard": 19 + } + } + ], + "SK": [ + { + "effective_from": "2025-01-01", + "rates": { + "reduced": 19, + "standard": 23 + } + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced": 10, + "standard": 20 + } + } + ], + "DE": [ + { + "effective_from": "2021-01-01", + "rates": { + "reduced": 7, + "standard": 19 + }, + "exceptions": [ + { + "name": "Büsingen am Hochrhein", + "postcode": "78266", + "standard": 0 + }, + { + "name": "Heligoland", + "postcode": "27498", + "standard": 0 + } + ] + }, + { + "effective_from": "2020-07-01", + "rates": { + "reduced": 5, + "standard": 16 + }, + "exceptions": [ + { + "name": "Büsingen am Hochrhein", + "postcode": "78266", + "standard": 0 + }, + { + "name": "Heligoland", + "postcode": "27498", + "standard": 0 + } + ] + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced": 7, + "standard": 19 + }, + "exceptions": [ + { + "name": "Büsingen am Hochrhein", + "postcode": "78266", + "standard": 0 + }, + { + "name": "Heligoland", + "postcode": "27498", + "standard": 0 + } + ] + } + ], + "PT": [ + { + "effective_from": "0000-01-01", + "rates": { + "reduced1": 6, + "reduced2": 13, + "standard": 23, + "parking": 13 + }, + "exceptions": [ + { + "name": "Madeira", + "postcode": "9[0-4]\\d{2,}", + "standard": 22 + }, + { + "name": "Azores", + "postcode": "9[5-9]\\d{2,}", + "standard": 18 + } + ] + } + ], + "AT": [ + { + "effective_from": "2016-01-01", + "rates": { + "reduced1": 10, + "reduced2": 13, + "standard": 20, + "parking": 13 + }, + "exceptions": [ + { + "name": "Jungholz", + "postcode": "6691", + "standard": 19 + }, + { + "name": "Mittelberg", + "postcode": "699[123]", + "standard": 19 + } + ] + }, + { + "effective_from": "0000-01-01", + "rates": { + "reduced": 10, + "standard": 20, + "parking": 12 + } + } + ] + } +} diff --git a/lib/lago_eu_vat/lago_eu_vat/rate.rb b/lib/lago_eu_vat/lago_eu_vat/rate.rb new file mode 100644 index 0000000..64049ac --- /dev/null +++ b/lib/lago_eu_vat/lago_eu_vat/rate.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module LagoEuVat + class Rate + COUNTRY_RATES = begin + file_path = Rails.root.join("lib/lago_eu_vat/lago_eu_vat/eu_vat_rates.json") + json_file = File.read(file_path) + rates = JSON.parse(json_file)["items"] + rates.freeze + end + + class << self + def country_codes + COUNTRY_RATES.keys + end + + def country_rates(country_code:) + # NOTE: country rates are ordered by date, so we select the most recent applicable + country_rates = COUNTRY_RATES[country_code].select do |period| + Time.zone.now >= DateTime.parse(period["effective_from"]) + end + + rates = country_rates.first.fetch("rates") + exceptions = country_rates.first.fetch("exceptions", []) + + {rates:, exceptions:} + end + end + end +end diff --git a/lib/lago_http_client/lago_http_client.rb b/lib/lago_http_client/lago_http_client.rb new file mode 100644 index 0000000..f46259c --- /dev/null +++ b/lib/lago_http_client/lago_http_client.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "net/http" +require "json" + +require "lago_http_client/client" +require "lago_http_client/session_client" +require "lago_http_client/http_error" + +module LagoHttpClient; end diff --git a/lib/lago_http_client/lago_http_client/client.rb b/lib/lago_http_client/lago_http_client/client.rb new file mode 100644 index 0000000..e259a81 --- /dev/null +++ b/lib/lago_http_client/lago_http_client/client.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "net/http/post/multipart" +require "event_stream_parser" + +module LagoHttpClient + class Client + RESPONSE_SUCCESS_CODES = [200, 201, 202, 204].freeze + MAX_RETRIES_ATTEMPTS = 3 + + attr_reader :uri, :retries_on + + def initialize(url, open_timeout: nil, read_timeout: nil, write_timeout: nil, retries_on: []) + @uri = URI(url) + @http_client = Net::HTTP.new(uri.host, uri.port) + @http_client.open_timeout = open_timeout if open_timeout.present? + @http_client.read_timeout = read_timeout if read_timeout.present? + @http_client.write_timeout = write_timeout if write_timeout.present? + @http_client.use_ssl = true if uri.scheme == "https" + @retries_on = retries_on + end + + def post(body, headers) + req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json") + + headers.each do |header| + key = header.keys.first + value = header[key] + req[key] = value + end + + req.body = body.to_json + response = request(req) + + JSON.parse(response.body.presence || "{}") + rescue JSON::ParserError + response.body.presence || "{}" + end + + def post_with_response(body, headers) + req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/json") + + headers.keys.each do |key| + req[key] = headers[key] + end + + req.body = body.to_json + request(req) + end + + def put_with_response(body, headers) + req = Net::HTTP::Put.new(uri.request_uri, "Content-Type" => "application/json") + + headers.keys.each do |key| + req[key] = headers[key] + end + + req.body = body.to_json + request(req) + end + + def post_multipart_file(params = {}) + req = Net::HTTP::Post::Multipart.new( + uri.path, + params + ) + + request(req) + end + + def post_url_encoded(params, headers) + encoded_form = URI.encode_www_form(params) + + req = Net::HTTP::Post.new(uri.request_uri, "Content-Type" => "application/x-www-form-urlencoded") + headers.keys.each do |key| + req[key] = headers[key] + end + + response = request(req, encoded_form) + JSON.parse(response.body.presence || "{}") + end + + def post_with_stream(body, headers = {}, &block) + req = Net::HTTP::Post.new(uri.request_uri, {"Content-Type" => "application/json"}.merge(headers)) + req.body = body.to_json + + parser = EventStreamParser::Parser.new + + http_client.start do |http| + http.request(req) do |response| + raise_error(response) unless RESPONSE_SUCCESS_CODES.include?(response.code.to_i) + + response.read_body do |chunk| + parser.feed(chunk) do |type, data, id, reconnection_time| + yield(type, data, id, reconnection_time) if block_given? + end + end + end + end + end + + def get(headers: {}, params: nil, body: nil) + path = params ? "#{uri.path}?#{URI.encode_www_form(params)}" : uri.path + req = Net::HTTP::Get.new(path) + req.body = URI.encode_www_form(body) if body.present? + + headers.keys.each do |key| + req[key] = headers[key] + end + + response = request(req) + JSON.parse(response.body.presence || "{}") + end + + private + + attr_reader :http_client + + def raise_error(response) + raise( + ::LagoHttpClient::HttpError.new(response.code, response.body, uri, response_headers: response.each_header.to_h) + ) + end + + def request(req, params = nil) + attempt = 0 + + response = begin + attempt += 1 + http_client.request(req, params) + rescue => e + if retries_on.include?(e.class) + retry if attempt < MAX_RETRIES_ATTEMPTS + else + raise + end + end + + raise_error(response) unless RESPONSE_SUCCESS_CODES.include?(response.code.to_i) + response + end + end +end diff --git a/lib/lago_http_client/lago_http_client/http_error.rb b/lib/lago_http_client/lago_http_client/http_error.rb new file mode 100644 index 0000000..0d701f1 --- /dev/null +++ b/lib/lago_http_client/lago_http_client/http_error.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module LagoHttpClient + class HttpError < StandardError + attr_reader :error_code, :error_body, :uri, :response_headers + + def initialize(code, body, uri, response_headers: {}) + @error_code = code + @error_body = body + @uri = uri + @response_headers = response_headers + end + + def message + "HTTP #{error_code} - URI: #{uri}.\nError: #{error_body}\nResponse headers: #{response_headers}" + end + + def json_message + JSON.parse(error_body) + rescue JSON::ParserError + {} + end + end +end diff --git a/lib/lago_http_client/lago_http_client/session_client.rb b/lib/lago_http_client/lago_http_client/session_client.rb new file mode 100644 index 0000000..3371ebb --- /dev/null +++ b/lib/lago_http_client/lago_http_client/session_client.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +module LagoHttpClient + class SessionClient + RESPONSE_SUCCESS_CODES = [200, 201, 202, 204].freeze + MAX_RETRIES_ATTEMPTS = 3 + + attr_reader :base_url, :cookies + + def initialize(base_url, read_timeout: 30, open_timeout: 30) + @base_url = base_url + @read_timeout = read_timeout + @open_timeout = open_timeout + @cookies = [] + end + + def get(path, headers: {}) + uri = URI.join(base_url, path) + http = create_http_client(uri) + + request = Net::HTTP::Get.new(uri.path) + apply_headers(request, headers) + inject_cookies(request) + + execute_request(request, http) + end + + def post(path, body: {}, headers: {}) + uri = URI.join(base_url, path) + http = create_http_client(uri) + + request = Net::HTTP::Post.new(uri.path) + apply_headers(request, headers) + inject_cookies(request) + request.body = format_body(body, headers) + + execute_request(request, http) + end + + def clear_cookies + @cookies = [] + end + + private + + attr_reader :read_timeout, :open_timeout + + def create_http_client(uri) + http = Net::HTTP.new(uri.host, uri.port) + + if uri.scheme == "https" + http.use_ssl = true + if Rails.env.development? || Rails.env.test? + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + end + + http.read_timeout = read_timeout + http.open_timeout = open_timeout + http + end + + def execute_request(request, http_client) + attempt = 0 + + begin + attempt += 1 + response = http_client.request(request) + store_cookies(response) + validate_response(response) + response + rescue OpenSSL::SSL::SSLError, Net::OpenTimeout, Net::ReadTimeout + retry if attempt < MAX_RETRIES_ATTEMPTS + raise + end + end + + def apply_headers(request, headers) + headers.each do |key, value| + request[key] = value + end + end + + def inject_cookies(request) + return if cookies.empty? + + request["Cookie"] = cookies.join("; ") + end + + def format_body(body, headers) + content_type = headers["Content-Type"] || headers["content-type"] + + case content_type + when "application/json" + body.to_json + when "application/x-www-form-urlencoded" + URI.encode_www_form(body) + else + body.is_a?(String) ? body : body.to_json + end + end + + def store_cookies(response) + return unless response["Set-Cookie"] + + new_cookies = response.get_fields("Set-Cookie") + return unless new_cookies + + new_cookies.each do |cookie| + cookie_value = cookie.split(";").first + cookie_name = cookie_value.split("=").first + + @cookies.reject! { |c| c.start_with?("#{cookie_name}=") } + @cookies << cookie_value + end + end + + def validate_response(response) + return if RESPONSE_SUCCESS_CODES.include?(response.code.to_i) + return if response.is_a?(Net::HTTPRedirection) + + raise LagoHttpClient::HttpError.new( + response.code, + response.body, + URI.join(base_url, response.uri || ""), + response_headers: response.each_header.to_h + ) + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client.rb b/lib/lago_mcp_client/lago_mcp_client.rb new file mode 100644 index 0000000..9f76af6 --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "net/http" +require "json" + +require "lago_mcp_client/config" +require "lago_mcp_client/tool" +require "lago_mcp_client/sse_parser" +require "lago_mcp_client/sse_client" +require "lago_mcp_client/client" +require "lago_mcp_client/run_context" +require "lago_mcp_client/mistral/agent" +require "lago_mcp_client/mistral/response_parser" +require "lago_mcp_client/mistral/client" + +module LagoMcpClient; end diff --git a/lib/lago_mcp_client/lago_mcp_client/client.rb b/lib/lago_mcp_client/lago_mcp_client/client.rb new file mode 100644 index 0000000..ccf1432 --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/client.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module LagoMcpClient + class Client + include SseParser + + PROTOCOL_VERSION = "2024-11-05" + CLIENT_NAME = "lago-mcp-client" + CLIENT_VERSION = "0.1" + + attr_reader :config, :session_id, :sse_client, :http_client + + def initialize(config) + @config = config + @session_id = nil + @sse_client = nil + @http_client = LagoHttpClient::Client.new( + config.mcp_server_url, + read_timeout: config.timeout + ) + end + + def setup! + init_connection + start_sse_client + end + + def list_tools + response = make_request(method: "tools/list") + tools = response.dig(:body, "result", "tools") || [] + tools.map do |tool| + Tool.new( + name: tool["name"], + description: tool["description"], + input_schema: tool["inputSchema"] + ) + end + end + + def call_tool(name, arguments = {}) + response = make_request( + method: "tools/call", + params: {name:, arguments:} + ) + + response.dig(:body, "result") + end + + def close_session + @sse_client&.stop + make_request(method: "close") + end + + private + + def make_request(method:, params: {}, id: SecureRandom.uuid) + response = http_client.post_with_response({jsonrpc: "2.0", method:, params:, id:}, headers) + + { + status: response.code.to_i, + headers: response.each_header.to_h, + body: parse_sse_data(find_sse_data_line(response.body)), + sse_id: extract_sse_id(find_sse_id_line(response.body)) + } + rescue => e + {error: e.message} + end + + def headers + { + "Content-Type" => "application/json", + "Accept" => "application/json,text/event-stream", + "Mcp-Session-Id" => session_id, + "X-LAGO-API-KEY" => config.lago_api_key, + "X-LAGO-API-URL" => config.lago_api_url + }.compact.merge(config.headers) + end + + def start_sse_client + @sse_client = SseClient.new(url: config.mcp_server_url, session_id:) + sse_client.start + end + + def init_connection + response = make_request( + method: "initialize", + params: { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: {name: CLIENT_NAME, version: CLIENT_VERSION} + } + ) + + @session_id ||= response[:headers]["mcp-session-id"] + make_request(method: "notifications/initialized") + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/config.rb b/lib/lago_mcp_client/lago_mcp_client/config.rb new file mode 100644 index 0000000..279b58e --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/config.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module LagoMcpClient + class Config + attr_accessor :mcp_server_url, :lago_api_key, :timeout, :headers + + def initialize(mcp_server_url:, lago_api_key:, timeout: 30, headers: {}) + @mcp_server_url = mcp_server_url + @lago_api_key = lago_api_key + @timeout = timeout + @headers = headers + end + + def lago_api_url + @lago_api_url ||= URI.join(ENV["LAGO_API_URL"], "/api/v1").to_s + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/mistral/agent.rb b/lib/lago_mcp_client/lago_mcp_client/mistral/agent.rb new file mode 100644 index 0000000..5cbf1e6 --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/mistral/agent.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module LagoMcpClient + module Mistral + class Agent + MAX_ITERATIONS = 2 + + attr_reader :conversation_id + + def initialize(client:, conversation_id:) + @mistral_client = LagoMcpClient::Mistral::Client.new + @mcp_context = LagoMcpClient::RunContext.new(client:) + @conversation_id = conversation_id + @mutex = Mutex.new + end + + def setup! + mcp_context.setup! + self + end + + def chat(user_message, max_tool_iterations: MAX_ITERATIONS) + raise ArgumentError, "Block required for streaming" unless block_given? + + process_conversation(user_message, max_tool_iterations) { |chunk| yield chunk } + end + + private + + attr_reader :mistral_client, :mcp_context, :mutex + attr_writer :conversation_id + + def process_conversation(user_message, max_iterations) + response = send_message(user_message) { |chunk| yield chunk } + + max_iterations.times do |i| + break unless has_tool_calls?(response) + + tool_results = execute_tools(response["tool_calls"]) + response = send_tool_results(tool_results) { |chunk| yield chunk } + end + + extract_final_content(response) + end + + def send_message(message) + if conversation_id + mistral_client.append_to_conversation( + conversation_id: conversation_id, + inputs: [{role: "user", content: message}] + ) { |chunk| yield chunk } + else + response = mistral_client.start_conversation(inputs: message) { |chunk| yield chunk } + self.conversation_id = response["conversation_id"] + response + end + end + + def send_tool_results(tool_results) + inputs = tool_results.map do |result| + { + tool_call_id: result[:tool_call_id], + result: result[:content], + type: "function.result", + object: "entry" + } + end + + mistral_client.append_to_conversation( + conversation_id: conversation_id, + inputs: inputs + ) { |chunk| yield chunk } + end + + def has_tool_calls?(response) + response["tool_calls"]&.any? + end + + def execute_tools(tool_calls) + mcp_context.process_tool_calls(tool_calls).map do |result| + { + tool_call_id: result[:tool_call_id] || result["tool_call_id"], + content: parse_tool_content(result[:content] || result["content"]) + } + end + end + + def parse_tool_content(content) + parsed = JSON.parse(content) + parsed.dig("content", 0, "text") || content + rescue JSON::ParserError + content.to_s + end + + def extract_final_content(response) + response.dig("outputs", 0, "content") || "" + end + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/mistral/client.rb b/lib/lago_mcp_client/lago_mcp_client/mistral/client.rb new file mode 100644 index 0000000..999fc4e --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/mistral/client.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module LagoMcpClient + module Mistral + class ApiError < StandardError + attr_reader :error_code, :error_body + + def initialize(error_code, error_body) + @error_code = error_code + @error_body = error_body + super("Mistral API Error (#{error_code}): #{error_body}") + end + end + + class Client + MISTRAL_CONVERSATIONS_URL = "https://api.mistral.ai/v1/conversations" + + def start_conversation(inputs:, &block) + payload = { + agent_id: ENV["MISTRAL_AGENT_ID"], + inputs: normalize_inputs(inputs), + stream: true + } + stream_conversation(payload, MISTRAL_CONVERSATIONS_URL, &block) + end + + def append_to_conversation(conversation_id:, inputs:, &block) + url = "#{MISTRAL_CONVERSATIONS_URL}/#{conversation_id}" + payload = {inputs: inputs, stream: true} + stream_conversation(payload, url, &block) + end + + private + + def normalize_inputs(inputs) + if inputs.is_a?(String) + [{role: "user", content: inputs}] + else + inputs + end + end + + def stream_conversation(payload, url) + http_client = LagoHttpClient::Client.new(url, read_timeout: 120) + headers = { + "Authorization" => "Bearer #{ENV["MISTRAL_API_KEY"]}", + "Accept" => "text/event-stream" + } + + parser = ResponseParser.new + + http_client.post_with_stream(payload, headers) do |_type, data, _id, _reconnection_time| + parser.process(data) { |content| yield content if block_given? } + end + + parser.to_result + rescue LagoHttpClient::HttpError => e + raise ApiError.new(e.error_code, e.error_body) + rescue => e + raise "Mistral Conversations API streaming error: #{e.message}" + end + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/mistral/response_parser.rb b/lib/lago_mcp_client/lago_mcp_client/mistral/response_parser.rb new file mode 100644 index 0000000..1300c77 --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/mistral/response_parser.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module LagoMcpClient + module Mistral + class ResponseParser + attr_reader :conversation_id, :outputs, :tool_calls + + def initialize + @conversation_id = nil + @outputs = [] + @tool_calls = [] + end + + # Processes a single SSE data chunk and updates internal state. + # Yields message content to the block for streaming output. + def process(data, &block) + return if data == "[DONE]" + + parsed_data = JSON.parse(data) + @conversation_id ||= parsed_data["conversation_id"] + + dispatch_event(parsed_data, &block) + extract_outputs(parsed_data["outputs"]) + rescue JSON::ParserError => e + Rails.logger.error("Failed to parse SSE data: #{data[0..200]}") + Rails.logger.error("Parse error: #{e.message}") + end + + # Returns the accumulated conversation result. + def to_result + { + "conversation_id" => conversation_id, + "outputs" => outputs, + "tool_calls" => tool_calls.empty? ? nil : tool_calls + } + end + + private + + # Routes events to appropriate processors based on event type. + def dispatch_event(parsed_data, &block) + case parsed_data["type"] + when "message.output.delta" + stream_content(parsed_data, &block) + when "conversation.response.done" + @conversation_id ||= parsed_data["conversation_id"] + when "function.call", "function.call.delta" + accumulate_tool_call(parsed_data) + end + end + + # Yields streamed content to the caller for real-time output. + def stream_content(parsed_data) + content = parsed_data["content"] + yield content if content.present? && block_given? + end + + # Accumulates function call data, merging arguments for streaming deltas. + def accumulate_tool_call(parsed_data) + tool_call_id = parsed_data["tool_call_id"] + existing = tool_calls.find { |tc| tc["id"] == tool_call_id } + + if existing + existing["function"]["arguments"] = (existing["function"]["arguments"] || "") + (parsed_data["arguments"] || "") + else + tool_calls << build_tool_call( + id: tool_call_id, + name: parsed_data["name"], + arguments: parsed_data["arguments"] || "" + ) + end + end + + # Extracts final outputs and tool calls from the outputs array. + def extract_outputs(outputs_array) + return unless outputs_array + + outputs_array.each do |output| + case output["type"] + when "message.output" + outputs << output + when "tool.call", "function.call" + tool_calls << build_tool_call( + id: output["tool_call_id"] || output["id"], + name: output["name"] || output.dig("function", "name"), + arguments: output["arguments"] || output.dig("function", "arguments") + ) + end + end + end + + def build_tool_call(id:, name:, arguments:) + { + "id" => id, + "type" => "function", + "function" => { + "name" => name, + "arguments" => arguments + } + } + end + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/run_context.rb b/lib/lago_mcp_client/lago_mcp_client/run_context.rb new file mode 100644 index 0000000..7844a43 --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/run_context.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module LagoMcpClient + class ToolNotFoundError < StandardError + attr_reader :tool_name + + def initialize(tool_name) + @tool_name = tool_name + super("Tool '#{tool_name}' not found") + end + end + + class RunContext + attr_reader :client + + def initialize(client:) + @client = client + @tools = [] + @tools_results = [] + @mutex = Mutex.new + end + + def setup! + @tools = client.list_tools + self + end + + def to_model_tools + @tools.map do |tool| + { + type: "function", + function: { + name: tool.name, + description: tool.description, + parameters: tool.input_schema + } + } + end + end + + def process_tool_calls(tool_calls) + results = [] + + tool_calls.each do |tool_call| + function_name = tool_call.dig("function", "name") + arguments = JSON.parse(tool_call.dig("function", "arguments") || "{}") + + result = call_tool(function_name, arguments) + results << { + tool_call_id: tool_call["id"], + role: "tool", + content: JSON.generate(result) + } + end + + results + end + + private + + def get_tool(name) + @tools.find { |tool| tool.name == name } + end + + def call_tool(name, arguments = {}) + tool = get_tool(name) + raise ToolNotFoundError.new(name) unless tool + + result = client.call_tool(name, arguments) + @tools_results << {name => result} + result + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/sse_client.rb b/lib/lago_mcp_client/lago_mcp_client/sse_client.rb new file mode 100644 index 0000000..46a1747 --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/sse_client.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module LagoMcpClient + class SseClient + include SseParser + + def initialize(url:, session_id:) + @uri = URI(url) + @session_id = session_id + @running = false + @mutex = Mutex.new + @thread = nil + @callbacks = [] + end + + def start(&block) + @mutex.synchronize { @callbacks << block if block } + @running = true + @thread ||= Thread.new { run } # rubocop:disable ThreadSafety/NewThread + end + + def stop + @running = false + @thread&.join(1) + @thread = nil + end + + private + + def run + Net::HTTP.start(@uri.host, @uri.port, use_ssl: @uri.scheme == "https") do |http| + request = Net::HTTP::Get.new(@uri) + request["Mcp-Session-Id"] = @session_id + request["Accept"] = "application/json,text/event-stream" + request["Cache-Control"] = "no-cache" + + http.request(request) do |response| + next unless response.code == "200" + + response.read_body do |chunk| + break unless @running + + chunk.each_line do |line| + event_data = parse_sse_data(line) + @callbacks.each { |cb| cb&.call(event_data) } if event_data + end + end + end + end + rescue => e + Rails.logger.error("SSE client error: #{e.message}") + Rails.logger.error(e.backtrace.join("\n")) + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/sse_parser.rb b/lib/lago_mcp_client/lago_mcp_client/sse_parser.rb new file mode 100644 index 0000000..6290de3 --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/sse_parser.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module LagoMcpClient + module SseParser + def parse_sse_data(line) + return if line.nil? || line.strip.empty? + return unless line.start_with?("data: ") + + JSON.parse(line.delete_prefix("data: ").strip) + rescue JSON::ParserError => e + Rails.logger.warn("SSE parse error: #{e.message} for line: #{line.truncate(100)}") + nil + end + + def extract_sse_id(line) + return unless line&.start_with?("id: ") + + line.delete_prefix("id: ").strip + end + + def find_sse_data_line(body) + return unless body + + body.each_line.find { |line| line.start_with?("data: ") } + end + + def find_sse_id_line(body) + return unless body + + body.each_line.find { |line| line.start_with?("id: ") } + end + end +end diff --git a/lib/lago_mcp_client/lago_mcp_client/tool.rb b/lib/lago_mcp_client/lago_mcp_client/tool.rb new file mode 100644 index 0000000..5c03f4b --- /dev/null +++ b/lib/lago_mcp_client/lago_mcp_client/tool.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module LagoMcpClient + class Tool + attr_reader :name, :description, :input_schema + + def initialize(name:, description:, input_schema:) + @name = name + @description = description + @input_schema = input_schema + end + + def to_h + { + name:, + description:, + input_schema: + } + end + end +end diff --git a/lib/lago_utils/lago_utils.rb b/lib/lago_utils/lago_utils.rb new file mode 100644 index 0000000..a5c5943 --- /dev/null +++ b/lib/lago_utils/lago_utils.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "lago_http_client" +require "lago_utils/license" +require "lago_utils/version" + +module LagoUtils; end diff --git a/lib/lago_utils/lago_utils/license.rb b/lib/lago_utils/lago_utils/license.rb new file mode 100644 index 0000000..181512d --- /dev/null +++ b/lib/lago_utils/lago_utils/license.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module LagoUtils + class License + def initialize(url) + @url = url + @premium = false + end + + def verify + return if ENV["LAGO_LICENSE"].blank? + + http_client = LagoHttpClient::Client.new("#{url}/verify/#{ENV["LAGO_LICENSE"]}") + response = http_client.get + + @premium = response["valid"] + end + + def premium? + premium + end + + private + + attr_reader :url, :premium + end +end diff --git a/lib/lago_utils/lago_utils/ruby_sandbox.rb b/lib/lago_utils/lago_utils/ruby_sandbox.rb new file mode 100644 index 0000000..02506f6 --- /dev/null +++ b/lib/lago_utils/lago_utils/ruby_sandbox.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module LagoUtils + module RubySandbox + def self.run(code) + LagoUtils::RubySandbox::Runner.new(code).run + end + end +end diff --git a/lib/lago_utils/lago_utils/ruby_sandbox/runner.rb b/lib/lago_utils/lago_utils/ruby_sandbox/runner.rb new file mode 100644 index 0000000..260ed3c --- /dev/null +++ b/lib/lago_utils/lago_utils/ruby_sandbox/runner.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "open3" + +module LagoUtils + module RubySandbox + class Runner + def initialize(code) + @code = code + end + + def run + result = nil + error = nil + + temp_file = prepare_ruby_file + + Open3.popen3("ruby #{temp_file.path}", chdir: "/tmp") do |_, stdout, stderr, _| + error = stderr.read + result = stdout.read + end + + raise SandboxError.new(initial_error: error) if error.present? + + parsed_result = JSON.parse(result) + + if parsed_result.is_a?(Hash) && parsed_result["type"] == "error" + raise SandboxError.new( + initial_error: parsed_result["error"], + backtrace: parsed_result["backtrace"] + ) + end + + parsed_result + ensure + temp_file.unlink + end + + private + + attr_reader :code + + def sanitized_code + @sanitized_code ||= LagoUtils::RubySandbox::Sanitizer.new(code).sanitize + end + + def prepare_ruby_file + file = Tempfile.new("lago-ruby-sandbox") + file.write(<<~STRING) + require 'json' + require 'bigdecimal' + + #{LagoUtils::RubySandbox::SafeEnvironment::SAFE_ENV} + + result = begin + #{sanitized_code} + rescue Exception => e + { type: 'error', error: e.message, backtrace: e.backtrace } + end + + print JSON.dump(result) + STRING + file.rewind + file + end + end + end +end diff --git a/lib/lago_utils/lago_utils/ruby_sandbox/safe_environment.rb b/lib/lago_utils/lago_utils/ruby_sandbox/safe_environment.rb new file mode 100644 index 0000000..d60539a --- /dev/null +++ b/lib/lago_utils/lago_utils/ruby_sandbox/safe_environment.rb @@ -0,0 +1,423 @@ +# frozen_string_literal: true + +module LagoUtils + module RubySandbox + module SafeEnvironment + ALLOWED_CONSTANTS = [ + :Object, + :Module, + :Class, + :BasicObject, + :Kernel, + :NilClass, + :NIL, + :Data, + :TrueClass, + :TRUE, + :FalseClass, + :FALSE, + :Encoding, + :Comparable, + :Enumerable, + :String, + :Symbol, + :Exception, + :SystemExit, + :SignalException, + :Interrupt, + :StandardError, + :TypeError, + :ArgumentError, + :IndexError, + :KeyError, + :RangeError, + :ScriptError, + :SyntaxError, + :LoadError, + :NotImplementedError, + :NameError, + :NoMethodError, + :RuntimeError, + :SecurityError, + :NoMemoryError, + :EncodingError, + :SystemCallError, + :Errno, + :ZeroDivisionError, + :FloatDomainError, + :Numeric, + :Integer, + :Fixnum, + :Float, + :Bignum, + :BigDecimal, + :Array, + :Hash, + :Struct, + :RegexpError, + :Regexp, + :MatchData, + :Range, + :IOError, + :EOFError, + :STDIN, + :STDOUT, + :STDERR, + :Time, + :Random, + :Signal, + :Proc, + :LocalJumpError, + :SystemStackError, + :Method, + :UnboundMethod, + :Math, + :Enumerator, + :StopIteration, + :TOPLEVEL_BINDING, + :Rational, + :Complex, + :RUBY_VERSION, + :RUBY_RELEASE_DATE, + :RUBY_PLATFORM, + :RUBY_PATCHLEVEL, + :RUBY_REVISION, + :RUBY_DESCRIPTION, + :RUBY_COPYRIGHT, + :RUBY_ENGINE, + :TracePoint, + :ARGV, + :Gem, + :RbConfig, + :Config, + :CROSS_COMPILING, + :Date, + :ConditionVariable, + :Queue, + :SizedQueue, + :MonitorMixin, + :Monitor, + :Exception2MessageMapper, + :RubyToken, + :RubyLex, + :RUBYGEMS_ACTIVATION_MONITOR, + :JSON + ].freeze + + KERNEL_S_METHODS = %w[ + Array + binding + block_given? + catch + chomp + chomp! + chop + chop! + eval + fail + Float + format + global_variables + gsub + gsub! + Integer + iterator? + lambda + local_variables + loop + method_missing + proc + raise + scan + split + sprintf + String + sub + sub! + throw + ].freeze + + SYMBOL_S_METHODS = %w[ + all_symbols + ].freeze + + STRING_S_METHODS = %w[].freeze + + KERNEL_METHODS = %w[ + == + + ray + nding + ock_given? + tch + omp + omp! + op + op! + ass + clone + dup + eql? + equal? + eval + fail + Float + format + freeze + frozen? + global_variables + gsub + gsub! + hash + id + initialize_copy + inspect + instance_eval + instance_of? + instance_variables + instance_variable_get + instance_variable_set + instance_variable_defined? + Integer + is_a? + iterator? + kind_of? + lambda + local_variables + loop + methods + method_missing + nil? + private_methods + print + proc + protected_methods + public_methods + raise + remove_instance_variable + respond_to? + respond_to_missing? + scan + send + singleton_methods + singleton_method_added + singleton_method_removed + singleton_method_undefined + split + sprintf + String + sub + sub! + taint + tainted? + throw + to_a + to_s + type + untaint + __send__ + ].freeze + + NILCLASS_METHODS = %w[ + & + inspect + nil? + to_a + to_f + to_i + to_s + ^ + | + ].freeze + + SYMBOL_METHODS = %w[ + === + id2name + inspect + to_i + to_int + to_s + to_sym + ].freeze + + TRUECLASS_METHODS = %w[ + & + to_s + ^ + | + ].freeze + + FALSECLASS_METHODS = %w[ + & + to_s + ^ + | + ].freeze + + ENUMERABLE_METHODS = %w[ + all? + any? + collect + detect + each_with_index + entries + find + find_all + grep + include? + inject + map + max + member? + min + partition + reject + select + sort + sort_by + to_a + zip + ].freeze + + STRING_METHODS = %w[ + % + * + + + << + <=> + == + =~ + capitalize + capitalize! + casecmp + center + chomp + chomp! + chop + chop! + concat + count + crypt + delete + delete! + downcase + downcase! + dump + each + each_byte + each_line + empty? + eql? + gsub + gsub! + hash + hex + include? + index + initialize + initialize_copy + insert + inspect + intern + length + ljust + lines + lstrip + lstrip! + match + next + next! + oct + replace + reverse + reverse! + rindex + rjust + rstrip + rstrip! + scan + size + slice + slice! + split + squeeze + squeeze! + strip + strip! + start_with? + sub + sub! + succ + succ! + sum + swapcase + swapcase! + to_f + to_i + to_s + to_str + to_sym + tr + tr! + tr_s + tr_s! + upcase + upcase! + upto + [] + []= + ].freeze + + SAFE_ENV = <<~STRING.freeze + def keep_singleton_methods(klass, singleton_methods) + klass = Object.const_get(klass) + singleton_methods = singleton_methods.map(&:to_sym) + undef_methods = (klass.singleton_methods - singleton_methods) + + undef_methods.each do |method| + klass.singleton_class.send(:undef_method, method) + end + + end + + def keep_methods(klass, methods) + klass = Object.const_get(klass) + methods = methods.map(&:to_sym) + undef_methods = (klass.methods(false) - methods) + undef_methods.each do |method| + klass.send(:undef_method, method) + end + end + + def clean_constants + (Object.constants - #{ALLOWED_CONSTANTS}).each do |const| + Object.send(:remove_const, const) if defined?(const) + end + end + + keep_singleton_methods(:Kernel, #{KERNEL_S_METHODS}) + keep_singleton_methods(:Symbol, #{SYMBOL_S_METHODS}) + keep_singleton_methods(:String, #{STRING_S_METHODS}) + + keep_methods(:Kernel, #{KERNEL_METHODS}) + keep_methods(:NilClass, #{NILCLASS_METHODS}) + keep_methods(:TrueClass, #{TRUECLASS_METHODS}) + keep_methods(:FalseClass, #{FALSECLASS_METHODS}) + keep_methods(:Enumerable, #{ENUMERABLE_METHODS}) + keep_methods(:String, #{STRING_METHODS}) + + Kernel.class_eval do + def `(*args) + raise NoMethodError, "` is unavailable" + end + + def system(*args) + raise NoMethodError, "system is unavailable" + end + end + + clean_constants + STRING + end + end +end diff --git a/lib/lago_utils/lago_utils/ruby_sandbox/sandbox_error.rb b/lib/lago_utils/lago_utils/ruby_sandbox/sandbox_error.rb new file mode 100644 index 0000000..c1d5f56 --- /dev/null +++ b/lib/lago_utils/lago_utils/ruby_sandbox/sandbox_error.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module LagoUtils + module RubySandbox + class SandboxError < StandardError + def initialize(initial_error:, backtrace: nil) + @initial_error = initial_error + @backtrace = backtrace + super + end + + attr_reader :initial_error, :backtrace + end + end +end diff --git a/lib/lago_utils/lago_utils/ruby_sandbox/sanitizer.rb b/lib/lago_utils/lago_utils/ruby_sandbox/sanitizer.rb new file mode 100644 index 0000000..a5ff3c0 --- /dev/null +++ b/lib/lago_utils/lago_utils/ruby_sandbox/sanitizer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module LagoUtils + module RubySandbox + class Sanitizer + def initialize(code) + @code = code || "" + end + + def sanitize + code.gsub(/require\s/, 'raise NoMethodError, "require is not allowed";') + end + + private + + attr_reader :code + end + end +end diff --git a/lib/lago_utils/lago_utils/version.rb b/lib/lago_utils/lago_utils/version.rb new file mode 100644 index 0000000..75e076e --- /dev/null +++ b/lib/lago_utils/lago_utils/version.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module LagoUtils + class Version + VERSION_FILE = Rails.root.join("LAGO_VERSION") + GITHUB_BASE_URL = "https://github.com/getlago/lago-api" + + Result = Data.define(:number, :github_url) + + class << self + def call(default:) + Result.new(version_number(default:), github_url) + end + + private + + def version_number(default:) + return release_date if git_hash? + + file_content + rescue Errno::ENOENT + default + end + + def github_url + "#{GITHUB_BASE_URL}/tree/#{file_content}" + rescue Errno::ENOENT + GITHUB_BASE_URL + end + + def file_content + File.read(VERSION_FILE).squish + end + + def release_date + File.ctime(VERSION_FILE).to_date.iso8601 + end + + def git_hash? + file_content&.size == 40 + end + end + end +end diff --git a/lib/migrations/extension_helper.rb b/lib/migrations/extension_helper.rb new file mode 100644 index 0000000..139cd06 --- /dev/null +++ b/lib/migrations/extension_helper.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Migrations + module ExtensionHelper + def pg_extension_present?(extension) + result = execute <<~SQL + SELECT 1 FROM pg_available_extensions WHERE name = '#{extension}' + SQL + + result.ntuples.positive? + end + end +end diff --git a/lib/redlock/client_patch.rb b/lib/redlock/client_patch.rb new file mode 100644 index 0000000..18abd89 --- /dev/null +++ b/lib/redlock/client_patch.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "redlock" +require "redlock/client" + +# Redlock retry logic actually behaves the same whether there's an error (network, Redis error, etc.) or whether it +# fails to lock because the resource is already locked. So, if we enable redlock retry, whenever we try to enqueue a job +# for which there's already a job locked (enqueued or running), we’ll try 3 times to enqueue the job causing a ~0.5s +# delay. +# +# This patch is used to work around this issue by retrying the lock acquisition only if there is a network error. +module Redlock + module ClientPatch + def lock(resource, ttl, options = {}, &block) + with_retry_on_error do + super(resource, ttl, options, &block) + end + end + + private + + def with_retry_on_error(&block) + tries = Redlock::Client::DEFAULT_RETRY_COUNT + 1 + error = nil + tries.times do |attempt_number| + # Wait a random delay before retrying. + attempt_retry_delay = (Redlock::Client::DEFAULT_RETRY_DELAY + rand(Redlock::Client::DEFAULT_RETRY_JITTER)).to_f / 1000 + sleep(attempt_retry_delay) if attempt_number > 0 + + return yield + rescue Redlock::LockAcquisitionError => error + if attempt_number == tries - 1 + raise error + end + end + end + end +end + +Redlock::Client.prepend(Redlock::ClientPatch) diff --git a/lib/task_prompt.rb b/lib/task_prompt.rb new file mode 100644 index 0000000..ca451f9 --- /dev/null +++ b/lib/task_prompt.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +# rubocop:disable Rails/Output,Rails/Exit +module TaskPrompt + def self.ask(prompt) + print prompt + $stdin.gets.chomp + end + + def self.confirm!(prompt) + abort "Aborted." unless ask(prompt).downcase == "y" + end + + def self.ask_for_organization + organization_id = ask("Organization ID: ") + organization = Organization.find_by(id: organization_id) + abort "Organization not found with ID: #{organization_id}" unless organization + + puts "Organization found: #{organization.name} (#{organization.id})" + confirm!("Is this the correct organization? (y/n): ") + + organization + end + + def self.ask_for_timestamp_range + from_time = ask_for_timestamp("From timestamp (UTC, e.g. 2026-01-01 00:00:00): ") + to_time = ask_for_timestamp("To timestamp (UTC, e.g. 2026-01-31 23:59:59): ") + + abort "from_timestamp must be before to_timestamp" if from_time > to_time + + [from_time, to_time] + end + + def self.ask_for_timestamp(prompt) + input = ask(prompt) + timestamp = Time.zone.parse(input) + abort "Invalid timestamp: #{input}" unless timestamp + + timestamp + end +end +# rubocop:enable Rails/Output,Rails/Exit diff --git a/lib/tasks/annotate_rb.rake b/lib/tasks/annotate_rb.rake new file mode 100644 index 0000000..9095c3f --- /dev/null +++ b/lib/tasks/annotate_rb.rake @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +# This rake task was added by annotate_rb gem. + +# Can set `ANNOTATERB_SKIP_ON_DB_TASKS` to be anything to skip this +if (Rails.env.development? || Rails.env.test?) && ENV["ANNOTATERB_SKIP_ON_DB_TASKS"].nil? + require "annotate_rb" + + AnnotateRb::Core.load_rake_tasks +end diff --git a/lib/tasks/applied_coupons.rake b/lib/tasks/applied_coupons.rake new file mode 100644 index 0000000..ba16d48 --- /dev/null +++ b/lib/tasks/applied_coupons.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +namespace :applied_coupons do + desc "Populate frequency duration remaining field" + task populate_frequency_duration_remaining: :environment do + AppliedCoupon.find_each do |applied_coupon| + next unless applied_coupon.recurring? + + applied_coupon.frequency_duration_remaining = applied_coupon.frequency_duration + applied_coupon.save! + end + end +end diff --git a/lib/tasks/cache.rake b/lib/tasks/cache.rake new file mode 100644 index 0000000..c9a00b7 --- /dev/null +++ b/lib/tasks/cache.rake @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +namespace :cache do + desc "Reset the current usage cache for migration from group to filters" + task remove_group_usage_cache: :environment do + charge_id = Charge.joins(:group_properties).select(:id) + + Charge.where(id: charge_id).includes(plan: :subscriptions).find_each do |charge| + charge.plan.subscriptions.find_each do |subscription| + Subscriptions::ChargeCacheService.expire_for_subscription_charge(subscription:, charge:) + end + end + end + + desc "Expire cache for a given subscription" + task expire_subscription_cache: :environment do + subscription = Subscription.find(ENV["subscription_id"]) + puts "Expiring cache for subscription #{subscription.id}" + + Subscriptions::ChargeCacheService.expire_for_subscription(subscription) + end +end diff --git a/lib/tasks/coupons.rake b/lib/tasks/coupons.rake new file mode 100644 index 0000000..ccd5bb2 --- /dev/null +++ b/lib/tasks/coupons.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +namespace :coupons do + desc "Populate expiration_date for coupons" + task fill_expiration_date: :environment do + Coupon.unscoped.find_each do |coupon| + next unless coupon.expiration_duration + + expiration_date = coupon.created_at.to_date + coupon.expiration_duration.days + coupon.update!(expiration_date:) + end + end +end diff --git a/lib/tasks/custom_aggregation.rake b/lib/tasks/custom_aggregation.rake new file mode 100644 index 0000000..86ea6cd --- /dev/null +++ b/lib/tasks/custom_aggregation.rake @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +namespace :custom_aggregation do + desc "Sandbox for to perform custom aggregation" + task debug: :environment do + # Custom aggregator + def aggregate(event, previous_state, aggregation_properties) + # TODO: change me + {total_units: BigDecimal("0"), amount: BigDecimal("0")} + end + + # Aggregation properties - TODO: change me + aggregation_properties = {} + # Intial state + previous_state = {total_units: BigDecimal("0"), amount: BigDecimal("0")} + # Event list - TODO: change me + events = [OpenStruct.new(properties: {})] + + amount = 0 + + events.each do |event| + puts "=============" + puts "Event: #{event}" + previous_state = aggregate(event, previous_state, aggregation_properties) + puts "State: #{previous_state}" + amount += previous_state[:amount] + puts "Amount: #{amount}" + end + + puts "=============" + puts "Final state: #{previous_state}" + puts "Final amount: #{amount}" + end +end diff --git a/lib/tasks/customers.rake b/lib/tasks/customers.rake new file mode 100644 index 0000000..815dd7f --- /dev/null +++ b/lib/tasks/customers.rake @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +namespace :customers do + desc "Generate Slug for Customers" + task generate_slug: :environment do + Customer.unscoped.order(:created_at).find_each(&:save) + end + + # WARNING! Potentially dangerous task + desc "Migrate customer to a new billing entity. This version is actual on August 2025, please, check before running if anything needs to be updated" + task :migrate_to_new_entity, [:organization_id, :customer_external_id, :billing_entity_code] => :environment do |_task, args| + customer_external_id = args[:customer_external_id] + billing_entity_code = args[:billing_entity_code] + + cust = Customer.find_by(external_id: customer_external_id) + new_be = cust.organization.billing_entities.find_by(code: billing_entity_code) + + # wallets are now implemented, but require a change in the codebase + # taxes should be easy to implement, but current customers do not have taxes, so we're not getting into it + raise "Taxes not implemented" if cust.taxes.any? + # current customer do not have coupons. when implementing coupons, pay attention on currencies + raise "Coupons not implemented" if cust.coupons.any? + + # triggered dunning_campaign can be not a problem if all payments and payment_requests are managed - to figure it out with the organization + raise "Customer has dunning campaigns triggered" if cust.last_dunning_campaign_attempt != 0 + raise "Customer has dunning campaigns triggered" unless cust.last_dunning_campaign_attempt_at.nil? + raise "Customer should not have payment requests" if cust.payment_requests.any? + # pay_in_advance will immediately trigger the invoice, which is not a desired behaviour + raise "customer has a subscription with a plan that is pay_in_advance" if cust.subscriptions.any? { |sub| sub.plan.pay_in_advance? } + raise "Customer has an unknown integration customer" if cust.integration_customers.any? { |int_cust| int_cust.type != "IntegrationCustomers::AnrokCustomer" && int_cust.type != "IntegrationCustomers::NetsuiteCustomer" } + # customers this script was created for, did not have credit_notes, metadata, invoice custom sections + raise "Customer should not have any credit notes" if cust.credit_notes.any? + raise "Metadata is not implemented" if cust.metadata.any? + raise "Invoice custom sections are not implemented" if cust.applied_invoice_custom_sections.any? + + ActiveRecord::Base.transaction do + cust.discard + + new_cust = cust.dup + new_cust.billing_entity = new_be + new_cust.deleted_at = nil + new_cust.payment_receipt_counter = 0 + new_cust.sequential_id = nil + new_cust.slug = nil + new_cust.last_dunning_campaign_attempt = 0 + new_cust.last_dunning_campaign_attempt_at = nil + new_cust.save! + + cust.subscriptions.active.each do |sub| + puts "Terminating active subscription with id #{sub.id} for customer #{cust.id}" + sub.update(on_termination_invoice: :skip) + Subscriptions::TerminateService.call(subscription: sub, async: false) + end + + cust.integration_customers.each do |int_cust| + if int_cust.type == "IntegrationCustomers::AnrokCustomer" + new_int_cust = int_cust.dup + new_int_cust.customer = new_cust + new_int_cust.save! + elsif int_cust.type == "IntegrationCustomers::NetsuiteCustomer" + # we decided that they will need to manually create new integration customers + else + raise "Unknown integration customer type: #{int_cust.type}" + end + end + + cust.payment_provider_customers.each do |payment_provider_cust| + new_payment_provider_cust = payment_provider_cust.dup + new_payment_provider_cust.customer = new_cust + new_payment_provider_cust.save! + end + + # do we want to create wallet with 0 values, and create an inbound transaction of granted credits??? + cust.wallets.each do |wallet| + wallet_params = { + organization_id: new_cust.organization_id, + customer: new_cust, + name: wallet.name, + rate_amount: wallet.rate_amount, + currency: wallet.currency, + expiration_at: wallet.expiration_at, + invoice_requires_successful_payment: wallet.invoice_requires_successful_payment, + applies_to: { + fee_types: wallet.allowed_fee_types + }, + granted_credits: wallet.credits_balance.to_s + } + new_wallet = Wallets::CreateService.call!(params: wallet_params).wallet + + wallet.recurring_transaction_rules.each do |rule| + new_rule = rule.dup + new_rule.wallet = new_wallet + new_rule.save! + end + wallet.wallet_targets.each do |target| + new_target = target.dup + new_target.wallet = new_wallet + new_target.save! + end + end + + Customers::TerminateRelationsService.call(customer: cust) + end + end + + desc "Backfill EU auto-taxes for customers whose applied lago_eu_XX_standard no longer matches customers.country" + task :backfill_eu_auto_taxes, [:organization_id] => :environment do |_task, args| + organization_id = args[:organization_id] + abort "Missing organization_id argument\n\nUsage: rake customers:backfill_eu_auto_taxes[organization_id]" if organization_id.blank? + + batch_size = (ENV["BATCH_SIZE"] || 500).to_i + abort "BATCH_SIZE must be positive" if batch_size <= 0 + + dry_run = ENV.fetch("DRY_RUN", "true") != "false" + mode = dry_run ? "DRY RUN" : "LIVE" + + total_processed = 0 + counters = {reapply: 0, vies_pending: 0, skipped: 0} + + puts "Starting EU auto-taxes backfill [#{mode}] for organization #{organization_id} (batch_size: #{batch_size})..." + + preview_customer = lambda do |customer| + result = nil + ActiveRecord::Base.transaction(requires_new: true) do + result = Customers::EuAutoTaxesService.call( + customer: customer, + new_record: false, + tax_attributes_changed: true + ) + raise ActiveRecord::Rollback + end + + current_eu_codes = customer.taxes.where("code ILIKE ?", "lago_eu%").pluck(:code) + + if result.success? + counters[:reapply] += 1 + puts " [DRY RUN] customer=#{customer.id} country=#{customer.country} current_eu=#{current_eu_codes.inspect} -> target=#{result.tax_code} (would re-apply)" + elsif result.error.is_a?(BaseService::ServiceFailure) && result.error.code == "vies_check_pending" + counters[:vies_pending] += 1 + puts " [DRY RUN] customer=#{customer.id} country=#{customer.country} current_eu=#{current_eu_codes.inspect} would schedule VIES check" + else + code = result.error.respond_to?(:code) ? result.error.code : "unknown" + counters[:skipped] += 1 + puts " [DRY RUN] customer=#{customer.id} country=#{customer.country} current_eu=#{current_eu_codes.inspect} skipped (#{code})" + end + end + + scope = Customer + .joins(applied_taxes: :tax) + .joins(:billing_entity) + .where(customers: {organization_id: organization_id}) + .where(billing_entities: {eu_tax_management: true}) + .where.not(customers: {country: nil}) + .where("taxes.code ~ '^lago_eu_[a-z]{2}_standard$'") + .where("taxes.code <> CONCAT('lago_eu_', LOWER(customers.country), '_standard')") + .where("NOT EXISTS (SELECT 1 FROM pending_vies_checks pvc WHERE pvc.customer_id = customers.id)") + .distinct + + if dry_run + candidate_ids = scope.pluck(:id) + puts " Candidates found: #{candidate_ids.size}" + + candidate_ids.each_slice(batch_size) do |ids| + Customer.where(id: ids).find_each(&preview_customer) + + total_processed += ids.size + puts " Batch processed: #{ids.size} customers (total: #{total_processed})" + end + else + loop do + customer_ids = scope.limit(batch_size).pluck(:id) + break if customer_ids.empty? + + Customer.where(id: customer_ids).find_each do |customer| + result = Customers::EuAutoTaxesService.call( + customer: customer, + new_record: false, + tax_attributes_changed: true + ) + next unless result.success? + + preserved_codes = customer.taxes.where.not("code ILIKE 'lago_eu%'").pluck(:code) + tax_codes = (preserved_codes + [result.tax_code]).uniq + + Customers::ApplyTaxesService.call!(customer: customer, tax_codes: tax_codes) + end + + total_processed += customer_ids.size + puts " Batch processed: #{customer_ids.size} customers (total: #{total_processed})" + break if customer_ids.size < batch_size + end + end + + puts "Done [#{mode}]. Total processed: #{total_processed}." + if dry_run + puts " Would re-apply: #{counters[:reapply]}" + puts " Would schedule VIES check: #{counters[:vies_pending]}" + puts " Would skip: #{counters[:skipped]}" + end + end +end + diff --git a/lib/tasks/daily_usages.rake b/lib/tasks/daily_usages.rake new file mode 100644 index 0000000..6c6dc93 --- /dev/null +++ b/lib/tasks/daily_usages.rake @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +namespace :daily_usages do + desc "Fill past daily usage" + task :fill_history, [:organization_id, :days_ago] => :environment do |_task, args| + abort "Missing organization_id\n\n" unless args[:organization_id] + + Rails.logger.level = Logger::INFO + + from_date = (args[:days_ago] || DailyUsage::DEFAULT_HISTORY_DAYS).days.ago.to_date + organization = Organization.find(args[:organization_id]) + + subscriptions = organization.subscriptions + .where(status: [:active, :terminated]) + .where.not(started_at: nil) + .where("terminated_at IS NULL OR terminated_at >= ?", from_date) + .includes(customer: :organization) + + subscriptions.find_each do |subscription| + DailyUsages::FillHistoryJob.perform_later(subscription:, from_date:) + end + end +end diff --git a/lib/tasks/db.rake b/lib/tasks/db.rake new file mode 100644 index 0000000..00fc12e --- /dev/null +++ b/lib/tasks/db.rake @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +namespace :db do + # NOTE: The main benefit is to avoid PG::ObjectInUse error when dropping + # Migration state is preserved so `db:migrate:redo:primary` can still be used + desc "Truncate all tables and keep migrations state" + task truncate: :environment do + raise "Can only be used in development" unless Rails.env.development? + + ActiveRecord::Base.connection.tables.each do |table| + next if table == "schema_migrations" || table == "ar_internal_metadata" + ActiveRecord::Base.connection.execute("TRUNCATE TABLE #{table} RESTART IDENTITY CASCADE") + end + end +end diff --git a/lib/tasks/enriched_events_comparison.rake b/lib/tasks/enriched_events_comparison.rake new file mode 100644 index 0000000..26c191e --- /dev/null +++ b/lib/tasks/enriched_events_comparison.rake @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "json" + +namespace :enriched_events do + desc "Compare ClickhouseStore vs ClickhouseEnrichedStore usage for given subscription IDs" + task :compare, [:subscription_id] => :environment do |_task, args| + Rails.logger.level = Logger::Severity::ERROR + + abort "Usage: [QUIET=true] [DEDUPLICATE=true] [FORMAT=json] rake enriched_events:compare[sub_id_1,sub_id_2,...]\n\n" unless args[:subscription_id] + abort "[SKIP] Clickhouse is not enabled on this system" if ENV["LAGO_CLICKHOUSE_ENABLED"].blank? + + quiet = ENV.fetch("QUIET", "false") == "true" + deduplicate = ENV.fetch("DEDUPLICATE", "false") == "true" + format_json = ENV.fetch("FORMAT", "").downcase == "json" + + log = format_json ? ->(_msg) {} : ->(msg) { puts msg } + json_results = [] if format_json + + subscription_ids = [args[:subscription_id]] + args.extras + total_diffs = 0 + total_legacy_elapsed = 0.0 + total_enriched_elapsed = 0.0 + + subscription_ids.each do |sub_id| + log.call("\n#{"=" * 80}") + log.call("Subscription: #{sub_id}") + log.call("=" * 80) + + subscription = Subscription.includes(:customer, plan: :organization).find_by(id: sub_id) + + if subscription.nil? + log.call("[SKIP] Subscription not found") + json_results&.push({subscription_id: sub_id, status: "skipped", reason: "Subscription not found"}) + next + end + + organization = subscription.plan.organization + + unless organization.clickhouse_events_store? + log.call("[SKIP] Organization #{organization.id} does not use ClickHouse") + json_results&.push({subscription_id: sub_id, status: "skipped", reason: "Organization does not use ClickHouse"}) + next + end + + comparison_result = Events::Stores::Clickhouse::EnrichedStoreMigration::ComparisonService.call( + subscription:, + deduplicate: + ) + + unless comparison_result.success? + log.call("[ERROR] Comparison failed: #{comparison_result.error&.message}") + json_results&.push({subscription_id: sub_id, status: "error", reason: comparison_result.error&.message}) + next + end + + legacy_elapsed = comparison_result.legacy_elapsed + enriched_elapsed = comparison_result.enriched_elapsed + total_legacy_elapsed += legacy_elapsed + total_enriched_elapsed += enriched_elapsed + + sub_diffs = comparison_result.diff_count + total_diffs += sub_diffs + fee_details = [] if format_json + + comparison_result.fee_details.each do |detail| + parts = ["charge=#{detail.charge_id}"] + parts << "filter=#{detail.charge_filter_id}" if detail.charge_filter_id + parts << "metric=#{detail.billable_metric_code}" if detail.billable_metric_code + parts << "grouped_by=#{detail.grouped_by}" if detail.grouped_by.present? + parts << "agg=#{detail.aggregation_type}" if detail.aggregation_type + parts << "model=#{detail.charge_model}" if detail.charge_model + parts << "from=#{detail.from}" if detail.from + parts << "to=#{detail.to}" if detail.to + label = parts.join(" ") + + case detail.status + when "only_in_legacy" + log.call(" [ONLY IN LEGACY] #{label}") + when "only_in_enriched" + log.call(" [ONLY IN ENRICHED] #{label}") + when "diff" + log.call(" [DIFF] #{label}") + unless format_json + detail.diffs.each do |field, values| + log.call(" #{field}: legacy=#{values.legacy} enriched=#{values.enriched}") + end + end + when "match" + log.call(" [MATCH] #{label}") unless quiet + end + + if format_json && (detail.status != "match" || !quiet) + fee_details << detail.to_h + end + end + + timing_info = build_timing(legacy_elapsed, enriched_elapsed) + log.call("\n Summary: #{comparison_result.fee_details.size} fee(s), #{sub_diffs} difference(s)") + log.call(" Timing: legacy=#{legacy_elapsed.round(3)}s enriched=#{enriched_elapsed.round(3)}s #{timing_info[:comparison]}") + + if format_json + json_results << { + subscription_id: sub_id, + status: "compared", + timing: {legacy_seconds: legacy_elapsed.round(3), enriched_seconds: enriched_elapsed.round(3), speedup: timing_info[:speedup]}, + fee_count: comparison_result.fee_details.size, + diff_count: sub_diffs, + fees: fee_details + } + end + end + + total_timing = build_timing(total_legacy_elapsed, total_enriched_elapsed) + log.call("\n#{"=" * 80}") + log.call("Total differences across all subscriptions: #{total_diffs}") + log.call("Total timing: legacy=#{total_legacy_elapsed.round(3)}s enriched=#{total_enriched_elapsed.round(3)}s #{total_timing[:comparison]}") + log.call("=" * 80) + + if format_json + output = { + generated_at: Time.current.iso8601, + options: {quiet: quiet, deduplicate: deduplicate}, + total_diffs: total_diffs, + total_subscriptions: subscription_ids.size, + total_timing: {legacy_seconds: total_legacy_elapsed.round(3), enriched_seconds: total_enriched_elapsed.round(3), speedup: total_timing[:speedup]}, + subscriptions: json_results + } + puts JSON.pretty_generate(output) + end + end + + private + + def build_timing(legacy_elapsed, enriched_elapsed) + if enriched_elapsed.zero? + {speedup: nil, comparison: "enriched=0s"} + elsif legacy_elapsed.zero? + {speedup: nil, comparison: "legacy=0s"} + else + speedup = (legacy_elapsed / enriched_elapsed).round(2) + comparison = if speedup >= 1.0 + "speedup=#{speedup}x (enriched is faster)" + else + "slowdown=#{(1.0 / speedup).round(2)}x (enriched is slower)" + end + {speedup: speedup, comparison: comparison} + end + end +end diff --git a/lib/tasks/entitlements.rake b/lib/tasks/entitlements.rake new file mode 100644 index 0000000..96bd0a9 --- /dev/null +++ b/lib/tasks/entitlements.rake @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +namespace :entitlements do + desc "Count duplicate subscription entitlements that would be cleaned up" + task :count_duplicate_subscription_entitlements, [:organization_id] => :environment do |_task, args| + organization_id = args[:organization_id] + abort "Missing organization_id argument\n\nUsage: rake entitlements:count_duplicate_subscription_entitlements[organization_id]" unless organization_id + + count_sql = <<~SQL.squish + SELECT COUNT(*) + FROM entitlement_entitlements sub_ent + JOIN subscriptions s ON s.id = sub_ent.subscription_id + JOIN plans p ON p.id = s.plan_id + JOIN entitlement_entitlements plan_ent + ON plan_ent.entitlement_feature_id = sub_ent.entitlement_feature_id + AND plan_ent.plan_id = COALESCE(p.parent_id, p.id) + AND plan_ent.deleted_at IS NULL + WHERE sub_ent.subscription_id IS NOT NULL + AND sub_ent.deleted_at IS NULL + AND sub_ent.organization_id = $1 + AND NOT EXISTS ( + SELECT 1 FROM entitlement_entitlement_values v + WHERE v.entitlement_entitlement_id = sub_ent.id + AND v.deleted_at IS NULL + ) + SQL + + count = ActiveRecord::Base.connection.select_value(count_sql, "Count duplicate entitlements", [organization_id]) + puts "Found #{count} duplicate subscription entitlements for organization #{organization_id}." + end + + desc "Soft-delete duplicate subscription entitlements that have no values and whose feature is already on the parent plan" + task :cleanup_duplicate_subscription_entitlements, [:organization_id] => :environment do |_task, args| + organization_id = args[:organization_id] + abort "Missing organization_id argument\n\nUsage: rake entitlements:cleanup_duplicate_subscription_entitlements[organization_id]" unless organization_id + + read_batch_size = 1_000 + write_batch_size = 5000 + deleted_at = Time.current.beginning_of_hour + total_deleted = 0 + last_id = nil + + puts "Starting cleanup of duplicate subscription entitlements for organization #{organization_id} (deleted_at: #{deleted_at})..." + + loop do + # Step 1: Get entitlements attached to subscriptions that are not soft deleted. + # Uses ID-based cursor pagination to ensure progress through the dataset. + scope = Entitlement::Entitlement + .where(organization_id: organization_id) + .where.not(subscription_id: nil) + .order(:id) + .limit(read_batch_size) + scope = scope.where("id > ?", last_id) if last_id + + subscription_entitlements = scope.to_a + break if subscription_entitlements.empty? + + last_id = subscription_entitlements.last.id + + # Step 2: Check which entitlements have values and exclude them from processing. + entitlement_ids = subscription_entitlements.map(&:id) + entitlements_with_values_ids = Entitlement::EntitlementValue + .where(entitlement_entitlement_id: entitlement_ids) + .distinct + .pluck(:entitlement_entitlement_id) + .to_set + + subscription_entitlements.reject! { |e| entitlements_with_values_ids.include?(e.id) } + next if subscription_entitlements.empty? + + # Step 3: For each subscription in the batch, resolve the effective plan + # (parent plan if it exists, otherwise the subscription's own plan). + subscription_ids = subscription_entitlements.map(&:subscription_id).uniq + subscription_to_plan = Subscription + .joins(:plan) + .where(id: subscription_ids) + .pluck(:id, Arel.sql("COALESCE(plans.parent_id, plans.id)")) + .to_h + + # Step 4: Get all feature IDs attached to these plans and build a lookup hash + # mapping each subscription_id to its plan's feature IDs. + plan_ids = subscription_to_plan.values.uniq + plan_to_features = Hash.new { |h, k| h[k] = Set.new } + Entitlement::Entitlement + .where(plan_id: plan_ids) + .pluck(:plan_id, :entitlement_feature_id) + .each { |plan_id, feature_id| plan_to_features[plan_id].add(feature_id) } + + subscription_to_plan_feature_ids = subscription_to_plan.transform_values { |plan_id| plan_to_features[plan_id] } + + # Step 5: Soft-delete subscription entitlements whose feature is already on the plan. + ids_to_delete = subscription_entitlements + .select { |e| subscription_to_plan_feature_ids[e.subscription_id]&.include?(e.entitlement_feature_id) } + .map(&:id) + + next if ids_to_delete.empty? + + ids_to_delete.each_slice(write_batch_size) do |batch_ids| + deleted_count = Entitlement::Entitlement + .where(id: batch_ids) + .update_all(deleted_at: deleted_at) # rubocop:disable Rails/SkipsModelValidations + total_deleted += deleted_count + puts " Progress: #{total_deleted} entitlements soft-deleted..." + end + end + + puts "Done. Soft-deleted #{total_deleted} entitlements." + end +end diff --git a/lib/tasks/events.rake b/lib/tasks/events.rake new file mode 100644 index 0000000..527fec6 --- /dev/null +++ b/lib/tasks/events.rake @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +namespace :events do + # NOTE: related to https://github.com/getlago/lago-api/issues/317 + desc "Fill missing timestamps for events" + task fill_timestamp: :environment do + Event.unscoped.where(timestamp: nil).find_each do |event| + event.update!(timestamp: event.created_at) + end + end + + desc "Fill missing subscription_id" + task fill_subscription: :environment do + Event.unscoped.where(subscription_id: nil).find_each do |event| + subscription = event.customer.active_subscription || event.customer.subscriptions.order(:created_at).last + + unless subscription + event.destroy + next + end + + event.update!(subscription_id: subscription.id) + end + end + + desc "Deduplicate events_enriched_expanded by removing older versions of duplicate rows" + task deduplicate_enriched_expanded: :environment do + Rails.logger.level = Logger::Severity::INFO + + organization_id = ENV.fetch("ORGANIZATION_ID") + subscription_ids = ENV["SUBSCRIPTION_IDS"].to_s.split(",").map(&:strip).reject(&:blank?) + codes = ENV["BM_CODES"].to_s.split(",").map(&:strip).reject(&:blank?) + dry_run = ENV.fetch("DRY_RUN", "true") != "false" + quiet = ENV.fetch("QUIET", "false") == "true" + timeout = ENV["TIMEOUT"].presence + + organization = Organization.find(organization_id) + subscriptions = organization.subscriptions + subscriptions = subscriptions.where(id: subscription_ids) if subscription_ids.present? + + total_duplicates = 0 + + subscriptions.find_each do |subscription| + service = Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService.new(subscription:, codes:, timeout: timeout.to_i.positive? ? timeout.to_i : nil) + + duplicate_count = service.count_duplicates + + if dry_run + if duplicate_count > 0 || !quiet + Rails.logger.info( + "events:deduplicate_enriched_expanded [DRY RUN] - Subscription #{subscription.external_id}: #{duplicate_count} duplicate rows would be removed" + ) + end + else + begin + result = service.call + duplicate_count = result.duplicated_count + + if duplicate_count > 0 || !quiet + message = "duplicate rows will be removed" + message = "#{duplicate_count} have to be removed" if result.queries.present? + + Rails.logger.info( + "events:deduplicate_enriched_expanded - Subscription #{subscription.external_id}: #{duplicate_count} #{message}" + ) + end + + if result.queries.present? + Rails.logger.warn( + "events:deduplicate_enriched_expanded - Subscription #{subscription.external_id}: " \ + "#{result.queries.size} batch(es) timed out. Run manually:" + ) + result.queries.each { |q| Rails.logger.warn(q) } + end + rescue => e + duplicate_count = 0 + Rails.logger.error("events:deduplicate_enriched_expanded - Subscription #{subscription.external_id}: Error removing duplicates: #{e.message}") + end + end + + total_duplicates += duplicate_count + end + + mode = dry_run ? "DRY RUN" : "LIVE" + action = dry_run ? "found" : "removed" + Rails.logger.info("events:deduplicate_enriched_expanded [#{mode}] - Complete. #{total_duplicates} total duplicate rows #{action}.") + end + + desc "Detect and optionally reprocess events for subscriptions needing re-enrichment" + task reprocess: :environment do + Rails.logger.level = Logger::Severity::INFO + + organization_id = ENV.fetch("ORGANIZATION_ID") + reprocess = ENV.fetch("REPROCESS", "false") == "true" + batch_size = (ENV["BATCH_SIZE"] || 1000).to_i + sleep_seconds = (ENV["SLEEP_SECONDS"] || 0.5).to_f + + organization = Organization.find(organization_id) + + service_result = Events::Stores::Clickhouse::PreEnrichmentCheckService.call( + organization:, reprocess:, batch_size:, sleep_seconds: + ) + + subscriptions_map = service_result.subscriptions_to_reprocess + mode = reprocess ? "REPROCESS" : "DRY RUN" + + if subscriptions_map.empty? + Rails.logger.info("events:reprocess [#{mode}] - No subscriptions need reprocessing") + else + subscriptions_map.each do |sub_id, codes| + Rails.logger.info("events:reprocess [#{mode}] - Subscription #{sub_id}: #{codes.join(", ")}") + end + Rails.logger.info("events:reprocess [#{mode}] - #{subscriptions_map.size} subscriptions detected") + end + ensure + Karafka.producer.close if reprocess + end +end diff --git a/lib/tasks/fees.rake b/lib/tasks/fees.rake new file mode 100644 index 0000000..9a62315 --- /dev/null +++ b/lib/tasks/fees.rake @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +namespace :fees do + desc "Fill missing fee_type" + task fill_fee_type: :environment do + Fee.where(fee_type: nil).find_each do |fee| + next fee.add_on! if fee.applied_add_on_id.present? + next fee.charge! if fee.charge_id.present? + + fee.subscription! + end + end +end diff --git a/lib/tasks/filters.rake b/lib/tasks/filters.rake new file mode 100644 index 0000000..46c098c --- /dev/null +++ b/lib/tasks/filters.rake @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +namespace :filters do + desc "Clean duplicated filters" + task deduplicate: :environment do + charges = Charge.joins(:filters).includes(filters: {values: :billable_metric_filter}).distinct + + charges.find_each do |charge| + next if charge.filters.count <= 1 + + charge.filters.each do |filter| + h = filter.to_h + next if filter.reload.deleted_at.present? + + duplicates = charge.filters.select do |f| + next false if f.id == filter.id + next false if f.reload.deleted_at.present? + + h.keys.sort == f.to_h.keys.sort && h.keys.all? { |k| h[k].sort == f.to_h[k].sort } + end + + duplicates.each { |f| f.discard! } + end + end + end +end diff --git a/lib/tasks/invoices.rake b/lib/tasks/invoices.rake new file mode 100644 index 0000000..28d7f13 --- /dev/null +++ b/lib/tasks/invoices.rake @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +namespace :invoices do + desc "Generate Number for Invoices" + task generate_number: :environment do + Invoice.order(:created_at).find_each(&:save) + end + + desc "Populate invoice_subscriptions join table" + task handle_subscriptions: :environment do + Invoice.order(:created_at).find_each do |invoice| + subscription_id = invoice&.subscription_id + next unless subscription_id + + invoice_subscription = InvoiceSubscription.find_by( + invoice_id: invoice.id, + subscription_id: + ) + + next if invoice_subscription + + InvoiceSubscription.create!(invoice_id: invoice.id, subscription_id:, timestamp: Time.current) + end + end + + desc "Fill missing customer_id" + task fill_customer: :environment do + Invoice.where(customer_id: nil).find_each do |invoice| + invoice.update!(customer_id: invoice.subscriptions&.first&.customer_id) + end + end + + desc "Fill invoice Taxes rate" + task fill_taxes_rate: :environment do + Invoice.where(taxes_rate: nil).find_each do |invoice| + invoice.update!( + taxes_rate: (invoice.taxes_amount_cents.fdiv(invoice.amount_cents) * 100).round(2) + ) + end + end + + desc "Fill expected_finalization_date" + task fill_expected_finalization_date: :environment do + Invoice.in_batches(of: 10_000).update_all("expected_finalization_date = COALESCE(expected_finalization_date, issuing_date)") # rubocop:disable Rails/SkipsModelValidations + end +end diff --git a/lib/tasks/lago.rake b/lib/tasks/lago.rake new file mode 100644 index 0000000..cadc35a --- /dev/null +++ b/lib/tasks/lago.rake @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +namespace :lago do + desc "Print the current version of Lago" + task version: :environment do + output = { + number: LAGO_VERSION.number, + github_url: LAGO_VERSION.github_url, + schema_version: ApplicationRecord.connection.migration_context.current_version + } + + if ENV["LAGO_CLICKHOUSE_MIGRATIONS_ENABLED"] == "true" + output[:clickhouse_schema_version] = Clickhouse::BaseRecord.connection.migration_context.current_version + end + + puts(output.to_json) + end +end diff --git a/lib/tasks/memberships.rake b/lib/tasks/memberships.rake new file mode 100644 index 0000000..7e7c51e --- /dev/null +++ b/lib/tasks/memberships.rake @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +namespace :memberships do + desc "Revoke duplicates memberships" + task revoke_duplicates: :environment do + duplicated_memberships = Membership.active + .group(:user_id, :organization_id) + .having("count(*) > 1") + .select(:user_id, :organization_id) + + duplicated_memberships.each do |membership| + memberships = Membership.where( + user_id: membership.user_id, + organization_id: membership.organization_id + ).order("created_at ASC") + + memberships.first.mark_as_revoke! + end + end +end diff --git a/lib/tasks/migrations/organization_id.rake b/lib/tasks/migrations/organization_id.rake new file mode 100644 index 0000000..dd5e4fa --- /dev/null +++ b/lib/tasks/migrations/organization_id.rake @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +namespace :migrations do + desc "Populate organization_id on every tables" + task fill_organization_id: :environment do + Rails.logger.level = Logger::Severity::ERROR + + resources_to_fill = [ + {model: AddOn::AppliedTax, job: DatabaseMigrations::PopulateAddOnsTaxesWithOrganizationJob}, + {model: AdjustedFee, job: DatabaseMigrations::PopulateAdjustedFeesWithOrganizationJob}, + {model: AppliedCoupon, job: DatabaseMigrations::PopulateAppliedCouponsWithOrganizationJob}, + {model: AppliedInvoiceCustomSection, job: DatabaseMigrations::PopulateAppliedInvoiceCustomSectionsWithOrganizationJob}, + {model: AppliedUsageThreshold, job: DatabaseMigrations::PopulateAppliedUsageThresholdsWithOrganizationJob}, + {model: BillableMetricFilter, job: DatabaseMigrations::PopulateBillableMetricFiltersWithOrganizationJob}, + {model: BillingEntity::AppliedTax, job: DatabaseMigrations::PopulateBillingEntitiesTaxesWithOrganizationJob}, + {model: ChargeFilterValue, job: DatabaseMigrations::PopulateChargeFilterValuesWithOrganizationJob}, + {model: ChargeFilter, job: DatabaseMigrations::PopulateChargeFiltersWithOrganizationJob}, + {model: Charge::AppliedTax, job: DatabaseMigrations::PopulateChargesTaxesWithOrganizationJob}, + {model: Charge, job: DatabaseMigrations::PopulateChargesWithOrganizationJob}, + {model: Commitment::AppliedTax, job: DatabaseMigrations::PopulateCommitmentsTaxesWithOrganizationJob}, + {model: Commitment, job: DatabaseMigrations::PopulateCommitmentsWithOrganizationJob}, + {model: CouponTarget, job: DatabaseMigrations::PopulateCouponTargetsWithOrganizationJob}, + {model: CreditNoteItem, job: DatabaseMigrations::PopulateCreditNoteItemsWithOrganizationJob}, + {model: CreditNote::AppliedTax, job: DatabaseMigrations::PopulateCreditNotesTaxesWithOrganizationJob}, + {model: CreditNote, job: DatabaseMigrations::PopulateCreditNotesWithOrganizationJob}, + {model: Credit, job: DatabaseMigrations::PopulateCreditsWithOrganizationJob}, + {model: Metadata::CustomerMetadata, job: DatabaseMigrations::PopulateCustomerMetadataWithOrganizationJob}, + {model: Customer::AppliedTax, job: DatabaseMigrations::PopulateCustomersTaxesWithOrganizationJob}, + {model: DataExportPart, job: DatabaseMigrations::PopulateDataExportPartsWithOrganizationJob}, + {model: DunningCampaignThreshold, job: DatabaseMigrations::PopulateDunningCampaignThresholdsWithOrganizationJob}, + {model: Fee::AppliedTax, job: DatabaseMigrations::PopulateFeesTaxesWithOrganizationJob}, + {model: IdempotencyRecord, job: DatabaseMigrations::PopulateIdempotencyRecordsWithOrganizationJob}, + {model: IntegrationCollectionMappings::BaseCollectionMapping, job: DatabaseMigrations::PopulateIntegrationCollectionMappingsWithOrganizationJob}, + {model: IntegrationCustomers::BaseCustomer, job: DatabaseMigrations::PopulateIntegrationCustomersWithOrganizationJob}, + {model: IntegrationItem, job: DatabaseMigrations::PopulateIntegrationItemsWithOrganizationJob}, + {model: IntegrationMappings::BaseMapping, job: DatabaseMigrations::PopulateIntegrationMappingsWithOrganizationJob}, + {model: IntegrationResource, job: DatabaseMigrations::PopulateIntegrationResourcesWithOrganizationJob}, + {model: Metadata::InvoiceMetadata, job: DatabaseMigrations::PopulateInvoiceMetadataWithOrganizationJob}, + {model: InvoiceSubscription, job: DatabaseMigrations::PopulateInvoiceSubscriptionsWithOrganizationJob}, + {model: PaymentRequest::AppliedInvoice, job: DatabaseMigrations::PopulateInvoicesPaymentRequestsWithOrganizationJob}, + {model: Invoice::AppliedTax, job: DatabaseMigrations::PopulateInvoicesTaxesWithOrganizationJob}, + {model: PaymentProviderCustomers::BaseCustomer, job: DatabaseMigrations::PopulatePaymentProviderCustomersWithOrganizationJob}, + {model: Payment, job: DatabaseMigrations::PopulatePaymentsWithOrganizationFromInvoiceJob}, + {model: Payment, job: DatabaseMigrations::PopulatePaymentsWithOrganizationFromPaymentRequestJob}, + {model: Plan::AppliedTax, job: DatabaseMigrations::PopulatePlansTaxesWithOrganizationJob}, + {model: RecurringTransactionRule, job: DatabaseMigrations::PopulateRecurringTransactionRulesWithOrganizationJob}, + {model: Refund, job: DatabaseMigrations::PopulateRefundsWithOrganizationJob}, + {model: Subscription, job: DatabaseMigrations::PopulateSubscriptionsWithOrganizationJob}, + {model: UsageThreshold, job: DatabaseMigrations::PopulateUsageThresholdsWithOrganizationJob}, + {model: WalletTransaction, job: DatabaseMigrations::PopulateWalletTransactionsWithOrganizationJob}, + {model: Wallet, job: DatabaseMigrations::PopulateWalletsWithOrganizationJob}, + {model: Webhook, job: DatabaseMigrations::PopulateWebhooksWithOrganizationJob} + ] + + puts "##################################\nStarting filling organization_id" + puts "\n#### Checking for resource to fill ####" + + to_fill = [] + + resources_to_fill.each do |resource| + model = resource[:model] + pp "- Checking #{model.name}: 🔎" + count = model.where(organization_id: nil).count + + if count > 0 + to_fill << resource + pp " -> #{count} records to fill 🧮" + else + pp " -> Nothing to do ✅" + end + end + + if to_fill.any? + puts "\n#### Enqueue jobs in the low_priority queue ####" + to_fill.each do |resource| + pp "- Enqueuing #{resource[:job].name}" + resource[:job].perform_later + end + end + + while to_fill.present? + sleep 5 + puts "\n#### Checking status ####" + + to_delete = [] + to_fill.each do |resource| + model = resource[:model] + pp "- Checking #{model.name}: 🔎" + count = model.where(organization_id: nil).count + + if count > 0 + pp " -> #{count} remaining 🧮" + else + to_delete << resource + pp " -> Done ✅" + end + end + + to_delete.each { to_fill.delete(it) } + end + + puts "\n#### All good, ready to Upgrade! ✅ ####" + end +end diff --git a/lib/tasks/migrations/payment_methods.rake b/lib/tasks/migrations/payment_methods.rake new file mode 100644 index 0000000..b69cd7b --- /dev/null +++ b/lib/tasks/migrations/payment_methods.rake @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +namespace :migrations do + desc "Backfill payment methods from existing Stripe provider customers" + task backfill_stripe_payment_methods: :environment do + Rails.logger.level = Logger::Severity::ERROR + + count_sql = <<~SQL + SELECT COUNT(*) FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::StripeCustomer' + AND ppc.settings->>'payment_method_id' IS NOT NULL + AND ppc.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'payment_method_id' + ) + SQL + + puts "##################################\nStarting payment methods backfill" + puts "\n#### Checking for resources to fill ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} provider customers to migrate 🧮" + puts "\n#### Enqueue job in the low_priority queue ####" + pp "- Enqueuing DatabaseMigrations::BackfillStripePaymentMethodsJob" + DatabaseMigrations::BackfillStripePaymentMethodsJob.perform_later + else + pp " -> Nothing to do ✅" + end + + while count > 0 + sleep 5 + puts "\n#### Checking status ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} remaining 🧮" + else + pp " -> Done ✅" + end + end + + puts "\n#### All good! ✅ ####" + end + + desc "Backfill payment methods from existing Adyen provider customers" + task backfill_adyen_payment_methods: :environment do + Rails.logger.level = Logger::Severity::ERROR + + count_sql = <<~SQL + SELECT COUNT(*) FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::AdyenCustomer' + AND ppc.settings->>'payment_method_id' IS NOT NULL + AND ppc.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'payment_method_id' + ) + SQL + + puts "##################################\nStarting Adyen payment methods backfill" + puts "\n#### Checking for resources to fill ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} provider customers to migrate 🧮" + puts "\n#### Enqueue job in the low_priority queue ####" + pp "- Enqueuing DatabaseMigrations::BackfillAdyenPaymentMethodsJob" + DatabaseMigrations::BackfillAdyenPaymentMethodsJob.perform_later + else + pp " -> Nothing to do ✅" + end + + while count > 0 + sleep 5 + puts "\n#### Checking status ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} remaining 🧮" + else + pp " -> Done ✅" + end + end + + puts "\n#### All good! ✅ ####" + end + + desc "Backfill payment methods from existing GoCardless provider customers" + task backfill_gocardless_payment_methods: :environment do + Rails.logger.level = Logger::Severity::ERROR + + count_sql = <<~SQL + SELECT COUNT(*) FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::GocardlessCustomer' + AND ppc.settings->>'provider_mandate_id' IS NOT NULL + AND ppc.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'provider_mandate_id' + ) + SQL + + puts "##################################\nStarting GoCardless payment methods backfill" + puts "\n#### Checking for resources to fill ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} provider customers to migrate 🧮" + puts "\n#### Enqueue job in the low_priority queue ####" + pp "- Enqueuing DatabaseMigrations::BackfillGocardlessPaymentMethodsJob" + DatabaseMigrations::BackfillGocardlessPaymentMethodsJob.perform_later + else + pp " -> Nothing to do ✅" + end + + while count > 0 + sleep 5 + puts "\n#### Checking status ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} remaining 🧮" + else + pp " -> Done ✅" + end + end + + puts "\n#### All good! ✅ ####" + end + + desc "Backfill payment methods from existing Moneyhash provider customers" + task backfill_moneyhash_payment_methods: :environment do + Rails.logger.level = Logger::Severity::ERROR + + count_sql = <<~SQL + SELECT COUNT(*) FROM payment_provider_customers ppc + WHERE ppc.type = 'PaymentProviderCustomers::MoneyhashCustomer' + AND ppc.settings->>'payment_method_id' IS NOT NULL + AND ppc.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM payment_methods pm + WHERE pm.payment_provider_customer_id = ppc.id + AND pm.provider_method_id = ppc.settings->>'payment_method_id' + ) + SQL + + puts "##################################\nStarting Moneyhash payment methods backfill" + puts "\n#### Checking for resources to fill ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} provider customers to migrate 🧮" + puts "\n#### Enqueue job in the low_priority queue ####" + pp "- Enqueuing DatabaseMigrations::BackfillMoneyhashPaymentMethodsJob" + DatabaseMigrations::BackfillMoneyhashPaymentMethodsJob.perform_later + else + pp " -> Nothing to do ✅" + end + + while count > 0 + sleep 5 + puts "\n#### Checking status ####" + + count = ActiveRecord::Base.connection.select_value(count_sql).to_i + + if count > 0 + pp " -> #{count} remaining 🧮" + else + pp " -> Done ✅" + end + end + + puts "\n#### All good! ✅ ####" + end +end diff --git a/lib/tasks/migrations/usage_thresholds.rake b/lib/tasks/migrations/usage_thresholds.rake new file mode 100644 index 0000000..874fc43 --- /dev/null +++ b/lib/tasks/migrations/usage_thresholds.rake @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +namespace :migrations do + desc "Migrate usage thresholds from child plans to subscriptions or remove duplicates" + task :migrate_usage_thresholds, [:organization_id] => :environment do |_task, args| + organization_id = args[:organization_id] + abort "Missing organization_id argument\n\nUsage: rake migrations:migrate_usage_thresholds[organization_id]" unless organization_id + + organization = Organization.find(organization_id) + + threshold_signature = ->(thresholds) { thresholds.map { |t| [t.amount_cents, t.recurring] }.sort } + + parent_plans = organization.plans.parents + + total_sub_migrated = 0 + + parent_plans.find_each do |parent_plan| + parent_signature = threshold_signature.call(parent_plan.usage_thresholds) + + subscriptions = organization.subscriptions + .joins(:plan) + .where(plans: {parent_id: parent_plan.id}) + .where(status: [:pending, :active]) + .includes(plan: :usage_thresholds) + + puts "#{subscriptions.count} subscriptions to migrate" + puts "\t Parent signature: #{parent_signature.to_json}" + + subscriptions.find_each do |subscription| + child_plan = subscription.plan + child_thresholds = child_plan.usage_thresholds.to_a + + if child_thresholds.empty? + if parent_signature.present? + subscription.update!(progressive_billing_disabled: true) + total_sub_migrated += 1 + end + next + end + + child_signature = threshold_signature.call(child_thresholds) + + if child_signature == parent_signature + child_plan.usage_thresholds.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + else + ActiveRecord::Base.transaction do + if subscription.usage_thresholds.none? + child_thresholds.each do |threshold| + UsageThreshold.create!( + organization:, + subscription:, + amount_cents: threshold.amount_cents, + recurring: threshold.recurring, + threshold_display_name: threshold.threshold_display_name + ) + end + end + + child_plan.usage_thresholds.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + end + + total_sub_migrated += 1 + end + end + + puts + puts "Done. Migrated #{total_sub_migrated} subscription." + end +end diff --git a/lib/tasks/migrations/wallet_traceability.rake b/lib/tasks/migrations/wallet_traceability.rake new file mode 100644 index 0000000..335531d --- /dev/null +++ b/lib/tasks/migrations/wallet_traceability.rake @@ -0,0 +1,524 @@ +# frozen_string_literal: true + +require "csv" +require "parallel" + +class WalletMigration + def self.default_error_log_file + File.join(Dir.tmpdir, "wallet_migration_errors_#{Time.current.strftime("%Y%m%d%H%M%S")}.csv") + end + + def initialize(dry_run: true, limit: nil, batch_size: 1000, error_display_limit: 50, + thread_count: 0, error_log_file: nil, cursor: nil, scope: Wallet.where(traceable: false)) + @scope = scope + @dry_run = dry_run + @limit = limit + @batch_size = limit ? [batch_size, limit].min : batch_size + @error_display_limit = error_display_limit + @thread_count = thread_count + @error_log_file = error_log_file || self.class.default_error_log_file + validate_error_log_file! + parse_cursor(cursor) + end + + def run + puts "Wallet migration — mode: #{@dry_run ? "DRY-RUN (validation only)" : "BACKFILL (writing data)"}" + puts "Customer limit: #{@limit || "all"}, Batch size: #{@batch_size}, Threads: #{@thread_count.zero? ? "sequential" : @thread_count}" + puts "Cursor: #{@cursor_start}" + if @limit + puts "Next cursor: #{@next_cursor_start || "none (all remaining records fit within limit)"}" + end + puts "=" * 60 + + if @dry_run + run_validation + else + run_backfill + end + end + + private + + attr_reader :scope + + def validate_error_log_file! + FileUtils.touch(@error_log_file) + rescue SystemCallError => e + raise "Cannot write to error log file #{@error_log_file}: #{e.message}" + end + + UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i + + def parse_cursor(cursor) + if cursor + raise "Invalid CURSOR format: #{cursor}" unless cursor.match?(UUID_REGEX) + + @cursor_start = cursor + else + @cursor_start = scope.order(:customer_id).pick(:customer_id) + end + + @next_cursor_start = compute_next_cursor + end + + def compute_next_cursor + return unless @limit + + query = scope + query = query.where(Wallet.arel_table[:customer_id].gteq(@cursor_start)) if @cursor_start + query.order(:customer_id).select(:customer_id).distinct + .offset(@limit).limit(1).pick(:customer_id) + end + + # --------------------------------------------------------------------------- + # Dry-run: validate without writing + # --------------------------------------------------------------------------- + + def run_validation + mutex = Mutex.new + total_wallets = 0 + customers_validated = 0 + migratable_wallets = 0 + problematic_wallets = [] + progress_total = progress_count + + iterate_customers_in_batches do |customer_ids| + Parallel.each(customer_ids, in_threads: @thread_count) do |customer_id| + ActiveRecord::Base.connection_pool.with_connection do + wallets = scope.where(customer_id: customer_id).includes(:customer, :organization, :wallet_transactions).to_a + wallets.each do |wallet| + issues = validate_wallet(wallet) + mutex.synchronize do + total_wallets += 1 + if issues.empty? + migratable_wallets += 1 + else + problematic_wallets << build_wallet_error(wallet, issues) + end + end + end + mutex.synchronize { customers_validated += 1 } + end + end + mutex.synchronize { print_progress("Validating", customers_validated, progress_total) } + end + + clear_progress + print_validation_summary(total_wallets, migratable_wallets, problematic_wallets) + end + + def settled_inbound(wallet) + wallet.wallet_transactions.select { |tx| tx.inbound? && tx.settled? }.sort_by(&:created_at) + end + + def settled_outbound(wallet) + wallet.wallet_transactions.select { |tx| tx.outbound? && tx.settled? }.sort_by(&:created_at) + end + + def validate_wallet(wallet) + issues = [] + + # Check wallet-level issues + if wallet.balance_cents < 0 + issues << "Negative wallet balance: #{wallet.balance_cents} cents" + end + + # Check transaction-level issues + validate_transactions(wallet, issues) + + # Simulate FIFO consumption and check for issues + simulation_result = simulate_fifo_consumption(wallet, issues) + + # Check balance drift + drift = wallet.balance_cents - simulation_result[:final_balance] + if drift != 0 + issues << if drift.abs < 100 + "Balance drift < 1 unit: #{drift} cents (wallet: #{wallet.balance_cents}, simulated: #{simulation_result[:final_balance]}) — likely rounding" + else + "Balance drift >= 1 unit: #{drift} cents (wallet: #{wallet.balance_cents}, simulated: #{simulation_result[:final_balance]})" + end + end + + issues + end + + def validate_transactions(wallet, issues) + settled_inbound(wallet).each do |tx| + amount = tx.amount_cents + if amount != amount.to_i + issues << "Decimal amount_cents on inbound #{tx.id}: #{amount} (expected integer)" + end + if amount < 0 + issues << "Negative amount_cents on inbound #{tx.id}: #{amount}" + end + end + + settled_outbound(wallet).each do |tx| + amount = tx.amount_cents + if amount != amount.to_i + issues << "Decimal amount_cents on outbound #{tx.id}: #{amount} (expected integer)" + end + if amount < 0 + issues << "Negative amount_cents on outbound #{tx.id}: #{amount}" + end + end + end + + def simulate_fifo_consumption(wallet, issues) + inbound_txs = settled_inbound(wallet) + outbound_txs = settled_outbound(wallet) + + if outbound_txs.any? && inbound_txs.empty? + issues << "No inbound transactions found but #{outbound_txs.size} outbound exist — missing transaction history" + return {final_balance: 0} + end + + # Pre-sort inbound by consumption priority (stable across all outbound) + sorted_inbound = inbound_txs.map do |tx| + {id: tx.id, remaining: tx.amount_cents, transaction_status: tx.transaction_status, + priority: tx.priority || 0, created_at: tx.created_at} + end.sort_by { |d| [(d[:transaction_status] == "granted") ? 0 : 1, d[:priority], d[:created_at]] } + + # Index for newly eligible inbound (sorted by created_at for eligibility check) + inbound_by_time = inbound_txs.map do |tx| + {id: tx.id, created_at: tx.created_at} + end.sort_by { |d| d[:created_at] } + time_cursor = 0 + eligible_ids = Set.new + + # Remaining balance lookup + sorted_inbound.index_by { |d| d[:id] } + + outbound_txs.each do |outbound| + amount_to_consume = outbound.amount_cents + next if amount_to_consume <= 0 + + # Advance eligibility cursor — inbound created_at <= outbound created_at + while time_cursor < inbound_by_time.size && inbound_by_time[time_cursor][:created_at] <= outbound.created_at + eligible_ids.add(inbound_by_time[time_cursor][:id]) + time_cursor += 1 + end + + available = sorted_inbound.select { |d| eligible_ids.include?(d[:id]) && d[:remaining] > 0 } + + if available.empty? + issues << "Outbound #{outbound.id} (#{outbound.created_at.to_date}): no inbound transactions available — missing transaction history" + next + end + + total_available = available.sum { |d| d[:remaining] } + + available.each do |data| + break if amount_to_consume <= 0 + + consume_amount = [data[:remaining], amount_to_consume].min + data[:remaining] -= consume_amount + amount_to_consume -= consume_amount + end + + if amount_to_consume > 0 + issues << "Outbound #{outbound.id} (#{outbound.created_at.to_date}): insufficient inbound to consume #{outbound.amount_cents} cents " \ + "(available: #{total_available} cents, shortfall: #{amount_to_consume} cents)" + end + end + + final_balance = sorted_inbound.sum { |d| d[:remaining] } + + {final_balance: final_balance} + end + + def print_validation_summary(total_wallets, migratable_wallets, problematic_wallets) + puts "\n" + "=" * 60 + puts "Total wallets: #{total_wallets}" + puts "Migratable: #{migratable_wallets}" + puts "Problematic: #{problematic_wallets.size}" + + if problematic_wallets.any? + puts "\n" + "=" * 60 + puts "PROBLEMATIC WALLETS (first #{@error_display_limit}):" + problematic_wallets.first(@error_display_limit).each do |pw| + puts " Wallet #{pw[:wallet_id]}:" + puts " - Customer: #{pw[:customer_name]} (#{pw[:customer_id]})" + puts " - Org: #{pw[:organization_name]} (#{pw[:organization_id]})" + puts " - Created At: #{pw[:created_at].to_date}" + puts " - Issues:" + pw[:issues].first(3).each { |issue| puts " - #{issue}" } + remaining = pw[:issues].size - 3 + puts " - ... and #{remaining} more issues" if remaining > 0 + end + hidden = problematic_wallets.size - @error_display_limit + puts " ... and #{hidden} more problematic wallets" if hidden > 0 + end + + puts "\n" + "=" * 60 + percentage = (total_wallets > 0) ? (migratable_wallets.to_f / total_wallets * 100).round(2) : 0 + puts "Migration readiness: #{percentage}%" + + if @error_log_file && problematic_wallets.any? + export_csv(problematic_wallets, headers: %w[wallet_id customer_id customer_name organization_id organization_name created_at issues]) do |pw| + [pw[:wallet_id], pw[:customer_id], pw[:customer_name], pw[:organization_id], pw[:organization_name], pw[:created_at].to_date, pw[:issues].join(" | ")] + end + end + end + + # --------------------------------------------------------------------------- + # Backfill: write data + # --------------------------------------------------------------------------- + + def run_backfill + mutex = Mutex.new + customers_processed = 0 + wallets_processed = 0 + errored_wallets = [] + progress_total = progress_count + + iterate_customers_in_batches do |customer_ids| + Parallel.each(customer_ids, in_threads: @thread_count) do |customer_id| + ActiveRecord::Base.connection_pool.with_connection do + customer = Customer.new(id: customer_id).freeze + current_wallet = nil + ApplicationRecord.transaction do + Customers::LockService.call(customer:, scope: :prepaid_credit) do + wallets = scope.where(customer_id: customer_id).includes(:customer, :organization, wallet_transactions: :fundings).to_a + next if wallets.empty? + + wallets.each do |wallet| + current_wallet = wallet + issues = validate_wallet(wallet) + if issues.any? + raise issues.join("; ") + end + + backfill_wallet_transactions(wallet) + end + + Wallet.where(id: wallets.map(&:id)).update_all(traceable: true) # rubocop:disable Rails/SkipsModelValidations + + mutex.synchronize do + wallets_processed += wallets.size + customers_processed += 1 + end + end + rescue => e + mutex.synchronize do + errored_wallets << build_wallet_error(current_wallet, [e.message]) + end + raise ActiveRecord::Rollback + end + end + end + mutex.synchronize { print_progress("Backfilling", customers_processed + errored_wallets.size, progress_total) } + end + + clear_progress + print_backfill_summary(customers_processed, wallets_processed, errored_wallets) + end + + def backfill_wallet_transactions(wallet) + inbound_txs = settled_inbound(wallet) + + # Step 1: Initialize all settled inbound transactions with full amount + inbound_txs.each do |tx| + next if tx.remaining_amount_cents.present? + tx.update_column(:remaining_amount_cents, tx.amount_cents) # rubocop:disable Rails/SkipsModelValidations + end + + # Pre-sort inbound by consumption priority (stable across all outbound) + sorted_inbound = inbound_txs.map do |tx| + {id: tx.id, transaction: tx, remaining: tx.amount_cents, transaction_status: tx.transaction_status, + priority: tx.priority || 0, created_at: tx.created_at} + end.sort_by { |d| [(d[:transaction_status] == "granted") ? 0 : 1, d[:priority], d[:created_at]] } + + # Index for newly eligible inbound (sorted by created_at for eligibility check) + inbound_by_time = inbound_txs.sort_by(&:created_at) + time_cursor = 0 + eligible_ids = Set.new + + # Step 2: Process settled outbound transactions in chronological order + settled_outbound(wallet).each do |outbound| + next if outbound.fundings.any? + + amount_to_consume = outbound.amount_cents + next if amount_to_consume <= 0 + + # Advance eligibility cursor + while time_cursor < inbound_by_time.size && inbound_by_time[time_cursor].created_at <= outbound.created_at + eligible_ids.add(inbound_by_time[time_cursor].id) + time_cursor += 1 + end + + consumption_records = [] + + sorted_inbound.each do |data| + break if amount_to_consume <= 0 + next unless eligible_ids.include?(data[:id]) && data[:remaining] > 0 + + consume_amount = [data[:remaining], amount_to_consume].min + + consumption_records << { + organization_id: wallet.organization_id, + inbound_wallet_transaction_id: data[:id], + outbound_wallet_transaction_id: outbound.id, + consumed_amount_cents: consume_amount, + created_at: outbound.created_at, + updated_at: Time.current + } + + data[:remaining] -= consume_amount + amount_to_consume -= consume_amount + end + + if amount_to_consume > 0 + raise "Wallet #{wallet.id}: Could not fully consume outbound #{outbound.id}, #{amount_to_consume} cents remaining" + end + + WalletTransactionConsumption.insert_all!(consumption_records) if consumption_records.any? # rubocop:disable Rails/SkipsModelValidations + end + + # Step 3: Update remaining_amount_cents based on final state + sorted_inbound.each do |data| + data[:transaction].update_column(:remaining_amount_cents, data[:remaining]) # rubocop:disable Rails/SkipsModelValidations + end + end + + def build_wallet_error(wallet, issues) + { + wallet_id: wallet&.id || "unknown", + customer_id: wallet&.customer_id || "unknown", + customer_name: wallet&.customer&.name || "unknown", + organization_id: wallet&.organization_id || "unknown", + organization_name: wallet&.organization&.name || "unknown", + created_at: wallet&.created_at, + issues: issues + } + end + + def print_backfill_summary(customers_processed, wallets_processed, errored_wallets) + puts "\n" + "=" * 60 + puts "Customers processed: #{customers_processed}" + puts "Wallets processed: #{wallets_processed}" + puts "Errors: #{errored_wallets.size}" + + if errored_wallets.any? + puts "\nErrors (first #{@error_display_limit}):" + errored_wallets.first(@error_display_limit).each do |pw| + puts " Wallet #{pw[:wallet_id]}:" + puts " - Customer: #{pw[:customer_name]} (#{pw[:customer_id]})" + puts " - Org: #{pw[:organization_name]} (#{pw[:organization_id]})" + puts " - Created At: #{pw[:created_at]&.to_date}" + puts " - Issues:" + pw[:issues].first(3).each { |issue| puts " - #{issue}" } + remaining = pw[:issues].size - 3 + puts " - ... and #{remaining} more issues" if remaining > 0 + end + hidden = errored_wallets.size - @error_display_limit + puts " ... and #{hidden} more errored wallets" if hidden > 0 + end + + if @error_log_file && errored_wallets.any? + export_csv(errored_wallets, headers: %w[wallet_id customer_id customer_name organization_id organization_name created_at issues]) do |pw| + [pw[:wallet_id], pw[:customer_id], pw[:customer_name], pw[:organization_id], pw[:organization_name], pw[:created_at]&.to_date, pw[:issues].join(" | ")] + end + end + end + + # --------------------------------------------------------------------------- + # CSV export + # --------------------------------------------------------------------------- + + def export_csv(records, headers:) + CSV.open(@error_log_file, "w") do |csv| + csv << headers + records.each { |record| csv << yield(record) } + end + puts "CSV exported to #{@error_log_file} (#{records.size} records)" + end + + # --------------------------------------------------------------------------- + # Shared helpers + # --------------------------------------------------------------------------- + + def windowed_scope + query = scope + query = query.where(Wallet.arel_table[:customer_id].gteq(@cursor_start)) if @cursor_start + query = query.where(Wallet.arel_table[:customer_id].lt(@next_cursor_start)) if @next_cursor_start + query + end + + def progress_count + windowed_scope.select(:customer_id).distinct.count + end + + # Iterates distinct customer IDs in batches using cursor-based pagination. + # The window is bounded by @cursor_start (inclusive) and @next_cursor_start (exclusive). + def iterate_customers_in_batches + last_customer_id = nil + + loop do + query = windowed_scope + query = query.where(Wallet.arel_table[:customer_id].gt(last_customer_id)) if last_customer_id + customer_ids = query.order(:customer_id).distinct.limit(@batch_size).pluck(:customer_id) + break if customer_ids.empty? + + last_customer_id = customer_ids.last + + yield(customer_ids) + end + end + + def print_progress(label, current, total) + return if total == 0 + + percentage = (current.to_f / total * 100).round(1) + bar_width = 30 + filled = (current.to_f / total * bar_width).round + bar = "#" * filled + "-" * (bar_width - filled) + print "\r#{label}: [#{bar}] #{current}/#{total} (#{percentage}%)" + end + + def clear_progress + print "\r" + " " * 80 + "\r" + end +end + +namespace :migrations do + desc "Migrate wallets to traceable (DRY_RUN=true by default)" + task wallet_traceability: :environment do + Rails.logger.level = :info + + dry_run = ENV.fetch("DRY_RUN", "true") != "false" + include_terminated = ENV["INCLUDE_TERMINATED"] == "true" + scope = Wallet.where(traceable: false) + scope = scope.active unless include_terminated + scope = scope.where(organization_id: ENV["ORGANIZATION_ID"]) if ENV["ORGANIZATION_ID"].present? + + options = {scope:, dry_run:} + options[:limit] = parse_positive_integer("LIMIT") if ENV["LIMIT"].present? + options[:batch_size] = parse_positive_integer("BATCH_SIZE") if ENV["BATCH_SIZE"].present? + options[:error_display_limit] = parse_positive_integer("ERROR_DISPLAY_LIMIT") if ENV["ERROR_DISPLAY_LIMIT"].present? + options[:thread_count] = parse_non_negative_integer("THREAD_COUNT") if ENV["THREAD_COUNT"].present? + options[:error_log_file] = ENV["ERROR_LOG_FILE"] if ENV["ERROR_LOG_FILE"].present? + + options[:cursor] = ENV["CURSOR"] if ENV["CURSOR"].present? + + WalletMigration.new(**options).run + end + + def parse_positive_integer(name) + value = ENV[name] + integer = Integer(value) + raise "#{name} must be a positive integer, got: #{value}" unless integer > 0 + integer + rescue ArgumentError + raise "#{name} must be a positive integer, got: #{value}" + end + + def parse_non_negative_integer(name) + value = ENV[name] + integer = Integer(value) + raise "#{name} must be a non-negative integer, got: #{value}" unless integer >= 0 + integer + rescue ArgumentError + raise "#{name} must be a non-negative integer, got: #{value}" + end +end diff --git a/lib/tasks/organizations.rake b/lib/tasks/organizations.rake new file mode 100644 index 0000000..c8d862f --- /dev/null +++ b/lib/tasks/organizations.rake @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +namespace :organizations do + desc "Think couple times before running this!!! It will delete all the data related to invoices, credit notes and events" + task :delete_invoices_data, [:org_id] => :environment do |_task, args| + organization = Organization.find(args[:org_id]) + organization.invoices.find_each do |invoice| + invoice.credit_notes.find_each do |credit_note| + Credit.where(credit_note_id: credit_note.id).destroy_all + WalletTransaction.where(credit_note_id: credit_note.id).destroy_all + credit_note.destroy + end + invoice.fees.find_each do |fee| + fee.adjusted_fee&.destroy + fee.destroy + end + invoice.invoice_subscriptions.destroy_all + AdjustedFee.where(invoice_id: invoice.id).destroy_all + Credit.where(invoice_id: invoice.id).destroy_all + WalletTransaction.where(invoice_id: invoice.id).destroy_all + invoice.destroy + end + + organization.events.destroy_all + end +end diff --git a/lib/tasks/recipes/events.rake b/lib/tasks/recipes/events.rake new file mode 100644 index 0000000..c7534d1 --- /dev/null +++ b/lib/tasks/recipes/events.rake @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require_relative "../../task_prompt" + +BATCH_SIZE = 2000 + +# rubocop:disable Rails/Output,Rails/Exit +namespace :recipes do + namespace :events do + desc "Soft-delete PG events for an organization within a time range" + task delete_in_range: :environment do + organization = TaskPrompt.ask_for_organization + abort "This task only supports organizations using PostgreSQL events store." unless organization.postgres_events_store? + + from_time, to_time = TaskPrompt.ask_for_timestamp_range + + subscriptions = organization.subscriptions.distinct.pluck(:external_id) + + if subscriptions.empty? + puts "No subscriptions found for this organization." + next + end + + puts "Found #{subscriptions.size} distinct subscriptions." + + puts "\nThis will soft-delete events from \"#{organization.name}\" " \ + "from #{from_time.utc} to #{to_time.utc} (inclusive)." + TaskPrompt.confirm!("Continue? (y/n): ") + + total_deleted = 0 + + # rubocop:disable Rails/SkipsModelValidations + subscriptions.each_with_index do |external_id, index| + events = Event.where( + organization_id: organization.id, + external_subscription_id: external_id + ).from_datetime(from_time).to_datetime(to_time) + + sub_deleted = 0 + prefix = "[#{index + 1}/#{subscriptions.size}]" + + events.in_batches(of: BATCH_SIZE) do |batch| + sub_deleted += batch.update_all(deleted_at: Time.current) + print "\r\e[K#{prefix} Subscription #{external_id}: #{sub_deleted} events deleted. Total: #{total_deleted + sub_deleted}" + end + + if sub_deleted.zero? + print "\r\e[K#{prefix} Subscription #{external_id}: no events, skipped." + end + + total_deleted += sub_deleted + end + # rubocop:enable Rails/SkipsModelValidations + + if total_deleted.zero? + puts "No events found in the given time range. Nothing to delete." + next + end + + puts "\nDone. #{total_deleted} events soft-deleted across #{subscriptions.size} subscriptions." + end + end +end +# rubocop:enable Rails/Output,Rails/Exit diff --git a/lib/tasks/roles.rake b/lib/tasks/roles.rake new file mode 100644 index 0000000..cc74de9 --- /dev/null +++ b/lib/tasks/roles.rake @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +namespace :roles do + desc "Seeds predefined roles (admin, finance, manager) for production deployment" + task seed_predefined: :environment do + Role.find_or_create_by!(code: "admin", organization_id: nil) do |role| + role.admin = true + role.name = "Admin" + role.description = "Administrator having all permissions" + role.permissions = [] + end + + Role.find_or_create_by!(code: "finance", organization_id: nil) do |role| + role.admin = false + role.name = "Finance" + role.description = "Finance role with permissions to manage financial data" + role.permissions = [] + end + + Role.find_or_create_by!(code: "manager", organization_id: nil) do |role| + role.admin = false + role.name = "Manager" + role.description = "The predefined manager role" + role.permissions = [] + end + end +end diff --git a/lib/tasks/signup.rake b/lib/tasks/signup.rake new file mode 100644 index 0000000..5ae7e96 --- /dev/null +++ b/lib/tasks/signup.rake @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +namespace :signup do + desc "This task seeds lago with an organisation & a user for on premise deployment" + task seed_organization: :environment do + if ENV["LAGO_CREATE_ORG"].present? && ENV["LAGO_CREATE_ORG"] == "true" + pp "starting seeding environment" + unless ENV["LAGO_ORG_USER_PASSWORD"].present? && + ENV["LAGO_ORG_USER_EMAIL"].present? && + ENV["LAGO_ORG_NAME"].present? + raise "Couldn't find LAGO_ORG_USER_PASSWORD, LAGO_ORG_USER_EMAIL or LAGO_ORG_NAME in environement variables" + end + + user = User.create_with(password: ENV["LAGO_ORG_USER_PASSWORD"]) + .find_or_create_by!(email: ENV["LAGO_ORG_USER_EMAIL"]) + organization = Organization.find_or_create_by!(name: ENV["LAGO_ORG_NAME"]) + + BillingEntity.find_or_create_by!( + id: organization.id, organization:, name: organization.name, code: organization.name.parameterize + ) + + admin_role = Role.find_or_create_by!(admin: true) + membership = Membership.find_or_create_by!(user:, organization:) + MembershipRole.find_or_create_by!(membership:, organization:, role: admin_role) + + if ENV["LAGO_ORG_API_KEY"].present? + api_key = ApiKey.find_or_create_by!(organization:, value: ENV["LAGO_ORG_API_KEY"]) + api_key.update!(value: ENV["LAGO_ORG_API_KEY"]) + else + ApiKey.find_or_create_by!(organization:) + end + + pp "ending seeding environment" + end + end +end diff --git a/lib/tasks/stripe.rake b/lib/tasks/stripe.rake new file mode 100644 index 0000000..448281b --- /dev/null +++ b/lib/tasks/stripe.rake @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +namespace :stripe do + desc "Refresh stripe webhooks to add or remove an event type" + task refresh_registered_webhooks: :environment do + PaymentProviders::StripeProvider.unscoped.find_each do |stripe_provider| + next unless stripe_provider.secret_key + + PaymentProviders::Stripe::RefreshWebhookJob.perform_later(stripe_provider) + end + end +end diff --git a/lib/tasks/subscriptions.rake b/lib/tasks/subscriptions.rake new file mode 100644 index 0000000..1d091d2 --- /dev/null +++ b/lib/tasks/subscriptions.rake @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +namespace :subscriptions do + desc "Fill missing unique_id" + task fill_unique_id: :environment do + Subscription.includes(:customer).find_each do |subscription| + subscription.update!(unique_id: subscription.customer.customer_id) + end + end + + # NOTE: Ability to create invoices in the future. + # How to use it: bundle exec rake "subscriptions:generate_invoice[timestamp, external_id1, external_id2, ...]" + # ie bundle exec rake "subscriptions:generate_invoice[1675267200, 7ee92df2-0d15-48df-a57b-593c529f50b3]" + desc "Generate invoice for a specific timestamp" + task :generate_invoice, [:timestamp] => :environment do |_task, args| + abort "Missing timestamp and external subscription ids\n\n" unless args[:timestamp] + abort "Missing external subscription ids\n\n" if args.extras.blank? + + subscriptions = Subscription.where(external_id: args.extras) + + abort "External subscription ids not found\n\n" if subscriptions.blank? + abort "Subscriptions don't belong to the same customer\n\n" if subscriptions.pluck(:customer_id).uniq.count > 1 + + result = Invoices::SubscriptionService.call( + subscriptions:, + timestamp: args[:timestamp].to_i, + recurring: false + ) + invoice = result.invoice + + invoice.update!(created_at: Time.zone.at(args[:timestamp].to_i)) + invoice.fees.update_all(created_at: invoice.created_at + 1.second) # rubocop:disable Rails/SkipsModelValidations + + # NOTE: Do not generate the PDF file if invoice is draft. + Invoices::GeneratePdfService.call(invoice:) if invoice.finalized? + end +end diff --git a/lib/tasks/tests/seed_plans.rake b/lib/tasks/tests/seed_plans.rake new file mode 100644 index 0000000..61c4d74 --- /dev/null +++ b/lib/tasks/tests/seed_plans.rake @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "faker" +require "factory_bot_rails" + +namespace :tests do + desc "creates plans with children plans - creates [] of parents plans and 10 children for each plan; decides if charges of children plans will be unlinked from parent charges" + task :seed_plans, [:num_plans, :delete_charge_parents] => :environment do |_task, args| + organization = Organization.find_or_create_by!(name: "Hooli") + all_metrics = find_or_create_metrics(organization) + + delete_charge_parents = args[:delete_charge_parents] == "true" + create_plans = (args[:num_plans] || 10).to_i + create_plans.times do |i| + args = build_plan_args(organization, all_metrics, i) + result = Plans::CreateService.call(args) + plan = result.plan + generate_children_plans(plan, all_metrics, delete_charge_parents) + end + end +end + +def find_or_create_metrics(organization) + metrics_params = [ + {name: "metered_count", agg_type: :count_agg, recurring: false}, + {name: "metered_count_uniq", agg_type: :unique_count_agg, recurring: false}, + {name: "metered_latest", agg_type: :latest_agg, recurring: false}, + {name: "metered_max", agg_type: :max_agg, recurring: false}, + {name: "metered_sum", agg_type: :sum_agg, recurring: false}, + {name: "metered_weighted_sum", agg_type: :weighted_sum_agg, recurring: false}, + {name: "recurring_sum", agg_type: :sum_agg, recurring: true}, + {name: "recurring_weighted_sum", agg_type: :weighted_sum_agg, recurring: true} + ] + metrics_params.map do |params| + organization.billable_metrics.find_or_create_by!(name: params[:name], + code: params[:name], + recurring: params[:recurring], + aggregation_type: params[:agg_type], + field_name: "test", + weighted_interval: "seconds") + end +end + +def build_plan_args(organization, all_metrics, i) + charges_params = [] + charge_models = Charge::CHARGE_MODELS.dup + charge_models.delete(:custom) + rand(2..25).times do + metric = all_metrics.sample + if metric.latest_agg? + charge_models.delete(:graduated_percentage) + charge_models.delete(:percentage) + end + unless metric.sum_agg? + charge_models.delete(:dynamic) + end + charge_model = charge_models.sample + pay_in_advance = (metric.payable_in_advance? && charge_model != :volume) ? [true, false].sample : false + charge_params = { + billable_metric_id: metric.id, + charge_model: charge_model, + pay_in_advance: pay_in_advance, + prorated: can_be_prorated?(charge_model, metric, pay_in_advance) ? [true, false].sample : false + } + charges_params << charge_params + end + + { + organization_id: organization.id, + name: "Plan parent #{i + 1}", + code: "plan_parent_#{i + 1}-#{SecureRandom.hex(5)}", + pay_in_advance: [true, false].sample, + amount_cents: Faker::Number.number(digits: 4), + amount_currency: "USD", + interval: %i[monthly yearly].sample, + trial_period: [0, 10].sample, + charges: charges_params + } +end + +def can_be_prorated?(charge_model, billable_metric, pay_in_advance) + unless billable_metric.weighted_sum_agg? + return true if billable_metric.recurring? && pay_in_advance && charge_model == :standard + return true if billable_metric.recurring? && !pay_in_advance && (charge_model == :standard || charge_model == :volume || charge_model == :graduated) + end + + false +end + +def generate_children_plans(plan, all_metrics, delete_charge_parents) + 5.times do + # change plan, do not change charges + res = Plans::OverrideService.call(plan: plan, params: {name: "Plan '#{plan.code}' child"}) + pl = res.plan + pl.charges.update_all(parent_id: nil) if delete_charge_parents # rubocop:disable Rails/SkipsModelValidations + + # change charges models and properties (randomly) + res = Plans::OverrideService.call(plan: plan, params: {charges: override_charges_rand(plan, all_metrics)}) + pl = res.plan + pl.charges.update_all(parent_id: nil) if delete_charge_parents # rubocop:disable Rails/SkipsModelValidations + end +end + +def override_charges_rand(plan, all_metrics) + plan.charges.map do |charge| + charge_model = charge.charge_model + metric = nil + loop do + metric = all_metrics.sample + if metric.latest_agg? + next if charge_model == :graduated_percentage + next if charge_model == :percentage + end + unless metric.sum_agg? + next if charge_model == :dynamic + end + break + end + + pay_in_advance = (metric.payable_in_advance? && charge_model != :volume) ? [true, false].sample : false + puts "before charge_mode: " + charge.charge_model.to_s + puts "after charge_mode: " + charge_model.to_s + { + id: charge.id, + billable_metric_id: metric.id, + charge_model: charge_model, + pay_in_advance: pay_in_advance, + prorated: can_be_prorated?(charge_model, metric, pay_in_advance) ? [true, false].sample : false, + properties: new_properties_for(charge_model) + } + end +end + +def new_properties_for(charge_model) + case charge_model&.to_sym + when :standard then default_standard_properties + when :graduated then default_graduated_properties + when :package then default_package_properties + when :percentage then default_percentage_properties + when :volume then default_volume_properties + when :graduated_percentage then default_graduated_percentage_properties + when :dynamic then default_dynamic_properties + end +end + +def default_standard_properties + {amount: "10"} +end + +def default_graduated_properties + { + "graduated_ranges" => [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "10", + flat_amount: "5" + } + ] + } +end + +def default_package_properties + { + package_size: 1, + amount: "5", + free_units: 10 + } +end + +def default_percentage_properties + {rate: "20"} +end + +def default_volume_properties + { + "volume_ranges" => [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "20", + flat_amount: "10" + } + ] + } +end + +def default_graduated_percentage_properties + { + "graduated_percentage_ranges" => [ + { + from_value: 0, + to_value: nil, + rate: "20", + fixed_amount: "20", + flat_amount: "20" + } + ] + } +end + +def default_dynamic_properties + {} +end diff --git a/lib/tasks/upgrade_verification.rake b/lib/tasks/upgrade_verification.rake new file mode 100644 index 0000000..2841cbd --- /dev/null +++ b/lib/tasks/upgrade_verification.rake @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require "net/http" +require "yaml" + +namespace :upgrade do + # Note: this task is to be filled with jobs needed to be run before the upgrade + # and is to be changed depending on what is required for the next version. + desc "Performs required jobs that need to be run after the upgrade" + task perform_required_jobs: :environment do + Rails.logger.level = Logger::Severity::ERROR + + resources_to_fill = [ + # TODO: Uncomment when code is required for wallets + # {model: Wallet, job: DatabaseMigrations::PopulateWalletsWithCodeJob}, + ] + + puts "##################################\nStarting required jobs" + puts "\n#### Checking for resource to fill ####" + + to_fill = [] + + resources_to_fill.each do |resource| + model = resource[:model] + pp "- Checking #{model.name}: 🔎" + count = model.where(code: nil).count + + if count > 0 + to_fill << resource + pp " -> #{count} records to fill 🧮" + else + pp " -> Nothing to do ✅" + end + end + + pp "- Checking Subscription#last_received_event_on: 🔎" + backfill_org_ids = Organization + .joins(:subscriptions) + .where(subscriptions: {status: :active, last_received_event_on: nil}) + .distinct + .pluck(:id) + + if backfill_org_ids.any? + pp " -> #{backfill_org_ids.size} organizations to process 🧮" + else + pp " -> Nothing to do ✅" + end + + if to_fill.any? + puts "\n#### Enqueue jobs in the low_priority queue ####" + to_fill.each do |resource| + pp "- Enqueuing #{resource[:job].name}" + resource[:job].perform_later + end + end + + if backfill_org_ids.any? + puts "\n#### Enqueue BackfillLastReceivedEventOnJob per organization ####" + backfill_org_ids.each do |organization_id| + pp "- Enqueuing BackfillLastReceivedEventOnJob for org #{organization_id}" + DatabaseMigrations::BackfillLastReceivedEventOnJob.perform_later(organization_id) + end + end + + while to_fill.present? || backfill_org_ids.any? + sleep 5 + puts "\n#### Checking status ####" + + to_delete = [] + to_fill.each do |resource| + model = resource[:model] + pp "- Checking #{model.name}: 🔎" + count = model.where(code: nil).count + + if count > 0 + pp " -> #{count} remaining 🧮" + else + to_delete << resource + pp " -> Done ✅" + end + end + to_delete.each { to_fill.delete(it) } + + if backfill_org_ids.any? + pp "- Checking BackfillLastReceivedEventOnJob: 🔎" + still_running = backfill_last_received_event_on_jobs_running? + if still_running + pp " -> Jobs still running 🧮" + else + backfill_org_ids = [] + pp " -> Done ✅" + end + end + end + + puts "\n#### All good, ready to Upgrade! ✅ ####" + end + + desc "Verifies the current system's readiness for an upgrade and outlines necessary migration paths" + task verify: [:check_migrations, :check_background_jobs] do + current_version = fetch_current_version + versions_data = load_versions_data + verify_upgrade_path(current_version, versions_data) + end + + desc "Checks if all migrations for the current version have been run and if the system is ready to upgrade" + task check_migrations: :environment do + current_version = fetch_current_version + versions_data = load_versions_data + ready_to_upgrade = check_migrations_status(current_version, versions_data) + unless ready_to_upgrade + puts "System is not ready to upgrade. Please ensure all migrations for the current version have been run." + exit 1 + end + end + + desc "Checks if all jobs on the 'background_migration' queue have been run" + task check_background_jobs: :environment do + unless background_jobs_cleared? + puts "System is not ready to upgrade. There are pending jobs in the 'background_migration' queue." + exit 1 + end + end + + private + + def check_migrations_status(current_version, versions_data) + versions = versions_data["versions"] + current_version_data = versions.find do |version_data| + Gem::Version.new(version_data["version"]) == Gem::Version.new(current_version) + end + + if current_version_data.nil? + puts "Current version #{current_version} not found in versions data." + return true + end + + migrations = current_version_data["migrations"] + if migrations.empty? + puts "No migrations required for current version #{current_version}. System is ready to upgrade." + return true + end + + missing_migrations = migrations.reject { |migration| migration_already_run?(migration) } + + if missing_migrations.empty? + puts "All migrations for version #{current_version} have been run. System is ready to upgrade." + true + else + puts "The following migrations for version #{current_version} have not been run:" + missing_migrations.each { |migration| puts " - #{migration}" } + false + end + end + + def fetch_current_version + if Rails.env.development? + # Load the version from versions.yml file in development + versions = YAML.load_file(Rails.root.join("config/versions.yml")) + Gem::Version.new(versions["versions"].last["version"]) + else + # Use the LAGO_VERSION constant in other environments + Gem::Version.new(LAGO_VERSION.number) + end + end + + def load_versions_data + uri = URI("https://raw.githubusercontent.com/getlago/lago-api/main/config/versions.yml") + response = Net::HTTP.get(uri) + YAML.load(response) + end + + def verify_upgrade_path(current_version, versions_data) + versions = versions_data["versions"] + latest_version = Gem::Version.new(versions.last["version"]) + + if current_version >= latest_version + puts "Your system is already up-to-date with version #{latest_version}." + return + end + + puts "Your current version is #{current_version}. The latest version is #{latest_version}." + + migration_path = [] + + versions.each do |version_data| + version = Gem::Version.new(version_data["version"]) + next if version <= current_version + + migrations = version_data["migrations"] + unless migrations.empty? + migration_path << {version: version, migrations: migrations} + end + end + + if migration_path.empty? + puts "You can upgrade to the latest version #{latest_version}." + else + puts "You need to upgrade. Here is the migration path:" + migration_path.each do |upgrade| + puts "To upgrade to version #{upgrade[:version]}, you need to run the following migrations:" + upgrade[:migrations].each do |migration| + puts " - #{migration}" + end + end + end + end + + def migration_already_run?(migration) + ActiveRecord::Base.connection.table_exists?("schema_migrations") && + ActiveRecord::Base.connection.select_values("SELECT version FROM schema_migrations").include?(migration.to_s) + end + + def background_jobs_cleared? + queue = Sidekiq::Queue.new("background_migration") + queue.size == 0 + end + + def backfill_last_received_event_on_jobs_running? + job_class = "DatabaseMigrations::BackfillLastReceivedEventOnJob" + + queued = Sidekiq::Queue.new("low_priority").any? { |job| job.klass == job_class } + return true if queued + + Sidekiq::Workers.new.any? do |_process_id, _thread_id, work| + work.dig("payload", "class") == job_class + end + end +end diff --git a/lib/templates/active_record/migration/migration.rb.tt b/lib/templates/active_record/migration/migration.rb.tt new file mode 100644 index 0000000..baf9c81 --- /dev/null +++ b/lib/templates/active_record/migration/migration.rb.tt @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class <%= migration_class_name %> < ActiveRecord::Migration[<%= ActiveRecord::Migration.current_version %>] + def change + end +end diff --git a/log/.keep b/log/.keep new file mode 100644 index 0000000..e69de29 diff --git a/public/assets/images/lago-logo-email.png b/public/assets/images/lago-logo-email.png new file mode 100644 index 0000000..7444a02 Binary files /dev/null and b/public/assets/images/lago-logo-email.png differ diff --git a/public/assets/images/lago-logo-invoice.png b/public/assets/images/lago-logo-invoice.png new file mode 100644 index 0000000..664ede7 Binary files /dev/null and b/public/assets/images/lago-logo-invoice.png differ diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..c19f78a --- /dev/null +++ b/public/robots.txt @@ -0,0 +1 @@ +# See https://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file diff --git a/schema.graphql b/schema.graphql new file mode 100644 index 0000000..7882f79 --- /dev/null +++ b/schema.graphql @@ -0,0 +1,13710 @@ +schema { + query: Query + mutation: Mutation + subscription: GraphqlSubscription +} + +""" +Autogenerated input type of AcceptInvite +""" +input AcceptInviteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String! + password: String! + + """ + Uniq token of the Invite + """ + token: String! +} + +enum ActivationRuleStatusEnum { + declined + expired + failed + inactive + not_applicable + pending + satisfied +} + +enum ActivationRuleTypeEnum { + payment +} + +""" +Base activity log +""" +type ActivityLog { + activityId: ID! + activityObject: JSON + activityObjectChanges: JSON + activitySource: ActivitySourceEnum! + activityType: ActivityTypeEnum! + apiKey: SanitizedApiKey + createdAt: ISO8601DateTime! + externalCustomerId: String + externalSubscriptionId: String + loggedAt: ISO8601DateTime! + organization: Organization + resource: ActivityLogResourceObject + userEmail: String +} + +""" +ActivityLogCollection type +""" +type ActivityLogCollection { + """ + A collection of paginated ActivityLogCollection + """ + collection: [ActivityLog!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Activity log resource +""" +union ActivityLogResourceObject = BillableMetric | BillingEntity | Coupon | CreditNote | Customer | FeatureObject | Invoice | PaymentReceipt | PaymentRequest | Plan | Subscription | Wallet + +""" +Activity Logs source enums +""" +enum ActivitySourceEnum { + api + front + system +} + +""" +Activity Logs type enums +""" +enum ActivityTypeEnum { + """ + applied_coupon.created + """ + applied_coupon_created + + """ + applied_coupon.deleted + """ + applied_coupon_deleted + + """ + billable_metric.created + """ + billable_metric_created + + """ + billable_metric.deleted + """ + billable_metric_deleted + + """ + billable_metric.updated + """ + billable_metric_updated + + """ + billing_entities.created + """ + billing_entities_created + + """ + billing_entities.deleted + """ + billing_entities_deleted + + """ + billing_entities.updated + """ + billing_entities_updated + + """ + coupon.created + """ + coupon_created + + """ + coupon.deleted + """ + coupon_deleted + + """ + coupon.updated + """ + coupon_updated + + """ + credit_note.created + """ + credit_note_created + + """ + credit_note.generated + """ + credit_note_generated + + """ + credit_note.refund_failure + """ + credit_note_refund_failure + + """ + customer.created + """ + customer_created + + """ + customer.deleted + """ + customer_deleted + + """ + customer.updated + """ + customer_updated + + """ + email.sent + """ + email_sent + + """ + feature.created + """ + feature_created + + """ + feature.deleted + """ + feature_deleted + + """ + feature.updated + """ + feature_updated + + """ + invoice.created + """ + invoice_created + + """ + invoice.drafted + """ + invoice_drafted + + """ + invoice.failed + """ + invoice_failed + + """ + invoice.generated + """ + invoice_generated + + """ + invoice.one_off_created + """ + invoice_one_off_created + + """ + invoice.paid_credit_added + """ + invoice_paid_credit_added + + """ + invoice.payment_failure + """ + invoice_payment_failure + + """ + invoice.payment_overdue + """ + invoice_payment_overdue + + """ + invoice.payment_status_updated + """ + invoice_payment_status_updated + + """ + invoice.regenerated + """ + invoice_regenerated + + """ + invoice.voided + """ + invoice_voided + + """ + payment_receipt.created + """ + payment_receipt_created + + """ + payment_receipt.generated + """ + payment_receipt_generated + + """ + payment.recorded + """ + payment_recorded + + """ + payment_request.created + """ + payment_request_created + + """ + plan.created + """ + plan_created + + """ + plan.deleted + """ + plan_deleted + + """ + plan.updated + """ + plan_updated + + """ + subscription.canceled + """ + subscription_canceled + + """ + subscription.incomplete + """ + subscription_incomplete + + """ + subscription.started + """ + subscription_started + + """ + subscription.terminated + """ + subscription_terminated + + """ + subscription.updated + """ + subscription_updated + + """ + wallet.created + """ + wallet_created + + """ + wallet_transaction.created + """ + wallet_transaction_created + + """ + wallet_transaction.payment_failure + """ + wallet_transaction_payment_failure + + """ + wallet_transaction.updated + """ + wallet_transaction_updated + + """ + wallet.updated + """ + wallet_updated +} + +""" +Adyen input arguments +""" +input AddAdyenPaymentProviderInput { + apiKey: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + hmacKey: String + livePrefix: String + merchantAccount: String! + name: String! + successRedirectUrl: String +} + +""" +Cashfree input arguments +""" +input AddCashfreePaymentProviderInput { + clientId: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + clientSecret: String! + code: String! + name: String! + successRedirectUrl: String +} + +""" +Flutterwave input arguments +""" +input AddFlutterwavePaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + name: String! + secretKey: String! + successRedirectUrl: String +} + +""" +Gocardless input arguments +""" +input AddGocardlessPaymentProviderInput { + accessCode: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + name: String! + successRedirectUrl: String +} + +""" +Moneyhash input arguments +""" +input AddMoneyhashPaymentProviderInput { + apiKey: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + flowId: String! + name: String! + successRedirectUrl: String +} + +type AddOn { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + appliedAddOnsCount: Int! + code: String! + createdAt: ISO8601DateTime! + + """ + Number of customers using this add-on + """ + customersCount: Int! + deletedAt: ISO8601DateTime + description: String + id: ID! + integrationMappings(integrationId: ID): [Mapping!] + invoiceDisplayName: String + name: String! + organization: Organization + taxes: [Tax!] + updatedAt: ISO8601DateTime! +} + +""" +AddOnCollection type +""" +type AddOnCollection { + """ + A collection of paginated AddOnCollection + """ + collection: [AddOn!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Stripe input arguments +""" +input AddStripePaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + name: String! + secretKey: String + successRedirectUrl: String + supports3ds: Boolean +} + +enum AdjustedFeeTypeEnum { + adjusted_amount + adjusted_units +} + +type AdyenProvider { + apiKey: ObfuscatedString + code: String! + hmacKey: ObfuscatedString + id: ID! + livePrefix: String + merchantAccount: String + name: String! + successRedirectUrl: String +} + +enum AggregationTypeEnum { + count_agg + custom_agg + latest_agg + max_agg + sum_agg + unique_count_agg + weighted_sum_agg +} + +type AiConversation { + createdAt: ISO8601DateTime! + id: ID! + mistralConversationId: String + name: String! + organization: Organization! + updatedAt: ISO8601DateTime! +} + +""" +AiConversationCollection type +""" +type AiConversationCollection { + """ + A collection of paginated AiConversationCollection + """ + collection: [AiConversation!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type AiConversationMessage { + content: String! + createdAt: ISO8601DateTime! + type: String! +} + +type AiConversationStream { + chunk: String + done: Boolean! +} + +type AiConversationWithMessages { + createdAt: ISO8601DateTime! + id: ID! + + """ + Messages belonging to this conversation + """ + messages: [AiConversationMessage!]! + mistralConversationId: String + name: String! + organization: Organization! + updatedAt: ISO8601DateTime! +} + +type Alert { + alertType: AlertTypeEnum! + billableMetric: BillableMetric + billableMetricId: ID + code: String! + createdAt: ISO8601DateTime! + deletedAt: ISO8601DateTime + direction: DirectionEnum! + id: ID! + name: String + subscriptionExternalId: String + thresholds: [AlertThreshold!] + updatedAt: ISO8601DateTime! + walletId: String +} + +""" +AlertCollection type +""" +type AlertCollection { + """ + A collection of paginated AlertCollection + """ + collection: [Alert!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type AlertThreshold { + code: String + recurring: Boolean! + value: String! +} + +enum AlertTypeEnum { + billable_metric_current_usage_amount + billable_metric_current_usage_units + billable_metric_lifetime_usage_units + current_usage_amount + lifetime_usage_amount + wallet_balance_amount + wallet_credits_balance + wallet_credits_ongoing_balance + wallet_ongoing_balance_amount +} + +type AnrokCustomer { + externalAccountId: String + externalCustomerId: String + id: ID! + integrationCode: String + integrationId: ID + integrationType: IntegrationTypeEnum + syncWithProvider: Boolean +} + +type AnrokIntegration { + apiKey: ObfuscatedString! + code: String! + externalAccountId: String + failedInvoicesCount: Int + hasMappingsConfigured: Boolean + id: ID! + name: String! +} + +type ApiKey { + createdAt: ISO8601DateTime! + expiresAt: ISO8601DateTime + id: ID! + lastUsedAt: ISO8601DateTime + name: String + permissions: JSON! + value: String! +} + +""" +Base api log +""" +type ApiLog { + apiKey: SanitizedApiKey + apiVersion: String + client: String + createdAt: ISO8601DateTime! + httpMethod: HttpMethodEnum! + httpStatus: Int! + loggedAt: ISO8601DateTime! + requestBody: JSON + requestId: ID! + requestOrigin: String + requestPath: String + requestResponse: JSON! +} + +""" +ApiLogCollection type +""" +type ApiLogCollection { + """ + A collection of paginated ApiLogCollection + """ + collection: [ApiLog!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type AppliedAddOn { + addOn: AddOn! + amountCents: BigInt! + amountCurrency: CurrencyEnum! + createdAt: ISO8601DateTime! + id: ID! +} + +type AppliedCoupon { + amountCents: BigInt + amountCentsRemaining: BigInt + amountCurrency: CurrencyEnum + coupon: Coupon! + createdAt: ISO8601DateTime! + customer: Customer! + frequency: CouponFrequency! + frequencyDuration: Int + frequencyDurationRemaining: Int + id: ID! + percentageRate: Float + status: AppliedCouponStatusEnum! + terminatedAt: ISO8601DateTime +} + +""" +AppliedCouponCollection type +""" +type AppliedCouponCollection { + """ + A collection of paginated AppliedCouponCollection + """ + collection: [AppliedCoupon!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +enum AppliedCouponStatusEnum { + active + terminated +} + +type AppliedPricingUnit { + conversionRate: Float! + createdAt: ISO8601DateTime! + id: ID! + pricingUnit: PricingUnit! + updatedAt: ISO8601DateTime! +} + +input AppliedPricingUnitInput { + code: String! + conversionRate: Float! +} + +input AppliedPricingUnitOverrideInput { + conversionRate: Float! +} + +interface AppliedTax { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + createdAt: ISO8601DateTime! + id: ID! + tax: Tax + taxCode: String! + taxDescription: String + taxName: String! + taxRate: Float! + updatedAt: ISO8601DateTime! +} + +input AppliesToInput { + billableMetricIds: [ID!] + feeTypes: [FeeTypesEnum!] +} + +""" +Autogenerated input type of ApplyTaxes +""" +input ApplyTaxesInput { + billingEntityId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + taxCodes: [String!]! +} + +""" +Autogenerated return type of ApplyTaxes. +""" +type ApplyTaxesPayload { + appliedTaxes: [Tax!]! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String +} + +""" +Autogenerated input type of ApproveQuoteVersion +""" +input ApproveQuoteVersionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +type AuthUrl { + url: String! +} + +""" +Organization Authentication Methods Values +""" +enum AuthenticationMethodsEnum { + email_password + google_oauth + okta +} + +type Authorize { + url: String! +} + +type AvalaraCustomer { + externalCustomerId: String + id: ID! + integrationCode: String + integrationId: ID + integrationType: IntegrationTypeEnum + syncWithProvider: Boolean +} + +type AvalaraIntegration { + accountId: String + code: String! + companyCode: String! + companyId: String + failedInvoicesCount: Int + hasMappingsConfigured: Boolean + id: ID! + licenseKey: ObfuscatedString! + name: String! +} + +""" +Represents non-fractional signed whole numeric values. Since the value may +exceed the size of a 32-bit integer, it's encoded as a string. +""" +scalar BigInt + +""" +Base billable metric +""" +type BillableMetric { + activityLogs: [ActivityLog!] + aggregationType: AggregationTypeEnum! + code: String! + createdAt: ISO8601DateTime! + deletedAt: ISO8601DateTime + description: String + expression: String + fieldName: String + filters: [BillableMetricFilter!] + hasActiveSubscriptions: Boolean! + hasDraftInvoices: Boolean! + hasPlans: Boolean! + hasSubscriptions: Boolean! + id: ID! + integrationMappings(integrationId: ID): [Mapping!] + name: String! + organization: Organization + recurring: Boolean! + roundingFunction: RoundingFunctionEnum + roundingPrecision: Int + updatedAt: ISO8601DateTime! + weightedInterval: WeightedIntervalEnum +} + +""" +BillableMetricCollection type +""" +type BillableMetricCollection { + """ + A collection of paginated BillableMetricCollection + """ + collection: [BillableMetric!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Billable metric filters +""" +type BillableMetricFilter { + id: ID! + key: String! + values: [String!]! +} + +""" +Billable metric filters input arguments +""" +input BillableMetricFiltersInput { + key: String! + values: [String!]! +} + +""" +Base billing entity +""" +type BillingEntity { + addressLine1: String + addressLine2: String + appliedDunningCampaign: DunningCampaign + billingConfiguration: BillingEntityBillingConfiguration + city: String + code: String! + country: CountryCode + createdAt: ISO8601DateTime! + defaultCurrency: CurrencyEnum! + documentNumberPrefix: String! + documentNumbering: BillingEntityDocumentNumberingEnum! + einvoicing: Boolean! + email: String + emailSettings: [BillingEntityEmailSettingsEnum!] + euTaxManagement: Boolean! + finalizeZeroAmountInvoice: Boolean! + id: ID! + isDefault: Boolean! + legalName: String + legalNumber: String + logoUrl: String + name: String! + netPaymentTerm: Int! + organization: Organization! + selectedInvoiceCustomSections: [InvoiceCustomSection!] + state: String + taxIdentificationNumber: String + timezone: TimezoneEnum + updatedAt: ISO8601DateTime! + zipcode: String +} + +type BillingEntityBillingConfiguration { + documentLocale: String + id: ID! + invoiceFooter: String + invoiceGracePeriod: Int! + subscriptionInvoiceIssuingDateAdjustment: BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum! + subscriptionInvoiceIssuingDateAnchor: BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum! +} + +input BillingEntityBillingConfigurationInput { + documentLocale: String + documentNumbering: BillingEntityDocumentNumberingEnum + invoiceFooter: String + invoiceGracePeriod: Int + subscriptionInvoiceIssuingDateAdjustment: BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum + subscriptionInvoiceIssuingDateAnchor: BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum +} + +""" +BillingEntityCollection type +""" +type BillingEntityCollection { + """ + A collection of paginated BillingEntityCollection + """ + collection: [BillingEntity!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Document numbering type +""" +enum BillingEntityDocumentNumberingEnum { + per_billing_entity + per_customer +} + +""" +BillingEntity Email Settings Values +""" +enum BillingEntityEmailSettingsEnum { + """ + credit_note.created + """ + credit_note_created + + """ + invoice.finalized + """ + invoice_finalized + + """ + payment_receipt.created + """ + payment_receipt_created +} + +""" +Subscription Invoice Issuing Date Adjustment Values +""" +enum BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum { + align_with_finalization_date + keep_anchor +} + +""" +Subscription Invoice Issuing Date Anchor Values +""" +enum BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum { + current_period_end + next_period_start +} + +""" +Autogenerated input type of BillingEntityUpdateAppliedDunningCampaign +""" +input BillingEntityUpdateAppliedDunningCampaignInput { + appliedDunningCampaignId: String + billingEntityId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String +} + +enum BillingTimeEnum { + anniversary + calendar +} + +enum CancelationReasonEnum { + payment_failed + timeout +} + +type CashfreeProvider { + clientId: String + clientSecret: String + code: String! + id: ID! + name: String! + successRedirectUrl: String +} + +type Charge { + appliedPricingUnit: AppliedPricingUnit + billableMetric: BillableMetric! + chargeModel: ChargeModelEnum! + code: String + createdAt: ISO8601DateTime! + deletedAt: ISO8601DateTime + filters: [ChargeFilter!] + id: ID! + invoiceDisplayName: String + invoiceable: Boolean! + minAmountCents: BigInt! + parentId: ID + payInAdvance: Boolean! + properties: Properties + prorated: Boolean! + regroupPaidFees: RegroupPaidFeesEnum + taxes: [Tax!] + updatedAt: ISO8601DateTime! +} + +""" +Autogenerated input type of CreateCharge +""" +input ChargeCreateInput { + appliedPricingUnit: AppliedPricingUnitInput + billableMetricId: ID! + cascadeUpdates: Boolean + chargeModel: ChargeModelEnum! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + filters: [ChargeFilterInput!] + invoiceDisplayName: String + invoiceable: Boolean + minAmountCents: BigInt + payInAdvance: Boolean + planId: ID! + properties: PropertiesInput + prorated: Boolean + regroupPaidFees: RegroupPaidFeesEnum + taxCodes: [String!] +} + +""" +Charge filters object +""" +type ChargeFilter { + chargeCode: String + id: ID! + invoiceDisplayName: String + properties: Properties! + values: ChargeFilterValues! +} + +""" +Charge filter create input arguments +""" +input ChargeFilterCreateInput { + cascadeUpdates: Boolean + chargeId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceDisplayName: String + properties: PropertiesInput! + values: ChargeFilterValues! +} + +""" +Charge filters input arguments +""" +input ChargeFilterInput { + invoiceDisplayName: String + properties: PropertiesInput! + values: ChargeFilterValues! +} + +""" +Charge filter update input arguments +""" +input ChargeFilterUpdateInput { + cascadeUpdates: Boolean + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + invoiceDisplayName: String + properties: PropertiesInput +} + +type ChargeFilterUsage { + amountCents: BigInt! + eventsCount: Int! + id: ID + invoiceDisplayName: String + pricingUnitAmountCents: BigInt + units: Float! + values: ChargeFilterValues! +} + +scalar ChargeFilterValues + +input ChargeInput { + appliedPricingUnit: AppliedPricingUnitInput + billableMetricId: ID! + chargeModel: ChargeModelEnum! + filters: [ChargeFilterInput!] + id: ID + invoiceDisplayName: String + invoiceable: Boolean + minAmountCents: BigInt + payInAdvance: Boolean + properties: PropertiesInput + prorated: Boolean + regroupPaidFees: RegroupPaidFeesEnum + taxCodes: [String!] +} + +enum ChargeModelEnum { + custom + dynamic + graduated + graduated_percentage + package + percentage + standard + volume +} + +input ChargeOverridesInput { + appliedPricingUnit: AppliedPricingUnitOverrideInput + billableMetricId: ID! + filters: [ChargeFilterInput!] + id: ID + invoiceDisplayName: String + minAmountCents: BigInt + properties: PropertiesInput + taxCodes: [String!] +} + +""" +Autogenerated input type of UpdateCharge +""" +input ChargeUpdateInput { + appliedPricingUnit: AppliedPricingUnitInput + cascadeUpdates: Boolean + chargeModel: ChargeModelEnum + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + filters: [ChargeFilterInput!] + id: ID! + invoiceDisplayName: String + invoiceable: Boolean + minAmountCents: BigInt + payInAdvance: Boolean + properties: PropertiesInput + prorated: Boolean + regroupPaidFees: RegroupPaidFeesEnum + taxCodes: [String!] +} + +type ChargeUsage { + amountCents: BigInt! + billableMetric: BillableMetric! + charge: Charge! + eventsCount: Int! + filters: [ChargeFilterUsage!] + groupedUsage: [GroupedChargeUsage!]! + id: ID! + presentationBreakdowns: [PresentationBreakdownUsage!] + pricingUnitAmountCents: BigInt + units: Float! +} + +""" +Autogenerated input type of CloneQuoteVersion +""" +input CloneQuoteVersionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +type CollectionMapping { + billingEntityId: ID + currencies: [CurrencyMappingItem!] + externalAccountCode: String + externalId: String + externalName: String + id: ID! + integrationId: ID! + mappingType: MappingTypeEnum! + taxCode: String + taxNexus: String + taxType: String +} + +""" +CollectionMappingCollection type +""" +type CollectionMappingCollection { + """ + A collection of paginated CollectionMappingCollection + """ + collection: [CollectionMapping!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Type for CollectionMetadataType +""" +type CollectionMetadata { + """ + Current Page of loaded data + """ + currentPage: Int! + + """ + The number of items per page + """ + limitValue: Int! + + """ + The total number of items to be paginated + """ + totalCount: Int! + + """ + The total number of pages in the pagination + """ + totalPages: Int! +} + +type Commitment { + amountCents: BigInt! + commitmentType: CommitmentTypeEnum! + createdAt: ISO8601DateTime! + id: ID! + invoiceDisplayName: String + plan: Plan! + taxes: [Tax!] + updatedAt: ISO8601DateTime! +} + +input CommitmentInput { + amountCents: BigInt + commitmentType: CommitmentTypeEnum + id: ID + invoiceDisplayName: String + taxCodes: [String!] +} + +enum CommitmentTypeEnum { + minimum_commitment +} + +enum CountryCode { + """ + Andorra + """ + AD + + """ + United Arab Emirates + """ + AE + + """ + Afghanistan + """ + AF + + """ + Antigua and Barbuda + """ + AG + + """ + Anguilla + """ + AI + + """ + Albania + """ + AL + + """ + Armenia + """ + AM + + """ + Angola + """ + AO + + """ + Antarctica + """ + AQ + + """ + Argentina + """ + AR + + """ + American Samoa + """ + AS + + """ + Austria + """ + AT + + """ + Australia + """ + AU + + """ + Aruba + """ + AW + + """ + Åland Islands + """ + AX + + """ + Azerbaijan + """ + AZ + + """ + Bosnia and Herzegovina + """ + BA + + """ + Barbados + """ + BB + + """ + Bangladesh + """ + BD + + """ + Belgium + """ + BE + + """ + Burkina Faso + """ + BF + + """ + Bulgaria + """ + BG + + """ + Bahrain + """ + BH + + """ + Burundi + """ + BI + + """ + Benin + """ + BJ + + """ + Saint Barthélemy + """ + BL + + """ + Bermuda + """ + BM + + """ + Brunei Darussalam + """ + BN + + """ + Bolivia (Plurinational State of) + """ + BO + + """ + Bonaire, Sint Eustatius and Saba + """ + BQ + + """ + Brazil + """ + BR + + """ + Bahamas + """ + BS + + """ + Bhutan + """ + BT + + """ + Bouvet Island + """ + BV + + """ + Botswana + """ + BW + + """ + Belarus + """ + BY + + """ + Belize + """ + BZ + + """ + Canada + """ + CA + + """ + Cocos (Keeling) Islands + """ + CC + + """ + Congo (Democratic Republic of the) + """ + CD + + """ + Central African Republic + """ + CF + + """ + Congo + """ + CG + + """ + Switzerland + """ + CH + + """ + Côte d'Ivoire + """ + CI + + """ + Cook Islands + """ + CK + + """ + Chile + """ + CL + + """ + Cameroon + """ + CM + + """ + China + """ + CN + + """ + Colombia + """ + CO + + """ + Costa Rica + """ + CR + + """ + Cuba + """ + CU + + """ + Cabo Verde + """ + CV + + """ + Curaçao + """ + CW + + """ + Christmas Island + """ + CX + + """ + Cyprus + """ + CY + + """ + Czechia + """ + CZ + + """ + Germany + """ + DE + + """ + Djibouti + """ + DJ + + """ + Denmark + """ + DK + + """ + Dominica + """ + DM + + """ + Dominican Republic + """ + DO + + """ + Algeria + """ + DZ + + """ + Ecuador + """ + EC + + """ + Estonia + """ + EE + + """ + Egypt + """ + EG + + """ + Western Sahara + """ + EH + + """ + Eritrea + """ + ER + + """ + Spain + """ + ES + + """ + Ethiopia + """ + ET + + """ + Finland + """ + FI + + """ + Fiji + """ + FJ + + """ + Falkland Islands (Malvinas) + """ + FK + + """ + Micronesia (Federated States of) + """ + FM + + """ + Faroe Islands + """ + FO + + """ + France + """ + FR + + """ + Gabon + """ + GA + + """ + United Kingdom of Great Britain and Northern Ireland + """ + GB + + """ + Grenada + """ + GD + + """ + Georgia + """ + GE + + """ + French Guiana + """ + GF + + """ + Guernsey + """ + GG + + """ + Ghana + """ + GH + + """ + Gibraltar + """ + GI + + """ + Greenland + """ + GL + + """ + Gambia + """ + GM + + """ + Guinea + """ + GN + + """ + Guadeloupe + """ + GP + + """ + Equatorial Guinea + """ + GQ + + """ + Greece + """ + GR + + """ + South Georgia and the South Sandwich Islands + """ + GS + + """ + Guatemala + """ + GT + + """ + Guam + """ + GU + + """ + Guinea-Bissau + """ + GW + + """ + Guyana + """ + GY + + """ + Hong Kong + """ + HK + + """ + Heard Island and McDonald Islands + """ + HM + + """ + Honduras + """ + HN + + """ + Croatia + """ + HR + + """ + Haiti + """ + HT + + """ + Hungary + """ + HU + + """ + Indonesia + """ + ID + + """ + Ireland + """ + IE + + """ + Israel + """ + IL + + """ + Isle of Man + """ + IM + + """ + India + """ + IN + + """ + British Indian Ocean Territory + """ + IO + + """ + Iraq + """ + IQ + + """ + Iran (Islamic Republic of) + """ + IR + + """ + Iceland + """ + IS + + """ + Italy + """ + IT + + """ + Jersey + """ + JE + + """ + Jamaica + """ + JM + + """ + Jordan + """ + JO + + """ + Japan + """ + JP + + """ + Kenya + """ + KE + + """ + Kyrgyzstan + """ + KG + + """ + Cambodia + """ + KH + + """ + Kiribati + """ + KI + + """ + Comoros + """ + KM + + """ + Saint Kitts and Nevis + """ + KN + + """ + Korea (Democratic People's Republic of) + """ + KP + + """ + Korea (Republic of) + """ + KR + + """ + Kuwait + """ + KW + + """ + Cayman Islands + """ + KY + + """ + Kazakhstan + """ + KZ + + """ + Lao People's Democratic Republic + """ + LA + + """ + Lebanon + """ + LB + + """ + Saint Lucia + """ + LC + + """ + Liechtenstein + """ + LI + + """ + Sri Lanka + """ + LK + + """ + Liberia + """ + LR + + """ + Lesotho + """ + LS + + """ + Lithuania + """ + LT + + """ + Luxembourg + """ + LU + + """ + Latvia + """ + LV + + """ + Libya + """ + LY + + """ + Morocco + """ + MA + + """ + Monaco + """ + MC + + """ + Moldova (Republic of) + """ + MD + + """ + Montenegro + """ + ME + + """ + Saint Martin (French part) + """ + MF + + """ + Madagascar + """ + MG + + """ + Marshall Islands + """ + MH + + """ + North Macedonia + """ + MK + + """ + Mali + """ + ML + + """ + Myanmar + """ + MM + + """ + Mongolia + """ + MN + + """ + Macao + """ + MO + + """ + Northern Mariana Islands + """ + MP + + """ + Martinique + """ + MQ + + """ + Mauritania + """ + MR + + """ + Montserrat + """ + MS + + """ + Malta + """ + MT + + """ + Mauritius + """ + MU + + """ + Maldives + """ + MV + + """ + Malawi + """ + MW + + """ + Mexico + """ + MX + + """ + Malaysia + """ + MY + + """ + Mozambique + """ + MZ + + """ + Namibia + """ + NA + + """ + New Caledonia + """ + NC + + """ + Niger + """ + NE + + """ + Norfolk Island + """ + NF + + """ + Nigeria + """ + NG + + """ + Nicaragua + """ + NI + + """ + Netherlands + """ + NL + + """ + Norway + """ + NO + + """ + Nepal + """ + NP + + """ + Nauru + """ + NR + + """ + Niue + """ + NU + + """ + New Zealand + """ + NZ + + """ + Oman + """ + OM + + """ + Panama + """ + PA + + """ + Peru + """ + PE + + """ + French Polynesia + """ + PF + + """ + Papua New Guinea + """ + PG + + """ + Philippines + """ + PH + + """ + Pakistan + """ + PK + + """ + Poland + """ + PL + + """ + Saint Pierre and Miquelon + """ + PM + + """ + Pitcairn + """ + PN + + """ + Puerto Rico + """ + PR + + """ + Palestine, State of + """ + PS + + """ + Portugal + """ + PT + + """ + Palau + """ + PW + + """ + Paraguay + """ + PY + + """ + Qatar + """ + QA + + """ + Réunion + """ + RE + + """ + Romania + """ + RO + + """ + Serbia + """ + RS + + """ + Russian Federation + """ + RU + + """ + Rwanda + """ + RW + + """ + Saudi Arabia + """ + SA + + """ + Solomon Islands + """ + SB + + """ + Seychelles + """ + SC + + """ + Sudan + """ + SD + + """ + Sweden + """ + SE + + """ + Singapore + """ + SG + + """ + Saint Helena, Ascension and Tristan da Cunha + """ + SH + + """ + Slovenia + """ + SI + + """ + Svalbard and Jan Mayen + """ + SJ + + """ + Slovakia + """ + SK + + """ + Sierra Leone + """ + SL + + """ + San Marino + """ + SM + + """ + Senegal + """ + SN + + """ + Somalia + """ + SO + + """ + Suriname + """ + SR + + """ + South Sudan + """ + SS + + """ + Sao Tome and Principe + """ + ST + + """ + El Salvador + """ + SV + + """ + Sint Maarten (Dutch part) + """ + SX + + """ + Syrian Arab Republic + """ + SY + + """ + Eswatini + """ + SZ + + """ + Turks and Caicos Islands + """ + TC + + """ + Chad + """ + TD + + """ + French Southern Territories + """ + TF + + """ + Togo + """ + TG + + """ + Thailand + """ + TH + + """ + Tajikistan + """ + TJ + + """ + Tokelau + """ + TK + + """ + Timor-Leste + """ + TL + + """ + Turkmenistan + """ + TM + + """ + Tunisia + """ + TN + + """ + Tonga + """ + TO + + """ + Türkiye + """ + TR + + """ + Trinidad and Tobago + """ + TT + + """ + Tuvalu + """ + TV + + """ + Taiwan, Province of China + """ + TW + + """ + Tanzania, United Republic of + """ + TZ + + """ + Ukraine + """ + UA + + """ + Uganda + """ + UG + + """ + United States Minor Outlying Islands + """ + UM + + """ + United States of America + """ + US + + """ + Uruguay + """ + UY + + """ + Uzbekistan + """ + UZ + + """ + Holy See + """ + VA + + """ + Saint Vincent and the Grenadines + """ + VC + + """ + Venezuela (Bolivarian Republic of) + """ + VE + + """ + Virgin Islands (British) + """ + VG + + """ + Virgin Islands (U.S.) + """ + VI + + """ + Viet Nam + """ + VN + + """ + Vanuatu + """ + VU + + """ + Wallis and Futuna + """ + WF + + """ + Samoa + """ + WS + + """ + Kosovo + """ + XK + + """ + Yemen + """ + YE + + """ + Mayotte + """ + YT + + """ + South Africa + """ + ZA + + """ + Zambia + """ + ZM + + """ + Zimbabwe + """ + ZW +} + +type Coupon { + activityLogs: [ActivityLog!] + amountCents: BigInt + amountCurrency: CurrencyEnum + appliedCouponsCount: Int! + billableMetrics: [BillableMetric!] + code: String + couponType: CouponTypeEnum! + createdAt: ISO8601DateTime! + + """ + Number of customers using this coupon + """ + customersCount: Int! + description: String + expiration: CouponExpiration! + expirationAt: ISO8601DateTime + frequency: CouponFrequency! + frequencyDuration: Int + id: ID! + limitedBillableMetrics: Boolean! + limitedPlans: Boolean! + name: String! + organization: Organization + percentageRate: Float + plans: [Plan!] + reusable: Boolean! + status: CouponStatusEnum! + terminatedAt: ISO8601DateTime + updatedAt: ISO8601DateTime! +} + +""" +CouponCollection type +""" +type CouponCollection { + """ + A collection of paginated CouponCollection + """ + collection: [Coupon!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +enum CouponExpiration { + no_expiration + time_limit +} + +enum CouponFrequency { + forever + once + recurring +} + +enum CouponStatusEnum { + active + terminated +} + +enum CouponTypeEnum { + fixed_amount + percentage +} + +""" +Autogenerated input type of CreateAddOn +""" +input CreateAddOnInput { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + invoiceDisplayName: String + name: String! + taxCodes: [String!] +} + +""" +Create Adjusted Fee Input +""" +input CreateAdjustedFeeInput { + chargeFilterId: ID + chargeId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + feeId: ID + fixedChargeId: ID + invoiceDisplayName: String + invoiceId: ID! + invoiceSubscriptionId: ID + subscriptionId: ID + unitPreciseAmount: String + units: Float +} + +""" +Autogenerated input type of CreateAiConversation +""" +input CreateAiConversationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + conversationId: ID + message: String! +} + +""" +Autogenerated input type of CreateAnrokIntegration +""" +input CreateAnrokIntegrationInput { + apiKey: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + connectionId: String! + name: String! +} + +""" +Autogenerated input type of CreateApiKey +""" +input CreateApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + name: String + permissions: JSON +} + +""" +Autogenerated input type of CreateAppliedCoupon +""" +input CreateAppliedCouponInput { + amountCents: BigInt + amountCurrency: CurrencyEnum + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + couponId: ID! + customerId: ID! + frequency: CouponFrequency + frequencyDuration: Int + percentageRate: Float +} + +""" +Autogenerated input type of CreateAvalaraIntegration +""" +input CreateAvalaraIntegrationInput { + accountId: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + companyCode: String! + connectionId: String! + licenseKey: String! + name: String! +} + +""" +Create Billable metric input arguments +""" +input CreateBillableMetricInput { + aggregationType: AggregationTypeEnum! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String! + expression: String + fieldName: String + filters: [BillableMetricFiltersInput!] + name: String! + recurring: Boolean + roundingFunction: RoundingFunctionEnum + roundingPrecision: Int + weightedInterval: WeightedIntervalEnum +} + +""" +Create Billing Entity input arguments +""" +input CreateBillingEntityInput { + addressLine1: String + addressLine2: String + billingConfiguration: BillingEntityBillingConfigurationInput + city: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + country: CountryCode + defaultCurrency: CurrencyEnum + documentNumberPrefix: String + documentNumbering: BillingEntityDocumentNumberingEnum + einvoicing: Boolean + email: String + emailSettings: [BillingEntityEmailSettingsEnum!] + euTaxManagement: Boolean + finalizeZeroAmountInvoice: Boolean + legalName: String + legalNumber: String + logo: String + name: String! + netPaymentTerm: Int + state: String + taxIdentificationNumber: String + timezone: TimezoneEnum + zipcode: String +} + +""" +Autogenerated input type of CreateCoupon +""" +input CreateCouponInput { + amountCents: BigInt + amountCurrency: CurrencyEnum + appliesTo: LimitationInput + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + couponType: CouponTypeEnum! + description: String + expiration: CouponExpiration! + expirationAt: ISO8601DateTime + frequency: CouponFrequency! + frequencyDuration: Int + name: String! + percentageRate: Float + reusable: Boolean +} + +""" +Autogenerated input type of CreateCreditNote +""" +input CreateCreditNoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + creditAmountCents: BigInt + description: String + invoiceId: ID! + items: [CreditNoteItemInput!]! + metadata: [MetadataInput!] + offsetAmountCents: BigInt + reason: CreditNoteReasonEnum! + refundAmountCents: BigInt +} + +""" +Create Customer input arguments +""" +input CreateCustomerInput { + accountType: CustomerAccountTypeEnum + addressLine1: String + addressLine2: String + billingConfiguration: CustomerBillingConfigurationInput + billingEntityCode: String + city: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + country: CountryCode + currency: CurrencyEnum + customerType: CustomerTypeEnum + email: String + externalId: String! + externalSalesforceId: String + finalizeZeroAmountInvoice: FinalizeZeroAmountInvoiceEnum + firstname: String + integrationCustomers: [IntegrationCustomerInput!] + invoiceGracePeriod: Int + lastname: String + legalName: String + legalNumber: String + logoUrl: String + metadata: [CustomerMetadataInput!] + name: String + netPaymentTerm: Int + paymentProvider: ProviderTypeEnum + paymentProviderCode: String + phone: String + providerCustomer: ProviderCustomerInput + shippingAddress: CustomerAddressInput + state: String + taxCodes: [String!] + taxIdentificationNumber: String + timezone: TimezoneEnum + url: String + zipcode: String +} + +""" +Autogenerated input type of CreateCustomerPortalWalletTransaction +""" +input CreateCustomerPortalWalletTransactionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + paidCredits: String + walletId: ID! +} + +""" +Autogenerated input type of CreateCustomerWalletAlert +""" +input CreateCustomerWalletAlertInput { + alertType: AlertTypeEnum! + billableMetricId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + name: String + subscriptionId: ID + thresholds: [ThresholdInput!]! + walletId: ID! +} + +""" +Create Wallet Input +""" +input CreateCustomerWalletInput { + appliesTo: AppliesToInput + billingEntityId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + currency: CurrencyEnum! + customerId: ID! + expirationAt: ISO8601DateTime + grantedCredits: String! + ignorePaidTopUpLimitsOnCreation: Boolean + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + invoiceRequiresSuccessfulPayment: Boolean + metadata: [MetadataInput!] + name: String + paidCredits: String! + paidTopUpMaxAmountCents: BigInt + paidTopUpMinAmountCents: BigInt + paymentMethod: PaymentMethodReferenceInput + priority: Int! + rateAmount: String! + recurringTransactionRules: [CreateRecurringTransactionRuleInput!] + transactionName: String +} + +""" +Autogenerated input type of CreateCustomerWalletTransaction +""" +input CreateCustomerWalletTransactionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + grantedCredits: String + ignorePaidTopUpLimits: Boolean + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + invoiceRequiresSuccessfulPayment: Boolean + metadata: [WalletTransactionMetadataInput!] + name: String + paidCredits: String + paymentMethod: PaymentMethodReferenceInput + priority: Int + voidedCredits: String + walletId: ID! +} + +""" +Autogenerated input type of CreateCreditNotesDataExport +""" +input CreateDataExportsCreditNotesInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + filters: DataExportCreditNoteFiltersInput! + format: DataExportFormatTypeEnum! + resourceType: CreditNoteExportTypeEnum! +} + +""" +Autogenerated input type of CreateInvoicesDataExport +""" +input CreateDataExportsInvoicesInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + filters: DataExportInvoiceFiltersInput! + format: DataExportFormatTypeEnum! + resourceType: InvoiceExportTypeEnum! +} + +""" +Autogenerated input type of CreateDunningCampaign +""" +input CreateDunningCampaignInput { + appliedToOrganization: Boolean! + bccEmails: [String!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + daysBetweenAttempts: Int! + description: String + maxAttempts: Int! + name: String! + thresholds: [DunningCampaignThresholdInput!]! +} + +""" +Input for creating a feature +""" +input CreateFeatureInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The code of the feature + """ + code: String! + + """ + The description of the feature + """ + description: String + + """ + The name of the feature + """ + name: String + + """ + The privileges configuration + """ + privileges: [UpdatePrivilegeInput!]! +} + +""" +Autogenerated input type of CreateHubspotIntegration +""" +input CreateHubspotIntegrationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + connectionId: String! + defaultTargetedObject: HubspotTargetedObjectsEnum! + name: String! + syncInvoices: Boolean + syncSubscriptions: Boolean +} + +""" +Autogenerated input type of CreateIntegrationCollectionMapping +""" +input CreateIntegrationCollectionMappingInput { + billingEntityId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + currencies: [CurrencyMappingItemInput!] + externalAccountCode: String + externalId: String + externalName: String + integrationId: ID! + mappingType: MappingTypeEnum! + taxCode: String + taxNexus: String + taxType: String +} + +""" +Autogenerated input type of CreateIntegrationMapping +""" +input CreateIntegrationMappingInput { + billingEntityId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + externalAccountCode: String + externalId: String! + externalName: String + integrationId: ID! + mappableId: ID! + mappableType: MappableTypeEnum! +} + +""" +Autogenerated input type of CreateInvite +""" +input CreateInviteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String! + roles: [String!]! +} + +""" +Autogenerated input type of CreateInvoiceCustomSection +""" +input CreateInvoiceCustomSectionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + details: String + displayName: String + name: String! +} + +""" +Create Invoice input arguments +""" +input CreateInvoiceInput { + billingEntityId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + currency: CurrencyEnum + customerId: ID! + fees: [FeeInput!]! + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + paymentMethod: PaymentMethodReferenceInput + voidedInvoiceId: ID +} + +""" +Autogenerated input type of CreateNetsuiteIntegration +""" +input CreateNetsuiteIntegrationInput { + accountId: String! + clientId: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + clientSecret: String! + code: String! + connectionId: String! + name: String! + scriptEndpointUrl: String! + syncCreditNotes: Boolean + syncInvoices: Boolean + syncPayments: Boolean + tokenId: String! + tokenSecret: String! +} + +""" +Autogenerated input type of CreateOktaIntegration +""" +input CreateOktaIntegrationInput { + clientId: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + clientSecret: String! + domain: String! + host: String + organizationName: String! +} + +""" +Autogenerated input type of CreateOrUpdateSubscriptionEntitlement +""" +input CreateOrUpdateSubscriptionEntitlementInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + entitlement: EntitlementInput! + subscriptionId: ID! +} + +""" +Autogenerated input type of CreatePasswordReset +""" +input CreatePasswordResetInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String! +} + +""" +Autogenerated return type of CreatePasswordReset. +""" +type CreatePasswordResetPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: String! +} + +""" +Autogenerated input type of CreatePayment +""" +input CreatePaymentInput { + amountCents: BigInt! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + createdAt: ISO8601DateTime! + invoiceId: ID! + reference: String! +} + +""" +Autogenerated input type of CreatePlan +""" +input CreatePlanInput { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + billChargesMonthly: Boolean + billFixedChargesMonthly: Boolean + charges: [ChargeInput!]! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + entitlements: [EntitlementInput!] + fixedCharges: [FixedChargeInput!] + interval: PlanInterval! + invoiceDisplayName: String + metadata: [MetadataInput!] + minimumCommitment: CommitmentInput + name: String! + payInAdvance: Boolean! + taxCodes: [String!] + trialPeriod: Float + usageThresholds: [UsageThresholdInput!] +} + +""" +Autogenerated input type of CreatePricingUnit +""" +input CreatePricingUnitInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + name: String! + shortName: String! +} + +""" +Autogenerated input type of CreateQuote +""" +input CreateQuoteInput { + billingItems: JSON + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + content: String + customerId: ID! + orderType: OrderTypeEnum! + owners: [ID!] + subscriptionId: ID +} + +input CreateRecurringTransactionRuleInput { + expirationAt: ISO8601DateTime + grantedCredits: String + ignorePaidTopUpLimits: Boolean + interval: RecurringTransactionIntervalEnum + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + invoiceRequiresSuccessfulPayment: Boolean + method: RecurringTransactionMethodEnum + paidCredits: String + paymentMethod: PaymentMethodReferenceInput + startedAt: ISO8601DateTime + targetOngoingBalance: String + thresholdCredits: String + transactionMetadata: [CreateTransactionMetadataInput!] + transactionName: String + trigger: RecurringTransactionTriggerEnum! +} + +""" +Create Role input arguments +""" +input CreateRoleInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + name: String! + permissions: [PermissionEnum!]! +} + +""" +Autogenerated input type of CreateSalesforceIntegration +""" +input CreateSalesforceIntegrationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + instanceId: String! + name: String! +} + +""" +Autogenerated input type of CreateSubscriptionAlert +""" +input CreateSubscriptionAlertInput { + alertType: AlertTypeEnum! + billableMetricId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + name: String + subscriptionId: ID! + thresholds: [ThresholdInput!]! + walletId: ID +} + +""" +Create subscription charge filter input arguments +""" +input CreateSubscriptionChargeFilterInput { + chargeCode: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceDisplayName: String + properties: PropertiesInput! + subscriptionId: ID! + values: ChargeFilterValues! +} + +""" +Create Subscription input arguments +""" +input CreateSubscriptionInput { + activationRules: [SubscriptionActivationRuleInput!] + billingEntityId: ID + billingTime: BillingTimeEnum! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + customerId: ID! + endingAt: ISO8601DateTime + externalId: String + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + name: String + paymentMethod: PaymentMethodReferenceInput + planId: ID! + planOverrides: PlanOverridesInput + progressiveBillingDisabled: Boolean + subscriptionAt: ISO8601DateTime + subscriptionId: ID + usageThresholds: [UsageThresholdInput!] +} + +input CreateTransactionMetadataInput { + key: String! + value: String! +} + +""" +Autogenerated input type of CreateXeroIntegration +""" +input CreateXeroIntegrationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + connectionId: String! + name: String! + syncCreditNotes: Boolean + syncInvoices: Boolean + syncPayments: Boolean +} + +""" +CreditNote +""" +type CreditNote { + activityLogs: [ActivityLog!] + appliedTaxes: [CreditNoteAppliedTax!] + balanceAmountCents: BigInt! + billingEntity: BillingEntity! + + """ + Check if credit note can be voided + """ + canBeVoided: Boolean! + couponsAdjustmentAmountCents: BigInt! + createdAt: ISO8601DateTime! + creditAmountCents: BigInt! + creditStatus: CreditNoteCreditStatusEnum + currency: CurrencyEnum! + customer: Customer! + description: String + errorDetails: [ErrorDetail!] + externalIntegrationId: String + fileUrl: String + id: ID! + integrationSyncable: Boolean! + invoice: Invoice + issuingDate: ISO8601Date! + items: [CreditNoteItem!]! + metadata: [ItemMetadata!] + number: String! + offsetAmountCents: BigInt! + reason: CreditNoteReasonEnum! + refundAmountCents: BigInt! + refundStatus: CreditNoteRefundStatusEnum + refundedAt: ISO8601DateTime + sequentialId: ID! + subTotalExcludingTaxesAmountCents: BigInt! + taxProviderId: String + taxProviderSyncable: Boolean! + taxesAmountCents: BigInt! + taxesRate: Float! + totalAmountCents: BigInt! + updatedAt: ISO8601DateTime! + voidedAt: ISO8601DateTime + xmlUrl: String +} + +type CreditNoteAppliedTax implements AppliedTax { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + baseAmountCents: BigInt! + createdAt: ISO8601DateTime! + creditNote: CreditNote! + id: ID! + tax: Tax + taxCode: String! + taxDescription: String + taxName: String! + taxRate: Float! + updatedAt: ISO8601DateTime! +} + +""" +CreditNoteCollection type +""" +type CreditNoteCollection { + """ + A collection of paginated CreditNoteCollection + """ + collection: [CreditNote!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +enum CreditNoteCreditStatusEnum { + available + consumed + voided +} + +""" +Estimate amounts for credit note creation +""" +type CreditNoteEstimate { + appliedTaxes: [CreditNoteAppliedTax!]! + couponsAdjustmentAmountCents: BigInt! + currency: CurrencyEnum! + items: [CreditNoteItemEstimate!]! + maxCreditableAmountCents: BigInt! + maxOffsettableAmountCents: BigInt! + maxRefundableAmountCents: BigInt! + preciseCouponsAdjustmentAmountCents: Float! + preciseTaxesAmountCents: Float! + subTotalExcludingTaxesAmountCents: BigInt! + taxesAmountCents: BigInt! + taxesRate: Float! +} + +enum CreditNoteExportTypeEnum { + credit_note_items + credit_notes +} + +type CreditNoteItem { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + createdAt: ISO8601DateTime! + fee: Fee! + id: ID! +} + +type CreditNoteItemEstimate { + amountCents: BigInt! + fee: Fee! +} + +input CreditNoteItemInput { + amountCents: BigInt! + feeId: ID! +} + +enum CreditNoteReasonEnum { + duplicated_charge + fraudulent_charge + order_cancellation + order_change + other + product_unsatisfactory +} + +enum CreditNoteRefundStatusEnum { + failed + pending + succeeded +} + +enum CreditNoteTypeEnum { + credit + offset + refund +} + +enum CurrencyEnum { + """ + United Arab Emirates Dirham + """ + AED + + """ + Afghan Afghani + """ + AFN + + """ + Albanian Lek + """ + ALL + + """ + Armenian Dram + """ + AMD + + """ + Netherlands Antillean Gulden + """ + ANG + + """ + Angolan Kwanza + """ + AOA + + """ + Argentine Peso + """ + ARS + + """ + Australian Dollar + """ + AUD + + """ + Aruban Florin + """ + AWG + + """ + Azerbaijani Manat + """ + AZN + + """ + Bosnia and Herzegovina Convertible Mark + """ + BAM + + """ + Barbadian Dollar + """ + BBD + + """ + Bangladeshi Taka + """ + BDT + + """ + Bulgarian Lev + """ + BGN + + """ + Bahraini Dinar + """ + BHD + + """ + Burundian Franc + """ + BIF + + """ + Bermudian Dollar + """ + BMD + + """ + Brunei Dollar + """ + BND + + """ + Bolivian Boliviano + """ + BOB + + """ + Brazilian Real + """ + BRL + + """ + Bahamian Dollar + """ + BSD + + """ + Botswana Pula + """ + BWP + + """ + Belarusian Ruble + """ + BYN + + """ + Belize Dollar + """ + BZD + + """ + Canadian Dollar + """ + CAD + + """ + Congolese Franc + """ + CDF + + """ + Swiss Franc + """ + CHF + + """ + Unidad de Fomento + """ + CLF + + """ + Chilean Peso + """ + CLP + + """ + Chinese Renminbi Yuan + """ + CNY + + """ + Colombian Peso + """ + COP + + """ + Costa Rican Colón + """ + CRC + + """ + Cape Verdean Escudo + """ + CVE + + """ + Czech Koruna + """ + CZK + + """ + Djiboutian Franc + """ + DJF + + """ + Danish Krone + """ + DKK + + """ + Dominican Peso + """ + DOP + + """ + Algerian Dinar + """ + DZD + + """ + Egyptian Pound + """ + EGP + + """ + Ethiopian Birr + """ + ETB + + """ + Euro + """ + EUR + + """ + Fijian Dollar + """ + FJD + + """ + Falkland Pound + """ + FKP + + """ + British Pound + """ + GBP + + """ + Georgian Lari + """ + GEL + + """ + Ghanaian Cedi + """ + GHS + + """ + Gibraltar Pound + """ + GIP + + """ + Gambian Dalasi + """ + GMD + + """ + Guinean Franc + """ + GNF + + """ + Guatemalan Quetzal + """ + GTQ + + """ + Guyanese Dollar + """ + GYD + + """ + Hong Kong Dollar + """ + HKD + + """ + Honduran Lempira + """ + HNL + + """ + Croatian Kuna + """ + HRK + + """ + Haitian Gourde + """ + HTG + + """ + Hungarian Forint + """ + HUF + + """ + Indonesian Rupiah + """ + IDR + + """ + Israeli New Sheqel + """ + ILS + + """ + Indian Rupee + """ + INR + + """ + Iranian Rial + """ + IRR + + """ + Icelandic Króna + """ + ISK + + """ + Jamaican Dollar + """ + JMD + + """ + Jordanian Dinar + """ + JOD + + """ + Japanese Yen + """ + JPY + + """ + Kenyan Shilling + """ + KES + + """ + Kyrgyzstani Som + """ + KGS + + """ + Cambodian Riel + """ + KHR + + """ + Comorian Franc + """ + KMF + + """ + South Korean Won + """ + KRW + + """ + Kuwaiti Dinar + """ + KWD + + """ + Cayman Islands Dollar + """ + KYD + + """ + Kazakhstani Tenge + """ + KZT + + """ + Lao Kip + """ + LAK + + """ + Lebanese Pound + """ + LBP + + """ + Sri Lankan Rupee + """ + LKR + + """ + Liberian Dollar + """ + LRD + + """ + Lesotho Loti + """ + LSL + + """ + Moroccan Dirham + """ + MAD + + """ + Moldovan Leu + """ + MDL + + """ + Malagasy Ariary + """ + MGA + + """ + Macedonian Denar + """ + MKD + + """ + Myanmar Kyat + """ + MMK + + """ + Mongolian Tögrög + """ + MNT + + """ + Macanese Pataca + """ + MOP + + """ + Mauritanian Ouguiya + """ + MRO + + """ + Mauritian Rupee + """ + MUR + + """ + Maldivian Rufiyaa + """ + MVR + + """ + Malawian Kwacha + """ + MWK + + """ + Mexican Peso + """ + MXN + + """ + Malaysian Ringgit + """ + MYR + + """ + Mozambican Metical + """ + MZN + + """ + Namibian Dollar + """ + NAD + + """ + Nigerian Naira + """ + NGN + + """ + Nicaraguan Córdoba + """ + NIO + + """ + Norwegian Krone + """ + NOK + + """ + Nepalese Rupee + """ + NPR + + """ + New Zealand Dollar + """ + NZD + + """ + Panamanian Balboa + """ + PAB + + """ + Peruvian Sol + """ + PEN + + """ + Papua New Guinean Kina + """ + PGK + + """ + Philippine Peso + """ + PHP + + """ + Pakistani Rupee + """ + PKR + + """ + Polish Złoty + """ + PLN + + """ + Paraguayan Guaraní + """ + PYG + + """ + Qatari Riyal + """ + QAR + + """ + Romanian Leu + """ + RON + + """ + Serbian Dinar + """ + RSD + + """ + Russian Ruble + """ + RUB + + """ + Rwandan Franc + """ + RWF + + """ + Saudi Riyal + """ + SAR + + """ + Solomon Islands Dollar + """ + SBD + + """ + Seychellois Rupee + """ + SCR + + """ + Swedish Krona + """ + SEK + + """ + Singapore Dollar + """ + SGD + + """ + Saint Helenian Pound + """ + SHP + + """ + Sierra Leonean Leone + """ + SLL + + """ + Somali Shilling + """ + SOS + + """ + Surinamese Dollar + """ + SRD + + """ + São Tomé and Príncipe Dobra + """ + STD + + """ + Swazi Lilangeni + """ + SZL + + """ + Thai Baht + """ + THB + + """ + Tajikistani Somoni + """ + TJS + + """ + Tongan Paʻanga + """ + TOP + + """ + Turkish Lira + """ + TRY + + """ + Trinidad and Tobago Dollar + """ + TTD + + """ + New Taiwan Dollar + """ + TWD + + """ + Tanzanian Shilling + """ + TZS + + """ + Ukrainian Hryvnia + """ + UAH + + """ + Ugandan Shilling + """ + UGX + + """ + United States Dollar + """ + USD + + """ + Uruguayan Peso + """ + UYU + + """ + Uzbekistan Som + """ + UZS + + """ + Vietnamese Đồng + """ + VND + + """ + Vanuatu Vatu + """ + VUV + + """ + Samoan Tala + """ + WST + + """ + Central African Cfa Franc + """ + XAF + + """ + East Caribbean Dollar + """ + XCD + + """ + West African Cfa Franc + """ + XOF + + """ + Cfp Franc + """ + XPF + + """ + Yemeni Rial + """ + YER + + """ + South African Rand + """ + ZAR + + """ + Zambian Kwacha + """ + ZMW +} + +type CurrencyMappingItem { + currencyCode: CurrencyEnum! + currencyExternalCode: String! +} + +input CurrencyMappingItemInput { + currencyCode: CurrencyEnum! + currencyExternalCode: String! +} + +""" +Current Organization Type +""" +type CurrentOrganization { + accessibleByCurrentSession: Boolean! + addressLine1: String + addressLine2: String + adyenPaymentProviders: [AdyenProvider!] + apiKey: String + appliedDunningCampaign: DunningCampaign + authenticatedMethod: AuthenticationMethodsEnum! + authenticationMethods: [AuthenticationMethodsEnum!]! + billingConfiguration: OrganizationBillingConfiguration + canCreateBillingEntity: Boolean! + cashfreePaymentProviders: [CashfreeProvider!] + city: String + country: CountryCode + createdAt: ISO8601DateTime! + defaultCurrency: CurrencyEnum! + documentNumberPrefix: String! + documentNumbering: DocumentNumberingEnum! + email: String + emailSettings: [EmailSettingsEnum!] + euTaxManagement: Boolean! + featureFlags: [FeatureFlagEnum!]! + finalizeZeroAmountInvoice: Boolean! + gocardlessPaymentProviders: [GocardlessProvider!] + hmacKey: String + id: ID! + legalName: String + legalNumber: String + logoUrl: String + name: String! + netPaymentTerm: Int! + premiumIntegrations: [PremiumIntegrationTypeEnum!]! + slug: String! + state: String + stripePaymentProviders: [StripeProvider!] + taxIdentificationNumber: String + + """ + Query taxes of an organization + """ + taxes(appliedToOrganization: Boolean, autoGenerated: Boolean, limit: Int, order: String, page: Int, searchTerm: String): [Tax!] + timezone: TimezoneEnum + updatedAt: ISO8601DateTime! + webhookUrl: String + zipcode: String +} + +type CurrentVersion { + githubUrl: String! + number: String! +} + +type Customer { + accountType: CustomerAccountTypeEnum! + + """ + Number of active subscriptions per customer + """ + activeSubscriptionsCount: Int! + activityLogs: [ActivityLog!] + addressLine1: String + addressLine2: String + anrokCustomer: AnrokCustomer + applicableTimezone: TimezoneEnum! + appliedAddOns: [AppliedAddOn!] + appliedCoupons: [AppliedCoupon!] + appliedDunningCampaign: DunningCampaign + avalaraCustomer: AvalaraCustomer + billingConfiguration: CustomerBillingConfiguration + billingEntity: BillingEntity! + + """ + Check if customer attributes are editable + """ + canEditAttributes: Boolean! + city: String + + """ + Invoice custom sections manually configured for the customer + """ + configurableInvoiceCustomSections: [InvoiceCustomSection!] + country: CountryCode + createdAt: ISO8601DateTime! + creditNotes: [CreditNote!] + + """ + Credit notes credits balance available per customer + """ + creditNotesBalanceAmountCents: BigInt! + + """ + Credit notes credits balance available per customer per currency + """ + creditNotesBalances: [CustomerCreditNotesBalance!]! + + """ + Number of available credits from credit notes per customer + """ + creditNotesCreditsAvailableCount: Int! + currency: CurrencyEnum + customerType: CustomerTypeEnum + deletedAt: ISO8601DateTime + displayName: String! + email: String + errorDetails: [ErrorDetail!] + excludeFromDunningCampaign: Boolean! + externalId: String! + externalSalesforceId: String + + """ + Options for handling invoices with a zero total amount. + """ + finalizeZeroAmountInvoice: FinalizeZeroAmountInvoiceEnum + firstname: String + + """ + Define if a customer has an active wallet + """ + hasActiveWallet: Boolean! + + """ + Define if a customer has any credit note + """ + hasCreditNotes: Boolean! + + """ + Define if a customer has overdue invoices + """ + hasOverdueInvoices: Boolean! + + """ + Define if the customer has custom invoice custom sections selection + """ + hasOverwrittenInvoiceCustomSectionsSelection: Boolean + hubspotCustomer: HubspotCustomer + id: ID! + invoiceGracePeriod: Int + invoices: [Invoice!] + lastDunningCampaignAttempt: Int! + lastDunningCampaignAttemptAt: ISO8601DateTime + lastname: String + legalName: String + legalNumber: String + logoUrl: String + metadata: [CustomerMetadata!] + name: String + netPaymentTerm: Int + netsuiteCustomer: NetsuiteCustomer + paymentProvider: ProviderTypeEnum + paymentProviderCode: String + phone: String + providerCustomer: ProviderCustomer + salesforceCustomer: SalesforceCustomer + sequentialId: String! + shippingAddress: CustomerAddress + + """ + Skip invoice custom sections for the customer + """ + skipInvoiceCustomSections: Boolean + slug: String! + state: String + + """ + Query subscriptions of a customer + """ + subscriptions( + """ + Statuses of subscriptions to retrieve + """ + status: [StatusTypeEnum!] + ): [Subscription!]! + taxIdentificationNumber: String + taxes: [Tax!] + timezone: TimezoneEnum + updatedAt: ISO8601DateTime! + url: String + xeroCustomer: XeroCustomer + zipcode: String +} + +enum CustomerAccountTypeEnum { + customer + partner +} + +type CustomerAddress { + addressLine1: String + addressLine2: String + city: String + country: CountryCode + state: String + zipcode: String +} + +input CustomerAddressInput { + addressLine1: String + addressLine2: String + city: String + country: CountryCode + state: String + zipcode: String +} + +type CustomerBillingConfiguration { + documentLocale: String + id: ID! + subscriptionInvoiceIssuingDateAdjustment: CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum + subscriptionInvoiceIssuingDateAnchor: CustomerSubscriptionInvoiceIssuingDateAnchorEnum +} + +input CustomerBillingConfigurationInput { + documentLocale: String + subscriptionInvoiceIssuingDateAdjustment: CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum + subscriptionInvoiceIssuingDateAnchor: CustomerSubscriptionInvoiceIssuingDateAnchorEnum +} + +""" +CustomerCollection type +""" +type CustomerCollection { + """ + A collection of paginated CustomerCollection + """ + collection: [Customer!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type CustomerCreditNotesBalance { + amountCents: BigInt! + currency: CurrencyEnum! +} + +type CustomerMetadata { + createdAt: ISO8601DateTime! + displayInInvoice: Boolean! + id: ID! + key: String! + updatedAt: ISO8601DateTime! + value: String! +} + +input CustomerMetadataFilter { + key: String! + value: String! +} + +input CustomerMetadataInput { + displayInInvoice: Boolean! + id: ID + key: String! + value: String! +} + +type CustomerPortalCustomer { + accountType: CustomerAccountTypeEnum! + addressLine1: String + addressLine2: String + applicableTimezone: TimezoneEnum! + billingConfiguration: CustomerBillingConfiguration + billingEntityBillingConfiguration: BillingEntityBillingConfiguration! + city: String + country: CountryCode + currency: CurrencyEnum + customerType: CustomerTypeEnum + displayName: String! + email: String + firstname: String + id: ID! + lastname: String + legalName: String + legalNumber: String + name: String + premium: Boolean! + shippingAddress: CustomerAddress + state: String + taxIdentificationNumber: String + zipcode: String +} + +""" +CustomerPortalOrganization +""" +type CustomerPortalOrganization { + billingConfiguration: OrganizationBillingConfiguration + defaultCurrency: CurrencyEnum! + id: ID! + logoUrl: String + name: String! + premiumIntegrations: [PremiumIntegrationTypeEnum!]! + timezone: TimezoneEnum +} + +""" +CustomerPortalWallet +""" +type CustomerPortalWallet { + balanceCents: BigInt! + code: String + consumedAmountCents: BigInt! + consumedCredits: Float! + creditsBalance: Float! + creditsOngoingBalance: Float! + currency: CurrencyEnum! + expirationAt: ISO8601DateTime + id: ID! + lastBalanceSyncAt: ISO8601DateTime + name: String + ongoingBalanceCents: BigInt! + ongoingUsageBalanceCents: BigInt! + paidTopUpMaxAmountCents: BigInt + paidTopUpMaxCredits: BigInt + paidTopUpMinAmountCents: BigInt + paidTopUpMinCredits: BigInt + priority: Int! + rateAmount: Float! + status: WalletStatusEnum! +} + +""" +CustomerPortalWalletCollection type +""" +type CustomerPortalWalletCollection { + """ + A collection of paginated CustomerPortalWalletCollection + """ + collection: [CustomerPortalWallet!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type CustomerPortalWalletTransaction { + amount: String! + createdAt: ISO8601DateTime! + creditAmount: String! + id: ID! + settledAt: ISO8601DateTime + status: WalletTransactionStatusEnum! + transactionStatus: WalletTransactionTransactionStatusEnum! + transactionType: WalletTransactionTransactionTypeEnum! + updatedAt: ISO8601DateTime! + wallet: CustomerPortalWallet +} + +""" +CustomerPortalWalletTransactionCollection type +""" +type CustomerPortalWalletTransactionCollection { + """ + A collection of paginated CustomerPortalWalletTransactionCollection + """ + collection: [CustomerPortalWalletTransaction!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type CustomerProjectedUsage { + amountCents: BigInt! + chargesUsage: [ProjectedChargeUsage!]! + currency: CurrencyEnum! + fromDatetime: ISO8601DateTime! + issuingDate: ISO8601Date! + projectedAmountCents: BigInt! + taxesAmountCents: BigInt! + toDatetime: ISO8601DateTime! + totalAmountCents: BigInt! +} + +""" +Subscription Invoice Issuing Date Adjustment Values +""" +enum CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum { + align_with_finalization_date + keep_anchor +} + +""" +Subscription Invoice Issuing Date Anchor Values +""" +enum CustomerSubscriptionInvoiceIssuingDateAnchorEnum { + current_period_end + next_period_start +} + +enum CustomerTypeEnum { + company + individual +} + +type CustomerUsage { + amountCents: BigInt! + chargesUsage: [ChargeUsage!]! + currency: CurrencyEnum! + fromDatetime: ISO8601DateTime! + issuingDate: ISO8601Date! + taxesAmountCents: BigInt! + toDatetime: ISO8601DateTime! + totalAmountCents: BigInt! +} + +type DataApiMetadata { + currentPage: Int! + nextPage: Int! + prevPage: Int! + totalCount: Int! + totalPages: Int! +} + +type DataApiMrr { + amountCurrency: CurrencyEnum! + endOfPeriodDt: ISO8601Date! + endingMrr: BigInt! + mrrChange: BigInt! + mrrChurn: BigInt! + mrrContraction: BigInt! + mrrExpansion: BigInt! + mrrNew: BigInt! + startOfPeriodDt: ISO8601Date! + startingMrr: BigInt! +} + +""" +DataApiMrrCollection type +""" +type DataApiMrrCollection { + """ + A collection of paginated DataApiMrrCollection + """ + collection: [DataApiMrr!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type DataApiMrrPlan { + activeCustomersCount: BigInt! + activeCustomersShare: Float! + amountCurrency: CurrencyEnum! + dt: ISO8601Date! + mrr: Float! + mrrShare: Float + planCode: String! + planDeletedAt: ISO8601DateTime + planId: ID! + planInterval: PlanInterval! + planName: String! +} + +type DataApiMrrsPlans { + collection: [DataApiMrrPlan!]! + metadata: DataApiMetadata! +} + +type DataApiPrepaidCredit { + amountCurrency: CurrencyEnum! + consumedAmount: Float! + consumedCreditsQuantity: Float! + endOfPeriodDt: ISO8601Date! + offeredAmount: Float! + offeredCreditsQuantity: Float! + purchasedAmount: Float! + purchasedCreditsQuantity: Float! + startOfPeriodDt: ISO8601Date! + voidedAmount: Float! + voidedCreditsQuantity: Float! +} + +""" +DataApiPrepaidCreditCollection type +""" +type DataApiPrepaidCreditCollection { + """ + A collection of paginated DataApiPrepaidCreditCollection + """ + collection: [DataApiPrepaidCredit!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type DataApiRevenueStream { + amountCurrency: CurrencyEnum! + commitmentFeeAmountCents: BigInt! + contraRevenueAmountCents: BigInt + couponsAmountCents: BigInt! + creditNotesCreditsAmountCents: BigInt + endOfPeriodDt: ISO8601Date! + freeCreditsAmountCents: BigInt + grossRevenueAmountCents: BigInt! + netRevenueAmountCents: BigInt! + oneOffFeeAmountCents: BigInt! + prepaidCreditsAmountCents: BigInt + progressiveBillingCreditAmountCents: BigInt + startOfPeriodDt: ISO8601Date! + subscriptionFeeAmountCents: BigInt! + usageBasedFeeAmountCents: BigInt! +} + +""" +DataApiRevenueStreamCollection type +""" +type DataApiRevenueStreamCollection { + """ + A collection of paginated DataApiRevenueStreamCollection + """ + collection: [DataApiRevenueStream!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type DataApiRevenueStreamCustomer { + amountCurrency: CurrencyEnum! + customerDeletedAt: ISO8601DateTime + customerId: ID! + customerName: String + externalCustomerId: String! + grossRevenueAmountCents: BigInt! + grossRevenueShare: Float + netRevenueAmountCents: BigInt! + netRevenueShare: Float +} + +type DataApiRevenueStreamPlan { + amountCurrency: CurrencyEnum! + customersCount: Int! + customersShare: Float! + grossRevenueAmountCents: BigInt! + grossRevenueShare: Float + netRevenueAmountCents: BigInt! + netRevenueShare: Float + planCode: String! + planDeletedAt: ISO8601DateTime + planId: ID! + planInterval: PlanInterval! + planName: String! +} + +type DataApiRevenueStreamsCustomers { + collection: [DataApiRevenueStreamCustomer!]! + metadata: DataApiMetadata! +} + +type DataApiRevenueStreamsPlans { + collection: [DataApiRevenueStreamPlan!]! + metadata: DataApiMetadata! +} + +type DataApiUsage { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + billableMetricCode: String! + endOfPeriodDt: ISO8601Date! + isBillableMetricDeleted: Boolean! + startOfPeriodDt: ISO8601Date! + units: Float! +} + +type DataApiUsageAggregatedAmount { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + endOfPeriodDt: ISO8601Date! + startOfPeriodDt: ISO8601Date! +} + +""" +DataApiUsageAggregatedAmountCollection type +""" +type DataApiUsageAggregatedAmountCollection { + """ + A collection of paginated DataApiUsageAggregatedAmountCollection + """ + collection: [DataApiUsageAggregatedAmount!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +DataApiUsageCollection type +""" +type DataApiUsageCollection { + """ + A collection of paginated DataApiUsageCollection + """ + collection: [DataApiUsage!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type DataApiUsageForecasted { + amountCents: BigInt! + amountCentsForecastConservative: BigInt! + amountCentsForecastOptimistic: BigInt! + amountCentsForecastRealistic: BigInt! + amountCurrency: CurrencyEnum! + endOfPeriodDt: ISO8601Date! + startOfPeriodDt: ISO8601Date! + units: Float! + unitsForecastConservative: Float! + unitsForecastOptimistic: Float! + unitsForecastRealistic: Float! +} + +""" +DataApiUsageForecastedCollection type +""" +type DataApiUsageForecastedCollection { + """ + A collection of paginated DataApiUsageForecastedCollection + """ + collection: [DataApiUsageForecasted!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type DataApiUsageInvoiced { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + billableMetricCode: String! + endOfPeriodDt: ISO8601Date! + startOfPeriodDt: ISO8601Date! +} + +""" +DataApiUsageInvoicedCollection type +""" +type DataApiUsageInvoicedCollection { + """ + A collection of paginated DataApiUsageInvoicedCollection + """ + collection: [DataApiUsageInvoiced!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type DataExport { + id: ID! + status: DataExportStatusEnum! +} + +""" +Export credit notes search query and filters input argument +""" +input DataExportCreditNoteFiltersInput { + amountFrom: Int + amountTo: Int + billingEntityIds: [ID!] + creditStatus: [CreditNoteCreditStatusEnum!] + currency: CurrencyEnum + customerExternalId: String + + """ + Uniq ID of the customer + """ + customerId: ID + invoiceNumber: String + issuingDateFrom: ISO8601Date + issuingDateTo: ISO8601Date + reason: [CreditNoteReasonEnum!] + refundStatus: [CreditNoteRefundStatusEnum!] + searchTerm: String + selfBilled: Boolean + types: [CreditNoteTypeEnum!] +} + +enum DataExportFormatTypeEnum { + csv +} + +""" +Export Invoices search query and filters input argument +""" +input DataExportInvoiceFiltersInput { + amountFrom: Int + amountTo: Int + billingEntityIds: [ID!] + currency: CurrencyEnum + customerExternalId: String + invoiceType: [InvoiceTypeEnum!] + issuingDateFrom: ISO8601Date + issuingDateTo: ISO8601Date + paymentDisputeLost: Boolean + paymentOverdue: Boolean + paymentStatus: [InvoicePaymentStatusTypeEnum!] + searchTerm: String + selfBilled: Boolean + status: [InvoiceStatusTypeEnum!] +} + +enum DataExportStatusEnum { + completed + failed + pending + processing +} + +""" +Autogenerated input type of DestroyAddOn +""" +input DestroyAddOnInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyAddOn. +""" +type DestroyAddOnPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyAdjustedFee +""" +input DestroyAdjustedFeeInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyAdjustedFee. +""" +type DestroyAdjustedFeePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyApiKey +""" +input DestroyApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DestroyBillableMetric +""" +input DestroyBillableMetricInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: String! +} + +""" +Autogenerated return type of DestroyBillableMetric. +""" +type DestroyBillableMetricPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyBillingEntity +""" +input DestroyBillingEntityInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! +} + +""" +Autogenerated return type of DestroyBillingEntity. +""" +type DestroyBillingEntityPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String +} + +""" +Autogenerated input type of DestroyChargeFilter +""" +input DestroyChargeFilterInput { + cascadeUpdates: Boolean + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyChargeFilter. +""" +type DestroyChargeFilterPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyCharge +""" +input DestroyChargeInput { + cascadeUpdates: Boolean + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyCharge. +""" +type DestroyChargePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyCoupon +""" +input DestroyCouponInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyCoupon. +""" +type DestroyCouponPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyCustomer +""" +input DestroyCustomerInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyCustomer. +""" +type DestroyCustomerPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyCustomerWalletAlert +""" +input DestroyCustomerWalletAlertInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DestroyDunningCampaign +""" +input DestroyDunningCampaignInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyDunningCampaign. +""" +type DestroyDunningCampaignPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyFeature +""" +input DestroyFeatureInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The ID of the feature to destroy + """ + id: ID! +} + +""" +Autogenerated input type of DestroyFixedCharge +""" +input DestroyFixedChargeInput { + cascadeUpdates: Boolean + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyFixedCharge. +""" +type DestroyFixedChargePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyIntegrationCollectionMapping +""" +input DestroyIntegrationCollectionMappingInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyIntegrationCollectionMapping. +""" +type DestroyIntegrationCollectionMappingPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyIntegration +""" +input DestroyIntegrationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DestroyIntegrationMapping +""" +input DestroyIntegrationMappingInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyIntegrationMapping. +""" +type DestroyIntegrationMappingPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated return type of DestroyIntegration. +""" +type DestroyIntegrationPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyInvoiceCustomSection +""" +input DestroyInvoiceCustomSectionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyInvoiceCustomSection. +""" +type DestroyInvoiceCustomSectionPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyPaymentMethod +""" +input DestroyPaymentMethodInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyPaymentMethod. +""" +type DestroyPaymentMethodPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyPaymentProvider +""" +input DestroyPaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyPaymentProvider. +""" +type DestroyPaymentProviderPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyPlan +""" +input DestroyPlanInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyPlan. +""" +type DestroyPlanPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyRole +""" +input DestroyRoleInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DestroySubscriptionAlert +""" +input DestroySubscriptionAlertInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Destroy subscription charge filter input arguments +""" +input DestroySubscriptionChargeFilterInput { + chargeCode: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + subscriptionId: ID! + values: ChargeFilterValues! +} + +""" +Autogenerated input type of DestroyTax +""" +input DestroyTaxInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyTax. +""" +type DestroyTaxPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +""" +Autogenerated input type of DestroyWebhookEndpoint +""" +input DestroyWebhookEndpointInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of DestroyWebhookEndpoint. +""" +type DestroyWebhookEndpointPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID +} + +enum DirectionEnum { + decreasing + increasing +} + +""" +Document numbering type +""" +enum DocumentNumberingEnum { + per_customer + per_organization +} + +""" +Autogenerated input type of DownloadCreditNote +""" +input DownloadCreditNoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DownloadCustomerPortalInvoice +""" +input DownloadCustomerPortalInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DownloadInvoice +""" +input DownloadInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DownloadPaymentReceipt +""" +input DownloadPaymentReceiptInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DownloadXMLPaymentReceipt +""" +input DownloadXMLPaymentReceiptInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DownloadXmlCreditNote +""" +input DownloadXmlCreditNoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of DownloadXmlInvoice +""" +input DownloadXmlInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +type DunningCampaign { + appliedToOrganization: Boolean! + bccEmails: [String!] + code: String! + createdAt: ISO8601DateTime! + customersCount: Int! + daysBetweenAttempts: Int! + description: String + id: ID! + maxAttempts: Int! + name: String! + thresholds: [DunningCampaignThreshold!]! + updatedAt: ISO8601DateTime! +} + +""" +DunningCampaignCollection type +""" +type DunningCampaignCollection { + """ + A collection of paginated DunningCampaignCollection + """ + collection: [DunningCampaign!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type DunningCampaignThreshold { + amountCents: BigInt! + currency: CurrencyEnum! + id: ID! +} + +input DunningCampaignThresholdInput { + amountCents: BigInt! + currency: CurrencyEnum! + id: ID +} + +""" +Organization Email Settings Values +""" +enum EmailSettingsEnum { + """ + credit_note.created + """ + credit_note_created + + """ + invoice.finalized + """ + invoice_finalized + + """ + payment_receipt.created + """ + payment_receipt_created +} + +""" +Input for updating a plan entitlement +""" +input EntitlementInput { + featureCode: String! + + """ + The privileges configuration + """ + privileges: [EntitlementPrivilegeInput!] +} + +""" +Input for updating a plan entitlement privilege value +""" +input EntitlementPrivilegeInput { + privilegeCode: String! + value: String! +} + +enum ErrorCodesEnum { + invoice_generation_error + not_provided + tax_error + tax_voiding_error +} + +type ErrorDetail { + errorCode: ErrorCodesEnum! + errorDetails: String + id: ID! +} + +type Event { + apiClient: String + billableMetricName: String + code: String! + customerTimezone: TimezoneEnum! + deletedAt: ISO8601DateTime + externalSubscriptionId: String + id: ID! + ipAddress: String + matchBillableMetric: Boolean + matchCustomField: Boolean + matchCustomer: Boolean + matchSubscription: Boolean + payload: JSON! + receivedAt: ISO8601DateTime + timestamp: ISO8601DateTime + transactionId: String +} + +enum EventCategoryEnum { + ALERTS + CREDIT_NOTES + CUSTOMERS + DUNNING_CAMPAIGNS + EVENT_INGESTION + FEATURES + INTEGRATIONS + INVOICES + PAYMENTS + PAYMENT_RECEIPTS + PLANS + SUBSCRIPTIONS_AND_FEES + WALLETS_AND_CREDITS +} + +""" +EventCollection type +""" +type EventCollection { + """ + A collection of paginated EventCollection + """ + collection: [Event!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +enum EventTypeEnum { + alert_triggered + all + credit_note_created + credit_note_generated + credit_note_provider_refund_failure + customer_accounting_provider_created + customer_accounting_provider_error + customer_checkout_url_generated + customer_created + customer_crm_provider_created + customer_crm_provider_error + customer_payment_provider_created + customer_payment_provider_error + customer_tax_provider_error + customer_updated + customer_vies_check + dunning_campaign_finished + event_error + events_errors + feature_created + feature_deleted + feature_updated + fee_created + fee_tax_provider_error + integration_provider_error + invoice_created + invoice_drafted + invoice_generated + invoice_one_off_created + invoice_paid_credit_added + invoice_payment_dispute_lost + invoice_payment_failure + invoice_payment_overdue + invoice_payment_status_updated + invoice_resynced + invoice_voided + payment_provider_error + payment_receipt_created + payment_receipt_generated + payment_request_created + payment_request_payment_failure + payment_request_payment_status_updated + payment_requires_action + payment_succeeded + plan_created + plan_deleted + plan_updated + subscription_canceled + subscription_incomplete + subscription_started + subscription_terminated + subscription_termination_alert + subscription_trial_ended + subscription_updated + subscription_usage_threshold_reached + wallet_created + wallet_depleted_ongoing_balance + wallet_terminated + wallet_transaction_created + wallet_transaction_payment_failure + wallet_transaction_updated + wallet_updated +} + +""" +Organization Feature Flag Values +""" +enum FeatureFlagEnum { + enriched_events_aggregation + multi_currency + multi_entity_billing + multiple_payment_methods + non_persistable_charge_cache_optimization + order_forms + payment_gated_subscriptions + postgres_enriched_events + wallet_traceability +} + +type FeatureObject { + code: String! + createdAt: ISO8601DateTime! + description: String + id: ID! + name: String + privileges: [PrivilegeObject!]! + subscriptionsCount: Int! +} + +""" +FeatureObjectCollection type +""" +type FeatureObjectCollection { + """ + A collection of paginated FeatureObjectCollection + """ + collection: [FeatureObject!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type Fee implements InvoiceItem { + addOn: AddOn + adjustedFee: Boolean! + adjustedFeeType: AdjustedFeeTypeEnum + amountCents: BigInt! + amountCurrency: CurrencyEnum! + amountDetails: FeeAmountDetails + appliedTaxes: [FeeAppliedTax!] + charge: Charge + chargeFilter: ChargeFilter + creditableAmountCents: BigInt! + currency: CurrencyEnum! + description: String + eventsCount: BigInt + feeType: FeeTypesEnum! + fixedCharge: FixedCharge + groupedBy: JSON! + id: ID! + invoiceDisplayName: String + invoiceId: ID + invoiceName: String + itemCode: String! + itemName: String! + itemType: String! + offsettableAmountCents: BigInt! + preciseUnitAmount: Float! + presentationBreakdowns: [PresentationBreakdownUsage!] + pricingUnitUsage: PricingUnitUsage + properties: FeeProperties + subscription: Subscription + succeededAt: ISO8601DateTime + taxesAmountCents: BigInt! + taxesRate: Float + trueUpFee: Fee + trueUpParentFee: Fee + units: Float! + walletTransaction: WalletTransaction +} + +type FeeAmountDetails { + fixedFeeTotalAmount: String + fixedFeeUnitAmount: String + flatUnitAmount: String + freeEvents: Int + freeUnits: String + graduatedPercentageRanges: [FeeAmountDetailsGraduatedPercentageRange!] + graduatedRanges: [FeeAmountDetailsGraduatedRange!] + minMaxAdjustmentTotalAmount: String + paidEvents: Int + paidUnits: String + perPackageSize: Int + perPackageUnitAmount: String + perUnitAmount: String + perUnitTotalAmount: String + rate: String + units: String +} + +type FeeAmountDetailsGraduatedPercentageRange { + flatUnitAmount: String + fromValue: BigInt + perUnitTotalAmount: String + rate: String + toValue: BigInt + totalWithFlatAmount: String + units: String +} + +type FeeAmountDetailsGraduatedRange { + flatUnitAmount: String + fromValue: Float + perUnitAmount: String + perUnitTotalAmount: String + toValue: Float + totalWithFlatAmount: String + units: String +} + +type FeeAppliedTax implements AppliedTax { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + createdAt: ISO8601DateTime! + fee: Fee! + id: ID! + tax: Tax + taxCode: String! + taxDescription: String + taxName: String! + taxRate: Float! + updatedAt: ISO8601DateTime! +} + +""" +Fee input for creating invoice +""" +input FeeInput { + addOnId: ID + description: String + fromDatetime: ISO8601DateTime! + invoiceDisplayName: String + name: String + taxCodes: [String!] + toDatetime: ISO8601DateTime! + unitAmountCents: BigInt + units: Float +} + +type FeeProperties { + fromDatetime: ISO8601DateTime + toDatetime: ISO8601DateTime +} + +enum FeeTypesEnum { + add_on + charge + commitment + credit + fixed_charge + subscription +} + +""" +Create Invoice input arguments +""" +input FetchDraftInvoiceTaxesInput { + billingEntityId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + currency: CurrencyEnum + customerId: ID! + fees: [FeeInput!]! + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + paymentMethod: PaymentMethodReferenceInput + voidedInvoiceId: ID +} + +""" +Autogenerated input type of FetchIntegrationAccounts +""" +input FetchIntegrationAccountsInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + integrationId: ID! +} + +""" +Autogenerated input type of FetchIntegrationItems +""" +input FetchIntegrationItemsInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + integrationId: ID! +} + +""" +Autogenerated input type of FinalizeAllInvoices +""" +input FinalizeAllInvoicesInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String +} + +""" +Autogenerated input type of FinalizeInvoice +""" +input FinalizeInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +enum FinalizeZeroAmountInvoiceEnum { + finalize + inherit + skip +} + +type FinalizedInvoiceCollection { + amountCents: BigInt! + currency: CurrencyEnum + invoicesCount: BigInt! + month: ISO8601DateTime! + paymentStatus: InvoicePaymentStatusTypeEnum +} + +""" +FinalizedInvoiceCollectionCollection type +""" +type FinalizedInvoiceCollectionCollection { + """ + A collection of paginated FinalizedInvoiceCollectionCollection + """ + collection: [FinalizedInvoiceCollection!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type FixedCharge { + addOn: AddOn! + chargeModel: FixedChargeChargeModelEnum! + code: String + createdAt: ISO8601DateTime! + deletedAt: ISO8601DateTime + id: ID! + invoiceDisplayName: String + parentId: ID + payInAdvance: Boolean! + properties: FixedChargeProperties + prorated: Boolean! + taxes: [Tax!] + units: String! + updatedAt: ISO8601DateTime! +} + +enum FixedChargeChargeModelEnum { + graduated + standard + volume +} + +""" +Autogenerated input type of CreateFixedCharge +""" +input FixedChargeCreateInput { + addOnId: ID! + applyUnitsImmediately: Boolean + cascadeUpdates: Boolean + chargeModel: FixedChargeChargeModelEnum! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + invoiceDisplayName: String + payInAdvance: Boolean + planId: ID! + properties: FixedChargePropertiesInput + prorated: Boolean + taxCodes: [String!] + units: String +} + +input FixedChargeInput { + addOnId: ID! + applyUnitsImmediately: Boolean + chargeModel: FixedChargeChargeModelEnum! + id: ID + invoiceDisplayName: String + payInAdvance: Boolean + properties: FixedChargePropertiesInput + prorated: Boolean + taxCodes: [String!] + units: String +} + +input FixedChargeOverridesInput { + addOnId: ID + applyUnitsImmediately: Boolean + id: ID + invoiceDisplayName: String + properties: FixedChargePropertiesInput + taxCodes: [String!] + units: String +} + +type FixedChargeProperties { + amount: String + graduatedRanges: [GraduatedRange!] + volumeRanges: [VolumeRange!] +} + +input FixedChargePropertiesInput { + amount: String + graduatedRanges: [GraduatedRangeInput!] + volumeRanges: [VolumeRangeInput!] +} + +""" +Autogenerated input type of UpdateFixedCharge +""" +input FixedChargeUpdateInput { + applyUnitsImmediately: Boolean + cascadeUpdates: Boolean + chargeModel: FixedChargeChargeModelEnum + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + id: ID! + invoiceDisplayName: String + payInAdvance: Boolean + properties: FixedChargePropertiesInput + prorated: Boolean + taxCodes: [String!] + units: String +} + +type FlutterwaveProvider { + code: String! + id: ID! + name: String! + secretKey: ObfuscatedString + successRedirectUrl: String + webhookSecret: String +} + +""" +Autogenerated input type of GenerateCheckoutUrl +""" +input GenerateCheckoutUrlInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + customerId: ID! +} + +""" +Autogenerated return type of GenerateCheckoutUrl. +""" +type GenerateCheckoutUrlPayload { + checkoutUrl: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String +} + +""" +Autogenerated input type of GenerateCustomerPortalUrl +""" +input GenerateCustomerPortalUrlInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated return type of GenerateCustomerPortalUrl. +""" +type GenerateCustomerPortalUrlPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + url: String! +} + +""" +Autogenerated input type of GeneratePaymentUrl +""" +input GeneratePaymentUrlInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceId: ID! +} + +""" +Autogenerated return type of GeneratePaymentUrl. +""" +type GeneratePaymentUrlPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + paymentUrl: String +} + +type GocardlessProvider { + code: String! + hasAccessToken: Boolean + id: ID! + name: String! + successRedirectUrl: String + webhookSecret: String +} + +""" +Autogenerated input type of GoogleAcceptInvite +""" +input GoogleAcceptInviteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + inviteToken: String! +} + +""" +Autogenerated input type of GoogleLoginUser +""" +input GoogleLoginUserInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! +} + +""" +Autogenerated input type of GoogleRegisterUser +""" +input GoogleRegisterUserInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + organizationName: String! +} + +type GraduatedPercentageRange { + flatAmount: String! + fromValue: Float! + rate: String! + toValue: Float +} + +input GraduatedPercentageRangeInput { + flatAmount: String! + fromValue: Float! + rate: String! + toValue: Float +} + +type GraduatedRange { + flatAmount: String! + fromValue: Float! + perUnitAmount: String! + toValue: Float +} + +input GraduatedRangeInput { + flatAmount: String! + fromValue: Float! + perUnitAmount: String! + toValue: Float +} + +type GraphqlSubscription { + aiConversationStreamed(id: ID!): AiConversationStream! +} + +type GrossRevenue { + amountCents: BigInt + currency: CurrencyEnum + invoicesCount: BigInt! + month: ISO8601DateTime! +} + +""" +GrossRevenueCollection type +""" +type GrossRevenueCollection { + """ + A collection of paginated GrossRevenueCollection + """ + collection: [GrossRevenue!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type GroupedChargeUsage { + amountCents: BigInt! + eventsCount: Int! + filters: [ChargeFilterUsage!] + groupedBy: JSON + id: ID! + presentationBreakdowns: [PresentationBreakdownUsage!] + pricingUnitAmountCents: BigInt + units: Float! +} + +""" +Api Logs http method enums +""" +enum HttpMethodEnum { + delete + post + put +} + +""" +Api Logs HTTP status +""" +scalar HttpStatus + +type HubspotCustomer { + externalCustomerId: String + id: ID! + integrationCode: String + integrationId: ID + integrationType: IntegrationTypeEnum + syncWithProvider: Boolean + targetedObject: HubspotTargetedObjectsEnum +} + +type HubspotIntegration { + code: String! + connectionId: ID! + defaultTargetedObject: HubspotTargetedObjectsEnum! + id: ID! + invoicesObjectTypeId: String + name: String! + portalId: String + subscriptionsObjectTypeId: String + syncInvoices: Boolean + syncSubscriptions: Boolean +} + +enum HubspotTargetedObjectsEnum { + companies + contacts +} + +""" +An ISO 8601-encoded date +""" +scalar ISO8601Date @specifiedBy(url: "https://tools.ietf.org/html/rfc3339") + +""" +An ISO 8601-encoded datetime +""" +scalar ISO8601DateTime @specifiedBy(url: "https://tools.ietf.org/html/rfc3339") + +union Integration = AnrokIntegration | AvalaraIntegration | HubspotIntegration | NetsuiteIntegration | OktaIntegration | SalesforceIntegration | XeroIntegration + +""" +IntegrationCollection type +""" +type IntegrationCollection { + """ + A collection of paginated IntegrationCollection + """ + collection: [Integration!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +input IntegrationCustomerInput { + externalCustomerId: String + id: ID + integrationCode: String + integrationId: ID + integrationType: IntegrationTypeEnum + subsidiaryId: String + syncWithProvider: Boolean + targetedObject: HubspotTargetedObjectsEnum +} + +type IntegrationItem { + externalAccountCode: String + externalId: String! + externalName: String + id: ID! + integrationId: ID! + itemType: IntegrationItemTypeEnum! +} + +""" +IntegrationItemCollection type +""" +type IntegrationItemCollection { + """ + A collection of paginated IntegrationItemCollection + """ + collection: [IntegrationItem!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +enum IntegrationItemTypeEnum { + account + standard + tax +} + +enum IntegrationTypeEnum { + analytics_dashboards + anrok + api_permissions + auto_dunning + avalara + beta_payment_authorization + custom_roles + events_targeting_wallets + forecasted_usage + from_email + granular_lifetime_usage + hubspot + issue_receipts + lifetime_usage + manual_payments + multi_entities_enterprise + multi_entities_pro + netsuite + okta + order_forms + preview + progressive_billing + projected_usage + remove_branding_watermark + revenue_analytics + revenue_share + salesforce + security_logs + xero +} + +type Invite { + acceptedAt: ISO8601DateTime + email: String! + id: ID! + organization: Organization! + recipient: Membership! + revokedAt: ISO8601DateTime + roles: [String!]! + status: InviteStatusTypeEnum! + token: String! +} + +""" +InviteCollection type +""" +type InviteCollection { + """ + A collection of paginated InviteCollection + """ + collection: [Invite!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +enum InviteStatusTypeEnum { + accepted + pending + revoked +} + +""" +Invoice +""" +type Invoice { + activityLogs: [ActivityLog!] + allChargesHaveFees: Boolean! + allFixedChargesHaveFees: Boolean! + appliedTaxes: [InvoiceAppliedTax!] + associatedActiveWalletPresent: Boolean! + availableToCreditAmountCents: BigInt! + billingEntity: BillingEntity! + chargeAmountCents: BigInt! + couponsAmountCents: BigInt! + createdAt: ISO8601DateTime! + creditNotes: [CreditNote!] + creditNotesAmountCents: BigInt! + creditableAmountCents: BigInt! + currency: CurrencyEnum + customer: Customer! + errorDetails: [ErrorDetail!] + expectedFinalizationDate: ISO8601Date! + externalHubspotIntegrationId: String + externalIntegrationId: String + externalSalesforceIntegrationId: String + fees: [Fee!] + feesAmountCents: BigInt! + fileUrl: String + id: ID! + integrationHubspotSyncable: Boolean! + integrationSalesforceSyncable: Boolean! + integrationSyncable: Boolean! + invoiceSubscriptions: [InvoiceSubscription!] + invoiceType: InvoiceTypeEnum! + issuingDate: ISO8601Date! + metadata: [InvoiceMetadata!] + number: String! + offsettableAmountCents: BigInt! + payableType: String! + paymentDisputeLosable: Boolean! + paymentDisputeLostAt: ISO8601DateTime + paymentDueDate: ISO8601Date! + paymentOverdue: Boolean! + paymentStatus: InvoicePaymentStatusTypeEnum! + payments: [Payment!] + prepaidCreditAmountCents: BigInt! + prepaidGrantedCreditAmountCents: BigInt + prepaidPurchasedCreditAmountCents: BigInt + progressiveBillingCreditAmountCents: BigInt! + readyForPaymentProcessing: Boolean! + refundableAmountCents: BigInt! + regeneratedInvoiceId: String + selfBilled: Boolean! + sequentialId: ID! + status: InvoiceStatusTypeEnum! + subTotalExcludingTaxesAmountCents: BigInt! + subTotalIncludingTaxesAmountCents: BigInt! + subscriptions: [Subscription!] + taxProviderId: String + taxProviderVoidable: Boolean! + taxStatus: InvoiceTaxStatusTypeEnum + taxesAmountCents: BigInt! + taxesRate: Float! + totalAmountCents: BigInt! + totalDueAmountCents: BigInt! + totalPaidAmountCents: BigInt! + totalSettledAmountCents: BigInt! + updatedAt: ISO8601DateTime! + versionNumber: Int! + voidable: Boolean! + voidedAt: ISO8601DateTime + voidedInvoiceId: String + xmlUrl: String +} + +type InvoiceAppliedTax implements AppliedTax { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + appliedOnWholeInvoice: Boolean! + createdAt: ISO8601DateTime! + enumedTaxCode: InvoiceAppliedTaxOnWholeInvoiceCodeEnum + feesAmountCents: BigInt! + id: ID! + invoice: Invoice! + tax: Tax + taxCode: String! + taxDescription: String + taxName: String! + taxRate: Float! + taxableAmountCents: BigInt! + updatedAt: ISO8601DateTime! +} + +enum InvoiceAppliedTaxOnWholeInvoiceCodeEnum { + customer_exempt + juris_has_no_tax + juris_not_taxed + not_collecting + reverse_charge + transaction_exempt + unknown_taxation +} + +""" +InvoiceCollection type +""" +type InvoiceCollection { + """ + A collection of paginated InvoiceCollection + """ + collection: [Invoice!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type InvoiceCustomSection { + code: String! + description: String + details: String + displayName: String + id: ID! + name: String! + organization: Organization +} + +""" +InvoiceCustomSectionCollection type +""" +type InvoiceCustomSectionCollection { + """ + A collection of paginated InvoiceCustomSectionCollection + """ + collection: [InvoiceCustomSection!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +input InvoiceCustomSectionsReferenceInput { + invoiceCustomSectionIds: [ID!] + skipInvoiceCustomSections: Boolean +} + +enum InvoiceExportTypeEnum { + invoice_fees + invoices +} + +""" +Invoice Item +""" +interface InvoiceItem { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + id: ID! + itemCode: String! + itemName: String! + itemType: String! +} + +""" +Attributes for invoice metadata object +""" +type InvoiceMetadata { + createdAt: ISO8601DateTime! + id: ID! + key: String! + updatedAt: ISO8601DateTime! + value: String! +} + +""" +Attributes for creating or updating invoice metadata object +""" +input InvoiceMetadataInput { + id: ID + key: String! + value: String! +} + +enum InvoicePaymentStatusTypeEnum { + failed + pending + succeeded +} + +enum InvoiceSettlementTypeEnum { + credit_note +} + +enum InvoiceStatusTypeEnum { + closed + draft + failed + finalized + generating + open + pending + voided +} + +type InvoiceSubscription { + acceptNewChargeFees: Boolean! + chargeAmountCents: BigInt! + chargesFromDatetime: ISO8601DateTime + chargesToDatetime: ISO8601DateTime + fees: [Fee!] + fromDatetime: ISO8601DateTime + inAdvanceChargesFromDatetime: ISO8601DateTime + inAdvanceChargesToDatetime: ISO8601DateTime + invoice: Invoice! + subscription: Subscription! + subscriptionAmountCents: BigInt! + toDatetime: ISO8601DateTime + totalAmountCents: BigInt! +} + +enum InvoiceTaxStatusTypeEnum { + failed + pending + succeeded +} + +enum InvoiceTypeEnum { + add_on + advance_charges + credit + one_off + progressive_billing + subscription +} + +type InvoicedUsage { + amountCents: BigInt! + code: String + currency: CurrencyEnum! + month: ISO8601DateTime! +} + +""" +InvoicedUsageCollection type +""" +type InvoicedUsageCollection { + """ + A collection of paginated InvoicedUsageCollection + """ + collection: [InvoicedUsage!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Metadata key-value pair +""" +type ItemMetadata { + key: String! + value: String +} + +""" +Represents untyped JSON +""" +scalar JSON + +input LimitationInput { + billableMetricIds: [ID!] + planIds: [ID!] +} + +""" +Security Log event +""" +enum LogEventEnum { + """ + api_key.created + """ + api_key_created + + """ + api_key.deleted + """ + api_key_deleted + + """ + api_key.rotated + """ + api_key_rotated + + """ + api_key.updated + """ + api_key_updated + + """ + billing_entity.created + """ + billing_entity_created + + """ + billing_entity.updated + """ + billing_entity_updated + + """ + export.created + """ + export_created + + """ + integration.created + """ + integration_created + + """ + integration.deleted + """ + integration_deleted + + """ + integration.updated + """ + integration_updated + + """ + role.created + """ + role_created + + """ + role.deleted + """ + role_deleted + + """ + role.updated + """ + role_updated + + """ + user.deleted + """ + user_deleted + + """ + user.invited + """ + user_invited + + """ + user.new_device_logged_in + """ + user_new_device_logged_in + + """ + user.password_edited + """ + user_password_edited + + """ + user.password_reset_requested + """ + user_password_reset_requested + + """ + user.role_edited + """ + user_role_edited + + """ + user.signed_up + """ + user_signed_up + + """ + webhook_endpoint.created + """ + webhook_endpoint_created + + """ + webhook_endpoint.deleted + """ + webhook_endpoint_deleted + + """ + webhook_endpoint.updated + """ + webhook_endpoint_updated +} + +""" +Security Log type +""" +enum LogTypeEnum { + """ + api_key + """ + api_key + + """ + billing_entity + """ + billing_entity + + """ + export + """ + export + + """ + integration + """ + integration + + """ + role + """ + role + + """ + user + """ + user + + """ + webhook_endpoint + """ + webhook_endpoint +} + +type LoginUser { + token: String! + user: User! +} + +""" +Autogenerated input type of LoginUser +""" +input LoginUserInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String! + password: String! +} + +""" +Autogenerated input type of LoseInvoiceDispute +""" +input LoseInvoiceDisputeInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +enum MappableTypeEnum { + AddOn + BillableMetric +} + +type Mapping { + billingEntityId: ID + externalAccountCode: String + externalId: String! + externalName: String + id: ID! + integrationId: ID! + mappableId: ID! + mappableType: MappableTypeEnum! +} + +enum MappingTypeEnum { + account + coupon + credit_note + currencies + fallback_item + minimum_commitment + prepaid_credit + subscription_fee + tax +} + +type Membership { + createdAt: ISO8601DateTime! + id: ID! + organization: Organization! + permissions: Permissions! + revokedAt: ISO8601DateTime + roles: [String!]! + status: MembershipStatus! + updatedAt: ISO8601DateTime! + user: User! +} + +""" +MembershipCollection type +""" +type MembershipCollection { + """ + A collection of paginated MembershipCollection + """ + collection: [Membership!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: Metadata! +} + +enum MembershipStatus { + active + revoked +} + +""" +Type for CollectionMetadataType +""" +type Metadata { + adminCount: Int! + + """ + Current Page of loaded data + """ + currentPage: Int! + + """ + The number of items per page + """ + limitValue: Int! + + """ + The total number of items to be paginated + """ + totalCount: Int! + + """ + The total number of pages in the pagination + """ + totalPages: Int! +} + +""" +Input for metadata key-value pair +""" +input MetadataInput { + key: String! + value: String +} + +type MoneyhashProvider { + apiKey: String + code: String! + flowId: String + id: ID! + name: String! + successRedirectUrl: String +} + +type Mrr { + amountCents: BigInt + currency: CurrencyEnum + month: ISO8601DateTime! +} + +""" +MrrCollection type +""" +type MrrCollection { + """ + A collection of paginated MrrCollection + """ + collection: [Mrr!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type Mutation { + """ + Accepts a new Invite + """ + acceptInvite( + """ + Parameters for AcceptInvite + """ + input: AcceptInviteInput! + ): RegisterUser + + """ + Add Adyen payment provider + """ + addAdyenPaymentProvider( + """ + Parameters for AddAdyenPaymentProvider + """ + input: AddAdyenPaymentProviderInput! + ): AdyenProvider + + """ + Add or update Cashfree payment provider + """ + addCashfreePaymentProvider( + """ + Parameters for AddCashfreePaymentProvider + """ + input: AddCashfreePaymentProviderInput! + ): CashfreeProvider + + """ + Add Flutterwave payment provider + """ + addFlutterwavePaymentProvider( + """ + Parameters for AddFlutterwavePaymentProvider + """ + input: AddFlutterwavePaymentProviderInput! + ): FlutterwaveProvider + + """ + Add or update Gocardless payment provider + """ + addGocardlessPaymentProvider( + """ + Parameters for AddGocardlessPaymentProvider + """ + input: AddGocardlessPaymentProviderInput! + ): GocardlessProvider + + """ + Add Moneyhash payment provider + """ + addMoneyhashPaymentProvider( + """ + Parameters for AddMoneyhashPaymentProvider + """ + input: AddMoneyhashPaymentProviderInput! + ): MoneyhashProvider + + """ + Add Stripe API keys to the organization + """ + addStripePaymentProvider( + """ + Parameters for AddStripePaymentProvider + """ + input: AddStripePaymentProviderInput! + ): StripeProvider + + """ + Approve a quote version + """ + approveQuoteVersion( + """ + Parameters for ApproveQuoteVersion + """ + input: ApproveQuoteVersionInput! + ): QuoteVersion + billingEntityApplyTaxes( + """ + Parameters for ApplyTaxes + """ + input: ApplyTaxesInput! + ): ApplyTaxesPayload + billingEntityRemoveTaxes( + """ + Parameters for RemoveTaxes + """ + input: RemoveTaxesInput! + ): RemoveTaxesPayload + + """ + Updates the applied dunning campaign for a billing entity + """ + billingEntityUpdateAppliedDunningCampaign( + """ + Parameters for BillingEntityUpdateAppliedDunningCampaign + """ + input: BillingEntityUpdateAppliedDunningCampaignInput! + ): BillingEntity + + """ + Clone a quote version + """ + cloneQuoteVersion( + """ + Parameters for CloneQuoteVersion + """ + input: CloneQuoteVersionInput! + ): QuoteVersion + + """ + Creates a new add-on + """ + createAddOn( + """ + Parameters for CreateAddOn + """ + input: CreateAddOnInput! + ): AddOn + + """ + Creates Adjusted Fee + """ + createAdjustedFee( + """ + Parameters for CreateAdjustedFee + """ + input: CreateAdjustedFeeInput! + ): Fee + + """ + Creates an AI conversation and appends a message to it + """ + createAiConversation( + """ + Parameters for CreateAiConversation + """ + input: CreateAiConversationInput! + ): AiConversation + + """ + Create Anrok integration + """ + createAnrokIntegration( + """ + Parameters for CreateAnrokIntegration + """ + input: CreateAnrokIntegrationInput! + ): AnrokIntegration + + """ + Creates a new API key + """ + createApiKey( + """ + Parameters for CreateApiKey + """ + input: CreateApiKeyInput! + ): ApiKey + + """ + Assigns a Coupon to a Customer + """ + createAppliedCoupon( + """ + Parameters for CreateAppliedCoupon + """ + input: CreateAppliedCouponInput! + ): AppliedCoupon + + """ + Create Avalara integration + """ + createAvalaraIntegration( + """ + Parameters for CreateAvalaraIntegration + """ + input: CreateAvalaraIntegrationInput! + ): AvalaraIntegration + + """ + Creates a new Billable metric + """ + createBillableMetric( + """ + Parameters for CreateBillableMetric + """ + input: CreateBillableMetricInput! + ): BillableMetric + + """ + Creates a new Billing Entity + """ + createBillingEntity( + """ + Parameters for CreateBillingEntity + """ + input: CreateBillingEntityInput! + ): BillingEntity + + """ + Creates a new Charge for a Plan + """ + createCharge( + """ + Parameters for CreateCharge + """ + input: ChargeCreateInput! + ): Charge + + """ + Creates a new Charge Filter + """ + createChargeFilter( + """ + Parameters for CreateChargeFilter + """ + input: ChargeFilterCreateInput! + ): ChargeFilter + + """ + Creates a new Coupon + """ + createCoupon( + """ + Parameters for CreateCoupon + """ + input: CreateCouponInput! + ): Coupon + + """ + Creates a new Credit Note + """ + createCreditNote( + """ + Parameters for CreateCreditNote + """ + input: CreateCreditNoteInput! + ): CreditNote + + """ + Request data export of credit notes + """ + createCreditNotesDataExport( + """ + Parameters for CreateCreditNotesDataExport + """ + input: CreateDataExportsCreditNotesInput! + ): DataExport + + """ + Creates a new customer + """ + createCustomer( + """ + Parameters for CreateCustomer + """ + input: CreateCustomerInput! + ): Customer + + """ + Creates a new Customer Wallet Transaction from Customer Portal + """ + createCustomerPortalWalletTransaction( + """ + Parameters for CreateCustomerPortalWalletTransaction + """ + input: CreateCustomerPortalWalletTransactionInput! + ): CustomerPortalWalletTransactionCollection + + """ + Creates a new Customer Wallet + """ + createCustomerWallet( + """ + Parameters for CreateCustomerWallet + """ + input: CreateCustomerWalletInput! + ): Wallet + + """ + Creates a new Alert for wallet + """ + createCustomerWalletAlert( + """ + Parameters for CreateCustomerWalletAlert + """ + input: CreateCustomerWalletAlertInput! + ): Alert + + """ + Creates a new Customer Wallet Transaction + """ + createCustomerWalletTransaction( + """ + Parameters for CreateCustomerWalletTransaction + """ + input: CreateCustomerWalletTransactionInput! + ): WalletTransactionCollection + + """ + Creates a new dunning campaign + """ + createDunningCampaign( + """ + Parameters for CreateDunningCampaign + """ + input: CreateDunningCampaignInput! + ): DunningCampaign + + """ + Creates a new feature + """ + createFeature( + """ + Parameters for CreateFeature + """ + input: CreateFeatureInput! + ): FeatureObject + + """ + Creates a new Fixed Charge for a Plan + """ + createFixedCharge( + """ + Parameters for CreateFixedCharge + """ + input: FixedChargeCreateInput! + ): FixedCharge + + """ + Create Hubspot integration + """ + createHubspotIntegration( + """ + Parameters for CreateHubspotIntegration + """ + input: CreateHubspotIntegrationInput! + ): HubspotIntegration + + """ + Create integration collection mapping + """ + createIntegrationCollectionMapping( + """ + Parameters for CreateIntegrationCollectionMapping + """ + input: CreateIntegrationCollectionMappingInput! + ): CollectionMapping + + """ + Create integration mapping + """ + createIntegrationMapping( + """ + Parameters for CreateIntegrationMapping + """ + input: CreateIntegrationMappingInput! + ): Mapping + + """ + Creates a new Invite + """ + createInvite( + """ + Parameters for CreateInvite + """ + input: CreateInviteInput! + ): Invite + + """ + Creates a new Invoice + """ + createInvoice( + """ + Parameters for CreateInvoice + """ + input: CreateInvoiceInput! + ): Invoice + + """ + Creates a new InvoiceCustomSection + """ + createInvoiceCustomSection( + """ + Parameters for CreateInvoiceCustomSection + """ + input: CreateInvoiceCustomSectionInput! + ): InvoiceCustomSection + + """ + Request data export of invoices + """ + createInvoicesDataExport( + """ + Parameters for CreateInvoicesDataExport + """ + input: CreateDataExportsInvoicesInput! + ): DataExport + + """ + Create Netsuite integration + """ + createNetsuiteIntegration( + """ + Parameters for CreateNetsuiteIntegration + """ + input: CreateNetsuiteIntegrationInput! + ): NetsuiteIntegration + + """ + Create Okta integration + """ + createOktaIntegration( + """ + Parameters for CreateOktaIntegration + """ + input: CreateOktaIntegrationInput! + ): OktaIntegration + + """ + Updates a subscription entitlement + """ + createOrUpdateSubscriptionEntitlement( + """ + Parameters for CreateOrUpdateSubscriptionEntitlement + """ + input: CreateOrUpdateSubscriptionEntitlementInput! + ): SubscriptionEntitlement + + """ + Creates a new password reset + """ + createPasswordReset( + """ + Parameters for CreatePasswordReset + """ + input: CreatePasswordResetInput! + ): CreatePasswordResetPayload + + """ + Creates a manual payment + """ + createPayment( + """ + Parameters for CreatePayment + """ + input: CreatePaymentInput! + ): Payment + + """ + Creates a payment request + """ + createPaymentRequest( + """ + Parameters for CreatePaymentRequest + """ + input: PaymentRequestCreateInput! + ): PaymentRequest + + """ + Creates a new Plan + """ + createPlan( + """ + Parameters for CreatePlan + """ + input: CreatePlanInput! + ): Plan + + """ + Creates a new pricing unit + """ + createPricingUnit( + """ + Parameters for CreatePricingUnit + """ + input: CreatePricingUnitInput! + ): PricingUnit + + """ + Create a new quote + """ + createQuote( + """ + Parameters for CreateQuote + """ + input: CreateQuoteInput! + ): Quote + + """ + Creates a new custom role + """ + createRole( + """ + Parameters for CreateRole + """ + input: CreateRoleInput! + ): Role + + """ + Create Salesforce integration + """ + createSalesforceIntegration( + """ + Parameters for CreateSalesforceIntegration + """ + input: CreateSalesforceIntegrationInput! + ): SalesforceIntegration + + """ + Create a new Subscription + """ + createSubscription( + """ + Parameters for CreateSubscription + """ + input: CreateSubscriptionInput! + ): Subscription + + """ + Creates a new Alert for subscription + """ + createSubscriptionAlert( + """ + Parameters for CreateSubscriptionAlert + """ + input: CreateSubscriptionAlertInput! + ): Alert + + """ + Create a charge filter for a subscription + """ + createSubscriptionChargeFilter( + """ + Parameters for CreateSubscriptionChargeFilter + """ + input: CreateSubscriptionChargeFilterInput! + ): ChargeFilter + + """ + Creates a tax + """ + createTax( + """ + Parameters for CreateTax + """ + input: TaxCreateInput! + ): Tax + + """ + Create a new webhook endpoint + """ + createWebhookEndpoint( + """ + Parameters for CreateWebhookEndpoint + """ + input: WebhookEndpointCreateInput! + ): WebhookEndpoint + + """ + Create Xero integration + """ + createXeroIntegration( + """ + Parameters for CreateXeroIntegration + """ + input: CreateXeroIntegrationInput! + ): XeroIntegration + + """ + Deletes an add-on + """ + destroyAddOn( + """ + Parameters for DestroyAddOn + """ + input: DestroyAddOnInput! + ): DestroyAddOnPayload + + """ + Deletes an adjusted fee + """ + destroyAdjustedFee( + """ + Parameters for DestroyAdjustedFee + """ + input: DestroyAdjustedFeeInput! + ): DestroyAdjustedFeePayload + + """ + Deletes an API key + """ + destroyApiKey( + """ + Parameters for DestroyApiKey + """ + input: DestroyApiKeyInput! + ): ApiKey + + """ + Deletes a Billable metric + """ + destroyBillableMetric( + """ + Parameters for DestroyBillableMetric + """ + input: DestroyBillableMetricInput! + ): DestroyBillableMetricPayload + + """ + Destroys a new Billing Entity + """ + destroyBillingEntity( + """ + Parameters for DestroyBillingEntity + """ + input: DestroyBillingEntityInput! + ): DestroyBillingEntityPayload + + """ + Deletes a Charge + """ + destroyCharge( + """ + Parameters for DestroyCharge + """ + input: DestroyChargeInput! + ): DestroyChargePayload + + """ + Deletes a Charge Filter + """ + destroyChargeFilter( + """ + Parameters for DestroyChargeFilter + """ + input: DestroyChargeFilterInput! + ): DestroyChargeFilterPayload + + """ + Deletes a coupon + """ + destroyCoupon( + """ + Parameters for DestroyCoupon + """ + input: DestroyCouponInput! + ): DestroyCouponPayload + + """ + Delete a Customer + """ + destroyCustomer( + """ + Parameters for DestroyCustomer + """ + input: DestroyCustomerInput! + ): DestroyCustomerPayload + + """ + Deletes an alert + """ + destroyCustomerWalletAlert( + """ + Parameters for DestroyCustomerWalletAlert + """ + input: DestroyCustomerWalletAlertInput! + ): Alert + + """ + Deletes a dunning campaign + """ + destroyDunningCampaign( + """ + Parameters for DestroyDunningCampaign + """ + input: DestroyDunningCampaignInput! + ): DestroyDunningCampaignPayload + + """ + Destroys an existing feature + """ + destroyFeature( + """ + Parameters for DestroyFeature + """ + input: DestroyFeatureInput! + ): FeatureObject + + """ + Deletes a Fixed Charge + """ + destroyFixedCharge( + """ + Parameters for DestroyFixedCharge + """ + input: DestroyFixedChargeInput! + ): DestroyFixedChargePayload + + """ + Destroy an integration + """ + destroyIntegration( + """ + Parameters for DestroyIntegration + """ + input: DestroyIntegrationInput! + ): DestroyIntegrationPayload + + """ + Destroy an integration collection mapping + """ + destroyIntegrationCollectionMapping( + """ + Parameters for DestroyIntegrationCollectionMapping + """ + input: DestroyIntegrationCollectionMappingInput! + ): DestroyIntegrationCollectionMappingPayload + + """ + Destroy an integration mapping + """ + destroyIntegrationMapping( + """ + Parameters for DestroyIntegrationMapping + """ + input: DestroyIntegrationMappingInput! + ): DestroyIntegrationMappingPayload + + """ + Deletes an invoice_custom_section + """ + destroyInvoiceCustomSection( + """ + Parameters for DestroyInvoiceCustomSection + """ + input: DestroyInvoiceCustomSectionInput! + ): DestroyInvoiceCustomSectionPayload + + """ + Deletes a payment method + """ + destroyPaymentMethod( + """ + Parameters for DestroyPaymentMethod + """ + input: DestroyPaymentMethodInput! + ): DestroyPaymentMethodPayload + + """ + Destroy a payment provider + """ + destroyPaymentProvider( + """ + Parameters for DestroyPaymentProvider + """ + input: DestroyPaymentProviderInput! + ): DestroyPaymentProviderPayload + + """ + Deletes a Plan + """ + destroyPlan( + """ + Parameters for DestroyPlan + """ + input: DestroyPlanInput! + ): DestroyPlanPayload + + """ + Deletes a custom role + """ + destroyRole( + """ + Parameters for DestroyRole + """ + input: DestroyRoleInput! + ): Role + + """ + Deletes an alert + """ + destroySubscriptionAlert( + """ + Parameters for DestroySubscriptionAlert + """ + input: DestroySubscriptionAlertInput! + ): Alert + + """ + Destroy a charge filter for a subscription + """ + destroySubscriptionChargeFilter( + """ + Parameters for DestroySubscriptionChargeFilter + """ + input: DestroySubscriptionChargeFilterInput! + ): ChargeFilter + + """ + Deletes a tax + """ + destroyTax( + """ + Parameters for DestroyTax + """ + input: DestroyTaxInput! + ): DestroyTaxPayload + + """ + Deletes a webhook endpoint + """ + destroyWebhookEndpoint( + """ + Parameters for DestroyWebhookEndpoint + """ + input: DestroyWebhookEndpointInput! + ): DestroyWebhookEndpointPayload + + """ + Download a Credit Note PDF + """ + downloadCreditNote( + """ + Parameters for DownloadCreditNote + """ + input: DownloadCreditNoteInput! + ): CreditNote + + """ + Download customer portal invoice PDF + """ + downloadCustomerPortalInvoice( + """ + Parameters for DownloadCustomerPortalInvoice + """ + input: DownloadCustomerPortalInvoiceInput! + ): Invoice + + """ + Download an Invoice PDF + """ + downloadInvoice( + """ + Parameters for DownloadInvoice + """ + input: DownloadInvoiceInput! + ): Invoice + + """ + Download an Invoice XML + """ + downloadInvoiceXml( + """ + Parameters for DownloadXmlInvoice + """ + input: DownloadXmlInvoiceInput! + ): Invoice + + """ + Download an PaymentReceipt PDF + """ + downloadPaymentReceipt( + """ + Parameters for DownloadPaymentReceipt + """ + input: DownloadPaymentReceiptInput! + ): PaymentReceipt + + """ + Download a Credit Note XML + """ + downloadXmlCreditNote( + """ + Parameters for DownloadXmlCreditNote + """ + input: DownloadXmlCreditNoteInput! + ): CreditNote + + """ + Download an PaymentReceipt XML + """ + downloadXmlPaymentReceipt( + """ + Parameters for DownloadXMLPaymentReceipt + """ + input: DownloadXMLPaymentReceiptInput! + ): PaymentReceipt + + """ + Fetches taxes for one-off invoice + """ + fetchDraftInvoiceTaxes( + """ + Parameters for FetchDraftInvoiceTaxes + """ + input: FetchDraftInvoiceTaxesInput! + ): TaxFeeObjectCollection + + """ + Fetch integration accounts + """ + fetchIntegrationAccounts( + """ + Parameters for FetchIntegrationAccounts + """ + input: FetchIntegrationAccountsInput! + ): IntegrationItemCollection! + + """ + Fetch integration items + """ + fetchIntegrationItems( + """ + Parameters for FetchIntegrationItems + """ + input: FetchIntegrationItemsInput! + ): IntegrationItemCollection! + + """ + Finalize all draft invoices + """ + finalizeAllInvoices( + """ + Parameters for FinalizeAllInvoices + """ + input: FinalizeAllInvoicesInput! + ): InvoiceCollection + + """ + Finalize a draft invoice + """ + finalizeInvoice( + """ + Parameters for FinalizeInvoice + """ + input: FinalizeInvoiceInput! + ): Invoice + + """ + Generates checkout url for payment method + """ + generateCheckoutUrl( + """ + Parameters for GenerateCheckoutUrl + """ + input: GenerateCheckoutUrlInput! + ): GenerateCheckoutUrlPayload + + """ + Generate customer portal URL + """ + generateCustomerPortalUrl( + """ + Parameters for GenerateCustomerPortalUrl + """ + input: GenerateCustomerPortalUrlInput! + ): GenerateCustomerPortalUrlPayload + + """ + Generates a payment URL for an invoice + """ + generatePaymentUrl( + """ + Parameters for GeneratePaymentUrl + """ + input: GeneratePaymentUrlInput! + ): GeneratePaymentUrlPayload + + """ + Accepts a membership invite with Google Oauth + """ + googleAcceptInvite( + """ + Parameters for GoogleAcceptInvite + """ + input: GoogleAcceptInviteInput! + ): RegisterUser + + """ + Opens a session for an existing user with Google Oauth + """ + googleLoginUser( + """ + Parameters for GoogleLoginUser + """ + input: GoogleLoginUserInput! + ): LoginUser + + """ + Register a new user with Google Oauth + """ + googleRegisterUser( + """ + Parameters for GoogleRegisterUser + """ + input: GoogleRegisterUserInput! + ): RegisterUser + + """ + Opens a session for an existing user + """ + loginUser( + """ + Parameters for LoginUser + """ + input: LoginUserInput! + ): LoginUser + + """ + Mark payment dispute as lost + """ + loseInvoiceDispute( + """ + Parameters for LoseInvoiceDispute + """ + input: LoseInvoiceDisputeInput! + ): Invoice + + """ + Accepts a membership invite with Okta Oauth + """ + oktaAcceptInvite( + """ + Parameters for OktaAcceptInvite + """ + input: OktaAcceptInviteInput! + ): LoginUser + oktaAuthorize( + """ + Parameters for OktaAuthorize + """ + input: OktaAuthorizeInput! + ): Authorize + oktaLogin( + """ + Parameters for OktaLogin + """ + input: OktaLoginInput! + ): LoginUser + + """ + Preview Adjusted Fee + """ + previewAdjustedFee( + """ + Parameters for PreviewAdjustedFee + """ + input: PreviewAdjustedFeeInput! + ): Fee + + """ + Refresh a draft invoice + """ + refreshInvoice( + """ + Parameters for RefreshInvoice + """ + input: RefreshInvoiceInput! + ): Invoice + + """ + Regenerate an invoice from a voided invoice + """ + regenerateFromVoided( + """ + Parameters for RegenerateInvoice + """ + input: RegenerateInvoiceInput! + ): Invoice + + """ + Registers a new user and creates related organization + """ + registerUser( + """ + Parameters for RegisterUser + """ + input: RegisterUserInput! + ): RegisterUser + + """ + Removes a feature entitlement from a subscription + """ + removeSubscriptionEntitlement( + """ + Parameters for RemoveSubscriptionEntitlement + """ + input: RemoveSubscriptionEntitlementInput! + ): RemoveSubscriptionEntitlementPayload + + """ + Resend credit note email with optional custom recipients + """ + resendCreditNoteEmail( + """ + Parameters for ResendCreditNoteEmail + """ + input: ResendCreditNoteEmailInput! + ): CreditNote + + """ + Resend invoice email with optional custom recipients + """ + resendInvoiceEmail( + """ + Parameters for ResendInvoiceEmail + """ + input: ResendInvoiceEmailInput! + ): Invoice + + """ + Resend payment receipt email with optional custom recipients + """ + resendPaymentReceiptEmail( + """ + Parameters for ResendPaymentReceiptEmail + """ + input: ResendPaymentReceiptEmailInput! + ): PaymentReceipt + + """ + Reset password for user and log in + """ + resetPassword( + """ + Parameters for ResetPassword + """ + input: ResetPasswordInput! + ): LoginUser + + """ + Retry all invoice payments + """ + retryAllInvoicePayments( + """ + Parameters for RetryAllInvoicePayments + """ + input: RetryAllInvoicePaymentsInput! + ): InvoiceCollection + + """ + Retry all failed invoices + """ + retryAllInvoices( + """ + Parameters for RetryAllInvoices + """ + input: RetryAllInvoicesInput! + ): InvoiceCollection + + """ + Retry failed invoice + """ + retryInvoice( + """ + Parameters for RetryInvoice + """ + input: RetryInvoiceInput! + ): Invoice + + """ + Retry invoice payment + """ + retryInvoicePayment( + """ + Parameters for RetryInvoicePayment + """ + input: RetryInvoicePaymentInput! + ): Invoice + + """ + Retry voided invoice sync + """ + retryTaxProviderVoiding( + """ + Parameters for RetryTaxProviderVoiding + """ + input: RetryTaxProviderVoidingInput! + ): Invoice + + """ + Retry tax reporting + """ + retryTaxReporting( + """ + Parameters for RetryTaxReporting + """ + input: RetryTaxReportingInput! + ): CreditNote + + """ + Retry a Webhook + """ + retryWebhook( + """ + Parameters for RetryWebhook + """ + input: RetryWebhookInput! + ): Webhook + + """ + Revokes an invite + """ + revokeInvite( + """ + Parameters for RevokeInvite + """ + input: RevokeInviteInput! + ): Invite + + """ + Revoke a membership + """ + revokeMembership( + """ + Parameters for RevokeMembership + """ + input: RevokeMembershipInput! + ): Membership + + """ + Create new ApiKey while expiring provided + """ + rotateApiKey( + """ + Parameters for RotateApiKey + """ + input: RotateApiKeyInput! + ): ApiKey + + """ + Set payment method as default + """ + setPaymentMethodAsDefault( + """ + Parameters for SetAsDefault + """ + input: SetAsDefaultInput! + ): PaymentMethod + + """ + Sync hubspot integration invoice + """ + syncHubspotIntegrationInvoice( + """ + Parameters for SyncHubspotInvoice + """ + input: SyncHubspotIntegrationInvoiceInput! + ): SyncHubspotInvoicePayload + + """ + Sync integration credit note + """ + syncIntegrationCreditNote( + """ + Parameters for SyncIntegrationCreditNote + """ + input: SyncIntegrationCreditNoteInput! + ): SyncIntegrationCreditNotePayload + + """ + Sync integration invoice + """ + syncIntegrationInvoice( + """ + Parameters for SyncIntegrationInvoice + """ + input: SyncIntegrationInvoiceInput! + ): SyncIntegrationInvoicePayload + + """ + Sync Salesforce integration invoice + """ + syncSalesforceInvoice( + """ + Parameters for SyncSalesforceInvoice + """ + input: SyncSalesforceInvoiceInput! + ): SyncSalesforceInvoicePayload + + """ + Unassign a coupon from a customer + """ + terminateAppliedCoupon( + """ + Parameters for TerminateAppliedCoupon + """ + input: TerminateAppliedCouponInput! + ): AppliedCoupon + + """ + Deletes a coupon + """ + terminateCoupon( + """ + Parameters for TerminateCoupon + """ + input: TerminateCouponInput! + ): Coupon + + """ + Terminates a new Customer Wallet + """ + terminateCustomerWallet( + """ + Parameters for TerminateCustomerWallet + """ + input: TerminateCustomerWalletInput! + ): Wallet + + """ + Terminate a Subscription + """ + terminateSubscription( + """ + Parameters for TerminateSubscription + """ + input: TerminateSubscriptionInput! + ): Subscription + + """ + Update an existing add-on + """ + updateAddOn( + """ + Parameters for UpdateAddOn + """ + input: UpdateAddOnInput! + ): AddOn + + """ + Update Adyen payment provider + """ + updateAdyenPaymentProvider( + """ + Parameters for UpdateAdyenPaymentProvider + """ + input: UpdateAdyenPaymentProviderInput! + ): AdyenProvider + + """ + Update Anrok integration + """ + updateAnrokIntegration( + """ + Parameters for UpdateAnrokIntegration + """ + input: UpdateAnrokIntegrationInput! + ): AnrokIntegration + updateApiKey( + """ + Parameters for UpdateApiKey + """ + input: UpdateApiKeyInput! + ): ApiKey + + """ + Update Avalara integration + """ + updateAvalaraIntegration( + """ + Parameters for UpdateAvalaraIntegration + """ + input: UpdateAvalaraIntegrationInput! + ): AvalaraIntegration + + """ + Updates an existing Billable metric + """ + updateBillableMetric( + """ + Parameters for UpdateBillableMetric + """ + input: UpdateBillableMetricInput! + ): BillableMetric + + """ + Updates a Billing Entity + """ + updateBillingEntity( + """ + Parameters for UpdateBillingEntity + """ + input: UpdateBillingEntityInput! + ): BillingEntity + + """ + Update Cashfree payment provider + """ + updateCashfreePaymentProvider( + """ + Parameters for UpdateCashfreePaymentProvider + """ + input: UpdateCashfreePaymentProviderInput! + ): CashfreeProvider + + """ + Updates an existing Charge + """ + updateCharge( + """ + Parameters for UpdateCharge + """ + input: ChargeUpdateInput! + ): Charge + + """ + Updates an existing Charge Filter + """ + updateChargeFilter( + """ + Parameters for UpdateChargeFilter + """ + input: ChargeFilterUpdateInput! + ): ChargeFilter + + """ + Update an existing coupon + """ + updateCoupon( + """ + Parameters for UpdateCoupon + """ + input: UpdateCouponInput! + ): Coupon + + """ + Updates an existing Credit Note + """ + updateCreditNote( + """ + Parameters for UpdateCreditNote + """ + input: UpdateCreditNoteInput! + ): CreditNote + + """ + Updates an existing Customer + """ + updateCustomer( + """ + Parameters for UpdateCustomer + """ + input: UpdateCustomerInput! + ): Customer + + """ + Assign the invoice grace period to Customers + """ + updateCustomerInvoiceGracePeriod( + """ + Parameters for UpdateCustomerInvoiceGracePeriod + """ + input: UpdateCustomerInvoiceGracePeriodInput! + ): Customer + + """ + Update customer data from Customer Portal + """ + updateCustomerPortalCustomer( + """ + Parameters for UpdateCustomerPortalCustomer + """ + input: UpdateCustomerPortalCustomerInput! + ): CustomerPortalCustomer + + """ + Updates a new Customer Wallet + """ + updateCustomerWallet( + """ + Parameters for UpdateCustomerWallet + """ + input: UpdateCustomerWalletInput! + ): Wallet + + """ + Updates an alert + """ + updateCustomerWalletAlert( + """ + Parameters for UpdateCustomerWalletAlert + """ + input: UpdateCustomerWalletAlertInput! + ): Alert + + """ + Updates a dunning campaign and its thresholds + """ + updateDunningCampaign( + """ + Parameters for UpdateDunningCampaign + """ + input: UpdateDunningCampaignInput! + ): DunningCampaign + + """ + Updates an existing feature + """ + updateFeature( + """ + Parameters for UpdateFeature + """ + input: UpdateFeatureInput! + ): FeatureObject + + """ + Updates an existing Fixed Charge + """ + updateFixedCharge( + """ + Parameters for UpdateFixedCharge + """ + input: FixedChargeUpdateInput! + ): FixedCharge + + """ + Update Flutterwave payment provider + """ + updateFlutterwavePaymentProvider( + """ + Parameters for UpdateFlutterwavePaymentProvider + """ + input: UpdateFlutterwavePaymentProviderInput! + ): FlutterwaveProvider + + """ + Update Gocardless payment provider + """ + updateGocardlessPaymentProvider( + """ + Parameters for UpdateGocardlessPaymentProvider + """ + input: UpdateGocardlessPaymentProviderInput! + ): GocardlessProvider + + """ + Update Hubspot integration + """ + updateHubspotIntegration( + """ + Parameters for UpdateHubspotIntegration + """ + input: UpdateHubspotIntegrationInput! + ): HubspotIntegration + + """ + Update integration mapping + """ + updateIntegrationCollectionMapping( + """ + Parameters for UpdateIntegrationCollectionMapping + """ + input: UpdateIntegrationCollectionMappingInput! + ): CollectionMapping + + """ + Update integration mapping + """ + updateIntegrationMapping( + """ + Parameters for UpdateIntegrationMapping + """ + input: UpdateIntegrationMappingInput! + ): Mapping + + """ + Update an invite + """ + updateInvite( + """ + Parameters for UpdateInvite + """ + input: UpdateInviteInput! + ): Invite + + """ + Update an existing invoice + """ + updateInvoice( + """ + Parameters for UpdateInvoice + """ + input: UpdateInvoiceInput! + ): Invoice + + """ + Updates an InvoiceCustomSection + """ + updateInvoiceCustomSection( + """ + Parameters for UpdateInvoiceCustomSection + """ + input: UpdateInvoiceCustomSectionInput! + ): InvoiceCustomSection + + """ + Update a membership + """ + updateMembership( + """ + Parameters for UpdateMembership + """ + input: UpdateMembershipInput! + ): Membership + + """ + Update Moneyhash payment provider + """ + updateMoneyhashPaymentProvider( + """ + Parameters for UpdateMoneyhashPaymentProvider + """ + input: UpdateMoneyhashPaymentProviderInput! + ): MoneyhashProvider + + """ + Update Netsuite integration + """ + updateNetsuiteIntegration( + """ + Parameters for UpdateNetsuiteIntegration + """ + input: UpdateNetsuiteIntegrationInput! + ): NetsuiteIntegration + + """ + Update Okta integration + """ + updateOktaIntegration( + """ + Parameters for UpdateOktaIntegration + """ + input: UpdateOktaIntegrationInput! + ): OktaIntegration + + """ + Updates an Organization + """ + updateOrganization( + """ + Parameters for UpdateOrganization + """ + input: UpdateOrganizationInput! + ): CurrentOrganization + + """ + Updates an existing Plan + """ + updatePlan( + """ + Parameters for UpdatePlan + """ + input: UpdatePlanInput! + ): Plan + updatePricingUnit( + """ + Parameters for UpdatePricingUnit + """ + input: UpdatePricingUnitInput! + ): PricingUnit + + """ + Update a quote + """ + updateQuote( + """ + Parameters for UpdateQuote + """ + input: UpdateQuoteInput! + ): Quote + + """ + Update a quote version + """ + updateQuoteVersion( + """ + Parameters for UpdateQuoteVersion + """ + input: UpdateQuoteVersionInput! + ): QuoteVersion + + """ + Updates an existing custom role + """ + updateRole( + """ + Parameters for UpdateRole + """ + input: UpdateRoleInput! + ): Role + + """ + Update Salesforce integration + """ + updateSalesforceIntegration( + """ + Parameters for UpdateSalesforceIntegration + """ + input: UpdateSalesforceIntegrationInput! + ): SalesforceIntegration + + """ + Update Stripe payment provider + """ + updateStripePaymentProvider( + """ + Parameters for UpdateStripePaymentProvider + """ + input: UpdateStripePaymentProviderInput! + ): StripeProvider + + """ + Update a Subscription + """ + updateSubscription( + """ + Parameters for UpdateSubscription + """ + input: UpdateSubscriptionInput! + ): Subscription + + """ + Updates an alert + """ + updateSubscriptionAlert( + """ + Parameters for UpdateSubscriptionAlert + """ + input: UpdateSubscriptionAlertInput! + ): Alert + + """ + Update a charge for a subscription + """ + updateSubscriptionCharge( + """ + Parameters for UpdateSubscriptionCharge + """ + input: UpdateSubscriptionChargeInput! + ): Charge + + """ + Update a charge filter for a subscription + """ + updateSubscriptionChargeFilter( + """ + Parameters for UpdateSubscriptionChargeFilter + """ + input: UpdateSubscriptionChargeFilterInput! + ): ChargeFilter + + """ + Update a fixed charge for a subscription + """ + updateSubscriptionFixedCharge( + """ + Parameters for UpdateSubscriptionFixedCharge + """ + input: UpdateSubscriptionFixedChargeInput! + ): FixedCharge + + """ + Update an existing tax + """ + updateTax( + """ + Parameters for UpdateTax + """ + input: TaxUpdateInput! + ): Tax + + """ + Update a new webhook endpoint + """ + updateWebhookEndpoint( + """ + Parameters for UpdateWebhookEndpoint + """ + input: WebhookEndpointUpdateInput! + ): WebhookEndpoint + + """ + Update Xero integration + """ + updateXeroIntegration( + """ + Parameters for UpdateXeroIntegration + """ + input: UpdateXeroIntegrationInput! + ): XeroIntegration + + """ + Voids a Credit Note + """ + voidCreditNote( + """ + Parameters for VoidCreditNote + """ + input: VoidCreditNoteInput! + ): CreditNote + + """ + Void an invoice + """ + voidInvoice( + """ + Parameters for VoidInvoice + """ + input: VoidInvoiceInput! + ): Invoice + + """ + Void a quote version + """ + voidQuoteVersion( + """ + Parameters for VoidQuoteVersion + """ + input: VoidQuoteVersionInput! + ): QuoteVersion +} + +type NetsuiteCustomer { + externalCustomerId: String + id: ID! + integrationCode: String + integrationId: ID + integrationType: IntegrationTypeEnum + subsidiaryId: String + syncWithProvider: Boolean +} + +type NetsuiteIntegration { + accountId: String + clientId: String + clientSecret: ObfuscatedString + code: String! + connectionId: ID! + hasMappingsConfigured: Boolean + id: ID! + name: String! + scriptEndpointUrl: String! + syncCreditNotes: Boolean + syncInvoices: Boolean + syncPayments: Boolean + tokenId: String + tokenSecret: ObfuscatedString +} + +enum NextSubscriptionTypeEnum { + downgrade + upgrade +} + +scalar ObfuscatedString + +""" +Accept Invite with Okta Oauth input arguments +""" +input OktaAcceptInviteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + inviteToken: String! + state: String! +} + +""" +Autogenerated input type of OktaAuthorize +""" +input OktaAuthorizeInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String! + inviteToken: String +} + +type OktaIntegration { + clientId: String + clientSecret: ObfuscatedString + code: String! + domain: String! + host: String + id: ID! + name: String! + organizationName: String! +} + +""" +Autogenerated input type of OktaLogin +""" +input OktaLoginInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + state: String! +} + +enum OnTerminationCreditNoteEnum { + credit + offset + refund + skip +} + +enum OnTerminationInvoiceEnum { + generate + skip +} + +enum OrderByEnum { + gross_revenue_amount_cents + net_revenue_amount_cents +} + +enum OrderTypeEnum { + one_off + subscription_amendment + subscription_creation +} + +""" +Safe Organization Type +""" +type Organization { + accessibleByCurrentSession: Boolean! + billingConfiguration: OrganizationBillingConfiguration + canCreateBillingEntity: Boolean! + defaultCurrency: CurrencyEnum! + id: ID! + logoUrl: String + name: String! + slug: String! + timezone: TimezoneEnum +} + +type OrganizationBillingConfiguration { + documentLocale: String + id: ID! + invoiceFooter: String + invoiceGracePeriod: Int! +} + +input OrganizationBillingConfigurationInput { + documentLocale: String + invoiceFooter: String + invoiceGracePeriod: Int +} + +type OverdueBalance { + amountCents: BigInt! + currency: CurrencyEnum! + lagoInvoiceIds: [String!]! + month: ISO8601DateTime! +} + +""" +OverdueBalanceCollection type +""" +type OverdueBalanceCollection { + """ + A collection of paginated OverdueBalanceCollection + """ + collection: [OverdueBalance!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +union Payable = Invoice | PaymentRequest + +enum PayablePaymentStatusEnum { + failed + pending + processing + succeeded +} + +type Payment { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + createdAt: ISO8601DateTime! + customer: Customer! + id: ID! + payable: Payable! + payablePaymentStatus: PayablePaymentStatusEnum + paymentMethodId: ID + paymentProvider: PaymentProvider + paymentProviderType: ProviderTypeEnum + paymentReceipt: PaymentReceipt + paymentType: PaymentTypeEnum! + providerPaymentId: String + reference: String + updatedAt: ISO8601DateTime +} + +""" +PaymentCollection type +""" +type PaymentCollection { + """ + A collection of paginated PaymentCollection + """ + collection: [Payment!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type PaymentMethod { + createdAt: ISO8601DateTime! + customer: Customer! + deletedAt: ISO8601DateTime + details: PaymentMethodDetails + id: ID! + isDefault: Boolean! + paymentProviderCode: String + paymentProviderCustomerId: ID + paymentProviderName: String + paymentProviderType: ProviderTypeEnum + providerMethodId: String! + updatedAt: ISO8601DateTime +} + +""" +PaymentMethodCollection type +""" +type PaymentMethodCollection { + """ + A collection of paginated PaymentMethodCollection + """ + collection: [PaymentMethod!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type PaymentMethodDetails { + brand: String + expirationMonth: String + expirationYear: String + last4: String + type: String +} + +input PaymentMethodReferenceInput { + paymentMethodId: ID + paymentMethodType: PaymentMethodTypeEnum +} + +enum PaymentMethodTypeEnum { + manual + provider +} + +union PaymentProvider = AdyenProvider | CashfreeProvider | FlutterwaveProvider | GocardlessProvider | MoneyhashProvider | StripeProvider + +""" +PaymentProviderCollection type +""" +type PaymentProviderCollection { + """ + A collection of paginated PaymentProviderCollection + """ + collection: [PaymentProvider!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +PaymentReceipt +""" +type PaymentReceipt { + createdAt: ISO8601DateTime! + fileUrl: String + id: ID! + number: String! + organization: Organization! + payment: Payment! + xmlUrl: String +} + +type PaymentRequest { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + createdAt: ISO8601DateTime! + customer: Customer! + email: String! + id: ID! + invoices: [Invoice!]! + payableType: String! + paymentStatus: InvoicePaymentStatusTypeEnum! + updatedAt: ISO8601DateTime! +} + +""" +PaymentRequestCollection type +""" +type PaymentRequestCollection { + """ + A collection of paginated PaymentRequestCollection + """ + collection: [PaymentRequest!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Autogenerated input type of CreatePaymentRequest +""" +input PaymentRequestCreateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String + externalCustomerId: String! + lagoInvoiceIds: [String!] + paymentMethod: PaymentMethodReferenceInput +} + +enum PaymentTypeEnum { + manual + provider +} + +""" +Permission +""" +enum PermissionEnum { + addons_create + addons_delete + addons_update + addons_view + ai_conversations_create + ai_conversations_view + analytics_view + audit_logs_view + authentication_methods_update + authentication_methods_view + billable_metrics_create + billable_metrics_delete + billable_metrics_update + billable_metrics_view + billing_entities_create + billing_entities_delete + billing_entities_update + billing_entities_view + charges_create + charges_delete + charges_update + coupons_attach + coupons_create + coupons_delete + coupons_detach + coupons_update + coupons_view + credit_notes_create + credit_notes_export + credit_notes_send + credit_notes_update + credit_notes_view + credit_notes_void + customers_create + customers_delete + customers_update + customers_view + data_api_view + developers_keys_manage + developers_manage + dunning_campaigns_create + dunning_campaigns_delete + dunning_campaigns_update + dunning_campaigns_view + features_create + features_delete + features_update + features_view + invoice_custom_sections_create + invoice_custom_sections_delete + invoice_custom_sections_update + invoice_custom_sections_view + invoices_create + invoices_export + invoices_send + invoices_update + invoices_view + invoices_void + organization_emails_update + organization_emails_view + organization_integrations_create + organization_integrations_delete + organization_integrations_update + organization_integrations_view + organization_invoices_update + organization_invoices_view + organization_members_create + organization_members_delete + organization_members_update + organization_members_view + organization_taxes_update + organization_taxes_view + organization_update + organization_view + payment_methods_create + payment_methods_delete + payment_methods_update + payment_methods_view + payment_receipts_send + payment_receipts_view + payments_create + payments_view + plans_create + plans_delete + plans_update + plans_view + pricing_units_create + pricing_units_update + pricing_units_view + quotes_approve + quotes_clone + quotes_create + quotes_update + quotes_view + quotes_void + roles_create + roles_delete + roles_update + roles_view + security_logs_view + subscriptions_create + subscriptions_update + subscriptions_view + wallets_create + wallets_terminate + wallets_top_up + wallets_update +} + +""" +Permissions Type +""" +type Permissions { + addonsCreate: Boolean! + addonsDelete: Boolean! + addonsUpdate: Boolean! + addonsView: Boolean! + aiConversationsCreate: Boolean! + aiConversationsView: Boolean! + analyticsView: Boolean! + auditLogsView: Boolean! + authenticationMethodsUpdate: Boolean! + authenticationMethodsView: Boolean! + billableMetricsCreate: Boolean! + billableMetricsDelete: Boolean! + billableMetricsUpdate: Boolean! + billableMetricsView: Boolean! + billingEntitiesCreate: Boolean! + billingEntitiesDelete: Boolean! + billingEntitiesUpdate: Boolean! + billingEntitiesView: Boolean! + chargesCreate: Boolean! + chargesDelete: Boolean! + chargesUpdate: Boolean! + couponsAttach: Boolean! + couponsCreate: Boolean! + couponsDelete: Boolean! + couponsDetach: Boolean! + couponsUpdate: Boolean! + couponsView: Boolean! + creditNotesCreate: Boolean! + creditNotesExport: Boolean! + creditNotesSend: Boolean! + creditNotesUpdate: Boolean! + creditNotesView: Boolean! + creditNotesVoid: Boolean! + customersCreate: Boolean! + customersDelete: Boolean! + customersUpdate: Boolean! + customersView: Boolean! + dataApiView: Boolean! + developersKeysManage: Boolean! + developersManage: Boolean! + dunningCampaignsCreate: Boolean! + dunningCampaignsDelete: Boolean! + dunningCampaignsUpdate: Boolean! + dunningCampaignsView: Boolean! + featuresCreate: Boolean! + featuresDelete: Boolean! + featuresUpdate: Boolean! + featuresView: Boolean! + invoiceCustomSectionsCreate: Boolean! + invoiceCustomSectionsDelete: Boolean! + invoiceCustomSectionsUpdate: Boolean! + invoiceCustomSectionsView: Boolean! + invoicesCreate: Boolean! + invoicesExport: Boolean! + invoicesSend: Boolean! + invoicesUpdate: Boolean! + invoicesView: Boolean! + invoicesVoid: Boolean! + organizationEmailsUpdate: Boolean! + organizationEmailsView: Boolean! + organizationIntegrationsCreate: Boolean! + organizationIntegrationsDelete: Boolean! + organizationIntegrationsUpdate: Boolean! + organizationIntegrationsView: Boolean! + organizationInvoicesUpdate: Boolean! + organizationInvoicesView: Boolean! + organizationMembersCreate: Boolean! + organizationMembersDelete: Boolean! + organizationMembersUpdate: Boolean! + organizationMembersView: Boolean! + organizationTaxesUpdate: Boolean! + organizationTaxesView: Boolean! + organizationUpdate: Boolean! + organizationView: Boolean! + paymentMethodsCreate: Boolean! + paymentMethodsDelete: Boolean! + paymentMethodsUpdate: Boolean! + paymentMethodsView: Boolean! + paymentReceiptsSend: Boolean! + paymentReceiptsView: Boolean! + paymentsCreate: Boolean! + paymentsView: Boolean! + plansCreate: Boolean! + plansDelete: Boolean! + plansUpdate: Boolean! + plansView: Boolean! + pricingUnitsCreate: Boolean! + pricingUnitsUpdate: Boolean! + pricingUnitsView: Boolean! + quotesApprove: Boolean! + quotesClone: Boolean! + quotesCreate: Boolean! + quotesUpdate: Boolean! + quotesView: Boolean! + quotesVoid: Boolean! + rolesCreate: Boolean! + rolesDelete: Boolean! + rolesUpdate: Boolean! + rolesView: Boolean! + securityLogsView: Boolean! + subscriptionsCreate: Boolean! + subscriptionsUpdate: Boolean! + subscriptionsView: Boolean! + walletsCreate: Boolean! + walletsTerminate: Boolean! + walletsTopUp: Boolean! + walletsUpdate: Boolean! +} + +type Plan { + activeSubscriptionsCount: Int! + activityLogs: [ActivityLog!] + amountCents: BigInt! + amountCurrency: CurrencyEnum! + applicableUsageThresholds: [UsageThreshold!] + billChargesMonthly: Boolean + billFixedChargesMonthly: Boolean + charges: [Charge!] + + """ + Number of charges attached to a plan + """ + chargesCount: Int! + code: String! + createdAt: ISO8601DateTime! + + """ + Number of customers attached to a plan + """ + customersCount: Int! + deletedAt: ISO8601DateTime + description: String + draftInvoicesCount: Int! + entitlements: [PlanEntitlement!] + fixedCharges: [FixedCharge!] + + """ + Number of fixed charges attached to a plan + """ + fixedChargesCount: Int! + hasActiveSubscriptions: Boolean! + hasCharges: Boolean! + hasCustomers: Boolean! + hasDraftInvoices: Boolean! + hasFixedCharges: Boolean! + hasOverriddenPlans: Boolean + hasSubscriptions: Boolean! + id: ID! + interval: PlanInterval! + invoiceDisplayName: String + isOverridden: Boolean! + metadata: [ItemMetadata!] + minimumCommitment: Commitment + name: String! + organization: Organization + parent: Plan + payInAdvance: Boolean! + subscriptionsCount: Int! + taxes: [Tax!] + trialPeriod: Float + updatedAt: ISO8601DateTime! + usageThresholds: [UsageThreshold!] +} + +""" +PlanCollection type +""" +type PlanCollection { + """ + A collection of paginated PlanCollection + """ + collection: [Plan!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type PlanEntitlement { + code: String! + description: String + name: String! + privileges: [PlanEntitlementPrivilegeObject!]! +} + +type PlanEntitlementPrivilegeObject { + code: String! + config: PrivilegeConfigObject! + name: String + value: String! + valueType: PrivilegeValueTypeEnum! +} + +enum PlanInterval { + monthly + quarterly + semiannual + weekly + yearly +} + +input PlanOverridesInput { + amountCents: BigInt + amountCurrency: CurrencyEnum + charges: [ChargeOverridesInput!] + description: String + fixedCharges: [FixedChargeOverridesInput!] + invoiceDisplayName: String + minimumCommitment: CommitmentInput + name: String + taxCodes: [String!] + trialPeriod: Float +} + +enum PremiumIntegrationTypeEnum { + analytics_dashboards + api_permissions + auto_dunning + avalara + beta_payment_authorization + custom_roles + events_targeting_wallets + forecasted_usage + from_email + granular_lifetime_usage + hubspot + issue_receipts + lifetime_usage + manual_payments + multi_entities_enterprise + multi_entities_pro + netsuite + okta + order_forms + preview + progressive_billing + projected_usage + remove_branding_watermark + revenue_analytics + revenue_share + salesforce + security_logs + xero +} + +type PresentationBreakdownUsage { + presentationBy: JSON! + units: String! +} + +type PresentationGroupKey { + options: PresentationGroupKeyOptions + value: String! +} + +input PresentationGroupKeyInput { + options: PresentationGroupKeyOptionsInput + value: String! +} + +type PresentationGroupKeyOptions { + displayInInvoice: Boolean +} + +input PresentationGroupKeyOptionsInput { + displayInInvoice: Boolean +} + +""" +Create Adjusted Fee Input +""" +input PreviewAdjustedFeeInput { + chargeFilterId: ID + chargeId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + feeId: ID + fixedChargeId: ID + invoiceDisplayName: String + invoiceId: ID! + invoiceSubscriptionId: ID + subscriptionId: ID + unitPreciseAmount: String + units: Float +} + +type PricingUnit { + code: String! + createdAt: ISO8601DateTime! + description: String + id: ID! + name: String! + shortName: String! + updatedAt: ISO8601DateTime! +} + +""" +PricingUnitCollection type +""" +type PricingUnitCollection { + """ + A collection of paginated PricingUnitCollection + """ + collection: [PricingUnit!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type PricingUnitUsage { + amountCents: BigInt! + conversionRate: Float! + createdAt: ISO8601DateTime! + id: ID! + preciseAmountCents: Float! + preciseUnitAmount: Float! + pricingUnit: PricingUnit! + shortName: String! + unitAmountCents: BigInt! + updatedAt: ISO8601DateTime! +} + +""" +Input for privilege configuration +""" +input PrivilegeConfigInput { + """ + Available options for select type privileges + """ + selectOptions: [String!] +} + +""" +Configuration object for privileges +""" +type PrivilegeConfigObject { + """ + Available options for select type privileges + """ + selectOptions: [String!] +} + +type PrivilegeObject { + code: String! + config: PrivilegeConfigObject! + id: ID! + name: String + valueType: PrivilegeValueTypeEnum! +} + +enum PrivilegeValueTypeEnum { + boolean + integer + select + string +} + +type ProjectedChargeFilterUsage { + amountCents: BigInt! + eventsCount: Int! + id: ID + invoiceDisplayName: String + pricingUnitAmountCents: BigInt + pricingUnitProjectedAmountCents: BigInt + projectedAmountCents: BigInt! + projectedUnits: Float! + units: Float! + values: ChargeFilterValues! +} + +type ProjectedChargeUsage { + amountCents: BigInt! + billableMetric: BillableMetric! + charge: Charge! + eventsCount: Int! + filters: [ProjectedChargeFilterUsage!] + groupedUsage: [ProjectedGroupedChargeUsage!]! + id: ID! + presentationBreakdowns: [PresentationBreakdownUsage!] + pricingUnitAmountCents: BigInt + pricingUnitProjectedAmountCents: BigInt + projectedAmountCents: BigInt! + projectedUnits: Float! + units: Float! +} + +type ProjectedGroupedChargeUsage { + amountCents: BigInt! + eventsCount: Int! + filters: [ProjectedChargeFilterUsage!] + groupedBy: JSON + id: ID! + presentationBreakdowns: [PresentationBreakdownUsage!] + pricingUnitAmountCents: BigInt + pricingUnitProjectedAmountCents: BigInt + projectedAmountCents: BigInt! + projectedUnits: Float! + units: Float! +} + +type Properties { + amount: String + customProperties: JSON + fixedAmount: String + freeUnits: BigInt + freeUnitsPerEvents: BigInt + freeUnitsPerTotalAggregation: String + graduatedPercentageRanges: [GraduatedPercentageRange!] + graduatedRanges: [GraduatedRange!] + packageSize: BigInt + perTransactionMaxAmount: String + perTransactionMinAmount: String + presentationGroupKeys: [PresentationGroupKey!] + pricingGroupKeys: [String!] + rate: String + volumeRanges: [VolumeRange!] +} + +input PropertiesInput { + amount: String + customProperties: JSON + fixedAmount: String + freeUnits: BigInt + freeUnitsPerEvents: BigInt + freeUnitsPerTotalAggregation: String + graduatedPercentageRanges: [GraduatedPercentageRangeInput!] + graduatedRanges: [GraduatedRangeInput!] + packageSize: BigInt + perTransactionMaxAmount: String + perTransactionMinAmount: String + presentationGroupKeys: [PresentationGroupKeyInput!] + pricingGroupKeys: [String!] + rate: String + volumeRanges: [VolumeRangeInput!] +} + +type ProviderCustomer { + id: ID! + providerCustomerId: ID + providerPaymentMethods: [ProviderPaymentMethodsEnum!] + syncWithProvider: Boolean +} + +input ProviderCustomerInput { + providerCustomerId: ID + providerPaymentMethods: [ProviderPaymentMethodsEnum!] + syncWithProvider: Boolean +} + +enum ProviderPaymentMethodsEnum { + bacs_debit + boleto + card + crypto + customer_balance + link + sepa_debit + us_bank_account +} + +enum ProviderTypeEnum { + adyen + cashfree + flutterwave + gocardless + moneyhash + stripe +} + +type Query { + """ + Query a single activity log of an organization + """ + activityLog( + """ + Uniq ID of the activity log + """ + activityId: ID! + ): ActivityLog + + """ + Query activity logs of an organization + """ + activityLogs(activityIds: [String!], activitySources: [ActivitySourceEnum!], activityTypes: [ActivityTypeEnum!], apiKeyIds: [String!], externalCustomerId: String, externalSubscriptionId: String, fromDate: ISO8601Date, fromDatetime: ISO8601DateTime, limit: Int, page: Int, resourceIds: [String!], resourceTypes: [ResourceTypeEnum!], toDate: ISO8601Date, toDatetime: ISO8601DateTime, userEmails: [String!]): ActivityLogCollection + + """ + Query a single add-on of an organization + """ + addOn( + """ + Uniq ID of the add-on + """ + id: ID! + ): AddOn + + """ + Query add-ons of an organization + """ + addOns(limit: Int, page: Int, searchTerm: String): AddOnCollection! + + """ + Query a single ai conversation of an organization + """ + aiConversation( + """ + Uniq ID of the ai conversation + """ + id: ID! + ): AiConversationWithMessages + + """ + Query the latest AI conversations of current organization + """ + aiConversations(limit: Int): AiConversationCollection + + """ + Query a single subscription alert + """ + alert( + """ + Unique ID of the alert + """ + id: ID! + ): Alert + + """ + Query alerts of a subscription + """ + alerts( + limit: Int + page: Int + + """ + External id of a subscription + """ + subscriptionExternalId: String! + ): AlertCollection! + + """ + Query the API key + """ + apiKey( + """ + Uniq ID of the API key + """ + id: ID! + ): ApiKey! + + """ + Query the API keys of current organization + """ + apiKeys(limit: Int, page: Int): SanitizedApiKeyCollection! + + """ + Query a single api log of an organization + """ + apiLog( + """ + Uniq ID of the api log + """ + requestId: ID! + ): ApiLog + + """ + Query api logs of an organization + """ + apiLogs(apiKeyIds: [String!], fromDate: ISO8601Date, fromDatetime: ISO8601DateTime, httpMethods: [HttpMethodEnum!], httpStatuses: [HttpStatus!], limit: Int, page: Int, requestIds: [String!], requestPaths: [String!], toDate: ISO8601Date, toDatetime: ISO8601DateTime): ApiLogCollection + + """ + Query applied coupons of an organization + """ + appliedCoupons(couponCode: [String!], externalCustomerId: String, limit: Int, page: Int, status: AppliedCouponStatusEnum): AppliedCouponCollection! + + """ + Query a single billable metric of an organization + """ + billableMetric( + """ + Uniq ID of the billable metric + """ + id: ID! + ): BillableMetric + + """ + Query billable metrics of an organization + """ + billableMetrics(aggregationTypes: [AggregationTypeEnum!], limit: Int, page: Int, planId: ID, recurring: Boolean, searchTerm: String): BillableMetricCollection! + + """ + Query active billing_entities of an organization + """ + billingEntities: BillingEntityCollection! + + """ + Query a single billing_entity of an organization + """ + billingEntity( + """ + Code of the billing_entity + """ + code: String! + ): BillingEntity + + """ + Query taxes of a billing entity + """ + billingEntityTaxes( + """ + Uniq ID of the billing entity + """ + billingEntityId: ID! + ): TaxCollection! + + """ + Query a single coupon of an organization + """ + coupon( + """ + Uniq ID of the coupon + """ + id: ID! + ): Coupon + + """ + Query coupons of an organization + """ + coupons(limit: Int, page: Int, searchTerm: String, status: CouponStatusEnum): CouponCollection! + + """ + Query a single credit note + """ + creditNote( + """ + Uniq ID of the credit note + """ + id: ID! + ): CreditNote + + """ + Fetch amounts for credit note creation + """ + creditNoteEstimate(invoiceId: ID!, items: [CreditNoteItemInput!]!): CreditNoteEstimate! + + """ + Query credit notes + """ + creditNotes( + amountFrom: Int + amountTo: Int + billingEntityIds: [ID!] + creditStatus: [CreditNoteCreditStatusEnum!] + currency: CurrencyEnum + customerExternalId: String + + """ + Uniq ID of the customer + """ + customerId: ID + invoiceNumber: String + issuingDateFrom: ISO8601Date + issuingDateTo: ISO8601Date + limit: Int + page: Int + reason: [CreditNoteReasonEnum!] + refundStatus: [CreditNoteRefundStatusEnum!] + searchTerm: String + selfBilled: Boolean + types: [CreditNoteTypeEnum!] + ): CreditNoteCollection! + + """ + Retrieves currently connected user + """ + currentUser: User! + + """ + Retrieve the version of the application + """ + currentVersion: CurrentVersion! + + """ + Query a single customer of an organization + """ + customer( + """ + External ID of the customer + """ + externalId: ID + + """ + Lago ID of the customer + """ + id: ID + ): Customer + + """ + Query invoices of a customer + """ + customerInvoices(customerId: ID!, limit: Int, page: Int, searchTerm: String, status: [InvoiceStatusTypeEnum!]): InvoiceCollection! + + """ + Query the projected usage of the customer on the current billing period + """ + customerPortalCustomerProjectedUsage(subscriptionId: ID!): CustomerProjectedUsage! + + """ + Query the usage of the customer on the current billing period + """ + customerPortalCustomerUsage(subscriptionId: ID!): CustomerUsage! + + """ + Query invoice collections of a customer portal user + """ + customerPortalInvoiceCollections(expireCache: Boolean, months: Int): FinalizedInvoiceCollectionCollection! + + """ + Query invoices of a customer + """ + customerPortalInvoices(limit: Int, page: Int, searchTerm: String, status: [InvoiceStatusTypeEnum!]): InvoiceCollection! + + """ + Query customer portal organization + """ + customerPortalOrganization: CustomerPortalOrganization + + """ + Query overdue balances of a customer portal user + """ + customerPortalOverdueBalances(expireCache: Boolean, months: Int): OverdueBalanceCollection! + + """ + Query a single subscription from the customer portal + """ + customerPortalSubscription( + """ + Uniq ID of the subscription + """ + id: ID! + ): Subscription + + """ + Query customer portal subscriptions + """ + customerPortalSubscriptions(currency: String, limit: Int, page: Int, planCode: String, status: [StatusTypeEnum!]): SubscriptionCollection! + + """ + Query a customer portal user + """ + customerPortalUser: CustomerPortalCustomer + + """ + Query a single wallet from the customer portal + """ + customerPortalWallet( + """ + Uniq ID of the wallet + """ + id: ID! + ): CustomerPortalWallet + + """ + Query wallets + """ + customerPortalWallets(limit: Int, page: Int, status: WalletStatusEnum): CustomerPortalWalletCollection! + + """ + Query the projected usage of the customer on the current billing period + """ + customerProjectedUsage(customerId: ID, subscriptionId: ID!): CustomerProjectedUsage! + + """ + Query the usage of the customer on the current billing period + """ + customerUsage(customerId: ID, subscriptionId: ID!): CustomerUsage! + + """ + Query customers of an organization + """ + customers(accountType: [CustomerAccountTypeEnum!], activeSubscriptionsCountFrom: Int, activeSubscriptionsCountTo: Int, billingEntityIds: [ID!], countries: [CountryCode!], currencies: [CurrencyEnum!], customerType: CustomerTypeEnum, hasCustomerType: Boolean, hasTaxIdentificationNumber: Boolean, limit: Int, metadata: [CustomerMetadataFilter!], page: Int, searchTerm: String, states: [String!], withDeleted: Boolean, zipcodes: [String!]): CustomerCollection! + + """ + Query monthly recurring revenues of an organization + """ + dataApiMrrs(billingEntityCode: String, currency: CurrencyEnum, customerCountry: CountryCode, customerType: CustomerTypeEnum, externalCustomerId: String, externalSubscriptionId: String, fromDate: ISO8601Date, isCustomerTinEmpty: Boolean, planCode: String, timeGranularity: TimeGranularityEnum, toDate: ISO8601Date): DataApiMrrCollection! + + """ + Query monthly recurring revenues plans of an organization + """ + dataApiMrrsPlans(currency: CurrencyEnum, limit: Int, page: Int): DataApiMrrsPlans! + + """ + Query prepaid credits of an organization + """ + dataApiPrepaidCredits(billingEntityCode: String, currency: CurrencyEnum, customerCountry: CountryCode, customerType: CustomerTypeEnum, externalCustomerId: String, externalSubscriptionId: String, fromDate: ISO8601Date, isCustomerTinEmpty: Boolean, planCode: String, timeGranularity: TimeGranularityEnum, toDate: ISO8601Date): DataApiPrepaidCreditCollection! + + """ + Query revenue streams of an organization + """ + dataApiRevenueStreams(billingEntityCode: String, currency: CurrencyEnum, customerCountry: CountryCode, customerType: CustomerTypeEnum, externalCustomerId: String, externalSubscriptionId: String, fromDate: ISO8601Date, isCustomerTinEmpty: Boolean, planCode: String, timeGranularity: TimeGranularityEnum, toDate: ISO8601Date): DataApiRevenueStreamCollection! + + """ + Query revenue streams customers of an organization + """ + dataApiRevenueStreamsCustomers(currency: CurrencyEnum, limit: Int, orderBy: OrderByEnum, page: Int): DataApiRevenueStreamsCustomers! + + """ + Query revenue streams plans of an organization + """ + dataApiRevenueStreamsPlans(currency: CurrencyEnum, limit: Int, orderBy: OrderByEnum, page: Int): DataApiRevenueStreamsPlans! + + """ + Query usages of an organization + """ + dataApiUsages(billableMetricCode: String, billingEntityCode: String, currency: CurrencyEnum, customerCountry: CountryCode, customerType: CustomerTypeEnum, externalCustomerId: String, externalSubscriptionId: String, fromDate: ISO8601Date, isBillableMetricRecurring: Boolean, isCustomerTinEmpty: Boolean, planCode: String, timeGranularity: TimeGranularityEnum, toDate: ISO8601Date): DataApiUsageCollection! + + """ + Query usages of an organization + """ + dataApiUsagesAggregatedAmounts(billingEntityCode: String, currency: CurrencyEnum, customerCountry: CountryCode, customerType: CustomerTypeEnum, externalCustomerId: String, externalSubscriptionId: String, fromDate: ISO8601Date, isBillableMetricRecurring: Boolean, isCustomerTinEmpty: Boolean, planCode: String, timeGranularity: TimeGranularityEnum, toDate: ISO8601Date): DataApiUsageAggregatedAmountCollection! + + """ + Query forecasted usages of an organization + """ + dataApiUsagesForecasted(billableMetricCode: String, billingEntityCode: String, chargeFilterId: String, chargeId: String, currency: CurrencyEnum, customerCountry: CountryCode, customerType: CustomerTypeEnum, externalCustomerId: String, externalSubscriptionId: String, fromDate: ISO8601Date, isCustomerTinEmpty: Boolean, planCode: String, timeGranularity: TimeGranularityEnum, toDate: ISO8601Date): DataApiUsageForecastedCollection! + + """ + Query invoiced usages of an organization + """ + dataApiUsagesInvoiced(billableMetricCode: String, billingEntityCode: String, currency: CurrencyEnum, customerCountry: CountryCode, customerType: CustomerTypeEnum, externalCustomerId: String, externalSubscriptionId: String, filterValues: String, fromDate: ISO8601Date, groupedBy: String, isCustomerTinEmpty: Boolean, planCode: String, timeGranularity: TimeGranularityEnum, toDate: ISO8601Date): DataApiUsageInvoicedCollection! + + """ + Query a single dunning campaign of an organization + """ + dunningCampaign( + """ + Unique ID of the dunning campaign + """ + id: ID! + ): DunningCampaign! + + """ + Query dunning campaigns of an organization + """ + dunningCampaigns(appliedToOrganization: Boolean, currency: [CurrencyEnum!], limit: Int, order: String, page: Int, searchTerm: String): DunningCampaignCollection! + + """ + Query a single event of an organization + """ + event( + """ + Transaction ID of the event + """ + transactionId: ID! + ): Event + + """ + Query Event Types for Webhook Endpoints + """ + eventTypes: [WebhookEventType!]! + + """ + Query events of an organization + """ + events(limit: Int, page: Int): EventCollection + + """ + Query a single feature + """ + feature( + """ + Unique code of the feature + """ + code: String + + """ + Unique ID of the feature + """ + id: ID + ): FeatureObject! + + """ + Query features of an organization + """ + features(limit: Int, page: Int, searchTerm: String): FeatureObjectCollection! + + """ + Get Google auth url. + """ + googleAuthUrl: AuthUrl! + + """ + Query gross revenue of an organization + """ + grossRevenues(billingEntityId: ID, currency: CurrencyEnum, expireCache: Boolean, externalCustomerId: String, months: Int): GrossRevenueCollection! + + """ + Query a single integration + """ + integration( + """ + Uniq ID of the integration + """ + id: ID + ): Integration + + """ + Query integration collection mappings + """ + integrationCollectionMappings(integrationId: ID!): CollectionMappingCollection + + """ + Query integration items of an integration + """ + integrationItems(integrationId: ID!, itemType: IntegrationItemTypeEnum, limit: Int, page: Int, searchTerm: String): IntegrationItemCollection! + + """ + Query integration subsidiaries + """ + integrationSubsidiaries(integrationId: ID): SubsidiaryCollection + + """ + Query organization's integrations + """ + integrations(limit: Int, page: Int, types: [IntegrationTypeEnum!]): IntegrationCollection + + """ + Query a single Invite + """ + invite( + """ + Uniq token of the Invite + """ + token: String! + ): Invite + + """ + Query pending invites of an organization + """ + invites(limit: Int, page: Int): InviteCollection! + + """ + Query a single Invoice of an organization + """ + invoice( + """ + Uniq ID of the invoice + """ + id: ID! + ): Invoice + + """ + Query invoice collections of an organization + """ + invoiceCollections(billingEntityCode: String, billingEntityId: ID, currency: CurrencyEnum, isCustomerTinEmpty: Boolean): FinalizedInvoiceCollectionCollection! + + """ + Query invoice's credit note + """ + invoiceCreditNotes( + """ + Uniq ID of the invoice + """ + invoiceId: ID! + limit: Int + page: Int + ): CreditNoteCollection + + """ + Query a single invoice_custom_section of an organization + """ + invoiceCustomSection( + """ + Uniq ID of the invoice_custom_section + """ + id: ID! + ): InvoiceCustomSection! + + """ + Query invoice_custom_sections + """ + invoiceCustomSections(limit: Int, page: Int): InvoiceCustomSectionCollection + + """ + Query invoiced usage of an organization + """ + invoicedUsages(billingEntityId: ID, currency: CurrencyEnum): InvoicedUsageCollection! + + """ + Query invoices + """ + invoices( + amountFrom: Int + amountTo: Int + billingEntityIds: [ID!] + currency: CurrencyEnum + customerExternalId: String + + """ + Uniq ID of the customer + """ + customerId: ID + invoiceType: [InvoiceTypeEnum!] + issuingDateFrom: ISO8601Date + issuingDateTo: ISO8601Date + limit: Int + page: Int + partiallyPaid: Boolean + paymentDisputeLost: Boolean + paymentOverdue: Boolean + paymentStatus: [InvoicePaymentStatusTypeEnum!] + positiveDueAmount: Boolean + searchTerm: String + selfBilled: Boolean + settlements: [InvoiceSettlementTypeEnum!] + status: [InvoiceStatusTypeEnum!] + subscriptionId: ID + ): InvoiceCollection! + + """ + Query memberships of an organization + """ + memberships(limit: Int, page: Int): MembershipCollection! + + """ + Query MRR of an organization + """ + mrrs(billingEntityId: ID, currency: CurrencyEnum): MrrCollection! + + """ + Query the current organization + """ + organization: CurrentOrganization + + """ + Query overdue balances of an organization + """ + overdueBalances(billingEntityCode: String, billingEntityId: ID, currency: CurrencyEnum, expireCache: Boolean, externalCustomerId: String, isCustomerTinEmpty: Boolean, months: Int): OverdueBalanceCollection! + + """ + Query a password reset by token + """ + passwordReset( + """ + Uniq token of the password reset + """ + token: String! + ): ResetPassword! + + """ + Query a single Payment + """ + payment( + """ + Uniq ID of the payment + """ + id: ID! + ): Payment + + """ + Query payment methods of a customer + """ + paymentMethods( + """ + External ID of the customer + """ + externalCustomerId: ID! + limit: Int + page: Int + withDeleted: Boolean + ): PaymentMethodCollection! + + """ + Query a single payment provider + """ + paymentProvider( + """ + Code of the payment provider + """ + code: String + + """ + Uniq ID of the payment provider + """ + id: ID + ): PaymentProvider + + """ + Query organization's payment providers + """ + paymentProviders(limit: Int, page: Int, type: ProviderTypeEnum): PaymentProviderCollection + + """ + Query payment requests of an organization + """ + paymentRequests(currency: String, externalCustomerId: String, limit: Int, page: Int, paymentStatus: InvoicePaymentStatusTypeEnum): PaymentRequestCollection! + + """ + Query payments of an organization + """ + payments(externalCustomerId: ID, invoiceId: ID, limit: Int, page: Int, searchTerm: String): PaymentCollection! + + """ + Query a single plan of an organization + """ + plan( + """ + Uniq ID of the plan + """ + id: ID! + ): Plan + + """ + Query plans of an organization + """ + plans(limit: Int, page: Int, searchTerm: String, withDeleted: Boolean): PlanCollection! + + """ + Query the pricing unit + """ + pricingUnit( + """ + Uniq ID of the pricing unit + """ + id: ID! + ): PricingUnit! + + """ + Query the pricing units of current organization + """ + pricingUnits(limit: Int, page: Int, searchTerm: String): PricingUnitCollection! + + """ + Query a quote + """ + quote(id: ID!): Quote + + """ + Query quotes of an organization + """ + quotes(customers: [ID!], fromDate: ISO8601Date, limit: Int, numbers: [String!], orderTypes: [OrderTypeEnum!], owners: [ID!], page: Int, statuses: [StatusEnum!], toDate: ISO8601Date): QuoteCollection! + + """ + Query a single role + """ + role( + """ + Uniq ID of the role + """ + id: ID! + ): Role + + """ + Query roles available for the organization + """ + roles: [Role!]! + + """ + Query a single security log by ID + """ + securityLog(logId: ID!): SecurityLog + + """ + Query security logs of an organization + """ + securityLogs( + apiKeyIds: [ID!] + fromDatetime: ISO8601DateTime + limit: Int + logEvents: [LogEventEnum!] + logTypes: [LogTypeEnum!] + page: Int + + """ + Upper date boundary (required for consistent pagination) + """ + toDatetime: ISO8601DateTime! + userIds: [ID!] + ): SecurityLogCollection + + """ + Query a single subscription of an organization + """ + subscription( + """ + External ID of the subscription + """ + externalId: ID + + """ + Lago ID of the subscription + """ + id: ID + ): Subscription + + """ + Query a single subscription alert + """ + subscriptionAlert( + """ + Unique ID of the alert + """ + id: ID! + ): Alert + + """ + Query alerts of a subscription + """ + subscriptionAlerts( + limit: Int + page: Int + + """ + External id of a subscription + """ + subscriptionExternalId: String! + ): AlertCollection! + + """ + Retrieve an entitlement of a subscriptions + """ + subscriptionEntitlement(featureCode: String!, subscriptionId: ID!): SubscriptionEntitlement! + + """ + Query entitlements of a subscriptions + """ + subscriptionEntitlements(subscriptionId: ID!): SubscriptionEntitlementCollection! + + """ + Query subscriptions of an organization + """ + subscriptions(currency: String, externalCustomerId: String, limit: Int, overriden: Boolean, page: Int, planCode: String, searchTerm: String, status: [StatusTypeEnum!]): SubscriptionCollection! + + """ + Query all Superset dashboards with embedded configuration and guest tokens + """ + supersetDashboards: [SupersetDashboard!]! + + """ + Query a single tax of an organization + """ + tax( + """ + Uniq ID of the tax + """ + id: ID! + ): Tax + + """ + Query taxes of an organization + """ + taxes(appliedToOrganization: Boolean, autoGenerated: Boolean, limit: Int, order: String, page: Int, searchTerm: String): TaxCollection! + + """ + Query a single wallet of an organization + """ + wallet( + """ + Uniq ID of the wallet + """ + id: ID! + ): Wallet + + """ + Query a single wallet alert + """ + walletAlert( + """ + Unique ID of the alert + """ + id: ID! + ): Alert + + """ + Query alerts of a wallet + """ + walletAlerts( + limit: Int + page: Int + + """ + Id of a wallet + """ + walletId: String! + ): AlertCollection! + + """ + Query a single wallet transaction + """ + walletTransaction( + """ + Unique ID of the wallet transaction + """ + id: ID! + ): WalletTransaction + + """ + Query wallet transaction consumptions for an inbound transaction + """ + walletTransactionConsumptions( + limit: Int + page: Int + + """ + Uniq ID of the inbound wallet transaction + """ + walletTransactionId: ID! + ): WalletTransactionConsumptionCollection! + + """ + Query wallet transaction fundings for an outbound transaction + """ + walletTransactionFundings( + limit: Int + page: Int + + """ + Uniq ID of the outbound wallet transaction + """ + walletTransactionId: ID! + ): WalletTransactionFundingCollection! + + """ + Query wallet transactions + """ + walletTransactions( + limit: Int + page: Int + status: WalletTransactionStatusEnum + transactionType: WalletTransactionTransactionTypeEnum + + """ + Uniq ID of the wallet + """ + walletId: ID! + ): WalletTransactionCollection! + + """ + Query wallets + """ + wallets( + """ + Uniq ID of the customer + """ + customerId: ID! + + """ + List of wallet IDs to fetch + """ + ids: [ID!] + limit: Int + page: Int + status: WalletStatusEnum + ): WalletCollection! + + """ + Query a webhook + """ + webhook(id: ID!): Webhook + + """ + Query a single webhook endpoint + """ + webhookEndpoint( + """ + Uniq ID of the webhook endpoint + """ + id: ID! + ): WebhookEndpoint + + """ + Query webhook endpoints of an organization + """ + webhookEndpoints(limit: Int, page: Int, searchTerm: String): WebhookEndpointCollection! + + """ + Query Webhooks + """ + webhooks(eventTypes: [String!], fromDate: ISO8601DateTime, httpStatuses: [String!], limit: Int, page: Int, searchTerm: String, status: WebhookStatusEnum, statuses: [WebhookStatusEnum!], toDate: ISO8601DateTime, webhookEndpointId: String!): WebhookCollection! +} + +type Quote { + createdAt: ISO8601DateTime! + currentVersion: QuoteVersion! + customer: Customer! + id: ID! + number: String! + orderType: OrderTypeEnum! + organization: Organization! + owners: [User!] + subscription: Subscription + updatedAt: ISO8601DateTime! + versions: [QuoteVersion!]! +} + +""" +QuoteCollection type +""" +type QuoteCollection { + """ + A collection of paginated QuoteCollection + """ + collection: [Quote!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type QuoteVersion { + approvedAt: ISO8601DateTime + billingItems: JSON + content: String + createdAt: ISO8601DateTime! + id: ID! + organization: Organization! + quote: Quote! + shareToken: String + status: StatusEnum! + updatedAt: ISO8601DateTime! + version: Int! + voidReason: VoidReasonEnum + voidedAt: ISO8601DateTime +} + +enum RecurringTransactionIntervalEnum { + monthly + quarterly + semiannual + weekly + yearly +} + +enum RecurringTransactionMethodEnum { + fixed + target +} + +type RecurringTransactionRule { + createdAt: ISO8601DateTime! + expirationAt: ISO8601DateTime + grantedCredits: String! + ignorePaidTopUpLimits: Boolean! + interval: RecurringTransactionIntervalEnum + invoiceRequiresSuccessfulPayment: Boolean! + lagoId: ID! + method: RecurringTransactionMethodEnum! + paidCredits: String! + paymentMethod: PaymentMethod + paymentMethodType: PaymentMethodTypeEnum + selectedInvoiceCustomSections: [InvoiceCustomSection!] + skipInvoiceCustomSections: Boolean + startedAt: ISO8601DateTime + targetOngoingBalance: String + thresholdCredits: String + transactionMetadata: [TransactionMetadata!] + transactionName: String + trigger: RecurringTransactionTriggerEnum! +} + +enum RecurringTransactionTriggerEnum { + interval + threshold +} + +""" +Autogenerated input type of RefreshInvoice +""" +input RefreshInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of RegenerateInvoice +""" +input RegenerateInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + fees: [VoidedInvoiceFeeInput!]! + voidedInvoiceId: ID! +} + +type RegisterUser { + membership: Membership! + organization: Organization! + token: String! + user: User! +} + +""" +Autogenerated input type of RegisterUser +""" +input RegisterUserInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + email: String! + organizationName: String! + password: String! +} + +enum RegroupPaidFeesEnum { + invoice +} + +""" +Autogenerated input type of RemoveSubscriptionEntitlement +""" +input RemoveSubscriptionEntitlementInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + featureCode: String! + subscriptionId: ID! +} + +""" +Autogenerated return type of RemoveSubscriptionEntitlement. +""" +type RemoveSubscriptionEntitlementPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + featureCode: String +} + +""" +Autogenerated input type of RemoveTaxes +""" +input RemoveTaxesInput { + billingEntityId: ID! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + taxCodes: [String!]! +} + +""" +Autogenerated return type of RemoveTaxes. +""" +type RemoveTaxesPayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + removedTaxes: [Tax!]! +} + +""" +Resend email input arguments +""" +input ResendCreditNoteEmailInput { + """ + BCC recipients + """ + bcc: [String!] + + """ + CC recipients + """ + cc: [String!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Document ID + """ + id: ID! + + """ + Custom recipients (defaults to customer email) + """ + to: [String!] +} + +""" +Resend email input arguments +""" +input ResendInvoiceEmailInput { + """ + BCC recipients + """ + bcc: [String!] + + """ + CC recipients + """ + cc: [String!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Document ID + """ + id: ID! + + """ + Custom recipients (defaults to customer email) + """ + to: [String!] +} + +""" +Resend email input arguments +""" +input ResendPaymentReceiptEmailInput { + """ + BCC recipients + """ + bcc: [String!] + + """ + CC recipients + """ + cc: [String!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Document ID + """ + id: ID! + + """ + Custom recipients (defaults to customer email) + """ + to: [String!] +} + +""" +ResetPassword type +""" +type ResetPassword { + expireAt: ISO8601DateTime! + id: ID! + token: String! + user: User! +} + +""" +Autogenerated input type of ResetPassword +""" +input ResetPasswordInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + newPassword: String! + token: String! +} + +""" +Activity Logs resource type enums +""" +enum ResourceTypeEnum { + """ + BillableMetric + """ + billable_metric + + """ + BillingEntity + """ + billing_entity + + """ + Coupon + """ + coupon + + """ + CreditNote + """ + credit_note + + """ + Customer + """ + customer + + """ + Entitlement::Feature + """ + feature + + """ + Invoice + """ + invoice + + """ + PaymentReceipt + """ + payment_receipt + + """ + PaymentRequest + """ + payment_request + + """ + Plan + """ + plan + + """ + Subscription + """ + subscription + + """ + Wallet + """ + wallet +} + +""" +Autogenerated input type of RetryAllInvoicePayments +""" +input RetryAllInvoicePaymentsInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String +} + +""" +Autogenerated input type of RetryAllInvoices +""" +input RetryAllInvoicesInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String +} + +""" +Autogenerated input type of RetryInvoice +""" +input RetryInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Retry payment input arguments +""" +input RetryInvoicePaymentInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + paymentMethod: PaymentMethodReferenceInput +} + +""" +Autogenerated input type of RetryTaxProviderVoiding +""" +input RetryTaxProviderVoidingInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of RetryTaxReporting +""" +input RetryTaxReportingInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of RetryWebhook +""" +input RetryWebhookInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of RevokeInvite +""" +input RevokeInviteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of RevokeMembership +""" +input RevokeMembershipInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +type Role { + admin: Boolean! + code: String! + createdAt: ISO8601DateTime! + description: String + id: ID! + memberships: [Membership!]! + name: String! + permissions: [PermissionEnum!]! +} + +""" +Autogenerated input type of RotateApiKey +""" +input RotateApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + expiresAt: ISO8601DateTime + id: ID! + name: String +} + +enum RoundingFunctionEnum { + ceil + floor + round +} + +type SalesforceCustomer { + externalCustomerId: String + id: ID! + integrationCode: String + integrationId: ID + integrationType: IntegrationTypeEnum + syncWithProvider: Boolean +} + +type SalesforceIntegration { + code: String! + id: ID! + instanceId: String! + name: String! +} + +type SanitizedApiKey { + createdAt: ISO8601DateTime! + expiresAt: ISO8601DateTime + id: ID! + lastUsedAt: ISO8601DateTime + name: String + permissions: JSON! + value: String! +} + +""" +SanitizedApiKeyCollection type +""" +type SanitizedApiKeyCollection { + """ + A collection of paginated SanitizedApiKeyCollection + """ + collection: [SanitizedApiKey!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Security log entry +""" +type SecurityLog { + createdAt: ISO8601DateTime! + deviceInfo: JSON + logEvent: LogEventEnum! + logId: ID! + logType: LogTypeEnum! + loggedAt: ISO8601DateTime! + resources: JSON + userEmail: String +} + +""" +SecurityLogCollection type +""" +type SecurityLogCollection { + """ + A collection of paginated SecurityLogCollection + """ + collection: [SecurityLog!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Autogenerated input type of SetAsDefault +""" +input SetAsDefaultInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +enum StatusEnum { + approved + draft + voided +} + +enum StatusTypeEnum { + active + canceled + incomplete + pending + terminated +} + +type StripeProvider { + code: String! + id: ID! + name: String! + secretKey: ObfuscatedString + successRedirectUrl: String + supports3ds: Boolean +} + +type Subscription { + activatedAt: ISO8601DateTime + activationRules: [SubscriptionActivationRule!]! + activityLogs: [ActivityLog!] + billingTime: BillingTimeEnum + cancelationReason: CancelationReasonEnum + canceledAt: ISO8601DateTime + charges: [Charge!] + createdAt: ISO8601DateTime! + currentBillingPeriodEndingAt: ISO8601DateTime + currentBillingPeriodStartedAt: ISO8601DateTime + customer: Customer! + downgradePlanDate: ISO8601Date + endingAt: ISO8601DateTime + externalId: String! + fees: [Fee!] + fixedCharges: [FixedCharge!] + id: ID! + lifetimeUsage: SubscriptionLifetimeUsage + name: String + nextName: String + nextPlan: Plan + nextSubscription: Subscription + nextSubscriptionAt: ISO8601DateTime + nextSubscriptionType: NextSubscriptionTypeEnum + onTerminationCreditNote: OnTerminationCreditNoteEnum + onTerminationInvoice: OnTerminationInvoiceEnum! + paymentMethod: PaymentMethod + paymentMethodType: PaymentMethodTypeEnum + periodEndDate: ISO8601Date + plan: Plan! + previousPlan: Plan + previousSubscription: Subscription + progressiveBillingDisabled: Boolean + selectedInvoiceCustomSections: [InvoiceCustomSection!] + skipInvoiceCustomSections: Boolean + startedAt: ISO8601DateTime + status: StatusTypeEnum + subscriptionAt: ISO8601DateTime + terminatedAt: ISO8601DateTime + updatedAt: ISO8601DateTime! + usageThresholds: [UsageThreshold!]! +} + +type SubscriptionActivationRule { + createdAt: ISO8601DateTime! + expiresAt: ISO8601DateTime + id: ID! + status: ActivationRuleStatusEnum! + timeoutHours: Int + type: ActivationRuleTypeEnum! + updatedAt: ISO8601DateTime! +} + +input SubscriptionActivationRuleInput { + id: ID + timeoutHours: Int + type: ActivationRuleTypeEnum! +} + +""" +SubscriptionCollection type +""" +type SubscriptionCollection { + """ + A collection of paginated SubscriptionCollection + """ + collection: [Subscription!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type SubscriptionEntitlement { + code: String! + description: String + name: String! + privileges: [SubscriptionEntitlementPrivilegeObject!]! +} + +""" +SubscriptionEntitlementCollection type +""" +type SubscriptionEntitlementCollection { + """ + A collection of paginated SubscriptionEntitlementCollection + """ + collection: [SubscriptionEntitlement!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type SubscriptionEntitlementPrivilegeObject { + code: String! + config: PrivilegeConfigObject! + name: String + value: String + valueType: PrivilegeValueTypeEnum! +} + +type SubscriptionLifetimeUsage { + lastThresholdAmountCents: BigInt + nextThresholdAmountCents: BigInt + nextThresholdRatio: Float + totalUsageAmountCents: BigInt! + totalUsageFromDatetime: ISO8601DateTime! + totalUsageToDatetime: ISO8601DateTime! +} + +type Subsidiary { + externalId: String! + externalName: String +} + +""" +SubsidiaryCollection type +""" +type SubsidiaryCollection { + """ + A collection of paginated SubsidiaryCollection + """ + collection: [Subsidiary!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type SupersetDashboard { + dashboardTitle: String! + embeddedId: String! + guestToken: String! + id: String! + supersetUrl: String! +} + +""" +Autogenerated input type of SyncHubspotInvoice +""" +input SyncHubspotIntegrationInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceId: ID! +} + +""" +Autogenerated return type of SyncHubspotInvoice. +""" +type SyncHubspotInvoicePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceId: ID +} + +""" +Autogenerated input type of SyncIntegrationCreditNote +""" +input SyncIntegrationCreditNoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + creditNoteId: ID! +} + +""" +Autogenerated return type of SyncIntegrationCreditNote. +""" +type SyncIntegrationCreditNotePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + creditNoteId: ID +} + +""" +Autogenerated input type of SyncIntegrationInvoice +""" +input SyncIntegrationInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceId: ID! +} + +""" +Autogenerated return type of SyncIntegrationInvoice. +""" +type SyncIntegrationInvoicePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceId: ID +} + +""" +Autogenerated input type of SyncSalesforceInvoice +""" +input SyncSalesforceInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceId: ID! +} + +""" +Autogenerated return type of SyncSalesforceInvoice. +""" +type SyncSalesforceInvoicePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceId: ID +} + +type Tax { + """ + Number of add ons using this tax + """ + addOnsCount: Int! + appliedToOrganization: Boolean! + autoGenerated: Boolean! + + """ + Number of charges using this tax + """ + chargesCount: Int! + code: String! + createdAt: ISO8601DateTime! + + """ + Number of customers using this tax + """ + customersCount: Int! + description: String + id: ID! + name: String! + organization: Organization + + """ + Number of plans using this tax + """ + plansCount: Int! + rate: Float! + updatedAt: ISO8601DateTime! +} + +type TaxBreakdownObject { + enumedTaxCode: InvoiceAppliedTaxOnWholeInvoiceCodeEnum + name: String + rate: Float + taxAmount: BigInt + type: String +} + +""" +TaxCollection type +""" +type TaxCollection { + """ + A collection of paginated TaxCollection + """ + collection: [Tax!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Autogenerated input type of CreateTax +""" +input TaxCreateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + name: String! + rate: Float! +} + +type TaxFeeObject { + amountCents: BigInt + itemCode: String + itemId: String + taxAmountCents: BigInt + taxBreakdown: [TaxBreakdownObject!] +} + +""" +TaxFeeObjectCollection type +""" +type TaxFeeObjectCollection { + """ + A collection of paginated TaxFeeObjectCollection + """ + collection: [TaxFeeObject!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Autogenerated input type of UpdateTax +""" +input TaxUpdateInput { + appliedToOrganization: Boolean + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + description: String + id: ID! + name: String + rate: Float +} + +""" +Autogenerated input type of TerminateAppliedCoupon +""" +input TerminateAppliedCouponInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of TerminateCoupon +""" +input TerminateCouponInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Autogenerated input type of TerminateCustomerWallet +""" +input TerminateCustomerWalletInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Input for terminating a subscription +""" +input TerminateSubscriptionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + onTerminationCreditNote: OnTerminationCreditNoteEnum + onTerminationInvoice: OnTerminationInvoiceEnum +} + +input ThresholdInput { + code: String + recurring: Boolean + value: String! +} + +enum TimeGranularityEnum { + daily + monthly + weekly +} + +enum TimezoneEnum { + """ + Africa/Algiers + """ + TZ_AFRICA_ALGIERS + + """ + Africa/Cairo + """ + TZ_AFRICA_CAIRO + + """ + Africa/Casablanca + """ + TZ_AFRICA_CASABLANCA + + """ + Africa/Harare + """ + TZ_AFRICA_HARARE + + """ + Africa/Johannesburg + """ + TZ_AFRICA_JOHANNESBURG + + """ + Africa/Monrovia + """ + TZ_AFRICA_MONROVIA + + """ + Africa/Nairobi + """ + TZ_AFRICA_NAIROBI + + """ + America/Argentina/Buenos_Aires + """ + TZ_AMERICA_ARGENTINA_BUENOS_AIRES + + """ + America/Bogota + """ + TZ_AMERICA_BOGOTA + + """ + America/Caracas + """ + TZ_AMERICA_CARACAS + + """ + America/Chicago + """ + TZ_AMERICA_CHICAGO + + """ + America/Chihuahua + """ + TZ_AMERICA_CHIHUAHUA + + """ + America/Denver + """ + TZ_AMERICA_DENVER + + """ + America/Guatemala + """ + TZ_AMERICA_GUATEMALA + + """ + America/Guyana + """ + TZ_AMERICA_GUYANA + + """ + America/Halifax + """ + TZ_AMERICA_HALIFAX + + """ + America/Indiana/Indianapolis + """ + TZ_AMERICA_INDIANA_INDIANAPOLIS + + """ + America/Juneau + """ + TZ_AMERICA_JUNEAU + + """ + America/La_Paz + """ + TZ_AMERICA_LA_PAZ + + """ + America/Lima + """ + TZ_AMERICA_LIMA + + """ + America/Los_Angeles + """ + TZ_AMERICA_LOS_ANGELES + + """ + America/Mazatlan + """ + TZ_AMERICA_MAZATLAN + + """ + America/Mexico_City + """ + TZ_AMERICA_MEXICO_CITY + + """ + America/Monterrey + """ + TZ_AMERICA_MONTERREY + + """ + America/Montevideo + """ + TZ_AMERICA_MONTEVIDEO + + """ + America/New_York + """ + TZ_AMERICA_NEW_YORK + + """ + America/Nuuk + """ + TZ_AMERICA_NUUK + + """ + America/Phoenix + """ + TZ_AMERICA_PHOENIX + + """ + America/Puerto_Rico + """ + TZ_AMERICA_PUERTO_RICO + + """ + America/Regina + """ + TZ_AMERICA_REGINA + + """ + America/Santiago + """ + TZ_AMERICA_SANTIAGO + + """ + America/Sao_Paulo + """ + TZ_AMERICA_SAO_PAULO + + """ + America/St_Johns + """ + TZ_AMERICA_ST_JOHNS + + """ + America/Tijuana + """ + TZ_AMERICA_TIJUANA + + """ + Asia/Almaty + """ + TZ_ASIA_ALMATY + + """ + Asia/Baghdad + """ + TZ_ASIA_BAGHDAD + + """ + Asia/Baku + """ + TZ_ASIA_BAKU + + """ + Asia/Bangkok + """ + TZ_ASIA_BANGKOK + + """ + Asia/Chongqing + """ + TZ_ASIA_CHONGQING + + """ + Asia/Colombo + """ + TZ_ASIA_COLOMBO + + """ + Asia/Dhaka + """ + TZ_ASIA_DHAKA + + """ + Asia/Hong_Kong + """ + TZ_ASIA_HONG_KONG + + """ + Asia/Irkutsk + """ + TZ_ASIA_IRKUTSK + + """ + Asia/Jakarta + """ + TZ_ASIA_JAKARTA + + """ + Asia/Jerusalem + """ + TZ_ASIA_JERUSALEM + + """ + Asia/Kabul + """ + TZ_ASIA_KABUL + + """ + Asia/Kamchatka + """ + TZ_ASIA_KAMCHATKA + + """ + Asia/Karachi + """ + TZ_ASIA_KARACHI + + """ + Asia/Kathmandu + """ + TZ_ASIA_KATHMANDU + + """ + Asia/Kolkata + """ + TZ_ASIA_KOLKATA + + """ + Asia/Krasnoyarsk + """ + TZ_ASIA_KRASNOYARSK + + """ + Asia/Kuala_Lumpur + """ + TZ_ASIA_KUALA_LUMPUR + + """ + Asia/Kuwait + """ + TZ_ASIA_KUWAIT + + """ + Asia/Magadan + """ + TZ_ASIA_MAGADAN + + """ + Asia/Muscat + """ + TZ_ASIA_MUSCAT + + """ + Asia/Novosibirsk + """ + TZ_ASIA_NOVOSIBIRSK + + """ + Asia/Riyadh + """ + TZ_ASIA_RIYADH + + """ + Asia/Seoul + """ + TZ_ASIA_SEOUL + + """ + Asia/Shanghai + """ + TZ_ASIA_SHANGHAI + + """ + Asia/Singapore + """ + TZ_ASIA_SINGAPORE + + """ + Asia/Srednekolymsk + """ + TZ_ASIA_SREDNEKOLYMSK + + """ + Asia/Taipei + """ + TZ_ASIA_TAIPEI + + """ + Asia/Tashkent + """ + TZ_ASIA_TASHKENT + + """ + Asia/Tbilisi + """ + TZ_ASIA_TBILISI + + """ + Asia/Tehran + """ + TZ_ASIA_TEHRAN + + """ + Asia/Tokyo + """ + TZ_ASIA_TOKYO + + """ + Asia/Ulaanbaatar + """ + TZ_ASIA_ULAANBAATAR + + """ + Asia/Urumqi + """ + TZ_ASIA_URUMQI + + """ + Asia/Vladivostok + """ + TZ_ASIA_VLADIVOSTOK + + """ + Asia/Yakutsk + """ + TZ_ASIA_YAKUTSK + + """ + Asia/Yangon + """ + TZ_ASIA_YANGON + + """ + Asia/Yekaterinburg + """ + TZ_ASIA_YEKATERINBURG + + """ + Asia/Yerevan + """ + TZ_ASIA_YEREVAN + + """ + Atlantic/Azores + """ + TZ_ATLANTIC_AZORES + + """ + Atlantic/Cape_Verde + """ + TZ_ATLANTIC_CAPE_VERDE + + """ + Atlantic/South_Georgia + """ + TZ_ATLANTIC_SOUTH_GEORGIA + + """ + Australia/Adelaide + """ + TZ_AUSTRALIA_ADELAIDE + + """ + Australia/Brisbane + """ + TZ_AUSTRALIA_BRISBANE + + """ + Australia/Canberra + """ + TZ_AUSTRALIA_CANBERRA + + """ + Australia/Darwin + """ + TZ_AUSTRALIA_DARWIN + + """ + Australia/Hobart + """ + TZ_AUSTRALIA_HOBART + + """ + Australia/Melbourne + """ + TZ_AUSTRALIA_MELBOURNE + + """ + Australia/Perth + """ + TZ_AUSTRALIA_PERTH + + """ + Australia/Sydney + """ + TZ_AUSTRALIA_SYDNEY + + """ + Etc/GMT+12 + """ + TZ_ETC_GMT_12 + + """ + Europe/Amsterdam + """ + TZ_EUROPE_AMSTERDAM + + """ + Europe/Athens + """ + TZ_EUROPE_ATHENS + + """ + Europe/Belgrade + """ + TZ_EUROPE_BELGRADE + + """ + Europe/Berlin + """ + TZ_EUROPE_BERLIN + + """ + Europe/Bratislava + """ + TZ_EUROPE_BRATISLAVA + + """ + Europe/Brussels + """ + TZ_EUROPE_BRUSSELS + + """ + Europe/Bucharest + """ + TZ_EUROPE_BUCHAREST + + """ + Europe/Budapest + """ + TZ_EUROPE_BUDAPEST + + """ + Europe/Copenhagen + """ + TZ_EUROPE_COPENHAGEN + + """ + Europe/Dublin + """ + TZ_EUROPE_DUBLIN + + """ + Europe/Helsinki + """ + TZ_EUROPE_HELSINKI + + """ + Europe/Istanbul + """ + TZ_EUROPE_ISTANBUL + + """ + Europe/Kaliningrad + """ + TZ_EUROPE_KALININGRAD + + """ + Europe/Kyiv + """ + TZ_EUROPE_KYIV + + """ + Europe/Lisbon + """ + TZ_EUROPE_LISBON + + """ + Europe/Ljubljana + """ + TZ_EUROPE_LJUBLJANA + + """ + Europe/London + """ + TZ_EUROPE_LONDON + + """ + Europe/Madrid + """ + TZ_EUROPE_MADRID + + """ + Europe/Minsk + """ + TZ_EUROPE_MINSK + + """ + Europe/Moscow + """ + TZ_EUROPE_MOSCOW + + """ + Europe/Paris + """ + TZ_EUROPE_PARIS + + """ + Europe/Prague + """ + TZ_EUROPE_PRAGUE + + """ + Europe/Riga + """ + TZ_EUROPE_RIGA + + """ + Europe/Rome + """ + TZ_EUROPE_ROME + + """ + Europe/Samara + """ + TZ_EUROPE_SAMARA + + """ + Europe/Sarajevo + """ + TZ_EUROPE_SARAJEVO + + """ + Europe/Skopje + """ + TZ_EUROPE_SKOPJE + + """ + Europe/Sofia + """ + TZ_EUROPE_SOFIA + + """ + Europe/Stockholm + """ + TZ_EUROPE_STOCKHOLM + + """ + Europe/Tallinn + """ + TZ_EUROPE_TALLINN + + """ + Europe/Vienna + """ + TZ_EUROPE_VIENNA + + """ + Europe/Vilnius + """ + TZ_EUROPE_VILNIUS + + """ + Europe/Volgograd + """ + TZ_EUROPE_VOLGOGRAD + + """ + Europe/Warsaw + """ + TZ_EUROPE_WARSAW + + """ + Europe/Zagreb + """ + TZ_EUROPE_ZAGREB + + """ + Europe/Zurich + """ + TZ_EUROPE_ZURICH + + """ + Pacific/Apia + """ + TZ_PACIFIC_APIA + + """ + Pacific/Auckland + """ + TZ_PACIFIC_AUCKLAND + + """ + Pacific/Chatham + """ + TZ_PACIFIC_CHATHAM + + """ + Pacific/Fakaofo + """ + TZ_PACIFIC_FAKAOFO + + """ + Pacific/Fiji + """ + TZ_PACIFIC_FIJI + + """ + Pacific/Guadalcanal + """ + TZ_PACIFIC_GUADALCANAL + + """ + Pacific/Guam + """ + TZ_PACIFIC_GUAM + + """ + Pacific/Honolulu + """ + TZ_PACIFIC_HONOLULU + + """ + Pacific/Majuro + """ + TZ_PACIFIC_MAJURO + + """ + Pacific/Midway + """ + TZ_PACIFIC_MIDWAY + + """ + Pacific/Noumea + """ + TZ_PACIFIC_NOUMEA + + """ + Pacific/Pago_Pago + """ + TZ_PACIFIC_PAGO_PAGO + + """ + Pacific/Port_Moresby + """ + TZ_PACIFIC_PORT_MORESBY + + """ + Pacific/Tongatapu + """ + TZ_PACIFIC_TONGATAPU + + """ + UTC + """ + TZ_UTC +} + +type TransactionMetadata { + key: String! + value: String! +} + +""" +Autogenerated input type of UpdateAddOn +""" +input UpdateAddOnInput { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + id: ID! + invoiceDisplayName: String + name: String! + taxCodes: [String!] +} + +""" +Update input arguments +""" +input UpdateAdyenPaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + flowId: String + id: ID! + name: String + successRedirectUrl: String + supports3ds: Boolean +} + +""" +Autogenerated input type of UpdateAnrokIntegration +""" +input UpdateAnrokIntegrationInput { + apiKey: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + id: ID + name: String +} + +""" +Autogenerated input type of UpdateApiKey +""" +input UpdateApiKeyInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + name: String + permissions: JSON +} + +""" +Autogenerated input type of UpdateAvalaraIntegration +""" +input UpdateAvalaraIntegrationInput { + accountId: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + companyCode: String + id: ID + licenseKey: String + name: String +} + +""" +Update Billable metric input arguments +""" +input UpdateBillableMetricInput { + aggregationType: AggregationTypeEnum! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String! + expression: String + fieldName: String + filters: [BillableMetricFiltersInput!] + id: String! + name: String! + recurring: Boolean + roundingFunction: RoundingFunctionEnum + roundingPrecision: Int + weightedInterval: WeightedIntervalEnum +} + +""" +Update Billing Entity input arguments +""" +input UpdateBillingEntityInput { + addressLine1: String + addressLine2: String + billingConfiguration: BillingEntityBillingConfigurationInput + city: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + country: CountryCode + defaultCurrency: CurrencyEnum + documentNumberPrefix: String + documentNumbering: BillingEntityDocumentNumberingEnum + einvoicing: Boolean + email: String + emailSettings: [BillingEntityEmailSettingsEnum!] + euTaxManagement: Boolean + finalizeZeroAmountInvoice: Boolean + id: ID! + invoiceCustomSectionIds: [ID!] + legalName: String + legalNumber: String + logo: String + name: String + netPaymentTerm: Int + state: String + taxIdentificationNumber: String + timezone: TimezoneEnum + zipcode: String +} + +""" +Update input arguments +""" +input UpdateCashfreePaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + flowId: String + id: ID! + name: String + successRedirectUrl: String + supports3ds: Boolean +} + +""" +Autogenerated input type of UpdateCoupon +""" +input UpdateCouponInput { + amountCents: BigInt + amountCurrency: CurrencyEnum + appliesTo: LimitationInput + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + couponType: CouponTypeEnum! + description: String + expiration: CouponExpiration! + expirationAt: ISO8601DateTime + frequency: CouponFrequency! + frequencyDuration: Int + id: String! + name: String! + percentageRate: Float + reusable: Boolean +} + +""" +Update Credit Note input arguments +""" +input UpdateCreditNoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + metadata: [MetadataInput!] + refundStatus: CreditNoteRefundStatusEnum +} + +""" +Update Customer input arguments +""" +input UpdateCustomerInput { + accountType: CustomerAccountTypeEnum + addressLine1: String + addressLine2: String + appliedDunningCampaignId: ID + billingConfiguration: CustomerBillingConfigurationInput + billingEntityCode: String + city: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + configurableInvoiceCustomSectionIds: [ID!] + country: CountryCode + currency: CurrencyEnum + customerType: CustomerTypeEnum + email: String + excludeFromDunningCampaign: Boolean + externalId: String! + externalSalesforceId: String + finalizeZeroAmountInvoice: FinalizeZeroAmountInvoiceEnum + firstname: String + id: ID! + integrationCustomers: [IntegrationCustomerInput!] + invoiceGracePeriod: Int + lastname: String + legalName: String + legalNumber: String + logoUrl: String + metadata: [CustomerMetadataInput!] + name: String + netPaymentTerm: Int + paymentProvider: ProviderTypeEnum + paymentProviderCode: String + phone: String + providerCustomer: ProviderCustomerInput + shippingAddress: CustomerAddressInput + skipInvoiceCustomSections: Boolean + state: String + taxCodes: [String!] + taxIdentificationNumber: String + timezone: TimezoneEnum + url: String + zipcode: String +} + +""" +Autogenerated input type of UpdateCustomerInvoiceGracePeriod +""" +input UpdateCustomerInvoiceGracePeriodInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + invoiceGracePeriod: Int +} + +""" +Customer Portal Customer Update input arguments +""" +input UpdateCustomerPortalCustomerInput { + addressLine1: String + addressLine2: String + city: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + country: CountryCode + customerType: CustomerTypeEnum + documentLocale: String + email: String + firstname: String + lastname: String + legalName: String + name: String + shippingAddress: CustomerAddressInput + state: String + taxIdentificationNumber: String + zipcode: String +} + +""" +Autogenerated input type of UpdateCustomerWalletAlert +""" +input UpdateCustomerWalletAlertInput { + billableMetricId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + id: ID! + name: String + thresholds: [ThresholdInput!] +} + +""" +Update Wallet Input +""" +input UpdateCustomerWalletInput { + appliesTo: AppliesToInput + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + expirationAt: ISO8601DateTime + id: ID! + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + invoiceRequiresSuccessfulPayment: Boolean + metadata: [MetadataInput!] + name: String + paidTopUpMaxAmountCents: BigInt + paidTopUpMinAmountCents: BigInt + paymentMethod: PaymentMethodReferenceInput + priority: Int! + recurringTransactionRules: [UpdateRecurringTransactionRuleInput!] +} + +""" +Autogenerated input type of UpdateDunningCampaign +""" +input UpdateDunningCampaignInput { + appliedToOrganization: Boolean + bccEmails: [String!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + daysBetweenAttempts: Int + description: String + id: ID! + maxAttempts: Int + name: String + thresholds: [DunningCampaignThresholdInput!] +} + +""" +Input for updating a feature +""" +input UpdateFeatureInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + The description of the feature + """ + description: String + + """ + The ID of the feature to update + """ + id: ID! + + """ + The name of the feature + """ + name: String + + """ + The privileges configuration + """ + privileges: [UpdatePrivilegeInput!]! +} + +""" +Update input arguments +""" +input UpdateFlutterwavePaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + flowId: String + id: ID! + name: String + successRedirectUrl: String + supports3ds: Boolean +} + +""" +Update input arguments +""" +input UpdateGocardlessPaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + flowId: String + id: ID! + name: String + successRedirectUrl: String + supports3ds: Boolean +} + +""" +Autogenerated input type of UpdateHubspotIntegration +""" +input UpdateHubspotIntegrationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + connectionId: String + defaultTargetedObject: HubspotTargetedObjectsEnum + id: ID + name: String + syncInvoices: Boolean + syncSubscriptions: Boolean +} + +""" +Autogenerated input type of UpdateIntegrationCollectionMapping +""" +input UpdateIntegrationCollectionMappingInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + currencies: [CurrencyMappingItemInput!] + externalAccountCode: String + externalId: String + externalName: String + id: ID! + integrationId: ID + mappingType: MappingTypeEnum + taxCode: String + taxNexus: String + taxType: String +} + +""" +Autogenerated input type of UpdateIntegrationMapping +""" +input UpdateIntegrationMappingInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + externalAccountCode: String + externalId: String + externalName: String + id: ID! + integrationId: ID + mappableId: ID + mappableType: MappableTypeEnum +} + +""" +Autogenerated input type of UpdateInvite +""" +input UpdateInviteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + roles: [String!]! +} + +""" +Autogenerated input type of UpdateInvoiceCustomSection +""" +input UpdateInvoiceCustomSectionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + description: String + details: String + displayName: String + id: ID! + name: String +} + +""" +Update Invoice input arguments +""" +input UpdateInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + metadata: [InvoiceMetadataInput!] + paymentStatus: InvoicePaymentStatusTypeEnum +} + +""" +Autogenerated input type of UpdateMembership +""" +input UpdateMembershipInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + roles: [String!]! +} + +""" +Update input arguments +""" +input UpdateMoneyhashPaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + flowId: String + id: ID! + name: String + successRedirectUrl: String + supports3ds: Boolean +} + +""" +Autogenerated input type of UpdateNetsuiteIntegration +""" +input UpdateNetsuiteIntegrationInput { + accountId: String + clientId: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + clientSecret: String + code: String + connectionId: String + id: ID + name: String + scriptEndpointUrl: String + syncCreditNotes: Boolean + syncInvoices: Boolean + syncPayments: Boolean + tokenId: String + tokenSecret: String +} + +""" +Autogenerated input type of UpdateOktaIntegration +""" +input UpdateOktaIntegrationInput { + clientId: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + clientSecret: String + domain: String + host: String + id: ID + organizationName: String +} + +""" +Update Organization input arguments +""" +input UpdateOrganizationInput { + addressLine1: String + addressLine2: String + authenticationMethods: [AuthenticationMethodsEnum!] + billingConfiguration: OrganizationBillingConfigurationInput + city: String + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + country: CountryCode + defaultCurrency: CurrencyEnum + documentNumberPrefix: String + documentNumbering: DocumentNumberingEnum + email: String + emailSettings: [EmailSettingsEnum!] + euTaxManagement: Boolean + finalizeZeroAmountInvoice: Boolean + legalName: String + legalNumber: String + logo: String + netPaymentTerm: Int + slug: String + state: String + taxIdentificationNumber: String + timezone: TimezoneEnum + webhookUrl: String + zipcode: String +} + +""" +Autogenerated input type of UpdatePlan +""" +input UpdatePlanInput { + amountCents: BigInt! + amountCurrency: CurrencyEnum! + billChargesMonthly: Boolean + billFixedChargesMonthly: Boolean + cascadeUpdates: Boolean + charges: [ChargeInput!]! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String! + description: String + entitlements: [EntitlementInput!] + fixedCharges: [FixedChargeInput!] + id: ID! + interval: PlanInterval! + invoiceDisplayName: String + metadata: [MetadataInput!] + minimumCommitment: CommitmentInput + name: String! + payInAdvance: Boolean! + taxCodes: [String!] + trialPeriod: Float + usageThresholds: [UsageThresholdInput!] +} + +""" +Autogenerated input type of UpdatePricingUnit +""" +input UpdatePricingUnitInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + description: String + id: ID! + name: String + shortName: String +} + +""" +Input for updating a privilege +""" +input UpdatePrivilegeInput { + code: String! + config: PrivilegeConfigInput + name: String + valueType: PrivilegeValueTypeEnum +} + +""" +Autogenerated input type of UpdateQuote +""" +input UpdateQuoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + owners: [ID!] +} + +""" +Autogenerated input type of UpdateQuoteVersion +""" +input UpdateQuoteVersionInput { + billingItems: JSON + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + content: String + id: ID! +} + +input UpdateRecurringTransactionRuleInput { + expirationAt: ISO8601DateTime + grantedCredits: String + ignorePaidTopUpLimits: Boolean + interval: RecurringTransactionIntervalEnum + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + invoiceRequiresSuccessfulPayment: Boolean + lagoId: ID + method: RecurringTransactionMethodEnum + paidCredits: String + paymentMethod: PaymentMethodReferenceInput + startedAt: ISO8601DateTime + targetOngoingBalance: String + thresholdCredits: String + transactionMetadata: [CreateTransactionMetadataInput!] + transactionName: String + trigger: RecurringTransactionTriggerEnum +} + +""" +Update Role input arguments +""" +input UpdateRoleInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + description: String + id: ID! + name: String + permissions: [PermissionEnum!] +} + +""" +Autogenerated input type of UpdateSalesforceIntegration +""" +input UpdateSalesforceIntegrationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + id: ID + instanceId: String + name: String +} + +""" +Update input arguments +""" +input UpdateStripePaymentProviderInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + flowId: String + id: ID! + name: String + successRedirectUrl: String + supports3ds: Boolean +} + +""" +Autogenerated input type of UpdateSubscriptionAlert +""" +input UpdateSubscriptionAlertInput { + billableMetricId: ID + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + id: ID! + name: String + thresholds: [ThresholdInput!] +} + +""" +Update subscription charge filter input arguments +""" +input UpdateSubscriptionChargeFilterInput { + chargeCode: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + invoiceDisplayName: String + properties: PropertiesInput + subscriptionId: ID! + values: ChargeFilterValues! +} + +""" +Autogenerated input type of UpdateSubscriptionCharge +""" +input UpdateSubscriptionChargeInput { + appliedPricingUnit: AppliedPricingUnitOverrideInput + chargeCode: String! + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + filters: [ChargeFilterInput!] + invoiceDisplayName: String + minAmountCents: BigInt + properties: PropertiesInput + subscriptionId: ID! + taxCodes: [String!] +} + +""" +Autogenerated input type of UpdateSubscriptionFixedCharge +""" +input UpdateSubscriptionFixedChargeInput { + applyUnitsImmediately: Boolean + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + fixedChargeCode: String! + invoiceDisplayName: String + properties: FixedChargePropertiesInput + subscriptionId: ID! + taxCodes: [String!] + units: String +} + +""" +Update Subscription input arguments +""" +input UpdateSubscriptionInput { + activationRules: [SubscriptionActivationRuleInput!] + + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + endingAt: ISO8601DateTime + id: ID! + invoiceCustomSection: InvoiceCustomSectionsReferenceInput + name: String + paymentMethod: PaymentMethodReferenceInput + planOverrides: PlanOverridesInput + progressiveBillingDisabled: Boolean + subscriptionAt: ISO8601DateTime + usageThresholds: [UsageThresholdInput!] +} + +""" +Autogenerated input type of UpdateXeroIntegration +""" +input UpdateXeroIntegrationInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + code: String + connectionId: String + id: ID + name: String + syncCreditNotes: Boolean + syncInvoices: Boolean + syncPayments: Boolean +} + +type UsageThreshold { + amountCents: BigInt! + id: ID! + recurring: Boolean! + thresholdDisplayName: String +} + +input UsageThresholdInput { + amountCents: BigInt! + recurring: Boolean + thresholdDisplayName: String +} + +type User { + createdAt: ISO8601DateTime! + email: String + id: ID! + memberships: [Membership!]! + organizations: [Organization!]! + premium: Boolean! + updatedAt: ISO8601DateTime! +} + +""" +Autogenerated input type of VoidCreditNote +""" +input VoidCreditNoteInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! +} + +""" +Void Invoice input arguments +""" +input VoidInvoiceInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + creditAmount: BigInt + generateCreditNote: Boolean + id: ID! + refundAmount: BigInt +} + +""" +Autogenerated input type of VoidQuoteVersion +""" +input VoidQuoteVersionInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + id: ID! + reason: VoidReasonEnum! +} + +enum VoidReasonEnum { + cascade_of_expired + cascade_of_voided + manual + superseded +} + +""" +Fee input for creating or updating invoice from voided invoice +""" +input VoidedInvoiceFeeInput { + addOnId: ID + chargeFilterId: ID + chargeId: ID + description: String + fixedChargeId: ID + id: ID + invoiceDisplayName: String + subscriptionId: ID + unitAmountCents: BigInt + units: Float +} + +type VolumeRange { + flatAmount: String! + fromValue: BigInt! + perUnitAmount: String! + toValue: BigInt +} + +input VolumeRangeInput { + flatAmount: String! + fromValue: BigInt! + perUnitAmount: String! + toValue: BigInt +} + +""" +Wallet +""" +type Wallet { + activityLogs: [ActivityLog!] + appliesTo: WalletAppliesTo + balanceCents: BigInt! + code: String + consumedAmountCents: BigInt! + consumedCredits: Float! + createdAt: ISO8601DateTime! + creditsBalance: Float! + creditsOngoingBalance: Float! + creditsOngoingUsageBalance: Float! + currency: CurrencyEnum! + customer: Customer + expirationAt: ISO8601DateTime + id: ID! + invoiceRequiresSuccessfulPayment: Boolean! + lastBalanceSyncAt: ISO8601DateTime + lastConsumedCreditAt: ISO8601DateTime + lastOngoingBalanceSyncAt: ISO8601DateTime + metadata: [ItemMetadata!] + name: String + ongoingBalanceCents: BigInt! + ongoingUsageBalanceCents: BigInt! + paidTopUpMaxAmountCents: BigInt + paidTopUpMaxCredits: BigInt + paidTopUpMinAmountCents: BigInt + paidTopUpMinCredits: BigInt + paymentMethod: PaymentMethod + paymentMethodType: PaymentMethodTypeEnum + priority: Int! + rateAmount: Float! + recurringTransactionRules: [RecurringTransactionRule!] + selectedInvoiceCustomSections: [InvoiceCustomSection!] + skipInvoiceCustomSections: Boolean + status: WalletStatusEnum! + terminatedAt: ISO8601DateTime + traceable: Boolean! + updatedAt: ISO8601DateTime! +} + +type WalletAppliesTo { + billableMetrics: [BillableMetric!] + feeTypes: [FeeTypesEnum!] +} + +""" +WalletCollection type +""" +type WalletCollection { + """ + A collection of paginated WalletCollection + """ + collection: [Wallet!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: WalletCollectionMetadata! +} + +""" +Type for CollectionMetadataType +""" +type WalletCollectionMetadata { + """ + Current Page of loaded data + """ + currentPage: Int! + customerActiveWalletsCount: Int! + + """ + The number of items per page + """ + limitValue: Int! + + """ + The total number of items to be paginated + """ + totalCount: Int! + + """ + The total number of pages in the pagination + """ + totalPages: Int! +} + +enum WalletStatusEnum { + active + terminated +} + +type WalletTransaction { + amount: String! + createdAt: ISO8601DateTime! + creditAmount: String! + failedAt: ISO8601DateTime + id: ID! + invoice: Invoice + invoiceRequiresSuccessfulPayment: Boolean! + metadata: [WalletTransactionMetadataObject!] + name: String + priority: Int! + remainingAmountCents: BigInt + remainingCreditAmount: String + selectedInvoiceCustomSections: [InvoiceCustomSection!] + settledAt: ISO8601DateTime + skipInvoiceCustomSections: Boolean + source: WalletTransactionSourceEnum! + status: WalletTransactionStatusEnum! + transactionStatus: WalletTransactionTransactionStatusEnum! + transactionType: WalletTransactionTransactionTypeEnum! + updatedAt: ISO8601DateTime! + voidedInvoice: Invoice + wallet: Wallet + walletName: String +} + +""" +WalletTransactionCollection type +""" +type WalletTransactionCollection { + """ + A collection of paginated WalletTransactionCollection + """ + collection: [WalletTransaction!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type WalletTransactionConsumption { + amountCents: BigInt! + createdAt: ISO8601DateTime! + creditAmount: String! + id: ID! + walletTransaction: WalletTransaction! +} + +""" +WalletTransactionConsumptionCollection type +""" +type WalletTransactionConsumptionCollection { + """ + A collection of paginated WalletTransactionConsumptionCollection + """ + collection: [WalletTransactionConsumption!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type WalletTransactionFunding { + amountCents: BigInt! + createdAt: ISO8601DateTime! + creditAmount: String! + id: ID! + walletTransaction: WalletTransaction! +} + +""" +WalletTransactionFundingCollection type +""" +type WalletTransactionFundingCollection { + """ + A collection of paginated WalletTransactionFundingCollection + """ + collection: [WalletTransactionFunding!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +input WalletTransactionMetadataInput { + key: String! + value: String! +} + +type WalletTransactionMetadataObject { + key: String! + value: String! +} + +enum WalletTransactionSourceEnum { + interval + manual + threshold +} + +enum WalletTransactionStatusEnum { + failed + pending + settled +} + +enum WalletTransactionTransactionStatusEnum { + granted + invoiced + purchased + voided +} + +enum WalletTransactionTransactionTypeEnum { + inbound + outbound +} + +type Webhook { + createdAt: ISO8601DateTime! + endpoint: String! + httpStatus: Int + id: ID! + lastRetriedAt: ISO8601DateTime + objectType: String! + payload: String + response: String + retries: Int! + status: WebhookStatusEnum! + updatedAt: ISO8601DateTime! + webhookEndpoint: WebhookEndpoint + webhookType: String! +} + +""" +WebhookCollection type +""" +type WebhookCollection { + """ + A collection of paginated WebhookCollection + """ + collection: [Webhook!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +type WebhookEndpoint { + createdAt: ISO8601DateTime! + eventTypes: [EventTypeEnum!] + id: ID! + name: String + organization: Organization + signatureAlgo: WebhookEndpointSignatureAlgoEnum + updatedAt: ISO8601DateTime! + webhookUrl: String! +} + +""" +WebhookEndpointCollection type +""" +type WebhookEndpointCollection { + """ + A collection of paginated WebhookEndpointCollection + """ + collection: [WebhookEndpoint!]! + + """ + Pagination Metadata for navigating the Pagination + """ + metadata: CollectionMetadata! +} + +""" +Autogenerated input type of CreateWebhookEndpoint +""" +input WebhookEndpointCreateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + eventTypes: [EventTypeEnum!] + name: String + signatureAlgo: WebhookEndpointSignatureAlgoEnum + webhookUrl: String! +} + +enum WebhookEndpointSignatureAlgoEnum { + hmac + jwt +} + +""" +Autogenerated input type of UpdateWebhookEndpoint +""" +input WebhookEndpointUpdateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + eventTypes: [EventTypeEnum!] + id: ID! + name: String + signatureAlgo: WebhookEndpointSignatureAlgoEnum + webhookUrl: String! +} + +type WebhookEventType { + category: EventCategoryEnum! + deprecated: Boolean! + description: String! + key: EventTypeEnum! + name: String! +} + +enum WebhookStatusEnum { + failed + pending + retrying + succeeded +} + +enum WeightedIntervalEnum { + seconds +} + +type XeroCustomer { + externalCustomerId: String + id: ID! + integrationCode: String + integrationId: ID + integrationType: IntegrationTypeEnum + syncWithProvider: Boolean +} + +type XeroIntegration { + code: String! + connectionId: ID! + hasMappingsConfigured: Boolean + id: ID! + name: String! + syncCreditNotes: Boolean + syncInvoices: Boolean + syncPayments: Boolean +} diff --git a/schema.json b/schema.json new file mode 100644 index 0000000..21d9301 --- /dev/null +++ b/schema.json @@ -0,0 +1,74880 @@ +{ + "data": { + "__schema": { + "queryType": { + "name": "Query" + }, + "mutationType": { + "name": "Mutation" + }, + "subscriptionType": { + "name": "GraphqlSubscription" + }, + "types": [ + { + "kind": "INPUT_OBJECT", + "name": "AcceptInviteInput", + "description": "Autogenerated input type of AcceptInvite", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "password", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "token", + "description": "Uniq token of the Invite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ActivationRuleStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "inactive", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "satisfied", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "declined", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expired", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "not_applicable", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ActivationRuleTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "payment", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ActivityLog", + "description": "Base activity log", + "fields": [ + { + "name": "activityId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityObject", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityObjectChanges", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activitySource", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ActivitySourceEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ActivityTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKey", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "SanitizedApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "loggedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resource", + "description": null, + "args": [], + "type": { + "kind": "UNION", + "name": "ActivityLogResourceObject", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userEmail", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ActivityLogCollection", + "description": "ActivityLogCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated ActivityLogCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "ActivityLogResourceObject", + "description": "Activity log resource", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Coupon", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "FeatureObject", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "PaymentReceipt", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "PaymentRequest", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "Wallet", + "ofType": null + } + ] + }, + { + "kind": "ENUM", + "name": "ActivitySourceEnum", + "description": "Activity Logs source enums", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "api", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "front", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "system", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ActivityTypeEnum", + "description": "Activity Logs type enums", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "billable_metric_created", + "description": "billable_metric.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metric_updated", + "description": "billable_metric.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metric_deleted", + "description": "billable_metric.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan_created", + "description": "plan.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan_updated", + "description": "plan.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan_deleted", + "description": "plan.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_created", + "description": "customer.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_updated", + "description": "customer.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_deleted", + "description": "customer.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_drafted", + "description": "invoice.drafted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_failed", + "description": "invoice.failed", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_created", + "description": "invoice.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_one_off_created", + "description": "invoice.one_off_created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_paid_credit_added", + "description": "invoice.paid_credit_added", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_generated", + "description": "invoice.generated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_payment_status_updated", + "description": "invoice.payment_status_updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_payment_overdue", + "description": "invoice.payment_overdue", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_voided", + "description": "invoice.voided", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_regenerated", + "description": "invoice.regenerated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_payment_failure", + "description": "invoice.payment_failure", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipt_created", + "description": "payment_receipt.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipt_generated", + "description": "payment_receipt.generated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_created", + "description": "credit_note.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_generated", + "description": "credit_note.generated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_refund_failure", + "description": "credit_note.refund_failure", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entities_created", + "description": "billing_entities.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entities_updated", + "description": "billing_entities.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entities_deleted", + "description": "billing_entities.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_canceled", + "description": "subscription.canceled", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_incomplete", + "description": "subscription.incomplete", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_started", + "description": "subscription.started", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_terminated", + "description": "subscription.terminated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_updated", + "description": "subscription.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_created", + "description": "wallet.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_updated", + "description": "wallet.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_transaction_payment_failure", + "description": "wallet_transaction.payment_failure", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_transaction_created", + "description": "wallet_transaction.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_transaction_updated", + "description": "wallet_transaction.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_recorded", + "description": "payment.recorded", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupon_created", + "description": "coupon.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupon_updated", + "description": "coupon.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupon_deleted", + "description": "coupon.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applied_coupon_created", + "description": "applied_coupon.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applied_coupon_deleted", + "description": "applied_coupon.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_request_created", + "description": "payment_request.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email_sent", + "description": "email.sent", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature_created", + "description": "feature.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature_deleted", + "description": "feature.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature_updated", + "description": "feature.updated", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AddAdyenPaymentProviderInput", + "description": "Adyen input arguments", + "fields": null, + "inputFields": [ + { + "name": "apiKey", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hmacKey", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "livePrefix", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "merchantAccount", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AddCashfreePaymentProviderInput", + "description": "Cashfree input arguments", + "fields": null, + "inputFields": [ + { + "name": "clientId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AddFlutterwavePaymentProviderInput", + "description": "Flutterwave input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "secretKey", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AddGocardlessPaymentProviderInput", + "description": "Gocardless input arguments", + "fields": null, + "inputFields": [ + { + "name": "accessCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AddMoneyhashPaymentProviderInput", + "description": "Moneyhash input arguments", + "fields": null, + "inputFields": [ + { + "name": "apiKey", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AddOn", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedAddOnsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersCount", + "description": "Number of customers using this add-on", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationMappings", + "description": null, + "args": [ + { + "name": "integrationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Mapping", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AddOnCollection", + "description": "AddOnCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated AddOnCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AddOn", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AddStripePaymentProviderInput", + "description": "Stripe input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "secretKey", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "AdjustedFeeTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "adjusted_units", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adjusted_amount", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AdyenProvider", + "description": null, + "fields": [ + { + "name": "apiKey", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hmacKey", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "livePrefix", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "merchantAccount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "AggregationTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "count_agg", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sum_agg", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "max_agg", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unique_count_agg", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "weighted_sum_agg", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "latest_agg", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "custom_agg", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AiConversation", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mistralConversationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AiConversationCollection", + "description": "AiConversationCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated AiConversationCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AiConversation", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AiConversationMessage", + "description": null, + "fields": [ + { + "name": "content", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AiConversationStream", + "description": null, + "fields": [ + { + "name": "chunk", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "done", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AiConversationWithMessages", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "messages", + "description": "Messages belonging to this conversation", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AiConversationMessage", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mistralConversationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Alert", + "description": null, + "fields": [ + { + "name": "alertType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AlertTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetric", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "direction", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DirectionEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionExternalId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AlertThreshold", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AlertCollection", + "description": "AlertCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated AlertCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AlertThreshold", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "AlertTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "current_usage_amount", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metric_current_usage_amount", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metric_current_usage_units", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lifetime_usage_amount", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metric_lifetime_usage_units", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_balance_amount", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_credits_balance", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_ongoing_balance_amount", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_credits_ongoing_balance", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AnrokCustomer", + "description": null, + "fields": [ + { + "name": "externalAccountId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AnrokIntegration", + "description": null, + "fields": [ + { + "name": "apiKey", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalAccountId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failedInvoicesCount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasMappingsConfigured", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ApiKey", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiresAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastUsedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ApiLog", + "description": "Base api log", + "fields": [ + { + "name": "apiKey", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "SanitizedApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiVersion", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "client", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "httpMethod", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "HttpMethodEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "httpStatus", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "loggedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestBody", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestOrigin", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestPath", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestResponse", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ApiLogCollection", + "description": "ApiLogCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated ApiLogCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApiLog", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AppliedAddOn", + "description": null, + "fields": [ + { + "name": "addOn", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AddOn", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AppliedCoupon", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCentsRemaining", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupon", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Coupon", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponFrequency", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequencyDuration", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequencyDurationRemaining", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "percentageRate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AppliedCouponStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AppliedCouponCollection", + "description": "AppliedCouponCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated AppliedCouponCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppliedCoupon", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "AppliedCouponStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "active", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AppliedPricingUnit", + "description": null, + "fields": [ + { + "name": "conversionRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnit", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PricingUnit", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AppliedPricingUnitInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "conversionRate", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "AppliedPricingUnitOverrideInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "conversionRate", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "AppliedTax", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxDescription", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "CreditNoteAppliedTax", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "FeeAppliedTax", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "InvoiceAppliedTax", + "ofType": null + } + ] + }, + { + "kind": "INPUT_OBJECT", + "name": "AppliesToInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "billableMetricIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feeTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FeeTypesEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ApplyTaxesInput", + "description": "Autogenerated input type of ApplyTaxes", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ApplyTaxesPayload", + "description": "Autogenerated return type of ApplyTaxes.", + "fields": [ + { + "name": "appliedTaxes", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ApproveQuoteVersionInput", + "description": "Autogenerated input type of ApproveQuoteVersion", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AuthUrl", + "description": null, + "fields": [ + { + "name": "url", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "AuthenticationMethodsEnum", + "description": "Organization Authentication Methods Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "email_password", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "google_oauth", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "okta", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Authorize", + "description": null, + "fields": [ + { + "name": "url", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AvalaraCustomer", + "description": null, + "fields": [ + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "AvalaraIntegration", + "description": null, + "fields": [ + { + "name": "accountId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "companyCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "companyId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failedInvoicesCount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasMappingsConfigured", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "licenseKey", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "BigInt", + "description": "Represents non-fractional signed whole numeric values. Since the value may exceed the size of a 32-bit integer, it's encoded as a string.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "BillableMetric", + "description": "Base billable metric", + "fields": [ + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "aggregationType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AggregationTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expression", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fieldName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetricFilter", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasActiveSubscriptions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasDraftInvoices", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasPlans", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasSubscriptions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationMappings", + "description": null, + "args": [ + { + "name": "integrationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Mapping", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roundingFunction", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "RoundingFunctionEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roundingPrecision", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "weightedInterval", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "WeightedIntervalEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "BillableMetricCollection", + "description": "BillableMetricCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated BillableMetricCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "BillableMetricFilter", + "description": "Billable metric filters", + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "key", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "BillableMetricFiltersInput", + "description": "Billable metric filters input arguments", + "fields": null, + "inputFields": [ + { + "name": "key", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "BillingEntity", + "description": "Base billing entity", + "fields": [ + { + "name": "addressLine1", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedDunningCampaign", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "DunningCampaign", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "BillingEntityBillingConfiguration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumberPrefix", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumbering", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "BillingEntityDocumentNumberingEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "einvoicing", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "emailSettings", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "BillingEntityEmailSettingsEnum", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "euTaxManagement", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDefault", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logoUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selectedInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "BillingEntityBillingConfiguration", + "description": null, + "fields": [ + { + "name": "documentLocale", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceFooter", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAdjustment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAnchor", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "BillingEntityBillingConfigurationInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "documentLocale", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumbering", + "description": null, + "type": { + "kind": "ENUM", + "name": "BillingEntityDocumentNumberingEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceFooter", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAdjustment", + "description": null, + "type": { + "kind": "ENUM", + "name": "BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAnchor", + "description": null, + "type": { + "kind": "ENUM", + "name": "BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "BillingEntityCollection", + "description": "BillingEntityCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated BillingEntityCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "BillingEntityDocumentNumberingEnum", + "description": "Document numbering type", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "per_customer", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "per_billing_entity", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "BillingEntityEmailSettingsEnum", + "description": "BillingEntity Email Settings Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "invoice_finalized", + "description": "invoice.finalized", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_created", + "description": "credit_note.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipt_created", + "description": "payment_receipt.created", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum", + "description": "Subscription Invoice Issuing Date Adjustment Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "keep_anchor", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "align_with_finalization_date", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum", + "description": "Subscription Invoice Issuing Date Anchor Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "current_period_end", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "next_period_start", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "BillingEntityUpdateAppliedDunningCampaignInput", + "description": "Autogenerated input type of BillingEntityUpdateAppliedDunningCampaign", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedDunningCampaignId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "BillingTimeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "calendar", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "anniversary", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Boolean", + "description": "Represents `true` or `false` values.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CancelationReasonEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "payment_failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeout", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CashfreeProvider", + "description": null, + "fields": [ + { + "name": "clientId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Charge", + "description": null, + "fields": [ + { + "name": "appliedPricingUnit", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "AppliedPricingUnit", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetric", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ChargeModelEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChargeFilter", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "parentId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Properties", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "regroupPaidFees", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "RegroupPaidFeesEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ChargeCreateInput", + "description": "Autogenerated input type of CreateCharge", + "fields": null, + "inputFields": [ + { + "name": "planId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ChargeModelEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceable", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "regroupPaidFees", + "description": null, + "type": { + "kind": "ENUM", + "name": "RegroupPaidFeesEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedPricingUnit", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AppliedPricingUnitInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ChargeFilter", + "description": "Charge filters object", + "fields": [ + { + "name": "chargeCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Properties", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterCreateInput", + "description": "Charge filter create input arguments", + "fields": null, + "inputFields": [ + { + "name": "chargeId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterInput", + "description": "Charge filters input arguments", + "fields": null, + "inputFields": [ + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterUpdateInput", + "description": "Charge filter update input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ChargeFilterUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ChargeInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "billableMetricId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ChargeModelEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceable", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "regroupPaidFees", + "description": null, + "type": { + "kind": "ENUM", + "name": "RegroupPaidFeesEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedPricingUnit", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AppliedPricingUnitInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ChargeModelEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "standard", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "package", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "percentage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "volume", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduated_percentage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "custom", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dynamic", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ChargeOverridesInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "billableMetricId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedPricingUnit", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AppliedPricingUnitOverrideInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ChargeUpdateInput", + "description": "Autogenerated input type of UpdateCharge", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "type": { + "kind": "ENUM", + "name": "ChargeModelEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceable", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "regroupPaidFees", + "description": null, + "type": { + "kind": "ENUM", + "name": "RegroupPaidFeesEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedPricingUnit", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AppliedPricingUnitInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ChargeUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetric", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charge", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChargeFilterUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "groupedUsage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GroupedChargeUsage", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "presentationBreakdowns", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PresentationBreakdownUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CloneQuoteVersionInput", + "description": "Autogenerated input type of CloneQuoteVersion", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CollectionMapping", + "description": null, + "fields": [ + { + "name": "billingEntityId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currencies", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CurrencyMappingItem", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalAccountCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappingType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MappingTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxNexus", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxType", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CollectionMappingCollection", + "description": "CollectionMappingCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated CollectionMappingCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMapping", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CollectionMetadata", + "description": "Type for CollectionMetadataType", + "fields": [ + { + "name": "currentPage", + "description": "Current Page of loaded data", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limitValue", + "description": "The number of items per page", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalCount", + "description": "The total number of items to be paginated", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalPages", + "description": "The total number of pages in the pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Commitment", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "commitmentType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CommitmentTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CommitmentInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "commitmentType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CommitmentTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CommitmentTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "minimum_commitment", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CountryCode", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "AD", + "description": "Andorra", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AE", + "description": "United Arab Emirates", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AF", + "description": "Afghanistan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AG", + "description": "Antigua and Barbuda", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AI", + "description": "Anguilla", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AL", + "description": "Albania", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AM", + "description": "Armenia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AO", + "description": "Angola", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AQ", + "description": "Antarctica", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AR", + "description": "Argentina", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AS", + "description": "American Samoa", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AT", + "description": "Austria", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AU", + "description": "Australia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AW", + "description": "Aruba", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AX", + "description": "Åland Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AZ", + "description": "Azerbaijan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BA", + "description": "Bosnia and Herzegovina", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BB", + "description": "Barbados", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BD", + "description": "Bangladesh", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BE", + "description": "Belgium", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BF", + "description": "Burkina Faso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BG", + "description": "Bulgaria", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BH", + "description": "Bahrain", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BI", + "description": "Burundi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BJ", + "description": "Benin", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BL", + "description": "Saint Barthélemy", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BM", + "description": "Bermuda", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BN", + "description": "Brunei Darussalam", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BO", + "description": "Bolivia (Plurinational State of)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BQ", + "description": "Bonaire, Sint Eustatius and Saba", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BR", + "description": "Brazil", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BS", + "description": "Bahamas", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BT", + "description": "Bhutan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BV", + "description": "Bouvet Island", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BW", + "description": "Botswana", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BY", + "description": "Belarus", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BZ", + "description": "Belize", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CA", + "description": "Canada", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CC", + "description": "Cocos (Keeling) Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CD", + "description": "Congo (Democratic Republic of the)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CF", + "description": "Central African Republic", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CG", + "description": "Congo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CH", + "description": "Switzerland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CI", + "description": "Côte d'Ivoire", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CK", + "description": "Cook Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CL", + "description": "Chile", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CM", + "description": "Cameroon", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CN", + "description": "China", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CO", + "description": "Colombia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CR", + "description": "Costa Rica", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CU", + "description": "Cuba", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CV", + "description": "Cabo Verde", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CW", + "description": "Curaçao", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CX", + "description": "Christmas Island", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CY", + "description": "Cyprus", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CZ", + "description": "Czechia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DE", + "description": "Germany", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DJ", + "description": "Djibouti", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DK", + "description": "Denmark", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DM", + "description": "Dominica", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DO", + "description": "Dominican Republic", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DZ", + "description": "Algeria", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EC", + "description": "Ecuador", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EE", + "description": "Estonia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EG", + "description": "Egypt", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EH", + "description": "Western Sahara", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ER", + "description": "Eritrea", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ES", + "description": "Spain", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ET", + "description": "Ethiopia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FI", + "description": "Finland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FJ", + "description": "Fiji", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FK", + "description": "Falkland Islands (Malvinas)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FM", + "description": "Micronesia (Federated States of)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FO", + "description": "Faroe Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FR", + "description": "France", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GA", + "description": "Gabon", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GB", + "description": "United Kingdom of Great Britain and Northern Ireland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GD", + "description": "Grenada", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GE", + "description": "Georgia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GF", + "description": "French Guiana", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GG", + "description": "Guernsey", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GH", + "description": "Ghana", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GI", + "description": "Gibraltar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GL", + "description": "Greenland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GM", + "description": "Gambia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GN", + "description": "Guinea", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GP", + "description": "Guadeloupe", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GQ", + "description": "Equatorial Guinea", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GR", + "description": "Greece", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GS", + "description": "South Georgia and the South Sandwich Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GT", + "description": "Guatemala", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GU", + "description": "Guam", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GW", + "description": "Guinea-Bissau", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GY", + "description": "Guyana", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HK", + "description": "Hong Kong", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HM", + "description": "Heard Island and McDonald Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HN", + "description": "Honduras", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HR", + "description": "Croatia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HT", + "description": "Haiti", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HU", + "description": "Hungary", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ID", + "description": "Indonesia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IE", + "description": "Ireland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IL", + "description": "Israel", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IM", + "description": "Isle of Man", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IN", + "description": "India", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IO", + "description": "British Indian Ocean Territory", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IQ", + "description": "Iraq", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IR", + "description": "Iran (Islamic Republic of)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IS", + "description": "Iceland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IT", + "description": "Italy", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JE", + "description": "Jersey", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JM", + "description": "Jamaica", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JO", + "description": "Jordan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JP", + "description": "Japan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KE", + "description": "Kenya", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KG", + "description": "Kyrgyzstan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KH", + "description": "Cambodia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KI", + "description": "Kiribati", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KM", + "description": "Comoros", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KN", + "description": "Saint Kitts and Nevis", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KP", + "description": "Korea (Democratic People's Republic of)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KR", + "description": "Korea (Republic of)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KW", + "description": "Kuwait", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KY", + "description": "Cayman Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KZ", + "description": "Kazakhstan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LA", + "description": "Lao People's Democratic Republic", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LB", + "description": "Lebanon", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LC", + "description": "Saint Lucia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LI", + "description": "Liechtenstein", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LK", + "description": "Sri Lanka", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LR", + "description": "Liberia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LS", + "description": "Lesotho", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LT", + "description": "Lithuania", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LU", + "description": "Luxembourg", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LV", + "description": "Latvia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LY", + "description": "Libya", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MA", + "description": "Morocco", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MC", + "description": "Monaco", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MD", + "description": "Moldova (Republic of)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ME", + "description": "Montenegro", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MF", + "description": "Saint Martin (French part)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MG", + "description": "Madagascar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MH", + "description": "Marshall Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MK", + "description": "North Macedonia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ML", + "description": "Mali", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MM", + "description": "Myanmar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MN", + "description": "Mongolia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MO", + "description": "Macao", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MP", + "description": "Northern Mariana Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MQ", + "description": "Martinique", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MR", + "description": "Mauritania", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MS", + "description": "Montserrat", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MT", + "description": "Malta", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MU", + "description": "Mauritius", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MV", + "description": "Maldives", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MW", + "description": "Malawi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MX", + "description": "Mexico", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MY", + "description": "Malaysia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MZ", + "description": "Mozambique", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NA", + "description": "Namibia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NC", + "description": "New Caledonia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NE", + "description": "Niger", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NF", + "description": "Norfolk Island", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NG", + "description": "Nigeria", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NI", + "description": "Nicaragua", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NL", + "description": "Netherlands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NO", + "description": "Norway", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NP", + "description": "Nepal", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NR", + "description": "Nauru", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NU", + "description": "Niue", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NZ", + "description": "New Zealand", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OM", + "description": "Oman", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PA", + "description": "Panama", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PE", + "description": "Peru", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PF", + "description": "French Polynesia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PG", + "description": "Papua New Guinea", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PH", + "description": "Philippines", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PK", + "description": "Pakistan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PL", + "description": "Poland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PM", + "description": "Saint Pierre and Miquelon", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PN", + "description": "Pitcairn", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PR", + "description": "Puerto Rico", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PS", + "description": "Palestine, State of", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PT", + "description": "Portugal", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PW", + "description": "Palau", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PY", + "description": "Paraguay", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "QA", + "description": "Qatar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RE", + "description": "Réunion", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RO", + "description": "Romania", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RS", + "description": "Serbia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RU", + "description": "Russian Federation", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RW", + "description": "Rwanda", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SA", + "description": "Saudi Arabia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SB", + "description": "Solomon Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SC", + "description": "Seychelles", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SD", + "description": "Sudan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SE", + "description": "Sweden", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SG", + "description": "Singapore", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SH", + "description": "Saint Helena, Ascension and Tristan da Cunha", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SI", + "description": "Slovenia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SJ", + "description": "Svalbard and Jan Mayen", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SK", + "description": "Slovakia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SL", + "description": "Sierra Leone", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SM", + "description": "San Marino", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SN", + "description": "Senegal", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SO", + "description": "Somalia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SR", + "description": "Suriname", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SS", + "description": "South Sudan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ST", + "description": "Sao Tome and Principe", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SV", + "description": "El Salvador", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SX", + "description": "Sint Maarten (Dutch part)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SY", + "description": "Syrian Arab Republic", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SZ", + "description": "Eswatini", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TC", + "description": "Turks and Caicos Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TD", + "description": "Chad", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TF", + "description": "French Southern Territories", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TG", + "description": "Togo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TH", + "description": "Thailand", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TJ", + "description": "Tajikistan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TK", + "description": "Tokelau", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TL", + "description": "Timor-Leste", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TM", + "description": "Turkmenistan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TN", + "description": "Tunisia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TO", + "description": "Tonga", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TR", + "description": "Türkiye", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TT", + "description": "Trinidad and Tobago", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TV", + "description": "Tuvalu", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TW", + "description": "Taiwan, Province of China", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ", + "description": "Tanzania, United Republic of", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UA", + "description": "Ukraine", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UG", + "description": "Uganda", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UM", + "description": "United States Minor Outlying Islands", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "US", + "description": "United States of America", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UY", + "description": "Uruguay", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UZ", + "description": "Uzbekistan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VA", + "description": "Holy See", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VC", + "description": "Saint Vincent and the Grenadines", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VE", + "description": "Venezuela (Bolivarian Republic of)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VG", + "description": "Virgin Islands (British)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VI", + "description": "Virgin Islands (U.S.)", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VN", + "description": "Viet Nam", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VU", + "description": "Vanuatu", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "WF", + "description": "Wallis and Futuna", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "WS", + "description": "Samoa", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "YE", + "description": "Yemen", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "YT", + "description": "Mayotte", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ZA", + "description": "South Africa", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ZM", + "description": "Zambia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ZW", + "description": "Zimbabwe", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "XK", + "description": "Kosovo", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Coupon", + "description": null, + "fields": [ + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedCouponsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetrics", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersCount", + "description": "Number of customers using this coupon", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiration", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponExpiration", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponFrequency", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequencyDuration", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limitedBillableMetrics", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limitedPlans", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "percentageRate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plans", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reusable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CouponCollection", + "description": "CouponCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated CouponCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Coupon", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CouponExpiration", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "no_expiration", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "time_limit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CouponFrequency", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "once", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "forever", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CouponStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "active", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CouponTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "fixed_amount", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "percentage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateAddOnInput", + "description": "Autogenerated input type of CreateAddOn", + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateAdjustedFeeInput", + "description": "Create Adjusted Fee Input", + "fields": null, + "inputFields": [ + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeFilterId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedChargeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitPreciseAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateAiConversationInput", + "description": "Autogenerated input type of CreateAiConversation", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "conversationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "message", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateAnrokIntegrationInput", + "description": "Autogenerated input type of CreateAnrokIntegration", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKey", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateApiKeyInput", + "description": "Autogenerated input type of CreateApiKey", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateAppliedCouponInput", + "description": "Autogenerated input type of CreateAppliedCoupon", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CouponFrequency", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequencyDuration", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "percentageRate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateAvalaraIntegrationInput", + "description": "Autogenerated input type of CreateAvalaraIntegration", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accountId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "companyCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "licenseKey", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateBillableMetricInput", + "description": "Create Billable metric input arguments", + "fields": null, + "inputFields": [ + { + "name": "aggregationType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AggregationTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expression", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fieldName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roundingFunction", + "description": null, + "type": { + "kind": "ENUM", + "name": "RoundingFunctionEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roundingPrecision", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "weightedInterval", + "description": null, + "type": { + "kind": "ENUM", + "name": "WeightedIntervalEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "BillableMetricFiltersInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateBillingEntityInput", + "description": "Create Billing Entity input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultCurrency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "einvoicing", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "euTaxManagement", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumberPrefix", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumbering", + "description": null, + "type": { + "kind": "ENUM", + "name": "BillingEntityDocumentNumberingEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "BillingEntityBillingConfigurationInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "emailSettings", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "BillingEntityEmailSettingsEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCouponInput", + "description": "Autogenerated input type of CreateCoupon", + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponFrequency", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequencyDuration", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "percentageRate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reusable", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliesTo", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "LimitationInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiration", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponExpiration", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCreditNoteInput", + "description": "Autogenerated input type of CreateCreditNote", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reason", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteReasonEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offsetAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "items", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreditNoteItemInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerInput", + "description": "Create Customer input arguments", + "fields": null, + "inputFields": [ + { + "name": "accountType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerAccountTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSalesforceId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "firstname", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastname", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logoUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "phone", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "url", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shippingAddress", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CustomerAddressInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CustomerMetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProvider", + "description": null, + "type": { + "kind": "ENUM", + "name": "ProviderTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerCustomer", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "ProviderCustomerInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCustomers", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "IntegrationCustomerInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CustomerBillingConfigurationInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": null, + "type": { + "kind": "ENUM", + "name": "FinalizeZeroAmountInvoiceEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerPortalWalletTransactionInput", + "description": "Autogenerated input type of CreateCustomerPortalWalletTransaction", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerWalletAlertInput", + "description": "Autogenerated input type of CreateCustomerWalletAlert", + "fields": null, + "inputFields": [ + { + "name": "alertType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AlertTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ThresholdInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerWalletInput", + "description": "Create Wallet Input", + "fields": null, + "inputFields": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grantedCredits", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidCredits", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "priority", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rateAmount", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ignorePaidTopUpLimitsOnCreation", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMaxAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMinAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurringTransactionRules", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateRecurringTransactionRuleInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliesTo", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AppliesToInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerWalletTransactionInput", + "description": "Autogenerated input type of CreateCustomerWalletTransaction", + "fields": null, + "inputFields": [ + { + "name": "walletId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grantedCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ignorePaidTopUpLimits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "WalletTransactionMetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "priority", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateDataExportsCreditNotesInput", + "description": "Autogenerated input type of CreateCreditNotesDataExport", + "fields": null, + "inputFields": [ + { + "name": "filters", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DataExportCreditNoteFiltersInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "format", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DataExportFormatTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resourceType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteExportTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateDataExportsInvoicesInput", + "description": "Autogenerated input type of CreateInvoicesDataExport", + "fields": null, + "inputFields": [ + { + "name": "filters", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DataExportInvoiceFiltersInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "format", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DataExportFormatTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resourceType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceExportTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateDunningCampaignInput", + "description": "Autogenerated input type of CreateDunningCampaign", + "fields": null, + "inputFields": [ + { + "name": "appliedToOrganization", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "bccEmails", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "daysBetweenAttempts", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "maxAttempts", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DunningCampaignThresholdInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateFeatureInput", + "description": "Input for creating a feature", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": "The code of the feature", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "The description of the feature", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name of the feature", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privileges", + "description": "The privileges configuration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdatePrivilegeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateHubspotIntegrationInput", + "description": "Autogenerated input type of CreateHubspotIntegration", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultTargetedObject", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "HubspotTargetedObjectsEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncSubscriptions", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateIntegrationCollectionMappingInput", + "description": "Autogenerated input type of CreateIntegrationCollectionMapping", + "fields": null, + "inputFields": [ + { + "name": "externalAccountCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxNexus", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxType", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currencies", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CurrencyMappingItemInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappingType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MappingTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateIntegrationMappingInput", + "description": "Autogenerated input type of CreateIntegrationMapping", + "fields": null, + "inputFields": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalAccountCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappableId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappableType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MappableTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateInviteInput", + "description": "Autogenerated input type of CreateInvite", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateInvoiceCustomSectionInput", + "description": "Autogenerated input type of CreateInvoiceCustomSection", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "details", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "displayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateInvoiceInput", + "description": "Create Invoice input arguments", + "fields": null, + "inputFields": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fees", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FeeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedInvoiceId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateNetsuiteIntegrationInput", + "description": "Autogenerated input type of CreateNetsuiteIntegration", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accountId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "scriptEndpointUrl", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tokenId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tokenSecret", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncCreditNotes", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncPayments", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateOktaIntegrationInput", + "description": "Autogenerated input type of CreateOktaIntegration", + "fields": null, + "inputFields": [ + { + "name": "clientId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "domain", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "host", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationName", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateOrUpdateSubscriptionEntitlementInput", + "description": "Autogenerated input type of CreateOrUpdateSubscriptionEntitlement", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "entitlement", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EntitlementInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreatePasswordResetInput", + "description": "Autogenerated input type of CreatePasswordReset", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreatePasswordResetPayload", + "description": "Autogenerated return type of CreatePasswordReset.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreatePaymentInput", + "description": "Autogenerated input type of CreatePayment", + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reference", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreatePlanInput", + "description": "Autogenerated input type of CreatePlan", + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billChargesMonthly", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billFixedChargesMonthly", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interval", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PlanInterval", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trialPeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedCharges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FixedChargeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minimumCommitment", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CommitmentInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "usageThresholds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UsageThresholdInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "entitlements", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EntitlementInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreatePricingUnitInput", + "description": "Autogenerated input type of CreatePricingUnit", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shortName", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateQuoteInput", + "description": "Autogenerated input type of CreateQuote", + "fields": null, + "inputFields": [ + { + "name": "billingItems", + "description": null, + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "content", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "orderType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "OrderTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "owners", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateRecurringTransactionRuleInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "expirationAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grantedCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ignorePaidTopUpLimits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interval", + "description": null, + "type": { + "kind": "ENUM", + "name": "RecurringTransactionIntervalEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "method", + "description": null, + "type": { + "kind": "ENUM", + "name": "RecurringTransactionMethodEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startedAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetOngoingBalance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholdCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionMetadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateTransactionMetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trigger", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "RecurringTransactionTriggerEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateRoleInput", + "description": "Create Role input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PermissionEnum", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateSalesforceIntegrationInput", + "description": "Autogenerated input type of CreateSalesforceIntegration", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "instanceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateSubscriptionAlertInput", + "description": "Autogenerated input type of CreateSubscriptionAlert", + "fields": null, + "inputFields": [ + { + "name": "alertType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AlertTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ThresholdInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateSubscriptionChargeFilterInput", + "description": "Create subscription charge filter input arguments", + "fields": null, + "inputFields": [ + { + "name": "chargeCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateSubscriptionInput", + "description": "Create Subscription input arguments", + "fields": null, + "inputFields": [ + { + "name": "endingAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planOverrides", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PlanOverridesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "usageThresholds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UsageThresholdInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activationRules", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SubscriptionActivationRuleInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingTime", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "BillingTimeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressiveBillingDisabled", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateTransactionMetadataInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "key", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreateXeroIntegrationInput", + "description": "Autogenerated input type of CreateXeroIntegration", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncCreditNotes", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncPayments", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreditNote", + "description": "CreditNote", + "fields": [ + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedTaxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNoteAppliedTax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "balanceAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "canBeVoided", + "description": "Check if credit note can be voided", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsAdjustmentAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditStatus", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CreditNoteCreditStatusEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errorDetails", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ErrorDetail", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalIntegrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fileUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationSyncable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "items", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNoteItem", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ItemMetadata", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "number", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offsetAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reason", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteReasonEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundStatus", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CreditNoteRefundStatusEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sequentialId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subTotalExcludingTaxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxProviderId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxProviderSyncable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "xmlUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreditNoteAppliedTax", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "baseAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNote", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxDescription", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "AppliedTax", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreditNoteCollection", + "description": "CreditNoteCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated CreditNoteCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CreditNoteCreditStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "available", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "consumed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voided", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreditNoteEstimate", + "description": "Estimate amounts for credit note creation", + "fields": [ + { + "name": "appliedTaxes", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNoteAppliedTax", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsAdjustmentAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "items", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNoteItemEstimate", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "maxCreditableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "maxOffsettableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "maxRefundableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "preciseCouponsAdjustmentAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "preciseTaxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subTotalExcludingTaxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CreditNoteExportTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "credit_notes", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_items", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreditNoteItem", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fee", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CreditNoteItemEstimate", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fee", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CreditNoteItemInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feeId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CreditNoteReasonEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "duplicated_charge", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "product_unsatisfactory", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_change", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_cancellation", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fraudulent_charge", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "other", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CreditNoteRefundStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "succeeded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CreditNoteTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "credit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refund", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offset", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CurrencyEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "AED", + "description": "United Arab Emirates Dirham", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AFN", + "description": "Afghan Afghani", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ALL", + "description": "Albanian Lek", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AMD", + "description": "Armenian Dram", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ANG", + "description": "Netherlands Antillean Gulden", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AOA", + "description": "Angolan Kwanza", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARS", + "description": "Argentine Peso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AUD", + "description": "Australian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AWG", + "description": "Aruban Florin", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "AZN", + "description": "Azerbaijani Manat", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BAM", + "description": "Bosnia and Herzegovina Convertible Mark", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BBD", + "description": "Barbadian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BDT", + "description": "Bangladeshi Taka", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BGN", + "description": "Bulgarian Lev", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BHD", + "description": "Bahraini Dinar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BIF", + "description": "Burundian Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BMD", + "description": "Bermudian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BND", + "description": "Brunei Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BOB", + "description": "Bolivian Boliviano", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BRL", + "description": "Brazilian Real", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BSD", + "description": "Bahamian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BWP", + "description": "Botswana Pula", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BYN", + "description": "Belarusian Ruble", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BZD", + "description": "Belize Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CAD", + "description": "Canadian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CDF", + "description": "Congolese Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CHF", + "description": "Swiss Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CLF", + "description": "Unidad de Fomento", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CLP", + "description": "Chilean Peso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CNY", + "description": "Chinese Renminbi Yuan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "COP", + "description": "Colombian Peso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CRC", + "description": "Costa Rican Colón", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CVE", + "description": "Cape Verdean Escudo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CZK", + "description": "Czech Koruna", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DJF", + "description": "Djiboutian Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DKK", + "description": "Danish Krone", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DOP", + "description": "Dominican Peso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DZD", + "description": "Algerian Dinar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EGP", + "description": "Egyptian Pound", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ETB", + "description": "Ethiopian Birr", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EUR", + "description": "Euro", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FJD", + "description": "Fijian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FKP", + "description": "Falkland Pound", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GBP", + "description": "British Pound", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GEL", + "description": "Georgian Lari", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GHS", + "description": "Ghanaian Cedi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GIP", + "description": "Gibraltar Pound", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GMD", + "description": "Gambian Dalasi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GNF", + "description": "Guinean Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GTQ", + "description": "Guatemalan Quetzal", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "GYD", + "description": "Guyanese Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HKD", + "description": "Hong Kong Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HNL", + "description": "Honduran Lempira", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HRK", + "description": "Croatian Kuna", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HTG", + "description": "Haitian Gourde", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HUF", + "description": "Hungarian Forint", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IDR", + "description": "Indonesian Rupiah", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ILS", + "description": "Israeli New Sheqel", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INR", + "description": "Indian Rupee", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "IRR", + "description": "Iranian Rial", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ISK", + "description": "Icelandic Króna", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JMD", + "description": "Jamaican Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JOD", + "description": "Jordanian Dinar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "JPY", + "description": "Japanese Yen", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KES", + "description": "Kenyan Shilling", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KGS", + "description": "Kyrgyzstani Som", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KHR", + "description": "Cambodian Riel", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KMF", + "description": "Comorian Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KRW", + "description": "South Korean Won", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KWD", + "description": "Kuwaiti Dinar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KYD", + "description": "Cayman Islands Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "KZT", + "description": "Kazakhstani Tenge", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LAK", + "description": "Lao Kip", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LBP", + "description": "Lebanese Pound", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LKR", + "description": "Sri Lankan Rupee", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LRD", + "description": "Liberian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LSL", + "description": "Lesotho Loti", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MAD", + "description": "Moroccan Dirham", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MDL", + "description": "Moldovan Leu", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MGA", + "description": "Malagasy Ariary", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MKD", + "description": "Macedonian Denar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MMK", + "description": "Myanmar Kyat", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MNT", + "description": "Mongolian Tögrög", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MOP", + "description": "Macanese Pataca", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MRO", + "description": "Mauritanian Ouguiya", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUR", + "description": "Mauritian Rupee", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MVR", + "description": "Maldivian Rufiyaa", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MWK", + "description": "Malawian Kwacha", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MXN", + "description": "Mexican Peso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MYR", + "description": "Malaysian Ringgit", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MZN", + "description": "Mozambican Metical", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NAD", + "description": "Namibian Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NGN", + "description": "Nigerian Naira", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NIO", + "description": "Nicaraguan Córdoba", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NOK", + "description": "Norwegian Krone", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NPR", + "description": "Nepalese Rupee", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NZD", + "description": "New Zealand Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PAB", + "description": "Panamanian Balboa", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PEN", + "description": "Peruvian Sol", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PGK", + "description": "Papua New Guinean Kina", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PHP", + "description": "Philippine Peso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PKR", + "description": "Pakistani Rupee", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PLN", + "description": "Polish Złoty", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PYG", + "description": "Paraguayan Guaraní", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "QAR", + "description": "Qatari Riyal", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RON", + "description": "Romanian Leu", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RSD", + "description": "Serbian Dinar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RUB", + "description": "Russian Ruble", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "RWF", + "description": "Rwandan Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SAR", + "description": "Saudi Riyal", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SBD", + "description": "Solomon Islands Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCR", + "description": "Seychellois Rupee", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SEK", + "description": "Swedish Krona", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SGD", + "description": "Singapore Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SHP", + "description": "Saint Helenian Pound", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SLL", + "description": "Sierra Leonean Leone", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SOS", + "description": "Somali Shilling", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SRD", + "description": "Surinamese Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "STD", + "description": "São Tomé and Príncipe Dobra", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SZL", + "description": "Swazi Lilangeni", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "THB", + "description": "Thai Baht", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TJS", + "description": "Tajikistani Somoni", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TOP", + "description": "Tongan Paʻanga", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TRY", + "description": "Turkish Lira", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TTD", + "description": "Trinidad and Tobago Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TWD", + "description": "New Taiwan Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZS", + "description": "Tanzanian Shilling", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UAH", + "description": "Ukrainian Hryvnia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UGX", + "description": "Ugandan Shilling", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "USD", + "description": "United States Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UYU", + "description": "Uruguayan Peso", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UZS", + "description": "Uzbekistan Som", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VND", + "description": "Vietnamese Đồng", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VUV", + "description": "Vanuatu Vatu", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "WST", + "description": "Samoan Tala", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "XAF", + "description": "Central African Cfa Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "XCD", + "description": "East Caribbean Dollar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "XOF", + "description": "West African Cfa Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "XPF", + "description": "Cfp Franc", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "YER", + "description": "Yemeni Rial", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ZAR", + "description": "South African Rand", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ZMW", + "description": "Zambian Kwacha", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CurrencyMappingItem", + "description": null, + "fields": [ + { + "name": "currencyCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currencyExternalCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CurrencyMappingItemInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "currencyCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currencyExternalCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CurrentOrganization", + "description": "Current Organization Type", + "fields": [ + { + "name": "accessibleByCurrentSession", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adyenPaymentProviders", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AdyenProvider", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKey", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedDunningCampaign", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "DunningCampaign", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authenticatedMethod", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AuthenticationMethodsEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authenticationMethods", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AuthenticationMethodsEnum", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "OrganizationBillingConfiguration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "canCreateBillingEntity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cashfreePaymentProviders", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumberPrefix", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumbering", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DocumentNumberingEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "emailSettings", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EmailSettingsEnum", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "euTaxManagement", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "featureFlags", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FeatureFlagEnum", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "gocardlessPaymentProviders", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GocardlessProvider", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hmacKey", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logoUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "premiumIntegrations", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PremiumIntegrationTypeEnum", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "slug", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "stripePaymentProviders", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "StripeProvider", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": "Query taxes of an organization", + "args": [ + { + "name": "appliedToOrganization", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "autoGenerated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CurrentVersion", + "description": null, + "fields": [ + { + "name": "githubUrl", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "number", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Customer", + "description": null, + "fields": [ + { + "name": "accountType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CustomerAccountTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activeSubscriptionsCount", + "description": "Number of active subscriptions per customer", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "anrokCustomer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "AnrokCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applicableTimezone", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedAddOns", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppliedAddOn", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedCoupons", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppliedCoupon", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedDunningCampaign", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "DunningCampaign", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "avalaraCustomer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "AvalaraCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "CustomerBillingConfiguration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "canEditAttributes", + "description": "Check if customer attributes are editable", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "configurableInvoiceCustomSections", + "description": "Invoice custom sections manually configured for the customer", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesBalanceAmountCents", + "description": "Credit notes credits balance available per customer", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesBalances", + "description": "Credit notes credits balance available per customer per currency", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerCreditNotesBalance", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesCreditsAvailableCount", + "description": "Number of available credits from credit notes per customer", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "displayName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errorDetails", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ErrorDetail", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "excludeFromDunningCampaign", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSalesforceId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": "Options for handling invoices with a zero total amount.", + "args": [], + "type": { + "kind": "ENUM", + "name": "FinalizeZeroAmountInvoiceEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "firstname", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasActiveWallet", + "description": "Define if a customer has an active wallet", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasCreditNotes", + "description": "Define if a customer has any credit note", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasOverdueInvoices", + "description": "Define if a customer has overdue invoices", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasOverwrittenInvoiceCustomSectionsSelection", + "description": "Define if the customer has custom invoice custom sections selection", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hubspotCustomer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "HubspotCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastDunningCampaignAttempt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastDunningCampaignAttemptAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastname", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logoUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerMetadata", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netsuiteCustomer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "NetsuiteCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProvider", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "ProviderTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "phone", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerCustomer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "ProviderCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "salesforceCustomer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "SalesforceCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sequentialId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shippingAddress", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "CustomerAddress", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skipInvoiceCustomSections", + "description": "Skip invoice custom sections for the customer", + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "slug", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptions", + "description": "Query subscriptions of a customer", + "args": [ + { + "name": "status", + "description": "Statuses of subscriptions to retrieve", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "StatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "url", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "xeroCustomer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "XeroCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CustomerAccountTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "customer", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "partner", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerAddress", + "description": null, + "fields": [ + { + "name": "addressLine1", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CustomerAddressInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "addressLine1", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerBillingConfiguration", + "description": null, + "fields": [ + { + "name": "documentLocale", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAdjustment", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAnchor", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CustomerSubscriptionInvoiceIssuingDateAnchorEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CustomerBillingConfigurationInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "documentLocale", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAdjustment", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionInvoiceIssuingDateAnchor", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerSubscriptionInvoiceIssuingDateAnchorEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerCollection", + "description": "CustomerCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated CustomerCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerCreditNotesBalance", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerMetadata", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "displayInInvoice", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "key", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CustomerMetadataFilter", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "key", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "CustomerMetadataInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "key", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "displayInInvoice", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerPortalCustomer", + "description": null, + "fields": [ + { + "name": "accountType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CustomerAccountTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applicableTimezone", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "CustomerBillingConfiguration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityBillingConfiguration", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillingEntityBillingConfiguration", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "displayName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "firstname", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastname", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "premium", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shippingAddress", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "CustomerAddress", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerPortalOrganization", + "description": "CustomerPortalOrganization", + "fields": [ + { + "name": "billingConfiguration", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "OrganizationBillingConfiguration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logoUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "premiumIntegrations", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PremiumIntegrationTypeEnum", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerPortalWallet", + "description": "CustomerPortalWallet", + "fields": [ + { + "name": "balanceCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "consumedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "consumedCredits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditsBalance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditsOngoingBalance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastBalanceSyncAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ongoingBalanceCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ongoingUsageBalanceCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMaxAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMaxCredits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMinAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMinCredits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "priority", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rateAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerPortalWalletCollection", + "description": "CustomerPortalWalletCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated CustomerPortalWalletCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerPortalWallet", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerPortalWalletTransaction", + "description": null, + "fields": [ + { + "name": "amount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settledAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletTransactionStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionStatus", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletTransactionTransactionStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletTransactionTransactionTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "CustomerPortalWallet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerPortalWalletTransactionCollection", + "description": "CustomerPortalWalletTransactionCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated CustomerPortalWalletTransactionCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerPortalWalletTransaction", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerProjectedUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesUsage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectedChargeUsage", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDatetime", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum", + "description": "Subscription Invoice Issuing Date Adjustment Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "keep_anchor", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "align_with_finalization_date", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CustomerSubscriptionInvoiceIssuingDateAnchorEnum", + "description": "Subscription Invoice Issuing Date Anchor Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "current_period_end", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "next_period_start", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "company", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "individual", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "CustomerUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesUsage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChargeUsage", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDatetime", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiMetadata", + "description": null, + "fields": [ + { + "name": "currentPage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextPage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prevPage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalPages", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiMrr", + "description": null, + "fields": [ + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endingMrr", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrrChange", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrrChurn", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrrContraction", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrrExpansion", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrrNew", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startingMrr", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiMrrCollection", + "description": "DataApiMrrCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DataApiMrrCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiMrr", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiMrrPlan", + "description": null, + "fields": [ + { + "name": "activeCustomersCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activeCustomersShare", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrr", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrrShare", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planDeletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planInterval", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PlanInterval", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiMrrsPlans", + "description": null, + "fields": [ + { + "name": "collection", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiMrrPlan", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiPrepaidCredit", + "description": null, + "fields": [ + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "consumedAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "consumedCreditsQuantity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offeredAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offeredCreditsQuantity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "purchasedAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "purchasedCreditsQuantity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedCreditsQuantity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiPrepaidCreditCollection", + "description": "DataApiPrepaidCreditCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DataApiPrepaidCreditCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiPrepaidCredit", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiRevenueStream", + "description": null, + "fields": [ + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "commitmentFeeAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "contraRevenueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesCreditsAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeCreditsAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grossRevenueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netRevenueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "oneOffFeeAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prepaidCreditsAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressiveBillingCreditAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionFeeAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "usageBasedFeeAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiRevenueStreamCollection", + "description": "DataApiRevenueStreamCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DataApiRevenueStreamCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiRevenueStream", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiRevenueStreamCustomer", + "description": null, + "fields": [ + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerDeletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grossRevenueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grossRevenueShare", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netRevenueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netRevenueShare", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiRevenueStreamPlan", + "description": null, + "fields": [ + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersShare", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grossRevenueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grossRevenueShare", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netRevenueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netRevenueShare", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planDeletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planInterval", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PlanInterval", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiRevenueStreamsCustomers", + "description": null, + "fields": [ + { + "name": "collection", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiRevenueStreamCustomer", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiRevenueStreamsPlans", + "description": null, + "fields": [ + { + "name": "collection", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiRevenueStreamPlan", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isBillableMetricDeleted", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsageAggregatedAmount", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsageAggregatedAmountCollection", + "description": "DataApiUsageAggregatedAmountCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DataApiUsageAggregatedAmountCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsageAggregatedAmount", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsageCollection", + "description": "DataApiUsageCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DataApiUsageCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsage", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsageForecasted", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCentsForecastConservative", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCentsForecastOptimistic", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCentsForecastRealistic", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitsForecastConservative", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitsForecastOptimistic", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitsForecastRealistic", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsageForecastedCollection", + "description": "DataApiUsageForecastedCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DataApiUsageForecastedCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsageForecasted", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsageInvoiced", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startOfPeriodDt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataApiUsageInvoicedCollection", + "description": "DataApiUsageInvoicedCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DataApiUsageInvoicedCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsageInvoiced", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DataExport", + "description": null, + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "DataExportStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DataExportCreditNoteFiltersInput", + "description": "Export credit notes search query and filters input argument", + "fields": null, + "inputFields": [ + { + "name": "amountFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditStatus", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteCreditStatusEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerExternalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": "Uniq ID of the customer", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reason", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteReasonEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundStatus", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteRefundStatusEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selfBilled", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DataExportFormatTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "csv", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DataExportInvoiceFiltersInput", + "description": "Export Invoices search query and filters input argument", + "fields": null, + "inputFields": [ + { + "name": "amountFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerExternalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceType", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentDisputeLost", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentOverdue", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentStatus", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selfBilled", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceStatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DataExportStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "processing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "completed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyAddOnInput", + "description": "Autogenerated input type of DestroyAddOn", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyAddOnPayload", + "description": "Autogenerated return type of DestroyAddOn.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyAdjustedFeeInput", + "description": "Autogenerated input type of DestroyAdjustedFee", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyAdjustedFeePayload", + "description": "Autogenerated return type of DestroyAdjustedFee.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyApiKeyInput", + "description": "Autogenerated input type of DestroyApiKey", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyBillableMetricInput", + "description": "Autogenerated input type of DestroyBillableMetric", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyBillableMetricPayload", + "description": "Autogenerated return type of DestroyBillableMetric.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyBillingEntityInput", + "description": "Autogenerated input type of DestroyBillingEntity", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyBillingEntityPayload", + "description": "Autogenerated return type of DestroyBillingEntity.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyChargeFilterInput", + "description": "Autogenerated input type of DestroyChargeFilter", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyChargeFilterPayload", + "description": "Autogenerated return type of DestroyChargeFilter.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyChargeInput", + "description": "Autogenerated input type of DestroyCharge", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyChargePayload", + "description": "Autogenerated return type of DestroyCharge.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyCouponInput", + "description": "Autogenerated input type of DestroyCoupon", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyCouponPayload", + "description": "Autogenerated return type of DestroyCoupon.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyCustomerInput", + "description": "Autogenerated input type of DestroyCustomer", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyCustomerPayload", + "description": "Autogenerated return type of DestroyCustomer.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyCustomerWalletAlertInput", + "description": "Autogenerated input type of DestroyCustomerWalletAlert", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyDunningCampaignInput", + "description": "Autogenerated input type of DestroyDunningCampaign", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyDunningCampaignPayload", + "description": "Autogenerated return type of DestroyDunningCampaign.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyFeatureInput", + "description": "Autogenerated input type of DestroyFeature", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "The ID of the feature to destroy", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyFixedChargeInput", + "description": "Autogenerated input type of DestroyFixedCharge", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyFixedChargePayload", + "description": "Autogenerated return type of DestroyFixedCharge.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyIntegrationCollectionMappingInput", + "description": "Autogenerated input type of DestroyIntegrationCollectionMapping", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyIntegrationCollectionMappingPayload", + "description": "Autogenerated return type of DestroyIntegrationCollectionMapping.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyIntegrationInput", + "description": "Autogenerated input type of DestroyIntegration", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyIntegrationMappingInput", + "description": "Autogenerated input type of DestroyIntegrationMapping", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyIntegrationMappingPayload", + "description": "Autogenerated return type of DestroyIntegrationMapping.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyIntegrationPayload", + "description": "Autogenerated return type of DestroyIntegration.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyInvoiceCustomSectionInput", + "description": "Autogenerated input type of DestroyInvoiceCustomSection", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyInvoiceCustomSectionPayload", + "description": "Autogenerated return type of DestroyInvoiceCustomSection.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyPaymentMethodInput", + "description": "Autogenerated input type of DestroyPaymentMethod", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyPaymentMethodPayload", + "description": "Autogenerated return type of DestroyPaymentMethod.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyPaymentProviderInput", + "description": "Autogenerated input type of DestroyPaymentProvider", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyPaymentProviderPayload", + "description": "Autogenerated return type of DestroyPaymentProvider.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyPlanInput", + "description": "Autogenerated input type of DestroyPlan", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyPlanPayload", + "description": "Autogenerated return type of DestroyPlan.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyRoleInput", + "description": "Autogenerated input type of DestroyRole", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroySubscriptionAlertInput", + "description": "Autogenerated input type of DestroySubscriptionAlert", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroySubscriptionChargeFilterInput", + "description": "Destroy subscription charge filter input arguments", + "fields": null, + "inputFields": [ + { + "name": "chargeCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyTaxInput", + "description": "Autogenerated input type of DestroyTax", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyTaxPayload", + "description": "Autogenerated return type of DestroyTax.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DestroyWebhookEndpointInput", + "description": "Autogenerated input type of DestroyWebhookEndpoint", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DestroyWebhookEndpointPayload", + "description": "Autogenerated return type of DestroyWebhookEndpoint.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DirectionEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "increasing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "decreasing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DocumentNumberingEnum", + "description": "Document numbering type", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "per_customer", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "per_organization", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DownloadCreditNoteInput", + "description": "Autogenerated input type of DownloadCreditNote", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DownloadCustomerPortalInvoiceInput", + "description": "Autogenerated input type of DownloadCustomerPortalInvoice", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DownloadInvoiceInput", + "description": "Autogenerated input type of DownloadInvoice", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DownloadPaymentReceiptInput", + "description": "Autogenerated input type of DownloadPaymentReceipt", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DownloadXMLPaymentReceiptInput", + "description": "Autogenerated input type of DownloadXMLPaymentReceipt", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DownloadXmlCreditNoteInput", + "description": "Autogenerated input type of DownloadXmlCreditNote", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DownloadXmlInvoiceInput", + "description": "Autogenerated input type of DownloadXmlInvoice", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DunningCampaign", + "description": null, + "fields": [ + { + "name": "appliedToOrganization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "bccEmails", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "daysBetweenAttempts", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "maxAttempts", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DunningCampaignThreshold", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DunningCampaignCollection", + "description": "DunningCampaignCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated DunningCampaignCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DunningCampaign", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DunningCampaignThreshold", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "DunningCampaignThresholdInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "EmailSettingsEnum", + "description": "Organization Email Settings Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "invoice_finalized", + "description": "invoice.finalized", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_created", + "description": "credit_note.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipt_created", + "description": "payment_receipt.created", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "EntitlementInput", + "description": "Input for updating a plan entitlement", + "fields": null, + "inputFields": [ + { + "name": "featureCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privileges", + "description": "The privileges configuration", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EntitlementPrivilegeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "EntitlementPrivilegeInput", + "description": "Input for updating a plan entitlement privilege value", + "fields": null, + "inputFields": [ + { + "name": "privilegeCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ErrorCodesEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "not_provided", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax_voiding_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_generation_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ErrorDetail", + "description": null, + "fields": [ + { + "name": "errorCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ErrorCodesEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errorDetails", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Event", + "description": null, + "fields": [ + { + "name": "apiClient", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerTimezone", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ipAddress", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "matchBillableMetric", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "matchCustomField", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "matchCustomer", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "matchSubscription", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payload", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "receivedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timestamp", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "EventCategoryEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "ALERTS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CUSTOMERS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "CREDIT_NOTES", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "DUNNING_CAMPAIGNS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "EVENT_INGESTION", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FEATURES", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUBSCRIPTIONS_AND_FEES", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INVOICES", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTEGRATIONS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PAYMENTS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PAYMENT_RECEIPTS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "PLANS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "WALLETS_AND_CREDITS", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "EventCollection", + "description": "EventCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated EventCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Event", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "EventTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "alert_triggered", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_accounting_provider_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_accounting_provider_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_crm_provider_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_crm_provider_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_payment_provider_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_payment_provider_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_checkout_url_generated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_tax_provider_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_vies_check", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_generated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note_provider_refund_failure", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunning_campaign_finished", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "event_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "events_errors", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature_deleted", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fee_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fee_tax_provider_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_one_off_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_paid_credit_added", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_generated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_drafted", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_voided", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_payment_dispute_lost", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_payment_status_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_payment_overdue", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_payment_failure", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_resynced", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integration_provider_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_succeeded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_requires_action", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_provider_error", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipt_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipt_generated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_request_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_request_payment_failure", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_request_payment_status_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan_deleted", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_canceled", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_incomplete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_terminated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_started", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_termination_alert", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_trial_ended", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_usage_threshold_reached", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_depleted_ongoing_balance", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_terminated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_transaction_created", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_transaction_updated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_transaction_payment_failure", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "all", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "FeatureFlagEnum", + "description": "Organization Feature Flag Values", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "multiple_payment_methods", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "non_persistable_charge_cache_optimization", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "postgres_enriched_events", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enriched_events_aggregation", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet_traceability", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "multi_currency", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_gated_subscriptions", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "multi_entity_billing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_forms", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FeatureObject", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privileges", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PrivilegeObject", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FeatureObjectCollection", + "description": "FeatureObjectCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated FeatureObjectCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FeatureObject", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Fee", + "description": null, + "fields": [ + { + "name": "addOn", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "AddOn", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adjustedFee", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adjustedFeeType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "AdjustedFeeTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountDetails", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "FeeAmountDetails", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedTaxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FeeAppliedTax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charge", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeFilter", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "ChargeFilter", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsCount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feeType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FeeTypesEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedCharge", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "FixedCharge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "groupedBy", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offsettableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "preciseUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "presentationBreakdowns", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PresentationBreakdownUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitUsage", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "PricingUnitUsage", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "FeeProperties", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "succeededAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesRate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trueUpFee", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trueUpParentFee", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransaction", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "WalletTransaction", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "InvoiceItem", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FeeAmountDetails", + "description": null, + "fields": [ + { + "name": "fixedFeeTotalAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedFeeUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flatUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeEvents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeUnits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedPercentageRanges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FeeAmountDetailsGraduatedPercentageRange", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedRanges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FeeAmountDetailsGraduatedRange", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minMaxAdjustmentTotalAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidEvents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidUnits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perPackageSize", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perPackageUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitTotalAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FeeAmountDetailsGraduatedPercentageRange", + "description": null, + "fields": [ + { + "name": "flatUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitTotalAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalWithFlatAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FeeAmountDetailsGraduatedRange", + "description": null, + "fields": [ + { + "name": "flatUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitTotalAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalWithFlatAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FeeAppliedTax", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fee", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxDescription", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "AppliedTax", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FeeInput", + "description": "Fee input for creating invoice", + "fields": null, + "inputFields": [ + { + "name": "addOnId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDatetime", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FeeProperties", + "description": null, + "fields": [ + { + "name": "fromDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "FeeTypesEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "charge", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "add_on", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "commitment", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixed_charge", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FetchDraftInvoiceTaxesInput", + "description": "Create Invoice input arguments", + "fields": null, + "inputFields": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fees", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FeeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedInvoiceId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FetchIntegrationAccountsInput", + "description": "Autogenerated input type of FetchIntegrationAccounts", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FetchIntegrationItemsInput", + "description": "Autogenerated input type of FetchIntegrationItems", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FinalizeAllInvoicesInput", + "description": "Autogenerated input type of FinalizeAllInvoices", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FinalizeInvoiceInput", + "description": "Autogenerated input type of FinalizeInvoice", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "FinalizeZeroAmountInvoiceEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "inherit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skip", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalize", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FinalizedInvoiceCollection", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "month", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentStatus", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FinalizedInvoiceCollectionCollection", + "description": "FinalizedInvoiceCollectionCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated FinalizedInvoiceCollectionCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FinalizedInvoiceCollection", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FixedCharge", + "description": null, + "fields": [ + { + "name": "addOn", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AddOn", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FixedChargeChargeModelEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "parentId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "FixedChargeProperties", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "FixedChargeChargeModelEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "standard", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "volume", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FixedChargeCreateInput", + "description": "Autogenerated input type of CreateFixedCharge", + "fields": null, + "inputFields": [ + { + "name": "planId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addOnId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applyUnitsImmediately", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FixedChargeChargeModelEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "FixedChargePropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FixedChargeInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addOnId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applyUnitsImmediately", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FixedChargeChargeModelEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "FixedChargePropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FixedChargeOverridesInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "addOnId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applyUnitsImmediately", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "FixedChargePropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FixedChargeProperties", + "description": null, + "fields": [ + { + "name": "amount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedRanges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GraduatedRange", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "volumeRanges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "VolumeRange", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FixedChargePropertiesInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedRanges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GraduatedRangeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "volumeRanges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "VolumeRangeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "FixedChargeUpdateInput", + "description": "Autogenerated input type of UpdateFixedCharge", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applyUnitsImmediately", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeModel", + "description": null, + "type": { + "kind": "ENUM", + "name": "FixedChargeChargeModelEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prorated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "FixedChargePropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Float", + "description": "Represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point).", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "FlutterwaveProvider", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "secretKey", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookSecret", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GenerateCheckoutUrlInput", + "description": "Autogenerated input type of GenerateCheckoutUrl", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GenerateCheckoutUrlPayload", + "description": "Autogenerated return type of GenerateCheckoutUrl.", + "fields": [ + { + "name": "checkoutUrl", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GenerateCustomerPortalUrlInput", + "description": "Autogenerated input type of GenerateCustomerPortalUrl", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GenerateCustomerPortalUrlPayload", + "description": "Autogenerated return type of GenerateCustomerPortalUrl.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "url", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GeneratePaymentUrlInput", + "description": "Autogenerated input type of GeneratePaymentUrl", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GeneratePaymentUrlPayload", + "description": "Autogenerated return type of GeneratePaymentUrl.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GocardlessProvider", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasAccessToken", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookSecret", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GoogleAcceptInviteInput", + "description": "Autogenerated input type of GoogleAcceptInvite", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inviteToken", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GoogleLoginUserInput", + "description": "Autogenerated input type of GoogleLoginUser", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GoogleRegisterUserInput", + "description": "Autogenerated input type of GoogleRegisterUser", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationName", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GraduatedPercentageRange", + "description": null, + "fields": [ + { + "name": "flatAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromValue", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GraduatedPercentageRangeInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "fromValue", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flatAmount", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GraduatedRange", + "description": null, + "fields": [ + { + "name": "flatAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromValue", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "GraduatedRangeInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "fromValue", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flatAmount", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitAmount", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GraphqlSubscription", + "description": null, + "fields": [ + { + "name": "aiConversationStreamed", + "description": null, + "args": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AiConversationStream", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GrossRevenue", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "month", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GrossRevenueCollection", + "description": "GrossRevenueCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated GrossRevenueCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GrossRevenue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "GroupedChargeUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ChargeFilterUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "groupedBy", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "presentationBreakdowns", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PresentationBreakdownUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "HttpMethodEnum", + "description": "Api Logs http method enums", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "post", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "put", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "HttpStatus", + "description": "Api Logs HTTP status", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "HubspotCustomer", + "description": null, + "fields": [ + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetedObject", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "HubspotTargetedObjectsEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "HubspotIntegration", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultTargetedObject", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "HubspotTargetedObjectsEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesObjectTypeId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "portalId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionsObjectTypeId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncSubscriptions", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "HubspotTargetedObjectsEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "companies", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "contacts", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ID", + "description": "Represents a unique identifier that is Base64 obfuscated. It is often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `\"VXNlci0xMA==\"`) or integer (such as `4`) input value will be accepted as an ID.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ISO8601Date", + "description": "An ISO 8601-encoded date", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "description": "An ISO 8601-encoded datetime", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "Int", + "description": "Represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "Integration", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "AnrokIntegration", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "AvalaraIntegration", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "HubspotIntegration", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "NetsuiteIntegration", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "OktaIntegration", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "SalesforceIntegration", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "XeroIntegration", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "IntegrationCollection", + "description": "IntegrationCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated IntegrationCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "Integration", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "IntegrationCustomerInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationType", + "description": null, + "type": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subsidiaryId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetedObject", + "description": null, + "type": { + "kind": "ENUM", + "name": "HubspotTargetedObjectsEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "IntegrationItem", + "description": null, + "fields": [ + { + "name": "externalAccountCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "IntegrationItemTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "IntegrationItemCollection", + "description": "IntegrationItemCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated IntegrationItemCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "IntegrationItem", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "IntegrationItemTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "standard", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "account", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "anrok", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "beta_payment_authorization", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netsuite", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "okta", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "avalara", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "xero", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressive_billing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lifetime_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hubspot", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "auto_dunning", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revenue_analytics", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "salesforce", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "api_permissions", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revenue_share", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "remove_branding_watermark", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "manual_payments", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "from_email", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issue_receipts", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "preview", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "multi_entities_pro", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "multi_entities_enterprise", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "analytics_dashboards", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "forecasted_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projected_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "custom_roles", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "events_targeting_wallets", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "security_logs", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "granular_lifetime_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_forms", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Invite", + "description": null, + "fields": [ + { + "name": "acceptedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recipient", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Membership", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revokedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InviteStatusTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "token", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InviteCollection", + "description": "InviteCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated InviteCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Invite", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InviteStatusTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accepted", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revoked", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Invoice", + "description": "Invoice", + "fields": [ + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "allChargesHaveFees", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "allFixedChargesHaveFees", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedTaxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceAppliedTax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "associatedActiveWalletPresent", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "availableToCreditAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errorDetails", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ErrorDetail", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expectedFinalizationDate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalHubspotIntegrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalIntegrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSalesforceIntegrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fees", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fileUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationHubspotSyncable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationSalesforceSyncable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationSyncable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceSubscriptions", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceSubscription", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceMetadata", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "number", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offsettableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payableType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentDisputeLosable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentDisputeLostAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentDueDate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentOverdue", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentStatus", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payments", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Payment", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prepaidCreditAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prepaidGrantedCreditAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prepaidPurchasedCreditAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressiveBillingCreditAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "readyForPaymentProcessing", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "regeneratedInvoiceId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selfBilled", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sequentialId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceStatusTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subTotalExcludingTaxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subTotalIncludingTaxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptions", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxProviderId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxProviderVoidable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxStatus", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "InvoiceTaxStatusTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxesRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalDueAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalPaidAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalSettledAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "versionNumber", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedInvoiceId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "xmlUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InvoiceAppliedTax", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedOnWholeInvoice", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumedTaxCode", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "InvoiceAppliedTaxOnWholeInvoiceCodeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feesAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxDescription", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxableAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + { + "kind": "INTERFACE", + "name": "AppliedTax", + "ofType": null + } + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InvoiceAppliedTaxOnWholeInvoiceCodeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "not_collecting", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "juris_not_taxed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reverse_charge", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_exempt", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transaction_exempt", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "juris_has_no_tax", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unknown_taxation", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InvoiceCollection", + "description": "InvoiceCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated InvoiceCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "details", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "displayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InvoiceCustomSectionCollection", + "description": "InvoiceCustomSectionCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated InvoiceCustomSectionCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "invoiceCustomSectionIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skipInvoiceCustomSections", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InvoiceExportTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "invoices", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_fees", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INTERFACE", + "name": "InvoiceItem", + "description": "Invoice Item", + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemCode", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "InvoiceMetadata", + "description": "Attributes for invoice metadata object", + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "key", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "InvoiceMetadataInput", + "description": "Attributes for creating or updating invoice metadata object", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "key", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "succeeded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InvoiceSettlementTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "credit_note", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InvoiceStatusTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "draft", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalized", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voided", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "generating", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "open", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "closed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InvoiceSubscription", + "description": null, + "fields": [ + { + "name": "acceptNewChargeFees", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesFromDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesToDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fees", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inAdvanceChargesFromDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inAdvanceChargesToDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InvoiceTaxStatusTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "succeeded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "InvoiceTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "subscription", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "add_on", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "one_off", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "advance_charges", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressive_billing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InvoicedUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "month", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "InvoicedUsageCollection", + "description": "InvoicedUsageCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated InvoicedUsageCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoicedUsage", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ItemMetadata", + "description": "Metadata key-value pair", + "fields": [ + { + "name": "key", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "JSON", + "description": "Represents untyped JSON", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "LimitationInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "billableMetricIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "LogEventEnum", + "description": "Security Log event", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "api_key_created", + "description": "api_key.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "api_key_deleted", + "description": "api_key.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "api_key_rotated", + "description": "api_key.rotated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "api_key_updated", + "description": "api_key.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entity_created", + "description": "billing_entity.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entity_updated", + "description": "billing_entity.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "export_created", + "description": "export.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integration_created", + "description": "integration.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integration_deleted", + "description": "integration.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integration_updated", + "description": "integration.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "role_created", + "description": "role.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "role_deleted", + "description": "role.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "role_updated", + "description": "role.updated", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user_deleted", + "description": "user.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user_new_device_logged_in", + "description": "user.new_device_logged_in", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user_invited", + "description": "user.invited", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user_password_edited", + "description": "user.password_edited", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user_password_reset_requested", + "description": "user.password_reset_requested", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user_role_edited", + "description": "user.role_edited", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user_signed_up", + "description": "user.signed_up", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhook_endpoint_created", + "description": "webhook_endpoint.created", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhook_endpoint_deleted", + "description": "webhook_endpoint.deleted", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhook_endpoint_updated", + "description": "webhook_endpoint.updated", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "LogTypeEnum", + "description": "Security Log type", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "api_key", + "description": "api_key", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entity", + "description": "billing_entity", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "export", + "description": "export", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integration", + "description": "integration", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "role", + "description": "role", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user", + "description": "user", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhook_endpoint", + "description": "webhook_endpoint", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "LoginUser", + "description": null, + "fields": [ + { + "name": "token", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "LoginUserInput", + "description": "Autogenerated input type of LoginUser", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "password", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "LoseInvoiceDisputeInput", + "description": "Autogenerated input type of LoseInvoiceDispute", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MappableTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "AddOn", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "BillableMetric", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Mapping", + "description": null, + "fields": [ + { + "name": "billingEntityId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalAccountCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappableId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappableType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MappableTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MappingTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "fallback_item", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupon", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_fee", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minimum_commitment", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "prepaid_credit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "account", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currencies", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Membership", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Permissions", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revokedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "MembershipStatus", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MembershipCollection", + "description": "MembershipCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated MembershipCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Membership", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Metadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "MembershipStatus", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "active", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revoked", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Metadata", + "description": "Type for CollectionMetadataType", + "fields": [ + { + "name": "adminCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currentPage", + "description": "Current Page of loaded data", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limitValue", + "description": "The number of items per page", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalCount", + "description": "The total number of items to be paginated", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalPages", + "description": "The total number of pages in the pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "MetadataInput", + "description": "Input for metadata key-value pair", + "fields": null, + "inputFields": [ + { + "name": "key", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "description": null, + "fields": [ + { + "name": "apiKey", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Mrr", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "month", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "MrrCollection", + "description": "MrrCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated MrrCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Mrr", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Mutation", + "description": null, + "fields": [ + { + "name": "acceptInvite", + "description": "Accepts a new Invite", + "args": [ + { + "name": "input", + "description": "Parameters for AcceptInvite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AcceptInviteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RegisterUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addAdyenPaymentProvider", + "description": "Add Adyen payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for AddAdyenPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddAdyenPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AdyenProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addCashfreePaymentProvider", + "description": "Add or update Cashfree payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for AddCashfreePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddCashfreePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addFlutterwavePaymentProvider", + "description": "Add Flutterwave payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for AddFlutterwavePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddFlutterwavePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FlutterwaveProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addGocardlessPaymentProvider", + "description": "Add or update Gocardless payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for AddGocardlessPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddGocardlessPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "GocardlessProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addMoneyhashPaymentProvider", + "description": "Add Moneyhash payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for AddMoneyhashPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddMoneyhashPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addStripePaymentProvider", + "description": "Add Stripe API keys to the organization", + "args": [ + { + "name": "input", + "description": "Parameters for AddStripePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "AddStripePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "StripeProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "approveQuoteVersion", + "description": "Approve a quote version", + "args": [ + { + "name": "input", + "description": "Parameters for ApproveQuoteVersion", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ApproveQuoteVersionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "QuoteVersion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityApplyTaxes", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for ApplyTaxes", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ApplyTaxesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ApplyTaxesPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityRemoveTaxes", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for RemoveTaxes", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RemoveTaxesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RemoveTaxesPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityUpdateAppliedDunningCampaign", + "description": "Updates the applied dunning campaign for a billing entity", + "args": [ + { + "name": "input", + "description": "Parameters for BillingEntityUpdateAppliedDunningCampaign", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "BillingEntityUpdateAppliedDunningCampaignInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cloneQuoteVersion", + "description": "Clone a quote version", + "args": [ + { + "name": "input", + "description": "Parameters for CloneQuoteVersion", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CloneQuoteVersionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "QuoteVersion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createAddOn", + "description": "Creates a new add-on", + "args": [ + { + "name": "input", + "description": "Parameters for CreateAddOn", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateAddOnInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AddOn", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createAdjustedFee", + "description": "Creates Adjusted Fee", + "args": [ + { + "name": "input", + "description": "Parameters for CreateAdjustedFee", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateAdjustedFeeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createAiConversation", + "description": "Creates an AI conversation and appends a message to it", + "args": [ + { + "name": "input", + "description": "Parameters for CreateAiConversation", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateAiConversationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AiConversation", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createAnrokIntegration", + "description": "Create Anrok integration", + "args": [ + { + "name": "input", + "description": "Parameters for CreateAnrokIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateAnrokIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AnrokIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createApiKey", + "description": "Creates a new API key", + "args": [ + { + "name": "input", + "description": "Parameters for CreateApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createAppliedCoupon", + "description": "Assigns a Coupon to a Customer", + "args": [ + { + "name": "input", + "description": "Parameters for CreateAppliedCoupon", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateAppliedCouponInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AppliedCoupon", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createAvalaraIntegration", + "description": "Create Avalara integration", + "args": [ + { + "name": "input", + "description": "Parameters for CreateAvalaraIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateAvalaraIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AvalaraIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createBillableMetric", + "description": "Creates a new Billable metric", + "args": [ + { + "name": "input", + "description": "Parameters for CreateBillableMetric", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateBillableMetricInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createBillingEntity", + "description": "Creates a new Billing Entity", + "args": [ + { + "name": "input", + "description": "Parameters for CreateBillingEntity", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateBillingEntityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCharge", + "description": "Creates a new Charge for a Plan", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeCreateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createChargeFilter", + "description": "Creates a new Charge Filter", + "args": [ + { + "name": "input", + "description": "Parameters for CreateChargeFilter", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterCreateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ChargeFilter", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCoupon", + "description": "Creates a new Coupon", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCoupon", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCouponInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Coupon", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCreditNote", + "description": "Creates a new Credit Note", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCreditNote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCreditNoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCreditNotesDataExport", + "description": "Request data export of credit notes", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCreditNotesDataExport", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateDataExportsCreditNotesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DataExport", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCustomer", + "description": "Creates a new customer", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCustomer", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCustomerPortalWalletTransaction", + "description": "Creates a new Customer Wallet Transaction from Customer Portal", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCustomerPortalWalletTransaction", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerPortalWalletTransactionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CustomerPortalWalletTransactionCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCustomerWallet", + "description": "Creates a new Customer Wallet", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCustomerWallet", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerWalletInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Wallet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCustomerWalletAlert", + "description": "Creates a new Alert for wallet", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCustomerWalletAlert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerWalletAlertInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createCustomerWalletTransaction", + "description": "Creates a new Customer Wallet Transaction", + "args": [ + { + "name": "input", + "description": "Parameters for CreateCustomerWalletTransaction", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateCustomerWalletTransactionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "WalletTransactionCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createDunningCampaign", + "description": "Creates a new dunning campaign", + "args": [ + { + "name": "input", + "description": "Parameters for CreateDunningCampaign", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateDunningCampaignInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DunningCampaign", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createFeature", + "description": "Creates a new feature", + "args": [ + { + "name": "input", + "description": "Parameters for CreateFeature", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateFeatureInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FeatureObject", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createFixedCharge", + "description": "Creates a new Fixed Charge for a Plan", + "args": [ + { + "name": "input", + "description": "Parameters for CreateFixedCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FixedChargeCreateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FixedCharge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createHubspotIntegration", + "description": "Create Hubspot integration", + "args": [ + { + "name": "input", + "description": "Parameters for CreateHubspotIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateHubspotIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "HubspotIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createIntegrationCollectionMapping", + "description": "Create integration collection mapping", + "args": [ + { + "name": "input", + "description": "Parameters for CreateIntegrationCollectionMapping", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateIntegrationCollectionMappingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CollectionMapping", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createIntegrationMapping", + "description": "Create integration mapping", + "args": [ + { + "name": "input", + "description": "Parameters for CreateIntegrationMapping", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateIntegrationMappingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Mapping", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createInvite", + "description": "Creates a new Invite", + "args": [ + { + "name": "input", + "description": "Parameters for CreateInvite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateInviteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invite", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createInvoice", + "description": "Creates a new Invoice", + "args": [ + { + "name": "input", + "description": "Parameters for CreateInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createInvoiceCustomSection", + "description": "Creates a new InvoiceCustomSection", + "args": [ + { + "name": "input", + "description": "Parameters for CreateInvoiceCustomSection", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateInvoiceCustomSectionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createInvoicesDataExport", + "description": "Request data export of invoices", + "args": [ + { + "name": "input", + "description": "Parameters for CreateInvoicesDataExport", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateDataExportsInvoicesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DataExport", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createNetsuiteIntegration", + "description": "Create Netsuite integration", + "args": [ + { + "name": "input", + "description": "Parameters for CreateNetsuiteIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateNetsuiteIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "NetsuiteIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createOktaIntegration", + "description": "Create Okta integration", + "args": [ + { + "name": "input", + "description": "Parameters for CreateOktaIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateOktaIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "OktaIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createOrUpdateSubscriptionEntitlement", + "description": "Updates a subscription entitlement", + "args": [ + { + "name": "input", + "description": "Parameters for CreateOrUpdateSubscriptionEntitlement", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateOrUpdateSubscriptionEntitlementInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SubscriptionEntitlement", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPasswordReset", + "description": "Creates a new password reset", + "args": [ + { + "name": "input", + "description": "Parameters for CreatePasswordReset", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreatePasswordResetInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreatePasswordResetPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPayment", + "description": "Creates a manual payment", + "args": [ + { + "name": "input", + "description": "Parameters for CreatePayment", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreatePaymentInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Payment", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPaymentRequest", + "description": "Creates a payment request", + "args": [ + { + "name": "input", + "description": "Parameters for CreatePaymentRequest", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PaymentRequestCreateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PaymentRequest", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPlan", + "description": "Creates a new Plan", + "args": [ + { + "name": "input", + "description": "Parameters for CreatePlan", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreatePlanInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createPricingUnit", + "description": "Creates a new pricing unit", + "args": [ + { + "name": "input", + "description": "Parameters for CreatePricingUnit", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreatePricingUnitInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PricingUnit", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createQuote", + "description": "Create a new quote", + "args": [ + { + "name": "input", + "description": "Parameters for CreateQuote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateQuoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Quote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createRole", + "description": "Creates a new custom role", + "args": [ + { + "name": "input", + "description": "Parameters for CreateRole", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateRoleInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Role", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createSalesforceIntegration", + "description": "Create Salesforce integration", + "args": [ + { + "name": "input", + "description": "Parameters for CreateSalesforceIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateSalesforceIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SalesforceIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createSubscription", + "description": "Create a new Subscription", + "args": [ + { + "name": "input", + "description": "Parameters for CreateSubscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createSubscriptionAlert", + "description": "Creates a new Alert for subscription", + "args": [ + { + "name": "input", + "description": "Parameters for CreateSubscriptionAlert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateSubscriptionAlertInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createSubscriptionChargeFilter", + "description": "Create a charge filter for a subscription", + "args": [ + { + "name": "input", + "description": "Parameters for CreateSubscriptionChargeFilter", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateSubscriptionChargeFilterInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ChargeFilter", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createTax", + "description": "Creates a tax", + "args": [ + { + "name": "input", + "description": "Parameters for CreateTax", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TaxCreateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createWebhookEndpoint", + "description": "Create a new webhook endpoint", + "args": [ + { + "name": "input", + "description": "Parameters for CreateWebhookEndpoint", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "WebhookEndpointCreateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "WebhookEndpoint", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createXeroIntegration", + "description": "Create Xero integration", + "args": [ + { + "name": "input", + "description": "Parameters for CreateXeroIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateXeroIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "XeroIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyAddOn", + "description": "Deletes an add-on", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyAddOn", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyAddOnInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyAddOnPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyAdjustedFee", + "description": "Deletes an adjusted fee", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyAdjustedFee", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyAdjustedFeeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyAdjustedFeePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyApiKey", + "description": "Deletes an API key", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyBillableMetric", + "description": "Deletes a Billable metric", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyBillableMetric", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyBillableMetricInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyBillableMetricPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyBillingEntity", + "description": "Destroys a new Billing Entity", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyBillingEntity", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyBillingEntityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyBillingEntityPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyCharge", + "description": "Deletes a Charge", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyChargeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyChargePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyChargeFilter", + "description": "Deletes a Charge Filter", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyChargeFilter", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyChargeFilterInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyChargeFilterPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyCoupon", + "description": "Deletes a coupon", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyCoupon", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyCouponInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyCouponPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyCustomer", + "description": "Delete a Customer", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyCustomer", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyCustomerInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyCustomerPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyCustomerWalletAlert", + "description": "Deletes an alert", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyCustomerWalletAlert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyCustomerWalletAlertInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyDunningCampaign", + "description": "Deletes a dunning campaign", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyDunningCampaign", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyDunningCampaignInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyDunningCampaignPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyFeature", + "description": "Destroys an existing feature", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyFeature", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyFeatureInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FeatureObject", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyFixedCharge", + "description": "Deletes a Fixed Charge", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyFixedCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyFixedChargeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyFixedChargePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyIntegration", + "description": "Destroy an integration", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyIntegrationPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyIntegrationCollectionMapping", + "description": "Destroy an integration collection mapping", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyIntegrationCollectionMapping", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyIntegrationCollectionMappingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyIntegrationCollectionMappingPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyIntegrationMapping", + "description": "Destroy an integration mapping", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyIntegrationMapping", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyIntegrationMappingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyIntegrationMappingPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyInvoiceCustomSection", + "description": "Deletes an invoice_custom_section", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyInvoiceCustomSection", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyInvoiceCustomSectionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyInvoiceCustomSectionPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyPaymentMethod", + "description": "Deletes a payment method", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyPaymentMethod", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyPaymentMethodInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyPaymentMethodPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyPaymentProvider", + "description": "Destroy a payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyPaymentProviderPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyPlan", + "description": "Deletes a Plan", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyPlan", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyPlanInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyPlanPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyRole", + "description": "Deletes a custom role", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyRole", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyRoleInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Role", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroySubscriptionAlert", + "description": "Deletes an alert", + "args": [ + { + "name": "input", + "description": "Parameters for DestroySubscriptionAlert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroySubscriptionAlertInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroySubscriptionChargeFilter", + "description": "Destroy a charge filter for a subscription", + "args": [ + { + "name": "input", + "description": "Parameters for DestroySubscriptionChargeFilter", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroySubscriptionChargeFilterInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ChargeFilter", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyTax", + "description": "Deletes a tax", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyTax", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyTaxInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyTaxPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "destroyWebhookEndpoint", + "description": "Deletes a webhook endpoint", + "args": [ + { + "name": "input", + "description": "Parameters for DestroyWebhookEndpoint", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DestroyWebhookEndpointInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DestroyWebhookEndpointPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadCreditNote", + "description": "Download a Credit Note PDF", + "args": [ + { + "name": "input", + "description": "Parameters for DownloadCreditNote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DownloadCreditNoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadCustomerPortalInvoice", + "description": "Download customer portal invoice PDF", + "args": [ + { + "name": "input", + "description": "Parameters for DownloadCustomerPortalInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DownloadCustomerPortalInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadInvoice", + "description": "Download an Invoice PDF", + "args": [ + { + "name": "input", + "description": "Parameters for DownloadInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DownloadInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadInvoiceXml", + "description": "Download an Invoice XML", + "args": [ + { + "name": "input", + "description": "Parameters for DownloadXmlInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DownloadXmlInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadPaymentReceipt", + "description": "Download an PaymentReceipt PDF", + "args": [ + { + "name": "input", + "description": "Parameters for DownloadPaymentReceipt", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DownloadPaymentReceiptInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PaymentReceipt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadXmlCreditNote", + "description": "Download a Credit Note XML", + "args": [ + { + "name": "input", + "description": "Parameters for DownloadXmlCreditNote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DownloadXmlCreditNoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downloadXmlPaymentReceipt", + "description": "Download an PaymentReceipt XML", + "args": [ + { + "name": "input", + "description": "Parameters for DownloadXMLPaymentReceipt", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DownloadXMLPaymentReceiptInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PaymentReceipt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fetchDraftInvoiceTaxes", + "description": "Fetches taxes for one-off invoice", + "args": [ + { + "name": "input", + "description": "Parameters for FetchDraftInvoiceTaxes", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FetchDraftInvoiceTaxesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "TaxFeeObjectCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fetchIntegrationAccounts", + "description": "Fetch integration accounts", + "args": [ + { + "name": "input", + "description": "Parameters for FetchIntegrationAccounts", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FetchIntegrationAccountsInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "IntegrationItemCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fetchIntegrationItems", + "description": "Fetch integration items", + "args": [ + { + "name": "input", + "description": "Parameters for FetchIntegrationItems", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FetchIntegrationItemsInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "IntegrationItemCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeAllInvoices", + "description": "Finalize all draft invoices", + "args": [ + { + "name": "input", + "description": "Parameters for FinalizeAllInvoices", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FinalizeAllInvoicesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "InvoiceCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeInvoice", + "description": "Finalize a draft invoice", + "args": [ + { + "name": "input", + "description": "Parameters for FinalizeInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FinalizeInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "generateCheckoutUrl", + "description": "Generates checkout url for payment method", + "args": [ + { + "name": "input", + "description": "Parameters for GenerateCheckoutUrl", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GenerateCheckoutUrlInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "GenerateCheckoutUrlPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "generateCustomerPortalUrl", + "description": "Generate customer portal URL", + "args": [ + { + "name": "input", + "description": "Parameters for GenerateCustomerPortalUrl", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GenerateCustomerPortalUrlInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "GenerateCustomerPortalUrlPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "generatePaymentUrl", + "description": "Generates a payment URL for an invoice", + "args": [ + { + "name": "input", + "description": "Parameters for GeneratePaymentUrl", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GeneratePaymentUrlInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "GeneratePaymentUrlPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "googleAcceptInvite", + "description": "Accepts a membership invite with Google Oauth", + "args": [ + { + "name": "input", + "description": "Parameters for GoogleAcceptInvite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GoogleAcceptInviteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RegisterUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "googleLoginUser", + "description": "Opens a session for an existing user with Google Oauth", + "args": [ + { + "name": "input", + "description": "Parameters for GoogleLoginUser", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GoogleLoginUserInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LoginUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "googleRegisterUser", + "description": "Register a new user with Google Oauth", + "args": [ + { + "name": "input", + "description": "Parameters for GoogleRegisterUser", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GoogleRegisterUserInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RegisterUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "loginUser", + "description": "Opens a session for an existing user", + "args": [ + { + "name": "input", + "description": "Parameters for LoginUser", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "LoginUserInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LoginUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "loseInvoiceDispute", + "description": "Mark payment dispute as lost", + "args": [ + { + "name": "input", + "description": "Parameters for LoseInvoiceDispute", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "LoseInvoiceDisputeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "oktaAcceptInvite", + "description": "Accepts a membership invite with Okta Oauth", + "args": [ + { + "name": "input", + "description": "Parameters for OktaAcceptInvite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "OktaAcceptInviteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LoginUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "oktaAuthorize", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for OktaAuthorize", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "OktaAuthorizeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Authorize", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "oktaLogin", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for OktaLogin", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "OktaLoginInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LoginUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "previewAdjustedFee", + "description": "Preview Adjusted Fee", + "args": [ + { + "name": "input", + "description": "Parameters for PreviewAdjustedFee", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PreviewAdjustedFeeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refreshInvoice", + "description": "Refresh a draft invoice", + "args": [ + { + "name": "input", + "description": "Parameters for RefreshInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RefreshInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "regenerateFromVoided", + "description": "Regenerate an invoice from a voided invoice", + "args": [ + { + "name": "input", + "description": "Parameters for RegenerateInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RegenerateInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "registerUser", + "description": "Registers a new user and creates related organization", + "args": [ + { + "name": "input", + "description": "Parameters for RegisterUser", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RegisterUserInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RegisterUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removeSubscriptionEntitlement", + "description": "Removes a feature entitlement from a subscription", + "args": [ + { + "name": "input", + "description": "Parameters for RemoveSubscriptionEntitlement", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RemoveSubscriptionEntitlementInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "RemoveSubscriptionEntitlementPayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resendCreditNoteEmail", + "description": "Resend credit note email with optional custom recipients", + "args": [ + { + "name": "input", + "description": "Parameters for ResendCreditNoteEmail", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ResendCreditNoteEmailInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resendInvoiceEmail", + "description": "Resend invoice email with optional custom recipients", + "args": [ + { + "name": "input", + "description": "Parameters for ResendInvoiceEmail", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ResendInvoiceEmailInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resendPaymentReceiptEmail", + "description": "Resend payment receipt email with optional custom recipients", + "args": [ + { + "name": "input", + "description": "Parameters for ResendPaymentReceiptEmail", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ResendPaymentReceiptEmailInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PaymentReceipt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resetPassword", + "description": "Reset password for user and log in", + "args": [ + { + "name": "input", + "description": "Parameters for ResetPassword", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ResetPasswordInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "LoginUser", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryAllInvoicePayments", + "description": "Retry all invoice payments", + "args": [ + { + "name": "input", + "description": "Parameters for RetryAllInvoicePayments", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryAllInvoicePaymentsInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "InvoiceCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryAllInvoices", + "description": "Retry all failed invoices", + "args": [ + { + "name": "input", + "description": "Parameters for RetryAllInvoices", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryAllInvoicesInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "InvoiceCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryInvoice", + "description": "Retry failed invoice", + "args": [ + { + "name": "input", + "description": "Parameters for RetryInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryInvoicePayment", + "description": "Retry invoice payment", + "args": [ + { + "name": "input", + "description": "Parameters for RetryInvoicePayment", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryInvoicePaymentInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryTaxProviderVoiding", + "description": "Retry voided invoice sync", + "args": [ + { + "name": "input", + "description": "Parameters for RetryTaxProviderVoiding", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryTaxProviderVoidingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryTaxReporting", + "description": "Retry tax reporting", + "args": [ + { + "name": "input", + "description": "Parameters for RetryTaxReporting", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryTaxReportingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retryWebhook", + "description": "Retry a Webhook", + "args": [ + { + "name": "input", + "description": "Parameters for RetryWebhook", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RetryWebhookInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Webhook", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revokeInvite", + "description": "Revokes an invite", + "args": [ + { + "name": "input", + "description": "Parameters for RevokeInvite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RevokeInviteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invite", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revokeMembership", + "description": "Revoke a membership", + "args": [ + { + "name": "input", + "description": "Parameters for RevokeMembership", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RevokeMembershipInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Membership", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rotateApiKey", + "description": "Create new ApiKey while expiring provided", + "args": [ + { + "name": "input", + "description": "Parameters for RotateApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "RotateApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "setPaymentMethodAsDefault", + "description": "Set payment method as default", + "args": [ + { + "name": "input", + "description": "Parameters for SetAsDefault", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SetAsDefaultInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PaymentMethod", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncHubspotIntegrationInvoice", + "description": "Sync hubspot integration invoice", + "args": [ + { + "name": "input", + "description": "Parameters for SyncHubspotInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SyncHubspotIntegrationInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SyncHubspotInvoicePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncIntegrationCreditNote", + "description": "Sync integration credit note", + "args": [ + { + "name": "input", + "description": "Parameters for SyncIntegrationCreditNote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SyncIntegrationCreditNoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SyncIntegrationCreditNotePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncIntegrationInvoice", + "description": "Sync integration invoice", + "args": [ + { + "name": "input", + "description": "Parameters for SyncIntegrationInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SyncIntegrationInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SyncIntegrationInvoicePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncSalesforceInvoice", + "description": "Sync Salesforce integration invoice", + "args": [ + { + "name": "input", + "description": "Parameters for SyncSalesforceInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SyncSalesforceInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SyncSalesforceInvoicePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminateAppliedCoupon", + "description": "Unassign a coupon from a customer", + "args": [ + { + "name": "input", + "description": "Parameters for TerminateAppliedCoupon", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TerminateAppliedCouponInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AppliedCoupon", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminateCoupon", + "description": "Deletes a coupon", + "args": [ + { + "name": "input", + "description": "Parameters for TerminateCoupon", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TerminateCouponInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Coupon", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminateCustomerWallet", + "description": "Terminates a new Customer Wallet", + "args": [ + { + "name": "input", + "description": "Parameters for TerminateCustomerWallet", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TerminateCustomerWalletInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Wallet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminateSubscription", + "description": "Terminate a Subscription", + "args": [ + { + "name": "input", + "description": "Parameters for TerminateSubscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TerminateSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateAddOn", + "description": "Update an existing add-on", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateAddOn", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateAddOnInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AddOn", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateAdyenPaymentProvider", + "description": "Update Adyen payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateAdyenPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateAdyenPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AdyenProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateAnrokIntegration", + "description": "Update Anrok integration", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateAnrokIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateAnrokIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AnrokIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateApiKey", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for UpdateApiKey", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateApiKeyInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateAvalaraIntegration", + "description": "Update Avalara integration", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateAvalaraIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateAvalaraIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AvalaraIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateBillableMetric", + "description": "Updates an existing Billable metric", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateBillableMetric", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateBillableMetricInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateBillingEntity", + "description": "Updates a Billing Entity", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateBillingEntity", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateBillingEntityInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCashfreePaymentProvider", + "description": "Update Cashfree payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCashfreePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCashfreePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCharge", + "description": "Updates an existing Charge", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeUpdateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateChargeFilter", + "description": "Updates an existing Charge Filter", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateChargeFilter", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterUpdateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ChargeFilter", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCoupon", + "description": "Update an existing coupon", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCoupon", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCouponInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Coupon", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCreditNote", + "description": "Updates an existing Credit Note", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCreditNote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCreditNoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCustomer", + "description": "Updates an existing Customer", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCustomer", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCustomerInvoiceGracePeriod", + "description": "Assign the invoice grace period to Customers", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCustomerInvoiceGracePeriod", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerInvoiceGracePeriodInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCustomerPortalCustomer", + "description": "Update customer data from Customer Portal", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCustomerPortalCustomer", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerPortalCustomerInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CustomerPortalCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCustomerWallet", + "description": "Updates a new Customer Wallet", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCustomerWallet", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerWalletInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Wallet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateCustomerWalletAlert", + "description": "Updates an alert", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateCustomerWalletAlert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerWalletAlertInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateDunningCampaign", + "description": "Updates a dunning campaign and its thresholds", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateDunningCampaign", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateDunningCampaignInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DunningCampaign", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateFeature", + "description": "Updates an existing feature", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateFeature", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateFeatureInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FeatureObject", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateFixedCharge", + "description": "Updates an existing Fixed Charge", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateFixedCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FixedChargeUpdateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FixedCharge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateFlutterwavePaymentProvider", + "description": "Update Flutterwave payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateFlutterwavePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateFlutterwavePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FlutterwaveProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateGocardlessPaymentProvider", + "description": "Update Gocardless payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateGocardlessPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateGocardlessPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "GocardlessProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateHubspotIntegration", + "description": "Update Hubspot integration", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateHubspotIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateHubspotIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "HubspotIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateIntegrationCollectionMapping", + "description": "Update integration mapping", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateIntegrationCollectionMapping", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateIntegrationCollectionMappingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CollectionMapping", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateIntegrationMapping", + "description": "Update integration mapping", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateIntegrationMapping", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateIntegrationMappingInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Mapping", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateInvite", + "description": "Update an invite", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateInvite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateInviteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invite", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateInvoice", + "description": "Update an existing invoice", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateInvoiceCustomSection", + "description": "Updates an InvoiceCustomSection", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateInvoiceCustomSection", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateInvoiceCustomSectionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateMembership", + "description": "Update a membership", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateMembership", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateMembershipInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Membership", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateMoneyhashPaymentProvider", + "description": "Update Moneyhash payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateMoneyhashPaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateMoneyhashPaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateNetsuiteIntegration", + "description": "Update Netsuite integration", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateNetsuiteIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateNetsuiteIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "NetsuiteIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateOktaIntegration", + "description": "Update Okta integration", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateOktaIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateOktaIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "OktaIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateOrganization", + "description": "Updates an Organization", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateOrganization", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateOrganizationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CurrentOrganization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatePlan", + "description": "Updates an existing Plan", + "args": [ + { + "name": "input", + "description": "Parameters for UpdatePlan", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdatePlanInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatePricingUnit", + "description": null, + "args": [ + { + "name": "input", + "description": "Parameters for UpdatePricingUnit", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdatePricingUnitInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PricingUnit", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateQuote", + "description": "Update a quote", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateQuote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateQuoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Quote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateQuoteVersion", + "description": "Update a quote version", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateQuoteVersion", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateQuoteVersionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "QuoteVersion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateRole", + "description": "Updates an existing custom role", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateRole", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateRoleInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Role", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateSalesforceIntegration", + "description": "Update Salesforce integration", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateSalesforceIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateSalesforceIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SalesforceIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateStripePaymentProvider", + "description": "Update Stripe payment provider", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateStripePaymentProvider", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateStripePaymentProviderInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "StripeProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateSubscription", + "description": "Update a Subscription", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateSubscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateSubscriptionAlert", + "description": "Updates an alert", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateSubscriptionAlert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionAlertInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateSubscriptionCharge", + "description": "Update a charge for a subscription", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateSubscriptionCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionChargeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateSubscriptionChargeFilter", + "description": "Update a charge filter for a subscription", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateSubscriptionChargeFilter", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionChargeFilterInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ChargeFilter", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateSubscriptionFixedCharge", + "description": "Update a fixed charge for a subscription", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateSubscriptionFixedCharge", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionFixedChargeInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "FixedCharge", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateTax", + "description": "Update an existing tax", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateTax", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "TaxUpdateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateWebhookEndpoint", + "description": "Update a new webhook endpoint", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateWebhookEndpoint", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "WebhookEndpointUpdateInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "WebhookEndpoint", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updateXeroIntegration", + "description": "Update Xero integration", + "args": [ + { + "name": "input", + "description": "Parameters for UpdateXeroIntegration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateXeroIntegrationInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "XeroIntegration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidCreditNote", + "description": "Voids a Credit Note", + "args": [ + { + "name": "input", + "description": "Parameters for VoidCreditNote", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "VoidCreditNoteInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidInvoice", + "description": "Void an invoice", + "args": [ + { + "name": "input", + "description": "Parameters for VoidInvoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "VoidInvoiceInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidQuoteVersion", + "description": "Void a quote version", + "args": [ + { + "name": "input", + "description": "Parameters for VoidQuoteVersion", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "VoidQuoteVersionInput", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "QuoteVersion", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "NetsuiteCustomer", + "description": null, + "fields": [ + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subsidiaryId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "NetsuiteIntegration", + "description": null, + "fields": [ + { + "name": "accountId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasMappingsConfigured", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "scriptEndpointUrl", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncCreditNotes", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncPayments", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tokenId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tokenSecret", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "NextSubscriptionTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "upgrade", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downgrade", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "ObfuscatedString", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "OktaAcceptInviteInput", + "description": "Accept Invite with Okta Oauth input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inviteToken", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "OktaAuthorizeInput", + "description": "Autogenerated input type of OktaAuthorize", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inviteToken", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "OktaIntegration", + "description": null, + "fields": [ + { + "name": "clientId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "domain", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "host", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "OktaLoginInput", + "description": "Autogenerated input type of OktaLogin", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "OnTerminationCreditNoteEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "credit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skip", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refund", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "offset", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "OnTerminationInvoiceEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "generate", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skip", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "OrderByEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "gross_revenue_amount_cents", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "net_revenue_amount_cents", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "OrderTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "subscription_creation", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription_amendment", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "one_off", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Organization", + "description": "Safe Organization Type", + "fields": [ + { + "name": "accessibleByCurrentSession", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "OrganizationBillingConfiguration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "canCreateBillingEntity", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logoUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "slug", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "OrganizationBillingConfiguration", + "description": null, + "fields": [ + { + "name": "documentLocale", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceFooter", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "OrganizationBillingConfigurationInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "documentLocale", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceFooter", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "OverdueBalance", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lagoInvoiceIds", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "month", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "OverdueBalanceCollection", + "description": "OverdueBalanceCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated OverdueBalanceCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "OverdueBalance", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "Payable", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "PaymentRequest", + "ofType": null + } + ] + }, + { + "kind": "ENUM", + "name": "PayablePaymentStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "processing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "succeeded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Payment", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "Payable", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payablePaymentStatus", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "PayablePaymentStatusEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProvider", + "description": null, + "args": [], + "type": { + "kind": "UNION", + "name": "PaymentProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "ProviderTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentReceipt", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "PaymentReceipt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PaymentTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerPaymentId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reference", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PaymentCollection", + "description": "PaymentCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated PaymentCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Payment", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PaymentMethod", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "details", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "PaymentMethodDetails", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDefault", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "ProviderTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerMethodId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PaymentMethodCollection", + "description": "PaymentMethodCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated PaymentMethodCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PaymentMethod", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PaymentMethodDetails", + "description": null, + "fields": [ + { + "name": "brand", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationMonth", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationYear", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "last4", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "paymentMethodId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodType", + "description": null, + "type": { + "kind": "ENUM", + "name": "PaymentMethodTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "PaymentMethodTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "provider", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "manual", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "UNION", + "name": "PaymentProvider", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": [ + { + "kind": "OBJECT", + "name": "AdyenProvider", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "CashfreeProvider", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "FlutterwaveProvider", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "GocardlessProvider", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "MoneyhashProvider", + "ofType": null + }, + { + "kind": "OBJECT", + "name": "StripeProvider", + "ofType": null + } + ] + }, + { + "kind": "OBJECT", + "name": "PaymentProviderCollection", + "description": "PaymentProviderCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated PaymentProviderCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "UNION", + "name": "PaymentProvider", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PaymentReceipt", + "description": "PaymentReceipt", + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fileUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "number", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Payment", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "xmlUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PaymentRequest", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payableType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentStatus", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PaymentRequestCollection", + "description": "PaymentRequestCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated PaymentRequestCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PaymentRequest", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PaymentRequestCreateInput", + "description": "Autogenerated input type of CreatePaymentRequest", + "fields": null, + "inputFields": [ + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lagoInvoiceIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "PaymentTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "provider", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "manual", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "PermissionEnum", + "description": "Permission", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "addons_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addons_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addons_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addons_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ai_conversations_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ai_conversations_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "audit_logs_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authentication_methods_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authentication_methods_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "analytics_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metrics_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metrics_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metrics_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billable_metrics_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entities_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entities_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entities_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entities_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupons_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupons_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupons_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupons_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupons_attach", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupons_detach", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_notes_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_notes_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_notes_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_notes_void", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_notes_export", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_notes_send", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customers_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customers_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customers_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customers_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "data_api_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "developers_manage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "developers_keys_manage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunning_campaigns_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunning_campaigns_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunning_campaigns_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunning_campaigns_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "features_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "features_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "features_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "features_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices_send", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices_void", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices_export", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_custom_sections_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_custom_sections_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_custom_sections_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice_custom_sections_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotes_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotes_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotes_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotes_approve", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotes_clone", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotes_void", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_invoices_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_invoices_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_taxes_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_taxes_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_emails_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_emails_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_integrations_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_integrations_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_integrations_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_integrations_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_members_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_members_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_members_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization_members_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payments_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payments_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_methods_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_methods_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_methods_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_methods_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipts_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipts_send", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plans_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plans_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plans_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plans_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricing_units_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricing_units_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricing_units_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles_delete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "security_logs_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptions_view", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptions_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptions_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallets_create", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallets_update", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallets_top_up", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallets_terminate", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Permissions", + "description": "Permissions Type", + "fields": [ + { + "name": "addonsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addonsDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addonsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addonsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "aiConversationsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "aiConversationsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "analyticsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "auditLogsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authenticationMethodsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "authenticationMethodsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricsDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntitiesCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntitiesDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntitiesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntitiesView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsAttach", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsDetach", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesExport", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesSend", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotesVoid", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "developersKeysManage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "developersManage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunningCampaignsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunningCampaignsDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunningCampaignsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunningCampaignsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "featuresCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "featuresDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "featuresUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "featuresView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSectionsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSectionsDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSectionsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSectionsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesExport", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesSend", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicesVoid", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationEmailsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationEmailsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationIntegrationsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationIntegrationsDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationIntegrationsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationIntegrationsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationInvoicesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationInvoicesView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationMembersCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationMembersDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationMembersUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationMembersView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationTaxesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationTaxesView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodsDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentReceiptsSend", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentReceiptsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plansCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plansDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plansUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plansView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotesApprove", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotesClone", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotesCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotesView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotesVoid", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rolesCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rolesDelete", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rolesUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rolesView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "securityLogsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionsView", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletsCreate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletsTerminate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletsTopUp", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletsUpdate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Plan", + "description": null, + "fields": [ + { + "name": "activeSubscriptionsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applicableUsageThresholds", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UsageThreshold", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billChargesMonthly", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billFixedChargesMonthly", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesCount", + "description": "Number of charges attached to a plan", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersCount", + "description": "Number of customers attached to a plan", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deletedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "draftInvoicesCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "entitlements", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PlanEntitlement", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedCharges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FixedCharge", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedChargesCount", + "description": "Number of fixed charges attached to a plan", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasActiveSubscriptions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasCharges", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasCustomers", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasDraftInvoices", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasFixedCharges", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasOverriddenPlans", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasSubscriptions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interval", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PlanInterval", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isOverridden", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ItemMetadata", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minimumCommitment", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Commitment", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "parent", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trialPeriod", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "usageThresholds", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UsageThreshold", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PlanCollection", + "description": "PlanCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated PlanCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PlanEntitlement", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privileges", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PlanEntitlementPrivilegeObject", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PlanEntitlementPrivilegeObject", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "config", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PrivilegeConfigObject", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "valueType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PrivilegeValueTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "PlanInterval", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "weekly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "monthly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "yearly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quarterly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "semiannual", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PlanOverridesInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeOverridesInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedCharges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FixedChargeOverridesInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minimumCommitment", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CommitmentInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trialPeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "PremiumIntegrationTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "beta_payment_authorization", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netsuite", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "okta", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "avalara", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "xero", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressive_billing", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lifetime_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hubspot", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "auto_dunning", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revenue_analytics", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "salesforce", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "api_permissions", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "revenue_share", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "remove_branding_watermark", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "manual_payments", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "from_email", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issue_receipts", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "preview", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "multi_entities_pro", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "multi_entities_enterprise", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "analytics_dashboards", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "forecasted_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projected_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "custom_roles", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "events_targeting_wallets", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "security_logs", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "granular_lifetime_usage", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order_forms", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PresentationBreakdownUsage", + "description": null, + "fields": [ + { + "name": "presentationBy", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PresentationGroupKey", + "description": null, + "fields": [ + { + "name": "options", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "PresentationGroupKeyOptions", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PresentationGroupKeyInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "options", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PresentationGroupKeyOptionsInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PresentationGroupKeyOptions", + "description": null, + "fields": [ + { + "name": "displayInInvoice", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PresentationGroupKeyOptionsInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "displayInInvoice", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PreviewAdjustedFeeInput", + "description": "Create Adjusted Fee Input", + "fields": null, + "inputFields": [ + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeFilterId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedChargeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitPreciseAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PricingUnit", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shortName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PricingUnitCollection", + "description": "PricingUnitCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated PricingUnitCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PricingUnit", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PricingUnitUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "conversionRate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "preciseAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "preciseUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnit", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PricingUnit", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shortName", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PrivilegeConfigInput", + "description": "Input for privilege configuration", + "fields": null, + "inputFields": [ + { + "name": "selectOptions", + "description": "Available options for select type privileges", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PrivilegeConfigObject", + "description": "Configuration object for privileges", + "fields": [ + { + "name": "selectOptions", + "description": "Available options for select type privileges", + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "PrivilegeObject", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "config", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PrivilegeConfigObject", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "valueType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PrivilegeValueTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "PrivilegeValueTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "integer", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "string", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "boolean", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "select", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProjectedChargeFilterUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitProjectedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectedUnits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProjectedChargeUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetric", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charge", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectedChargeFilterUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "groupedUsage", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectedGroupedChargeUsage", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "presentationBreakdowns", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PresentationBreakdownUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitProjectedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectedUnits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProjectedGroupedChargeUsage", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ProjectedChargeFilterUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "groupedBy", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "presentationBreakdowns", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PresentationBreakdownUsage", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnitProjectedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "projectedUnits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Properties", + "description": null, + "fields": [ + { + "name": "amount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customProperties", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeUnits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeUnitsPerEvents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeUnitsPerTotalAggregation", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedPercentageRanges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GraduatedPercentageRange", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedRanges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GraduatedRange", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packageSize", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perTransactionMaxAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perTransactionMinAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "presentationGroupKeys", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PresentationGroupKey", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingGroupKeys", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "volumeRanges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "VolumeRange", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingGroupKeys", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "presentationGroupKeys", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "PresentationGroupKeyInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedRanges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GraduatedRangeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "graduatedPercentageRanges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "GraduatedPercentageRangeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeUnits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "packageSize", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeUnitsPerEvents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "freeUnitsPerTotalAggregation", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perTransactionMaxAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perTransactionMinAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "volumeRanges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "VolumeRangeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customProperties", + "description": null, + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ProviderCustomer", + "description": null, + "fields": [ + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerPaymentMethods", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ProviderPaymentMethodsEnum", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ProviderCustomerInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "providerCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerPaymentMethods", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ProviderPaymentMethodsEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ProviderPaymentMethodsEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "card", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "sepa_debit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "us_bank_account", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "bacs_debit", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "link", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "boleto", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "crypto", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer_balance", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ProviderTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "stripe", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "gocardless", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cashfree", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "adyen", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flutterwave", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "moneyhash", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Query", + "description": null, + "fields": [ + { + "name": "activityLog", + "description": "Query a single activity log of an organization", + "args": [ + { + "name": "activityId", + "description": "Uniq ID of the activity log", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityLogs", + "description": "Query activity logs of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activitySources", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ActivitySourceEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ActivityTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKeyIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resourceIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resourceTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ResourceTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userEmails", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDatetime", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ActivityLogCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addOn", + "description": "Query a single add-on of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the add-on", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AddOn", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addOns", + "description": "Query add-ons of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AddOnCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "aiConversation", + "description": "Query a single ai conversation of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the ai conversation", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AiConversationWithMessages", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "aiConversations", + "description": "Query the latest AI conversations of current organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "AiConversationCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "alert", + "description": "Query a single subscription alert", + "args": [ + { + "name": "id", + "description": "Unique ID of the alert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "alerts", + "description": "Query alerts of a subscription", + "args": [ + { + "name": "subscriptionExternalId", + "description": "External id of a subscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AlertCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKey", + "description": "Query the API key", + "args": [ + { + "name": "id", + "description": "Uniq ID of the API key", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ApiKey", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKeys", + "description": "Query the API keys of current organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SanitizedApiKeyCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiLog", + "description": "Query a single api log of an organization", + "args": [ + { + "name": "requestId", + "description": "Uniq ID of the api log", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ApiLog", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiLogs", + "description": "Query api logs of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDatetime", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKeyIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "httpMethods", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "HttpMethodEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "httpStatuses", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "HttpStatus", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "requestPaths", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "ApiLogCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedCoupons", + "description": "Query applied coupons of an organization", + "args": [ + { + "name": "couponCode", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "ENUM", + "name": "AppliedCouponStatusEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AppliedCouponCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetric", + "description": "Query a single billable metric of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the billable metric", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetrics", + "description": "Query billable metrics of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "aggregationTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AggregationTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetricCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntities", + "description": "Query active billing_entities of an organization", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillingEntityCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntity", + "description": "Query a single billing_entity of an organization", + "args": [ + { + "name": "code", + "description": "Code of the billing_entity", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "BillingEntity", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityTaxes", + "description": "Query taxes of a billing entity", + "args": [ + { + "name": "billingEntityId", + "description": "Uniq ID of the billing entity", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TaxCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupon", + "description": "Query a single coupon of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the coupon", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Coupon", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupons", + "description": "Query coupons of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "ENUM", + "name": "CouponStatusEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CouponCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNote", + "description": "Query a single credit note", + "args": [ + { + "name": "id", + "description": "Uniq ID of the credit note", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNoteEstimate", + "description": "Fetch amounts for credit note creation", + "args": [ + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "items", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreditNoteItemInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNoteEstimate", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNotes", + "description": "Query credit notes", + "args": [ + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditStatus", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteCreditStatusEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerExternalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": "Uniq ID of the customer", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reason", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteReasonEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundStatus", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteRefundStatusEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selfBilled", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CreditNoteTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CreditNoteCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currentUser", + "description": "Retrieves currently connected user", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currentVersion", + "description": "Retrieve the version of the application", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CurrentVersion", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": "Query a single customer of an organization", + "args": [ + { + "name": "externalId", + "description": "External ID of the customer", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Lago ID of the customer", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerInvoices", + "description": "Query invoices of a customer", + "args": [ + { + "name": "customerId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceStatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalCustomerProjectedUsage", + "description": "Query the projected usage of the customer on the current billing period", + "args": [ + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerProjectedUsage", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalCustomerUsage", + "description": "Query the usage of the customer on the current billing period", + "args": [ + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerUsage", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalInvoiceCollections", + "description": "Query invoice collections of a customer portal user", + "args": [ + { + "name": "months", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expireCache", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FinalizedInvoiceCollectionCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalInvoices", + "description": "Query invoices of a customer", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceStatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalOrganization", + "description": "Query customer portal organization", + "args": [], + "type": { + "kind": "OBJECT", + "name": "CustomerPortalOrganization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalOverdueBalances", + "description": "Query overdue balances of a customer portal user", + "args": [ + { + "name": "months", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expireCache", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "OverdueBalanceCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalSubscription", + "description": "Query a single subscription from the customer portal", + "args": [ + { + "name": "id", + "description": "Uniq ID of the subscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalSubscriptions", + "description": "Query customer portal subscriptions", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "StatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubscriptionCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalUser", + "description": "Query a customer portal user", + "args": [], + "type": { + "kind": "OBJECT", + "name": "CustomerPortalCustomer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalWallet", + "description": "Query a single wallet from the customer portal", + "args": [ + { + "name": "id", + "description": "Uniq ID of the wallet", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CustomerPortalWallet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerPortalWallets", + "description": "Query wallets", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "ENUM", + "name": "WalletStatusEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerPortalWalletCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerProjectedUsage", + "description": "Query the projected usage of the customer on the current billing period", + "args": [ + { + "name": "customerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerProjectedUsage", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerUsage", + "description": "Query the usage of the customer on the current billing period", + "args": [ + { + "name": "customerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerUsage", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customers", + "description": "Query customers of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accountType", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CustomerAccountTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activeSubscriptionsCountFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activeSubscriptionsCountTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "countries", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currencies", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasCustomerType", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasTaxIdentificationNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CustomerMetadataFilter", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "states", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "withDeleted", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CustomerCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiMrrs", + "description": "Query monthly recurring revenues of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerCountry", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeGranularity", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiMrrCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiMrrsPlans", + "description": "Query monthly recurring revenues plans of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiMrrsPlans", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiPrepaidCredits", + "description": "Query prepaid credits of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerCountry", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeGranularity", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiPrepaidCreditCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiRevenueStreams", + "description": "Query revenue streams of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerCountry", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeGranularity", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiRevenueStreamCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiRevenueStreamsCustomers", + "description": "Query revenue streams customers of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "orderBy", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderByEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiRevenueStreamsCustomers", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiRevenueStreamsPlans", + "description": "Query revenue streams plans of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "orderBy", + "description": null, + "type": { + "kind": "ENUM", + "name": "OrderByEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiRevenueStreamsPlans", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiUsages", + "description": "Query usages of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerCountry", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isBillableMetricRecurring", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeGranularity", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsageCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiUsagesAggregatedAmounts", + "description": "Query usages of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerCountry", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isBillableMetricRecurring", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeGranularity", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsageAggregatedAmountCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiUsagesForecasted", + "description": "Query forecasted usages of an organization", + "args": [ + { + "name": "chargeFilterId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerCountry", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeGranularity", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsageForecastedCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dataApiUsagesInvoiced", + "description": "Query invoiced usages of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerCountry", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeGranularity", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSubscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filterValues", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "groupedBy", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DataApiUsageInvoicedCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunningCampaign", + "description": "Query a single dunning campaign of an organization", + "args": [ + { + "name": "id", + "description": "Unique ID of the dunning campaign", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DunningCampaign", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "dunningCampaigns", + "description": "Query dunning campaigns of an organization", + "args": [ + { + "name": "appliedToOrganization", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "DunningCampaignCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "event", + "description": "Query a single event of an organization", + "args": [ + { + "name": "transactionId", + "description": "Transaction ID of the event", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Event", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventTypes", + "description": "Query Event Types for Webhook Endpoints", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WebhookEventType", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "events", + "description": "Query events of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "EventCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature", + "description": "Query a single feature", + "args": [ + { + "name": "code", + "description": "Unique code of the feature", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Unique ID of the feature", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FeatureObject", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "features", + "description": "Query features of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FeatureObjectCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "googleAuthUrl", + "description": "Get Google auth url.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AuthUrl", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grossRevenues", + "description": "Query gross revenue of an organization", + "args": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "months", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expireCache", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "GrossRevenueCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integration", + "description": "Query a single integration", + "args": [ + { + "name": "id", + "description": "Uniq ID of the integration", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "UNION", + "name": "Integration", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCollectionMappings", + "description": "Query integration collection mappings", + "args": [ + { + "name": "integrationId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CollectionMappingCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationItems", + "description": "Query integration items of an integration", + "args": [ + { + "name": "integrationId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemType", + "description": null, + "type": { + "kind": "ENUM", + "name": "IntegrationItemTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "IntegrationItemCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationSubsidiaries", + "description": "Query integration subsidiaries", + "args": [ + { + "name": "integrationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SubsidiaryCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrations", + "description": "Query organization's integrations", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "IntegrationCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invite", + "description": "Query a single Invite", + "args": [ + { + "name": "token", + "description": "Uniq token of the Invite", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invite", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invites", + "description": "Query pending invites of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InviteCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice", + "description": "Query a single Invoice of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the invoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCollections", + "description": "Query invoice collections of an organization", + "args": [ + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FinalizedInvoiceCollectionCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCreditNotes", + "description": "Query invoice's credit note", + "args": [ + { + "name": "invoiceId", + "description": "Uniq ID of the invoice", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "CreditNoteCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": "Query a single invoice_custom_section of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the invoice_custom_section", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSections", + "description": "Query invoice_custom_sections", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "InvoiceCustomSectionCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoicedUsages", + "description": "Query invoiced usage of an organization", + "args": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoicedUsageCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoices", + "description": "Query invoices", + "args": [ + { + "name": "amountFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerExternalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerId", + "description": "Uniq ID of the customer", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceType", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateFrom", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "issuingDateTo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "partiallyPaid", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentDisputeLost", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentOverdue", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentStatus", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "positiveDueAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selfBilled", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settlements", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceSettlementTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "InvoiceStatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "memberships", + "description": "Query memberships of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MembershipCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mrrs", + "description": "Query MRR of an organization", + "args": [ + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "MrrCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": "Query the current organization", + "args": [], + "type": { + "kind": "OBJECT", + "name": "CurrentOrganization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "overdueBalances", + "description": "Query overdue balances of an organization", + "args": [ + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isCustomerTinEmpty", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "months", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expireCache", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "OverdueBalanceCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "passwordReset", + "description": "Query a password reset by token", + "args": [ + { + "name": "token", + "description": "Uniq token of the password reset", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ResetPassword", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment", + "description": "Query a single Payment", + "args": [ + { + "name": "id", + "description": "Uniq ID of the payment", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Payment", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethods", + "description": "Query payment methods of a customer", + "args": [ + { + "name": "externalCustomerId", + "description": "External ID of the customer", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "withDeleted", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PaymentMethodCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProvider", + "description": "Query a single payment provider", + "args": [ + { + "name": "code", + "description": "Code of the payment provider", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Uniq ID of the payment provider", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "UNION", + "name": "PaymentProvider", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviders", + "description": "Query organization's payment providers", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "type": { + "kind": "ENUM", + "name": "ProviderTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "PaymentProviderCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentRequests", + "description": "Query payment requests of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentStatus", + "description": null, + "type": { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PaymentRequestCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payments", + "description": "Query payments of an organization", + "args": [ + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PaymentCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan", + "description": "Query a single plan of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the plan", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plans", + "description": "Query plans of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "withDeleted", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PlanCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnit", + "description": "Query the pricing unit", + "args": [ + { + "name": "id", + "description": "Uniq ID of the pricing unit", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PricingUnit", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "pricingUnits", + "description": "Query the pricing units of current organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PricingUnitCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quote", + "description": "Query a quote", + "args": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Quote", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quotes", + "description": "Query quotes of an organization", + "args": [ + { + "name": "customers", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "numbers", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "orderTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "OrderTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "owners", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statuses", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "StatusEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuoteCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "role", + "description": "Query a single role", + "args": [ + { + "name": "id", + "description": "Uniq ID of the role", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Role", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles", + "description": "Query roles available for the organization", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Role", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "securityLog", + "description": "Query a single security log by ID", + "args": [ + { + "name": "logId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SecurityLog", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "securityLogs", + "description": "Query security logs of an organization", + "args": [ + { + "name": "apiKeyIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDatetime", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logEvents", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "LogEventEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "LogTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDatetime", + "description": "Upper date boundary (required for consistent pagination)", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "SecurityLogCollection", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription", + "description": "Query a single subscription of an organization", + "args": [ + { + "name": "externalId", + "description": "External ID of the subscription", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Lago ID of the subscription", + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionAlert", + "description": "Query a single subscription alert", + "args": [ + { + "name": "id", + "description": "Unique ID of the alert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionAlerts", + "description": "Query alerts of a subscription", + "args": [ + { + "name": "subscriptionExternalId", + "description": "External id of a subscription", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AlertCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionEntitlement", + "description": "Retrieve an entitlement of a subscriptions", + "args": [ + { + "name": "featureCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubscriptionEntitlement", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionEntitlements", + "description": "Query entitlements of a subscriptions", + "args": [ + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubscriptionEntitlementCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptions", + "description": "Query subscriptions of an organization", + "args": [ + { + "name": "currency", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalCustomerId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "overriden", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "StatusTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubscriptionCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supersetDashboards", + "description": "Query all Superset dashboards with embedded configuration and guest tokens", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SupersetDashboard", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tax", + "description": "Query a single tax of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the tax", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxes", + "description": "Query taxes of an organization", + "args": [ + { + "name": "appliedToOrganization", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "autoGenerated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "order", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TaxCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet", + "description": "Query a single wallet of an organization", + "args": [ + { + "name": "id", + "description": "Uniq ID of the wallet", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Wallet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletAlert", + "description": "Query a single wallet alert", + "args": [ + { + "name": "id", + "description": "Unique ID of the alert", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Alert", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletAlerts", + "description": "Query alerts of a wallet", + "args": [ + { + "name": "walletId", + "description": "Id of a wallet", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "AlertCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransaction", + "description": "Query a single wallet transaction", + "args": [ + { + "name": "id", + "description": "Unique ID of the wallet transaction", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "WalletTransaction", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransactionConsumptions", + "description": "Query wallet transaction consumptions for an inbound transaction", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransactionId", + "description": "Uniq ID of the inbound wallet transaction", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransactionConsumptionCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransactionFundings", + "description": "Query wallet transaction fundings for an outbound transaction", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransactionId", + "description": "Uniq ID of the outbound wallet transaction", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransactionFundingCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransactions", + "description": "Query wallet transactions", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "ENUM", + "name": "WalletTransactionStatusEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionType", + "description": null, + "type": { + "kind": "ENUM", + "name": "WalletTransactionTransactionTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletId", + "description": "Uniq ID of the wallet", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransactionCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallets", + "description": "Query wallets", + "args": [ + { + "name": "customerId", + "description": "Uniq ID of the customer", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ids", + "description": "List of wallet IDs to fetch", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "ENUM", + "name": "WalletStatusEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhook", + "description": "Query a webhook", + "args": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "Webhook", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookEndpoint", + "description": "Query a single webhook endpoint", + "args": [ + { + "name": "id", + "description": "Uniq ID of the webhook endpoint", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "OBJECT", + "name": "WebhookEndpoint", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookEndpoints", + "description": "Query webhook endpoints of an organization", + "args": [ + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WebhookEndpointCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhooks", + "description": "Query Webhooks", + "args": [ + { + "name": "eventTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "httpStatuses", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limit", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "page", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "searchTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "type": { + "kind": "ENUM", + "name": "WebhookStatusEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "statuses", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WebhookStatusEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toDate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookEndpointId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WebhookCollection", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Quote", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currentVersion", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuoteVersion", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "number", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "orderType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "OrderTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "owners", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "versions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "QuoteVersion", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QuoteCollection", + "description": "QuoteCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated QuoteCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Quote", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "QuoteVersion", + "description": null, + "fields": [ + { + "name": "approvedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingItems", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "content", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quote", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Quote", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shareToken", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "StatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "version", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidReason", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "VoidReasonEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "RecurringTransactionIntervalEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "weekly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "monthly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "quarterly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "yearly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "semiannual", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "RecurringTransactionMethodEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "fixed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "target", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RecurringTransactionRule", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grantedCredits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ignorePaidTopUpLimits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interval", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "RecurringTransactionIntervalEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lagoId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "method", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "RecurringTransactionMethodEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidCredits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "PaymentMethod", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "PaymentMethodTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selectedInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skipInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetOngoingBalance", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholdCredits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionMetadata", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TransactionMetadata", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trigger", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "RecurringTransactionTriggerEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "RecurringTransactionTriggerEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "interval", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "threshold", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RefreshInvoiceInput", + "description": "Autogenerated input type of RefreshInvoice", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RegenerateInvoiceInput", + "description": "Autogenerated input type of RegenerateInvoice", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fees", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "VoidedInvoiceFeeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedInvoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RegisterUser", + "description": null, + "fields": [ + { + "name": "membership", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Membership", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "token", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RegisterUserInput", + "description": "Autogenerated input type of RegisterUser", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationName", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "password", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "RegroupPaidFeesEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "invoice", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RemoveSubscriptionEntitlementInput", + "description": "Autogenerated input type of RemoveSubscriptionEntitlement", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "featureCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RemoveSubscriptionEntitlementPayload", + "description": "Autogenerated return type of RemoveSubscriptionEntitlement.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "featureCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RemoveTaxesInput", + "description": "Autogenerated input type of RemoveTaxes", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "RemoveTaxesPayload", + "description": "Autogenerated return type of RemoveTaxes.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "removedTaxes", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ResendCreditNoteEmailInput", + "description": "Resend email input arguments", + "fields": null, + "inputFields": [ + { + "name": "bcc", + "description": "BCC recipients", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cc", + "description": "CC recipients", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Document ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "to", + "description": "Custom recipients (defaults to customer email)", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ResendInvoiceEmailInput", + "description": "Resend email input arguments", + "fields": null, + "inputFields": [ + { + "name": "bcc", + "description": "BCC recipients", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cc", + "description": "CC recipients", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Document ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "to", + "description": "Custom recipients (defaults to customer email)", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ResendPaymentReceiptEmailInput", + "description": "Resend email input arguments", + "fields": null, + "inputFields": [ + { + "name": "bcc", + "description": "BCC recipients", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cc", + "description": "CC recipients", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "Document ID", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "to", + "description": "Custom recipients (defaults to customer email)", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "ResetPassword", + "description": "ResetPassword type", + "fields": [ + { + "name": "expireAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "token", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "user", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "User", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ResetPasswordInput", + "description": "Autogenerated input type of ResetPassword", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "newPassword", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "token", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "ResourceTypeEnum", + "description": "Activity Logs resource type enums", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "billable_metric", + "description": "BillableMetric", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan", + "description": "Plan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": "Customer", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice", + "description": "Invoice", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "credit_note", + "description": "CreditNote", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billing_entity", + "description": "BillingEntity", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscription", + "description": "Subscription", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet", + "description": "Wallet", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "coupon", + "description": "Coupon", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_receipt", + "description": "PaymentReceipt", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payment_request", + "description": "PaymentRequest", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feature", + "description": "Entitlement::Feature", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RetryAllInvoicePaymentsInput", + "description": "Autogenerated input type of RetryAllInvoicePayments", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RetryAllInvoicesInput", + "description": "Autogenerated input type of RetryAllInvoices", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RetryInvoiceInput", + "description": "Autogenerated input type of RetryInvoice", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RetryInvoicePaymentInput", + "description": "Retry payment input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RetryTaxProviderVoidingInput", + "description": "Autogenerated input type of RetryTaxProviderVoiding", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RetryTaxReportingInput", + "description": "Autogenerated input type of RetryTaxReporting", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RetryWebhookInput", + "description": "Autogenerated input type of RetryWebhook", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RevokeInviteInput", + "description": "Autogenerated input type of RevokeInvite", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RevokeMembershipInput", + "description": "Autogenerated input type of RevokeMembership", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Role", + "description": null, + "fields": [ + { + "name": "admin", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "memberships", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Membership", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PermissionEnum", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "RotateApiKeyInput", + "description": "Autogenerated input type of RotateApiKey", + "fields": null, + "inputFields": [ + { + "name": "expiresAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "RoundingFunctionEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "round", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ceil", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "floor", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SalesforceCustomer", + "description": null, + "fields": [ + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SalesforceIntegration", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "instanceId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SanitizedApiKey", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiresAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastUsedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SanitizedApiKeyCollection", + "description": "SanitizedApiKeyCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated SanitizedApiKeyCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SanitizedApiKey", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SecurityLog", + "description": "Security log entry", + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deviceInfo", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logEvent", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "LogEventEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "LogTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "loggedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "resources", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "userEmail", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SecurityLogCollection", + "description": "SecurityLogCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated SecurityLogCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SecurityLog", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SetAsDefaultInput", + "description": "Autogenerated input type of SetAsDefault", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "StatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "draft", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "approved", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voided", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "StatusTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "active", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "canceled", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "incomplete", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "String", + "description": "Represents textual data as UTF-8 character sequences. This type is most often used by GraphQL to represent free-form human-readable text.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "StripeProvider", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "secretKey", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ObfuscatedString", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Subscription", + "description": null, + "fields": [ + { + "name": "activatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activationRules", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubscriptionActivationRule", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingTime", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "BillingTimeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cancelationReason", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "CancelationReasonEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "canceledAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Charge", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currentBillingPeriodEndingAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currentBillingPeriodStartedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "downgradePlanDate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endingAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fees", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Fee", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedCharges", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "FixedCharge", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lifetimeUsage", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "SubscriptionLifetimeUsage", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextPlan", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextSubscription", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextSubscriptionAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextSubscriptionType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "NextSubscriptionTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onTerminationCreditNote", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "OnTerminationCreditNoteEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onTerminationInvoice", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "OnTerminationInvoiceEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "PaymentMethod", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "PaymentMethodTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "periodEndDate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601Date", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plan", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "previousPlan", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Plan", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "previousSubscription", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressiveBillingDisabled", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selectedInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skipInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "StatusTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "usageThresholds", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "UsageThreshold", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubscriptionActivationRule", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiresAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ActivationRuleStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeoutHours", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ActivationRuleTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SubscriptionActivationRuleInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timeoutHours", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "ActivationRuleTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubscriptionCollection", + "description": "SubscriptionCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated SubscriptionCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Subscription", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubscriptionEntitlement", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privileges", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubscriptionEntitlementPrivilegeObject", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubscriptionEntitlementCollection", + "description": "SubscriptionEntitlementCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated SubscriptionEntitlementCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "SubscriptionEntitlement", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubscriptionEntitlementPrivilegeObject", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "config", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "PrivilegeConfigObject", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "valueType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PrivilegeValueTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubscriptionLifetimeUsage", + "description": null, + "fields": [ + { + "name": "lastThresholdAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextThresholdAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "nextThresholdRatio", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalUsageAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalUsageFromDatetime", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalUsageToDatetime", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Subsidiary", + "description": null, + "fields": [ + { + "name": "externalId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SubsidiaryCollection", + "description": "SubsidiaryCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated SubsidiaryCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Subsidiary", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SupersetDashboard", + "description": null, + "fields": [ + { + "name": "dashboardTitle", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "embeddedId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "guestToken", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supersetUrl", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SyncHubspotIntegrationInvoiceInput", + "description": "Autogenerated input type of SyncHubspotInvoice", + "fields": null, + "inputFields": [ + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SyncHubspotInvoicePayload", + "description": "Autogenerated return type of SyncHubspotInvoice.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SyncIntegrationCreditNoteInput", + "description": "Autogenerated input type of SyncIntegrationCreditNote", + "fields": null, + "inputFields": [ + { + "name": "creditNoteId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SyncIntegrationCreditNotePayload", + "description": "Autogenerated return type of SyncIntegrationCreditNote.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditNoteId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SyncIntegrationInvoiceInput", + "description": "Autogenerated input type of SyncIntegrationInvoice", + "fields": null, + "inputFields": [ + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SyncIntegrationInvoicePayload", + "description": "Autogenerated return type of SyncIntegrationInvoice.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "SyncSalesforceInvoiceInput", + "description": "Autogenerated input type of SyncSalesforceInvoice", + "fields": null, + "inputFields": [ + { + "name": "invoiceId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "SyncSalesforceInvoicePayload", + "description": "Autogenerated return type of SyncSalesforceInvoice.", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Tax", + "description": null, + "fields": [ + { + "name": "addOnsCount", + "description": "Number of add ons using this tax", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedToOrganization", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "autoGenerated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargesCount", + "description": "Number of charges using this tax", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customersCount", + "description": "Number of customers using this tax", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "plansCount", + "description": "Number of plans using this tax", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TaxBreakdownObject", + "description": null, + "fields": [ + { + "name": "enumedTaxCode", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "InvoiceAppliedTaxOnWholeInvoiceCodeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TaxCollection", + "description": "TaxCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated TaxCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Tax", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TaxCreateInput", + "description": "Autogenerated input type of CreateTax", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TaxFeeObject", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "itemId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxBreakdown", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TaxBreakdownObject", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TaxFeeObjectCollection", + "description": "TaxFeeObjectCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated TaxFeeObjectCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "TaxFeeObject", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TaxUpdateInput", + "description": "Autogenerated input type of UpdateTax", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedToOrganization", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TerminateAppliedCouponInput", + "description": "Autogenerated input type of TerminateAppliedCoupon", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TerminateCouponInput", + "description": "Autogenerated input type of TerminateCoupon", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TerminateCustomerWalletInput", + "description": "Autogenerated input type of TerminateCustomerWallet", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "TerminateSubscriptionInput", + "description": "Input for terminating a subscription", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onTerminationCreditNote", + "description": null, + "type": { + "kind": "ENUM", + "name": "OnTerminationCreditNoteEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onTerminationInvoice", + "description": null, + "type": { + "kind": "ENUM", + "name": "OnTerminationInvoiceEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "ThresholdInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "TimeGranularityEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "daily", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "weekly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "monthly", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "TimezoneEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "TZ_AFRICA_ALGIERS", + "description": "Africa/Algiers", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AFRICA_CAIRO", + "description": "Africa/Cairo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AFRICA_CASABLANCA", + "description": "Africa/Casablanca", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AFRICA_HARARE", + "description": "Africa/Harare", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AFRICA_JOHANNESBURG", + "description": "Africa/Johannesburg", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AFRICA_MONROVIA", + "description": "Africa/Monrovia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AFRICA_NAIROBI", + "description": "Africa/Nairobi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_ARGENTINA_BUENOS_AIRES", + "description": "America/Argentina/Buenos_Aires", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_BOGOTA", + "description": "America/Bogota", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_CARACAS", + "description": "America/Caracas", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_CHICAGO", + "description": "America/Chicago", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_CHIHUAHUA", + "description": "America/Chihuahua", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_DENVER", + "description": "America/Denver", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_GUATEMALA", + "description": "America/Guatemala", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_GUYANA", + "description": "America/Guyana", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_HALIFAX", + "description": "America/Halifax", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_INDIANA_INDIANAPOLIS", + "description": "America/Indiana/Indianapolis", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_JUNEAU", + "description": "America/Juneau", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_LA_PAZ", + "description": "America/La_Paz", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_LIMA", + "description": "America/Lima", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_LOS_ANGELES", + "description": "America/Los_Angeles", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_MAZATLAN", + "description": "America/Mazatlan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_MEXICO_CITY", + "description": "America/Mexico_City", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_MONTERREY", + "description": "America/Monterrey", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_MONTEVIDEO", + "description": "America/Montevideo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_NEW_YORK", + "description": "America/New_York", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_NUUK", + "description": "America/Nuuk", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_PHOENIX", + "description": "America/Phoenix", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_PUERTO_RICO", + "description": "America/Puerto_Rico", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_REGINA", + "description": "America/Regina", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_SANTIAGO", + "description": "America/Santiago", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_SAO_PAULO", + "description": "America/Sao_Paulo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_ST_JOHNS", + "description": "America/St_Johns", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AMERICA_TIJUANA", + "description": "America/Tijuana", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_ALMATY", + "description": "Asia/Almaty", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_BAGHDAD", + "description": "Asia/Baghdad", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_BAKU", + "description": "Asia/Baku", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_BANGKOK", + "description": "Asia/Bangkok", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_CHONGQING", + "description": "Asia/Chongqing", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_COLOMBO", + "description": "Asia/Colombo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_DHAKA", + "description": "Asia/Dhaka", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_HONG_KONG", + "description": "Asia/Hong_Kong", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_IRKUTSK", + "description": "Asia/Irkutsk", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_JAKARTA", + "description": "Asia/Jakarta", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_JERUSALEM", + "description": "Asia/Jerusalem", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KABUL", + "description": "Asia/Kabul", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KAMCHATKA", + "description": "Asia/Kamchatka", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KARACHI", + "description": "Asia/Karachi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KATHMANDU", + "description": "Asia/Kathmandu", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KOLKATA", + "description": "Asia/Kolkata", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KRASNOYARSK", + "description": "Asia/Krasnoyarsk", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KUALA_LUMPUR", + "description": "Asia/Kuala_Lumpur", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_KUWAIT", + "description": "Asia/Kuwait", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_MAGADAN", + "description": "Asia/Magadan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_MUSCAT", + "description": "Asia/Muscat", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_NOVOSIBIRSK", + "description": "Asia/Novosibirsk", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_RIYADH", + "description": "Asia/Riyadh", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_SEOUL", + "description": "Asia/Seoul", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_SHANGHAI", + "description": "Asia/Shanghai", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_SINGAPORE", + "description": "Asia/Singapore", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_SREDNEKOLYMSK", + "description": "Asia/Srednekolymsk", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_TAIPEI", + "description": "Asia/Taipei", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_TASHKENT", + "description": "Asia/Tashkent", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_TBILISI", + "description": "Asia/Tbilisi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_TEHRAN", + "description": "Asia/Tehran", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_TOKYO", + "description": "Asia/Tokyo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_ULAANBAATAR", + "description": "Asia/Ulaanbaatar", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_URUMQI", + "description": "Asia/Urumqi", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_VLADIVOSTOK", + "description": "Asia/Vladivostok", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_YAKUTSK", + "description": "Asia/Yakutsk", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_YANGON", + "description": "Asia/Yangon", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_YEKATERINBURG", + "description": "Asia/Yekaterinburg", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ASIA_YEREVAN", + "description": "Asia/Yerevan", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ATLANTIC_AZORES", + "description": "Atlantic/Azores", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ATLANTIC_CAPE_VERDE", + "description": "Atlantic/Cape_Verde", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ATLANTIC_SOUTH_GEORGIA", + "description": "Atlantic/South_Georgia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_ADELAIDE", + "description": "Australia/Adelaide", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_BRISBANE", + "description": "Australia/Brisbane", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_CANBERRA", + "description": "Australia/Canberra", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_DARWIN", + "description": "Australia/Darwin", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_HOBART", + "description": "Australia/Hobart", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_MELBOURNE", + "description": "Australia/Melbourne", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_PERTH", + "description": "Australia/Perth", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_AUSTRALIA_SYDNEY", + "description": "Australia/Sydney", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_ETC_GMT_12", + "description": "Etc/GMT+12", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_UTC", + "description": "UTC", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_AMSTERDAM", + "description": "Europe/Amsterdam", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_ATHENS", + "description": "Europe/Athens", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_BELGRADE", + "description": "Europe/Belgrade", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_BERLIN", + "description": "Europe/Berlin", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_BRATISLAVA", + "description": "Europe/Bratislava", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_BRUSSELS", + "description": "Europe/Brussels", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_BUCHAREST", + "description": "Europe/Bucharest", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_BUDAPEST", + "description": "Europe/Budapest", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_COPENHAGEN", + "description": "Europe/Copenhagen", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_DUBLIN", + "description": "Europe/Dublin", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_HELSINKI", + "description": "Europe/Helsinki", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_ISTANBUL", + "description": "Europe/Istanbul", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_KALININGRAD", + "description": "Europe/Kaliningrad", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_KYIV", + "description": "Europe/Kyiv", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_LISBON", + "description": "Europe/Lisbon", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_LJUBLJANA", + "description": "Europe/Ljubljana", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_LONDON", + "description": "Europe/London", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_MADRID", + "description": "Europe/Madrid", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_MINSK", + "description": "Europe/Minsk", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_MOSCOW", + "description": "Europe/Moscow", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_PARIS", + "description": "Europe/Paris", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_PRAGUE", + "description": "Europe/Prague", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_RIGA", + "description": "Europe/Riga", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_ROME", + "description": "Europe/Rome", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_SAMARA", + "description": "Europe/Samara", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_SARAJEVO", + "description": "Europe/Sarajevo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_SKOPJE", + "description": "Europe/Skopje", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_SOFIA", + "description": "Europe/Sofia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_STOCKHOLM", + "description": "Europe/Stockholm", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_TALLINN", + "description": "Europe/Tallinn", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_VIENNA", + "description": "Europe/Vienna", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_VILNIUS", + "description": "Europe/Vilnius", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_VOLGOGRAD", + "description": "Europe/Volgograd", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_WARSAW", + "description": "Europe/Warsaw", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_ZAGREB", + "description": "Europe/Zagreb", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_EUROPE_ZURICH", + "description": "Europe/Zurich", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_APIA", + "description": "Pacific/Apia", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_AUCKLAND", + "description": "Pacific/Auckland", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_CHATHAM", + "description": "Pacific/Chatham", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_FAKAOFO", + "description": "Pacific/Fakaofo", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_FIJI", + "description": "Pacific/Fiji", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_GUADALCANAL", + "description": "Pacific/Guadalcanal", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_GUAM", + "description": "Pacific/Guam", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_HONOLULU", + "description": "Pacific/Honolulu", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_MAJURO", + "description": "Pacific/Majuro", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_MIDWAY", + "description": "Pacific/Midway", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_NOUMEA", + "description": "Pacific/Noumea", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_PAGO_PAGO", + "description": "Pacific/Pago_Pago", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_PORT_MORESBY", + "description": "Pacific/Port_Moresby", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "TZ_PACIFIC_TONGATAPU", + "description": "Pacific/Tongatapu", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "TransactionMetadata", + "description": null, + "fields": [ + { + "name": "key", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateAddOnInput", + "description": "Autogenerated input type of UpdateAddOn", + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateAdyenPaymentProviderInput", + "description": "Update input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateAnrokIntegrationInput", + "description": "Autogenerated input type of UpdateAnrokIntegration", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "apiKey", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateApiKeyInput", + "description": "Autogenerated input type of UpdateApiKey", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateAvalaraIntegrationInput", + "description": "Autogenerated input type of UpdateAvalaraIntegration", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "companyCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accountId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "licenseKey", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateBillableMetricInput", + "description": "Update Billable metric input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "aggregationType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AggregationTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expression", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fieldName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roundingFunction", + "description": null, + "type": { + "kind": "ENUM", + "name": "RoundingFunctionEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roundingPrecision", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "weightedInterval", + "description": null, + "type": { + "kind": "ENUM", + "name": "WeightedIntervalEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "BillableMetricFiltersInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateBillingEntityInput", + "description": "Update Billing Entity input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultCurrency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "einvoicing", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "euTaxManagement", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumberPrefix", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumbering", + "description": null, + "type": { + "kind": "ENUM", + "name": "BillingEntityDocumentNumberingEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "BillingEntityBillingConfigurationInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "emailSettings", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "BillingEntityEmailSettingsEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSectionIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCashfreePaymentProviderInput", + "description": "Update input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCouponInput", + "description": "Autogenerated input type of UpdateCoupon", + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "couponType", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponTypeEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponFrequency", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "frequencyDuration", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "percentageRate", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reusable", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliesTo", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "LimitationInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expiration", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CouponExpiration", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCreditNoteInput", + "description": "Update Credit Note input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundStatus", + "description": null, + "type": { + "kind": "ENUM", + "name": "CreditNoteRefundStatusEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerInput", + "description": "Update Customer input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accountType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerAccountTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalSalesforceId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "firstname", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastname", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logoUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "phone", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "url", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingEntityCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shippingAddress", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CustomerAddressInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CustomerMetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProvider", + "description": null, + "type": { + "kind": "ENUM", + "name": "ProviderTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentProviderCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "providerCustomer", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "ProviderCustomerInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCustomers", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "IntegrationCustomerInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CustomerBillingConfigurationInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": null, + "type": { + "kind": "ENUM", + "name": "FinalizeZeroAmountInvoiceEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedDunningCampaignId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "excludeFromDunningCampaign", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "configurableInvoiceCustomSectionIds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skipInvoiceCustomSections", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerInvoiceGracePeriodInput", + "description": "Autogenerated input type of UpdateCustomerInvoiceGracePeriod", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceGracePeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerPortalCustomerInput", + "description": "Customer Portal Customer Update input arguments", + "fields": null, + "inputFields": [ + { + "name": "customerType", + "description": null, + "type": { + "kind": "ENUM", + "name": "CustomerTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentLocale", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "firstname", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastname", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shippingAddress", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CustomerAddressInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerWalletAlertInput", + "description": "Autogenerated input type of UpdateCustomerWalletAlert", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ThresholdInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateCustomerWalletInput", + "description": "Update Wallet Input", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "priority", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMaxAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMinAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurringTransactionRules", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdateRecurringTransactionRuleInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliesTo", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AppliesToInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateDunningCampaignInput", + "description": "Autogenerated input type of UpdateDunningCampaign", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedToOrganization", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "bccEmails", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "daysBetweenAttempts", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "maxAttempts", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DunningCampaignThresholdInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateFeatureInput", + "description": "Input for updating a feature", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": "The ID of the feature to update", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": "The description of the feature", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": "The name of the feature", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "privileges", + "description": "The privileges configuration", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UpdatePrivilegeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateFlutterwavePaymentProviderInput", + "description": "Update input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateGocardlessPaymentProviderInput", + "description": "Update input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateHubspotIntegrationInput", + "description": "Autogenerated input type of UpdateHubspotIntegration", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultTargetedObject", + "description": null, + "type": { + "kind": "ENUM", + "name": "HubspotTargetedObjectsEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncSubscriptions", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateIntegrationCollectionMappingInput", + "description": "Autogenerated input type of UpdateIntegrationCollectionMapping", + "fields": null, + "inputFields": [ + { + "name": "externalAccountCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxNexus", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxType", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currencies", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CurrencyMappingItemInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappingType", + "description": null, + "type": { + "kind": "ENUM", + "name": "MappingTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateIntegrationMappingInput", + "description": "Autogenerated input type of UpdateIntegrationMapping", + "fields": null, + "inputFields": [ + { + "name": "externalAccountCode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "externalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappableId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mappableType", + "description": null, + "type": { + "kind": "ENUM", + "name": "MappableTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateInviteInput", + "description": "Autogenerated input type of UpdateInvite", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateInvoiceCustomSectionInput", + "description": "Autogenerated input type of UpdateInvoiceCustomSection", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "details", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "displayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateInvoiceInput", + "description": "Update Invoice input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "InvoiceMetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentStatus", + "description": null, + "type": { + "kind": "ENUM", + "name": "InvoicePaymentStatusTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateMembershipInput", + "description": "Autogenerated input type of UpdateMembership", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "roles", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateMoneyhashPaymentProviderInput", + "description": "Update input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateNetsuiteIntegrationInput", + "description": "Autogenerated input type of UpdateNetsuiteIntegration", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "accountId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "scriptEndpointUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tokenId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "tokenSecret", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncCreditNotes", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncPayments", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateOktaIntegrationInput", + "description": "Autogenerated input type of UpdateOktaIntegration", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientSecret", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "domain", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "host", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizationName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateOrganizationInput", + "description": "Update Organization input arguments", + "fields": null, + "inputFields": [ + { + "name": "authenticationMethods", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "AuthenticationMethodsEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "defaultCurrency", + "description": null, + "type": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "legalNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "logo", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "slug", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxIdentificationNumber", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine1", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "addressLine2", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "city", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "country", + "description": null, + "type": { + "kind": "ENUM", + "name": "CountryCode", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "netPaymentTerm", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "state", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "zipcode", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "timezone", + "description": null, + "type": { + "kind": "ENUM", + "name": "TimezoneEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "euTaxManagement", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumberPrefix", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "documentNumbering", + "description": null, + "type": { + "kind": "ENUM", + "name": "DocumentNumberingEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billingConfiguration", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "OrganizationBillingConfigurationInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "emailSettings", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EmailSettingsEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "finalizeZeroAmountInvoice", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdatePlanInput", + "description": "Autogenerated input type of UpdatePlan", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "amountCurrency", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billChargesMonthly", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billFixedChargesMonthly", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascadeUpdates", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interval", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PlanInterval", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "MetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payInAdvance", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trialPeriod", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "charges", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeInput", + "ofType": null + } + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedCharges", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "FixedChargeInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minimumCommitment", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "CommitmentInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "usageThresholds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UsageThresholdInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "entitlements", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "EntitlementInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdatePricingUnitInput", + "description": "Autogenerated input type of UpdatePricingUnit", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "shortName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdatePrivilegeInput", + "description": "Input for updating a privilege", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "config", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PrivilegeConfigInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "valueType", + "description": null, + "type": { + "kind": "ENUM", + "name": "PrivilegeValueTypeEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateQuoteInput", + "description": "Autogenerated input type of UpdateQuote", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "owners", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateQuoteVersionInput", + "description": "Autogenerated input type of UpdateQuoteVersion", + "fields": null, + "inputFields": [ + { + "name": "billingItems", + "description": null, + "type": { + "kind": "SCALAR", + "name": "JSON", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "content", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateRecurringTransactionRuleInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "expirationAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "grantedCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ignorePaidTopUpLimits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interval", + "description": null, + "type": { + "kind": "ENUM", + "name": "RecurringTransactionIntervalEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lagoId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "method", + "description": null, + "type": { + "kind": "ENUM", + "name": "RecurringTransactionMethodEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "startedAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "targetOngoingBalance", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholdCredits", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionMetadata", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "CreateTransactionMetadataInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "trigger", + "description": null, + "type": { + "kind": "ENUM", + "name": "RecurringTransactionTriggerEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateRoleInput", + "description": "Update Role input arguments", + "fields": null, + "inputFields": [ + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "permissions", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "PermissionEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateSalesforceIntegrationInput", + "description": "Autogenerated input type of UpdateSalesforceIntegration", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "instanceId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateStripePaymentProviderInput", + "description": "Update input arguments", + "fields": null, + "inputFields": [ + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flowId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "successRedirectUrl", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "supports3ds", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionAlertInput", + "description": "Autogenerated input type of UpdateSubscriptionAlert", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "billableMetricId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ThresholdInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionChargeFilterInput", + "description": "Update subscription charge filter input arguments", + "fields": null, + "inputFields": [ + { + "name": "chargeCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "values", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ChargeFilterValues", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionChargeInput", + "description": "Autogenerated input type of UpdateSubscriptionCharge", + "fields": null, + "inputFields": [ + { + "name": "chargeCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliedPricingUnit", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "AppliedPricingUnitOverrideInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "filters", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "ChargeFilterInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "minAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionFixedChargeInput", + "description": "Autogenerated input type of UpdateSubscriptionFixedCharge", + "fields": null, + "inputFields": [ + { + "name": "fixedChargeCode", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "applyUnitsImmediately", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "properties", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "FixedChargePropertiesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "taxCodes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateSubscriptionInput", + "description": "Update Subscription input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "activationRules", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "SubscriptionActivationRuleInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endingAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceCustomSection", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "InvoiceCustomSectionsReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PaymentMethodReferenceInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "planOverrides", + "description": null, + "type": { + "kind": "INPUT_OBJECT", + "name": "PlanOverridesInput", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "progressiveBillingDisabled", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionAt", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "usageThresholds", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "UsageThresholdInput", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UpdateXeroIntegrationInput", + "description": "Autogenerated input type of UpdateXeroIntegration", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncCreditNotes", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncPayments", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "UsageThreshold", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholdDisplayName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "UsageThresholdInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "amountCents", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurring", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "thresholdDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "User", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "email", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "memberships", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Membership", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organizations", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "premium", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "VoidCreditNoteInput", + "description": "Autogenerated input type of VoidCreditNote", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "VoidInvoiceInput", + "description": "Void Invoice input arguments", + "fields": null, + "inputFields": [ + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "generateCreditNote", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "refundAmount", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "VoidQuoteVersionInput", + "description": "Autogenerated input type of VoidQuoteVersion", + "fields": null, + "inputFields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "reason", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "VoidReasonEnum", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "VoidReasonEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "manual", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "superseded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascade_of_expired", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "cascade_of_voided", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "VoidedInvoiceFeeInput", + "description": "Fee input for creating or updating invoice from voided invoice", + "fields": null, + "inputFields": [ + { + "name": "addOnId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeFilterId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "chargeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fixedChargeId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceDisplayName", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionId", + "description": null, + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "unitAmountCents", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "units", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "VolumeRange", + "description": null, + "fields": [ + { + "name": "flatAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fromValue", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "VolumeRangeInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "fromValue", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "toValue", + "description": null, + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "flatAmount", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "perUnitAmount", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Wallet", + "description": "Wallet", + "fields": [ + { + "name": "activityLogs", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ActivityLog", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "appliesTo", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "WalletAppliesTo", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "balanceCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "consumedAmountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "consumedCredits", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditsBalance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditsOngoingBalance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditsOngoingUsageBalance", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "currency", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "CurrencyEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customer", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Customer", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "expirationAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastBalanceSyncAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastConsumedCreditAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastOngoingBalanceSyncAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "ItemMetadata", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ongoingBalanceCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ongoingUsageBalanceCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMaxAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMaxCredits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMinAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paidTopUpMinCredits", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethod", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "PaymentMethod", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "paymentMethodType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "PaymentMethodTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "priority", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "rateAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Float", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "recurringTransactionRules", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "RecurringTransactionRule", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selectedInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skipInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminatedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "traceable", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletAppliesTo", + "description": null, + "fields": [ + { + "name": "billableMetrics", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "BillableMetric", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "feeTypes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "FeeTypesEnum", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletCollection", + "description": "WalletCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated WalletCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Wallet", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletCollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletCollectionMetadata", + "description": "Type for CollectionMetadataType", + "fields": [ + { + "name": "currentPage", + "description": "Current Page of loaded data", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "customerActiveWalletsCount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "limitValue", + "description": "The number of items per page", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalCount", + "description": "The total number of items to be paginated", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "totalPages", + "description": "The total number of pages in the pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WalletStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "active", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "terminated", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletTransaction", + "description": null, + "fields": [ + { + "name": "amount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoice", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiceRequiresSuccessfulPayment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransactionMetadataObject", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "priority", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "remainingAmountCents", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "remainingCreditAmount", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "selectedInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "InvoiceCustomSection", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settledAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "skipInvoiceCustomSections", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "source", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletTransactionSourceEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletTransactionStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionStatus", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletTransactionTransactionStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "transactionType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WalletTransactionTransactionTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voidedInvoice", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Invoice", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "wallet", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Wallet", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletName", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletTransactionCollection", + "description": "WalletTransactionCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated WalletTransactionCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransaction", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletTransactionConsumption", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransaction", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransaction", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletTransactionConsumptionCollection", + "description": "WalletTransactionConsumptionCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated WalletTransactionConsumptionCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransactionConsumption", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletTransactionFunding", + "description": null, + "fields": [ + { + "name": "amountCents", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "BigInt", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "creditAmount", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "walletTransaction", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransaction", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletTransactionFundingCollection", + "description": "WalletTransactionFundingCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated WalletTransactionFundingCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WalletTransactionFunding", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "WalletTransactionMetadataInput", + "description": null, + "fields": null, + "inputFields": [ + { + "name": "key", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WalletTransactionMetadataObject", + "description": null, + "fields": [ + { + "name": "key", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "value", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WalletTransactionSourceEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "manual", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interval", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "threshold", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WalletTransactionStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "settled", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WalletTransactionTransactionStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "purchased", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "granted", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "voided", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "invoiced", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WalletTransactionTransactionTypeEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "inbound", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "outbound", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "Webhook", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "endpoint", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "httpStatus", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "lastRetriedAt", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "objectType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "payload", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "response", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retries", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Int", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "WebhookStatusEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookEndpoint", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "WebhookEndpoint", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookType", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WebhookCollection", + "description": "WebhookCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated WebhookCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "Webhook", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WebhookEndpoint", + "description": null, + "fields": [ + { + "name": "createdAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "eventTypes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EventTypeEnum", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "organization", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "Organization", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "signatureAlgo", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "WebhookEndpointSignatureAlgoEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "updatedAt", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ISO8601DateTime", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookUrl", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WebhookEndpointCollection", + "description": "WebhookEndpointCollection type", + "fields": [ + { + "name": "collection", + "description": "A collection of paginated WebhookEndpointCollection", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "WebhookEndpoint", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "metadata", + "description": "Pagination Metadata for navigating the Pagination", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "CollectionMetadata", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "WebhookEndpointCreateInput", + "description": "Autogenerated input type of CreateWebhookEndpoint", + "fields": null, + "inputFields": [ + { + "name": "eventTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EventTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "signatureAlgo", + "description": null, + "type": { + "kind": "ENUM", + "name": "WebhookEndpointSignatureAlgoEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookUrl", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WebhookEndpointSignatureAlgoEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "jwt", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hmac", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "INPUT_OBJECT", + "name": "WebhookEndpointUpdateInput", + "description": "Autogenerated input type of UpdateWebhookEndpoint", + "fields": null, + "inputFields": [ + { + "name": "eventTypes", + "description": null, + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EventTypeEnum", + "ofType": null + } + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "signatureAlgo", + "description": null, + "type": { + "kind": "ENUM", + "name": "WebhookEndpointSignatureAlgoEnum", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "webhookUrl", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "WebhookEventType", + "description": null, + "fields": [ + { + "name": "category", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EventCategoryEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "key", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "EventTypeEnum", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WebhookStatusEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "pending", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "succeeded", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "failed", + "description": null, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "retrying", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "WeightedIntervalEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "seconds", + "description": null, + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "XeroCustomer", + "description": null, + "fields": [ + { + "name": "externalCustomerId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationCode", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationId", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "integrationType", + "description": null, + "args": [], + "type": { + "kind": "ENUM", + "name": "IntegrationTypeEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncWithProvider", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "XeroIntegration", + "description": null, + "fields": [ + { + "name": "code", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "connectionId", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "hasMappingsConfigured", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncCreditNotes", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncInvoices", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "syncPayments", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Directive", + "description": "A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document.\n\nIn some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isRepeatable", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "locations", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__DirectiveLocation", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "onField", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onFragment", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + }, + { + "name": "onOperation", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": true, + "deprecationReason": "Use `locations`." + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__DirectiveLocation", + "description": "A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "QUERY", + "description": "Location adjacent to a query operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "MUTATION", + "description": "Location adjacent to a mutation operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SUBSCRIPTION", + "description": "Location adjacent to a subscription operation.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD", + "description": "Location adjacent to a field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_DEFINITION", + "description": "Location adjacent to a fragment definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FRAGMENT_SPREAD", + "description": "Location adjacent to a fragment spread.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INLINE_FRAGMENT", + "description": "Location adjacent to an inline fragment.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "VARIABLE_DEFINITION", + "description": "Location adjacent to a variable definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCHEMA", + "description": "Location adjacent to a schema definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "SCALAR", + "description": "Location adjacent to a scalar definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Location adjacent to an object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "FIELD_DEFINITION", + "description": "Location adjacent to a field definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ARGUMENT_DEFINITION", + "description": "Location adjacent to an argument definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Location adjacent to an interface definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Location adjacent to a union definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Location adjacent to an enum definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM_VALUE", + "description": "Location adjacent to an enum value definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Location adjacent to an input object type definition.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_FIELD_DEFINITION", + "description": "Location adjacent to an input object field definition.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__EnumValue", + "description": "One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string.", + "fields": [ + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Field", + "description": "Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type.", + "fields": [ + { + "name": "args", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__InputValue", + "description": "Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value.", + "fields": [ + { + "name": "defaultValue", + "description": "A GraphQL-formatted string representing the default value for this input value.", + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "deprecationReason", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isDeprecated", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "type", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Schema", + "description": "A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations.", + "fields": [ + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "directives", + "description": "A list of all directives supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Directive", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "mutationType", + "description": "If this server supports mutation, the type that mutation operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "queryType", + "description": "The type that query operations will be rooted at.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "subscriptionType", + "description": "If this server support subscription, the type that subscription operations will be rooted at.", + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "types", + "description": "A list of all types supported by this server.", + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "__Type", + "description": "The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum.\n\nDepending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name and description, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types.", + "fields": [ + { + "name": "description", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "enumValues", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__EnumValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "fields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Field", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "inputFields", + "description": null, + "args": [ + { + "name": "includeDeprecated", + "description": null, + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + }, + "defaultValue": "false", + "isDeprecated": false, + "deprecationReason": null + } + ], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__InputValue", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "interfaces", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "isOneOf", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "kind", + "description": null, + "args": [], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "ENUM", + "name": "__TypeKind", + "ofType": null + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "name", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ofType", + "description": null, + "args": [], + "type": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "possibleTypes", + "description": null, + "args": [], + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "OBJECT", + "name": "__Type", + "ofType": null + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "specifiedByURL", + "description": null, + "args": [], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "__TypeKind", + "description": "An enum describing what kind of type a given `__Type` is.", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "SCALAR", + "description": "Indicates this type is a scalar.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "OBJECT", + "description": "Indicates this type is an object. `fields` and `interfaces` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INTERFACE", + "description": "Indicates this type is an interface. `fields` and `possibleTypes` are valid fields.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "UNION", + "description": "Indicates this type is a union. `possibleTypes` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "ENUM", + "description": "Indicates this type is an enum. `enumValues` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "INPUT_OBJECT", + "description": "Indicates this type is an input object. `inputFields` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "LIST", + "description": "Indicates this type is a list. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "NON_NULL", + "description": "Indicates this type is a non-null. `ofType` is a valid field.", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + } + ], + "directives": [ + { + "name": "deprecated", + "description": "Marks an element of a GraphQL schema as no longer supported.", + "locations": [ + "FIELD_DEFINITION", + "ENUM_VALUE", + "ARGUMENT_DEFINITION", + "INPUT_FIELD_DEFINITION" + ], + "args": [ + { + "name": "reason", + "description": "Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted in [Markdown](https://daringfireball.net/projects/markdown/).", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": "\"No longer supported\"", + "isDeprecated": false, + "deprecationReason": null + } + ] + }, + { + "name": "include", + "description": "Directs the executor to include this field or fragment only when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Included when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, + { + "name": "oneOf", + "description": "Requires that exactly one field must be supplied and that field must not be `null`.", + "locations": [ + "INPUT_OBJECT" + ], + "args": [] + }, + { + "name": "skip", + "description": "Directs the executor to skip this field or fragment when the `if` argument is true.", + "locations": [ + "FIELD", + "FRAGMENT_SPREAD", + "INLINE_FRAGMENT" + ], + "args": [ + { + "name": "if", + "description": "Skipped when true.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + }, + { + "name": "specifiedBy", + "description": "Exposes a URL that specifies the behavior of this scalar.", + "locations": [ + "SCALAR" + ], + "args": [ + { + "name": "url", + "description": "The URL that specifies the behavior of this scalar.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null, + "isDeprecated": false, + "deprecationReason": null + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/scripts/generate.rsa.sh b/scripts/generate.rsa.sh new file mode 100755 index 0000000..e3e4399 --- /dev/null +++ b/scripts/generate.rsa.sh @@ -0,0 +1,23 @@ +#!/bin/bash +set -e + +# Path where RSA keys will be stored inside the container + +KEY_DIR="./config/keys" +PRIVATE_KEY="$KEY_DIR/private.pem" +PUBLIC_KEY="$KEY_DIR/public.pem" + +# Create the keys directory if it doesn't exist +mkdir -p "$KEY_DIR" + +# Generate RSA key pair if not already generated +if [ ! -f "$PRIVATE_KEY" ]; then + echo "Generating RSA key pair..." + openssl genpkey -algorithm RSA -out "$PRIVATE_KEY" + openssl rsa -pubout -in "$PRIVATE_KEY" -out "$PUBLIC_KEY" + echo "RSA keys generated at $KEY_DIR" +fi + +# Set permissions +chmod 600 "$PRIVATE_KEY" +chmod 644 "$PUBLIC_KEY" diff --git a/scripts/generate.version.sh b/scripts/generate.version.sh new file mode 100755 index 0000000..03bef9f --- /dev/null +++ b/scripts/generate.version.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +cd $LAGO_PATH/api + +VERSION=`git tag --points-at HEAD | tail -1` + +if [ "${#VERSION}" -eq "0" ]; then + VERSION=`git rev-parse HEAD` +fi + +echo "Current version: ${VERSION}" + +echo $VERSION > $LAGO_PATH/api/LAGO_VERSION diff --git a/scripts/karafka.web.sh b/scripts/karafka.web.sh new file mode 100755 index 0000000..a1e1e45 --- /dev/null +++ b/scripts/karafka.web.sh @@ -0,0 +1,6 @@ +#!/bin/bash + +if [ -v LAGO_KARAFKA_WEB ] && [ "$LAGO_KARAFKA_WEB" == "true" ] +then + karafka-web migrate --replication-factor=1 +fi diff --git a/scripts/migrate.dev.sh b/scripts/migrate.dev.sh new file mode 100755 index 0000000..828e45c --- /dev/null +++ b/scripts/migrate.dev.sh @@ -0,0 +1,5 @@ +#!/bin/bash +./scripts/generate.rsa.sh + +bundle install +bundle exec rails db:prepare diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..a6f8086 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,16 @@ +#!/bin/bash +set -e + +if [ "$RAILS_ENV" == "staging" ] +then + bundle exec rake db:prepare +else + bundle exec rake db:create + bundle exec rails db:migrate + bundle exec rails roles:seed_predefined + + if [ -v LAGO_CREATE_ORG ] && [ "$LAGO_CREATE_ORG" == "true" ] + then + bundle exec rails signup:seed_organization + fi +fi diff --git a/scripts/start.ai_agent.worker.sh b/scripts/start.ai_agent.worker.sh new file mode 100755 index 0000000..44847bc --- /dev/null +++ b/scripts/start.ai_agent.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_ai_agent.yml diff --git a/scripts/start.analytics.worker.sh b/scripts/start.analytics.worker.sh new file mode 100755 index 0000000..3e703d2 --- /dev/null +++ b/scripts/start.analytics.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_analytics.yml diff --git a/scripts/start.api.sh b/scripts/start.api.sh new file mode 100755 index 0000000..5516dd5 --- /dev/null +++ b/scripts/start.api.sh @@ -0,0 +1,4 @@ +#!/bin/bash + +rm -f ./tmp/pids/server.pid +exec bundle exec rails s -b :: diff --git a/scripts/start.billing.worker.sh b/scripts/start.billing.worker.sh new file mode 100755 index 0000000..d2f8e08 --- /dev/null +++ b/scripts/start.billing.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_billing.yml diff --git a/scripts/start.clock.sh b/scripts/start.clock.sh new file mode 100755 index 0000000..22fdfb9 --- /dev/null +++ b/scripts/start.clock.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec clockwork ./clock.rb diff --git a/scripts/start.clock.worker.sh b/scripts/start.clock.worker.sh new file mode 100755 index 0000000..b03a0a0 --- /dev/null +++ b/scripts/start.clock.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_clock.yml \ No newline at end of file diff --git a/scripts/start.dedicated.worker.sh b/scripts/start.dedicated.worker.sh new file mode 100755 index 0000000..4bde9fa --- /dev/null +++ b/scripts/start.dedicated.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_dedicated.yml diff --git a/scripts/start.dev.sh b/scripts/start.dev.sh new file mode 100755 index 0000000..b64f0ce --- /dev/null +++ b/scripts/start.dev.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +./scripts/generate.rsa.sh +./scripts/karafka.web.sh + +rm -f ./tmp/pids/server.pid +bundle install + +bundle exec rails db:prepare +bundle exec rails signup:seed_organization +bundle exec rails s -b 0.0.0.0 diff --git a/scripts/start.events.consumer.sh b/scripts/start.events.consumer.sh new file mode 100755 index 0000000..462b134 --- /dev/null +++ b/scripts/start.events.consumer.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec karafka server diff --git a/scripts/start.events.worker.sh b/scripts/start.events.worker.sh new file mode 100755 index 0000000..745fdbd --- /dev/null +++ b/scripts/start.events.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_events.yml diff --git a/scripts/start.payments.worker.sh b/scripts/start.payments.worker.sh new file mode 100755 index 0000000..79418da --- /dev/null +++ b/scripts/start.payments.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_payments.yml diff --git a/scripts/start.pdfs.worker.sh b/scripts/start.pdfs.worker.sh new file mode 100755 index 0000000..de5c7a0 --- /dev/null +++ b/scripts/start.pdfs.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_pdfs.yml diff --git a/scripts/start.sh b/scripts/start.sh new file mode 100755 index 0000000..470dae5 --- /dev/null +++ b/scripts/start.sh @@ -0,0 +1,19 @@ +#!/bin/bash + +if [ "$RAILS_ENV" == "staging" ] +then + bundle exec rake db:prepare +fi + +rm -f ./tmp/pids/server.pid + +if [ -v LAGO_CLICKHOUSE_MIGRATIONS_ENABLED ] && [ "$LAGO_CLICKHOUSE_MIGRATIONS_ENABLED" == "true" ] +then + bundle exec rails db:migrate:primary + bundle exec rails db:migrate:clickhouse +else + bundle exec rails db:migrate +fi + +bundle exec rails signup:seed_organization +exec bundle exec rails s -b :: diff --git a/scripts/start.webhook.worker.sh b/scripts/start.webhook.worker.sh new file mode 100755 index 0000000..7b11a4c --- /dev/null +++ b/scripts/start.webhook.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq_webhook.yml diff --git a/scripts/start.worker.sh b/scripts/start.worker.sh new file mode 100755 index 0000000..b1cd71c --- /dev/null +++ b/scripts/start.worker.sh @@ -0,0 +1,3 @@ +#!/bin/bash + +exec bundle exec sidekiq -C config/sidekiq/sidekiq.yml diff --git a/spec/clockwork_spec.rb b/spec/clockwork_spec.rb new file mode 100644 index 0000000..dbac143 --- /dev/null +++ b/spec/clockwork_spec.rb @@ -0,0 +1,427 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clockwork do + after { Clockwork::Test.clear! } + + let(:clock_file) { Rails.root.join("clock.rb") } + + describe "schedule:terminate_expired_wallet_transaction_rules" do + let(:job) { "schedule:terminate_expired_wallet_transaction_rules" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:50:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 01:50:00") } + + it "enqueues a terminate expired wallet transaction rules job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(1) + + Clockwork::Test.block_for(job).call + expect(Clock::TerminateRecurringTransactionRulesJob).to have_been_enqueued + end + end + + describe "schedule:bill_customers" do + let(:job) { "schedule:bill_customers" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 01:01:00") } + + it "enqueue a subscription biller job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(1) + + Clockwork::Test.block_for(job).call + expect(Clock::SubscriptionsBillerJob).to have_been_enqueued + end + end + + describe "schedule:activate_subscriptions" do + let(:job) { "schedule:activate_subscriptions" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 00:31:00") } + + it "enqueue a activate subscriptions job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(6) + + Clockwork::Test.block_for(job).call + expect(Clock::ActivateSubscriptionsJob).to have_been_enqueued + end + end + + describe "schedule:process_subscription_activity" do + let(:job) { "schedule:process_subscription_activity" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 00:31:00") } + + it "enqueue a process subscription activity job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(30) + + Clockwork::Test.block_for(job).call + expect(Clock::ProcessAllSubscriptionActivitiesJob).to have_been_enqueued.once + end + + context "with a custom refresh interval configured" do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("LAGO_SUBSCRIPTION_ACTIVITY_PROCESSING_INTERVAL_SECONDS").and_return("150") + end + + it 'uses the ENV["LAGO_SUBSCRIPTION_ACTIVITY_PROCESSING_INTERVAL_SECONDS"] to set a custom period' do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(12) + + Clockwork::Test.block_for(job).call + expect(Clock::ProcessAllSubscriptionActivitiesJob).to have_been_enqueued.once + + expect(ENV).to have_received(:[]).with("LAGO_SUBSCRIPTION_ACTIVITY_PROCESSING_INTERVAL_SECONDS") + end + end + end + + describe "schedule:post_validate_events" do + let(:job) { "schedule:post_validate_events" } + let(:start_time) { Time.zone.parse("1 Apr 2022 01:00:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 03:00:00") } + + it "enqueue a activate subscriptions job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(2) + + Clockwork::Test.block_for(job).call + expect(Clock::EventsValidationJob).to have_been_enqueued + end + end + + describe "schedule:refresh_lifetime_usages" do + let(:job) { "schedule:refresh_lifetime_usages" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 00:31:00") } + + it "enqueue a refresh lifetime usages job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(6) + + Clockwork::Test.block_for(job).call + expect(Clock::RefreshLifetimeUsagesJob).to have_been_enqueued + end + + context "with a custom refresh interval configured" do + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("LAGO_LIFETIME_USAGE_REFRESH_INTERVAL_SECONDS").and_return("150") + end + + it 'uses the ENV["LAGO_LIFETIME_USAGE_REFRESH_INTERVAL_SECONDS"] to set a custom period' do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(12) + + Clockwork::Test.block_for(job).call + expect(Clock::RefreshLifetimeUsagesJob).to have_been_enqueued + + expect(ENV).to have_received(:[]).with("LAGO_LIFETIME_USAGE_REFRESH_INTERVAL_SECONDS") + end + end + end + + describe "schedule:retry_generating_subscription_invoices" do + let(:job) { "schedule:retry_generating_subscription_invoices" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 01:01:00") } + + it "enqueues a Clock::RetryGeneratingSubscriptionInvoiceJob" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(1) + + Clockwork::Test.block_for(job).call + expect(Clock::RetryGeneratingSubscriptionInvoicesJob).to have_been_enqueued + end + end + + describe "schedule:compute_daily_usage" do + let(:job) { "schedule:compute_daily_usage" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 01:01:00") } + + it "enqueue a activate subscriptions job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(1) + + Clockwork::Test.block_for(job).call + expect(Clock::ComputeAllDailyUsagesJob).to have_been_enqueued + end + end + + describe "schedule:process_dunning_campaigns" do + let(:job) { "schedule:process_dunning_campaigns" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 01:01:00") } + + it "enqueue a process dunning campaigns job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(1) + + Clockwork::Test.block_for(job).call + expect(Clock::ProcessDunningCampaignsJob).to have_been_enqueued + end + end + + describe "schedule:clean_inbound_webhooks" do + let(:job) { "schedule:clean_inbound_webhooks" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:01:00") } + let(:end_time) { Time.zone.parse("2 Apr 2022 00:00:00") } + + it "enqueue a clean inbound webhooks job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.minute + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(1) + + Clockwork::Test.block_for(job).call + expect(Clock::InboundWebhooksCleanupJob).to have_been_enqueued + end + end + + describe "schedule:retry_inbound_webhooks" do + let(:job) { "schedule:retry_inbound_webhooks" } + let(:start_time) { Time.zone.parse("1 Apr 2022 00:05:00") } + let(:end_time) { Time.zone.parse("1 Apr 2022 00:20:00") } + + it "enqueue a retry inbound webhooks job" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.minute + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(1) + + Clockwork::Test.block_for(job).call + expect(Clock::InboundWebhooksRetryJob).to have_been_enqueued + end + end + + describe "schedule:refresh_flagged_subscriptions" do + let(:job) { "schedule:refresh_flagged_subscriptions" } + let(:start_time) { Time.zone.parse("2025-03-27T00:05:00") } + let(:end_time) { Time.zone.parse("2025-03-27T00:06:00") } + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("LAGO_KAFKA_BOOTSTRAP_SERVERS").and_return("redpanda:9092") + allow(ENV).to receive(:[]).with("LAGO_REDIS_STORE_URL").and_return("redis:6379") + allow(ENV).to receive(:[]).with("LAGO_CLICKHOUSE_ENABLED").and_return("true") + end + + it "enqueue a refresh flagged subscriptions job every 10 seconds" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(6) + + Clockwork::Test.block_for(job).call + expect(Clock::ConsumeSubscriptionRefreshedQueueJob).to have_been_enqueued + end + end + + describe "schedule:process_dedicated_orgs_subscription_activities" do + let(:job) { "schedule:process_dedicated_orgs_subscription_activities" } + let(:start_time) { Time.zone.parse("2025-03-27T00:05:00") } + let(:end_time) { Time.zone.parse("2025-03-27T00:06:00") } + + before do + allow(ENV).to receive(:[]).and_call_original + stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", ["org-1"]) + end + + it "enqueues a process dedicated orgs subscription activities job every 5 seconds" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(12) + + Clockwork::Test.block_for(job).call + expect(Clock::ProcessDedicatedOrgsSubscriptionActivitiesJob).to have_been_enqueued + end + + context "with a custom interval configured" do + before do + allow(ENV).to receive(:[]).with("LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS").and_return("10") + end + + it 'uses the ENV["LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS"] to set a custom period' do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(6) + + Clockwork::Test.block_for(job).call + expect(Clock::ProcessDedicatedOrgsSubscriptionActivitiesJob).to have_been_enqueued + end + end + + context "when the dedicated org list is empty" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", []) } + + it "does not register the schedule" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).not_to be_ran_job(job) + end + end + end + + describe "schedule:refresh_dedicated_org_wallets" do + let(:job) { "schedule:refresh_dedicated_org_wallets" } + let(:start_time) { Time.zone.parse("2025-03-27T00:05:00") } + let(:end_time) { Time.zone.parse("2025-03-27T00:06:00") } + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("LAGO_REDIS_CACHE_URL").and_return("redis:6379") + allow(ENV).to receive(:[]).with("LAGO_DISABLE_WALLET_REFRESH").and_return(nil) + stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", ["org-1"]) + end + + it "enqueue a refresh dedicated org wallets job every 5 seconds" do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(12) + + Clockwork::Test.block_for(job).call + expect(Clock::RefreshDedicatedOrgWalletsOngoingBalanceJob).to have_been_enqueued + end + + context "with a custom interval configured" do + before do + allow(ENV).to receive(:[]).with("LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS").and_return("10") + end + + it 'uses the ENV["LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS"] to set a custom period' do + Clockwork::Test.run( + file: clock_file, + start_time:, + end_time:, + tick_speed: 1.second + ) + + expect(Clockwork::Test).to be_ran_job(job) + expect(Clockwork::Test.times_run(job)).to eq(6) + + Clockwork::Test.block_for(job).call + expect(Clock::RefreshDedicatedOrgWalletsOngoingBalanceJob).to have_been_enqueued + end + end + end +end diff --git a/spec/consumers/events_charged_in_advance_consumer_spec.rb b/spec/consumers/events_charged_in_advance_consumer_spec.rb new file mode 100644 index 0000000..1ec4ca8 --- /dev/null +++ b/spec/consumers/events_charged_in_advance_consumer_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EventsChargedInAdvanceConsumer do + subject(:consumer) { karafka.consumer_for(ENV["LAGO_KAFKA_EVENTS_CHARGED_IN_ADVANCE_TOPIC"]) } + + let(:event) { build(:common_event) } + + before { karafka.produce(event.to_json) } + + it "enqueues a pay in advance job with a delay" do + freeze_time do + expect { consumer.consume }.to have_enqueued_job(Events::PayInAdvanceJob) + .with(event.as_json) + .at(Events::Stores::ClickhouseStore::CLICKHOUSE_MERGE_DELAY.from_now) + end + end +end diff --git a/spec/contracts/queries/billable_metrics_query_filters_contract_spec.rb b/spec/contracts/queries/billable_metrics_query_filters_contract_spec.rb new file mode 100644 index 0000000..f245438 --- /dev/null +++ b/spec/contracts/queries/billable_metrics_query_filters_contract_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Queries::BillableMetricsQueryFiltersContract do + subject(:result) { described_class.new.call(filters.to_h) } + + let(:filters) { {} } + + context "when filters are valid" do + let(:filters) do + { + recurring: true, + aggregation_types: ["max_agg", "count_agg"] + } + end + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filters are invalid" do + it_behaves_like "an invalid filter", :recurring, nil, ["must be filled"] + it_behaves_like "an invalid filter", :recurring, "not_a_bool", ["must be boolean"] + + it_behaves_like "an invalid filter", :aggregation_types, nil, ["must be an array"] + it_behaves_like "an invalid filter", :aggregation_types, "not_an_array", ["must be an array"] + it_behaves_like "an invalid filter", :aggregation_types, [1], {0 => ["must be a string"]} + it_behaves_like "an invalid filter", :aggregation_types, ["invalid_type"], {0 => ["must be one of: max_agg, count_agg"]} + end +end diff --git a/spec/contracts/queries/customers_query_filters_contract_spec.rb b/spec/contracts/queries/customers_query_filters_contract_spec.rb new file mode 100644 index 0000000..88b694a --- /dev/null +++ b/spec/contracts/queries/customers_query_filters_contract_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Queries::CustomersQueryFiltersContract do + subject(:result) { described_class.new.call(filters.to_h) } + + let(:filters) { {} } + + context "when filtering by account type" do + let(:filters) { {account_type: %w[customer partner]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by billing entity ids" do + let(:filters) { {billing_entity_ids: ["123e4567-e89b-12d3-a456-426614174000"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by currencies" do + let(:filters) { {currencies: ["USD"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by countries" do + let(:filters) { {countries: ["US"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by states" do + let(:filters) { {states: ["CA"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by zipcodes" do + let(:filters) { {zipcodes: ["10115"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by has_tax_identification_number" do + [ + "true", + "false", + true, + false + ].each do |value| + let(:filters) { {has_tax_identification_number: value} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + end + + context "when filtering by metadata" do + let(:filters) { {metadata: {"key" => "value"}} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by customer_type" do + let(:filters) { {customer_type: "company"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by has_customer_type" do + context "when filtering by true" do + let(:filters) { {has_customer_type: true} } + + it "is valid" do + expect(result.success?).to be(true) + end + + context "when customer_type is provided" do + let(:filters) { {has_customer_type: true, customer_type: "company"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + end + + context "when filtering by false" do + let(:filters) { {has_customer_type: false} } + + it "is valid" do + expect(result.success?).to be(true) + end + + context "when customer_type is provided" do + let(:filters) { {has_customer_type: false, customer_type: "company"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({customer_type: ["must be nil when has_customer_type is false"]}) + end + end + end + end + + context "when filters are invalid" do + it_behaves_like "an invalid filter", :account_type, nil, ["must be an array"] + it_behaves_like "an invalid filter", :account_type, %w[random], {0 => ["must be one of: customer, partner"]} + it_behaves_like "an invalid filter", :billing_entity_ids, SecureRandom.uuid, ["must be an array"] + it_behaves_like "an invalid filter", :billing_entity_ids, %w[random], {0 => ["is in invalid format"]} + it_behaves_like "an invalid filter", :currencies, %w[random], {0 => [/^must be one of: AED,.*ZMW$/]} + it_behaves_like "an invalid filter", :countries, %w[random], {0 => [/^must be one of: AD, .*XK$/]} + it_behaves_like "an invalid filter", :states, SecureRandom.uuid, ["must be an array"] + it_behaves_like "an invalid filter", :zipcodes, SecureRandom.uuid, ["must be an array"] + it_behaves_like "an invalid filter", :has_tax_identification_number, SecureRandom.uuid, ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_tax_identification_number, "t", ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_tax_identification_number, "f", ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_tax_identification_number, 1, ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_tax_identification_number, 0, ["must be one of: true, false"] + it_behaves_like "an invalid filter", :metadata, SecureRandom.uuid, ["must be a hash"] + it_behaves_like "an invalid filter", :metadata, {0 => "integer key"}, ["keys must be string"] + it_behaves_like "an invalid filter", :metadata, {"key" => ["must be a string"]}, {"key" => ["must be a string"]} + it_behaves_like "an invalid filter", :customer_type, "random", ["must be one of: company, individual"] + it_behaves_like "an invalid filter", :has_customer_type, SecureRandom.uuid, ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_customer_type, "t", ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_customer_type, "f", ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_customer_type, 1, ["must be one of: true, false"] + it_behaves_like "an invalid filter", :has_customer_type, 0, ["must be one of: true, false"] + end +end diff --git a/spec/contracts/queries/invoices_query_filters_contract_spec.rb b/spec/contracts/queries/invoices_query_filters_contract_spec.rb new file mode 100644 index 0000000..b75871f --- /dev/null +++ b/spec/contracts/queries/invoices_query_filters_contract_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Queries::InvoicesQueryFiltersContract do + subject(:result) { described_class.new.call(filters.to_h) } + + let(:filters) { {} } + + context "when filtering by settlements" do + let(:filters) { {settlements: "credit_note"} } + + it "is valid" do + expect(result.success?).to be(true) + end + + context "when settlement is payment" do + let(:filters) { {settlements: "payment"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filter is an array" do + let(:filters) { {settlements: ["credit_note"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + + context "when settlement is payment" do + let(:filters) { {settlements: ["payment"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + end + end + + context "when filtering by payment status" do + let(:filters) { {payment_status: "succeeded"} } + + it "is valid" do + expect(result.success?).to be(true) + end + + context "when filter is an array" do + let(:filters) { {payment_status: ["succeeded", "failed"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + end + + context "when filtering by status" do + let(:filters) { {status: "draft"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by billing entity ids" do + let(:filters) { {billing_entity_ids: ["123e4567-e89b-12d3-a456-426614174000"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by self billed" do + let(:filters) { {self_billed: false} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering by partially paid" do + let(:filters) { {partially_paid: false} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filtering payment overdue" do + let(:filters) { {payment_overdue: false} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when filters are invalid" do + it_behaves_like "an invalid filter", :settlements, "random", ["must be one of: payment, credit_note or must be an array"] + it_behaves_like "an invalid filter", :settlements, ["credit_note", "random"], {1 => ["must be one of: payment, credit_note"]} + it_behaves_like "an invalid filter", :payment_status, "random", ["must be one of: pending, succeeded, failed or must be an array"] + it_behaves_like "an invalid filter", :payment_status, ["succeeded", "random"], {1 => ["must be one of: pending, succeeded, failed"]} + it_behaves_like "an invalid filter", :status, "random", ["must be one of: draft, finalized, voided, failed, pending or must be an array"] + it_behaves_like "an invalid filter", :status, ["draft", "random"], {1 => ["must be one of: draft, finalized, voided, failed, pending"]} + it_behaves_like "an invalid filter", :self_billed, "invalid", ["must be boolean"] + it_behaves_like "an invalid filter", :partially_paid, "invalid", ["must be boolean"] + it_behaves_like "an invalid filter", :payment_overdue, "invalid", ["must be boolean"] + it_behaves_like "an invalid filter", :billing_entity_ids, SecureRandom.uuid, ["must be an array"] + it_behaves_like "an invalid filter", :billing_entity_ids, %w[random], {0 => ["is in invalid format"]} + end +end diff --git a/spec/contracts/queries/payment_receipts_query_filters_contract_spec.rb b/spec/contracts/queries/payment_receipts_query_filters_contract_spec.rb new file mode 100644 index 0000000..1ebd182 --- /dev/null +++ b/spec/contracts/queries/payment_receipts_query_filters_contract_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Queries::PaymentReceiptsQueryFiltersContract do + subject(:result) { described_class.new.call(filters.to_h) } + + let(:filters) { {} } + + context "when filters are valid" do + context "when invoice_id is valid" do + let(:filters) { {invoice_id: "7b199d93-2663-4e68-beca-203aefcd019b"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when invoice_id is blank" do + let(:filters) { {invoice_id: nil} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + end + + context "when filters are invalid" do + context "when invoice_id is not a UUID" do + let(:filters) { {invoice_id: "invalid_uuid"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({invoice_id: ["is in invalid format"]}) + end + end + end +end diff --git a/spec/contracts/queries/payments_query_filters_contract_spec.rb b/spec/contracts/queries/payments_query_filters_contract_spec.rb new file mode 100644 index 0000000..ac6d338 --- /dev/null +++ b/spec/contracts/queries/payments_query_filters_contract_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Queries::PaymentsQueryFiltersContract do + subject(:result) { described_class.new.call(filters.to_h) } + + let(:filters) { {} } + + context "when filters are valid" do + context "when invoice_id is valid" do + let(:filters) { {invoice_id: "7b199d93-2663-4e68-beca-203aefcd019b"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when invoice_id is blank" do + let(:filters) { {invoice_id: nil} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when external_customer_id is valid" do + let(:filters) { {external_customer_id: "valid_string"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when external_customer_id is blank" do + let(:filters) { {external_customer_id: nil} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when both invoice_id and external_customer_id are valid" do + let(:filters) { {invoice_id: "7b199d93-2663-4e68-beca-203aefcd019b", external_customer_id: "valid_string"} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + end + + context "when filters are invalid" do + context "when invoice_id is not a UUID" do + let(:filters) { {invoice_id: "invalid_uuid"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include(invoice_id: ["is in invalid format"]) + end + end + + context "when external_customer_id is not a string" do + let(:filters) { {external_customer_id: 123} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include(external_customer_id: ["must be a string"]) + end + end + + context "when both invoice_id and external_customer_id are invalid" do + let(:filters) { {invoice_id: "invalid_uuid", external_customer_id: 123} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include( + invoice_id: ["is in invalid format"], + external_customer_id: ["must be a string"] + ) + end + end + end +end diff --git a/spec/contracts/queries/quotes_query_filters_contract_spec.rb b/spec/contracts/queries/quotes_query_filters_contract_spec.rb new file mode 100644 index 0000000..5878561 --- /dev/null +++ b/spec/contracts/queries/quotes_query_filters_contract_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Queries::QuotesQueryFiltersContract do + subject(:result) { described_class.new.call(filters.to_h) } + + let(:filters) { {} } + + context "when filtering by customer" do + let(:filters) { {customers: ["00000000-0000-0000-0000-000000000000"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + + context "when customer filter is invalid" do + context "when filter is a string" do + let(:filters) { {customers: "wrong"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({customers: ["must be an array"]}) + end + end + + context "when filter is an array with invalid values" do + let(:filters) { {customers: ["wrong"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({customers: {0 => ["is in invalid format"]}}) + end + end + end + end + + context "when filtering by status" do + context "when filter is valid" do + let(:filters) { {statuses: ["draft"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when status filter is invalid" do + context "when filter is a string" do + let(:filters) { {statuses: "wrong"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({statuses: ["must be an array"]}) + end + end + + context "when filter is an array with invalid values" do + let(:filters) { {statuses: ["wrong"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({statuses: {0 => ["must be one of: draft, approved, voided"]}}) + end + end + end + end + + context "when filtering by number" do + context "when filter is valid" do + let(:filters) { {numbers: ["QT-2025-0001"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when number filter is invalid" do + context "when filter is a string" do + let(:filters) { {numbers: "wrong"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({numbers: ["must be an array"]}) + end + end + + context "when filter is an array with invalid values" do + let(:filters) { {numbers: ["wrong"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({numbers: {0 => ["is in invalid format"]}}) + end + end + end + end + + context "when filtering by from_date and to_date" do + context "when filters are valid" do + let(:filters) { {from_date: 2.days.ago.iso8601, to_date: Date.current.iso8601} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when from_date is invalid" do + let(:filters) { {from_date: "invalid date"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({from_date: ["must be a date"]}) + end + end + + context "when to_date is invalid" do + let(:filters) { {to_date: "invalid date"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({to_date: ["must be a date"]}) + end + end + end + + context "when filtering by owners" do + context "when filter is valid" do + let(:filters) { {owners: ["00000000-0000-0000-0000-000000000000"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when owners filter is invalid" do + context "when filter is a string" do + let(:filters) { {owners: "wrong"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({owners: ["must be an array"]}) + end + end + + context "when filter is an array with invalid values" do + let(:filters) { {owners: ["wrong"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({owners: {0 => ["is in invalid format"]}}) + end + end + end + end + + context "when filtering by order_types" do + context "when filter is valid" do + let(:filters) { {order_types: ["one_off"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when order_types filter is invalid" do + context "when filter is a string" do + let(:filters) { {order_types: "wrong"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({order_types: ["must be an array"]}) + end + end + + context "when filter is an array with invalid values" do + let(:filters) { {order_types: ["wrong"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({order_types: {0 => ["must be one of: subscription_creation, subscription_amendment, one_off"]}}) + end + end + end + end +end diff --git a/spec/contracts/queries/webhooks_query_filters_contract_spec.rb b/spec/contracts/queries/webhooks_query_filters_contract_spec.rb new file mode 100644 index 0000000..dcc39e7 --- /dev/null +++ b/spec/contracts/queries/webhooks_query_filters_contract_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Queries::WebhooksQueryFiltersContract do + subject(:result) { described_class.new.call(filters.to_h) } + + let(:filters) { {} } + + context "when filtering by webhook_endpoint_id" do + let(:filters) { {webhook_endpoint_id: "webhook-123"} } + + it "is valid" do + expect(result.success?).to be(true) + end + + context "when filter is blank" do + let(:filters) { {webhook_endpoint_id: nil} } + + it "is invalid" do + expect(result.success?).to be(false) + end + end + end + + context "when filtering by status" do + context "when filter is valid" do + let(:filters) { {webhook_endpoint_id: "webhook-123", statuses: ["pending", "succeeded"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when status filter is invalid" do + context "when filter is a string" do + let(:filters) { {webhook_endpoint_id: "webhook-123", statuses: "random"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({statuses: ["must be an array"]}) + end + end + + context "when filter is an array with invalid values" do + let(:filters) { {webhook_endpoint_id: "webhook-123", statuses: ["pending", "random"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({statuses: {1 => ["must be one of: pending, succeeded, failed, retrying"]}}) + end + end + end + end + + context "when filtering by event_types" do + context "when filter is valid" do + let(:filters) { {webhook_endpoint_id: "webhook-123", event_types: ["invoice.created", "invoice.generated"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when event_types filter is invalid" do + context "when filter is a string" do + let(:filters) { {webhook_endpoint_id: "webhook-123", event_types: "random"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({event_types: ["must be an array"]}) + end + end + + context "when filter is an array with invalid values" do + let(:filters) { {webhook_endpoint_id: "webhook-123", event_types: ["invoice.created", "random"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({event_types: {1 => ["must be one of: #{WebhookEndpoint::WEBHOOK_EVENT_TYPES.join(", ")}"]}}) + end + end + end + end + + context "when filtering by http_statuses" do + context "when filter is valid" do + let(:filters) { {webhook_endpoint_id: "webhook-123", http_statuses: ["200", "5xx", "400-404", "timeout"]} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when http_statuses filter is invalid" do + context "when filter is a string" do + let(:filters) { {webhook_endpoint_id: "webhook-123", http_statuses: "random"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({http_statuses: ["must be an array"]}) + end + end + + context "when filter has invalid format" do + let(:filters) { {webhook_endpoint_id: "webhook-123", http_statuses: ["200", "invalid"]} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({http_statuses: {1 => ["is in invalid format"]}}) + end + end + end + end + + context "when filtering by from_date and to_date" do + context "when filters are valid" do + let(:filters) { {webhook_endpoint_id: "webhook-123", from_date: 2.days.ago, to_date: Time.current} } + + it "is valid" do + expect(result.success?).to be(true) + end + end + + context "when from_date is invalid" do + let(:filters) { {webhook_endpoint_id: "webhook-123", from_date: "invalid date"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({from_date: ["must be a time"]}) + end + end + + context "when to_date is invalid" do + let(:filters) { {webhook_endpoint_id: "webhook-123", to_date: "invalid date"} } + + it "is invalid" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({to_date: ["must be a time"]}) + end + end + end +end diff --git a/spec/controllers/concerns/api_loggable_spec.rb b/spec/controllers/concerns/api_loggable_spec.rb new file mode 100644 index 0000000..55a250e --- /dev/null +++ b/spec/controllers/concerns/api_loggable_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiLoggable do + # rubocop:disable RSpec/DescribedClass + controller(ApplicationController) do + include ApiLoggable + + attr_reader :current_organization + + def index + render json: :ok + end + end + # rubocop:enable RSpec/DescribedClass + + before do + allow(Utils::ApiLog).to receive(:produce) + end + + context "when get" do + it "does not produce api log" do + get :index + + expect(Utils::ApiLog).not_to have_received(:produce) + end + end + + [:post, :put, :delete].each do |method| + context "when method is #{method}" do + it "produces api log" do + send(method, :index) + + expect(Utils::ApiLog).to have_received(:produce) + end + end + end + + context "with skip_audit_logs!" do + # rubocop:disable RSpec/DescribedClass + controller(ApplicationController) do + include ApiLoggable + + skip_audit_logs! + + def index + render json: :ok + end + end + # rubocop:enable RSpec/DescribedClass + + [:get, :post, :put, :delete].each do |method| + context "when method is #{method}" do + it "does not produce api log" do + send(method, :index) + + expect(Utils::ApiLog).not_to have_received(:produce) + end + end + end + end +end diff --git a/spec/controllers/concerns/common_spec.rb b/spec/controllers/concerns/common_spec.rb new file mode 100644 index 0000000..9c4404f --- /dev/null +++ b/spec/controllers/concerns/common_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +RSpec.describe Common do + let(:controller) { klass.new } + + let(:klass) do + Class.new do + include Common + + public :valid_date? # expose for testing + end + end + + describe "#valid_date?" do + subject(:method_call) { controller.valid_date?(date) } + + context "when date is nil" do + let(:date) { nil } + + it "returns false" do + expect(subject).to be false + end + end + + context "when date is empty string" do + let(:date) { "" } + + it "returns false" do + expect(subject).to be false + end + end + + context "when a valid date string is provided" do + let(:date) { "2021-01-01" } + + it "returns true" do + expect(subject).to be true + end + end + + context "when an invalid date string is provided" do + let(:date) { "2021-02-30" } + + it "returns false" do + expect(subject).to be false + end + end + + context "when a malformed date sis provided" do + let(:date) { "not-a-date" } + + it "returns false" do + expect(subject).to be false + end + end + end +end diff --git a/spec/controllers/concerns/pagination_spec.rb b/spec/controllers/concerns/pagination_spec.rb new file mode 100644 index 0000000..2bb4d26 --- /dev/null +++ b/spec/controllers/concerns/pagination_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +# rubocop:disable RSpec/VerifiedDoubles +require "rails_helper" + +RSpec.describe Pagination do + subject(:result) { instance.pagination_metadata(records) } + + let(:instance) { dummy_class.new } + let(:dummy_class) do + Class.new do + include Pagination + + public :_count_total, :pagination_metadata + end + end + let(:cache) { ActiveSupport::Cache::MemoryStore.new } + let(:records) { double("records", total_count: 25, current_page: 2, limit_value: 10) } + let(:key) { "invoices" } + let(:organization_id) { SecureRandom.uuid } + + before { allow(Rails).to receive(:cache).and_return(cache) } + + context "when records are present" do + it "returns correct metadata" do + expect(result).to eq( + "current_page" => 2, + "next_page" => 3, + "prev_page" => 1, + "total_pages" => 3, + "total_count" => 25 + ) + end + end + + context "when on the first page" do + let(:records) { double("records", total_count: 25, current_page: 1, limit_value: 10) } + + it "returns nil for prev_page" do + expect(result["prev_page"]).to be_nil + end + end + + context "when on the last page" do + let(:records) { double("records", total_count: 25, current_page: 3, limit_value: 10) } + + it "returns nil for next_page" do + expect(result["next_page"]).to be_nil + end + end + + context "when total_count is zero" do + let(:records) { double("records", total_count: 0) } + + it "returns zeroed metadata" do + expect(result).to eq( + "current_page" => 0, + "next_page" => nil, + "prev_page" => nil, + "total_pages" => 0, + "total_count" => 0 + ) + end + end + + context "when count is cached" do + subject(:result) { instance.pagination_metadata(records, key:, organization_id:, params:) } + + let(:params) { {"per_page" => "10", "page" => "1", "status" => "active"}.with_indifferent_access } + let(:records) { double("records", current_page: 1, limit_value: 10) } + + before { instance._count_total(key:, organization_id:, params:) { 99 } } + + it "uses cached count instead of records.total_count" do + expect(result["total_count"]).to eq(99) + expect(result["total_pages"]).to eq(10) + end + end + + context "when cache is stale on the last page" do + subject(:result) { instance.pagination_metadata(records, key:, organization_id:, params: query_params) } + + let(:query_params) { {"per_page" => "10", "page" => "10", "status" => "active"}.with_indifferent_access } + let(:records) { double("records", total_count: 105, current_page: 10, limit_value: 10) } + + before do + instance._count_total(key: "invoices", organization_id:, params: query_params.merge("page" => "1")) { 99 } + end + + it "re-calculates from records" do + expect(result["total_count"]).to eq(105) + expect(result["total_pages"]).to eq(11) + end + end + + context "when records do not respond to limit_value" do + let(:records) { double("records", total_count: 25, current_page: 2, total_pages: 3, next_page: 3, prev_page: 1) } + + it "uses precomputed pagination attributes" do + expect(result).to eq( + "current_page" => 2, + "next_page" => 3, + "prev_page" => 1, + "total_pages" => 3, + "total_count" => 25 + ) + end + end + + context "when nested params are in different order" do + let(:organization_id) { SecureRandom.uuid } + + it "hits the same cache entry" do + params_asc = {"per_page" => "10", "page" => "1", "metadata" => {"a" => "1", "b" => "2"}}.with_indifferent_access + instance._count_total(key: "invoices", organization_id:, params: params_asc) { 77 } + + params_desc = {"metadata" => {"b" => "2", "a" => "1"}, "page" => "1", "per_page" => "10"}.with_indifferent_access + records = double("records", current_page: 1, limit_value: 10) + result = instance.pagination_metadata(records, key:, organization_id:, params: params_desc) + expect(result["total_count"]).to eq(77) + end + end + + context "with different filter params" do + let(:organization_id) { SecureRandom.uuid } + + it "does not share cache across different queries" do + params = {"per_page" => "10", "page" => "1", "status" => "active"}.with_indifferent_access + instance._count_total(key: "invoices", organization_id:, params:) { 99 } + records = double("records", current_page: 1, limit_value: 10) + result = instance.pagination_metadata(records, key:, organization_id:, params:) + expect(result["total_count"]).to eq(99) + + params = {"per_page" => "10", "page" => "1", "status" => "draft"}.with_indifferent_access + records = double("records", total_count: 5, current_page: 1, limit_value: 10) + result = instance.pagination_metadata(records, key:, organization_id:, params:) + expect(result["total_count"]).to eq(5) + end + end +end +# rubocop:enable RSpec/VerifiedDoubles diff --git a/spec/controllers/concerns/premium_feature_only_spec.rb b/spec/controllers/concerns/premium_feature_only_spec.rb new file mode 100644 index 0000000..5bf5543 --- /dev/null +++ b/spec/controllers/concerns/premium_feature_only_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PremiumFeatureOnly do + include ApiHelper + + # rubocop:disable RSpec/DescribedClass + controller(ApplicationController) do + include ApiErrors + include PremiumFeatureOnly + + attr_reader :current_organization + + def index + render json: {premium: "only"} + end + end + # rubocop:enable RSpec/DescribedClass + + context "with free usage" do + it "returns a forbidden error" do + get :index + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "when premium usage", :premium do + it "does not block the request" do + get :index + + expect(response).to have_http_status(:success) + expect(json[:premium]).to eq("only") + end + end +end diff --git a/spec/controllers/concerns/trackable_spec.rb b/spec/controllers/concerns/trackable_spec.rb new file mode 100644 index 0000000..9f98060 --- /dev/null +++ b/spec/controllers/concerns/trackable_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Trackable do + describe "#set_tracing_information" do + let(:membership) { create(:membership) } + + it "sets the membership identifier to context" do + build_dummy(current_user: membership.user).set_tracing_information + + expect(CurrentContext.membership).to eq "membership/#{membership.id}" + end + + context "when current organization is not present" do + it 'sets an "unidentifiable" membership identifier to context' do + build_dummy(current_organization: nil).set_tracing_information + + expect(CurrentContext.membership).to eq "membership/unidentifiable" + end + end + + context "when current user is nil" do + it "sets the first created membership to context" do + build_dummy(current_user: nil).set_tracing_information + + expect(CurrentContext.membership).to eq "membership/#{membership.id}" + end + end + end + + def dummy_class + Class.new do + def self.before_action(*) + end + + include Trackable + + def initialize(options = {}) + self.current_user = options.fetch(:current_user) if options[:current_user] + self.current_organization = options.fetch(:current_organization) + end + + private + + attr_accessor :current_user + attr_accessor :current_organization + end + end + + def build_dummy(attrs = {}) + base_attrs = {current_organization: membership.organization} + stub_const("DummyClass", dummy_class) + DummyClass.new(base_attrs.merge(attrs)) + end +end diff --git a/spec/cop_helper.rb b/spec/cop_helper.rb new file mode 100644 index 0000000..82e6739 --- /dev/null +++ b/spec/cop_helper.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" +require "rubocop" +require "rubocop/rspec/support" + +# Load all custom cops +Dir[Rails.root.join("dev/cops/**/*.rb")].each do |file| + require file +end + +RSpec.configure do |config| + config.include RuboCop::RSpec::ExpectOffense +end diff --git a/spec/factories/add_on_applied_taxes.rb b/spec/factories/add_on_applied_taxes.rb new file mode 100644 index 0000000..6be2ca6 --- /dev/null +++ b/spec/factories/add_on_applied_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :add_on_applied_tax, class: "AddOn::AppliedTax" do + add_on + tax + organization { add_on&.organization || tax&.organization || association(:organization) } + end +end diff --git a/spec/factories/add_ons.rb b/spec/factories/add_ons.rb new file mode 100644 index 0000000..e4a6739 --- /dev/null +++ b/spec/factories/add_ons.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :add_on do + organization + name { Faker::Name.name } + invoice_display_name { Faker::Fantasy::Tolkien.location } + code { Faker::Alphanumeric.alphanumeric(number: 10) } + description { "test description" } + amount_cents { 200 } + amount_currency { "EUR" } + end +end diff --git a/spec/factories/adjusted_fees.rb b/spec/factories/adjusted_fees.rb new file mode 100644 index 0000000..520350c --- /dev/null +++ b/spec/factories/adjusted_fees.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :adjusted_fee do + organization { invoice&.organization || fee&.organization || association(:organization) } + invoice + fee + charge { nil } + subscription + + fee_type { "subscription" } + + unit_amount_cents { 200 } + units { 2 } + adjusted_amount { true } + + invoice_display_name { Faker::Fantasy::Tolkien.character } + end +end diff --git a/spec/factories/ai_conversations.rb b/spec/factories/ai_conversations.rb new file mode 100644 index 0000000..90602b8 --- /dev/null +++ b/spec/factories/ai_conversations.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :ai_conversation do + organization + membership + name { "How can I create a coupon?" } + mistral_conversation_id { "mistral-conv-id-123" } + end +end diff --git a/spec/factories/api_keys.rb b/spec/factories/api_keys.rb new file mode 100644 index 0000000..bd5dd95 --- /dev/null +++ b/spec/factories/api_keys.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :api_key do + name { "API Key" } + organization { association(:organization, api_keys: []) } + + trait :expired do + expires_at { generate(:past_date) } + end + + trait :expiring do + expires_at { generate(:future_date) } + end + end +end diff --git a/spec/factories/applied_add_ons.rb b/spec/factories/applied_add_ons.rb new file mode 100644 index 0000000..d054fd1 --- /dev/null +++ b/spec/factories/applied_add_ons.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :applied_add_on do + customer + add_on + + amount_cents { 200 } + amount_currency { "EUR" } + end +end diff --git a/spec/factories/applied_coupons.rb b/spec/factories/applied_coupons.rb new file mode 100644 index 0000000..5197218 --- /dev/null +++ b/spec/factories/applied_coupons.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :applied_coupon do + customer + coupon + organization { customer&.organization || coupon&.organization || association(:organization) } + + amount_cents { 200 } + amount_currency { "EUR" } + status { "active" } + frequency { "once" } + + trait :terminated do + terminated_at { 1.day.ago } + end + end +end diff --git a/spec/factories/applied_invoice_custom_sections.rb b/spec/factories/applied_invoice_custom_sections.rb new file mode 100644 index 0000000..eaa321e --- /dev/null +++ b/spec/factories/applied_invoice_custom_sections.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :applied_invoice_custom_section do + invoice + organization { invoice&.organization || association(:organization) } + code { Faker::Lorem.words(number: 3).join("_") } + name { Faker::Lorem.words(number: 3).join(" ") } + display_name { Faker::Lorem.words(number: 3).join(" ") } + details { "These details are shown in the invoice" } + end +end diff --git a/spec/factories/applied_pricing_units.rb b/spec/factories/applied_pricing_units.rb new file mode 100644 index 0000000..81f2b04 --- /dev/null +++ b/spec/factories/applied_pricing_units.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :applied_pricing_unit do + pricing_unit + pricing_unitable { association(:standard_charge) } + organization { pricing_unit&.organization || pricing_unitable&.organization || association(:organization) } + conversion_rate { rand(1.0..10.0) } + end +end diff --git a/spec/factories/applied_usage_thresholds.rb b/spec/factories/applied_usage_thresholds.rb new file mode 100644 index 0000000..72c9112 --- /dev/null +++ b/spec/factories/applied_usage_thresholds.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :applied_usage_threshold do + usage_threshold + invoice + organization { invoice&.organization || usage_threshold&.organization || association(:organization) } + + lifetime_usage_amount_cents { 100 } + end +end diff --git a/spec/factories/billable_metric_filters.rb b/spec/factories/billable_metric_filters.rb new file mode 100644 index 0000000..5b47a5c --- /dev/null +++ b/spec/factories/billable_metric_filters.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :billable_metric_filter do + billable_metric + organization { billable_metric&.organization || association(:organization) } + key { Faker::Name.name.underscore } + values { [Faker::Name.name, Faker::Name.name, Faker::Name.name] } + end +end diff --git a/spec/factories/billable_metrics.rb b/spec/factories/billable_metrics.rb new file mode 100644 index 0000000..47bf8a6 --- /dev/null +++ b/spec/factories/billable_metrics.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :billable_metric do + organization + name { Faker::Alphanumeric.alphanumeric(number: 10) } + description { "some description" } + code { Faker::Alphanumeric.alphanumeric(number: 10) } + aggregation_type { "count_agg" } + recurring { false } + properties { {} } + expression { "" } + + trait :recurring do + recurring { true } + end + + trait :discarded do + deleted_at { Time.current } + end + end + + factory :sum_billable_metric, parent: :billable_metric do + aggregation_type { "sum_agg" } + field_name { "item_id" } + end + + factory :latest_billable_metric, parent: :billable_metric do + aggregation_type { "latest_agg" } + field_name { "item_id" } + end + + factory :max_billable_metric, parent: :billable_metric do + aggregation_type { "max_agg" } + field_name { "item_id" } + end + + factory :weighted_sum_billable_metric, parent: :billable_metric do + aggregation_type { "weighted_sum_agg" } + weighted_interval { "seconds" } + field_name { "value" } + end + + factory :unique_count_billable_metric, parent: :billable_metric do + aggregation_type { "unique_count_agg" } + field_name { "item_id" } + end + + factory :custom_billable_metric, parent: :billable_metric do + aggregation_type { "custom_agg" } + custom_aggregator { "def aggregate(event, agg, aggregation_properties); agg; end" } + end +end diff --git a/spec/factories/billing_entities.rb b/spec/factories/billing_entities.rb new file mode 100644 index 0000000..6a287af --- /dev/null +++ b/spec/factories/billing_entities.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :billing_entity do + name { Faker::Company.name } + code { "entity_#{SecureRandom.uuid}" } + default_currency { "USD" } + + email { Faker::Internet.email } + email_settings { ["invoice.finalized", "credit_note.created"] } + organization { association(:organization, billing_entities: [instance]) } + + trait :deleted do + deleted_at { Time.current } + end + + trait :archived do + archived_at { Time.current } + end + + trait :with_static_values do + name { "ACME Corporation" } + email { "billing@acme.com" } + address_line1 { "123 Business St" } + address_line2 { "Suite 100" } + city { "San Francisco" } + state { "CA" } + zipcode { "94105" } + country { "US" } + document_number_prefix { "ACM-8924" } + end + end +end diff --git a/spec/factories/billing_entity_applied_invoice_custom_sections.rb b/spec/factories/billing_entity_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..ce2886f --- /dev/null +++ b/spec/factories/billing_entity_applied_invoice_custom_sections.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :billing_entity_applied_invoice_custom_section, class: "BillingEntity::AppliedInvoiceCustomSection" do + organization + billing_entity { organization&.default_billing_entity || association(:billing_entity) } + invoice_custom_section { association(:invoice_custom_section, organization:) } + end +end diff --git a/spec/factories/billing_entity_applied_taxes.rb b/spec/factories/billing_entity_applied_taxes.rb new file mode 100644 index 0000000..17ec60b --- /dev/null +++ b/spec/factories/billing_entity_applied_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :billing_entity_applied_tax, class: "BillingEntity::AppliedTax" do + billing_entity + tax + organization { billing_entity&.organization || tax&.organization || association(:organization) } + end +end diff --git a/spec/factories/cached_aggregations.rb b/spec/factories/cached_aggregations.rb new file mode 100644 index 0000000..1c63c65 --- /dev/null +++ b/spec/factories/cached_aggregations.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :cached_aggregation do + organization + association :charge, factory: :standard_charge + event_transaction_id { SecureRandom.uuid } + external_subscription_id { SecureRandom.uuid } + timestamp { Time.current } + end +end diff --git a/spec/factories/charge_applied_taxes.rb b/spec/factories/charge_applied_taxes.rb new file mode 100644 index 0000000..d57fcbc --- /dev/null +++ b/spec/factories/charge_applied_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :charge_applied_tax, class: "Charge::AppliedTax" do + association :charge, factory: :standard_charge + tax + organization { charge&.organization || tax&.organization || association(:organization) } + end +end diff --git a/spec/factories/charge_filter_values.rb b/spec/factories/charge_filter_values.rb new file mode 100644 index 0000000..7ff0101 --- /dev/null +++ b/spec/factories/charge_filter_values.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :charge_filter_value do + transient do + billable_metric_filter { create(:billable_metric_filter) } + end + + charge_filter + organization { charge_filter&.organization || association(:organization) } + billable_metric_filter_id { billable_metric_filter.id } + values { [billable_metric_filter.values.sample] } + end +end diff --git a/spec/factories/charge_filters.rb b/spec/factories/charge_filters.rb new file mode 100644 index 0000000..6fa00b1 --- /dev/null +++ b/spec/factories/charge_filters.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :charge_filter do + transient do + charge { create(:standard_charge) } + end + + organization { charge&.organization || association(:organization) } + charge_id { charge.id } + properties { charge.properties } + end +end diff --git a/spec/factories/charges.rb b/spec/factories/charges.rb new file mode 100644 index 0000000..d65610b --- /dev/null +++ b/spec/factories/charges.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :charge do + billable_metric + plan + organization { billable_metric&.organization || plan&.organization || association(:organization) } + code { Faker::Alphanumeric.alphanumeric(number: 10) } + invoice_display_name { Faker::Fantasy::Tolkien.location } + + factory :standard_charge do + charge_model { "standard" } + properties do + {amount: Faker::Number.between(from: 100, to: 500).to_s} + end + end + + factory :graduated_charge do + charge_model { "graduated" } + properties do + {graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "0", flat_amount: "200"}, + {from_value: 11, to_value: nil, per_unit_amount: "0", flat_amount: "300"} + ]} + end + end + + factory :package_charge do + charge_model { "package" } + properties do + { + amount: "100", + free_units: 10, + package_size: 10 + } + end + end + + factory :percentage_charge do + charge_model { "percentage" } + properties do + { + rate: "0.0555", + fixed_amount: "2" + } + end + end + + factory :volume_charge do + charge_model { "volume" } + properties do + { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}, + {from_value: 101, to_value: nil, per_unit_amount: "1", flat_amount: "0"} + ] + } + end + end + + factory :dynamic_charge do + charge_model { "dynamic" } + billable_metric { create(:sum_billable_metric) } + properties do + {} + end + end + + factory :graduated_percentage_charge do + charge_model { "graduated_percentage" } + properties do + { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: 10, + rate: "0", + flat_amount: "200" + }, + { + from_value: 11, + to_value: nil, + rate: "0", + flat_amount: "300" + } + ] + } + end + end + + factory :custom_charge do + charge_model { "custom" } + properties do + {custom_properties: {rate: "20"}} + end + end + + trait :pay_in_advance do + pay_in_advance { true } + end + + trait :regroup_paid_fees do + pay_in_advance { true } + invoiceable { false } + regroup_paid_fees { "invoice" } + end + end +end diff --git a/spec/factories/clickhouse/activity_logs.rb b/spec/factories/clickhouse/activity_logs.rb new file mode 100644 index 0000000..05d772a --- /dev/null +++ b/spec/factories/clickhouse/activity_logs.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :clickhouse_activity_log, class: "Clickhouse::ActivityLog" do + transient do + membership { create(:membership) } + customer { create(:customer, organization: membership.organization) } + subscription { create(:subscription, customer:) } + metric { create(:billable_metric, organization: membership.organization) } + end + + organization { membership.organization } + resource { metric } + user_id { membership.user_id } + api_key_id { create(:api_key, organization: membership.organization).id } + external_customer_id { customer.external_id } + external_subscription_id { subscription.external_id } + activity_type { "billable_metric.created" } + activity_source { "api" } + logged_at { Time.current } + activity_object { {"foo" => "bar", "baz" => "qux"} } + activity_object_changes { {"foo" => '{"old": "bar", "new": "baz"}'} } + end +end diff --git a/spec/factories/clickhouse/api_logs.rb b/spec/factories/clickhouse/api_logs.rb new file mode 100644 index 0000000..ba4c849 --- /dev/null +++ b/spec/factories/clickhouse/api_logs.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :clickhouse_api_log, class: "Clickhouse::ApiLog" do + transient do + membership { create(:membership) } + end + + api_version { "v1" } + client { "RSpec" } + logged_at { Time.current } + request_body { {"foo" => "bar", "baz" => "qux"} } + http_method { "post" } + http_status { 200 } + request_origin { "https://lago.test" } + request_path { "/api/v1/test-endpoint" } + request_response { {"foo" => "bar"} } + api_key_id { create(:api_key, organization: membership.organization).id } + organization { membership.organization } + end +end diff --git a/spec/factories/clickhouse/events_dead_letter.rb b/spec/factories/clickhouse/events_dead_letter.rb new file mode 100644 index 0000000..2172edd --- /dev/null +++ b/spec/factories/clickhouse/events_dead_letter.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :clickhouse_events_dead_letter, class: "Clickhouse::EventsDeadLetter" do + transient do + subscription { create(:subscription, customer:) } + customer { create(:customer) } + organization { customer.organization } + billable_metric { create(:billable_metric, organization: organization) } + end + + organization_id { organization.id } + external_subscription_id { subscription.external_id } + code { billable_metric.code } + timestamp { Time.current } + failed_at { Time.current } + ingested_at { Time.current } + transaction_id { "tr_#{SecureRandom.hex}" } + error_code { "fetch_billable_metric" } + error_message { "Error fetching billable metric" } + initial_error_message { "record not found" } + event do + { + event: { + organization_id: organization_id, + external_subscription_id: external_subscription_id, + transaction_id: transaction_id, + code: code, + properties: { + value: 42 + }, + precise_total_amount_cents: "0.0", + source: "http_ruby", + timestamp: timestamp.to_f.to_s, + source_metadata: { + api_post_processed: true + }, + ingested_at: ingested_at.iso8601(3) + }, + initial_error_message: "record not found", + error_message: "Error fetching billable metric", + error_code: "fetch_billable_metric", + failed_at: failed_at.iso8601(3) + } + end + end +end diff --git a/spec/factories/clickhouse/events_enriched.rb b/spec/factories/clickhouse/events_enriched.rb new file mode 100644 index 0000000..1af9ab3 --- /dev/null +++ b/spec/factories/clickhouse/events_enriched.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :clickhouse_events_enriched, class: "Clickhouse::EventsEnriched" do + transient do + subscription { create(:subscription, customer:) } + customer { create(:customer) } + organization { customer.organization } + billable_metric { create(:billable_metric, organization: organization) } + end + + organization_id { organization.id } + external_subscription_id { subscription.external_id } + code { billable_metric.code } + timestamp { Time.current } + transaction_id { "tr_#{SecureRandom.hex}" } + properties { {} } + value { "21.0" } + decimal_value { 21.0 } + end +end diff --git a/spec/factories/clickhouse/events_enriched_expanded.rb b/spec/factories/clickhouse/events_enriched_expanded.rb new file mode 100644 index 0000000..06c5b6b --- /dev/null +++ b/spec/factories/clickhouse/events_enriched_expanded.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :clickhouse_events_enriched_expanded, class: "Clickhouse::EventsEnrichedExpanded" do + transient do + subscription { create(:subscription, customer:) } + customer { create(:customer) } + organization { customer.organization } + billable_metric { create(:billable_metric, organization:) } + charge { create(:standard_charge, billable_metric:, plan: subscription.plan) } + end + + organization_id { organization.id } + subscription_id { subscription.id } + external_subscription_id { subscription.external_id } + charge_id { charge.id } + charge_filter_id { "" } + code { billable_metric.code } + timestamp { Time.current } + transaction_id { "tr_#{SecureRandom.hex}" } + enriched_at { Time.current } + value { "21.0" } + aggregation_type { "sum_agg" } + grouped_by { {} } + properties { {} } + end +end diff --git a/spec/factories/clickhouse/security_logs.rb b/spec/factories/clickhouse/security_logs.rb new file mode 100644 index 0000000..a7c7bbe --- /dev/null +++ b/spec/factories/clickhouse/security_logs.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :clickhouse_security_log, class: "Clickhouse::SecurityLog" do + transient do + membership { create(:membership) } + end + + organization { membership.organization } + user_id { membership.user_id } + api_key_id { create(:api_key, organization: membership.organization).id } + log_type { "user" } + log_event { "user.signed_up" } + logged_at { Time.current } + device_info { {"browser" => "Chrome", "os" => "macOS"} } + resources { {"user_email" => "test@example.com"} } + end +end diff --git a/spec/factories/clickhouse_events.rb b/spec/factories/clickhouse_events.rb new file mode 100644 index 0000000..238ef6c --- /dev/null +++ b/spec/factories/clickhouse_events.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :clickhouse_events_raw, class: "Clickhouse::EventsRaw" do + transient do + organization { create(:organization) } + billable_metric { create(:billable_metric, organization: organization) } + subscription { create(:subscription, organization: organization) } + end + + organization_id { organization.id } + transaction_id { SecureRandom.uuid } + external_subscription_id { subscription.external_id } + timestamp { Time.current } + code { billable_metric.code } + properties { {} } + end +end diff --git a/spec/factories/commitment_applied_taxes.rb b/spec/factories/commitment_applied_taxes.rb new file mode 100644 index 0000000..3daa6e3 --- /dev/null +++ b/spec/factories/commitment_applied_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :commitment_applied_tax, class: "Commitment::AppliedTax" do + commitment + tax + organization { commitment&.organization || tax&.organization || association(:organization) } + end +end diff --git a/spec/factories/commitments.rb b/spec/factories/commitments.rb new file mode 100644 index 0000000..7bca3ec --- /dev/null +++ b/spec/factories/commitments.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :commitment do + plan + organization { plan&.organization || association(:organization) } + commitment_type { "minimum_commitment" } + amount_cents { 1_000 } + invoice_display_name { Faker::Subscription.plan } + + trait :minimum_commitment do + commitment_type { "minimum_commitment" } + end + end +end diff --git a/spec/factories/common.rb b/spec/factories/common.rb new file mode 100644 index 0000000..bafb829 --- /dev/null +++ b/spec/factories/common.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +FactoryBot.define do + sequence(:future_date) { rand(1..(10**7)).seconds.from_now } + sequence(:past_date) { rand(1..(10**7)).seconds.ago } +end diff --git a/spec/factories/common_events.rb b/spec/factories/common_events.rb new file mode 100644 index 0000000..3982adf --- /dev/null +++ b/spec/factories/common_events.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :common_event, class: "Events::Common" do + transient do + organization { create(:organization) } + billable_metric { create(:billable_metric, organization: organization) } + subscription { create(:subscription, organization: organization) } + end + + organization_id { organization.id } + transaction_id { SecureRandom.uuid } + external_subscription_id { subscription.external_id } + timestamp { Time.current } + code { billable_metric.code } + properties { {} } + end +end diff --git a/spec/factories/coupon_targets.rb b/spec/factories/coupon_targets.rb new file mode 100644 index 0000000..e864701 --- /dev/null +++ b/spec/factories/coupon_targets.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :coupon_plan, class: "CouponTarget" do + coupon + plan + organization { plan&.organization || coupon&.organization || association(:organization) } + end + + factory :coupon_billable_metric, class: "CouponTarget" do + coupon + billable_metric + organization { billable_metric&.organization || coupon&.organization || association(:organization) } + end +end diff --git a/spec/factories/coupons.rb b/spec/factories/coupons.rb new file mode 100644 index 0000000..62be949 --- /dev/null +++ b/spec/factories/coupons.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :coupon do + organization + name { Faker::Name.name } + code { Faker::Alphanumeric.alphanumeric(number: 10) } + coupon_type { "fixed_amount" } + status { "active" } + expiration { "no_expiration" } + amount_cents { 200 } + amount_currency { "EUR" } + frequency { "once" } + description { "Coupon Description" } + + trait :deleted do + deleted_at { 1.day.ago } + end + end +end diff --git a/spec/factories/credit_note_applied_taxes.rb b/spec/factories/credit_note_applied_taxes.rb new file mode 100644 index 0000000..f6ffe66 --- /dev/null +++ b/spec/factories/credit_note_applied_taxes.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :credit_note_applied_tax, class: "CreditNote::AppliedTax" do + credit_note + tax + organization { credit_note&.organization || tax&.organization || association(:organization) } + tax_code { "vat-#{SecureRandom.uuid}" } + tax_description { "French Standard VAT" } + tax_name { "VAT" } + tax_rate { 20.0 } + amount_cents { 200 } + amount_currency { "EUR" } + end +end diff --git a/spec/factories/credit_note_items.rb b/spec/factories/credit_note_items.rb new file mode 100644 index 0000000..b90a237 --- /dev/null +++ b/spec/factories/credit_note_items.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :credit_note_item do + credit_note + fee + organization { credit_note&.organization || fee&.organization || association(:organization) } + amount_cents { 100 } + precise_amount_cents { 100 } + amount_currency { "EUR" } + end +end diff --git a/spec/factories/credit_notes.rb b/spec/factories/credit_notes.rb new file mode 100644 index 0000000..2543c49 --- /dev/null +++ b/spec/factories/credit_notes.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :credit_note do + customer + invoice + organization { customer&.organization || invoice&.organization || association(:organization) } + + issuing_date { Time.zone.today } + + reason { "duplicated_charge" } + total_amount_cents { 120 } + total_amount_currency { "EUR" } + taxes_amount_cents { 20 } + + credit_status { "available" } + credit_amount_cents { 120 } + credit_amount_currency { "EUR" } + balance_amount_cents { 120 } + balance_amount_currency { "EUR" } + + trait :with_file do + after(:build) do |credit_note| + credit_note.file.attach( + io: File.open(Rails.root.join("spec/fixtures/blank.pdf")), + filename: "blank.pdf", + content_type: "application/pdf" + ) + end + end + + trait :draft do + status { :draft } + end + + trait :with_tax_error do + after :create do |i| + create(:error_detail, owner: i, error_code: "tax_error") + end + end + + trait :with_items do + items { create_pair(:credit_note_item) } + end + + trait :with_metadata do + after(:create) do |credit_note| + credit_note.create_metadata!( + organization_id: credit_note.organization_id, + value: {"key" => "value"} + ) + end + end + end +end diff --git a/spec/factories/credits.rb b/spec/factories/credits.rb new file mode 100644 index 0000000..2888139 --- /dev/null +++ b/spec/factories/credits.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :credit do + invoice + applied_coupon + organization { invoice&.organization || applied_coupon&.organization || association(:organization) } + + amount_cents { 200 } + amount_currency { "EUR" } + end + + factory :credit_note_credit, class: "Credit" do + invoice + credit_note + organization { invoice&.organization || credit_note&.organization || association(:organization) } + + amount_cents { 200 } + amount_currency { "EUR" } + end + + factory :progressive_billing_invoice_credit, class: "Credit" do + invoice + progressive_billing_invoice factory: :invoice + organization { invoice&.organization || association(:organization) } + + amount_cents { 200 } + amount_currency { "EUR" } + end +end diff --git a/spec/factories/customer_applied_invoice_custom_sections.rb b/spec/factories/customer_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..8dc922a --- /dev/null +++ b/spec/factories/customer_applied_invoice_custom_sections.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :customer_applied_invoice_custom_section, class: "Customer::AppliedInvoiceCustomSection" do + organization + billing_entity { organization&.default_billing_entity || association(:billing_entity) } + customer { association(:customer, organization:, billing_entity:) } + invoice_custom_section { association(:invoice_custom_section, organization:) } + end +end diff --git a/spec/factories/customer_applied_taxes.rb b/spec/factories/customer_applied_taxes.rb new file mode 100644 index 0000000..dde3b36 --- /dev/null +++ b/spec/factories/customer_applied_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :customer_applied_tax, class: "Customer::AppliedTax" do + customer + tax + organization { customer&.organization || tax&.organization || association(:organization) } + end +end diff --git a/spec/factories/customer_metadata.rb b/spec/factories/customer_metadata.rb new file mode 100644 index 0000000..c7209d1 --- /dev/null +++ b/spec/factories/customer_metadata.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :customer_metadata, class: "Metadata::CustomerMetadata" do + customer + organization { customer&.organization || association(:organization) } + + key { "lead_name" } + value { "John Doe" } + display_in_invoice { true } + end +end diff --git a/spec/factories/customers.rb b/spec/factories/customers.rb new file mode 100644 index 0000000..afaa90b --- /dev/null +++ b/spec/factories/customers.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :customer do + organization + billing_entity { organization&.default_billing_entity || association(:billing_entity) } + name { Faker::TvShows::SiliconValley.character } + firstname { Faker::Name.first_name } + lastname { Faker::Name.last_name } + customer_type { nil } + external_id { SecureRandom.uuid } + country { Faker::Address.country_code } + address_line1 { Faker::Address.street_address } + address_line2 { Faker::Address.secondary_address } + state { Faker::Address.state } + zipcode { Faker::Address.zip_code } + email { Faker::Internet.email } + city { Faker::Address.city } + url { Faker::Internet.url } + phone { Faker::PhoneNumber.phone_number } + logo_url { Faker::Internet.url } + legal_name { Faker::Company.name } + legal_number { Faker::Company.duns_number } + currency { "EUR" } + + trait :with_shipping_address do + shipping_address_line1 { Faker::Address.street_address } + shipping_address_line2 { Faker::Address.secondary_address } + shipping_city { Faker::Address.city } + shipping_zipcode { Faker::Address.zip_code } + shipping_state { Faker::Address.state } + shipping_country { Faker::Address.country_code } + end + + trait :with_same_billing_and_shipping_address do + shipping_address_line1 { address_line1 } + shipping_address_line2 { address_line2 } + shipping_city { city } + shipping_zipcode { zipcode } + shipping_state { state } + shipping_country { country } + end + + trait :with_tax_integration do + after :create do |customer| + create(:anrok_customer, customer:) + end + end + + trait :with_hubspot_integration do + after :create do |customer| + create(:hubspot_customer, customer:) + end + end + + trait :with_salesforce_integration do + after :create do |customer| + create(:salesforce_customer, customer:) + end + end + + trait :with_inherited_invoice_custom_sections do + organization { create(:organization, :with_invoice_custom_sections) } + end + + trait :with_stripe_payment_provider do + payment_provider { "stripe" } + payment_provider_code { Faker::Lorem.word } + + after(:create) do |customer| + payment_provider = build( + :stripe_provider, + organization: customer.organization, + code: customer.payment_provider_code + ) + + create(:stripe_customer, customer:, payment_provider:) + end + end + + trait :with_static_values do + with_same_billing_and_shipping_address + + firstname { "John" } + lastname { "Doe" } + name { "John Doe" } + legal_name { "Doe Corp" } + legal_number { "1234567890" } + external_id { "customer_123" } + email { "john.doe@example.com" } + address_line1 { "456 Customer Ave" } + address_line2 { "Apt 202" } + city { "New York" } + state { "NY" } + zipcode { "10001" } + country { "US" } + phone { "+1-555-123-4567" } + end + end +end diff --git a/spec/factories/daily_usages.rb b/spec/factories/daily_usages.rb new file mode 100644 index 0000000..ee7c364 --- /dev/null +++ b/spec/factories/daily_usages.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :daily_usage do + customer + organization { customer.organization } + subscription { create(:subscription, customer:) } + + external_subscription_id { subscription.external_id } + from_datetime { Time.current.beginning_of_month } + to_datetime { Time.current.end_of_month } + refreshed_at { Time.current } + usage { {} } + usage_date { Date.yesterday } + end +end diff --git a/spec/factories/data_export_parts.rb b/spec/factories/data_export_parts.rb new file mode 100644 index 0000000..bc7c0bc --- /dev/null +++ b/spec/factories/data_export_parts.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :data_export_part do + data_export + organization { data_export&.organization || association(:organization) } + + index { 0 } + object_ids { [] } + end +end diff --git a/spec/factories/data_exports.rb b/spec/factories/data_exports.rb new file mode 100644 index 0000000..f18bfa1 --- /dev/null +++ b/spec/factories/data_exports.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :data_export do + organization + membership { association :membership, organization: organization } + + format { "csv" } + resource_type { "invoices" } + resource_query { {currency: "EUR"} } + status { "pending" } + file { nil } + + trait :processing do + status { "processing" } + started_at { 2.hours.ago } + end + + trait :completed do + with_file + status { "completed" } + started_at { 2.hours.ago } + completed_at { 30.minutes.ago } + expires_at { 7.days.from_now } + end + + trait :expired do + completed + expires_at { 1.day.ago } + end + + trait :with_file do + file { Rack::Test::UploadedFile.new(Rails.root.join("spec/fixtures/export.csv")) } + end + end +end diff --git a/spec/factories/dunning_campaign_thresholds.rb b/spec/factories/dunning_campaign_thresholds.rb new file mode 100644 index 0000000..c8ea6d1 --- /dev/null +++ b/spec/factories/dunning_campaign_thresholds.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :dunning_campaign_threshold do + dunning_campaign + organization { dunning_campaign&.organization || association(:organization) } + + currency { "USD" } + amount_cents { 1000 } + end +end diff --git a/spec/factories/dunning_campaigns.rb b/spec/factories/dunning_campaigns.rb new file mode 100644 index 0000000..8ea816a --- /dev/null +++ b/spec/factories/dunning_campaigns.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :dunning_campaign do + organization + name { Faker::Name.name } + code { SecureRandom.uuid } + days_between_attempts { Faker::Number.number(digits: 2) } + max_attempts { Faker::Number.number(digits: 2) } + end +end diff --git a/spec/factories/enriched_events.rb b/spec/factories/enriched_events.rb new file mode 100644 index 0000000..91f472c --- /dev/null +++ b/spec/factories/enriched_events.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :enriched_event do + transient do + subscription { create(:subscription) } + charge { create(:standard_charge, plan_id: subscription.plan_id) } + end + + event { create(:event, organization_id: subscription.organization_id) } + + code { event.code } + timestamp { event.timestamp } + transaction_id { event.transaction_id } + external_subscription_id { subscription.external_id } + + organization_id { event.organization_id } + subscription_id { subscription.id } + plan_id { subscription.plan_id } + charge_id { charge.id } + + enriched_at { Time.current } + end +end diff --git a/spec/factories/enriched_store_migrations.rb b/spec/factories/enriched_store_migrations.rb new file mode 100644 index 0000000..af72d84 --- /dev/null +++ b/spec/factories/enriched_store_migrations.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :enriched_store_migration do + organization + + trait :checking do + status { :checking } + started_at { Time.current } + end + + trait :processing do + status { :processing } + started_at { Time.current } + end + + trait :enabling do + status { :enabling } + started_at { Time.current } + end + + trait :completed do + status { :completed } + started_at { 1.hour.ago } + completed_at { Time.current } + end + + trait :failed do + status { :failed } + started_at { Time.current } + error_message { "Something went wrong" } + end + end +end diff --git a/spec/factories/enriched_store_subscription_migrations.rb b/spec/factories/enriched_store_subscription_migrations.rb new file mode 100644 index 0000000..8ec915d --- /dev/null +++ b/spec/factories/enriched_store_subscription_migrations.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :enriched_store_subscription_migration do + enriched_store_migration + organization { enriched_store_migration&.organization || association(:organization) } + subscription { association(:subscription, organization: organization) } + + trait :comparing do + status { :comparing } + started_at { Time.current } + end + + trait :reprocessing do + status { :reprocessing } + started_at { Time.current } + end + + trait :waiting_for_enrichment do + status { :waiting_for_enrichment } + started_at { Time.current } + end + + trait :deduplicating do + status { :deduplicating } + started_at { Time.current } + end + + trait :dedup_paused do + status { :dedup_paused } + started_at { Time.current } + dedup_pending_queries { ["DELETE FROM events_enriched_expanded..."] } + end + + trait :validating do + status { :validating } + started_at { Time.current } + end + + trait :completed do + status { :completed } + started_at { 1.hour.ago } + completed_at { Time.current } + end + + trait :failed do + status { :failed } + started_at { Time.current } + error_message { "Something went wrong" } + end + end +end diff --git a/spec/factories/entitlement/entitlement_values.rb b/spec/factories/entitlement/entitlement_values.rb new file mode 100644 index 0000000..475d1c8 --- /dev/null +++ b/spec/factories/entitlement/entitlement_values.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :entitlement_value, class: "Entitlement::EntitlementValue" do + organization { entitlement&.organization || privilege&.organization || association(:organization) } + association :privilege, factory: :privilege + association :entitlement, factory: :entitlement + value { "test_value" } + end +end diff --git a/spec/factories/entitlement/entitlements.rb b/spec/factories/entitlement/entitlements.rb new file mode 100644 index 0000000..ec14256 --- /dev/null +++ b/spec/factories/entitlement/entitlements.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :entitlement, class: "Entitlement::Entitlement" do + organization { feature&.organization || plan&.organization || association(:organization) } + association :feature, factory: :feature + association :plan + + trait :subscription do + plan { nil } + association :subscription + end + end +end diff --git a/spec/factories/entitlement/features.rb b/spec/factories/entitlement/features.rb new file mode 100644 index 0000000..7abf676 --- /dev/null +++ b/spec/factories/entitlement/features.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :feature, class: "Entitlement::Feature" do + association :organization + sequence(:code) { |n| "feature_#{n}" } + name { "Feature Name" } + description { "Feature Description" } + end +end diff --git a/spec/factories/entitlement/privileges.rb b/spec/factories/entitlement/privileges.rb new file mode 100644 index 0000000..14460d7 --- /dev/null +++ b/spec/factories/entitlement/privileges.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :privilege, class: "Entitlement::Privilege" do + organization { feature&.organization || association(:organization) } + association :feature, factory: :feature + sequence(:code) { |n| "privilege_#{n}" } + name { nil } + value_type { "string" } + config { {} } + end + + trait :integer_type do + code { "int" } + value_type { "integer" } + end + + trait :string_type do + code { "str" } + value_type { "string" } + end + + trait :boolean_type do + code { "bool" } + value_type { "boolean" } + end + + trait :select_type do + code { "opt" } + value_type { "select" } + config { {select_options: ["option1", "option2", "option3"]} } + end +end diff --git a/spec/factories/entitlement/subscription_feature_removals.rb b/spec/factories/entitlement/subscription_feature_removals.rb new file mode 100644 index 0000000..353fa41 --- /dev/null +++ b/spec/factories/entitlement/subscription_feature_removals.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :subscription_feature_removal, class: "Entitlement::SubscriptionFeatureRemoval" do + organization { feature&.organization || privilege&.organization || association(:organization) } + subscription { association(:subscription, organization:) } + + entitlement_feature_id { nil } + entitlement_privilege_id { nil } + end +end diff --git a/spec/factories/error_details.rb b/spec/factories/error_details.rb new file mode 100644 index 0000000..d01158a --- /dev/null +++ b/spec/factories/error_details.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :error_detail do + organization + association :owner, factory: %i[invoice].sample + end +end diff --git a/spec/factories/events.rb b/spec/factories/events.rb new file mode 100644 index 0000000..d2acf1d --- /dev/null +++ b/spec/factories/events.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :event do + transient do + subscription { create(:subscription) } + customer { subscription.customer } + end + + organization_id { create(:organization).id } + + transaction_id { "tr_#{SecureRandom.hex}" } + code { Faker::Alphanumeric.alphanumeric(number: 10) } + timestamp { Time.current } + + external_subscription_id { subscription.external_id } + end + + factory :received_event, class: "Event" do + transient do + source_organization { create(:organization) } + source_customer { create(:customer, organization: source_organization) } + source_subscription do + create( + :subscription, + customer: source_customer, + organization: source_organization + ) + end + end + + organization_id { source_organization.id } + external_subscription_id { source_subscription.external_id } + + transaction_id { "tr_#{SecureRandom.hex}" } + code { Faker::Alphanumeric.alphanumeric(number: 10) } + timestamp { Time.current } + end +end diff --git a/spec/factories/fee_applied_taxes.rb b/spec/factories/fee_applied_taxes.rb new file mode 100644 index 0000000..9a17988 --- /dev/null +++ b/spec/factories/fee_applied_taxes.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :fee_applied_tax, class: "Fee::AppliedTax" do + fee + tax + organization { fee&.organization || tax&.organization || association(:organization) } + + tax_code { tax&.code.presence || "vat-#{SecureRandom.uuid}" } + tax_description { "French Standard VAT" } + tax_name { "VAT" } + tax_rate { 20.0 } + amount_cents { 200 } + amount_currency { "EUR" } + transient do + provider_tax_breakdown_object { nil } + end + + trait :with_provider_tax do + tax_description { provider_tax_breakdown_object.type } + tax_code { provider_tax_breakdown_object.name.parameterize(separator: "_") } + tax_name { provider_tax_breakdown_object.name } + tax_rate { provider_tax_breakdown_object.rate } + end + end +end diff --git a/spec/factories/fees.rb b/spec/factories/fees.rb new file mode 100644 index 0000000..fffde25 --- /dev/null +++ b/spec/factories/fees.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :fee do + invoice + charge { nil } + fixed_charge { nil } + add_on { nil } + fee_type { "subscription" } + subscription + organization { invoice&.organization || subscription&.organization || association(:organization) } + billing_entity { invoice&.billing_entity || subscription&.customer&.billing_entity || association(:billing_entity) } + + amount_cents { 200 } + precise_amount_cents { 200.0000000001 } + amount_currency { "EUR" } + taxes_amount_cents { 2 } + taxes_precise_amount_cents { 2.0000000001 } + + invoiceable_type { "Subscription" } + invoiceable_id { subscription.id } + + invoice_display_name { Faker::Fantasy::Tolkien.character } + + trait :succeeded do + payment_status { :succeeded } + succeeded_at { Time.current } + end + + trait :failed do + payment_status { :failed } + failed_at { Time.current } + end + + trait :refunded do + payment_status { :refunded } + refunded_at { Time.current } + end + end + + factory :charge_fee, parent: :fee do + invoice + charge factory: :standard_charge + fee_type { "charge" } + + invoiceable_type { "Charge" } + invoiceable_id { charge.id } + + properties do + { + "timestamp" => Date.parse("2022-08-01 00:03:24"), + "from_datetime" => Date.parse("2022-08-01 00:00:00"), + "to_datetime" => Date.parse("2022-08-31 23:59:59"), + "charges_from_datetime" => Date.parse("2022-08-01 00:00:00"), + "charges_to_datetime" => Date.parse("2022-08-31 23:59:59"), + "charges_duration" => 31 + } + end + + total_aggregated_units { 0 } + + trait :with_charge_filter do + charge_filter + end + end + + factory :add_on_fee, class: "Fee" do + invoice + applied_add_on + fee_type { "add_on" } + subscription { nil } + + organization { invoice&.organization || association(:organization) } + billing_entity { invoice&.billing_entity || association(:billing_entity) } + + amount_cents { 200 } + amount_currency { "EUR" } + taxes_amount_cents { 2 } + + invoiceable_type { "AppliedAddOn" } + invoiceable_id { applied_add_on.id } + end + + factory :one_off_fee, class: "Fee" do + invoice + add_on + fee_type { "add_on" } + subscription { nil } + + organization { invoice&.organization || association(:organization) } + billing_entity { invoice&.billing_entity || association(:billing_entity) } + + amount_cents { 200 } + amount_currency { "EUR" } + taxes_amount_cents { 2 } + + invoiceable_type { "AddOn" } + invoiceable_id { add_on.id } + end + + factory :minimum_commitment_fee, class: "Fee" do + invoice + fee_type { "commitment" } + subscription { invoice&.subscriptions&.first || association(:subscription) } + + organization { invoice&.organization || association(:organization) } + billing_entity { invoice&.billing_entity || association(:billing_entity) } + + amount_cents { 200 } + amount_currency { "EUR" } + taxes_amount_cents { 2 } + + transient do + commitment { subscription.plan.minimum_commitment.presence || create(:commitment, plan: subscription.plan) } + end + + invoiceable_type { "Commitment" } + invoiceable_id { commitment.id } + end + + factory :fixed_charge_fee, class: "Fee" do + invoice + fee_type { "fixed_charge" } + subscription { nil } + + organization { invoice&.organization || association(:organization) } + billing_entity { invoice&.billing_entity || association(:billing_entity) } + + amount_cents { 200 } + precise_amount_cents { 200.0000000001 } + amount_currency { "EUR" } + taxes_amount_cents { 2 } + taxes_precise_amount_cents { 2.0000000001 } + + invoiceable_type { "FixedCharge" } + invoiceable_id { fixed_charge.id } + + invoice_display_name { Faker::Fantasy::Tolkien.character } + + properties do + { + "timestamp" => Date.parse("2022-08-01 00:03:24"), + "from_datetime" => Date.parse("2022-08-01 00:00:00"), + "to_datetime" => Date.parse("2022-08-31 23:59:59"), + "charges_from_datetime" => Date.parse("2022-08-01 00:00:00"), + "charges_to_datetime" => Date.parse("2022-08-31 23:59:59"), + "fixed_charges_from_datetime" => Date.parse("2022-07-01 00:00:00"), + "fixed_charges_to_datetime" => Date.parse("2022-07-31 23:59:59") + } + end + + transient do + fixed_charge { create(:fixed_charge) } + end + + after(:build) do |fee, evaluator| + fee.write_attribute(:fixed_charge_id, evaluator.fixed_charge.id) + end + end + + factory :credit_fee, parent: :fee do + transient do + wallet_transaction { association(:wallet_transaction, organization:) } + end + fee_type { "credit" } + invoiceable_id { wallet_transaction.id } + invoiceable_type { "WalletTransaction" } + subscription { nil } + charge { nil } + end +end diff --git a/spec/factories/fixed_charge_applied_taxes.rb b/spec/factories/fixed_charge_applied_taxes.rb new file mode 100644 index 0000000..5f54368 --- /dev/null +++ b/spec/factories/fixed_charge_applied_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :fixed_charge_applied_tax, class: "FixedCharge::AppliedTax" do + fixed_charge + tax + organization { fixed_charge.organization || create(:organization) } + end +end diff --git a/spec/factories/fixed_charge_events.rb b/spec/factories/fixed_charge_events.rb new file mode 100644 index 0000000..c0e2a22 --- /dev/null +++ b/spec/factories/fixed_charge_events.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :fixed_charge_event do + organization { subscription&.organization || association(:organization) } + subscription + fixed_charge + units { "9.99" } + timestamp { Time.current } + deleted_at { nil } + end +end diff --git a/spec/factories/fixed_charges.rb b/spec/factories/fixed_charges.rb new file mode 100644 index 0000000..cb36e9d --- /dev/null +++ b/spec/factories/fixed_charges.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :fixed_charge do + organization { add_on&.organization || plan&.organization || association(:organization) } + plan + add_on + code { Faker::Alphanumeric.alphanumeric(number: 10) } + charge_model { "standard" } + units { 1 } + properties { {amount: Faker::Number.between(from: 100, to: 500).to_s} } + invoice_display_name { Faker::Fantasy::Tolkien.location } + + trait :pay_in_advance do + pay_in_advance { true } + end + + trait :graduated do + charge_model { "graduated" } + properties do + { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "5", flat_amount: "200"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "300"} + ] + } + end + end + + trait :volume do + charge_model { "volume" } + properties do + { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}, + {from_value: 101, to_value: nil, per_unit_amount: "1", flat_amount: "0"} + ] + } + end + end + + trait :deleted do + deleted_at { Time.current } + end + + trait :with_applied_taxes do + transient do + taxes { [create(:tax)] } + end + + after(:create) do |fixed_charge, evaluator| + evaluator.taxes.each do |tax| + create(:fixed_charge_applied_tax, fixed_charge:, tax:) + end + end + end + end +end diff --git a/spec/factories/idempotency_records.rb b/spec/factories/idempotency_records.rb new file mode 100644 index 0000000..5553a62 --- /dev/null +++ b/spec/factories/idempotency_records.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :idempotency_record do + organization + idempotency_key { SecureRandom.uuid } + resource { nil } + end +end diff --git a/spec/factories/images/big_sized_logo.jpg b/spec/factories/images/big_sized_logo.jpg new file mode 100644 index 0000000..1aa016b Binary files /dev/null and b/spec/factories/images/big_sized_logo.jpg differ diff --git a/spec/factories/images/logo.gif b/spec/factories/images/logo.gif new file mode 100644 index 0000000..415ecd7 Binary files /dev/null and b/spec/factories/images/logo.gif differ diff --git a/spec/factories/images/logo.png b/spec/factories/images/logo.png new file mode 100644 index 0000000..2043a6c Binary files /dev/null and b/spec/factories/images/logo.png differ diff --git a/spec/factories/inbound_webhooks.rb b/spec/factories/inbound_webhooks.rb new file mode 100644 index 0000000..5b165b7 --- /dev/null +++ b/spec/factories/inbound_webhooks.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :inbound_webhook do + organization + + source { "stripe" } + event_type { "payment_intent.succeeded" } + status { "pending" } + code { "webhook-endpoint-code" } + signature { "MySignature" } + + payload { "{}" } + end +end diff --git a/spec/factories/integration_collection_mappings.rb b/spec/factories/integration_collection_mappings.rb new file mode 100644 index 0000000..e0e070c --- /dev/null +++ b/spec/factories/integration_collection_mappings.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :netsuite_collection_mapping, class: "IntegrationCollectionMappings::NetsuiteCollectionMapping" do + association :integration, factory: :netsuite_integration + mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit].sample } + organization { integration&.organization || association(:organization) } + billing_entity { nil } + + settings do + { + external_id: "netsuite-123", + external_account_code: "netsuite-code-1", + external_name: "Credits and Discounts", + tax_nexus: "tax-nexus-1", + tax_type: "tax-type-1", + tax_code: "tax-code-1" + } + end + end + + factory :netsuite_currencies_mapping, class: "IntegrationCollectionMappings::NetsuiteCollectionMapping" do + association :integration, factory: :netsuite_integration + organization { integration&.organization || association(:organization) } + + mapping_type { :currencies } + settings do + { + currencies: { + "EUR" => "3", + "USD" => "7" + } + } + end + end + + factory :xero_collection_mapping, class: "IntegrationCollectionMappings::XeroCollectionMapping" do + association :integration, factory: :xero_integration + mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit account].sample } + organization { integration&.organization || association(:organization) } + billing_entity { nil } + + settings do + { + external_id: "xero-123", + external_account_code: "xero-code-1", + external_name: "Credits and Discounts" + } + end + end + + factory :anrok_collection_mapping, class: "IntegrationCollectionMappings::AnrokCollectionMapping" do + association :integration, factory: :anrok_integration + mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit account].sample } + organization { integration&.organization || association(:organization) } + billing_entity { nil } + + settings do + { + external_id: "anrok-123", + external_account_code: "anrok-code-1", + external_name: "Credits and Discounts" + } + end + end + + factory :avalara_collection_mapping, class: "IntegrationCollectionMappings::AvalaraCollectionMapping" do + association :integration, factory: :avalara_integration + mapping_type { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit account].sample } + organization { integration&.organization || association(:organization) } + billing_entity { nil } + + settings do + { + external_id: "avalara-123", + external_account_code: "avalara-code-1", + external_name: "Credits and Discounts" + } + end + end +end diff --git a/spec/factories/integration_customers.rb b/spec/factories/integration_customers.rb new file mode 100644 index 0000000..1826549 --- /dev/null +++ b/spec/factories/integration_customers.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :netsuite_customer, class: "IntegrationCustomers::NetsuiteCustomer" do + association :integration, factory: :netsuite_integration + customer + organization { customer&.organization || integration&.organization || association(:organization) } + type { "IntegrationCustomers::NetsuiteCustomer" } + external_customer_id { SecureRandom.uuid } + + settings do + {sync_with_provider: true, subsidiary_id: Faker::Number.number(digits: 3)} + end + end + + factory :anrok_customer, class: "IntegrationCustomers::AnrokCustomer" do + association :integration, factory: :anrok_integration + customer + organization { customer&.organization || integration&.organization || association(:organization) } + type { "IntegrationCustomers::AnrokCustomer" } + external_customer_id { SecureRandom.uuid } + + settings do + {sync_with_provider: true} + end + end + + factory :avalara_customer, class: "IntegrationCustomers::AvalaraCustomer" do + association :integration, factory: :avalara_integration + customer + organization { customer&.organization || integration&.organization || association(:organization) } + type { "IntegrationCustomers::AvalaraCustomer" } + external_customer_id { SecureRandom.uuid } + + settings do + {sync_with_provider: true} + end + end + + factory :xero_customer, class: "IntegrationCustomers::XeroCustomer" do + association :integration, factory: :xero_integration + customer + organization { customer&.organization || integration&.organization || association(:organization) } + type { "IntegrationCustomers::XeroCustomer" } + external_customer_id { SecureRandom.uuid } + + settings do + {sync_with_provider: true} + end + end + + factory :hubspot_customer, class: "IntegrationCustomers::HubspotCustomer" do + association :integration, factory: :hubspot_integration + customer + organization { customer&.organization || integration&.organization || association(:organization) } + type { "IntegrationCustomers::HubspotCustomer" } + external_customer_id { SecureRandom.uuid } + + settings do + { + sync_with_provider: true, + email: Faker::Internet.email, + targeted_object: Integrations::HubspotIntegration::TARGETED_OBJECTS.sample + } + end + end + + factory :salesforce_customer, class: "IntegrationCustomers::SalesforceCustomer" do + association :integration, factory: :salesforce_integration + customer + organization { customer&.organization || integration&.organization || association(:organization) } + type { "IntegrationCustomers::SalesforceCustomer" } + external_customer_id { SecureRandom.uuid } + settings { {} } + end +end diff --git a/spec/factories/integration_items.rb b/spec/factories/integration_items.rb new file mode 100644 index 0000000..a5bb1b4 --- /dev/null +++ b/spec/factories/integration_items.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :integration_item do + association :integration, factory: :netsuite_integration + organization { integration&.organization || association(:organization) } + item_type { "standard" } + external_name { "test name" } + external_account_code { "test_code" } + external_id { SecureRandom.uuid } + end +end diff --git a/spec/factories/integration_mappings.rb b/spec/factories/integration_mappings.rb new file mode 100644 index 0000000..59216b9 --- /dev/null +++ b/spec/factories/integration_mappings.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +FactoryBot.define do + [ + :netsuite, + :xero, + :anrok, + :avalara + ].each do |integration_type| + factory "#{integration_type}_mapping", class: "IntegrationMappings::#{integration_type.to_s.classify}Mapping" do + association :integration, factory: "#{integration_type}_integration" + association :mappable, factory: :add_on + organization { integration&.organization || association(:organization) } + billing_entity { nil } + + settings do + { + external_id: "#{integration_type}-123", + external_account_code: "#{integration_type}-code-1", + external_name: "Credits and Discounts" + } + end + end + end +end diff --git a/spec/factories/integration_resources.rb b/spec/factories/integration_resources.rb new file mode 100644 index 0000000..218601c --- /dev/null +++ b/spec/factories/integration_resources.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :integration_resource do + association :syncable, factory: %i[invoice payment credit_note].sample + association :integration, factory: :netsuite_integration + organization { integration&.organization || association(:organization) } + external_id { SecureRandom.uuid } + end +end diff --git a/spec/factories/integrations.rb b/spec/factories/integrations.rb new file mode 100644 index 0000000..eb8c34c --- /dev/null +++ b/spec/factories/integrations.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :netsuite_integration, class: "Integrations::NetsuiteIntegration" do + organization + type { "Integrations::NetsuiteIntegration" } + code { "netsuite_#{SecureRandom.uuid}" } + name { "Accounting integration 1" } + + secrets do + {connection_id: SecureRandom.uuid, client_secret: SecureRandom.uuid}.to_json + end + + settings do + {account_id: "acc_12345", client_id: "cli_12345", script_endpoint_url: Faker::Internet.url, legacy_script: false} + end + end + + factory :okta_integration, class: "Integrations::OktaIntegration" do + organization + type { "Integrations::OktaIntegration" } + code { "okta" } + name { "Okta Integration" } + + settings do + {client_id: SecureRandom.uuid, domain: "foo.test", organization_name: "Foobar"} + end + + secrets do + {client_secret: SecureRandom.uuid}.to_json + end + end + + factory :anrok_integration, class: "Integrations::AnrokIntegration" do + organization + type { "Integrations::AnrokIntegration" } + code { "anrok" } + name { "Anrok Integration" } + + secrets do + {connection_id: SecureRandom.uuid, api_key: SecureRandom.uuid}.to_json + end + end + + factory :avalara_integration, class: "Integrations::AvalaraIntegration" do + organization + type { "Integrations::AvalaraIntegration" } + code { "avalara" } + name { "Avalara Integration" } + + settings do + {account_id: SecureRandom.uuid, company_code: "DEFAULT"} + end + + secrets do + {connection_id: SecureRandom.uuid, license_key: SecureRandom.uuid}.to_json + end + end + + factory :xero_integration, class: "Integrations::XeroIntegration" do + organization + type { "Integrations::XeroIntegration" } + code { "xero" } + name { "Xero Integration" } + + secrets do + {connection_id: SecureRandom.uuid}.to_json + end + end + + factory :hubspot_integration, class: "Integrations::HubspotIntegration" do + organization + type { "Integrations::HubspotIntegration" } + code { "hubspot" } + name { "Hubspot Integration" } + + settings do + { + default_targeted_object: "companies", + sync_subscriptions: true, + sync_invoices: true, + subscriptions_object_type_id: Faker::Number.number(digits: 2), + invoices_object_type_id: Faker::Number.number(digits: 2), + companies_properties_version: 1, + contacts_properties_version: 1, + subscriptions_properties_version: 1, + invoices_properties_version: 1 + } + end + + secrets do + {connection_id: SecureRandom.uuid}.to_json + end + end + + factory :salesforce_integration, class: "Integrations::SalesforceIntegration" do + organization + type { "Integrations::SalesforceIntegration" } + code { "salesforce" } + name { "Salesforce Integration" } + + settings do + {instance_id: SecureRandom.uuid} + end + end +end diff --git a/spec/factories/invites.rb b/spec/factories/invites.rb new file mode 100644 index 0000000..73b3234 --- /dev/null +++ b/spec/factories/invites.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :invite do + organization + + status { "pending" } + email { Faker::Internet.email } + token { SecureRandom.hex(20) } + roles { %w[admin] } + + after(:build) do |invite| + existing_codes = Role.with_code(*invite.roles).with_organization(invite.organization.id).pluck(:code) + missing_roles = invite.roles.reject { |code| existing_codes.include?(code) } + missing_roles.each { |code| create(:role, organization: invite.organization, code:) } + end + end +end diff --git a/spec/factories/invoice_applied_taxes.rb b/spec/factories/invoice_applied_taxes.rb new file mode 100644 index 0000000..05ed8df --- /dev/null +++ b/spec/factories/invoice_applied_taxes.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :invoice_applied_tax, class: "Invoice::AppliedTax" do + invoice + tax + organization { invoice&.organization || tax&.organization || association(:organization) } + tax_code { tax&.code.presence || "vat-#{SecureRandom.uuid}" } + tax_description { "French Standard VAT" } + tax_name { "VAT" } + tax_rate { tax&.rate || 20.0 } + amount_cents { 200 } + amount_currency { "EUR" } + + transient do + provider_tax_breakdown_object { nil } + end + + trait :with_provider_tax do + tax_description { provider_tax_breakdown_object.type } + tax_code { provider_tax_breakdown_object.name.parameterize(separator: "_") } + tax_name { provider_tax_breakdown_object.name } + tax_rate { provider_tax_breakdown_object.rate } + end + end +end diff --git a/spec/factories/invoice_custom_sections.rb b/spec/factories/invoice_custom_sections.rb new file mode 100644 index 0000000..8ce5046 --- /dev/null +++ b/spec/factories/invoice_custom_sections.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :invoice_custom_section do + organization + code { Faker::Lorem.words(number: 3).join("_") } + name { Faker::Lorem.words(number: 3).join(" ") } + display_name { Faker::Lorem.words(number: 3).join(" ") } + details { "These details are shown in the invoice" } + + trait :system_generated do + section_type { "system_generated" } + end + end +end diff --git a/spec/factories/invoice_metadata.rb b/spec/factories/invoice_metadata.rb new file mode 100644 index 0000000..2d62ca7 --- /dev/null +++ b/spec/factories/invoice_metadata.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :invoice_metadata, class: "Metadata::InvoiceMetadata" do + invoice + organization { invoice&.organization || association(:organization) } + + key { Faker::Commerce.color } + value { rand(100) } + end +end diff --git a/spec/factories/invoice_settlements.rb b/spec/factories/invoice_settlements.rb new file mode 100644 index 0000000..d1296a0 --- /dev/null +++ b/spec/factories/invoice_settlements.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :invoice_settlement do + organization + billing_entity + association :target_invoice, factory: :invoice + + amount_cents { 10_000 } + amount_currency { "EUR" } + settlement_type { :payment } + + trait :with_payment do + settlement_type { :payment } + source_payment { association(:payment) } + end + + trait :with_credit_note do + settlement_type { :credit_note } + source_credit_note { association(:credit_note) } + end + end +end diff --git a/spec/factories/invoice_subscriptions.rb b/spec/factories/invoice_subscriptions.rb new file mode 100644 index 0000000..3d4a520 --- /dev/null +++ b/spec/factories/invoice_subscriptions.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :invoice_subscription do + subscription + invoice + organization { subscription&.organization || invoice&.organization || association(:organization) } + + recurring { false } + + trait :boundaries do + timestamp { Time.current } + + from_datetime { timestamp.beginning_of_month } + to_datetime { timestamp.end_of_month } + charges_from_datetime { from_datetime - 1.month } + charges_to_datetime { to_datetime.end_of_month } + fixed_charges_from_datetime { from_datetime.beginning_of_month } + fixed_charges_to_datetime { to_datetime.end_of_month } + end + end +end diff --git a/spec/factories/invoices.rb b/spec/factories/invoices.rb new file mode 100644 index 0000000..57ff9dc --- /dev/null +++ b/spec/factories/invoices.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :invoice do + customer + # TODO: change building invoices from billing_entity by default + organization { customer&.organization || association(:organization) } + + issuing_date { Time.zone.now - 1.day } + expected_finalization_date { Time.zone.now - 1.day } + payment_due_date { issuing_date } + payment_status { "pending" } + currency { "EUR" } + # in case the organization was only build and not saved, it won't have a default_billing_entity, so we need + # to build it as well + billing_entity { organization&.default_billing_entity || association(:billing_entity) } + + organization_sequential_id { rand(1_000_000) } + + trait :draft do + status { :draft } + end + + trait :open do + status { :open } + end + + trait :credit do + invoice_type { :credit } + end + + trait :dispute_lost do + payment_dispute_lost_at { DateTime.current - 1.day } + end + + trait :with_tax_error do + after :create do |i| + create(:error_detail, owner: i, error_code: "tax_error") + end + end + + trait :with_tax_voiding_error do + after :create do |i| + create(:error_detail, owner: i, error_code: "tax_voiding_error") + end + end + + trait :failed do + status { :failed } + end + + trait :pending do + status { :pending } + end + + trait :voided do + status { :voided } + end + + trait :with_subscriptions do + transient do + subscriptions { [create(:subscription, organization:)] } + end + + after :create do |invoice, evaluator| + evaluator.subscriptions.each do |subscription| + create(:invoice_subscription, :boundaries, invoice:, subscription:) + end + end + end + + trait :subscription do + invoice_type { :subscription } + with_subscriptions + end + + trait :self_billed do + self_billed { true } + end + + trait :invisible do + status { Invoice::INVISIBLE_STATUS.keys.sample } + end + + trait :progressive_billing_invoice do + invoice_type { :progressive_billing } + with_subscriptions + after :create do |invoice| + create(:applied_usage_threshold, invoice:, organization: invoice.organization) + end + end + end +end diff --git a/spec/factories/item_metadata.rb b/spec/factories/item_metadata.rb new file mode 100644 index 0000000..51f1711 --- /dev/null +++ b/spec/factories/item_metadata.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :item_metadata, class: "Metadata::ItemMetadata" do + organization + owner { association :credit_note, organization: } + value { {"key" => "value"} } + end +end diff --git a/spec/factories/lifetime_usage.rb b/spec/factories/lifetime_usage.rb new file mode 100644 index 0000000..d4ba5ba --- /dev/null +++ b/spec/factories/lifetime_usage.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :lifetime_usage do + organization + subscription + + current_usage_amount_cents { 0 } + invoiced_usage_amount_cents { 0 } + recalculate_current_usage { false } + recalculate_invoiced_usage { false } + end +end diff --git a/spec/factories/membership_roles.rb b/spec/factories/membership_roles.rb new file mode 100644 index 0000000..53eb887 --- /dev/null +++ b/spec/factories/membership_roles.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :membership_role do + membership + organization { membership.organization } + role { association :role, organization: } + + %i[admin manager finance].each do |role_trait| + trait role_trait do + role { association :role, role_trait } + end + end + end +end diff --git a/spec/factories/memberships.rb b/spec/factories/memberships.rb new file mode 100644 index 0000000..b0c5881 --- /dev/null +++ b/spec/factories/memberships.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :membership do + user + organization + + transient do + role {} + roles { [] } + end + + trait :revoked do + status { :revoked } + revoked_at { Time.current } + end + + after(:create) do |membership, evaluator| + if evaluator.role.present? + create(:membership_role, role: evaluator.role, membership:) + end + + evaluator.roles.each do |role_trait| + create(:membership_role, role_trait.to_sym, membership:) + end + end + end +end diff --git a/spec/factories/organizations.rb b/spec/factories/organizations.rb new file mode 100644 index 0000000..6349e0a --- /dev/null +++ b/spec/factories/organizations.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :organization do + transient do + with_static_values { false } + end + + name { Faker::Company.name } + sequence(:slug) { |n| "test-org-#{n}" } + default_currency { "USD" } + audit_logs_period { nil } + + email { Faker::Internet.email } + email_settings { ["invoice.finalized", "credit_note.created"] } + + api_keys { [association(:api_key, organization: instance)] } + billing_entities do + [ + association( + :billing_entity, + *(with_static_values ? [:with_static_values] : []), + organization: instance + ) + ] + end + + transient do + webhook_url { Faker::Internet.url } + end + + after(:create) do |organization, evaluator| + # because we're building billing entity while building the organization, possible that the billing_entity will be + # created att he same moment as the organization, so we need to reload it to get the correct scope + organization.reload + if evaluator.webhook_url + organization.webhook_endpoints.create!(webhook_url: evaluator.webhook_url) + end + end + + trait :premium do + premium_integrations { Organization::PREMIUM_INTEGRATIONS } + end + + trait :with_invoice_custom_sections do + after :create do |org| + create_list(:invoice_custom_section, 3, organization: org) + end + end + + trait :with_default_dunning_campaign do + after :create do |org| + create(:dunning_campaign, organization: org, applied_to_organization: true) + end + end + + trait :with_static_values do + with_static_values { true } + + name { "ACME Corporation" } + slug { "acme-corp" } + default_currency { "USD" } + country { "US" } + end + end +end diff --git a/spec/factories/password_resets.rb b/spec/factories/password_resets.rb new file mode 100644 index 0000000..416e86c --- /dev/null +++ b/spec/factories/password_resets.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :password_reset do + user + + token { SecureRandom.hex(20) } + expire_at { Time.current + 30.minutes } + end +end diff --git a/spec/factories/payment_intents.rb b/spec/factories/payment_intents.rb new file mode 100644 index 0000000..7fc039c --- /dev/null +++ b/spec/factories/payment_intents.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :payment_intent do + invoice { association(:invoice) } + organization { invoice.organization } + payment_url { Faker::Internet.url } + + trait :expired do + status { :expired } + expires_at { generate(:past_date) } + end + + trait :awaiting_expiration do + expires_at { 1.hour.ago } + end + end +end diff --git a/spec/factories/payment_methods.rb b/spec/factories/payment_methods.rb new file mode 100644 index 0000000..c8fd2b6 --- /dev/null +++ b/spec/factories/payment_methods.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :payment_method do + association :payment_provider_customer, factory: :stripe_customer + payment_provider { payment_provider_customer&.payment_provider || association(:stripe_provider) } + organization { payment_provider_customer&.organization || association(:organization) } + customer { payment_provider_customer&.customer || association(:customer) } + provider_method_id { "ext_123" } + provider_method_type { "card" } + is_default { true } + + details do + {last4: "9876", brand: "Visa"} + end + end +end diff --git a/spec/factories/payment_provider_customers.rb b/spec/factories/payment_provider_customers.rb new file mode 100644 index 0000000..5da9221 --- /dev/null +++ b/spec/factories/payment_provider_customers.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :stripe_customer, class: "PaymentProviderCustomers::StripeCustomer" do + customer + organization { customer.organization } + + provider_customer_id { "cus_#{SecureRandom.hex}" } + provider_payment_methods { %w[card sepa_debit] } + end + + factory :gocardless_customer, class: "PaymentProviderCustomers::GocardlessCustomer" do + customer + organization { customer.organization } + + provider_customer_id { SecureRandom.uuid } + end + + factory :cashfree_customer, class: "PaymentProviderCustomers::CashfreeCustomer" do + customer + organization { customer.organization } + + provider_customer_id { SecureRandom.uuid } + end + + factory :adyen_customer, class: "PaymentProviderCustomers::AdyenCustomer" do + customer + organization { customer.organization } + + provider_customer_id { SecureRandom.uuid } + end + + factory :moneyhash_customer, class: "PaymentProviderCustomers::MoneyhashCustomer" do + customer + organization { customer.organization } + + provider_customer_id { SecureRandom.uuid } + end + factory :flutterwave_customer, class: "PaymentProviderCustomers::FlutterwaveCustomer" do + customer + organization { customer.organization } + payment_provider { association(:flutterwave_provider, organization: organization) } + + provider_customer_id { SecureRandom.uuid } + end +end diff --git a/spec/factories/payment_providers.rb b/spec/factories/payment_providers.rb new file mode 100644 index 0000000..cffd0a0 --- /dev/null +++ b/spec/factories/payment_providers.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :stripe_provider, class: "PaymentProviders::StripeProvider" do + organization + type { "PaymentProviders::StripeProvider" } + code { "stripe_account_#{SecureRandom.uuid}" } + name { "Stripe Account 1" } + + secrets do + {secret_key: SecureRandom.uuid}.to_json + end + + settings do + {success_redirect_url:} + end + + transient do + success_redirect_url { Faker::Internet.url } + end + end + + factory :gocardless_provider, class: "PaymentProviders::GocardlessProvider" do + organization + type { "PaymentProviders::GocardlessProvider" } + code { "gocardless_account_#{SecureRandom.uuid}" } + name { "GoCardless Account 1" } + + secrets do + {access_token: SecureRandom.uuid}.to_json + end + + settings do + {success_redirect_url:} + end + + transient do + success_redirect_url { Faker::Internet.url } + end + end + + factory :adyen_provider, class: "PaymentProviders::AdyenProvider" do + organization + type { "PaymentProviders::AdyenProvider" } + code { "adyen_account_#{SecureRandom.uuid}" } + name { "Adyen Account 1" } + + secrets do + {api_key:, hmac_key:}.to_json + end + + settings do + {live_prefix:, merchant_account:, success_redirect_url:} + end + + transient do + api_key { SecureRandom.uuid } + merchant_account { Faker::Company.duns_number } + live_prefix { Faker::Internet.domain_word } + hmac_key { SecureRandom.uuid } + success_redirect_url { Faker::Internet.url } + end + end + + factory :cashfree_provider, class: "PaymentProviders::CashfreeProvider" do + organization + type { "PaymentProviders::CashfreeProvider" } + code { "cashfree_account_#{SecureRandom.uuid}" } + name { "Cashfree Account 1" } + + secrets do + {client_id: SecureRandom.uuid, client_secret: SecureRandom.uuid}.to_json + end + + settings do + {success_redirect_url:} + end + + transient do + success_redirect_url { Faker::Internet.url } + end + end + + factory :moneyhash_provider, class: "PaymentProviders::MoneyhashProvider" do + organization + type { "PaymentProviders::MoneyhashProvider" } + name { "MoneyHash" } + code { "moneyhash_#{SecureRandom.uuid}" } + + secrets do + {api_key:}.to_json + end + + settings do + {success_redirect_url:, flow_id:} + end + + transient do + api_key { SecureRandom.uuid } + success_redirect_url { Faker::Internet.url } + flow_id { SecureRandom.uuid[0..19] } + end + end + factory :flutterwave_provider, class: "PaymentProviders::FlutterwaveProvider" do + organization + type { "PaymentProviders::FlutterwaveProvider" } + name { "Flutterwave" } + code { "flutterwave_#{SecureRandom.uuid}" } + secrets do + {secret_key:, webhook_secret:}.to_json + end + + settings do + {success_redirect_url:} + end + + transient do + secret_key { "FLWSECK-#{SecureRandom.uuid}" } + success_redirect_url { Faker::Internet.url } + webhook_secret { SecureRandom.hex(32) } + end + end +end diff --git a/spec/factories/payment_receipts.rb b/spec/factories/payment_receipts.rb new file mode 100644 index 0000000..b673817 --- /dev/null +++ b/spec/factories/payment_receipts.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :payment_receipt do + number { Faker::Alphanumeric.alphanumeric(number: 12) } + payment + organization + billing_entity { organization&.default_billing_entity || association(:billing_entity) } + end +end diff --git a/spec/factories/payment_request_applied_invoices.rb b/spec/factories/payment_request_applied_invoices.rb new file mode 100644 index 0000000..53f80ab --- /dev/null +++ b/spec/factories/payment_request_applied_invoices.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :payment_request_applied_invoice, class: "PaymentRequest::AppliedInvoice" do + payment_request + invoice + organization { invoice&.organization || payment_request&.organization || association(:organization) } + end +end diff --git a/spec/factories/payment_requests.rb b/spec/factories/payment_requests.rb new file mode 100644 index 0000000..0e55c52 --- /dev/null +++ b/spec/factories/payment_requests.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :payment_request do + customer + organization { customer.organization } + + amount_cents { 200 } + amount_currency { "EUR" } + email { Faker::Internet.email } + payment_status { "pending" } + ready_for_payment_processing { true } + payment_attempts { 0 } + + transient do + invoices { [] } + end + + trait :succeeded do + payment_status { "succeeded" } + end + + trait :failed do + payment_status { "failed" } + end + + after(:create) do |payment_request, evaluator| + evaluator.invoices.each do |invoice| + create(:payment_request_applied_invoice, payment_request:, invoice:) + end + end + end +end diff --git a/spec/factories/payment_responses.rb b/spec/factories/payment_responses.rb new file mode 100644 index 0000000..0de56e6 --- /dev/null +++ b/spec/factories/payment_responses.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +FactoryBot.define do + sequence :adyen_payments_response do + OpenStruct.new( + status: 200, + response: { + "additionalData" => { + "recurringProcessingModel" => "UnscheduledCardOnFile" + }, + "pspReference" => SecureRandom.uuid, + "resultCode" => "Authorised", + "merchantReference" => SecureRandom.uuid + } + ) + end + + sequence :adyen_payments_error_response do + OpenStruct.new( + status: 422, + response: { + "errorType" => "validation", + "message" => "There are no payment methods available for the given parameters." + } + ) + end + + sequence :adyen_payment_links_response do + OpenStruct.new( + status: 200, + response: { + "amount" => { + "currency" => "EUR", + "value" => 0 + }, + "expiresAt" => "2023-05-19T10:00:19+02:00", + "merchantAccount" => SecureRandom.uuid, + "recurringProcessingModel" => "UnscheduledCardOnFile", + "reference" => SecureRandom.uuid, + "reusable" => false, + "shopperReference" => SecureRandom.uuid, + "storePaymentMethodMode" => "enabled", + "id" => SecureRandom.uuid, + "status" => "active", + "url" => "https://test.adyen.link/test" + } + ) + end + + sequence :adyen_payment_links_error_response do + OpenStruct.new( + status: 422, + response: { + "errorType" => "validation", + "message" => "There are no payment methods available for the given parameters." + } + ) + end + + sequence :adyen_payment_methods_response do + OpenStruct.new( + status: 200, + response: { + "paymentMethods" => [ + { + "brands" => %w[amex bcmc cartebancaire mc visa visadankort], + "name" => "Credit Card", + "type" => "scheme" + } + ], + "storedPaymentMethods" => [ + { + "brand" => "visa", + "expiryMonth" => "03", + "expiryYear" => "30", + "holderName" => "Checkout Shopper PlaceHolder", + "id" => SecureRandom.uuid, + "lastFour" => "1234", + "name" => "VISA", + "networkTxReference" => SecureRandom.uuid, + "supportedRecurringProcessingModels" => %w[CardOnFile Subscription UnscheduledCardOnFile], + "supportedShopperInteractions" => %w[Ecommerce ContAuth], + "type" => "scheme" + } + ] + } + ) + end +end diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb new file mode 100644 index 0000000..ade971c --- /dev/null +++ b/spec/factories/payments.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :payment do + association :payable, factory: :invoice + association :payment_provider, factory: :stripe_provider + association :payment_provider_customer, factory: :stripe_customer + organization { payable&.organization || payment_provider&.organization || association(:organization) } + customer { payable&.customer || association(:customer) } + + amount_cents { 200 } + amount_currency { "EUR" } + provider_payment_id { SecureRandom.uuid } + status { "pending" } + payable_payment_status { "pending" } + payment_type { "provider" } + + trait :adyen_payment do + association :payment_provider, factory: :adyen_provider + association :payment_provider_customer, factory: :adyen_customer + end + + trait :gocardless_payment do + association :payment_provider, factory: :gocardless_provider + association :payment_provider_customer, factory: :gocardless_customer + end + + trait :cashfree_payment do + association :payment_provider, factory: :cashfree_provider + association :payment_provider_customer, factory: :cashfree_customer + end + + trait :requires_action do + status { "requires_action" } + provider_payment_data do + { + redirect_to_url: {url: "https://foo.bar"} + } + end + end + end +end diff --git a/spec/factories/pending_vies_checks.rb b/spec/factories/pending_vies_checks.rb new file mode 100644 index 0000000..ad3655f --- /dev/null +++ b/spec/factories/pending_vies_checks.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :pending_vies_check do + organization { + customer&.organization || billing_entity&.organization || association(:organization) + } + billing_entity { customer&.billing_entity || association(:billing_entity) } + customer + attempts_count { 1 } + last_attempt_at { Time.current } + last_error_type { "timeout" } + tax_identification_number { customer&.tax_identification_number || "EU123456789" } + + trait :with_multiple_attempts do + attempts_count { 3 } + last_error_message { "Service temporarily unavailable" } + last_error_type { "service_unavailable" } + end + end +end diff --git a/spec/factories/plan_applied_taxes.rb b/spec/factories/plan_applied_taxes.rb new file mode 100644 index 0000000..9c4ad40 --- /dev/null +++ b/spec/factories/plan_applied_taxes.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :plan_applied_tax, class: "Plan::AppliedTax" do + plan + tax + organization { plan&.organization || tax&.organization || association(:organization) } + end +end diff --git a/spec/factories/plans.rb b/spec/factories/plans.rb new file mode 100644 index 0000000..b072e54 --- /dev/null +++ b/spec/factories/plans.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :plan do + organization + name { Faker::TvShows::SiliconValley.app } + invoice_display_name { Faker::TvShows::BreakingBad.episode } + code { Faker::Alphanumeric.alphanumeric(number: 10) } + description { Faker::Lorem.sentence } + interval { "monthly" } + pay_in_advance { false } + amount_cents { 100 } + amount_currency { "EUR" } + + trait :pay_in_advance do + pay_in_advance { true } + end + end +end diff --git a/spec/factories/presentation_breakdowns.rb b/spec/factories/presentation_breakdowns.rb new file mode 100644 index 0000000..10425aa --- /dev/null +++ b/spec/factories/presentation_breakdowns.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :presentation_breakdown do + organization { fee&.organization || association(:organization) } + fee factory: :charge_fee + units { 60.0 } + presentation_by do + {department: "engineering"} + end + + trait :with_composite_presentation_by do + presentation_by do + {department: "engineering", region: "eu"} + end + end + end +end diff --git a/spec/factories/pricing_unit_usages.rb b/spec/factories/pricing_unit_usages.rb new file mode 100644 index 0000000..be1f77a --- /dev/null +++ b/spec/factories/pricing_unit_usages.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :pricing_unit_usage do + organization { fee&.organization || pricing_unit&.organization || association(:organization) } + fee + pricing_unit + short_name { pricing_unit.short_name } + amount_cents { 200 } + precise_amount_cents { BigDecimal("200.0000000001") } + unit_amount_cents { 10 } + precise_unit_amount { 0.1 } + conversion_rate { 1.0 } + end +end diff --git a/spec/factories/pricing_units.rb b/spec/factories/pricing_units.rb new file mode 100644 index 0000000..2903777 --- /dev/null +++ b/spec/factories/pricing_units.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :pricing_unit do + organization { association(:organization, pricing_units: []) } + name { [Faker::Emotion.adjective, Faker::Currency.name].join(" ") } + code { Faker::Lorem.unique.word } + short_name { Faker::CryptoCoin.coin_name.first(3) } + end +end diff --git a/spec/factories/quote_owners.rb b/spec/factories/quote_owners.rb new file mode 100644 index 0000000..3cdbb10 --- /dev/null +++ b/spec/factories/quote_owners.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :quote_owner do + quote + organization { quote.organization } + user { create(:membership, organization: quote.organization).user } + end +end diff --git a/spec/factories/quote_versions.rb b/spec/factories/quote_versions.rb new file mode 100644 index 0000000..415ffeb --- /dev/null +++ b/spec/factories/quote_versions.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :quote_version do + quote + organization { quote.organization } + status { :draft } + sequence(:sequential_id) { |n| n } + + trait :approved do + status { :approved } + approved_at { Time.current } + end + + trait :voided do + status { :voided } + voided_at { Time.current } + void_reason { :manual } + end + end +end diff --git a/spec/factories/quotes.rb b/spec/factories/quotes.rb new file mode 100644 index 0000000..e77f9ae --- /dev/null +++ b/spec/factories/quotes.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :quote do + organization + customer + order_type { :subscription_creation } + sequence(:sequential_id) { |n| n } + + trait :with_version do + transient do + version_trait { nil } + end + + after(:create) do |quote, evaluator| + traits = Array(evaluator.version_trait) + create( + :quote_version, + *traits, + quote: quote, + organization: quote.organization + ) + end + end + end +end diff --git a/spec/factories/recurring_rule_applied_invoice_custom_sections.rb b/spec/factories/recurring_rule_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..49fad63 --- /dev/null +++ b/spec/factories/recurring_rule_applied_invoice_custom_sections.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :recurring_rule_applied_invoice_custom_section, class: "RecurringTransactionRule::AppliedInvoiceCustomSection" do + recurring_transaction_rule + organization { recurring_transaction_rule&.organization || association(:organization) } + invoice_custom_section { association(:invoice_custom_section, organization:) } + end +end diff --git a/spec/factories/recurring_transaction_rules.rb b/spec/factories/recurring_transaction_rules.rb new file mode 100644 index 0000000..9aec98e --- /dev/null +++ b/spec/factories/recurring_transaction_rules.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :recurring_transaction_rule do + wallet + organization { wallet&.organization || association(:organization) } + paid_credits { "10.00" } + granted_credits { "10.00" } + interval { "monthly" } + trigger { "interval" } + transaction_name { "Recurring Transaction Rule" } + end +end diff --git a/spec/factories/refund_responses.rb b/spec/factories/refund_responses.rb new file mode 100644 index 0000000..54b7f30 --- /dev/null +++ b/spec/factories/refund_responses.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + sequence :adyen_refunds_response do + OpenStruct.new( + response: { + "merchantAccount" => SecureRandom.uuid, + "pspReference" => SecureRandom.uuid, + "paymentPspReference" => SecureRandom.uuid, + "status" => "received", + "amount" => { + "currency" => "CHF", + "value" => 134 + } + } + ) + end +end diff --git a/spec/factories/refunds.rb b/spec/factories/refunds.rb new file mode 100644 index 0000000..3ec237a --- /dev/null +++ b/spec/factories/refunds.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :refund do + credit_note + payment + organization { credit_note&.organization || payment&.organization || association(:organization) } + association :payment_provider, factory: :stripe_provider + association :payment_provider_customer, factory: :stripe_customer + + amount_cents { 200 } + amount_currency { "EUR" } + provider_refund_id { SecureRandom.uuid } + status { "pending" } + end +end diff --git a/spec/factories/roles.rb b/spec/factories/roles.rb new file mode 100644 index 0000000..a2822b9 --- /dev/null +++ b/spec/factories/roles.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :role do + sequence(:code) { |n| "role_#{n}" } + name { code&.to_s&.camelize || Faker::Job.unique.title } + custom + + trait :custom do + organization { create(:organization) } + permissions { %w[organization:view] } + end + + trait :predefined do + organization { nil } + permissions { [] } + end + + trait :admin do + predefined + code { "admin" } + name { "Admin" } + admin { true } + end + + trait :finance do + predefined + code { "finance" } + name { "Finance" } + end + + trait :manager do + predefined + code { "manager" } + name { "Manager" } + end + + to_create do |instance| + instance.id = Role.unscoped.with_code(instance.code).with_organization(instance.organization&.id).first&.id + if instance.id + instance.instance_variable_set(:@new_record, false) + instance.reload + else + instance.save(validate: false) + end + end + end +end diff --git a/spec/factories/subscription/activation_rules.rb b/spec/factories/subscription/activation_rules.rb new file mode 100644 index 0000000..5a8420a --- /dev/null +++ b/spec/factories/subscription/activation_rules.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :subscription_activation_rule, class: "Subscription::ActivationRule::Payment", aliases: [:payment_subscription_activation_rule] do + subscription + organization { subscription&.organization || association(:organization) } + type { "payment" } + status { "inactive" } + timeout_hours { 48 } + end +end diff --git a/spec/factories/subscription_applied_invoice_custom_sections.rb b/spec/factories/subscription_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..b8e95e2 --- /dev/null +++ b/spec/factories/subscription_applied_invoice_custom_sections.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :subscription_applied_invoice_custom_section, class: "Subscription::AppliedInvoiceCustomSection" do + subscription + organization { subscription&.organization || association(:organization) } + invoice_custom_section { association(:invoice_custom_section, organization:) } + end +end diff --git a/spec/factories/subscriptions.rb b/spec/factories/subscriptions.rb new file mode 100644 index 0000000..1fe68b8 --- /dev/null +++ b/spec/factories/subscriptions.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :subscription do + customer + plan + organization { customer&.organization || plan&.organization || association(:organization) } + status { :active } + external_id { SecureRandom.uuid } + started_at { 1.day.ago } + activated_at { 1.day.ago } + subscription_at { 1.day.ago } + + trait :pending do + status { :pending } + started_at { nil } + activated_at { nil } + end + + trait :canceled do + status { :canceled } + canceled_at { Time.current } + end + + trait :terminated do + status { :terminated } + started_at { 1.month.ago } + activated_at { 1.month.ago } + terminated_at { Time.zone.now } + end + + trait :incomplete do + status { :incomplete } + started_at { Time.current } + activated_at { nil } + end + + trait :calendar do + billing_time { :calendar } + end + + trait :anniversary do + billing_time { :anniversary } + end + + trait :with_previous_subscription do + previous_subscription { association(:subscription, customer:, plan:, organization:) } + end + + trait :with_activation_rules do + transient do + activation_rules_config { [{type: "payment", timeout_hours: 48}] } + end + + after(:create) do |subscription, evaluator| + evaluator.activation_rules_config.each do |config| + create(:subscription_activation_rule, subscription:, organization: subscription.organization, **config) + end + end + end + end +end diff --git a/spec/factories/taxes.rb b/spec/factories/taxes.rb new file mode 100644 index 0000000..809ae1d --- /dev/null +++ b/spec/factories/taxes.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :tax do + organization + code { "vat-#{SecureRandom.uuid}" } + description { "French Standard VAT" } + name { "VAT" } + rate { 20.0 } + # NOTE: usage of applied_to_organization is deprecated. Please, use :applied_to_billing_entity trait instead + applied_to_organization { false } + auto_generated { false } + + trait :applied_to_billing_entity do + transient do + billing_entity { nil } + end + + after(:create) do |tax, evaluator| + billing_entity = evaluator.billing_entity || tax.organization.default_billing_entity + create(:billing_entity_applied_tax, tax:, billing_entity:, organization: tax.organization) + end + end + end +end diff --git a/spec/factories/usage_monitoring/alert_thresholds.rb b/spec/factories/usage_monitoring/alert_thresholds.rb new file mode 100644 index 0000000..35f4c4f --- /dev/null +++ b/spec/factories/usage_monitoring/alert_thresholds.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :alert_threshold, class: "UsageMonitoring::AlertThreshold" do + alert + organization { alert.organization } + code { "warn" } + value { rand(40..500) * 100 } + end +end diff --git a/spec/factories/usage_monitoring/alerts.rb b/spec/factories/usage_monitoring/alerts.rb new file mode 100644 index 0000000..9653d34 --- /dev/null +++ b/spec/factories/usage_monitoring/alerts.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :alert, class: "UsageMonitoring::Alert" do + association :organization + subscription_external_id { create(:subscription, organization: organization).external_id } + name { "General Alert" } + sequence(:code) { |n| "default#{n}" } + alert_type { "current_usage_amount" } + direction { "increasing" } + + transient do + thresholds { [15_00] } + recurring_threshold { nil } + end + + after(:create) do |alert, evaluator| + if evaluator.thresholds + thresholds_attributes = evaluator.thresholds.map do |v| + {value: v, code: "warn#{v}", organization_id: alert.organization_id} + end + alert.thresholds.create! thresholds_attributes + end + + if evaluator.recurring_threshold + alert.thresholds.create!({ + value: evaluator.recurring_threshold, code: "rec", recurring: true, organization_id: alert.organization_id + }) + end + end + end + + trait :processed do + previous_value { 8_00 } + last_processed_at { DateTime.new(2000, 1, 1, 12, 0, 0) } + end + + factory :usage_current_amount_alert, + class: "UsageMonitoring::CurrentUsageAmountAlert", + parent: :alert do + alert_type { "current_usage_amount" } + end + + factory :lifetime_usage_amount_alert, + class: "UsageMonitoring::LifetimeUsageAmountAlert", + parent: :alert do + alert_type { "lifetime_usage_amount" } + end + + factory :billable_metric_current_usage_amount_alert, + class: "UsageMonitoring::BillableMetricCurrentUsageAmountAlert", + parent: :alert do + alert_type { "billable_metric_current_usage_amount" } + billable_metric { association(:billable_metric, organization:) } + end + + factory :billable_metric_current_usage_units_alert, + class: "UsageMonitoring::BillableMetricCurrentUsageUnitsAlert", + parent: :alert do + alert_type { "billable_metric_current_usage_units" } + billable_metric { association(:billable_metric, organization:) } + end + + factory :billable_metric_lifetime_usage_units_alert, + class: "UsageMonitoring::BillableMetricLifetimeUsageUnitsAlert", + parent: :alert do + alert_type { "billable_metric_lifetime_usage_units" } + billable_metric { association(:billable_metric, organization:) } + end + + factory :wallet_balance_amount_alert, + class: "UsageMonitoring::WalletBalanceAmountAlert", + parent: :alert do + alert_type { "wallet_balance_amount" } + direction { "decreasing" } + subscription_external_id { nil } + wallet { association(:wallet, organization:) } + end + + factory :wallet_credits_balance_alert, + class: "UsageMonitoring::WalletCreditsBalanceAlert", + parent: :alert do + alert_type { "wallet_credits_balance" } + direction { "decreasing" } + subscription_external_id { nil } + wallet { association(:wallet, organization:) } + end + + factory :wallet_ongoing_balance_amount_alert, + class: "UsageMonitoring::WalletOngoingBalanceAmountAlert", + parent: :alert do + alert_type { "wallet_ongoing_balance_amount" } + direction { "decreasing" } + subscription_external_id { nil } + wallet { association(:wallet, organization:) } + end + + factory :wallet_credits_ongoing_balance_alert, + class: "UsageMonitoring::WalletCreditsOngoingBalanceAlert", + parent: :alert do + alert_type { "wallet_credits_ongoing_balance" } + direction { "decreasing" } + subscription_external_id { nil } + wallet { association(:wallet, organization:) } + end +end diff --git a/spec/factories/usage_monitoring/subscription_activities.rb b/spec/factories/usage_monitoring/subscription_activities.rb new file mode 100644 index 0000000..2b1a7f1 --- /dev/null +++ b/spec/factories/usage_monitoring/subscription_activities.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :subscription_activity, class: "UsageMonitoring::SubscriptionActivity" do + association :organization + association :subscription + end + + trait :enqueued do + inserted_at { 3.minutes.ago } + enqueued { true } + enqueued_at { Time.current } + end +end diff --git a/spec/factories/usage_monitoring/triggered_alerts.rb b/spec/factories/usage_monitoring/triggered_alerts.rb new file mode 100644 index 0000000..99a1c7c --- /dev/null +++ b/spec/factories/usage_monitoring/triggered_alerts.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :triggered_alert, class: "UsageMonitoring::TriggeredAlert" do + alert + organization { alert.organization } + subscription { association(:subscription, organization: alert.organization) } + current_value { 3000 } + previous_value { 1000 } + triggered_at { Time.current } + crossed_thresholds do + [ + {code: :warn, value: BigDecimal(2000), recurring: false}, + {code: :repeat, value: BigDecimal(2500), recurring: true} + ] + end + end +end diff --git a/spec/factories/usage_thresholds.rb b/spec/factories/usage_thresholds.rb new file mode 100644 index 0000000..7e39b62 --- /dev/null +++ b/spec/factories/usage_thresholds.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :usage_threshold do + plan + subscription { nil } + organization { plan&.organization || subscription&.organization || association(:organization) } + threshold_display_name { Faker::Name.name } + amount_cents { 100 } + recurring { false } + + trait :recurring do + recurring { true } + end + + trait :for_subscription do + plan { nil } + subscription + end + end +end diff --git a/spec/factories/user_devices.rb b/spec/factories/user_devices.rb new file mode 100644 index 0000000..b903195 --- /dev/null +++ b/spec/factories/user_devices.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user_device do + user + + fingerprint { SecureRandom.hex(32) } + last_logged_at { Time.current } + end +end diff --git a/spec/factories/users.rb b/spec/factories/users.rb new file mode 100644 index 0000000..d05602c --- /dev/null +++ b/spec/factories/users.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :user do + email { Faker::Internet.email } + password { "ILoveLago" } + end +end diff --git a/spec/factories/utils.rb b/spec/factories/utils.rb new file mode 100644 index 0000000..499a661 --- /dev/null +++ b/spec/factories/utils.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +FactoryBot.define do + trait :deleted do + deleted_at { Time.current } + end +end diff --git a/spec/factories/wallet_applied_invoice_custom_sections.rb b/spec/factories/wallet_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..3435727 --- /dev/null +++ b/spec/factories/wallet_applied_invoice_custom_sections.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :wallet_applied_invoice_custom_section, class: "Wallet::AppliedInvoiceCustomSection" do + wallet + organization { wallet&.organization || association(:organization) } + invoice_custom_section { association(:invoice_custom_section, organization:) } + end +end diff --git a/spec/factories/wallet_targets.rb b/spec/factories/wallet_targets.rb new file mode 100644 index 0000000..63d01f7 --- /dev/null +++ b/spec/factories/wallet_targets.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :wallet_target, class: "WalletTarget" do + wallet + billable_metric + organization { billable_metric&.organization || wallet&.organization || association(:organization) } + end +end diff --git a/spec/factories/wallet_transaction_applied_invoice_custom_sections.rb b/spec/factories/wallet_transaction_applied_invoice_custom_sections.rb new file mode 100644 index 0000000..917cd64 --- /dev/null +++ b/spec/factories/wallet_transaction_applied_invoice_custom_sections.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :wallet_transaction_applied_invoice_custom_section, class: "WalletTransaction::AppliedInvoiceCustomSection" do + wallet_transaction + organization { wallet_transaction&.organization || association(:organization) } + invoice_custom_section { association(:invoice_custom_section, organization:) } + end +end diff --git a/spec/factories/wallet_transaction_consumptions.rb b/spec/factories/wallet_transaction_consumptions.rb new file mode 100644 index 0000000..87c2e23 --- /dev/null +++ b/spec/factories/wallet_transaction_consumptions.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :wallet_transaction_consumption do + transient do + wallet { association(:wallet) } + end + organization { wallet.organization } + inbound_wallet_transaction do + association(:wallet_transaction, + transaction_type: "inbound", + wallet:, + organization:, + remaining_amount_cents: 10000) + end + outbound_wallet_transaction do + association(:wallet_transaction, + transaction_type: "outbound", + wallet:, + organization:) + end + consumed_amount_cents { 100 } + end +end diff --git a/spec/factories/wallet_transactions.rb b/spec/factories/wallet_transactions.rb new file mode 100644 index 0000000..403b647 --- /dev/null +++ b/spec/factories/wallet_transactions.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :wallet_transaction do + wallet + organization { wallet&.organization || association(:organization) } + transaction_type { "inbound" } + status { "settled" } + amount { "1.00" } + credit_amount { "1.00" } + settled_at { Time.zone.now } + name { "Custom Transaction Name" } + remaining_amount_cents { (transaction_type.to_s == "inbound") ? (credit_amount.to_d * 100).to_i : nil } + invoice_requires_successful_payment { false } + + trait :failed do + status { "failed" } + failed_at { Time.current } + end + + trait :with_invoice do + transient do + customer { association(:customer) } + end + + invoice { association(:invoice, customer:, organization: customer.organization) } + end + + trait :with_credit_note do + transient do + customer { association(:customer) } + end + + credit_note { association(:credit_note, customer:, invoice:, organization: customer.organization) } + end + end +end diff --git a/spec/factories/wallets.rb b/spec/factories/wallets.rb new file mode 100644 index 0000000..0ccee29 --- /dev/null +++ b/spec/factories/wallets.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :wallet do + customer + organization { customer&.organization || association(:organization) } + name { Faker::Name.name } + code { name.to_s.parameterize(separator: "_").presence || "default" } + status { "active" } + rate_amount { "1.00" } + currency { "EUR" } + credits_balance { 0 } + balance_cents { 0 } + consumed_credits { 0 } + invoice_requires_successful_payment { false } + traceable { true } + + trait :terminated do + status { "terminated" } + end + + trait :with_recurring_transaction_rules do + recurring_transaction_rules { [association(:recurring_transaction_rule)] } + end + + trait :with_top_up_limits do + paid_top_up_min_amount_cents { rand(100..1000) } + paid_top_up_max_amount_cents { rand(2000..5000) } + end + + trait :with_inbound_transaction do + after(:create) do |wallet| + create(:wallet_transaction, + wallet:, + organization: wallet.organization, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: wallet.credits_balance, + credit_amount: wallet.credits_balance, + remaining_amount_cents: wallet.balance_cents) + end + end + end +end diff --git a/spec/factories/webhook_endpoints.rb b/spec/factories/webhook_endpoints.rb new file mode 100644 index 0000000..eb89f44 --- /dev/null +++ b/spec/factories/webhook_endpoints.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :webhook_endpoint do + organization + webhook_url { Faker::Internet.url } + end +end diff --git a/spec/factories/webhooks.rb b/spec/factories/webhooks.rb new file mode 100644 index 0000000..8fb5adc --- /dev/null +++ b/spec/factories/webhooks.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :webhook do + association :webhook_endpoint, factory: :webhook_endpoint + association :object, factory: :invoice + organization { webhook_endpoint&.organization || object&.organization || association(:organization) } + + payload { Faker::Types.rb_hash(number: 3) } + webhook_type { "invoice.created" } + endpoint { Faker::Internet.url } + + trait :succeeded do + http_status { 200 } + status { :succeeded } + retries { 0 } + end + + trait :succeeded_with_retries do + http_status { 200 } + status { :succeeded } + retries { Faker::Number.between(from: 1, to: 20) } + last_retried_at { Time.zone.now - 3.minutes } + end + + trait :retrying do + status { :retrying } + response { Faker::Json.shallow_json(width: 1) } + end + + trait :retrying_with_retries do + status { :retrying } + retries { Faker::Number.between(from: 1, to: 20) } + last_retried_at { Time.zone.now - 3.minutes } + response { Faker::Json.shallow_json(width: 1) } + end + + trait :failed do + http_status { 500 } + status { :failed } + response { Faker::Json.shallow_json(width: 1) } + end + + trait :failed_with_retries do + http_status { 500 } + status { :failed } + retries { Faker::Number.between(from: 1, to: 20) } + last_retried_at { Time.zone.now - 3.minutes } + response { Faker::Json.shallow_json(width: 1) } + end + + trait :pending do + status { :pending } + end + end +end diff --git a/spec/fixtures/adyen/chargeback_lost_event.json b/spec/fixtures/adyen/chargeback_lost_event.json new file mode 100644 index 0000000..6f587d4 --- /dev/null +++ b/spec/fixtures/adyen/chargeback_lost_event.json @@ -0,0 +1,28 @@ +{ + "live" : "true", + "notificationItems" : [ + { + "NotificationRequestItem" : { + "additionalData" : { + "chargebackReasonCode" : "13.1", + "modificationMerchantReferences" : "", + "chargebackSchemeCode" : "visa", + "disputeStatus" : "Lost" + }, + "amount" : { + "currency" : "GBP", + "value" : 10000 + }, + "eventCode" : "CHARGEBACK", + "eventDate" : "2020-03-13T11:35:42+01:00", + "merchantAccountCode" : "YOUR_MERCHANT_ACCOUNT", + "merchantReference" : "YOUR_REFERENCE", + "originalReference":"9913333333333333", + "paymentMethod" : "visa", + "pspReference" : "9915555555555555", + "reason" : "Merchandise/Services Not Received", + "success" : "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/chargeback_won_event.json b/spec/fixtures/adyen/chargeback_won_event.json new file mode 100644 index 0000000..a1448c7 --- /dev/null +++ b/spec/fixtures/adyen/chargeback_won_event.json @@ -0,0 +1,30 @@ +{ + "live":"true", + "notificationItems":[ + { + "NotificationRequestItem":{ + "additionalData":{ + "chargebackReasonCode":"10.4", + "modificationMerchantReferences":"", + "chargebackSchemeCode":"visa", + "defensePeriodEndsAt":"2021-05-24T22:09:50+02:00", + "defendable" : "true", + "disputeStatus" : "Won" + }, + "amount":{ + "currency":"EUR", + "value":1000 + }, + "eventCode":"CHARGEBACK", + "eventDate":"2021-05-06T22:09:50+02:00", + "merchantAccountCode":"YOUR_MERCHANT_ACCOUNT", + "merchantReference":"YOUR_REFERENCE", + "originalReference":"9913333333333333", + "paymentMethod":"visa", + "pspReference":"9915555555555555", + "reason":"Other Fraud-Card Absent Environment", + "success":"true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_authorisation_payment_response.json b/spec/fixtures/adyen/webhook_authorisation_payment_response.json new file mode 100644 index 0000000..b1dbb1d --- /dev/null +++ b/spec/fixtures/adyen/webhook_authorisation_payment_response.json @@ -0,0 +1,40 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "authCode": "051793", + "paymentLinkId": "PLF11278A8985273C2", + "metadata.payment_type": "one-time", + "cardSummary": "1142", + "metadata.invoice_type": "subscription", + "checkout.cardAddedBrand": "visa", + "metadata.invoice_issuing_date": "2024-01-24", + "expiryDate": "03/2030", + "metadata.lago_customer_id": "a5488a6c-d2ed-44fd-8c97-7fcca4a6a84a", + "threeds2.cardEnrolled": "false", + "recurringProcessingModel": "CardOnFile", + "metadata.lago_invoice_id": "ec82efeb-88bb-44f8-ba30-0d55b3fd583a" + }, + "amount": { + "currency": "EUR", + "value": 71 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2024-01-26T14:06:02+01:00", + "merchantAccountCode": "LagoAccountECOM", + "merchantReference": "HOO-3588-202401-033", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "paymentMethod": "visa", + "pspReference": "SGVWRSNQLDQ2WN82", + "reason": "051793:1142:03/2030", + "success": "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_authorisation_payment_response_invalid_payable.json b/spec/fixtures/adyen/webhook_authorisation_payment_response_invalid_payable.json new file mode 100644 index 0000000..510d314 --- /dev/null +++ b/spec/fixtures/adyen/webhook_authorisation_payment_response_invalid_payable.json @@ -0,0 +1,39 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "authCode": "051793", + "paymentLinkId": "PLF11278A8985273C2", + "metadata.payment_type": "one-time", + "cardSummary": "1142", + "checkout.cardAddedBrand": "visa", + "expiryDate": "03/2030", + "metadata.lago_customer_id": "a5488a6c-d2ed-44fd-8c97-7fcca4a6a84a", + "threeds2.cardEnrolled": "false", + "recurringProcessingModel": "CardOnFile", + "metadata.lago_payment_request_id": "ec82efeb-88bb-44f8-ba30-0d55b3fd583a", + "metadata.lago_payable_type": "InvalidPayableTypeName" + }, + "amount": { + "currency": "EUR", + "value": 71 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2024-01-26T14:06:02+01:00", + "merchantAccountCode": "LagoAccountECOM", + "merchantReference": "HOO-3588-202401-033", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "paymentMethod": "visa", + "pspReference": "SGVWRSNQLDQ2WN82", + "reason": "051793:1142:03/2030", + "success": "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_authorisation_payment_response_payment_request.json b/spec/fixtures/adyen/webhook_authorisation_payment_response_payment_request.json new file mode 100644 index 0000000..6892eaf --- /dev/null +++ b/spec/fixtures/adyen/webhook_authorisation_payment_response_payment_request.json @@ -0,0 +1,39 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "authCode": "051793", + "paymentLinkId": "PLF11278A8985273C2", + "metadata.payment_type": "one-time", + "cardSummary": "1142", + "checkout.cardAddedBrand": "visa", + "expiryDate": "03/2030", + "metadata.lago_customer_id": "a5488a6c-d2ed-44fd-8c97-7fcca4a6a84a", + "threeds2.cardEnrolled": "false", + "recurringProcessingModel": "CardOnFile", + "metadata.lago_payment_request_id": "ec82efeb-88bb-44f8-ba30-0d55b3fd583a", + "metadata.lago_payable_type": "PaymentRequest" + }, + "amount": { + "currency": "EUR", + "value": 71 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2024-01-26T14:06:02+01:00", + "merchantAccountCode": "LagoAccountECOM", + "merchantReference": "HOO-3588-202401-033", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "paymentMethod": "visa", + "pspReference": "SGVWRSNQLDQ2WN82", + "reason": "051793:1142:03/2030", + "success": "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_authorisation_response.json b/spec/fixtures/adyen/webhook_authorisation_response.json new file mode 100644 index 0000000..3864e8f --- /dev/null +++ b/spec/fixtures/adyen/webhook_authorisation_response.json @@ -0,0 +1,34 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "expiryDate": "03\/2030", + "authCode": "12345", + "cardSummary": "0002", + "recurringProcessingModel": "UnscheduledCardOnFile", + "checkout.cardAddedBrand": "amex", + "shopperReference": "test_1" + }, + "amount": { + "currency": "USD", + "value": 0 + }, + "eventCode": "AUTHORISATION", + "eventDate": "2023-05-13T12:17:10+02:00", + "merchantAccountCode": "Lago", + "merchantReference": "123-456-789", + "operations": [ + "CANCEL", + "CAPTURE", + "REFUND" + ], + "paymentMethod": "amex", + "pspReference": "ABCDEFGH", + "reason": "092332:0002:03\/2030", + "success": "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_offer_closed_response.json b/spec/fixtures/adyen/webhook_offer_closed_response.json new file mode 100644 index 0000000..02b6f8c --- /dev/null +++ b/spec/fixtures/adyen/webhook_offer_closed_response.json @@ -0,0 +1,25 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "b0ea55c2fe60d4d1d605e9c385e0e7...", + "paymentMethodVariant": "ideal" + }, + "amount": { + "currency": "EUR", + "value": 1000 + }, + "eventCode": "OFFER_CLOSED", + "eventDate": "2021-01-01T01:00:00+01:00", + "merchantAccountCode": "YOUR_MERCHANT_ACCOUNT", + "merchantReference": "YOUR_MERCHANT_REFERENCE", + "paymentMethod": "ideal", + "pspReference": "QFQTPCQ8HXSKGK82", + "reason": "", + "success": "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_recurring_contract_response.json b/spec/fixtures/adyen/webhook_recurring_contract_response.json new file mode 100644 index 0000000..83c9c1f --- /dev/null +++ b/spec/fixtures/adyen/webhook_recurring_contract_response.json @@ -0,0 +1,25 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "recurring.shopperReference": "cus_0001", + "paymentLinkId": "XXX", + "recurring.recurringDetailReference": "YYY", + "shopperReference": "cus_0001" + }, + "amount": { "currency": "USD", "value": 0 }, + "eventCode": "RECURRING_CONTRACT", + "eventDate": "2025-07-01T09:21:50+02:00", + "merchantAccountCode": "YOUR_MERCHANT_ACCOUNT", + "merchantReference": "", + "originalReference": "WWW", + "paymentMethod": "visa", + "pspReference": "ZZZ", + "reason": "", + "success": "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_refund_response.json b/spec/fixtures/adyen/webhook_refund_response.json new file mode 100644 index 0000000..e5b7d3b --- /dev/null +++ b/spec/fixtures/adyen/webhook_refund_response.json @@ -0,0 +1,25 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "bookingDate": "2023-05-13T18:03:05Z" + }, + "amount": { + "currency": "USD", + "value": 1000 + }, + "eventCode": "REFUND", + "eventDate": "2023-05-13T18:02:30+02:00", + "merchantAccountCode": "Lago", + "merchantReference": "2280c3e7-569c-4346-893d-12a23798322e", + "originalReference": "ABCDEFGH", + "paymentMethod": "amex", + "pspReference": "ABCDEFGH", + "reason": "", + "success": "true" + } + } + ] +} diff --git a/spec/fixtures/adyen/webhook_report_available_response.json b/spec/fixtures/adyen/webhook_report_available_response.json new file mode 100644 index 0000000..c20b615 --- /dev/null +++ b/spec/fixtures/adyen/webhook_report_available_response.json @@ -0,0 +1,23 @@ +{ + "live": "false", + "notificationItems": [ + { + "NotificationRequestItem": { + "additionalData": { + "hmacSignature": "b0ea55c2fe60d4d1d605e9c385e0e7..." + }, + "amount": { + "currency": "EUR", + "value": 1000 + }, + "eventCode": "REPORT_AVAILABLE", + "eventDate": "2021-01-01T01:00:00+01:00", + "merchantAccountCode": "YOUR_MERCHANT_ACCOUNT", + "merchantReference": "", + "pspReference": "QFQTPCQ8HXSKGK82", + "reason": "URL_TO_DOWNLOAD_REPORT", + "success": "true" + } + } + ] +} \ No newline at end of file diff --git a/spec/fixtures/blank.pdf b/spec/fixtures/blank.pdf new file mode 100644 index 0000000..757bb1f Binary files /dev/null and b/spec/fixtures/blank.pdf differ diff --git a/spec/fixtures/blank.xml b/spec/fixtures/blank.xml new file mode 100644 index 0000000..9cffc76 --- /dev/null +++ b/spec/fixtures/blank.xml @@ -0,0 +1,4 @@ + + + + diff --git a/spec/fixtures/cashfree/payment_link_event_payment.json b/spec/fixtures/cashfree/payment_link_event_payment.json new file mode 100644 index 0000000..9dfab76 --- /dev/null +++ b/spec/fixtures/cashfree/payment_link_event_payment.json @@ -0,0 +1,36 @@ +{ + "data": { + "cf_link_id": 1576977, + "link_id": "payment_ps11", + "link_status": "PAID", + "link_currency": "INR", + "link_amount": "200.12", + "link_amount_paid": "55.00", + "link_partial_payments": true, + "link_minimum_partial_amount": "11.00", + "link_purpose": "Payment for order 10", + "link_created_at": "2021-08-18T07:13:41", + "customer_details": { + "customer_phone": "9000000000", + "customer_email": "john@gmail.com", + "customer_name": "John " + }, + "link_meta": { "notify_url": "https://ee08e626ecd88c61c85f5c69c0418cb5.m.pipedream.net" }, + "link_url": "https://payments-test.cashfree.com/links//U1mgll3c0e9g", + "link_expiry_time": "2021-11-28T21:46:20", + "link_notes": { "lago_invoice_id": "06afb06b-4e54-4f8f-89c1-6d8b9907465a" }, + "link_auto_reminders": true, + "link_notify": { "send_sms": true, "send_email": true }, + "order": { + "order_amount": "22.00", + "order_id": "CFPay_U1mgll3c0e9g_ehdcjjbtckf", + "order_expiry_time": "2021-08-18T07:34:50", + "order_hash": "Gb2gC7z0tILhGbZUIeds", + "transaction_id": 1021206, + "transaction_status": "SUCCESS" + } + }, + "type": "PAYMENT_LINK_EVENT", + "version": 1, + "event_time": "2021-08-18T12:55:06+05:30" +} diff --git a/spec/fixtures/cashfree/payment_link_event_payment_request.json b/spec/fixtures/cashfree/payment_link_event_payment_request.json new file mode 100644 index 0000000..4afd4c0 --- /dev/null +++ b/spec/fixtures/cashfree/payment_link_event_payment_request.json @@ -0,0 +1,36 @@ +{ + "data": { + "cf_link_id": 1576977, + "link_id": "payment_ps11", + "link_status": "PAID", + "link_currency": "INR", + "link_amount": "200.12", + "link_amount_paid": "55.00", + "link_partial_payments": true, + "link_minimum_partial_amount": "11.00", + "link_purpose": "Payment for order 10", + "link_created_at": "2021-08-18T07:13:41", + "customer_details": { + "customer_phone": "9000000000", + "customer_email": "john@gmail.com", + "customer_name": "John " + }, + "link_meta": { "notify_url": "https://ee08e626ecd88c61c85f5c69c0418cb5.m.pipedream.net" }, + "link_url": "https://payments-test.cashfree.com/links//U1mgll3c0e9g", + "link_expiry_time": "2021-11-28T21:46:20", + "link_notes": { "lago_payable_id": "06afb06b-4e54-4f8f-89c1-6d8b9907465a", "lago_payable_type": "PaymentRequest" }, + "link_auto_reminders": true, + "link_notify": { "send_sms": true, "send_email": true }, + "order": { + "order_amount": "22.00", + "order_id": "CFPay_U1mgll3c0e9g_ehdcjjbtckf", + "order_expiry_time": "2021-08-18T07:34:50", + "order_hash": "Gb2gC7z0tILhGbZUIeds", + "transaction_id": 1021206, + "transaction_status": "SUCCESS" + } + }, + "type": "PAYMENT_LINK_EVENT", + "version": 1, + "event_time": "2021-08-18T12:55:06+05:30" +} diff --git a/spec/fixtures/export.csv b/spec/fixtures/export.csv new file mode 100644 index 0000000..24b6413 --- /dev/null +++ b/spec/fixtures/export.csv @@ -0,0 +1,3 @@ +invoice.lago_id,invoice.sequential_id,invoice.issuing_date,invoice.customer.customer_lago_id,invoice.customer.external_id,invoice.customer.country,invoice.customer.tax_identification_number,invoice.number,invoice.total_amount_cents,invoice.currency,invoice.invoice_type,invoice.payment_status,invoice.status,invoice.file_url,invoice.taxes_amount_cents,invoice.credit_notes_amount_cents,invoice.prepaid_credit_amount_cents,invoice.coupons_amount_cents,invoice.payment_due_date,invoice.payment_dispute_lost_at,invoice.payment_overdue +292ef60b-9e0c-42e7-9f50-44d5af4162ec,1,2024-06-06,80ebcc26-3703-4577-b13e-765591255df4,hooli_1,US,US12345,TWI-2B86-170-001,1000,USD,subscription,pending,finalized,https://file1.com,100,0,1000,0,2024-06-06,,true +7d430962-02cb-4183-b255-de3bb75af798,2,2024-06-07,80ebcc26-3703-4577-b13e-765591255df4,hooli_1,US,US12345,TWI-2B86-170-002,2000,USD,subscription,failed,draft,https://file2.com,200,100,0,0,2024-07-20,2024-06-06,false diff --git a/spec/fixtures/gocardless/events.json b/spec/fixtures/gocardless/events.json new file mode 100644 index 0000000..2d34f32 --- /dev/null +++ b/spec/fixtures/gocardless/events.json @@ -0,0 +1,22 @@ +{ + "events": [ + { + "id": "EVTESTYM78PGEM", + "created_at": "2022-10-29T16:26:50.380Z", + "resource_type": "payments", + "action": "paid_out", + "links": { + "payment": "PM0068WBTXDQ0Q" + }, + "details": { + "origin": "gocardless", + "cause": "payment_paid_out", + "description": "The payment has been paid out by GoCardless." + }, + "metadata": {} + } + ], + "meta": { + "webhook_id": "WB001F6SJ8MG2S" + } +} diff --git a/spec/fixtures/gocardless/events_invalid_payable_type.json b/spec/fixtures/gocardless/events_invalid_payable_type.json new file mode 100644 index 0000000..189bf56 --- /dev/null +++ b/spec/fixtures/gocardless/events_invalid_payable_type.json @@ -0,0 +1,24 @@ +{ + "events": [ + { + "id": "EVTESTYM78PGEM", + "created_at": "2022-10-29T16:26:50.380Z", + "resource_type": "payments", + "action": "paid_out", + "links": { + "payment": "PM0068WBTXDQ0Q" + }, + "details": { + "origin": "gocardless", + "cause": "payment_paid_out", + "description": "The payment has been paid out by GoCardless." + }, + "metadata": { + "lago_payable_type": "InvalidPayableTypeName" + } + } + ], + "meta": { + "webhook_id": "WB001F6SJ8MG2S" + } +} diff --git a/spec/fixtures/gocardless/events_mandate_cancelled.json b/spec/fixtures/gocardless/events_mandate_cancelled.json new file mode 100644 index 0000000..0eeddbf --- /dev/null +++ b/spec/fixtures/gocardless/events_mandate_cancelled.json @@ -0,0 +1,24 @@ +{ + "events": [ + { + "id": "EVTEST7YZZGP7F", + "created_at": "2026-02-03T10:53:48.287Z", + "resource_type": "mandates", + "action": "cancelled", + "links": { + "mandate": "index_ID_123" + }, + "details": { + "origin": "api", + "cause": "mandate_cancelled", + "description": "The mandate was cancelled via an API call or the GoCardless dashboard." + }, + "metadata": {}, + "resource_metadata": {}, + "organisation_id": "OR000052Y8M2N5" + } + ], + "meta": { + "webhook_id": "WB01KGHJ65WA3BD5MQF703D3FDBQ" + } +} diff --git a/spec/fixtures/gocardless/events_mandate_cancelled_by_bank.json b/spec/fixtures/gocardless/events_mandate_cancelled_by_bank.json new file mode 100644 index 0000000..c17ba02 --- /dev/null +++ b/spec/fixtures/gocardless/events_mandate_cancelled_by_bank.json @@ -0,0 +1,24 @@ +{ + "events": [ + { + "id": "EVTEST7YZZGP7F", + "created_at": "2026-02-03T10:53:48.287Z", + "resource_type": "mandates", + "action": "cancelled", + "links": { + "mandate": "index_ID_123" + }, + "details": { + "origin": "bank", + "cause": "mandate_cancelled", + "description": "The mandate was cancelled at a bank branch." + }, + "metadata": {}, + "resource_metadata": {}, + "organisation_id": "OR000052Y8M2N5" + } + ], + "meta": { + "webhook_id": "WB01KGHJ65WA3BD5MQF703D3FDBQ" + } +} diff --git a/spec/fixtures/gocardless/events_mandate_created.json b/spec/fixtures/gocardless/events_mandate_created.json new file mode 100644 index 0000000..ba1cd59 --- /dev/null +++ b/spec/fixtures/gocardless/events_mandate_created.json @@ -0,0 +1,24 @@ +{ + "events": [ + { + "id": "EVTEST6XZZGP6E", + "created_at": "2026-02-03T10:53:48.287Z", + "resource_type": "mandates", + "action": "created", + "links": { + "mandate": "index_ID_123" + }, + "details": { + "origin": "gocardless", + "cause": "mandate_activated", + "description": "The time window after submission for the banks to refuse a mandate has ended without any errors being received, so this mandate is now active." + }, + "metadata": {}, + "resource_metadata": {}, + "organisation_id": "OR000052Y8M2N5" + } + ], + "meta": { + "webhook_id": "WB01KGHJ65WA3BD5MQF703D3FDBQ" + } +} diff --git a/spec/fixtures/gocardless/events_payment_request.json b/spec/fixtures/gocardless/events_payment_request.json new file mode 100644 index 0000000..120cd60 --- /dev/null +++ b/spec/fixtures/gocardless/events_payment_request.json @@ -0,0 +1,24 @@ +{ + "events": [ + { + "id": "EVTESTYM78PGEM", + "created_at": "2022-10-29T16:26:50.380Z", + "resource_type": "payments", + "action": "paid_out", + "links": { + "payment": "PM0068WBTXDQ0Q" + }, + "details": { + "origin": "gocardless", + "cause": "payment_paid_out", + "description": "The payment has been paid out by GoCardless." + }, + "metadata": { + "lago_payable_type": "PaymentRequest" + } + } + ], + "meta": { + "webhook_id": "WB001F6SJ8MG2S" + } +} diff --git a/spec/fixtures/gocardless/events_refund.json b/spec/fixtures/gocardless/events_refund.json new file mode 100644 index 0000000..5c0ee12 --- /dev/null +++ b/spec/fixtures/gocardless/events_refund.json @@ -0,0 +1,22 @@ +{ + "events": [ + { + "id": "EVTESTJZ4276PM", + "created_at": "2022-11-24T14:08:43.756Z", + "resource_type": "refunds", + "action": "paid", + "links": { + "refund": "index_ID_123" + }, + "details": { + "origin": "gocardless", + "cause": "refund_paid", + "description": "The refund has been paid to your customer." + }, + "metadata": {} + } + ], + "meta": { + "webhook_id": "WB001GFRA4YMX2" + } +} diff --git a/spec/fixtures/integration_aggregator/account_information_response.json b/spec/fixtures/integration_aggregator/account_information_response.json new file mode 100644 index 0000000..3e2fc62 --- /dev/null +++ b/spec/fixtures/integration_aggregator/account_information_response.json @@ -0,0 +1,11 @@ +{ + "id": "1234567890", + "type": "STANDARD", + "timeZone": "US/Eastern", + "companyCurrency": "USD", + "additionalCurrencies": [], + "utcOffset": "-04:00", + "utcOffsetMilliseconds": -14400000, + "uiDomain": "app.hubspot.com", + "dataHostingLocation": "na1" +} diff --git a/spec/fixtures/integration_aggregator/accounts_response.json b/spec/fixtures/integration_aggregator/accounts_response.json new file mode 100644 index 0000000..6690ea3 --- /dev/null +++ b/spec/fixtures/integration_aggregator/accounts_response.json @@ -0,0 +1,59 @@ +{ + "records": [ + { + "id": "12ec4c59-ad56-4a4f-93eb-fb0a7740f4e2", + "code": "1111", + "name": "Accounts Payable", + "type": "CURRLIAB", + "tax_type": "NONE", + "description": "Outstanding invoices the company has received from suppliers but has not yet paid at balance date", + "class": "LIABILITY", + "bank_account_type": "", + "reporting_code": "LIA.CUR.PAY.TRA", + "_nango_metadata": { + "first_seen_at": "2024-05-21T15:05:44.031424+00:00", + "last_modified_at": "2024-05-21T15:05:44.031424+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "" + } + }, + { + "id": "6317441d-6547-417c-89e2-6e43ece791d8", + "code": "2222", + "name": "Accounts Receivable", + "type": "CURRENT", + "tax_type": "NONE", + "description": "Outstanding invoices the company has issued out to the client but has not yet received in cash at balance date.", + "class": "ASSET", + "bank_account_type": "", + "reporting_code": "ASS.CUR.REC.ACR", + "_nango_metadata": { + "first_seen_at": "2024-05-21T15:05:44.031424+00:00", + "last_modified_at": "2024-05-21T15:05:44.031424+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "" + } + }, + { + "id": "80701036-73b5-4468-a4b3-a139262035b4", + "code": "3333", + "name": "Automobile Expense", + "type": "EXPENSE", + "tax_type": "NONE", + "description": "", + "class": "EXPENSE", + "bank_account_type": "", + "reporting_code": "EXP.VEH", + "_nango_metadata": { + "first_seen_at": "2024-05-21T15:05:44.031424+00:00", + "last_modified_at": "2024-05-21T15:05:44.031424+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "" + } + } + ], + "next_cursor": null +} diff --git a/spec/fixtures/integration_aggregator/bad_gateway_error.html b/spec/fixtures/integration_aggregator/bad_gateway_error.html new file mode 100644 index 0000000..b669c35 --- /dev/null +++ b/spec/fixtures/integration_aggregator/bad_gateway_error.html @@ -0,0 +1,10 @@ + + + 502 Bad Gateway + + +

502 Bad Gateway

+
+
cloudflare
+ + diff --git a/spec/fixtures/integration_aggregator/companies/failure_hash_response.json b/spec/fixtures/integration_aggregator/companies/failure_hash_response.json new file mode 100644 index 0000000..e7b5e80 --- /dev/null +++ b/spec/fixtures/integration_aggregator/companies/failure_hash_response.json @@ -0,0 +1,20 @@ +{ + "succeededCompanies": [], + "failedCompanies": [ + { + "id": "2e50c200-9a54-4a66-b241-1e75fb87373f", + "name": "Test", + "email": "s@", + "city": "z", + "zip": "73993", + "country": "US", + "state": "NYC", + "phone": "+33818282828", + "validation_errors": [ + { + "Message": "Email address must be valid." + } + ] + } + ] +} diff --git a/spec/fixtures/integration_aggregator/companies/success_hash_response.json b/spec/fixtures/integration_aggregator/companies/success_hash_response.json new file mode 100644 index 0000000..2c07a1e --- /dev/null +++ b/spec/fixtures/integration_aggregator/companies/success_hash_response.json @@ -0,0 +1,16 @@ +{ + "succeededCompanies": [ + { + "id": "2e50c200-9a54-4a66-b241-1e75fb87373f", + "name": "Test", + "email": "roger@rogers.com", + "lago_billing_email": "roger@rogers.com", + "city": "NYC", + "zip": "73993", + "country": "US", + "state": "NYC", + "phone": "+33818282828" + } + ], + "failedCompanies": [] +} diff --git a/spec/fixtures/integration_aggregator/companies/success_string_response.json b/spec/fixtures/integration_aggregator/companies/success_string_response.json new file mode 100644 index 0000000..f27b76c --- /dev/null +++ b/spec/fixtures/integration_aggregator/companies/success_string_response.json @@ -0,0 +1 @@ +"1" diff --git a/spec/fixtures/integration_aggregator/contacts/failure_hash_response.json b/spec/fixtures/integration_aggregator/contacts/failure_hash_response.json new file mode 100644 index 0000000..e8e75ff --- /dev/null +++ b/spec/fixtures/integration_aggregator/contacts/failure_hash_response.json @@ -0,0 +1,20 @@ +{ + "succeededContacts": [], + "failedContacts": [ + { + "id": "2e50c200-9a54-4a66-b241-1e75fb87373f", + "name": "Test", + "email": "s@", + "city": "z", + "zip": "73993", + "country": "US", + "state": "NYC", + "phone": "+33818282828", + "validation_errors": [ + { + "Message": "Email address must be valid." + } + ] + } + ] +} diff --git a/spec/fixtures/integration_aggregator/contacts/success_hash_response.json b/spec/fixtures/integration_aggregator/contacts/success_hash_response.json new file mode 100644 index 0000000..af3837c --- /dev/null +++ b/spec/fixtures/integration_aggregator/contacts/success_hash_response.json @@ -0,0 +1,15 @@ +{ + "succeededContacts": [ + { + "id": "2e50c200-9a54-4a66-b241-1e75fb87373f", + "name": "Test", + "email": "roger@rogers.com", + "city": "NYC", + "zip": "73993", + "country": "US", + "state": "NYC", + "phone": "+33818282828" + } + ], + "failedContacts": [] +} diff --git a/spec/fixtures/integration_aggregator/contacts/success_string_response.json b/spec/fixtures/integration_aggregator/contacts/success_string_response.json new file mode 100644 index 0000000..f27b76c --- /dev/null +++ b/spec/fixtures/integration_aggregator/contacts/success_string_response.json @@ -0,0 +1 @@ +"1" diff --git a/spec/fixtures/integration_aggregator/credit_notes/failure_hash_response.json b/spec/fixtures/integration_aggregator/credit_notes/failure_hash_response.json new file mode 100644 index 0000000..31998fd --- /dev/null +++ b/spec/fixtures/integration_aggregator/credit_notes/failure_hash_response.json @@ -0,0 +1,42 @@ +{ + "succeededCreditNotes": [], + "failedCreditNotes": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "type": "ACCREC", + "external_contact_id": "b20f8b1e-b934-464d-9bf6-b82551b7f352", + "status": "AUTHORISED", + "issuing_date": "2024-12-19T00:00:00.000Z", + "payment_due_date": "2024-01-23T00:00:00.000Z", + "number": "INV-0071299", + "currency": "USD", + "purchase_order": null, + "fees": [ + { + "description": "Test item3", + "units": 10, + "precise_unit_amount": 15, + "account_code": "4000", + "amount_cents": 15000, + "taxes_amount_cents": 1800 + }, + { + "description": "Test item3", + "units": 1, + "precise_unit_amount": -1, + "account_code": "4000", + "amount_cents": -100, + "taxes_amount_cents": null + } + ], + "validation_errors": [ + { + "Message": "To update fields on a paid invoice line item, you must supply a LineItemID" + }, + { + "Message": "The status AUTHORISED cannot be applied to the invoice because it has payments or credit notes allocated to it." + } + ] + } + ] +} diff --git a/spec/fixtures/integration_aggregator/credit_notes/success_hash_response.json b/spec/fixtures/integration_aggregator/credit_notes/success_hash_response.json new file mode 100644 index 0000000..87f1e2c --- /dev/null +++ b/spec/fixtures/integration_aggregator/credit_notes/success_hash_response.json @@ -0,0 +1,26 @@ +{ + "succeededCreditNotes": [ + { + "id": "e5a62e05-e192-489f-8965-e01b597b523b", + "type": "ACCRECCREDIT", + "external_contact_id": "e78e0e6b-dd7e-44f6-9f68-075bcd328535", + "status": "AUTHORISED", + "number": "CN-0053", + "currency": "USD", + "reference": "", + "issuing_date": "2023-12-19T00:00:00.000Z", + "fees": [ + { + "item_id": "552732aa-ad09-4373-bbf8-20eb74b0582e", + "description": "Test item3", + "units": 1, + "precise_unit_amount": 250, + "account_code": "4000", + "amount_cents": 25000, + "taxes_amount_cents": 10000 + } + ] + } + ], + "failedCreditNotes": [] +} diff --git a/spec/fixtures/integration_aggregator/credit_notes/success_string_response.json b/spec/fixtures/integration_aggregator/credit_notes/success_string_response.json new file mode 100644 index 0000000..063c417 --- /dev/null +++ b/spec/fixtures/integration_aggregator/credit_notes/success_string_response.json @@ -0,0 +1 @@ +"456" diff --git a/spec/fixtures/integration_aggregator/custom_object_response.json b/spec/fixtures/integration_aggregator/custom_object_response.json new file mode 100644 index 0000000..375c25d --- /dev/null +++ b/spec/fixtures/integration_aggregator/custom_object_response.json @@ -0,0 +1,1310 @@ +{ + "labels": { + "singular": "LagoInvoice", + "plural": "LagoInvoices" + }, + "requiredProperties": [ + "lago_invoice_id", + "lago_invoice_number" + ], + "searchableProperties": [ + "lago_invoice_id", + "lago_invoice_number" + ], + "primaryDisplayProperty": "lago_invoice_number", + "secondaryDisplayProperties": [ + "lago_invoice_status", + "lago_invoice_id" + ], + "description": "Invoices issued by Lago billing engine", + "archived": false, + "restorable": true, + "metaType": "PORTAL_SPECIFIC", + "id": "35482707", + "fullyQualifiedName": "p46788684_LagoInvoices", + "createdAt": "2024-10-10T16:44:12.791Z", + "updatedAt": "2024-10-10T16:44:14.736Z", + "createdByUserId": 68744312, + "updatedByUserId": 68744312, + "objectTypeId": "2-35482707", + "properties": [ + { + "name": "hs_all_accessible_team_ids", + "label": "All teams", + "type": "enumeration", + "fieldType": "checkbox", + "description": "The team IDs, including the team hierarchy, of all default and custom owner properties for this record.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_all_assigned_business_unit_ids", + "label": "Business units", + "type": "enumeration", + "fieldType": "checkbox", + "description": "The business units this record is assigned to.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_all_owner_ids", + "label": "All owner IDs", + "type": "enumeration", + "fieldType": "checkbox", + "description": "Values of all default and custom owner properties for this record.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_all_team_ids", + "label": "All team IDs", + "type": "enumeration", + "fieldType": "checkbox", + "description": "The team IDs of all default and custom owner properties for this record.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_created_by_user_id", + "label": "Created by user ID", + "type": "number", + "fieldType": "number", + "description": "The user who created this record. This value is set automatically by HubSpot.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_createdate", + "label": "Object create date/time", + "type": "datetime", + "fieldType": "date", + "description": "The date and time at which this object was created. This value is automatically set by HubSpot and may not be modified.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_lastmodifieddate", + "label": "Object last modified date/time", + "type": "datetime", + "fieldType": "date", + "description": "Most recent timestamp of any property update for this object. This includes HubSpot internal properties, which can be visible or hidden. This property is updated automatically.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_merged_object_ids", + "label": "Merged record IDs", + "type": "enumeration", + "fieldType": "checkbox", + "description": "The list of record IDs that have been merged into this record. This value is set automatically by HubSpot.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_id", + "label": "Record ID", + "type": "number", + "fieldType": "number", + "description": "The unique ID for this record. This value is set automatically by HubSpot.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_source", + "label": "Record creation source", + "type": "string", + "fieldType": "text", + "description": "Raw internal PropertySource present in the RequestMeta when this record was created.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_source_detail_1", + "label": "Record source detail 1", + "type": "string", + "fieldType": "text", + "description": "First level of detail on how this record was created.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_source_detail_2", + "label": "Record source detail 2", + "type": "string", + "fieldType": "text", + "description": "Second level of detail on how this record was created.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_source_detail_3", + "label": "Record source detail 3", + "type": "string", + "fieldType": "text", + "description": "Third level of detail on how this record was created.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_source_id", + "label": "Record creation source ID", + "type": "string", + "fieldType": "text", + "description": "Raw internal sourceId present in the RequestMeta when this record was created.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_source_label", + "label": "Record source", + "type": "enumeration", + "fieldType": "select", + "description": "How this record was created.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_object_source_user_id", + "label": "Record creation source user ID", + "type": "number", + "fieldType": "number", + "description": "Raw internal userId present in the RequestMeta when this record was created.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:13.352Z", + "createdAt": "2024-10-10T16:44:13.352Z", + "name": "hs_pinned_engagement_id", + "label": "Pinned Engagement ID", + "type": "number", + "fieldType": "number", + "description": "The object ID of the current pinned engagement. This will only be shown in the app if there is already an association to the engagement.", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_read_only", + "label": "Read only object", + "type": "bool", + "fieldType": "booleancheckbox", + "description": "Determines whether a record can be edited by a user.", + "groupName": "lagoinvoices_information", + "options": [ + { + "label": "True", + "value": "true", + "displayOrder": 0, + "hidden": false + }, + { + "label": "False", + "value": "false", + "displayOrder": 1, + "hidden": false + } + ], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_shared_team_ids", + "label": "Shared teams", + "type": "enumeration", + "fieldType": "checkbox", + "description": "Additional teams whose users can access the record based on their permissions. This can be set manually or through Workflows or APIs.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_shared_user_ids", + "label": "Shared users", + "type": "enumeration", + "fieldType": "checkbox", + "description": "Additional users that can access the record based on their permissions. This can be set manually or through Workflows and APIs.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_unique_creation_key", + "label": "Unique creation key", + "type": "string", + "fieldType": "text", + "description": "Unique property used for idempotent creates", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": true, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_updated_by_user_id", + "label": "Updated by user ID", + "type": "number", + "fieldType": "number", + "description": "The user who last updated this record. This value is set automatically by HubSpot.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_user_ids_of_all_notification_followers", + "label": "User IDs of all notification followers", + "type": "enumeration", + "fieldType": "checkbox", + "description": "The user IDs of all users that have clicked follow within the object to opt-in to getting follow notifications", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_user_ids_of_all_notification_unfollowers", + "label": "User IDs of all notification unfollowers", + "type": "enumeration", + "fieldType": "checkbox", + "description": "The user IDs of all object owners that have clicked unfollow within the object to opt-out of getting follow notifications", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_user_ids_of_all_owners", + "label": "User IDs of all owners", + "type": "enumeration", + "fieldType": "checkbox", + "description": "The user IDs of all owners of this record.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hs_was_imported", + "label": "Performed in an import", + "type": "bool", + "fieldType": "booleancheckbox", + "description": "Object is part of an import", + "groupName": "lagoinvoices_information", + "options": [ + { + "label": "True", + "value": "true", + "displayOrder": 0, + "hidden": false + }, + { + "label": "False", + "value": "false", + "displayOrder": 1, + "hidden": false + } + ], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": true, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hubspot_owner_assigneddate", + "label": "Owner assigned date", + "type": "datetime", + "fieldType": "date", + "description": "The most recent timestamp of when an owner was assigned to this record. This value is set automatically by HubSpot.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hubspot_owner_id", + "label": "Owner", + "type": "enumeration", + "fieldType": "select", + "description": "The owner of the object.", + "groupName": "lagoinvoices_information", + "options": [], + "referencedObjectType": "OWNER", + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "name": "hubspot_team_id", + "label": "Owner's main team", + "type": "enumeration", + "fieldType": "select", + "description": "The main team of the record owner. This value is set automatically by HubSpot.", + "groupName": "lagoinvoices_information", + "options": [], + "displayOrder": -1, + "calculated": false, + "externalOptions": true, + "hasUniqueValue": false, + "hidden": false, + "hubspotDefined": true, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": true, + "readOnlyValue": true + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_currency", + "label": "Lago Invoice Currency", + "type": "string", + "fieldType": "text", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_file_url", + "label": "Lago Invoice File URL", + "type": "string", + "fieldType": "file", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_id", + "label": "Lago Invoice Id", + "type": "string", + "fieldType": "text", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": true, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_issuing_date", + "label": "Lago Invoice Issuing Date", + "type": "date", + "fieldType": "date", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_number", + "label": "Lago Invoice Number", + "type": "string", + "fieldType": "text", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_payment_due_date", + "label": "Lago Invoice Payment Due Date", + "type": "date", + "fieldType": "date", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_payment_overdue", + "label": "Lago Invoice Payment Overdue", + "type": "bool", + "fieldType": "booleancheckbox", + "description": "", + "groupName": "lagoinvoices", + "options": [ + { + "label": "True", + "value": "true", + "displayOrder": 0, + "hidden": false + }, + { + "label": "False", + "value": "false", + "displayOrder": 1, + "hidden": false + } + ], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_payment_status", + "label": "Lago Invoice Payment Status", + "type": "string", + "fieldType": "text", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_status", + "label": "Lago Invoice Status", + "type": "string", + "fieldType": "text", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_subtotal_excluding_taxes", + "label": "Lago Invoice Subtotal Excluding Taxes", + "type": "number", + "fieldType": "number", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_total_amount", + "label": "Lago Invoice Total Amount", + "type": "number", + "fieldType": "number", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + }, + { + "updatedAt": "2024-10-10T16:44:12.996Z", + "createdAt": "2024-10-10T16:44:12.996Z", + "name": "lago_invoice_type", + "label": "Lago Invoice Type", + "type": "string", + "fieldType": "text", + "description": "", + "groupName": "lagoinvoices_information", + "options": [], + "createdUserId": "68744312", + "updatedUserId": "68744312", + "displayOrder": -1, + "calculated": false, + "externalOptions": false, + "archived": false, + "hasUniqueValue": false, + "hidden": false, + "modificationMetadata": { + "archivable": true, + "readOnlyDefinition": false, + "readOnlyValue": false + }, + "formField": false, + "dataSensitivity": "non_sensitive" + } + ], + "associations": [ + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-116", + "name": "lagoinvoices_to_postal_mail", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "615", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-116", + "toObjectTypeId": "2-35482707", + "name": "lagoinvoices_to_postal_mail", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "616", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-18", + "name": "communication_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "619", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-18", + "toObjectTypeId": "2-35482707", + "name": "communication_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "620", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-27", + "name": "lagoinvoices_to_task", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "611", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-27", + "toObjectTypeId": "2-35482707", + "name": "lagoinvoices_to_task", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "612", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-2", + "name": "company_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 50000, + "maxFromObjectIds": 50000, + "id": "627", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-2", + "toObjectTypeId": "2-35482707", + "name": "company_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 50000, + "maxFromObjectIds": 50000, + "id": "628", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-47", + "name": "lagoinvoices_to_meeting_event", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "617", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-47", + "toObjectTypeId": "2-35482707", + "name": "lagoinvoices_to_meeting_event", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "618", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-46", + "name": "lagoinvoices_to_note", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "623", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-46", + "toObjectTypeId": "2-35482707", + "name": "lagoinvoices_to_note", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "624", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-48", + "name": "call_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "621", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-48", + "toObjectTypeId": "2-35482707", + "name": "call_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "622", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-51", + "name": "conversation_session_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "609", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-51", + "toObjectTypeId": "2-35482707", + "name": "conversation_session_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "610", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-1", + "name": "contact_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 50000, + "maxFromObjectIds": 50000, + "id": "625", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-1", + "toObjectTypeId": "2-35482707", + "name": "contact_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 50000, + "maxFromObjectIds": 50000, + "id": "626", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "2-35482707", + "toObjectTypeId": "0-49", + "name": "email_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "613", + "createdAt": null, + "updatedAt": null + }, + { + "fromObjectTypeId": "0-49", + "toObjectTypeId": "2-35482707", + "name": "email_to_lagoinvoices", + "cardinality": "ONE_TO_MANY", + "inverseCardinality": "ONE_TO_MANY", + "hasUserEnforcedMaxToObjectIds": false, + "hasUserEnforcedMaxFromObjectIds": false, + "maxToObjectIds": 10000, + "maxFromObjectIds": 10000, + "id": "614", + "createdAt": null, + "updatedAt": null + } + ], + "name": "LagoInvoices" +} diff --git a/spec/fixtures/integration_aggregator/error_auth_response.json b/spec/fixtures/integration_aggregator/error_auth_response.json new file mode 100644 index 0000000..84e3de5 --- /dev/null +++ b/spec/fixtures/integration_aggregator/error_auth_response.json @@ -0,0 +1,6 @@ +{ + "error": { + "code": "invalid_secret_key_format", + "message": "Authentication failed. The provided secret key is not a UUID v4." + } +} diff --git a/spec/fixtures/integration_aggregator/error_payload_response.json b/spec/fixtures/integration_aggregator/error_payload_response.json new file mode 100644 index 0000000..c9b90f1 --- /dev/null +++ b/spec/fixtures/integration_aggregator/error_payload_response.json @@ -0,0 +1,9 @@ +{ + "error": { + "code": "action_script_runtime_error", + "payload": { + "name": "TypeError", + "message": "Please enter value(s) for: Company Name" + } + } +} diff --git a/spec/fixtures/integration_aggregator/error_response.json b/spec/fixtures/integration_aggregator/error_response.json new file mode 100644 index 0000000..bc8b898 --- /dev/null +++ b/spec/fixtures/integration_aggregator/error_response.json @@ -0,0 +1,6 @@ +{ + "type": "action_script_runtime_error", + "payload": { + "message": "submitFields: Missing a required argument: type" + } +} diff --git a/spec/fixtures/integration_aggregator/error_script_response.json b/spec/fixtures/integration_aggregator/error_script_response.json new file mode 100644 index 0000000..b883d88 --- /dev/null +++ b/spec/fixtures/integration_aggregator/error_script_response.json @@ -0,0 +1,9 @@ +{ + "error": { + "message": "The action script failed with an error: {}", + "code": "action_script_failure", + "payload": { + "error": "Error starting integration 'netsuite-customer-create': {\n \"name\": \"TRPCClientError\",\n \"message\": \"fetch failed\"\n}" + } + } +} diff --git a/spec/fixtures/integration_aggregator/invoices/failure_hash_response.json b/spec/fixtures/integration_aggregator/invoices/failure_hash_response.json new file mode 100644 index 0000000..fd783ad --- /dev/null +++ b/spec/fixtures/integration_aggregator/invoices/failure_hash_response.json @@ -0,0 +1,42 @@ +{ + "succeededInvoices": [], + "failedInvoices": [ + { + "id": "00000000-0000-0000-0000-000000000000", + "type": "ACCREC", + "external_contact_id": "b20f8b1e-b934-464d-9bf6-b82551b7f352", + "status": "AUTHORISED", + "issuing_date": "2024-12-19T00:00:00.000Z", + "payment_due_date": "2024-01-23T00:00:00.000Z", + "number": "INV-0071299", + "currency": "USD", + "purchase_order": null, + "fees": [ + { + "description": "Test item3", + "units": 10, + "precise_unit_amount": 15, + "account_code": "4000", + "amount_cents": 15000, + "taxes_amount_cents": 1800 + }, + { + "description": "Test item3", + "units": 1, + "precise_unit_amount": -1, + "account_code": "4000", + "amount_cents": -100, + "taxes_amount_cents": null + } + ], + "validation_errors": [ + { + "Message": "To update fields on a paid invoice line item, you must supply a LineItemID" + }, + { + "Message": "The status AUTHORISED cannot be applied to the invoice because it has payments or credit notes allocated to it." + } + ] + } + ] +} diff --git a/spec/fixtures/integration_aggregator/invoices/hubspot/failure_hash_response.json b/spec/fixtures/integration_aggregator/invoices/hubspot/failure_hash_response.json new file mode 100644 index 0000000..cd406e4 --- /dev/null +++ b/spec/fixtures/integration_aggregator/invoices/hubspot/failure_hash_response.json @@ -0,0 +1,17 @@ +{ + "error": { + "message": "An error occurred during an HTTP call", + "code": "script_http_error", + "payload": { + "status": "error", + "message": "Error creating LagoInvoices. Some required properties were not set.", + "correlationId": "12345678-1111-2222-3333-db140d8d163f", + "context": { + "properties": [ + "lago_invoice_id" + ] + }, + "category": "VALIDATION_ERROR" + } + } +} diff --git a/spec/fixtures/integration_aggregator/invoices/hubspot/success_hash_response.json b/spec/fixtures/integration_aggregator/invoices/hubspot/success_hash_response.json new file mode 100644 index 0000000..8ada6fb --- /dev/null +++ b/spec/fixtures/integration_aggregator/invoices/hubspot/success_hash_response.json @@ -0,0 +1,26 @@ +{ + "id": "123456789", + "properties": { + "hs_createdate": "2024-10-18T08:02:41.409Z", + "hs_lastmodifieddate": "2024-10-18T08:02:41.409Z", + "hs_object_id": "123456789123", + "hs_object_source": "INTEGRATION", + "hs_object_source_id": "3652947", + "hs_object_source_label": "INTEGRATION", + "lago_invoice_currency": "EUR", + "lago_invoice_file_url": "https://file.com", + "lago_invoice_id": "12345678-1234-1234-5678-7d76f44d5edc", + "lago_invoice_issuing_date": "2024-01-01", + "lago_invoice_number": "TWI-INV-001", + "lago_invoice_payment_due_date": "2024-01-01", + "lago_invoice_payment_overdue": "true", + "lago_invoice_payment_status": "failed", + "lago_invoice_status": "voided", + "lago_invoice_subtotal_excluding_taxes": "90.2", + "lago_invoice_total_amount": "100.2", + "lago_invoice_type": "subscription" + }, + "createdAt": "2024-10-18T08:02:41.409Z", + "updatedAt": "2024-10-18T08:02:41.409Z", + "archived": false +} diff --git a/spec/fixtures/integration_aggregator/invoices/success_hash_response.json b/spec/fixtures/integration_aggregator/invoices/success_hash_response.json new file mode 100644 index 0000000..6358937 --- /dev/null +++ b/spec/fixtures/integration_aggregator/invoices/success_hash_response.json @@ -0,0 +1,36 @@ +{ + "succeededInvoices": [ + { + "id": "cc1576cf-7b1c-480e-8f25-ae10fa34d6d1", + "type": "ACCREC", + "external_contact_id": "b20f8b1e-b934-464d-9bf6-b82551b7f352", + "status": "AUTHORISED", + "issuing_date": "2024-12-19T00:00:00.000Z", + "payment_due_date": "2024-01-23T00:00:00.000Z", + "number": "INV-007129", + "currency": "USD", + "purchase_order": null, + "fees": [ + { + "item_id": "44665d26-7852-4bfa-bc38-e4886c130fec", + "description": "Test item3", + "units": 10, + "precise_unit_amount": 15, + "account_code": "4000", + "amount_cents": 15000, + "taxes_amount_cents": 1800 + }, + { + "item_id": "7f2c5416-5f70-48c4-86f2-ace369afee22", + "description": "Test item3", + "units": 1, + "precise_unit_amount": -1, + "account_code": "4000", + "amount_cents": -100, + "taxes_amount_cents": 0 + } + ] + } + ], + "failedInvoices": [] +} diff --git a/spec/fixtures/integration_aggregator/invoices/success_string_response.json b/spec/fixtures/integration_aggregator/invoices/success_string_response.json new file mode 100644 index 0000000..063c417 --- /dev/null +++ b/spec/fixtures/integration_aggregator/invoices/success_string_response.json @@ -0,0 +1 @@ +"456" diff --git a/spec/fixtures/integration_aggregator/items_response.json b/spec/fixtures/integration_aggregator/items_response.json new file mode 100644 index 0000000..cd31511 --- /dev/null +++ b/spec/fixtures/integration_aggregator/items_response.json @@ -0,0 +1,70 @@ +{ + "records": [ + { + "id": "755", + "item_code": "test-lead-conduit", + "name": "Test-LeadConduit", + "account_code": "7691", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.430428+00:00", + "last_modified_at": "2024-04-17T12:10:02.430428+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi40MzA0MjgrMDA6MDB8fDAwNmZmYjJjLWQ1MjAtNWNiNy1hMjRhLTE5NzYzNzI1MDhlZQ==" + } + }, + { + "id": "745", + "item_code": "test-trusted-form", + "name": "Test-TrustedForm : TF Pings", + "account_code": "2428", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.430428+00:00", + "last_modified_at": "2024-04-17T12:10:02.430428+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi40MzA0MjgrMDA6MDB8fGY1ZDZiZmNiLTNlOGMtNWFlZS04OTU4LTQxMDBlMGNjMmYyMw==" + } + }, + { + "id": "753", + "item_code": "test-anura", + "name": "Test-Anura", + "account_code": "1411", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.430428+00:00", + "last_modified_at": "2024-04-17T12:10:02.430428+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi40MzA0MjgrMDA6MDB8fGZiM2UwYjhiLTYyNjctNWEwNy1iNzhiLTk2ZDY1YjIwMTAzNA==" + } + }, + { + "id": "484", + "item_code": "test-platform", + "name": "Test-Platform Subscription Annual", + "account_code": "9662", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.430428+00:00", + "last_modified_at": "2024-04-17T12:10:02.430428+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi40MzA0MjgrMDA6MDB8fGZlNWMwZjcxLTM0ZDktNTFiYy05ZGFiLTIxMGJkMTcyZmUyMw==" + } + }, + { + "id": "828", + "item_code": "test-lead-conduit-add-on", + "name": "Test-LeadConduit : Add-Ons : CoreLogic Title Verification", + "account_code": "2904", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.430428+00:00", + "last_modified_at": "2024-04-17T12:10:02.430428+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi40MzA0MjgrMDA6MDB8fGZlYTRjNGQwLWYwZjctNTQwZi1iNjE5LWIyZGZlYzM5NGM0Zg==" + } + } + ], + "next_cursor": null +} diff --git a/spec/fixtures/integration_aggregator/payments/failure_hash_response.json b/spec/fixtures/integration_aggregator/payments/failure_hash_response.json new file mode 100644 index 0000000..d551bf8 --- /dev/null +++ b/spec/fixtures/integration_aggregator/payments/failure_hash_response.json @@ -0,0 +1,17 @@ +{ + "succeededPayment": [], + "failedPayments": [ + { + "invoice_id": "99a63b5e-1d77-4c3b-b05f-d5fa33599593", + "credit_note_id": null, + "account_code": "4000", + "date": "2023-12-20T00:00:00.000Z", + "amount_cents": 16700, + "validation_errors": [ + { + "Message": "Invoice could not be found" + } + ] + } + ] +} diff --git a/spec/fixtures/integration_aggregator/payments/success_hash_response.json b/spec/fixtures/integration_aggregator/payments/success_hash_response.json new file mode 100644 index 0000000..710a928 --- /dev/null +++ b/spec/fixtures/integration_aggregator/payments/success_hash_response.json @@ -0,0 +1,14 @@ +{ + "succeededPayment": [ + { + "id": "e68f6095-f8d2-4d7a-ac05-7bb919d0330e", + "status": "AUTHORISED", + "invoice_id": "318ac709-3c26-46fe-b636-7b28031d638f", + "credit_note_id": null, + "account_code": "6040", + "date": "2023-12-20T00:00:00.000Z", + "amount_cents": 16700 + } + ], + "failedPayments": [] +} diff --git a/spec/fixtures/integration_aggregator/payments/success_string_response.json b/spec/fixtures/integration_aggregator/payments/success_string_response.json new file mode 100644 index 0000000..e91e065 --- /dev/null +++ b/spec/fixtures/integration_aggregator/payments/success_string_response.json @@ -0,0 +1 @@ +"999" diff --git a/spec/fixtures/integration_aggregator/subscriptions/hubspot/failure_hash_response.json b/spec/fixtures/integration_aggregator/subscriptions/hubspot/failure_hash_response.json new file mode 100644 index 0000000..b6a6f12 --- /dev/null +++ b/spec/fixtures/integration_aggregator/subscriptions/hubspot/failure_hash_response.json @@ -0,0 +1,17 @@ +{ + "error": { + "message": "An error occurred during an HTTP call", + "code": "script_http_error", + "payload": { + "status": "error", + "message": "Error creating LagoSubscriptions. Some required properties were not set.", + "correlationId": "b9561d6a-f454-4146-ac49-c7ce9a8fe7cc", + "context": { + "properties": [ + "lago_subscription_id" + ] + }, + "category": "VALIDATION_ERROR" + } + } +} diff --git a/spec/fixtures/integration_aggregator/subscriptions/hubspot/success_hash_response.json b/spec/fixtures/integration_aggregator/subscriptions/hubspot/success_hash_response.json new file mode 100644 index 0000000..119684a --- /dev/null +++ b/spec/fixtures/integration_aggregator/subscriptions/hubspot/success_hash_response.json @@ -0,0 +1,26 @@ +{ + "id": "123456789123", + "properties": { + "hs_createdate": "2024-10-22T08:37:45.758Z", + "hs_lastmodifieddate": "2024-10-22T08:37:45.758Z", + "hs_object_id": "123456789123", + "hs_object_source": "INTEGRATION", + "hs_object_source_id": "3652947", + "hs_object_source_label": "INTEGRATION", + "lago_billing_time": "calendar", + "lago_external_subscription_id": "12345678-1234-1234-5678-7d76f44d5edc", + "lago_subscription_at": "2024-01-01", + "lago_subscription_created_at": "2024-01-01", + "lago_subscription_ending_at": "2025-01-01", + "lago_subscription_id": "12345678-1234-1234-5678-7d76f44d5edc", + "lago_subscription_name": "Sub001", + "lago_subscription_plan_code": "premium", + "lago_subscription_started_at": "2024-01-01", + "lago_subscription_status": "active", + "lago_subscription_terminated_at": null, + "lago_subscription_trial_ended_at": null + }, + "createdAt": "2024-10-22T08:37:45.758Z", + "updatedAt": "2024-10-22T08:37:45.758Z", + "archived": false +} diff --git a/spec/fixtures/integration_aggregator/subsidiaries_response.json b/spec/fixtures/integration_aggregator/subsidiaries_response.json new file mode 100644 index 0000000..262189e --- /dev/null +++ b/spec/fixtures/integration_aggregator/subsidiaries_response.json @@ -0,0 +1,49 @@ +{ + "records": [ + { + "id": "1", + "name": "Holo, Inc.", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.341285+00:00", + "last_modified_at": "2024-04-17T12:10:02.341285+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi4zNDEyODUrMDA6MDB8fDAwYTM5NzdmLWYxYjctNTU2ZS04ZDRhLTJkYTRmY2UyODkwMg==" + } + }, + { + "id": "3", + "name": "Holo, Inc. : Elimination Subsidiary", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.341285+00:00", + "last_modified_at": "2024-04-17T12:10:02.341285+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi4zNDEyODUrMDA6MDB8fDYwOWNmYzg5LTdhMGItNTYxNy1iYWEyLWU3ODczNzE3NzJlYQ==" + } + }, + { + "id": "4", + "name": "Holo, Inc. : Te Inc", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.341285+00:00", + "last_modified_at": "2024-04-17T12:10:02.341285+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi4zNDEyODUrMDA6MDB8fDhjNzY3NDg1LTQyOTgtNWU1YS1hOWI0LTIyMTZhOTYwMTk0ZQ==" + } + }, + { + "id": "2", + "name": "Holo, Inc. : Te SRL", + "_nango_metadata": { + "first_seen_at": "2024-04-17T12:10:02.341285+00:00", + "last_modified_at": "2024-04-17T12:10:02.341285+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMjoxMDowMi4zNDEyODUrMDA6MDB8fGI5MzYyMTkxLWU0NzItNTU1Zi1hYTA0LTQzZmFhOTg1NjU5OA==" + } + } + ], + "next_cursor": null +} \ No newline at end of file diff --git a/spec/fixtures/integration_aggregator/tax_items_response.json b/spec/fixtures/integration_aggregator/tax_items_response.json new file mode 100644 index 0000000..bda6b1d --- /dev/null +++ b/spec/fixtures/integration_aggregator/tax_items_response.json @@ -0,0 +1,49 @@ +{ + "records": [ + { + "id": "-3557", + "name": "CA_PLPL", + "_nango_metadata": { + "first_seen_at": "2024-04-17T13:04:48.699947+00:00", + "last_modified_at": "2024-04-17T13:04:48.699947+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMzowNDo0OC42OTk5NDcrMDA6MDB8fDAwMDU4MWQ1LThkNGMtNTk0Yi1hMDJkLWYyOTU3YzFiZTNjMA==" + } + }, + { + "id": "-3879", + "name": "CA_ARROWHED FARM", + "_nango_metadata": { + "first_seen_at": "2024-04-17T13:04:48.699947+00:00", + "last_modified_at": "2024-04-17T13:04:48.699947+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMzowNDo0OC42OTk5NDcrMDA6MDB8fDAwMmVlYjA2LTRlMjktNTc0ZC04NWJhLTIwMzU4NWM5NGM2MA==" + } + }, + { + "id": "-4692", + "name": "GA_STEWART", + "_nango_metadata": { + "first_seen_at": "2024-04-17T13:04:48.699947+00:00", + "last_modified_at": "2024-04-17T13:04:48.699947+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMzowNDo0OC42OTk5NDcrMDA6MDB8fDAwOGEzYmFmLTY5MmItNTllNC1hYmYyLWJmMDY1YWVmM2JlNg==" + } + }, + { + "id": "-5307", + "name": "IL_FRANKLIN PARK", + "_nango_metadata": { + "first_seen_at": "2024-04-17T13:04:48.699947+00:00", + "last_modified_at": "2024-04-17T13:04:48.699947+00:00", + "last_action": "ADDED", + "deleted_at": null, + "cursor": "MjAyNC0wNC0xN1QxMzowNDo0OC42OTk5NDcrMDA6MDB8fDAwYTEwZDllLTk2NjUtNWNlYy1iZDAwLWJlMjA3ZTk2ODJmMw==" + } + } + ], + "next_cursor": null +} \ No newline at end of file diff --git a/spec/fixtures/integration_aggregator/taxes/companies/failed_response.json b/spec/fixtures/integration_aggregator/taxes/companies/failed_response.json new file mode 100644 index 0000000..eaa6ca2 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/companies/failed_response.json @@ -0,0 +1,3 @@ +{ + "companies": [] +} diff --git a/spec/fixtures/integration_aggregator/taxes/companies/success_response.json b/spec/fixtures/integration_aggregator/taxes/companies/success_response.json new file mode 100644 index 0000000..e35c0f9 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/companies/success_response.json @@ -0,0 +1,7 @@ +{ + "companies": [ + { + "id": "DEFAULT-12345" + } + ] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/api_limit_response.json b/spec/fixtures/integration_aggregator/taxes/invoices/api_limit_response.json new file mode 100644 index 0000000..0279fb8 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/api_limit_response.json @@ -0,0 +1,27 @@ +{ + "succeededInvoices": [], + "failedInvoices": [ + { + "issuing_date": "2024-12-03", + "currency": "EUR", + "contact": { + "external_id": "sync-1-test-1", + "name": "sync-1-test-1", + "address_line_1": "88 rue du Chemin Vert", + "city": "Paris", + "zip": "75011", + "country": "fr", + "taxable": true, + "tax_number": "FR86894827773" + }, + "fees": [ + { + "item_id": "sync-1", + "item_code": "lago_default_b2b", + "amount_cents": 2000 + } + ], + "validation_errors": "You've exceeded your API limit of 10 per second" + } + ] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json new file mode 100644 index 0000000..c214997 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json @@ -0,0 +1,29 @@ +{ + "succeededInvoices": [], + "failedInvoices": [ + { + "issuing_date": "2024-12-03", + "currency": "EUR", + "contact": { + "external_id": "sync-1-test-1", + "name": "sync-1-test-1", + "address_line_1": "88 rue du Chemin Vert", + "city": "Paris", + "zip": "75011", + "country": "fr", + "taxable": true, + "tax_number": "FR86894827773" + }, + "fees": [ + { + "item_id": "sync-1", + "item_code": "lago_default_b2b", + "amount_cents": 2000 + } + ], + "validation_errors": { + "type": "taxDateTooFarInFuture" + } + } + ] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_locked_void.json b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_locked_void.json new file mode 100644 index 0000000..6203713 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_locked_void.json @@ -0,0 +1,11 @@ +{ + "succeededInvoices": [], + "failedInvoices": [ + { + "id": "invoice_id", + "validation_errors": { + "type": "CannotModifyLockedTransaction" + } + } + ] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json new file mode 100644 index 0000000..bc81e48 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json @@ -0,0 +1,11 @@ +{ + "succeededInvoices": [], + "failedInvoices": [ + { + "id": "invoice_id", + "validation_errors": { + "type": "transactionFrozenForFiling" + } + } + ] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/success_response.json b/spec/fixtures/integration_aggregator/taxes/invoices/success_response.json new file mode 100644 index 0000000..1bea2f9 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/success_response.json @@ -0,0 +1,38 @@ +{ + "succeededInvoices": [ + { + "id": "inv_1234567890", + "issuing_date": "2024-03-07", + "sub_total_excluding_taxes": 9900, + "taxes_amount_cents": 99, + "currency": "USD", + "contact": { + "external_id": "cus_lago_12345", + "name": "John Doe", + "taxable": true, + "tax_number": "1234567890" + }, + "fees": [ + { + "item_id": "lago_fee_id", + "item_code": "lago_default_b2b", + "amount_cents": 9900, + "tax_amount_cents": 990, + "tax_breakdown": [ + { + "name": "GST/HST", + "rate": "0.10", + "tax_amount": 990, + "type": "tax_exempt" + }, + { + "reason": "reverseCharge", + "type": "exempt" + } + ] + } + ] + } + ], + "failedInvoices": [] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json new file mode 100644 index 0000000..d61afd4 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json @@ -0,0 +1,46 @@ +{ + "succeededInvoices": [ + { + "id": "transaction-id-12345", + "issuing_date": "2024-03-07", + "sub_total_excluding_taxes": 9900, + "taxes_amount_cents": 99, + "currency": "USD", + "contact": { + "external_id": "cus_lago_12345", + "name": "John Doe", + "taxable": true, + "tax_number": "1234567890" + }, + "fees": [ + { + "item_id": "sub_fee_id-12345", + "item_code": "lago_default_b2b", + "amount_cents": 2000, + "tax_amount_cents": 200, + "tax_breakdown": [ + { + "name": "GST/HST", + "rate": "0.10", + "tax_amount": 200 + } + ] + }, + { + "item_id": "charge_fee_id-12345", + "item_code": "lago_default_b2c", + "amount_cents": 1000, + "tax_amount_cents": 150, + "tax_breakdown": [ + { + "name": "GST/HST", + "rate": "0.15", + "tax_amount": 150 + } + ] + } + ] + } + ], + "failedInvoices": [] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/success_response_negate.json b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_negate.json new file mode 100644 index 0000000..fd4dc68 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_negate.json @@ -0,0 +1,9 @@ +{ + "succeededInvoices": [ + { + "id": "f9cde4d6-3b79-4c44-89b5-d5a91e430054", + "voided_id": "f9cde4d6-3b79-4c44-89b5-d5a91e430054_voided" + } + ], + "failedInvoices": [] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/success_response_seller_pays_taxes.json b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_seller_pays_taxes.json new file mode 100644 index 0000000..df9b7b2 --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_seller_pays_taxes.json @@ -0,0 +1,33 @@ +{ + "succeededInvoices": [ + { + "issuing_date": "2024-03-07", + "sub_total_excluding_taxes": 9900, + "taxes_amount_cents": 0, + "currency": "USD", + "contact": { + "external_id": "cus_lago_12345", + "name": "John Doe", + "taxable": true, + "tax_number": "1234567890" + }, + "fees": [ + { + "item_id": "lago_fee_id", + "item_code": "lago_default_b2b", + "amount_cents": 9900, + "tax_amount_cents": 0, + "tax_breakdown": [ + { + "name": "GST/HST", + "rate": "0.10", + "tax_amount": 990, + "type": "tax_exempt" + } + ] + } + ] + } + ], + "failedInvoices": [] +} diff --git a/spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json new file mode 100644 index 0000000..98c53fa --- /dev/null +++ b/spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json @@ -0,0 +1,8 @@ +{ + "succeededInvoices": [ + { + "id": "f9cde4d6-3b79-4c44-89b5-d5a91e430054" + } + ], + "failedInvoices": [] +} diff --git a/spec/fixtures/lago_data_api/mrrs.json b/spec/fixtures/lago_data_api/mrrs.json new file mode 100644 index 0000000..463ba30 --- /dev/null +++ b/spec/fixtures/lago_data_api/mrrs.json @@ -0,0 +1,54 @@ +[ + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2023-11-01", + "end_of_period_dt": "2023-11-30", + "amount_currency": "EUR", + "starting_mrr": 0, + "ending_mrr": 23701746, + "mrr_new": 25016546, + "mrr_expansion": 0, + "mrr_contraction": 0, + "mrr_churn": -1314800, + "mrr_change": 23701746 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2023-12-01", + "end_of_period_dt": "2023-12-31", + "amount_currency": "EUR", + "starting_mrr": 23701746, + "ending_mrr": 23457216, + "mrr_new": 6171791, + "mrr_expansion": 0, + "mrr_contraction": 0, + "mrr_churn": -6416321, + "mrr_change": -244530 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "amount_currency": "EUR", + "starting_mrr": 23457216, + "ending_mrr": 24821948, + "mrr_new": 4334134, + "mrr_expansion": 0, + "mrr_contraction": 0, + "mrr_churn": -2969402, + "mrr_change": 1364732 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-02-01", + "end_of_period_dt": "2024-02-29", + "amount_currency": "EUR", + "starting_mrr": 24821948, + "ending_mrr": 25828288, + "mrr_new": 1306340, + "mrr_expansion": 0, + "mrr_contraction": 0, + "mrr_churn": -300000, + "mrr_change": 1006340 + } +] \ No newline at end of file diff --git a/spec/fixtures/lago_data_api/mrrs_plans.json b/spec/fixtures/lago_data_api/mrrs_plans.json new file mode 100644 index 0000000..f515968 --- /dev/null +++ b/spec/fixtures/lago_data_api/mrrs_plans.json @@ -0,0 +1,68 @@ + +{ + "mrrs_plans": [ + { + "dt": "2025-02-25", + "amount_currency": "EUR", + "plan_id": "8f550d3e-1234-4f4d-a752-61b0f98a9ef7", + "active_customers_count": 1, + "mrr": 1000000.0, + "mrr_share": 0.0279, + "plan_name": "Tondr", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_code": "custom_plan_tondr", + "plan_deleted_at": null, + "plan_interval": "monthly", + "active_customers_share": 0.009 + }, + { + "dt": "2025-02-25", + "amount_currency": "EUR", + "plan_id": "627ee06a-4567-47f7-8eae-a07eec5a9ba3", + "active_customers_count": 1, + "mrr": 849000.0, + "mrr_share": 0.0237, + "plan_name": "Place", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_code": "custom_plan_place", + "plan_deleted_at": null, + "plan_interval": "monthly", + "active_customers_share": 0.009 + }, + { + "dt": "2025-02-25", + "amount_currency": "EUR", + "plan_id": "504cb319-8910-4175-9364-840028c39475", + "active_customers_count": 1, + "mrr": 799000.0, + "mrr_share": 0.0223, + "plan_name": "Silo", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_code": "custom_plan_silo", + "plan_deleted_at": null, + "plan_interval": "monthly", + "active_customers_share": 0.009 + }, + { + "dt": "2025-02-25", + "amount_currency": "EUR", + "plan_id": "8d39f27f-1112-43ea-a327-c9579e70eeb3", + "active_customers_count": 1, + "mrr": 790000.0, + "mrr_share": 0.022, + "plan_name": "Penny", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_code": "custom_plan_penny", + "plan_deleted_at": null, + "plan_interval": "monthly", + "active_customers_share": 0.009 + } + ], + "meta": { + "current_page": 1, + "next_page": 2, + "prev_page": 0, + "total_count": 100, + "total_pages": 5 + } +} diff --git a/spec/fixtures/lago_data_api/prepaid_credits.json b/spec/fixtures/lago_data_api/prepaid_credits.json new file mode 100644 index 0000000..3808707 --- /dev/null +++ b/spec/fixtures/lago_data_api/prepaid_credits.json @@ -0,0 +1,44 @@ +[ + { + "organization_id": "5e6eb312-1e25-40d7-83b8-4ee117b74255", + "start_of_period_dt": "2023-12-01", + "end_of_period_dt": "2023-12-31", + "amount_currency": "EUR", + "purchased_amount": 0.0, + "offered_amount": 0.0, + "consumed_amount": 120.45, + "voided_amount": 0.0, + "purchased_credits_quantity": 0.0, + "offered_credits_quantity": 0.0, + "consumed_credits_quantity": 120.45, + "voided_credits_quantity": 0.0 + }, + { + "organization_id": "5e6eb312-1e25-40d7-83b8-4ee117b74255", + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "amount_currency": "EUR", + "purchased_amount": 0.0, + "offered_amount": 0.0, + "consumed_amount": 8223.74, + "voided_amount": 0.0, + "purchased_credits_quantity": 0.0, + "offered_credits_quantity": 0.0, + "consumed_credits_quantity": 8223.74, + "voided_credits_quantity": 0.0 + }, + { + "organization_id": "5e6eb312-1e25-40d7-83b8-4ee117b74255", + "start_of_period_dt": "2024-02-01", + "end_of_period_dt": "2024-02-29", + "amount_currency": "EUR", + "purchased_amount": 0.0, + "offered_amount": 0.0, + "consumed_amount": 10340.97, + "voided_amount": 0.0, + "purchased_credits_quantity": 0.0, + "offered_credits_quantity": 0.0, + "consumed_credits_quantity": 10340.97, + "voided_credits_quantity": 0.0 + } +] diff --git a/spec/fixtures/lago_data_api/revenue_streams.json b/spec/fixtures/lago_data_api/revenue_streams.json new file mode 100644 index 0000000..8a1eb8e --- /dev/null +++ b/spec/fixtures/lago_data_api/revenue_streams.json @@ -0,0 +1,158 @@ +[ + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 46256357, + "usage_based_fee_amount_cents": 20574902, + "one_off_fee_amount_cents": 0, + "subscription_fee_amount_cents": 25681455, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 46256357 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-02-01", + "end_of_period_dt": "2024-02-29", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 37096414, + "usage_based_fee_amount_cents": 13661672, + "one_off_fee_amount_cents": 0, + "subscription_fee_amount_cents": 23434742, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 37096414 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-03-01", + "end_of_period_dt": "2024-03-31", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 93532863, + "usage_based_fee_amount_cents": 46112907, + "one_off_fee_amount_cents": 146705, + "subscription_fee_amount_cents": 47245987, + "commitment_fee_amount_cents": 27264, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 93532863 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-04-01", + "end_of_period_dt": "2024-04-30", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 73819070, + "usage_based_fee_amount_cents": 38071434, + "one_off_fee_amount_cents": 960000, + "subscription_fee_amount_cents": 34787636, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 73819070 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-05-01", + "end_of_period_dt": "2024-05-31", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 71893223, + "usage_based_fee_amount_cents": 36955129, + "one_off_fee_amount_cents": 512500, + "subscription_fee_amount_cents": 34425594, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 71893223 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-06-01", + "end_of_period_dt": "2024-06-30", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 71622949, + "usage_based_fee_amount_cents": 38000302, + "one_off_fee_amount_cents": 2878247, + "subscription_fee_amount_cents": 30744400, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 71622949 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-07-01", + "end_of_period_dt": "2024-07-31", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 92848061, + "usage_based_fee_amount_cents": 51953494, + "one_off_fee_amount_cents": 1854954, + "subscription_fee_amount_cents": 39039613, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 92848061 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-08-01", + "end_of_period_dt": "2024-08-31", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 77280393, + "usage_based_fee_amount_cents": 40273238, + "one_off_fee_amount_cents": 2301159, + "subscription_fee_amount_cents": 34705996, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 77280393 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-09-01", + "end_of_period_dt": "2024-09-30", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 81456025, + "usage_based_fee_amount_cents": 49038731, + "one_off_fee_amount_cents": 2699250, + "subscription_fee_amount_cents": 29718044, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 81456025 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-10-01", + "end_of_period_dt": "2024-10-31", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 97921834, + "usage_based_fee_amount_cents": 60737725, + "one_off_fee_amount_cents": 827400, + "subscription_fee_amount_cents": 36356709, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 97921834 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-11-01", + "end_of_period_dt": "2024-11-30", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 67457101, + "usage_based_fee_amount_cents": 42991476, + "one_off_fee_amount_cents": 535118, + "subscription_fee_amount_cents": 23930507, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 67457101 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-12-01", + "end_of_period_dt": "2024-12-31", + "amount_currency": "EUR", + "gross_revenue_amount_cents": 29571906, + "usage_based_fee_amount_cents": 14717059, + "one_off_fee_amount_cents": 821502, + "subscription_fee_amount_cents": 14033345, + "commitment_fee_amount_cents": 0, + "coupons_amount_cents": 0, + "net_revenue_amount_cents": 29571906 + } +] diff --git a/spec/fixtures/lago_data_api/revenue_streams_customers.json b/spec/fixtures/lago_data_api/revenue_streams_customers.json new file mode 100644 index 0000000..ca530a5 --- /dev/null +++ b/spec/fixtures/lago_data_api/revenue_streams_customers.json @@ -0,0 +1,59 @@ +{ + "revenue_streams_customers": [ + { + "amount_currency": "EUR", + "customer_id": "e4676e50-1234-4606-bcdb-42effbc2b635", + "customer_deleted_at": null, + "customer_name": "Penny", + "gross_revenue_share": 0.1185, + "net_revenue_share": 0.1185, + "organization_id": "c0047031-41b6-4386-a10b-0a36f787c84f", + "external_customer_id": "2537afc4-1234-4abb-89b7-d9b28c35780b", + "gross_revenue_amount_cents": 124628322, + "net_revenue_amount_cents": 124628322 + }, + { + "amount_currency": "EUR", + "customer_id": "144944d4-5678-4136-854f-11b92b171847", + "customer_deleted_at": null, + "customer_name": "Indy", + "gross_revenue_share": 0.0767, + "net_revenue_share": 0.0767, + "organization_id": "c0047031-41b6-4386-a10b-0a36f787c84f", + "external_customer_id": "cb999dff-4567-4d08-962d-93565dfdbad8", + "gross_revenue_amount_cents": 80609006, + "net_revenue_amount_cents": 80609006 + }, + { + "amount_currency": "EUR", + "customer_id": "93fdf149-9012-4f7e-b799-20b7edbb9628", + "customer_deleted_at": null, + "customer_name": "Place", + "gross_revenue_share": 0.056, + "net_revenue_share": 0.056, + "organization_id": "c0047031-41b6-4386-a10b-0a36f787c84f", + "external_customer_id": "58dd4fa2-8910-4a41-825b-ca3b865fe81b", + "gross_revenue_amount_cents": 58883577, + "net_revenue_amount_cents": 58883577 + }, + { + "amount_currency": "EUR", + "customer_id": "8fd8731f-3456-4d2e-96c9-a9aa7a6a2550", + "customer_deleted_at": null, + "customer_name": "Piano", + "gross_revenue_share": 0.042, + "net_revenue_share": 0.042, + "organization_id": "c0047031-41b6-4386-a10b-0a36f787c84f", + "external_customer_id": "ca3535f0-2345-4f5e-9238-e6ed7372b133", + "gross_revenue_amount_cents": 44196934, + "net_revenue_amount_cents": 44196934 + } + ], + "meta": { + "current_page": 1, + "next_page": 2, + "prev_page": 0, + "total_pages": 5, + "total_count": 100 + } +} \ No newline at end of file diff --git a/spec/fixtures/lago_data_api/revenue_streams_plans.json b/spec/fixtures/lago_data_api/revenue_streams_plans.json new file mode 100644 index 0000000..75bbec2 --- /dev/null +++ b/spec/fixtures/lago_data_api/revenue_streams_plans.json @@ -0,0 +1,71 @@ +{ + "revenue_streams_plans": [ + { + "plan_id": "8d39f27f-8371-43ea-a327-c9579e70eeb3", + "amount_currency": "EUR", + "plan_code": "custom_plan_penny", + "plan_deleted_at": null, + "customers_count": 1, + "gross_revenue_amount_cents": 120735293, + "net_revenue_amount_cents": 120735293, + "plan_name": "Penny", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_interval": "monthly", + "customers_share": 0.0055, + "gross_revenue_share": 0.1148, + "net_revenue_share": 0.1148 + }, + { + "plan_id": "bea5197f-d862-4a38-89e0-4244b07aa365", + "amount_currency": "EUR", + "plan_code": "custom_plan_indy", + "plan_deleted_at": null, + "customers_count": 1, + "gross_revenue_amount_cents": 79260312, + "net_revenue_amount_cents": 79260312, + "plan_name": "Indy", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_interval": "monthly", + "customers_share": 0.0055, + "gross_revenue_share": 0.0754, + "net_revenue_share": 0.0754 + }, + { + "plan_id": "627ee06a-6e08-47f7-8eae-a07eec5a9ba3", + "amount_currency": "EUR", + "plan_code": "custom_plan_place", + "plan_deleted_at": null, + "customers_count": 1, + "gross_revenue_amount_cents": 57755092, + "net_revenue_amount_cents": 57755092, + "plan_name": "Place", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_interval": "monthly", + "customers_share": 0.0055, + "gross_revenue_share": 0.0549, + "net_revenue_share": 0.0549 + }, + { + "plan_id": "25d18c15-71ae-49af-bd38-94c7c2c80c7c", + "amount_currency": "EUR", + "plan_code": "custom_plan_piano", + "plan_deleted_at": null, + "customers_count": 1, + "gross_revenue_amount_cents": 40018661, + "net_revenue_amount_cents": 40018661, + "plan_name": "Piano", + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_interval": "monthly", + "customers_share": 0.0055, + "gross_revenue_share": 0.0381, + "net_revenue_share": 0.0381 + } + ], + "meta": { + "current_page": 1, + "next_page": 2, + "prev_page": 0, + "total_count": 100, + "total_pages": 5 + } +} \ No newline at end of file diff --git a/spec/fixtures/lago_data_api/usages.json b/spec/fixtures/lago_data_api/usages.json new file mode 100644 index 0000000..2c11c20 --- /dev/null +++ b/spec/fixtures/lago_data_api/usages.json @@ -0,0 +1,26 @@ +[ + { + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "billable_metric_code": "account_members", + "amount_currency": "EUR", + "amount_cents": 26600, + "units": 266 + }, + { + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "billable_metric_code": "accounts", + "amount_currency": "EUR", + "amount_cents": 145950, + "units": 1459 + }, + { + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "billable_metric_code": "business_account_opening", + "amount_currency": "EUR", + "amount_cents": 2521800, + "units": 25218 + } +] diff --git a/spec/fixtures/lago_data_api/usages_aggregated_amounts.json b/spec/fixtures/lago_data_api/usages_aggregated_amounts.json new file mode 100644 index 0000000..e95e3cd --- /dev/null +++ b/spec/fixtures/lago_data_api/usages_aggregated_amounts.json @@ -0,0 +1,20 @@ +[ + { + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "amount_currency": "EUR", + "amount_cents": 26600 + }, + { + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "amount_currency": "EUR", + "amount_cents": 145950 + }, + { + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "amount_currency": "EUR", + "amount_cents": 2521800 + } +] diff --git a/spec/fixtures/lago_data_api/usages_forecasted.json b/spec/fixtures/lago_data_api/usages_forecasted.json new file mode 100644 index 0000000..8fb561f --- /dev/null +++ b/spec/fixtures/lago_data_api/usages_forecasted.json @@ -0,0 +1,15 @@ +[ + { + "start_of_period_dt": "2025-06-27T06:46:28.300Z", + "end_of_period_dt": "2025-06-28T06:46:28.300Z", + "amount_currency": "EUR", + "units": 100, + "amount_cents": 1000, + "units_forecast_conservative": 100, + "units_forecast_realistic": 100, + "units_forecast_optimistic": 100, + "amount_cents_forecast_conservative": 1000, + "amount_cents_forecast_realistic": 1000, + "amount_cents_forecast_optimistic": 1000 + } +] diff --git a/spec/fixtures/lago_data_api/usages_invoiced.json b/spec/fixtures/lago_data_api/usages_invoiced.json new file mode 100644 index 0000000..4d1bf98 --- /dev/null +++ b/spec/fixtures/lago_data_api/usages_invoiced.json @@ -0,0 +1,34 @@ +[ + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "billable_metric_code": "account_members", + "amount_currency": "EUR", + "amount_cents": 26600 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "billable_metric_code": "accounts", + "amount_currency": "EUR", + "amount_cents": 145950 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "billable_metric_code": "applepay_cards_enrolled", + "amount_currency": "EUR", + "amount_cents": 20175 + }, + { + "organization_id": "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt": "2024-01-01", + "end_of_period_dt": "2024-01-31", + "billable_metric_code": "business_account_opening", + "amount_currency": "EUR", + "amount_cents": 2521800 + } +] diff --git a/spec/fixtures/moneyhash/card_token.created.json b/spec/fixtures/moneyhash/card_token.created.json new file mode 100644 index 0000000..9be269b --- /dev/null +++ b/spec/fixtures/moneyhash/card_token.created.json @@ -0,0 +1,42 @@ +{ + "type": "card_token.created", + "data": { + "intent_id": "Z0e5lo9", + "card_token": { + "id": "419cb5f7-20fc-4571-a64d-4a64f0a0412f", + "hashid": "gMRXKaZ", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_mit": false, + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_mh_service": "Invoices::Payments::MoneyhashService", + "lago_payable_id": "23fd1f3c-922c-4928-b40b-c8a31e4d6664", + "lago_customer_id": "53c14b65-3406-4b1b-9d49-77fb389ee92e", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_subscription_external_id": "69f64026-69eb-4836-b1bd-9323d9f7d892" + }, + "provider_token_data": [ + { + "last_4": "0000", + "service_provider_id": "gQJnWwZ", + "pay_in_method": "gqYGvEg", + "provider_specific_data": { + "token_id": "test_token" + } + } + ], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + } + } +} diff --git a/spec/fixtures/moneyhash/card_token.deleted.json b/spec/fixtures/moneyhash/card_token.deleted.json new file mode 100644 index 0000000..2919aa3 --- /dev/null +++ b/spec/fixtures/moneyhash/card_token.deleted.json @@ -0,0 +1,26 @@ +{ + "type": "card_token.deleted", + "data": { + "intent_id": "ZBv8DkZ", + "card_token": { + "id": "bf05e221-1305-44cb-ac04-f227f0a8b67a", + "hashid": "gERzJPZ", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_customer_id": "2f2f4867-1ac9-4529-b03b-4f7133897b82" + }, + "provider_token_data": [], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + } + } +} diff --git a/spec/fixtures/moneyhash/card_token.updated.json b/spec/fixtures/moneyhash/card_token.updated.json new file mode 100644 index 0000000..5b57bb9 --- /dev/null +++ b/spec/fixtures/moneyhash/card_token.updated.json @@ -0,0 +1,40 @@ +{ + "type": "card_token.updated", + "data": { + "intent_id": "LW0prGg", + "card_token": { + "id": "419cb5f7-20fc-4571-a64d-4a64f0a0412f", + "hashid": "gMRXKaZ", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_mit": false, + "lago_mh_service": "Invoices::Payments::MoneyhashService", + "lago_payable_id": "713ba385-5686-4a52-b5f6-a9b678ef4535", + "lago_customer_id": "53c14b65-3406-4b1b-9d49-77fb389ee92e", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414" + }, + "provider_token_data": [ + { + "last_4": "0000", + "service_provider_id": "gQJnWwZ", + "pay_in_method": "gqYGvEg", + "provider_specific_data": { + "token_id": "test_token" + } + } + ], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + } + } +} diff --git a/spec/fixtures/moneyhash/checkout_url_response.json b/spec/fixtures/moneyhash/checkout_url_response.json new file mode 100644 index 0000000..fb12d99 --- /dev/null +++ b/spec/fixtures/moneyhash/checkout_url_response.json @@ -0,0 +1,67 @@ +{ + "status": { + "code": 200, + "message": "success", + "errors": [] + }, + "data": { + "embed_url": "https://stg-embed.moneyhash.io/embed/payment/LYPoo59", + "id": "LYPoo59", + "status": "UNPROCESSED", + "amount": 50.0, + "amount_currency": "USD", + "type": "Payin", + "account": "lgaxEBL", + "custom_fields": { + "lago_mit": false, + "lago_customer_id": "2f2f4867-1ac9-4529-b03b-4f7133897b82", + "lago_request": "generate_checkout_url", + "lago_provider_customer_id": "d835774d-7839-48b2-9161-aaae81eb3b89", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414" + }, + "billing_data": { + "first_name": "mustafa", + "last_name": "eid", + "email": "test@email.com", + "phone_number": "+201064610000" + }, + "transaction_provider_fields": {}, + "active_transaction": null, + "transactions_history": [], + "flow": "xgXlXgX", + "flow_data": { + "id": "xgXlXgX", + "version": 1, + "sequence": null + }, + "is_live": false, + "created": "today", + "template": null, + "merchant_reference": null, + "customer": "d835774d-7839-48b2-9161-aaae81eb3b89", + "state": "INTENT_FORM", + "state_details": { + "embed_url": "https://stg-embed.moneyhash.io/embed/payment/LYPoo59" + }, + "customer_last_used_payment_method": "CARD", + "last_used_method": { + "id": "CARD", + "type": "payment_method" + }, + "payment_status": { + "status": "NO_AUTHORIZE_ATTEMPTS", + "balances": { + "total_authorized": "0.00", + "total_voided": "0.00", + "available_to_void": "0.00", + "total_captured": "0.00", + "available_to_capture": "0.00", + "total_refunded": "0.00", + "available_to_refund": "0.00" + } + } + }, + "count": 1, + "next": null, + "previous": null +} diff --git a/spec/fixtures/moneyhash/create_customer.json b/spec/fixtures/moneyhash/create_customer.json new file mode 100644 index 0000000..19ab565 --- /dev/null +++ b/spec/fixtures/moneyhash/create_customer.json @@ -0,0 +1,31 @@ +{ + "status": { "code": 200, "message": "success", "errors": [] }, + "data": { + "id": "dc98b896-c008-4446-97b8-8af852f476ef", + "type": "INDIVIDUAL", + "name": "Legal00x3 Customer", + "first_name": "Legal00x3", + "last_name": "Customer", + "email": "test@mh.io", + "phone_number": "+201087654321", + "description": null, + "custom_fields": null, + "company_name": "legal name cust00x3", + "tax_id": 76543456, + "tax_rate": null, + "address": "first line addr", + "national_id": null, + "contact_person_name": "legal name cust00x3 - Legal00x3 Customer", + "birth_date": null, + "is_live": false, + "wallets": {}, + "created_during_a_payment": false, + "webhook_url": null, + "last_used_payment_method": null, + "last_used_method": {}, + "custom_webhook_headers": {} + }, + "count": 1, + "next": null, + "previous": null +} diff --git a/spec/fixtures/moneyhash/intent.processed.json b/spec/fixtures/moneyhash/intent.processed.json new file mode 100644 index 0000000..e434756 --- /dev/null +++ b/spec/fixtures/moneyhash/intent.processed.json @@ -0,0 +1,442 @@ +{ + "type": "intent.processed", + "intent_type": "PAYMENT", + "data": { + "intent_id": "g6Q6QJL", + "intent": { + "id": "g6Q6QJL", + "status": "PROCESSED", + "amount": 5, + "amount_currency": "USD", + "type": "Payin", + "account": "lgaxEBL", + "custom_fields": { + "lago_mh_connection_id": "ee0e4bd6-fad0-483c-8865-6314df1199ea", + "lago_mh_connection_code": "salah-demo", + "lago_mit": true, + "lago_customer_id": "46275968-4805-44e2-a181-e587b4c13b19", + "lago_external_customer_id": "cust_external_id_xxx1", + "lago_provider_customer_id": "1f3e6132-85d7-4c35-81e1-0c302f056d34", + "lago_payable_id": "727bb6ca-9a31-458e-ac05-98bf48967387", + "lago_payable_type": "Invoice", + "lago_plan_id": "", + "lago_subscription_external_id": "", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_invoice_type": "one_off", + "lago_request": "invoice_automatic_payment" + }, + "billing_data": { + "first_name": "salah", + "last_name": "salah", + "email": null, + "phone_number": "+201087654321" + }, + "active_transaction": { + "id": "af6395fe-617c-480e-864e-2de6d2e6828f", + "custom_fields": { + "lago_mh_connection_id": "ee0e4bd6-fad0-483c-8865-6314df1199ea", + "lago_mh_connection_code": "salah-demo", + "lago_mit": true, + "lago_customer_id": "46275968-4805-44e2-a181-e587b4c13b19", + "lago_external_customer_id": "cust_external_id_xxx1", + "lago_provider_customer_id": "1f3e6132-85d7-4c35-81e1-0c302f056d34", + "lago_payable_id": "727bb6ca-9a31-458e-ac05-98bf48967387", + "lago_payable_type": "Invoice", + "lago_plan_id": "", + "lago_subscription_external_id": "", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_invoice_type": "one_off", + "lago_request": "invoice_automatic_payment" + }, + "created": "2025-03-13T23:52:45.201012Z", + "status": "SUCCESSFUL", + "amount": "5.00", + "amount_currency": "USD", + "billing_data": { + "name": "salah salah", + "first_name": "salah", + "last_name": "salah", + "email": null, + "phone_number": "+201087654321", + "address": null, + "address1": null, + "apartment": null, + "floor": null, + "building": null, + "street": null, + "city": null, + "state": null, + "country": null, + "postal_code": null, + "mobile_wallet_number": null + }, + "external_action_message": [], + "payment_method": "CARD", + "payment_method_name": "Card", + "custom_form_answers": null, + "custom_message": "", + "account": "lgaxEBL", + "provider_transaction_fields": { + "checkoutdotcom_payment_id": null, + "checkoutdotcom_payment_auth_code": null, + "checkoutdotcom_increase_auth_code": null, + "checkoutdotcom_increase_auth_action_id": null, + "checkoutdotcom_acquirer_reference_number": null, + "checkoutdotcom_processing_object": null, + "checkoutdotcom_operation_fields": {} + }, + "provider_signature_match": false, + "service_provider": "gQJnWwZ", + "method": { + "id": "gqYGvEg", + "display_name": "CheckoutDotCom - Card", + "service_provider": { + "id": "gQJnWwZ", + "display_name": "Checkout.com" + } + }, + "operations": [ + { + "id": "9JzpB79", + "type": "purchase", + "status": "successful", + "amount": { + "value": 5, + "currency": "USD" + }, + "latest_status": { + "id": "gl2pKo9", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful" + }, + "statuses": [ + { + "id": "ZBzOvWZ", + "value": "pending", + "code": "8000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending", + "localized_message": "Pending", + "created": "2025-03-13 23:52:45.236134+00:00" + }, + { + "id": "gl2pKo9", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful", + "created": "2025-03-13 23:52:45.354504+00:00" + } + ], + "refund_type": null, + "custom_fields": null, + "extra": {}, + "rrn": null, + "arn": null, + "authorization_code": null + } + ], + "fraud_decision": null, + "device_check": null, + "paying_card_token": { + "id": "60cd8d40-3acd-41b4-b5ab-62ce4eceb12d", + "hashid": "ZONbEpg", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_mh_connection_id": "ee0e4bd6-fad0-483c-8865-6314df1199ea", + "lago_mh_connection_code": "salah-demo", + "lago_mit": true, + "lago_customer_id": "46275968-4805-44e2-a181-e587b4c13b19", + "lago_external_customer_id": "cust_external_id_xxx1", + "lago_provider_customer_id": "1f3e6132-85d7-4c35-81e1-0c302f056d34", + "lago_payable_id": "727bb6ca-9a31-458e-ac05-98bf48967387", + "lago_payable_type": "Invoice", + "lago_plan_id": "", + "lago_subscription_external_id": "", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_invoice_type": "one_off", + "lago_request": "invoice_automatic_payment" + }, + "provider_token_data": [ + { + "last_4": "0000", + "service_provider_id": "gQJnWwZ", + "pay_in_method": "gqYGvEg", + "provider_specific_data": { + "token_id": "test_token" + } + } + ], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + }, + "authorization_code": null, + "payment_method_details": { + "type": "CARD", + "data": null, + "provider_data": null + }, + "merchant_reference": null, + "provider_unique_reference": { + "key": "checkoutdotcom_payment_id", + "value": null + }, + "authentication_data": null, + "trx_rrn": null, + "full_capture": true, + "ip": { + "ip": { + "ip_address": null, + "country": { + "iso_code": null, + "name": null + } + } + } + }, + "transactions_count": 1, + "transactions_history": [ + { + "id": "af6395fe-617c-480e-864e-2de6d2e6828f", + "custom_fields": { + "lago_mit": true, + "lago_plan_id": "", + "lago_request": "invoice_automatic_payment", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_payable_id": "727bb6ca-9a31-458e-ac05-98bf48967387", + "lago_customer_id": "46275968-4805-44e2-a181-e587b4c13b19", + "lago_invoice_type": "one_off", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_mh_connection_id": "ee0e4bd6-fad0-483c-8865-6314df1199ea", + "lago_mh_connection_code": "salah-demo", + "lago_external_customer_id": "cust_external_id_xxx1", + "lago_provider_customer_id": "1f3e6132-85d7-4c35-81e1-0c302f056d34", + "lago_subscription_external_id": "" + }, + "created": "2025-03-13T23:52:45.201012Z", + "status": "SUCCESSFUL", + "amount": "5.00", + "amount_currency": "USD", + "billing_data": { + "name": "salah salah", + "first_name": "salah", + "last_name": "salah", + "email": null, + "phone_number": "+201087654321", + "address": null, + "address1": null, + "apartment": null, + "floor": null, + "building": null, + "street": null, + "city": null, + "state": null, + "country": null, + "postal_code": null, + "mobile_wallet_number": null + }, + "external_action_message": [], + "payment_method": "CARD", + "payment_method_name": "Card", + "custom_form_answers": null, + "custom_message": "", + "account": "lgaxEBL", + "provider_transaction_fields": { + "checkoutdotcom_payment_id": null, + "checkoutdotcom_payment_auth_code": null, + "checkoutdotcom_increase_auth_code": null, + "checkoutdotcom_increase_auth_action_id": null, + "checkoutdotcom_acquirer_reference_number": null, + "checkoutdotcom_processing_object": null, + "checkoutdotcom_operation_fields": {} + }, + "provider_signature_match": false, + "service_provider": "gQJnWwZ", + "method": { + "id": "gqYGvEg", + "display_name": "CheckoutDotCom - Card", + "service_provider": { + "id": "gQJnWwZ", + "display_name": "Checkout.com" + } + }, + "operations": [ + { + "id": "9JzpB79", + "type": "purchase", + "status": "successful", + "amount": { + "value": 5, + "currency": "USD" + }, + "latest_status": { + "id": "gl2pKo9", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful" + }, + "statuses": [ + { + "id": "ZBzOvWZ", + "value": "pending", + "code": "8000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending", + "localized_message": "Pending", + "created": "2025-03-13 23:52:45.236134+00:00" + }, + { + "id": "gl2pKo9", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful", + "created": "2025-03-13 23:52:45.354504+00:00" + } + ], + "refund_type": null, + "custom_fields": null, + "extra": {}, + "rrn": null, + "arn": null, + "authorization_code": null + } + ], + "fraud_decision": null, + "device_check": null, + "paying_card_token": { + "id": "60cd8d40-3acd-41b4-b5ab-62ce4eceb12d", + "hashid": "ZONbEpg", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_mh_connection_id": "ee0e4bd6-fad0-483c-8865-6314df1199ea", + "lago_mh_connection_code": "salah-demo", + "lago_mit": true, + "lago_customer_id": "46275968-4805-44e2-a181-e587b4c13b19", + "lago_external_customer_id": "cust_external_id_xxx1", + "lago_provider_customer_id": "1f3e6132-85d7-4c35-81e1-0c302f056d34", + "lago_payable_id": "727bb6ca-9a31-458e-ac05-98bf48967387", + "lago_payable_type": "Invoice", + "lago_plan_id": "", + "lago_subscription_external_id": "", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_invoice_type": "one_off", + "lago_request": "invoice_automatic_payment" + }, + "provider_token_data": [ + { + "last_4": "0000", + "service_provider_id": "gQJnWwZ", + "pay_in_method": "gqYGvEg", + "provider_specific_data": { + "token_id": "test_token" + } + } + ], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + }, + "authorization_code": null, + "payment_method_details": { + "type": "CARD", + "data": null, + "provider_data": null + }, + "merchant_reference": null, + "provider_unique_reference": { + "key": "checkoutdotcom_payment_id", + "value": null + }, + "authentication_data": null, + "trx_rrn": null, + "full_capture": true, + "ip": { + "ip": { + "ip_address": null, + "country": { + "iso_code": null, + "name": null + } + } + } + } + ], + "method": { + "display_name": "CheckoutDotCom - Card", + "id": "gqYGvEg" + }, + "flow": "xgXlXgX", + "flow_data": { + "id": "xgXlXgX", + "version": 1, + "sequence": { + "id": 29319, + "alias": "RrPGuUx-Untitled Trigger", + "last_action": { + "id": 49432, + "alias": "vAZSwgB-Payment Provider" + } + } + }, + "is_live": false, + "created": "2025-03-13T23:52:44.967714Z", + "merchant_reference": null, + "ip": { + "ip_address": null, + "country": { + "iso_code": null, + "name": null + } + }, + "payment_status": { + "status": "CAPTURED", + "balances": { + "total_authorized": "5.00", + "total_voided": "0.00", + "available_to_void": "0.00", + "total_captured": "5.00", + "available_to_capture": "0.00", + "total_refunded": "0.00", + "available_to_refund": "5.00" + } + } + } + }, + "api_version": "1.1" +} diff --git a/spec/fixtures/moneyhash/intent.time_expired.json b/spec/fixtures/moneyhash/intent.time_expired.json new file mode 100644 index 0000000..a4babdf --- /dev/null +++ b/spec/fixtures/moneyhash/intent.time_expired.json @@ -0,0 +1,64 @@ +{ + "type": "intent.time_expired", + "intent_type": "PAYMENT", + "data": { + "intent_id": "9J0x2Dg", + "intent": { + "id": "9J0x2Dg", + "status": "TIME_EXPIRED", + "amount": 6.77, + "amount_currency": "USD", + "type": "Payin", + "account": "lgaxEBL", + "custom_fields": { + "lago_mit": false, + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_mh_service": "Invoices::Payments::MoneyhashService", + "lago_payable_id": "b42828c5-ae57-4716-8281-3317fed01042", + "lago_customer_id": "e6676e85-9e4f-4fb5-bd2f-590a9c170d0e", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_subscription_external_id": "7373a747-b6ab-44f2-b6cd-0662b73cbfcb" + }, + "billing_data": { + "first_name": "Ahmed", + "last_name": "Shahwan", + "email": "a.shahwan@moneyhash.io", + "phone_number": "+201087654321" + }, + "active_transaction": null, + "transactions_count": 0, + "transactions_history": [], + "method": {}, + "flow": "xgXlXgX", + "flow_data": { + "id": "xgXlXgX", + "version": 1, + "sequence": null + }, + "is_live": false, + "created": "2025-03-11T11:21:02.267317Z", + "merchant_reference": null, + "ip": { + "ip_address": "196.150.203.149", + "country": { + "iso_code": "EG", + "name": "Egypt" + } + }, + "payment_status": { + "status": "EXPIRED", + "balances": { + "total_authorized": "0.00", + "total_voided": "0.00", + "available_to_void": "0.00", + "total_captured": "0.00", + "available_to_capture": "0.00", + "total_refunded": "0.00", + "available_to_refund": "0.00" + } + } + } + }, + "api_version": "1.1" +} diff --git a/spec/fixtures/moneyhash/recurring_mit_payment_failure_response.json b/spec/fixtures/moneyhash/recurring_mit_payment_failure_response.json new file mode 100644 index 0000000..3fa5f66 --- /dev/null +++ b/spec/fixtures/moneyhash/recurring_mit_payment_failure_response.json @@ -0,0 +1,11 @@ +{ + "status": { + "code": 400, + "message": "", + "errors": [{ "card_token": "This field may not be null." }] + }, + "data": {}, + "count": null, + "next": null, + "previous": null +} diff --git a/spec/fixtures/moneyhash/recurring_mit_payment_payload.json b/spec/fixtures/moneyhash/recurring_mit_payment_payload.json new file mode 100644 index 0000000..4773e09 --- /dev/null +++ b/spec/fixtures/moneyhash/recurring_mit_payment_payload.json @@ -0,0 +1,29 @@ +{ + "amount": 10, + "amount_currency": "USD", + "billing_data": { + "email": "a.shahwan@moneyhash.io", + "first_name": "Ahmed", + "last_name": "Shahwan", + "phone_number": "+201087654321" + }, + "custom_fields": { + "lago_customer_id": "e6676e85-9e4f-4fb5-bd2f-590a9c170d0e", + "lago_external_customer_id": "ext_cust_004", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_mit": true, + "lago_payable_id": "15fb51fb-586f-42bd-8e39-620d539d4e79", + "lago_payable_type": "Invoice", + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_provider_customer_id": "c4fc7f2f-e46b-42d1-842b-c246cf1a597d", + "lago_subscription_external_id": "7373a747-b6ab-44f2-b6cd-0662b73cbfcb" + }, + "customer": "c4fc7f2f-e46b-42d1-842b-c246cf1a597d", + "card_token": "card_token_001", + "flow_id": "xgXlXgX", + "merchant_initiated": true, + "recurring_data": { + "agreement_id": "7373a747-b6ab-44f2-b6cd-0662b73cbfcb" + }, + "webhook_url": "https://29c9-196-150-203-149.ngrok-free.app/webhooks/moneyhash/1f6edf98-9eb4-4baf-8c64-7c6be9d0b414?code=mh-lago" +} diff --git a/spec/fixtures/moneyhash/recurring_mit_payment_success_response.json b/spec/fixtures/moneyhash/recurring_mit_payment_success_response.json new file mode 100644 index 0000000..f2312b7 --- /dev/null +++ b/spec/fixtures/moneyhash/recurring_mit_payment_success_response.json @@ -0,0 +1,426 @@ +{ + "status": { + "code": 200, + "message": "success", + "errors": [] + }, + "data": { + "embed_url": "https://stg-embed.moneyhash.io/embed/payment/ZdBEBXL", + "id": "ZdBEBXL", + "status": "PROCESSED", + "amount": 10.0, + "amount_currency": "USD", + "type": "Payin", + "account": "lgaxEBL", + "custom_fields": { + "lago_customer_id": "a7a7deae-ca21-4c9b-bd3e-18f31bb358f8", + "lago_external_customer_id": "test_lago10", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_mit": true, + "lago_payable_id": "962256eb-dc4b-4339-b8b1-b4b888ab984a", + "lago_payable_type": "Invoice", + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_provider_customer_id": "f8be4051-d93e-4307-a1d3-b9d59c01a7c5", + "lago_subscription_external_id": "81c781e8-6361-4cf2-8c66-bdfde9fe842f" + }, + "billing_data": { + "first_name": "Ahmed", + "last_name": "Shahwan", + "email": "a.shahwan@moneyhash.io", + "phone_number": "+201087654321" + }, + "transaction_provider_fields": { + "checkoutdotcom_payment_id": null, + "checkoutdotcom_payment_auth_code": null, + "checkoutdotcom_increase_auth_code": null, + "checkoutdotcom_increase_auth_action_id": null, + "checkoutdotcom_acquirer_reference_number": null, + "checkoutdotcom_processing_object": null, + "checkoutdotcom_operation_fields": {} + }, + "active_transaction": { + "id": "d7156e7f-ef0b-4fa2-a862-cb1c1f758a5c", + "custom_fields": { + "lago_customer_id": "a7a7deae-ca21-4c9b-bd3e-18f31bb358f8", + "lago_external_customer_id": "test_lago10", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_mit": true, + "lago_payable_id": "962256eb-dc4b-4339-b8b1-b4b888ab984a", + "lago_payable_type": "Invoice", + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_provider_customer_id": "f8be4051-d93e-4307-a1d3-b9d59c01a7c5", + "lago_subscription_external_id": "81c781e8-6361-4cf2-8c66-bdfde9fe842f" + }, + "created": "2025-03-12T08:56:40.181596Z", + "status": "SUCCESSFUL", + "amount": 10.0, + "amount_currency": "USD", + "billing_data": { + "name": "Ahmed Shahwan", + "first_name": "Ahmed", + "last_name": "Shahwan", + "email": "a.shahwan@moneyhash.io", + "phone_number": "+201087654321", + "address": null, + "address1": null, + "apartment": null, + "floor": null, + "building": null, + "street": null, + "city": null, + "state": null, + "country": null, + "postal_code": null, + "mobile_wallet_number": null + }, + "external_action_message": [], + "payment_method": "CARD", + "payment_method_name": "Card", + "custom_form_answers": null, + "custom_message": "", + "account": "lgaxEBL", + "provider_transaction_fields": { + "checkoutdotcom_payment_id": null, + "checkoutdotcom_payment_auth_code": null, + "checkoutdotcom_increase_auth_code": null, + "checkoutdotcom_increase_auth_action_id": null, + "checkoutdotcom_acquirer_reference_number": null, + "checkoutdotcom_processing_object": null, + "checkoutdotcom_operation_fields": {} + }, + "provider_signature_match": false, + "service_provider": "gQJnWwZ", + "method": { + "id": "gqYGvEg", + "display_name": "CheckoutDotCom - Card", + "service_provider": { + "id": "gQJnWwZ", + "display_name": "Checkout.com" + } + }, + "operations": [ + { + "id": "gMK40Q9", + "type": "purchase", + "status": "successful", + "amount": { + "value": 10, + "currency": "USD" + }, + "latest_status": { + "id": "Z1m4xrZ", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful" + }, + "statuses": [ + { + "id": "LPoNjr9", + "value": "pending", + "code": "8000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending", + "localized_message": "Pending", + "created": "2025-03-12 08:56:40.223232+00:00" + }, + { + "id": "Z1m4xrZ", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful", + "created": "2025-03-12 08:56:40.342989+00:00" + } + ], + "refund_type": null, + "custom_fields": null, + "extra": {}, + "rrn": null, + "arn": null, + "authorization_code": null + } + ], + "fraud_decision": null, + "device_check": null, + "paying_card_token": { + "id": "41fbf0e5-c8d8-4ca8-9b11-c0afdf631af7", + "hashid": "ZGxOdA9", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_customer_id": "a7a7deae-ca21-4c9b-bd3e-18f31bb358f8", + "lago_external_customer_id": "test_lago10", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_mit": true, + "lago_payable_id": "962256eb-dc4b-4339-b8b1-b4b888ab984a", + "lago_payable_type": "Invoice", + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_provider_customer_id": "f8be4051-d93e-4307-a1d3-b9d59c01a7c5", + "lago_subscription_external_id": "81c781e8-6361-4cf2-8c66-bdfde9fe842f" + }, + "provider_token_data": [ + { + "last_4": "0000", + "service_provider_id": "gQJnWwZ", + "pay_in_method": "gqYGvEg", + "provider_specific_data": { + "token_id": "test_token" + } + } + ], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + }, + "authorization_code": null, + "payment_method_details": { + "type": "CARD", + "data": null, + "provider_data": null + }, + "merchant_reference": null, + "provider_unique_reference": { + "key": "checkoutdotcom_payment_id", + "value": null + }, + "authentication_data": null, + "trx_rrn": null, + "full_capture": true, + "ip": { + "ip": { + "ip_address": null, + "country": { + "iso_code": null, + "name": null + } + } + } + }, + "transactions_history": [ + { + "id": "d7156e7f-ef0b-4fa2-a862-cb1c1f758a5c", + "custom_fields": { + "lago_mit": true, + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_payable_id": "962256eb-dc4b-4339-b8b1-b4b888ab984a", + "lago_customer_id": "a7a7deae-ca21-4c9b-bd3e-18f31bb358f8", + "lago_payable_type": "Invoice", + "lago_external_customer_id": "test_lago10", + "lago_provider_customer_id": "f8be4051-d93e-4307-a1d3-b9d59c01a7c5", + "lago_subscription_external_id": "81c781e8-6361-4cf2-8c66-bdfde9fe842f" + }, + "created": "2025-03-12T08:56:40.181596Z", + "status": "SUCCESSFUL", + "amount": 10.0, + "amount_currency": "USD", + "billing_data": { + "name": "Ahmed Shahwan", + "first_name": "Ahmed", + "last_name": "Shahwan", + "email": "a.shahwan@moneyhash.io", + "phone_number": "+201087654321", + "address": null, + "address1": null, + "apartment": null, + "floor": null, + "building": null, + "street": null, + "city": null, + "state": null, + "country": null, + "postal_code": null, + "mobile_wallet_number": null + }, + "external_action_message": [], + "payment_method": "CARD", + "payment_method_name": "Card", + "custom_form_answers": null, + "custom_message": "", + "account": "lgaxEBL", + "provider_transaction_fields": { + "checkoutdotcom_payment_id": null, + "checkoutdotcom_payment_auth_code": null, + "checkoutdotcom_increase_auth_code": null, + "checkoutdotcom_increase_auth_action_id": null, + "checkoutdotcom_acquirer_reference_number": null, + "checkoutdotcom_processing_object": null, + "checkoutdotcom_operation_fields": {} + }, + "provider_signature_match": false, + "service_provider": "gQJnWwZ", + "method": { + "id": "gqYGvEg", + "display_name": "CheckoutDotCom - Card", + "service_provider": { + "id": "gQJnWwZ", + "display_name": "Checkout.com" + } + }, + "operations": [ + { + "id": "gMK40Q9", + "type": "purchase", + "status": "successful", + "amount": { + "value": 10, + "currency": "USD" + }, + "latest_status": { + "id": "Z1m4xrZ", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful" + }, + "statuses": [ + { + "id": "LPoNjr9", + "value": "pending", + "code": "8000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending", + "localized_message": "Pending", + "created": "2025-03-12 08:56:40.223232+00:00" + }, + { + "id": "Z1m4xrZ", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful", + "created": "2025-03-12 08:56:40.342989+00:00" + } + ], + "refund_type": null, + "custom_fields": null, + "extra": {}, + "rrn": null, + "arn": null, + "authorization_code": null + } + ], + "fraud_decision": null, + "device_check": null, + "paying_card_token": { + "id": "41fbf0e5-c8d8-4ca8-9b11-c0afdf631af7", + "hashid": "ZGxOdA9", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_customer_id": "a7a7deae-ca21-4c9b-bd3e-18f31bb358f8", + "lago_external_customer_id": "test_lago10", + "lago_mh_service": "PaymentProviders::Moneyhash::Payments::CreateService", + "lago_mit": true, + "lago_payable_id": "962256eb-dc4b-4339-b8b1-b4b888ab984a", + "lago_payable_type": "Invoice", + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_provider_customer_id": "f8be4051-d93e-4307-a1d3-b9d59c01a7c5", + "lago_subscription_external_id": "81c781e8-6361-4cf2-8c66-bdfde9fe842f" + }, + "provider_token_data": [ + { + "last_4": "0000", + "service_provider_id": "gQJnWwZ", + "pay_in_method": "gqYGvEg", + "provider_specific_data": { + "token_id": "test_token" + } + } + ], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + }, + "authorization_code": null, + "payment_method_details": { + "type": "CARD", + "data": null, + "provider_data": null + }, + "merchant_reference": null, + "provider_unique_reference": { + "key": "checkoutdotcom_payment_id", + "value": null + }, + "authentication_data": null, + "trx_rrn": null, + "full_capture": true, + "ip": { + "ip": { + "ip_address": null, + "country": { + "iso_code": null, + "name": null + } + } + } + } + ], + "flow": "xgXlXgX", + "flow_data": { + "id": "xgXlXgX", + "version": 1, + "sequence": { + "id": 29319, + "alias": "RrPGuUx-Untitled Trigger", + "last_action": { + "id": 49432, + "alias": "vAZSwgB-Payment Provider" + } + } + }, + "is_live": false, + "created": "today", + "template": null, + "merchant_reference": null, + "customer": "f8be4051-d93e-4307-a1d3-b9d59c01a7c5", + "state": "INTENT_PROCESSED", + "state_details": "{\"transaction\": {\"id\": \"d7156e7f-ef0b-4fa2-a862-cb1c1f758a5c\", \"custom_fields\": {\"lago_customer_id\": \"a7a7deae-ca21-4c9b-bd3e-18f31bb358f8\", \"lago_external_customer_id\": \"test_lago10\", \"lago_mh_service\": \"PaymentProviders::Moneyhash::Payments::CreateService\", \"lago_mit\": true, \"lago_payable_id\": \"962256eb-dc4b-4339-b8b1-b4b888ab984a\", \"lago_payable_type\": \"Invoice\", \"lago_plan_id\": \"de95fd5d-3349-4439-a5d0-18b42b8551d1\", \"lago_provider_customer_id\": \"f8be4051-d93e-4307-a1d3-b9d59c01a7c5\", \"lago_subscription_external_id\": \"81c781e8-6361-4cf2-8c66-bdfde9fe842f\"}, \"created\": \"2025-03-12T08:56:40.181596Z\", \"status\": \"SUCCESSFUL\", \"amount\": 10.0, \"amount_currency\": \"USD\", \"billing_data\": {\"name\": \"Ahmed Shahwan\", \"first_name\": \"Ahmed\", \"last_name\": \"Shahwan\", \"email\": \"a.shahwan@moneyhash.io\", \"phone_number\": \"+201087654321\", \"address\": null, \"address1\": null, \"apartment\": null, \"floor\": null, \"building\": null, \"street\": null, \"city\": null, \"state\": null, \"country\": null, \"postal_code\": null, \"mobile_wallet_number\": null}, \"external_action_message\": [], \"payment_method\": \"CARD\", \"payment_method_name\": \"Card\", \"custom_form_answers\": null, \"custom_message\": \"\", \"account\": \"lgaxEBL\", \"provider_transaction_fields\": {\"checkoutdotcom_payment_id\": null, \"checkoutdotcom_payment_auth_code\": null, \"checkoutdotcom_increase_auth_code\": null, \"checkoutdotcom_increase_auth_action_id\": null, \"checkoutdotcom_acquirer_reference_number\": null, \"checkoutdotcom_processing_object\": null, \"checkoutdotcom_operation_fields\": {}}, \"provider_signature_match\": false, \"service_provider\": \"gQJnWwZ\", \"method\": {\"id\": \"gqYGvEg\", \"display_name\": \"CheckoutDotCom - Card\", \"service_provider\": {\"id\": \"gQJnWwZ\", \"display_name\": \"Checkout.com\"}}, \"operations\": [{\"id\": \"gMK40Q9\", \"type\": \"purchase\", \"status\": \"successful\", \"amount\": {\"value\": 10, \"currency\": \"USD\"}, \"latest_status\": {\"id\": \"Z1m4xrZ\", \"value\": \"successful\", \"code\": \"6000\", \"provider_error_code\": null, \"provider_error_message\": null, \"message\": \"Successful\", \"localized_message\": \"Successful\"}, \"statuses\": [{\"id\": \"LPoNjr9\", \"value\": \"pending\", \"code\": \"8000\", \"provider_error_code\": null, \"provider_error_message\": null, \"message\": \"Pending\", \"localized_message\": \"Pending\", \"created\": \"2025-03-12 08:56:40.223232+00:00\"}, {\"id\": \"Z1m4xrZ\", \"value\": \"successful\", \"code\": \"6000\", \"provider_error_code\": null, \"provider_error_message\": null, \"message\": \"Successful\", \"localized_message\": \"Successful\", \"created\": \"2025-03-12 08:56:40.342989+00:00\"}], \"refund_type\": null, \"custom_fields\": null, \"extra\": {}, \"rrn\": null, \"arn\": null, \"authorization_code\": null}], \"fraud_decision\": null, \"device_check\": null, \"paying_card_token\": {\"id\": \"41fbf0e5-c8d8-4ca8-9b11-c0afdf631af7\", \"hashid\": \"ZGxOdA9\", \"brand\": \"Visa\", \"card_holder_name\": \"Kevin Smith\", \"bin\": \"111100\", \"last_4\": \"0000\", \"issuer\": \"test\", \"expiry_month\": \"02\", \"expiry_year\": \"26\", \"country\": null, \"type\": null, \"custom_fields\": {\"lago_customer_id\": \"a7a7deae-ca21-4c9b-bd3e-18f31bb358f8\", \"lago_external_customer_id\": \"test_lago10\", \"lago_mh_service\": \"PaymentProviders::Moneyhash::Payments::CreateService\", \"lago_mit\": true, \"lago_payable_id\": \"962256eb-dc4b-4339-b8b1-b4b888ab984a\", \"lago_payable_type\": \"Invoice\", \"lago_plan_id\": \"de95fd5d-3349-4439-a5d0-18b42b8551d1\", \"lago_provider_customer_id\": \"f8be4051-d93e-4307-a1d3-b9d59c01a7c5\", \"lago_subscription_external_id\": \"81c781e8-6361-4cf2-8c66-bdfde9fe842f\"}, \"provider_token_data\": [{\"last_4\": \"0000\", \"service_provider_id\": \"gQJnWwZ\", \"pay_in_method\": \"gqYGvEg\", \"provider_specific_data\": {\"token_id\": \"test_token\"}}], \"requires_cvv\": true, \"fingerprint\": \"d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5\", \"vault_region\": \"default\"}, \"authorization_code\": null, \"payment_method_details\": {\"type\": \"CARD\", \"data\": null, \"provider_data\": null}, \"merchant_reference\": null, \"provider_unique_reference\": {\"key\": \"checkoutdotcom_payment_id\", \"value\": null}, \"authentication_data\": null, \"trx_rrn\": null, \"full_capture\": true, \"ip\": {\"ip\": {\"ip_address\": null, \"country\": {\"iso_code\": null, \"name\": null}}}, \"localized_status\": \"SUCCESSFUL\", \"trx_status\": \"purchase.successful\", \"type\": \"payin\"}, \"product_items_data\": null, \"shipping_data\": null, \"amount\": \"10.00USD\"}", + "customer_last_used_payment_method": "CARD", + "last_used_method": { + "type": "saved_card", + "id": "ZGxOdA9" + }, + "payment_status": { + "status": "CAPTURED", + "balances": { + "total_authorized": "10.00", + "total_voided": "0.00", + "available_to_void": "0.00", + "total_captured": "10.00", + "available_to_capture": "0.00", + "total_refunded": "0.00", + "available_to_refund": "10.00" + } + } + }, + "count": 1, + "next": null, + "previous": null +} diff --git a/spec/fixtures/moneyhash/transaction.purchase.failed.json b/spec/fixtures/moneyhash/transaction.purchase.failed.json new file mode 100644 index 0000000..21b4585 --- /dev/null +++ b/spec/fixtures/moneyhash/transaction.purchase.failed.json @@ -0,0 +1,160 @@ +{ + "type": "transaction.purchase.failed", + "status_id": "Zv2kmzZ", + "operation_id": "Z0kD6Dg", + "intent": { + "id": "LVR0VXL", + "created": "2025-03-11 00:04:17.347506+00:00", + "custom_fields": { + "lago_mit": false, + "lago_mh_service": "Invoices::Payments::MoneyhashService", + "lago_payable_id": "2b618048-af9e-48f5-b188-f38642eef389", + "lago_customer_id": "53c14b65-3406-4b1b-9d49-77fb389ee92e", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414" + }, + "split_data": [], + "custom_form_answers": null, + "amount": { + "value": 5, + "currency": "USD" + }, + "flow_data": { + "id": "xgXlXgX", + "version": 1, + "sequence": null + }, + "payment_status": { + "status": "AUTHORIZE_ATTEMPT_FAILED", + "balances": { + "total_authorized": "0.00", + "total_voided": "0.00", + "available_to_void": "0.00", + "total_captured": "0.00", + "available_to_capture": "0.00", + "total_refunded": "0.00", + "available_to_refund": "0.00" + } + } + }, + "account": { + "id": "lgaxEBL" + }, + "transaction": { + "type": "payment", + "id": "2414609b-8c3f-4298-8879-c9daa244a039", + "created": "2025-03-11 00:04:33.864332+00:00", + "status": "purchase.failed", + "billing_data": { + "name": "Legal00x3 Customer", + "first_name": "Legal00x3", + "last_name": "Customer", + "email": "test@mh.io", + "phone_number": "+201087654321", + "address": null, + "address1": null, + "apartment": null, + "floor": null, + "building": null, + "street": null, + "city": null, + "state": null, + "country": null, + "postal_code": null, + "mobile_wallet_number": null + }, + "external_action_message": [], + "provider_transaction_fields": { + "checkoutdotcom_payment_id": null, + "checkoutdotcom_payment_auth_code": null, + "checkoutdotcom_increase_auth_code": null, + "checkoutdotcom_increase_auth_action_id": null, + "checkoutdotcom_acquirer_reference_number": null, + "checkoutdotcom_processing_object": null, + "checkoutdotcom_operation_fields": {} + }, + "provider_unique_reference": { + "key": "checkoutdotcom_payment_id", + "value": null + }, + "operations": [ + { + "id": "Z0kD6Dg", + "type": "purchase", + "status": "failed", + "amount": { + "value": 5, + "currency": "USD" + }, + "latest_status": { + "id": "Zv2kmzZ", + "value": "failed", + "code": "7000", + "provider_error_code": null, + "provider_error_message": null, + "message": "A generic payment processing error occurred.", + "localized_message": "A generic payment processing error occurred." + }, + "statuses": [ + { + "id": "Zo2p0Vg", + "value": "pending", + "code": "8000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending", + "localized_message": "Pending", + "created": "2025-03-11 00:04:33.903421+00:00" + }, + { + "id": "Zv2kmzZ", + "value": "failed", + "code": "7000", + "provider_error_code": null, + "provider_error_message": null, + "message": "A generic payment processing error occurred.", + "localized_message": "A generic payment processing error occurred.", + "created": "2025-03-11 00:04:34.016913+00:00" + } + ], + "refund_type": null, + "custom_fields": null, + "extra": {}, + "rrn": null, + "arn": null, + "authorization_code": null + } + ], + "fraud_decision": null, + "device_check": null, + "custom_message": "", + "method": { + "id": "gqYGvEg", + "display_name": "CheckoutDotCom - Card", + "service_provider": { + "id": "gQJnWwZ", + "display_name": "Checkout.com" + } + }, + "payment_method_details": { + "type": "CARD", + "data": null, + "provider_data": null + }, + "authentication_data": null, + "paying_card_token": null, + "authorization_code": null, + "merchant_reference": null, + "shipping_data": null, + "trx_rrn": null, + "ip": { + "ip_address": "196.150.203.149", + "country": { + "iso_code": "EG", + "name": "Egypt" + } + }, + "full_capture": false + }, + "api_version": "1.1" +} diff --git a/spec/fixtures/moneyhash/transaction.purchase.pending_authentication.json b/spec/fixtures/moneyhash/transaction.purchase.pending_authentication.json new file mode 100644 index 0000000..dfcb1a8 --- /dev/null +++ b/spec/fixtures/moneyhash/transaction.purchase.pending_authentication.json @@ -0,0 +1,164 @@ +{ + "type": "transaction.purchase.pending_authentication", + "status_id": "1LkmkZr", + "operation_id": "4L2GvgE", + "intent": { + "id": "wgjowZJ", + "created": "2025-02-26 20:31:54.673799+00:00", + "custom_fields": { + "lago_mit": false, + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_mh_service": "Invoices::Payments::MoneyhashService", + "lago_payable_id": "23fd1f3c-922c-4928-b40b-c8a31e4d6664", + "lago_customer_id": "53c14b65-3406-4b1b-9d49-77fb389ee92e", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_subscription_external_id": "69f64026-69eb-4836-b1bd-9323d9f7d892" + }, + "split_data": [], + "custom_form_answers": null, + "amount": { "value": 50, "currency": "USD" }, + "flow_data": null, + "payment_status": { + "status": "AUTHORIZE_ATTEMPT_PENDING", + "balances": { + "total_authorized": 0, + "total_voided": 0, + "available_to_void": 0, + "total_captured": 0, + "available_to_capture": 0, + "total_refunded": 0, + "available_to_refund": 0 + } + } + }, + "account": { "id": "YVglAZx" }, + "transaction": { + "type": "payment", + "id": "e2a009af-af3c-4068-b254-2a5f140dc733", + "created": "2025-02-26 20:31:57.152967+00:00", + "status": "purchase.pending_authentication", + "billing_data": { + "name": null, + "first_name": null, + "last_name": null, + "email": null, + "phone_number": null, + "address": null, + "address1": null, + "apartment": null, + "floor": null, + "building": null, + "street": null, + "city": null, + "state": null, + "country": null, + "postal_code": null, + "mobile_wallet_number": null + }, + "external_action_message": [], + "provider_transaction_fields": { + "hyperpay_checkout_id": null, + "hyperpay_transaction_id": "8ac7a4a19542d70b019543f5b42725d6", + "hyperpay_merchant_transaction_id": "e2a009afaf3c4068", + "hyperpay_result_details_object": { + "TermID": "71F00820", + "OrderID": "8316384413", + "AuthCode": "f2e7a815c3", + "ProcStatus": "0", + "ConnectorTxID1": "8ac7a4a19542d70b019543f5b42725d6", + "ConnectorTxID2": "8ac7a4a1", + "ConnectorTxID3": "42d70b019543f5b42725d6", + "AcquirerResponse": "00", + "ExtendedDescription": "Successfully processed", + "EXTERNAL_SYSTEM_LINK": "https://csi-test.retaildecisions.com/RS60/TransDetail.aspx?oid=000194001101S2E20110926045038668&support=Link+to+Risk+Details", + "clearingInstituteName": "NCB BANK" + }, + "extended_error_description": null + }, + "provider_unique_reference": { + "key": "hyperpay_checkout_id", + "value": null + }, + "operations": [ + { + "id": "4L2GvgE", + "type": "purchase", + "status": "pending_authentication", + "amount": { "value": 50, "currency": "USD" }, + "latest_status": { + "id": "1LkmkZr", + "value": "pending_authentication", + "code": "8001", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending Authentication", + "localized_message": "Pending Authentication" + }, + "statuses": [ + { + "id": "GgqzyZx", + "value": "pending", + "code": "8000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending", + "localized_message": "Pending", + "created": "2025-02-26 20:31:57.204039+00:00" + }, + { + "id": "1LkmkZr", + "value": "pending_authentication", + "code": "8001", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending Authentication", + "localized_message": "Pending Authentication", + "created": "2025-02-26 20:32:11.034455+00:00" + } + ], + "refund_type": null, + "custom_fields": null, + "extra": {}, + "rrn": null, + "arn": null, + "authorization_code": null + } + ], + "fraud_decision": null, + "device_check": null, + "custom_message": "", + "method": { + "id": "lgbY0LB", + "display_name": "Hyperpay - Card", + "service_provider": { "id": "8LVnmLr", "display_name": "HyperPay" } + }, + "payment_method_details": { + "type": "CARD", + "data": { + "bin": "411111", + "type": null, + "brand": "VISA", + "issuer": null, + "last_4": "1111", + "country": null, + "expiry_year": "2025", + "expiry_month": "05", + "card_holder_name": "ahmed shahhwan" + }, + "provider_data": null + }, + "authentication_data": null, + "paying_card_token": null, + "authorization_code": null, + "merchant_reference": null, + "shipping_data": null, + "trx_rrn": null, + "ip": { + "ip_address": "127.0.0.1", + "country": { "iso_code": null, "name": null } + }, + "full_capture": false + }, + "api_version": "1.1" +} diff --git a/spec/fixtures/moneyhash/transaction.purchase.successful.json b/spec/fixtures/moneyhash/transaction.purchase.successful.json new file mode 100644 index 0000000..2f53189 --- /dev/null +++ b/spec/fixtures/moneyhash/transaction.purchase.successful.json @@ -0,0 +1,197 @@ +{ + "type": "transaction.purchase.successful", + "status_id": "9z2l4n9", + "operation_id": "94eK1qL", + "intent": { + "id": "LY0a5W9", + "created": "2025-03-10 17:44:35.192694+00:00", + "custom_fields": { + "lago_mit": false, + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_mh_service": "Invoices::Payments::MoneyhashService", + "lago_payable_id": "23fd1f3c-922c-4928-b40b-c8a31e4d6664", + "lago_customer_id": "53c14b65-3406-4b1b-9d49-77fb389ee92e", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_subscription_external_id": "69f64026-69eb-4836-b1bd-9323d9f7d892" + }, + "split_data": [], + "custom_form_answers": null, + "amount": { + "value": 7.1, + "currency": "USD" + }, + "flow_data": { + "id": "xgXlXgX", + "version": 1, + "sequence": null + }, + "payment_status": { + "status": "CAPTURED", + "balances": { + "total_authorized": "7.10", + "total_voided": "0.00", + "available_to_void": "0.00", + "total_captured": "7.10", + "available_to_capture": "0.00", + "total_refunded": "0.00", + "available_to_refund": "7.10" + } + } + }, + "account": { + "id": "lgaxEBL" + }, + "transaction": { + "type": "payment", + "id": "0f6d9298-5346-49e4-8043-f3899bedbdb1", + "created": "2025-03-10 17:44:44.588034+00:00", + "status": "purchase.successful", + "billing_data": { + "name": "Legal00x3 Customer", + "first_name": "Legal00x3", + "last_name": "Customer", + "email": "test@mh.io", + "phone_number": "+201087654321", + "address": null, + "address1": null, + "apartment": null, + "floor": null, + "building": null, + "street": null, + "city": null, + "state": null, + "country": null, + "postal_code": null, + "mobile_wallet_number": null + }, + "external_action_message": [], + "provider_transaction_fields": { + "checkoutdotcom_payment_id": null, + "checkoutdotcom_payment_auth_code": null, + "checkoutdotcom_increase_auth_code": null, + "checkoutdotcom_increase_auth_action_id": null, + "checkoutdotcom_acquirer_reference_number": null, + "checkoutdotcom_processing_object": null, + "checkoutdotcom_operation_fields": {} + }, + "provider_unique_reference": { + "key": "checkoutdotcom_payment_id", + "value": null + }, + "operations": [ + { + "id": "94eK1qL", + "type": "purchase", + "status": "successful", + "amount": { + "value": 7.1, + "currency": "USD" + }, + "latest_status": { + "id": "9z2l4n9", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful" + }, + "statuses": [ + { + "id": "gy2BVQZ", + "value": "pending", + "code": "8000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Pending", + "localized_message": "Pending", + "created": "2025-03-10 17:44:44.642478+00:00" + }, + { + "id": "9z2l4n9", + "value": "successful", + "code": "6000", + "provider_error_code": null, + "provider_error_message": null, + "message": "Successful", + "localized_message": "Successful", + "created": "2025-03-10 17:44:44.778054+00:00" + } + ], + "refund_type": null, + "custom_fields": null, + "extra": {}, + "rrn": null, + "arn": null, + "authorization_code": null + } + ], + "fraud_decision": null, + "device_check": null, + "custom_message": "", + "method": { + "id": "gqYGvEg", + "display_name": "CheckoutDotCom - Card", + "service_provider": { + "id": "gQJnWwZ", + "display_name": "Checkout.com" + } + }, + "payment_method_details": { + "type": "CARD", + "data": null, + "provider_data": null + }, + "authentication_data": null, + "paying_card_token": { + "id": "419cb5f7-20fc-4571-a64d-4a64f0a0412f", + "hashid": "gMRXKaZ", + "brand": "Visa", + "card_holder_name": "Kevin Smith", + "bin": "111100", + "last_4": "0000", + "issuer": "test", + "expiry_month": "02", + "expiry_year": "26", + "country": null, + "type": null, + "custom_fields": { + "lago_mit": false, + "lago_plan_id": "de95fd5d-3349-4439-a5d0-18b42b8551d1", + "lago_mh_service": "Invoices::Payments::MoneyhashService", + "lago_payable_id": "23fd1f3c-922c-4928-b40b-c8a31e4d6664", + "lago_customer_id": "53c14b65-3406-4b1b-9d49-77fb389ee92e", + "lago_payable_type": "Invoice", + "lago_organization_id": "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414", + "lago_subscription_external_id": "69f64026-69eb-4836-b1bd-9323d9f7d892" + }, + "provider_token_data": [ + { + "last_4": "0000", + "service_provider_id": "gQJnWwZ", + "pay_in_method": "gqYGvEg", + "provider_specific_data": { + "token_id": "test_token" + } + } + ], + "requires_cvv": true, + "fingerprint": "d795b7befe90eaf9d91a9d563f446487c69f06c3a35374289a385d40709a82f0b14ebe7c24139e19aaca6f3661ec1a3715d92e51a5b099880acb66b39a621dc5", + "vault_region": "default" + }, + "authorization_code": null, + "merchant_reference": null, + "shipping_data": null, + "trx_rrn": null, + "ip": { + "ip_address": "196.150.203.149", + "country": { + "iso_code": "EG", + "name": "Egypt" + } + }, + "full_capture": true + }, + "api_version": "1.1" +} diff --git a/spec/fixtures/moneyhash/webhook_signature_response.json b/spec/fixtures/moneyhash/webhook_signature_response.json new file mode 100644 index 0000000..80fd922 --- /dev/null +++ b/spec/fixtures/moneyhash/webhook_signature_response.json @@ -0,0 +1,13 @@ +{ + "status": { + "code": 200, + "message": "success", + "errors": [] + }, + "data": { + "webhook_signature_secret": "oVZo2bro-a7UBN3k9QFlsSz6cN_Zrdt9KCWk4_5q2lo=" + }, + "count": 1, + "next": null, + "previous": null +} diff --git a/spec/fixtures/stripe/2020-08-27/customer_list_payment_methods_empty_response.json b/spec/fixtures/stripe/2020-08-27/customer_list_payment_methods_empty_response.json new file mode 100644 index 0000000..a710cfc --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/customer_list_payment_methods_empty_response.json @@ -0,0 +1,6 @@ +{ + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/customers/cus_Rw5Qso78STEap3/payment_methods" +} diff --git a/spec/fixtures/stripe/2020-08-27/customer_list_payment_methods_response.json b/spec/fixtures/stripe/2020-08-27/customer_list_payment_methods_response.json new file mode 100644 index 0000000..32d776b --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/customer_list_payment_methods_response.json @@ -0,0 +1,108 @@ +{ + "object": "list", + "data": [ + { + "id": "pm_1R2EmOQ8iJWBZFaMKJHOwcvP", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 12, + "exp_year": 2028, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1741883924, + "customer": "cus_Rw5Qso78STEap3", + "livemode": false, + "metadata": {}, + "radar_options": {}, + "type": "card" + }, + { + "id": "pm_1R2DFsQ8iJWBZFaMw3LLbR0r", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "awdawd@desf.com", + "name": "Testing Stripe", + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 12, + "exp_year": 2028, + "fingerprint": "8TOiB4cGytYxCweY", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1741878064, + "customer": "cus_Rw5Qso78STEap3", + "livemode": false, + "metadata": {}, + "type": "card" + } + ], + "has_more": false, + "url": "/v1/customers/cus_Rw5Qso78STEap3/payment_methods" +} diff --git a/spec/fixtures/stripe/2020-08-27/customer_retrieve_response.json b/spec/fixtures/stripe/2020-08-27/customer_retrieve_response.json new file mode 100644 index 0000000..d293cdb --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/customer_retrieve_response.json @@ -0,0 +1,32 @@ +{ + "id": "cus_123456789", + "object": "customer", + "address": null, + "balance": 0, + "created": 1688679325, + "currency": null, + "default_currency": null, + "default_source": "card_123456789", + "delinquent": false, + "description": null, + "discount": null, + "email": "test@getlago.com", + "invoice_prefix": "123456", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "customer_id": "test_5", + "lago_customer_id": "123456-1234-1234-1234-1234567890" + }, + "name": "Test 5", + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null +} diff --git a/spec/fixtures/stripe/2020-08-27/payment_intent_authorization_failed_response.json b/spec/fixtures/stripe/2020-08-27/payment_intent_authorization_failed_response.json new file mode 100644 index 0000000..c31c642 --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/payment_intent_authorization_failed_response.json @@ -0,0 +1,305 @@ +{ + "error": { + "advice_code": "try_again_later", + "charge": "ch_3QxY0cQ8iJWBZFaM1fnWlwJp", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_intent": { + "id": "pi_3QxY0cQ8iJWBZFaM1rmKGIIJ", + "object": "payment_intent", + "amount": 110, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3QxY0cQ8iJWBZFaM1fnWlwJp", + "object": "charge", + "amount": 110, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "87654", + "state": null + }, + "email": "adwawd@esef.com", + "name": "Testing Stripe", + "phone": null + }, + "calculated_statement_descriptor": "JULIEN", + "captured": false, + "created": 1740766203, + "currency": "usd", + "customer": "cus_RqyKy71Xwsb1Xr", + "description": "Pre-authorization for subscription", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "card_declined", + "failure_message": "Your card was declined.", + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": { + "plan_code": "plan_a" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": "try_again_later", + "network_advice_code": null, + "network_decline_code": null, + "network_status": "declined_by_network", + "reason": "generic_decline", + "risk_level": "normal", + "risk_score": 52, + "seller_message": "The bank did not return any further details with this decline.", + "type": "issuer_declined" + }, + "paid": false, + "payment_intent": "pi_3QxY0cQ8iJWBZFaM1rmKGIIJ", + "payment_method": "pm_1QxGfaQ8iJWBZFaMWvBuRccO", + "payment_method_details": { + "card": { + "amount_authorized": null, + "authorization_code": null, + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": null + }, + "country": "US", + "exp_month": 12, + "exp_year": 2028, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "0341", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "112109731026556", + "overcapture": { + "maximum_amount_capturable": 110, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": {}, + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3QxY0cQ8iJWBZFaM1fnWlwJp/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3QxY0cQ8iJWBZFaM1rmKGIIJ" + }, + "client_secret": "pi_3Q**********************_******_*********************eUu2", + "confirmation_method": "automatic", + "created": 1740766202, + "currency": "usd", + "customer": "cus_RqyKy71Xwsb1Xr", + "description": "Pre-authorization for subscription", + "invoice": null, + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3QxY0cQ8iJWBZFaM1fnWlwJp", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_method": { + "id": "pm_1QxGfaQ8iJWBZFaMWvBuRccO", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "87654", + "state": null + }, + "email": "adwawd@esef.com", + "name": "Testing Stripe", + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 12, + "exp_year": 2028, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1740699551, + "customer": "cus_RqyKy71Xwsb1Xr", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3QxY0cQ8iJWBZFaM1fnWlwJp", + "livemode": false, + "metadata": { + "plan_code": "plan_a" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "capture_method": "manual", + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "payment_method": { + "id": "pm_1QxGfaQ8iJWBZFaMWvBuRccO", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": "US", + "line1": null, + "line2": null, + "postal_code": "87654", + "state": null + }, + "email": "adwawd@esef.com", + "name": "Testing Stripe", + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": "pass", + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 12, + "exp_year": 2028, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1740699551, + "customer": "cus_RqyKy71Xwsb1Xr", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "request_log_url": "https://dashboard.stripe.com/test/logs/req_R6dwJQCrHDQkZr?t=1740766202", + "type": "card_error" + } +} diff --git a/spec/fixtures/stripe/2020-08-27/payment_intent_card_declined_response.json b/spec/fixtures/stripe/2020-08-27/payment_intent_card_declined_response.json new file mode 100644 index 0000000..0e4fdb2 --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/payment_intent_card_declined_response.json @@ -0,0 +1,180 @@ +{ + "error": { + "advice_code": "try_again_later", + "charge": "ch_3RECBrEODpjARzFD04rY3lbk", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_intent": { + "id": "pi_3RECBrEODpjARzFD0ML00Ti8", + "object": "payment_intent", + "amount": 1000, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_3R**********************_******_*********************3QVt", + "confirmation_method": "automatic", + "created": 1744733907, + "currency": "usd", + "customer": "cus_S8R6JWuhYOpkRg", + "description": "Prod QA - Invoice PRO-377A-162-005", + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3RECBrEODpjARzFD04rY3lbk", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_method": { + "id": "pm_1REC83EODpjARzFD3ldcuxvo", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 2, + "exp_year": 2027, + "fingerprint": "Vv4JaVkpcCmfA1tl", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1744733671, + "customer": "cus_S8R6JWuhYOpkRg", + "livemode": false, + "metadata": {}, + "radar_options": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3RECBrEODpjARzFD04rY3lbk", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "b0529d6b-25ad-4e41-b4b2-8302884df936", + "invoice_issuing_date": "2025-04-15", + "lago_invoice_id": "4aec6beb-92c4-49c5-94e8-a57476a509bf", + "lago_payment_id": "19fadd69-d45d-4408-a6c9-986ac0c060a2" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "payment_method": { + "id": "pm_1REC83EODpjARzFD3ldcuxvo", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 2, + "exp_year": 2027, + "fingerprint": "Vv4JaVkpcCmfA1tl", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1744733671, + "customer": "cus_S8R6JWuhYOpkRg", + "livemode": false, + "metadata": {}, + "radar_options": {}, + "type": "card" + }, + "request_log_url": "https://dashboard.stripe.com/test/logs/req_SHiR3CuoVK6Z5n?t=1744733907", + "type": "card_error" + } +} diff --git a/spec/fixtures/stripe/2020-08-27/retrieve_payment_method_response.json b/spec/fixtures/stripe/2020-08-27/retrieve_payment_method_response.json new file mode 100644 index 0000000..3fa12fb --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/retrieve_payment_method_response.json @@ -0,0 +1,50 @@ +{ + "id": "pm_1R2DFsQ8iJWBZFaMw3LLbR0r", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "awdawd@desf.com", + "name": "Testing Stripe", + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 12, + "exp_year": 2028, + "fingerprint": "8TOiB4cGytYxCweY", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1741878064, + "customer": "cus_Rw5Qso78STEap3", + "livemode": false, + "metadata": {}, + "type": "card" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhook_endpoint_create_response.json b/spec/fixtures/stripe/2020-08-27/webhook_endpoint_create_response.json new file mode 100644 index 0000000..ad1016b --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhook_endpoint_create_response.json @@ -0,0 +1,22 @@ +{ + "id": "we_1QzHw4Q8iJWBZFaMg54WCeIn", + "object": "webhook_endpoint", + "api_version": "2020-08-27", + "application": null, + "created": 1741181072, + "description": null, + "enabled_events": [ + "setup_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.succeeded", + "payment_method.detached", + "charge.refund.updated", + "customer.updated", + "charge.dispute.closed" + ], + "livemode": false, + "metadata": {}, + "secret": "whsec_fZ**************************KNnP", + "status": "enabled", + "url": "https://api.lago.dev/webhooks/stripe/e3f46553-726d-4948-96be-2eab7a84caab?code=stripe_sandbox" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhook_endpoint_update_response.json b/spec/fixtures/stripe/2020-08-27/webhook_endpoint_update_response.json new file mode 100644 index 0000000..c7996bb --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhook_endpoint_update_response.json @@ -0,0 +1,21 @@ +{ + "id": "we_1QzHw4Q8iJWBZFaMg54WCeIn", + "object": "webhook_endpoint", + "api_version": "2020-08-27", + "application": null, + "created": 1741181072, + "description": null, + "enabled_events": [ + "setup_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.succeeded", + "payment_method.detached", + "charge.refund.updated", + "customer.updated", + "charge.dispute.closed" + ], + "livemode": false, + "metadata": {}, + "status": "enabled", + "url": "https://api.lago.dev/webhooks/stripe/e3f46553-726d-4948-96be-2eab7a84caab?code=stripe_sandbox" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/charge_dispute_closed.json b/spec/fixtures/stripe/2020-08-27/webhooks/charge_dispute_closed.json new file mode 100644 index 0000000..9578c66 --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/charge_dispute_closed.json @@ -0,0 +1,103 @@ +{ + "id": "evt_3OzgpDH4tiDZlIUa09cnGOsO", + "object": "event", + "api_version": "2020-08-27", + "created": 1711724294, + "data": { + "object": { + "id": "dp_1OzgpEH4tiDZlIUaZ8iWQMsb", + "object": "dispute", + "amount": 4516, + "balance_transaction": "txn_1OzgpEH4tiDZlIUaXu3PFSnM", + "balance_transactions": [ + { + "id": "txn_1OzgpEH4tiDZlIUaXu3PFSnM", + "object": "balance_transaction", + "amount": -4516, + "available_on": 1712275200, + "created": 1711724076, + "currency": "usd", + "description": "Chargeback withdrawal for ch_3OzgpDH4tiDZlIUa0M7QchGT", + "exchange_rate": null, + "fee": 2000, + "fee_details": [ + { + "amount": 2000, + "application": null, + "currency": "usd", + "description": "Dispute fee", + "type": "stripe_fee" + } + ], + "net": -6516, + "reporting_category": "dispute", + "source": "dp_1OzgpEH4tiDZlIUaZ8iWQMsb", + "status": "pending", + "type": "adjustment" + } + ], + "charge": "ch_3OzgpDH4tiDZlIUa0M7QchGT", + "created": 1711724076, + "currency": "usd", + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": null, + "customer_name": "i_stripe_3", + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "product_description": null, + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1712534399, + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": false, + "livemode": false, + "metadata": {}, + "payment_intent": "pi_3OzgpDH4tiDZlIUa0Ezzggtg", + "payment_method_details": { + "card": { + "brand": "visa", + "network_reason_code": "83" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "lost" + }, + "previous_attributes": { + "status": "needs_response" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_Lb7Yp8XdgG2n7N", + "idempotency_key": "[FILTERED]" + }, + "type": "charge.dispute.closed", + "organization_id": "57e0597e-3cc5-4496-a46b-ac29b9439ab9" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/charge_refund_updated.json b/spec/fixtures/stripe/2020-08-27/webhooks/charge_refund_updated.json new file mode 100644 index 0000000..a34dc83 --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/charge_refund_updated.json @@ -0,0 +1,31 @@ +{ + "id": "evt_1LEBtZDu4p87RxPwbHKmfr13", + "object": "event", + "api_version": "2020-08-27", + "created": 1656074757, + "data": { + "object": { + "id": "re_3M01Us2eZvKYlo2C1J36vhMv", + "object": "refund", + "amount": 100, + "balance_transaction": null, + "charge": "ch_3M01Us2eZvKYlo2C1O9xQHEB", + "created": 1667474532, + "currency": "usd", + "metadata": {}, + "payment_intent": null, + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + } + }, + "livemode": false, + "pending_webhooks": 0, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "charge.refund.updated" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/customer_updated.json b/spec/fixtures/stripe/2020-08-27/webhooks/customer_updated.json new file mode 100644 index 0000000..5ab21c7 --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/customer_updated.json @@ -0,0 +1,49 @@ +{ + "id": "evt_123456789", + "object": "event", + "api_version": "2020-08-27", + "created": 1688816058, + "data": { + "object": { + "id": "cus_123456789", + "object": "customer", + "address": null, + "balance": 0, + "created": 1688679325, + "currency": null, + "default_currency": null, + "default_source": "card_123456789", + "delinquent": false, + "description": null, + "discount": null, + "email": "test@getlago.com", + "invoice_prefix": "123456", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": {}, + "name": "Test 5", + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + }, + "previous_attributes": { + "invoice_settings": { + "default_payment_method": "pm_123456789" + } + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_123456789", + "idempotency_key": "123456-1234-1234-1234-1234567890" + }, + "type": "customer.updated" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_canceled.json b/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_canceled.json new file mode 100644 index 0000000..2cf0458 --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_canceled.json @@ -0,0 +1,91 @@ +{ + "id": "evt_3SVCroQ8iJWBZFaM2GyG1PVP", + "object": "event", + "api_version": "2020-08-27", + "created": 1763738710, + "data": { + "object": { + "id": "pi_3SVCroQ8iJWBZFaM22iaIuU3", + "object": "payment_intent", + "amount": 23040, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": 1763738710, + "cancellation_reason": "duplicate", + "capture_method": "automatic_async", + "charges": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges?payment_intent=pi_3SVCroQ8iJWBZFaM22iaIuU3" + }, + "client_secret": "pi_3SVCroQ8iJWBZFaM22iaIuU3_secret_wMcPlg3I0vg6axAwII8B1HOVv", + "confirmation_method": "automatic", + "created": 1763564420, + "currency": "usd", + "customer": "cus_TOlZpKcK7V4tSl", + "description": "Hooli - Invoice HOO-156A-028-011", + "excluded_payment_method_types": null, + "invoice": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "edde4cc9-a9d7-4e33-bc12-a81476684bf2", + "lago_payment_id": "8f1e4ee4-301b-4332-8815-f699878a853c", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "516ab92d-1975-4cca-a688-1bf23d22156a", + "lago_payable_id": "b5757d8a-347a-4149-a5fa-3fd9f5154a04", + "invoice_issuing_date": "2025-11-19", + "lago_invoice_id": "b5757d8a-347a-4149-a5fa-3fd9f5154a04", + "lago_organization_id": "11111111-2222-3333-4444-555555555555" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1SRyLyQ8iJWBZFaMEvqtm6U6", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "us_bank_account": { + "mandate_options": {}, + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "us_bank_account" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 5, + "request": { + "id": "req_Eo1OhTrnHDS81N", + "idempotency_key": "0b7e3cfb-5084-4f1b-abd8-b4172e5df135" + }, + "type": "payment_intent.canceled" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_payment_failed.json b/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_payment_failed.json new file mode 100644 index 0000000..a4312bb --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_payment_failed.json @@ -0,0 +1,274 @@ +{ + "id": "evt_3SVvxMQ8iJWBZFaM1z5wZ6Za", + "object": "event", + "api_version": "2020-08-27", + "created": 1763737745, + "data": { + "object": { + "id": "pi_3SVvxMQ8iJWBZFaM1Lao8ehu", + "object": "payment_intent", + "amount": 11880, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3SVvxMQ8iJWBZFaM1YEcxCs2", + "object": "charge", + "amount": 11880, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "gagerg@sfsefs.csrkgjsdbrgjdrbg", + "name": "awdawd", + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "JULIEN", + "captured": false, + "created": 1763737745, + "currency": "usd", + "customer": "cus_TSOWyTkxXlWiTc", + "description": "Hooli - Invoice HOO-156A-030-005", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "authentication_required", + "failure_message": "Your card was declined. This transaction requires authentication.", + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "8adce609-5e68-4f3b-acf2-cdc9ee6609fd", + "lago_payment_id": "6a20520f-2047-4170-8282-a628197dd9ff", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "516ab92d-1975-4cca-a688-1bf23d22156a", + "lago_payable_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "invoice_issuing_date": "2025-11-21", + "lago_invoice_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "lago_organization_id": "11111111-2222-3333-4444-555555555555" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "declined_by_network", + "reason": "authentication_required", + "risk_level": "normal", + "risk_score": 8, + "seller_message": "The bank returned the decline code `authentication_required`.", + "type": "issuer_declined" + }, + "paid": false, + "payment_intent": "pi_3SVvxMQ8iJWBZFaM1Lao8ehu", + "payment_method": "pm_1SVTn7Q8iJWBZFaMgjl33y0O", + "payment_method_details": { + "card": { + "amount_authorized": null, + "authorization_code": "071199", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "IE", + "exp_month": 2, + "exp_year": 2029, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "eumCxi1ZFoTDYqvg", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "3220", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "101117109671201", + "overcapture": { + "maximum_amount_capturable": 11880, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": {}, + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3SVvxMQ8iJWBZFaM1YEcxCs2/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3SVvxMQ8iJWBZFaM1Lao8ehu" + }, + "client_secret": "pi_3SVvxMQ8iJWBZFaM1Lao8ehu_secret_fwMspul9uxTskxWVJekMXcffk", + "confirmation_method": "automatic", + "created": 1763737744, + "currency": "usd", + "customer": "cus_TSOWyTkxXlWiTc", + "description": "Hooli - Invoice HOO-156A-030-005", + "excluded_payment_method_types": null, + "invoice": null, + "last_payment_error": { + "code": "authentication_required", + "decline_code": "authentication_not_handled", + "doc_url": "https://stripe.com/docs/error-codes/authentication-required", + "message": "This payment required an authentication action to complete, but `error_on_requires_action` was set. When you're ready, you can upgrade your integration to handle actions at https://stripe.com/docs/payments/payment-intents/upgrade-to-handle-actions.", + "payment_method": { + "id": "pm_1SVTn7Q8iJWBZFaMgjl33y0O", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "gagerg@sfsefs.csrkgjsdbrgjdrbg", + "name": "awdawd", + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "IE", + "display_brand": "visa", + "exp_month": 2, + "exp_year": 2029, + "fingerprint": "eumCxi1ZFoTDYqvg", + "funding": "credit", + "generated_from": null, + "last4": "3220", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1763629477, + "customer": "cus_TSOWyTkxXlWiTc", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3SVvxMQ8iJWBZFaM1YEcxCs2", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "8adce609-5e68-4f3b-acf2-cdc9ee6609fd", + "lago_payment_id": "6a20520f-2047-4170-8282-a628197dd9ff", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "516ab92d-1975-4cca-a688-1bf23d22156a", + "lago_payable_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "invoice_issuing_date": "2025-11-21", + "lago_invoice_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "lago_organization_id": "11111111-2222-3333-4444-555555555555" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_ugAfLhYwxZ3saC", + "idempotency_key": "payment-6a20520f-2047-4170-8282-a628197dd9ff" + }, + "type": "payment_intent.payment_failed" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_succeeded.json b/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_succeeded.json new file mode 100644 index 0000000..f5c62ae --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/payment_intent_succeeded.json @@ -0,0 +1,207 @@ +{ + "id": "evt_3R3dvoQ8iJWBZFaM0Wh4E44o", + "object": "event", + "api_version": "2020-08-27", + "created": 1742218937, + "data": { + "object": { + "id": "pi_3R3dvoQ8iJWBZFaM0uu1G1rx", + "object": "payment_intent", + "amount": 3897, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 3897, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3R3dvoQ8iJWBZFaM0NhAbztB", + "object": "charge", + "amount": 3897, + "amount_captured": 3897, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": "txn_3R3dvoQ8iJWBZFaM0vIgoQgO", + "billing_details": { + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "awdawd@desf.com", + "name": "Testing Stripe", + "phone": null + }, + "calculated_statement_descriptor": "JULIEN", + "captured": true, + "created": 1742218936, + "currency": "usd", + "customer": "cus_Rw5Qso78STEap3", + "description": "Hooli - Invoice HOO-CAAB-028-004", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": null, + "failure_message": null, + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "e4674f68-a7ba-4ce8-95e9-981f346b49d7", + "invoice_issuing_date": "2025-03-17", + "lago_invoice_id": "5ccdc601-18a5-4f22-a8e7-a53ca18e1f00" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "approved_by_network", + "reason": null, + "risk_level": "normal", + "risk_score": 49, + "seller_message": "Payment complete.", + "type": "authorized" + }, + "paid": true, + "payment_intent": "pi_3R3dvoQ8iJWBZFaM0uu1G1rx", + "payment_method": "pm_1R2DFsQ8iJWBZFaMw3LLbR0r", + "payment_method_details": { + "card": { + "amount_authorized": 3897, + "authorization_code": null, + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": null + }, + "country": "US", + "exp_month": 12, + "exp_year": 2028, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "8TOiB4cGytYxCweY", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "4242", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "568479105665299", + "overcapture": { + "maximum_amount_capturable": 3897, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": {}, + "receipt_email": null, + "receipt_number": null, + "receipt_url": "https://pay.stripe.com/receipts/payment/CAcaFwoVYWNjdF8xUXNPODZROGlKV0JaRmFNKLnN4L4GMgZG_ZU7Occ6LBa2vkaCb0vmYUzyS_hCt6KVClQQn57DEam-9o5grpM2ZtQsdc7xMPJsKpwG", + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3R3dvoQ8iJWBZFaM0NhAbztB/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3R3dvoQ8iJWBZFaM0uu1G1rx" + }, + "client_secret": "pi_3R3dvoQ8iJWBZFaM0uu1G1rx_secret_qADOJoHblKMdHbpS73aZEncpt", + "confirmation_method": "automatic", + "created": 1742218936, + "currency": "usd", + "customer": "cus_Rw5Qso78STEap3", + "description": "Hooli - Invoice HOO-CAAB-028-004", + "invoice": null, + "last_payment_error": null, + "latest_charge": "ch_3R3dvoQ8iJWBZFaM0NhAbztB", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "e4674f68-a7ba-4ce8-95e9-981f346b49d7", + "invoice_issuing_date": "2025-03-17", + "lago_invoice_id": "5ccdc601-18a5-4f22-a8e7-a53ca18e1f00" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1R2DFsQ8iJWBZFaMw3LLbR0r", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "crypto": {} + }, + "payment_method_types": [ + "card", + "crypto" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_O6QX7CMLkdywUG", + "idempotency_key": "payment-086e04f0-7459-41d2-ab66-8795f2c0ff9e" + }, + "type": "payment_intent.succeeded" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/payment_method_detached.json b/spec/fixtures/stripe/2020-08-27/webhooks/payment_method_detached.json new file mode 100644 index 0000000..30915f9 --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/payment_method_detached.json @@ -0,0 +1,62 @@ +{ + "id": "evt_1LVXQRH4tiDZlIUa2kOODfxl", + "object": "event", + "api_version": "2020-08-27", + "created": 1660209095, + "data": { + "object": { + "id": "card_1LVXJrH4tiDZlIUaDiJElAN9", + "object": "payment_method", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "exp_month": 12, + "exp_year": 2023, + "fingerprint": "ek9QuolF5FxYiTDH", + "funding": "debit", + "generated_from": null, + "last4": "5556", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1660208687, + "customer": "cus_LxpUP3kyHRmiwV", + "livemode": false, + "metadata": {}, + "type": "card" + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_fs39TluTwjkndU", + "idempotency_key": "c41958b3-5ab4-49d2-8791-a7871635765d" + }, + "type": "payment_method.detached" +} diff --git a/spec/fixtures/stripe/2020-08-27/webhooks/setup_intent_succeeded.json b/spec/fixtures/stripe/2020-08-27/webhooks/setup_intent_succeeded.json new file mode 100644 index 0000000..8acd6cd --- /dev/null +++ b/spec/fixtures/stripe/2020-08-27/webhooks/setup_intent_succeeded.json @@ -0,0 +1,48 @@ +{ + "id": "evt_1LEBtZDu4p87RxPwbHKmfr13", + "object": "event", + "api_version": "2020-08-27", + "created": 1656074757, + "data": { + "object": { + "id": "seti_1LFeh5Du4p87RxPwCKBRSNzO", + "object": "setup_intent", + "application": null, + "cancellation_reason": null, + "client_secret": "seti_1LFeh5Du4p87RxPwCKBRSNzO_secret_LxZqWjIzFT06Hyqw9wfMcUbtnhmcTNC", + "created": 1656423787, + "customer": null, + "description": null, + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": null, + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1LFeTwDu4p87RxPwh3lMmhvu", + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": ["card"], + "single_use_mandate": null, + "status": "requires_payment_method", + "usage": "off_session" + }, + "previous_attributes": { + "default_source": null + } + }, + "livemode": false, + "pending_webhooks": 0, + "request": { + "id": null, + "idempotency_key": null + }, + "type": "setup_intent.succeeded" +} diff --git a/spec/fixtures/stripe/2024-09-30.acacia/retrieve_payment_method.json b/spec/fixtures/stripe/2024-09-30.acacia/retrieve_payment_method.json new file mode 100644 index 0000000..e47b1ff --- /dev/null +++ b/spec/fixtures/stripe/2024-09-30.acacia/retrieve_payment_method.json @@ -0,0 +1,51 @@ +{ + "id": "pm_1RTjuYQ8iJWBZFaMCfXmSmQD", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 5, + "exp_year": 2026, + "fingerprint": "8TOiB4cGytYxCweY", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1748438450, + "customer": "cus_SOWyVXSNy6uKcg", + "livemode": false, + "metadata": {}, + "type": "card" +} diff --git a/spec/fixtures/stripe/2024-09-30.acacia/retrieve_payment_method_response.json b/spec/fixtures/stripe/2024-09-30.acacia/retrieve_payment_method_response.json new file mode 100644 index 0000000..e47b1ff --- /dev/null +++ b/spec/fixtures/stripe/2024-09-30.acacia/retrieve_payment_method_response.json @@ -0,0 +1,51 @@ +{ + "id": "pm_1RTjuYQ8iJWBZFaMCfXmSmQD", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 5, + "exp_year": 2026, + "fingerprint": "8TOiB4cGytYxCweY", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1748438450, + "customer": "cus_SOWyVXSNy6uKcg", + "livemode": false, + "metadata": {}, + "type": "card" +} diff --git a/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_canceled.json b/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_canceled.json new file mode 100644 index 0000000..6e818c4 --- /dev/null +++ b/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_canceled.json @@ -0,0 +1,84 @@ +{ + "id": "evt_3SVCroQ8iJWBZFaM2GyG1PVP", + "object": "event", + "api_version": "2024-09-30.acacia", + "created": 1763738710, + "data": { + "object": { + "id": "pi_3SVCroQ8iJWBZFaM22iaIuU3", + "object": "payment_intent", + "amount": 23040, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": 1763738710, + "cancellation_reason": "duplicate", + "capture_method": "automatic_async", + "client_secret": "pi_3SVCroQ8iJWBZFaM22iaIuU3_secret_wMcPlg3I0vg6axAwII8B1HOVv", + "confirmation_method": "automatic", + "created": 1763564420, + "currency": "usd", + "customer": "cus_TOlZpKcK7V4tSl", + "description": "Hooli - Invoice HOO-156A-028-011", + "excluded_payment_method_types": null, + "invoice": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "edde4cc9-a9d7-4e33-bc12-a81476684bf2", + "lago_payment_id": "8f1e4ee4-301b-4332-8815-f699878a853c", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "516ab92d-1975-4cca-a688-1bf23d22156a", + "lago_payable_id": "b5757d8a-347a-4149-a5fa-3fd9f5154a04", + "invoice_issuing_date": "2025-11-19", + "lago_invoice_id": "b5757d8a-347a-4149-a5fa-3fd9f5154a04", + "lago_organization_id": "11111111-2222-3333-4444-555555555555" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1SRyLyQ8iJWBZFaMEvqtm6U6", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "us_bank_account": { + "mandate_options": {}, + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "us_bank_account" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 5, + "request": { + "id": "req_Eo1OhTrnHDS81N", + "idempotency_key": "0b7e3cfb-5084-4f1b-abd8-b4172e5df135" + }, + "type": "payment_intent.canceled" +} diff --git a/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_payment_failed.json b/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_payment_failed.json new file mode 100644 index 0000000..d63a2b3 --- /dev/null +++ b/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_payment_failed.json @@ -0,0 +1,136 @@ +{ + "id": "evt_3SVvxMQ8iJWBZFaM1z5wZ6Za", + "object": "event", + "api_version": "2024-09-30.acacia", + "created": 1763737745, + "data": { + "object": { + "id": "pi_3SVvxMQ8iJWBZFaM1Lao8ehu", + "object": "payment_intent", + "amount": 11880, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_3SVvxMQ8iJWBZFaM1Lao8ehu_secret_fwMspul9uxTskxWVJekMXcffk", + "confirmation_method": "automatic", + "created": 1763737744, + "currency": "usd", + "customer": "cus_TSOWyTkxXlWiTc", + "description": "Hooli - Invoice HOO-156A-030-005", + "excluded_payment_method_types": null, + "invoice": null, + "last_payment_error": { + "code": "authentication_required", + "decline_code": "authentication_not_handled", + "doc_url": "https://stripe.com/docs/error-codes/authentication-required", + "message": "This payment required an authentication action to complete, but `error_on_requires_action` was set. When you're ready, you can upgrade your integration to handle actions at https://stripe.com/docs/payments/payment-intents/upgrade-to-handle-actions.", + "payment_method": { + "id": "pm_1SVTn7Q8iJWBZFaMgjl33y0O", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "gagerg@sfsefs.csrkgjsdbrgjdrbg", + "name": "awdawd", + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "IE", + "display_brand": "visa", + "exp_month": 2, + "exp_year": 2029, + "fingerprint": "eumCxi1ZFoTDYqvg", + "funding": "credit", + "generated_from": null, + "last4": "3220", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1763629477, + "customer": "cus_TSOWyTkxXlWiTc", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3SVvxMQ8iJWBZFaM1YEcxCs2", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "8adce609-5e68-4f3b-acf2-cdc9ee6609fd", + "lago_payment_id": "6a20520f-2047-4170-8282-a628197dd9ff", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "516ab92d-1975-4cca-a688-1bf23d22156a", + "lago_payable_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "invoice_issuing_date": "2025-11-21", + "lago_invoice_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "lago_organization_id": "11111111-2222-3333-4444-555555555555" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_ugAfLhYwxZ3saC", + "idempotency_key": "payment-6a20520f-2047-4170-8282-a628197dd9ff" + }, + "type": "payment_intent.payment_failed" +} diff --git a/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_succeeded.json b/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_succeeded.json new file mode 100644 index 0000000..1b57500 --- /dev/null +++ b/spec/fixtures/stripe/2024-09-30.acacia/webhooks/payment_intent_succeeded.json @@ -0,0 +1,75 @@ +{ + "id": "evt_3R3dvoQ8iJWBZFaM0Wh4E44o", + "object": "event", + "api_version": "2024-09-30.acacia", + "created": 1742218937, + "data": { + "object": { + "id": "pi_3R3dvoQ8iJWBZFaM0uu1G1rx", + "object": "payment_intent", + "amount": 3897, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 3897, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "client_secret": "pi_3R3dvoQ8iJWBZFaM0uu1G1rx_secret_qADOJoHblKMdHbpS73aZEncpt", + "confirmation_method": "automatic", + "created": 1742218936, + "currency": "usd", + "customer": "cus_Rw5Qso78STEap3", + "description": "Hooli - Invoice HOO-CAAB-028-004", + "invoice": null, + "last_payment_error": null, + "latest_charge": "ch_3R3dvoQ8iJWBZFaM0NhAbztB", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "e4674f68-a7ba-4ce8-95e9-981f346b49d7", + "invoice_issuing_date": "2025-03-17", + "lago_invoice_id": "5ccdc601-18a5-4f22-a8e7-a53ca18e1f00" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1R2DFsQ8iJWBZFaMw3LLbR0r", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "crypto": {} + }, + "payment_method_types": [ + "card", + "crypto" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_O6QX7CMLkdywUG", + "idempotency_key": "payment-086e04f0-7459-41d2-ab66-8795f2c0ff9e" + }, + "type": "payment_intent.succeeded" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/customer_list_payment_methods_empty_response.json b/spec/fixtures/stripe/2025-04-30.basil/customer_list_payment_methods_empty_response.json new file mode 100644 index 0000000..d64ccd0 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/customer_list_payment_methods_empty_response.json @@ -0,0 +1,6 @@ +{ + "object": "list", + "data": [], + "has_more": false, + "url": "/v1/customers/cus_SRBmHaB5ZDcJ4i/payment_methods" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/customer_list_payment_methods_response.json b/spec/fixtures/stripe/2025-04-30.basil/customer_list_payment_methods_response.json new file mode 100644 index 0000000..d48d4bd --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/customer_list_payment_methods_response.json @@ -0,0 +1,58 @@ +{ + "object": "list", + "data": [ + { + "id": "pm_1RWGX9Q8iJWBZFaMnSMSDSi0", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2026, + "fingerprint": "8TOiB4cGytYxCweY", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1749040507, + "customer": "cus_SR8o2vcGRkVb6v", + "livemode": false, + "metadata": {}, + "type": "card" + } + ], + "has_more": false, + "url": "/v1/customers/cus_SR8o2vcGRkVb6v/payment_methods" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/customer_retrieve_response.json b/spec/fixtures/stripe/2025-04-30.basil/customer_retrieve_response.json new file mode 100644 index 0000000..9d1eae3 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/customer_retrieve_response.json @@ -0,0 +1,39 @@ +{ + "id": "cus_SR8o2vcGRkVb6v", + "object": "customer", + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "balance": 0, + "created": 1749040499, + "currency": null, + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": null, + "invoice_prefix": "23RAT06I", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "customer_id": "cust_2025-06-04T12:34:59Z--d2ac6d", + "lago_customer_id": "2c0f0f00-c378-4915-8ea4-c26304e1968a" + }, + "name": "Integration Test UPDATED 2025-06-04T12:35:09Z", + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/payment_intent_authentication_required_response.json b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_authentication_required_response.json new file mode 100644 index 0000000..4925cbd --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_authentication_required_response.json @@ -0,0 +1,320 @@ +{ + "error": { + "code": "authentication_required", + "decline_code": "authentication_not_handled", + "doc_url": "https://stripe.com/docs/error-codes/authentication-required", + "message": "This payment required an authentication action to complete, but `error_on_requires_action` was set. When you're ready, you can upgrade your integration to handle actions at https://stripe.com/docs/payments/payment-intents/upgrade-to-handle-actions.", + "payment_intent": { + "id": "pi_3SUpk9Q8iJWBZFaM20I3flZT", + "object": "payment_intent", + "amount": 1343333, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [ + { + "id": "ch_3SUpk9Q8iJWBZFaM216nFNJC", + "object": "charge", + "amount": 1343333, + "amount_captured": 0, + "amount_refunded": 0, + "application": null, + "application_fee": null, + "application_fee_amount": null, + "balance_transaction": null, + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "calculated_statement_descriptor": "JULIEN", + "captured": false, + "created": 1763475533, + "currency": "eur", + "customer": "cus_TRjC4B0UDzH8K1", + "description": "VonRueden-Harber - Invoice VON-A4DD-001-002", + "destination": null, + "dispute": null, + "disputed": false, + "failure_balance_transaction": null, + "failure_code": "authentication_required", + "failure_message": "Your card was declined. This transaction requires authentication.", + "fraud_details": {}, + "invoice": null, + "livemode": false, + "metadata": { + "invoice_type": "subscription", + "lago_customer_id": "98bf77bb-cac2-47c6-a358-41092b25c9f9", + "lago_payment_id": "2df287d3-eca2-41b1-9058-72b4c6e1b641", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "c1d5c434-0218-4a98-a23e-66a90e06a4dd", + "lago_payable_id": "0c5c28cc-f852-4ae1-9eb3-c66f04ed1f7a", + "invoice_issuing_date": "2025-11-18", + "lago_invoice_id": "0c5c28cc-f852-4ae1-9eb3-c66f04ed1f7a", + "lago_organization_id": "3b6eb26a-2fba-4a09-89a1-c8dc35d0d294" + }, + "on_behalf_of": null, + "order": null, + "outcome": { + "advice_code": null, + "network_advice_code": null, + "network_decline_code": null, + "network_status": "declined_by_network", + "reason": "authentication_required", + "risk_level": "normal", + "risk_score": 33, + "seller_message": "The bank returned the decline code `authentication_required`.", + "type": "issuer_declined" + }, + "paid": false, + "payment_intent": "pi_3SUpk9Q8iJWBZFaM20I3flZT", + "payment_method": "pm_1SUpk3Q8iJWBZFaMdTZGqSKL", + "payment_method_details": { + "card": { + "amount_authorized": null, + "authorization_code": "178760", + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "DE", + "exp_month": 11, + "exp_year": 2026, + "extended_authorization": { + "status": "disabled" + }, + "fingerprint": "Kimrq0v3wE9PFZiL", + "funding": "credit", + "incremental_authorization": { + "status": "unavailable" + }, + "installments": null, + "last4": "3184", + "mandate": null, + "multicapture": { + "status": "unavailable" + }, + "network": "visa", + "network_token": { + "used": false + }, + "network_transaction_id": "751051091141134", + "overcapture": { + "maximum_amount_capturable": 1343333, + "status": "unavailable" + }, + "regulated_status": "unregulated", + "three_d_secure": null, + "wallet": null + }, + "type": "card" + }, + "radar_options": {}, + "receipt_email": null, + "receipt_number": null, + "receipt_url": null, + "refunded": false, + "refunds": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges/ch_3SUpk9Q8iJWBZFaM216nFNJC/refunds" + }, + "review": null, + "shipping": null, + "source": null, + "source_transfer": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "failed", + "transfer_data": null, + "transfer_group": null + } + ], + "has_more": false, + "total_count": 1, + "url": "/v1/charges?payment_intent=pi_3SUpk9Q8iJWBZFaM20I3flZT" + }, + "client_secret": "pi_3S**********************_******_*********************pT2d", + "confirmation_method": "automatic", + "created": 1763475533, + "currency": "eur", + "customer": "cus_TRjC4B0UDzH8K1", + "description": "VonRueden-Harber - Invoice VON-A4DD-001-002", + "excluded_payment_method_types": null, + "invoice": null, + "last_payment_error": { + "code": "authentication_required", + "decline_code": "authentication_not_handled", + "doc_url": "https://stripe.com/docs/error-codes/authentication-required", + "message": "This payment required an authentication action to complete, but `error_on_requires_action` was set. When you're ready, you can upgrade your integration to handle actions at https://stripe.com/docs/payments/payment-intents/upgrade-to-handle-actions.", + "payment_method": { + "id": "pm_1SUpk3Q8iJWBZFaMdTZGqSKL", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "DE", + "display_brand": "visa", + "exp_month": 11, + "exp_year": 2026, + "fingerprint": "Kimrq0v3wE9PFZiL", + "funding": "credit", + "generated_from": null, + "last4": "3184", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1763475527, + "customer": "cus_TRjC4B0UDzH8K1", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3SUpk9Q8iJWBZFaM216nFNJC", + "livemode": false, + "metadata": { + "invoice_type": "subscription", + "lago_customer_id": "98bf77bb-cac2-47c6-a358-41092b25c9f9", + "lago_payment_id": "2df287d3-eca2-41b1-9058-72b4c6e1b641", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "c1d5c434-0218-4a98-a23e-66a90e06a4dd", + "lago_payable_id": "0c5c28cc-f852-4ae1-9eb3-c66f04ed1f7a", + "invoice_issuing_date": "2025-11-18", + "lago_invoice_id": "0c5c28cc-f852-4ae1-9eb3-c66f04ed1f7a", + "lago_organization_id": "3b6eb26a-2fba-4a09-89a1-c8dc35d0d294" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "payment_method": { + "id": "pm_1SUpk3Q8iJWBZFaMdTZGqSKL", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "DE", + "display_brand": "visa", + "exp_month": 11, + "exp_year": 2026, + "fingerprint": "Kimrq0v3wE9PFZiL", + "funding": "credit", + "generated_from": null, + "last4": "3184", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1763475527, + "customer": "cus_TRjC4B0UDzH8K1", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "request_log_url": "https://dashboard.stripe.com/test/logs/req_DTG7G7rkBx6HNr?t=1763475533", + "type": "card_error" + } +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/payment_intent_authorization_failed_response.json b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_authorization_failed_response.json new file mode 100644 index 0000000..9a9037b --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_authorization_failed_response.json @@ -0,0 +1,187 @@ +{ + "error": { + "advice_code": "try_again_later", + "charge": "ch_3RWO3wQ8iJWBZFaM14akVyoQ", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_intent": { + "id": "pi_3RWO3wQ8iJWBZFaM1DwAq25i", + "object": "payment_intent", + "amount": 2100, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": { + "allow_redirects": "never", + "enabled": true + }, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_3R**********************_******_*********************EJNT", + "confirmation_method": "automatic", + "created": 1749069448, + "currency": "eur", + "customer": "cus_SRGblkdyBI9RS4", + "description": "Pre-authorization for subscription", + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3RWO3wQ8iJWBZFaM14akVyoQ", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_method": { + "id": "pm_1RWO3tQ8iJWBZFaMV8nA6nzH", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2026, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1749069445, + "customer": "cus_SRGblkdyBI9RS4", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3RWO3wQ8iJWBZFaM14akVyoQ", + "livemode": false, + "metadata": { + "plan_code": "any_plan" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": { + "id": "pmc_1QsO8FQ8iJWBZFaMYOXGb0Ic", + "parent": null + }, + "payment_method_options": { + "card": { + "capture_method": "manual", + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "link": { + "persistent_token": null + } + }, + "payment_method_types": [ + "card", + "link" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "payment_method": { + "id": "pm_1RWO3tQ8iJWBZFaMV8nA6nzH", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2026, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1749069445, + "customer": "cus_SRGblkdyBI9RS4", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "request_log_url": "https://dashboard.stripe.com/test/logs/req_2bJRp7SEnKhkMf?t=1749069448", + "type": "card_error" + } +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/payment_intent_card_declined_response.json b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_card_declined_response.json new file mode 100644 index 0000000..1a69405 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_card_declined_response.json @@ -0,0 +1,184 @@ +{ + "error": { + "advice_code": "try_again_later", + "charge": "ch_3RWJu0Q8iJWBZFaM2XNS6uiT", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_intent": { + "id": "pi_3RWJu0Q8iJWBZFaM2S1rzYRV", + "object": "payment_intent", + "amount": 4620, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_3R**********************_******_*********************bifg", + "confirmation_method": "automatic", + "created": 1749053456, + "currency": "eur", + "customer": "cus_SRCIP043oBsNpV", + "description": "Hamill and Sons - Invoice HAM-4D33-001-001", + "last_payment_error": { + "advice_code": "try_again_later", + "charge": "ch_3RWJu0Q8iJWBZFaM2XNS6uiT", + "code": "card_declined", + "decline_code": "generic_decline", + "doc_url": "https://stripe.com/docs/error-codes/card-declined", + "message": "Your card was declined.", + "payment_method": { + "id": "pm_1RWJtyQ8iJWBZFaM7VUymbOi", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2026, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1749053454, + "customer": "cus_SRCIP043oBsNpV", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3RWJu0Q8iJWBZFaM2XNS6uiT", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "6905cdff-e814-4b11-bfd0-354c9c892a14", + "lago_payment_id": "f479e061-3d72-422a-9cbf-c2231d1dc22e", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "fd82eee2-ae1e-453e-8170-cfc371db4d33", + "lago_payable_id": "5d61b290-4b8f-433e-ad83-575d74eb89ad", + "invoice_issuing_date": "2025-06-04", + "lago_invoice_id": "5d61b290-4b8f-433e-ad83-575d74eb89ad", + "lago_organization_id": "fb73459d-9e7f-4f22-859f-06fc4751cc06" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + }, + "payment_method": { + "id": "pm_1RWJtyQ8iJWBZFaM7VUymbOi", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2026, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1749053454, + "customer": "cus_SRCIP043oBsNpV", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "request_log_url": "https://dashboard.stripe.com/test/logs/req_Mnze9i9NwAxolD?t=1749053456", + "type": "card_error" + } +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/payment_intent_requires_action_response.json b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_requires_action_response.json new file mode 100644 index 0000000..b1e4aac --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/payment_intent_requires_action_response.json @@ -0,0 +1,77 @@ +{ + "id": "pi_3SUpkBQ8iJWBZFaM0SuylvJC", + "object": "payment_intent", + "amount": 1343333, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic", + "charges": { + "object": "list", + "data": [], + "has_more": false, + "total_count": 0, + "url": "/v1/charges?payment_intent=pi_3SUpkBQ8iJWBZFaM0SuylvJC" + }, + "client_secret": "pi_3S**********************_******_*********************GpAp", + "confirmation_method": "automatic", + "created": 1763475535, + "currency": "eur", + "customer": "cus_TRjC4B0UDzH8K1", + "description": "VonRueden-Harber - Invoice VON-A4DD-001-002", + "excluded_payment_method_types": null, + "invoice": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "metadata": { + "invoice_type": "subscription", + "lago_customer_id": "98bf77bb-cac2-47c6-a358-41092b25c9f9", + "lago_payment_id": "1b38a6f4-3170-41a9-b803-9fea9efc48bc", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "c1d5c434-0218-4a98-a23e-66a90e06a4dd", + "lago_payable_id": "0c5c28cc-f852-4ae1-9eb3-c66f04ed1f7a", + "invoice_issuing_date": "2025-11-18", + "lago_invoice_id": "0c5c28cc-f852-4ae1-9eb3-c66f04ed1f7a", + "lago_organization_id": "3b6eb26a-2fba-4a09-89a1-c8dc35d0d294" + }, + "next_action": { + "redirect_to_url": { + "return_url": "https://app.lago.dev", + "url": "https://hooks.stripe.com/3d_secure_2/hosted?merchant=acct_1QsO86Q8iJWBZFaM&payment_intent=pi_3SUpkBQ8iJWBZFaM0SuylvJC&payment_intent_client_secret=pi_3SUpkBQ8iJWBZFaM0SuylvJC_secret_bYitZ99s7rl1899EtiJdtGpAp&publishable_key=pk_test_51QsO86Q8iJWBZFaMwtpbKdhk6KBo35c33qoCf4VmWBJsXPuFQHsPxOW7vYLoWWNTZ6OeRHOTZCvndlERhCwTQuxk008fi0Py0N&source=payatt_3SUpkBQ8iJWBZFaM03X7Tpiy" + }, + "type": "redirect_to_url" + }, + "on_behalf_of": null, + "payment_method": "pm_1SUpk3Q8iJWBZFaMdTZGqSKL", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_action", + "transfer_data": null, + "transfer_group": null +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/retrieve_payment_method_response.json b/spec/fixtures/stripe/2025-04-30.basil/retrieve_payment_method_response.json new file mode 100644 index 0000000..e47b1ff --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/retrieve_payment_method_response.json @@ -0,0 +1,51 @@ +{ + "id": "pm_1RTjuYQ8iJWBZFaMCfXmSmQD", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 5, + "exp_year": 2026, + "fingerprint": "8TOiB4cGytYxCweY", + "funding": "credit", + "generated_from": null, + "last4": "4242", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1748438450, + "customer": "cus_SOWyVXSNy6uKcg", + "livemode": false, + "metadata": {}, + "type": "card" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhook_endpoint_create_response.json b/spec/fixtures/stripe/2025-04-30.basil/webhook_endpoint_create_response.json new file mode 100644 index 0000000..2ac3a0d --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhook_endpoint_create_response.json @@ -0,0 +1,22 @@ +{ + "id": "we_1RWGXDQ8iJWBZFaMFV5KoJxR", + "object": "webhook_endpoint", + "api_version": "2025-04-30.basil", + "application": null, + "created": 1749040511, + "description": null, + "enabled_events": [ + "setup_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.succeeded", + "payment_method.detached", + "charge.refund.updated", + "customer.updated", + "charge.dispute.closed" + ], + "livemode": false, + "metadata": {}, + "secret": "whsec_SB**************************RyvM", + "status": "enabled", + "url": "https://api.lago.dev/webhooks/stripe/c09af284-dc02-422d-98f8-26ce9a52f1e8?code=stripe_spec" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhook_endpoint_update_response.json b/spec/fixtures/stripe/2025-04-30.basil/webhook_endpoint_update_response.json new file mode 100644 index 0000000..ac0f5da --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhook_endpoint_update_response.json @@ -0,0 +1,21 @@ +{ + "id": "we_1RWK7AQ8iJWBZFaMa5O0M5XQ", + "object": "webhook_endpoint", + "api_version": "2025-04-30.basil", + "application": null, + "created": 1749054272, + "description": null, + "enabled_events": [ + "setup_intent.succeeded", + "payment_intent.payment_failed", + "payment_intent.succeeded", + "payment_method.detached", + "charge.refund.updated", + "customer.updated", + "charge.dispute.closed" + ], + "livemode": false, + "metadata": {}, + "status": "enabled", + "url": "https://api.lago.dev/webhooks/stripe/777b8139-9981-470a-bbb5-5c6e851f5913?code=stripe_spec" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/charge_dispute_closed.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/charge_dispute_closed.json new file mode 100644 index 0000000..27e3e44 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/charge_dispute_closed.json @@ -0,0 +1,80 @@ +{ + "id": "evt_3RWXidQ8iJWBZFaM0N9UpCe2", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1749106569, + "data": { + "object": { + "id": "dp_1RWXieQ8iJWBZFaM572opwWo", + "object": "dispute", + "amount": 100, + "balance_transaction": null, + "balance_transactions": [], + "charge": "ch_3RWXidQ8iJWBZFaM07eC54Yk", + "created": 1749106568, + "currency": "usd", + "enhanced_eligibility_types": [], + "evidence": { + "access_activity_log": null, + "billing_address": null, + "cancellation_policy": null, + "cancellation_policy_disclosure": null, + "cancellation_rebuttal": null, + "customer_communication": null, + "customer_email_address": null, + "customer_name": null, + "customer_purchase_ip": null, + "customer_signature": null, + "duplicate_charge_documentation": null, + "duplicate_charge_explanation": null, + "duplicate_charge_id": null, + "enhanced_evidence": {}, + "product_description": null, + "receipt": null, + "refund_policy": null, + "refund_policy_disclosure": null, + "refund_refusal_explanation": null, + "service_date": null, + "service_documentation": null, + "shipping_address": null, + "shipping_carrier": null, + "shipping_date": null, + "shipping_documentation": null, + "shipping_tracking_number": null, + "uncategorized_file": null, + "uncategorized_text": null + }, + "evidence_details": { + "due_by": 1749945599, + "enhanced_eligibility": {}, + "has_evidence": false, + "past_due": false, + "submission_count": 0 + }, + "is_charge_refundable": true, + "livemode": false, + "metadata": {}, + "payment_intent": "pi_3RWXidQ8iJWBZFaM0hwfe7LL", + "payment_method_details": { + "card": { + "brand": "visa", + "case_type": "inquiry", + "network_reason_code": "10" + }, + "type": "card" + }, + "reason": "fraudulent", + "status": "warning_closed" + }, + "previous_attributes": { + "status": "warning_needs_response" + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": "req_SamoTJQJOWbCeW", + "idempotency_key": "dd3d2c7b-ebc7-4ec0-a02e-2147b5af3e98" + }, + "type": "charge.dispute.closed" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/charge_refund_updated.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/charge_refund_updated.json new file mode 100644 index 0000000..8558edf --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/charge_refund_updated.json @@ -0,0 +1,46 @@ +{ + "id": "evt_3RWYCFQ8iJWBZFaM1z10b8SX", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1749108405, + "data": { + "object": { + "id": "re_3RWYCFQ8iJWBZFaM191ZqGD9", + "object": "refund", + "amount": 100, + "balance_transaction": "txn_3RWYCFQ8iJWBZFaM1sKiDOf8", + "charge": "ch_3RWYCFQ8iJWBZFaM1R8YF07I", + "created": 1749108404, + "currency": "usd", + "destination_details": { + "card": { + "reference_status": "pending", + "reference_type": "acquirer_reference_number", + "type": "refund" + }, + "type": "card" + }, + "metadata": { + "order_id": "6735" + }, + "payment_intent": "pi_3RWYCFQ8iJWBZFaM1p1gEVdI", + "reason": null, + "receipt_number": null, + "source_transfer_reversal": null, + "status": "succeeded", + "transfer_reversal": null + }, + "previous_attributes": { + "metadata": { + "order_id": null + } + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": "req_jxok5iOQTsD18s", + "idempotency_key": "7ccefd8c-132e-4246-94a4-53e9c923632e" + }, + "type": "charge.refund.updated" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/customer_updated.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/customer_updated.json new file mode 100644 index 0000000..f316fa6 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/customer_updated.json @@ -0,0 +1,57 @@ +{ + "id": "evt_1RWGXCQ8iJWBZFaMVlV0UVdh", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1749040510, + "data": { + "object": { + "id": "cus_SR8o2vcGRkVb6v", + "object": "customer", + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "balance": 0, + "created": 1749040499, + "currency": null, + "default_source": null, + "delinquent": false, + "description": null, + "discount": null, + "email": null, + "invoice_prefix": "23RAT06I", + "invoice_settings": { + "custom_fields": null, + "default_payment_method": null, + "footer": null, + "rendering_options": null + }, + "livemode": false, + "metadata": { + "lago_customer_id": "2c0f0f00-c378-4915-8ea4-c26304e1968a", + "customer_id": "cust_2025-06-04T12:34:59Z--d2ac6d" + }, + "name": "Integration Test UPDATED 2025-06-04T12:35:09Z", + "next_invoice_sequence": 1, + "phone": null, + "preferred_locales": [], + "shipping": null, + "tax_exempt": "none", + "test_clock": null + }, + "previous_attributes": { + "name": "Integration Test 2025-06-04T12:34:59Z" + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": "req_vsEORJTarL1PZe", + "idempotency_key": "c604ba99-16e2-41ca-a585-0a334e666b39" + }, + "type": "customer.updated" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_canceled.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_canceled.json new file mode 100644 index 0000000..ae2f02a --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_canceled.json @@ -0,0 +1,83 @@ +{ + "id": "evt_3SVCroQ8iJWBZFaM2GyG1PVP", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1763738710, + "data": { + "object": { + "id": "pi_3SVCroQ8iJWBZFaM22iaIuU3", + "object": "payment_intent", + "amount": 23040, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": 1763738710, + "cancellation_reason": "duplicate", + "capture_method": "automatic_async", + "client_secret": "pi_3SVCroQ8iJWBZFaM22iaIuU3_secret_wMcPlg3I0vg6axAwII8B1HOVv", + "confirmation_method": "automatic", + "created": 1763564420, + "currency": "usd", + "customer": "cus_TOlZpKcK7V4tSl", + "description": "Hooli - Invoice HOO-156A-028-011", + "excluded_payment_method_types": null, + "last_payment_error": null, + "latest_charge": null, + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "edde4cc9-a9d7-4e33-bc12-a81476684bf2", + "lago_payment_id": "8f1e4ee4-301b-4332-8815-f699878a853c", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "516ab92d-1975-4cca-a688-1bf23d22156a", + "lago_payable_id": "b5757d8a-347a-4149-a5fa-3fd9f5154a04", + "invoice_issuing_date": "2025-11-19", + "lago_invoice_id": "b5757d8a-347a-4149-a5fa-3fd9f5154a04", + "lago_organization_id": "11111111-2222-3333-4444-555555555555" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1SRyLyQ8iJWBZFaMEvqtm6U6", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + }, + "us_bank_account": { + "mandate_options": {}, + "verification_method": "automatic" + } + }, + "payment_method_types": [ + "card", + "us_bank_account" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "canceled", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 5, + "request": { + "id": "req_Eo1OhTrnHDS81N", + "idempotency_key": "0b7e3cfb-5084-4f1b-abd8-b4172e5df135" + }, + "type": "payment_intent.canceled" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_payment_failed.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_payment_failed.json new file mode 100644 index 0000000..6e356c3 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_payment_failed.json @@ -0,0 +1,135 @@ +{ + "id": "evt_3SVvxMQ8iJWBZFaM1z5wZ6Za", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1763737745, + "data": { + "object": { + "id": "pi_3SVvxMQ8iJWBZFaM1Lao8ehu", + "object": "payment_intent", + "amount": 11880, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 0, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_3SVvxMQ8iJWBZFaM1Lao8ehu_secret_fwMspul9uxTskxWVJekMXcffk", + "confirmation_method": "automatic", + "created": 1763737744, + "currency": "usd", + "customer": "cus_TSOWyTkxXlWiTc", + "description": "Hooli - Invoice HOO-156A-030-005", + "excluded_payment_method_types": null, + "last_payment_error": { + "code": "authentication_required", + "decline_code": "authentication_not_handled", + "doc_url": "https://stripe.com/docs/error-codes/authentication-required", + "message": "This payment required an authentication action to complete, but `error_on_requires_action` was set. When you're ready, you can upgrade your integration to handle actions at https://stripe.com/docs/payments/payment-intents/upgrade-to-handle-actions.", + "payment_method": { + "id": "pm_1SVTn7Q8iJWBZFaMgjl33y0O", + "object": "payment_method", + "allow_redisplay": "always", + "billing_details": { + "address": { + "city": null, + "country": "FR", + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": "gagerg@sfsefs.csrkgjsdbrgjdrbg", + "name": "awdawd", + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "IE", + "display_brand": "visa", + "exp_month": 2, + "exp_year": 2029, + "fingerprint": "eumCxi1ZFoTDYqvg", + "funding": "credit", + "generated_from": null, + "last4": "3220", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1763629477, + "customer": "cus_TSOWyTkxXlWiTc", + "livemode": false, + "metadata": {}, + "type": "card" + }, + "type": "card_error" + }, + "latest_charge": "ch_3SVvxMQ8iJWBZFaM1YEcxCs2", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "8adce609-5e68-4f3b-acf2-cdc9ee6609fd", + "lago_payment_id": "6a20520f-2047-4170-8282-a628197dd9ff", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "516ab92d-1975-4cca-a688-1bf23d22156a", + "lago_payable_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "invoice_issuing_date": "2025-11-21", + "lago_invoice_id": "7fd56270-c904-422c-b48c-c2eced9b569f", + "lago_organization_id": "11111111-2222-3333-4444-555555555555" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": null, + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "requires_payment_method", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 3, + "request": { + "id": "req_ugAfLhYwxZ3saC", + "idempotency_key": "payment-6a20520f-2047-4170-8282-a628197dd9ff" + }, + "type": "payment_intent.payment_failed" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_succeeded.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_succeeded.json new file mode 100644 index 0000000..ebf56e1 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_intent_succeeded.json @@ -0,0 +1,77 @@ +{ + "id": "evt_3RTkpYQ8iJWBZFaM1G1JtOIT", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1748441984, + "data": { + "object": { + "id": "pi_3RTkpYQ8iJWBZFaM1LBHNo0J", + "object": "payment_intent", + "amount": 4620, + "amount_capturable": 0, + "amount_details": { + "tip": {} + }, + "amount_received": 4620, + "application": null, + "application_fee_amount": null, + "automatic_payment_methods": null, + "canceled_at": null, + "cancellation_reason": null, + "capture_method": "automatic_async", + "client_secret": "pi_3RTkpYQ8iJWBZFaM1LBHNo0J_secret_f0dnQpWeyO5mbpUXVoumiPsPL", + "confirmation_method": "automatic", + "created": 1748441984, + "currency": "eur", + "customer": "cus_SOXv8CncjHqSEv", + "description": "Oberbrunner-Purdy - Invoice OBE-B200-001-001", + "last_payment_error": null, + "latest_charge": "ch_3RTkpYQ8iJWBZFaM1WnB7qvm", + "livemode": false, + "metadata": { + "invoice_type": "one_off", + "lago_customer_id": "1342ce9f-c8cb-4b16-b9d4-7add8ec0cf5d", + "lago_payment_id": "de7602d6-fbd7-40e4-891e-c9937d2d4aa9", + "lago_payable_type": "Invoice", + "lago_billing_entity_id": "c0612b05-c818-456e-aa1b-0381b3fbb200", + "lago_payable_id": "0d0b6c24-c5bd-44db-98ae-e8beeb4560bb", + "invoice_issuing_date": "2025-05-28", + "lago_invoice_id": "0d0b6c24-c5bd-44db-98ae-e8beeb4560bb", + "lago_organization_id": "9a443ddf-b2f3-4abb-b822-b99f425b8578" + }, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1RTkpWQ8iJWBZFaMdDm6wKzg", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "installments": null, + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "processing": null, + "receipt_email": null, + "review": null, + "setup_future_usage": null, + "shipping": null, + "source": null, + "statement_descriptor": null, + "statement_descriptor_suffix": null, + "status": "succeeded", + "transfer_data": null, + "transfer_group": null + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": "req_8GknGVjaPItZiK", + "idempotency_key": "payment-de7602d6-fbd7-40e4-891e-c9937d2d4aa9" + }, + "type": "payment_intent.succeeded" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_method_detached.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_method_detached.json new file mode 100644 index 0000000..692c345 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/payment_method_detached.json @@ -0,0 +1,69 @@ +{ + "id": "evt_1RWGX6Q8iJWBZFaMeBzMFOpP", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1749040504, + "data": { + "object": { + "id": "pm_1RWGX3Q8iJWBZFaM6l9fORzc", + "object": "payment_method", + "allow_redisplay": "unspecified", + "billing_details": { + "address": { + "city": null, + "country": null, + "line1": null, + "line2": null, + "postal_code": null, + "state": null + }, + "email": null, + "name": null, + "phone": null, + "tax_id": null + }, + "card": { + "brand": "visa", + "checks": { + "address_line1_check": null, + "address_postal_code_check": null, + "cvc_check": "pass" + }, + "country": "US", + "display_brand": "visa", + "exp_month": 6, + "exp_year": 2026, + "fingerprint": "pmIfA8TCefcd72yD", + "funding": "credit", + "generated_from": null, + "last4": "0341", + "networks": { + "available": [ + "visa" + ], + "preferred": null + }, + "regulated_status": "unregulated", + "three_d_secure_usage": { + "supported": true + }, + "wallet": null + }, + "created": 1749040501, + "customer": null, + "livemode": false, + "metadata": {}, + "type": "card" + }, + "previous_attributes": { + "customer": "cus_SR8o2vcGRkVb6v" + } + }, + "livemode": false, + "pending_webhooks": 2, + "request": { + "id": "req_heIwvPaoGGRyhp", + "idempotency_key": "a2a98b51-7dd7-40a7-b880-b7123f62798b" + }, + "type": "payment_method.detached" +} diff --git a/spec/fixtures/stripe/2025-04-30.basil/webhooks/setup_intent_succeeded.json b/spec/fixtures/stripe/2025-04-30.basil/webhooks/setup_intent_succeeded.json new file mode 100644 index 0000000..e4a5ec8 --- /dev/null +++ b/spec/fixtures/stripe/2025-04-30.basil/webhooks/setup_intent_succeeded.json @@ -0,0 +1,49 @@ +{ + "id": "evt_1RWYtwQ8iJWBZFaMBOWfjn6y", + "object": "event", + "api_version": "2025-04-30.basil", + "created": 1749111112, + "data": { + "object": { + "id": "seti_1RWYtwQ8iJWBZFaMWFEGPCuJ", + "object": "setup_intent", + "application": null, + "automatic_payment_methods": null, + "cancellation_reason": null, + "client_secret": "seti_1RWYtwQ8iJWBZFaMWFEGPCuJ_secret_SRRnn2YuKQ08toZ1DVCrrUgXZjIM2j0", + "created": 1749111112, + "customer": null, + "description": "(created by Stripe CLI)", + "flow_directions": null, + "last_setup_error": null, + "latest_attempt": "setatt_1RWYtwQ8iJWBZFaMlX5E1s1r", + "livemode": false, + "mandate": null, + "metadata": {}, + "next_action": null, + "on_behalf_of": null, + "payment_method": "pm_1RWYtwQ8iJWBZFaMCbtQuuSy", + "payment_method_configuration_details": null, + "payment_method_options": { + "card": { + "mandate_options": null, + "network": null, + "request_three_d_secure": "automatic" + } + }, + "payment_method_types": [ + "card" + ], + "single_use_mandate": null, + "status": "succeeded", + "usage": "off_session" + } + }, + "livemode": false, + "pending_webhooks": 1, + "request": { + "id": "req_C9awUGQGsVSJ4E", + "idempotency_key": "d1606d92-e7f0-4c7d-8a63-a76b6e47c1f1" + }, + "type": "setup_intent.succeeded" +} diff --git a/spec/graphql/concerns/authenticable_api_user_spec.rb b/spec/graphql/concerns/authenticable_api_user_spec.rb new file mode 100644 index 0000000..8d627ff --- /dev/null +++ b/spec/graphql/concerns/authenticable_api_user_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +module AuthenticableApiUserSpec + class ThingType < Types::BaseObject + field :name, String, null: false + field :count, Integer + end + + class RenameThingMutation < Mutations::BaseMutation + include AuthenticableApiUser + + graphql_name "RenameThing" + argument :new_name, String, required: true + type ThingType + + def resolve(**args) + {name: args[:new_name], count: 1} + end + end + + class ThingsMutationType < Types::BaseObject + field :renameThing, mutation: RenameThingMutation + end + + class TestApiSchema < LagoApiSchema + mutation(ThingsMutationType) + end +end + +RSpec.describe AuthenticableApiUser do + let(:mutation) do + <<-GQL + mutation($input: RenameThingInput!) { + renameThing(input: $input) { + name + } + } + GQL + end + + context "with a current user" do + it "renames the thing" do + membership = create(:membership) + + result = AuthenticableApiUserSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {current_user: membership.user} + ) + + expect(result["data"]["renameThing"]["name"]).to eq "new name" + end + end + + context "without a current user" do + it "returns an error" do + result = AuthenticableApiUserSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {current_user: nil} + ) + + partial_error = { + "message" => "unauthorized", + "extensions" => {"status" => :unauthorized, "code" => "unauthorized"} + } + + expect(result["errors"]).to include hash_including(partial_error) + end + end +end diff --git a/spec/graphql/concerns/authenticable_customer_portal_user_spec.rb b/spec/graphql/concerns/authenticable_customer_portal_user_spec.rb new file mode 100644 index 0000000..6355965 --- /dev/null +++ b/spec/graphql/concerns/authenticable_customer_portal_user_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +module AuthenticableCustomerPortalUserSpec + class ThingType < Types::BaseObject + field :name, String, null: false + end + + class ThingResolver < Resolvers::BaseResolver + include AuthenticableCustomerPortalUser + + type ThingType, null: false + + def resolve(**_args) + {name: "something", count: 1} + end + end + + class ThingsQueryType < Types::BaseObject + field :thing, resolver: ThingResolver + end + + class TestApiSchema < LagoApiSchema + query(ThingsQueryType) + end +end + +RSpec.describe AuthenticableCustomerPortalUser do + let(:resolver) do + <<~GQL + query { + thing { + name + } + } + GQL + end + + context "with a customer portal user" do + it "resolvers the thing" do + result = AuthenticableCustomerPortalUserSpec::TestApiSchema.execute( + resolver, + context: {customer_portal_user: create(:user)} + ) + + expect(result["data"]["thing"]).to eq "name" => "something" + end + end + + context "without a current user" do + it "returns an error" do + result = AuthenticableCustomerPortalUserSpec::TestApiSchema.execute( + resolver, + context: {permissions: Permission.permissions_hash(:admin)} + ) + + partial_error = { + "message" => "unauthorized", + "extensions" => {"status" => :unauthorized, "code" => "unauthorized"} + } + + expect(result["errors"]).to include hash_including(partial_error) + end + end +end diff --git a/spec/graphql/concerns/can_require_permissions_spec.rb b/spec/graphql/concerns/can_require_permissions_spec.rb new file mode 100644 index 0000000..7584645 --- /dev/null +++ b/spec/graphql/concerns/can_require_permissions_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +module CanRequirePermissionsSpec + class ThingType < Types::BaseObject + field :name, String, null: false + field :count, Integer + end + + class RenameThingMutation < Mutations::BaseMutation + include CanRequirePermissions + + REQUIRED_PERMISSION = "things:rename" + + graphql_name "RenameThing" + argument :new_name, String, required: true + type ThingType + + def resolve(**args) + {name: args[:new_name], count: 1} + end + end + + class ThingsMutationType < Types::BaseObject + field :renameThing, mutation: RenameThingMutation + end + + class TestApiSchema < LagoApiSchema + mutation(ThingsMutationType) + end +end + +RSpec.describe CanRequirePermissions do + let(:mutation) do + <<-GQL + mutation($input: RenameThingInput!) { + renameThing(input: $input) { + name + } + } + GQL + end + + context "with a the correct permissions" do + it "renames the thing" do + result = CanRequirePermissionsSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {permissions: {"things:rename" => true}} + ) + + expect(result["data"]["renameThing"]["name"]).to eq "new name" + end + end + + context "without a current user" do + it "returns an error" do + result = CanRequirePermissionsSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {permissions: Permission.permissions_hash} + ) + + partial_error = { + "message" => "Missing permissions", + "extensions" => {"status" => :forbidden, "code" => "forbidden", "required_permissions" => ["things:rename"]} + } + + expect(result["errors"]).to include hash_including(partial_error) + end + end +end diff --git a/spec/graphql/concerns/execution_error_responder_spec.rb b/spec/graphql/concerns/execution_error_responder_spec.rb new file mode 100644 index 0000000..632b644 --- /dev/null +++ b/spec/graphql/concerns/execution_error_responder_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +RSpec.describe ExecutionErrorResponder do + let(:responder) { klass.new } + + let(:klass) do + Class.new do + include ExecutionErrorResponder + + public :execution_error, :not_found_error, :not_allowed_error, :forbidden_error, :validation_error, + :third_party_failure, :result_error + end + end + + describe "execution_error" do + subject(:error) do + responder.execution_error(error: "Custom error", status: 400, code: "custom_code", details: {foo_bar: "baz"}) + end + + let(:extensions) do + {status: 400, code: "custom_code", details: {"fooBar" => "baz"}} + end + + it "returns a GraphQL::ExecutionError with correct message and extensions" do + expect(subject).to be_a(GraphQL::ExecutionError) + expect(subject.message).to eq("Custom error") + expect(subject.extensions).to eq(extensions) + end + + it "omits details if not a Hash" do + error = responder.execution_error(details: "not a hash") + expect(error.extensions).not_to have_key(:details) + end + end + + describe "not_found_error" do + subject(:error) { responder.not_found_error(resource: :alert) } + + let(:extensions) do + {status: 404, code: "not_found", details: {"alert" => ["not_found"]}} + end + + it "returns a 404 not found error with resource details" do + expect(subject.extensions).to eq(extensions) + end + end + + describe "not_allowed_error" do + subject(:error) { responder.not_allowed_error(code: "method_not_allowed") } + + let(:extensions) do + {status: 405, code: "method_not_allowed"} + end + + it "returns a 405 not allowed error with code" do + expect(subject.extensions).to eq(extensions) + end + end + + describe "forbidden_error" do + subject(:error) { responder.forbidden_error(code: "access_denied") } + + let(:extensions) do + {status: 403, code: "access_denied"} + end + + it "returns a 403 forbidden error with code" do + expect(subject.extensions).to eq(extensions) + end + end + + describe "validation_error" do + subject(:error) { responder.validation_error(messages: {name: ["can't be blank"]}) } + + let(:extensions) do + {status: 422, code: "unprocessable_entity", details: {"name" => ["can't be blank"]}} + end + + it "returns a 422 validation error with messages" do + expect(subject.extensions).to eq(extensions) + end + end + + describe "#third_party_failure" do + subject(:error) { responder.third_party_failure(messages: "External service failed") } + + let(:extensions) do + {status: 422, code: "third_party_error", details: {"error" => "External service failed"}} + end + + it "returns a 422 third party error with messages" do + expect(subject.extensions).to eq(extensions) + end + end + + describe "#result_error" do + subject(:result_error) { responder.result_error(result) } + + let(:result) { BaseResult.new } + + before { result.fail_with_error!(error) } + + context "when the service result is a NotFoundFailure" do + let(:error) { BaseService::NotFoundFailure.new(result, resource: :alert) } + + it "returns a not found error" do + expect(subject).to be_a(GraphQL::ExecutionError) + expect(subject.extensions).to include(status: 404, code: "not_found") + end + end + + context "when the service result is a MethodNotAllowedFailure" do + let(:error) { BaseService::MethodNotAllowedFailure.new(result, code: "method_not_allowed") } + + it "returns a not allowed error" do + expect(subject).to be_a(GraphQL::ExecutionError) + expect(subject.extensions).to include(status: 405, code: "method_not_allowed") + end + end + + context "when the service result is a ValidationFailure" do + let(:error) { BaseService::ValidationFailure.new(result, messages: {name: ["can't be blank"]}) } + + it "returns a validation error" do + expect(subject).to be_a(GraphQL::ExecutionError) + expect(subject.extensions).to include(status: 422, code: "unprocessable_entity") + end + end + + context "when the service result is a ForbiddenFailure" do + let(:error) { BaseService::ForbiddenFailure.new(result, code: "access_denied") } + + it "returns a forbidden error" do + expect(subject).to be_a(GraphQL::ExecutionError) + expect(subject.extensions).to include(status: 403, code: "access_denied") + end + end + + context "when the service result is a ThirdPartyFailure" do + let(:error) do + BaseService::ThirdPartyFailure.new( + result, + third_party: "3rd party service", + error_code: "external_error", + error_message: "External service failed" + ) + end + + it "returns a third party failure error" do + expect(subject).to be_a(GraphQL::ExecutionError) + expect(subject.extensions).to include(status: 422, code: "third_party_error") + end + end + + context "when the service result is an unknown failure" do + let(:error) { BaseService::UnknownTaxFailure.new(result, code: "unknown_tax", error_message: "error") } + + it "returns an execution error" do + expect(subject).to be_a(GraphQL::ExecutionError) + expect(subject.extensions).to include({status: 500, code: "unknown_tax"}) + end + end + end +end diff --git a/spec/graphql/concerns/required_organization_spec.rb b/spec/graphql/concerns/required_organization_spec.rb new file mode 100644 index 0000000..c8efe21 --- /dev/null +++ b/spec/graphql/concerns/required_organization_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "rails_helper" + +module RequiredOrganizationSpec + class ThingType < Types::BaseObject + field :name, String, null: false + field :count, Integer + end + + class RenameThingMutation < Mutations::BaseMutation + include RequiredOrganization + + graphql_name "RenameThing" + argument :new_name, String, required: true + type ThingType + + def resolve(**args) + {name: args[:new_name], count: 1} + end + end + + class ThingsMutationType < Types::BaseObject + field :renameThing, mutation: RenameThingMutation + end + + class TestApiSchema < LagoApiSchema + mutation(ThingsMutationType) + end +end + +RSpec.describe RequiredOrganization do + let(:mutation) do + <<-GQL + mutation($input: RenameThingInput!) { + renameThing(input: $input) { + name + } + } + GQL + end + + context "with a current organization and a member" do + it "renames the thing" do + membership = create(:membership) + + result = RequiredOrganizationSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {current_user: membership.user, current_organization: membership.organization, current_membership: membership} + ) + + expect(result["data"]["renameThing"]["name"]).to eq "new name" + end + end + + context "without a current organization" do + it "returns an error" do + result = RequiredOrganizationSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {current_user: create(:user), permissions: Permission.permissions_hash(:admin)} + ) + + partial_error = { + "message" => "Missing organization id", + "extensions" => {"status" => :forbidden, "code" => "forbidden"} + } + + expect(result["errors"]).to include hash_including(partial_error) + end + end + + context "without a current organization but the current is not a member" do + it "returns an error" do + membership = create(:membership) + + result = RequiredOrganizationSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {current_user: membership.user, current_membership: membership, current_organization: create(:organization)} + ) + + partial_error = { + "message" => "Not in organization", + "extensions" => {"status" => :forbidden, "code" => "forbidden"} + } + + expect(result["errors"]).to include hash_including(partial_error) + end + end + + context "when a current membership is not set in context" do + it "returns an error" do + result = RequiredOrganizationSpec::TestApiSchema.execute( + mutation, + variables: {input: {newName: "new name"}}, + context: {current_user: create(:user), current_organization: create(:organization)} + ) + + partial_error = { + "message" => "Missing membership", + "extensions" => {"status" => :forbidden, "code" => "forbidden"} + } + + expect(result["errors"]).to include hash_including(partial_error) + end + end +end diff --git a/spec/graphql/lago_api_schema_spec.rb b/spec/graphql/lago_api_schema_spec.rb new file mode 100644 index 0000000..73b66d7 --- /dev/null +++ b/spec/graphql/lago_api_schema_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoApiSchema do + it "matches the dumped graphql schema" do + expect(described_class.to_definition.rstrip).to eq(File.read(Rails.root.join("schema.graphql")).rstrip) + end + + it "matches the dumped JSON schema" do + actual_json_schema = JSON.parse(described_class.to_json) + expected_json_schema = JSON.parse(File.read(Rails.root.join("schema.json"))) + + expect(actual_json_schema).to eq(expected_json_schema) + end +end diff --git a/spec/graphql/mutations/add_ons/create_spec.rb b/spec/graphql/mutations/add_ons/create_spec.rb new file mode 100644 index 0000000..c641bf2 --- /dev/null +++ b/spec/graphql/mutations/add_ons/create_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AddOns::Create do + let(:required_permission) { "addons:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:) } + let(:mutation) do + <<-GQL + mutation($input: CreateAddOnInput!) { + createAddOn(input: $input) { + id, + name, + invoiceDisplayName, + code, + description, + amountCents, + amountCurrency, + taxes { id code rate } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "addons:create" + + it "creates an add-on" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + query: mutation, + permissions: required_permission, + variables: { + input: { + name: "Test Add-on", + invoiceDisplayName: "Test Add-on Invoice", + code: "free-beer-for-us", + description: "some text", + amountCents: 5000, + amountCurrency: "EUR", + taxCodes: [tax.code] + } + } + ) + + result_data = result["data"]["createAddOn"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Test Add-on") + expect(result_data["invoiceDisplayName"]).to eq("Test Add-on Invoice") + expect(result_data["code"]).to eq("free-beer-for-us") + expect(result_data["description"]).to eq("some text") + expect(result_data["amountCents"]).to eq("5000") + expect(result_data["amountCurrency"]).to eq("EUR") + expect(result_data["taxes"].map { |t| t["code"] }).to contain_exactly(tax.code) + end +end diff --git a/spec/graphql/mutations/add_ons/destroy_spec.rb b/spec/graphql/mutations/add_ons/destroy_spec.rb new file mode 100644 index 0000000..05b2409 --- /dev/null +++ b/spec/graphql/mutations/add_ons/destroy_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AddOns::Destroy do + let(:required_permission) { "addons:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:add_on) { create(:add_on, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyAddOnInput!) { + destroyAddOn(input: $input) { id } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "addons:delete" + + it "deletes an add-on" do + result = execute_query( + query: mutation, + input: {id: add_on.id} + ) + + data = result["data"]["destroyAddOn"] + expect(data["id"]).to eq(add_on.id) + end +end diff --git a/spec/graphql/mutations/add_ons/update_spec.rb b/spec/graphql/mutations/add_ons/update_spec.rb new file mode 100644 index 0000000..7964191 --- /dev/null +++ b/spec/graphql/mutations/add_ons/update_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AddOns::Update do + let(:required_permission) { "addons:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:) } + let(:tax2) { create(:tax, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:mutation) do + <<-GQL + mutation($input: UpdateAddOnInput!) { + updateAddOn(input: $input) { + id, + name, + invoiceDisplayName, + code, + description, + amountCents, + amountCurrency, + taxes { id code rate } + } + } + GQL + end + + before { create(:add_on_applied_tax, add_on:, tax:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "addons:update" + + it "updates an add-on" do + result = execute_query( + query: mutation, + input: { + id: add_on.id, + name: "New name", + invoiceDisplayName: "New invoice name", + code: "new_code", + description: "desc", + amountCents: 123, + amountCurrency: "USD", + taxCodes: [tax2.code] + } + ) + + result_data = result["data"]["updateAddOn"] + + expect(result_data["name"]).to eq("New name") + expect(result_data["invoiceDisplayName"]).to eq("New invoice name") + expect(result_data["code"]).to eq("new_code") + expect(result_data["description"]).to eq("desc") + expect(result_data["amountCents"]).to eq("123") + expect(result_data["amountCurrency"]).to eq("USD") + expect(result_data["taxes"].map { |t| t["code"] }).to contain_exactly(tax2.code) + end +end diff --git a/spec/graphql/mutations/adjusted_fees/create_spec.rb b/spec/graphql/mutations/adjusted_fees/create_spec.rb new file mode 100644 index 0000000..a62986e --- /dev/null +++ b/spec/graphql/mutations/adjusted_fees/create_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AdjustedFees::Create, :premium do + let(:required_permission) { "invoices:update" } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:invoice) { create(:invoice, invoice_type: :subscription, organization:, customer:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, started_at: Time.current - 1.year) } + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + timestamp: Time.current, + from_datetime: Time.current.beginning_of_month, + to_datetime: Time.current.end_of_month, + charges_from_datetime: Time.current.beginning_of_month - 1.month, + charges_to_datetime: (Time.current - 1.month).end_of_month + ) + end + + let(:fee) do + create( + :charge_fee, + subscription:, + invoice:, + charge:, + properties: { + from_datetime: invoice_subscription.from_datetime, + to_datetime: invoice_subscription.to_datetime, + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime, + timestamp: invoice_subscription.timestamp + } + ) + end + + let(:input) do + { + feeId: fee.id, + invoiceId: invoice.id, + units: 4, + unitPreciseAmount: "10.00001", + invoiceDisplayName: "Hello" + } + end + + let(:mutation) do + <<-GQL + mutation($input: CreateAdjustedFeeInput!) { + createAdjustedFee(input: $input) { + id, + units, + invoiceDisplayName + adjustedFee + } + } + GQL + end + + before { fee.invoice.draft! } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "creates an adjusted fee" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["createAdjustedFee"]["id"]).to be_present + expect(result["data"]["createAdjustedFee"]["adjustedFee"]).to be_truthy + expect(result["data"]["createAdjustedFee"]["units"]).to eq(4) + expect(result["data"]["createAdjustedFee"]["invoiceDisplayName"]).to eq("Hello") + end + + context "without an existing fee" do + let(:billable_metric2) { create(:billable_metric, organization:) } + let(:charge2) { create(:standard_charge, plan:, billable_metric: billable_metric2) } + + let(:input) do + { + invoiceId: invoice.id, + chargeId: charge2.id, + subscriptionId: subscription.id, + units: 4, + unitPreciseAmount: "10.00001", + invoiceDisplayName: "Hello" + } + end + + it "creates an adjusted fee" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["createAdjustedFee"]["id"]).to be_present + expect(result["data"]["createAdjustedFee"]["adjustedFee"]).to be_truthy + expect(result["data"]["createAdjustedFee"]["units"]).to eq(4) + expect(result["data"]["createAdjustedFee"]["invoiceDisplayName"]).to eq("Hello") + end + end + + context "with finalized invoice" do + before { fee.invoice.finalized! } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_forbidden_error(result) + end + end +end diff --git a/spec/graphql/mutations/adjusted_fees/destroy_spec.rb b/spec/graphql/mutations/adjusted_fees/destroy_spec.rb new file mode 100644 index 0000000..3e7dbaa --- /dev/null +++ b/spec/graphql/mutations/adjusted_fees/destroy_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AdjustedFees::Destroy do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice) { create(:invoice, status: :draft, organization:) } + let(:fee) { create(:charge_fee, invoice:) } + let(:adjusted_fee) { create(:adjusted_fee, invoice:, fee:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyAdjustedFeeInput!) { + destroyAdjustedFee(input: $input) { id } + } + GQL + end + + before { adjusted_fee } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "destroys an adjusted fee" do + expect do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: fee.id}} + ) + end.to change(AdjustedFee, :count).by(-1) + end +end diff --git a/spec/graphql/mutations/adjusted_fees/preview_spec.rb b/spec/graphql/mutations/adjusted_fees/preview_spec.rb new file mode 100644 index 0000000..3cc61d3 --- /dev/null +++ b/spec/graphql/mutations/adjusted_fees/preview_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AdjustedFees::Preview do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) do + create( + :invoice, + :voided, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + + let(:mutation) do + <<~GQL + mutation($input: PreviewAdjustedFeeInput!) { + previewAdjustedFee(input: $input) { + id + invoiceDisplayName + units + preciseUnitAmount + } + } + GQL + end + + let(:input) do + { + invoiceId: invoice.id, + feeId: fee_subscription.id, + units: 10, + unitPreciseAmount: "500", + invoiceDisplayName: "Previewed Fee" + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "previews an adjusted fee" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input: input} + ) + + data = result.dig("data", "previewAdjustedFee") + expect(data["id"]).to be_present + expect(data["invoiceDisplayName"]).to eq("Previewed Fee") + expect(data["units"]).to eq(10) + expect(data["preciseUnitAmount"]).to eq(500) + end + end +end diff --git a/spec/graphql/mutations/ai_conversations/create_spec.rb b/spec/graphql/mutations/ai_conversations/create_spec.rb new file mode 100644 index 0000000..0dc3756 --- /dev/null +++ b/spec/graphql/mutations/ai_conversations/create_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AiConversations::Create do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: {message: message}} + ) + end + + let(:query) do + <<-GQL + mutation($input: CreateAiConversationInput!) { + createAiConversation(input: $input) { id name } + } + GQL + end + + let(:required_permission) { "ai_conversations:create" } + let!(:membership) { create(:membership) } + let(:message) { Faker::Lorem.word } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "ai_conversations:create" + + context "without premium feature" do + it "returns an error" do + expect_graphql_error(result:, message: "unauthorized") + end + end + + context "without mistral configuration", :premium do + before do + ENV["MISTRAL_API_KEY"] = nil + ENV["MISTRAL_AGENT_ID"] = nil + end + + it "returns an error" do + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + context "with premium feature", :premium do + before do + ENV["MISTRAL_API_KEY"] = "test_api_key" + ENV["MISTRAL_AGENT_ID"] = "test_agent_id" + end + + it "creates a new AI conversation" do + expect { result }.to change(AiConversation, :count).by(1) + expect(result["data"]["createAiConversation"]["name"]).to eq(message) + end + + it "triggers streaming" do + expect { result }.to have_enqueued_job(AiConversations::StreamJob).with( + ai_conversation: kind_of(AiConversation), + message: + ).on_queue("default") + end + end +end diff --git a/spec/graphql/mutations/api_keys/create_spec.rb b/spec/graphql/mutations/api_keys/create_spec.rb new file mode 100644 index 0000000..b52dd44 --- /dev/null +++ b/spec/graphql/mutations/api_keys/create_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::ApiKeys::Create do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: {name:}} + ) + end + + let(:query) do + <<-GQL + mutation($input: CreateApiKeyInput!) { + createApiKey(input: $input) { id name value } + } + GQL + end + + let(:required_permission) { "developers:keys:manage" } + let!(:membership) { create(:membership) } + let(:name) { Faker::Lorem.word } + + include_context "with mocked security logger" + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:keys:manage" + + context "with premium organization", :premium do + it "creates a new API key" do + expect { result }.to change(ApiKey, :count).by(1) + end + + it "returns created API key" do + api_key_response = result["data"]["createApiKey"] + + expect(api_key_response["name"]).to eq(name) + end + + it_behaves_like "produces a security log", "api_key.created" do + before { result } + end + end + + context "with free organization" do + it "returns an error" do + expect_graphql_error(result:, message: "feature_unavailable") + end + end +end diff --git a/spec/graphql/mutations/api_keys/destroy_spec.rb b/spec/graphql/mutations/api_keys/destroy_spec.rb new file mode 100644 index 0000000..70ce055 --- /dev/null +++ b/spec/graphql/mutations/api_keys/destroy_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::ApiKeys::Destroy do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: {id: api_key.id}} + ) + end + + let(:query) do + <<-GQL + mutation($input: DestroyApiKeyInput!) { + destroyApiKey(input: $input) { id expiresAt } + } + GQL + end + + let(:required_permission) { "developers:keys:manage" } + let!(:membership) { create(:membership) } + + include_context "with mocked security logger" + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:keys:manage" + + context "when api key with such ID exists in the current organization" do + let(:api_key) { create(:api_key, organization: membership.organization) } + + it "expires the api key" do + expect { result }.to change { api_key.reload.expires_at }.from(nil).to(Time) + end + + it "returns expired api key" do + api_key_response = result["data"]["destroyApiKey"] + api_key.reload + + expect(api_key_response["id"]).to eq(api_key.id) + expect(api_key_response["expiresAt"]).to eq(api_key.expires_at.iso8601) + end + + it_behaves_like "produces a security log", "api_key.deleted" do + before { result } + end + end + + context "when api key with such ID does not exist in the current organization" do + let!(:api_key) { create(:api_key) } + + it "does not change the api key" do + expect { result }.not_to change { api_key.reload.expires_at } + end + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/mutations/api_keys/rotate_spec.rb b/spec/graphql/mutations/api_keys/rotate_spec.rb new file mode 100644 index 0000000..0e2f546 --- /dev/null +++ b/spec/graphql/mutations/api_keys/rotate_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::ApiKeys::Rotate do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: {id: api_key.id, expiresAt: expires_at, name:}} + ) + end + + let(:query) do + <<-GQL + mutation($input: RotateApiKeyInput!) { + rotateApiKey(input: $input) { id value name createdAt expiresAt } + } + GQL + end + + let(:required_permission) { "developers:keys:manage" } + let!(:membership) { create(:membership) } + let(:expires_at) { generate(:future_date).iso8601 } + let(:name) { Faker::Lorem.words.join(" ") } + + include_context "with mocked security logger" + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:keys:manage" + + context "when api key with such ID exists in the current organization", :premium do + let(:api_key) { membership.organization.api_keys.first } + + it "expires the api key" do + expect { result } + .to change { api_key.reload.expires_at&.iso8601 } + .to(expires_at) + end + + it "returns newly created api key" do + api_key_response = result["data"]["rotateApiKey"] + new_api_key = membership.organization.api_keys.order(:created_at).last + + expect(api_key_response["id"]).to eq(new_api_key.id) + expect(api_key_response["value"]).to eq(new_api_key.value) + expect(api_key_response["name"]).to eq(name) + expect(api_key_response["createdAt"]).to eq(new_api_key.created_at.iso8601) + expect(api_key_response["expiresAt"]).to be_nil + end + + it_behaves_like "produces a security log", "api_key.rotated" do + before { result } + end + end + + context "when api key with such ID does not exist in the current organization" do + let!(:api_key) { create(:api_key) } + + it "does not change the api key" do + expect { result }.not_to change { api_key.reload.expires_at } + end + + it "does not create an api key" do + expect { result }.not_to change(ApiKey, :count) + end + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/mutations/api_keys/update_spec.rb b/spec/graphql/mutations/api_keys/update_spec.rb new file mode 100644 index 0000000..729fab6 --- /dev/null +++ b/spec/graphql/mutations/api_keys/update_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::ApiKeys::Update, :premium do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: input_params} + ) + end + + let(:query) do + <<-GQL + mutation($input: UpdateApiKeyInput!) { + updateApiKey(input: $input) { id name permissions } + } + GQL + end + + let(:required_permission) { "developers:keys:manage" } + let!(:membership) { create(:membership) } + let(:input_params) { {id: api_key.id, permissions:, name:} } + let(:permissions) { api_key.permissions.merge("add_on" => ["read"]) } + let(:name) { Faker::Lorem.words.join(" ") } + + include_context "with mocked security logger" + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:keys:manage" + + context "when api key with such ID exists in the current organization" do + let(:api_key) { membership.organization.api_keys.first } + + before { membership.organization.update!(premium_integrations: ["api_permissions"]) } + + context "when permissions are present" do + it "returns updated api key" do + api_key_response = result["data"]["updateApiKey"] + + expect(api_key_response["id"]).to eq(api_key.id) + expect(api_key_response["name"]).to eq(name) + expect(api_key_response["permissions"]).to eq(permissions) + end + + it_behaves_like "produces a security log", "api_key.updated" do + before { result } + end + end + + context "when permissions are missing" do + let(:input_params) { {id: api_key.id, name:} } + + it "returns updated api key" do + api_key_response = result["data"]["updateApiKey"] + + expect(api_key_response["id"]).to eq(api_key.id) + expect(api_key_response["name"]).to eq(name) + expect(api_key_response["permissions"]).to eq(api_key.permissions) + end + end + end + + context "when api key with such ID does not exist in the current organization" do + let!(:api_key) { create(:api_key) } + + it "does not change the api key" do + expect { result }.not_to change { api_key.reload.name } + end + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/mutations/applied_coupons/create_spec.rb b/spec/graphql/mutations/applied_coupons/create_spec.rb new file mode 100644 index 0000000..86415ac --- /dev/null +++ b/spec/graphql/mutations/applied_coupons/create_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AppliedCoupons::Create do + let(:required_permission) { "coupons:attach" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:mutation) do + <<-GQL + mutation($input: CreateAppliedCouponInput!) { + createAppliedCoupon(input: $input) { + coupon { id } + id, + amountCents, + amountCurrency, + createdAt + } + } + GQL + end + + let(:coupon) { create(:coupon, organization:) } + let(:customer) { create(:customer, organization:) } + + before do + create(:subscription, customer:) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:attach" + + it "assigns a coupon to the customer" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + couponId: coupon.id, + customerId: customer.id, + frequency: "once", + amountCents: 123, + amountCurrency: "EUR" + } + } + ) + + result_data = result["data"]["createAppliedCoupon"] + + expect(result_data["id"]).to be_present + expect(result_data["coupon"]["id"]).to eq(coupon.id) + expect(result_data["amountCents"]).to eq("123") + expect(result_data["amountCurrency"]).to eq("EUR") + expect(result_data["createdAt"]).to be_present + end +end diff --git a/spec/graphql/mutations/applied_coupons/terminate_spec.rb b/spec/graphql/mutations/applied_coupons/terminate_spec.rb new file mode 100644 index 0000000..c832bdd --- /dev/null +++ b/spec/graphql/mutations/applied_coupons/terminate_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::AppliedCoupons::Terminate do + let(:required_permission) { "coupons:detach" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:coupon) { create(:coupon, organization:) } + let(:applied_coupon) { create(:applied_coupon, coupon:, customer:) } + + let(:mutation) do + <<-GQL + mutation($input: TerminateAppliedCouponInput!) { + terminateAppliedCoupon(input: $input) { + id terminatedAt + } + } + GQL + end + + before { applied_coupon } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:detach" + + it "terminates an applied coupon" do + result = execute_query( + query: mutation, + input: {id: applied_coupon.id} + ) + + data = result["data"]["terminateAppliedCoupon"] + + expect(data["id"]).to eq(applied_coupon.id) + expect(data["terminatedAt"]).to be_present + end +end diff --git a/spec/graphql/mutations/auth/google/accept_invite_spec.rb b/spec/graphql/mutations/auth/google/accept_invite_spec.rb new file mode 100644 index 0000000..5772c00 --- /dev/null +++ b/spec/graphql/mutations/auth/google/accept_invite_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Auth::Google::AcceptInvite do + let(:google_service) { instance_double(Auth::GoogleService) } + let(:user) { create(:user) } + let(:invite) { create(:invite) } + + let(:accept_invite_result) do + result = BaseService::Result.new + result.user = user + result.token = "token" + result + end + + let(:mutation) do + <<~GQL + mutation($input: GoogleAcceptInviteInput!) { + googleAcceptInvite(input: $input) { + token + user { + id + email + } + } + } + GQL + end + + before do + allow(Auth::GoogleService).to receive(:new).and_return(google_service) + allow(google_service).to receive(:accept_invite).and_return(accept_invite_result) + end + + it "returns token and user" do + result = execute_graphql( + query: mutation, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")), + variables: { + input: { + code: "code", + inviteToken: invite.token + } + } + ) + + response = result["data"]["googleAcceptInvite"] + + expect(response["token"]).to eq("token") + expect(response["user"]["id"]).to be_present + expect(response["user"]["email"]).to be_present + end + + context "when invite email and google email are different" do + let(:accept_invite_result) do + result = BaseService::Result.new + result.single_validation_failure!(error_code: "invite_email_mistmatch") + result + end + + it "returns an error" do + result = execute_graphql( + query: mutation, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")), + variables: { + input: { + code: "code", + inviteToken: invite.token + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(422) + expect(response["details"]["base"]).to include("invite_email_mistmatch") + end + end + + context "when invite does not exist" do + let(:accept_invite_result) do + result = BaseService::Result.new + result.not_found_failure!(resource: "invite") + result + end + + it "returns an error" do + result = execute_graphql( + query: mutation, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")), + variables: { + input: { + code: "code", + inviteToken: invite.token + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(404) + expect(response["details"]["invite"]).to include("not_found") + end + end +end diff --git a/spec/graphql/mutations/auth/google/login_user_spec.rb b/spec/graphql/mutations/auth/google/login_user_spec.rb new file mode 100644 index 0000000..484c720 --- /dev/null +++ b/spec/graphql/mutations/auth/google/login_user_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Auth::Google::LoginUser do + let(:membership) { create(:membership) } + let(:user) { membership.user } + let(:google_service) { instance_double(Auth::GoogleService) } + + let(:login_result) do + result = BaseService::Result.new + result.user = user + result.token = "token" + result + end + + let(:mutation) do + <<~GQL + mutation($input: GoogleLoginUserInput!) { + googleLoginUser(input: $input) { + token + user { + id + email + } + } + } + GQL + end + + before do + allow(Auth::GoogleService).to receive(:new).and_return(google_service) + allow(google_service).to receive(:login).and_return(login_result) + end + + it "returns token and user" do + result = execute_graphql( + query: mutation, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")), + variables: { + input: { + code: "code" + } + } + ) + + response = result["data"]["googleLoginUser"] + + expect(response["token"]).to eq("token") + expect(response["user"]["id"]).to eq(user.id) + expect(response["user"]["email"]).to eq(user.email) + end + + context "when user does not exist" do + let(:login_result) do + result = BaseService::Result.new + result.single_validation_failure!(error_code: "user_does_not_exist") + result + end + + it "returns an error" do + result = execute_graphql( + query: mutation, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")), + variables: { + input: { + code: "code" + } + } + ) + + response = result["errors"].first + + expect(response["extensions"]["status"]).to eq(422) + expect(response["message"]).to eq("Unprocessable Entity") + end + end +end diff --git a/spec/graphql/mutations/auth/google/register_user_spec.rb b/spec/graphql/mutations/auth/google/register_user_spec.rb new file mode 100644 index 0000000..7180f6e --- /dev/null +++ b/spec/graphql/mutations/auth/google/register_user_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Auth::Google::RegisterUser do + let(:google_service) { instance_double(Auth::GoogleService) } + let(:user) { create(:user) } + + let(:register_user_result) do + result = BaseService::Result.new + result.user = user + result.token = "token" + result + end + + let(:mutation) do + <<~GQL + mutation($input: GoogleRegisterUserInput!) { + googleRegisterUser(input: $input) { + token + user { + id + email + } + } + } + GQL + end + + before do + allow(Auth::GoogleService).to receive(:new).and_return(google_service) + allow(google_service).to receive(:register_user).and_return(register_user_result) + end + + it "returns token and user" do + result = execute_graphql( + query: mutation, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")), + variables: { + input: { + code: "code", + organizationName: "FooBar" + } + } + ) + + response = result["data"]["googleRegisterUser"] + + expect(response["token"]).to eq("token") + expect(response["user"]["id"]).to be_present + expect(response["user"]["email"]).to be_present + end + + context "when user already exists" do + let(:register_user_result) do + result = BaseService::Result.new + result.single_validation_failure!(error_code: "user_already_exists") + result + end + + before { user } + + it "returns an error" do + result = execute_graphql( + query: mutation, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")), + variables: { + input: { + code: "code", + organizationName: "FooBar" + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(422) + expect(response["details"]["base"]).to include("user_already_exists") + end + end +end diff --git a/spec/graphql/mutations/auth/okta/accept_invite_spec.rb b/spec/graphql/mutations/auth/okta/accept_invite_spec.rb new file mode 100644 index 0000000..dcb0f8b --- /dev/null +++ b/spec/graphql/mutations/auth/okta/accept_invite_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Auth::Okta::AcceptInvite, :premium, cache: :memory do + let(:organization) { create(:organization, premium_integrations: ["okta"]) } + let(:invite) { create(:invite, email: "foo@bar.com", organization:) } + let(:okta_integration) { create(:okta_integration, domain: "bar.com", organization_name: "foo", organization:) } + let(:lago_http_client) { instance_double(LagoHttpClient::Client) } + let(:okta_token_response) { OpenStruct.new(body: {access_token: "access_token"}) } + let(:okta_userinfo_response) { OpenStruct.new({email: "foo@bar.com"}) } + let(:state) { SecureRandom.uuid } + + let(:mutation) do + <<~GQL + mutation($input: OktaAcceptInviteInput!) { + oktaAcceptInvite(input: $input) { + user { + email + } + token + } + } + GQL + end + + before do + invite + okta_integration + + organization.enable_okta_authentication! + + Rails.cache.write(state, "foo@bar.com") + + allow(LagoHttpClient::Client).to receive(:new).and_return(lago_http_client) + allow(lago_http_client).to receive(:post_url_encoded).and_return(okta_token_response) + allow(lago_http_client).to receive(:get).and_return(okta_userinfo_response) + end + + it "returns logged user" do + result = execute_graphql( + query: mutation, + variables: { + input: { + state:, + code: "code", + inviteToken: invite.token + } + } + ) + + response = result["data"]["oktaAcceptInvite"] + + expect(response["user"]["email"]).to eq("foo@bar.com") + expect(response["token"]).to be_present + end + + context "when email domain is not configured with an integration" do + let(:okta_integration) { nil } + + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: { + state:, + code: "code", + inviteToken: invite.token + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(422) + expect(response["details"]["base"]).to include("domain_not_configured") + end + end +end diff --git a/spec/graphql/mutations/auth/okta/authorize_spec.rb b/spec/graphql/mutations/auth/okta/authorize_spec.rb new file mode 100644 index 0000000..03c2c2b --- /dev/null +++ b/spec/graphql/mutations/auth/okta/authorize_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Auth::Okta::Authorize do + let(:user) { create(:user) } + let(:okta_integration) { create(:okta_integration) } + + let(:mutation) do + <<~GQL + mutation($input: OktaAuthorizeInput!) { + oktaAuthorize(input: $input) { + url + } + } + GQL + end + + it "returns authorize url" do + result = execute_graphql( + query: mutation, + variables: { + input: { + email: "foo@#{okta_integration.domain}" + } + } + ) + + response = result["data"]["oktaAuthorize"] + + expect(response["url"]).to include(okta_integration.organization_name.downcase) + end + + context "when email domain is not configured with an integration" do + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: { + email: "foo@b.ar" + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(422) + expect(response["details"]["base"]).to include("domain_not_configured") + end + end + + context "when invite token is provided" do + let(:invite) { create(:invite, email: "foo@#{okta_integration.domain}") } + + it "returns authorize url" do + result = execute_graphql( + query: mutation, + variables: { + input: { + email: "foo@#{okta_integration.domain}", + inviteToken: invite.token + } + } + ) + + response = result["data"]["oktaAuthorize"] + + expect(response["url"]).to include(okta_integration.organization_name.downcase) + end + end +end diff --git a/spec/graphql/mutations/auth/okta/login_spec.rb b/spec/graphql/mutations/auth/okta/login_spec.rb new file mode 100644 index 0000000..61affa6 --- /dev/null +++ b/spec/graphql/mutations/auth/okta/login_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Auth::Okta::Login, :premium, cache: :memory do + let(:okta_integration) { create(:okta_integration, domain: "bar.com", organization_name: "foo") } + let(:lago_http_client) { instance_double(LagoHttpClient::Client) } + let(:okta_token_response) { OpenStruct.new(body: {access_token: "access_token"}) } + let(:okta_userinfo_response) { OpenStruct.new({email: "foo@bar.com"}) } + let(:state) { SecureRandom.uuid } + + let(:mutation) do + <<~GQL + mutation($input: OktaLoginInput!) { + oktaLogin(input: $input) { + user { + email + } + token + } + } + GQL + end + + before do + okta_integration + + if okta_integration + okta_integration.organization.premium_integrations << "okta" + okta_integration.organization.save! + okta_integration.organization.enable_okta_authentication! + end + + Rails.cache.write(state, "foo@bar.com") + + allow(LagoHttpClient::Client).to receive(:new).and_return(lago_http_client) + allow(lago_http_client).to receive(:post_url_encoded).and_return(okta_token_response) + allow(lago_http_client).to receive(:get).and_return(okta_userinfo_response) + end + + it "returns logged user" do + result = execute_graphql( + query: mutation, + variables: { + input: { + state:, + code: "code" + } + } + ) + + response = result["data"]["oktaLogin"] + + expect(response["user"]["email"]).to eq("foo@bar.com") + expect(response["token"]).to be_present + end + + context "when email domain is not configured with an integration" do + let(:okta_integration) { nil } + + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: { + state:, + code: "code" + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(422) + expect(response["details"]["base"]).to include("domain_not_configured") + end + end +end diff --git a/spec/graphql/mutations/billable_metrics/create_spec.rb b/spec/graphql/mutations/billable_metrics/create_spec.rb new file mode 100644 index 0000000..ab4835c --- /dev/null +++ b/spec/graphql/mutations/billable_metrics/create_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillableMetrics::Create do + let(:required_permission) { "billable_metrics:create" } + let(:membership) { create(:membership) } + let(:mutation) do + <<~GQL + mutation($input: CreateBillableMetricInput!) { + createBillableMetric(input: $input) { + id, + name, + code, + aggregationType, + expression, + recurring + organization { id }, + weightedInterval + filters { key values } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billable_metrics:create" + + it "creates a billable metric" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "New Metric", + code: "new_metric", + description: "New metric description", + aggregationType: "count_agg", + recurring: false, + filters: [ + { + key: "region", + values: %w[usa europe] + } + ] + } + } + ) + + result_data = result["data"]["createBillableMetric"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("New Metric") + expect(result_data["code"]).to eq("new_metric") + expect(result_data["organization"]["id"]).to eq(membership.organization_id) + expect(result_data["aggregationType"]).to eq("count_agg") + expect(result_data["recurring"]).to eq(false) + expect(result_data["weightedInterval"]).to be_nil + expect(result_data["filters"].count).to eq(1) + end +end diff --git a/spec/graphql/mutations/billable_metrics/destroy_spec.rb b/spec/graphql/mutations/billable_metrics/destroy_spec.rb new file mode 100644 index 0000000..80fe215 --- /dev/null +++ b/spec/graphql/mutations/billable_metrics/destroy_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillableMetrics::Destroy do + let(:required_permission) { "billable_metrics:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billable_metric) { create(:billable_metric, organization: membership.organization) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyBillableMetricInput!) { + destroyBillableMetric(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billable_metrics:delete" + + it "deletes a billable metric" do + result = execute_query( + query: mutation, + input: {id: billable_metric.id} + ) + + data = result["data"]["destroyBillableMetric"] + expect(data["id"]).to eq(billable_metric.id) + end +end diff --git a/spec/graphql/mutations/billable_metrics/update_spec.rb b/spec/graphql/mutations/billable_metrics/update_spec.rb new file mode 100644 index 0000000..8a990cb --- /dev/null +++ b/spec/graphql/mutations/billable_metrics/update_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillableMetrics::Update do + let(:required_permission) { "billable_metrics:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billable_metric) { create(:weighted_sum_billable_metric, organization:) } + let(:mutation) do + <<-GQL + mutation($input: UpdateBillableMetricInput!) { + updateBillableMetric(input: $input) { + id, + name, + code, + aggregationType, + weightedInterval + recurring + organization { id }, + filters { key values } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billable_metrics:update" + + it "updates a billable metric" do + result = execute_query( + query: mutation, + input: { + id: billable_metric.id, + name: "New Metric", + code: "new_metric", + description: "New metric description", + aggregationType: "count_agg", + recurring: false, + weightedInterval: "seconds", + filters: [ + { + key: "region", + values: %w[usa europe] + } + ] + } + ) + + result_data = result["data"]["updateBillableMetric"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("New Metric") + expect(result_data["code"]).to eq("new_metric") + expect(result_data["organization"]["id"]).to eq(membership.organization_id) + expect(result_data["aggregationType"]).to eq("count_agg") + expect(result_data["weightedInterval"]).to eq("seconds") + expect(result_data["recurring"]).to eq(false) + expect(result_data["filters"].count).to eq(1) + end +end diff --git a/spec/graphql/mutations/billing_entities/apply_taxes_spec.rb b/spec/graphql/mutations/billing_entities/apply_taxes_spec.rb new file mode 100644 index 0000000..5b35e92 --- /dev/null +++ b/spec/graphql/mutations/billing_entities/apply_taxes_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillingEntities::ApplyTaxes do + let(:required_permission) { "billing_entities:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { organization.default_billing_entity } + let(:tax_codes) { ["TAX_CODE_1", "TAX_CODE_2"] } + + let(:mutation) do + <<~GQL + mutation($input: ApplyTaxesInput!) { + billingEntityApplyTaxes(input: $input) { + appliedTaxes { + id + code + } + } + } + GQL + end + + before do + allow(::BillingEntities::Taxes::ApplyTaxesService).to receive(:call).and_call_original + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:update" + + context "when tax codes exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + let(:tax2) { create(:tax, organization:, code: "TAX_CODE_2") } + + before do + tax1 + tax2 + end + + it "applies the specified taxes to the billing entity" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: [required_permission], + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + taxCodes: tax_codes + } + } + ) + + result_data = result["data"]["billingEntityApplyTaxes"] + expect(result_data["appliedTaxes"].length).to eq(2) + expect(result_data["appliedTaxes"].map { |at| at["code"] }).to match_array(tax_codes) + + expect(::BillingEntities::Taxes::ApplyTaxesService).to have_received(:call).with( + billing_entity: billing_entity, + tax_codes: tax_codes + ) + end + end + + context "when some tax codes do not exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + + before { tax1 } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: [required_permission], + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + taxCodes: tax_codes + } + } + ) + + expect(result["errors"].first["message"]).to include("Resource not found") + end + end + + context "when tax_codes is empty" do + let(:tax_codes) { [] } + + it "returns the billing entity with no applied taxes" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: [required_permission], + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + taxCodes: tax_codes + } + } + ) + + result_data = result["data"]["billingEntityApplyTaxes"] + expect(result_data["appliedTaxes"]).to be_empty + end + end +end diff --git a/spec/graphql/mutations/billing_entities/create_spec.rb b/spec/graphql/mutations/billing_entities/create_spec.rb new file mode 100644 index 0000000..8da7d0b --- /dev/null +++ b/spec/graphql/mutations/billing_entities/create_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillingEntities::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "billing_entities:create" } + let(:membership) { create(:membership, organization:) } + let(:organization) { create(:organization) } + let(:mutation) do + <<~GQL + mutation($input: CreateBillingEntityInput!) { + createBillingEntity(input: $input) { + id + name, + code, + defaultCurrency, + email, + legalName, + legalNumber, + logoUrl, + taxIdentificationNumber, + addressLine1, + addressLine2, + city, + country, + netPaymentTerm, + state, + zipcode, + timezone, + euTaxManagement, + documentNumberPrefix, + documentNumbering, + emailSettings, + finalizeZeroAmountInvoice, + billingConfiguration { + invoiceFooter, + invoiceGracePeriod, + documentLocale, + subscriptionInvoiceIssuingDateAnchor, + subscriptionInvoiceIssuingDateAdjustment + } + } + } + GQL + end + + let(:input) do + { + code: "NEW-0001", + name: "New entity", + email: "new@email.com", + legalName: "New legal name", + legalNumber: "1234567890", + taxIdentificationNumber: "Tax-1234", + addressLine1: "Calle de la Princesa 1", + addressLine2: "Apt 1", + city: "Barcelona", + state: "Barcelona", + zipcode: "08001", + country: "ES", + defaultCurrency: "EUR", + timezone: "TZ_EUROPE_MADRID", + documentNumbering: "per_billing_entity", + documentNumberPrefix: "NEW-0001", + euTaxManagement: true, + finalizeZeroAmountInvoice: true, + netPaymentTerm: 15, + logo: logo, + emailSettings: ["invoice_finalized", "credit_note_created"], + billingConfiguration: { + invoiceFooter: "invoice footer", + documentLocale: "es", + invoiceGracePeriod: 10, + subscriptionInvoiceIssuingDateAnchor: "current_period_end", + subscriptionInvoiceIssuingDateAdjustment: "keep_anchor" + } + } + end + + let(:logo) do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + base64_logo = Base64.encode64(logo_file) + + "data:image/png;base64,#{base64_logo}" + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:create" + + it "returns a feaature not available error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_graphql_error( + result:, + message: "forbidden" + ) + end + + context "when the organization can create billing entities" do + let(:organization) { create(:organization, premium_integrations: %w[multi_entities_enterprise]) } + + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + it "creates a billing entity for the current organization" do + result_data = result["data"]["createBillingEntity"] + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq("NEW-0001") + expect(result_data["name"]).to eq("New entity") + expect(result_data["email"]).to eq("new@email.com") + expect(result_data["legalName"]).to eq("New legal name") + expect(result_data["legalNumber"]).to eq("1234567890") + expect(result_data["taxIdentificationNumber"]).to eq("Tax-1234") + expect(result_data["addressLine1"]).to eq("Calle de la Princesa 1") + expect(result_data["addressLine2"]).to eq("Apt 1") + expect(result_data["state"]).to eq("Barcelona") + expect(result_data["city"]).to eq("Barcelona") + expect(result_data["zipcode"]).to eq("08001") + expect(result_data["country"]).to eq("ES") + expect(result_data["defaultCurrency"]).to eq("EUR") + expect(result_data["timezone"]).to eq("TZ_EUROPE_MADRID") + expect(result_data["documentNumbering"]).to eq("per_billing_entity") + expect(result_data["documentNumberPrefix"]).to eq("NEW-0001") + expect(result_data["euTaxManagement"]).to eq true + expect(result_data["finalizeZeroAmountInvoice"]).to eq true + expect(result_data["netPaymentTerm"]).to eq(15) + expect(result_data["logoUrl"]).to match(%r{.*/rails/active_storage/blobs/redirect/.*/logo}) + expect(result_data["emailSettings"]).to be_nil + expect(result_data["billingConfiguration"]).to be_nil + end + + it_behaves_like "produces a security log", "billing_entity.created" + end + + context "when the organization can create billing entities with extra view permissions" do + let(:organization) { create(:organization, premium_integrations: %w[multi_entities_enterprise]) } + let(:permissions) { %w[billing_entities:create billing_entities:view] } + + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions:, + query: mutation, + variables: {input:} + ) + end + + it "includes the email settings and billing configuration in the response" do + result_data = result["data"]["createBillingEntity"] + expect(result_data["emailSettings"]).to eq(["invoice_finalized", "credit_note_created"]) + expect(result_data["billingConfiguration"]["invoiceFooter"]).to eq("invoice footer") + expect(result_data["billingConfiguration"]["documentLocale"]).to eq("es") + expect(result_data["billingConfiguration"]["invoiceGracePeriod"]).to eq(10) + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAnchor"]).to eq("current_period_end") + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAdjustment"]).to eq("keep_anchor") + end + end +end diff --git a/spec/graphql/mutations/billing_entities/destroy_spec.rb b/spec/graphql/mutations/billing_entities/destroy_spec.rb new file mode 100644 index 0000000..65e2358 --- /dev/null +++ b/spec/graphql/mutations/billing_entities/destroy_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillingEntities::Destroy do + let(:required_permission) { "billing_entities:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:mutation) do + <<~GQL + mutation($input: DestroyBillingEntityInput!) { + destroyBillingEntity(input: $input) { + code + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:delete" + + # We're not allowing now to destroy a billing entity, but this endpoint is needed for FE + it "returns default billing entity for the current organization" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code: organization.default_billing_entity.code + } + } + ) + + result_data = result["data"]["destroyBillingEntity"] + expect(result_data["code"]).to eq(organization.default_billing_entity.code) + expect(organization.default_billing_entity.deleted_at).to be_nil + end +end diff --git a/spec/graphql/mutations/billing_entities/remove_taxes_spec.rb b/spec/graphql/mutations/billing_entities/remove_taxes_spec.rb new file mode 100644 index 0000000..d5bb155 --- /dev/null +++ b/spec/graphql/mutations/billing_entities/remove_taxes_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillingEntities::RemoveTaxes do + let(:required_permission) { "billing_entities:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { organization.default_billing_entity } + let(:tax_codes) { ["TAX_CODE_1", "TAX_CODE_2"] } + + let(:mutation) do + <<~GQL + mutation($input: RemoveTaxesInput!) { + billingEntityRemoveTaxes(input: $input) { + removedTaxes { + id + code + } + } + } + GQL + end + + before do + allow(BillingEntities::Taxes::RemoveTaxesService).to receive(:call).and_call_original + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:update" + + context "when tax codes exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + let(:tax2) { create(:tax, organization:, code: "TAX_CODE_2") } + + before do + tax1 + tax2 + end + + it "removes the specified taxes from the billing entity" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: [required_permission], + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + taxCodes: tax_codes + } + } + ) + + result_data = result["data"]["billingEntityRemoveTaxes"] + expect(result_data["removedTaxes"].length).to eq(2) + expect(result_data["removedTaxes"].map { |at| at["code"] }).to match_array(tax_codes) + + expect(BillingEntities::Taxes::RemoveTaxesService).to have_received(:call).with( + billing_entity: billing_entity, + tax_codes: tax_codes + ) + end + end + + context "when some tax codes do not exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + + before { tax1 } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: [required_permission], + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + taxCodes: tax_codes + } + } + ) + + expect(result["errors"].first["message"]).to include("Resource not found") + end + end + + context "when tax_codes is empty" do + let(:tax_codes) { [] } + + it "returns the billing entity with no applied taxes" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: [required_permission], + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + taxCodes: tax_codes + } + } + ) + + result_data = result["data"]["billingEntityRemoveTaxes"] + expect(result_data["removedTaxes"]).to be_empty + end + end +end diff --git a/spec/graphql/mutations/billing_entities/update_applied_dunning_campaign_spec.rb b/spec/graphql/mutations/billing_entities/update_applied_dunning_campaign_spec.rb new file mode 100644 index 0000000..e281d6a --- /dev/null +++ b/spec/graphql/mutations/billing_entities/update_applied_dunning_campaign_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillingEntities::UpdateAppliedDunningCampaign do + let(:required_permission) { "billing_entities:update" } + let(:membership) { create(:membership, organization:) } + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:, applied_dunning_campaign:) } + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + let(:applied_dunning_campaign) { create(:dunning_campaign, organization:) } + let(:mutation) do + <<~GQL + mutation($input: BillingEntityUpdateAppliedDunningCampaignInput!) { + billingEntityUpdateAppliedDunningCampaign(input: $input) { + id + appliedDunningCampaign { + id + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:update" + + context "when the user has the required permission" do + it "changes the applied dunning campaign successfully" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + appliedDunningCampaignId: dunning_campaign.id + } + } + ) + + data = result["data"]["billingEntityUpdateAppliedDunningCampaign"] + expect(data["id"]).to eq(billing_entity.id.to_s) + expect(data["appliedDunningCampaign"]["id"]).to eq(dunning_campaign.id.to_s) + end + + it "removes the applied dunning campaign when ID is nil" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + appliedDunningCampaignId: nil + } + } + ) + + data = result["data"]["billingEntityUpdateAppliedDunningCampaign"] + + expect(data["id"]).to eq(billing_entity.id.to_s) + expect(data["appliedDunningCampaign"]).to be_nil + end + end + + context "when the user does not have the required permission" do + it "returns an authorization error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: [], + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + appliedDunningCampaignId: dunning_campaign.id + } + } + ) + + errors = result["errors"] + + expect(errors).not_to be_empty + expect(errors.first["message"]).to eq("Missing permissions") + end + end + + context "when the billing entity does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + billingEntityId: "nonexistent-id", + appliedDunningCampaignId: dunning_campaign.id + } + } + ) + + errors = result["errors"] + + expect(errors).not_to be_empty + expect(errors.first["message"]).to eq("Resource not found") + end + end + + context "when the dunning campaign does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + billingEntityId: billing_entity.id, + appliedDunningCampaignId: "nonexistent-id" + } + } + ) + + errors = result["errors"] + + expect(errors).not_to be_empty + expect(errors.first["message"]).to eq("Resource not found") + end + end +end diff --git a/spec/graphql/mutations/billing_entities/update_spec.rb b/spec/graphql/mutations/billing_entities/update_spec.rb new file mode 100644 index 0000000..094509c --- /dev/null +++ b/spec/graphql/mutations/billing_entities/update_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::BillingEntities::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "billing_entities:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 2, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: UpdateBillingEntityInput!) { + updateBillingEntity(input: $input) { + id + name + code + defaultCurrency + email + legalName + legalNumber + taxIdentificationNumber + addressLine1 + addressLine2 + city + country + netPaymentTerm + state + zipcode + timezone + logoUrl + euTaxManagement + documentNumberPrefix + documentNumbering + emailSettings + finalizeZeroAmountInvoice + billingConfiguration { + invoiceFooter, + invoiceGracePeriod, + documentLocale, + subscriptionInvoiceIssuingDateAnchor, + subscriptionInvoiceIssuingDateAdjustment, + } + selectedInvoiceCustomSections { id } + } + } + GQL + end + + let(:logo) do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + base64_logo = Base64.encode64(logo_file) + + "data:image/png;base64,#{base64_logo}" + end + + let(:input) do + { + id: billing_entity.id, + code: billing_entity.code, + name: "Updated entity", + email: "updated@email.com", + legalName: "Updated legal name", + legalNumber: "1234567890", + taxIdentificationNumber: "Tax-1234", + addressLine1: "Calle de la Princesa 1", + addressLine2: "Apt 1", + city: "Barcelona", + state: "Barcelona", + zipcode: "08001", + country: "ES", + defaultCurrency: "EUR", + timezone: "TZ_EUROPE_MADRID", + documentNumbering: "per_billing_entity", + documentNumberPrefix: "NEW-0001", + euTaxManagement: true, + finalizeZeroAmountInvoice: true, + netPaymentTerm: 15, + logo: logo, + emailSettings: ["invoice_finalized", "credit_note_created"], + billingConfiguration: { + invoiceFooter: "invoice footer", + documentLocale: "es", + invoiceGracePeriod: 10, + subscriptionInvoiceIssuingDateAnchor: "current_period_end", + subscriptionInvoiceIssuingDateAdjustment: "keep_anchor" + }, + invoiceCustomSectionIds: invoice_custom_sections.map(&:id) + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + it "updates the billing entity" do + result_data = result["data"]["updateBillingEntity"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(billing_entity.code) + expect(result_data["name"]).to eq("Updated entity") + expect(result_data["email"]).to eq("updated@email.com") + expect(result_data["legalName"]).to eq("Updated legal name") + expect(result_data["legalNumber"]).to eq("1234567890") + expect(result_data["taxIdentificationNumber"]).to eq("Tax-1234") + expect(result_data["addressLine1"]).to eq("Calle de la Princesa 1") + expect(result_data["addressLine2"]).to eq("Apt 1") + expect(result_data["state"]).to eq("Barcelona") + expect(result_data["city"]).to eq("Barcelona") + expect(result_data["zipcode"]).to eq("08001") + expect(result_data["country"]).to eq("ES") + expect(result_data["defaultCurrency"]).to eq("EUR") + expect(result_data["timezone"]).to eq("TZ_EUROPE_MADRID") + expect(result_data["documentNumbering"]).to eq("per_billing_entity") + expect(result_data["documentNumberPrefix"]).to eq("NEW-0001") + expect(result_data["euTaxManagement"]).to eq true + expect(result_data["finalizeZeroAmountInvoice"]).to eq true + expect(result_data["netPaymentTerm"]).to eq(15) + expect(result_data["logoUrl"]).to match(%r{.*/rails/active_storage/blobs/redirect/.*/logo}) + expect(result_data["emailSettings"]).to be_nil + expect(result_data["billingConfiguration"]).to be_nil + expect(result_data["selectedInvoiceCustomSections"]).to match_array(invoice_custom_sections.map { |section| {"id" => section.id} }) + end + + it_behaves_like "produces a security log", "billing_entity.updated" + end + + context "with valid input and extra view permissions" do + let(:permissions) do + [required_permission].concat(%w[billing_entities:view]) + end + + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions:, + query: mutation, + variables: {input:} + ) + end + + it "includes the email settings and billing configuration in the response" do + result_data = result["data"]["updateBillingEntity"] + + expect(result_data["emailSettings"]).to eq(["invoice_finalized", "credit_note_created"]) + expect(result_data["billingConfiguration"]["invoiceFooter"]).to eq("invoice footer") + expect(result_data["billingConfiguration"]["documentLocale"]).to eq("es") + expect(result_data["billingConfiguration"]["invoiceGracePeriod"]).to eq(10) + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAnchor"]).to eq("current_period_end") + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAdjustment"]).to eq("keep_anchor") + end + end + + context "when the billing entity is not found" do + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "non_existent_id", + name: "Updated entity" + } + } + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/mutations/charge_filters/create_spec.rb b/spec/graphql/mutations/charge_filters/create_spec.rb new file mode 100644 index 0000000..f353368 --- /dev/null +++ b/spec/graphql/mutations/charge_filters/create_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::ChargeFilters::Create, type: :graphql do + let(:required_permission) { "charges:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, values: %w[value1 value2 value3]) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + + let(:mutation) do + <<~GQL + mutation($input: ChargeFilterCreateInput!) { + createChargeFilter(input: $input) { + id + chargeCode + invoiceDisplayName + properties { + amount + } + values + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:create" + + it "creates a charge filter" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + chargeId: charge.id, + invoiceDisplayName: "My Filter", + properties: { + amount: "50" + }, + values: { + billable_metric_filter.key.to_s => ["value1", "value2"] + } + } + } + ) + + result_data = result["data"]["createChargeFilter"] + + expect(result_data["id"]).to be_present + expect(result_data["chargeCode"]).to eq(charge.code) + expect(result_data["invoiceDisplayName"]).to eq("My Filter") + expect(result_data["properties"]["amount"]).to eq("50") + expect(result_data["values"][billable_metric_filter.key]).to eq(["value1", "value2"]) + end + + context "when charge does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + chargeId: "unknown", + properties: {amount: "50"}, + values: {"key" => ["value"]} + } + } + ) + + expect_not_found(result) + end + end + + context "when values are not provided" do + it "returns a validation error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + chargeId: charge.id, + properties: {amount: "50"}, + values: {} + } + } + ) + + expect_unprocessable_entity(result) + end + end +end diff --git a/spec/graphql/mutations/charge_filters/destroy_spec.rb b/spec/graphql/mutations/charge_filters/destroy_spec.rb new file mode 100644 index 0000000..6804ed9 --- /dev/null +++ b/spec/graphql/mutations/charge_filters/destroy_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::ChargeFilters::Destroy, type: :graphql do + let(:required_permission) { "charges:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + + let(:mutation) do + <<~GQL + mutation($input: DestroyChargeFilterInput!) { + destroyChargeFilter(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:delete" + + it "destroys a charge filter" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge_filter.id + } + } + ) + + result_data = result["data"]["destroyChargeFilter"] + + expect(result_data["id"]).to eq(charge_filter.id) + expect(charge_filter.reload.deleted_at).to be_present + end + + context "with cascade_updates" do + it "destroys a charge filter with cascade" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge_filter.id, + cascadeUpdates: true + } + } + ) + + result_data = result["data"]["destroyChargeFilter"] + + expect(result_data["id"]).to eq(charge_filter.id) + expect(charge_filter.reload.deleted_at).to be_present + end + end + + context "when charge filter does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "unknown" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/charge_filters/update_spec.rb b/spec/graphql/mutations/charge_filters/update_spec.rb new file mode 100644 index 0000000..8d45288 --- /dev/null +++ b/spec/graphql/mutations/charge_filters/update_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::ChargeFilters::Update, type: :graphql do + let(:required_permission) { "charges:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + + let(:mutation) do + <<~GQL + mutation($input: ChargeFilterUpdateInput!) { + updateChargeFilter(input: $input) { + id + invoiceDisplayName + properties { + amount + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:update" + + it "updates a charge filter" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge_filter.id, + invoiceDisplayName: "Updated Filter", + properties: { + amount: "75" + } + } + } + ) + + result_data = result["data"]["updateChargeFilter"] + + expect(result_data["id"]).to eq(charge_filter.id) + expect(result_data["invoiceDisplayName"]).to eq("Updated Filter") + expect(result_data["properties"]["amount"]).to eq("75") + end + + context "with cascade_updates" do + it "updates a charge filter with cascade" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge_filter.id, + invoiceDisplayName: "Updated Filter with Cascade", + cascadeUpdates: true + } + } + ) + + result_data = result["data"]["updateChargeFilter"] + + expect(result_data["id"]).to eq(charge_filter.id) + expect(result_data["invoiceDisplayName"]).to eq("Updated Filter with Cascade") + end + end + + context "when charge filter does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "unknown", + invoiceDisplayName: "Updated" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/charges/create_spec.rb b/spec/graphql/mutations/charges/create_spec.rb new file mode 100644 index 0000000..fbf766c --- /dev/null +++ b/spec/graphql/mutations/charges/create_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Charges::Create, type: :graphql do + let(:required_permission) { "charges:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: ChargeCreateInput!) { + createCharge(input: $input) { + id + code + invoiceDisplayName + chargeModel + payInAdvance + prorated + invoiceable + minAmountCents + properties { + amount + } + billableMetric { + id + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:create" + + it "creates a charge" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + planId: plan.id, + billableMetricId: billable_metric.id, + chargeModel: "standard", + code: "my_charge", + invoiceDisplayName: "My Charge", + payInAdvance: false, + prorated: false, + properties: { + amount: "10" + } + } + } + ) + + result_data = result["data"]["createCharge"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq("my_charge") + expect(result_data["invoiceDisplayName"]).to eq("My Charge") + expect(result_data["chargeModel"]).to eq("standard") + expect(result_data["payInAdvance"]).to eq(false) + expect(result_data["prorated"]).to eq(false) + expect(result_data["properties"]["amount"]).to eq("10") + expect(result_data["billableMetric"]["id"]).to eq(billable_metric.id) + end + + context "with filters" do + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, values: %w[value1 value2]) } + + it "creates a charge with filters" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + planId: plan.id, + billableMetricId: billable_metric.id, + chargeModel: "standard", + code: "charge_with_filters", + properties: { + amount: "10" + }, + filters: [ + { + invoiceDisplayName: "Filter 1", + properties: {amount: "20"}, + values: {billable_metric_filter.key.to_s => ["value1"]} + } + ] + } + } + ) + + result_data = result["data"]["createCharge"] + expect(result_data["id"]).to be_present + end + end + + context "when plan does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + planId: "unknown", + billableMetricId: billable_metric.id, + chargeModel: "standard", + properties: {amount: "10"} + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/charges/destroy_spec.rb b/spec/graphql/mutations/charges/destroy_spec.rb new file mode 100644 index 0000000..5f0fb46 --- /dev/null +++ b/spec/graphql/mutations/charges/destroy_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Charges::Destroy, type: :graphql do + let(:required_permission) { "charges:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + + let(:mutation) do + <<~GQL + mutation($input: DestroyChargeInput!) { + destroyCharge(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:delete" + + it "destroys a charge" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge.id + } + } + ) + + result_data = result["data"]["destroyCharge"] + + expect(result_data["id"]).to eq(charge.id) + expect(charge.reload.deleted_at).to be_present + end + + context "with cascade_updates" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric:, parent: charge) } + + before do + child_charge + allow(Charges::DestroyChildrenJob).to receive(:perform_later) + end + + it "cascades the deletion to children" do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge.id, + cascadeUpdates: true + } + } + ) + + expect(Charges::DestroyChildrenJob).to have_received(:perform_later).with(charge.id) + end + end + + context "when charge does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "unknown" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/charges/update_spec.rb b/spec/graphql/mutations/charges/update_spec.rb new file mode 100644 index 0000000..a870aad --- /dev/null +++ b/spec/graphql/mutations/charges/update_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Charges::Update, type: :graphql do + let(:required_permission) { "charges:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + + let(:mutation) do + <<~GQL + mutation($input: ChargeUpdateInput!) { + updateCharge(input: $input) { + id + code + invoiceDisplayName + chargeModel + payInAdvance + prorated + properties { + amount + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:update" + + it "updates a charge" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge.id, + chargeModel: "standard", + invoiceDisplayName: "Updated Charge", + properties: { + amount: "25" + } + } + } + ) + + result_data = result["data"]["updateCharge"] + + expect(result_data["id"]).to eq(charge.id) + expect(result_data["invoiceDisplayName"]).to eq("Updated Charge") + expect(result_data["properties"]["amount"]).to eq("25") + end + + context "with cascade_updates" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric:, parent: charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(Charges::UpdateChildrenJob).to receive(:perform_later) + end + + it "passes cascade_updates to the service" do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: charge.id, + chargeModel: "standard", + cascadeUpdates: true, + properties: { + amount: "25" + } + } + } + ) + + expect(Charges::UpdateChildrenJob).to have_received(:perform_later) + end + end + + context "when charge does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "unknown", + invoiceDisplayName: "Updated" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/coupons/create_spec.rb b/spec/graphql/mutations/coupons/create_spec.rb new file mode 100644 index 0000000..09127b1 --- /dev/null +++ b/spec/graphql/mutations/coupons/create_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Coupons::Create do + let(:required_permission) { "coupons:create" } + let(:membership) { create(:membership) } + let(:expiration_at) { Time.current + 3.days } + let(:plan) { create(:plan, organization: membership.organization) } + let(:billable_metric) { create(:billable_metric, organization: membership.organization) } + let(:mutation) do + <<-GQL + mutation($input: CreateCouponInput!) { + createCoupon(input: $input) { + id, + name, + code, + description, + amountCents, + amountCurrency, + expiration, + expirationAt, + status, + limitedPlans, + plans { + id + }, + reusable + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:create" + + it "creates a coupon" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "Super Coupon", + code: "free-beer", + description: "This is a description", + couponType: "fixed_amount", + frequency: "once", + amountCents: 5000, + amountCurrency: "EUR", + expiration: "time_limit", + expirationAt: expiration_at.iso8601, + reusable: false, + appliesTo: { + planIds: [plan.id] + } + } + } + ) + + result_data = result["data"]["createCoupon"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Super Coupon") + expect(result_data["code"]).to eq("free-beer") + expect(result_data["description"]).to eq("This is a description") + expect(result_data["amountCents"]).to eq("5000") + expect(result_data["amountCurrency"]).to eq("EUR") + expect(result_data["expiration"]).to eq("time_limit") + expect(result_data["expirationAt"]).to eq expiration_at.iso8601 + expect(result_data["status"]).to eq("active") + expect(result_data["reusable"]).to eq(false) + expect(result_data["limitedPlans"]).to eq(true) + expect(result_data["plans"].first["id"]).to eq(plan.id) + end + + context "with billable metric limitations" do + let(:mutation) do + <<-GQL + mutation($input: CreateCouponInput!) { + createCoupon(input: $input) { + id, + name, + code, + amountCents, + amountCurrency, + expiration, + expirationAt, + status, + limitedBillableMetrics, + billableMetrics { + id + }, + reusable + } + } + GQL + end + + it "creates a coupon" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "Super Coupon", + code: "free-beer", + couponType: "fixed_amount", + frequency: "once", + amountCents: 5000, + amountCurrency: "EUR", + expiration: "time_limit", + expirationAt: expiration_at.iso8601, + reusable: false, + appliesTo: { + billableMetricIds: [billable_metric.id] + } + } + } + ) + + result_data = result["data"]["createCoupon"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Super Coupon") + expect(result_data["code"]).to eq("free-beer") + expect(result_data["amountCents"]).to eq("5000") + expect(result_data["amountCurrency"]).to eq("EUR") + expect(result_data["expiration"]).to eq("time_limit") + expect(result_data["expirationAt"]).to eq expiration_at.iso8601 + expect(result_data["status"]).to eq("active") + expect(result_data["reusable"]).to eq(false) + expect(result_data["limitedBillableMetrics"]).to eq(true) + expect(result_data["billableMetrics"].first["id"]).to eq(billable_metric.id) + end + end + + context "with an expiration date" do + it "creates a coupon" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "Super Coupon", + code: "free-beer", + couponType: "fixed_amount", + frequency: "once", + amountCents: 5000, + amountCurrency: "EUR", + expiration: "time_limit", + expirationAt: expiration_at.iso8601 + } + } + ) + + result_data = result["data"]["createCoupon"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Super Coupon") + expect(result_data["code"]).to eq("free-beer") + expect(result_data["amountCents"]).to eq("5000") + expect(result_data["amountCurrency"]).to eq("EUR") + expect(result_data["expiration"]).to eq("time_limit") + expect(result_data["expirationAt"]).to eq(expiration_at.iso8601) + expect(result_data["status"]).to eq("active") + end + end +end diff --git a/spec/graphql/mutations/coupons/destroy_spec.rb b/spec/graphql/mutations/coupons/destroy_spec.rb new file mode 100644 index 0000000..c2e6222 --- /dev/null +++ b/spec/graphql/mutations/coupons/destroy_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Coupons::Destroy do + let(:required_permission) { "coupons:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon) { create(:coupon, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyCouponInput!) { + destroyCoupon(input: $input) { id } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:delete" + + it "deletes a coupon" do + result = execute_query( + query: mutation, + input: {id: coupon.id} + ) + + data = result["data"]["destroyCoupon"] + expect(data["id"]).to eq(coupon.id) + end +end diff --git a/spec/graphql/mutations/coupons/terminate_spec.rb b/spec/graphql/mutations/coupons/terminate_spec.rb new file mode 100644 index 0000000..9ffe1e8 --- /dev/null +++ b/spec/graphql/mutations/coupons/terminate_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Coupons::Terminate do + let(:required_permission) { "coupons:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon) { create(:coupon, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: TerminateCouponInput!) { + terminateCoupon(input: $input) { + id name status terminatedAt + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:update" + + it "terminates a coupon" do + result = execute_query( + query: mutation, + input: {id: coupon.id} + ) + + data = result["data"]["terminateCoupon"] + expect(data["id"]).to eq(coupon.id) + expect(data["name"]).to eq(coupon.name) + expect(data["status"]).to eq("terminated") + expect(data["terminatedAt"]).to be_present + end +end diff --git a/spec/graphql/mutations/coupons/update_spec.rb b/spec/graphql/mutations/coupons/update_spec.rb new file mode 100644 index 0000000..c12f1e7 --- /dev/null +++ b/spec/graphql/mutations/coupons/update_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Coupons::Update do + let(:required_permission) { "coupons:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon) { create(:coupon, organization:) } + let(:expiration_at) { Time.current + 3.days } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:mutation) do + <<-GQL + mutation($input: UpdateCouponInput!) { + updateCoupon(input: $input) { + id, + name, + code, + description + status, + amountCents, + amountCurrency, + expiration, + expirationAt, + limitedPlans, + plans { + id + }, + reusable + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:update" + + it "updates a coupon" do + result = execute_query( + query: mutation, + input: { + id: coupon.id, + name: "New name", + couponType: "fixed_amount", + frequency: "once", + code: "new_code", + description: "This is a description", + amountCents: 123, + amountCurrency: "USD", + expiration: "time_limit", + expirationAt: expiration_at.iso8601, + reusable: false, + appliesTo: { + planIds: [plan.id] + } + } + ) + + result_data = result["data"]["updateCoupon"] + + expect(result_data["name"]).to eq("New name") + expect(result_data["code"]).to eq("new_code") + expect(result_data["description"]).to eq("This is a description") + expect(result_data["status"]).to eq("active") + expect(result_data["amountCents"]).to eq("123") + expect(result_data["amountCurrency"]).to eq("USD") + expect(result_data["expiration"]).to eq("time_limit") + expect(result_data["expirationAt"]).to eq expiration_at.iso8601 + expect(result_data["reusable"]).to eq(false) + expect(result_data["limitedPlans"]).to eq(true) + expect(result_data["plans"].first["id"]).to eq(plan.id) + end + + context "with billable metric limitations" do + let(:mutation) do + <<-GQL + mutation($input: UpdateCouponInput!) { + updateCoupon(input: $input) { + id, + name, + code, + status, + amountCents, + amountCurrency, + expiration, + expirationAt, + limitedBillableMetrics, + billableMetrics { + id + }, + reusable + } + } + GQL + end + + it "updates a coupon" do + result = execute_query( + query: mutation, + input: { + id: coupon.id, + name: "New name", + couponType: "fixed_amount", + frequency: "once", + code: "new_code", + amountCents: 123, + amountCurrency: "USD", + expiration: "time_limit", + expirationAt: expiration_at.iso8601, + reusable: false, + appliesTo: { + billableMetricIds: [billable_metric.id] + } + } + ) + + result_data = result["data"]["updateCoupon"] + + expect(result_data["name"]).to eq("New name") + expect(result_data["code"]).to eq("new_code") + expect(result_data["status"]).to eq("active") + expect(result_data["amountCents"]).to eq("123") + expect(result_data["amountCurrency"]).to eq("USD") + expect(result_data["expiration"]).to eq("time_limit") + expect(result_data["expirationAt"]).to eq expiration_at.iso8601 + expect(result_data["reusable"]).to eq(false) + expect(result_data["limitedBillableMetrics"]).to eq(true) + expect(result_data["billableMetrics"].first["id"]).to eq(billable_metric.id) + end + end +end diff --git a/spec/graphql/mutations/credit_notes/create_spec.rb b/spec/graphql/mutations/credit_notes/create_spec.rb new file mode 100644 index 0000000..4e0b9a8 --- /dev/null +++ b/spec/graphql/mutations/credit_notes/create_spec.rb @@ -0,0 +1,206 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CreditNotes::Create, :premium do + let(:required_permission) { "credit_notes:create" } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:customer) { create(:customer, organization:) } + + let(:fee1) { create(:fee, invoice:) } + let(:fee2) { create(:charge_fee, invoice:) } + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + payment_status: "succeeded", + currency: "EUR", + fees_amount_cents: 100, + taxes_amount_cents: 120, + total_amount_cents: 120, + total_paid_amount_cents: 110 + ) + end + + let(:mutation) do + <<~GQL + mutation($input: CreateCreditNoteInput!) { + createCreditNote(input: $input) { + id + creditStatus + refundStatus + reason + description + currency + totalAmountCents + creditAmountCents + balanceAmountCents + refundAmountCents + offsetAmountCents + items { + id + amountCents + amountCurrency + fee { id } + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:create" + + it "creates a credit note" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + reason: "duplicated_charge", + invoiceId: invoice.id, + description: "Duplicated charge", + creditAmountCents: 10, + refundAmountCents: 5, + offsetAmountCents: 10, + items: [ + { + feeId: fee1.id, + amountCents: 10 + }, + { + feeId: fee2.id, + amountCents: 15 + } + ] + } + } + ) + + result_data = result["data"]["createCreditNote"] + + expect(result_data["id"]).to be_present + expect(result_data["creditStatus"]).to eq("available") + expect(result_data["refundStatus"]).to eq("pending") + expect(result_data["reason"]).to eq("duplicated_charge") + expect(result_data["description"]).to eq("Duplicated charge") + expect(result_data["currency"]).to eq("EUR") + expect(result_data["totalAmountCents"]).to eq("25") + expect(result_data["creditAmountCents"]).to eq("10") + expect(result_data["balanceAmountCents"]).to eq("10") + expect(result_data["refundAmountCents"]).to eq("5") + expect(result_data["offsetAmountCents"]).to eq("10") + + expect(result_data["items"][0]["id"]).to be_present + expect(result_data["items"][0]["amountCents"]).to eq("10") + expect(result_data["items"][0]["amountCurrency"]).to eq("EUR") + expect(result_data["items"][0]["fee"]["id"]).to eq(fee1.id) + + expect(result_data["items"][1]["id"]).to be_present + expect(result_data["items"][1]["amountCents"]).to eq("15") + expect(result_data["items"][1]["amountCurrency"]).to eq("EUR") + expect(result_data["items"][1]["fee"]["id"]).to eq(fee2.id) + end + + context "when invoice is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + reason: "duplicated_charge", + invoiceId: "foo_id", + creditAmountCents: 10, + refundAmountCents: 5, + items: [ + { + feeId: fee1.id, + amountCents: 15 + } + ] + } + } + ) + + expect_not_found(result) + end + end + + context "when total amount is zero" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + reason: "duplicated_charge", + invoiceId: invoice.id, + creditAmountCents: 0, + refundAmountCents: 0, + items: [ + { + feeId: fee1.id, + amountCents: 0 + }, + { + feeId: fee2.id, + amountCents: 0 + } + ] + } + } + ) + + expect_unprocessable_entity(result) + end + end + + context "with metadata" do + let(:mutation) do + <<~GQL + mutation($input: CreateCreditNoteInput!) { + createCreditNote(input: $input) { + id + metadata { key value } + } + } + GQL + end + + it "creates credit note with metadata" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + reason: "duplicated_charge", + invoiceId: invoice.id, + creditAmountCents: 10, + refundAmountCents: 5, + items: [{feeId: fee1.id, amountCents: 10}, {feeId: fee2.id, amountCents: 5}], + metadata: [{key: "foo", value: "bar"}, {key: "baz", value: "qux"}] + } + } + ) + + result_data = result["data"]["createCreditNote"] + expect(result_data["metadata"]).to match_array([ + {"key" => "foo", "value" => "bar"}, + {"key" => "baz", "value" => "qux"} + ]) + end + end +end diff --git a/spec/graphql/mutations/credit_notes/download_spec.rb b/spec/graphql/mutations/credit_notes/download_spec.rb new file mode 100644 index 0000000..9c1371c --- /dev/null +++ b/spec/graphql/mutations/credit_notes/download_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CreditNotes::Download do + let(:required_permission) { "credit_notes:view" } + let(:credit_note) { create(:credit_note) } + let(:organization) { credit_note.organization } + let(:membership) { create(:membership, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: DownloadCreditNoteInput!) { + downloadCreditNote(input: $input) { + id + fileUrl + } + } + GQL + end + + before { stub_pdf_generation } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:view" + + it "generates the credit note PDF" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: credit_note.id + } + } + ) + + result_data = result["data"]["downloadCreditNote"] + + expect(result_data["id"]).to eq(credit_note.id) + expect(result_data["fileUrl"]).to be_present + end +end diff --git a/spec/graphql/mutations/credit_notes/download_xml_spec.rb b/spec/graphql/mutations/credit_notes/download_xml_spec.rb new file mode 100644 index 0000000..faace0b --- /dev/null +++ b/spec/graphql/mutations/credit_notes/download_xml_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CreditNotes::DownloadXml do + let(:required_permission) { "credit_notes:view" } + let(:credit_note) { create(:credit_note) } + let(:organization) { credit_note.organization } + let(:membership) { create(:membership, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: DownloadXmlCreditNoteInput!) { + downloadXmlCreditNote(input: $input) { + id + xmlUrl + } + } + GQL + end + + before do + credit_note.xml_file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.xml"))), + filename: "credit_note.xml", + content_type: "application/xml" + ) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:view" + + it "generates the credit note XML" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: credit_note.id + } + } + ) + + result_data = result["data"]["downloadXmlCreditNote"] + + expect(result_data["id"]).to eq(credit_note.id) + expect(result_data["xmlUrl"]).to be_present + end +end diff --git a/spec/graphql/mutations/credit_notes/resend_email_spec.rb b/spec/graphql/mutations/credit_notes/resend_email_spec.rb new file mode 100644 index 0000000..caccd50 --- /dev/null +++ b/spec/graphql/mutations/credit_notes/resend_email_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CreditNotes::ResendEmail do + let(:required_permission) { "credit_notes:send" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, email: "customer@example.com") } + let(:billing_entity) { customer.billing_entity } + let(:invoice) { create(:invoice, customer:, organization:, status: :finalized) } + let(:credit_note) { create(:credit_note, invoice:, customer:, status: :finalized) } + + let(:mutation) do + <<~GQL + mutation($input: ResendCreditNoteEmailInput!) { + resendCreditNoteEmail(input: $input) { + id + } + } + GQL + end + + before do + billing_entity.update!(email: "billing@example.com") + billing_entity.email_settings = ["credit_note.created"] + billing_entity.save! + allow(License).to receive(:premium?).and_return(true) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:send" + + it "resends the credit note email" do + expect do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: credit_note.id}} + ) + + expect(result["data"]["resendCreditNoteEmail"]["id"]).to eq(credit_note.id) + end.to have_enqueued_mail(CreditNoteMailer, :created) + end + + context "with custom recipients" do + it "resends the credit note email with custom recipients" do + expect do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: credit_note.id, + to: ["custom@example.com"], + cc: ["cc@example.com"], + bcc: ["bcc@example.com"] + } + } + ) + + expect(result["data"]["resendCreditNoteEmail"]["id"]).to eq(credit_note.id) + end.to have_enqueued_mail(CreditNoteMailer, :created) + end + end + + context "when credit note does not exist" do + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: SecureRandom.uuid}} + ) + + expect(result["errors"]).to be_present + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + end + end +end diff --git a/spec/graphql/mutations/credit_notes/retry_tax_reporting_spec.rb b/spec/graphql/mutations/credit_notes/retry_tax_reporting_spec.rb new file mode 100644 index 0000000..103faad --- /dev/null +++ b/spec/graphql/mutations/credit_notes/retry_tax_reporting_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CreditNotes::RetryTaxReporting do + let(:required_permission) { "credit_notes:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:user) { membership.user } + + let(:invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:credit_note) do + create( + :credit_note, + :with_tax_error, + organization:, + customer:, + invoice: + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(path) + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:mutation) do + <<-GQL + mutation($input: RetryTaxReportingInput!) { + retryTaxReporting(input: $input) { + id + } + } + GQL + end + + before do + integration_collection_mapping + fee_subscription + + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:update" + + context "with valid preconditions" do + it "returns the credit note after successful retry" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: credit_note.id} + } + ) + + data = result["data"]["retryTaxReporting"] + + expect(data["id"]).to eq(credit_note.id) + end + end + + context "when there is tax error" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "returns the error" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: credit_note.id} + } + ) + + expect_graphql_error( + result:, + message: "Unprocessable Entity" + ) + end + end +end diff --git a/spec/graphql/mutations/credit_notes/update_spec.rb b/spec/graphql/mutations/credit_notes/update_spec.rb new file mode 100644 index 0000000..39a2bbe --- /dev/null +++ b/spec/graphql/mutations/credit_notes/update_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CreditNotes::Update do + let(:required_permission) { "credit_notes:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:credit_note) { create(:credit_note, customer:, invoice:) } + + let(:mutation) do + <<~GQL + mutation($input: UpdateCreditNoteInput!) { + updateCreditNote(input: $input) { + id + refundStatus + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:update" + + it "updates the credit note" do + result = execute_query( + query: mutation, + input: { + id: credit_note.id, + refundStatus: "succeeded" + } + ) + + result_data = result["data"]["updateCreditNote"] + + expect(result_data["id"]).to eq(credit_note.id) + expect(result_data["refundStatus"]).to eq("succeeded") + end + + context "when credit note is not found" do + it "returns an error" do + result = execute_query( + query: mutation, + input: { + id: "foo_bar", + refundStatus: "succeeded" + } + ) + + expect_not_found(result) + end + end + + context "with metadata" do + let(:mutation) do + <<~GQL + mutation($input: UpdateCreditNoteInput!) { + updateCreditNote(input: $input) { + id + metadata { key value } + } + } + GQL + end + + before { create(:item_metadata, owner: credit_note, organization:, value: {"existing" => "value"}) } + + it "replaces metadata (not merges)" do + result = execute_query( + query: mutation, + input: { + id: credit_note.id, + metadata: [{key: "new", value: "data"}] + } + ) + + result_data = result["data"]["updateCreditNote"] + expect(result_data["metadata"]).to eq([{"key" => "new", "value" => "data"}]) + end + end +end diff --git a/spec/graphql/mutations/credit_notes/void_spec.rb b/spec/graphql/mutations/credit_notes/void_spec.rb new file mode 100644 index 0000000..2969e6d --- /dev/null +++ b/spec/graphql/mutations/credit_notes/void_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CreditNotes::Void do + let(:required_permission) { "credit_notes:void" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:credit_note) { create(:credit_note, customer:, invoice:) } + + let(:mutation) do + <<~GQL + mutation($input: VoidCreditNoteInput!) { + voidCreditNote(input: $input) { + id + creditStatus + canBeVoided + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:void" + + it "voids the credit note" do + result = execute_query( + query: mutation, + input: {id: credit_note.id} + ) + + result_data = result["data"]["voidCreditNote"] + + expect(result_data["id"]).to eq(credit_note.id) + expect(result_data["creditStatus"]).to eq("voided") + end + + context "when credit note is not found" do + it "returns an error" do + result = execute_query( + query: mutation, + input: {id: "foo_bar"} + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/customer_portal/download_invoice_spec.rb b/spec/graphql/mutations/customer_portal/download_invoice_spec.rb new file mode 100644 index 0000000..9188ebc --- /dev/null +++ b/spec/graphql/mutations/customer_portal/download_invoice_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CustomerPortal::DownloadInvoice do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: DownloadCustomerPortalInvoiceInput!) { + downloadCustomerPortalInvoice(input: $input) { + id + } + } + GQL + end + + before { stub_pdf_generation } + + it_behaves_like "requires a customer portal user" + + it "generates the PDF for the given invoice" do + freeze_time do + result = execute_graphql( + customer_portal_user: customer, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result["data"]["downloadCustomerPortalInvoice"] + + expect(result_data["id"]).to eq(invoice.id) + end + end + + context "without customer portal user" do + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + expect_unauthorized_error(result) + end + end +end diff --git a/spec/graphql/mutations/customer_portal/generate_url_spec.rb b/spec/graphql/mutations/customer_portal/generate_url_spec.rb new file mode 100644 index 0000000..36f1c9e --- /dev/null +++ b/spec/graphql/mutations/customer_portal/generate_url_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CustomerPortal::GenerateUrl do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:user) { membership.user } + let(:mutation) do + <<-GQL + mutation($input: GenerateCustomerPortalUrlInput!) { + generateCustomerPortalUrl(input: $input) { + url + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + + context "when licence is premium", :premium do + it "returns customer portal url" do + result = execute_graphql( + current_organization: organization, + current_user: user, + query: mutation, + variables: { + input: {id: customer.id} + } + ) + + data = result["data"]["generateCustomerPortalUrl"] + + expect(data["url"]).to include("/customer-portal/") + end + end +end diff --git a/spec/graphql/mutations/customer_portal/update_customer_spec.rb b/spec/graphql/mutations/customer_portal/update_customer_spec.rb new file mode 100644 index 0000000..6d1485a --- /dev/null +++ b/spec/graphql/mutations/customer_portal/update_customer_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CustomerPortal::UpdateCustomer do + subject(:result) do + execute_graphql( + customer_portal_user: customer, + query: mutation, + variables: { + input: + } + ) + end + + let(:customer) { create(:customer, legal_name: nil) } + + let(:mutation) do + <<~GQL + mutation($input: UpdateCustomerPortalCustomerInput!) { + updateCustomerPortalCustomer(input: $input) { + customerType + name + firstname + lastname + legalName + taxIdentificationNumber + email + addressLine1 + addressLine2 + zipcode + city + state + country + billingConfiguration { + documentLocale + } + billingEntityBillingConfiguration { + documentLocale + } + shippingAddress { + addressLine1 + addressLine2 + zipcode + city + state + country + } + } + } + GQL + end + + let(:input) do + { + customerType: "company", + name: "Updated customer name", + firstname: "Updated customer firstname", + lastname: "Updated customer lastname", + legalName: "Updated customer legalName", + taxIdentificationNumber: "2246", + email: "customer@email.test", + documentLocale: "fr", + addressLine1: "Updated customer addressLine1", + addressLine2: "Updated customer addressLine2", + zipcode: "Updated customer zipcode", + city: "Updated customer city", + state: "Updated customer state", + country: "PT", + shippingAddress: { + addressLine1: "Updated customer shipping addressLine1", + addressLine2: "Updated customer shipping addressLine2", + zipcode: "Updated customer shipping zipcode", + city: "Updated customer shipping city", + state: "Updated customer shipping state", + country: "ES" + } + } + end + + it_behaves_like "requires a customer portal user" + + it "updates a customer" do + result_data = result["data"]["updateCustomerPortalCustomer"] + + expect(result_data["customerType"]).to eq(input[:customerType]) + expect(result_data["name"]).to eq(input[:name]) + expect(result_data["firstname"]).to eq(input[:firstname]) + expect(result_data["lastname"]).to eq(input[:lastname]) + expect(result_data["taxIdentificationNumber"]).to eq(input[:taxIdentificationNumber]) + expect(result_data["legalName"]).to eq(input[:legalName]) + expect(result_data["email"]).to eq(input[:email]) + expect(result_data["addressLine1"]).to eq(input[:addressLine1]) + expect(result_data["addressLine2"]).to eq(input[:addressLine2]) + expect(result_data["zipcode"]).to eq(input[:zipcode]) + expect(result_data["city"]).to eq(input[:city]) + expect(result_data["state"]).to eq(input[:state]) + expect(result_data["country"]).to eq(input[:country]) + expect(result_data["billingConfiguration"]["documentLocale"]).to eq(input[:documentLocale]) + expect(result_data["billingEntityBillingConfiguration"]["documentLocale"]).to eq(customer.billing_entity.document_locale) + expect(result_data["shippingAddress"]["addressLine1"]).to eq(input[:shippingAddress][:addressLine1]) + expect(result_data["shippingAddress"]["addressLine2"]).to eq(input[:shippingAddress][:addressLine2]) + expect(result_data["shippingAddress"]["zipcode"]).to eq(input[:shippingAddress][:zipcode]) + expect(result_data["shippingAddress"]["city"]).to eq(input[:shippingAddress][:city]) + expect(result_data["shippingAddress"]["state"]).to eq(input[:shippingAddress][:state]) + expect(result_data["shippingAddress"]["country"]).to eq(input[:shippingAddress][:country]) + end + + context "when updating some fields" do + let(:input) { {name: "Updated customer name"} } + + it "does not change the fields not changed" do + old_firstname = customer.firstname + + result_data = result["data"]["updateCustomerPortalCustomer"] + + expect(result_data["name"]).to eq(input[:name]) + expect(result_data["firstname"]).to eq(old_firstname) + end + end + + context "when updating not allowed fields" do + let(:input) { {currency: "USD"} } + + it "does not change the fields not changed" do + expect { result }.not_to change { customer.reload.currency } + end + end +end diff --git a/spec/graphql/mutations/customer_portal/wallet_transactions/create_spec.rb b/spec/graphql/mutations/customer_portal/wallet_transactions/create_spec.rb new file mode 100644 index 0000000..36af76e --- /dev/null +++ b/spec/graphql/mutations/customer_portal/wallet_transactions/create_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::CustomerPortal::WalletTransactions::Create do + let(:wallet) { create(:wallet, balance: 10.0, credits_balance: 10.0) } + + let(:mutation) do + <<-GQL + mutation($input: CreateCustomerPortalWalletTransactionInput!) { + createCustomerPortalWalletTransaction(input: $input) { + collection { id, status, amount } + } + } + GQL + end + + before do + wallet + end + + it_behaves_like "requires a customer portal user" + + it "creates a wallet transaction" do + result = execute_graphql( + customer_portal_user: wallet.customer, + query: mutation, + variables: { + input: { + walletId: wallet.id, + paidCredits: "5.00" + } + } + ) + + result_data = result["data"]["createCustomerPortalWalletTransaction"] + + expect(result_data["collection"].count).to eq 1 + expect(result_data["collection"].first["status"]).to eq "pending" + expect(result_data["collection"].first["amount"]).to eq "5.0" + end + + context "when wallet has a minimum amount" do + it "returns an error" do + wallet.update!(paid_top_up_min_amount_cents: 10_00) + + result = execute_graphql( + customer_portal_user: wallet.customer, + query: mutation, + variables: { + input: { + walletId: wallet.id, + paidCredits: "5.123459999" + } + } + ) + + # TODO: improve when we have metadata on errors + expect_unprocessable_entity(result) + end + end + + context "when customer tries to top up another customer's wallet" do + let(:other_customer) { create(:customer, organization: wallet.customer.organization) } + let(:other_wallet) { create(:wallet, customer: other_customer, balance: 10.0, credits_balance: 10.0) } + + it "returns an error" do + result = execute_graphql( + customer_portal_user: wallet.customer, + query: mutation, + variables: { + input: { + walletId: other_wallet.id, + paidCredits: "5.00" + } + } + ) + + expect_unprocessable_entity(result) + end + end + + context "without customer portal user" do + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: { + walletId: wallet.id, + paidCredits: "5.00" + } + } + ) + + expect_unauthorized_error(result) + end + end +end diff --git a/spec/graphql/mutations/customers/create_spec.rb b/spec/graphql/mutations/customers/create_spec.rb new file mode 100644 index 0000000..ad72f41 --- /dev/null +++ b/spec/graphql/mutations/customers/create_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Customers::Create do + let(:required_permissions) { "customers:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:tax) { create(:tax, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: CreateCustomerInput!) { + createCustomer(input: $input) { + id + name + firstname + lastname + displayName + customerType + externalId + city + country + paymentProvider + providerCustomer { id, providerCustomerId providerPaymentMethods } + currency + taxIdentificationNumber + timezone + netPaymentTerm + canEditAttributes + invoiceGracePeriod + finalizeZeroAmountInvoice + billingConfiguration { + subscriptionInvoiceIssuingDateAnchor + subscriptionInvoiceIssuingDateAdjustment + documentLocale + } + shippingAddress { addressLine1 city state } + metadata { id, key, value, displayInInvoice } + taxes { code } + billingEntity { code } + } + } + GQL + end + + let(:body) do + { + object: "event", + data: {url: "test.url"} + } + end + + before do + stub_request(:post, "https://api.stripe.com/v1/checkout/sessions") + .to_return(status: 200, body: body.to_json, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "customers:create" + + it "creates a customer" do + stripe_provider + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: { + name: "John Doe Inc", + firstname: "John", + lastname: "Doe", + customerType: "company", + externalId: "john_doe_2", + city: "London", + country: "GB", + paymentProvider: "stripe", + taxIdentificationNumber: "123456789", + currency: "EUR", + netPaymentTerm: 30, + finalizeZeroAmountInvoice: "skip", + billingEntityCode: billing_entity.code, + providerCustomer: { + providerCustomerId: "cu_12345", + providerPaymentMethods: ["card"] + }, + billingConfiguration: { + documentLocale: "fr", + subscriptionInvoiceIssuingDateAnchor: "current_period_end", + subscriptionInvoiceIssuingDateAdjustment: "keep_anchor" + }, + shippingAddress: { + addressLine1: "Test 12", + zipcode: "102030", + state: "test state", + city: "Paris" + }, + metadata: [ + { + key: "manager", + value: "John Doe", + displayInInvoice: true + } + ], + taxCodes: [tax.code] + } + } + ) + + result_data = result["data"]["createCustomer"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("John Doe Inc") + expect(result_data["firstname"]).to eq("John") + expect(result_data["lastname"]).to eq("Doe") + expect(result_data["displayName"]).to eq("John Doe Inc - John Doe") + expect(result_data["customerType"]).to eq("company") + expect(result_data["externalId"]).to eq("john_doe_2") + expect(result_data["city"]).to eq("London") + expect(result_data["country"]).to eq("GB") + expect(result_data["currency"]).to eq("EUR") + expect(result_data["taxIdentificationNumber"]).to eq("123456789") + expect(result_data["paymentProvider"]).to eq("stripe") + expect(result_data["providerCustomer"]["id"]).to be_present + expect(result_data["providerCustomer"]["providerCustomerId"]).to eq("cu_12345") + expect(result_data["providerCustomer"]["providerPaymentMethods"]).to eq(["card"]) + expect(result_data["invoiceGracePeriod"]).to be_nil + expect(result_data["billingConfiguration"]["documentLocale"]).to eq("fr") + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAnchor"]).to eq("current_period_end") + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAdjustment"]).to eq("keep_anchor") + expect(result_data["shippingAddress"]["addressLine1"]).to eq("Test 12") + expect(result_data["shippingAddress"]["city"]).to eq("Paris") + expect(result_data["shippingAddress"]["state"]).to eq("test state") + expect(result_data["netPaymentTerm"]).to eq(30) + expect(result_data["finalizeZeroAmountInvoice"]).to eq("skip") + expect(result_data["metadata"].count).to eq(1) + expect(result_data["metadata"][0]["value"]).to eq("John Doe") + expect(result_data["taxes"][0]["code"]).to eq(tax.code) + expect(result_data["billingEntity"]["code"]).to eq(billing_entity.code) + end + + context "with premium feature", :premium do + it "creates a customer" do + stripe_provider + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: { + name: "John Doe", + externalId: "john_doe_2", + city: "London", + country: "GB", + paymentProvider: "stripe", + currency: "EUR", + timezone: "TZ_EUROPE_PARIS", + providerCustomer: { + providerCustomerId: "cu_12345", + providerPaymentMethods: ["card"] + }, + invoiceGracePeriod: 2 + } + } + ) + + result_data = result["data"]["createCustomer"] + + expect(result_data["timezone"]).to eq("TZ_EUROPE_PARIS") + expect(result_data["invoiceGracePeriod"]).to eq(2) + end + end + + context "with validation errors" do + it "returns an error with validation messages" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: { + name: "John Doe", + externalId: "john_doe_2", + city: "London", + country: 0 + } + } + ) + + expect(result["errors"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/customers/destroy_spec.rb b/spec/graphql/mutations/customers/destroy_spec.rb new file mode 100644 index 0000000..eb9c89e --- /dev/null +++ b/spec/graphql/mutations/customers/destroy_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Customers::Destroy do + let(:required_permission) { "customers:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyCustomerInput!) { + destroyCustomer(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "customers:delete" + + it "deletes a customer" do + result = execute_query( + query: mutation, + input: {id: customer.id} + ) + + data = result["data"]["destroyCustomer"] + expect(data["id"]).to eq(customer.id) + end +end diff --git a/spec/graphql/mutations/customers/update_invoice_grace_period_spec.rb b/spec/graphql/mutations/customers/update_invoice_grace_period_spec.rb new file mode 100644 index 0000000..49fc11c --- /dev/null +++ b/spec/graphql/mutations/customers/update_invoice_grace_period_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Customers::UpdateInvoiceGracePeriod, :premium do + let(:required_permission) { "customers:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: UpdateCustomerInvoiceGracePeriodInput!) { + updateCustomerInvoiceGracePeriod(input: $input) { + id, + name, + externalId, + invoiceGracePeriod + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", %w[customers:update] + + it "updates a customer" do + result = execute_query( + query: mutation, + input: { + id: customer.id, + invoiceGracePeriod: 12 + } + ) + + result_data = result["data"]["updateCustomerInvoiceGracePeriod"] + + expect(result_data["id"]).to be_present + expect(result_data["invoiceGracePeriod"]).to eq(12) + end +end diff --git a/spec/graphql/mutations/customers/update_spec.rb b/spec/graphql/mutations/customers/update_spec.rb new file mode 100644 index 0000000..171983f --- /dev/null +++ b/spec/graphql/mutations/customers/update_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Customers::Update do + let(:required_permission) { "customers:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, legal_name: nil) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:tax) { create(:tax, organization:) } + let(:external_id) { SecureRandom.uuid } + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 2, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: UpdateCustomerInput!) { + updateCustomer(input: $input) { + id + name + firstname + lastname + displayName + customerType + taxIdentificationNumber + externalId + paymentProvider + currency + timezone + netPaymentTerm + canEditAttributes + invoiceGracePeriod + finalizeZeroAmountInvoice + billingEntity { code } + providerCustomer { id, providerCustomerId, providerPaymentMethods } + billingConfiguration { + id + subscriptionInvoiceIssuingDateAnchor + subscriptionInvoiceIssuingDateAdjustment + documentLocale + } + metadata { id, key, value, displayInInvoice } + taxes { code } + configurableInvoiceCustomSections { id } + } + } + GQL + end + + let(:body) do + { + object: "event", + data: {} + } + end + + let(:input) do + { + id: customer.id, + name: "Updated customer", + firstname: "Updated firstname", + lastname: "Updated lastname", + customerType: "individual", + taxIdentificationNumber: "2246", + externalId: external_id, + paymentProvider: "stripe", + currency: "USD", + netPaymentTerm: 3, + finalizeZeroAmountInvoice: "skip", + billingEntityCode: billing_entity.code, + providerCustomer: { + providerCustomerId: "cu_12345", + providerPaymentMethods: %w[card sepa_debit] + }, + billingConfiguration: { + documentLocale: "fr", + subscriptionInvoiceIssuingDateAnchor: "current_period_end", + subscriptionInvoiceIssuingDateAdjustment: "keep_anchor" + }, + metadata: [ + { + key: "test-key", + value: "value", + displayInInvoice: true + } + ], + taxCodes: [tax.code], + configurableInvoiceCustomSectionIds: invoice_custom_sections.map(&:id) + } + end + + before do + stub_request(:post, "https://api.stripe.com/v1/checkout/sessions") + .to_return(status: 200, body: body.to_json, headers: {}) + + allow(Stripe::Customer).to receive(:update).and_return(BaseService::Result.new) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", %w[customers:update] + + it "updates a customer" do + stripe_provider + + result = execute_query( + query: mutation, + input: + ) + + result_data = result["data"]["updateCustomer"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Updated customer") + expect(result_data["firstname"]).to eq("Updated firstname") + expect(result_data["lastname"]).to eq("Updated lastname") + expect(result_data["displayName"]).to eq("Updated customer - Updated firstname Updated lastname") + expect(result_data["customerType"]).to eq("individual") + expect(result_data["taxIdentificationNumber"]).to eq("2246") + expect(result_data["externalId"]).to eq(external_id) + expect(result_data["paymentProvider"]).to eq("stripe") + expect(result_data["currency"]).to eq("USD") + expect(result_data["timezone"]).to be_nil + expect(result_data["netPaymentTerm"]).to eq(3) + expect(result_data["finalizeZeroAmountInvoice"]).to eq("skip") + expect(result_data["providerCustomer"]["id"]).to be_present + expect(result_data["providerCustomer"]["providerCustomerId"]).to eq("cu_12345") + expect(result_data["providerCustomer"]["providerPaymentMethods"]).to eq(%w[card sepa_debit]) + expect(result_data["invoiceGracePeriod"]).to be_nil + expect(result_data["billingConfiguration"]["documentLocale"]).to eq("fr") + expect(result_data["billingConfiguration"]["id"]).to eq("#{customer.id}-c0nf") + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAnchor"]).to eq("current_period_end") + expect(result_data["billingConfiguration"]["subscriptionInvoiceIssuingDateAdjustment"]).to eq("keep_anchor") + expect(result_data["metadata"][0]["key"]).to eq("test-key") + expect(result_data["taxes"][0]["code"]).to eq(tax.code) + expect(result_data["configurableInvoiceCustomSections"]).to match_array(invoice_custom_sections.map { |section| {"id" => section.id} }) + expect(result_data["billingEntity"]["code"]).to eq(billing_entity.code) + end + + context "when stripe customer does not exist" do + before do + stripe_provider + + allow(Stripe::Customer).to receive(:update) + .and_raise(Stripe::InvalidRequestError.new("No such customer: 'cus_invalid'", nil, code: "resource_missing")) + end + + it "returns a third party error" do + result = execute_query( + query: mutation, + input: input.merge( + providerCustomer: {providerCustomerId: "cus_invalid"} + ) + ) + + expect(result["errors"].first["extensions"]["status"]).to eq(422) + expect(result["errors"].first["extensions"]["code"]).to eq("third_party_error") + end + end + + context "with premium feature", :premium do + it "updates a customer" do + result = execute_query( + query: mutation, + input: { + id: customer.id, + externalId: SecureRandom.uuid, + name: "Updated customer", + timezone: "TZ_EUROPE_PARIS", + invoiceGracePeriod: 2 + } + ) + + result_data = result["data"]["updateCustomer"] + + expect(result_data["timezone"]).to eq("TZ_EUROPE_PARIS") + expect(result_data["invoiceGracePeriod"]).to eq(2) + end + end + + context "when user can update customers", :premium do + it "updates a customer" do + result = execute_query( + query: mutation, + input: input.merge({ + invoiceGracePeriod: 2, + timezone: "TZ_EUROPE_PARIS" + }) + ) + + result_data = result["data"]["updateCustomer"] + + # What should have changed + expect(result_data["id"]).to be_present + expect(result_data["taxes"][0]["code"]).to eq(tax.code) + expect(result_data["netPaymentTerm"]).to eq(3) + expect(result_data["invoiceGracePeriod"]).to eq 2 + expect(result_data["billingConfiguration"]["documentLocale"]).to eq("fr") + expect(result_data["name"]).to eq("Updated customer") + expect(result_data["taxIdentificationNumber"]).to eq("2246") + expect(result_data["externalId"]).to eq(external_id) + expect(result_data["paymentProvider"]).to eq("stripe") + expect(result_data["currency"]).to eq("USD") + expect(result_data["timezone"]).to eq("TZ_EUROPE_PARIS") + expect(result_data["providerCustomer"]).to be_present + expect(result_data["metadata"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/data_exports/credit_notes/create_spec.rb b/spec/graphql/mutations/data_exports/credit_notes/create_spec.rb new file mode 100644 index 0000000..484a573 --- /dev/null +++ b/spec/graphql/mutations/data_exports/credit_notes/create_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::DataExports::CreditNotes::Create do + include_context "with mocked security logger" + + let(:required_permission) { "credit_notes:export" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:mutation) do + <<-GQL + mutation($input: CreateDataExportsCreditNotesInput!) { + createCreditNotesDataExport(input: $input) { + id, + status, + } + } + GQL + end + + let(:variables) do + { + input: { + format: "csv", + resourceType: "credit_notes", + filters: { + amountFrom: 2000, + amountTo: 5000, + creditStatus: %w[available consumed], + currency: "USD", + customerExternalId: "abc123", + issuingDateFrom: "2024-05-23", + issuingDateTo: "2024-07-01", + reason: %w[duplicated_charge product_unsatisfactory], + refundStatus: %w[pending succeeded], + searchTerm: "abc", + types: %w[credit refund] + } + } + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:export" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: + ) + end + + it "creates data export" do + result_data = result["data"]["createCreditNotesDataExport"] + + expect(result_data).to include("id" => String, "status" => "pending") + end + + it_behaves_like "produces a security log", "export.created" + end +end diff --git a/spec/graphql/mutations/data_exports/invoices/create_spec.rb b/spec/graphql/mutations/data_exports/invoices/create_spec.rb new file mode 100644 index 0000000..c0c3402 --- /dev/null +++ b/spec/graphql/mutations/data_exports/invoices/create_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::DataExports::Invoices::Create do + include_context "with mocked security logger" + + let(:required_permission) { "invoices:export" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:mutation) do + <<-GQL + mutation($input: CreateDataExportsInvoicesInput!) { + createInvoicesDataExport(input: $input) { + id, + status, + } + } + GQL + end + + before { membership } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:export" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + format: "csv", + resourceType: "invoices", + filters: { + amountFrom: 0, + amountTo: 10000, + currency: "USD", + customerExternalId: "abc123", + invoiceType: ["one_off"], + issuingDateFrom: "2024-05-23", + issuingDateTo: "2024-07-01", + paymentDisputeLost: false, + paymentOverdue: true, + paymentStatus: ["pending"], + searchTerm: "service ABC", + status: ["finalized"] + } + } + } + ) + end + + it "creates data export" do + result_data = result["data"]["createInvoicesDataExport"] + + expect(result_data).to include( + "id" => String, + "status" => "pending" + ) + end + + it_behaves_like "produces a security log", "export.created" + end +end diff --git a/spec/graphql/mutations/dunning_campaigns/create_spec.rb b/spec/graphql/mutations/dunning_campaigns/create_spec.rb new file mode 100644 index 0000000..939c3b0 --- /dev/null +++ b/spec/graphql/mutations/dunning_campaigns/create_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::DunningCampaigns::Create, :premium do + let(:required_permission) { "dunning_campaigns:create" } + let(:organization) { create(:organization, premium_integrations: ["auto_dunning"]) } + let(:membership) { create(:membership, organization:) } + let(:input) do + { + name: "Dunning campaign name", + code: "dunning-campaign-code", + description: "Dunning campaign description", + bccEmails: ["earl@example.com"], + maxAttempts: 3, + daysBetweenAttempts: 1, + appliedToOrganization: false, + thresholds: [ + { + amountCents: 10000, + currency: "EUR" + } + ] + } + end + + let(:mutation) do + <<-GQL + mutation($input: CreateDunningCampaignInput!) { + createDunningCampaign(input: $input) { + id + name + code + description + bccEmails + maxAttempts + daysBetweenAttempts + appliedToOrganization + thresholds { + amountCents + currency + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "dunning_campaigns:create" + + it "creates a dunning campaign" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["createDunningCampaign"]).to include( + "id" => String, + "name" => "Dunning campaign name", + "code" => "dunning-campaign-code", + "description" => "Dunning campaign description", + "bccEmails" => ["earl@example.com"], + "maxAttempts" => 3, + "daysBetweenAttempts" => 1, + "appliedToOrganization" => false, + "thresholds" => [ + { + "amountCents" => "10000", + "currency" => "EUR" + } + ] + ) + end +end diff --git a/spec/graphql/mutations/dunning_campaigns/destroy_spec.rb b/spec/graphql/mutations/dunning_campaigns/destroy_spec.rb new file mode 100644 index 0000000..cfe1dfd --- /dev/null +++ b/spec/graphql/mutations/dunning_campaigns/destroy_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::DunningCampaigns::Destroy, :premium do + let(:required_permissions) { "dunning_campaigns:delete" } + let(:membership) { create(:membership, organization:) } + let(:organization) { create(:organization, premium_integrations: ["auto_dunning"]) } + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyDunningCampaignInput!) { + destroyDunningCampaign(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "dunning_campaigns:delete" + + it "deletes a dunning campaign" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: {id: dunning_campaign.id} + } + ) + + data = result["data"]["destroyDunningCampaign"] + expect(data["id"]).to eq(dunning_campaign.id) + end + + context "when dunnign campaign is not found" do + let(:dunning_campaign) { create(:dunning_campaign) } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: {id: dunning_campaign.id} + } + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/mutations/dunning_campaigns/update_spec.rb b/spec/graphql/mutations/dunning_campaigns/update_spec.rb new file mode 100644 index 0000000..5984e65 --- /dev/null +++ b/spec/graphql/mutations/dunning_campaigns/update_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::DunningCampaigns::Update, :premium do + let(:required_permission) { "dunning_campaigns:update" } + let(:organization) { create(:organization, premium_integrations: ["auto_dunning"]) } + let(:membership) { create(:membership, organization:) } + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + let(:dunning_campaign_threshold) do + create(:dunning_campaign_threshold, dunning_campaign:) + end + + let(:mutation) do + <<-GQL + mutation($input: UpdateDunningCampaignInput!) { + updateDunningCampaign(input: $input) { + id + name + code + appliedToOrganization + } + } + GQL + end + + let(:input) do + { + id: dunning_campaign.id, + name: "Updated Dunning campaign name", + code: "updated-dunning-campaign-code", + description: "Updated Dunning campaign description", + maxAttempts: 99, + daysBetweenAttempts: 7, + appliedToOrganization: false, + thresholds: [ + { + id: dunning_campaign_threshold.id, + amountCents: 999_00, + currency: "USD" + } + ] + } + end + + before do + organization.default_billing_entity.update!(applied_dunning_campaign: dunning_campaign) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "dunning_campaigns:update" + + it "updates a dunning campaign" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["updateDunningCampaign"]).to include( + "id" => dunning_campaign.id, + "name" => "Updated Dunning campaign name", + "code" => "updated-dunning-campaign-code", + "appliedToOrganization" => input[:appliedToOrganization] + ) + end +end diff --git a/spec/graphql/mutations/entitlement/create_feature_spec.rb b/spec/graphql/mutations/entitlement/create_feature_spec.rb new file mode 100644 index 0000000..14b23d7 --- /dev/null +++ b/spec/graphql/mutations/entitlement/create_feature_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Entitlement::CreateFeature, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "features:create" } + + let(:organization) { create(:organization) } + let(:query) do + <<-GQL + mutation($input: CreateFeatureInput!) { + createFeature(input: $input) { + id + code + name + description + privileges { + code + name + valueType + } + } + } + GQL + end + + let(:input) do + { + code: "test_feature", + name: "Test Feature", + description: "Test Feature Description", + privileges: [] + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "features:create" + + it "creates a feature" do + result = subject + + result_data = result["data"]["createFeature"] + + expect(result_data["code"]).to eq("test_feature") + expect(result_data["name"]).to eq("Test Feature") + expect(result_data["description"]).to eq("Test Feature Description") + expect(result_data["privileges"]).to be_empty + end + + context "when creating feature with privileges" do + let(:privilege_code) { "test_privilege" } + let(:privilege_name) { "Test Privilege" } + let(:input) do + { + code: "test_feature_with_privileges", + name: "Test Feature With Privileges", + description: "Test Feature Description", + privileges: [ + {code: privilege_code, name: privilege_name} + ] + } + end + + it "creates a feature with privileges" do + result = subject + + result_data = result["data"]["createFeature"] + + expect(result_data["code"]).to eq("test_feature_with_privileges") + expect(result_data["name"]).to eq("Test Feature With Privileges") + expect(result_data["description"]).to eq("Test Feature Description") + expect(result_data["privileges"].size).to eq(1) + expect(result_data["privileges"].sole["code"]).to eq(privilege_code) + expect(result_data["privileges"].sole["name"]).to eq(privilege_name) + expect(result_data["privileges"].sole["valueType"]).to eq("string") + end + end +end diff --git a/spec/graphql/mutations/entitlement/create_or_update_subscription_entitlement_spec.rb b/spec/graphql/mutations/entitlement/create_or_update_subscription_entitlement_spec.rb new file mode 100644 index 0000000..5be725f --- /dev/null +++ b/spec/graphql/mutations/entitlement/create_or_update_subscription_entitlement_spec.rb @@ -0,0 +1,315 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Entitlement::CreateOrUpdateSubscriptionEntitlement, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:organization) { create(:organization) } + + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan:) } + let(:feature) { create(:feature, organization:, code: "seats", name: "SEATS") } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:privilege2) { create(:privilege, feature:, code: "reset", value_type: "select", config: {select_options: %w[email slack]}) } + + let(:query) do + <<~GQL + mutation($input: CreateOrUpdateSubscriptionEntitlementInput!) { + createOrUpdateSubscriptionEntitlement(input: $input) { + code + name + privileges { code value valueType } + } + } + GQL + end + + let(:input) do + { + subscriptionId: subscription.id, + entitlement: { + featureCode: feature.code, + privileges: [ + {privilegeCode: privilege.code, value: "100"} + ] + } + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:update" + + context "when feature is not on plan nor on subscription" do + it "adds the feature to the subscription" do + result = subject + + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "100", + "valueType" => "integer" + } + ] + }) + end + end + + context "when feature is on plan but not the privilege" do + let(:input) do + { + subscriptionId: subscription.id, + entitlement: { + featureCode: feature.code, + privileges: [ + {privilegeCode: privilege.code, value: "2"}, + {privilegeCode: privilege2.code, value: "slack"} + ] + } + } + end + + it "add new privilege" do + entitlement = create(:entitlement, plan:, feature:) + create(:entitlement_value, entitlement:, privilege:, value: "2") + + result = subject + + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "2", + "valueType" => "integer" + }, + { + "code" => "reset", + "value" => "slack", + "valueType" => "select" + } + ] + }) + end + end + + context "when removing privileges from the subscription" do + let(:plan_entitlement) { create(:entitlement, plan:, feature:) } + + let(:input) do + { + subscriptionId: subscription.id, + entitlement: { + featureCode: feature.code, + privileges: [ + {privilegeCode: privilege.code, value: "100"} + ] + } + } + end + + before do + create(:entitlement_value, entitlement: plan_entitlement, privilege:, value: "2") + end + + context "when privileges is from the plan" do + it "removes the privileges" do + create(:entitlement_value, entitlement: plan_entitlement, privilege: privilege2, value: "email") + + result = subject + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "100", + "valueType" => "integer" + } + ] + }) + end + end + + context "when privileges is from the plan and is already removed" do + it "removes the privileges" do + create(:entitlement_value, entitlement: plan_entitlement, privilege: privilege2, value: "email") + create(:subscription_feature_removal, subscription:, privilege: privilege2) + + result = subject + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "100", + "valueType" => "integer" + } + ] + }) + end + end + + context "when privileges is from the plan. was removed but is restored" do + let(:input) do + { + subscriptionId: subscription.id, + entitlement: { + featureCode: feature.code, + privileges: [ + {privilegeCode: privilege.code, value: value} + ] + } + } + end + + before do + # create(:entitlement_value, entitlement: plan_entitlement, privilege: privilege2, value: "email") + create(:subscription_feature_removal, subscription:, privilege: privilege, deleted_at: 1.day.ago) + create(:subscription_feature_removal, subscription:, privilege: privilege) + end + + context "when the value is the same as plan" do + let(:value) { "2" } + + it "restores the privilege" do + result = subject + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "2", + "valueType" => "integer" + } + ] + }) + end + end + + context "when the value is different from plan" do + let(:value) { "100" } + + it "restores the privilege with an override" do + result = subject + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "100", + "valueType" => "integer" + } + ] + }) + end + end + end + + context "when privileges is from the subscription (previous override)" do + it "removes the privileges" do + sub_entitlement = create(:entitlement, feature:, plan: nil, subscription:) + create(:entitlement_value, entitlement: sub_entitlement, privilege: privilege2, value: "email") + + result = subject + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "100", + "valueType" => "integer" + } + ] + }) + end + end + end + + context "when feature is on plan" do + it "overrides the value" do + entitlement = create(:entitlement, plan:, feature:) + create(:entitlement_value, entitlement:, privilege:, value: "2") + result = subject + + result_data = result["data"]["createOrUpdateSubscriptionEntitlement"] + expect(result_data).to eq({ + "code" => "seats", + "name" => "SEATS", + "privileges" => [ + { + "code" => "max", + "value" => "100", + "valueType" => "integer" + } + ] + }) + end + end + + context "when feature does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + entitlement: { + featureCode: "not_existing", + privileges: [ + {privilegeCode: privilege.code, value: "100"} + ] + } + } + end + + it "returns not found error" do + expect_graphql_error(result: subject, message: "not_found") + end + end + + context "when privilege does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + entitlement: { + featureCode: feature.code, + privileges: [ + {privilegeCode: "not exist", value: "100"} + ] + } + } + end + + it "returns not found error" do + expect_graphql_error(result: subject, message: "not_found") + end + end + + context "when subscription does not exist" do + let(:input) do + { + subscriptionId: "non-existent-id", + entitlement: {featureCode: feature.code} + } + end + + it "returns not found error" do + expect_graphql_error(result: subject, message: "not_found") + end + end +end diff --git a/spec/graphql/mutations/entitlement/destroy_feature_spec.rb b/spec/graphql/mutations/entitlement/destroy_feature_spec.rb new file mode 100644 index 0000000..74ac938 --- /dev/null +++ b/spec/graphql/mutations/entitlement/destroy_feature_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Entitlement::DestroyFeature, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "features:delete" } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:input) { {id: feature.id} } + let(:query) do + <<-GQL + mutation($input: DestroyFeatureInput!) { + destroyFeature(input: $input) { + id + code + name + description + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "features:delete" + + it "destroys a feature" do + expect { feature }.to change(Entitlement::Feature, :count).by(1) + + result = subject + + result_data = result["data"]["destroyFeature"] + + expect(result_data["id"]).to eq(feature.id) + expect(result_data["code"]).to eq(feature.code) + expect(result_data["name"]).to eq(feature.name) + expect(result_data["description"]).to eq(feature.description) + end + + context "when feature does not exist" do + let(:input) { {id: "non-existent-id"} } + + it "returns not found error for non-existent feature" do + result = subject + + expect(result["errors"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/entitlement/remove_subscription_entitlement_spec.rb b/spec/graphql/mutations/entitlement/remove_subscription_entitlement_spec.rb new file mode 100644 index 0000000..5f57958 --- /dev/null +++ b/spec/graphql/mutations/entitlement/remove_subscription_entitlement_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Entitlement::RemoveSubscriptionEntitlement, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:organization) { create(:organization) } + + let(:feature) { create(:feature, organization:, code: "seats") } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan:) } + let(:privilege) { create(:privilege, feature:, code: "max") } + + let(:query) do + <<~GQL + mutation($input: RemoveSubscriptionEntitlementInput!) { + removeSubscriptionEntitlement(input: $input) { + featureCode + } + } + GQL + end + + let(:input) do + { + subscriptionId: subscription.id, + featureCode: feature.code + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:update" + + context "when feature wasn't available in subscription" do + it "removes the feature" do + result = subject + expect(result["data"]["removeSubscriptionEntitlement"]["featureCode"]).to eq "seats" + + sub_entitlements = Entitlement::SubscriptionEntitlement.for_subscription(subscription) + expect(sub_entitlements.map(&:code)).not_to include("seats") + end + end + + context "when feature belongs to the plan" do + let(:plan_entitlement) { create(:entitlement, feature:, plan:) } + let(:plan_entitlement_value) { create(:entitlement_value, entitlement: plan_entitlement, privilege:, value: "10") } + + before do + plan_entitlement_value + end + + it "creates a FeatureRemoval model" do + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription).map(&:code)).to include("seats") + result = subject + expect(result["data"]["removeSubscriptionEntitlement"]["featureCode"]).to eq "seats" + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription)).to be_empty + + expect(Entitlement::SubscriptionFeatureRemoval.where(subscription:, feature:)).to exist + end + end + + context "when feature is not in plan but belongs to the subscription" do + let(:sub_entitlement) { create(:entitlement, feature:, plan: nil, subscription:) } + let(:sub_entitlement_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege:, value: "99") } + + before do + sub_entitlement_value + end + + it "removes the subscription entitlement" do + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription).map(&:code)).to include("seats") + result = subject + expect(result["data"]["removeSubscriptionEntitlement"]["featureCode"]).to eq "seats" + + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription)).to be_empty + + expect(sub_entitlement_value.reload).to be_discarded + expect(sub_entitlement.reload).to be_discarded + expect(Entitlement::SubscriptionFeatureRemoval.where(subscription:, feature:)).to be_empty + end + end + + context "when feature is in plan and there was subscription override" do + let(:plan_entitlement) { create(:entitlement, feature:, plan:) } + let(:plan_entitlement_value) { create(:entitlement_value, entitlement: plan_entitlement, privilege:, value: "10") } + let(:sub_entitlement) { create(:entitlement, feature:, plan: nil, subscription:) } + let(:sub_entitlement_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege:, value: "99") } + + before do + plan_entitlement_value + sub_entitlement_value + end + + it "removes the subscription entitlement and add a FeatureRemoval" do + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription).map(&:code)).to include("seats") + result = subject + expect(result["data"]["removeSubscriptionEntitlement"]["featureCode"]).to eq "seats" + + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription)).to be_empty + + expect(sub_entitlement_value.reload).to be_discarded + expect(sub_entitlement.reload).to be_discarded + expect(Entitlement::SubscriptionFeatureRemoval.where(subscription:, feature:)).to exist + end + end + + context "when subscription does not exist" do + let(:input) do + { + subscriptionId: "non-existent-id", + featureCode: feature.code + } + end + + it "returns not found error" do + expect_graphql_error(result: subject, message: "not_found") + end + end + + context "when feature does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + featureCode: "not_existing" + } + end + + it "returns not found error" do + expect_graphql_error(result: subject, message: "not_found") + end + end +end diff --git a/spec/graphql/mutations/entitlement/update_feature_spec.rb b/spec/graphql/mutations/entitlement/update_feature_spec.rb new file mode 100644 index 0000000..f6fe5e0 --- /dev/null +++ b/spec/graphql/mutations/entitlement/update_feature_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Entitlement::UpdateFeature, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "features:update" } + let(:organization) { create(:organization) } + let(:feature) do + create(:feature, organization:) + end + + let(:query) do + <<-GQL + mutation($input: UpdateFeatureInput!) { + updateFeature(input: $input) { + id + name + description + code + privileges { code name } + } + } + GQL + end + let(:input) do + { + id: feature.id, + name: "Updated Feature Name", + description: "Updated Feature Description", + privileges: [] + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "features:update" + + it "updates a feature" do + result = subject + + result_data = result["data"]["updateFeature"] + + expect(result_data["name"]).to eq("Updated Feature Name") + expect(result_data["description"]).to eq("Updated Feature Description") + expect(result_data["code"]).to eq(feature.code) + expect(result_data["privileges"]).to be_empty + end + + context "when feature does not exist" do + let(:input) do + { + id: "non-existent-id", + name: "Updated Feature Name", + description: "Updated Feature Description", + privileges: [] + } + end + + it "returns not found error for non-existent feature" do + expect_graphql_error( + result: subject, + message: "not_found" + ) + end + end + + context "with privileges" do + context "when privilege already exists" do + let(:input) do + { + id: feature.id, + name: "Feature Name", + description: "Feature Description", + privileges: [ + {code: "seats", name: "new name"} + ] + } + end + + it "updates the privilege" do + create(:privilege, feature:, code: "seats", name: "Old Name") + + result = subject + + result_data = result["data"]["updateFeature"] + + expect(result_data["privileges"].size).to eq(1) + expect(result_data["privileges"].sole["code"]).to eq("seats") + expect(result_data["privileges"].sole["name"]).to eq("new name") + end + end + + context "when privilege is new" do + let(:new_privilege_code) { "new_privilege" } + let(:new_privilege_name) { "New Privilege" } + let(:input) do + { + id: feature.id, + name: "Updated Feature Name", + description: "Updated Feature Description", + privileges: [ + {code: new_privilege_code, name: new_privilege_name} + ] + } + end + + it "adds new privileges to the feature" do + result = subject + + result_data = result["data"]["updateFeature"] + + expect(result_data["privileges"].size).to eq(1) + expect(result_data["privileges"].sole["code"]).to eq(new_privilege_code) + expect(result_data["privileges"].sole["name"]).to eq(new_privilege_name) + end + end + end +end diff --git a/spec/graphql/mutations/fixed_charges/create_spec.rb b/spec/graphql/mutations/fixed_charges/create_spec.rb new file mode 100644 index 0000000..e5aa284 --- /dev/null +++ b/spec/graphql/mutations/fixed_charges/create_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::FixedCharges::Create, type: :graphql do + let(:required_permission) { "charges:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: FixedChargeCreateInput!) { + createFixedCharge(input: $input) { + id + code + invoiceDisplayName + chargeModel + payInAdvance + prorated + units + properties { + amount + } + addOn { + id + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:create" + + it "creates a fixed charge" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + planId: plan.id, + addOnId: add_on.id, + chargeModel: "standard", + code: "my_fixed_charge", + invoiceDisplayName: "My Fixed Charge", + payInAdvance: false, + prorated: false, + units: "10", + properties: { + amount: "100" + } + } + } + ) + + result_data = result["data"]["createFixedCharge"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq("my_fixed_charge") + expect(result_data["invoiceDisplayName"]).to eq("My Fixed Charge") + expect(result_data["chargeModel"]).to eq("standard") + expect(result_data["payInAdvance"]).to eq(false) + expect(result_data["prorated"]).to eq(false) + expect(result_data["units"]).to eq("10") + expect(result_data["properties"]["amount"]).to eq("100") + expect(result_data["addOn"]["id"]).to eq(add_on.id) + end + + context "when plan does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + planId: "unknown", + addOnId: add_on.id, + chargeModel: "standard", + properties: {amount: "100"} + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/fixed_charges/destroy_spec.rb b/spec/graphql/mutations/fixed_charges/destroy_spec.rb new file mode 100644 index 0000000..82931f6 --- /dev/null +++ b/spec/graphql/mutations/fixed_charges/destroy_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::FixedCharges::Destroy, type: :graphql do + let(:required_permission) { "charges:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + + let(:mutation) do + <<~GQL + mutation($input: DestroyFixedChargeInput!) { + destroyFixedCharge(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:delete" + + it "destroys a fixed charge" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: fixed_charge.id + } + } + ) + + result_data = result["data"]["destroyFixedCharge"] + + expect(result_data["id"]).to eq(fixed_charge.id) + expect(fixed_charge.reload.deleted_at).to be_present + end + + context "with cascade_updates" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, organization:, add_on:, parent: fixed_charge) } + + before do + child_fixed_charge + allow(FixedCharges::DestroyChildrenJob).to receive(:perform_later) + end + + it "cascades the deletion to children" do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: fixed_charge.id, + cascadeUpdates: true + } + } + ) + + expect(FixedCharges::DestroyChildrenJob).to have_received(:perform_later).with(fixed_charge.id) + end + end + + context "when fixed charge does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "unknown" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/fixed_charges/update_spec.rb b/spec/graphql/mutations/fixed_charges/update_spec.rb new file mode 100644 index 0000000..c59725c --- /dev/null +++ b/spec/graphql/mutations/fixed_charges/update_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::FixedCharges::Update, type: :graphql do + let(:required_permission) { "charges:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + + let(:mutation) do + <<~GQL + mutation($input: FixedChargeUpdateInput!) { + updateFixedCharge(input: $input) { + id + code + invoiceDisplayName + chargeModel + units + properties { + amount + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "charges:update" + + it "updates a fixed charge" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: fixed_charge.id, + chargeModel: "standard", + invoiceDisplayName: "Updated Fixed Charge", + units: "25", + properties: { + amount: "200" + } + } + } + ) + + result_data = result["data"]["updateFixedCharge"] + + expect(result_data["id"]).to eq(fixed_charge.id) + expect(result_data["invoiceDisplayName"]).to eq("Updated Fixed Charge") + expect(result_data["units"]).to eq("25") + expect(result_data["properties"]["amount"]).to eq("200") + end + + context "with cascade_updates" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, organization:, add_on:, parent: fixed_charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_fixed_charge + allow(FixedCharges::UpdateChildrenJob).to receive(:perform_later) + end + + it "passes cascade_updates to the service" do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: fixed_charge.id, + chargeModel: "standard", + cascadeUpdates: true, + units: "25", + properties: { + amount: "200" + } + } + } + ) + + expect(FixedCharges::UpdateChildrenJob).to have_received(:perform_later) + end + end + + context "when fixed charge does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "unknown", + invoiceDisplayName: "Updated" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/integration_collection_mappings/create_spec.rb b/spec/graphql/mutations/integration_collection_mappings/create_spec.rb new file mode 100644 index 0000000..29d3603 --- /dev/null +++ b/spec/graphql/mutations/integration_collection_mappings/create_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationCollectionMappings::Create do + subject { execute_query(query:, input:) } + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:netsuite_integration, organization:) } + let(:mapping_type) { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit].sample.to_s } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:external_account_code) { Faker::Barcode.ean } + let(:external_id) { SecureRandom.uuid } + let(:external_name) { Faker::Commerce.department } + + let(:query) do + <<-GQL + mutation($input: CreateIntegrationCollectionMappingInput!) { + createIntegrationCollectionMapping(input: $input) { + id, + integrationId, + mappingType, + externalAccountCode, + externalId, + externalName, + currencies {currencyCode currencyExternalCode} + } + } + GQL + end + let(:input) do + { + integrationId: integration.id, + mappingType: mapping_type, + externalAccountCode: external_account_code, + externalId: external_id, + externalName: external_name, + **(billing_entity_id ? {billingEntityId: billing_entity_id} : {}) + } + end + let(:billing_entity_id) { nil } + + def create_integration_collection_mapping(input:, raw: false) + result = execute_query(query:, input:) + raw ? result : result["data"]["createIntegrationCollectionMapping"] + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "creates an integration collection mapping" do + result = create_integration_collection_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappingType" => mapping_type, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name, + "currencies" => nil + ) + end + + context "with currencies" do + let(:input) do + { + integrationId: integration.id, + mappingType: "currencies", + currencies: [ + {"currencyCode" => "EUR", "currencyExternalCode" => "1000222"} + ] + } + end + + it "updates the mapping" do + result_data = subject["data"]["createIntegrationCollectionMapping"] + expect(result_data["integrationId"]).to eq(integration.id) + expect(result_data["currencies"]).to eq([{"currencyCode" => "EUR", "currencyExternalCode" => "1000222"}]) + end + end + + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:billing_entity_id) { billing_entity.id } + + it "creates an integration collection mapping with billing entity" do + result = create_integration_collection_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappingType" => mapping_type, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name, + "currencies" => nil + ) + end + + context "when billing entity belongs to different organization" do + let(:billing_entity) { create(:billing_entity, organization: create(:organization)) } + + it "returns an error when billing entity belongs to different organization" do + result = create_integration_collection_mapping(input:, raw: true) + + expect_graphql_error(result:, message: "Resource not found") + end + end + end +end diff --git a/spec/graphql/mutations/integration_collection_mappings/destroy_spec.rb b/spec/graphql/mutations/integration_collection_mappings/destroy_spec.rb new file mode 100644 index 0000000..1865afa --- /dev/null +++ b/spec/graphql/mutations/integration_collection_mappings/destroy_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationCollectionMappings::Destroy do + let(:required_permission) { "organization:integrations:update" } + let(:integration_collection_mapping) { create(:netsuite_collection_mapping, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyIntegrationCollectionMappingInput!) { + destroyIntegrationCollectionMapping(input: $input) { id } + } + GQL + end + + before { integration_collection_mapping } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "deletes an integration collection mapping" do + expect do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: integration_collection_mapping.id} + } + ) + end.to change(::IntegrationCollectionMappings::BaseCollectionMapping, :count).by(-1) + end + + context "when integration collection mapping is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: "123456"} + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/integration_collection_mappings/update_spec.rb b/spec/graphql/mutations/integration_collection_mappings/update_spec.rb new file mode 100644 index 0000000..c6399ba --- /dev/null +++ b/spec/graphql/mutations/integration_collection_mappings/update_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationCollectionMappings::Update do + subject { execute_query(query:, input:) } + + let(:required_permission) { "organization:integrations:update" } + let(:integration_collection_mapping) { create(:netsuite_collection_mapping, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:mapping_type) { %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit].sample.to_s } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:external_account_code) { Faker::Barcode.ean } + let(:external_id) { SecureRandom.uuid } + let(:external_name) { Faker::Commerce.department } + + let(:query) do + <<-GQL + mutation($input: UpdateIntegrationCollectionMappingInput!) { + updateIntegrationCollectionMapping(input: $input) { + id, + integrationId, + mappingType, + externalAccountCode, + externalId, + externalName + currencies {currencyCode currencyExternalCode} + } + } + GQL + end + + let(:input) do + original_mapping_type = integration_collection_mapping.mapping_type + different_integration = create(:netsuite_integration, organization:) + different_mapping_type = %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit].reject { |type| type.to_s == original_mapping_type }.sample.to_s + + { + id: integration_collection_mapping.id, + integrationId: different_integration.id, + mappingType: different_mapping_type, + externalAccountCode: external_account_code, + externalId: external_id, + externalName: external_name + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "updates a netsuite integration" do + original_integration_id = integration_collection_mapping.integration_id + original_mapping_type = integration_collection_mapping.mapping_type + + result = subject + + result_data = result["data"]["updateIntegrationCollectionMapping"] + + # Deprecated fields should be ignored - original values should remain + expect(result_data["integrationId"]).to eq(original_integration_id) + expect(result_data["mappingType"]).to eq(original_mapping_type) + # Other fields should be updated normally + expect(result_data["externalAccountCode"]).to eq(external_account_code) + expect(result_data["externalId"]).to eq(external_id) + expect(result_data["externalName"]).to eq(external_name) + end + + context "when currencies" do + let(:integration_collection_mapping) { create(:netsuite_currencies_mapping, integration:) } + + let(:input) do + { + id: integration_collection_mapping.id, + currencies: + } + end + + context "when currency_code is duplicated" do + let(:currencies) do + [ + {currencyCode: "EUR", currencyExternalCode: "1"}, + {currencyCode: "EUR", currencyExternalCode: "2"}, + {currencyCode: "GBP", currencyExternalCode: "3"}, + {currencyCode: "USD", currencyExternalCode: "4"}, + {currencyCode: "USD", currencyExternalCode: "4"} + ] + end + + it "returns a graphql error" do + result = subject + + expect_graphql_error(result:, message: "duplicated_field") + end + end + + context "when currencies is empty" do + let(:currencies) { [] } + + it "returns a graphql error" do + result = subject + + expect_unprocessable_entity(result, details: { + currencies: ["cannot_be_empty"] + }) + end + end + + context "when currencies mapping has an empty value" do + let(:currencies) do + [ + {currencyCode: "EUR", currencyExternalCode: "1"}, + {currencyCode: "USD", currencyExternalCode: ""} + ] + end + + it "returns a graphql error" do + result = subject + + expect_unprocessable_entity(result, details: { + currencies: ["invalid_format"] + }) + end + end + + context "when mapping is valid" do + let(:currencies) do + [ + {"currencyCode" => "EUR", "currencyExternalCode" => "1000222"} + ] + end + + it "updates the mapping" do + result_data = subject["data"]["updateIntegrationCollectionMapping"] + expect(result_data["id"]).to eq(integration_collection_mapping.id) + expect(result_data["currencies"]).to eq(currencies) + end + end + end +end diff --git a/spec/graphql/mutations/integration_items/fetch_accounts_spec.rb b/spec/graphql/mutations/integration_items/fetch_accounts_spec.rb new file mode 100644 index 0000000..4f7b833 --- /dev/null +++ b/spec/graphql/mutations/integration_items/fetch_accounts_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationItems::FetchAccounts do + let(:required_permission) { "organization:integrations:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_item) { create(:integration_item, integration:) } + let(:sync_service) { instance_double(Integrations::Aggregator::SyncService) } + + let(:accounts_response) do + File.read(Rails.root.join("spec/fixtures/integration_aggregator/accounts_response.json")) + end + + let(:mutation) do + <<~GQL + mutation($input: FetchIntegrationAccountsInput!) { + fetchIntegrationAccounts(input: $input) { + collection { externalName, externalAccountCode, externalId } + } + } + GQL + end + + let(:account_ids) do + %w[12ec4c59-ad56-4a4f-93eb-fb0a7740f4e2 6317441d-6547-417c-89e2-6e43ece791d8 80701036-73b5-4468-a4b3-a139262035b4] + end + + before do + allow(Integrations::Aggregator::SyncService).to receive(:call).and_return(BaseResult.new) + + stub_request(:get, "https://api.nango.dev/v1/netsuite/accounts?limit=450") + .to_return(status: 200, body: accounts_response, headers: {}) + + integration_item + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "fetches the integration accounts" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {integrationId: integration.id} + } + ) + + result_data = result["data"]["fetchIntegrationAccounts"] + + ids = result_data["collection"].map { |value| value["externalId"] } + + expect(ids).to eq(account_ids) + expect(integration.integration_items.where(item_type: :account).count).to eq(3) + end +end diff --git a/spec/graphql/mutations/integration_items/fetch_items_spec.rb b/spec/graphql/mutations/integration_items/fetch_items_spec.rb new file mode 100644 index 0000000..08d6bd2 --- /dev/null +++ b/spec/graphql/mutations/integration_items/fetch_items_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationItems::FetchItems do + let(:required_permission) { "organization:integrations:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_item) { create(:integration_item, integration:) } + let(:sync_service) { instance_double(Integrations::Aggregator::SyncService) } + + let(:items_response) do + File.read(Rails.root.join("spec/fixtures/integration_aggregator/items_response.json")) + end + + let(:mutation) do + <<~GQL + mutation($input: FetchIntegrationItemsInput!) { + fetchIntegrationItems(input: $input) { + collection { externalName, externalAccountCode, externalId } + } + } + GQL + end + + before do + allow(Integrations::Aggregator::SyncService).to receive(:call).and_return(true) + + stub_request(:get, "https://api.nango.dev/v1/netsuite/items?limit=450") + .to_return(status: 200, body: items_response, headers: {}) + + integration_item + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "fetches the integration items" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {integrationId: integration.id} + } + ) + + result_data = result["data"]["fetchIntegrationItems"] + + external_ids = result_data["collection"].map { |value| value["externalId"] } + + expect(external_ids).to eq(%w[755 745 753 484 828]) + expect(integration.integration_items.where(item_type: :standard).count).to eq(5) + end +end diff --git a/spec/graphql/mutations/integration_mappings/create_spec.rb b/spec/graphql/mutations/integration_mappings/create_spec.rb new file mode 100644 index 0000000..ae5028e --- /dev/null +++ b/spec/graphql/mutations/integration_mappings/create_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationMappings::Create do + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:netsuite_integration, organization:) } + let(:mappable) { create(:add_on, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:external_account_code) { Faker::Barcode.ean } + let(:external_id) { SecureRandom.uuid } + let(:external_name) { Faker::Commerce.department } + let(:mutation) do + <<-GQL + mutation($input: CreateIntegrationMappingInput!) { + createIntegrationMapping(input: $input) { + id, + integrationId, + mappableId, + mappableType, + billingEntityId, + externalAccountCode, + externalId, + externalName + } + } + GQL + end + let(:input) do + { + integrationId: integration.id, + mappableId: mappable.id, + mappableType: "AddOn", + externalAccountCode: external_account_code, + externalId: external_id, + externalName: external_name, + **(billing_entity_id ? {billingEntityId: billing_entity_id} : {}) + } + end + let(:billing_entity_id) { nil } + + def create_integration_mapping(input:, raw: false) + result = execute_query(query: mutation, input:) + raw ? result : result["data"]["createIntegrationMapping"] + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "creates an integration mapping" do + result = create_integration_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappableId" => mappable.id, + "mappableType" => "AddOn", + "billingEntityId" => nil, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name + ) + end + + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:billing_entity_id) { billing_entity.id } + + it "creates an integration mapping with billing entity" do + result = create_integration_mapping(input:) + + expect(result).to match( + "id" => be_present, + "integrationId" => integration.id, + "mappableId" => mappable.id, + "mappableType" => "AddOn", + "billingEntityId" => billing_entity.id, + "externalAccountCode" => external_account_code, + "externalId" => external_id, + "externalName" => external_name + ) + end + + context "when billing entity belongs to different organization" do + let(:billing_entity) { create(:billing_entity, organization: create(:organization)) } + + it "returns an error when billing entity belongs to different organization" do + result = create_integration_mapping(input:, raw: true) + + expect_graphql_error(result:, message: "Resource not found") + end + end + end +end diff --git a/spec/graphql/mutations/integration_mappings/destroy_spec.rb b/spec/graphql/mutations/integration_mappings/destroy_spec.rb new file mode 100644 index 0000000..b27c0e6 --- /dev/null +++ b/spec/graphql/mutations/integration_mappings/destroy_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationMappings::Destroy do + let(:required_permission) { "organization:integrations:update" } + let(:integration_mapping) { create(:netsuite_mapping, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyIntegrationMappingInput!) { + destroyIntegrationMapping(input: $input) { id } + } + GQL + end + + before { integration_mapping } + + it "deletes an integration mapping" do + expect do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: integration_mapping.id} + } + ) + end.to change(::IntegrationMappings::BaseMapping, :count).by(-1) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "when integration mapping is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: "123456"} + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/integration_mappings/update_spec.rb b/spec/graphql/mutations/integration_mappings/update_spec.rb new file mode 100644 index 0000000..65d67d8 --- /dev/null +++ b/spec/graphql/mutations/integration_mappings/update_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::IntegrationMappings::Update do + let(:required_permission) { "organization:integrations:update" } + let(:integration_mapping) { create(:netsuite_mapping, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:mappable) { integration_mapping.mappable } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:external_account_code) { Faker::Barcode.ean } + let(:external_id) { SecureRandom.uuid } + let(:external_name) { Faker::Commerce.department } + + let(:mutation) do + <<-GQL + mutation($input: UpdateIntegrationMappingInput!) { + updateIntegrationMapping(input: $input) { + id, + integrationId, + mappableId, + mappableType, + externalAccountCode, + externalId, + externalName + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "updates an integration" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration_mapping.id, + integrationId: integration.id, + mappableId: mappable.id, + mappableType: "AddOn", + externalAccountCode: external_account_code, + externalId: external_id, + externalName: external_name + } + } + ) + + result_data = result["data"]["updateIntegrationMapping"] + + expect(result_data["integrationId"]).to eq(integration.id) + expect(result_data["mappableId"]).to eq(mappable.id) + expect(result_data["mappableType"]).to eq("AddOn") + expect(result_data["externalAccountCode"]).to eq(external_account_code) + expect(result_data["externalId"]).to eq(external_id) + expect(result_data["externalName"]).to eq(external_name) + end +end diff --git a/spec/graphql/mutations/integrations/anrok/create_spec.rb b/spec/graphql/mutations/integrations/anrok/create_spec.rb new file mode 100644 index 0000000..d8c609a --- /dev/null +++ b/spec/graphql/mutations/integrations/anrok/create_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Anrok::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:code) { "anrok1" } + let(:name) { "Anrok 1" } + + let(:mutation) do + <<-GQL + mutation($input: CreateAnrokIntegrationInput!) { + createAnrokIntegration(input: $input) { + id, + code, + name, + apiKey, + externalAccountId + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code:, + name:, + apiKey: "123/456/789", + connectionId: "this-is-random-uuid" + } + } + ) + end + + it "creates an anrok integration" do + result_data = result["data"]["createAnrokIntegration"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["apiKey"]).to eq("••••••••…789") + expect(result_data["externalAccountId"]).to eq("123") + expect(Integrations::AnrokIntegration.order(:created_at).last.connection_id).to eq("this-is-random-uuid") + end + + it_behaves_like "produces a security log", "integration.created" + end +end diff --git a/spec/graphql/mutations/integrations/anrok/update_spec.rb b/spec/graphql/mutations/integrations/anrok/update_spec.rb new file mode 100644 index 0000000..c3ac231 --- /dev/null +++ b/spec/graphql/mutations/integrations/anrok/update_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Anrok::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:anrok_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:code) { "anrok1" } + let(:name) { "Anrok 1" } + let(:api_key) { "123456789" } + + let(:mutation) do + <<-GQL + mutation($input: UpdateAnrokIntegrationInput!) { + updateAnrokIntegration(input: $input) { + id, + code, + name, + apiKey + } + } + GQL + end + + before do + integration + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration.id, + name:, + code:, + apiKey: api_key + } + } + ) + end + + it "updates an anrok integration" do + result_data = result["data"]["updateAnrokIntegration"] + + expect(result_data["name"]).to eq(name) + expect(result_data["code"]).to eq(code) + expect(result_data["apiKey"]).to eq("••••••••…789") + end + + it_behaves_like "produces a security log", "integration.updated" + end +end diff --git a/spec/graphql/mutations/integrations/avalara/create_spec.rb b/spec/graphql/mutations/integrations/avalara/create_spec.rb new file mode 100644 index 0000000..a75c6cc --- /dev/null +++ b/spec/graphql/mutations/integrations/avalara/create_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Avalara::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:code) { "avalara1" } + let(:name) { "Avalara 1" } + + let(:mutation) do + <<-GQL + mutation($input: CreateAvalaraIntegrationInput!) { + createAvalaraIntegration(input: $input) { + id, + code, + name, + accountId, + licenseKey, + companyCode + } + } + GQL + end + + before { membership.organization.update!(premium_integrations: ["avalara"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code:, + name:, + accountId: "account-id1", + licenseKey: "license-key12", + connectionId: "this-is-random-uuid", + companyCode: "company-code1" + } + } + ) + end + + it "creates an avalara integration" do + result_data = result["data"]["createAvalaraIntegration"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["licenseKey"]).to eq("••••••••…y12") + expect(result_data["accountId"]).to eq("account-id1") + expect(result_data["companyCode"]).to eq("company-code1") + expect(Integrations::AvalaraIntegration.order(:created_at).last.connection_id).to eq("this-is-random-uuid") + end + + it_behaves_like "produces a security log", "integration.created" + end +end diff --git a/spec/graphql/mutations/integrations/avalara/update_spec.rb b/spec/graphql/mutations/integrations/avalara/update_spec.rb new file mode 100644 index 0000000..c03402d --- /dev/null +++ b/spec/graphql/mutations/integrations/avalara/update_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Avalara::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:avalara_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:code) { "avalara1" } + let(:name) { "Avalara 1" } + let(:account_id) { "acc-id-1" } + let(:license_key) { "123456789" } + + let(:mutation) do + <<-GQL + mutation($input: UpdateAvalaraIntegrationInput!) { + updateAvalaraIntegration(input: $input) { + id, + code, + name, + accountId, + licenseKey + } + } + GQL + end + + before do + integration + membership.organization.update!(premium_integrations: ["avalara"]) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration.id, + name:, + code:, + accountId: account_id, + licenseKey: license_key + } + } + ) + end + + it "updates an avalara integration" do + result_data = result["data"]["updateAvalaraIntegration"] + + expect(result_data["name"]).to eq(name) + expect(result_data["code"]).to eq(code) + end + + it_behaves_like "produces a security log", "integration.updated" + end +end diff --git a/spec/graphql/mutations/integrations/destroy_spec.rb b/spec/graphql/mutations/integrations/destroy_spec.rb new file mode 100644 index 0000000..69a3d70 --- /dev/null +++ b/spec/graphql/mutations/integrations/destroy_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Destroy do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: integration.id}} + ) + end + + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:netsuite_integration, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyIntegrationInput!) { + destroyIntegration(input: $input) { id } + } + GQL + end + + before { integration } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:delete" + + it "deletes an integration" do + expect { result }.to change(::Integrations::BaseIntegration, :count).by(-1) + end + + it_behaves_like "produces a security log", "integration.deleted" do + before { result } + end + + context "when okta integration", :premium do + let(:integration) { create(:okta_integration, organization:) } + + before do + organization.enable_okta_authentication! + + allow(::Integrations::Okta::DestroyService).to receive(:call).with(integration:).and_call_original + end + + it "deletes calling the okta destroy service" do + expect { result }.to change(::Integrations::BaseIntegration, :count).by(-1) + expect(::Integrations::Okta::DestroyService).to have_received(:call).with(integration:) + end + + it_behaves_like "produces a security log", "integration.deleted" do + before { result } + end + end +end diff --git a/spec/graphql/mutations/integrations/fetch_draft_invoice_taxes_spec.rb b/spec/graphql/mutations/integrations/fetch_draft_invoice_taxes_spec.rb new file mode 100644 index 0000000..46d0b22 --- /dev/null +++ b/spec/graphql/mutations/integrations/fetch_draft_invoice_taxes_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::FetchDraftInvoiceTaxes do + let(:required_permission) { "invoices:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:currency) { "EUR" } + let(:customer) { create(:customer, organization:) } + let(:add_on_first) { create(:add_on, organization:) } + let(:add_on_second) { create(:add_on, amount_cents: 400, organization:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/draft_invoices" } + let(:current_time) { DateTime.new(2023, 7, 19, 12, 12) } + let(:fees) do + [ + { + addOnId: add_on_first.id, + unitAmountCents: 1200, + units: 2, + description: "desc-123", + invoiceDisplayName: "fee-123", + fromDatetime: current_time.utc.iso8601(3), + toDatetime: current_time.utc.iso8601(3) + }, + { + addOnId: add_on_second.id, + unitAmountCents: 400, + units: 1, + description: "desc-12345", + invoiceDisplayName: "fee-12345", + fromDatetime: current_time.utc.iso8601(3), + toDatetime: current_time.utc.iso8601(3) + } + ] + end + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on_first.id, + settings: {external_id: "m1", external_account_code: "m11", external_name: ""} + ) + end + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(path) + end + let(:mutation) do + <<-GQL + mutation($input: FetchDraftInvoiceTaxesInput!) { + fetchDraftInvoiceTaxes(input: $input) { + collection { + itemId + itemCode + amountCents + taxAmountCents + taxBreakdown { + name + rate + taxAmount + type + enumedTaxCode + } + } + } + } + GQL + end + + before do + integration_customer + integration_collection_mapping1 + integration_mapping_add_on + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:create" + + it "fetches tax results" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + currency:, + fees: + } + } + ) + + result_data = result["data"]["fetchDraftInvoiceTaxes"]["collection"] + + fee = result_data.first + + expect(fee["itemId"]).to eq("lago_fee_id") + expect(fee["itemCode"]).to eq("lago_default_b2b") + expect(fee["amountCents"]).to eq("9900") + expect(fee["taxAmountCents"]).to eq("990") + + breakdown1 = fee["taxBreakdown"].first + + expect(breakdown1["name"]).to eq("GST/HST") + expect(breakdown1["rate"]).to eq(0.1) + expect(breakdown1["taxAmount"]).to eq("990") + expect(breakdown1["type"]).to eq("tax_exempt") + expect(breakdown1["enumedTaxCode"]).to eq(nil) + + breakdown2 = fee["taxBreakdown"].last + + expect(breakdown2["name"]).to eq("Reverse charge") + expect(breakdown2["rate"]).to eq(0.0) + expect(breakdown2["taxAmount"]).to eq("0") + expect(breakdown2["type"]).to eq("exempt") + expect(breakdown2["enumedTaxCode"]).to eq("reverse_charge") + end + + context "when there is tax error" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "returns validation error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + currency:, + fees: + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(422) + expect(response["details"]["taxError"]).to include("taxDateTooFarInFuture") + end + end +end diff --git a/spec/graphql/mutations/integrations/hubspot/create_spec.rb b/spec/graphql/mutations/integrations/hubspot/create_spec.rb new file mode 100644 index 0000000..5e8f757 --- /dev/null +++ b/spec/graphql/mutations/integrations/hubspot/create_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Hubspot::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:code) { "hubspot1" } + let(:name) { "Hubspot 1" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: CreateHubspotIntegrationInput!) { + createHubspotIntegration(input: $input) { + id, + code, + name, + connectionId, + defaultTargetedObject, + syncInvoices, + syncSubscriptions + } + } + GQL + end + + before { membership.organization.update!(premium_integrations: ["hubspot"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code:, + name:, + connectionId: "this-is-random-uuid", + defaultTargetedObject: "companies" + } + } + ) + end + + it "creates a hubspot integration" do + result_data = result["data"]["createHubspotIntegration"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + end + + it_behaves_like "produces a security log", "integration.created" + end +end diff --git a/spec/graphql/mutations/integrations/hubspot/sync_invoice_spec.rb b/spec/graphql/mutations/integrations/hubspot/sync_invoice_spec.rb new file mode 100644 index 0000000..32ff120 --- /dev/null +++ b/spec/graphql/mutations/integrations/hubspot/sync_invoice_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Hubspot::SyncInvoice do + subject(:execute_graphql_call) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {invoiceId: invoice.id} + } + ) + end + + let(:required_permission) { "organization:integrations:update" } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:integration_customer) { create(:hubspot_customer, customer:, integration:) } + let(:integration) { create(:hubspot_integration, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: SyncHubspotIntegrationInvoiceInput!) { + syncHubspotIntegrationInvoice(input: $input) { invoiceId } + } + GQL + end + + let(:service) { instance_double(Integrations::Aggregator::Invoices::Hubspot::CreateService) } + + let(:result) do + r = BaseService::Result.new + r.invoice_id = invoice.id + r + end + + before do + integration_customer + allow(Integrations::Aggregator::Invoices::Hubspot::CreateService).to receive(:new).and_return(service) + allow(service).to receive(:call_async).and_return(result) + execute_graphql_call + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "syncs an invoice" do + expect(::Integrations::Aggregator::Invoices::Hubspot::CreateService).to have_received(:new).with(invoice:) + expect(service).to have_received(:call_async) + end +end diff --git a/spec/graphql/mutations/integrations/hubspot/update_spec.rb b/spec/graphql/mutations/integrations/hubspot/update_spec.rb new file mode 100644 index 0000000..bd62d3a --- /dev/null +++ b/spec/graphql/mutations/integrations/hubspot/update_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Hubspot::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:hubspot_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:code) { "hubspot1" } + let(:name) { "Hubspot 1" } + + let(:mutation) do + <<-GQL + mutation($input: UpdateHubspotIntegrationInput!) { + updateHubspotIntegration(input: $input) { + id, + code, + name, + connectionId, + defaultTargetedObject, + syncInvoices, + syncSubscriptions + } + } + GQL + end + + before do + integration + membership.organization.update!(premium_integrations: ["hubspot"]) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration.id, + name:, + code: + } + } + ) + end + + it "updates a hubspot integration" do + result_data = result["data"]["updateHubspotIntegration"] + + expect(result_data["name"]).to eq(name) + expect(result_data["code"]).to eq(code) + end + + it_behaves_like "produces a security log", "integration.updated" + end +end diff --git a/spec/graphql/mutations/integrations/netsuite/create_spec.rb b/spec/graphql/mutations/integrations/netsuite/create_spec.rb new file mode 100644 index 0000000..bbc490b --- /dev/null +++ b/spec/graphql/mutations/integrations/netsuite/create_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Netsuite::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:code) { "netsuite1" } + let(:name) { "Netsuite 1" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: CreateNetsuiteIntegrationInput!) { + createNetsuiteIntegration(input: $input) { + id, + code, + name, + clientId, + clientSecret, + syncInvoices, + syncCreditNotes, + syncPayments, + scriptEndpointUrl, + tokenId, + tokenSecret + } + } + GQL + end + + before { membership.organization.update!(premium_integrations: ["netsuite"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code:, + name:, + scriptEndpointUrl: script_endpoint_url, + accountId: "012", + clientId: "123", + clientSecret: "456", + tokenId: "xyz", + tokenSecret: "zyx", + connectionId: "this-is-random-uuid" + } + } + ) + end + + it "creates a netsuite integration" do + result_data = result["data"]["createNetsuiteIntegration"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["tokenId"]).to eq("xyz") + expect(result_data["tokenSecret"]).to eq("••••••••…zyx") + expect(result_data["scriptEndpointUrl"]).to eq(script_endpoint_url) + end + + it_behaves_like "produces a security log", "integration.created" + end +end diff --git a/spec/graphql/mutations/integrations/netsuite/update_spec.rb b/spec/graphql/mutations/integrations/netsuite/update_spec.rb new file mode 100644 index 0000000..9b2927b --- /dev/null +++ b/spec/graphql/mutations/integrations/netsuite/update_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Netsuite::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:code) { "netsuite1" } + let(:name) { "Netsuite 1" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: UpdateNetsuiteIntegrationInput!) { + updateNetsuiteIntegration(input: $input) { + id, + code, + name, + clientId, + clientSecret, + syncInvoices, + syncCreditNotes, + syncPayments, + scriptEndpointUrl + } + } + GQL + end + + before do + integration + membership.organization.update!(premium_integrations: ["netsuite"]) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration.id, + name:, + code:, + scriptEndpointUrl: script_endpoint_url + } + } + ) + end + + it "updates a netsuite integration" do + result_data = result["data"]["updateNetsuiteIntegration"] + + expect(result_data["name"]).to eq(name) + expect(result_data["code"]).to eq(code) + expect(result_data["scriptEndpointUrl"]).to eq(script_endpoint_url) + end + + it_behaves_like "produces a security log", "integration.updated" + end +end diff --git a/spec/graphql/mutations/integrations/okta/create_spec.rb b/spec/graphql/mutations/integrations/okta/create_spec.rb new file mode 100644 index 0000000..799f7a7 --- /dev/null +++ b/spec/graphql/mutations/integrations/okta/create_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Okta::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + + let(:mutation) do + <<-GQL + mutation($input: CreateOktaIntegrationInput!) { + createOktaIntegration(input: $input) { + id, + name, + code, + clientId, + clientSecret, + domain, + } + } + GQL + end + + before { membership.organization.update!(premium_integrations: ["okta"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + clientId: "123", + clientSecret: "456", + domain: "foo.bar", + organizationName: "Foobar" + } + } + ) + end + + it "creates an okta integration" do + result_data = result["data"]["createOktaIntegration"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq("okta") + expect(result_data["name"]).to eq("Okta Integration") + end + + it_behaves_like "produces a security log", "integration.created" + end +end diff --git a/spec/graphql/mutations/integrations/okta/update_spec.rb b/spec/graphql/mutations/integrations/okta/update_spec.rb new file mode 100644 index 0000000..c54af6a --- /dev/null +++ b/spec/graphql/mutations/integrations/okta/update_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Okta::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:okta_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + let(:mutation) do + <<-GQL + mutation($input: UpdateOktaIntegrationInput!) { + updateOktaIntegration(input: $input) { + id, + code, + name, + clientId, + clientSecret, + domain, + organizationName, + } + } + GQL + end + + before do + integration + membership.organization.update!(premium_integrations: ["okta"]) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration.id, + domain: "foo.bar", + organizationName: "Footest" + } + } + ) + end + + it "updates an okta integration" do + result_data = result["data"]["updateOktaIntegration"] + + expect(result_data["domain"]).to eq("foo.bar") + expect(result_data["organizationName"]).to eq("Footest") + end + + it_behaves_like "produces a security log", "integration.updated" + end +end diff --git a/spec/graphql/mutations/integrations/salesforce/create_spec.rb b/spec/graphql/mutations/integrations/salesforce/create_spec.rb new file mode 100644 index 0000000..7228c47 --- /dev/null +++ b/spec/graphql/mutations/integrations/salesforce/create_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Salesforce::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:name) { "Salesforce 1" } + let(:code) { "salesforce_test" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: CreateSalesforceIntegrationInput!) { + createSalesforceIntegration(input: $input) { + id, + code, + name, + instanceId + } + } + GQL + end + + before { membership.organization.update!(premium_integrations: ["salesforce"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name:, + code:, + instanceId: "this-is-random-uuid" + } + } + ) + end + + it "creates a salesforce integration" do + result_data = result["data"]["createSalesforceIntegration"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + end + + it_behaves_like "produces a security log", "integration.created" + end +end diff --git a/spec/graphql/mutations/integrations/salesforce/sync_invoice_spec.rb b/spec/graphql/mutations/integrations/salesforce/sync_invoice_spec.rb new file mode 100644 index 0000000..1da3b36 --- /dev/null +++ b/spec/graphql/mutations/integrations/salesforce/sync_invoice_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Salesforce::SyncInvoice do + subject(:execute_graphql_call) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {invoiceId: invoice.id} + } + ) + end + + let(:required_permission) { "organization:integrations:update" } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + let(:mutation) do + <<-GQL + mutation($input: SyncSalesforceInvoiceInput!) { + syncSalesforceInvoice(input: $input) { invoiceId } + } + GQL + end + + before do + allow(SendWebhookJob).to receive(:perform_later) + execute_graphql_call + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "sends resync invoice webhook" do + expect(SendWebhookJob).to have_received(:perform_later).with("invoice.resynced", invoice) + end +end diff --git a/spec/graphql/mutations/integrations/salesforce/update_spec.rb b/spec/graphql/mutations/integrations/salesforce/update_spec.rb new file mode 100644 index 0000000..c1577b3 --- /dev/null +++ b/spec/graphql/mutations/integrations/salesforce/update_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Salesforce::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:salesforce_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:name) { "Salesforce 1" } + let(:code) { "salesforce_work" } + let(:instance_id) { "salesforce_link" } + + let(:mutation) do + <<-GQL + mutation($input: UpdateSalesforceIntegrationInput!) { + updateSalesforceIntegration(input: $input) { + id, + code, + name, + instanceId + } + } + GQL + end + + before do + integration + membership.organization.update!(premium_integrations: ["salesforce"]) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration.id, + name:, + code:, + instanceId: instance_id + } + } + ) + end + + it "updates a salesforce integration" do + result_data = result["data"]["updateSalesforceIntegration"] + + expect(result_data["name"]).to eq(name) + expect(result_data["code"]).to eq(code) + expect(result_data["instanceId"]).to eq(instance_id) + end + + it_behaves_like "produces a security log", "integration.updated" + end +end diff --git a/spec/graphql/mutations/integrations/sync_credit_note_spec.rb b/spec/graphql/mutations/integrations/sync_credit_note_spec.rb new file mode 100644 index 0000000..3644214 --- /dev/null +++ b/spec/graphql/mutations/integrations/sync_credit_note_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::SyncCreditNote do + subject(:execute_graphql_call) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {creditNoteId: credit_note.id} + } + ) + end + + let(:required_permission) { "organization:integrations:update" } + let(:credit_note) { create(:credit_note, customer:, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:integration_customer) { create(:netsuite_customer, customer:, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: SyncIntegrationCreditNoteInput!) { + syncIntegrationCreditNote(input: $input) { creditNoteId } + } + GQL + end + + let(:service) { instance_double(Integrations::Aggregator::CreditNotes::CreateService) } + + let(:result) do + r = BaseService::Result.new + r.credit_note_id = credit_note.id + r + end + + before do + integration_customer + allow(Integrations::Aggregator::CreditNotes::CreateService).to receive(:new).and_return(service) + allow(service).to receive(:call_async).and_return(result) + execute_graphql_call + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "syncs a credit note" do + expect(::Integrations::Aggregator::CreditNotes::CreateService).to have_received(:new).with(credit_note:) + expect(service).to have_received(:call_async) + end +end diff --git a/spec/graphql/mutations/integrations/sync_invoice_spec.rb b/spec/graphql/mutations/integrations/sync_invoice_spec.rb new file mode 100644 index 0000000..5ea3b72 --- /dev/null +++ b/spec/graphql/mutations/integrations/sync_invoice_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::SyncInvoice do + subject(:execute_graphql_call) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {invoiceId: invoice.id} + } + ) + end + + let(:required_permission) { "organization:integrations:update" } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:integration_customer) { create(:netsuite_customer, customer:, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: SyncIntegrationInvoiceInput!) { + syncIntegrationInvoice(input: $input) { invoiceId } + } + GQL + end + + let(:service) { instance_double(Integrations::Aggregator::Invoices::CreateService) } + + let(:result) do + r = BaseService::Result.new + r.invoice_id = invoice.id + r + end + + before do + integration_customer + allow(Integrations::Aggregator::Invoices::CreateService).to receive(:new).and_return(service) + allow(service).to receive(:call_async).and_return(result) + execute_graphql_call + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "syncs an invoice" do + expect(::Integrations::Aggregator::Invoices::CreateService).to have_received(:new).with(invoice:) + expect(service).to have_received(:call_async) + end +end diff --git a/spec/graphql/mutations/integrations/xero/create_spec.rb b/spec/graphql/mutations/integrations/xero/create_spec.rb new file mode 100644 index 0000000..ee58d29 --- /dev/null +++ b/spec/graphql/mutations/integrations/xero/create_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Xero::Create, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:code) { "xero1" } + let(:name) { "Xero 1" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: CreateXeroIntegrationInput!) { + createXeroIntegration(input: $input) { + id, + code, + name, + syncInvoices, + syncCreditNotes, + syncPayments + } + } + GQL + end + + before { membership.organization.update!(premium_integrations: ["xero"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code:, + name:, + connectionId: "this-is-random-uuid" + } + } + ) + end + + it "creates a xero integration" do + result_data = result["data"]["createXeroIntegration"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + end + + it_behaves_like "produces a security log", "integration.created" + end +end diff --git a/spec/graphql/mutations/integrations/xero/update_spec.rb b/spec/graphql/mutations/integrations/xero/update_spec.rb new file mode 100644 index 0000000..5ca16c0 --- /dev/null +++ b/spec/graphql/mutations/integrations/xero/update_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Integrations::Xero::Update, :premium do + include_context "with mocked security logger" + + let(:required_permission) { "organization:integrations:update" } + let(:integration) { create(:xero_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:code) { "xero1" } + let(:name) { "Xero 1" } + + let(:mutation) do + <<-GQL + mutation($input: UpdateXeroIntegrationInput!) { + updateXeroIntegration(input: $input) { + id, + code, + name, + syncInvoices, + syncCreditNotes, + syncPayments + } + } + GQL + end + + before do + integration + membership.organization.update!(premium_integrations: ["xero"]) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: integration.id, + name:, + code: + } + } + ) + end + + it "updates a xero integration" do + result_data = result["data"]["updateXeroIntegration"] + + expect(result_data["name"]).to eq(name) + expect(result_data["code"]).to eq(code) + end + + it_behaves_like "produces a security log", "integration.updated" + end +end diff --git a/spec/graphql/mutations/invites/accept_spec.rb b/spec/graphql/mutations/invites/accept_spec.rb new file mode 100644 index 0000000..c1692bd --- /dev/null +++ b/spec/graphql/mutations/invites/accept_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invites::Accept do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:password) { Faker::Internet.password } + + let(:mutation) do + <<~GQL + mutation($input: AcceptInviteInput!) { + acceptInvite(input: $input) { + token + user { + id + email + } + } + } + GQL + end + + describe "Invite revoke mutation" do + context "with a new user" do + let(:invite) { create(:invite, organization:) } + + it "accepts the invite" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: mutation, + variables: { + input: { + email: invite.email, + password:, + token: invite.token + } + } + ) + + data = result["data"]["acceptInvite"] + + expect(data["user"]["email"]).to eq(invite.email) + expect(data["token"]).to be_present + + expect(Auth::TokenService.decode(token: data["token"])).to include("login_method" => "email_password") + end + end + + context "when invite is revoked" do + let(:invite) { create(:invite, organization:, status: :revoked) } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: mutation, + variables: { + input: { + email: invite.email, + password:, + token: invite.token + } + } + ) + + expect(result["errors"].first["extensions"]["status"]).to eq(404) + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["invite"]).to eq(["not_found"]) + end + end + + context "when invite is already accepted" do + let(:invite) { create(:invite, organization:, status: :accepted) } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: mutation, + variables: { + input: { + email: invite.email, + password:, + token: invite.token + } + } + ) + + expect(result["errors"].first["extensions"]["status"]).to eq(404) + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["invite"]).to eq(["not_found"]) + end + end + end +end diff --git a/spec/graphql/mutations/invites/create_spec.rb b/spec/graphql/mutations/invites/create_spec.rb new file mode 100644 index 0000000..3f4b9eb --- /dev/null +++ b/spec/graphql/mutations/invites/create_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invites::Create do + include_context "with mocked security logger" + + let(:required_permission) { "organization:members:create" } + let(:membership) { create(:membership) } + let(:revoked_membership) do + create( + :membership, + organization: membership.organization, + status: :revoked + ) + end + let(:organization) { membership.organization } + let(:email) { Faker::Internet.email } + let(:roles) { %w[finance] } + + let(:mutation) do + <<~GQL + mutation($input: CreateInviteInput!) { + createInvite(input: $input) { + id + token + email + roles + } + } + GQL + end + + before { create(:role, :finance) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:members:create" + + context "when creating an invite for a new user" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + email:, + roles: + } + } + ) + end + + it "creates an invite" do + data = result["data"]["createInvite"] + + expect(data["email"]).to eq(email) + expect(data["roles"]).to eq(roles) + expect(data["token"]).to be_present + end + + it_behaves_like "produces a security log", "user.invited" do + before { result } + end + end + + context "when creating an invite with admin role" do + it "creates an invite with admin role when acting user is admin" do + admin_role = create(:role, :admin) + create(:membership_role, membership:, role: admin_role) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + email:, + roles: ["admin"] + } + } + ) + + data = result["data"]["createInvite"] + + expect(data["email"]).to eq(email) + expect(data["roles"]).to eq(["admin"]) + end + + it "prevents non-admin from creating an invite with admin role" do + create(:role, :admin) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + email:, + roles: ["admin"] + } + } + ) + + expect_forbidden_error(result) + error = result["errors"].first + expect(error["extensions"]["code"]).to eq("cannot_grant_admin") + end + end + + context "when creating an invite with custom role" do + it "creates an invite" do + create(:role, code: "developer", name: "Developer", organization:, permissions: %w[organization:view]) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + email:, + roles: ["developer"] + } + } + ) + + data = result["data"]["createInvite"] + + expect(data["email"]).to eq(email) + expect(data["roles"]).to eq(["developer"]) + end + end + + context "when creating an invite for a revoked user" do + it "creates an invite" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + email: revoked_membership.user.email, + roles: + } + } + ) + + data = result["data"]["createInvite"] + + expect(data["email"]).to eq(revoked_membership.user.email) + expect(data["token"]).to be_present + end + end + + context "when invite already exists" do + it "returns an error" do + create(:invite, email:, recipient: membership, organization: membership.organization) + + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + email:, + roles: + } + } + ) + + expect(result["errors"].first["extensions"]["status"]).to eq(422) + expect(result["errors"].first["extensions"]["code"]).to eq("unprocessable_entity") + expect(result["errors"].first["extensions"]["details"]["invite"]).to eq(["invite_already_exists"]) + end + end + + context "when email already attached to a user of the organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + email: membership.user.email, + roles: + } + } + ) + + expect(result["errors"].first["extensions"]["status"]).to eq(422) + expect(result["errors"].first["extensions"]["code"]).to eq("unprocessable_entity") + expect(result["errors"].first["extensions"]["details"]["email"]).to eq(["email_already_used"]) + end + end +end diff --git a/spec/graphql/mutations/invites/revoke_spec.rb b/spec/graphql/mutations/invites/revoke_spec.rb new file mode 100644 index 0000000..89dfcf5 --- /dev/null +++ b/spec/graphql/mutations/invites/revoke_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invites::Revoke do + let(:required_permission) { "organization:members:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { membership.user } + + let(:mutation) do + <<-GQL + mutation($input: RevokeInviteInput!) { + revokeInvite(input: $input) { + id + status + revokedAt + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:members:delete" + + describe "Invite revoke mutation" do + context "with an existing invite" do + let(:invite) { create(:invite, organization:) } + + it "returns the revoked invite" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invite.id} + } + ) + + data = result["data"]["revokeInvite"] + + expect(data["id"]).to eq(invite.id) + expect(data["status"]).to eq("revoked") + expect(data["revokedAt"]).to be_present + end + end + + context "when the invite accepted" do + let(:invite) { create(:invite, organization:, status: :accepted) } + + it "returns an error" do + result = execute_graphql( + current_organization: organization, + permissions: required_permission, + current_user: user, + query: mutation, + variables: { + input: {id: invite.id} + } + ) + + expect(result["errors"].first["message"]).to eq("Resource not found") + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["invite"]).to eq(["not_found"]) + end + end + end +end diff --git a/spec/graphql/mutations/invites/update_spec.rb b/spec/graphql/mutations/invites/update_spec.rb new file mode 100644 index 0000000..a1ab275 --- /dev/null +++ b/spec/graphql/mutations/invites/update_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invites::Update do + let(:required_permission) { "organization:members:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { membership.user } + + let(:mutation) do + <<-GQL + mutation($input: UpdateInviteInput!) { + updateInvite(input: $input) { + id + roles + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:members:update" + + describe "Invite update mutation" do + context "with an existing invite" do + let(:invite) { create(:invite, organization:, roles: %w[admin]) } + + before { create(:role, :finance) } + + it "returns the updated invite" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invite.id, + roles: %w[finance] + } + } + ) + + data = result["data"]["updateInvite"] + + expect(data["id"]).to eq(invite.id) + expect(data["roles"]).to eq(%w[finance]) + end + end + + context "when non-admin sets admin role on invite" do + let(:invite) { create(:invite, organization:, roles: %w[finance]) } + + before { create(:role, :admin) } + + it "returns an error" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invite.id, + roles: %w[admin] + } + } + ) + + expect_forbidden_error(result) + error = result["errors"].first + expect(error["extensions"]["code"]).to eq("cannot_grant_admin") + end + end + + context "when the invite accepted" do + let(:invite) { create(:invite, organization:, status: :accepted) } + + it "returns an error" do + result = execute_graphql( + current_organization: organization, + permissions: required_permission, + current_user: user, + query: mutation, + variables: { + input: {id: invite.id, roles: %w[finance]} + } + ) + + expect(result["errors"].first["message"]).to eq("Resource not found") + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["invite"]).to eq(["not_found"]) + end + end + end +end diff --git a/spec/graphql/mutations/invoice_custom_sections/create_spec.rb b/spec/graphql/mutations/invoice_custom_sections/create_spec.rb new file mode 100644 index 0000000..b5ded31 --- /dev/null +++ b/spec/graphql/mutations/invoice_custom_sections/create_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::InvoiceCustomSections::Create do + let(:required_permission) { "invoice_custom_sections:create" } + let(:membership) { create(:membership) } + + let(:mutation) do + <<~GQL + mutation($input: CreateInvoiceCustomSectionInput!) { + createInvoiceCustomSection(input: $input) { + id, + code, + name, + description, + details, + displayName + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoice_custom_sections:create" + + it "creates a invoice_custom_section" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code: "section_code", + name: "First Invoice Custom Section", + description: "this invoice custom section will be added in the PDF", + details: "This is the exact information shown in the invoice", + displayName: "Section name displayed in the invoice" + } + } + ) + + result_data = result["data"]["createInvoiceCustomSection"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("First Invoice Custom Section") + expect(result_data["displayName"]).to eq("Section name displayed in the invoice") + expect(result_data["code"]).to eq("section_code") + expect(result_data["description"]).to eq("this invoice custom section will be added in the PDF") + expect(result_data["details"]).to eq("This is the exact information shown in the invoice") + end + + context "when fail to create invoice_custom_section" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + code: nil, + name: "First Invoice Custom Section", + description: "this invoice custom section will be added in the PDF", + details: "This is the exact information shown in the invoice", + displayName: "Section name displayed in the invoice" + } + } + ) + + expect(result["errors"]).to be_present + expect(result["errors"].first["message"]).to include("invalid value for code") + end + end +end diff --git a/spec/graphql/mutations/invoice_custom_sections/destroy_spec.rb b/spec/graphql/mutations/invoice_custom_sections/destroy_spec.rb new file mode 100644 index 0000000..adcab5e --- /dev/null +++ b/spec/graphql/mutations/invoice_custom_sections/destroy_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::InvoiceCustomSections::Destroy do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: {id: invoice_custom_section.id}} + ) + end + + let(:query) do + <<-GQL + mutation($input: DestroyInvoiceCustomSectionInput!) { + destroyInvoiceCustomSection(input: $input) { id } + } + GQL + end + + let(:required_permission) { "invoice_custom_sections:delete" } + let(:membership) { create(:membership) } + let(:invoice_custom_section) { create(:invoice_custom_section, organization: membership.organization) } + + before { invoice_custom_section } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoice_custom_sections:delete" + + context "when invoice custom section with such ID exists in the current organization" do + it "destroys the invoice custom section" do + expect { result }.to change(InvoiceCustomSection, :count).from(1).to(0) + end + end + + context "when invoice_custom_section with such ID does not exist in the current organization" do + let(:invoice_custom_section) { create(:invoice_custom_section) } + + it "does not delete the invoice_custom_section" do + expect { result }.not_to change(InvoiceCustomSection, :count) + end + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/mutations/invoice_custom_sections/update_spec.rb b/spec/graphql/mutations/invoice_custom_sections/update_spec.rb new file mode 100644 index 0000000..aa60a4a --- /dev/null +++ b/spec/graphql/mutations/invoice_custom_sections/update_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::InvoiceCustomSections::Update do + let(:required_permission) { "invoice_custom_sections:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:mutation) do + <<~GQL + mutation($input: UpdateInvoiceCustomSectionInput!) { + updateInvoiceCustomSection(input: $input) { + id, + code, + name, + description, + details, + displayName + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoice_custom_sections:update" + + context "when there is a invoice_custom_section" do + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + + before { invoice_custom_section } + + it "updates a invoice_custom_section" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invoice_custom_section.id, + name: "First Invoice Custom Section", + description: "this invoice custom section will be added in the PDF", + details: "This is the exact information shown in the invoice", + displayName: "Section name displayed in the invoice" + } + } + ) + + result_data = result["data"]["updateInvoiceCustomSection"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("First Invoice Custom Section") + expect(result_data["displayName"]).to eq("Section name displayed in the invoice") + expect(result_data["code"]).to eq(invoice_custom_section.code) + expect(result_data["description"]).to eq("this invoice custom section will be added in the PDF") + expect(result_data["details"]).to eq("This is the exact information shown in the invoice") + end + end + + context "when updating to wrong values" do + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + + before { invoice_custom_section } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invoice_custom_section.id, + name: nil, + description: "this invoice custom section will be added in the PDF", + details: "This is the exact information shown in the invoice", + displayName: "Section name displayed in the invoice" + } + } + ) + + expect(result["errors"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/invoices/create_spec.rb b/spec/graphql/mutations/invoices/create_spec.rb new file mode 100644 index 0000000..53bcec9 --- /dev/null +++ b/spec/graphql/mutations/invoices/create_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::Create do + let(:required_permission) { "invoices:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:currency) { "EUR" } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:add_on_first) { create(:add_on, organization:) } + let(:add_on_second) { create(:add_on, amount_cents: 400, organization:) } + let(:current_time) { DateTime.new(2023, 7, 19, 12, 12) } + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:fees) do + [ + { + addOnId: add_on_first.id, + unitAmountCents: 1200, + units: 2, + description: "desc-123", + invoiceDisplayName: "fee-123", + taxCodes: [tax.code], + fromDatetime: current_time.utc.iso8601(3), + toDatetime: current_time.utc.iso8601(3) + }, + { + addOnId: add_on_second.id, + fromDatetime: current_time.utc.iso8601(3), + toDatetime: current_time.utc.iso8601(3) + } + ] + end + let(:mutation) do + <<-GQL + mutation($input: CreateInvoiceInput!) { + createInvoice(input: $input) { + id, + feesAmountCents, + taxesAmountCents, + totalAmountCents, + currency, + taxesRate, + invoiceType, + issuingDate, + appliedTaxes { id taxCode taxRate }, + fees { + units + preciseUnitAmount + properties { + fromDatetime + toDatetime + } + }, + } + } + GQL + end + + before { tax } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:create" + + it "creates one-off invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + currency:, + fees:, + invoiceCustomSection: {invoiceCustomSectionIds: [section_1.id]} + } + } + ) + + result_data = result["data"]["createInvoice"] + + expect(result_data).to include( + "id" => String, + "issuingDate" => Time.current.to_date.to_s, + "invoiceType" => "one_off", + "feesAmountCents" => "2800", + "taxesAmountCents" => "560", + "totalAmountCents" => "3360", + "taxesRate" => 20, + "currency" => "EUR" + ) + expect(result_data["appliedTaxes"].map { |t| t["taxCode"] }).to contain_exactly(tax.code) + expect(result_data["fees"]).to contain_exactly( + { + "units" => 2.0, + "preciseUnitAmount" => 12.0, + "properties" => { + "fromDatetime" => current_time.to_time.iso8601, + "toDatetime" => current_time.to_time.iso8601 + } + }, + { + "units" => 1.0, + "preciseUnitAmount" => 4.0, + "properties" => { + "fromDatetime" => current_time.to_time.iso8601, + "toDatetime" => current_time.to_time.iso8601 + } + } + ) + expect(Invoice.one_off.order(created_at: :desc).first.applied_invoice_custom_sections.pluck(:code)).to eq([section_1.code]) + end + + context "when multi_entity_billing feature flag is enabled" do + let(:other_billing_entity) { create(:billing_entity, organization:) } + let(:mutation) do + <<-GQL + mutation($input: CreateInvoiceInput!) { + createInvoice(input: $input) { + id + billingEntity { id code } + } + } + GQL + end + + before do + organization.enable_feature_flag!(:multi_entity_billing) + create(:tax, :applied_to_billing_entity, billing_entity: other_billing_entity, organization:, rate: 20) + end + + it "stamps the invoice with the resolved billing entity" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + currency:, + fees:, + billingEntityId: other_billing_entity.id + } + } + ) + + expect(result["data"]["createInvoice"]["billingEntity"]).to include( + "id" => other_billing_entity.id, + "code" => other_billing_entity.code + ) + end + + it "returns a not found error when billing entity is unknown" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + currency:, + fees:, + billingEntityId: SecureRandom.uuid + } + } + ) + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]).to include("billingEntity" => ["not_found"]) + end + end + + context "when multi_entity_billing feature flag is disabled" do + let(:other_billing_entity) { create(:billing_entity, organization:) } + let(:mutation) do + <<-GQL + mutation($input: CreateInvoiceInput!) { + createInvoice(input: $input) { + id + billingEntity { id code } + } + } + GQL + end + + it "ignores billingEntityId and falls back to the customer's billing entity" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + currency:, + fees:, + billingEntityId: other_billing_entity.id + } + } + ) + + expect(result["data"]["createInvoice"]["billingEntity"]["id"]).to eq(customer.billing_entity.id) + end + end +end diff --git a/spec/graphql/mutations/invoices/download_spec.rb b/spec/graphql/mutations/invoices/download_spec.rb new file mode 100644 index 0000000..c4670b5 --- /dev/null +++ b/spec/graphql/mutations/invoices/download_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::Download do + let(:required_permission) { "invoices:view" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: DownloadInvoiceInput!) { + downloadInvoice(input: $input) { + id + } + } + GQL + end + + before { stub_pdf_generation } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:view" + + it "generates the PDF for the given invoice" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result["data"]["downloadInvoice"] + + expect(result_data["id"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/invoices/download_xml_spec.rb b/spec/graphql/mutations/invoices/download_xml_spec.rb new file mode 100644 index 0000000..bb5189d --- /dev/null +++ b/spec/graphql/mutations/invoices/download_xml_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::DownloadXml do + let(:required_permission) { "invoices:view" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: DownloadXmlInvoiceInput!) { + downloadInvoiceXml(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:view" + + it "generates the XML for the given invoice" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + result_data = result["data"]["downloadInvoiceXml"] + + expect(result_data["id"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/invoices/finalize_all_spec.rb b/spec/graphql/mutations/invoices/finalize_all_spec.rb new file mode 100644 index 0000000..7c1ae31 --- /dev/null +++ b/spec/graphql/mutations/invoices/finalize_all_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::FinalizeAll do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { membership.user } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + :draft, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + + let(:mutation) do + <<-GQL + mutation($input: FinalizeAllInvoicesInput!) { + finalizeAllInvoices(input: $input) { + collection { id } + } + } + GQL + end + + before do + fee_subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + context "with valid preconditions" do + it "returns the invoices that are scheduled for finalize" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {} + } + ) + + data = result["data"]["finalizeAllInvoices"] + invoice_ids = data["collection"].map { |value| value["id"] } + + expect(invoice_ids).to include(invoice.id) + end + end +end diff --git a/spec/graphql/mutations/invoices/finalize_spec.rb b/spec/graphql/mutations/invoices/finalize_spec.rb new file mode 100644 index 0000000..7957d85 --- /dev/null +++ b/spec/graphql/mutations/invoices/finalize_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::Finalize do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, :draft, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: FinalizeInvoiceInput!) { + finalizeInvoice(input: $input) { + id + status + taxStatus + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "finalizes the given invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result["data"]["finalizeInvoice"] + + expect(result_data["id"]).to be_present + expect(result_data["status"]).to eq("finalized") + end + + context "with tax provider" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:subscription) do + create( + :subscription, + customer:, + subscription_at: Time.current - 3.months, + started_at: Time.current - 3.months, + created_at: Time.current - 3.months + ) + end + let(:date_service) do + Subscriptions::DatesService.new_instance( + subscription, + Time.zone.at(timestamp), + current_usage: false + ) + end + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + timestamp:, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime + ) + end + + before do + integration_customer + invoice_subscription + end + + it "returns pending invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result["data"]["finalizeInvoice"] + + expect(result_data["id"]).to be_present + expect(result_data["status"]).to eq("pending") + expect(result_data["taxStatus"]).to eq("pending") + end + end +end diff --git a/spec/graphql/mutations/invoices/generate_payment_url_spec.rb b/spec/graphql/mutations/invoices/generate_payment_url_spec.rb new file mode 100644 index 0000000..18163d4 --- /dev/null +++ b/spec/graphql/mutations/invoices/generate_payment_url_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::GeneratePaymentUrl do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: GeneratePaymentUrlInput!) { + generatePaymentUrl(input: $input) { + paymentUrl + } + } + GQL + end + + let(:service_result) do + result = BaseService::Result.new + result.payment_url = "https://payment.example.com/pay/123" + result + end + + before do + allow(Invoices::Payments::GeneratePaymentUrlService).to receive(:call).and_return(service_result) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "generates a payment URL for the invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + invoiceId: invoice.id + } + } + ) + + result_data = result["data"]["generatePaymentUrl"] + + expect(result_data["paymentUrl"]).to eq("https://payment.example.com/pay/123") + expect(Invoices::Payments::GeneratePaymentUrlService).to have_received(:call).with(invoice:) + end + + context "when the invoice is not found" do + it "returns a GraphQL error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + invoiceId: "invalid" + } + } + ) + + expect_not_found(result) + end + end + + context "when service returns an error" do + let(:service_result) do + result = BaseService::Result.new + result.single_validation_failure!(error_code: "no_linked_payment_provider") + result + end + + it "returns a GraphQL error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + invoiceId: invoice.id + } + } + ) + + expect_graphql_error( + result:, + message: "Unprocessable Entity" + ) + end + + context "when error is ThirdPartyFailure" do + let(:service_result) do + result = BaseService::Result.new + result.third_party_failure!(third_party: "stripe", error_code: 500, error_message: "Hummm, there's an error!") + result + end + + it "returns a GraphQL error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + invoiceId: invoice.id + } + } + ) + + expect_graphql_error( + result:, + message: "Unprocessable Entity" + ) + end + end + end +end diff --git a/spec/graphql/mutations/invoices/lose_dispute_spec.rb b/spec/graphql/mutations/invoices/lose_dispute_spec.rb new file mode 100644 index 0000000..b41aa24 --- /dev/null +++ b/spec/graphql/mutations/invoices/lose_dispute_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::LoseDispute do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, status: :finalized, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: LoseInvoiceDisputeInput!) { + loseInvoiceDispute(input: $input) { + id + paymentDisputeLostAt + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "marks payment dispute lost to true" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result["data"]["loseInvoiceDispute"] + + expect(result_data["id"]).to be_present + expect(result_data["paymentDisputeLostAt"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/invoices/refresh_spec.rb b/spec/graphql/mutations/invoices/refresh_spec.rb new file mode 100644 index 0000000..79563cf --- /dev/null +++ b/spec/graphql/mutations/invoices/refresh_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::Refresh do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: RefreshInvoiceInput!) { + refreshInvoice(input: $input) { + id + updatedAt + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "refreshes the given invoice" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result["data"]["refreshInvoice"] + + expect(result_data["id"]).to be_present + expect(result_data["updatedAt"]).to eq(Time.current.iso8601) + end + end +end diff --git a/spec/graphql/mutations/invoices/regenerate_from_voided_spec.rb b/spec/graphql/mutations/invoices/regenerate_from_voided_spec.rb new file mode 100644 index 0000000..107139c --- /dev/null +++ b/spec/graphql/mutations/invoices/regenerate_from_voided_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::RegenerateFromVoided do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + let(:voided_invoice) do + create( + :invoice, + :voided, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice: voided_invoice, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + + let(:mutation) do + <<~GQL + mutation($input: RegenerateInvoiceInput!) { + regenerateFromVoided(input: $input) { + id + voidedInvoiceId + fees { + id + invoiceDisplayName + units + preciseUnitAmount + } + status + } + } + GQL + end + + let(:fee_input) do + { + id: fee_subscription.id, + chargeId: fee_subscription.charge_id, + subscriptionId: fee_subscription.subscription_id, + invoiceDisplayName: "Adjusted", + units: 2, + unitAmountCents: 5000 + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + it "regenerates an invoice from a voided invoice" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + voidedInvoiceId: voided_invoice.id, + fees: [fee_input] + } + } + ) + + result_data = result.dig("data", "regenerateFromVoided") + expect(result_data["id"]).to be_present + expect(result_data["id"]).not_to eq(voided_invoice.id) + expect(result_data["voidedInvoiceId"]).to eq(voided_invoice.id.to_s) + expect(result_data["fees"].first["invoiceDisplayName"]).to eq(fee_input[:invoiceDisplayName]) + expect(result_data["fees"].first["units"]).to eq(fee_input[:units]) + expect(result_data["fees"].first["preciseUnitAmount"]).to eq(fee_input[:unitAmountCents]) + expect(result_data["status"]).to eq("finalized") + end + end +end diff --git a/spec/graphql/mutations/invoices/resend_email_spec.rb b/spec/graphql/mutations/invoices/resend_email_spec.rb new file mode 100644 index 0000000..bc373e7 --- /dev/null +++ b/spec/graphql/mutations/invoices/resend_email_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::ResendEmail do + let(:required_permission) { "invoices:send" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, email: "customer@example.com") } + let(:billing_entity) { customer.billing_entity } + let(:invoice) { create(:invoice, customer:, organization:, status: :finalized) } + + let(:mutation) do + <<~GQL + mutation($input: ResendInvoiceEmailInput!) { + resendInvoiceEmail(input: $input) { + id + } + } + GQL + end + + before do + billing_entity.update!(email: "billing@example.com") + billing_entity.email_settings = ["invoice.finalized"] + billing_entity.save! + allow(License).to receive(:premium?).and_return(true) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:send" + + it "resends the invoice email" do + expect do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: invoice.id}} + ) + + expect(result["data"]["resendInvoiceEmail"]["id"]).to eq(invoice.id) + end.to have_enqueued_mail(InvoiceMailer, :created) + end + + context "with custom recipients" do + it "resends the invoice email with custom recipients" do + expect do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invoice.id, + to: ["custom@example.com"], + cc: ["cc@example.com"], + bcc: ["bcc@example.com"] + } + } + ) + + expect(result["data"]["resendInvoiceEmail"]["id"]).to eq(invoice.id) + end.to have_enqueued_mail(InvoiceMailer, :created) + end + end + + context "when invoice does not exist" do + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: SecureRandom.uuid}} + ) + + expect(result["errors"]).to be_present + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + end + end +end diff --git a/spec/graphql/mutations/invoices/retry_all_payments_spec.rb b/spec/graphql/mutations/invoices/retry_all_payments_spec.rb new file mode 100644 index 0000000..61784ca --- /dev/null +++ b/spec/graphql/mutations/invoices/retry_all_payments_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::RetryAllPayments do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { membership.user } + let(:gocardless_payment_provider) { create(:gocardless_provider, organization:) } + let(:customer_first) { create(:customer, organization:, payment_provider: "gocardless") } + let(:gocardless_customer_first) { create(:gocardless_customer, customer: customer_first) } + let(:customer_second) { create(:customer, organization:, payment_provider: "gocardless") } + let(:gocardless_customer_second) { create(:gocardless_customer, customer: customer_second) } + let(:mutation) do + <<-GQL + mutation($input: RetryAllInvoicePaymentsInput!) { + retryAllInvoicePayments(input: $input) { + collection { id } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + context "with valid preconditions" do + let(:invoice_first) do + create( + :invoice, + organization:, + customer: customer_first, + status: "finalized", + payment_status: "failed", + ready_for_payment_processing: true + ) + end + let(:invoice_second) do + create( + :invoice, + organization:, + customer: customer_second, + status: "finalized", + payment_status: "failed", + ready_for_payment_processing: true + ) + end + + before do + gocardless_payment_provider + gocardless_customer_first + gocardless_customer_second + invoice_first + invoice_second + end + + it "returns the invoices that are scheduled for retry" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {} + } + ) + + data = result["data"]["retryAllInvoicePayments"] + invoice_ids = data["collection"].map { |value| value["id"] } + + expect(invoice_ids).to include(invoice_first.id) + expect(invoice_ids).to include(invoice_second.id) + end + end +end diff --git a/spec/graphql/mutations/invoices/retry_all_spec.rb b/spec/graphql/mutations/invoices/retry_all_spec.rb new file mode 100644 index 0000000..844174d --- /dev/null +++ b/spec/graphql/mutations/invoices/retry_all_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::RetryAll do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { membership.user } + let(:customer) { create(:customer, organization:, payment_provider: "gocardless") } + + let(:invoice) do + create( + :invoice, + :failed, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + json = File.read(path) + + # setting item_id based on the test example + response = JSON.parse(json) + response["succeededInvoices"].first["fees"].first["item_id"] = subscription.id + + response.to_json + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:mutation) do + <<-GQL + mutation($input: RetryAllInvoicesInput!) { + retryAllInvoices(input: $input) { + collection { id } + } + } + GQL + end + + before do + integration_collection_mapping + fee_subscription + + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + context "with valid preconditions" do + it "returns the invoices that are scheduled for retry" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {} + } + ) + + data = result["data"]["retryAllInvoices"] + invoice_ids = data["collection"].map { |value| value["id"] } + + expect(invoice_ids).to include(invoice.id) + end + end +end diff --git a/spec/graphql/mutations/invoices/retry_payment_spec.rb b/spec/graphql/mutations/invoices/retry_payment_spec.rb new file mode 100644 index 0000000..948be1e --- /dev/null +++ b/spec/graphql/mutations/invoices/retry_payment_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::RetryPayment do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, payment_provider: "gocardless") } + let(:gocardless_payment_provider) { create(:gocardless_provider, organization:) } + let(:gocardless_customer) { create(:gocardless_customer, customer:) } + let(:user) { membership.user } + let(:invoice) do + create( + :invoice, + organization:, + customer:, + status: "finalized", + payment_status: "failed", + ready_for_payment_processing: true + ) + end + let(:mutation) do + <<-GQL + mutation($input: RetryInvoicePaymentInput!) { + retryInvoicePayment(input: $input) { + id + paymentStatus + } + } + GQL + end + + before do + gocardless_payment_provider + gocardless_customer + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + context "with valid preconditions" do + it "returns the invoice after payment retry" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + data = result["data"]["retryInvoicePayment"] + + expect(data["id"]).to eq(invoice.id) + end + + it "returns the invoice after payment retry with dedicated payment method" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invoice.id, + paymentMethod: {paymentMethodType: "manual"} + } + } + ) + + data = result["data"]["retryInvoicePayment"] + + expect(data["id"]).to eq(invoice.id) + end + end +end diff --git a/spec/graphql/mutations/invoices/retry_spec.rb b/spec/graphql/mutations/invoices/retry_spec.rb new file mode 100644 index 0000000..57cd2f4 --- /dev/null +++ b/spec/graphql/mutations/invoices/retry_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::Retry do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, payment_provider: "gocardless") } + let(:user) { membership.user } + + let(:invoice) do + create( + :invoice, + :failed, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:mutation) do + <<-GQL + mutation($input: RetryInvoiceInput!) { + retryInvoice(input: $input) { + id + status + } + } + GQL + end + + before do + fee_subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + context "with valid preconditions" do + it "returns the invoice after retry" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + data = result["data"]["retryInvoice"] + + expect(data["id"]).to eq(invoice.id) + expect(data["status"]).to eq("pending") + end + end +end diff --git a/spec/graphql/mutations/invoices/retry_tax_provider_voiding_spec.rb b/spec/graphql/mutations/invoices/retry_tax_provider_voiding_spec.rb new file mode 100644 index 0000000..f956511 --- /dev/null +++ b/spec/graphql/mutations/invoices/retry_tax_provider_voiding_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::RetryTaxProviderVoiding do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, payment_provider: "gocardless") } + let(:user) { membership.user } + + let(:invoice) do + create( + :invoice, + :voided, + :with_tax_voiding_error, + :subscription, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/void_invoices" } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json") + File.read(path) + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:mutation) do + <<-GQL + mutation($input: RetryTaxProviderVoidingInput!) { + retryTaxProviderVoiding(input: $input) { + id + status + } + } + GQL + end + + before do + integration_collection_mapping + fee_subscription + + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:update" + + context "with valid preconditions" do + it "returns the invoice after successful retry" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + data = result["data"]["retryTaxProviderVoiding"] + + expect(data["id"]).to eq(invoice.id) + expect(data["status"]).to eq("voided") + end + end +end diff --git a/spec/graphql/mutations/invoices/update_spec.rb b/spec/graphql/mutations/invoices/update_spec.rb new file mode 100644 index 0000000..74de1f9 --- /dev/null +++ b/spec/graphql/mutations/invoices/update_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::Update do + let(:required_permission) { "invoices:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice) { create(:invoice, organization:) } + let(:mutation) do + <<~GQL + mutation($input: UpdateInvoiceInput!) { + updateInvoice(input: $input) { + id + paymentStatus + metadata { id, key, value } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "invoices:update" + + it "updates a invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invoice.id, + paymentStatus: "succeeded", + metadata: [ + { + key: "test-key", + value: "value" + } + ] + } + } + ) + + result_data = result["data"]["updateInvoice"] + + expect(result_data["id"]).to be_present + expect(result_data["paymentStatus"]).to eq("succeeded") + expect(result_data["metadata"][0]["key"]).to eq("test-key") + end + + context "when invoice does not exists" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "1234", + paymentStatus: "succeeded" + } + } + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/mutations/invoices/void_spec.rb b/spec/graphql/mutations/invoices/void_spec.rb new file mode 100644 index 0000000..c7ae904 --- /dev/null +++ b/spec/graphql/mutations/invoices/void_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Invoices::Void do + let(:required_permission) { "invoices:void" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, status: :finalized, customer:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: VoidInvoiceInput!) { + voidInvoice(input: $input) { + id + status + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:void" + + it "voids the given invoice" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: invoice.id} + } + ) + + result_data = result["data"]["voidInvoice"] + + expect(result_data["id"]).to be_present + expect(result_data["status"]).to eq("voided") + end + end + + context "when passing credit note parameters", :premium do + let(:credit_amount) { 0 } + let(:refund_amount) { 0 } + + it "calls the void service with all parameters" do + allow(::Invoices::VoidService).to receive(:call).with( + invoice: instance_of(Invoice), + params: hash_including( + generate_credit_note: true, + credit_amount: credit_amount, + refund_amount: refund_amount + ) + ).and_call_original + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: invoice.id, + generateCreditNote: true, + creditAmount: credit_amount, + refundAmount: refund_amount + } + } + ) + + expect(::Invoices::VoidService).to have_received(:call).with( + invoice: instance_of(Invoice), + params: hash_including( + generate_credit_note: true, + credit_amount: credit_amount, + refund_amount: refund_amount + ) + ) + + result_data = result["data"]["voidInvoice"] + + expect(result_data["id"]).to be_present + expect(result_data["status"]).to eq("voided") + end + end +end diff --git a/spec/graphql/mutations/login_user_spec.rb b/spec/graphql/mutations/login_user_spec.rb new file mode 100644 index 0000000..59b730b --- /dev/null +++ b/spec/graphql/mutations/login_user_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::LoginUser do + let(:membership) { create(:membership) } + let(:user) { membership.user } + let(:mutation) do + <<~GQL + mutation($input: LoginUserInput!) { + loginUser(input: $input) { + token + user { + id + email + } + } + } + GQL + end + + it "returns token and user" do + result = execute_graphql( + query: mutation, + variables: { + input: { + email: user.email, + password: "ILoveLago" + } + } + ) + + result_data = result["data"]["loginUser"] + + expect(result_data["token"]).to be_present + expect(result_data["user"]["id"]).to eq(user.id) + end + + context "with bad credentials" do + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: { + email: user.email, + password: "badpassword" + } + } + ) + + expect_unprocessable_entity(result) + expect(result["errors"].first.dig("extensions", "details").keys).to include("base") + expect(result["errors"].first.dig("extensions", "details", "base")).to include("incorrect_login_or_password") + end + end + + context "with revoked membership" do + let(:revoked_membership) { create(:membership, status: :revoked) } + + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: { + email: revoked_membership.user.email, + password: "ILoveLago" + } + } + ) + + expect_unprocessable_entity(result) + expect(result["errors"].first.dig("extensions", "details").keys).to include("base") + expect(result["errors"].first.dig("extensions", "details", "base")).to include("incorrect_login_or_password") + end + end +end diff --git a/spec/graphql/mutations/memberships/revoke_spec.rb b/spec/graphql/mutations/memberships/revoke_spec.rb new file mode 100644 index 0000000..510bdd4 --- /dev/null +++ b/spec/graphql/mutations/memberships/revoke_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Memberships::Revoke do + include_context "with mocked security logger" + + let(:required_permission) { "organization:members:update" } + let(:admin_role) { create(:role, :admin) } + let(:finance_role) { create(:role, :finance) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:mutation) do + <<-GQL + mutation($input: RevokeMembershipInput!) { + revokeMembership(input: $input) { + id + revokedAt + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:members:update" + + context "when revoking another membership" do + subject(:result) do + execute_graphql( + current_organization: organization, + current_user: membership.user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: membership_to_remove.id} + } + ) + end + + let(:membership_to_remove) { create(:membership, organization:) } + + before do + create(:membership_role, membership: membership_to_remove, role: admin_role) + create(:membership_role, membership:, role: admin_role) + end + + it "revokes a membership" do + data = result["data"]["revokeMembership"] + + expect(data["id"]).to eq(membership_to_remove.id) + expect(data["revokedAt"]).to be_present + end + + it_behaves_like "produces a security log", "user.deleted" do + before { result } + end + end + + it "Cannot Revoke my own membership" do + result = execute_graphql( + current_organization: organization, + current_user: membership.user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: membership.id} + } + ) + + expect(result["errors"].first["message"]).to eq("Method Not Allowed") + expect(result["errors"].first["extensions"]["code"]).to eq("cannot_revoke_own_membership") + expect(result["errors"].first["extensions"]["status"]).to eq(405) + end + + it "cannot revoke membership if it's the last admin of the organization" do + # `finance` users normally don't have delete permissions on memberships + # but here the permissions array is passed regardless of the actual user permission + create(:membership_role, membership:, role: admin_role) + other_user = create(:membership, organization:) + create(:membership_role, membership: other_user, role: finance_role) + + result = execute_graphql( + current_organization: organization, + current_user: other_user.user, + current_membership: other_user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: membership.id} + } + ) + + expect(result["errors"].first["message"]).to eq("Method Not Allowed") + expect(result["errors"].first["extensions"]["code"]).to eq("last_admin") + expect(result["errors"].first["extensions"]["status"]).to eq(405) + end +end diff --git a/spec/graphql/mutations/memberships/update_spec.rb b/spec/graphql/mutations/memberships/update_spec.rb new file mode 100644 index 0000000..c8e7a47 --- /dev/null +++ b/spec/graphql/mutations/memberships/update_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Memberships::Update do + include_context "with mocked security logger" + + let(:required_permission) { "organization:members:update" } + let(:admin_role) { create(:role, :admin) } + let(:finance_role) { create(:role, :finance) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { membership.user } + + let(:mutation) do + <<-GQL + mutation($input: UpdateMembershipInput!) { + updateMembership(input: $input) { + id + roles + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:members:update" + + describe "Membership update mutation" do + subject(:result) do + execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: membership_to_edit.id, + roles: %w[admin] + } + } + ) + end + + let(:membership_to_edit) { create(:membership, organization:) } + + before do + create(:membership_role, membership: membership_to_edit, role: finance_role) + create(:membership_role, membership:, role: admin_role) + end + + it "returns the updated membership" do + data = result["data"]["updateMembership"] + + expect(data["id"]).to eq(membership_to_edit.id) + expect(data["roles"]).to eq(%w[Admin]) + end + + it_behaves_like "produces a security log", "user.role_edited" do + before { result } + end + end + + describe "self-promotion with custom role" do + subject(:result) do + execute_graphql( + current_organization: organization, + current_user: user, + current_membership: membership, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: membership.id, + roles: %w[admin] + } + } + ) + end + + let(:custom_role) do + create(:role, :custom, + organization: organization, + code: "accounting", + name: "Accounting", + permissions: %w[organization:members:update organization:view]) + end + + before do + admin_role + create(:membership_role, membership:, role: custom_role) + end + + it "prevents a non-admin member from promoting themselves to admin" do + expect_forbidden_error(result) + error = result["errors"].first + expect(error["extensions"]["code"]).to eq("cannot_grant_admin") + end + end + + describe "non-admin promoting another member to admin" do + subject(:result) do + execute_graphql( + current_organization: organization, + current_user: user, + current_membership: membership, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: other_membership.id, + roles: %w[admin] + } + } + ) + end + + let(:other_membership) { create(:membership, organization:) } + + let(:custom_role) do + create(:role, :custom, + organization: organization, + code: "accounting", + name: "Accounting", + permissions: %w[organization:members:update organization:view]) + end + + before do + admin_role + create(:membership_role, membership:, role: custom_role) + create(:membership_role, membership: other_membership, role: finance_role) + end + + it "prevents a non-admin from promoting another member to admin" do + expect_forbidden_error(result) + error = result["errors"].first + expect(error["extensions"]["code"]).to eq("cannot_grant_admin") + end + end +end diff --git a/spec/graphql/mutations/organizations/update_spec.rb b/spec/graphql/mutations/organizations/update_spec.rb new file mode 100644 index 0000000..cc70d89 --- /dev/null +++ b/spec/graphql/mutations/organizations/update_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Organizations::Update do + let(:membership) { create(:membership) } + let(:mutation) do + <<~GQL + mutation($input: UpdateOrganizationInput!) { + updateOrganization(input: $input) { + legalNumber + legalName + taxIdentificationNumber + email + addressLine1 + addressLine2 + state + zipcode + city + country + defaultCurrency + netPaymentTerm + timezone + emailSettings + webhookUrl + euTaxManagement, + documentNumbering + documentNumberPrefix + finalizeZeroAmountInvoice + billingConfiguration { + invoiceFooter, + invoiceGracePeriod, + documentLocale, + } + authenticationMethods + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + + it "updates an organization" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: Permission.permissions_hash(:admin), + query: mutation, + variables: { + input: { + legalNumber: "1234", + legalName: "Foobar", + taxIdentificationNumber: "2246", + email: "foo@bar.com", + addressLine1: "Line 1", + addressLine2: "Line 2", + netPaymentTerm: 10, + state: "Foobar", + zipcode: "FOO1234", + city: "Foobar", + country: "FR", + defaultCurrency: "EUR", + euTaxManagement: true, + webhookUrl: "https://app.test.dev", + documentNumberPrefix: "ORG-2", + finalizeZeroAmountInvoice: false, + billingConfiguration: { + invoiceFooter: "invoice footer", + documentLocale: "fr" + } + } + } + ) + + result_data = result["data"]["updateOrganization"] + + expect(result_data["legalNumber"]).to eq("1234") + expect(result_data["legalName"]).to eq("Foobar") + expect(result_data["taxIdentificationNumber"]).to eq("2246") + expect(result_data["email"]).to eq("foo@bar.com") + expect(result_data["addressLine1"]).to eq("Line 1") + expect(result_data["addressLine2"]).to eq("Line 2") + expect(result_data["state"]).to eq("Foobar") + expect(result_data["zipcode"]).to eq("FOO1234") + expect(result_data["city"]).to eq("Foobar") + expect(result_data["country"]).to eq("FR") + expect(result_data["defaultCurrency"]).to eq("EUR") + expect(result_data["netPaymentTerm"]).to eq(10) + expect(result_data["webhookUrl"]).to eq("https://app.test.dev") + expect(result_data["documentNumbering"]).to eq("per_customer") + expect(result_data["documentNumberPrefix"]).to eq("ORG-2") + expect(result_data["billingConfiguration"]["invoiceFooter"]).to eq("invoice footer") + expect(result_data["billingConfiguration"]["invoiceGracePeriod"]).to eq(0) + expect(result_data["billingConfiguration"]["documentLocale"]).to eq("fr") + expect(result_data["euTaxManagement"]).to be_truthy + expect(result_data["timezone"]).to eq("TZ_UTC") + expect(result_data["finalizeZeroAmountInvoice"]).to be false + end + + context "without necessary permissions" do + it "ignores permissions-protected field and updates the rest" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: %w[], + query: mutation, + variables: { + input: { + email: "foo@bar2.com", + taxIdentificationNumber: "tax007", + emailSettings: ["invoice_finalized"] + } + } + ) + + result_data = result["data"]["updateOrganization"] + + expect(result_data["email"]).to eq "foo@bar2.com" + expect(result_data["taxIdentificationNumber"]).to eq "tax007" + expect(result_data["emailSettings"]).to be_nil + end + end + + context "with premium features", :premium do + let(:timezone) { "TZ_EUROPE_PARIS" } + + it "updates an organization" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: %w[organization:emails:view organization:invoices:view], + query: mutation, + variables: { + input: { + email: "foo@bar.com", + timezone:, + billingConfiguration: { + invoiceGracePeriod: 3 + }, + emailSettings: ["invoice_finalized"], + authenticationMethods: ["google_oauth"] + } + } + ) + + result_data = result["data"]["updateOrganization"] + + expect(result_data["timezone"]).to eq(timezone) + expect(result_data["billingConfiguration"]["invoiceGracePeriod"]).to eq(3) + expect(result_data["emailSettings"]).to eq(["invoice_finalized"]) + expect(result_data["authenticationMethods"]).to eq(["google_oauth"]) + end + + context "with Etc/GMT+12 timezone" do + let(:timezone) { "TZ_ETC_GMT_12" } + + it "updates an organization" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: "organization:invoices:view", + query: mutation, + variables: { + input: { + email: "foo@bar.com", + timezone:, + billingConfiguration: { + invoiceGracePeriod: 3 + } + } + } + ) + + result_data = result["data"]["updateOrganization"] + + expect(result_data["timezone"]).to eq(timezone) + expect(result_data["billingConfiguration"]["invoiceGracePeriod"]).to eq(3) + end + end + end +end diff --git a/spec/graphql/mutations/password_resets/create_spec.rb b/spec/graphql/mutations/password_resets/create_spec.rb new file mode 100644 index 0000000..6637321 --- /dev/null +++ b/spec/graphql/mutations/password_resets/create_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PasswordResets::Create do + include_context "with mocked security logger" + + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:user) { membership.user } + let(:email) { user.email } + + let(:mutation) do + <<~GQL + mutation($input: CreatePasswordResetInput!) { + createPasswordReset(input: $input) { + id + } + } + GQL + end + + context "with a valid user" do + subject(:result) do + execute_graphql( + query: mutation, + variables: { + input: { + email: + } + } + ) + end + + it "creates a password reset for a user" do + data = result["data"]["createPasswordReset"] + + expect(data["id"]).to be_present + end + + it_behaves_like "produces a security log", "user.password_reset_requested" do + before { result } + end + end +end diff --git a/spec/graphql/mutations/password_resets/reset_spec.rb b/spec/graphql/mutations/password_resets/reset_spec.rb new file mode 100644 index 0000000..5a403ea --- /dev/null +++ b/spec/graphql/mutations/password_resets/reset_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PasswordResets::Reset do + include_context "with mocked security logger" + + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:, user: create(:user, password: "HelloLago!1")) } + let(:user) { membership.user } + let(:password_reset) { create(:password_reset, user:) } + + let(:mutation) do + <<~GQL + mutation($input: ResetPasswordInput!) { + resetPassword(input: $input) { + token + } + } + GQL + end + + context "with a valid token" do + subject(:result) do + execute_graphql( + query: mutation, + variables: { + input: { + newPassword: "HelloLago!2", + token: password_reset.token + } + } + ) + end + + it "returns the auth token after a password reset" do + data = result["data"]["resetPassword"] + + expect(data["token"]).to be_present + end + + it_behaves_like "produces a security log", "user.password_edited" do + before { result } + end + end + + context "when the password reset is expired" do + let(:expired_password_reset) do + create(:password_reset, user:, expire_at: Time.current - 1.minute) + end + + it "returns an error" do + result = execute_graphql( + query: mutation, + variables: { + input: { + newPassword: "HelloLago!3", + token: expired_password_reset.token + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/payment_methods/destroy_spec.rb b/spec/graphql/mutations/payment_methods/destroy_spec.rb new file mode 100644 index 0000000..b8bac43 --- /dev/null +++ b/spec/graphql/mutations/payment_methods/destroy_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentMethods::Destroy do + let(:required_permissions) { "payment_methods:delete" } + let(:membership) { create(:membership, organization:) } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:payment_method) { create(:payment_method, organization:, customer:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyPaymentMethodInput!) { + destroyPaymentMethod(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payment_methods:delete" + + it "deletes a payment method" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: {id: payment_method.id} + } + ) + + data = result["data"]["destroyPaymentMethod"] + expect(data["id"]).to eq(payment_method.id) + end + + context "when payment method is not found" do + let(:payment_method) { create(:payment_method) } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permissions, + query: mutation, + variables: { + input: {id: payment_method.id} + } + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/mutations/payment_methods/generate_checkout_url_spec.rb b/spec/graphql/mutations/payment_methods/generate_checkout_url_spec.rb new file mode 100644 index 0000000..e63f7d0 --- /dev/null +++ b/spec/graphql/mutations/payment_methods/generate_checkout_url_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentMethods::GenerateCheckoutUrl do + let(:required_permission) { "payment_methods:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:user) { membership.user } + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: true) } + + let(:mutation) do + <<-GQL + mutation($input: GenerateCheckoutUrlInput!) { + generateCheckoutUrl(input: $input) { + checkoutUrl + } + } + GQL + end + + before do + payment_method + + create( + :stripe_customer, + customer_id: customer.id, + payment_provider: stripe_provider + ) + + customer.update!(payment_provider: "stripe", payment_provider_code: stripe_provider.code) + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example.com"}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payment_methods:create" + + context "with valid preconditions" do + it "returns the checkout url" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {customerId: customer.id} + } + ) + + data = result["data"]["generateCheckoutUrl"] + + expect(data["checkoutUrl"]).to eq("https://example.com") + end + end + + context "when customer is not found" do + it "returns an error" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: "foo_bar" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/payment_methods/set_as_default_spec.rb b/spec/graphql/mutations/payment_methods/set_as_default_spec.rb new file mode 100644 index 0000000..9cfee8a --- /dev/null +++ b/spec/graphql/mutations/payment_methods/set_as_default_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentMethods::SetAsDefault do + let(:required_permission) { "payment_methods:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:user) { membership.user } + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: false) } + let(:payment_method2) { create(:payment_method, customer:, organization:, is_default: true) } + let(:payment_method3) { create(:payment_method, customer:, organization:, is_default: false) } + + let(:mutation) do + <<-GQL + mutation($input: SetAsDefaultInput!) { + setPaymentMethodAsDefault(input: $input) { + id + } + } + GQL + end + + before do + payment_method + payment_method2 + payment_method3 + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payment_methods:update" + + context "with valid preconditions" do + it "returns the payment method after setting it as default" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: {id: payment_method.id} + } + ) + + data = result["data"]["setPaymentMethodAsDefault"] + + expect(data["id"]).to eq(payment_method.id) + expect(payment_method.reload.is_default).to eq(true) + expect(payment_method2.reload.is_default).to eq(false) + expect(payment_method3.reload.is_default).to eq(false) + end + end + + context "when payment method is not found" do + it "returns an error" do + result = execute_graphql( + current_organization: organization, + current_user: user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: "foo_bar" + } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/payment_providers/adyen/create_spec.rb b/spec/graphql/mutations/payment_providers/adyen/create_spec.rb new file mode 100644 index 0000000..3cba3c2 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/adyen/create_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Adyen::Create do + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:api_key) { "api_key_123456_abc" } + let(:hmac_key) { "hmac_124" } + let(:code) { "adyen_1" } + let(:name) { "Adyen 1" } + let(:live_prefix) { "test" } + let(:merchant_account) { "Merchant1" } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: AddAdyenPaymentProviderInput!) { + addAdyenPaymentProvider(input: $input) { + id, + apiKey, + code, + name, + hmacKey, + livePrefix, + merchantAccount, + successRedirectUrl + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + it "creates an adyen provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + apiKey: api_key, + hmacKey: hmac_key, + code:, + name:, + merchantAccount: merchant_account, + livePrefix: live_prefix, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result["data"]["addAdyenPaymentProvider"] + + expect(result_data["id"]).to be_present + expect(result_data["apiKey"]).to eq("••••••••…abc") + expect(result_data["hmacKey"]).to eq("••••••••…124") + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["livePrefix"]).to eq(live_prefix) + expect(result_data["merchantAccount"]).to eq(merchant_account) + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + end +end diff --git a/spec/graphql/mutations/payment_providers/adyen/update_spec.rb b/spec/graphql/mutations/payment_providers/adyen/update_spec.rb new file mode 100644 index 0000000..750eb12 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/adyen/update_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Adyen::Update do + let(:required_permission) { "organization:integrations:update" } + let(:membership) { create(:membership) } + let(:adyen_provider) { create(:adyen_provider, organization: membership.organization) } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: UpdateAdyenPaymentProviderInput!) { + updateAdyenPaymentProvider(input: $input) { + id, + successRedirectUrl + } + } + GQL + end + + before { adyen_provider } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "updates an adyen provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + id: adyen_provider.id, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result["data"]["updateAdyenPaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + end + + context "when success redirect url is nil" do + it "removes success redirect url from the provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + id: adyen_provider.id, + successRedirectUrl: nil + } + } + ) + + result_data = result["data"]["updateAdyenPaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(nil) + end + end +end diff --git a/spec/graphql/mutations/payment_providers/cashfree/create_spec.rb b/spec/graphql/mutations/payment_providers/cashfree/create_spec.rb new file mode 100644 index 0000000..f3a58d2 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/cashfree/create_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Cashfree::Create do + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:client_id) { "123456_abc" } + let(:client_secret) { "cfsk_ma_prod_abc_123456" } + let(:code) { "cashfree_1" } + let(:name) { "Cashfree 1" } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: AddCashfreePaymentProviderInput!) { + addCashfreePaymentProvider(input: $input) { + id, + code, + name, + clientId, + clientSecret + successRedirectUrl + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + it "creates a cashfree provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + code:, + name:, + clientId: client_id, + clientSecret: client_secret, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result["data"]["addCashfreePaymentProvider"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["clientId"]).to eq(client_id) + expect(result_data["clientSecret"]).to eq(client_secret) + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + end +end diff --git a/spec/graphql/mutations/payment_providers/cashfree/update_spec.rb b/spec/graphql/mutations/payment_providers/cashfree/update_spec.rb new file mode 100644 index 0000000..76640b5 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/cashfree/update_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Cashfree::Update do + let(:required_permission) { "organization:integrations:update" } + let(:membership) { create(:membership) } + let(:cashfree_provider) { create(:cashfree_provider, organization: membership.organization) } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: UpdateCashfreePaymentProviderInput!) { + updateCashfreePaymentProvider(input: $input) { + id, + successRedirectUrl + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "updates an cashfree provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + id: cashfree_provider.id, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result["data"]["updateCashfreePaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + end + + context "when success redirect url is nil" do + it "removes success redirect url from the provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: cashfree_provider.id, + successRedirectUrl: nil + } + } + ) + + result_data = result["data"]["updateCashfreePaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(nil) + end + end +end diff --git a/spec/graphql/mutations/payment_providers/destroy_spec.rb b/spec/graphql/mutations/payment_providers/destroy_spec.rb new file mode 100644 index 0000000..e02e59e --- /dev/null +++ b/spec/graphql/mutations/payment_providers/destroy_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Destroy do + let(:required_permission) { "organization:integrations:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:payment_provider) { create(:stripe_provider, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyPaymentProviderInput!) { + destroyPaymentProvider(input: $input) { id } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:delete" + + it "deletes a payment provider" do + result = execute_query( + query: mutation, + input: {id: payment_provider.id} + ) + + data = result["data"]["destroyPaymentProvider"] + expect(data["id"]).to eq(payment_provider.id) + end + + context "when payment provider is not attached to the organization" do + let(:payment_provider) { create(:stripe_provider) } + + it "returns an error" do + result = execute_query( + query: mutation, + input: {id: payment_provider.id} + ) + + expect(result["errors"].first["message"]).to eq("Resource not found") + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["status"]).to eq(404) + end + end +end diff --git a/spec/graphql/mutations/payment_providers/flutterwave/create_spec.rb b/spec/graphql/mutations/payment_providers/flutterwave/create_spec.rb new file mode 100644 index 0000000..e8e4f03 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/flutterwave/create_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Flutterwave::Create do + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:secret_key) { "FLWSECK-xxxxxxxxx-X" } + let(:code) { "flutterwave_1" } + let(:name) { "Flutterwave 1" } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: AddFlutterwavePaymentProviderInput!) { + addFlutterwavePaymentProvider(input: $input) { + id, + code, + name, + secretKey, + successRedirectUrl, + webhookSecret + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + it "creates a flutterwave provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: {input: { + code:, + name:, + secretKey: secret_key, + successRedirectUrl: success_redirect_url + }} + ) + + result_data = result["data"]["addFlutterwavePaymentProvider"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["secretKey"]).to eq("••••••••…x-X") + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + expect(result_data["webhookSecret"]).to be_present + end +end diff --git a/spec/graphql/mutations/payment_providers/flutterwave/update_spec.rb b/spec/graphql/mutations/payment_providers/flutterwave/update_spec.rb new file mode 100644 index 0000000..795deac --- /dev/null +++ b/spec/graphql/mutations/payment_providers/flutterwave/update_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Flutterwave::Update do + let(:required_permission) { "organization:integrations:update" } + let(:membership) { create(:membership) } + let(:flutterwave_provider) { create(:flutterwave_provider, organization: membership.organization) } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: UpdateFlutterwavePaymentProviderInput!) { + updateFlutterwavePaymentProvider(input: $input) { + id, + successRedirectUrl + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "updates a flutterwave provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + id: flutterwave_provider.id, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result["data"]["updateFlutterwavePaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + end + + context "when success redirect url is nil" do + it "removes success redirect url from the provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: flutterwave_provider.id, + successRedirectUrl: nil + } + } + ) + + result_data = result["data"]["updateFlutterwavePaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(nil) + end + end +end diff --git a/spec/graphql/mutations/payment_providers/gocardless/create_spec.rb b/spec/graphql/mutations/payment_providers/gocardless/create_spec.rb new file mode 100644 index 0000000..c8e17a9 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/gocardless/create_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Gocardless::Create do + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + let(:access_code) { "ert_123456_abc" } + let(:oauth_client) { instance_double(OAuth2::Client) } + let(:auth_code_strategy) { instance_double(OAuth2::Strategy::AuthCode) } + let(:access_token) { instance_double(OAuth2::AccessToken) } + let(:code) { "gocardless_1" } + let(:name) { "GoCardless 1" } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: AddGocardlessPaymentProviderInput!) { + addGocardlessPaymentProvider(input: $input) { + id, + code, + name, + hasAccessToken, + successRedirectUrl + } + } + GQL + end + + before do + allow(OAuth2::Client).to receive(:new) + .and_return(oauth_client) + allow(oauth_client).to receive(:auth_code) + .and_return(auth_code_strategy) + allow(auth_code_strategy).to receive(:get_token) + .and_return(access_token) + allow(access_token).to receive(:token) + .and_return("access_token_554") + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + it "creates a gocardless provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + accessCode: access_code, + code:, + name:, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result["data"]["addGocardlessPaymentProvider"] + + expect(result_data["id"]).to be_present + expect(result_data["hasAccessToken"]).to be(true) + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + end +end diff --git a/spec/graphql/mutations/payment_providers/gocardless/update_spec.rb b/spec/graphql/mutations/payment_providers/gocardless/update_spec.rb new file mode 100644 index 0000000..851b810 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/gocardless/update_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Gocardless::Update do + let(:required_permission) { "organization:integrations:update" } + let(:oauth_client) { instance_double(OAuth2::Client) } + let(:auth_code_strategy) { instance_double(OAuth2::Strategy::AuthCode) } + let(:access_token) { instance_double(OAuth2::AccessToken) } + let(:membership) { create(:membership) } + let(:gocardless_provider) { create(:gocardless_provider, organization: membership.organization) } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: UpdateGocardlessPaymentProviderInput!) { + updateGocardlessPaymentProvider(input: $input) { + id, + successRedirectUrl + } + } + GQL + end + + before do + allow(OAuth2::Client).to receive(:new) + .and_return(oauth_client) + allow(oauth_client).to receive(:auth_code) + .and_return(auth_code_strategy) + allow(auth_code_strategy).to receive(:get_token) + .and_return(access_token) + allow(access_token).to receive(:token) + .and_return("access_token_554") + + gocardless_provider + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "updates an gocardless provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + id: gocardless_provider.id, + successRedirectUrl: success_redirect_url + } + } + ) + + result_data = result["data"]["updateGocardlessPaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + end + + context "when success redirect url is nil" do + it "removes success redirect url from the provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: gocardless_provider.id, + successRedirectUrl: nil + } + } + ) + + result_data = result["data"]["updateGocardlessPaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(nil) + end + end +end diff --git a/spec/graphql/mutations/payment_providers/stripe/create_spec.rb b/spec/graphql/mutations/payment_providers/stripe/create_spec.rb new file mode 100644 index 0000000..14c3f78 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/stripe/create_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Stripe::Create do + let(:required_permission) { "organization:integrations:create" } + let(:membership) { create(:membership) } + + let(:mutation) do + <<-GQL + mutation($input: AddStripePaymentProviderInput!) { + addStripePaymentProvider(input: $input) { + id + secretKey + code + name + successRedirectUrl + supports3ds + } + } + GQL + end + + let(:code) { "stripe_1" } + let(:name) { "Stripe 1" } + let(:secret_key) { "sk_12345678901234567890" } + let(:success_redirect_url) { Faker::Internet.url } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:create" + + it "creates a stripe provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + secretKey: secret_key, + code:, + name:, + successRedirectUrl: success_redirect_url, + supports3ds: true + } + } + ) + + result_data = result["data"]["addStripePaymentProvider"] + + expect(result_data["id"]).to be_present + expect(result_data["secretKey"]).to eq("••••••••…890") + expect(result_data["code"]).to eq(code) + expect(result_data["name"]).to eq(name) + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + expect(result_data["supports3ds"]).to eq(true) + end +end diff --git a/spec/graphql/mutations/payment_providers/stripe/update_spec.rb b/spec/graphql/mutations/payment_providers/stripe/update_spec.rb new file mode 100644 index 0000000..778de55 --- /dev/null +++ b/spec/graphql/mutations/payment_providers/stripe/update_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentProviders::Stripe::Update do + let(:required_permission) { "organization:integrations:update" } + let(:membership) { create(:membership) } + let(:stripe_provider) { create(:stripe_provider, organization: membership.organization) } + let(:success_redirect_url) { Faker::Internet.url } + + let(:mutation) do + <<-GQL + mutation($input: UpdateStripePaymentProviderInput!) { + updateStripePaymentProvider(input: $input) { + id + successRedirectUrl + supports3ds + } + } + GQL + end + + before { stripe_provider } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:update" + + it "updates an stripe provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + # You wouldn't have `create` without `view` permission + # `view` is necessary to retrieve the created record in the response + permissions: [required_permission, "organization:integrations:view"], + query: mutation, + variables: { + input: { + id: stripe_provider.id, + successRedirectUrl: success_redirect_url, + supports3ds: true + } + } + ) + + result_data = result["data"]["updateStripePaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(success_redirect_url) + expect(result_data["supports3ds"]).to eq(true) + end + + context "when success redirect url is nil" do + it "removes success redirect url from the provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: stripe_provider.id, + successRedirectUrl: nil + } + } + ) + + result_data = result["data"]["updateStripePaymentProvider"] + + expect(result_data["successRedirectUrl"]).to eq(nil) + end + end +end diff --git a/spec/graphql/mutations/payment_receipts/download_spec.rb b/spec/graphql/mutations/payment_receipts/download_spec.rb new file mode 100644 index 0000000..75554d2 --- /dev/null +++ b/spec/graphql/mutations/payment_receipts/download_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentReceipts::Download do + let(:required_permission) { "invoices:view" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment) { create(:payment, payable: invoice) } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: DownloadPaymentReceiptInput!) { + downloadPaymentReceipt(input: $input) { + id + } + } + GQL + end + + before { stub_pdf_generation } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:view" + + it "generates the PDF for the given payment receipt" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: payment_receipt.id} + } + ) + + result_data = result["data"]["downloadPaymentReceipt"] + + expect(result_data["id"]).to be_present + end + end +end diff --git a/spec/graphql/mutations/payment_receipts/download_xml_spec.rb b/spec/graphql/mutations/payment_receipts/download_xml_spec.rb new file mode 100644 index 0000000..cdb5f93 --- /dev/null +++ b/spec/graphql/mutations/payment_receipts/download_xml_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentReceipts::DownloadXml do + let(:required_permission) { "invoices:view" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, country: "FR", einvoicing: true) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment) { create(:payment, payable: invoice, customer:) } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:, billing_entity:) } + + let(:mutation) do + <<~GQL + mutation($input: DownloadXMLPaymentReceiptInput!) { + downloadXmlPaymentReceipt(input: $input) { + id + xmlUrl + } + } + GQL + end + + before { payment_receipt } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:view" + + it "generates the XML for the given payment receipt" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: payment_receipt.id} + } + ) + + result_data = result["data"]["downloadXmlPaymentReceipt"] + + expect(result_data["id"]).to be_present + expect(result_data["xmlUrl"]).to be_present + end +end diff --git a/spec/graphql/mutations/payment_receipts/resend_email_spec.rb b/spec/graphql/mutations/payment_receipts/resend_email_spec.rb new file mode 100644 index 0000000..4746b58 --- /dev/null +++ b/spec/graphql/mutations/payment_receipts/resend_email_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentReceipts::ResendEmail do + let(:required_permission) { "payment_receipts:send" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, email: "customer@example.com") } + let(:billing_entity) { customer.billing_entity } + let(:invoice) { create(:invoice, customer:, organization:, status: :finalized) } + let(:payment) { create(:payment, payable: invoice) } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:) } + + let(:mutation) do + <<~GQL + mutation($input: ResendPaymentReceiptEmailInput!) { + resendPaymentReceiptEmail(input: $input) { + id + } + } + GQL + end + + before do + billing_entity.update!(email: "billing@example.com") + billing_entity.email_settings = ["payment_receipt.created"] + billing_entity.save! + allow(License).to receive(:premium?).and_return(true) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payment_receipts:send" + + it "resends the payment receipt email" do + expect do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: payment_receipt.id}} + ) + + expect(result["data"]["resendPaymentReceiptEmail"]["id"]).to eq(payment_receipt.id) + end.to have_enqueued_mail(PaymentReceiptMailer, :created) + end + + context "with custom recipients" do + it "resends the payment receipt email with custom recipients" do + expect do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: payment_receipt.id, + to: ["custom@example.com"], + cc: ["cc@example.com"], + bcc: ["bcc@example.com"] + } + } + ) + + expect(result["data"]["resendPaymentReceiptEmail"]["id"]).to eq(payment_receipt.id) + end.to have_enqueued_mail(PaymentReceiptMailer, :created) + end + end + + context "when payment receipt does not exist" do + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: {input: {id: SecureRandom.uuid}} + ) + + expect(result["errors"]).to be_present + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + end + end +end diff --git a/spec/graphql/mutations/payment_requests/create_spec.rb b/spec/graphql/mutations/payment_requests/create_spec.rb new file mode 100644 index 0000000..37e909b --- /dev/null +++ b/spec/graphql/mutations/payment_requests/create_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PaymentRequests::Create do + let(:required_permission) { "payments:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice1) { create(:invoice, organization:) } + let(:invoice2) { create(:invoice, organization:) } + + let(:input) do + { + email: "john.doe@example.com", + externalCustomerId: customer.external_id, + lagoInvoiceIds: [invoice1.id, invoice2.id] + } + end + + let(:mutation) do + <<-GQL + mutation($input: PaymentRequestCreateInput!) { + createPaymentRequest(input: $input) { + id + email + customer { id } + invoices { id } + } + } + GQL + end + + it "creates a payment request" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]).to include( + "createPaymentRequest" => nil + ) + end +end diff --git a/spec/graphql/mutations/payments/create_spec.rb b/spec/graphql/mutations/payments/create_spec.rb new file mode 100644 index 0000000..996da29 --- /dev/null +++ b/spec/graphql/mutations/payments/create_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Payments::Create do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + let(:required_permission) { "payments:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, total_amount_cents: 100) } + + let(:input) do + { + invoiceId: invoice.id, + reference: "ref1", + amountCents: 100, + createdAt: 1.day.ago.iso8601 + } + end + + let(:mutation) do + <<-GQL + mutation($input: CreatePaymentInput!) { + createPayment(input: $input) { + id + payablePaymentStatus + paymentType + } + } + GQL + end + + context "with premium organization", :premium do + it "creates a manual payment" do + expect(result["data"]).to include( + "createPayment" => { + "id" => anything, + "payablePaymentStatus" => "succeeded", + "paymentType" => "manual" + } + ) + end + end + + context "with free organization" do + it "returns an error" do + expect_graphql_error(result:, message: "feature_unavailable") + end + end +end diff --git a/spec/graphql/mutations/plans/create_spec.rb b/spec/graphql/mutations/plans/create_spec.rb new file mode 100644 index 0000000..ed86b5e --- /dev/null +++ b/spec/graphql/mutations/plans/create_spec.rb @@ -0,0 +1,601 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Plans::Create, :premium do + let(:required_permission) { "plans:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan_tax) { create(:tax, organization:) } + let(:charge_tax) { create(:tax, organization:) } + let(:commitment_tax) { create(:tax, organization:) } + let(:minimum_commitment_invoice_display_name) { "Minimum spending" } + let(:minimum_commitment_amount_cents) { 100 } + + let(:feature) { create(:feature, code: :seats, organization:) } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:entitlement) { create(:entitlement, feature:, plan:) } + let(:entitlement_value) { create(:entitlement_value, privilege:, entitlement:, value: "99") } + + let(:feature2) { create(:feature, code: "sso", organization:) } + + let(:mutation) do + <<~GQL + mutation($input: CreatePlanInput!) { + createPlan(input: $input) { + id, + name, + invoiceDisplayName, + code, + interval, + payInAdvance, + amountCents, + amountCurrency, + billChargesMonthly, + billFixedChargesMonthly, + taxes { id code rate } + minimumCommitment { + id, + amountCents, + invoiceDisplayName, + taxes { id code rate } + } + charges { + id, + chargeModel, + billableMetric { id name code } + taxes { id code rate } + appliedPricingUnit { + id + conversionRate + pricingUnit { id code name } + } + properties { + amount, + presentationGroupKeys { value options { displayInInvoice } } + freeUnits, + packageSize, + rate, + fixedAmount, + freeUnitsPerEvents, + freeUnitsPerTotalAggregation, + graduatedRanges { fromValue, toValue } + volumeRanges { fromValue, toValue } + graduatedPercentageRanges { fromValue toValue } + perTransactionMaxAmount + perTransactionMinAmount + } + filters { + invoiceDisplayName + values + properties { + amount + } + } + } + fixedCharges { + id, + chargeModel, + addOn { id name code } + taxes { id code rate } + properties { + amount, + graduatedRanges { fromValue, toValue } + volumeRanges { fromValue, toValue } + } + } + usageThresholds { + amountCents, + thresholdDisplayName, + recurring + } + entitlements { + code + privileges { code value } + } + } + } + GQL + end + + let(:billable_metrics) do + create_list(:billable_metric, 6, organization:) + end + + let(:add_ons) do + create_list(:add_on, 3, organization:) + end + + let(:billable_metric_filter) do + create( + :billable_metric_filter, + billable_metric: billable_metrics[0], + key: "payment_method", + values: %w[card sepa] + ) + end + + let(:tax) { create(:tax, organization:) } + let(:pricing_unit) { create(:pricing_unit, organization:) } + + before { organization.update!(premium_integrations: ["progressive_billing"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "plans:create" + + it "creates a plan" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "New Plan", + invoiceDisplayName: "New Plan Invoice Name", + code: "new_plan", + interval: "monthly", + payInAdvance: false, + amountCents: 200, + amountCurrency: "EUR", + taxCodes: [plan_tax.code], + minimumCommitment: { + amountCents: minimum_commitment_amount_cents, + invoiceDisplayName: minimum_commitment_invoice_display_name, + taxCodes: [commitment_tax.code] + }, + charges: [ + { + billableMetricId: billable_metrics[0].id, + chargeModel: "standard", + properties: { + amount: "100.00", + presentationGroupKeys: [ + { + value: "region", + options: { + displayInInvoice: true + } + } + ] + }, + taxCodes: [charge_tax.code], + appliedPricingUnit: { + code: pricing_unit.code, + conversionRate: 2.5 + }, + filters: [ + { + invoiceDisplayName: "Payment Method", + properties: { + amount: "100.00" + }, + values: {billable_metric_filter.key => %w[card sepa]} + } + ] + }, + { + billableMetricId: billable_metrics[1].id, + chargeModel: "package", + properties: { + amount: "300.00", + freeUnits: 10, + packageSize: 10 + } + }, + { + billableMetricId: billable_metrics[2].id, + chargeModel: "percentage", + properties: { + rate: "0.25", + fixedAmount: "2", + freeUnitsPerEvents: 5, + freeUnitsPerTotalAggregation: "50", + perTransactionMaxAmount: "20", + perTransactionMinAmount: "10" + } + }, + { + billableMetricId: billable_metrics[3].id, + chargeModel: "graduated", + properties: { + graduatedRanges: [ + { + fromValue: 0, + toValue: 10, + perUnitAmount: "2.00", + flatAmount: "0" + }, + { + fromValue: 11, + toValue: nil, + perUnitAmount: "3.00", + flatAmount: "3.00" + } + ] + } + }, + { + billableMetricId: billable_metrics[4].id, + chargeModel: "volume", + properties: { + volumeRanges: [ + { + fromValue: 0, + toValue: 10, + perUnitAmount: "2.00", + flatAmount: "0" + }, + { + fromValue: 11, + toValue: nil, + perUnitAmount: "3.00", + flatAmount: "3.00" + } + ] + } + }, + { + billableMetricId: billable_metrics[5].id, + chargeModel: "graduated_percentage", + properties: { + graduatedPercentageRanges: [ + { + fromValue: 0, + toValue: 10, + flatAmount: "0", + rate: "2" + }, + { + fromValue: 11, + toValue: nil, + flatAmount: "3.00", + rate: "3" + } + ] + } + } + ], + fixedCharges: [ + { + addOnId: add_ons[0].id, + chargeModel: "standard", + properties: {amount: "100.00"}, + taxCodes: [charge_tax.code] + }, + { + addOnId: add_ons[1].id, + chargeModel: "graduated", + properties: { + graduatedRanges: [ + { + fromValue: 0, + toValue: 10, + perUnitAmount: "2.00", + flatAmount: "0" + }, + { + fromValue: 11, + toValue: nil, + perUnitAmount: "3.00", + flatAmount: "3.00" + } + ] + } + }, + { + addOnId: add_ons[2].id, + chargeModel: "volume", + properties: { + volumeRanges: [ + { + fromValue: 0, + toValue: 10, + perUnitAmount: "5.00", + flatAmount: "0" + }, + { + fromValue: 11, + toValue: nil, + perUnitAmount: "1.00", + flatAmount: "2.00" + } + ] + } + } + ], + usageThresholds: [ + { + amountCents: 100, + thresholdDisplayName: "Threshold 1" + }, + { + amountCents: 200, + thresholdDisplayName: "Threshold 2" + }, + { + amountCents: 1, + thresholdDisplayName: "Threshold 3 Recurring", + recurring: true + } + ], + entitlements: [ + {featureCode: feature.code, privileges: [{privilegeCode: privilege.code, value: "22"}]}, + {featureCode: feature2.code, privileges: []} + ] + } + } + ) + + result_data = result["data"]["createPlan"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("New Plan") + expect(result_data["invoiceDisplayName"]).to eq("New Plan Invoice Name") + expect(result_data["code"]).to eq("new_plan") + expect(result_data["interval"]).to eq("monthly") + expect(result_data["payInAdvance"]).to eq(false) + expect(result_data["amountCents"]).to eq("200") + expect(result_data["billChargesMonthly"]).to be_nil + expect(result_data["billFixedChargesMonthly"]).to be_nil + expect(result_data["taxes"][0]["code"]).to eq(plan_tax.code) + expect(result_data["charges"].count).to eq(6) + expect(result_data["fixedCharges"].count).to eq(3) + expect(result_data["usageThresholds"].count).to eq(3) + + standard_charge = result_data["charges"][0] + expect(standard_charge["properties"]["amount"]).to eq("100.00") + expect(standard_charge.dig("properties", "presentationGroupKeys")).to eq([ + {"value" => "region", "options" => {"displayInInvoice" => true}} + ]) + expect(standard_charge["chargeModel"]).to eq("standard") + expect(standard_charge["taxes"].count).to eq(1) + expect(standard_charge["taxes"].first["code"]).to eq(charge_tax.code) + + applied_pricing_unit = standard_charge["appliedPricingUnit"] + expect(applied_pricing_unit).to be_present + expect(applied_pricing_unit["conversionRate"]).to eq(2.5) + expect(applied_pricing_unit["pricingUnit"]["code"]).to eq(pricing_unit.code) + expect(applied_pricing_unit["pricingUnit"]["name"]).to eq(pricing_unit.name) + + filter = standard_charge["filters"].first + expect(filter["invoiceDisplayName"]).to eq("Payment Method") + expect(filter["properties"]["amount"]).to eq("100.00") + expect(filter["values"]).to eq("payment_method" => %w[card sepa]) + + package_charge = result_data["charges"][1] + expect(package_charge["chargeModel"]).to eq("package") + package_properties = package_charge["properties"] + expect(package_properties["amount"]).to eq("300.00") + expect(package_properties["freeUnits"]).to eq("10") + expect(package_properties["packageSize"]).to eq("10") + + percentage_charge = result_data["charges"][2] + expect(percentage_charge["chargeModel"]).to eq("percentage") + percentage_properties = percentage_charge["properties"] + expect(percentage_properties["rate"]).to eq("0.25") + expect(percentage_properties["fixedAmount"]).to eq("2") + expect(percentage_properties["freeUnitsPerEvents"]).to eq("5") + expect(percentage_properties["freeUnitsPerTotalAggregation"]).to eq("50") + + graduated_charge = result_data["charges"][3] + expect(graduated_charge["chargeModel"]).to eq("graduated") + expect(graduated_charge["properties"]["graduatedRanges"].count).to eq(2) + + volume_charge = result_data["charges"][4] + expect(volume_charge["chargeModel"]).to eq("volume") + expect(volume_charge["properties"]["volumeRanges"].count).to eq(2) + + graduated_percentage_charge = result_data["charges"][5] + expect(graduated_percentage_charge["chargeModel"]).to eq("graduated_percentage") + expect(graduated_percentage_charge["properties"]["graduatedPercentageRanges"].count).to eq(2) + + expect(result_data["minimumCommitment"]).to include( + "invoiceDisplayName" => minimum_commitment_invoice_display_name, + "amountCents" => minimum_commitment_amount_cents.to_s + ) + expect(result_data["minimumCommitment"]["taxes"].count).to eq(1) + + thresholds = result_data["usageThresholds"].sort_by { |threshold| threshold["thresholdDisplayName"] } + expect(thresholds).to include hash_including( + "thresholdDisplayName" => "Threshold 1", + "amountCents" => "100", + "recurring" => false + ) + expect(thresholds).to include hash_including( + "thresholdDisplayName" => "Threshold 2", + "amountCents" => "200", + "recurring" => false + ) + expect(thresholds).to include hash_including( + "thresholdDisplayName" => "Threshold 3 Recurring", + "amountCents" => "1", + "recurring" => true + ) + + expect(result_data["entitlements"]).to contain_exactly( + { + "code" => "seats", + "privileges" => [{"code" => "max", "value" => "22"}] + }, { + "code" => "sso", + "privileges" => [] + } + ) + standard_fixed_charge = result_data["fixedCharges"][0] + expect(standard_fixed_charge["properties"]["amount"]).to eq("100.00") + expect(standard_fixed_charge["chargeModel"]).to eq("standard") + expect(standard_fixed_charge["taxes"].count).to eq(1) + expect(standard_fixed_charge["taxes"].first["code"]).to eq(charge_tax.code) + + graduated_fixed_charge = result_data["fixedCharges"][1] + expect(graduated_fixed_charge["chargeModel"]).to eq("graduated") + expect(graduated_fixed_charge["properties"]["graduatedRanges"].count).to eq(2) + + volume_fixed_charge = result_data["fixedCharges"][2] + expect(volume_fixed_charge["chargeModel"]).to eq("volume") + expect(volume_fixed_charge["properties"]["volumeRanges"].count).to eq(2) + end + + context "when fixed charges are not provided" do + it "creates a plan without fixed charges" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "Plan Without Fixed Charges", + invoiceDisplayName: "No Fixed Charges Plan", + code: "no_fixed_charges_plan", + interval: "monthly", + payInAdvance: false, + amountCents: 100, + amountCurrency: "USD", + charges: [] + } + } + ) + + result_data = result["data"]["createPlan"] + + expect(result_data["id"]).to be_present + expect(result_data["fixedCharges"]).to be_empty + end + end + + context "when interval is yearly" do + it "creates a plan with monthly billing for charges and fixed charges" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "New Plan", + invoiceDisplayName: "New Plan Invoice Name", + code: "new_plan", + interval: "yearly", + payInAdvance: true, + amountCents: 200, + amountCurrency: "EUR", + billChargesMonthly: true, + billFixedChargesMonthly: true, + charges: [], + fixedCharges: [] + } + } + ) + + result_data = result["data"]["createPlan"] + + expect(result_data["billChargesMonthly"]).to be true + expect(result_data["billFixedChargesMonthly"]).to be true + end + end + + context "when entitlements is nil" do + it "does not call PlanEntitlementsUpdateService" do + allow(::Entitlement::PlanEntitlementsUpdateService).to receive(:call) + + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "Plan without entitlements", + code: "plan_no_entitlements", + interval: "monthly", + payInAdvance: false, + amountCents: 100, + amountCurrency: "USD", + charges: [] + } + } + ) + + expect(::Entitlement::PlanEntitlementsUpdateService).not_to have_received(:call) + end + end + + context "when entitlements is an empty array" do + it "calls PlanEntitlementsUpdateService to remove all entitlements" do + allow(::Entitlement::PlanEntitlementsUpdateService).to receive(:call) + .and_return(BaseService::Result.new) + + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "Plan empty entitlements", + code: "plan_empty_entitlements", + interval: "monthly", + payInAdvance: false, + amountCents: 100, + amountCurrency: "USD", + charges: [], + entitlements: [] + } + } + ) + + expect(::Entitlement::PlanEntitlementsUpdateService).to have_received(:call).with( + organization: organization, + plan: Plan.last, + entitlements_params: {}, + partial: false + ) + end + end + + context "with metadata" do + let(:mutation) do + <<~GQL + mutation($input: CreatePlanInput!) { + createPlan(input: $input) { + id + code + metadata { key value } + } + } + GQL + end + + it "creates plan with metadata" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + name: "Plan with metadata", + code: "plan_with_metadata", + interval: "monthly", + payInAdvance: false, + amountCents: 100, + amountCurrency: "USD", + charges: [], + metadata: [{key: "foo", value: "bar"}, {key: "baz", value: "qux"}] + } + } + ) + + result_data = result["data"]["createPlan"] + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq("plan_with_metadata") + expect(result_data["metadata"]).to match_array([ + {"key" => "foo", "value" => "bar"}, + {"key" => "baz", "value" => "qux"} + ]) + end + end +end diff --git a/spec/graphql/mutations/plans/destroy_spec.rb b/spec/graphql/mutations/plans/destroy_spec.rb new file mode 100644 index 0000000..232e505 --- /dev/null +++ b/spec/graphql/mutations/plans/destroy_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Plans::Destroy do + subject(:graphql_request) do + execute_query( + query: mutation, + input: {id: plan.id} + ) + end + + let(:required_permission) { "plans:delete" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization: membership.organization) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyPlanInput!) { + destroyPlan(input: $input) { + id + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "plans:delete" + + it "marks plan as pending_deletion" do + expect { graphql_request }.to change { plan.reload.pending_deletion }.from(false).to(true) + end + + it "returns the deleted plan" do + data = graphql_request["data"]["destroyPlan"] + expect(data["id"]).to eq(plan.id) + end +end diff --git a/spec/graphql/mutations/plans/update_spec.rb b/spec/graphql/mutations/plans/update_spec.rb new file mode 100644 index 0000000..9ed2e86 --- /dev/null +++ b/spec/graphql/mutations/plans/update_spec.rb @@ -0,0 +1,732 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Plans::Update do + let(:required_permission) { "plans:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:minimum_commitment_invoice_display_name) { "Minimum spending" } + let(:minimum_commitment_amount_cents) { 100 } + let(:commitment_tax) { create(:tax, organization:) } + + let(:feature) { create(:feature, code: :seats, organization:) } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:entitlement) { create(:entitlement, feature:, plan:) } + let(:entitlement_value) { create(:entitlement_value, privilege:, entitlement:, value: "99") } + + let(:feature2) { create(:feature, code: "sso", organization:) } + + let(:mutation) do + <<~GQL + mutation($input: UpdatePlanInput!) { + updatePlan(input: $input) { + id, + name, + invoiceDisplayName, + code, + interval, + payInAdvance, + amountCents, + amountCurrency, + billChargesMonthly, + billFixedChargesMonthly, + minimumCommitment { + id, + amountCents, + invoiceDisplayName, + taxes { id code rate } + }, + charges { + id, + chargeModel, + billableMetric { id name code }, + appliedPricingUnit { + id + conversionRate + pricingUnit { id code name } + }, + properties { + amount, + presentationGroupKeys { value options { displayInInvoice } } + freeUnits, + packageSize, + rate, + fixedAmount, + freeUnitsPerEvents, + freeUnitsPerTotalAggregation, + graduatedRanges { fromValue, toValue }, + volumeRanges { fromValue, toValue } + } + filters { + invoiceDisplayName + values + properties { + amount + } + } + }, + fixedCharges { + id, + units, + addOn { id name code }, + chargeModel, + properties { + amount + graduatedRanges { fromValue, toValue }, + volumeRanges { fromValue, toValue } + } + }, + usageThresholds { + amountCents, + thresholdDisplayName, + recurring + } + entitlements { + code + privileges { code value } + } + } + } + GQL + end + + let(:billable_metrics) do + create_list(:billable_metric, 5, organization:) + end + + let(:billable_metric_filter) do + create( + :billable_metric_filter, + billable_metric: billable_metrics[0], + key: "payment_method", + values: %w[card sepa] + ) + end + + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:) } + let(:pricing_unit) { create(:pricing_unit, organization:) } + + let(:graphql) do + { + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: plan.id, + name: "Updated plan", + invoiceDisplayName: "Updated plan invoice name", + code: "new_plan", + interval: "monthly", + payInAdvance: false, + amountCents: "200", + amountCurrency: "EUR", + minimumCommitment: { + amountCents: minimum_commitment_amount_cents, + invoiceDisplayName: minimum_commitment_invoice_display_name, + taxCodes: [commitment_tax.code] + }, + charges: [ + { + billableMetricId: billable_metrics[0].id, + chargeModel: "standard", + properties: { + amount: "100.00", + presentationGroupKeys: [ + { + value: "region", + options: { + displayInInvoice: true + } + } + ] + }, + appliedPricingUnit: { + code: pricing_unit.code, + conversionRate: 0.1 + }, + filters: [ + { + invoiceDisplayName: "Payment method", + properties: { + amount: "10.00" + }, + values: {billable_metric_filter.key => %w[card]} + } + ] + }, + { + billableMetricId: billable_metrics[1].id, + chargeModel: "package", + properties: { + amount: "300.00", + freeUnits: 10, + packageSize: 10 + } + }, + { + billableMetricId: billable_metrics[2].id, + chargeModel: "percentage", + properties: { + rate: "0.25", + fixedAmount: "2", + freeUnitsPerEvents: 5, + freeUnitsPerTotalAggregation: "50" + } + }, + { + billableMetricId: billable_metrics[3].id, + chargeModel: "graduated", + properties: { + graduatedRanges: [ + { + fromValue: 0, + toValue: 10, + perUnitAmount: "2.00", + flatAmount: "0" + }, + { + fromValue: 11, + toValue: nil, + perUnitAmount: "3.00", + flatAmount: "3.00" + } + ] + } + }, + { + billableMetricId: billable_metrics[4].id, + chargeModel: "volume", + properties: { + volumeRanges: [ + { + fromValue: 0, + toValue: 10, + perUnitAmount: "2.00", + flatAmount: "0" + }, + { + fromValue: 11, + toValue: nil, + perUnitAmount: "3.00", + flatAmount: "3.00" + } + ] + } + } + ], + usageThresholds: [ + { + amountCents: 100, + thresholdDisplayName: "Threshold 1" + }, + { + amountCents: 200, + thresholdDisplayName: "Threshold 2" + }, + { + amountCents: 1, + thresholdDisplayName: "Threshold 3 Recurring", + recurring: true + } + ], + entitlements: [ + {featureCode: feature.code, privileges: [{privilegeCode: privilege.code, value: "22"}]}, + {featureCode: feature2.code, privileges: []} + ] + } + } + } + end + + before do + minimum_commitment + entitlement_value + feature2 + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "plans:update" + + context "with premium license", :premium do + before { organization.update!(premium_integrations: ["progressive_billing"]) } + + it "updates a plan" do + result = execute_graphql(**graphql) + result_data = result["data"]["updatePlan"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Updated plan") + expect(result_data["invoiceDisplayName"]).to eq("Updated plan invoice name") + expect(result_data["code"]).to eq("new_plan") + expect(result_data["interval"]).to eq("monthly") + expect(result_data["payInAdvance"]).to eq(false) + expect(result_data["amountCents"]).to eq("200") + expect(result_data["amountCurrency"]).to eq("EUR") + expect(result_data["charges"].count).to eq(5) + expect(result_data["usageThresholds"].count).to eq(3) + + standard_charge = result_data["charges"][0] + expect(standard_charge["properties"]["amount"]).to eq("100.00") + expect(standard_charge.dig("properties", "presentationGroupKeys")).to eq([ + {"value" => "region", "options" => {"displayInInvoice" => true}} + ]) + expect(standard_charge["chargeModel"]).to eq("standard") + + applied_pricing_unit = standard_charge["appliedPricingUnit"] + expect(applied_pricing_unit).to be_present + expect(applied_pricing_unit["conversionRate"]).to eq(0.1) + expect(applied_pricing_unit["pricingUnit"]["code"]).to eq(pricing_unit.code) + expect(applied_pricing_unit["pricingUnit"]["name"]).to eq(pricing_unit.name) + + filter = standard_charge["filters"].first + expect(filter["invoiceDisplayName"]).to eq("Payment method") + expect(filter["properties"]["amount"]).to eq("10.00") + expect(filter["values"]).to eq("payment_method" => %w[card]) + + package_charge = result_data["charges"][1] + expect(package_charge["chargeModel"]).to eq("package") + package_properties = package_charge["properties"] + expect(package_properties["amount"]).to eq("300.00") + expect(package_properties["freeUnits"]).to eq("10") + expect(package_properties["packageSize"]).to eq("10") + + percentage_charge = result_data["charges"][2] + expect(percentage_charge["chargeModel"]).to eq("percentage") + percentage_properties = percentage_charge["properties"] + expect(percentage_properties["rate"]).to eq("0.25") + expect(percentage_properties["fixedAmount"]).to eq("2") + expect(percentage_properties["freeUnitsPerEvents"]).to eq("5") + expect(percentage_properties["freeUnitsPerTotalAggregation"]).to eq("50") + + graduated_charge = result_data["charges"][3] + expect(graduated_charge["chargeModel"]).to eq("graduated") + expect(graduated_charge["properties"]["graduatedRanges"].count).to eq(2) + + volume_charge = result_data["charges"][4] + expect(volume_charge["chargeModel"]).to eq("volume") + expect(volume_charge["properties"]["volumeRanges"].count).to eq(2) + + expect(result_data["minimumCommitment"]).to include( + "invoiceDisplayName" => minimum_commitment_invoice_display_name, + "amountCents" => minimum_commitment_amount_cents.to_s + ) + expect(result_data["minimumCommitment"]["taxes"].count).to eq(1) + + thresholds = result_data["usageThresholds"].sort_by { |threshold| threshold["thresholdDisplayName"] } + expect(thresholds).to include hash_including( + "thresholdDisplayName" => "Threshold 1", + "amountCents" => "100", + "recurring" => false + ) + expect(thresholds).to include hash_including( + "thresholdDisplayName" => "Threshold 2", + "amountCents" => "200", + "recurring" => false + ) + expect(thresholds).to include hash_including( + "thresholdDisplayName" => "Threshold 3 Recurring", + "amountCents" => "1", + "recurring" => true + ) + + expect(result_data["entitlements"]).to contain_exactly( + { + "code" => "seats", + "privileges" => [{"code" => "max", "value" => "22"}] + }, { + "code" => "sso", + "privileges" => [] + } + ) + end + + it "updates minimum commitment" do + result = execute_graphql(**graphql) + result_data = result["data"]["updatePlan"] + + expect(result_data["minimumCommitment"]).to include( + "invoiceDisplayName" => minimum_commitment_invoice_display_name, + "amountCents" => minimum_commitment_amount_cents.to_s + ) + expect(result_data["minimumCommitment"]["taxes"].count).to eq(1) + end + end + + context "without premium license" do + it "updates a plan" do + result = execute_graphql(**graphql) + result_data = result["data"]["updatePlan"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Updated plan") + expect(result_data["invoiceDisplayName"]).to eq("Updated plan invoice name") + expect(result_data["code"]).to eq("new_plan") + expect(result_data["interval"]).to eq("monthly") + expect(result_data["payInAdvance"]).to eq(false) + expect(result_data["amountCents"]).to eq("200") + expect(result_data["amountCurrency"]).to eq("EUR") + expect(result_data["charges"].count).to eq(5) + + standard_charge = result_data["charges"][0] + expect(standard_charge["properties"]["amount"]).to eq("100.00") + expect(standard_charge.dig("properties", "presentationGroupKeys")).to eq([ + {"value" => "region", "options" => {"displayInInvoice" => true}} + ]) + expect(standard_charge["chargeModel"]).to eq("standard") + + expect(standard_charge["appliedPricingUnit"]).to be_nil + + package_charge = result_data["charges"][1] + expect(package_charge["chargeModel"]).to eq("package") + package_properties = package_charge["properties"] + expect(package_properties["amount"]).to eq("300.00") + expect(package_properties["freeUnits"]).to eq("10") + expect(package_properties["packageSize"]).to eq("10") + + percentage_charge = result_data["charges"][2] + expect(percentage_charge["chargeModel"]).to eq("percentage") + percentage_properties = percentage_charge["properties"] + expect(percentage_properties["rate"]).to eq("0.25") + expect(percentage_properties["fixedAmount"]).to eq("2") + expect(percentage_properties["freeUnitsPerEvents"]).to eq("5") + expect(percentage_properties["freeUnitsPerTotalAggregation"]).to eq("50") + + graduated_charge = result_data["charges"][3] + expect(graduated_charge["chargeModel"]).to eq("graduated") + expect(graduated_charge["properties"]["graduatedRanges"].count).to eq(2) + + volume_charge = result_data["charges"][4] + expect(volume_charge["chargeModel"]).to eq("volume") + expect(volume_charge["properties"]["volumeRanges"].count).to eq(2) + + expect(result_data["entitlements"]).to contain_exactly( + { + "code" => "seats", + "privileges" => [{"code" => "max", "value" => "22"}] + }, { + "code" => "sso", + "privileges" => [] + } + ) + end + + it "does not update minimum commitment" do + result = execute_graphql(**graphql) + result_data = result["data"]["updatePlan"] + + expect(result_data["minimumCommitment"]).to include( + "invoiceDisplayName" => minimum_commitment.invoice_display_name, + "amountCents" => minimum_commitment.amount_cents.to_s + ) + end + end + + context "when entitlements is nil" do + it "does not call PlanEntitlementsUpdateService" do + allow(::Entitlement::PlanEntitlementsUpdateService).to receive(:call) + + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: plan.id, + name: "Updated plan", + code: "updated_plan", + interval: "monthly", + payInAdvance: false, + amountCents: 200, + amountCurrency: "EUR", + charges: [] + } + } + ) + + expect(::Entitlement::PlanEntitlementsUpdateService).not_to have_received(:call) + end + end + + context "when entitlements is an empty array" do + it "calls PlanEntitlementsUpdateService to remove all entitlements" do + allow(::Entitlement::PlanEntitlementsUpdateService).to receive(:call) + .and_return(BaseService::Result.new) + + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: plan.id, + name: "Updated plan", + code: "updated_plan", + interval: "monthly", + payInAdvance: false, + amountCents: 200, + amountCurrency: "EUR", + charges: [], + entitlements: [] + } + } + ) + + expect(::Entitlement::PlanEntitlementsUpdateService).to have_received(:call).with( + organization: organization, + plan:, + entitlements_params: {}, + partial: false + ) + end + end + + context "when fixed charges are not provided" do + let(:fixed_charge) { create(:fixed_charge, plan:, charge_model: "standard", properties: {amount: "100.00"}) } + + before do + fixed_charge + end + + it "updates the plan without changing fixed charges" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: plan.id, + name: "Updated plan", + code: "updated_plan", + interval: "monthly", + payInAdvance: true, + amountCents: 200, + amountCurrency: "EUR", + charges: [] + } + } + ) + + result_data = result["data"]["updatePlan"] + + expect(result_data["fixedCharges"].count).to eq(1) + expect(result_data["fixedCharges"].first["id"]).to eq(fixed_charge.id) + end + end + + context "when fixed charges are provided" do + let(:add_ons) { create_list(:add_on, 5, organization:) } + let(:add_on_1) { add_ons[0] } + let(:add_on_2) { add_ons[1] } + let(:add_on_3) { add_ons[2] } + let(:add_on_4) { add_ons[3] } + let(:add_on_5) { add_ons[4] } + + let(:fixed_charge_1) { create(:fixed_charge, plan:, add_on: add_on_1, units: 10) } + let(:fixed_charge_2) { create(:fixed_charge, plan:, add_on: add_on_2, units: 5) } + + let(:active_subscription) { create(:subscription, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + active_subscription + end + + it "updates the plan with the provided fixed charges" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: plan.id, + name: "Updated plan", + code: "updated_plan", + interval: "monthly", + payInAdvance: true, + amountCents: 200, + amountCurrency: "EUR", + charges: [], + fixedCharges: [ + { + id: fixed_charge_1.id, + addOnId: add_on_1.id, + # does not change units + units: "10", + chargeModel: "standard", + properties: {amount: "10.00"}, + applyUnitsImmediately: true + }, + { + id: fixed_charge_2.id, + addOnId: add_on_2.id, + # changes units + units: "20.55", + chargeModel: "standard", + properties: {amount: "100.00"}, + applyUnitsImmediately: true + }, + { + addOnId: add_on_3.id, + units: "30", + chargeModel: "standard", + properties: {amount: "1000.00"}, + applyUnitsImmediately: true + }, + { + addOnId: add_on_4.id, + units: "40", + chargeModel: "graduated", + properties: { + graduatedRanges: [ + {fromValue: 0, toValue: 10, perUnitAmount: "10.00", flatAmount: "0"}, + {fromValue: 11, toValue: nil, perUnitAmount: "15.00", flatAmount: "100"} + ] + } + }, + { + addOnId: add_on_5.id, + units: "50", + chargeModel: "volume", + properties: { + volumeRanges: [ + {fromValue: 0, toValue: nil, perUnitAmount: "10.00", flatAmount: "0"} + ] + } + } + ] + } + } + ) + + result_data = result["data"]["updatePlan"] + + expect(result_data["fixedCharges"].count).to eq(5) + + expect(result_data["fixedCharges"].first["id"]).to eq(fixed_charge_1.id) + expect(result_data["fixedCharges"].first["units"]).to eq("10") + expect(result_data["fixedCharges"].first["properties"]["amount"]).to eq("10.00") + expect(result_data["fixedCharges"].first["addOn"]["id"]).to eq(fixed_charge_1.add_on_id) + + expect(result_data["fixedCharges"].second["id"]).to eq(fixed_charge_2.id) + expect(result_data["fixedCharges"].second["units"]).to eq("20.55") + expect(result_data["fixedCharges"].second["properties"]["amount"]).to eq("100.00") + expect(result_data["fixedCharges"].second["addOn"]["id"]).to eq(fixed_charge_2.add_on_id) + + expect(result_data["fixedCharges"].third["chargeModel"]).to eq("standard") + expect(result_data["fixedCharges"].third["units"]).to eq("30") + expect(result_data["fixedCharges"].third["properties"]["amount"]).to eq("1000.00") + expect(result_data["fixedCharges"].third["addOn"]["id"]).to eq(add_on_3.id) + expect(result_data["fixedCharges"].third["addOn"]["name"]).to eq(add_on_3.name) + + expect(result_data["fixedCharges"].fourth["chargeModel"]).to eq("graduated") + expect(result_data["fixedCharges"].fourth["units"]).to eq("40") + expect(result_data["fixedCharges"].fourth["properties"]["graduatedRanges"].count).to eq(2) + expect(result_data["fixedCharges"].fourth["addOn"]["id"]).to eq(add_on_4.id) + expect(result_data["fixedCharges"].fourth["addOn"]["name"]).to eq(add_on_4.name) + + expect(result_data["fixedCharges"].fifth["chargeModel"]).to eq("volume") + expect(result_data["fixedCharges"].fifth["units"]).to eq("50") + expect(result_data["fixedCharges"].fifth["properties"]["volumeRanges"].count).to eq(1) + expect(result_data["fixedCharges"].fifth["addOn"]["id"]).to eq(add_on_5.id) + expect(result_data["fixedCharges"].fifth["addOn"]["name"]).to eq(add_on_5.name) + + expect(FixedCharge.count).to eq(5) + + # NOTE: first fixed charge does not have event associated because units did not change + # and subscription was created with factory and already activated. + expect(FixedChargeEvent.count).to eq(4) + + fixed_charge_3 = plan.fixed_charges.find_by(add_on_id: add_on_3.id) + fixed_charge_4 = plan.fixed_charges.find_by(add_on_id: add_on_4.id) + fixed_charge_5 = plan.fixed_charges.find_by(add_on_id: add_on_5.id) + + expect(FixedChargeEvent.pluck(:fixed_charge_id, :timestamp, :units)) + .to contain_exactly( + [fixed_charge_2.id, be_within(1.minute).of(Time.current), BigDecimal("20.55")], + [fixed_charge_3.id, be_within(1.minute).of(Time.current), BigDecimal(30)], + [fixed_charge_4.id, be_within(1.minute).of(1.month.from_now.beginning_of_month), BigDecimal(40)], + [fixed_charge_5.id, be_within(1.minute).of(1.month.from_now.beginning_of_month), BigDecimal(50)] + ) + end + end + + context "when interval is yearly" do + it "updates a plan with monthly billing for charges and fixed charges" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: plan.id, + name: "Updated plan", + code: "updated_plan", + interval: "yearly", + payInAdvance: true, + amountCents: 200, + amountCurrency: "EUR", + billChargesMonthly: true, + billFixedChargesMonthly: true, + charges: [] + } + } + ) + + result_data = result["data"]["updatePlan"] + + expect(result_data["billChargesMonthly"]).to be true + expect(result_data["billFixedChargesMonthly"]).to be true + end + end + + context "with metadata" do + let(:mutation) do + <<~GQL + mutation($input: UpdatePlanInput!) { + updatePlan(input: $input) { + id + metadata { key value } + } + } + GQL + end + + before { create(:item_metadata, owner: plan, organization:, value: {"existing" => "value"}) } + + it "replaces metadata (not merges)" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: plan.id, + name: "Updated plan", + code: plan.code, + interval: "monthly", + payInAdvance: false, + amountCents: 200, + amountCurrency: "EUR", + charges: [], + metadata: [{key: "new", value: "data"}] + } + } + ) + + result_data = result["data"]["updatePlan"] + expect(result_data["metadata"]).to eq([{"key" => "new", "value" => "data"}]) + end + end +end diff --git a/spec/graphql/mutations/pricing_units/create_spec.rb b/spec/graphql/mutations/pricing_units/create_spec.rb new file mode 100644 index 0000000..6895318 --- /dev/null +++ b/spec/graphql/mutations/pricing_units/create_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PricingUnits::Create do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: input_params} + ) + end + + let(:query) do + <<-GQL + mutation($input: CreatePricingUnitInput!) { + createPricingUnit(input: $input) { id name code shortName description } + } + GQL + end + + let(:required_permission) { "pricing_units:create" } + let(:membership) { create(:membership) } + + let(:input_params) do + { + name: "Cloud token", + code:, + shortName: "CT", + description: "" + } + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "pricing_units:create" + + context "with premium organization", :premium do + context "with valid params" do + let(:code) { "cloud_token" } + + it "creates a new pricing unit" do + expect { result }.to change(PricingUnit, :count).by(1) + end + + it "returns created pricing unit" do + pricing_unit_response = result["data"]["createPricingUnit"] + + expect(pricing_unit_response["name"]).to eq(input_params[:name]) + expect(pricing_unit_response["code"]).to eq(input_params[:code]) + expect(pricing_unit_response["shortName"]).to eq(input_params[:shortName]) + expect(pricing_unit_response["description"]).to eq(input_params[:description]) + end + end + + context "with invalid params" do + let(:code) { "" } + + it "does not create a new pricing unit" do + expect { result }.not_to change(PricingUnit, :count) + end + + it "returns validation error" do + expect_graphql_error(result:, message: "unprocessable_entity") + end + end + end + + context "with free organization" do + let(:code) { "cloud_token" } + + it "does not create a new pricing unit" do + expect { result }.not_to change(PricingUnit, :count) + end + + it "returns an error" do + expect_graphql_error(result:, message: "feature_unavailable") + end + end +end diff --git a/spec/graphql/mutations/pricing_units/update_spec.rb b/spec/graphql/mutations/pricing_units/update_spec.rb new file mode 100644 index 0000000..8c754b4 --- /dev/null +++ b/spec/graphql/mutations/pricing_units/update_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::PricingUnits::Update, :premium do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {input: input_params} + ) + end + + let(:query) do + <<-GQL + mutation($input: UpdatePricingUnitInput!) { + updatePricingUnit(input: $input) { id name shortName description } + } + GQL + end + + let(:required_permission) { "pricing_units:update" } + let!(:membership) { create(:membership) } + let(:input_params) { {id: pricing_unit.id, name:, shortName: short_name, description:} } + let(:name) { "Updated Name" } + let(:short_name) { "CR" } + let(:description) { "Updated Description" } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "pricing_units:update" + + context "when pricing unit with such ID exists in the current organization" do + let(:pricing_unit) { create(:pricing_unit, organization: membership.organization) } + + context "with valid params" do + it "returns updated pricing unit" do + pricing_unit_response = result["data"]["updatePricingUnit"] + + expect(pricing_unit_response["id"]).to eq(pricing_unit.id) + expect(pricing_unit_response["name"]).to eq(name) + expect(pricing_unit_response["shortName"]).to eq(short_name) + expect(pricing_unit_response["description"]).to eq(description) + end + end + + context "with invalid params" do + let(:name) { "" } + + it "does not change the pricing unit" do + expect { result }.not_to change { pricing_unit.reload.attributes } + end + + it "returns validation error" do + expect_graphql_error(result:, message: "unprocessable_entity") + end + end + end + + context "when pricing unit with such ID does not exist in the current organization" do + let!(:pricing_unit) { create(:pricing_unit) } + + it "does not change the pricing unit" do + expect { result }.not_to change { pricing_unit.reload.attributes } + end + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/mutations/quote_versions/approve_spec.rb b/spec/graphql/mutations/quote_versions/approve_spec.rb new file mode 100644 index 0000000..ba41ef3 --- /dev/null +++ b/spec/graphql/mutations/quote_versions/approve_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::QuoteVersions::Approve do + let(:required_permission) { "quotes:approve" } + let(:membership) { create(:membership) } + let(:quote_version) { create(:quote_version, organization: membership.organization) } + + let(:input) do + { + id: quote_version.id + } + end + + let(:mutation) do + <<-GQL + mutation($input: ApproveQuoteVersionInput!) { + approveQuoteVersion(input: $input) { + id, + organization { id }, + version, + status, + approvedAt + } + } + GQL + end + + before do + membership.organization.enable_feature_flag!(:order_forms) + quote_version + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:approve" + + context "with valid input", :premium do + it "approves a quote version" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["approveQuoteVersion"]).to include( + "id" => quote_version.id, + "organization" => {"id" => membership.organization.id}, + "version" => quote_version.version, + "status" => "approved", + "approvedAt" => Time.current.iso8601 + ) + end + end + end + + context "when quote version is not found", :premium do + let(:input) { {id: "00000000-0000-0000-0000-000000000000"} } + + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_not_found(result) + end + end + + context "when quote version is not in draft state", :premium do + let(:quote_version) { create(:quote_version, :voided, organization: membership.organization) } + + it "returns a not allowed error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_graphql_error(result:, message: "inappropriate_state") + end + end +end diff --git a/spec/graphql/mutations/quote_versions/clone_spec.rb b/spec/graphql/mutations/quote_versions/clone_spec.rb new file mode 100644 index 0000000..4f6987b --- /dev/null +++ b/spec/graphql/mutations/quote_versions/clone_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::QuoteVersions::Clone do + let(:required_permission) { "quotes:clone" } + let(:membership) { create(:membership) } + let(:quote_version) { create(:quote_version, organization: membership.organization) } + + let(:input) do + { + id: quote_version.id + } + end + + let(:mutation) do + <<-GQL + mutation($input: CloneQuoteVersionInput!) { + cloneQuoteVersion(input: $input) { + id, + organization { id }, + version, + status, + shareToken, + voidReason, + voidedAt + } + } + GQL + end + + before do + membership.organization.enable_feature_flag!(:order_forms) + quote_version + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:clone" + + context "with valid input", :premium do + it "clones a quote" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + cloned = result["data"]["cloneQuoteVersion"] + expect(cloned).to include( + "organization" => {"id" => membership.organization.id}, + "version" => quote_version.version + 1, + "status" => "draft", + "voidReason" => nil, + "voidedAt" => nil + ) + expect(cloned["id"]).to be_present + expect(cloned["id"]).not_to eq(quote_version.id) + expect(cloned["shareToken"]).to be_present + + quote_version.reload + expect(quote_version.voided?).to eq(true) + expect(quote_version.void_reason).to eq("superseded") + expect(quote_version.voided_at).to eq(Time.current) + expect(quote_version.share_token).to eq(nil) + end + end + end + + context "when quote version is not found", :premium do + let(:input) { {id: "00000000-0000-0000-0000-000000000000"} } + + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_not_found(result) + end + end + + context "when quote version is approved", :premium do + let(:quote_version) { create(:quote_version, :approved, organization: membership.organization) } + + it "returns a not allowed error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_graphql_error(result:, message: "inappropriate_state") + end + end +end diff --git a/spec/graphql/mutations/quote_versions/update_spec.rb b/spec/graphql/mutations/quote_versions/update_spec.rb new file mode 100644 index 0000000..b0d3c66 --- /dev/null +++ b/spec/graphql/mutations/quote_versions/update_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::QuoteVersions::Update do + let(:required_permission) { "quotes:update" } + let(:membership) { create(:membership) } + let(:quote_version) { create(:quote_version, organization: membership.organization) } + + let(:input) do + { + id: quote_version.id, + billingItems: {}, + content: "Test content" + } + end + + let(:mutation) do + <<-GQL + mutation($input: UpdateQuoteVersionInput!) { + updateQuoteVersion(input: $input) { + id, + organization { id }, + version, + status, + billingItems, + content + } + } + GQL + end + + before do + membership.organization.enable_feature_flag!(:order_forms) + quote_version + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:update" + + context "with valid input", :premium do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + it "updates a quote version" do + expect(result["data"]["updateQuoteVersion"]).to include( + "id" => quote_version.id, + "organization" => {"id" => membership.organization.id}, + "version" => quote_version.version, + "status" => quote_version.status, + "billingItems" => {}, + "content" => "Test content" + ) + end + end + + context "when quote version is not found", :premium do + let(:input) do + { + id: "00000000-0000-0000-0000-000000000000", + billingItems: {}, + content: "Test content" + } + end + + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_not_found(result) + end + end + + context "when quote version is not in draft state", :premium do + let(:quote_version) { create(:quote_version, :voided, organization: membership.organization) } + + it "returns a not allowed error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_graphql_error(result:, message: "inappropriate_state") + end + end +end diff --git a/spec/graphql/mutations/quote_versions/void_spec.rb b/spec/graphql/mutations/quote_versions/void_spec.rb new file mode 100644 index 0000000..6548ce0 --- /dev/null +++ b/spec/graphql/mutations/quote_versions/void_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::QuoteVersions::Void do + let(:required_permission) { "quotes:void" } + let(:membership) { create(:membership) } + let(:quote_version) { create(:quote_version, organization: membership.organization) } + + let(:input) do + { + id: quote_version.id, + reason: "manual" + } + end + + let(:mutation) do + <<-GQL + mutation($input: VoidQuoteVersionInput!) { + voidQuoteVersion(input: $input) { + id, + organization { id }, + version, + status, + voidReason, + voidedAt + } + } + GQL + end + + before do + membership.organization.enable_feature_flag!(:order_forms) + quote_version + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:void" + + context "with valid input", :premium do + it "voids a quote" do + freeze_time do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["voidQuoteVersion"]).to include( + "id" => quote_version.id, + "organization" => {"id" => membership.organization.id}, + "version" => quote_version.version, + "status" => "voided", + "voidReason" => "manual", + "voidedAt" => Time.current.iso8601 + ) + end + end + end + + context "when quote version is not found", :premium do + let(:input) { {id: "00000000-0000-0000-0000-000000000000", reason: "manual"} } + + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_not_found(result) + end + end + + context "when quote version is already voided", :premium do + let(:quote_version) { create(:quote_version, :voided, organization: membership.organization) } + + it "returns a not allowed error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_graphql_error(result:, message: "inappropriate_state") + end + end +end diff --git a/spec/graphql/mutations/quotes/create_spec.rb b/spec/graphql/mutations/quotes/create_spec.rb new file mode 100644 index 0000000..d1684ad --- /dev/null +++ b/spec/graphql/mutations/quotes/create_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Quotes::Create do + let(:required_permission) { "quotes:create" } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization) } + let(:input) do + { + customerId: customer.id, + orderType: "one_off", + content: "Test content", + billingItems: {} + } + end + let(:mutation) do + <<-GQL + mutation($input: CreateQuoteInput!) { + createQuote(input: $input) { + id, + customer { id }, + organization { id }, + number, + orderType + currentVersion { id version status content billingItems } + versions { id version status content billingItems } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:create" + + context "with valid input", :premium do + before { membership.organization.enable_feature_flag!(:order_forms) } + + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + it "creates a quote" do + quote = result["data"]["createQuote"] + expect(quote).to include( + "id" => String, + "customer" => {"id" => customer.id}, + "organization" => {"id" => membership.organization.id}, + "number" => String, + "orderType" => "one_off", + "currentVersion" => { + "id" => String, + "status" => "draft", + "version" => 1, + "content" => "Test content", + "billingItems" => {} + } + ) + expect(quote["versions"].size).to eq(1) + expect(quote["currentVersion"]).to eq(quote["versions"].first) + end + end + + context "when customer is not found", :premium do + before { membership.organization.enable_feature_flag!(:order_forms) } + + let(:input) do + { + customerId: "00000000-0000-0000-0000-000000000000", + orderType: "one_off", + content: "Test content", + billingItems: {} + } + end + + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_not_found(result) + end + end + + context "when subscription is required but missing", :premium do + before { membership.organization.enable_feature_flag!(:order_forms) } + + let(:input) do + { + customerId: customer.id, + orderType: "subscription_amendment", + content: "Test content", + billingItems: {} + } + end + + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/quotes/update_spec.rb b/spec/graphql/mutations/quotes/update_spec.rb new file mode 100644 index 0000000..1095f9a --- /dev/null +++ b/spec/graphql/mutations/quotes/update_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Quotes::Update do + let(:required_permission) { "quotes:update" } + let(:membership) { create(:membership) } + let(:quote) { create(:quote, :with_version, organization: membership.organization) } + + let(:input) do + { + id: quote.id, + owners: [membership.user.id] + } + end + + let(:mutation) do + <<-GQL + mutation($input: UpdateQuoteInput!) { + updateQuote(input: $input) { + id, + organization { id }, + owners { id email } + } + } + GQL + end + + before do + membership.organization.enable_feature_flag!(:order_forms) + quote + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:update" + + context "with valid input", :premium do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + it "updates a quote" do + expect(result["data"]["updateQuote"]).to include( + "id" => quote.id, + "organization" => {"id" => membership.organization.id}, + "owners" => [ + { + "id" => membership.user.id, + "email" => membership.user.email + } + ] + ) + end + end + + context "when quote is not found", :premium do + let(:input) do + { + id: "00000000-0000-0000-0000-000000000000", + owners: [membership.user.id] + } + end + + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/mutations/register_user_spec.rb b/spec/graphql/mutations/register_user_spec.rb new file mode 100644 index 0000000..6bbb108 --- /dev/null +++ b/spec/graphql/mutations/register_user_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::RegisterUser do + include_context "with mocked security logger" + + before { create(:role, :admin) } + + let(:mutation) do + <<~GQL + mutation($input: RegisterUserInput!) { + registerUser(input: $input) { + token + user { + id + email + } + organization { + id + name + } + membership { + id + } + } + } + GQL + end + + context "with a valid new user" do + subject(:result) do + execute_graphql( + query: mutation, + variables: { + input: { + email: "foo@bar.com", + password: "ILoveLago", + organizationName: "FooBar" + } + } + ) + end + + it "returns user, organization and membership" do + aggregate_failures do + expect(result["data"]["registerUser"]["membership"]["id"]).to be_present + expect(result["data"]["registerUser"]["user"]["email"]).to eq("foo@bar.com") + expect(result["data"]["registerUser"]["organization"]["name"]).to eq("FooBar") + expect(result["data"]["registerUser"]["token"]).to be_present + end + end + + it_behaves_like "produces a security log", "user.signed_up" do + before { result } + end + end + + context "with already existing user" do + subject(:result) do + execute_graphql( + query: mutation, + variables: { + input: { + email: user.email, + password: "ILoveLago", + organizationName: "FooBar" + } + } + ) + end + + let(:user) { create(:user) } + + it "returns an error" do + aggregate_failures do + expect_unprocessable_entity(result) + expect(result["errors"].first.dig("extensions", "details").keys).to include("email") + expect(result["errors"].first.dig("extensions", "details", "email")).to include("user_already_exists") + end + end + end +end diff --git a/spec/graphql/mutations/roles/create_spec.rb b/spec/graphql/mutations/roles/create_spec.rb new file mode 100644 index 0000000..363da5d --- /dev/null +++ b/spec/graphql/mutations/roles/create_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Roles::Create do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {input: {code:, name:, description:, permissions: role_permissions}} + ) + end + + include_context "with mocked security logger" + + let(:query) do + <<-GQL + mutation($input: CreateRoleInput!) { + createRole(input: $input) { + id + name + description + permissions + } + } + GQL + end + + let(:required_permission) { "roles:create" } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:code) { "custom_role" } + let(:name) { "Custom Role" } + let(:description) { "A custom role" } + let(:role_permissions) { %w[customers_view customers_create] } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "roles:create" + + context "with premium organization and custom_roles integration", :premium do + before { organization.update!(premium_integrations: ["custom_roles"]) } + + it "creates a new role" do + expect { result }.to change(Role, :count).by(1) + end + + it "returns created role" do + role_response = result["data"]["createRole"] + + expect(role_response).to include( + "name" => name, + "description" => description, + "permissions" => role_permissions + ) + end + + it_behaves_like "produces a security log", "role.created" do + before { result } + end + + context "when permissions are sent with underscores" do + let(:role_permissions) { %w[customers_view customers_create] } + + it "stores permissions with colons and returns them with underscores" do + role_response = result["data"]["createRole"] + + expect(role_response["permissions"]).to match_array(%w[customers_view customers_create]) + + created_role = Role.find(role_response["id"]) + expect(created_role.permissions).to match_array(%w[customers:view customers:create]) + end + end + end + + context "with premium organization but without custom_roles integration", :premium do + before { organization.update!(premium_integrations: []) } + + it "returns an error" do + expect_graphql_error(result:, message: "premium_integration_missing") + end + end + + context "without premium license" do + it "returns an error" do + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + describe "with admin role permissions" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + current_membership: membership, + permissions: membership.permissions_hash, + query:, + variables: {input: {code:, name:, description:, permissions: role_permissions}} + ) + end + + let!(:membership) { create(:membership, organization:, roles: [:admin]) } + + context "with premium organization and custom_roles integration", :premium do + before { organization.update!(premium_integrations: ["custom_roles"]) } + + it "allows admin to create a role" do + expect { result }.to change(Role, :count).by(1) + end + end + + context "without premium license" do + it "returns feature_unavailable error" do + expect_graphql_error(result:, message: "feature_unavailable") + end + end + end +end diff --git a/spec/graphql/mutations/roles/destroy_spec.rb b/spec/graphql/mutations/roles/destroy_spec.rb new file mode 100644 index 0000000..51d9ffd --- /dev/null +++ b/spec/graphql/mutations/roles/destroy_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Roles::Destroy do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {input: {id: role.id}} + ) + end + + let(:query) do + <<-GQL + mutation($input: DestroyRoleInput!) { + destroyRole(input: $input) { + id + } + } + GQL + end + let(:required_permission) { "roles:delete" } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:role) { create(:role, organization:) } + + include_context "with mocked security logger" + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "roles:delete" + + context "when role exists in the current organization" do + it "soft-deletes the role" do + expect { result }.to change { role.reload.deleted_at }.from(nil) + end + + it "returns deleted role" do + role_response = result["data"]["destroyRole"] + + expect(role_response["id"]).to eq(role.id) + end + + it_behaves_like "produces a security log", "role.deleted" do + before { result } + end + end + + context "when role does not exist in the current organization" do + let(:role) { create(:role) } + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when role is predefined" do + let(:role) { create(:role, :predefined, name: "Finance") } + + it "returns an error" do + expect_graphql_error(result:, message: "predefined_role") + end + end + + context "when role has assigned members" do + before { create(:membership_role, membership:, role:) } + + it "does not delete the role" do + expect { result }.not_to change { role.reload.deleted_at } + end + + it "returns an error" do + expect_graphql_error(result:, message: "role_assigned_to_members") + end + end + + describe "with admin role permissions" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + current_membership: membership, + permissions: membership.permissions_hash, + query:, + variables: {input: {id: role.id}} + ) + end + + let(:membership) { create(:membership, organization:, roles: [:admin]) } + + it "allows admin to delete a role" do + expect { result }.to change { role.reload.deleted_at }.from(nil) + end + end +end diff --git a/spec/graphql/mutations/roles/update_spec.rb b/spec/graphql/mutations/roles/update_spec.rb new file mode 100644 index 0000000..d98c5dd --- /dev/null +++ b/spec/graphql/mutations/roles/update_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Roles::Update do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {input: {id: role.id, name: new_name, description: new_description}} + ) + end + + include_context "with mocked security logger" + + let(:query) do + <<-GQL + mutation($input: UpdateRoleInput!) { + updateRole(input: $input) { + id + name + description + } + } + GQL + end + + let(:required_permission) { "roles:update" } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:role) { create(:role, organization:, name: "Old Name", description: "Old description") } + let(:new_name) { "New Name" } + let(:new_description) { "New description" } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "roles:update" + + context "when role exists in the current organization" do + it "updates the role" do + result + role.reload + + expect(role.name).to eq(new_name) + expect(role.description).to eq(new_description) + end + + it "returns updated role" do + role_response = result["data"]["updateRole"] + + expect(role_response).to include( + "id" => role.id, + "name" => new_name, + "description" => new_description + ) + end + + it_behaves_like "produces a security log", "role.updated" do + before { result } + end + + context "when permissions are sent with underscores" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: permissions_query, + variables: {input: {id: role.id, permissions: new_permissions}} + ) + end + + let(:permissions_query) do + <<-GQL + mutation($input: UpdateRoleInput!) { + updateRole(input: $input) { + id + permissions + } + } + GQL + end + + let(:new_permissions) { %w[addons_view addons_create] } + + it "stores permissions with colons and returns them with underscores" do + role_response = result["data"]["updateRole"] + + expect(role_response["permissions"]).to include("addons_view", "addons_create") + + role.reload + expect(role.permissions).to include("addons:view", "addons:create") + end + end + end + + context "when role does not exist in the current organization" do + let(:role) { create(:role) } + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when role is predefined" do + let(:role) { create(:role, :predefined, name: "Finance") } + + it "returns an error" do + expect_graphql_error(result:, message: "predefined_role") + end + end + + describe "with admin role permissions" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + current_membership: membership, + permissions: membership.permissions_hash, + query:, + variables: {input: {id: role.id, name: new_name, description: new_description}} + ) + end + + let(:membership) { create(:membership, organization:, roles: [:admin]) } + + it "allows admin to update a role" do + result + role.reload + + expect(role.name).to eq(new_name) + expect(role.description).to eq(new_description) + end + end +end diff --git a/spec/graphql/mutations/subscriptions/alerts/create_spec.rb b/spec/graphql/mutations/subscriptions/alerts/create_spec.rb new file mode 100644 index 0000000..e81fbd5 --- /dev/null +++ b/spec/graphql/mutations/subscriptions/alerts/create_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::Alerts::Create do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: + } + ) + end + + let(:required_permission) { "subscriptions:update" } + let(:mutation) do + <<-GQL + mutation ($input: CreateSubscriptionAlertInput!) { + createSubscriptionAlert(input: $input) { + subscriptionExternalId + alertType + code + thresholds { + code + value + recurring + } + billableMetric { id code } + } + } + GQL + end + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization) } + let(:subscription) { create(:subscription, customer:) } + + let(:input) do + { + subscriptionId: subscription.id, + code: "global", + alertType: "current_usage_amount", + thresholds: [ + {code: "warn", value: "10"}, + {code: "alert", value: "50"}, + {value: "20", recurring: true} + ] + } + end + + before do + subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:update" + + it "creates an alert" do + result_data = result["data"]["createSubscriptionAlert"] + expect(result_data["subscriptionExternalId"]).to eq subscription.external_id + expect(result_data["walletId"]).to be_nil + expect(result_data["alertType"]).to eq "current_usage_amount" + expect(result_data["code"]).to eq "global" + expect(result_data["thresholds"]).to contain_exactly( + {"code" => "warn", "value" => "10.0", "recurring" => false}, # Notice .0 since it's a BigDecimal + {"code" => "alert", "value" => "50.0", "recurring" => false}, + {"code" => nil, "value" => "20.0", "recurring" => true} + ) + end + + context "with billable_metric_id" do + let(:billable_metric) { create(:billable_metric, organization: membership.organization) } + let(:input) do + { + subscriptionId: subscription.id, + code: "bm", + alertType: "billable_metric_current_usage_amount", + thresholds: [{code: "warn", value: "10"}], + billableMetricId: billable_metric.id + } + end + + it "creates an alert" do + result_data = result["data"]["createSubscriptionAlert"] + expect(result_data["subscriptionExternalId"]).to eq subscription.external_id + expect(result_data["alertType"]).to eq "billable_metric_current_usage_amount" + expect(result_data["code"]).to eq "bm" + expect(result_data["thresholds"]).to contain_exactly( + {"code" => "warn", "value" => "10.0", "recurring" => false} + ) + expect(result_data["billableMetric"]["id"]).to eq billable_metric.id + expect(result_data["billableMetric"]["code"]).to eq billable_metric.code + end + end + + context "when billable_metric is not found" do + let(:input) do + { + subscriptionId: subscription.id, + code: "bm", + alertType: "billable_metric_current_usage_amount", + thresholds: [{code: "warn", value: "10"}], + billableMetricId: SecureRandom.uuid + } + end + + it "returns an error" do + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(404) + expect(response["details"]["billableMetric"]).to include("not_found") + end + end + + context "when billable_metric_id are missing" do + let(:input) do + { + subscriptionId: subscription.id, + code: "bm", + alertType: "billable_metric_current_usage_amount", + thresholds: [{code: "warn", value: "10"}] + } + end + + it "returns an error" do + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(422) + expect(response["details"]["billableMetric"]).to include("value_is_mandatory") + end + end +end diff --git a/spec/graphql/mutations/subscriptions/alerts/destroy_spec.rb b/spec/graphql/mutations/subscriptions/alerts/destroy_spec.rb new file mode 100644 index 0000000..bb68f7a --- /dev/null +++ b/spec/graphql/mutations/subscriptions/alerts/destroy_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::Alerts::Destroy do + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization) } + let(:subscription) { create(:subscription, customer:) } + let(:alert) { create(:usage_current_amount_alert, subscription_external_id: subscription.external_id, organization: membership.organization, recurring_threshold: 33, thresholds: [10, 20, 22]) } + + let(:mutation) do + <<-GQL + mutation ($input: DestroySubscriptionAlertInput!) { + destroySubscriptionAlert(input: $input) { + id + alertType + code + deletedAt + thresholds { + code + value + } + } + } + GQL + end + + before do + alert + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:update" + + it "creates an alert" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: alert.id} + } + ) + + result_data = result["data"]["destroySubscriptionAlert"] + expect(result_data["alertType"]).to eq "current_usage_amount" + expect(result_data["code"]).to start_with "default" + expect(result_data["thresholds"]).to be_empty + expect(result_data["deletedAt"]).to start_with Time.current.year.to_s + end + + context "when alert is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: SecureRandom.uuid} + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(404) + expect(response["details"]["alert"]).to include("not_found") + end + end +end diff --git a/spec/graphql/mutations/subscriptions/alerts/update_spec.rb b/spec/graphql/mutations/subscriptions/alerts/update_spec.rb new file mode 100644 index 0000000..5046fc5 --- /dev/null +++ b/spec/graphql/mutations/subscriptions/alerts/update_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::Alerts::Update do + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization) } + let(:subscription) { create(:subscription, customer:, organization: membership.organization) } + let(:alert) { create(:usage_current_amount_alert, subscription_external_id: subscription.external_id, organization: membership.organization, recurring_threshold: 33, thresholds: [10, 20, 22]) } + + let(:mutation) do + <<-GQL + mutation ($input: UpdateSubscriptionAlertInput!) { + updateSubscriptionAlert(input: $input) { + id + alertType + code + thresholds { code value recurring } + billableMetric { id code } + } + } + GQL + end + + before do + subscription + alert + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:update" + + it "updates an alert" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: alert.id, + code: "new code", + thresholds: [ + {code: "warn", value: "10"}, + {code: "alert", value: "50", recurring: true} + ] + } + } + ) + + result_data = result["data"]["updateSubscriptionAlert"] + expect(result_data["id"]).to eq alert.id + expect(result_data["alertType"]).to eq "current_usage_amount" + expect(result_data["code"]).to eq "new code" + expect(result_data["billableMetric"]).to be_nil + expect(result_data["thresholds"]).to contain_exactly( + {"code" => "warn", "value" => "10.0", "recurring" => false}, + {"code" => "alert", "value" => "50.0", "recurring" => true} + ) + end + + context "with new billable_metric" do + let(:alert) { create(:billable_metric_current_usage_amount_alert, subscription_external_id: subscription.external_id, organization: membership.organization, recurring_threshold: 33, thresholds: [10, 12]) } + + it "updates the alert" do + new_billable_metric = create(:billable_metric, code: "new_bm", organization: membership.organization) + + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: alert.id, + code: "new code", + billableMetricId: new_billable_metric.id + } + } + ) + + result_data = result["data"]["updateSubscriptionAlert"] + expect(result_data["id"]).to eq alert.id + expect(result_data["alertType"]).to eq "billable_metric_current_usage_amount" + expect(result_data["code"]).to eq "new code" + expect(result_data["thresholds"]).to contain_exactly( + {"code" => "warn10", "value" => "10.0", "recurring" => false}, + {"code" => "warn12", "value" => "12.0", "recurring" => false}, + {"code" => "rec", "value" => "33.0", "recurring" => true} + ) + expect(result_data["billableMetric"]["id"]).to eq new_billable_metric.id + expect(result_data["billableMetric"]["code"]).to eq "new_bm" + end + end + + context "when alert is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: SecureRandom.uuid, + code: "new code" + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(404) + expect(response["details"]["alert"]).to include("not_found") + end + end +end diff --git a/spec/graphql/mutations/subscriptions/create_charge_filter_spec.rb b/spec/graphql/mutations/subscriptions/create_charge_filter_spec.rb new file mode 100644 index 0000000..9aa4e0d --- /dev/null +++ b/spec/graphql/mutations/subscriptions/create_charge_filter_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::CreateChargeFilter, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:query) do + <<~GQL + mutation($input: CreateSubscriptionChargeFilterInput!) { + createSubscriptionChargeFilter(input: $input) { + id + invoiceDisplayName + properties { + amount + } + values + } + } + GQL + end + + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + invoiceDisplayName: "New Filter", + properties: {amount: "100"}, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + end + + before do + charge + subscription + billable_metric_filter + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "subscriptions:update" + + it "creates a plan override, charge override and charge filter" do + expect { subject } + .to change(Plan, :count).by(1) + .and change(Charge, :count).by(1) + .and change(ChargeFilter, :count).by(1) + + result_data = subject["data"]["createSubscriptionChargeFilter"] + + expect(result_data["invoiceDisplayName"]).to eq("New Filter") + expect(result_data["properties"]["amount"]).to eq("100") + expect(result_data["values"]).to eq({billable_metric_filter.key => [billable_metric_filter.values.first]}) + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:input) do + { + subscriptionId: "invalid-id", + chargeCode: charge.code, + invoiceDisplayName: "Test", + properties: {amount: "100"}, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["subscription"]).to eq(["not_found"]) + end + end + + context "when charge does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: "invalid-code", + invoiceDisplayName: "Test", + properties: {amount: "100"}, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["charge"]).to eq(["not_found"]) + end + end + + context "when values are empty" do + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + invoiceDisplayName: "Test", + properties: {amount: "100"}, + values: {} + } + end + + it "returns validation error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("unprocessable_entity") + expect(result["errors"].first["extensions"]["details"]["values"]).to eq(["value_is_mandatory"]) + end + end + + context "when subscription already has plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, organization:, plan: overridden_plan) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + before { overridden_charge } + + it "does not create a new plan or charge" do + expect { subject } + .to not_change(Plan, :count) + .and not_change(Charge, :count) + .and change(ChargeFilter, :count).by(1) + end + + it "creates the filter on the existing charge override" do + result_data = subject["data"]["createSubscriptionChargeFilter"] + + filter = ChargeFilter.find(result_data["id"]) + expect(filter.charge_id).to eq(overridden_charge.id) + end + end +end diff --git a/spec/graphql/mutations/subscriptions/create_spec.rb b/spec/graphql/mutations/subscriptions/create_spec.rb new file mode 100644 index 0000000..9a858d6 --- /dev/null +++ b/spec/graphql/mutations/subscriptions/create_spec.rb @@ -0,0 +1,274 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::Create, :premium do + let(:required_permission) { "subscriptions:create" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, plan:) } + let(:fixed_charge) { create(:fixed_charge, plan:) } + let(:threshold) { create(:usage_threshold, plan:) } + let(:ending_at) { Time.current.beginning_of_day + 1.year } + let(:customer) { create(:customer, organization:) } + + let(:feature) { create(:feature, code: :seats, organization:) } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:entitlement) { create(:entitlement, feature:, plan:) } + let(:entitlement_value) { create(:entitlement_value, privilege:, entitlement:, value: "99") } + + let(:feature2) { create(:feature, code: "sso", organization:) } + + let(:mutation) do + <<~GQL + mutation($input: CreateSubscriptionInput!) { + createSubscription(input: $input) { + id + status + name + externalId + startedAt + billingTime + subscriptionAt + endingAt + progressiveBillingDisabled + customer { + id + }, + plan { + id + amountCents + fixedCharges { + invoiceDisplayName + units + } + } + usageThresholds { + amountCents + thresholdDisplayName + } + } + } + GQL + end + + before { organization.update!(premium_integrations: ["progressive_billing"]) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:create" + + it "creates a subscription" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + planId: plan.id, + name: "name", + externalId: "custom-external-id", + billingTime: "anniversary", + endingAt: ending_at.iso8601, + progressiveBillingDisabled: true, + usageThresholds: [ + amountCents: 100, + thresholdDisplayName: "threshold display name" + ], + planOverrides: { + amountCents: 100, + charges: [ + id: charge.id, + billableMetricId: charge.billable_metric_id, + invoiceDisplayName: "invoice display name" + ], + fixedCharges: [ + { + id: fixed_charge.id, + invoiceDisplayName: "NEW fixed charge display name", + units: "99" + } + ] + } + } + } + ) + + result_data = result["data"]["createSubscription"] + + expect(result_data).to include( + "id" => String, + "status" => "active", + "name" => "name", + "externalId" => "custom-external-id", + "startedAt" => String, + "billingTime" => "anniversary", + "endingAt" => ending_at.iso8601, + "progressiveBillingDisabled" => true + ) + expect(result_data["customer"]).to include( + "id" => customer.id + ) + expect(result_data["plan"]).to include( + "id" => String, + "amountCents" => "100" + ) + expect(result_data["usageThresholds"].first).to include( + "thresholdDisplayName" => "threshold display name", + "amountCents" => "100" + ) + expect(result_data["plan"]["fixedCharges"].first).to include( + "invoiceDisplayName" => "NEW fixed charge display name", + "units" => "99" + ) + end + + context "with billing entity binding" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:mutation) do + <<~GQL + mutation($input: CreateSubscriptionInput!) { + createSubscription(input: $input) { + id + externalId + } + } + GQL + end + + context "when multi_entity_billing flag is enabled" do + before { organization.enable_feature_flag!(:multi_entity_billing) } + + it "binds the subscription to the resolved entity" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + planId: plan.id, + billingTime: "anniversary", + billingEntityId: billing_entity.id + } + } + ) + + external_id = result["data"]["createSubscription"]["externalId"] + subscription = Subscription.find_by(external_id:) + expect(subscription.billing_entity_id).to eq(billing_entity.id) + end + + it "returns a not_found error when billing_entity_id is unknown" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + planId: plan.id, + billingTime: "anniversary", + billingEntityId: SecureRandom.uuid + } + } + ) + + expect(result["errors"].first["extensions"]).to include( + "code" => "not_found", + "details" => {"billingEntity" => ["not_found"]} + ) + end + end + + context "when multi_entity_billing flag is disabled" do + it "ignores the billing entity binding" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + planId: plan.id, + billingTime: "anniversary", + billingEntityId: billing_entity.id + } + } + ) + + external_id = result["data"]["createSubscription"]["externalId"] + subscription = Subscription.find_by(external_id:) + expect(subscription.billing_entity_id).to be_nil + end + end + end + + context "with activation rules" do + let(:customer) { create(:customer, organization:, payment_provider: "stripe") } + + let(:mutation) do + <<~GQL + mutation($input: CreateSubscriptionInput!) { + createSubscription(input: $input) { + id + status + cancelationReason + activationRules { + id + type + timeoutHours + status + expiresAt + createdAt + updatedAt + } + } + } + GQL + end + + it "creates a subscription with activation rules" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + planId: plan.id, + billingTime: "anniversary", + subscriptionAt: (Time.current + 5.days).iso8601, + activationRules: [ + {type: "payment", timeoutHours: 48} + ] + } + } + ) + + result_data = result["data"]["createSubscription"] + + expect(result_data).to include( + "status" => "pending", + "cancelationReason" => nil + ) + expect(result_data["activationRules"].size).to eq(1) + expect(result_data["activationRules"].first).to include( + "id" => String, + "type" => "payment", + "timeoutHours" => 48, + "status" => "inactive", + "createdAt" => String, + "expiresAt" => nil, + "updatedAt" => String + ) + end + end +end diff --git a/spec/graphql/mutations/subscriptions/destroy_charge_filter_spec.rb b/spec/graphql/mutations/subscriptions/destroy_charge_filter_spec.rb new file mode 100644 index 0000000..7dc0427 --- /dev/null +++ b/spec/graphql/mutations/subscriptions/destroy_charge_filter_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::DestroyChargeFilter, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:charge_filter) do + create(:charge_filter, charge:, organization:, properties: {amount: "50"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:query) do + <<~GQL + mutation($input: DestroySubscriptionChargeFilterInput!) { + destroySubscriptionChargeFilter(input: $input) { + id + } + } + GQL + end + + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + end + + before do + charge_filter + subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "subscriptions:update" + + it "creates a plan override and charge override, then destroys the filter" do + expect { subject } + .to change(Plan, :count).by(1) + .and change(Charge, :count).by(1) + + result_data = subject["data"]["destroySubscriptionChargeFilter"] + + expect(result_data["id"]).to be_present + destroyed_filter = ChargeFilter.unscoped.find(result_data["id"]) + expect(destroyed_filter).to be_discarded + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:input) do + { + subscriptionId: "invalid-id", + chargeCode: charge.code, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["subscription"]).to eq(["not_found"]) + end + end + + context "when charge does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: "invalid-code", + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["charge"]).to eq(["not_found"]) + end + end + + context "when charge filter does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + values: {"nonexistent" => ["value"]} + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["chargeFilter"]).to eq(["not_found"]) + end + end + + context "when subscription already has plan override with charge and filter" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, organization:, plan: overridden_plan) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + let(:overridden_filter) do + create(:charge_filter, charge: overridden_charge, organization:, properties: {amount: "75"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + + before { overridden_filter } + + it "does not create new plan or charge" do + expect { subject } + .to not_change(Plan, :count) + .and not_change(Charge, :count) + end + + it "soft deletes the existing filter override" do + result_data = subject["data"]["destroySubscriptionChargeFilter"] + + expect(result_data["id"]).to eq(overridden_filter.id) + expect(overridden_filter.reload).to be_discarded + end + end +end diff --git a/spec/graphql/mutations/subscriptions/terminate_spec.rb b/spec/graphql/mutations/subscriptions/terminate_spec.rb new file mode 100644 index 0000000..debf67f --- /dev/null +++ b/spec/graphql/mutations/subscriptions/terminate_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::Terminate do + subject(:result) do + execute_query( + query: mutation, + input: input + ) + end + + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:subscription) { create(:subscription, organization:) } + let(:mutation) do + <<~GQL + mutation($input: TerminateSubscriptionInput!) { + terminateSubscription(input: $input) { + id, + status, + terminatedAt, + onTerminationCreditNote + onTerminationInvoice + } + } + GQL + end + let(:input) { {id: subscription.id} } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:update" + + context "when plan is pay in advance" do + let(:subscription) { create(:subscription, organization:, plan: create(:plan, :pay_in_advance)) } + + it "terminates a subscription" do + result_data = result["data"]["terminateSubscription"] + + expect(result_data["id"]).to eq(subscription.id) + expect(result_data["status"]).to eq("terminated") + expect(result_data["terminatedAt"]).to be_present + expect(result_data["onTerminationCreditNote"]).to eq("credit") + expect(result_data["onTerminationInvoice"]).to eq("generate") + end + + context "when on_termination_credit_note is provided" do + let(:input) { {id: subscription.id, onTerminationCreditNote: "skip"} } + + it "creates a credit note" do + result_data = result["data"]["terminateSubscription"] + + expect(result_data["id"]).to eq(subscription.id) + expect(result_data["status"]).to eq("terminated") + expect(result_data["terminatedAt"]).to be_present + expect(result_data["onTerminationCreditNote"]).to eq("skip") + expect(result_data["onTerminationInvoice"]).to eq("generate") + expect(subscription.reload.on_termination_credit_note).to eq("skip") + end + end + + context "when on_termination_invoice is provided" do + let(:input) { {id: subscription.id, onTerminationInvoice: "skip"} } + + it "sets the invoice behavior" do + result_data = result["data"]["terminateSubscription"] + + expect(result_data["id"]).to eq(subscription.id) + expect(result_data["status"]).to eq("terminated") + expect(result_data["terminatedAt"]).to be_present + expect(result_data["onTerminationInvoice"]).to eq("skip") + expect(subscription.reload.on_termination_invoice).to eq("skip") + end + end + end +end diff --git a/spec/graphql/mutations/subscriptions/update_charge_filter_spec.rb b/spec/graphql/mutations/subscriptions/update_charge_filter_spec.rb new file mode 100644 index 0000000..a8ec24f --- /dev/null +++ b/spec/graphql/mutations/subscriptions/update_charge_filter_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::UpdateChargeFilter, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:charge_filter) do + create(:charge_filter, charge:, organization:, properties: {amount: "50"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:query) do + <<~GQL + mutation($input: UpdateSubscriptionChargeFilterInput!) { + updateSubscriptionChargeFilter(input: $input) { + id + invoiceDisplayName + properties { + amount + } + values + } + } + GQL + end + + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]}, + invoiceDisplayName: "Updated Filter", + properties: {amount: "200"} + } + end + + before do + charge_filter + subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "subscriptions:update" + + it "creates a plan override, charge override and charge filter override" do + expect { subject } + .to change(Plan, :count).by(1) + .and change(Charge, :count).by(1) + .and change(ChargeFilter, :count).by(1) + + result_data = subject["data"]["updateSubscriptionChargeFilter"] + + expect(result_data["invoiceDisplayName"]).to eq("Updated Filter") + expect(result_data["properties"]["amount"]).to eq("200") + expect(result_data["values"]).to eq({billable_metric_filter.key => [billable_metric_filter.values.first]}) + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:input) do + { + subscriptionId: "invalid-id", + chargeCode: charge.code, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]}, + invoiceDisplayName: "Test" + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["subscription"]).to eq(["not_found"]) + end + end + + context "when charge does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: "invalid-code", + values: {billable_metric_filter.key => [billable_metric_filter.values.first]}, + invoiceDisplayName: "Test" + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["charge"]).to eq(["not_found"]) + end + end + + context "when charge filter does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + values: {"nonexistent" => ["value"]}, + invoiceDisplayName: "Test" + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["chargeFilter"]).to eq(["not_found"]) + end + end + + context "when subscription already has plan override with charge and filter" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, organization:, plan: overridden_plan) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + let(:overridden_filter) do + create(:charge_filter, charge: overridden_charge, organization:, properties: {amount: "75"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + + before { overridden_filter } + + it "does not create new plan, charge, or filter" do + expect { subject } + .to not_change(Plan, :count) + .and not_change(Charge, :count) + .and not_change(ChargeFilter, :count) + end + + it "updates the existing filter override" do + result_data = subject["data"]["updateSubscriptionChargeFilter"] + + expect(result_data["id"]).to eq(overridden_filter.id) + expect(result_data["invoiceDisplayName"]).to eq("Updated Filter") + expect(result_data["properties"]["amount"]).to eq("200") + end + end +end diff --git a/spec/graphql/mutations/subscriptions/update_charge_spec.rb b/spec/graphql/mutations/subscriptions/update_charge_spec.rb new file mode 100644 index 0000000..c0863c9 --- /dev/null +++ b/spec/graphql/mutations/subscriptions/update_charge_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::UpdateCharge, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:query) do + <<~GQL + mutation($input: UpdateSubscriptionChargeInput!) { + updateSubscriptionCharge(input: $input) { + id + invoiceDisplayName + minAmountCents + properties { + amount + } + parentId + } + } + GQL + end + + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + invoiceDisplayName: "Updated Charge", + minAmountCents: 500, + properties: {amount: "200"} + } + end + + before do + charge + subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "subscriptions:update" + + it "creates a plan override and charge override" do + expect { subject }.to change(Plan, :count).by(1).and change(Charge, :count).by(1) + + result_data = subject["data"]["updateSubscriptionCharge"] + + expect(result_data["invoiceDisplayName"]).to eq("Updated Charge") + expect(result_data["minAmountCents"]).to eq("500") + expect(result_data["properties"]["amount"]).to eq("200") + expect(result_data["parentId"]).to eq(charge.id) + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:input) do + { + subscriptionId: "invalid-id", + chargeCode: charge.code, + invoiceDisplayName: "Test" + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["subscription"]).to eq(["not_found"]) + end + end + + context "when charge does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: "invalid-code", + invoiceDisplayName: "Test" + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["charge"]).to eq(["not_found"]) + end + end + + context "when subscription already has plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, organization:, plan: overridden_plan) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + before { overridden_charge } + + it "does not create a new plan" do + expect { subject }.not_to change(Plan, :count) + end + + it "updates the existing charge override" do + result_data = subject["data"]["updateSubscriptionCharge"] + + expect(result_data["id"]).to eq(overridden_charge.id) + expect(result_data["invoiceDisplayName"]).to eq("Updated Charge") + expect(result_data["minAmountCents"]).to eq("500") + end + end + + context "with taxes" do + let(:tax) { create(:tax, organization:) } + let(:input) do + { + subscriptionId: subscription.id, + chargeCode: charge.code, + invoiceDisplayName: "Taxed Charge", + taxCodes: [tax.code] + } + end + + it "creates a charge override with taxes" do + subject + + result_data = subject["data"]["updateSubscriptionCharge"] + expect(result_data["invoiceDisplayName"]).to eq("Taxed Charge") + end + end +end diff --git a/spec/graphql/mutations/subscriptions/update_fixed_charge_spec.rb b/spec/graphql/mutations/subscriptions/update_fixed_charge_spec.rb new file mode 100644 index 0000000..667fe31 --- /dev/null +++ b/spec/graphql/mutations/subscriptions/update_fixed_charge_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::UpdateFixedCharge, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:query) do + <<~GQL + mutation($input: UpdateSubscriptionFixedChargeInput!) { + updateSubscriptionFixedCharge(input: $input) { + id + invoiceDisplayName + units + parentId + } + } + GQL + end + + let(:input) do + { + subscriptionId: subscription.id, + fixedChargeCode: fixed_charge.code, + invoiceDisplayName: "Updated Fixed Charge", + units: "20" + } + end + + before do + fixed_charge + subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "subscriptions:update" + + it "creates a plan override and fixed charge override" do + expect { subject }.to change(Plan, :count).by(1).and change(FixedCharge, :count).by(1) + + result_data = subject["data"]["updateSubscriptionFixedCharge"] + + expect(result_data["invoiceDisplayName"]).to eq("Updated Fixed Charge") + expect(result_data["units"]).to eq("20") + expect(result_data["parentId"]).to eq(fixed_charge.id) + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:input) do + { + subscriptionId: "invalid-id", + fixedChargeCode: fixed_charge.code, + invoiceDisplayName: "Test" + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["subscription"]).to eq(["not_found"]) + end + end + + context "when fixed charge does not exist" do + let(:input) do + { + subscriptionId: subscription.id, + fixedChargeCode: "invalid-code", + invoiceDisplayName: "Test" + } + end + + it "returns not found error" do + result = subject + + expect(result["errors"].first["extensions"]["code"]).to eq("not_found") + expect(result["errors"].first["extensions"]["details"]["fixedCharge"]).to eq(["not_found"]) + end + end + + context "when subscription already has plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, organization:, plan: overridden_plan) } + let(:overridden_fixed_charge) { create(:fixed_charge, plan: overridden_plan, organization:, add_on:, parent: fixed_charge, code: fixed_charge.code) } + + before { overridden_fixed_charge } + + it "does not create a new plan" do + expect { subject }.not_to change(Plan, :count) + end + + it "updates the existing fixed charge override" do + result_data = subject["data"]["updateSubscriptionFixedCharge"] + + expect(result_data["id"]).to eq(overridden_fixed_charge.id) + expect(result_data["invoiceDisplayName"]).to eq("Updated Fixed Charge") + expect(result_data["units"]).to eq("20") + end + end + + context "with taxes" do + let(:tax) { create(:tax, organization:) } + let(:input) do + { + subscriptionId: subscription.id, + fixedChargeCode: fixed_charge.code, + invoiceDisplayName: "Taxed Fixed Charge", + taxCodes: [tax.code] + } + end + + it "creates a fixed charge override with taxes" do + subject + + result_data = subject["data"]["updateSubscriptionFixedCharge"] + expect(result_data["invoiceDisplayName"]).to eq("Taxed Fixed Charge") + end + end +end diff --git a/spec/graphql/mutations/subscriptions/update_spec.rb b/spec/graphql/mutations/subscriptions/update_spec.rb new file mode 100644 index 0000000..892723d --- /dev/null +++ b/spec/graphql/mutations/subscriptions/update_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Subscriptions::Update, :premium do + subject { execute_query(query:, input:) } + + let(:required_permission) { "subscriptions:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:) } + + let(:subscription) do + create( + :subscription, + organization:, + plan:, + subscription_at: Time.current + 3.days + ) + end + + let(:query) do + <<~GQL + mutation($input: UpdateSubscriptionInput!) { + updateSubscription(input: $input) { + id + name + subscriptionAt + progressiveBillingDisabled + plan { + fixedCharges { + invoiceDisplayName + units + } + } + } + } + GQL + end + let(:input) do + { + id: subscription.id, + name: "New name", + progressiveBillingDisabled: true, + planOverrides: { + fixedCharges: [ + { + id: fixed_charge.id, + invoiceDisplayName: "NEW fixed charge display name", + units: "99", + applyUnitsImmediately: true + } + ] + } + } + end + + before do + plan + fixed_charge + subscription + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "subscriptions:update" + + it "updates an subscription" do + result = subject + + result_data = result["data"]["updateSubscription"] + + expect(result_data["name"]).to eq("New name") + expect(result_data["progressiveBillingDisabled"]).to be(true) + + expect(result_data["plan"]["fixedCharges"].first).to include( + "invoiceDisplayName" => "NEW fixed charge display name", + "units" => "99" + ) + end + + context "when subscription is active" do + let(:subscription) { create(:subscription, plan:, organization:) } + + it "emits a fixed charge event" do + expect { subject }.to change(FixedChargeEvent, :count).by(1) + + expect(FixedChargeEvent.first).to have_attributes(units: BigDecimal("99")) + end + end + + context "when updating only usage_thresholds" do + let(:query) do + <<~GQL + mutation($input: UpdateSubscriptionInput!) { + updateSubscription(input: $input) { + id + usageThresholds { + id + amountCents + thresholdDisplayName + recurring + } + } + } + GQL + end + let(:input) do + { + id: subscription.id, + usageThresholds: [ + { + amountCents: 10_000, + thresholdDisplayName: "First threshold" + }, + { + amountCents: 50_000, + thresholdDisplayName: "Second threshold", + recurring: true + } + ] + } + end + + before { organization.update!(premium_integrations: ["progressive_billing"]) } + + it "updates the usage thresholds" do + result = subject + + result_data = result["data"]["updateSubscription"] + thresholds = result_data["usageThresholds"] + + expect(thresholds.size).to eq(2) + expect(thresholds).to match_array([ + hash_including( + "id" => String, + "amountCents" => "10000", + "thresholdDisplayName" => "First threshold", + "recurring" => false + ), + hash_including( + "id" => String, + "amountCents" => "50000", + "thresholdDisplayName" => "Second threshold", + "recurring" => true + ) + ]) + end + end + + context "with activation rules" do + let(:query) do + <<~GQL + mutation($input: UpdateSubscriptionInput!) { + updateSubscription(input: $input) { + id + activationRules { + id + type + timeoutHours + status + expiresAt + } + } + } + GQL + end + + context "when subscription is pending" do + let(:subscription) { create(:subscription, :pending, organization:, plan:, subscription_at: Time.current + 3.days) } + let(:input) do + { + id: subscription.id, + activationRules: [ + {type: "payment", timeoutHours: 24} + ] + } + end + + it "persists and returns activation rules" do + result = subject + + result_data = result["data"]["updateSubscription"] + + expect(result_data["activationRules"].size).to eq(1) + expect(result_data["activationRules"].first).to include( + "id" => String, + "type" => "payment", + "timeoutHours" => 24, + "status" => "inactive", + "expiresAt" => nil + ) + end + end + + context "when removing activation rules with empty array" do + let(:subscription) { create(:subscription, :pending, :with_activation_rules, organization:, plan:, subscription_at: Time.current + 3.days) } + let(:input) do + { + id: subscription.id, + activationRules: [] + } + end + + it "removes all activation rules" do + result = subject + + result_data = result["data"]["updateSubscription"] + + expect(result_data["activationRules"]).to be_empty + end + end + + context "when updating existing activation rules" do + let(:subscription) { create(:subscription, :pending, :with_activation_rules, organization:, plan:, subscription_at: Time.current + 3.days) } + let(:input) do + { + id: subscription.id, + activationRules: [ + {type: "payment", timeoutHours: 72} + ] + } + end + + it "replaces activation rules with new values" do + result = subject + + result_data = result["data"]["updateSubscription"] + + expect(result_data["activationRules"].size).to eq(1) + expect(result_data["activationRules"].first).to include( + "id" => String, + "type" => "payment", + "timeoutHours" => 72, + "status" => "inactive", + "expiresAt" => nil + ) + end + end + + context "when subscription is active" do + let(:subscription) { create(:subscription, organization:, plan:) } + let(:input) do + { + id: subscription.id, + activationRules: [ + {type: "payment", timeoutHours: 24} + ] + } + end + + it "returns a validation error" do + result = subject + + expect(result["errors"].first.dig("extensions", "details", "activationRules")).to include("subscription_not_pending") + end + end + end +end diff --git a/spec/graphql/mutations/taxes/create_spec.rb b/spec/graphql/mutations/taxes/create_spec.rb new file mode 100644 index 0000000..db62b76 --- /dev/null +++ b/spec/graphql/mutations/taxes/create_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Taxes::Create do + let(:membership) { create(:membership) } + let(:input) do + { + name: "Tax name", + code: "tax-code", + description: "Tax description", + rate: 15.0 + } + end + + let(:mutation) do + <<-GQL + mutation($input: TaxCreateInput!) { + createTax(input: $input) { + id name code description rate addOnsCount plansCount chargesCount customersCount + } + } + GQL + end + + it "creates a tax" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["createTax"]).to include( + "id" => String, + "name" => "Tax name", + "code" => "tax-code", + "description" => "Tax description", + "rate" => 15.0 + ) + end +end diff --git a/spec/graphql/mutations/taxes/destroy_spec.rb b/spec/graphql/mutations/taxes/destroy_spec.rb new file mode 100644 index 0000000..785c145 --- /dev/null +++ b/spec/graphql/mutations/taxes/destroy_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Taxes::Destroy do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: DestroyTaxInput!) { + destroyTax(input: $input) { id } + } + GQL + end + + before { tax } + + it "destroys a tax" do + expect do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + query: mutation, + variables: {input: {id: tax.id}} + ) + end.to change(Tax, :count).by(-1) + end +end diff --git a/spec/graphql/mutations/taxes/update_spec.rb b/spec/graphql/mutations/taxes/update_spec.rb new file mode 100644 index 0000000..8bf9190 --- /dev/null +++ b/spec/graphql/mutations/taxes/update_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Taxes::Update do + let(:membership) { create(:membership) } + let(:tax) { create(:tax, organization: membership.organization) } + let(:input) do + { + id: tax.id, + name: "Updated tax name", + code: "updated-tax-code", + description: "Updated tax description", + rate: 30.0, + appliedToOrganization: false + } + end + + let(:mutation) do + <<-GQL + mutation($input: TaxUpdateInput!) { + updateTax(input: $input) { + id name code description rate appliedToOrganization + } + } + GQL + end + + it "updates a tax" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + query: mutation, + variables: {input:} + ) + + expect(result["data"]["updateTax"]).to include( + "id" => String, + "name" => "Updated tax name", + "code" => "updated-tax-code", + "description" => "Updated tax description", + "rate" => 30.0, + "appliedToOrganization" => false + ) + end +end diff --git a/spec/graphql/mutations/wallet_transactions/create_spec.rb b/spec/graphql/mutations/wallet_transactions/create_spec.rb new file mode 100644 index 0000000..45bc203 --- /dev/null +++ b/spec/graphql/mutations/wallet_transactions/create_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::WalletTransactions::Create do + subject(:result) { execute_query(query:, input:) } + + let(:required_permission) { "wallets:top_up" } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:, balance: 10.0, credits_balance: 10.0) } + + let(:query) do + <<-GQL + mutation ($input: CreateCustomerWalletTransactionInput!) { + createCustomerWalletTransaction(input: $input) { + collection { + id + status + priority + source + name + invoiceRequiresSuccessfulPayment + transactionStatus + transactionType + creditAmount + amount + metadata { + key + value + } + } + } + } + GQL + end + + let(:input) do + { + walletId: wallet.id, + name: "Test Transaction", + paidCredits: "5.00", + grantedCredits: "15.00", + invoiceRequiresSuccessfulPayment: true, + priority: 25, + metadata: [ + { + key: "fixed", + value: "0" + }, + { + key: "test 2", + value: "mew meta" + } + ] + } + end + + before do + subscription + wallet + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:top_up" + + it "creates a wallet transaction" do + result_data = result["data"]["createCustomerWalletTransaction"] + transactions = result_data["collection"].sort_by { |wt| wt["transactionStatus"] } + + expect(transactions.length).to eq(2) + expect(transactions).to all(include( + "metadata" => contain_exactly( + {"key" => "fixed", "value" => "0"}, + {"key" => "test 2", "value" => "mew meta"} + ), + "name" => "Test Transaction", + "priority" => 25, + "transactionType" => "inbound", + "source" => "manual" + )) + + granted_transaction = transactions.first + paid_transaction = transactions.second + + expect(granted_transaction).to include( + "transactionStatus" => "granted", + "status" => "settled", + "creditAmount" => "15.0", + "amount" => "15.0", + "invoiceRequiresSuccessfulPayment" => false + ) + + expect(paid_transaction).to include( + "transactionStatus" => "purchased", + "status" => "pending", + "creditAmount" => "5.0", + "amount" => "5.0", + "invoiceRequiresSuccessfulPayment" => true + ) + end + + context "when wallet has a minimum amount" do + before do + wallet.update!(paid_top_up_min_amount_cents: 10_00) + end + + it "returns an error" do + # TODO: check error content when we return metadata + expect_unprocessable_entity(result) + end + + context "when the ignore_paid_top_up_limits is passed" do + let(:input) do + { + walletId: wallet.id, + paidCredits: "5.00", + ignorePaidTopUpLimits: true + } + end + + it "creates the transaction" do + expect { subject }.to change(organization.wallet_transactions, :count).by(1) + + result_data = result["data"]["createCustomerWalletTransaction"] + expect(result_data["collection"].count).to eq 1 + end + end + end +end diff --git a/spec/graphql/mutations/wallets/alerts/create_spec.rb b/spec/graphql/mutations/wallets/alerts/create_spec.rb new file mode 100644 index 0000000..ab4d758 --- /dev/null +++ b/spec/graphql/mutations/wallets/alerts/create_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Wallets::Alerts::Create do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: + } + ) + end + + let(:required_permission) { "wallets:update" } + let(:mutation) do + <<-GQL + mutation ($input: CreateCustomerWalletAlertInput!) { + createCustomerWalletAlert(input: $input) { + walletId + alertType + code + thresholds { + code + value + recurring + } + } + } + GQL + end + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization) } + let(:wallet) { create(:wallet, customer:) } + + let(:input) do + { + walletId: wallet.id, + code: "wallet_balance_alert", + alertType: "wallet_balance_amount", + thresholds: [ + {code: "warn", value: "5000"}, + {code: "alert", value: "2500"}, + {value: "1000", recurring: true} + ] + } + end + + before do + wallet + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:update" + + it "creates an alert" do + result_data = result["data"]["createCustomerWalletAlert"] + + expect(result_data["subscriptionExternalId"]).to be_nil + expect(result_data["walletId"]).to eq wallet.id + expect(result_data["alertType"]).to eq "wallet_balance_amount" + expect(result_data["code"]).to eq "wallet_balance_alert" + expect(result_data["thresholds"]).to contain_exactly( + {"code" => "warn", "value" => "5000.0", "recurring" => false}, # Notice .0 since it's a BigDecimal + {"code" => "alert", "value" => "2500.0", "recurring" => false}, + {"code" => nil, "value" => "1000.0", "recurring" => true} + ) + end +end diff --git a/spec/graphql/mutations/wallets/alerts/destroy_spec.rb b/spec/graphql/mutations/wallets/alerts/destroy_spec.rb new file mode 100644 index 0000000..acc64bb --- /dev/null +++ b/spec/graphql/mutations/wallets/alerts/destroy_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Wallets::Alerts::Destroy do + let(:required_permission) { "wallets:update" } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization) } + let(:wallet) { create(:wallet, customer:) } + let(:alert) { create(:wallet_balance_amount_alert, wallet:, organization: membership.organization, recurring_threshold: 10, thresholds: [75, 50, 25]) } + + let(:mutation) do + <<-GQL + mutation ($input: DestroyCustomerWalletAlertInput!) { + destroyCustomerWalletAlert(input: $input) { + id + alertType + code + deletedAt + thresholds { + code + value + } + } + } + GQL + end + + before do + alert + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:update" + + it "destroys an alert" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: alert.id} + } + ) + + result_data = result["data"]["destroyCustomerWalletAlert"] + expect(result_data["alertType"]).to eq "wallet_balance_amount" + expect(result_data["code"]).to start_with "default1" + expect(result_data["thresholds"]).to be_empty + expect(result_data["deletedAt"]).to start_with Time.current.year.to_s + end + + context "when alert is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: SecureRandom.uuid} + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(404) + expect(response["details"]["alert"]).to include("not_found") + end + end +end diff --git a/spec/graphql/mutations/wallets/alerts/update_spec.rb b/spec/graphql/mutations/wallets/alerts/update_spec.rb new file mode 100644 index 0000000..41aee0c --- /dev/null +++ b/spec/graphql/mutations/wallets/alerts/update_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Wallets::Alerts::Update do + let(:required_permission) { "wallets:update" } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization) } + let(:wallet) { create(:wallet, customer:, organization: membership.organization) } + let(:alert) { create(:wallet_balance_amount_alert, wallet:, organization: membership.organization, recurring_threshold: 10, thresholds: [75, 50, 25]) } + + let(:mutation) do + <<-GQL + mutation ($input: UpdateCustomerWalletAlertInput!) { + updateCustomerWalletAlert(input: $input) { + id + alertType + code + thresholds { code value recurring } + } + } + GQL + end + + before do + wallet + alert + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:update" + + it "updates an alert" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: alert.id, + code: "new code", + thresholds: [ + {code: "warn", value: "10"}, + {code: "alert", value: "50", recurring: true} + ] + } + } + ) + + result_data = result["data"]["updateCustomerWalletAlert"] + expect(result_data["id"]).to eq alert.id + expect(result_data["alertType"]).to eq "wallet_balance_amount" + expect(result_data["code"]).to eq "new code" + expect(result_data["billableMetric"]).to be_nil + expect(result_data["thresholds"]).to contain_exactly( + {"code" => "warn", "value" => "10.0", "recurring" => false}, + {"code" => "alert", "value" => "50.0", "recurring" => true} + ) + end + + context "when alert is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: SecureRandom.uuid, + code: "new code" + } + } + ) + + response = result["errors"].first["extensions"] + + expect(response["status"]).to eq(404) + expect(response["details"]["alert"]).to include("not_found") + end + end +end diff --git a/spec/graphql/mutations/wallets/create_spec.rb b/spec/graphql/mutations/wallets/create_spec.rb new file mode 100644 index 0000000..c7b960c --- /dev/null +++ b/spec/graphql/mutations/wallets/create_spec.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Wallets::Create, :premium do + let(:required_permission) { "wallets:create" } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization: membership.organization, currency: "EUR") } + let(:billable_metric) { create(:billable_metric, organization: membership.organization) } + let(:expiration_at) { Time.zone.now + 1.year } + + let(:mutation) do + <<-GQL + mutation($input: CreateCustomerWalletInput!) { + createCustomerWallet(input: $input) { + id + code + name + priority + rateAmount + status + currency + expirationAt + invoiceRequiresSuccessfulPayment + paidTopUpMinAmountCents + paidTopUpMaxAmountCents + metadata { + key + value + } + recurringTransactionRules { + lagoId + method + trigger + interval + thresholdCredits + paidCredits + grantedCredits + targetOngoingBalance + invoiceRequiresSuccessfulPayment + expirationAt + ignorePaidTopUpLimits + transactionMetadata { + key + value + } + transactionName + } + appliesTo { + feeTypes + billableMetrics { + id + } + } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:create" + + it "creates a wallet" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + name: "First Wallet", + priority: 9, + rateAmount: "1", + paidCredits: "10.00", + grantedCredits: "0.00", + expirationAt: expiration_at.iso8601, + currency: "EUR", + invoiceRequiresSuccessfulPayment: true, + paidTopUpMinAmountCents: 1_00, + paidTopUpMaxAmountCents: 100_00, + transactionName: "Initial Credits Purchase", + recurringTransactionRules: [ + { + method: "target", + trigger: "interval", + interval: "monthly", + targetOngoingBalance: "0.0", + invoiceRequiresSuccessfulPayment: true, + expirationAt: expiration_at.iso8601, + ignorePaidTopUpLimits: true, + transactionMetadata: [ + {key: "example_key", value: "example_value"}, + {key: "another_key", value: "another_value"} + ], + transactionName: "Monthly AI Credits Top-up" + } + ], + appliesTo: { + feeTypes: %w[subscription], + billableMetricIds: [billable_metric.id] + } + } + } + ) + + result_data = result["data"]["createCustomerWallet"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq("first_wallet") + expect(result_data["name"]).to eq("First Wallet") + expect(result_data["priority"]).to eq(9) + expect(result_data["invoiceRequiresSuccessfulPayment"]).to eq(true) + expect(result_data["expirationAt"]).to eq(expiration_at.iso8601) + expect(result_data["paidTopUpMinAmountCents"]).to eq("100") + expect(result_data["paidTopUpMaxAmountCents"]).to eq("10000") + expect(result_data["recurringTransactionRules"].count).to eq(1) + expect(result_data["recurringTransactionRules"][0]["lagoId"]).to be_present + expect(result_data["recurringTransactionRules"][0]["method"]).to eq("target") + expect(result_data["recurringTransactionRules"][0]["trigger"]).to eq("interval") + expect(result_data["recurringTransactionRules"][0]["interval"]).to eq("monthly") + expect(result_data["recurringTransactionRules"][0]["paidCredits"]).to eq("0.0") + expect(result_data["recurringTransactionRules"][0]["grantedCredits"]).to eq("0.0") + expect(result_data["recurringTransactionRules"][0]["invoiceRequiresSuccessfulPayment"]).to eq(true) + expect(result_data["recurringTransactionRules"][0]["ignorePaidTopUpLimits"]).to eq(true) + expect(result_data["recurringTransactionRules"][0]["transactionMetadata"]).to contain_exactly( + {"key" => "example_key", "value" => "example_value"}, + {"key" => "another_key", "value" => "another_value"} + ) + expect(result_data["recurringTransactionRules"][0]["transactionName"]).to eq("Monthly AI Credits Top-up") + expect(result_data["appliesTo"]["feeTypes"]).to eq(["subscription"]) + expect(result_data["appliesTo"]["billableMetrics"].first["id"]).to eq(billable_metric.id) + + expect(WalletTransactions::CreateJob).to have_been_enqueued.with( + organization_id: membership.organization.id, + params: { + wallet_id: Regex::UUID, + paid_credits: "10.00", + granted_credits: "0.00", + source: :manual, + metadata: nil, + priority: nil, + name: "Initial Credits Purchase", + ignore_paid_top_up_limits: nil + } + ) + expect(SendWebhookJob).to have_been_enqueued.with("wallet.created", Wallet) + end + + context "when name is not present" do + it "creates a wallet with default code" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + name: nil, + priority: 11, + rateAmount: "1", + paidCredits: "0.00", + grantedCredits: "0.00", + expirationAt: (Time.zone.now + 1.year).iso8601, + currency: "EUR" + } + } + ) + + result_data = result["data"]["createCustomerWallet"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to be_nil + expect(result_data["code"]).to eq("default") + end + end + + context "when code is provided" do + it "creates a wallet with the provided code" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + name: "My Wallet", + code: "custom_code", + priority: 9, + rateAmount: "1", + paidCredits: "0.00", + grantedCredits: "0.00", + expirationAt: expiration_at.iso8601, + currency: "EUR" + } + } + ) + + result_data = result["data"]["createCustomerWallet"] + + expect(result_data["id"]).to be_present + expect(result_data["code"]).to eq("custom_code") + expect(result_data["name"]).to eq("My Wallet") + end + end + + context "when code is already taken for the customer" do + before do + create(:wallet, customer:, code: "existing_code") + end + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + name: "My Wallet", + code: "existing_code", + priority: 9, + rateAmount: "1", + paidCredits: "0.00", + grantedCredits: "0.00", + expirationAt: expiration_at.iso8601, + currency: "EUR" + } + } + ) + + expect_unprocessable_entity(result, details: {code: ["value_already_exist"]}) + end + end + + context "when transaction_name is not provided" do + it "creates a wallet with null transaction_name" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + name: "Test Wallet", + priority: 9, + rateAmount: "1", + paidCredits: "10.00", + grantedCredits: "0.00", + expirationAt: expiration_at.iso8601, + currency: "EUR", + recurringTransactionRules: [ + { + method: "fixed", + trigger: "interval", + interval: "monthly", + paidCredits: "10.0", + grantedCredits: "5.0" + } + ] + } + } + ) + + result_data = result["data"]["createCustomerWallet"] + + expect(result_data["id"]).to be_present + expect(result_data["recurringTransactionRules"].count).to eq(1) + expect(result_data["recurringTransactionRules"][0]["transactionName"]).to be_nil + + expect(WalletTransactions::CreateJob).to have_been_enqueued.with( + organization_id: membership.organization.id, + params: { + wallet_id: Regex::UUID, + paid_credits: "10.00", + granted_credits: "0.00", + source: :manual, + metadata: nil, + priority: nil, + name: nil, + ignore_paid_top_up_limits: nil + } + ) + end + end + + context "with metadata" do + it "creates a wallet with metadata" do + result = execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + customerId: customer.id, + name: "Wallet with Metadata", + priority: 9, + rateAmount: "1", + paidCredits: "0.00", + grantedCredits: "0.00", + expirationAt: expiration_at.iso8601, + currency: "EUR", + metadata: [ + {key: "env", value: "production"}, + {key: "team", value: "engineering"} + ] + } + } + ) + + result_data = result["data"]["createCustomerWallet"] + + expect(result_data["id"]).to be_present + expect(result_data["name"]).to eq("Wallet with Metadata") + expect(result_data["metadata"]).to contain_exactly( + {"key" => "env", "value" => "production"}, + {"key" => "team", "value" => "engineering"} + ) + end + end +end diff --git a/spec/graphql/mutations/wallets/terminate_spec.rb b/spec/graphql/mutations/wallets/terminate_spec.rb new file mode 100644 index 0000000..6c177de --- /dev/null +++ b/spec/graphql/mutations/wallets/terminate_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Wallets::Terminate do + let(:required_permission) { "wallets:terminate" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:) } + + let(:mutation) do + <<-GQL + mutation($input: TerminateCustomerWalletInput!) { + terminateCustomerWallet(input: $input) { + id name status terminatedAt + } + } + GQL + end + + before { subscription } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:terminate" + + it "terminates a wallet" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: {id: wallet.id} + } + ) + + data = result["data"]["terminateCustomerWallet"] + + expect(data["id"]).to eq(wallet.id) + expect(data["name"]).to eq(wallet.name) + expect(data["status"]).to eq("terminated") + expect(data["terminatedAt"]).to be_present + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.terminated", Wallet) + end +end diff --git a/spec/graphql/mutations/wallets/update_spec.rb b/spec/graphql/mutations/wallets/update_spec.rb new file mode 100644 index 0000000..98c6ff6 --- /dev/null +++ b/spec/graphql/mutations/wallets/update_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Wallets::Update, :premium do + let(:required_permission) { "wallets:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:billable_metric, organization: membership.organization) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:) } + let(:expiration_at) { (Time.zone.now + 1.year) } + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, wallet:) } + + let(:mutation) do + <<-GQL + mutation($input: UpdateCustomerWalletInput!) { + updateCustomerWallet(input: $input) { + id + code + name + priority + status + expirationAt + invoiceRequiresSuccessfulPayment + paidTopUpMinAmountCents + paidTopUpMaxAmountCents + metadata { + key + value + } + recurringTransactionRules { + lagoId + method + trigger + interval + thresholdCredits + paidCredits + grantedCredits + targetOngoingBalance + invoiceRequiresSuccessfulPayment + ignorePaidTopUpLimits + expirationAt + transactionMetadata { + key + value + } + transactionName + } + appliesTo { + feeTypes + billableMetrics { + id + } + } + } + } + GQL + end + + before do + subscription + recurring_transaction_rule + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:update" + + it "updates a wallet" do + result = execute_graphql( + current_organization: organization, + current_user: membership.user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: wallet.id, + name: "New name", + priority: 22, + expirationAt: expiration_at.iso8601, + invoiceRequiresSuccessfulPayment: true, + paidTopUpMinAmountCents: 1_00, + paidTopUpMaxAmountCents: 100_00, + recurringTransactionRules: [ + { + lagoId: recurring_transaction_rule.id, + method: "target", + trigger: "interval", + interval: "weekly", + paidCredits: "22.2", + grantedCredits: "22.2", + targetOngoingBalance: "300", + invoiceRequiresSuccessfulPayment: true, + ignorePaidTopUpLimits: true, + expirationAt: expiration_at.iso8601, + transactionMetadata: [ + {key: "example_key", value: "example_value"}, + {key: "another_key", value: "another_value"} + ], + transactionName: "Updated Credits Transaction" + } + ], + appliesTo: { + feeTypes: %w[subscription], + billableMetricIds: [billable_metric.id] + } + } + } + ) + + result_data = result["data"]["updateCustomerWallet"] + + expect(result_data).to include( + "id" => wallet.id, + "code" => wallet.code, + "name" => "New name", + "priority" => 22, + "status" => "active", + "invoiceRequiresSuccessfulPayment" => true, + "expirationAt" => expiration_at.iso8601, + "paidTopUpMinAmountCents" => "100", + "paidTopUpMaxAmountCents" => "10000" + ) + + expect(result_data["recurringTransactionRules"].count).to eq(1) + expect(result_data["recurringTransactionRules"][0]["transactionMetadata"]).to contain_exactly( + {"key" => "example_key", "value" => "example_value"}, + {"key" => "another_key", "value" => "another_value"} + ) + expect(result_data["recurringTransactionRules"][0]["transactionName"]).to eq("Updated Credits Transaction") + expect(result_data["recurringTransactionRules"][0]).to include( + "lagoId" => recurring_transaction_rule.id, + "method" => "target", + "trigger" => "interval", + "interval" => "weekly", + "paidCredits" => "22.2", + "grantedCredits" => "22.2", + "targetOngoingBalance" => "300.0", + "invoiceRequiresSuccessfulPayment" => true, + "ignorePaidTopUpLimits" => true + ) + expect(result_data["appliesTo"]["feeTypes"]).to eq(["subscription"]) + expect(result_data["appliesTo"]["billableMetrics"].first["id"]).to eq(billable_metric.id) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + + context "with metadata" do + it "updates a wallet with metadata" do + result = execute_graphql( + current_organization: organization, + current_user: membership.user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: wallet.id, + name: "Wallet with Metadata", + priority: 22, + metadata: [ + {key: "env", value: "staging"}, + {key: "region", value: "us-east"} + ] + } + } + ) + + result_data = result["data"]["updateCustomerWallet"] + + expect(result_data["id"]).to eq(wallet.id) + expect(result_data["name"]).to eq("Wallet with Metadata") + expect(result_data["metadata"]).to contain_exactly( + {"key" => "env", "value" => "staging"}, + {"key" => "region", "value" => "us-east"} + ) + end + end + + context "when updating code" do + it "updates a wallet with the new code" do + result = execute_graphql( + current_organization: organization, + current_user: membership.user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: wallet.id, + code: "updated_code", + priority: 22 + } + } + ) + + result_data = result["data"]["updateCustomerWallet"] + + expect(result_data["id"]).to eq(wallet.id) + expect(result_data["code"]).to eq("updated_code") + end + end + + context "when updating code to a value already taken for the customer" do + before do + create(:wallet, customer:, code: "existing_code") + end + + it "returns an error" do + result = execute_graphql( + current_organization: organization, + current_user: membership.user, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: wallet.id, + code: "existing_code", + priority: 22 + } + } + ) + + expect_unprocessable_entity(result, details: {code: ["value_already_exist"]}) + end + end +end diff --git a/spec/graphql/mutations/webhook_endpoints/create_spec.rb b/spec/graphql/mutations/webhook_endpoints/create_spec.rb new file mode 100644 index 0000000..fe5c4bd --- /dev/null +++ b/spec/graphql/mutations/webhook_endpoints/create_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::WebhookEndpoints::Create do + include_context "with mocked security logger" + + let(:required_permission) { "developers:manage" } + let(:membership) { create(:membership) } + let(:webhook_url) { Faker::Internet.url } + let(:input) do + { + webhookUrl: webhook_url, + signatureAlgo: "hmac", + name: "Test Webhook", + eventTypes: ["customer_created"] + } + end + let(:mutation) do + <<-GQL + mutation($input: WebhookEndpointCreateInput!) { + createWebhookEndpoint(input: $input) { + id, + webhookUrl, + signatureAlgo, + name, + eventTypes + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + it "creates a webhook_endpoint" do + expect(result["data"]["createWebhookEndpoint"]).to include( + "id" => String, + "webhookUrl" => webhook_url, + "signatureAlgo" => "hmac", + "name" => "Test Webhook", + "eventTypes" => ["customer_created"] + ) + end + + it_behaves_like "produces a security log", "webhook_endpoint.created" + end +end diff --git a/spec/graphql/mutations/webhook_endpoints/destroy_spec.rb b/spec/graphql/mutations/webhook_endpoints/destroy_spec.rb new file mode 100644 index 0000000..6d53ee2 --- /dev/null +++ b/spec/graphql/mutations/webhook_endpoints/destroy_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::WebhookEndpoints::Destroy do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {input: {id: webhook_endpoint.id}} + ) + end + + include_context "with mocked security logger" + + let(:query) do + <<-GQL + mutation($input: DestroyWebhookEndpointInput!) { + destroyWebhookEndpoint(input: $input) { id } + } + GQL + end + let(:required_permission) { "developers:manage" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let!(:webhook_endpoint) { create(:webhook_endpoint, organization:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + context "when webhook endpoint exists in the current organization" do + it "destroys the webhook endpoint" do + expect { result }.to change(WebhookEndpoint, :count).by(-1) + end + + it_behaves_like "produces a security log", "webhook_endpoint.deleted" do + before { result } + end + end +end diff --git a/spec/graphql/mutations/webhook_endpoints/update_spec.rb b/spec/graphql/mutations/webhook_endpoints/update_spec.rb new file mode 100644 index 0000000..69041a9 --- /dev/null +++ b/spec/graphql/mutations/webhook_endpoints/update_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::WebhookEndpoints::Update do + include_context "with mocked security logger" + + let(:required_permission) { "developers:manage" } + let(:membership) { create(:membership) } + let(:webhook_url) { Faker::Internet.url } + let(:webhook_endpoint) { create(:webhook_endpoint, organization: membership.organization) } + + let(:input) do + { + id: webhook_endpoint.id, + webhookUrl: webhook_url, + signatureAlgo: "hmac", + name: "Updated Webhook", + eventTypes: ["customer_updated"] + } + end + + let(:mutation) do + <<-GQL + mutation($input: WebhookEndpointUpdateInput!) { + updateWebhookEndpoint(input: $input) { + id, + webhookUrl, + signatureAlgo, + name, + eventTypes + } + } + GQL + end + + before { webhook_endpoint } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + context "with valid input" do + let!(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query: mutation, + variables: {input:} + ) + end + + it "updates a webhook_endpoint" do + expect(result["data"]["updateWebhookEndpoint"]).to include( + "id" => String, + "webhookUrl" => webhook_url, + "signatureAlgo" => "hmac", + "name" => "Updated Webhook", + "eventTypes" => ["customer_updated"] + ) + end + + it_behaves_like "produces a security log", "webhook_endpoint.updated" + end +end diff --git a/spec/graphql/mutations/webhooks/retry_spec.rb b/spec/graphql/mutations/webhooks/retry_spec.rb new file mode 100644 index 0000000..d29c3b3 --- /dev/null +++ b/spec/graphql/mutations/webhooks/retry_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Mutations::Webhooks::Retry do + let(:required_permission) { "developers:manage" } + let(:webhook) { create(:webhook, :failed, webhook_endpoint:) } + let(:webhook_endpoint) { create(:webhook_endpoint) } + let(:organization) { webhook_endpoint.organization.reload } + let(:membership) { create(:membership, organization:) } + + let(:mutation) do + <<-GQL + mutation($input: RetryWebhookInput!) { + retryWebhook(input: $input) { + id, + } + } + GQL + end + + before { webhook } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + it "retries a webhook" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: mutation, + variables: { + input: { + id: webhook.id + } + } + ) + + result_data = result["data"]["retryWebhook"] + + expect(result_data["id"]).to eq(webhook.id) + end +end diff --git a/spec/graphql/resolvers/activity_log_resolver_spec.rb b/spec/graphql/resolvers/activity_log_resolver_spec.rb new file mode 100644 index 0000000..c810bf4 --- /dev/null +++ b/spec/graphql/resolvers/activity_log_resolver_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::ActivityLogResolver, clickhouse: true do + let(:required_permission) { "audit_logs:view" } + let(:query) do + <<~GQL + query($activityLogId: ID!) { + activityLog(activityId: $activityLogId) { + activityId + activityObjectChanges + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:clickhouse_activity_log) { create(:clickhouse_activity_log, membership:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "audit_logs:view" + + shared_examples "blocked feature" do |message| + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {activityLogId: clickhouse_activity_log.activity_id} + ) + + expect_graphql_error(result:, message:) + end + end + + context "without premium feature" do + it_behaves_like "blocked feature", "unauthorized" + end + + context "without database configuration", :premium do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"] = nil + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it_behaves_like "blocked feature", "feature_unavailable" + end + + context "with premium feature", :premium do + it "returns a single activity log with parsed object changes" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {activityLogId: clickhouse_activity_log.activity_id} + ) + + activity_log_response = result["data"]["activityLog"] + + expect(activity_log_response["activityId"]).to eq(clickhouse_activity_log.activity_id) + expect(activity_log_response["activityObjectChanges"]).to eq("foo" => {"old" => "bar", "new" => "baz"}) + end + + context "when activity log is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {activityLogId: "invalid"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + end +end diff --git a/spec/graphql/resolvers/activity_logs_resolver_spec.rb b/spec/graphql/resolvers/activity_logs_resolver_spec.rb new file mode 100644 index 0000000..b867534 --- /dev/null +++ b/spec/graphql/resolvers/activity_logs_resolver_spec.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::ActivityLogsResolver, clickhouse: true do + let(:required_permission) { "audit_logs:view" } + let(:query) { build_query(limit: 5) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:clickhouse_activity_log) { create(:clickhouse_activity_log, membership:, logged_at: Time.iso8601("2025-09-08T15:04:45.016Z")) } + + before { clickhouse_activity_log } + + def build_query(limit: 5, filters: "") + <<~GQL + query { + activityLogs(limit: #{limit}#{", #{filters}" if filters.present?}) { + collection { + activityId + } + metadata { currentPage, totalCount } + } + } + GQL + end + + def execute_and_get_collection(filters = "") + result = execute_query(query: build_query(filters:)) + + result.dig("data", "activityLogs", "collection") + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "audit_logs:view" + + shared_examples "blocked feature" do |message| + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error(result:, message:) + end + end + + context "without premium feature" do + it_behaves_like "blocked feature", "unauthorized" + end + + context "without database configuration", :premium do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"] = nil + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it_behaves_like "blocked feature", "feature_unavailable" + end + + context "with premium feature", :premium do + it "returns the list of activity logs" do + result = execute_query(query:) + activity_logs_response = result["data"]["activityLogs"] + + expect(activity_logs_response["collection"].count).to eq(organization.activity_logs.count) + expect(activity_logs_response["collection"].first["activityId"]).to eq(clickhouse_activity_log.activity_id) + + expect(activity_logs_response["metadata"]["currentPage"]).to eq(1) + expect(activity_logs_response["metadata"]["totalCount"]).to eq(1) + end + + context "with fromDate filter" do + context "when fromDate is a date" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("fromDate: \"2025-09-08\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDate: \"2025-09-09\"") + expect(activity_logs.count).to eq(0) + end + end + + context "when fromDate is a datetime" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("fromDate: \"2025-09-08T15:00:00Z\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDate: \"2025-09-08T16:00:00+01:00\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDate: \"2025-09-09T15:10:00Z\"") + expect(activity_logs.count).to eq(0) + end + end + end + + context "with toDate filter" do + context "when toDate is a date" do + let(:query) {} + + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("toDate: \"2025-09-09\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("toDate: \"2025-09-08\"") + expect(activity_logs.count).to eq(0) + end + end + + context "when toDate is a datetime" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("toDate: \"2025-09-09T15:15:00Z\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("toDate: \"2025-09-08T16:15:00+01:00\"") + expect(activity_logs.count).to eq(0) + + activity_logs = execute_and_get_collection("toDate: \"2025-09-08T15:00:00Z\"") + expect(activity_logs.count).to eq(0) + + activity_logs = execute_and_get_collection("toDate: \"2025-09-08T16:00:00+02:00\"") + expect(activity_logs.count).to eq(0) + end + end + end + + context "with fromDatetime filter" do + context "when fromDatetime is a date" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-09\"") + expect(activity_logs.count).to eq(0) + end + end + + context "when fromDatetime is a datetime" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T15:00:00Z\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T16:00:00+01:00\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T15:10:00Z\"") + expect(activity_logs.count).to eq(0) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T16:00:00+02:00\"") + expect(activity_logs.count).to eq(1) + end + end + end + + context "with toDatetime filter" do + context "when toDatetime is a date" do + let(:query) {} + + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("toDatetime: \"2025-09-09\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("toDatetime: \"2025-09-08\"") + expect(activity_logs.count).to eq(0) + end + end + + context "when toDatetime is a datetime" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("toDatetime: \"2025-09-08T15:15:00Z\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("toDatetime: \"2025-09-08T16:15:00+01:00\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("toDatetime: \"2025-09-08T15:00:00Z\"") + expect(activity_logs.count).to eq(0) + + activity_logs = execute_and_get_collection("toDatetime: \"2025-09-08T16:00:00+02:00\"") + expect(activity_logs.count).to eq(0) + end + end + end + + context "with apiKeyIds filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("apiKeyIds: [\"#{clickhouse_activity_log.api_key_id}\"]") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("apiKeyIds: [\"other\"]") + expect(activity_logs.count).to eq(0) + end + end + + context "with activityIds filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("activityIds: [\"#{clickhouse_activity_log.activity_id}\"]") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("activityIds: [\"other\"]") + expect(activity_logs.count).to eq(0) + end + end + + context "with activityTypes filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("activityTypes: [billable_metric_created]") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("activityTypes: [billable_metric_deleted]") + expect(activity_logs.count).to eq(0) + end + end + + context "with activitySources filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("activitySources: [api]") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("activitySources: [front]") + expect(activity_logs.count).to eq(0) + end + end + + context "with userEmails filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("userEmails: [\"#{clickhouse_activity_log.user.email}\"]") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("userEmails: [\"other\"]") + expect(activity_logs.count).to eq(0) + end + end + + context "with externalCustomerId filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("externalCustomerId: \"#{clickhouse_activity_log.external_customer_id}\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("externalCustomerId: \"other\"") + expect(activity_logs.count).to eq(0) + end + end + + context "with externalSubscriptionId filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("externalSubscriptionId: \"#{clickhouse_activity_log.external_subscription_id}\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("externalSubscriptionId: \"other\"") + expect(activity_logs.count).to eq(0) + end + end + + context "with resourceIds filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("resourceIds: [\"#{clickhouse_activity_log.resource_id}\"]") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("resourceIds: [\"other\"]") + expect(activity_logs.count).to eq(0) + end + end + + context "with resourceTypes filter" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("resourceTypes: [billable_metric]") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("resourceTypes: [coupon]") + expect(activity_logs.count).to eq(0) + end + end + end +end diff --git a/spec/graphql/resolvers/add_on_resolver_spec.rb b/spec/graphql/resolvers/add_on_resolver_spec.rb new file mode 100644 index 0000000..6bf6cf4 --- /dev/null +++ b/spec/graphql/resolvers/add_on_resolver_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::AddOnResolver do + let(:required_permission) { "addons:view" } + let(:query) do + <<~GQL + query($addOnId: ID!) { + addOn(id: $addOnId) { + id name customersCount appliedAddOnsCount + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:add_on) { create(:add_on, organization:) } + let(:customer) { create(:customer, organization:) } + let(:customer2) { create(:customer, organization:) } + let(:applied_add_on_list) { create_list(:applied_add_on, 3, add_on:, customer:) } + let(:applied_add_on) { create(:applied_add_on, add_on:, customer: customer2) } + + before do + customer + customer2 + applied_add_on_list + applied_add_on + + 3.times do + create(:subscription, customer:) + end + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "addons:view" + + it "returns a single add-on" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {addOnId: add_on.id} + ) + + add_on_response = result["data"]["addOn"] + + expect(add_on_response["id"]).to eq(add_on.id) + expect(add_on_response["name"]).to eq(add_on.name) + expect(add_on_response["customersCount"]).to eq(2) + expect(add_on_response["appliedAddOnsCount"]).to eq(4) + end + + context "when add-on is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {addOnId: "invalid"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/add_ons_resolver_spec.rb b/spec/graphql/resolvers/add_ons_resolver_spec.rb new file mode 100644 index 0000000..3564f8b --- /dev/null +++ b/spec/graphql/resolvers/add_ons_resolver_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::AddOnsResolver do + let(:required_permission) { "addons:view" } + let(:query) do + <<~GQL + query { + addOns(limit: 5) { + collection { id name } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:add_on) { create(:add_on, organization:) } + + before { add_on } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "addons:view" + + it "returns a list of add-ons" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + add_ons_response = result["data"]["addOns"] + + expect(add_ons_response["collection"].first["id"]).to eq(add_on.id) + expect(add_ons_response["collection"].first["name"]).to eq(add_on.name) + + expect(add_ons_response["metadata"]["currentPage"]).to eq(1) + expect(add_ons_response["metadata"]["totalCount"]).to eq(1) + end + + context "with integration mappings" do + let(:integration) { create(:netsuite_integration, organization:) } + let(:netsuite_mapping) { create(:netsuite_mapping, integration:, mappable_type: "AddOn", mappable_id: add_on.id) } + let(:netsuite_mapping2) { create(:netsuite_mapping, external_name: "Bla") } + let(:query) do + <<~GQL + query($integrationId: ID) { + addOns(limit: 5) { + collection { id name integrationMappings(integrationId: $integrationId) { externalId externalName } } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + integration + netsuite_mapping + netsuite_mapping2 + end + + it "returns a list of add-ons" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {integrationId: integration.id} + ) + + add_ons_response = result["data"]["addOns"] + + expect(add_ons_response["collection"].first["id"]).to eq(add_on.id) + expect(add_ons_response["collection"].first["name"]).to eq(add_on.name) + + expect(add_ons_response["collection"].first["integrationMappings"].count).to eq(1) + expect(add_ons_response["collection"].first["integrationMappings"].first["externalName"]) + .to eq("Credits and Discounts") + + expect(add_ons_response["metadata"]["currentPage"]).to eq(1) + expect(add_ons_response["metadata"]["totalCount"]).to eq(1) + end + end +end diff --git a/spec/graphql/resolvers/ai_conversation_resolver_spec.rb b/spec/graphql/resolvers/ai_conversation_resolver_spec.rb new file mode 100644 index 0000000..f276c90 --- /dev/null +++ b/spec/graphql/resolvers/ai_conversation_resolver_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::AiConversationResolver do + let(:required_permission) { "ai_conversations:view" } + let(:query) do + <<~GQL + query($id: ID!) { + aiConversation(id: $id) { + id + name + mistralConversationId + messages { + content + createdAt + type + } + createdAt + updatedAt + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:ai_conversation) { create(:ai_conversation, organization:, mistral_conversation_id: "conv_123") } + let(:fetch_messages_service) { instance_double(AiConversations::FetchMessagesService) } + let(:service_result) do + result = BaseService::Result.new + result.messages = [ + { + "content" => "Hello", + "created_at" => "2024-01-01T12:00:00Z", + "type" => "message.output" + }, + { + "content" => "Hi there!", + "created_at" => "2024-01-01T12:01:00Z", + "type" => "message.output" + } + ] + result + end + + before do + ai_conversation + allow(AiConversations::FetchMessagesService).to receive(:call).and_return(service_result) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "ai_conversations:view" + + shared_examples "blocked feature" do |message| + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: ai_conversation.id} + ) + + expect_graphql_error(result:, message:) + end + end + + context "without premium feature" do + it_behaves_like "blocked feature", "unauthorized" + end + + context "without mistral configuration", :premium do + before do + ENV["MISTRAL_API_KEY"] = nil + ENV["MISTRAL_AGENT_ID"] = nil + end + + it_behaves_like "blocked feature", "feature_unavailable" + end + + context "with premium feature", :premium do + before do + ENV["MISTRAL_API_KEY"] = "test_api_key" + ENV["MISTRAL_AGENT_ID"] = "test_agent_id" + end + + it "returns a single ai conversation with messages" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: ai_conversation.id} + ) + + ai_conversation_response = result["data"]["aiConversation"] + + expect(ai_conversation_response["id"]).to eq(ai_conversation.id) + expect(ai_conversation_response["name"]).to eq(ai_conversation.name) + expect(ai_conversation_response["mistralConversationId"]).to eq("conv_123") + expect(ai_conversation_response["messages"].count).to eq(2) + expect(ai_conversation_response["messages"][0]["content"]).to eq("Hello") + expect(ai_conversation_response["messages"][0]["type"]).to eq("message.output") + expect(ai_conversation_response["messages"][1]["content"]).to eq("Hi there!") + end + + context "when ai conversation is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when fetch messages service fails" do + let(:failed_result) do + result = BaseService::Result.new + result.service_failure!(code: "mistral_api_error", message: "API error") + result + end + + before do + allow(AiConversations::FetchMessagesService).to receive(:call).and_return(failed_result) + end + + it "raises the error" do + expect do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: ai_conversation.id} + ) + end.to raise_error(BaseService::ServiceFailure) + end + end + end +end diff --git a/spec/graphql/resolvers/ai_conversations_resolver_spec.rb b/spec/graphql/resolvers/ai_conversations_resolver_spec.rb new file mode 100644 index 0000000..9b15b55 --- /dev/null +++ b/spec/graphql/resolvers/ai_conversations_resolver_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::AiConversationsResolver do + let(:required_permission) { "ai_conversations:view" } + let(:query) do + <<~GQL + query($limit: Int) { + aiConversations(limit: $limit) { + collection { + id + name + mistralConversationId + createdAt + updatedAt + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let!(:ai_conversation) { create(:ai_conversation, organization:, membership:, mistral_conversation_id: "conv_123") } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "ai_conversations:view" + + shared_examples "blocked feature" do |message| + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error(result:, message:) + end + end + + context "without premium feature" do + it_behaves_like "blocked feature", "unauthorized" + end + + context "without mistral configuration", :premium do + before do + ENV["MISTRAL_API_KEY"] = nil + ENV["MISTRAL_AGENT_ID"] = nil + end + + it_behaves_like "blocked feature", "feature_unavailable" + end + + context "with premium feature", :premium do + before do + ENV["MISTRAL_API_KEY"] = "test_api_key" + ENV["MISTRAL_AGENT_ID"] = "test_agent_id" + end + + it "returns the list of ai conversations for the current user" do + other_membership = create(:membership, organization:) + create(:ai_conversation, organization:, membership: other_membership) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {limit: 10} + ) + + ai_conversations_response = result["data"]["aiConversations"]["collection"] + + expect(ai_conversations_response.count).to eq(1) + expect(ai_conversations_response.first["id"]).to eq(ai_conversation.id) + expect(ai_conversations_response.first["name"]).to eq(ai_conversation.name) + expect(ai_conversations_response.first["mistralConversationId"]).to eq("conv_123") + end + + context "with limit parameter" do + let(:ai_conversation2) { create(:ai_conversation, organization:, membership:) } + let(:ai_conversation3) { create(:ai_conversation, organization:, membership:) } + + before do + ai_conversation2 + ai_conversation3 + end + + it "limits the number of returned conversations" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {limit: 2} + ) + + ai_conversations_response = result["data"]["aiConversations"]["collection"] + + expect(ai_conversations_response.count).to eq(2) + end + end + end +end diff --git a/spec/graphql/resolvers/analytics/gross_revenues_resolver_spec.rb b/spec/graphql/resolvers/analytics/gross_revenues_resolver_spec.rb new file mode 100644 index 0000000..51e7fbc --- /dev/null +++ b/spec/graphql/resolvers/analytics/gross_revenues_resolver_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Analytics::GrossRevenuesResolver do + let(:required_permission) { "analytics:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $externalCustomerId: String, $expireCache: Boolean) { + grossRevenues(currency: $currency, externalCustomerId: $externalCustomerId, expireCache: $expireCache) { + collection { + month + amountCents + currency + invoicesCount + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "analytics:view" + + it "returns a list of gross revenues" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(result["data"]["grossRevenues"]["collection"]).to eq([]) + end +end diff --git a/spec/graphql/resolvers/analytics/invoice_collections_resolver_spec.rb b/spec/graphql/resolvers/analytics/invoice_collections_resolver_spec.rb new file mode 100644 index 0000000..86599c5 --- /dev/null +++ b/spec/graphql/resolvers/analytics/invoice_collections_resolver_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Analytics::InvoiceCollectionsResolver do + let(:required_permission) { "analytics:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum) { + invoiceCollections(currency: $currency) { + collection { + month + amountCents + invoicesCount + currency + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "analytics:view" + + context "without premium feature" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error( + result:, + message: "unauthorized" + ) + end + end + + context "with premium feature", :premium do + it "returns a list of invoice collections" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoice_collections_response = result["data"]["invoiceCollections"] + month = DateTime.parse invoice_collections_response["collection"].first["month"] + + expect(month).to eq(DateTime.current.beginning_of_month) + expect(invoice_collections_response["collection"].first["amountCents"]).to eq("0") + expect(invoice_collections_response["collection"].first["invoicesCount"]).to eq("0") + end + end +end diff --git a/spec/graphql/resolvers/analytics/invoiced_usages_resolver_spec.rb b/spec/graphql/resolvers/analytics/invoiced_usages_resolver_spec.rb new file mode 100644 index 0000000..39f7741 --- /dev/null +++ b/spec/graphql/resolvers/analytics/invoiced_usages_resolver_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Analytics::InvoicedUsagesResolver do + let(:required_permission) { "analytics:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum) { + invoicedUsages(currency: $currency) { + collection { + month + amountCents + currency + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "analytics:view" + + context "without premium feature" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error( + result:, + message: "unauthorized" + ) + end + end + + context "with premium feature", :premium do + it "returns a list of invoiced usages" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(result["data"]["invoicedUsages"]["collection"]).to eq([]) + end + end +end diff --git a/spec/graphql/resolvers/analytics/mrrs_resolver_spec.rb b/spec/graphql/resolvers/analytics/mrrs_resolver_spec.rb new file mode 100644 index 0000000..5a81ca9 --- /dev/null +++ b/spec/graphql/resolvers/analytics/mrrs_resolver_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Analytics::MrrsResolver do + let(:required_permission) { "analytics:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum) { + mrrs(currency: $currency) { + collection { + month + amountCents + currency + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "analytics:view" + + context "without premium feature" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error( + result:, + message: "unauthorized" + ) + end + end + + context "with premium feature", :premium do + it "returns a list of mrrs" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + mrrs_response = result["data"]["mrrs"] + month = DateTime.parse mrrs_response["collection"].first["month"] + + expect(month).to eq(DateTime.current.beginning_of_month) + expect(mrrs_response["collection"].first["amountCents"]).to eq(nil) + expect(mrrs_response["collection"].first["currency"]).to eq(nil) + end + end +end diff --git a/spec/graphql/resolvers/analytics/overdue_balances_resolver_spec.rb b/spec/graphql/resolvers/analytics/overdue_balances_resolver_spec.rb new file mode 100644 index 0000000..55c9ea2 --- /dev/null +++ b/spec/graphql/resolvers/analytics/overdue_balances_resolver_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Analytics::OverdueBalancesResolver do + let(:required_permission) { "analytics:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $externalCustomerId: String, $months: Int, $expireCache: Boolean) { + overdueBalances(currency: $currency, externalCustomerId: $externalCustomerId, months: $months, expireCache: $expireCache) { + collection { + amountCents + currency + lagoInvoiceIds + month + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "analytics:view" + + it "returns a list of overdue balances" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(result["data"]["overdueBalances"]["collection"]).to eq([]) + end +end diff --git a/spec/graphql/resolvers/api_key_resolver_spec.rb b/spec/graphql/resolvers/api_key_resolver_spec.rb new file mode 100644 index 0000000..ccb6c15 --- /dev/null +++ b/spec/graphql/resolvers/api_key_resolver_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::ApiKeyResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {apiKeyId: api_key_id} + ) + end + + let(:query) do + <<~GQL + query($apiKeyId: ID!) { + apiKey(id: $apiKeyId) { + id value createdAt + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:required_permission) { "developers:keys:manage" } + let(:api_key) { membership.organization.api_keys.first } + + before { create(:api_key) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:keys:manage" + + context "when api key with such ID exists in the current organization" do + let(:api_key_id) { api_key.id } + + it "returns an api key" do + api_key_response = result["data"]["apiKey"] + + expect(api_key_response["id"]).to eq(api_key.id) + expect(api_key_response["value"]).to eq(api_key.value) + expect(api_key_response["createdAt"]).to eq(api_key.created_at.iso8601) + end + end + + context "when api key with such ID does not exist in the current organization" do + let(:api_key_id) { SecureRandom.uuid } + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/api_keys_resolver_spec.rb b/spec/graphql/resolvers/api_keys_resolver_spec.rb new file mode 100644 index 0000000..c171e02 --- /dev/null +++ b/spec/graphql/resolvers/api_keys_resolver_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::ApiKeysResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + end + + let(:query) do + <<~GQL + query { + apiKeys { + collection { id value createdAt } + metadata { currentPage, totalCount totalPages } + } + } + GQL + end + + let(:organization) { create(:api_key).organization } + let(:membership) { create(:membership, organization:) } + let(:required_permission) { "developers:keys:manage" } + let(:api_key) { membership.organization.api_keys.first } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:keys:manage" + + it "returns a list of api keys" do + api_key_response = result["data"]["apiKeys"] + + expect(api_key_response["collection"].first["id"]).to eq(api_key.id) + expect(api_key_response["collection"].first["value"]).to eq("••••••••" + api_key.value.last(3)) + expect(api_key_response["collection"].first["createdAt"]).to eq(api_key.created_at.iso8601) + + expect(api_key_response["metadata"]["currentPage"]).to eq(1) + expect(api_key_response["metadata"]["totalCount"]).to eq(1) + expect(api_key_response["metadata"]["totalPages"]).to eq(1) + end + + context "when pagination is provided" do + let(:query) do + <<~GQL + query($limit: Int, $page: Int) { + apiKeys(limit: $limit, page: $page) { + collection { id value name createdAt } + metadata { currentPage, totalCount totalPages} + } + } + GQL + end + + before do + create(:api_key, organization: membership.organization, created_at: 1.day.ago, name: "Older API Key") + end + + def fetch_api_keys(page: 1, limit: 1) + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + limit:, + page: + } + ) + end + + it "returns a list of api keys" do + [ + {page: 1, limit: 1, expected_total_pages: 2, expected_api_key_names: ["Older API Key"]}, + {page: 2, limit: 1, expected_total_pages: 2, expected_api_key_names: ["API Key"]}, + {page: 1, limit: 2, expected_total_pages: 1, expected_api_key_names: ["Older API Key", "API Key"]} + ].each do |test_case| + page, limit, expected_total_pages, expected_api_key_names = test_case.values_at(:page, :limit, :expected_total_pages, :expected_api_key_names) + api_key_response = fetch_api_keys(page:, limit:)["data"]["apiKeys"] + + collection = api_key_response["collection"] + expect(collection.size).to eq(limit) + expect(collection.map { |api_key| api_key["name"] }).to eq(expected_api_key_names) + + expect(api_key_response["metadata"]["currentPage"]).to eq(page) + expect(api_key_response["metadata"]["totalCount"]).to eq(2) + expect(api_key_response["metadata"]["totalPages"]).to eq(expected_total_pages) + end + end + end +end diff --git a/spec/graphql/resolvers/api_log_resolver_spec.rb b/spec/graphql/resolvers/api_log_resolver_spec.rb new file mode 100644 index 0000000..bc4c890 --- /dev/null +++ b/spec/graphql/resolvers/api_log_resolver_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::ApiLogResolver, clickhouse: true do + let(:required_permission) { "audit_logs:view" } + let(:query) do + <<~GQL + query($requestId: ID!) { + apiLog(requestId: $requestId) { + requestId + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:clickhouse_api_log) { create(:clickhouse_api_log, membership:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "audit_logs:view" + + shared_examples "blocked feature" do |message| + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {requestId: clickhouse_api_log.request_id} + ) + + expect_graphql_error(result:, message:) + end + end + + context "without premium feature" do + it_behaves_like "blocked feature", "unauthorized" + end + + context "without database configuration", :premium do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = nil + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it_behaves_like "blocked feature", "feature_unavailable" + end + + context "with premium feature", :premium do + it "returns a single api log" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {requestId: clickhouse_api_log.request_id} + ) + + api_log_response = result["data"]["apiLog"] + expect(api_log_response["requestId"]).to eq(clickhouse_api_log.request_id) + end + + context "when activity log is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {requestId: "invalid"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + end +end diff --git a/spec/graphql/resolvers/api_logs_resolver_spec.rb b/spec/graphql/resolvers/api_logs_resolver_spec.rb new file mode 100644 index 0000000..4698999 --- /dev/null +++ b/spec/graphql/resolvers/api_logs_resolver_spec.rb @@ -0,0 +1,265 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::ApiLogsResolver, clickhouse: true do + let(:required_permission) { "audit_logs:view" } + let(:query) { build_query(limit: 5) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:api_log) { create(:clickhouse_api_log, membership:, logged_at: Time.iso8601("2025-09-08T15:04:45.016Z")) } + + before { api_log } + + def build_query(limit: 5, filters: "") + <<~GQL + query { + apiLogs(limit: #{limit}#{", #{filters}" if filters.present?}) { + collection { + requestId + } + metadata { currentPage, totalCount } + } + } + GQL + end + + def execute_and_get_collection(filters = "") + result = execute_query(query: build_query(filters:)) + + result.dig("data", "apiLogs", "collection") + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "audit_logs:view" + + shared_examples "blocked feature" do |message| + it "returns an error" do + result = execute_query(query:) + expect_graphql_error(result:, message:) + end + end + + context "without premium feature" do + it_behaves_like "blocked feature", "unauthorized" + end + + context "without database configuration", :premium do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = nil + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it_behaves_like "blocked feature", "feature_unavailable" + end + + context "with premium feature", :premium do + it "returns the list of api logs" do + result = execute_query( + query: + ) + + api_logs_response = result["data"]["apiLogs"] + + expect(api_logs_response["collection"].count).to eq(organization.api_logs.count) + expect(api_logs_response["collection"].first["requestId"]).to eq(api_log.request_id) + + expect(api_logs_response["metadata"]["currentPage"]).to eq(1) + expect(api_logs_response["metadata"]["totalCount"]).to eq(1) + end + + context "with httpStatuses filter" do + let(:failed_api_log) { create(:clickhouse_api_log, membership:, http_status: 404) } + + before { failed_api_log } + + context "with string" do + let(:http_status) { "failed" } + + it "return failed api logs" do + result = execute_query(query: build_query(filters: "httpStatuses: [#{http_status}]")) + + api_logs_response = result["data"]["apiLogs"] + + expect(api_logs_response["collection"].first["requestId"]).to eq(failed_api_log.request_id) + + expect(api_logs_response["metadata"]["currentPage"]).to eq(1) + expect(api_logs_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "with integer" do + let(:http_status) { 404 } + + it "return failed api logs" do + result = execute_query( + query: build_query(filters: "httpStatuses: [#{http_status}]") + ) + + api_logs_response = result["data"]["apiLogs"] + + expect(api_logs_response["collection"].first["requestId"]).to eq(failed_api_log.request_id) + + expect(api_logs_response["metadata"]["currentPage"]).to eq(1) + expect(api_logs_response["metadata"]["totalCount"]).to eq(1) + end + end + end + + context "with httpMethods filter" do + it "return api logs with the http method" do + api_logs = execute_and_get_collection("httpMethods: [post]") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("httpMethods: [put]") + expect(api_logs.count).to eq(0) + end + end + + context "with apiKeyIds filter" do + it "return api logs with the api key id" do + api_logs = execute_and_get_collection("apiKeyIds: [\"#{api_log.api_key_id}\"]") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("apiKeyIds: [\"other\"]") + expect(api_logs.count).to eq(0) + end + end + + context "with requestIds filter" do + it "return api logs with the request id" do + api_logs = execute_and_get_collection("requestIds: [\"#{api_log.request_id}\"]") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("requestIds: [\"other\"]") + expect(api_logs.count).to eq(0) + end + end + + context "with requestPaths filter" do + it "return api logs with the request path" do + api_logs = execute_and_get_collection("requestPaths: [\"#{api_log.request_path}\"]") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("requestPaths: [\"other\"]") + expect(api_logs.count).to eq(0) + end + end + + context "with fromDate filter" do + context "when fromDate is a date" do + it "returns expected activity logs" do + api_logs = execute_and_get_collection("fromDate: \"2025-09-08\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("fromDate: \"2025-09-09\"") + expect(api_logs.count).to eq(0) + end + end + + context "when fromDate is a datetime" do + it "returns expected activity logs" do + api_logs = execute_and_get_collection("fromDate: \"2025-09-08T15:00:00Z\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("fromDate: \"2025-09-08T16:00:00+01:00\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("fromDate: \"2025-09-08T15:10:00Z\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("fromDate: \"2025-09-09T16:00:00+02:00\"") + expect(api_logs.count).to eq(0) + end + end + end + + context "with toDate filter" do + context "when toDate is a date" do + let(:query) {} + + it "returns expected activity logs" do + api_logs = execute_and_get_collection("toDate: \"2025-09-09\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("toDate: \"2025-09-08\"") + expect(api_logs.count).to eq(0) + end + end + + context "when toDate is a datetime" do + it "returns expected activity logs" do + api_logs = execute_and_get_collection("toDate: \"2025-09-09T15:15:00Z\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("toDate: \"2025-09-08T16:15:00+01:00\"") + expect(api_logs.count).to eq(0) + + api_logs = execute_and_get_collection("toDate: \"2025-09-08T15:00:00Z\"") + expect(api_logs.count).to eq(0) + end + end + end + + context "with fromDatetime filter" do + context "when fromDatetime is a date" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-09\"") + expect(activity_logs.count).to eq(0) + end + end + + context "when fromDatetime is a datetime" do + it "returns expected activity logs" do + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T15:00:00Z\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T16:00:00+01:00\"") + expect(activity_logs.count).to eq(1) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T15:10:00Z\"") + expect(activity_logs.count).to eq(0) + + activity_logs = execute_and_get_collection("fromDatetime: \"2025-09-08T16:00:00+02:00\"") + expect(activity_logs.count).to eq(1) + end + end + end + + context "with toDatetime filter" do + context "when toDatetime is a date" do + let(:query) {} + + it "returns expected activity logs" do + api_logs = execute_and_get_collection("toDatetime: \"2025-09-09\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("toDatetime: \"2025-09-08\"") + expect(api_logs.count).to eq(0) + end + end + + context "when toDatetime is a datetime" do + it "returns expected activity logs" do + api_logs = execute_and_get_collection("toDatetime: \"2025-09-08T15:15:00Z\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("toDatetime: \"2025-09-08T16:15:00+01:00\"") + expect(api_logs.count).to eq(1) + + api_logs = execute_and_get_collection("toDatetime: \"2025-09-08T15:00:00Z\"") + expect(api_logs.count).to eq(0) + + api_logs = execute_and_get_collection("toDatetime: \"2025-09-08T16:00:00+02:00\"") + expect(api_logs.count).to eq(0) + end + end + end + end +end diff --git a/spec/graphql/resolvers/applied_coupons_resolver_spec.rb b/spec/graphql/resolvers/applied_coupons_resolver_spec.rb new file mode 100644 index 0000000..2686a11 --- /dev/null +++ b/spec/graphql/resolvers/applied_coupons_resolver_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::AppliedCouponsResolver do + let(:required_permission) { "coupons:view" } + let(:query) do + <<~GQL + query { + appliedCoupons(limit: 5, status: active) { + collection { id amountCents amountCurrency frequency } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:coupon) { create(:coupon, organization:) } + let(:applied_coupon) { create(:applied_coupon, customer:, coupon:, organization:) } + + before do + applied_coupon + + create(:applied_coupon, customer:, coupon:, organization:, status: :terminated) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:view" + + it "returns a list of applied coupons" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + applied_coupons_response = result["data"]["appliedCoupons"] + + expect(applied_coupons_response["collection"].count).to eq(1) + expect(applied_coupons_response["collection"].first["id"]).to eq(applied_coupon.id) + + expect(applied_coupons_response["metadata"]["currentPage"]).to eq(1) + expect(applied_coupons_response["metadata"]["totalCount"]).to eq(1) + end + + context "with external_customer_id filter" do + let(:query) do + <<~GQL + query($externalCustomerId: String) { + appliedCoupons(externalCustomerId: $externalCustomerId, limit: 5) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:other_customer) { create(:customer, organization:) } + + before do + create(:applied_coupon, customer: other_customer, coupon:, organization:) + end + + it "filters by external customer id" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {externalCustomerId: customer.external_id} + ) + + applied_coupons_response = result["data"]["appliedCoupons"] + + expect(applied_coupons_response["metadata"]["totalCount"]).to eq(2) + expect(applied_coupons_response["collection"].pluck("id")).to include(applied_coupon.id) + end + end + + context "with coupon_code filter" do + let(:query) do + <<~GQL + query($couponCode: [String!]) { + appliedCoupons(couponCode: $couponCode, limit: 5) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:other_coupon) { create(:coupon, organization:) } + + before do + create(:applied_coupon, customer:, coupon: other_coupon, organization:) + end + + it "filters by coupon code" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {couponCode: [coupon.code]} + ) + + applied_coupons_response = result["data"]["appliedCoupons"] + + expect(applied_coupons_response["metadata"]["totalCount"]).to eq(2) + expect(applied_coupons_response["collection"].pluck("id")).to include(applied_coupon.id) + end + end +end diff --git a/spec/graphql/resolvers/auth/google/auth_url_resolver_spec.rb b/spec/graphql/resolvers/auth/google/auth_url_resolver_spec.rb new file mode 100644 index 0000000..e40da4f --- /dev/null +++ b/spec/graphql/resolvers/auth/google/auth_url_resolver_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Auth::Google::AuthUrlResolver do + let(:query) do + <<~GQL + query { + googleAuthUrl { + url + } + } + GQL + end + + before do + ENV["GOOGLE_AUTH_CLIENT_ID"] = "client_id" + ENV["GOOGLE_AUTH_CLIENT_SECRET"] = "client_secret" + end + + it "returns the google auth url" do + result = execute_graphql( + query:, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")) + ) + + response = result["data"]["googleAuthUrl"] + + expect(response["url"]).to include("https://accounts.google.com/o/oauth2/auth") + end + + context "when google auth is not setup" do + before do + ENV["GOOGLE_AUTH_CLIENT_ID"] = nil + ENV["GOOGLE_AUTH_CLIENT_SECRET"] = nil + end + + it "returns an error" do + result = execute_graphql( + query:, + request: Rack::Request.new(Rack::MockRequest.env_for("http://example.com")) + ) + + response = result["errors"].first + + expect(response["extensions"]["code"]).to eq("google_auth_missing_setup") + expect(response["extensions"]["status"]).to eq(500) + end + end +end diff --git a/spec/graphql/resolvers/billable_metric_resolver_spec.rb b/spec/graphql/resolvers/billable_metric_resolver_spec.rb new file mode 100644 index 0000000..4aeca28 --- /dev/null +++ b/spec/graphql/resolvers/billable_metric_resolver_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::BillableMetricResolver do + subject(:graphql_request) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {billableMetricId: billable_metric.id} + ) + end + + let(:required_permission) { "billable_metrics:view" } + let(:query) do + <<~GQL + query($billableMetricId: ID!) { + billableMetric(id: $billableMetricId) { + id + name + hasSubscriptions + hasActiveSubscriptions + hasDraftInvoices + hasPlans + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billable_metric) { create(:billable_metric, organization:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billable_metrics:view" + + it "returns a single billable metric" do + metric_response = graphql_request["data"]["billableMetric"] + + expect(metric_response["id"]).to eq(billable_metric.id) + expect(metric_response["hasSubscriptions"]).to eq(false) + expect(metric_response["hasActiveSubscriptions"]).to eq(false) + expect(metric_response["hasDraftInvoices"]).to eq(false) + expect(metric_response["hasPlans"]).to eq(false) + end + + context "when billable metric has subscriptions" do + before do + plan = create(:plan, organization:) + create(:subscription, :terminated, plan:, organization:) + create(:standard_charge, plan:, billable_metric:, organization:) + end + + it "returns true for has subscriptions" do + metric_response = graphql_request["data"]["billableMetric"] + expect(metric_response["hasSubscriptions"]).to eq(true) + expect(metric_response["hasActiveSubscriptions"]).to eq(false) + end + end + + context "when billable metric has active subscriptions" do + before do + terminated_plan = create(:plan, organization:) + create(:subscription, :terminated, plan: terminated_plan, organization:) + create(:standard_charge, plan: terminated_plan, billable_metric:, organization:) + + active_plan = create(:plan, organization:) + create(:subscription, plan: active_plan, organization:) + create(:standard_charge, plan: active_plan, billable_metric:, organization:) + end + + it "returns true for has active subscriptions" do + metric_response = graphql_request["data"]["billableMetric"] + expect(metric_response["hasSubscriptions"]).to eq(true) + expect(metric_response["hasActiveSubscriptions"]).to eq(true) + end + end + + context "when billable metric has draft invoices" do + before do + customer = create(:customer, organization:) + plan = create(:plan, organization:) + plan_2 = create(:plan, organization:) + create(:subscription, plan:, organization:) + create(:subscription, plan: plan_2, organization:) + charge = create(:standard_charge, plan:, billable_metric:, organization:) + charge_2 = create(:standard_charge, plan: plan_2, billable_metric:, organization:) + + invoice = create(:invoice, customer:, organization:) + create(:fee, invoice:, charge:) + + draft_invoice = create(:invoice, :draft, customer:, organization:) + create(:fee, invoice: draft_invoice, charge: charge_2) + create(:fee, invoice: draft_invoice, charge: charge_2) + end + + it "returns true for has draft invoices" do + metric_response = graphql_request["data"]["billableMetric"] + expect(metric_response["hasDraftInvoices"]).to eq(true) + end + end + + context "when billable metric has plans" do + before do + plan = create(:plan, organization:) + plan_2 = create(:plan, organization:) + create(:subscription, plan:, organization:) + create(:subscription, plan: plan_2, organization:) + create(:standard_charge, plan:, billable_metric:, organization:) + create(:standard_charge, plan: plan_2, billable_metric:, organization:) + end + + it "returns true for has plans" do + metric_response = graphql_request["data"]["billableMetric"] + expect(metric_response["hasPlans"]).to eq(true) + end + end + + context "when billable metric is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {billableMetricId: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/billable_metrics_resolver_spec.rb b/spec/graphql/resolvers/billable_metrics_resolver_spec.rb new file mode 100644 index 0000000..a171e44 --- /dev/null +++ b/spec/graphql/resolvers/billable_metrics_resolver_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::BillableMetricsResolver do + let(:required_permission) { "billable_metrics:view" } + let(:query) do + <<~GQL + query { + billableMetrics(limit: 5) { + collection { + id + name + filters { key values } + } + metadata { currentPage totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billable_metrics:view" + + it "returns a list of billable metrics" do + metric = create(:billable_metric, organization:) + + filter1 = create(:billable_metric_filter, billable_metric: metric, key: "cloud", values: %w[aws gcp azure]) + filter2 = create(:billable_metric_filter, billable_metric: metric, key: "region", values: %w[usa europe asia]) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(result["data"]["billableMetrics"]["collection"].count).to eq(organization.billable_metrics.count) + expect(result["data"]["billableMetrics"]["collection"].first["id"]).to eq(metric.id) + + expect(result["data"]["billableMetrics"]["collection"].first["filters"].first["key"]).to eq(filter1.key) + expect(result["data"]["billableMetrics"]["collection"].first["filters"].first["values"]) + .to match_array(filter1.values) + + expect(result["data"]["billableMetrics"]["collection"].first["filters"].second["key"]).to eq(filter2.key) + expect(result["data"]["billableMetrics"]["collection"].first["filters"].second["values"]) + .to match_array(filter2.values) + + expect(result["data"]["billableMetrics"]["metadata"]["currentPage"]).to eq(1) + expect(result["data"]["billableMetrics"]["metadata"]["totalCount"]).to eq(1) + end +end diff --git a/spec/graphql/resolvers/billing_entities_resolver_spec.rb b/spec/graphql/resolvers/billing_entities_resolver_spec.rb new file mode 100644 index 0000000..e9a4d4a --- /dev/null +++ b/spec/graphql/resolvers/billing_entities_resolver_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::BillingEntitiesResolver do + let(:required_permission) { "billing_entities:view" } + let(:query) do + <<~GQL + query { + billingEntities { + collection { + id + name + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization:) } + + before do + billing_entity1 + billing_entity2 + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:view" + + it "returns a list of billing entities" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + result_data = result["data"]["billingEntities"] + expect(result_data["collection"].count).to eq(organization.billing_entities.count) + expect(result_data["collection"].map { |be| be["id"] }).to match_array([billing_entity1.id, billing_entity2.id]) + end +end diff --git a/spec/graphql/resolvers/billing_entity_resolver_spec.rb b/spec/graphql/resolvers/billing_entity_resolver_spec.rb new file mode 100644 index 0000000..d8e7efe --- /dev/null +++ b/spec/graphql/resolvers/billing_entity_resolver_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::BillingEntityResolver do + subject(:graphql_request) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {billingEntityCode: billing_entity.code} + ) + end + + let(:required_permission) { "billing_entities:view" } + let(:query) do + <<~GQL + query($billingEntityCode: String!) { + billingEntity(code: $billingEntityCode) { + id + name + legalName + legalNumber + taxIdentificationNumber + email + addressLine1 + addressLine2 + zipcode + state + country + city + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, organization:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "billing_entities:view" + + it "returns a single billing entity" do + entity_response = graphql_request["data"]["billingEntity"] + + expect(entity_response["id"]).to eq(billing_entity.id) + expect(entity_response["name"]).to eq(billing_entity.name) + expect(entity_response["legalName"]).to eq(billing_entity.legal_name) + expect(entity_response["legalNumber"]).to eq(billing_entity.legal_number) + expect(entity_response["taxIdentificationNumber"]).to eq(billing_entity.tax_identification_number) + expect(entity_response["email"]).to eq(billing_entity.email) + expect(entity_response["addressLine1"]).to eq(billing_entity.address_line1) + expect(entity_response["addressLine2"]).to eq(billing_entity.address_line2) + expect(entity_response["zipcode"]).to eq(billing_entity.zipcode) + expect(entity_response["state"]).to eq(billing_entity.state) + expect(entity_response["country"]).to eq(billing_entity.country) + expect(entity_response["city"]).to eq(billing_entity.city) + end + + context "when billing_entity is archived" do + before do + billing_entity.update(archived_at: Time.current) + end + + it "returns the billing_entity" do + entity_response = graphql_request["data"]["billingEntity"] + + expect(entity_response["id"]).to eq(billing_entity.id) + expect(entity_response["name"]).to eq(billing_entity.name) + expect(entity_response["legalName"]).to eq(billing_entity.legal_name) + expect(entity_response["legalNumber"]).to eq(billing_entity.legal_number) + end + end + + context "when billing entity is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {billingEntityCode: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when billing_entity is deleted" do + it "returns an error" do + billing_entity.discard + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {billingEntityCode: billing_entity.code} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/billing_entity_taxes_resolver_spec.rb b/spec/graphql/resolvers/billing_entity_taxes_resolver_spec.rb new file mode 100644 index 0000000..3e4d3a2 --- /dev/null +++ b/spec/graphql/resolvers/billing_entity_taxes_resolver_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::BillingEntityTaxesResolver do + let(:query) do + <<~GQL + query($billing_entity_id: ID!) { + billingEntityTaxes(billingEntityId: $billing_entity_id) { + collection { + id + code + name + rate + description + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { organization.default_billing_entity } + let(:tax1) { create(:tax, organization: organization) } + let(:tax2) { create(:tax, organization: organization) } + + before do + create(:billing_entity_applied_tax, billing_entity:, tax: tax1, organization:) + create(:billing_entity_applied_tax, billing_entity:, tax: tax2, organization:) + end + + it "returns billing entity taxes" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: ["billing_entities:view"], + query:, + variables: {billing_entity_id: billing_entity.id} + ) + + taxes_data = result["data"]["billingEntityTaxes"]["collection"] + expect(taxes_data.count).to eq(2) + expect(taxes_data.map { |t| t["id"] }).to include(tax1.id, tax2.id) + end + + it "returns error if billing entity is not found" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: ["billing_entities:view"], + query:, + variables: {billing_entity_id: "invalid"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end +end diff --git a/spec/graphql/resolvers/coupon_resolver_spec.rb b/spec/graphql/resolvers/coupon_resolver_spec.rb new file mode 100644 index 0000000..f14d3e9 --- /dev/null +++ b/spec/graphql/resolvers/coupon_resolver_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CouponResolver do + let(:required_permission) { "coupons:view" } + let(:query) do + <<~GQL + query($couponId: ID!) { + coupon(id: $couponId) { + id name description customersCount appliedCouponsCount expirationAt + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:coupon) { create(:coupon, organization:) } + let(:applied_coupon) { create(:applied_coupon, coupon:) } + + before do + customer + applied_coupon + + 2.times do + create(:subscription, customer:) + end + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:view" + + it "returns a single coupon" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {couponId: coupon.id} + ) + + coupon_response = result["data"]["coupon"] + + expect(coupon_response["id"]).to eq(coupon.id) + expect(coupon_response["customersCount"]).to eq(1) + expect(coupon_response["description"]).to eq(coupon.description) + expect(coupon_response["appliedCouponsCount"]).to eq(1) + end + + context "when plan is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {couponId: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/coupons_resolver_spec.rb b/spec/graphql/resolvers/coupons_resolver_spec.rb new file mode 100644 index 0000000..3dc0ca1 --- /dev/null +++ b/spec/graphql/resolvers/coupons_resolver_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CouponsResolver do + let(:required_permission) { "coupons:view" } + let(:query) do + <<~GQL + query { + coupons(limit: 5, status: active) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon) { create(:coupon, organization:) } + + before do + coupon + + create(:coupon, organization:, status: :terminated) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "coupons:view" + + it "returns a list of coupons" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + coupons_response = result["data"]["coupons"] + + expect(coupons_response["collection"].count).to eq(organization.coupons.active.count) + expect(coupons_response["collection"].first["id"]).to eq(coupon.id) + + expect(coupons_response["metadata"]["currentPage"]).to eq(1) + expect(coupons_response["metadata"]["totalCount"]).to eq(1) + end +end diff --git a/spec/graphql/resolvers/credit_note_resolver_spec.rb b/spec/graphql/resolvers/credit_note_resolver_spec.rb new file mode 100644 index 0000000..f0119ae --- /dev/null +++ b/spec/graphql/resolvers/credit_note_resolver_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CreditNoteResolver do + let(:required_permission) { "credit_notes:view" } + let(:query) do + <<-GQL + query($creditNoteId: ID!) { + creditNote(id: $creditNoteId) { + id + number + creditStatus + reason + currency + totalAmountCents + creditAmountCents + balanceAmountCents + totalAmountCents + taxesAmountCents + subTotalExcludingTaxesAmountCents + couponsAdjustmentAmountCents + createdAt + updatedAt + voidedAt + refundedAt + fileUrl + invoice { id number } + items { + id + amountCents + amountCurrency + createdAt + fee { id amountCents itemType itemCode itemName } + } + appliedTaxes { + taxCode + taxName + taxRate + taxDescription + amountCents + amountCurrency + } + } + } + GQL + end + + let(:membership) { create(:membership) } + + let(:customer) { create(:customer, organization: membership.organization) } + let(:invoice) { create(:invoice, organization: membership.organization, customer:) } + let(:credit_note) { create(:credit_note, customer:, invoice:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:view" + + it "returns a single credit note" do + result = execute_graphql( + current_user: membership.user, + current_organization: customer.organization, + permissions: required_permission, + query:, + variables: { + creditNoteId: credit_note.id + } + ) + + credit_note_response = result["data"]["creditNote"] + + expect(credit_note_response["id"]).to eq(credit_note.id) + end +end diff --git a/spec/graphql/resolvers/credit_notes/estimate_resolver_spec.rb b/spec/graphql/resolvers/credit_notes/estimate_resolver_spec.rb new file mode 100644 index 0000000..1ff1bd7 --- /dev/null +++ b/spec/graphql/resolvers/credit_notes/estimate_resolver_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CreditNotes::EstimateResolver, :premium do + let(:query) do + <<~GQL + query($invoiceId: ID!, $items: [CreditNoteItemInput!]!) { + creditNoteEstimate(invoiceId: $invoiceId, items: $items) { + currency + taxesAmountCents + subTotalExcludingTaxesAmountCents + maxCreditableAmountCents + maxRefundableAmountCents + couponsAdjustmentAmountCents + taxesRate + items { amountCents fee { id } } + appliedTaxes { id amountCents } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + + let(:fees) do + create_list( + :fee, + 2, + invoice:, + amount_cents: 100, + precise_coupons_amount_cents: 50 + ) + end + + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 100, + expiration: :no_expiration, + coupon_type: :fixed_amount, + frequency: :forever + ) + end + + let(:applied_coupon) { create(:applied_coupon, coupon:, customer:) } + + let(:credit) { create(:credit, invoice:, applied_coupon:, amount_cents: 100) } + + before { credit } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + + it "returns the estimate for the credit note creation" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: { + invoiceId: invoice.id, + items: fees.map { |f| {feeId: f.id, amountCents: 50} } + } + ) + + estimate_response = result["data"]["creditNoteEstimate"] + + expect(estimate_response["currency"]).to eq("EUR") + expect(estimate_response["taxesAmountCents"]).to eq("0") + expect(estimate_response["subTotalExcludingTaxesAmountCents"]).to eq("50") + expect(estimate_response["maxCreditableAmountCents"]).to eq("50") + expect(estimate_response["maxRefundableAmountCents"]).to eq("0") + expect(estimate_response["couponsAdjustmentAmountCents"]).to eq("50") + expect(estimate_response["items"].first["amountCents"]).to eq("50") + expect(estimate_response["appliedTaxes"]).to be_blank + end + + context "with invalid invoice" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: { + invoiceId: create(:invoice).id, + items: fees.map { |f| {feeId: f.id, amountCents: 50} } + } + ) + + expect_not_found(result) + end + end +end diff --git a/spec/graphql/resolvers/credit_notes_resolver_spec.rb b/spec/graphql/resolvers/credit_notes_resolver_spec.rb new file mode 100644 index 0000000..2d479bb --- /dev/null +++ b/spec/graphql/resolvers/credit_notes_resolver_spec.rb @@ -0,0 +1,418 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CreditNotesResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + end + + let(:required_permission) { "credit_notes:view" } + let(:query) do + <<~GQL + query { + creditNotes(#{[arguments, "limit: 5"].join(", ")}) { + collection { id number } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:arguments) { "" } + + let(:response_collection) { result["data"]["creditNotes"]["collection"] } + + before { create(:credit_note, :draft, customer:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:view" + + context "with no arguments" do + let!(:credit_note) { create(:credit_note, customer:) } + + it "returns finalized credit_notes" do + expect(response_collection.pluck("id")).to contain_exactly credit_note.id + + expect(result["data"]["creditNotes"]["metadata"]["currentPage"]).to eq(1) + expect(result["data"]["creditNotes"]["metadata"]["totalCount"]).to eq(1) + end + end + + context "with currency" do + let(:arguments) { "currency: #{credit_note.currency.upcase}" } + let!(:credit_note) { create(:credit_note, customer:, total_amount_currency: "USD") } + + before { create(:credit_note, customer:, total_amount_currency: "EUR") } + + it "returns finalized credit_notes matching currency" do + expect(response_collection.pluck("id")).to contain_exactly credit_note.id + end + end + + context "with customer_external_id" do + let(:arguments) { "customerExternalId: #{customer.external_id.inspect}" } + let!(:credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create(:customer, organization:) + create(:credit_note, customer: another_customer) + end + + it "returns finalized credit_notes with matching customer external id" do + expect(response_collection.pluck("id")).to contain_exactly credit_note.id + end + end + + context "with customer_id" do + let(:arguments) { "customerId: #{customer.id.inspect}" } + let!(:credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create(:customer, organization:) + create(:credit_note, customer: another_customer) + end + + it "returns finalized credit_notes with matching customer id" do + expect(response_collection.pluck("id")).to contain_exactly credit_note.id + end + end + + context "with reason" do + let(:arguments) { "reason: [#{matching_reasons.map(&:to_s).join(", ")}]" } + let(:matching_reasons) { CreditNote::REASON.sample(2) } + + let!(:credit_notes) do + matching_reasons.map { |reason| create(:credit_note, reason:, customer:) } + end + + before do + create( + :credit_note, + reason: CreditNote::REASON.excluding(matching_reasons).sample, + customer: + ) + end + + it "returns finalized credit_notes with matching reasons" do + expect(response_collection.pluck("id")).to match_array credit_notes.pluck(:id) + end + end + + context "with credit_status" do + let(:arguments) { "creditStatus: [#{matching_credit_statuses.map(&:to_s).join(", ")}]" } + let(:matching_credit_statuses) { CreditNote::CREDIT_STATUS.sample(2) } + + let!(:credit_notes) do + matching_credit_statuses.map do |credit_status| + create(:credit_note, credit_status:, customer:) + end + end + + before do + create( + :credit_note, + credit_status: CreditNote::CREDIT_STATUS.excluding(matching_credit_statuses).sample, + customer: + ) + end + + it "returns finalized credit_notes with matching credit statuses" do + expect(response_collection.pluck("id")).to match_array credit_notes.pluck(:id) + end + end + + context "with refund_status" do + let(:arguments) { "refundStatus: [#{matching_refund_statuses.map(&:to_s).join(", ")}]" } + let(:matching_refund_statuses) { CreditNote::REFUND_STATUS.sample(2) } + + let!(:credit_notes) do + matching_refund_statuses.map do |refund_status| + create(:credit_note, refund_status:, customer:) + end + end + + before do + create( + :credit_note, + refund_status: CreditNote::REFUND_STATUS.excluding(matching_refund_statuses).sample, + customer: + ) + end + + it "returns finalized credit_notes with matching refund statuses" do + expect(response_collection.pluck("id")).to match_array credit_notes.pluck(:id) + end + end + + context "with types" do + let(:arguments) { "types: [#{types.join(", ")}]" } + + let(:credit_note) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 0, offset_amount_cents: 0) + end + + let(:refund_note) do + create(:credit_note, customer:, credit_amount_cents: 0, refund_amount_cents: 10, offset_amount_cents: 0) + end + + let(:offset_note) do + create(:credit_note, customer:, credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 10) + end + + let(:credit_and_refund_note) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 10, offset_amount_cents: 0) + end + + before do + credit_note + refund_note + offset_note + credit_and_refund_note + end + + context "when type is credit" do + let(:types) { ["credit"] } + + it "returns credit notes with positive credit amount" do + expect(response_collection.pluck("id")).to match_array([credit_note.id, credit_and_refund_note.id]) + end + end + + context "when type is refund" do + let(:types) { ["refund"] } + + it "returns credit notes with positive refund amount" do + expect(response_collection.pluck("id")).to match_array([refund_note.id, credit_and_refund_note.id]) + end + end + + context "when type is offset" do + let(:types) { ["offset"] } + + it "returns credit notes with positive offset amount" do + expect(response_collection.pluck("id")).to match_array([offset_note.id]) + end + end + + context "when multiple types are provided" do + let(:types) { %w[credit refund] } + + it "returns credit notes matching any of the given types" do + expect(response_collection.pluck("id")).to match_array([credit_note.id, refund_note.id, credit_and_refund_note.id]) + end + end + end + + context "with invoice_number" do + let(:arguments) { "invoiceNumber: #{credit_note.invoice.number.inspect}" } + let!(:credit_note) { create(:credit_note, customer:) } + + before do + invoice = create(:invoice, customer:, number: "FOO-01") + create(:credit_note, customer:, invoice:) + end + + it "returns finalized credit_notes matching invoice number" do + expect(response_collection.pluck("id")).to contain_exactly credit_note.id + end + end + + context "with both issuing_date_from and issuing_date_to" do + let(:arguments) do + [ + "issuingDateFrom: #{credit_notes.second.issuing_date.to_s.inspect}", + "issuingDateTo: #{credit_notes.fourth.issuing_date.to_s.inspect}" + ].join(", ") + end + + let!(:credit_notes) do + (1..5).to_a.map do |i| + create(:credit_note, issuing_date: i.days.ago, customer:) + end.reverse # from oldest to newest + end + + it "returns finalized credit notes that were issued between provided dates" do + expect(response_collection.pluck("id")).to match_array credit_notes[1..3].pluck(:id) + end + end + + context "with both amount_from and amount_to" do + let(:arguments) do + [ + "amountFrom: #{credit_notes.second.total_amount_cents.inspect}", + "amountTo: #{credit_notes.fourth.total_amount_cents.inspect}" + ].join(", ") + end + + let!(:credit_notes) do + (1..5).to_a.map do |i| + create(:credit_note, total_amount_cents: i.succ * 1_000, customer:) + end # from smallest to biggest + end + + it "returns finalized credit notes total cents amount in provided range" do + expect(response_collection.pluck("id")).to match_array credit_notes[1..3].pluck(:id) + end + end + + context "with search_term" do + let(:arguments) { "searchTerm: #{credit_note.number.inspect}" } + let!(:credit_note) { create(:credit_note, customer:) } + + before do + invoice = create(:invoice, customer:, number: "FOO-01") + create(:credit_note, customer:, invoice:) + end + + it "returns finalized credit_notes matching the terms" do + expect(response_collection.pluck("id")).to contain_exactly credit_note.id + end + end + + context "when filtering by self billed invoice" do + let(:self_billed_credit_note) do + invoice = create(:invoice, :self_billed, customer:, organization:) + + create(:credit_note, invoice:, customer:) + end + + let(:non_self_billed_credit_note) do + create(:credit_note, customer:) + end + + let(:query) do + <<~GQL + query { + creditNotes(limit: 5, selfBilled: true) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + self_billed_credit_note + non_self_billed_credit_note + end + + it "returns all credit notes from self billed invoices" do + expect(response_collection.count).to eq(1) + expect(response_collection.first["id"]).to eq(self_billed_credit_note.id) + + expect(result["data"]["creditNotes"]["metadata"]["currentPage"]).to eq(1) + expect(result["data"]["creditNotes"]["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by billing_entity_id" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:matching_credit_note) { create(:credit_note, customer:, invoice: create(:invoice, billing_entity:)) } + let(:non_matching_credit_note) { create(:credit_note, customer:) } + + let(:query) do + <<~GQL + query { + creditNotes(limit: 5, billingEntityIds: ["#{billing_entity.id}"]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + matching_credit_note + non_matching_credit_note + end + + it "returns credit notes with matching billing entity id" do + expect(response_collection.count).to eq(1) + expect(response_collection.first["id"]).to eq(matching_credit_note.id) + + expect(result["data"]["creditNotes"]["metadata"]["currentPage"]).to eq(1) + expect(result["data"]["creditNotes"]["metadata"]["totalCount"]).to eq(1) + end + end + + context "with N+1 queries detection on metadata", bullet: {unused_eager_loading: false} do + before do + credit_notes = create_list(:credit_note, 3, customer:) + credit_notes.each do |credit_note| + create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar"}) + end + end + + it "does not trigger N+1 queries for metadata" do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: <<~GQL + query { + creditNotes(limit: 5) { + collection { + id + metadata { key value } + } + } + } + GQL + ) + end + end + + context "with N+1 queries detection on items and fees", bullet: {unused_eager_loading: false} do + before do + subscription = create(:subscription, customer:, organization:) + + 3.times do + inv = create(:invoice, customer:, organization:) + sub_fee = create(:fee, invoice: inv, subscription:, organization:) + charge_fee = create(:charge_fee, invoice: inv, subscription:, organization:) + + cn = create(:credit_note, customer:, invoice: inv, organization:) + create(:credit_note_item, credit_note: cn, fee: sub_fee) + create(:credit_note_item, credit_note: cn, fee: charge_fee) + create(:credit_note_applied_tax, credit_note: cn, organization:) + end + end + + it "does not trigger N+1 queries for items and fees" do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: <<~GQL + query { + creditNotes(limit: 5) { + collection { + id + appliedTaxes { id taxRate } + items { + id + fee { + id + feeType + subscription { id } + charge { id } + } + } + } + } + } + GQL + ) + end + end +end diff --git a/spec/graphql/resolvers/current_user_resolver_spec.rb b/spec/graphql/resolvers/current_user_resolver_spec.rb new file mode 100644 index 0000000..f23268a --- /dev/null +++ b/spec/graphql/resolvers/current_user_resolver_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CurrentUserResolver do + let(:admin_role) { create(:role, :admin) } + + let(:query) do + <<~GRAPHQL + query { + currentUser { + id + email + premium + memberships { + roles + status + organization { + name + } + } + } + } + GRAPHQL + end + + it "returns current_user" do + user = create(:user) + membership = create(:membership, user:) + create(:membership_role, membership:, role: admin_role) + + result = execute_graphql( + current_user: user, + query: + ) + + expect(result["data"]["currentUser"]["email"]).to eq(user.email) + expect(result["data"]["currentUser"]["id"]).to eq(user.id) + expect(result["data"]["currentUser"]["premium"]).to be_falsey + expect(result["data"]["currentUser"]["memberships"][0]["roles"]).to eq ["Admin"] + expect(result["data"]["currentUser"]["memberships"][0]["organization"]["name"]).not_to be_empty + end + + it "returns null for deprecated role field when using custom role" do + membership = create(:membership) + custom_role = create(:role, name: "Developer", organization: membership.organization, permissions: %w[organization:view]) + create(:membership_role, membership:, role: custom_role) + + result = execute_graphql(current_user: membership.user, query:) + + expect(result["data"]["currentUser"]["memberships"][0]["role"]).to be_nil + expect(result["data"]["currentUser"]["memberships"][0]["roles"]).to eq(["Developer"]) + end + + describe "with organizations instead of memberships" do + let(:query) do + <<~GRAPHQL + query { + currentUser { + id + email + premium + organizations { + id + } + } + } + GRAPHQL + end + + it "returns organizations" do + organization = create(:organization) + membership = create(:membership, organization:) + result = execute_graphql( + current_user: membership.user, + query: + ) + + expect(result["data"]["currentUser"]["organizations"][0]["id"]).to eq organization.id + end + end + + describe "with revoked membership" do + let(:membership) { create(:membership) } + let(:revoked_membership) do + create(:membership, user: membership.user, status: :revoked) + end + + before do + create(:membership_role, membership:, role: admin_role) + revoked_membership + end + + it "only lists organizations when membership has an active status" do + result = execute_graphql( + current_user: membership.user, + query: + ) + + expect(result["data"]["currentUser"]["memberships"]).not_to include(revoked_membership) + end + end + + context "with no current_user" do + it "returns an error" do + result = execute_graphql(query:) + + expect_unauthorized_error(result) + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/analytics/invoice_collections_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/analytics/invoice_collections_resolver_spec.rb new file mode 100644 index 0000000..6dc96fa --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/analytics/invoice_collections_resolver_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::Analytics::InvoiceCollectionsResolver do + let(:query) do + <<~GQL + query { + customerPortalInvoiceCollections { + collection { + month + amountCents + invoicesCount + currency + } + } + } + GQL + end + + let(:organization) { create(:organization, created_at: DateTime.new(2024, 1, 15)) } + let(:membership) { create(:membership, organization:) } + let(:customer) { create(:customer, organization:, currency: "USD") } + + it_behaves_like "requires a customer portal user" + + it "returns a list of invoice collections" do + travel_to(DateTime.new(2024, 2, 10)) do + create(:invoice, organization:, customer:, total_amount_cents: 1000, currency: "USD") + create(:invoice, organization:, customer:, total_amount_cents: 2000, currency: "USD") + + result = execute_graphql(customer_portal_user: customer, query:) + invoice_collections_response = result["data"]["customerPortalInvoiceCollections"] + + expect(invoice_collections_response["collection"]).to contain_exactly( + "month" => include("2024-02-01"), + "currency" => "USD", + "amountCents" => "3000", + "invoicesCount" => "2" + ) + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/analytics/overdue_balances_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/analytics/overdue_balances_resolver_spec.rb new file mode 100644 index 0000000..952c120 --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/analytics/overdue_balances_resolver_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::Analytics::OverdueBalancesResolver do + let(:query) do + <<~GQL + query { + customerPortalOverdueBalances { + collection { + month + amountCents + lagoInvoiceIds + currency + } + } + } + GQL + end + + let(:organization) { create(:organization, created_at: DateTime.new(2024, 1, 15)) } + let(:membership) { create(:membership, organization:) } + let(:customer) { create(:customer, organization:, currency: "USD") } + + it_behaves_like "requires a customer portal user" + + it "returns a list of overdue balances" do + travel_to(DateTime.new(2024, 2, 10)) do + create(:invoice, organization:, customer:, total_amount_cents: 1000, currency: "USD") + i1 = create(:invoice, organization:, customer:, total_amount_cents: 2000, currency: "USD", payment_overdue: true) + i2 = create(:invoice, organization:, customer:, total_amount_cents: 2000, currency: "USD", payment_overdue: true) + + result = execute_graphql(customer_portal_user: customer, query:) + overdue_balances_response = result["data"]["customerPortalOverdueBalances"] + + expect(overdue_balances_response["collection"]).to contain_exactly( + "month" => include("2024-02-01"), + "currency" => "USD", + "amountCents" => "4000", + "lagoInvoiceIds" => contain_exactly(i1.id, i2.id) + ) + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/customer_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/customer_resolver_spec.rb new file mode 100644 index 0000000..39bf04d --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/customer_resolver_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::CustomerResolver do + let(:query) do + <<~GQL + query { + customerPortalUser { + id + name + currency + billingEntityBillingConfiguration { + documentLocale + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) do + create(:customer, organization:, currency: "EUR") + end + + it_behaves_like "requires a customer portal user" + + it "returns a single customer" do + result = execute_graphql( + customer_portal_user: customer, + query: + ) + + customer_response = result["data"]["customerPortalUser"] + + expect(customer_response["id"]).to eq(customer.id) + expect(customer_response["name"]).to eq(customer.name) + expect(customer_response["currency"]).to eq("EUR") + expect(customer_response["billingEntityBillingConfiguration"]["documentLocale"]).to eq(customer.billing_entity.document_locale) + end + + context "without customer portal user" do + it "returns an error" do + result = execute_graphql( + query: + ) + + expect_unauthorized_error(result) + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/customers/projected_usage_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/customers/projected_usage_resolver_spec.rb new file mode 100644 index 0000000..4d9d5b0 --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/customers/projected_usage_resolver_spec.rb @@ -0,0 +1,411 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::Customers::ProjectedUsageResolver do + let(:query) do + <<~GQL + query($subscriptionId: ID!) { + customerPortalCustomerProjectedUsage(subscriptionId: $subscriptionId) { + fromDatetime + toDatetime + currency + issuingDate + amountCents + projectedAmountCents + totalAmountCents + taxesAmountCents + chargesUsage { + billableMetric { name code aggregationType } + charge { chargeModel } + filters { id units amountCents pricingUnitAmountCents invoiceDisplayName values eventsCount } + units + projectedUnits + amountCents + projectedAmountCents + pricingUnitAmountCents + pricingUnitProjectedAmountCents + groupedUsage { + amountCents + projectedAmountCents + units + projectedUnits + eventsCount + groupedBy + filters { id units amountCents pricingUnitAmountCents invoiceDisplayName values eventsCount } + presentationBreakdowns { presentationBy units } + } + presentationBreakdowns { presentationBy units } + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:, rate: 20) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) do + create( + :subscription, + plan:, + customer:, + started_at: Time.zone.now - 2.years + ) + end + let(:plan) { create(:plan, interval: "monthly") } + + let(:metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:sum_metric) { create(:sum_billable_metric, organization:) } + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric: metric, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + grouped_by: ["agent_name"] + } + ) + end + + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric: metric, key: "cloud", values: %w[aws gcp]) + end + + let(:charge_filter) { create(:charge_filter, charge: standard_charge, invoice_display_name: nil) } + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["aws"]) + end + + before do + subscription + charge + tax + charge_filter_value + + create( + :applied_pricing_unit, + organization: organization, + conversion_rate: 0.25, + pricing_unitable: standard_charge + ) + + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: Time.zone.now + ) + + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: sum_metric.code, + timestamp: Time.zone.now, + properties: { + agent_name: "frodo", + cloud: "aws", + item_id: 1 + } + ) + end + end + + it_behaves_like "requires a customer portal user" + + it "returns the projected usage for the customer" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + # debugger + usage_response = result["data"]["customerPortalCustomerProjectedUsage"] + + expect(usage_response["fromDatetime"]).to eq(Time.current.beginning_of_month.iso8601) + expect(usage_response["toDatetime"]).to eq(Time.current.end_of_month.iso8601) + expect(usage_response["currency"]).to eq("EUR") + expect(usage_response["issuingDate"]).to eq(Time.zone.today.end_of_month.iso8601) + expect(usage_response["amountCents"]).to eq("105") + expect(usage_response["projectedAmountCents"]).to eq("836") + expect(usage_response["totalAmountCents"]).to eq("105") + expect(usage_response["taxesAmountCents"]).to eq("0") + + charge_usage = usage_response["chargesUsage"].find { it["billableMetric"]["code"] == metric.code } + expect(charge_usage["billableMetric"]["name"]).to eq(metric.name) + expect(charge_usage["billableMetric"]["aggregationType"]).to eq("count_agg") + expect(charge_usage["charge"]["chargeModel"]).to eq("graduated") + expect(charge_usage["pricingUnitAmountCents"]).to eq(nil) + expect(charge_usage["units"]).to eq(4.0) + expect(charge_usage["projectedUnits"]).to eq(8.27) + expect(charge_usage["amountCents"]).to eq("5") + expect(charge_usage["projectedAmountCents"]).to eq("9") + + charge_usage = usage_response["chargesUsage"].find { it["billableMetric"]["code"] == sum_metric.code } + expect(charge_usage["billableMetric"]["name"]).to eq(sum_metric.name) + expect(charge_usage["billableMetric"]["aggregationType"]).to eq("sum_agg") + expect(charge_usage["charge"]["chargeModel"]).to eq("standard") + expect(charge_usage["pricingUnitAmountCents"]).to eq("400") + expect(charge_usage["pricingUnitProjectedAmountCents"]).to eq("207") + expect(charge_usage["units"]).to eq(4.0) + expect(charge_usage["projectedUnits"]).to eq(8.27) + expect(charge_usage["amountCents"]).to eq("100") + expect(charge_usage["projectedAmountCents"]).to eq("827") + + grouped_usage = charge_usage["groupedUsage"].first + expect(grouped_usage["amountCents"]).to eq("100") + expect(grouped_usage["projectedAmountCents"]).to eq("827") + expect(grouped_usage["units"]).to eq(4.0) + expect(grouped_usage["projectedUnits"]).to eq(8.27) + expect(grouped_usage["eventsCount"]).to eq(4) + expect(grouped_usage["groupedBy"]).to eq({"agent_name" => "frodo"}) + end + end + + context "with filters" do + let(:filter_metric) { create(:billable_metric, aggregation_type: "count_agg", organization:) } + let(:cloud_bm_filter) do + create(:billable_metric_filter, billable_metric: filter_metric, key: "cloud", values: %w[aws google]) + end + + let(:aws_filter) do + create(:charge_filter, charge:, properties: {amount: "10"}) + end + let(:aws_filter_value) do + create(:charge_filter_value, charge_filter: aws_filter, billable_metric_filter: cloud_bm_filter, values: ["aws"]) + end + + let(:google_filter) do + create(:charge_filter, charge:, properties: {amount: "20"}) + end + let(:google_filter_value) do + create( + :charge_filter_value, + charge_filter: google_filter, + billable_metric_filter: cloud_bm_filter, + values: ["google"] + ) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: filter_metric, + properties: {amount: "0"} + ) + end + + before do + subscription + charge + tax + aws_filter_value + google_filter_value + + create( + :applied_pricing_unit, + organization: organization, + conversion_rate: 0.2, + pricing_unitable: charge + ) + + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "aws"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "google"} + ) + end + end + + it "returns the projected filter usage for the customer" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + charge_usage = result["data"]["customerPortalCustomerProjectedUsage"]["chargesUsage"].find do |usage| + usage["billableMetric"]["code"] == filter_metric.code + end + + filters_usage = charge_usage["filters"] + + expect(charge_usage["units"]).to eq(4) + expect(charge_usage["amountCents"]).to eq("1000") + expect(charge_usage["projectedUnits"]).to eq(8.27) + expect(charge_usage["projectedAmountCents"]).to eq("10340") + + aws_filter_data = filters_usage.find { |f| f["id"] == aws_filter.id } + expect(aws_filter_data["units"]).to eq(3) + expect(aws_filter_data["amountCents"]).to eq("600") + expect(aws_filter_data["pricingUnitAmountCents"]).to eq("3000") + + google_filter_data = filters_usage.find { |f| f["id"] == google_filter.id } + expect(google_filter_data["units"]).to eq(1) + expect(google_filter_data["amountCents"]).to eq("400") + expect(google_filter_data["pricingUnitAmountCents"]).to eq("2000") + end + end + end + + context "with presentation group keys" do + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + grouped_by: ["agent_name"], + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + it "returns the presentation breakdowns" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerPortalCustomerProjectedUsage"]["chargesUsage"] + + graduated_charge_usage = charges_usage.find { |usage| usage["charge"]["chargeModel"] == "graduated" } + expect(graduated_charge_usage["presentationBreakdowns"]).to be_empty + + standard_charge_usage = charges_usage.find { |usage| usage["charge"]["chargeModel"] == "standard" } + expect(standard_charge_usage["presentationBreakdowns"]).to be_empty + + grouped_usage = standard_charge_usage["groupedUsage"] + expect(grouped_usage.first["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + expect(grouped_usage.second["presentationBreakdowns"]).to be_empty + end + end + + context "with two charges without pricing_group_keys" do + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + presentation_group_keys: [{value: "cloud"}] + } + ) + end + let(:presentation_metric) { create(:sum_billable_metric, organization:) } + let(:presentation_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: presentation_metric, + properties: { + amount: "1", + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + before do + presentation_charge + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: presentation_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "gcp", item_id: 1} + ) + end + end + + it "returns presentation breakdowns for both charges with no grouped_usage" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerPortalCustomerProjectedUsage"]["chargesUsage"] + presentation_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == presentation_metric.code } + sum_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == sum_metric.code } + + expect(presentation_charge_usage["groupedUsage"]).to be_empty + expect(presentation_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "gcp"}, "units" => "3.0"} + ]) + + expect(sum_charge_usage["groupedUsage"]).to be_empty + expect(sum_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + end + end + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/customers/usage_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/customers/usage_resolver_spec.rb new file mode 100644 index 0000000..ccc6423 --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/customers/usage_resolver_spec.rb @@ -0,0 +1,411 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::Customers::UsageResolver do + let(:now) { Time.zone.parse("2025-06-15").in_time_zone } + let(:timestamp) { now } + let(:query) do + <<~GQL + query($subscriptionId: ID!) { + customerPortalCustomerUsage(subscriptionId: $subscriptionId) { + fromDatetime + toDatetime + currency + issuingDate + amountCents + totalAmountCents + taxesAmountCents + chargesUsage { + billableMetric { name code aggregationType } + charge { chargeModel } + filters { id units amountCents invoiceDisplayName values eventsCount } + units + amountCents + groupedUsage { + amountCents + units + eventsCount + groupedBy + filters { id units amountCents invoiceDisplayName values eventsCount } + presentationBreakdowns { presentationBy units } + } + presentationBreakdowns { presentationBy units } + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:, rate: 20) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) do + create( + :subscription, + plan:, + customer:, + started_at: now - 2.years + ) + end + let(:plan) { create(:plan, interval: "monthly") } + + let(:metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:sum_metric) { create(:sum_billable_metric, organization:) } + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric: metric, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + grouped_by: ["agent_name"] + } + ) + end + + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric: metric, key: "cloud", values: %w[aws gcp]) + end + + let(:charge_filter) { create(:charge_filter, charge: standard_charge, invoice_display_name: nil) } + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["aws"]) + end + + before do + subscription + charge + tax + charge_filter_value + + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: now - 1.hour + ) + + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: sum_metric.code, + timestamp: now - 1.hour, + properties: { + agent_name: "frodo", + cloud: "aws", + item_id: 1 + } + ) + end + + it_behaves_like "requires a customer portal user" + + it "returns the usage for the customer" do + travel_to(now) do + Subscriptions::ChargeCacheService.expire_for_subscription(subscription) + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + usage_response = result["data"]["customerPortalCustomerUsage"] + + expect(usage_response["fromDatetime"]).to eq(now.beginning_of_month.iso8601) + expect(usage_response["toDatetime"]).to eq(now.end_of_month.iso8601) + expect(usage_response["currency"]).to eq("EUR") + expect(usage_response["issuingDate"]).to eq(now.to_date.end_of_month.iso8601) + expect(usage_response["amountCents"]).to eq("405") + expect(usage_response["totalAmountCents"]).to eq("405") + expect(usage_response["taxesAmountCents"]).to eq("0") + charge_usage = usage_response["chargesUsage"].find { |usage| usage["billableMetric"]["code"] == metric.code } + expect(charge_usage["billableMetric"]["name"]).to eq(metric.name) + expect(charge_usage["billableMetric"]["aggregationType"]).to eq("count_agg") + expect(charge_usage["charge"]["chargeModel"]).to eq("graduated") + expect(charge_usage["units"]).to eq(4.0) + expect(charge_usage["amountCents"]).to eq("5") + charge_usage = usage_response["chargesUsage"].find { |usage| usage["billableMetric"]["code"] == sum_metric.code } + expect(charge_usage["billableMetric"]["name"]).to eq(sum_metric.name) + expect(charge_usage["billableMetric"]["aggregationType"]).to eq("sum_agg") + expect(charge_usage["charge"]["chargeModel"]).to eq("standard") + expect(charge_usage["units"]).to eq(4.0) + expect(charge_usage["amountCents"]).to eq("400") + grouped_usage = charge_usage["groupedUsage"].first + expect(grouped_usage["amountCents"]).to eq("400") + expect(grouped_usage["units"]).to eq(4.0) + expect(grouped_usage["eventsCount"]).to eq(4) + expect(grouped_usage["groupedBy"]).to eq({"agent_name" => "frodo"}) + end + end + + context "with filters" do + let(:cloud_bm_filter) do + create(:billable_metric_filter, billable_metric: metric, key: "cloud", values: %w[aws google]) + end + + let(:aws_filter) do + create(:charge_filter, charge:, properties: {amount: "10"}) + end + let(:aws_filter_value) do + create(:charge_filter_value, charge_filter: aws_filter, billable_metric_filter: cloud_bm_filter, values: ["aws"]) + end + + let(:google_filter) do + create(:charge_filter, charge:, properties: {amount: "20"}) + end + let(:google_filter_value) do + create( + :charge_filter_value, + charge_filter: google_filter, + billable_metric_filter: cloud_bm_filter, + values: ["google"] + ) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: metric, + properties: {amount: "0"} + ) + end + + before do + aws_filter_value + google_filter_value + + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: now - 1.hour, + properties: {cloud: "aws"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: now - 1.hour, + properties: {cloud: "google"} + ) + end + + it "returns the filter usage for the customer" do + travel_to(now) do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + charge_usage = result["data"]["customerPortalCustomerUsage"]["chargesUsage"].find do |usage| + usage["billableMetric"]["code"] == metric.code + end + filters_usage = charge_usage["filters"] + + expect(charge_usage["units"]).to eq(8) + expect(charge_usage["amountCents"]).to eq("5000") + expect(filters_usage).to contain_exactly( + { + "id" => nil, + "units" => 4, + "amountCents" => "0", + "invoiceDisplayName" => nil, + "values" => {}, + "eventsCount" => 4 + }, + { + "id" => aws_filter.id, + "units" => 3, + "amountCents" => "3000", + "invoiceDisplayName" => nil, + "values" => { + "cloud" => ["aws"] + }, + "eventsCount" => 3 + }, + { + "id" => google_filter.id, + "units" => 1, + "amountCents" => "2000", + "invoiceDisplayName" => nil, + "values" => { + "cloud" => ["google"] + }, + "eventsCount" => 1 + } + ) + end + end + end + + context "with presentation group keys" do + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + it "returns the presentation breakdowns" do + travel_to(now) do + Subscriptions::ChargeCacheService.expire_for_subscription(subscription) + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerPortalCustomerUsage"]["chargesUsage"] + presentation_breakdown_sum_metric = charges_usage.find { |usage| usage["billableMetric"]["code"] == sum_metric.code }["presentationBreakdowns"] + expect(presentation_breakdown_sum_metric).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + presentation_breakdown_metric = charges_usage.find { |usage| usage["billableMetric"]["code"] == metric.code }["presentationBreakdowns"] + expect(presentation_breakdown_metric).to be_empty + end + end + + context "with pricing group keys" do + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + pricing_group_keys: ["item_id"], + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + it "returns the presentation breakdowns" do + travel_to(now) do + Subscriptions::ChargeCacheService.expire_for_subscription(subscription) + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerPortalCustomerUsage"]["chargesUsage"] + sum_charge = charges_usage.find { |usage| usage["billableMetric"]["code"] == sum_metric.code } + expect(sum_charge["presentationBreakdowns"]).to be_empty + + grouped_usage = sum_charge["groupedUsage"] + expect(grouped_usage.first["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + expect(grouped_usage.second["presentationBreakdowns"]).to be_empty + + metric_charge = charges_usage.find { |usage| usage["billableMetric"]["code"] == metric.code } + expect(metric_charge["presentationBreakdowns"]).to be_empty + end + end + end + + context "with two charges without pricing_group_keys" do + let(:presentation_metric) { create(:sum_billable_metric, organization:) } + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: presentation_metric, + properties: { + amount: "1", + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + before do + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: presentation_metric.code, + timestamp: now - 1.hour, + properties: {cloud: "gcp", item_id: 1} + ) + end + + it "returns presentation breakdowns for both charges with no grouped_usage" do + travel_to(now) do + Subscriptions::ChargeCacheService.expire_for_subscription(subscription) + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: { + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerPortalCustomerUsage"]["chargesUsage"] + presentation_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == presentation_metric.code } + sum_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == sum_metric.code } + + expect(presentation_charge_usage["groupedUsage"]).to be_empty + expect(presentation_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "gcp"}, "units" => "3.0"} + ]) + + expect(sum_charge_usage["groupedUsage"]).to be_empty + expect(sum_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + end + end + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/invoices_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/invoices_resolver_spec.rb new file mode 100644 index 0000000..1062d65 --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/invoices_resolver_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::InvoicesResolver do + let(:query) do + <<~GQL + query { + customerPortalInvoices(limit: 5) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:draft_invoice) { create(:invoice, :draft, customer:, organization:) } + let(:finalized_invoice) { create(:invoice, customer:, organization:) } + + before do + draft_invoice + finalized_invoice + end + + it_behaves_like "requires a customer portal user" + + it "returns a list of invoices" do + result = execute_graphql( + customer_portal_user: customer, + query: + ) + + invoices_response = result["data"]["customerPortalInvoices"] + + expect(invoices_response["collection"].count).to eq(2) + expect(invoices_response["collection"].pluck("id")).to contain_exactly(draft_invoice.id, finalized_invoice.id) + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(2) + end + + context "with filter on status" do + let(:query) do + <<~GQL + query($status: [InvoiceStatusTypeEnum!]) { + customerPortalInvoices(status: $status) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "only returns draft invoice" do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: {status: ["draft"]} + ) + + invoices_response = result["data"]["customerPortalInvoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(draft_invoice.id) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when preloading offset amounts" do + subject do + execute_graphql( + customer_portal_user: customer, + query: + ) + end + + let(:query) do + <<~GQL + query { + customerPortalInvoices(limit: 5) { + collection { id totalDueAmountCents totalSettledAmountCents } + metadata { currentPage, totalCount } + } + } + GQL + end + let(:preloadable_invoices) { [draft_invoice, finalized_invoice] } + + include_examples "preloads offset amounts" + end + + context "when query fails" do + it "returns an error" do + allow(InvoicesQuery).to receive(:call).and_return( + BaseService::Result.new.tap { |r| r.validation_failure!(errors: {base: ["test_error"]}) } + ) + + result = execute_graphql( + customer_portal_user: customer, + query: + ) + + expect_graphql_error(result:, message: "Unprocessable Entity") + end + end + + context "without customer portal user" do + it "returns an error" do + result = execute_graphql( + query: + ) + + expect_unauthorized_error(result) + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/organization_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/organization_resolver_spec.rb new file mode 100644 index 0000000..8b6b850 --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/organization_resolver_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::OrganizationResolver do + let(:query) do + <<~GQL + query { + customerPortalOrganization { + id + name + billingConfiguration { + id + documentLocale + } + premiumIntegrations + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + it_behaves_like "requires a customer portal user" + + it "returns the customer portal organization" do + result = execute_graphql( + customer_portal_user: customer, + query: + ) + + data = result["data"]["customerPortalOrganization"] + + expect(data["id"]).to eq(organization.id) + expect(data["name"]).to eq(organization.name) + expect(data["billingConfiguration"]["id"]).to eq("#{organization.id}-c0nf") + expect(data["billingConfiguration"]["documentLocale"]).to eq("en") + expect(data["premiumIntegrations"]).to eq([]) + end +end diff --git a/spec/graphql/resolvers/customer_portal/subscription_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/subscription_resolver_spec.rb new file mode 100644 index 0000000..a6fea2b --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/subscription_resolver_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::SubscriptionResolver do + let(:query) do + <<~GQL + query($subscriptionId: ID!) { + customerPortalSubscription(id: $subscriptionId) { + id + name + startedAt + endingAt + plan { + id + code + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + + before do + customer + end + + it_behaves_like "requires a customer portal user" + + it "returns a single subscription" do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: {subscriptionId: subscription.id} + ) + + subscription_response = result["data"]["customerPortalSubscription"] + expect(subscription_response).to include( + "id" => subscription.id, + "name" => subscription.name, + "startedAt" => subscription.started_at.iso8601, + "endingAt" => subscription.ending_at + ) + + expect(subscription_response["plan"]).to include( + "id" => subscription.plan.id, + "code" => subscription.plan.code + ) + end + + context "when subscription is not found" do + it "returns an error" do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: {subscriptionId: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/subscriptions_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/subscriptions_resolver_spec.rb new file mode 100644 index 0000000..23e3548 --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/subscriptions_resolver_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::SubscriptionsResolver do + let(:query) do + <<~GQL + query { + customerPortalSubscriptions(limit: 5, planCode: "#{plan.code}", status: [active]) { + collection { id externalId currentBillingPeriodStartedAt currentBillingPeriodEndingAt plan { code } } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:active_subscription) { create(:subscription, customer:, plan:) } + let(:terminated_subscription) { create(:subscription, :terminated, customer:, plan:) } + + before do + active_subscription + terminated_subscription + end + + it_behaves_like "requires a customer portal user" + + it "returns a list of subscriptions" do + result = execute_graphql(customer_portal_user: customer, query:) + + subscriptions_response = result["data"]["customerPortalSubscriptions"] + + expect(subscriptions_response["collection"].pluck("id")).to contain_exactly(active_subscription.id) + expect(subscriptions_response["metadata"]["currentPage"]).to eq(1) + expect(subscriptions_response["metadata"]["totalCount"]).to eq(1) + end + + context "with filter on status" do + let(:query) do + <<~GQL + query($status: [StatusTypeEnum!]) { + customerPortalSubscriptions(status: $status) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "only returns draft invoice" do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: {status: ["terminated"]} + ) + + subscriptions_response = result["data"]["customerPortalSubscriptions"] + + expect(subscriptions_response["collection"].first["id"]).to eq(terminated_subscription.id) + expect(subscriptions_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "with currency filter" do + let(:brl_plan) { create(:plan, organization:, amount_currency: "BRL") } + let!(:brl_subscription) { create(:subscription, customer:, plan: brl_plan) } + + let(:query) do + <<~GQL + query { + customerPortalSubscriptions(currency: "#{brl_plan.amount_currency}", status: [active]) { + collection { id } + metadata { totalCount } + } + } + GQL + end + + it "returns only subscriptions with matching currency" do + result = execute_graphql(customer_portal_user: customer, query:) + response = result["data"]["customerPortalSubscriptions"] + + expect(response["collection"].count).to eq(1) + expect(response["collection"].first["id"]).to eq(brl_subscription.id) + expect(response["metadata"]["totalCount"]).to eq(1) + end + end + + context "without customer portal user" do + it "returns an error" do + result = execute_graphql(query:) + expect_unauthorized_error(result) + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/wallet_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/wallet_resolver_spec.rb new file mode 100644 index 0000000..392e934 --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/wallet_resolver_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::WalletResolver do + let(:query) do + <<~GQL + query($walletId: ID!) { + customerPortalWallet(id: $walletId) { + id + name + priority + currency + status + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, organization:, customer:) } + + before do + customer + end + + it_behaves_like "requires a customer portal user" + + it "returns a single wallet" do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: {walletId: wallet.id} + ) + + wallet_response = result["data"]["customerPortalWallet"] + expect(wallet_response).to include( + "id" => wallet.id, + "name" => wallet.name, + "priority" => wallet.priority, + "currency" => wallet.currency, + "status" => wallet.status + ) + end + + context "when wallet is not found" do + it "returns an error" do + result = execute_graphql( + customer_portal_user: customer, + query:, + variables: {walletId: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/customer_portal/wallets_resolver_spec.rb b/spec/graphql/resolvers/customer_portal/wallets_resolver_spec.rb new file mode 100644 index 0000000..e1356fe --- /dev/null +++ b/spec/graphql/resolvers/customer_portal/wallets_resolver_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerPortal::WalletsResolver do + let(:query) do + <<~GQL + query { + customerPortalWallets { + collection { + id + name + priority + currency + paidTopUpMinAmountCents + paidTopUpMinCredits + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, organization:, customer:, paid_top_up_min_amount_cents: 10_00) } + + before do + wallet + + create(:wallet, status: :terminated, customer:, organization:) + end + + it_behaves_like "requires a customer portal user" + + it "returns a list of active wallets" do + result = execute_graphql( + customer_portal_user: customer, + query: + ) + + wallets_response = result["data"]["customerPortalWallets"] + expect(wallets_response["collection"].count).to eq(customer.wallets.active.count) + + wallet_item = wallets_response["collection"].sole + expect(wallet_item["id"]).to eq(wallet.id) + expect(wallet_item["name"]).to eq(wallet.name) + expect(wallet_item["priority"]).to eq(wallet.priority) + expect(wallet_item["currency"]).to eq(wallet.currency) + expect(wallet_item["paidTopUpMinAmountCents"]).to eq("1000") + expect(wallet_item["paidTopUpMinCredits"]).to eq("10") + end + + context "without customer portal user" do + it "returns an error" do + result = execute_graphql( + query: + ) + + expect_unauthorized_error(result) + end + end +end diff --git a/spec/graphql/resolvers/customer_resolver_spec.rb b/spec/graphql/resolvers/customer_resolver_spec.rb new file mode 100644 index 0000000..e910ebf --- /dev/null +++ b/spec/graphql/resolvers/customer_resolver_spec.rb @@ -0,0 +1,234 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomerResolver do + let(:required_permission) { "customers:view" } + let(:query) do + <<~GQL + query($customerId: ID, $externalId: ID) { + customer(id: $customerId, externalId: $externalId) { + id + externalId + externalSalesforceId + name + currency + hasCreditNotes + creditNotesCreditsAvailableCount + creditNotesBalanceAmountCents + applicableTimezone + hasOverwrittenInvoiceCustomSectionsSelection + skipInvoiceCustomSections + invoices { + id + invoiceType + paymentStatus + totalAmountCents + feesAmountCents + taxesAmountCents + subTotalExcludingTaxesAmountCents + subTotalIncludingTaxesAmountCents + couponsAmountCents + creditNotesAmountCents + } + subscriptions(status: [active]) { id, status } + appliedCoupons { id amountCents amountCurrency coupon { id name } } + appliedAddOns { id amountCents amountCurrency addOn { id name } } + taxes { id code name } + configurableInvoiceCustomSections { id name } + creditNotes { + id + creditStatus + reason + totalAmountCents + creditAmountCents + balanceAmountCents + refundAmountCents + items { + id + amountCents + amountCurrency + fee { id amountCents amountCurrency itemType itemCode itemName taxesRate units eventsCount } + } + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, organization:, timezone: "America/New_York") } + let(:customer) do + create(:customer, billing_entity:, organization:, currency: "EUR", skip_invoice_custom_sections: false) + end + let(:subscription) { create(:subscription, customer:) } + let(:applied_add_on) { create(:applied_add_on, customer:) } + let(:applied_tax) { create(:customer_applied_tax, customer:) } + let(:credit_note) { create(:credit_note, customer:) } + let(:credit_note_item) { create(:credit_note_item, credit_note:) } + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 3, organization:) } + + before do + create_list(:invoice, 2, customer:) + applied_add_on + applied_tax + subscription + credit_note_item + invoice_custom_sections.each do |invoice_custom_section| + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section:) + end + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: invoice_custom_sections[0]) + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: invoice_custom_sections[1]) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "customers:view" + + context "when id and external_id are not provided" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error( + result:, + message: "You must provide either `id` or `external_id`." + ) + end + end + + context "when external_id is provided" do + it "returns a single customer" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + externalId: customer.external_id + } + ) + + customer_response = result["data"]["customer"] + expect(customer_response["id"]).to eq(customer.id) + expect(customer_response["externalId"]).to eq(customer.external_id) + end + end + + it "returns a single customer" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id + } + ) + + customer_response = result["data"]["customer"] + + expect(customer_response["id"]).to eq(customer.id) + expect(customer_response["subscriptions"].count).to eq(1) + expect(customer_response["invoices"].count).to eq(2) + expect(customer_response["appliedAddOns"].count).to eq(1) + expect(customer_response["taxes"].count).to eq(1) + expect(customer_response["currency"]).to be_present + expect(customer_response["externalSalesforceId"]).to be_nil + expect(customer_response["timezone"]).to be_nil + expect(customer_response["applicableTimezone"]).to eq("TZ_AMERICA_NEW_YORK") + expect(customer_response["hasCreditNotes"]).to be true + expect(customer_response["creditNotesCreditsAvailableCount"]).to eq(1) + expect(customer_response["creditNotesBalanceAmountCents"]).to eq("120") + expect(customer_response["hasOverwrittenInvoiceCustomSectionsSelection"]).to be true + expect(customer_response["skipInvoiceCustomSections"]).to be false + expect(customer_response["configurableInvoiceCustomSections"].count).to eq(2) + end + + context "when customer has invoice_custom_sections selected on organization level" do + before do + customer.selected_invoice_custom_sections = [] + end + + it "returns a single customer with correct hasOverwrittenInvoiceCustomSectionsSelection value" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id + } + ) + + customer_response = result["data"]["customer"] + expect(customer_response["hasOverwrittenInvoiceCustomSectionsSelection"]).to be false + end + end + + context "when active and pending subscriptions are requested" do + let(:second_subscription) { create(:subscription, :pending, customer:) } + let(:third_subscription) { create(:subscription, :pending, customer:, previous_subscription: subscription) } + + let(:query) do + <<~GQL + query($customerId: ID!) { + customer(id: $customerId) { + id externalId name currency + invoices { id invoiceType paymentStatus } + subscriptions(status: [active, pending]) { id, status } + appliedCoupons { id amountCents amountCurrency coupon { id name } } + appliedAddOns { id amountCents amountCurrency addOn { id name } } + taxes { id name code description } + } + } + GQL + end + + before do + second_subscription + third_subscription + end + + it "returns a single customer with correct subscriptions" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id + } + ) + + subscription_ids = result["data"]["customer"]["subscriptions"].map { |el| el["id"] } + + expect(subscription_ids.count).to eq(2) + expect(subscription_ids).not_to include(third_subscription.id) + end + end + + context "when customer is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: "foo" + } + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/customers/invoices_resolver_spec.rb b/spec/graphql/resolvers/customers/invoices_resolver_spec.rb new file mode 100644 index 0000000..c3c2f92 --- /dev/null +++ b/spec/graphql/resolvers/customers/invoices_resolver_spec.rb @@ -0,0 +1,156 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Customers::InvoicesResolver do + let(:required_permission) { "invoices:view" } + let(:query) do + <<~GQL + query($customerId: ID!) { + customerInvoices(customerId: $customerId) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, organization:) } + let(:draft_invoice) { create(:invoice, :draft, customer:, organization:) } + let(:finalized_invoice) { create(:invoice, customer:, organization:) } + + before do + subscription + draft_invoice + finalized_invoice + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:view" + + it "returns a list of invoices" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + + invoices_response = result["data"]["customerInvoices"] + + expect(invoices_response["collection"].count).to eq(customer.invoices.count) + expect(invoices_response["collection"].pluck("id")).to contain_exactly(draft_invoice.id, finalized_invoice.id) + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(2) + end + + context "with filter on status" do + let(:query) do + <<~GQL + query($customerId: ID!, $status: [InvoiceStatusTypeEnum!]) { + customerInvoices(customerId: $customerId, status: $status) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "only returns draft invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id, status: ["draft"]} + ) + + invoices_response = result["data"]["customerInvoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(draft_invoice.id) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when not member of the organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: create(:organization), + query:, + variables: {customerId: customer.id} + ) + + expect_graphql_error( + result:, + message: "Not in organization" + ) + end + end + + context "when preloading offset amounts" do + subject do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + end + + let(:query) do + <<~GQL + query($customerId: ID!) { + customerInvoices(customerId: $customerId) { + collection { id totalDueAmountCents totalSettledAmountCents } + metadata { currentPage, totalCount } + } + } + GQL + end + let(:preloadable_invoices) { [draft_invoice, finalized_invoice] } + + include_examples "preloads offset amounts" + end + + context "when query fails" do + it "returns an error" do + allow(InvoicesQuery).to receive(:call).and_return( + BaseService::Result.new.tap { |r| r.validation_failure!(errors: {base: ["test_error"]}) } + ) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + + expect_graphql_error(result:, message: "Unprocessable Entity") + end + end + + context "when customer does not exists" do + it "returns no results" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: "123456"} + ) + + invoices_response = result["data"]["customerInvoices"] + + expect(invoices_response["collection"].count).to eq(0) + end + end +end diff --git a/spec/graphql/resolvers/customers/projected_usage_resolver_spec.rb b/spec/graphql/resolvers/customers/projected_usage_resolver_spec.rb new file mode 100644 index 0000000..ec53800 --- /dev/null +++ b/spec/graphql/resolvers/customers/projected_usage_resolver_spec.rb @@ -0,0 +1,431 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Customers::ProjectedUsageResolver do + let(:required_permission) { "customers:view" } + let(:query) do + <<~GQL + query($customerId: ID!, $subscriptionId: ID!) { + customerProjectedUsage(customerId: $customerId, subscriptionId: $subscriptionId) { + fromDatetime + toDatetime + currency + issuingDate + amountCents + projectedAmountCents + totalAmountCents + taxesAmountCents + chargesUsage { + billableMetric { name code aggregationType } + charge { chargeModel } + filters { id units amountCents pricingUnitAmountCents invoiceDisplayName values eventsCount } + units + projectedUnits + amountCents + projectedAmountCents + pricingUnitAmountCents + pricingUnitProjectedAmountCents + groupedUsage { + amountCents + projectedAmountCents + units + projectedUnits + eventsCount + groupedBy + filters { id units amountCents pricingUnitAmountCents invoiceDisplayName values eventsCount } + presentationBreakdowns { presentationBy units } + } + presentationBreakdowns { presentationBy units } + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:, rate: 20) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) do + create( + :subscription, + plan:, + customer:, + started_at: Time.zone.now - 2.years + ) + end + let(:plan) { create(:plan, interval: "monthly") } + + let(:metric) { create(:billable_metric, name: "count_metric", aggregation_type: "count_agg") } + let(:sum_metric) { create(:sum_billable_metric, name: "sum_metric", organization:) } + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric: metric, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + pricing_group_keys: ["agent_name"] + } + ) + end + + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric: metric, key: "cloud", values: %w[aws gcp]) + end + + let(:charge_filter) { create(:charge_filter, charge: standard_charge, invoice_display_name: nil) } + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["aws"]) + end + + before do + subscription + charge + tax + charge_filter_value + + create( + :applied_pricing_unit, + organization: organization, + conversion_rate: 0.25, + pricing_unitable: standard_charge + ) + + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: Time.zone.now + ) + + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: sum_metric.code, + timestamp: Time.zone.now, + properties: { + agent_name: "frodo", + cloud: "aws", + item_id: 1 + } + ) + end + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "customers:view" + + it "returns the projected usage for the customer" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + usage_response = result["data"]["customerProjectedUsage"] + + expect(usage_response["fromDatetime"]).to eq(Time.current.beginning_of_month.iso8601) + expect(usage_response["toDatetime"]).to eq(Time.current.end_of_month.iso8601) + expect(usage_response["currency"]).to eq("EUR") + expect(usage_response["issuingDate"]).to eq(Time.zone.today.end_of_month.iso8601) + expect(usage_response["amountCents"]).to eq("105") + expect(usage_response["projectedAmountCents"]).to eq("836") + expect(usage_response["totalAmountCents"]).to eq("105") + expect(usage_response["taxesAmountCents"]).to eq("0") + + # Find standard charge by charge model + standard_charge_usage = usage_response["chargesUsage"].find { |usage| usage["charge"]["chargeModel"] == "standard" } + expect(standard_charge_usage["billableMetric"]["name"]).to eq(sum_metric.name) + expect(standard_charge_usage["billableMetric"]["code"]).to eq(sum_metric.code) + expect(standard_charge_usage["billableMetric"]["aggregationType"]).to eq("sum_agg") + expect(standard_charge_usage["charge"]["chargeModel"]).to eq("standard") + expect(standard_charge_usage["pricingUnitAmountCents"]).to eq("400") + expect(standard_charge_usage["pricingUnitProjectedAmountCents"]).to eq("207") + expect(standard_charge_usage["units"]).to eq(4.0) + expect(standard_charge_usage["projectedUnits"]).to eq(8.27) + expect(standard_charge_usage["amountCents"]).to eq("100") + expect(standard_charge_usage["projectedAmountCents"]).to eq("827") + + # Find graduated charge by charge model + graduated_charge_usage = usage_response["chargesUsage"].find { |usage| usage["charge"]["chargeModel"] == "graduated" } + expect(graduated_charge_usage["billableMetric"]["name"]).to eq(metric.name) + expect(graduated_charge_usage["billableMetric"]["code"]).to eq(metric.code) + expect(graduated_charge_usage["billableMetric"]["aggregationType"]).to eq("count_agg") + expect(graduated_charge_usage["charge"]["chargeModel"]).to eq("graduated") + expect(graduated_charge_usage["pricingUnitAmountCents"]).to eq(nil) + expect(graduated_charge_usage["units"]).to eq(4.0) + expect(graduated_charge_usage["projectedUnits"]).to eq(8.27) + expect(graduated_charge_usage["amountCents"]).to eq("5") + expect(graduated_charge_usage["projectedAmountCents"]).to eq("9") + + # Check grouped usage on the standard charge (sum_metric with grouping) + grouped_usage = standard_charge_usage["groupedUsage"].first + expect(grouped_usage["amountCents"]).to eq("100") + expect(grouped_usage["projectedAmountCents"]).to eq("827") + expect(grouped_usage["units"]).to eq(4.0) + expect(grouped_usage["projectedUnits"]).to eq(8.27) + expect(grouped_usage["eventsCount"]).to eq(4) + expect(grouped_usage["groupedBy"]).to eq({"agent_name" => "frodo"}) + end + end + + context "with filters" do + let(:filter_metric) { create(:billable_metric, aggregation_type: "count_agg", organization:) } + let(:cloud_bm_filter) do + create(:billable_metric_filter, billable_metric: filter_metric, key: "cloud", values: %w[aws google]) + end + + let(:aws_filter) do + create(:charge_filter, charge:, properties: {amount: "10"}) + end + let(:aws_filter_value) do + create(:charge_filter_value, charge_filter: aws_filter, billable_metric_filter: cloud_bm_filter, values: ["aws"]) + end + + let(:google_filter) do + create(:charge_filter, charge:, properties: {amount: "20"}) + end + let(:google_filter_value) do + create( + :charge_filter_value, + charge_filter: google_filter, + billable_metric_filter: cloud_bm_filter, + values: ["google"] + ) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: filter_metric, + properties: {amount: "0"} + ) + end + + before do + subscription + charge + tax + aws_filter_value + google_filter_value + + create( + :applied_pricing_unit, + organization: organization, + conversion_rate: 0.2, + pricing_unitable: charge + ) + + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "aws"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "google"} + ) + end + end + + it "returns the projected filter usage for the customer" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + charge_usage = result["data"]["customerProjectedUsage"]["chargesUsage"].find do |usage| + usage["billableMetric"]["code"] == filter_metric.code + end + + filters_usage = charge_usage["filters"] + + expect(charge_usage["units"]).to eq(4) + expect(charge_usage["amountCents"]).to eq("1000") + expect(charge_usage["projectedUnits"]).to eq(8.27) + expect(charge_usage["projectedAmountCents"]).to eq("10340") + + # Check that filter data contains projected values + aws_filter_data = filters_usage.find { |f| f["id"] == aws_filter.id } + expect(aws_filter_data["units"]).to eq(3) + expect(aws_filter_data["amountCents"]).to eq("600") + expect(aws_filter_data["pricingUnitAmountCents"]).to eq("3000") + + google_filter_data = filters_usage.find { |f| f["id"] == google_filter.id } + expect(google_filter_data["units"]).to eq(1) + expect(google_filter_data["amountCents"]).to eq("400") + expect(google_filter_data["pricingUnitAmountCents"]).to eq("2000") + end + end + end + + context "with presentation group keys" do + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + pricing_group_keys: ["agent_name"], + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + it "returns the presentation breakdowns" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerProjectedUsage"]["chargesUsage"] + + graduated_charge_usage = charges_usage.find { |usage| usage["charge"]["chargeModel"] == "graduated" } + expect(graduated_charge_usage["presentationBreakdowns"]).to be_empty + + standard_charge_usage = charges_usage.find { |usage| usage["charge"]["chargeModel"] == "standard" } + expect(standard_charge_usage["presentationBreakdowns"]).to be_empty + + grouped_usage = standard_charge_usage["groupedUsage"] + expect(grouped_usage.first["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + expect(grouped_usage.second["presentationBreakdowns"]).to be_empty + end + end + + context "with two charges without pricing_group_keys" do + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + presentation_group_keys: [{value: "cloud"}] + } + ) + end + let(:presentation_metric) { create(:sum_billable_metric, organization:) } + let(:presentation_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: presentation_metric, + properties: { + amount: "1", + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + before do + presentation_charge + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: presentation_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "gcp", item_id: 1} + ) + end + end + + it "returns presentation breakdowns for both charges with no grouped_usage" do + travel_to(Time.parse("2025-07-15T10:00:00Z")) do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerProjectedUsage"]["chargesUsage"] + presentation_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == presentation_metric.code } + sum_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == sum_metric.code } + + expect(presentation_charge_usage["groupedUsage"]).to be_empty + expect(presentation_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "gcp"}, "units" => "3.0"} + ]) + + expect(sum_charge_usage["groupedUsage"]).to be_empty + expect(sum_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + end + end + end + end +end diff --git a/spec/graphql/resolvers/customers/subscriptions_resolver_spec.rb b/spec/graphql/resolvers/customers/subscriptions_resolver_spec.rb new file mode 100644 index 0000000..a809482 --- /dev/null +++ b/spec/graphql/resolvers/customers/subscriptions_resolver_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Customers::SubscriptionsResolver do + let(:required_permission) { "customers:view" } + let(:query) do + <<~GQL + query($customerId: ID!) { + customer(id: $customerId) { + subscriptions(status: [#{status_filter}]) { + id + status + startedAt + plan { + code + } + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:status_filter) { nil } + + before do + customer + end + + it_behaves_like "requires permission", "customers:view" + + describe "when no status filter is provided" do + let(:active_subscription) { create(:subscription, customer:, plan:, status: "active") } + let(:terminated_subscription) { create(:subscription, customer:, plan:, status: "terminated") } + let(:pending_subscription) { create(:subscription, customer:, plan:, status: "pending", started_at: 1.day.from_now) } + let(:downgraded_active_subscription) { create(:subscription, customer:, plan: plan2, status: "active") } + let(:pending_from_downgrade) { create(:subscription, customer:, plan:, status: "pending", previous_subscription: downgraded_active_subscription) } + let(:plan2) { create(:plan, organization:, amount_cents: 1_000_000) } + + before do + active_subscription + terminated_subscription + pending_subscription + downgraded_active_subscription + pending_from_downgrade + end + + it "returns all subscriptions except downgraded pending ones" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + + response = result["data"]["customer"]["subscriptions"] + + expect(response.count).to eq(4) + expect(response.map { |s| s["id"] }).to contain_exactly( + active_subscription.id, + terminated_subscription.id, + pending_subscription.id, + downgraded_active_subscription.id + ) + end + end + + describe "when filtering by active status" do + let(:status_filter) { "active" } + let(:active_subscription) { create(:subscription, customer:, plan:, status: "active") } + let(:terminated_subscription) { create(:subscription, customer:, plan:, status: "terminated") } + let(:pending_subscription) { create(:subscription, customer:, plan:, status: "pending") } + + before do + active_subscription + terminated_subscription + pending_subscription + end + + it "returns only active subscriptions" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + response = result["data"]["customer"]["subscriptions"] + + expect(response.count).to eq(1) + expect(response.first["id"]).to eq(active_subscription.id) + end + end + + describe "when filtering by pending status" do + let(:status_filter) { "pending" } + let(:active_subscription) { create(:subscription, customer:, plan:, status: "active") } + let(:pending_subscription) { create(:subscription, customer:, plan:, status: "pending", started_at: 1.day.from_now) } + + before do + active_subscription + pending_subscription + end + + it "returns only pending subscriptions that are starting in the future" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + response = result["data"]["customer"]["subscriptions"] + + expect(response.count).to eq(1) + expect(response.first["id"]).to eq(pending_subscription.id) + end + end + + describe "when filtering by multiple statuses including pending" do + let(:status_filter) { "active, pending" } + let(:active_subscription) { create(:subscription, customer:, plan:, status: "active") } + let(:pending_subscription) { create(:subscription, customer:, plan:, status: "pending", started_at: 1.day.from_now) } + + before do + active_subscription + pending_subscription + end + + it "returns subscriptions matching the statuses and pending subscriptions starting in the future" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + response = result["data"]["customer"]["subscriptions"] + + expect(response.count).to eq(2) + expect(response.map { |s| s["id"] }).to contain_exactly( + active_subscription.id, + pending_subscription.id + ) + end + end + + describe "when filtering by multiple statuses excluding pending" do + let(:status_filter) { "active, terminated" } + let(:active_subscription) { create(:subscription, customer:, plan:, status: "active") } + let(:terminated_subscription) { create(:subscription, customer:, plan:, status: "terminated") } + let(:pending_subscription) { create(:subscription, customer:, plan:, status: "pending") } + + before do + active_subscription + terminated_subscription + pending_subscription + end + + it "returns only subscriptions matching the specified statuses" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {customerId: customer.id} + ) + response = result["data"]["customer"]["subscriptions"] + + expect(response.count).to eq(2) + expect(response.map { |s| s["id"] }).to contain_exactly( + active_subscription.id, + terminated_subscription.id + ) + end + end +end diff --git a/spec/graphql/resolvers/customers/usage_resolver_spec.rb b/spec/graphql/resolvers/customers/usage_resolver_spec.rb new file mode 100644 index 0000000..ac78d1f --- /dev/null +++ b/spec/graphql/resolvers/customers/usage_resolver_spec.rb @@ -0,0 +1,431 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Customers::UsageResolver do + let(:required_permission) { "customers:view" } + let(:query) do + <<~GQL + query($customerId: ID!, $subscriptionId: ID!) { + customerUsage(customerId: $customerId, subscriptionId: $subscriptionId) { + fromDatetime + toDatetime + currency + issuingDate + amountCents + totalAmountCents + taxesAmountCents + chargesUsage { + billableMetric { name code aggregationType } + charge { chargeModel } + filters { id units amountCents pricingUnitAmountCents invoiceDisplayName values eventsCount } + units + amountCents + pricingUnitAmountCents + groupedUsage { + amountCents + units + eventsCount + groupedBy + filters { id units amountCents pricingUnitAmountCents invoiceDisplayName values eventsCount } + presentationBreakdowns { presentationBy units } + } + presentationBreakdowns { presentationBy units } + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:, rate: 20) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) do + create( + :subscription, + plan:, + customer:, + started_at: Time.zone.now - 2.years + ) + end + let(:plan) { create(:plan, interval: "monthly") } + + let(:metric) { create(:billable_metric, name: "count_metric", aggregation_type: "count_agg") } + let(:sum_metric) { create(:sum_billable_metric, name: "sum_metric", organization:) } + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric: metric, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: { + amount: 1.to_s, + pricing_group_keys: ["agent_name"] + } + ) + end + + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric: metric, key: "cloud", values: %w[aws gcp]) + end + + let(:charge_filter) { create(:charge_filter, charge: standard_charge, invoice_display_name: nil) } + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["aws"]) + end + + before do + subscription + charge + tax + charge_filter_value + + create( + :applied_pricing_unit, + organization: organization, + conversion_rate: 0.25, + pricing_unitable: standard_charge + ) + + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: Time.zone.now + ) + + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: sum_metric.code, + timestamp: Time.zone.now, + properties: { + agent_name: "frodo", + cloud: "aws", + item_id: 1 + } + ) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "customers:view" + + it "returns the usage for the customer" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + usage_response = result["data"]["customerUsage"] + + expect(usage_response["fromDatetime"]).to eq(Time.current.beginning_of_month.iso8601) + expect(usage_response["toDatetime"]).to eq(Time.current.end_of_month.iso8601) + expect(usage_response["currency"]).to eq("EUR") + expect(usage_response["issuingDate"]).to eq(Time.zone.today.end_of_month.iso8601) + expect(usage_response["amountCents"]).to eq("105") + expect(usage_response["totalAmountCents"]).to eq("105") + expect(usage_response["taxesAmountCents"]).to eq("0") + + # Find graduated charge by charge model + graduated_charge_usage = usage_response["chargesUsage"].find { |usage| usage["charge"]["chargeModel"] == "graduated" } + expect(graduated_charge_usage["billableMetric"]["name"]).to eq(metric.name) + expect(graduated_charge_usage["billableMetric"]["code"]).to eq(metric.code) + expect(graduated_charge_usage["billableMetric"]["aggregationType"]).to eq("count_agg") + expect(graduated_charge_usage["charge"]["chargeModel"]).to eq("graduated") + expect(graduated_charge_usage["pricingUnitAmountCents"]).to eq(nil) + expect(graduated_charge_usage["units"]).to eq(4.0) + expect(graduated_charge_usage["amountCents"]).to eq("5") + + # Find standard charge by charge model + standard_charge_usage = usage_response["chargesUsage"].find { |usage| usage["charge"]["chargeModel"] == "standard" } + expect(standard_charge_usage["billableMetric"]["name"]).to eq(sum_metric.name) + expect(standard_charge_usage["billableMetric"]["code"]).to eq(sum_metric.code) + expect(standard_charge_usage["billableMetric"]["aggregationType"]).to eq("sum_agg") + expect(standard_charge_usage["charge"]["chargeModel"]).to eq("standard") + expect(standard_charge_usage["pricingUnitAmountCents"]).to eq("400") + expect(standard_charge_usage["units"]).to eq(4.0) + expect(standard_charge_usage["amountCents"]).to eq("100") + + grouped_usage = standard_charge_usage["groupedUsage"].first + expect(grouped_usage["amountCents"]).to eq("100") + expect(grouped_usage["pricingUnitAmountCents"]).to eq(nil) + expect(grouped_usage["units"]).to eq(4.0) + expect(grouped_usage["eventsCount"]).to eq(4) + expect(grouped_usage["groupedBy"]).to eq({"agent_name" => "frodo"}) + end + + context "with presentation group keys" do + let(:properties) do + { + amount: 1.to_s, + presentation_group_keys: [{value: "cloud"}] + } + end + let(:standard_charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: sum_metric, + properties: properties + ) + end + + it "returns the presentation breakdowns" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerUsage"]["chargesUsage"] + expect(charges_usage.first["presentationBreakdowns"]).to be_empty + expect(charges_usage.second["presentationBreakdowns"]).to eq([{"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"}]) + end + + context "with pricing group keys" do + let(:properties) do + { + amount: 1.to_s, + pricing_group_keys: ["item_id"], + presentation_group_keys: [{value: "cloud"}] + } + end + + it "returns the presentation breakdowns" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerUsage"]["chargesUsage"] + expect(charges_usage.first["presentationBreakdowns"]).to be_empty + expect(charges_usage.second["presentationBreakdowns"]).to be_empty + + grouped_usage = charges_usage.second["groupedUsage"] + expect(grouped_usage.first["presentationBreakdowns"]).to eq([{"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"}]) + expect(grouped_usage.second["presentationBreakdowns"]).to be_empty + end + end + + context "with two charges without pricing_group_keys" do + let(:presentation_metric) { create(:sum_billable_metric, organization:) } + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: presentation_metric, + properties: { + amount: "1", + presentation_group_keys: [{value: "cloud"}] + } + ) + end + + before do + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: presentation_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "gcp", item_id: 1} + ) + end + + it "returns presentation breakdowns for both charges with no grouped_usage" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + charges_usage = result["data"]["customerUsage"]["chargesUsage"] + presentation_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == presentation_metric.code } + sum_charge_usage = charges_usage.find { |u| u["billableMetric"]["code"] == sum_metric.code } + + expect(presentation_charge_usage["groupedUsage"]).to be_empty + expect(presentation_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "gcp"}, "units" => "3.0"} + ]) + + expect(sum_charge_usage["groupedUsage"]).to be_empty + expect(sum_charge_usage["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"cloud" => "aws"}, "units" => "4.0"} + ]) + end + end + end + + context "with filters" do + let(:cloud_bm_filter) do + create(:billable_metric_filter, billable_metric: metric, key: "cloud", values: %w[aws google]) + end + + let(:aws_filter) do + create(:charge_filter, charge:, properties: {amount: "10"}) + end + let(:aws_filter_value) do + create(:charge_filter_value, charge_filter: aws_filter, billable_metric_filter: cloud_bm_filter, values: ["aws"]) + end + + let(:google_filter) do + create(:charge_filter, charge:, properties: {amount: "20"}) + end + let(:google_filter_value) do + create( + :charge_filter_value, + charge_filter: google_filter, + billable_metric_filter: cloud_bm_filter, + values: ["google"] + ) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: metric, + properties: {amount: "0"} + ) + end + + before do + aws_filter_value + google_filter_value + + create( + :applied_pricing_unit, + organization: organization, + conversion_rate: 0.2, + pricing_unitable: charge + ) + + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: Time.zone.now, + properties: {cloud: "aws"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: Time.zone.now, + properties: {cloud: "google"} + ) + end + + it "returns the filter usage for the customer" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + customerId: customer.id, + subscriptionId: subscription.id + } + ) + + charge_usage = result["data"]["customerUsage"]["chargesUsage"].find do |usage| + usage["billableMetric"]["code"] == metric.code + end + + filters_usage = charge_usage["filters"] + + expect(charge_usage["units"]).to eq(8) + expect(charge_usage["amountCents"]).to eq("1000") + expect(filters_usage).to contain_exactly( + { + "id" => nil, + "units" => 4, + "amountCents" => "0", + "pricingUnitAmountCents" => "0", + "invoiceDisplayName" => nil, + "values" => {}, + "eventsCount" => 4 + }, + { + "id" => aws_filter.id, + "units" => 3, + "amountCents" => "600", + "pricingUnitAmountCents" => "3000", + "invoiceDisplayName" => nil, + "values" => { + "cloud" => ["aws"] + }, + "eventsCount" => 3 + }, + { + "id" => google_filter.id, + "units" => 1, + "amountCents" => "400", + "pricingUnitAmountCents" => "2000", + "invoiceDisplayName" => nil, + "values" => { + "cloud" => ["google"] + }, + "eventsCount" => 1 + } + ) + end + end +end diff --git a/spec/graphql/resolvers/customers_resolver_spec.rb b/spec/graphql/resolvers/customers_resolver_spec.rb new file mode 100644 index 0000000..76e5824 --- /dev/null +++ b/spec/graphql/resolvers/customers_resolver_spec.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::CustomersResolver do + let(:required_permission) { "customers:view" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization:) } + let(:query) do + <<~GQL + query( + $searchTerm: String, + $page: Int, + $limit: Int, + $accountType: [CustomerAccountTypeEnum!], + $billingEntityIds: [ID!], + $activeSubscriptionsCountFrom: Int, + $activeSubscriptionsCountTo: Int, + $customerType: CustomerTypeEnum, + $hasCustomerType: Boolean, + $hasTaxIdentificationNumber: Boolean, + $countries: [CountryCode!], + $states: [String!], + $zipcodes: [String!], + $currencies: [CurrencyEnum!], + $withDeleted: Boolean, + $metadata: [CustomerMetadataFilter!] + ) { + customers( + limit: $limit, + searchTerm: $searchTerm, + page: $page, + accountType: $accountType, + billingEntityIds: $billingEntityIds, + activeSubscriptionsCountFrom: $activeSubscriptionsCountFrom, + activeSubscriptionsCountTo: $activeSubscriptionsCountTo, + customerType: $customerType, + hasCustomerType: $hasCustomerType, + hasTaxIdentificationNumber: $hasTaxIdentificationNumber, + countries: $countries, + states: $states, + zipcodes: $zipcodes, + currencies: $currencies, + withDeleted: $withDeleted, + metadata: $metadata + ) { + collection { id externalId name } + metadata { currentPage, totalCount } + } + } + GQL + end + + def test_customers_resolver(expected_customers, variables: {}) + variables = {page: 1, limit: 5}.merge(variables) + result = execute_query(query:, variables: variables) + + customers_response = result["data"]["customers"] + + expected_customers = Array.wrap(expected_customers) + + expect(customers_response["collection"].count).to eq(expected_customers.count) + expect(customers_response["collection"].pluck("id")).to match_array(expected_customers.pluck(:id)) + + expect(customers_response["metadata"]["currentPage"]).to eq(1) + expect(customers_response["metadata"]["totalCount"]).to eq(expected_customers.count) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "customers:view" + + it "returns a list of customers" do + customer_1 = create(:customer, organization:) + customer_2 = create(:customer, organization:) + + test_customers_resolver([customer_1, customer_2]) + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql(current_user: membership.user, query:) + + expect_graphql_error(result:, message: "Missing organization id") + end + end + + context "when filtering by partner account type" do + let(:customer) { create(:customer, organization:) } + let(:partner) { create(:customer, organization:, account_type: "partner") } + + before do + customer + partner + end + + it "returns all customers with account_type partner" do + test_customers_resolver(partner, variables: {accountType: ["partner"]}) + end + end + + context "when filtering by billing_entity_id" do + let(:customer) { create(:customer, organization:, billing_entity: billing_entity1) } + let(:customer2) { create(:customer, organization:, billing_entity: billing_entity2) } + + before do + customer + customer2 + end + + it "returns all customers for the specified billing entity" do + test_customers_resolver(customer2, variables: {billingEntityIds: [billing_entity2.id]}) + end + end + + context "when filtering by with_deleted" do + let(:customer) { create(:customer, organization:) } + let(:deleted_customer) { create(:customer, organization:, deleted_at: Time.current) } + + before do + customer + deleted_customer + end + + it "returns all customers including deleted ones" do + test_customers_resolver([customer, deleted_customer], variables: {withDeleted: true}) + end + end + + context "when filtering by active subscriptions" do + let(:active_subscription_customer) { create(:customer, organization:) } + + before do + other_customer = create(:customer, organization:) + create(:subscription, customer: other_customer) + 2.times do + create(:subscription, customer: active_subscription_customer) + end + end + + it "returns all customers with 2 active subscriptions" do + test_customers_resolver(active_subscription_customer, variables: {activeSubscriptionsCountFrom: 2, activeSubscriptionsCountTo: 2}) + end + end + + context "when filtering by customer_type" do + let!(:company_customer) { create(:customer, organization:, customer_type: "company") } + + before do + create(:customer, organization:, customer_type: "individual") + end + + it "returns all customers with customer_type company" do + test_customers_resolver(company_customer, variables: {customerType: "company"}) + end + end + + context "when filtering by has_customer_type" do + let!(:company_customer) { create(:customer, organization:, customer_type: "company") } + + before do + create(:customer, organization:, customer_type: nil) + end + + it "returns all customers with customer_type company" do + test_customers_resolver(company_customer, variables: {hasCustomerType: true}) + end + end + + context "when filtering by has_tax_identification_number" do + let!(:customer_with_tax_identification_number) { create(:customer, organization:, tax_identification_number: "1234567890") } + + before do + create(:customer, organization:, tax_identification_number: nil) + end + + it "returns all customers with tax_identification_number" do + test_customers_resolver(customer_with_tax_identification_number, variables: {hasTaxIdentificationNumber: true}) + end + end + + context "when filtering by countries" do + let!(:customer_in_france) { create(:customer, organization:, country: "FR") } + + before do + create(:customer, organization:, country: "US") + end + + it "returns all customers in France" do + test_customers_resolver(customer_in_france, variables: {countries: ["FR"]}) + end + end + + context "when filtering by states" do + let!(:customer_in_new_york) { create(:customer, organization:, state: "NY") } + + before do + create(:customer, organization:, state: "CA") + end + + it "returns all customers in New York" do + test_customers_resolver(customer_in_new_york, variables: {states: ["NY"]}) + end + end + + context "when filtering by zipcodes" do + let!(:customer_in_new_york) { create(:customer, organization:, zipcode: "10001") } + + before do + create(:customer, organization:, zipcode: "90001") + end + + it "returns all customers in New York" do + test_customers_resolver(customer_in_new_york, variables: {zipcodes: ["10001"]}) + end + end + + context "when filtering by currencies" do + let!(:customer_in_usd) { create(:customer, organization:, currency: "USD") } + + before do + create(:customer, organization:, currency: "EUR") + end + + it "returns all customers in USD" do + test_customers_resolver(customer_in_usd, variables: {currencies: ["USD"]}) + end + end + + context "when filtering by search_term" do + let!(:john_doe) { create(:customer, organization:, name: "John, Doe", email: "john@doe.com", legal_name: "John-Doe", firstname: "Johnnas", lastname: "Doefe", external_id: "1234567890") } + + before do + create(:customer, organization:, name: "Jane Doe", email: "jane@doe.com", legal_name: "Jane-Doe", firstname: "Janenas", lastname: "Doefae", external_id: "1234567891") + end + + [ + ["John,", :name], + ["ohn@doe.com", :email], + ["hn-d", :legal_name], + ["nnas", :firstname], + ["doefe", :lastname], + ["1234567890", :external_id] + ].each do |search_term, field| + context "when search_term matches #{field}" do + it "returns the matching customer" do + test_customers_resolver(john_doe, variables: {searchTerm: search_term}) + end + end + end + end + + context "when filtering by metadata" do + let!(:customer_with_metadata) { create(:customer, organization:) } + + before do + create(:customer_metadata, customer: customer_with_metadata, key: "key_1", value: "value_1") + + second_customer = create(:customer, organization:) + create(:customer_metadata, customer: second_customer, key: "key_1", value: "value_1") + create(:customer_metadata, customer: second_customer, key: "key_2", value: "value_2") + end + + it "returns all customers with metadata" do + test_customers_resolver( + customer_with_metadata, + variables: {metadata: [{key: "key_1", value: "value_1"}, {key: "key_2", value: ""}]} + ) + end + end +end diff --git a/spec/graphql/resolvers/data_api/mrrs/plans_resolver_spec.rb b/spec/graphql/resolvers/data_api/mrrs/plans_resolver_spec.rb new file mode 100644 index 0000000..f49441a --- /dev/null +++ b/spec/graphql/resolvers/data_api/mrrs/plans_resolver_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::Mrrs::PlansResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $limit: Int, $page: Int) { + dataApiMrrsPlans(currency: $currency, limit: $limit, page: $page) { + collection { + amountCurrency + dt + planCode + planDeletedAt + planId + planInterval + planName + activeCustomersCount + activeCustomersShare + mrr + mrrShare + } + metadata { + currentPage + nextPage + prevPage + totalCount + totalPages + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/mrrs_plans.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/mrrs/#{organization.id}/plans/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of mrrs plans" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + mrrs_response = result["data"]["dataApiMrrsPlans"] + expect(mrrs_response["collection"].first).to include( + { + "dt" => "2025-02-25", + "amountCurrency" => "EUR", + "planId" => "8f550d3e-1234-4f4d-a752-61b0f98a9ef7", + "activeCustomersCount" => "1", + "mrr" => 1000000.0, + "mrrShare" => 0.0279, + "planName" => "Tondr", + "planCode" => "custom_plan_tondr", + "planDeletedAt" => nil, + "planInterval" => "monthly", + "activeCustomersShare" => 0.009 + } + ) + expect(mrrs_response["metadata"]).to include( + "currentPage" => 1, + "nextPage" => 2, + "prevPage" => 0, + "totalCount" => 100, + "totalPages" => 5 + ) + end +end diff --git a/spec/graphql/resolvers/data_api/mrrs_resolver_spec.rb b/spec/graphql/resolvers/data_api/mrrs_resolver_spec.rb new file mode 100644 index 0000000..989c1d9 --- /dev/null +++ b/spec/graphql/resolvers/data_api/mrrs_resolver_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::MrrsResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum) { + dataApiMrrs(currency: $currency) { + collection { + amountCurrency + startingMrr + endingMrr + mrrNew + mrrExpansion + mrrContraction + mrrChurn + mrrChange + startOfPeriodDt + endOfPeriodDt + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/mrrs.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/mrrs/#{organization.id}/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of mrrs" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + mrrs_response = result["data"]["dataApiMrrs"] + expect(mrrs_response["collection"].first).to eq( + { + "startOfPeriodDt" => "2023-11-01", + "endOfPeriodDt" => "2023-11-30", + "amountCurrency" => "EUR", + "startingMrr" => "0", + "endingMrr" => "23701746", + "mrrNew" => "25016546", + "mrrExpansion" => "0", + "mrrContraction" => "0", + "mrrChurn" => "-1314800", + "mrrChange" => "23701746" + } + ) + end +end diff --git a/spec/graphql/resolvers/data_api/prepaid_credits_resolver_spec.rb b/spec/graphql/resolvers/data_api/prepaid_credits_resolver_spec.rb new file mode 100644 index 0000000..50c02a2 --- /dev/null +++ b/spec/graphql/resolvers/data_api/prepaid_credits_resolver_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::PrepaidCreditsResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $externalCustomerId: String) { + dataApiPrepaidCredits(currency: $currency, externalCustomerId: $externalCustomerId) { + collection { + amountCurrency + consumedAmount + offeredAmount + purchasedAmount + voidedAmount + consumedCreditsQuantity + offeredCreditsQuantity + purchasedCreditsQuantity + voidedCreditsQuantity + startOfPeriodDt + endOfPeriodDt + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/prepaid_credits.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/prepaid_credits/#{organization.id}/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of prepaid credits" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + prepaid_credits_response = result["data"]["dataApiPrepaidCredits"] + expect(prepaid_credits_response["collection"].first).to include( + { + "startOfPeriodDt" => "2023-12-01", + "endOfPeriodDt" => "2023-12-31", + "amountCurrency" => "EUR" + } + ) + end +end diff --git a/spec/graphql/resolvers/data_api/revenue_streams/customers_resolver_spec.rb b/spec/graphql/resolvers/data_api/revenue_streams/customers_resolver_spec.rb new file mode 100644 index 0000000..976aea7 --- /dev/null +++ b/spec/graphql/resolvers/data_api/revenue_streams/customers_resolver_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::RevenueStreams::CustomersResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $orderBy: OrderByEnum, $limit: Int, $page: Int) { + dataApiRevenueStreamsCustomers(currency: $currency, orderBy: $orderBy, limit: $limit, page: $page) { + collection { + customerId + customerDeletedAt + externalCustomerId + customerName + amountCurrency + grossRevenueAmountCents + grossRevenueShare + netRevenueAmountCents + netRevenueShare + } + metadata { + currentPage + nextPage + prevPage + totalCount + totalPages + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/revenue_streams_customers.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/revenue_streams/#{organization.id}/customers/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of revenue streams customers" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + revenue_streams_response = result["data"]["dataApiRevenueStreamsCustomers"] + expect(revenue_streams_response["collection"].first).to include( + { + "amountCurrency" => "EUR", + "customerId" => "e4676e50-1234-4606-bcdb-42effbc2b635", + "customerDeletedAt" => nil, + "externalCustomerId" => "2537afc4-1234-4abb-89b7-d9b28c35780b", + "customerName" => "Penny", + "grossRevenueAmountCents" => "124628322", + "netRevenueAmountCents" => "124628322", + "grossRevenueShare" => 0.1185, + "netRevenueShare" => 0.1185 + } + ) + expect(revenue_streams_response["metadata"]).to include( + "currentPage" => 1, + "nextPage" => 2, + "prevPage" => 0, + "totalCount" => 100, + "totalPages" => 5 + ) + end +end diff --git a/spec/graphql/resolvers/data_api/revenue_streams/plans_resolver_spec.rb b/spec/graphql/resolvers/data_api/revenue_streams/plans_resolver_spec.rb new file mode 100644 index 0000000..1808450 --- /dev/null +++ b/spec/graphql/resolvers/data_api/revenue_streams/plans_resolver_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::RevenueStreams::PlansResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $orderBy: OrderByEnum, $limit: Int, $page: Int) { + dataApiRevenueStreamsPlans(currency: $currency, orderBy: $orderBy, limit: $limit, page: $page) { + collection { + planCode + planDeletedAt + planId + planInterval + planName + customersCount + customersShare + amountCurrency + grossRevenueAmountCents + grossRevenueShare + netRevenueAmountCents + netRevenueShare + } + metadata { + currentPage + nextPage + prevPage + totalCount + totalPages + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/revenue_streams_plans.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/revenue_streams/#{organization.id}/plans/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of revenue streams plans" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + revenue_streams_response = result["data"]["dataApiRevenueStreamsPlans"] + expect(revenue_streams_response["collection"].first).to include( + { + "planId" => "8d39f27f-8371-43ea-a327-c9579e70eeb3", + "amountCurrency" => "EUR", + "planCode" => "custom_plan_penny", + "planDeletedAt" => nil, + "customersCount" => 1, + "grossRevenueAmountCents" => "120735293", + "netRevenueAmountCents" => "120735293", + "planName" => "Penny", + "planInterval" => "monthly", + "customersShare" => 0.0055, + "grossRevenueShare" => 0.1148, + "netRevenueShare" => 0.1148 + } + ) + expect(revenue_streams_response["metadata"]).to include( + "currentPage" => 1, + "nextPage" => 2, + "prevPage" => 0, + "totalCount" => 100, + "totalPages" => 5 + ) + end +end diff --git a/spec/graphql/resolvers/data_api/revenue_streams_resolver_spec.rb b/spec/graphql/resolvers/data_api/revenue_streams_resolver_spec.rb new file mode 100644 index 0000000..26ff017 --- /dev/null +++ b/spec/graphql/resolvers/data_api/revenue_streams_resolver_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::RevenueStreamsResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $externalCustomerId: String) { + dataApiRevenueStreams(currency: $currency, externalCustomerId: $externalCustomerId) { + collection { + amountCurrency + couponsAmountCents + grossRevenueAmountCents + netRevenueAmountCents + commitmentFeeAmountCents + oneOffFeeAmountCents + subscriptionFeeAmountCents + usageBasedFeeAmountCents + startOfPeriodDt + endOfPeriodDt + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/revenue_streams.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/revenue_streams/#{organization.id}/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of revenue streams" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + revenue_streams_response = result["data"]["dataApiRevenueStreams"] + expect(revenue_streams_response["collection"].first).to include( + { + "startOfPeriodDt" => "2024-01-01", + "endOfPeriodDt" => "2024-01-31" + } + ) + end +end diff --git a/spec/graphql/resolvers/data_api/usages/aggregated_amounts_resolver_spec.rb b/spec/graphql/resolvers/data_api/usages/aggregated_amounts_resolver_spec.rb new file mode 100644 index 0000000..fd847a2 --- /dev/null +++ b/spec/graphql/resolvers/data_api/usages/aggregated_amounts_resolver_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::Usages::AggregatedAmountsResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum) { + dataApiUsagesAggregatedAmounts(currency: $currency) { + collection { + startOfPeriodDt + endOfPeriodDt + amountCents + amountCurrency + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages_aggregated_amounts.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/aggregated_amounts/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of usages aggregated amounts" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + usages_invoiced_response = result["data"]["dataApiUsagesAggregatedAmounts"] + expect(usages_invoiced_response["collection"].first).to include( + { + "startOfPeriodDt" => "2024-01-01", + "endOfPeriodDt" => "2024-01-31", + "amountCurrency" => "EUR", + "amountCents" => "26600" + } + ) + end +end diff --git a/spec/graphql/resolvers/data_api/usages/forecasted_resolver_spec.rb b/spec/graphql/resolvers/data_api/usages/forecasted_resolver_spec.rb new file mode 100644 index 0000000..6dcf05d --- /dev/null +++ b/spec/graphql/resolvers/data_api/usages/forecasted_resolver_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::Usages::ForecastedResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query { + dataApiUsagesForecasted { + collection { + amountCurrency + amountCents + amountCentsForecastConservative + amountCentsForecastRealistic + amountCentsForecastOptimistic + units + unitsForecastConservative + unitsForecastRealistic + unitsForecastOptimistic + endOfPeriodDt + startOfPeriodDt + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages_forecasted.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/forecasted/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of forecasted usages" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + forecasted_response = result["data"]["dataApiUsagesForecasted"] + expect(forecasted_response["collection"].first).to include( + { + "amountCents" => "1000", + "amountCentsForecastConservative" => "1000", + "amountCentsForecastOptimistic" => "1000", + "amountCentsForecastRealistic" => "1000", + "amountCurrency" => "EUR", + "endOfPeriodDt" => "2025-06-28", + "startOfPeriodDt" => "2025-06-27", + "units" => 100.0, + "unitsForecastConservative" => 100.0, + "unitsForecastOptimistic" => 100.0, + "unitsForecastRealistic" => 100.0 + } + ) + end +end diff --git a/spec/graphql/resolvers/data_api/usages/invoiced_resolver_spec.rb b/spec/graphql/resolvers/data_api/usages/invoiced_resolver_spec.rb new file mode 100644 index 0000000..fd153df --- /dev/null +++ b/spec/graphql/resolvers/data_api/usages/invoiced_resolver_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::Usages::InvoicedResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum) { + dataApiUsagesInvoiced(currency: $currency) { + collection { + startOfPeriodDt + endOfPeriodDt + billableMetricCode + amountCents + amountCurrency + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages_invoiced.json") } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/invoiced/") + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of usages invoiced" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + usages_invoiced_response = result["data"]["dataApiUsagesInvoiced"] + expect(usages_invoiced_response["collection"].first).to include( + { + "startOfPeriodDt" => "2024-01-01", + "endOfPeriodDt" => "2024-01-31", + "billableMetricCode" => "account_members", + "amountCurrency" => "EUR", + "amountCents" => "26600" + } + ) + end +end diff --git a/spec/graphql/resolvers/data_api/usages_resolver_spec.rb b/spec/graphql/resolvers/data_api/usages_resolver_spec.rb new file mode 100644 index 0000000..b9d960e --- /dev/null +++ b/spec/graphql/resolvers/data_api/usages_resolver_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DataApi::UsagesResolver, :premium do + let(:required_permission) { "data_api:view" } + let(:query) do + <<~GQL + query($currency: CurrencyEnum, $externalCustomerId: String, $billingEntityCode: String) { + dataApiUsages(currency: $currency, externalCustomerId: $externalCustomerId, billingEntityCode: $billingEntityCode) { + collection { + amountCurrency + amountCents + billableMetricCode + units + startOfPeriodDt + endOfPeriodDt + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages.json") } + let(:params) { {time_granularity: "daily", billing_entity_code: "code"} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/") + .with(query: params) + .to_return(status: 200, body: body_response, headers: {}) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "data_api:view" + + it "returns a list of usages" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {billingEntityCode: "code"} + ) + + usages_response = result["data"]["dataApiUsages"] + expect(usages_response["collection"].first).to include( + { + "startOfPeriodDt" => "2024-01-01", + "endOfPeriodDt" => "2024-01-31" + } + ) + end +end diff --git a/spec/graphql/resolvers/dunning_campaign_resolver_spec.rb b/spec/graphql/resolvers/dunning_campaign_resolver_spec.rb new file mode 100644 index 0000000..f7d3dda --- /dev/null +++ b/spec/graphql/resolvers/dunning_campaign_resolver_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DunningCampaignResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {dunningCampaignId: dunning_campaign.id} + ) + end + + let(:required_permission) { "dunning_campaigns:view" } + let(:query) do + <<~GQL + query($dunningCampaignId: ID!) { + dunningCampaign(id: $dunningCampaignId) { + id + customersCount + appliedToOrganization + bccEmails + code + daysBetweenAttempts + description + maxAttempts + name + createdAt + updatedAt + + thresholds { amountCents, currency } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:dunning_campaign) { create(:dunning_campaign, organization:, bcc_emails: %w[earl@example.com]) } + let(:dunning_campaign_threshold) { create(:dunning_campaign_threshold, dunning_campaign:) } + + before do + dunning_campaign + dunning_campaign_threshold + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "dunning_campaigns:view" + + it "returns a single dunning campaign" do + dunning_campaign_response = result["data"]["dunningCampaign"] + + expect(dunning_campaign_response).to include( + { + "id" => dunning_campaign.id, + "customersCount" => 0, + "appliedToOrganization" => organization.default_billing_entity.applied_dunning_campaign_id == dunning_campaign.id, + "bccEmails" => dunning_campaign.bcc_emails, + "code" => dunning_campaign.code, + "daysBetweenAttempts" => dunning_campaign.days_between_attempts, + "description" => dunning_campaign.description, + "maxAttempts" => dunning_campaign.max_attempts, + "name" => dunning_campaign.name, + "thresholds" => [ + { + "amountCents" => dunning_campaign_threshold.amount_cents.to_s, + "currency" => dunning_campaign_threshold.currency + } + ] + } + ) + end + + context "when the campaign is applied on 2 out of 3 billing entities of the organization" do + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + let(:another_dunning_campaign) { create(:dunning_campaign, organization:) } + let(:billing_entity_2) { create(:billing_entity, organization:) } + let(:billing_entity_3) { create(:billing_entity, organization:) } + + before do + create(:customer, organization:, exclude_from_dunning_campaign: true) + create(:customer, organization:, applied_dunning_campaign: dunning_campaign) + create(:customer, organization:, applied_dunning_campaign: another_dunning_campaign) + create(:customer, organization:) + create(:customer, organization:, billing_entity: billing_entity_2) + create(:customer, organization:, billing_entity: billing_entity_3) + + organization.default_billing_entity.update!(applied_dunning_campaign: dunning_campaign) + billing_entity_2.update!(applied_dunning_campaign: dunning_campaign) + billing_entity_3.update!(applied_dunning_campaign: another_dunning_campaign) + end + + it "includes all customers defaulting to billing entities with the campaign applied in customers_count" do + expect(result["data"]["dunningCampaign"]["customersCount"]).to eq(3) + end + end + + context "when the campaign is not applied on any billing entity" do + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + let(:another_dunning_campaign) { create(:dunning_campaign, organization:) } + + before do + create(:customer, organization:, exclude_from_dunning_campaign: true) + create(:customer, organization:, applied_dunning_campaign: dunning_campaign) + create(:customer, organization:, applied_dunning_campaign: another_dunning_campaign) + create(:customer, organization:) + end + + it "does not includes customers defaulting to organizations default in customers_count" do + expect(result["data"]["dunningCampaign"]["customersCount"]).to eq(1) + end + end + + context "when dunning campaign is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {dunningCampaignId: "unknown"} + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/dunning_campaigns_resolver_spec.rb b/spec/graphql/resolvers/dunning_campaigns_resolver_spec.rb new file mode 100644 index 0000000..4010348 --- /dev/null +++ b/spec/graphql/resolvers/dunning_campaigns_resolver_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::DunningCampaignsResolver do + let(:required_permission) { "dunning_campaigns:view" } + let(:query) do + <<~GQL + query { + dunningCampaigns(limit: 5) { + collection { id name } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + + before { dunning_campaign } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "dunning_campaigns:view" + + it "returns a list of dunning campaigns" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + dunning_campaigns_response = result["data"]["dunningCampaigns"] + + expect(dunning_campaigns_response["collection"].first).to include( + "id" => dunning_campaign.id, + "name" => dunning_campaign.name + ) + + expect(dunning_campaigns_response["metadata"]).to include( + "currentPage" => 1, + "totalCount" => 1 + ) + end + + context "when filtering by threshold currency" do + let(:query) do + <<~GQL + query { + dunningCampaigns(limit: 5, currency: [EUR]) { + collection { id name } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + create(:dunning_campaign, organization:) + create(:dunning_campaign_threshold, dunning_campaign:, currency: "EUR") + end + + it "returns all dunning campaigns with currency threshold" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + dunning_campaigns_response = result["data"]["dunningCampaigns"] + + expect(dunning_campaigns_response["collection"].first).to include( + "id" => dunning_campaign.id, + "name" => dunning_campaign.name + ) + + expect(dunning_campaigns_response["metadata"]).to include( + "currentPage" => 1, + "totalCount" => 1 + ) + end + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql(current_user: membership.user, query:) + + expect_graphql_error(result:, message: "Missing organization id") + end + end + + context "when not member of the organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: create(:organization), + permissions: required_permission, + query: + ) + + expect_graphql_error(result:, message: "Not in organization") + end + end +end diff --git a/spec/graphql/resolvers/entitlement/feature_resolver_spec.rb b/spec/graphql/resolvers/entitlement/feature_resolver_spec.rb new file mode 100644 index 0000000..5352654 --- /dev/null +++ b/spec/graphql/resolvers/entitlement/feature_resolver_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Entitlement::FeatureResolver, :premium do + subject { execute_query(query:, variables:) } + + let(:organization) { create(:organization) } + let(:required_permission) { "features:view" } + let(:query) do + <<~GQL + query($featureId: ID!) { + feature(id: $featureId) { + id + code + name + description + privileges { + id + code + name + valueType + config { selectOptions } + } + createdAt + } + } + GQL + end + let(:variables) { {featureId: feature.id} } + let(:feature) { create(:feature, organization:) } + + before do + feature + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "features:view" + + it "returns a single feature" do + privilege = create(:privilege, feature:, value_type: "boolean") + + result = subject + + feature_response = result["data"]["feature"] + + expect(feature_response["id"]).to eq(feature.id) + expect(feature_response["code"]).to eq(feature.code) + expect(feature_response["name"]).to eq(feature.name) + expect(feature_response["description"]).to eq(feature.description) + expect(feature_response["createdAt"]).to be_present + expect(feature_response["privileges"].count).to eq(1) + expect(feature_response["privileges"].first["id"]).to eq(privilege.id) + expect(feature_response["privileges"].first["code"]).to eq(privilege.code) + expect(feature_response["privileges"].first["valueType"]).to eq(privilege.value_type) + expect(feature_response["privileges"].first["config"]).to eq({"selectOptions" => nil}) + end + + context "when feature is not found" do + let(:variables) { {featureId: "invalid"} } + + it "returns an error" do + expect_graphql_error(result: subject, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/entitlement/features_resolver_spec.rb b/spec/graphql/resolvers/entitlement/features_resolver_spec.rb new file mode 100644 index 0000000..2bc76df --- /dev/null +++ b/spec/graphql/resolvers/entitlement/features_resolver_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Entitlement::FeaturesResolver, :premium do + subject { execute_query(query:) } + + let(:organization) { create(:organization) } + let(:required_permission) { "features:view" } + let(:query) do + <<~GQL + query { + features(limit: 5) { + collection { + id + code + name + description + privileges { + id + code + name + valueType + config { selectOptions } + } + createdAt + } + metadata { currentPage totalCount } + } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "features:view" + + it do + expect(described_class).to accept_argument(:limit).of_type("Int") + expect(described_class).to accept_argument(:page).of_type("Int") + expect(described_class).to accept_argument(:search_term).of_type("String") + end + + it "returns a list of features" do + feature_with_privilege = create(:feature, organization:) + privilege = create(:privilege, feature: feature_with_privilege, value_type: "boolean") + + feature_without_privilege = create(:feature, organization:) + + result = subject + + expect(result["data"]["features"]["collection"].count).to eq(organization.features.count) + + # Check feature with privilege + feature_with_privilege_data = result["data"]["features"]["collection"].find { |f| f["code"] == feature_with_privilege.code } + expect(feature_with_privilege_data["code"]).to eq(feature_with_privilege.code) + expect(feature_with_privilege_data["name"]).to eq(feature_with_privilege.name) + expect(feature_with_privilege_data["description"]).to eq(feature_with_privilege.description) + expect(feature_with_privilege_data["privileges"].count).to eq(1) + expect(feature_with_privilege_data["privileges"].first["code"]).to eq(privilege.code) + expect(feature_with_privilege_data["privileges"].first["valueType"]).to eq(privilege.value_type) + + # Check feature without privilege + feature_without_privilege_data = result["data"]["features"]["collection"].find { |f| f["code"] == feature_without_privilege.code } + expect(feature_without_privilege_data["code"]).to eq(feature_without_privilege.code) + expect(feature_without_privilege_data["privileges"].count).to eq(0) + + expect(result["data"]["features"]["metadata"]["currentPage"]).to eq(1) + expect(result["data"]["features"]["metadata"]["totalCount"]).to eq(2) + end + + it "does not trigger N+1 queries for privileges", :bullet do + features = create_list(:feature, 3, organization:) + features.each do |feature| + create(:privilege, feature:) + end + + subject + end + + context "when search_term is provided" do + let(:query) do + <<~GQL + query { + features(limit: 5, searchTerm: "testtest") { + collection { + id + code + name + description + privileges { + id + code + name + valueType + config { selectOptions } + } + createdAt + } + metadata { currentPage totalCount } + } + } + GQL + end + + it "returns features matching the search term" do + create(:feature, organization:) + feature1 = create(:feature, organization:, code: "testtest1", name: "Test Feature 1") + + result = subject + + expect(result["data"]["features"]["collection"].sole["code"]).to eq(feature1.code) + end + end +end diff --git a/spec/graphql/resolvers/entitlement/subscription_entitlement_resolver_spec.rb b/spec/graphql/resolvers/entitlement/subscription_entitlement_resolver_spec.rb new file mode 100644 index 0000000..570b546 --- /dev/null +++ b/spec/graphql/resolvers/entitlement/subscription_entitlement_resolver_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Entitlement::SubscriptionEntitlementResolver, :premium do + subject { execute_query(query:, variables:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan:) } + let(:required_permission) { "subscriptions:view" } + let(:query) do + <<~GQL + query($subscriptionId: ID!, $featureCode: String!) { + subscriptionEntitlement(subscriptionId: $subscriptionId, featureCode: $featureCode) { + code + name + description + privileges { + code + name + valueType + value + config { selectOptions } + } + } + } + GQL + end + + let(:feature) { create(:feature, code: "seats", organization:) } + let(:privilege) { create(:privilege, feature: feature, code: "max", value_type: "integer") } + + let(:variables) { {subscriptionId: subscription.id, featureCode: feature.code} } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:view" + + it do + expect(described_class).to accept_argument(:subscription_id).of_type("ID!") + expect(described_class).to accept_argument(:feature_code).of_type("String!") + end + + context "when subscription has no features" do + it "returns an error" do + expect_graphql_error(result: subject, message: "Resource not found") + end + end + + context "when subscription has features" do + let(:entitlement) { create(:entitlement, feature:, plan:) } + + before do + create(:entitlement_value, entitlement:, privilege:, value: 10) + end + + it "returns the entitlement" do + result = subject + data = result["data"]["subscriptionEntitlement"] + + expect(data).to eq({ + "code" => "seats", + "name" => "Feature Name", + "description" => "Feature Description", + "privileges" => [ + { + "code" => "max", + "name" => nil, + "valueType" => "integer", + "value" => "10", + "config" => { + "selectOptions" => nil + } + } + ] + }) + end + + context "when privilege is boolean" do + let(:enabled) { create(:privilege, feature:, code: "enabled", value_type: "boolean") } + let(:beta) { create(:privilege, feature:, code: "beta", value_type: "boolean") } + let(:enabled_value) { create(:entitlement_value, entitlement:, privilege: enabled, value: true) } + let(:beta_value) { create(:entitlement_value, entitlement:, privilege: beta, value: false) } + + it "casts boolean values to strings" do + expect(enabled_value.value).to eq("t") + expect(beta_value.value).to eq("f") + + result = subject + data = result["data"]["subscriptionEntitlement"] + expect(data["privileges"].map { |p| p["value"] }).to contain_exactly("10", "true", "false") + end + end + + context "when requesting a feature not on the subscription" do + let(:other_feature) { create(:feature, code: "new", organization:) } + let(:variables) { {subscriptionId: subscription.id, featureCode: other_feature.code} } + + it "returns an error" do + expect_graphql_error(result: subject, message: "Resource not found") + end + end + end +end diff --git a/spec/graphql/resolvers/entitlement/subscription_entitlements_resolver_spec.rb b/spec/graphql/resolvers/entitlement/subscription_entitlements_resolver_spec.rb new file mode 100644 index 0000000..762c8c6 --- /dev/null +++ b/spec/graphql/resolvers/entitlement/subscription_entitlements_resolver_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Entitlement::SubscriptionEntitlementsResolver, :premium do + subject { execute_query(query:, variables:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan:) } + let(:required_permission) { "subscriptions:view" } + let(:query) do + <<~GQL + query($subscriptionId: ID!) { + subscriptionEntitlements(subscriptionId: $subscriptionId) { + collection { + code + name + description + privileges { + code + name + valueType + config { selectOptions } + } + } + } + } + GQL + end + + let(:variables) { {subscriptionId: subscription.id} } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:view" + + it do + expect(described_class).to accept_argument(:subscription_id).of_type("ID!") + end + + context "when subscription has no features" do + it "returns an empty list" do + result = subject + expect(result["data"]["subscriptionEntitlements"]["collection"]).to be_empty + end + end + + context "when subscription has features" do + it "returns entitlements" do + feature = create(:feature, organization:, code: "seats") + privilege_1 = create(:privilege, feature:, code: "max", value_type: "integer") + privilege_2 = create(:privilege, feature:, code: "root", value_type: "boolean") + privilege_3 = create(:privilege, feature:, code: "reset", value_type: "select", config: {select_options: %w[yes no]}) + + entitlement = create(:entitlement, feature:, plan:) + create(:entitlement_value, entitlement:, privilege: privilege_1, value: 10) + create(:entitlement_value, entitlement:, privilege: privilege_2, value: true) + create(:entitlement_value, entitlement:, privilege: privilege_3, value: "yes") + + sub_entitlement_1 = create(:entitlement, feature:, plan: nil, subscription:) + create(:entitlement_value, entitlement: sub_entitlement_1, privilege: privilege_1, value: 99) + + feature_2 = create(:feature, organization:, code: "storage") + privilege_2_1 = create(:privilege, feature: feature_2, code: "limit", value_type: "integer") + sub_entitlement_2 = create(:entitlement, feature: feature_2, subscription:, plan: nil) + create(:entitlement_value, entitlement: sub_entitlement_2, privilege: privilege_2_1, value: 1_000) + + feature_3 = create(:feature, organization:) + create(:entitlement) { create(:entitlement, organization:, feature: feature_3, plan: create(:plan, organization:)) } + + result = subject + data = result["data"]["subscriptionEntitlements"]["collection"] + expect(data.count).to eq(2) + + expect(data).to contain_exactly({ + "code" => "seats", + "name" => "Feature Name", + "description" => "Feature Description", + "privileges" => [ + { + "code" => "max", + "name" => nil, + "valueType" => "integer", + "config" => { + "selectOptions" => nil + } + }, + { + "code" => "root", + "name" => nil, + "valueType" => "boolean", + "config" => { + "selectOptions" => nil + } + }, + { + "code" => "reset", + "name" => nil, + "valueType" => "select", + "config" => { + "selectOptions" => [ + "yes", + "no" + ] + } + } + ] + }, { + "code" => "storage", + "name" => "Feature Name", + "description" => "Feature Description", + "privileges" => [ + { + "code" => "limit", + "name" => nil, + "valueType" => "integer", + "config" => { + "selectOptions" => nil + } + } + ] + }) + end + end + + it "does not trigger N+1 queries for privileges", :bullet do + features = create_list(:feature, 3, organization:) + features.each do |feature| + privilege = create(:privilege, feature:) + entitlement = Entitlement::Entitlement.create(organization:, feature: feature, plan:) + Entitlement::EntitlementValue.create(organization:, entitlement:, privilege:, value: "val") + end + + subject + end +end diff --git a/spec/graphql/resolvers/event_resolver_spec.rb b/spec/graphql/resolvers/event_resolver_spec.rb new file mode 100644 index 0000000..d47e01b --- /dev/null +++ b/spec/graphql/resolvers/event_resolver_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::EventResolver, transaction: false do + let(:query) do + <<~GQL + query($eventTransactionId: ID!) { + event(transactionId: $eventTransactionId) { + id + code + transactionId + externalSubscriptionId + timestamp + receivedAt + customerTimezone + ipAddress + apiClient + payload + billableMetricName + matchBillableMetric + matchCustomField + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + + let(:event) do + create( + :event, + code: billable_metric.code, + organization:, + external_subscription_id: subscription.external_id, + timestamp: 2.days.ago, + properties: {foo_bar: 1234}, + metadata: {user_agent: "Lago Ruby v0.0.1", ip_address: "182.11.32.11"} + ) + end + + before { event } + + it "returns a single event" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {eventTransactionId: event.transaction_id} + ) + + event_response = result["data"]["event"] + expect(event_response["id"]).to eq(event.id) + expect(event_response["code"]).to eq(event.code) + end + + context "with clickhouse", clickhouse: true do + let(:event) do + create(:clickhouse_events_raw, organization_id: organization.id) + end + + before { organization.update!(clickhouse_events_store: true) } + + it "returns a single event" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {eventTransactionId: event.transaction_id} + ) + + event_response = result["data"]["event"] + expect(event_response["id"]).to eq(event.id) + expect(event_response["code"]).to eq(event.code) + end + end + + context "when event is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {eventTransactionId: "non_existing"} + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/event_types_resolver_spec.rb b/spec/graphql/resolvers/event_types_resolver_spec.rb new file mode 100644 index 0000000..d91deb8 --- /dev/null +++ b/spec/graphql/resolvers/event_types_resolver_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::EventTypesResolver do + let(:required_permission) { "developers:manage" } + let(:user) { create(:user) } + let(:query) do + <<~GQL + query { + eventTypes { name description category deprecated key } + } + GQL + end + + it_behaves_like "requires current user" + it_behaves_like "requires permission", "developers:manage" + + it "returns all supported event types" do + result = execute_graphql( + current_user: user, + permissions: required_permission, + query: + ) + + event_types_response = result["data"]["eventTypes"].map { |event_type| event_type["name"] } + expect(event_types_response).to match_array(WebhookEndpoint::WEBHOOK_EVENT_TYPES) + end +end diff --git a/spec/graphql/resolvers/events_resolver_spec.rb b/spec/graphql/resolvers/events_resolver_spec.rb new file mode 100644 index 0000000..982f27e --- /dev/null +++ b/spec/graphql/resolvers/events_resolver_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::EventsResolver, clickhouse: true, transaction: false do + let(:query) do + <<~GQL + query($page: Int) { + events(page: $page, limit: 5) { + collection { + id + code + transactionId + externalSubscriptionId + timestamp + receivedAt + customerTimezone + ipAddress + apiClient + payload + billableMetricName + matchBillableMetric + matchCustomField + } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:organization) { create(:organization) } + let(:user) { create(:user) } + let(:membership) { create(:membership, user:, organization:) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + + let(:event) do + create( + :event, + code: billable_metric.code, + organization:, + external_subscription_id: subscription.external_id, + timestamp: 2.days.ago, + properties: {foo_bar: 1234}, + metadata: {user_agent: "Lago Ruby v0.0.1", ip_address: "182.11.32.11"} + ) + end + + before do + event + membership + end + + it "returns a list of events" do + result = execute_graphql( + current_user: user, + current_organization: organization, + query: + ) + + events_response = result["data"]["events"] + + expect(events_response["collection"].count).to eq(Event.where(organization_id: organization.id).count) + expect(events_response["collection"].first["id"]).to eq(event.id) + expect(events_response["collection"].first["code"]).to eq(event.code) + expect(events_response["collection"].first["externalSubscriptionId"]).to eq(subscription.external_id) + expect(events_response["collection"].first["transactionId"]).to eq(event.transaction_id) + expect(events_response["collection"].first["timestamp"]).to eq(event.timestamp.iso8601) + expect(events_response["collection"].first["receivedAt"]).to eq(event.created_at.iso8601) + expect(events_response["collection"].first["customerTimezone"]).to eq("TZ_UTC") + expect(events_response["collection"].first["ipAddress"]).to eq(event.metadata["ip_address"]) + expect(events_response["collection"].first["apiClient"]).to eq(event.metadata["user_agent"]) + expect(events_response["collection"].first["payload"]).to be_present + expect(events_response["collection"].first["billableMetricName"]).to eq(billable_metric.name) + expect(events_response["collection"].first["matchBillableMetric"]).to be_truthy + expect(events_response["collection"].first["matchCustomField"]).to be_truthy + end + + context "with a deleted billable metric" do + it "does not return duplicated events" do + billable_metric.discard! + create(:billable_metric, organization:, code: billable_metric.code) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: + ) + + events_response = result["data"]["events"] + + expect(events_response["collection"].count).to eq(Event.where(organization_id: organization.id).count) + expect(events_response["collection"].first["id"]).to eq(event.id) + end + end + + context "with missing billable_metric" do + let(:event) do + create( + :event, + code: "foo", + organization:, + timestamp: 2.days.ago, + properties: {foo_bar: 1234} + ) + end + + it "returns a list of events" do + event + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: + ) + + events_response = result["data"]["events"] + expect(events_response["collection"].first["matchBillableMetric"]).to be_falsey + end + end + + context "with missing custom field" do + let(:billable_metric) { create(:billable_metric, organization:, field_name: "mandatory") } + + it "returns a list of events" do + event + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: + ) + + events_response = result["data"]["events"] + expect(events_response["collection"].first["matchCustomField"]).to be_falsey + end + end + + context "with clickhouse event store" do + let(:organization) { create(:organization, clickhouse_events_store: true) } + + let(:event) do + Clickhouse::EventsRaw.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: 2.days.ago, + properties: {}, + precise_total_amount_cents: 12, + ingested_at: 2.days.ago + ) + end + + it "returns a list of events" do + result = execute_graphql( + current_user: user, + current_organization: organization, + query: + ) + + events_response = result["data"]["events"] + + expect(events_response["collection"].count).to eq(Clickhouse::EventsRaw.where(organization_id: organization.id).count) + expect(events_response["collection"].first["id"]).to eq(event.id) + expect(events_response["collection"].first["code"]).to eq(event.code) + expect(events_response["collection"].first["externalSubscriptionId"]).to eq(subscription.external_id) + expect(events_response["collection"].first["transactionId"]).to eq(event.transaction_id) + expect(events_response["collection"].first["timestamp"]).to eq(event.timestamp.iso8601) + expect(events_response["collection"].first["receivedAt"]).to eq(event.created_at.iso8601) + expect(events_response["collection"].first["customerTimezone"]).to eq("TZ_UTC") + expect(events_response["collection"].first["ipAddress"]).to be_nil + expect(events_response["collection"].first["apiClient"]).to be_nil + expect(events_response["collection"].first["payload"]).to be_present + expect(events_response["collection"].first["billableMetricName"]).to eq(billable_metric.name) + expect(events_response["collection"].first["matchBillableMetric"]).to be_truthy + expect(events_response["collection"].first["matchCustomField"]).to be_truthy + end + + context "when querying an empty page" do + it "returns an empty list of events" do + result = execute_graphql( + current_user: user, + current_organization: organization, + query:, + variables: {page: 5} + ) + + events_response = result["data"]["events"] + expect(events_response["collection"].count).to be_zero + end + end + end +end diff --git a/spec/graphql/resolvers/integration_collection_mappings_resolver_spec.rb b/spec/graphql/resolvers/integration_collection_mappings_resolver_spec.rb new file mode 100644 index 0000000..0fbe69c --- /dev/null +++ b/spec/graphql/resolvers/integration_collection_mappings_resolver_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::IntegrationCollectionMappingsResolver do + let(:required_permission) { "organization:integrations:view" } + let(:query) do + <<~GQL + query($integrationId: ID!) { + integrationCollectionMappings(integrationId: $integrationId) { + collection { id mappingType externalId taxCode currencies { currencyCode currencyExternalCode} } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:netsuite_integration, organization:) } + let(:netsuite_collection_mapping) { create(:netsuite_collection_mapping, integration:) } + + before { netsuite_collection_mapping } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:view" + + it "returns a list of mappings" do + result = execute_query(query:, variables: {integrationId: integration.id}) + + integration_collection_mappings_response = result["data"]["integrationCollectionMappings"] + + mapping = integration_collection_mappings_response["collection"].sole + expect(mapping["id"]).to eq(netsuite_collection_mapping.id) + expect(mapping["mappingType"]).to eq(netsuite_collection_mapping.mapping_type) + expect(mapping["externalId"]).to eq("netsuite-123") + expect(mapping["taxCode"]).to eq("tax-code-1") + expect(mapping["currencies"]).to be_nil + + expect(integration_collection_mappings_response["metadata"]["currentPage"]).to eq(1) + expect(integration_collection_mappings_response["metadata"]["totalCount"]).to eq(1) + end + + context "when a currency mapping" do + let(:netsuite_collection_mapping) { create(:netsuite_currencies_mapping, integration:) } + + it do + result = execute_query(query:, variables: {integrationId: integration.id}) + + integration_collection_mappings_response = result["data"]["integrationCollectionMappings"] + + mapping = integration_collection_mappings_response["collection"].sole + expect(mapping["id"]).to eq(netsuite_collection_mapping.id) + expect(mapping["mappingType"]).to eq("currencies") + expect(mapping["externalId"]).to be_nil + expect(mapping["taxCode"]).to be_nil + + expect(mapping["currencies"]).to contain_exactly( + {"currencyCode" => "EUR", "currencyExternalCode" => "3"}, + {"currencyCode" => "USD", "currencyExternalCode" => "7"} + ) + end + end + + context "when the integration id is not provided" do + it "returns an error" do + result = execute_query(query:) + expect(result["errors"]).to eq([ + { + "extensions" => { + "problems" => + [ + {"explanation" => "Expected value to not be null", "path" => []} + ], + "value" => nil + }, + "locations" => [{"column" => 7, "line" => 1}], + "message" => "Variable $integrationId of type ID! was provided invalid value" + } + ]) + end + end +end diff --git a/spec/graphql/resolvers/integration_items_resolver_spec.rb b/spec/graphql/resolvers/integration_items_resolver_spec.rb new file mode 100644 index 0000000..e69b6ec --- /dev/null +++ b/spec/graphql/resolvers/integration_items_resolver_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::IntegrationItemsResolver do + let(:required_permission) { "organization:integrations:view" } + let(:query) do + <<~GQL + query($integrationId: ID!, $itemType: IntegrationItemTypeEnum) { + integrationItems(integrationId: $integrationId, itemType: $itemType, limit: 5) { + collection { id externalId itemType externalName } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:integration_item) { create(:integration_item, integration:) } + let(:integration_item2) { create(:integration_item, item_type: "tax", integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + before do + integration_item + integration_item2 + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:view" + + it "returns a list of integration items" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + integrationId: integration.id, + itemType: "tax" + } + ) + + integration_items_response = result["data"]["integrationItems"] + + expect(integration_items_response["collection"].count).to eq(1) + expect(integration_items_response["collection"].first["id"]).to eq(integration_item2.id) + + expect(integration_items_response["metadata"]["currentPage"]).to eq(1) + expect(integration_items_response["metadata"]["totalCount"]).to eq(1) + end + + context "without integration id" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + permissions: required_permission, + query: + ) + + expect_graphql_error( + result:, + message: "Variable $integrationId of type ID! was provided invalid value" + ) + end + end +end diff --git a/spec/graphql/resolvers/integration_resolver_spec.rb b/spec/graphql/resolvers/integration_resolver_spec.rb new file mode 100644 index 0000000..88f1b13 --- /dev/null +++ b/spec/graphql/resolvers/integration_resolver_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::IntegrationResolver do + let(:required_permission) { "organization:integrations:view" } + let(:query) do + <<~GQL + query($integrationId: ID!) { + integration(id: $integrationId) { + ... on NetsuiteIntegration { + id + code + name + scriptEndpointUrl + __typename + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:netsuite_integration) { create(:netsuite_integration, organization:) } + + before do + customer + netsuite_integration + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:view" + + it "returns a single integration" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {integrationId: netsuite_integration.id} + ) + + integration_response = result["data"]["integration"] + + expect(integration_response["id"]).to eq(netsuite_integration.id) + expect(integration_response["code"]).to eq(netsuite_integration.code) + expect(integration_response["name"]).to eq(netsuite_integration.name) + expect(integration_response["scriptEndpointUrl"]).to eq(netsuite_integration.script_endpoint_url) + end + + context "when integration is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {integrationId: "foo"} + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/integrations/subsidiaries_resolver_spec.rb b/spec/graphql/resolvers/integrations/subsidiaries_resolver_spec.rb new file mode 100644 index 0000000..76960c0 --- /dev/null +++ b/spec/graphql/resolvers/integrations/subsidiaries_resolver_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Integrations::SubsidiariesResolver do + let(:required_permission) { "organization:integrations:view" } + let(:query) do + <<~GQL + query($integrationId: ID!) { + integrationSubsidiaries(integrationId: $integrationId) { + collection { externalId externalName } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:netsuite_integration, organization:) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:subsidiaries_endpoint) { "https://api.nango.dev/v1/netsuite/subsidiaries" } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "netsuite-tba" + } + end + + let(:aggregator_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/subsidiaries_response.json") + JSON.parse(File.read(path)) + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(subsidiaries_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:get) + .with(headers:) + .and_return(aggregator_response) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:view" + + it "returns a list of subsidiaries" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {integrationId: integration.id} + ) + + subsidiaries = result["data"]["integrationSubsidiaries"] + + expect(subsidiaries["collection"].count).to eq(4) + expect(subsidiaries["collection"].first["externalId"]).to eq("1") + end +end diff --git a/spec/graphql/resolvers/integrations_resolver_spec.rb b/spec/graphql/resolvers/integrations_resolver_spec.rb new file mode 100644 index 0000000..0e25701 --- /dev/null +++ b/spec/graphql/resolvers/integrations_resolver_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::IntegrationsResolver do + let(:required_permission) { "customers:view" } + let(:query) do + <<~GQL + query { + integrations(limit: 5) { + collection { + ... on NetsuiteIntegration { + id + code + __typename + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:netsuite_integration) { create(:netsuite_integration, organization:) } + let(:xero_integration) { create(:xero_integration, organization:) } + + before do + netsuite_integration + xero_integration + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", %w[customers:view organization:integrations:view] + + context "when types is present" do + let(:query) do + <<~GQL + query { + integrations(limit: 5, types: [netsuite]) { + collection { + ... on NetsuiteIntegration { + id + code + __typename + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns a list of integrations" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + integrations_response = result["data"]["integrations"] + + expect(integrations_response["collection"].count).to eq(1) + expect(integrations_response["collection"].first["id"]).to eq(netsuite_integration.id) + + expect(integrations_response["metadata"]["currentPage"]).to eq(1) + expect(integrations_response["metadata"]["totalCount"]).to eq(1) + end + end +end diff --git a/spec/graphql/resolvers/invite_resolver_spec.rb b/spec/graphql/resolvers/invite_resolver_spec.rb new file mode 100644 index 0000000..053cda0 --- /dev/null +++ b/spec/graphql/resolvers/invite_resolver_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::InviteResolver do + let(:query) do + <<~GQL + query($token: String!) { + invite(token: $token) { + id + token + email + organization { + id + name + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invite) { create(:invite, organization:) } + + it "returns a single invite" do + result = execute_graphql( + query:, + variables: { + token: invite.token + } + ) + + data = result["data"]["invite"] + + expect(data["token"]).to eq(invite.token) + expect(data["email"]).to eq(invite.email) + expect(data["organization"]["name"]).to eq(organization.name) + end + + context "when invite is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: invite.organization, + query:, + variables: { + token: "foo" + } + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/invites_resolver_spec.rb b/spec/graphql/resolvers/invites_resolver_spec.rb new file mode 100644 index 0000000..6588fd5 --- /dev/null +++ b/spec/graphql/resolvers/invites_resolver_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::InvitesResolver do + let(:query) do + <<~GQL + query { + invites(limit: 5) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invite) { create(:invite, organization:) } + + it "returns a list of invites" do + result = execute_graphql( + current_user: membership.user, + current_organization: invite.organization, + query: + ) + + invites_response = result["data"]["invites"] + + expect(invites_response["collection"].count).to eq(organization.invites.count) + expect(invites_response["collection"].first["id"]).to eq(invite.id) + + expect(invites_response["metadata"]["currentPage"]).to eq(1) + expect(invites_response["metadata"]["totalCount"]).to eq(1) + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + query: + ) + + expect_graphql_error( + result:, + message: "Missing organization id" + ) + end + end +end diff --git a/spec/graphql/resolvers/invoice_credit_notes_resolver_spec.rb b/spec/graphql/resolvers/invoice_credit_notes_resolver_spec.rb new file mode 100644 index 0000000..e395945 --- /dev/null +++ b/spec/graphql/resolvers/invoice_credit_notes_resolver_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::InvoiceCreditNotesResolver do + let(:required_permission) { "credit_notes:view" } + let(:query) do + <<~GQL + query($invoiceId: ID!) { + invoiceCreditNotes(invoiceId: $invoiceId, limit: 5) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:subscription) { create(:subscription, customer:, organization:) } + let(:credit_note) { create(:credit_note, organization:, customer:, invoice:) } + + before do + subscription + credit_note + create(:credit_note, :draft, organization:, customer:, invoice:) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "credit_notes:view" + + it "returns a list of finalized credit_notes for an invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + invoiceId: invoice.id + } + ) + + credit_notes_response = result["data"]["invoiceCreditNotes"] + + expect(credit_notes_response["collection"].count).to eq(1) + expect(credit_notes_response["collection"].first["id"]).to eq(credit_note.id) + + expect(credit_notes_response["metadata"]["currentPage"]).to eq(1) + expect(credit_notes_response["metadata"]["totalCount"]).to eq(1) + end + + context "when invoice does not exists" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + invoiceId: "123456" + } + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/invoice_custom_section_resolver_spec.rb b/spec/graphql/resolvers/invoice_custom_section_resolver_spec.rb new file mode 100644 index 0000000..7b050e8 --- /dev/null +++ b/spec/graphql/resolvers/invoice_custom_section_resolver_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::InvoiceCustomSectionResolver do + let(:query) do + <<~GQL + query($invoiceCustomSectionId: ID!) { + invoiceCustomSection(id: $invoiceCustomSectionId) { + id code name description details displayName + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + + before { invoice_custom_section } + + it "returns a single invoice_custom_section" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {invoiceCustomSectionId: invoice_custom_section.id} + ) + + expect(result["data"]["invoiceCustomSection"]).to include( + "id" => invoice_custom_section.id, + "code" => invoice_custom_section.code, + "name" => invoice_custom_section.name, + "description" => invoice_custom_section.description, + "details" => invoice_custom_section.details, + "displayName" => invoice_custom_section.display_name + ) + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + query:, + variables: {invoiceCustomSectionId: invoice_custom_section.id} + ) + + expect_graphql_error(result:, message: "Missing organization id") + end + end + + context "when invoice_custom_section is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {invoiceCustomSectionId: "unknown"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/invoice_custom_sections_resolver_spec.rb b/spec/graphql/resolvers/invoice_custom_sections_resolver_spec.rb new file mode 100644 index 0000000..c33cf98 --- /dev/null +++ b/spec/graphql/resolvers/invoice_custom_sections_resolver_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::InvoiceCustomSectionsResolver do + let(:required_permission) { "invoice_custom_sections:view" } + let(:query) do + <<~GQL + query() { + invoiceCustomSections(limit: 5) { + collection { id, name } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:invoice_custom_section_1_manual) { create(:invoice_custom_section, organization:, name: "x") } + let(:invoice_custom_section_2_manual) { create(:invoice_custom_section, organization:, name: "a") } + let(:invoice_custom_section_3_manual) { create(:invoice_custom_section, organization:, name: "c") } + let(:invoice_custom_section_4_system_generated) { create(:invoice_custom_section, :system_generated, organization:, name: "not show") } + + before do + invoice_custom_section_1_manual + invoice_custom_section_2_manual + invoice_custom_section_3_manual + invoice_custom_section_4_system_generated + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoice_custom_sections:view" + + it "returns a list of sorted invoice_custom_sections: alphabetical" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoice_custom_sections_response = result["data"]["invoiceCustomSections"] + + expect(invoice_custom_sections_response["collection"].count).to eq(3) + expect(invoice_custom_sections_response["collection"].map { |ics| ics["name"] }.join("")).to eq("acx") + + expect(invoice_custom_sections_response["metadata"]["currentPage"]).to eq(1) + expect(invoice_custom_sections_response["metadata"]["totalCount"]).to eq(3) + end +end diff --git a/spec/graphql/resolvers/invoice_resolver_spec.rb b/spec/graphql/resolvers/invoice_resolver_spec.rb new file mode 100644 index 0000000..968ee3e --- /dev/null +++ b/spec/graphql/resolvers/invoice_resolver_spec.rb @@ -0,0 +1,480 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::InvoiceResolver do + let(:required_permission) { "invoices:view" } + let(:query) do + <<~GQL + query($id: ID!) { + invoice(id: $id) { + id + number + feesAmountCents + couponsAmountCents + creditNotesAmountCents + prepaidCreditAmountCents + refundableAmountCents + creditableAmountCents + paymentDisputeLosable + paymentStatus + status + customer { + id + name + deletedAt + } + appliedTaxes { + taxCode + taxName + taxRate + taxDescription + amountCents + amountCurrency + } + invoiceSubscriptions { + fromDatetime + toDatetime + chargesFromDatetime + chargesToDatetime + subscription { + id + } + fees { + currency + id + itemType + itemCode + itemName + charge { id billableMetric { code } } + taxesRate + taxesAmountCents + trueUpFee { id } + trueUpParentFee { id } + units + preciseUnitAmount + chargeFilter { invoiceDisplayName values } + presentationBreakdowns { presentationBy units } + appliedTaxes { + taxCode + taxName + taxRate + taxDescription + amountCents + amountCurrency + } + properties { + fromDatetime + toDatetime + } + } + } + subscriptions { + id + } + fees { + id + itemType + itemCode + itemName + creditableAmountCents + presentationBreakdowns { presentationBy units } + charge { + id + billableMetric { + code + filters { key values } + } + filters { invoiceDisplayName values } + } + pricingUnitUsage { + id + amountCents + conversionRate + preciseAmountCents + shortName + unitAmountCents + pricingUnit { + id + code + name + shortName + } + } + walletTransaction { + name + walletName + } + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:) } + let(:invoice) { create(:invoice, customer:, organization:, fees_amount_cents: 10) } + let(:subscription) { invoice_subscription.subscription } + let(:fee) do + create(:fee, subscription:, invoice:, amount_cents: 10, properties: { + from_datetime: Time.current.beginning_of_month, + to_datetime: Time.current.end_of_month, + charges_from_datetime: Time.current.beginning_of_month - 1.month, + charges_to_datetime: Time.current.end_of_month - 1.month, + fixed_charges_from_datetime: Time.current.beginning_of_month + 1.month, + fixed_charges_to_datetime: Time.current.end_of_month + 1.month + }, presentation_breakdowns: [build(:presentation_breakdown, organization:)]) + end + let(:charge_fee) do + create(:charge_fee, subscription:, invoice:, amount_cents: 10, properties: { + from_datetime: Time.current.beginning_of_month, + to_datetime: Time.current.end_of_month, + charges_from_datetime: Time.current.beginning_of_month - 1.month, + charges_to_datetime: Time.current.end_of_month - 1.month, + fixed_charges_from_datetime: Time.current.beginning_of_month + 1.month, + fixed_charges_to_datetime: Time.current.end_of_month + 1.month + }, presentation_breakdowns: [build(:presentation_breakdown, organization:)]) + end + let(:fixed_charge_fee) do + create(:fixed_charge_fee, subscription:, invoice:, amount_cents: 10, properties: { + from_datetime: Time.current.beginning_of_month, + to_datetime: Time.current.end_of_month, + charges_from_datetime: Time.current.beginning_of_month - 1.month, + charges_to_datetime: Time.current.end_of_month - 1.month, + fixed_charges_from_datetime: Time.current.beginning_of_month + 1.month, + fixed_charges_to_datetime: Time.current.end_of_month + 1.month + }, presentation_breakdowns: [build(:presentation_breakdown, organization:)]) + end + + before do + fee + charge_fee + fixed_charge_fee + invoice + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:view" + + it "returns a single invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + id: invoice.id + } + ) + + data = result["data"]["invoice"] + + expect(data["id"]).to eq(invoice.id) + expect(data["number"]).to eq(invoice.number) + expect(data["paymentStatus"]).to eq(invoice.payment_status) + expect(data["paymentDisputeLosable"]).to eq(true) + expect(data["status"]).to eq(invoice.status) + expect(data["customer"]["id"]).to eq(customer.id) + expect(data["customer"]["name"]).to eq(customer.name) + expect(data["invoiceSubscriptions"][0]["subscription"]["id"]).to eq(subscription.id) + + subscription_fee = data["invoiceSubscriptions"][0]["fees"].find { |f| f["itemType"] == "subscription" } + expect(subscription_fee["id"]).to eq(fee.id) + expect(subscription_fee["properties"]["fromDatetime"]).to eq(Time.current.beginning_of_month.to_datetime.iso8601) + expect(subscription_fee["properties"]["toDatetime"]).to eq(Time.current.end_of_month.to_datetime.iso8601) + expect(subscription_fee["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"department" => "engineering"}, "units" => "60.0"} + ]) + + charge_fee_result = data["invoiceSubscriptions"][0]["fees"].find { |f| f["itemType"] == "charge" } + expect(charge_fee_result["id"]).to eq(charge_fee.id) + expect(charge_fee_result["properties"]["fromDatetime"]).to eq((Time.current.beginning_of_month - 1.month).to_datetime.iso8601) + expect(charge_fee_result["properties"]["toDatetime"]).to eq((Time.current.end_of_month - 1.month).to_datetime.iso8601) + expect(charge_fee_result["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"department" => "engineering"}, "units" => "60.0"} + ]) + + fixed_charge_fee_result = data["invoiceSubscriptions"][0]["fees"].find { |f| f["itemType"] == "fixed_charge" } + expect(fixed_charge_fee_result["id"]).to eq(fixed_charge_fee.id) + expect(fixed_charge_fee_result["properties"]["fromDatetime"]).to eq((Time.current.beginning_of_month + 1.month).to_datetime.iso8601) + expect(fixed_charge_fee_result["properties"]["toDatetime"]).to eq((Time.current.end_of_month + 1.month).to_datetime.iso8601) + expect(fixed_charge_fee_result["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"department" => "engineering"}, "units" => "60.0"} + ]) + end + + it "includes filters for the fee" do + billable_metric_filter = create(:billable_metric_filter, key: "cloud", values: %w[aws gcp]) + charge_filter = create(:charge_filter, invoice_display_name: nil) + charge_filter_value = create(:charge_filter_value, billable_metric_filter:, charge_filter:, values: ["aws"]) + + fee.update!(charge_filter_id: charge_filter.id) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: invoice.id} + ) + + fee_result = result["data"]["invoice"]["invoiceSubscriptions"][0]["fees"].find { |f| f["id"] == fee.id } + expect(fee_result["chargeFilter"]["values"][billable_metric_filter.key]).to eq(charge_filter_value.values) + end + + it "includes pricing unit usage when available" do + pricing_unit = create(:pricing_unit, organization:) + billable_metric = create(:billable_metric, organization:) + charge = create(:standard_charge, billable_metric:) + applied_pricing_unit = create(:applied_pricing_unit, pricing_unit:, conversion_rate: 2.5) + + pricing_unit_usage = build( + :pricing_unit_usage, + pricing_unit:, + organization:, + short_name: pricing_unit.short_name, + conversion_rate: applied_pricing_unit.conversion_rate, + amount_cents: 40, + precise_amount_cents: 40.0, + unit_amount_cents: 20 + ) + + fee_with_usage = create( + :fee, + subscription:, + invoice:, + charge:, + amount_cents: 100, + organization:, + pricing_unit_usage:, + presentation_breakdowns: [build(:presentation_breakdown, organization:)] + ) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: invoice.id} + ) + + fees = result["data"]["invoice"]["fees"] + fee_data = fees.find { |f| f["id"] == fee_with_usage.id } + + expect(fee_data["pricingUnitUsage"]).to be_present + expect(fee_data["pricingUnitUsage"]["amountCents"]).to eq(pricing_unit_usage.amount_cents.to_s) + expect(fee_data["pricingUnitUsage"]["preciseAmountCents"]).to eq(pricing_unit_usage.precise_amount_cents) + expect(fee_data["pricingUnitUsage"]["unitAmountCents"]).to eq(pricing_unit_usage.unit_amount_cents.to_s) + expect(fee_data["pricingUnitUsage"]["conversionRate"]).to eq(pricing_unit_usage.conversion_rate) + expect(fee_data["pricingUnitUsage"]["shortName"]).to eq(pricing_unit_usage.short_name) + expect(fee_data["pricingUnitUsage"]["pricingUnit"]["code"]).to eq(pricing_unit.code) + expect(fee_data["pricingUnitUsage"]["pricingUnit"]["name"]).to eq(pricing_unit.name) + expect(fee_data["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"department" => "engineering"}, "units" => "60.0"} + ]) + end + + context "when invoice is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: invoice.organization, + permissions: required_permission, + query:, + variables: { + id: "foo" + } + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "with a deleted billable metric" do + let(:billable_metric) { create(:billable_metric, :deleted) } + let(:billable_metric_filter) { create(:billable_metric_filter, :deleted, billable_metric:) } + let(:charge_filter) do + create(:charge_filter, :deleted, charge:, properties: {amount: "10"}) + end + let(:charge_filter_value) do + create( + :charge_filter_value, + :deleted, + charge_filter:, + billable_metric_filter:, + values: [billable_metric_filter.values.first] + ) + end + let(:fee) do + create( + :charge_fee, + subscription:, + invoice:, + charge_filter:, + charge:, + amount_cents: 10, + presentation_breakdowns: [build(:presentation_breakdown, organization:)] + ) + end + + let(:charge) do + create(:standard_charge, :deleted, billable_metric:) + end + + it "returns the invoice with the deleted resources" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + id: invoice.id + } + ) + + data = result["data"]["invoice"] + + expect(data["id"]).to eq(invoice.id) + expect(data["number"]).to eq(invoice.number) + expect(data["paymentStatus"]).to eq(invoice.payment_status) + expect(data["status"]).to eq(invoice.status) + expect(data["customer"]["id"]).to eq(customer.id) + expect(data["customer"]["name"]).to eq(customer.name) + expect(data["invoiceSubscriptions"][0]["subscription"]["id"]).to eq(subscription.id) + expect(data["invoiceSubscriptions"][0]["fees"]).to include(a_hash_including("id" => fee.id)) + end + end + + context "with an add on invoice" do + let(:invoice) { create(:invoice, customer:, organization:, fees_amount_cents: 10) } + let(:add_on) { create(:add_on, organization:) } + let(:applied_add_on) { create(:applied_add_on, add_on:, customer:) } + let(:fee) do + create( + :add_on_fee, + invoice:, + applied_add_on:, + presentation_breakdowns: [build(:presentation_breakdown, organization:)] + ) + end + + it "returns a single invoice" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + id: invoice.id + } + ) + + data = result["data"]["invoice"] + + expect(data["id"]).to eq(invoice.id) + expect(data["number"]).to eq(invoice.number) + expect(data["paymentStatus"]).to eq(invoice.payment_status) + expect(data["status"]).to eq(invoice.status) + expect(data["customer"]["id"]).to eq(customer.id) + expect(data["customer"]["name"]).to eq(customer.name) + add_on_fee = data["fees"].find { |f| f["itemType"] == "add_on" } + expect(add_on_fee).to include( + "itemCode" => add_on.code, + "itemName" => add_on.name + ) + expect(add_on_fee["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"department" => "engineering"}, "units" => "60.0"} + ]) + end + + context "with a deleted add_on" do + let(:add_on) { create(:add_on, :deleted, organization:) } + + it "returns the invoice with the deleted resources" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + id: invoice.id + } + ) + + data = result["data"]["invoice"] + + expect(data["id"]).to eq(invoice.id) + expect(data["number"]).to eq(invoice.number) + expect(data["paymentStatus"]).to eq(invoice.payment_status) + expect(data["status"]).to eq(invoice.status) + expect(data["customer"]["id"]).to eq(customer.id) + expect(data["customer"]["name"]).to eq(customer.name) + add_on_fee = data["fees"].find { |f| f["itemType"] == "add_on" } + expect(add_on_fee).to include( + "itemCode" => add_on.code, + "itemName" => add_on.name + ) + expect(add_on_fee["presentationBreakdowns"]).to eq([ + {"presentationBy" => {"department" => "engineering"}, "units" => "60.0"} + ]) + end + end + end + + context "with a deleted customer" do + let(:customer) { create(:customer, :deleted, organization:) } + + it "returns the invoice with the deleted customer" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + id: invoice.id + } + ) + + data = result["data"]["invoice"] + + expect(data["id"]).to eq(invoice.id) + expect(data["number"]).to eq(invoice.number) + expect(data["paymentStatus"]).to eq(invoice.payment_status) + expect(data["status"]).to eq(invoice.status) + expect(data["customer"]["id"]).to eq(customer.id) + expect(data["customer"]["name"]).to eq(customer.name) + expect(data["customer"]["deletedAt"]).to eq(customer.deleted_at.iso8601) + end + end + + context "with a credit invoice" do + let(:invoice) { create(:invoice, :credit, customer:, organization:) } + let(:fee) do + create( + :credit_fee, + invoice:, + invoiceable: wallet_transaction, + presentation_breakdowns: [build(:presentation_breakdown, organization:)] + ) + end + let(:wallet_transaction) { create(:wallet_transaction, organization:, wallet:) } + let(:wallet) { create(:wallet, organization:, name: "wallet name") } + + before { fee } + + it "returns the invoice with the credit invoice" do + result = execute_query(query:, variables: {id: invoice.id}) + + data = result["data"]["invoice"] + graphql_wallet_transaction = data.dig("fees", 0, "walletTransaction") + expect(graphql_wallet_transaction["name"]).to eq("Custom Transaction Name") + expect(graphql_wallet_transaction["walletName"]).to eq("wallet name") + expect(data.dig("fees", 0, "presentationBreakdowns")).to eq([ + {"presentationBy" => {"department" => "engineering"}, "units" => "60.0"} + ]) + end + end +end diff --git a/spec/graphql/resolvers/invoices_resolver_spec.rb b/spec/graphql/resolvers/invoices_resolver_spec.rb new file mode 100644 index 0000000..7d498bc --- /dev/null +++ b/spec/graphql/resolvers/invoices_resolver_spec.rb @@ -0,0 +1,807 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::InvoicesResolver do + let(:required_permission) { "invoices:view" } + let(:query) do + <<~GQL + query { + invoices(limit: 5) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer_first) { create(:customer, organization:) } + let(:customer_second) { create(:customer, organization:) } + let(:invoice_first) do + create(:invoice, customer: customer_first, payment_status: :pending, status: :finalized, organization:) + end + let(:invoice_second) do + create(:invoice, customer: customer_second, payment_status: :succeeded, status: :finalized, organization:) + end + + before do + invoice_first + invoice_second + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "invoices:view" + + it "returns all invoices" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + returned_ids = invoices_response["collection"].map { |hash| hash["id"] } + + expect(invoices_response["collection"].count).to eq(2) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(2) + end + + context "when filtering by succeeded payment status" do + let(:query) do + <<~GQL + query { + invoices(limit: 5, paymentStatus: [succeeded]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns all succeeded invoices" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + returned_ids = invoices_response["collection"].map { |hash| hash["id"] } + + expect(invoices_response["collection"].count).to eq(1) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by draft status" do + let(:invoice_third) { create(:invoice, customer: customer_second, status: :draft, organization:) } + let(:query) do + <<~GQL + query { + invoices(limit: 5, status: [draft]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before { invoice_third } + + it "returns all draft invoices" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by payment dispute lost" do + let(:invoice_third) do + create( + :invoice, + customer: customer_second, + status: :draft, + organization: + ) + end + + let(:invoice_fourth) do + create( + :invoice, + :dispute_lost, + customer: customer_second, + status: :finalized, + organization: + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5, paymentDisputeLost: true) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + invoice_fourth + end + + it "returns all invoices with payment dispute lost" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_fourth.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by invoice type" do + let(:invoice_third) do + create( + :invoice, + customer: customer_second, + invoice_type: "one_off", + organization: + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5, invoiceType: [one_off]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + end + + it "returns all invoices with type one_off" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by currency" do + let(:invoice_third) do + create( + :invoice, + customer: customer_second, + organization:, + currency: "USD" + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5, currency: USD) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + end + + it "returns all invoices with currency USD" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by customer external id" do + let(:invoice_third) do + create( + :invoice, + customer: customer_third, + organization: + ) + end + + let(:customer_third) { create(:customer, organization:, external_id: "external_id") } + + let(:query) do + <<~GQL + query { + invoices(limit: 5, customerExternalId: "external_id") { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + end + + it 'returns all invoices with customer external id "external_id"' do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by partially paid" do + let(:invoice_third) do + create( + :invoice, + customer: customer_first, + organization:, + total_amount_cents: 1000, + total_paid_amount_cents: 10 + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5, partiallyPaid: true) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + end + + it "returns all partially paid invoices" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by positive due amount" do + let(:invoice_third) do + create( + :invoice, + customer: customer_first, + organization:, + total_amount_cents: 1000, + total_paid_amount_cents: 10 + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5, positiveDueAmount: #{positive_due_amount}) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + end + + before do + invoice_third + end + + context "when the flag is set to true" do + let(:positive_due_amount) { true } + + it "returns all invoices with due amount is greater than 0" do + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when the flag is set to false" do + let(:positive_due_amount) { false } + + it "returns all invoices with due amount is 0" do + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(2) + + expect(invoices_response["collection"].map { it["id"] }).to contain_exactly(invoice_first.id, invoice_second.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(2) + end + end + end + + context "when filtering by issuing date" do + let(:invoice_third) do + create( + :invoice, + customer: customer_second, + organization:, + issuing_date: 1.week.ago + ) + end + + let(:query) do + <<~GQL + query { + invoices( + limit: 5, + issuingDateFrom: "#{2.weeks.ago.to_date.iso8601}", + issuingDateTo: "#{1.week.ago.to_date.iso8601}" + ) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + end + + it "returns all invoices issued within the from and to dates" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "with both amount_from and amount_to" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + end + + let(:query) do + <<~GQL + query { + invoices( + limit: 5, + amountFrom: #{invoices.second.total_amount_cents}, + amountTo: #{invoices.fourth.total_amount_cents} + ) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let!(:invoices) do + (1..5).to_a.map do |i| + create(:invoice, total_amount_cents: i.succ * 1_000, organization:) + end # from smallest to biggest + end + + it "returns visible invoices total cents amount in provided range" do + collection = result["data"]["invoices"]["collection"] + + expect(collection.pluck("id")).to match_array invoices[1..3].pluck(:id) + end + end + + context "when filtering by self billed" do + let(:invoice_third) do + create( + :invoice, + customer: customer_second, + status: :draft, + organization: + ) + end + + let(:invoice_fourth) do + create( + :invoice, + :self_billed, + customer: customer_second, + status: :finalized, + organization: + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5, selfBilled: true) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + invoice_fourth + end + + it "returns all self billed invoices" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_fourth.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when preloading offset amounts" do + subject do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5) { + collection { id totalDueAmountCents totalSettledAmountCents } + metadata { currentPage, totalCount } + } + } + GQL + end + let(:preloadable_invoices) { [invoice_first, invoice_second] } + + include_examples "preloads offset amounts" + end + + context "when filters are invalid" do + let(:query) do + <<~GQL + query { + invoices(limit: 5, billingEntityIds: ["random"]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error(result:, message: "Unprocessable Entity") + end + end + + context "when filtering by billing_entity_id" do + let(:billing_entity2) { create(:billing_entity, organization:) } + let(:invoice_third) do + create( + :invoice, + customer: customer_second, + billing_entity: billing_entity2, + organization: + ) + end + + let(:query) do + <<~GQL + query { + invoices(limit: 5, billingEntityIds: ["#{billing_entity2.id}"]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + invoice_third + end + + it "returns all invoices for the specified billing entity" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_third.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by subscription_id" do + let(:invoice_with_subscription_1) { create(:invoice, :subscription, organization:) } + let(:invoice_with_subscription_2) { create(:invoice, :subscription, organization:) } + + let(:query) do + <<~GQL + query($subscriptionId: ID) { + invoices(subscriptionId: $subscriptionId) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:result) do + execute_query(query:, variables: {subscriptionId: invoice_with_subscription_1.subscriptions.first.id}) + end + + before do + invoice_with_subscription_1 + invoice_with_subscription_2 + end + + it "returns invoices for the specified subscription" do + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].count).to eq(1) + expect(invoices_response["collection"].first["id"]).to eq(invoice_with_subscription_1.id) + + expect(invoices_response["metadata"]["currentPage"]).to eq(1) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when filtering by settlements" do + let(:credit_note) { create(:credit_note, invoice: invoice_first, customer: invoice_first.customer, organization:) } + + before do + create( + :invoice_settlement, + organization:, + billing_entity: invoice_first.billing_entity, + target_invoice: invoice_first, + settlement_type: :credit_note, + source_credit_note: credit_note + ) + + create( + :invoice_settlement, + organization:, + billing_entity: invoice_second.billing_entity, + target_invoice: invoice_second, + settlement_type: :payment, + source_payment: create(:payment) + ) + end + + context "when settlements contains credit_note" do + let(:query) do + <<~GQL + query { + invoices(limit: 5, settlements: [credit_note]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns invoices with a credit note settlement" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + invoices_response = result["data"]["invoices"] + + expect(invoices_response["collection"].pluck("id")).to eq([invoice_first.id]) + expect(invoices_response["metadata"]["totalCount"]).to eq(1) + end + end + end + + context "with N+1 query detection on associations", bullet: {unused_eager_loading: false} do + let(:query) do + <<~GQL + query { + invoices(limit: 5) { + collection { + id + status + taxStatus + paymentStatus + paymentOverdue + number + issuingDate + totalAmountCents + totalDueAmountCents + totalPaidAmountCents + currency + voidable + paymentDisputeLostAt + taxProviderVoidable + invoiceType + creditableAmountCents + refundableAmountCents + offsettableAmountCents + associatedActiveWalletPresent + voidedInvoiceId + regeneratedInvoiceId + customer { + id + externalId + name + displayName + applicableTimezone + paymentProvider + hasActiveWallet + email + deletedAt + __typename + } + errorDetails { + errorCode + errorDetails + __typename + } + billingEntity { + id + name + code + email + einvoicing + emailSettings + __typename + } + payments { + createdAt + paymentMethodId + __typename + } + } + } + } + GQL + end + + it "does not trigger N+1 queries on associations" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(result["data"]["invoices"]["collection"].count).to eq(2) + end + end +end diff --git a/spec/graphql/resolvers/memberships_resolver_spec.rb b/spec/graphql/resolvers/memberships_resolver_spec.rb new file mode 100644 index 0000000..65e3a1d --- /dev/null +++ b/spec/graphql/resolvers/memberships_resolver_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::MembershipsResolver do + let(:query) do + <<~GQL + query { + memberships(limit: 5) { + collection { id } + metadata { currentPage, totalCount, adminCount } + } + } + GQL + end + + let(:membership) { create(:membership, roles: %i[admin]) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + + it "returns a list of memberships" do + create(:membership, organization: organization, roles: %i[admin]) + create_list(:membership, 2, organization: organization, roles: %i[finance]) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: + ) + + memberships_response = result["data"]["memberships"] + + expect(memberships_response["collection"].count).to eq(4) + expect(memberships_response["collection"].map { it["id"] }).to include(membership.id) + + expect(memberships_response["metadata"]["currentPage"]).to eq(1) + expect(memberships_response["metadata"]["totalCount"]).to eq(4) + expect(memberships_response["metadata"]["adminCount"]).to eq(2) + end + + it "returns the count of active admin memberships" do + create(:membership, organization: organization, roles: %i[admin], status: :revoked) + create_list(:membership, 2, organization: organization, roles: %i[finance]) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: + ) + + expect(result["data"]["memberships"]["metadata"]["adminCount"]).to eq(1) + end + + describe "traversal attack attempt" do + let!(:other_org) { create(:organization) } + + let(:other_user) { create(:user) } + let(:other_user_membership) { create(:membership, user: other_user, organization:) } + let(:other_user_other_membership) { create(:membership, user: other_user, organization: other_org) } + + let(:query) do + <<~GQL + query { + memberships(limit: 5) { + collection { + id + user { + organizations { + id #{organization_field} + } + } + } + } + } + GQL + end + + let(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + query: + ) + end + + let(:other_org_result_data) do + result.dig("data", "memberships", "collection") + &.find { |h| h["id"] == other_user_membership.id } + &.dig("user", "organizations") + &.find { |h| h["id"] == other_org.id } + end + + before do + other_user + other_user_membership + other_user_other_membership + end + + context "with non-sensitive field" do + let(:organization_field) { "name" } + + it "allows the query" do + expect(other_org_result_data).to eq( + "id" => other_org.id, + "name" => other_org.name + ) + end + end + + context "with sensitive field" do + let(:organization_field) { "apiKey" } + + it "rejects the query for a sensitive field" do + expect(other_org_result_data).to be nil + expect_graphql_error( + result:, + message: "Field 'apiKey' doesn't exist on type 'Organization'" + ) + end + end + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + query: + ) + + expect_graphql_error( + result:, + message: "Missing organization id" + ) + end + end +end diff --git a/spec/graphql/resolvers/organization_resolver_spec.rb b/spec/graphql/resolvers/organization_resolver_spec.rb new file mode 100644 index 0000000..5c2a1d7 --- /dev/null +++ b/spec/graphql/resolvers/organization_resolver_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::OrganizationResolver do + let(:query) do + <<~GQL + query { + organization { + id + name + email + city + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it "returns the current organization" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {} + ) + + data = result["data"]["organization"] + + expect(data["id"]).to eq(organization.id) + expect(data["name"]).to eq(organization.name) + expect(data["email"]).to eq(organization.email) + expect(data["city"]).to eq(organization.city) + end + + context "with field requiring permissions" do + let(:query) do + <<~GQL + query { + organization { + taxIdentificationNumber + apiKey + webhookUrl + billingConfiguration { invoiceFooter } + emailSettings + taxes { id code } + } + } + GQL + end + + it "returns the current organization" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: Permission.permissions_hash(:admin), + query:, + variables: {} + ) + + data = result["data"]["organization"] + + expect(data["taxIdentificationNumber"]).to eq(organization.tax_identification_number) + expect(data["apiKey"]).to eq(organization.api_keys.first.value) + expect(data["webhookUrl"]).to eq(organization.webhook_endpoints.first.webhook_url) + expect(data["billingConfiguration"]["invoiceFooter"]).to eq(organization.invoice_footer) + expect(data["emailSettings"]).to eq(organization.email_settings.map { it.tr(".", "_") }) + expect(data["taxes"]).to eq [] + end + end +end diff --git a/spec/graphql/resolvers/password_reset_resolver_spec.rb b/spec/graphql/resolvers/password_reset_resolver_spec.rb new file mode 100644 index 0000000..42a7004 --- /dev/null +++ b/spec/graphql/resolvers/password_reset_resolver_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PasswordResetResolver do + let(:query) do + <<~GQL + query($token: String!) { + passwordReset(token: $token) { + id + user { + id + email + } + } + } + GQL + end + + let(:password_reset) { create(:password_reset) } + + it "returns a single password reset" do + result = execute_graphql( + query:, + variables: { + token: password_reset.token + } + ) + + data = result["data"]["passwordReset"] + + expect(data["id"]).to eq(password_reset.id) + expect(data["user"]["email"]).to eq(password_reset.user.email) + end + + context "when password reset is not found" do + it "returns an error" do + result = execute_graphql( + query:, + variables: { + token: "foo" + } + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/payment_methods_resolver_spec.rb b/spec/graphql/resolvers/payment_methods_resolver_spec.rb new file mode 100644 index 0000000..784c5dd --- /dev/null +++ b/spec/graphql/resolvers/payment_methods_resolver_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PaymentMethodsResolver do + let(:required_permission) { "payment_methods:view" } + + let(:payment_method) { create(:payment_method, customer:, organization:) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + before do + payment_method + create(:payment_method, organization:) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payment_methods:view" + + context "when external customer id is present" do + let(:query) do + <<~GQL + query($externalCustomerId: ID!, $withDeleted: Boolean) { + paymentMethods(externalCustomerId: $externalCustomerId, limit: 5, withDeleted: $withDeleted) { + collection { + id + customer { id } + isDefault + paymentProviderCode + paymentProviderType + details { brand last4 } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns a list of payment methods" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + externalCustomerId: customer.external_id + } + ) + + payments_response = result["data"]["paymentMethods"] + + expect(payments_response["collection"].count).to eq(1) + expect(payments_response["collection"].first["paymentProviderCode"]).to eq(payment_method.payment_provider.code) + expect(payments_response["collection"].first["paymentProviderType"]).to eq("stripe") + expect(payments_response["collection"].first["details"]["brand"]).to eq("Visa") + expect(payments_response["collection"].first["details"]["last4"]).to eq("9876") + end + + context "when filtering by with_deleted" do + let(:deleted_payment_method) { create(:payment_method, customer:, organization:, deleted_at: Time.current) } + + before { deleted_payment_method } + + it "returns all payment_methods including deleted ones" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + externalCustomerId: customer.external_id, + withDeleted: true + } + ) + payments_response = result["data"]["paymentMethods"] + + expect(payments_response["collection"].count).to eq(2) + expect(payments_response["collection"].map { |p| p["id"] }).to include(payment_method.id, deleted_payment_method.id) + + expect(payments_response["metadata"]["currentPage"]).to eq(1) + expect(payments_response["metadata"]["totalCount"]).to eq(2) + end + end + end +end diff --git a/spec/graphql/resolvers/payment_provider_resolver_spec.rb b/spec/graphql/resolvers/payment_provider_resolver_spec.rb new file mode 100644 index 0000000..ff06b98 --- /dev/null +++ b/spec/graphql/resolvers/payment_provider_resolver_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PaymentProviderResolver do + let(:required_permission) { "organization:integrations:view" } + let(:query) do + <<~GQL + query($paymentProviderId: ID!) { + paymentProvider(id: $paymentProviderId) { + ... on AdyenProvider { + id + code + name + __typename + } + ... on CashfreeProvider { + id + code + name + __typename + } + ... on GocardlessProvider { + id + code + name + __typename + } + ... on StripeProvider { + id + code + name + __typename + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + + before do + customer + stripe_provider + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "organization:integrations:view" + + it "returns a single payment provider" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {paymentProviderId: stripe_provider.id} + ) + + payment_provider_response = result["data"]["paymentProvider"] + + expect(payment_provider_response["id"]).to eq(stripe_provider.id) + expect(payment_provider_response["code"]).to eq(stripe_provider.code) + expect(payment_provider_response["name"]).to eq(stripe_provider.name) + end +end diff --git a/spec/graphql/resolvers/payment_providers_resolver_spec.rb b/spec/graphql/resolvers/payment_providers_resolver_spec.rb new file mode 100644 index 0000000..1bd6cbe --- /dev/null +++ b/spec/graphql/resolvers/payment_providers_resolver_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PaymentProvidersResolver do + let(:required_permission) { "customers:view" } + let(:query) do + <<~GQL + query { + paymentProviders(limit: 5) { + collection { + ... on AdyenProvider { + id + code + __typename + } + ... on CashfreeProvider { + id + code + __typename + } + ... on GocardlessProvider { + id + code + __typename + } + ... on StripeProvider { + id + code + __typename + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:adyen_provider) { create(:adyen_provider, organization:) } + let(:cashfree_provider) { create(:cashfree_provider, organization:) } + let(:gocardless_provider) { create(:gocardless_provider, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + + before do + adyen_provider + cashfree_provider + gocardless_provider + stripe_provider + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", %w[customers:view organization:integrations:view] + + context "when type is present" do + let(:query) do + <<~GQL + query { + paymentProviders(limit: 5, type: stripe) { + collection { + ... on AdyenProvider { + id + code + __typename + } + ... on CashfreeProvider { + id + code + __typename + } + ... on GocardlessProvider { + id + code + __typename + } + ... on StripeProvider { + id + code + __typename + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns a list of payment providers" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + payment_providers_response = result["data"]["paymentProviders"] + + expect(payment_providers_response["collection"].count).to eq(1) + expect(payment_providers_response["collection"].first["id"]).to eq(stripe_provider.id) + + expect(payment_providers_response["metadata"]["currentPage"]).to eq(1) + expect(payment_providers_response["metadata"]["totalCount"]).to eq(1) + end + end + + context "when type is not present" do + let(:query) do + <<~GQL + query { + paymentProviders(limit: 5) { + collection { + ... on AdyenProvider { + id + code + __typename + } + ... on CashfreeProvider { + id + code + __typename + } + ... on GocardlessProvider { + id + code + __typename + } + ... on StripeProvider { + id + code + __typename + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns a list of all payment providers" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + payment_providers_response = result["data"]["paymentProviders"] + + adyen_provider_result = payment_providers_response["collection"].find do |record| + record["__typename"] == "AdyenProvider" + end + cashfree_provider_result = payment_providers_response["collection"].find do |record| + record["__typename"] == "CashfreeProvider" + end + gocardless_provider_result = payment_providers_response["collection"].find do |record| + record["__typename"] == "GocardlessProvider" + end + stripe_provider_result = payment_providers_response["collection"].find do |record| + record["__typename"] == "StripeProvider" + end + + expect(payment_providers_response["collection"].count).to eq(4) + + expect(adyen_provider_result["id"]).to eq(adyen_provider.id) + expect(cashfree_provider_result["id"]).to eq(cashfree_provider.id) + expect(gocardless_provider_result["id"]).to eq(gocardless_provider.id) + expect(stripe_provider_result["id"]).to eq(stripe_provider.id) + + expect(payment_providers_response["metadata"]["currentPage"]).to eq(1) + expect(payment_providers_response["metadata"]["totalCount"]).to eq(4) + end + end + + context "when requesting protected fields" do + let(:query) do + <<~GQL + query { + paymentProviders(limit: 5) { + collection { + ... on AdyenProvider { + livePrefix + } + ... on CashfreeProvider { + clientId + clientSecret + } + ... on GocardlessProvider { + hasAccessToken + } + ... on StripeProvider { + successRedirectUrl + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + context "without organization:integrations:view permission" do + it "filters out protected fields" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(adyen_provider.live_prefix).to be_a String + expect(cashfree_provider.client_id).to be_a String + expect(cashfree_provider.client_secret).to be_a String + expect(gocardless_provider.access_token).to be_a String + expect(stripe_provider.success_redirect_url).to be_a String + + payment_providers_response = result["data"]["paymentProviders"]["collection"] + expect(payment_providers_response.map(&:values)).to contain_exactly([nil], [nil, nil], [nil], [nil]) + end + end + + context "with permission" do + it "filters out protected fields" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: ["organization:integrations:view"], + query: + ) + + payment_providers_response = result["data"]["paymentProviders"]["collection"] + expect(payment_providers_response.map(&:values)).to contain_exactly( + [adyen_provider.live_prefix], + [cashfree_provider.client_id, cashfree_provider.client_secret], + [true], + [stripe_provider.success_redirect_url] + ) + end + end + end +end diff --git a/spec/graphql/resolvers/payment_requests_resolver_spec.rb b/spec/graphql/resolvers/payment_requests_resolver_spec.rb new file mode 100644 index 0000000..77dd917 --- /dev/null +++ b/spec/graphql/resolvers/payment_requests_resolver_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PaymentRequestsResolver do + let(:required_permission) { "payments:view" } + let(:filters) { "limit: 5" } + let(:query) do + <<~GQL + query { + paymentRequests(#{filters}) { + collection { + id + amountCents + customer { id } + invoices { id } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:payment_request) { create(:payment_request, organization:, customer:) } + + before do + payment_request + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payments:view" + + it "returns a list of payment_requests" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + payment_requests_response = result["data"]["paymentRequests"] + + expect(payment_requests_response["collection"].count).to eq(organization.payment_requests.count) + expect(payment_requests_response["collection"].first["id"]).to eq(payment_request.id) + expect(payment_requests_response["collection"].first["amountCents"]).to eq(payment_request.amount_cents.to_s) + expect(payment_requests_response["collection"].first["customer"]["id"]).to eq(customer.id) + expect(payment_requests_response["collection"].first["invoices"]).to eq([]) + end + + describe "filters" do + context "with currency" do + let(:filters) { "limit: 5, currency: \"BRL\"" } + let!(:brl_payment_request) { create(:payment_request, organization:, customer:, amount_currency: "BRL") } + + it "returns only payment requests with matching currency" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + response = result["data"]["paymentRequests"] + + expect(response["collection"].count).to eq(1) + expect(response["collection"].first["id"]).to eq(brl_payment_request.id) + end + end + + context "with paymentStatus" do + let(:filters) { "limit: 5, paymentStatus: succeeded" } + + before { payment_request.payment_succeeded! } + + it "returns a list of payment_requests" do + allow(PaymentRequestsQuery).to receive(:call).and_call_original + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(PaymentRequestsQuery).to have_received(:call).with( + organization: organization, + pagination: {limit: 5, page: nil}, + filters: {external_customer_id: nil, payment_status: "succeeded", currency: nil} + ) + + payment_requests_response = result["data"]["paymentRequests"] + + expect(payment_requests_response["collection"].count).to eq(organization.payment_requests.count) + expect(payment_requests_response["collection"].first["id"]).to eq(payment_request.id) + end + end + end +end diff --git a/spec/graphql/resolvers/payment_resolver_spec.rb b/spec/graphql/resolvers/payment_resolver_spec.rb new file mode 100644 index 0000000..034ab3e --- /dev/null +++ b/spec/graphql/resolvers/payment_resolver_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PaymentResolver do + let(:required_permission) { "payments:view" } + + let(:query) do + <<~GQL + query($id: ID!) { + payment(id: $id) { + id createdAt updatedAt + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:, fees_amount_cents: 10) } + let(:payment) { create(:payment, payable: invoice) } + + before { payment } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payments:view" + + it "returns a single payment" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + id: payment.id + } + ) + + data = result["data"]["payment"] + + expect(data["id"]).to eq(payment.id) + expect(data["createdAt"]).to eq(payment.created_at.iso8601) + expect(data["updatedAt"]).to eq(payment.updated_at.iso8601) + end + + context "when payment is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: invoice.organization, + permissions: required_permission, + query:, + variables: { + id: "foo" + } + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/payments_resolver_spec.rb b/spec/graphql/resolvers/payments_resolver_spec.rb new file mode 100644 index 0000000..1b36377 --- /dev/null +++ b/spec/graphql/resolvers/payments_resolver_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PaymentsResolver do + let(:required_permission) { "payments:view" } + let(:query) {} + + let!(:payment) { create(:payment, payable: invoice1) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:invoice1) { create(:invoice, customer:, organization:) } + let(:invoice2) { create(:invoice, customer:, organization:) } + + before do + create(:payment, payable: invoice2) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "payments:view" + + context "when invoice id is present" do + let(:query) do + <<~GQL + query($invoiceId: ID!) { + payments(invoiceId: $invoiceId, limit: 5) { + collection { + id + amountCents + customer { id } + paymentProviderType + payable { + ... on Invoice { id payableType } + ... on PaymentRequest { id payableType } + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns a list of payments" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + invoiceId: invoice1.id + } + ) + + payments_response = result["data"]["payments"] + + expect(payments_response["collection"].count).to eq(1) + expect(payments_response["collection"].first["id"]).to eq(payment.id) + expect(payments_response["collection"].first["amountCents"]).to eq(payment.amount_cents.to_s) + expect(payments_response["collection"].first["paymentProviderType"]).to eq("stripe") + expect(payments_response["collection"].first["payable"]["id"]).to eq(invoice1.id) + expect(payments_response["collection"].first["payable"]["payableType"]).to eq("Invoice") + expect(payments_response["collection"].first["customer"]["id"]).to eq(customer.id) + end + end + + context "when external customer id is present" do + let(:query) do + <<~GQL + query($externalCustomerId: ID!) { + payments(externalCustomerId: $externalCustomerId, limit: 5) { + collection { + id + amountCents + customer { id } + paymentProviderType + payable { + ... on Invoice { id } + ... on PaymentRequest { id } + } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns a list of payments" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + externalCustomerId: customer.external_id + } + ) + + payments_response = result["data"]["payments"] + + expect(payments_response["collection"].count).to eq(2) + expect(payments_response["collection"].map { |payable| payable.dig("payable", "id") }) + .to contain_exactly(invoice1.id, invoice2.id) + end + end +end diff --git a/spec/graphql/resolvers/plan_resolver_spec.rb b/spec/graphql/resolvers/plan_resolver_spec.rb new file mode 100644 index 0000000..f0f3b29 --- /dev/null +++ b/spec/graphql/resolvers/plan_resolver_spec.rb @@ -0,0 +1,359 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PlanResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {planId: plan.id} + ) + end + + let(:required_permission) { "plans:view" } + let(:query) do + <<~GQL + query($planId: ID!) { + plan(id: $planId) { + id + name + hasActiveSubscriptions + hasCharges + hasCustomers + hasDraftInvoices + hasFixedCharges + hasOverriddenPlans + hasSubscriptions + + customersCount + subscriptionsCount + activeSubscriptionsCount + draftInvoicesCount + + taxes { id rate } + charges { + id + taxes { id rate } + properties { + amount + pricingGroupKeys + presentationGroupKeys { value options { displayInInvoice } } + freeUnits + packageSize + fixedAmount + freeUnitsPerEvents + freeUnitsPerTotalAggregation + perTransactionMaxAmount + perTransactionMinAmount + rate + } + } + fixedCharges { + id + taxes { id rate } + properties { amount } + } + minimumCommitment { + id + amountCents + invoiceDisplayName + taxes { id rate } + } + applicableUsageThresholds { amountCents thresholdDisplayName recurring } + usageThresholds { amountCents thresholdDisplayName recurring } + entitlements { code name description privileges { code value name valueType } } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + + let(:add_on) { create(:add_on, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:) } + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 100) } + + before do + customer + minimum_commitment + usage_threshold + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "plans:view" + + it "returns a single plan" do + plan_response = result["data"]["plan"] + + expect(plan_response["id"]).to eq(plan.id) + expect(plan_response["hasCharges"]).to eq(false) + expect(plan_response["hasCustomers"]).to eq(false) + expect(plan_response["hasDraftInvoices"]).to eq(false) + expect(plan_response["hasFixedCharges"]).to eq(false) + expect(plan_response["hasActiveSubscriptions"]).to eq(false) + expect(plan_response["hasSubscriptions"]).to eq(false) + + expect(plan_response["usageThresholds"]).to contain_exactly({ + "amountCents" => "100", + "thresholdDisplayName" => usage_threshold.threshold_display_name, + "recurring" => false + }) + + expect(plan_response["applicableUsageThresholds"]).to contain_exactly({ + "amountCents" => "100", + "thresholdDisplayName" => usage_threshold.threshold_display_name, + "recurring" => false + }) + + expect(plan_response["minimumCommitment"]).to include( + "id" => minimum_commitment.id, + "amountCents" => minimum_commitment.amount_cents.to_s, + "invoiceDisplayName" => minimum_commitment.invoice_display_name, + "taxes" => [] + ) + expect(plan_response["entitlements"]).to be_empty + end + + context "when plan has active subscriptions" do + before do + create_list(:subscription, 2, customer:, plan:) + end + + it "returns true for has active subscriptions and subscriptions" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasCustomers"]).to eq(true) + expect(plan_response["hasActiveSubscriptions"]).to eq(true) + expect(plan_response["hasSubscriptions"]).to eq(true) + + expect(plan_response["customersCount"]).to eq(1) + expect(plan_response["subscriptionsCount"]).to eq(2) + end + end + + context "when child plan has active subscriptions" do + before do + child_plan = create(:plan, organization:, parent: plan) + create(:subscription, customer:, plan: child_plan) + end + + it "returns true for has active subscriptions and subscriptions" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasCustomers"]).to eq(true) + expect(plan_response["hasActiveSubscriptions"]).to eq(true) + expect(plan_response["hasSubscriptions"]).to eq(true) + + expect(plan_response["customersCount"]).to eq(1) + expect(plan_response["subscriptionsCount"]).to eq(1) + end + end + + context "when plan only has terminated subscriptions" do + before do + create(:subscription, :terminated, customer:, plan:) + end + + it "returns true for has subscriptions but false for active subscriptions" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasCustomers"]).to eq(false) + expect(plan_response["hasActiveSubscriptions"]).to eq(false) + expect(plan_response["hasSubscriptions"]).to eq(true) + + expect(plan_response["customersCount"]).to eq(0) + expect(plan_response["subscriptionsCount"]).to eq(1) + end + end + + context "when child plan has terminated subscriptions" do + before do + child_plan = create(:plan, organization:, parent: plan) + create(:subscription, :terminated, customer:, plan: child_plan) + end + + it "returns true for has subscriptions but false for active subscriptions" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasCustomers"]).to eq(false) + expect(plan_response["hasActiveSubscriptions"]).to eq(false) + expect(plan_response["hasSubscriptions"]).to eq(true) + + expect(plan_response["customersCount"]).to eq(0) + expect(plan_response["subscriptionsCount"]).to eq(1) + end + end + + context "when plan has charges" do + before do + create( + :standard_charge, + billable_metric:, + plan:, + properties: { + amount: "100", + presentation_group_keys: [ + {"value" => "region", "options" => {"display_in_invoice" => true}} + ] + } + ) + end + + it "returns true for has charges" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasCharges"]).to eq(true) + + expect(plan_response["charges"].sole.dig("properties", "presentationGroupKeys")).to eq([ + {"value" => "region", "options" => {"displayInInvoice" => true}} + ]) + end + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, add_on:, plan:) } + + before { fixed_charge } + + it "returns true for has charges" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasFixedCharges"]).to eq(true) + expect(plan_response["fixedCharges"].pluck("id")).to match_array([fixed_charge.id]) + end + end + + context "when plan has draft invoices" do + before do + subscription = create(:subscription, customer:, plan:) + invoice = create(:invoice, :draft, customer:) + create(:invoice_subscription, subscription:, invoice:) + end + + it "returns true for has draft invoices" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasDraftInvoices"]).to eq(true) + end + end + + context "when child plan has draft invoices" do + before do + child_plan = create(:plan, organization:, parent: plan) + subscription = create(:subscription, :terminated, customer:, plan: child_plan) + invoice = create(:invoice, :draft, customer:) + create(:invoice_subscription, subscription:, invoice:) + end + + it "returns true for has draft invoices" do + plan_response = result["data"]["plan"] + + expect(plan_response["hasDraftInvoices"]).to eq(true) + end + end + + context "when plan is a child plan" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {planId: child_plan.id} + ) + end + + let(:child_plan) { create(:plan, organization:, parent: plan) } + + it "returns parent usage thresholds as applicable usage thresholds" do + plan_response = result["data"]["plan"] + + expect(plan_response["usageThresholds"]).to be_empty + expect(plan_response["applicableUsageThresholds"]).to contain_exactly({ + "amountCents" => "100", + "thresholdDisplayName" => usage_threshold.threshold_display_name, + "recurring" => false + }) + end + end + + context "when plan has entitlements" do + let(:feature) { create(:feature, organization:, code: "seats") } + let(:entitlement) { create(:entitlement, plan:, feature:) } + + before do + create(:entitlement_value, entitlement:, privilege: create(:privilege, feature:, code: "max", value_type: "integer"), value: 10) + + feature2 = create(:feature, organization:, code: "storage") + entitlement2 = create(:entitlement, plan:, feature: feature2, created_at: 1.day.ago) + create(:entitlement_value, entitlement: entitlement2, privilege: create(:privilege, feature: feature2, code: "curr"), value: 2) + end + + it "returns entitlements" do + entitlements = result["data"]["plan"]["entitlements"] + expect(entitlements.first["code"]).to eq "storage" + expect(entitlements.first["privileges"].sole["value"]).to eq "2" + expect(entitlements.second["code"]).to eq "seats" + expect(entitlements.second["privileges"].sole["value"]).to eq "10" + end + + context "when privilege is boolean" do + let(:enabled) { create(:privilege, feature:, code: "enabled", value_type: "boolean") } + let(:beta) { create(:privilege, feature:, code: "beta", value_type: "boolean") } + let(:enabled_value) { create(:entitlement_value, entitlement:, privilege: enabled, value: true) } + let(:beta_value) { create(:entitlement_value, entitlement:, privilege: beta, value: false) } + + it "casts boolean values to strings" do + expect(enabled_value.value).to eq("t") + expect(beta_value.value).to eq("f") + + result = subject + feat = result["data"]["plan"]["entitlements"].find { |e| e["code"] == feature.code } + expect(feat["privileges"].map { |p| p["value"] }).to contain_exactly("10", "true", "false") + end + end + + context "when plan is an override" do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {planId: child_plan.id} + ) + end + + let(:child_plan) { create(:plan, organization:, parent: plan) } + + it "doesn't return entitlements to avoid confusion" do + expect(result["data"]["plan"]["entitlements"]).to be_empty + end + end + end + + context "when plan is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {planId: "foo"} + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/plans_resolver_spec.rb b/spec/graphql/resolvers/plans_resolver_spec.rb new file mode 100644 index 0000000..7775633 --- /dev/null +++ b/spec/graphql/resolvers/plans_resolver_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PlansResolver do + let(:required_permission) { "plans:view" } + let(:query) do + <<~GQL + query($withDeleted: Boolean) { + plans(limit: 5, withDeleted: $withDeleted) { + collection { id chargesCount customersCount } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:plan) { create(:plan, organization:) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + before do + plan + customer + + 2.times do + create(:subscription, customer:, plan:) + end + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "plans:view" + + it "returns a list of plans" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + plans_response = result["data"]["plans"] + + expect(plans_response["collection"].count).to eq(organization.plans.count) + expect(plans_response["collection"].first["id"]).to eq(plan.id) + expect(plans_response["collection"].first["customersCount"]).to eq(1) + + expect(plans_response["metadata"]["currentPage"]).to eq(1) + expect(plans_response["metadata"]["totalCount"]).to eq(1) + end + + context "when filtering by with_deleted" do + let(:plan) { create(:plan, organization:) } + let(:deleted_plan) { create(:plan, organization:, deleted_at: Time.current) } + + before do + plan + deleted_plan + end + + it "returns all plans including deleted ones" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {withDeleted: true} + ) + + plans_response = result["data"]["plans"] + expect(plans_response["collection"].count).to eq(2) + expect(plans_response["collection"].map { |p| p["id"] }).to include(plan.id, deleted_plan.id) + + expect(plans_response["metadata"]["currentPage"]).to eq(1) + expect(plans_response["metadata"]["totalCount"]).to eq(2) + end + end +end diff --git a/spec/graphql/resolvers/pricing_unit_resolver_spec.rb b/spec/graphql/resolvers/pricing_unit_resolver_spec.rb new file mode 100644 index 0000000..71a231b --- /dev/null +++ b/spec/graphql/resolvers/pricing_unit_resolver_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PricingUnitResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: membership.organization, + permissions: required_permission, + query:, + variables: {pricingUnitId: pricing_unit_id} + ) + end + + let(:query) do + <<~GQL + query($pricingUnitId: ID!) { + pricingUnit(id: $pricingUnitId) { + id name code shortName description createdAt + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:required_permission) { "pricing_units:view" } + let(:pricing_unit) { create(:pricing_unit, organization: membership.organization) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "pricing_units:view" + + context "when pricing unit with such ID exists in the current organization" do + let(:pricing_unit_id) { pricing_unit.id } + + it "returns a pricing unit" do + pricing_unit_response = result["data"]["pricingUnit"] + + expect(pricing_unit_response["id"]).to eq(pricing_unit.id) + expect(pricing_unit_response["name"]).to eq(pricing_unit.name) + expect(pricing_unit_response["code"]).to eq(pricing_unit.code) + expect(pricing_unit_response["shortName"]).to eq(pricing_unit.short_name) + expect(pricing_unit_response["description"]).to eq(pricing_unit.description) + expect(pricing_unit_response["createdAt"]).to eq(pricing_unit.created_at.iso8601) + end + end + + context "when pricing unit with such ID does not exist in the current organization" do + let(:pricing_unit_id) { SecureRandom.uuid } + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/pricing_units_resolver_spec.rb b/spec/graphql/resolvers/pricing_units_resolver_spec.rb new file mode 100644 index 0000000..e9c57bd --- /dev/null +++ b/spec/graphql/resolvers/pricing_units_resolver_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::PricingUnitsResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {searchTerm: "token"} + ) + end + + let(:query) do + <<~GQL + query($searchTerm: String) { + pricingUnits(page: 2, limit: 1, searchTerm: $searchTerm) { + collection { id name code shortName description createdAt } + metadata { + currentPage + totalCount + totalPages + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:required_permission) { "pricing_units:view" } + let(:pricing_unit) { create(:pricing_unit, name: "Compute token", organization:) } + + before do + create(:pricing_unit, name: "Cloud token", organization:) + pricing_unit + create(:pricing_unit, code: "token", organization:) + create(:pricing_unit, name: "coin", code: "coin", organization:) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "pricing_units:view" + + it "returns a list of pricing units matching search term" do + pricing_units_response = result["data"]["pricingUnits"] + + expect(pricing_units_response["collection"].first["id"]).to eq(pricing_unit.id) + expect(pricing_units_response["collection"].first["name"]).to eq(pricing_unit.name) + expect(pricing_units_response["collection"].first["code"]).to eq(pricing_unit.code) + expect(pricing_units_response["collection"].first["shortName"]).to eq(pricing_unit.short_name) + expect(pricing_units_response["collection"].first["description"]).to eq(pricing_unit.description) + expect(pricing_units_response["collection"].first["createdAt"]).to eq(pricing_unit.created_at.iso8601) + + expect(pricing_units_response["collection"].size).to eq(1) + expect(pricing_units_response["metadata"]["currentPage"]).to eq(2) + expect(pricing_units_response["metadata"]["totalCount"]).to eq(3) + expect(pricing_units_response["metadata"]["totalPages"]).to eq(3) + end +end diff --git a/spec/graphql/resolvers/quote_resolver_spec.rb b/spec/graphql/resolvers/quote_resolver_spec.rb new file mode 100644 index 0000000..7981948 --- /dev/null +++ b/spec/graphql/resolvers/quote_resolver_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::QuoteResolver do + let(:required_permission) { "quotes:view" } + let(:query) do + <<-GQL + query($quoteId: ID!) { + quote(id: $quoteId) { + id + customer { id name } + organization { id name } + subscription { id } + number + orderType + currentVersion { id version status billingItems content } + versions { id version status } + createdAt + updatedAt + owners { id email } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:view" + + context "when the quote exists" do + let(:quote) { create(:quote, :with_version, version_trait: :voided, organization:, customer:) } + let(:current_version) { create(:quote_version, organization:, quote:) } + + before do + quote + current_version + end + + it "returns a single quote" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + quoteId: quote.id + } + ) + + response = result.dig("data", "quote") + + expect(response.dig("id")).to eq(quote.id) + expect(response.dig("organization", "id")).to eq(organization.id) + expect(response.dig("organization", "name")).to eq(organization.name) + expect(response.dig("subscription", "id")).to eq(quote.subscription_id) + expect(response.dig("customer", "id")).to eq(customer.id) + expect(response.dig("customer", "name")).to eq(customer.name) + expect(response.dig("number")).to eq(quote.number) + expect(response.dig("orderType")).to eq(quote.order_type) + expect(response.dig("createdAt")).to eq(quote.created_at.iso8601) + expect(response.dig("updatedAt")).to eq(quote.updated_at.iso8601) + expect(response.dig("owners")).to eq([]) + + expect(response.dig("currentVersion", "id")).to eq(quote.current_version.id) + expect(response.dig("currentVersion", "billingItems")).to eq(quote.current_version.billing_items) + expect(response.dig("currentVersion", "content")).to eq(quote.current_version.content) + expect(response.dig("currentVersion", "status")).to eq(quote.current_version.status) + expect(response.dig("currentVersion", "version")).to eq(quote.current_version.version) + expect(response.dig("versions")).to match_array( + [ + { + "id" => quote.versions[0].id, + "version" => quote.versions[0].version, + "status" => quote.versions[0].status + }, + { + "id" => quote.versions[1].id, + "version" => quote.versions[1].version, + "status" => quote.versions[1].status + } + ] + ) + end + end + + context "when the quote is not found" do + it "returns a not found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + quoteId: "00000000-0000-0000-0000-000000000000" + } + ) + + expect_not_found(result) + end + end + + context "with N+1 query detection", :with_bullet, bullet: {n_plus_one_query: true, unused_eager_loading: false} do + let(:other_user) { create(:user) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:quote) { create(:quote, organization:, customer:, subscription:, order_type: :subscription_amendment) } + + let(:query) do + <<~GQL + query($quoteId: ID!) { + quote(id: $quoteId) { + id + customer { id } + organization { id } + subscription { id } + owners { id } + versions { id quote { id } organization { id } } + currentVersion { id quote { id } organization { id } } + } + } + GQL + end + + before do + quote + QuoteOwner.create!(organization:, quote:, user: membership.user) + QuoteOwner.create!(organization:, quote:, user: other_user) + create(:quote_version, :voided, organization:, quote:) + create(:quote_version, organization:, quote:) + end + + it "does not trigger N+1 queries" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {quoteId: quote.id} + ) + + expect(result["errors"]).to be_nil + expect(result.dig("data", "quote", "id")).to eq(quote.id) + end + end +end diff --git a/spec/graphql/resolvers/quotes_resolver_spec.rb b/spec/graphql/resolvers/quotes_resolver_spec.rb new file mode 100644 index 0000000..b26fa22 --- /dev/null +++ b/spec/graphql/resolvers/quotes_resolver_spec.rb @@ -0,0 +1,313 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::QuotesResolver do + let(:required_permission) { "quotes:view" } + let(:query) do + <<~GQL + query { + quotes(limit: 5) { + collection { id number } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:membership) { create(:membership, organization:) } + + before do + (1..3).each do |version| + create( + :quote, + organization:, + customer: + ) + end + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "quotes:view" + + context "when all versions are requested" do + it "returns a full list of quotes" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(3) + expect(response.dig("metadata", "currentPage")).to eq(1) + expect(response.dig("metadata", "totalCount")).to eq(3) + end + end + + context "with pagination" do + let(:query) do + <<~GQL + query { + quotes(page: 2, limit: 2) { + collection { id } + metadata { currentPage, totalCount, totalPages } + } + } + GQL + end + + it "applies the pagination" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(1) + expect(response.dig("metadata", "currentPage")).to eq(2) + expect(response.dig("metadata", "totalPages")).to eq(2) + expect(response.dig("metadata", "totalCount")).to eq(3) + end + end + + context "when filtering by customer" do + let(:other_customer) { create(:customer, organization:) } + let!(:other_quote) { create(:quote, organization:, customer: other_customer) } + + let(:query) do + <<~GQL + query { + quotes(limit: 5, customers: ["#{other_customer.id}"]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns quotes for the specified customer" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(1) + expect(response.dig("collection").first.dig("id")).to eq(other_quote.id) + expect(response.dig("metadata", "totalCount")).to eq(1) + end + end + + context "when filtering by number" do + let!(:other_quote) { create(:quote, organization:, customer:, sequential_id: 99999) } + + let(:query) do + <<~GQL + query { + quotes(limit: 5, numbers: ["#{other_quote.number}"]) { + collection { id number } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns the quote with the given number" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(1) + expect(response.dig("collection").first.dig("id")).to eq(other_quote.id) + expect(response.dig("collection").first.dig("number")).to eq(other_quote.number) + end + end + + context "when filtering by status" do + let!(:approved_quote) { create(:quote, :with_version, version_trait: :approved, organization:, customer:) } + + let(:query) do + <<~GQL + query { + quotes(limit: 5, statuses: [approved]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns quotes with the specified version status" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(1) + expect(response.dig("collection").first.dig("id")).to eq(approved_quote.id) + expect(response.dig("metadata", "totalCount")).to eq(1) + end + end + + context "when filtering by from_date and to_date" do + let!(:old_quote) { create(:quote, :with_version, organization:, customer:, created_at: 10.days.ago) } + + let(:query) do + <<~GQL + query { + quotes(limit: 5, fromDate: "#{11.days.ago.to_date.iso8601}", toDate: "#{9.days.ago.to_date.iso8601}") { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns quotes created within the provided date range" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(1) + expect(response.dig("collection").first.dig("id")).to eq(old_quote.id) + expect(response.dig("metadata", "totalCount")).to eq(1) + end + end + + context "when filtering by owners" do + let(:owner_user) { membership.user } + let(:query) do + <<~GQL + query { + quotes(limit: 5, owners: ["#{owner_user.id}"]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + let!(:owner_quote) { create(:quote, organization:, customer:) } + + before do + QuoteOwner.create!(organization: organization, quote: owner_quote, user: owner_user) + end + + it "returns quotes that belong to the specified owners" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(result["errors"]).to be_nil + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(1) + expect(response.dig("collection").first.dig("id")).to eq(owner_quote.id) + expect(response.dig("metadata", "totalCount")).to eq(1) + end + end + + context "when filtering by order_types" do + let!(:one_off_quote) { create(:quote, organization:, customer:, order_type: :one_off) } + + let(:query) do + <<~GQL + query { + quotes(limit: 5, orderTypes: [one_off]) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns quotes with the specified order type" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + response = result.dig("data", "quotes") + expect(response.dig("collection").count).to eq(1) + expect(response.dig("collection").first.dig("id")).to eq(one_off_quote.id) + expect(response.dig("metadata", "totalCount")).to eq(1) + end + end + + context "with N+1 query detection", :with_bullet do + let(:query) do + <<~GQL + query { + quotes(limit: 10) { + collection { + id + customer { id } + organization { id } + subscription { id } + owners { id } + versions { id quote { id } organization { id } } + currentVersion { id quote { id } organization { id } } + } + metadata { totalCount } + } + } + GQL + end + + let(:other_user) { create(:user) } + let(:subscription) { create(:subscription, organization:, customer:) } + + before do + Quote.destroy_all + 3.times do |i| + quote_customer = i.zero? ? customer : create(:customer, organization:) + quote = create( + :quote, + organization:, + customer: quote_customer, + subscription:, + order_type: :subscription_amendment + ) + QuoteOwner.create!(organization:, quote:, user: membership.user) + QuoteOwner.create!(organization:, quote:, user: other_user) + create(:quote_version, :voided, organization:, quote:) + create(:quote_version, organization:, quote:) + end + end + + it "does not trigger N+1 queries" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(result["errors"]).to be_nil + expect(result.dig("data", "quotes", "collection").length).to eq(3) + end + end +end diff --git a/spec/graphql/resolvers/role_resolver_spec.rb b/spec/graphql/resolvers/role_resolver_spec.rb new file mode 100644 index 0000000..64ce937 --- /dev/null +++ b/spec/graphql/resolvers/role_resolver_spec.rb @@ -0,0 +1,158 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::RoleResolver do + let(:query) do + <<~GQL + query($roleId: ID!) { + role(id: $roleId) { + id name description admin permissions + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:current_organization) { membership.organization } + let(:current_user) { membership.user } + let(:permissions) { "roles:view" } + let(:role) { create(:role, organization: current_organization) } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "roles:view" + + it "returns a single role" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: role.id} + ) + + expect(result["data"]["role"]).to include( + "id" => role.id, + "name" => role.name, + "description" => role.description, + "admin" => role.admin, + "permissions" => %w[organization_view] + ) + end + + context "with system role" do + let(:admin_role) { create(:role, :admin) } + + it "returns system role" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: admin_role.id} + ) + + expect(result["data"]["role"]).to include( + "id" => admin_role.id, + "name" => admin_role.name, + "admin" => true + ) + end + + it "returns all permissions for admin role" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: admin_role.id} + ) + + all_permissions = Permission.permissions_hash.each_key.map { |p| p.tr(":", "_") } + expect(result["data"]["role"]["permissions"]).to match_array(all_permissions) + end + end + + context "with finance role" do + let(:finance_role) { create(:role, :finance) } + + it "returns finance-specific permissions" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: finance_role.id} + ) + + finance_permissions = Permission.permissions_hash("finance").filter_map { |k, v| k.tr(":", "_") if v } + expect(result["data"]["role"]["permissions"]).to match_array(finance_permissions) + end + end + + context "with manager role" do + let(:manager_role) { create(:role, :manager) } + + it "returns manager-specific permissions" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: manager_role.id} + ) + + manager_permissions = Permission.permissions_hash("manager").filter_map { |k, v| k.tr(":", "_") if v } + expect(result["data"]["role"]["permissions"]).to match_array(manager_permissions) + end + end + + context "with custom role having explicit permissions" do + let(:custom_role) do + create(:role, organization: current_organization, name: "Custom", permissions: %w[addons:view customers:create]) + end + + it "returns custom role permissions with underscores" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: custom_role.id} + ) + + expect(result["data"]["role"]["permissions"]).to match_array(%w[addons_view customers_create]) + end + end + + context "when role is not found" do + it "returns an error" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: "unknown"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when role belongs to another organization" do + let(:other_role) { create(:role) } + + it "returns an error" do + result = execute_graphql( + current_user:, + current_organization:, + permissions:, + query:, + variables: {roleId: other_role.id} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/roles_resolver_spec.rb b/spec/graphql/resolvers/roles_resolver_spec.rb new file mode 100644 index 0000000..c0e088d --- /dev/null +++ b/spec/graphql/resolvers/roles_resolver_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::RolesResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + end + + let(:query) do + <<~GQL + query { + roles { + id name description admin permissions + memberships { + id + user { id email } + } + } + } + GQL + end + + let(:required_permission) { "roles:view" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:other_user) { create(:user, email: "other@example.com") } + let!(:other_membership) { create(:membership, organization:, user: other_user) } + + before do + create(:role, :admin) + create(:role, :finance) + operator_role = create(:role, organization:, name: "OPERATOR") + create(:role, organization:, name: "accountant") + create(:membership_role, membership: other_membership, role: operator_role, organization:) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "roles:view" + + it "returns roles sorted by organization_id nulls first then by lower(name)" do + roles_response = result["data"]["roles"] + + expect(roles_response.map { |r| r["name"] }).to eq(%w[Admin Finance accountant OPERATOR]) + end + + it "returns role attributes" do + roles_response = result["data"]["roles"] + admin_role = roles_response.find { |r| r["name"] == "Admin" } + + all_permissions = Permission.permissions_hash.each_key.map { |p| p.tr(":", "_") } + + expect(admin_role["name"]).to eq("Admin") + expect(admin_role["admin"]).to be(true) + expect(admin_role["permissions"]).to match_array(all_permissions) + end + + it "does not return roles from other organizations" do + other_organization = create(:organization) + create(:role, organization: other_organization, name: "OtherOrgRole") + + roles_response = result["data"]["roles"] + + expect(roles_response.map { |r| r["name"] }).not_to include("OtherOrgRole") + end + + it "returns memberships with user for a role" do + roles_response = result["data"]["roles"] + operator_role = roles_response.find { |r| r["name"] == "OPERATOR" } + + expect(operator_role["memberships"].size).to eq(1) + expect(operator_role["memberships"].first["user"]["email"]).to eq("other@example.com") + end + + it "does not return memberships from other organizations" do + admin_role = Role.find_by(admin: true) + other_org = create(:organization) + other_org_membership = create(:membership, organization: other_org) + create(:membership_role, membership: other_org_membership, role: admin_role, organization: other_org) + + roles_response = result["data"]["roles"] + admin = roles_response.find { |r| r["name"] == "Admin" } + + expect(admin["memberships"]).to be_empty + end +end diff --git a/spec/graphql/resolvers/security_log_resolver_spec.rb b/spec/graphql/resolvers/security_log_resolver_spec.rb new file mode 100644 index 0000000..ee05649 --- /dev/null +++ b/spec/graphql/resolvers/security_log_resolver_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::SecurityLogResolver, clickhouse: true do + let(:query) do + <<~GQL + query($logId: ID!) { + securityLog(logId: $logId) { + logId + logType + logEvent + userEmail + } + } + GQL + end + let(:variables) { {logId: security_log.log_id} } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:security_log) { create(:clickhouse_security_log, membership:) } + + before { organization.update!(premium_integrations: ["security_logs"]) } + + include_context "with clickhouse availability" + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "security_logs:view" + + context "without premium license" do + it "returns feature_unavailable error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + context "when clickhouse is not available", :premium do + let(:clickhouse_enabled) { nil } + + it "returns feature_unavailable error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + context "when security_logs is not enabled", :premium do + before { organization.update!(premium_integrations: []) } + + it "returns feature_unavailable error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + context "when all conditions are met and log exists", :premium do + it "returns the security log" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + data = result.dig("data", "securityLog") + expect(data["logId"]).to eq(security_log.log_id) + expect(data["logType"]).to eq(security_log.log_type) + expect(data["logEvent"]).to eq(security_log.log_event.tr(".", "_")) + end + end + + context "when log does not exist", :premium do + let(:variables) { {logId: "non-existent-id"} } + + it "returns not_found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "not_found") + end + end + + context "when log is outside retention period", :premium do + let(:security_log) { create(:clickhouse_security_log, membership:, logged_at: 91.days.ago) } + + it "returns not_found error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "not_found") + end + end +end diff --git a/spec/graphql/resolvers/security_logs_resolver_spec.rb b/spec/graphql/resolvers/security_logs_resolver_spec.rb new file mode 100644 index 0000000..745c406 --- /dev/null +++ b/spec/graphql/resolvers/security_logs_resolver_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::SecurityLogsResolver, clickhouse: true do + let(:query) do + <<~GQL + query($toDatetime: ISO8601DateTime!) { + securityLogs(toDatetime: $toDatetime) { + collection { + logId + logType + logEvent + userEmail + } + metadata { currentPage, totalCount } + } + } + GQL + end + let(:variables) { {toDatetime: Time.current.iso8601} } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + before { organization.update!(premium_integrations: ["security_logs"]) } + + include_context "with clickhouse availability" + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "security_logs:view" + + context "without premium license" do + it "returns feature_unavailable error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + context "when clickhouse is not available", :premium do + let(:clickhouse_enabled) { nil } + + it "returns feature_unavailable error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + context "when security_logs is not enabled", :premium do + before { organization.update!(premium_integrations: []) } + + it "returns feature_unavailable error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + expect_graphql_error(result:, message: "feature_unavailable") + end + end + + context "when all conditions are met but no events exist", :premium do + it "returns empty collection" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + security_logs = result.dig("data", "securityLogs") + expect(security_logs["collection"]).to eq([]) + expect(security_logs["metadata"]["totalCount"]).to eq(0) + end + end + + context "when security logs exist", :premium do + let(:security_log) { create(:clickhouse_security_log, membership:, logged_at: 1.hour.ago) } + + before { security_log } + + it "returns the collection" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: "security_logs:view", + query:, + variables: + ) + + security_logs = result.dig("data", "securityLogs") + expect(security_logs["collection"].size).to eq(1) + expect(security_logs["collection"].first["logId"]).to eq(security_log.log_id) + expect(security_logs["metadata"]["totalCount"]).to eq(1) + end + end +end diff --git a/spec/graphql/resolvers/subscription_resolver_spec.rb b/spec/graphql/resolvers/subscription_resolver_spec.rb new file mode 100644 index 0000000..23a05c8 --- /dev/null +++ b/spec/graphql/resolvers/subscription_resolver_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::SubscriptionResolver do + let(:required_permission) { "subscriptions:view" } + let(:query) do + <<~GQL + query($subscriptionId: ID, $externalId: ID) { + subscription(id: $subscriptionId, externalId: $externalId) { + id + externalId + name + startedAt + endingAt + progressiveBillingDisabled + plan { + id + code + } + nextSubscriptionType + nextSubscriptionAt + downgradePlanDate + previousPlan { + id + name + } + previousSubscription { + id + downgradePlanDate + } + usageThresholds { amountCents thresholdDisplayName recurring } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + + before do + customer + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:view" + + context "when id and external_id are not provided" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect_graphql_error( + result:, + message: "You must provide either `id` or `external_id`." + ) + end + end + + context "when external_id is provided" do + it "returns a single subscription" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + externalId: subscription.external_id + } + ) + + subscription_response = result["data"]["subscription"] + expect(subscription_response["id"]).to eq(subscription.id) + expect(subscription_response["externalId"]).to eq(subscription.external_id) + expect(subscription_response["usageThresholds"]).to be_an(Array).and be_empty + end + end + + it "returns a single subscription" do + threshold = create(:usage_threshold, :for_subscription, subscription:, amount_cents: 99_00) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionId: subscription.id} + ) + + subscription_response = result["data"]["subscription"] + expect(subscription_response).to include( + "id" => subscription.id, + "name" => subscription.name, + "startedAt" => subscription.started_at.iso8601, + "endingAt" => subscription.ending_at, + "progressiveBillingDisabled" => false + ) + + expect(subscription_response["plan"]).to include( + "id" => subscription.plan.id, + "code" => subscription.plan.code + ) + + expect(subscription_response["usageThresholds"]).to contain_exactly({ + "amountCents" => "9900", + "thresholdDisplayName" => threshold.threshold_display_name, + "recurring" => false + }) + end + + context "when subscription is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionId: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when subscription has a pending downgrade" do + let(:plan) { create(:plan, organization:, amount_cents: 500_00) } + let(:lower_plan) { create(:plan, organization:, amount_cents: 100_00) } + let(:subscription) do + create(:subscription, :anniversary, customer:, plan:, subscription_at: Time.zone.parse("2026-04-22 00:00:00"), started_at: Time.zone.parse("2026-04-22 00:00:00")) + end + let(:pending_subscription) do + create(:subscription, :pending, customer:, plan: lower_plan, previous_subscription: subscription) + end + + before { pending_subscription } + + it "returns downgradePlanDate computed from the current billing period" do + travel_to Time.zone.parse("2026-04-25 12:00:00") do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionId: subscription.id} + ) + + subscription_response = result["data"]["subscription"] + expect(subscription_response["downgradePlanDate"]).to eq("2026-05-22") + expect(subscription_response["nextSubscriptionType"]).to eq("downgrade") + end + end + + it "exposes previousPlan and previousSubscription.downgradePlanDate on the pending subscription" do + travel_to Time.zone.parse("2026-04-25 12:00:00") do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionId: pending_subscription.id} + ) + + subscription_response = result["data"]["subscription"] + expect(subscription_response["previousPlan"]).to include( + "id" => plan.id, + "name" => plan.name + ) + expect(subscription_response["downgradePlanDate"]).to be_nil + expect(subscription_response["previousSubscription"]).to include( + "id" => subscription.id, + "downgradePlanDate" => "2026-05-22" + ) + end + end + end + + context "when subscription has no previous subscription" do + it "returns null for previousPlan, previousSubscription and downgradePlanDate" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionId: subscription.id} + ) + + subscription_response = result["data"]["subscription"] + expect(subscription_response["previousPlan"]).to be_nil + expect(subscription_response["previousSubscription"]).to be_nil + expect(subscription_response["downgradePlanDate"]).to be_nil + end + end + + context "when subscription was upgraded" do + let(:subscription) { create(:subscription, :terminated, customer:, next_subscriptions: [next_subscription], terminated_at: 1.day.ago, external_id: next_subscription.external_id) } + let(:next_subscription) { create(:subscription, customer: customer, plan: create(:plan, amount_cents: 33000_00)) } + + it do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionId: subscription.id} + ) + + subscription_response = result["data"]["subscription"] + expect(subscription_response["nextSubscriptionType"]).to eq "upgrade" + expect(subscription_response["nextSubscriptionAt"]).to be_present + end + end +end diff --git a/spec/graphql/resolvers/subscriptions/alert_resolver_spec.rb b/spec/graphql/resolvers/subscriptions/alert_resolver_spec.rb new file mode 100644 index 0000000..e8dd362 --- /dev/null +++ b/spec/graphql/resolvers/subscriptions/alert_resolver_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Subscriptions::AlertResolver do + let(:required_permission) { "subscriptions:view" } + let(:query) do + <<~GQL + query($alertId: ID!) { + subscriptionAlert(id: $alertId) { + id code name thresholds {code value recurring} + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:alert) { create(:alert, organization:, recurring_threshold: 33, thresholds: [10, 20]) } + + before do + alert + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:view" + + it "returns a single alert" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {alertId: alert.id} + ) + + alert_response = result["data"]["subscriptionAlert"] + + expect(alert_response["id"]).to eq(alert.id) + expect(alert_response["code"]).to start_with("default") + expect(alert_response["name"]).to eq("General Alert") + expect(alert_response["thresholds"].map(&:symbolize_keys)).to contain_exactly( + {code: "warn10", value: "10.0", recurring: false}, + {code: "warn20", value: "20.0", recurring: false}, + {code: "rec", value: "33.0", recurring: true} + ) + end + + context "when alert is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {alertId: "invalid"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/subscriptions/alerts_resolver_spec.rb b/spec/graphql/resolvers/subscriptions/alerts_resolver_spec.rb new file mode 100644 index 0000000..60cf30a --- /dev/null +++ b/spec/graphql/resolvers/subscriptions/alerts_resolver_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Subscriptions::AlertsResolver do + let(:required_permission) { "subscriptions:view" } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:subscription) { create(:subscription) } + let(:alert) { create(:alert, organization:, subscription_external_id: subscription.external_id, recurring_threshold: 33, thresholds: [10, 20]) } + let(:alert_bm) { create(:billable_metric_current_usage_amount_alert, organization:, subscription_external_id: subscription.external_id, recurring_threshold: 33, thresholds: [10, 20]) } + let(:another_alert) { create(:alert, organization:) } + + let(:query) do + <<~GQL + query($subscriptionExternalId: String!) { + subscriptionAlerts(subscriptionExternalId: $subscriptionExternalId) { + collection { id name code deletedAt thresholds { code value recurring} } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + alert + alert_bm + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:view" + + it "returns all alerts" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionExternalId: subscription.external_id} + ) + + alerts = result["data"]["subscriptionAlerts"]["collection"] + + expect(alerts.pluck("id")).to contain_exactly(alert.id, alert_bm.id) + expect(alerts).to all(include({"name" => "General Alert", "deletedAt" => nil})) + expect(alerts.pluck("code")).to all(start_with("default")) + expect(alerts.pluck("thresholds")).to all(contain_exactly( + {"code" => "warn10", "value" => "10.0", "recurring" => false}, + {"code" => "warn20", "value" => "20.0", "recurring" => false}, + {"code" => "rec", "value" => "33.0", "recurring" => true} + )) + + metadata = result["data"]["subscriptionAlerts"]["metadata"] + expect(metadata["currentPage"]).to eq(1) + expect(metadata["totalCount"]).to eq(2) + end + + context "when no alert is not found" do + it "returns an empty list" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionExternalId: "invalid"} + ) + + expect(result["data"]["subscriptionAlerts"]["collection"]).to be_empty + end + end + + context "when making a list of existing alerts combination" do + let(:query) do + <<~GQL + query($subscriptionExternalId: String!) { + subscriptionAlerts(subscriptionExternalId: $subscriptionExternalId) { + collection { alertType billableMetricId } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns all alerts" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {subscriptionExternalId: subscription.external_id} + ) + alerts = result["data"]["subscriptionAlerts"]["collection"] + + h = alerts.find { it["alertType"] == "billable_metric_current_usage_amount" } + expect(h["billableMetricId"]).to eq alert_bm.billable_metric_id + + metadata = result["data"]["subscriptionAlerts"]["metadata"] + expect(metadata["currentPage"]).to eq(1) + expect(metadata["totalCount"]).to eq(2) + end + end + # + # context "when requesting relationships" do + # let(:query) do + # <<~GQL + # query($subscriptionExternalId: String!) { + # alerts(subscriptionExternalId: $subscriptionExternalId) { + # collection { code billableMetric { id } thresholds { value } } + # } + # } + # GQL + # end + # + # it "eager loads the relationships", :bullet do + # create_list(:billable_metric_current_usage_amount_alert, 3, organization:, subscription_external_id: subscription.external_id, thresholds: [10]) + # + # Bullet.start_request + # + # execute_graphql( + # current_user: membership.user, + # current_organization: organization, + # permissions: required_permission, + # query:, + # variables: {subscriptionExternalId: subscription.external_id} + # ) + # + # expect(Bullet.notification?).to eq false + # end + # end +end diff --git a/spec/graphql/resolvers/subscriptions_resolver_spec.rb b/spec/graphql/resolvers/subscriptions_resolver_spec.rb new file mode 100644 index 0000000..3aeacad --- /dev/null +++ b/spec/graphql/resolvers/subscriptions_resolver_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::SubscriptionsResolver do + let(:required_permission) { "subscriptions:view" } + let(:query) do + <<~GQL + query { + subscriptions(limit: 5, planCode: "#{plan.code}", status: [active]) { + collection { id externalId plan { code } } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:plan) { create(:plan, organization:) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + before do + customer + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "subscriptions:view" + + it "returns a list of subscriptions" do + first_subscription = create(:subscription, customer:, plan:) + second_subscription = create(:subscription, customer:, plan:) + create(:subscription, customer:, plan:, status: :terminated) + create(:subscription, customer:) + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + response = result["data"]["subscriptions"] + + expect(response["collection"].count).to eq(2) + expect(response["collection"].map { |s| s["id"] }).to contain_exactly( + first_subscription.id, + second_subscription.id + ) + expect(response["collection"].first["plan"]).to include( + "code" => plan.code + ) + + expect(response["metadata"]["currentPage"]).to eq(1) + expect(response["metadata"]["totalCount"]).to eq(2) + end + + context "with currency filter" do + let(:brl_plan) { create(:plan, organization:, amount_currency: "BRL") } + let!(:brl_subscription) { create(:subscription, customer:, plan: brl_plan) } + + let(:query) do + <<~GQL + query { + subscriptions(limit: 5, currency: "#{brl_plan.amount_currency}") { + collection { id } + metadata { totalCount } + } + } + GQL + end + + it "returns only subscriptions with matching currency" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + response = result["data"]["subscriptions"] + + expect(response["collection"].count).to eq(1) + expect(response["collection"].first["id"]).to eq(brl_subscription.id) + expect(response["metadata"]["totalCount"]).to eq(1) + end + end +end diff --git a/spec/graphql/resolvers/superset/dashboards_resolver_spec.rb b/spec/graphql/resolvers/superset/dashboards_resolver_spec.rb new file mode 100644 index 0000000..ee5f288 --- /dev/null +++ b/spec/graphql/resolvers/superset/dashboards_resolver_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Superset::DashboardsResolver do + let(:required_permission) { "analytics:view" } + let(:query) do + <<~GQL + query { + supersetDashboards { + id + dashboardTitle + embeddedId + guestToken + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:dashboards) do + [ + { + id: "1", + dashboard_title: "Sales Dashboard", + embedded_id: "embedded-uuid-1", + guest_token: "guest-token-1" + }, + { + id: "2", + dashboard_title: "Analytics Dashboard", + embedded_id: "embedded-uuid-2", + guest_token: "guest-token-2" + } + ] + end + + let(:result) do + BaseService::Result.new.tap do |result| + result.dashboards = dashboards + end + end + + before do + allow(Auth::SupersetService).to receive(:call).and_return(result) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "analytics:view" + + it "returns a list of Superset dashboards" do + graphql_result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + dashboards_response = graphql_result["data"]["supersetDashboards"] + + expect(dashboards_response).to be_an(Array) + expect(dashboards_response.size).to eq(2) + + expect(dashboards_response[0]["id"]).to eq("1") + expect(dashboards_response[0]["dashboardTitle"]).to eq("Sales Dashboard") + expect(dashboards_response[0]["embeddedId"]).to eq("embedded-uuid-1") + expect(dashboards_response[0]["guestToken"]).to eq("guest-token-1") + + expect(dashboards_response[1]["id"]).to eq("2") + expect(dashboards_response[1]["dashboardTitle"]).to eq("Analytics Dashboard") + expect(dashboards_response[1]["embeddedId"]).to eq("embedded-uuid-2") + expect(dashboards_response[1]["guestToken"]).to eq("guest-token-2") + + expect(Auth::SupersetService).to have_received(:call).with( + organization: organization, + user: nil + ) + end + + context "when no dashboards exist" do + let(:dashboards) { [] } + + it "returns an empty array" do + graphql_result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + dashboards_response = graphql_result["data"]["supersetDashboards"] + + expect(dashboards_response).to eq([]) + end + end + + context "when the superset service fails" do + let(:result) do + BaseService::Result.new.tap do |r| + r.service_failure!(code: "superset_auth_failed", message: "Failed to authenticate with Superset") + end + end + + it "returns an error" do + graphql_result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + expect(graphql_result["errors"]).to be_present + expect(graphql_result["errors"].first["extensions"]["code"]).to eq("superset_auth_failed") + end + end +end diff --git a/spec/graphql/resolvers/tax_resolver_spec.rb b/spec/graphql/resolvers/tax_resolver_spec.rb new file mode 100644 index 0000000..0c3f336 --- /dev/null +++ b/spec/graphql/resolvers/tax_resolver_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::TaxResolver do + let(:query) do + <<~GQL + query($taxId: ID!) { + tax(id: $taxId) { + id code description name rate customersCount + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:) } + + before do + tax + end + + it "returns a single tax" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {taxId: tax.id} + ) + + expect(result["data"]["tax"]).to include( + "id" => tax.id, + "code" => tax.code, + "description" => tax.description, + "name" => tax.name, + "rate" => tax.rate, + "customersCount" => 0 + ) + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + query:, + variables: {taxId: tax.id} + ) + + expect_graphql_error(result:, message: "Missing organization id") + end + end + + context "when tax is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {taxId: "unknown"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/taxes_resolver_spec.rb b/spec/graphql/resolvers/taxes_resolver_spec.rb new file mode 100644 index 0000000..dece9c1 --- /dev/null +++ b/spec/graphql/resolvers/taxes_resolver_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::TaxesResolver do + let(:query) do + <<~GQL + query { + taxes(limit: 5) { + collection { id name } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, organization:) } + + before { tax } + + it "returns a list of taxes" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query: + ) + + taxes_response = result["data"]["taxes"] + + expect(taxes_response["collection"].first).to include( + "id" => tax.id, + "name" => tax.name + ) + + expect(taxes_response["metadata"]).to include( + "currentPage" => 1, + "totalCount" => 1 + ) + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql(current_user: membership.user, query:) + + expect_graphql_error(result:, message: "Missing organization id") + end + end + + context "when not member of the organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: create(:organization), + query: + ) + + expect_graphql_error(result:, message: "Not in organization") + end + end +end diff --git a/spec/graphql/resolvers/version_resolver_spec.rb b/spec/graphql/resolvers/version_resolver_spec.rb new file mode 100644 index 0000000..db3ef2e --- /dev/null +++ b/spec/graphql/resolvers/version_resolver_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::VersionResolver do + let(:query) do + <<~GQL + query { + currentVersion { number githubUrl } + } + GQL + end + + it "returns the currentVersion" do + result = execute_graphql(query:) + + version_response = result["data"]["currentVersion"] + + expect(version_response["number"]).to be_present + expect(version_response["githubUrl"]).to start_with("https://github.com/getlago/lago-api") + end +end diff --git a/spec/graphql/resolvers/wallet_resolver_spec.rb b/spec/graphql/resolvers/wallet_resolver_spec.rb new file mode 100644 index 0000000..bb5ef1d --- /dev/null +++ b/spec/graphql/resolvers/wallet_resolver_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WalletResolver do + let(:query) do + <<~GQL + query($id: ID!) { + wallet(id: $id) { + id name status creditsBalance + metadata { key value } + recurringTransactionRules { + transactionName + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, :with_recurring_transaction_rules, customer:) } + + before { wallet } + + it "returns a wallet" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {id: wallet.id} + ) + + wallet_response = result["data"]["wallet"] + + expect(wallet_response).to eq( + { + "creditsBalance" => 0.0, + "id" => wallet.id, + "name" => wallet.name, + "metadata" => nil, + "recurringTransactionRules" => [{"transactionName" => "Recurring Transaction Rule"}], + "status" => "active" + } + ) + end + + context "when wallet has metadata" do + let(:metadata) { create(:item_metadata, owner: wallet, value: {"key1" => "value_1", "key2" => "value_2"}) } + + before { metadata } + + it "returns wallet with metadata" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {id: wallet.id} + ) + + wallet_response = result["data"]["wallet"] + + expect(wallet_response).to include( + "id" => wallet.id, + "name" => wallet.name, + "status" => "active", + "metadata" => [ + {"key" => "key1", "value" => "value_1"}, + {"key" => "key2", "value" => "value_2"} + ] + ) + end + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + query:, + variables: {id: wallet.id} + ) + + expect_graphql_error(result:, message: "Missing organization id") + end + end + + context "when wallet is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {id: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/wallet_transaction_consumptions_resolver_spec.rb b/spec/graphql/resolvers/wallet_transaction_consumptions_resolver_spec.rb new file mode 100644 index 0000000..f76c32b --- /dev/null +++ b/spec/graphql/resolvers/wallet_transaction_consumptions_resolver_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WalletTransactionConsumptionsResolver do + let(:query) do + <<~GQL + query($walletTransactionId: ID!, $page: Int, $limit: Int) { + walletTransactionConsumptions(walletTransactionId: $walletTransactionId, page: $page, limit: $limit) { + collection { + id + amountCents + creditAmount + createdAt + walletTransaction { id } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let!(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 500) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + + it "returns a list of consumptions for an inbound transaction" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: inbound_transaction.id, limit: 5} + ) + + consumptions_response = result["data"]["walletTransactionConsumptions"] + + expect(consumptions_response["collection"].count).to eq(1) + expect(consumptions_response["collection"].first["id"]).to eq(consumption.id) + expect(consumptions_response["collection"].first["amountCents"]).to eq("500") + expect(consumptions_response["collection"].first["walletTransaction"]["id"]).to eq(outbound_transaction.id) + expect(consumptions_response["metadata"]["currentPage"]).to eq(1) + expect(consumptions_response["metadata"]["totalCount"]).to eq(1) + end + + context "with pagination" do + before do + outbounds = create_list(:wallet_transaction, 3, wallet:, organization:, transaction_type: :outbound) + outbounds.each do |outbound| + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound, + consumed_amount_cents: 100) + end + end + + it "returns paginated results" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: inbound_transaction.id, page: 1, limit: 2} + ) + + consumptions_response = result["data"]["walletTransactionConsumptions"] + + expect(consumptions_response["collection"].count).to eq(2) + expect(consumptions_response["metadata"]["currentPage"]).to eq(1) + expect(consumptions_response["metadata"]["totalCount"]).to eq(4) + end + + it "returns second page of results" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: inbound_transaction.id, page: 2, limit: 2} + ) + + consumptions_response = result["data"]["walletTransactionConsumptions"] + + expect(consumptions_response["collection"].count).to eq(2) + expect(consumptions_response["metadata"]["currentPage"]).to eq(2) + expect(consumptions_response["metadata"]["totalCount"]).to eq(4) + end + end + + context "when transaction is outbound" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: outbound_transaction.id} + ) + + expect_graphql_error(result:, message: "Unprocessable Entity") + end + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, customer:, traceable: false) } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: inbound_transaction.id} + ) + + expect_graphql_error(result:, message: "Unprocessable Entity") + end + end + + context "when transaction does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: "non-existent-id"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when transaction belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_wallet) { create(:wallet, customer: other_customer, traceable: true) } + let(:other_transaction) do + create(:wallet_transaction, + wallet: other_wallet, + organization: other_organization, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: other_transaction.id} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/wallet_transaction_fundings_resolver_spec.rb b/spec/graphql/resolvers/wallet_transaction_fundings_resolver_spec.rb new file mode 100644 index 0000000..fa36774 --- /dev/null +++ b/spec/graphql/resolvers/wallet_transaction_fundings_resolver_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WalletTransactionFundingsResolver do + let(:query) do + <<~GQL + query($walletTransactionId: ID!, $page: Int, $limit: Int) { + walletTransactionFundings(walletTransactionId: $walletTransactionId, page: $page, limit: $limit) { + collection { + id + amountCents + creditAmount + createdAt + walletTransaction { id } + } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let!(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 500) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + + it "returns a list of fundings for an outbound transaction" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: outbound_transaction.id, limit: 5} + ) + + fundings_response = result["data"]["walletTransactionFundings"] + + expect(fundings_response["collection"].count).to eq(1) + expect(fundings_response["collection"].first["id"]).to eq(consumption.id) + expect(fundings_response["collection"].first["amountCents"]).to eq("500") + expect(fundings_response["collection"].first["walletTransaction"]["id"]).to eq(inbound_transaction.id) + expect(fundings_response["metadata"]["currentPage"]).to eq(1) + expect(fundings_response["metadata"]["totalCount"]).to eq(1) + end + + context "with pagination" do + let(:inbounds) { create_list(:wallet_transaction, 3, wallet:, organization:, transaction_type: :inbound, remaining_amount_cents: 10000) } + + before do + inbounds.each do |inbound| + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 100) + end + end + + it "returns paginated results" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: outbound_transaction.id, page: 1, limit: 2} + ) + + fundings_response = result["data"]["walletTransactionFundings"] + + expect(fundings_response["collection"].count).to eq(2) + expect(fundings_response["metadata"]["currentPage"]).to eq(1) + expect(fundings_response["metadata"]["totalCount"]).to eq(4) + end + + it "returns second page of results" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: outbound_transaction.id, page: 2, limit: 2} + ) + + fundings_response = result["data"]["walletTransactionFundings"] + + expect(fundings_response["collection"].count).to eq(2) + expect(fundings_response["metadata"]["currentPage"]).to eq(2) + expect(fundings_response["metadata"]["totalCount"]).to eq(4) + end + end + + context "when transaction is inbound" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: inbound_transaction.id} + ) + + expect_graphql_error(result:, message: "Unprocessable Entity") + end + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, customer:, traceable: false) } + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: outbound_transaction.id} + ) + + expect_graphql_error(result:, message: "Unprocessable Entity") + end + end + + context "when transaction does not exist" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: "non-existent-id"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when transaction belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_wallet) { create(:wallet, customer: other_customer, traceable: true) } + let(:other_transaction) do + create(:wallet_transaction, + wallet: other_wallet, + organization: other_organization, + transaction_type: :outbound) + end + + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: {walletTransactionId: other_transaction.id} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/wallet_transaction_resolver_spec.rb b/spec/graphql/resolvers/wallet_transaction_resolver_spec.rb new file mode 100644 index 0000000..ede1f2d --- /dev/null +++ b/spec/graphql/resolvers/wallet_transaction_resolver_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WalletTransactionResolver do + subject(:result) do + execute_graphql( + current_user: membership.user, + current_organization:, + query:, + variables: {id: wallet_transaction_id} + ) + end + + let(:query) do + <<~GQL + query($id: ID!) { + walletTransaction(id: $id) { + id + status + amount + transactionType + failedAt + invoice { + id + totalAmountCents + payments { + id + amountCents + amountCurrency + } + } + } + } + GQL + end + + let(:wallet_transaction_id) { wallet_transaction.id } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:) } + let!(:wallet_transaction) { create(:wallet_transaction, :failed, wallet:, invoice:) } + let(:invoice) { create(:invoice, customer:) } + + context "with valid authorization" do + let(:current_organization) { organization } + + context "when associated invoice is visible" do + let!(:payment) { create(:payment, payable: invoice, amount_cents: 1000, amount_currency: "EUR") } + + it "returns a single wallet transaction with its invoice and payments" do + transaction_response = result["data"]["walletTransaction"] + invoice_response = transaction_response["invoice"] + payments_response = invoice_response["payments"] + + expect(transaction_response["id"]).to eq(wallet_transaction.id.to_s) + expect(transaction_response["status"]).to eq(wallet_transaction.status) + expect(transaction_response["failedAt"]).not_to be_nil + expect(transaction_response["amount"]).to eq(wallet_transaction.amount.to_s) + expect(transaction_response["transactionType"]).to eq(wallet_transaction.transaction_type) + expect(invoice_response["id"]).to eq(invoice.id.to_s) + expect(invoice_response["totalAmountCents"].to_i).to eq(invoice.total_amount_cents) + expect(payments_response).not_to be_empty + expect(payments_response.first["id"]).to eq(payment.id.to_s) + expect(payments_response.first["amountCents"].to_i).to eq(payment.amount_cents) + expect(payments_response.first["amountCurrency"]).to eq(payment.amount_currency) + end + end + + context "when associated invoice is invisible" do + let(:invoice) { create(:invoice, :invisible) } + + it "returns a single wallet transaction with its invoice and payments" do + transaction_response = result["data"]["walletTransaction"] + + expect(transaction_response["id"]).to eq(wallet_transaction.id.to_s) + expect(transaction_response["status"]).to eq(wallet_transaction.status) + expect(transaction_response["failedAt"]).not_to be_nil + expect(transaction_response["amount"]).to eq(wallet_transaction.amount.to_s) + expect(transaction_response["transactionType"]).to eq(wallet_transaction.transaction_type) + expect(transaction_response["invoice"]).to be_nil + end + end + + context "when transaction does not exist" do + let(:wallet_transaction_id) { SecureRandom.uuid } + + it "returns an error" do + expect_graphql_error(result:, message: "Resource not found") + end + end + end + + context "without current organization" do + let(:current_organization) { nil } + + it "returns an error" do + expect_graphql_error(result:, message: "Missing organization id") + end + end + + context "when not a member of the organization" do + let(:current_organization) { create(:organization) } + + it "returns an error" do + expect_graphql_error(result:, message: "Not in organization") + end + end +end diff --git a/spec/graphql/resolvers/wallets/alert_resolver_spec.rb b/spec/graphql/resolvers/wallets/alert_resolver_spec.rb new file mode 100644 index 0000000..abae15e --- /dev/null +++ b/spec/graphql/resolvers/wallets/alert_resolver_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Wallets::AlertResolver do + let(:required_permission) { "wallets:update" } + let(:query) do + <<~GQL + query($alertId: ID!) { + walletAlert(id: $alertId) { + id code name thresholds {code value recurring} + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:alert) { create(:wallet_balance_amount_alert, organization:, recurring_threshold: 10, thresholds: [75, 50, 25]) } + + before do + alert + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:update" + + it "returns a single alert" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {alertId: alert.id} + ) + + alert_response = result["data"]["walletAlert"] + + expect(alert_response["id"]).to eq(alert.id) + expect(alert_response["code"]).to start_with("default") + expect(alert_response["name"]).to eq("General Alert") + expect(alert_response["thresholds"].map(&:symbolize_keys)).to contain_exactly( + {code: "warn75", value: "75.0", recurring: false}, + {code: "warn50", value: "50.0", recurring: false}, + {code: "warn25", value: "25.0", recurring: false}, + {code: "rec", value: "10.0", recurring: true} + ) + end + + context "when alert is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {alertId: "invalid"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end +end diff --git a/spec/graphql/resolvers/wallets/alerts_resolver_spec.rb b/spec/graphql/resolvers/wallets/alerts_resolver_spec.rb new file mode 100644 index 0000000..11a2d99 --- /dev/null +++ b/spec/graphql/resolvers/wallets/alerts_resolver_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::Wallets::AlertsResolver do + let(:required_permission) { "wallets:update" } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:wallet) { create(:wallet, organization:) } + let(:balance_alert) { create(:wallet_balance_amount_alert, organization:, wallet:, recurring_threshold: 10, thresholds: [75, 50, 25]) } + let(:credits_alert) { create(:wallet_credits_balance_alert, organization:, wallet:, recurring_threshold: 10, thresholds: [75, 50, 25]) } + let(:another_alert) { create(:alert, organization:) } + + let(:query) do + <<~GQL + query($walletId: String!) { + walletAlerts(walletId: $walletId) { + collection { id name code deletedAt thresholds { code value recurring} } + metadata { currentPage, totalCount } + } + } + GQL + end + + before do + balance_alert + credits_alert + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "wallets:update" + + it "returns all alerts" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {walletId: wallet.id} + ) + + alerts = result["data"]["walletAlerts"]["collection"] + + expect(alerts.pluck("id")).to contain_exactly(balance_alert.id, credits_alert.id) + expect(alerts).to all(include({"name" => "General Alert", "deletedAt" => nil})) + expect(alerts.pluck("code")).to all(start_with("default")) + expect(alerts.pluck("thresholds")).to all(contain_exactly( + {"code" => "warn75", "value" => "75.0", "recurring" => false}, + {"code" => "warn50", "value" => "50.0", "recurring" => false}, + {"code" => "warn25", "value" => "25.0", "recurring" => false}, + {"code" => "rec", "value" => "10.0", "recurring" => true} + )) + + metadata = result["data"]["walletAlerts"]["metadata"] + expect(metadata["currentPage"]).to eq(1) + expect(metadata["totalCount"]).to eq(2) + end + + context "when no alert is not found" do + it "returns an empty list" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {walletId: "invalid"} + ) + + expect(result["data"]["walletAlerts"]["collection"]).to be_empty + end + end + + context "when making a list of existing alerts combination" do + let(:query) do + <<~GQL + query($walletId: String!) { + walletAlerts(walletId: $walletId) { + collection { alertType code } + metadata { currentPage, totalCount } + } + } + GQL + end + + it "returns all alerts" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {walletId: wallet.id} + ) + alerts = result["data"]["walletAlerts"]["collection"] + + expect(alerts).to match_array([ + include("alertType" => "wallet_balance_amount"), + include("alertType" => "wallet_credits_balance") + ]) + + metadata = result["data"]["walletAlerts"]["metadata"] + expect(metadata["currentPage"]).to eq(1) + expect(metadata["totalCount"]).to eq(2) + end + end +end diff --git a/spec/graphql/resolvers/wallets_resolver_spec.rb b/spec/graphql/resolvers/wallets_resolver_spec.rb new file mode 100644 index 0000000..4d53e0f --- /dev/null +++ b/spec/graphql/resolvers/wallets_resolver_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WalletsResolver do + let(:query) do + <<~GQL + query($customerId: ID!) { + wallets(customerId: $customerId, limit: 5, status: active) { + collection { id } + metadata { + currentPage, + totalCount, + customerActiveWalletsCount + } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, organization:) } + let(:wallet) { create(:wallet, organization:, customer:) } + + before do + subscription + wallet + + create(:wallet, status: :terminated, customer:) + end + + it "returns a list of wallets" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: { + customerId: customer.id + } + ) + + wallets_response = result["data"]["wallets"] + + expect(wallets_response["collection"].count).to eq(customer.wallets.active.count) + expect(wallets_response["collection"].first["id"]).to eq(wallet.id) + + expect(wallets_response["metadata"]["customerActiveWalletsCount"]).to eq(1) + expect(wallets_response["metadata"]["currentPage"]).to eq(1) + expect(wallets_response["metadata"]["totalCount"]).to eq(1) + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + query:, + variables: { + customerId: customer.id + } + ) + + expect_graphql_error( + result:, + message: "Missing organization id" + ) + end + end + + context "when not member of the organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: create(:organization), + query:, + variables: { + customerId: customer.id + } + ) + + expect_graphql_error( + result:, + message: "Not in organization" + ) + end + end + + context "when customer does not exists" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: { + customerId: "123456" + } + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/wallets_resolver_transactions_spec.rb b/spec/graphql/resolvers/wallets_resolver_transactions_spec.rb new file mode 100644 index 0000000..7eb7dfe --- /dev/null +++ b/spec/graphql/resolvers/wallets_resolver_transactions_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WalletsResolver do + let(:query) do + <<~GQL + query($walletId: ID!) { + walletTransactions(walletId: $walletId, limit: 5, status: settled) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, organization:) } + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + + before do + subscription + wallet_transaction + end + + it "returns a list of wallet transactions" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: { + walletId: wallet.id + } + ) + + wallet_transactions_response = result["data"]["walletTransactions"] + + expect(wallet_transactions_response["collection"].count).to eq(wallet.wallet_transactions.count) + expect(wallet_transactions_response["collection"].first["id"]).to eq(wallet_transaction.id) + + expect(wallet_transactions_response["metadata"]["currentPage"]).to eq(1) + expect(wallet_transactions_response["metadata"]["totalCount"]).to eq(1) + end + + context "without current organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + query:, + variables: { + walletId: wallet.id + } + ) + + expect_graphql_error( + result:, + message: "Missing organization id" + ) + end + end + + context "when not member of the organization" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: create(:organization), + query:, + variables: { + walletId: wallet.id + } + ) + + expect_graphql_error( + result:, + message: "Not in organization" + ) + end + end + + context "when wallet does not exists" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + query:, + variables: { + walletId: "123456" + } + ) + + expect_graphql_error( + result:, + message: "Resource not found" + ) + end + end +end diff --git a/spec/graphql/resolvers/webhook_endpoint_resolver_spec.rb b/spec/graphql/resolvers/webhook_endpoint_resolver_spec.rb new file mode 100644 index 0000000..4d8ad71 --- /dev/null +++ b/spec/graphql/resolvers/webhook_endpoint_resolver_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WebhookEndpointResolver do + let(:required_permission) { "developers:manage" } + let(:query) do + <<-GQL + query($webhookEndpointId: ID!) { + webhookEndpoint(id: $webhookEndpointId) { + id + webhookUrl + signatureAlgo + name + eventTypes + createdAt + updatedAt + organization { id name } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:webhook_endpoint) { build(:webhook_endpoint, organization:, event_types: ["customer.created"]) } + let(:organization) { membership.organization } + + before do + organization.webhook_endpoints << webhook_endpoint + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + it "returns a single credit note" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: { + webhookEndpointId: webhook_endpoint.id + } + ) + + webhook_endpoint_response = result["data"]["webhookEndpoint"] + + expect(webhook_endpoint_response["id"]).to eq(webhook_endpoint.id) + expect(webhook_endpoint_response["webhookUrl"]).to eq(webhook_endpoint.webhook_url) + expect(webhook_endpoint_response["signatureAlgo"]).to eq(webhook_endpoint.signature_algo) + expect(webhook_endpoint_response["name"]).to eq(webhook_endpoint.name) + expect(webhook_endpoint_response["eventTypes"]).to match_array(["customer_created"]) + expect(webhook_endpoint_response["createdAt"]).to eq(webhook_endpoint.created_at.iso8601) + expect(webhook_endpoint_response["updatedAt"]).to eq(webhook_endpoint.updated_at.iso8601) + expect(webhook_endpoint_response["organization"]["id"]).to eq(organization.id) + expect(webhook_endpoint_response["organization"]["name"]).to eq(organization.name) + end +end diff --git a/spec/graphql/resolvers/webhook_endpoints_resolver_spec.rb b/spec/graphql/resolvers/webhook_endpoints_resolver_spec.rb new file mode 100644 index 0000000..53da9cb --- /dev/null +++ b/spec/graphql/resolvers/webhook_endpoints_resolver_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WebhookEndpointsResolver do + let(:required_permission) { "developers:manage" } + let(:query) do + <<~GQL + query { + webhookEndpoints(limit: 5) { + collection { id webhookUrl signatureAlgo name eventTypes } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + it "returns a list of webhook endpoints" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + webhook_endpoints_response = result["data"]["webhookEndpoints"] + + expect(webhook_endpoints_response["collection"].first).to include( + "id" => organization.webhook_endpoints.first.id, + "webhookUrl" => organization.webhook_endpoints.first.webhook_url, + "signatureAlgo" => organization.webhook_endpoints.first.signature_algo, + "name" => organization.webhook_endpoints.first.name, + "eventTypes" => organization.webhook_endpoints.first.event_types + ) + + expect(webhook_endpoints_response["metadata"]).to include( + "currentPage" => 1, + "totalCount" => 1 + ) + end +end diff --git a/spec/graphql/resolvers/webhook_resolver_spec.rb b/spec/graphql/resolvers/webhook_resolver_spec.rb new file mode 100644 index 0000000..e5de595 --- /dev/null +++ b/spec/graphql/resolvers/webhook_resolver_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WebhookResolver do + let(:required_permission) { "developers:manage" } + let(:query) do + <<~GQL + query($id: ID!) { + webhook(id: $id) { + id endpoint status webhookType httpStatus payload response createdAt + } + } + GQL + end + + let(:webhook_endpoint) { create(:webhook_endpoint) } + let(:webhook) { create(:webhook, :succeeded, webhook_endpoint:) } + let(:organization) { webhook_endpoint.organization.reload } + let(:membership) { create(:membership, organization:) } + + before { webhook } + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + it "returns a single webhook" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: webhook.id} + ) + + webhook_response = result["data"]["webhook"] + expect(webhook_response["id"]).to eq(webhook.id) + expect(webhook_response["endpoint"]).to eq(webhook.endpoint) + expect(webhook_response["status"]).to eq(webhook.status) + expect(webhook_response["webhookType"]).to eq(webhook.webhook_type) + expect(webhook_response["payload"]).to be_a String + expect(JSON.parse(webhook_response["payload"])).to be_a Hash + end + + context "when webhook is not found" do + it "returns an error" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: "foo"} + ) + + expect_graphql_error(result:, message: "Resource not found") + end + end + + context "when the webhook payload is json-serialized in the database" do + before do + webhook.update_column(:payload, {"foo" => "bar"}.to_json) # rubocop:disable Rails/SkipsModelValidations + end + + it "returns the webhook with properly formatted payload" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query:, + variables: {id: webhook.id} + ) + + webhook_response = result["data"]["webhook"] + + expect(webhook_response["payload"]).to be_a String + expect(JSON.parse(webhook_response["payload"])).to eq({"foo" => "bar"}) + end + end +end diff --git a/spec/graphql/resolvers/webhooks_resolver_spec.rb b/spec/graphql/resolvers/webhooks_resolver_spec.rb new file mode 100644 index 0000000..63f4bdc --- /dev/null +++ b/spec/graphql/resolvers/webhooks_resolver_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Resolvers::WebhooksResolver do + let(:required_permission) { "developers:manage" } + let(:query) do + <<~GQL + query { + webhooks(limit: 5, webhookEndpointId: "#{webhook_endpoint.id}") { + collection { id payload } + metadata { currentPage, totalCount } + } + } + GQL + end + + let(:webhook_endpoint) { create(:webhook_endpoint) } + let(:organization) { webhook_endpoint.organization.reload } + let(:membership) { create(:membership, organization:) } + + before do + create_list(:webhook, 5, :succeeded, webhook_endpoint:) + end + + it_behaves_like "requires current user" + it_behaves_like "requires current organization" + it_behaves_like "requires permission", "developers:manage" + + it "returns a list of webhooks" do + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + webhooks_response = result["data"]["webhooks"] + webhook = webhooks_response["collection"].first + + expect(webhooks_response["collection"].count).to eq(webhook_endpoint.webhooks.count) + expect(webhooks_response["metadata"]["currentPage"]).to eq(1) + expect(webhook["payload"]).to be_a String + expect(JSON.parse(webhook["payload"])).to be_a Hash + end + + context "when the webhook payload is json-serialized in the database" do + it "returns a list of webhooks" do + Webhook.all.find_each do |w| + w.update_column(:payload, {"foo" => "bar"}.to_json) # rubocop:disable Rails/SkipsModelValidations + end + + result = execute_graphql( + current_user: membership.user, + current_organization: organization, + permissions: required_permission, + query: + ) + + webhooks_response = result["data"]["webhooks"] + webhook = webhooks_response["collection"].first + + expect(webhooks_response["collection"].count).to eq(webhook_endpoint.webhooks.count) + expect(webhooks_response["metadata"]["currentPage"]).to eq(1) + expect(webhook["payload"]).to be_a String + expect(JSON.parse(webhook["payload"])).to be_a Hash + end + end +end diff --git a/spec/graphql/sources/memberships_for_role_spec.rb b/spec/graphql/sources/memberships_for_role_spec.rb new file mode 100644 index 0000000..9d6f5d7 --- /dev/null +++ b/spec/graphql/sources/memberships_for_role_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Sources::MembershipsForRole do + subject(:source) { described_class.new(organization) } + + let(:membership_role) { create(:membership_role) } + let(:organization) { membership_role.organization } + let(:membership) { membership_role.membership } + let(:role) { membership_role.role } + + describe "#fetch" do + it "returns memberships for a role" do + result = source.fetch([role.id]) + + expect(result[0]).to contain_exactly(membership) + end + + it "returns empty array for roles without memberships" do + empty_role = create(:role, organization:) + + result = source.fetch([empty_role.id]) + + expect(result[0]).to be_blank + end + + it "does not return memberships from other organizations" do + role = create(:role, :admin) + create(:membership_role, membership:, role:, organization:) + other_membership = create(:membership_role, role:).membership + + result = source.fetch([role.id]) + + expect(result[0]).to contain_exactly(membership) + expect(result[0]).not_to include(other_membership) + end + + it "does not return revoked memberships" do + revoked_membership = create(:membership, :revoked, organization:) + create(:membership_role, membership: revoked_membership, role:, organization:) + + result = source.fetch([role.id]) + + expect(result[0]).to contain_exactly(membership) + end + end +end diff --git a/spec/graphql/types/activity_logs/activity_source_enum_spec.rb b/spec/graphql/types/activity_logs/activity_source_enum_spec.rb new file mode 100644 index 0000000..24a1158 --- /dev/null +++ b/spec/graphql/types/activity_logs/activity_source_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ActivityLogs::ActivitySourceEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array(%w[api front system]) + end +end diff --git a/spec/graphql/types/activity_logs/activity_type_enum_spec.rb b/spec/graphql/types/activity_logs/activity_type_enum_spec.rb new file mode 100644 index 0000000..ef4edfb --- /dev/null +++ b/spec/graphql/types/activity_logs/activity_type_enum_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ActivityLogs::ActivityTypeEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array( + %w[ + billable_metric_created + billable_metric_updated + billable_metric_deleted + plan_created + plan_updated + plan_deleted + customer_created + customer_updated + customer_deleted + invoice_drafted + invoice_failed + invoice_one_off_created + invoice_created + invoice_paid_credit_added + invoice_generated + invoice_payment_status_updated + invoice_payment_overdue + invoice_voided + invoice_regenerated + invoice_payment_failure + payment_receipt_created + payment_receipt_generated + credit_note_created + credit_note_generated + credit_note_refund_failure + billing_entities_created + billing_entities_updated + billing_entities_deleted + subscription_canceled + subscription_incomplete + subscription_started + subscription_terminated + subscription_updated + wallet_created + wallet_updated + wallet_transaction_payment_failure + wallet_transaction_created + wallet_transaction_updated + payment_recorded + coupon_created + coupon_updated + coupon_deleted + applied_coupon_created + applied_coupon_deleted + payment_request_created + feature_created + feature_deleted + feature_updated + email_sent + ] + ) + end +end diff --git a/spec/graphql/types/activity_logs/object_spec.rb b/spec/graphql/types/activity_logs/object_spec.rb new file mode 100644 index 0000000..08ba36c --- /dev/null +++ b/spec/graphql/types/activity_logs/object_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ActivityLogs::Object do + subject { described_class } + + it do + expect(subject).to have_field(:activity_id).of_type("ID!") + expect(subject).to have_field(:activity_object).of_type("JSON") + expect(subject).to have_field(:activity_object_changes).of_type("JSON") + expect(subject).to have_field(:activity_source).of_type("ActivitySourceEnum!") + expect(subject).to have_field(:activity_type).of_type("ActivityTypeEnum!") + expect(subject).to have_field(:api_key).of_type("SanitizedApiKey") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:external_customer_id).of_type("String") + expect(subject).to have_field(:external_subscription_id).of_type("String") + expect(subject).to have_field(:logged_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:organization).of_type("Organization") + expect(subject).to have_field(:resource).of_type("ActivityLogResourceObject") + expect(subject).to have_field(:user_email).of_type("String") + end +end diff --git a/spec/graphql/types/activity_logs/resource_object_spec.rb b/spec/graphql/types/activity_logs/resource_object_spec.rb new file mode 100644 index 0000000..ed56068 --- /dev/null +++ b/spec/graphql/types/activity_logs/resource_object_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ActivityLogs::ResourceObject do + subject { described_class } + + it "has the correct graphql name" do + expect(subject.graphql_name).to eq("ActivityLogResourceObject") + end + + it "includes the correct possible types" do + expect(subject.possible_types).to contain_exactly( + Types::BillableMetrics::Object, + Types::Plans::Object, + Types::Customers::Object, + Types::Invoices::Object, + Types::CreditNotes::Object, + Types::BillingEntities::Object, + Types::Subscriptions::Object, + Types::Wallets::Object, + Types::Coupons::Object, + Types::PaymentRequests::Object, + Types::Entitlement::FeatureObject, + Types::PaymentReceipts::Object + ) + end + + describe ".resolve_type" do + let(:billable_metric) { create(:billable_metric) } + let(:plan) { create(:plan) } + let(:customer) { create(:customer) } + let(:invoice) { create(:invoice) } + let(:credit_note) { create(:credit_note) } + let(:billing_entity) { create(:billing_entity) } + let(:subscription) { create(:subscription) } + let(:wallet) { create(:wallet) } + let(:coupon) { create(:coupon) } + let(:payment_request) { create(:payment_request) } + let(:feature) { create(:feature) } + + it "returns Types::BillableMetrics::Object for BillableMetric objects" do + expect(subject.resolve_type(billable_metric, {})).to eq(Types::BillableMetrics::Object) + end + + it "returns Types::Plans::Object for Plan objects" do + expect(subject.resolve_type(plan, {})).to eq(Types::Plans::Object) + end + + it "returns Types::Customers::Object for Customer objects" do + expect(subject.resolve_type(customer, {})).to eq(Types::Customers::Object) + end + + it "returns Types::Invoices::Object for Invoice objects" do + expect(subject.resolve_type(invoice, {})).to eq(Types::Invoices::Object) + end + + it "returns Types::CreditNotes::Object for CreditNote objects" do + expect(subject.resolve_type(credit_note, {})).to eq(Types::CreditNotes::Object) + end + + it "returns Types::BillingEntities::Object for BillingEntity objects" do + expect(subject.resolve_type(billing_entity, {})).to eq(Types::BillingEntities::Object) + end + + it "returns Types::Subscriptions::Object for Subscription objects" do + expect(subject.resolve_type(subscription, {})).to eq(Types::Subscriptions::Object) + end + + it "returns Types::Wallets::Object for Wallet objects" do + expect(subject.resolve_type(wallet, {})).to eq(Types::Wallets::Object) + end + + it "returns Types::Coupons::Object for Coupon objects" do + expect(subject.resolve_type(coupon, {})).to eq(Types::Coupons::Object) + end + + it "raises an error for unexpected types" do + expect { subject.resolve_type("Unexpected", {}) }.to raise_error(StandardError) + end + + it "returns Types::PaymentRequests::Object for PaymentRequest objects" do + expect(subject.resolve_type(payment_request, {})).to eq(Types::PaymentRequests::Object) + end + + it "returns Types::Entitlement::FeatureObject for Feature objects" do + expect(subject.resolve_type(feature, {})).to eq(Types::Entitlement::FeatureObject) + end + end +end diff --git a/spec/graphql/types/activity_logs/resource_type_enum_spec.rb b/spec/graphql/types/activity_logs/resource_type_enum_spec.rb new file mode 100644 index 0000000..a20d933 --- /dev/null +++ b/spec/graphql/types/activity_logs/resource_type_enum_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ActivityLogs::ResourceTypeEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array( + %w[ + billable_metric + plan + customer + invoice + credit_note + billing_entity + subscription + wallet + coupon + payment_request + feature + payment_receipt + ] + ) + end +end diff --git a/spec/graphql/types/add_ons/create_input_spec.rb b/spec/graphql/types/add_ons/create_input_spec.rb new file mode 100644 index 0000000..eac77eb --- /dev/null +++ b/spec/graphql/types/add_ons/create_input_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AddOns::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:amount_cents).of_type("BigInt!") + expect(subject).to accept_argument(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/add_ons/object_spec.rb b/spec/graphql/types/add_ons/object_spec.rb new file mode 100644 index 0000000..8af1ea0 --- /dev/null +++ b/spec/graphql/types/add_ons/object_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AddOns::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:invoice_display_name).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:deleted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:applied_add_ons_count).of_type("Int!") + expect(subject).to have_field(:customers_count).of_type("Int!") + expect(subject).to have_field(:taxes).of_type("[Tax!]") + expect(subject).to have_field(:integration_mappings).of_type("[Mapping!]") + end +end diff --git a/spec/graphql/types/add_ons/update_input_spec.rb b/spec/graphql/types/add_ons/update_input_spec.rb new file mode 100644 index 0000000..93f0936 --- /dev/null +++ b/spec/graphql/types/add_ons/update_input_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AddOns::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:amount_cents).of_type("BigInt!") + expect(subject).to accept_argument(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/adjusted_fees/create_input_spec.rb b/spec/graphql/types/adjusted_fees/create_input_spec.rb new file mode 100644 index 0000000..dc7b65b --- /dev/null +++ b/spec/graphql/types/adjusted_fees/create_input_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AdjustedFees::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:invoice_id).of_type("ID!") + expect(subject).to accept_argument(:invoice_subscription_id).of_type("ID") + expect(subject).to accept_argument(:fee_id).of_type("ID") + expect(subject).to accept_argument(:charge_id).of_type("ID") + expect(subject).to accept_argument(:charge_filter_id).of_type("ID") + expect(subject).to accept_argument(:fixed_charge_id).of_type("ID") + expect(subject).to accept_argument(:subscription_id).of_type("ID") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:unit_precise_amount).of_type("String") + expect(subject).to accept_argument(:units).of_type("Float") + end +end diff --git a/spec/graphql/types/ai_conversations/message_spec.rb b/spec/graphql/types/ai_conversations/message_spec.rb new file mode 100644 index 0000000..9c75d32 --- /dev/null +++ b/spec/graphql/types/ai_conversations/message_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AiConversations::Message do + subject { described_class } + + it do + expect(subject).to have_field(:content).of_type("String!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:type).of_type("String!") + end +end diff --git a/spec/graphql/types/ai_conversations/object_spec.rb b/spec/graphql/types/ai_conversations/object_spec.rb new file mode 100644 index 0000000..67ee223 --- /dev/null +++ b/spec/graphql/types/ai_conversations/object_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AiConversations::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization!") + expect(subject).to have_field(:mistral_conversation_id).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/ai_conversations/object_with_messages_spec.rb b/spec/graphql/types/ai_conversations/object_with_messages_spec.rb new file mode 100644 index 0000000..6eb61d0 --- /dev/null +++ b/spec/graphql/types/ai_conversations/object_with_messages_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AiConversations::ObjectWithMessages do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization!") + expect(subject).to have_field(:mistral_conversation_id).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:messages).of_type("[AiConversationMessage!]!") + end +end diff --git a/spec/graphql/types/ai_conversations/stream_spec.rb b/spec/graphql/types/ai_conversations/stream_spec.rb new file mode 100644 index 0000000..db59043 --- /dev/null +++ b/spec/graphql/types/ai_conversations/stream_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AiConversations::Stream do + subject { described_class } + + it do + expect(subject).to have_field(:chunk).of_type("String") + expect(subject).to have_field(:done).of_type("Boolean!") + end +end diff --git a/spec/graphql/types/analytics/gross_revenues/object_spec.rb b/spec/graphql/types/analytics/gross_revenues/object_spec.rb new file mode 100644 index 0000000..2fcf533 --- /dev/null +++ b/spec/graphql/types/analytics/gross_revenues/object_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Analytics::GrossRevenues::Object do + subject { described_class } + + it do + expect(subject).to have_field(:month).of_type("ISO8601DateTime!") + expect(subject).to have_field(:amount_cents).of_type("BigInt") + expect(subject).to have_field(:currency).of_type("CurrencyEnum") + end +end diff --git a/spec/graphql/types/analytics/invoice_collections/object_spec.rb b/spec/graphql/types/analytics/invoice_collections/object_spec.rb new file mode 100644 index 0000000..711c264 --- /dev/null +++ b/spec/graphql/types/analytics/invoice_collections/object_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Analytics::InvoiceCollections::Object do + subject { described_class } + + it do + expect(subject).to have_field(:month).of_type("ISO8601DateTime!") + expect(subject).to have_field(:payment_status).of_type("InvoicePaymentStatusTypeEnum") + expect(subject).to have_field(:invoices_count).of_type("BigInt!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:currency).of_type("CurrencyEnum") + end +end diff --git a/spec/graphql/types/analytics/invoiced_usages/object_spec.rb b/spec/graphql/types/analytics/invoiced_usages/object_spec.rb new file mode 100644 index 0000000..99009bc --- /dev/null +++ b/spec/graphql/types/analytics/invoiced_usages/object_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Analytics::InvoicedUsages::Object do + subject { described_class } + + it do + expect(subject).to have_field(:month).of_type("ISO8601DateTime!") + expect(subject).to have_field(:code).of_type("String") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + end +end diff --git a/spec/graphql/types/analytics/mrrs/object_spec.rb b/spec/graphql/types/analytics/mrrs/object_spec.rb new file mode 100644 index 0000000..8accd80 --- /dev/null +++ b/spec/graphql/types/analytics/mrrs/object_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Analytics::Mrrs::Object do + subject { described_class } + + it do + expect(subject).to have_field(:month).of_type("ISO8601DateTime!") + expect(subject).to have_field(:amount_cents).of_type("BigInt") + expect(subject).to have_field(:currency).of_type("CurrencyEnum") + end +end diff --git a/spec/graphql/types/analytics/overdue_balances/object_spec.rb b/spec/graphql/types/analytics/overdue_balances/object_spec.rb new file mode 100644 index 0000000..b4fa72a --- /dev/null +++ b/spec/graphql/types/analytics/overdue_balances/object_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Analytics::OverdueBalances::Object do + subject { described_class } + + it do + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:lago_invoice_ids).of_type("[String!]!") + expect(subject).to have_field(:month).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/api_keys/object_spec.rb b/spec/graphql/types/api_keys/object_spec.rb new file mode 100644 index 0000000..ad92c4b --- /dev/null +++ b/spec/graphql/types/api_keys/object_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ApiKeys::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:permissions).of_type("JSON!") + expect(subject).to have_field(:value).of_type("String!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:expires_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:last_used_at).of_type("ISO8601DateTime") + end +end diff --git a/spec/graphql/types/api_keys/rotate_input_spec.rb b/spec/graphql/types/api_keys/rotate_input_spec.rb new file mode 100644 index 0000000..2eaacf1 --- /dev/null +++ b/spec/graphql/types/api_keys/rotate_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ApiKeys::RotateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:expires_at).of_type("ISO8601DateTime") + end +end diff --git a/spec/graphql/types/api_keys/sanitized_object_spec.rb b/spec/graphql/types/api_keys/sanitized_object_spec.rb new file mode 100644 index 0000000..ddda4d9 --- /dev/null +++ b/spec/graphql/types/api_keys/sanitized_object_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ApiKeys::SanitizedObject do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:permissions).of_type("JSON!") + expect(subject).to have_field(:value).of_type("String!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:expires_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:last_used_at).of_type("ISO8601DateTime") + end +end diff --git a/spec/graphql/types/api_keys/update_input_spec.rb b/spec/graphql/types/api_keys/update_input_spec.rb new file mode 100644 index 0000000..f785a80 --- /dev/null +++ b/spec/graphql/types/api_keys/update_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ApiKeys::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:permissions).of_type("JSON") + end +end diff --git a/spec/graphql/types/api_logs/http_method_enum_spec.rb b/spec/graphql/types/api_logs/http_method_enum_spec.rb new file mode 100644 index 0000000..a745e8f --- /dev/null +++ b/spec/graphql/types/api_logs/http_method_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ApiLogs::HttpMethodEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array(%w[post put delete]) + end +end diff --git a/spec/graphql/types/api_logs/http_status_spec.rb b/spec/graphql/types/api_logs/http_status_spec.rb new file mode 100644 index 0000000..8abaea1 --- /dev/null +++ b/spec/graphql/types/api_logs/http_status_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ApiLogs::HttpStatus do + it "coerce input to integer when possible" do + expect(described_class.coerce_input("failed", nil)).to eq("failed") + expect(described_class.coerce_input("succeeded", nil)).to eq("succeeded") + + expect(described_class.coerce_input("404", nil)).to eq(404) + expect(described_class.coerce_input("200", nil)).to eq(200) + end + + it "do not coerce result" do + expect(described_class.coerce_result("failed", nil)).to eq("failed") + expect(described_class.coerce_result("succeeded", nil)).to eq("succeeded") + + expect(described_class.coerce_result(404, nil)).to eq(404) + expect(described_class.coerce_result(200, nil)).to eq(200) + end +end diff --git a/spec/graphql/types/applied_coupons/object_spec.rb b/spec/graphql/types/applied_coupons/object_spec.rb new file mode 100644 index 0000000..1c2577a --- /dev/null +++ b/spec/graphql/types/applied_coupons/object_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AppliedCoupons::Object do + subject { described_class } + + it do + expect(subject).to have_field(:coupon).of_type("Coupon!") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum") + expect(subject).to have_field(:amount_cents_remaining).of_type("BigInt") + expect(subject).to have_field(:frequency).of_type("CouponFrequency!") + expect(subject).to have_field(:frequency_duration).of_type("Int") + expect(subject).to have_field(:frequency_duration_remaining).of_type("Int") + expect(subject).to have_field(:percentage_rate).of_type("Float") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:terminated_at).of_type("ISO8601DateTime") + end +end diff --git a/spec/graphql/types/applied_pricing_units/input_spec.rb b/spec/graphql/types/applied_pricing_units/input_spec.rb new file mode 100644 index 0000000..a5da1e2 --- /dev/null +++ b/spec/graphql/types/applied_pricing_units/input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AppliedPricingUnits::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:conversion_rate).of_type("Float!") + end +end diff --git a/spec/graphql/types/applied_pricing_units/object_spec.rb b/spec/graphql/types/applied_pricing_units/object_spec.rb new file mode 100644 index 0000000..2bfc868 --- /dev/null +++ b/spec/graphql/types/applied_pricing_units/object_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AppliedPricingUnits::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:conversion_rate).of_type("Float!") + expect(subject).to have_field(:pricing_unit).of_type("PricingUnit!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/applied_pricing_units/override_input_spec.rb b/spec/graphql/types/applied_pricing_units/override_input_spec.rb new file mode 100644 index 0000000..62bb0bb --- /dev/null +++ b/spec/graphql/types/applied_pricing_units/override_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::AppliedPricingUnits::OverrideInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:conversion_rate).of_type("Float!") + end +end diff --git a/spec/graphql/types/billable_metric_filters/input_spec.rb b/spec/graphql/types/billable_metric_filters/input_spec.rb new file mode 100644 index 0000000..1ac2d08 --- /dev/null +++ b/spec/graphql/types/billable_metric_filters/input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillableMetricFilters::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:key).of_type("String!") + expect(subject).to accept_argument(:values).of_type("[String!]!") + end +end diff --git a/spec/graphql/types/billable_metric_filters/object_spec.rb b/spec/graphql/types/billable_metric_filters/object_spec.rb new file mode 100644 index 0000000..d41e26f --- /dev/null +++ b/spec/graphql/types/billable_metric_filters/object_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillableMetricFilters::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:key).of_type("String!") + expect(subject).to have_field(:values).of_type("[String!]!") + end +end diff --git a/spec/graphql/types/billable_metrics/create_input_spec.rb b/spec/graphql/types/billable_metrics/create_input_spec.rb new file mode 100644 index 0000000..2a8b726 --- /dev/null +++ b/spec/graphql/types/billable_metrics/create_input_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillableMetrics::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:aggregation_type).of_type("AggregationTypeEnum!") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:description).of_type("String!") + expect(subject).to accept_argument(:expression).of_type("String") + expect(subject).to accept_argument(:field_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:recurring).of_type("Boolean") + expect(subject).to accept_argument(:rounding_function).of_type("RoundingFunctionEnum") + expect(subject).to accept_argument(:rounding_precision).of_type("Int") + expect(subject).to accept_argument(:weighted_interval).of_type("WeightedIntervalEnum") + expect(subject).to accept_argument(:filters).of_type("[BillableMetricFiltersInput!]") + end +end diff --git a/spec/graphql/types/billable_metrics/object_spec.rb b/spec/graphql/types/billable_metrics/object_spec.rb new file mode 100644 index 0000000..f4e3a5c --- /dev/null +++ b/spec/graphql/types/billable_metrics/object_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillableMetrics::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:aggregation_type).of_type("AggregationTypeEnum!") + expect(subject).to have_field(:expression).of_type("String") + expect(subject).to have_field(:field_name).of_type("String") + expect(subject).to have_field(:weighted_interval).of_type("WeightedIntervalEnum") + expect(subject).to have_field(:filters).of_type("[BillableMetricFilter!]") + expect(subject).to have_field(:recurring).of_type("Boolean!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:deleted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:integration_mappings).of_type("[Mapping!]") + expect(subject).to have_field(:rounding_function).of_type("RoundingFunctionEnum") + expect(subject).to have_field(:rounding_precision).of_type("Int") + expect(subject).to have_field(:activity_logs).of_type("[ActivityLog!]") + + expect(subject).to have_field(:has_active_subscriptions).of_type("Boolean!") + expect(subject).to have_field(:has_draft_invoices).of_type("Boolean!") + expect(subject).to have_field(:has_plans).of_type("Boolean!") + expect(subject).to have_field(:has_subscriptions).of_type("Boolean!") + end +end diff --git a/spec/graphql/types/billable_metrics/update_input_spec.rb b/spec/graphql/types/billable_metrics/update_input_spec.rb new file mode 100644 index 0000000..fdf9d16 --- /dev/null +++ b/spec/graphql/types/billable_metrics/update_input_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillableMetrics::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("String!") + expect(subject).to accept_argument(:aggregation_type).of_type("AggregationTypeEnum!") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:description).of_type("String!") + expect(subject).to accept_argument(:expression).of_type("String") + expect(subject).to accept_argument(:field_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:recurring).of_type("Boolean") + expect(subject).to accept_argument(:rounding_function).of_type("RoundingFunctionEnum") + expect(subject).to accept_argument(:rounding_precision).of_type("Int") + expect(subject).to accept_argument(:weighted_interval).of_type("WeightedIntervalEnum") + expect(subject).to accept_argument(:filters).of_type("[BillableMetricFiltersInput!]") + end +end diff --git a/spec/graphql/types/billing_entities/billing_configuration_input_spec.rb b/spec/graphql/types/billing_entities/billing_configuration_input_spec.rb new file mode 100644 index 0000000..d5c890b --- /dev/null +++ b/spec/graphql/types/billing_entities/billing_configuration_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::BillingConfigurationInput do + subject { described_class } + + it { is_expected.to accept_argument(:document_locale).of_type("String") } + it { is_expected.to accept_argument(:invoice_footer).of_type("String") } + it { is_expected.to accept_argument(:invoice_grace_period).of_type("Int") } + it { is_expected.to accept_argument(:subscription_invoice_issuing_date_anchor).of_type("BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum") } + it { is_expected.to accept_argument(:subscription_invoice_issuing_date_adjustment).of_type("BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum") } +end diff --git a/spec/graphql/types/billing_entities/billing_configuration_spec.rb b/spec/graphql/types/billing_entities/billing_configuration_spec.rb new file mode 100644 index 0000000..6bd8108 --- /dev/null +++ b/spec/graphql/types/billing_entities/billing_configuration_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::BillingConfiguration do + subject { described_class } + + it { is_expected.to have_field(:document_locale).of_type("String") } + it { is_expected.to have_field(:id).of_type("ID!") } + it { is_expected.to have_field(:invoice_footer).of_type("String") } + it { is_expected.to have_field(:invoice_grace_period).of_type("Int!") } + it { is_expected.to have_field(:subscription_invoice_issuing_date_anchor).of_type("BillingEntitySubscriptionInvoiceIssuingDateAnchorEnum!") } + it { is_expected.to have_field(:subscription_invoice_issuing_date_adjustment).of_type("BillingEntitySubscriptionInvoiceIssuingDateAdjustmentEnum!") } +end diff --git a/spec/graphql/types/billing_entities/create_input_spec.rb b/spec/graphql/types/billing_entities/create_input_spec.rb new file mode 100644 index 0000000..9b2eced --- /dev/null +++ b/spec/graphql/types/billing_entities/create_input_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::CreateInput do + subject { described_class } + + it { is_expected.to accept_argument(:name).of_type("String!") } + it { is_expected.to accept_argument(:code).of_type("String!") } + it { is_expected.to accept_argument(:default_currency).of_type("CurrencyEnum") } + it { is_expected.to accept_argument(:email).of_type("String") } + it { is_expected.to accept_argument(:legal_name).of_type("String") } + it { is_expected.to accept_argument(:legal_number).of_type("String") } + it { is_expected.to accept_argument(:logo).of_type("String") } + it { is_expected.to accept_argument(:tax_identification_number).of_type("String") } + it { is_expected.to accept_argument(:address_line1).of_type("String") } + it { is_expected.to accept_argument(:address_line2).of_type("String") } + it { is_expected.to accept_argument(:state).of_type("String") } + it { is_expected.to accept_argument(:country).of_type("CountryCode") } + it { is_expected.to accept_argument(:city).of_type("String") } + it { is_expected.to accept_argument(:zipcode).of_type("String") } + it { is_expected.to accept_argument(:net_payment_term).of_type("Int") } + it { is_expected.to accept_argument(:timezone).of_type("TimezoneEnum") } + it { is_expected.to accept_argument(:eu_tax_management).of_type("Boolean") } + it { is_expected.to accept_argument(:document_number_prefix).of_type("String") } + it { is_expected.to accept_argument(:document_numbering).of_type("BillingEntityDocumentNumberingEnum") } + it { is_expected.to accept_argument(:billing_configuration).of_type("BillingEntityBillingConfigurationInput") } + it { is_expected.to accept_argument(:email_settings).of_type("[BillingEntityEmailSettingsEnum!]") } + it { is_expected.to accept_argument(:finalize_zero_amount_invoice).of_type("Boolean") } + it { is_expected.to accept_argument(:einvoicing).of_type("Boolean") } +end diff --git a/spec/graphql/types/billing_entities/document_numbering_enum_spec.rb b/spec/graphql/types/billing_entities/document_numbering_enum_spec.rb new file mode 100644 index 0000000..18677b8 --- /dev/null +++ b/spec/graphql/types/billing_entities/document_numbering_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::DocumentNumberingEnum do + subject { described_class.values.keys } + + it { is_expected.to match_array(["per_customer", "per_billing_entity"]) } +end diff --git a/spec/graphql/types/billing_entities/email_settings_enum_spec.rb b/spec/graphql/types/billing_entities/email_settings_enum_spec.rb new file mode 100644 index 0000000..0f58d26 --- /dev/null +++ b/spec/graphql/types/billing_entities/email_settings_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::EmailSettingsEnum do + subject { described_class.values.keys } + + it { is_expected.to match_array(["invoice_finalized", "credit_note_created", "payment_receipt_created"]) } +end diff --git a/spec/graphql/types/billing_entities/object_spec.rb b/spec/graphql/types/billing_entities/object_spec.rb new file mode 100644 index 0000000..e153bc5 --- /dev/null +++ b/spec/graphql/types/billing_entities/object_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::Object do + subject { described_class } + + it { is_expected.to have_field(:id).of_type("ID!") } + it { is_expected.to have_field(:organization).of_type("Organization!") } + it { is_expected.to have_field(:code).of_type("String!") } + it { is_expected.to have_field(:name).of_type("String!") } + it { is_expected.to have_field(:logo_url).of_type("String") } + it { is_expected.to have_field(:timezone).of_type("TimezoneEnum") } + it { is_expected.to have_field(:default_currency).of_type("CurrencyEnum!") } + it { is_expected.to have_field(:email).of_type("String") } + + it { is_expected.to have_field(:legal_name).of_type("String") } + it { is_expected.to have_field(:legal_number).of_type("String") } + it { is_expected.to have_field(:tax_identification_number).of_type("String") } + + it { is_expected.to have_field(:address_line1).of_type("String") } + it { is_expected.to have_field(:address_line2).of_type("String") } + it { is_expected.to have_field(:city).of_type("String") } + it { is_expected.to have_field(:country).of_type("CountryCode") } + it { is_expected.to have_field(:net_payment_term).of_type("Int!") } + it { is_expected.to have_field(:state).of_type("String") } + it { is_expected.to have_field(:zipcode).of_type("String") } + + it { is_expected.to have_field(:document_number_prefix).of_type("String!") } + it { is_expected.to have_field(:document_numbering).of_type("BillingEntityDocumentNumberingEnum!") } + + it { is_expected.to have_field(:created_at).of_type("ISO8601DateTime!") } + it { is_expected.to have_field(:updated_at).of_type("ISO8601DateTime!") } + + it { is_expected.to have_field(:eu_tax_management).of_type("Boolean!") } + it { is_expected.to have_field(:billing_configuration).of_type("BillingEntityBillingConfiguration") } + it { is_expected.to have_field(:email_settings).of_type("[BillingEntityEmailSettingsEnum!]") } + it { is_expected.to have_field(:finalize_zero_amount_invoice).of_type("Boolean!") } + it { is_expected.to have_field(:einvoicing).of_type("Boolean!") } + + it { is_expected.to have_field(:applied_dunning_campaign).of_type("DunningCampaign") } + it { is_expected.to have_field(:is_default).of_type("Boolean!") } + + it { is_expected.to have_field(:selected_invoice_custom_sections).of_type("[InvoiceCustomSection!]") } +end diff --git a/spec/graphql/types/billing_entities/subscription_invoice_issuing_date_adjustment_enum_spec.rb b/spec/graphql/types/billing_entities/subscription_invoice_issuing_date_adjustment_enum_spec.rb new file mode 100644 index 0000000..3d62bab --- /dev/null +++ b/spec/graphql/types/billing_entities/subscription_invoice_issuing_date_adjustment_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::SubscriptionInvoiceIssuingDateAdjustmentEnum do + subject { described_class.values.keys } + + it { is_expected.to match_array(["keep_anchor", "align_with_finalization_date"]) } +end diff --git a/spec/graphql/types/billing_entities/subscription_invoice_issuing_date_anchor_enum_spec.rb b/spec/graphql/types/billing_entities/subscription_invoice_issuing_date_anchor_enum_spec.rb new file mode 100644 index 0000000..461d3a5 --- /dev/null +++ b/spec/graphql/types/billing_entities/subscription_invoice_issuing_date_anchor_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::SubscriptionInvoiceIssuingDateAnchorEnum do + subject { described_class.values.keys } + + it { is_expected.to match_array(["current_period_end", "next_period_start"]) } +end diff --git a/spec/graphql/types/billing_entities/update_input_spec.rb b/spec/graphql/types/billing_entities/update_input_spec.rb new file mode 100644 index 0000000..226ba41 --- /dev/null +++ b/spec/graphql/types/billing_entities/update_input_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::BillingEntities::UpdateInput do + subject { described_class } + + it { is_expected.to accept_argument(:id).of_type("ID!") } + + it { is_expected.to accept_argument(:name).of_type("String") } + it { is_expected.to accept_argument(:code).of_type("String") } + it { is_expected.to accept_argument(:default_currency).of_type("CurrencyEnum") } + it { is_expected.to accept_argument(:email).of_type("String") } + it { is_expected.to accept_argument(:legal_name).of_type("String") } + it { is_expected.to accept_argument(:legal_number).of_type("String") } + it { is_expected.to accept_argument(:logo).of_type("String") } + it { is_expected.to accept_argument(:tax_identification_number).of_type("String") } + it { is_expected.to accept_argument(:address_line1).of_type("String") } + it { is_expected.to accept_argument(:address_line2).of_type("String") } + it { is_expected.to accept_argument(:state).of_type("String") } + it { is_expected.to accept_argument(:country).of_type("CountryCode") } + it { is_expected.to accept_argument(:city).of_type("String") } + it { is_expected.to accept_argument(:zipcode).of_type("String") } + it { is_expected.to accept_argument(:net_payment_term).of_type("Int") } + it { is_expected.to accept_argument(:timezone).of_type("TimezoneEnum") } + it { is_expected.to accept_argument(:eu_tax_management).of_type("Boolean") } + it { is_expected.to accept_argument(:document_number_prefix).of_type("String") } + it { is_expected.to accept_argument(:document_numbering).of_type("BillingEntityDocumentNumberingEnum") } + it { is_expected.to accept_argument(:billing_configuration).of_type("BillingEntityBillingConfigurationInput") } + it { is_expected.to accept_argument(:email_settings).of_type("[BillingEntityEmailSettingsEnum!]") } + it { is_expected.to accept_argument(:finalize_zero_amount_invoice).of_type("Boolean") } + it { is_expected.to accept_argument(:invoice_custom_section_ids).of_type("[ID!]") } + it { is_expected.to accept_argument(:einvoicing).of_type("Boolean") } +end diff --git a/spec/graphql/types/charge_filters/create_input_spec.rb b/spec/graphql/types/charge_filters/create_input_spec.rb new file mode 100644 index 0000000..06d7a95 --- /dev/null +++ b/spec/graphql/types/charge_filters/create_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeFilters::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:charge_id).of_type("ID!") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput!") + expect(subject).to accept_argument(:values).of_type("ChargeFilterValues!") + end +end diff --git a/spec/graphql/types/charge_filters/input_spec.rb b/spec/graphql/types/charge_filters/input_spec.rb new file mode 100644 index 0000000..cbd7c90 --- /dev/null +++ b/spec/graphql/types/charge_filters/input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeFilters::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput!") + expect(subject).to accept_argument(:values).of_type("ChargeFilterValues!") + end +end diff --git a/spec/graphql/types/charge_filters/object_spec.rb b/spec/graphql/types/charge_filters/object_spec.rb new file mode 100644 index 0000000..5a1af72 --- /dev/null +++ b/spec/graphql/types/charge_filters/object_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeFilters::Object do + subject { described_class } + + it do + expect(subject).to have_field(:charge_code).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:invoice_display_name).of_type("String") + expect(subject).to have_field(:properties).of_type("Properties!") + expect(subject).to have_field(:values).of_type("ChargeFilterValues!") + end + + describe "#charge_code" do + subject { run_graphql_field("ChargeFilter.chargeCode", charge_filter) } + + let(:charge) { create(:standard_charge, code: "test_charge") } + let(:charge_filter) { create(:charge_filter, charge:) } + + it "returns the charge code" do + expect(subject).to eq("test_charge") + end + end +end diff --git a/spec/graphql/types/charge_filters/update_input_spec.rb b/spec/graphql/types/charge_filters/update_input_spec.rb new file mode 100644 index 0000000..5077179 --- /dev/null +++ b/spec/graphql/types/charge_filters/update_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeFilters::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:cascade_updates).of_type("Boolean") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput") + end +end diff --git a/spec/graphql/types/charge_models/graduated_percentage_range_input_spec.rb b/spec/graphql/types/charge_models/graduated_percentage_range_input_spec.rb new file mode 100644 index 0000000..224f5fa --- /dev/null +++ b/spec/graphql/types/charge_models/graduated_percentage_range_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeModels::GraduatedPercentageRangeInput do + subject { described_class } + + it { is_expected.to accept_argument(:from_value).of_type("Float!") } + it { is_expected.to accept_argument(:to_value).of_type("Float") } + it { is_expected.to accept_argument(:flat_amount).of_type("String!") } + it { is_expected.to accept_argument(:rate).of_type("String!") } +end diff --git a/spec/graphql/types/charge_models/graduated_percentage_range_spec.rb b/spec/graphql/types/charge_models/graduated_percentage_range_spec.rb new file mode 100644 index 0000000..4f9fbb4 --- /dev/null +++ b/spec/graphql/types/charge_models/graduated_percentage_range_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeModels::GraduatedPercentageRange do + subject { described_class } + + it { is_expected.to have_field(:from_value).of_type("Float!") } + it { is_expected.to have_field(:to_value).of_type("Float") } + it { is_expected.to have_field(:flat_amount).of_type("String!") } + it { is_expected.to have_field(:rate).of_type("String!") } +end diff --git a/spec/graphql/types/charge_models/graduated_range_input_spec.rb b/spec/graphql/types/charge_models/graduated_range_input_spec.rb new file mode 100644 index 0000000..961927f --- /dev/null +++ b/spec/graphql/types/charge_models/graduated_range_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeModels::GraduatedRangeInput do + subject { described_class } + + it { is_expected.to accept_argument(:from_value).of_type("Float!") } + it { is_expected.to accept_argument(:to_value).of_type("Float") } + it { is_expected.to accept_argument(:flat_amount).of_type("String!") } + it { is_expected.to accept_argument(:per_unit_amount).of_type("String!") } +end diff --git a/spec/graphql/types/charge_models/graduated_range_spec.rb b/spec/graphql/types/charge_models/graduated_range_spec.rb new file mode 100644 index 0000000..a97cbe4 --- /dev/null +++ b/spec/graphql/types/charge_models/graduated_range_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeModels::GraduatedRange do + subject { described_class } + + it { is_expected.to have_field(:from_value).of_type("Float!") } + it { is_expected.to have_field(:to_value).of_type("Float") } + it { is_expected.to have_field(:flat_amount).of_type("String!") } + it { is_expected.to have_field(:per_unit_amount).of_type("String!") } +end diff --git a/spec/graphql/types/charge_models/volume_range_input_spec.rb b/spec/graphql/types/charge_models/volume_range_input_spec.rb new file mode 100644 index 0000000..4b5c9ac --- /dev/null +++ b/spec/graphql/types/charge_models/volume_range_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeModels::VolumeRangeInput do + subject { described_class } + + it { is_expected.to accept_argument(:from_value).of_type("BigInt!") } + it { is_expected.to accept_argument(:to_value).of_type("BigInt") } + it { is_expected.to accept_argument(:flat_amount).of_type("String!") } + it { is_expected.to accept_argument(:per_unit_amount).of_type("String!") } +end diff --git a/spec/graphql/types/charge_models/volume_range_spec.rb b/spec/graphql/types/charge_models/volume_range_spec.rb new file mode 100644 index 0000000..a55fa95 --- /dev/null +++ b/spec/graphql/types/charge_models/volume_range_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::ChargeModels::VolumeRange do + subject { described_class } + + it { is_expected.to have_field(:from_value).of_type("BigInt!") } + it { is_expected.to have_field(:to_value).of_type("BigInt") } + it { is_expected.to have_field(:flat_amount).of_type("String!") } + it { is_expected.to have_field(:per_unit_amount).of_type("String!") } +end diff --git a/spec/graphql/types/charges/charge_model_enum_spec.rb b/spec/graphql/types/charges/charge_model_enum_spec.rb new file mode 100644 index 0000000..45cc52b --- /dev/null +++ b/spec/graphql/types/charges/charge_model_enum_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::ChargeModelEnum do + subject { described_class } + + it "enumerates the correct charge model values" do + expect(subject.values.keys) + .to match_array( + %w[ + standard + graduated + package + percentage + volume + graduated_percentage + custom + dynamic + ] + ) + end +end diff --git a/spec/graphql/types/charges/create_input_spec.rb b/spec/graphql/types/charges/create_input_spec.rb new file mode 100644 index 0000000..a494409 --- /dev/null +++ b/spec/graphql/types/charges/create_input_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:plan_id).of_type("ID!") + expect(subject).to accept_argument(:billable_metric_id).of_type("ID!") + expect(subject).to accept_argument(:charge_model).of_type("ChargeModelEnum!") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:invoiceable).of_type("Boolean") + expect(subject).to accept_argument(:min_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:pay_in_advance).of_type("Boolean") + expect(subject).to accept_argument(:prorated).of_type("Boolean") + expect(subject).to accept_argument(:regroup_paid_fees).of_type("RegroupPaidFeesEnum") + expect(subject).to accept_argument(:filters).of_type("[ChargeFilterInput!]") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput") + expect(subject).to accept_argument(:applied_pricing_unit).of_type("AppliedPricingUnitInput") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/charges/input_spec.rb b/spec/graphql/types/charges/input_spec.rb new file mode 100644 index 0000000..446fc30 --- /dev/null +++ b/spec/graphql/types/charges/input_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:billable_metric_id).of_type("ID!") + expect(subject).to accept_argument(:charge_model).of_type("ChargeModelEnum!") + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:invoiceable).of_type("Boolean") + expect(subject).to accept_argument(:min_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:pay_in_advance).of_type("Boolean") + expect(subject).to accept_argument(:prorated).of_type("Boolean") + expect(subject).to accept_argument(:regroup_paid_fees).of_type("RegroupPaidFeesEnum") + + expect(subject).to accept_argument(:filters).of_type("[ChargeFilterInput!]") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput") + + expect(subject).to accept_argument(:applied_pricing_unit).of_type("AppliedPricingUnitInput") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/charges/object_spec.rb b/spec/graphql/types/charges/object_spec.rb new file mode 100644 index 0000000..848dc14 --- /dev/null +++ b/spec/graphql/types/charges/object_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::Object do + subject { described_class } + + it do + expect(subject).to have_field(:code).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:invoice_display_name).of_type("String") + expect(subject).to have_field(:parent_id).of_type("ID") + expect(subject).to have_field(:billable_metric).of_type("BillableMetric!") + expect(subject).to have_field(:charge_model).of_type("ChargeModelEnum!") + expect(subject).to have_field(:regroup_paid_fees).of_type("RegroupPaidFeesEnum") + expect(subject).to have_field(:invoiceable).of_type("Boolean!") + expect(subject).to have_field(:min_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:pay_in_advance).of_type("Boolean!") + expect(subject).to have_field(:properties).of_type("Properties") + expect(subject).to have_field(:prorated).of_type("Boolean!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:deleted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:taxes).of_type("[Tax!]") + expect(subject).to have_field(:applied_pricing_unit).of_type("AppliedPricingUnit") + expect(subject).to have_field(:filters).of_type("[ChargeFilter!]") + end +end diff --git a/spec/graphql/types/charges/presentation_group_key_input_spec.rb b/spec/graphql/types/charges/presentation_group_key_input_spec.rb new file mode 100644 index 0000000..f0cfbb7 --- /dev/null +++ b/spec/graphql/types/charges/presentation_group_key_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::PresentationGroupKeyInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:value).of_type("String!") + expect(subject).to accept_argument(:options).of_type("PresentationGroupKeyOptionsInput") + end +end diff --git a/spec/graphql/types/charges/presentation_group_key_options_input_spec.rb b/spec/graphql/types/charges/presentation_group_key_options_input_spec.rb new file mode 100644 index 0000000..d886dda --- /dev/null +++ b/spec/graphql/types/charges/presentation_group_key_options_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::PresentationGroupKeyOptionsInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:display_in_invoice).of_type("Boolean") + end +end diff --git a/spec/graphql/types/charges/presentation_group_key_options_spec.rb b/spec/graphql/types/charges/presentation_group_key_options_spec.rb new file mode 100644 index 0000000..d3b9932 --- /dev/null +++ b/spec/graphql/types/charges/presentation_group_key_options_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::PresentationGroupKeyOptions do + subject { described_class } + + it do + expect(subject).to have_field(:display_in_invoice).of_type("Boolean") + end +end diff --git a/spec/graphql/types/charges/presentation_group_key_spec.rb b/spec/graphql/types/charges/presentation_group_key_spec.rb new file mode 100644 index 0000000..6172561 --- /dev/null +++ b/spec/graphql/types/charges/presentation_group_key_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::PresentationGroupKey do + subject { described_class } + + it do + expect(subject).to have_field(:value).of_type("String!") + expect(subject).to have_field(:options).of_type("PresentationGroupKeyOptions") + end +end diff --git a/spec/graphql/types/charges/properties_input_spec.rb b/spec/graphql/types/charges/properties_input_spec.rb new file mode 100644 index 0000000..226d763 --- /dev/null +++ b/spec/graphql/types/charges/properties_input_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::PropertiesInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:amount).of_type("String") + expect(subject).to accept_argument(:pricing_group_keys).of_type("[String!]") + expect(subject).to accept_argument(:presentation_group_keys).of_type("[PresentationGroupKeyInput!]") + + expect(subject).to accept_argument(:graduated_ranges).of_type("[GraduatedRangeInput!]") + + expect(subject).to accept_argument(:graduated_percentage_ranges).of_type("[GraduatedPercentageRangeInput!]") + + expect(subject).to accept_argument(:free_units).of_type("BigInt") + expect(subject).to accept_argument(:package_size).of_type("BigInt") + + expect(subject).to accept_argument(:fixed_amount).of_type("String") + expect(subject).to accept_argument(:free_units_per_events).of_type("BigInt") + expect(subject).to accept_argument(:free_units_per_total_aggregation).of_type("String") + expect(subject).to accept_argument(:per_transaction_max_amount).of_type("String") + expect(subject).to accept_argument(:per_transaction_min_amount).of_type("String") + expect(subject).to accept_argument(:rate).of_type("String") + + expect(subject).to accept_argument(:volume_ranges).of_type("[VolumeRangeInput!]") + + expect(subject).to accept_argument(:custom_properties).of_type("JSON") + end +end diff --git a/spec/graphql/types/charges/properties_spec.rb b/spec/graphql/types/charges/properties_spec.rb new file mode 100644 index 0000000..62cd3ff --- /dev/null +++ b/spec/graphql/types/charges/properties_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::Properties do + subject { described_class } + + it do + expect(subject).to have_field(:amount).of_type("String") + expect(subject).to have_field(:pricing_group_keys).of_type("[String!]") + + expect(subject).to have_field(:graduated_ranges).of_type("[GraduatedRange!]") + + expect(subject).to have_field(:presentation_group_keys).of_type("[PresentationGroupKey!]") + expect(subject).to have_field(:graduated_percentage_ranges).of_type("[GraduatedPercentageRange!]") + + expect(subject).to have_field(:free_units).of_type("BigInt") + expect(subject).to have_field(:package_size).of_type("BigInt") + + expect(subject).to have_field(:fixed_amount).of_type("String") + expect(subject).to have_field(:free_units_per_events).of_type("BigInt") + expect(subject).to have_field(:free_units_per_total_aggregation).of_type("String") + expect(subject).to have_field(:per_transaction_max_amount).of_type("String") + expect(subject).to have_field(:per_transaction_min_amount).of_type("String") + expect(subject).to have_field(:rate).of_type("String") + + expect(subject).to have_field(:volume_ranges).of_type("[VolumeRange!]") + + expect(subject).to have_field(:custom_properties).of_type("JSON") + end +end diff --git a/spec/graphql/types/charges/update_input_spec.rb b/spec/graphql/types/charges/update_input_spec.rb new file mode 100644 index 0000000..2127f71 --- /dev/null +++ b/spec/graphql/types/charges/update_input_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Charges::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:charge_model).of_type("ChargeModelEnum") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:invoiceable).of_type("Boolean") + expect(subject).to accept_argument(:min_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:pay_in_advance).of_type("Boolean") + expect(subject).to accept_argument(:prorated).of_type("Boolean") + expect(subject).to accept_argument(:regroup_paid_fees).of_type("RegroupPaidFeesEnum") + expect(subject).to accept_argument(:filters).of_type("[ChargeFilterInput!]") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput") + expect(subject).to accept_argument(:applied_pricing_unit).of_type("AppliedPricingUnitInput") + expect(subject).to accept_argument(:cascade_updates).of_type("Boolean") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/commitments/input_spec.rb b/spec/graphql/types/commitments/input_spec.rb new file mode 100644 index 0000000..491780d --- /dev/null +++ b/spec/graphql/types/commitments/input_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Commitments::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:commitment_type).of_type("CommitmentTypeEnum") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/commitments/object_spec.rb b/spec/graphql/types/commitments/object_spec.rb new file mode 100644 index 0000000..e7238bf --- /dev/null +++ b/spec/graphql/types/commitments/object_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Commitments::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:commitment_type).of_type("CommitmentTypeEnum!") + expect(subject).to have_field(:invoice_display_name).of_type("String") + expect(subject).to have_field(:plan).of_type("Plan!") + expect(subject).to have_field(:taxes).of_type("[Tax!]") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/coupons/object_spec.rb b/spec/graphql/types/coupons/object_spec.rb new file mode 100644 index 0000000..1cd9825 --- /dev/null +++ b/spec/graphql/types/coupons/object_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Coupons::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization") + expect(subject).to have_field(:amount_cents).of_type("BigInt") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum") + expect(subject).to have_field(:code).of_type("String") + expect(subject).to have_field(:coupon_type).of_type("CouponTypeEnum!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:frequency).of_type("CouponFrequency!") + expect(subject).to have_field(:frequency_duration).of_type("Int") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:percentage_rate).of_type("Float") + expect(subject).to have_field(:reusable).of_type("Boolean!") + expect(subject).to have_field(:status).of_type("CouponStatusEnum!") + expect(subject).to have_field(:expiration).of_type("CouponExpiration!") + expect(subject).to have_field(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:activity_logs).of_type("[ActivityLog!]") + expect(subject).to have_field(:billable_metrics).of_type("[BillableMetric!]") + expect(subject).to have_field(:limited_billable_metrics).of_type("Boolean!") + expect(subject).to have_field(:limited_plans).of_type("Boolean!") + expect(subject).to have_field(:plans).of_type("[Plan!]") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:terminated_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:applied_coupons_count).of_type("Int!") + expect(subject).to have_field(:customers_count).of_type("Int!") + end + + describe "#plans" do + subject { run_graphql_field("Coupon.plans", coupon) } + + let(:coupon_plan) { create(:coupon_plan) } + let(:coupon) { coupon_plan.coupon } + let(:parent_plan) { coupon_plan.plan } + + before do + create(:coupon_plan, coupon:, plan: create(:plan, parent: parent_plan, code: parent_plan.code)) + end + + context "when coupon has multiple plans" do + it "only list parent plans" do + expect(coupon.plans.count).to eq(2) + expect(subject).to be_present + expect(subject).to eq([parent_plan]) + end + end + end +end diff --git a/spec/graphql/types/credit_note_items/estimate_spec.rb b/spec/graphql/types/credit_note_items/estimate_spec.rb new file mode 100644 index 0000000..912c907 --- /dev/null +++ b/spec/graphql/types/credit_note_items/estimate_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CreditNoteItems::Estimate do + subject { described_class } + + it do + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:fee).of_type("Fee!") + end +end diff --git a/spec/graphql/types/credit_notes/estimate_spec.rb b/spec/graphql/types/credit_notes/estimate_spec.rb new file mode 100644 index 0000000..7cc5a67 --- /dev/null +++ b/spec/graphql/types/credit_notes/estimate_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CreditNotes::Estimate do + subject { described_class } + + it do + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:sub_total_excluding_taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:max_creditable_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:max_refundable_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:max_offsettable_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:coupons_adjustment_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:taxes_rate).of_type("Float!") + expect(subject).to have_field(:items).of_type("[CreditNoteItemEstimate!]!") + expect(subject).to have_field(:applied_taxes).of_type("[CreditNoteAppliedTax!]!") + end +end diff --git a/spec/graphql/types/credit_notes/object_spec.rb b/spec/graphql/types/credit_notes/object_spec.rb new file mode 100644 index 0000000..1336247 --- /dev/null +++ b/spec/graphql/types/credit_notes/object_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CreditNotes::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:billing_entity).of_type("BillingEntity!") + expect(subject).to have_field(:number).of_type("String!") + expect(subject).to have_field(:sequential_id).of_type("ID!") + + expect(subject).to have_field(:issuing_date).of_type("ISO8601Date!") + + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:reason).of_type("CreditNoteReasonEnum!") + + expect(subject).to have_field(:credit_status).of_type("CreditNoteCreditStatusEnum") + expect(subject).to have_field(:refund_status).of_type("CreditNoteRefundStatusEnum") + + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:taxes_rate).of_type("Float!") + + expect(subject).to have_field(:balance_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:coupons_adjustment_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:credit_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:refund_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:offset_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:sub_total_excluding_taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_amount_cents).of_type("BigInt!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:refunded_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:voided_at).of_type("ISO8601DateTime") + + expect(subject).to have_field(:file_url).of_type("String") + expect(subject).to have_field(:xml_url).of_type("String") + + expect(subject).to have_field(:activity_logs).of_type("[ActivityLog!]") + expect(subject).to have_field(:applied_taxes).of_type("[CreditNoteAppliedTax!]") + expect(subject).to have_field(:customer).of_type("Customer!") + expect(subject).to have_field(:invoice).of_type("Invoice") + expect(subject).to have_field(:items).of_type("[CreditNoteItem!]!") + + expect(subject).to have_field(:can_be_voided).of_type("Boolean!") + + expect(subject).to have_field(:external_integration_id).of_type("String") + expect(subject).to have_field(:integration_syncable).of_type("Boolean!") + end +end diff --git a/spec/graphql/types/credit_notes/type_enum_spec.rb b/spec/graphql/types/credit_notes/type_enum_spec.rb new file mode 100644 index 0000000..73fc90e --- /dev/null +++ b/spec/graphql/types/credit_notes/type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CreditNotes::TypeEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[credit refund offset]) + end +end diff --git a/spec/graphql/types/customer_portal/customers/object_spec.rb b/spec/graphql/types/customer_portal/customers/object_spec.rb new file mode 100644 index 0000000..e43852b --- /dev/null +++ b/spec/graphql/types/customer_portal/customers/object_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CustomerPortal::Customers::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:account_type).of_type("CustomerAccountTypeEnum!") + expect(subject).to have_field(:applicable_timezone).of_type("TimezoneEnum!") + expect(subject).to have_field(:currency).of_type("CurrencyEnum") + expect(subject).to have_field(:customer_type).of_type("CustomerTypeEnum") + expect(subject).to have_field(:display_name).of_type("String!") + expect(subject).to have_field(:firstname).of_type("String") + expect(subject).to have_field(:lastname).of_type("String") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:email).of_type("String") + expect(subject).to have_field(:legal_name).of_type("String") + expect(subject).to have_field(:legal_number).of_type("String") + expect(subject).to have_field(:tax_identification_number).of_type("String") + + expect(subject).to have_field(:address_line1).of_type("String") + expect(subject).to have_field(:address_line2).of_type("String") + expect(subject).to have_field(:city).of_type("String") + expect(subject).to have_field(:country).of_type("CountryCode") + expect(subject).to have_field(:state).of_type("String") + expect(subject).to have_field(:zipcode).of_type("String") + + expect(subject).to have_field(:shipping_address).of_type("CustomerAddress") + + expect(subject).to have_field(:billing_configuration).of_type("CustomerBillingConfiguration") + + expect(subject).to have_field(:premium).of_type("Boolean!") + end +end diff --git a/spec/graphql/types/customer_portal/customers/update_input_spec.rb b/spec/graphql/types/customer_portal/customers/update_input_spec.rb new file mode 100644 index 0000000..fb02308 --- /dev/null +++ b/spec/graphql/types/customer_portal/customers/update_input_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CustomerPortal::Customers::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:customer_type).of_type("CustomerTypeEnum") + expect(subject).to accept_argument(:document_locale).of_type("String") + expect(subject).to accept_argument(:email).of_type("String") + expect(subject).to accept_argument(:firstname).of_type("String") + expect(subject).to accept_argument(:lastname).of_type("String") + expect(subject).to accept_argument(:legal_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:tax_identification_number).of_type("String") + + expect(subject).to accept_argument(:address_line1).of_type("String") + expect(subject).to accept_argument(:address_line2).of_type("String") + expect(subject).to accept_argument(:city).of_type("String") + expect(subject).to accept_argument(:country).of_type("CountryCode") + expect(subject).to accept_argument(:state).of_type("String") + expect(subject).to accept_argument(:zipcode).of_type("String") + + expect(subject).to accept_argument(:shipping_address).of_type("CustomerAddressInput") + end +end diff --git a/spec/graphql/types/customer_portal/organizations/object_spec.rb b/spec/graphql/types/customer_portal/organizations/object_spec.rb new file mode 100644 index 0000000..81456cf --- /dev/null +++ b/spec/graphql/types/customer_portal/organizations/object_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CustomerPortal::Organizations::Object do + subject { described_class } + + it do + expect(subject).to be < ::Types::Organizations::BaseOrganizationType + + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:billing_configuration).of_type("OrganizationBillingConfiguration") + expect(subject).to have_field(:default_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:logo_url).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:premium_integrations).of_type("[PremiumIntegrationTypeEnum!]!") + expect(subject).to have_field(:timezone).of_type("TimezoneEnum") + end +end diff --git a/spec/graphql/types/customer_portal/wallet_transactions/object_spec.rb b/spec/graphql/types/customer_portal/wallet_transactions/object_spec.rb new file mode 100644 index 0000000..9b04f35 --- /dev/null +++ b/spec/graphql/types/customer_portal/wallet_transactions/object_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CustomerPortal::WalletTransactions::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:wallet).of_type("CustomerPortalWallet") + + expect(subject).to have_field(:amount).of_type("String!") + expect(subject).to have_field(:credit_amount).of_type("String!") + expect(subject).to have_field(:status).of_type("WalletTransactionStatusEnum!") + expect(subject).to have_field(:transaction_status).of_type("WalletTransactionTransactionStatusEnum!") + expect(subject).to have_field(:transaction_type).of_type("WalletTransactionTransactionTypeEnum!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:settled_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/customer_portal/wallets/object_spec.rb b/spec/graphql/types/customer_portal/wallets/object_spec.rb new file mode 100644 index 0000000..2b7f505 --- /dev/null +++ b/spec/graphql/types/customer_portal/wallets/object_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::CustomerPortal::Wallets::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:code).of_type("String") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:priority).of_type("Int!") + expect(subject).to have_field(:status).of_type("WalletStatusEnum!") + + expect(subject).to have_field(:balance_cents).of_type("BigInt!") + expect(subject).to have_field(:consumed_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:consumed_credits).of_type("Float!") + expect(subject).to have_field(:credits_balance).of_type("Float!") + expect(subject).to have_field(:credits_ongoing_balance).of_type("Float!") + expect(subject).to have_field(:ongoing_balance_cents).of_type("BigInt!") + expect(subject).to have_field(:ongoing_usage_balance_cents).of_type("BigInt!") + expect(subject).to have_field(:rate_amount).of_type("Float!") + expect(subject).to have_field(:last_balance_sync_at).of_type("ISO8601DateTime") + + expect(subject).to have_field(:paid_top_up_max_amount_cents).of_type("BigInt") + expect(subject).to have_field(:paid_top_up_min_amount_cents).of_type("BigInt") + expect(subject).to have_field(:paid_top_up_max_credits).of_type("BigInt") + expect(subject).to have_field(:paid_top_up_min_credits).of_type("BigInt") + end +end diff --git a/spec/graphql/types/customers/account_type_enum_spec.rb b/spec/graphql/types/customers/account_type_enum_spec.rb new file mode 100644 index 0000000..2a4aced --- /dev/null +++ b/spec/graphql/types/customers/account_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::AccountTypeEnum do + it "enumerizes the correct values" do + expect(described_class.values.keys).to match_array(%w[customer partner]) + end +end diff --git a/spec/graphql/types/customers/billing_configuration_input_spec.rb b/spec/graphql/types/customers/billing_configuration_input_spec.rb new file mode 100644 index 0000000..51bcf57 --- /dev/null +++ b/spec/graphql/types/customers/billing_configuration_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::BillingConfigurationInput do + subject { described_class } + + it { is_expected.to accept_argument(:document_locale).of_type("String") } + it { is_expected.to accept_argument(:subscription_invoice_issuing_date_anchor).of_type("CustomerSubscriptionInvoiceIssuingDateAnchorEnum") } + it { is_expected.to accept_argument(:subscription_invoice_issuing_date_adjustment).of_type("CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum") } +end diff --git a/spec/graphql/types/customers/billing_configuration_spec.rb b/spec/graphql/types/customers/billing_configuration_spec.rb new file mode 100644 index 0000000..c5637ae --- /dev/null +++ b/spec/graphql/types/customers/billing_configuration_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::BillingConfiguration do + subject { described_class } + + it { is_expected.to have_field(:document_locale).of_type("String") } + it { is_expected.to have_field(:id).of_type("ID!") } + it { is_expected.to have_field(:subscription_invoice_issuing_date_anchor).of_type("CustomerSubscriptionInvoiceIssuingDateAnchorEnum") } + it { is_expected.to have_field(:subscription_invoice_issuing_date_adjustment).of_type("CustomerSubscriptionInvoiceIssuingDateAdjustmentEnum") } +end diff --git a/spec/graphql/types/customers/create_customer_input_spec.rb b/spec/graphql/types/customers/create_customer_input_spec.rb new file mode 100644 index 0000000..ed9830b --- /dev/null +++ b/spec/graphql/types/customers/create_customer_input_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::CreateCustomerInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:account_type).of_type(Types::Customers::AccountTypeEnum) + expect(subject).to accept_argument(:address_line1).of_type("String") + expect(subject).to accept_argument(:address_line2).of_type("String") + expect(subject).to accept_argument(:city).of_type("String") + expect(subject).to accept_argument(:country).of_type("CountryCode") + expect(subject).to accept_argument(:currency).of_type("CurrencyEnum") + expect(subject).to accept_argument(:customer_type).of_type("CustomerTypeEnum") + expect(subject).to accept_argument(:email).of_type("String") + expect(subject).to accept_argument(:external_id).of_type("String!") + expect(subject).to accept_argument(:external_salesforce_id).of_type("String") + expect(subject).to accept_argument(:firstname).of_type("String") + expect(subject).to accept_argument(:invoice_grace_period).of_type("Int") + expect(subject).to accept_argument(:lastname).of_type("String") + expect(subject).to accept_argument(:legal_name).of_type("String") + expect(subject).to accept_argument(:legal_number).of_type("String") + expect(subject).to accept_argument(:logo_url).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:net_payment_term).of_type("Int") + expect(subject).to accept_argument(:phone).of_type("String") + expect(subject).to accept_argument(:state).of_type("String") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + expect(subject).to accept_argument(:tax_identification_number).of_type("String") + expect(subject).to accept_argument(:timezone).of_type("TimezoneEnum") + expect(subject).to accept_argument(:url).of_type("String") + expect(subject).to accept_argument(:zipcode).of_type("String") + expect(subject).to accept_argument(:shipping_address).of_type("CustomerAddressInput") + expect(subject).to accept_argument(:metadata).of_type("[CustomerMetadataInput!]") + expect(subject).to accept_argument(:payment_provider).of_type("ProviderTypeEnum") + expect(subject).to accept_argument(:payment_provider_code).of_type("String") + expect(subject).to accept_argument(:provider_customer).of_type("ProviderCustomerInput") + expect(subject).to accept_argument(:integration_customers).of_type("[IntegrationCustomerInput!]") + expect(subject).to accept_argument(:billing_configuration).of_type("CustomerBillingConfigurationInput") + expect(subject).to accept_argument(:finalize_zero_amount_invoice).of_type("FinalizeZeroAmountInvoiceEnum") + expect(subject).to accept_argument(:billing_entity_code).of_type("String") + end +end diff --git a/spec/graphql/types/customers/credit_notes_balance_spec.rb b/spec/graphql/types/customers/credit_notes_balance_spec.rb new file mode 100644 index 0000000..5ed30dd --- /dev/null +++ b/spec/graphql/types/customers/credit_notes_balance_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::CreditNotesBalance do + subject { described_class } + + it { is_expected.to have_field(:currency).of_type("CurrencyEnum!") } + it { is_expected.to have_field(:amount_cents).of_type("BigInt!") } +end diff --git a/spec/graphql/types/customers/customer_type_enum_spec.rb b/spec/graphql/types/customers/customer_type_enum_spec.rb new file mode 100644 index 0000000..2b481a3 --- /dev/null +++ b/spec/graphql/types/customers/customer_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::CustomerTypeEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array(%w[company individual]) + end +end diff --git a/spec/graphql/types/customers/metadata/filter_spec.rb b/spec/graphql/types/customers/metadata/filter_spec.rb new file mode 100644 index 0000000..005a935 --- /dev/null +++ b/spec/graphql/types/customers/metadata/filter_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Metadata::Filter do + subject { described_class } + + it do + expect(subject).to accept_argument(:key).of_type("String!") + expect(subject).to accept_argument(:value).of_type("String!") + end +end diff --git a/spec/graphql/types/customers/object_spec.rb b/spec/graphql/types/customers/object_spec.rb new file mode 100644 index 0000000..d1c7b19 --- /dev/null +++ b/spec/graphql/types/customers/object_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:billing_entity).of_type("BillingEntity!") + + expect(subject).to have_field(:account_type).of_type("CustomerAccountTypeEnum!") + expect(subject).to have_field(:customer_type).of_type(Types::Customers::CustomerTypeEnum) + expect(subject).to have_field(:display_name).of_type("String!") + expect(subject).to have_field(:external_id).of_type("String!") + expect(subject).to have_field(:firstname).of_type("String") + expect(subject).to have_field(:lastname).of_type("String") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:sequential_id).of_type("String!") + expect(subject).to have_field(:slug).of_type("String!") + + expect(subject).to have_field(:address_line1).of_type("String") + expect(subject).to have_field(:address_line2).of_type("String") + + expect(subject).to have_field(:applicable_timezone).of_type("TimezoneEnum!") + expect(subject).to have_field(:city).of_type("String") + expect(subject).to have_field(:country).of_type("CountryCode") + expect(subject).to have_field(:currency).of_type("CurrencyEnum") + expect(subject).to have_field(:email).of_type("String") + expect(subject).to have_field(:external_salesforce_id).of_type("String") + expect(subject).to have_field(:invoice_grace_period).of_type("Int") + expect(subject).to have_field(:legal_name).of_type("String") + expect(subject).to have_field(:legal_number).of_type("String") + expect(subject).to have_field(:logo_url).of_type("String") + expect(subject).to have_field(:net_payment_term).of_type("Int") + expect(subject).to have_field(:payment_provider).of_type("ProviderTypeEnum") + expect(subject).to have_field(:payment_provider_code).of_type("String") + expect(subject).to have_field(:phone).of_type("String") + expect(subject).to have_field(:state).of_type("String") + expect(subject).to have_field(:tax_identification_number).of_type("String") + expect(subject).to have_field(:timezone).of_type("TimezoneEnum") + expect(subject).to have_field(:url).of_type("String") + expect(subject).to have_field(:zipcode).of_type("String") + + expect(subject).to have_field(:metadata).of_type("[CustomerMetadata!]") + + expect(subject).to have_field(:billing_configuration).of_type("CustomerBillingConfiguration") + + expect(subject).to have_field(:shipping_address).of_type("CustomerAddress") + + expect(subject).to have_field(:anrok_customer).of_type("AnrokCustomer") + expect(subject).to have_field(:avalara_customer).of_type("AvalaraCustomer") + expect(subject).to have_field(:hubspot_customer).of_type("HubspotCustomer") + expect(subject).to have_field(:netsuite_customer).of_type("NetsuiteCustomer") + expect(subject).to have_field(:salesforce_customer).of_type("SalesforceCustomer") + expect(subject).to have_field(:provider_customer).of_type("ProviderCustomer") + expect(subject).to have_field(:subscriptions).of_type("[Subscription!]!") + expect(subject).to have_field(:xero_customer).of_type("XeroCustomer") + + expect(subject).to have_field(:invoices).of_type("[Invoice!]") + + expect(subject).to have_field(:activity_logs).of_type("[ActivityLog!]") + expect(subject).to have_field(:applied_add_ons).of_type("[AppliedAddOn!]") + expect(subject).to have_field(:applied_coupons).of_type("[AppliedCoupon!]") + expect(subject).to have_field(:taxes).of_type("[Tax!]") + + expect(subject).to have_field(:credit_notes).of_type("[CreditNote!]") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:deleted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + + expect(subject).to have_field(:active_subscriptions_count).of_type("Int!") + expect(subject).to have_field(:credit_notes_balance_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:credit_notes_balances).of_type("[CustomerCreditNotesBalance!]!") + expect(subject).to have_field(:credit_notes_credits_available_count).of_type("Int!") + expect(subject).to have_field(:has_active_wallet).of_type("Boolean!") + expect(subject).to have_field(:has_credit_notes).of_type("Boolean!") + expect(subject).to have_field(:has_overdue_invoices).of_type("Boolean!") + + expect(subject).to have_field(:can_edit_attributes).of_type("Boolean!") + expect(subject).to have_field(:finalize_zero_amount_invoice).of_type("FinalizeZeroAmountInvoiceEnum") + + expect(subject).to have_field(:applied_dunning_campaign).of_type("DunningCampaign") + expect(subject).to have_field(:exclude_from_dunning_campaign).of_type("Boolean!") + expect(subject).to have_field(:last_dunning_campaign_attempt).of_type("Int!") + expect(subject).to have_field(:last_dunning_campaign_attempt_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:configurable_invoice_custom_sections).of_type("[InvoiceCustomSection!]") + + expect(subject).to have_field(:error_details).of_type("[ErrorDetail!]") + end +end diff --git a/spec/graphql/types/customers/subscription_invoice_issuing_date_adjustment_enum_spec.rb b/spec/graphql/types/customers/subscription_invoice_issuing_date_adjustment_enum_spec.rb new file mode 100644 index 0000000..503c978 --- /dev/null +++ b/spec/graphql/types/customers/subscription_invoice_issuing_date_adjustment_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::SubscriptionInvoiceIssuingDateAdjustmentEnum do + subject { described_class.values.keys } + + it { is_expected.to match_array(["keep_anchor", "align_with_finalization_date"]) } +end diff --git a/spec/graphql/types/customers/subscription_invoice_issuing_date_anchor_enum_spec.rb b/spec/graphql/types/customers/subscription_invoice_issuing_date_anchor_enum_spec.rb new file mode 100644 index 0000000..b6aaeec --- /dev/null +++ b/spec/graphql/types/customers/subscription_invoice_issuing_date_anchor_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::SubscriptionInvoiceIssuingDateAnchorEnum do + subject { described_class.values.keys } + + it { is_expected.to match_array(["current_period_end", "next_period_start"]) } +end diff --git a/spec/graphql/types/customers/update_customer_input_spec.rb b/spec/graphql/types/customers/update_customer_input_spec.rb new file mode 100644 index 0000000..1a40b1b --- /dev/null +++ b/spec/graphql/types/customers/update_customer_input_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::UpdateCustomerInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + + expect(subject).to accept_argument(:account_type).of_type(Types::Customers::AccountTypeEnum) + expect(subject).to accept_argument(:address_line1).of_type("String") + expect(subject).to accept_argument(:address_line2).of_type("String") + expect(subject).to accept_argument(:city).of_type("String") + expect(subject).to accept_argument(:country).of_type("CountryCode") + expect(subject).to accept_argument(:currency).of_type("CurrencyEnum") + expect(subject).to accept_argument(:customer_type).of_type("CustomerTypeEnum") + expect(subject).to accept_argument(:email).of_type("String") + expect(subject).to accept_argument(:external_id).of_type("String!") + expect(subject).to accept_argument(:external_salesforce_id).of_type("String") + expect(subject).to accept_argument(:firstname).of_type("String") + expect(subject).to accept_argument(:invoice_grace_period).of_type("Int") + expect(subject).to accept_argument(:lastname).of_type("String") + expect(subject).to accept_argument(:legal_name).of_type("String") + expect(subject).to accept_argument(:legal_number).of_type("String") + expect(subject).to accept_argument(:logo_url).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:net_payment_term).of_type("Int") + expect(subject).to accept_argument(:phone).of_type("String") + expect(subject).to accept_argument(:state).of_type("String") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + expect(subject).to accept_argument(:tax_identification_number).of_type("String") + expect(subject).to accept_argument(:timezone).of_type("TimezoneEnum") + expect(subject).to accept_argument(:url).of_type("String") + expect(subject).to accept_argument(:zipcode).of_type("String") + expect(subject).to accept_argument(:shipping_address).of_type("CustomerAddressInput") + expect(subject).to accept_argument(:metadata).of_type("[CustomerMetadataInput!]") + expect(subject).to accept_argument(:payment_provider).of_type("ProviderTypeEnum") + expect(subject).to accept_argument(:payment_provider_code).of_type("String") + expect(subject).to accept_argument(:provider_customer).of_type("ProviderCustomerInput") + expect(subject).to accept_argument(:integration_customers).of_type("[IntegrationCustomerInput!]") + expect(subject).to accept_argument(:billing_configuration).of_type("CustomerBillingConfigurationInput") + + expect(subject).to accept_argument(:applied_dunning_campaign_id).of_type("ID") + expect(subject).to accept_argument(:exclude_from_dunning_campaign).of_type("Boolean") + expect(subject).to accept_argument(:billing_entity_code).of_type("String") + + expect(subject).to accept_argument(:configurable_invoice_custom_section_ids).of_type("[ID!]") + expect(subject).to accept_argument(:skip_invoice_custom_sections).of_type("Boolean") + end +end diff --git a/spec/graphql/types/customers/usage/charge_filter_spec.rb b/spec/graphql/types/customers/usage/charge_filter_spec.rb new file mode 100644 index 0000000..433ad2d --- /dev/null +++ b/spec/graphql/types/customers/usage/charge_filter_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::ChargeFilter do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:pricing_unit_amount_cents).of_type("BigInt") + expect(subject).to have_field(:events_count).of_type("Int!") + expect(subject).to have_field(:invoice_display_name).of_type("String") + expect(subject).to have_field(:units).of_type("Float!") + expect(subject).to have_field(:values).of_type("ChargeFilterValues!") + end +end diff --git a/spec/graphql/types/customers/usage/charge_spec.rb b/spec/graphql/types/customers/usage/charge_spec.rb new file mode 100644 index 0000000..c594f8a --- /dev/null +++ b/spec/graphql/types/customers/usage/charge_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::Charge do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:events_count).of_type("Int!") + expect(subject).to have_field(:units).of_type("Float!") + expect(subject).to have_field(:billable_metric).of_type("BillableMetric!") + expect(subject).to have_field(:charge).of_type("Charge!") + expect(subject).to have_field(:grouped_usage).of_type("[GroupedChargeUsage!]!") + expect(subject).to have_field(:filters).of_type("[ChargeFilterUsage!]") + expect(subject).to have_field(:pricing_unit_amount_cents).of_type("BigInt") + end +end diff --git a/spec/graphql/types/customers/usage/current_spec.rb b/spec/graphql/types/customers/usage/current_spec.rb new file mode 100644 index 0000000..09e3df4 --- /dev/null +++ b/spec/graphql/types/customers/usage/current_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::Current do + subject { described_class } + + it do + expect(subject).to have_field(:from_datetime).of_type("ISO8601DateTime!") + expect(subject).to have_field(:to_datetime).of_type("ISO8601DateTime!") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:issuing_date).of_type("ISO8601Date!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:charges_usage).of_type("[ChargeUsage!]!") + end +end diff --git a/spec/graphql/types/customers/usage/grouped_usage_spec.rb b/spec/graphql/types/customers/usage/grouped_usage_spec.rb new file mode 100644 index 0000000..edf2057 --- /dev/null +++ b/spec/graphql/types/customers/usage/grouped_usage_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::GroupedUsage do + subject { described_class } + + it do + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:pricing_unit_amount_cents).of_type("BigInt") + expect(subject).to have_field(:events_count).of_type("Int!") + expect(subject).to have_field(:units).of_type("Float!") + expect(subject).to have_field(:filters).of_type("[ChargeFilterUsage!]") + expect(subject).to have_field(:grouped_by).of_type("JSON") + expect(subject).to have_field(:presentation_breakdowns).of_type("[PresentationBreakdownUsage!]") + end +end diff --git a/spec/graphql/types/customers/usage/presentation_breakdown_spec.rb b/spec/graphql/types/customers/usage/presentation_breakdown_spec.rb new file mode 100644 index 0000000..714eadf --- /dev/null +++ b/spec/graphql/types/customers/usage/presentation_breakdown_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::PresentationBreakdown do + subject { described_class } + + it do + expect(subject).to have_field(:presentation_by).of_type("JSON!") + expect(subject).to have_field(:units).of_type("String!") + end +end diff --git a/spec/graphql/types/customers/usage/projected_charge_filter_spec.rb b/spec/graphql/types/customers/usage/projected_charge_filter_spec.rb new file mode 100644 index 0000000..ec4241b --- /dev/null +++ b/spec/graphql/types/customers/usage/projected_charge_filter_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::ProjectedChargeFilter do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:projected_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:events_count).of_type("Int!") + expect(subject).to have_field(:units).of_type("Float!") + expect(subject).to have_field(:projected_units).of_type("Float!") + expect(subject).to have_field(:invoice_display_name).of_type("String") + expect(subject).to have_field(:pricing_unit_amount_cents).of_type("BigInt") + expect(subject).to have_field(:pricing_unit_projected_amount_cents).of_type("BigInt") + expect(subject).to have_field(:values).of_type("ChargeFilterValues!") + end +end diff --git a/spec/graphql/types/customers/usage/projected_charge_spec.rb b/spec/graphql/types/customers/usage/projected_charge_spec.rb new file mode 100644 index 0000000..cdaaaf6 --- /dev/null +++ b/spec/graphql/types/customers/usage/projected_charge_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::ProjectedCharge do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:projected_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:events_count).of_type("Int!") + expect(subject).to have_field(:units).of_type("Float!") + expect(subject).to have_field(:projected_units).of_type("Float!") + expect(subject).to have_field(:billable_metric).of_type("BillableMetric!") + expect(subject).to have_field(:charge).of_type("Charge!") + expect(subject).to have_field(:grouped_usage).of_type("[ProjectedGroupedChargeUsage!]!") + expect(subject).to have_field(:filters).of_type("[ProjectedChargeFilterUsage!]") + expect(subject).to have_field(:pricing_unit_amount_cents).of_type("BigInt") + expect(subject).to have_field(:pricing_unit_projected_amount_cents).of_type("BigInt") + end +end diff --git a/spec/graphql/types/customers/usage/projected_grouped_usage_spec.rb b/spec/graphql/types/customers/usage/projected_grouped_usage_spec.rb new file mode 100644 index 0000000..0437794 --- /dev/null +++ b/spec/graphql/types/customers/usage/projected_grouped_usage_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::ProjectedGroupedUsage do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:projected_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:events_count).of_type("Int!") + expect(subject).to have_field(:units).of_type("Float!") + expect(subject).to have_field(:projected_units).of_type("Float!") + expect(subject).to have_field(:pricing_unit_amount_cents).of_type("BigInt") + expect(subject).to have_field(:pricing_unit_projected_amount_cents).of_type("BigInt") + expect(subject).to have_field(:grouped_by).of_type("JSON") + expect(subject).to have_field(:filters).of_type("[ProjectedChargeFilterUsage!]") + end +end diff --git a/spec/graphql/types/customers/usage/projected_spec.rb b/spec/graphql/types/customers/usage/projected_spec.rb new file mode 100644 index 0000000..d808d36 --- /dev/null +++ b/spec/graphql/types/customers/usage/projected_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Customers::Usage::Projected do + subject { described_class } + + it do + expect(subject).to have_field(:from_datetime).of_type("ISO8601DateTime!") + expect(subject).to have_field(:to_datetime).of_type("ISO8601DateTime!") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:issuing_date).of_type("ISO8601Date!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:projected_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:charges_usage).of_type("[ProjectedChargeUsage!]!") + end +end diff --git a/spec/graphql/types/data_api/metadata_spec.rb b/spec/graphql/types/data_api/metadata_spec.rb new file mode 100644 index 0000000..0e75432 --- /dev/null +++ b/spec/graphql/types/data_api/metadata_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Metadata do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiMetadata") + expect(subject).to have_field(:current_page).of_type("Int!") + expect(subject).to have_field(:next_page).of_type("Int!") + expect(subject).to have_field(:prev_page).of_type("Int!") + expect(subject).to have_field(:total_count).of_type("Int!") + expect(subject).to have_field(:total_pages).of_type("Int!") + end +end diff --git a/spec/graphql/types/data_api/mrrs/object_spec.rb b/spec/graphql/types/data_api/mrrs/object_spec.rb new file mode 100644 index 0000000..2915e38 --- /dev/null +++ b/spec/graphql/types/data_api/mrrs/object_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Mrrs::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiMrr") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:ending_mrr).of_type("BigInt!") + expect(subject).to have_field(:starting_mrr).of_type("BigInt!") + expect(subject).to have_field(:mrr_new).of_type("BigInt!") + expect(subject).to have_field(:mrr_expansion).of_type("BigInt!") + expect(subject).to have_field(:mrr_contraction).of_type("BigInt!") + expect(subject).to have_field(:mrr_churn).of_type("BigInt!") + expect(subject).to have_field(:mrr_change).of_type("BigInt!") + expect(subject).to have_field(:end_of_period_dt).of_type("ISO8601Date!") + expect(subject).to have_field(:start_of_period_dt).of_type("ISO8601Date!") + end +end diff --git a/spec/graphql/types/data_api/mrrs/plans/collection_spec.rb b/spec/graphql/types/data_api/mrrs/plans/collection_spec.rb new file mode 100644 index 0000000..00fad9c --- /dev/null +++ b/spec/graphql/types/data_api/mrrs/plans/collection_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Mrrs::Plans::Collection do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiMrrsPlans") + expect(subject).to have_field(:collection).of_type("[DataApiMrrPlan!]!") + expect(subject).to have_field(:metadata).of_type("DataApiMetadata!") + end +end diff --git a/spec/graphql/types/data_api/mrrs/plans/object_spec.rb b/spec/graphql/types/data_api/mrrs/plans/object_spec.rb new file mode 100644 index 0000000..f850afc --- /dev/null +++ b/spec/graphql/types/data_api/mrrs/plans/object_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Mrrs::Plans::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiMrrPlan") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:dt).of_type("ISO8601Date!") + expect(subject).to have_field(:plan_code).of_type("String!") + expect(subject).to have_field(:plan_deleted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:plan_id).of_type("ID!") + expect(subject).to have_field(:plan_interval).of_type("PlanInterval!") + expect(subject).to have_field(:plan_name).of_type("String!") + expect(subject).to have_field(:active_customers_count).of_type("BigInt!") + expect(subject).to have_field(:active_customers_share).of_type("Float!") + expect(subject).to have_field(:mrr).of_type("Float!") + expect(subject).to have_field(:mrr_share).of_type("Float") + end +end diff --git a/spec/graphql/types/data_api/prepaid_credits/object_spec.rb b/spec/graphql/types/data_api/prepaid_credits/object_spec.rb new file mode 100644 index 0000000..9247a0e --- /dev/null +++ b/spec/graphql/types/data_api/prepaid_credits/object_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::PrepaidCredits::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiPrepaidCredit") + + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + + expect(subject).to have_field(:purchased_amount).of_type("Float!") + expect(subject).to have_field(:offered_amount).of_type("Float!") + expect(subject).to have_field(:consumed_amount).of_type("Float!") + expect(subject).to have_field(:voided_amount).of_type("Float!") + + expect(subject).to have_field(:purchased_credits_quantity).of_type("Float!") + expect(subject).to have_field(:offered_credits_quantity).of_type("Float!") + expect(subject).to have_field(:consumed_credits_quantity).of_type("Float!") + expect(subject).to have_field(:voided_credits_quantity).of_type("Float!") + + expect(subject).to have_field(:end_of_period_dt).of_type("ISO8601Date!") + expect(subject).to have_field(:start_of_period_dt).of_type("ISO8601Date!") + end +end diff --git a/spec/graphql/types/data_api/revenue_streams/customers/collection_spec.rb b/spec/graphql/types/data_api/revenue_streams/customers/collection_spec.rb new file mode 100644 index 0000000..f600a81 --- /dev/null +++ b/spec/graphql/types/data_api/revenue_streams/customers/collection_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::RevenueStreams::Customers::Collection do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiRevenueStreamsCustomers") + expect(subject).to have_field(:collection).of_type("[DataApiRevenueStreamCustomer!]!") + expect(subject).to have_field(:metadata).of_type("DataApiMetadata!") + end +end diff --git a/spec/graphql/types/data_api/revenue_streams/customers/object_spec.rb b/spec/graphql/types/data_api/revenue_streams/customers/object_spec.rb new file mode 100644 index 0000000..bb58a25 --- /dev/null +++ b/spec/graphql/types/data_api/revenue_streams/customers/object_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::RevenueStreams::Customers::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiRevenueStreamCustomer") + expect(subject).to have_field(:customer_id).of_type("ID!") + expect(subject).to have_field(:customer_deleted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:external_customer_id).of_type("String!") + expect(subject).to have_field(:customer_name).of_type("String") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:gross_revenue_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:gross_revenue_share).of_type("Float") + expect(subject).to have_field(:net_revenue_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:net_revenue_share).of_type("Float") + end +end diff --git a/spec/graphql/types/data_api/revenue_streams/object_spec.rb b/spec/graphql/types/data_api/revenue_streams/object_spec.rb new file mode 100644 index 0000000..0d2e049 --- /dev/null +++ b/spec/graphql/types/data_api/revenue_streams/object_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::RevenueStreams::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiRevenueStream") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:coupons_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:gross_revenue_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:net_revenue_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:commitment_fee_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:one_off_fee_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:subscription_fee_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:usage_based_fee_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:end_of_period_dt).of_type("ISO8601Date!") + expect(subject).to have_field(:start_of_period_dt).of_type("ISO8601Date!") + end +end diff --git a/spec/graphql/types/data_api/revenue_streams/order_by_enum_spec.rb b/spec/graphql/types/data_api/revenue_streams/order_by_enum_spec.rb new file mode 100644 index 0000000..209f808 --- /dev/null +++ b/spec/graphql/types/data_api/revenue_streams/order_by_enum_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::RevenueStreams::OrderByEnum do + subject { described_class } + + it "supports sorting by gross revenue and net revenue" do + expect(subject.values.keys).to match_array(%w[ + gross_revenue_amount_cents + net_revenue_amount_cents + ]) + end +end diff --git a/spec/graphql/types/data_api/revenue_streams/plans/collection_spec.rb b/spec/graphql/types/data_api/revenue_streams/plans/collection_spec.rb new file mode 100644 index 0000000..cee91a8 --- /dev/null +++ b/spec/graphql/types/data_api/revenue_streams/plans/collection_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::RevenueStreams::Plans::Collection do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiRevenueStreamsPlans") + expect(subject).to have_field(:collection).of_type("[DataApiRevenueStreamPlan!]!") + expect(subject).to have_field(:metadata).of_type("DataApiMetadata!") + end +end diff --git a/spec/graphql/types/data_api/revenue_streams/plans/object_spec.rb b/spec/graphql/types/data_api/revenue_streams/plans/object_spec.rb new file mode 100644 index 0000000..b543eca --- /dev/null +++ b/spec/graphql/types/data_api/revenue_streams/plans/object_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::RevenueStreams::Plans::Object do + subject { described_class } + + it do + expect(subject).to have_field(:plan_code).of_type("String!") + expect(subject).to have_field(:plan_deleted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:plan_id).of_type("ID!") + expect(subject).to have_field(:plan_interval).of_type("PlanInterval!") + expect(subject).to have_field(:plan_name).of_type("String!") + expect(subject).to have_field(:customers_count).of_type("Int!") + expect(subject).to have_field(:customers_share).of_type("Float!") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:gross_revenue_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:gross_revenue_share).of_type("Float") + expect(subject).to have_field(:net_revenue_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:net_revenue_share).of_type("Float") + end +end diff --git a/spec/graphql/types/data_api/time_granularity_enum_spec.rb b/spec/graphql/types/data_api/time_granularity_enum_spec.rb new file mode 100644 index 0000000..efd367e --- /dev/null +++ b/spec/graphql/types/data_api/time_granularity_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::TimeGranularityEnum do + it "enumerizes the correct values" do + expect(described_class.values.keys).to match_array(%w[daily weekly monthly]) + end +end diff --git a/spec/graphql/types/data_api/usages/aggregated_amounts/object_spec.rb b/spec/graphql/types/data_api/usages/aggregated_amounts/object_spec.rb new file mode 100644 index 0000000..9e3c4ce --- /dev/null +++ b/spec/graphql/types/data_api/usages/aggregated_amounts/object_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Usages::AggregatedAmounts::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiUsageAggregatedAmount") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:end_of_period_dt).of_type("ISO8601Date!") + expect(subject).to have_field(:start_of_period_dt).of_type("ISO8601Date!") + end +end diff --git a/spec/graphql/types/data_api/usages/forecasted/object_spec.rb b/spec/graphql/types/data_api/usages/forecasted/object_spec.rb new file mode 100644 index 0000000..7a905e1 --- /dev/null +++ b/spec/graphql/types/data_api/usages/forecasted/object_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Usages::Forecasted::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiUsageForecasted") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:amount_cents_forecast_conservative).of_type("BigInt!") + expect(subject).to have_field(:amount_cents_forecast_optimistic).of_type("BigInt!") + expect(subject).to have_field(:amount_cents_forecast_realistic).of_type("BigInt!") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:units).of_type("Float!") + expect(subject).to have_field(:units_forecast_conservative).of_type("Float!") + expect(subject).to have_field(:units_forecast_optimistic).of_type("Float!") + expect(subject).to have_field(:units_forecast_realistic).of_type("Float!") + expect(subject).to have_field(:end_of_period_dt).of_type("ISO8601Date!") + expect(subject).to have_field(:start_of_period_dt).of_type("ISO8601Date!") + end +end diff --git a/spec/graphql/types/data_api/usages/invoiced/object_spec.rb b/spec/graphql/types/data_api/usages/invoiced/object_spec.rb new file mode 100644 index 0000000..aadc452 --- /dev/null +++ b/spec/graphql/types/data_api/usages/invoiced/object_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Usages::Invoiced::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiUsageInvoiced") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:billable_metric_code).of_type("String!") + expect(subject).to have_field(:end_of_period_dt).of_type("ISO8601Date!") + expect(subject).to have_field(:start_of_period_dt).of_type("ISO8601Date!") + end +end diff --git a/spec/graphql/types/data_api/usages/object_spec.rb b/spec/graphql/types/data_api/usages/object_spec.rb new file mode 100644 index 0000000..f14b809 --- /dev/null +++ b/spec/graphql/types/data_api/usages/object_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataApi::Usages::Object do + subject { described_class } + + it do + expect(subject.graphql_name).to eq("DataApiUsage") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:billable_metric_code).of_type("String!") + expect(subject).to have_field(:units).of_type("Float!") + + expect(subject).to have_field(:is_billable_metric_deleted).of_type("Boolean!") + + expect(subject).to have_field(:end_of_period_dt).of_type("ISO8601Date!") + expect(subject).to have_field(:start_of_period_dt).of_type("ISO8601Date!") + end +end diff --git a/spec/graphql/types/data_exports/credit_notes/filters_input_spec.rb b/spec/graphql/types/data_exports/credit_notes/filters_input_spec.rb new file mode 100644 index 0000000..5be99d7 --- /dev/null +++ b/spec/graphql/types/data_exports/credit_notes/filters_input_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataExports::CreditNotes::FiltersInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:amount_from).of_type("Int") + expect(subject).to accept_argument(:amount_to).of_type("Int") + expect(subject).to accept_argument(:billing_entity_ids).of_type("[ID!]") + expect(subject).to accept_argument(:credit_status).of_type("[CreditNoteCreditStatusEnum!]") + expect(subject).to accept_argument(:currency).of_type("CurrencyEnum") + expect(subject).to accept_argument(:customer_external_id).of_type("String") + expect(subject).to accept_argument(:customer_id).of_type("ID") + expect(subject).to accept_argument(:invoice_number).of_type("String") + expect(subject).to accept_argument(:issuing_date_from).of_type("ISO8601Date") + expect(subject).to accept_argument(:issuing_date_to).of_type("ISO8601Date") + expect(subject).to accept_argument(:reason).of_type("[CreditNoteReasonEnum!]") + expect(subject).to accept_argument(:refund_status).of_type("[CreditNoteRefundStatusEnum!]") + expect(subject).to accept_argument(:search_term).of_type("String") + expect(subject).to accept_argument(:self_billed).of_type("Boolean") + expect(subject).to accept_argument(:types).of_type("[CreditNoteTypeEnum!]") + end +end diff --git a/spec/graphql/types/data_exports/invoices/filters_input_spec.rb b/spec/graphql/types/data_exports/invoices/filters_input_spec.rb new file mode 100644 index 0000000..1c73295 --- /dev/null +++ b/spec/graphql/types/data_exports/invoices/filters_input_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataExports::Invoices::FiltersInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:amount_from).of_type("Int") + expect(subject).to accept_argument(:amount_to).of_type("Int") + expect(subject).to accept_argument(:billing_entity_ids).of_type("[ID!]") + expect(subject).to accept_argument(:currency).of_type("CurrencyEnum") + expect(subject).to accept_argument(:customer_external_id).of_type("String") + expect(subject).to accept_argument(:invoice_type).of_type("[InvoiceTypeEnum!]") + expect(subject).to accept_argument(:issuing_date_from).of_type("ISO8601Date") + expect(subject).to accept_argument(:issuing_date_to).of_type("ISO8601Date") + expect(subject).to accept_argument(:payment_dispute_lost).of_type("Boolean") + expect(subject).to accept_argument(:payment_overdue).of_type("Boolean") + expect(subject).to accept_argument(:payment_status).of_type("[InvoicePaymentStatusTypeEnum!]") + expect(subject).to accept_argument(:search_term).of_type("String") + expect(subject).to accept_argument(:status).of_type("[InvoiceStatusTypeEnum!]") + end +end diff --git a/spec/graphql/types/data_exports/object_spec.rb b/spec/graphql/types/data_exports/object_spec.rb new file mode 100644 index 0000000..ffcdbe8 --- /dev/null +++ b/spec/graphql/types/data_exports/object_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DataExports::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:status).of_type("DataExportStatusEnum!") + end +end diff --git a/spec/graphql/types/dunning_campaign_thresholds/input_spec.rb b/spec/graphql/types/dunning_campaign_thresholds/input_spec.rb new file mode 100644 index 0000000..55a6448 --- /dev/null +++ b/spec/graphql/types/dunning_campaign_thresholds/input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DunningCampaignThresholds::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID") + + expect(subject).to accept_argument(:amount_cents).of_type("BigInt!") + expect(subject).to accept_argument(:currency).of_type("CurrencyEnum!") + end +end diff --git a/spec/graphql/types/dunning_campaign_thresholds/object_spec.rb b/spec/graphql/types/dunning_campaign_thresholds/object_spec.rb new file mode 100644 index 0000000..9848c46 --- /dev/null +++ b/spec/graphql/types/dunning_campaign_thresholds/object_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DunningCampaignThresholds::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + end +end diff --git a/spec/graphql/types/dunning_campaigns/create_input_spec.rb b/spec/graphql/types/dunning_campaigns/create_input_spec.rb new file mode 100644 index 0000000..f9a2d41 --- /dev/null +++ b/spec/graphql/types/dunning_campaigns/create_input_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DunningCampaigns::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:applied_to_organization).of_type("Boolean!") + expect(subject).to accept_argument(:bcc_emails).of_type("[String!]") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:days_between_attempts).of_type("Int!") + expect(subject).to accept_argument(:max_attempts).of_type("Int!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:thresholds).of_type("[DunningCampaignThresholdInput!]!") + + expect(subject).to accept_argument(:description).of_type("String") + end +end diff --git a/spec/graphql/types/dunning_campaigns/object_spec.rb b/spec/graphql/types/dunning_campaigns/object_spec.rb new file mode 100644 index 0000000..739d287 --- /dev/null +++ b/spec/graphql/types/dunning_campaigns/object_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DunningCampaigns::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:customers_count).of_type("Int!") + expect(subject).to have_field(:applied_to_organization).of_type("Boolean!") + expect(subject).to have_field(:bcc_emails).of_type("[String!]") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:days_between_attempts).of_type("Int!") + expect(subject).to have_field(:max_attempts).of_type("Int!") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:thresholds).of_type("[DunningCampaignThreshold!]!") + + expect(subject).to have_field(:description).of_type("String") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/dunning_campaigns/update_input_spec.rb b/spec/graphql/types/dunning_campaigns/update_input_spec.rb new file mode 100644 index 0000000..4f5e5b6 --- /dev/null +++ b/spec/graphql/types/dunning_campaigns/update_input_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::DunningCampaigns::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + + expect(subject).to accept_argument(:applied_to_organization).of_type("Boolean") + expect(subject).to accept_argument(:bcc_emails).of_type("[String!]") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:days_between_attempts).of_type("Int") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:max_attempts).of_type("Int") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:thresholds).of_type("[DunningCampaignThresholdInput!]") + end +end diff --git a/spec/graphql/types/entitlement/create_feature_input_spec.rb b/spec/graphql/types/entitlement/create_feature_input_spec.rb new file mode 100644 index 0000000..26a5e3f --- /dev/null +++ b/spec/graphql/types/entitlement/create_feature_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::CreateFeatureInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:privileges).of_type("[UpdatePrivilegeInput!]!") + end +end diff --git a/spec/graphql/types/entitlement/entitlement_input_spec.rb b/spec/graphql/types/entitlement/entitlement_input_spec.rb new file mode 100644 index 0000000..aa88787 --- /dev/null +++ b/spec/graphql/types/entitlement/entitlement_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::EntitlementInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:feature_code).of_type("String!") + expect(subject).to accept_argument(:privileges).of_type("[EntitlementPrivilegeInput!]") + end +end diff --git a/spec/graphql/types/entitlement/entitlement_privilege_input_spec.rb b/spec/graphql/types/entitlement/entitlement_privilege_input_spec.rb new file mode 100644 index 0000000..9bbcf8d --- /dev/null +++ b/spec/graphql/types/entitlement/entitlement_privilege_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::EntitlementPrivilegeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:privilege_code).of_type("String!") + expect(subject).to accept_argument(:value).of_type("String!") + end +end diff --git a/spec/graphql/types/entitlement/feature_object_spec.rb b/spec/graphql/types/entitlement/feature_object_spec.rb new file mode 100644 index 0000000..4025262 --- /dev/null +++ b/spec/graphql/types/entitlement/feature_object_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::FeatureObject do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:name).of_type("String") + + expect(subject).to have_field(:privileges).of_type("[PrivilegeObject!]!") + + expect(subject).to have_field(:subscriptions_count).of_type("Int!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/entitlement/plan_entitlement_object_spec.rb b/spec/graphql/types/entitlement/plan_entitlement_object_spec.rb new file mode 100644 index 0000000..a78ca13 --- /dev/null +++ b/spec/graphql/types/entitlement/plan_entitlement_object_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::PlanEntitlementObject do + subject { described_class } + + it do + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:privileges).of_type("[PlanEntitlementPrivilegeObject!]!") + end +end diff --git a/spec/graphql/types/entitlement/plan_entitlement_privilege_object_spec.rb b/spec/graphql/types/entitlement/plan_entitlement_privilege_object_spec.rb new file mode 100644 index 0000000..5e82b46 --- /dev/null +++ b/spec/graphql/types/entitlement/plan_entitlement_privilege_object_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::PlanEntitlementPrivilegeObject do + subject { described_class } + + it do + expect(subject).to have_field(:value).of_type("String!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:config).of_type("PrivilegeConfigObject!") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:value_type).of_type("PrivilegeValueTypeEnum!") + end +end diff --git a/spec/graphql/types/entitlement/privilege_config_object_spec.rb b/spec/graphql/types/entitlement/privilege_config_object_spec.rb new file mode 100644 index 0000000..91d508e --- /dev/null +++ b/spec/graphql/types/entitlement/privilege_config_object_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::PrivilegeConfigObject do + subject { described_class } + + it do + expect(subject).to have_field(:select_options).of_type("[String!]") + end +end diff --git a/spec/graphql/types/entitlement/privilege_object_spec.rb b/spec/graphql/types/entitlement/privilege_object_spec.rb new file mode 100644 index 0000000..96e5acb --- /dev/null +++ b/spec/graphql/types/entitlement/privilege_object_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::PrivilegeObject do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:config).of_type("PrivilegeConfigObject!") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:value_type).of_type("PrivilegeValueTypeEnum!") + end +end diff --git a/spec/graphql/types/entitlement/privilege_value_type_enum_spec.rb b/spec/graphql/types/entitlement/privilege_value_type_enum_spec.rb new file mode 100644 index 0000000..d6ff9cc --- /dev/null +++ b/spec/graphql/types/entitlement/privilege_value_type_enum_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::PrivilegeValueTypeEnum do + subject { described_class } + + it "defines all privilege value types" do + Entitlement::Privilege::VALUE_TYPES.each do |value_type| + expect(subject.values[value_type].value).to eq(value_type) + end + end + + it "has the correct number of values" do + expect(subject.values.count).to eq(Entitlement::Privilege::VALUE_TYPES.count) + end +end diff --git a/spec/graphql/types/entitlement/subscription_entitlement_object_spec.rb b/spec/graphql/types/entitlement/subscription_entitlement_object_spec.rb new file mode 100644 index 0000000..3afcf3d --- /dev/null +++ b/spec/graphql/types/entitlement/subscription_entitlement_object_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::SubscriptionEntitlementObject do + subject { described_class } + + it do + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:privileges).of_type("[SubscriptionEntitlementPrivilegeObject!]!") + end +end diff --git a/spec/graphql/types/entitlement/subscription_entitlement_privilege_object_spec.rb b/spec/graphql/types/entitlement/subscription_entitlement_privilege_object_spec.rb new file mode 100644 index 0000000..ff662a1 --- /dev/null +++ b/spec/graphql/types/entitlement/subscription_entitlement_privilege_object_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::SubscriptionEntitlementPrivilegeObject do + subject { described_class } + + it do + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:config).of_type("PrivilegeConfigObject!") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:value).of_type("String") + expect(subject).to have_field(:value_type).of_type("PrivilegeValueTypeEnum!") + end +end diff --git a/spec/graphql/types/entitlement/update_feature_input_spec.rb b/spec/graphql/types/entitlement/update_feature_input_spec.rb new file mode 100644 index 0000000..4a6b66d --- /dev/null +++ b/spec/graphql/types/entitlement/update_feature_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::UpdateFeatureInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:privileges).of_type("[UpdatePrivilegeInput!]!") + end +end diff --git a/spec/graphql/types/entitlement/update_privilege_input_spec.rb b/spec/graphql/types/entitlement/update_privilege_input_spec.rb new file mode 100644 index 0000000..c10c92e --- /dev/null +++ b/spec/graphql/types/entitlement/update_privilege_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Entitlement::UpdatePrivilegeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:config).of_type("PrivilegeConfigInput") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:value_type).of_type("PrivilegeValueTypeEnum") + end +end diff --git a/spec/graphql/types/fees/object_spec.rb b/spec/graphql/types/fees/object_spec.rb new file mode 100644 index 0000000..e3aae10 --- /dev/null +++ b/spec/graphql/types/fees/object_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Fees::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:add_on).of_type("AddOn") + expect(subject).to have_field(:charge).of_type("Charge") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:grouped_by).of_type("JSON!") + expect(subject).to have_field(:fixed_charge).of_type("FixedCharge") + expect(subject).to have_field(:invoice_display_name).of_type("String") + expect(subject).to have_field(:invoice_id).of_type("ID") + expect(subject).to have_field(:invoice_name).of_type("String") + expect(subject).to have_field(:subscription).of_type("Subscription") + expect(subject).to have_field(:true_up_fee).of_type("Fee") + expect(subject).to have_field(:true_up_parent_fee).of_type("Fee") + expect(subject).to have_field(:wallet_transaction).of_type("WalletTransaction") + + expect(subject).to have_field(:creditable_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:events_count).of_type("BigInt") + expect(subject).to have_field(:fee_type).of_type("FeeTypesEnum!") + expect(subject).to have_field(:offsettable_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:precise_unit_amount).of_type("Float!") + expect(subject).to have_field(:succeeded_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:taxes_rate).of_type("Float") + expect(subject).to have_field(:units).of_type("Float!") + + expect(subject).to have_field(:applied_taxes).of_type("[FeeAppliedTax!]") + + expect(subject).to have_field(:amount_details).of_type("FeeAmountDetails") + + expect(subject).to have_field(:adjusted_fee).of_type("Boolean!") + expect(subject).to have_field(:adjusted_fee_type).of_type("AdjustedFeeTypeEnum") + + expect(subject).to have_field(:charge_filter).of_type("ChargeFilter") + expect(subject).to have_field(:presentation_breakdowns).of_type("[PresentationBreakdownUsage!]") + expect(subject).to have_field(:pricing_unit_usage).of_type("PricingUnitUsage") + expect(subject).to have_field(:properties).of_type("FeeProperties") + end + + describe "#wallet_transaction" do + subject { run_graphql_field("Fee.walletTransaction", fee) } + + context "when fee is a credit" do + let(:fee) { create(:credit_fee) } + let(:wallet_transaction) { fee.invoiceable } + + it "returns the wallet transaction" do + expect(subject).to be_present + expect(subject).to eq(wallet_transaction) + end + end + + context "when fee is not a credit" do + let(:fee) { create(:charge_fee) } + + it "returns nil" do + expect(subject).to be_nil + end + end + end + + describe "#presentation_breakdowns" do + subject { run_graphql_field("Fee.presentationBreakdowns", fee) } + + context "when fee has no presentation_breakdowns" do + let(:fee) { create(:charge_fee) } + + it "returns an empty array" do + expect(subject).to eq([]) + end + end + + context "when fee has a presentation_breakdown" do + let(:fee) do + create( + :charge_fee, + presentation_breakdowns: [build(:presentation_breakdown)] + ) + end + + it "returns the presentation_breakdowns" do + expect(subject).to eq([ + { + presentation_by: {"department" => "engineering"}, + units: "60.0" + } + ]) + end + end + + context "when fee has a composite presentation_breakdown" do + let(:fee) do + create( + :charge_fee, + presentation_breakdowns: [ + build( + :presentation_breakdown, + :with_composite_presentation_by + ) + ] + ) + end + + it "returns the presentation_breakdowns" do + expect(subject).to eq([ + { + presentation_by: {"department" => "engineering", "region" => "eu"}, + units: "60.0" + } + ]) + end + end + end +end diff --git a/spec/graphql/types/fees/presentation_breakdown_builder_spec.rb b/spec/graphql/types/fees/presentation_breakdown_builder_spec.rb new file mode 100644 index 0000000..4a385de --- /dev/null +++ b/spec/graphql/types/fees/presentation_breakdown_builder_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Fees::PresentationBreakdownBuilder do + subject(:result) { described_class.call(fees, filter:) } + + let(:filter) { described_class::UNGROUPED } + let(:fees) { [fee_one, fee_two] } + + let(:fee_one) do + build( + :charge_fee, + grouped_by: {}, + presentation_breakdowns: [ + build(:presentation_breakdown, fee: nil, presentation_by: {"cloud" => "aws"}, units: 1.2) + ] + ) + end + + let(:fee_two) do + build( + :charge_fee, + invoice: fee_one.invoice, + grouped_by: {}, + presentation_breakdowns: [ + build(:presentation_breakdown, presentation_by: {"cloud" => "aws"}, units: 0.3), + build(:presentation_breakdown, presentation_by: {"cloud" => "gcp"}, units: 3) + ] + ) + end + + it "returns one entry per breakdown with stringified units" do + expect(result).to eq([ + {presentation_by: {"cloud" => "aws"}, units: "1.2"}, + {presentation_by: {"cloud" => "aws"}, units: "0.3"}, + {presentation_by: {"cloud" => "gcp"}, units: "3.0"} + ]) + end + + context "when fees contain no presentation_breakdowns" do + let(:fees) { [build(:charge_fee, grouped_by: {}, presentation_breakdowns: [])] } + + it "returns an empty array" do + expect(result).to eq([]) + end + end + + describe "filtering" do + let(:ungrouped_fee) do + build( + :charge_fee, + grouped_by: {}, + presentation_breakdowns: [ + build(:presentation_breakdown, fee: nil, presentation_by: {"region" => "us"}, units: 1) + ] + ) + end + + let(:grouped_fee) do + build( + :charge_fee, + grouped_by: {"region" => "eu"}, + presentation_breakdowns: [ + build(:presentation_breakdown, fee: nil, presentation_by: {"region" => "eu"}, units: 2) + ] + ) + end + + let(:fees) { [ungrouped_fee, grouped_fee] } + + context "when filter is UNGROUPED" do + let(:filter) { described_class::UNGROUPED } + + it "includes only fees with blank grouped_by" do + expect(result).to eq([ + {presentation_by: {"region" => "us"}, units: "1.0"} + ]) + end + end + + context "when filter is GROUPED" do + let(:filter) { described_class::GROUPED } + + it "includes only fees with present grouped_by" do + expect(result).to eq([ + {presentation_by: {"region" => "eu"}, units: "2.0"} + ]) + end + end + + context "when filter is ALL" do + let(:filter) { described_class::ALL } + + it "includes breakdowns from all fees regardless of grouped_by" do + expect(result).to eq([ + {presentation_by: {"region" => "us"}, units: "1.0"}, + {presentation_by: {"region" => "eu"}, units: "2.0"} + ]) + end + end + end +end diff --git a/spec/graphql/types/fixed_charges/charge_model_enum_spec.rb b/spec/graphql/types/fixed_charges/charge_model_enum_spec.rb new file mode 100644 index 0000000..95dc414 --- /dev/null +++ b/spec/graphql/types/fixed_charges/charge_model_enum_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::FixedCharges::ChargeModelEnum do + subject { described_class } + + it "enumerates the correct charge model values" do + expect(subject.values.keys) + .to match_array(%w[standard graduated volume]) + end +end diff --git a/spec/graphql/types/fixed_charges/create_input_spec.rb b/spec/graphql/types/fixed_charges/create_input_spec.rb new file mode 100644 index 0000000..79689a3 --- /dev/null +++ b/spec/graphql/types/fixed_charges/create_input_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::FixedCharges::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:plan_id).of_type("ID!") + expect(subject).to accept_argument(:add_on_id).of_type("ID!") + expect(subject).to accept_argument(:apply_units_immediately).of_type("Boolean") + expect(subject).to accept_argument(:charge_model).of_type("FixedChargeChargeModelEnum!") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:pay_in_advance).of_type("Boolean") + expect(subject).to accept_argument(:prorated).of_type("Boolean") + expect(subject).to accept_argument(:units).of_type("String") + expect(subject).to accept_argument(:properties).of_type("FixedChargePropertiesInput") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/fixed_charges/input_spec.rb b/spec/graphql/types/fixed_charges/input_spec.rb new file mode 100644 index 0000000..020f306 --- /dev/null +++ b/spec/graphql/types/fixed_charges/input_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::FixedCharges::Input do + subject { described_class } + + it { is_expected.to accept_argument(:id).of_type("ID") } + it { is_expected.to accept_argument(:add_on_id).of_type("ID!") } + it { is_expected.to accept_argument(:apply_units_immediately).of_type("Boolean") } + it { is_expected.to accept_argument(:charge_model).of_type("FixedChargeChargeModelEnum!") } + it { is_expected.to accept_argument(:invoice_display_name).of_type("String") } + it { is_expected.to accept_argument(:pay_in_advance).of_type("Boolean") } + it { is_expected.to accept_argument(:prorated).of_type("Boolean") } + it { is_expected.to accept_argument(:properties).of_type("FixedChargePropertiesInput") } + it { is_expected.to accept_argument(:tax_codes).of_type("[String!]") } + it { is_expected.to accept_argument(:units).of_type("String") } +end diff --git a/spec/graphql/types/fixed_charges/object_spec.rb b/spec/graphql/types/fixed_charges/object_spec.rb new file mode 100644 index 0000000..00322e9 --- /dev/null +++ b/spec/graphql/types/fixed_charges/object_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::FixedCharges::Object do + subject { described_class } + + it { is_expected.to have_field(:code).of_type("String") } + it { is_expected.to have_field(:id).of_type("ID!") } + it { is_expected.to have_field(:invoice_display_name).of_type("String") } + it { is_expected.to have_field(:parent_id).of_type("ID") } + + it { is_expected.to have_field(:add_on).of_type("AddOn!") } + it { is_expected.to have_field(:charge_model).of_type("FixedChargeChargeModelEnum!") } + it { is_expected.to have_field(:pay_in_advance).of_type("Boolean!") } + it { is_expected.to have_field(:properties).of_type("FixedChargeProperties") } + it { is_expected.to have_field(:prorated).of_type("Boolean!") } + it { is_expected.to have_field(:units).of_type("String!") } + + it { is_expected.to have_field(:created_at).of_type("ISO8601DateTime!") } + it { is_expected.to have_field(:deleted_at).of_type("ISO8601DateTime") } + it { is_expected.to have_field(:updated_at).of_type("ISO8601DateTime!") } + + it { is_expected.to have_field(:taxes).of_type("[Tax!]") } + + describe "#units" do + subject { run_graphql_field("FixedCharge.units", fixed_charge) } + + context "when units is a whole number" do + let(:fixed_charge) { create(:fixed_charge, units: 1.0) } + + it "returns the value without decimal point" do + expect(subject).to eq("1") + end + end + + context "when units has decimal places" do + let(:fixed_charge) { create(:fixed_charge, units: 1.5) } + + it "returns the value with decimal places" do + expect(subject).to eq("1.5") + end + end + + context "when units is zero" do + let(:fixed_charge) { create(:fixed_charge, units: 0.0) } + + it "returns zero without decimal point" do + expect(subject).to eq("0") + end + end + + context "when units has trailing zeros" do + let(:fixed_charge) { create(:fixed_charge, units: 2.5000) } + + it "returns the value without trailing zeros" do + expect(subject).to eq("2.5") + end + end + end +end diff --git a/spec/graphql/types/fixed_charges/properties_input_spec.rb b/spec/graphql/types/fixed_charges/properties_input_spec.rb new file mode 100644 index 0000000..3885ed8 --- /dev/null +++ b/spec/graphql/types/fixed_charges/properties_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::FixedCharges::PropertiesInput do + subject { described_class } + + it { is_expected.to accept_argument(:amount).of_type("String") } + it { is_expected.to accept_argument(:graduated_ranges).of_type("[GraduatedRangeInput!]") } + it { is_expected.to accept_argument(:volume_ranges).of_type("[VolumeRangeInput!]") } +end diff --git a/spec/graphql/types/fixed_charges/properties_spec.rb b/spec/graphql/types/fixed_charges/properties_spec.rb new file mode 100644 index 0000000..f1c76c3 --- /dev/null +++ b/spec/graphql/types/fixed_charges/properties_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::FixedCharges::Properties do + subject { described_class } + + it { is_expected.to have_field(:amount).of_type("String") } + it { is_expected.to have_field(:graduated_ranges).of_type("[GraduatedRange!]") } + it { is_expected.to have_field(:volume_ranges).of_type("[VolumeRange!]") } +end diff --git a/spec/graphql/types/fixed_charges/update_input_spec.rb b/spec/graphql/types/fixed_charges/update_input_spec.rb new file mode 100644 index 0000000..300a463 --- /dev/null +++ b/spec/graphql/types/fixed_charges/update_input_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::FixedCharges::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:apply_units_immediately).of_type("Boolean") + expect(subject).to accept_argument(:charge_model).of_type("FixedChargeChargeModelEnum") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:pay_in_advance).of_type("Boolean") + expect(subject).to accept_argument(:prorated).of_type("Boolean") + expect(subject).to accept_argument(:units).of_type("String") + expect(subject).to accept_argument(:cascade_updates).of_type("Boolean") + expect(subject).to accept_argument(:properties).of_type("FixedChargePropertiesInput") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/integration_collection_mappings/create_input_spec.rb b/spec/graphql/types/integration_collection_mappings/create_input_spec.rb new file mode 100644 index 0000000..7147944 --- /dev/null +++ b/spec/graphql/types/integration_collection_mappings/create_input_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCollectionMappings::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:billing_entity_id).of_type("ID") + expect(subject).to accept_argument(:integration_id).of_type("ID!") + expect(subject).to accept_argument(:mapping_type).of_type("MappingTypeEnum!") + expect(subject).to accept_argument(:external_account_code).of_type("String") + expect(subject).to accept_argument(:external_id).of_type("String") + expect(subject).to accept_argument(:external_name).of_type("String") + expect(subject).to accept_argument(:tax_code).of_type("String") + expect(subject).to accept_argument(:tax_nexus).of_type("String") + expect(subject).to accept_argument(:tax_type).of_type("String") + expect(subject).to accept_argument(:currencies).of_type("[CurrencyMappingItemInput!]") + end +end diff --git a/spec/graphql/types/integration_collection_mappings/currency_mapping_item_input_spec.rb b/spec/graphql/types/integration_collection_mappings/currency_mapping_item_input_spec.rb new file mode 100644 index 0000000..81acfc8 --- /dev/null +++ b/spec/graphql/types/integration_collection_mappings/currency_mapping_item_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCollectionMappings::CurrencyMappingItemInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:currency_code).of_type("CurrencyEnum!") + expect(subject).to accept_argument(:currency_external_code).of_type("String!") + end +end diff --git a/spec/graphql/types/integration_collection_mappings/currency_mapping_item_spec.rb b/spec/graphql/types/integration_collection_mappings/currency_mapping_item_spec.rb new file mode 100644 index 0000000..c164f9c --- /dev/null +++ b/spec/graphql/types/integration_collection_mappings/currency_mapping_item_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCollectionMappings::CurrencyMappingItem do + subject { described_class } + + it do + expect(subject).to have_field(:currency_code).of_type("CurrencyEnum!") + expect(subject).to have_field(:currency_external_code).of_type("String!") + end +end diff --git a/spec/graphql/types/integration_collection_mappings/object_spec.rb b/spec/graphql/types/integration_collection_mappings/object_spec.rb new file mode 100644 index 0000000..4d043f4 --- /dev/null +++ b/spec/graphql/types/integration_collection_mappings/object_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCollectionMappings::Object do + subject { described_class } + + it do + expect(subject).to have_field(:billing_entity_id).of_type("ID") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_id).of_type("ID!") + expect(subject).to have_field(:mapping_type).of_type("MappingTypeEnum!") + + expect(subject).to have_field(:external_account_code).of_type("String") + expect(subject).to have_field(:external_id).of_type("String") + expect(subject).to have_field(:external_name).of_type("String") + + expect(subject).to have_field(:tax_code).of_type("String") + expect(subject).to have_field(:tax_nexus).of_type("String") + expect(subject).to have_field(:tax_type).of_type("String") + + expect(subject).to have_field(:currencies).of_type("[CurrencyMappingItem!]") + end +end diff --git a/spec/graphql/types/integration_collection_mappings/update_input_spec.rb b/spec/graphql/types/integration_collection_mappings/update_input_spec.rb new file mode 100644 index 0000000..b08ee1e --- /dev/null +++ b/spec/graphql/types/integration_collection_mappings/update_input_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCollectionMappings::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:integration_id).of_type("ID") + expect(subject).to accept_argument(:mapping_type).of_type("MappingTypeEnum") + expect(subject).to accept_argument(:external_account_code).of_type("String") + expect(subject).to accept_argument(:external_id).of_type("String") + expect(subject).to accept_argument(:external_name).of_type("String") + expect(subject).to accept_argument(:tax_code).of_type("String") + expect(subject).to accept_argument(:tax_nexus).of_type("String") + expect(subject).to accept_argument(:tax_type).of_type("String") + expect(subject).to accept_argument(:currencies).of_type("[CurrencyMappingItemInput!]") + end +end diff --git a/spec/graphql/types/integration_customers/anrok_spec.rb b/spec/graphql/types/integration_customers/anrok_spec.rb new file mode 100644 index 0000000..209841c --- /dev/null +++ b/spec/graphql/types/integration_customers/anrok_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCustomers::Anrok do + subject { described_class } + + it do + expect(subject).to have_field(:external_account_id).of_type("String") + expect(subject).to have_field(:external_customer_id).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_type).of_type("IntegrationTypeEnum") + expect(subject).to have_field(:integration_id).of_type("ID") + expect(subject).to have_field(:integration_code).of_type("String") + expect(subject).to have_field(:sync_with_provider).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integration_customers/avalara_spec.rb b/spec/graphql/types/integration_customers/avalara_spec.rb new file mode 100644 index 0000000..eb33ea9 --- /dev/null +++ b/spec/graphql/types/integration_customers/avalara_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCustomers::Avalara do + subject { described_class } + + it do + expect(subject).to have_field(:external_customer_id).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_type).of_type("IntegrationTypeEnum") + expect(subject).to have_field(:integration_id).of_type("ID") + expect(subject).to have_field(:integration_code).of_type("String") + expect(subject).to have_field(:sync_with_provider).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integration_customers/hubspot_spec.rb b/spec/graphql/types/integration_customers/hubspot_spec.rb new file mode 100644 index 0000000..9176bbe --- /dev/null +++ b/spec/graphql/types/integration_customers/hubspot_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCustomers::Hubspot do + subject { described_class } + + it do + expect(subject).to have_field(:external_customer_id).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_type).of_type("IntegrationTypeEnum") + expect(subject).to have_field(:integration_id).of_type("ID") + expect(subject).to have_field(:integration_code).of_type("String") + expect(subject).to have_field(:targeted_object).of_type("HubspotTargetedObjectsEnum") + expect(subject).to have_field(:sync_with_provider).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integration_customers/input_spec.rb b/spec/graphql/types/integration_customers/input_spec.rb new file mode 100644 index 0000000..be89781 --- /dev/null +++ b/spec/graphql/types/integration_customers/input_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCustomers::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:external_customer_id).of_type("String") + expect(subject).to accept_argument(:integration_type).of_type("IntegrationTypeEnum") + expect(subject).to accept_argument(:integration_id).of_type("ID") + expect(subject).to accept_argument(:integration_code).of_type("String") + expect(subject).to accept_argument(:subsidiary_id).of_type("String") + expect(subject).to accept_argument(:sync_with_provider).of_type("Boolean") + expect(subject).to accept_argument(:targeted_object).of_type("HubspotTargetedObjectsEnum") + end +end diff --git a/spec/graphql/types/integration_customers/netsuite_spec.rb b/spec/graphql/types/integration_customers/netsuite_spec.rb new file mode 100644 index 0000000..5b35272 --- /dev/null +++ b/spec/graphql/types/integration_customers/netsuite_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCustomers::Netsuite do + subject { described_class } + + it do + expect(subject).to have_field(:external_customer_id).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_type).of_type("IntegrationTypeEnum") + expect(subject).to have_field(:integration_id).of_type("ID") + expect(subject).to have_field(:integration_code).of_type("String") + expect(subject).to have_field(:subsidiary_id).of_type("String") + expect(subject).to have_field(:sync_with_provider).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integration_customers/salesforce_spec.rb b/spec/graphql/types/integration_customers/salesforce_spec.rb new file mode 100644 index 0000000..f9ac49d --- /dev/null +++ b/spec/graphql/types/integration_customers/salesforce_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCustomers::Salesforce do + subject { described_class } + + it do + expect(subject).to have_field(:external_customer_id).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_type).of_type("IntegrationTypeEnum") + expect(subject).to have_field(:integration_id).of_type("ID") + expect(subject).to have_field(:integration_code).of_type("String") + expect(subject).to have_field(:sync_with_provider).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integration_customers/xero_spec.rb b/spec/graphql/types/integration_customers/xero_spec.rb new file mode 100644 index 0000000..29b4cc3 --- /dev/null +++ b/spec/graphql/types/integration_customers/xero_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationCustomers::Xero do + subject { described_class } + + it do + expect(subject).to have_field(:external_customer_id).of_type("String") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_type).of_type("IntegrationTypeEnum") + expect(subject).to have_field(:integration_id).of_type("ID") + expect(subject).to have_field(:integration_code).of_type("String") + expect(subject).to have_field(:sync_with_provider).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integration_items/object_spec.rb b/spec/graphql/types/integration_items/object_spec.rb new file mode 100644 index 0000000..51c361f --- /dev/null +++ b/spec/graphql/types/integration_items/object_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationItems::Object do + subject { described_class } + + it do + expect(subject).to have_field(:external_account_code).of_type("String") + expect(subject).to have_field(:external_id).of_type("String!") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_id).of_type("ID!") + expect(subject).to have_field(:item_type).of_type("IntegrationItemTypeEnum!") + expect(subject).to have_field(:external_name).of_type("String") + end +end diff --git a/spec/graphql/types/integration_mappings/create_input_spec.rb b/spec/graphql/types/integration_mappings/create_input_spec.rb new file mode 100644 index 0000000..33869b0 --- /dev/null +++ b/spec/graphql/types/integration_mappings/create_input_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationMappings::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:integration_id).of_type("ID!") + expect(subject).to accept_argument(:mappable_id).of_type("ID!") + expect(subject).to accept_argument(:mappable_type).of_type("MappableTypeEnum!") + expect(subject).to accept_argument(:external_account_code).of_type("String") + expect(subject).to accept_argument(:external_id).of_type("String!") + expect(subject).to accept_argument(:external_name).of_type("String") + end +end diff --git a/spec/graphql/types/integration_mappings/object_spec.rb b/spec/graphql/types/integration_mappings/object_spec.rb new file mode 100644 index 0000000..eac0e63 --- /dev/null +++ b/spec/graphql/types/integration_mappings/object_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationMappings::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:integration_id).of_type("ID!") + expect(subject).to have_field(:mappable_id).of_type("ID!") + expect(subject).to have_field(:mappable_type).of_type("MappableTypeEnum!") + expect(subject).to have_field(:external_account_code).of_type("String") + expect(subject).to have_field(:external_id).of_type("String!") + expect(subject).to have_field(:external_name).of_type("String") + end +end diff --git a/spec/graphql/types/integration_mappings/update_input_spec.rb b/spec/graphql/types/integration_mappings/update_input_spec.rb new file mode 100644 index 0000000..dcf697c --- /dev/null +++ b/spec/graphql/types/integration_mappings/update_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::IntegrationMappings::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:external_account_code).of_type("String") + expect(subject).to accept_argument(:external_id).of_type("String") + expect(subject).to accept_argument(:external_name).of_type("String") + end +end diff --git a/spec/graphql/types/integrations/accounts/object_spec.rb b/spec/graphql/types/integrations/accounts/object_spec.rb new file mode 100644 index 0000000..0094750 --- /dev/null +++ b/spec/graphql/types/integrations/accounts/object_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Accounts::Object do + subject { described_class } + + it do + expect(subject).to have_field(:external_account_code).of_type("String!") + expect(subject).to have_field(:external_id).of_type("String!") + expect(subject).to have_field(:external_name).of_type("String") + end +end diff --git a/spec/graphql/types/integrations/anrok_spec.rb b/spec/graphql/types/integrations/anrok_spec.rb new file mode 100644 index 0000000..9d3be61 --- /dev/null +++ b/spec/graphql/types/integrations/anrok_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Anrok do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:api_key).of_type("ObfuscatedString!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:failed_invoices_count).of_type("Int") + expect(subject).to have_field(:has_mappings_configured).of_type("Boolean") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:external_account_id).of_type("String") + end +end diff --git a/spec/graphql/types/integrations/avalara/create_input_spec.rb b/spec/graphql/types/integrations/avalara/create_input_spec.rb new file mode 100644 index 0000000..281dd69 --- /dev/null +++ b/spec/graphql/types/integrations/avalara/create_input_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Avalara::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:company_code).of_type("String!") + expect(subject).to accept_argument(:connection_id).of_type("String!") + expect(subject).to accept_argument(:account_id).of_type("String!") + expect(subject).to accept_argument(:license_key).of_type("String!") + end +end diff --git a/spec/graphql/types/integrations/avalara/update_input_spec.rb b/spec/graphql/types/integrations/avalara/update_input_spec.rb new file mode 100644 index 0000000..0a32f4e --- /dev/null +++ b/spec/graphql/types/integrations/avalara/update_input_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Avalara::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:company_code).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:account_id).of_type("String") + expect(subject).to accept_argument(:license_key).of_type("String") + end +end diff --git a/spec/graphql/types/integrations/avalara_spec.rb b/spec/graphql/types/integrations/avalara_spec.rb new file mode 100644 index 0000000..fb500cf --- /dev/null +++ b/spec/graphql/types/integrations/avalara_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Avalara do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:license_key).of_type("ObfuscatedString!") + expect(subject).to have_field(:account_id).of_type("String") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:company_code).of_type("String!") + expect(subject).to have_field(:company_id).of_type("String") + expect(subject).to have_field(:failed_invoices_count).of_type("Int") + expect(subject).to have_field(:has_mappings_configured).of_type("Boolean") + expect(subject).to have_field(:name).of_type("String!") + end +end diff --git a/spec/graphql/types/integrations/hubspot/create_input_spec.rb b/spec/graphql/types/integrations/hubspot/create_input_spec.rb new file mode 100644 index 0000000..9dbc354 --- /dev/null +++ b/spec/graphql/types/integrations/hubspot/create_input_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Hubspot::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:connection_id).of_type("String!") + expect(subject).to accept_argument(:default_targeted_object).of_type("HubspotTargetedObjectsEnum!") + expect(subject).to accept_argument(:sync_invoices).of_type("Boolean") + expect(subject).to accept_argument(:sync_subscriptions).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integrations/hubspot/targeted_objects_enum_spec.rb b/spec/graphql/types/integrations/hubspot/targeted_objects_enum_spec.rb new file mode 100644 index 0000000..4a6ca15 --- /dev/null +++ b/spec/graphql/types/integrations/hubspot/targeted_objects_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Hubspot::TargetedObjectsEnum do + it "enumerizes the correct values" do + expect(described_class.values.keys).to match_array(%w[companies contacts]) + end +end diff --git a/spec/graphql/types/integrations/hubspot/update_input_spec.rb b/spec/graphql/types/integrations/hubspot/update_input_spec.rb new file mode 100644 index 0000000..da30eeb --- /dev/null +++ b/spec/graphql/types/integrations/hubspot/update_input_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Hubspot::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:connection_id).of_type("String") + expect(subject).to accept_argument(:default_targeted_object).of_type("HubspotTargetedObjectsEnum") + expect(subject).to accept_argument(:sync_invoices).of_type("Boolean") + expect(subject).to accept_argument(:sync_subscriptions).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integrations/hubspot_spec.rb b/spec/graphql/types/integrations/hubspot_spec.rb new file mode 100644 index 0000000..cfe19cf --- /dev/null +++ b/spec/graphql/types/integrations/hubspot_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Hubspot do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:connection_id).of_type("ID!") + expect(subject).to have_field(:default_targeted_object).of_type("HubspotTargetedObjectsEnum!") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:portal_id).of_type("String") + + expect(subject).to have_field(:invoices_object_type_id).of_type("String") + expect(subject).to have_field(:subscriptions_object_type_id).of_type("String") + + expect(subject).to have_field(:sync_invoices).of_type("Boolean") + expect(subject).to have_field(:sync_subscriptions).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integrations/netsuite_spec.rb b/spec/graphql/types/integrations/netsuite_spec.rb new file mode 100644 index 0000000..9c1ff3d --- /dev/null +++ b/spec/graphql/types/integrations/netsuite_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Netsuite do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:client_id).of_type("String") + expect(subject).to have_field(:client_secret).of_type("ObfuscatedString") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:has_mappings_configured).of_type("Boolean") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:script_endpoint_url).of_type("String!") + expect(subject).to have_field(:token_id).of_type("String") + expect(subject).to have_field(:token_secret).of_type("ObfuscatedString") + + expect(subject).to have_field(:sync_credit_notes).of_type("Boolean") + expect(subject).to have_field(:sync_invoices).of_type("Boolean") + expect(subject).to have_field(:sync_payments).of_type("Boolean") + end +end diff --git a/spec/graphql/types/integrations/okta_spec.rb b/spec/graphql/types/integrations/okta_spec.rb new file mode 100644 index 0000000..703da65 --- /dev/null +++ b/spec/graphql/types/integrations/okta_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Okta do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:client_id).of_type("String") + expect(subject).to have_field(:client_secret).of_type("ObfuscatedString") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:domain).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:organization_name).of_type("String!") + expect(subject).to have_field(:host).of_type("String") + end + + it "ensure all fields are tested" do + expect(subject.fields.values.map(&:original_name) - + %i[id client_id client_secret code domain name organization_name host]).to be_empty + end +end diff --git a/spec/graphql/types/integrations/premium_integration_type_enum_spec.rb b/spec/graphql/types/integrations/premium_integration_type_enum_spec.rb new file mode 100644 index 0000000..037fa5e --- /dev/null +++ b/spec/graphql/types/integrations/premium_integration_type_enum_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::PremiumIntegrationTypeEnum do + let(:premium_integration_types) do + %w[ + beta_payment_authorization + api_permissions + auto_dunning + hubspot + netsuite + okta + progressive_billing + lifetime_usage + revenue_analytics + revenue_share + salesforce + xero + remove_branding_watermark + manual_payments + from_email + issue_receipts + preview + avalara + multi_entities_pro + multi_entities_enterprise + analytics_dashboards + forecasted_usage + projected_usage + custom_roles + events_targeting_wallets + security_logs + granular_lifetime_usage + order_forms + ] + end + + it "enumerizes the correct values" do + expect(described_class.values.keys).to match_array(premium_integration_types) + end +end diff --git a/spec/graphql/types/integrations/salesforce/create_input_spec.rb b/spec/graphql/types/integrations/salesforce/create_input_spec.rb new file mode 100644 index 0000000..5b90dd1 --- /dev/null +++ b/spec/graphql/types/integrations/salesforce/create_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Salesforce::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:instance_id).of_type("String!") + end +end diff --git a/spec/graphql/types/integrations/salesforce/sync_invoice_input_spec.rb b/spec/graphql/types/integrations/salesforce/sync_invoice_input_spec.rb new file mode 100644 index 0000000..ff17bda --- /dev/null +++ b/spec/graphql/types/integrations/salesforce/sync_invoice_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Salesforce::SyncInvoiceInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:invoice_id).of_type("ID!") + end +end diff --git a/spec/graphql/types/integrations/salesforce/update_input_spec.rb b/spec/graphql/types/integrations/salesforce/update_input_spec.rb new file mode 100644 index 0000000..4c2ab2a --- /dev/null +++ b/spec/graphql/types/integrations/salesforce/update_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Salesforce::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:instance_id).of_type("String") + end +end diff --git a/spec/graphql/types/integrations/salesforce_spec.rb b/spec/graphql/types/integrations/salesforce_spec.rb new file mode 100644 index 0000000..529f554 --- /dev/null +++ b/spec/graphql/types/integrations/salesforce_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Salesforce do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:instance_id).of_type("String!") + end +end diff --git a/spec/graphql/types/integrations/subsidiaries/object_spec.rb b/spec/graphql/types/integrations/subsidiaries/object_spec.rb new file mode 100644 index 0000000..46ab8c3 --- /dev/null +++ b/spec/graphql/types/integrations/subsidiaries/object_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Subsidiaries::Object do + subject { described_class } + + it do + expect(subject).to have_field(:external_id).of_type("String!") + expect(subject).to have_field(:external_name).of_type("String") + end +end diff --git a/spec/graphql/types/integrations/sync_credit_note_input_spec.rb b/spec/graphql/types/integrations/sync_credit_note_input_spec.rb new file mode 100644 index 0000000..9094051 --- /dev/null +++ b/spec/graphql/types/integrations/sync_credit_note_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::SyncCreditNoteInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:credit_note_id).of_type("ID!") + end +end diff --git a/spec/graphql/types/integrations/sync_hubspot_invoice_input_spec.rb b/spec/graphql/types/integrations/sync_hubspot_invoice_input_spec.rb new file mode 100644 index 0000000..7f91562 --- /dev/null +++ b/spec/graphql/types/integrations/sync_hubspot_invoice_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::SyncHubspotInvoiceInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:invoice_id).of_type("ID!") + end +end diff --git a/spec/graphql/types/integrations/sync_invoice_input_spec.rb b/spec/graphql/types/integrations/sync_invoice_input_spec.rb new file mode 100644 index 0000000..ab305ea --- /dev/null +++ b/spec/graphql/types/integrations/sync_invoice_input_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::SyncInvoiceInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:invoice_id).of_type("ID!") + end +end diff --git a/spec/graphql/types/integrations/xero_spec.rb b/spec/graphql/types/integrations/xero_spec.rb new file mode 100644 index 0000000..1433a57 --- /dev/null +++ b/spec/graphql/types/integrations/xero_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Integrations::Xero do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:connection_id).of_type("ID!") + expect(subject).to have_field(:has_mappings_configured).of_type("Boolean") + expect(subject).to have_field(:name).of_type("String!") + + expect(subject).to have_field(:sync_credit_notes).of_type("Boolean") + expect(subject).to have_field(:sync_invoices).of_type("Boolean") + expect(subject).to have_field(:sync_payments).of_type("Boolean") + end +end diff --git a/spec/graphql/types/invites/object_spec.rb b/spec/graphql/types/invites/object_spec.rb new file mode 100644 index 0000000..7c66d9f --- /dev/null +++ b/spec/graphql/types/invites/object_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invites::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:organization).of_type("Organization!") + expect(subject).to have_field(:recipient).of_type("Membership!") + + expect(subject).to have_field(:email).of_type("String!") + expect(subject).to have_field(:roles).of_type("[String!]!") + expect(subject).to have_field(:status).of_type("InviteStatusTypeEnum!") + expect(subject).to have_field(:token).of_type("String!") + + expect(subject).to have_field(:accepted_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:revoked_at).of_type("ISO8601DateTime") + end +end diff --git a/spec/graphql/types/invoice_custom_sections/create_input_spec.rb b/spec/graphql/types/invoice_custom_sections/create_input_spec.rb new file mode 100644 index 0000000..1d41c79 --- /dev/null +++ b/spec/graphql/types/invoice_custom_sections/create_input_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::InvoiceCustomSections::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:details).of_type("String") + expect(subject).to accept_argument(:display_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String!") + end +end diff --git a/spec/graphql/types/invoice_custom_sections/object_spec.rb b/spec/graphql/types/invoice_custom_sections/object_spec.rb new file mode 100644 index 0000000..c46a364 --- /dev/null +++ b/spec/graphql/types/invoice_custom_sections/object_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::InvoiceCustomSections::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization") + + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:details).of_type("String") + expect(subject).to have_field(:display_name).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + end +end diff --git a/spec/graphql/types/invoice_custom_sections/update_input_spec.rb b/spec/graphql/types/invoice_custom_sections/update_input_spec.rb new file mode 100644 index 0000000..37edfd5 --- /dev/null +++ b/spec/graphql/types/invoice_custom_sections/update_input_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::InvoiceCustomSections::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:details).of_type("String") + expect(subject).to accept_argument(:display_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + end +end diff --git a/spec/graphql/types/invoice_subscriptions/object_spec.rb b/spec/graphql/types/invoice_subscriptions/object_spec.rb new file mode 100644 index 0000000..5ed430f --- /dev/null +++ b/spec/graphql/types/invoice_subscriptions/object_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::InvoiceSubscriptions::Object do + subject { described_class } + + it do + expect(subject).to have_field(:invoice).of_type("Invoice!") + expect(subject).to have_field(:subscription).of_type("Subscription!") + + expect(subject).to have_field(:charge_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:subscription_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_amount_cents).of_type("BigInt!") + + expect(subject).to have_field(:fees).of_type("[Fee!]") + + expect(subject).to have_field(:charges_from_datetime).of_type("ISO8601DateTime") + expect(subject).to have_field(:charges_to_datetime).of_type("ISO8601DateTime") + + expect(subject).to have_field(:in_advance_charges_from_datetime).of_type("ISO8601DateTime") + expect(subject).to have_field(:in_advance_charges_to_datetime).of_type("ISO8601DateTime") + + expect(subject).to have_field(:from_datetime).of_type("ISO8601DateTime") + expect(subject).to have_field(:to_datetime).of_type("ISO8601DateTime") + + expect(subject).to have_field(:accept_new_charge_fees).of_type("Boolean!") + end +end diff --git a/spec/graphql/types/invoices/create_invoice_input_spec.rb b/spec/graphql/types/invoices/create_invoice_input_spec.rb new file mode 100644 index 0000000..c412c6a --- /dev/null +++ b/spec/graphql/types/invoices/create_invoice_input_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invoices::CreateInvoiceInput do + subject { described_class } + + it "has the expected arguments with correct types" do + expect(subject).to accept_argument(:billing_entity_id).of_type("ID") + expect(subject).to accept_argument(:currency).of_type("CurrencyEnum") + expect(subject).to accept_argument(:customer_id).of_type("ID!") + expect(subject).to accept_argument(:fees).of_type("[FeeInput!]!") + expect(subject).to accept_argument(:invoice_custom_section).of_type("InvoiceCustomSectionsReferenceInput") + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + expect(subject).to accept_argument(:voided_invoice_id).of_type("ID") + end +end diff --git a/spec/graphql/types/invoices/fee_input_spec.rb b/spec/graphql/types/invoices/fee_input_spec.rb new file mode 100644 index 0000000..f8e2ed3 --- /dev/null +++ b/spec/graphql/types/invoices/fee_input_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invoices::FeeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:add_on_id).of_type("ID") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + expect(subject).to accept_argument(:unit_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:units).of_type("Float") + expect(subject).to accept_argument(:from_datetime).of_type("ISO8601DateTime!") + expect(subject).to accept_argument(:to_datetime).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/invoices/object_spec.rb b/spec/graphql/types/invoices/object_spec.rb new file mode 100644 index 0000000..d52443f --- /dev/null +++ b/spec/graphql/types/invoices/object_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invoices::Object do + subject { described_class } + + it "has the expected fields with correct types" do + expect(subject).to have_field(:customer).of_type("Customer!") + expect(subject).to have_field(:billing_entity).of_type("BillingEntity!") + + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:number).of_type("String!") + expect(subject).to have_field(:sequential_id).of_type("ID!") + + expect(subject).to have_field(:self_billed).of_type("Boolean!") + expect(subject).to have_field(:version_number).of_type("Int!") + + expect(subject).to have_field(:invoice_type).of_type("InvoiceTypeEnum!") + expect(subject).to have_field(:payment_dispute_losable).of_type("Boolean!") + expect(subject).to have_field(:payment_dispute_lost_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:payment_status).of_type("InvoicePaymentStatusTypeEnum!") + expect(subject).to have_field(:status).of_type("InvoiceStatusTypeEnum!") + expect(subject).to have_field(:voidable).of_type("Boolean!") + + expect(subject).to have_field(:currency).of_type("CurrencyEnum") + expect(subject).to have_field(:taxes_rate).of_type("Float!") + + expect(subject).to have_field(:charge_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:coupons_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:credit_notes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:fees_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:prepaid_credit_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:prepaid_granted_credit_amount_cents).of_type("BigInt") + expect(subject).to have_field(:prepaid_purchased_credit_amount_cents).of_type("BigInt") + expect(subject).to have_field(:progressive_billing_credit_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:sub_total_excluding_taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:sub_total_including_taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:taxes_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_due_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_paid_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_settled_amount_cents).of_type("BigInt!") + + expect(subject).to have_field(:issuing_date).of_type("ISO8601Date!") + expect(subject).to have_field(:expected_finalization_date).of_type("ISO8601Date!") + expect(subject).to have_field(:payment_due_date).of_type("ISO8601Date!") + expect(subject).to have_field(:payment_overdue).of_type("Boolean!") + expect(subject).to have_field(:ready_for_payment_processing).of_type("Boolean!") + expect(subject).to have_field(:all_charges_have_fees).of_type("Boolean!") + expect(subject).to have_field(:all_fixed_charges_have_fees).of_type("Boolean!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + + expect(subject).to have_field(:creditable_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:offsettable_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:refundable_amount_cents).of_type("BigInt!") + + expect(subject).to have_field(:file_url).of_type("String") + expect(subject).to have_field(:xml_url).of_type("String") + expect(subject).to have_field(:metadata).of_type("[InvoiceMetadata!]") + + expect(subject).to have_field(:activity_logs).of_type("[ActivityLog!]") + expect(subject).to have_field(:applied_taxes).of_type("[InvoiceAppliedTax!]") + expect(subject).to have_field(:credit_notes).of_type("[CreditNote!]") + expect(subject).to have_field(:fees).of_type("[Fee!]") + expect(subject).to have_field(:invoice_subscriptions).of_type("[InvoiceSubscription!]") + expect(subject).to have_field(:subscriptions).of_type("[Subscription!]") + + expect(subject).to have_field(:external_hubspot_integration_id).of_type("String") + expect(subject).to have_field(:external_salesforce_integration_id).of_type("String") + expect(subject).to have_field(:external_integration_id).of_type("String") + expect(subject).to have_field(:integration_hubspot_syncable).of_type("Boolean!") + expect(subject).to have_field(:integration_salesforce_syncable).of_type("Boolean!") + expect(subject).to have_field(:integration_syncable).of_type("Boolean!") + expect(subject).to have_field(:payments).of_type("[Payment!]") + + expect(subject).to have_field(:tax_provider_id).of_type("String") + + expect(subject).to have_field(:regenerated_invoice_id).of_type("String") + expect(subject).to have_field(:voided_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:voided_invoice_id).of_type("String") + end + + describe "#subscriptions" do + subject(:subscriptions) { run_graphql_field("Invoice.subscriptions", invoice) } + + let(:invoice) { create(:invoice) } + let(:organization) { invoice.organization } + let(:customer) { invoice.customer } + + let(:plan_zebra) { create(:plan, organization:, name: "Zebra Plan", invoice_display_name: nil) } + let(:plan_alpha) { create(:plan, organization:, name: "Alpha Plan", invoice_display_name: nil) } + + let(:subscription_zebra) { create(:subscription, customer:, plan: plan_zebra, name: nil) } + let(:subscription_alpha) { create(:subscription, customer:, plan: plan_alpha, name: nil) } + let(:subscription_custom) { create(:subscription, customer:, plan: plan_zebra, name: "AAA Custom") } + + before do + create(:invoice_subscription, invoice:, subscription: subscription_zebra) + create(:invoice_subscription, invoice:, subscription: subscription_alpha) + create(:invoice_subscription, invoice:, subscription: subscription_custom) + end + + it "returns subscriptions ordered alphabetically by invoice_name" do + expect(subscriptions.map(&:invoice_name)).to eq([ + "AAA Custom", + "Alpha Plan", + "Zebra Plan" + ]) + end + end + + describe "#invoice_subscriptions" do + subject(:invoice_subscriptions) { run_graphql_field("Invoice.invoiceSubscriptions", invoice) } + + let(:invoice) { create(:invoice) } + let(:organization) { invoice.organization } + let(:customer) { invoice.customer } + + let(:plan_zebra) { create(:plan, organization:, name: "Zebra Plan", invoice_display_name: nil) } + let(:plan_alpha) { create(:plan, organization:, name: "Alpha Plan", invoice_display_name: nil) } + + let(:subscription_zebra) { create(:subscription, customer:, plan: plan_zebra, name: nil) } + let(:subscription_alpha) { create(:subscription, customer:, plan: plan_alpha, name: nil) } + let(:subscription_custom) { create(:subscription, customer:, plan: plan_zebra, name: "AAA Custom") } + + before do + create(:invoice_subscription, invoice:, subscription: subscription_zebra) + create(:invoice_subscription, invoice:, subscription: subscription_alpha) + create(:invoice_subscription, invoice:, subscription: subscription_custom) + end + + it "returns invoice_subscriptions ordered alphabetically by subscription invoice_name" do + expect(invoice_subscriptions.map { |is| is.subscription.invoice_name }).to eq([ + "AAA Custom", + "Alpha Plan", + "Zebra Plan" + ]) + end + end +end diff --git a/spec/graphql/types/invoices/retry_payment_input_spec.rb b/spec/graphql/types/invoices/retry_payment_input_spec.rb new file mode 100644 index 0000000..dd9a0e7 --- /dev/null +++ b/spec/graphql/types/invoices/retry_payment_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invoices::RetryPaymentInput do + subject { described_class } + + it "has the expected arguments with correct types" do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + end +end diff --git a/spec/graphql/types/invoices/settlement_type_enum_spec.rb b/spec/graphql/types/invoices/settlement_type_enum_spec.rb new file mode 100644 index 0000000..1b8c1f7 --- /dev/null +++ b/spec/graphql/types/invoices/settlement_type_enum_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invoices::SettlementTypeEnum do + it "exposes allowed settlement types" do + expect(described_class.values.keys).to match_array([ + InvoiceSettlement::SETTLEMENT_TYPES.fetch(:credit_note) + ]) + end +end diff --git a/spec/graphql/types/invoices/void_invoice_input_spec.rb b/spec/graphql/types/invoices/void_invoice_input_spec.rb new file mode 100644 index 0000000..00640ad --- /dev/null +++ b/spec/graphql/types/invoices/void_invoice_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invoices::VoidInvoiceInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:generate_credit_note).of_type("Boolean") + expect(subject).to accept_argument(:credit_amount).of_type("BigInt") + expect(subject).to accept_argument(:refund_amount).of_type("BigInt") + end +end diff --git a/spec/graphql/types/invoices/voided_invoice_fee_input_spec.rb b/spec/graphql/types/invoices/voided_invoice_fee_input_spec.rb new file mode 100644 index 0000000..3a87232 --- /dev/null +++ b/spec/graphql/types/invoices/voided_invoice_fee_input_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Invoices::VoidedInvoiceFeeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:add_on_id).of_type("ID") + expect(subject).to accept_argument(:charge_filter_id).of_type("ID") + expect(subject).to accept_argument(:charge_id).of_type("ID") + expect(subject).to accept_argument(:fixed_charge_id).of_type("ID") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:subscription_id).of_type("ID") + expect(subject).to accept_argument(:unit_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:units).of_type("Float") + end +end diff --git a/spec/graphql/types/membership_type_spec.rb b/spec/graphql/types/membership_type_spec.rb new file mode 100644 index 0000000..4f1b61f --- /dev/null +++ b/spec/graphql/types/membership_type_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::MembershipType do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:organization).of_type("Organization!") + expect(subject).to have_field(:user).of_type("User!") + + expect(subject).to have_field(:permissions).of_type("Permissions!") + expect(subject).to have_field(:roles).of_type("[String!]!") + expect(subject).to have_field(:status).of_type("MembershipStatus!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:revoked_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/metadata/input_spec.rb b/spec/graphql/types/metadata/input_spec.rb new file mode 100644 index 0000000..35569b5 --- /dev/null +++ b/spec/graphql/types/metadata/input_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Metadata::Input do + subject { described_class } + + it "has the expected arguments" do + expect(subject).to accept_argument(:key).of_type("String!") + expect(subject).to accept_argument(:value).of_type("String") + end + + describe "ARGUMENT_OPTIONS" do + let(:prepare) { described_class::ARGUMENT_OPTIONS[:prepare] } + let(:validator_config) { described_class::ARGUMENT_OPTIONS[:validates] } + let(:validated_object) { double("Validated Object") } # rubocop:disable RSpec/VerifiedDoubles + let(:validator) do + Validators::UniqueByFieldValidator.new(**validator_config.values.first, validated: validated_object) + end + + describe "prepare" do + it "converts array of items to hash" do + input = [{key: "foo", value: "bar"}, {key: "baz", value: "qux"}] + expect(prepare.call(input, nil)).to eq({"foo" => "bar", "baz" => "qux"}) + end + + it "returns nil for nil input" do + expect(prepare.call(nil, nil)).to be_nil + end + + it "returns empty hash for empty array" do + expect(prepare.call([], nil)).to eq({}) + end + + it "handles nil value" do + input = [{key: "foo", value: nil}] + expect(prepare.call(input, nil)).to eq({"foo" => nil}) + end + + it "handles empty string key" do + input = [{key: "", value: "bar"}] + expect(prepare.call(input, nil)).to eq({"" => "bar"}) + end + + it "handles empty string value" do + input = [{key: "foo", value: ""}] + expect(prepare.call(input, nil)).to eq({"foo" => ""}) + end + + it "overwrites duplicate keys with last value" do + input = [{key: "foo", value: "first"}, {key: "foo", value: "second"}] + expect(prepare.call(input, nil)).to eq({"foo" => "second"}) + end + + it "handles mixed edge cases" do + input = [ + {key: "foo", value: nil}, + {key: "", value: "baz"}, + {key: "bar", value: ""}, + {key: "bar", value: "qux"} + ] + expect(prepare.call(input, nil)).to eq({ + "foo" => nil, + "" => "baz", + "bar" => "qux" + }) + end + end + + describe "validates" do + it "uses UniqueByFieldValidator with field_name: :key" do + expect(validator_config).to eq({ + Validators::UniqueByFieldValidator => {field_name: :key} + }) + end + + it "returns error for duplicate keys" do + input = [{key: "foo", value: "bar"}, {key: "foo", value: "baz"}] + expect(validator.validate(nil, nil, input)).to eq("duplicated_field") + end + + it "returns nil for unique keys" do + input = [{key: "foo", value: "bar"}, {key: "baz", value: "qux"}] + expect(validator.validate(nil, nil, input)).to be_nil + end + + it "returns nil for empty array" do + expect(validator.validate(nil, nil, [])).to be_nil + end + + it "returns nil for nil keys (nil keys are not duplicates)" do + input = [{key: nil, value: "bar"}, {key: nil, value: "baz"}] + expect(validator.validate(nil, nil, input)).to be_nil + end + end + end + + describe "integration with GraphQL argument" do + let(:test_input_class) do + Class.new(Types::BaseInputObject) do + graphql_name "TestMetadataInputObject" + argument :key, String, required: true + argument :value, String, required: :nullable + end + end + + let(:test_mutation_class) do + input_class = test_input_class + options = described_class::ARGUMENT_OPTIONS + + Class.new(GraphQL::Schema::Mutation) do + graphql_name "TestMutation" + argument :metadata, [input_class], required: false, **options + + field :result, String + + def resolve(metadata:) + {result: metadata.to_json} + end + end + end + + let(:test_schema) do + mutation_class = test_mutation_class + + Class.new(GraphQL::Schema) do + mutation(Class.new(GraphQL::Schema::Object) do + graphql_name "TestMutationType" + field :test_mutation, mutation: mutation_class + end) + end + end + + it "validates before prepare (rejects duplicates before conversion to hash)" do + query = <<~GQL + mutation { + testMutation(metadata: [{key: "foo", value: "bar"}, {key: "foo", value: "baz"}]) { + result + } + } + GQL + + result = test_schema.execute(query) + + expect(result["errors"]).to be_present + expect(result["errors"].first["message"]).to include("duplicated_field") + end + + it "prepares valid input to hash" do + query = <<~GQL + mutation { + testMutation(metadata: [{key: "foo", value: "bar"}, {key: "baz", value: "qux"}]) { + result + } + } + GQL + + result = test_schema.execute(query) + + expect(result["errors"]).to be_nil + expect(JSON.parse(result["data"]["testMutation"]["result"])).to eq({"foo" => "bar", "baz" => "qux"}) + end + end +end diff --git a/spec/graphql/types/metadata/object_spec.rb b/spec/graphql/types/metadata/object_spec.rb new file mode 100644 index 0000000..79d952c --- /dev/null +++ b/spec/graphql/types/metadata/object_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Metadata::Object do + subject { described_class } + + it "has the expected fields" do + expect(subject).to have_field(:key).of_type("String!") + expect(subject).to have_field(:value).of_type("String") + end +end diff --git a/spec/graphql/types/organizations/authentication_methods_enum_spec.rb b/spec/graphql/types/organizations/authentication_methods_enum_spec.rb new file mode 100644 index 0000000..bedb188 --- /dev/null +++ b/spec/graphql/types/organizations/authentication_methods_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Organizations::AuthenticationMethodsEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array(%w[email_password google_oauth okta]) + end +end diff --git a/spec/graphql/types/organizations/current_organization_type_spec.rb b/spec/graphql/types/organizations/current_organization_type_spec.rb new file mode 100644 index 0000000..abb3d42 --- /dev/null +++ b/spec/graphql/types/organizations/current_organization_type_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" +RSpec.describe Types::Organizations::CurrentOrganizationType do + subject { described_class } + + it do + expect(subject).to be < ::Types::Organizations::BaseOrganizationType + + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:default_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:email).of_type("String") + expect(subject).to have_field(:legal_name).of_type("String") + expect(subject).to have_field(:legal_number).of_type("String") + expect(subject).to have_field(:logo_url).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:slug).of_type("String!") + expect(subject).to have_field(:tax_identification_number).of_type("String") + + expect(subject).to have_field(:address_line1).of_type("String") + expect(subject).to have_field(:address_line2).of_type("String") + expect(subject).to have_field(:city).of_type("String") + expect(subject).to have_field(:country).of_type("CountryCode") + expect(subject).to have_field(:net_payment_term).of_type("Int!") + expect(subject).to have_field(:state).of_type("String") + expect(subject).to have_field(:zipcode).of_type("String") + + expect(subject).to have_field(:api_key).of_type("String").with_permission("developers:keys:manage") + expect(subject).to have_field(:hmac_key).of_type("String").with_permission("developers:keys:manage") + expect(subject).to have_field(:webhook_url).of_type("String").with_permission("developers:manage") + + expect(subject).to have_field(:timezone).of_type("TimezoneEnum") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + + expect(subject).to have_field(:finalize_zero_amount_invoice).of_type("Boolean!") + expect(subject).to have_field(:billing_configuration).of_type("OrganizationBillingConfiguration").with_permission("organization:invoices:view") + expect(subject).to have_field(:email_settings).of_type("[EmailSettingsEnum!]").with_permission("organization:emails:view") + expect(subject).to have_field(:taxes).of_type("[Tax!]").with_permission("organization:taxes:view") + + expect(subject).to have_field(:adyen_payment_providers).of_type("[AdyenProvider!]").with_permission("organization:integrations:view") + expect(subject).to have_field(:cashfree_payment_providers).of_type("[CashfreeProvider!]").with_permission("organization:integrations:view") + expect(subject).to have_field(:gocardless_payment_providers).of_type("[GocardlessProvider!]").with_permission("organization:integrations:view") + expect(subject).to have_field(:stripe_payment_providers).of_type("[StripeProvider!]").with_permission("organization:integrations:view") + + expect(subject).to have_field(:applied_dunning_campaign).of_type("DunningCampaign") + + expect(subject).to have_field(:authentication_methods).of_type("[AuthenticationMethodsEnum!]!") + expect(subject).to have_field(:accessible_by_current_session).of_type("Boolean!") + expect(subject).to have_field(:authenticated_method).of_type("AuthenticationMethodsEnum!") + + expect(subject).to have_field(:feature_flags).of_type("[FeatureFlagEnum!]!") + end +end diff --git a/spec/graphql/types/organizations/organization_type_spec.rb b/spec/graphql/types/organizations/organization_type_spec.rb new file mode 100644 index 0000000..3a92838 --- /dev/null +++ b/spec/graphql/types/organizations/organization_type_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Organizations::OrganizationType do + subject { described_class } + + it do + expect(subject).to be < ::Types::Organizations::BaseOrganizationType + + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:logo_url).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:slug).of_type("String!") + expect(subject).to have_field(:timezone).of_type("TimezoneEnum") + expect(subject).to have_field(:default_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:can_create_billing_entity).of_type("Boolean!") + expect(subject).to have_field(:accessible_by_current_session).of_type("Boolean!") + end +end diff --git a/spec/graphql/types/organizations/update_organization_input_spec.rb b/spec/graphql/types/organizations/update_organization_input_spec.rb new file mode 100644 index 0000000..c3f45e9 --- /dev/null +++ b/spec/graphql/types/organizations/update_organization_input_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Organizations::UpdateOrganizationInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:authentication_methods).of_type("[AuthenticationMethodsEnum!]") + expect(subject).to accept_argument(:default_currency).of_type("CurrencyEnum") + expect(subject).to accept_argument(:email).of_type("String") + expect(subject).to accept_argument(:slug).of_type("String") + expect(subject).to accept_argument(:legal_name).of_type("String") + expect(subject).to accept_argument(:legal_number).of_type("String") + expect(subject).to accept_argument(:logo).of_type("String") + expect(subject).to accept_argument(:tax_identification_number).of_type("String") + + expect(subject).to accept_argument(:address_line1).of_type("String") + expect(subject).to accept_argument(:address_line2).of_type("String") + expect(subject).to accept_argument(:city).of_type("String") + expect(subject).to accept_argument(:country).of_type("CountryCode") + expect(subject).to accept_argument(:net_payment_term).of_type("Int") + expect(subject).to accept_argument(:state).of_type("String") + expect(subject).to accept_argument(:zipcode).of_type("String") + + expect(subject).to accept_argument(:document_numbering).of_type("DocumentNumberingEnum") + expect(subject).to accept_argument(:document_number_prefix).of_type("String") + + expect(subject).to accept_argument(:webhook_url).of_type("String").with_permission("developers:manage") + + expect(subject).to accept_argument(:timezone).of_type("TimezoneEnum") + + expect(subject).to accept_argument(:billing_configuration).of_type("OrganizationBillingConfigurationInput").with_permission("organization:invoices:view") + expect(subject).to accept_argument(:email_settings).of_type("[EmailSettingsEnum!]").with_permission("organization:emails:view") + expect(subject).to accept_argument(:finalize_zero_amount_invoice).of_type("Boolean") + end +end diff --git a/spec/graphql/types/payables/object_spec.rb b/spec/graphql/types/payables/object_spec.rb new file mode 100644 index 0000000..e18f5c6 --- /dev/null +++ b/spec/graphql/types/payables/object_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Payables::Object do + subject { described_class } + + it "has the correct graphql name" do + expect(subject.graphql_name).to eq("Payable") + end + + it "includes the correct possible types" do + expect(subject.possible_types).to include(Types::Invoices::Object, Types::PaymentRequests::Object) + end + + describe ".resolve_type" do + let(:invoice) { create(:invoice) } + let(:payment_request) { create(:payment_request) } + + it "returns Types::Invoices::Object for Invoice objects" do + expect(subject.resolve_type(invoice, {})).to eq(Types::Invoices::Object) + end + + it "returns Types::PaymentRequests::Object for PaymentRequest objects" do + expect(subject.resolve_type(payment_request, {})).to eq(Types::PaymentRequests::Object) + end + + it "raises an error for unexpected types" do + expect { subject.resolve_type("Unexpected", {}) }.to raise_error(StandardError) + end + end +end diff --git a/spec/graphql/types/payment_methods/details_spec.rb b/spec/graphql/types/payment_methods/details_spec.rb new file mode 100644 index 0000000..74f12b3 --- /dev/null +++ b/spec/graphql/types/payment_methods/details_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentMethods::Details do + subject { described_class } + + it { is_expected.to have_field(:brand).of_type("String") } + it { is_expected.to have_field(:expiration_month).of_type("String") } + it { is_expected.to have_field(:expiration_year).of_type("String") } + it { is_expected.to have_field(:last4).of_type("String") } + it { is_expected.to have_field(:type).of_type("String") } +end diff --git a/spec/graphql/types/payment_methods/object_spec.rb b/spec/graphql/types/payment_methods/object_spec.rb new file mode 100644 index 0000000..ce18d9e --- /dev/null +++ b/spec/graphql/types/payment_methods/object_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentMethods::Object do + subject { described_class } + + it { is_expected.to have_field(:id).of_type("ID!") } + + it { is_expected.to have_field(:customer).of_type("Customer!") } + it { is_expected.to have_field(:details).of_type("PaymentMethodDetails") } + it { is_expected.to have_field(:is_default).of_type("Boolean!") } + it { is_expected.to have_field(:payment_provider_code).of_type("String") } + it { is_expected.to have_field(:payment_provider_customer_id).of_type("ID") } + it { is_expected.to have_field(:payment_provider_type).of_type("ProviderTypeEnum") } + it { is_expected.to have_field(:payment_provider_name).of_type("String") } + it { is_expected.to have_field(:provider_method_id).of_type("String!") } + + it { is_expected.to have_field(:created_at).of_type("ISO8601DateTime!") } + it { is_expected.to have_field(:deleted_at).of_type("ISO8601DateTime") } + it { is_expected.to have_field(:updated_at).of_type("ISO8601DateTime") } +end diff --git a/spec/graphql/types/payment_methods/reference_input_spec.rb b/spec/graphql/types/payment_methods/reference_input_spec.rb new file mode 100644 index 0000000..5f8a57d --- /dev/null +++ b/spec/graphql/types/payment_methods/reference_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentMethods::ReferenceInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:payment_method_id).of_type("ID") + expect(subject).to accept_argument(:payment_method_type).of_type("PaymentMethodTypeEnum") + end +end diff --git a/spec/graphql/types/payment_provider_customers/provider_payment_methods_enum_spec.rb b/spec/graphql/types/payment_provider_customers/provider_payment_methods_enum_spec.rb new file mode 100644 index 0000000..57e9a53 --- /dev/null +++ b/spec/graphql/types/payment_provider_customers/provider_payment_methods_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviderCustomers::ProviderPaymentMethodsEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array(%w[card sepa_debit us_bank_account bacs_debit link boleto crypto customer_balance]) + end +end diff --git a/spec/graphql/types/payment_providers/adyen_input_spec.rb b/spec/graphql/types/payment_providers/adyen_input_spec.rb new file mode 100644 index 0000000..23a5693 --- /dev/null +++ b/spec/graphql/types/payment_providers/adyen_input_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::AdyenInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:api_key).of_type("String!") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:hmac_key).of_type("String") + expect(subject).to accept_argument(:live_prefix).of_type("String") + expect(subject).to accept_argument(:merchant_account).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:success_redirect_url).of_type("String") + end +end diff --git a/spec/graphql/types/payment_providers/adyen_spec.rb b/spec/graphql/types/payment_providers/adyen_spec.rb new file mode 100644 index 0000000..4e98a6e --- /dev/null +++ b/spec/graphql/types/payment_providers/adyen_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::Adyen do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + + expect(subject).to have_field(:api_key).of_type("ObfuscatedString").with_permission("organization:integrations:view") + expect(subject).to have_field(:hmac_key).of_type("ObfuscatedString").with_permission("organization:integrations:view") + expect(subject).to have_field(:live_prefix).of_type("String").with_permission("organization:integrations:view") + expect(subject).to have_field(:merchant_account).of_type("String").with_permission("organization:integrations:view") + expect(subject).to have_field(:success_redirect_url).of_type("String").with_permission("organization:integrations:view") + end +end diff --git a/spec/graphql/types/payment_providers/cashfree_input_spec.rb b/spec/graphql/types/payment_providers/cashfree_input_spec.rb new file mode 100644 index 0000000..3b46542 --- /dev/null +++ b/spec/graphql/types/payment_providers/cashfree_input_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::CashfreeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:client_id).of_type("String!") + expect(subject).to accept_argument(:client_secret).of_type("String!") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:success_redirect_url).of_type("String") + end +end diff --git a/spec/graphql/types/payment_providers/cashfree_spec.rb b/spec/graphql/types/payment_providers/cashfree_spec.rb new file mode 100644 index 0000000..897c27b --- /dev/null +++ b/spec/graphql/types/payment_providers/cashfree_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::Cashfree do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + + expect(subject).to have_field(:client_id).of_type("String").with_permission("organization:integrations:view") + expect(subject).to have_field(:client_secret).of_type("String").with_permission("organization:integrations:view") + expect(subject).to have_field(:success_redirect_url).of_type("String").with_permission("organization:integrations:view") + end +end diff --git a/spec/graphql/types/payment_providers/flutterwave_input_spec.rb b/spec/graphql/types/payment_providers/flutterwave_input_spec.rb new file mode 100644 index 0000000..f48a4ef --- /dev/null +++ b/spec/graphql/types/payment_providers/flutterwave_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::FlutterwaveInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:secret_key).of_type("String!") + expect(subject).to accept_argument(:success_redirect_url).of_type("String") + end +end diff --git a/spec/graphql/types/payment_providers/flutterwave_spec.rb b/spec/graphql/types/payment_providers/flutterwave_spec.rb new file mode 100644 index 0000000..e53ece7 --- /dev/null +++ b/spec/graphql/types/payment_providers/flutterwave_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::Flutterwave do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:secret_key).of_type("ObfuscatedString").with_permission("organization:integrations:view") + expect(subject).to have_field(:success_redirect_url).of_type("String").with_permission("organization:integrations:view") + expect(subject).to have_field(:webhook_secret).of_type("String").with_permission("organization:integrations:view") + end +end diff --git a/spec/graphql/types/payment_providers/gocardless_input_spec.rb b/spec/graphql/types/payment_providers/gocardless_input_spec.rb new file mode 100644 index 0000000..149f717 --- /dev/null +++ b/spec/graphql/types/payment_providers/gocardless_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::GocardlessInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:access_code).of_type("String") + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:success_redirect_url).of_type("String") + end +end diff --git a/spec/graphql/types/payment_providers/gocardless_spec.rb b/spec/graphql/types/payment_providers/gocardless_spec.rb new file mode 100644 index 0000000..be8ea6d --- /dev/null +++ b/spec/graphql/types/payment_providers/gocardless_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::Gocardless do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + + expect(subject).to have_field(:has_access_token).of_type("Boolean").with_permission("organization:integrations:view") + expect(subject).to have_field(:success_redirect_url).of_type("String").with_permission("organization:integrations:view") + expect(subject).to have_field(:webhook_secret).of_type("String").with_permission("organization:integrations:view") + end +end diff --git a/spec/graphql/types/payment_providers/stripe_input_spec.rb b/spec/graphql/types/payment_providers/stripe_input_spec.rb new file mode 100644 index 0000000..362c30f --- /dev/null +++ b/spec/graphql/types/payment_providers/stripe_input_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::StripeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:secret_key).of_type("String") + expect(subject).to accept_argument(:success_redirect_url).of_type("String") + expect(subject).to accept_argument(:supports_3ds).of_type("Boolean") + end +end diff --git a/spec/graphql/types/payment_providers/stripe_spec.rb b/spec/graphql/types/payment_providers/stripe_spec.rb new file mode 100644 index 0000000..361c737 --- /dev/null +++ b/spec/graphql/types/payment_providers/stripe_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::Stripe do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + + expect(subject).to have_field(:secret_key).of_type("ObfuscatedString").with_permission("organization:integrations:view") + expect(subject).to have_field(:success_redirect_url).of_type("String").with_permission("organization:integrations:view") + expect(subject).to have_field(:supports_3ds).of_type("Boolean") + end +end diff --git a/spec/graphql/types/payment_providers/update_input_spec.rb b/spec/graphql/types/payment_providers/update_input_spec.rb new file mode 100644 index 0000000..3b3c901 --- /dev/null +++ b/spec/graphql/types/payment_providers/update_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentProviders::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:success_redirect_url).of_type("String") + end +end diff --git a/spec/graphql/types/payment_receipts/object_spec.rb b/spec/graphql/types/payment_receipts/object_spec.rb new file mode 100644 index 0000000..a8a238d --- /dev/null +++ b/spec/graphql/types/payment_receipts/object_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentReceipts::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:file_url).of_type("String") + expect(subject).to have_field(:number).of_type("String!") + expect(subject).to have_field(:payment).of_type("Payment!") + expect(subject).to have_field(:organization).of_type("Organization!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/payment_requests/object_spec.rb b/spec/graphql/types/payment_requests/object_spec.rb new file mode 100644 index 0000000..b7b4024 --- /dev/null +++ b/spec/graphql/types/payment_requests/object_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PaymentRequests::Object do + subject { described_class } + + it do + expect(subject).to have_field(:customer).of_type("Customer!") + expect(subject).to have_field(:invoices).of_type("[Invoice!]!") + + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:amount_currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:email).of_type("String!") + expect(subject).to have_field(:payment_status).of_type("InvoicePaymentStatusTypeEnum!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/payments/create_input_spec.rb b/spec/graphql/types/payments/create_input_spec.rb new file mode 100644 index 0000000..fdac4bb --- /dev/null +++ b/spec/graphql/types/payments/create_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Payments::CreateInput do + subject { described_class } + + it { is_expected.to accept_argument(:invoice_id).of_type("ID!") } + it { is_expected.to accept_argument(:created_at).of_type("ISO8601DateTime!") } + it { is_expected.to accept_argument(:reference).of_type("String!") } + it { is_expected.to accept_argument(:amount_cents).of_type("BigInt!") } +end diff --git a/spec/graphql/types/payments/object_spec.rb b/spec/graphql/types/payments/object_spec.rb new file mode 100644 index 0000000..c0216c5 --- /dev/null +++ b/spec/graphql/types/payments/object_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Payments::Object do + subject { described_class } + + it { is_expected.to have_field(:id).of_type("ID!") } + + it { is_expected.to have_field(:amount_cents).of_type("BigInt!") } + it { is_expected.to have_field(:amount_currency).of_type("CurrencyEnum!") } + + it { is_expected.to have_field(:customer).of_type("Customer!") } + it { is_expected.to have_field(:payable).of_type("Payable!") } + it { is_expected.to have_field(:payable_payment_status).of_type("PayablePaymentStatusEnum") } + it { is_expected.to have_field(:payment_method_id).of_type("ID") } + it { is_expected.to have_field(:payment_provider).of_type("PaymentProvider") } + it { is_expected.to have_field(:payment_provider_type).of_type("ProviderTypeEnum") } + it { is_expected.to have_field(:payment_receipt).of_type("PaymentReceipt") } + it { is_expected.to have_field(:payment_type).of_type("PaymentTypeEnum!") } + it { is_expected.to have_field(:provider_payment_id).of_type("String") } + it { is_expected.to have_field(:reference).of_type("String") } + + it { is_expected.to have_field(:created_at).of_type("ISO8601DateTime!") } + it { is_expected.to have_field(:updated_at).of_type("ISO8601DateTime") } +end diff --git a/spec/graphql/types/payments/payable_payment_status_enum_spec.rb b/spec/graphql/types/payments/payable_payment_status_enum_spec.rb new file mode 100644 index 0000000..79a03fb --- /dev/null +++ b/spec/graphql/types/payments/payable_payment_status_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Payments::PayablePaymentStatusEnum do + it "enumerizes the correct values" do + expect(described_class.values.keys).to match_array(%w[pending processing succeeded failed]) + end +end diff --git a/spec/graphql/types/payments/payment_type_enum_spec.rb b/spec/graphql/types/payments/payment_type_enum_spec.rb new file mode 100644 index 0000000..7e669db --- /dev/null +++ b/spec/graphql/types/payments/payment_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Payments::PaymentTypeEnum do + it "enumerizes the correct values" do + expect(described_class.values.keys).to match_array(%w[provider manual]) + end +end diff --git a/spec/graphql/types/permissions_type_spec.rb b/spec/graphql/types/permissions_type_spec.rb new file mode 100644 index 0000000..e6747a5 --- /dev/null +++ b/spec/graphql/types/permissions_type_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PermissionsType do + it "matches the list of default permissions" do + all_boolean = described_class.fields.values.all? do |f| + f.type.to_type_signature == "Boolean!" + end + expect(all_boolean).to be_truthy + + gql_field_names = described_class.fields.keys.map(&:underscore) + rails_field_names = Permission.permissions_hash.keys.map { |k| k.tr(":", "_") } + expect(gql_field_names).to match_array(rails_field_names) + end +end diff --git a/spec/graphql/types/plans/create_input_spec.rb b/spec/graphql/types/plans/create_input_spec.rb new file mode 100644 index 0000000..5e0c78f --- /dev/null +++ b/spec/graphql/types/plans/create_input_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Plans::CreateInput do + subject { described_class } + + it { is_expected.to accept_argument(:amount_cents).of_type("BigInt!") } + it { is_expected.to accept_argument(:amount_currency).of_type("CurrencyEnum!") } + it { is_expected.to accept_argument(:bill_charges_monthly).of_type("Boolean") } + it { is_expected.to accept_argument(:bill_fixed_charges_monthly).of_type("Boolean") } + it { is_expected.to accept_argument(:code).of_type("String!") } + it { is_expected.to accept_argument(:description).of_type("String") } + it { is_expected.to accept_argument(:interval).of_type("PlanInterval!") } + it { is_expected.to accept_argument(:invoice_display_name).of_type("String") } + it { is_expected.to accept_argument(:name).of_type("String!") } + it { is_expected.to accept_argument(:pay_in_advance).of_type("Boolean!") } + it { is_expected.to accept_argument(:tax_codes).of_type("[String!]") } + it { is_expected.to accept_argument(:trial_period).of_type("Float") } + + it { is_expected.to accept_argument(:charges).of_type("[ChargeInput!]!") } + it { is_expected.to accept_argument(:fixed_charges).of_type("[FixedChargeInput!]") } + it { is_expected.to accept_argument(:minimum_commitment).of_type("CommitmentInput") } + it { is_expected.to accept_argument(:usage_thresholds).of_type("[UsageThresholdInput!]") } +end diff --git a/spec/graphql/types/plans/object_spec.rb b/spec/graphql/types/plans/object_spec.rb new file mode 100644 index 0000000..b02891c --- /dev/null +++ b/spec/graphql/types/plans/object_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Plans::Object do + subject { described_class } + + it { is_expected.to have_field(:id).of_type("ID!") } + it { is_expected.to have_field(:organization).of_type("Organization") } + it { is_expected.to have_field(:amount_cents).of_type("BigInt!") } + it { is_expected.to have_field(:amount_currency).of_type("CurrencyEnum!") } + it { is_expected.to have_field(:bill_charges_monthly).of_type("Boolean") } + it { is_expected.to have_field(:bill_fixed_charges_monthly).of_type("Boolean") } + it { is_expected.to have_field(:code).of_type("String!") } + it { is_expected.to have_field(:description).of_type("String") } + it { is_expected.to have_field(:has_overridden_plans).of_type("Boolean") } + it { is_expected.to have_field(:interval).of_type("PlanInterval!") } + it { is_expected.to have_field(:invoice_display_name).of_type("String") } + it { is_expected.to have_field(:minimum_commitment).of_type("Commitment") } + it { is_expected.to have_field(:name).of_type("String!") } + it { is_expected.to have_field(:parent).of_type("Plan") } + it { is_expected.to have_field(:pay_in_advance).of_type("Boolean!") } + it { is_expected.to have_field(:trial_period).of_type("Float") } + it { is_expected.to have_field(:activity_logs).of_type("[ActivityLog!]") } + it { is_expected.to have_field(:charges).of_type("[Charge!]") } + it { is_expected.to have_field(:fixed_charges).of_type("[FixedCharge!]") } + it { is_expected.to have_field(:taxes).of_type("[Tax!]") } + it { is_expected.to have_field(:created_at).of_type("ISO8601DateTime!") } + it { is_expected.to have_field(:updated_at).of_type("ISO8601DateTime!") } + it { is_expected.to have_field(:deleted_at).of_type("ISO8601DateTime") } + + it { is_expected.to have_field(:usage_thresholds).of_type("[UsageThreshold!]") } + + it { is_expected.to have_field(:has_active_subscriptions).of_type("Boolean!") } + it { is_expected.to have_field(:has_charges).of_type("Boolean!") } + it { is_expected.to have_field(:has_customers).of_type("Boolean!") } + it { is_expected.to have_field(:has_draft_invoices).of_type("Boolean!") } + it { is_expected.to have_field(:has_fixed_charges).of_type("Boolean!") } + it { is_expected.to have_field(:has_subscriptions).of_type("Boolean!") } + + it { is_expected.to have_field(:active_subscriptions_count).of_type("Int!") } + it { is_expected.to have_field(:charges_count).of_type("Int!") } + it { is_expected.to have_field(:fixed_charges_count).of_type("Int!") } + it { is_expected.to have_field(:customers_count).of_type("Int!") } + it { is_expected.to have_field(:draft_invoices_count).of_type("Int!") } + it { is_expected.to have_field(:subscriptions_count).of_type("Int!") } +end diff --git a/spec/graphql/types/plans/update_input_spec.rb b/spec/graphql/types/plans/update_input_spec.rb new file mode 100644 index 0000000..325b502 --- /dev/null +++ b/spec/graphql/types/plans/update_input_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Plans::UpdateInput do + subject { described_class } + + it { is_expected.to accept_argument(:id).of_type("ID!") } + it { is_expected.to accept_argument(:amount_cents).of_type("BigInt!") } + it { is_expected.to accept_argument(:amount_currency).of_type("CurrencyEnum!") } + it { is_expected.to accept_argument(:bill_charges_monthly).of_type("Boolean") } + it { is_expected.to accept_argument(:bill_fixed_charges_monthly).of_type("Boolean") } + it { is_expected.to accept_argument(:cascade_updates).of_type("Boolean") } + it { is_expected.to accept_argument(:code).of_type("String!") } + it { is_expected.to accept_argument(:description).of_type("String") } + it { is_expected.to accept_argument(:interval).of_type("PlanInterval!") } + it { is_expected.to accept_argument(:invoice_display_name).of_type("String") } + it { is_expected.to accept_argument(:name).of_type("String!") } + it { is_expected.to accept_argument(:pay_in_advance).of_type("Boolean!") } + it { is_expected.to accept_argument(:tax_codes).of_type("[String!]") } + it { is_expected.to accept_argument(:trial_period).of_type("Float") } + + it { is_expected.to accept_argument(:charges).of_type("[ChargeInput!]!") } + it { is_expected.to accept_argument(:fixed_charges).of_type("[FixedChargeInput!]") } + it { is_expected.to accept_argument(:minimum_commitment).of_type("CommitmentInput") } + it { is_expected.to accept_argument(:usage_thresholds).of_type("[UsageThresholdInput!]") } + + it { is_expected.to accept_argument(:entitlements).of_type("[EntitlementInput!]") } +end diff --git a/spec/graphql/types/pricing_unit_usages/object_spec.rb b/spec/graphql/types/pricing_unit_usages/object_spec.rb new file mode 100644 index 0000000..70f992d --- /dev/null +++ b/spec/graphql/types/pricing_unit_usages/object_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PricingUnitUsages::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:conversion_rate).of_type("Float!") + expect(subject).to have_field(:precise_amount_cents).of_type("Float!") + expect(subject).to have_field(:precise_unit_amount).of_type("Float!") + expect(subject).to have_field(:short_name).of_type("String!") + expect(subject).to have_field(:unit_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:pricing_unit).of_type("PricingUnit!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/pricing_units/create_input_spec.rb b/spec/graphql/types/pricing_units/create_input_spec.rb new file mode 100644 index 0000000..6aa7ad4 --- /dev/null +++ b/spec/graphql/types/pricing_units/create_input_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PricingUnits::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:code).of_type("String!") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:name).of_type("String!") + expect(subject).to accept_argument(:short_name).of_type("String!") + end +end diff --git a/spec/graphql/types/pricing_units/object_spec.rb b/spec/graphql/types/pricing_units/object_spec.rb new file mode 100644 index 0000000..3fef648 --- /dev/null +++ b/spec/graphql/types/pricing_units/object_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PricingUnits::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:short_name).of_type("String!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/pricing_units/update_input_spec.rb b/spec/graphql/types/pricing_units/update_input_spec.rb new file mode 100644 index 0000000..d4bfc65 --- /dev/null +++ b/spec/graphql/types/pricing_units/update_input_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::PricingUnits::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:short_name).of_type("String") + end +end diff --git a/spec/graphql/types/quote_versions/object_spec.rb b/spec/graphql/types/quote_versions/object_spec.rb new file mode 100644 index 0000000..1b90577 --- /dev/null +++ b/spec/graphql/types/quote_versions/object_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::QuoteVersions::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization!") + expect(subject).to have_field(:quote).of_type("Quote!") + expect(subject).to have_field(:approved_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:billing_items).of_type("JSON") + expect(subject).to have_field(:content).of_type("String") + expect(subject).to have_field(:share_token).of_type("String") + expect(subject).to have_field(:status).of_type("StatusEnum!") + expect(subject).to have_field(:version).of_type("Int!") + expect(subject).to have_field(:void_reason).of_type("VoidReasonEnum") + expect(subject).to have_field(:voided_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/quotes/object_spec.rb b/spec/graphql/types/quotes/object_spec.rb new file mode 100644 index 0000000..13ef66c --- /dev/null +++ b/spec/graphql/types/quotes/object_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Quotes::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization!") + expect(subject).to have_field(:customer).of_type("Customer!") + expect(subject).to have_field(:subscription).of_type("Subscription") + expect(subject).to have_field(:current_version).of_type("QuoteVersion!") + expect(subject).to have_field(:versions).of_type("[QuoteVersion!]!") + expect(subject).to have_field(:number).of_type("String!") + expect(subject).to have_field(:order_type).of_type("OrderTypeEnum!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/role_type_spec.rb b/spec/graphql/types/role_type_spec.rb new file mode 100644 index 0000000..6b63728 --- /dev/null +++ b/spec/graphql/types/role_type_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::RoleType do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:code).of_type("String!") + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:description).of_type("String") + expect(subject).to have_field(:admin).of_type("Boolean!") + expect(subject).to have_field(:permissions).of_type("[PermissionEnum!]!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:memberships).of_type("[Membership!]!") + end +end diff --git a/spec/graphql/types/subscriptions/activation_rule_input_spec.rb b/spec/graphql/types/subscriptions/activation_rule_input_spec.rb new file mode 100644 index 0000000..8b6d961 --- /dev/null +++ b/spec/graphql/types/subscriptions/activation_rule_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::ActivationRuleInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:timeout_hours).of_type("Int") + expect(subject).to accept_argument(:type).of_type("ActivationRuleTypeEnum!") + end +end diff --git a/spec/graphql/types/subscriptions/activation_rule_status_enum_spec.rb b/spec/graphql/types/subscriptions/activation_rule_status_enum_spec.rb new file mode 100644 index 0000000..0582420 --- /dev/null +++ b/spec/graphql/types/subscriptions/activation_rule_status_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::ActivationRuleStatusEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[inactive pending satisfied declined failed expired not_applicable]) + end +end diff --git a/spec/graphql/types/subscriptions/activation_rule_type_enum_spec.rb b/spec/graphql/types/subscriptions/activation_rule_type_enum_spec.rb new file mode 100644 index 0000000..a30c691 --- /dev/null +++ b/spec/graphql/types/subscriptions/activation_rule_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::ActivationRuleTypeEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[payment]) + end +end diff --git a/spec/graphql/types/subscriptions/activation_rule_type_spec.rb b/spec/graphql/types/subscriptions/activation_rule_type_spec.rb new file mode 100644 index 0000000..dd4b60a --- /dev/null +++ b/spec/graphql/types/subscriptions/activation_rule_type_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::ActivationRuleType do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:expires_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:status).of_type("ActivationRuleStatusEnum!") + expect(subject).to have_field(:timeout_hours).of_type("Int") + expect(subject).to have_field(:type).of_type("ActivationRuleTypeEnum!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/subscriptions/billing_time_enum_spec.rb b/spec/graphql/types/subscriptions/billing_time_enum_spec.rb new file mode 100644 index 0000000..9e4f2e0 --- /dev/null +++ b/spec/graphql/types/subscriptions/billing_time_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::BillingTimeEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[calendar anniversary]) + end +end diff --git a/spec/graphql/types/subscriptions/cancelation_reason_enum_spec.rb b/spec/graphql/types/subscriptions/cancelation_reason_enum_spec.rb new file mode 100644 index 0000000..1c03936 --- /dev/null +++ b/spec/graphql/types/subscriptions/cancelation_reason_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::CancelationReasonEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[payment_failed timeout]) + end +end diff --git a/spec/graphql/types/subscriptions/charge_overrides_input_spec.rb b/spec/graphql/types/subscriptions/charge_overrides_input_spec.rb new file mode 100644 index 0000000..1db8c08 --- /dev/null +++ b/spec/graphql/types/subscriptions/charge_overrides_input_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::ChargeOverridesInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:applied_pricing_unit).of_type("AppliedPricingUnitOverrideInput") + expect(subject).to accept_argument(:billable_metric_id).of_type("ID!") + expect(subject).to accept_argument(:id).of_type("ID") + expect(subject).to accept_argument(:filters).of_type("[ChargeFilterInput!]") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:min_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/subscriptions/create_subscription_input_spec.rb b/spec/graphql/types/subscriptions/create_subscription_input_spec.rb new file mode 100644 index 0000000..42883c4 --- /dev/null +++ b/spec/graphql/types/subscriptions/create_subscription_input_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::CreateSubscriptionInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:ending_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:external_id).of_type("String") + expect(subject).to accept_argument(:customer_id).of_type("ID!") + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + expect(subject).to accept_argument(:plan_id).of_type("ID!") + expect(subject).to accept_argument(:plan_overrides).of_type("PlanOverridesInput") + expect(subject).to accept_argument(:subscription_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:subscription_id).of_type("ID") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:billing_time).of_type("BillingTimeEnum!") + expect(subject).to accept_argument(:activation_rules).of_type("[SubscriptionActivationRuleInput!]") + end +end diff --git a/spec/graphql/types/subscriptions/fixed_charge_overrides_input_spec.rb b/spec/graphql/types/subscriptions/fixed_charge_overrides_input_spec.rb new file mode 100644 index 0000000..6b50911 --- /dev/null +++ b/spec/graphql/types/subscriptions/fixed_charge_overrides_input_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::FixedChargeOverridesInput do + subject { described_class } + + it { is_expected.to accept_argument(:id).of_type("ID") } + it { is_expected.to accept_argument(:add_on_id).of_type("ID") } + it { is_expected.to accept_argument(:apply_units_immediately).of_type("Boolean") } + it { is_expected.to accept_argument(:invoice_display_name).of_type("String") } + it { is_expected.to accept_argument(:properties).of_type("FixedChargePropertiesInput") } + it { is_expected.to accept_argument(:tax_codes).of_type("[String!]") } + it { is_expected.to accept_argument(:units).of_type("String") } +end diff --git a/spec/graphql/types/subscriptions/lifetime_usage_object_spec.rb b/spec/graphql/types/subscriptions/lifetime_usage_object_spec.rb new file mode 100644 index 0000000..65cce37 --- /dev/null +++ b/spec/graphql/types/subscriptions/lifetime_usage_object_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::LifetimeUsageObject do + subject { described_class } + + it do + expect(subject).to have_field(:total_usage_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:total_usage_from_datetime).of_type("ISO8601DateTime!") + expect(subject).to have_field(:total_usage_to_datetime).of_type("ISO8601DateTime!") + + expect(subject).to have_field(:last_threshold_amount_cents).of_type("BigInt") + expect(subject).to have_field(:next_threshold_amount_cents).of_type("BigInt") + expect(subject).to have_field(:next_threshold_ratio).of_type("Float") + end +end diff --git a/spec/graphql/types/subscriptions/next_subscription_type_enum_spec.rb b/spec/graphql/types/subscriptions/next_subscription_type_enum_spec.rb new file mode 100644 index 0000000..98768f3 --- /dev/null +++ b/spec/graphql/types/subscriptions/next_subscription_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::NextSubscriptionTypeEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[upgrade downgrade]) + end +end diff --git a/spec/graphql/types/subscriptions/object_spec.rb b/spec/graphql/types/subscriptions/object_spec.rb new file mode 100644 index 0000000..369ee69 --- /dev/null +++ b/spec/graphql/types/subscriptions/object_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::Object do + subject { described_class } + + it do + expect(subject).to have_field(:customer).of_type("Customer!") + expect(subject).to have_field(:external_id).of_type("String!") + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:plan).of_type("Plan!") + + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:next_name).of_type("String") + expect(subject).to have_field(:period_end_date).of_type("ISO8601Date") + expect(subject).to have_field(:status).of_type("StatusTypeEnum") + + expect(subject).to have_field(:billing_time).of_type("BillingTimeEnum") + expect(subject).to have_field(:canceled_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:ending_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:started_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:subscription_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:terminated_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:on_termination_credit_note).of_type("OnTerminationCreditNoteEnum") + expect(subject).to have_field(:on_termination_invoice).of_type("OnTerminationInvoiceEnum!") + + expect(subject).to have_field(:selected_invoice_custom_sections).of_type("[InvoiceCustomSection!]") + expect(subject).to have_field(:skip_invoice_custom_sections).of_type("Boolean") + + expect(subject).to have_field(:current_billing_period_started_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:current_billing_period_ending_at).of_type("ISO8601DateTime") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + + expect(subject).to have_field(:next_plan).of_type("Plan") + expect(subject).to have_field(:next_subscription).of_type("Subscription") + expect(subject).to have_field(:next_subscription_type).of_type("NextSubscriptionTypeEnum") + expect(subject).to have_field(:next_subscription_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:downgrade_plan_date).of_type("ISO8601Date") + expect(subject).to have_field(:previous_plan).of_type("Plan") + expect(subject).to have_field(:previous_subscription).of_type("Subscription") + + expect(subject).to have_field(:activity_logs).of_type("[ActivityLog!]") + expect(subject).to have_field(:charges).of_type("[Charge!]") + expect(subject).to have_field(:fees).of_type("[Fee!]") + expect(subject).to have_field(:fixed_charges).of_type("[FixedCharge!]") + + expect(subject).to have_field(:lifetime_usage).of_type("SubscriptionLifetimeUsage") + + expect(subject).to have_field(:usage_thresholds).of_type("[UsageThreshold!]!") + + expect(subject).to have_field(:payment_method).of_type("PaymentMethod") + expect(subject).to have_field(:payment_method_type).of_type("PaymentMethodTypeEnum") + + expect(subject).to have_field(:activated_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:activation_rules).of_type("[SubscriptionActivationRule!]!") + expect(subject).to have_field(:cancelation_reason).of_type("CancelationReasonEnum") + end +end diff --git a/spec/graphql/types/subscriptions/on_termination_credit_note_enum_spec.rb b/spec/graphql/types/subscriptions/on_termination_credit_note_enum_spec.rb new file mode 100644 index 0000000..ece6809 --- /dev/null +++ b/spec/graphql/types/subscriptions/on_termination_credit_note_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::OnTerminationCreditNoteEnum do + it "enumerates the correct values" do + expect(described_class.values.keys).to match_array(%w[skip credit refund offset]) + end +end diff --git a/spec/graphql/types/subscriptions/on_termination_invoice_enum_spec.rb b/spec/graphql/types/subscriptions/on_termination_invoice_enum_spec.rb new file mode 100644 index 0000000..039dd3c --- /dev/null +++ b/spec/graphql/types/subscriptions/on_termination_invoice_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::OnTerminationInvoiceEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[generate skip]) + end +end diff --git a/spec/graphql/types/subscriptions/plan_overrides_input_spec.rb b/spec/graphql/types/subscriptions/plan_overrides_input_spec.rb new file mode 100644 index 0000000..4264bd3 --- /dev/null +++ b/spec/graphql/types/subscriptions/plan_overrides_input_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::PlanOverridesInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:amount_currency).of_type("CurrencyEnum") + expect(subject).to accept_argument(:charges).of_type("[ChargeOverridesInput!]") + expect(subject).to accept_argument(:fixed_charges).of_type("[FixedChargeOverridesInput!]") + expect(subject).to accept_argument(:description).of_type("String") + expect(subject).to accept_argument(:minimum_commitment).of_type("CommitmentInput") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + expect(subject).to accept_argument(:trial_period).of_type("Float") + end +end diff --git a/spec/graphql/types/subscriptions/status_type_enum_spec.rb b/spec/graphql/types/subscriptions/status_type_enum_spec.rb new file mode 100644 index 0000000..5789606 --- /dev/null +++ b/spec/graphql/types/subscriptions/status_type_enum_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::StatusTypeEnum do + it "exposes all enum values" do + expect(described_class.values.keys).to match_array(%w[pending active terminated canceled incomplete]) + end +end diff --git a/spec/graphql/types/subscriptions/terminate_subscription_input_spec.rb b/spec/graphql/types/subscriptions/terminate_subscription_input_spec.rb new file mode 100644 index 0000000..8f1391f --- /dev/null +++ b/spec/graphql/types/subscriptions/terminate_subscription_input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::TerminateSubscriptionInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:on_termination_credit_note).of_type("OnTerminationCreditNoteEnum") + expect(subject).to accept_argument(:on_termination_invoice).of_type("OnTerminationInvoiceEnum") + end +end diff --git a/spec/graphql/types/subscriptions/update_charge_input_spec.rb b/spec/graphql/types/subscriptions/update_charge_input_spec.rb new file mode 100644 index 0000000..477146e --- /dev/null +++ b/spec/graphql/types/subscriptions/update_charge_input_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::UpdateChargeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:charge_code).of_type("String!") + expect(subject).to accept_argument(:subscription_id).of_type("ID!") + expect(subject).to accept_argument(:applied_pricing_unit).of_type("AppliedPricingUnitOverrideInput") + expect(subject).to accept_argument(:filters).of_type("[ChargeFilterInput!]") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:min_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:properties).of_type("PropertiesInput") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + end +end diff --git a/spec/graphql/types/subscriptions/update_fixed_charge_input_spec.rb b/spec/graphql/types/subscriptions/update_fixed_charge_input_spec.rb new file mode 100644 index 0000000..ae00e81 --- /dev/null +++ b/spec/graphql/types/subscriptions/update_fixed_charge_input_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::UpdateFixedChargeInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:fixed_charge_code).of_type("String!") + expect(subject).to accept_argument(:subscription_id).of_type("ID!") + expect(subject).to accept_argument(:apply_units_immediately).of_type("Boolean") + expect(subject).to accept_argument(:invoice_display_name).of_type("String") + expect(subject).to accept_argument(:properties).of_type("FixedChargePropertiesInput") + expect(subject).to accept_argument(:tax_codes).of_type("[String!]") + expect(subject).to accept_argument(:units).of_type("String") + end +end diff --git a/spec/graphql/types/subscriptions/update_subscription_input_spec.rb b/spec/graphql/types/subscriptions/update_subscription_input_spec.rb new file mode 100644 index 0000000..42f652e --- /dev/null +++ b/spec/graphql/types/subscriptions/update_subscription_input_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Subscriptions::UpdateSubscriptionInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:ending_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + expect(subject).to accept_argument(:plan_overrides).of_type("PlanOverridesInput") + expect(subject).to accept_argument(:subscription_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:activation_rules).of_type("[SubscriptionActivationRuleInput!]") + end +end diff --git a/spec/graphql/types/superset/dashboard/object_spec.rb b/spec/graphql/types/superset/dashboard/object_spec.rb new file mode 100644 index 0000000..5abddaa --- /dev/null +++ b/spec/graphql/types/superset/dashboard/object_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Superset::Dashboard::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("String!") + expect(subject).to have_field(:dashboard_title).of_type("String!") + expect(subject).to have_field(:embedded_id).of_type("String!") + expect(subject).to have_field(:guest_token).of_type("String!") + expect(subject).to have_field(:superset_url).of_type("String!") + end +end diff --git a/spec/graphql/types/usage_thresholds/input_spec.rb b/spec/graphql/types/usage_thresholds/input_spec.rb new file mode 100644 index 0000000..19f531b --- /dev/null +++ b/spec/graphql/types/usage_thresholds/input_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::UsageThresholds::Input do + subject { described_class } + + it do + expect(subject).to accept_argument(:amount_cents).of_type("BigInt!") + expect(subject).to accept_argument(:recurring).of_type("Boolean") + expect(subject).to accept_argument(:threshold_display_name).of_type("String") + end +end diff --git a/spec/graphql/types/usage_thresholds/object_spec.rb b/spec/graphql/types/usage_thresholds/object_spec.rb new file mode 100644 index 0000000..7498414 --- /dev/null +++ b/spec/graphql/types/usage_thresholds/object_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::UsageThresholds::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:threshold_display_name).of_type("String") + expect(subject).to have_field(:recurring).of_type("Boolean!") + end +end diff --git a/spec/graphql/types/wallet_transaction_consumptions/object_spec.rb b/spec/graphql/types/wallet_transaction_consumptions/object_spec.rb new file mode 100644 index 0000000..ff8bedf --- /dev/null +++ b/spec/graphql/types/wallet_transaction_consumptions/object_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WalletTransactionConsumptions::Object do + subject { described_class } + + it "has the expected fields with correct types" do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:credit_amount).of_type("String!") + expect(subject).to have_field(:wallet_transaction).of_type("WalletTransaction!") + end + + describe "#amount_cents" do + subject { run_graphql_field("WalletTransactionConsumption.amountCents", consumption) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 5000) + end + + it "returns the consumed_amount_cents" do + expect(subject).to eq(5000) + end + end + + describe "#credit_amount" do + subject { run_graphql_field("WalletTransactionConsumption.creditAmount", consumption) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true, rate_amount: 1.5) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 3000) + end + + it "returns the credit amount by dividing consumed amount by wallet rate" do + expect(subject).to eq("20.0") + end + end + + describe "#wallet_transaction" do + subject { run_graphql_field("WalletTransactionConsumption.walletTransaction", consumption) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 5000) + end + + it "returns the outbound_wallet_transaction" do + expect(subject.id).to eq(outbound_transaction.id) + end + end +end diff --git a/spec/graphql/types/wallet_transaction_fundings/object_spec.rb b/spec/graphql/types/wallet_transaction_fundings/object_spec.rb new file mode 100644 index 0000000..fb6e65b --- /dev/null +++ b/spec/graphql/types/wallet_transaction_fundings/object_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WalletTransactionFundings::Object do + subject { described_class } + + it "has the expected fields with correct types" do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:amount_cents).of_type("BigInt!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:credit_amount).of_type("String!") + expect(subject).to have_field(:wallet_transaction).of_type("WalletTransaction!") + end + + describe "#amount_cents" do + subject { run_graphql_field("WalletTransactionFunding.amountCents", consumption) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 5000) + end + + it "returns the consumed_amount_cents" do + expect(subject).to eq(5000) + end + end + + describe "#credit_amount" do + subject { run_graphql_field("WalletTransactionFunding.creditAmount", consumption) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true, rate_amount: 1.5) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 3000) + end + + it "returns the credit amount by dividing consumed amount by wallet rate" do + expect(subject).to eq("20.0") + end + end + + describe "#wallet_transaction" do + subject { run_graphql_field("WalletTransactionFunding.walletTransaction", consumption) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + remaining_amount_cents: 10000) + end + let(:outbound_transaction) do + create(:wallet_transaction, wallet:, organization:, transaction_type: :outbound) + end + let(:consumption) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 5000) + end + + it "returns the inbound_wallet_transaction" do + expect(subject.id).to eq(inbound_transaction.id) + end + end +end diff --git a/spec/graphql/types/wallet_transactions/create_input_spec.rb b/spec/graphql/types/wallet_transactions/create_input_spec.rb new file mode 100644 index 0000000..2f0ae86 --- /dev/null +++ b/spec/graphql/types/wallet_transactions/create_input_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WalletTransactions::CreateInput do + subject { described_class } + + it "has the expected arguments" do + expect(subject).to accept_argument(:wallet_id).of_type("ID!") + + expect(subject).to accept_argument(:granted_credits).of_type("String") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:ignore_paid_top_up_limits).of_type("Boolean") + expect(subject).to accept_argument(:invoice_requires_successful_payment).of_type("Boolean") + expect(subject).to accept_argument(:invoice_custom_section).of_type("InvoiceCustomSectionsReferenceInput") + expect(subject).to accept_argument(:metadata).of_type("[WalletTransactionMetadataInput!]") + expect(subject).to accept_argument(:paid_credits).of_type("String") + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + expect(subject).to accept_argument(:priority).of_type("Int") + expect(subject).to accept_argument(:voided_credits).of_type("String") + end +end diff --git a/spec/graphql/types/wallet_transactions/metadata_input_spec.rb b/spec/graphql/types/wallet_transactions/metadata_input_spec.rb new file mode 100644 index 0000000..3cd3d46 --- /dev/null +++ b/spec/graphql/types/wallet_transactions/metadata_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WalletTransactions::MetadataInput do + subject { described_class } + + it "has the expected arguments" do + expect(subject).to accept_argument(:key).of_type("String!") + expect(subject).to accept_argument(:value).of_type("String!") + end +end diff --git a/spec/graphql/types/wallet_transactions/metadata_object_spec.rb b/spec/graphql/types/wallet_transactions/metadata_object_spec.rb new file mode 100644 index 0000000..75eecc3 --- /dev/null +++ b/spec/graphql/types/wallet_transactions/metadata_object_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WalletTransactions::MetadataObject do + subject { described_class } + + it "has the expected fields" do + expect(subject).to have_field(:key).of_type("String!") + expect(subject).to have_field(:value).of_type("String!") + end +end diff --git a/spec/graphql/types/wallet_transactions/object_spec.rb b/spec/graphql/types/wallet_transactions/object_spec.rb new file mode 100644 index 0000000..776b6e8 --- /dev/null +++ b/spec/graphql/types/wallet_transactions/object_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WalletTransactions::Object do + subject { described_class } + + it "has the expected fields with correct types" do + expect(subject).to have_field(:wallet).of_type("Wallet") + + expect(subject).to have_field(:amount).of_type("String!") + expect(subject).to have_field(:credit_amount).of_type("String!") + expect(subject).to have_field(:invoice_requires_successful_payment).of_type("Boolean!") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:priority).of_type("Int!") + expect(subject).to have_field(:source).of_type("WalletTransactionSourceEnum!") + expect(subject).to have_field(:status).of_type("WalletTransactionStatusEnum!") + expect(subject).to have_field(:transaction_status).of_type("WalletTransactionTransactionStatusEnum!") + expect(subject).to have_field(:transaction_type).of_type("WalletTransactionTransactionTypeEnum!") + expect(subject).to have_field(:wallet_name).of_type("String") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:failed_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:remaining_amount_cents).of_type("BigInt") + expect(subject).to have_field(:remaining_credit_amount).of_type("String") + expect(subject).to have_field(:invoice).of_type("Invoice") + expect(subject).to have_field(:voided_invoice).of_type("Invoice") + expect(subject).to have_field(:metadata).of_type("[WalletTransactionMetadataObject!]") + expect(subject).to have_field(:settled_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + + expect(subject).to have_field(:selected_invoice_custom_sections).of_type("[InvoiceCustomSection!]") + expect(subject).to have_field(:skip_invoice_custom_sections).of_type("Boolean") + end + + describe "#remaining_credit_amount" do + subject { run_graphql_field("WalletTransaction.remainingCreditAmount", wallet_transaction) } + + context "when remaining_amount_cents is nil" do + let(:wallet_transaction) { create(:wallet_transaction, remaining_amount_cents: nil) } + + it "returns nil" do + expect(subject).to be_nil + end + end + + context "when remaining_amount_cents is set" do + let(:customer) { create(:customer) } + let(:wallet) { create(:wallet, customer:, rate_amount: 1.5) } + let(:wallet_transaction) do + create(:wallet_transaction, + wallet:, + transaction_type: :inbound, + remaining_amount_cents: 3000) + end + + it "returns the remaining credit amount as string" do + expect(subject).to eq("20.0") + end + end + end + + describe "#voided_invoice" do + subject { run_graphql_field("WalletTransaction.voidedInvoice", wallet_transaction) } + + let(:wallet_transaction) { create(:wallet_transaction, voided_invoice:) } + let(:voided_invoice) { nil } + + context "when voided_invoice is nil" do + it "returns nil" do + expect(subject).to be_nil + end + end + + context "when voided_invoice is present" do + let(:voided_invoice) { create(:invoice, :voided) } + + it "returns the invoice" do + expect(subject).to eq(voided_invoice) + end + end + end + + describe "#wallet_name" do + subject { run_graphql_field("WalletTransaction.walletName", wallet_transaction) } + + let(:wallet_transaction) { create(:wallet_transaction) } + + context "when wallet has a name" do + it "returns the wallet name" do + expect(subject).to be_present + expect(subject).to eq(wallet_transaction.wallet.name) + end + end + + context "when wallet has no name" do + let(:wallet_transaction) { create(:wallet_transaction, wallet: create(:wallet, name: nil)) } + + it "returns nil" do + expect(subject).to be_nil + end + end + end +end diff --git a/spec/graphql/types/wallets/create_input_spec.rb b/spec/graphql/types/wallets/create_input_spec.rb new file mode 100644 index 0000000..f0ee0ea --- /dev/null +++ b/spec/graphql/types/wallets/create_input_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:billing_entity_id).of_type("ID") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:currency).of_type("CurrencyEnum!") + expect(subject).to accept_argument(:customer_id).of_type("ID!") + expect(subject).to accept_argument(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:granted_credits).of_type("String!") + expect(subject).to accept_argument(:invoice_requires_successful_payment).of_type("Boolean") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:paid_credits).of_type("String!") + expect(subject).to accept_argument(:priority).of_type("Int!") + expect(subject).to accept_argument(:rate_amount).of_type("String!") + + expect(subject).to accept_argument(:ignore_paid_top_up_limits_on_creation).of_type("Boolean") + expect(subject).to accept_argument(:paid_top_up_max_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:paid_top_up_min_amount_cents).of_type("BigInt") + + expect(subject).to accept_argument(:recurring_transaction_rules).of_type("[CreateRecurringTransactionRuleInput!]") + + expect(subject).to accept_argument(:invoice_custom_section).of_type("InvoiceCustomSectionsReferenceInput") + + expect(subject).to accept_argument(:applies_to).of_type("AppliesToInput") + + expect(subject).to accept_argument(:metadata).of_type("[MetadataInput!]") + + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + end +end diff --git a/spec/graphql/types/wallets/object_spec.rb b/spec/graphql/types/wallets/object_spec.rb new file mode 100644 index 0000000..a5a8288 --- /dev/null +++ b/spec/graphql/types/wallets/object_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::Object do + subject { described_class } + + it do + expect(subject).to have_field(:customer).of_type("Customer") + + expect(subject).to have_field(:code).of_type("String") + expect(subject).to have_field(:currency).of_type("CurrencyEnum!") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:priority).of_type("Int!") + expect(subject).to have_field(:status).of_type("WalletStatusEnum!") + + expect(subject).to have_field(:rate_amount).of_type("Float!") + + expect(subject).to have_field(:balance_cents).of_type("BigInt!") + expect(subject).to have_field(:consumed_amount_cents).of_type("BigInt!") + expect(subject).to have_field(:ongoing_balance_cents).of_type("BigInt!") + expect(subject).to have_field(:ongoing_usage_balance_cents).of_type("BigInt!") + + expect(subject).to have_field(:consumed_credits).of_type("Float!") + expect(subject).to have_field(:credits_balance).of_type("Float!") + expect(subject).to have_field(:credits_ongoing_balance).of_type("Float!") + expect(subject).to have_field(:credits_ongoing_usage_balance).of_type("Float!") + + expect(subject).to have_field(:last_balance_sync_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:last_consumed_credit_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:last_ongoing_balance_sync_at).of_type("ISO8601DateTime") + + expect(subject).to have_field(:activity_logs).of_type("[ActivityLog!]") + expect(subject).to have_field(:recurring_transaction_rules).of_type("[RecurringTransactionRule!]") + + expect(subject).to have_field(:invoice_requires_successful_payment).of_type("Boolean!") + + expect(subject).to have_field(:paid_top_up_max_amount_cents).of_type("BigInt") + expect(subject).to have_field(:paid_top_up_min_amount_cents).of_type("BigInt") + expect(subject).to have_field(:paid_top_up_max_credits).of_type("BigInt") + expect(subject).to have_field(:paid_top_up_min_credits).of_type("BigInt") + + expect(subject).to have_field(:selected_invoice_custom_sections).of_type("[InvoiceCustomSection!]") + expect(subject).to have_field(:skip_invoice_custom_sections).of_type("Boolean") + + expect(subject).to have_field(:payment_method).of_type("PaymentMethod") + expect(subject).to have_field(:payment_method_type).of_type("PaymentMethodTypeEnum") + + expect(subject).to have_field(:applies_to).of_type("WalletAppliesTo") + + expect(subject).to have_field(:metadata).of_type("[ItemMetadata!]") + + expect(subject).to have_field(:traceable).of_type("Boolean!") + + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:terminated_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/wallets/recurring_transaction_rules/create_input_spec.rb b/spec/graphql/types/wallets/recurring_transaction_rules/create_input_spec.rb new file mode 100644 index 0000000..1ae6940 --- /dev/null +++ b/spec/graphql/types/wallets/recurring_transaction_rules/create_input_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::RecurringTransactionRules::CreateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:granted_credits).of_type("String") + expect(subject).to accept_argument(:interval).of_type("RecurringTransactionIntervalEnum") + expect(subject).to accept_argument(:invoice_requires_successful_payment).of_type("Boolean") + expect(subject).to accept_argument(:ignore_paid_top_up_limits).of_type("Boolean") + expect(subject).to accept_argument(:method).of_type("RecurringTransactionMethodEnum") + expect(subject).to accept_argument(:paid_credits).of_type("String") + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + expect(subject).to accept_argument(:started_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:target_ongoing_balance).of_type("String") + expect(subject).to accept_argument(:threshold_credits).of_type("String") + expect(subject).to accept_argument(:trigger).of_type("RecurringTransactionTriggerEnum!") + expect(subject).to accept_argument(:transaction_metadata).of_type("[CreateTransactionMetadataInput!]") + expect(subject).to accept_argument(:invoice_custom_section).of_type("InvoiceCustomSectionsReferenceInput") + end +end diff --git a/spec/graphql/types/wallets/recurring_transaction_rules/object_spec.rb b/spec/graphql/types/wallets/recurring_transaction_rules/object_spec.rb new file mode 100644 index 0000000..1aff8ff --- /dev/null +++ b/spec/graphql/types/wallets/recurring_transaction_rules/object_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::RecurringTransactionRules::Object do + subject { described_class } + + it do + expect(subject).to have_field(:lago_id).of_type("ID!") + expect(subject).to have_field(:method).of_type("RecurringTransactionMethodEnum!") + expect(subject).to have_field(:trigger).of_type("RecurringTransactionTriggerEnum!") + expect(subject).to have_field(:interval).of_type("RecurringTransactionIntervalEnum") + expect(subject).to have_field(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:started_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:target_ongoing_balance).of_type("String") + expect(subject).to have_field(:threshold_credits).of_type("String") + expect(subject).to have_field(:paid_credits).of_type("String!") + expect(subject).to have_field(:granted_credits).of_type("String!") + expect(subject).to have_field(:ignore_paid_top_up_limits).of_type("Boolean!") + expect(subject).to have_field(:invoice_requires_successful_payment).of_type("Boolean!") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:transaction_metadata).of_type("[TransactionMetadata!]") + expect(subject).to have_field(:selected_invoice_custom_sections).of_type("[InvoiceCustomSection!]") + expect(subject).to have_field(:skip_invoice_custom_sections).of_type("Boolean") + expect(subject).to have_field(:payment_method).of_type("PaymentMethod") + expect(subject).to have_field(:payment_method_type).of_type("PaymentMethodTypeEnum") + end +end diff --git a/spec/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_input_spec.rb b/spec/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_input_spec.rb new file mode 100644 index 0000000..de6b37e --- /dev/null +++ b/spec/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_input_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::RecurringTransactionRules::TransactionMetadataInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:key).of_type("String!") + expect(subject).to accept_argument(:value).of_type("String!") + end +end diff --git a/spec/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_object_spec.rb b/spec/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_object_spec.rb new file mode 100644 index 0000000..20f1522 --- /dev/null +++ b/spec/graphql/types/wallets/recurring_transaction_rules/transaction_metadata_object_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::RecurringTransactionRules::TransactionMetadataObject do + subject { described_class } + + it do + expect(subject).to have_field(:key).of_type("String!") + expect(subject).to have_field(:value).of_type("String!") + end +end diff --git a/spec/graphql/types/wallets/recurring_transaction_rules/update_input_spec.rb b/spec/graphql/types/wallets/recurring_transaction_rules/update_input_spec.rb new file mode 100644 index 0000000..63b9ffc --- /dev/null +++ b/spec/graphql/types/wallets/recurring_transaction_rules/update_input_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::RecurringTransactionRules::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:granted_credits).of_type("String") + expect(subject).to accept_argument(:interval).of_type("RecurringTransactionIntervalEnum") + expect(subject).to accept_argument(:ignore_paid_top_up_limits).of_type("Boolean") + expect(subject).to accept_argument(:invoice_requires_successful_payment).of_type("Boolean") + expect(subject).to accept_argument(:method).of_type("RecurringTransactionMethodEnum") + expect(subject).to accept_argument(:paid_credits).of_type("String") + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + expect(subject).to accept_argument(:started_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:target_ongoing_balance).of_type("String") + expect(subject).to accept_argument(:threshold_credits).of_type("String") + expect(subject).to accept_argument(:trigger).of_type("RecurringTransactionTriggerEnum") + expect(subject).to accept_argument(:lago_id).of_type("ID") + expect(subject).to accept_argument(:transaction_metadata).of_type("[CreateTransactionMetadataInput!]") + expect(subject).to accept_argument(:invoice_custom_section).of_type("InvoiceCustomSectionsReferenceInput") + end +end diff --git a/spec/graphql/types/wallets/update_input_spec.rb b/spec/graphql/types/wallets/update_input_spec.rb new file mode 100644 index 0000000..7615d6e --- /dev/null +++ b/spec/graphql/types/wallets/update_input_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Wallets::UpdateInput do + subject { described_class } + + it do + expect(subject).to accept_argument(:expiration_at).of_type("ISO8601DateTime") + expect(subject).to accept_argument(:id).of_type("ID!") + expect(subject).to accept_argument(:code).of_type("String") + expect(subject).to accept_argument(:invoice_requires_successful_payment).of_type("Boolean") + expect(subject).to accept_argument(:name).of_type("String") + expect(subject).to accept_argument(:priority).of_type("Int!") + + expect(subject).to accept_argument(:paid_top_up_max_amount_cents).of_type("BigInt") + expect(subject).to accept_argument(:paid_top_up_min_amount_cents).of_type("BigInt") + + expect(subject).to accept_argument(:recurring_transaction_rules).of_type("[UpdateRecurringTransactionRuleInput!]") + expect(subject).to accept_argument(:invoice_custom_section).of_type("InvoiceCustomSectionsReferenceInput") + + expect(subject).to accept_argument(:applies_to).of_type("AppliesToInput") + + expect(subject).to accept_argument(:metadata).of_type("[MetadataInput!]") + + expect(subject).to accept_argument(:payment_method).of_type("PaymentMethodReferenceInput") + end +end diff --git a/spec/graphql/types/webhook_endpoints/event_type_spec.rb b/spec/graphql/types/webhook_endpoints/event_type_spec.rb new file mode 100644 index 0000000..2d679d1 --- /dev/null +++ b/spec/graphql/types/webhook_endpoints/event_type_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WebhookEndpoints::EventType do + subject { described_class } + + it do + expect(subject).to have_field(:name).of_type("String!") + expect(subject).to have_field(:description).of_type("String!") + expect(subject).to have_field(:category).of_type("EventCategoryEnum!") + expect(subject).to have_field(:deprecated).of_type("Boolean!") + expect(subject).to have_field(:key).of_type("EventTypeEnum!") + end +end diff --git a/spec/graphql/types/webhook_endpoints/object_spec.rb b/spec/graphql/types/webhook_endpoints/object_spec.rb new file mode 100644 index 0000000..78cb130 --- /dev/null +++ b/spec/graphql/types/webhook_endpoints/object_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::WebhookEndpoints::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:organization).of_type("Organization") + expect(subject).to have_field(:webhook_url).of_type("String!") + expect(subject).to have_field(:signature_algo).of_type("WebhookEndpointSignatureAlgoEnum") + expect(subject).to have_field(:name).of_type("String") + expect(subject).to have_field(:event_types).of_type("[EventTypeEnum!]") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/types/webhooks/object_spec.rb b/spec/graphql/types/webhooks/object_spec.rb new file mode 100644 index 0000000..f174578 --- /dev/null +++ b/spec/graphql/types/webhooks/object_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Types::Webhooks::Object do + subject { described_class } + + it do + expect(subject).to have_field(:id).of_type("ID!") + expect(subject).to have_field(:webhook_endpoint).of_type("WebhookEndpoint") + expect(subject).to have_field(:endpoint).of_type("String!") + expect(subject).to have_field(:object_type).of_type("String!") + expect(subject).to have_field(:retries).of_type("Int!") + expect(subject).to have_field(:status).of_type("WebhookStatusEnum!") + expect(subject).to have_field(:webhook_type).of_type("String!") + expect(subject).to have_field(:http_status).of_type("Int") + expect(subject).to have_field(:payload).of_type("String") + expect(subject).to have_field(:response).of_type("String") + expect(subject).to have_field(:created_at).of_type("ISO8601DateTime!") + expect(subject).to have_field(:last_retried_at).of_type("ISO8601DateTime") + expect(subject).to have_field(:updated_at).of_type("ISO8601DateTime!") + end +end diff --git a/spec/graphql/validators/unique_by_field_validator_spec.rb b/spec/graphql/validators/unique_by_field_validator_spec.rb new file mode 100644 index 0000000..c0c9c0e --- /dev/null +++ b/spec/graphql/validators/unique_by_field_validator_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Validators::UniqueByFieldValidator do + subject(:validator) { described_class.new(field_name: :code, validated: validated_object) } + + let(:validated_object) { double("Validated Object") } # rubocop:disable RSpec/VerifiedDoubles + let(:graphql_object) { double("GraphQL Object") } # rubocop:disable RSpec/VerifiedDoubles + let(:context) { double("Context") } # rubocop:disable RSpec/VerifiedDoubles + + describe "#validate" do + context "when there are no duplicates" do + let(:value) { [{code: "USD"}, {code: "EUR"}, {code: "GBP"}] } + + it do + expect(validator.validate(graphql_object, context, value)).to be_nil + end + end + + context "when there are duplicates" do + let(:value) { [{code: "USD"}, {code: "EUR"}, {code: "USD"}] } + + it "returns duplicated_field error" do + expect(validator.validate(graphql_object, context, value)).to eq("duplicated_field") + end + end + + context "when there are multiple different duplicates" do + let(:value) do + [ + {code: "USD"}, + {code: "EUR"}, + {code: "USD"}, + {code: "GBP"}, + {code: "EUR"} + ] + end + + it "returns duplicated_field error" do + expect(validator.validate(graphql_object, context, value)).to eq("duplicated_field") + end + end + + context "when the value array is empty" do + let(:value) { [] } + + it do + expect(validator.validate(graphql_object, context, value)).to be_nil + end + end + + context "when there is only one item" do + let(:value) { [{code: "USD"}] } + + it do + expect(validator.validate(graphql_object, context, value)).to be_nil + end + end + + context "with custom field_name" do + subject(:validator) { described_class.new(field_name: :currency_code, validated: validated_object) } + + context "when there are no duplicates" do + let(:value) { [{currency_code: "USD"}, {currency_code: "EUR"}] } + + it do + expect(validator.validate(graphql_object, context, value)).to be_nil + end + end + + context "when there are duplicates" do + let(:value) { [{currency_code: "USD"}, {currency_code: "USD"}] } + + it "returns duplicated_field error" do + expect(validator.validate(graphql_object, context, value)).to eq("duplicated_field") + end + end + end + + context "when field values are nil" do + let(:value) { [{code: "USD"}, {code: nil}, {code: nil}] } + + it do + expect(validator.validate(graphql_object, context, value)).to be_nil + end + end + + context "when field values are strings and symbols" do + let(:value) { [{code: "USD"}, {code: :USD}] } + + it "treats them as different values" do + expect(validator.validate(graphql_object, context, value)).to be_nil + end + end + end +end diff --git a/spec/i18n_spec.rb b/spec/i18n_spec.rb new file mode 100644 index 0000000..0cb63c0 --- /dev/null +++ b/spec/i18n_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" +require "i18n/tasks" + +RSpec.describe I18n do + let(:i18n) { I18n::Tasks::BaseTask.new } + let(:missing_keys) { i18n.missing_keys } + let(:unused_keys) { i18n.unused_keys } + let(:inconsistent_interpolations) { i18n.inconsistent_interpolations } + + # it 'does not have missing keys' do + # expect(missing_keys).to be_empty, + # "Missing #{missing_keys.leaves.count} i18n keys, run `i18n-tasks missing' to show them" + # end + + # it 'does not have unused keys' do + # expect(unused_keys).to be_empty, + # "#{unused_keys.leaves.count} unused i18n keys, run `i18n-tasks unused' to show them" + # end + + it "files are normalized" do + non_normalized = i18n.non_normalized_paths + error_message = "The following files need to be normalized:\n" \ + "#{non_normalized.map { |path| " #{path}" }.join("\n")}\n" \ + "Please run `i18n-tasks normalize' to fix" + expect(non_normalized).to be_empty, error_message + end + + it "does not have inconsistent interpolations" do + error_message = "#{inconsistent_interpolations.leaves.count} i18n keys have inconsistent interpolations.\n" \ + "Run `i18n-tasks check-consistent-interpolations' to show them" + expect(inconsistent_interpolations).to be_empty, error_message + end +end diff --git a/spec/integration/stripe/simple_payment_integration_spec.rb b/spec/integration/stripe/simple_payment_integration_spec.rb new file mode 100644 index 0000000..bb95509 --- /dev/null +++ b/spec/integration/stripe/simple_payment_integration_spec.rb @@ -0,0 +1,404 @@ +# frozen_string_literal: true + +require "rails_helper" + +# This file does not run on CI because it's suffixed `_integration_spec.rb` +# +# The goal is to use Rspec to run real HTTP request against Stripe API to ensure +# things still work when upgrading the API version. +# This also creates logs on Stripe so you can copy past them to update the fixtures. + +describe "Stripe Payment Integration Test", :premium, :with_pdf_generation_stub, type: :request do + let(:api_key) { ENV["STRIPE_API_KEY"] } + + let(:organization) { create(:organization, :premium, name: "Stripe IRL") } + let(:addon) { create(:add_on, amount_cents: 310, amount_currency: currency, organization:, name: "Setup fee") } + let(:external_id) { "#{Time.current.iso8601}--#{SecureRandom.hex(3)}" } + let(:currency) { "EUR" } + let(:provider_payment_methods) { ["card"] } + + let(:provider) { organization.stripe_payment_providers.sole } + + include_context "with webhook tracking" + + before do + raise "You need to set the SPEC_STRIPE_SECRET_KEY environment variable" unless api_key + + # Uncomment to print Stripe logs + # ::Stripe.log_level = Logger::INFO + + ::Stripe.api_version = ENV["STRIPE_API_VERSION"] + ::Stripe.api_key = api_key + WebMock.disable_net_connect!(allow: "api.stripe.com") + + # Setting up the stripe payment providers, which register the webhook endpoint + ::PaymentProviders::StripeService.new.create_or_update( + organization_id: organization.id, + code: "stripe_spec", + name: "Stripe Spec", + secret_key: api_key, + success_redirect_url: "https://app.lago.dev" + ) + + perform_all_enqueued_jobs + end + + after do + ids = [] + organization.stripe_payment_providers.each do |payment_provider| + ids << payment_provider.webhook_id + ::Stripe::WebhookEndpoint.delete(payment_provider.webhook_id, {}) + end + + ids.each do |id| + ::Stripe::WebhookEndpoint.retrieve(id) + raise StandardError, "#{id} was not deleted" + rescue Stripe::InvalidRequestError + nil + end + end + + it "can refresh the webhook endpoint" do + expect(provider.webhook_id).to start_with "we_" + expect(provider.webhook_secret).to start_with "whsec_" + PaymentProviders::Stripe::RefreshWebhookService.call(provider.reload) + end + + it "makes call to the actual API" do + customer = create_customer + + expect(webhooks_sent.map { it["webhook_type"] }).to match_array(%w[ + customer.payment_provider_created customer.created customer.checkout_url_generated + ]) + webhooks_sent.clear + + stripe_customer = customer.payment_provider_customers.sole + expect(stripe_customer.provider_customer_id).to start_with "cus_" # generated by stripe API + + # WITH FAILING CARD + failing_pm_id = attach_card(stripe_customer, :pm_card_chargeCustomerFail) + create_one_off_invoice(customer, [addon], units: 22) + expect(stripe_customer.reload.payment_method_id).to eq failing_pm_id + invoice = customer.invoices.sole + expect(invoice.payment_status).to eq("failed") + expect(invoice.payments.sole.status).to eq("failed") + expect(webhooks_sent.map { it["webhook_type"] }).to include("invoice.payment_failure") + webhooks_sent.clear + + # RetrieveLatestPaymentMethodService + latest_id = PaymentProviderCustomers::Stripe::RetrieveLatestPaymentMethodService.call!(provider_customer: stripe_customer).payment_method_id + expect(latest_id).to eq failing_pm_id + + # CREATING SUBSCRIPTION WITH PRE-AUTH + response = create_subscription({ + plan_code: "any_plan", + external_customer_id: "cust_#{external_id}" + }, { + amount_cents: 21_00, + amount_currency: "EUR" + }, raise_on_error: false) + expect(response["error_details"]["code"]).to eq "card_declined" + expect(response["error_details"]["http_body"]["error"]["code"]).to eq "card_declined" + + # WITH VALID CARD + customer.update!(country: "FR") + valid_pm_id = attach_card(stripe_customer, :pm_card_visa) + retry_invoice_payment(invoice.id) + expect(stripe_customer.reload.payment_method_id).to eq valid_pm_id + expect(invoice.reload.payment_status).to eq("succeeded") + expect(invoice.payment_attempts).to eq(2) + + perform_all_enqueued_jobs + + # CREATE A REFUND + create_credit_note({invoice_id: invoice.id, reason: :other, refund_amount_cents: 12_00, + items: [ + {fee_id: invoice.fees.first.id, amount_cents: 12_00} + ]}) + + cn = invoice.reload.credit_notes.sole + expect(cn.refund_amount_cents).to eq 12_00 + refund_id = cn.refunds.sole.provider_refund_id + expect(refund_id).to start_with "re_" + r = ::Stripe::Refund.retrieve(refund_id) + expect(r.metadata.lago_credit_note_id).to eq cn.id + expect(r.metadata.lago_invoice_id).to eq invoice.id + expect(r.metadata.lago_customer_id).to eq customer.id + + # UPDATE THE CUSTOMER + create_or_update_customer({ + external_id: "cust_#{external_id}", + name: "Integration Test UPDATED #{Time.current.iso8601}" + }) + end + + context "when invoice payment requires 3DS" do + it "create a pending payment and send webhook with `next_action`" do + provider.update! supports_3ds: true + + customer = create_customer + stripe_customer = customer.payment_provider_customers.sole + webhooks_sent.clear + + three_ds_pm_id = attach_card(stripe_customer, :pm_card_authenticationRequired) + create_one_off_invoice(customer, [addon], units: 22) + + invoice = customer.invoices.sole + expect(stripe_customer.reload.payment_method_id).to eq three_ds_pm_id + expect(invoice.reload.payment_status).to eq("pending") + expect(invoice.payments.pluck(:status, :error_code)).to contain_exactly( + ["failed", ::PaymentProviders::StripeProvider::NEED_3DS_ERROR_CODE], + ["requires_action", nil] + ) + expect(invoice.payment_attempts).to eq(2) + expect(invoice).not_to be_ready_for_payment_processing + + webhook = webhooks_sent.find { it["webhook_type"] == "payment.requires_action" } + expect(webhook.dig("payment", "next_action")).to match({ + "type" => "redirect_to_url", + "redirect_to_url" => { + "url" => start_with("https://hooks.stripe.com/3d_secure_2/hosted"), + "return_url" => "https://app.lago.dev" + } + }) + webhooks_sent.clear + + # CREATING SUBSCRIPTION WITH PRE-AUTH + # A 3DS challenge on authorization will not result in an error. The auth is canceled and the subscription is created. + # The subscription invoice will be finalized with a pending payment because the card requests 3DS again. + plan = create(:plan, organization:, code: "std_plan", pay_in_advance: true, amount_cents: 31_000_00, amount_currency: "EUR") + json = create_subscription({ + plan_code: plan.code, + external_id: "sub_#{external_id}", + external_customer_id: customer.external_id + }, { + amount_cents: 1_99, + amount_currency: "EUR" + }) + + expect(response).to have_http_status(:ok) + expect(json[:authorization][:id]).to start_with "pi_" + expect(json[:authorization][:status]).to eq "requires_action" + + invoice = customer.invoices.subscription.sole + expect(invoice.total_amount_cents).to be > 1_000_00 # Ensure this is the subscription invoice + expect(invoice.status).to eq "finalized" + expect(invoice.payment_status).to eq "pending" + expect(invoice.payments.pluck(:status, :payable_payment_status, :error_code)).to contain_exactly( + ["failed", "failed", ::PaymentProviders::StripeProvider::NEED_3DS_ERROR_CODE], + ["requires_action", "processing", nil] + ) + expect(invoice.payment_attempts).to eq(2) + expect(invoice).not_to be_ready_for_payment_processing + + payment_webhook = webhooks_sent.select { it["webhook_type"] == "payment.requires_action" }.sole + expect(payment_webhook.dig("payment", "invoice_ids")).to contain_exactly(invoice.id) + end + end + + context "when customer is in India and has `require_action` on 3DS" do + it do + customer = create_customer(country: "IN") + stripe_customer = customer.payment_provider_customers.sole + webhooks_sent.clear + + three_ds_pm_id = attach_card(stripe_customer, :pm_card_authenticationRequired) + create_one_off_invoice(customer, [addon], units: 22) + invoice = customer.invoices.sole + expect(stripe_customer.reload.payment_method_id).to eq three_ds_pm_id + expect(invoice.reload.payment_status).to eq("pending") + expect(invoice.payments.where(status: "requires_action").count).to eq 1 + expect(invoice.payment_attempts).to eq(1) + expect(invoice).not_to be_ready_for_payment_processing + webhook = webhooks_sent.find { it["webhook_type"] == "payment.requires_action" } + expect(webhook.dig("payment", "next_action")).to match({ + "type" => "redirect_to_url", + "redirect_to_url" => { + "url" => start_with("https://hooks.stripe.com/3d_secure_2/hosted"), + "return_url" => "https://app.lago.dev" + } + }) + webhooks_sent.clear + end + end + + context "with customer_balance" do + let(:provider_payment_methods) { ["customer_balance"] } + + it "create a pending payment and send webhook with `next_action" do + customer = create_customer + stripe_customer = customer.payment_provider_customers.sole + webhooks_sent.clear + + create_one_off_invoice(customer, [addon], units: 7) + + invoice = customer.invoices.sole + expect(stripe_customer.reload.payment_method_id).to be_nil + expect(invoice.reload.payment_status).to eq("pending") + expect(invoice.payment_attempts).to eq(1) + expect(invoice).not_to be_ready_for_payment_processing + + payment = invoice.payments.where(status: "requires_action").sole + expect(payment.status).to eq "requires_action" + expect(payment.payable_payment_status).to eq "processing" + + webhook = webhooks_sent.find { it["webhook_type"] == "payment.requires_action" } + expect(webhook.dig("payment", "next_action", "type")).to eq "display_bank_transfer_instructions" + expect(webhook.dig("payment", "next_action", "display_bank_transfer_instructions", "amount_remaining")).to eq 2170 + webhooks_sent.clear + end + end + + context "with SEPA Direct Debit" do + let(:provider_payment_methods) { ["sepa_debit"] } + + it do + customer = create_customer({ + name: "SEPA Direct Debit Test (#{Time.current.iso8601})" + }) + stripe_customer = customer.payment_provider_customers.sole + + create_one_off_invoice(customer, [addon], units: 22) + + # Stripe should return a 400 because of `confirm: true` and no payment method + invoice = customer.invoices.sole + expect(invoice.ready_for_payment_processing).to eq true + expect(invoice.payment_status).to eq("failed") + expect(invoice.payments.count).to eq(1) + payment = invoice.payments.sole + expect(payment.provider_payment_id).to be_nil + + attach_iban(stripe_customer, "AT321904300235473204") + retry_invoice_payment(invoice.id) + expect(invoice.reload.payment_status).to eq("pending") + expect(invoice.payment_attempts).to eq(2) + processing_payment = invoice.payments.where.not(id: payment.id).sole + expect(processing_payment.provider_payment_id).to start_with "pi_" + expect(processing_payment.status).to eq "processing" + expect(processing_payment.payable_payment_status).to eq "processing" + end + end + + context "with US Bank Account (ACH)" do + let(:provider_payment_methods) { ["us_bank_account"] } + let(:currency) { "USD" } + + it do + customer = create_customer({ + name: "ACH Test (#{Time.current.iso8601})", + country: "US", + state: "Alaska" + }) + stripe_customer = customer.payment_provider_customers.sole + + attach_ach(stripe_customer) + + res = create_one_off_invoice(customer, [addon], currency:, units: 14) + invoice = Invoice.find res["invoice"]["lago_id"] + + expect(invoice.reload.payment_status).to eq("pending") + expect(invoice.payment_attempts).to eq(1) + expect(invoice.payments.sole.provider_payment_id).to start_with "pi_" + end + end + + def create_customer(params = {}) + create_or_update_customer({ + external_id: "cust_#{external_id}", + name: "Integration Test [#{external_id}]", + currency:, + country: "FR", + billing_configuration: { + payment_provider: "stripe", + payment_provider_code: provider.code, + sync: "true", + sync_with_provider: "true", + provider_payment_methods: + } + }.merge(params)) + + Customer.find(json[:customer][:lago_id]) + end + + def remove_all_payment_methods(stripe_customer) + Stripe::PaymentMethod.list({ + customer: stripe_customer.provider_customer_id, + type: "card" + }).each { Stripe::PaymentMethod.detach(it.id, {}) } + end + + # In Lago, we only attach payment method via checkout links + def attach_card(stripe_customer, pm_id) + remove_all_payment_methods(stripe_customer) + Stripe::PaymentMethod.attach( + pm_id.to_s, + {customer: stripe_customer.provider_customer_id} + ).id + end + + def attach_iban(stripe_customer, iban) + customer_id = stripe_customer.provider_customer_id + + pm_id = Stripe::PaymentMethod.create({ + type: "sepa_debit", + sepa_debit: {iban:}, + billing_details: { + name: "My Name", + email: "my-email@inter.net" + } + }).id + + Stripe::SetupIntent.create({ + customer: customer_id, + payment_method: pm_id, + payment_method_types: ["sepa_debit"], + confirm: true, + usage: "off_session", + mandate_data: { + customer_acceptance: { + type: "online", + online: { + ip_address: "127.0.0.1", + user_agent: "Mozilla/5.0 (compatible; test)" + } + } + } + }) + + pm_id + end + + def attach_ach(stripe_customer, pm_id = "pm_usBankAccount_success") + Stripe::PaymentMethod.attach( + pm_id, + {customer: stripe_customer.provider_customer_id} + ) + + pm_id + end + + def attach_bacs(stripe_customer) + pm = Stripe::PaymentMethod.create({ + type: "bacs_debit", + bacs_debit: { + sort_code: "108800", + account_number: "00012345" + }, + billing_details: { + name: "John Doe", + email: "john@example.com", + address: { + line1: "123 Test Street", + city: "London", + postal_code: "SW1A 1AA", + country: "GB" + } + } + }) + + Stripe::PaymentMethod.attach( + pm.id, + {customer: stripe_customer.provider_customer_id} + ).id + end +end diff --git a/spec/jobs/ai_conversations/stream_job_spec.rb b/spec/jobs/ai_conversations/stream_job_spec.rb new file mode 100644 index 0000000..f62784e --- /dev/null +++ b/spec/jobs/ai_conversations/stream_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AiConversations::StreamJob do + let(:ai_conversation) { create(:ai_conversation) } + let(:message) { Faker::Lorem.word } + + before do + allow(AiConversations::StreamService).to receive(:call).with(ai_conversation:, message:) + end + + it "calls the service" do + described_class.perform_now(ai_conversation:, message:) + + expect(AiConversations::StreamService).to have_received(:call) + end +end diff --git a/spec/jobs/application_job_spec.rb b/spec/jobs/application_job_spec.rb new file mode 100644 index 0000000..e9acd0b --- /dev/null +++ b/spec/jobs/application_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApplicationJob do + let(:job_class) do + Class.new(ApplicationJob) do + def perform(arg1, arg2, option: "default") + end + end + end + + describe "#perform_after_commit" do + it "performs the job after the commit" do + ApplicationRecord.transaction do + job_class.perform_after_commit(1, 2, option: "custom") + expect(job_class).not_to have_been_enqueued + end + + expect(job_class).to have_been_enqueued.with(1, 2, option: "custom") + end + end +end diff --git a/spec/jobs/bill_paid_credit_job_spec.rb b/spec/jobs/bill_paid_credit_job_spec.rb new file mode 100644 index 0000000..1251172 --- /dev/null +++ b/spec/jobs/bill_paid_credit_job_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillPaidCreditJob do + let(:wallet_transaction) { create(:wallet_transaction) } + let(:timestamp) { Time.current.to_i } + + let(:invoice_service) { instance_double(Invoices::PaidCreditService) } + let(:result) { BaseService::Result.new } + + let(:invoice) { nil } + + before do + allow(Invoices::PaidCreditService).to receive(:call) + .with(wallet_transaction:, timestamp:, invoice:) + .and_return(result) + end + + it "calls the paid credit service call method" do + described_class.perform_now(wallet_transaction, timestamp) + + expect(Invoices::PaidCreditService).to have_received(:call) + end + + context "when result is a failure" do + let(:result) do + BaseService::Result.new.single_validation_failure!(error_code: "error") + end + + it "raises an error" do + expect do + described_class.perform_now(wallet_transaction, timestamp) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::PaidCreditService).to have_received(:call) + end + + context "with a previously created invoice" do + let(:previous_invoice) { create(:invoice, :generating) } + let(:invoice) { previous_invoice } + + it "raises an error" do + expect do + described_class.perform_now(wallet_transaction, timestamp, invoice: previous_invoice) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::PaidCreditService).to have_received(:call) + end + end + + context "when a generating invoice is attached to the result" do + let(:previous_invoice) { create(:invoice, :generating) } + + before { result.invoice = previous_invoice } + + it "retries the job with the invoice" do + described_class.perform_now(wallet_transaction, timestamp) + + expect(Invoices::PaidCreditService).to have_received(:call) + + expect(described_class).to have_been_enqueued + .with(wallet_transaction, timestamp, invoice: previous_invoice) + end + end + + context "when a not generating invoice is attached to the result" do + let(:previous_invoice) { create(:invoice, :draft) } + + before { result.invoice = previous_invoice } + + it "raises an error" do + expect do + described_class.perform_now(wallet_transaction, timestamp) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::PaidCreditService).to have_received(:call) + end + end + end +end diff --git a/spec/jobs/bill_subscription_job_spec.rb b/spec/jobs/bill_subscription_job_spec.rb new file mode 100644 index 0000000..e2706ef --- /dev/null +++ b/spec/jobs/bill_subscription_job_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillSubscriptionJob do + let(:subscriptions) { [create(:subscription)] } + let(:timestamp) { Time.zone.now.to_i } + + let(:invoice) { nil } + let(:invoicing_reason) { :subscription_starting } + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::SubscriptionService).to receive(:call) + .with(subscriptions:, timestamp:, invoicing_reason:, invoice:, skip_charges: false) + .and_return(result) + end + + describe "#perform" do + it "calls the invoices create service" do + described_class.perform_now(subscriptions, timestamp, invoicing_reason:) + + expect(Invoices::SubscriptionService).to have_received(:call) + end + + context "when result is a failure" do + let(:result) do + result = BaseService::Result.new + result.invoice = invoice + result.single_validation_failure!(error_code: "error") + end + + it "raises an error" do + expect do + described_class.perform_now(subscriptions, timestamp, invoicing_reason:) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::SubscriptionService).to have_received(:call) + end + + context "with a previously created invoice" do + let(:invoice) { create(:invoice, :generating) } + + it "raises an error" do + expect do + described_class.perform_now(subscriptions, timestamp, invoicing_reason:, invoice:) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::SubscriptionService).to have_received(:call) + end + + it "creates an ErrorDetail" do + expect do + described_class.perform_now(subscriptions, timestamp, invoicing_reason:, invoice:) + end.to raise_error(BaseService::FailedResult).and change(invoice.error_details.invoice_generation_error, :count) + .from(0).to(1) + end + end + + context "when a generating invoice is attached to the result" do + let(:result_invoice) { create(:invoice, :generating) } + + before { result.invoice = result_invoice } + + it "retries the job with the invoice" do + described_class.perform_now(subscriptions, timestamp, invoicing_reason:) + + expect(Invoices::SubscriptionService).to have_received(:call) + + expect(described_class).to have_been_enqueued + .with(subscriptions, timestamp, invoicing_reason:, invoice: result_invoice, skip_charges: false) + end + end + + context "when a not generating invoice is attached to the result" do + let(:result_invoice) { create(:invoice, :draft) } + + before { result.invoice = result_invoice } + + it "raises an error" do + expect do + described_class.perform_now(subscriptions, timestamp, invoicing_reason:) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::SubscriptionService).to have_received(:call) + end + + it "creates an invoice generation error_detail" do + expect do + described_class.perform_now(subscriptions, timestamp, invoicing_reason:) + end.to raise_error(BaseService::FailedResult) + + expect(ErrorDetail.invoice_generation_error.size).to eq(1) + expect(result_invoice.error_details.invoice_generation_error.count).to eq(1) + end + end + end + end + + describe "#lock_key_arguments" do + let(:customer) { create(:customer, timezone: "Europe/Paris") } + let(:subscription) { create(:subscription, customer:) } + let(:subscriptions) { [subscription] } + + context "when subscriptions are empty" do + let(:subscriptions) { [] } + let(:timestamp) { Time.zone.parse("2024-01-15 10:00:00 UTC").to_i } + + it "returns arguments unchanged" do + job = described_class.new(subscriptions, timestamp, invoicing_reason: :subscription_periodic) + + expect(job.lock_key_arguments).to eq( + [[], timestamp, {invoicing_reason: :subscription_periodic}] + ) + end + end + + it "normalizes the timestamp to the date in customer timezone" do + timestamp = Time.zone.parse("2024-01-15 10:00:00 UTC").to_i + + job = described_class.new(subscriptions, timestamp, invoicing_reason: :subscription_periodic) + + expected_date = Time.zone.parse("2024-01-15T11:00:00+01:00").to_date + expect(job.lock_key_arguments).to eq( + [subscriptions, expected_date, {invoicing_reason: :subscription_periodic}] + ) + end + + it "returns the same lock key for different timestamps on the same day in customer timezone" do + first_billing_batch_timestamp = Time.zone.parse("2024-01-15 23:00:00 UTC").to_i + second_billing_batch_timestamp = Time.zone.parse("2024-01-16 00:00:00 UTC").to_i + + morning_job = described_class.new(subscriptions, first_billing_batch_timestamp, invoicing_reason: :subscription_periodic) + evening_job = described_class.new(subscriptions, second_billing_batch_timestamp, invoicing_reason: :subscription_periodic) + expect(morning_job.lock_key_arguments).to eq(evening_job.lock_key_arguments) + end + + it "returns different lock keys for timestamps on different days in customer timezone" do + first_day_batch_timestamp = Time.zone.parse("2024-01-15 00:00:00 UTC").to_i + second_day_batch_timestamp = Time.zone.parse("2024-01-15 23:00:00 UTC").to_i + + late_night_job = described_class.new(subscriptions, first_day_batch_timestamp, invoicing_reason: :subscription_periodic) + after_midnight_job = described_class.new(subscriptions, second_day_batch_timestamp, invoicing_reason: :subscription_periodic) + expect(late_night_job.lock_key_arguments).not_to eq(after_midnight_job.lock_key_arguments) + end + + it "returns different lock keys for different subscriptions" do + timestamp = Time.zone.parse("2024-01-15 10:00:00 UTC").to_i + other_subscription = create(:subscription, customer:) + + job1 = described_class.new([subscription], timestamp, invoicing_reason: :subscription_periodic) + job2 = described_class.new([other_subscription], timestamp, invoicing_reason: :subscription_periodic) + + expect(job1.lock_key_arguments).not_to eq(job2.lock_key_arguments) + end + + it "returns different lock keys for different invoicing reasons" do + timestamp = Time.zone.parse("2024-01-15 10:00:00 UTC").to_i + + job1 = described_class.new(subscriptions, timestamp, invoicing_reason: :subscription_periodic) + job2 = described_class.new(subscriptions, timestamp, invoicing_reason: :subscription_starting) + + expect(job1.lock_key_arguments).not_to eq(job2.lock_key_arguments) + end + end + + describe "retry_on" do + [ + [Customers::FailedToAcquireLock.new("customer-1-prepaid_credit"), 25], + [ActiveRecord::StaleObjectError.new("Attempted to update a stale object: Wallet."), 25], + [Sequenced::SequenceError.new("Sequenced::SequenceError"), 15] + ].each do |error, attempts| + error_class = error.class + + context "when a #{error_class} error is raised" do + before do + allow(Invoices::SubscriptionService).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + described_class.perform_later(subscriptions, timestamp, invoicing_reason:) + end.to raise_error(error_class) + end + end + end + end + end +end diff --git a/spec/jobs/billable_metric_filters/destroy_all_job_spec.rb b/spec/jobs/billable_metric_filters/destroy_all_job_spec.rb new file mode 100644 index 0000000..ce14cb5 --- /dev/null +++ b/spec/jobs/billable_metric_filters/destroy_all_job_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.describe BillableMetricFilters::DestroyAllJob do + let(:billable_metric) { create(:billable_metric, :discarded) } + + it "destroys all filters and filter values" do + allow(BillableMetricFilters::DestroyAllService) + .to receive(:call!) + .with(billable_metric) + .and_return(BillableMetricFilters::DestroyAllService::Result.new) + + described_class.perform_now(billable_metric.id) + + expect(BillableMetricFilters::DestroyAllService).to have_received(:call!) + end +end diff --git a/spec/jobs/billable_metric_filters/refresh_draft_invoices_job_spec.rb b/spec/jobs/billable_metric_filters/refresh_draft_invoices_job_spec.rb new file mode 100644 index 0000000..cc7bcd0 --- /dev/null +++ b/spec/jobs/billable_metric_filters/refresh_draft_invoices_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetricFilters::RefreshDraftInvoicesJob do + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization:) } + + it "marks matching draft invoices as ready to be refreshed" do + plan = create(:plan, organization:) + create(:standard_charge, plan:, billable_metric:) + subscription = create(:subscription, plan:) + draft_invoice = create(:invoice, :draft, organization:, customer: subscription.customer) + create(:invoice_subscription, invoice: draft_invoice, subscription:) + finalized_invoice = create(:invoice, organization:, customer: subscription.customer) + + described_class.perform_now(billable_metric.id) + + expect(draft_invoice.reload.ready_to_be_refreshed).to be true + expect(finalized_invoice.reload.ready_to_be_refreshed).to be false + end + + it "is a no-op when the billable metric does not exist" do + expect { described_class.perform_now("nonexistent-id") }.not_to raise_error + end +end diff --git a/spec/jobs/billable_metrics/delete_events_job_spec.rb b/spec/jobs/billable_metrics/delete_events_job_spec.rb new file mode 100644 index 0000000..b0b371c --- /dev/null +++ b/spec/jobs/billable_metrics/delete_events_job_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::DeleteEventsJob, clickhouse: true, transaction: false do + let(:billable_metric) { create(:billable_metric, :deleted) } + let(:subscription) { create(:subscription) } + + it "deletes related events" do + create(:standard_charge, plan: subscription.plan, billable_metric:) + not_impacted_event = create(:event, subscription_id: subscription.id) + event = create(:event, code: billable_metric.code, subscription_id: subscription.id) + + freeze_time do + expect { described_class.perform_now(billable_metric) } + .to change { event.reload.deleted_at }.from(nil).to(Time.current) + + expect(not_impacted_event.reload.deleted_at).to be_nil + end + end + + it "deletes new-style external_subscription based events" do + create(:standard_charge, plan: subscription.plan, billable_metric:) + not_impacted_event = create(:event, external_subscription_id: SecureRandom.uuid, organization_id: billable_metric.organization_id) + event = create(:event, code: billable_metric.code, external_subscription_id: subscription.external_id, organization_id: billable_metric.organization_id) + + freeze_time do + expect { described_class.perform_now(billable_metric) } + .to change { event.reload.deleted_at }.from(nil).to(Time.current) + + expect(not_impacted_event.reload.deleted_at).to be_nil + end + end + + it "deletes new-style external_subscription based events stored in clickhouse" do + create(:standard_charge, plan: subscription.plan, billable_metric:) + not_impacted_event = create(:clickhouse_events_raw, external_subscription_id: SecureRandom.uuid, organization_id: billable_metric.organization_id) + event = create(:clickhouse_events_raw, code: billable_metric.code, external_subscription_id: subscription.external_id, organization_id: billable_metric.organization_id) + + freeze_time do + described_class.perform_now(billable_metric) + expect(Clickhouse::EventsRaw.find_by(transaction_id: event.transaction_id)).to eq(nil) + expect(Clickhouse::EventsRaw.find_by(transaction_id: not_impacted_event.transaction_id)).not_to eq(nil) + end + end + + it "deletes new-style external_subscription based events_enriched stored in clickhouse" do + create(:standard_charge, plan: subscription.plan, billable_metric:) + not_impacted_event = create(:clickhouse_events_enriched, external_subscription_id: SecureRandom.uuid, organization_id: billable_metric.organization_id) + event = create(:clickhouse_events_enriched, code: billable_metric.code, external_subscription_id: subscription.external_id, organization_id: billable_metric.organization_id) + + freeze_time do + described_class.perform_now(billable_metric) + expect(Clickhouse::EventsEnriched.find_by(transaction_id: event.transaction_id)).to eq(nil) + expect(Clickhouse::EventsEnriched.find_by(transaction_id: not_impacted_event.transaction_id)).not_to eq(nil) + end + end +end diff --git a/spec/jobs/billing_entities/taxes/refresh_draft_invoices_job_spec.rb b/spec/jobs/billing_entities/taxes/refresh_draft_invoices_job_spec.rb new file mode 100644 index 0000000..d6844b2 --- /dev/null +++ b/spec/jobs/billing_entities/taxes/refresh_draft_invoices_job_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::Taxes::RefreshDraftInvoicesJob do + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + + it "marks draft invoices of the billing entity as ready to be refreshed" do + draft_invoice = create(:invoice, :draft, organization:, billing_entity:) + finalized_invoice = create(:invoice, organization:, billing_entity:) + other_billing_entity = create(:billing_entity, organization:) + other_draft_invoice = create(:invoice, :draft, organization:, billing_entity: other_billing_entity) + + described_class.perform_now(billing_entity.id) + + expect(draft_invoice.reload.ready_to_be_refreshed).to be true + expect(finalized_invoice.reload.ready_to_be_refreshed).to be false + expect(other_draft_invoice.reload.ready_to_be_refreshed).to be false + end + + it "is a no-op when the billing entity does not exist" do + expect { described_class.perform_now("nonexistent-id") }.not_to raise_error + end +end diff --git a/spec/jobs/charge_filters/cascade_job_spec.rb b/spec/jobs/charge_filters/cascade_job_spec.rb new file mode 100644 index 0000000..90542c4 --- /dev/null +++ b/spec/jobs/charge_filters/cascade_job_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::CascadeJob do + let(:charge) { create(:standard_charge) } + let(:filter_values) { {"region" => ["us"]} } + let(:old_properties) { {"amount" => "10"} } + let(:new_properties) { {"amount" => "15"} } + let(:invoice_display_name) { "US region" } + + before do + allow(ChargeFilters::CascadeService).to receive(:call!) + end + + it "calls the cascade service" do + described_class.perform_now(charge.id, "update", filter_values, old_properties, new_properties, invoice_display_name) + + expect(ChargeFilters::CascadeService).to have_received(:call!).with( + charge:, + action: "update", + filter_values:, + old_properties:, + new_properties:, + invoice_display_name: + ) + end + + context "when charge does not exist" do + it "does not call the cascade service" do + described_class.perform_now(SecureRandom.uuid, "update", filter_values, old_properties, new_properties, invoice_display_name) + + expect(ChargeFilters::CascadeService).not_to have_received(:call!) + end + end +end diff --git a/spec/jobs/charges/create_children_batch_job_spec.rb b/spec/jobs/charges/create_children_batch_job_spec.rb new file mode 100644 index 0000000..eea0867 --- /dev/null +++ b/spec/jobs/charges/create_children_batch_job_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::CreateChildrenBatchJob do + let(:billable_metric) { create(:billable_metric) } + let(:plan) { create(:plan, organization: billable_metric.organization) } + let(:child_plan) { create(:plan, organization: billable_metric.organization, parent_id: plan.id) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:child_ids) { [child_plan.id] } + + let(:params) do + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + invoice_display_name: "charge1", + min_amount_cents: 100 + } + end + + before do + allow(Charges::CreateChildrenService).to receive(:call!) + .with(child_ids:, charge:, payload: params) + .and_call_original + end + + it "calls the batch service" do + described_class.perform_now(child_ids:, charge:, payload: params) + + expect(Charges::CreateChildrenService).to have_received(:call!) + end +end diff --git a/spec/jobs/charges/create_children_job_spec.rb b/spec/jobs/charges/create_children_job_spec.rb new file mode 100644 index 0000000..1ff4aa7 --- /dev/null +++ b/spec/jobs/charges/create_children_job_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::CreateChildrenJob do + let(:billable_metric) { create(:billable_metric) } + let(:plan) { create(:plan, organization: billable_metric.organization) } + let(:subscription) { create(:subscription, plan: child_plan) } + let(:subscription2) { create(:subscription, plan: child_plan2, status: :terminated) } + let(:child_plan) { create(:plan, organization: billable_metric.organization, parent_id: plan.id) } + let(:child_plan2) { create(:plan, organization: billable_metric.organization, parent_id: plan.id) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:child_ids) { [child_plan.id] } + + let(:params) do + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + invoice_display_name: "charge1", + min_amount_cents: 100 + } + end + + before do + subscription + subscription2 + child_plan2 + allow(Charges::CreateChildrenBatchJob).to receive(:perform_later) + .with(child_ids:, charge:, payload: params) + .and_call_original + end + + it "calls the batch job" do + described_class.perform_now(charge:, payload: params) + + expect(Charges::CreateChildrenBatchJob).to have_received(:perform_later).once + end +end diff --git a/spec/jobs/charges/destroy_children_job_spec.rb b/spec/jobs/charges/destroy_children_job_spec.rb new file mode 100644 index 0000000..60dde99 --- /dev/null +++ b/spec/jobs/charges/destroy_children_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::DestroyChildrenJob do + let(:charge) { create(:standard_charge, :deleted) } + + before do + allow(Charges::DestroyChildrenService).to receive(:call!).with(charge) + .and_call_original + end + + it "calls the service" do + described_class.perform_now(charge.id) + + expect(Charges::DestroyChildrenService).to have_received(:call!) + end +end diff --git a/spec/jobs/charges/update_children_batch_job_spec.rb b/spec/jobs/charges/update_children_batch_job_spec.rb new file mode 100644 index 0000000..03f0ec3 --- /dev/null +++ b/spec/jobs/charges/update_children_batch_job_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::UpdateChildrenBatchJob do + let(:charge) { create(:standard_charge) } + let(:child_charge) { create(:standard_charge, parent_id: charge.id) } + let(:child_charge2) { create(:standard_charge, parent_id: charge.id) } + let(:old_parent_attrs) { charge.attributes } + let(:old_parent_filters_attrs) { charge.filters.map(&:attributes) } + let(:old_parent_applied_pricing_unit_attrs) { charge.filters.map(&:attributes) } + let(:child_ids) { [child_charge.id, child_charge2.id] } + let(:params) do + { + properties: {} + } + end + + before do + allow(Charges::UpdateChildrenService) + .to receive(:call!) + .with(charge:, child_ids:, params:, old_parent_attrs:, old_parent_filters_attrs:, old_parent_applied_pricing_unit_attrs:) + .and_call_original + end + + it "calls the children service" do + described_class.perform_now(child_ids:, params:, old_parent_attrs:, old_parent_filters_attrs:, old_parent_applied_pricing_unit_attrs:) + + expect(Charges::UpdateChildrenService).to have_received(:call!) + end +end diff --git a/spec/jobs/charges/update_children_job_spec.rb b/spec/jobs/charges/update_children_job_spec.rb new file mode 100644 index 0000000..91ce7ca --- /dev/null +++ b/spec/jobs/charges/update_children_job_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::UpdateChildrenJob do + let(:charge) { create(:standard_charge) } + let(:child_plan1) { create(:plan, parent_id: charge.plan.id) } + let(:child_plan2) { create(:plan, parent_id: charge.plan.id) } + let(:child_charge) { create(:standard_charge, parent_id: charge.id, plan: child_plan1) } + let(:child_charge2) { create(:standard_charge, parent_id: charge.id, plan: child_plan2) } + let(:subscription) { create(:subscription, plan: child_plan1) } + let(:subscription2) { create(:subscription, plan: child_plan2, status: :terminated) } + let(:old_parent_attrs) { charge.attributes } + let(:old_parent_filters_attrs) { charge.filters.map(&:attributes) } + let(:old_parent_applied_pricing_unit_attrs) { charge.filters.map(&:attributes) } + let(:params) do + { + properties: {} + } + end + + before do + child_plan1 + child_plan2 + subscription + subscription2 + allow(Charges::UpdateChildrenBatchJob) + .to receive(:perform_later) + .with(child_ids: [child_charge.id], params:, old_parent_attrs:, old_parent_filters_attrs:, old_parent_applied_pricing_unit_attrs:) + .and_call_original + end + + it "calls the batch jobs" do + described_class.perform_now(params:, old_parent_attrs:, old_parent_filters_attrs:, old_parent_applied_pricing_unit_attrs:) + + expect(Charges::UpdateChildrenBatchJob).to have_received(:perform_later).once + end +end diff --git a/spec/jobs/clock/api_keys/track_usage_job_spec.rb b/spec/jobs/clock/api_keys/track_usage_job_spec.rb new file mode 100644 index 0000000..7795ea8 --- /dev/null +++ b/spec/jobs/clock/api_keys/track_usage_job_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clock::ApiKeys::TrackUsageJob, job: true do + describe ".perform" do + subject { described_class.perform_now } + + before { allow(ApiKeys::TrackUsageService).to receive(:call) } + + it "tracks API keys last usage" do + subject + expect(ApiKeys::TrackUsageService).to have_received(:call) + end + end +end diff --git a/spec/jobs/clock/compute_all_daily_usages_job_spec.rb b/spec/jobs/clock/compute_all_daily_usages_job_spec.rb new file mode 100644 index 0000000..9a7ef45 --- /dev/null +++ b/spec/jobs/clock/compute_all_daily_usages_job_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clock::ComputeAllDailyUsagesJob do + subject(:compute_job) { described_class } + + describe "unique job behavior" do + around do |example| + ActiveJob::Uniqueness.reset_manager! + example.run + ActiveJob::Uniqueness.test_mode! + end + + it "does not enqueue duplicate jobs" do + expect do + described_class.perform_later + described_class.perform_later + end.to change { enqueued_jobs.count }.by(1) # rubocop:disable RSpec/ExpectChange + end + end + + describe ".perform" do + before { allow(DailyUsages::ComputeAllService).to receive(:call) } + + it "calls DailyUsages::ComputeAllService" do + freeze_time do + compute_job.perform_now + expect(DailyUsages::ComputeAllService).to have_received(:call).with(timestamp: Time.current) + end + end + end +end diff --git a/spec/jobs/clock/consume_subscription_refreshed_queue_job_spec.rb b/spec/jobs/clock/consume_subscription_refreshed_queue_job_spec.rb new file mode 100644 index 0000000..034cad6 --- /dev/null +++ b/spec/jobs/clock/consume_subscription_refreshed_queue_job_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clock::ConsumeSubscriptionRefreshedQueueJob do + subject(:refresh_jobs) { described_class } + + describe "#perform" do + before do + allow(Subscriptions::ConsumeSubscriptionRefreshedQueueService).to receive(:call!) + end + + it "consumes the v2 sorted set queue" do + refresh_jobs.perform_now + + expect(Subscriptions::ConsumeSubscriptionRefreshedQueueService).to have_received(:call!) + end + end +end diff --git a/spec/jobs/clock/create_interval_wallet_transactions_job_spec.rb b/spec/jobs/clock/create_interval_wallet_transactions_job_spec.rb new file mode 100644 index 0000000..c8c86bf --- /dev/null +++ b/spec/jobs/clock/create_interval_wallet_transactions_job_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::CreateIntervalWalletTransactionsJob, job: true do + subject(:interval_wallet_transactions_job) { described_class } + + describe ".perform" do + before { allow(Wallets::CreateIntervalWalletTransactionsService).to receive(:call) } + + it "removes all old webhooks" do + interval_wallet_transactions_job.perform_now + + expect(Wallets::CreateIntervalWalletTransactionsService).to have_received(:call) + end + end +end diff --git a/spec/jobs/clock/events_validation_job_spec.rb b/spec/jobs/clock/events_validation_job_spec.rb new file mode 100644 index 0000000..3160d49 --- /dev/null +++ b/spec/jobs/clock/events_validation_job_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::EventsValidationJob, job: true, transaction: false do + subject { described_class } + + let(:organization) { create(:organization) } + let(:event) do + create( + :event, + organization:, + created_at: Time.current.beginning_of_hour - 25.minutes + ) + end + + describe ".perform" do + before { event } + + it "refresh the events materialized view" do + described_class.perform_now + + expect(Events::LastHourMv.count).to eq(1) + end + + it "enqueues job for post validation" do + described_class.perform_now + + expect(Events::PostValidationJob).to have_been_enqueued + .with(organization:) + end + + context "when organization does not have webhook endpoints" do + before { organization.webhook_endpoints.destroy_all } + + it "does not enqueue a job" do + described_class.perform_now + + expect(Events::PostValidationJob).not_to have_been_enqueued + .with(organization:) + end + end + end +end diff --git a/spec/jobs/clock/finalize_invoices_job_spec.rb b/spec/jobs/clock/finalize_invoices_job_spec.rb new file mode 100644 index 0000000..b3a3d83 --- /dev/null +++ b/spec/jobs/clock/finalize_invoices_job_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::FinalizeInvoicesJob, job: true do + subject { described_class } + + describe ".perform" do + let(:customer) { create(:customer, invoice_grace_period: 3) } + let(:draft_invoice) do + create( + :invoice, + status: :draft, + issuing_date: DateTime.parse("23 Jun 2022").to_date, + expected_finalization_date: DateTime.parse("23 Jun 2022").to_date, + customer:, + organization: customer.organization + ) + end + let(:finalized_invoice) do + create( + :invoice, + status: :finalized, + issuing_date: DateTime.parse("23 Jun 2022").to_date, + expected_finalization_date: DateTime.parse("23 Jun 2022").to_date, + customer:, + organization: customer.organization + ) + end + + before do + draft_invoice + finalized_invoice + allow(Invoices::RefreshDraftAndFinalizeService).to receive(:call) + end + + context "when during the grace period" do + it "does not call the finalize service" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + described_class.perform_now + expect(Invoices::FinalizeJob).not_to have_been_enqueued.with(draft_invoice) + expect(Invoices::FinalizeJob).not_to have_been_enqueued.with(finalized_invoice) + end + end + end + + context "when after the grace period" do + it "calls the finalize service" do + current_date = DateTime.parse("24 Jun 2022") + + travel_to(current_date) do + described_class.perform_now + expect(Invoices::FinalizeJob).to have_been_enqueued.with(draft_invoice) + end + end + end + end +end diff --git a/spec/jobs/clock/inbound_webhooks_cleanup_job_spec.rb b/spec/jobs/clock/inbound_webhooks_cleanup_job_spec.rb new file mode 100644 index 0000000..e376570 --- /dev/null +++ b/spec/jobs/clock/inbound_webhooks_cleanup_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::InboundWebhooksCleanupJob, job: true do + subject(:inbound_webhooks_cleanup_job) { described_class } + + describe ".perform" do + it "removes all old inbound webhooks" do + create(:inbound_webhook, updated_at: 90.days.ago) + + expect { inbound_webhooks_cleanup_job.perform_now } + .to change(InboundWebhook, :count).to(0) + end + + it "does not delete recent inbound webhooks" do + create(:inbound_webhook, updated_at: 89.days.ago) + + expect { inbound_webhooks_cleanup_job.perform_now } + .not_to change(InboundWebhook, :count) + end + end +end diff --git a/spec/jobs/clock/inbound_webhooks_retry_job_spec.rb b/spec/jobs/clock/inbound_webhooks_retry_job_spec.rb new file mode 100644 index 0000000..5675e72 --- /dev/null +++ b/spec/jobs/clock/inbound_webhooks_retry_job_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::InboundWebhooksRetryJob, job: true do + subject(:inbound_webhooks_retry_job) { described_class } + + describe ".perform" do + before { inbound_webhook } + + context "when inbound webhook is pending" do + let(:inbound_webhook) { create :inbound_webhook, status:, created_at: } + let(:status) { "pending" } + let(:created_at) { 119.minutes.ago } + + it "does not queue a job" do + inbound_webhooks_retry_job.perform_now + + expect(InboundWebhooks::ProcessJob).not_to have_been_enqueued + end + + context "when inbound webhook was created more than 2 hours ago" do + let(:created_at) { 121.hours.ago } + + it "queues a job to process the inbound webhook" do + inbound_webhooks_retry_job.perform_now + + expect(InboundWebhooks::ProcessJob) + .to have_been_enqueued + .with(inbound_webhook: inbound_webhook) + end + end + end + + context "when inbound webhook is processing" do + let(:inbound_webhook) { create :inbound_webhook, status:, processing_at: } + let(:status) { "processing" } + let(:processing_at) { 119.minutes.ago } + + it "does not queue a job" do + inbound_webhooks_retry_job.perform_now + + expect(InboundWebhooks::ProcessJob).not_to have_been_enqueued + end + + context "when inbound webhook started processing more than 2 hours ago" do + let(:processing_at) { 121.minutes.ago } + + it "queues a job to process the inbound webhook" do + inbound_webhooks_retry_job.perform_now + + expect(InboundWebhooks::ProcessJob) + .to have_been_enqueued + .with(inbound_webhook: inbound_webhook) + end + end + end + + context "when inbound webhook is failed" do + let(:inbound_webhook) { create :inbound_webhook, status: } + let(:status) { "failed" } + + it "does not queue a job" do + inbound_webhooks_retry_job.perform_now + + expect(InboundWebhooks::ProcessJob).not_to have_been_enqueued + end + end + + context "when inbound webhook is processed" do + let(:inbound_webhook) { create :inbound_webhook, status: } + let(:status) { "succeeded" } + + it "does not queue a job" do + inbound_webhooks_retry_job.perform_now + + expect(InboundWebhooks::ProcessJob).not_to have_been_enqueued + end + end + end +end diff --git a/spec/jobs/clock/mark_invoices_as_payment_overdue_job_spec.rb b/spec/jobs/clock/mark_invoices_as_payment_overdue_job_spec.rb new file mode 100644 index 0000000..c00744d --- /dev/null +++ b/spec/jobs/clock/mark_invoices_as_payment_overdue_job_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::MarkInvoicesAsPaymentOverdueJob, job: true do + subject { described_class } + + describe ".perform" do + let!(:overdue_invoice_1) { create(:invoice, payment_due_date: 1.day.ago) } + let!(:overdue_invoice_2) { create(:invoice, payment_due_date: 2.days.ago) } + + it "marks expected invoices as payment overdue" do + create(:invoice, :draft, payment_due_date: 1.day.ago) + create(:invoice, payment_status: :succeeded, payment_due_date: 1.day.ago) + create(:invoice, payment_due_date: 1.day.ago, payment_dispute_lost_at: 1.day.ago) + create(:invoice, payment_due_date: nil) + create(:invoice, payment_due_date: 1.day.from_now) + + expect do + described_class.perform_now + end.to have_enqueued_job(Invoices::Payments::MarkOverdueJob).with(invoice: overdue_invoice_1) + .and have_enqueued_job(Invoices::Payments::MarkOverdueJob).with(invoice: overdue_invoice_2) + end + end + + describe "index usage" do + # Force PostgreSQL to use indexes even on a tiny test dataset so we can + # verify the planner CAN use them for the query patterns the job produces. + around do |example| + ActiveRecord::Base.connection.execute("SET enable_seqscan = off") + example.run + ensure + ActiveRecord::Base.connection.execute("SET enable_seqscan = on") + end + + it "uses the partial index on payment_due_date for the overdue invoice lookup" do + create(:invoice, payment_due_date: 1.day.ago) + + plan = Invoice + .finalized + .not_payment_succeeded + .where(payment_overdue: false) + .where(payment_dispute_lost_at: nil) + .where(payment_due_date: ...Time.current) + .order(:payment_due_date, :id) + .limit(1000) + .explain + .inspect + + expect(plan).to include("index_invoices_on_payment_due_date") + end + end +end diff --git a/spec/jobs/clock/process_all_subscription_activities_job_spec.rb b/spec/jobs/clock/process_all_subscription_activities_job_spec.rb new file mode 100644 index 0000000..c617f77 --- /dev/null +++ b/spec/jobs/clock/process_all_subscription_activities_job_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::ProcessAllSubscriptionActivitiesJob, job: true do + subject { described_class } + + let(:matching_service) { UsageMonitoring::ProcessAllSubscriptionActivitiesService } + + describe ".perform" do + before do + allow(matching_service).to receive(:call!) + end + + context "when premium features are enabled", :premium do + it "calls matching service" do + described_class.perform_now + expect(matching_service).to have_received(:call!) + end + end + + it "does nothing" do + described_class.perform_now + expect(matching_service).not_to have_received(:call!) + end + end +end diff --git a/spec/jobs/clock/process_dedicated_orgs_subscription_activities_job_spec.rb b/spec/jobs/clock/process_dedicated_orgs_subscription_activities_job_spec.rb new file mode 100644 index 0000000..cf20f6d --- /dev/null +++ b/spec/jobs/clock/process_dedicated_orgs_subscription_activities_job_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clock::ProcessDedicatedOrgsSubscriptionActivitiesJob, job: true do + describe "#perform" do + subject { described_class.perform_now } + + let(:target_organization) { create(:organization) } + let(:other_organization) { create(:organization) } + + context "when the dedicated org list is empty" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", []) } + + context "when premium", :premium do + it "does not enqueue any job" do + subject + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).not_to have_been_enqueued + end + end + end + + context "when the dedicated org list contains the target organization" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", [target_organization.id]) } + + context "when freemium" do + it "does not enqueue any job" do + create(:subscription_activity, organization: target_organization, enqueued: false) + + subject + + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).not_to have_been_enqueued + end + end + + context "when premium", :premium do + context "when the target org has pending subscription activities" do + before do + create(:subscription_activity, organization: target_organization, enqueued: false) + create(:subscription_activity, organization: other_organization, enqueued: false) + end + + it "enqueues ProcessOrganizationSubscriptionActivitiesJob for target org only" do + subject + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).to have_been_enqueued.with(target_organization.id).once + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).not_to have_been_enqueued.with(other_organization.id) + end + end + + context "when the target org has no pending subscription activities" do + it "does not enqueue a job" do + subject + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).not_to have_been_enqueued + end + end + + context "when the target org only has activities already enqueued" do + before { create(:subscription_activity, organization: target_organization, enqueued: true) } + + it "does not enqueue a job" do + subject + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).not_to have_been_enqueued + end + end + end + end + end +end diff --git a/spec/jobs/clock/process_dunning_campaigns_job_spec.rb b/spec/jobs/clock/process_dunning_campaigns_job_spec.rb new file mode 100644 index 0000000..ba91c1a --- /dev/null +++ b/spec/jobs/clock/process_dunning_campaigns_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::ProcessDunningCampaignsJob, job: true do + subject { described_class } + + describe ".perform" do + context "when premium features are enabled", :premium do + it "queue a DunningCampaigns::ProcessDunningCampaignsJob" do + described_class.perform_now + expect(DunningCampaigns::BulkProcessJob).to have_been_enqueued + end + end + + it "does nothing" do + described_class.perform_now + expect(DunningCampaigns::BulkProcessJob).not_to have_been_enqueued + end + end +end diff --git a/spec/jobs/clock/refresh_dedicated_org_wallets_ongoing_balance_job_spec.rb b/spec/jobs/clock/refresh_dedicated_org_wallets_ongoing_balance_job_spec.rb new file mode 100644 index 0000000..0f8b2fb --- /dev/null +++ b/spec/jobs/clock/refresh_dedicated_org_wallets_ongoing_balance_job_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clock::RefreshDedicatedOrgWalletsOngoingBalanceJob, job: true do + describe "#perform" do + subject { described_class.perform_now } + + let(:target_organization) { create(:organization) } + let(:other_organization) { create(:organization) } + let(:target_customer) { create(:customer, organization: target_organization, awaiting_wallet_refresh: true) } + let(:other_customer) { create(:customer, organization: other_organization, awaiting_wallet_refresh: true) } + let(:customer_without_wallet) do + create(:customer, organization: target_organization, awaiting_wallet_refresh: true) + end + let(:customer_with_terminated_wallet) do + create(:customer, organization: target_organization, awaiting_wallet_refresh: true) do |customer| + create(:wallet, customer:, status: :terminated) + end + end + + before do + create(:wallet, customer: target_customer) + create(:wallet, customer: other_customer) + customer_without_wallet + customer_with_terminated_wallet + end + + context "when the dedicated org list is empty" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", []) } + + context "when premium", :premium do + it "does not enqueue any refresh job" do + subject + expect(Customers::RefreshWalletJob).not_to have_been_enqueued + end + end + end + + context "when the dedicated org list contains the target organization" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", [target_organization.id]) } + + context "when freemium" do + it "does not enqueue refresh jobs" do + subject + expect(Customers::RefreshWalletJob).not_to have_been_enqueued + end + end + + context "when premium", :premium do + it "enqueues refresh jobs for flagged customers with active wallets in target org only" do + subject + expect(Customers::RefreshWalletJob).to have_been_enqueued.with(target_customer) + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(other_customer) + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(customer_without_wallet) + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(customer_with_terminated_wallet) + end + + context "when the target customer has tax errors" do + before do + create(:error_detail, owner: target_customer, error_code: ErrorDetail.error_codes[:tax_error]) + end + + it "does not enqueue the refresh job for that customer" do + subject + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(target_customer) + end + end + end + end + end +end diff --git a/spec/jobs/clock/refresh_draft_invoices_jobs_spec.rb b/spec/jobs/clock/refresh_draft_invoices_jobs_spec.rb new file mode 100644 index 0000000..9f49dd3 --- /dev/null +++ b/spec/jobs/clock/refresh_draft_invoices_jobs_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::RefreshDraftInvoicesJob, job: true do + subject { described_class } + + describe ".perform" do + let(:invoice) { create(:invoice, :draft) } + let(:subscription) { create(:subscription) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:) } + + before do + invoice_subscription + allow(Invoices::RefreshDraftService).to receive(:call) + end + + context "when not ready to be refreshed" do + it "does not call the refresh service" do + described_class.perform_now + expect(Invoices::RefreshDraftJob).not_to have_been_enqueued.with(invoice:) + end + end + + context "when invoice is related only to terminated subscriptions" do + let(:invoice) { create(:invoice, :draft, ready_to_be_refreshed: true) } + let(:subscription) { create(:subscription, :terminated) } + + it "does not call the refresh service" do + described_class.perform_now + expect(Invoices::RefreshDraftJob).not_to have_been_enqueued.with(invoice:) + end + end + + context "when ready to be refreshed" do + let(:invoice) { create(:invoice, :draft, ready_to_be_refreshed: true) } + + it "calls the refresh service" do + described_class.perform_now + expect(Invoices::RefreshDraftJob).to have_been_enqueued.with(invoice:) + end + end + end +end diff --git a/spec/jobs/clock/refresh_lifetime_usages_job_spec.rb b/spec/jobs/clock/refresh_lifetime_usages_job_spec.rb new file mode 100644 index 0000000..ca8d484 --- /dev/null +++ b/spec/jobs/clock/refresh_lifetime_usages_job_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::RefreshLifetimeUsagesJob, job: true do + subject { described_class } + + describe ".perform" do + let(:organization) { create(:organization) } + let(:lifetime_usage1) { create(:lifetime_usage, organization:, recalculate_invoiced_usage: true) } + let(:lifetime_usage2) { create(:lifetime_usage, organization:, recalculate_invoiced_usage: false) } + + before do + lifetime_usage1 + lifetime_usage2 + end + + context "when freemium" do + it "does not call the refresh service" do + described_class.perform_now + expect(LifetimeUsages::RecalculateAndCheckJob).not_to have_been_enqueued.with(lifetime_usage1) + expect(LifetimeUsages::RecalculateAndCheckJob).not_to have_been_enqueued.with(lifetime_usage2) + end + end + + context "when only premium", :premium do + it "does not enqueue any job" do + described_class.perform_now + + expect(LifetimeUsages::RecalculateAndCheckJob).not_to have_been_enqueued.with(lifetime_usage1) + expect(LifetimeUsages::RecalculateAndCheckJob).not_to have_been_enqueued.with(lifetime_usage2) + end + end + + context "when premium & with the premium_integration enabled", :premium do + let(:organization) { create(:organization, premium_integrations: ["progressive_billing"]) } + + it "enqueues a job for every usage that needs to be recalculated" do + described_class.perform_now + + expect(LifetimeUsages::RecalculateAndCheckJob).to have_been_enqueued.with(lifetime_usage1) + expect(LifetimeUsages::RecalculateAndCheckJob).not_to have_been_enqueued.with(lifetime_usage2) + end + end + end +end diff --git a/spec/jobs/clock/refresh_wallets_ongoing_balance_job_spec.rb b/spec/jobs/clock/refresh_wallets_ongoing_balance_job_spec.rb new file mode 100644 index 0000000..c2c2fd9 --- /dev/null +++ b/spec/jobs/clock/refresh_wallets_ongoing_balance_job_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::RefreshWalletsOngoingBalanceJob, job: true do + describe "#perform" do + subject { described_class.perform_now } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, awaiting_wallet_refresh: true) } + let(:wallet) { create(:wallet, customer:) } + let(:customer_without_wallet) { create(:customer, organization:, awaiting_wallet_refresh: true) } + + let(:customer_with_terminated_wallet) do + create(:customer, organization:, awaiting_wallet_refresh: true) do |customer| + create(:wallet, customer:, status: :terminated) + end + end + + before do + wallet + customer_without_wallet + customer_with_terminated_wallet + allow(Customers::RefreshWalletsService).to receive(:call) + end + + context "when freemium" do + it "does not schedule refresh job" do + subject + expect(Customers::RefreshWalletJob).not_to have_been_enqueued + end + end + + context "when premium", :premium do + it "schedules refresh job for customers with active wallet awaiting refresh" do + subject + expect(Customers::RefreshWalletJob).to have_been_enqueued.with(customer) + end + + it "does not schedule refresh job for customers with terminated wallet or not awaiting for refresh" do + subject + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(customer_without_wallet) + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(customer_with_terminated_wallet) + end + + context "when customer has tax errors" do + before { create(:error_detail, owner: customer, error_code: ErrorDetail.error_codes[:tax_error]) } + + it "does not schedule refresh job for customers with tax errors" do + subject + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(customer) + end + end + + context "when the customer's organization is handled by the dedicated worker" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", [organization.id]) } + + it "does not schedule refresh job for customers in the dedicated organization" do + subject + expect(Customers::RefreshWalletJob).not_to have_been_enqueued.with(customer) + end + + context "with a customer in a non-dedicated organization" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization, awaiting_wallet_refresh: true) } + + before { create(:wallet, customer: other_customer) } + + it "still schedules refresh job for customers outside the dedicated list" do + subject + expect(Customers::RefreshWalletJob).to have_been_enqueued.with(other_customer) + end + end + end + end + end +end diff --git a/spec/jobs/clock/retry_failed_invoices_job_spec.rb b/spec/jobs/clock/retry_failed_invoices_job_spec.rb new file mode 100644 index 0000000..a32d99f --- /dev/null +++ b/spec/jobs/clock/retry_failed_invoices_job_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::RetryFailedInvoicesJob, job: true do + subject { described_class } + + describe ".perform" do + let(:customer) { create(:customer) } + let(:failed_invoice) do + create( + :invoice, + status: :failed, + created_at: DateTime.parse("20 Jun 2022"), + customer:, + organization: customer.organization + ) + end + let(:error_detail) do + create( + :error_detail, + owner: failed_invoice, + organization: customer.organization, + error_code: :tax_error, + details: { + tax_error: "validationError", + tax_error_message: "You've exceeded your API limit of 10 per second" + } + ) + end + let(:finalized_invoice) do + create( + :invoice, + status: :finalized, + created_at: DateTime.parse("20 Jun 2022"), + customer:, + organization: customer.organization + ) + end + + before do + failed_invoice + finalized_invoice + error_detail + allow(Invoices::RetryService).to receive(:call) + end + + context "with invalid product error" do + let(:error_detail) do + create( + :error_detail, + owner: failed_invoice, + organization: customer.organization, + error_code: :tax_error, + details: { + tax_error: "productExternalIdUnknown" + } + ) + end + + it "does not call the retry service" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + described_class.perform_now + + expect(Invoices::RetryService).not_to have_received(:call).with(invoice: failed_invoice) + expect(Invoices::RetryService).not_to have_received(:call).with(invoice: finalized_invoice) + end + end + end + + context "with api limit error" do + it "calls the retry service" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + described_class.perform_now + + expect(Invoices::RetryService).to have_received(:call).with(invoice: failed_invoice) + expect(Invoices::RetryService).not_to have_received(:call).with(invoice: finalized_invoice) + end + end + end + end +end diff --git a/spec/jobs/clock/retry_generating_subscription_invoices_job_spec.rb b/spec/jobs/clock/retry_generating_subscription_invoices_job_spec.rb new file mode 100644 index 0000000..884d751 --- /dev/null +++ b/spec/jobs/clock/retry_generating_subscription_invoices_job_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::RetryGeneratingSubscriptionInvoicesJob, job: true do + subject { described_class } + + describe ".perform" do + let(:old_generating_invoice) { create(:invoice, :generating, created_at: 5.days.ago) } + + before do + old_generating_invoice + end + + it "does not enqueue a BillSubscriptionJob for this invoice (missing subscriptions)" do + expect do + described_class.perform_now + end.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "with an actual invoice that should be retried" do + let(:old_generating_invoice) { create(:invoice, :subscription, created_at: 5.days.ago) } + + before do + old_generating_invoice.update(status: :generating) + end + + it "does enqueue a BillSubscriptionJob for this invoice" do + expect do + described_class.perform_now + end.to have_enqueued_job(BillSubscriptionJob) + end + + context "with an existing invoice generation error detail" do + let(:invoice_error) do + ErrorDetail.create(owner: old_generating_invoice, + organization: old_generating_invoice.organization, + error_code: :invoice_generation_error) + end + + before do + invoice_error + end + + it "does not enqueue a BillSubscriptionJob for this invoice" do + expect do + described_class.perform_now + end.not_to have_enqueued_job(BillSubscriptionJob) + end + end + end + + context "with an addon" do + let(:old_generating_invoice) { create(:invoice, :add_on, created_at: 5.days.ago) } + + before do + old_generating_invoice.update(status: :generating) + end + + it "does not enqueue a BillSubscriptionJob for this invoice" do + expect do + described_class.perform_now + end.not_to have_enqueued_job(BillSubscriptionJob) + end + end + end +end diff --git a/spec/jobs/clock/subscriptions_biller_job_spec.rb b/spec/jobs/clock/subscriptions_biller_job_spec.rb new file mode 100644 index 0000000..950af25 --- /dev/null +++ b/spec/jobs/clock/subscriptions_biller_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clock::SubscriptionsBillerJob do + subject { described_class } + + describe ".perform" do + let(:organization1) { create(:organization, api_keys: []) } + let(:organization2) { create(:organization, api_keys: []) } + + before do + organization1 + organization2 + end + + it "enqueues Subscriptions::OrganizationBillingJob for each organization" do + expect do + described_class.perform_now + end.to have_enqueued_job(Subscriptions::OrganizationBillingJob).exactly(2).times + end + end +end diff --git a/spec/jobs/clock/subscriptions_to_be_terminated_job_spec.rb b/spec/jobs/clock/subscriptions_to_be_terminated_job_spec.rb new file mode 100644 index 0000000..da73fb9 --- /dev/null +++ b/spec/jobs/clock/subscriptions_to_be_terminated_job_spec.rb @@ -0,0 +1,454 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::SubscriptionsToBeTerminatedJob, job: true do + subject { described_class } + + describe ".perform" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let!(:webhook_endpoint) { create(:webhook_endpoint, organization:) } + let(:current_date) { DateTime.parse("2026-06-01") } + let(:ending_at_15_days) { (current_date + 15.days).beginning_of_day } + let(:ending_at_45_days) { (current_date + 45.days).beginning_of_day } + + it "enqueues SendWebhookJob for subscriptions ending in 15 days" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + create(:subscription, customer:, organization:, ending_at: ending_at_15_days + 1.year) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + + it "enqueues SendWebhookJob for subscriptions ending in 45 days" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_45_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + + it "does not enqueue for subscriptions without ending_at" do + create(:subscription, customer:, organization:, ending_at: nil) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + + it "does not enqueue for subscriptions with non-matching ending_at" do + create(:subscription, customer:, organization:, ending_at: current_date + 10.days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + + context "with non-active subscriptions" do + it "does not enqueue for pending subscriptions" do + create(:subscription, :pending, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + + it "does not enqueue for terminated subscriptions" do + create(:subscription, :terminated, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + + it "does not enqueue for canceled subscriptions" do + create(:subscription, :canceled, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + end + + context "with multiple matching subscriptions at different windows" do + it "enqueues for both subscriptions" do + sub_15 = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + sub_45 = create(:subscription, customer:, organization:, ending_at: ending_at_45_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", sub_15) + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", sub_45) + end + end + end + + context "when termination_alert webhook was already sent today" do + it "does not enqueue" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + create( + :webhook, + :succeeded, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date + ) + + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + end + + context "when termination_alert webhook was sent yesterday" do + it "enqueues the alert" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + create( + :webhook, + :succeeded, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date - 1.day + ) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + end + + context "when termination_alert webhook was sent 30 days ago" do + it "enqueues the alert" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + create( + :webhook, + :succeeded, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date - 30.days + ) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + end + + context "when a different webhook type was sent today" do + it "still enqueues the termination alert" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + create( + :webhook, + :succeeded, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.started", + created_at: current_date + ) + + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + end + + context "when a pending termination_alert webhook exists for today" do + it "does not enqueue" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + create( + :webhook, + :pending, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date + ) + + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + end + + context "when a failed termination_alert webhook exists for today" do + it "does not enqueue" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + create( + :webhook, + :failed, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date + ) + + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + end + + context "with multiple webhook endpoints having webhooks today" do + it "does not enqueue" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + webhook_endpoint2 = create(:webhook_endpoint, organization:) + + travel_to(current_date) do + create( + :webhook, + :succeeded, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date + ) + create( + :webhook, + :succeeded, + webhook_endpoint: webhook_endpoint2, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date + ) + + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + end + + context "with multiple webhook endpoints having webhooks from a past day" do + it "enqueues exactly once" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + webhook_endpoint2 = create(:webhook_endpoint, organization:) + create( + :webhook, + :succeeded, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date - 30.days + ) + create( + :webhook, + :succeeded, + webhook_endpoint: webhook_endpoint2, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date - 30.days + ) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + end + + context "when ending_at is at end of day" do + it "still enqueues the alert" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days.end_of_day) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + end + + context "with subscriptions from different organizations" do + it "enqueues for matching subscriptions across organizations" do + other_org = create(:organization) + other_customer = create(:customer, organization: other_org) + create(:webhook_endpoint, organization: other_org) + other_sub = create(:subscription, customer: other_customer, organization: other_org, ending_at: ending_at_15_days) + sub = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", sub) + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", other_sub) + end + end + end + + context "when a termination_alert webhook exists with a different object_type" do + it "does not block the alert" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + travel_to(current_date) do + webhook = create( + :webhook, + :succeeded, + webhook_endpoint:, + object: subscription, + webhook_type: "subscription.termination_alert", + created_at: current_date + ) + webhook.update_column(:object_type, "Invoice") # rubocop:disable Rails/SkipsModelValidations + + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", subscription) + .exactly(:once) + end + end + end + + describe "index usage" do + # Force PostgreSQL to use indexes even on a tiny test dataset so we can + # verify the planner CAN use them for the query patterns the job produces. + around do |example| + ActiveRecord::Base.connection.execute("SET enable_seqscan = off") + example.run + ensure + ActiveRecord::Base.connection.execute("SET enable_seqscan = on") + end + + it "uses the partial index on ending_at for the subscription lookup" do + create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + plan = travel_to(current_date) do + Subscription + .active + .where("DATE(ending_at::timestamptz) IN (?)", [ending_at_15_days.to_date]) + .explain + .inspect + end + + expect(plan).to include("index_subscriptions_on_ending_at_active") + end + + it "uses the composite index on (object_type, object_id, webhook_type) for the webhook dedup" do + subscription = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + + plan = travel_to(current_date) do + Webhook + .where( + webhook_type: "subscription.termination_alert", + object_type: "Subscription", + object_id: [subscription.id] + ) + .where("created_at::date = ?", current_date.to_date) + .explain + .inspect + end + + expect(plan).to include("index_webhooks_on_object_type_and_object_id_and_webhook_type") + end + end + + context "with custom LAGO_SUBSCRIPTION_TERMINATION_ALERT_SENT_AT_DAYS" do + around do |test| + old_value = ENV["LAGO_SUBSCRIPTION_TERMINATION_ALERT_SENT_AT_DAYS"] + ENV["LAGO_SUBSCRIPTION_TERMINATION_ALERT_SENT_AT_DAYS"] = "1,15,45" + test.run + ensure + if old_value + ENV["LAGO_SUBSCRIPTION_TERMINATION_ALERT_SENT_AT_DAYS"] = old_value + else + ENV.delete("LAGO_SUBSCRIPTION_TERMINATION_ALERT_SENT_AT_DAYS") + end + end + + it "uses custom day intervals" do + ending_at_1_day = (current_date + 1.day).beginning_of_day + sub_1 = create(:subscription, customer:, organization:, ending_at: ending_at_1_day) + sub_15 = create(:subscription, customer:, organization:, ending_at: ending_at_15_days) + sub_45 = create(:subscription, customer:, organization:, ending_at: ending_at_45_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", sub_1) + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", sub_15) + expect(SendWebhookJob).to have_been_enqueued + .with("subscription.termination_alert", sub_45) + end + end + + it "does not enqueue for default-only intervals not in custom config" do + # 15 and 45 are in custom config, but 30 is not + ending_at_30_days = (current_date + 30.days).beginning_of_day + create(:subscription, customer:, organization:, ending_at: ending_at_30_days) + + travel_to(current_date) do + described_class.perform_now + + expect(SendWebhookJob).not_to have_been_enqueued + .with("subscription.termination_alert", anything) + end + end + end + end +end diff --git a/spec/jobs/clock/terminate_ended_subscriptions_job_spec.rb b/spec/jobs/clock/terminate_ended_subscriptions_job_spec.rb new file mode 100644 index 0000000..4a61b58 --- /dev/null +++ b/spec/jobs/clock/terminate_ended_subscriptions_job_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::TerminateEndedSubscriptionsJob, job: true do + subject { described_class } + + let(:ending_at) { (Time.current + 2.months).beginning_of_day } + let!(:subscription1) { create(:subscription, ending_at:) } + let!(:subscription2) { create(:subscription, ending_at: ending_at + 1.year) } + let!(:subscription3) { create(:subscription, ending_at: nil) } + + describe ".perform" do + it "enqueues a TerminateEndedSubscriptionJob for matching subscriptions" do + current_date = Time.current + 2.months + + travel_to(current_date) do + described_class.perform_now + + expect(Subscriptions::TerminateEndedSubscriptionJob) + .to have_been_enqueued.with(subscription1) + expect(Subscriptions::TerminateEndedSubscriptionJob) + .not_to have_been_enqueued.with(subscription2) + expect(Subscriptions::TerminateEndedSubscriptionJob) + .not_to have_been_enqueued.with(subscription3) + end + end + + context "with customer timezone" do + let(:ending_at) { DateTime.parse("2022-10-21 00:30:00") } + + before do + subscription1.customer.update!(timezone: "America/New_York") + end + + it "takes timezone into account" do + current_date = ending_at + + travel_to(current_date) do + described_class.perform_now + + expect(Subscriptions::TerminateEndedSubscriptionJob) + .to have_been_enqueued.with(subscription1) + expect(Subscriptions::TerminateEndedSubscriptionJob) + .not_to have_been_enqueued.with(subscription2) + expect(Subscriptions::TerminateEndedSubscriptionJob) + .not_to have_been_enqueued.with(subscription3) + end + end + end + end +end diff --git a/spec/jobs/clock/terminate_recurring_transaction_rules_job_spec.rb b/spec/jobs/clock/terminate_recurring_transaction_rules_job_spec.rb new file mode 100644 index 0000000..47e246b --- /dev/null +++ b/spec/jobs/clock/terminate_recurring_transaction_rules_job_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::TerminateRecurringTransactionRulesJob, job: true do + let(:wallet) { create(:wallet) } + let(:to_expire_rule) do + create( + :recurring_transaction_rule, wallet:, + status: "active", + expiration_at: Time.zone.now - 40.days + ) + end + + let(:to_keep_active_rule) do + create( + :recurring_transaction_rule, wallet:, + status: "active", + expiration_at: Time.zone.now + 40.days + ) + end + + before do + allow(Wallets::RecurringTransactionRules::TerminateService).to receive(:call) + to_expire_rule + to_keep_active_rule + end + + it "terminates expired recurring transaction rules" do + described_class.perform_now + + expect(Wallets::RecurringTransactionRules::TerminateService) + .to have_received(:call) + .with(recurring_transaction_rule: to_expire_rule) + + expect(Wallets::RecurringTransactionRules::TerminateService) + .not_to have_received(:call) + .with(recurring_transaction_rule: to_keep_active_rule) + end +end diff --git a/spec/jobs/clock/terminate_wallets_job_spec.rb b/spec/jobs/clock/terminate_wallets_job_spec.rb new file mode 100644 index 0000000..15e6900 --- /dev/null +++ b/spec/jobs/clock/terminate_wallets_job_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::TerminateWalletsJob, job: true do + let(:to_expire_wallet) do + create( + :wallet, + status: "active", + expiration_at: Time.zone.now - 40.days + ) + end + + let(:to_keep_active_wallet) do + create( + :wallet, + status: "active", + expiration_at: Time.zone.now + 40.days + ) + end + + before do + to_expire_wallet + to_keep_active_wallet + end + + it "terminates the expired wallets" do + described_class.perform_now + + expect(to_expire_wallet.reload.status).to eq("terminated") + expect(to_keep_active_wallet.reload.status).to eq("active") + end +end diff --git a/spec/jobs/clock/webhooks_cleanup_job_spec.rb b/spec/jobs/clock/webhooks_cleanup_job_spec.rb new file mode 100644 index 0000000..b15a63a --- /dev/null +++ b/spec/jobs/clock/webhooks_cleanup_job_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Clock::WebhooksCleanupJob do + subject(:webhooks_cleanup_job) { described_class } + + def with_batch_size(size) + previous = described_class.batch_size + described_class.batch_size = size + yield + ensure + described_class.batch_size = previous + end + + def with_retention_period(period) + previous = described_class.retention_period + described_class.retention_period = period + yield + ensure + described_class.retention_period = previous + end + + describe ".perform" do + context "when webhooks are older than the retention period" do + it "removes them" do + create(:webhook, :succeeded, updated_at: 100.days.ago) + + expect { webhooks_cleanup_job.perform_now } + .to change(Webhook, :count).to(0) + end + end + + context "when webhooks are newer than the retention period" do + it "does not delete them" do + create(:webhook, updated_at: 89.days.ago) + + expect { webhooks_cleanup_job.perform_now } + .not_to change(Webhook, :count) + end + end + + context "when there are more webhooks than the batch size" do + around { |test| with_batch_size(2, &test) } + + it "processes multiple batches" do + create_list(:webhook, 3, :succeeded, updated_at: 100.days.ago) + recent = create(:webhook, updated_at: 89.days.ago) + + expect { webhooks_cleanup_job.perform_now } + .to change(Webhook, :count).to(1) + + expect(Webhook.first).to eq(recent) + end + end + + context "with a custom retention period" do + around { |test| with_retention_period(30.days, &test) } + + it "uses the configured retention period" do + create(:webhook, updated_at: 31.days.ago) + create(:webhook, updated_at: 29.days.ago) + + expect { webhooks_cleanup_job.perform_now } + .to change(Webhook, :count).to(1) + end + end + end +end diff --git a/spec/jobs/credit_notes/generate_documents_job_spec.rb b/spec/jobs/credit_notes/generate_documents_job_spec.rb new file mode 100644 index 0000000..eb22435 --- /dev/null +++ b/spec/jobs/credit_notes/generate_documents_job_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::GenerateDocumentsJob do + subject { described_class.perform_now(credit_note) } + + let(:credit_note) { create(:credit_note) } + let(:result) { BaseService::Result.new } + + before do + allow(CreditNotes::GeneratePdfService).to receive(:call).with(credit_note:, context: "api").and_return(result) + allow(CreditNotes::GenerateXmlService).to receive(:call).with(credit_note:, context: "api").and_return(result) + end + + it_behaves_like "a configurable queue", "pdfs", "SIDEKIQ_PDFS", "invoices" do + let(:arguments) { credit_note } + end + + it_behaves_like "a retryable on network errors job" do + let(:service_class) { CreditNotes::GenerateXmlService } + let(:job_arguments) { credit_note } + end + + it "delegates to the Generate service" do + subject + + expect(CreditNotes::GeneratePdfService).to have_received(:call) + expect(CreditNotes::GenerateXmlService).to have_received(:call) + end +end diff --git a/spec/jobs/credit_notes/generate_pdf_job_spec.rb b/spec/jobs/credit_notes/generate_pdf_job_spec.rb new file mode 100644 index 0000000..6a197f3 --- /dev/null +++ b/spec/jobs/credit_notes/generate_pdf_job_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::GeneratePdfJob do + let(:credit_note) { create(:credit_note) } + + let(:result) { BaseService::Result.new } + + it "delegates to the Generate service" do + allow(CreditNotes::GeneratePdfService).to receive(:call) + .with(credit_note:, context: "api") + .and_return(result) + + described_class.perform_now(credit_note) + + expect(CreditNotes::GeneratePdfService).to have_received(:call) + end +end diff --git a/spec/jobs/credit_notes/generate_xml_job_spec.rb b/spec/jobs/credit_notes/generate_xml_job_spec.rb new file mode 100644 index 0000000..80432a3 --- /dev/null +++ b/spec/jobs/credit_notes/generate_xml_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::GenerateXmlJob do + let(:credit_note) { create(:credit_note) } + + let(:result) { BaseService::Result.new } + + let(:generate_service) do + instance_double(CreditNotes::GenerateXmlService) + end + + it "delegates to the Generate service" do + allow(CreditNotes::GenerateXmlService).to receive(:new) + .with(credit_note:, context: "api") + .and_return(generate_service) + allow(generate_service).to receive(:call_with_middlewares) + .and_return(result) + + described_class.perform_now(credit_note) + + expect(CreditNotes::GenerateXmlService).to have_received(:new) + expect(generate_service).to have_received(:call_with_middlewares) + end +end diff --git a/spec/jobs/credit_notes/provider_taxes/report_job_spec.rb b/spec/jobs/credit_notes/provider_taxes/report_job_spec.rb new file mode 100644 index 0000000..dc6344e --- /dev/null +++ b/spec/jobs/credit_notes/provider_taxes/report_job_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::ProviderTaxes::ReportJob do + let(:organization) { create(:organization) } + let(:credit_note) { create(:credit_note, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:result) { BaseService::Result.new } + + before do + allow(CreditNotes::ProviderTaxes::ReportService).to receive(:call) + .with(credit_note:) + .and_return(result) + end + + context "when there is anrok customer" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before { integration_customer } + + it "calls successfully report service" do + described_class.perform_now(credit_note:) + + expect(CreditNotes::ProviderTaxes::ReportService).to have_received(:call) + end + end + + context "when there is NOT anrok customer" do + it "does not call report service" do + described_class.perform_now(credit_note:) + + expect(CreditNotes::ProviderTaxes::ReportService).not_to have_received(:call) + end + end +end diff --git a/spec/jobs/credit_notes/refunds/adyen_create_job_spec.rb b/spec/jobs/credit_notes/refunds/adyen_create_job_spec.rb new file mode 100644 index 0000000..b333903 --- /dev/null +++ b/spec/jobs/credit_notes/refunds/adyen_create_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::Refunds::AdyenCreateJob do + let(:credit_note) { create(:credit_note) } + + let(:refund_service) do + instance_double(CreditNotes::Refunds::AdyenService) + end + + it "delegates to the adyen refund service" do + allow(CreditNotes::Refunds::AdyenService).to receive(:new) + .with(credit_note) + .and_return(refund_service) + allow(refund_service).to receive(:create) + .and_return(BaseService::Result.new) + + described_class.perform_now(credit_note) + + expect(CreditNotes::Refunds::AdyenService).to have_received(:new) + expect(refund_service).to have_received(:create) + end +end diff --git a/spec/jobs/credit_notes/refunds/gocardless_create_job_spec.rb b/spec/jobs/credit_notes/refunds/gocardless_create_job_spec.rb new file mode 100644 index 0000000..f42080b --- /dev/null +++ b/spec/jobs/credit_notes/refunds/gocardless_create_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::Refunds::GocardlessCreateJob do + let(:credit_note) { create(:credit_note) } + + let(:refund_service) do + instance_double(CreditNotes::Refunds::GocardlessService) + end + + it "delegates to the gocardless refund service" do + allow(CreditNotes::Refunds::GocardlessService).to receive(:new) + .with(credit_note) + .and_return(refund_service) + allow(refund_service).to receive(:create) + .and_return(BaseService::Result.new) + + described_class.perform_now(credit_note) + + expect(CreditNotes::Refunds::GocardlessService).to have_received(:new) + expect(refund_service).to have_received(:create) + end +end diff --git a/spec/jobs/credit_notes/refunds/stripe_create_job_spec.rb b/spec/jobs/credit_notes/refunds/stripe_create_job_spec.rb new file mode 100644 index 0000000..af493f1 --- /dev/null +++ b/spec/jobs/credit_notes/refunds/stripe_create_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::Refunds::StripeCreateJob do + let(:credit_note) { create(:credit_note) } + + let(:refund_service) do + instance_double(CreditNotes::Refunds::StripeService) + end + + it "delegates to the stripe refund service" do + allow(CreditNotes::Refunds::StripeService).to receive(:new) + .with(credit_note) + .and_return(refund_service) + allow(refund_service).to receive(:create) + .and_return(BaseService::Result.new) + + described_class.perform_now(credit_note) + + expect(CreditNotes::Refunds::StripeService).to have_received(:new) + expect(refund_service).to have_received(:create) + end +end diff --git a/spec/jobs/customers/refresh_wallet_job_spec.rb b/spec/jobs/customers/refresh_wallet_job_spec.rb new file mode 100644 index 0000000..0af1d7a --- /dev/null +++ b/spec/jobs/customers/refresh_wallet_job_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::RefreshWalletJob do + describe "queue routing" do + let(:customer) { create(:customer) } + + context "when the customer's organization is in the dedicated list" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", [customer.organization_id]) } + + it "routes to the dedicated queue" do + expect(described_class.new(customer).queue_name).to eq("dedicated_wallets") + end + end + + context "when the customer's organization is not in the dedicated list" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", ["some-other-org-id"]) } + + it "falls back to low_priority" do + expect(described_class.new(customer).queue_name).to eq("low_priority") + end + end + + context "when the dedicated list is empty" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", []) } + + it "falls back to low_priority" do + expect(described_class.new(customer).queue_name).to eq("low_priority") + end + end + end + + describe "#perform" do + subject { described_class.perform_now(customer) } + + let(:customer) { create(:customer, awaiting_wallet_refresh:) } + let(:organization) { customer.organization } + let(:result) { BaseService::Result.new } + + before do + allow(Customers::RefreshWalletsService).to receive(:call).with(customer:).and_return(result) + end + + context "when customer is not awaiting wallet refresh" do + let(:awaiting_wallet_refresh) { false } + + it "does not call the Customers::RefreshWalletsService service" do + subject + expect(Customers::RefreshWalletsService).not_to have_received(:call) + end + end + + context "when customer is awaiting wallet refresh" do + let(:awaiting_wallet_refresh) { true } + + context "when refresh customer's wallets succeeds" do + it "calls the Customers::RefreshWalletsService service" do + subject + expect(Customers::RefreshWalletsService).to have_received(:call).with(customer:) + end + end + + context "when a tax_error error_detail already exists" do + before do + create(:error_detail, owner: customer, organization:, error_code: :tax_error) + end + + it "does not call the Customers::RefreshWalletsService service" do + subject + expect(Customers::RefreshWalletsService).not_to have_received(:call) + end + end + + context "when refresh customer's wallets fails with a tax error" do + let(:result) { BaseService::Result.new.validation_failure!(errors: {tax_error: ["customerAddressCouldNotResolve"]}) } + + context "when the error is related to the customer's address" do + it "creates a tax_error error_detail on the customer" do + expect { subject }.to change { customer.error_details.tax_error.count }.by(1) + end + + it "does not re-raise the error" do + expect { subject }.not_to raise_error + end + end + + context "when the error is an out of memory error" do + let(:result) { BaseService::Result.new.validation_failure!(errors: {tax_error: ["function_runtime_out_of_memory"]}) } + + it "raises the error and retries the job" do + assert_performed_jobs(6, only: [described_class]) do + expect do + described_class.perform_later(customer) + end.to raise_error(Integrations::Aggregator::OutOfMemoryError) + end + end + end + + context "when the tax error is an unknown failure" do + let(:result) { BaseService::Result.new.validation_failure!(errors: {tax_error: ["failure"]}) } + + it "does not create an error_detail and re-raises the error" do + expect { subject }.to raise_error(BaseService::ValidationFailure).and not_change { customer.error_details.count } + end + end + end + + context "when refresh customer's wallets fails with a non-tax error" do + let(:result) { BaseService::Result.new.validation_failure!(errors: {other_error: ["something"]}) } + + it "re-raises the error" do + expect { subject }.to raise_error(BaseService::ValidationFailure) + end + end + end + end +end diff --git a/spec/jobs/customers/retry_vies_check_job_spec.rb b/spec/jobs/customers/retry_vies_check_job_spec.rb new file mode 100644 index 0000000..5328e32 --- /dev/null +++ b/spec/jobs/customers/retry_vies_check_job_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::RetryViesCheckJob do + let(:customer) { create(:customer) } + + it "finds the customer by ID and delegates to ViesCheckJob" do + allow(Customers::ViesCheckJob).to receive(:perform_now) + + described_class.perform_now(customer.id) + + expect(Customers::ViesCheckJob).to have_received(:perform_now).with(customer) + end +end diff --git a/spec/jobs/customers/terminate_relations_job_spec.rb b/spec/jobs/customers/terminate_relations_job_spec.rb new file mode 100644 index 0000000..e27db13 --- /dev/null +++ b/spec/jobs/customers/terminate_relations_job_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::TerminateRelationsJob do + let(:customer) { create(:customer, :deleted) } + + it "calls the service" do + allow(Customers::TerminateRelationsService) + .to receive(:call) + .with(customer:) + .and_return(BaseService::Result.new) + + described_class.perform_now(customer_id: customer.id) + + expect(Customers::TerminateRelationsService) + .to have_received(:call) + end +end diff --git a/spec/jobs/customers/vies_check_job_spec.rb b/spec/jobs/customers/vies_check_job_spec.rb new file mode 100644 index 0000000..d3f29e3 --- /dev/null +++ b/spec/jobs/customers/vies_check_job_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::ViesCheckJob do + let(:customer) { create(:customer, tax_identification_number: "IE6388047V") } + let(:vies_response) do + { + country_code: "FR" + } + end + + before do + customer.billing_entity.update(eu_tax_management: true, country: "FR") + + allow(Customers::ApplyTaxesService).to receive(:call) + .and_call_original + allow_any_instance_of(Valvat).to receive(:exists?).and_return(vies_response) # rubocop:disable RSpec/AnyInstance + end + + it "calls the ViesCheckService" do + allow(Customers::ViesCheckService).to receive(:call).and_call_original + + described_class.perform_now(customer) + + expect(Customers::ViesCheckService).to have_received(:call).with(customer:) + end + + context "when ViesCheckService returns a tax code" do + it "applies the tax code" do + described_class.perform_now(customer) + + expect(Customers::ApplyTaxesService).to have_received(:call) + .with(customer: customer, tax_codes: ["lago_eu_fr_standard"]) + end + + context "when customer has pending invoices blocked by VIES" do + let(:pending_invoice) do + create(:invoice, :pending, customer:, organization: customer.organization, tax_status: :pending) + end + let(:finalized_invoice) do + create(:invoice, :finalized, customer:, organization: customer.organization) + end + let(:pending_but_tax_succeeded_invoice) do + create(:invoice, :pending, customer:, organization: customer.organization, tax_status: :succeeded) + end + + before do + pending_invoice + finalized_invoice + pending_but_tax_succeeded_invoice + end + + it "enqueues FinalizePendingViesInvoiceJob for pending invoices with pending tax_status" do + expect { described_class.perform_now(customer) } + .to have_enqueued_job(Invoices::FinalizePendingViesInvoiceJob).with(pending_invoice) + end + + it "does not enqueue job for finalized invoices" do + expect { described_class.perform_now(customer) } + .not_to have_enqueued_job(Invoices::FinalizePendingViesInvoiceJob).with(finalized_invoice) + end + + it "does not enqueue job for pending invoices with succeeded tax_status" do + expect { described_class.perform_now(customer) } + .not_to have_enqueued_job(Invoices::FinalizePendingViesInvoiceJob).with(pending_but_tax_succeeded_invoice) + end + + context "when customer has gated invoices blocked by VIES" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: :payment, timeout_hours: 48, status: :pending}], + customer:, organization: customer.organization) + end + let(:gated_invoice) do + create(:invoice, :with_subscriptions, customer:, organization: customer.organization, + status: :open, tax_status: :pending, subscriptions: [subscription]) + end + + before { gated_invoice } + + it "enqueues FinalizePendingViesInvoiceJob for gated invoices with pending tax_status" do + expect { described_class.perform_now(customer) } + .to have_enqueued_job(Invoices::FinalizePendingViesInvoiceJob).with(gated_invoice) + end + end + end + end + + context "when valvat has an error" do + let(:pending_invoice) do + create(:invoice, :pending, customer:, organization: customer.organization, tax_status: :pending) + end + + before do + pending_invoice + allow_any_instance_of(Valvat).to receive(:exists?).and_raise(Valvat::Timeout.new("Timeout", "dummy")) # rubocop:disable RSpec/AnyInstance + end + + it "enqueues another retry job" do + expect { described_class.perform_now(customer) }.to have_enqueued_job(described_class) + end + + it "does not apply taxes" do + described_class.perform_now(customer) + + expect(Customers::ApplyTaxesService).not_to have_received(:call) + end + + it "does not enqueue invoice finalization" do + expect { described_class.perform_now(customer) } + .not_to have_enqueued_job(Invoices::FinalizePendingViesInvoiceJob) + end + end +end diff --git a/spec/jobs/daily_usages/compute_job_spec.rb b/spec/jobs/daily_usages/compute_job_spec.rb new file mode 100644 index 0000000..63e1f67 --- /dev/null +++ b/spec/jobs/daily_usages/compute_job_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::ComputeJob do + subject(:compute_job) { described_class } + + let(:subscription) { create(:subscription) } + let(:timestamp) { Time.current } + + let(:result) { BaseService::Result.new } + + describe ".perform" do + it "delegates to DailyUsages::ComputeService" do + allow(DailyUsages::ComputeService).to receive(:call) + .with(subscription:, timestamp:) + .and_return(result) + + compute_job.perform_now(subscription, timestamp:) + + expect(DailyUsages::ComputeService).to have_received(:call) + .with(subscription:, timestamp:).once + end + end + + describe "#lock_key_arguments" do + let(:customer) { create(:customer, timezone: "Europe/Paris") } + let(:subscription) { create(:subscription, customer:) } + + it "returns subscription id and date in customer timezone" do + timestamp = Time.zone.parse("2024-01-15 10:00:00 UTC") + job = described_class.new(subscription, timestamp:) + + # 10:00 UTC = 11:00 Europe/Paris -> 2024-01-15 + expect(job.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 15)]) + end + + context "when timestamps fall on the same day in customer timezone" do + it "returns the same lock key" do + # 23:00 UTC = 00:00+01:00 (Jan 16 in Paris) + job1 = described_class.new(subscription, timestamp: Time.zone.parse("2024-01-15 23:00:00 UTC")) + # 00:00 UTC Jan 16 = 01:00+01:00 (Jan 16 in Paris) + job2 = described_class.new(subscription, timestamp: Time.zone.parse("2024-01-16 00:00:00 UTC")) + + expect(job1.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 16)]) + expect(job2.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 16)]) + end + end + + context "when timestamps fall on different days in customer timezone" do + it "returns different lock keys" do + # 00:00 UTC = 01:00+01:00 (Jan 15 in Paris) + job1 = described_class.new(subscription, timestamp: Time.zone.parse("2024-01-15 00:00:00 UTC")) + # 23:00 UTC = 00:00+01:00 (Jan 16 in Paris) + job2 = described_class.new(subscription, timestamp: Time.zone.parse("2024-01-15 23:00:00 UTC")) + + expect(job1.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 15)]) + expect(job2.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 16)]) + end + end + + context "when subscriptions are different" do + it "returns different lock keys" do + timestamp = Time.zone.parse("2024-01-15 10:00:00 UTC") + other_subscription = create(:subscription, customer:) + + job1 = described_class.new(subscription, timestamp:) + job2 = described_class.new(other_subscription, timestamp:) + + expect(job1.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 15)]) + expect(job2.lock_key_arguments).to eq([other_subscription.id, Date.new(2024, 1, 15)]) + end + end + + context "when customer has no timezone but billing entity has one" do + let(:billing_entity) { create(:billing_entity, timezone: "America/New_York") } + let(:customer) { create(:customer, timezone: nil, billing_entity:) } + + it "falls back to billing entity timezone" do + # 03:00 UTC = 22:00-05:00 (Jan 14 in New York) + job = described_class.new(subscription, timestamp: Time.zone.parse("2024-01-15 03:00:00 UTC")) + + expect(job.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 14)]) + end + end + + context "when customer has no timezone and billing entity uses default UTC" do + let(:billing_entity) { create(:billing_entity, timezone: "UTC") } + let(:customer) { create(:customer, timezone: nil, billing_entity:) } + + it "falls back to UTC" do + # 23:00 UTC stays Jan 15 in UTC + job = described_class.new(subscription, timestamp: Time.zone.parse("2024-01-15 23:00:00 UTC")) + + expect(job.lock_key_arguments).to eq([subscription.id, Date.new(2024, 1, 15)]) + end + end + end +end diff --git a/spec/jobs/daily_usages/fill_from_invoice_job_spec.rb b/spec/jobs/daily_usages/fill_from_invoice_job_spec.rb new file mode 100644 index 0000000..1de423c --- /dev/null +++ b/spec/jobs/daily_usages/fill_from_invoice_job_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::FillFromInvoiceJob do + subject(:compute_job) { described_class } + + let(:subscription) { create(:subscription) } + let(:invoice) { create(:invoice, :subscription, subscriptions: [subscription]) } + + let(:result) { BaseService::Result.new } + + describe ".perform" do + it "delegates its logic to the DailyUsages::FillFromInvoiceService" do + allow(DailyUsages::FillFromInvoiceService).to receive(:call) + .with(invoice:, subscriptions: [subscription]) + .and_return(result) + + compute_job.perform_now(invoice:, subscriptions: [subscription]) + + expect(DailyUsages::FillFromInvoiceService).to have_received(:call) + .with(invoice:, subscriptions: [subscription]).once + end + end +end diff --git a/spec/jobs/data_exports/combine_parts_job_spec.rb b/spec/jobs/data_exports/combine_parts_job_spec.rb new file mode 100644 index 0000000..d3ea8f0 --- /dev/null +++ b/spec/jobs/data_exports/combine_parts_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::CombinePartsJob do + let(:data_export) { create(:data_export) } + let(:result) { BaseService::Result.new } + + before do + allow(DataExports::CombinePartsService) + .to receive(:call) + .with(data_export:) + .and_return(result) + end + + it "calls ProcessPart service" do + described_class.perform_now(data_export) + + expect(DataExports::CombinePartsService) + .to have_received(:call) + .with(data_export:) + end +end diff --git a/spec/jobs/data_exports/export_resources_job_spec.rb b/spec/jobs/data_exports/export_resources_job_spec.rb new file mode 100644 index 0000000..067248e --- /dev/null +++ b/spec/jobs/data_exports/export_resources_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::ExportResourcesJob do + let(:data_export) { create(:data_export) } + let(:result) { BaseService::Result.new } + + before do + allow(DataExports::ExportResourcesService) + .to receive(:call) + .with(data_export:, batch_size: 20) + .and_return(result) + end + + it "calls ExportResources service" do + described_class.perform_now(data_export) + + expect(DataExports::ExportResourcesService) + .to have_received(:call) + .with(data_export:, batch_size: 20) + end +end diff --git a/spec/jobs/data_exports/process_part_job_spec.rb b/spec/jobs/data_exports/process_part_job_spec.rb new file mode 100644 index 0000000..63d80a8 --- /dev/null +++ b/spec/jobs/data_exports/process_part_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::ProcessPartJob do + let(:data_export_part) { create(:data_export_part) } + let(:result) { BaseService::Result.new } + + before do + allow(DataExports::ProcessPartService) + .to receive(:call) + .with(data_export_part:) + .and_return(result) + end + + it "calls ProcessPart service" do + described_class.perform_now(data_export_part) + + expect(DataExports::ProcessPartService) + .to have_received(:call) + .with(data_export_part:) + end +end diff --git a/spec/jobs/database_migrations/backfill_adyen_payment_methods_job_spec.rb b/spec/jobs/database_migrations/backfill_adyen_payment_methods_job_spec.rb new file mode 100644 index 0000000..5056620 --- /dev/null +++ b/spec/jobs/database_migrations/backfill_adyen_payment_methods_job_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DatabaseMigrations::BackfillAdyenPaymentMethodsJob do + subject(:perform_job) { described_class.perform_now } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:adyen_provider) { create(:adyen_provider, organization:) } + let(:adyen_customer) do + create(:adyen_customer, customer:, payment_provider: adyen_provider).tap do |c| + c.update!(payment_method_id: "adyen_pm_123", provider_customer_id: "shopper_123") + end + end + + context "when adyen customer has a payment_method_id" do + it "creates a payment method" do + adyen_customer + + expect { perform_job }.to change(PaymentMethod, :count).by(1) + end + + it "sets the correct attributes on the payment method" do + adyen_customer + perform_job + + payment_method = customer.payment_methods.first + expect(payment_method.provider_method_id).to eq("adyen_pm_123") + expect(payment_method.provider_method_type).to eq("card") + expect(payment_method.payment_provider_customer).to eq(adyen_customer) + expect(payment_method.payment_provider).to eq(adyen_provider) + expect(payment_method.organization).to eq(organization) + expect(payment_method.is_default).to be true + expect(payment_method.details["from_migration"]).to be true + expect(payment_method.details["provider_customer_id"]).to eq("shopper_123") + end + end + + context "when adyen customer has no payment_method_id" do + let(:adyen_customer) { create(:adyen_customer, customer:, payment_provider: adyen_provider) } + + it "does not create a payment method" do + adyen_customer + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when payment method already exists for the adyen customer" do + it "does not create a duplicate" do + create(:payment_method, customer:, payment_provider_customer: adyen_customer, provider_method_id: "adyen_pm_123") + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when a discarded payment method already exists for the adyen customer" do + it "does not recreate the discarded payment method" do + create(:payment_method, customer:, payment_provider_customer: adyen_customer, provider_method_id: "adyen_pm_123").discard! + + expect { perform_job }.not_to change(PaymentMethod.unscoped, :count) + end + end + + context "with organization_id filter" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_adyen_provider) { create(:adyen_provider, organization: other_organization) } + let(:other_adyen_customer) do + create(:adyen_customer, customer: other_customer, payment_provider: other_adyen_provider).tap do |c| + c.update!(payment_method_id: "adyen_pm_other") + end + end + + it "only processes the specified organization" do + adyen_customer + other_adyen_customer + + expect { described_class.perform_now(organization.id) } + .to change(PaymentMethod, :count).by(1) + + expect(customer.payment_methods.count).to eq(1) + expect(other_customer.payment_methods.count).to eq(0) + end + end + + context "when there is more work after the batch" do + before { stub_const("#{described_class}::BATCH_SIZE", 1) } + + it "enqueues the next batch" do + customer_2 = create(:customer, organization:) + adyen_customer + create(:adyen_customer, customer: customer_2, payment_provider: adyen_provider).tap do |c| + c.update!(payment_method_id: "adyen_pm_456") + end + + expect { perform_job }.to have_enqueued_job(described_class) + end + end + + context "when there is no pending work" do + it "does not enqueue any job" do + expect { perform_job }.not_to have_enqueued_job + end + end +end diff --git a/spec/jobs/database_migrations/backfill_gocardless_payment_methods_job_spec.rb b/spec/jobs/database_migrations/backfill_gocardless_payment_methods_job_spec.rb new file mode 100644 index 0000000..f56abfa --- /dev/null +++ b/spec/jobs/database_migrations/backfill_gocardless_payment_methods_job_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DatabaseMigrations::BackfillGocardlessPaymentMethodsJob do + subject(:perform_job) { described_class.perform_now } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:gocardless_provider) { create(:gocardless_provider, organization:) } + let(:gocardless_customer) do + create(:gocardless_customer, customer:, payment_provider: gocardless_provider).tap do |c| + c.update!(provider_mandate_id: "mandate_123", provider_customer_id: "gc_customer_123") + end + end + + context "when gocardless customer has a provider_mandate_id" do + it "creates a payment method" do + gocardless_customer + + expect { perform_job }.to change(PaymentMethod, :count).by(1) + end + + it "sets the correct attributes on the payment method" do + gocardless_customer + perform_job + + payment_method = customer.payment_methods.first + expect(payment_method.provider_method_id).to eq("mandate_123") + expect(payment_method.provider_method_type).to eq("card") + expect(payment_method.payment_provider_customer).to eq(gocardless_customer) + expect(payment_method.payment_provider).to eq(gocardless_provider) + expect(payment_method.organization).to eq(organization) + expect(payment_method.is_default).to be true + expect(payment_method.details["from_migration"]).to be true + expect(payment_method.details["provider_customer_id"]).to eq("gc_customer_123") + end + end + + context "when gocardless customer has no provider_mandate_id" do + let(:gocardless_customer) { create(:gocardless_customer, customer:, payment_provider: gocardless_provider) } + + it "does not create a payment method" do + gocardless_customer + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when payment method already exists for the gocardless customer" do + it "does not create a duplicate" do + create(:payment_method, customer:, payment_provider_customer: gocardless_customer, provider_method_id: "mandate_123") + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when a discarded payment method already exists for the gocardless customer" do + it "does not recreate the discarded payment method" do + create(:payment_method, customer:, payment_provider_customer: gocardless_customer, provider_method_id: "mandate_123").discard! + + expect { perform_job }.not_to change(PaymentMethod.unscoped, :count) + end + end + + context "with organization_id filter" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_gocardless_provider) { create(:gocardless_provider, organization: other_organization) } + let(:other_gocardless_customer) do + create(:gocardless_customer, customer: other_customer, payment_provider: other_gocardless_provider).tap do |c| + c.update!(provider_mandate_id: "mandate_other") + end + end + + it "only processes the specified organization" do + gocardless_customer + other_gocardless_customer + + expect { described_class.perform_now(organization.id) } + .to change(PaymentMethod, :count).by(1) + + expect(customer.payment_methods.count).to eq(1) + expect(other_customer.payment_methods.count).to eq(0) + end + end + + context "when there is more work after the batch" do + before { stub_const("#{described_class}::BATCH_SIZE", 1) } + + it "enqueues the next batch" do + customer_2 = create(:customer, organization:) + gocardless_customer + create(:gocardless_customer, customer: customer_2, payment_provider: gocardless_provider).tap do |c| + c.update!(provider_mandate_id: "mandate_456") + end + + expect { perform_job }.to have_enqueued_job(described_class) + end + end + + context "when there is no pending work" do + it "does not enqueue any job" do + expect { perform_job }.not_to have_enqueued_job + end + end +end diff --git a/spec/jobs/database_migrations/backfill_moneyhash_payment_methods_job_spec.rb b/spec/jobs/database_migrations/backfill_moneyhash_payment_methods_job_spec.rb new file mode 100644 index 0000000..f54c607 --- /dev/null +++ b/spec/jobs/database_migrations/backfill_moneyhash_payment_methods_job_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DatabaseMigrations::BackfillMoneyhashPaymentMethodsJob do + subject(:perform_job) { described_class.perform_now } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:moneyhash_provider) { create(:moneyhash_provider, organization:) } + let(:moneyhash_customer) do + create(:moneyhash_customer, customer:, payment_provider: moneyhash_provider).tap do |c| + c.update!(payment_method_id: "mh_pm_123", provider_customer_id: "mh_customer_123") + end + end + + context "when moneyhash customer has a payment_method_id" do + it "creates a payment method" do + moneyhash_customer + + expect { perform_job }.to change(PaymentMethod, :count).by(1) + end + + it "sets the correct attributes on the payment method" do + moneyhash_customer + perform_job + + payment_method = customer.payment_methods.first + expect(payment_method.provider_method_id).to eq("mh_pm_123") + expect(payment_method.provider_method_type).to eq("card") + expect(payment_method.payment_provider_customer).to eq(moneyhash_customer) + expect(payment_method.payment_provider).to eq(moneyhash_provider) + expect(payment_method.organization).to eq(organization) + expect(payment_method.is_default).to be true + expect(payment_method.details["from_migration"]).to be true + expect(payment_method.details["provider_customer_id"]).to eq("mh_customer_123") + end + end + + context "when moneyhash customer has no payment_method_id" do + let(:moneyhash_customer) { create(:moneyhash_customer, customer:, payment_provider: moneyhash_provider) } + + it "does not create a payment method" do + moneyhash_customer + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when payment method already exists for the moneyhash customer" do + it "does not create a duplicate" do + create(:payment_method, customer:, payment_provider_customer: moneyhash_customer, provider_method_id: "mh_pm_123") + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when a discarded payment method already exists for the moneyhash customer" do + it "does not recreate the discarded payment method" do + create(:payment_method, customer:, payment_provider_customer: moneyhash_customer, provider_method_id: "mh_pm_123").discard! + + expect { perform_job }.not_to change(PaymentMethod.unscoped, :count) + end + end + + context "with organization_id filter" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_moneyhash_provider) { create(:moneyhash_provider, organization: other_organization) } + let(:other_moneyhash_customer) do + create(:moneyhash_customer, customer: other_customer, payment_provider: other_moneyhash_provider).tap do |c| + c.update!(payment_method_id: "mh_pm_other") + end + end + + it "only processes the specified organization" do + moneyhash_customer + other_moneyhash_customer + + expect { described_class.perform_now(organization.id) } + .to change(PaymentMethod, :count).by(1) + + expect(customer.payment_methods.count).to eq(1) + expect(other_customer.payment_methods.count).to eq(0) + end + end + + context "when there is more work after the batch" do + before { stub_const("#{described_class}::BATCH_SIZE", 1) } + + it "enqueues the next batch" do + customer_2 = create(:customer, organization:) + moneyhash_customer + create(:moneyhash_customer, customer: customer_2, payment_provider: moneyhash_provider).tap do |c| + c.update!(payment_method_id: "mh_pm_456") + end + + expect { perform_job }.to have_enqueued_job(described_class) + end + end + + context "when there is no pending work" do + it "does not enqueue any job" do + expect { perform_job }.not_to have_enqueued_job + end + end +end diff --git a/spec/jobs/database_migrations/backfill_stripe_payment_method_card_details_job_spec.rb b/spec/jobs/database_migrations/backfill_stripe_payment_method_card_details_job_spec.rb new file mode 100644 index 0000000..ea7dbff --- /dev/null +++ b/spec/jobs/database_migrations/backfill_stripe_payment_method_card_details_job_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DatabaseMigrations::BackfillStripePaymentMethodCardDetailsJob do + subject(:perform_job) { described_class.perform_now } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_provider) } + let(:invoice) { create(:invoice, customer:, organization:) } + + let(:migration_payment_method) do + create( + :payment_method, + customer:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer, + provider_method_id: "pm_123", + details: {"from_migration" => true} + ) + end + + let(:payment_with_card_details) do + create( + :payment, + payable: invoice, + organization:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer, + provider_payment_method_id: "pm_123", + provider_payment_method_data: { + type: "card", + brand: "visa", + last4: "4242", + expiration_month: 12, + expiration_year: 2028 + } + ) + end + + context "when payment method has from_migration marker and matching payment data" do + it "updates the card details on the payment method" do + migration_payment_method + payment_with_card_details + perform_job + + details = migration_payment_method.reload.details + expect(details["type"]).to eq("card") + expect(details["brand"]).to eq("visa") + expect(details["last4"]).to eq("4242") + expect(details["expiration_month"]).to eq(12) + expect(details["expiration_year"]).to eq(2028) + expect(details["from_migration"]).to be true + end + end + + context "when payment data has no expiration fields (historical data)" do + let(:payment_without_expiry) do + create( + :payment, + payable: invoice, + organization:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer, + provider_payment_method_id: "pm_123", + provider_payment_method_data: {type: "card", brand: "visa", last4: "4242"} + ) + end + + it "updates only the available fields" do + migration_payment_method + payment_without_expiry + perform_job + + details = migration_payment_method.reload.details + expect(details["last4"]).to eq("4242") + expect(details["expiration_month"]).to be_nil + expect(details["expiration_year"]).to be_nil + end + end + + context "when payment method has from_migration marker but no matching payment data" do + it "does not update the payment method" do + migration_payment_method + + expect { perform_job }.not_to change { migration_payment_method.reload.details } + end + end + + context "when payment method was not created by migration" do + let(:regular_payment_method) do + create( + :payment_method, + customer:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer, + provider_method_id: "pm_123", + details: {last4: "9999", brand: "mastercard"} + ) + end + + it "does not touch it" do + regular_payment_method + payment_with_card_details + + expect { perform_job }.not_to change { regular_payment_method.reload.details } + end + end + + context "when payment method already has card details" do + let(:already_filled_payment_method) do + create( + :payment_method, + customer:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer, + provider_method_id: "pm_123", + details: {"from_migration" => true, "last4" => "4242", "brand" => "visa"} + ) + end + + it "does not update it again" do + already_filled_payment_method + payment_with_card_details + + expect { perform_job }.not_to change { already_filled_payment_method.reload.updated_at } + end + end + + context "when customer has multiple payments for the same method" do + let(:old_invoice) { create(:invoice, customer:, organization:) } + let(:recent_invoice) { create(:invoice, customer:, organization:) } + + it "uses card details from the most recent payment" do + migration_payment_method + + create( + :payment, + payable: old_invoice, + organization:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer, + provider_payment_method_id: "pm_123", + provider_payment_method_data: {type: "card", brand: "visa", last4: "1111"}, + created_at: 2.months.ago + ) + create( + :payment, + payable: recent_invoice, + organization:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer, + provider_payment_method_id: "pm_123", + provider_payment_method_data: {type: "card", brand: "visa", last4: "4242"}, + created_at: 1.day.ago + ) + + perform_job + + expect(migration_payment_method.reload.details["last4"]).to eq("4242") + end + end + + context "with organization_id filter" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_stripe_provider) { create(:stripe_provider, organization: other_organization) } + let(:other_stripe_customer) { create(:stripe_customer, customer: other_customer, payment_provider: other_stripe_provider) } + let(:other_invoice) { create(:invoice, customer: other_customer, organization: other_organization) } + let(:other_payment_method) do + create( + :payment_method, + customer: other_customer, + payment_provider: other_stripe_provider, + payment_provider_customer: other_stripe_customer, + provider_method_id: "pm_other", + details: {"from_migration" => true} + ) + end + + it "only updates payment methods for the specified organization" do + migration_payment_method + payment_with_card_details + other_payment_method + create( + :payment, + payable: other_invoice, + organization: other_organization, + payment_provider: other_stripe_provider, + payment_provider_customer: other_stripe_customer, + provider_payment_method_id: "pm_other", + provider_payment_method_data: {type: "card", brand: "mastercard", last4: "9999"} + ) + + described_class.perform_now(organization.id) + + expect(migration_payment_method.reload.details["last4"]).to eq("4242") + expect(other_payment_method.reload.details["last4"]).to be_nil + end + end + + context "when there is more work after the batch" do + before { stub_const("#{described_class}::BATCH_SIZE", 1) } + + it "enqueues the next batch" do + customer_2 = create(:customer, organization:) + stripe_customer_2 = create(:stripe_customer, customer: customer_2, payment_provider: stripe_provider) + invoice_2 = create(:invoice, customer: customer_2, organization:) + + migration_payment_method + payment_with_card_details + + create( + :payment_method, + customer: customer_2, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer_2, + provider_method_id: "pm_456", + details: {"from_migration" => true} + ) + create( + :payment, + payable: invoice_2, + organization:, + payment_provider: stripe_provider, + payment_provider_customer: stripe_customer_2, + provider_payment_method_id: "pm_456", + provider_payment_method_data: {type: "card", brand: "mastercard", last4: "9999"} + ) + + expect { perform_job }.to have_enqueued_job(described_class) + end + end +end diff --git a/spec/jobs/database_migrations/backfill_stripe_payment_methods_job_spec.rb b/spec/jobs/database_migrations/backfill_stripe_payment_methods_job_spec.rb new file mode 100644 index 0000000..8d523fa --- /dev/null +++ b/spec/jobs/database_migrations/backfill_stripe_payment_methods_job_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DatabaseMigrations::BackfillStripePaymentMethodsJob do + subject(:perform_job) { described_class.perform_now } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) do + create(:stripe_customer, customer:, payment_provider: stripe_provider).tap do |sc| + sc.update!(payment_method_id: "pm_123") + end + end + + context "when stripe customer has a payment_method_id" do + it "creates a payment method" do + stripe_customer + + expect { perform_job }.to change(PaymentMethod, :count).by(1) + end + + it "sets the correct attributes on the payment method" do + stripe_customer + perform_job + + payment_method = customer.payment_methods.first + expect(payment_method.provider_method_id).to eq("pm_123") + expect(payment_method.provider_method_type).to eq("card") + expect(payment_method.payment_provider_customer).to eq(stripe_customer) + expect(payment_method.payment_provider).to eq(stripe_provider) + expect(payment_method.organization).to eq(organization) + expect(payment_method.is_default).to be true + expect(payment_method.details["from_migration"]).to be true + end + end + + context "when stripe customer has no payment_method_id" do + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_provider) } + + it "does not create a payment method" do + stripe_customer + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when customer has a non-stripe provider customer" do + let(:gocardless_provider) { create(:gocardless_provider, organization:) } + let(:gocardless_customer) { create(:gocardless_customer, customer:, payment_provider: gocardless_provider) } + + it "does not create a payment method" do + gocardless_customer + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when customer has no provider customer at all" do + it "does not create a payment method" do + customer + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when payment method already exists for the stripe customer" do + it "does not create a duplicate" do + create(:payment_method, customer:, payment_provider_customer: stripe_customer, provider_method_id: "pm_123") + + expect { perform_job }.not_to change(PaymentMethod, :count) + end + end + + context "when a discarded payment method already exists for the stripe customer" do + it "does not recreate the discarded payment method" do + create(:payment_method, customer:, payment_provider_customer: stripe_customer, provider_method_id: "pm_123").discard! + + expect { perform_job }.not_to change(PaymentMethod.unscoped, :count) + end + end + + context "when multiple customers have stripe customers with payment_method_id" do + let(:customer_2) { create(:customer, organization:) } + let(:stripe_customer_2) do + create(:stripe_customer, customer: customer_2, payment_provider: stripe_provider).tap do |sc| + sc.update!(payment_method_id: "pm_456") + end + end + + it "creates a payment method for each customer" do + stripe_customer + stripe_customer_2 + + expect { perform_job }.to change(PaymentMethod, :count).by(2) + end + end + + context "with organization_id filter" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_stripe_provider) { create(:stripe_provider, organization: other_organization) } + let(:other_stripe_customer) do + create(:stripe_customer, customer: other_customer, payment_provider: other_stripe_provider).tap do |sc| + sc.update!(payment_method_id: "pm_other") + end + end + + it "only processes the specified organization" do + stripe_customer + other_stripe_customer + + expect { described_class.perform_now(organization.id) } + .to change(PaymentMethod, :count).by(1) + + expect(customer.payment_methods.count).to eq(1) + expect(other_customer.payment_methods.count).to eq(0) + end + end + + context "when there is more work after the batch" do + before { stub_const("#{described_class}::BATCH_SIZE", 1) } + + it "enqueues the next batch" do + customer_2 = create(:customer, organization:) + stripe_customer + create(:stripe_customer, customer: customer_2, payment_provider: stripe_provider).tap do |sc| + sc.update!(payment_method_id: "pm_456") + end + + expect { perform_job }.to have_enqueued_job(described_class) + end + end + + context "when there is no pending work" do + it "enqueues BackfillStripePaymentMethodCardDetailsJob" do + expect { perform_job } + .to have_enqueued_job(DatabaseMigrations::BackfillStripePaymentMethodCardDetailsJob) + end + end +end diff --git a/spec/jobs/database_migrations/fix_invoices_organization_sequential_id_job_spec.rb b/spec/jobs/database_migrations/fix_invoices_organization_sequential_id_job_spec.rb new file mode 100644 index 0000000..615a16f --- /dev/null +++ b/spec/jobs/database_migrations/fix_invoices_organization_sequential_id_job_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DatabaseMigrations::FixInvoicesOrganizationSequentialIdJob do + subject(:perform_job) { described_class.perform_now } + + let(:organization) { create(:organization, document_numbering: :per_organization) } + + context "when maximum sequential_id matches invoice count" do + it "does not change the last invoice" do + invoice_1 = create(:invoice, organization:, organization_sequential_id: 1) + invoice_2 = create(:invoice, organization:, organization_sequential_id: 2) + + expect { perform_job } + .to not_change { invoice_1.reload.organization_sequential_id } + .and not_change { invoice_2.reload.organization_sequential_id } + end + end + + context "when maximum sequential_id does not match invoice count" do + it "updates the last invoice sequential_id" do + invoice_1 = create(:invoice, organization:, organization_sequential_id: 1, created_at: 2.days.ago) + invoice_2 = create(:invoice, organization:, organization_sequential_id: 0, created_at: 1.day.ago) + + expect { perform_job } + .to change { invoice_2.reload.organization_sequential_id }.to(2) + .and not_change { invoice_1.reload.organization_sequential_id } + end + + it "does not consider self-billed invoices" do + invoice_1 = create(:invoice, organization:, organization_sequential_id: 0, created_at: 2.days.ago) + invoice_2 = create(:invoice, :self_billed, organization:, organization_sequential_id: 0, created_at: 1.day.ago) + + expect { perform_job } + .to change { invoice_1.reload.organization_sequential_id }.to(1) + .and not_change { invoice_2.reload.organization_sequential_id } + end + + it "does not consider draft invoices" do + invoice_1 = create(:invoice, organization:, organization_sequential_id: 0, created_at: 2.days.ago) + invoice_2 = create(:invoice, :draft, organization:, organization_sequential_id: 0, created_at: 1.day.ago) + + expect { perform_job } + .to change { invoice_1.reload.organization_sequential_id }.to(1) + .and not_change { invoice_2.reload.organization_sequential_id } + end + end + + context "when organization has no invoices" do + it "does nothing" do + expect { perform_job }.not_to raise_error + end + end + + context "when organization is on per_customer document_numbering" do + let(:organization) { create(:organization, document_numbering: :per_customer) } + + it "does not change any invoices" do + invoice = create(:invoice, organization:, organization_sequential_id: 0) + + expect { perform_job } + .not_to change { invoice.reload.organization_sequential_id } + end + end +end diff --git a/spec/jobs/dunning_campaigns/bulk_process_job_spec.rb b/spec/jobs/dunning_campaigns/bulk_process_job_spec.rb new file mode 100644 index 0000000..021dae7 --- /dev/null +++ b/spec/jobs/dunning_campaigns/bulk_process_job_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::BulkProcessJob do + let(:result) { BaseService::Result.new } + + before do + allow(DunningCampaigns::BulkProcessService) + .to receive(:call) + .and_return(result) + end + + context "when premium features are enabled", :premium do + it "calls DunningCampaigns::BulkProcessService service" do + described_class.perform_now + + expect(DunningCampaigns::BulkProcessService) + .to have_received(:call) + end + end + + it "does nothing" do + described_class.perform_now + + expect(DunningCampaigns::BulkProcessService) + .not_to have_received(:call) + end +end diff --git a/spec/jobs/dunning_campaigns/process_attempt_job_spec.rb b/spec/jobs/dunning_campaigns/process_attempt_job_spec.rb new file mode 100644 index 0000000..37d4c40 --- /dev/null +++ b/spec/jobs/dunning_campaigns/process_attempt_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::ProcessAttemptJob do + let(:result) { BaseService::Result.new } + let(:customer) { build :customer } + let(:dunning_campaign_threshold) { build :dunning_campaign_threshold } + + before do + allow(DunningCampaigns::ProcessAttemptService) + .to receive(:call) + .and_return(result) + end + + it "calls DunningCampaigns::ProcessAttemptService" do + described_class.perform_now(customer:, dunning_campaign_threshold:) + + expect(DunningCampaigns::ProcessAttemptService) + .to have_received(:call) + .with(customer:, dunning_campaign_threshold:) + end +end diff --git a/spec/jobs/events/pay_in_advance_job_spec.rb b/spec/jobs/events/pay_in_advance_job_spec.rb new file mode 100644 index 0000000..e2d8ad5 --- /dev/null +++ b/spec/jobs/events/pay_in_advance_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::PayInAdvanceJob do + let(:pay_in_advance_service) { instance_double(Events::PayInAdvanceService) } + let(:result) { BaseService::Result.new } + + let(:event) { build(:common_event) } + + it "calls the event pay in advance service" do + allow(Events::PayInAdvanceService).to receive(:call) + .with(event:) + .and_return(result) + + described_class.perform_now(event) + + expect(Events::PayInAdvanceService).to have_received(:call) + end +end diff --git a/spec/jobs/events/post_process_job_spec.rb b/spec/jobs/events/post_process_job_spec.rb new file mode 100644 index 0000000..cb46042 --- /dev/null +++ b/spec/jobs/events/post_process_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::PostProcessJob do + let(:result) { BaseService::Result.new } + + let(:event) do + create(:event) + end + + it "calls the event post process service" do + allow(Events::PostProcessService).to receive(:call) + .with(event:) + .and_return(result) + + described_class.perform_now(event:) + + expect(Events::PostProcessService).to have_received(:call) + end +end diff --git a/spec/jobs/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_job_spec.rb b/spec/jobs/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_job_spec.rb new file mode 100644 index 0000000..53f80f5 --- /dev/null +++ b/spec/jobs/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_job_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::Stores::Clickhouse::EnrichedStoreMigration::SubscriptionOrchestratorJob, type: :job do + let(:organization) { create(:organization) } + let(:migration) { create(:enriched_store_migration, :processing, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:subscription_migration) do + create(:enriched_store_subscription_migration, + enriched_store_migration: migration, + organization:, + subscription:) + end + + before do + allow(Events::Stores::Clickhouse::EnrichedStoreMigration::SubscriptionOrchestratorService) + .to receive(:call!) + end + + describe "#perform" do + it "calls the SubscriptionOrchestratorService" do + described_class.perform_now(subscription_migration) + + expect(Events::Stores::Clickhouse::EnrichedStoreMigration::SubscriptionOrchestratorService) + .to have_received(:call!).with(subscription_migration:) + end + end +end diff --git a/spec/jobs/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_job_spec.rb b/spec/jobs/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_job_spec.rb new file mode 100644 index 0000000..0defc55 --- /dev/null +++ b/spec/jobs/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_job_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::Stores::Clickhouse::EnrichedStoreMigration::WaitForEnrichmentJob, type: :job do + let(:organization) { create(:organization) } + let(:migration) { create(:enriched_store_migration, :processing, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :waiting_for_enrichment, + enriched_store_migration: migration, + organization:, + subscription:, + events_reprocessed_count: 100, + billable_metric_codes: ["code1"]) + end + + let(:service_result) do + result = Events::Stores::Clickhouse::EnrichedStoreMigration::WaitForEnrichmentService::Result.new + result.status = status + result.enriched_count = 50 + result + end + + before do + allow(Events::Stores::Clickhouse::EnrichedStoreMigration::WaitForEnrichmentService) + .to receive(:call!).and_return(service_result) + end + + describe "#perform" do + context "when service returns ready" do + let(:status) { :ready } + + it "does not re-enqueue" do + described_class.perform_now(subscription_migration, 1) + + expect(described_class).not_to have_been_enqueued + end + end + + context "when service returns not_ready" do + let(:status) { :not_ready } + + it "re-enqueues with backoff" do + freeze_time do + expect { described_class.perform_now(subscription_migration, 1) }.to have_enqueued_job(described_class) + .with(subscription_migration, 2) + .at(5.minutes.from_now) + end + end + end + + context "when service returns not_ready on later attempt" do + let(:status) { :not_ready } + + it "uses the correct backoff schedule" do + freeze_time do + expect { described_class.perform_now(subscription_migration, 2) }.to have_enqueued_job(described_class) + .with(subscription_migration, 3) + .at(10.minutes.from_now) + end + end + end + + context "when service returns max_attempts_reached" do + let(:status) { :max_attempts_reached } + + it "does not re-enqueue" do + expect { described_class.perform_now(subscription_migration, 10) }.not_to have_enqueued_job(described_class) + end + end + end +end diff --git a/spec/jobs/events/stores/clickhouse/pre_enrichment_check_job_spec.rb b/spec/jobs/events/stores/clickhouse/pre_enrichment_check_job_spec.rb new file mode 100644 index 0000000..d29e6e0 --- /dev/null +++ b/spec/jobs/events/stores/clickhouse/pre_enrichment_check_job_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Events::Stores::Clickhouse::PreEnrichmentCheckJob, type: :job do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:codes) { ["metric_code"] } + let(:batch_size) { 1000 } + let(:sleep_seconds) { 0 } + + let(:re_enrich_result) { BaseResult.new } + + before do + allow(Events::Stores::Clickhouse::ReEnrichSubscriptionEventsService).to receive(:call!) + .and_return(re_enrich_result) + end + + describe "#perform" do + it "calls ReEnrichSubscriptionEventsService" do + described_class.perform_now(subscription_id: subscription.id, codes:, batch_size:, sleep_seconds:) + + expect(Events::Stores::Clickhouse::ReEnrichSubscriptionEventsService).to have_received(:call!).with( + subscription:, codes:, reprocess: true, batch_size:, sleep_seconds: + ) + end + end +end diff --git a/spec/jobs/fees/create_pay_in_advance_job_spec.rb b/spec/jobs/fees/create_pay_in_advance_job_spec.rb new file mode 100644 index 0000000..09c52e9 --- /dev/null +++ b/spec/jobs/fees/create_pay_in_advance_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::CreatePayInAdvanceJob do + let(:charge) { create(:standard_charge, :pay_in_advance) } + let(:event) { create(:event) } + + let(:result) { BaseService::Result.new } + + it "delegates to the pay_in_advance aggregation service" do + allow(Fees::CreatePayInAdvanceService).to receive(:call) + .with(charge:, event:, billing_at: nil) + .and_return(result) + + described_class.perform_now(charge:, event:) + + expect(Fees::CreatePayInAdvanceService).to have_received(:call) + end +end diff --git a/spec/jobs/fixed_charges/cascade_child_plan_update_job_spec.rb b/spec/jobs/fixed_charges/cascade_child_plan_update_job_spec.rb new file mode 100644 index 0000000..5ff7081 --- /dev/null +++ b/spec/jobs/fixed_charges/cascade_child_plan_update_job_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::CascadeChildPlanUpdateJob do + subject(:perform) do + described_class.perform_now( + plan:, + cascade_fixed_charges_payload:, + timestamp: + ) + end + + let(:organization) { create(:organization) } + let(:parent_plan) { create(:plan, organization:) } + let(:plan) { create(:plan, organization:, parent: parent_plan) } + let(:add_on) { create(:add_on, organization:) } + let(:parent_fixed_charge) { create(:fixed_charge, plan: parent_plan, add_on:, units: 10) } + let(:timestamp) { Time.current.to_i } + let(:cascade_fixed_charges_payload) do + [ + { + action: :create, + parent_id: parent_fixed_charge.id, + code: parent_fixed_charge.code, + add_on_id: add_on.id, + charge_model: "standard", + units: 10, + pay_in_advance: true, + properties: {amount: "10"} + } + ] + end + + before do + allow(FixedCharges::CascadeChildPlanUpdateService).to receive(:call!).and_call_original + end + + it "calls the cascade child plan update service with correct parameters" do + perform + + expect(FixedCharges::CascadeChildPlanUpdateService) + .to have_received(:call!) + .with( + plan:, + cascade_fixed_charges_payload:, + timestamp: + ) + .once + end +end diff --git a/spec/jobs/fixed_charges/cascade_plan_update_job_spec.rb b/spec/jobs/fixed_charges/cascade_plan_update_job_spec.rb new file mode 100644 index 0000000..99fb0c1 --- /dev/null +++ b/spec/jobs/fixed_charges/cascade_plan_update_job_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::CascadePlanUpdateJob do + subject(:perform) do + described_class.perform_now( + plan:, + cascade_fixed_charges_payload:, + timestamp: + ) + end + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:timestamp) { Time.current.to_i } + let(:cascade_fixed_charges_payload) do + [ + { + action: :create, + add_on_id: "addon_id", + charge_model: "standard", + units: 10, + pay_in_advance: true, + properties: {amount: "10"} + } + ] + end + + context "when no children plans" do + it "does not queue a job" do + expect { perform }.not_to have_enqueued_job(FixedCharges::CascadeChildPlanUpdateJob) + end + end + + context "when plan has children plans but no active/pending subscriptions" do + before do + create(:subscription, :terminated, plan: create(:plan, organization:, parent: plan)) + end + + it "does not queue a job" do + expect { perform }.not_to have_enqueued_job(FixedCharges::CascadeChildPlanUpdateJob) + end + end + + context "when plan has children plans with active/pending subscriptions" do + let(:child_plan_1) { create(:plan, organization:, parent: plan) } + let(:child_plan_2) { create(:plan, organization:, parent: plan) } + + before do + create(:subscription, :active, plan: child_plan_1) + create(:subscription, :pending, plan: child_plan_2) + end + + it "queues a job for each child plan" do + expect { perform } + .to have_enqueued_job(FixedCharges::CascadeChildPlanUpdateJob).with(plan: child_plan_1, cascade_fixed_charges_payload:, timestamp:) + .and have_enqueued_job(FixedCharges::CascadeChildPlanUpdateJob).with(plan: child_plan_2, cascade_fixed_charges_payload:, timestamp:) + end + end +end diff --git a/spec/jobs/fixed_charges/create_children_batch_job_spec.rb b/spec/jobs/fixed_charges/create_children_batch_job_spec.rb new file mode 100644 index 0000000..d13b918 --- /dev/null +++ b/spec/jobs/fixed_charges/create_children_batch_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::CreateChildrenBatchJob do + let(:add_on) { create(:add_on) } + let(:organization) { add_on.organization } + let(:plan) { create(:plan, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, organization:) } + let(:child_ids) { ["child-id"] } + let(:payload) { {add_on_id: add_on.id, charge_model: "standard"} } + + before do + allow(FixedCharges::CreateChildrenService).to receive(:call!) + end + + it "calls the create children service" do + described_class.perform_now(child_ids:, fixed_charge:, payload:) + + expect(FixedCharges::CreateChildrenService).to have_received(:call!).with( + child_ids:, + fixed_charge:, + payload: + ) + end +end diff --git a/spec/jobs/fixed_charges/create_children_job_spec.rb b/spec/jobs/fixed_charges/create_children_job_spec.rb new file mode 100644 index 0000000..52b8c4b --- /dev/null +++ b/spec/jobs/fixed_charges/create_children_job_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::CreateChildrenJob do + let(:add_on) { create(:add_on) } + let(:organization) { add_on.organization } + let(:plan) { create(:plan, organization:) } + let(:child_plan) { create(:plan, organization:, parent_id: plan.id) } + let(:child_plan2) { create(:plan, organization:, parent_id: plan.id) } + let(:subscription) { create(:subscription, plan: child_plan) } + let(:subscription2) { create(:subscription, plan: child_plan2, status: :terminated) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, organization:) } + let(:child_ids) { [child_plan.id] } + + let(:params) do + { + add_on_id: add_on.id, + charge_model: "standard", + invoice_display_name: "fixed_charge1" + } + end + + before do + subscription + subscription2 + child_plan2 + allow(FixedCharges::CreateChildrenBatchJob).to receive(:perform_later) + .with(child_ids:, fixed_charge:, payload: params) + .and_call_original + end + + it "calls the batch job" do + described_class.perform_now(fixed_charge:, payload: params) + + expect(FixedCharges::CreateChildrenBatchJob).to have_received(:perform_later).once + end +end diff --git a/spec/jobs/fixed_charges/destroy_children_job_spec.rb b/spec/jobs/fixed_charges/destroy_children_job_spec.rb new file mode 100644 index 0000000..4cf7935 --- /dev/null +++ b/spec/jobs/fixed_charges/destroy_children_job_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::DestroyChildrenJob do + let(:fixed_charge) { create(:fixed_charge, :deleted) } + + before do + allow(FixedCharges::DestroyChildrenService).to receive(:call!) + .and_call_original + end + + it "calls the service with the fixed charge" do + described_class.perform_now(fixed_charge.id) + + expect(FixedCharges::DestroyChildrenService).to have_received(:call!).with(fixed_charge).once + end + + context "when fixed charge is not found" do + it "calls the service with nil" do + described_class.perform_now("non-existent-id") + + expect(FixedCharges::DestroyChildrenService).to have_received(:call!).with(nil).once + end + end + + context "when fixed charge exists but is not deleted" do + let(:fixed_charge) { create(:fixed_charge) } + + it "still calls the service" do + described_class.perform_now(fixed_charge.id) + + expect(FixedCharges::DestroyChildrenService).to have_received(:call!).with(fixed_charge).once + end + end +end diff --git a/spec/jobs/fixed_charges/update_children_batch_job_spec.rb b/spec/jobs/fixed_charges/update_children_batch_job_spec.rb new file mode 100644 index 0000000..0c52031 --- /dev/null +++ b/spec/jobs/fixed_charges/update_children_batch_job_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::UpdateChildrenBatchJob do + let(:organization) { create(:organization) } + let(:add_on) { create(:add_on, organization:) } + let(:plan) { create(:plan, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:child_fixed_charge) { create(:fixed_charge, parent_id: fixed_charge.id, add_on:) } + let(:child_fixed_charge2) { create(:fixed_charge, parent_id: fixed_charge.id, add_on:) } + let(:old_parent_attrs) { fixed_charge.attributes } + let(:child_ids) { [child_fixed_charge.id, child_fixed_charge2.id] } + let(:params) do + { + properties: {} + } + end + + before do + allow(FixedCharges::UpdateChildrenService) + .to receive(:call!) + .with(fixed_charge:, child_ids:, params:, old_parent_attrs:) + .and_call_original + end + + it "calls the children service" do + described_class.perform_now(child_ids:, params:, old_parent_attrs:) + + expect(FixedCharges::UpdateChildrenService).to have_received(:call!) + end +end diff --git a/spec/jobs/fixed_charges/update_children_job_spec.rb b/spec/jobs/fixed_charges/update_children_job_spec.rb new file mode 100644 index 0000000..0eb4fee --- /dev/null +++ b/spec/jobs/fixed_charges/update_children_job_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::UpdateChildrenJob do + let(:organization) { create(:organization) } + let(:add_on) { create(:add_on, organization:) } + let(:plan) { create(:plan, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:child_plan1) { create(:plan, parent_id: plan.id, organization:) } + let(:child_plan2) { create(:plan, parent_id: plan.id, organization:) } + let(:child_fixed_charge) { create(:fixed_charge, parent_id: fixed_charge.id, plan: child_plan1, add_on:) } + let(:child_fixed_charge2) { create(:fixed_charge, parent_id: fixed_charge.id, plan: child_plan2, add_on:) } + let(:subscription) { create(:subscription, plan: child_plan1) } + let(:subscription2) { create(:subscription, plan: child_plan2, status: :terminated) } + let(:old_parent_attrs) { fixed_charge.attributes } + let(:params) do + { + properties: {} + } + end + + before do + child_plan1 + child_plan2 + subscription + subscription2 + allow(FixedCharges::UpdateChildrenBatchJob) + .to receive(:perform_later) + .with(child_ids: [child_fixed_charge.id], params:, old_parent_attrs:) + .and_call_original + end + + it "calls the batch jobs" do + described_class.perform_now(params:, old_parent_attrs:) + + expect(FixedCharges::UpdateChildrenBatchJob).to have_received(:perform_later).once + end +end diff --git a/spec/jobs/inbound_webhooks/process_job_spec.rb b/spec/jobs/inbound_webhooks/process_job_spec.rb new file mode 100644 index 0000000..d881d2c --- /dev/null +++ b/spec/jobs/inbound_webhooks/process_job_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InboundWebhooks::ProcessJob do + subject(:process_job) { described_class } + + let(:inbound_webhook) { create :inbound_webhook } + let(:result) { BaseService::Result.new } + + before do + allow(InboundWebhooks::ProcessService).to receive(:call).and_return(result) + end + + it "calls the process webhook service" do + process_job.perform_now(inbound_webhook:) + + expect(InboundWebhooks::ProcessService) + .to have_received(:call) + .with(inbound_webhook:) + end + + context "when result is a failure" do + let(:result) do + BaseService::Result.new.service_failure!(code: "error", message: "error message") + end + + it "raises an error" do + expect { process_job.perform_now(inbound_webhook:) } + .to raise_error(BaseService::FailedResult) + end + end +end diff --git a/spec/jobs/integration_customers/create_job_spec.rb b/spec/jobs/integration_customers/create_job_spec.rb new file mode 100644 index 0000000..22a3192 --- /dev/null +++ b/spec/jobs/integration_customers/create_job_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::CreateJob do + let(:integration) { create(:netsuite_integration) } + let(:customer) { create(:customer) } + let(:integration_customer_params) do + { + sync_with_provider: true + } + end + + describe "#perform" do + subject(:create_job) { described_class } + + before do + allow(IntegrationCustomers::CreateService).to receive(:call!) + end + + it "calls the create service" do + described_class.perform_now(integration_customer_params:, integration:, customer:) + + expect(IntegrationCustomers::CreateService).to have_received(:call!) + end + end + + describe "#lock_key_arguments" do + it "returns customer and integration for the lock key" do + job = described_class.new( + integration_customer_params: integration_customer_params, + integration: integration, + customer: customer + ) + + expect(job.lock_key_arguments).to eq([integration, customer]) + end + end +end diff --git a/spec/jobs/integration_customers/update_job_spec.rb b/spec/jobs/integration_customers/update_job_spec.rb new file mode 100644 index 0000000..7352933 --- /dev/null +++ b/spec/jobs/integration_customers/update_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::UpdateJob do + subject(:create_job) { described_class } + + let(:integration) { create(:netsuite_integration) } + let(:integration_customer) { create(:netsuite_customer, integration:) } + let(:result) { BaseService::Result.new } + let(:integration_customer_params) do + { + sync_with_provider: true + } + end + + before do + allow(IntegrationCustomers::UpdateService).to receive(:call).and_return(result) + end + + it "calls the update service" do + described_class.perform_now(integration_customer_params:, integration:, integration_customer:) + + expect(IntegrationCustomers::UpdateService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/credit_notes/create_job_spec.rb b/spec/jobs/integrations/aggregator/credit_notes/create_job_spec.rb new file mode 100644 index 0000000..22d9f95 --- /dev/null +++ b/spec/jobs/integrations/aggregator/credit_notes/create_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::CreateJob do + subject(:create_job) { described_class } + + let(:credit_note) { create(:credit_note) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::CreditNotes::CreateService).to receive(:call).and_return(result) + end + + it "calls the aggregator create credit_note service" do + described_class.perform_now(credit_note:) + + expect(Integrations::Aggregator::CreditNotes::CreateService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/fetch_items_job_spec.rb b/spec/jobs/integrations/aggregator/fetch_items_job_spec.rb new file mode 100644 index 0000000..c26caba --- /dev/null +++ b/spec/jobs/integrations/aggregator/fetch_items_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::FetchItemsJob do + subject(:fetch_items_job) { described_class } + + let(:integration) { create(:netsuite_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::ItemsService).to receive(:call).and_return(result) + end + + it "calls the items service" do + described_class.perform_now(integration:) + + expect(Integrations::Aggregator::ItemsService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/invoices/create_job_spec.rb b/spec/jobs/integrations/aggregator/invoices/create_job_spec.rb new file mode 100644 index 0000000..8da5264 --- /dev/null +++ b/spec/jobs/integrations/aggregator/invoices/create_job_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::CreateJob do + subject(:create_job) { described_class } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Invoices::CreateService).to receive(:call).and_return(result) + end + + it "calls the aggregator create invoice service" do + described_class.perform_now(invoice:) + + expect(Integrations::Aggregator::Invoices::CreateService).to have_received(:call) + end + + describe "Net::ReadTimeout retry" do + before do + allow(Integrations::Aggregator::Invoices::CreateService).to receive(:call).and_raise(Net::ReadTimeout.new) + end + + context "when the invoice is for a NetSuite integration" do + let(:integration) { create(:netsuite_integration, organization:) } + + before { create(:netsuite_customer, integration:, customer:) } + + it "schedules the next attempt at least 6 minutes later" do + freeze_time do + described_class.perform_now(invoice:) + + retry_at = ActiveJob::Base.queue_adapter.enqueued_jobs.last[:at] + # NOTE: ActiveJob applies up to 15% positive jitter on top of the configured wait, + # so the retry lands in [6 minutes, 6 minutes + 15%] from now. + expect(retry_at).to be_between(6.minutes.from_now.to_f, 7.minutes.from_now.to_f) + end + end + end + + context "when the invoice is for a non-NetSuite integration" do + let(:integration) { create(:xero_integration, organization:) } + + before { create(:xero_customer, integration:, customer:) } + + it "schedules the next attempt with polynomial backoff" do + freeze_time do + described_class.perform_now(invoice:) + + retry_at = ActiveJob::Base.queue_adapter.enqueued_jobs.last[:at] + # NOTE: First polynomial retry is ~3s (1**4 + 2) plus up to 15% jitter; well under a minute. + expect(retry_at).to be < 1.minute.from_now.to_f + end + end + end + end +end diff --git a/spec/jobs/integrations/aggregator/invoices/hubspot/create_customer_association_job_spec.rb b/spec/jobs/integrations/aggregator/invoices/hubspot/create_customer_association_job_spec.rb new file mode 100644 index 0000000..ef55a9d --- /dev/null +++ b/spec/jobs/integrations/aggregator/invoices/hubspot/create_customer_association_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Hubspot::CreateCustomerAssociationJob do + subject(:create_job) { described_class } + + let(:invoice) { create(:invoice) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Invoices::Hubspot::CreateCustomerAssociationService) + .to receive(:call).and_return(result) + end + + it "calls the aggregator create invoice hubspot service" do + described_class.perform_now(invoice:) + + expect(Integrations::Aggregator::Invoices::Hubspot::CreateCustomerAssociationService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/invoices/hubspot/create_job_spec.rb b/spec/jobs/integrations/aggregator/invoices/hubspot/create_job_spec.rb new file mode 100644 index 0000000..4c8c8b5 --- /dev/null +++ b/spec/jobs/integrations/aggregator/invoices/hubspot/create_job_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Hubspot::CreateJob do + subject(:create_job) { described_class } + + let(:invoice) { create(:invoice) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Invoices::Hubspot::CreateService).to receive(:call).and_return(result) + end + + context "when the service call is not successful" do + before do + allow(result).to receive(:success?).and_return(false) + allow(result).to receive(:raise_if_error!).and_raise(StandardError) + end + + it "raises an error" do + expect { create_job.perform_now(invoice:) }.to raise_error(StandardError) + end + end + + context "when the service call is successful" do + it "calls the aggregator create invoice hubspot service" do + described_class.perform_now(invoice:) + + expect(Integrations::Aggregator::Invoices::Hubspot::CreateService).to have_received(:call) + end + + it "enqueues the aggregator create customer association invoice job" do + expect do + described_class.perform_now(invoice:) + end.to have_enqueued_job(Integrations::Aggregator::Invoices::Hubspot::CreateCustomerAssociationJob).with(invoice:) + end + end +end diff --git a/spec/jobs/integrations/aggregator/invoices/hubspot/update_job_spec.rb b/spec/jobs/integrations/aggregator/invoices/hubspot/update_job_spec.rb new file mode 100644 index 0000000..f1c386f --- /dev/null +++ b/spec/jobs/integrations/aggregator/invoices/hubspot/update_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Hubspot::UpdateJob do + subject(:create_job) { described_class } + + let(:invoice) { create(:invoice) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Invoices::Hubspot::UpdateService).to receive(:call).and_return(result) + end + + it "calls the aggregator create invoice hubspot service" do + described_class.perform_now(invoice:) + + expect(Integrations::Aggregator::Invoices::Hubspot::UpdateService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/payments/create_job_spec.rb b/spec/jobs/integrations/aggregator/payments/create_job_spec.rb new file mode 100644 index 0000000..507189a --- /dev/null +++ b/spec/jobs/integrations/aggregator/payments/create_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Payments::CreateJob do + subject(:create_job) { described_class } + + let(:payment) { create(:payment) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Payments::CreateService).to receive(:call).and_return(result) + end + + it "calls the aggregator create payment service" do + described_class.perform_now(payment:) + + expect(Integrations::Aggregator::Payments::CreateService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/perform_sync_job_spec.rb b/spec/jobs/integrations/aggregator/perform_sync_job_spec.rb new file mode 100644 index 0000000..1274330 --- /dev/null +++ b/spec/jobs/integrations/aggregator/perform_sync_job_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::PerformSyncJob do + subject(:perform_sync_job) { described_class.perform_now(integration:, sync_items:) } + + let(:integration) { create(:netsuite_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::SyncService).to receive(:call).and_return(result) + allow(Integrations::Aggregator::ItemsService).to receive(:call).and_return(result) + + perform_sync_job + end + + context "when sync_items is true" do + let(:sync_items) { true } + + it "calls the aggregator sync service" do + expect(Integrations::Aggregator::SyncService).to have_received(:call) + end + + it "calls the aggregator items service" do + expect(Integrations::Aggregator::ItemsService).to have_received(:call) + end + end + + context "when sync_items is false" do + let(:sync_items) { false } + + it "calls the aggregator sync service" do + expect(Integrations::Aggregator::SyncService).to have_received(:call) + end + + it "does not call the aggregator items service" do + expect(Integrations::Aggregator::ItemsService).not_to have_received(:call) + end + end +end diff --git a/spec/jobs/integrations/aggregator/send_restlet_endpoint_job_spec.rb b/spec/jobs/integrations/aggregator/send_restlet_endpoint_job_spec.rb new file mode 100644 index 0000000..d735d09 --- /dev/null +++ b/spec/jobs/integrations/aggregator/send_restlet_endpoint_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::SendRestletEndpointJob do + subject(:send_endpoint_job) { described_class } + + let(:integration) { create(:netsuite_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::SendRestletEndpointService).to receive(:call).and_return(result) + end + + it "sends restlet url to the aggregator" do + described_class.perform_now(integration:) + + expect(Integrations::Aggregator::SendRestletEndpointService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/subscriptions/hubspot/create_customer_association_job_spec.rb b/spec/jobs/integrations/aggregator/subscriptions/hubspot/create_customer_association_job_spec.rb new file mode 100644 index 0000000..404636b --- /dev/null +++ b/spec/jobs/integrations/aggregator/subscriptions/hubspot/create_customer_association_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Hubspot::CreateCustomerAssociationJob do + subject(:create_job) { described_class } + + let(:subscription) { create(:subscription) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Subscriptions::Hubspot::CreateCustomerAssociationService) + .to receive(:call).and_return(result) + end + + it "calls the aggregator create subscription hubspot service" do + described_class.perform_now(subscription:) + + expect(Integrations::Aggregator::Subscriptions::Hubspot::CreateCustomerAssociationService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/subscriptions/hubspot/create_job_spec.rb b/spec/jobs/integrations/aggregator/subscriptions/hubspot/create_job_spec.rb new file mode 100644 index 0000000..7889585 --- /dev/null +++ b/spec/jobs/integrations/aggregator/subscriptions/hubspot/create_job_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Hubspot::CreateJob do + subject(:create_job) { described_class } + + let(:subscription) { create(:subscription) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Subscriptions::Hubspot::CreateService).to receive(:call).and_return(result) + end + + context "when the service call is not successful" do + before do + allow(result).to receive(:success?).and_return(false) + allow(result).to receive(:raise_if_error!).and_raise(StandardError) + end + + it "raises an error" do + expect { create_job.perform_now(subscription:) }.to raise_error(StandardError) + end + end + + context "when the service call is successful" do + it "calls the aggregator create subscription hubspot service" do + described_class.perform_now(subscription:) + + expect(Integrations::Aggregator::Subscriptions::Hubspot::CreateService).to have_received(:call) + end + + it "enqueues the aggregator create customer association subscription job" do + expect do + described_class.perform_now(subscription:) + end.to have_enqueued_job(Integrations::Aggregator::Subscriptions::Hubspot::CreateCustomerAssociationJob).with(subscription:) + end + end +end diff --git a/spec/jobs/integrations/aggregator/subscriptions/hubspot/update_job_spec.rb b/spec/jobs/integrations/aggregator/subscriptions/hubspot/update_job_spec.rb new file mode 100644 index 0000000..37f627c --- /dev/null +++ b/spec/jobs/integrations/aggregator/subscriptions/hubspot/update_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob do + subject(:create_job) { described_class } + + let(:subscription) { create(:subscription) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Subscriptions::Hubspot::UpdateService).to receive(:call).and_return(result) + end + + it "calls the aggregator create subscription hubspot service" do + described_class.perform_now(subscription:) + + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateService).to have_received(:call) + end +end diff --git a/spec/jobs/integrations/aggregator/sync_custom_objects_and_properties_job_spec.rb b/spec/jobs/integrations/aggregator/sync_custom_objects_and_properties_job_spec.rb new file mode 100644 index 0000000..282a9ca --- /dev/null +++ b/spec/jobs/integrations/aggregator/sync_custom_objects_and_properties_job_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::SyncCustomObjectsAndPropertiesJob do + describe "#perform" do + subject(:sync_job) { described_class } + + let(:integration) { create(:hubspot_integration) } + + before do + allow(Integrations::Hubspot::Subscriptions::DeployObjectService).to receive(:call) + allow(Integrations::Hubspot::Invoices::DeployObjectService).to receive(:call) + allow(Integrations::Hubspot::Companies::DeployPropertiesService).to receive(:call) + allow(Integrations::Hubspot::Contacts::DeployPropertiesService).to receive(:call) + end + + it "call all the services with the current integration" do + sync_job.perform_now(integration: integration) + + expect(Integrations::Hubspot::Subscriptions::DeployObjectService).to have_received(:call).with(integration:) + expect(Integrations::Hubspot::Invoices::DeployObjectService).to have_received(:call).with(integration:) + expect(Integrations::Hubspot::Companies::DeployPropertiesService).to have_received(:call).with(integration:) + expect(Integrations::Hubspot::Contacts::DeployPropertiesService).to have_received(:call).with(integration:) + end + end +end diff --git a/spec/jobs/integrations/avalara/fetch_company_id_job_spec.rb b/spec/jobs/integrations/avalara/fetch_company_id_job_spec.rb new file mode 100644 index 0000000..b14f2f8 --- /dev/null +++ b/spec/jobs/integrations/avalara/fetch_company_id_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Avalara::FetchCompanyIdJob do + describe "#perform" do + subject(:job) { described_class } + + let(:service) { instance_double(Integrations::Avalara::FetchCompanyIdService) } + let(:integration) { create(:avalara_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Avalara::FetchCompanyIdService).to receive(:call).and_return(result) + end + + it "calls dedicated service" do + described_class.perform_now(integration:) + + expect(Integrations::Avalara::FetchCompanyIdService).to have_received(:call) + end + end +end diff --git a/spec/jobs/integrations/hubspot/companies/deploy_properties_job_spec.rb b/spec/jobs/integrations/hubspot/companies/deploy_properties_job_spec.rb new file mode 100644 index 0000000..4dad2ce --- /dev/null +++ b/spec/jobs/integrations/hubspot/companies/deploy_properties_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Companies::DeployPropertiesJob do + describe "#perform" do + subject(:deploy_properties_job) { described_class } + + let(:integration) { create(:hubspot_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Hubspot::Companies::DeployPropertiesService).to receive(:call).and_return(result) + end + + it "calls the DeployPropertiesService to sync companies custom properties" do + deploy_properties_job.perform_now(integration:) + expect(Integrations::Hubspot::Companies::DeployPropertiesService).to have_received(:call) + end + end +end diff --git a/spec/jobs/integrations/hubspot/contacts/deploy_properties_job_spec.rb b/spec/jobs/integrations/hubspot/contacts/deploy_properties_job_spec.rb new file mode 100644 index 0000000..11e56f4 --- /dev/null +++ b/spec/jobs/integrations/hubspot/contacts/deploy_properties_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Contacts::DeployPropertiesJob do + describe "#perform" do + subject(:deploy_properties_job) { described_class } + + let(:integration) { create(:hubspot_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Hubspot::Contacts::DeployPropertiesService).to receive(:call).and_return(result) + end + + it "calls the DeployPropertiesService to sync contacts custom properties" do + deploy_properties_job.perform_now(integration:) + + expect(Integrations::Hubspot::Contacts::DeployPropertiesService).to have_received(:call) + end + end +end diff --git a/spec/jobs/integrations/hubspot/invoices/deploy_object_job_spec.rb b/spec/jobs/integrations/hubspot/invoices/deploy_object_job_spec.rb new file mode 100644 index 0000000..089adfe --- /dev/null +++ b/spec/jobs/integrations/hubspot/invoices/deploy_object_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Invoices::DeployObjectJob do + describe "#perform" do + subject(:deploy_object_job) { described_class } + + let(:integration) { create(:hubspot_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Hubspot::Invoices::DeployObjectService).to receive(:call).and_return(result) + end + + it "calls the DeployObjectService to deploy invoice custom object" do + deploy_object_job.perform_now(integration:) + + expect(Integrations::Hubspot::Invoices::DeployObjectService).to have_received(:call) + end + end +end diff --git a/spec/jobs/integrations/hubspot/save_portal_id_job_spec.rb b/spec/jobs/integrations/hubspot/save_portal_id_job_spec.rb new file mode 100644 index 0000000..934ee0a --- /dev/null +++ b/spec/jobs/integrations/hubspot/save_portal_id_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::SavePortalIdJob do + describe "#perform" do + subject(:job) { described_class } + + let(:service) { instance_double(Integrations::Hubspot::SavePortalIdService) } + let(:integration) { create(:hubspot_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Hubspot::SavePortalIdService).to receive(:call).and_return(result) + end + + it "saves portal id to the integration" do + described_class.perform_now(integration:) + + expect(Integrations::Hubspot::SavePortalIdService).to have_received(:call) + end + end +end diff --git a/spec/jobs/integrations/hubspot/subscriptions/deploy_object_job_spec.rb b/spec/jobs/integrations/hubspot/subscriptions/deploy_object_job_spec.rb new file mode 100644 index 0000000..dedc0da --- /dev/null +++ b/spec/jobs/integrations/hubspot/subscriptions/deploy_object_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Subscriptions::DeployObjectJob do + describe "#perform" do + subject(:deploy_object_job) { described_class } + + let(:integration) { create(:hubspot_integration) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Hubspot::Subscriptions::DeployObjectService).to receive(:call).and_return(result) + end + + it "calls the DeployObjectService to deploy subscription custom object" do + deploy_object_job.perform_now(integration:) + + expect(Integrations::Hubspot::Subscriptions::DeployObjectService).to have_received(:call) + end + end +end diff --git a/spec/jobs/invoices/create_pay_in_advance_charge_job_spec.rb b/spec/jobs/invoices/create_pay_in_advance_charge_job_spec.rb new file mode 100644 index 0000000..2f2ca3f --- /dev/null +++ b/spec/jobs/invoices/create_pay_in_advance_charge_job_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CreatePayInAdvanceChargeJob do + describe "#perform" do + let(:charge) { create(:standard_charge, :pay_in_advance, invoiceable: true) } + let(:event) { create(:event) } + let(:timestamp) { Time.current.to_i } + + let(:invoice) { nil } + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::CreatePayInAdvanceChargeService).to receive(:call) + .with(charge:, event:, timestamp:) + .and_return(result) + end + + it "calls the create pay in advance charge service" do + described_class.perform_now(charge:, event:, timestamp:) + + expect(Invoices::CreatePayInAdvanceChargeService).to have_received(:call) + end + + context "when result is a failure" do + let(:result) do + BaseService::Result.new.single_validation_failure!(error_code: "error") + end + + it "raises an error" do + expect do + described_class.perform_now(charge:, event:, timestamp:) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::CreatePayInAdvanceChargeService).to have_received(:call) + end + + context "with a previously created invoice" do + let(:invoice) { create(:invoice, :generating) } + + it "raises an error" do + expect do + described_class.perform_now(charge:, event:, timestamp:, invoice:) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::CreatePayInAdvanceChargeService).to have_received(:call) + end + end + + context "when no invoice is attached to the result" do + let(:result_invoice) { create(:invoice, :draft) } + + before { result.invoice = nil } + + it "raises an error" do + expect do + described_class.perform_now(charge:, event:, timestamp:) + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::CreatePayInAdvanceChargeService).to have_received(:call) + end + end + end + + describe "retry_on" do + [ + [Sequenced::SequenceError.new("Sequenced::SequenceError"), 15], + [Customers::FailedToAcquireLock.new("customer-1-prepaid_credit"), 25], + [ActiveRecord::StaleObjectError.new("Attempted to update a stale object: Wallet."), 25], + [BaseService::ThrottlingError.new(provider_name: "Stripe"), 25] + ].each do |error, attempts| + error_class = error.class + + context "when a #{error_class} error is raised" do + before do + allow(Invoices::CreatePayInAdvanceChargeService).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + described_class.perform_later(charge:, event:, timestamp:) + end.to raise_error(error_class) + end + end + end + end + end + end +end diff --git a/spec/jobs/invoices/create_pay_in_advance_fixed_charges_job_spec.rb b/spec/jobs/invoices/create_pay_in_advance_fixed_charges_job_spec.rb new file mode 100644 index 0000000..df50971 --- /dev/null +++ b/spec/jobs/invoices/create_pay_in_advance_fixed_charges_job_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CreatePayInAdvanceFixedChargesJob do + subject(:perform_now) { described_class.perform_now(subscription, timestamp) } + + let(:subscription) { create(:subscription) } + let(:timestamp) { Time.current.to_i } + let(:result) { BaseService::Result.new } + + describe "#perform" do + before do + allow(Invoices::CreatePayInAdvanceFixedChargesService).to receive(:call) + .with(subscription:, timestamp:).and_return(result) + end + + it "calls the create pay in advance fixed charges service" do + perform_now + + expect(Invoices::CreatePayInAdvanceFixedChargesService).to have_received(:call) + end + + context "when result is a failure" do + let(:result) do + BaseService::Result.new.single_validation_failure!(error_code: "error") + end + + it "raises an error" do + expect do + perform_now + end.to raise_error(BaseService::FailedResult) + + expect(Invoices::CreatePayInAdvanceFixedChargesService).to have_received(:call) + end + end + + context "when result is a tax error" do + let(:result) do + BaseService::Result.new.validation_failure!(errors: {tax_error: ["taxDateTooFarInFuture"]}) + end + + it "does not raise an error" do + expect { perform_now }.not_to raise_error + + expect(Invoices::CreatePayInAdvanceFixedChargesService).to have_received(:call) + end + end + + describe "retry_on" do + [ + [Sequenced::SequenceError.new("Sequenced::SequenceError"), 15], + [Customers::FailedToAcquireLock.new("customer-1-prepaid_credit"), 25], + [ActiveRecord::StaleObjectError.new("Attempted to update a stale object: Wallet."), 25], + [BaseService::ThrottlingError.new(provider_name: "Stripe"), 25] + ].each do |error, attempts| + error_class = error.class + + context "when a #{error_class} error is raised" do + before do + allow(Invoices::CreatePayInAdvanceFixedChargesService).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + described_class.perform_later(subscription, timestamp) + end.to raise_error(error_class) + end + end + end + end + end + end +end diff --git a/spec/jobs/invoices/finalize_all_job_spec.rb b/spec/jobs/invoices/finalize_all_job_spec.rb new file mode 100644 index 0000000..e4f799f --- /dev/null +++ b/spec/jobs/invoices/finalize_all_job_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::FinalizeAllJob do + subject(:finalize_all_job) { described_class } + + let(:finalize_batch_service) { instance_double(Invoices::FinalizeBatchService) } + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, :draft, organization:) } + + context "when succesfully fetching taxes" do + before do + allow(Invoices::FinalizeBatchService).to receive(:new).and_return(finalize_batch_service) + allow(finalize_batch_service).to receive(:call).and_return(result) + end + + it "calls the retry batch service" do + finalize_all_job.perform_now(organization:, invoice_ids: [invoice.id]) + + expect(Invoices::FinalizeBatchService).to have_received(:new) + expect(finalize_batch_service).to have_received(:call) + end + end + + context "when there was a tax fetching error in FinalizeBatch service" do + let(:integration_customer) { create(:anrok_customer, customer: invoice.customer) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration: integration_customer.integration, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(p) + end + + before do + integration_collection_mapping + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "does not throw an error when it is a tax error" do + expect { described_class.perform_now(organization: invoice.organization, invoice_ids: [invoice.id]) } + .not_to raise_error + end + end +end diff --git a/spec/jobs/invoices/finalize_job_spec.rb b/spec/jobs/invoices/finalize_job_spec.rb new file mode 100644 index 0000000..36b0ccc --- /dev/null +++ b/spec/jobs/invoices/finalize_job_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::FinalizeJob do + let(:invoice) { create(:invoice) } + + let(:result) { BaseService::Result.new } + + it "delegates to the RefreshDraftAndFinalizeService service" do + allow(Invoices::RefreshDraftAndFinalizeService).to receive(:call) + .with(invoice:) + .and_return(result) + + described_class.perform_now(invoice) + + expect(Invoices::RefreshDraftAndFinalizeService).to have_received(:call) + end + + context "when there was a tax fetching error in RefreshDraftAndFinalize service" do + let(:integration_customer) { create(:anrok_customer, customer: invoice.customer) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration: integration_customer.integration, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(p) + end + + before do + integration_collection_mapping + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "does not throw an error when it is a tax error" do + expect { described_class.perform_now(invoice) }.not_to raise_error + end + end + + describe "retry_on" do + [ + [Customers::FailedToAcquireLock.new("customer-1-prepaid_credit"), 25], + [ActiveRecord::StaleObjectError.new("Attempted to update a stale object: Wallet."), 25], + [Sequenced::SequenceError.new("Sequenced::SequenceError"), 15] + ].each do |error, attempts| + error_class = error.class + + context "when a #{error_class} error is raised" do + before do + allow(Invoices::RefreshDraftAndFinalizeService).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + described_class.perform_later(invoice) + end.to raise_error(error_class) + end + end + end + end + end +end diff --git a/spec/jobs/invoices/finalize_pending_vies_invoice_job_spec.rb b/spec/jobs/invoices/finalize_pending_vies_invoice_job_spec.rb new file mode 100644 index 0000000..c39b123 --- /dev/null +++ b/spec/jobs/invoices/finalize_pending_vies_invoice_job_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::FinalizePendingViesInvoiceJob do + let(:invoice) { create(:invoice, :pending, tax_status: "pending") } + let(:result) { BaseService::Result.new } + + describe "#perform" do + before do + allow(Invoices::FinalizePendingViesInvoiceService).to receive(:call!) + .with(invoice:) + .and_return(result) + end + + it "delegates to the FinalizePendingViesInvoiceService" do + described_class.perform_now(invoice) + + expect(Invoices::FinalizePendingViesInvoiceService).to have_received(:call!).with(invoice:) + end + end + + describe "retry_on" do + [ + [Customers::FailedToAcquireLock.new("customer-1-prepaid_credit"), 25], + [ActiveRecord::StaleObjectError.new("Attempted to update a stale object: Wallet."), 25] + ].each do |error, attempts| + error_class = error.class + + context "when a #{error_class} error is raised" do + before do + allow(Invoices::FinalizePendingViesInvoiceService).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + described_class.perform_later(invoice) + end.to raise_error(error_class) + end + end + end + end + end +end diff --git a/spec/jobs/invoices/generate_documents_job_spec.rb b/spec/jobs/invoices/generate_documents_job_spec.rb new file mode 100644 index 0000000..fc98165 --- /dev/null +++ b/spec/jobs/invoices/generate_documents_job_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::GenerateDocumentsJob do + subject { described_class.perform_now(invoice:, notify:) } + + let(:invoice) { create(:invoice) } + let(:result) { BaseService::Result.new } + let(:notify) { false } + + before do + allow(Invoices::GeneratePdfService).to receive(:call).with(invoice:).and_return(result) + allow(Invoices::GenerateXmlService).to receive(:call).with(invoice:).and_return(result) + end + + it_behaves_like "a configurable queue", "pdfs", "SIDEKIQ_PDFS", "invoices" do + let(:arguments) { {invoice:, notify:} } + end + + it_behaves_like "a retryable on network errors job" do + let(:service_class) { Invoices::GenerateXmlService } + let(:job_arguments) { {invoice:, notify:} } + end + + it "generates the PDF" do + subject + expect(Invoices::GeneratePdfService).to have_received(:call) + end + + it "generates the XML" do + subject + expect(Invoices::GenerateXmlService).to have_received(:call) + end + + context "when notify is sent" do + context "with true" do + let(:notify) { true } + + it "enqueues Invoices::NotifyJob" do + expect { subject }.to have_enqueued_job(Invoices::NotifyJob).with(invoice:) + end + end + + context "with false" do + let(:notify) { false } + + it "does nothing" do + expect { subject }.not_to have_enqueued_job(Invoices::NotifyJob) + end + end + end +end diff --git a/spec/jobs/invoices/generate_pdf_and_notify_job_spec.rb b/spec/jobs/invoices/generate_pdf_and_notify_job_spec.rb new file mode 100644 index 0000000..db6b150 --- /dev/null +++ b/spec/jobs/invoices/generate_pdf_and_notify_job_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::GeneratePdfAndNotifyJob do + subject { described_class.perform_now(invoice:, email:) } + + let(:invoice) { create(:invoice) } + let(:email) { true } + let(:notify) { email } + + it "enqueues GenerateDocumentsJob" do + expect { subject }.to enqueue_job(Invoices::GenerateDocumentsJob).with(invoice:, notify:) + end +end diff --git a/spec/jobs/invoices/generate_pdf_job_spec.rb b/spec/jobs/invoices/generate_pdf_job_spec.rb new file mode 100644 index 0000000..95e2a3c --- /dev/null +++ b/spec/jobs/invoices/generate_pdf_job_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::GeneratePdfJob do + let(:invoice) { create(:invoice) } + + let(:result) { BaseService::Result.new } + + it "delegates to the Generate service" do + allow(Invoices::GeneratePdfService).to receive(:call) + .with(invoice:, context: "api") + .and_return(result) + + described_class.perform_now(invoice) + + expect(Invoices::GeneratePdfService).to have_received(:call) + end +end diff --git a/spec/jobs/invoices/generate_xml_job_spec.rb b/spec/jobs/invoices/generate_xml_job_spec.rb new file mode 100644 index 0000000..e6ddb62 --- /dev/null +++ b/spec/jobs/invoices/generate_xml_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::GenerateXmlJob do + let(:invoice) { create(:invoice) } + + let(:result) { BaseService::Result.new } + let(:service_class) { Invoices::GenerateXmlService } + let(:generate_service) do + instance_double(Invoices::GenerateXmlService) + end + + it "delegates to the Generate service" do + allow(service_class).to receive(:new) + .with(invoice:, context: "api") + .and_return(generate_service) + allow(generate_service).to receive(:call_with_middlewares) + .and_return(result) + + described_class.perform_now(invoice) + + expect(service_class).to have_received(:new) + expect(generate_service).to have_received(:call_with_middlewares) + end +end diff --git a/spec/jobs/invoices/notify_job_spec.rb b/spec/jobs/invoices/notify_job_spec.rb new file mode 100644 index 0000000..003754a --- /dev/null +++ b/spec/jobs/invoices/notify_job_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::NotifyJob do + subject { described_class.perform_now(invoice:) } + + let(:invoice) { create(:invoice) } + + it "sends email" do + expect { subject }.to have_enqueued_mail(InvoiceMailer, :created) + .with(params: {invoice:}, args: []) + end +end diff --git a/spec/jobs/invoices/payments/adyen_create_job_spec.rb b/spec/jobs/invoices/payments/adyen_create_job_spec.rb new file mode 100644 index 0000000..25a6755 --- /dev/null +++ b/spec/jobs/invoices/payments/adyen_create_job_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::AdyenCreateJob do + let(:invoice) { create(:invoice) } + + it "calls the stripe create service" do + allow(Invoices::Payments::CreateService).to receive(:call!) + .with(invoice:, payment_provider: :adyen) + .and_return(BaseService::Result.new) + + described_class.perform_now(invoice) + + expect(Invoices::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/invoices/payments/create_job_spec.rb b/spec/jobs/invoices/payments/create_job_spec.rb new file mode 100644 index 0000000..8158fe3 --- /dev/null +++ b/spec/jobs/invoices/payments/create_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::CreateJob do + let(:invoice) { create(:invoice) } + let(:payment_provider) { "stripe" } + + it "calls the stripe create service" do + allow(Invoices::Payments::CreateService).to receive(:call!) + .with(invoice:, payment_provider:, payment_method_params: {}) + .and_return(BaseService::Result.new) + + described_class.perform_now(invoice:, payment_provider:, payment_method_params: {}) + + expect(Invoices::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/invoices/payments/gocardless_create_job_spec.rb b/spec/jobs/invoices/payments/gocardless_create_job_spec.rb new file mode 100644 index 0000000..ededfe2 --- /dev/null +++ b/spec/jobs/invoices/payments/gocardless_create_job_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::GocardlessCreateJob do + let(:invoice) { create(:invoice) } + + it "calls the stripe create service" do + allow(Invoices::Payments::CreateService).to receive(:call!) + .with(invoice:, payment_provider: :gocardless) + .and_return(BaseService::Result.new) + + described_class.perform_now(invoice) + + expect(Invoices::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/invoices/payments/mark_overdue_job_spec.rb b/spec/jobs/invoices/payments/mark_overdue_job_spec.rb new file mode 100644 index 0000000..a5a3e51 --- /dev/null +++ b/spec/jobs/invoices/payments/mark_overdue_job_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe Invoices::Payments::MarkOverdueJob, job: true do + subject { described_class.new } + + describe ".perform" do + let(:overdue_invoice) { create(:invoice, payment_due_date: 1.day.ago) } + + before do + overdue_invoice + end + + it "marks expected invoices as payment overdue" do + described_class.perform_now(invoice: overdue_invoice) + expect(Invoice.payment_overdue).to eq([overdue_invoice]) + end + + it "enqueues a SendWebhookJob" do + expect do + described_class.perform_now(invoice: overdue_invoice) + end.to have_enqueued_job(SendWebhookJob).with("invoice.payment_overdue", overdue_invoice) + end + + context "when invoice is draft" do + let(:invoice) { create(:invoice, :draft, payment_due_date: 1.day.ago) } + + it "returns a failure" do + result = described_class.perform_now(invoice: invoice) + expect(result).not_to be_success + expect(result.error.message).to eq("invoice_not_finalized") + end + end + + context "when invoice is succeeded" do + let(:invoice) { create(:invoice, payment_status: :succeeded, payment_due_date: 1.day.ago) } + + it "returns a failure" do + result = described_class.perform_now(invoice: invoice) + expect(result).not_to be_success + expect(result.error.message).to eq("invoice_payment_already_succeeded") + end + end + + context "when invoice is dispute lost" do + let(:invoice) { create(:invoice, payment_due_date: 1.day.ago, payment_dispute_lost_at: 1.day.ago) } + + it "returns a failure" do + result = described_class.perform_now(invoice: invoice) + expect(result).not_to be_success + expect(result.error.message).to eq("invoice_dispute_lost") + end + end + + context "when invoice is nil" do + it "returns a failure" do + result = described_class.perform_now(invoice: nil) + expect(result).not_to be_success + expect(result.error.message).to eq("invoice_not_found") + end + end + + context "when invoice is future" do + let(:invoice) { create(:invoice, payment_due_date: 1.day.from_now) } + + it "returns a failure" do + result = described_class.perform_now(invoice: invoice) + expect(result).not_to be_success + expect(result.error.message).to eq("invoice_due_date_in_future") + end + end + end +end diff --git a/spec/jobs/invoices/payments/retry_all_job_spec.rb b/spec/jobs/invoices/payments/retry_all_job_spec.rb new file mode 100644 index 0000000..65bf987 --- /dev/null +++ b/spec/jobs/invoices/payments/retry_all_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::RetryAllJob do + subject(:retry_all_job) { described_class } + + let(:retry_batch_service) { instance_double(Invoices::Payments::RetryBatchService) } + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, organization:) } + + before do + allow(Invoices::Payments::RetryBatchService).to receive(:new) + .and_return(retry_batch_service) + allow(retry_batch_service).to receive(:call) + .and_return(result) + end + + it "calls the retry batch service" do + retry_all_job.perform_now(organization_id: organization.id, invoice_ids: [invoice.id]) + + expect(Invoices::Payments::RetryBatchService).to have_received(:new) + expect(retry_batch_service).to have_received(:call) + end +end diff --git a/spec/jobs/invoices/payments/stripe_create_job_spec.rb b/spec/jobs/invoices/payments/stripe_create_job_spec.rb new file mode 100644 index 0000000..2e22cc7 --- /dev/null +++ b/spec/jobs/invoices/payments/stripe_create_job_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::StripeCreateJob do + let(:invoice) { create(:invoice) } + + it "calls the stripe create service" do + allow(Invoices::Payments::CreateService).to receive(:call!) + .with(invoice:, payment_provider: :stripe) + .and_return(BaseService::Result.new) + + described_class.perform_now(invoice) + + expect(Invoices::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/invoices/prepaid_credit_job_spec.rb b/spec/jobs/invoices/prepaid_credit_job_spec.rb new file mode 100644 index 0000000..08712f7 --- /dev/null +++ b/spec/jobs/invoices/prepaid_credit_job_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::PrepaidCreditJob do + let(:invoice) { create(:invoice, customer:, organization: customer.organization) } + let(:customer) { create(:customer) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:, balance_cents: 1000, credits_balance: 10.0) } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, amount: 15.0, credit_amount: 15.0, status: "pending") + end + let(:fee) do + create( + :fee, + fee_type: "credit", + invoiceable_type: "WalletTransaction", + invoiceable_id: wallet_transaction.id, + invoice: + ) + end + + before do + wallet_transaction + fee + subscription + invoice.update(invoice_type: "credit") + end + + it "updates wallet balance" do + described_class.perform_now(invoice) + + expect(wallet.reload.balance_cents).to eq(2500) + end + + it "settles the wallet transaction" do + described_class.perform_now(invoice) + + expect(wallet_transaction.reload.status).to eq("settled") + end + + it "finalize the invoice" do + allow(Invoices::FinalizeOpenCreditService).to receive(:call) + described_class.perform_now(invoice) + expect(Invoices::FinalizeOpenCreditService).to have_received(:call).with(invoice:) + end + + it "does not retry the job" do + expect { + described_class.perform_now(invoice) + }.not_to have_enqueued_job(described_class) + end + + context "when there is race condition error" do + before do + allow(Wallets::ApplyPaidCreditsService).to receive(:call).and_raise(ActiveRecord::StaleObjectError.new) + end + + it "retries the job" do + expect { + described_class.perform_now(invoice) + }.to have_enqueued_job(described_class) + end + end + + shared_examples "does not grant credits" do |payment_status| + it "marks the wallet transaction as failed" do + allow(WalletTransactions::MarkAsFailedService).to receive(:new).and_call_original + described_class.perform_now(invoice, payment_status) + expect(WalletTransactions::MarkAsFailedService).to have_received(:new).with(wallet_transaction: wallet_transaction) + expect(wallet_transaction.reload.status).to eq("failed") + end + + it "does not grant prepaid credits" do + expect { + described_class.perform_now(invoice, payment_status) + }.not_to change { wallet.reload.balance_cents } + end + + it "does not call the invoice FinalizeOpenCreditService" do + allow(Invoices::FinalizeOpenCreditService).to receive(:call) + described_class.perform_now(invoice, payment_status) + expect(Invoices::FinalizeOpenCreditService).not_to have_received(:call) + end + end + + context "when payment fails" do + it_behaves_like "does not grant credits", :failed + end + + context "when invoice is paid by credit note" do + let(:source_credit_note) { create(:credit_note, invoice:, customer:) } + + before do + create(:invoice_settlement, target_invoice: invoice, source_credit_note:, settlement_type: :credit_note) + end + + it_behaves_like "does not grant credits", :succeeded + end + + context "when payment_status is not provided (Default to :succeeded for old jobs)" do + it "defaults to :succeeded and grants prepaid credits" do + described_class.perform_now(invoice) + + expect(wallet.reload.balance_cents).to eq(2500) + expect(wallet_transaction.reload.status).to eq("settled") + end + end + + describe "#lock_key_arguments" do + it "returns invoice and payment_status" do + job = described_class.new(invoice, :succeeded) + expect(job.lock_key_arguments).to eq([invoice, :succeeded]) + end + + it "defaults payment_status to :succeeded when not provided" do + job = described_class.new(invoice) + expect(job.lock_key_arguments).to eq([invoice, :succeeded]) + end + + it "converts payment_status string to symbol" do + job = described_class.new(invoice, "failed") + expect(job.lock_key_arguments).to eq([invoice, :failed]) + end + end +end diff --git a/spec/jobs/invoices/provider_taxes/pull_taxes_and_apply_job_spec.rb b/spec/jobs/invoices/provider_taxes/pull_taxes_and_apply_job_spec.rb new file mode 100644 index 0000000..95d3b67 --- /dev/null +++ b/spec/jobs/invoices/provider_taxes/pull_taxes_and_apply_job_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ProviderTaxes::PullTaxesAndApplyJob do + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::ProviderTaxes::PullTaxesAndApplyService).to receive(:call) + .with(invoice:) + .and_return(result) + end + + it "calls successfully the service" do + described_class.perform_now(invoice:) + + expect(Invoices::ProviderTaxes::PullTaxesAndApplyService).to have_received(:call) + end + + describe "unique" do + it "has unique :until_executed constraint" do + expect(described_class.lock_strategy_class).to eq(ActiveJob::Uniqueness::Strategies::UntilExecuted) + end + end + + describe "retry_on" do + [ + [Customers::FailedToAcquireLock.new("customer-1-prepaid_credit"), 25], + [ActiveRecord::StaleObjectError.new("Attempted to update a stale object: Wallet."), 25], + [BaseService::ThrottlingError.new, 25], + [LagoHttpClient::HttpError.new(401, "body", "uri"), 6], + [OpenSSL::SSL::SSLError.new("OpenSSL::SSL::SSLError"), 6], + [Net::ReadTimeout.new("Net::ReadTimeout"), 6], + [Net::OpenTimeout.new("Net::OpenTimeout"), 6] + ].each do |error, attempts| + error_class = error.class + + context "when a #{error_class} error is raised" do + before do + allow(Invoices::ProviderTaxes::PullTaxesAndApplyService).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + described_class.perform_later(invoice:) + end.to raise_error(error_class) + end + end + end + end + end +end diff --git a/spec/jobs/invoices/provider_taxes/void_job_spec.rb b/spec/jobs/invoices/provider_taxes/void_job_spec.rb new file mode 100644 index 0000000..c1745e0 --- /dev/null +++ b/spec/jobs/invoices/provider_taxes/void_job_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ProviderTaxes::VoidJob do + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::ProviderTaxes::VoidService).to receive(:call) + .with(invoice:) + .and_return(result) + end + + context "when there is anrok customer" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before { integration_customer } + + it "calls successfully void service" do + described_class.perform_now(invoice:) + + expect(Invoices::ProviderTaxes::VoidService).to have_received(:call) + end + end + + context "when there is avalara customer" do + let(:integration) { create(:avalara_integration, organization:) } + let(:integration_customer) { create(:avalara_customer, integration:, customer:) } + + before { integration_customer } + + it "calls successfully void service" do + described_class.perform_now(invoice:) + + expect(Invoices::ProviderTaxes::VoidService).to have_received(:call) + end + end + + context "when there is NOT tax customer" do + it "does not call void service" do + described_class.perform_now(invoice:) + + expect(Invoices::ProviderTaxes::VoidService).not_to have_received(:call) + end + end +end diff --git a/spec/jobs/invoices/refresh_draft_job_spec.rb b/spec/jobs/invoices/refresh_draft_job_spec.rb new file mode 100644 index 0000000..70587e8 --- /dev/null +++ b/spec/jobs/invoices/refresh_draft_job_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::RefreshDraftJob do + let(:invoice) { create(:invoice, ready_to_be_refreshed: true) } + let(:result) { BaseService::Result.new } + + it "delegates to the RefreshDraft service" do + allow(Invoices::RefreshDraftService).to receive(:call).with(invoice:).and_return(result) + + described_class.perform_now(invoice:) + + expect(Invoices::RefreshDraftService).to have_received(:call) + end + + it "does not delegate to the RefreshDraft service if the ready_to_be_refreshed? is false" do + allow(Invoices::RefreshDraftService).to receive(:call).with(invoice:) + + invoice.update ready_to_be_refreshed: false + described_class.perform_now(invoice:) + + expect(Invoices::RefreshDraftService).not_to have_received(:call) + end + + it "has a lock_ttl of 12.hours" do + # When there's lots of draft invoices to be refreshed, we might end up enqueueing multiple of them. + # This will block all queues with lower prio than the `invoices` queue. (e.g. wallets). This is undesirable, + # so we bump the lock_ttl for this job to 6 hours + expect(described_class.new.lock_options[:lock_ttl]).to eq(12.hours) + end + + context "when there was a tax fetching error in RefreshDraft service" do + let(:integration_customer) { create(:anrok_customer, customer: invoice.customer) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration: integration_customer.integration, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(p) + end + + before do + integration_collection_mapping + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "does not throw an error when it is a tax error" do + expect { described_class.perform_now(invoice:) }.not_to raise_error + end + end +end diff --git a/spec/jobs/invoices/retry_all_job_spec.rb b/spec/jobs/invoices/retry_all_job_spec.rb new file mode 100644 index 0000000..81a46e7 --- /dev/null +++ b/spec/jobs/invoices/retry_all_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::RetryAllJob do + subject(:retry_all_job) { described_class } + + let(:retry_batch_service) { instance_double(Invoices::RetryBatchService) } + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, organization:) } + + before do + allow(Invoices::RetryBatchService).to receive(:new) + .and_return(retry_batch_service) + allow(retry_batch_service).to receive(:call) + .and_return(result) + end + + it "calls the retry batch service" do + retry_all_job.perform_now(organization:, invoice_ids: [invoice.id]) + + expect(Invoices::RetryBatchService).to have_received(:new) + expect(retry_batch_service).to have_received(:call) + end +end diff --git a/spec/jobs/invoices/update_all_invoice_issuing_date_from_billing_entity_job_spec.rb b/spec/jobs/invoices/update_all_invoice_issuing_date_from_billing_entity_job_spec.rb new file mode 100644 index 0000000..d01a7bf --- /dev/null +++ b/spec/jobs/invoices/update_all_invoice_issuing_date_from_billing_entity_job_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityJob do + subject { described_class.perform_now(billing_entity, previous_issuing_date_settings) } + + let(:billing_entity) { create(:billing_entity) } + let(:previous_issuing_date_settings) do + { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + } + end + + before do + allow(Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityService).to receive(:call).and_call_original + end + + it "calls the service" do + subject + + expect(Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityService) + .to have_received(:call) + .with(billing_entity:, previous_issuing_date_settings:) + end +end diff --git a/spec/jobs/invoices/update_fees_payment_status_job_spec.rb b/spec/jobs/invoices/update_fees_payment_status_job_spec.rb new file mode 100644 index 0000000..df1490c --- /dev/null +++ b/spec/jobs/invoices/update_fees_payment_status_job_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::UpdateFeesPaymentStatusJob do + let(:invoice) { create(:invoice, payment_status: :succeeded) } + let(:fee) { create(:fee, invoice:) } + + before { fee } + + it "updates the payment_status of the fee" do + described_class.perform_now(invoice) + + expect(fee.reload.payment_status).to eq("succeeded") + end +end diff --git a/spec/jobs/invoices/update_issuing_date_from_billing_entity_job_spec.rb b/spec/jobs/invoices/update_issuing_date_from_billing_entity_job_spec.rb new file mode 100644 index 0000000..c1a0a79 --- /dev/null +++ b/spec/jobs/invoices/update_issuing_date_from_billing_entity_job_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::UpdateIssuingDateFromBillingEntityJob do + subject { described_class.perform_now(invoice, previous_issuing_date_settings) } + + let(:invoice) { create(:invoice) } + let(:previous_issuing_date_settings) do + { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + } + end + + before do + allow(Invoices::UpdateIssuingDateFromBillingEntityService).to receive(:call).and_call_original + end + + it "calls the service" do + subject + + expect(Invoices::UpdateIssuingDateFromBillingEntityService) + .to have_received(:call) + .with(invoice:, previous_issuing_date_settings:) + end +end diff --git a/spec/jobs/lifetime_usages/flag_refresh_from_plan_update_job_spec.rb b/spec/jobs/lifetime_usages/flag_refresh_from_plan_update_job_spec.rb new file mode 100644 index 0000000..054817a --- /dev/null +++ b/spec/jobs/lifetime_usages/flag_refresh_from_plan_update_job_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::FlagRefreshFromPlanUpdateJob do + let(:plan) { create(:plan) } + + it "delegates to the FlagRefreshFromPlanUpdate service" do + allow(LifetimeUsages::FlagRefreshFromPlanUpdateService).to receive(:call) + described_class.perform_now(plan) + expect(LifetimeUsages::FlagRefreshFromPlanUpdateService).to have_received(:call).with(plan:) + end +end diff --git a/spec/jobs/lifetime_usages/recalculate_and_check_job_spec.rb b/spec/jobs/lifetime_usages/recalculate_and_check_job_spec.rb new file mode 100644 index 0000000..68853bb --- /dev/null +++ b/spec/jobs/lifetime_usages/recalculate_and_check_job_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::RecalculateAndCheckJob do + let(:organization) { create(:organization, :premium, premium_integrations:) } + let(:lifetime_usage) { create(:lifetime_usage, organization:) } + + let(:premium_integrations) { ["progressive_billing"] } + + it_behaves_like "a configurable queue", "billing_low_priority", "SIDEKIQ_BILLING" do + let(:arguments) { lifetime_usage } + end + + it "delegates to the Calculate service" do + allow(LifetimeUsages::CalculateService).to receive(:call!) + allow(LifetimeUsages::CheckThresholdsService).to receive(:call!) + described_class.perform_now(lifetime_usage) + expect(LifetimeUsages::CalculateService).to have_received(:call!).with(lifetime_usage:, current_usage: nil) + expect(LifetimeUsages::CheckThresholdsService).not_to have_received(:call!) + end + + context "when premium", :premium do + it "delegates to the RecalculateAndCheck service" do + allow(LifetimeUsages::CalculateService).to receive(:call!) + allow(LifetimeUsages::CheckThresholdsService).to receive(:call!) + described_class.perform_now(lifetime_usage) + expect(LifetimeUsages::CalculateService).to have_received(:call!).with(lifetime_usage:, current_usage: nil) + expect(LifetimeUsages::CheckThresholdsService).to have_received(:call!).with(lifetime_usage:) + end + + context "when progressive billing is disabled" do + let(:premium_integrations) { [] } + + it "delegates to the RecalculateAndCheck service" do + allow(LifetimeUsages::CalculateService).to receive(:call!) + allow(LifetimeUsages::CheckThresholdsService).to receive(:call!) + described_class.perform_now(lifetime_usage) + expect(LifetimeUsages::CalculateService).to have_received(:call!).with(lifetime_usage:, current_usage: nil) + expect(LifetimeUsages::CheckThresholdsService).not_to have_received(:call!) + end + end + end + + describe "retry_on" do + [ + [Customers::FailedToAcquireLock.new("customer-1-prepaid_credit"), 25], + [ActiveRecord::StaleObjectError.new("Attempted to update a stale object: Wallet."), 25] + ].each do |error, attempts| + error_class = error.class + + context "when a #{error_class} error is raised" do + before do + allow(LifetimeUsages::CalculateService).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + described_class.perform_later(lifetime_usage) + end.to raise_error(error_class) + end + end + end + end + end +end diff --git a/spec/jobs/payment_provider_customers/gocardless_checkout_url_job_spec.rb b/spec/jobs/payment_provider_customers/gocardless_checkout_url_job_spec.rb new file mode 100644 index 0000000..0c3a622 --- /dev/null +++ b/spec/jobs/payment_provider_customers/gocardless_checkout_url_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::GocardlessCheckoutUrlJob do + subject(:gocardless_checkout_job) { described_class } + + let(:gocardless_customer) { create(:gocardless_customer) } + + let(:gocardless_service) { instance_double(PaymentProviderCustomers::GocardlessService) } + + it "calls generate_checkout_url method" do + allow(PaymentProviderCustomers::GocardlessService).to receive(:new) + .with(gocardless_customer) + .and_return(gocardless_service) + allow(gocardless_service).to receive(:generate_checkout_url) + .and_return(BaseService::Result.new) + + gocardless_checkout_job.perform_now(gocardless_customer) + + expect(PaymentProviderCustomers::GocardlessService).to have_received(:new) + expect(gocardless_service).to have_received(:generate_checkout_url) + end +end diff --git a/spec/jobs/payment_provider_customers/gocardless_create_job_spec.rb b/spec/jobs/payment_provider_customers/gocardless_create_job_spec.rb new file mode 100644 index 0000000..171e543 --- /dev/null +++ b/spec/jobs/payment_provider_customers/gocardless_create_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::GocardlessCreateJob do + let(:gocardless_customer) { create(:gocardless_customer) } + + let(:gocardless_service) { instance_double(PaymentProviderCustomers::GocardlessService) } + + it "calls the gocardless create service" do + allow(PaymentProviderCustomers::GocardlessService).to receive(:new) + .with(gocardless_customer) + .and_return(gocardless_service) + allow(gocardless_service).to receive(:create) + .and_return(BaseService::Result.new) + + described_class.perform_now(gocardless_customer) + + expect(PaymentProviderCustomers::GocardlessService).to have_received(:new) + expect(gocardless_service).to have_received(:create) + end +end diff --git a/spec/jobs/payment_provider_customers/stripe_checkout_url_job_spec.rb b/spec/jobs/payment_provider_customers/stripe_checkout_url_job_spec.rb new file mode 100644 index 0000000..bee459d --- /dev/null +++ b/spec/jobs/payment_provider_customers/stripe_checkout_url_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::StripeCheckoutUrlJob do + subject(:stripe_checkout_job) { described_class } + + let(:stripe_customer) { create(:stripe_customer) } + + let(:stripe_service) { instance_double(PaymentProviderCustomers::StripeService) } + + it "calls generate_checkout_url method" do + allow(PaymentProviderCustomers::StripeService).to receive(:new) + .with(stripe_customer) + .and_return(stripe_service) + allow(stripe_service).to receive(:generate_checkout_url) + .and_return(BaseService::Result.new) + + stripe_checkout_job.perform_now(stripe_customer) + + expect(PaymentProviderCustomers::StripeService).to have_received(:new) + expect(stripe_service).to have_received(:generate_checkout_url) + end +end diff --git a/spec/jobs/payment_provider_customers/stripe_create_job_spec.rb b/spec/jobs/payment_provider_customers/stripe_create_job_spec.rb new file mode 100644 index 0000000..690f5a2 --- /dev/null +++ b/spec/jobs/payment_provider_customers/stripe_create_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::StripeCreateJob do + let(:stripe_customer) { create(:stripe_customer) } + + let(:stripe_service) { instance_double(PaymentProviderCustomers::StripeService) } + + it "calls the stripe create service" do + allow(PaymentProviderCustomers::StripeService).to receive(:new) + .with(stripe_customer) + .and_return(stripe_service) + allow(stripe_service).to receive(:create) + .and_return(BaseService::Result.new) + + described_class.perform_now(stripe_customer) + + expect(PaymentProviderCustomers::StripeService).to have_received(:new) + expect(stripe_service).to have_received(:create) + end +end diff --git a/spec/jobs/payment_provider_customers/stripe_sync_funding_instructions_job_spec.rb b/spec/jobs/payment_provider_customers/stripe_sync_funding_instructions_job_spec.rb new file mode 100644 index 0000000..e5af6d2 --- /dev/null +++ b/spec/jobs/payment_provider_customers/stripe_sync_funding_instructions_job_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::StripeSyncFundingInstructionsJob do + subject(:stripe_sync_funding_instructions_job) { described_class } + + let(:stripe_customer) { create(:stripe_customer) } + let(:stripe_service) { instance_double(PaymentProviderCustomers::Stripe::SyncFundingInstructionsService) } + + it "calls the funding instructions sync service" do + allow(PaymentProviderCustomers::Stripe::SyncFundingInstructionsService).to receive(:new) + .with(stripe_customer) + .and_return(stripe_service) + allow(stripe_service).to receive(:call) + .and_return(BaseService::Result.new) + + stripe_sync_funding_instructions_job.perform_now(stripe_customer) + + expect(PaymentProviderCustomers::Stripe::SyncFundingInstructionsService).to have_received(:new) + expect(stripe_service).to have_received(:call) + end +end diff --git a/spec/jobs/payment_providers/adyen/handle_event_job_spec.rb b/spec/jobs/payment_providers/adyen/handle_event_job_spec.rb new file mode 100644 index 0000000..677ebfd --- /dev/null +++ b/spec/jobs/payment_providers/adyen/handle_event_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Adyen::HandleEventJob do + subject(:handle_event_job) { described_class } + + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + let(:event_json) { "{}" } + + before do + allow(PaymentProviders::Adyen::HandleEventService).to receive(:call!) + .and_return(result) + end + + it "calls the handle event service" do + described_class.perform_now(organization:, event_json:) + + expect(PaymentProviders::Adyen::HandleEventService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_providers/cancel_payment_authorization_job_spec.rb b/spec/jobs/payment_providers/cancel_payment_authorization_job_spec.rb new file mode 100644 index 0000000..e68bdd5 --- /dev/null +++ b/spec/jobs/payment_providers/cancel_payment_authorization_job_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::CancelPaymentAuthorizationJob do + it "calls the Stripe API" do + payment_provider = create(:stripe_provider) + stub_request(:post, %r{stripe}).and_return(status: 200, body: "{}") + + described_class.perform_now(payment_provider:, id: "pi_123456789") + + expect(WebMock).to have_requested(:post, "https://api.stripe.com/v1/payment_intents/pi_123456789/cancel") + end + + context "when the payment provider is not Stripe" do + it "raises NotImplementedError for Adyen provider" do + payment_provider = create(:adyen_provider) + + expect { + described_class.perform_now(payment_provider:, id: "AUTH_123") + }.to raise_error( + NotImplementedError, + "Cancelling payment authorization not implemented for adyen" + ) + end + end +end diff --git a/spec/jobs/payment_providers/cashfree/handle_event_job_spec.rb b/spec/jobs/payment_providers/cashfree/handle_event_job_spec.rb new file mode 100644 index 0000000..103761a --- /dev/null +++ b/spec/jobs/payment_providers/cashfree/handle_event_job_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::HandleEventJob do + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + + let(:cashfree_event) do + {} + end + + before do + allow(PaymentProviders::Cashfree::HandleEventService) + .to receive(:call) + .and_return(result) + end + + it "calls the handle event service" do + described_class.perform_now( + organization:, + event: cashfree_event + ) + + expect(PaymentProviders::Cashfree::HandleEventService).to have_received(:call) + end +end diff --git a/spec/jobs/payment_providers/flutterwave/handle_event_job_spec.rb b/spec/jobs/payment_providers/flutterwave/handle_event_job_spec.rb new file mode 100644 index 0000000..6626463 --- /dev/null +++ b/spec/jobs/payment_providers/flutterwave/handle_event_job_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Flutterwave::HandleEventJob do + subject(:handle_event_job) { described_class.new } + + let(:organization) { create(:organization) } + let(:event_json) { {event: "charge.completed", data: {}}.to_json } + + describe "#perform" do + it "calls the HandleEventService" do + allow(PaymentProviders::Flutterwave::HandleEventService) + .to receive(:call!) + + handle_event_job.perform(organization:, event: event_json) + + expect(PaymentProviders::Flutterwave::HandleEventService) + .to have_received(:call!) + .with(organization:, event_json: event_json) + end + end + + describe "queue configuration" do + context "when SIDEKIQ_PAYMENTS is true" do + before { ENV["SIDEKIQ_PAYMENTS"] = "true" } + after { ENV.delete("SIDEKIQ_PAYMENTS") } + + it "uses the payments queue" do + expect { + described_class.perform_later(organization:, event: "test") + }.to have_enqueued_job.on_queue("payments") + end + end + + context "when SIDEKIQ_PAYMENTS is false or not set" do + before { ENV.delete("SIDEKIQ_PAYMENTS") } + + it "uses the providers queue" do + expect { + described_class.perform_later(organization:, event: "test") + }.to have_enqueued_job.on_queue("providers") + end + end + end +end diff --git a/spec/jobs/payment_providers/gocardless/handle_event_job_spec.rb b/spec/jobs/payment_providers/gocardless/handle_event_job_spec.rb new file mode 100644 index 0000000..be92fd8 --- /dev/null +++ b/spec/jobs/payment_providers/gocardless/handle_event_job_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::HandleEventJob do + subject(:handle_event_job) { described_class } + + let(:gocardless_service) { instance_double(PaymentProviders::GocardlessService) } + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + let(:payment_provider) { create(:gocardless_provider, organization:) } + + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + let(:service_result) { BaseService::Result.new } + + it "delegate to the event service" do + allow(PaymentProviders::Gocardless::HandleEventService).to receive(:call) + .with(payment_provider:, event_json:) + .and_return(service_result) + + handle_event_job.perform_now(organization:, payment_provider:, event_json:) + + expect(PaymentProviders::Gocardless::HandleEventService).to have_received(:call) + end + + context "with legacy multiple events" do + let(:events_json) do + path = Rails.root.join("spec/fixtures/gocardless/events.json") + File.read(path) + end + + it "enqueues a job for each event" do + handle_event_job.perform_now(events_json:) + + expect(described_class).to have_been_enqueued.exactly(JSON.parse(events_json)["events"].count).times + end + end +end diff --git a/spec/jobs/payment_providers/moneyhash/handle_event_job_spec.rb b/spec/jobs/payment_providers/moneyhash/handle_event_job_spec.rb new file mode 100644 index 0000000..5c21dd3 --- /dev/null +++ b/spec/jobs/payment_providers/moneyhash/handle_event_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Moneyhash::HandleEventJob do + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + + let(:moneyhash_event) { {} } + + before do + allow(PaymentProviders::Moneyhash::HandleEventService) + .to receive(:call) + .and_return(result) + end + + it "calls the handle event service" do + described_class.perform_now(organization:, event_json: moneyhash_event) + + expect(PaymentProviders::Moneyhash::HandleEventService).to have_received(:call) + end +end diff --git a/spec/jobs/payment_providers/stripe/customers/fetch_default_payment_method_job_spec.rb b/spec/jobs/payment_providers/stripe/customers/fetch_default_payment_method_job_spec.rb new file mode 100644 index 0000000..ab1021c --- /dev/null +++ b/spec/jobs/payment_providers/stripe/customers/fetch_default_payment_method_job_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodJob, type: :job do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:provider_customer) do + create(:stripe_customer, customer:, provider_customer_id: "cus_123", payment_provider: stripe_provider) + end + + describe "#perform" do + it "calls the FetchDefaultPaymentMethodService" do + allow(PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodService) + .to receive(:call!) + .with(provider_customer:) + + described_class.perform_now(provider_customer) + + expect(PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodService) + .to have_received(:call!) + .with(provider_customer:) + end + end +end diff --git a/spec/jobs/payment_providers/stripe/handle_event_job_spec.rb b/spec/jobs/payment_providers/stripe/handle_event_job_spec.rb new file mode 100644 index 0000000..206f556 --- /dev/null +++ b/spec/jobs/payment_providers/stripe/handle_event_job_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::HandleEventJob do + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + + let(:stripe_event) do + {} + end + + before do + allow(PaymentProviders::Stripe::HandleEventService) + .to receive(:call) + .and_return(result) + end + + it "calls the handle event service" do + described_class.perform_now( + organization:, + event: stripe_event + ) + + expect(PaymentProviders::Stripe::HandleEventService).to have_received(:call) + end +end diff --git a/spec/jobs/payment_providers/stripe/refresh_webhook_job_spec.rb b/spec/jobs/payment_providers/stripe/refresh_webhook_job_spec.rb new file mode 100644 index 0000000..f2ebc36 --- /dev/null +++ b/spec/jobs/payment_providers/stripe/refresh_webhook_job_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::RefreshWebhookJob do + it "calls the refresh webhook service" do + allow(PaymentProviders::Stripe::RefreshWebhookService).to receive(:call!) + + described_class.perform_now(instance_double(PaymentProviders::StripeProvider)) + + expect(PaymentProviders::Stripe::RefreshWebhookService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_providers/stripe/register_webhook_job_spec.rb b/spec/jobs/payment_providers/stripe/register_webhook_job_spec.rb new file mode 100644 index 0000000..66dade9 --- /dev/null +++ b/spec/jobs/payment_providers/stripe/register_webhook_job_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::RegisterWebhookJob do + it "calls the register webhook service" do + allow(PaymentProviders::Stripe::RegisterWebhookService).to receive(:call!) + + described_class.perform_now(instance_double(PaymentProviders::StripeProvider)) + + expect(PaymentProviders::Stripe::RegisterWebhookService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_receipts/create_job_spec.rb b/spec/jobs/payment_receipts/create_job_spec.rb new file mode 100644 index 0000000..4ea2b73 --- /dev/null +++ b/spec/jobs/payment_receipts/create_job_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::CreateJob do + let(:payment) { create(:payment) } + + it "calls the create service" do + allow(PaymentReceipts::CreateService) + .to receive(:call!).with(payment:).and_return(BaseService::Result.new) + + described_class.perform_now(payment) + + expect(PaymentReceipts::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_receipts/generate_documents_job_spec.rb b/spec/jobs/payment_receipts/generate_documents_job_spec.rb new file mode 100644 index 0000000..1af1a29 --- /dev/null +++ b/spec/jobs/payment_receipts/generate_documents_job_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::GenerateDocumentsJob do + subject { described_class.perform_now(payment_receipt:, notify:) } + + let(:payment_receipt) { create(:payment_receipt) } + let(:result) { BaseService::Result.new } + let(:notify) { false } + + before do + allow(PaymentReceipts::GeneratePdfService).to receive(:call).with(payment_receipt:).and_return(result) + allow(PaymentReceipts::GenerateXmlService).to receive(:call).with(payment_receipt:).and_return(result) + end + + it_behaves_like "a configurable queue", "pdfs", "SIDEKIQ_PDFS", "low_priority" do + let(:arguments) { {payment_receipt:, notify:} } + end + + it_behaves_like "a retryable on network errors job" do + let(:service_class) { PaymentReceipts::GenerateXmlService } + let(:job_arguments) { {payment_receipt:, notify:} } + end + + it "generates the PDF" do + subject + expect(PaymentReceipts::GeneratePdfService).to have_received(:call) + end + + it "generates the XML" do + subject + expect(PaymentReceipts::GenerateXmlService).to have_received(:call) + end + + context "when notify is sent" do + context "with true" do + let(:notify) { true } + + it "enqueues PaymentReceipts::NotifyJob" do + expect { subject }.to have_enqueued_job(PaymentReceipts::NotifyJob).with(payment_receipt:) + end + end + + context "with false" do + let(:notify) { false } + + it "does nothing" do + expect { subject }.not_to have_enqueued_job(PaymentReceipts::NotifyJob) + end + end + end +end diff --git a/spec/jobs/payment_receipts/generate_pdf_and_notify_job_spec.rb b/spec/jobs/payment_receipts/generate_pdf_and_notify_job_spec.rb new file mode 100644 index 0000000..075ba17 --- /dev/null +++ b/spec/jobs/payment_receipts/generate_pdf_and_notify_job_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::GeneratePdfAndNotifyJob do + subject { described_class.perform_now(payment_receipt:, email:) } + + let(:payment_receipt) { create(:payment_receipt) } + let(:email) { true } + let(:notify) { email } + + it "enqueues GenerateDocumentsJob" do + expect { subject }.to enqueue_job(PaymentReceipts::GenerateDocumentsJob).with(payment_receipt:, notify:) + end +end diff --git a/spec/jobs/payment_receipts/generate_pdf_job_spec.rb b/spec/jobs/payment_receipts/generate_pdf_job_spec.rb new file mode 100644 index 0000000..81b6ca2 --- /dev/null +++ b/spec/jobs/payment_receipts/generate_pdf_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::GeneratePdfJob do + let(:payment_receipt) { create(:payment_receipt) } + let(:result) { BaseService::Result.new } + + it "delegates to the Generate service" do + allow(PaymentReceipts::GeneratePdfService).to receive(:call) + .with(payment_receipt:, context: "api") + .and_return(result) + + described_class.perform_now(payment_receipt) + + expect(PaymentReceipts::GeneratePdfService).to have_received(:call) + end +end diff --git a/spec/jobs/payment_receipts/generate_xml_job_spec.rb b/spec/jobs/payment_receipts/generate_xml_job_spec.rb new file mode 100644 index 0000000..4cf82be --- /dev/null +++ b/spec/jobs/payment_receipts/generate_xml_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::GenerateXmlJob do + let(:payment_receipt) { create(:payment_receipt) } + let(:result) { BaseService::Result.new } + + it "delegates to the Generate service" do + allow(PaymentReceipts::GenerateXmlService).to receive(:call) + .with(payment_receipt:, context: "api") + .and_return(result) + + described_class.perform_now(payment_receipt) + + expect(PaymentReceipts::GenerateXmlService).to have_received(:call) + end +end diff --git a/spec/jobs/payment_receipts/notify_job_spec.rb b/spec/jobs/payment_receipts/notify_job_spec.rb new file mode 100644 index 0000000..157734d --- /dev/null +++ b/spec/jobs/payment_receipts/notify_job_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::NotifyJob do + let(:payment_receipt) { create(:payment_receipt) } + + it "sends the email" do + expect { described_class.perform_now(payment_receipt:) } + .to have_enqueued_mail(PaymentReceiptMailer, :created).with(params: {payment_receipt:}, args: []) + end +end diff --git a/spec/jobs/payment_requests/payments/adyen_create_job_spec.rb b/spec/jobs/payment_requests/payments/adyen_create_job_spec.rb new file mode 100644 index 0000000..ea61db0 --- /dev/null +++ b/spec/jobs/payment_requests/payments/adyen_create_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::AdyenCreateJob do + let(:payment_request) { create(:payment_request) } + + let(:service_result) { BaseService::Result.new } + + before do + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider: "adyen") + .and_return(service_result) + end + + it "calls the stripe create service" do + described_class.perform_now(payment_request) + + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_requests/payments/create_job_spec.rb b/spec/jobs/payment_requests/payments/create_job_spec.rb new file mode 100644 index 0000000..dfc4799 --- /dev/null +++ b/spec/jobs/payment_requests/payments/create_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::CreateJob do + let(:payment_request) { create(:payment_request) } + + let(:service_result) { BaseService::Result.new } + let(:payment_provider) { "stripe" } + + before do + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider:, payment_method_params: {}) + .and_return(service_result) + end + + it "calls the stripe create service" do + described_class.perform_now(payable: payment_request, payment_provider:, payment_method_params: {}) + + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_requests/payments/gocardless_create_job_spec.rb b/spec/jobs/payment_requests/payments/gocardless_create_job_spec.rb new file mode 100644 index 0000000..cbeb5d3 --- /dev/null +++ b/spec/jobs/payment_requests/payments/gocardless_create_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::GocardlessCreateJob do + let(:payment_request) { create(:payment_request) } + + let(:service_result) { BaseService::Result.new } + + before do + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider: "gocardless") + .and_return(service_result) + end + + it "calls the stripe create service" do + described_class.perform_now(payment_request) + + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/payment_requests/payments/stripe_create_job_spec.rb b/spec/jobs/payment_requests/payments/stripe_create_job_spec.rb new file mode 100644 index 0000000..0595479 --- /dev/null +++ b/spec/jobs/payment_requests/payments/stripe_create_job_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::StripeCreateJob do + let(:payment_request) { create(:payment_request) } + + let(:service_result) { BaseService::Result.new } + + before do + allow(PaymentRequests::Payments::CreateService).to receive(:call!) + .with(payable: payment_request, payment_provider: "stripe") + .and_return(service_result) + end + + it "calls the stripe create service" do + described_class.perform_now(payment_request) + + expect(PaymentRequests::Payments::CreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/payments/manual_create_job_spec.rb b/spec/jobs/payments/manual_create_job_spec.rb new file mode 100644 index 0000000..9dc3f38 --- /dev/null +++ b/spec/jobs/payments/manual_create_job_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Payments::ManualCreateJob do + let(:organization) { invoice.customer.organization } + let(:invoice) { create(:invoice) } + let(:params) { {invoice_id: invoice.id, amount_cents: invoice.total_amount_cents, reference: "ref1"} } + + it "calls the create service" do + allow(Payments::ManualCreateService) + .to receive(:call!).with(organization:, params:).and_return(BaseService::Result.new) + + described_class.perform_now(organization:, params:) + + expect(Payments::ManualCreateService).to have_received(:call!) + end +end diff --git a/spec/jobs/payments/set_payment_method_and_create_receipt_job_spec.rb b/spec/jobs/payments/set_payment_method_and_create_receipt_job_spec.rb new file mode 100644 index 0000000..f552c88 --- /dev/null +++ b/spec/jobs/payments/set_payment_method_and_create_receipt_job_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Payments::SetPaymentMethodAndCreateReceiptJob do + let(:payment) { create(:payment) } + let(:provider_payment_method_id) { "pm_001" } + + before do + allow(Payments::SetPaymentMethodDataService) + .to receive(:call!).with(payment:, provider_payment_method_id:).and_return(BaseService::Result.new) + end + + it "calls the service" do + described_class.perform_now(payment:, provider_payment_method_id:) + + expect(Payments::SetPaymentMethodDataService).to have_received(:call!) + end + + context "with customer payment_method" do + let(:customer) { payment.customer } + let(:payment_method) { create(:payment_method, customer:, provider_method_id: provider_payment_method_id) } + + before { payment_method } + + it "attachs the payment method to the payment" do + described_class.perform_now(payment:, provider_payment_method_id:) + expect(payment.payment_method_id).to eq(payment_method.id) + end + end +end diff --git a/spec/jobs/plans/destroy_job_spec.rb b/spec/jobs/plans/destroy_job_spec.rb new file mode 100644 index 0000000..6dfc05b --- /dev/null +++ b/spec/jobs/plans/destroy_job_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::DestroyJob do + include ActiveJob::TestHelper + + let(:plan) { create(:plan) } + let(:child_plan) { create(:plan, parent: plan) } + let(:service_result) { BaseService::Result.new } + + before do + plan.children << child_plan + end + + describe "unique job behavior" do + around do |example| + ActiveJob::Uniqueness.reset_manager! + example.run + ActiveJob::Uniqueness.test_mode! + end + + it "does not enqueue duplicate jobs" do + expect do + described_class.perform_later(plan) + described_class.perform_later(plan) + end.to change { enqueued_jobs.count }.by(1) # rubocop:disable RSpec/ExpectChange + end + end + + describe "#perform" do + before do + allow(Plans::DestroyService).to receive(:call).and_return(service_result) + end + + context "when destroy service succeeds" do + let(:service_result) { BaseService::Result.new } + + it "calls the destroy service for child plans first" do + described_class.perform_now(plan) + + expect(Plans::DestroyService) + .to have_received(:call) + .with(plan: child_plan) + .ordered + + expect(Plans::DestroyService) + .to have_received(:call) + .with(plan: plan) + .ordered + end + end + + context "when destroy service fails" do + let(:service_result) do + BaseService::Result.new.service_failure!( + code: "failure", + message: "Destroy failed" + ) + end + + it "raises an error when the destroy service fails" do + expect { described_class.perform_now(plan) } + .to raise_error(BaseService::ServiceFailure, "failure: Destroy failed") + end + end + end +end diff --git a/spec/jobs/plans/update_amount_job_spec.rb b/spec/jobs/plans/update_amount_job_spec.rb new file mode 100644 index 0000000..d90b958 --- /dev/null +++ b/spec/jobs/plans/update_amount_job_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::UpdateAmountJob do + let(:plan) { create(:plan) } + let(:amount_cents) { 200 } + let(:expected_amount_cents) { 100 } + + before do + allow(Plans::UpdateAmountService).to receive(:call).with(plan:, amount_cents:, expected_amount_cents:).and_call_original + end + + it "calls the service" do + described_class.perform_now(plan:, amount_cents:, expected_amount_cents:) + + expect(Plans::UpdateAmountService).to have_received(:call).with(plan:, amount_cents:, expected_amount_cents:) + end +end diff --git a/spec/jobs/segment_identify_job_spec.rb b/spec/jobs/segment_identify_job_spec.rb new file mode 100644 index 0000000..dab5331 --- /dev/null +++ b/spec/jobs/segment_identify_job_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe SegmentIdentifyJob, job: true do + subject { described_class } + + describe ".perform" do + let(:membership_id) { "membership/#{membership.id}" } + let(:membership) { create(:membership) } + + before do + ENV["LAGO_DISABLE_SEGMENT"] = "" + allow(CurrentContext).to receive(:membership).and_return(membership_id) + allow(SEGMENT_CLIENT).to receive(:identify) + end + + it "calls SegmentIdentifyJob's process method" do + subject.perform_now(membership_id:) + + expect(SEGMENT_CLIENT).to have_received(:identify) + .with( + user_id: membership_id, + traits: { + created_at: membership.created_at, + hosting_type: "self", + version: "test", + organization_name: membership.organization.name, + email: membership.user.email + } + ) + end + + context "when LAGO_CLOUD is true" do + before do + ENV["LAGO_CLOUD"] = "true" + end + + it "includes hosting type equal to cloud" do + subject.perform_now(membership_id:) + + expect(SEGMENT_CLIENT).to have_received(:identify).with( + hash_including(traits: hash_including(hosting_type: "cloud")) + ) + end + end + + context "when membership is nil" do + it "does not send any events" do + subject.perform_now(membership_id: nil) + + expect(SEGMENT_CLIENT).not_to have_received(:identify) + end + end + + context "when membership is unidentifiable" do + it "does not send any events" do + subject.perform_now(membership_id: "membership/unidentifiable") + + expect(SEGMENT_CLIENT).not_to have_received(:identify) + end + end + + context "when LAGO_DISABLE_SEGMENT is true" do + it "does not call SegmentIdentifyJob" do + ENV["LAGO_DISABLE_SEGMENT"] = "true" + + subject.perform_now(membership_id:) + expect(SEGMENT_CLIENT).not_to have_received(:identify) + end + end + end +end diff --git a/spec/jobs/segment_track_job_spec.rb b/spec/jobs/segment_track_job_spec.rb new file mode 100644 index 0000000..ee09f1d --- /dev/null +++ b/spec/jobs/segment_track_job_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe SegmentTrackJob, job: true do + subject { described_class } + + describe ".perform" do + let(:membership_id) { "membership/#{SecureRandom.uuid}" } + let(:event) { "event" } + let(:properties) do + {method: 1} + end + + before do + ENV["LAGO_DISABLE_SEGMENT"] = "" + allow(CurrentContext).to receive(:membership).and_return(membership_id) + allow(SEGMENT_CLIENT).to receive(:track) + end + + it "calls SegmentTrackJob's process method" do + subject.perform_now(membership_id:, event:, properties:) + + expect(SEGMENT_CLIENT).to have_received(:track) + .with( + user_id: membership_id, + event:, + properties: { + method: 1, + hosting_type: "self", + version: "test" + } + ) + end + + context "when LAGO_CLOUD is true" do + it "includes hosting type equal to cloud" do + ENV["LAGO_CLOUD"] = "true" + + subject.perform_now(membership_id:, event:, properties:) + + expect(SEGMENT_CLIENT).to have_received(:track).with( + hash_including(properties: hash_including(hosting_type: "cloud")) + ) + end + end + + context "when membership is nil" do + it "sends event to an unidentifiable membership" do + subject.perform_now(membership_id: nil, event:, properties:) + + expect(SEGMENT_CLIENT).to have_received(:track).with( + hash_including(user_id: "membership/unidentifiable") + ) + end + end + + context "when LAGO_DISABLE_SEGMENT is true" do + it "does not call SegmentTrackJob" do + ENV["LAGO_DISABLE_SEGMENT"] = "true" + + subject.perform_now(membership_id:, event:, properties:) + + expect(SEGMENT_CLIENT).not_to have_received(:track) + end + end + end +end diff --git a/spec/jobs/send_email_job_spec.rb b/spec/jobs/send_email_job_spec.rb new file mode 100644 index 0000000..3558c37 --- /dev/null +++ b/spec/jobs/send_email_job_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +# rubocop:disable RSpec/AnyInstance +RSpec.describe SendEmailJob do + subject(:perform_job) { job.perform_now } + + let(:job) { described_class.new("InvoiceMailer", "created", "deliver_now", args: [], params:) } + let(:invoice) { create(:invoice, fees_amount_cents: 100) } + let(:params) { {invoice:} } + let(:error) { Net::SMTPServerBusy.new("busy") } + + before do + allow(Utils::EmailActivityLog).to receive(:produce) + allow_any_instance_of(InvoiceMailer).to receive(:ensure_pdf) # rubocop:disable RSpec/AnyInstance + end + + it "delivers email" do + expect { perform_job }.to change { ActionMailer::Base.deliveries.count }.by(1) + end + + it "logs activity" do + perform_job + + expect(Utils::EmailActivityLog).to have_received(:produce).with( + document: invoice, + message: be_present, + error: nil + ) + end + + context "with user_id" do + let(:params) { {invoice:, resend: true, user_id: "user-123"} } + + it "passes user_id to activity log" do + perform_job + + expect(Utils::EmailActivityLog).to have_received(:produce).with( + document: invoice, + message: be_present, + error: nil, + resend: true, + user_id: "user-123" + ) + end + end + + context "with api_key_id" do + let(:params) { {invoice:, api_key_id: "key-456"} } + + it "passes api_key_id to activity log" do + perform_job + + expect(Utils::EmailActivityLog).to have_received(:produce).with( + document: invoice, + message: be_present, + error: nil, + api_key_id: "key-456" + ) + end + end + + context "when email is not sent" do + let(:invoice) { create(:invoice, fees_amount_cents: 0) } + + it "does not deliver email" do + expect { perform_job }.not_to change { ActionMailer::Base.deliveries.count } + end + + it "does not log activity" do + perform_job + + expect(Utils::EmailActivityLog).not_to have_received(:produce) + end + end + + describe "retry configuration" do + it "retries on PaymentReceipts::FilesNotReadyError" do + matcher = described_class.rescue_handlers.find { |klass, _| klass == "PaymentReceipts::FilesNotReadyError" } + expect(matcher).not_to be_nil + end + end + + context "when delivery fails with retryable error" do + before { allow_any_instance_of(Mail::Message).to receive(:deliver).and_raise(error) } + + it "does not log activity on first attempt" do + perform_job + expect(Utils::EmailActivityLog).not_to have_received(:produce) + end + + context "when retries exhausted" do + before { job.exception_executions["[Net::SMTPServerBusy]"] = 25 } + + it "logs activity with error after final failure" do + expect { perform_job }.to raise_error(Net::SMTPServerBusy) + + expect(Utils::EmailActivityLog).to have_received(:produce).with( + document: invoice, + message: be_present, + error: + ) + end + end + end +end +# rubocop:enable RSpec/AnyInstance diff --git a/spec/jobs/send_webhook_job_spec.rb b/spec/jobs/send_webhook_job_spec.rb new file mode 100644 index 0000000..9d44c62 --- /dev/null +++ b/spec/jobs/send_webhook_job_spec.rb @@ -0,0 +1,754 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SendWebhookJob do + subject(:send_webhook_job) { described_class } + + let(:organization) { create(:organization, webhook_url: "http://foo.bar") } + let(:invoice) { create(:invoice, organization:) } + + describe ".perform_later" do + context "when no webhook endpoints is present" do + let(:organization) { create(:organization, webhook_url: nil) } + + it "does not enqueue a job" do + expect do + described_class.perform_later("invoice.created", invoice) + end.not_to have_enqueued_job(described_class) + end + + context "when webhook_id is nil" do + it "does not enqueue a job" do + expect do + described_class.perform_later("invoice.created", invoice, {}, nil) + end.not_to have_enqueued_job(described_class) + end + end + end + + context "when webhook_id is present" do + let(:webhook) { create(:webhook, webhook_endpoint: organization.webhook_endpoints.first) } + + it "enqueues a job to send the webhook" do + expect do + described_class.perform_later("invoice.created", invoice, {}, webhook.id) + end.to have_enqueued_job(described_class).with( + "invoice.created", + invoice, + {}, + webhook.id + ) + end + end + + context "when webhook endpoint is present and no webhook_id is present" do + it "enqueues a job to send the webhook" do + expect do + described_class.perform_later("invoice.created", invoice, {key: "value"}) + end.to have_enqueued_job(described_class).with("invoice.created", invoice, {key: "value"}) + end + end + end + + describe "#perform" do + shared_examples "a webhook service" do |webhook_type, service_class, object, options| + let(:webhook_service) { instance_double(service_class) } + + before do + allow(service_class).to receive(:new) + .with(object: object, options: options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now(webhook_type, object, options) + + expect(service_class).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_id is present" do + let(:webhook_service) { instance_double(Webhooks::Invoices::CreatedService) } + + before do + allow(Webhooks::Invoices::CreatedService).to receive(:new) + allow(SendHttpWebhookJob).to receive(:perform_later) + end + + it "calls the webhook invoice service" do + webhook = create(:webhook, webhook_endpoint: create(:webhook_endpoint, organization:)) + send_webhook_job.perform_now("invoice.created", invoice, {}, webhook.id) + + expect(SendHttpWebhookJob).to have_received(:perform_later).with(webhook) + expect(Webhooks::Invoices::CreatedService).not_to have_received(:new) + end + end + + context "when webhook_type is invoice.created" do + let(:webhook_service) { instance_double(Webhooks::Invoices::CreatedService) } + + before do + allow(Webhooks::Invoices::CreatedService).to receive(:new) + .with(object: invoice, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook invoice service" do + send_webhook_job.perform_now("invoice.created", invoice) + + expect(Webhooks::Invoices::CreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is invoice.paid_credit_added" do + let(:webhook_service) { instance_double(Webhooks::Invoices::PaidCreditAddedService) } + + before do + allow(Webhooks::Invoices::PaidCreditAddedService).to receive(:new) + .with(object: invoice, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook invoice paid credit added service" do + send_webhook_job.perform_now("invoice.paid_credit_added", invoice) + + expect(Webhooks::Invoices::PaidCreditAddedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is invoice.resynced" do + let(:webhook_service) { instance_double(Webhooks::Invoices::ResyncedService) } + + before do + allow(Webhooks::Invoices::ResyncedService).to receive(:new) + .with(object: invoice, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook invoice resynced service" do + send_webhook_job.perform_now("invoice.resynced", invoice) + + expect(Webhooks::Invoices::ResyncedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is event" do + let(:webhook_service) { instance_double(Webhooks::Events::ErrorService) } + let(:object) do + { + input_params: { + customer_id: "customer", + transaction_id: SecureRandom.uuid, + code: "code" + }, + error: "Code does not exist", + organization_id: organization.id + } + end + + before do + allow(Webhooks::Events::ErrorService).to receive(:new) + .with(object:, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook event service" do + send_webhook_job.perform_now("event.error", object) + + expect(Webhooks::Events::ErrorService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is events.errors" do + let(:webhook_service) { instance_double(Webhooks::Events::ValidationErrorsService) } + let(:object) { organization } + let(:options) do + { + errors: [ + invalid_code: [SecureRandom.uuid], + missing_aggregation_property: [SecureRandom.uuid], + missing_group_key: [SecureRandom.uuid] + ] + } + end + + before do + allow(Webhooks::Events::ValidationErrorsService).to receive(:new) + .with(object:, options:) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook event service" do + send_webhook_job.perform_now("events.errors", object, options) + + expect(Webhooks::Events::ValidationErrorsService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is fee.created" do + let(:webhook_service) { instance_double(Webhooks::Fees::PayInAdvanceCreatedService) } + let(:fee) { create(:fee) } + + before do + allow(Webhooks::Fees::PayInAdvanceCreatedService).to receive(:new) + .with(object: fee, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook fee service" do + send_webhook_job.perform_now("fee.created", fee) + + expect(Webhooks::Fees::PayInAdvanceCreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is event.error" do + let(:webhook_service) { instance_double(Webhooks::PaymentProviders::InvoicePaymentFailureService) } + + let(:webhook_options) do + { + provider_error: { + message: "message", + error_code: "code" + } + } + end + + before do + allow(Webhooks::PaymentProviders::InvoicePaymentFailureService).to receive(:new) + .with(object: invoice, options: webhook_options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook event service" do + send_webhook_job.perform_now( + "invoice.payment_failure", + invoice, + webhook_options + ) + + expect(Webhooks::PaymentProviders::InvoicePaymentFailureService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is customer.payment_provider_created" do + let(:webhook_service) { instance_double(Webhooks::PaymentProviders::CustomerCreatedService) } + let(:customer) { create(:customer) } + + before do + allow(Webhooks::PaymentProviders::CustomerCreatedService).to receive(:new) + .with(object: customer, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook event service" do + send_webhook_job.perform_now( + "customer.payment_provider_created", + customer + ) + + expect(Webhooks::PaymentProviders::CustomerCreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is customer.accounting_provider_created" do + let(:webhook_service) { instance_double(Webhooks::Integrations::AccountingCustomerCreatedService) } + let(:customer) { create(:customer) } + + before do + allow(Webhooks::Integrations::AccountingCustomerCreatedService).to receive(:new) + .with(object: customer, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook event service" do + send_webhook_job.perform_now( + "customer.accounting_provider_created", + customer + ) + + expect(Webhooks::Integrations::AccountingCustomerCreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is customer.crm_provider_created" do + let(:webhook_service) { instance_double(Webhooks::Integrations::CrmCustomerCreatedService) } + let(:customer) { create(:customer) } + + before do + allow(Webhooks::Integrations::CrmCustomerCreatedService).to receive(:new) + .with(object: customer, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook event service" do + send_webhook_job.perform_now( + "customer.crm_provider_created", + customer + ) + + expect(Webhooks::Integrations::CrmCustomerCreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is customer.checkout_url_generated" do + let(:webhook_service) { instance_double(Webhooks::PaymentProviders::CustomerCheckoutService) } + let(:customer) { create(:customer) } + + before do + allow(Webhooks::PaymentProviders::CustomerCheckoutService).to receive(:new) + .with(object: customer, options: {checkout_url: "https://example.com"}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now( + "customer.checkout_url_generated", + customer, + checkout_url: "https://example.com" + ) + + expect(Webhooks::PaymentProviders::CustomerCheckoutService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is customer.payment_provider_error" do + let(:webhook_service) { instance_double(Webhooks::PaymentProviders::CustomerErrorService) } + let(:customer) { create(:customer) } + + let(:webhook_options) do + { + provider_error: { + message: "message", + error_code: "code" + } + } + end + + before do + allow(Webhooks::PaymentProviders::CustomerErrorService).to receive(:new) + .with(object: customer, options: webhook_options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook event service" do + send_webhook_job.perform_now( + "customer.payment_provider_error", + customer, + webhook_options + ) + + expect(Webhooks::PaymentProviders::CustomerErrorService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is credit_note.created" do + let(:webhook_service) { instance_double(Webhooks::CreditNotes::CreatedService) } + let(:credit_note) { create(:credit_note) } + + before do + allow(Webhooks::CreditNotes::CreatedService).to receive(:new) + .with(object: credit_note, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now( + "credit_note.created", + credit_note + ) + + expect(Webhooks::CreditNotes::CreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is credit_note.generated" do + let(:webhook_service) { instance_double(Webhooks::CreditNotes::GeneratedService) } + let(:credit_note) { create(:credit_note) } + + before do + allow(Webhooks::CreditNotes::GeneratedService).to receive(:new) + .with(object: credit_note, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now( + "credit_note.generated", + credit_note + ) + + expect(Webhooks::CreditNotes::GeneratedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is credit_note.provider_refund_failure" do + let(:webhook_service) { instance_double(Webhooks::CreditNotes::PaymentProviderRefundFailureService) } + let(:credit_note) { create(:credit_note) } + + let(:webhook_options) do + { + provider_error: { + message: "message", + error_code: "code" + } + } + end + + before do + allow(Webhooks::CreditNotes::PaymentProviderRefundFailureService).to receive(:new) + .with(object: credit_note, options: webhook_options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + described_class.perform_now( + "credit_note.provider_refund_failure", + credit_note, + webhook_options + ) + + expect(Webhooks::CreditNotes::PaymentProviderRefundFailureService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is invoice.drafted" do + let(:webhook_service) { instance_double(Webhooks::Invoices::DraftedService) } + let(:invoice) { create(:invoice, organization:) } + + before do + allow(Webhooks::Invoices::DraftedService).to receive(:new) + .with(object: invoice, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now( + "invoice.drafted", + invoice + ) + + expect(Webhooks::Invoices::DraftedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook type is invoice.payment_status_updated" do + let(:webhook_service) { instance_double(Webhooks::Invoices::PaymentStatusUpdatedService) } + let(:invoice) { create(:invoice, organization:) } + + before do + allow(Webhooks::Invoices::PaymentStatusUpdatedService).to receive(:new) + .with(object: invoice, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now( + "invoice.payment_status_updated", + invoice + ) + + expect(Webhooks::Invoices::PaymentStatusUpdatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "with not implemented webhook type" do + it "raises a NotImplementedError" do + expect { send_webhook_job.perform_now(:subscription, invoice) } + .to raise_error(NotImplementedError) + end + end + + context "with subscription webhooks" do + let(:object) { create(:subscription) } + let(:options) { {usage_threshold: create(:usage_threshold)} } + + it_behaves_like "a webhook service", + "subscription.canceled", + Webhooks::Subscriptions::CanceledService + + it_behaves_like "a webhook service", + "subscription.incomplete", + Webhooks::Subscriptions::IncompleteService + + it_behaves_like "a webhook service", + "subscription.started", + Webhooks::Subscriptions::StartedService + + it_behaves_like "a webhook service", + "subscription.terminated", + Webhooks::Subscriptions::TerminatedService + + it_behaves_like "a webhook service", + "subscription.updated", + Webhooks::Subscriptions::UpdatedService + + it_behaves_like "a webhook service", + "subscription.termination_alert", + Webhooks::Subscriptions::TerminationAlertService + + it_behaves_like "a webhook service", + "subscription.trial_ended", + Webhooks::Subscriptions::TrialEndedService + + it_behaves_like "a webhook service", + "subscription.usage_threshold_reached", + Webhooks::Subscriptions::UsageThresholdsReachedService + end + + context "when webhook type is customer.vies_check" do + let(:webhook_service) { instance_double(Webhooks::Customers::ViesCheckService) } + let(:customer) { create(:customer) } + + before do + allow(Webhooks::Customers::ViesCheckService).to receive(:new) + .with(object: customer, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now( + "customer.vies_check", + customer + ) + + expect(Webhooks::Customers::ViesCheckService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook type is customer.created" do + let(:webhook_service) { instance_double(Webhooks::Customers::CreatedService) } + let(:customer) { create(:customer) } + + before do + allow(Webhooks::Customers::CreatedService).to receive(:new) + .with(object: customer, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the created webhook service" do + send_webhook_job.perform_now("customer.created", customer) + + expect(Webhooks::Customers::CreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook type is customer.updated" do + let(:webhook_service) { instance_double(Webhooks::Customers::UpdatedService) } + let(:customer) { create(:customer) } + + before do + allow(Webhooks::Customers::UpdatedService).to receive(:new) + .with(object: customer, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the updated webhook service" do + send_webhook_job.perform_now("customer.updated", customer) + + expect(Webhooks::Customers::UpdatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is payment_request.created" do + let(:webhook_service) { instance_double(Webhooks::PaymentRequests::CreatedService) } + let(:payment_request) { create(:payment_request) } + + before do + allow(Webhooks::PaymentRequests::CreatedService).to receive(:new) + .with(object: payment_request, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook payment_request service" do + send_webhook_job.perform_now("payment_request.created", payment_request) + + expect(Webhooks::PaymentRequests::CreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is payment_receipt.created" do + let(:webhook_service) { instance_double(Webhooks::PaymentReceipts::CreatedService) } + let(:payment_receipt) { create(:payment_receipt) } + + before do + allow(Webhooks::PaymentReceipts::CreatedService).to receive(:new) + .with(object: payment_receipt, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook payment_receipt service" do + send_webhook_job.perform_now("payment_receipt.created", payment_receipt) + + expect(Webhooks::PaymentReceipts::CreatedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is payment_receipt.generated" do + let(:webhook_service) { instance_double(Webhooks::PaymentReceipts::GeneratedService) } + let(:payment_receipt) { create(:payment_receipt) } + + before do + allow(Webhooks::PaymentReceipts::GeneratedService).to receive(:new) + .with(object: payment_receipt, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook service" do + send_webhook_job.perform_now( + "payment_receipt.generated", + payment_receipt + ) + + expect(Webhooks::PaymentReceipts::GeneratedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is payment_request.payment_failure" do + let(:webhook_service) { instance_double(Webhooks::PaymentProviders::PaymentRequestPaymentFailureService) } + let(:payment_request) { create(:payment_request) } + let(:webhook_options) do + { + provider_error: { + message: "message", + error_code: "code" + } + } + end + + before do + allow(Webhooks::PaymentProviders::PaymentRequestPaymentFailureService) + .to receive(:new) + .with(object: payment_request, options: webhook_options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook payment_request_payment_failure service" do + send_webhook_job.perform_now("payment_request.payment_failure", payment_request, webhook_options) + + expect(Webhooks::PaymentProviders::PaymentRequestPaymentFailureService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is payment.requires_action" do + let(:webhook_service) { instance_double(Webhooks::Payments::RequiresActionService) } + let(:payment) { create(:payment, :requires_action) } + let(:webhook_options) do + { + provider_customer_id: "customer_id" + } + end + + before do + allow(Webhooks::Payments::RequiresActionService) + .to receive(:new) + .with(object: payment, options: webhook_options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook payment.requires_action" do + send_webhook_job.perform_now("payment.requires_action", payment, webhook_options) + + expect(Webhooks::Payments::RequiresActionService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is payment.succeeded" do + let(:webhook_service) { instance_double(Webhooks::Payments::SucceededService) } + let(:payment) { create(:payment) } + + before do + allow(Webhooks::Payments::SucceededService) + .to receive(:new) + .with(object: payment, options: {}) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook payment.succeeded" do + send_webhook_job.perform_now("payment.succeeded", payment) + + expect(Webhooks::Payments::SucceededService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + + context "when webhook_type is dunning_campaign.finished" do + let(:webhook_service) { instance_double(Webhooks::DunningCampaigns::FinishedService) } + let(:customer) { create(:customer) } + let(:webhook_options) do + { + dunning_campaign_code: "campaign_code" + } + end + + before do + allow(Webhooks::DunningCampaigns::FinishedService) + .to receive(:new) + .with(object: customer, options: webhook_options) + .and_return(webhook_service) + allow(webhook_service).to receive(:call) + end + + it "calls the webhook dunning_campaign.finished" do + send_webhook_job.perform_now("dunning_campaign.finished", customer, webhook_options) + + expect(Webhooks::DunningCampaigns::FinishedService).to have_received(:new) + expect(webhook_service).to have_received(:call) + end + end + end +end diff --git a/spec/jobs/subscriptions/activation_rules/payment/resolve_job_spec.rb b/spec/jobs/subscriptions/activation_rules/payment/resolve_job_spec.rb new file mode 100644 index 0000000..14bb5e5 --- /dev/null +++ b/spec/jobs/subscriptions/activation_rules/payment/resolve_job_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::Payment::ResolveJob do + subject(:job) { described_class.perform_now(subscription, invoice, payment_status) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, :incomplete, organization:, customer:) } + let(:invoice) { create(:invoice, organization:, customer:, status: :open, invoice_type: :subscription) } + let(:payment_status) { "failed" } + + before do + allow(Subscriptions::ActivationRules::Payment::ResolveService).to receive(:call!) + end + + it "calls ResolveService with the correct arguments" do + job + + expect(Subscriptions::ActivationRules::Payment::ResolveService).to have_received(:call!) + .with(subscription:, invoice:, payment_status:) + end +end diff --git a/spec/jobs/subscriptions/flag_refreshed_job_spec.rb b/spec/jobs/subscriptions/flag_refreshed_job_spec.rb new file mode 100644 index 0000000..416b44d --- /dev/null +++ b/spec/jobs/subscriptions/flag_refreshed_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::FlagRefreshedJob do + let(:subscription_id) { SecureRandom.uuid } + + it_behaves_like "a configurable queue", "events", "SIDEKIQ_EVENTS" do + let(:arguments) { subscription_id } + end + + describe "#perform" do + it "calls the subscriptions flag refreshed job" do + allow(Subscriptions::FlagRefreshedService).to receive(:call!) + + described_class.perform_now(subscription_id) + + expect(Subscriptions::FlagRefreshedService).to have_received(:call!) + .with(subscription_id) + end + end +end diff --git a/spec/jobs/subscriptions/organization_billing_job_spec.rb b/spec/jobs/subscriptions/organization_billing_job_spec.rb new file mode 100644 index 0000000..8597d47 --- /dev/null +++ b/spec/jobs/subscriptions/organization_billing_job_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::OrganizationBillingJob do + subject { described_class } + + describe ".perform" do + let(:organization) { create(:organization, api_keys: []) } + let(:result) { BaseService::Result.new } + + it "calls the subscriptions biller service" do + allow(Subscriptions::OrganizationBillingService).to receive(:call!) + .with(organization:) + .and_return(result) + + described_class.perform_now(organization:) + + expect(Subscriptions::OrganizationBillingService).to have_received(:call!) + end + end +end diff --git a/spec/jobs/subscriptions/terminate_ended_subscription_job_spec.rb b/spec/jobs/subscriptions/terminate_ended_subscription_job_spec.rb new file mode 100644 index 0000000..d81e5e2 --- /dev/null +++ b/spec/jobs/subscriptions/terminate_ended_subscription_job_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::TerminateEndedSubscriptionJob do + let(:subscription) { create(:subscription) } + let(:result) { BaseService::Result.new } + + before do + allow(Subscriptions::TerminateService).to receive(:call!).and_call_original + allow(Subscriptions::TerminateService).to receive(:call) + .with(subscription:) + .and_return(result) + end + + describe "#perform" do + it "calls the subscription service" do + described_class.perform_now(subscription) + + expect(Subscriptions::TerminateService).to have_received(:call!).with(subscription:) + end + + context "when the service returns a failure" do + let(:result) do + BaseService::Result.new.not_found_failure!(resource: "subscription") + end + + it "raises a FailedResult error" do + expect { described_class.perform_now(subscription) } + .to raise_error(BaseService::FailedResult) + end + end + end +end diff --git a/spec/jobs/subscriptions/terminate_job_spec.rb b/spec/jobs/subscriptions/terminate_job_spec.rb new file mode 100644 index 0000000..4b62c1a --- /dev/null +++ b/spec/jobs/subscriptions/terminate_job_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::TerminateJob do + let(:subscription) { create(:subscription) } + let(:timestamp) { Time.zone.now.to_i } + + let(:subscription_service) { instance_double(Subscriptions::TerminateService) } + let(:result) { BaseService::Result.new } + + it "calls the subscription service" do + allow(Subscriptions::TerminateService).to receive(:new) + .with(subscription:) + .and_return(subscription_service) + allow(subscription_service).to receive(:terminate_and_start_next) + .with(timestamp:) + .and_return(result) + + described_class.perform_now(subscription, timestamp) + + expect(Subscriptions::TerminateService).to have_received(:new) + expect(subscription_service).to have_received(:terminate_and_start_next) + end + + context "when result is a failure" do + let(:result) do + BaseService::Result.new.not_found_failure!(resource: "subscription") + end + + it "raises an error" do + allow(Subscriptions::TerminateService).to receive(:new) + .with(subscription:) + .and_return(subscription_service) + allow(subscription_service).to receive(:terminate_and_start_next) + .with(timestamp:) + .and_return(result) + + expect do + described_class.perform_now(subscription, timestamp) + end.to raise_error(BaseService::FailedResult) + + expect(Subscriptions::TerminateService).to have_received(:new) + expect(subscription_service).to have_received(:terminate_and_start_next) + end + end +end diff --git a/spec/jobs/taxes/update_all_eu_taxes_job_spec.rb b/spec/jobs/taxes/update_all_eu_taxes_job_spec.rb new file mode 100644 index 0000000..231ea36 --- /dev/null +++ b/spec/jobs/taxes/update_all_eu_taxes_job_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Taxes::UpdateAllEuTaxesJob do + subject { described_class.perform_now } + + let(:organization) { create(:organization, eu_tax_management: true) } + + describe ".perform" do + it "enqueues a job for organization with EU Tax Management" do + create(:organization, eu_tax_management: false) + + expect { subject }.to have_enqueued_job(::Taxes::UpdateOrganizationEuTaxesJob).with(organization).exactly(:once) + end + end +end diff --git a/spec/jobs/taxes/update_organization_eu_taxes_job_spec.rb b/spec/jobs/taxes/update_organization_eu_taxes_job_spec.rb new file mode 100644 index 0000000..d8329ef --- /dev/null +++ b/spec/jobs/taxes/update_organization_eu_taxes_job_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Taxes::UpdateOrganizationEuTaxesJob do + let(:organization) { create(:organization) } + + describe ".perform" do + let(:organization) { create(:organization, api_keys: []) } + let(:result) { BaseService::Result.new } + + it "calls the subscriptions biller service" do + allow(Taxes::AutoGenerateService).to receive(:call!).and_call_original + + described_class.perform_now(organization) + + expect(Taxes::AutoGenerateService).to have_received(:call!).with(organization:) + end + end +end diff --git a/spec/jobs/usage_monitoring/process_lifetime_usage_alert_job_spec.rb b/spec/jobs/usage_monitoring/process_lifetime_usage_alert_job_spec.rb new file mode 100644 index 0000000..d6cf537 --- /dev/null +++ b/spec/jobs/usage_monitoring/process_lifetime_usage_alert_job_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessLifetimeUsageAlertJob do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, organization:) } + let(:alert) { create(:billable_metric_lifetime_usage_units_alert, organization:, subscription_external_id: subscription.external_id) } + + it_behaves_like "a unique job" do + let(:job_args) { [{alert:, subscription:}] } + end + + describe "#perform" do + before do + allow(UsageMonitoring::ProcessLifetimeUsageAlertService).to receive(:call!) + end + + it "calls ProcessLifetimeUsageAlertService with the alert and subscription" do + described_class.perform_now(alert:, subscription:) + expect(UsageMonitoring::ProcessLifetimeUsageAlertService).to have_received(:call!).with(alert:, subscription:) + end + end +end diff --git a/spec/jobs/usage_monitoring/process_organization_subscription_activities_job_spec.rb b/spec/jobs/usage_monitoring/process_organization_subscription_activities_job_spec.rb new file mode 100644 index 0000000..6555ca0 --- /dev/null +++ b/spec/jobs/usage_monitoring/process_organization_subscription_activities_job_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob do + let(:organization) { create(:organization) } + + before do + allow(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesService).to receive(:call!) + end + + context "when license is premium", :premium do + it "calls the service with the organization" do + described_class.perform_now(organization.id) + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesService).to have_received(:call!).with(organization:) + end + end + + context "when license is not premium" do + it "does not call the service or log" do + described_class.perform_now(organization.id) + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesService).not_to have_received(:call!) + end + end + + describe "queue routing" do + let(:other_organization) { create(:organization) } + + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", [organization.id]) } + + it "routes to the dedicated queue for a targeted organization" do + job = described_class.new(organization.id) + expect(job.queue_name).to eq("dedicated_alerts") + end + + it "falls back to the default queue for non-targeted organizations" do + job = described_class.new(other_organization.id) + expect(job.queue_name).to eq("default") + end + end +end diff --git a/spec/jobs/usage_monitoring/process_subscription_activity_job_spec.rb b/spec/jobs/usage_monitoring/process_subscription_activity_job_spec.rb new file mode 100644 index 0000000..2c6da32 --- /dev/null +++ b/spec/jobs/usage_monitoring/process_subscription_activity_job_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessSubscriptionActivityJob do + describe "#perform" do + let(:subscription_activity) { create(:subscription_activity) } + let(:subscription_activity_id) { subscription_activity.id } + + before do + allow(UsageMonitoring::ProcessSubscriptionActivityService).to receive(:call!) + end + + it "calls the ProcessSubscriptionActivityService with the subscription activity" do + described_class.perform_now(subscription_activity_id) + expect(UsageMonitoring::ProcessSubscriptionActivityService).to have_received(:call!).with(subscription_activity:) + end + + context "when the subscription activity does not exist" do + let(:subscription_activity_id) { 9_999_999_999_999 } + + it "does not call the ProcessSubscriptionActivityService" do + expect(UsageMonitoring::ProcessSubscriptionActivityService).not_to have_received(:call!) + described_class.perform_now(subscription_activity_id) + end + end + + context "when ProcessSubscriptionActivityService raises" do + before do + allow(described_class).to receive(:perform_later) + allow(UsageMonitoring::ProcessSubscriptionActivityService).to receive(:call!).and_raise(BaseService::ThrottlingError) + end + + it "re-enqueues the job" do + described_class.perform_now(subscription_activity_id) + expect(described_class).to have_received(:perform_later).with(subscription_activity_id, 2) + end + + context "when the max retries is reached" do + it "removes the SubscriptionActivity" do + begin + described_class.perform_now(subscription_activity_id, 4) + rescue BaseService::ThrottlingError => _e + end + expect(described_class).not_to have_received(:perform_later) + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + end +end diff --git a/spec/jobs/usage_monitoring/process_wallet_alerts_job_spec.rb b/spec/jobs/usage_monitoring/process_wallet_alerts_job_spec.rb new file mode 100644 index 0000000..e52676f --- /dev/null +++ b/spec/jobs/usage_monitoring/process_wallet_alerts_job_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessWalletAlertsJob do + describe "#perform" do + let(:wallet) { create(:wallet) } + + before do + allow(UsageMonitoring::ProcessWalletAlertsService).to receive(:call!) + end + + context "when premium", :premium do + it "calls the ProcessWalletAlertsService with the wallet" do + described_class.perform_now(wallet) + expect(UsageMonitoring::ProcessWalletAlertsService).to have_received(:call!).with(wallet:) + end + end + + context "when freemium" do + it "does not call the ProcessWalletAlertsService with the wallet" do + described_class.perform_now(wallet) + expect(UsageMonitoring::ProcessWalletAlertsService).not_to have_received(:call!) + end + end + end +end diff --git a/spec/jobs/wallet_transactions/create_job_spec.rb b/spec/jobs/wallet_transactions/create_job_spec.rb new file mode 100644 index 0000000..f35be22 --- /dev/null +++ b/spec/jobs/wallet_transactions/create_job_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::CreateJob do + subject(:create_job) { described_class } + + let(:organization) { create(:organization) } + let(:wallet) { create(:wallet) } + let(:wallet_transaction_create_service) { instance_double(WalletTransactions::CreateFromParamsService) } + let(:params) do + { + wallet_id: wallet.id, + paid_credits: "1.00", + granted_credits: "1.00", + source: "manual" + } + end + + it "calls the WalletTransactions::CreateFromParamsService" do + allow(WalletTransactions::CreateFromParamsService).to receive(:call!) + + described_class.perform_now(organization_id: organization.id, params:) + + expect(WalletTransactions::CreateFromParamsService).to have_received(:call!).with(organization:, params:) + end + + describe "#lock_key_arguments" do + let(:organization_id) { "org-123" } + let(:wallet_id) { "wallet-456" } + let(:params) do + { + wallet_id: wallet_id, + paid_credits: "10.0", + granted_credits: "3.0", + source: :threshold + } + end + + context "when unique_transaction is true" do + it "returns a stable lock key array" do + job = described_class.new + allow(job).to receive(:arguments).and_return([{ + organization_id: organization_id, + params: params, + unique_transaction: true + }]) + + expect(job.lock_key_arguments).to eq([ + organization_id, + wallet_id, + "10.0", + "3.0" + ]) + end + end + end +end diff --git a/spec/lib/active_job/json_log_subscriber_spec.rb b/spec/lib/active_job/json_log_subscriber_spec.rb new file mode 100644 index 0000000..7cfc3b9 --- /dev/null +++ b/spec/lib/active_job/json_log_subscriber_spec.rb @@ -0,0 +1,586 @@ +# frozen_string_literal: true + +require "rails_helper" +require "active_job/json_log_subscriber" + +class TestLogJob < ApplicationJob + self.log_arguments = false + + def perform(*) + end +end + +RSpec.describe ActiveJob::JsonLogSubscriber do + subject(:subscriber) { described_class.new } + + let(:log_output) { StringIO.new } + let(:logger) { ActiveSupport::Logger.new(log_output) } + + around do |example| + original_logger = ActiveSupport::LogSubscriber.logger + ActiveSupport::LogSubscriber.logger = logger + example.run + ensure + ActiveSupport::LogSubscriber.logger = original_logger + end + + def build_job( + job_id: "test-job-id", + queue_name: "default", + enqueue_error: nil, + enqueued_at: nil, + scheduled_at: nil, + executions: 0 + ) + TestLogJob.new.tap do |job| + job.job_id = job_id + job.queue_name = queue_name + job.enqueue_error = enqueue_error + job.enqueued_at = enqueued_at + job.scheduled_at = scheduled_at + job.executions = executions + end + end + + def build_event(name, payload) + ActiveSupport::Notifications::Event.new(name, nil, nil, "transaction_id", payload) + end + + def parsed_log_lines + log_output.rewind + log_output.read.lines.map { |l| JSON.parse(l) } + end + + describe "#enqueue" do + context "when the job is successfully enqueued" do + it "logs a success entry with all expected attributes" do + job = build_job(job_id: "abc-123", queue_name: "billing") + event = build_event("enqueue.active_job", {job: job, exception_object: nil, aborted: false}) + + subscriber.enqueue(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "success", + "job" => "TestLogJob", + "job_id" => "abc-123", + "queue" => "billing", + "arguments" => {} + }) + end + end + + context "when the job has an exception" do + it "logs an error entry with all expected attributes" do + exception = RuntimeError.new("redis down") + job = build_job + event = build_event("enqueue.active_job", {job: job, exception_object: exception}) + + subscriber.enqueue(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "default", + "exception" => {"class" => "RuntimeError", "message" => "redis down"} + }) + end + end + + context "when the job has an enqueue_error" do + it "logs an error entry with all expected attributes" do + error = ArgumentError.new("invalid args") + job = build_job(queue_name: "low_priority", enqueue_error: error) + event = build_event("enqueue.active_job", {job: job, exception_object: nil}) + + subscriber.enqueue(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "low_priority", + "exception" => {"class" => "ArgumentError", "message" => "invalid args"} + }) + end + end + + context "when the job is aborted" do + it "logs an aborted entry with all expected attributes" do + job = build_job + event = build_event("enqueue.active_job", {job: job, exception_object: nil, aborted: true}) + + subscriber.enqueue(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "aborted", + "job" => "TestLogJob", + "queue" => "default" + }) + end + end + end + + describe "#enqueue_all" do + context "when all jobs are successfully enqueued" do + it "logs a success entry with all expected attributes for each job" do + job1 = build_job(job_id: "id-1", queue_name: "low_priority") + job2 = build_job(job_id: "id-2", queue_name: "default") + event = build_event("enqueue_all.active_job", {jobs: [job1, job2], exception_object: nil}) + + subscriber.enqueue_all(event) + + logs = parsed_log_lines + expect(logs.size).to eq(2) + + job1_log = logs.find { |l| l["job_id"] == "id-1" } + job2_log = logs.find { |l| l["job_id"] == "id-2" } + + expect(job1_log).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "success", + "job" => "TestLogJob", + "job_id" => "id-1", + "queue" => "low_priority", + "arguments" => {} + }) + + expect(job2_log).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "success", + "job" => "TestLogJob", + "job_id" => "id-2", + "queue" => "default", + "arguments" => {} + }) + end + end + + context "when there is a global exception" do + it "logs an error entry with all expected attributes for each job" do + job1 = build_job(queue_name: "billing") + job2 = build_job(queue_name: "default") + exception = RuntimeError.new("connection failed") + event = build_event("enqueue_all.active_job", {jobs: [job1, job2], exception_object: exception}) + + subscriber.enqueue_all(event) + + logs = parsed_log_lines + expect(logs.size).to eq(2) + + job1_log = logs.find { |l| l["queue"] == "billing" } + job2_log = logs.find { |l| l["queue"] == "default" } + + expected_exception = {"class" => "RuntimeError", "message" => "connection failed"} + + expect(job1_log).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "billing", + "exception" => expected_exception + }) + + expect(job2_log).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "default", + "exception" => expected_exception + }) + end + end + + context "when individual jobs have enqueue errors" do + it "logs error for failed jobs and success for others with all expected attributes" do + error = ArgumentError.new("queue full") + failed_job = build_job(job_id: "id-fail", enqueue_error: error) + successful_job = build_job(job_id: "id-ok", queue_name: "billing") + event = build_event("enqueue_all.active_job", {jobs: [failed_job, successful_job], exception_object: nil}) + + subscriber.enqueue_all(event) + + logs = parsed_log_lines + expect(logs.size).to eq(2) + + error_log = logs.find { |l| l["status"] == "error" } + success_log = logs.find { |l| l["status"] == "success" } + + expect(error_log).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "default", + "exception" => {"class" => "ArgumentError", "message" => "queue full"} + }) + + expect(success_log).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "success", + "job" => "TestLogJob", + "job_id" => "id-ok", + "queue" => "billing", + "arguments" => {} + }) + end + end + + context "when jobs have scheduled_at set" do + it "includes enqueued_at in the log entry" do + scheduled_at = Time.utc(2024, 6, 15, 10, 30, 0).to_f + job = build_job(job_id: "id-1", scheduled_at: scheduled_at) + event = build_event("enqueue_all.active_job", {jobs: [job], exception_object: nil}) + + subscriber.enqueue_all(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "success", + "job" => "TestLogJob", + "job_id" => "id-1", + "queue" => "default", + "arguments" => {}, + "enqueued_at" => "2024-06-15T10:30:00.000Z" + }) + end + end + + context "when the global exception takes precedence over individual enqueue errors" do + it "logs the global exception for all jobs" do + individual_error = ArgumentError.new("individual error") + global_error = RuntimeError.new("global failure") + job = build_job(enqueue_error: individual_error) + event = build_event("enqueue_all.active_job", {jobs: [job], exception_object: global_error}) + + subscriber.enqueue_all(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "default", + "exception" => {"class" => "RuntimeError", "message" => "global failure"} + }) + end + end + end + + describe "#enqueue_at" do + let(:scheduled_time) { Time.utc(2024, 6, 15, 10, 30, 0) } + + context "when the job is successfully enqueued" do + it "logs a success entry with enqueued_at and all expected attributes" do + job = build_job(job_id: "abc-123", queue_name: "billing", scheduled_at: scheduled_time.to_f) + event = build_event("enqueue_at.active_job", {job: job, exception_object: nil, aborted: false}) + + subscriber.enqueue_at(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "success", + "job" => "TestLogJob", + "job_id" => "abc-123", + "queue" => "billing", + "arguments" => {}, + "enqueued_at" => "2024-06-15T10:30:00.000Z" + }) + end + end + + context "when the job has an exception" do + it "logs an error entry with all expected attributes" do + exception = RuntimeError.new("redis down") + job = build_job + event = build_event("enqueue_at.active_job", {job: job, exception_object: exception}) + + subscriber.enqueue_at(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "default", + "exception" => {"class" => "RuntimeError", "message" => "redis down"} + }) + end + end + + context "when the job has an enqueue_error" do + it "logs an error entry with all expected attributes" do + error = ArgumentError.new("invalid args") + job = build_job(queue_name: "low_priority", enqueue_error: error) + event = build_event("enqueue_at.active_job", {job: job, exception_object: nil}) + + subscriber.enqueue_at(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "enqueue", + "status" => "error", + "job" => "TestLogJob", + "queue" => "low_priority", + "exception" => {"class" => "ArgumentError", "message" => "invalid args"} + }) + end + end + + context "when the job is aborted" do + it "logs an aborted entry with all expected attributes" do + job = build_job + event = build_event("enqueue_at.active_job", {job: job, exception_object: nil, aborted: true}) + + subscriber.enqueue_at(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "enqueue", + "status" => "aborted", + "job" => "TestLogJob", + "queue" => "default" + }) + end + end + end + + describe "#perform_start" do + context "when the job has no enqueued_at" do + it "logs a start entry without enqueued_at" do + job = build_job(job_id: "abc-123", queue_name: "billing") + event = build_event("perform_start.active_job", {job: job}) + + subscriber.perform_start(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "perform", + "status" => "start", + "job" => "TestLogJob", + "job_id" => "abc-123", + "arguments" => {}, + "queue" => "billing" + }) + end + end + + context "when the job has an enqueued_at" do + it "logs a start entry with enqueued_at" do + enqueued_at = Time.utc(2024, 6, 15, 10, 30, 0) + job = build_job(job_id: "abc-123", queue_name: "billing", enqueued_at: enqueued_at) + event = build_event("perform_start.active_job", {job: job}) + + subscriber.perform_start(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "perform", + "status" => "start", + "job" => "TestLogJob", + "job_id" => "abc-123", + "arguments" => {}, + "queue" => "billing", + "enqueued_at" => "2024-06-15T10:30:00.000Z" + }) + end + end + end + + describe "#perform" do + context "when the job completes successfully" do + it "logs a success entry with all expected attributes" do + job = build_job(job_id: "abc-123", queue_name: "billing") + event = build_event("perform.active_job", {job: job, exception_object: nil, aborted: false}) + allow(event).to receive(:duration).and_return(123.456) + + subscriber.perform(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "perform", + "status" => "success", + "job" => "TestLogJob", + "duration" => 123.46, + "job_id" => "abc-123", + "queue" => "billing" + }) + end + end + + context "when the job raises an exception" do + it "logs an error entry with all expected attributes" do + exception = RuntimeError.new("something broke") + job = build_job(job_id: "abc-123") + event = build_event("perform.active_job", {job: job, exception_object: exception}) + allow(event).to receive(:duration).and_return(45.678) + + subscriber.perform(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "perform", + "status" => "error", + "job" => "TestLogJob", + "duration" => 45.68, + "job_id" => "abc-123", + "queue" => "default", + "exception" => {"class" => "RuntimeError", "message" => "something broke"} + }) + end + end + + context "when the job is aborted" do + it "logs an aborted entry with all expected attributes" do + job = build_job(job_id: "abc-123") + event = build_event("perform.active_job", {job: job, exception_object: nil, aborted: true}) + allow(event).to receive(:duration).and_return(0.12) + + subscriber.perform(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "perform", + "status" => "aborted", + "job" => "TestLogJob", + "duration" => 0.12, + "job_id" => "abc-123", + "queue" => "default" + }) + end + end + end + + describe "#enqueue_retry" do + context "when there is an error" do + it "logs a retry error entry with all expected attributes" do + exception = RuntimeError.new("transient failure") + job = build_job(job_id: "abc-123", executions: 3) + event = build_event("enqueue_retry.active_job", {job: job, error: exception, wait: 30.5}) + + subscriber.enqueue_retry(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "retry", + "status" => "error", + "job" => "TestLogJob", + "job_id" => "abc-123", + "execution" => 3, + "wait" => 30, + "exception" => {"class" => "RuntimeError", "message" => "transient failure"} + }) + end + end + + context "when there is no error" do + it "logs a retry success entry with all expected attributes" do + job = build_job(job_id: "abc-123", executions: 1) + event = build_event("enqueue_retry.active_job", {job: job, error: nil, wait: 5}) + + subscriber.enqueue_retry(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "info", + "event" => "retry", + "status" => "success", + "job" => "TestLogJob", + "job_id" => "abc-123", + "execution" => 1, + "wait" => 5 + }) + end + end + end + + describe "#retry_stopped" do + it "logs a stopped entry with all expected attributes" do + exception = RuntimeError.new("permanent failure") + job = build_job(job_id: "abc-123", executions: 5) + event = build_event("retry_stopped.active_job", {job: job, error: exception}) + + subscriber.retry_stopped(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "retry", + "status" => "stopped", + "job" => "TestLogJob", + "job_id" => "abc-123", + "queue" => "default", + "retries" => 5, + "exception" => {"class" => "RuntimeError", "message" => "permanent failure"} + }) + end + end + + describe "#discard" do + it "logs a discard entry with all expected attributes" do + exception = RuntimeError.new("unrecoverable error") + job = build_job(job_id: "abc-123") + event = build_event("discard.active_job", {job: job, error: exception}) + + subscriber.discard(event) + + logs = parsed_log_lines + expect(logs.size).to eq(1) + expect(logs.first).to eq({ + "level" => "error", + "event" => "discard", + "status" => "error", + "job" => "TestLogJob", + "job_id" => "abc-123", + "exception" => {"class" => "RuntimeError", "message" => "unrecoverable error"} + }) + end + end +end diff --git a/spec/lib/cops/discard_all_cop_spec.rb b/spec/lib/cops/discard_all_cop_spec.rb new file mode 100644 index 0000000..7f1d67a --- /dev/null +++ b/spec/lib/cops/discard_all_cop_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "cop_helper" + +RSpec.describe Cops::DiscardAllCop, :config do + it "registers an offense when using discard_all" do + expect_offense(<<~RUBY) + users.discard_all + ^^^^^^^^^^^^^^^^^ Avoid using `discard_all`. Use `update_all(deleted_at: Time.current)` instead. + RUBY + end + + it "registers an offense when using discard_all on a class" do + expect_offense(<<~RUBY) + User.discard_all + ^^^^^^^^^^^^^^^^ Avoid using `discard_all`. Use `update_all(deleted_at: Time.current)` instead. + RUBY + end + + it "registers an offense when using discard_all with a block" do + expect_offense(<<~RUBY) + users.where(active: false).discard_all + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Avoid using `discard_all`. Use `update_all(deleted_at: Time.current)` instead. + RUBY + end + + it "does not register an offense when using update_all" do + expect_no_offenses(<<~RUBY) + users.update_all(deleted_at: Time.current) + RUBY + end + + it "does not register an offense when using discard (singular)" do + expect_no_offenses(<<~RUBY) + user.discard + RUBY + end +end diff --git a/spec/lib/cops/no_drop_column_or_table_cop_spec.rb b/spec/lib/cops/no_drop_column_or_table_cop_spec.rb new file mode 100644 index 0000000..8964eb0 --- /dev/null +++ b/spec/lib/cops/no_drop_column_or_table_cop_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "cop_helper" + +RSpec.describe Cops::NoDropColumnOrTableCop, :config do + describe "remove_column" do + it "registers an offense" do + expect_offense(<<~RUBY) + remove_column :users, :email + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Dropping columns or tables is disabled due to the risks involved. See docs/dropping_columns_and_tables.md for more information. + RUBY + end + + it "registers an offense inside change_table block" do + expect_offense(<<~RUBY) + change_table :users do |t| + t.remove_column :email + ^^^^^^^^^^^^^^^^^^^^^^ Dropping columns or tables is disabled due to the risks involved. See docs/dropping_columns_and_tables.md for more information. + end + RUBY + end + end + + describe "remove_columns" do + it "registers an offense" do + expect_offense(<<~RUBY) + remove_columns :users, :email, :name + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Dropping columns or tables is disabled due to the risks involved. See docs/dropping_columns_and_tables.md for more information. + RUBY + end + end + + describe "drop_table" do + it "registers an offense" do + expect_offense(<<~RUBY) + drop_table :users + ^^^^^^^^^^^^^^^^^ Dropping columns or tables is disabled due to the risks involved. See docs/dropping_columns_and_tables.md for more information. + RUBY + end + + it "registers an offense with a block" do + expect_offense(<<~RUBY) + drop_table :users do |t| + ^^^^^^^^^^^^^^^^^ Dropping columns or tables is disabled due to the risks involved. See docs/dropping_columns_and_tables.md for more information. + t.string :email + end + RUBY + end + end + + describe "allowed methods" do + it "does not register an offense for add_column" do + expect_no_offenses(<<~RUBY) + add_column :users, :email, :string + RUBY + end + + it "does not register an offense for create_table" do + expect_no_offenses(<<~RUBY) + create_table :users do |t| + t.string :email + end + RUBY + end + + it "does not register an offense for rename_column" do + expect_no_offenses(<<~RUBY) + rename_column :users, :email, :email_address + RUBY + end + end +end diff --git a/spec/lib/cops/service_call_cop_spec.rb b/spec/lib/cops/service_call_cop_spec.rb new file mode 100644 index 0000000..2d0a24d --- /dev/null +++ b/spec/lib/cops/service_call_cop_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "cop_helper" + +RSpec.describe Cops::ServiceCallCop, :config do + it "registers an offense when defining call with arguments" do + expect_offense(<<~RUBY) + class X < BaseService + def call(args) + ^^^^^^^^^^^^^^ Subclasses of Baseservice should have #call without arguments + super + end + end + RUBY + end + + it "registers an offense when defining call with keyword arguments" do + expect_offense(<<~RUBY) + class X < BaseService + def call(arg:) + ^^^^^^^^^^^^^^ Subclasses of Baseservice should have #call without arguments + super + end + end + RUBY + end + + it "registers an offense when subclass of ::BaseService" do + expect_offense(<<~RUBY) + class X < ::BaseService + def call(arg:) + ^^^^^^^^^^^^^^ Subclasses of Baseservice should have #call without arguments + super + end + end + RUBY + end + + it "does not register an offense when not a subclass of BaseService" do + expect_no_offenses(<<~RUBY) + class X + def call(arg:) + super + end + end + RUBY + end +end diff --git a/spec/lib/date_and_time/half_year_calculations_spec.rb b/spec/lib/date_and_time/half_year_calculations_spec.rb new file mode 100644 index 0000000..c1b075e --- /dev/null +++ b/spec/lib/date_and_time/half_year_calculations_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DateAndTime::HalfYearCalculations do + describe "#beginning_of_half_year" do + it "returns January 1st for dates in the first half" do + expect(Date.new(2025, 1, 15).beginning_of_half_year).to eq(Date.new(2025, 1, 1)) + expect(Date.new(2025, 6, 30).beginning_of_half_year).to eq(Date.new(2025, 1, 1)) + end + + it "returns July 1st for dates in the second half" do + expect(Date.new(2025, 7, 1).beginning_of_half_year).to eq(Date.new(2025, 7, 1)) + expect(Date.new(2025, 12, 31).beginning_of_half_year).to eq(Date.new(2025, 7, 1)) + end + + it "works with Time and respects midnight" do + t = Time.zone.parse("2025-08-19 14:35") + expect(t.beginning_of_half_year).to eq(Time.zone.parse("2025-07-01 00:00:00")) + end + + context "when date is on exact half-year boundaries" do + it "returns Jan 1 for Jan 1" do + expect(Date.new(2025, 1, 1).beginning_of_half_year).to eq(Date.new(2025, 1, 1)) + end + + it "returns Jul 1 for Jul 1" do + expect(Date.new(2025, 7, 1).beginning_of_half_year).to eq(Date.new(2025, 7, 1)) + end + end + end + + describe "#end_of_half_year" do + it "returns June 30th for first half-year" do + expect(Date.new(2025, 3, 10).end_of_half_year).to eq(Date.new(2025, 6, 30)) + end + + it "returns December 31st for second half-year" do + expect(Date.new(2025, 10, 5).end_of_half_year).to eq(Date.new(2025, 12, 31)) + end + + it "works with Time and respects end of day" do + t = Time.zone.parse("2025-03-20 10:00") + expect(t.end_of_half_year).to eq(Time.zone.parse("2025-06-30 23:59:59.999999999")) + end + + context "when it is a leap year" do + it "still returns June 30th for first half" do + expect(Date.new(2024, 2, 29).end_of_half_year).to eq(Date.new(2024, 6, 30)) + end + + it "still returns December 31st for second half" do + expect(Date.new(2024, 11, 15).end_of_half_year).to eq(Date.new(2024, 12, 31)) + end + end + + context "when date is on exact half-year boundaries" do + it "returns June 30 for June 30" do + expect(Date.new(2025, 6, 30).end_of_half_year).to eq(Date.new(2025, 6, 30)) + end + + it "returns Dec 31 for Dec 31" do + expect(Date.new(2025, 12, 31).end_of_half_year).to eq(Date.new(2025, 12, 31)) + end + end + end +end diff --git a/spec/lib/lago/adyen/params_spec.rb b/spec/lib/lago/adyen/params_spec.rb new file mode 100644 index 0000000..48f8c37 --- /dev/null +++ b/spec/lib/lago/adyen/params_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Lago::Adyen::Params do + let(:adyen_params) { described_class.new(params) } + + describe "#to_h" do + subject(:to_h) { adyen_params.to_h } + + let(:default_params_hash) do + { + applicationInfo: { + externalPlatform: { + name: "Lago", + integrator: "Lago" + }, + merchantApplication: { + name: "Lago" + } + } + } + end + + context "when params are empty hash" do + let(:params) { {} } + + it "returns default params hash" do + expect(to_h).to eq(default_params_hash) + end + end + + context "when params are nil" do + let(:params) { nil } + + it "returns default params hash" do + expect(to_h).to eq(default_params_hash) + end + end + + context "when params are present" do + let(:params) do + { + merchantAccount: "Lago Account", + shopperReference: "Lago123" + } + end + + let(:merged_params_hash) do + default_params_hash.merge(params) + end + + it "returns default params hash merged with given params" do + expect(to_h).to eq(merged_params_hash) + end + end + end +end diff --git a/spec/lib/lago/redis_config_builder_spec.rb b/spec/lib/lago/redis_config_builder_spec.rb new file mode 100644 index 0000000..1f45a64 --- /dev/null +++ b/spec/lib/lago/redis_config_builder_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "rails_helper" +require "lago/redis_config_builder" + +RSpec.describe Lago::RedisConfigBuilder do + subject(:builder) { described_class.new } + + around do |example| + original_env = ENV.to_h.slice("REDIS_URL", "REDIS_PASSWORD", "LAGO_REDIS_SIDEKIQ_SENTINELS", "LAGO_REDIS_SIDEKIQ_MASTER_NAME") + example.run + ENV.update(original_env) + ENV.delete_if { |k, _| ["REDIS_URL", "REDIS_PASSWORD", "LAGO_REDIS_SIDEKIQ_SENTINELS", "LAGO_REDIS_SIDEKIQ_MASTER_NAME"].include?(k) && !original_env.key?(k) } + end + + describe "#sidekiq" do + subject(:result) { builder.sidekiq } + + context "with no environment variables set" do + before do + ENV.delete("REDIS_URL") + ENV.delete("REDIS_PASSWORD") + ENV.delete("LAGO_REDIS_SIDEKIQ_SENTINELS") + end + + it "returns base config" do + expect(result).to eq( + ssl_params: {verify_mode: OpenSSL::SSL::VERIFY_NONE} + ) + end + end + + context "with REDIS_URL set" do + before do + ENV["REDIS_URL"] = "redis://localhost:6379" + ENV.delete("REDIS_PASSWORD") + ENV.delete("LAGO_REDIS_SIDEKIQ_SENTINELS") + end + + it "includes the url" do + expect(result).to include(url: "redis://localhost:6379") + end + end + + context "with REDIS_PASSWORD set" do + before do + ENV.delete("REDIS_URL") + ENV["REDIS_PASSWORD"] = "secret" + ENV.delete("LAGO_REDIS_SIDEKIQ_SENTINELS") + end + + it "includes the password" do + expect(result).to include(password: "secret") + end + end + + context "with REDIS_PASSWORD empty" do + before do + ENV.delete("REDIS_URL") + ENV["REDIS_PASSWORD"] = "" + ENV.delete("LAGO_REDIS_SIDEKIQ_SENTINELS") + end + + it "does not include the password" do + expect(result).not_to have_key(:password) + end + end + + context "with sentinels configured" do + before do + ENV.delete("REDIS_URL") + ENV.delete("REDIS_PASSWORD") + ENV["LAGO_REDIS_SIDEKIQ_SENTINELS"] = "sentinel1:26379,sentinel2:26380" + end + + it "includes sentinel config with default master name" do + expect(result).to include( + sentinels: [{host: "sentinel1", port: 26379}, {host: "sentinel2", port: 26380}], + role: :master, + name: "master" + ) + end + + context "with custom master name" do + before { ENV["LAGO_REDIS_SIDEKIQ_MASTER_NAME"] = "mymaster" } + + it "uses the custom master name" do + expect(result).to include(name: "mymaster") + end + end + + context "with blank master name" do + before { ENV["LAGO_REDIS_SIDEKIQ_MASTER_NAME"] = "" } + + it "falls back to default master name" do + expect(result).to include(name: "master") + end + end + + context "with sentinel without port" do + before { ENV["LAGO_REDIS_SIDEKIQ_SENTINELS"] = "sentinel1" } + + it "parses sentinel without port" do + expect(result[:sentinels]).to eq([{host: "sentinel1"}]) + end + end + + context "with invalid sentinel port" do + before { ENV["LAGO_REDIS_SIDEKIQ_SENTINELS"] = "sentinel1:abc" } + + it "raises an error" do + expect { result }.to raise_error(ArgumentError, /Invalid Redis sentinel port/) + end + end + + context "with whitespace in sentinel host and port" do + before { ENV["LAGO_REDIS_SIDEKIQ_SENTINELS"] = " sentinel1 : 26379 " } + + it "strips whitespace from host and port" do + expect(result[:sentinels]).to eq([{host: "sentinel1", port: 26379}]) + end + end + end + + context "with sentinels and REDIS_URL set" do + before do + ENV["REDIS_URL"] = "redis://localhost:6379" + ENV["LAGO_REDIS_SIDEKIQ_SENTINELS"] = "sentinel1:26379" + ENV.delete("REDIS_PASSWORD") + end + + it "still includes sentinel config" do + expect(result).to include(:sentinels, :role, :name) + end + end + + context "with all options set" do + before do + ENV["REDIS_URL"] = "redis://localhost:6379" + ENV["REDIS_PASSWORD"] = "secret" + ENV["LAGO_REDIS_SIDEKIQ_SENTINELS"] = "sentinel1:26379" + ENV["LAGO_REDIS_SIDEKIQ_MASTER_NAME"] = "mymaster" + end + + it "includes all config options" do + expect(result).to eq( + url: "redis://localhost:6379", + ssl_params: {verify_mode: OpenSSL::SSL::VERIFY_NONE}, + sentinels: [{host: "sentinel1", port: 26379}], + role: :master, + name: "mymaster", + password: "secret" + ) + end + end + end + + describe "#with_options" do + before do + ENV.delete("REDIS_URL") + ENV.delete("REDIS_PASSWORD") + ENV.delete("LAGO_REDIS_SIDEKIQ_SENTINELS") + end + + it "merges extra options into the config" do + result = builder.with_options(reconnect_attempts: 4).sidekiq + expect(result).to include(reconnect_attempts: 4) + end + + it "returns self for chaining" do + expect(builder.with_options(foo: 1)).to eq(builder) + end + + it "merges multiple calls" do + result = builder + .with_options(reconnect_attempts: 4) + .with_options(custom: "value") + .sidekiq + expect(result).to include(reconnect_attempts: 4, custom: "value") + end + + it "extra options override base config keys" do + result = builder.with_options(ssl_params: {verify_mode: OpenSSL::SSL::VERIFY_PEER}).sidekiq + expect(result).to include(ssl_params: {verify_mode: OpenSSL::SSL::VERIFY_PEER}) + end + end +end diff --git a/spec/lib/lago_eu_vat/rate_spec.rb b/spec/lib/lago_eu_vat/rate_spec.rb new file mode 100644 index 0000000..ca1b822 --- /dev/null +++ b/spec/lib/lago_eu_vat/rate_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoEuVat::Rate do + subject { described_class } + + describe "#country_codes" do + it "returns all EU country codes" do + # NOTE: 28 in the original file but we removed GB manually + expect(subject.country_codes.count).to eq(27) + end + end + + describe "#country_rate" do + it "returns all applicable rates for a country" do + fr_taxes = subject.country_rates(country_code: "FR") + fr_rates = fr_taxes[:rates] + fr_exceptions = fr_taxes[:exceptions] + + expect(fr_rates["standard"]).to eq(20) + expect(fr_exceptions.count).to eq(5) + end + end +end diff --git a/spec/lib/lago_http_client/client_spec.rb b/spec/lib/lago_http_client/client_spec.rb new file mode 100644 index 0000000..20e4810 --- /dev/null +++ b/spec/lib/lago_http_client/client_spec.rb @@ -0,0 +1,564 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoHttpClient::Client do + subject(:client) { described_class.new(url, **client_options) } + + let(:url) { "http://example.com/api/v1/example" } + let(:client_options) { {} } + + describe "#initialize" do + it "parses the URL into a URI" do + expect(client.uri).to eq URI("http://example.com/api/v1/example") + end + + context "with custom open_timeout" do + let(:client_options) { {open_timeout: 5} } + + it "configures open_timeout on the http client" do + expect(client.send(:http_client).open_timeout).to eq 5 + end + end + + context "with custom read_timeout" do + let(:client_options) { {read_timeout: 10} } + + it "configures read_timeout on the http client" do + expect(client.send(:http_client).read_timeout).to eq 10 + end + end + + context "with custom write_timeout" do + let(:client_options) { {write_timeout: 15} } + + it "configures write_timeout on the http client" do + expect(client.send(:http_client).write_timeout).to eq 15 + end + end + + context "with HTTPS URL" do + let(:url) { "https://example.com/api/v1/example" } + + it "enables SSL" do + expect(client.send(:http_client).use_ssl?).to be true + end + end + + context "with HTTP URL" do + it "does not enable SSL" do + expect(client.send(:http_client).use_ssl?).to be false + end + end + + context "with retries_on option" do + let(:client_options) { {retries_on: [Net::OpenTimeout, Net::ReadTimeout]} } + + it "stores the retries_on classes" do + expect(client.retries_on).to eq [Net::OpenTimeout, Net::ReadTimeout] + end + end + end + + describe "timeouts" do + context "when open timeout occurs" do + before do + stub_request(:post, url).to_raise(Net::OpenTimeout) + end + + it "raises Net::OpenTimeout" do + expect { client.post({}, []) }.to raise_error(Net::OpenTimeout) + end + end + + context "when read timeout occurs" do + before do + stub_request(:post, url).to_raise(Net::ReadTimeout) + end + + it "raises Net::ReadTimeout" do + expect { client.post({}, []) }.to raise_error(Net::ReadTimeout) + end + end + + context "when write timeout occurs" do + before do + stub_request(:post, url).to_raise(Net::WriteTimeout) + end + + it "raises Net::WriteTimeout" do + expect { client.post({}, []) }.to raise_error(Net::WriteTimeout) + end + end + + context "with retries_on configured for timeouts" do + let(:client_options) { {retries_on: [Net::OpenTimeout, Net::ReadTimeout]} } + + context "when open timeout occurs and succeeds on retry" do + before do + call_count = 0 + stub_request(:post, url).to_return do + call_count += 1 + raise Net::OpenTimeout if call_count == 1 + {body: '{"retried": true}', status: 200} + end + end + + it "retries and returns successful response" do + expect(client.post({}, [])).to eq({"retried" => true}) + end + end + + context "when read timeout occurs and succeeds on retry" do + before do + call_count = 0 + stub_request(:post, url).to_return do + call_count += 1 + raise Net::ReadTimeout if call_count == 1 + {body: '{"retried": true}', status: 200} + end + end + + it "retries and returns successful response" do + expect(client.post({}, [])).to eq({"retried" => true}) + end + end + + context "when write timeout occurs (not in retries_on)" do + before do + stub_request(:post, url).to_raise(Net::WriteTimeout) + end + + it "raises immediately without retry" do + expect { client.post({}, []) }.to raise_error(Net::WriteTimeout) + end + end + end + end + + describe "#post" do + let(:request_body) { {data: "test"} } + let(:request_headers) { [{"Authorization" => "Bearer token"}, {"X-Custom" => "value"}] } + + context "when response status code is 2xx" do + let(:response_body) { {"status" => 200, "message" => "Success"}.to_json } + + before do + stub_request(:post, url) + .with( + body: request_body.to_json, + headers: {"Content-Type" => "application/json", "Authorization" => "Bearer token", "X-Custom" => "value"} + ) + .to_return(body: response_body, status: 200) + end + + it "returns parsed JSON response body" do + response = client.post(request_body, request_headers) + + expect(response).to eq({"status" => 200, "message" => "Success"}) + end + end + + context "when response body is blank" do + before do + stub_request(:post, url).to_return(body: "", status: 200) + end + + it "returns an empty hash" do + expect(client.post({}, [])).to eq({}) + end + end + + context "when response body is nil" do + before do + stub_request(:post, url).to_return(body: nil, status: 200) + end + + it "returns an empty hash" do + expect(client.post({}, [])).to eq({}) + end + end + + context "when response is not valid JSON" do + before do + stub_request(:post, url).to_return(body: "Accepted", status: 200) + end + + it "returns the raw response body" do + expect(client.post({}, [])).to eq("Accepted") + end + end + + context "when response status code is NOT 2xx" do + before do + stub_request(:post, url).to_return(body: "Error", status: 422) + end + + it "raises an HttpError" do + expect { client.post({}, []) }.to raise_error(LagoHttpClient::HttpError) do |error| + expect(error.error_code).to eq "422" + expect(error.error_body).to eq "Error" + end + end + end + + context "when path is empty" do + let(:url) { "http://example.com" } + + before do + stub_request(:post, "http://example.com/").to_return(body: "{}", status: 200) + end + + it "makes request to root path" do + expect(client.post({}, [])).to eq({}) + end + end + + context "with query params in URL" do + let(:url) { "http://example.com/api?foo=bar" } + + before do + stub_request(:post, "http://example.com/api?foo=bar").to_return(body: "{}", status: 200) + end + + it "preserves query params" do + expect(client.post({}, [])).to eq({}) + end + end + end + + describe "#post_with_response" do + let(:request_body) { {data: "test"} } + let(:request_headers) { {"Authorization" => "Bearer token", "X-Custom" => "value"} } + + context "when response is successful" do + before do + stub_request(:post, url) + .with( + body: request_body.to_json, + headers: {"Content-Type" => "application/json", "Authorization" => "Bearer token", "X-Custom" => "value"} + ) + .to_return(body: "OK", status: 201) + end + + it "returns the raw Net::HTTP response" do + response = client.post_with_response(request_body, request_headers) + + expect(response).to be_a(Net::HTTPResponse) + expect(response.code).to eq "201" + expect(response.body).to eq "OK" + end + end + + context "when response status code is NOT 2xx" do + before do + stub_request(:post, url).to_return(body: "Error", status: 500) + end + + it "raises an HttpError" do + expect { client.post_with_response({}, {}) }.to raise_error(LagoHttpClient::HttpError) + end + end + end + + describe "#put_with_response" do + let(:request_body) { {data: "updated"} } + let(:request_headers) { {"Authorization" => "Bearer token"} } + + context "when response is successful" do + before do + stub_request(:put, url) + .with( + body: request_body.to_json, + headers: {"Content-Type" => "application/json", "Authorization" => "Bearer token"} + ) + .to_return(body: "Updated", status: 200) + end + + it "returns the raw Net::HTTP response" do + response = client.put_with_response(request_body, request_headers) + + expect(response).to be_a(Net::HTTPResponse) + expect(response.code).to eq "200" + expect(response.body).to eq "Updated" + end + end + + context "when response status code is NOT 2xx" do + before do + stub_request(:put, url).to_return(body: "Error", status: 404) + end + + it "raises an HttpError" do + expect { client.put_with_response({}, {}) }.to raise_error(LagoHttpClient::HttpError) + end + end + end + + describe "#post_multipart_file" do + let(:url) { "http://example.com/upload" } + let(:file_content) { "file content" } + let(:temp_file) { Tempfile.new("test") } + + before do + temp_file.write(file_content) + temp_file.rewind + end + + after do + temp_file.close + temp_file.unlink + end + + context "when response is successful" do + before do + stub_request(:post, url) + .with(headers: {"Content-Type" => %r{multipart/form-data}}) + .to_return(body: "Uploaded", status: 200) + end + + it "returns the raw Net::HTTP response" do + response = client.post_multipart_file(file: UploadIO.new(temp_file, "text/plain", "test.txt")) + + expect(response).to be_a(Net::HTTPResponse) + expect(response.code).to eq "200" + end + end + + context "when response status code is NOT 2xx" do + before do + stub_request(:post, url).to_return(body: "Error", status: 413) + end + + it "raises an HttpError" do + expect { client.post_multipart_file({}) }.to raise_error(LagoHttpClient::HttpError) + end + end + end + + describe "#post_url_encoded" do + let(:params) { {grant_type: "authorization_code", code: "abc123"} } + let(:request_headers) { {"Authorization" => "Basic xyz"} } + + context "when response is successful" do + let(:response_body) { {"access_token" => "token123"}.to_json } + + before do + stub_request(:post, url) + .with( + body: "grant_type=authorization_code&code=abc123", + headers: {"Content-Type" => "application/x-www-form-urlencoded", "Authorization" => "Basic xyz"} + ) + .to_return(body: response_body, status: 200) + end + + it "returns parsed JSON response" do + response = client.post_url_encoded(params, request_headers) + + expect(response).to eq({"access_token" => "token123"}) + end + end + + context "when response body is blank" do + before do + stub_request(:post, url).to_return(body: "", status: 200) + end + + it "returns an empty hash" do + expect(client.post_url_encoded({}, {})).to eq({}) + end + end + + context "when response status code is NOT 2xx" do + before do + stub_request(:post, url).to_return(body: "Error", status: 401) + end + + it "raises an HttpError" do + expect { client.post_url_encoded({}, {}) }.to raise_error(LagoHttpClient::HttpError) + end + end + end + + describe "#post_with_stream" do + let(:request_body) { {prompt: "Hello"} } + let(:request_headers) { {"Authorization" => "Bearer token"} } + + context "when response is successful" do + let(:sse_response) { "event: message\ndata: {\"text\":\"Hello\"}\n\nevent: message\ndata: {\"text\":\"World\"}\n\n" } + + before do + stub_request(:post, url) + .with(body: request_body.to_json, headers: {"Content-Type" => "application/json", "Authorization" => "Bearer token"}) + .to_return(body: sse_response, status: 200) + end + + it "yields parsed SSE events" do + events = [] + client.post_with_stream(request_body, request_headers) do |type, data, id, reconnection_time| + events << {type: type, data: data, id: id, reconnection_time: reconnection_time} + end + + expect(events.size).to eq 2 + expect(events[0][:type]).to eq "message" + expect(events[0][:data]).to eq '{"text":"Hello"}' + expect(events[1][:data]).to eq '{"text":"World"}' + end + end + + context "when response status code is NOT 2xx" do + before do + stub_request(:post, url).to_return(body: "Error", status: 500) + end + + it "raises an HttpError" do + expect { client.post_with_stream({}, {}) { |*| } }.to raise_error(LagoHttpClient::HttpError) + end + end + end + + describe "#get" do + context "when response is successful" do + let(:response_body) { {"data" => [1, 2, 3]}.to_json } + + before do + stub_request(:get, url) + .with(headers: {"Authorization" => "Bearer token"}) + .to_return(body: response_body, status: 200) + end + + it "returns parsed JSON response" do + response = client.get(headers: {"Authorization" => "Bearer token"}) + + expect(response).to eq({"data" => [1, 2, 3]}) + end + end + + context "with query params" do + let(:url) { "http://example.com/api" } + + before do + stub_request(:get, "http://example.com/api?page=1&per_page=10") + .to_return(body: "{}", status: 200) + end + + it "appends params to the URL" do + expect(client.get(params: {page: 1, per_page: 10})).to eq({}) + end + end + + context "with body" do + before do + stub_request(:get, url) + .with(body: "filter=active") + .to_return(body: "{}", status: 200) + end + + it "sends URL-encoded body" do + expect(client.get(body: {filter: "active"})).to eq({}) + end + end + + context "when response body is blank" do + before do + stub_request(:get, url).to_return(body: "", status: 200) + end + + it "returns an empty hash" do + expect(client.get).to eq({}) + end + end + + context "when response status code is NOT 2xx" do + before do + stub_request(:get, url).to_return(body: "Not Found", status: 404) + end + + it "raises an HttpError" do + expect { client.get }.to raise_error(LagoHttpClient::HttpError) + end + end + end + + describe "retry logic" do + let(:client_options) { {retries_on: [Net::OpenTimeout]} } + + context "when retryable error occurs" do + before do + call_count = 0 + stub_request(:post, url).to_return do + call_count += 1 + if call_count < 3 + raise Net::OpenTimeout + else + {body: "{}", status: 200} + end + end + end + + it "retries up to MAX_RETRIES_ATTEMPTS times" do + expect(client.post({}, [])).to eq({}) + end + end + + context "when retryable error exceeds max attempts" do + before do + stub_request(:post, url).to_raise(Net::OpenTimeout) + end + + it "raises NoMethodError due to nil response" do + expect { client.post({}, []) }.to raise_error(NoMethodError) + end + end + + context "when non-retryable error occurs" do + before do + stub_request(:post, url).to_raise(Errno::ECONNREFUSED) + end + + it "raises the error immediately" do + expect { client.post({}, []) }.to raise_error(Errno::ECONNREFUSED) + end + end + + context "when retries_on is empty" do + let(:client_options) { {retries_on: []} } + + before do + stub_request(:post, url).to_raise(Net::OpenTimeout) + end + + it "raises the error immediately" do + expect { client.post({}, []) }.to raise_error(Net::OpenTimeout) + end + end + end + + describe "response success codes" do + described_class::RESPONSE_SUCCESS_CODES.each do |code| + context "when response code is #{code}" do + before do + stub_request(:post, url).to_return(body: "{}", status: code) + end + + it "does not raise an error" do + expect { client.post({}, []) }.not_to raise_error + end + end + end + + [400, 401, 403, 404, 422, 500, 502, 503].each do |code| + context "when response code is #{code}" do + before do + stub_request(:post, url).to_return(body: "Error", status: code) + end + + it "raises an HttpError" do + expect { client.post({}, []) }.to raise_error(LagoHttpClient::HttpError) do |error| + expect(error.error_code).to eq code.to_s + end + end + end + end + end +end diff --git a/spec/lib/lago_http_client/session_client_spec.rb b/spec/lib/lago_http_client/session_client_spec.rb new file mode 100644 index 0000000..bbba266 --- /dev/null +++ b/spec/lib/lago_http_client/session_client_spec.rb @@ -0,0 +1,284 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoHttpClient::SessionClient do + subject(:client) { described_class.new(base_url) } + + let(:base_url) { "http://example.com" } + + describe "#initialize" do + it "sets default timeouts" do + expect(client.cookies).to eq([]) + end + + it "can override timeouts" do + client = described_class.new(base_url, read_timeout: 10, open_timeout: 5) + expect(client.send(:read_timeout)).to eq(10) + expect(client.send(:open_timeout)).to eq(5) + end + end + + describe "#get" do + let(:path) { "/api/v1/resource" } + let(:response_body) { {"status" => "ok", "data" => "test"}.to_json } + + context "when request is successful" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_return(status: 200, body: response_body, headers: {"Content-Type" => "application/json"}) + end + + it "returns the response" do + response = client.get(path) + expect(response.body).to eq(response_body) + end + + it "includes custom headers" do + stub_request(:get, "#{base_url}#{path}") + .with(headers: {"Authorization" => "Bearer token"}) + .to_return(status: 200, body: response_body) + + client.get(path, headers: {"Authorization" => "Bearer token"}) + end + end + + context "when response includes cookies" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_return( + status: 200, + body: response_body, + headers: {"Set-Cookie" => "session_id=abc123; Path=/; HttpOnly"} + ) + end + + it "stores cookies" do + client.get(path) + expect(client.cookies).to eq(["session_id=abc123"]) + end + end + + context "when response is not successful" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_return(status: 404, body: "Not Found") + end + + it "raises an HttpError" do + expect { client.get(path) }.to raise_error(LagoHttpClient::HttpError) + end + end + + context "when response is a redirection" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_return(status: 302, body: "", headers: {"Location" => "/new-location"}) + end + + it "does not raise an error" do + expect { client.get(path) }.not_to raise_error + end + end + end + + describe "#post" do + let(:path) { "/api/v1/resource" } + let(:response_body) { {"status" => "created"}.to_json } + + context "when request is successful with JSON body" do + let(:body) { {name: "test", value: 123} } + + before do + stub_request(:post, "#{base_url}#{path}") + .with( + body: body.to_json, + headers: {"Content-Type" => "application/json"} + ) + .to_return(status: 201, body: response_body) + end + + it "returns the response" do + response = client.post(path, body: body, headers: {"Content-Type" => "application/json"}) + expect(response.body).to eq(response_body) + end + end + + context "when request is successful with form-encoded body" do + let(:body) { {username: "user", password: "pass"} } + + before do + stub_request(:post, "#{base_url}#{path}") + .with( + body: URI.encode_www_form(body), + headers: {"Content-Type" => "application/x-www-form-urlencoded"} + ) + .to_return(status: 200, body: response_body) + end + + it "returns the response" do + response = client.post( + path, + body: body, + headers: {"Content-Type" => "application/x-www-form-urlencoded"} + ) + expect(response.body).to eq(response_body) + end + end + + context "when response includes cookies" do + let(:body) { {data: "test"} } + + before do + stub_request(:post, "#{base_url}#{path}") + .to_return( + status: 200, + body: response_body, + headers: {"Set-Cookie" => "auth_token=xyz789; Path=/; Secure"} + ) + end + + it "stores cookies" do + client.post(path, body: body, headers: {"Content-Type" => "application/json"}) + expect(client.cookies).to eq(["auth_token=xyz789"]) + end + end + + context "when response is not successful" do + let(:body) { {data: "test"} } + + before do + stub_request(:post, "#{base_url}#{path}") + .to_return(status: 422, body: "Validation Error") + end + + it "raises an HttpError" do + expect do + client.post(path, body: body, headers: {"Content-Type" => "application/json"}) + end.to raise_error(LagoHttpClient::HttpError) + end + end + end + + describe "cookie management" do + let(:path) { "/api/v1/resource" } + + context "when multiple cookies are set" do + before do + stub_request(:get, "#{base_url}/step1") + .to_return( + status: 200, + body: "{}", + headers: { + "Set-Cookie" => [ + "session_id=abc123; Path=/; HttpOnly", + "user_pref=dark_mode; Path=/" + ] + } + ) + end + + it "stores all cookies" do + client.get("/step1") + expect(client.cookies).to contain_exactly("session_id=abc123", "user_pref=dark_mode") + end + end + + context "when a cookie is updated" do + before do + stub_request(:get, "#{base_url}/step1") + .to_return( + status: 200, + body: "{}", + headers: {"Set-Cookie" => "session_id=abc123; Path=/"} + ) + + stub_request(:get, "#{base_url}/step2") + .to_return( + status: 200, + body: "{}", + headers: {"Set-Cookie" => "session_id=xyz789; Path=/"} + ) + end + + it "replaces the old cookie" do + client.get("/step1") + expect(client.cookies).to eq(["session_id=abc123"]) + + client.get("/step2") + expect(client.cookies).to eq(["session_id=xyz789"]) + end + end + + context "when cookies are sent with requests" do + before do + stub_request(:get, "#{base_url}/login") + .to_return( + status: 200, + body: "{}", + headers: {"Set-Cookie" => "session_id=abc123; Path=/"} + ) + + stub_request(:get, "#{base_url}/protected") + .with(headers: {"Cookie" => "session_id=abc123"}) + .to_return(status: 200, body: "{}") + end + + it "includes cookies in subsequent requests" do + client.get("/login") + client.get("/protected") + + expect(WebMock).to have_requested(:get, "#{base_url}/protected") + .with(headers: {"Cookie" => "session_id=abc123"}) + end + end + end + + describe "#clear_cookies" do + before do + stub_request(:get, "#{base_url}/login") + .to_return( + status: 200, + body: "{}", + headers: {"Set-Cookie" => "session_id=abc123; Path=/"} + ) + end + + it "clears all stored cookies" do + client.get("/login") + expect(client.cookies).not_to be_empty + + client.clear_cookies + expect(client.cookies).to be_empty + end + end + + describe "retry logic" do + let(:path) { "/api/v1/resource" } + + context "when request times out" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_timeout + .then.to_return(status: 200, body: "{}") + end + + it "retries the request" do + response = client.get(path) + expect(response.code).to eq("200") + end + end + + context "when request fails multiple times" do + before do + stub_request(:get, "#{base_url}#{path}") + .to_timeout + .times(3) + end + + it "raises an error after max retries" do + expect { client.get(path) }.to raise_error(Net::OpenTimeout) + end + end + end +end diff --git a/spec/lib/lago_mcp_client/client_spec.rb b/spec/lib/lago_mcp_client/client_spec.rb new file mode 100644 index 0000000..02805d7 --- /dev/null +++ b/spec/lib/lago_mcp_client/client_spec.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::Client do + subject(:client) { described_class.new(config) } + + let(:config) do + instance_double( + LagoMcpClient::Config, + mcp_server_url: "https://mcp.example.com", + timeout: 5, + lago_api_key: "secret", + lago_api_url: "https://api.lago.dev", + headers: {"X-Custom" => "foo"} + ) + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:sse_client) { instance_double(LagoMcpClient::SseClient, start: true, stop: true) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(LagoMcpClient::SseClient).to receive(:new).and_return(sse_client) + end + + describe "#setup!" do + let(:init_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 1\ndata: {\"result\": {\"protocolVersion\": \"2024-11-05\"}}", + each_header: {"mcp-session-id" => "sess-abc"} + ) + end + + let(:notif_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 2\ndata: {\"result\": {}}", + each_header: {} + ) + end + + before do + allow(http_client).to receive(:post_with_response) + .and_return(init_response, notif_response) + end + + it "initializes connection and starts SSE client" do + client.setup! + + expect(http_client).to have_received(:post_with_response).twice + expect(LagoMcpClient::SseClient).to have_received(:new).with( + url: "https://mcp.example.com", + session_id: "sess-abc" + ) + expect(sse_client).to have_received(:start) + end + + it "sets the session_id from response headers" do + client.setup! + expect(client.session_id).to eq("sess-abc") + end + + it "sets the sse_client" do + client.setup! + expect(client.sse_client).to eq(sse_client) + end + + it "sends initialize request with correct parameters" do + allow(http_client).to receive(:post_with_response) + .and_return(init_response, notif_response) + + client.setup! + + expect(http_client).to have_received(:post_with_response).with( + hash_including( + method: "initialize", + params: hash_including( + protocolVersion: "2024-11-05", + clientInfo: {name: "lago-mcp-client", version: "0.1"} + ) + ), + anything + ) + end + + it "sends notifications/initialized after initialize" do + allow(http_client).to receive(:post_with_response).and_return(init_response, notif_response) + + client.setup! + + expect(http_client).to have_received(:post_with_response).with( + hash_including(method: "initialize"), + anything + ).ordered + + expect(http_client).to have_received(:post_with_response).with( + hash_including(method: "notifications/initialized"), + anything + ).ordered + end + end + + describe "#list_tools" do + let(:response_body) do + { + "result" => { + "tools" => [ + {"name" => "tool_a", "description" => "desc a", "inputSchema" => {"type" => "object"}}, + {"name" => "tool_b", "description" => "desc b", "inputSchema" => {"type" => "object"}} + ] + } + } + end + + let(:mock_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 10\ndata: #{response_body.to_json}", + each_header: {} + ) + end + + before do + allow(http_client).to receive(:post_with_response).and_return(mock_response) + stub_const("LagoMcpClient::Tool", Struct.new(:name, :description, :input_schema, keyword_init: true)) + end + + it "returns an array of Tool instances" do + tools = client.list_tools + expect(tools.size).to eq(2) + expect(tools.first.name).to eq("tool_a") + expect(tools.last.description).to eq("desc b") + end + + it "sends tools/list request" do + client.list_tools + + expect(http_client).to have_received(:post_with_response).with( + hash_including(method: "tools/list"), + anything + ) + end + + context "when no tools are returned" do + let(:empty_response_body) { {"result" => {}} } + let(:empty_mock_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 10\ndata: #{empty_response_body.to_json}", + each_header: {} + ) + end + + before do + allow(http_client).to receive(:post_with_response).and_return(empty_mock_response) + end + + it "returns an empty array" do + expect(client.list_tools).to eq([]) + end + end + end + + describe "#call_tool" do + let(:response_body) { {"result" => {"status" => "ok", "output" => "Hello World"}} } + let(:mock_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 15\ndata: #{response_body.to_json}", + each_header: {} + ) + end + + before do + allow(http_client).to receive(:post_with_response).and_return(mock_response) + end + + it "calls the tool with given name and arguments" do + result = client.call_tool("echo", {message: "hello"}) + expect(result).to eq({"status" => "ok", "output" => "Hello World"}) + end + + it "sends tools/call request with correct parameters" do + client.call_tool("echo", {message: "hello"}) + + expect(http_client).to have_received(:post_with_response).with( + hash_including( + method: "tools/call", + params: {name: "echo", arguments: {message: "hello"}} + ), + anything + ) + end + + context "when arguments are not provided" do + it "defaults to empty hash" do + client.call_tool("test_tool") + + expect(http_client).to have_received(:post_with_response).with( + hash_including( + params: {name: "test_tool", arguments: {}} + ), + anything + ) + end + end + end + + describe "#close_session" do + let(:mock_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 20\ndata: {\"result\": {}}", + each_header: {} + ) + end + + before do + client.instance_variable_set(:@sse_client, sse_client) + allow(http_client).to receive(:post_with_response).and_return(mock_response) + end + + it "stops the SSE client" do + client.close_session + expect(sse_client).to have_received(:stop) + end + + it "sends close request" do + client.close_session + expect(http_client).to have_received(:post_with_response).with( + hash_including(method: "close"), + anything + ) + end + + context "when sse_client is nil" do + before do + client.instance_variable_set(:@sse_client, nil) + end + + it "does not raise error" do + expect { client.close_session }.not_to raise_error + end + end + end + + describe "error handling" do + context "when HTTP request fails" do + let(:error_message) { "Connection refused" } + + before do + allow(http_client).to receive(:post_with_response).and_raise(StandardError.new(error_message)) + end + + it "returns error hash for list_tools" do + result = client.list_tools + expect(result).to eq([]) + end + + it "returns error hash for call_tool" do + result = client.call_tool("test", {}) + expect(result).to be_nil + end + end + + context "when response body has invalid JSON" do + let(:invalid_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "data: {invalid json}", + each_header: {} + ) + end + + before do + allow(http_client).to receive(:post_with_response).and_return(invalid_response) + end + + it "handles parsing error gracefully" do + result = client.list_tools + expect(result).to eq([]) + end + end + + context "when response has no data line" do + let(:no_data_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 123\n", + each_header: {} + ) + end + + before do + allow(http_client).to receive(:post_with_response).and_return(no_data_response) + end + + it "handles missing data gracefully" do + result = client.list_tools + expect(result).to eq([]) + end + end + end + + describe "headers management" do + let(:init_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 1\ndata: {\"result\": {}}", + each_header: {"mcp-session-id" => "sess-123"} + ) + end + + let(:notif_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 2\ndata: {\"result\": {}}", + each_header: {} + ) + end + + let(:tools_response) do + instance_double( + Net::HTTPResponse, + code: "200", + body: "id: 3\ndata: {\"result\": {\"tools\": []}}", + each_header: {} + ) + end + + before do + allow(http_client).to receive(:post_with_response) + .and_return(init_response, notif_response, tools_response) + end + + it "includes all required headers in requests" do + client.setup! + client.list_tools + + expect(http_client).to have_received(:post_with_response).with( + anything, + hash_including( + "Content-Type" => "application/json", + "Accept" => "application/json,text/event-stream", + "Mcp-Session-Id" => "sess-123", + "X-LAGO-API-KEY" => "secret", + "X-LAGO-API-URL" => "https://api.lago.dev", + "X-Custom" => "foo" + ) + ).at_least(:once) + end + + it "includes custom headers from config" do + client.setup! + client.list_tools + + expect(http_client).to have_received(:post_with_response).with( + anything, + hash_including("X-Custom" => "foo") + ).at_least(:once) + end + end +end diff --git a/spec/lib/lago_mcp_client/config_spec.rb b/spec/lib/lago_mcp_client/config_spec.rb new file mode 100644 index 0000000..b92b5f0 --- /dev/null +++ b/spec/lib/lago_mcp_client/config_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::Config do + let(:mcp_server_url) { "http://mcp-server:3001/mcp" } + let(:lago_api_key) { "test_key_123" } + + before do + stub_const("ENV", ENV.to_h.merge("LAGO_API_URL" => "https://api.lago.dev")) + end + + describe "#initialize" do + it "sets server_url, timeout, headers and lago_api_key" do + config = described_class.new(mcp_server_url:, lago_api_key:) + + expect(config.mcp_server_url).to eq(mcp_server_url) + expect(config.timeout).to eq(30) + expect(config.headers).to eq({}) + expect(config.lago_api_key).to eq(lago_api_key) + end + end + + describe "#lago_api_url" do + it "joins the ENV base URL with /api/v1" do + expect( + described_class.new(mcp_server_url:, lago_api_key:).lago_api_url + ).to eq("https://api.lago.dev/api/v1") + end + end +end diff --git a/spec/lib/lago_mcp_client/mistral/agent_spec.rb b/spec/lib/lago_mcp_client/mistral/agent_spec.rb new file mode 100644 index 0000000..93550c8 --- /dev/null +++ b/spec/lib/lago_mcp_client/mistral/agent_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::Mistral::Agent do + subject(:agent) { described_class.new(client: mcp_client, conversation_id:) } + + let(:mcp_client) { instance_double("McpClient") } + let(:mistral_client) { instance_double(LagoMcpClient::Mistral::Client) } + let(:mcp_context) { instance_double(LagoMcpClient::RunContext) } + let(:conversation_id) { nil } + + before do + allow(LagoMcpClient::Mistral::Client).to receive(:new).and_return(mistral_client) + allow(LagoMcpClient::RunContext).to receive(:new).with(client: mcp_client).and_return(mcp_context) + end + + describe "#setup!" do + before { allow(mcp_context).to receive(:setup!) } + + it "calls setup! on the MCP context" do + agent.setup! + expect(mcp_context).to have_received(:setup!) + end + + it "returns self" do + expect(agent.setup!).to eq(agent) + end + end + + describe "#chat" do + let(:user_message) { "Hello, assistant!" } + let(:chunks) { [] } + + context "when no block is given" do + it "raises an ArgumentError" do + expect { agent.chat(user_message) } + .to raise_error(ArgumentError, "Block required for streaming") + end + end + + context "when starting a new conversation" do + let(:conversation_id) { nil } + let(:new_conversation_id) { "conv_new_456" } + let(:response) do + { + "conversation_id" => new_conversation_id, + "outputs" => [{"content" => "Hello there!"}] + } + end + + before do + allow(mistral_client).to receive(:start_conversation) do |**_args, &block| + block&.call("chunk1") + block&.call("chunk2") + response + end + end + + it "calls start_conversation on mistral client" do + agent.chat(user_message) { |chunk| chunks << chunk } + + expect(mistral_client).to have_received(:start_conversation).with( + inputs: user_message + ) + end + + it "yields streaming chunks" do + agent.chat(user_message) { |chunk| chunks << chunk } + expect(chunks).to eq(["chunk1", "chunk2"]) + end + + it "stores the new conversation_id" do + agent.chat(user_message) { |chunk| chunks << chunk } + expect(agent.conversation_id).to eq(new_conversation_id) + end + + it "returns the final content" do + result = agent.chat(user_message) { |chunk| chunks << chunk } + expect(result).to eq("Hello there!") + end + end + + context "when continuing an existing conversation" do + let(:conversation_id) { "conv_existing_789" } + let(:response) do + { + "outputs" => [{"content" => "Continued response"}] + } + end + + before do + allow(mistral_client).to receive(:append_to_conversation) do |**_args, &block| + block&.call("chunk") + response + end + end + + it "calls append_to_conversation on mistral client" do + agent.chat(user_message) { |chunk| chunks << chunk } + + expect(mistral_client).to have_received(:append_to_conversation).with( + conversation_id: conversation_id, + inputs: [{role: "user", content: user_message}] + ) + end + + it "returns the final content" do + result = agent.chat(user_message) { |chunk| chunks << chunk } + expect(result).to eq("Continued response") + end + + it "keeps the same conversation_id" do + agent.chat(user_message) { |chunk| chunks << chunk } + expect(agent.conversation_id).to eq(conversation_id) + end + end + + context "when response contains tool calls" do + let(:conversation_id) { "conv_with_tools" } + let(:tool_call_id) { "call_123" } + let(:tool_calls) do + [ + { + "id" => tool_call_id, + "type" => "function", + "function" => {"name" => "test_tool", "arguments" => "{}"} + } + ] + end + let(:initial_response) do + { + "tool_calls" => tool_calls, + "outputs" => [] + } + end + let(:tool_result) do + [ + { + tool_call_id: tool_call_id, + content: '{"content":[{"text":"Tool result text"}]}' + } + ] + end + let(:final_response) do + { + "outputs" => [{"content" => "Task completed successfully"}] + } + end + + before do + call_count = 0 + allow(mistral_client).to receive(:append_to_conversation) do |**_args, &block| + call_count += 1 + block&.call("chunk#{call_count}") + (call_count == 1) ? initial_response : final_response + end + + allow(mcp_context).to receive(:process_tool_calls) + .with(tool_calls) + .and_return(tool_result) + end + + it "processes tool calls via mcp_context" do + agent.chat(user_message) { |chunk| chunks << chunk } + expect(mcp_context).to have_received(:process_tool_calls).with(tool_calls) + end + + it "sends tool results back to the conversation" do + agent.chat(user_message) { |chunk| chunks << chunk } + + expect(mistral_client).to have_received(:append_to_conversation).with( + conversation_id: conversation_id, + inputs: [ + { + tool_call_id: tool_call_id, + result: "Tool result text", + type: "function.result", + object: "entry" + } + ] + ) + end + + it "makes two API calls (initial + after tool execution)" do + agent.chat(user_message) { |chunk| chunks << chunk } + expect(mistral_client).to have_received(:append_to_conversation).twice + end + + it "returns final assistant response" do + result = agent.chat(user_message) { |chunk| chunks << chunk } + expect(result).to eq("Task completed successfully") + end + + it "yields chunks from both API calls" do + agent.chat(user_message) { |chunk| chunks << chunk } + expect(chunks).to eq(["chunk1", "chunk2"]) + end + end + end +end diff --git a/spec/lib/lago_mcp_client/mistral/client_spec.rb b/spec/lib/lago_mcp_client/mistral/client_spec.rb new file mode 100644 index 0000000..15e1add --- /dev/null +++ b/spec/lib/lago_mcp_client/mistral/client_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::Mistral::Client do + subject(:client) { described_class.new } + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:api_key) { "test_api_key" } + let(:agent_id) { "test_agent_id" } + + before do + ENV["MISTRAL_API_KEY"] = api_key + ENV["MISTRAL_AGENT_ID"] = agent_id + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + end + + describe "#start_conversation" do + let(:inputs) { "Hello, how can I help?" } + let(:conversation_id) { "conv_123" } + + before do + allow(http_client).to receive(:post_with_stream).and_yield( + nil, + {conversation_id:, type: "conversation.response.done"}.to_json, + nil, + nil + ) + end + + it "creates an HTTP client with the conversations URL and timeout" do + client.start_conversation(inputs:) + + expect(LagoHttpClient::Client).to have_received(:new).with( + "https://api.mistral.ai/v1/conversations", + read_timeout: 120 + ) + end + + it "calls post_with_stream with correct payload and headers" do + client.start_conversation(inputs:) + + expect(http_client).to have_received(:post_with_stream).with( + { + agent_id:, + inputs: [{role: "user", content: inputs}], + stream: true + }, + { + "Authorization" => "Bearer #{api_key}", + "Accept" => "text/event-stream" + } + ) + end + + it "returns the parsed result" do + result = client.start_conversation(inputs:) + + expect(result["conversation_id"]).to eq(conversation_id) + end + + context "when inputs is an array" do + let(:inputs) { [{role: "user", content: "Hello"}] } + + it "passes inputs as-is without normalizing" do + client.start_conversation(inputs:) + + expect(http_client).to have_received(:post_with_stream).with( + hash_including(inputs:), + anything + ) + end + end + + context "when streaming content" do + let(:chunks) { [] } + + before do + allow(http_client).to receive(:post_with_stream) + .and_yield(nil, {type: "message.output.delta", content: "Hello"}.to_json, nil, nil) + .and_yield(nil, {type: "message.output.delta", content: " world"}.to_json, nil, nil) + .and_yield(nil, {conversation_id:, type: "conversation.response.done"}.to_json, nil, nil) + end + + it "yields content chunks to the block" do + client.start_conversation(inputs:) { |chunk| chunks << chunk } + + expect(chunks).to eq(["Hello", " world"]) + end + end + + context "when HTTP error occurs" do + before do + allow(http_client).to receive(:post_with_stream).and_raise( + LagoHttpClient::HttpError.new(401, "Unauthorized", URI("https://api.mistral.ai")) + ) + end + + it "raises ApiError with code and body" do + expect { client.start_conversation(inputs:) } + .to raise_error(LagoMcpClient::Mistral::ApiError) do |error| + expect(error.error_code).to eq(401) + expect(error.error_body).to eq("Unauthorized") + expect(error.message).to eq("Mistral API Error (401): Unauthorized") + end + end + end + + context "when other error occurs" do + before do + allow(http_client).to receive(:post_with_stream).and_raise(StandardError.new("Connection failed")) + end + + it "raises a streaming error" do + expect { client.start_conversation(inputs:) } + .to raise_error("Mistral Conversations API streaming error: Connection failed") + end + end + end + + describe "#append_to_conversation" do + let(:conversation_id) { "conv_existing_789" } + let(:inputs) { [{role: "user", content: "Follow-up question"}] } + + before do + allow(http_client).to receive(:post_with_stream).and_yield( + nil, + {type: "conversation.response.done"}.to_json, + nil, + nil + ) + end + + it "creates an HTTP client with the conversation-specific URL" do + client.append_to_conversation(conversation_id:, inputs:) + + expect(LagoHttpClient::Client).to have_received(:new).with( + "https://api.mistral.ai/v1/conversations/#{conversation_id}", + read_timeout: 120 + ) + end + + it "calls post_with_stream with correct payload" do + client.append_to_conversation(conversation_id:, inputs:) + + expect(http_client).to have_received(:post_with_stream).with( + {inputs:, stream: true}, + { + "Authorization" => "Bearer #{api_key}", + "Accept" => "text/event-stream" + } + ) + end + end +end diff --git a/spec/lib/lago_mcp_client/mistral/response_parser_spec.rb b/spec/lib/lago_mcp_client/mistral/response_parser_spec.rb new file mode 100644 index 0000000..4d32a56 --- /dev/null +++ b/spec/lib/lago_mcp_client/mistral/response_parser_spec.rb @@ -0,0 +1,197 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::Mistral::ResponseParser do + subject(:parser) { described_class.new } + + describe "#process" do + context "when receiving [DONE] marker" do + it "does nothing" do + parser.process("[DONE]") + + expect(parser.conversation_id).to be_nil + expect(parser.outputs).to be_empty + expect(parser.tool_calls).to be_empty + end + end + + context "when receiving conversation_id" do + it "extracts conversation_id from response" do + parser.process({conversation_id: "conv_123", type: "conversation.response.done"}.to_json) + + expect(parser.conversation_id).to eq("conv_123") + end + end + + context "when receiving message.output.delta" do + it "yields content to the block" do + chunks = [] + + parser.process({type: "message.output.delta", content: "Hello"}.to_json) { |c| chunks << c } + parser.process({type: "message.output.delta", content: " world"}.to_json) { |c| chunks << c } + + expect(chunks).to eq(["Hello", " world"]) + end + + it "does not yield when content is blank" do + chunks = [] + + parser.process({type: "message.output.delta", content: ""}.to_json) { |c| chunks << c } + parser.process({type: "message.output.delta", content: nil}.to_json) { |c| chunks << c } + + expect(chunks).to be_empty + end + + it "does not yield when no block given" do + expect { parser.process({type: "message.output.delta", content: "Hello"}.to_json) }.not_to raise_error + end + end + + context "when receiving function.call" do + it "adds tool call to the list" do + parser.process({ + type: "function.call", + tool_call_id: "call_123", + name: "get_customer", + arguments: '{"id": "456"}' + }.to_json) + + expect(parser.tool_calls).to eq([ + { + "id" => "call_123", + "type" => "function", + "function" => { + "name" => "get_customer", + "arguments" => '{"id": "456"}' + } + } + ]) + end + + it "handles missing arguments" do + parser.process({ + type: "function.call", + tool_call_id: "call_123", + name: "list_all" + }.to_json) + + expect(parser.tool_calls.first.dig("function", "arguments")).to eq("") + end + end + + context "when receiving function.call.delta (streaming)" do + it "accumulates arguments across multiple deltas" do + parser.process({ + type: "function.call.delta", + tool_call_id: "call_stream", + name: "search", + arguments: '{"query":' + }.to_json) + + parser.process({ + type: "function.call.delta", + tool_call_id: "call_stream", + arguments: ' "test"}' + }.to_json) + + expect(parser.tool_calls).to eq([ + { + "id" => "call_stream", + "type" => "function", + "function" => { + "name" => "search", + "arguments" => '{"query": "test"}' + } + } + ]) + end + end + + context "when receiving outputs array with message.output" do + it "adds outputs to the list" do + parser.process({ + outputs: [{type: "message.output", content: "Final response"}] + }.to_json) + + expect(parser.outputs).to eq([{"type" => "message.output", "content" => "Final response"}]) + end + end + + context "when receiving outputs array with tool.call" do + it "adds tool calls from outputs" do + parser.process({ + outputs: [ + { + type: "tool.call", + tool_call_id: "tool_789", + name: "list_customers", + arguments: "{}" + } + ] + }.to_json) + + expect(parser.tool_calls).to eq([ + { + "id" => "tool_789", + "type" => "function", + "function" => { + "name" => "list_customers", + "arguments" => "{}" + } + } + ]) + end + + it "extracts tool call from nested function structure" do + parser.process({ + outputs: [ + { + type: "function.call", + id: "func_123", + function: {name: "get_data", arguments: '{"key": "value"}'} + } + ] + }.to_json) + + expect(parser.tool_calls.first).to include( + "id" => "func_123", + "function" => {"name" => "get_data", "arguments" => '{"key": "value"}'} + ) + end + end + + context "when JSON parsing fails" do + it "logs error and continues" do + allow(Rails.logger).to receive(:error) + + parser.process("invalid json {") + + expect(Rails.logger).to have_received(:error).with(/Failed to parse SSE data/) + expect(Rails.logger).to have_received(:error).with(/Parse error/) + end + end + end + + describe "#to_result" do + it "returns hash with conversation_id, outputs, and tool_calls" do + parser.process({conversation_id: "conv_abc"}.to_json) + parser.process({outputs: [{type: "message.output", content: "Hi"}]}.to_json) + parser.process({type: "function.call", tool_call_id: "call_1", name: "test", arguments: "{}"}.to_json) + + result = parser.to_result + + expect(result).to eq({ + "conversation_id" => "conv_abc", + "outputs" => [{"type" => "message.output", "content" => "Hi"}], + "tool_calls" => [{"id" => "call_1", "type" => "function", "function" => {"name" => "test", "arguments" => "{}"}}] + }) + end + + it "returns nil for tool_calls when empty" do + parser.process({conversation_id: "conv_abc"}.to_json) + + expect(parser.to_result["tool_calls"]).to be_nil + end + end +end diff --git a/spec/lib/lago_mcp_client/run_context_spec.rb b/spec/lib/lago_mcp_client/run_context_spec.rb new file mode 100644 index 0000000..78200a4 --- /dev/null +++ b/spec/lib/lago_mcp_client/run_context_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::RunContext do + subject(:run_context) { described_class.new(client:) } + + let(:client) { instance_double("LagoMcpClient::Client") } + let(:tool_sum) { instance_double("LagoMcpClient::Tool", name: "sum", description: "Adds numbers", input_schema: {"a" => "int", "b" => "int"}) } + let(:tool_echo) { instance_double("LagoMcpClient::Tool", name: "echo", description: "Echoes text", input_schema: {"msg" => "string"}) } + + describe "#setup!" do + it "loads tools from the client and returns self" do + allow(client).to receive(:list_tools).and_return([tool_sum, tool_echo]) + + result = run_context.setup! + + expect(result).to eq(run_context) + expect(run_context.to_model_tools.map { |t| t[:function][:name] }).to contain_exactly("sum", "echo") + end + end + + describe "#to_model_tools" do + before do + allow(client).to receive(:list_tools).and_return([tool_sum]) + run_context.setup! + end + + it "returns formatted model tool definitions" do + expect(run_context.to_model_tools).to eq([ + { + type: "function", + function: { + name: "sum", + description: "Adds numbers", + parameters: {"a" => "int", "b" => "int"} + } + } + ]) + end + end + + describe "#process_tool_calls" do + before do + allow(client).to receive(:list_tools).and_return([tool_sum]) + run_context.setup! + allow(client).to receive(:call_tool).with("sum", {"a" => 1, "b" => 2}).and_return({"result" => 3}) + end + + let(:tool_call) do + { + "id" => "123", + "function" => { + "name" => "sum", + "arguments" => '{"a":1,"b":2}' + } + } + end + + it "executes a valid tool call and returns JSON-formatted results" do + result = run_context.process_tool_calls([tool_call]) + + expect(result).to eq([ + { + tool_call_id: "123", + role: "tool", + content: JSON.generate({"result" => 3}) + } + ]) + end + + it "raises an error if the tool does not exist" do + fake_call = { + "id" => "456", + "function" => {"name" => "missing", "arguments" => "{}"} + } + + expect { run_context.process_tool_calls([fake_call]) }.to raise_error("Tool 'missing' not found") + end + end +end diff --git a/spec/lib/lago_mcp_client/sse_client_spec.rb b/spec/lib/lago_mcp_client/sse_client_spec.rb new file mode 100644 index 0000000..013d4d0 --- /dev/null +++ b/spec/lib/lago_mcp_client/sse_client_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::SseClient do + subject(:sse_client) { described_class.new(url: url, session_id: session_id) } + + let(:url) { "https://mcp.example.com/sse" } + let(:session_id) { "sess-abc123" } + + let(:http) { instance_double(Net::HTTP) } + let(:response) { instance_double(Net::HTTPResponse, code: "200") } + let(:request) { instance_double(Net::HTTP::Get) } + + before do + allow(Net::HTTP).to receive(:start).and_yield(http) + allow(Net::HTTP::Get).to receive(:new).and_return(request) + allow(request).to receive(:[]=) + allow(http).to receive(:request).with(request).and_yield(response) + allow(response).to receive(:read_body) + end + + describe "#start" do + before do + allow(Thread).to receive(:new).and_return(instance_double(Thread)) + end + + it "starts a background thread" do + sse_client.start + expect(Thread).to have_received(:new) + end + + it "registers the callback" do + callback_called = false + sse_client.start { callback_called = true } + + callbacks = sse_client.instance_variable_get(:@callbacks) + expect(callbacks.size).to eq(1) + end + + it "sets running to true" do + sse_client.start + expect(sse_client.instance_variable_get(:@running)).to be(true) + end + + it "does not start multiple threads when called multiple times" do + sse_client.start + sse_client.start + + expect(Thread).to have_received(:new).once + end + end + + describe "#stop" do + let(:mock_thread) { instance_double(Thread, join: true) } + + before do + allow(Thread).to receive(:new).and_return(mock_thread) + sse_client.start + end + + it "sets running to false" do + sse_client.stop + expect(sse_client.instance_variable_get(:@running)).to be(false) + end + + it "joins the thread with timeout" do + sse_client.stop + expect(mock_thread).to have_received(:join).with(1) + end + + it "clears the thread reference" do + sse_client.stop + expect(sse_client.instance_variable_get(:@thread)).to be_nil + end + end + + describe "HTTP request configuration" do + before do + allow(Thread).to receive(:new) do |&block| + block.call + instance_double(Thread) + end + end + + it "sets the correct headers" do + sse_client.start + + expect(request).to have_received(:[]=).with("Mcp-Session-Id", session_id) + expect(request).to have_received(:[]=).with("Accept", "application/json,text/event-stream") + expect(request).to have_received(:[]=).with("Cache-Control", "no-cache") + end + + it "uses SSL for https URLs" do + sse_client.start + + expect(Net::HTTP).to have_received(:start).with("mcp.example.com", 443, use_ssl: true) + end + + context "with http URL" do + let(:url) { "http://mcp.example.com/sse" } + + it "does not use SSL" do + sse_client.start + + expect(Net::HTTP).to have_received(:start).with("mcp.example.com", 80, use_ssl: false) + end + end + end + + describe "callback invocation" do + before do + allow(Thread).to receive(:new) do |&block| + block.call + instance_double(Thread) + end + end + + it "invokes callbacks with parsed event data" do + received_data = [] + allow(response).to receive(:read_body).and_yield("data: {\"message\": \"hello\"}\n") + + sse_client.start { |data| received_data << data } + + expect(received_data).to eq([{"message" => "hello"}]) + end + + it "invokes multiple callbacks" do + results1 = [] + results2 = [] + allow(response).to receive(:read_body).and_yield("data: {\"test\": true}\n") + + # Pre-register both callbacks before starting + sse_client.instance_variable_get(:@callbacks) << proc { |data| results1 << data } + sse_client.instance_variable_get(:@callbacks) << proc { |data| results2 << data } + + sse_client.start + + expect(results1).to eq([{"test" => true}]) + expect(results2).to eq([{"test" => true}]) + end + + it "handles multiple events in a single chunk" do + received_data = [] + allow(response).to receive(:read_body).and_yield("data: {\"event\": 1}\ndata: {\"event\": 2}\n") + + sse_client.start { |data| received_data << data } + + expect(received_data).to eq([{"event" => 1}, {"event" => 2}]) + end + end + + describe "error handling" do + before do + allow(Rails.logger).to receive(:error) + allow(Thread).to receive(:new) do |&block| + block.call + instance_double(Thread) + end + end + + it "logs errors when HTTP request fails" do + allow(Net::HTTP).to receive(:start).and_raise(StandardError.new("Connection failed")) + + sse_client.start + + expect(Rails.logger).to have_received(:error).with("SSE client error: Connection failed") + expect(Rails.logger).to have_received(:error).twice + end + + context "with non-200 response" do + let(:response) { instance_double(Net::HTTPResponse, code: "500") } + + it "does not process the response body" do + sse_client.start + expect(response).not_to have_received(:read_body) + end + end + end +end diff --git a/spec/lib/lago_mcp_client/sse_parser_spec.rb b/spec/lib/lago_mcp_client/sse_parser_spec.rb new file mode 100644 index 0000000..47acf52 --- /dev/null +++ b/spec/lib/lago_mcp_client/sse_parser_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::SseParser do + subject(:parser) { Class.new { include LagoMcpClient::SseParser }.new } + + describe "#parse_sse_data" do + it "parses valid JSON data lines" do + result = parser.parse_sse_data('data: {"foo": "bar"}') + expect(result).to eq({"foo" => "bar"}) + end + + it "returns nil when JSON parsing fails" do + result = parser.parse_sse_data("data: invalid json") + expect(result).to be_nil + end + + it "returns nil for nil input" do + expect(parser.parse_sse_data(nil)).to be_nil + end + + it "returns nil for empty lines" do + expect(parser.parse_sse_data("")).to be_nil + expect(parser.parse_sse_data(" ")).to be_nil + expect(parser.parse_sse_data("\n")).to be_nil + end + + it "returns nil for non-data lines" do + expect(parser.parse_sse_data("id: 123")).to be_nil + expect(parser.parse_sse_data("event: message")).to be_nil + expect(parser.parse_sse_data(": comment")).to be_nil + end + + it "strips whitespace from data" do + result = parser.parse_sse_data("data: {\"key\": \"value\"} \n") + expect(result).to eq({"key" => "value"}) + end + end + + describe "#extract_sse_id" do + it "extracts id from valid id line" do + expect(parser.extract_sse_id("id: 123")).to eq("123") + end + + it "strips whitespace from id" do + expect(parser.extract_sse_id("id: abc-456 \n")).to eq("abc-456") + end + + it "returns nil for nil input" do + expect(parser.extract_sse_id(nil)).to be_nil + end + + it "returns nil for non-id lines" do + expect(parser.extract_sse_id("data: foo")).to be_nil + expect(parser.extract_sse_id("event: message")).to be_nil + end + end + + describe "#find_sse_data_line" do + it "finds the data line in a multi-line body" do + body = "id: 123\nevent: message\ndata: {\"foo\": \"bar\"}\n" + expect(parser.find_sse_data_line(body)).to eq("data: {\"foo\": \"bar\"}\n") + end + + it "returns the first data line when multiple exist" do + body = "data: first\ndata: second\n" + expect(parser.find_sse_data_line(body)).to eq("data: first\n") + end + + it "returns nil when no data line exists" do + body = "id: 123\nevent: message\n" + expect(parser.find_sse_data_line(body)).to be_nil + end + + it "returns nil for nil body" do + expect(parser.find_sse_data_line(nil)).to be_nil + end + end + + describe "#find_sse_id_line" do + it "finds the id line in a multi-line body" do + body = "id: 123\nevent: message\ndata: {\"foo\": \"bar\"}\n" + expect(parser.find_sse_id_line(body)).to eq("id: 123\n") + end + + it "returns the first id line when multiple exist" do + body = "id: first\nid: second\n" + expect(parser.find_sse_id_line(body)).to eq("id: first\n") + end + + it "returns nil when no id line exists" do + body = "data: foo\nevent: message\n" + expect(parser.find_sse_id_line(body)).to be_nil + end + + it "returns nil for nil body" do + expect(parser.find_sse_id_line(nil)).to be_nil + end + end +end diff --git a/spec/lib/lago_mcp_client/tool_spec.rb b/spec/lib/lago_mcp_client/tool_spec.rb new file mode 100644 index 0000000..95edec7 --- /dev/null +++ b/spec/lib/lago_mcp_client/tool_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoMcpClient::Tool do + subject(:tool) { described_class.new(name:, description:, input_schema:) } + + let(:name) { "get_billable_metric" } + let(:description) { "Get a specific billable metric by its code" } + let(:input_schema) do + { + "$schema" => "http://json-schema.org/draft-07/schema#", + "properties" => {"code" => {"type" => "string"}}, + "required" => ["code"], + "title" => "GetBillableMetricArgs", + "type" => "object" + } + end + + describe "#to_h" do + it "returns a hash with all attributes" do + expect(tool.to_h).to eq( + name: "get_billable_metric", + description: "Get a specific billable metric by its code", + input_schema: + ) + end + end +end diff --git a/spec/lib/lago_utils/license_spec.rb b/spec/lib/lago_utils/license_spec.rb new file mode 100644 index 0000000..38bd908 --- /dev/null +++ b/spec/lib/lago_utils/license_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoUtils::License do + subject(:license) { described_class.new(url) } + + let(:url) { "https://license.lago" } + + before do + ENV["LAGO_LICENSE"] = "test-license" + end + + describe "#verify" do + context "when license is valid" do + let(:response) do + { + "valid" => true + }.to_json + end + + before do + stub_request(:get, "#{url}/verify/test-license") + .to_return(body: response, status: 200) + end + + it "sets premium to true" do + license.verify + + expect(license).to be_premium + end + end + + context "when license is not present" do + before do + ENV["LAGO_LICENSE"] = nil + end + + it "keeps premium to false" do + license.verify + + expect(license).not_to be_premium + end + end + + context "when license is invalid" do + let(:response) do + { + "valid" => false + }.to_json + end + + before do + stub_request(:get, "#{url}/verify/test-license") + .to_return(body: response, status: 200) + end + + it "keeps premium to false" do + license.verify + + expect(license).not_to be_premium + end + end + end +end diff --git a/spec/lib/lago_utils/ruby_sandbox_spec.rb b/spec/lib/lago_utils/ruby_sandbox_spec.rb new file mode 100644 index 0000000..8b28e9c --- /dev/null +++ b/spec/lib/lago_utils/ruby_sandbox_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LagoUtils::RubySandbox do + let(:code) { <<~RUBY } + input = { 'a' => 1, 'b' => 2 } + + input.values.sum + RUBY + + it "runs the code" do + expect(described_class.run(code)).to eq(3) + end + + context "with method definition" do + let(:code) { <<~RUBY } + def sum(a, b) + a + b + end + + sum(1, 2) + RUBY + + it "runs the code" do + expect(described_class.run(code)).to eq(3) + end + end + + context "when code requires a library" do + let(:code) { <<~RUBY } + require 'json' + + { 'a' => 1, 'b' => 2 }.to_json + RUBY + + it "raises an error" do + expect { described_class.run(code) }.to raise_error(LagoUtils::RubySandbox::SandboxError) + end + end + + context "when code is calling a blacklisted method" do + let(:code) { <<~RUBY } + Kernel.exit + RUBY + + it "raises an error" do + expect { described_class.run(code) }.to raise_error(LagoUtils::RubySandbox::SandboxError) + end + end + + context "when code is executing an external script" do + let(:code) { <<~RUBY } + `rm -rf /tmp` + RUBY + + it "raises an error" do + expect { described_class.run(code) }.to raise_error(LagoUtils::RubySandbox::SandboxError) + end + end +end diff --git a/spec/lib/redlock/client_patch_spec.rb b/spec/lib/redlock/client_patch_spec.rb new file mode 100644 index 0000000..0a33e32 --- /dev/null +++ b/spec/lib/redlock/client_patch_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Redlock::ClientPatch do + subject(:lock_manager) { Redlock::Client.new([redis_client]) } + + let(:redis_url) { ENV["REDIS_URL"] } + let(:redis_client) do + redis_wrapper_class.new(RedisClient.new(url: redis_url)) + end + let(:redis_wrapper_class) do + Class.new(SimpleDelegator) do + attr_reader :lock_attempts + + def initialize(*args, **kwargs) + super + @lock_attempts = [] + end + + def reset_lock_attempts + @lock_attempts = [] + end + + def call(*args, **kwargs) + if args.first == "EVALSHA" && args[1] == Redlock::Scripts::LOCK_SCRIPT_SHA + @lock_attempts << Time.zone.now + end + super + end + end + end + + let(:resource) { "test_resource_#{SecureRandom.hex(8)}" } + let(:ttl) { 10000 } + let(:options) { {retry_count: 0} } + + describe "#lock" do + it "locks the resource" do + lock_info = lock_manager.lock(resource, ttl, options) + + expect(lock_info).to match({validity: a_kind_of(Integer), resource: resource, value: a_kind_of(String)}) + + lock_info2 = lock_manager.lock(resource, ttl, options) + expect(lock_info2).to be false + + lock_manager.unlock(lock_info) + + lock_info3 = lock_manager.lock(resource, ttl, options) + expect(lock_info3).to match({validity: a_kind_of(Integer), resource: resource, value: a_kind_of(String)}) + end + + context "when lock acquisition fails" do + before do + lock_manager.lock(resource, ttl, options) + redis_client.reset_lock_attempts + end + + it "does not retry" do + expect(lock_manager.lock(resource, ttl, options)).to be false + expect(redis_client.lock_attempts.length).to eq 1 + end + + context "when lock retry options is defined" do + it "retries to acquire the lock" do + expect(lock_manager.lock(resource, ttl, {retry_count: 3})).to be false + + expect(redis_client.lock_attempts.length).to eq 4 + 3.times do |i| + expect(redis_client.lock_attempts[i + 1]).to be >= (redis_client.lock_attempts[i] + 0.2.seconds) + end + end + end + end + + context "when connection to redis is lost" do + let(:redis_url) { "redis://localhost:22222" } + + it "retries to acquire the lock" do + expect do + lock_manager.lock(resource, ttl, options) + end.to raise_error(Redlock::LockAcquisitionError) + + expect(redis_client.lock_attempts.length).to eq 4 + 3.times do |i| + expect(redis_client.lock_attempts[i + 1]).to be >= (redis_client.lock_attempts[i] + 0.2.seconds) + end + end + end + end +end diff --git a/spec/lib/sidekiq/profiling_middleware_spec.rb b/spec/lib/sidekiq/profiling_middleware_spec.rb new file mode 100644 index 0000000..302689b --- /dev/null +++ b/spec/lib/sidekiq/profiling_middleware_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "rails_helper" +require_relative "../../../dev/sidekiq/profiling_middleware" + +RSpec.describe Sidekiq::ProfilingMiddleware do + subject(:middleware) { described_class.new(**options) } + + let(:test_dir) { "tmp/test_profiling_#{SecureRandom.hex(4)}" } + let(:options) { {dir: test_dir} } + let(:instance) { nil } + let(:queue) { "default" } + let(:job_hash) do + { + "class" => "TestJob", + "jid" => "test_jid_123", + "enqueued_at" => Time.current.to_f + } + end + + def test_method_to_profile + value = 0 + 200_000.times { value += 1 } + value + end + + around do |example| + FileUtils.rm_rf(test_dir) + example.run + ensure + FileUtils.rm_rf(test_dir) + end + + describe "#call" do + let(:block) { -> { test_method_to_profile } } + + it "generates profile files" do + result = middleware.call(instance, job_hash, queue, &block) + + expect(result).to eq(200_000) + + profile_dir = "#{test_dir}/TestJob" + + expect(Dir.exist?(profile_dir)).to be(true) + + files = Dir.glob("#{profile_dir}/*.json") + expect(files.length).to eq 1 + + profiling_content = File.read(files.first) + + expect(profiling_content).to include("test_method_to_profile") + end + + context "with wrapped job class" do + let(:job_hash) do + { + "class" => "ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper", + "wrapped" => "MyWrappedJob", + "jid" => "wrapped_jid_456", + "enqueued_at" => Time.current.to_f + } + end + + it "generates profile files with wrapped job name" do + result = middleware.call(instance, job_hash, queue, &block) + + expect(result).to eq(200_000) + + profile_dir = "#{test_dir}/MyWrappedJob" + + expect(Dir.exist?(profile_dir)).to be(true) + + files = Dir.glob("#{profile_dir}/*.json") + expect(files.length).to eq 1 + + profiling_content = File.read(files.first) + + expect(profiling_content).to include("test_method_to_profile") + end + end + end +end diff --git a/spec/lib/tasks/customers_rake_spec.rb b/spec/lib/tasks/customers_rake_spec.rb new file mode 100644 index 0000000..f9a16ef --- /dev/null +++ b/spec/lib/tasks/customers_rake_spec.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require "rails_helper" + +require "rake" + +RSpec.describe "customers:backfill_eu_auto_taxes" do # rubocop:disable RSpec/DescribeClass + let(:task) { Rake::Task["customers:backfill_eu_auto_taxes"] } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", eu_tax_management: true) } + + let(:fr_standard) { create(:tax, organization:, code: "lago_eu_fr_standard") } + + before do + Rake.application.rake_require("tasks/customers") + Rake::Task.define_task(:environment) + task.reenable + create(:tax, organization:, code: "lago_eu_de_standard") + end + + def apply_tax(customer, tax) + create(:customer_applied_tax, customer:, tax:) + end + + context "without an organization_id argument" do + it "aborts with a usage message" do + expect { task.invoke }.to raise_error(SystemExit).and output(/Missing organization_id argument/).to_stderr + end + end + + context "when DRY_RUN is disabled" do + around do |example| + ENV["DRY_RUN"] = "false" + example.run + ensure + ENV["DRY_RUN"] = nil + end + + context "when customer country differs from currently applied EU standard tax" do + let(:customer) do + create(:customer, organization:, billing_entity:, country: "DE", zipcode: "10115", tax_identification_number: nil) + end + + before { apply_tax(customer, fr_standard) } + + it "re-applies the customer country standard tax" do + task.invoke(organization.id) + + expect(customer.reload.taxes.pluck(:code)).to contain_exactly("lago_eu_de_standard") + end + + context "when the customer also has a manually applied non-EU tax" do + let!(:custom_tax) { create(:tax, organization:, code: "custom_local_tax") } + + before { apply_tax(customer, custom_tax) } + + it "preserves the manually applied non-EU tax" do + task.invoke(organization.id) + + expect(customer.reload.taxes.pluck(:code)).to match_array(["lago_eu_de_standard", "custom_local_tax"]) + end + end + end + + context "when customer country matches the currently applied EU standard tax" do + let(:customer) { create(:customer, organization:, billing_entity:, country: "FR") } + + before { apply_tax(customer, fr_standard) } + + it "does not change the applied tax" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + end + + context "when billing entity has eu_tax_management disabled" do + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", eu_tax_management: false) } + let(:customer) { create(:customer, organization:, billing_entity:, country: "DE") } + + before { apply_tax(customer, fr_standard) } + + it "does not process the customer" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + end + + context "when customer has no country" do + let(:customer) { create(:customer, organization:, billing_entity:, country: nil) } + + before { apply_tax(customer, fr_standard) } + + it "does not process the customer" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + end + + context "when customer is on a reverse charge tax" do + let!(:reverse_charge) { create(:tax, organization:, code: "lago_eu_reverse_charge") } + let(:customer) { create(:customer, organization:, billing_entity:, country: "DE") } + + before { apply_tax(customer, reverse_charge) } + + it "does not process the customer" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + end + + context "when customer is on an exception tax code" do + let!(:exception_tax) { create(:tax, organization:, code: "lago_eu_fr_exception_corsica") } + let(:customer) { create(:customer, organization:, billing_entity:, country: "FR", zipcode: "20000") } + + before { apply_tax(customer, exception_tax) } + + it "does not process the customer" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + end + + context "when customer already has a pending VIES check" do + let(:customer) do + create(:customer, organization:, billing_entity:, country: "DE", tax_identification_number: "DE123456789") + end + + before do + apply_tax(customer, fr_standard) + create(:pending_vies_check, customer:) + end + + it "does not process the customer" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + end + + context "when customer has a tax identification number but no pending VIES check" do + let(:customer) do + create(:customer, organization:, billing_entity:, country: "DE", tax_identification_number: "DE123456789") + end + + before { apply_tax(customer, fr_standard) } + + it "schedules an async VIES check and leaves the tax unchanged for now" do + expect { task.invoke(organization.id) }.to change(PendingViesCheck, :count).by(1) + expect(customer.reload.taxes.pluck(:code)).to contain_exactly("lago_eu_fr_standard") + end + end + + context "when BATCH_SIZE is smaller than the number of matching customers" do + before do + ENV["BATCH_SIZE"] = "1" + + 2.times do + customer = create(:customer, organization:, billing_entity:, country: "DE", tax_identification_number: nil) + apply_tax(customer, fr_standard) + end + end + + after { ENV.delete("BATCH_SIZE") } + + it "processes all customers across multiple batches" do + task.invoke(organization.id) + + expect(Customer.where(organization:).flat_map { |c| c.taxes.pluck(:code) }.uniq) + .to contain_exactly("lago_eu_de_standard") + end + end + + context "when the affected customer belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_billing_entity) do + create(:billing_entity, organization: other_organization, country: "FR", eu_tax_management: true) + end + let(:other_customer) do + create(:customer, organization: other_organization, billing_entity: other_billing_entity, country: "DE", tax_identification_number: nil) + end + let(:other_fr_standard) { create(:tax, organization: other_organization, code: "lago_eu_fr_standard") } + + before do + create(:tax, organization: other_organization, code: "lago_eu_de_standard") + apply_tax(other_customer, other_fr_standard) + end + + it "does not process the customer" do + expect { task.invoke(organization.id) }.not_to change { other_customer.reload.taxes.pluck(:code) } + end + end + end + + context "when DRY_RUN is enabled" do + around do |example| + ENV["DRY_RUN"] = "true" + example.run + ensure + ENV["DRY_RUN"] = nil + end + + context "when customer country differs from currently applied EU standard tax" do + let(:customer) do + create(:customer, organization:, billing_entity:, country: "DE", zipcode: "10115", tax_identification_number: nil) + end + + before { apply_tax(customer, fr_standard) } + + it "does not change the applied taxes" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + + it "does not enqueue a ViesCheckJob" do + expect { task.invoke(organization.id) }.not_to have_enqueued_job(Customers::ViesCheckJob) + end + + it "prints a dry-run preview with the target tax code" do + expect { task.invoke(organization.id) } + .to output(/\[DRY RUN\].*target=lago_eu_de_standard.*would re-apply/).to_stdout + end + end + + context "when customer has a tax identification number" do + let(:customer) do + create(:customer, organization:, billing_entity:, country: "DE", tax_identification_number: "DE123456789") + end + + before { apply_tax(customer, fr_standard) } + + it "does not create a PendingViesCheck record" do + expect { task.invoke(organization.id) }.not_to change(PendingViesCheck, :count) + end + + it "does not enqueue a ViesCheckJob" do + expect { task.invoke(organization.id) }.not_to have_enqueued_job(Customers::ViesCheckJob) + end + + it "prints a dry-run preview indicating a VIES check would be scheduled" do + expect { task.invoke(organization.id) } + .to output(/\[DRY RUN\].*would schedule VIES check/).to_stdout + end + end + + context "when there are no matching candidates" do + let(:customer) { create(:customer, organization:, billing_entity:, country: "FR") } + + before { apply_tax(customer, fr_standard) } + + it "prints the DRY RUN header and summary" do + expect { task.invoke(organization.id) } + .to output(/Starting EU auto-taxes backfill \[DRY RUN\].*Done \[DRY RUN\]/m).to_stdout + end + end + end + + context "when DRY_RUN is not set" do + around do |example| + ENV.delete("DRY_RUN") + example.run + end + + let(:customer) do + create(:customer, organization:, billing_entity:, country: "DE", zipcode: "10115", tax_identification_number: nil) + end + + before { apply_tax(customer, fr_standard) } + + it "defaults to dry-run mode and does not change applied taxes" do + expect { task.invoke(organization.id) }.not_to change { customer.reload.taxes.pluck(:code) } + end + + it "prints a DRY RUN preview" do + expect { task.invoke(organization.id) } + .to output(/Starting EU auto-taxes backfill \[DRY RUN\]/).to_stdout + end + end +end diff --git a/spec/lib/tasks/entitlements_rake_spec.rb b/spec/lib/tasks/entitlements_rake_spec.rb new file mode 100644 index 0000000..8b65871 --- /dev/null +++ b/spec/lib/tasks/entitlements_rake_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require "rails_helper" + +require "rake" + +RSpec.describe "entitlements:cleanup_duplicate_subscription_entitlements" do # rubocop:disable RSpec/DescribeClass + let(:task) { Rake::Task["entitlements:cleanup_duplicate_subscription_entitlements"] } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:privilege) { create(:privilege, feature:, organization:) } + let(:parent_plan) { create(:plan, organization:) } + let(:child_plan) { create(:plan, organization:, parent: parent_plan) } + let(:subscription) { create(:subscription, organization:, plan: child_plan) } + + let(:plan_entitlement) do + create(:entitlement, feature:, plan: parent_plan, organization:) + end + + before do + plan_entitlement + Rake.application.rake_require("tasks/entitlements") + Rake::Task.define_task(:environment) + task.reenable + end + + context "with a duplicate subscription entitlement (no values, feature on plan)" do + let!(:duplicate_entitlement) do + create(:entitlement, :subscription, feature:, subscription:, organization:) + end + + it "soft-deletes the duplicate entitlement with a rounded timestamp" do + freeze_time do + expected_deleted_at = Time.current.beginning_of_hour + + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(duplicate_entitlement.reload.deleted_at).to eq(expected_deleted_at) + end + end + end + + context "with a subscription entitlement that has values" do + let!(:entitlement_with_values) do + entitlement = create(:entitlement, :subscription, feature:, subscription:, organization:) + create(:entitlement_value, entitlement:, privilege:, organization:) + entitlement + end + + it "does not soft-delete entitlements that have values" do + expect { task.invoke(organization.id) }.to output(/Soft-deleted 0 entitlements/).to_stdout + + expect(entitlement_with_values.reload.deleted_at).to be_nil + end + end + + context "with a subscription entitlement whose feature is not on the plan" do + let(:other_feature) { create(:feature, organization:) } + + let!(:unique_entitlement) do + create(:entitlement, :subscription, feature: other_feature, subscription:, organization:) + end + + it "does not soft-delete entitlements for features not on the plan" do + expect { task.invoke(organization.id) }.to output(/Soft-deleted 0 entitlements/).to_stdout + + expect(unique_entitlement.reload.deleted_at).to be_nil + end + end + + context "with a subscription on a plan without a parent" do + let(:standalone_plan) { create(:plan, organization:) } + let(:standalone_subscription) { create(:subscription, organization:, plan: standalone_plan) } + + let!(:duplicate_on_standalone) do + create(:entitlement, :subscription, feature:, subscription: standalone_subscription, organization:) + end + + before do + create(:entitlement, feature:, plan: standalone_plan, organization:) + end + + it "soft-deletes duplicates using COALESCE(parent_id, plan_id)" do + freeze_time do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(duplicate_on_standalone.reload.deleted_at).to eq(Time.current.beginning_of_hour) + end + end + end + + context "with mixed features on the same subscription (one duplicate, one unique)" do + let(:unique_feature) { create(:feature, organization:) } + + let!(:duplicate_entitlement) do + create(:entitlement, :subscription, feature:, subscription:, organization:) + end + + let!(:unique_entitlement) do + create(:entitlement, :subscription, feature: unique_feature, subscription:, organization:) + end + + it "only soft-deletes the duplicate and preserves the unique one" do + freeze_time do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(duplicate_entitlement.reload.deleted_at).to eq(Time.current.beginning_of_hour) + expect(unique_entitlement.reload.deleted_at).to be_nil + end + end + end + + context "when the plan entitlement is already soft-deleted" do + let!(:subscription_entitlement) do + create(:entitlement, :subscription, feature:, subscription:, organization:) + end + + before do + plan_entitlement.discard! + end + + it "does not soft-delete the subscription entitlement" do + expect { task.invoke(organization.id) }.to output(/Soft-deleted 0 entitlements/).to_stdout + + expect(subscription_entitlement.reload.deleted_at).to be_nil + end + end + + context "when entitlement values are soft-deleted" do + let!(:entitlement_with_discarded_values) do + entitlement = create(:entitlement, :subscription, feature:, subscription:, organization:) + value = create(:entitlement_value, entitlement:, privilege:, organization:) + value.discard! + entitlement + end + + it "soft-deletes the entitlement since soft-deleted values do not count" do + freeze_time do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(entitlement_with_discarded_values.reload.deleted_at).to eq(Time.current.beginning_of_hour) + end + end + end + + context "without an organization_id argument" do + it "aborts with a usage message" do + expect { task.invoke }.to raise_error(SystemExit).and output(/Missing organization_id argument/).to_stderr + end + end + + context "with a duplicate in a different organization" do + let(:other_organization) { create(:organization) } + let(:other_feature) { create(:feature, organization: other_organization) } + let(:other_plan) { create(:plan, organization: other_organization) } + let(:other_subscription) { create(:subscription, organization: other_organization, plan: other_plan) } + + let!(:other_duplicate) do + create(:entitlement, :subscription, feature: other_feature, subscription: other_subscription, organization: other_organization) + end + + before do + create(:entitlement, feature: other_feature, plan: other_plan, organization: other_organization) + end + + it "does not soft-delete entitlements from other organizations" do + expect { task.invoke(organization.id) }.to output(/Soft-deleted 0 entitlements/).to_stdout + + expect(other_duplicate.reload.deleted_at).to be_nil + end + end +end diff --git a/spec/lib/tasks/migrations/usage_thresholds_rake_spec.rb b/spec/lib/tasks/migrations/usage_thresholds_rake_spec.rb new file mode 100644 index 0000000..b937aa5 --- /dev/null +++ b/spec/lib/tasks/migrations/usage_thresholds_rake_spec.rb @@ -0,0 +1,334 @@ +# frozen_string_literal: true + +require "rails_helper" + +require "rake" + +RSpec.describe "migrations:migrate_usage_thresholds" do # rubocop:disable RSpec/DescribeClass + let(:task) { Rake::Task["migrations:migrate_usage_thresholds"] } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:parent_plan) { create(:plan, organization:) } + let(:child_plan) { create(:plan, organization:, parent: parent_plan) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, plan: child_plan, customer:) } + + before do + Rake.application.rake_require("tasks/migrations/usage_thresholds") + Rake::Task.define_task(:environment) + task.reenable + end + + context "without an organization_id argument" do + it "aborts with a usage message" do + expect { task.invoke }.to raise_error(SystemExit).and output(/Missing organization_id argument/).to_stderr + end + end + + context "when child plan thresholds match the parent plan thresholds" do + let!(:parent_threshold) do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + end + + let!(:child_threshold) do + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 1000, recurring: false) + end + + before { subscription } + + it "soft-deletes the child plan thresholds" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(child_threshold.reload.deleted_at).not_to be_nil + expect(parent_threshold.reload.deleted_at).to be_nil + end + + it "does not create any subscription thresholds" do + expect { task.invoke(organization.id) }.to output(/Migrated 1 subscription/).to_stdout + + expect(subscription.reload.usage_thresholds).to be_empty + end + end + + context "when child plan thresholds differ from the parent plan thresholds" do + let!(:parent_threshold) do # rubocop:disable RSpec/LetSetup + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + end + + let!(:child_threshold) do + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 2000, recurring: false) + end + + before { subscription } + + it "copies the child plan thresholds to each subscription" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + sub_thresholds = subscription.reload.usage_thresholds + expect(sub_thresholds.size).to eq(1) + expect(sub_thresholds.first).to have_attributes( + amount_cents: 2000, + recurring: false, + organization_id: organization.id + ) + end + + it "soft-deletes the child plan thresholds" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(child_threshold.reload.deleted_at).not_to be_nil + end + end + + context "when child plan has multiple thresholds matching the parent" do + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + create(:usage_threshold, :recurring, plan: parent_plan, organization:, amount_cents: 500) + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 1000, recurring: false) + create(:usage_threshold, :recurring, plan: child_plan, organization:, amount_cents: 500) + subscription + end + + it "soft-deletes all child plan thresholds" do + task.invoke(organization.id) + expect(child_plan.usage_thresholds.unscoped.where.not(deleted_at: nil).count).to eq(2) + end + end + + context "when child plan has multiple thresholds differing from the parent" do + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 2000, recurring: false) + create(:usage_threshold, :recurring, plan: child_plan, organization:, amount_cents: 3000) + subscription + end + + it "copies all child thresholds to the subscription" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + sub_thresholds = subscription.reload.usage_thresholds + expect(sub_thresholds.map { |t| [t.amount_cents, t.recurring] }).to match_array( + [[2000, false], [3000, true]] + ) + end + end + + context "when child plan has no usage thresholds but parent does" do + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + subscription + end + + it "sets progressive_billing_disabled on the subscription" do + expect { task.invoke(organization.id) }.to output(/Migrated 1 subscription/).to_stdout + + expect(subscription.reload.progressive_billing_disabled).to be(true) + end + + it "does not create any subscription thresholds" do + task.invoke(organization.id) + + expect(subscription.reload.usage_thresholds).to be_empty + end + end + + context "when child plan has no usage thresholds and parent also has none" do + before { subscription } + + it "does not set progressive_billing_disabled" do + expect { task.invoke(organization.id) }.to output(/Migrated 0 subscription/).to_stdout + + expect(subscription.reload.progressive_billing_disabled).to be(false) + end + end + + context "when parent plan has no usage thresholds but child does" do + let!(:child_threshold) do + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 500, recurring: false) + end + + before { subscription } + + it "moves child thresholds to subscriptions since signatures differ" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(subscription.reload.usage_thresholds.size).to eq(1) + expect(child_threshold.reload.deleted_at).not_to be_nil + end + end + + context "when subscription already has its own thresholds" do + let!(:parent_threshold) do # rubocop:disable RSpec/LetSetup + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + end + + let!(:child_threshold) do + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 2000, recurring: false) + end + + let!(:existing_sub_threshold) do + create(:usage_threshold, :for_subscription, subscription:, organization:, amount_cents: 5000, recurring: false) + end + + it "skips the subscription and does not duplicate thresholds" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + sub_thresholds = subscription.reload.usage_thresholds + expect(sub_thresholds.size).to eq(1) + expect(sub_thresholds.first.id).to eq(existing_sub_threshold.id) + end + + it "still soft-deletes the child plan thresholds" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(child_threshold.reload.deleted_at).not_to be_nil + end + end + + context "when thresholds on child plan are already soft-deleted" do + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + threshold = create(:usage_threshold, plan: child_plan, organization:, amount_cents: 2000, recurring: false) + threshold.discard! + subscription + end + + it "sets progressive_billing_disabled since child plan has no visible thresholds" do + expect { task.invoke(organization.id) }.to output(/Done. Migrated 1 subscription./).to_stdout + + expect(subscription.reload.progressive_billing_disabled).to be(true) + end + end + + context "with a different organization" do + let(:other_organization) { create(:organization) } + let(:other_feature) { create(:feature, organization: other_organization) } + let(:other_parent_plan) { create(:plan, organization: other_organization) } + let(:other_child_plan) { create(:plan, organization: other_organization, parent: other_parent_plan) } + + let!(:other_child_threshold) do + create(:usage_threshold, plan: other_child_plan, organization: other_organization, amount_cents: 2000, recurring: false) + end + + before do + create(:usage_threshold, plan: other_parent_plan, organization: other_organization, amount_cents: 1000, recurring: false) + create(:subscription, organization: other_organization, plan: other_child_plan) + end + + it "does not affect thresholds from other organizations" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(other_child_threshold.reload.deleted_at).to be_nil + end + end + + context "when the recurring flag differs between parent and child" do + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + create(:usage_threshold, :recurring, plan: child_plan, organization:, amount_cents: 1000) + subscription + end + + it "treats them as different signatures and moves to subscription" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + sub_thresholds = subscription.reload.usage_thresholds + expect(sub_thresholds.size).to eq(1) + expect(sub_thresholds.first).to have_attributes(amount_cents: 1000, recurring: true) + end + end + + context "when threshold_display_name is preserved" do + let!(:child_threshold) do # rubocop:disable RSpec/LetSetup + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 2000, recurring: false, threshold_display_name: "Custom Name") + end + + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + subscription + end + + it "copies the threshold_display_name to the subscription threshold" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + sub_threshold = subscription.reload.usage_thresholds.first + expect(sub_threshold.threshold_display_name).to eq("Custom Name") + end + end + + context "with multiple child plans under the same parent" do + let(:child_plan2) { create(:plan, organization:, parent: parent_plan) } + let(:customer2) { create(:customer, organization:) } + let!(:subscription2) { create(:subscription, organization:, plan: child_plan2, customer: customer2) } + + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + # child_plan has matching thresholds + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 1000, recurring: false) + # child_plan2 has different thresholds + create(:usage_threshold, plan: child_plan2, organization:, amount_cents: 3000, recurring: false) + subscription + end + + it "deletes matching child plan thresholds and moves differing ones" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + expect(child_plan.usage_thresholds).to be_empty + expect(child_plan2.usage_thresholds).to be_empty + expect(subscription.reload.usage_thresholds).to be_empty + expect(subscription2.reload.usage_thresholds.size).to eq(1) + expect(subscription2.usage_thresholds.first.amount_cents).to eq(3000) + end + end + + context "with multiple child plans where one has no thresholds" do + let(:child_plan2) { create(:plan, organization:, parent: parent_plan) } + let(:child_plan3) { create(:plan, organization:, parent: parent_plan) } + let(:customer2) { create(:customer, organization:) } + let(:customer3) { create(:customer, organization:) } + let!(:subscription2) { create(:subscription, organization:, plan: child_plan2, customer: customer2) } + let!(:subscription3) { create(:subscription, organization:, plan: child_plan3, customer: customer3) } + + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + # child_plan has matching thresholds + create(:usage_threshold, plan: child_plan, organization:, amount_cents: 1000, recurring: false) + # child_plan2 has different thresholds + create(:usage_threshold, plan: child_plan2, organization:, amount_cents: 2000, recurring: false) + # child_plan3 has NO thresholds + subscription + end + + it "handles each subscription appropriately" do + expect { task.invoke(organization.id) }.to output(/Done/).to_stdout + + # child_plan matching → soft-deleted, no subscription thresholds + expect(child_plan.usage_thresholds).to be_empty + expect(subscription.reload.usage_thresholds).to be_empty + expect(subscription.progressive_billing_disabled).to be(false) + + # child_plan2 differing → moved to subscription, soft-deleted + expect(child_plan2.usage_thresholds).to be_empty + expect(subscription2.reload.usage_thresholds.size).to eq(1) + expect(subscription2.progressive_billing_disabled).to be(false) + + # child_plan3 empty → progressive_billing_disabled set + expect(subscription3.reload.usage_thresholds).to be_empty + expect(subscription3.progressive_billing_disabled).to be(true) + end + end + + context "when subscription already has progressive_billing_disabled set" do + before do + create(:usage_threshold, plan: parent_plan, organization:, amount_cents: 1000, recurring: false) + subscription.update!(progressive_billing_disabled: true) + end + + it "keeps progressive_billing_disabled as true" do + expect { task.invoke(organization.id) }.to output(/Migrated 1 subscription/).to_stdout + + expect(subscription.reload.progressive_billing_disabled).to be(true) + end + end +end diff --git a/spec/lib/tasks/migrations/wallet_traceability_spec.rb b/spec/lib/tasks/migrations/wallet_traceability_spec.rb new file mode 100644 index 0000000..fa95b34 --- /dev/null +++ b/spec/lib/tasks/migrations/wallet_traceability_spec.rb @@ -0,0 +1,1644 @@ +# frozen_string_literal: true + +require "rails_helper" +require "rake" + +describe "migrations:wallet_traceability", type: :request, with_pdf_generation_stub: true do + let(:task) { Rake::Task["migrations:wallet_traceability"] } + let(:organization) { create(:organization, webhook_url: nil) } + let(:billing_entity) { create(:billing_entity, organization:, invoice_grace_period: 0) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: false) } + let(:billable_metric) { create(:billable_metric, organization:, field_name: "total", aggregation_type: "sum_agg") } + let(:charge) { create(:charge, plan:, billable_metric:, charge_model: "standard", properties: {"amount" => "1"}) } + + before do + charge + Rake.application.rake_require("tasks/migrations/wallet_traceability") + Rake::Task.define_task(:environment) + task.reenable + end + + def create_non_traceable_wallet(for_customer: customer, rate_amount: "1", name: "Non-Traceable Wallet") + params = { + external_customer_id: for_customer.external_id, + rate_amount:, + name:, + currency: "EUR", + granted_credits: "0", + invoice_requires_successful_payment: false + } + + wallet = create_wallet(params, as: :model) + wallet.update!(traceable: false) + wallet + end + + def top_up_wallet(wallet, granted_credits: nil, paid_credits: nil) + params = {wallet_id: wallet.id} + params[:granted_credits] = granted_credits if granted_credits + params[:paid_credits] = paid_credits if paid_credits + + create_wallet_transaction(params, as: :model) + end + + def setup_subscription + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + customer.subscriptions.first + end + + def ingest_usage(subscription, amount) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric.field_name => amount} + }) + perform_usage_update + end + + def run_migration(dry_run: nil, include_terminated: false, silent: true) + env_vars = { + "ORGANIZATION_ID" => organization.id, + "BATCH_SIZE" => "100", + "ERROR_DISPLAY_LIMIT" => "50" + } + env_vars["DRY_RUN"] = "false" if dry_run == false + env_vars["INCLUDE_TERMINATED"] = "true" if include_terminated + + env_vars.each { |k, v| ENV[k] = v } + task.reenable + if silent + original_stdout = $stdout + $stdout = StringIO.new + begin + task.invoke + ensure + $stdout = original_stdout + end + else + task.invoke + end + ensure + env_vars&.each_key { |k| ENV.delete(k) } + end + + describe "ENV var defaults" do + it "defaults to dry-run when dry_run is not set" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + ENV["ORGANIZATION_ID"] = organization.id + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(wallet.reload.traceable).to eq(false) + ensure + ENV.delete("ORGANIZATION_ID") + end + + it "processes all organizations when organization_id is not set" do + other_organization = create(:organization, webhook_url: nil) + other_billing_entity = create(:billing_entity, organization: other_organization, invoice_grace_period: 0) + other_customer = create(:customer, organization: other_organization, billing_entity: other_billing_entity) + + wallet1 = create_non_traceable_wallet + top_up_wallet(wallet1, granted_credits: "50") + + params = { + external_customer_id: other_customer.external_id, + rate_amount: "1", + name: "Other Org Wallet", + currency: "EUR", + granted_credits: "0", + invoice_requires_successful_payment: false + } + api_call { post_with_token(other_organization, "/api/v1/wallets", {wallet: params}) } + wallet2 = Wallet.find(json[:wallet][:lago_id]) + api_call do + post_with_token(other_organization, "/api/v1/wallet_transactions", { + wallet_transaction: {wallet_id: wallet2.id, granted_credits: "30"} + }) + end + + ENV["DRY_RUN"] = "false" + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(wallet1.reload.traceable).to eq(true) + expect(wallet2.reload.traceable).to eq(true) + ensure + ENV.delete("DRY_RUN") + end + + it "passes thread_count env var to migration" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + ENV["ORGANIZATION_ID"] = organization.id + ENV["THREAD_COUNT"] = "4" + task.reenable + + expect { task.invoke }.to output(a_string_including("Threads: 4")).to_stdout + + expect(wallet.reload.traceable).to eq(false) + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("THREAD_COUNT") + end + + it "caps batch_size to limit when both are set and batch_size exceeds limit" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + ENV["ORGANIZATION_ID"] = organization.id + ENV["LIMIT"] = "5" + ENV["BATCH_SIZE"] = "100" + task.reenable + + expect { task.invoke }.to output(a_string_including("Customer limit: 5, Batch size: 5")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("LIMIT") + ENV.delete("BATCH_SIZE") + end + + it "keeps batch_size when it is smaller than limit" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + ENV["ORGANIZATION_ID"] = organization.id + ENV["LIMIT"] = "100" + ENV["BATCH_SIZE"] = "10" + task.reenable + + expect { task.invoke }.to output(a_string_including("Customer limit: 100, Batch size: 10")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("LIMIT") + ENV.delete("BATCH_SIZE") + end + + it "does not cap batch_size when limit is not set" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + ENV["ORGANIZATION_ID"] = organization.id + ENV["BATCH_SIZE"] = "500" + task.reenable + + expect { task.invoke }.to output(a_string_including("Customer limit: all, Batch size: 500")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("BATCH_SIZE") + end + + it "cursor processes customers from the given customer_id (inclusive)" do + other_customer = create(:customer, organization:, billing_entity:) + wallet1 = create_non_traceable_wallet + top_up_wallet(wallet1, granted_credits: "50") + + wallet2 = create_non_traceable_wallet(for_customer: other_customer, name: "Other Wallet") + top_up_wallet(wallet2, granted_credits: "30") + + _, second_id = [customer.id, other_customer.id].sort + ENV["DRY_RUN"] = "false" + ENV["CURSOR"] = second_id + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + first_wallet = (customer.id == second_id) ? wallet1 : wallet2 + second_wallet = (customer.id == second_id) ? wallet2 : wallet1 + + expect(first_wallet.reload.traceable).to eq(true) + expect(second_wallet.reload.traceable).to eq(false) + ensure + ENV.delete("DRY_RUN") + ENV.delete("CURSOR") + end + + it "prints next cursor when limit is set and more records exist" do + other_customer = create(:customer, organization:, billing_entity:) + create_non_traceable_wallet + create_non_traceable_wallet(for_customer: other_customer, name: "Other Wallet") + + ENV["ORGANIZATION_ID"] = organization.id + ENV["LIMIT"] = "1" + task.reenable + + second_customer_id = [customer.id, other_customer.id].max + expect { task.invoke }.to output(a_string_including("Next cursor: #{second_customer_id}")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("LIMIT") + end + + it "does not print next cursor when all records fit within limit" do + create_non_traceable_wallet + + ENV["ORGANIZATION_ID"] = organization.id + ENV["LIMIT"] = "100" + task.reenable + + expect { task.invoke }.to output(a_string_including("Next cursor: none (all remaining records fit within limit)")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("LIMIT") + end + + it "prints next cursor accounting for cursor offset" do + other_customer = create(:customer, organization:, billing_entity:) + third_customer = create(:customer, organization:, billing_entity:) + create_non_traceable_wallet + create_non_traceable_wallet(for_customer: other_customer, name: "Wallet") + create_non_traceable_wallet(for_customer: third_customer, name: "Wallet") + + sorted_ids = [customer.id, other_customer.id, third_customer.id].sort + ENV["ORGANIZATION_ID"] = organization.id + ENV["CURSOR"] = sorted_ids[1] + ENV["LIMIT"] = "1" + task.reenable + + expect { task.invoke }.to output( + a_string_including("Cursor: #{sorted_ids[1]}").and(a_string_including("Next cursor: #{sorted_ids[2]}")) + ).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("CURSOR") + ENV.delete("LIMIT") + end + + it "re-running a cursor window after next cursor was processed is a no-op" do + other_customer = create(:customer, organization:, billing_entity:) + wallet1 = create_non_traceable_wallet + top_up_wallet(wallet1, granted_credits: "50") + + wallet2 = create_non_traceable_wallet(for_customer: other_customer, name: "Other Wallet") + top_up_wallet(wallet2, granted_credits: "30") + + first_id, second_id = [customer.id, other_customer.id].sort + first_wallet = (customer.id == first_id) ? wallet1 : wallet2 + second_wallet = (customer.id == first_id) ? wallet2 : wallet1 + + # Run first window: processes first customer only + ENV["DRY_RUN"] = "false" + ENV["CURSOR"] = first_id + ENV["LIMIT"] = "1" + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(first_wallet.reload.traceable).to eq(true) + expect(second_wallet.reload.traceable).to eq(false) + + # Run second window: processes second customer + ENV["CURSOR"] = second_id + ENV.delete("LIMIT") + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(second_wallet.reload.traceable).to eq(true) + + # Re-run first window: should be a no-op since wallets are already traceable + ENV["CURSOR"] = first_id + ENV["LIMIT"] = "1" + task.reenable + + expect { task.invoke }.to output(a_string_including("Customers processed: 0")).to_stdout + ensure + ENV.delete("DRY_RUN") + ENV.delete("CURSOR") + ENV.delete("LIMIT") + end + + it "re-running a cursor window processes remaining non-traceable wallets" do + customer_b = create(:customer, organization:, billing_entity:) + customer_c = create(:customer, organization:, billing_entity:) + + wallet1 = create_non_traceable_wallet + top_up_wallet(wallet1, granted_credits: "50") + + wallet2 = create_non_traceable_wallet(for_customer: customer_b, name: "Wallet B") + top_up_wallet(wallet2, granted_credits: "30") + + wallet3 = create_non_traceable_wallet(for_customer: customer_c, name: "Wallet C") + top_up_wallet(wallet3, granted_credits: "20") + + first_id, second_id, third_id = [customer.id, customer_b.id, customer_c.id].sort + wallets_by_customer = {customer.id => wallet1, customer_b.id => wallet2, customer_c.id => wallet3} + + # Run first window: processes first customer only + ENV["DRY_RUN"] = "false" + ENV["CURSOR"] = first_id + ENV["LIMIT"] = "1" + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(wallets_by_customer[first_id].reload.traceable).to eq(true) + expect(wallets_by_customer[second_id].reload.traceable).to eq(false) + expect(wallets_by_customer[third_id].reload.traceable).to eq(false) + + # Run second window: processes second customer only + ENV["CURSOR"] = second_id + ENV["LIMIT"] = "1" + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(wallets_by_customer[second_id].reload.traceable).to eq(true) + expect(wallets_by_customer[third_id].reload.traceable).to eq(false) + + # Re-run first cursor: the traceable: false scope shifts the window, + # so the third customer (still non-traceable) falls into the LIMIT=1 window + ENV["CURSOR"] = first_id + ENV["LIMIT"] = "1" + task.reenable + + expect { task.invoke }.to output( + a_string_including("Customers processed: 1").and(a_string_including("Wallets processed: 1")) + ).to_stdout + expect(wallets_by_customer[third_id].reload.traceable).to eq(true) + ensure + ENV.delete("DRY_RUN") + ENV.delete("CURSOR") + ENV.delete("LIMIT") + end + + it "processes nothing when cursor is past all customer_ids" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "50") + + # Use a UUID that sorts after all real customer_ids + ENV["DRY_RUN"] = "false" + ENV["CURSOR"] = "ffffffff-ffff-ffff-ffff-ffffffffffff" + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(wallet.reload.traceable).to eq(false) + ensure + ENV.delete("DRY_RUN") + ENV.delete("CURSOR") + end + + it "displays current cursor in header" do + create_non_traceable_wallet + + ENV["ORGANIZATION_ID"] = organization.id + ENV["CURSOR"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" + task.reenable + + expect { task.invoke }.to output(a_string_including("Cursor: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + ENV.delete("CURSOR") + end + + it "defaults cursor to the first customer_id when not set" do + create_non_traceable_wallet + + ENV["ORGANIZATION_ID"] = organization.id + task.reenable + + expect { task.invoke }.to output(a_string_including("Cursor: #{customer.id}")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + end + + it "does not print next cursor line when limit is not set" do + create_non_traceable_wallet + + ENV["ORGANIZATION_ID"] = organization.id + task.reenable + + expect { task.invoke }.not_to output(a_string_including("Next cursor:")).to_stdout + ensure + ENV.delete("ORGANIZATION_ID") + end + + it "does not print next cursor line when only cursor is set without limit" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "50") + + ENV["DRY_RUN"] = "false" + ENV["CURSOR"] = "00000000-0000-0000-0000-000000000000" + task.reenable + + expect { task.invoke }.not_to output(a_string_including("Next cursor:")).to_stdout + ensure + ENV.delete("DRY_RUN") + ENV.delete("CURSOR") + end + + it "defaults to dry-run even when dry_run is set to any value other than 'false'" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + ENV["DRY_RUN"] = "true" + ENV["ORGANIZATION_ID"] = organization.id + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + expect(wallet.reload.traceable).to eq(false) + ensure + ENV.delete("DRY_RUN") + ENV.delete("ORGANIZATION_ID") + end + end + + describe "Dry-run mode" do + describe "migratable wallet" do + it "validates a wallet with consistent balance without modifying data" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 40) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + expect { + run_migration + }.not_to change { + [ + WalletTransactionConsumption.count, + wallet.reload.traceable, + wallet.wallet_transactions.inbound.first.remaining_amount_cents + ] + } + end + end + + describe "CSV export on problematic wallets" do + it "exports problematic wallets to CSV when output_file is set" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + # Manually corrupt the balance to create drift + Wallet.where(id: wallet.id).update_all(balance_cents: 5000, credits_balance: 50) # rubocop:disable Rails/SkipsModelValidations + + Tempfile.create(["problematic_wallets", ".csv"]) do |tmpfile| + env_vars = { + "ORGANIZATION_ID" => organization.id, + "BATCH_SIZE" => "100", + "ERROR_DISPLAY_LIMIT" => "50", + "ERROR_LOG_FILE" => tmpfile.path + } + env_vars.each { |k, v| ENV[k] = v } + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + rows = CSV.read(tmpfile.path, headers: true) + expect(rows.headers).to eq(%w[wallet_id customer_id customer_name organization_id organization_name created_at issues]) + expect(rows.size).to eq(1) + row = rows.first + expect(row["wallet_id"]).to eq(wallet.id) + expect(row["customer_id"]).to eq(customer.id) + expect(row["customer_name"]).to eq(customer.name) + expect(row["organization_id"]).to eq(organization.id) + expect(row["organization_name"]).to eq(organization.name) + expect(row["created_at"]).to eq(wallet.created_at.to_date.to_s) + expect(row["issues"]).to include("Balance drift") + ensure + env_vars&.each_key { |k| ENV.delete(k) } + end + end + end + + describe "wallet with balance drift" do + it "detects balance drift >= 1 unit and reports it as problematic" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + # Manually corrupt the balance to create drift + Wallet.where(id: wallet.id).update_all(balance_cents: 5000, credits_balance: 50) # rubocop:disable Rails/SkipsModelValidations + + expect { + run_migration(silent: false) + }.to output( + a_string_including("Problematic: 1").and(a_string_including("Balance drift >= 1 unit")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + end + + it "detects balance drift < 1 unit as likely rounding" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + # Small drift (50 cents) — below 100 cent threshold + Wallet.where(id: wallet.id).update_all(balance_cents: 10050, credits_balance: 100.50) # rubocop:disable Rails/SkipsModelValidations + + expect { + run_migration(silent: false) + }.to output( + a_string_including("Problematic: 1").and(a_string_including("Balance drift < 1 unit")) + .and(a_string_including("likely rounding")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + end + end + + describe "negative wallet balance" do + it "detects negative wallet balance as problematic" do + wallet = create_non_traceable_wallet + + Wallet.where(id: wallet.id).update_all(balance_cents: -500, credits_balance: -5) # rubocop:disable Rails/SkipsModelValidations + + expect { + run_migration(silent: false) + }.to output( + a_string_including("Problematic: 1").and(a_string_including("Negative wallet balance")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + end + end + + describe "missing inbound transactions" do + it "detects outbound without any inbound as problematic" do + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00") + create(:wallet_transaction, wallet:, organization:, + transaction_type: :outbound, status: :settled, amount: "10.00", credit_amount: "10.00", + transaction_status: :invoiced) + + expect { + run_migration(silent: false) + }.to output( + a_string_including("Problematic: 1").and(a_string_including("missing transaction history")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + end + end + + describe "negative transaction amount" do + it "detects negative amount_cents on inbound transaction" do + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00", + balance_cents: 0, credits_balance: 0) + create(:wallet_transaction, wallet:, organization:, + transaction_type: :inbound, status: :settled, amount: "-10.00", credit_amount: "-10.00", + transaction_status: :granted, remaining_amount_cents: nil) + + expect { + run_migration(silent: false) + }.to output( + a_string_including("Problematic: 1").and(a_string_including("Negative amount_cents on inbound")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + end + + it "detects negative amount_cents on outbound transaction" do + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00", + balance_cents: 0, credits_balance: 0) + create(:wallet_transaction, wallet:, organization:, + transaction_type: :outbound, status: :settled, amount: "-10.00", credit_amount: "-10.00", + transaction_status: :invoiced) + + expect { + run_migration(silent: false) + }.to output( + a_string_including("Problematic: 1").and(a_string_including("Negative amount_cents on outbound")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + end + end + end + + describe "Backfill mode" do + describe "simple consumption" do + # Customer tops up $100, then invoice consumes $40. + # After backfill: + # - One WalletTransactionConsumption: TX1 -> TX2 for $40 + # - TX1.remaining_amount_cents = 6000 + # - Wallet marked traceable + + it "creates consumption records and marks wallet traceable" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 40) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx1 = wallet.wallet_transactions.inbound.settled.first + tx2 = wallet.wallet_transactions.outbound.settled.first + expect(tx2).to be_present + + run_migration(dry_run: false) + + wallet.reload + expect(wallet.traceable).to eq(true) + + expect(tx1.reload.remaining_amount_cents).to eq(6000) + + consumptions = WalletTransactionConsumption.where( + inbound_wallet_transaction_id: tx1.id, + outbound_wallet_transaction_id: tx2.id + ) + expect(consumptions.count).to eq(1) + expect(consumptions.first.consumed_amount_cents).to eq(4000) + end + end + + describe "consumption spanning multiple inbounds (FIFO)" do + # Customer has two top-ups ($30 granted, $50 granted), then invoice consumes $60. + # After backfill: + # - TX1 -> TX3: $30 (TX1 fully consumed) + # - TX2 -> TX3: $30 (TX2 partially consumed) + + it "creates consumption records following FIFO order" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + tx2 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "30") + tx1 = wallet.wallet_transactions.inbound.first + end + + travel_to(time_0 + 1.hour) do + top_up_wallet(wallet, granted_credits: "50") + tx2 = wallet.wallet_transactions.inbound.order(created_at: :desc).first + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 60) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx3 = wallet.wallet_transactions.outbound.settled.first + + run_migration(dry_run: false) + + expect(wallet.reload.traceable).to eq(true) + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(2000) + + consumptions = WalletTransactionConsumption.where(outbound_wallet_transaction_id: tx3.id) + .order(:consumed_amount_cents) + expect(consumptions.count).to eq(2) + + tx1_consumption = consumptions.find_by(inbound_wallet_transaction_id: tx1.id) + tx2_consumption = consumptions.find_by(inbound_wallet_transaction_id: tx2.id) + + expect(tx1_consumption.consumed_amount_cents).to eq(3000) + expect(tx2_consumption.consumed_amount_cents).to eq(3000) + end + end + + describe "multiple outbounds from same inbound" do + # Customer tops up $100, then two billing periods consume $25 and $35. + # After backfill: + # - TX1 -> TX2: $25 + # - TX1 -> TX3: $35 + # - TX1.remaining_amount_cents = 4000 + + it "creates separate consumption records for each outbound" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + tx1 = wallet.wallet_transactions.inbound.first + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 25) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx2 = wallet.wallet_transactions.outbound.settled.first + + travel_to(time_0 + 1.month + 5.days) do + ingest_usage(subscription, 35) + end + + travel_to(time_0 + 2.months) do + perform_billing + end + + tx3 = wallet.wallet_transactions.outbound.settled.order(created_at: :desc).first + + run_migration(dry_run: false) + + expect(wallet.reload.traceable).to eq(true) + expect(tx1.reload.remaining_amount_cents).to eq(4000) + + consumptions = WalletTransactionConsumption.where(inbound_wallet_transaction_id: tx1.id) + expect(consumptions.count).to eq(2) + + tx2_consumption = consumptions.find_by(outbound_wallet_transaction_id: tx2.id) + tx3_consumption = consumptions.find_by(outbound_wallet_transaction_id: tx3.id) + + expect(tx2_consumption.consumed_amount_cents).to eq(2500) + expect(tx3_consumption.consumed_amount_cents).to eq(3500) + end + end + + describe "priority-based consumption" do + # Customer has: $20 granted (priority 1), $25 granted (priority 2 older), + # $25 granted (priority 2 newer), $30 granted (priority 2 newest). + # Invoice consumes $80. Consumption order: + # TX1 (prio 1) -> TX2 (prio 2, oldest) -> TX3 (prio 2, newer) -> TX4 (prio 2, newest) + + it "consumes in order: granted before purchased, priority first, then FIFO" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + tx2 = nil + tx3 = nil + tx4 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + + transactions1 = top_up_wallet(wallet, granted_credits: "20") + tx1 = transactions1.find(&:inbound?) + tx1.update!(priority: 1) + + transactions2 = top_up_wallet(wallet, granted_credits: "25") + tx2 = transactions2.find(&:inbound?) + tx2.update!(priority: 2, created_at: 3.days.ago) + + transactions3 = top_up_wallet(wallet, granted_credits: "25") + tx3 = transactions3.find(&:inbound?) + tx3.update!(priority: 2, created_at: 1.day.ago) + + transactions4 = top_up_wallet(wallet, granted_credits: "30") + tx4 = transactions4.find(&:inbound?) + tx4.update!(priority: 2) + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 80) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx5 = wallet.wallet_transactions.outbound.settled.first + + run_migration(dry_run: false) + + expect(wallet.reload.traceable).to eq(true) + + consumptions = WalletTransactionConsumption.where(outbound_wallet_transaction_id: tx5.id) + expect(consumptions.count).to eq(4) + + expect(consumptions.find_by(inbound_wallet_transaction_id: tx1.id).consumed_amount_cents).to eq(2000) + expect(consumptions.find_by(inbound_wallet_transaction_id: tx2.id).consumed_amount_cents).to eq(2500) + expect(consumptions.find_by(inbound_wallet_transaction_id: tx3.id).consumed_amount_cents).to eq(2500) + expect(consumptions.find_by(inbound_wallet_transaction_id: tx4.id).consumed_amount_cents).to eq(1000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(0) + expect(tx3.reload.remaining_amount_cents).to eq(0) + expect(tx4.reload.remaining_amount_cents).to eq(2000) + end + end + + describe "granted before purchased ordering" do + # Customer has $30 granted and $70 purchased. Invoice consumes $80. + # Granted is consumed first, then purchased. + + it "consumes granted transactions before purchased" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + tx2 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "30") + tx1 = wallet.wallet_transactions.inbound.where(transaction_status: :granted).first + end + + travel_to(time_0 + 1.hour) do + top_up_wallet(wallet, paid_credits: "70") + tx2 = wallet.wallet_transactions.inbound.where(transaction_status: :purchased).first + + # Mark the credit invoice as paid so the purchased transaction becomes settled + credit_invoice = customer.invoices.credit.sole + update_invoice(credit_invoice, {payment_status: "succeeded"}) + perform_all_enqueued_jobs + + tx2.reload + expect(tx2.status).to eq("settled") + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 80) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx3 = wallet.wallet_transactions.outbound.settled.where(transaction_status: :invoiced).first + + run_migration(dry_run: false) + + expect(wallet.reload.traceable).to eq(true) + + consumptions = WalletTransactionConsumption.where(outbound_wallet_transaction_id: tx3.id) + expect(consumptions.count).to eq(2) + + tx1_consumption = consumptions.find_by(inbound_wallet_transaction_id: tx1.id) + tx2_consumption = consumptions.find_by(inbound_wallet_transaction_id: tx2.id) + + expect(tx1_consumption.consumed_amount_cents).to eq(3000) + expect(tx2_consumption.consumed_amount_cents).to eq(5000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(2000) + end + end + + describe "idempotency" do + it "does not create duplicate records when run twice" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 40) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + run_migration(dry_run: false) + + expect(wallet.reload.traceable).to eq(true) + consumption_count = WalletTransactionConsumption.count + remaining = wallet.wallet_transactions.inbound.first.reload.remaining_amount_cents + + # Run again - should not change anything since wallet is now traceable + run_migration(dry_run: false) + + expect(WalletTransactionConsumption.count).to eq(consumption_count) + expect(wallet.wallet_transactions.inbound.first.reload.remaining_amount_cents).to eq(remaining) + end + end + + describe "skips already traceable wallets" do + it "does not process wallets that are already traceable" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + wallet.update_column(:traceable, true) # rubocop:disable Rails/SkipsModelValidations + + expect { + run_migration(dry_run: false) + }.not_to change(WalletTransactionConsumption, :count) + end + end + + describe "skips terminated wallets by default" do + it "does not process terminated wallets unless include_terminated is set" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + wallet.reload.update!(status: :terminated) + + expect { + run_migration(dry_run: false) + }.not_to change(WalletTransactionConsumption, :count) + + expect(wallet.reload.traceable).to eq(false) + + run_migration(dry_run: false, include_terminated: true) + + expect(wallet.reload.traceable).to eq(true) + end + end + + describe "wallet with no outbound transactions" do + it "marks wallet as traceable and sets remaining_amount_cents" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + tx1 = wallet.wallet_transactions.inbound.first + + run_migration(dry_run: false) + + expect(wallet.reload.traceable).to eq(true) + expect(tx1.reload.remaining_amount_cents).to eq(10000) + end + end + + describe "multiple customers processed independently" do + let(:customer2) { create(:customer, organization:, billing_entity:) } + + it "processes each customer in separate transactions" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + wallet2 = nil + subscription1 = nil + subscription2 = nil + + travel_to(time_0) do + # Customer 1 wallet + wallet1 = create_non_traceable_wallet + top_up_wallet(wallet1, granted_credits: "50") + subscription1 = setup_subscription + end + + travel_to(time_0) do + # Customer 2 wallet + params = { + external_customer_id: customer2.external_id, + rate_amount: "1", + name: "Customer 2 Wallet", + currency: "EUR", + granted_credits: "0", + invoice_requires_successful_payment: false + } + wallet2 = create_wallet(params, as: :model) + top_up_wallet(wallet2, granted_credits: "80") + + create_subscription({ + external_customer_id: customer2.external_id, + external_id: customer2.external_id, + plan_code: plan.code + }) + subscription2 = customer2.subscriptions.first + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription1, 20) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription2.external_id, + properties: {billable_metric.field_name => 30} + }) + perform_usage_update + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + run_migration(dry_run: false) + + expect(wallet1.reload.traceable).to eq(true) + expect(wallet2.reload.traceable).to eq(true) + + tx1 = wallet1.wallet_transactions.inbound.first + tx2 = wallet2.wallet_transactions.inbound.first + + expect(tx1.reload.remaining_amount_cents).to eq(3000) + expect(tx2.reload.remaining_amount_cents).to eq(5000) + end + end + + describe "multiple wallets for the same customer" do + let(:billable_metric2) { create(:billable_metric, organization:, field_name: "total", aggregation_type: "sum_agg") } + let(:charge2) { create(:charge, plan:, billable_metric: billable_metric2, charge_model: "standard", properties: {"amount" => "1"}) } + + before { charge2 } + + def create_scoped_wallet(applies_to:, granted_credits: "0") + params = { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Scoped Wallet", + currency: "EUR", + granted_credits:, + invoice_requires_successful_payment: false, + applies_to: + } + + wallet = create_wallet(params, as: :model) + wallet.update!(traceable: false) + wallet + end + + # Customer has two wallets scoped to different metrics: + # - Wallet 1: $30 (applies to billable_metric) + # - Wallet 2: $50 (applies to billable_metric2) + # Invoice consumes $25 from each metric. + # After backfill: + # - Wallet 1: TX1 -> TX3: $25, remaining $5 + # - Wallet 2: TX2 -> TX4: $25, remaining $25 + # Both wallets marked traceable. + + it "backfills consumption records for each wallet independently" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + wallet2 = nil + tx1 = nil + tx2 = nil + subscription = nil + + travel_to(time_0) do + wallet1 = create_scoped_wallet( + applies_to: {billable_metric_codes: [billable_metric.code]}, + granted_credits: "30" + ) + tx1 = wallet1.wallet_transactions.inbound.first + end + + travel_to(time_0 + 1.hour) do + wallet2 = create_scoped_wallet( + applies_to: {billable_metric_codes: [billable_metric2.code]}, + granted_credits: "50" + ) + tx2 = wallet2.wallet_transactions.inbound.first + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 25) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric2.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric2.field_name => 25} + }) + perform_usage_update + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + run_migration(dry_run: false) + + expect(wallet1.reload.traceable).to eq(true) + expect(wallet2.reload.traceable).to eq(true) + + tx3 = wallet1.wallet_transactions.outbound.settled.first + tx4 = wallet2.wallet_transactions.outbound.settled.first + + consumption1 = WalletTransactionConsumption.find_by( + inbound_wallet_transaction_id: tx1.id, + outbound_wallet_transaction_id: tx3.id + ) + expect(consumption1.consumed_amount_cents).to eq(2500) + + consumption2 = WalletTransactionConsumption.find_by( + inbound_wallet_transaction_id: tx2.id, + outbound_wallet_transaction_id: tx4.id + ) + expect(consumption2.consumed_amount_cents).to eq(2500) + + expect(tx1.reload.remaining_amount_cents).to eq(500) + expect(tx2.reload.remaining_amount_cents).to eq(2500) + end + + it "only migrates non-traceable wallets, leaving already-traceable ones untouched" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + wallet2 = nil + subscription = nil + + travel_to(time_0) do + wallet1 = create_scoped_wallet( + applies_to: {billable_metric_codes: [billable_metric.code]}, + granted_credits: "30" + ) + # Mark wallet1 as already traceable (simulating it was already migrated) + wallet1.update_column(:traceable, true) # rubocop:disable Rails/SkipsModelValidations + wallet1.wallet_transactions.inbound.each do |tx| + tx.update_column(:remaining_amount_cents, tx.amount_cents) # rubocop:disable Rails/SkipsModelValidations + end + end + + travel_to(time_0 + 1.hour) do + wallet2 = create_scoped_wallet( + applies_to: {billable_metric_codes: [billable_metric2.code]}, + granted_credits: "50" + ) + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 25) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric2.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric2.field_name => 25} + }) + perform_usage_update + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + # wallet1 is traceable, so billing already created consumption records for it + tx3 = wallet1.wallet_transactions.outbound.settled.first + consumption_count_before = WalletTransactionConsumption.where(outbound_wallet_transaction_id: tx3.id).count + expect(consumption_count_before).to eq(1) + + run_migration(dry_run: false) + + # wallet1 was already traceable — migration did not create additional consumption records + expect(WalletTransactionConsumption.where(outbound_wallet_transaction_id: tx3.id).count).to eq(consumption_count_before) + + # wallet2 was non-traceable — should now be migrated + expect(wallet2.reload.traceable).to eq(true) + tx4 = wallet2.wallet_transactions.outbound.settled.first + consumption = WalletTransactionConsumption.find_by(outbound_wallet_transaction_id: tx4.id) + expect(consumption.consumed_amount_cents).to eq(2500) + end + + # Customer has three wallets: + # - Wallet 1: active, non-traceable, $30 (applies to billable_metric) — should be migrated + # - Wallet 2: terminated, non-traceable, $50 (applies to billable_metric2) — should be migrated (include_terminated) + # - Wallet 3: active, already traceable, $20 (applies to billable_metric) — should be skipped + + it "migrates active and terminated non-traceable wallets, skips already-traceable ones" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + wallet2 = nil + tx1 = nil + tx2 = nil + subscription = nil + + travel_to(time_0) do + # Wallet 1: active, non-traceable + wallet1 = create_scoped_wallet( + applies_to: {billable_metric_codes: [billable_metric.code]}, + granted_credits: "30" + ) + tx1 = wallet1.wallet_transactions.inbound.first + end + + travel_to(time_0 + 1.hour) do + # Wallet 2: will be terminated, non-traceable + wallet2 = create_scoped_wallet( + applies_to: {billable_metric_codes: [billable_metric2.code]}, + granted_credits: "50" + ) + tx2 = wallet2.wallet_transactions.inbound.first + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 20) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric2.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric2.field_name => 15} + }) + perform_usage_update + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + # Terminate wallet2 after billing + wallet2.reload.update!(status: :terminated) + + # Wallet 3: active, already traceable — created after billing so no outbound + wallet3 = create_scoped_wallet( + applies_to: {billable_metric_codes: [billable_metric.code]}, + granted_credits: "20" + ) + wallet3.update_column(:traceable, true) # rubocop:disable Rails/SkipsModelValidations + wallet3.wallet_transactions.inbound.each do |tx| + tx.update_column(:remaining_amount_cents, tx.amount_cents) # rubocop:disable Rails/SkipsModelValidations + end + + WalletTransactionConsumption.count + + run_migration(dry_run: false, include_terminated: true) + + # Wallet 1: active, non-traceable -> migrated + expect(wallet1.reload.traceable).to eq(true) + expect(tx1.reload.remaining_amount_cents).to eq(1000) + tx3 = wallet1.wallet_transactions.outbound.settled.first + expect(WalletTransactionConsumption.find_by( + inbound_wallet_transaction_id: tx1.id, + outbound_wallet_transaction_id: tx3.id + ).consumed_amount_cents).to eq(2000) + + # Wallet 2: terminated, non-traceable -> migrated + expect(wallet2.reload.traceable).to eq(true) + expect(wallet2.status).to eq("terminated") + expect(tx2.reload.remaining_amount_cents).to eq(3500) + tx4 = wallet2.wallet_transactions.outbound.settled.first + expect(WalletTransactionConsumption.find_by( + inbound_wallet_transaction_id: tx2.id, + outbound_wallet_transaction_id: tx4.id + ).consumed_amount_cents).to eq(1500) + + # Wallet 3: already traceable -> no new consumption records + wallet3_consumptions = WalletTransactionConsumption.where( + inbound_wallet_transaction_id: wallet3.wallet_transactions.inbound.pluck(:id) + ) + expect(wallet3_consumptions.count).to eq(0) + end + end + + describe "customer rollback when one wallet fails" do + let(:billable_metric2) { create(:billable_metric, organization:, field_name: "total", aggregation_type: "sum_agg") } + let(:charge2) { create(:charge, plan:, billable_metric:, charge_model: "standard", properties: {"amount" => "1"}) } + + before { charge2 } + + # Customer has two wallets: + # - Wallet 1: migratable ($50 inbound, $20 outbound) + # - Wallet 2: NOT migratable (inbound amount corrupted so outbound can't be consumed) + # + # Since all wallets for a customer are processed in a single transaction, + # the failure on wallet2 should roll back wallet1's changes too. + + it "rolls back all wallets for the customer when one wallet fails" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + subscription = nil + + travel_to(time_0) do + wallet1 = create_non_traceable_wallet + top_up_wallet(wallet1, granted_credits: "50") + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 20) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + # Create wallet2 with an inconsistent state: outbound > inbound + wallet2 = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00") + inbound = create(:wallet_transaction, wallet: wallet2, organization:, + transaction_type: :inbound, status: :settled, amount: "10.00", credit_amount: "10.00", + transaction_status: :granted, remaining_amount_cents: nil) + create(:wallet_transaction, wallet: wallet2, organization:, + transaction_type: :outbound, status: :settled, amount: "50.00", credit_amount: "50.00", + transaction_status: :invoiced, created_at: inbound.created_at + 1.hour) + + run_migration(dry_run: false) + + # Neither wallet should be marked traceable + expect(wallet1.reload.traceable).to eq(false) + expect(wallet2.reload.traceable).to eq(false) + + # No consumption records created for either wallet + wallet1_consumptions = WalletTransactionConsumption.where( + inbound_wallet_transaction_id: wallet1.wallet_transactions.inbound.pluck(:id) + ) + expect(wallet1_consumptions.count).to eq(0) + + wallet2_consumptions = WalletTransactionConsumption.where( + inbound_wallet_transaction_id: wallet2.wallet_transactions.inbound.pluck(:id) + ) + expect(wallet2_consumptions.count).to eq(0) + end + end + + describe "backfill error reporting" do + it "reports errors in the summary output" do + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00") + inbound = create(:wallet_transaction, wallet:, organization:, + transaction_type: :inbound, status: :settled, amount: "10.00", credit_amount: "10.00", + transaction_status: :granted, remaining_amount_cents: nil) + create(:wallet_transaction, wallet:, organization:, + transaction_type: :outbound, status: :settled, amount: "50.00", credit_amount: "50.00", + transaction_status: :invoiced, created_at: inbound.created_at + 1.hour) + + expect { + run_migration(dry_run: false, silent: false) + }.to output( + a_string_including("Errors: 1") + .and(a_string_including("insufficient inbound to consume")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + expect(inbound.reload.remaining_amount_cents).to be_nil + expect(WalletTransactionConsumption.where(inbound_wallet_transaction_id: inbound.id).count).to eq(0) + end + + it "rejects wallet with negative balance" do + wallet = create_non_traceable_wallet + Wallet.where(id: wallet.id).update_all(balance_cents: -500, credits_balance: -5) # rubocop:disable Rails/SkipsModelValidations + + expect { + run_migration(dry_run: false, silent: false) + }.to output( + a_string_including("Errors: 1") + .and(a_string_including("Negative wallet balance")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + end + + it "rejects wallet with negative amount_cents on inbound transaction" do + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00", + balance_cents: 0, credits_balance: 0) + inbound = create(:wallet_transaction, wallet:, organization:, + transaction_type: :inbound, status: :settled, amount: "-10.00", credit_amount: "-10.00", + transaction_status: :granted, remaining_amount_cents: nil) + + expect { + run_migration(dry_run: false, silent: false) + }.to output( + a_string_including("Errors: 1") + .and(a_string_including("Negative amount_cents on inbound")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + expect(inbound.reload.remaining_amount_cents).to be_nil + end + + it "rejects wallet with negative amount_cents on outbound transaction" do + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00", + balance_cents: 0, credits_balance: 0) + outbound = create(:wallet_transaction, wallet:, organization:, + transaction_type: :outbound, status: :settled, amount: "-10.00", credit_amount: "-10.00", + transaction_status: :invoiced) + + expect { + run_migration(dry_run: false, silent: false) + }.to output( + a_string_including("Errors: 1") + .and(a_string_including("Negative amount_cents on outbound")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + expect(WalletTransactionConsumption.where(outbound_wallet_transaction_id: outbound.id).count).to eq(0) + end + + it "rejects wallet with outbound but no inbound transactions" do + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00") + outbound = create(:wallet_transaction, wallet:, organization:, + transaction_type: :outbound, status: :settled, amount: "10.00", credit_amount: "10.00", + transaction_status: :invoiced) + + expect { + run_migration(dry_run: false, silent: false) + }.to output( + a_string_including("Errors: 1") + .and(a_string_including("missing transaction history")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + expect(WalletTransactionConsumption.where(outbound_wallet_transaction_id: outbound.id).count).to eq(0) + end + + it "rejects wallet with balance drift" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + inbound = wallet.wallet_transactions.inbound.first + Wallet.where(id: wallet.id).update_all(balance_cents: 5000, credits_balance: 50) # rubocop:disable Rails/SkipsModelValidations + + expect { + run_migration(dry_run: false, silent: false) + }.to output( + a_string_including("Errors: 1") + .and(a_string_including("Balance drift")) + ).to_stdout + expect(wallet.reload.traceable).to eq(false) + expect(inbound.reload.remaining_amount_cents).to be_nil + expect(WalletTransactionConsumption.where(inbound_wallet_transaction_id: inbound.id).count).to eq(0) + end + + it "continues processing other customers when one customer errors" do + other_customer = create(:customer, organization:, billing_entity:) + + # Healthy wallet for other_customer + healthy_wallet_params = { + external_customer_id: other_customer.external_id, + rate_amount: "1", + name: "Healthy Wallet", + currency: "EUR", + granted_credits: "0", + invoice_requires_successful_payment: false + } + api_call { post_with_token(organization, "/api/v1/wallets", {wallet: healthy_wallet_params}) } + healthy_wallet = Wallet.find(json[:wallet][:lago_id]) + api_call do + post_with_token(organization, "/api/v1/wallet_transactions", { + wallet_transaction: {wallet_id: healthy_wallet.id, granted_credits: "30"} + }) + end + + # Broken wallet for customer (outbound > inbound) + broken_wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00") + inbound = create(:wallet_transaction, wallet: broken_wallet, organization:, + transaction_type: :inbound, status: :settled, amount: "10.00", credit_amount: "10.00", + transaction_status: :granted) + create(:wallet_transaction, wallet: broken_wallet, organization:, + transaction_type: :outbound, status: :settled, amount: "50.00", credit_amount: "50.00", + transaction_status: :invoiced, created_at: inbound.created_at + 1.hour) + + run_migration(dry_run: false) + + expect(healthy_wallet.reload.traceable).to eq(true) + expect(broken_wallet.reload.traceable).to eq(false) + end + end + + describe "CSV export on backfill errors" do + it "exports errors to CSV when output_file is set" do + # Create wallet with inconsistent state: outbound > inbound + wallet = create(:wallet, customer:, organization:, traceable: false, currency: "EUR", rate_amount: "1.00") + inbound = create(:wallet_transaction, wallet:, organization:, + transaction_type: :inbound, status: :settled, amount: "10.00", credit_amount: "10.00", + transaction_status: :granted, remaining_amount_cents: nil) + create(:wallet_transaction, wallet:, organization:, + transaction_type: :outbound, status: :settled, amount: "50.00", credit_amount: "50.00", + transaction_status: :invoiced, created_at: inbound.created_at + 1.hour) + + Tempfile.create(["backfill_errors", ".csv"]) do |tmpfile| + env_vars = { + "ORGANIZATION_ID" => organization.id, + "BATCH_SIZE" => "100", + "ERROR_DISPLAY_LIMIT" => "50", + "DRY_RUN" => "false", + "ERROR_LOG_FILE" => tmpfile.path + } + env_vars.each { |k, v| ENV[k] = v } + task.reenable + expect { task.invoke }.to output(anything).to_stdout + + rows = CSV.read(tmpfile.path, headers: true) + expect(rows.headers).to eq(%w[wallet_id customer_id customer_name organization_id organization_name created_at issues]) + expect(rows.size).to eq(1) + row = rows.first + expect(row["wallet_id"]).to eq(wallet.id) + expect(row["customer_id"]).to eq(customer.id) + expect(row["customer_name"]).to eq(customer.name) + expect(row["organization_id"]).to eq(organization.id) + expect(row["organization_name"]).to eq(organization.name) + expect(row["created_at"]).to eq(wallet.created_at.to_date.to_s) + expect(row["issues"]).to include("insufficient inbound to consume") + ensure + env_vars&.each_key { |k| ENV.delete(k) } + end + end + end + + describe "non-integer wallet rate" do + it "correctly tracks consumption with non-integer rate_amount" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet(rate_amount: "0.5") + top_up_wallet(wallet, granted_credits: "100") + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + # With rate 0.5, 100 credits = 50 EUR. Usage of 30 EUR = 60 credits consumed. + ingest_usage(subscription, 30) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx1 = wallet.wallet_transactions.inbound.settled.first + tx2 = wallet.wallet_transactions.outbound.settled.first + + run_migration(dry_run: false) + + expect(wallet.reload.traceable).to eq(true) + + consumption = WalletTransactionConsumption.find_by( + inbound_wallet_transaction_id: tx1.id, + outbound_wallet_transaction_id: tx2.id + ) + expect(consumption).to be_present + expect(consumption.consumed_amount_cents).to eq(3000) + expect(tx1.reload.remaining_amount_cents).to eq(2000) + end + end + end + + describe "invalid CURSOR" do + it "raises an error for non-UUID values" do + ENV["CURSOR"] = "not-a-uuid" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /Invalid CURSOR format: not-a-uuid/) + ensure + ENV.delete("CURSOR") + end + + it "raises an error for pipe-separated values" do + ENV["CURSOR"] = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa|bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /Invalid CURSOR format/) + ensure + ENV.delete("CURSOR") + end + end + + describe "invalid ERROR_LOG_FILE" do + it "raises an error at startup when the file path is not writable" do + ENV["ERROR_LOG_FILE"] = "/nonexistent/directory/errors.csv" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /Cannot write to error log file/) + ensure + ENV.delete("ERROR_LOG_FILE") + end + end + + describe "invalid numeric ENV vars" do + %w[LIMIT BATCH_SIZE ERROR_DISPLAY_LIMIT].each do |var| + it "raises an error when #{var} is not numeric" do + ENV[var] = "abc" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /#{var} must be a positive integer, got: abc/) + ensure + ENV.delete(var) + end + + it "raises an error when #{var} is zero" do + ENV[var] = "0" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /#{var} must be a positive integer, got: 0/) + ensure + ENV.delete(var) + end + + it "raises an error when #{var} is negative" do + ENV[var] = "-1" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /#{var} must be a positive integer, got: -1/) + ensure + ENV.delete(var) + end + end + + it "raises an error when THREAD_COUNT is not numeric" do + ENV["THREAD_COUNT"] = "abc" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /THREAD_COUNT must be a non-negative integer, got: abc/) + ensure + ENV.delete("THREAD_COUNT") + end + + it "raises an error when THREAD_COUNT is negative" do + ENV["THREAD_COUNT"] = "-1" + task.reenable + + expect { task.invoke }.to raise_error(RuntimeError, /THREAD_COUNT must be a non-negative integer, got: -1/) + ensure + ENV.delete("THREAD_COUNT") + end + end +end diff --git a/spec/lib/tasks/recipes/events_rake_spec.rb b/spec/lib/tasks/recipes/events_rake_spec.rb new file mode 100644 index 0000000..5298211 --- /dev/null +++ b/spec/lib/tasks/recipes/events_rake_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "rails_helper" + +require "rake" + +RSpec.describe "recipes:events:delete_in_range" do # rubocop:disable RSpec/DescribeClass + let(:task) { Rake::Task["recipes:events:delete_in_range"] } + + let(:organization) { create(:organization) } + let(:from_timestamp) { "2026-04-09 19:00:00" } + let(:to_timestamp) { "2026-04-27 23:59:59" } + + before do + Rake.application.rake_require("tasks/recipes/events") + Rake::Task.define_task(:environment) + task.reenable + end + + def stub_stdin(*responses) + allow($stdin).to receive(:gets).and_return(*responses.map { |r| "#{r}\n" }) + end + + context "when organization is not found" do + before { stub_stdin("00000000") } + + it "aborts" do + expect { task.invoke }.to raise_error(SystemExit) + end + end + + context "when user does not confirm the organization" do + before { stub_stdin(organization.id, "n") } + + it "aborts" do + expect { task.invoke }.to raise_error(SystemExit) + end + end + + context "when organization uses clickhouse events store" do + let(:organization) { create(:organization, clickhouse_events_store: true) } + + before { stub_stdin(organization.id, "y") } + + it "aborts" do + expect { task.invoke }.to raise_error(SystemExit) + end + end + + context "when from_timestamp is after to_timestamp" do + before { stub_stdin(organization.id, "y", "2026-04-28 00:00:00", "2026-04-27 00:00:00") } + + it "aborts" do + expect { task.invoke }.to raise_error(SystemExit) + end + end + + context "when organization has no subscriptions" do + before { stub_stdin(organization.id, "y", from_timestamp, to_timestamp) } + + it "finishes without deleting anything" do + expect { task.invoke }.not_to raise_error + end + end + + context "when events exist in the time range" do + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, organization:) } + + let!(:event_in_range) do + create(:event, + organization_id: organization.id, + subscription:, + external_subscription_id: subscription.external_id, + timestamp: Time.zone.parse("2026-04-10 12:00:00")) + end + + let!(:event_out_of_range) do + create(:event, + organization_id: organization.id, + subscription:, + external_subscription_id: subscription.external_id, + timestamp: Time.zone.parse("2026-04-01 12:00:00")) + end + + context "when user confirms deletion" do + before { stub_stdin(organization.id, "y", from_timestamp, to_timestamp, "y") } + + it "soft-deletes only events within the time range" do + task.invoke + + expect(event_in_range.reload.deleted_at).to be_present + expect(event_out_of_range.reload.deleted_at).to be_nil + end + end + + context "when user declines deletion" do + before { stub_stdin(organization.id, "y", from_timestamp, to_timestamp, "n") } + + it "aborts without deleting events" do + expect { task.invoke }.to raise_error(SystemExit) + expect(event_in_range.reload.deleted_at).to be_nil + end + end + + context "when events span multiple subscriptions" do + let(:other_subscription) { create(:subscription, customer:, organization:) } + + let!(:event_other_sub) do + create(:event, + organization_id: organization.id, + subscription: other_subscription, + external_subscription_id: other_subscription.external_id, + timestamp: Time.zone.parse("2026-04-12 12:00:00")) + end + + before { stub_stdin(organization.id, "y", from_timestamp, to_timestamp, "y") } + + it "soft-deletes events across all subscriptions" do + task.invoke + + expect(event_in_range.reload.deleted_at).to be_present + expect(event_other_sub.reload.deleted_at).to be_present + expect(event_out_of_range.reload.deleted_at).to be_nil + end + end + end +end diff --git a/spec/mailers/api_key_mailer_spec.rb b/spec/mailers/api_key_mailer_spec.rb new file mode 100644 index 0000000..dec5b8b --- /dev/null +++ b/spec/mailers/api_key_mailer_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKeyMailer do + describe "#rotated" do + let(:mail) { described_class.with(api_key:).rotated } + let(:api_key) { create(:api_key) } + let(:organization) { api_key.organization } + + before { create(:membership, organization:, roles: [:admin]) } + + describe "subject" do + subject { mail.subject } + + it { is_expected.to eq "Your Lago API key has been rolled" } + end + + describe "recipients" do + subject { mail.bcc } + + before { create(:membership, organization:, roles: [:manager]) } + + specify do + expect(subject) + .to be_present + .and eq organization.admins.pluck(:email) + end + end + + describe "body" do + subject { mail.body.to_s } + + it "includes organization's name" do + expect(subject).to include CGI.escapeHTML(organization.name) + end + end + end + + describe "#created" do + let(:mail) { described_class.with(api_key:).created } + let(:api_key) { create(:api_key) } + let(:organization) { api_key.organization } + + before { create(:membership, organization:, roles: [:admin]) } + + describe "subject" do + subject { mail.subject } + + it { is_expected.to eq "A new Lago API key has been created" } + end + + describe "recipients" do + subject { mail.bcc } + + before { create(:membership, organization:, roles: [:manager]) } + + specify do + expect(subject) + .to be_present + .and eq organization.admins.pluck(:email) + end + end + + describe "body" do + subject { mail.body.to_s } + + it "includes organization's name" do + expect(subject).to include CGI.escapeHTML(organization.name) + end + end + end + + describe "#destroyed" do + let(:mail) { described_class.with(api_key:).destroyed } + let(:api_key) { create(:api_key) } + let(:organization) { api_key.organization } + + before { create(:membership, organization:, roles: [:admin]) } + + describe "subject" do + subject { mail.subject } + + it { is_expected.to eq "A Lago API key has been deleted" } + end + + describe "recipients" do + subject { mail.bcc } + + before { create(:membership, organization:, roles: [:manager]) } + + specify do + expect(subject) + .to be_present + .and eq organization.admins.pluck(:email) + end + end + + describe "body" do + subject { mail.body.to_s } + + it "includes organization's name" do + expect(subject).to include CGI.escapeHTML(organization.name) + end + end + end +end diff --git a/spec/mailers/credit_note_mailer_spec.rb b/spec/mailers/credit_note_mailer_spec.rb new file mode 100644 index 0000000..a401cd9 --- /dev/null +++ b/spec/mailers/credit_note_mailer_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNoteMailer do + subject(:credit_note_mailer) { described_class } + + let(:credit_note) { create(:credit_note) } + + before do + credit_note.file.attach(io: File.open(Rails.root.join("spec/fixtures/blank.pdf")), filename: "blank.pdf") + end + + describe "#created" do + specify do + mailer = credit_note_mailer.with(credit_note:).created + + expect(mailer.to).to eq([credit_note.customer.email]) + expect(mailer.reply_to).to eq([credit_note.billing_entity.email]) + expect(mailer.attachments).not_to be_empty + expect(mailer.attachments.first.filename).to eq("credit_note-#{credit_note.number}.pdf") + end + + context "when pdfs are disabled" do + before { ENV["LAGO_DISABLE_PDF_GENERATION"] = "true" } + + it "does not attach the pdf" do + mailer = credit_note_mailer.with(credit_note:).created + + expect(mailer.attachments).to be_empty + end + end + + context "with no pdf file" do + before do + credit_note.file = nil + + allow(CreditNotes::GeneratePdfService).to receive(:call) + end + + it "calls the credit note pdf generate service" do + mailer = credit_note_mailer.with(credit_note:).created + + expect(mailer.to).not_to be_nil + expect(CreditNotes::GeneratePdfService).to have_received(:call) + end + end + + context "when billing entity email is nil" do + before do + credit_note.billing_entity.update(email: nil) + end + + it "returns a mailer with nil values" do + mailer = credit_note_mailer.with(credit_note:).created + + expect(mailer.to).to be_nil + end + end + + context "when customer email is nil" do + before do + credit_note.customer.update(email: nil) + end + + it "returns a mailer with nil values" do + mailer = credit_note_mailer.with(credit_note:).created + + expect(mailer.to).to be_nil + end + end + + context "when customer email is an empty string" do + before do + credit_note.customer.update(email: "") + end + + it "returns a mailer with nil values" do + mailer = credit_note_mailer.with(credit_note:).created + + expect(mailer.to).to be_nil + end + end + end +end diff --git a/spec/mailers/data_export_mailer_spec.rb b/spec/mailers/data_export_mailer_spec.rb new file mode 100644 index 0000000..5c7451c --- /dev/null +++ b/spec/mailers/data_export_mailer_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExportMailer do + let(:data_export) { create(:data_export, :completed) } + + describe "#completed" do + let(:mail) { described_class.with(data_export:).completed } + let(:file_url) { "https://api.lago.dev/rails/active_storage/blobs/redirect/eyJf" } + + before { allow(data_export).to receive(:file_url).and_return(file_url) } + + describe "subject" do + subject { mail.subject } + + context "with invoice data export" do + let(:data_export) { create(:data_export, :completed, resource_type: "invoices") } + + it { is_expected.to eq "Your Lago invoices export is ready!" } + end + + context "with invoice fee data export" do + let(:data_export) { create(:data_export, :completed, resource_type: "invoice_fees") } + + it { is_expected.to eq "Your Lago invoice fees export is ready!" } + end + end + + describe "recipients" do + subject { mail.to } + + it { is_expected.to eq [data_export.user.email] } + end + + describe "body" do + subject { mail.body.to_s } + + it "includes expiration notice and link to file" do + expect(subject).to match("will be available for 7 days") + expect(subject).to match(data_export.file_url) + end + end + + describe "delivery" do + subject { mail.deliver_now } + + let(:deliveries) { ActionMailer::Base.deliveries } + + context "when data export is not completed" do + let(:data_export) { create(:data_export, :processing) } + + it "is not performed" do + expect { subject }.not_to change(deliveries, :count) + end + end + + context "when data export is completed" do + let(:data_export) { create(:data_export, :completed) } + + it "is performed" do + expect { subject }.to change(deliveries, :count).by(1) + end + end + end + end +end diff --git a/spec/mailers/invoice_mailer_spec.rb b/spec/mailers/invoice_mailer_spec.rb new file mode 100644 index 0000000..c65aea2 --- /dev/null +++ b/spec/mailers/invoice_mailer_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceMailer do + subject(:invoice_mailer) { described_class } + + let(:invoice) { create(:invoice, organization:, billing_entity:, fees_amount_cents: 100) } + let(:organization) { create(:organization) } + let(:billing_entity) do + create( + :billing_entity, + organization:, + name: "ACME Corp", + email: billing_entity_email + ) + end + let(:billing_entity_email) { "billing_entity@email.com" } + + before do + invoice.file.attach(io: File.open(Rails.root.join("spec/fixtures/blank.pdf")), filename: "blank.pdf") + end + + describe "#created" do + specify do + mailer = invoice_mailer.with(invoice:).created + + expect(mailer.subject).to eq("Your Invoice from ACME Corp ##{invoice.number}") + expect(mailer.to).to eq([invoice.customer.email]) + expect(mailer.from).to eq(["noreply@getlago.com"]) + expect(mailer.reply_to).to eq([billing_entity_email]) + expect(mailer.attachments).not_to be_empty + expect(mailer.attachments.first.filename).to eq("invoice-#{invoice.number}.pdf") + end + + context "when pdfs are disabled" do + before { ENV["LAGO_DISABLE_PDF_GENERATION"] = "true" } + + it "does not attach the pdf" do + mailer = invoice_mailer.with(invoice:).created + + expect(mailer.attachments).to be_empty + end + end + + context "with no pdf file" do + let(:pdf_service) { instance_double(Invoices::GeneratePdfService) } + + before do + invoice.file = nil + + allow(Invoices::GeneratePdfService).to receive(:new) + .and_return(pdf_service) + allow(pdf_service).to receive(:call) + end + + it "calls the invoice pdf generate service" do + mailer = invoice_mailer.with(invoice:).created + + expect(mailer.to).not_to be_nil + expect(Invoices::GeneratePdfService).to have_received(:new) + end + end + + context "when billing_entity email is nil" do + let(:billing_entity_email) { nil } + + it "returns a mailer with nil values" do + mailer = invoice_mailer.with(invoice:).created + + expect(mailer.to).to be_nil + end + end + + context "when customer email is nil" do + before do + invoice.customer.update(email: nil) + end + + it "returns a mailer with nil values" do + mailer = invoice_mailer.with(invoice:).created + + expect(mailer.to).to be_nil + end + end + + context "when customer email is an empty string" do + before do + invoice.customer.update(email: "") + end + + it "returns a mailer with nil values" do + mailer = invoice_mailer.with(invoice:).created + + expect(mailer.to).to be_nil + end + end + + context "when invoice fees amount is zero" do + before do + invoice.update(fees_amount_cents: 0) + end + + it "returns a mailer with nil values" do + mailer = invoice_mailer.with(invoice:).created + + expect(mailer.to).to be_nil + end + end + end +end diff --git a/spec/mailers/organization_mailer_spec.rb b/spec/mailers/organization_mailer_spec.rb new file mode 100644 index 0000000..80727e6 --- /dev/null +++ b/spec/mailers/organization_mailer_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe OrganizationMailer do + subject(:organization_mailer) do + described_class.with(organization:, user: admin0, additions:, deletions:).authentication_methods_updated + end + + let(:organization) { create(:organization) } + let(:additions) { ["okta"] } + let(:deletions) { ["google_oauth"] } + + let(:admin0) { create(:membership, organization:, roles: [:admin], user: create(:user)).user } + let(:admin1) { create(:membership, organization:, roles: [:admin], user: create(:user)).user } + let(:admin2) { create(:membership, organization:, roles: [:admin], user: create(:user)).user } + + before do + admin0 + admin1 + admin2 + end + + describe "#authentication_methods_updated" do + specify do + expect(subject.subject).to eq("Login method updated in your Lago workspace") + expect(subject.bcc).to contain_exactly(admin0.email, admin1.email, admin2.email) + expect(subject.from).to eq(["noreply@getlago.com"]) + expect(subject.reply_to).to eq(["noreply@getlago.com"]) + + email_body = subject.message.body + expect(email_body).to include(admin0.email) + expect(email_body).to include("Enabled Okta") + expect(email_body).to include("Disabled Google Oauth") + end + + context "without changes" do + let(:additions) { [] } + let(:deletions) { [] } + + it "returns a message with nil values" do + expect(subject.message).to be_a(ActionMailer::Base::NullMail) + end + end + end +end diff --git a/spec/mailers/password_reset_mailer_spec.rb b/spec/mailers/password_reset_mailer_spec.rb new file mode 100644 index 0000000..ecfa5d4 --- /dev/null +++ b/spec/mailers/password_reset_mailer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PasswordResetMailer do + subject(:password_reset_mailer) { described_class } + + let(:password_reset) { create(:password_reset) } + + describe "#requested" do + specify do + mailer = password_reset_mailer.with(password_reset:).requested + + expect(mailer.to).to eq([password_reset.user.email]) + end + + context "when user email is nil" do + before do + password_reset.user.update(email: nil) + end + + it "returns a mailer with nil values" do + mailer = password_reset_mailer.with(password_reset:).requested + + expect(mailer.to).to be_nil + end + end + end +end diff --git a/spec/mailers/payment_receipt_mailer_spec.rb b/spec/mailers/payment_receipt_mailer_spec.rb new file mode 100644 index 0000000..e602551 --- /dev/null +++ b/spec/mailers/payment_receipt_mailer_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceiptMailer do + subject(:payment_receipt_mailer) { described_class } + + let(:payment_receipt) { create(:payment_receipt) } + let(:invoice) { payment_receipt.payment.payable } + + before do + payment_receipt.file.attach(io: File.open(Rails.root.join("spec/fixtures/blank.pdf")), filename: "blank.pdf") + invoice.file.attach(io: File.open(Rails.root.join("spec/fixtures/blank.pdf")), filename: "blank.pdf") + allow(payment_receipt.payment.payable).to receive(:file_url).and_return("https://example.com/invoice.pdf") + end + + describe "#created" do + specify do + mailer = payment_receipt_mailer.with(payment_receipt:).created + + expect(mailer.to).to eq([payment_receipt.payment.payable.customer.email]) + expect(mailer.reply_to).to eq([payment_receipt.billing_entity.email]) + expect(mailer.attachments).not_to be_empty + expect(mailer.attachments.first.filename).to eq("receipt-#{payment_receipt.number}.pdf") + end + + context "when pdfs are disabled" do + around do |example| + ENV["LAGO_DISABLE_PDF_GENERATION"] = "true" + example.run + ENV.delete("LAGO_DISABLE_PDF_GENERATION") + end + + it "does not attach the pdf" do + mailer = payment_receipt_mailer.with(payment_receipt:).created + + expect(mailer.attachments).to be_empty + end + end + + context "when the payment receipt file is still missing after generation" do + let(:pdf_service) { instance_double(PaymentReceipts::GeneratePdfService, call: nil) } + + before do + payment_receipt.file.purge + allow(PaymentReceipts::GeneratePdfService).to receive(:new).and_return(pdf_service) + end + + it "raises FilesNotReadyError" do + expect { + payment_receipt_mailer.with(payment_receipt:).created.deliver_now + }.to raise_error(PaymentReceipts::FilesNotReadyError) + + expect(PaymentReceipts::GeneratePdfService).to have_received(:new) + end + end + + context "when an invoice file is missing" do + before { invoice.file.purge } + + it "raises FilesNotReadyError" do + expect { + payment_receipt_mailer.with(payment_receipt:).created.deliver_now + }.to raise_error(PaymentReceipts::FilesNotReadyError) + end + end + + context "when the payment is for a payment request covering multiple invoices" do + let(:other_invoice) { create(:invoice, organization: invoice.organization, customer: invoice.customer) } + let(:payment_request) do + create(:payment_request, organization: invoice.organization, customer: invoice.customer, invoices: [invoice, other_invoice]) + end + + before do + payment_receipt.payment.update!(payable: payment_request) + allow(other_invoice).to receive(:file_url).and_return("https://example.com/other.pdf") + end + + context "when every invoice has a file attached" do + before do + other_invoice.file.attach(io: File.open(Rails.root.join("spec/fixtures/blank.pdf")), filename: "blank.pdf") + end + + it "renders the email" do + mailer = payment_receipt_mailer.with(payment_receipt:).created + + expect(mailer.to).to eq([payment_request.customer.email]) + end + end + + context "when one invoice in the request is missing its file" do + it "raises FilesNotReadyError" do + expect { + payment_receipt_mailer.with(payment_receipt:).created.deliver_now + }.to raise_error(PaymentReceipts::FilesNotReadyError) + end + end + end + + context "when billing entity email is nil" do + before do + payment_receipt.billing_entity.update(email: nil) + end + + it "returns a mailer with nil values" do + mailer = payment_receipt_mailer.with(payment_receipt:).created + + expect(mailer.to).to be_nil + end + end + + context "when customer email is nil" do + before do + payment_receipt.payment.payable.customer.update(email: nil) + end + + it "returns a mailer with nil values" do + mailer = payment_receipt_mailer.with(payment_receipt:).created + + expect(mailer.to).to be_nil + end + end + + context "when customer email is an empty string" do + before do + payment_receipt.payment.payable.customer.update(email: "") + end + + it "returns a mailer with nil values" do + mailer = payment_receipt_mailer.with(payment_receipt:).created + + expect(mailer.to).to be_nil + end + end + end +end diff --git a/spec/mailers/payment_request_mailer_spec.rb b/spec/mailers/payment_request_mailer_spec.rb new file mode 100644 index 0000000..9c69052 --- /dev/null +++ b/spec/mailers/payment_request_mailer_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequestMailer do + subject(:mailer) { described_class.with(payment_request:).requested } + + let(:organization) { create(:organization, document_number_prefix: "ORG-123B") } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, organization:) } + let(:first_invoice) { create(:invoice, total_amount_cents: 1000, total_paid_amount_cents: 1, organization:, customer:) } + let(:second_invoice) { create(:invoice, total_amount_cents: 2000, total_paid_amount_cents: 2, organization:, customer:) } + let(:payment_request) { create(:payment_request, organization:, invoices: [first_invoice, second_invoice], customer:) } + + before do + first_invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + second_invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + end + + describe "#requested" do + let(:payment_url) { Faker::Internet.url } + let(:payment_url_result) do + BaseService::Result.new.tap do |result| + result.payment_url = payment_url + end + end + + before do + allow(::PaymentRequests::Payments::GeneratePaymentUrlService) + .to receive(:call) + .and_return(payment_url_result) + end + + specify do + expect(mailer.to).to eq([payment_request.email]) + expect(mailer.reply_to).to eq([payment_request.billing_entity.email]) + expect(mailer.bcc).to be_nil + expect(mailer.body.encoded).to include(CGI.escapeHTML(first_invoice.number)) + expect(mailer.body.encoded).to include(CGI.escapeHTML(second_invoice.number)) + expect(mailer.body.encoded).to include(CGI.escapeHTML(MoneyHelper.format(first_invoice.total_due_amount))) + expect(mailer.body.encoded).to include(CGI.escapeHTML(MoneyHelper.format(second_invoice.total_due_amount))) + end + + it "calls the generate payment url service" do + parsed_body = Nokogiri::HTML(mailer.body.encoded) + + expect(parsed_body.at_css("a#payment_link")["href"]).to eq(payment_url) + expect(mailer.body.encoded).to include("Pay balance") + expect(PaymentRequests::Payments::GeneratePaymentUrlService) + .to have_received(:call) + .with(payable: payment_request) + end + + context "when payment request has dunning campaign attached and there are 2 addresses in bcc_emails" do + let(:bcc_emails) { %w[bcc1@example.com bcc2@example.com] } + let(:dunning_campaign) { create(:dunning_campaign, organization:, bcc_emails:) } + + before do + payment_request.update!(dunning_campaign:) + end + + it "includes the BCC email addresses in the mailer" do + expect(mailer.bcc).to match_array(bcc_emails) + end + end + + context "when payment request email is nil" do + before { payment_request.update!(email: nil) } + + it "returns a mailer with nil values" do + expect(mailer.to).to be_nil + end + end + + context "when billing_entity email is nil" do + before { billing_entity.update!(email: nil) } + + it "returns a mailer with nil values" do + expect(mailer.to).to be_nil + end + end + + context "when no payment url is available" do + let(:payment_url_result) do + BaseService::Result.new.tap do |result| + result.single_validation_failure!(error_code: "invalid_payment_provider") + end + end + + it "does not include the payment link" do + parsed_body = Nokogiri::HTML(mailer.body.encoded) + + expect(parsed_body.css("a#payment_link")).not_to be_present + expect(mailer.body.encoded).not_to include("Pay balance") + end + end + end +end diff --git a/spec/mailers/previews/api_key_mailer_preview.rb b/spec/mailers/previews/api_key_mailer_preview.rb new file mode 100644 index 0000000..1f2e1e6 --- /dev/null +++ b/spec/mailers/previews/api_key_mailer_preview.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +class ApiKeyMailerPreview < BasePreviewMailer + def rotated + api_key = FactoryBot.create(:api_key) + ApiKeyMailer.with(api_key:).rotated + end + + def created + api_key = FactoryBot.create(:api_key) + ApiKeyMailer.with(api_key:).created + end + + def destroyed + api_key = FactoryBot.create(:api_key) + ApiKeyMailer.with(api_key:).destroyed + end +end diff --git a/spec/mailers/previews/base_preview_mailer.rb b/spec/mailers/previews/base_preview_mailer.rb new file mode 100644 index 0000000..9d5ab84 --- /dev/null +++ b/spec/mailers/previews/base_preview_mailer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class BasePreviewMailer < ActionMailer::Preview + def self.call(...) + message = nil + ActiveRecord::Base.transaction do + message = super(...) + raise ActiveRecord::Rollback + end + message + end +end diff --git a/spec/mailers/previews/data_export_mailer_preview.rb b/spec/mailers/previews/data_export_mailer_preview.rb new file mode 100644 index 0000000..9ecec43 --- /dev/null +++ b/spec/mailers/previews/data_export_mailer_preview.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class DataExportMailerPreview < BasePreviewMailer + def completed + data_export = FactoryBot.create :data_export, :completed + DataExportMailer.with(data_export:).completed + end +end diff --git a/spec/mailers/previews/organization_mailer_preview.rb b/spec/mailers/previews/organization_mailer_preview.rb new file mode 100644 index 0000000..5bd0609 --- /dev/null +++ b/spec/mailers/previews/organization_mailer_preview.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +class OrganizationMailerPreview < BasePreviewMailer + def authentication_methods_updated + organization = Organization.first + user = organization.admins.first + additions = Organization::PREMIUM_AUTHENTICATION_METHODS + deletions = Organization::FREE_AUTHENTICATION_METHODS + + OrganizationMailer.with(organization:, user:, additions:, deletions:).authentication_methods_updated + end +end diff --git a/spec/mailers/previews/payment_receipt_mailer_preview.rb b/spec/mailers/previews/payment_receipt_mailer_preview.rb new file mode 100644 index 0000000..0a5b275 --- /dev/null +++ b/spec/mailers/previews/payment_receipt_mailer_preview.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +class PaymentReceiptMailerPreview < BasePreviewMailer + def created + payment = FactoryBot.create(:payment) + payment_receipt = FactoryBot.create( + :payment_receipt, + payment:, + organization: payment.payable.organization + ) + + payment_receipt.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "receipt.pdf", + content_type: "application/pdf" + ) + + payment_receipt.payment.payable.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + + PaymentReceiptMailer.with(payment_receipt:).created + end +end diff --git a/spec/mailers/previews/payment_request_mailer_preview.rb b/spec/mailers/previews/payment_request_mailer_preview.rb new file mode 100644 index 0000000..0642532 --- /dev/null +++ b/spec/mailers/previews/payment_request_mailer_preview.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +class PaymentRequestMailerPreview < BasePreviewMailer + def requested + first_invoice = FactoryBot.create(:invoice, total_amount_cents: 1000, total_paid_amount_cents: 100) + second_invoice = FactoryBot.create(:invoice, total_amount_cents: 2000, total_paid_amount_cents: 200) + + payment_request = FactoryBot.create( + :payment_request, + amount_cents: 2700, + invoices: [first_invoice, second_invoice] + ) + + first_invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + second_invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + + PaymentRequestMailer.with(payment_request:).requested + end + + def requested_with_payment_url + first_invoice = FactoryBot.create(:invoice, total_amount_cents: 1000) + second_invoice = FactoryBot.create(:invoice, total_amount_cents: 2000) + + payment_request = FactoryBot.create( + :payment_request, + amount_cents: 3000, + invoices: [first_invoice, second_invoice] + ) + + first_invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + second_invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + + ::PaymentRequests::Payments::GeneratePaymentUrlService.class_eval do + def self.call(payable:) + BaseService::Result.new.tap do |result| + result.payment_url = "https://stripe.com/payment_url" + end + end + end + + PaymentRequestMailer.with(payment_request:).requested + end +end diff --git a/spec/models/add_on_spec.rb b/spec/models/add_on_spec.rb new file mode 100644 index 0000000..b80c52a --- /dev/null +++ b/spec/models/add_on_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AddOn do + subject { build(:add_on) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:applied_add_ons) } + it { is_expected.to have_many(:customers) } + it { is_expected.to have_many(:fees) } + it { is_expected.to have_many(:fixed_charges).dependent(:destroy) } + it { is_expected.to have_many(:applied_taxes).dependent(:destroy) } + it { is_expected.to have_many(:taxes) } + it { is_expected.to have_many(:netsuite_mappings).dependent(:destroy) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_numericality_of(:amount_cents) } + it { is_expected.to validate_presence_of(:code) } + + describe "validations" do + let(:errors) { add_on.errors } + + describe "of amount currency inclusion" do + subject(:add_on) { build(:add_on, amount_currency:) } + + before { add_on.valid? } + + context "when it is one from the currency list" do + let(:amount_currency) { "EUR" } + + it "does not add an error" do + expect(errors.where(:amount_currency, :inclusion)).not_to be_present + end + end + + context "when it is not one from the currency list" do + let(:amount_currency) { "ABC" } + + it "adds an error" do + expect(errors.where(:amount_currency, :inclusion)).to be_present + end + end + end + + describe "of code uniqueness" do + context "when it is unique in scope of organization" do + subject(:add_on) { build(:add_on) } + + it "does not add an error" do + expect(errors.where(:code, :taken)).not_to be_present + end + end + + context "when it not is unique in scope of organization" do + subject(:add_on) { build(:add_on, organization:, code:) } + + let(:code) { Faker::Name.name } + let(:organization) { create(:organization) } + let(:errors) { add_on.errors } + + before do + create(:add_on, organization:, code:) + add_on.valid? + end + + it "adds an error" do + expect(errors.where(:code, :taken)).to be_present + end + end + end + end + + describe "#invoice_name" do + subject(:add_on_invoice_name) { add_on.invoice_name } + + context "when invoice display name is blank" do + let(:add_on) { build_stubbed(:add_on, invoice_display_name: [nil, ""].sample) } + + it "returns name" do + expect(add_on_invoice_name).to eq(add_on.name) + end + end + + context "when invoice display name is present" do + let(:add_on) { build_stubbed(:add_on) } + + it "returns invoice display name" do + expect(add_on_invoice_name).to eq(add_on.invoice_display_name) + end + end + end +end diff --git a/spec/models/ai_conversation_spec.rb b/spec/models/ai_conversation_spec.rb new file mode 100644 index 0000000..51a1bdb --- /dev/null +++ b/spec/models/ai_conversation_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +RSpec.describe AiConversation do + subject { build(:ai_conversation) } + + describe "associations" do + it { is_expected.to belong_to(:membership) } + it { is_expected.to belong_to(:organization) } + end + + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + end +end diff --git a/spec/models/analytics/gross_revenue_spec.rb b/spec/models/analytics/gross_revenue_spec.rb new file mode 100644 index 0000000..b13d7b9 --- /dev/null +++ b/spec/models/analytics/gross_revenue_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::GrossRevenue do + describe ".cache_key" do + subject(:gross_revenue_cache_key) { described_class.cache_key(organization_id, **args) } + + let(:organization_id) { SecureRandom.uuid } + let(:billing_entity_id) { SecureRandom.uuid } + let(:external_customer_id) { "customer_01" } + let(:currency) { "EUR" } + let(:months) { 12 } + let(:date) { Date.current.strftime("%Y-%m-%d") } + + context "with no arguments" do + let(:args) { {} } + let(:cache_key) { "gross-revenue/#{date}/#{organization_id}////" } + + it "returns the cache key" do + expect(gross_revenue_cache_key).to eq(cache_key) + end + end + + context "with customer external id, currency and months" do + let(:args) { {external_customer_id:, currency:, months:} } + + let(:cache_key) do + "gross-revenue/#{date}/#{organization_id}//#{external_customer_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(gross_revenue_cache_key).to eq(cache_key) + end + + context "with billing entity id" do + let(:args) { {external_customer_id:, currency:, months:, billing_entity_id:} } + let(:cache_key) do + "gross-revenue/#{date}/#{organization_id}/#{billing_entity_id}/#{external_customer_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(gross_revenue_cache_key).to eq(cache_key) + end + end + end + + context "with customer external id" do + let(:args) { {external_customer_id:} } + + let(:cache_key) do + "gross-revenue/#{date}/#{organization_id}//#{external_customer_id}//" + end + + it "returns the cache key" do + expect(gross_revenue_cache_key).to eq(cache_key) + end + end + + context "with currency" do + let(:args) { {currency:} } + let(:cache_key) { "gross-revenue/#{date}/#{organization_id}///#{currency}/" } + + it "returns the cache key" do + expect(gross_revenue_cache_key).to eq(cache_key) + end + end + + context "with billing entity id" do + let(:args) { {billing_entity_id:} } + let(:cache_key) { "gross-revenue/#{date}/#{organization_id}/#{billing_entity_id}///" } + + it "returns the cache key" do + expect(gross_revenue_cache_key).to eq(cache_key) + end + end + end + + describe ".find_all_by" do + subject(:gross_revenues) { described_class.find_all_by(organization.id, **args) } + + let(:organization) { create(:organization, created_at: 3.months.ago) } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization: organization) } + let(:invoices) { + [ + create(:invoice, organization:, total_amount_cents: 1000, issuing_date: 1.month.ago, billing_entity: billing_entity1), + create(:invoice, organization:, total_amount_cents: 2000, issuing_date: 1.month.ago, billing_entity: billing_entity2), + create(:invoice, organization:, total_amount_cents: 3000, issuing_date: 2.months.ago, billing_entity: billing_entity1), + create(:invoice, organization:, total_amount_cents: 4000, issuing_date: 2.months.ago, billing_entity: billing_entity2) + ] + } + + before { invoices } + + context "when no filters passed" do + let(:args) { {} } + + it "returns all gross revenues" do + expect(gross_revenues).to match_array([hash_including({ + "month" => Time.current.beginning_of_month - 2.months, + "currency" => "EUR", + "invoices_count" => 2, + "amount_cents" => 7000.0 + }), hash_including({ + "month" => Time.current.beginning_of_month - 1.month, + "currency" => "EUR", + "invoices_count" => 2, + "amount_cents" => 3000.0 + })]) + end + + context "when an organization has multiple billing entities with different currencies" do + let(:invoices) { + [ + create(:invoice, organization:, total_amount_cents: 1000, issuing_date: 1.month.ago, billing_entity: billing_entity1, currency: "USD"), + create(:invoice, organization:, total_amount_cents: 2000, issuing_date: 1.month.ago, billing_entity: billing_entity2, currency: "EUR"), + create(:invoice, organization:, total_amount_cents: 3000, issuing_date: 2.months.ago, billing_entity: billing_entity1, currency: "USD"), + create(:invoice, organization:, total_amount_cents: 4000, issuing_date: 2.months.ago, billing_entity: billing_entity2, currency: "EUR") + ] + } + + it "returns gross revenues grouped by currencies" do + expect(gross_revenues).to match_array([hash_including({ + "month" => Time.current.beginning_of_month - 1.month, + "currency" => "USD", + "invoices_count" => 1, + "amount_cents" => 1000.0 + }), hash_including({ + "month" => Time.current.beginning_of_month - 1.month, + "currency" => "EUR", + "invoices_count" => 1, + "amount_cents" => 2000.0 + }), hash_including({ + "month" => Time.current.beginning_of_month - 2.months, + "currency" => "USD", + "invoices_count" => 1, + "amount_cents" => 3000.0 + }), hash_including({ + "month" => Time.current.beginning_of_month - 2.months, + "currency" => "EUR", + "invoices_count" => 1, + "amount_cents" => 4000.0 + })]) + end + end + end + + context "when filtering by billing_entity_id" do + let(:args) { {billing_entity_id: billing_entity1.id} } + + it "returns all gross revenues for the billing entity" do + expect(gross_revenues).to match_array([hash_including({ + "month" => Time.current.beginning_of_month - 1.month, + "currency" => "EUR", + "invoices_count" => 1, + "amount_cents" => 1000.0 + }), hash_including({ + "month" => Time.current.beginning_of_month - 2.months, + "currency" => "EUR", + "invoices_count" => 1, + "amount_cents" => 3000.0 + })]) + end + end + end +end diff --git a/spec/models/analytics/invoice_collection_spec.rb b/spec/models/analytics/invoice_collection_spec.rb new file mode 100644 index 0000000..e7cc531 --- /dev/null +++ b/spec/models/analytics/invoice_collection_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::InvoiceCollection do + describe ".cache_key" do + subject(:invoice_collection_cache_key) { described_class.cache_key(organization_id, **args) } + + let(:organization_id) { SecureRandom.uuid } + let(:external_customer_id) { "customer_01" } + let(:billing_entity_id) { SecureRandom.uuid } + let(:currency) { "EUR" } + let(:months) { 12 } + let(:date) { Date.current.strftime("%Y-%m-%d") } + + context "with no arguments" do + let(:args) { {} } + let(:cache_key) { "invoice-collection/#{date}/#{organization_id}////" } + + it "returns the cache key" do + expect(invoice_collection_cache_key).to eq(cache_key) + end + end + + context "with customer external id, currency and months" do + let(:args) { {external_customer_id:, currency:, months:} } + + let(:cache_key) do + "invoice-collection/#{date}/#{organization_id}//#{external_customer_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(invoice_collection_cache_key).to eq(cache_key) + end + + context "with billing entity id" do + let(:args) { {external_customer_id:, currency:, months:, billing_entity_id:} } + let(:cache_key) do + "invoice-collection/#{date}/#{organization_id}/#{billing_entity_id}/#{external_customer_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(invoice_collection_cache_key).to eq(cache_key) + end + end + end + + context "with months" do + let(:args) { {months:} } + + let(:cache_key) do + "invoice-collection/#{date}/#{organization_id}////#{months}" + end + + it "returns the cache key" do + expect(invoice_collection_cache_key).to eq(cache_key) + end + end + + context "with currency" do + let(:args) { {currency:} } + let(:cache_key) { "invoice-collection/#{date}/#{organization_id}///#{currency}/" } + + it "returns the cache key" do + expect(invoice_collection_cache_key).to eq(cache_key) + end + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id:} } + let(:cache_key) { "invoice-collection/#{date}/#{organization_id}/#{billing_entity_id}///" } + + it "returns the cache key" do + expect(invoice_collection_cache_key).to eq(cache_key) + end + end + end + + describe ".find_all_by" do + subject(:invoice_collections) { described_class.find_all_by(organization.id, **args) } + + let(:organization) { create(:organization, created_at: 3.months.ago) } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization: organization) } + let(:invoices) { + [ + create(:invoice, organization:, customer: customer1, billing_entity: billing_entity1, issuing_date: 2.months.ago, total_amount_cents: 100, status: :pending), + create(:invoice, organization:, customer: customer1, billing_entity: billing_entity1, issuing_date: 2.months.ago, total_amount_cents: 200, status: :finalized), + create(:invoice, organization:, customer: customer2, billing_entity: billing_entity2, issuing_date: 1.month.ago, total_amount_cents: 300, status: :pending), + create(:invoice, organization:, customer: customer2, billing_entity: billing_entity2, issuing_date: 1.month.ago, total_amount_cents: 400, status: :finalized) + ] + } + + let(:customer1) { create(:customer, organization:, tax_identification_number: nil) } + let(:customer2) { create(:customer, organization:, tax_identification_number: "123456789") } + + before { invoices } + + context "with no arguments" do + let(:args) { {} } + + it "returns the finalized invoices collections" do + expect(invoice_collections).to match_array([ + hash_including({"month" => Time.current.beginning_of_month - 3.months, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month - 2.months, + "payment_status" => "pending", "currency" => "EUR", "invoices_count" => 1, "amount_cents" => 200.0}), + hash_including({"month" => Time.current.beginning_of_month - 1.month, + "payment_status" => "pending", "currency" => "EUR", "invoices_count" => 1, "amount_cents" => 400.0}), + hash_including({"month" => Time.current.beginning_of_month, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}) + ]) + end + end + + context "when billing_entity_id is provided" do + let(:args) { {billing_entity_id: billing_entity1.id} } + + it "returns the finalized invoices collections filtered by billing_entity_id" do + expect(invoice_collections).to match_array([ + hash_including({"month" => Time.current.beginning_of_month - 3.months, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month - 2.months, + "payment_status" => "pending", "currency" => "EUR", "invoices_count" => 1, "amount_cents" => 200.0}), + hash_including({"month" => Time.current.beginning_of_month - 1.month, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}) + ]) + end + end + + context "when billing entity code is provided" do + let(:args) { {billing_entity_code: billing_entity2.code} } + + it "returns the finalized invoices collections filtered by billing_entity_code" do + expect(invoice_collections).to match_array([ + hash_including({"month" => Time.current.beginning_of_month - 3.months, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month - 2.months, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month - 1.month, + "payment_status" => "pending", "currency" => "EUR", "invoices_count" => 1, "amount_cents" => 400.0}), + hash_including({"month" => Time.current.beginning_of_month, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}) + ]) + end + end + + context "when is_customer_tin_empty is true" do + let(:args) { {is_customer_tin_empty: true} } + + it "invoices collections for customer with no tax_identification_number" do + expect(invoice_collections).to match_array([ + hash_including({"month" => Time.current.beginning_of_month - 3.months, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month - 2.months, + "payment_status" => "pending", "currency" => "EUR", "invoices_count" => 1, "amount_cents" => 200.0}), + hash_including({"month" => Time.current.beginning_of_month - 1.month, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}) + ]) + end + end + + context "when is_customer_tin_empty is false" do + let(:args) { {is_customer_tin_empty: false} } + + it "invoices collections for customer with tax_identification_number" do + expect(invoice_collections).to match_array([ + hash_including({"month" => Time.current.beginning_of_month - 3.months, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month - 2.months, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}), + hash_including({"month" => Time.current.beginning_of_month - 1.month, + "payment_status" => "pending", "currency" => "EUR", "invoices_count" => 1, "amount_cents" => 400.0}), + hash_including({"month" => Time.current.beginning_of_month, + "payment_status" => nil, "currency" => nil, "invoices_count" => 0, "amount_cents" => 0.0}) + ]) + end + end + end +end diff --git a/spec/models/analytics/invoiced_usage_spec.rb b/spec/models/analytics/invoiced_usage_spec.rb new file mode 100644 index 0000000..aa7db48 --- /dev/null +++ b/spec/models/analytics/invoiced_usage_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::InvoicedUsage do + describe ".cache_key" do + subject(:invoiced_usage_cache_key) { described_class.cache_key(organization_id, **args) } + + let(:organization_id) { SecureRandom.uuid } + let(:billing_entity_id) { SecureRandom.uuid } + let(:currency) { "EUR" } + let(:months) { 12 } + let(:date) { Date.current.strftime("%Y-%m-%d") } + + context "with no arguments" do + let(:args) { {} } + let(:cache_key) { "invoiced-usage/#{date}/#{organization_id}///" } + + it "returns the cache key" do + expect(invoiced_usage_cache_key).to eq(cache_key) + end + end + + context "with currency and months" do + let(:args) { {currency:, months:} } + + let(:cache_key) do + "invoiced-usage/#{date}/#{organization_id}//#{currency}/#{months}" + end + + it "returns the cache key" do + expect(invoiced_usage_cache_key).to eq(cache_key) + end + + context "with billing entity id" do + let(:args) { {currency:, months:, billing_entity_id:} } + let(:cache_key) do + "invoiced-usage/#{date}/#{organization_id}/#{billing_entity_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(invoiced_usage_cache_key).to eq(cache_key) + end + end + end + + context "with months" do + let(:args) { {months:} } + + let(:cache_key) do + "invoiced-usage/#{date}/#{organization_id}///#{months}" + end + + it "returns the cache key" do + expect(invoiced_usage_cache_key).to eq(cache_key) + end + end + + context "with currency" do + let(:args) { {currency:} } + let(:cache_key) { "invoiced-usage/#{date}/#{organization_id}//#{currency}/" } + + it "returns the cache key" do + expect(invoiced_usage_cache_key).to eq(cache_key) + end + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id:} } + let(:cache_key) { "invoiced-usage/#{date}/#{organization_id}/#{billing_entity_id}//" } + + it "returns the cache key" do + expect(invoiced_usage_cache_key).to eq(cache_key) + end + end + end + + describe ".find_all_by" do + subject(:invoiced_usages) { described_class.find_all_by(organization.id, **args) } + + let(:organization) { create(:organization, created_at: 3.months.ago) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization: organization) } + + let(:billable_metric) { create(:billable_metric, organization:, code: "api_calls") } + let(:charge) { create(:standard_charge, billable_metric:) } + + let(:fee1) do + create(:charge_fee, charge:, subscription:, amount_cents: 100, amount_currency: "EUR", created_at: 2.months.ago) + end + + let(:fee2) do + create(:charge_fee, charge:, subscription:, amount_cents: 200, amount_currency: "EUR", created_at: 2.months.ago) + end + + let(:fee3) do + create(:charge_fee, charge:, subscription:, amount_cents: 300, amount_currency: "EUR", created_at: 1.month.ago) + end + + let(:fee4) do + create(:charge_fee, charge:, subscription:, amount_cents: 400, amount_currency: "EUR", created_at: 1.month.ago) + end + + let(:invoice1) do + create(:invoice, organization:, billing_entity: billing_entity1, issuing_date: 2.months.ago, status: :finalized, fees: [fee1, fee2]) + end + + let(:invoice2) do + create(:invoice, organization:, billing_entity: billing_entity2, issuing_date: 1.month.ago, status: :finalized, fees: [fee3, fee4]) + end + + before do + invoice1 + invoice2 + end + + context "with no arguments" do + let(:args) { {} } + + it "returns all invoiced usages" do + expect(invoiced_usages).to match_array([hash_including({ + "amount_cents" => 700.0, + "code" => "api_calls", + "currency" => "EUR", + "month" => Time.current.beginning_of_month - 1.month + }), hash_including({ + "amount_cents" => 300.0, + "code" => "api_calls", + "currency" => "EUR", + "month" => Time.current.beginning_of_month - 2.months + })]) + end + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id: billing_entity1.id} } + + it "returns all invoiced usages for the specified billing entity" do + expect(invoiced_usages).to match_array([hash_including({ + "amount_cents" => 300.0, + "code" => "api_calls", + "currency" => "EUR", + "month" => Time.current.beginning_of_month - 2.months + })]) + end + end + end +end diff --git a/spec/models/analytics/mrr_spec.rb b/spec/models/analytics/mrr_spec.rb new file mode 100644 index 0000000..7b8082e --- /dev/null +++ b/spec/models/analytics/mrr_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::Mrr do + describe ".cache_key" do + subject(:mrr_cache_key) { described_class.cache_key(organization_id, **args) } + + let(:organization_id) { SecureRandom.uuid } + let(:billing_entity_id) { SecureRandom.uuid } + let(:currency) { "EUR" } + let(:months) { 12 } + let(:date) { Date.current.strftime("%Y-%m-%d") } + + context "with no arguments" do + let(:args) { {} } + let(:cache_key) { "mrr/#{date}/#{organization_id}///" } + + it "returns the cache key" do + expect(mrr_cache_key).to eq(cache_key) + end + end + + context "with currency and months" do + let(:args) { {currency:, months:} } + + let(:cache_key) do + "mrr/#{date}/#{organization_id}//#{currency}/#{months}" + end + + it "returns the cache key" do + expect(mrr_cache_key).to eq(cache_key) + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id:, currency:, months:} } + let(:cache_key) do + "mrr/#{date}/#{organization_id}/#{billing_entity_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(mrr_cache_key).to eq(cache_key) + end + end + end + + context "with months" do + let(:args) { {months:} } + let(:cache_key) { "mrr/#{date}/#{organization_id}///#{months}" } + + it "returns the cache key" do + expect(mrr_cache_key).to eq(cache_key) + end + end + + context "with currency" do + let(:args) { {currency:} } + let(:cache_key) { "mrr/#{date}/#{organization_id}//#{currency}/" } + + it "returns the cache key" do + expect(mrr_cache_key).to eq(cache_key) + end + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id:} } + let(:cache_key) { "mrr/#{date}/#{organization_id}/#{billing_entity_id}//" } + + it "returns the cache key" do + expect(mrr_cache_key).to eq(cache_key) + end + end + end + + describe ".find_all_by" do + subject(:mrrs) { described_class.find_all_by(organization.id, **args) } + + let(:organization) { create(:organization, created_at: 3.months.ago) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization: organization) } + + let(:fee1) do + create(:fee, subscription:, amount_cents: 100, amount_currency: "EUR", created_at: 2.months.ago, taxes_amount_cents: 10) + end + + let(:fee2) do + create(:fee, subscription:, amount_cents: 200, amount_currency: "EUR", created_at: 2.months.ago, taxes_amount_cents: 20) + end + + let(:fee3) do + create(:fee, subscription:, amount_cents: 300, amount_currency: "EUR", created_at: 1.month.ago, taxes_amount_cents: 30) + end + + let(:fee4) do + create(:fee, subscription:, amount_cents: 400, amount_currency: "EUR", created_at: 1.month.ago, taxes_amount_cents: 40) + end + + let(:invoice1) do + create(:invoice, organization:, billing_entity: billing_entity1, issuing_date: 2.months.ago, status: :finalized, fees: [fee1, fee2], customer: customer) + end + + let(:invoice2) do + create(:invoice, organization:, billing_entity: billing_entity2, issuing_date: 1.month.ago, status: :finalized, fees: [fee3, fee4], customer: customer) + end + + before do + invoice1 + invoice2 + end + + context "with no arguments" do + let(:args) { {} } + + it "returns all MRRs" do + expect(mrrs).to match_array([ + hash_including({ + "amount_cents" => nil, + "currency" => nil, + "month" => Time.current.beginning_of_month + }), hash_including({ + "amount_cents" => 770.0, + "currency" => "EUR", + "month" => Time.current.beginning_of_month - 1.month + }), hash_including({ + "amount_cents" => 330.0, + "currency" => "EUR", + "month" => Time.current.beginning_of_month - 2.months + }), hash_including({ + "amount_cents" => nil, + "currency" => nil, + "month" => Time.current.beginning_of_month - 3.months + }) + ]) + end + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id: billing_entity1.id} } + + it "returns MRRs only for the provided billing_entity" do + expect(mrrs).to match_array([ + hash_including({ + "amount_cents" => nil, + "currency" => nil, + "month" => Time.current.beginning_of_month + }), hash_including({ + "amount_cents" => nil, + "currency" => nil, + "month" => Time.current.beginning_of_month - 1.month + }), hash_including({ + "amount_cents" => 330.0, + "currency" => "EUR", + "month" => Time.current.beginning_of_month - 2.months + }), hash_including({ + "amount_cents" => nil, + "currency" => nil, + "month" => Time.current.beginning_of_month - 3.months + }) + ]) + end + end + end +end diff --git a/spec/models/analytics/overdue_balance_spec.rb b/spec/models/analytics/overdue_balance_spec.rb new file mode 100644 index 0000000..f0d24a1 --- /dev/null +++ b/spec/models/analytics/overdue_balance_spec.rb @@ -0,0 +1,285 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::OverdueBalance do + describe ".cache_key" do + subject(:overdue_balance_cache_key) { described_class.cache_key(organization_id, **args) } + + let(:organization_id) { SecureRandom.uuid } + let(:billing_entity_id) { SecureRandom.uuid } + let(:external_customer_id) { "customer_01" } + let(:currency) { "EUR" } + let(:months) { 12 } + let(:date) { Date.current.strftime("%Y-%m-%d") } + + context "with no arguments" do + let(:args) { {} } + let(:cache_key) { "overdue-balance/#{date}/#{organization_id}////" } + + it "returns the cache key" do + expect(overdue_balance_cache_key).to eq(cache_key) + end + end + + context "with customer external id, currency and months" do + let(:args) { {external_customer_id:, currency:, months:} } + + let(:cache_key) do + "overdue-balance/#{date}/#{organization_id}//#{external_customer_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(overdue_balance_cache_key).to eq(cache_key) + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id:, external_customer_id:, currency:, months:} } + let(:cache_key) do + "overdue-balance/#{date}/#{organization_id}/#{billing_entity_id}/#{external_customer_id}/#{currency}/#{months}" + end + + it "returns the cache key" do + expect(overdue_balance_cache_key).to eq(cache_key) + end + end + end + + context "with customer external id" do + let(:args) { {external_customer_id:} } + + let(:cache_key) do + "overdue-balance/#{date}/#{organization_id}//#{external_customer_id}//" + end + + it "returns the cache key" do + expect(overdue_balance_cache_key).to eq(cache_key) + end + end + + context "with currency" do + let(:args) { {currency:} } + let(:cache_key) { "overdue-balance/#{date}/#{organization_id}///#{currency}/" } + + it "returns the cache key" do + expect(overdue_balance_cache_key).to eq(cache_key) + end + end + + context "with billing_entity_id" do + let(:args) { {billing_entity_id:} } + let(:cache_key) { "overdue-balance/#{date}/#{organization_id}/#{billing_entity_id}///" } + + it "returns the cache key" do + expect(overdue_balance_cache_key).to eq(cache_key) + end + end + end + + describe ".find_all_by" do + subject(:overdue_balances) { described_class.find_all_by(organization.id, **args) } + + let(:organization) { create(:organization, created_at: 3.months.ago) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization: organization) } + let(:invoice1) do + create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 1.month.ago, + total_amount_cents: 100, billing_entity: billing_entity1, issuing_date: 1.month.ago) + end + let(:invoice2) do + create(:invoice, customer:, organization:, payment_overdue: false, payment_due_date: 1.month.ago, + total_amount_cents: 200, billing_entity: billing_entity2, issuing_date: 1.month.ago) + end + let(:invoice3) do + create(:invoice, customer:, organization:, payment_overdue: false, payment_due_date: 2.months.ago, + total_amount_cents: 300, billing_entity: billing_entity1, issuing_date: 2.months.ago) + end + let(:invoice4) do + create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 2.months.ago, + total_amount_cents: 400, billing_entity: billing_entity2, issuing_date: 2.months.ago) + end + + before do + invoice1 + invoice2 + invoice3 + invoice4 + end + + context "with no arguments" do + let(:args) { {} } + + it "returns the overdue balances" do + expect(overdue_balances).to match_array([ + hash_including({ + "month" => Time.current.beginning_of_month - 2.months, + "currency" => "EUR", + "amount_cents" => 400, + "lago_invoice_ids" => "[[\"#{invoice4.id}\"]]" + }), hash_including({ + "month" => Time.current.beginning_of_month - 1.month, + "currency" => "EUR", + "amount_cents" => 100, + "lago_invoice_ids" => "[[\"#{invoice1.id}\"]]" + }) + ]) + end + end + + context "with billing entity id" do + let(:args) { {billing_entity_id: billing_entity1.id} } + + it "returns the overdue balances for provided billing_entity only" do + expect(overdue_balances).to match_array([ + hash_including({ + "month" => Time.current.beginning_of_month - 1.month, + "currency" => "EUR", + "amount_cents" => 100, + "lago_invoice_ids" => "[[\"#{invoice1.id}\"]]" + }) + ]) + end + end + + context "with billing entity code" do + let(:args) { {billing_entity_code: billing_entity2.code} } + + it "returns the overdue balances for provided billing_entity only" do + expect(overdue_balances).to match_array([ + hash_including({ + "month" => Time.current.beginning_of_month - 2.months, + "currency" => "EUR", + "amount_cents" => 400, + "lago_invoice_ids" => "[[\"#{invoice4.id}\"]]" + }) + ]) + end + end + + context "with special invoice scenarios" do + let(:args) { {} } + + context "with credit notes offsetting invoice amounts" do + let(:invoice_with_credit) do + create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 1.month.ago, + total_amount_cents: 1000, billing_entity: billing_entity1, issuing_date: 1.month.ago) + end + + before do + invoice_with_credit + create(:credit_note, invoice: invoice_with_credit, customer:, status: :finalized, + total_amount_cents: 300, credit_amount_cents: 300, balance_amount_cents: 300, + refund_amount_cents: 0, coupons_adjustment_amount_cents: 0, offset_amount_cents: 300) + end + + it "deducts credit note offset from the overdue amount" do + result = overdue_balances.find { |r| r["lago_invoice_ids"].include?(invoice_with_credit.id) } + expect(result["amount_cents"]).to eq(800) # invoice1 (100) + invoice_with_credit (1000 - 300) + end + end + + context "with self-billed invoices" do + let(:self_billed_invoice) do + create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 1.month.ago, + total_amount_cents: 5000, billing_entity: billing_entity1, issuing_date: 1.month.ago, self_billed: true) + end + + before { self_billed_invoice } + + it "excludes self-billed invoices from overdue balances" do + invoice_ids = overdue_balances.flat_map { |r| JSON.parse(r["lago_invoice_ids"]).flatten } + expect(invoice_ids).not_to include(self_billed_invoice.id) + end + end + + context "with partially paid invoices" do + let(:partially_paid_invoice) do + create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 1.month.ago, + total_amount_cents: 1000, total_paid_amount_cents: 400, billing_entity: billing_entity1, issuing_date: 1.month.ago) + end + + before { partially_paid_invoice } + + it "calculates overdue amount as total minus paid amount" do + result = overdue_balances.find { |r| r["lago_invoice_ids"].include?(partially_paid_invoice.id) } + expect(result["amount_cents"]).to eq(700) # invoice1 (100) + partially_paid_invoice (1000 - 400) + end + end + + context "with only finalized credit notes" do + let(:invoice_with_draft_credit) do + create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 1.month.ago, + total_amount_cents: 1000, billing_entity: billing_entity1, issuing_date: 1.month.ago) + end + + before do + invoice_with_draft_credit + create(:credit_note, invoice: invoice_with_draft_credit, customer:, status: :draft, + total_amount_cents: 200, credit_amount_cents: 200, balance_amount_cents: 200, + refund_amount_cents: 0, coupons_adjustment_amount_cents: 0, offset_amount_cents: 200) + end + + it "only includes finalized credit notes in offset calculation" do + result = overdue_balances.find { |r| r["lago_invoice_ids"].include?(invoice_with_draft_credit.id) } + expect(result["amount_cents"]).to eq(1100) # invoice1 (100) + invoice_with_draft_credit (1000, draft credit note not applied) + end + end + end + + context "with filters" do + context "with currency filter" do + let(:args) { {currency: "USD"} } + let(:usd_invoice) do + create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 1.month.ago, + total_amount_cents: 500, currency: "USD", billing_entity: billing_entity1, issuing_date: 1.month.ago) + end + + before { usd_invoice } + + it "returns only invoices with the specified currency" do + expect(overdue_balances.map { |r| r["currency"] }.uniq).to eq(["USD"]) + result = overdue_balances.find { |r| r["lago_invoice_ids"].include?(usd_invoice.id) } + expect(result).to be_present + end + + it "excludes invoices with different currencies" do + invoice_ids = overdue_balances.flat_map { |r| JSON.parse(r["lago_invoice_ids"]).flatten } + expect(invoice_ids).not_to include(invoice1.id, invoice4.id) # EUR invoices + end + end + + context "with external_customer_id filter" do + let(:args) { {external_customer_id: customer.external_id} } + let(:other_customer) { create(:customer, organization:, external_id: "other_customer") } + let(:other_invoice) do + create(:invoice, customer: other_customer, organization:, payment_overdue: true, + payment_due_date: 1.month.ago, total_amount_cents: 999, billing_entity: billing_entity1, issuing_date: 1.month.ago) + end + + before { other_invoice } + + it "returns only overdue balances for the specified customer" do + invoice_ids = overdue_balances.flat_map { |r| JSON.parse(r["lago_invoice_ids"]).flatten } + expect(invoice_ids).to include(invoice1.id) + expect(invoice_ids).not_to include(other_invoice.id) + end + end + + context "with deleted customer" do + let(:deleted_customer) { create(:customer, organization:, deleted_at: 1.day.ago) } + let(:args) { {external_customer_id: deleted_customer.external_id} } + + before do + create(:invoice, customer: deleted_customer, organization:, payment_overdue: true, + payment_due_date: 1.month.ago, total_amount_cents: 888, billing_entity: billing_entity1, issuing_date: 1.month.ago) + end + + it "excludes invoices from deleted customers" do + expect(overdue_balances).to be_empty + end + end + end + end +end diff --git a/spec/models/api_key_spec.rb b/spec/models/api_key_spec.rb new file mode 100644 index 0000000..b3a9ce5 --- /dev/null +++ b/spec/models/api_key_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKey do + subject { build(:api_key, expires_at:) } + + let(:expires_at) { nil } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } + + it { is_expected.to validate_presence_of(:permissions) } + + describe "validations" do + describe "of value uniqueness" do + before { create(:api_key) } + + it { is_expected.to validate_uniqueness_of(:value) } + end + + describe "of value presence" do + subject { api_key } + + context "with a new record" do + let(:api_key) { build(:api_key) } + + it { is_expected.not_to validate_presence_of(:value) } + end + + context "with a persisted record" do + let(:api_key) { create(:api_key) } + + it { is_expected.to validate_presence_of(:value) } + end + end + + describe "of permissions structure" do + subject { api_key.valid? } + + let(:api_key) { build_stubbed(:api_key) } + let(:error) { api_key.errors.where(:permissions, :forbidden_keys) } + + context "when permissions has forbidden keys" do + before do + api_key.permissions = api_key.permissions.merge(forbidden: []) + subject + end + + it "adds forbidden keys error" do + expect(error).to be_present + end + end + + context "when permissions has no forbidden keys" do + before { subject } + + it "does not add forbidden keys error" do + expect(error).not_to be_present + end + end + end + + describe "of permissions values" do + subject { api_key.valid? } + + let(:api_key) { build_stubbed(:api_key, permissions:) } + let(:error) { api_key.errors.where(:permissions, :forbidden_values) } + + before { subject } + + context "when permission contains forbidden values" do + let(:permissions) { {add_on: ["forbidden", "read"]} } + + it "adds an error" do + expect(error).to be_present + end + end + + context "when permission contains only allowed values" do + let(:permissions) { {add_on: ["read", "write"]} } + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + end + + describe "#save" do + subject { api_key.save! } + + context "with a new record" do + let(:api_key) { build(:api_key) } + let(:used_value) { create(:api_key).value } + let(:unique_value) { SecureRandom.uuid } + + before do + allow(SecureRandom).to receive(:uuid).and_return(used_value, unique_value) + end + + it "sets the value" do + expect { subject }.to change(api_key, :value).to unique_value + end + end + + context "with a persisted record" do + let(:api_key) { create(:api_key) } + + it "does not change the value" do + expect { subject }.not_to change(api_key, :value) + end + end + end + + describe "default_scope" do + subject { described_class.all } + + let!(:scoped) do + [ + create(:api_key), + create(:api_key, :expiring) + ] + end + + before { create(:api_key, :expired) } + + it "returns API keys with either no expiration or future expiration dates" do + expect(subject).to match_array scoped + end + end + + describe ".non_expiring" do + subject { described_class.non_expiring } + + let!(:scoped) { create(:api_key) } + + before { create(:api_key, :expiring) } + + it "returns API keys with no expiration date" do + expect(subject).to contain_exactly scoped + end + end + + describe ".with_most_permissions" do + subject { organization.api_keys.with_most_permissions } + + let(:organization) { create(:organization) } + + let(:limited_permissions_key) do + create(:api_key, organization:, permissions: {add_on: ["read"], customer: ["read"]}) + end + + before { limited_permissions_key } + + it "returns the API key with the most permissions" do + expect(subject).not_to eq(limited_permissions_key) + expect(subject.permissions).to eq(described_class.default_permissions) + end + end + + describe "#permit?", :premium do + subject { api_key.permit?(resource, mode) } + + let(:api_key) { create(:api_key, permissions:) } + let(:resource) { described_class::RESOURCES.sample } + let(:mode) { described_class::MODES.sample } + + before { api_key.organization.update!(premium_integrations:) } + + context "when organization has 'api_permissions' add-on enabled" do + let(:premium_integrations) { ["api_permissions"] } + + context "when corresponding resource is specified in permissions" do + let(:permissions) { {resource => allowed_modes} } + + context "when corresponding resource allows provided mode" do + let(:allowed_modes) { [mode] } + + it "returns true" do + expect(subject).to be true + end + end + + context "when corresponding resource does not allow provided mode" do + let(:allowed_modes) { described_class::MODES.excluding(mode) } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when corresponding resource does not specified in permissions" do + let(:permissions) { described_class.default_permissions.without(resource) } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when organization has 'api_permissions' add-on disabled" do + let(:premium_integrations) { [] } + + context "when corresponding resource is specified in permissions" do + let(:permissions) { {resource => allowed_modes} } + + context "when corresponding resource allows provided mode" do + let(:allowed_modes) { [mode] } + + it "returns true" do + expect(subject).to be true + end + end + + context "when corresponding resource does not allow provided mode" do + let(:allowed_modes) { described_class::MODES.excluding(mode) } + + it "returns true" do + expect(subject).to be true + end + end + end + + context "when corresponding resource does not specified in permissions" do + let(:permissions) { described_class.default_permissions.without(resource) } + + it "returns true" do + expect(subject).to be true + end + end + end + end + + describe "#expired?" do + it { expect(subject).not_to be_expired } + + context "with an expires_at value" do + let(:expires_at) { Time.current + 1.hour } + + it { expect(subject).not_to be_expired } + + context "when expires_at is in the past" do + let(:expires_at) { Time.current - 1.hour } + + it { expect(subject).to be_expired } + end + end + end +end diff --git a/spec/models/applied_add_on_spec.rb b/spec/models/applied_add_on_spec.rb new file mode 100644 index 0000000..2caf407 --- /dev/null +++ b/spec/models/applied_add_on_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedAddOn do + subject { build(:applied_add_on) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:add_on) } + it { is_expected.to belong_to(:customer) } + + it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than(0) } + + specify do + expect(subject) + .to validate_inclusion_of(:amount_currency) + .in_array(described_class.currency_list) + end +end diff --git a/spec/models/applied_coupon_spec.rb b/spec/models/applied_coupon_spec.rb new file mode 100644 index 0000000..931f530 --- /dev/null +++ b/spec/models/applied_coupon_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedCoupon do + subject(:applied_coupon) { create(:applied_coupon) } + + it_behaves_like "paper_trail traceable" + + describe "associations" do + subject(:applied_coupon) { create(:applied_coupon, coupon: create(:coupon, :deleted)) } + + it { is_expected.to belong_to(:coupon) } + it { expect(subject.coupon).not_to be_nil } + + it { is_expected.to belong_to(:customer) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:credits) } + end + + describe "enums" do + it { is_expected.to define_enum_for(:status).with_values(%i[active terminated]) } + it { is_expected.to define_enum_for(:frequency).with_values(%i[once recurring forever]) } + end + + describe "validations" do + it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_inclusion_of(:amount_currency).in_array(described_class.currency_list) } + + describe "of frequency_duration" do + subject(:applied_coupon) { build(:applied_coupon, frequency:) } + + context "when recurring" do + let(:frequency) { "recurring" } + + it { is_expected.to validate_presence_of(:frequency_duration).with_message("value_is_mandatory") } + it { is_expected.to validate_numericality_of(:frequency_duration).is_greater_than(0) } + it { is_expected.to validate_presence_of(:frequency_duration_remaining).with_message("value_is_mandatory") } + it { is_expected.to validate_numericality_of(:frequency_duration_remaining).is_greater_than_or_equal_to(0) } + end + + context "when once" do + let(:frequency) { "once" } + + it { is_expected.not_to validate_presence_of(:frequency_duration) } + it { is_expected.not_to validate_presence_of(:frequency_duration_remaining) } + end + + context "when forever" do + let(:frequency) { "forever" } + + it { is_expected.not_to validate_presence_of(:frequency_duration) } + it { is_expected.not_to validate_presence_of(:frequency_duration_remaining) } + end + end + end + + describe "#remaining_amount" do + let(:applied_coupon) { create(:applied_coupon, amount_cents: 50) } + let(:invoice) { create(:invoice) } + + before do + create(:credit, applied_coupon: applied_coupon, amount_cents: 10, invoice: invoice) + end + + context "when invoice is not voided" do + it "returns the amount minus credit" do + expect(applied_coupon.remaining_amount).to eq(40) + end + end + + context "when invoice is voided" do + let(:invoice) { create(:invoice, status: :voided) } + + it "ignores the credit amount" do + expect(applied_coupon.remaining_amount).to eq(50) + end + end + end + + describe "#mark_as_terminated!" do + it "marks the applied coupon as terminated" do + expect { applied_coupon.mark_as_terminated! }.to change(applied_coupon, :status).to("terminated").and \ + change(applied_coupon, :terminated_at).to be_present + end + end +end diff --git a/spec/models/applied_invoice_custom_section_spec.rb b/spec/models/applied_invoice_custom_section_spec.rb new file mode 100644 index 0000000..07153ae --- /dev/null +++ b/spec/models/applied_invoice_custom_section_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedInvoiceCustomSection do + subject(:applied_invoice_custom_section) { build(:applied_invoice_custom_section) } + + it { is_expected.to belong_to(:invoice) } + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/applied_pricing_unit_spec.rb b/spec/models/applied_pricing_unit_spec.rb new file mode 100644 index 0000000..bd8a38f --- /dev/null +++ b/spec/models/applied_pricing_unit_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedPricingUnit do + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:pricing_unit) } + it { is_expected.to belong_to(:pricing_unitable) } + + it { is_expected.to validate_presence_of(:conversion_rate) } + it { is_expected.to validate_numericality_of(:conversion_rate).is_greater_than(0) } +end diff --git a/spec/models/applied_usage_threshold_spec.rb b/spec/models/applied_usage_threshold_spec.rb new file mode 100644 index 0000000..d8f51b5 --- /dev/null +++ b/spec/models/applied_usage_threshold_spec.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedUsageThreshold do + subject(:applied_usage_threshold) { build(:applied_usage_threshold) } + + it { is_expected.to belong_to(:usage_threshold) } + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/billable_metric_filter_spec.rb b/spec/models/billable_metric_filter_spec.rb new file mode 100644 index 0000000..98df57e --- /dev/null +++ b/spec/models/billable_metric_filter_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetricFilter do + subject(:billable_metric_filter) { build(:billable_metric_filter) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:billable_metric) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:filter_values).dependent(:destroy) } + it { is_expected.to have_many(:charge_filters).through(:filter_values) } + + it { is_expected.to validate_presence_of(:key) } + it { is_expected.to validate_presence_of(:values) } +end diff --git a/spec/models/billable_metric_spec.rb b/spec/models/billable_metric_spec.rb new file mode 100644 index 0000000..a615217 --- /dev/null +++ b/spec/models/billable_metric_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetric do + subject(:billable_metric) { create(:billable_metric) } + + it_behaves_like "paper_trail traceable" + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + + expect(subject).to have_many(:alerts).class_name("UsageMonitoring::Alert") + expect(subject).to have_many(:charges).dependent(:destroy) + expect(subject).to have_many(:plans).through(:charges) + expect(subject).to have_many(:fees).through(:charges) + expect(subject).to have_many(:subscriptions).through(:plans) + expect(subject).to have_many(:invoices).through(:fees) + expect(subject).to have_many(:filters).dependent(:delete_all) + expect(subject).to have_many(:netsuite_mappings).dependent(:destroy) + end + end + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + it { validate_presence_of(:field_name) } + it { validate_presence_of(:custom_aggregator) } + + describe "#aggregation_type=" do + let(:billable_metric) { described_class.new } + + it "assigns the aggregation type" do + billable_metric.aggregation_type = :count_agg + billable_metric.valid? + + expect(billable_metric).to be_count_agg + expect(billable_metric.errors[:aggregation_type]).to be_blank + end + + context "when aggregation type is invalid" do + it "does not assign the aggregation type" do + billable_metric.aggregation_type = :invalid_agg + billable_metric.valid? + + expect(billable_metric.aggregation_type).to be_nil + expect(billable_metric.errors[:aggregation_type]).to include("value_is_invalid") + end + end + end + + describe "#validate_recurring" do + let(:recurring) { false } + let(:billable_metric) { build(:max_billable_metric, recurring:) } + + it "does not return an error if recurring is false for max_agg" do + expect(billable_metric).to be_valid + end + + context "when recurring is true" do + let(:recurring) { true } + + it "returns an error for max_agg" do + expect(billable_metric).not_to be_valid + expect(billable_metric.errors.messages[:recurring]).to include("not_compatible_with_aggregation_type") + end + end + + context "when recurring is true and aggregation type is latest_agg" do + let(:billable_metric) { build(:latest_billable_metric, recurring:) } + let(:recurring) { true } + + it "returns an error" do + expect(billable_metric).not_to be_valid + expect(billable_metric.errors.messages[:recurring]).to include("not_compatible_with_aggregation_type") + end + end + end + + describe "#validate_expression" do + let(:expression) { "" } + let(:billable_metric) { build(:max_billable_metric, expression:) } + + it "does not return an error if expression is blank" do + expect(billable_metric).to be_valid + end + + context "with valid expression" do + let(:expression) { "1 + event.timestamp" } + + it "does not return an error" do + expect(billable_metric).to be_valid + end + end + + context "when expression is not valid" do + let(:expression) { "1+" } + + it "returns an error for expression" do + expect(billable_metric).not_to be_valid + expect(billable_metric.errors.messages[:expression]).to include("invalid_expression") + end + end + end + + describe "#payable_in_advance?" do + it do + described_class::AGGREGATION_TYPES_PAYABLE_IN_ADVANCE.each do |agg| + expect(build(:billable_metric, aggregation_type: agg)).to be_payable_in_advance + end + + (described_class::AGGREGATION_TYPES.keys - described_class::AGGREGATION_TYPES_PAYABLE_IN_ADVANCE).each do |agg| + expect(build(:billable_metric, aggregation_type: agg)).not_to be_payable_in_advance + end + end + end + + describe "#attached_subscriptions" do + subject(:billable_metric) { create(:billable_metric, organization:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:other_plan) { create(:plan, organization:) } + + it "returns subscriptions of plans that have a charge for this billable metric" do + create(:standard_charge, billable_metric:, plan:, organization:) + attached_subscription = create(:subscription, plan:, organization:) + create(:subscription, plan: other_plan, organization:) + + expect(billable_metric.attached_subscriptions).to contain_exactly(attached_subscription) + end + + it "returns an empty relation when no charge references the billable metric" do + create(:subscription, plan:, organization:) + + expect(billable_metric.attached_subscriptions).to be_empty + end + + it "returns a chainable ActiveRecord relation" do + create(:standard_charge, billable_metric:, plan:, organization:) + create(:subscription, plan:, organization:) + create(:subscription, :terminated, plan:, organization:) + + expect(billable_metric.attached_subscriptions.active.count).to eq(1) + end + end +end diff --git a/spec/models/billing_entity/applied_invoice_custom_section_spec.rb b/spec/models/billing_entity/applied_invoice_custom_section_spec.rb new file mode 100644 index 0000000..0cfb14f --- /dev/null +++ b/spec/models/billing_entity/applied_invoice_custom_section_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntity::AppliedInvoiceCustomSection do + subject(:applied_invoice_custom_section) do + create(:billing_entity_applied_invoice_custom_section) + end + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:billing_entity) } + it { is_expected.to belong_to(:invoice_custom_section) } +end diff --git a/spec/models/billing_entity/applied_tax_spec.rb b/spec/models/billing_entity/applied_tax_spec.rb new file mode 100644 index 0000000..e876f77 --- /dev/null +++ b/spec/models/billing_entity/applied_tax_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntity::AppliedTax do + subject(:billing_entity_applied_tax) { create(:billing_entity_applied_tax) } + + it { is_expected.to belong_to(:billing_entity) } + it { is_expected.to belong_to(:tax) } + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/billing_entity_spec.rb b/spec/models/billing_entity_spec.rb new file mode 100644 index 0000000..ce6e7b4 --- /dev/null +++ b/spec/models/billing_entity_spec.rb @@ -0,0 +1,386 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntity do + subject(:billing_entity) { build(:billing_entity) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:applied_dunning_campaign).class_name("DunningCampaign").optional } + + it { is_expected.to have_many(:customers) } + it { is_expected.to have_many(:invoices) } + it { is_expected.to have_many(:fees) } + it { is_expected.to have_many(:pending_vies_checks) } + it { is_expected.to have_many(:payment_receipts) } + it { is_expected.to have_many(:applied_invoice_custom_sections).class_name("BillingEntity::AppliedInvoiceCustomSection").dependent(:destroy) } + it { is_expected.to have_many(:integration_collection_mappings).class_name("IntegrationCollectionMappings::BaseCollectionMapping").dependent(:destroy) } + it { is_expected.to have_many(:integration_mappings).class_name("IntegrationMappings::BaseMapping").dependent(:destroy) } + + it { is_expected.to have_many(:subscriptions).through(:customers) } + it { is_expected.to have_many(:wallets).through(:customers) } + it { is_expected.to have_many(:wallet_transactions).through(:wallets) } + it { is_expected.to have_many(:credit_notes).through(:invoices) } + it { is_expected.to have_many(:selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } + it { is_expected.to have_many(:manual_selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } + it { is_expected.to have_many(:system_generated_selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } + + it { is_expected.to have_many(:applied_taxes).dependent(:destroy) } + it { is_expected.to have_many(:taxes).through(:applied_taxes) } + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + describe "code validation" do + let(:organization) { create :organization } + + it "validates uniqueness of organization_id for code excluding deleted and archived records" do + record_1 = create(:billing_entity, organization: organization) + expect(record_1).to be_valid + + record_2 = build(:billing_entity, organization: organization, code: record_1.code) + expect(record_2).not_to be_valid + expect(record_2.errors[:code]).to include("value_already_exist") + + record_3 = create(:billing_entity, code: record_1.code) + expect(record_3).to be_valid + + record_1.discard! + record_4 = build(:billing_entity, organization: organization, code: record_1.code) + expect(record_4).to be_valid + + record_1.undiscard! + record_1.update(archived_at: Time.current) + record_5 = build(:billing_entity, organization: organization, code: record_1.code) + expect(record_5).to be_valid + end + end + + describe "Scopes" do + let(:active_billing_entity_1) { create(:billing_entity, created_at: 1.week.ago) } + let(:active_billing_entity_2) { create(:billing_entity, created_at: 2.weeks.ago) } + let(:archived_billing_entity) { create(:billing_entity, :archived) } + let(:deleted_billing_entity) { create(:billing_entity, :deleted) } + + before do + active_billing_entity_1 + active_billing_entity_2 + archived_billing_entity + deleted_billing_entity + end + + describe ".active" do + it "returns active billing entities ordered" do + expect(described_class.active).to eq [active_billing_entity_2, active_billing_entity_1] + end + end + end + + describe "Validations" do + let(:billing_entity) { build(:billing_entity) } + + it "is valid with valid attributes" do + expect(billing_entity).to be_valid + end + + it { is_expected.to validate_length_of(:document_number_prefix).is_at_least(1).is_at_most(10).on(:update) } + + it { is_expected.to allow_value(nil).for(:document_number_prefix).on(:create) } + it { is_expected.to validate_length_of(:document_number_prefix).is_at_least(1).is_at_most(10).on(:create) } + + it "is not valid without name" do + billing_entity.name = nil + expect(billing_entity).not_to be_valid + end + + it "is invalid with invalid email" do + billing_entity.email = "foo.bar" + expect(billing_entity).not_to be_valid + end + + it "is invalid with invalid country" do + billing_entity.country = "ZWX" + expect(billing_entity).not_to be_valid + + billing_entity.country = "" + expect(billing_entity).not_to be_valid + end + + it "validates the language code" do + billing_entity.document_locale = nil + expect(billing_entity).not_to be_valid + + billing_entity.document_locale = "en" + expect(billing_entity).to be_valid + + billing_entity.document_locale = "foo" + expect(billing_entity).not_to be_valid + + billing_entity.document_locale = "" + expect(billing_entity).not_to be_valid + end + + it "is invalid with invalid invoice footer" do + billing_entity.invoice_footer = SecureRandom.alphanumeric(601) + expect(billing_entity).not_to be_valid + end + + it "is valid with logo" do + billing_entity.logo.attach( + io: File.open(Rails.root.join("spec/factories/images/logo.png")), + content_type: "image/png", + filename: "logo" + ) + expect(billing_entity).to be_valid + end + + it "is invalid with too big logo" do + billing_entity.logo.attach( + io: File.open(Rails.root.join("spec/factories/images/big_sized_logo.jpg")), + content_type: "image/jpeg", + filename: "logo" + ) + expect(billing_entity).not_to be_valid + end + + it "is invalid with unsupported logo content type" do + billing_entity.logo.attach( + io: File.open(Rails.root.join("spec/factories/images/logo.gif")), + content_type: "image/gif", + filename: "logo" + ) + expect(billing_entity).not_to be_valid + end + + it "is invalid with invalid timezone" do + billing_entity.timezone = "foo" + expect(billing_entity).not_to be_valid + end + + it "is valid with email_settings" do + billing_entity.email_settings = ["invoice.finalized", "credit_note.created"] + expect(billing_entity).to be_valid + end + + it "is invalid with non permitted email_settings value" do + billing_entity.email_settings = ["email.not_permitted"] + + expect(billing_entity).not_to be_valid + expect(billing_entity.errors.first.attribute).to eq(:email_settings) + expect(billing_entity.errors.first.type).to eq(:unsupported_value) + end + + it "dont allow finalize_zero_amount_invoice with null value" do + expect(billing_entity.finalize_zero_amount_invoice).to eq true + billing_entity.finalize_zero_amount_invoice = nil + + expect(billing_entity).not_to be_valid + end + + it "validates subscription_invoice_issuing_date_anchor" do + billing_entity.subscription_invoice_issuing_date_anchor = nil + expect(billing_entity).not_to be_valid + + billing_entity.subscription_invoice_issuing_date_anchor = "invalid" + expect(billing_entity).not_to be_valid + + billing_entity.subscription_invoice_issuing_date_anchor = "current_period_end" + expect(billing_entity).to be_valid + + billing_entity.subscription_invoice_issuing_date_anchor = "next_period_start" + expect(billing_entity).to be_valid + end + + it "validates subscription_invoice_issuing_date_adjustments" do + billing_entity.subscription_invoice_issuing_date_adjustment = nil + expect(billing_entity).not_to be_valid + + billing_entity.subscription_invoice_issuing_date_adjustment = "invalid" + expect(billing_entity).not_to be_valid + + billing_entity.subscription_invoice_issuing_date_adjustment = "keep_anchor" + expect(billing_entity).to be_valid + + billing_entity.subscription_invoice_issuing_date_adjustment = "align_with_finalization_date" + expect(billing_entity).to be_valid + end + end + + context "when validate einvoicing" do + let(:einvoicing) { true } + let(:country) { "FR" } + + before do + billing_entity.einvoicing = einvoicing + billing_entity.country = country + end + + context "without country" do + let(:country) { nil } + + it "is not valid" do + expect(billing_entity).not_to be_valid + expect(billing_entity.errors.first.attribute).to eq(:einvoicing) + expect(billing_entity.errors.first.type).to eq(:country_must_be_present) + end + end + + context "with an unsupported country" do + let(:country) { "BR" } + + it "is not valid" do + expect(billing_entity).not_to be_valid + expect(billing_entity.errors.first.attribute).to eq(:einvoicing) + expect(billing_entity.errors.first.type).to eq(:country_not_supported) + end + end + + context "with a supported country" do + let(:country) { "fr" } + + it "is not valid" do + expect(billing_entity).to be_valid + end + end + + context "when einvoincing is false" do + let(:einvoicing) { false } + let(:country) { "BR" } + + it "succeeds" do + expect(billing_entity).to be_valid + end + end + end + + describe "#save" do + subject { billing_entity.save! } + + context "with a new record" do + let(:billing_entity) { build(:billing_entity) } + + it "sets document number prefix of billing_entity" do + subject + + expect(billing_entity.document_number_prefix) + .to eq "#{billing_entity.name.first(3).upcase}-#{billing_entity.id.last(4).upcase}" + end + + context "when document number prefix is already set" do + it "does not change existing document number prefix of billing_entity" do + billing_entity.document_number_prefix = "ABC-1234" + subject + + expect(billing_entity.document_number_prefix).to eq "ABC-1234" + end + end + end + + context "with a persisted record" do + let(:billing_entity) { create(:billing_entity) } + + it "does not change document number prefix of billing_entity" do + expect { subject }.not_to change(billing_entity, :document_number_prefix) + end + end + end + + describe "#country=" do + it "upcases country" do + billing_entity.country = "us" + + expect(billing_entity.country).to eq "US" + end + end + + describe "#document_number_prefix=" do + it "upcases the value" do + billing_entity.document_number_prefix = "abc-1234" + expect(billing_entity.document_number_prefix).to eq "ABC-1234" + end + end + + describe "#logo_url" do + it "returns the url of the logo saved locally" do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + billing_entity.logo.attach( + io: StringIO.new(logo_file), + filename: "logo", + content_type: "image/png" + ) + billing_entity.save! + expect(billing_entity.logo_url).to include("rails/active_storage/blobs") + end + end + + describe "#base64_logo" do + it "returns the base64 encoded logo" do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + billing_entity.logo.attach( + io: StringIO.new(logo_file), + filename: "logo", + content_type: "image/png" + ) + billing_entity.save! + expect(billing_entity.base64_logo).to eq Base64.encode64(logo_file) + end + end + + describe "#eu_vat_eligible?" do + context "when country is nil" do + it "returns false" do + billing_entity.country = nil + expect(billing_entity).not_to be_eu_vat_eligible + end + end + + context "when country is not in the EU" do + it "returns false" do + billing_entity.country = "US" + expect(billing_entity).not_to be_eu_vat_eligible + end + end + + context "when country is in the EU" do + it "returns true" do + billing_entity.country = "FR" + expect(billing_entity).to be_eu_vat_eligible + end + end + end + + describe "#from_email_address" do + subject(:from_email_address) { billing_entity.from_email_address } + + it "returns the env var email" do + expect(from_email_address).to eq("noreply@getlago.com") + end + + context "when organization from_email integration is enabled", :premium do + let(:organization) { create(:organization, premium_integrations: ["from_email"]) } + let(:billing_entity) { build(:billing_entity, organization:) } + + it "returns the billing_entity email" do + expect(from_email_address).to eq(billing_entity.email) + end + end + end + + describe "#reset_customers_last_dunning_campaign_attempt" do + let(:last_dunning_campaign_attempt_at) { 1.day.ago } + let(:campaign) { create(:dunning_campaign, organization: billing_entity.organization) } + + it "resets the last dunning campaign attempt for customers with fallback dunning_campaign" do + customer1 = create(:customer, billing_entity:, last_dunning_campaign_attempt: 1, last_dunning_campaign_attempt_at:) + customer2 = create(:customer, billing_entity:, last_dunning_campaign_attempt: 1, last_dunning_campaign_attempt_at:, applied_dunning_campaign: campaign) + + expect { billing_entity.reset_customers_last_dunning_campaign_attempt } + .to change { customer1.reload.last_dunning_campaign_attempt }.from(1).to(0) + .and change(customer1, :last_dunning_campaign_attempt_at).from(last_dunning_campaign_attempt_at).to(nil) + expect(customer2.reload.last_dunning_campaign_attempt).to eq(1) + end + end +end diff --git a/spec/models/billing_period_boundaries_spec.rb b/spec/models/billing_period_boundaries_spec.rb new file mode 100644 index 0000000..bffabc2 --- /dev/null +++ b/spec/models/billing_period_boundaries_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingPeriodBoundaries do + subject(:boundaries) do + described_class.new( + from_datetime:, + to_datetime:, + charges_from_datetime:, + charges_to_datetime:, + charges_duration:, + fixed_charges_from_datetime:, + fixed_charges_to_datetime:, + fixed_charges_duration:, + timestamp: + ) + end + + let(:from_datetime) { timestamp.beginning_of_month } + let(:to_datetime) { timestamp.end_of_month } + let(:charges_from_datetime) { (timestamp - 1.month).beginning_of_month } + let(:charges_to_datetime) { (timestamp - 1.month).end_of_month } + let(:timestamp) { Time.current } + let(:charges_duration) { charges_to_datetime - charges_from_datetime } + let(:fixed_charges_from_datetime) { (timestamp - 2.months).beginning_of_month } + let(:fixed_charges_to_datetime) { (timestamp - 2.months).end_of_month } + let(:fixed_charges_duration) { fixed_charges_to_datetime - fixed_charges_from_datetime } + + describe "#to_h" do + it "returns a hash with the boundaries" do + expect(boundaries.to_h).to eq( + "from_datetime" => from_datetime, + "to_datetime" => to_datetime, + "charges_from_datetime" => charges_from_datetime, + "charges_to_datetime" => charges_to_datetime, + "timestamp" => timestamp, + "charges_duration" => charges_duration, + "fixed_charges_from_datetime" => fixed_charges_from_datetime, + "fixed_charges_to_datetime" => fixed_charges_to_datetime, + "fixed_charges_duration" => fixed_charges_duration + ) + end + end + + describe ".from_fee" do + let(:fee) { build(:charge_fee) } + + it "returns a BillingPeriodBoundaries instance" do + instance = described_class.from_fee(fee) + + expect(instance).to be_a(described_class) + expect(instance.from_datetime).to eq(fee.properties["from_datetime"]) + expect(instance.to_datetime).to eq(fee.properties["to_datetime"]) + expect(instance.charges_from_datetime).to eq(fee.properties["charges_from_datetime"]) + expect(instance.charges_to_datetime).to eq(fee.properties["charges_to_datetime"]) + expect(instance.charges_duration).to eq(fee.properties["charges_duration"]) + expect(instance.timestamp).to eq(fee.properties["timestamp"]) + end + + context "when fee belongs to a fixed charge" do + let(:fee) { build(:fixed_charge_fee) } + + it "returns a BillingPeriodBoundaries instance" do + instance = described_class.from_fee(fee) + + expect(instance).to be_a(described_class) + expect(instance.from_datetime).to eq(fee.properties["from_datetime"]) + expect(instance.to_datetime).to eq(fee.properties["to_datetime"]) + expect(instance.charges_from_datetime).to eq(fee.properties["charges_from_datetime"]) + expect(instance.charges_to_datetime).to eq(fee.properties["charges_to_datetime"]) + expect(instance.charges_duration).to eq(fee.properties["charges_duration"]) + expect(instance.timestamp).to eq(fee.properties["timestamp"]) + expect(instance.fixed_charges_from_datetime).to eq(fee.properties["fixed_charges_from_datetime"]) + expect(instance.fixed_charges_to_datetime).to eq(fee.properties["fixed_charges_to_datetime"]) + expect(instance.fixed_charges_duration).to eq(fee.properties["fixed_charges_duration"]) + end + end + end +end diff --git a/spec/models/charge/applied_tax_spec.rb b/spec/models/charge/applied_tax_spec.rb new file mode 100644 index 0000000..f892078 --- /dev/null +++ b/spec/models/charge/applied_tax_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.describe Charge::AppliedTax do + subject(:charge_applied_tax) { create(:charge_applied_tax) } + + it { is_expected.to belong_to(:charge) } + it { is_expected.to belong_to(:tax) } + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/charge_filter_spec.rb b/spec/models/charge_filter_spec.rb new file mode 100644 index 0000000..6679427 --- /dev/null +++ b/spec/models/charge_filter_spec.rb @@ -0,0 +1,459 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilter do + subject(:charge_filter) { build(:charge_filter) } + + it_behaves_like "paper_trail traceable" + + describe "associations" do + it do + expect(subject).to belong_to(:charge) + expect(subject).to belong_to(:organization) + expect(subject).to have_many(:values).dependent(:destroy) + expect(subject).to have_many(:fees) + end + end + + describe "#validate_properties" do + subject(:charge_filter) { build(:charge_filter, charge:, properties: charge_properties) } + + let(:charge) do + build(:standard_charge) + end + + context "when charge model is standard" do + let(:charge_properties) { [{"foo" => "bar"}] } + let(:validation_service) { instance_double(Charges::Validators::StandardService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::StandardService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + + expect(Charges::Validators::StandardService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + end + + context "when charge model is graduated" do + let(:charge) { build(:graduated_charge) } + let(:charge_properties) { {graduated_ranges: [{"foo" => "bar"}]} } + + let(:validation_service) { instance_double(Charges::Validators::GraduatedService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + per_unit_amount: ["invalid_amount"], + flat_amount: ["invalid_amount"], + graduated_ranges: ["missing_graduated_ranges"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::GraduatedService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + expect(charge.errors.messages[:properties]).to include("missing_graduated_ranges") + + expect(Charges::Validators::GraduatedService).to have_received(:new) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + end + + context "when charge model is package" do + let(:charge) do + build(:package_charge, properties: charge_properties) + end + + let(:charge_properties) { [{"foo" => "bar"}] } + let(:validation_service) { instance_double(Charges::Validators::PackageService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"], + free_units: ["invalid_free_units"], + package_size: ["invalid_package_size"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::PackageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + expect(charge.errors.messages[:properties]).to include("invalid_free_units") + expect(charge.errors.messages[:properties]).to include("invalid_package_size") + + expect(Charges::Validators::PackageService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not package" do + let(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::PackageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::PackageService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + context "when charge model is percentage" do + let(:charge) { build(:percentage_charge, properties: charge_properties) } + + let(:charge_properties) { [{"foo" => "bar"}] } + let(:validation_service) { instance_double(Charges::Validators::PercentageService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_fixed_amount"], + free_units_per_events: ["invalid_free_units_per_events"], + free_units_per_total_aggregation: ["invalid_free_units_per_total_aggregation"], + rate: ["invalid_rate"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::PercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_rate") + expect(charge.errors.messages[:properties]).to include("invalid_fixed_amount") + expect(charge.errors.messages[:properties]).to include("invalid_free_units_per_events") + expect(charge.errors.messages[:properties]).to include("invalid_free_units_per_total_aggregation") + + expect(Charges::Validators::PercentageService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not percentage" do + let(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::PercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + charge.valid? + + expect(Charges::Validators::PercentageService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + context "when charge model is volume" do + let(:charge) do + build(:volume_charge, properties: charge_properties) + end + + let(:charge_properties) { {volume_ranges: [{"foo" => "bar"}]} } + let(:validation_service) { instance_double(Charges::Validators::VolumeService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"], + volume_ranges: ["invalid_volume_ranges"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::VolumeService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + expect(charge.errors.messages[:properties]).to include("invalid_volume_ranges") + + expect(Charges::Validators::VolumeService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not volume" do + let(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::VolumeService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::VolumeService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + context "when charge model is graduated percentage" do + let(:charge) do + build(:graduated_percentage_charge, properties: charge_properties) + end + + let(:charge_properties) do + {graduated_percentage_ranges: [{"foo" => "bar"}]} + end + let(:validation_service) { instance_double(Charges::Validators::GraduatedPercentageService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + rate: ["invalid_rate"], + ranges: ["invalid_graduated_percentage_ranges"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::GraduatedPercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_rate") + expect(charge.errors.messages[:properties]).to include("invalid_graduated_percentage_ranges") + + expect(Charges::Validators::GraduatedPercentageService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not graduated percentage" do + subject(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::GraduatedPercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::GraduatedPercentageService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + end + + describe "#display_name" do + subject(:charge_filter) { create(:charge_filter, charge:, invoice_display_name:) } + + let(:charge) { create(:standard_charge) } + let(:method_filter) { create(:billable_metric_filter, key: "card", values: %w[card apple_pay]) } + let(:scheme_filter) { create(:billable_metric_filter, key: "card", values: %w[visa mastercard]) } + + let(:invoice_display_name) { Faker::Fantasy::Tolkien.character } + let(:values) do + [ + create(:charge_filter_value, values: ["card"], charge_filter:, billable_metric_filter: method_filter), + create(:charge_filter_value, values: ["visa"], charge_filter:, billable_metric_filter: scheme_filter) + ] + end + + before { values } + + it "returns the invoice display name" do + expect(charge_filter.display_name).to eq(invoice_display_name) + end + + context "when invoice display name is not present" do + let(:invoice_display_name) { nil } + + it "returns the values joined" do + expect(charge_filter.display_name).to eq("card, visa") + end + end + end + + describe "#to_h" do + subject(:charge_filter) { create(:charge_filter) } + + let(:card) { create(:billable_metric_filter, key: "card", values: %w[credit debit]) } + let(:scheme) { create(:billable_metric_filter, key: "scheme", values: %w[visa mastercard]) } + let(:values) do + [ + create(:charge_filter_value, charge_filter:, values: ["credit"], billable_metric_filter: card), + create(:charge_filter_value, charge_filter:, values: ["visa"], billable_metric_filter: scheme) + ] + end + let(:card_filter_value) { values.first } + + before { values } + + it "returns the values as a memoized frozen hash" do + original_values = {"card" => ["credit"], "scheme" => ["visa"]} + expect(charge_filter.to_h).to eq(original_values) + + expect(charge_filter.to_h).to be_frozen + + card_filter_value.update(values: ["debit"]) + charge_filter.values.reload + + expect(charge_filter.to_h).to eq(original_values) + + expect(described_class.find(charge_filter.id).to_h).to eq({ + "card" => ["debit"], + "scheme" => ["visa"] + }) + end + end + + describe "#to_h_with_discarded" do + subject(:charge_filter) { create(:charge_filter) } + + let(:card) { create(:billable_metric_filter, key: "card", values: %w[credit debit]) } + let(:scheme) { create(:billable_metric_filter, key: "scheme", values: %w[visa mastercard]) } + let(:values) do + [ + create(:charge_filter_value, charge_filter:, values: ["credit"], billable_metric_filter: card).tap(&:discard), + create(:charge_filter_value, charge_filter:, values: ["visa"], billable_metric_filter: scheme).tap(&:discard) + ] + end + let(:card_filter_value) { values.first } + + before { values } + + it "returns the values as a hash" do + original_values = {"card" => ["credit"], "scheme" => ["visa"]} + expect(charge_filter.to_h_with_discarded).to eq(original_values) + expect(charge_filter.to_h_with_discarded).to be_frozen + + card_filter_value.update(values: ["debit"]) + charge_filter.values.reload + + expect(charge_filter.to_h_with_discarded).to eq(original_values) + + expect(described_class.find(charge_filter.id).to_h_with_discarded).to eq({ + "card" => ["debit"], + "scheme" => ["visa"] + }) + end + end + + describe "#to_h_with_all_values" do + subject(:charge_filter) { create(:charge_filter, values:) } + + let(:card) { create(:billable_metric_filter, key: "card", values: %w[credit debit]) } + let(:scheme) { create(:billable_metric_filter, key: "scheme", values: %w[visa mastercard]) } + let(:values) do + [ + build(:charge_filter_value, values: ["credit"], billable_metric_filter: card), + build(:charge_filter_value, values: [ChargeFilterValue::ALL_FILTER_VALUES], billable_metric_filter: scheme) + ] + end + let(:card_filter_value) { values.first } + + it "returns all values as a memoized frozen hash" do + original_values = {"card" => ["credit"], "scheme" => %w[visa mastercard]} + expect(charge_filter.to_h_with_all_values).to eq(original_values) + expect(charge_filter.to_h_with_all_values).to be_frozen + + card_filter_value.update(values: ["debit"]) + charge_filter.values.reload + + expect(charge_filter.to_h_with_all_values).to eq(original_values) + + expect(described_class.find(charge_filter.id).to_h_with_all_values).to eq({ + "card" => ["debit"], + "scheme" => %w[visa mastercard] + }) + end + end + + describe "#pricing_group_keys" do + subject(:charge_filter) { build(:charge_filter, properties:) } + + let(:properties) { {"amount_cents" => "1000", :pricing_group_keys => ["user_id"]} } + + it "returns the pricing group keys" do + expect(charge_filter.pricing_group_keys).to eq(["user_id"]) + end + + context "with grouped_by property" do + let(:properties) { {"amount_cents" => "1000", :grouped_by => ["user_id"]} } + + it "returns the pricing group keys" do + expect(charge_filter.pricing_group_keys).to eq(["user_id"]) + end + end + end +end diff --git a/spec/models/charge_filter_value_spec.rb b/spec/models/charge_filter_value_spec.rb new file mode 100644 index 0000000..909f786 --- /dev/null +++ b/spec/models/charge_filter_value_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilterValue do + subject { build(:charge_filter_value) } + + it_behaves_like "paper_trail traceable" + + describe "associations" do + it do + expect(subject).to belong_to(:charge_filter) + expect(subject).to belong_to(:billable_metric_filter) + expect(subject).to belong_to(:organization) + end + end + + describe "validations" do + it do + expect(subject).to validate_presence_of(:values) + end + end + + describe "#valdiate_values" do + subject(:charge_filter_value) do + build(:charge_filter_value, billable_metric_filter:, values:) + end + + let(:billable_metric_filter) { create(:billable_metric_filter) } + let(:values) { [billable_metric_filter.values.first] } + + it { expect(charge_filter_value).to be_valid } + + context "when value is not included in billable_metric_filter values" do + let(:values) { ["invalid_value"] } + + it do + expect(charge_filter_value).to be_invalid + expect(charge_filter_value.errors[:values]).to include("value_is_invalid") + end + end + + context "when values are empty" do + let(:values) { [] } + + it do + expect(charge_filter_value).to be_invalid + expect(charge_filter_value.errors[:values]).to include("value_is_mandatory") + end + end + + context "when value is ALL_FILTER_VALUES" do + let(:values) { [ChargeFilterValue::ALL_FILTER_VALUES] } + + it { expect(charge_filter_value).to be_valid } + end + end +end diff --git a/spec/models/charge_spec.rb b/spec/models/charge_spec.rb new file mode 100644 index 0000000..6cd826a --- /dev/null +++ b/spec/models/charge_spec.rb @@ -0,0 +1,920 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charge do + subject(:charge) { create(:standard_charge) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to validate_presence_of(:code) } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to have_many(:filters).dependent(:destroy) + expect(subject).to have_one(:applied_pricing_unit) + expect(subject).to have_one(:pricing_unit).through(:applied_pricing_unit) + end + end + + describe "#validate_graduated" do + subject(:charge) do + build(:graduated_charge, properties: charge_properties) + end + + let(:charge_properties) do + {graduated_ranges: [{"foo" => "bar"}]} + end + let(:validation_service) { instance_double(Charges::Validators::GraduatedService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"], + ranges: ["invalid_graduated_ranges"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::GraduatedService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + expect(charge.errors.messages[:properties]).to include("invalid_graduated_ranges") + + expect(Charges::Validators::GraduatedService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not graduated" do + subject(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::GraduatedService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::GraduatedService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + describe "#validate_amount" do + subject(:charge) do + build(:standard_charge, properties: charge_properties) + end + + let(:charge_properties) { [{"foo" => "bar"}] } + let(:validation_service) { instance_double(Charges::Validators::StandardService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::StandardService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + + expect(Charges::Validators::StandardService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not graduated" do + subject(:charge) { build(:graduated_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::StandardService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::StandardService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + describe "#validate_package" do + subject(:charge) do + build(:package_charge, properties: charge_properties) + end + + let(:charge_properties) { [{"foo" => "bar"}] } + let(:validation_service) { instance_double(Charges::Validators::PackageService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"], + free_units: ["invalid_free_units"], + package_size: ["invalid_package_size"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::PackageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + expect(charge.errors.messages[:properties]).to include("invalid_free_units") + expect(charge.errors.messages[:properties]).to include("invalid_package_size") + + expect(Charges::Validators::PackageService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not package" do + subject(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::PackageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::PackageService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + describe "#validate_percentage" do + subject(:charge) { build(:percentage_charge, properties: charge_properties) } + + let(:charge_properties) { [{"foo" => "bar"}] } + let(:validation_service) { instance_double(Charges::Validators::PercentageService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_fixed_amount"], + free_units_per_events: ["invalid_free_units_per_events"], + free_units_per_total_aggregation: ["invalid_free_units_per_total_aggregation"], + rate: ["invalid_rate"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::PercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_rate") + expect(charge.errors.messages[:properties]).to include("invalid_fixed_amount") + expect(charge.errors.messages[:properties]).to include("invalid_free_units_per_events") + expect(charge.errors.messages[:properties]).to include("invalid_free_units_per_total_aggregation") + + expect(Charges::Validators::PercentageService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not percentage" do + subject(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::PercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + charge.valid? + + expect(Charges::Validators::PercentageService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + describe "#validate_volume" do + subject(:charge) do + build(:volume_charge, properties: charge_properties) + end + + let(:charge_properties) { {volume_ranges: [{"foo" => "bar"}]} } + let(:validation_service) { instance_double(Charges::Validators::VolumeService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"], + volume_ranges: ["invalid_volume_ranges"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::VolumeService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_amount") + expect(charge.errors.messages[:properties]).to include("invalid_volume_ranges") + + expect(Charges::Validators::VolumeService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not volume" do + subject(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::VolumeService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::VolumeService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + describe "#validate_dynamic" do + subject(:charge) { build(:dynamic_charge, billable_metric:) } + + context "with sum aggregation" do + let(:billable_metric) { create(:sum_billable_metric) } + + it "is valid" do + expect(charge).to be_valid + end + end + + context "with other aggregation" do + let(:billable_metric) { create(:latest_billable_metric) } + + it "is invalid" do + expect(charge).not_to be_valid + expect(charge.errors[:charge_model]).to include("invalid_aggregation_type_or_charge_model") + end + end + end + + describe "#validate_graduated_percentage" do + subject(:charge) do + build(:graduated_percentage_charge, properties: charge_properties) + end + + let(:charge_properties) do + {graduated_percentage_ranges: [{"foo" => "bar"}]} + end + let(:validation_service) { instance_double(Charges::Validators::GraduatedPercentageService) } + + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + rate: ["invalid_rate"], + ranges: ["invalid_graduated_percentage_ranges"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::GraduatedPercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(charge).not_to be_valid + expect(charge.errors.messages.keys).to include(:properties) + expect(charge.errors.messages[:properties]).to include("invalid_rate") + expect(charge.errors.messages[:properties]).to include("invalid_graduated_percentage_ranges") + + expect(Charges::Validators::GraduatedPercentageService).to have_received(:new).with(charge:) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + + context "when charge model is not graduated percentage" do + subject(:charge) { build(:standard_charge) } + + it "does not apply the validation" do + allow(Charges::Validators::GraduatedPercentageService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + charge.valid? + + expect(Charges::Validators::GraduatedPercentageService).not_to have_received(:new) + expect(validation_service).not_to have_received(:valid?) + expect(validation_service).not_to have_received(:result) + end + end + end + + describe "#validate_pay_in_advance" do + it "does not return an error" do + expect(build(:standard_charge)).to be_valid + end + + context "when billable metric is max_agg" do + it "returns an error" do + billable_metric = create(:max_billable_metric) + charge = build(:standard_charge, :pay_in_advance, billable_metric:) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:pay_in_advance]).to include("invalid_aggregation_type_or_charge_model") + end + end + + context "when billable metric is latest_agg" do + it "returns an error" do + billable_metric = create(:latest_billable_metric) + charge = build(:standard_charge, :pay_in_advance, billable_metric:) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:pay_in_advance]).to include("invalid_aggregation_type_or_charge_model") + end + end + + context "when billable metric is weighted_sum_agg" do + it "returns an error" do + billable_metric = create(:weighted_sum_billable_metric) + charge = build(:standard_charge, :pay_in_advance, billable_metric:) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:pay_in_advance]).to include("invalid_aggregation_type_or_charge_model") + end + end + + context "when charge model is volume" do + it "returns an error" do + charge = build(:volume_charge, :pay_in_advance) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:pay_in_advance]).to include("invalid_aggregation_type_or_charge_model") + end + end + end + + describe "#validate_regroup_paid_fees" do + context "when regroup_paid_fees is nil" do + it "does not return an error when" do + expect(build(:standard_charge, pay_in_advance: true, invoiceable: true, regroup_paid_fees: nil)).to be_valid + expect(build(:standard_charge, pay_in_advance: true, invoiceable: false, regroup_paid_fees: nil)).to be_valid + expect(build(:standard_charge, pay_in_advance: false, invoiceable: true, regroup_paid_fees: nil)).to be_valid + end + end + + context "when regroup_paid_fees is `invoice`" do + it "requires charge to be pay_in_advance and non invoiceable" do + expect(build(:standard_charge, pay_in_advance: true, invoiceable: false, regroup_paid_fees: "invoice")).to be_valid + + [ + {pay_in_advance: true, invoiceable: true}, + {pay_in_advance: false, invoiceable: true}, + {pay_in_advance: false, invoiceable: false} + ].each do |params| + charge = build(:standard_charge, regroup_paid_fees: "invoice", **params) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:regroup_paid_fees]).to include("only_compatible_with_pay_in_advance_and_non_invoiceable") + end + end + end + end + + describe "#validate_min_amount_cents" do + it "does not return an error" do + expect(build(:standard_charge)).to be_valid + end + + context "when charge is pay_in_advance" do + it "returns an error" do + charge = build(:standard_charge, :pay_in_advance, min_amount_cents: 1200) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:min_amount_cents]).to include("not_compatible_with_pay_in_advance") + end + end + end + + describe "#validate_prorated" do + let(:billable_metric) { create(:sum_billable_metric, recurring: true) } + + it "does not return error if prorated is false and price model is percentage" do + expect(build(:percentage_charge, prorated: false)).to be_valid + end + + context "when charge is standard, pay_in_advance, prorated but BM is not recurring" do + let(:billable_metric) { create(:billable_metric, recurring: false) } + + it "returns an error" do + charge = build(:standard_charge, :pay_in_advance, prorated: true, billable_metric:) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:prorated]).to include("invalid_billable_metric_or_charge_model") + end + end + + context "when charge is package, pay_in_advance, prorated and BM is recurring" do + it "returns an error" do + charge = build(:package_charge, :pay_in_advance, prorated: true, billable_metric:) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:prorated]).to include("invalid_billable_metric_or_charge_model") + end + end + + context "when charge is percentage, pay_in_arrear, prorated and BM is recurring" do + it "returns an error" do + charge = build(:percentage_charge, prorated: true, billable_metric:) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:prorated]).to include("invalid_billable_metric_or_charge_model") + end + end + + context "when billable metric is weighted sum" do + let(:billable_metric) { create(:weighted_sum_billable_metric) } + + it "returns an error" do + charge = build(:percentage_charge, prorated: true, billable_metric:) + + expect(charge).not_to be_valid + expect(charge.errors.messages[:prorated]).to include("invalid_billable_metric_or_charge_model") + end + end + end + + describe "#validate_custom_model" do + subject(:charge) { build(:charge, billable_metric:, charge_model: "custom") } + + let(:billable_metric) { create(:billable_metric, aggregation_type: :count_agg) } + + it "returns an error for invalid metric type" do + expect(charge).not_to be_valid + expect(charge.errors.messages[:charge_model]).to include("invalid_aggregation_type_or_charge_model") + end + end + + describe "#pricing_group_keys" do + subject(:charge) { build(:standard_charge, properties:) } + + let(:properties) { {"amount_cents" => "1000", :pricing_group_keys => ["user_id"]} } + + it "returns the pricing group keys" do + expect(charge.pricing_group_keys).to eq(["user_id"]) + end + + context "with grouped_by property" do + let(:properties) { {"amount_cents" => "1000", :grouped_by => ["user_id"]} } + + it "returns the pricing group keys" do + expect(charge.pricing_group_keys).to eq(["user_id"]) + end + end + end + + describe "#presentation_group_keys" do + subject(:charge) { build(:standard_charge, properties:) } + + context "when presentation_group_keys is present" do + let(:properties) { {"amount_cents" => "1000", "presentation_group_keys" => [{"value" => "region"}]} } + + it "returns the presentation group keys" do + expect(charge.presentation_group_keys).to eq([{"value" => "region"}]) + end + end + + context "when presentation_group_keys is blank" do + let(:properties) { {"amount_cents" => "1000"} } + + it "returns nil" do + expect(charge.presentation_group_keys).to be_nil + end + end + + context "when presentation_group_keys is an empty array" do + let(:properties) { {"amount_cents" => "1000", "presentation_group_keys" => []} } + + it "returns nil" do + expect(charge.presentation_group_keys).to be_nil + end + end + end + + describe "#presentation_group_keys_values" do + subject(:charge) { build(:standard_charge, properties:) } + + context "when presentation_group_keys is blank" do + let(:properties) { {"amount_cents" => "1000"} } + + it "returns an empty array" do + expect(charge.presentation_group_keys_values).to eq([]) + end + end + + context "when presentation_group_keys is nil" do + let(:properties) { {"amount_cents" => "1000", "presentation_group_keys" => nil} } + + it "returns an empty array" do + expect(charge.presentation_group_keys_values).to eq([]) + end + end + + context "when presentation_group_keys is an empty array" do + let(:properties) { {"amount_cents" => "1000", "presentation_group_keys" => []} } + + it "returns an empty array" do + expect(charge.presentation_group_keys_values).to eq([]) + end + end + + context "when presentation_group_keys has one element with value" do + let(:properties) { {"amount_cents" => "1000", "presentation_group_keys" => [{"value" => "region"}]} } + + it "returns array with the value" do + expect(charge.presentation_group_keys_values).to eq(["region"]) + end + end + + context "when presentation_group_keys has multiple elements with values" do + let(:properties) do + { + "amount_cents" => "1000", + "presentation_group_keys" => [ + {"value" => "region"}, + {"value" => "country"} + ] + } + end + + it "returns array with all values" do + expect(charge.presentation_group_keys_values).to eq(["region", "country"]) + end + end + + context "when presentation_group_keys has elements with nil values" do + let(:properties) do + { + "amount_cents" => "1000", + "presentation_group_keys" => [ + {"value" => "region"}, + {"value" => nil} + ] + } + end + + it "returns array with only non-nil values" do + expect(charge.presentation_group_keys_values).to eq(["region"]) + end + end + end + + describe "#equal_properties?" do + let(:charge1) { build(:standard_charge, properties: {amount: 100}) } + + context "when charge model is not the same" do + let(:charge2) { build(:percentage_charge) } + + it "returns false" do + expect(charge1.equal_properties?(charge2)).to eq(false) + end + end + + context "when charge model is the same and properties are different" do + let(:charge2) { build(:standard_charge, properties: {amount: 200}) } + + it "returns false if properties are not the same" do + expect(charge1.equal_properties?(charge2)).to eq(false) + end + end + + context "when charge model and properties are the same" do + let(:charge2) { build(:standard_charge, properties: {amount: 100}) } + + it "returns true if both charge model and properties are the same" do + expect(charge1.equal_properties?(charge2)).to eq(true) + end + end + end + + describe "#included_in_next_subscription?" do + subject { charge.included_in_next_subscription?(subscription) } + + let(:charge) { create(:standard_charge) } + let(:subscription) { create(:subscription, next_subscriptions:) } + + context "when subscription has next subscription" do + let(:next_subscriptions) { [create(:subscription, plan: next_plan)] } + let(:next_plan) { build(:plan, charges:) } + + context "when next subscription's plan has charges" do + let(:charges) { [create(:standard_charge, billable_metric:)] } + + context "when next plan charges includes charge billable metric" do + let(:billable_metric) { charge.billable_metric } + + it "returns true" do + expect(subject).to be true + end + end + + context "when next plan charges does not include charge billable metric" do + let(:billable_metric) { create(:billable_metric) } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when next subscription's plan has no charges" do + let(:charges) { [] } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when subscription has no next subscription" do + let(:next_subscriptions) { [] } + + it "returns false" do + expect(subject).to be false + end + end + end + + describe "validations" do + subject { charge.valid? } + + describe "code" do + it "validates uniqueness scoped to plan_id for parent charges" do + existing_charge = create(:standard_charge, code: "my_code") + new_charge = build(:standard_charge, code: "my_code", plan: existing_charge.plan) + expect(new_charge).not_to be_valid + expect(new_charge.errors[:code]).to include("value_already_exist") + end + + it "allows same code on different plans" do + create(:standard_charge, code: "my_code") + new_charge = build(:standard_charge, code: "my_code") + expect(new_charge).to be_valid + end + + it "allows same code on soft-deleted charges" do + existing_charge = create(:standard_charge, code: "my_code") + existing_charge.discard + new_charge = build(:standard_charge, code: "my_code", plan: existing_charge.plan) + expect(new_charge).to be_valid + end + + it "allows same code for child charges" do + parent_charge = create(:standard_charge, code: "my_code") + child_charge = build(:standard_charge, code: "my_code", plan: parent_charge.plan, parent: parent_charge) + expect(child_charge).to be_valid + end + end + + describe "#validate_invoiceable_unless_pay_in_advance" do + let(:charge) { build_stubbed(:standard_charge, pay_in_advance:, invoiceable:) } + + context "when pay_in_advance is true" do + let(:pay_in_advance) { true } + + context "with invoiceable set to true" do + let(:invoiceable) { true } + + it "is valid" do + expect(subject).to be true + end + end + + context "with invoiceable set to false" do + let(:invoiceable) { false } + + it "is valid" do + expect(subject).to be true + end + end + end + + context "when pay_in_advance is false" do + let(:pay_in_advance) { false } + + context "with invoiceable set to true" do + let(:invoiceable) { true } + + it "is valid" do + expect(subject).to be true + end + end + + context "with invoiceable set to false" do + let(:invoiceable) { false } + + it "is invalid" do + expect(subject).to be false + expect(charge.errors[:invoiceable]).to include("must_be_true_unless_pay_in_advance") + end + end + end + end + + describe "of charge model" do + let(:error) { charge.errors.where(:charge_model, :graduated_percentage_requires_premium_license) } + let(:charge) { build_stubbed(:charge, charge_model:, properties:) } + let(:properties) { attributes_for("#{charge_model}_charge")[:properties] } + + context "when premium", :premium do + before { subject } + + context "when charge model is graduated percentage" do + let(:charge_model) { :graduated_percentage } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when charge model is non graduated percentage" do + let(:charge_model) { described_class::CHARGE_MODELS.excluding(:graduated_percentage).sample } + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + + context "when freemium" do + before { subject } + + context "when charge model is graduated percentage" do + let(:charge_model) { :graduated_percentage } + + it "adds an error" do + expect(error).to be_present + end + end + + context "when charge model is non graduated percentage" do + let(:charge_model) { described_class::CHARGE_MODELS.excluding(:graduated_percentage).sample } + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + end + end + + describe "#validate_accepts_target_wallet" do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + + context "when accepts_target_wallet is false" do + it "is valid" do + charge = build(:standard_charge, plan:, billable_metric:, accepts_target_wallet: false) + expect(charge).to be_valid + end + end + + context "when accepts_target_wallet is true" do + context "when feature is not enabled" do + it "returns an error" do + charge = build(:standard_charge, plan:, billable_metric:, accepts_target_wallet: true) + + expect(charge).not_to be_valid + expect(charge.errors[:accepts_target_wallet]).to include("feature_unavailable") + end + end + + context "when premium license is active but integration is not enabled", :premium do + it "returns an error" do + charge = build(:standard_charge, plan:, billable_metric:, accepts_target_wallet: true) + + expect(charge).not_to be_valid + expect(charge.errors[:accepts_target_wallet]).to include("feature_unavailable") + end + end + + context "when feature is enabled", :premium do + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "is valid" do + charge = build(:standard_charge, plan:, billable_metric:, accepts_target_wallet: true) + expect(charge).to be_valid + end + end + end + end + + describe "#equal_applied_pricing_unit_rate?" do + subject { charge.equal_applied_pricing_unit_rate?(another_charge) } + + let(:charge) { build(:standard_charge, applied_pricing_unit:) } + + let(:another_charge) do + build( + :standard_charge, + applied_pricing_unit: build(:applied_pricing_unit) + ) + end + + context "when has associated applied pricing unit" do + let(:applied_pricing_unit) { build(:applied_pricing_unit, conversion_rate:) } + + context "when charges conversion rate is equal" do + let(:conversion_rate) { another_charge.applied_pricing_unit.conversion_rate } + + it "returns true" do + expect(subject).to be true + end + end + + context "when charges conversion rate is not equal" do + let(:conversion_rate) { another_charge.applied_pricing_unit.conversion_rate - 0.5 } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when has no associated applied pricing unit" do + let(:applied_pricing_unit) { nil } + + it "returns false" do + expect(subject).to be false + end + end + end +end diff --git a/spec/models/clickhouse/activity_log_spec.rb b/spec/models/clickhouse/activity_log_spec.rb new file mode 100644 index 0000000..ef888ed --- /dev/null +++ b/spec/models/clickhouse/activity_log_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clickhouse::ActivityLog, clickhouse: true do + subject(:activity_log) { create(:clickhouse_activity_log) } + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:resource) } + it { is_expected.to belong_to(:customer).optional } + it { is_expected.to belong_to(:subscription).optional } + it { is_expected.to belong_to(:user).optional } + it { is_expected.to belong_to(:api_key).optional } + + describe "#ensure_activity_id" do + it "sets the activity_id if it is not set" do + expect(activity_log.activity_id).to be_present + end + end +end diff --git a/spec/models/clickhouse/api_log_spec.rb b/spec/models/clickhouse/api_log_spec.rb new file mode 100644 index 0000000..38a66c3 --- /dev/null +++ b/spec/models/clickhouse/api_log_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clickhouse::ApiLog, clickhouse: true do + subject(:api_log) { create(:clickhouse_api_log) } + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:api_key) } + + describe "#ensure_request_id" do + it "sets the request_id if it is not set" do + expect(api_log.request_id).to be_present + end + end +end diff --git a/spec/models/clickhouse/events_dead_letter_spec.rb b/spec/models/clickhouse/events_dead_letter_spec.rb new file mode 100644 index 0000000..ef9ae5a --- /dev/null +++ b/spec/models/clickhouse/events_dead_letter_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clickhouse::EventsDeadLetter, clickhouse: true do + subject(:event_dead_letter) { create(:clickhouse_events_dead_letter) } + + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/clickhouse/security_log_spec.rb b/spec/models/clickhouse/security_log_spec.rb new file mode 100644 index 0000000..3edd7c5 --- /dev/null +++ b/spec/models/clickhouse/security_log_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Clickhouse::SecurityLog, clickhouse: true do + subject(:security_log) { create(:clickhouse_security_log) } + + describe "associations" do + it do + expect(security_log).to belong_to(:organization) + expect(security_log).to belong_to(:user).optional + expect(security_log).to belong_to(:api_key).optional + end + end + + describe "#ensure_log_id" do + it "sets the log_id if it is not set" do + expect(security_log.log_id).to be_present + end + end + + describe "#resources" do + subject(:security_log) do + create(:clickhouse_security_log, resources: { + "email" => "test@example.com", + "roles" => '["admin", "finance"]', + "name" => '{"deleted":"A","added":"B"}' + }) + end + + it "deserializes nested JSON string values" do + log = described_class.find_by(log_id: security_log.log_id) + + expect(log.resources).to eq({ + "email" => "test@example.com", + "roles" => %w[admin finance], + "name" => {"deleted" => "A", "added" => "B"} + }) + end + end + + describe "#device_info" do + subject(:security_log) do + create(:clickhouse_security_log, device_info: { + "browser" => "Chrome", + "resolution" => '{"width":1920,"height":1080}' + }) + end + + it "deserializes nested JSON string values" do + log = described_class.find_by(log_id: security_log.log_id) + + expect(log.device_info).to eq({ + "browser" => "Chrome", + "resolution" => {"width" => 1920, "height" => 1080} + }) + end + end +end diff --git a/spec/models/commitment/applied_tax_spec.rb b/spec/models/commitment/applied_tax_spec.rb new file mode 100644 index 0000000..c35e16d --- /dev/null +++ b/spec/models/commitment/applied_tax_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitment::AppliedTax do + it { is_expected.to belong_to(:commitment) } + it { is_expected.to belong_to(:tax) } + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/commitment_spec.rb b/spec/models/commitment_spec.rb new file mode 100644 index 0000000..417a577 --- /dev/null +++ b/spec/models/commitment_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitment do + it { is_expected.to belong_to(:plan) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:applied_taxes).dependent(:destroy) } + it { is_expected.to have_many(:taxes) } + + it { is_expected.to validate_numericality_of(:amount_cents) } + + describe "#invoice_name" do + subject(:commitment_invoice_name) { commitment.invoice_name } + + context "when invoice display name is blank" do + let(:commitment) { build_stubbed(:commitment, invoice_display_name: [nil, ""].sample) } + + it "returns name" do + expect(commitment_invoice_name).to eq("Minimum commitment") + end + end + + context "when invoice display name is present" do + let(:commitment) { build_stubbed(:commitment) } + + it "returns invoice display name" do + expect(commitment_invoice_name).to eq(commitment.invoice_display_name) + end + end + end + + describe "validations" do + subject(:commitment) { build(:commitment) } + + describe "of commitment type uniqueness" do + let(:errors) { commitment.errors } + + context "when it is unique in scope of plan" do + it "does not add an error" do + expect(errors.where(:commitment_type, :taken)).not_to be_present + end + end + + context "when it not is unique in scope of plan" do + subject(:commitment) do + build(:commitment, plan:) + end + + let(:plan) { create(:plan) } + let(:errors) { commitment.errors } + + before do + create(:commitment, plan:) + commitment.valid? + end + + it "adds an error" do + expect(errors.where(:commitment_type, :taken)).to be_present + end + end + end + end +end diff --git a/spec/models/concerns/has_feature_flags_spec.rb b/spec/models/concerns/has_feature_flags_spec.rb new file mode 100644 index 0000000..6a10db7 --- /dev/null +++ b/spec/models/concerns/has_feature_flags_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe HasFeatureFlags do + subject(:organization) { create(:organization, feature_flags: []) } + + let(:valid_flag) { FeatureFlag::DEFINITION.keys.first } + + before do + skip "No feature flags defined" if FeatureFlag::DEFINITION.empty? + end + + describe "#feature_flag_enabled?" do + it "returns false when flag is not in the list" do + expect(organization.feature_flag_enabled?(valid_flag)).to be false + end + + it "returns true when flag is in the list and definition" do + organization.update!(feature_flags: [valid_flag]) + expect(organization.feature_flag_enabled?(valid_flag)).to be true + end + + context "when flag is in the list but not in definition" do + it "raises error in non-production environment" do + organization.update_column(:feature_flags, ["unknown_flag"]) # rubocop:disable Rails/SkipsModelValidations + expect { organization.feature_flag_enabled?("unknown_flag") } + .to raise_error(ArgumentError, "Unknown feature flag: unknown_flag") + end + + it "is a no-op in production environment" do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production")) + organization.update_column(:feature_flags, ["unknown_flag"]) # rubocop:disable Rails/SkipsModelValidations + expect(organization.feature_flag_enabled?("unknown_flag")).to be false + end + end + + it "accepts symbol as flag name" do + organization.update!(feature_flags: [valid_flag]) + expect(organization.feature_flag_enabled?(valid_flag.to_sym)).to be true + end + end + + describe "#feature_flag_disabled?" do + it "returns true when flag is not enabled" do + expect(organization.feature_flag_disabled?(valid_flag)).to be true + end + + it "returns false when flag is enabled" do + organization.update!(feature_flags: [valid_flag]) + expect(organization.feature_flag_disabled?(valid_flag)).to be false + end + end + + describe "#enable_feature_flag!" do + it "adds the flag to the list" do + expect { organization.enable_feature_flag!(valid_flag) } + .to change { organization.reload.feature_flags } + .from([]).to([valid_flag]) + end + + it "does not duplicate flags" do + organization.update!(feature_flags: [valid_flag]) + expect { organization.enable_feature_flag!(valid_flag) } + .not_to change { organization.reload.feature_flags } + end + + it "accepts symbol as flag name" do + organization.enable_feature_flag!(valid_flag.to_sym) + expect(organization.reload.feature_flags).to eq([valid_flag]) + end + + context "when flag is not in definition" do + it "raises error in non-production environment" do + expect { organization.enable_feature_flag!("unknown_flag") } + .to raise_error(ArgumentError, "Unknown feature flag: unknown_flag") + end + + it "is a no-op in production environment" do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production")) + expect { organization.enable_feature_flag!("unknown_flag") } + .not_to change { organization.reload.feature_flags } + end + end + end + + describe "#disable_feature_flag!" do + before { organization.update!(feature_flags: [valid_flag]) } + + it "removes the flag from the list" do + expect { organization.disable_feature_flag!(valid_flag) } + .to change { organization.reload.feature_flags } + .from([valid_flag]).to([]) + end + + it "does nothing if flag is not in the list" do + organization.update!(feature_flags: []) + expect { organization.disable_feature_flag!(valid_flag) } + .not_to change { organization.reload.feature_flags } + end + + it "accepts symbol as flag name" do + organization.disable_feature_flag!(valid_flag.to_sym) + expect(organization.reload.feature_flags).to eq([]) + end + + context "when flag is not in definition" do + it "raises error in non-production environment" do + expect { organization.disable_feature_flag!("unknown_flag") } + .to raise_error(ArgumentError, "Unknown feature flag: unknown_flag") + end + + it "is a no-op in production environment" do + allow(Rails).to receive(:env).and_return(ActiveSupport::StringInquirer.new("production")) + expect { organization.disable_feature_flag!("unknown_flag") } + .not_to change { organization.reload.feature_flags } + end + end + end +end diff --git a/spec/models/concerns/organizations/authentication_methods_spec.rb b/spec/models/concerns/organizations/authentication_methods_spec.rb new file mode 100644 index 0000000..2bb71c5 --- /dev/null +++ b/spec/models/concerns/organizations/authentication_methods_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Organizations::AuthenticationMethods do + describe "authentication_methods" do + let(:organization) { create(:organization) } + + before do + organization + end + + it "creates with default values" do + expect(organization.authentication_methods.count).to eq(Organization::FREE_AUTHENTICATION_METHODS.count) + expect(organization.authentication_methods).to eq(Organization::FREE_AUTHENTICATION_METHODS) + end + + Organization::FREE_AUTHENTICATION_METHODS.each do |auth| + context "when FREE AUTHENTICATION METHOD #{auth}" do + it "is enabled by default" do + expect(organization.authentication_methods).to include(auth) + end + + it "can be disabled" do + expect(organization.send(:"disable_#{auth}_authentication!")).to be_truthy + expect(organization.send(:"#{auth}_authentication_enabled?")).to be_falsey + end + + it "can be enabled" do + expect(organization.send(:"enable_#{auth}_authentication!")).to be_truthy + expect(organization.send(:"#{auth}_authentication_enabled?")).to be_truthy + end + end + end + + Organization::PREMIUM_AUTHENTICATION_METHODS.each do |auth| + context "when PREMIUM AUTHENTICATION METHOD #{auth}" do + it "is not enabled by default" do + expect(organization.authentication_methods).not_to include(auth) + end + + context "with free organization" do + it "cant be enabled" do + expect(organization.send(:"enable_#{auth}_authentication!")).to be_falsey + expect(organization.send(:"#{auth}_authentication_enabled?")).to be_falsey + end + end + + context "with premium organization", :premium do + before do + organization.premium_integrations << auth + end + + it "can be enabled" do + expect(organization.send(:"enable_#{auth}_authentication!")).to be_truthy + expect(organization.send(:"#{auth}_authentication_enabled?")).to be_truthy + end + + it "can be disabled" do + organization.send(:"enable_#{auth}_authentication!") + expect(organization.send(:"disable_#{auth}_authentication!")).to be_truthy + expect(organization.send(:"#{auth}_authentication_enabled?")).to be_falsey + end + end + end + end + + context "when disabling authentication methods" do + it "cant disable all" do + expect do + organization.authentication_methods.dup.each do |auth| + organization.send(:"disable_#{auth}_authentication!") + end + end.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context "when invalid auth" do + it "cant save" do + expect do + organization.authentication_methods = ["strange"] + organization.save! + end.to raise_error(ActiveRecord::RecordInvalid) + end + end + end +end diff --git a/spec/models/concerns/organizations/sluggable_spec.rb b/spec/models/concerns/organizations/sluggable_spec.rb new file mode 100644 index 0000000..5417de4 --- /dev/null +++ b/spec/models/concerns/organizations/sluggable_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Organizations::Sluggable do + describe "validations" do + subject(:organization) { build(:organization) } + + it "validates presence of slug on update" do + persisted_org = create(:organization) + persisted_org.slug = nil + expect(persisted_org).not_to be_valid + expect(persisted_org.errors[:slug]).to be_present + end + + it "validates uniqueness of slug" do + create(:organization, slug: "taken-slug") + organization.slug = "taken-slug" + expect(organization).not_to be_valid + expect(organization.errors[:slug]).to be_present + end + + it "validates minimum length of 3" do + organization.slug = "ab" + expect(organization).not_to be_valid + end + + it "validates maximum length of 40" do + organization.slug = "a" * 41 + expect(organization).not_to be_valid + end + + it "validates format" do + valid_slugs = %w[acme acme-corp a1b2 my-org-123] + valid_slugs.each do |slug| + organization.slug = slug + expect(organization).to be_valid, "expected '#{slug}' to be valid" + end + + invalid_slugs = ["-starts-with-dash", "ends-with-dash-", "UPPERCASE", "has spaces", "special!chars", "under_score"] + invalid_slugs.each do |slug| + organization.slug = slug + expect(organization).not_to be_valid, "expected '#{slug}' to be invalid" + end + end + + it "rejects reserved slugs" do + Organizations::Sluggable::RESERVED_SLUGS.each do |reserved| + organization.slug = reserved + expect(organization).not_to be_valid, "expected reserved slug '#{reserved}' to be rejected" + end + end + + it "skips slug validations when slug has not changed on persisted record" do + organization = create(:organization) + organization.name = "Updated Name" + expect(organization).to be_valid + end + end + + describe "#generate_slug" do + it "auto-generates slug from organization name" do + organization = build(:organization, name: "Acme Corporation", slug: nil) + organization.valid? + expect(organization.slug).to eq("acme-corporation") + end + + it "skips generation when slug is already present" do + organization = build(:organization, name: "Acme Corporation", slug: "custom-slug") + organization.valid? + expect(organization.slug).to eq("custom-slug") + end + + it "transliterates accented characters" do + organization = build(:organization, name: "Société Générale", slug: nil) + organization.valid? + expect(organization.slug).to eq("societe-generale") + end + + it "handles umlauts and special characters" do + organization = build(:organization, name: "Müller & Söhne GmbH", slug: nil) + organization.valid? + expect(organization.slug).to eq("muller-sohne-gmbh") + end + + it "strips special characters" do + organization = build(:organization, name: "Tech & Co. #1 @2024!", slug: nil) + organization.valid? + expect(organization.slug).to eq("tech-co-1-2024") + end + + it "cleans up consecutive and trailing dashes" do + organization = build(:organization, name: "test ()(/&()/-.,-.,--_:_;-,-,)(/&/()&(-.,--.,-,_:;_;", slug: nil) + organization.valid? + expect(organization.slug).to eq("test") + end + + it "truncates to 40 characters" do + organization = build(:organization, name: "A Very Long Organization Name That Exceeds The Forty Character Limit", slug: nil) + organization.valid? + expect(organization.slug.length).to be <= 40 + end + + it "does not leave trailing hyphen after truncation" do + organization = build(:organization, name: "Alpha Beta Gamma Delta Epsilon Zeta Eta T", slug: nil) + organization.valid? + expect(organization.slug).not_to end_with("-") + expect(organization.slug).to match(Organizations::Sluggable::SLUG_FORMAT) + end + + context "with fallback cases" do + it "generates random slug for non-transliterable names (Cyrillic)" do + organization = build(:organization, name: "Газпром", slug: nil) + organization.valid? + expect(organization.slug).to match(/\Aorg-[a-z0-9]{5}\z/) + end + + it "generates random slug for non-transliterable names (CJK)" do + organization = build(:organization, name: "日本企業", slug: nil) + organization.valid? + expect(organization.slug).to match(/\Aorg-[a-z0-9]{5}\z/) + end + + it "generates random slug for purely numeric names" do + organization = build(:organization, name: "12345", slug: nil) + organization.valid? + expect(organization.slug).to match(/\Aorg-[a-z0-9]{5}\z/) + end + + it "generates random slug for reserved words" do + organization = build(:organization, name: "Admin", slug: nil) + organization.valid? + expect(organization.slug).to match(/\Aorg-[a-z0-9]{5}\z/) + end + + it "generates random slug for names shorter than 3 characters" do + organization = build(:organization, name: "AB", slug: nil) + organization.valid? + expect(organization.slug).to match(/\Aorg-[a-z0-9]{5}\z/) + end + + it "generates random slug for blank names" do + organization = build(:organization, name: "🚀", slug: nil) + organization.valid? + expect(organization.slug).to match(/\Aorg-[a-z0-9]{5}\z/) + end + end + + context "with collision handling" do + it "appends random suffix on collision" do + create(:organization, slug: "acme-corp") + organization = build(:organization, name: "Acme Corp", slug: nil) + organization.valid? + expect(organization.slug).to match(/\Aacme-corp-[a-z0-9]{3}\z/) + end + + it "generates unique slugs for organizations with the same name" do + org1 = create(:organization, name: "Acme Corp", slug: nil) + org2 = build(:organization, name: "Acme Corp", slug: nil) + org2.valid? + expect(org2.slug).not_to eq(org1.slug) + expect(org2.slug).to start_with("acme-corp") + end + + it "does not produce double hyphens when base ends with hyphen" do + # "Alpha Beta Gamma Delta Epsilon Zeta Eta Theta" → truncated to 40 → "alpha-beta-gamma-delta-epsilon-zeta-eta-" → cleaned → "alpha-beta-gamma-delta-epsilon-zeta-eta" + # On collision, base truncated to 36 → "alpha-beta-gamma-delta-epsilon-zeta-" → cleaned → "alpha-beta-gamma-delta-epsilon-zeta" + create(:organization, slug: "alpha-beta-gamma-delta-epsilon-zeta-eta") + organization = build(:organization, name: "Alpha Beta Gamma Delta Epsilon Zeta Eta Theta", slug: nil) + organization.valid? + expect(organization.slug).not_to include("--") + end + end + end +end diff --git a/spec/models/coupon_spec.rb b/spec/models/coupon_spec.rb new file mode 100644 index 0000000..6fb56a2 --- /dev/null +++ b/spec/models/coupon_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Coupon do + subject(:coupon) { build(:coupon) } + + it_behaves_like "paper_trail traceable" + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than(0).allow_nil } + + specify do + expect(subject) + .to validate_inclusion_of(:amount_currency) + .in_array(described_class.currency_list) + end + + describe "validations" do + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_exclusion_of(:reusable).in_array([nil]) } + + describe "of amount cents" do + subject { coupon } + + let(:coupon) { build_stubbed(:coupon, coupon_type:) } + + context "when coupon type is fixed amount" do + let(:coupon_type) { :fixed_amount } + + it { is_expected.to validate_presence_of(:amount_cents) } + end + + context "when coupon type is percentage" do + let(:coupon_type) { :percentage } + + it { is_expected.not_to validate_presence_of(:amount_cents) } + end + end + + describe "of amount currency" do + subject { coupon } + + let(:coupon) { build_stubbed(:coupon, coupon_type:) } + + context "when coupon type is fixed amount" do + let(:coupon_type) { :fixed_amount } + + it { is_expected.to validate_presence_of(:amount_currency) } + end + + context "when coupon type is percentage" do + let(:coupon_type) { :percentage } + + it { is_expected.not_to validate_presence_of(:amount_currency) } + end + end + + describe "of percentage rate" do + subject { coupon } + + let(:coupon) { build_stubbed(:coupon, coupon_type:) } + + context "when coupon type is fixed amount" do + let(:coupon_type) { :fixed_amount } + + it { is_expected.not_to validate_presence_of(:percentage_rate) } + end + + context "when coupon type is percentage" do + let(:coupon_type) { :percentage } + + it { is_expected.to validate_presence_of(:percentage_rate) } + end + end + + describe "of frequency_duration" do + subject(:coupon) { build(:coupon, frequency:) } + + context "when recurring" do + let(:frequency) { "recurring" } + + it { is_expected.to validate_presence_of(:frequency_duration).with_message("value_is_mandatory") } + it { is_expected.to validate_numericality_of(:frequency_duration).is_greater_than(0) } + end + + context "when once" do + let(:frequency) { "once" } + + it { is_expected.not_to validate_presence_of(:frequency_duration) } + end + + context "when forever" do + let(:frequency) { "forever" } + + it { is_expected.not_to validate_presence_of(:frequency_duration) } + end + end + end + + describe ".mark_as_terminated" do + it "terminates the coupon" do + coupon.mark_as_terminated! + + expect(coupon).to be_terminated + expect(coupon.terminated_at).to be_present + end + end +end diff --git a/spec/models/coupon_target_spec.rb b/spec/models/coupon_target_spec.rb new file mode 100644 index 0000000..5467217 --- /dev/null +++ b/spec/models/coupon_target_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe CouponTarget do + subject(:coupon_target) { build(:coupon_plan) } + + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/credit_note/applied_tax_spec.rb b/spec/models/credit_note/applied_tax_spec.rb new file mode 100644 index 0000000..cd20c65 --- /dev/null +++ b/spec/models/credit_note/applied_tax_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNote::AppliedTax do + subject(:applied_tax) { create(:credit_note_applied_tax) } + + describe "associations" do + it { is_expected.to belong_to(:credit_note) } + it { is_expected.to belong_to(:tax).optional } + it { is_expected.to belong_to(:organization) } + end + + it_behaves_like "paper_trail traceable" +end diff --git a/spec/models/credit_note_item_spec.rb b/spec/models/credit_note_item_spec.rb new file mode 100644 index 0000000..9b32027 --- /dev/null +++ b/spec/models/credit_note_item_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNoteItem do + subject(:credit_note_item) { create(:credit_note_item) } + + it { is_expected.to belong_to(:credit_note) } + it { is_expected.to belong_to(:fee) } + it { is_expected.to belong_to(:organization) } + + describe "#sub_total_excluding_taxes_amount_cents" do + let(:credit_note_item) { build(:credit_note_item, amount_cents: 100, fee: fee) } + let(:fee) { build(:fee, amount_cents: 1000, precise_amount_cents: 1000, precise_coupons_amount_cents: 0) } + + context "when there are no coupons applied" do + it "returns item amount with coupons applied" do + expect(credit_note_item.sub_total_excluding_taxes_amount_cents).to eq(100) + end + end + + context "when there are coupons applied" do + let(:fee) { build(:fee, amount_cents: 1000, precise_amount_cents: 1000, precise_coupons_amount_cents: 20) } + + it "returns item amount with coupons applied" do + expect(credit_note_item.sub_total_excluding_taxes_amount_cents).to eq(98) + end + end + end +end diff --git a/spec/models/credit_note_spec.rb b/spec/models/credit_note_spec.rb new file mode 100644 index 0000000..7a127d3 --- /dev/null +++ b/spec/models/credit_note_spec.rb @@ -0,0 +1,621 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNote do + subject(:credit_note) do + create :credit_note, credit_amount_cents: 11000, total_amount_cents: 11000, taxes_amount_cents: 1000, + taxes_rate: 10.0, precise_taxes_amount_cents: 1000 + end + + let(:item) { create(:credit_note_item, credit_note:, precise_amount_cents: 10000, amount_cents: 1000) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_one(:metadata).class_name("Metadata::ItemMetadata").dependent(:destroy) } + it { is_expected.to have_many(:integration_resources) } + it { is_expected.to have_many(:error_details) } + it { is_expected.to have_many(:invoice_settlements).with_foreign_key(:source_credit_note_id) } + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + describe "validations" do + it { is_expected.to validate_numericality_of(:total_amount_cents).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:credit_amount_cents).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:refund_amount_cents).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:offset_amount_cents).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_numericality_of(:balance_amount_cents).is_greater_than_or_equal_to(0) } + end + + describe "constants" do + it "defines TYPES" do + expect(described_class::TYPES).to eq(%w[credit refund offset]) + end + end + + describe "sequential_id" do + let(:invoice) { create(:invoice) } + let(:customer) { invoice.customer } + let(:credit_note) { build(:credit_note, invoice:, customer:) } + + it "assigns a sequential_id is present" do + credit_note.save + + expect(credit_note).to be_valid + expect(credit_note.sequential_id).to eq(1) + end + + context "when sequential_id is present" do + before { credit_note.sequential_id = 3 } + + it "does not replace the sequential_id" do + credit_note.save + + expect(credit_note).to be_valid + expect(credit_note.sequential_id).to eq(3) + end + end + + context "when credit note already exists" do + before do + create(:credit_note, invoice:, sequential_id: 5) + end + + it "takes the next available id" do + credit_note.save! + + expect(credit_note).to be_valid + expect(credit_note.sequential_id).to eq(6) + end + end + + context "with credit note on other invoice" do + before do + create(:credit_note, sequential_id: 1) + end + + it "scopes the sequence to the invoice" do + credit_note.save + + expect(credit_note).to be_valid + expect(credit_note.sequential_id).to eq(1) + end + end + end + + describe "number" do + let(:invoice) { create(:invoice, number: "CUST-001") } + let(:customer) { invoice.customer } + let(:credit_note) { build(:credit_note, invoice:, customer:) } + + it "generates the credit_note_number" do + credit_note.save + + expect(credit_note.number).to eq("CUST-001-CN001") + end + end + + describe "#xml_url" do + before do + credit_note.xml_file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.xml"))), + filename: "credit_note.xml", + content_type: "application/xml" + ) + end + + it "returns the xml file url" do + xml_url = credit_note.xml_url + + expect(xml_url).to be_present + expect(xml_url).to include(ENV["LAGO_API_URL"]) + end + end + + describe "#currency" do + let(:credit_note) { build(:credit_note, total_amount_currency: "JPY") } + + it { expect(credit_note.currency).to eq("JPY") } + end + + describe "#credited?" do + let(:credit_note) { build(:credit_note, credit_amount_cents: 0) } + + it { expect(credit_note).not_to be_credited } + + context "when credit amount is present" do + let(:credit_note) { build(:credit_note, credit_amount_cents: 10) } + + it { expect(credit_note).to be_credited } + end + end + + describe "#refunded?" do + let(:credit_note) { build(:credit_note) } + + it { expect(credit_note).not_to be_refunded } + end + + describe "#refund_amount_cents" do + let(:credit_note) { build(:credit_note) } + + it { expect(credit_note.refund_amount_cents).to be_zero } + end + + describe "offset amount" do + describe "#has_offset?" do + it "returns false when offset is zero" do + credit_note = build(:credit_note, offset_amount_cents: 0) + expect(credit_note).not_to have_offset + end + + it "returns true when offset is present" do + credit_note = build(:credit_note, offset_amount_cents: 100) + expect(credit_note).to have_offset + end + end + + describe "#offset_amount_cents" do + it "defaults to zero" do + credit_note = build(:credit_note) + expect(credit_note.offset_amount_cents).to be_zero + end + + it "returns the set value" do + credit_note = build(:credit_note, offset_amount_cents: 500) + expect(credit_note.offset_amount_cents).to eq(500) + end + end + + describe "monetization" do + it "provides offset_amount as a Money object" do + credit_note = build(:credit_note, offset_amount_cents: 1234, offset_amount_currency: "USD") + expect(credit_note.offset_amount).to be_a(Money) + expect(credit_note.offset_amount.cents).to eq(1234) + expect(credit_note.offset_amount.currency.to_s).to eq("USD") + end + end + end + + describe "invoice_settlements association" do + it "returns associated invoice settlements" do + credit_note = create(:credit_note) + invoice_settlement = create(:invoice_settlement, + target_invoice: credit_note.invoice, source_credit_note: credit_note, + settlement_type: :credit_note, amount_cents: 100) + + expect(credit_note.invoice_settlements).to include(invoice_settlement) + expect(credit_note.invoice_settlements.first.amount_cents).to eq(100) + end + end + + describe "#for_credit_invoice?" do + it "returns true for credit invoices" do + credit_invoice = create(:invoice, invoice_type: :credit) + credit_note = build(:credit_note, invoice: credit_invoice) + expect(credit_note.for_credit_invoice?).to eq(true) + end + + it "returns false for non-credit invoices" do + regular_invoice = create(:invoice, invoice_type: :subscription) + credit_note = build(:credit_note, invoice: regular_invoice) + expect(credit_note.for_credit_invoice?).to eq(false) + end + end + + describe "#subscription_ids" do + let(:invoice) { credit_note.invoice } + let(:subscription_fee) { create(:fee, invoice:) } + let(:credit_note_item1) do + create(:credit_note_item, credit_note:, fee: subscription_fee) + end + + let(:charge_fee) { create(:charge_fee, invoice:) } + let(:credit_note_item2) do + create(:credit_note_item, credit_note:, fee: charge_fee) + end + + before do + credit_note_item1 + credit_note_item2 + end + + it "returns the list of subscription ids" do + expect(credit_note.subscription_ids).to contain_exactly( + subscription_fee.subscription_id, + charge_fee.subscription_id + ) + end + + context "with add_on fee" do + let(:add_on_fee) { create(:add_on_fee, invoice:) } + let(:credit_note_item3) do + create(:credit_note_item, credit_note:, fee: add_on_fee) + end + + before { credit_note_item3 } + + it "returns an empty subscription id" do + expect(credit_note.subscription_ids).to include( + subscription_fee.subscription_id, + charge_fee.subscription_id, + nil + ) + end + end + + describe "#subscription_item" do + let(:invoice) { credit_note.invoice } + let(:subscription_fee) { create(:fee, invoice:) } + let(:credit_note_item1) do + create(:credit_note_item, credit_note:, fee: subscription_fee) + end + + let(:subscription) { subscription_fee.subscription } + let(:charge_fee) { create(:charge_fee, invoice:, subscription:) } + let(:credit_note_item2) do + create(:credit_note_item, credit_note:, fee: charge_fee) + end + let(:fixed_charge_fee) { create(:fixed_charge_fee, invoice:, subscription:) } + let(:credit_note_item3) do + create(:credit_note_item, credit_note:, fee: fixed_charge_fee) + end + + before do + credit_note_item1 + credit_note_item2 + credit_note_item3 + end + + it "returns the item for the subscription fee" do + expect(credit_note.subscription_item(subscription.id)).to eq(credit_note_item1) + end + + context "when subscription id does not match an existing fee" do + let(:missing_subscription) { create(:subscription) } + + it "returns a new fee" do + fee = credit_note.subscription_item(missing_subscription.id) + + expect(fee).to be_new_record + end + end + end + + describe "#subscription_charge_items" do + let(:invoice) { credit_note.invoice } + let(:subscription_fee) { create(:fee, invoice:) } + let(:credit_note_item1) do + create(:credit_note_item, credit_note:, fee: subscription_fee) + end + + let(:subscription) { subscription_fee.subscription } + + let(:charge_fee) { create(:charge_fee, invoice:, subscription:) } + let(:credit_note_item2) do + create(:credit_note_item, credit_note:, fee: charge_fee) + end + + let(:fixed_charge_fee) { create(:fixed_charge_fee, invoice:, subscription:) } + let(:credit_note_item3) do + create(:credit_note_item, credit_note:, fee: fixed_charge_fee) + end + + before do + credit_note_item1 + credit_note_item2 + credit_note_item3 + end + + it "returns the item for the charge fee" do + expect(credit_note.subscription_charge_items(subscription.id)).to eq([credit_note_item2]) + end + end + + describe "#subscription_fixed_charge_items" do + let(:invoice) { credit_note.invoice } + let(:subscription_fee) { create(:fee, invoice:) } + let(:credit_note_item1) do + create(:credit_note_item, credit_note:, fee: subscription_fee) + end + + let(:subscription) { subscription_fee.subscription } + + let(:charge_fee) { create(:charge_fee, invoice:, subscription:) } + let(:credit_note_item2) do + create(:credit_note_item, credit_note:, fee: charge_fee) + end + + let(:fixed_charge_fee) { create(:fixed_charge_fee, invoice:, subscription:) } + let(:credit_note_item3) do + create(:credit_note_item, credit_note:, fee: fixed_charge_fee) + end + + before do + credit_note_item1 + credit_note_item2 + credit_note_item3 + end + + it "returns the item for the fixed charge fee" do + expect(credit_note.subscription_fixed_charge_items(subscription.id)).to eq([credit_note_item3]) + end + end + end + + describe "#add_on_items" do + let(:invoice) { credit_note.invoice } + let(:add_on) { create(:add_on, organization: credit_note.organization) } + let(:applied_add_on) { create(:applied_add_on, add_on:) } + let(:credit_note_item) { create(:credit_note_item, credit_note:, fee: add_on_fee) } + let(:add_on_fee) { create(:add_on_fee, invoice:, applied_add_on:) } + + before { credit_note_item } + + it "returns items of the add-on" do + expect(credit_note.add_on_items).to eq([credit_note_item]) + end + end + + describe "#voidable?" do + let(:credit_note) { create(:credit_note, balance_amount_cents:, credit_status:) } + let(:balance_amount_cents) { 10 } + let(:credit_status) { :available } + + it { expect(credit_note).to be_voidable } + + context "when balance is consumed" do + let(:balance_amount_cents) { 0 } + + it { expect(credit_note).not_to be_voidable } + end + + context "when already voided" do + let(:credit_status) { :voided } + + it { expect(credit_note).not_to be_voidable } + end + end + + context "when calculating depends on related items" do + before do + item + credit_note.reload + end + + describe "#sub_total_excluding_taxes_amount_cents" do + it "returs the total amount without the taxes" do + expect(credit_note.sub_total_excluding_taxes_amount_cents) + .to eq(credit_note.items.sum(&:precise_amount_cents) - credit_note.precise_coupons_adjustment_amount_cents) + end + end + + describe "#precise_total" do + it "returns the total precise amount including precise taxes" do + expect(credit_note.precise_total).to eq(11000) + end + end + end + + describe "#taxes_rounding_adjustment" do + it "returns the difference between taxes and precise taxes" do + expect(credit_note.taxes_rounding_adjustment).to eq(0) + end + end + + describe "#rounding_adjustment" do + it "returns the difference between credit note total and credit note precise total" do + expect(credit_note.taxes_rounding_adjustment).to eq(0) + end + end + + describe "#status_changed_to_finalized?" do + subject(:method_call) { credit_note.send(:status_changed_to_finalized?) } + + let(:credit_note) { create(:credit_note, status: :draft) } + + context "when status changes from draft to finalized" do + it "returns true" do + credit_note.status = :finalized + expect(subject).to eq(true) + end + end + + context "when status changes from finalized to draft" do + let(:finalized_credit_note) { create(:credit_note, status: :finalized) } + + it "returns false" do + finalized_credit_note.status = :draft + expect(subject).to eq(false) + end + end + + context "when status remains draft" do + it "returns false" do + expect(subject).to eq(false) + end + end + + context "when status remains finalized" do + let(:finalized_credit_note) { create(:credit_note, status: :finalized) } + + it "returns false" do + expect(subject).to eq(false) + end + end + + context "when credit note is new and status is set to finalized" do + let(:new_credit_note) { build(:credit_note, status: :finalized) } + + it "returns false" do + expect(subject).to eq(false) + end + end + + context "when credit note is new and status is set to draft" do + let(:new_credit_note) { build(:credit_note, status: :draft) } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + describe "#ensure_number" do + let(:invoice) { create(:invoice, number: "LAG-1234-001") } + + context "when creating a new credit note" do + let(:credit_note) { build(:credit_note, invoice:, sequential_id: 1) } + + before { credit_note.save! } + + it "generates number" do + expect(credit_note.number).to eq("LAG-1234-001-CN001") + end + end + + context "when credit note already has a number" do + context "when credit note has a number" do + let(:credit_note) { create(:credit_note, invoice:, number: "EXISTING-NUMBER") } + + it "does not change existing number on create" do + expect(credit_note.number).to eq("EXISTING-NUMBER") + end + end + + context "when finalizing a draft credit note" do + let(:credit_note) { create(:credit_note, invoice:, status: :draft, number: "DRAFT-NUMBER", sequential_id: 5) } + + before { credit_note.finalized! } + + it "regenerates the number" do + expect(credit_note.number).to eq("LAG-1234-001-CN005") + end + end + end + + context "when credit note has no number" do + let(:credit_note) { create(:credit_note, number: nil, invoice:, sequential_id: 3) } + + it "generates number on update" do + credit_note.update!(description: "Updated") + + expect(credit_note.number).to eq("LAG-1234-001-CN003") + end + end + end + + describe "#should_sync_credit_note?" do + subject(:method_call) { credit_note.should_sync_credit_note? } + + let(:credit_note) { create(:credit_note, customer:, organization:, status:) } + let(:organization) { create(:organization) } + + context "when credit note is not finalized" do + let(:status) { :draft } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration customer" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_credit_notes:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync credit notes is true" do + let(:sync_credit_notes) { true } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "when sync credit notes is false" do + let(:sync_credit_notes) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + + context "when credit note is finalized" do + let(:status) { :finalized } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration customer" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_credit_notes:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync credit notes is true" do + let(:sync_credit_notes) { true } + + it "returns true" do + expect(method_call).to eq(true) + end + end + + context "when sync credit notes is false" do + let(:sync_credit_notes) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + end + + context "when taxes are not precise" do + subject(:credit_note) do + create :credit_note, credit_amount_cents: 8200, total_amount_cents: 8200, taxes_amount_cents: 1367, + taxes_rate: 20.0, precise_taxes_amount_cents: 1366.6 + end + + let(:item) { create(:credit_note_item, credit_note:, precise_amount_cents: 6833, amount_cents: 6833) } + + before do + item + credit_note.reload + end + + describe "#precise_total" do + it "returns the total precise amount including precise taxes" do + expect(credit_note.precise_total).to eq(8199.6) + end + end + + describe "#taxes_rounding_adjustment" do + it "returns the difference between taxes and precise taxes" do + expect(credit_note.taxes_rounding_adjustment).to eq(0.4) + end + end + + describe "#rounding_adjustment" do + it "returns the difference between credit note total and credit note precise total" do + expect(credit_note.taxes_rounding_adjustment).to eq(0.4) + end + end + end +end diff --git a/spec/models/credit_spec.rb b/spec/models/credit_spec.rb new file mode 100644 index 0000000..69b6d93 --- /dev/null +++ b/spec/models/credit_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Credit do + subject(:credit) { create(:credit) } + + describe "associations" do + it { is_expected.to belong_to(:invoice) } + it { is_expected.to belong_to(:applied_coupon).optional } + it { is_expected.to belong_to(:credit_note).optional } + it { is_expected.to belong_to(:progressive_billing_invoice).optional } + it { is_expected.to belong_to(:organization) } + end + + describe "scopes" do + let!(:active_invoice) { create(:invoice, status: :finalized) } + let!(:voided_invoice) { create(:invoice, status: :voided) } + let!(:active_credit) { create(:credit, invoice: active_invoice) } + let!(:voided_credit) { create(:credit, invoice: voided_invoice) } + + describe ".active" do + it "returns only credits with non-voided invoices" do + expect(described_class.active).to match_array([active_credit]) + end + end + + describe ".voided" do + it "returns only credits with voided invoices" do + expect(described_class.voided).to match_array([voided_credit]) + end + end + end + + describe "invoice item" do + context "when credit is a coupon" do + subject(:credit) { create(:credit, applied_coupon:) } + + let(:applied_coupon) { create(:applied_coupon, coupon:) } + let(:coupon) do + create( + :coupon, + code: "coupon_code", + name: "Coupon name", + description: "Coupon desc" + ) + end + + it "returns coupon details" do + expect(credit.item_id).to eq(coupon.id) + expect(credit.item_type).to eq("coupon") + expect(credit.item_code).to eq("coupon_code") + expect(credit.item_name).to eq("Coupon name") + expect(credit.item_description).to eq("Coupon desc") + end + + context "when coupon is deleted" do + let(:coupon) do + create( + :coupon, + :deleted, + code: "coupon_code", + name: "Coupon name", + description: "Coupon desc", + amount_cents: 200, + amount_currency: "EUR" + ) + end + + it "returns coupon details" do + expect(credit.item_id).to eq(coupon.id) + expect(credit.item_type).to eq("coupon") + expect(credit.item_code).to eq("coupon_code") + expect(credit.item_name).to eq("Coupon name") + expect(credit.item_description).to eq("Coupon desc") + expect(credit.invoice_coupon_display_name).to eq("Coupon name (€2.00)") + end + end + end + + context "when credit is a credit note" do + subject(:credit) { create(:credit_note_credit) } + + let(:credit_note) do + c = credit.credit_note + c.update! description: "Credit note description" + c + end + + it "returns credit note details" do + expect(credit.item_id).to eq(credit_note.id) + expect(credit.item_type).to eq("credit_note") + expect(credit.item_code).to eq(credit_note.number) + expect(credit.item_name).to eq(credit_note.invoice.number) + expect(credit.item_description).to eq("Credit note description") + end + end + + context "when credit is a progressive billing invoice" do + subject(:credit) { create(:progressive_billing_invoice_credit, progressive_billing_invoice:) } + + let(:progressive_billing_invoice) { create(:invoice, invoice_type: :progressive_billing) } + + it "returns invoice details" do + expect(credit.item_id).to eq(progressive_billing_invoice.id) + expect(credit.item_type).to eq("invoice") + expect(credit.item_code).to eq(progressive_billing_invoice.number) + expect(credit.item_name).to eq(progressive_billing_invoice.number) + expect(credit.item_description).to be_nil + end + end + end +end diff --git a/spec/models/customer/applied_invoice_custom_section_spec.rb b/spec/models/customer/applied_invoice_custom_section_spec.rb new file mode 100644 index 0000000..673c814 --- /dev/null +++ b/spec/models/customer/applied_invoice_custom_section_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customer::AppliedInvoiceCustomSection do + subject(:applied_invoice_custom_section) do + create(:customer_applied_invoice_custom_section) + end + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:billing_entity) } + it { is_expected.to belong_to(:customer) } + it { is_expected.to belong_to(:invoice_custom_section) } +end diff --git a/spec/models/customer/applied_tax_spec.rb b/spec/models/customer/applied_tax_spec.rb new file mode 100644 index 0000000..859e6a2 --- /dev/null +++ b/spec/models/customer/applied_tax_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customer::AppliedTax do + subject(:applied_tax) { create(:customer_applied_tax) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb new file mode 100644 index 0000000..de6f4eb --- /dev/null +++ b/spec/models/customer_spec.rb @@ -0,0 +1,1251 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customer do + subject(:customer) { create(:customer) } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:applied_dunning_campaign).optional } + it { is_expected.to belong_to(:billing_entity).optional } + it { is_expected.to have_many(:daily_usages) } + + it { is_expected.to have_many(:integration_customers).dependent(:destroy) } + it { is_expected.to have_many(:payment_methods) } + it { is_expected.to have_many(:payment_requests) } + it { is_expected.to have_many(:error_details).dependent(:destroy) } + + it { is_expected.to have_one(:netsuite_customer) } + it { is_expected.to have_one(:anrok_customer) } + it { is_expected.to have_one(:xero_customer) } + it { is_expected.to have_one(:hubspot_customer) } + it { is_expected.to have_one(:salesforce_customer) } + + it { is_expected.to have_one(:default_payment_method) } + it { is_expected.to have_one(:pending_vies_check) } + + it { is_expected.to have_many(:applied_invoice_custom_sections).class_name("Customer::AppliedInvoiceCustomSection").dependent(:destroy) } + it { is_expected.to have_many(:selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } + it { is_expected.to have_many(:manual_selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section).conditions(section_type: :manual) } + it { is_expected.to have_many(:system_generated_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section).conditions(section_type: :system_generated) } + + describe "enums" do + it { is_expected.to define_enum_for(:account_type).with_values(described_class::ACCOUNT_TYPES).backed_by_column_of_type(:enum).with_suffix(:account).validating } + end + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + it "sets the default value to inherit" do + expect(customer.finalize_zero_amount_invoice).to eq "inherit" + end + + describe "normalizations" do + it "strips null bytes from address and shipping address attributes" do + normalized_customer = build( + :customer, + address_line1: "Foo\u0000Bar", + address_line2: "Bar\u0000Baz", + city: "Baz\u0000Qux", + zipcode: "Qux\u0000Quux", + state: "Quux\u0000Quuux", + country: "Quuux\u0000Quuuux", + shipping_address_line1: "Baz\u0000Qux", + shipping_address_line2: "Qux\u0000Quux", + shipping_city: "Quux\u0000Quuux", + shipping_zipcode: "Quuux\u0000Quuuux", + shipping_state: "Quuuux\u0000Quuuuux", + shipping_country: "Quuuuux\u0000Quuuuuux" + ) + + expect(normalized_customer.address_line1).to eq("FooBar") + expect(normalized_customer.address_line2).to eq("BarBaz") + expect(normalized_customer.city).to eq("BazQux") + expect(normalized_customer.zipcode).to eq("QuxQuux") + expect(normalized_customer.state).to eq("QuuxQuuux") + expect(normalized_customer.country).to eq("QuuuxQuuuux") + expect(normalized_customer.shipping_address_line1).to eq("BazQux") + expect(normalized_customer.shipping_address_line2).to eq("QuxQuux") + expect(normalized_customer.shipping_city).to eq("QuuxQuuux") + expect(normalized_customer.shipping_zipcode).to eq("QuuuxQuuuux") + expect(normalized_customer.shipping_state).to eq("QuuuuxQuuuuux") + expect(normalized_customer.shipping_country).to eq("QuuuuuxQuuuuuux") + end + + it "return nil for empty strings" do + described_class::ADDRESS_FIELDS.each do |field| + expect(described_class.normalize_value_for(field, "")).to be_nil + end + end + end + + describe "validations" do + subject(:customer) do + described_class.new(organization:, external_id:) + end + + let(:external_id) { SecureRandom.uuid } + + it "validates the language code" do + customer.document_locale = nil + expect(customer).to be_valid + + customer.document_locale = "en" + expect(customer).to be_valid + + customer.document_locale = "foo" + expect(customer).not_to be_valid + + customer.document_locale = "" + expect(customer).not_to be_valid + end + + it "validates the timezone" do + expect(customer).to be_valid + + customer.timezone = "Europe/Paris" + expect(customer).to be_valid + + customer.timezone = "foo" + expect(customer).not_to be_valid + + customer.timezone = "America/Guadeloupe" + expect(customer).not_to be_valid + end + + describe "of email" do + let(:customer) { build_stubbed(:customer, email: "invalid @example.com") } + let(:error) { customer.errors.where(:email, :invalid_email_format) } + + context "when email is not changed" do + it "does not add an error" do + customer.valid? + expect(error).not_to be_present + end + end + + context "when email is changed" do + before do + customer.email = email + customer.valid? + end + + context "when there is only one email" do + context "when email is nil" do + let(:email) { nil } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when email is empty string" do + let(:email) { "" } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when email is valid" do + let(:email) { "test@test-test.com" } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when email is invalid" do + let(:email) { "test@test- test.com" } + + it "adds an error" do + expect(error).to be_present + end + end + end + + context "when there are multiple comma-separated emails" do + context "when emails are valid" do + let(:email) { "test@test-test.com, test2@test.com" } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when emails are not valid" do + context "when one of the emails is blank" do + let(:email) { "test@test- test.com, test2@test.com," } + + it "adds an error" do + expect(error).to be_present + end + end + + context "when first one is invalid" do + let(:email) { "test@test- test.com, test2@test.com" } + + it "adds an error" do + expect(error).to be_present + end + end + + context "when second one is invalid" do + let(:email) { "test@test-test.com, test2@te st.com" } + + it "adds an error" do + expect(error).to be_present + end + end + + context "when both are invalid" do + let(:email) { "test@test -test.com, test2@te st.com" } + + it "adds an error" do + expect(error).to be_present + end + end + end + end + end + end + + describe "of country" do + let(:customer) { build_stubbed(:customer, country:) } + let(:error) { customer.errors.where(:country, :country_code_invalid) } + + before { customer.valid? } + + context "with non-null country value" do + context "when value is a valid country code" do + let(:country) { TZInfo::Country.all_codes.sample } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when value is an invalid country code" do + let(:country) { "USA" } + + it "adds an error" do + expect(error).to be_present + end + end + end + + context "with null country value" do + let(:country) { nil } + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + + describe "of shipping country" do + let(:customer) { build_stubbed(:customer, shipping_country:) } + let(:error) { customer.errors.where(:shipping_country, :country_code_invalid) } + + before { customer.valid? } + + context "with non-null shipping country value" do + context "when value is a valid country code" do + let(:shipping_country) { TZInfo::Country.all_codes.sample } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when value is an invalid country code" do + let(:shipping_country) { "USA" } + + it "adds an error" do + expect(error).to be_present + end + end + end + + context "with null shipping country value" do + let(:shipping_country) { nil } + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + + it "validates subscription_invoice_issuing_date_anchor" do + customer.subscription_invoice_issuing_date_anchor = nil + expect(customer).to be_valid + + customer.subscription_invoice_issuing_date_anchor = "invalid" + expect(customer).not_to be_valid + + customer.subscription_invoice_issuing_date_anchor = "current_period_end" + expect(customer).to be_valid + + customer.subscription_invoice_issuing_date_anchor = "next_period_start" + expect(customer).to be_valid + end + + it "validates subscription_invoice_issuing_date_adjustments" do + customer.subscription_invoice_issuing_date_adjustment = nil + expect(customer).to be_valid + + customer.subscription_invoice_issuing_date_adjustment = "invalid" + expect(customer).not_to be_valid + + customer.subscription_invoice_issuing_date_adjustment = "keep_anchor" + expect(customer).to be_valid + + customer.subscription_invoice_issuing_date_adjustment = "align_with_finalization_date" + expect(customer).to be_valid + end + + it { is_expected.to validate_inclusion_of(:customer_type).in_array(described_class::CUSTOMER_TYPES.keys) } + end + + describe ".awaiting_wallet_refresh" do + subject { described_class.awaiting_wallet_refresh } + + let!(:scoped) { create(:customer, awaiting_wallet_refresh: true) } + + before { create(:customer) } + + it "returns only customers awaiting wallet refresh" do + expect(subject).to contain_exactly(scoped) + end + end + + describe ".with_active_wallets" do + subject { described_class.with_active_wallets } + + let!(:scoped) { create(:wallet).customer } + + before do + create(:customer) + create(:wallet, :terminated) + end + + it "returns customers that have at least one active wallet" do + expect(subject).to contain_exactly(scoped) + end + end + + describe ".falling_back_to_default_dunning_campaign" do + subject { described_class.falling_back_to_default_dunning_campaign } + + let!(:scoped) { create(:customer) } + + before do + create(:customer, exclude_from_dunning_campaign: true) + create(:customer, applied_dunning_campaign: create(:dunning_campaign)) + end + + it "returns customers that have no dunning campaign but should" do + expect(subject).to contain_exactly(scoped) + end + end + + describe "#display_name" do + subject(:customer) { build_stubbed(:customer, name:, legal_name:, firstname:, lastname:) } + + let(:name) { "ACME Inc" } + let(:legal_name) { "ACME International Corporation" } + let(:firstname) { "Thomas" } + let(:lastname) { "Anderson" } + + context "when all fields are nil" do + let(:name) { nil } + let(:legal_name) { nil } + let(:firstname) { nil } + let(:lastname) { nil } + + it "returns an empty string" do + expect(customer.display_name).to eq("") + end + end + + context "when name and legal_name are nil" do + let(:name) { nil } + let(:legal_name) { nil } + + it "returns only firstname and lastname if present" do + expect(customer.display_name).to eq("Thomas Anderson") + end + end + + context "when firstname and lastname are nil" do + let(:firstname) { nil } + let(:lastname) { nil } + + it "returns only the legal_name" do + expect(customer.display_name).to eq("ACME International Corporation") + end + + context "when we dont have a legal_name" do + let(:legal_name) { nil } + + it "returns only the name if present" do + expect(customer.display_name).to eq("ACME Inc") + end + end + end + + context "when name is present and both firstname and lastname are present" do + let(:legal_name) { nil } + + it "returns name with firstname and lastname" do + expect(customer.display_name).to eq("ACME Inc - Thomas Anderson") + expect(customer.display_name(prefer_legal_name: false)).to eq("ACME Inc - Thomas Anderson") + end + end + + context "when legal_name is present and both firstname and lastname are present" do + let(:name) { nil } + + it "returns legal_name with firstname and lastname" do + expect(customer.display_name).to eq("ACME International Corporation - Thomas Anderson") + expect(customer.display_name(prefer_legal_name: false)).to eq("Thomas Anderson") + end + end + + context "when all fields are present" do + it "returns display name" do + expect(customer.display_name).to eq("ACME International Corporation - Thomas Anderson") + expect(customer.display_name(prefer_legal_name: false)).to eq("ACME Inc - Thomas Anderson") + end + end + end + + describe "customer_type enum" do + subject(:customer) { build_stubbed(:customer, customer_type:) } + + context "when customer_type is company" do + let(:customer_type) { "company" } + + it "identifies the customer as a company" do + expect(customer.customer_type).to eq("company") + expect(customer.customer_type_company?).to be true + end + end + + context "when customer_type is individual" do + let(:customer_type) { "individual" } + + it "identifies the customer as an individual" do + expect(customer.customer_type).to eq("individual") + expect(customer.customer_type_individual?).to be true + end + end + + context "when customer_type is nil" do + subject(:customer) { build(:customer) } + + it "defaults to nil for existing customers" do + expect(customer.customer_type).to be_nil + end + end + end + + describe "account_type enum" do + subject(:customer) { build_stubbed(:customer, account_type:) } + + context "when account_type is customer" do + let(:account_type) { "customer" } + + it "identifies the customer as a customer" do + expect(customer.account_type).to eq("customer") + expect(customer).to be_customer_account + end + end + + context "when account_type is partner" do + let(:account_type) { "partner" } + + it "identifies the customer as partner" do + expect(customer.account_type).to eq("partner") + expect(customer).to be_partner_account + end + end + + context "when account_type is nil" do + subject(:customer) { build(:customer) } + + it "defaults to customer for existing customers" do + expect(customer.account_type).to eq "customer" + end + end + end + + describe "preferred_document_locale" do + subject(:preferred_document_locale) { customer.preferred_document_locale } + + let(:customer) do + described_class.new( + organization:, + billing_entity:, + document_locale: "en" + ) + end + + it "returns the customer document_locale" do + expect(preferred_document_locale).to eq(:en) + end + + context "when customer does not have a document_locale" do + before do + customer.document_locale = nil + billing_entity.document_locale = "fr" + end + + it "returns the billing_entity document_locale" do + expect(customer.preferred_document_locale).to eq(:fr) + end + end + end + + describe "#editable?" do + subject(:editable) { customer.editable? } + + context "when customer has a wallet" do + let(:customer) { wallet.customer } + let(:wallet) { create(:wallet) } + + it "returns false" do + expect(editable).to eq(false) + end + end + + context "when customer has a coupon applied" do + let(:customer) { applied_coupon.customer } + let(:applied_coupon) { create(:applied_coupon) } + + it "returns false" do + expect(editable).to eq(false) + end + end + + context "when customer has an addon applied" do + let(:customer) { applied_add_on.customer } + let(:applied_add_on) { create(:applied_add_on) } + + it "returns false" do + expect(editable).to eq(false) + end + end + + context "when customer has an invoice" do + let(:customer) { invoice.customer } + let(:invoice) { create(:invoice) } + + it "returns false" do + expect(editable).to eq(false) + end + end + + context "when customer has a subscription" do + let(:customer) { subscription.customer } + let(:subscription) { create(:subscription) } + + it "returns false" do + expect(editable).to eq(false) + end + end + + context "when customer has no record that prevents editing" do + it "returns true" do + expect(editable).to eq(true) + end + end + end + + describe "#vies_check_in_progress?" do + subject(:vies_check_in_progress) { customer.vies_check_in_progress? } + + context "when billing entity does not have eu_tax_management enabled" do + before { customer.billing_entity.update!(eu_tax_management: false) } + + it "returns false" do + expect(vies_check_in_progress).to eq(false) + end + end + + context "when billing entity has eu_tax_management enabled" do + before { customer.billing_entity.update!(eu_tax_management: true) } + + context "when customer has no pending_vies_check" do + it "returns false" do + expect(vies_check_in_progress).to eq(false) + end + end + + context "when customer has a pending_vies_check" do + before { create(:pending_vies_check, customer:) } + + it "returns true" do + expect(vies_check_in_progress).to eq(true) + end + end + end + end + + describe "#provider_customer" do + subject(:customer) { create(:customer, organization:, payment_provider:) } + + context "when payment provider is stripe" do + let(:payment_provider) { "stripe" } + let(:stripe_customer) { create(:stripe_customer, customer:) } + + before { stripe_customer } + + it "returns the stripe provider customer object" do + expect(customer.provider_customer).to eq(stripe_customer) + end + end + + context "when payment provider is gocardless" do + let(:payment_provider) { "gocardless" } + let(:gocardless_customer) { create(:gocardless_customer, customer:) } + + before { gocardless_customer } + + it "returns the gocardless provider customer object" do + expect(customer.provider_customer).to eq(gocardless_customer) + end + end + end + + describe "#applicable_timezone" do + subject(:customer) do + described_class.new(billing_entity:, timezone: "Europe/Paris") + end + + it "returns the customer timezone" do + expect(customer.applicable_timezone).to eq("Europe/Paris") + end + + context "when customer does not have a timezone" do + let(:billing_entity_timezone) { "Europe/London" } + + before do + customer.timezone = nil + billing_entity.timezone = billing_entity_timezone + end + + it "returns the billing entity timezone" do + expect(customer.applicable_timezone).to eq("Europe/London") + end + + context "when billing entity timezone is nil" do + let(:billing_entity_timezone) { nil } + + it "returns the default timezone" do + expect(customer.applicable_timezone).to eq("UTC") + end + end + end + end + + describe "#applicable_invoice_grace_period" do + subject(:customer) do + described_class.new(billing_entity:, invoice_grace_period: 3) + end + + it "returns the customer invoice_grace_period" do + expect(customer.applicable_invoice_grace_period).to eq(3) + end + + context "when customer does not have an invoice grace period" do + let(:billing_entity_invoice_grace_period) { 5 } + + before do + customer.invoice_grace_period = nil + billing_entity.invoice_grace_period = billing_entity_invoice_grace_period + end + + it "returns the billing entity invoice_grace_period" do + expect(customer.applicable_invoice_grace_period).to eq(5) + end + + context "when billing entity invoice_grace_period is nil" do + let(:billing_entity_invoice_grace_period) { nil } + + it "returns the default invoice_grace_period" do + expect(customer.applicable_invoice_grace_period).to eq(0) + end + end + end + end + + describe "#applicable_subscription_invoice_issuing_date_anchor" do + subject(:customer) do + described_class.new(billing_entity:, subscription_invoice_issuing_date_anchor:) + end + + context "when customer has subscription_invoice_issuing_date_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + + it "returns the customer subscription_invoice_issuing_date_anchor" do + expect(customer.applicable_subscription_invoice_issuing_date_anchor).to eq("current_period_end") + end + end + + context "when customer does not have subscription_invoice_issuing_date_anchor" do + let(:subscription_invoice_issuing_date_anchor) { nil } + + it "returns the billing entity subscription_invoice_issuing_date_anchor" do + expect(customer.applicable_subscription_invoice_issuing_date_anchor).to eq("next_period_start") + end + end + end + + describe "#applicable_subscription_invoice_issuing_date_adjustment" do + subject(:customer) do + described_class.new(billing_entity:, subscription_invoice_issuing_date_adjustment:) + end + + context "when customer has subscription_invoice_issuing_date_adjustment" do + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "returns the customer subscription_invoice_issuing_date_adjustment" do + expect(customer.applicable_subscription_invoice_issuing_date_adjustment).to eq("keep_anchor") + end + end + + context "when customer does not have subscription_invoice_issuing_date_adjustment" do + let(:subscription_invoice_issuing_date_adjustment) { nil } + + it "returns the billing entity subscription_invoice_issuing_date_adjustment" do + expect(customer.applicable_subscription_invoice_issuing_date_adjustment).to eq("align_with_finalization_date") + end + end + end + + describe "#applicable_net_payment_term" do + subject(:applicable_net_payment_term) { customer.applicable_net_payment_term } + + let(:customer) do + described_class.new(organization:, billing_entity:, net_payment_term: 15) + end + + it "returns the customer net_payment_term" do + expect(applicable_net_payment_term).to eq(15) + end + + context "when customer does not have a net payment term" do + let(:billing_entity_net_payment_term) { 30 } + + before do + customer.net_payment_term = nil + billing_entity.net_payment_term = billing_entity_net_payment_term + end + + it "returns the billing entity net payment term" do + expect(applicable_net_payment_term).to eq(billing_entity_net_payment_term) + end + + context "when billing entity net_payment_term is nil" do + let(:billing_entity_net_payment_term) { nil } + + it { is_expected.to be_nil } + end + end + end + + describe "scoped selected_invoice_custom_sections" do + let(:organization) { customer.organization } + let(:manual_section) { create(:invoice_custom_section, organization:, section_type: :manual) } + let(:system_generated_section) { create(:invoice_custom_section, organization:, section_type: :system_generated) } + let(:customer_applied_manual_section) { create(:customer_applied_invoice_custom_section, customer:, invoice_custom_section: manual_section) } + let(:customer_applied_system_generated_section) { create(:customer_applied_invoice_custom_section, customer:, invoice_custom_section: system_generated_section) } + + before do + customer_applied_manual_section + customer_applied_system_generated_section + end + + it "returns the correct sections for each scoped association" do + expect(customer.manual_selected_invoice_custom_sections).to contain_exactly(manual_section) + expect(customer.system_generated_invoice_custom_sections).to contain_exactly(system_generated_section) + end + end + + describe "#applicable_invoice_custom_sections" do + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + + let(:manual_customer_section) do + create(:invoice_custom_section, organization:, section_type: :manual, name: "Customer Section") + end + + let(:manual_billing_entity_section) do + create(:invoice_custom_section, organization:, section_type: :manual, name: "Billing Entity Section") + end + + let(:system_generated_section) do + create(:invoice_custom_section, organization:, section_type: :system_generated, name: "System Section") + end + + context "when skip_invoice_custom_sections is true and there are system sections" do + before do + customer.update!(skip_invoice_custom_sections: true) + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: system_generated_section) + end + + it "returns only system generated sections" do + expect(customer.applicable_invoice_custom_sections).to contain_exactly(system_generated_section) + end + end + + context "when customer has manual and system sections" do + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: manual_customer_section) + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: system_generated_section) + end + + it "returns both manual and system generated sections" do + expect(customer.applicable_invoice_custom_sections).to contain_exactly(manual_customer_section, system_generated_section) + end + end + + context "when customer has no manual, but billing entity has manual, and customer has system" do + before do + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: manual_billing_entity_section) + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: system_generated_section) + end + + it "returns billing entity manual + system sections" do + expect(customer.applicable_invoice_custom_sections).to contain_exactly(manual_billing_entity_section, system_generated_section) + end + end + + context "when only billing entity has manual sections and no system sections" do + before do + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: manual_billing_entity_section) + end + + it "returns only billing entity manual sections" do + expect(customer.applicable_invoice_custom_sections).to contain_exactly(manual_billing_entity_section) + end + end + + context "when only system_generated sections exist" do + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: system_generated_section) + end + + it "returns only system_generated sections" do + expect(customer.applicable_invoice_custom_sections).to contain_exactly(system_generated_section) + end + end + + context "when no manual or system_generated sections are selected" do + it "returns an empty collection" do + expect(customer.applicable_invoice_custom_sections).to be_empty + end + end + end + + describe "#configurable_invoice_custom_sections" do + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + let(:invoice_custom_section_a) { create(:invoice_custom_section, organization:) } + let(:invoice_custom_section_b) { create(:invoice_custom_section, organization:) } + + before do + invoice_custom_section_a + invoice_custom_section_b + end + + context "when customer has skip_invoice_custom_sections set to true" do + before do + customer.update!(skip_invoice_custom_sections: true) + create(:billing_entity_applied_invoice_custom_section, billing_entity:, invoice_custom_section: invoice_custom_section_a) + end + + it "returns an empty collection" do + expect(customer.configurable_invoice_custom_sections).to be_empty + end + end + + context "when customer has its own applied_invoice_custom_sections" do + before do + create(:customer_applied_invoice_custom_section, customer:, invoice_custom_section: invoice_custom_section_b) + end + + it "returns the customer's selected_invoice_custom_sections" do + expect(customer.configurable_invoice_custom_sections).to contain_exactly(invoice_custom_section_b) + end + end + + context "when customer does not have any applied_invoice_custom_sections but billing entity has" do + before do + create(:billing_entity_applied_invoice_custom_section, billing_entity:, invoice_custom_section: invoice_custom_section_a) + end + + it "returns the billing entity's invoice_custom_sections" do + expect(customer.configurable_invoice_custom_sections).to contain_exactly(invoice_custom_section_a) + end + end + + context "when neither customer nor billing entity have selected invoice custom sections" do + it "returns an empty collection" do + expect(customer.configurable_invoice_custom_sections).to be_empty + end + end + end + + describe "timezones" do + subject(:customer) do + build( + :customer, + organization:, + timezone: "Europe/Paris", + created_at: DateTime.parse("2022-11-17 23:34:23") + ) + end + + let(:organization) { create(:organization) } + + before do + organization.default_billing_entity.update(timezone: "America/Los_Angeles") + end + + it "has helper to get dates in timezones" do + expect(customer.created_at.to_s).to eq("2022-11-17 23:34:23 UTC") + expect(customer.created_at_in_customer_timezone.to_s).to eq("2022-11-18 00:34:23 +0100") + expect(customer.created_at_in_organization_timezone.to_s).to eq("2022-11-17 15:34:23 -0800") + expect(customer.created_at_in_billing_entity_timezone.to_s).to eq("2022-11-17 15:34:23 -0800") + end + end + + describe "slug" do + let(:organization) { create(:organization, name: "LAGO") } + + let(:customer) do + build(:customer, organization:) + end + + it "assigns a sequential id and a slug to a new customer" do + customer.save + organization_id_substring = organization.id.last(4).upcase + + expect(customer).to be_valid + expect(customer.sequential_id).to eq(1) + expect(customer.slug).to eq("LAG-#{organization_id_substring}-001") + end + + context "with custom document_number_prefix" do + let(:organization) { create(:organization, name: "LAGO") } + + before do + create(:customer, organization:, sequential_id: 5) + organization.update!(document_number_prefix: "ORG-55") + end + + it "assigns a sequential id and a slug to a new customer" do + customer.save + + expect(customer).to be_valid + expect(customer.sequential_id).to eq(6) + expect(customer.slug).to eq("ORG-55-006") + end + end + end + + describe "#same_billing_and_shipping_address?" do + subject(:method_call) { customer.same_billing_and_shipping_address? } + + context "when shipping address is present" do + context "when shipping address is not the same as billing address" do + let(:customer) { build_stubbed(:customer, :with_shipping_address) } + + it "returns false" do + expect(subject).to be(false) + end + end + + context "when shipping address is the same as billing address" do + let(:customer) { build_stubbed(:customer, :with_same_billing_and_shipping_address) } + + it "returns true" do + expect(subject).to be(true) + end + end + end + + context "when shipping address is not present" do + let(:customer) { build_stubbed(:customer) } + + it "returns true" do + expect(subject).to be(true) + end + end + end + + describe "#empty_billing_and_shipping_address?" do + subject(:method_call) { customer.empty_billing_and_shipping_address? } + + context "when shipping address is present" do + context "when billing address is present" do + let(:customer) { build_stubbed(:customer, :with_shipping_address) } + + it "returns false" do + expect(subject).to be(false) + end + end + + context "when billing address is not present" do + let(:customer) do + build_stubbed( + :customer, + :with_shipping_address, + address_line1: nil, + address_line2: nil, + city: nil, + zipcode: nil, + state: nil, + country: nil + ) + end + + it "returns false" do + expect(subject).to be(false) + end + end + end + + context "when shipping address is not present" do + context "when billing address is present" do + let(:customer) { build_stubbed(:customer) } + + it "returns false" do + expect(subject).to be(false) + end + end + + context "when billing address is not present" do + let(:customer) do + build_stubbed( + :customer, + address_line1: nil, + address_line2: nil, + city: nil, + zipcode: nil, + state: nil, + country: nil + ) + end + + it "returns true" do + expect(subject).to be(true) + end + end + end + end + + describe "#overdue_balance_cents" do + subject(:overdue_balance_cents) { customer.overdue_balance_cents } + + let(:customer) { create(:customer, currency: "USD") } + + context "when there are no overdue invoices" do + before do + create(:invoice, customer: customer, payment_overdue: false, currency: "USD", total_amount_cents: 5_00) + end + + it { is_expected.to be_zero } + end + + context "when there are overdue invoices in the customer's currency" do + before do + create(:invoice, customer: customer, payment_overdue: true, currency: "USD", total_amount_cents: 2_00) + create(:invoice, customer: customer, payment_overdue: true, currency: "USD", total_amount_cents: 3_00) + end + + it { is_expected.to eq 5_00 } + end + + context "when there are overdue invoices in a different currency" do + before do + create(:invoice, customer: customer, payment_overdue: true, currency: "USD", total_amount_cents: 4_00) + create(:invoice, customer: customer, payment_overdue: true, currency: "EUR", total_amount_cents: 3_00) + end + + it "ignores invoices in other currencies" do + expect(customer.overdue_balance_cents).to eq 4_00 + end + end + + context "when there are both overdue and non-overdue invoices" do + before do + create(:invoice, customer: customer, payment_overdue: true, currency: "USD", total_amount_cents: 2_00) + create(:invoice, customer: customer, payment_overdue: false, currency: "USD", total_amount_cents: 1_00) + end + + it "only sums the overdue invoices" do + expect(customer.overdue_balance_cents).to eq 2_00 + end + end + + context "when invoices are self billed" do + before do + create(:invoice, customer: customer, payment_overdue: true, currency: "USD", total_amount_cents: 2_00) + create(:invoice, :self_billed, customer: customer, payment_overdue: true, currency: "USD", total_amount_cents: 3_00) + end + + it "ignores self billed invoices" do + expect(customer.overdue_balance_cents).to eq 2_00 + end + end + end + + describe "#reset_dunning_campaign!" do + let(:customer) do + create( + :customer, + last_dunning_campaign_attempt: 5, + last_dunning_campaign_attempt_at: 1.day.ago + ) + end + + it "changes dunning campaign status counters" do + expect { customer.reset_dunning_campaign! && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + end + end + + describe "#flag_wallets_for_refresh" do + subject { customer.flag_wallets_for_refresh } + + let(:customer) { create(:customer, awaiting_wallet_refresh:) } + + context "when customer has at least one active wallet" do + before { create(:wallet, customer:) } + + context "when customer already awaiting wallet refresh" do + let(:awaiting_wallet_refresh) { true } + + it "keeps customer as awaiting wallet refresh" do + expect { subject }.not_to change(customer, :awaiting_wallet_refresh).from(true) + end + end + + context "when customer is not awaiting wallet refresh" do + let(:awaiting_wallet_refresh) { false } + + it "marks customer as awaiting wallet refresh" do + expect { subject }.to change(customer, :awaiting_wallet_refresh).from(false).to(true) + end + end + end + + context "when customer has no wallet" do + context "when customer already awaiting wallet refresh" do + let(:awaiting_wallet_refresh) { true } + + it "does not change customer" do + expect { subject }.not_to change(customer, :awaiting_wallet_refresh) + end + end + + context "when customer is not awaiting wallet refresh" do + let(:awaiting_wallet_refresh) { false } + + it "does not change customer" do + expect { subject }.not_to change(customer, :awaiting_wallet_refresh) + end + end + end + end + + describe "#tax_customer" do + let(:customer) { create(:customer) } + + context "with anrok attached" do + let(:anrok_customer) { create(:anrok_customer, customer:) } + + before { anrok_customer } + + it "returns anrok customer" do + expect(customer.tax_customer).to eq(anrok_customer) + end + end + + context "with avalara attached" do + let(:avalara_customer) { create(:avalara_customer, customer:) } + + before { avalara_customer } + + it "returns avalara customer" do + expect(customer.tax_customer).to eq(avalara_customer) + end + end + + context "without any tax integration" do + it "returns nil" do + expect(customer.tax_customer).to eq(nil) + end + end + end + + describe "#address_changed?" do + context "when a billing address field changes" do + it "returns true" do + customer.address_line1 = "New Street" + expect(customer.address_changed?).to be(true) + end + end + + context "when a shipping address field changes" do + it "returns true" do + customer.shipping_city = "New City" + expect(customer.address_changed?).to be(true) + end + end + + context "when no address field changes" do + it "returns false" do + customer.name = "New Name" + expect(customer.address_changed?).to be(false) + end + end + + context "when no field changes" do + it "returns false" do + expect(customer.address_changed?).to be(false) + end + end + end + + describe "#default_payment_method" do + let(:customer) { create(:customer) } + + before { payment_method } + + context "with default payment method" do + let(:payment_method) { create(:payment_method, customer:, organization: customer.organization, is_default: true) } + + it "returns correct payment method" do + expect(customer.default_payment_method.id).to eq(payment_method.id) + end + end + + context "without default payment method" do + let(:payment_method) { create(:payment_method, customer:, organization: customer.organization, is_default: false) } + + it "returns nil" do + expect(customer.default_payment_method).to eq(nil) + end + end + end +end diff --git a/spec/models/daily_usage_spec.rb b/spec/models/daily_usage_spec.rb new file mode 100644 index 0000000..e25a43d --- /dev/null +++ b/spec/models/daily_usage_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsage do + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:customer) } + it { is_expected.to belong_to(:subscription) } +end diff --git a/spec/models/data_export_part_spec.rb b/spec/models/data_export_part_spec.rb new file mode 100644 index 0000000..23ccf2e --- /dev/null +++ b/spec/models/data_export_part_spec.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExportPart do + it { is_expected.to belong_to(:data_export) } + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/data_export_spec.rb b/spec/models/data_export_spec.rb new file mode 100644 index 0000000..d2874ae --- /dev/null +++ b/spec/models/data_export_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExport do + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:membership) } + it { is_expected.to have_one(:user).through(:membership) } + it { is_expected.to have_many(:data_export_parts) } + + it { is_expected.to validate_presence_of(:resource_type) } + + specify do + expect(subject) + .to define_enum_for(:format) + .with_values([:csv]) + .validating + end + + specify do + expect(subject) + .to define_enum_for(:status) + .with_values(%i[pending processing completed failed]) + .validating + end + + describe "validations" do + describe "of file being attached" do + subject { data_export } + + let(:data_export) { build(:data_export, status:) } + + context "when status is completed" do + let(:status) { "completed" } + + it { is_expected.to validate_attached_of(:file) } + end + + context "when status is non-completed" do + let(:status) { described_class::STATUSES.excluding("completed").sample } + + it { is_expected.not_to validate_attached_of(:file) } + end + end + end + + describe "#processing!" do + subject(:processing!) { data_export.processing! } + + let(:data_export) { create :data_export } + + it "updates status and started_at timestamp" do + freeze_time do + expect { processing! } + .to change(data_export, :status).to("processing") + .and change(data_export, :started_at).to(Time.zone.now) + end + end + end + + describe "#completed!" do + subject(:completed!) { data_export.completed! } + + let(:data_export) { create :data_export, :with_file } + + it "updates status and started_at timestamp" do + freeze_time do + expect { completed! } + .to change(data_export, :status).to("completed") + .and change(data_export, :completed_at).to(Time.zone.now) + .and change(data_export, :expires_at).to(7.days.from_now) + end + end + end + + describe "#expired?" do + subject(:expired?) { data_export.expired? } + + let(:data_export) { build_stubbed :data_export } + + it { is_expected.to eq false } + + context "when export is completed" do + let(:data_export) { build_stubbed :data_export, :completed } + + it { is_expected.to eq false } + end + + context "when the expiration date is reached" do + let(:data_export) { build_stubbed :data_export, :expired } + + it { is_expected.to eq true } + end + end + + describe "#export_class" do + let(:data_export) { build_stubbed :data_export, resource_type: } + + context "when resource_type is invoices" do + let(:resource_type) { "invoices" } + + it "returns DataExports::Csv::Invoices" do + expect(data_export.export_class).to eq(DataExports::Csv::Invoices) + end + end + + context "when resource_type is invoice_fees" do + let(:resource_type) { "invoice_fees" } + + it "returns DataExports::Csv::InvoiceFees" do + expect(data_export.export_class).to eq(DataExports::Csv::InvoiceFees) + end + end + + context "when resource_type is credit notes" do + let(:resource_type) { "credit_notes" } + + it "returns DataExports::Csv::CreditNotes" do + expect(data_export.export_class).to eq(DataExports::Csv::CreditNotes) + end + end + + context "when resource_type is credit note items" do + let(:resource_type) { "credit_note_items" } + + it "returns DataExports::Csv::CreditNotes" do + expect(data_export.export_class).to eq(DataExports::Csv::CreditNoteItems) + end + end + + context "when resource_type is an unsupported value" do + let(:resource_type) { "unsupported" } + + it "returns nil" do + expect(data_export.export_class).to eq(nil) + end + end + end + + describe "#filename" do + subject(:filename) { data_export.filename } + + let(:data_export) { create :data_export, :completed } + + it "returns the file name" do + freeze_time do + timestamp = Time.zone.now.strftime("%Y%m%d%H%M%S") + expect(filename).to eq("#{timestamp}_invoices.csv") + end + end + + context "when data export does not have a file" do + let(:data_export) { create :data_export } + + it "returns the file name" do + freeze_time do + timestamp = Time.zone.now.strftime("%Y%m%d%H%M%S") + expect(filename).to eq("#{timestamp}_invoices.csv") + end + end + end + end + + describe "#file_url" do + subject(:file_url) { data_export.file_url } + + let(:data_export) { create :data_export, :completed } + + it "returns the file url" do + expect(file_url).to be_present + expect(file_url).to include(ENV["LAGO_API_URL"]) + end + + context "when data export does not have a file" do + let(:data_export) { create :data_export } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/models/deprecation_spec.rb b/spec/models/deprecation_spec.rb new file mode 100644 index 0000000..27b28db --- /dev/null +++ b/spec/models/deprecation_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Deprecation, cache: :redis do + let(:organization) { create(:organization) } + let(:feature_name) { "event_legacy" } + + before do + Rails.cache.write("deprecation:#{feature_name}:#{organization.id}:last_seen_at", "2024-05-22T14:58:20.280Z") + Rails.cache.increment("deprecation:#{feature_name}:#{organization.id}:count", 101) + end + + describe ".report" do + it "writes to cache" do + freeze_time do + described_class.report(feature_name, organization.id) + + expect(Rails.cache.read("deprecation:#{feature_name}:#{organization.id}:last_seen_at")).to eq(Time.zone.now.utc) + expect(Rails.cache.read("deprecation:#{feature_name}:#{organization.id}:count", raw: true)).to eq("102") + end + end + end + + describe ".get" do + it "returns deprecation data for an organization" do + expect(described_class.get(feature_name, organization.id)).to eq({ + organization_id: organization.id, + last_seen_at: "2024-05-22T14:58:20.280Z", + count: 101 + }) + end + end + + describe ".get_all" do + it "returns deprecation data for all organizations" do + expect(described_class.get_all(feature_name)).to eq([{ + organization_id: organization.id, + last_seen_at: "2024-05-22T14:58:20.280Z", + count: 101 + }]) + end + end + + describe ".get_all_as_csv" do + it "returns deprecation data for all organizations" do + csv = "org_id,org_name,org_email,last_event_sent_at,count\n" + csv += "#{organization.id},#{csv_safe(organization.name)},#{organization.email},2024-05-22T14:58:20.280Z,101\n" + expect(described_class.get_all_as_csv(feature_name)).to eq(csv) + end + end + + describe ".reset" do + it "deletes deprecation data for an organization" do + described_class.reset(feature_name, organization.id) + + expect(Rails.cache.read("deprecation:#{feature_name}:#{organization.id}:last_seen_at")).to be_nil + expect(Rails.cache.read("deprecation:#{feature_name}:#{organization.id}:count")).to be_nil + end + end + + describe ".reset_all" do + it "deletes deprecation data for all organizations" do + described_class.reset_all(feature_name) + + expect(Rails.cache.read("deprecation:#{feature_name}:#{organization.id}:last_seen_at")).to be_nil + expect(Rails.cache.read("deprecation:#{feature_name}:#{organization.id}:count")).to be_nil + end + end + + def csv_safe(value) + # Enclose the value in double quotes if it contains a comma or double quote + if value.include?(",") || value.include?('"') + value = value.gsub('"', '""') # Escape double quotes by doubling them + "\"#{value}\"" + else + value + end + end +end diff --git a/spec/models/dunning_campaign_spec.rb b/spec/models/dunning_campaign_spec.rb new file mode 100644 index 0000000..c19cf7f --- /dev/null +++ b/spec/models/dunning_campaign_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaign do + subject(:dunning_campaign) { create(:dunning_campaign) } + + it_behaves_like "paper_trail traceable" + + it do + expect(subject).to belong_to(:organization) + expect(subject).to have_many(:thresholds).dependent(:destroy) + expect(subject).to have_many(:customers).dependent(:nullify) + expect(subject).to have_many(:payment_requests).dependent(:nullify) + + expect(subject).to validate_presence_of(:name) + + expect(subject).to validate_numericality_of(:days_between_attempts).is_greater_than(0) + expect(subject).to validate_numericality_of(:max_attempts).is_greater_than(0) + + expect(subject).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + + describe "bcc_emails validation" do + it do + expect(dunning_campaign).to be_valid + + dunning_campaign.bcc_emails = nil + expect(dunning_campaign).not_to be_valid + + dunning_campaign.bcc_emails = [] + expect(dunning_campaign).to be_valid + + dunning_campaign.bcc_emails = ["test1@example.com", "test2@example.com"] + expect(dunning_campaign).to be_valid + + dunning_campaign.bcc_emails = ["test1@example.com", "name.com"] + expect(dunning_campaign).not_to be_valid + expect(dunning_campaign.errors.messages).to eq({ + bcc_emails: ["invalid_email_format[1,name.com]"] + }) + end + end + + describe "code validation" do + let(:code) { "123456" } + let(:organization) { create(:organization) } + + it "validates uniqueness of code scoped to organization_id excluding deleted records" do + deleted_record = create(:dunning_campaign, :deleted, code:, organization:) + expect(deleted_record).to be_valid + + record1 = create(:dunning_campaign, code:, organization:) + expect(record1).to be_valid + + record2 = build(:dunning_campaign, code:, organization:) + expect(record2).not_to be_valid + expect(record2.errors[:code]).to include("value_already_exist") + end + end + + describe "default scope" do + let(:deleted_dunning_campaign) { create(:dunning_campaign, :deleted) } + + before { deleted_dunning_campaign } + + it "only returns non-deleted dunning_campaign objects" do + expect(described_class.all).to eq([]) + expect(described_class.with_discarded).to eq([deleted_dunning_campaign]) + end + end + + describe "#reset_customers_last_attempt" do + let(:last_dunning_campaign_attempt_at) { Time.current } + let(:organization) { dunning_campaign.organization } + + it "resets last attempt on customers with the campaign applied explicitly" do + customer = create( + :customer, + organization:, + applied_dunning_campaign: dunning_campaign, + last_dunning_campaign_attempt: 1, + last_dunning_campaign_attempt_at: + ) + + expect { dunning_campaign.reset_customers_last_attempt } + .to change { customer.reload.last_dunning_campaign_attempt }.from(1).to(0) + .and change { customer.last_dunning_campaign_attempt_at }.from(last_dunning_campaign_attempt_at).to(nil) + end + + context "when applied to billing entity" do + subject(:dunning_campaign) { create(:dunning_campaign) } + + before { organization.default_billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "resets last attempt on customers falling back to the billing_entity campaign" do + customer = create( + :customer, + organization:, + last_dunning_campaign_attempt: 2, + last_dunning_campaign_attempt_at: + ) + + expect { dunning_campaign.reset_customers_last_attempt } + .to change { customer.reload.last_dunning_campaign_attempt }.from(2).to(0) + .and change { customer.last_dunning_campaign_attempt_at }.from(last_dunning_campaign_attempt_at).to(nil) + end + end + end +end diff --git a/spec/models/dunning_campaign_threshold_spec.rb b/spec/models/dunning_campaign_threshold_spec.rb new file mode 100644 index 0000000..44ee2a2 --- /dev/null +++ b/spec/models/dunning_campaign_threshold_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaignThreshold do + subject(:dunning_campaign_threshold) { create(:dunning_campaign_threshold) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:dunning_campaign) } + it { is_expected.to belong_to(:organization) } + + it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_inclusion_of(:currency).in_array(described_class.currency_list) } + it { is_expected.to validate_uniqueness_of(:currency).scoped_to(:dunning_campaign_id) } + + describe "currency validation" do + let(:currency) { "EUR" } + let(:dunning_campaign) { create(:dunning_campaign) } + + it "validates uniqueness of currency scoped to dunning_campaign_id excluding deleted records" do + deleted_record = create(:dunning_campaign_threshold, :deleted, currency:, dunning_campaign:) + expect(deleted_record).to be_valid + + record1 = create(:dunning_campaign_threshold, currency:, dunning_campaign:) + expect(record1).to be_valid + + record2 = build(:dunning_campaign_threshold, currency:, dunning_campaign:) + expect(record2).not_to be_valid + expect(record2.errors[:currency]).to include("value_already_exist") + end + end + + describe "default scope" do + let(:deleted_dunning_campaign_threshold) do + create(:dunning_campaign_threshold, :deleted) + end + + before { deleted_dunning_campaign_threshold } + + it "only returns non-deleted dunning_campaign_threshold objects" do + expect(described_class.all).to eq([]) + expect(described_class.with_discarded).to eq([deleted_dunning_campaign_threshold]) + end + end +end diff --git a/spec/models/enriched_event_spec.rb b/spec/models/enriched_event_spec.rb new file mode 100644 index 0000000..f00fa36 --- /dev/null +++ b/spec/models/enriched_event_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EnrichedEvent do + subject { build(:enriched_event) } + + it { is_expected.to belong_to(:event) } + + it { is_expected.to validate_presence_of(:code) } + it { is_expected.to validate_presence_of(:timestamp) } + it { is_expected.to validate_presence_of(:transaction_id) } + it { is_expected.to validate_presence_of(:external_subscription_id) } + it { is_expected.to validate_presence_of(:organization_id) } + it { is_expected.to validate_presence_of(:subscription_id) } + it { is_expected.to validate_presence_of(:plan_id) } + it { is_expected.to validate_presence_of(:charge_id) } + it { is_expected.to validate_presence_of(:enriched_at) } +end diff --git a/spec/models/enriched_store_migration_spec.rb b/spec/models/enriched_store_migration_spec.rb new file mode 100644 index 0000000..321ce10 --- /dev/null +++ b/spec/models/enriched_store_migration_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EnrichedStoreMigration do + subject(:migration) { create(:enriched_store_migration) } + + describe "enums" do + it do + expect(subject).to define_enum_for(:status) + .backed_by_column_of_type(:enum) + .validating + .with_values( + pending: "pending", + checking: "checking", + processing: "processing", + enabling: "enabling", + completed: "completed", + failed: "failed" + ) + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to have_many(:subscription_migrations) + .class_name("EnrichedStoreSubscriptionMigration") + .dependent(:destroy) + end + end + + describe "AASM transitions" do + describe "#start_check" do + it "transitions from pending to checking" do + expect(migration).to be_pending + migration.start_check! + expect(migration).to be_checking + end + end + + describe "#start_processing" do + subject(:migration) { create(:enriched_store_migration, :checking) } + + it "transitions from checking to processing" do + migration.start_processing! + expect(migration).to be_processing + end + end + + describe "#start_enabling" do + subject(:migration) { create(:enriched_store_migration, :processing) } + + it "transitions from processing to enabling" do + migration.start_enabling! + expect(migration).to be_enabling + end + end + + describe "#complete" do + subject(:migration) { create(:enriched_store_migration, :enabling) } + + it "transitions from enabling to completed" do + migration.complete! + expect(migration).to be_completed + end + end + + describe "#fail" do + %i[pending checking processing enabling].each do |state| + it "transitions from #{state} to failed" do + record = if state == :pending + create(:enriched_store_migration) + else + create(:enriched_store_migration, state) + end + + record.fail! + expect(record).to be_failed + end + end + end + + describe "#retry_migration" do + subject(:migration) { create(:enriched_store_migration, :failed) } + + it "transitions from failed to pending" do + migration.retry_migration! + expect(migration).to be_pending + end + end + end + + describe "#all_subscriptions_completed?" do + it "returns false when there are no subscription migrations" do + expect(migration).not_to be_all_subscriptions_completed + end + + it "returns true when all subscription migrations are completed" do + create(:enriched_store_subscription_migration, :completed, enriched_store_migration: migration) + create(:enriched_store_subscription_migration, :completed, enriched_store_migration: migration) + + expect(migration).to be_all_subscriptions_completed + end + + it "returns false when some subscription migrations are not completed" do + create(:enriched_store_subscription_migration, :completed, enriched_store_migration: migration) + create(:enriched_store_subscription_migration, enriched_store_migration: migration) + + expect(migration).not_to be_all_subscriptions_completed + end + end +end diff --git a/spec/models/enriched_store_subscription_migration_spec.rb b/spec/models/enriched_store_subscription_migration_spec.rb new file mode 100644 index 0000000..425fa63 --- /dev/null +++ b/spec/models/enriched_store_subscription_migration_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EnrichedStoreSubscriptionMigration do + subject(:subscription_migration) { create(:enriched_store_subscription_migration) } + + describe "enums" do + it do + expect(subject).to define_enum_for(:status) + .backed_by_column_of_type(:enum) + .validating + .with_values( + pending: "pending", + comparing: "comparing", + reprocessing: "reprocessing", + waiting_for_enrichment: "waiting_for_enrichment", + deduplicating: "deduplicating", + dedup_paused: "dedup_paused", + validating: "validating", + completed: "completed", + failed: "failed" + ) + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:enriched_store_migration) + expect(subject).to belong_to(:subscription) + expect(subject).to belong_to(:organization) + end + end + + describe "AASM transitions" do + describe "#start_comparing" do + it "transitions from pending to comparing" do + expect(subscription_migration).to be_pending + subscription_migration.start_comparing! + expect(subscription_migration).to be_comparing + end + end + + describe "#start_reprocessing" do + subject(:subscription_migration) { create(:enriched_store_subscription_migration, :comparing) } + + it "transitions from comparing to reprocessing" do + subscription_migration.start_reprocessing! + expect(subscription_migration).to be_reprocessing + end + end + + describe "#start_waiting" do + subject(:subscription_migration) { create(:enriched_store_subscription_migration, :reprocessing) } + + it "transitions from reprocessing to waiting_for_enrichment" do + subscription_migration.start_waiting! + expect(subscription_migration).to be_waiting_for_enrichment + end + end + + describe "#start_deduplicating" do + subject(:subscription_migration) { create(:enriched_store_subscription_migration, :waiting_for_enrichment) } + + it "transitions from waiting_for_enrichment to deduplicating" do + subscription_migration.start_deduplicating! + expect(subscription_migration).to be_deduplicating + end + end + + describe "#pause_dedup" do + subject(:subscription_migration) { create(:enriched_store_subscription_migration, :deduplicating) } + + it "transitions from deduplicating to dedup_paused" do + subscription_migration.pause_dedup! + expect(subscription_migration).to be_dedup_paused + end + end + + describe "#start_validating" do + %i[deduplicating dedup_paused].each do |state| + it "transitions from #{state} to validating" do + record = create(:enriched_store_subscription_migration, state) + record.start_validating! + expect(record).to be_validating + end + end + end + + describe "#complete" do + %i[comparing validating].each do |state| + it "transitions from #{state} to completed" do + record = create(:enriched_store_subscription_migration, state) + record.complete! + expect(record).to be_completed + end + end + end + + describe "#fail" do + %i[pending comparing reprocessing waiting_for_enrichment deduplicating dedup_paused validating].each do |state| + it "transitions from #{state} to failed" do + record = if state == :pending + create(:enriched_store_subscription_migration) + else + create(:enriched_store_subscription_migration, state) + end + + record.fail! + expect(record).to be_failed + end + end + end + + describe "#retry_migration" do + subject(:subscription_migration) { create(:enriched_store_subscription_migration, :failed) } + + it "transitions from failed to pending" do + subscription_migration.retry_migration! + expect(subscription_migration).to be_pending + end + end + end +end diff --git a/spec/models/entitlement/entitlement_spec.rb b/spec/models/entitlement/entitlement_spec.rb new file mode 100644 index 0000000..099e244 --- /dev/null +++ b/spec/models/entitlement/entitlement_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::Entitlement do + subject { build(:entitlement) } + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:feature).class_name("Entitlement::Feature") + expect(subject).to belong_to(:plan).optional + expect(subject).to belong_to(:subscription).optional + expect(subject).to have_many(:values).class_name("Entitlement::EntitlementValue").dependent(:destroy) + end + end + + describe "validations" do + describe "exactly_one_parent_present validation" do + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:) } + + it "is valid when only plan_id is present" do + entitlement = build(:entitlement, organization:, feature:, plan:, subscription: nil) + expect(entitlement).to be_valid + end + + it "is valid when only subscription is present" do + entitlement = build(:entitlement, organization:, feature:, plan: nil, subscription:) + expect(entitlement).to be_valid + end + + it "is invalid when both plan_id and subscription are present" do + entitlement = build(:entitlement, organization:, feature:, plan:, subscription:) + expect(entitlement).not_to be_valid + expect(entitlement.errors[:base]).to eq(["one_of_plan_or_subscription_required"]) + end + + it "is invalid when neither plan_id nor subscription are present" do + entitlement = build(:entitlement, organization:, feature:, plan: nil, subscription: nil) + expect(entitlement).not_to be_valid + expect(entitlement.errors[:base]).to eq(["one_of_plan_or_subscription_required"]) + end + end + end +end diff --git a/spec/models/entitlement/entitlement_value_spec.rb b/spec/models/entitlement/entitlement_value_spec.rb new file mode 100644 index 0000000..7f6fdf5 --- /dev/null +++ b/spec/models/entitlement/entitlement_value_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::EntitlementValue do + subject { create(:entitlement_value) } + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:privilege).class_name("Entitlement::Privilege") + expect(subject).to belong_to(:entitlement).class_name("Entitlement::Entitlement") + end + end + + describe "validations" do + it do + expect(subject).to validate_presence_of(:entitlement_privilege_id) + expect(subject).to validate_presence_of(:entitlement_entitlement_id) + expect(subject).to validate_presence_of(:value) + end + end +end diff --git a/spec/models/entitlement/feature_spec.rb b/spec/models/entitlement/feature_spec.rb new file mode 100644 index 0000000..a33e59b --- /dev/null +++ b/spec/models/entitlement/feature_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::Feature do + subject { build(:feature) } + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to have_many(:privileges).class_name("Entitlement::Privilege").dependent(:destroy) + expect(subject).to have_many(:entitlements).class_name("Entitlement::Entitlement").dependent(:destroy) + expect(subject).to have_many(:entitlement_values).through(:entitlements).source(:values).class_name("Entitlement::EntitlementValue").dependent(:destroy) + expect(subject).to have_many(:plans).through(:entitlements) + end + end + + describe "validations" do + it do + expect(subject).to validate_presence_of(:code) + expect(subject).to validate_length_of(:code).is_at_most(255) + expect(subject).to validate_length_of(:name).is_at_most(255) + expect(subject).to validate_length_of(:description).is_at_most(600) + end + end + + describe "#subscriptions_count" do + it "returns the number of subscriptions" do + expect(subject.subscriptions_count).to eq(0) + entitlement = create(:entitlement, feature: subject) + create(:subscription, plan: entitlement.plan) + create(:subscription, :pending, plan: entitlement.plan) + create(:subscription, :terminated, plan: entitlement.plan) + create(:subscription, :canceled, plan: entitlement.plan) + expect(subject.subscriptions_count).to eq(2) + create(:subscription, plan: create(:plan, parent: entitlement.plan)) + create(:subscription, :pending, plan: create(:plan, parent: entitlement.plan)) + create(:subscription, :terminated, plan: create(:plan, parent: entitlement.plan)) + create(:subscription, :canceled, plan: create(:plan, parent: entitlement.plan)) + expect(subject.subscriptions_count).to eq(4) + end + end +end diff --git a/spec/models/entitlement/privilege_spec.rb b/spec/models/entitlement/privilege_spec.rb new file mode 100644 index 0000000..5e02a59 --- /dev/null +++ b/spec/models/entitlement/privilege_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::Privilege do + subject { build(:privilege) } + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:feature).class_name("Entitlement::Feature") + expect(subject).to have_many(:values).class_name("Entitlement::EntitlementValue").dependent(:destroy) + expect(subject).to have_many(:entitlements).class_name("Entitlement::Entitlement").through(:values) + end + end + + describe "validations" do + it do + expect(subject).to validate_presence_of(:code) + expect(subject).to validate_length_of(:code).is_at_most(255) + expect(subject).to validate_length_of(:name).is_at_most(255) + expect(subject).to validate_presence_of(:value_type) + expect(subject).to validate_inclusion_of(:value_type).in_array(Entitlement::Privilege::VALUE_TYPES) + end + + describe "#validate_config" do + before do + subject.value_type = "select" + end + + context "when value_type is select" do + it "is valid with proper select_options config" do + subject.config = {"select_options" => ["option1", "option2"]} + expect(subject).to be_valid + end + + it "is invalid with empty select_options array" do + subject.config = {"select_options" => []} + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + + it "is invalid with missing select_options key" do + subject.config = {"other_key" => ["option1"]} + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + + it "is invalid with additional keys alongside select_options" do + subject.config = { + "select_options" => ["option1"], + "extra_key" => "value" + } + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + + it "is invalid with select_options as non-array" do + subject.config = {"select_options" => "not_an_array"} + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + + it "is invalid with select_options not all strings" do + subject.config = {"select_options" => ["true", false]} + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + + it "is invalid with blank config" do + subject.config = nil + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + end + + context "when value_type is not select" do + %w[integer string boolean].each do |value_type| + context "when value_type is #{value_type}" do + before do + subject.value_type = value_type + end + + it "is valid with nil config" do + subject.config = nil + expect(subject).to be_valid + end + + it "is valid with empty hash config" do + subject.config = {} + expect(subject).to be_valid + end + + it "is invalid with any config present" do + subject.config = {"some_key" => "some_value"} + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + + it "is invalid with select_options config" do + subject.config = {"select_options" => ["option1"]} + expect(subject).not_to be_valid + expect(subject.errors[:config]).to include("invalid_format") + end + end + end + end + end + end + + describe "value types" do + it "supports integer type" do + privilege = create(:privilege, :integer_type) + expect(privilege.value_type).to eq("integer") + end + + it "supports string type" do + privilege = create(:privilege, :string_type) + expect(privilege.value_type).to eq("string") + end + + it "supports boolean type" do + privilege = create(:privilege, :boolean_type) + expect(privilege.value_type).to eq("boolean") + end + + it "supports select type" do + privilege = create(:privilege, :select_type) + expect(privilege.value_type).to eq("select") + expect(privilege.config).to include("select_options" => ["option1", "option2", "option3"]) + end + end + + describe "unique code per feature" do + it "fails if code already exist for the feature, unless it's soft deleted" do + feature = create(:feature) + privilege = create(:privilege, feature:, code: "test") + expect { create(:privilege, feature:, code: "test") }.to raise_error(ActiveRecord::RecordNotUnique) + privilege.update! deleted_at: Time.current + expect { create(:privilege, feature:, code: "test") }.not_to raise_error(ActiveRecord::RecordNotUnique) + end + end +end diff --git a/spec/models/entitlement/subscription_entitlement_privilege_spec.rb b/spec/models/entitlement/subscription_entitlement_privilege_spec.rb new file mode 100644 index 0000000..e0d0132 --- /dev/null +++ b/spec/models/entitlement/subscription_entitlement_privilege_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionEntitlementPrivilege do + describe "initialization" do + it "creates an instance with no attributes" do + privilege = described_class.new + + expect(privilege.organization_id).to be_nil + expect(privilege.entitlement_feature_id).to be_nil + expect(privilege.code).to be_nil + expect(privilege.value).to be_nil + expect(privilege.value_type).to be_nil + expect(privilege.plan_value).to be_nil + expect(privilege.subscription_value).to be_nil + expect(privilege.name).to be_nil + expect(privilege.config).to be_nil + expect(privilege.ordering_date).to be_nil + expect(privilege.plan_entitlement_id).to be_nil + expect(privilege.sub_entitlement_id).to be_nil + expect(privilege.plan_entitlement_value_id).to be_nil + expect(privilege.sub_entitlement_value_id).to be_nil + end + end + + describe "ActiveModel compliance" do + it "includes ActiveModel::Attributes" do + expect(described_class.ancestors).to include(ActiveModel::Model) + expect(described_class.ancestors).to include(ActiveModel::Attributes) + end + end + + describe "#to_h" do + it "returns a hash" do + entitlement = described_class.new(code: "seats", config: {json: true}.to_json) + hash = entitlement.to_h + expect(hash).to be_a(HashWithIndifferentAccess) + expect(hash[:config]).to be_a(HashWithIndifferentAccess) + end + end +end diff --git a/spec/models/entitlement/subscription_entitlement_spec.rb b/spec/models/entitlement/subscription_entitlement_spec.rb new file mode 100644 index 0000000..2b760ef --- /dev/null +++ b/spec/models/entitlement/subscription_entitlement_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionEntitlement do + describe "initialization" do + it "creates an instance with no attributes" do + entitlement = described_class.new + + expect(entitlement.organization_id).to be_nil + expect(entitlement.entitlement_feature_id).to be_nil + expect(entitlement.code).to be_nil + expect(entitlement.name).to be_nil + expect(entitlement.description).to be_nil + expect(entitlement.plan_entitlement_id).to be_nil + expect(entitlement.sub_entitlement_id).to be_nil + expect(entitlement.plan_id).to be_nil + expect(entitlement.subscription_id).to be_nil + expect(entitlement.ordering_date).to be_nil + expect(entitlement.privileges).to be_nil + end + end + + describe "ActiveModel compliance" do + it "includes ActiveModel::Attributes" do + expect(described_class.ancestors).to include(ActiveModel::Model) + expect(described_class.ancestors).to include(ActiveModel::Attributes) + end + end + + describe ".for_subscription" do + let(:organization) { create(:organization) } + let(:parent) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan: create(:plan, parent:)) } + + it "returns the result from SubscriptionEntitlementQuery" do + allow(Entitlement::SubscriptionEntitlementQuery).to receive(:call).with( + organization: subscription.organization, + filters: {subscription_id: subscription.id, plan_id: parent.id} + ).and_return("works") + + result = described_class.for_subscription(subscription) + + expect(result).to eq("works") + end + end + + describe "#to_h" do + it "returns a hash" do + privilege = Entitlement::SubscriptionEntitlementPrivilege.new(code: "max") + entitlement = described_class.new(code: "seats", privileges: [privilege]) + hash = entitlement.to_h + expect(hash).to be_a(HashWithIndifferentAccess) + expect(hash[:privileges].values).to all be_a(HashWithIndifferentAccess) + expect(hash[:privileges].keys).to eq ["max"] + + entitlement = described_class.new(code: "seats") + hash = entitlement.to_h + + expect(hash).to be_a(HashWithIndifferentAccess) + expect(hash[:privileges]).to eq({}) + end + end +end diff --git a/spec/models/entitlement/subscription_feature_removal_spec.rb b/spec/models/entitlement/subscription_feature_removal_spec.rb new file mode 100644 index 0000000..7119034 --- /dev/null +++ b/spec/models/entitlement/subscription_feature_removal_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionFeatureRemoval do + subject { build(:subscription_feature_removal) } + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:subscription) + expect(subject).to belong_to(:feature).class_name("Entitlement::Feature").optional + expect(subject).to belong_to(:privilege).class_name("Entitlement::Privilege").optional + end + end +end diff --git a/spec/models/error_detail_spec.rb b/spec/models/error_detail_spec.rb new file mode 100644 index 0000000..473ce5e --- /dev/null +++ b/spec/models/error_detail_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ErrorDetail do + it { is_expected.to belong_to(:owner) } + it { is_expected.to belong_to(:organization) } + + context "when creating an invoice generation error for an invoice" do + let(:invoice) { create(:invoice, :generating) } + let(:result) { BaseService::Result.new } + let(:error) { BaseService::ValidationFailure.new(result, messages: messages) } + let(:messages) { ["message1", "message2"] } + + let(:error_with_backtrace) do + error = OpenStruct.new + error.backtrace = "backtrace" + error + end + + describe ".create_generation_error_for" do + it "does nothing if the invoice is nil" do + expect(described_class.create_generation_error_for(invoice: nil, error:)).to eq(nil) + end + + it "creates an error detail with link to invoice as an owner" do + invoice_error = described_class.create_generation_error_for(invoice:, error:) + expect(invoice_error.owner).to eq(invoice) + end + + it "stores the error in the details: :error field" do + invoice_error = described_class.create_generation_error_for(invoice:, error:) + expect(invoice_error.details["error"]).to eq(error.inspect.to_json) + end + + it "stores the backtrace in the details: :backtrace field" do + invoice_error = described_class.create_generation_error_for(invoice:, error: error_with_backtrace) + expect(invoice_error.details["backtrace"]).to eq("backtrace") + end + + it "stores the subscriptions in the details: :subscriptions field" do + invoice_error = described_class.create_generation_error_for(invoice:, error:) + expect(invoice_error.details["subscriptions"]).to eq("[]") + end + + it "updates when create_for is called with the same invoice" do + invoice_error = described_class.create_generation_error_for(invoice:, error:) + id = invoice_error.id + + invoice_error = described_class.create_generation_error_for(invoice:, error:) + expect(invoice_error.id).to eq(id) + end + end + end +end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb new file mode 100644 index 0000000..a27eb45 --- /dev/null +++ b/spec/models/event_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Event do + subject { build(:event) } + + it { is_expected.to have_many(:enriched_events) } + + it { is_expected.to validate_presence_of(:transaction_id) } + it { is_expected.to validate_presence_of(:code) } + + describe "#customer" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:external_customer_id) { customer.external_id } + let(:external_subscription_id) { subscription.external_id } + + let(:timestamp) { Time.current - 1.second } + + let(:event) do + create( + :event, + organization:, + external_customer_id:, + external_subscription_id:, + timestamp: + ) + end + + it "returns the customer" do + expect(event.customer).to eq(customer) + end + + context "when a customer with the same external id was deleted" do + let(:deleted_at) { timestamp - 2.days } + let(:deleted_customer) do + create(:customer, organization:, external_id: external_customer_id, deleted_at:) + end + + before { deleted_customer } + + it "returns the customer" do + expect(event.customer).to eq(customer) + end + + context "when the timestamp is before the deleted date" do + let(:deleted_at) { timestamp + 2.days } + + it "returns the deleted customer" do + expect(event.customer).to eq(deleted_customer) + end + end + end + end + + describe "#subscription" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:, started_at:) } + + let(:started_at) { Time.current - 3.days } + let(:external_customer_id) { customer.external_id } + let(:external_subscription_id) { subscription.external_id } + let(:timestamp) { Time.current - 1.second } + + let(:event) do + create( + :event, + organization:, + external_customer_id:, + external_subscription_id:, + timestamp: + ) + end + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + + context "without external_customer_id" do + let(:external_customer_id) { nil } + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + end + + context "when subscription is terminated" do + let(:subscription) { create(:subscription, :terminated, organization:, customer:, started_at:) } + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + + context "when subscription is terminated just after the ingestion" do + before do + subscription.update!(terminated_at: timestamp + 0.2.seconds) + end + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + end + + context "when a new active subscription exists" do + let(:started_at) { 1.month.ago } + let(:timestamp) { 1.week.ago } + + let(:active_subscription) do + create( + :subscription, + customer:, + organization:, + started_at: 1.day.ago, + external_id: subscription.external_id + ) + end + + before { active_subscription } + + it "returns the active subscription" do + expect(event.subscription).to eq(subscription) + end + end + + context "when subscription is an upgrade/downgrade" do + let(:started_at) { 1.week.ago } + + let(:terminated_subscription) do + create( + :subscription, + :terminated, + organization:, + customer:, + external_id: external_subscription_id, + started_at: 1.month.ago, + terminated_at: timestamp - 1.day + ) + end + + before { terminated_subscription } + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + end + end + end +end diff --git a/spec/models/events/common_spec.rb b/spec/models/events/common_spec.rb new file mode 100644 index 0000000..fbbece4 --- /dev/null +++ b/spec/models/events/common_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::Common do + subject(:event) do + described_class.new( + id: nil, + organization_id: organization.id, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp:, + code: billable_metric.code, + properties: {} + ) + end + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization: organization) } + let(:timestamp) { Time.current - 1.second } + + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:, started_at:) } + + let(:started_at) { Time.current - 3.days } + let(:external_subscription_id) { subscription.external_id } + + describe "#persisted?" do + it { expect(event.persisted).to be_truthy } + + context "when persisted value is passed to the event" do + subject(:event) do + described_class.new( + id: nil, + organization_id: organization.id, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp:, + code: billable_metric.code, + properties: {}, + persisted: false + ) + end + + it "sets the value to the instance" do + expect(event.persisted).to be_falsey + end + end + end + + describe "#event_id" do + it "returns the transaction_id" do + expect(event.event_id).to eq(event.transaction_id) + end + + context "when id is set" do + before { event.id = "event-id" } + + it "returns the id" do + expect(event.event_id).to eq("event-id") + end + end + end + + describe "#organization" do + it "returns the organization" do + expect(event.organization).to eq(organization) + end + end + + describe "#billable_metric" do + it "returns the billable_metric" do + expect(event.billable_metric).to eq(billable_metric) + end + end + + describe "#subscription" do + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + + context "when subscription is terminated" do + let(:subscription) { create(:subscription, :terminated, organization:, customer:, started_at:) } + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + + context "when subscription is terminated just after the ingestion" do + before do + subscription.update!(terminated_at: timestamp + 0.2.seconds) + end + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + end + + context "when a new active subscription exists" do + let(:started_at) { 1.month.ago } + let(:timestamp) { 1.week.ago } + + let(:active_subscription) do + create( + :subscription, + customer:, + organization:, + started_at: 1.day.ago, + external_id: subscription.external_id + ) + end + + before { active_subscription } + + it "returns the active subscription" do + expect(event.subscription).to eq(subscription) + end + end + + context "when subscription is an upgrade/downgrade" do + let(:started_at) { 1.week.ago } + + let(:terminated_subscription) do + create( + :subscription, + :terminated, + organization:, + customer:, + external_id: external_subscription_id, + started_at: 1.month.ago, + terminated_at: timestamp - 1.day + ) + end + + before { terminated_subscription } + + it "returns the subscription" do + expect(event.subscription).to eq(subscription) + end + end + end + end + + describe "#as_json" do + it "returns the event as json" do + expect(event.as_json).to include( + "organization_id" => organization.id, + "transaction_id" => event.transaction_id, + "external_subscription_id" => subscription.external_id, + "code" => billable_metric.code, + "properties" => {}, + "timestamp" => timestamp.to_f + ) + end + end +end diff --git a/spec/models/feature_flag_spec.rb b/spec/models/feature_flag_spec.rb new file mode 100644 index 0000000..8522d85 --- /dev/null +++ b/spec/models/feature_flag_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FeatureFlag do + describe ".valid?" do + it "returns true for a valid flag" do + expect(described_class.valid?("multiple_payment_methods")).to be(true) + end + + it "returns true for the multi_entity_billing flag" do + expect(described_class.valid?("multi_entity_billing")).to be(true) + end + + it "returns false for an invalid flag" do + expect(described_class.valid?("invalid_flag")).to be(false) + end + end + + describe ".validate!" do + context "when in production environment" do + before { allow(Rails.env).to receive(:production?).and_return(true) } + + it "does not raise an error for invalid flags" do + expect { described_class.validate!("invalid_flag") }.not_to raise_error + end + end + + context "when not in production environment" do + before { allow(Rails.env).to receive(:production?).and_return(false) } + + it "does not raise an error for valid flags" do + expect { described_class.validate!("multiple_payment_methods") }.not_to raise_error + end + + it "raises an error for invalid flags" do + expect { described_class.validate!("invalid_flag") } + .to raise_error(ArgumentError, "Unknown feature flag: invalid_flag") + end + end + end + + describe ".sanitize!" do + let(:organization_with_valid_flags) { create(:organization, feature_flags: ["multiple_payment_methods"]) } + let(:organization_with_invalid_flags) { create(:organization, feature_flags: ["invalid_flag", "another_invalid"]) } + let(:organization_with_mixed_flags) { create(:organization, feature_flags: ["multiple_payment_methods", "invalid_flag"]) } + let(:organization_without_flags) { create(:organization, feature_flags: []) } + + before do + organization_with_valid_flags + organization_with_invalid_flags + organization_with_mixed_flags + organization_without_flags + end + + it "removes invalid flags from organizations" do + described_class.sanitize! + + expect(organization_with_valid_flags.reload.feature_flags).to eq(["multiple_payment_methods"]) + expect(organization_with_invalid_flags.reload.feature_flags).to eq([]) + expect(organization_with_mixed_flags.reload.feature_flags).to eq(["multiple_payment_methods"]) + expect(organization_without_flags.reload.feature_flags).to eq([]) + end + + it "does not update organizations that only have valid flags" do + expect { described_class.sanitize! } + .not_to change { organization_with_valid_flags.reload.updated_at } + end + end +end diff --git a/spec/models/fee/applied_tax_spec.rb b/spec/models/fee/applied_tax_spec.rb new file mode 100644 index 0000000..5e8cdf5 --- /dev/null +++ b/spec/models/fee/applied_tax_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fee::AppliedTax do + subject(:applied_tax) { create(:fee_applied_tax) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/fee_spec.rb b/spec/models/fee_spec.rb new file mode 100644 index 0000000..b29b93a --- /dev/null +++ b/spec/models/fee_spec.rb @@ -0,0 +1,917 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fee do + subject { build(:fee) } + + it { is_expected.to belong_to(:add_on).optional } + it { is_expected.to belong_to(:charge).optional } + it { is_expected.to belong_to(:fixed_charge).optional } + it { is_expected.to have_many(:presentation_breakdowns) } + it { is_expected.to have_one(:fixed_charge_add_on).through(:fixed_charge) } + it { is_expected.to have_one(:adjusted_fee).dependent(:nullify) } + it { is_expected.to have_one(:billable_metric).through(:charge) } + it { is_expected.to have_one(:customer).through(:subscription) } + it { is_expected.to have_one(:pricing_unit_usage).dependent(:destroy) } + it { is_expected.to have_one(:true_up_fee).with_foreign_key(:true_up_parent_fee_id).class_name("Fee").dependent(:destroy) } + it { is_expected.to belong_to(:original_fee).class_name("Fee").optional } + + describe "#ordered_by_period" do + let(:fee1) do + create(:fee, properties: { + "from_datetime" => "2021-02-01T00:00:00Z", + "to_datetime" => "2021-03-31T23:59:59Z" + }) + end + let(:fee2) do + create(:fee, properties: { + "from_datetime" => "2021-03-01T00:00:00Z", + "to_datetime" => "2021-04-20T23:59:59Z" + }) + end + let(:fee3) do + create(:fee, properties: { + "from_datetime" => "2021-01-01T00:00:00Z", + "to_datetime" => "2021-02-18T23:59:59Z" + }) + end + let(:fee4) do + create(:fee, properties: { + "from_datetime" => "2021-01-01T00:00:00Z", + "to_datetime" => "2021-01-31T23:59:59Z" + }) + end + + before do + described_class.destroy_all + fee1 + fee2 + fee3 + fee4 + end + + it "returns fees in right order" do + expect(described_class.ordered_by_period).to eq([fee4, fee3, fee1, fee2]) + end + end + + describe "#item_code" do + context "when it is a subscription fee" do + let(:subscription) { create(:subscription) } + + it "returns related subscription code" do + expect(described_class.new(subscription:, fee_type: "subscription").item_code) + .to eq(subscription.plan.code) + end + end + + context "when it is a charge fee" do + let(:charge) { create(:standard_charge) } + + it "returns related billable metric code" do + expect(described_class.new(charge:, fee_type: "charge").item_code) + .to eq(charge.billable_metric.code) + end + end + + context "when it is a add-on fee" do + let(:applied_add_on) { create(:applied_add_on) } + + it "returns add on code" do + expect(described_class.new(applied_add_on:, fee_type: "add_on").item_code) + .to eq(applied_add_on.add_on.code) + end + end + + context "when it is a credit fee" do + it "returns add on code" do + expect(described_class.new(fee_type: "credit").item_code).to eq("credit") + end + end + + context "when it is an pay_in_advance charge fee" do + let(:charge) { create(:standard_charge, :pay_in_advance) } + + it "returns related billable metric code" do + expect(described_class.new(charge:, fee_type: "charge").item_code) + .to eq(charge.billable_metric.code) + end + end + + context "when it is a fixed charge fee" do + let(:fee) { create(:fixed_charge_fee) } + + it "returns related fixed charge add on code" do + expect(fee.item_code).to eq(fee.fixed_charge.add_on.code) + end + end + end + + describe "#invoice_name" do + subject(:fee_invoice_name) { fee.invoice_name } + + context "when invoice display name is present" do + let(:fee) { build(:fee) } + + it "returns fee invoice display name" do + expect(fee_invoice_name).to eq(fee.invoice_display_name) + end + end + + context "when invoice display name is blank" do + let(:invoice_display_name) { [nil, ""].sample } + + context "when it is a subscription fee" do + let(:fee) { build(:fee, subscription:, fee_type: "subscription", invoice_display_name:) } + let(:subscription) { create(:subscription) } + + it "returns related subscription name" do + expect(fee_invoice_name).to eq(subscription.plan.invoice_name) + end + end + + context "when it is a commitment fee" do + let(:fee) { build(:minimum_commitment_fee, invoice_display_name:) } + + it "returns related subscription name" do + expect(fee_invoice_name).to eq(fee.subscription.plan.invoice_name) + end + end + + context "when it is a charge fee" do + let(:fee) { build(:fee, charge:, fee_type: "charge", invoice_display_name:) } + let(:charge) { create(:standard_charge, invoice_display_name: charge_invoice_display_name) } + + context "when charge has invoice display name present" do + let(:charge_invoice_display_name) { Faker::Fantasy::Tolkien.location } + + it "returns charge invoice display name" do + expect(fee_invoice_name).to eq(charge.invoice_display_name) + end + end + + context "when charge has invoice display name blank" do + let(:charge_invoice_display_name) { [nil, ""].sample } + + it "returns related billable metric name" do + expect(fee_invoice_name).to eq(charge.billable_metric.name) + end + end + end + + context "when it is a fixed charge fee" do + let(:fee) { build(:fixed_charge_fee, invoice_display_name:, fixed_charge:) } + + context "when fixed charge has invoice display name present" do + let(:fixed_charge) { create(:fixed_charge, invoice_display_name: Faker::Fantasy::Tolkien.location) } + + it "returns related fixed charge add on code" do + expect(fee_invoice_name).to eq(fee.fixed_charge.invoice_display_name) + end + end + + context "when fixed charge has invoice display name blank" do + let(:fixed_charge) { create(:fixed_charge, invoice_display_name: [nil, ""].sample) } + + it "returns related fixed charge add on invoice name" do + expect(fee_invoice_name).to eq(fee.fixed_charge.add_on.invoice_name) + end + end + end + + context "when it is a add-on fee" do + let(:fee) { build(:fee, applied_add_on:, fee_type: "add_on", invoice_display_name:) } + let(:applied_add_on) { create(:applied_add_on) } + + it "returns add on name" do + expect(fee_invoice_name).to eq(applied_add_on.add_on.invoice_name) + end + end + + context "when it is a credit fee" do + let(:wallet) { create(:wallet, name: "My Wallet") } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, name:) } + let(:name) { "Custom Transaction" } + let(:fee) { build(:fee, fee_type: "credit", invoice_display_name:, invoiceable: wallet_transaction) } + + context "when wallet transaction has a name" do + it "returns the wallet transaction name" do + expect(fee_invoice_name).to eq("Custom Transaction") + end + end + + context "when wallet transaction has no name" do + let(:name) { nil } + + it "returns 'credit'" do + expect(fee_invoice_name).to eq("credit") + end + end + + context "when invoiceable is nil" do + let(:fee) { build(:fee, fee_type: "credit", invoice_display_name:, invoiceable: nil) } + + it "returns 'credit'" do + expect(fee_invoice_name).to eq("credit") + end + end + end + + context "when it is an pay_in_advance charge fee" do + let(:fee) { build(:fee, charge:, fee_type: "charge", invoice_display_name:) } + let(:charge) { create(:standard_charge, :pay_in_advance, invoice_display_name: charge_invoice_display_name) } + + context "when charge has invoice display name present" do + let(:charge_invoice_display_name) { Faker::Fantasy::Tolkien.location } + + it "returns charge invoice display name" do + expect(fee_invoice_name).to eq(charge.invoice_display_name) + end + end + + context "when charge has invoice display name blank" do + let(:charge_invoice_display_name) { [nil, ""].sample } + + it "returns related billable metric name" do + expect(fee_invoice_name).to eq(charge.billable_metric.name) + end + end + end + end + end + + describe "#item_name" do + context "when it is a subscription fee" do + let(:subscription) { create(:subscription) } + + it "returns related subscription name" do + expect(described_class.new(subscription:, fee_type: "subscription").item_name) + .to eq(subscription.plan.name) + end + end + + context "when it is a charge fee" do + let(:charge) { create(:standard_charge) } + + it "returns related billable metric name" do + expect(described_class.new(charge:, fee_type: "charge").item_name) + .to eq(charge.billable_metric.name) + end + end + + context "when it is a fixed charge fee" do + let(:fee) { create(:fixed_charge_fee) } + + it "returns related fixed charge add on name" do + expect(fee.item_name).to eq(fee.fixed_charge.add_on.name) + end + end + + context "when it is a add-on fee" do + let(:applied_add_on) { create(:applied_add_on) } + + it "returns add on name" do + expect(described_class.new(applied_add_on:, fee_type: "add_on").item_name) + .to eq(applied_add_on.add_on.name) + end + end + + context "when it is a credit fee" do + let(:wallet) { create(:wallet, name: "My Wallet") } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, name:) } + let(:name) { "Custom Transaction" } + let(:fee) { described_class.new(fee_type: "credit", invoiceable: wallet_transaction) } + + context "when wallet transaction has a name" do + it "returns the wallet transaction name" do + expect(fee.item_name).to eq("Custom Transaction") + end + end + + context "when wallet transaction has no name" do + let(:name) { nil } + + it "returns 'credit'" do + expect(fee.item_name).to eq("credit") + end + end + + context "when invoiceable is nil" do + let(:fee) { described_class.new(fee_type: "credit", invoiceable: nil) } + + it "returns 'credit'" do + expect(fee.item_name).to eq("credit") + end + end + end + + context "when it is an pay_in_advance charge fee" do + let(:charge) { create(:standard_charge, :pay_in_advance) } + + it "returns related billable metric name" do + expect(described_class.new(charge:, fee_type: "charge").item_name) + .to eq(charge.billable_metric.name) + end + end + end + + describe "#item_description" do + context "when it is a subscription fee" do + let(:subscription) { create(:subscription) } + + it "returns related subscription description" do + expect(described_class.new(subscription:, fee_type: "subscription").item_description) + .to eq(subscription.plan.description) + end + end + + context "when it is a charge fee" do + let(:charge) { create(:standard_charge) } + + it "returns related billable metric description" do + expect(described_class.new(charge:, fee_type: "charge").item_description) + .to eq(charge.billable_metric.description) + end + end + + context "when it is a fixed charge fee" do + let(:fee) { create(:fixed_charge_fee) } + + it "returns related fixed charge add on description" do + expect(fee.item_description).to eq(fee.fixed_charge.add_on.description) + end + end + + context "when it is a add-on fee" do + let(:applied_add_on) { create(:applied_add_on) } + + it "returns add on description" do + expect(described_class.new(applied_add_on:, fee_type: "add_on").item_description) + .to eq(applied_add_on.add_on.description) + end + end + + context "when it is a credit fee" do + it "returns 'credit'" do + expect(described_class.new(fee_type: "credit").item_description).to eq("credit") + end + end + + context "when it is an pay_in_advance charge fee" do + let(:charge) { create(:standard_charge, :pay_in_advance) } + + it "returns related billable metric description" do + expect(described_class.new(charge:, fee_type: "charge").item_description) + .to eq(charge.billable_metric.description) + end + end + end + + describe "#item_type" do + context "when it is a subscription fee" do + let(:subscription) { create(:subscription) } + + it "returns subscription" do + expect(described_class.new(subscription:, fee_type: "subscription").item_type) + .to eq("Subscription") + end + end + + context "when it is a charge fee" do + let(:charge) { create(:standard_charge) } + + it "returns billable metric" do + expect(described_class.new(charge:, fee_type: "charge").item_type) + .to eq("BillableMetric") + end + end + + context "when it is a fixed charge fee" do + let(:fee) { create(:fixed_charge_fee) } + + it "returns fixed charge" do + expect(fee.item_type).to eq("AddOn") + end + end + + context "when it is a add-on fee" do + let(:applied_add_on) { create(:applied_add_on) } + + it "returns add on" do + expect(described_class.new(applied_add_on:, fee_type: "add_on").item_type) + .to eq("AddOn") + end + end + + context "when it is a credit fee" do + it "returns wallet transaction" do + expect(described_class.new(fee_type: "credit").item_type).to eq("WalletTransaction") + end + end + + context "when it is an pay_in_advance charge fee" do + let(:charge) { create(:standard_charge, :pay_in_advance) } + + it "returns billable metric" do + expect(described_class.new(charge:, fee_type: "charge").item_type) + .to eq("BillableMetric") + end + end + end + + describe "#item_source" do + context "when it is a subscription fee" do + let(:subscription) { create(:subscription) } + + it "returns subscription" do + expect(described_class.new(subscription:, fee_type: "subscription").item_source) + .to eq(subscription.plan.code) + end + end + + context "when it is a charge fee" do + let(:charge) { create(:standard_charge) } + + it "returns billable metric" do + expect(described_class.new(charge:, fee_type: "charge").item_source) + .to eq(charge.billable_metric.code) + end + end + + context "when it is a fixed charge fee" do + let(:fee) { create(:fixed_charge_fee) } + + it "returns fixed charge" do + expect(fee.item_source).to eq(fee.fixed_charge.add_on.code) + end + end + + context "when it is a add-on fee" do + let(:applied_add_on) { create(:applied_add_on) } + + it "returns add on" do + expect(described_class.new(applied_add_on:, fee_type: "add_on").item_source) + .to eq(applied_add_on.add_on.code) + end + end + + context "when it is a credit fee" do + it "returns wallet transaction" do + expect(described_class.new(fee_type: "credit").item_source).to eq("consumed_credits") + end + end + + context "when it is an pay_in_advance charge fee" do + let(:charge) { create(:standard_charge, :pay_in_advance) } + + it "returns billable metric" do + expect(described_class.new(charge:, fee_type: "charge").item_source) + .to eq(charge.billable_metric.code) + end + end + end + + describe "#item_id" do + context "when it is a subscription fee" do + let(:subscription) { create(:subscription) } + + it "returns the subscription id" do + expect(described_class.new(subscription:, fee_type: "subscription").item_id) + .to eq(subscription.id) + end + end + + context "when it is a charge fee" do + let(:charge) { create(:standard_charge) } + + it "returns the billable metric id" do + expect(described_class.new(charge:, fee_type: "charge").item_id) + .to eq(charge.billable_metric.id) + end + end + + context "when it is a fixed charge fee" do + let(:fee) { create(:fixed_charge_fee) } + + it "returns the fixed charge add on id" do + expect(fee.item_id).to eq(fee.fixed_charge.add_on.id) + end + end + + context "when it is a add-on fee" do + let(:applied_add_on) { create(:applied_add_on) } + + it "returns the add on id" do + expect(described_class.new(applied_add_on:, fee_type: "add_on").item_id) + .to eq(applied_add_on.add_on_id) + end + end + + context "when it is a credit fee" do + let(:wallet_transaction) { create(:wallet_transaction) } + + it "returns the wallet transaction id" do + expect(described_class.new(fee_type: "credit", invoiceable: wallet_transaction).item_id) + .to eq(wallet_transaction.id) + end + end + + context "when it is an pay_in_advance charge fee" do + let(:charge) { create(:standard_charge, :pay_in_advance) } + + it "returns the billable metric id" do + expect(described_class.new(charge:, fee_type: "charge").item_id) + .to eq(charge.billable_metric.id) + end + end + end + + describe "#total_amount_cents" do + let(:fee) { create(:fee, amount_cents: 100, taxes_amount_cents: 20) } + + it "returns the sum of amount and taxes" do + expect(fee.total_amount_cents).to eq(120) + end + end + + describe "#total_amount_currency" do + let(:fee) { create(:fee, amount_currency: "EUR") } + + it { expect(fee.total_amount_currency).to eq("EUR") } + end + + describe "#precise_total_amount_cents" do + subject(:method_call) { fee.precise_total_amount_cents } + + let(:fee) { create(:fee, precise_amount_cents: 200.0000000123, taxes_precise_amount_cents: 20.00000000012) } + + it "returns sum of precise amount cents and taxes precise amount cents" do + expect(subject).to eq(220.00000001242) + end + end + + describe "#sub_total_excluding_taxes_precise_amount_cents" do + subject(:method_call) { fee.sub_total_excluding_taxes_precise_amount_cents } + + let(:fee) { create(:fee, precise_amount_cents: 200.00456000123, precise_coupons_amount_cents: 150.00123) } + + it "returns sub total minus coupons amount cents" do + expect(subject).to eq(50.00333000123) + end + end + + describe "#invoice_sorting_clause" do + let(:charge) { create(:standard_charge, properties:) } + let(:fee) { described_class.new(charge:, fee_type: "charge", grouped_by:) } + let(:grouped_by) do + { + "key_1" => "mercredi", + "key_2" => "week_01", + "key_3" => "2024" + } + end + let(:properties) do + { + "amount" => "5", + "grouped_by" => %w[key_1 key_2 key_3] + } + end + + context "when it is standard charge fee with grouped_by property" do + it "returns valid response" do + expect(fee.invoice_sorting_clause) + .to eq("#{fee.invoice_name} #{fee.grouped_by.values.join} #{fee.filter_display_name}".downcase) + end + end + + context "when missing grouped_by property" do + let(:properties) do + { + "amount" => "5" + } + end + + it "returns valid response" do + expect(fee.invoice_sorting_clause).to eq("#{fee.invoice_name} #{fee.grouped_by.values.join} #{fee.filter_display_name}".downcase) + end + end + end + + describe "#non_zero?" do + subject { fee.non_zero? } + + let(:fee) { build(:fee, units:, amount_cents:, events_count:) } + let(:units) { 0 } + let(:amount_cents) { 0 } + let(:events_count) { 0 } + + context "when units, amount_cents and events_count are all zero" do + it { is_expected.to be false } + end + + context "when only units are positive" do + let(:units) { 5 } + + it { is_expected.to be true } + end + + context "when only amount_cents are positive" do + let(:amount_cents) { 100 } + + it { is_expected.to be true } + end + + context "when only events_count is positive" do + let(:events_count) { 3 } + + it { is_expected.to be true } + end + + context "when events_count is nil" do + let(:events_count) { nil } + + it { is_expected.to be false } + end + end + + describe "#has_charge_filter?" do + subject(:fee) { create(:add_on_fee) } + + it { expect(fee).not_to be_has_charge_filters } + + context "when fee is a charge fee" do + subject(:fee) { create(:charge_fee) } + + it { expect(fee).not_to be_has_charge_filters } + + context "when charge has filters" do + let(:charge_filter) { create(:charge_filter, charge: fee.charge) } + + before { charge_filter } + + it { expect(fee).to be_has_charge_filters } + end + end + end + + describe "#compute_precise_credit_amount_cents" do + subject { fee.compute_precise_credit_amount_cents(credit_amount, base_amount_cents) } + + let(:fee) { create(:add_on_fee, amount_cents: 500, precise_coupons_amount_cents: 100) } + let(:credit_amount) { 10 } + + context "when base amount cents is non-zero" do + let(:base_amount_cents) { 5 } + + it "returns correct value" do + expect(subject).to eq(800) + end + end + + context "when base amount cents is zero" do + let(:base_amount_cents) { 0 } + + it "returns zero" do + expect(subject).to eq(0) + end + end + end + + describe "#creditable_amount_cents" do + subject { fee.creditable_amount_cents } + + let(:amount_cents) { 1000 } + + context "when fee_type is subscription" do + let(:fee) { create(:fee, fee_type: :subscription, amount_cents:) } + + it "returns the remaining amount" do + expect(subject).to eq(1000) + end + end + + context "when fee_type is credit" do + let(:wallet) { create(:wallet, balance_cents: 500, customer: invoice.customer) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, remaining_amount_cents: 500) } + let(:invoice) { create(:invoice, invoice_type: :credit) } + let(:fee) { create(:fee, fee_type: :credit, amount_cents:, invoice:, invoiceable: wallet_transaction) } + + it "returns the remaining amount of the inbound transaction" do + expect(subject).to eq(500) + end + + context "when remaining amount exceeds fee remaining amount" do + let(:wallet) { create(:wallet, balance_cents: 1500, customer: invoice.customer) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, remaining_amount_cents: 1500) } + + it "returns the fee remaining amount" do + expect(subject).to eq(1000) + end + end + + context "when credit note items reduce remaining amount" do + let(:wallet) { create(:wallet, balance_cents: 1500, customer: invoice.customer) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, remaining_amount_cents: 1500) } + + before { create(:credit_note_item, fee:, amount_cents: 300) } + + it "returns the reduced remaining amount" do + expect(subject).to eq(700) + end + end + end + end + + describe "#creditable_from_wallet_amount_cents" do + subject { fee.creditable_from_wallet_amount_cents } + + context "when fee is not credit" do + let(:fee) { create(:fee, fee_type: :subscription) } + + it "returns 0" do + expect(subject).to eq(0) + end + end + + context "when fee is credit" do + let(:wallet) { create(:wallet, balance_cents: 2000, customer: invoice.customer) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, remaining_amount_cents: 500) } + let(:invoice) { create(:invoice, invoice_type: :credit) } + let(:fee) { create(:fee, fee_type: :credit, amount_cents: 1000, invoice:, invoiceable: wallet_transaction) } + + it "returns the remaining amount of the inbound transaction" do + expect(subject).to eq(500) + end + + context "when wallet is terminated" do + let(:wallet) { create(:wallet, balance_cents: 500, status: :terminated, customer: invoice.customer) } + + it "returns 0" do + expect(subject).to eq(0) + end + end + + context "when remaining_amount_cents is nil" do + let(:wallet_transaction) { create(:wallet_transaction, wallet:, remaining_amount_cents: nil) } + + it "returns 0" do + expect(subject).to eq(0) + end + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, balance_cents: 500, customer: invoice.customer, traceable: false) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + + it "returns the wallet balance" do + expect(subject).to eq(500) + end + end + + context "when invoiceable is nil (historical fee)" do + let(:invoice) { create(:invoice, invoice_type: :credit) } + let(:fee) { create(:fee, fee_type: :credit, amount_cents: 1000, invoice:, invoiceable: nil) } + + it "returns 0" do + expect(subject).to eq(0) + end + end + end + end + + describe "#prepaid_credit_fee_wallet" do + subject { fee.prepaid_credit_fee_wallet } + + context "when fee is not credit" do + let(:fee) { create(:fee, fee_type: :subscription) } + + it "returns nil" do + expect(subject).to be_nil + end + end + + context "when fee is credit" do + let(:wallet) { create(:wallet, customer: invoice.customer) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + let(:invoice) { create(:invoice, invoice_type: :credit) } + let(:fee) { create(:fee, fee_type: :credit, invoice:, invoiceable: wallet_transaction) } + + it "returns the wallet" do + expect(subject).to eq(wallet) + end + + context "when invoiceable is nil (historical fee)" do + let(:fee) { create(:fee, fee_type: :credit, invoice:, invoiceable: nil) } + + it "returns nil" do + expect(subject).to be_nil + end + end + end + end + + describe "#offsettable_amount_cents" do + subject { fee.offsettable_amount_cents } + + let(:fee) { create(:fee, fee_type:, amount_cents: 1000, invoice:) } + + context "with credit invoices" do + let(:fee_type) { "credit" } + + context "when payment is pending" do + let(:invoice) { create(:invoice, invoice_type: :credit, payment_status: :pending) } + + it "returns full amount" do + expect(subject).to eq(1000) + end + end + + context "when payment succeeded" do + let(:invoice) { create(:invoice, invoice_type: :credit, payment_status: :succeeded) } + let(:wallet) { create(:wallet, balance_cents: 500, customer: invoice.customer) } + + before { fee.update(invoiceable: create(:wallet_transaction, wallet:, remaining_amount_cents: 500)) } + + it "returns the remaining amount of the inbound transaction" do + expect(subject).to eq(500) + end + end + + context "when payment failed" do + let(:invoice) { create(:invoice, invoice_type: :credit, payment_status: :failed) } + + it "returns full amount" do + expect(subject).to eq(1000) + end + end + end + + context "with regular invoices" do + let(:invoice) { create(:invoice, invoice_type: :subscription) } + let(:fee_type) { "subscription" } + + it "returns full amount when no credit notes" do + expect(subject).to eq(1000) + end + + it "deducts credit note items from amount" do + create(:credit_note_item, fee:, amount_cents: 300) + expect(subject).to eq(700) # 1000 - 300 + end + + it "deducts multiple credit note items" do + create(:credit_note_item, fee:, amount_cents: 200) + create(:credit_note_item, fee:, amount_cents: 150) + expect(subject).to eq(650) # 1000 - 200 - 150 + end + end + end + + describe "#basic_rate_percentage?" do + let(:fee) { create(:fee, fee_type: :charge, charge:, amount_cents: 1000, total_aggregated_units: 1) } + let(:charge) { create(:standard_charge) } + + it "returns false if charge model is not percentage" do + expect(fee).not_to be_basic_rate_percentage + end + + context "when charge model is percentage but has other properties except rate" do + let(:charge) { create(:charge, charge_model: "percentage", properties: {rate: "0", fixed_amount: "20"}) } + + it "returns false" do + expect(fee).not_to be_basic_rate_percentage + end + end + + context "when properties of percentage charge contain only rate" do + let(:charge) { create(:charge, charge_model: "percentage", properties: {rate: "0"}) } + + it "returns true" do + expect(fee).to be_basic_rate_percentage + end + end + + context "when charge is percentage and there are charge filters" do + let(:charge) { create(:charge, charge_model: "percentage", properties: {rate: "0"}) } + + before { fee.update!(charge_filter:) } + + context "when filter has other properties except rate" do + let(:charge_filter) { create(:charge_filter, charge:, properties: {rate: "0", fixed_amount: "20"}) } + + it "returns false" do + expect(fee).not_to be_basic_rate_percentage + end + end + + context "when filter properties contain only rate" do + let(:charge_filter) { create(:charge_filter, charge:, properties: {rate: "0"}) } + + it "returns true" do + expect(fee).to be_basic_rate_percentage + end + end + end + end +end diff --git a/spec/models/fixed_charge/applied_tax_spec.rb b/spec/models/fixed_charge/applied_tax_spec.rb new file mode 100644 index 0000000..96a5cd4 --- /dev/null +++ b/spec/models/fixed_charge/applied_tax_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharge::AppliedTax do + it { is_expected.to belong_to(:fixed_charge) } + it { is_expected.to belong_to(:tax) } + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/fixed_charge_event_spec.rb b/spec/models/fixed_charge_event_spec.rb new file mode 100644 index 0000000..5149c37 --- /dev/null +++ b/spec/models/fixed_charge_event_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedChargeEvent do + subject { build(:fixed_charge_event) } + + it { expect(described_class).to be_soft_deletable } + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:subscription) } + it { is_expected.to belong_to(:fixed_charge) } + + it { is_expected.to validate_numericality_of(:units).is_greater_than_or_equal_to(0) } +end diff --git a/spec/models/fixed_charge_spec.rb b/spec/models/fixed_charge_spec.rb new file mode 100644 index 0000000..ceafb6e --- /dev/null +++ b/spec/models/fixed_charge_spec.rb @@ -0,0 +1,371 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharge do + subject { build(:fixed_charge) } + + it_behaves_like "paper_trail traceable" + + it { expect(described_class).to be_soft_deletable } + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:plan) } + it { is_expected.to belong_to(:add_on) } + it { is_expected.to belong_to(:parent).class_name("FixedCharge").optional } + it { is_expected.to have_many(:children).class_name("FixedCharge").dependent(:nullify) } + it { is_expected.to have_many(:applied_taxes).class_name("FixedCharge::AppliedTax").dependent(:destroy) } + it { is_expected.to have_many(:taxes).through(:applied_taxes) } + it { is_expected.to have_many(:fees) } + it { is_expected.to have_many(:events).class_name("FixedChargeEvent").dependent(:destroy) } + + it { is_expected.to validate_numericality_of(:units).is_greater_than_or_equal_to(0) } + it { is_expected.to validate_presence_of(:charge_model) } + it { is_expected.to validate_presence_of(:code) } + it { is_expected.to validate_exclusion_of(:pay_in_advance).in_array([nil]) } + it { is_expected.to validate_exclusion_of(:prorated).in_array([nil]) } + it { is_expected.to validate_presence_of(:properties) } + + describe "validations" do + describe "code" do + it "validates uniqueness scoped to plan_id for parent fixed_charges" do + existing = create(:fixed_charge, code: "my_code") + new_fixed_charge = build(:fixed_charge, code: "my_code", plan: existing.plan) + expect(new_fixed_charge).not_to be_valid + expect(new_fixed_charge.errors[:code]).to include("value_already_exist") + end + + it "allows same code on different plans" do + create(:fixed_charge, code: "my_code") + new_fixed_charge = build(:fixed_charge, code: "my_code") + expect(new_fixed_charge).to be_valid + end + + it "allows same code on soft-deleted fixed_charges" do + existing = create(:fixed_charge, code: "my_code") + existing.discard + new_fixed_charge = build(:fixed_charge, code: "my_code", plan: existing.plan) + expect(new_fixed_charge).to be_valid + end + + it "allows same code for child fixed_charges" do + parent = create(:fixed_charge, code: "my_code") + child = build(:fixed_charge, code: "my_code", plan: parent.plan, parent:) + expect(child).to be_valid + end + end + end + + describe "#validate_properties" do + context "with standard charge model" do + subject(:fixed_charge) { build(:fixed_charge, charge_model: "standard", properties:) } + + let(:properties) { {amount: "invalid"} } + let(:validation_service) { instance_double(Charges::Validators::StandardService) } + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: {amount: ["invalid_amount"]} + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::StandardService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(fixed_charge).not_to be_valid + expect(fixed_charge.errors.messages.keys).to include(:properties) + expect(fixed_charge.errors.messages[:properties]).to include("invalid_amount") + + expect(Charges::Validators::StandardService).to have_received(:new).with(charge: fixed_charge) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + end + + context "with graduated charge model" do + subject(:fixed_charge) { build(:fixed_charge, :graduated, properties:) } + + let(:properties) { {graduated_ranges: [{"foo" => "bar"}]} } + let(:validation_service) { instance_double(Charges::Validators::GraduatedService) } + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: { + amount: ["invalid_amount"], + ranges: ["invalid_graduated_ranges"] + } + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::GraduatedService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(fixed_charge).not_to be_valid + expect(fixed_charge.errors.messages.keys).to include(:properties) + expect(fixed_charge.errors.messages[:properties]).to include("invalid_amount") + expect(fixed_charge.errors.messages[:properties]).to include("invalid_graduated_ranges") + + expect(Charges::Validators::GraduatedService).to have_received(:new).with(charge: fixed_charge) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + end + + context "with volume charge model" do + subject(:fixed_charge) { build(:fixed_charge, :volume, properties:) } + + let(:properties) { {volume_ranges: [{"foo" => "bar"}]} } + let(:validation_service) { instance_double(Charges::Validators::VolumeService) } + let(:service_response) do + BaseService::Result.new.validation_failure!( + errors: {ranges: ["invalid_volume_ranges"]} + ) + end + + it "delegates to a validation service" do + allow(Charges::Validators::VolumeService).to receive(:new) + .and_return(validation_service) + allow(validation_service).to receive(:valid?) + .and_return(false) + allow(validation_service).to receive(:result) + .and_return(service_response) + + expect(fixed_charge).not_to be_valid + expect(fixed_charge.errors.messages.keys).to include(:properties) + expect(fixed_charge.errors.messages[:properties]).to include("invalid_volume_ranges") + + expect(Charges::Validators::VolumeService).to have_received(:new).with(charge: fixed_charge) + expect(validation_service).to have_received(:valid?) + expect(validation_service).to have_received(:result) + end + end + end + + describe "scopes" do + let(:scoped) { create(:fixed_charge) } + let(:deleted) { create(:fixed_charge, :deleted) } + let(:pay_in_advance) { create(:fixed_charge, pay_in_advance: true) } + let(:pay_in_arrears) { create(:fixed_charge, pay_in_advance: false) } + + before do + scoped + deleted + pay_in_advance + pay_in_arrears + end + + describe ".all" do + it "returns all not deleted fixed charges" do + expect(described_class.all).to match_array([scoped, pay_in_advance, pay_in_arrears]) + end + end + + describe ".pay_in_advance" do + it "returns only pay_in_advance fixed charges" do + expect(described_class.pay_in_advance).to match_array([pay_in_advance]) + end + end + + describe ".pay_in_arrears" do + it "returns only pay_in_arrears fixed charges" do + expect(described_class.pay_in_arrears).to match_array([pay_in_arrears, scoped]) + end + end + end + + describe "#equal_properties?" do + subject(:equal_properties?) { fixed_charge1.equal_properties?(fixed_charge2) } + + let(:fixed_charge1) do + build(:fixed_charge, :standard, units: 2, properties: {amount: 100}) + end + + context "when charge model is not the same" do + let(:fixed_charge2) do + build( + :fixed_charge, + charge_model: :volume, + properties: fixed_charge1.properties, + units: fixed_charge1.units + ) + end + + it { is_expected.to be false } + end + + context "when properties are different" do + let(:fixed_charge2) do + build( + :fixed_charge, + charge_model: fixed_charge1.charge_model, + properties: {amount: 200}, + units: fixed_charge1.units + ) + end + + it { is_expected.to be false } + end + + context "when units are different" do + let(:fixed_charge2) do + build( + :fixed_charge, + charge_model: fixed_charge1.charge_model, + properties: fixed_charge1.properties, + units: 99999 + ) + end + + it { is_expected.to be false } + end + + context "when charge model, properties and units are the same" do + let(:fixed_charge2) do + build( + :fixed_charge, + charge_model: fixed_charge1.charge_model, + properties: fixed_charge1.properties, + units: fixed_charge1.units + ) + end + + it { is_expected.to be true } + end + end + + describe "#validate_pay_in_advance" do + context "when charge model is standard" do + it "is valid with pay_in_advance true" do + fixed_charge = build(:fixed_charge, charge_model: "standard", pay_in_advance: true) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance false" do + fixed_charge = build(:fixed_charge, charge_model: "standard", pay_in_advance: false) + expect(fixed_charge).to be_valid + end + end + + context "when charge model is volume" do + it "returns an error with pay_in_advance true" do + fixed_charge = build(:fixed_charge, :volume, pay_in_advance: true) + + expect(fixed_charge).not_to be_valid + expect(fixed_charge.errors.messages[:pay_in_advance]).to include("invalid_charge_model") + end + + it "is valid with pay_in_advance false" do + fixed_charge = build(:fixed_charge, :volume, pay_in_advance: false) + expect(fixed_charge).to be_valid + end + end + + context "when charge model is graduated" do + it "is valid with pay_in_advance true" do + fixed_charge = build(:fixed_charge, :graduated, pay_in_advance: true) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance false" do + fixed_charge = build(:fixed_charge, :graduated, pay_in_advance: false) + expect(fixed_charge).to be_valid + end + end + end + + describe "#validate_prorated" do + context "when charge model is standard" do + it "is valid with pay_in_advance true and prorated true" do + fixed_charge = build(:fixed_charge, charge_model: "standard", pay_in_advance: true, prorated: true) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance true and prorated false" do + fixed_charge = build(:fixed_charge, charge_model: "standard", pay_in_advance: true, prorated: false) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance false and prorated true" do + fixed_charge = build(:fixed_charge, charge_model: "standard", pay_in_advance: false, prorated: true) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance false and prorated false" do + fixed_charge = build(:fixed_charge, charge_model: "standard", pay_in_advance: false, prorated: false) + expect(fixed_charge).to be_valid + end + end + + context "when charge model is volume" do + it "is valid with pay_in_advance false and prorated true" do + fixed_charge = build(:fixed_charge, :volume, pay_in_advance: false, prorated: true) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance false and prorated false" do + fixed_charge = build(:fixed_charge, :volume, pay_in_advance: false, prorated: false) + expect(fixed_charge).to be_valid + end + end + + context "when charge model is graduated" do + it "returns an error with pay_in_advance true and prorated true" do + fixed_charge = build(:fixed_charge, :graduated, pay_in_advance: true, prorated: true) + + expect(fixed_charge).not_to be_valid + expect(fixed_charge.errors.messages[:prorated]).to include("invalid_charge_model") + end + + it "is valid with pay_in_advance true and prorated false" do + fixed_charge = build(:fixed_charge, :graduated, pay_in_advance: true, prorated: false) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance false and prorated true" do + fixed_charge = build(:fixed_charge, :graduated, pay_in_advance: false, prorated: true) + expect(fixed_charge).to be_valid + end + + it "is valid with pay_in_advance false and prorated false" do + fixed_charge = build(:fixed_charge, :graduated, pay_in_advance: false, prorated: false) + expect(fixed_charge).to be_valid + end + end + end + + describe "#matching_fixed_charge_prev_subscription" do + let(:add_on) { build(:add_on) } + let(:fixed_charge) { build(:fixed_charge, add_on:) } + let(:previous_subscription) { create(:subscription) } + let(:subscription) { create(:subscription, plan: fixed_charge.plan, previous_subscription:) } + + context "when the fixed charge is included in the previous subscription" do + before { previous_subscription.plan.fixed_charges = [fixed_charge] } + + it "returns the fixed charge" do + expect(fixed_charge.matching_fixed_charge_prev_subscription(subscription)).to eq(fixed_charge) + end + end + + context "when the fixed charge is not included in the previous subscription" do + it "returns nil" do + expect(fixed_charge.matching_fixed_charge_prev_subscription(subscription)).to be nil + end + end + + context "when there is no previous subscription" do + let(:previous_subscription) { nil } + + it "returns nil" do + expect(fixed_charge.matching_fixed_charge_prev_subscription(subscription)).to be nil + end + end + end +end diff --git a/spec/models/inbound_webhook_spec.rb b/spec/models/inbound_webhook_spec.rb new file mode 100644 index 0000000..d404c78 --- /dev/null +++ b/spec/models/inbound_webhook_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InboundWebhook do + subject(:inbound_webhook) { build(:inbound_webhook) } + + it { is_expected.to belong_to(:organization) } + + it { is_expected.to validate_presence_of(:event_type) } + it { is_expected.to validate_presence_of(:payload) } + it { is_expected.to validate_presence_of(:source) } + it { is_expected.to validate_presence_of(:status) } + + it { is_expected.to be_pending } + + describe "#processing!" do + it "updates status and processing_at" do + freeze_time do + expect { inbound_webhook.processing! } + .to change(inbound_webhook, :status).to("processing") + .and change(inbound_webhook, :processing_at).to(Time.zone.now) + end + end + end +end diff --git a/spec/models/integration_collection_mappings/base_collection_mapping_spec.rb b/spec/models/integration_collection_mappings/base_collection_mapping_spec.rb new file mode 100644 index 0000000..771c094 --- /dev/null +++ b/spec/models/integration_collection_mappings/base_collection_mapping_spec.rb @@ -0,0 +1,145 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCollectionMappings::BaseCollectionMapping do + subject(:mapping) { build(:netsuite_collection_mapping, settings: {}) } + + let(:mapping_types) do + %i[fallback_item coupon subscription_fee minimum_commitment tax prepaid_credit credit_note account currencies] + end + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:integration) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:billing_entity).optional } + + it { is_expected.to define_enum_for(:mapping_type).with_values(mapping_types).validating } + + describe "validations" do + describe "of mapping type uniqueness" do + let(:mapping_type) { :fallback_item } + let(:organization) { create(:organization) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:other_integration) { create(:netsuite_integration, organization: organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:other_billing_entity) { create(:billing_entity, organization: organization) } + + context "when billing entity is nil" do + subject(:mapping) do + build(:netsuite_collection_mapping, mapping_type:, organization:, integration:) + end + + context "when it is unique in scope of integration" do + before do + create(:netsuite_collection_mapping, mapping_type: :coupon, organization:, integration:) + create(:netsuite_collection_mapping, organization:, integration: other_integration) + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity: other_billing_entity, integration:) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of integration and billing entity" do + before do + create(:netsuite_collection_mapping, mapping_type:, organization: integration.organization, integration:) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mapping_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_int_collection_mappings_unique_billing_entity_is_null"/) + end + end + end + + context "when billing entity is not nil" do + subject(:mapping) do + build(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity:, integration:) + end + + context "when it is unique in scope of integration and billing entity" do + before do + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity: nil, integration:) + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity: other_billing_entity, integration:) + create(:netsuite_collection_mapping, mapping_type: :coupon, organization:, billing_entity:, integration:) + create(:netsuite_collection_mapping, mapping_type:, organization:, billing_entity:, integration: other_integration) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of integration and billing entity" do + before do + create(:netsuite_collection_mapping, mapping_type:, organization: integration.organization, billing_entity:, integration:) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mapping_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_int_collection_mappings_unique_billing_entity_is_not_null"/) + end + end + end + end + + describe "billing entity organization validation" do + subject(:mapping) { build(:netsuite_collection_mapping, integration:, billing_entity:) } + + let(:integration) { create(:netsuite_integration) } + + context "when billing entity belongs to the same organization" do + let(:billing_entity) { create(:billing_entity, organization: integration.organization) } + + it "is valid" do + expect(mapping).to be_valid + end + end + + context "when billing entity belongs to a different organization" do + let(:billing_entity) { create(:billing_entity) } + + it "is not valid" do + expect(mapping).not_to be_valid + expect(mapping.errors[:billing_entity]).to include("value_is_invalid") + end + end + + context "when billing entity is nil" do + let(:billing_entity) { nil } + + it "is valid" do + expect(mapping).to be_valid + end + end + end + end + + describe "#push_to_settings" do + it "push the value into settings" do + mapping.push_to_settings(key: "key1", value: "val1") + + expect(mapping.settings).to eq( + { + "key1" => "val1" + } + ) + end + end + + describe "#get_from_settings" do + before { mapping.push_to_settings(key: "key1", value: "val1") } + + it { expect(mapping.get_from_settings("key1")).to eq("val1") } + + it { expect(mapping.get_from_settings(nil)).to be_nil } + it { expect(mapping.get_from_settings("foo")).to be_nil } + end +end diff --git a/spec/models/integration_collection_mappings/netsuite_collection_mapping_spec.rb b/spec/models/integration_collection_mappings/netsuite_collection_mapping_spec.rb new file mode 100644 index 0000000..ccca1f0 --- /dev/null +++ b/spec/models/integration_collection_mappings/netsuite_collection_mapping_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCollectionMappings::NetsuiteCollectionMapping do + subject(:mapping) { build(:netsuite_collection_mapping) } + + describe "#external_id" do + let(:external_id) { SecureRandom.uuid } + + it "assigns and retrieve a setting" do + mapping.external_id = external_id + expect(mapping.external_id).to eq(external_id) + end + end + + describe "#external_account_code" do + let(:external_account_code) { "netsuite-code-1" } + + it "assigns and retrieve a setting" do + mapping.external_account_code = external_account_code + expect(mapping.external_account_code).to eq(external_account_code) + end + end + + describe "#external_name" do + let(:external_name) { "Credits and Discounts" } + + it "assigns and retrieve a setting" do + mapping.external_name = external_name + expect(mapping.external_name).to eq(external_name) + end + end + + describe "#tax_nexus" do + let(:tax_nexus) { "tax-nexus-1" } + + it "assigns and retrieve a setting" do + mapping.tax_nexus = tax_nexus + expect(mapping.tax_nexus).to eq(tax_nexus) + end + end + + describe "#tax_type" do + let(:tax_type) { "tax-type-1" } + + it "assigns and retrieve a setting" do + mapping.tax_type = tax_type + expect(mapping.tax_type).to eq(tax_type) + end + end + + describe "#tax_code" do + let(:tax_code) { "tax-code-1" } + + it "assigns and retrieve a setting" do + mapping.tax_code = tax_code + expect(mapping.tax_code).to eq(tax_code) + end + end + + describe "#currencies" do + let(:currencies) { {"EUR" => "8"} } + + it "assigns and retrieve a setting" do + mapping.currencies = currencies + expect(mapping.currencies).to eq(currencies) + end + + it do + mapping.mapping_type = :currencies + expect(mapping).to be_invalid + end + end + + describe "currencies validation" do + context "when mapping type is currencies" do + subject(:mapping) { build(:netsuite_collection_mapping, mapping_type: :currencies) } + + context "when currencies is blank" do + it do + mapping.currencies = nil + expect(mapping).to be_invalid + expect(mapping.errors[:currencies]).to eq ["value_is_mandatory"] + + mapping.currencies = {} + expect(mapping).to be_invalid + expect(mapping.errors[:currencies]).to eq ["cannot_be_empty"] + end + end + + context "when currencies is invalid" do + [ + [], + "invalid", + :mapping, + {"USD" => 8}, + {USD: "8"}, + {"invalid" => "8"}, + {"USD" => :test} + + ].each do |currencies| + it do + mapping.currencies = currencies + expect(mapping).to be_invalid + expect(mapping.errors[:currencies]).to eq ["invalid_format"] + end + end + end + end + + context "when currencies is expected" do + subject(:mapping) { build(:netsuite_collection_mapping, mapping_type: :currencies) } + + it { is_expected.to be_invalid } + end + + context "when currencies shouldn't be set" do + subject(:mapping) { build(:netsuite_collection_mapping, mapping_type: :fallback_item) } + + it do + mapping.currencies = {"EUR" => "12"} + expect(mapping).to be_invalid + expect(mapping.errors[:currencies]).to eq ["value_must_be_blank"] + end + end + end + + describe "organization_level_only_mapping validation" do + context "when mapping type is currencies" do + subject(:mapping) do + build(:netsuite_collection_mapping, + integration:, + mapping_type: :currencies, + currencies: {"USD" => "1"}) + end + + let(:integration) { create(:netsuite_integration) } + let(:billing_entity) { create(:billing_entity, organization: integration.organization) } + + context "when billing_entity_id is present" do + it do + mapping.billing_entity = billing_entity + expect(mapping).to be_invalid + expect(mapping.errors[:billing_entity]).to include "value_must_be_blank" + end + end + + context "when billing_entity_id is nil" do + it do + mapping.billing_entity_id = nil + expect(mapping).to be_valid + end + end + end + + context "when mapping type is not currencies" do + subject(:mapping) { build(:netsuite_collection_mapping, integration:, mapping_type: :fallback_item) } + + let(:integration) { create(:netsuite_integration) } + let(:billing_entity) { create(:billing_entity, organization: integration.organization) } + + it do + mapping.billing_entity = billing_entity + expect(mapping).to be_valid + end + end + end +end diff --git a/spec/models/integration_collection_mappings/xero_collection_mapping_spec.rb b/spec/models/integration_collection_mappings/xero_collection_mapping_spec.rb new file mode 100644 index 0000000..c45056f --- /dev/null +++ b/spec/models/integration_collection_mappings/xero_collection_mapping_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCollectionMappings::XeroCollectionMapping do + subject(:mapping) { build(:xero_collection_mapping) } + + describe "#external_id" do + let(:external_id) { SecureRandom.uuid } + + it "assigns and retrieve a setting" do + mapping.external_id = external_id + expect(mapping.external_id).to eq(external_id) + end + end + + describe "#external_account_code" do + let(:external_account_code) { "xero-code-1" } + + it "assigns and retrieve a setting" do + mapping.external_account_code = external_account_code + expect(mapping.external_account_code).to eq(external_account_code) + end + end + + describe "#external_name" do + let(:external_name) { "Credits and Discounts" } + + it "assigns and retrieve a setting" do + mapping.external_name = external_name + expect(mapping.external_name).to eq(external_name) + end + end +end diff --git a/spec/models/integration_customers/base_customer_spec.rb b/spec/models/integration_customers/base_customer_spec.rb new file mode 100644 index 0000000..5a30de2 --- /dev/null +++ b/spec/models/integration_customers/base_customer_spec.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::BaseCustomer do + subject(:integration_customer) { described_class.new(integration:, customer:, type:, external_customer_id:, organization:) } + + let(:integration) { create(:netsuite_integration) } + let(:type) { "IntegrationCustomers::NetsuiteCustomer" } + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:external_customer_id) { "123" } + + it { is_expected.to belong_to(:integration) } + it { is_expected.to belong_to(:customer) } + it { is_expected.to belong_to(:organization) } + + describe ".accounting_kind" do + let(:netsuite_customer) { create(:netsuite_customer) } + let(:xero_customer) { create(:xero_customer) } + let(:anrok_customer) { create(:anrok_customer) } + let(:hubspot_customer) { create(:hubspot_customer) } + + before do + netsuite_customer + xero_customer + anrok_customer + hubspot_customer + end + + it "returns only accounting kind customers" do + expect(described_class.accounting_kind).to contain_exactly(netsuite_customer, xero_customer) + end + end + + describe ".tax_kind" do + let(:netsuite_customer) { create(:netsuite_customer) } + let(:xero_customer) { create(:xero_customer) } + let(:anrok_customer) { create(:anrok_customer) } + let(:avalara_customer) { create(:avalara_customer) } + + before do + netsuite_customer + xero_customer + anrok_customer + avalara_customer + end + + it "returns only tax kind customers" do + expect(described_class.tax_kind).to contain_exactly(anrok_customer, avalara_customer) + end + end + + describe ".hubspot_kind and .salesforce_kind" do + let(:netsuite_customer) { create(:netsuite_customer) } + let(:xero_customer) { create(:xero_customer) } + let(:anrok_customer) { create(:anrok_customer) } + let(:hubspot_customer) { create(:hubspot_customer) } + let(:salesforce_customer) { create(:salesforce_customer) } + + before do + netsuite_customer + xero_customer + anrok_customer + hubspot_customer + salesforce_customer + end + + it "returns only hubspot kind customers" do + expect(described_class.hubspot_kind).to contain_exactly(hubspot_customer) + end + + it "returns only salesforce kind customers" do + expect(described_class.salesforce_kind).to contain_exactly(salesforce_customer) + end + end + + describe ".customer_type" do + subject(:customer_type_call) { described_class.customer_type(type) } + + context "when type is netsuite" do + let(:type) { "netsuite" } + let(:customer_type) { "IntegrationCustomers::NetsuiteCustomer" } + + it "returns customer type" do + expect(subject).to eq(customer_type) + end + end + + context "when type is okta" do + let(:type) { "okta" } + let(:customer_type) { "IntegrationCustomers::OktaCustomer" } + + it "returns customer type" do + expect(subject).to eq(customer_type) + end + end + + context "when type is anrok" do + let(:type) { "anrok" } + let(:customer_type) { "IntegrationCustomers::AnrokCustomer" } + + it "returns customer type" do + expect(subject).to eq(customer_type) + end + end + + context "when type is xero" do + let(:type) { "xero" } + let(:customer_type) { "IntegrationCustomers::XeroCustomer" } + + it "returns customer type" do + expect(subject).to eq(customer_type) + end + end + + context "when type is hubspot" do + let(:type) { "hubspot" } + let(:customer_type) { "IntegrationCustomers::HubspotCustomer" } + + it "returns customer type" do + expect(subject).to eq(customer_type) + end + end + + context "when type is salesforce" do + let(:type) { "salesforce" } + let(:customer_type) { "IntegrationCustomers::SalesforceCustomer" } + + it "returns customer type" do + expect(subject).to eq(customer_type) + end + end + + context "when type is not supported" do + let(:type) { "n/a" } + + it "raises an error" do + expect { subject }.to raise_error(NotImplementedError) + end + end + end + + describe "#push_to_settings" do + it "push the value into settings" do + integration_customer.push_to_settings(key: "key1", value: "val1") + + expect(integration_customer.settings).to eq( + { + "key1" => "val1" + } + ) + end + end + + describe "#get_from_settings" do + before { integration_customer.push_to_settings(key: "key1", value: "val1") } + + it { expect(integration_customer.get_from_settings("key1")).to eq("val1") } + + it { expect(integration_customer.get_from_settings(nil)).to be_nil } + it { expect(integration_customer.get_from_settings("foo")).to be_nil } + end + + describe "#sync_with_provider" do + it "assigns and retrieve a setting" do + integration_customer.sync_with_provider = true + expect(integration_customer.sync_with_provider).to eq(true) + end + end + + describe "#tax_kind?" do + context "with tax integration" do + let(:integration) { create(:anrok_integration) } + let(:type) { "IntegrationCustomers::AnrokCustomer" } + + it "returns true" do + expect(integration_customer).to be_tax_kind + end + end + + context "without tax integration" do + it "returns false" do + expect(integration_customer).not_to be_tax_kind + end + end + end + + describe "validations" do + describe "of customer id uniqueness" do + let(:errors) { another_integration_customer.errors } + + context "when it is unique in scope of type" do + subject(:another_integration_customer) do + described_class.new(integration: another_integration, customer:, type:, external_customer_id:) + end + + let(:another_integration) { create(:netsuite_integration) } + + before { another_integration_customer.valid? } + + it "does not add an error" do + expect(errors.where(:customer_id, :taken)).not_to be_present + end + end + + context "when it is not unique in scope of type" do + subject(:another_integration_customer) do + described_class.new(integration:, customer:, type:, external_customer_id:, organization: organization) + end + + before do + described_class.create(integration:, customer:, type:, external_customer_id:, organization: organization) + another_integration_customer.valid? + end + + it "adds an error" do + expect(errors.where(:customer_id, :taken)).to be_present + end + end + end + + describe "tax integration uniqueness validation" do + context "when no tax integration exists for a customer" do + let(:integration) { create(:anrok_integration) } + let(:type) { "IntegrationCustomers::AnrokCustomer" } + + it "allows creating a first tax integration" do + expect(integration_customer).to be_valid + end + end + + context "when a tax integration already exists for the customer" do + let(:integration) { create(:anrok_integration) } + let(:type) { "IntegrationCustomers::AnrokCustomer" } + + context "with existing anrok integration" do + before do + create(:anrok_customer, customer:) + end + + it "is invalid for a second AnrokCustomer" do + expect(integration_customer).not_to be_valid + expect(integration_customer.errors[:type]).to include("tax_integration_exists") + end + end + + context "with existing avalara integration" do + before do + create(:avalara_customer, customer:) + end + + it "is invalid for a different tax integration" do + expect(integration_customer).not_to be_valid + expect(integration_customer.errors[:type]).to include("tax_integration_exists") + end + end + + context "when validating persisted record" do + before do + integration_customer.save! + end + + it "does not add any errors" do + expect(integration_customer).to be_valid + end + end + end + end + end +end diff --git a/spec/models/integration_customers/hubspot_customer_spec.rb b/spec/models/integration_customers/hubspot_customer_spec.rb new file mode 100644 index 0000000..b6aad6e --- /dev/null +++ b/spec/models/integration_customers/hubspot_customer_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::HubspotCustomer do + subject(:hubspot_customer) { build(:hubspot_customer) } + + describe "#targeted_object" do + let(:targeted_object) { Integrations::HubspotIntegration::TARGETED_OBJECTS.sample } + + it "assigns and retrieve a setting" do + hubspot_customer.targeted_object = targeted_object + expect(hubspot_customer.targeted_object).to eq(targeted_object) + end + end + + describe "#email" do + let(:email) { Faker::Internet.email } + + it "assigns and retrieve a setting" do + hubspot_customer.email = email + expect(hubspot_customer.email).to eq(email) + end + end +end diff --git a/spec/models/integration_customers/netsuite_customer_spec.rb b/spec/models/integration_customers/netsuite_customer_spec.rb new file mode 100644 index 0000000..b1c27ea --- /dev/null +++ b/spec/models/integration_customers/netsuite_customer_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::NetsuiteCustomer do + subject(:netsuite_customer) { build(:netsuite_customer) } + + describe "#subsidiary_id" do + let(:subsidiary_id) { Faker::Number.number(digits: 3) } + + it "assigns and retrieve a setting" do + netsuite_customer.subsidiary_id = subsidiary_id + expect(netsuite_customer.subsidiary_id).to eq(subsidiary_id) + end + end +end diff --git a/spec/models/integration_item_spec.rb b/spec/models/integration_item_spec.rb new file mode 100644 index 0000000..b9ec4bf --- /dev/null +++ b/spec/models/integration_item_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationItem do + subject(:integration_item) { build(:integration_item) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:integration) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to validate_presence_of(:external_id) } + it { is_expected.to define_enum_for(:item_type).with_values(%i[standard tax account]) } + + it "validates uniqueness of external id" do + expect(integration_item).to validate_uniqueness_of(:external_id).scoped_to([:integration_id, :item_type]) + end +end diff --git a/spec/models/integration_mappings/base_mapping_spec.rb b/spec/models/integration_mappings/base_mapping_spec.rb new file mode 100644 index 0000000..1217fe4 --- /dev/null +++ b/spec/models/integration_mappings/base_mapping_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationMappings::BaseMapping do + subject(:mapping) { build(:netsuite_mapping, settings: {}) } + + it_behaves_like "paper_trail traceable" + + describe "associations" do + it { is_expected.to belong_to(:integration) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:mappable) } + it { is_expected.to belong_to(:billing_entity).optional } + end + + describe "validations" do + it { is_expected.to validate_inclusion_of(:mappable_type).in_array(%w[AddOn BillableMetric]) } + + describe "uniqueness validations" do + let(:mapping) do + build(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity:) + end + let(:organization) { create(:organization) } + let(:integration) { create(:netsuite_integration, organization: organization) } + let(:add_on) { create(:add_on, organization: organization) } + let(:other_add_on) { create(:add_on, organization: organization) } + let(:billable_metric) { create(:billable_metric, id: add_on.id, organization: organization) } + let(:other_integration) { create(:netsuite_integration, organization: organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:other_billing_entity) { create(:billing_entity, organization: organization) } + let(:other_organization) { create(:organization) } + + context "without billing entity" do + let(:mapping) do + build(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: nil) + end + + context "when it is unique in scope of mappable_id, integration_id" do + before do + create(:netsuite_mapping, integration: other_integration, organization:, mappable: add_on, billing_entity: nil) + create(:netsuite_mapping, integration:, organization:, mappable: other_add_on, billing_entity: nil) + create(:netsuite_mapping, integration:, mappable: add_on, billing_entity: nil, organization: other_organization) + create(:netsuite_mapping, integration:, organization:, mappable: billable_metric, billing_entity: nil) + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: other_billing_entity) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of mappable_id, integration_id, and billing_entity_id" do + before do + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: nil) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mappable_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_integration_mappings_unique_billing_entity_id_is_null"/) + end + end + end + + context "with billing entity" do + context "when it is unique in scope of mappable_id, integration_id, and billing_entity_id" do + before do + create(:netsuite_mapping, integration: other_integration, organization:, mappable: add_on, billing_entity:) + create(:netsuite_mapping, integration:, organization:, mappable: other_add_on, billing_entity:) + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: other_billing_entity) + create(:netsuite_mapping, integration:, organization:, mappable: billable_metric, billing_entity:) + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity: nil) + end + + it "does not add an error" do + expect(mapping).to be_valid + end + end + + context "when it is not unique in scope of mappable_id, integration_id, and billing_entity_id" do + before do + create(:netsuite_mapping, integration:, organization:, mappable: add_on, billing_entity:) + end + + it "adds an error" do + expect(mapping).not_to be_valid + expect(mapping.errors.where(:mappable_type, :taken)).to be_present + + expect { mapping.save!(validate: false) }.to raise_error(ActiveRecord::RecordNotUnique, /duplicate key value violates unique constraint "index_integration_mappings_unique_billing_entity_id_is_not_null"/) + end + end + end + end + + describe "billing entity organization validation" do + let(:organization) { create(:organization) } + let(:different_organization) { create(:organization) } + let(:integration) { create(:netsuite_integration, organization: organization) } + let(:billing_entity) { create(:billing_entity, organization: different_organization) } + let(:add_on) { create(:add_on, organization: organization) } + + it "validates billing entity belongs to same organization" do + mapping = build( + :netsuite_mapping, + integration: integration, + organization: organization, + billing_entity: billing_entity, + mappable: add_on, + external_id: "test_id" + ) + + expect(mapping).not_to be_valid + expect(mapping.errors[:billing_entity]).to include("must belong to the same organization") + end + + it "is valid when billing entity belongs to same organization" do + billing_entity.update!(organization: organization) + mapping = build( + :netsuite_mapping, + integration: integration, + organization: organization, + billing_entity: billing_entity, + mappable: add_on, + external_id: "test_id" + ) + + expect(mapping).to be_valid + end + + it "is valid when billing entity is nil" do + mapping = build( + :netsuite_mapping, + integration: integration, + organization: organization, + billing_entity: nil, + mappable: add_on, + external_id: "test_id" + ) + + expect(mapping).to be_valid + end + end + end + + describe "#push_to_settings" do + it "push the value into settings" do + mapping.push_to_settings(key: "key1", value: "val1") + + expect(mapping.settings).to eq( + { + "key1" => "val1" + } + ) + end + end + + describe "#get_from_settings" do + before { mapping.push_to_settings(key: "key1", value: "val1") } + + it { expect(mapping.get_from_settings("key1")).to eq("val1") } + + it { expect(mapping.get_from_settings(nil)).to be_nil } + it { expect(mapping.get_from_settings("foo")).to be_nil } + end +end diff --git a/spec/models/integration_mappings/netsuite_mapping_spec.rb b/spec/models/integration_mappings/netsuite_mapping_spec.rb new file mode 100644 index 0000000..d534575 --- /dev/null +++ b/spec/models/integration_mappings/netsuite_mapping_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationMappings::NetsuiteMapping do + subject(:mapping) { build(:netsuite_mapping) } + + describe "#external_id" do + let(:external_id) { SecureRandom.uuid } + + it "assigns and retrieve a setting" do + mapping.external_id = external_id + expect(mapping.external_id).to eq(external_id) + end + end + + describe "#external_account_code" do + let(:external_account_code) { "netsuite-code-1" } + + it "assigns and retrieve a setting" do + mapping.external_account_code = external_account_code + expect(mapping.external_account_code).to eq(external_account_code) + end + end + + describe "#external_name" do + let(:external_name) { "Credits and Discounts" } + + it "assigns and retrieve a setting" do + mapping.external_name = external_name + expect(mapping.external_name).to eq(external_name) + end + end +end diff --git a/spec/models/integration_mappings/xero_mapping_spec.rb b/spec/models/integration_mappings/xero_mapping_spec.rb new file mode 100644 index 0000000..c5cb3c6 --- /dev/null +++ b/spec/models/integration_mappings/xero_mapping_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationMappings::XeroMapping do + subject(:mapping) { build(:xero_mapping) } + + describe "#external_id" do + let(:external_id) { SecureRandom.uuid } + + it "assigns and retrieve a setting" do + mapping.external_id = external_id + expect(mapping.external_id).to eq(external_id) + end + end + + describe "#external_account_code" do + let(:external_account_code) { "xero-code-1" } + + it "assigns and retrieve a setting" do + mapping.external_account_code = external_account_code + expect(mapping.external_account_code).to eq(external_account_code) + end + end + + describe "#external_name" do + let(:external_name) { "Credits and Discounts" } + + it "assigns and retrieve a setting" do + mapping.external_name = external_name + expect(mapping.external_name).to eq(external_name) + end + end +end diff --git a/spec/models/integration_resource_spec.rb b/spec/models/integration_resource_spec.rb new file mode 100644 index 0000000..bcafd59 --- /dev/null +++ b/spec/models/integration_resource_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationResource do + subject(:integration_resource) { build(:integration_resource) } + + let(:resource_types) do + %i[invoice sales_order_deprecated payment credit_note subscription] + end + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:syncable) } + it { is_expected.to belong_to(:integration) } + it { is_expected.to belong_to(:organization) } + + it { is_expected.to define_enum_for(:resource_type).with_values(resource_types) } +end diff --git a/spec/models/integrations/anrok_integration_spec.rb b/spec/models/integrations/anrok_integration_spec.rb new file mode 100644 index 0000000..7d93df6 --- /dev/null +++ b/spec/models/integrations/anrok_integration_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::AnrokIntegration do + subject(:anrok_integration) { build(:anrok_integration) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:connection_id) } + it { is_expected.to have_many(:error_details) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(anrok_integration).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe ".api_key" do + it "assigns and retrieve an api_key" do + anrok_integration.api_key = "123abc456" + expect(anrok_integration.api_key).to eq("123abc456") + end + end + + describe ".connection_id" do + it "assigns and retrieve a secret pair" do + anrok_integration.connection_id = "connection_id" + expect(anrok_integration.connection_id).to eq("connection_id") + end + end +end diff --git a/spec/models/integrations/avalara_integration_spec.rb b/spec/models/integrations/avalara_integration_spec.rb new file mode 100644 index 0000000..a984d63 --- /dev/null +++ b/spec/models/integrations/avalara_integration_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::AvalaraIntegration do + subject(:avalara_integration) { build(:avalara_integration) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:connection_id) } + it { is_expected.to validate_presence_of(:account_id) } + it { is_expected.to validate_presence_of(:company_code) } + it { is_expected.to validate_presence_of(:license_key) } + it { is_expected.to have_many(:error_details) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(avalara_integration).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe ".license_key" do + it "assigns and retrieve an secret pair" do + avalara_integration.license_key = "123abc456" + expect(avalara_integration.license_key).to eq("123abc456") + end + end + + describe ".connection_id" do + it "assigns and retrieve a secret pair" do + avalara_integration.connection_id = "connection_id" + expect(avalara_integration.connection_id).to eq("connection_id") + end + end + + describe ".account_id" do + it "assigns and retrieve a settings pair" do + avalara_integration.account_id = "account_id" + expect(avalara_integration.account_id).to eq("account_id") + end + end + + describe ".company_code" do + it "assigns and retrieve a settings pair" do + avalara_integration.company_code = "company_code" + expect(avalara_integration.company_code).to eq("company_code") + end + end + + describe ".company_id" do + it "assigns and retrieve a settings pair" do + avalara_integration.company_id = "company_id" + expect(avalara_integration.company_id).to eq("company_id") + end + end +end diff --git a/spec/models/integrations/base_integration_spec.rb b/spec/models/integrations/base_integration_spec.rb new file mode 100644 index 0000000..5374051 --- /dev/null +++ b/spec/models/integrations/base_integration_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::BaseIntegration do + subject(:integration) { described_class.new(attributes) } + + it_behaves_like "paper_trail traceable" do + subject { build(:netsuite_integration) } + end + + let(:secrets) { {"api_key" => api_key, "api_secret" => api_secret} } + let(:api_key) { SecureRandom.uuid } + let(:api_secret) { SecureRandom.uuid } + + let(:attributes) do + {secrets: secrets.to_json} + end + + it { is_expected.to have_many(:integration_mappings).dependent(:destroy) } + it { is_expected.to have_many(:integration_collection_mappings).dependent(:destroy) } + it { is_expected.to have_many(:integration_customers).dependent(:destroy) } + it { is_expected.to have_many(:integration_items).dependent(:destroy) } + it { is_expected.to have_many(:integration_resources).dependent(:destroy) } + + describe ".secrets_json" do + it { expect(integration.secrets_json).to eq(secrets) } + end + + describe ".push_to_secrets" do + it "push the value into the secrets" do + integration.push_to_secrets(key: "api_key", value: "foo_bar") + + expect(integration.secrets_json).to eq( + { + "api_key" => "foo_bar", + "api_secret" => api_secret + } + ) + end + end + + describe ".get_from_secrets" do + it { expect(integration.get_from_secrets("api_secret")).to eq(api_secret) } + + it { expect(integration.get_from_secrets(nil)).to be_nil } + it { expect(integration.get_from_secrets("foo")).to be_nil } + end + + describe ".push_to_settings" do + it "push the value into the secrets" do + integration.push_to_settings(key: "key1", value: "val1") + + expect(integration.settings).to eq( + { + "key1" => "val1" + } + ) + end + end + + describe ".get_from_settings" do + before { integration.push_to_settings(key: "key1", value: "val1") } + + it { expect(integration.get_from_settings("key1")).to eq("val1") } + + it { expect(integration.get_from_settings(nil)).to be_nil } + it { expect(integration.get_from_settings("foo")).to be_nil } + end + + describe ".integration_type" do + context "when type is netsuite" do + it "returns the correct class name" do + expect(described_class.integration_type("netsuite")).to eq("Integrations::NetsuiteIntegration") + end + end + + context "when type is okta" do + it "returns the correct class name" do + expect(described_class.integration_type("okta")).to eq("Integrations::OktaIntegration") + end + end + + context "when type is anrok" do + it "returns the correct class name" do + expect(described_class.integration_type("anrok")).to eq("Integrations::AnrokIntegration") + end + end + + context "when type is xero" do + it "returns the correct class name" do + expect(described_class.integration_type("xero")).to eq("Integrations::XeroIntegration") + end + end + + context "when type is hubspot" do + it "returns the correct class name" do + expect(described_class.integration_type("hubspot")).to eq("Integrations::HubspotIntegration") + end + end + + context "when type is salesforce" do + it "returns the correct class name" do + expect(described_class.integration_type("salesforce")).to eq("Integrations::SalesforceIntegration") + end + end + + context "when type is unknown" do + it "raises a NotImplementedError" do + expect { described_class.integration_type("unknown") }.to raise_error(NotImplementedError) + end + end + end + + describe "#external_id_key" do + it "returns id" do + expect(integration.external_id_key).to eq("id") + end + end +end diff --git a/spec/models/integrations/hubspot_integration_spec.rb b/spec/models/integrations/hubspot_integration_spec.rb new file mode 100644 index 0000000..6ba45e8 --- /dev/null +++ b/spec/models/integrations/hubspot_integration_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::HubspotIntegration do + subject(:hubspot_integration) { build(:hubspot_integration) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:connection_id) } + it { is_expected.to validate_presence_of(:default_targeted_object) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(hubspot_integration).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe "#connection_id" do + it "assigns and retrieve a secret pair" do + hubspot_integration.connection_id = "connection_id" + expect(hubspot_integration.connection_id).to eq("connection_id") + end + end + + describe "#default_targeted_object" do + it "assigns and retrieve a setting" do + hubspot_integration.default_targeted_object = "companies" + expect(hubspot_integration.default_targeted_object).to eq("companies") + end + end + + describe "#portal_id" do + it "assigns and retrieve a setting" do + hubspot_integration.portal_id = "123456789" + expect(hubspot_integration.portal_id).to eq("123456789") + end + end + + describe "#sync_invoices" do + it "assigns and retrieve a setting" do + hubspot_integration.sync_invoices = true + expect(hubspot_integration.sync_invoices).to eq(true) + end + end + + describe "#sync_subscriptions" do + it "assigns and retrieve a setting" do + hubspot_integration.sync_subscriptions = true + expect(hubspot_integration.sync_subscriptions).to eq(true) + end + end + + describe "#subscriptions_object_type_id" do + it "assigns and retrieve a setting" do + hubspot_integration.subscriptions_object_type_id = "123" + expect(hubspot_integration.subscriptions_object_type_id).to eq("123") + end + end + + describe "#invoices_object_type_id" do + it "assigns and retrieve a setting" do + hubspot_integration.invoices_object_type_id = "123" + expect(hubspot_integration.invoices_object_type_id).to eq("123") + end + end + + describe "#companies_properties_version" do + it "assigns and retrieve a setting" do + hubspot_integration.companies_properties_version = 5 + expect(hubspot_integration.companies_properties_version).to eq(5) + end + end + + describe "#contacts_properties_version" do + it "assigns and retrieve a setting" do + hubspot_integration.contacts_properties_version = 6 + expect(hubspot_integration.contacts_properties_version).to eq(6) + end + end + + describe "#subscriptions_properties_version" do + it "assigns and retrieve a setting" do + hubspot_integration.subscriptions_properties_version = 7 + expect(hubspot_integration.subscriptions_properties_version).to eq(7) + end + end + + describe "#invoices_properties_version" do + it "assigns and retrieve a setting" do + hubspot_integration.invoices_properties_version = 8 + expect(hubspot_integration.invoices_properties_version).to eq(8) + end + end + + describe "#companies_object_type_id" do + it "returns the correct object type id for companies" do + expect(hubspot_integration.companies_object_type_id).to eq("0-2") + end + end + + describe "#contacts_object_type_id" do + it "returns the correct object type id for contacts" do + expect(hubspot_integration.contacts_object_type_id).to eq("0-1") + end + end +end diff --git a/spec/models/integrations/netsuite_integration_spec.rb b/spec/models/integrations/netsuite_integration_spec.rb new file mode 100644 index 0000000..99e4d15 --- /dev/null +++ b/spec/models/integrations/netsuite_integration_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::NetsuiteIntegration do + subject(:netsuite_integration) { build(:netsuite_integration) } + + it { is_expected.to validate_presence_of(:name) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(netsuite_integration).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe ".connection_id" do + it "assigns and retrieve a secret pair" do + netsuite_integration.connection_id = "connection_id" + expect(netsuite_integration.connection_id).to eq("connection_id") + end + end + + describe ".client_secret" do + it "assigns and retrieve a secret pair" do + netsuite_integration.client_secret = "client_secret" + expect(netsuite_integration.client_secret).to eq("client_secret") + end + end + + describe "account_id" do + it "assigns and retrieve a setting" do + netsuite_integration.account_id = "account_id" + expect(netsuite_integration.account_id).to eq("account_id") + end + + context "when format is invalid" do + it "assigns and retrieve a setting with correct format" do + netsuite_integration.account_id = " THIS is account id " + expect(netsuite_integration.account_id).to eq("this-is-account-id") + end + end + end + + describe ".client_id" do + it "assigns and retrieve a setting" do + netsuite_integration.client_id = "client_id" + expect(netsuite_integration.client_id).to eq("client_id") + end + end + + describe "#script_endpoint_url" do + let(:url) { Faker::Internet.url } + + it "assigns and retrieve a setting" do + netsuite_integration.script_endpoint_url = url + expect(netsuite_integration.script_endpoint_url).to eq(url) + end + end + + describe "#sync_credit_notes" do + it "assigns and retrieve a setting" do + netsuite_integration.sync_credit_notes = true + expect(netsuite_integration.sync_credit_notes).to eq(true) + end + end + + describe "#sync_invoices" do + it "assigns and retrieve a setting" do + netsuite_integration.sync_invoices = true + expect(netsuite_integration.sync_invoices).to eq(true) + end + end + + describe "#sync_payments" do + it "assigns and retrieve a setting" do + netsuite_integration.sync_payments = true + expect(netsuite_integration.sync_payments).to eq(true) + end + end + + describe "#legacy_script" do + it "assigns and retrieve a setting" do + netsuite_integration.legacy_script = true + expect(netsuite_integration.legacy_script).to eq(true) + end + end +end diff --git a/spec/models/integrations/okta_integration_spec.rb b/spec/models/integrations/okta_integration_spec.rb new file mode 100644 index 0000000..0836371 --- /dev/null +++ b/spec/models/integrations/okta_integration_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::OktaIntegration do + subject(:okta_integration) { build(:okta_integration) } + + it { is_expected.to validate_presence_of(:domain) } + it { is_expected.to validate_presence_of(:organization_name) } + it { is_expected.to validate_presence_of(:client_id) } + it { is_expected.to validate_presence_of(:client_secret) } + + describe "#host" do + context "when settings host is present" do + before do + subject.host = "test.com" + end + + it "use the settings host" do + expect(subject.host).to eq("test.com") + end + end + + context "when settings host is nil" do + before do + subject.organization_name = "test" + subject.host = nil + end + + it "use the default host" do + expect(subject.host).to eq("test.okta.com") + end + end + end + + describe "validations" do + it "validates uniqueness of domain" do + expect(okta_integration).to be_valid + end + + context "when domain already exists" do + before { create(:okta_integration) } + + it "does not validate the record" do + expect(okta_integration).not_to be_valid + expect(okta_integration.errors).to include(:domain) + end + end + end +end diff --git a/spec/models/integrations/salesforce_integration_spec.rb b/spec/models/integrations/salesforce_integration_spec.rb new file mode 100644 index 0000000..f842dd8 --- /dev/null +++ b/spec/models/integrations/salesforce_integration_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::SalesforceIntegration do + subject(:salesforce_integration) { build(:salesforce_integration) } + + it { is_expected.to validate_presence_of(:code) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:instance_id) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(salesforce_integration).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe "#instance_id" do + it "assigns and retrieve a setting" do + salesforce_integration.instance_id = "instance_id_1" + expect(salesforce_integration.instance_id).to eq("instance_id_1") + end + end + + describe "#code" do + it "returns salesforce" do + expect(salesforce_integration.code).to eq("salesforce") + end + end +end diff --git a/spec/models/integrations/xero_integration_spec.rb b/spec/models/integrations/xero_integration_spec.rb new file mode 100644 index 0000000..5f08571 --- /dev/null +++ b/spec/models/integrations/xero_integration_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::XeroIntegration do + subject(:xero_integration) { build(:xero_integration) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:connection_id) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(xero_integration).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe ".connection_id" do + it "assigns and retrieve a secret pair" do + xero_integration.connection_id = "connection_id" + expect(xero_integration.connection_id).to eq("connection_id") + end + end + + describe "#sync_credit_notes" do + it "assigns and retrieve a setting" do + xero_integration.sync_credit_notes = true + expect(xero_integration.sync_credit_notes).to eq(true) + end + end + + describe "#sync_invoices" do + it "assigns and retrieve a setting" do + xero_integration.sync_invoices = true + expect(xero_integration.sync_invoices).to eq(true) + end + end + + describe "#sync_payments" do + it "assigns and retrieve a setting" do + xero_integration.sync_payments = true + expect(xero_integration.sync_payments).to eq(true) + end + end + + describe "#external_id_key" do + it "returns item_code" do + expect(xero_integration.external_id_key).to eq("item_code") + end + end +end diff --git a/spec/models/invite_spec.rb b/spec/models/invite_spec.rb new file mode 100644 index 0000000..d3bad9e --- /dev/null +++ b/spec/models/invite_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invite do + subject(:invite) { create(:invite) } + + it_behaves_like "paper_trail traceable" + + describe "#mark_as_revoked" do + it "revokes the invite with a Time" do + freeze_time do + expect { invite.mark_as_revoked! } + .to change { invite.reload.status }.from("pending").to("revoked") + .and change(invite, :revoked_at).from(nil).to(Time.current) + end + end + end + + describe "#mark_as_accepted" do + it "accepts the invite with a Time" do + freeze_time do + expect { invite.mark_as_accepted! } + .to change { invite.reload.status }.from("pending").to("accepted") + .and change(invite, :accepted_at).from(nil).to(Time.current) + end + end + end + + describe "normalizations" do + it "sanitizes email on assignment" do + invite = build(:invite, email: " hello@some\u200Bthing\u2013other.com ") + expect(invite.email).to eq("hello@something-other.com") + end + end + + describe "validations" do + subject(:invite) { build(:invite, organization:) } + + let(:organization) { create(:organization) } + let!(:role) { create(:role, :custom, organization:) } + + before { create(:role, :admin) } + + it { is_expected.to be_valid } + + context "with wrong email format" do + before { invite.email = "wrong" } + + it { is_expected.not_to be_valid } + end + + context "without email" do + before { invite.email = nil } + + it { is_expected.not_to be_valid } + end + + context "when roles is empty" do + before { invite.roles = [] } + + it { is_expected.to be_valid } + end + + context "when all roles exist in the organization" do + before { invite.roles = [role.name.swapcase, "Admin"] } + + it { is_expected.to be_valid } + end + end +end diff --git a/spec/models/invoice/applied_tax_spec.rb b/spec/models/invoice/applied_tax_spec.rb new file mode 100644 index 0000000..6461393 --- /dev/null +++ b/spec/models/invoice/applied_tax_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoice::AppliedTax do + subject(:applied_tax) { create(:invoice_applied_tax) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } + + describe "#applied_on_whole_invoice?" do + subject(:applicable_on_whole_invoice) { applied_tax.applied_on_whole_invoice? } + + context "when applied tax represents special rule" do + let(:applied_tax) { create(:invoice_applied_tax, tax_code: Invoice::AppliedTax::TAX_CODES_APPLICABLE_ON_WHOLE_INVOICE.sample) } + + it "is applicable on whole invoice" do + expect(subject).to be(true) + end + end + + context "when normal applied tax" do + it "is not applicable on whole invoice" do + expect(subject).to be(false) + end + end + end + + describe "#taxable_amount_cents" do + before do + applied_tax.fees_amount_cents = 150 + end + + context "when taxable_base_amount_cents is zero" do + it "returns fees_amount_cents" do + applied_tax.taxable_base_amount_cents = 0 + + expect(applied_tax.taxable_amount_cents).to eq(150) + end + end + + context "when taxable_base_amount_cents is NOT zero" do + it "returns taxable_base_amount_cents" do + applied_tax.taxable_base_amount_cents = 100 + + expect(applied_tax.taxable_amount_cents).to eq(100) + end + end + end +end diff --git a/spec/models/invoice_custom_section_spec.rb b/spec/models/invoice_custom_section_spec.rb new file mode 100644 index 0000000..4b81d99 --- /dev/null +++ b/spec/models/invoice_custom_section_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceCustomSection do + subject(:invoice_custom_section) { create(:invoice_custom_section) } + + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:customer_applied_invoice_custom_sections).dependent(:destroy) } + it { is_expected.to have_many(:billing_entity_applied_invoice_custom_sections).dependent(:destroy) } + + describe "enums" do + it "defines section_type enum with correct values" do + expect(described_class.section_types).to eq( + "manual" => "manual", + "system_generated" => "system_generated" + ) + end + + it "has manual as the default section_type" do + expect(invoice_custom_section.section_type).to eq("manual") + end + end +end diff --git a/spec/models/invoice_settlement_spec.rb b/spec/models/invoice_settlement_spec.rb new file mode 100644 index 0000000..a1b277e --- /dev/null +++ b/spec/models/invoice_settlement_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceSettlement do + subject(:invoice_settlement) { build(:invoice_settlement) } + + describe "enums" do + it do + expect(subject) + .to define_enum_for(:settlement_type) + .backed_by_column_of_type(:enum) + .with_values(payment: "payment", credit_note: "credit_note") + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:billing_entity) + expect(subject).to belong_to(:target_invoice).class_name("Invoice") + expect(subject).to belong_to(:source_payment).class_name("Payment").optional + expect(subject).to belong_to(:source_credit_note).class_name("CreditNote").optional + end + end + + describe "validations" do + it do + expect(subject).to validate_numericality_of(:amount_cents).is_greater_than(0) + expect(subject).to validate_inclusion_of(:amount_currency).in_array(described_class.currency_list) + expect(subject).to validate_presence_of(:settlement_type) + end + + describe "source presence validation" do + context "when settlement_type is payment" do + subject(:invoice_settlement) { build(:invoice_settlement, settlement_type: :payment) } + + it "requires source_payment_id" do + invoice_settlement.source_payment_id = nil + expect(invoice_settlement).not_to be_valid + expect(invoice_settlement.errors[:source_payment_id]).to include("must be present when settlement type is payment") + end + + it "does not allow source_credit_note_id" do + invoice_settlement.source_credit_note_id = create(:credit_note).id + expect(invoice_settlement).not_to be_valid + expect(invoice_settlement.errors[:source_credit_note_id]).to include("must be blank when settlement type is payment") + end + end + + context "when settlement_type is credit_note" do + subject(:invoice_settlement) { build(:invoice_settlement, settlement_type: :credit_note) } + + it "requires source_credit_note_id" do + invoice_settlement.source_credit_note_id = nil + expect(invoice_settlement).not_to be_valid + expect(invoice_settlement.errors[:source_credit_note_id]).to include("must be present when settlement type is credit_note") + end + + it "does not allow source_payment_id" do + invoice_settlement.source_payment_id = create(:payment).id + expect(invoice_settlement).not_to be_valid + expect(invoice_settlement.errors[:source_payment_id]).to include("must be blank when settlement type is credit_note") + end + end + end + end +end diff --git a/spec/models/invoice_spec.rb b/spec/models/invoice_spec.rb new file mode 100644 index 0000000..fcffd24 --- /dev/null +++ b/spec/models/invoice_spec.rb @@ -0,0 +1,2414 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoice do + subject(:invoice) { create(:invoice, organization:) } + + let(:organization) { create(:organization) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to have_many(:integration_resources) } + it { is_expected.to have_many(:error_details) } + + it { is_expected.to have_many(:progressive_billing_credits) } + + it { is_expected.to have_many(:applied_payment_requests).class_name("PaymentRequest::AppliedInvoice") } + it { is_expected.to have_many(:payment_requests).through(:applied_payment_requests) } + it { is_expected.to have_many(:payments) } + it { is_expected.to have_many(:payment_receipts).through(:payments) } + it { is_expected.to have_many(:invoice_settlements).with_foreign_key(:target_invoice_id) } + + it { is_expected.to have_many(:applied_usage_thresholds) } + it { is_expected.to have_many(:usage_thresholds).through(:applied_usage_thresholds) } + it { is_expected.to have_one(:regenerated_invoice).class_name("Invoice").with_foreign_key(:voided_invoice_id) } + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + it { is_expected.to belong_to(:billing_entity).optional } + it { is_expected.to belong_to(:payment_method).optional } + + it "has fixed status mapping" do + expect(described_class::VISIBLE_STATUS).to match(draft: 0, finalized: 1, voided: 2, failed: 4, pending: 7) + expect(described_class::INVISIBLE_STATUS).to match(generating: 3, open: 5, closed: 6) + expect(described_class::STATUS).to match(draft: 0, finalized: 1, voided: 2, generating: 3, failed: 4, open: 5, closed: 6, pending: 7) + end + + describe "validation" do + describe "of payment dispute lost absence" do + context "when invoice is finalized" do + let(:invoice) { create(:invoice) } + + specify do + expect(invoice).not_to validate_absence_of(:payment_dispute_lost_at) + end + end + + context "when invoice is voided" do + let(:invoice) { create(:invoice, :voided) } + + specify do + expect(invoice).not_to validate_absence_of(:payment_dispute_lost_at) + end + end + + context "when invoice is pending" do + let(:invoice) { create(:invoice, :pending) } + + specify do + expect(invoice).to validate_absence_of(:payment_dispute_lost_at) + end + end + + context "when invoice is draft" do + let(:invoice) { create(:invoice, :draft) } + + specify do + expect(invoice).to validate_absence_of(:payment_dispute_lost_at) + end + end + + context "when invoice is failed" do + let(:invoice) { create(:invoice, :failed) } + + specify do + expect(invoice).to validate_absence_of(:payment_dispute_lost_at) + end + end + + context "when invoice is invisible" do + let(:invoice) { create(:invoice, :invisible) } + + specify do + expect(invoice).to validate_absence_of(:payment_dispute_lost_at) + end + end + end + end + + describe "finalized_at" do + let(:invoice) { create(:invoice, :draft) } + + it "is set when the invoice is finalized" do + freeze_time do + invoice.finalized! + + expect(invoice.finalized_at).to eq(Time.current) + end + end + end + + describe "sequential_id" do + let(:customer) { create(:customer, organization:) } + let(:billing_entity) { customer.billing_entity } + + let(:invoice) do + build(:invoice, customer:, organization:, billing_entity:, billing_entity_sequential_id: nil, organization_sequential_id: 0, status: :generating) + end + + it "assigns a sequential id, billing entity sequential id and organization sequential id to a new invoice" do + invoice.save! + invoice.finalized! + + expect(invoice).to be_valid + expect(invoice.sequential_id).to eq(1) + expect(invoice.billing_entity_sequential_id).to be_nil + expect(invoice.organization_sequential_id).to be_zero + end + + context "when sequential_id, billing_entity_sequential_id and organization_sequential_id are present" do + before do + invoice.sequential_id = 3 + invoice.billing_entity_sequential_id = 2 + invoice.organization_sequential_id = 5 + end + + it "does not replace the sequential_id, billing_entity_sequential_id and organization_sequential_id" do + invoice.save! + invoice.finalized! + + expect(invoice).to be_valid + expect(invoice.sequential_id).to eq(3) + expect(invoice.billing_entity_sequential_id).to eq(2) + expect(invoice.organization_sequential_id).to eq(5) + end + end + + context "when customer already has invoices" do + before do + create(:invoice, customer:, organization:, billing_entity:, sequential_id: 1, billing_entity_sequential_id: 1, organization_sequential_id: 0) + create(:invoice, customer:, organization:, billing_entity:, sequential_id: 2, billing_entity_sequential_id: 2, organization_sequential_id: 0) + end + + it "takes the next available id" do + invoice.save! + invoice.finalized! + + expect(invoice).to be_valid + expect(invoice.sequential_id).to eq(3) + expect(invoice.billing_entity_sequential_id).to be_nil + expect(invoice.organization_sequential_id).to be_zero + end + end + + context "with invoices in other billing entities" do + let(:billing_entity_2) { create(:billing_entity, organization:) } + + before do + create(:invoice, organization:, billing_entity: billing_entity_2, sequential_id: 1, billing_entity_sequential_id: 1, organization_sequential_id: 0) + create(:invoice, organization:, billing_entity: billing_entity_2, sequential_id: 2, billing_entity_sequential_id: 2, organization_sequential_id: 0) + end + + it "scopes the sequences to the billing entity" do + invoice.save! + invoice.finalized! + + expect(invoice).to be_valid + expect(invoice.sequential_id).to eq(1) + expect(invoice.billing_entity_sequential_id).to be_nil + expect(invoice.organization_sequential_id).to be_zero + end + end + + context "with invoices on other organization" do + before do + create(:invoice, sequential_id: 1, organization_sequential_id: 0) + end + + it "scopes the sequence to the organization" do + invoice.save! + invoice.finalized! + + expect(invoice).to be_valid + expect(invoice.sequential_id).to eq(1) + expect(invoice.organization_sequential_id).to be_zero + end + end + + context "with billing_entity numbering and invoices in another month" do + let(:organization) { create(:organization, document_numbering: "per_organization") } + let(:created_at) { Time.now.utc - 1.month } + + before do + organization.default_billing_entity.update!(document_numbering: "per_billing_entity") + create(:invoice, customer:, organization:, sequential_id: 1, billing_entity_sequential_id: 1, organization_sequential_id: 1, created_at:) + create(:invoice, customer:, organization:, sequential_id: 2, billing_entity_sequential_id: 2, organization_sequential_id: 2, created_at:) + end + + it "scopes the billing_entity_sequential_id to the billing_entity and month" do + invoice.save! + invoice.finalized! + + expect(invoice).to be_valid + expect(invoice.sequential_id).to eq(3) + expect(invoice.billing_entity_sequential_id).to eq(3) + # expect(invoice.organization_sequential_id).to eq(3) + end + end + end + + describe "ready_to_be_finalized" do + let(:invoice) { create(:invoice, status: :draft, expected_finalization_date: Time.current - 1.day) } + + before { invoice } + + it "returns all invoices that are ready for finalization" do + expect(described_class.ready_to_be_finalized.pluck(:id)).to include(invoice.id) + end + + context "when expected_finalization_date has not been reached" do + let(:invoice) { create(:invoice, status: :draft, expected_finalization_date: Time.current + 1.day) } + + it "returns all invoices that are ready for finalization" do + expect(described_class.ready_to_be_finalized.pluck(:id)).not_to include(invoice.id) + end + end + + context "when expected_finalization_date is nil" do + let(:invoice) { create(:invoice, status: :draft, issuing_date: Time.current + 1.day) } + + it "returns all invoices that are ready for finalization" do + expect(described_class.ready_to_be_finalized.pluck(:id)).to include(invoice.id) + end + end + end + + describe "ready_to_be_refreshed" do + let(:invoices) do + [ + create(:invoice, status: :draft, ready_to_be_refreshed: true), + create(:invoice, status: :draft, ready_to_be_refreshed: false), + create(:invoice, status: :finalized, ready_to_be_refreshed: true) + ] + end + + before { invoices } + + it "returns only the invoices that are ready for refresh" do + expect(described_class.ready_to_be_refreshed.pluck(:id)).to include(invoices[0].id) + end + end + + describe "when status is visible" do + it do + described_class::VISIBLE_STATUS.keys.each do |status| + i = create(:invoice, status:) + expect(i).to be_visible + expect(i).not_to be_invisible + end + end + end + + describe "when status is invisible" do + it do + described_class::INVISIBLE_STATUS.keys.each do |status| + i = create(:invoice, status:) + expect(i).to be_invisible + expect(i).not_to be_visible + end + end + end + + describe "#should_sync_invoice?" do + subject(:method_call) { invoice.should_sync_invoice? } + + let(:invoice) { create(:invoice, customer:, organization:, status:) } + + context "when invoice is not finalized" do + let(:status) { %i[draft generating voided].sample } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration customer" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_invoices:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration customer" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_invoices:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "returns true" do + expect(method_call).to eq(true) + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "when the invoice is self_billed" do + let(:invoice) { create(:invoice, customer:, organization:, status:, self_billed: true) } + let(:sync_invoices) { true } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + end + + describe "#should_sync_salesforce_invoice?" do + subject(:method_call) { invoice.should_sync_salesforce_invoice? } + + let(:invoice) { create(:invoice, customer:, organization:, status: :finalized) } + + context "with integration salesforce customer" do + let(:integration_customer) { create(:salesforce_customer, integration:, customer:) } + let(:integration) { create(:salesforce_integration, organization:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + it "returns true" do + expect(method_call).to eq(true) + end + + context "when the invoice is self-billed" do + let(:invoice) { create(:invoice, customer:, organization:, status: :finalized, self_billed: true) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + + context "without integration salesforce customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + + describe "#should_sync_hubspot_invoice?" do + subject(:method_call) { invoice.should_sync_hubspot_invoice? } + + let(:invoice) { create(:invoice, customer:, organization:, status:) } + + context "when invoice is not finalized" do + let(:status) { %i[draft generating voided].sample } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration hubspot customer" do + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration) { create(:hubspot_integration, organization:, sync_invoices:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration hubspot customer" do + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration) { create(:hubspot_integration, organization:, sync_invoices:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "returns true" do + expect(method_call).to eq(true) + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "when invoice is self_billed" do + let(:invoice) { create(:invoice, customer:, organization:, status:, self_billed: true) } + let(:sync_invoices) { true } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + end + + describe "#should_update_hubspot_invoice?" do + subject(:method_call) { invoice.should_update_hubspot_invoice? } + + let(:invoice) { create(:invoice, customer:, organization:, status:) } + + context "when invoice is not finalized" do + let(:status) { %i[draft generating voided].sample } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration hubpot customer" do + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration) { create(:hubspot_integration, organization:, sync_invoices:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "returns true" do + expect(method_call).to eq(true) + end + + context "when invoice is self_billed" do + let(:invoice) { create(:invoice, customer:, organization:, status:, self_billed: true) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration hubspot customer" do + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration) { create(:hubspot_integration, organization:, sync_invoices:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "returns true" do + expect(method_call).to eq(true) + end + + context "when invoice is self_billed" do + let(:invoice) { create(:invoice, customer:, organization:, status:, self_billed: true) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + end + + describe "number" do + let(:organization) { create(:organization, name: "ACME Corporation") } + let(:billing_entity) { create(:billing_entity, organization:, name: "LAGO") } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { build(:invoice, customer:, organization:, billing_entity:, billing_entity_sequential_id: nil, status: :generating) } + + it "generates the invoice number" do + invoice.save! + invoice.finalized! + billing_entity_id_substring = billing_entity.id.last(4).upcase + + expect(invoice.number).to eq("LAG-#{billing_entity_id_substring}-001-001") + end + + context "with billing entity numbering" do + let(:billing_entity) { create(:billing_entity, organization:, name: "lago", document_numbering: "per_billing_entity") } + + it "scopes the billing_entity_sequential_id to the billing entity and month" do + invoice.save! + invoice.finalized! + billing_entity_id_substring = billing_entity.id.last(4).upcase + + expect(invoice.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-001") + end + + context "with existing invoices in current month" do + let(:created_at) { Time.now.utc } + + before do + create(:invoice, customer:, billing_entity:, organization:, sequential_id: 4, billing_entity_sequential_id: 14, created_at:) + create(:invoice, customer:, billing_entity:, organization:, sequential_id: 5, billing_entity_sequential_id: 15, created_at:) + end + + it "scopes the billing_entity_sequential_id to the billing entity and month" do + invoice.save! + invoice.finalized! + + billing_entity_id_substring = billing_entity.id.last(4).upcase + + expect(invoice.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-016") + end + + context "when invoice is self_billed" do + let(:invoice) { build(:invoice, customer:, organization:, billing_entity:, billing_entity_sequential_id: nil, status: :generating, self_billed: true) } + + it "generates the invoice number based on customer sequence" do + invoice.customer.update(sequential_id: 27) + invoice.save! + invoice.finalized! + billing_entity_id_substring = billing_entity.id.last(4).upcase + + expect(invoice.number).to eq("LAG-#{billing_entity_id_substring}-027-006") + end + end + end + + context "with existing invoices in previous month" do + let(:created_at) { Time.now.utc - 1.month } + + before do + create(:invoice, customer:, billing_entity:, organization:, sequential_id: 4, billing_entity_sequential_id: 14, created_at:) + create(:invoice, customer:, billing_entity:, organization:, sequential_id: 5, billing_entity_sequential_id: 15, created_at:) + end + + it "scopes the billing_entity_sequential_id to the billing entity and month" do + invoice.save! + invoice.finalized! + + billing_entity_id_substring = billing_entity.id.last(4).upcase + + expect(invoice.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-016") + end + end + + context "with existing draft invoices that have generated sequential ids" do + let(:created_at) { Time.now.utc } + + let(:invoice1) do + create( + :invoice, + customer:, + organization:, + billing_entity:, + sequential_id: 4, + billing_entity_sequential_id: 14, + created_at:, + status: :draft, + number: "LAG-#{billing_entity.id.last(4).upcase}-#{Time.now.utc.strftime("%Y%m")}-014" + ) + end + let(:invoice2) do + create( + :invoice, + customer:, + organization:, + billing_entity:, + sequential_id: 5, + billing_entity_sequential_id: 15, + created_at:, + number: "LAG-#{billing_entity.id.last(4).upcase}-#{Time.now.utc.strftime("%Y%m")}-015" + ) + end + + before do + invoice1 + invoice2 + end + + it "scopes the billing_entity_sequential_id to the billing entity and month" do + invoice.save! + invoice.finalized! + + billing_entity_id_substring = billing_entity.id.last(4).upcase + + expect(invoice.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-016") + + invoice1.update!(payment_due_date: invoice1.payment_due_date + 1.day) + invoice2.update!(payment_due_date: invoice2.payment_due_date + 1.day) + + expect(invoice1.reload.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-014") + expect(invoice2.reload.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-015") + + invoice1.finalized! + + expect(invoice1.reload.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-014") + end + end + + context "with existing draft invoices that does not have generated sequential ids" do + let(:created_at) { Time.now.utc } + + let(:invoice1) do + create( + :invoice, + customer:, + organization:, + billing_entity:, + sequential_id: nil, + billing_entity_sequential_id: nil, + created_at:, + status: :draft, + number: "LAG-#{billing_entity.id.last(4).upcase}-DRAFT" + ) + end + let(:invoice2) do + create( + :invoice, + customer:, + organization:, + billing_entity:, + sequential_id: 4, + billing_entity_sequential_id: 14, + created_at:, + number: "LAG-#{billing_entity.id.last(4).upcase}-#{Time.now.utc.strftime("%Y%m")}-014" + ) + end + + before do + invoice1 + invoice2 + end + + it "scopes the billing_entity_sequential_id to the billing entity and month" do + invoice.save! + invoice.finalized! + + billing_entity_id_substring = billing_entity.id.last(4).upcase + + expect(invoice.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-015") + + invoice1.update!(payment_due_date: invoice1.payment_due_date + 1.day) + invoice2.update!(payment_due_date: invoice2.payment_due_date + 1.day) + + expect(invoice1.reload.number).to eq("LAG-#{billing_entity_id_substring}-DRAFT") + expect(invoice2.reload.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-014") + + invoice1.finalized! + + expect(invoice1.reload.number).to eq("LAG-#{billing_entity_id_substring}-#{Time.now.utc.strftime("%Y%m")}-016") + end + end + end + end + + describe "#currency" do + let(:invoice) { build(:invoice, currency: "JPY") } + + it { expect(invoice.currency).to eq("JPY") } + end + + describe "#prepaid_granted_credit_amount" do + let(:invoice) { build(:invoice, currency: "USD", prepaid_granted_credit_amount_cents: 500) } + + it "returns the granted credit amount as Money" do + expect(invoice.prepaid_granted_credit_amount).to eq(Money.new(500, "USD")) + end + + context "when nil" do + let(:invoice) { build(:invoice, currency: "USD", prepaid_granted_credit_amount_cents: nil) } + + it { expect(invoice.prepaid_granted_credit_amount).to be_nil } + end + end + + describe "#prepaid_purchased_credit_amount" do + let(:invoice) { build(:invoice, currency: "USD", prepaid_purchased_credit_amount_cents: 300) } + + it "returns the purchased credit amount as Money" do + expect(invoice.prepaid_purchased_credit_amount).to eq(Money.new(300, "USD")) + end + + context "when nil" do + let(:invoice) { build(:invoice, currency: "USD", prepaid_purchased_credit_amount_cents: nil) } + + it { expect(invoice.prepaid_purchased_credit_amount).to be_nil } + end + end + + describe "#charge_amount" do + let(:organization) { create(:organization, name: "LAGO") } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:fees) { create_list(:fee, 3, invoice:) } + + it "returns the charges amount" do + expect(invoice.charge_amount.to_s).to eq("0.00") + end + end + + describe "#fee_total_amount_cents" do + let(:organization) { create(:organization, name: "LAGO") } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + it "returns the fee amount vat included" do + create(:fee, invoice:, amount_cents: 100, taxes_rate: 20) + create(:fee, invoice:, amount_cents: 133, taxes_rate: 20) + + expect(invoice.fee_total_amount_cents).to eq(120 + 160) + end + end + + describe "#subscription_amount" do + let(:organization) { create(:organization, name: "LAGO") } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:fees) { create_list(:fee, 2, invoice:) } + + it "returns the subscriptions amount" do + create(:fee, invoice:, amount_cents: 200) + create(:fee, invoice:, amount_cents: 100) + create(:charge_fee, invoice:, charge_id: create(:standard_charge).id) + + expect(invoice.subscription_amount.to_s).to eq("3.00") + end + end + + describe "#invoice_subscription" do + let(:invoice_subscription) { create(:invoice_subscription) } + + it "returns the invoice_subscription for the given subscription id" do + invoice = invoice_subscription.invoice + subscription = invoice_subscription.subscription + + expect(invoice.invoice_subscription(subscription.id)).to eq(invoice_subscription) + end + end + + describe "#has_different_boundaries_for_subscription_and_charges?" do + subject { invoice.has_different_boundaries_for_subscription_and_charges?(subscription) } + + let(:invoice) { invoice_subscription.invoice } + let(:subscription) { invoice_subscription.subscription } + let(:customer) { subscription.customer } + let(:from_datetime) { DateTime.parse("2024-01-01 00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-31 23:59:59") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01 00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-01-31 23:59:59") } + + let(:invoice_subscription) do + create( + :invoice_subscription, + from_datetime:, + to_datetime:, + charges_from_datetime:, + charges_to_datetime: + ) + end + + it { is_expected.to be(false) } + + context "when only from_datetime differs" do + let(:charges_from_datetime) { DateTime.parse("2024-01-02 00:00:00") } + + it { is_expected.to be(false) } + end + + context "when only to_datetime differs" do + let(:charges_to_datetime) { DateTime.parse("2024-01-30 23:59:59") } + + it { is_expected.to be(false) } + end + + context "when both from_datetime and to_datetime differ" do + let(:charges_from_datetime) { DateTime.parse("2024-01-02 00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-01-30 23:59:59") } + + it { is_expected.to be(true) } + end + + context "with customer timezone" do + let(:customer) { create(:customer, timezone: "America/New_York") } + let(:subscription) { create(:subscription, customer:) } + let(:from_datetime) { DateTime.parse("2024-01-01 05:00:00 UTC") } + let(:to_datetime) { DateTime.parse("2024-02-01 04:59:59 UTC") } + let(:charges_from_datetime) { DateTime.parse("2024-01-02 05:00:00 UTC") } + let(:charges_to_datetime) { DateTime.parse("2024-02-02 04:59:59 UTC") } + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime:, + charges_from_datetime:, + charges_to_datetime: + ) + end + + it { is_expected.to be(true) } + end + + context "when dates are at boundary edges and dates conver to same day in customer timezone" do + let(:from_datetime) { DateTime.parse("2024-01-01 00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-31 23:59:59") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01 00:00:01") } + let(:charges_to_datetime) { DateTime.parse("2024-01-31 23:59:58") } + + it { is_expected.to be(false) } + end + end + + describe "#has_different_boundaries_for_subscription_and_fixed_charges?" do + subject { invoice.has_different_boundaries_for_subscription_and_fixed_charges?(subscription) } + + let(:invoice) { invoice_subscription.invoice } + let(:subscription) { invoice_subscription.subscription } + let(:customer) { subscription.customer } + let(:from_datetime) { DateTime.parse("2024-01-01 00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-31 23:59:59") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01 00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-01-31 23:59:59") } + + let(:invoice_subscription) do + create( + :invoice_subscription, + from_datetime:, + to_datetime:, + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + ) + end + + it { is_expected.to be(false) } + + context "when only from_datetime differs" do + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-02 00:00:00") } + + it { is_expected.to be(false) } + end + + context "when only to_datetime differs" do + let(:fixed_charges_to_datetime) { DateTime.parse("2024-01-30 23:59:59") } + + it { is_expected.to be(false) } + end + + context "when both from_datetime and to_datetime differ" do + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-02 00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-01-30 23:59:59") } + + it { is_expected.to be(true) } + end + + context "with customer timezone" do + let(:customer) { create(:customer, timezone: "America/New_York") } + let(:subscription) { create(:subscription, customer:) } + let(:from_datetime) { DateTime.parse("2024-01-01 05:00:00 UTC") } + let(:to_datetime) { DateTime.parse("2024-02-01 04:59:59 UTC") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-02 05:00:00 UTC") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-02-02 04:59:59 UTC") } + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime:, + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + ) + end + + it { is_expected.to be(true) } + end + + context "when dates are at boundary edges and dates conver to same day in customer timezone" do + let(:from_datetime) { DateTime.parse("2024-01-01 00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-31 23:59:59") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01 00:00:01") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-01-31 23:59:58") } + + it { is_expected.to be(false) } + end + end + + describe "#subscription_fees" do + let(:invoice_subscription) { create(:invoice_subscription) } + + it "returns the fees of the corresponding invoice_subscription" do + invoice = invoice_subscription.invoice + subscription = invoice_subscription.subscription + fee = create(:fee, subscription_id: subscription.id, invoice_id: invoice.id) + + expect(invoice.subscription_fees(subscription.id)).to eq([fee]) + end + end + + describe "#recurring_fees" do + let(:invoice_subscription) { create(:invoice_subscription) } + let(:invoice) { invoice_subscription.invoice } + let(:subscription) { invoice_subscription.subscription } + let(:billable_metric) { create(:sum_billable_metric, organization: subscription.organization, recurring: true) } + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:, pay_in_advance: false) } + let(:fee) { create(:charge_fee, subscription:, invoice:, charge:) } + + it "returns the fees of the corresponding invoice_subscription" do + expect(invoice.recurring_fees(subscription.id)).to eq([fee]) + end + + context "when charge is pay_in_advance" do + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:, pay_in_advance: true) } + + it "returns the fees of the corresponding invoice_subscription" do + expect(invoice.recurring_fees(subscription.id)).to eq([]) + end + end + end + + describe "#recurring_breakdown" do + let(:invoice_subscription) { create(:invoice_subscription) } + let(:invoice) { invoice_subscription.invoice } + let(:subscription) { invoice_subscription.subscription } + let(:billable_metric) { create(:sum_billable_metric, organization: subscription.organization, recurring: true) } + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:, pay_in_advance: false) } + let(:fee) { create(:charge_fee, subscription:, invoice:, charge:) } + + it "returns the fees of the corresponding invoice_subscription" do + expect(invoice.recurring_breakdown(fee)).to eq([]) + end + + context "with charge filter" do + let(:charge_filter) { create(:charge_filter, charge:) } + + before { fee.update(charge_filter:) } + + it "returns the fees of the corresponding invoice_subscription" do + expect(invoice.recurring_breakdown(fee)).to eq([]) + end + end + end + + describe "#document_invoice_name" do + let(:organization) { create(:organization, name: "LAGO", country: "FR") } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + it "returns the correct name for EU country" do + expect(invoice.document_invoice_name).to eq("Invoice") + end + + context "when invoice is self billed" do + let(:invoice) { create(:invoice, :self_billed, customer:, organization:) } + + it "returns the correct name for EU country" do + expect(invoice.document_invoice_name).to eq("Self-billing invoice") + end + end + + context "when organization country is Australia" do + let(:organization) { create(:organization, name: "LAGO", country: "AU") } + + it "returns the correct name that includes keyword tax" do + expect(invoice.document_invoice_name).to eq("Tax invoice") + end + end + + context "when the organization country is in TAX_INVOICE_LABEL_COUNTRIES" do + let(:country) { Invoice::TAX_INVOICE_LABEL_COUNTRIES.sample } + let(:organization) { create(:organization, name: "LAGO", country:) } + + it "returns the correct tax invoice name" do + expect(invoice.document_invoice_name).to eq(I18n.t("invoice.document_tax_name")) + end + end + + context "when it is credit invoice" do + let(:invoice) { create(:invoice, customer:, organization:, invoice_type: :credit) } + + it "returns the correct name for EU country" do + expect(invoice.document_invoice_name).to eq("Advance invoice") + end + end + end + + describe "#charge_pay_in_advance_proration_range" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:) } + let(:invoice) { invoice_subscription.invoice } + let(:subscription) { create(:subscription, started_at: timestamp - 1.year) } + let(:timestamp) { DateTime.parse("2023-07-25 00:00:00 UTC") } + let(:event) { create(:event, subscription_id: subscription.id, timestamp:) } + let(:billable_metric) { create(:sum_billable_metric, organization: subscription.organization, recurring: true) } + let(:fee) { create(:charge_fee, subscription:, invoice:, charge:, pay_in_advance_event_id: event.id, pay_in_advance_event_transaction_id: event.transaction_id) } + let(:charge) do + create(:standard_charge, plan: subscription.plan, billable_metric:, pay_in_advance: true, prorated: true) + end + + it "returns the fees of the corresponding invoice_subscription" do + expect(invoice.charge_pay_in_advance_proration_range(fee, event.timestamp)).to include( + period_duration: 31, + number_of_days: 7 + ) + end + end + + describe "#subscription_gated?" do + subject(:subscription_gated?) { invoice.subscription_gated? } + + before { invoice } + + context "when invoice is not open" do + let(:invoice) { create(:invoice) } + + it { is_expected.to be(false) } + end + + context "when invoice is open" do + let(:invoice) do + create( + :invoice, + :open, + :with_subscriptions, + subscriptions: [subscription], + organization: subscription.organization, + customer: subscription.customer + ) + end + + context "when subscription is gated" do + let(:subscription) { create(:subscription, :incomplete) } + + before do + create(:subscription_activation_rule, subscription:, status: "pending") + end + + it { is_expected.to be(true) } + end + + context "when subscription is not gated" do + let(:subscription) { create(:subscription, :incomplete) } + + it { is_expected.to be(false) } + end + end + end + + describe "#subscription_payment_gated?" do + subject(:subscription_payment_gated?) { invoice.subscription_payment_gated? } + + before { invoice } + + context "when invoice is not open" do + let(:invoice) { create(:invoice) } + + it { is_expected.to be(false) } + end + + context "when invoice is open" do + let(:invoice) do + create( + :invoice, + :open, + :with_subscriptions, + subscriptions: [subscription], + organization: subscription.organization, + customer: subscription.customer + ) + end + + context "when subscription is payment-gated" do + let(:subscription) { create(:subscription, :incomplete) } + + before do + create(:subscription_activation_rule, subscription:, status: "pending") + end + + it { is_expected.to be(true) } + end + + context "when subscription is not gated" do + let(:subscription) { create(:subscription, :incomplete) } + + it { is_expected.to be(false) } + end + end + end + + describe "#voidable?" do + subject(:voidable) { invoice.voidable? } + + context "when payment disputed was lost" do + let(:payment_dispute_lost_at) { DateTime.current - 1.day } + + context "when invoice has a voided credit note" do + let(:invoice) { create(:invoice, status:, payment_status:, payment_dispute_lost_at:) } + + before { create(:credit_note, credit_status: :voided, invoice:) } + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + + context "when invoice has a non-voided credit note" do + let(:invoice) { create(:invoice, status:, payment_status:, payment_dispute_lost_at:) } + + before { create(:credit_note, invoice:) } + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + + context "when invoice has no credit notes" do + let(:invoice) { build_stubbed(:invoice, status:, payment_status:, payment_dispute_lost_at:) } + + context "when invoice is not finalized" do + let(:status) { :draft } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + end + + context "when payment is not disputed" do + let(:payment_dispute_lost_at) { nil } + + context "when invoice has a voided credit note" do + let(:invoice) { create(:invoice, status:, payment_status:, payment_dispute_lost_at:, total_paid_amount_cents:) } + + before { create(:credit_note, credit_status: :voided, invoice:) } + + context "when invoice is not finalized" do + let(:status) { [:draft, :voided].sample } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns true" do + expect(voidable).to be true + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns true" do + expect(voidable).to be true + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + end + + context "when invoice has a non-voided credit note" do + let(:invoice) { create(:invoice, status:, payment_status:, payment_dispute_lost_at:) } + + before { create(:credit_note, invoice:) } + + context "when invoice is not finalized" do + let(:status) { [:draft, :voided].sample } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + context "when total paid amount is zero" do + let(:total_paid_amount_cents) { 0 } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when total paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + end + + context "when invoice has no credit notes" do + let(:invoice) { build_stubbed(:invoice, status:, payment_status:, payment_dispute_lost_at:) } + + context "when invoice is not finalized" do + let(:status) { [:draft, :voided].sample } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + it "returns false" do + expect(voidable).to be false + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + it "returns false" do + expect(voidable).to be false + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "when invoice is payment_pending" do + let(:payment_status) { :pending } + + it "returns true" do + expect(voidable).to be true + end + end + + context "when invoice is payment_failed" do + let(:payment_status) { :failed } + + it "returns true" do + expect(voidable).to be true + end + end + + context "when invoice is payment_succeeded" do + let(:payment_status) { :succeeded } + + it "returns false" do + expect(voidable).to be false + end + end + end + end + end + end + + describe "#mark_as_voided!" do + subject(:force_void_call) { invoice.void! } + + context "when invoice is finalized" do + let(:invoice) { build(:invoice, status: :finalized, ready_for_payment_processing: true, payment_overdue: true) } + + it "changes the status to voided and disables payment processing" do + expect { force_void_call }.to change(invoice, :status).from("finalized").to("voided") + .and change(invoice, :ready_for_payment_processing).from(true).to(false) + .and change(invoice, :payment_overdue).from(true).to(false) + end + end + end + + describe "#mark_as_dispute_lost!" do + subject(:mark_as_dispute_lost_call) { invoice.mark_as_dispute_lost! } + + context "when record is new" do + let(:invoice) { build(:invoice, payment_dispute_lost_at:, payment_overdue: true) } + + context "when payment is not disputed" do + let(:payment_dispute_lost_at) { nil } + + it "changes payment disputed lost date" do + expect { mark_as_dispute_lost_call }.to change(invoice, :payment_dispute_lost_at).from(nil) + .and change(invoice, :payment_overdue).from(true).to(false) + end + end + + context "when payment is disputed" do + let(:payment_dispute_lost_at) { DateTime.current - 1.day } + + it "does not change payment dispute lost date" do + expect { mark_as_dispute_lost_call }.not_to change(invoice, :payment_dispute_lost_at) + end + end + end + + context "when record already exists" do + let(:invoice) { create(:invoice, payment_dispute_lost_at:) } + + context "when payment is not disputed" do + let(:payment_dispute_lost_at) { nil } + + it "changes payment dispute lost date" do + expect { mark_as_dispute_lost_call }.to change(invoice, :payment_dispute_lost_at).from(nil) + end + end + + context "when payment is disputed" do + let(:payment_dispute_lost_at) { DateTime.current - 1.day } + + it "does not change payment dispute lost date" do + expect { mark_as_dispute_lost_call }.not_to change(invoice, :payment_dispute_lost_at) + end + end + end + end + + describe "#available_to_credit_amount_cents" do + context "with created fee" do + before do + invoice_subscription = create(:invoice_subscription, invoice:) + subscription = invoice_subscription.subscription + billable_metric = create(:unique_count_billable_metric, organization: subscription.organization) + charge = create(:standard_charge, plan: subscription.plan, billable_metric:) + create(:charge_fee, subscription:, invoice:, charge:, amount_cents: 133, taxes_rate: 20) + invoice.update(fees_amount_cents: 160) + end + + context "when invoice v1" do + let(:invoice) { create(:invoice, version_number: 1) } + + it "returns 0" do + expect(invoice.available_to_credit_amount_cents).to eq(0) + end + end + + context "when invoice is credit" do + let(:invoice) { create(:invoice, :credit) } + + it "returns 160" do + expect(invoice.available_to_credit_amount_cents).to eq(160) + end + end + + context "when draft" do + let(:invoice) { create(:invoice, :draft) } + + it "returns 0" do + expect(invoice.available_to_credit_amount_cents).to eq(0) + end + end + + it "returns the expected creditable amount in cents" do + expect(invoice.available_to_credit_amount_cents).to eq(160) + end + end + + context "when fees sum is zero" do + let(:invoice_subscription) { create(:invoice_subscription) } + let(:invoice) { invoice_subscription.invoice } + let(:subscription) { invoice_subscription.subscription } + let(:billable_metric) { create(:unique_count_billable_metric, organization: subscription.organization) } + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:) } + + before do + create(:charge_fee, subscription:, invoice:, charge:, amount_cents: 0, taxes_rate: 20) + end + + it "returns 0" do + expect(invoice.available_to_credit_amount_cents).to eq(0) + end + end + + context "when invoice v3 with coupons" do + let(:invoice) do + create( + :invoice, + fees_amount_cents: 200, + coupons_amount_cents: 20, + progressive_billing_credit_amount_cents:, + taxes_amount_cents: 36, + total_amount_cents: 216, + taxes_rate: 20, + version_number: 3 + ) + end + + let(:invoice_subscription) { create(:invoice_subscription, invoice:) } + let(:subscription) { invoice_subscription.subscription } + let(:billable_metric) do + create(:unique_count_billable_metric, organization: subscription.organization) + end + let(:charge) do + create(:standard_charge, plan: subscription.plan, billable_metric:) + end + let(:fee) do + create(:charge_fee, subscription:, invoice:, charge:, amount_cents: 200, taxes_rate: 20) + end + + let(:progressive_billing_credit_amount_cents) { 0 } + + before { fee } + + it "returns the expected creditable amount in cents" do + expect(invoice.available_to_credit_amount_cents).to eq(216) + end + + context "with progressive billing credit" do + let(:progressive_billing_credit_amount_cents) { 2 } + + it "returns the expected creditable amount in cents" do + expect(invoice.available_to_credit_amount_cents).to eq(214) + end + end + end + end + + describe ".file_url" do + before do + invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + end + + it "returns the file url" do + file_url = invoice.file_url + + expect(file_url).to be_present + expect(file_url).to include(ENV["LAGO_API_URL"]) + end + end + + describe ".xml_url" do + before do + invoice.xml_file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.xml"))), + filename: "invoice.xml", + content_type: "application/xml" + ) + end + + it "returns the xml file url" do + xml_url = invoice.xml_url + + expect(xml_url).to be_present + expect(xml_url).to include(ENV["LAGO_API_URL"]) + end + end + + describe "#creditable_amount_cents" do + let(:invoice) { create(:invoice, version_number:, status:, payment_status:, fees_amount_cents:, taxes_amount_cents:, coupons_amount_cents:, progressive_billing_credit_amount_cents:) } + let(:fees_amount_cents) { 1000 } + let(:taxes_amount_cents) { 200 } + let(:coupons_amount_cents) { 100 } + let(:progressive_billing_credit_amount_cents) { 50 } + + before do + invoice_subscription = create(:invoice_subscription, invoice:) + subscription = invoice_subscription.subscription + billable_metric = create(:unique_count_billable_metric, organization: subscription.organization) + charge = create(:standard_charge, plan: subscription.plan, billable_metric:) + create(:charge_fee, subscription:, invoice:, charge:, amount_cents: 133, taxes_rate: 20) + invoice.update(fees_amount_cents: 160) + end + + context "when version_number is less than CREDIT_NOTES_MIN_VERSION" do + let(:version_number) { 1 } + let(:status) { :finalized } + let(:payment_status) { :succeeded } + + it "returns 0" do + expect(invoice.creditable_amount_cents).to eq(0) + end + end + + context "when invoice is a draft" do + let(:version_number) { 2 } + let(:status) { :draft } + let(:payment_status) { :succeeded } + + it "returns 0" do + expect(invoice.creditable_amount_cents).to eq(0) + end + end + + context "when all conditions are met" do + let(:version_number) { 2 } + let(:status) { :finalized } + let(:payment_status) { :succeeded } + + it "returns the correct creditable amount" do + expect(invoice.creditable_amount_cents).to eq(160) + end + end + + context "when invoice is a credit" do + let(:invoice) { create(:invoice, :credit, version_number: 2, status: :finalized, payment_status: :succeeded, fees_amount_cents: 1000, taxes_amount_cents: 200, coupons_amount_cents: 100, progressive_billing_credit_amount_cents: 50) } + + it "returns the correct creditable amount" do + expect(invoice.creditable_amount_cents).to eq(0) + end + end + end + + describe "#refundable_amount_cents" do + let(:invoice) do + create( + :invoice, + version_number:, + status:, + payment_status:, + prepaid_credit_amount_cents:, + total_paid_amount_cents:, + total_amount_cents: 1000 + ) + end + + let(:available_to_credit_amount_cents) { 1000 } + let(:prepaid_credit_amount_cents) { 200 } + let(:total_paid_amount_cents) { 900 } + + before do + allow(invoice).to receive(:available_to_credit_amount_cents).and_return(available_to_credit_amount_cents) + create(:payment, payable: invoice, amount_cents: 900, status: :succeeded, payable_payment_status: :succeeded) + end + + context "when version_number is less than CREDIT_NOTES_MIN_VERSION" do + let(:version_number) { 1 } + let(:status) { :finalized } + let(:payment_status) { :succeeded } + + it "returns 0" do + expect(invoice.refundable_amount_cents).to eq(0) + end + end + + context "when invoice is a draft" do + let(:version_number) { 2 } + let(:status) { :draft } + let(:payment_status) { :succeeded } + + it "returns 0" do + expect(invoice.refundable_amount_cents).to eq(0) + end + end + + context "when payment is not succeeded" do + let(:version_number) { 2 } + let(:status) { :finalized } + let(:payment_status) { :pending } + + context "when total amount is equal to paid amount" do + let(:total_paid_amount_cents) { 1000 } + + it "returns the correct refundable amount" do + expect(invoice.refundable_amount_cents).to eq(0) + end + end + + context "when total amount is not equal to paid amount" do + it "returns the correct refundable amount" do + expect(invoice.refundable_amount_cents).to eq(900) + end + end + end + + context "when all conditions are met" do + let(:version_number) { 2 } + let(:status) { :finalized } + let(:payment_status) { :succeeded } + + it "returns the correct refundable amount" do + expect(invoice.refundable_amount_cents).to eq(900) + end + end + + context "when invoice is a credit" do + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, balance_cents: 500, traceable: false) } + let(:inbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 300) + end + let(:invoice) do + create( + :invoice, + :credit, + organization:, + customer:, + version_number: 2, + status: :finalized, + payment_status: :succeeded, + prepaid_credit_amount_cents: 200, + total_paid_amount_cents: 900 + ) + end + + before do + create(:fee, invoice:, fee_type: :credit, invoiceable: inbound_wallet_transaction) + create(:payment, payable: invoice, amount_cents: 900, status: :succeeded, payable_payment_status: :succeeded) + end + + context "when wallet is not traceable" do + it "returns the minimum of refundable amount and wallet balance" do + expect(invoice.refundable_amount_cents).to eq(500) + end + end + + context "when wallet is traceable" do + let(:wallet) { create(:wallet, customer:, balance_cents: 500, traceable: true) } + + it "returns the minimum of refundable amount and inbound transaction remaining amount" do + expect(invoice.refundable_amount_cents).to eq(300) + end + + context "when remaining amount is higher than paid amount" do + let(:inbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000) + end + + it "returns the paid amount" do + expect(invoice.refundable_amount_cents).to eq(900) + end + end + + context "when inbound transaction has nil remaining amount" do + let(:inbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: nil) + end + + it "returns 0" do + expect(invoice.refundable_amount_cents).to eq(0) + end + end + end + + context "when wallet is terminated" do + let(:wallet) { create(:wallet, customer:, balance_cents: 500, traceable: true, status: :terminated) } + + it "returns 0" do + expect(invoice.refundable_amount_cents).to eq(0) + end + end + + context "when payment is pending" do + let(:invoice) { create(:invoice, :credit, version_number: 2, status: :finalized, payment_status: :pending, prepaid_credit_amount_cents: 200) } + + it "returns zero" do + expect(invoice.refundable_amount_cents).to eq(0) + end + end + end + end + + describe "#offset_amount_cents" do + let(:invoice) { create(:invoice) } + + it "returns 0 when no credit notes" do + expect(invoice.offset_amount_cents).to eq(0) + end + + it "sums offset amounts from finalized credit notes" do + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 300) + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 150) + expect(invoice.offset_amount_cents).to eq(450) + end + + it "excludes draft credit notes" do + create(:credit_note, invoice:, status: :draft, offset_amount_cents: 200) + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 400) + expect(invoice.offset_amount_cents).to eq(400) + end + end + + describe "#total_due_amount_cents" do + let(:invoice) { create(:invoice, total_amount_cents: 1000, total_paid_amount_cents: 300) } + + it "returns total minus paid amount" do + expect(invoice.total_due_amount_cents).to eq(700) + end + + it "returns 0 when invoice is voided" do + invoice.update!(status: :voided) + expect(invoice.total_due_amount_cents).to eq(0) + end + + it "deducts offset amount from total due" do + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 200) + expect(invoice.total_due_amount_cents).to eq(500) # 1000 - 300 - 200 + end + + it "returns 0 when fully settled" do + invoice.update!(total_paid_amount_cents: 600) + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 400) + expect(invoice.total_due_amount_cents).to eq(0) # 1000 - 600 - 400 + end + end + + describe "#total_settled_amount_cents" do + let(:invoice) { create(:invoice, total_amount_cents: 1000, total_paid_amount_cents: 600) } + + it "returns only the paid amount when no offsets" do + expect(invoice.total_settled_amount_cents).to eq(600) + end + + it "returns sum of paid and offset amounts" do + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 250) + expect(invoice.total_settled_amount_cents).to eq(850) # 600 + 250 + end + + it "returns full settlement amount when fully settled" do + invoice.update!(total_paid_amount_cents: 700) + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 300) + expect(invoice.total_settled_amount_cents).to eq(1000) # 700 + 300 + end + end + + describe "#offsettable_amount_cents" do + context "with credit invoices" do + it "returns full amount for pending payments" do + invoice = create(:invoice, invoice_type: :credit, payment_status: :pending, total_amount_cents: 1000) + expect(invoice.offsettable_amount_cents).to eq(1000) + end + + it "returns full amount for failed payments" do + invoice = create(:invoice, invoice_type: :credit, payment_status: :failed, total_amount_cents: 1000) + expect(invoice.offsettable_amount_cents).to eq(1000) + end + + it "returns 0 for succeeded payments when fully paid" do + invoice = create(:invoice, invoice_type: :credit, payment_status: :succeeded, + total_amount_cents: 1000, total_paid_amount_cents: 1000) + allow(invoice).to receive(:creditable_amount_cents).and_return(800) + expect(invoice.offsettable_amount_cents).to eq(0) # total_due is 0 + end + + it "returns 0 when failed but fully offsetted by credit note" do + invoice = create(:invoice, invoice_type: :credit, payment_status: :failed, + total_amount_cents: 1000, total_paid_amount_cents: 0) + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 1000) + expect(invoice.offsettable_amount_cents).to eq(0) + end + end + + context "with regular invoices" do + it "returns minimum of total_due and creditable amounts" do + invoice = create(:invoice, invoice_type: :subscription, total_amount_cents: 1000, total_paid_amount_cents: 300) + allow(invoice).to receive(:creditable_amount_cents).and_return(900) + expect(invoice.offsettable_amount_cents).to eq(700) # min(700, 900) + end + + it "returns creditable amount when less than total due" do + invoice = create(:invoice, invoice_type: :subscription, total_amount_cents: 1000, total_paid_amount_cents: 300) + allow(invoice).to receive(:creditable_amount_cents).and_return(500) + expect(invoice.offsettable_amount_cents).to eq(500) # min(700, 500) + end + + it "calculates based on remaining due after offsets" do + invoice = create(:invoice, invoice_type: :subscription, total_amount_cents: 1000, total_paid_amount_cents: 200) + create(:credit_note, invoice:, status: :finalized, offset_amount_cents: 300) + allow(invoice).to receive(:creditable_amount_cents).and_return(700) + # total_due = 1000 - 200 - 300 = 500, creditable = 700, min(500, 700) = 500 + expect(invoice.offsettable_amount_cents).to eq(500) + end + end + end + + describe "#prepaid_credit_fee" do + let(:invoice) { create(:invoice, :credit) } + let(:fee) { create(:fee, fee_type: :credit, invoice:) } + + before { fee } + + it "returns the first fee of the invoice" do + expect(invoice.prepaid_credit_fee).to eq(fee) + end + end + + describe "#associated_active_wallet" do + context "when invoice is not a credit" do + let(:wallet) { create(:wallet) } + + before { wallet } + + it "returns nil" do + expect(invoice.associated_active_wallet).to be_nil + end + end + + context "when customer has no active wallet" do + let(:invoice) { create :invoice, invoice_type: :credit } + let(:wallet) { create :wallet, status: :terminated } + let(:wallet_transaction) { create :wallet_transaction, wallet: wallet } + let(:fee) { create :fee, fee_type: :credit, invoice:, invoiceable: wallet_transaction } + + before { fee } + + it "returns nil" do + expect(invoice.associated_active_wallet).to be_nil + end + end + + context "when customer has an active wallet that is not associated with this invoice" do + let(:wallet) { create(:wallet, customer: invoice.customer) } + + it "returns nil" do + expect(invoice.associated_active_wallet).to be_nil + end + end + + context "when there is a wallet associated with this credit invoice" do + let(:wallet) { create(:wallet, customer: invoice.customer) } + let(:wallet_transaction) { create(:wallet_transaction, wallet: wallet) } + let(:fee) { create(:fee, fee_type: :credit, invoice:, invoiceable: wallet_transaction) } + let(:invoice) { create(:invoice, :credit, version_number: 2, status: :finalized, payment_status: :succeeded) } + + before { fee } + + it "returns the wallet" do + expect(invoice.associated_active_wallet).to eq(wallet) + end + + context "when wallet is not active" do + let(:wallet) { create(:wallet, customer: invoice.customer, status: :terminated) } + + it "returns nil" do + expect(invoice.associated_active_wallet).to be_nil + end + end + end + end + + describe "#all_charges_have_fees?" do + let(:invoice) { create(:invoice, :subscription, subscriptions: [subscription]) } + let(:plan) { create(:plan) } + let(:subscription) { create(:subscription, plan:) } + + let(:charge1) { create(:standard_charge, plan:) } + let(:charge2) { create(:standard_charge, plan:) } + + before do + create(:charge_fee, charge: charge1, invoice:) + charge2 + end + + it { expect(invoice).not_to be_all_charges_have_fees } + + context "when all charges have fees" do + before { create(:charge_fee, charge: charge2, invoice:) } + + it { expect(invoice).to be_all_charges_have_fees } + end + + context "with filters" do + let(:charge_filter) { create(:charge_filter, charge: charge1) } + + before do + create(:charge_fee, charge: charge2, invoice:) + charge_filter + end + + it { expect(invoice).not_to be_all_charges_have_fees } + + context "when filters have fees" do + before { create(:charge_fee, charge: charge1, charge_filter:, invoice:) } + + it { expect(invoice).to be_all_charges_have_fees } + end + end + end + + describe "#all_fixed_charges_have_fees?" do + let(:invoice) { create(:invoice, :subscription, subscriptions: [subscription]) } + let(:plan) { create(:plan) } + let(:subscription) { create(:subscription, plan:) } + let(:fixed_charge1) { create(:fixed_charge, plan:) } + let(:fixed_charge2) { create(:fixed_charge, plan:) } + + before do + create(:fixed_charge_fee, fixed_charge: fixed_charge1, invoice:) + fixed_charge2 + end + + it { expect(invoice).not_to be_all_fixed_charges_have_fees } + + context "when all fixed charges have fees" do + before do + create(:fixed_charge_fee, fixed_charge: fixed_charge2, invoice:) + end + + it { expect(invoice).to be_all_fixed_charges_have_fees } + end + + context "without subscription" do + let(:invoice) { create(:invoice) } + + it { expect(invoice).to be_all_fixed_charges_have_fees } + end + end + + describe "should_apply_provider_tax?" do + subject { invoice.should_apply_provider_tax? } + + let(:invoice) { create(:invoice, :subscription, subscriptions: [subscription]) } + let(:plan) { create(:plan) } + let(:subscription) { create(:subscription, plan:) } + let(:charge) { create(:standard_charge, plan:) } + + before { charge_fee } + + context "when there are fees with non zero amount" do + let(:charge_fee) { create(:charge_fee, charge:, invoice:, amount: 100) } + + it { expect(subject).to be true } + end + + context "when there are no fees" do + let(:charge_fee) { nil } + + it { expect(subject).to be false } + end + + context "when fees amount is zero with skip configuration" do + let(:charge_fee) { create(:charge_fee, charge:, invoice:, amount: 0) } + + before do + invoice.customer.update!(finalize_zero_amount_invoice: "skip") + end + + it { expect(subject).to be false } + end + + context "when fees amount is zero with finalize configuration" do + let(:charge_fee) { create(:charge_fee, charge:, invoice:, amount: 0) } + + before do + invoice.customer.update!(finalize_zero_amount_invoice: "finalize") + end + + it { expect(subject).to be true } + end + end + + describe "#allow_manual_payment?" do + subject { invoice.allow_manual_payment? } + + let(:invoice) { create(:invoice, status:) } + + context "when invoice is in statuses that allow manual payment" do + let(:status) { Invoice::MANUALLY_PAYABLE_INVOICE_STATUS.sample } + + it { expect(subject).to be true } + end + + context "when invoice is in statuses that do not allow manual payment" do + let(:status) { (Invoice::STATUS.keys - Invoice::MANUALLY_PAYABLE_INVOICE_STATUS).sample } + + it { expect(subject).to be false } + end + end + + describe "#expected_finalization_date" do + subject { invoice.expected_finalization_date } + + let(:invoice) { build(:invoice, issuing_date: Time.current, expected_finalization_date:) } + + context "when expected_finalization_date is present" do + let(:expected_finalization_date) { Time.current.to_date + 1.day } + + it "returns expected_finalization_date value" do + expect(subject).to eq(expected_finalization_date) + end + end + + context "when expected_finalization_date is blank" do + let(:expected_finalization_date) { nil } + + it "returns issuing_date value" do + expect(subject).to eq(invoice.issuing_date) + end + end + end +end diff --git a/spec/models/invoice_subscription_spec.rb b/spec/models/invoice_subscription_spec.rb new file mode 100644 index 0000000..7f48fe0 --- /dev/null +++ b/spec/models/invoice_subscription_spec.rb @@ -0,0 +1,739 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceSubscription do + subject(:invoice_subscription) do + create( + :invoice_subscription, + from_datetime:, + to_datetime:, + charges_from_datetime:, + charges_to_datetime:, + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + ) + end + + let(:invoice) { invoice_subscription.invoice } + let(:subscription) { invoice_subscription.subscription } + + let(:from_datetime) { "2022-01-01 00:00:00" } + let(:to_datetime) { "2022-01-31 23:59:59" } + let(:charges_from_datetime) { "2022-01-01 00:00:00" } + let(:charges_to_datetime) { "2022-01-31 23:59:59" } + let(:fixed_charges_from_datetime) { "2022-01-01 00:00:00" } + let(:fixed_charges_to_datetime) { "2022-01-31 23:59:59" } + + it { is_expected.to belong_to(:organization) } + + describe ".order_by_subscription_invoice_name" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + let(:plan_zebra) { create(:plan, organization:, name: "Zebra Plan", invoice_display_name: nil) } + let(:plan_alpha) { create(:plan, organization:, name: "Alpha Plan", invoice_display_name: nil) } + let(:plan_beta) { create(:plan, organization:, name: "Beta Plan", invoice_display_name: "Custom Beta") } + + let(:subscription_zebra) { create(:subscription, customer:, plan: plan_zebra, name: nil) } + let(:subscription_alpha) { create(:subscription, customer:, plan: plan_alpha, name: nil) } + let(:subscription_custom) { create(:subscription, customer:, plan: plan_beta, name: "AAA Custom Name") } + + before do + create(:invoice_subscription, invoice:, subscription: subscription_zebra) + create(:invoice_subscription, invoice:, subscription: subscription_alpha) + create(:invoice_subscription, invoice:, subscription: subscription_custom) + end + + it "orders by COALESCE(subscription.name, plan.invoice_display_name, plan.name) ASC" do + result = invoice.invoice_subscriptions.order_by_subscription_invoice_name + + expect(result.map { |is| is.subscription.invoice_name }).to eq([ + "AAA Custom Name", + "Alpha Plan", + "Zebra Plan" + ]) + end + + it "uses plan.invoice_display_name when subscription.name is nil" do + subscription_alpha.update!(name: nil) + plan_alpha.update!(invoice_display_name: "ZZZ Display Name") + + result = invoice.invoice_subscriptions.order_by_subscription_invoice_name + + # Alphabetical order: AAA < ZZZ < Zebra + expect(result.map { |is| is.subscription.invoice_name }).to eq([ + "AAA Custom Name", + "ZZZ Display Name", + "Zebra Plan" + ]) + end + end + + describe ".matching?" do + subject(:matching?) { described_class.matching?(subscription, boundaries) } + + let(:subscription) { create(:subscription, plan:) } + let(:plan) { create(:plan, interval: plan_interval, bill_charges_monthly:, bill_fixed_charges_monthly:) } + let(:plan_interval) { "monthly" } + let(:bill_charges_monthly) { nil } + let(:bill_fixed_charges_monthly) { nil } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: from_datetime.to_datetime, + to_datetime: to_datetime.to_datetime, + charges_from_datetime: charges_from_datetime.to_datetime, + charges_to_datetime: charges_to_datetime.to_datetime, + fixed_charges_from_datetime: fixed_charges_from_datetime.to_datetime, + fixed_charges_to_datetime: fixed_charges_to_datetime.to_datetime, + charges_duration: 1.month, + timestamp: Time.current + ) + end + + let(:base_from_datetime) { "2022-01-01 00:00:00" } + let(:base_to_datetime) { "2022-01-31 23:59:59" } + let(:base_charges_from_datetime) { "2022-01-01 00:00:00" } + let(:base_charges_to_datetime) { "2022-01-31 23:59:59" } + let(:base_fixed_charges_from_datetime) { "2022-01-01 00:00:00" } + let(:base_fixed_charges_to_datetime) { "2022-01-31 23:59:59" } + + let(:from_datetime) { base_from_datetime } + let(:to_datetime) { base_to_datetime } + let(:charges_from_datetime) { base_charges_from_datetime } + let(:charges_to_datetime) { base_charges_to_datetime } + let(:fixed_charges_from_datetime) { base_fixed_charges_from_datetime } + let(:fixed_charges_to_datetime) { base_fixed_charges_to_datetime } + + context "when there are matching invoice subscriptions" do + let(:invoice_subscription_recurring) { true } + + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: invoice_subscription_recurring + ) + end + + context "with recurring" do + it { is_expected.to eq(true) } + + context "when non-recurring records exist" do + let(:invoice_subscription_recurring) { false } + + it { is_expected.to eq(false) } + end + end + + context "with not recurring" do + subject(:matching?) { described_class.matching?(subscription, boundaries, recurring: false) } + + it { is_expected.to eq(true) } + + context "when non-recurring records exist" do + let(:invoice_subscription_recurring) { false } + + it { is_expected.to eq(true) } + end + end + end + + context "when there are no matching invoice subscriptions" do + context "when no records exist" do + it { is_expected.to eq(false) } + end + + context "when records exist but don't match boundaries" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + context "when from_datetime doesn't match" do + let(:from_datetime) { "2022-02-01 00:00:00" } + + it { is_expected.to eq(false) } + end + + context "when to_datetime doesn't match" do + let(:to_datetime) { "2022-02-28 23:59:59" } + + it { is_expected.to eq(false) } + end + + context "when subscription_id doesn't match" do + subject(:matching?) { described_class.matching?(different_subscription, boundaries) } + + let(:different_subscription) { create(:subscription, plan:) } + + it { is_expected.to eq(false) } + end + end + + context "when record exists but doesn't match charges boundaries" do + let(:charges_from_datetime) { "2022-02-01 00:00:00" } + let(:charges_to_datetime) { "2022-02-28 23:59:59" } + + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + it "ignores charges boundaries and returns true" do + expect(matching?).to be(true) + end + end + end + + context "with yearly plan that doesn't bill charges monthly" do + let(:plan_interval) { "yearly" } + let(:bill_charges_monthly) { false } + let(:charges_from_datetime) { "2022-02-01 00:00:00" } + let(:charges_to_datetime) { "2022-02-28 23:59:59" } + + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + it "ignores charges boundaries and returns true" do + expect(matching?).to be(true) + end + end + + context "with yearly plan that bills charges monthly" do + let(:plan_interval) { "yearly" } + let(:bill_charges_monthly) { true } + + context "when charges boundaries match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + it { is_expected.to eq(true) } + end + + context "when charges boundaries don't match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + context "when charges_from_datetime doesn't match" do + let(:charges_from_datetime) { "2022-02-01 00:00:00" } + + it { is_expected.to eq(false) } + end + + context "when charges_to_datetime doesn't match" do + let(:charges_to_datetime) { "2022-02-28 23:59:59" } + + it { is_expected.to eq(false) } + end + end + end + + context "with yearly plan that bills fixed charges monthly" do + let(:plan_interval) { "yearly" } + let(:bill_fixed_charges_monthly) { true } + + context "when fixed charges boundaries match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + it { is_expected.to eq(true) } + end + + context "when fixed charges boundaries don't match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + context "when fixed_charges_from_datetime doesn't match" do + let(:fixed_charges_from_datetime) { "2022-02-01 00:00:00" } + + it { is_expected.to eq(false) } + end + + context "when fixed_charges_to_datetime doesn't match" do + let(:fixed_charges_to_datetime) { "2022-02-28 23:59:59" } + + it { is_expected.to eq(false) } + end + end + end + + context "with semiannual plan that doesn't bill charges monthly" do + let(:plan_interval) { "semiannual" } + let(:bill_charges_monthly) { false } + let(:charges_from_datetime) { "2022-02-01 00:00:00" } + let(:charges_to_datetime) { "2022-02-28 23:59:59" } + + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + it "ignores charges boundaries and returns true" do + expect(matching?).to be(true) + end + end + + context "with semiannual plan that bills charges monthly" do + let(:plan_interval) { "semiannual" } + let(:bill_charges_monthly) { true } + + context "when charges boundaries match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + it { is_expected.to eq(true) } + end + + context "when charges boundaries don't match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + context "when charges_from_datetime doesn't match" do + let(:charges_from_datetime) { "2022-02-01 00:00:00" } + + it { is_expected.to eq(false) } + end + + context "when charges_to_datetime doesn't match" do + let(:charges_to_datetime) { "2022-02-28 23:59:59" } + + it { is_expected.to eq(false) } + end + end + end + + context "with semiannual plan that bills fixed charges monthly" do + let(:plan_interval) { "semiannual" } + let(:bill_fixed_charges_monthly) { true } + + context "when charges boundaries match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + it { is_expected.to eq(true) } + end + + context "when charges boundaries don't match" do + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + context "when charges_from_datetime doesn't match" do + let(:fixed_charges_from_datetime) { "2022-02-01 00:00:00" } + + it { is_expected.to eq(false) } + end + + context "when charges_to_datetime doesn't match" do + let(:fixed_charges_to_datetime) { "2022-02-28 23:59:59" } + + it { is_expected.to eq(false) } + end + end + end + + context "with non-yearly plans" do + let(:charges_from_datetime) { "2022-02-01 00:00:00" } + let(:charges_to_datetime) { "2022-02-28 23:59:59" } + + before do + create( + :invoice_subscription, + subscription:, + from_datetime: base_from_datetime, + to_datetime: base_to_datetime, + charges_from_datetime: base_charges_from_datetime, + charges_to_datetime: base_charges_to_datetime, + fixed_charges_from_datetime: base_fixed_charges_from_datetime, + fixed_charges_to_datetime: base_fixed_charges_to_datetime, + recurring: true + ) + end + + context "with monthly plan" do + let(:plan_interval) { "monthly" } + + it "ignores charges boundaries and returns true" do + expect(matching?).to be(true) + end + end + + context "with quarterly plan" do + let(:plan_interval) { "quarterly" } + + it "ignores charges boundaries and returns true" do + expect(matching?).to be(true) + end + end + + context "with weekly plan" do + let(:plan_interval) { "weekly" } + + it "ignores charges boundaries and returns true" do + expect(matching?).to be(true) + end + end + end + end + + describe "#fees" do + it "returns corresponding fees" do + first_fee = create(:fee, subscription_id: subscription.id, invoice_id: invoice.id) + create(:fee, subscription_id: subscription.id) + create(:fee, invoice_id: invoice.id) + + expect(invoice_subscription.fees).to eq([first_fee]) + end + end + + describe "#charge_amount_cents" do + it "returns the sum of the related charge fees" do + charge = create(:standard_charge) + create( + :charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + charge:, + amount_cents: 100 + ) + + create( + :charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + charge:, + amount_cents: 200 + ) + + create( + :fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + amount_cents: 400 + ) + + expect(invoice_subscription.charge_amount_cents).to eq(300) + end + end + + describe "#fixed_charge_amount_cents" do + before do + create( + :fixed_charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + amount_cents: 100 + ) + + create( + :fixed_charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + amount_cents: 200 + ) + + create( + :charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + amount_cents: 400 + ) + end + + it "returns the sum of the related fixed charge fees" do + expect(invoice_subscription.fixed_charge_amount_cents).to eq(300) + end + end + + describe "#subscription_amount_cents" do + it "returns the amount of the subscription fees" do + create( + :fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + amount_cents: 50 + ) + + create( + :charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + charge: create(:standard_charge), + amount_cents: 200 + ) + + expect(invoice_subscription.subscription_amount_cents).to eq(50) + end + end + + describe "#total_amount_cents" do + it "returns the sum of the related fees" do + charge = create(:standard_charge) + create( + :fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + amount_cents: 50 + ) + + create( + :charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + charge:, + amount_cents: 200 + ) + + create( + :charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + charge:, + amount_cents: 100 + ) + + create( + :fixed_charge_fee, + subscription_id: subscription.id, + invoice_id: invoice.id, + amount_cents: 25 + ) + + expect(invoice_subscription.total_amount_cents).to eq(375) + end + end + + describe "#total_amount_currency" do + it "returns the currency of the total amount" do + expect(invoice_subscription.total_amount_currency).to eq(subscription.plan.amount_currency) + end + end + + describe "#charge_amount_currency" do + it "returns the currency of the charge amount" do + expect(invoice_subscription.charge_amount_currency).to eq(subscription.plan.amount_currency) + end + end + + describe "#fixed_charge_amount_currency" do + it "returns the currency of the fixed charge amount" do + expect(invoice_subscription.fixed_charge_amount_currency).to eq(subscription.plan.amount_currency) + end + end + + describe "#subscription_amount_currency" do + it "returns the currency of the subscription amount" do + expect(invoice_subscription.subscription_amount_currency).to eq(subscription.plan.amount_currency) + end + end + + describe "#previous_invoice_subscription" do + subject(:previous_invoice_subscription_call) { invoice_subscription.previous_invoice_subscription } + + context "when it has previous invoice subscription" do + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription: invoice_subscription.subscription, + from_datetime: invoice_subscription.from_datetime - 1.year, + to_datetime: invoice_subscription.to_datetime - 1.year, + charges_from_datetime: invoice_subscription.charges_from_datetime - 1.year, + charges_to_datetime: invoice_subscription.charges_to_datetime - 1.year + ) + end + + before do + previous_invoice_subscription + + create( + :fee, + subscription: previous_invoice_subscription.subscription, + invoice: previous_invoice_subscription.invoice, + amount_cents: 857 # prorated + ) + end + + it "returns previous invoice subscription" do + expect(previous_invoice_subscription_call).to eq(previous_invoice_subscription) + end + end + + context "when there is a previous invoice subscription for different subscription" do + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + from_datetime: invoice_subscription.from_datetime - 1.year, + to_datetime: invoice_subscription.to_datetime - 1.year, + charges_from_datetime: invoice_subscription.charges_from_datetime - 1.year, + charges_to_datetime: invoice_subscription.charges_to_datetime - 1.year + ) + end + + before do + previous_invoice_subscription + + create( + :invoice_subscription, + from_datetime: invoice_subscription.from_datetime - 2.years, + to_datetime: invoice_subscription.to_datetime - 2.years, + charges_from_datetime: invoice_subscription.charges_from_datetime - 2.years, + charges_to_datetime: invoice_subscription.charges_to_datetime - 2.years + ) + end + + it "returns nil" do + expect(previous_invoice_subscription_call).to be(nil) + end + end + + context "when it has no previous invoice subscription" do + before do + create( + :invoice_subscription, + from_datetime: invoice_subscription.from_datetime + 1.year, + to_datetime: invoice_subscription.to_datetime + 1.year, + charges_from_datetime: invoice_subscription.charges_from_datetime + 1.year, + charges_to_datetime: invoice_subscription.charges_to_datetime + 1.year + ) + end + + it "returns nil" do + expect(previous_invoice_subscription_call).to be(nil) + end + end + end +end diff --git a/spec/models/lifetime_usage_spec.rb b/spec/models/lifetime_usage_spec.rb new file mode 100644 index 0000000..52b85f7 --- /dev/null +++ b/spec/models/lifetime_usage_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsage do + subject(:lifetime_usage) { create(:lifetime_usage) } + + it { is_expected.to belong_to(:organization) } + + describe "default scope" do + let!(:deleted_lifetime_usage) { create(:lifetime_usage, :deleted) } + + it "only returns non-deleted lifetime-usage objects" do + expect(described_class.all).to eq([lifetime_usage]) + expect(described_class.unscoped.discarded).to eq([deleted_lifetime_usage]) + end + end + + describe "Validations" do + it "requires that current_usage_amount_cents is postive" do + lifetime_usage.current_usage_amount_cents = -1 + expect(lifetime_usage).not_to be_valid + + lifetime_usage.current_usage_amount_cents = 1 + expect(lifetime_usage).to be_valid + end + + it "requires that invoiced_usage_amount_cents is postive" do + lifetime_usage.invoiced_usage_amount_cents = -1 + expect(lifetime_usage).not_to be_valid + + lifetime_usage.invoiced_usage_amount_cents = 1 + expect(lifetime_usage).to be_valid + end + + it "requires that historical_usage_amount_cents is positive" do + lifetime_usage.historical_usage_amount_cents = -1 + expect(lifetime_usage).not_to be_valid + + lifetime_usage.historical_usage_amount_cents = 0 + expect(lifetime_usage).to be_valid + + lifetime_usage.historical_usage_amount_cents = 1 + expect(lifetime_usage).to be_valid + end + end + + describe "#total_amount_cents" do + it "returns the sum of the historical, invoiced, and current usage" do + lifetime_usage.historical_usage_amount_cents = 100 + lifetime_usage.invoiced_usage_amount_cents = 200 + lifetime_usage.current_usage_amount_cents = 300 + + expect(lifetime_usage.total_amount_cents).to eq(600) + end + end +end diff --git a/spec/models/membership_role_spec.rb b/spec/models/membership_role_spec.rb new file mode 100644 index 0000000..6fe9592 --- /dev/null +++ b/spec/models/membership_role_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MembershipRole do + subject(:membership_role) { build(:membership_role) } + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:membership) + expect(subject).to belong_to(:role) + end + end + + describe "scopes" do + describe ".admins" do + it "returns only admin member roles" do + membership_role = create(:membership_role) + admin_role_id = SecureRandom.uuid + + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, admin, permissions, created_at, updated_at) + VALUES ('#{admin_role_id}', 'test_admin', 'TestAdmin', true, ARRAY[]::text[], now(), now()) + SQL + + admin_membership_role_id = SecureRandom.uuid + described_class.connection.execute(<<~SQL) + INSERT INTO membership_roles (id, organization_id, membership_id, role_id, created_at, updated_at) + VALUES ( + '#{admin_membership_role_id}', + '#{membership_role.organization_id}', + '#{membership_role.membership_id}', + '#{admin_role_id}', + now(), + now() + ) + SQL + + expect(described_class.admins.pluck(:id)).to eq([admin_membership_role_id]) + end + end + end + + describe "validations" do + it "forbids discarding the last admin role in organization" do + membership = create(:membership) + admin_role_id = SecureRandom.uuid + + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, admin, permissions, created_at, updated_at) + VALUES ('#{admin_role_id}', 'test_admin_#{admin_role_id[0..7]}', 'TestAdmin', true, ARRAY[]::text[], now(), now()) + SQL + + membership_role_id = SecureRandom.uuid + described_class.connection.execute(<<~SQL) + INSERT INTO membership_roles (id, organization_id, membership_id, role_id, created_at, updated_at) + VALUES ( + '#{membership_role_id}', + '#{membership.organization_id}', + '#{membership.id}', + '#{admin_role_id}', + now(), + now() + ) + SQL + + membership_role = described_class.find(membership_role_id) + + expect(membership_role.discard).to be(false) + end + + it "allows discarding admin role when another admin exists" do + membership = create(:membership) + organization = membership.organization + other_membership = create(:membership, organization:) + custom_role = create(:role, organization:) + + membership_role = create(:membership_role, :admin, membership:) + create(:membership_role, membership:, role: custom_role) + create(:membership_role, :admin, membership: other_membership) + + expect(membership_role.discard).to be(true) + end + + it "forbids discarding last admin role when other admins have revoked membership" do + membership = create(:membership) + organization = membership.organization + revoked_membership = create(:membership, :revoked, organization:) + custom_role = create(:role, organization:) + + membership_role = create(:membership_role, :admin, membership:) + create(:membership_role, membership:, role: custom_role) + create(:membership_role, :admin, membership: revoked_membership) + + expect(membership_role.discard).to be(false) + end + + it "forbids discarding the last role of membership" do + membership_role = create(:membership_role) + + expect(membership_role.discard).to be(false) + end + + it "allows discarding role when membership has another role" do + membership_role = create(:membership_role) + other_role = create(:role, organization: membership_role.organization) + create(:membership_role, membership: membership_role.membership, role: other_role) + + expect(membership_role.discard).to be(true) + end + + it "rejects role from different organization" do + membership_role = build(:membership_role, role: create(:role)) + + expect(membership_role).not_to be_valid + expect(membership_role.errors[:role]).to include("invalid_value") + end + + it "allows predefined role (without organization)" do + membership_role = build(:membership_role, role: create(:role, :admin)) + + expect(membership_role).to be_valid + end + + it "forbids any modification except discard" do + membership_role = create(:membership_role) + membership_role.role = create(:role, organization: membership_role.organization) + + expect(membership_role).not_to be_valid + expect(membership_role.errors[:base]).to include("modification_not_allowed") + end + end +end diff --git a/spec/models/membership_spec.rb b/spec/models/membership_spec.rb new file mode 100644 index 0000000..2888093 --- /dev/null +++ b/spec/models/membership_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Membership do + subject(:membership) { create(:membership) } + + it { is_expected.to have_many(:data_exports) } + it { is_expected.to have_many(:membership_roles) } + it { is_expected.to have_many(:roles).through(:membership_roles) } + + it_behaves_like "paper_trail traceable" + + describe ".admins" do + let(:organization) { create(:organization) } + let(:admin_membership) { create(:membership, organization:) } + let(:finance_membership) { create(:membership, organization:) } + + before do + create(:membership_role, membership: admin_membership, role: create(:role, :admin)) + create(:membership_role, membership: finance_membership, role: create(:role, :finance)) + end + + it "returns only memberships with admin roles" do + expect(described_class.admins).to eq([admin_membership]) + end + end + + describe "#admin?" do + it "returns true when membership has admin role" do + create(:membership_role, membership:, role: create(:role, :admin)) + expect(membership.admin?).to be true + end + + it "returns false when membership has no admin role" do + create(:membership_role, membership:, role: create(:role, :finance)) + expect(membership.admin?).to be false + end + + it "returns false when membership has no roles" do + expect(membership.admin?).to be false + end + end + + describe "#mark_as_revoked" do + it "revokes the membership with a Time" do + freeze_time do + expect { membership.mark_as_revoked! } + .to change { membership.reload.status }.from("active").to("revoked") + .and change(membership, :revoked_at).from(nil).to(Time.current) + end + end + end + + describe "#permissions_hash" do + subject(:permissions_hash) { membership.permissions_hash } + + context "with admin role" do + let(:membership) { create(:membership, roles: %i[admin]) } + + it "includes all existing permissions" do + expect(permissions_hash.keys).to match_array(Permission.permissions_hash.keys) + end + + it "returns all permissions as true" do + expect(permissions_hash.values).to all(be true) + end + end + + context "with finance role" do + let(:membership) { create(:membership, roles: %i[finance]) } + + it "includes all existing permissions" do + expect(permissions_hash.keys).to match_array(Permission.permissions_hash.keys) + end + + it "returns true for finance-specific permissions" do + expect(permissions_hash).to include("analytics:view" => true) + end + + it "returns false for non-finance permissions" do + expect(permissions_hash).to include("coupons:attach" => false) + end + end + + context "with manager role" do + let(:membership) { create(:membership, roles: %i[manager]) } + + it "includes all existing permissions" do + expect(permissions_hash.keys).to match_array(Permission.permissions_hash.keys) + end + + it "returns true for manager-specific permissions" do + expect(permissions_hash).to include("coupons:attach" => true) + end + + it "returns false for non-manager permissions" do + expect(permissions_hash).to include("pricing_units:view" => false) + end + end + + context "with custom role" do + let(:organization) { create(:organization) } + let(:role) { create(:role, :custom, organization:, permissions: %w[foo addons:view]) } + let(:membership) { create(:membership, organization:, role:) } + + it "includes all existing permissions" do + expect(permissions_hash.keys).to match_array(Permission.permissions_hash.keys) + end + + it "excludes non-existing permissions" do + expect(permissions_hash).not_to be_key("foo") + end + + it "returns true for custom permissions" do + expect(permissions_hash).to include("addons:view" => true) + end + + it "returns false for other permissions" do + expect(permissions_hash.except("addons:view").values).to all(be false) + end + end + + context "with mixed roles" do + let(:organization) { create(:organization) } + let(:role) { create(:role, :custom, organization:, permissions: %w[foo addons:view]) } + let(:membership) { create(:membership, organization:, role:, roles: %i[finance]) } + + it "includes all existing permissions" do + expect(permissions_hash.keys).to match_array(Permission.permissions_hash.keys) + end + + it "excludes non-existing permissions" do + expect(permissions_hash).not_to be_key("foo") + end + + it "returns true for custom permissions" do + expect(permissions_hash).to include("addons:view" => true) + end + + it "returns true for predefined permissions" do + expect(permissions_hash).to include("analytics:view" => true) + end + + it "returns false for disabled permissions" do + expect(permissions_hash).to include("coupons:attach" => false) + end + end + end +end diff --git a/spec/models/metadata/customer_metadata_spec.rb b/spec/models/metadata/customer_metadata_spec.rb new file mode 100644 index 0000000..56533a2 --- /dev/null +++ b/spec/models/metadata/customer_metadata_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Metadata::CustomerMetadata do + subject(:metadata) { described_class.new(attributes) } + + let(:customer) { create(:customer) } + let(:key) { "hello" } + let(:value) { "abcdef" } + let(:attributes) do + {key:, value:, customer:, display_in_invoice: true, organization: customer.organization} + end + + it { is_expected.to belong_to(:organization) } + + describe "key validations" do + context "when uniqueness condition is satisfied", :tag do + it { expect(metadata).to be_valid } + end + + context "when key is not unique" do + let(:old_metadata) { create(:customer_metadata, customer:, key: "hello") } + + before { old_metadata } + + it { expect(metadata).not_to be_valid } + end + + context "when key length is invalid" do + let(:key) { "hello-hello-hello-hello-hello" } + + it { expect(metadata).not_to be_valid } + end + end + + describe "value validations" do + context "when length constraint is satisfied", :tag do + it { expect(metadata).to be_valid } + end + + context "when value length is invalid" do + let(:value) { "a" * 101 } + + it { expect(metadata).not_to be_valid } + end + end +end diff --git a/spec/models/metadata/invoice_metadata_spec.rb b/spec/models/metadata/invoice_metadata_spec.rb new file mode 100644 index 0000000..654796b --- /dev/null +++ b/spec/models/metadata/invoice_metadata_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Metadata::InvoiceMetadata do + subject(:metadata) { described_class.new(attributes) } + + let(:invoice) { create(:invoice) } + let(:key) { "hello" } + let(:value) { "abcdef" } + let(:attributes) do + {key:, value:, invoice:, organization: invoice.organization} + end + + describe "associations" do + it do + expect(subject).to belong_to(:invoice).touch(true) + expect(subject).to belong_to(:organization) + end + end + + describe "validations" do + context "when uniqueness condition is satisfied" do + it { expect(metadata).to be_valid } + end + + context "when key is not unique" do + let(:old_metadata) { create(:invoice_metadata, invoice:, key: "hello") } + + before { old_metadata } + + it { expect(metadata).not_to be_valid } + end + + context "when key length is invalid" do + let(:key) { "hello-hello-hello-hello-hello" } + + it { expect(metadata).not_to be_valid } + end + end +end diff --git a/spec/models/metadata/item_metadata_spec.rb b/spec/models/metadata/item_metadata_spec.rb new file mode 100644 index 0000000..fe17c6a --- /dev/null +++ b/spec/models/metadata/item_metadata_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Metadata::ItemMetadata do + subject(:item_metadata) { described_class.new(organization:, owner:, value:) } + + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, organization:) } + let(:customer) { invoice.customer } + let(:owner) { create(:credit_note, invoice:, customer:, organization:) } + let(:value) { {"key1" => "value1"} } + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:owner) } + + describe "validations" do + describe "of value not being nil" do + context "when value is nil" do + let(:value) { nil } + + it "adds an error" do + expect(item_metadata).not_to be_valid + expect(item_metadata.errors[:value]).to be_present + end + end + + context "when value is an empty hash" do + let(:value) { {} } + + it "is valid" do + expect(item_metadata).to be_valid + end + end + end + + describe "of owner uniqueness" do + context "when owner is nil" do + let(:owner) { nil } + + it "adds an error" do + expect(item_metadata).not_to be_valid + expect(item_metadata.errors[:owner]).to be_present + end + end + + context "when owner is already taken" do + before { described_class.create!(organization:, owner:, value:) } + + it "is valid at app level but raises database error on save" do + expect(item_metadata).to be_valid + expect { item_metadata.save! }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + end + + describe "of value correctness" do + context "when value is valid" do + let(:value) { {"key1" => "value1", "key2" => "value2"} } + + it { expect(item_metadata).to be_valid } + end + + context "when value is not a Hash" do + let(:value) { "not a hash" } + + it "adds an error" do + expect(item_metadata).not_to be_valid + expect(item_metadata.errors[:value]).to include("must be a Hash") + end + end + + context "when value has more than 50 keys" do + let(:value) { 51.times.to_h { |i| ["key#{i}", "value#{i}"] } } + + it "adds an error" do + expect(item_metadata).not_to be_valid + expect(item_metadata.errors[:value]).to include("cannot have more than 50 keys") + end + end + + context "when key is empty" do + let(:value) { {"" => "value"} } + + it "is valid" do + expect(item_metadata).to be_valid + end + end + + context "when key length is more than 100" do + let(:value) { {("a" * 101) => "value"} } + + it "adds an error" do + key = "a" * 101 + expect(item_metadata).not_to be_valid + expect(item_metadata.errors[:value]).to include("key '#{key}' must be a String up to 100 characters") + end + end + + context "when value is nil" do + let(:value) { {"foo" => nil} } + + it "is valid" do + expect(item_metadata).to be_valid + end + end + + context "when value is not a String" do + let(:value) { {"foo" => 123} } + + it "adds an error" do + expect(item_metadata).not_to be_valid + expect(item_metadata.errors[:value].join).to include("value for key 'foo' must be empty or a String up to 255 characters") + end + end + + context "when value length is less than 1" do + let(:value) { {"foo" => ""} } + + it "is valid" do + expect(item_metadata).to be_valid + end + end + + context "when value length is more than 255" do + let(:value) { {"foo" => "a" * 256} } + + it "adds an error" do + expect(item_metadata).not_to be_valid + expect(item_metadata.errors[:value].join).to include("value for key 'foo' must be empty or a String up to 255 characters") + end + end + end + end + + describe "database constraints" do + describe "NOT NULL constraints" do + it "enforces organization_id presence" do + item_metadata.organization_id = nil + expect { item_metadata.save!(validate: false) } + .to raise_error(ActiveRecord::NotNullViolation) + end + + it "enforces owner_type presence" do + item_metadata.owner_type = nil + expect { item_metadata.save!(validate: false) } + .to raise_error(ActiveRecord::NotNullViolation) + end + + it "enforces owner_id presence" do + item_metadata.owner_id = nil + expect { item_metadata.save!(validate: false) } + .to raise_error(ActiveRecord::NotNullViolation) + end + + it "enforces value presence" do + item_metadata.value = nil + expect { item_metadata.save!(validate: false) } + .to raise_error(ActiveRecord::NotNullViolation) + end + end + + describe "uniqueness constraint on owner" do + it "prevents duplicate owner_type and owner_id combination" do + described_class.create!(organization:, owner:, value:) + + new_item = described_class.new(organization:, owner:, value: {"key2" => "value2"}) + expect { new_item.save!(validate: false) } + .to raise_error(ActiveRecord::RecordNotUnique) + end + end + + describe "value must be JSON object constraint" do + it "prevents non-object JSON values" do + expect do + described_class.connection.execute(<<~SQL.squish) + INSERT INTO item_metadata ( + id, organization_id, owner_type, owner_id, value, created_at, updated_at + ) VALUES ( + '#{SecureRandom.uuid}', + '#{organization.id}', + '#{owner.class.name}', + '#{owner.id}', + '[]', + NOW(), + NOW() + ) + SQL + end.to raise_error(ActiveRecord::StatementInvalid) + end + end + end +end diff --git a/spec/models/organization_spec.rb b/spec/models/organization_spec.rb new file mode 100644 index 0000000..b5c7519 --- /dev/null +++ b/spec/models/organization_spec.rb @@ -0,0 +1,606 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Organization do + subject(:organization) do + described_class.new( + name: "PiedPiper", + email: "foo@bar.com", + country: "FR", + invoice_footer: "this is an invoice footer" + ) + end + + describe "associations" do + it do + expect(subject).to have_many(:stripe_payment_providers) + expect(subject).to have_many(:gocardless_payment_providers) + expect(subject).to have_many(:adyen_payment_providers) + + expect(subject).to have_many(:api_keys) + expect(subject).to have_many(:billing_entities).conditions(archived_at: nil) + expect(subject).to have_many(:all_billing_entities).class_name("BillingEntity") + expect(subject).to have_many(:pricing_units) + expect(subject).to have_many(:customers) + expect(subject).to have_many(:subscriptions) + expect(subject).to have_many(:activation_rules).class_name("Subscription::ActivationRule") + expect(subject).to have_many(:credit_notes) + expect(subject).to have_many(:invoices) + expect(subject).to have_many(:fees) + expect(subject).to have_many(:applied_coupons) + expect(subject).to have_many(:wallets) + expect(subject).to have_many(:wallet_transactions) + expect(subject).to have_one(:default_billing_entity).class_name("BillingEntity") + expect(subject).to have_many(:webhook_endpoints) + expect(subject).to have_many(:webhooks) + expect(subject).to have_many(:hubspot_integrations) + expect(subject).to have_many(:netsuite_integrations) + expect(subject).to have_many(:xero_integrations) + expect(subject).to have_one(:salesforce_integration) + expect(subject).to have_many(:data_exports) + expect(subject).to have_many(:dunning_campaigns) + expect(subject).to have_many(:daily_usages) + expect(subject).to have_many(:invoice_custom_sections) + expect(subject).to have_many(:ai_conversations) + expect(subject).to have_many(:manual_invoice_custom_sections).conditions(section_type: "manual") + expect(subject).to have_many(:payment_methods) + expect(subject).to have_many(:system_generated_invoice_custom_sections).conditions(section_type: "system_generated") + + expect(subject).to have_many(:features).class_name("Entitlement::Feature") + expect(subject).to have_many(:privileges).class_name("Entitlement::Privilege") + expect(subject).to have_many(:entitlements).class_name("Entitlement::Entitlement") + expect(subject).to have_many(:entitlement_values).class_name("Entitlement::EntitlementValue") + expect(subject).to have_many(:subscription_feature_removals).class_name("Entitlement::SubscriptionFeatureRemoval") + + expect(subject).to have_one(:applied_dunning_campaign).conditions(applied_to_organization: true) + expect(subject).to have_one(:enriched_store_migration) + expect(subject).to have_many(:pending_vies_checks) + end + end + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + it "sets the default value to true" do + expect(organization.finalize_zero_amount_invoice).to eq true + end + + it_behaves_like "paper_trail traceable" + + describe "Validations" do + it do + expect(subject).to validate_inclusion_of(:default_currency).in_array(described_class.currency_list) + end + + it "is valid with valid attributes" do + expect(organization).to be_valid + end + + it "is not valid without name" do + organization.name = nil + + expect(organization).not_to be_valid + end + + it "is invalid with invalid email" do + organization.email = "foo.bar" + + expect(organization).not_to be_valid + end + + it "is invalid with invalid country" do + organization.country = "ZWX" + + expect(organization).not_to be_valid + + organization.country = "" + + expect(organization).not_to be_valid + end + + it "validates the language code" do + organization.document_locale = nil + expect(organization).not_to be_valid + + organization.document_locale = "en" + expect(organization).to be_valid + + organization.document_locale = "foo" + expect(organization).not_to be_valid + + organization.document_locale = "" + expect(organization).not_to be_valid + end + + it "is invalid with invalid invoice footer" do + organization.invoice_footer = SecureRandom.alphanumeric(601) + + expect(organization).not_to be_valid + end + + it "is valid with logo" do + organization.logo.attach( + io: File.open(Rails.root.join("spec/factories/images/logo.png")), + content_type: "image/png", + filename: "logo" + ) + + expect(organization).to be_valid + end + + it "is invalid with too big logo" do + organization.logo.attach( + io: File.open(Rails.root.join("spec/factories/images/big_sized_logo.jpg")), + content_type: "image/jpeg", + filename: "logo" + ) + + expect(organization).not_to be_valid + end + + it "is invalid with unsupported logo content type" do + organization.logo.attach( + io: File.open(Rails.root.join("spec/factories/images/logo.gif")), + content_type: "image/gif", + filename: "logo" + ) + + expect(organization).not_to be_valid + end + + it "is invalid with invalid timezone" do + organization.timezone = "foo" + + expect(organization).not_to be_valid + end + + it "is valid with email_settings" do + organization.email_settings = ["invoice.finalized", "credit_note.created", "payment_receipt.created"] + + expect(organization).to be_valid + end + + it "is invalid with non permitted email_settings value" do + organization.email_settings = ["email.not_permitted"] + + expect(organization).not_to be_valid + expect(organization.errors.first.attribute).to eq(:email_settings) + expect(organization.errors.first.type).to eq(:unsupported_value) + end + + it "dont allow finalize_zero_amount_invoice with null value" do + expect(organization.finalize_zero_amount_invoice).to eq true + organization.finalize_zero_amount_invoice = nil + + expect(organization).not_to be_valid + end + + describe "of hmac key uniqueness" do + before { create(:organization) } + + it { is_expected.to validate_uniqueness_of(:hmac_key) } + end + + describe "of hmac key presence" do + subject { organization } + + context "with a new record" do + let(:organization) { build(:organization) } + + it { is_expected.not_to validate_presence_of(:hmac_key) } + end + + context "with a persisted record" do + let(:organization) { create(:organization) } + + it { is_expected.to validate_presence_of(:hmac_key) } + end + end + + describe "of slug" do + it "validates length, format, and exclusion" do + organization.slug = "ab" + expect(organization).not_to be_valid + + organization.slug = "a" * 41 + expect(organization).not_to be_valid + + organization.slug = "-invalid" + expect(organization).not_to be_valid + + organization.slug = "customers" + expect(organization).not_to be_valid + + organization.slug = "valid-slug" + expect(organization).to be_valid + end + + it "validates presence on persisted record" do + persisted_org = create(:organization) + persisted_org.slug = nil + expect(persisted_org).not_to be_valid + end + end + + describe "of premium_integrations inclusion" do + context "when it includes an invalid integration" do + subject(:organization) { build(:organization, premium_integrations: ["invalid_integration"]) } + + it do + expect(subject).not_to be_valid + expect(organization.errors.to_hash).to eq(premium_integrations: ["value_is_invalid"]) + end + end + + context "when it includes a valid integration" do + subject(:organization) { build(:organization, :premium) } + + it { is_expected.to be_valid } + end + end + end + + describe "#save" do + subject { organization.save! } + + context "with a new record" do + let(:organization) { build(:organization) } + let(:used_hmac_key) { create(:organization).hmac_key } + let(:unique_hmac_key) { SecureRandom.uuid } + + before do + allow(SecureRandom).to receive(:uuid).and_return(used_hmac_key, unique_hmac_key) + end + + it "sets document number prefix of organization" do + subject + + expect(organization.document_number_prefix) + .to eq "#{organization.name.first(3).upcase}-#{organization.id.last(4).upcase}" + end + + it "sets unique hmac key" do + expect { subject }.to change(organization, :hmac_key).to unique_hmac_key + end + + it "auto-generates slug from name when not provided" do + org = build(:organization, name: "Acme Corporation", slug: nil) + org.save! + expect(org.slug).to eq("acme-corporation") + end + + it "keeps provided slug when present" do + org = build(:organization, name: "Acme Corporation", slug: "custom-slug") + org.save! + expect(org.slug).to eq("custom-slug") + end + end + + context "with a persisted record" do + let(:organization) { create(:organization) } + + it "does not change document number prefix of organization" do + expect { subject }.not_to change(organization, :document_number_prefix) + end + + it "does not change the hmac key" do + expect { subject }.not_to change(organization, :hmac_key) + end + end + end + + describe ".with_any_premium_integrations" do + it do + create(:organization, premium_integrations: %w[okta xero from_email]) + create(:organization, premium_integrations: %w[okta]) + create(:organization, premium_integrations: %w[salesforce from_email]) + create(:organization, premium_integrations: %w[salesforce]) + + expect(described_class.with_any_premium_integrations([]).count).to eq(0) + expect(described_class.with_any_premium_integrations("okta").count).to eq(2) + expect(described_class.with_any_premium_integrations(%w[okta from_email]).count).to eq(3) + expect(described_class.with_any_premium_integrations(%w[okta salesforce]).count).to eq(4) + end + end + + describe "Premium integrations scopes" do + it "returns the organization if the premium integration is enabled" do + Organization::PREMIUM_INTEGRATIONS.each do |integration| + expect(described_class.send("with_#{integration}_support")).to be_empty + organization.update!(premium_integrations: [integration]) + expect(described_class.send("with_#{integration}_support")).to eq([organization]) + organization.update!(premium_integrations: []) + end + end + + it "does not return the organization for another premium integration" do + organization.update!(premium_integrations: ["progressive_billing"]) + expect(described_class.with_okta_support).to be_empty + expect(described_class.with_progressive_billing_support).to eq([organization]) + end + end + + describe "#premium_integrations_enabled?" do + described_class::PREMIUM_INTEGRATIONS.each do |integration| + it_behaves_like "organization premium feature", integration + end + end + + describe "#can_create_billing_entity?", :premium do + subject { organization.can_create_billing_entity? } + + context "when no premium multi entities integration is enabled" do + it { is_expected.to eq(true) } + + context "when organization has one active billing entity" do + before do + create(:billing_entity, organization:) + end + + it { is_expected.to eq(false) } + end + end + + context "when the premium multi_entities_pro integration is enabled" do + before do + organization.update!(premium_integrations: ["multi_entities_pro"]) + end + + it { is_expected.to eq(true) } + + context "when the organization has reached the limit" do + before do + create_list(:billing_entity, 2, organization:) + end + + it { is_expected.to eq(false) } + end + + context "when organization has archived billing entities" do + before do + create_list(:billing_entity, 2, :archived, organization:) + end + + it { is_expected.to eq true } + end + end + + context "when the premium multi_entities_enterprise integration is enabled" do + before do + organization.update!(premium_integrations: ["multi_entities_enterprise"]) + end + + it { is_expected.to eq(true) } + + context "when the organization has some billing entities" do + before do + create_list(:billing_entity, 2, organization:) + end + + it { is_expected.to eq(true) } + end + end + end + + describe "#using_lifetime_usage?", :premium do + it do + expect(build(:organization, premium_integrations: ["lifetime_usage"])).to be_using_lifetime_usage + expect(build(:organization, premium_integrations: ["progressive_billing"])).to be_using_lifetime_usage + expect(build(:organization, premium_integrations: ["lifetime_usage", "progressive_billing"])).to be_using_lifetime_usage + expect(build(:organization, premium_integrations: [])).not_to be_using_lifetime_usage + expect(build(:organization, premium_integrations: ["okta"])).not_to be_using_lifetime_usage + end + end + + describe "#admins" do + subject { organization.admins } + + let(:organization) { create(:organization) } + let(:admin_role) { create(:role, :admin) } + let(:finance_role) { create(:role, :finance) } + let(:admin_membership) { create(:membership, organization:) } + let(:admin_user) { admin_membership.user } + + before do + create(:membership_role, membership: admin_membership, role: admin_role) + create(:membership) + + non_admin = create(:membership, organization:) + create(:membership_role, membership: non_admin, role: finance_role) + + revoked_admin = create(:membership, :revoked, organization:) + create(:membership_role, membership: revoked_admin, role: admin_role) + end + + it "returns admins of the organization" do + expect(subject).to contain_exactly(admin_user) + end + end + + describe "#from_email_address" do + it "returns the env var email" do + expect(organization.from_email_address).to eq("noreply@getlago.com") + end + + context "when organization from_email integration is enabled", :premium do + it "returns the organization email" do + organization.update!(premium_integrations: ["from_email"]) + expect(organization.from_email_address).to eq(organization.email) + end + end + end + + describe "#default_billing_entity" do + subject(:default_billing_entity) { organization.default_billing_entity } + + let(:organization) { create(:organization, billing_entities: []) } + + context "when the organization has no billing entities" do + it { is_expected.to eq(nil) } + end + + context "when the organization has one billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + + before { billing_entity } + + it { is_expected.to eq(billing_entity) } + end + + context "when the organization has multiple billing entities" do + let(:billing_entity_1) { create(:billing_entity, organization:, created_at: 1.day.ago) } + let(:billing_entity_2) { create(:billing_entity, organization:, created_at: 2.days.ago) } + let(:billing_entity_3) { create(:billing_entity, organization:, created_at: 3.days.ago, archived_at: Time.current) } + + before do + billing_entity_1 + billing_entity_2 + billing_entity_3 + end + + it "returns the oldest active billing entity" do + expect(default_billing_entity).to eq(billing_entity_2) + end + end + end + + describe "#failed_tax_invoices_count" do + subject(:failed_tax_invoices_count) { organization.failed_tax_invoices_count } + + let(:organization) { create(:organization) } + let(:invoice1) { create(:invoice, organization:, status: :failed) } + let(:invoice2) { create(:invoice, organization:, status: :failed) } + let(:invoice3) { create(:invoice, organization:, status: :draft) } + let(:error_detail1) do + create( + :error_detail, + owner: invoice1, + organization:, + error_code: :tax_error, + details: { + tax_error: "productExternalIdUnknown" + } + ) + end + let(:error_detail2) do + create( + :error_detail, + owner: invoice2, + organization:, + error_code: :tax_error, + details: { + tax_error: "productExternalIdUnknown" + } + ) + end + + before do + invoice1 + invoice2 + invoice3 + error_detail1 + error_detail2 + end + + it "returns the count of failed tax invoices" do + expect(failed_tax_invoices_count).to eq(2) + end + end + + describe "default_currency" do + let(:organization) { create(:organization, default_currency: "USD") } + let(:billing_entity) { create(:billing_entity, organization:, default_currency: "EUR") } + + before do + organization.default_billing_entity.update(default_currency: "GBP") + billing_entity + end + + it "ignores existing value in organization and uses value from default_billing_entity" do + expect(organization.default_currency).to eq("GBP") + end + end + + describe "timezone" do + let(:organization) { create(:organization, timezone: "UTC") } + let(:billing_entity) { create(:billing_entity, organization:, timezone: "America/New_York") } + + before do + organization.default_billing_entity.update(timezone: "Europe/London") + billing_entity + end + + it "ignores existing value in organization and uses value from default_billing_entity" do + expect(organization.timezone).to eq("Europe/London") + end + end + + describe "postgres_events_store?" do + let(:organization) { create(:organization, clickhouse_events_store: true) } + + it "returns true if postgres_events_store is true" do + expect(organization).not_to be_postgres_events_store + expect(organization).to be_clickhouse_events_store + end + + context "when clickhouse_events_store is false" do + let(:organization) { create(:organization, clickhouse_events_store: false) } + + it "returns false" do + expect(organization).not_to be_clickhouse_events_store + expect(organization).to be_postgres_events_store + end + end + end + + describe "#organization" do + it "returns the organization" do + expect(organization.organization).to eq(organization) + end + end + + # this requires double confirmation: value on the org + premium integration + describe "#maximum_wallets_per_customer", :premium do + subject { organization.maximum_wallets_per_customer } + + let(:organization) { create(:organization, max_wallets:, premium_integrations:) } + let(:max_wallets) { nil } + let(:premium_integrations) { [] } + + context "when no events_targeting_wallets premium integration is enabled" do + context "when org has max_wallets set" do + let(:max_wallets) { 15 } + + it "returns nil" do + expect(subject).to eq(nil) + end + end + + context "when org has no max_wallets set" do + it "returns nil" do + expect(subject).to eq(nil) + end + end + end + + context "when events_targeting_wallets premium integration is enabled" do + let(:premium_integrations) { ["events_targeting_wallets"] } + + context "when org has max_wallets set" do + let(:max_wallets) { 15 } + + it "returns max value" do + expect(subject).to eq(15) + end + end + + context "when org has no max_wallets set" do + it "returns nil" do + expect(subject).to eq(nil) + end + end + end + end +end diff --git a/spec/models/password_reset_spec.rb b/spec/models/password_reset_spec.rb new file mode 100644 index 0000000..bad20c6 --- /dev/null +++ b/spec/models/password_reset_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PasswordReset do + subject(:password_reset) do + described_class.new( + user: create(:user), + token: SecureRandom.hex(20), + expire_at: Time.current + 30.minutes + ) + end + + describe "Validations" do + it "is valid with valid attributes" do + expect(password_reset).to be_valid + end + + it "is not valid without user" do + password_reset.user = nil + + expect(password_reset).not_to be_valid + end + + it "is not valid without token" do + password_reset.token = nil + + expect(password_reset).not_to be_valid + end + + it "is not valid without expire_at" do + password_reset.expire_at = nil + + expect(password_reset).not_to be_valid + end + end +end diff --git a/spec/models/payment_intent_spec.rb b/spec/models/payment_intent_spec.rb new file mode 100644 index 0000000..7fc8f08 --- /dev/null +++ b/spec/models/payment_intent_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentIntent do + it { is_expected.to define_enum_for(:status).with_values(described_class::STATUSES) } + + it { is_expected.to belong_to(:invoice) } + it { is_expected.to belong_to(:organization) } + + it { is_expected.to validate_presence_of(:status) } + it { is_expected.to validate_presence_of(:expires_at) } + + describe "validations" do + describe "of status uniqueness" do + subject { payment_intent.valid? } + + let(:payment_intent) { build(:payment_intent, invoice:, status:) } + let(:invoice) { create(:invoice) } + let(:error) { payment_intent.errors.where(:status, :taken) } + + context "when status is active" do + let(:status) { :active } + + before { create(:payment_intent, status:) } + + context "when a record with the same status and invoice exists" do + before do + create(:payment_intent, status:, invoice:) + subject + end + + it "adds an error" do + expect(error).to be_present + end + end + + context "when no record with the same status and invoice exists" do + before do + create(:payment_intent, :expired, invoice:) + subject + end + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + + context "when status is expired" do + let(:status) { :expired } + + before { create(:payment_intent, status:) } + + context "when a record with the same status and invoice exists" do + before do + create(:payment_intent, status:, invoice:) + subject + end + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when no record with the same status and invoice exists" do + before do + create(:payment_intent, invoice:) + subject + end + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + end + end + + describe ".non_expired" do + subject { described_class.non_expired } + + let!(:scoped) { create(:payment_intent) } + + before { create(:payment_intent, :expired) } + + it "returns intents with future expire date" do + expect(subject).to contain_exactly scoped + end + end + + describe ".awaiting_expiration" do + subject { described_class.awaiting_expiration } + + let!(:scoped) { create(:payment_intent, expires_at: generate(:past_date)) } + + before do + create(:payment_intent) + create(:payment_intent, :expired) + end + + it "returns intents with past expire date and active status" do + expect(subject).to contain_exactly scoped + end + end +end diff --git a/spec/models/payment_method_spec.rb b/spec/models/payment_method_spec.rb new file mode 100644 index 0000000..77f0e57 --- /dev/null +++ b/spec/models/payment_method_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethod do + subject { build(:payment_method) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:customer) } + it { is_expected.to belong_to(:payment_provider).class_name("PaymentProviders::BaseProvider").optional } + it { is_expected.to belong_to(:payment_provider_customer).class_name("PaymentProviderCustomers::BaseCustomer").optional } + + it { expect(described_class).to be_soft_deletable } + + describe "Validations" do + it "is not valid without payment_method_id" do + subject.provider_method_id = nil + + expect(subject).not_to be_valid + end + end + + describe "Scopes" do + describe ".default" do + let(:pm1) { create(:payment_method, is_default: true) } + let(:pm2) { create(:payment_method, is_default: true) } + let(:pm3) { create(:payment_method, is_default: false) } + + before do + pm1 + pm2 + pm3 + end + + it "lists only default payments" do + default_payments = described_class.default + + expect(default_payments).to include(pm1, pm2) + expect(default_payments).not_to include(pm3) + end + end + end + + describe "#payment_provider_type" do + subject(:payment_provider_type) { payment_method.payment_provider_type } + + let(:payment_method) { create(:payment_method, payment_provider:) } + + context "when payment provider is StripeProvider" do + let(:payment_provider) { create(:stripe_provider) } + + it "returns stripe" do + expect(payment_provider_type).to eq("stripe") + end + end + + context "when payment provider is nil" do + let(:payment_provider) { nil } + + it "returns nil" do + expect(payment_provider_type).to be_nil + end + end + end +end diff --git a/spec/models/payment_provider_customers/adyen_customer_spec.rb b/spec/models/payment_provider_customers/adyen_customer_spec.rb new file mode 100644 index 0000000..4cebd54 --- /dev/null +++ b/spec/models/payment_provider_customers/adyen_customer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::AdyenCustomer do + subject(:adyen_customer) { described_class.new(attributes) } + + let(:attributes) {} + + describe "#payment_method_id" do + subject(:customer_payment_method_id) { adyen_customer.payment_method_id } + + let(:adyen_customer) { FactoryBot.build_stubbed(:adyen_customer) } + let(:payment_method_id) { SecureRandom.uuid } + + before { adyen_customer.payment_method_id = payment_method_id } + + it "returns the payment method id" do + expect(customer_payment_method_id).to eq payment_method_id + end + end + + describe "#require_provider_payment_id?" do + it { expect(adyen_customer).to be_require_provider_payment_id } + end +end diff --git a/spec/models/payment_provider_customers/base_customer_spec.rb b/spec/models/payment_provider_customers/base_customer_spec.rb new file mode 100644 index 0000000..705e3c8 --- /dev/null +++ b/spec/models/payment_provider_customers/base_customer_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.describe PaymentProviderCustomers::BaseCustomer do + subject(:integration_customer) { described_class.new(attributes) } + + let(:attributes) { {} } + + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/payment_provider_customers/cashfree_customer_spec.rb b/spec/models/payment_provider_customers/cashfree_customer_spec.rb new file mode 100644 index 0000000..a16875a --- /dev/null +++ b/spec/models/payment_provider_customers/cashfree_customer_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::CashfreeCustomer do + subject(:cashfree_customer) { described_class.new(attributes) } + + let(:attributes) {} + + describe "#require_provider_payment_id?" do + it { expect(cashfree_customer).not_to be_require_provider_payment_id } + end +end diff --git a/spec/models/payment_provider_customers/gocardless_customer_spec.rb b/spec/models/payment_provider_customers/gocardless_customer_spec.rb new file mode 100644 index 0000000..1fb474f --- /dev/null +++ b/spec/models/payment_provider_customers/gocardless_customer_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::GocardlessCustomer do + subject(:gocardless_customer) { described_class.new(attributes) } + + let(:attributes) {} + + describe "#require_provider_payment_id?" do + it { expect(gocardless_customer).to be_require_provider_payment_id } + end +end diff --git a/spec/models/payment_provider_customers/moneyhash_customer_spec.rb b/spec/models/payment_provider_customers/moneyhash_customer_spec.rb new file mode 100644 index 0000000..908b78c --- /dev/null +++ b/spec/models/payment_provider_customers/moneyhash_customer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::MoneyhashCustomer do + subject(:moneyhash_customer) { described_class.new(attributes) } + + let(:attributes) {} + + describe "#payment_method_id" do + subject(:customer_payment_method_id) { moneyhash_customer.payment_method_id } + + let(:moneyhash_customer) { FactoryBot.build_stubbed(:moneyhash_customer) } + let(:payment_method_id) { SecureRandom.uuid } + + before { moneyhash_customer.payment_method_id = payment_method_id } + + it "returns the payment method id" do + expect(customer_payment_method_id).to eq payment_method_id + end + end + + describe "#require_provider_payment_id?" do + it { expect(moneyhash_customer).to be_require_provider_payment_id } + end +end diff --git a/spec/models/payment_provider_customers/stripe_customer_spec.rb b/spec/models/payment_provider_customers/stripe_customer_spec.rb new file mode 100644 index 0000000..3b753bd --- /dev/null +++ b/spec/models/payment_provider_customers/stripe_customer_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::StripeCustomer do + subject(:stripe_customer) { described_class.new(attributes) } + + let(:attributes) {} + + describe "validation" do + describe "of provider payment methods" do + subject(:valid) { stripe_customer.valid? } + + let(:stripe_customer) do + FactoryBot.build_stubbed(:stripe_customer, provider_payment_methods:) + end + + let(:errors) { stripe_customer.errors } + + before { valid } + + context "when it is an empty array" do + let(:provider_payment_methods) { [] } + + it "adds error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :blank)).to be_present + end + end + + context "when it is nil" do + let(:provider_payment_methods) { nil } + + it "adds error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :blank)).to be_present + end + end + + context "when it contains only invalid value" do + let(:provider_payment_methods) { %w[invalid] } + + it "adds error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :invalid)).to be_present + end + end + + context "when it contains both valid and invalid values" do + let(:provider_payment_methods) { %w[card cash] } + + it "adds error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :invalid)).to be_present + end + end + + context "when it contains only valid value" do + let(:provider_payment_methods) { %w[card] } + + it "does not add error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :invalid)).not_to be_present + end + end + + context "when it contains multiple valid values" do + let(:provider_payment_methods) { described_class::PAYMENT_METHODS } + + it "does not add error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :invalid)).not_to be_present + end + end + + context "when it contains link type" do + let(:provider_payment_methods) { %w[link] } + + context "when required provider payment method card is missing" do + it "adds error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :invalid)).to be_present + end + end + + context "when required provider payment method card exists" do + let(:provider_payment_methods) { %w[link card] } + + it "does not add error on provider payment methods" do + expect(errors.where(:provider_payment_methods, :invalid)).not_to be_present + end + end + + context "when provider_payment_methods contains 'customer_balance'" do + context "with other payment methods" do + let(:provider_payment_methods) { %w[customer_balance card] } + + it "adds an error" do + expect(errors[:provider_payment_methods]).to include("customer_balance cannot be combined with other payment methods") + end + end + + context "without other methods" do + let(:provider_payment_methods) { %w[customer_balance] } + + it "does not add an error" do + expect(errors[:provider_payment_methods]).to be_empty + end + end + end + end + end + end + + describe "#payment_method_id" do + it "assigns and retrieve a payment method id" do + stripe_customer.payment_method_id = "foo_bar" + expect(stripe_customer.payment_method_id).to eq("foo_bar") + end + end + + describe "#provider_payment_methods" do + subject(:provider_payment_methods) { stripe_customer.provider_payment_methods } + + let(:stripe_customer) { FactoryBot.build_stubbed(:stripe_customer) } + + let(:payment_methods) do + described_class::PAYMENT_METHODS.sample Faker::Number.between(from: 1, to: 2) + end + + before { stripe_customer.provider_payment_methods = payment_methods } + + it "returns provider payment methods" do + expect(provider_payment_methods).to eq payment_methods + end + end + + describe "#provider_payment_methods_with_setup" do + it "returns only payment methods that require setup" do + expect(build(:stripe_customer, provider_payment_methods: %w[card]).provider_payment_methods_with_setup).to eq %w[card] + expect(build(:stripe_customer, provider_payment_methods: %w[card crypto]).provider_payment_methods_with_setup).to eq %w[card] + expect(build(:stripe_customer, provider_payment_methods: %w[crypto]).provider_payment_methods_with_setup).to eq [] + end + end + + describe "#require_provider_payment_id?" do + it { expect(stripe_customer).to be_require_provider_payment_id } + end + + describe "#provider_payment_methods_require_setup?" do + it "returns true if the customer has payment methods that require setup" do + expect(build(:stripe_customer, provider_payment_methods: %w[card])).to be_provider_payment_methods_require_setup + expect(build(:stripe_customer, provider_payment_methods: %w[card crypto])).to be_provider_payment_methods_require_setup + expect(build(:stripe_customer, provider_payment_methods: %w[crypto])).not_to be_provider_payment_methods_require_setup + expect(build(:stripe_customer, provider_payment_methods: %w[customer_balance])).not_to be_provider_payment_methods_require_setup + end + end +end diff --git a/spec/models/payment_providers/adyen_provider_spec.rb b/spec/models/payment_providers/adyen_provider_spec.rb new file mode 100644 index 0000000..3caf1b8 --- /dev/null +++ b/spec/models/payment_providers/adyen_provider_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::AdyenProvider do + subject(:provider) { build(:adyen_provider) } + + it { is_expected.to validate_length_of(:success_redirect_url).is_at_most(1024).allow_nil } + it { is_expected.to validate_presence_of(:name) } + + describe "validations" do + let(:errors) { provider.errors } + + it "validates uniqueness of the code" do + expect(provider).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + + describe "of success redirect url format" do + subject(:provider) { build(:adyen_provider, success_redirect_url:) } + + before { provider.valid? } + + context "when it is valid url with http(s) scheme" do + let(:success_redirect_url) { Faker::Internet.url } + + it "does not add an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).not_to be_present + end + end + + context "when it is valid url with custom scheme" do + let(:success_redirect_url) { "my-app://your.package.name?param=12&p=7" } + + it "does not add an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).not_to be_present + end + end + + context "when it is nil" do + let(:success_redirect_url) { nil } + + it "does not add an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).not_to be_present + end + end + + context "when it is an empty string" do + let(:success_redirect_url) { "" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + + context "when it is not valid url" do + context "when it contains no scheme" do + let(:success_redirect_url) { "your.package.name?param=12&p=7" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + + context "when it contains only scheme" do + let(:success_redirect_url) { "https://" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + + context "when it is just a string" do + let(:success_redirect_url) { "invalidurl" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + end + end + end + + describe "#api_key" do + let(:api_key) { SecureRandom.uuid } + + before { provider.api_key = api_key } + + it "returns the api key" do + expect(provider.api_key).to eq api_key + end + end + + describe "#merchant_account" do + let(:merchant_account) { "TestECOM" } + + before { provider.merchant_account = merchant_account } + + it "returns the merchant account" do + expect(provider.merchant_account).to eq merchant_account + end + end + + describe "#live_prefix" do + let(:live_prefix) { Faker::Internet.domain_word } + + before { provider.live_prefix = live_prefix } + + it "returns the live prefix" do + expect(provider.live_prefix).to eq live_prefix + end + end + + describe "#hmac_key" do + let(:hmac_key) { SecureRandom.uuid } + + before { provider.hmac_key = hmac_key } + + it "returns the hmac key" do + expect(provider.hmac_key).to eq hmac_key + end + end + + describe "#success_redirect_url" do + let(:success_redirect_url) { Faker::Internet.url } + + before { provider.success_redirect_url = success_redirect_url } + + it "returns the url" do + expect(provider.success_redirect_url).to eq success_redirect_url + end + end +end diff --git a/spec/models/payment_providers/base_provider_spec.rb b/spec/models/payment_providers/base_provider_spec.rb new file mode 100644 index 0000000..7a9dc62 --- /dev/null +++ b/spec/models/payment_providers/base_provider_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::BaseProvider do + subject(:provider) { described_class.new(attributes) } + + let(:secrets) { {"api_key" => api_key, "api_secret" => api_secret} } + let(:api_key) { SecureRandom.uuid } + let(:api_secret) { SecureRandom.uuid } + + let(:attributes) do + {secrets: secrets.to_json} + end + + it_behaves_like "paper_trail traceable" do + subject { build(:stripe_provider) } + end + + it { is_expected.to have_many(:payment_provider_customers).dependent(:nullify) } + it { is_expected.to have_many(:customers).through(:payment_provider_customers) } + it { is_expected.to have_many(:payments).dependent(:nullify) } + it { is_expected.to have_many(:refunds).dependent(:nullify) } + + it { is_expected.to validate_presence_of(:name) } + + describe "validations" do + describe "of code uniqueness" do + let(:error) { payment_provider.errors.where(:code, :taken) } + + let(:payment_provider) { build(:stripe_provider, organization:, code: "stripe1") } + let(:organization) { create(:organization) } + + before do + create(:stripe_provider, code: "stripe1") + create(:stripe_provider, code: "stripe1", organization:, deleted_at: generate(:past_date)) + end + + context "when code is unique in scope of the organization" do + before { payment_provider.valid? } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when code is not unique in scope of the organization" do + before do + create(:stripe_provider, code: "stripe1", organization:) + + payment_provider.valid? + end + + it "adds an error" do + expect(error).to be_present + end + end + end + end + + describe ".json_secrets" do + it { expect(provider.secrets_json).to eq(secrets) } + end + + describe ".push_to_secrets" do + it "push the value into the secrets" do + provider.push_to_secrets(key: "api_key", value: "foo_bar") + + expect(provider.secrets_json).to eq( + { + "api_key" => "foo_bar", + "api_secret" => api_secret + } + ) + end + end + + describe ".get_from_secrets" do + it { expect(provider.get_from_secrets("api_secret")).to eq(api_secret) } + + it { expect(provider.get_from_secrets(nil)).to be_nil } + it { expect(provider.get_from_secrets("foo")).to be_nil } + end +end diff --git a/spec/models/payment_providers/flutterwave_provider_spec.rb b/spec/models/payment_providers/flutterwave_provider_spec.rb new file mode 100644 index 0000000..2b932c9 --- /dev/null +++ b/spec/models/payment_providers/flutterwave_provider_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::FlutterwaveProvider do + subject(:flutterwave_provider) { build(:flutterwave_provider) } + + describe "validations" do + it { is_expected.to validate_presence_of(:secret_key) } + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to allow_value(nil).for(:success_redirect_url) } + it { is_expected.to allow_value("https://example.com/success").for(:success_redirect_url) } + it { is_expected.not_to allow_value("invalid-url").for(:success_redirect_url) } + it { is_expected.not_to allow_value("a" * 1025).for(:success_redirect_url) } + + it "validates uniqueness of the code" do + expect(flutterwave_provider).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe "constants" do + it "defines success redirect URL" do + expect(described_class::SUCCESS_REDIRECT_URL).to eq("https://www.flutterwave.com/ng") + end + + it "defines API URL" do + expect(described_class::API_URL).to eq("https://api.flutterwave.com/v3") + end + + it "defines processing statuses" do + expect(described_class::PROCESSING_STATUSES).to eq(%w[pending]) + end + + it "defines success statuses" do + expect(described_class::SUCCESS_STATUSES).to eq(%w[successful]) + end + + it "defines failed statuses" do + expect(described_class::FAILED_STATUSES).to eq(%w[failed cancelled]) + end + end + + describe "#payment_type" do + it "returns flutterwave" do + expect(flutterwave_provider.payment_type).to eq("flutterwave") + end + end + + describe "#api_url" do + it "returns the API URL" do + expect(flutterwave_provider.api_url).to eq("https://api.flutterwave.com/v3") + end + end + + describe "webhook_secret generation" do + context "when creating a new provider" do + it "generates a webhook secret" do + provider = create(:flutterwave_provider) + expect(provider.webhook_secret).to be_present + expect(provider.webhook_secret.length).to eq(64) + end + + it "generates different secrets for different providers" do + provider1 = create(:flutterwave_provider) + provider2 = create(:flutterwave_provider) + expect(provider1.webhook_secret).not_to eq(provider2.webhook_secret) + end + + it "does not override existing webhook secret" do + existing_secret = "existing_secret" + provider = described_class.new( + organization: create(:organization), + name: "Test Provider", + code: "test_provider", + secret_key: "test_key", + webhook_secret: existing_secret + ) + provider.save! + expect(provider.reload.webhook_secret).to eq(existing_secret) + end + end + end + + describe "FlutterwavePayment" do + it "defines a data structure for payments" do + payment = described_class::FlutterwavePayment.new( + id: "12345", + status: "successful", + metadata: {amount: 1000} + ) + + expect(payment.id).to eq("12345") + expect(payment.status).to eq("successful") + expect(payment.metadata).to eq({amount: 1000}) + end + end + + describe "secrets accessors" do + it "provides access to secret_key through secrets" do + provider = create(:flutterwave_provider, secret_key: "test_secret_key") + expect(provider.secret_key).to eq("test_secret_key") + end + + it "provides access to webhook_secret through secrets" do + provider = create(:flutterwave_provider) + expect(provider.webhook_secret).to be_present + end + end +end diff --git a/spec/models/payment_providers/gocardless_provider_spec.rb b/spec/models/payment_providers/gocardless_provider_spec.rb new file mode 100644 index 0000000..9e57ad5 --- /dev/null +++ b/spec/models/payment_providers/gocardless_provider_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::GocardlessProvider do + subject(:gocardless_provider) { build(:gocardless_provider, attributes) } + + let(:attributes) {} + + it { is_expected.to validate_length_of(:success_redirect_url).is_at_most(1024).allow_nil } + it { is_expected.to validate_presence_of(:name) } + + describe "validations" do + let(:errors) { provider.errors } + + it "validates uniqueness of the code" do + expect(gocardless_provider).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + + describe "of success redirect url format" do + subject(:provider) { build(:gocardless_provider, success_redirect_url:) } + + before { provider.valid? } + + context "when it is valid url with http(s) scheme" do + let(:success_redirect_url) { Faker::Internet.url } + + it "does not add an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).not_to be_present + end + end + + context "when it is valid url with custom scheme" do + let(:success_redirect_url) { "my-app://your.package.name?param=12&p=7" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + + context "when it is nil" do + let(:success_redirect_url) { nil } + + it "does not add an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).not_to be_present + end + end + + context "when it is an empty string" do + let(:success_redirect_url) { "" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + + context "when it is not valid url" do + context "when it contains no scheme" do + let(:success_redirect_url) { "your.package.name?param=12&p=7" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + + context "when it contains only scheme" do + let(:success_redirect_url) { "https://" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + + context "when it is just a string" do + let(:success_redirect_url) { "invalidurl" } + + it "adds an error" do + expect(errors.where(:success_redirect_url, :url_invalid)).to be_present + end + end + end + end + end + + describe "access_token" do + it "assigns and retrieves an access token" do + gocardless_provider.access_token = "foo_bar" + expect(gocardless_provider.access_token).to eq("foo_bar") + end + end + + describe "#success_redirect_url" do + let(:success_redirect_url) { Faker::Internet.url } + + before { gocardless_provider.success_redirect_url = success_redirect_url } + + it "returns the url" do + expect(gocardless_provider.success_redirect_url).to eq success_redirect_url + end + end +end diff --git a/spec/models/payment_providers/moneyhash_provider_spec.rb b/spec/models/payment_providers/moneyhash_provider_spec.rb new file mode 100644 index 0000000..4c157dc --- /dev/null +++ b/spec/models/payment_providers/moneyhash_provider_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::MoneyhashProvider do + subject(:moneyhash_provider) { build(:moneyhash_provider, attributes) } + + let(:attributes) {} + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:api_key) } + it { is_expected.to validate_presence_of(:flow_id) } + it { is_expected.to validate_length_of(:flow_id).is_at_most(20) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(moneyhash_provider).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe "#api_key" do + let(:api_key) { SecureRandom.uuid } + + before { moneyhash_provider.api_key = api_key } + + it "returns the api key" do + expect(moneyhash_provider.api_key).to eq api_key + end + end + + describe "#flow_id" do + let(:flow_id) { "test_flow_id" } + + before { moneyhash_provider.flow_id = flow_id } + + it "returns the flow id" do + expect(moneyhash_provider.flow_id).to eq flow_id + end + end + + describe ".api_base_url" do + context "when in production environment" do + before do + allow(Rails.env).to receive(:production?).and_return(true) + end + + it "returns production URL" do + expect(described_class.api_base_url).to eq("https://web.moneyhash.io") + end + end + + context "when in non-production environment" do + before do + allow(Rails.env).to receive(:production?).and_return(false) + end + + it "returns staging URL" do + expect(described_class.api_base_url).to eq("https://staging-web.moneyhash.io") + end + end + end + + describe "#webhook_end_point" do + let(:organization_id) { SecureRandom.uuid } + let(:code) { "test_code" } + let(:lago_api_url) { "https://api.getlago.com" } + + before do + moneyhash_provider.organization_id = organization_id + moneyhash_provider.code = code + allow(ENV).to receive(:[]).with("LAGO_API_URL").and_return(lago_api_url) + end + + it "returns the correct webhook endpoint URL" do + expected_url = "#{lago_api_url}/webhooks/moneyhash/#{organization_id}?code=#{code}" + expect(moneyhash_provider.webhook_end_point.to_s).to eq(expected_url) + end + end +end diff --git a/spec/models/payment_providers/stripe_provider_spec.rb b/spec/models/payment_providers/stripe_provider_spec.rb new file mode 100644 index 0000000..f1fa182 --- /dev/null +++ b/spec/models/payment_providers/stripe_provider_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::StripeProvider do + subject(:stripe_provider) { build(:stripe_provider, attributes) } + + let(:attributes) {} + + it { is_expected.to validate_length_of(:success_redirect_url).is_at_most(1024).allow_nil } + it { is_expected.to validate_presence_of(:name) } + + describe "validations" do + it "validates uniqueness of the code" do + expect(stripe_provider).to validate_uniqueness_of(:code).scoped_to(:organization_id) + end + end + + describe "secret_key" do + it "assigns and retrieve a secret key" do + stripe_provider.secret_key = "foo_bar" + expect(stripe_provider.secret_key).to eq("foo_bar") + end + end + + describe "webhook_id" do + it "assigns and retrieve a setting" do + stripe_provider.webhook_id = "webhook_id" + expect(stripe_provider.webhook_id).to eq("webhook_id") + end + end + + describe "webhook_secret" do + it "assigns and retrieve a setting" do + stripe_provider.webhook_secret = "secret" + expect(stripe_provider.webhook_secret).to eq("secret") + end + end + + describe "#success_redirect_url" do + let(:success_redirect_url) { Faker::Internet.url } + + before { stripe_provider.success_redirect_url = success_redirect_url } + + it "returns the url" do + expect(stripe_provider.success_redirect_url).to eq success_redirect_url + end + end +end diff --git a/spec/models/payment_receipt_spec.rb b/spec/models/payment_receipt_spec.rb new file mode 100644 index 0000000..b549855 --- /dev/null +++ b/spec/models/payment_receipt_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipt do + subject(:payment_receipt) { build(:payment_receipt) } + + it do + expect(subject).to belong_to(:payment) + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:billing_entity) + expect(subject).to have_one_attached(:file) + expect(subject).to have_one_attached(:xml_file) + end + + describe "#file_url" do + before do + payment_receipt.save! + payment_receipt.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "payment_receipt.pdf", + content_type: "application/pdf" + ) + end + + it "returns the file url" do + file_url = payment_receipt.file_url + + expect(file_url).to be_present + expect(file_url).to include(ENV["LAGO_API_URL"]) + end + end + + describe "#xml_url" do + before do + payment_receipt.save! + payment_receipt.xml_file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.xml"))), + filename: "payment_receipt.xml", + content_type: "application/xml" + ) + end + + it "returns the xml url" do + xml_url = payment_receipt.xml_url + + expect(xml_url).to be_present + expect(xml_url).to include(ENV["LAGO_API_URL"]) + end + end +end diff --git a/spec/models/payment_request/applied_invoice_spec.rb b/spec/models/payment_request/applied_invoice_spec.rb new file mode 100644 index 0000000..48c5f54 --- /dev/null +++ b/spec/models/payment_request/applied_invoice_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe PaymentRequest::AppliedInvoice do + subject(:applied_invoice) { build(:payment_request_applied_invoice) } + + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/payment_request_spec.rb b/spec/models/payment_request_spec.rb new file mode 100644 index 0000000..bbc4d65 --- /dev/null +++ b/spec/models/payment_request_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequest do + subject(:payment_request) do + described_class.new( + organization:, + customer:, + email: Faker::Internet.email, + amount_cents: Faker::Number.number(digits: 5), + amount_currency: Faker::Currency.code + ) + end + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:payment) { create(:payment) } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to have_many(:applied_invoices).class_name("PaymentRequest::AppliedInvoice") } + it { is_expected.to have_many(:invoices).through(:applied_invoices) } + it { is_expected.to have_many(:payments) } + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:customer) } + it { is_expected.to belong_to(:dunning_campaign).optional } + + describe "normalizations" do + it "sanitizes email on assignment" do + payment_request.email = " hello@some\u200Bthing\u2013other.com " + expect(payment_request.email).to eq("hello@something-other.com") + end + end + + describe "Validations" do + it "is valid with valid attributes" do + expect(payment_request).to be_valid + end + + it "is not valid without amount_cents" do + payment_request.amount_cents = nil + expect(payment_request).not_to be_valid + end + + it "is not valid without amount_currency" do + payment_request.amount_currency = nil + expect(payment_request).not_to be_valid + end + end + + describe "#total_amount_cents" do + it "aliases amount_cents" do + expect(payment_request.total_amount_cents).to eq(payment_request.amount_cents) + end + end + + describe "#total_amount_cents=" do + let(:amount_cents) { 19_999_55 } + + it "aliases amount_cents=" do + payment_request.total_amount_cents = amount_cents + expect(payment_request.amount_cents).to eq(amount_cents) + end + end + + describe "#currency" do + it "aliases amount_currency" do + expect(payment_request.currency).to eq(payment_request.amount_currency) + end + end + + describe "#invoice_ids" do + let(:payment_request) { create(:payment_request, invoices:) } + let(:invoices) { create_list(:invoice, 2, organization:) } + + it "returns a list with the applied invoice ids" do + expect(payment_request.invoice_ids).to eq(invoices.map(&:id)) + end + end + + describe "#increment_payment_attempts!" do + let(:payment_request) { create :payment_request } + + it "updates payment_attempts attribute +1" do + expect { payment_request.increment_payment_attempts! } + .to change { payment_request.reload.payment_attempts } + .by(1) + end + end +end diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb new file mode 100644 index 0000000..f1b7ab6 --- /dev/null +++ b/spec/models/payment_spec.rb @@ -0,0 +1,656 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Payment do + subject(:payment) { build(:payment, payable:, payment_type:, provider_payment_id:, reference:, amount_cents:) } + + let(:payable) { create(:invoice, invoice_type:, total_amount_cents: 10000) } + let(:invoice_type) { :subscription } + let(:payment_type) { "provider" } + let(:provider_payment_id) { SecureRandom.uuid } + let(:reference) { nil } + let(:amount_cents) { 200 } + + it_behaves_like "paper_trail traceable" + + it { is_expected.to have_many(:integration_resources) } + it { is_expected.to have_one(:payment_receipt) } + it { is_expected.to have_one(:invoice_settlement).with_foreign_key(:source_payment_id) } + it { is_expected.to belong_to(:payable) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:customer).optional } + it { is_expected.to belong_to(:payment_method).optional } + it { is_expected.to validate_presence_of(:payment_type) } + + it do + expect(subject) + .to define_enum_for(:payment_type) + .with_values(Payment::PAYMENT_TYPES) + .with_prefix(:payment_type) + .backed_by_column_of_type(:enum) + end + + describe "attributes" do + describe "amount_currency" do + before { payment.update(amount_currency: "BRL") } + + it "aliases to currency" do + expect(payment.currency).to eq("BRL") + end + end + end + + describe "delegations" do + describe "billing_entity" do + it "delegates to customer" do + expect(payment.billing_entity).to be(payment.customer.billing_entity) + end + end + end + + describe "validations" do + let(:errors) { payment.errors } + + before { payment.valid? } + + describe "of amount cents" do + before { payment.save } + + context "when payable is an invoice" do + context "when invoice is not of a credit type" do + context "when payment type is manual" do + let(:payment_type) { "manual" } + + context "when amount cents does not equal invoice total amount cents" do + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + + context "when amount cents equals invoice total amount cents" do + let(:amount_cents) { payable.total_amount_cents } + + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + end + + context "when payment type is provider" do + let(:payment_type) { "provider" } + + context "when amount cents does not equal invoice total amount cents" do + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + + context "when amount cents equals invoice total amount cents" do + let(:amount_cents) { payable.total_amount_cents } + + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + end + end + + context "when invoice is of a credit type" do + let(:invoice_type) { :credit } + + context "when payment type is manual" do + let(:payment_type) { "manual" } + + context "when amount cents does not equal invoice total amount cents" do + it "adds an error" do + expect(errors.where(:amount_cents, :invalid_amount)).to be_present + end + end + + context "when amount cents equals invoice total amount cents" do + let(:amount_cents) { payable.total_amount_cents } + + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + end + + context "when payment type is provider" do + let(:payment_type) { "provider" } + + context "when amount cents does not equal invoice total amount cents" do + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + + context "when amount cents equals invoice total amount cents" do + let(:amount_cents) { payable.total_amount_cents } + + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + end + end + end + + context "when payable is a payment request" do + let(:payable) { create(:payment_request) } + + context "when payment type is manual" do + let(:payment_type) { "manual" } + + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + + context "when payment type is provider" do + let(:payment_type) { "provider" } + + it "does not add an error" do + expect(errors.where(:amount_cents, :invalid_amount)).not_to be_present + end + end + end + end + + describe "of max invoice paid amount cents" do + before { payment.save } + + context "when payable is an invoice" do + context "when payment type is provider" do + let(:payment_type) { "provider" } + + context "when amount cents + total paid amount cents is smaller or equal than invoice total amount cents" do + let(:payment_request) { create(:payment_request, payment_status: :succeeded) } + + it "does not add an error" do + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + end + + context "when amount cents + total paid amount cents is greater than invoice total amount cents" do + let(:amount_cents) { 10001 } + + it "does not add an error" do + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + end + end + + context "when payment type is manual" do + let(:payment_type) { "manual" } + + context "when amount cents + total paid amount cents is smaller or equal than invoice total amount cents" do + let(:payment_request) { create(:payment_request, payment_status: :succeeded) } + + it "does not add an error" do + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + end + + context "when amount cents + total paid amount cents is greater than invoice total amount cents" do + let(:amount_cents) { 10001 } + + it "adds an error" do + expect(errors.where(:amount_cents, :greater_than)).to be_present + end + end + + context "with offset amounts from credit notes" do + let(:payable) { create(:invoice, total_amount_cents: 10000, total_paid_amount_cents: 3000) } + + it "allows payment within remaining due amount after offset" do + create(:credit_note, invoice: payable, status: :finalized, offset_amount_cents: 2000) + payment.amount_cents = 5000 # total_due = 10000 - 3000 - 2000 = 5000 + payment.save + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + + it "rejects payment exceeding remaining due amount after offset" do + create(:credit_note, invoice: payable, status: :finalized, offset_amount_cents: 2000) + payment.amount_cents = 6000 # total_due = 10000 - 3000 - 2000 = 5000 + payment.save + expect(errors.where(:amount_cents, :greater_than)).to be_present + end + + it "sums multiple offset amounts correctly" do + create(:credit_note, invoice: payable, status: :finalized, offset_amount_cents: 1500) + create(:credit_note, invoice: payable, status: :finalized, offset_amount_cents: 1000) + payment.amount_cents = 4500 # total_due = 10000 - 3000 - 2500 = 4500 + payment.save + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + + it "rejects payment when invoice is fully settled by offsets" do + payable.update!(total_paid_amount_cents: 6000) + create(:credit_note, invoice: payable, status: :finalized, offset_amount_cents: 4000) + payment.amount_cents = 1 # total_due = 10000 - 6000 - 4000 = 0 + payment.save + expect(errors.where(:amount_cents, :greater_than)).to be_present + end + + it "ignores draft credit notes when calculating due amount" do + payable.update!(total_paid_amount_cents: 2000) + create(:credit_note, invoice: payable, status: :draft, offset_amount_cents: 1000) + payment.amount_cents = 8000 # total_due = 10000 - 2000 = 8000 (draft ignored) + payment.save + expect(errors.where(:amount_cents, :greater_than)).not_to be_present + end + end + end + end + end + + describe "of payment request succeeded" do + context "when payable is an invoice" do + context "when payment type is provider" do + let(:payment_type) { "provider" } + + context "when succeeded payment requests exist" do + let(:payment_request) { create(:payment_request, payment_status: :succeeded) } + + before do + create(:payment_request_applied_invoice, payment_request:, invoice: payable) + payment.save + end + + it "does not add an error" do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + + context "when no succeeded payment requests exist" do + before { payment.save } + + it "does not add an error" do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + end + + context "when payment type is manual" do + let(:payment_type) { "manual" } + + context "when succeeded payment request exist" do + let(:payment_request) { create(:payment_request, payment_status: "succeeded") } + + before do + create(:payment_request_applied_invoice, payment_request:, invoice: payable) + payment.save + end + + it "adds an error" do + expect(payment.errors.where(:base, :payment_request_is_already_succeeded)).to be_present + end + end + + context "when no succeeded payment requests exist" do + before { payment.save } + + it "does not add an error" do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + end + end + + context "when payable is not an invoice" do + let(:payable) { create(:payment_request) } + + context "when payment type is provider" do + let(:payment_type) { "provider" } + + before { payment.save } + + it "does not add an error" do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + + context "when payment type is manual" do + let(:payment_type) { "manual" } + + before { payment.save } + + it "does not add an error" do + expect(errors.where(:base, :payment_request_is_already_succeeded)).not_to be_present + end + end + end + end + + describe "of reference" do + context "when payment type is provider" do + context "when reference is present" do + let(:reference) { "123" } + + it "adds an error" do + expect(errors.where(:reference, :present)).to be_present + end + end + + context "when reference is not present" do + it "does not add an error" do + expect(errors.where(:reference, :present)).not_to be_present + end + end + end + + context "when payment type is manual" do + let(:payment_type) { "manual" } + + context "when reference is not present" do + it "adds an error" do + expect(errors[:reference]).to include("value_is_mandatory") + end + end + + context "when reference is present" do + context "when reference is less than 40 characters" do + let(:reference) { "123" } + + it "does not add an error" do + expect(errors.where(:reference, :blank)).not_to be_present + end + end + + context "when reference is more than 40 characters" do + let(:reference) { "a" * 41 } + + it "adds an error" do + expect(errors.where(:reference, :too_long)).to be_present + end + end + end + end + end + end + + describe "#invoices" do + context "when payable is an invoice" do + let(:payable) { create(:invoice) } + + it "returns the invoice as a ActiveRecord::Relation" do + expect(subject.invoices).to be_a(ActiveRecord::Relation) + expect(subject.invoices.sole).to eq payable + end + end + + context "when payable is a payment request" do + let(:invoices) { create_list(:invoice, 2) } + let(:payable) { create(:payment_request, invoices:) } + + it "returns the payment request invoices as a ActiveRecord::Relation" do + expect(subject.invoices).to be_a ActiveRecord::Relation + expect(subject.invoices).to match_array(invoices) + end + end + end + + describe "#invoice_numbers" do + context "when payable is an invoice" do + let(:payable) { create(:invoice) } + + it "returns the invoice number" do + expect(subject.invoice_numbers).to eq([payable.number]) + end + end + + context "when payable is a payment request" do + let(:invoices) { create_list(:invoice, 2) } + let(:payable) { create(:payment_request, invoices:) } + + it "returns the payment request invoice numbers" do + expect(subject.invoice_numbers).to match_array(invoices.map(&:number)) + end + end + end + + describe "#payment_provider_type" do + subject(:payment_provider_type) { payment.payment_provider_type } + + let(:payment) { create(:payment, payment_provider:) } + + context "when payment provider is AdyenProvider" do + let(:payment_provider) { create(:adyen_provider) } + + it "returns adyen" do + expect(payment_provider_type).to eq("adyen") + end + end + + context "when payment provider is CashfreeProvider" do + let(:payment_provider) { create(:cashfree_provider) } + + it "returns cashfree" do + expect(payment_provider_type).to eq("cashfree") + end + end + + context "when payment provider is GocardlessProvider" do + let(:payment_provider) { create(:gocardless_provider) } + + it "returns gocardless" do + expect(payment_provider_type).to eq("gocardless") + end + end + + context "when payment provider is StripeProvider" do + let(:payment_provider) { create(:stripe_provider) } + + it "returns stripe" do + expect(payment_provider_type).to eq("stripe") + end + end + + context "when payment provider is nil" do + let(:payment_provider) { nil } + + it "returns an empty string" do + expect(payment_provider_type).to be_nil + end + end + end + + describe "#method_display_name" do + subject(:method_display_name) { payment.method_display_name } + + context "when provider_payment_method_data is empty" do + let(:payment) { build(:payment, provider_payment_method_data: {}) } + + it "returns nil" do + expect(method_display_name).to be_nil + end + end + + context "when provider_payment_method_data contains card details" do + let(:payment) do + build(:payment, provider_payment_method_data: { + "type" => "card", + "brand" => "visa", + "last4" => "1234" + }) + end + + it "returns formatted card details" do + expect(method_display_name).to eq("Visa **** 1234") + end + end + + context "when provider_payment_method_data contains non-card details" do + let(:payment) do + build(:payment, provider_payment_method_data: { + "type" => "bank_transfer" + }) + end + + it "returns the payment method type" do + expect(method_display_name).to eq("Bank Transfer") + end + end + end + + describe "#card_last_digits" do + subject { payment.card_last_digits } + + context "when provider_payment_method_data is empty" do + let(:payment) { build(:payment, provider_payment_method_data: {}) } + + it { is_expected.to be_nil } + end + + context "when provider_payment_method_data type is not card" do + let(:payment) do + build(:payment, provider_payment_method_data: {"type" => "link"}) + end + + it { is_expected.to be_nil } + end + + context "when provider_payment_method_data type is card" do + let(:payment) do + build(:payment, provider_payment_method_data: { + "type" => "card", + "brand" => "visa", + "last4" => "1234" + }) + end + + it { is_expected.to eq("**** 1234") } + end + end + + describe "#should_sync_payment?" do + subject(:method_call) { payment.should_sync_payment? } + + let(:payment) { create(:payment, payable: invoice) } + let(:invoice) { create(:invoice, customer:, organization:, status:) } + let(:organization) { create(:organization) } + + context "when invoice is not finalized" do + let(:status) { %i[draft voided generating].sample } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration customer" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_payments:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync payments is true" do + let(:sync_payments) { true } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "when sync payments is false" do + let(:sync_payments) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "without integration customer" do + let(:customer) { create(:customer, organization:) } + + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration customer" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_payments:) } + let(:customer) { create(:customer, organization:) } + + before { integration_customer } + + context "when sync payments is true" do + let(:sync_payments) { true } + + it "returns true" do + expect(method_call).to eq(true) + end + end + + context "when sync payments is false" do + let(:sync_payments) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + end + + describe ".for_organization" do + subject(:result) { described_class.for_organization(organization) } + + let(:organization) { create(:organization) } + let(:visible_invoice) { create(:invoice, organization:, status: Invoice::VISIBLE_STATUS[:finalized]) } + let(:invisible_invoice) { create(:invoice, organization:, status: Invoice::INVISIBLE_STATUS[:generating]) } + let(:payment_request) { create(:payment_request, organization:) } + let(:other_org_payment_request) { create(:payment_request) } + + let(:visible_invoice_payment) { create(:payment, payable: visible_invoice) } + let(:invisible_invoice_payment) { create(:payment, payable: invisible_invoice) } + let(:payment_request_payment) { create(:payment, payable: payment_request) } + let(:other_org_invoice_payment) { create(:payment) } + let(:other_org_payment_request_payment) { create(:payment, payable: other_org_payment_request) } + + before do + visible_invoice_payment + invisible_invoice_payment + payment_request_payment + + other_org_invoice_payment + other_org_payment_request_payment + end + + it "returns payments and payment requests for the organization's visible invoices" do + payments = subject + + expect(payments).to include(visible_invoice_payment) + expect(payments).to include(payment_request_payment) + expect(payments).not_to include(invisible_invoice_payment) + expect(payments).not_to include(other_org_invoice_payment) + expect(payments).not_to include(other_org_payment_request_payment) + end + end + + describe ".customer" do + context "with discarded customer" do + before do + payment.customer.discard + payment.save! + end + + it "loads the discarded customer" do + payment.reload + expect(payment.customer).not_to be_nil + end + end + end +end diff --git a/spec/models/pending_vies_check_spec.rb b/spec/models/pending_vies_check_spec.rb new file mode 100644 index 0000000..eba7714 --- /dev/null +++ b/spec/models/pending_vies_check_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PendingViesCheck, type: :model do + subject(:pending_vies_check) { build(:pending_vies_check) } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:billing_entity) + expect(subject).to belong_to(:customer) + end + end + + describe "validations" do + subject(:pending_vies_check) { create(:pending_vies_check) } + + it do + expect(subject).to validate_uniqueness_of(:customer_id).ignoring_case_sensitivity + expect(subject).to validate_numericality_of(:attempts_count).is_greater_than_or_equal_to(0) + expect(subject).to validate_inclusion_of(:last_error_type).in_array(described_class::KNOWN_ERROR_TYPES).allow_nil + end + end + + describe ".error_type_for" do + it "maps Valvat exceptions to error type strings" do + expect(described_class.error_type_for(Valvat::RateLimitError.new("error", :vies))).to eq("rate_limit") + expect(described_class.error_type_for(Valvat::Timeout.new("error", :vies))).to eq("timeout") + expect(described_class.error_type_for(Valvat::BlockedError.new("error", :vies))).to eq("blocked") + expect(described_class.error_type_for(Valvat::InvalidRequester.new("error", :vies))).to eq("invalid_requester") + expect(described_class.error_type_for(Valvat::ServiceUnavailable.new("error", :vies))).to eq("service_unavailable") + expect(described_class.error_type_for(Valvat::HTTPError.new("The VIES web service returned the error: 307 ", :vies))).to eq("service_unavailable") + expect(described_class.error_type_for(Valvat::MemberStateUnavailable.new("error", :vies))).to eq("member_state_unavailable") + end + + it "returns 'unknown' for unmapped exceptions" do + expect(described_class.error_type_for(StandardError.new)).to eq("unknown") + end + end +end diff --git a/spec/models/plan/applied_tax_spec.rb b/spec/models/plan/applied_tax_spec.rb new file mode 100644 index 0000000..73870ab --- /dev/null +++ b/spec/models/plan/applied_tax_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +RSpec.describe Plan::AppliedTax do + subject(:plan_applied_tax) { create(:plan_applied_tax) } + + it { is_expected.to belong_to(:organization) } +end diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb new file mode 100644 index 0000000..59d5511 --- /dev/null +++ b/spec/models/plan_spec.rb @@ -0,0 +1,517 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plan do + subject(:plan) { build(:plan, trial_period: 3) } + + it { expect(described_class).to be_soft_deletable } + + it do + expect(subject).to have_one(:minimum_commitment) + expect(subject).to have_one(:metadata).class_name("Metadata::ItemMetadata") + expect(subject).to have_many(:usage_thresholds) + expect(subject).to have_many(:commitments) + expect(subject).to have_many(:charges).dependent(:destroy) + expect(subject).to have_many(:charge_filters).through(:charges).source(:filters) + expect(subject).to have_many(:billable_metrics).through(:charges) + expect(subject).to have_many(:fixed_charges).dependent(:destroy) + expect(subject).to have_many(:add_ons).through(:fixed_charges) + expect(subject).to have_many(:subscriptions) + expect(subject).to have_many(:customers).through(:subscriptions) + expect(subject).to have_many(:children).class_name("Plan").dependent(:destroy) + expect(subject).to have_many(:coupon_targets) + expect(subject).to have_many(:coupons).through(:coupon_targets) + expect(subject).to have_many(:invoices).through(:subscriptions) + expect(subject).to have_many(:usage_thresholds) + expect(subject).to have_many(:applied_taxes).class_name("Plan::AppliedTax").dependent(:destroy) + expect(subject).to have_many(:taxes).through(:applied_taxes) + expect(subject).to have_many(:entitlements).class_name("Entitlement::Entitlement").dependent(:destroy) + expect(subject).to have_many(:entitlement_values).through(:entitlements).source(:values).class_name("Entitlement::EntitlementValue").dependent(:destroy) + + expect(subject).to define_enum_for(:interval).with_values(Plan::INTERVALS).validating + end + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + it_behaves_like "paper_trail traceable" + + describe "Validations" do + it "requires the pay_in_advance" do + plan.pay_in_advance = nil + expect(plan).not_to be_valid + + plan.pay_in_advance = true + expect(plan).to be_valid + end + end + + describe "#is_parent? and #is_child?" do + it do + expect(plan.is_parent?).to be true + expect(plan.is_child?).to be false + + plan.parent_id = SecureRandom.uuid + expect(plan.is_parent?).to be false + expect(plan.is_child?).to be true + end + end + + describe "#applicable_usage_thresholds" do + let(:plan) { create(:plan) } + + it "returns usage thresholds plan is a parent" do + threshold = create(:usage_threshold, plan: plan) + expect(plan.applicable_usage_thresholds).to contain_exactly(threshold) + end + + it "returns parent usage thresholds if plan is a child" do + create(:usage_threshold, plan: plan, amount_cents: 99_00) + parent = create(:plan) + threshold = create(:usage_threshold, plan: parent) + plan.update!(parent: parent) + + expect(plan.applicable_usage_thresholds).to contain_exactly(threshold) + end + + it "returns an empty array if neither plan nor parent has thresholds" do + plan.update!(parent: create(:plan)) + expect(plan.applicable_usage_thresholds).to eq([]) + end + end + + describe "#has_trial?" do + it "returns true when trial_period" do + expect(plan).to have_trial + end + + context "when value is 0" do + let(:plan) { build(:plan, trial_period: 0) } + + it "returns false" do + expect(plan).not_to have_trial + end + end + end + + describe "#charges_billed_in_monthly_split_intervals?" do + subject(:method_call) { plan.charges_billed_in_monthly_split_intervals? } + + let(:plan) { build_stubbed(:plan, interval:, bill_charges_monthly:) } + + context "when interval is yearly" do + let(:interval) { :yearly } + + context "when bill charges monthly is true" do + let(:bill_charges_monthly) { true } + + it "returns true" do + expect(subject).to be true + end + end + + context "when bill charges monthly is false" do + let(:bill_charges_monthly) { false } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is nil" do + let(:bill_charges_monthly) { nil } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when interval is semiannual" do + let(:interval) { :semiannual } + + context "when bill charges monthly is true" do + let(:bill_charges_monthly) { true } + + it "returns true" do + expect(subject).to be true + end + end + + context "when bill charges monthly is false" do + let(:bill_charges_monthly) { false } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is nil" do + let(:bill_charges_monthly) { nil } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when interval is quarterly" do + let(:interval) { :quarterly } + + context "when bill charges monthly is true" do + let(:bill_charges_monthly) { true } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is false" do + let(:bill_charges_monthly) { false } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is nil" do + let(:bill_charges_monthly) { nil } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when interval is monthly" do + let(:interval) { :monthly } + + context "when bill charges monthly is true" do + let(:bill_charges_monthly) { true } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is false" do + let(:bill_charges_monthly) { false } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is nil" do + let(:bill_charges_monthly) { nil } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when interval is weekly" do + let(:interval) { :weekly } + + context "when bill charges monthly is true" do + let(:bill_charges_monthly) { true } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is false" do + let(:bill_charges_monthly) { false } + + it "returns false" do + expect(subject).to be false + end + end + + context "when bill charges monthly is nil" do + let(:bill_charges_monthly) { nil } + + it "returns false" do + expect(subject).to be false + end + end + end + end + + describe "#fixed_charges_billed_in_monthly_split_intervals?" do + subject(:method_call) { plan.fixed_charges_billed_in_monthly_split_intervals? } + + let(:plan) { build_stubbed(:plan, interval:, bill_fixed_charges_monthly:) } + + context "when interval is yearly" do + let(:interval) { :yearly } + + context "when bill fixed charges monthly is true" do + let(:bill_fixed_charges_monthly) { true } + + it { is_expected.to be true } + end + + context "when bill fixed charges monthly is false" do + let(:bill_fixed_charges_monthly) { false } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is nil" do + let(:bill_fixed_charges_monthly) { nil } + + it { is_expected.to be false } + end + end + + context "when interval is semiannual" do + let(:interval) { :semiannual } + + context "when bill fixed charges monthly is true" do + let(:bill_fixed_charges_monthly) { true } + + it { is_expected.to be true } + end + + context "when bill fixed charges monthly is false" do + let(:bill_fixed_charges_monthly) { false } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is nil" do + let(:bill_fixed_charges_monthly) { nil } + + it { is_expected.to be false } + end + end + + context "when interval is quarterly" do + let(:interval) { :quarterly } + + context "when bill fixed charges monthly is true" do + let(:bill_fixed_charges_monthly) { true } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is false" do + let(:bill_fixed_charges_monthly) { false } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is nil" do + let(:bill_fixed_charges_monthly) { nil } + + it { is_expected.to be false } + end + end + + context "when interval is monthly" do + let(:interval) { :monthly } + + context "when bill fixed charges monthly is true" do + let(:bill_fixed_charges_monthly) { true } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is false" do + let(:bill_fixed_charges_monthly) { false } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is nil" do + let(:bill_fixed_charges_monthly) { nil } + + it { is_expected.to be false } + end + end + + context "when interval is weekly" do + let(:interval) { :weekly } + + context "when bill fixed charges monthly is true" do + let(:bill_fixed_charges_monthly) { true } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is false" do + let(:bill_fixed_charges_monthly) { false } + + it { is_expected.to be false } + end + + context "when bill fixed charges monthly is nil" do + let(:bill_fixed_charges_monthly) { nil } + + it { is_expected.to be false } + end + end + end + + describe "#charges_or_fixed_charges_billed_in_monthly_split_intervals?" do + subject(:method_call) { plan.charges_or_fixed_charges_billed_in_monthly_split_intervals? } + + let(:plan) { build_stubbed(:plan, interval: :yearly, bill_charges_monthly:, bill_fixed_charges_monthly:) } + + context "when charges and fixed charges billed in monthly split intervals are false" do + let(:bill_charges_monthly) { false } + let(:bill_fixed_charges_monthly) { false } + + it { is_expected.to be false } + end + + context "when charges and fixed charges billed in monthly split intervals are true" do + let(:bill_charges_monthly) { true } + let(:bill_fixed_charges_monthly) { true } + + it { is_expected.to be true } + end + + context "when charges billed in monthly split intervals is true" do + let(:bill_charges_monthly) { true } + let(:bill_fixed_charges_monthly) { false } + + it { is_expected.to be true } + end + + context "when fixed charges billed in monthly split intervals is true" do + let(:bill_charges_monthly) { false } + let(:bill_fixed_charges_monthly) { true } + + it { is_expected.to be true } + end + end + + describe "#yearly_amount_cents" do + subject(:method_call) { plan.yearly_amount_cents } + + let(:plan) { build_stubbed(:plan, interval:, amount_cents: 100) } + + context "when plan is yearly" do + let(:interval) { :yearly } + + it "returns the correct amount" do + expect(subject).to eq(100) + end + end + + context "when plan is monthly" do + let(:interval) { :monthly } + + it "returns the correct amount" do + expect(subject).to eq(1200) + end + end + + context "when plan is weekly" do + let(:interval) { :weekly } + + it "returns the correct amount" do + expect(subject).to eq(5200) + end + end + + context "when plan is quarterly" do + let(:interval) { :quarterly } + + it "returns the correct amount" do + expect(subject).to eq(400) + end + end + + context "when plan is semiannual" do + let(:interval) { :semiannual } + + it "returns the correct amount" do + expect(subject).to eq(200) + end + end + end + + describe "#invoice_name" do + subject(:plan_invoice_name) { plan.invoice_name } + + context "when invoice display name is blank" do + let(:plan) { build_stubbed(:plan, invoice_display_name: [nil, ""].sample) } + + it "returns name" do + expect(plan_invoice_name).to eq(plan.name) + end + end + + context "when invoice display name is present" do + let(:plan) { build_stubbed(:plan) } + + it "returns invoice display name" do + expect(plan_invoice_name).to eq(plan.invoice_display_name) + end + end + end + + describe "#active_subscriptions_count" do + let(:plan) { create(:plan) } + + it "returns the number of active subscriptions" do + create(:subscription, plan:) + overridden_plan = create(:plan, parent_id: plan.id) + create(:subscription, plan: overridden_plan) + + expect(plan.active_subscriptions_count).to eq(2) + end + end + + describe "#customers_count" do + let(:customer) { create(:customer) } + let(:plan) { create(:plan) } + + it "returns the number of impacted customers" do + create(:subscription, customer:, plan:) + overridden_plan = create(:plan, parent_id: plan.id) + customer2 = create(:customer, organization: plan.organization) + create(:subscription, customer: customer2, plan: overridden_plan) + + expect(plan.customers_count).to eq(2) + end + end + + describe "#draft_invoices_count" do + let(:plan) { create(:plan) } + + it "returns the number draft invoices" do + subscription = create(:subscription, plan:) + invoice = create(:invoice, :draft) + create(:invoice_subscription, invoice:, subscription:) + + overridden_plan = create(:plan, parent_id: plan.id) + subscription2 = create(:subscription, plan: overridden_plan) + invoice2 = create(:invoice, :draft) + create(:invoice_subscription, invoice: invoice2, subscription: subscription2) + + expect(plan.draft_invoices_count).to eq(2) + end + end + + describe "#pay_in_arrears?" do + context "when pay_in_advance is true" do + let(:plan) { build(:plan, :pay_in_advance) } + + it { expect(plan.pay_in_arrears?).to be(false) } + end + + context "when pay_in_advance is false" do + let(:plan) { build(:plan) } + + it { expect(plan.pay_in_arrears?).to be(true) } + end + end +end diff --git a/spec/models/presentation_breakdown_spec.rb b/spec/models/presentation_breakdown_spec.rb new file mode 100644 index 0000000..84ac516 --- /dev/null +++ b/spec/models/presentation_breakdown_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PresentationBreakdown do + subject { build(:presentation_breakdown) } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:fee) + end + end +end diff --git a/spec/models/pricing_unit_spec.rb b/spec/models/pricing_unit_spec.rb new file mode 100644 index 0000000..924a7cc --- /dev/null +++ b/spec/models/pricing_unit_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PricingUnit do + subject { build(:pricing_unit) } + + it { is_expected.to belong_to(:organization) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_presence_of(:code) } + it { is_expected.to validate_presence_of(:short_name) } + it { is_expected.to validate_length_of(:description).is_at_most(600) } + it { is_expected.to validate_length_of(:short_name).is_at_most(3) } + it { is_expected.to validate_uniqueness_of(:code).scoped_to(:organization_id) } + + describe "#exponent" do + subject { pricing_unit.exponent } + + let(:pricing_unit) { build_stubbed(:pricing_unit) } + + it "returns 2" do + expect(subject).to eq(2) + end + end + + describe "#subunit_to_unit" do + subject { pricing_unit.subunit_to_unit } + + let(:pricing_unit) { build_stubbed(:pricing_unit) } + + it "returns 10 raised to the power of exponent" do + expect(subject).to eq(100) + end + end +end diff --git a/spec/models/pricing_unit_usage_spec.rb b/spec/models/pricing_unit_usage_spec.rb new file mode 100644 index 0000000..e8d97b9 --- /dev/null +++ b/spec/models/pricing_unit_usage_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PricingUnitUsage do + subject { build(:pricing_unit_usage) } + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:fee) } + it { is_expected.to belong_to(:pricing_unit) } + + it { is_expected.to validate_presence_of(:short_name) } + it { is_expected.to validate_presence_of(:conversion_rate) } + it { is_expected.to validate_numericality_of(:conversion_rate).is_greater_than(0) } + + describe ".build_from_fiat_amounts" do + subject { described_class.build_from_fiat_amounts(amount:, unit_amount:, applied_pricing_unit:) } + + let(:pricing_unit) { create(:pricing_unit) } + let(:applied_pricing_unit) { create(:applied_pricing_unit, pricing_unit:, conversion_rate: 3.0150695) } + let(:amount) { 10655.243249 } + let(:unit_amount) { 5.5423123 } + + let(:expected_attributes) do + { + organization: pricing_unit.organization, + pricing_unit:, + short_name: pricing_unit.short_name, + conversion_rate: applied_pricing_unit.conversion_rate, + amount_cents: 1065524, + precise_amount_cents: 1065524.3249, + unit_amount_cents: 554, + precise_unit_amount: 5.5423123 + } + end + + it "builds a new pricing unit usage with normalized amounts" do + expect(subject) + .to be_a(described_class) + .and be_new_record + .and have_attributes(expected_attributes) + end + end + + describe "#to_fiat_currency_cents" do + subject { pricing_unit_usage.to_fiat_currency_cents(fiat_currency) } + + let(:pricing_unit_usage) do + build( + :pricing_unit_usage, + amount_cents: 1065524, + precise_amount_cents: 1065524.3249, + unit_amount_cents: 550, + conversion_rate: 0.0075 + ) + end + + let(:fiat_currency) { Money::Currency.new("USD") } + + it "returns a hash with converted amounts" do + expect(subject).to be_a(Hash) + expect(subject[:amount_cents]).to eq(7991) + expect(subject[:precise_amount_cents]).to eq(7991.43) + expect(subject[:unit_amount_cents]).to eq(4.125) + expect(subject[:precise_unit_amount]).to eq(0.04125) + end + end +end diff --git a/spec/models/quote_owner_spec.rb b/spec/models/quote_owner_spec.rb new file mode 100644 index 0000000..ad5669f --- /dev/null +++ b/spec/models/quote_owner_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuoteOwner do + subject(:quote_owner) { create(:quote_owner) } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:quote) + expect(subject).to belong_to(:user) + end + end +end diff --git a/spec/models/quote_spec.rb b/spec/models/quote_spec.rb new file mode 100644 index 0000000..32e272a --- /dev/null +++ b/spec/models/quote_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Quote do + subject(:quote) { create(:quote) } + + describe "enums" do + it do + expect(subject).to define_enum_for(:order_type) + .backed_by_column_of_type(:enum) + .with_values( + subscription_creation: "subscription_creation", + subscription_amendment: "subscription_amendment", + one_off: "one_off" + ) + .without_instance_methods + .validating(allowing_nil: false) + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:customer) + expect(subject).to belong_to(:subscription).optional + expect(subject).to have_many(:quote_owners).dependent(:destroy) + expect(subject).to have_many(:owners).through(:quote_owners) + expect(subject).to have_many(:versions).class_name("QuoteVersion").order(sequential_id: :desc) + expect(subject).to have_one(:current_version).class_name("QuoteVersion").order(sequential_id: :desc) + end + end + + describe "validations" do + describe "subscription_id" do + it "requires subscription_id when order_type is subscription_amendment" do + organization = create(:organization) + customer = create(:customer, organization:) + quote = build(:quote, organization:, customer:, order_type: :subscription_amendment, subscription: nil) + expect(quote).not_to be_valid + quote.subscription = create(:subscription, organization:, customer:) + expect(quote).to be_valid + end + + it "does not require subscription_id when order_type is subscription_creation" do + quote = build(:quote, order_type: :subscription_creation, subscription: nil) + expect(quote).to be_valid + end + + it "does not require subscription_id when order_type is one_off" do + quote = build(:quote, order_type: :one_off, subscription: nil) + expect(quote).to be_valid + end + end + end + + describe "sequencing" do + it "assigns sequential ids per organization" do + organization = create(:organization) + customer = create(:customer, organization:) + first = create(:quote, organization:, customer:, sequential_id: nil) + second = create(:quote, organization:, customer:, sequential_id: nil) + expect([first.sequential_id, second.sequential_id]).to eq([1, 2]) + end + + it "scopes the sequence per organization" do + org_a = create(:organization) + org_b = create(:organization) + a1 = create(:quote, organization: org_a, customer: create(:customer, organization: org_a), sequential_id: nil) + b1 = create(:quote, organization: org_b, customer: create(:customer, organization: org_b), sequential_id: nil) + expect([a1.sequential_id, b1.sequential_id]).to eq([1, 1]) + end + end + + describe "ensure_number callback" do + it "assigns a formatted number when sequential_id and created_at are present" do + quote = create(:quote, sequential_id: 123, number: nil, created_at: Time.zone.local(2020, 1, 2)) + expect(quote.number).to eq("QT-2020-0123") + end + + it "uses the current year when created_at is blank on save" do + organization = create(:organization) + customer = create(:customer, organization:) + travel_to(Time.zone.local(2026, 6, 1)) do + quote = build(:quote, organization:, customer:, sequential_id: 7, number: nil, created_at: nil, order_type: :one_off) + quote.save! + expect(quote.number).to eq("QT-2026-0007") + end + end + + it "preserves an explicitly assigned number" do + quote = create(:quote, sequential_id: 1, number: "QT-CUSTOM-0001") + expect(quote.number).to eq("QT-CUSTOM-0001") + end + end +end diff --git a/spec/models/quote_version_spec.rb b/spec/models/quote_version_spec.rb new file mode 100644 index 0000000..390883d --- /dev/null +++ b/spec/models/quote_version_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuoteVersion do + subject(:quote_version) { create(:quote_version) } + + describe "enums" do + it do + expect(subject).to define_enum_for(:status) + .backed_by_column_of_type(:enum) + .with_values(draft: "draft", approved: "approved", voided: "voided") + .with_default(:draft) + .validating(allowing_nil: false) + + expect(subject).to define_enum_for(:void_reason) + .backed_by_column_of_type(:enum) + .with_values( + manual: "manual", + superseded: "superseded", + cascade_of_expired: "cascade_of_expired", + cascade_of_voided: "cascade_of_voided" + ) + .without_instance_methods + .validating(allowing_nil: true) + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:quote) + end + end + + describe "validations" do + it "is valid by default" do + expect(build(:quote_version)).to be_valid + end + + describe "share_token" do + it "is required for draft and approved statuses on update" do + draft = build(:quote_version, status: :draft, share_token: nil) + expect(draft.valid?(:update)).to be false + + approved = build(:quote_version, status: :approved, share_token: nil, approved_at: Time.current) + expect(approved.valid?(:update)).to be false + end + end + + describe "void_reason and voided_at" do + it "are required when status is voided" do + quote_version = build(:quote_version, status: :voided, void_reason: nil, voided_at: nil) + expect(quote_version).not_to be_valid + + quote_version.void_reason = :manual + quote_version.voided_at = Time.current + expect(quote_version).to be_valid + end + end + + describe "approved_at" do + it "is required when status is approved" do + quote_version = build(:quote_version, status: :approved, approved_at: nil) + expect(quote_version).not_to be_valid + end + + it "is allowed to be nil when status is draft" do + quote_version = build(:quote_version, status: :draft, approved_at: nil) + expect(quote_version).to be_valid + end + end + end + + describe "sequencing" do + it "assigns sequential ids per quote" do + quote = create(:quote) + v1 = create(:quote_version, :voided, quote:, organization: quote.organization, sequential_id: nil) + v2 = create(:quote_version, quote:, organization: quote.organization, sequential_id: nil) + expect([v1.sequential_id, v2.sequential_id]).to eq([1, 2]) + end + end + + describe "ensure_share_token callback" do + it "generates a share_token for draft versions" do + quote_version = create(:quote_version, status: :draft, share_token: nil) + expect(quote_version.share_token).to be_present + end + + it "does not generate a share_token for voided versions" do + quote_version = create(:quote_version, :voided) + expect(quote_version.share_token).to be_nil + end + + it "preserves an explicitly assigned share_token" do + token = SecureRandom.uuid + quote_version = create(:quote_version, status: :draft, share_token: token) + expect(quote_version.share_token).to eq(token) + end + end + + describe "#version" do + it "is an alias for sequential_id" do + quote_version = build(:quote_version, sequential_id: 42) + expect(quote_version.version).to eq(42) + end + end +end diff --git a/spec/models/recurring_transaction_rule/applied_invoice_custom_section_spec.rb b/spec/models/recurring_transaction_rule/applied_invoice_custom_section_spec.rb new file mode 100644 index 0000000..8c53282 --- /dev/null +++ b/spec/models/recurring_transaction_rule/applied_invoice_custom_section_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe RecurringTransactionRule::AppliedInvoiceCustomSection do + subject(:applied_invoice_custom_section) do + create(:recurring_rule_applied_invoice_custom_section) + end + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:recurring_transaction_rule) } + it { is_expected.to belong_to(:invoice_custom_section) } +end diff --git a/spec/models/recurring_transaction_rule_spec.rb b/spec/models/recurring_transaction_rule_spec.rb new file mode 100644 index 0000000..bbc2346 --- /dev/null +++ b/spec/models/recurring_transaction_rule_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe RecurringTransactionRule do + describe "associations" do + it { is_expected.to belong_to(:wallet) } + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:applied_invoice_custom_sections).class_name("RecurringTransactionRule::AppliedInvoiceCustomSection").dependent(:destroy) } + it { is_expected.to have_many(:selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } + end + + describe "enums" do + it "defines expected enum values" do + expect(described_class.defined_enums).to include( + "interval" => hash_including("weekly", "monthly", "quarterly", "yearly", "semiannual"), + "method" => hash_including("fixed", "target"), + "trigger" => hash_including("interval", "threshold"), + "status" => hash_including("active", "terminated") + ) + end + end + + describe "validations" do + it { is_expected.to validate_length_of(:transaction_name).is_at_least(1).is_at_most(255).allow_nil } + end + + describe "scopes" do + let!(:active_rule) { create(:recurring_transaction_rule, status: :active, expiration_at: nil) } + let!(:future_rule) { create(:recurring_transaction_rule, status: :active, expiration_at: 1.day.from_now) } + let!(:expired_rule) { create(:recurring_transaction_rule, status: :active, expiration_at: 1.day.ago) } + let!(:terminated_rule) { create(:recurring_transaction_rule, status: :terminated, expiration_at: 1.day.ago) } + + it "returns correct records for active, eligible_for_termination, and expired scopes" do + expect(described_class.active).to match_array([active_rule, future_rule]) + expect(described_class.eligible_for_termination).to match_array([expired_rule]) + expect(described_class.expired).to match_array([expired_rule, terminated_rule]) + end + end + + describe "#currently_active?" do + it "returns true for active rules with no expiration" do + rule = build_stubbed(:recurring_transaction_rule, status: :active, expiration_at: nil) + expect(rule.currently_active?).to be true + end + + it "returns true for active rules expiring in the future" do + rule = build_stubbed(:recurring_transaction_rule, status: :active, expiration_at: 1.day.from_now) + expect(rule.currently_active?).to be true + end + + it "returns false for active rules whose expiration has passed" do + rule = build_stubbed(:recurring_transaction_rule, status: :active, expiration_at: 1.day.ago) + expect(rule.currently_active?).to be false + end + + it "returns false for terminated rules" do + rule = build_stubbed(:recurring_transaction_rule, status: :terminated, expiration_at: nil) + expect(rule.currently_active?).to be false + end + end + + describe "#mark_as_terminated!" do + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, status: :active) } + + it "marks the rule as terminated" do + expect { recurring_transaction_rule.mark_as_terminated! } + .to change(recurring_transaction_rule, :status) + .from("active").to("terminated") + end + end + + describe "#apply_min_top_up_limits" do + subject { rule.apply_min_top_up_limits(credit_amount:) } + + let(:rule) { create(:recurring_transaction_rule, wallet:, ignore_paid_top_up_limits:) } + let(:wallet) { create(:wallet, paid_top_up_min_amount_cents: 10_00, paid_top_up_max_amount_cents: 20_00) } + let(:credit_amount) { 5 } + + context "when recurring transaction rule ignores paid top up limits" do + let(:ignore_paid_top_up_limits) { true } + + it "returns not changed value" do + expect(subject).to eq credit_amount + end + end + + context "when recurring transaction rule does not ignore paid top up limits" do + let(:ignore_paid_top_up_limits) { false } + + it "returns normalized to wallet limits value" do + expect(subject).to eq 10 + end + + context "when this is no minimum" do + let(:wallet) { create(:wallet, paid_top_up_min_amount_cents: nil) } + let(:credit_amount) { 5 } + + it "returns the credit amounts" do + expect(subject).to eq 5 + end + end + + context "when credit amount is lower than wallet min limit" do + let(:credit_amount) { 5 } + + it "returns wallet minimum" do + expect(subject).to eq 10 + end + end + + context "when credit amount is greater than wallet max limit" do + let(:credit_amount) { 25 } + + it "returns credit amount anyway" do + expect(subject).to eq 25 + end + end + end + end + + describe "#compute_granted_credits" do + subject { rule.compute_granted_credits } + + let(:rule) { create(:recurring_transaction_rule, method:) } + + context "when method is fixed" do + let(:method) { :fixed } + + it "returns granted credits specified on rule" do + expect(subject).to eq rule.granted_credits + end + end + + context "when method is target" do + let(:method) { :target } + + it "returns zero" do + expect(subject).to eq 0.0 + end + end + end + + describe "#compute_paid_credits" do + subject { rule.compute_paid_credits(ongoing_balance:) } + + let(:rule) { create(:recurring_transaction_rule, wallet:, method:, target_ongoing_balance:) } + let(:ongoing_balance) { 100.0 } + let(:wallet) { create(:wallet, rate_amount: 0.5, paid_top_up_min_amount_cents: 25_00) } + + context "when method is fixed" do + let(:method) { :fixed } + let(:target_ongoing_balance) { 100.0 } + + it "returns paid credits specified on rule" do + expect(subject).to eq rule.paid_credits + end + end + + context "when method is target" do + let(:method) { :target } + + context "when ongoing balance is greater than target balance" do + let(:target_ongoing_balance) { 99.0 } + + it "returns zero" do + expect(subject).to eq 0.0 + end + end + + context "when ongoing balance equals to target balance" do + let(:target_ongoing_balance) { 100.0 } + + it "returns zero" do + expect(subject).to eq 0.0 + end + end + + context "when ongoing balance is smaller than target balance" do + let(:target_ongoing_balance) { 101.0 } + + it "returns the gag with applied limits from wallet" do + expect(subject).to eq 50.0 # min amount 25 x 2 because of wallet's rate 0.5 + end + end + end + end +end diff --git a/spec/models/refund_spec.rb b/spec/models/refund_spec.rb new file mode 100644 index 0000000..90e3700 --- /dev/null +++ b/spec/models/refund_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Refund do + subject(:refund) { build(:refund) } + + describe "associations" do + it { is_expected.to belong_to(:organization) } + end +end diff --git a/spec/models/role_spec.rb b/spec/models/role_spec.rb new file mode 100644 index 0000000..637ab1d --- /dev/null +++ b/spec/models/role_spec.rb @@ -0,0 +1,271 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Role do + subject(:role) { build(:role) } + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it { is_expected.to belong_to(:organization).optional } + it { is_expected.to have_many(:membership_roles) } + it { is_expected.to have_many(:memberships).through(:membership_roles) } + end + + describe "callbacks" do + it "normalizes name before validation" do + role = build(:role, name: " Some Role ") + role.valid? + expect(role.name).to eq("Some Role") + end + end + + describe "scopes" do + describe ".with_code" do + let(:organization) { create(:organization) } + let!(:developer) { create(:role, code: :developer, organization:) } + + before { create(:role, code: :designer, organization:) } + + it "finds role by exact code" do + expect(described_class.with_code("developer")).to contain_exactly(developer) + end + + it "returns empty when code does not match" do + expect(described_class.with_code("nonexistent")).to be_empty + end + end + + describe ".with_organization" do + let(:organization) { create(:organization) } + let(:other_organization) { create(:organization) } + let!(:system_role) { create(:role, :admin) } + let!(:org_role) { create(:role, organization:) } + + before { create(:role, organization: other_organization) } + + it "returns system roles (organization_id is nil) and roles for given organization" do + result = described_class.with_organization(organization.id) + + expect(result).to contain_exactly(system_role, org_role) + end + + it "returns only system roles when organization_id is nil" do + result = described_class.with_organization(nil) + + expect(result).to contain_exactly(system_role) + end + end + end + + describe "validations" do + context "when role is custom" do + let(:role) { build(:role, :custom) } + + it { is_expected.to be_valid } + + it "is invalid without code" do + role.code = nil + expect(role).not_to be_valid + end + + it "is invalid with empty code" do + role.code = "" + expect(role).not_to be_valid + end + + it "is invalid with code longer than 100 characters" do + role.code = "a" * 101 + expect(role).not_to be_valid + end + + it "is invalid with code containing invalid characters" do + %w[UPPER Code-with-dash code.with.dot code\ with\ space].each do |code| + role.code = code + expect(role).not_to be_valid + expect(role.errors[:code]).to include("value_is_invalid") + end + end + + it "is invalid when code exists in the same organization" do + create(:role, :custom, code: role.code, organization: role.organization) + expect(role).not_to be_valid + end + + it "is valid when code exists in another organization" do + create(:role, :custom, code: role.code) + expect(role).to be_valid + end + + it "is invalid with reserved codes" do + %w[admin finance manager].each do |code| + role.code = code + expect(role).not_to be_valid + end + end + + it "is invalid without name" do + role.name = nil + expect(role).not_to be_valid + end + + it "is invalid with empty name" do + role.name = "" + expect(role).not_to be_valid + end + + it "is invalid with name longer than 100 characters" do + role.name = "a" * 101 + expect(role).not_to be_valid + end + + it "is invalid with description longer than 255 characters" do + role.description = "a" * 256 + expect(role).not_to be_valid + end + + it "is invalid with empty permissions" do + role.permissions = [] + expect(role).not_to be_valid + end + end + end + + describe "database constraints" do + let!(:organization) { create(:organization) } + + it "rejects empty name" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'test_code', '', ARRAY['test:view']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects admin with permissions" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, admin, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'super_admin', 'SuperAdmin', true, ARRAY['test:view']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects predefined roles with permissions" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'accountant', 'Accountant', ARRAY['test:view']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects custom roles without permissions" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, organization_id, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'accountant', 'Accountant', '#{organization.id}', '{}'::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects permissions containing empty strings" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, organization_id, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'bad_role', 'BadRole', '#{organization.id}', ARRAY['test:view', '']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects permission starting with colon" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, organization_id, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'bad_role', 'BadRole', '#{organization.id}', ARRAY[':view']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects permission ending with colon" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, organization_id, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'bad_role', 'BadRole', '#{organization.id}', ARRAY['test:']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects permission with double colon" do + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, organization_id, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'bad_role', 'BadRole', '#{organization.id}', ARRAY['test::view']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "allows only one active admin role" do + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, admin, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'first_admin', 'FirstAdmin', true, ARRAY[]::text[], now(), now()) + SQL + + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, admin, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'second_admin', 'SecondAdmin', true, ARRAY[]::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "rejects duplicate codes within same organization" do + role = create(:role, code: "developer") + + expect { + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, organization_id, permissions, created_at, updated_at) + VALUES ('#{SecureRandom.uuid}', 'developer', 'Another Developer', '#{role.organization_id}', ARRAY['test:view']::text[], now(), now()) + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "prevents modification of predefined role" do + admin_id = SecureRandom.uuid + described_class.connection.execute(<<~SQL) + INSERT INTO roles (id, code, name, admin, permissions, created_at, updated_at) + VALUES ('#{admin_id}', 'predefined_admin', 'PredefinedAdmin', true, ARRAY[]::text[], now(), now()) + SQL + + expect { + described_class.connection.execute(<<~SQL) + UPDATE roles SET description = 'Modified' WHERE id = '#{admin_id}' + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "prevents moving custom role to another organization" do + role = create(:role, :custom, organization:) + other_organization = create(:organization) + + expect { + described_class.connection.execute(<<~SQL) + UPDATE roles SET organization_id = '#{other_organization.id}' WHERE id = '#{role.id}' + SQL + }.to raise_error(ActiveRecord::StatementInvalid) + end + + it "deduplicates permissions" do + role = create(:role, :custom, organization:, permissions: %w[b:action a:action]) + + described_class.connection.execute(<<~SQL) + UPDATE roles SET permissions = ARRAY['z:action', 'a:action', 'z:action']::text[] WHERE id = '#{role.id}' + SQL + + role.reload + expect(role.permissions).to eq(%w[a:action z:action]) + end + end +end diff --git a/spec/models/subscription/activation_rule/payment_spec.rb b/spec/models/subscription/activation_rule/payment_spec.rb new file mode 100644 index 0000000..aa98a7d --- /dev/null +++ b/spec/models/subscription/activation_rule/payment_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscription::ActivationRule::Payment do + subject(:rule) { build(:subscription_activation_rule, subscription:, timeout_hours:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, :with_stripe_payment_provider, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + let(:timeout_hours) { 48 } + + describe "#applicable?" do + context "when plan is pay in advance and not in trial" do + let(:plan) { create(:plan, :pay_in_advance, organization:) } + + it "returns true" do + expect(rule.applicable?).to be(true) + end + end + + context "when plan is pay in arrears with no pay in advance fixed charges" do + it "returns false" do + expect(rule.applicable?).to be(false) + end + end + + context "when plan has a trial period and is pay in advance" do + let(:plan) { create(:plan, :pay_in_advance, trial_period: 30, organization:) } + + it "returns false" do + expect(rule.applicable?).to be(false) + end + end + + context "when plan has a trial period but has pay in advance fixed charges" do + let(:plan) { create(:plan, trial_period: 30, organization:) } + + before { create(:fixed_charge, :pay_in_advance, plan:) } + + it "returns true" do + expect(rule.applicable?).to be(true) + end + end + + context "when plan has pay in advance fixed charges" do + before { create(:fixed_charge, :pay_in_advance, plan:) } + + it "returns true" do + expect(rule.applicable?).to be(true) + end + end + end +end diff --git a/spec/models/subscription/activation_rule_spec.rb b/spec/models/subscription/activation_rule_spec.rb new file mode 100644 index 0000000..d4fd24b --- /dev/null +++ b/spec/models/subscription/activation_rule_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscription::ActivationRule do + subject(:activation_rule) { create(:subscription_activation_rule) } + + describe "enums" do + it do + expect(subject).to define_enum_for(:status) + .backed_by_column_of_type(:enum) + .validating + .with_values( + inactive: "inactive", + pending: "pending", + satisfied: "satisfied", + declined: "declined", + failed: "failed", + expired: "expired", + not_applicable: "not_applicable" + ) + expect(subject).to define_enum_for(:type) + .backed_by_column_of_type(:enum) + .validating + .with_values(payment: "payment") + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:subscription) + expect(subject).to belong_to(:organization) + end + end + + describe "validations" do + it do + expect(subject).to validate_presence_of(:type) + expect(subject).to validate_inclusion_of(:type).in_array(Subscription::ActivationRule::STI_MAPPING.keys) + end + end + + describe "Scopes" do + describe ".fulfilled" do + let(:satisfied_rule) { create(:subscription_activation_rule, status: "satisfied") } + let(:not_applicable_rule) { create(:subscription_activation_rule, status: "not_applicable") } + + before do + satisfied_rule + not_applicable_rule + create(:subscription_activation_rule, status: "pending") + create(:subscription_activation_rule, status: "failed") + end + + it "returns only satisfied and not_applicable rules" do + expect(described_class.fulfilled).to match_array([satisfied_rule, not_applicable_rule]) + end + end + + describe ".rejected" do + let(:failed_rule) { create(:subscription_activation_rule, status: "failed") } + let(:expired_rule) { create(:subscription_activation_rule, status: "expired") } + let(:declined_rule) { create(:subscription_activation_rule, status: "declined") } + + before do + failed_rule + expired_rule + declined_rule + create(:subscription_activation_rule, status: "pending") + create(:subscription_activation_rule, status: "satisfied") + end + + it "returns only failed, expired, and declined rules" do + expect(described_class.rejected).to match_array([failed_rule, expired_rule, declined_rule]) + end + end + + describe ".expirable" do + let(:expirable_rule) { create(:subscription_activation_rule, status: "pending", expires_at: 1.hour.ago) } + + before do + expirable_rule + create(:subscription_activation_rule, status: "pending", expires_at: 1.hour.from_now) + create(:subscription_activation_rule, status: "inactive", expires_at: 1.hour.ago) + end + + it "returns only pending rules past their expiry" do + expect(described_class.expirable).to eq([expirable_rule]) + end + end + end + + describe ".find_sti_class" do + it "resolves payment to Subscription::ActivationRule::Payment" do + expect(described_class.find_sti_class("payment")).to eq(Subscription::ActivationRule::Payment) + end + + it "raises KeyError for unknown type" do + expect { described_class.find_sti_class("unknown") }.to raise_error(KeyError) + end + end + + describe ".sti_name" do + it "returns payment for Subscription::ActivationRule::Payment" do + expect(Subscription::ActivationRule::Payment.sti_name).to eq("payment") + end + end + + describe "#applicable?" do + it "raises NotImplementedError on the base class" do + rule = described_class.new + expect { rule.applicable? }.to raise_error(NotImplementedError) + end + end + + describe "#evaluate!" do + it "calls the type-specific EvaluateService" do + allow(Subscriptions::ActivationRules::Payment::EvaluateService).to receive(:call!) + + activation_rule.evaluate! + + expect(Subscriptions::ActivationRules::Payment::EvaluateService) + .to have_received(:call!).with(rule: activation_rule) + end + end +end diff --git a/spec/models/subscription/applied_invoice_custom_section_spec.rb b/spec/models/subscription/applied_invoice_custom_section_spec.rb new file mode 100644 index 0000000..e14b6ea --- /dev/null +++ b/spec/models/subscription/applied_invoice_custom_section_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscription::AppliedInvoiceCustomSection do + subject(:applied_invoice_custom_section) do + create(:subscription_applied_invoice_custom_section) + end + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:subscription) } + it { is_expected.to belong_to(:invoice_custom_section) } +end diff --git a/spec/models/subscription_spec.rb b/spec/models/subscription_spec.rb new file mode 100644 index 0000000..190d5a4 --- /dev/null +++ b/spec/models/subscription_spec.rb @@ -0,0 +1,1060 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscription do + subject(:subscription) { create(:subscription, plan:) } + + let(:plan) { create(:plan) } + + it_behaves_like "paper_trail traceable" + + describe "enums" do + it do + expect(subject).to define_enum_for(:status).with_values( + pending: 0, + active: 1, + terminated: 2, + canceled: 3, + incomplete: 4 + ) + expect(subject).to define_enum_for(:billing_time).with_values( + calendar: 0, + anniversary: 1 + ) + expect(subject).to define_enum_for(:on_termination_credit_note) + .backed_by_column_of_type(:enum) + .with_values(credit: "credit", skip: "skip", refund: "refund", offset: "offset") + .with_prefix(:on_termination_credit_note) + expect(subject).to define_enum_for(:on_termination_invoice) + .backed_by_column_of_type(:enum) + .with_values(generate: "generate", skip: "skip") + .with_prefix(:on_termination_invoice) + expect(subject).to define_enum_for(:cancelation_reason) + .backed_by_column_of_type(:enum) + .with_values(payment_failed: "payment_failed", timeout: "timeout") + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:customer) + expect(subject).to belong_to(:plan) + expect(subject).to belong_to(:previous_subscription).optional + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:billing_entity).optional + expect(subject).to have_many(:applied_invoice_custom_sections).class_name("Subscription::AppliedInvoiceCustomSection").dependent(:destroy) + expect(subject).to have_many(:selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) + expect(subject).to have_many(:next_subscriptions).class_name("Subscription").with_foreign_key(:previous_subscription_id) + expect(subject).to have_many(:events) + expect(subject).to have_many(:invoice_subscriptions) + expect(subject).to have_many(:invoices).through(:invoice_subscriptions) + expect(subject).to have_many(:integration_resources) + expect(subject).to have_many(:fees) + expect(subject).to have_many(:daily_usages) + expect(subject).to have_many(:usage_thresholds) + expect(subject).to have_many(:fixed_charges).through(:plan) + expect(subject).to have_many(:fixed_charge_events) + expect(subject).to have_many(:add_ons).through(:fixed_charges) + expect(subject).to have_one(:lifetime_usage).autosave(true) + expect(subject).to have_one(:subscription_activity).class_name("UsageMonitoring::SubscriptionActivity") + expect(subject).to have_many(:entitlements).class_name("Entitlement::Entitlement") + expect(subject).to have_many(:entitlement_removals).class_name("Entitlement::SubscriptionFeatureRemoval") + expect(subject).to have_many(:alerts).class_name("UsageMonitoring::Alert") + expect(subject).to have_many(:activation_rules).class_name("Subscription::ActivationRule") + end + end + + describe "Clickhouse associations", clickhouse: true do + it do + expect(subject).to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") + end + end + + describe "#billing_entity" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + context "when subscription has a billing_entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:subscription) { create(:subscription, customer:, billing_entity:) } + + it "returns the subscription billing_entity" do + expect(subscription.billing_entity).to eq(billing_entity) + end + end + + context "when subscription does not have a billing_entity" do + let(:subscription) { create(:subscription, customer:, billing_entity: nil) } + + it "falls back to the customer billing_entity" do + expect(subscription.billing_entity).to eq(customer.billing_entity) + end + end + end + + describe "Scopes" do + describe ".starting_in_the_future" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let!(:pending_subscription_without_previous) { create(:subscription, :pending, customer:) } + + before do + create(:subscription, :with_previous_subscription, :pending, customer:) + create(:subscription, :active, customer:) + end + + it "returns only pending subscriptions without previous subscription" do + result = described_class.starting_in_the_future + + expect(result).to match_array([pending_subscription_without_previous]) + end + end + + describe ".expirable" do + let(:expirable_subscription) do + create(:subscription, :incomplete).tap do |sub| + create(:subscription_activation_rule, subscription: sub, status: :pending, expires_at: 1.hour.ago) + end + end + + before do + expirable_subscription + + # incomplete with future expiry — not expirable yet + sub = create(:subscription, :incomplete) + create(:subscription_activation_rule, subscription: sub, status: :pending, expires_at: 1.hour.from_now) + + # incomplete with no expiry — not expirable + sub2 = create(:subscription, :incomplete) + create(:subscription_activation_rule, subscription: sub2, status: :inactive) + + # active subscription — not expirable + create(:subscription) + end + + it "returns only incomplete subscriptions with expirable activation rules" do + expect(described_class.expirable).to match_array([expirable_subscription]) + end + end + end + + describe "validations" do + it do + expect(subject).to validate_presence_of(:external_id) + expect(subject).to validate_presence_of(:billing_time) + end + + describe "on_termination_credit_note validation" do + context "when plan is pay in arrears" do + subject(:subscription) { build(:subscription) } + + it { is_expected.to validate_absence_of(:on_termination_credit_note) } + end + + context "when plan is pay in advance" do + subject(:subscription) { build(:subscription, plan: create(:plan, :pay_in_advance)) } + + it { is_expected.to be_valid } + end + end + + describe "external_id validation" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:external_id) { SecureRandom.uuid } + let(:subscription) do + create( + :subscription, + plan:, + customer: create(:customer, organization:) + ) + end + + let(:new_subscription) do + build( + :subscription, + plan:, + external_id:, + customer: create(:customer, organization:) + ) + end + + before { subscription } + + context "when external_id is unique" do + it "does not raise validation error if external_id is unique" do + expect(new_subscription).to be_valid + end + end + + context "when external_id is NOT unique" do + let(:external_id) { subscription.external_id } + + it "raises validation error" do + expect(new_subscription).not_to be_valid + end + end + + context "when external_id is taken by an incomplete subscription" do + let(:external_id) { subscription.external_id } + + before { subscription.incomplete! } + + it "allows an active subscription with the same external_id" do + expect(new_subscription).to be_valid + end + end + + context "when an active and incomplete subscription both exist with the same external_id" do + let(:external_id) { subscription.external_id } + + before do + create( + :subscription, + plan:, + status: :incomplete, + started_at: Time.current, + external_id:, + customer: create(:customer, organization:) + ) + end + + it "rejects a second active subscription" do + expect(new_subscription).not_to be_valid + end + end + + context "when creating an incomplete subscription and one already exists" do + let(:external_id) { subscription.external_id } + + before { subscription.incomplete! } + + it "rejects a second incomplete subscription" do + incomplete_sub = build( + :subscription, + plan:, + status: :incomplete, + started_at: Time.current, + external_id:, + customer: create(:customer, organization:) + ) + expect(incomplete_sub).not_to be_valid + end + end + end + + describe "started_at validation" do + context "when status is active" do + it "is valid without started_at" do + sub = build(:subscription, started_at: nil) + expect(sub).to be_valid + end + end + + context "when status is incomplete" do + it "is invalid without started_at" do + sub = build(:subscription, :incomplete, started_at: nil) + expect(sub).not_to be_valid + expect(sub.errors[:started_at]).to be_present + end + end + + context "when status is pending" do + it "is valid without started_at" do + sub = build(:subscription, :pending) + expect(sub).to be_valid + end + end + end + end + + describe "#mark_as_incomplete!" do + let(:subscription) { create(:subscription, :pending) } + + it "sets started_at and changes status to incomplete" do + freeze_time do + subscription.mark_as_incomplete! + + expect(subscription.status).to eq("incomplete") + expect(subscription.started_at).to eq(Time.current) + expect(subscription.activated_at).to be_nil + end + end + end + + describe "#pending_rules?" do + subject(:pending_rules?) { subscription.pending_rules? } + + let(:subscription) { create(:subscription) } + + context "when there are pending activation rules" do + before { create(:subscription_activation_rule, subscription:, status: :pending) } + + it { is_expected.to be(true) } + end + + context "when there are no pending activation rules" do + before do + create(:subscription_activation_rule, subscription:, status: :inactive) + end + + it { is_expected.to be(false) } + end + + context "when activation rules are satisfied" do + before do + create(:subscription_activation_rule, subscription:, status: :satisfied) + end + + it { is_expected.to be(false) } + end + end + + describe "#gated?" do + subject(:gated?) { subscription.gated? } + + context "when incomplete with pending rules" do + let(:subscription) { create(:subscription, :incomplete) } + + before { create(:subscription_activation_rule, subscription:, status: :pending) } + + it { is_expected.to be(true) } + end + + context "when active with pending rules" do + let(:subscription) { create(:subscription) } + + before { create(:subscription_activation_rule, subscription:, status: :pending) } + + it { is_expected.to be(false) } + end + + context "when incomplete without satisfied rules" do + let(:subscription) { create(:subscription, :incomplete) } + + before { create(:subscription_activation_rule, subscription:, status: :satisfied) } + + it { is_expected.to be(false) } + end + end + + describe "#payment_gated?" do + subject(:payment_gated?) { subscription.payment_gated? } + + context "when incomplete with pending payment rule" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}]) + end + + it { is_expected.to be(true) } + end + + context "when incomplete with satisfied payment rule" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "satisfied"}]) + end + + it { is_expected.to be(false) } + end + + context "when active with pending payment rule" do + let(:subscription) do + create(:subscription, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}]) + end + + it { is_expected.to be(false) } + end + + context "when pending with pending payment rule" do + let(:subscription) do + create(:subscription, :pending, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}]) + end + + it { is_expected.to be(false) } + end + + context "when incomplete with no activation rules" do + let(:subscription) { create(:subscription, :incomplete) } + + it { is_expected.to be(false) } + end + end + + describe "#upgraded?" do + let(:previous_subscription) { nil } + let(:subscription) do + create(:subscription, previous_subscription:, plan:) + end + + context "without next subscription" do + it { expect(subscription).not_to be_upgraded } + end + + context "with next subscription" do + let(:previous_plan) { create(:plan) } + let(:previous_subscription) do + create(:subscription, plan: previous_plan) + end + + before { subscription } + + it { expect(previous_subscription).to be_upgraded } + + context "when previous plan was more expensive" do + let(:previous_plan) do + create(:plan, amount_cents: plan.amount_cents + 10) + end + + it { expect(previous_subscription).not_to be_upgraded } + end + + context "when plans have different intervals" do + before do + previous_plan.update!(interval: "monthly") + plan.update!(interval: "yearly") + end + + it { expect(previous_subscription).not_to be_upgraded } + end + end + end + + describe "#downgraded?" do + let(:previous_subscription) { nil } + let(:plan) { create(:plan, amount_cents: 100) } + + let(:subscription) do + create(:subscription, previous_subscription:, plan:) + end + + context "without next subscription" do + it { expect(subscription).not_to be_downgraded } + end + + context "with next subscription" do + let(:previous_plan) { create(:plan, amount_cents: 200) } + let(:previous_subscription) do + create(:subscription, plan: previous_plan) + end + + before { subscription } + + it { expect(previous_subscription).to be_downgraded } + + context "when previous plan was less expensive" do + let(:previous_plan) do + create(:plan, amount_cents: plan.amount_cents - 10) + end + + it { expect(previous_subscription).not_to be_downgraded } + end + + context "when plans have different intervals" do + before do + previous_plan.update!(interval: "yearly") + plan.update!(interval: "monthly") + end + + it { expect(previous_subscription).not_to be_downgraded } + end + end + end + + describe "#trial_end_date" do + let(:plan) { create(:plan, trial_period: 3) } + + it "returns the trial end date" do + trial_end_date = subscription.trial_end_date + + expect(trial_end_date).to be_present + expect(trial_end_date).to eq(subscription.started_at.to_date + 3.days) + end + + context "when plan has no trial" do + let(:plan) { create(:plan) } + + it "returns nil" do + expect(subscription.trial_end_date).to be_nil + end + end + + context "with a previous subscription" do + let(:subscription) do + create( + :subscription, + previous_subscription:, + started_at: Time.zone.yesterday, + plan:, + external_id: "sub_id", + customer: previous_subscription.customer + ) + end + let(:previous_subscription) do + create(:subscription, started_at: Time.current.last_month, external_id: "sub_id", status: :terminated) + end + + it "takes previous subscription started_at into account" do + trial_end_date = subscription.trial_end_date + + expect(trial_end_date).to be_present + expect(trial_end_date).to eq(previous_subscription.started_at.to_date + 3.days) + end + end + end + + describe "#trial_end_datetime" do + let(:plan) { create(:plan, trial_period: 3) } + let(:started_at) { subscription.initial_started_at } + + it "returns the trial end datetime" do + trial_end_datetime = subscription.trial_end_datetime + + expect(trial_end_datetime).to be_present + expect(trial_end_datetime).to eq(started_at + 3.days) + end + + context "when plan has no trial" do + let(:plan) { create(:plan) } + + it "returns nil" do + expect(subscription.trial_end_datetime).to be_nil + end + end + + context "with a previous subscription" do + let(:subscription) do + create( + :subscription, + previous_subscription:, + started_at: Time.zone.yesterday, + plan:, + external_id: "sub_id", + customer: previous_subscription.customer + ) + end + let(:previous_subscription) do + create(:subscription, started_at: Time.current.last_month, external_id: "sub_id", status: :terminated) + end + + it "takes previous subscription started_at into account" do + trial_end_datetime = subscription.trial_end_datetime + + expect(trial_end_datetime).to be_present + expect(trial_end_datetime).to eq(started_at + 3.days) + end + end + end + + describe "#in_trial_period?" do + context "when plan has no trial" do + it { expect(subscription.in_trial_period?).to be false } + end + + context "when subscription is in trial" do + let(:subscription) { create(:subscription, plan:, started_at: 5.days.ago) } + let(:plan) { create(:plan, trial_period: 10) } + + it { expect(subscription.in_trial_period?).to be true } + end + + context "when subscription trial has ended" do + let(:subscription) { create(:subscription, plan:, started_at: 5.days.ago) } + let(:plan) { create(:plan, trial_period: 2) } + + it { expect(subscription.in_trial_period?).to be false } + end + end + + describe "#initial_started_at" do + let(:customer) { create(:customer) } + let(:subscription) do + create( + :subscription, + previous_subscription:, + started_at: Time.zone.yesterday, + external_id: "sub_id", + customer: + ) + end + + let(:previous_subscription) { nil } + + it "returns the subscription started_at" do + expect(subscription.initial_started_at).to eq(subscription.started_at) + end + + context "with a previous subscription" do + let(:previous_subscription) do + create( + :subscription, + started_at: Time.current.last_month, + status: :terminated, + external_id: "sub_id", + customer: + ) + end + + it "returns the previous subscription started_at" do + expect(subscription.initial_started_at.to_date).to eq(previous_subscription.started_at.to_date) + end + end + + context "with two previous subscriptions" do + let(:previous_subscription) do + create( + :subscription, + previous_subscription: initial_subscription, + started_at: Time.zone.yesterday, + external_id: "sub_id", + customer:, + status: :terminated + ) + end + + let(:initial_subscription) do + create( + :subscription, + started_at: Time.current.last_year, + external_id: "sub_id", + status: :terminated, + customer: + ) + end + + it "returns the initial subscription started_at" do + expect(subscription.initial_started_at.to_date).to eq(initial_subscription.started_at.to_date) + end + end + end + + describe "#downgrade_plan_date" do + let(:subscription) { create(:subscription) } + + context "without next subscription" do + it "returns nil" do + expect(subscription.downgrade_plan_date).to be_nil + end + end + + context "without pending next subscription" do + it "returns nil" do + create(:subscription, previous_subscription: subscription, status: :active) + expect(subscription.downgrade_plan_date).to be_nil + end + end + + it "returns the date when the plan will be downgraded" do + current_date = DateTime.parse("20 Jun 2022") + create(:subscription, previous_subscription: subscription, status: :pending) + + travel_to(current_date) do + expect(subscription.downgrade_plan_date).to eq(Date.parse("1 Jul 2022")) + end + end + end + + describe "#starting_in_the_future?" do + context "when subscription is active" do + let(:subscription) { create(:subscription) } + + it "returns false" do + expect(subscription.starting_in_the_future?).to be false + end + end + + context "when subscription is pending and starting in the future" do + let(:subscription) { create(:subscription, :pending) } + + it "returns true" do + expect(subscription.starting_in_the_future?).to be true + end + end + + context "when subscription is pending and downgraded" do + let(:old_subscription) { create(:subscription) } + let(:subscription) { create(:subscription, :pending, previous_subscription: old_subscription) } + + it "returns false" do + expect(subscription.starting_in_the_future?).to be false + end + end + end + + describe "#display_name" do + let(:subscription) { build(:subscription, name: subscription_name, plan:) } + let(:subscription_name) { "some_name" } + let(:plan) { create(:plan, name: "some_plan_name") } + + it { expect(subscription.display_name).to eq("some_name") } + + context "when name is empty" do + let(:subscription_name) { nil } + + it "returns the plan name" do + expect(subscription.display_name).to eq("some_plan_name") + end + end + end + + describe "#invoice_name" do + subject(:subscription_invoice_name) { subscription.invoice_name } + + let(:subscription) { build(:subscription, plan:, name:, organization: plan.organization) } + + context "when plan invoice display name is blank" do + let(:plan) { build_stubbed(:plan, invoice_display_name: [nil, ""].sample) } + + context "when subscription name is blank" do + let(:name) { [nil, ""].sample } + + it "returns plan name" do + expect(subscription_invoice_name).to eq(plan.name) + end + end + + context "when subscription name is present" do + let(:name) { Faker::TvShows::GameOfThrones.characters } + + it "returns subscription name" do + expect(subscription_invoice_name).to eq(subscription.name) + end + end + end + + context "when plan invoice display name is present" do + let(:plan) { build_stubbed(:plan) } + + context "when subscription name is blank" do + let(:name) { [nil, ""].sample } + + it "returns plan invoice display name" do + expect(subscription_invoice_name).to eq(plan.invoice_display_name) + end + end + + context "when subscription name is present" do + let(:name) { Faker::TvShows::GameOfThrones.characters } + + it "returns subscription name" do + expect(subscription_invoice_name).to eq(subscription.name) + end + end + end + end + + describe "#should_sync_hubspot_subscription??" do + subject(:method_call) { subscription.should_sync_hubspot_subscription? } + + let(:subscription) { create(:subscription, customer:) } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + context "without integration hubspot customer" do + it "returns false" do + expect(method_call).to eq(false) + end + end + + context "with integration hubspot customer" do + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration) { create(:hubspot_integration, organization:, sync_subscriptions:) } + + before { integration_customer } + + context "when sync subscriptions is true" do + let(:sync_subscriptions) { true } + + it "returns true" do + expect(method_call).to eq(true) + end + end + + context "when sync subscriptions is false" do + let(:sync_subscriptions) { false } + + it "returns false" do + expect(method_call).to eq(false) + end + end + end + end + + describe ".date_diff_with_timezone" do + let(:from_datetime) { Time.zone.parse("2023-08-31T23:10:00") } + let(:to_datetime) { Time.zone.parse("2023-09-30T22:59:59") } + let(:customer) { create(:customer, timezone:) } + let(:terminated_at) { nil } + let(:timezone) { "Europe/Paris" } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + terminated_at: + ) + end + + let(:result) do + subscription.date_diff_with_timezone(from_datetime, to_datetime) + end + + it "returns the number of days between the two datetime" do + expect(result).to eq(30) + end + + context "with terminated and upgraded subscription" do + let(:terminated_at) { Time.zone.parse("2023-09-30T22:59:59") } + let(:new_subscription) do + create( + :subscription, + plan:, + customer:, + previous_subscription_id: subscription.id + ) + end + + before do + subscription.terminated! + new_subscription + end + + it "takes the daylight saving time into account" do + expect(result).to eq(29) + end + end + end + + describe "#mark_as_active!" do + subject(:subscription) { create(:subscription, :pending) } + + it "changes the status to active and sets started_at and activated_at" do + freeze_time do + expect { subscription.mark_as_active! } + .to change(subscription, :status).from("pending").to("active") + + expect(subscription.started_at).to eq(Time.current) + expect(subscription.activated_at).to eq(Time.current) + expect(subscription.lifetime_usage).to be_present + end + end + + context "when subscription was incomplete (already has started_at)" do + subject(:subscription) { create(:subscription, :incomplete) } + + it "preserves started_at and sets activated_at" do + original_started_at = subscription.started_at + + freeze_time do + expect { subscription.mark_as_active! } + .to change(subscription, :status).from("incomplete").to("active") + + expect(subscription.started_at).to eq(original_started_at) + expect(subscription.activated_at).to eq(Time.current) + end + end + end + + context "with a previous subscription" do + subject(:subscription) { create(:subscription, :pending, previous_subscription:) } + + let(:previous_subscription) { create(:subscription, :terminated) } + let(:lifetime_usage) { create(:lifetime_usage, subscription: previous_subscription) } + + before { lifetime_usage } + + it "changes the status to active" do + expect { subscription.mark_as_active! } + .to change(subscription, :status).from("pending").to("active") + + expect(lifetime_usage.reload.subscription).to eq(subscription) + end + end + end + + describe "#terminated_at?" do + context "when subscription is terminated before the timestamp" do + it "returns true" do + subscription = build(:subscription, :terminated, terminated_at: 2.days.ago) + expect(subscription.terminated_at?(1.day.ago)).to be true + end + end + + context "when subscription is terminated after the timestamp" do + it "returns false" do + subscription = build(:subscription, :terminated, terminated_at: 1.day.from_now) + expect(subscription.terminated_at?(2.days.ago)).to be false + end + end + + context "when subscription is not terminated" do + it "returns false" do + subscription = build(:subscription) + expect(subscription.terminated_at?(1.day.ago)).to be false + end + end + end + + describe "#adjusted_boundaries" do + let(:timestamp) { Time.zone.parse("30 Mar 2024") } + let(:billing_date) { Time.zone.parse("15 May 2024") } + let(:date_service) { Subscriptions::DatesService.new_instance(subscription, billing_date) } + let(:plan) { create(:plan, amount_cents: 100) } + let(:status) { "active" } + let(:terminated_at) { nil } + let(:subscription) do + create( + :subscription, + billing_time: "calendar", + started_at: timestamp, + created_at: timestamp, + status:, + terminated_at:, + subscription_at: timestamp, + plan: + ) + end + let(:default_boundaries) do + { + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime, + timestamp: billing_date + } + end + + context "with active subscription" do + let(:billing_date) { Time.zone.parse("01 Jun 2024") } + + it "returns default boundaries" do + expect(subscription.adjusted_boundaries(billing_date, default_boundaries)).to eq(default_boundaries) + end + end + + context "with termination on non billing day" do + let(:status) { "terminated" } + let(:terminated_at) { billing_date } + + it "returns default boundaries" do + expect(subscription.adjusted_boundaries(billing_date, default_boundaries)).to eq(default_boundaries) + end + end + + context "with termination on billing day without invoice for previous period" do + let(:status) { "terminated" } + let(:billing_date) { Time.zone.parse("01 Jun 2024") } + let(:terminated_at) { billing_date } + + it "returns new boundaries based on previous billing period" do + new_boundaries = subscription.adjusted_boundaries(billing_date, default_boundaries) + + expect(new_boundaries).not_to eq(default_boundaries) + expect(new_boundaries.from_datetime.iso8601).to eq("2024-05-01T00:00:00Z") + expect(new_boundaries.to_datetime.iso8601).to eq("2024-05-31T23:59:59Z") + expect(new_boundaries.charges_from_datetime.iso8601).to eq("2024-05-01T00:00:00Z") + expect(new_boundaries.charges_to_datetime.iso8601).to eq("2024-05-31T23:59:59Z") + expect(new_boundaries.fixed_charges_from_datetime.iso8601).to eq("2024-05-01T00:00:00Z") + expect(new_boundaries.fixed_charges_to_datetime.iso8601).to eq("2024-05-31T23:59:59Z") + end + end + end + + describe "#next_subscription" do + subject { subscription.next_subscription } + + let(:subscription) { build(:subscription, next_subscriptions:, organization: plan.organization) } + + let(:next_subscriptions) do + [ + build(:subscription, :canceled, organization: plan.organization), + build(:subscription, created_at: 1.day.ago, organization: plan.organization), + build(:subscription, created_at: 2.days.ago, organization: plan.organization) + ] + end + + it "returns most recently non-canceled next subscription" do + expect(subject).to eq next_subscriptions.second + end + end + + describe "#has_progressive_billing?" do + let(:plan) { create(:plan) } + + context "when plan has usage thresholds" do + before { create(:usage_threshold, plan:) } + + it "returns true" do + expect(subscription.has_progressive_billing?).to be(true) + end + end + + context "when plan has no usage thresholds" do + it "returns false" do + expect(subscription.has_progressive_billing?).to be(false) + end + end + end + + describe "#applicable_usage_thresholds" do + let(:plan_threshold) { create(:usage_threshold, plan:) } + + context "when subscription has its own usage thresholds" do + let(:subscription_threshold) { create(:usage_threshold, :for_subscription, subscription:) } + + before do + plan_threshold + subscription_threshold + end + + it "returns subscription usage thresholds" do + expect(subscription.applicable_usage_thresholds).to contain_exactly(subscription_threshold) + end + + context "when progressive billing is disabled for subscription" do + it "returns empty array" do + subscription.progressive_billing_disabled = true + expect(subscription.applicable_usage_thresholds).to be_empty + end + end + end + + context "when subscription has no usage thresholds" do + it "returns plan usage thresholds" do + plan_threshold + expect(subscription.applicable_usage_thresholds).to contain_exactly(plan_threshold) + end + + context "when progressive billing is disabled for subscription" do + it "returns empty array" do + subscription.progressive_billing_disabled = true + expect(subscription.applicable_usage_thresholds).to be_empty + end + end + + context "when plan is a child with no thresholds" do + let(:parent_plan) { create(:plan) } + let(:plan) { create(:plan, parent: parent_plan) } + let(:plan_threshold) { create(:usage_threshold, plan: parent_plan) } + + it "returns the parent plan usage thresholds" do + plan_threshold + expect(subscription.applicable_usage_thresholds).to contain_exactly(plan_threshold) + end + end + end + + context "when neither subscription nor plan has usage thresholds" do + it "returns empty collection" do + expect(subscription.applicable_usage_thresholds).to be_empty + end + end + end +end diff --git a/spec/models/tax_spec.rb b/spec/models/tax_spec.rb new file mode 100644 index 0000000..3339312 --- /dev/null +++ b/spec/models/tax_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Tax do + subject(:tax) { create(:tax, applied_to_organization:) } + + let(:organization) { tax.organization } + let(:applied_to_organization) { false } + + it { is_expected.to belong_to(:organization) } + + it { is_expected.to have_many(:billing_entities_taxes).dependent(:destroy) } + it { is_expected.to have_many(:billing_entities).through(:billing_entities_taxes) } + + it { expect(described_class).to be_soft_deletable } + + it_behaves_like "paper_trail traceable" + + describe "customers_count" do + let(:customer) { create(:customer, organization: tax.organization) } + + before { create(:customer_applied_tax, customer:, tax:) } + + it "returns the number of attached customer" do + expect(tax.customers_count).to eq(1) + end + + context "when tax is applied to the billing_entity" do + let(:applied_to_billing_entity_tax) { create(:billing_entity_applied_tax, tax:, billing_entity: tax.organization.default_billing_entity) } + + before do + create(:customer, organization: tax.organization) + applied_to_billing_entity_tax + end + + it "returns the number of customer without tax" do + expect(tax.customers_count).to eq(2) + end + end + + context "when tax is applied to multiple billing entities" do + let(:organization) { tax.organization } + let(:billing_entity) { organization.default_billing_entity } + let(:billing_entity_2) { create(:billing_entity, organization:) } + let(:billing_entity_3) { create(:billing_entity, organization:) } + let(:applied_to_billing_entity_tax) { create(:billing_entity_applied_tax, tax:, billing_entity:) } + let(:applied_to_billing_entity_tax_2) { create(:billing_entity_applied_tax, tax:, billing_entity: billing_entity_2) } + let(:customer) { create(:customer, organization:) } + let(:customer_2) { create(:customer, organization:, billing_entity: billing_entity_2) } + let(:customer_3) { create(:customer, organization:, billing_entity: billing_entity_3) } + let(:customer_4) { create(:customer, organization:, billing_entity: billing_entity_3) } + let(:customer_4_applied_tax) { create(:customer_applied_tax, customer: customer_4, tax:) } + + before do + applied_to_billing_entity_tax + applied_to_billing_entity_tax_2 + customer_4_applied_tax + customer + customer_2 + customer_3 + end + + it "returns correct number of customers" do + expect(tax.customers_count).to eq(3) + end + end + end + + describe "#destroy" do + subject { tax.destroy! } + + let(:tax) { create(:tax) } + + context "when associated to applied_taxes" do + context "with invoices and fees" do + let(:invoice_status) { :finalized } + let(:invoice) { create(:invoice, status: invoice_status) } + let(:invoice_applied_tax) { create(:invoice_applied_tax, invoice:, tax:) } + let(:fee) { create(:fee, invoice:) } + let(:fee_applied_tax) { create(:fee_applied_tax, fee:, tax:) } + + before do + invoice_applied_tax + fee_applied_tax + end + + context "when invoice finalized" do + it "does not remove applied taxes" do + subject + + expect(invoice.applied_taxes).to eq([invoice_applied_tax]) + expect(invoice_applied_tax.reload.tax).to be_nil + + expect(fee.applied_taxes).to eq([fee_applied_tax]) + expect(fee_applied_tax.reload.tax).to be_nil + end + end + + context "when invoice draft" do + let(:invoice_status) { :draft } + + it "does remove applied taxes" do + subject + + expect(invoice.applied_taxes).to be_empty + expect { invoice_applied_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + + expect(fee.applied_taxes).to be_empty + expect { fee_applied_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + end + end +end diff --git a/spec/models/usage_filters_spec.rb b/spec/models/usage_filters_spec.rb new file mode 100644 index 0000000..1c2a364 --- /dev/null +++ b/spec/models/usage_filters_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageFilters do + describe "#initialize" do + it "sets default values" do + filters = described_class.new + + expect(filters.filter_by_charge_id).to be_nil + expect(filters.filter_by_charge_code).to be_nil + expect(filters.filter_by_metric_code).to be_nil + expect(filters.filter_by_group).to be_nil + expect(filters.filter_by_presentation).to be_nil + expect(filters.skip_grouping).to be(false) + expect(filters.full_usage).to be(false) + end + + it "normalizes filter_by_group values to arrays" do + filters = described_class.new(filter_by_group: {"cloud" => "aws", "region" => ["eu"]}) + + expect(filters.filter_by_group).to eq({"cloud" => ["aws"], "region" => ["eu"]}) + end + + it "handles nil filter_by_group" do + filters = described_class.new(filter_by_group: nil) + + expect(filters.filter_by_group).to be_nil + end + + it "stores all provided values" do + filters = described_class.new( + filter_by_charge_id: "charge-id", + filter_by_charge_code: "charge-code", + filter_by_metric_code: "api-call", + filter_by_group: {"cloud" => ["aws"]}, + filter_by_presentation: ["region"], + skip_grouping: true, + full_usage: true + ) + + expect(filters.filter_by_charge_id).to eq("charge-id") + expect(filters.filter_by_charge_code).to eq("charge-code") + expect(filters.filter_by_metric_code).to eq("api-call") + expect(filters.filter_by_group).to eq({"cloud" => ["aws"]}) + expect(filters.filter_by_presentation).to eq(["region"]) + expect(filters.skip_grouping).to be(true) + expect(filters.full_usage).to be(true) + end + end + + describe ".init_from_params" do + it "builds filters from params" do + params = { + filter_by_charge_id: "charge-id", + filter_by_charge_code: "charge-code", + filter_by_metric_code: "api-call", + filter_by_group: {cloud: "aws"}, + filter_by_presentation: ["compact"], + skip_grouping: "true", + full_usage: "true" + } + + filters = described_class.init_from_params(params) + + expect(filters.filter_by_charge_id).to eq("charge-id") + expect(filters.filter_by_charge_code).to eq("charge-code") + expect(filters.filter_by_metric_code).to eq("api-call") + expect(filters.filter_by_group).to eq({cloud: ["aws"]}) + expect(filters.filter_by_presentation).to eq(["compact"]) + expect(filters.skip_grouping).to be(true) + expect(filters.full_usage).to be(true) + end + + it "parses filter_by_presentation from JSON" do + filters = described_class.init_from_params(filter_by_presentation: '["department","region"]') + + expect(filters.filter_by_presentation).to eq(["department", "region"]) + end + + it "keeps an empty filter_by_presentation array" do + filters = described_class.init_from_params(filter_by_presentation: []) + + expect(filters.filter_by_presentation).to eq([]) + end + + it "handles missing params with defaults" do + params = {} + + filters = described_class.init_from_params(params) + + expect(filters.filter_by_charge_id).to be_nil + expect(filters.filter_by_charge_code).to be_nil + expect(filters.filter_by_metric_code).to be_nil + expect(filters.filter_by_group).to be_nil + expect(filters.filter_by_presentation).to be_nil + expect(filters.skip_grouping).to be_nil + expect(filters.full_usage).to be_nil + end + end + + describe "#has_charge_filter?" do + it "returns false when no charge filters are set" do + expect(described_class.new.has_charge_filter?).to be(false) + end + + it "returns true when filter_by_charge_id is set" do + expect(described_class.new(filter_by_charge_id: "charge-id").has_charge_filter?).to be(true) + end + + it "returns true when filter_by_charge_code is set" do + expect(described_class.new(filter_by_charge_code: "api-call-2").has_charge_filter?).to be(true) + end + + it "returns true when filter_by_metric_code is set" do + expect(described_class.new(filter_by_metric_code: "api-call").has_charge_filter?).to be(true) + end + end + + describe "NONE" do + it "is a frozen instance with default values" do + expect(described_class::NONE).to be_frozen + expect(described_class::NONE.filter_by_charge_id).to be_nil + expect(described_class::NONE.filter_by_charge_code).to be_nil + expect(described_class::NONE.filter_by_metric_code).to be_nil + expect(described_class::NONE.filter_by_group).to be_nil + expect(described_class::NONE.filter_by_presentation).to be_nil + expect(described_class::NONE.skip_grouping).to be(false) + expect(described_class::NONE.full_usage).to be(false) + end + end + + describe "WITHOUT_PRESENTATION" do + it "is a frozen instance with default values but without presentation" do + expect(described_class::WITHOUT_PRESENTATION_FILTER).to be_frozen + expect(described_class::WITHOUT_PRESENTATION_FILTER.filter_by_charge_id).to be_nil + expect(described_class::WITHOUT_PRESENTATION_FILTER.filter_by_charge_code).to be_nil + expect(described_class::WITHOUT_PRESENTATION_FILTER.filter_by_group).to be_nil + expect(described_class::WITHOUT_PRESENTATION_FILTER.filter_by_presentation).to eq([]) + expect(described_class::WITHOUT_PRESENTATION_FILTER.skip_grouping).to be(false) + expect(described_class::WITHOUT_PRESENTATION_FILTER.full_usage).to be(false) + end + end +end diff --git a/spec/models/usage_monitoring/alert_spec.rb b/spec/models/usage_monitoring/alert_spec.rb new file mode 100644 index 0000000..908e50e --- /dev/null +++ b/spec/models/usage_monitoring/alert_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::Alert do + let(:alert) { create(:alert, code: "my-code", thresholds: [10, 30, 50], recurring_threshold: 100) } + + describe "enums" do + it do + expect(subject).to define_enum_for(:direction) + .backed_by_column_of_type(:enum) + .validating + .with_values(increasing: "increasing", decreasing: "decreasing") + end + end + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:billable_metric).optional + expect(subject).to belong_to(:wallet).optional + expect(subject).to have_many(:thresholds).class_name("UsageMonitoring::AlertThreshold") + .with_foreign_key(:usage_monitoring_alert_id).dependent(:delete_all) + expect(subject).to have_many(:triggered_alerts).class_name("UsageMonitoring::TriggeredAlert") + .with_foreign_key(:usage_monitoring_alert_id) + end + end + + describe "validations" do + context "when type requires billable_metric_id" do + it do + alert = build(:billable_metric_current_usage_amount_alert, billable_metric_id: nil) + expect(alert).to be_invalid + expect(alert.errors[:billable_metric]).to eq ["value_is_mandatory"] + end + end + + context "when type requires wallet_id" do + it do + alert = build(:wallet_balance_amount_alert, wallet_id: nil) + expect(alert).to be_invalid + expect(alert.errors[:wallet]).to eq ["value_is_mandatory"] + end + end + + context "when billable_metric is set on a wallet alert" do + it "is invalid" do + wallet = create(:wallet) + billable_metric = create(:billable_metric, organization: wallet.organization) + alert = build(:wallet_balance_amount_alert, wallet:, billable_metric:) + expect(alert).to be_invalid + expect(alert.errors[:billable_metric]).to eq(["value_must_be_blank"]) + end + end + + context "when code is not unique for a subscription" do + it "raises an error" do + expect { + create(:billable_metric_current_usage_amount_alert, code: "my-code", subscription_external_id: alert.subscription_external_id, organization: alert.organization) + }.to raise_error(ActiveRecord::RecordNotUnique) + end + end + end + + describe ".find_sti_class" do + it "returns correct constant for known alert types" do + expect(described_class.find_sti_class("current_usage_amount")).to eq(UsageMonitoring::CurrentUsageAmountAlert) + expect(described_class.find_sti_class("billable_metric_current_usage_amount")).to eq(UsageMonitoring::BillableMetricCurrentUsageAmountAlert) + expect(described_class.find_sti_class("wallet_balance_amount")).to eq(UsageMonitoring::WalletBalanceAmountAlert) + expect(described_class.find_sti_class("wallet_credits_balance")).to eq(UsageMonitoring::WalletCreditsBalanceAlert) + expect(described_class.find_sti_class("wallet_ongoing_balance_amount")).to eq(UsageMonitoring::WalletOngoingBalanceAmountAlert) + expect(described_class.find_sti_class("wallet_credits_ongoing_balance")).to eq(UsageMonitoring::WalletCreditsOngoingBalanceAlert) + end + + it "raises KeyError for unknown alert type" do + expect { described_class.find_sti_class("unknown_type") }.to raise_error(KeyError) + end + end + + describe ".sti_name" do + it "returns correct sti_name for subclasses" do + stub_const("UsageMonitoring::CurrentUsageAmountAlert", Class.new(described_class)) + stub_const("UsageMonitoring::BillableMetricCurrentUsageAmountAlert", Class.new(described_class)) + + expect(UsageMonitoring::CurrentUsageAmountAlert.sti_name).to eq("current_usage_amount") + expect(UsageMonitoring::BillableMetricCurrentUsageAmountAlert.sti_name).to eq("billable_metric_current_usage_amount") + end + end + + describe "#one_time_thresholds_values" do + it "returns sorted unique threshold values" do + expect(alert.one_time_thresholds_values).to eq([10, 30, 50]) + end + end + + describe "#find_thresholds_crossed" do + context "when alert has only recurring thresholds (no one-time thresholds)" do + let(:alert) { create(:alert, code: "only-recurring", thresholds: nil, recurring_threshold: 100) } + + it "does not raise ArgumentError for increasing direction" do + alert.previous_value = 0 + expect(alert.find_thresholds_crossed(150)).to eq([100]) + end + + it "does not raise ArgumentError for decreasing direction" do + alert.direction = "decreasing" + alert.previous_value = 50 + expect(alert.find_thresholds_crossed(-150)).to eq([-100]) + end + end + + context "when direction is increasing" do + it "returns threshold values between previous_value and current (inclusive)" do + alert.previous_value = 8 + expect(alert.find_thresholds_crossed(8)).to eq([]) # exclude previous_values + expect(alert.find_thresholds_crossed(30)).to eq([10, 30]) + alert.previous_value = 31 + expect(alert.find_thresholds_crossed(60)).to eq([50]) + end + + it "returns empty array if no thresholds crossed" do + alert.previous_value = 30 + expect(alert.find_thresholds_crossed(29)).to be_empty + end + + it "returns recurring threshold if crossed" do + alert.previous_value = 33 + expect(alert.find_thresholds_crossed(351)).to eq([50, 150, 250, 350]) + end + end + + context "when direction is decreasing" do + let(:alert) { create(:alert, code: "my-code", thresholds: [200, 500, 800], recurring_threshold: 100, direction: "decreasing") } + + it "returns threshold values between current and previous_value (inclusive)" do + alert.previous_value = 1000 + expect(alert.find_thresholds_crossed(1000)).to eq([]) # exclude previous_values + expect(alert.find_thresholds_crossed(500)).to eq([500, 800]) + alert.previous_value = 450 + expect(alert.find_thresholds_crossed(100)).to eq([100, 200]) + end + + it "returns empty array if value increases" do + alert.previous_value = 400 + expect(alert.find_thresholds_crossed(450)).to be_empty + end + + it "returns empty array if no thresholds crossed" do + alert.previous_value = 700 + expect(alert.find_thresholds_crossed(600)).to be_empty + end + + it "returns recurring thresholds if crossed" do + alert.previous_value = 600 + expect(alert.find_thresholds_crossed(-100)).to eq([-100, 0, 100, 200, 500]) + end + end + end + + describe "#formatted_crossed_thresholds" do + it "returns formatted array of crossed thresholds matching given values" do + result = alert.formatted_crossed_thresholds([10, 30]) + expect(result).to contain_exactly( + {code: "warn10", value: 10, recurring: false}, + {code: "warn30", value: 30, recurring: false} + ) + end + + context "when there is a non-recurring and a recurring threshold with the same value" do + let(:alert) { create(:alert, code: "my-code", thresholds: [10, 15, 50], recurring_threshold: 10) } + + it "rejects the recurring threshold" do + expect(alert.formatted_crossed_thresholds([10, 15])).to eq([ + {code: "warn10", recurring: false, value: 10}, + {code: "warn15", recurring: false, value: 15} + ]) + end + end + + context "when crossed thresholds isn't part of threshold values" do + it "assumes it's recurring" do + expect(alert.formatted_crossed_thresholds([40, 41, 42])).to eq([ + {code: "rec", recurring: true, value: 40}, + {code: "rec", recurring: true, value: 41}, + {code: "rec", recurring: true, value: 42} + ]) + end + end + end + + describe "#find_value" do + it "raises NotImplementedError" do + expect { alert.find_value(double) }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/models/usage_monitoring/billable_metric_current_usage_amount_alert_spec.rb b/spec/models/usage_monitoring/billable_metric_current_usage_amount_alert_spec.rb new file mode 100644 index 0000000..c785ed5 --- /dev/null +++ b/spec/models/usage_monitoring/billable_metric_current_usage_amount_alert_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::BillableMetricCurrentUsageAmountAlert do + subject { alert.find_value(current_usage) } + + let(:alert) { create(:billable_metric_current_usage_amount_alert, subscription_external_id: "test") } + let(:current_usage) { instance_double(SubscriptionUsage, amount_cents: 100, fees:) } + let(:charge) { create(:standard_charge, billable_metric: alert.billable_metric) } + let(:fees) do + [ + create(:charge_fee, charge:, amount_cents: 8), + create(:charge_fee, charge:, amount_cents: 4), # will ensure that we're using max not min + create(:charge_fee, amount_cents: 12) # ensure that we look only within correct charge fees + ] + end + + describe "#find_value" do + it "returns biggest units among fees related to the alert's billable metric" do + expect(subject).to eq(8) + end + end +end diff --git a/spec/models/usage_monitoring/billable_metric_current_usage_units_alert_spec.rb b/spec/models/usage_monitoring/billable_metric_current_usage_units_alert_spec.rb new file mode 100644 index 0000000..810cf19 --- /dev/null +++ b/spec/models/usage_monitoring/billable_metric_current_usage_units_alert_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::BillableMetricCurrentUsageUnitsAlert do + subject { alert.find_value(current_usage) } + + let(:alert) { create(:billable_metric_current_usage_units_alert, subscription_external_id: "test") } + let(:current_usage) { instance_double(SubscriptionUsage, amount_cents: 100, fees:) } + let(:charge) { create(:standard_charge, billable_metric: alert.billable_metric) } + let(:fees) do + [ + create(:charge_fee, charge:, units: 8), + create(:charge_fee, charge:, units: 4), # will ensure that we're using max not min + create(:charge_fee, units: 12) # ensure that we look only within correct charge fees + ] + end + + describe "#find_value" do + it "returns biggest units among fees related to the alert's billable metric" do + expect(subject).to eq(8) + end + end +end diff --git a/spec/models/usage_monitoring/billable_metric_lifetime_usage_units_alert_spec.rb b/spec/models/usage_monitoring/billable_metric_lifetime_usage_units_alert_spec.rb new file mode 100644 index 0000000..2411b21 --- /dev/null +++ b/spec/models/usage_monitoring/billable_metric_lifetime_usage_units_alert_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::BillableMetricLifetimeUsageUnitsAlert do + subject { alert.find_value(current_usage) } + + let(:alert) { create(:billable_metric_lifetime_usage_units_alert, subscription_external_id: "test") } + let(:current_usage) { instance_double(SubscriptionUsage, amount_cents: 100, fees:) } + let(:charge) { create(:standard_charge, billable_metric: alert.billable_metric) } + let(:fees) do + [ + create(:charge_fee, charge:, units: 8), + create(:charge_fee, charge:, units: 4), # will ensure that we're using max not min + create(:charge_fee, units: 12) # ensure that we look only within correct charge fees + ] + end + + describe "#find_value" do + it "returns biggest units among fees related to the alert's billable metric" do + expect(subject).to eq(8) + end + end +end diff --git a/spec/models/usage_monitoring/current_usage_amount_alert_spec.rb b/spec/models/usage_monitoring/current_usage_amount_alert_spec.rb new file mode 100644 index 0000000..08ffccf --- /dev/null +++ b/spec/models/usage_monitoring/current_usage_amount_alert_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::CurrentUsageAmountAlert do + let(:alert) { create(:usage_current_amount_alert) } + let(:subscription) { create(:subscription) } + + describe "#find_value" do + it do + current_usage = double(amount_cents: 100) # rubocop:disable RSpec/VerifiedDoubles + expect(alert.find_value(current_usage)).to eq(100) + end + end +end diff --git a/spec/models/usage_monitoring/lifetime_usage_amount_alert_spec.rb b/spec/models/usage_monitoring/lifetime_usage_amount_alert_spec.rb new file mode 100644 index 0000000..3274af3 --- /dev/null +++ b/spec/models/usage_monitoring/lifetime_usage_amount_alert_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::LifetimeUsageAmountAlert do + describe "#find_value" do + subject { alert.find_value(lifetime_usage) } + + let(:alert) { build_stubbed(:lifetime_usage_amount_alert, subscription_external_id: "test") } + let(:lifetime_usage) { build(:lifetime_usage, invoiced_usage_amount_cents: 6, current_usage_amount_cents: 3) } + + it "returns lifetime usage's total amount cents" do + expect(subject).to eq(9) + end + end +end diff --git a/spec/models/usage_monitoring/subscription_activity_spec.rb b/spec/models/usage_monitoring/subscription_activity_spec.rb new file mode 100644 index 0000000..02e1ae3 --- /dev/null +++ b/spec/models/usage_monitoring/subscription_activity_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::SubscriptionActivity do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:subscription) + expect(subject).to have_db_column(:enqueued).with_options(null: false, default: false) + expect(subject).to have_db_column(:inserted_at).with_options(null: false) + + expect(subject).to have_db_index(:subscription_id).unique(true) + expect(subject).to have_db_index([:organization_id, :enqueued]) + end +end diff --git a/spec/models/usage_monitoring/triggered_alert_spec.rb b/spec/models/usage_monitoring/triggered_alert_spec.rb new file mode 100644 index 0000000..841fb3b --- /dev/null +++ b/spec/models/usage_monitoring/triggered_alert_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::TriggeredAlert do + let(:triggered_alert) { create(:triggered_alert) } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:subscription).optional + expect(subject).to belong_to(:wallet).optional + expect(subject).to belong_to(:alert).class_name("UsageMonitoring::Alert") + .with_foreign_key(:usage_monitoring_alert_id) + end + end +end diff --git a/spec/models/usage_monitoring/wallet_balance_amount_alert_spec.rb b/spec/models/usage_monitoring/wallet_balance_amount_alert_spec.rb new file mode 100644 index 0000000..ae4b311 --- /dev/null +++ b/spec/models/usage_monitoring/wallet_balance_amount_alert_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::WalletBalanceAmountAlert do + describe "#find_value" do + let(:alert) { create(:wallet_balance_amount_alert) } + let(:wallet) { create(:wallet, balance_cents: 1500) } + + it "returns the wallet balance_cents" do + expect(alert.find_value(wallet)).to eq(1500) + end + end +end diff --git a/spec/models/usage_monitoring/wallet_credits_balance_alert_spec.rb b/spec/models/usage_monitoring/wallet_credits_balance_alert_spec.rb new file mode 100644 index 0000000..dda765f --- /dev/null +++ b/spec/models/usage_monitoring/wallet_credits_balance_alert_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::WalletCreditsBalanceAlert do + describe "#find_value" do + let(:alert) { create(:wallet_credits_balance_alert) } + let(:wallet) { create(:wallet, credits_balance: 25.5) } + + it "returns the wallet credits_balance" do + expect(alert.find_value(wallet)).to eq(25.5) + end + end +end diff --git a/spec/models/usage_monitoring/wallet_credits_ongoing_balance_alert_spec.rb b/spec/models/usage_monitoring/wallet_credits_ongoing_balance_alert_spec.rb new file mode 100644 index 0000000..3cea712 --- /dev/null +++ b/spec/models/usage_monitoring/wallet_credits_ongoing_balance_alert_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::WalletCreditsOngoingBalanceAlert do + describe "#find_value" do + let(:alert) { create(:wallet_credits_ongoing_balance_alert) } + let(:wallet) { create(:wallet, credits_ongoing_balance: 25.5) } + + it "returns the wallet credits_ongoing_balance" do + expect(alert.find_value(wallet)).to eq(25.5) + end + end +end diff --git a/spec/models/usage_monitoring/wallet_ongoing_balance_amount_alert_spec.rb b/spec/models/usage_monitoring/wallet_ongoing_balance_amount_alert_spec.rb new file mode 100644 index 0000000..e98724d --- /dev/null +++ b/spec/models/usage_monitoring/wallet_ongoing_balance_amount_alert_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::WalletOngoingBalanceAmountAlert do + describe "#find_value" do + let(:alert) { create(:wallet_ongoing_balance_amount_alert) } + let(:wallet) { create(:wallet, ongoing_balance_cents: 1500) } + + it "returns the wallet ongoing_balance_cents" do + expect(alert.find_value(wallet)).to eq(1500) + end + end +end diff --git a/spec/models/usage_threshold_spec.rb b/spec/models/usage_threshold_spec.rb new file mode 100644 index 0000000..658cbdd --- /dev/null +++ b/spec/models/usage_threshold_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageThreshold do + subject(:usage_threshold) { build(:usage_threshold) } + + it_behaves_like "paper_trail traceable" + + it { expect(described_class).to be_soft_deletable } + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:plan).without_validating_presence + expect(subject).to belong_to(:subscription).without_validating_presence + expect(subject).to have_many(:applied_usage_thresholds) + expect(subject).to have_many(:invoices).through(:applied_usage_thresholds) + end + end + + describe "validations" do + it { is_expected.to validate_numericality_of(:amount_cents).is_greater_than(0) } + + describe "exactly_one_parent_present validation" do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:) } + + it "is valid when only plan_id is present" do + threshold = build(:usage_threshold, organization:, plan:, subscription: nil) + expect(threshold).to be_valid + end + + it "is valid when only subscription_id is present" do + threshold = build(:usage_threshold, organization:, plan: nil, subscription:) + expect(threshold).to be_valid + end + + it "is invalid when both plan_id and subscription_id are present" do + threshold = build(:usage_threshold, organization:, plan:, subscription:) + expect(threshold).not_to be_valid + expect(threshold.errors[:base]).to eq(["one_of_plan_or_subscription_required"]) + end + + it "is invalid when neither plan_id nor subscription_id are present" do + threshold = described_class.new(organization:, plan: nil, subscription: nil, amount_cents: 100) + expect(threshold).not_to be_valid + expect(threshold.errors[:base]).to include("one_of_plan_or_subscription_required") + end + end + end + + describe "#currency" do + let(:organization) { create(:organization, default_currency: "USD") } + + context "when threshold belongs to a plan" do + let(:plan) { create(:plan, organization:, amount_currency: "GBP") } + let(:threshold) { build(:usage_threshold, organization:, plan:, subscription: nil) } + + it "returns the plan amount_currency" do + expect(threshold.currency).to eq("GBP") + end + end + + context "when threshold belongs to a subscription" do + let(:plan) { create(:plan, organization:, amount_currency: "JPY") } + let(:subscription) { create(:subscription, organization:, plan:) } + let(:threshold) { build(:usage_threshold, organization:, plan: nil, subscription:) } + + it "returns the subscription plan amount_currency" do + expect(threshold.currency).to eq("JPY") + end + end + end + + describe "invoice_name" do + subject(:usage_threshold) { build(:usage_threshold, threshold_display_name:) } + + let(:threshold_display_name) { "Threshold Display Name" } + + it { expect(usage_threshold.invoice_name).to eq(threshold_display_name) } + + context "when threshold display name is null" do + let(:threshold_display_name) { nil } + + it { expect(usage_threshold.invoice_name).to eq(I18n.t("invoice.usage_threshold")) } + end + end +end diff --git a/spec/models/user_device_spec.rb b/spec/models/user_device_spec.rb new file mode 100644 index 0000000..daa0f46 --- /dev/null +++ b/spec/models/user_device_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UserDevice do + subject(:user_device) { build(:user_device) } + + describe "associations" do + it { is_expected.to belong_to(:user) } + end + + describe "validations" do + it do + expect(user_device).to validate_uniqueness_of(:fingerprint).scoped_to(:user_id) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..abe0e53 --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe User do + subject { described_class.new(email: "gavin@hooli.com", password: "f**k_piedpiper") } + + it_behaves_like "paper_trail traceable" + + describe "associations" do + it do + expect(subject).to have_many(:password_resets) + expect(subject).to have_many(:user_devices) + expect(subject).to have_many(:memberships) + expect(subject).to have_many(:organizations).through(:memberships).class_name("Organization") + expect(subject).to have_many(:active_memberships).class_name("Membership") + expect(subject).to have_many(:active_organizations).through(:active_memberships).source(:organization) + expect(subject).to have_many(:quote_owners).dependent(:destroy) + expect(subject).to have_many(:quotes).through(:quote_owners) + end + end + + describe "normalizations" do + it "sanitizes email on assignment" do + user = described_class.new(email: " hello@some\u200Bthing\u2013other.com ", password: "password") + expect(user.email).to eq("hello@something-other.com") + end + end + + describe "Validations" do + it "is valid with valid attributes" do + expect(subject).to be_valid + end + + it "is not valid with no email" do + subject.email = nil + expect(subject).not_to be_valid + end + + it "is not valid with no password" do + subject.password = nil + expect(subject).not_to be_valid + end + end +end diff --git a/spec/models/wallet/applied_invoice_custom_section_spec.rb b/spec/models/wallet/applied_invoice_custom_section_spec.rb new file mode 100644 index 0000000..d7021da --- /dev/null +++ b/spec/models/wallet/applied_invoice_custom_section_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallet::AppliedInvoiceCustomSection do + subject(:applied_invoice_custom_section) do + create(:wallet_applied_invoice_custom_section) + end + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:wallet) } + it { is_expected.to belong_to(:invoice_custom_section) } +end diff --git a/spec/models/wallet_credit_spec.rb b/spec/models/wallet_credit_spec.rb new file mode 100644 index 0000000..7d3ab98 --- /dev/null +++ b/spec/models/wallet_credit_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletCredit do + subject { described_class.new(wallet:, credit_amount:, invoiceable:) } + + let(:wallet) { create(:wallet, rate_amount:, currency:) } + let(:currency) { "EUR" } + let(:credit_amount) { 1000 } + let(:rate_amount) { 1 } + let(:invoiceable) { true } + + context "with a simple wallet" do + describe "#credit_amount" do + it "returns the credit_amount" do + expect(subject.credit_amount).to eq(credit_amount) + end + end + + describe "#amount" do + it "returns the amount" do + expect(subject.amount).to eq(1000) + end + end + end + + context "with a low wallet rate_amount" do + let(:rate_amount) { 0.001 } + let(:credit_amount) { 1034 } + + describe "#credit_amount" do + it "returns the credit_amount" do + # The 1034 is rounded down as we cannot represent it in this currency + expect(subject.credit_amount).to eq(1030) + end + end + + describe "#amount" do + it "returns the amount" do + expect(subject.amount).to eq(1.03) + end + end + + describe "#amount_cents" do + it "returns the amount cents" do + expect(subject.amount_cents).to eq(103) + end + end + end + + describe ".from_amount_cents" do + subject { described_class.from_amount_cents(wallet:, amount_cents:) } + + let(:amount_cents) { 10 } + + describe "#credit_amount" do + it "returns the credit_amount" do + expect(subject.credit_amount).to eq(0.1) + end + end + + describe "#amount" do + it "returns the amount" do + expect(subject.amount).to eq(0.1) + end + end + + describe "#amount_cents" do + it "returns the amount cents" do + expect(subject.amount_cents).to eq(10) + end + end + + context "when amount cents has precision" do + let(:rate_amount) { 0.001 } + let(:amount_cents) { BigDecimal("103.4589") } + + describe "#credit_amount" do + it "returns the rounded credit_amount" do + expect(subject.credit_amount).to eq(1030) + end + end + + describe "#amount" do + it "returns the amount" do + expect(subject.amount).to eq(1.03) + end + end + + describe "#amount_cents" do + it "returns the amount cents" do + expect(subject.amount_cents).to eq(103) + end + end + end + end + + context "when invoiceable is false" do + let(:invoiceable) { false } + + context "with a simple wallet" do + describe "#credit_amount" do + it "returns the credit_amount" do + expect(subject.credit_amount).to eq(credit_amount) + end + end + + describe "#amount" do + it "returns the amount" do + expect(subject.amount).to eq(1000) + end + end + end + + context "with a low wallet rate_amount" do + let(:rate_amount) { 0.001 } + let(:credit_amount) { 1034 } + + describe "#credit_amount" do + it "returns the credit_amount" do + # The 1034 is not rounded here, as we're not invoicing the credit + expect(subject.credit_amount).to eq(1034) + end + end + + describe "#amount" do + it "returns the amount" do + expect(subject.amount).to eq(1.03) + end + end + end + end +end diff --git a/spec/models/wallet_spec.rb b/spec/models/wallet_spec.rb new file mode 100644 index 0000000..4a851be --- /dev/null +++ b/spec/models/wallet_spec.rb @@ -0,0 +1,301 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallet do + subject(:wallet) { build(:wallet) } + + it_behaves_like "paper_trail traceable" + + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:customer) + expect(subject).to belong_to(:billing_entity).optional + expect(subject).to have_many(:applied_invoice_custom_sections).class_name("Wallet::AppliedInvoiceCustomSection").dependent(:destroy) + expect(subject).to have_many(:selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) + expect(subject).to have_one(:metadata).class_name("Metadata::ItemMetadata").dependent(:destroy) + expect(subject).to have_many(:alerts).class_name("UsageMonitoring::Alert") + expect(subject).to have_many(:triggered_alerts).class_name("UsageMonitoring::TriggeredAlert") + end + end + + describe "Clickhouse associations", clickhouse: true do + it { is_expected.to have_many(:activity_logs).class_name("Clickhouse::ActivityLog") } + end + + describe "#billing_entity" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + context "when wallet has a billing_entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:wallet) { create(:wallet, customer:, billing_entity:) } + + it "returns the wallet billing_entity" do + expect(wallet.billing_entity).to eq(billing_entity) + end + end + + context "when wallet does not have a billing_entity" do + let(:wallet) { create(:wallet, customer:, billing_entity: nil) } + + it "falls back to the customer billing_entity" do + expect(wallet.billing_entity).to eq(customer.billing_entity) + end + end + end + + describe ".with_positive_balance" do + subject { described_class.with_positive_balance } + + let(:scoped) { create(:wallet, balance_cents: rand(1..1000)) } + + before do + create(:wallet, balance_cents: 0) + create(:wallet, balance_cents: -rand(1..1000), traceable: false) + end + + it "returns wallets with positive balance cents" do + expect(subject).to contain_exactly(scoped) + end + end + + describe ".in_application_order" do + subject { described_class.in_application_order } + + let!(:wallet_10_newer) { create(:wallet, priority: 10, created_at: 1.day.ago) } + let!(:wallet_5) { create(:wallet, priority: 5, created_at: 1.second.ago) } + let!(:wallet_10_older) { create(:wallet, priority: 10, created_at: 3.days.ago) } + let!(:wallet_50) { create(:wallet, created_at: 2.seconds.ago) } + + it "orders by priority first then by created_at" do + expect(subject.to_a).to eq([ + wallet_5, + wallet_10_older, + wallet_10_newer, + wallet_50 + ]) + end + end + + describe "validations" do + it { is_expected.to validate_numericality_of(:rate_amount).is_greater_than(0) } + it { is_expected.to validate_inclusion_of(:currency).in_array(described_class.currency_list) } + it { is_expected.to validate_exclusion_of(:invoice_requires_successful_payment).in_array([nil]) } + it { is_expected.to validate_numericality_of(:paid_top_up_min_amount_cents).is_greater_than(0).allow_nil } + it { is_expected.to validate_numericality_of(:paid_top_up_max_amount_cents).is_greater_than(0).allow_nil } + it { is_expected.to validate_inclusion_of(:priority).in_range(1..50) } + + it "validates than max is greater than min" do + subject.paid_top_up_min_amount_cents = 100 + subject.paid_top_up_max_amount_cents = 1 + + expect(subject).not_to be_valid + expect(subject.errors["paid_top_up_max_amount_cents"]).to eq ["must_be_greater_than_or_equal_min"] + + subject.paid_top_up_max_amount_cents = subject.paid_top_up_min_amount_cents + expect(subject).to be_valid + expect(subject.errors).to be_empty + end + + it "validates uniqueness of code per customer" do + customer = create(:customer) + existing_wallet = create(:wallet, customer:, code: "unique_code") + + subject.customer = customer + subject.code = existing_wallet.code + + expect(subject).not_to be_valid + expect(subject.errors["code"]).to eq ["value_already_exist"] + + # Same code with different customer should be valid + other_customer = create(:customer, organization: customer.organization) + subject.customer = other_customer + expect(subject).to be_valid + expect(subject.errors).to be_empty + end + + describe "of balance cents" do + let(:wallet) { build(:wallet, balance_cents:, traceable:) } + let(:error) { wallet.errors.where(:balance_cents, :greater_than_or_equal_to) } + + before { wallet.valid? } + + context "when wallet is traceable" do + let(:traceable) { true } + + context "when balance_cents is negative" do + let(:balance_cents) { rand(1..1000) * -1 } + + it "adds an error" do + expect(error).to be_present + end + end + + context "when balance_cents is zero" do + let(:balance_cents) { 0 } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when balance_cents is positive" do + let(:balance_cents) { rand(1..1000) } + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + + context "when wallet is not traceable" do + let(:traceable) { false } + + context "when balance_cents is negative" do + let(:balance_cents) { rand(1..1000) * -1 } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when balance_cents is zero" do + let(:balance_cents) { 0 } + + it "does not add an error" do + expect(error).not_to be_present + end + end + + context "when balance_cents is positive" do + let(:balance_cents) { rand(1..1000) } + + it "does not add an error" do + expect(error).not_to be_present + end + end + end + end + end + + describe "currency=" do + it "assigns the currency to all amounts" do + wallet.currency = "CAD" + + expect(wallet).to have_attributes( + balance_currency: "CAD", + consumed_amount_currency: "CAD" + ) + end + end + + describe "currency" do + it "returns the wallet currency" do + expect(wallet.currency).to eq(wallet.balance_currency) + end + end + + describe "limited_fee_types?" do + context "when allowed_fee_types is present" do + before { wallet.allowed_fee_types = %w[charge] } + + it "returns true" do + expect(wallet.limited_fee_types?).to be true + end + end + + context "when allowed_fee_types is empty" do + before { wallet.allowed_fee_types = [] } + + it "returns false" do + expect(wallet.limited_fee_types?).to be false + end + end + end + + describe "limited_to_billable_metrics?" do + context "when wallet_targets are present" do + before { create(:wallet_target, wallet:) } + + it "returns true" do + expect(wallet.limited_to_billable_metrics?).to be true + end + end + + context "when wallet targets are not present" do + it "returns false" do + expect(wallet.limited_to_billable_metrics?).to be false + end + end + end + + describe "#paid_top_up_min_credits" do + it "converts min amount cents to credits using the wallet rate" do + wallet.rate_amount = 25 + wallet.paid_top_up_min_amount_cents = 1_00 + expect(wallet.paid_top_up_min_credits).to eq(0.04) + + wallet.paid_top_up_min_amount_cents = nil + expect(wallet.paid_top_up_min_credits).to be_nil + end + end + + describe "#paid_top_up_max_credits" do + it "converts max amount cents to credits using the wallet rate" do + wallet.paid_top_up_max_amount_cents = 5_00 + expect(wallet.paid_top_up_max_credits).to eq(5.0) + + wallet.paid_top_up_max_amount_cents = nil + expect(wallet.paid_top_up_max_credits).to be_nil + end + end + + describe "REFRESH_RELEVANT_ATTRIBUTES" do + # If this list changes, you MUST decide whether the new/removed column + # should trigger Customers::RefreshWalletsService and update + # Wallet::REFRESH_RELEVANT_ATTRIBUTES accordingly. + non_refresh_relevant_attributes = %w[ + id + balance_cents + balance_currency + consumed_amount_cents + consumed_amount_currency + consumed_credits + credits_balance + credits_ongoing_balance + credits_ongoing_usage_balance + depleted_ongoing_balance + expiration_at + invoice_requires_successful_payment + last_balance_sync_at + last_consumed_credit_at + last_ongoing_balance_sync_at + lock_version + name + ongoing_balance_cents + ongoing_usage_balance_cents + paid_top_up_max_amount_cents + paid_top_up_min_amount_cents + payment_method_type + rate_amount + ready_to_be_refreshed + skip_invoice_custom_sections + status + terminated_at + traceable + created_at + updated_at + billing_entity_id + customer_id + organization_id + payment_method_id + ].freeze + + it "covers every column that does not need to trigger a refresh" do + expect(described_class.column_names - described_class::REFRESH_RELEVANT_ATTRIBUTES) + .to match_array(non_refresh_relevant_attributes) + end + end +end diff --git a/spec/models/wallet_target_spec.rb b/spec/models/wallet_target_spec.rb new file mode 100644 index 0000000..17fe33a --- /dev/null +++ b/spec/models/wallet_target_spec.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +RSpec.describe WalletTarget do + subject(:wallet_target) { build(:wallet_target) } + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:wallet) } + it { is_expected.to belong_to(:billable_metric) } +end diff --git a/spec/models/wallet_transaction/applied_invoice_custom_section_spec.rb b/spec/models/wallet_transaction/applied_invoice_custom_section_spec.rb new file mode 100644 index 0000000..f75d3de --- /dev/null +++ b/spec/models/wallet_transaction/applied_invoice_custom_section_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransaction::AppliedInvoiceCustomSection do + subject(:applied_invoice_custom_section) do + create(:wallet_transaction_applied_invoice_custom_section) + end + + it { is_expected.to belong_to(:organization) } + it { is_expected.to belong_to(:wallet_transaction) } + it { is_expected.to belong_to(:invoice_custom_section) } +end diff --git a/spec/models/wallet_transaction_consumption_spec.rb b/spec/models/wallet_transaction_consumption_spec.rb new file mode 100644 index 0000000..90c38bd --- /dev/null +++ b/spec/models/wallet_transaction_consumption_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactionConsumption do + describe "associations" do + it do + expect(subject).to belong_to(:organization) + expect(subject).to belong_to(:inbound_wallet_transaction).class_name("WalletTransaction").inverse_of(:consumptions) + expect(subject).to belong_to(:outbound_wallet_transaction).class_name("WalletTransaction").inverse_of(:fundings) + end + end + + describe "validations" do + it { is_expected.to validate_numericality_of(:consumed_amount_cents).is_greater_than(0) } + + describe "inbound_transaction_must_be_inbound" do + let(:wallet) { create(:wallet) } + let(:inbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: :inbound, remaining_amount_cents: 10000) } + let(:outbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: :outbound) } + + it "is valid when inbound_wallet_transaction is inbound" do + consumption = build(:wallet_transaction_consumption, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction) + + expect(consumption).to be_valid + end + + it "is invalid when inbound_wallet_transaction is outbound" do + consumption = build(:wallet_transaction_consumption, + inbound_wallet_transaction: outbound_transaction, + outbound_wallet_transaction: outbound_transaction) + + expect(consumption).not_to be_valid + expect(consumption.errors.where(:inbound_wallet_transaction, :invalid)).to be_present + end + end + + describe "outbound_transaction_must_be_outbound" do + let(:wallet) { create(:wallet) } + let(:inbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: :inbound, remaining_amount_cents: 10000) } + let(:outbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: :outbound) } + + it "is valid when outbound_wallet_transaction is outbound" do + consumption = build(:wallet_transaction_consumption, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction) + + expect(consumption).to be_valid + end + + it "is invalid when outbound_wallet_transaction is inbound" do + consumption = build(:wallet_transaction_consumption, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: inbound_transaction) + + expect(consumption).not_to be_valid + expect(consumption.errors.where(:outbound_wallet_transaction, :invalid)).to be_present + end + end + end + + describe "#credit_amount" do + let(:wallet) { create(:wallet, rate_amount: "2.00", currency: "EUR") } + let(:inbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: :inbound, remaining_amount_cents: 10000) } + let(:outbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: :outbound) } + let(:consumption) do + create(:wallet_transaction_consumption, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 3000) + end + + it "converts consumed cents to credit amount using the wallet rate" do + # 3000 cents = 30.00 EUR, at rate 2.00 => 15.0 credits + expect(consumption.credit_amount).to eq("15.0") + end + end +end diff --git a/spec/models/wallet_transaction_spec.rb b/spec/models/wallet_transaction_spec.rb new file mode 100644 index 0000000..83487e4 --- /dev/null +++ b/spec/models/wallet_transaction_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransaction do + describe "validations" do + it { is_expected.to validate_presence_of(:priority) } + it { is_expected.to validate_inclusion_of(:priority).in_range(1..50) } + it { is_expected.to validate_length_of(:name).is_at_most(255).is_at_least(1).allow_nil } + it { is_expected.to validate_exclusion_of(:invoice_requires_successful_payment).in_array([nil]) } + it { is_expected.to validate_presence_of(:status) } + it { is_expected.to validate_presence_of(:transaction_type) } + it { is_expected.to validate_presence_of(:source) } + it { is_expected.to validate_presence_of(:transaction_status) } + + describe "remaining_amount_cents validation" do + it "allows remaining_amount_cents on inbound transactions" do + transaction = build(:wallet_transaction, transaction_type: :inbound, remaining_amount_cents: 1000) + expect(transaction).to be_valid + end + + it "rejects negative remaining_amount_cents on inbound transactions" do + transaction = build(:wallet_transaction, transaction_type: :inbound, remaining_amount_cents: -100) + expect(transaction).not_to be_valid + expect(transaction.errors.where(:remaining_amount_cents, :greater_than_or_equal_to)).to be_present + end + + it "allows nil remaining_amount_cents on outbound transactions" do + transaction = build(:wallet_transaction, transaction_type: :outbound, remaining_amount_cents: nil) + expect(transaction).to be_valid + end + + it "rejects remaining_amount_cents on outbound transactions" do + transaction = build(:wallet_transaction, transaction_type: :outbound, remaining_amount_cents: 1000) + expect(transaction).not_to be_valid + expect(transaction.errors.where(:remaining_amount_cents, :present)).to be_present + end + end + end + + describe "associations" do + it { is_expected.to belong_to(:wallet) } + it { is_expected.to belong_to(:invoice).optional } + it { is_expected.to belong_to(:credit_note).optional } + it { is_expected.to belong_to(:voided_invoice).class_name("Invoice").optional } + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:consumptions).class_name("WalletTransactionConsumption").with_foreign_key(:inbound_wallet_transaction_id).inverse_of(:inbound_wallet_transaction).dependent(:destroy) } + it { is_expected.to have_many(:fundings).class_name("WalletTransactionConsumption").with_foreign_key(:outbound_wallet_transaction_id).inverse_of(:outbound_wallet_transaction).dependent(:destroy) } + it { is_expected.to have_many(:applied_invoice_custom_sections).class_name("WalletTransaction::AppliedInvoiceCustomSection").dependent(:destroy) } + it { is_expected.to have_many(:selected_invoice_custom_sections).through(:applied_invoice_custom_sections).source(:invoice_custom_section) } + end + + describe "enums" do + it "defines expected enum values" do + expect(described_class.defined_enums).to include( + "status" => hash_including("pending", "settled", "failed"), + "transaction_status" => hash_including("purchased", "granted", "voided", "invoiced"), + "transaction_type" => hash_including("inbound", "outbound"), + "source" => hash_including("manual", "interval", "threshold") + ) + end + end + + describe "Scopes" do + describe ".available_inbound" do + let(:wallet) { create(:wallet) } + let(:inbound_settled_available) { create(:wallet_transaction, wallet:, transaction_type: :inbound, status: :settled, remaining_amount_cents: 1000) } + let(:inbound_settled_exhausted) { create(:wallet_transaction, wallet:, transaction_type: :inbound, status: :settled, remaining_amount_cents: 0) } + let(:inbound_pending) { create(:wallet_transaction, wallet:, transaction_type: :inbound, status: :pending, remaining_amount_cents: 1000) } + let(:outbound_settled) { create(:wallet_transaction, wallet:, transaction_type: :outbound, status: :settled) } + + before do + inbound_settled_available + inbound_settled_exhausted + inbound_pending + outbound_settled + end + + it "returns only inbound settled transactions with remaining balance" do + expect(described_class.available_inbound).to eq([inbound_settled_available]) + end + end + + describe ".in_consumption_order" do + let(:wallet) { create(:wallet) } + let!(:granted_priority_10) { create(:wallet_transaction, wallet:, transaction_status: :granted, priority: 10, created_at: 1.day.ago) } + let!(:purchased_priority_5) { create(:wallet_transaction, wallet:, transaction_status: :purchased, priority: 5, created_at: 2.days.ago) } + let!(:granted_priority_5) { create(:wallet_transaction, wallet:, transaction_status: :granted, priority: 5, created_at: 3.days.ago) } + let!(:granted_priority_5_newer) { create(:wallet_transaction, wallet:, transaction_status: :granted, priority: 5, created_at: 1.day.ago) } + + it "orders by priority, then granted before purchased, then by created_at" do + expect(described_class.in_consumption_order).to eq([ + granted_priority_5, # priority 5, granted, oldest + granted_priority_5_newer, # priority 5, granted, newer + purchased_priority_5, # priority 5, purchased + granted_priority_10 # priority 10, granted + ]) + end + end + end + + describe ".order_by_priority" do + subject { described_class.order_by_priority } + + let(:wallet) { create(:wallet) } + let!(:purchased_10) { create(:wallet_transaction, wallet:, priority: 10, transaction_status: :purchased, created_at: 3.days.ago) } + let!(:granted_5) { create(:wallet_transaction, wallet:, priority: 5, transaction_status: :granted, created_at: 2.days.ago) } + let!(:granted_10) { create(:wallet_transaction, wallet:, priority: 10, transaction_status: :granted, created_at: 1.day.ago) } + let!(:voided_5) { create(:wallet_transaction, wallet:, priority: 5, transaction_status: :voided, created_at: 4.days.ago) } + let!(:invoiced_15) { create(:wallet_transaction, wallet:, priority: 15, transaction_status: :invoiced, created_at: 5.days.ago) } + let!(:granted_10_older) { create(:wallet_transaction, wallet:, priority: 10, transaction_status: :granted, created_at: 2.days.ago) } + + it "orders by priority first, then by transaction_status, then by created_at" do + expect(subject.to_a).to eq([ + granted_5, + voided_5, + granted_10_older, # priority 10, granted, 2 days ago + granted_10, # priority 10, granted, 1 day ago + purchased_10, + invoiced_15 + ]) + end + end + + describe "#mark_as_failed!" do + let(:transaction) { create(:wallet_transaction, status: :pending) } + + it "marks the transaction as failed" do + expect { transaction.mark_as_failed! } + .to change(transaction, :status).from("pending").to("failed") + .and change(transaction, :failed_at).from(nil) + end + end + + describe "#remaining_credit_amount" do + let(:wallet) { create(:wallet, rate_amount: "2.00", currency: "EUR") } + + context "when remaining_amount_cents is nil" do + let(:transaction) { create(:wallet_transaction, wallet:, transaction_type: :inbound, remaining_amount_cents: nil) } + + it "returns nil" do + expect(transaction.remaining_credit_amount).to be_nil + end + end + + context "when remaining_amount_cents is present" do + let(:transaction) { create(:wallet_transaction, wallet:, transaction_type: :inbound, remaining_amount_cents: 5000) } + + it "converts cents to credit amount using the wallet rate" do + # 5000 cents = 50.00 EUR, at rate 2.00 => 25.0 credits + expect(transaction.remaining_credit_amount).to eq("25.0") + end + end + end +end diff --git a/spec/models/webhook_endpoint_spec.rb b/spec/models/webhook_endpoint_spec.rb new file mode 100644 index 0000000..1be2cfc --- /dev/null +++ b/spec/models/webhook_endpoint_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WebhookEndpoint do + it { is_expected.to belong_to(:organization) } + it { is_expected.to have_many(:webhooks).dependent(:delete_all) } + + it { is_expected.to validate_presence_of(:webhook_url) } + + describe "validations" do + subject(:webhook_endpoint) { build(:webhook_endpoint) } + + describe "of webhook url uniqueness" do + let(:errors) { webhook_endpoint.errors } + + context "when it is unique in scope of organization" do + it "does not add an error" do + expect(errors.where(:webhook_url, :taken)).not_to be_present + end + end + + context "when it not is unique in scope of organization" do + subject(:webhook_endpoint) do + build(:webhook_endpoint, organization:, webhook_url: organization.webhook_endpoints.first.webhook_url) + end + + let(:organization) { create(:organization) } + let(:errors) { webhook_endpoint.errors } + + before { webhook_endpoint.valid? } + + it "adds an error" do + expect(errors.where(:webhook_url, :taken)).to be_present + end + end + end + + context "when http webhook url is valid" do + before { webhook_endpoint.webhook_url = "http://foo.bar" } + + it "is valid" do + expect(webhook_endpoint).to be_valid + end + end + + context "when https webhook url is valid" do + before { webhook_endpoint.webhook_url = "https://foo.bar" } + + it "is valid" do + expect(webhook_endpoint).to be_valid + end + end + + context "when webhook url is invalid" do + before { webhook_endpoint.webhook_url = "foobar" } + + it "is invalid" do + expect(webhook_endpoint).not_to be_valid + end + end + + describe "event_type validity" do + context "when nil" do + before { webhook_endpoint.event_types = nil } + + it "is valid" do + expect(webhook_endpoint).to be_valid + end + end + + context "when an empty array" do + before { webhook_endpoint.event_types = [] } + + it "is valid" do + expect(webhook_endpoint).to be_valid + end + end + + context "when not an array" do + before { webhook_endpoint.event_types = "not_an_array" } + + it "is not valid" do + expect(webhook_endpoint).not_to be_valid + end + end + + context "when contains valid types" do + before { webhook_endpoint.event_types = ["customer.updated"] } + + it "is valid" do + expect(webhook_endpoint).to be_valid + end + end + + context "when contains invalid types" do + before { webhook_endpoint.event_types = ["invalid.event"] } + + it "is not valid" do + expect(webhook_endpoint).not_to be_valid + end + end + end + end + + describe "callbacks" do + describe "#normalize_event_types" do + subject(:webhook_endpoint) { build(:webhook_endpoint, event_types:) } + + context "when event_types contains duplicates, blanks, mixed case and whitespaces" do + let(:event_types) { + [ + " Customer.Created ", + "invoice.drafted ", + " Invoice.DRAFTED", + nil, + " ", + "" + ] + } + + it "normalizes the event types" do + webhook_endpoint.valid? + expect(webhook_endpoint).to be_valid + expect(webhook_endpoint.event_types).to eq(["customer.created", "invoice.drafted"]) + end + end + + context "when event_types is nil" do + let(:event_types) { nil } + + it "does not change event_types" do + webhook_endpoint.valid? + expect(webhook_endpoint).to be_valid + expect(webhook_endpoint.event_types).to be_nil + end + end + + context "when event_types contains *" do + let(:event_types) { ["*"] } + + it "sets event_types to nil" do + webhook_endpoint.valid? + expect(webhook_endpoint).to be_valid + expect(webhook_endpoint.event_types).to be_nil + end + end + + context "when event_types contains * and other values" do + let(:event_types) { ["*", "customer.created"] } + + it "does not change event_types" do + webhook_endpoint.valid? + expect(webhook_endpoint).not_to be_valid + expect(webhook_endpoint.event_types).to eq(["*", "customer.created"]) + end + end + end + end + + describe "constants" do + describe "WEBHOOK_EVENT_TYPES" do + it "matches SendWebhookJob::WEBHOOK_SERVICES" do + expect(WebhookEndpoint::WEBHOOK_EVENT_TYPES).to match_array(SendWebhookJob::WEBHOOK_SERVICES.keys.map(&:to_s)) + end + end + end +end diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb new file mode 100644 index 0000000..10b557b --- /dev/null +++ b/spec/models/webhook_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhook do + subject(:webhook) { build(:webhook) } + + it { is_expected.to belong_to(:webhook_endpoint) } + it { is_expected.to belong_to(:object).optional } + it { is_expected.to belong_to(:organization) } + + describe "#payload" do + subject { webhook.payload } + + let(:webhook) { create(:webhook, payload:) } + let(:original_payload) { Faker::Types.rb_hash(number: 3).stringify_keys } + + context "when payload stored as string" do + let(:payload) { original_payload.to_json } + + it "returns payload as hash" do + expect(subject).to eq(original_payload) + end + end + + context "when payload stored as hash" do + let(:payload) { original_payload } + + it "returns payload as hash" do + expect(subject).to eq(original_payload) + end + end + end + + describe "#generate_headers" do + subject { webhook.generate_headers } + + let(:webhook) { create(:webhook, webhook_endpoint:) } + let(:webhook_endpoint) { create(:webhook_endpoint, signature_algo:) } + + context "when signature algorithm is JWT" do + let(:signature_algo) { :jwt } + + it "returns headers" do + expect(subject).to eq( + "X-Lago-Signature" => webhook.jwt_signature, + "X-Lago-Signature-Algorithm" => "jwt", + "X-Lago-Unique-Key" => webhook.id + ) + end + end + + context "when signature algorithm is HMAC" do + let(:signature_algo) { :hmac } + + it "returns headers" do + expect(subject).to eq( + "X-Lago-Signature" => webhook.hmac_signature, + "X-Lago-Signature-Algorithm" => "hmac", + "X-Lago-Unique-Key" => webhook.id + ) + end + end + end + + describe "#jwt_signature" do + let(:decoded_signature) do + JWT.decode( + webhook.jwt_signature, + RsaPublicKey, + true, + { + algorithm: "RS256", + iss: ENV["LAGO_API_URL"], + verify_iss: true + } + ) + end + + let(:expected_signature) do + [ + {"data" => webhook.payload.to_json, "iss" => "https://api.lago.dev"}, + {"alg" => "RS256"} + ] + end + + it "generates a correct jwt signature" do + expect(decoded_signature).to eq expected_signature + end + end + + describe "#hmac_signature" do + subject { webhook.hmac_signature } + + let(:webhook) { create(:webhook) } + + let(:expected_signature) do + hmac = OpenSSL::HMAC.digest( + "sha-256", + webhook.organization.hmac_key, + webhook.payload.to_json + ) + + Base64.strict_encode64(hmac) + end + + it "returns HMAC signature as base 64 encoded string" do + expect(subject).to eq expected_signature + end + end +end diff --git a/spec/organization_id_column_spec.rb b/spec/organization_id_column_spec.rb new file mode 100644 index 0000000..c511179 --- /dev/null +++ b/spec/organization_id_column_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +Rspec.describe "All tables must have an organization_id" do + let(:internal_tables) do + %w[ + active_storage_attachments + active_storage_blobs + active_storage_variant_records + ar_internal_metadata + schema_migrations + ] + end + + let(:tables_to_skip) do + %w[ + organizations + users + applied_add_ons + group_properties + groups + password_resets + user_devices + versions + ] + end + + it do + query = <<~SQL + SELECT DISTINCT + table_name + FROM + information_schema.columns + WHERE + table_schema = 'public' + AND table_name NOT IN ( + SELECT + table_name + FROM + information_schema.columns + WHERE + table_schema = 'public' + AND column_name = 'organization_id' + ); + SQL + + tables_without_organization_id = ActiveRecord::Base.connection.execute(query).to_a + .map { |r| r["table_name"] } + .reject { |table| internal_tables.include?(table) || tables_to_skip.include?(table) } + .sort + + expect(tables_without_organization_id).to be_empty + end +end diff --git a/spec/performance/credit_notes_index_spec.rb b/spec/performance/credit_notes_index_spec.rb new file mode 100644 index 0000000..34c91d3 --- /dev/null +++ b/spec/performance/credit_notes_index_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +# Performance benchmark for the credit notes index action. +# +# Runs both the old (N+1) and new (eager-loaded) query strategies against the +# same dataset in the same spec run — no git operations or stashing needed. +# +# Usage: +# - Uncomment lines 54, 68 and 120 to see the output +# - Change CREDIT_NOTE_COUNT and ITEMS_PER_NOTE to the desired number of records +# - Run bundle exec rspec spec/performance/credit_notes_index_spec.rb --format documentation + +require "rails_helper" +require "benchmark" + +CREDIT_NOTE_COUNT = 2 +ITEMS_PER_NOTE = 6 # 3 subscription fees + 3 charge fees + +# rubocop:disable Lint/UselessAssignment +RSpec.describe "Credit notes index performance", type: :request do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, :with_stripe_payment_provider, organization:) } + let(:membership) { create(:membership, organization:) } + + before do + subscription = create(:subscription, customer:, organization:) + + CREDIT_NOTE_COUNT.times do + inv = create(:invoice, customer:, organization:) + + fees = 3.times.flat_map do + [ + create(:fee, invoice: inv, subscription:, organization:), + create(:charge_fee, invoice: inv, subscription:, organization:) + ] + end + + cn = create(:credit_note, customer:, invoice: inv, organization:) + fees.each { |fee| create(:credit_note_item, credit_note: cn, fee:, organization:) } + create(:credit_note_applied_tax, credit_note: cn, organization:) + end + end + + def measure(label) + query_count = 0 + counter = ->(*) { query_count += 1 } + + elapsed_ms = Benchmark.realtime do + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + yield + end + end * 1000 + + # puts "\n %-60s queries: %3d time: %6.1fms" % [label, query_count, elapsed_ms] + query_count + end + + def serialize(credit_notes) + CollectionSerializer.new( + credit_notes, + V1::CreditNoteSerializer, + collection_name: "credit_notes", + includes: %i[items applied_taxes error_details customer] + ).serialize + end + + it "compares old vs new query strategy — REST serialization path" do + # puts "\n\n === Credit notes index: old vs new (#{CREDIT_NOTE_COUNT} notes, #{ITEMS_PER_NOTE} fee items each) ===\n" + + old_count = measure("OLD — customer join, items only, no fee tree") do + credit_notes = CreditNote + .joins(:customer) + .where("customers.organization_id = ?", organization.id) + .finalized + .includes( + :customer, + :items, + :applied_taxes, + :file_attachment, + :xml_file_attachment, + :error_details, + :metadata, + invoice: :billing_entity + ) + .page(1).per(CREDIT_NOTE_COUNT) + + serialize(credit_notes) + end + + new_count = measure("NEW — full fee tree preloaded, items.sort_by in memory") do + credit_notes = CreditNote + .where(organization:) + .finalized + .includes( + :applied_taxes, + :file_attachment, + :xml_file_attachment, + :error_details, + :metadata, + invoice: :billing_entity, + items: { + fee: [ + :charge_filter, + :charge, + :billable_metric, + :invoice, + :pricing_unit_usage, + :true_up_fee, + {subscription: :plan}, + :customer + ] + }, + customer: [:billing_entity, :metadata, :stripe_customer, :gocardless_customer, :cashfree_customer, :adyen_customer, :moneyhash_customer] + ).page(1).per(CREDIT_NOTE_COUNT) + + serialize(credit_notes) + end + + reduction = ((old_count - new_count).to_f / old_count * 100).round + # puts "\n Reduction: #{old_count} → #{new_count} queries (#{reduction}% fewer)\n\n" + + expect(new_count).to be < old_count + end +end +# rubocop:enable Lint/UselessAssignment diff --git a/spec/queries/activity_logs_query_spec.rb b/spec/queries/activity_logs_query_spec.rb new file mode 100644 index 0000000..316acaa --- /dev/null +++ b/spec/queries/activity_logs_query_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ActivityLogsQuery, clickhouse: true do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:returned_ids) { result.activity_logs.pluck(:activity_id) } + let(:organization) { activity_log.organization } + let(:activity_log) { create(:clickhouse_activity_log) } + let(:pagination) { {page: 1, limit: 10} } + let(:filters) { nil } + + before do + activity_log + end + + it "returns all activity logs" do + expect(result.activity_logs.count).to eq(1) + expect(returned_ids).to include(activity_log.activity_id) + end + + context "with old activity logs" do + let(:old_activity_log) do + create(:clickhouse_activity_log, + organization: organization, + resource: activity_log.resource, + logged_at: 33.days.ago) + end + + before do + old_activity_log + end + + context "with audit_logs_period value" do + before { organization.update(audit_logs_period: 30) } + + it "returns only recent ones" do + expect(result.activity_logs.count).to eq(1) + expect(returned_ids).to eq([activity_log.activity_id]) + end + end + + context "without audit_logs_period value" do + before { organization.update(audit_logs_period: nil) } + + it "returns all" do + expect(result.activity_logs.count).to eq(2) + expect(returned_ids).to eq([activity_log.activity_id, old_activity_log.activity_id]) + end + end + end + + context "with pagination" do + let(:pagination) { {page: 1, limit: 1} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.activity_logs.count).to eq(1) + expect(result.activity_logs.current_page).to eq(1) + expect(result.activity_logs.prev_page).to be_nil + expect(result.activity_logs.next_page).to be_nil + expect(result.activity_logs.total_pages).to eq(1) + expect(result.activity_logs.total_count).to eq(1) + end + end + + context "with from_date and to_date filters" do + it "returns expected activity logs" do + filters = {from_date: activity_log.logged_at + 1.day} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + + filters = {from_date: activity_log.logged_at - 1.day, to_date: activity_log.logged_at + 1.day} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {to_date: activity_log.logged_at - 1.day} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with api_key_ids filter" do + it "returns expected activity logs" do + filters = {api_key_ids: [activity_log.api_key_id]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {api_key_ids: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with activity_ids filter" do + it "returns expected activity logs" do + filters = {activity_ids: [activity_log.activity_id]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {activity_ids: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with activity_types filter" do + it "returns expected activity logs" do + filters = {activity_types: [activity_log.activity_type]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {activity_types: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with activity_sources filter" do + it "returns expected activity logs" do + filters = {activity_sources: [activity_log.activity_source]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {activity_sources: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with user_emails filter" do + it "returns expected activity logs" do + filters = {user_emails: [activity_log.user.email]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {user_emails: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with external_customer_id filter" do + it "returns expected activity logs" do + filters = {external_customer_id: activity_log.external_customer_id} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {external_customer_id: "other"} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with external_subscription_id filter" do + it "returns expected activity logs" do + filters = {external_subscription_id: activity_log.external_subscription_id} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {external_subscription_id: "other"} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with resource_ids filter" do + it "returns expected activity logs" do + filters = {resource_ids: [activity_log.resource_id]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {resource_ids: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "with resource_types filter" do + it "returns expected activity logs" do + filters = {resource_types: [activity_log.resource_type]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs.first.activity_id).to eq(activity_log.activity_id) + + filters = {resource_types: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).activity_logs).to be_empty + end + end + + context "when activty logs are not available" do + before do + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ForbiddenFailure) } + it { expect(result.error.code).to eq("feature_unavailable") } + end +end diff --git a/spec/queries/add_ons_query_spec.rb b/spec/queries/add_ons_query_spec.rb new file mode 100644 index 0000000..820c20e --- /dev/null +++ b/spec/queries/add_ons_query_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AddOnsQuery do + subject(:result) do + described_class.call(organization:, pagination:, search_term:) + end + + let(:returned_ids) { result.add_ons.pluck(:id) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:add_on_first) { create(:add_on, organization:, name: "defgh", code: "11") } + let(:add_on_second) { create(:add_on, organization:, name: "abcde", code: "22") } + let(:add_on_third) { create(:add_on, organization:, name: "presuv", code: "33") } + let(:pagination) { {page: 1, limit: 10} } + let(:search_term) { nil } + + before do + add_on_first + add_on_second + add_on_third + end + + it "returns all add_ons" do + expect(result.add_ons.count).to eq(3) + expect(returned_ids).to include(add_on_first.id) + expect(returned_ids).to include(add_on_second.id) + expect(returned_ids).to include(add_on_third.id) + end + + context "when add ons have the same values for the ordering criteria" do + let(:add_on_second) do + create( + :add_on, + organization:, + id: "00000000-0000-0000-0000-000000000000", + name: "abcde", + code: "22", + created_at: add_on_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(add_on_first.id) + expect(returned_ids).to include(add_on_second.id) + expect(returned_ids.index(add_on_first.id)).to be > returned_ids.index(add_on_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.add_ons.count).to eq(1) + expect(result.add_ons.current_page).to eq(2) + expect(result.add_ons.prev_page).to eq(1) + expect(result.add_ons.next_page).to be_nil + expect(result.add_ons.total_pages).to eq(2) + expect(result.add_ons.total_count).to eq(3) + end + end + + context "when searching for /de/ term" do + let(:search_term) { "de" } + + it "returns only two add_ons" do + expect(result.add_ons.count).to eq(2) + expect(returned_ids).to include(add_on_first.id) + expect(returned_ids).to include(add_on_second.id) + expect(returned_ids).not_to include(add_on_third.id) + end + end + + context "when searching for /1/ term" do + let(:search_term) { "1" } + + it "returns only two add_ons" do + expect(result.add_ons.count).to eq(1) + expect(returned_ids).to include(add_on_first.id) + expect(returned_ids).not_to include(add_on_second.id) + expect(returned_ids).not_to include(add_on_third.id) + end + end +end diff --git a/spec/queries/api_logs_query_spec.rb b/spec/queries/api_logs_query_spec.rb new file mode 100644 index 0000000..b7ba3f0 --- /dev/null +++ b/spec/queries/api_logs_query_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiLogsQuery, clickhouse: true do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:returned_ids) { result.api_logs.pluck(:request_id) } + let(:organization) { api_log.organization } + let(:api_log) { create(:clickhouse_api_log) } + let(:pagination) { {page: 1, limit: 10} } + let(:filters) { nil } + + before do + api_log + end + + it "returns all api logs" do + expect(result.api_logs.count).to eq(1) + expect(returned_ids).to include(api_log.request_id) + end + + context "with old api logs" do + let(:old_api_log) do + create( + :clickhouse_api_log, + organization:, + logged_at: 33.days.ago + ) + end + + before do + old_api_log + end + + context "with audit_logs_period value" do + before { organization.update(audit_logs_period: 30) } + + it "returns only recent ones" do + expect(result.api_logs.count).to eq(1) + expect(returned_ids).to eq([api_log.request_id]) + end + end + + context "without audit_logs_period value" do + before { organization.update(audit_logs_period: nil) } + + it "returns all" do + expect(result.api_logs.count).to eq(2) + expect(returned_ids).to eq([api_log.request_id, old_api_log.request_id]) + end + end + end + + context "with pagination" do + let(:pagination) { {page: 1, limit: 1} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.api_logs.count).to eq(1) + expect(result.api_logs.current_page).to eq(1) + expect(result.api_logs.prev_page).to be_nil + expect(result.api_logs.next_page).to be_nil + expect(result.api_logs.total_pages).to eq(1) + expect(result.api_logs.total_count).to eq(1) + end + end + + context "with from_date and to_date filters" do + it "returns expected api logs" do + filters = {from_date: api_log.logged_at + 1.day} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + + filters = {from_date: api_log.logged_at - 1.day, to_date: api_log.logged_at + 1.day} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {to_date: api_log.logged_at - 1.day} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + end + end + + context "with api_key_ids filter" do + it "returns expected api logs" do + filters = {api_key_ids: [api_log.api_key_id]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {api_key_ids: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + end + end + + context "with http_methods filter" do + it "returns expected api logs" do + filters = {http_methods: [api_log.http_method]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {http_methods: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + end + end + + context "with http_statuses filter" do + it "returns expected api logs" do + filters = {http_statuses: api_log.http_status} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {http_statuses: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + end + + context "when succeeded" do + it "returns expected api logs" do + filters = {http_statuses: ["succeeded"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + end + end + + context "when failed" do + let(:failed_api_log) { create(:clickhouse_api_log, organization:, http_status: 404) } + + before { failed_api_log } + + it "returns expected api logs" do + filters = {http_statuses: ["failed"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(failed_api_log.request_id) + end + end + + context "when not an array" do + it "returns expected api logs" do + filters = {http_statuses: 200} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + end + end + end + + context "with api_version filter" do + it "returns expected api logs" do + filters = {api_version: api_log.api_version} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + end + end + + context "with request_paths filter" do + let(:api_log) { create(:clickhouse_api_log, request_path: "/v1/billable_metrics/111222333") } + + it "returns expected api logs" do + filters = {request_paths: [api_log.request_path]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {request_paths: ["/v1/billable_metrics/*"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {request_paths: ["/v1/*/111222333"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {request_paths: ["*billable_metrics*"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {request_paths: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + end + end + + context "with request_ids filter" do + let(:filler_api_log) { create(:clickhouse_api_log, organization:, http_status: 404) } + let(:sorted_ids) { [api_log.request_id, filler_api_log.request_id].sort } + + it "returns expected api logs" do + filters = {request_ids: sorted_ids} + expect(described_class.call(organization:, pagination:, filters:).api_logs.map(&:request_id).sort).to eq(sorted_ids) + + filters = {request_ids: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + end + end + + context "with clients filter" do + it "returns expected api logs" do + filters = {clients: [api_log.client]} + expect(described_class.call(organization:, pagination:, filters:).api_logs.first.request_id).to eq(api_log.request_id) + + filters = {clients: ["other"]} + expect(described_class.call(organization:, pagination:, filters:).api_logs).to be_empty + end + end + + context "when api logs are not available" do + before do + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ForbiddenFailure) } + it { expect(result.error.code).to eq("feature_unavailable") } + end +end diff --git a/spec/queries/applied_coupons_query_spec.rb b/spec/queries/applied_coupons_query_spec.rb new file mode 100644 index 0000000..9aeacfd --- /dev/null +++ b/spec/queries/applied_coupons_query_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedCouponsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:organization) { create(:organization) } + let(:pagination) { nil } + let(:filters) { {} } + + let(:customer_1) { create(:customer, organization:) } + let(:coupon_1) { create(:coupon, organization:) } + let(:customer_2) { create(:customer, organization:) } + let(:coupon_2) { create(:coupon, organization:) } + + let!(:applied_coupon_1) { create(:applied_coupon, coupon: coupon_1, customer: customer_1) } + let!(:applied_coupon_2) { create(:applied_coupon, coupon: coupon_2, customer: customer_2) } + + it "returns a list of applied_coupons" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(2) + expect(result.applied_coupons).to eq([applied_coupon_2, applied_coupon_1]) + end + + context "when applied coupons have the same values for the ordering criteria" do + let!(:applied_coupon_3) do + create( + :applied_coupon, + coupon: coupon_2, + customer: customer_2, + id: "00000000-0000-0000-0000-000000000000", + created_at: applied_coupon_2.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(3) + expect(result.applied_coupons).to eq([applied_coupon_3, applied_coupon_2, applied_coupon_1]) + end + end + + context "when customer is deleted" do + let(:customer_1) { create(:customer, :deleted, organization:) } + + it "filters the applied_coupons" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(1) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 10} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(0) + expect(result.applied_coupons.current_page).to eq(2) + end + end + + context "with customer filter" do + let(:filters) { {external_customer_id: customer_1.external_id} } + + it "applies the filter" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(1) + expect(result.applied_coupons).to eq([applied_coupon_1]) + end + end + + context "with status filter" do + let(:filters) { {status: "terminated"} } + + it "applies the filter" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(0) + end + end + + context "with coupon code filter" do + let(:filters) { {coupon_code: [coupon_2.code]} } + + it "applies the filter for multiple codes" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(1) + expect(result.applied_coupons).to match_array([applied_coupon_2]) + end + + context "when the coupon is deleted" do + let(:coupon_2) { create(:coupon, :deleted, organization:) } + let!(:applied_coupon_2) do + create( + :applied_coupon, + :terminated, + customer: customer_2, + coupon: coupon_2 + ) + end + + it "returns the applied coupon" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(1) + expect(result.applied_coupons).to match_array([applied_coupon_2]) + end + end + + context "when coupon code is not found" do + let(:filters) { {coupon_code: "nonexistent"} } + + it "returns an empty list" do + expect(result).to be_success + expect(result.applied_coupons.count).to eq(0) + end + end + end +end diff --git a/spec/queries/base_filters_spec.rb b/spec/queries/base_filters_spec.rb new file mode 100644 index 0000000..1fc3357 --- /dev/null +++ b/spec/queries/base_filters_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BaseFilters do + dummy_filters = described_class[:value, :name] + + subject(:filters) { dummy_filters.new(**attributes) } + + let(:attributes) { {value: "test", name: "test"} } + + describe "filters" do + it "returns the list of filters" do + expect(filters.filters).to eq({"value" => "test", "name" => "test"}) + + expect(filters.value).to eql("test") + expect(filters.name).to eql("test") + end + + context "with unexpected attributes" do + let(:attributes) { {value: "test", name: "test", unexpected: "unexpected"} } + + it "ignores unexpected attributes" do + expect(filters.filters).to eq({"value" => "test", "name" => "test"}) + end + end + end + + describe "key?" do + it "returns whether the filter exists" do + expect(filters.key?(:value)).to be(true) + expect(filters.key?(:name)).to be(true) + expect(filters.key?(:unexpected)).to be(false) + expect(filters.key?(:not_defined)).to be(false) + end + end + + describe "[]" do + it "returns the value of the filter" do + expect(filters[:value]).to eq("test") + end + + context "with querying unexpected keys" do + it "returns nil" do + expect(filters[:unexpected]).to be_nil + end + end + end +end diff --git a/spec/queries/billable_metrics_query_spec.rb b/spec/queries/billable_metrics_query_spec.rb new file mode 100644 index 0000000..80066d3 --- /dev/null +++ b/spec/queries/billable_metrics_query_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetricsQuery do + subject(:result) do + described_class.call(organization:, search_term:, pagination:, filters:) + end + + let(:returned_ids) { result.billable_metrics.map(&:id) } + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { {} } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billable_metric_first) { create(:billable_metric, organization:, name: "defgh", code: "11") } + let(:billable_metric_second) { create(:billable_metric, organization:, name: "abcde", code: "22") } + let(:billable_metric_third) { create(:billable_metric, organization:, name: "presuv", code: "33") } + let(:billable_metric_fourth) { create(:unique_count_billable_metric, organization:, name: "qwerty", code: "44") } + + before do + billable_metric_first + billable_metric_second + billable_metric_third + billable_metric_fourth + end + + it "returns all billable metrics" do + expect(result).to be_success + expect(returned_ids.count).to eq(4) + expect(returned_ids).to include(billable_metric_first.id) + expect(returned_ids).to include(billable_metric_second.id) + expect(returned_ids).to include(billable_metric_third.id) + expect(returned_ids).to include(billable_metric_fourth.id) + end + + context "when billable metrics have the same values for the ordering criteria" do + let(:billable_metric_second) do + create( + :billable_metric, + organization:, + id: "00000000-0000-0000-0000-000000000000", + name: "abcde", + code: "22", + created_at: billable_metric_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(4) + expect(returned_ids).to include(billable_metric_first.id) + expect(returned_ids).to include(billable_metric_second.id) + expect(returned_ids.index(billable_metric_first.id)).to be > returned_ids.index(billable_metric_second.id) + end + end + + context "when filters validation fails" do + let(:filters) do + { + recurring: "unexpected_value", + aggregation_types: ["unexpected_value"] + } + end + + it "captures all validation errors" do + expect(result).not_to be_success + expect(result.error.messages[:recurring]).to include("must be boolean") + expect(result.error.messages[:aggregation_types][0]).to include("must be one of: max_agg, count_agg") + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 3} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.billable_metrics.count).to eq(1) + expect(result.billable_metrics.current_page).to eq(2) + expect(result.billable_metrics.prev_page).to eq(1) + expect(result.billable_metrics.next_page).to be_nil + expect(result.billable_metrics.total_pages).to eq(2) + expect(result.billable_metrics.total_count).to eq(4) + end + end + + context "when filtering by recurring billable metrics" do + let(:billable_metric_recurring) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + name: "defghz", + code: "55", + field_name: "test", + recurring: true + ) + end + + let(:filters) { {recurring: true} } + + before { billable_metric_recurring } + + it "returns 1 billable metric" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(billable_metric_first.id) + expect(returned_ids).not_to include(billable_metric_second.id) + expect(returned_ids).not_to include(billable_metric_third.id) + expect(returned_ids).not_to include(billable_metric_fourth.id) + expect(returned_ids).to include(billable_metric_recurring.id) + end + end + + context "when filtering by count_agg aggregation type" do + let(:filters) { {aggregation_types: ["count_agg"]} } + + it "returns 3 billable metrics" do + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(billable_metric_first.id) + expect(returned_ids).to include(billable_metric_second.id) + expect(returned_ids).to include(billable_metric_third.id) + expect(returned_ids).not_to include(billable_metric_fourth.id) + end + end + + context "when filtering by max_agg aggregation type" do + let(:filters) { {aggregation_types: ["max_agg"]} } + + it "returns 0 billable metrics" do + expect(returned_ids.count).to eq(0) + expect(returned_ids).not_to include(billable_metric_first.id) + expect(returned_ids).not_to include(billable_metric_second.id) + expect(returned_ids).not_to include(billable_metric_third.id) + expect(returned_ids).not_to include(billable_metric_fourth.id) + end + end + + context "when searching for /de/ term" do + let(:search_term) { "de" } + + it "returns only two billable metrics" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(billable_metric_first.id) + expect(returned_ids).to include(billable_metric_second.id) + expect(returned_ids).not_to include(billable_metric_third.id) + end + end + + context "when filtering by plan_id" do + let(:plan) { create(:plan, organization:) } + let(:filters) { {plan_id: plan.id} } + + before do + create(:standard_charge, plan:, billable_metric: billable_metric_first) + create(:standard_charge, plan:, billable_metric: billable_metric_first) + end + + it "returns only billable metrics associated with the plan" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(billable_metric_first.id) + expect(returned_ids).not_to include(billable_metric_second.id) + expect(returned_ids).not_to include(billable_metric_third.id) + expect(returned_ids).not_to include(billable_metric_fourth.id) + end + end +end diff --git a/spec/queries/coupons_query_spec.rb b/spec/queries/coupons_query_spec.rb new file mode 100644 index 0000000..c2263d9 --- /dev/null +++ b/spec/queries/coupons_query_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CouponsQuery do + subject(:result) do + described_class.call(organization:, search_term:, pagination:, filters:) + end + + let(:returned_ids) { result.coupons.pluck(:id) } + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { {} } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon_first) { create(:coupon, organization:, status: "active", name: "defgh", code: "11") } + let(:coupon_second) { create(:coupon, organization:, status: "terminated", name: "abcde", code: "22") } + let(:coupon_third) { create(:coupon, organization:, status: "active", name: "presuv", code: "33") } + + before do + coupon_first + coupon_second + coupon_third + end + + it "returns all coupons" do + expect(result.coupons.count).to eq(3) + expect(returned_ids).to include(coupon_first.id) + expect(returned_ids).to include(coupon_second.id) + expect(returned_ids).to include(coupon_third.id) + end + + context "when coupons have the same values for the ordering criteria" do + let(:coupon_second) do + create( + :coupon, + organization:, + id: "00000000-0000-0000-0000-000000000000", + status: "active", + name: "defgh", + code: "22", + created_at: coupon_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(coupon_first.id) + expect(returned_ids).to include(coupon_second.id) + expect(returned_ids.index(coupon_first.id)).to be > returned_ids.index(coupon_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.coupons.count).to eq(1) + expect(result.coupons.current_page).to eq(2) + expect(result.coupons.prev_page).to eq(1) + expect(result.coupons.next_page).to be_nil + expect(result.coupons.total_pages).to eq(2) + expect(result.coupons.total_count).to eq(3) + end + end + + context "when searching for /de/ term" do + let(:search_term) { "de" } + + it "returns only two coupons" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(coupon_first.id) + expect(returned_ids).to include(coupon_second.id) + expect(returned_ids).not_to include(coupon_third.id) + end + end + + context "when filtering by terminated status" do + let(:filters) { {status: "terminated"} } + + it "returns only two coupons" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(coupon_first.id) + expect(returned_ids).to include(coupon_second.id) + expect(returned_ids).not_to include(coupon_third.id) + end + end +end diff --git a/spec/queries/credit_notes_query_spec.rb b/spec/queries/credit_notes_query_spec.rb new file mode 100644 index 0000000..f2c63c8 --- /dev/null +++ b/spec/queries/credit_notes_query_spec.rb @@ -0,0 +1,685 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotesQuery do + subject(:result) do + described_class.call(organization:, search_term:, pagination:, filters:) + end + + let(:returned_ids) { result.credit_notes.pluck(:id) } + + let(:organization) { customer.organization } + let(:customer) { create(:customer) } + + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { {} } + + context "when no filters applied" do + let!(:credit_notes) { create_pair(:credit_note, customer:) } + + before { create(:credit_note, :draft, customer:) } + + it "returns all finalized credit notes" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array credit_notes.pluck(:id) + end + end + + context "when pagination options provided" do + let(:pagination) { {page: 2, limit: 1} } + let!(:credit_notes) { create_list(:credit_note, 3, customer:) } + + it "returns paginated credit notes" do + expect(result).to be_success + expect(result.credit_notes).to contain_exactly credit_notes.second + expect(result.credit_notes.current_page).to eq 2 + expect(result.credit_notes.total_pages).to eq 3 + expect(result.credit_notes.total_count).to eq 3 + end + end + + context "when currency filter applied" do + let(:filters) { {currency: matching_credit_note.total_amount_currency} } + + let!(:matching_credit_note) { create(:credit_note, total_amount_currency: "EUR", customer:) } + + before { create(:credit_note, total_amount_currency: "USD", customer:) } + + it "returns credit notes with matching total amount currency" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "when customer external id filter applied" do + let(:filters) { {customer_external_id: customer.external_id} } + + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create(:customer, organization:) + create(:credit_note, customer: another_customer) + end + + it "returns credit notes with matching customer external id" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "when customer id filter applied" do + let(:filters) { {customer_id: customer.id} } + + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create(:customer, organization:) + create(:credit_note, customer: another_customer) + end + + it "returns credit notes with matching customer id" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "when reason filter applied" do + let(:matching_reasons) { CreditNote::REASON.sample(2) } + + let!(:matching_credit_notes) do + matching_reasons.map { |reason| create(:credit_note, reason:, customer:) } + end + + let!(:non_matching_credit_note) do + create( + :credit_note, + reason: CreditNote::REASON.excluding(matching_reasons).sample, + customer: + ) + end + + context "with valid options" do + let(:filters) { {reason: matching_reasons} } + + it "returns credit notes with matching reasons" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array matching_credit_notes.pluck(:id) + end + end + + context "with invalid options" do + let(:filters) { {reason: "invalid-reason"} } + + it "returns all credit notes" do + expect(result).to be_success + + expect(result.credit_notes.pluck(:id)) + .to contain_exactly(*matching_credit_notes.pluck(:id), non_matching_credit_note.id) + end + end + end + + context "when types filter applied" do + let(:filters) { {types: types} } + + let!(:credit_only) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 0, offset_amount_cents: 0) + end + + let!(:refund_only) do + create(:credit_note, customer:, credit_amount_cents: 0, refund_amount_cents: 10, offset_amount_cents: 0) + end + + let!(:offset_only) do + create(:credit_note, customer:, credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 10) + end + + let!(:credit_and_refund) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 10, offset_amount_cents: 0) + end + + let!(:credit_and_offset) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 0, offset_amount_cents: 10) + end + + context "with one type" do + context "when type is credit" do + let(:types) { "credit" } + + it "returns credit notes with positive credit amount" do + expect(result).to be_success + expect(returned_ids).to match_array([credit_only.id, credit_and_refund.id, credit_and_offset.id]) + end + end + + context "when type is refund" do + let(:types) { "refund" } + + it "returns credit notes with positive refund amount" do + expect(result).to be_success + expect(returned_ids).to match_array([refund_only.id, credit_and_refund.id]) + end + end + + context "when type is offset" do + let(:types) { "offset" } + + it "returns credit notes with positive offset amount" do + expect(result).to be_success + expect(returned_ids).to match_array([offset_only.id, credit_and_offset.id]) + end + end + end + + context "with multiple types" do + let(:types) { %w[credit refund] } + + it "returns credit notes matching any of the given types" do + expect(result).to be_success + expect(returned_ids).to match_array([credit_only.id, refund_only.id, credit_and_refund.id, credit_and_offset.id]) + end + end + + context "with invalid type" do + let(:types) { "invalid-type" } + + it "returns all credit notes" do + expect(result).to be_success + expect(returned_ids).to match_array([credit_only.id, refund_only.id, offset_only.id, credit_and_refund.id, credit_and_offset.id]) + end + end + + context "when there are no matching credit notes" do + let(:types) { "refund" } + + before do + CreditNote.where("refund_amount_cents > 0").delete_all + end + + it "returns no credit notes" do + expect(result).to be_success + expect(returned_ids).to be_empty + end + end + end + + context "when credit status filter applied" do + let(:matching_credit_statuses) { CreditNote::CREDIT_STATUS.sample(2) } + + let!(:matching_credit_notes) do + matching_credit_statuses.map do |credit_status| + create(:credit_note, credit_status:, customer:) + end + end + + let!(:non_matching_credit_note) do + create( + :credit_note, + credit_status: CreditNote::CREDIT_STATUS.excluding(matching_credit_statuses).sample, + customer: + ) + end + + context "with valid options" do + let(:filters) { {credit_status: matching_credit_statuses} } + + it "returns credit notes with matching credit statuses" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array matching_credit_notes.pluck(:id) + end + end + + context "with invalid options" do + let(:filters) { {credit_status: "invalid-credit-status"} } + + it "returns all credit notes" do + expect(result).to be_success + + expect(result.credit_notes.pluck(:id)) + .to contain_exactly(*matching_credit_notes.pluck(:id), non_matching_credit_note.id) + end + end + end + + context "when refund status filter applied" do + let(:matching_refund_statuses) { CreditNote::REFUND_STATUS.sample(2) } + + let!(:matching_credit_notes) do + matching_refund_statuses.map do |refund_status| + create(:credit_note, refund_status:, customer:) + end + end + + let!(:non_matching_credit_note) do + create( + :credit_note, + refund_status: CreditNote::REFUND_STATUS.excluding(matching_refund_statuses).sample, + customer: + ) + end + + context "with valid options" do + let(:filters) { {refund_status: matching_refund_statuses} } + + it "returns credit notes with matching refund statuses" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array matching_credit_notes.pluck(:id) + end + end + + context "with invalid options" do + let(:filters) { {refund_status: "invalid-refund-status"} } + + it "returns all credit notes" do + expect(result).to be_success + + expect(result.credit_notes.pluck(:id)) + .to contain_exactly(*matching_credit_notes.pluck(:id), non_matching_credit_note.id) + end + end + end + + context "when invoice number filter applied" do + let(:filters) { {invoice_number: matching_credit_note.invoice.number} } + + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + invoice = create(:invoice, customer:, number: "FOO-01") + create(:credit_note, customer:, invoice:) + end + + it "returns credit notes with matching invoice number" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "when issuing date filters applied" do + let(:filters) { {issuing_date_from:, issuing_date_to:} } + + let!(:credit_notes) do + (1..5).to_a.map do |i| + create(:credit_note, issuing_date: i.days.ago, customer:) + end.reverse # from oldest to newest + end + + context "when only issuing date from provided" do + let(:issuing_date_to) { nil } + + context "with valid date" do + let(:issuing_date_from) { credit_notes.second.issuing_date } + + it "returns credit notes that were issued after provided date" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array credit_notes[1..].pluck(:id) + end + end + + context "with invalid date" do + let(:issuing_date_from) { "invalid_date_value" } + + it "returns a failed result" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:issuing_date_from]).to include("invalid_date") + end + end + end + + context "when only issuing date to provided" do + let(:issuing_date_from) { nil } + + context "with valid date" do + let(:issuing_date_to) { credit_notes.fourth.issuing_date } + + it "returns credit notes that were issued before provided date" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array credit_notes[..3].pluck(:id) + end + end + + context "with invalid date" do + let(:issuing_date_to) { "invalid_date_value" } + + it "returns a failed result" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:issuing_date_to]).to include("invalid_date") + end + end + end + + context "when both issuing date from and issuing date to provided" do + let(:issuing_date_from) { credit_notes.second.issuing_date } + let(:issuing_date_to) { credit_notes.fourth.issuing_date } + + it "returns credit notes that were issued between provided dates" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array credit_notes[1..3].pluck(:id) + end + end + end + + context "when amount filters applied" do + let(:filters) { {amount_from:, amount_to:} } + + let!(:credit_notes) do + (1..5).to_a.map do |i| + create(:credit_note, total_amount_cents: i.succ * 1_000, customer:) + end # from smallest to biggest + end + + context "when only amount from provided" do + let(:amount_from) { credit_notes.second.total_amount_cents } + let(:amount_to) { nil } + + it "returns credit notes with total cents amount bigger or equal to provided value" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array credit_notes[1..].pluck(:id) + end + end + + context "when only amount to provided" do + let(:amount_from) { nil } + let(:amount_to) { credit_notes.fourth.total_amount_cents } + + it "returns credit notes with total cents amount lower or equal to provided value" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array credit_notes[..3].pluck(:id) + end + end + + context "when both amount from and amount to provided" do + let(:amount_from) { credit_notes.second.total_amount_cents } + let(:amount_to) { credit_notes.fourth.total_amount_cents } + + it "returns credit notes with total cents amount in provided range" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to match_array credit_notes[1..3].pluck(:id) + end + end + end + + context "when filtering by self_billed" do + let(:credit_note_first) do + invoice = create(:invoice, :self_billed, organization:, customer:) + + create(:credit_note, customer:, invoice:) + end + + let(:credit_note_second) do + invoice = create(:invoice, organization:, customer:) + + create(:credit_note, customer:, invoice:) + end + + before do + credit_note_first + credit_note_second + end + + context "when self_billed is true" do + let(:filters) { {self_billed: true} } + + it "returns only credit notes from self billed invoices" do + expect(returned_ids).to include(credit_note_first.id) + expect(returned_ids).not_to include(credit_note_second.id) + end + end + + context "when self_billed is false" do + let(:filters) { {self_billed: false} } + + it "returns only credit notes from non self billed invoices" do + expect(returned_ids).not_to include(credit_note_first.id) + expect(returned_ids).to include(credit_note_second.id) + end + end + + context "when self_billed is nil" do + let(:filters) { {self_billed: nil} } + + it "returns all credit notes" do + expect(returned_ids).to include(credit_note_first.id) + expect(returned_ids).to include(credit_note_second.id) + end + end + end + + context "when billing entity ids filter applied" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:filters) { {billing_entity_ids: [billing_entity.id]} } + + let(:matching_credit_note) { create(:credit_note, customer:, invoice: create(:invoice, billing_entity:)) } + let(:other_credit_note) { create(:credit_note, customer:, invoice: create(:invoice, organization:)) } + + before do + matching_credit_note + other_credit_note + end + + it "returns credit notes with matching billing entity id" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + + context "when matching credit notes from more than one billing_entity" do + let(:billing_entity_2) { create(:billing_entity, organization:) } + let(:filters) { {billing_entity_ids: [billing_entity.id, billing_entity_2.id]} } + + let(:matching_credit_note_2) { create(:credit_note, customer:, invoice: create(:invoice, billing_entity: billing_entity_2)) } + + before do + matching_credit_note_2 + end + + it "returns credit notes with matching billing entity ids" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly(matching_credit_note.id, matching_credit_note_2.id) + end + end + end + + context "when search term filter applied" do + context "with term matching credit note by id" do + let(:search_term) { matching_credit_note.id.first(10) } + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before { create(:credit_note, customer:) } + + it "returns credit notes by partially matching id" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "with term matching credit note by number" do + let(:search_term) { matching_credit_note.number.first(10) } + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + invoice = create(:invoice, customer:, number: "FOO-01") + create(:credit_note, customer:, invoice:) + end + + it "returns credit notes by partially matching number" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "with term matching credit note by customer name" do + let(:search_term) { customer.name.first(6) } + + let(:customer) do + create( + :customer, + name: "Rick Sanchez", + firstname: "Rick Ramon", + lastname: "Sanchez Spencer" + ) + end + + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create( + :customer, + organization:, + name: "Morty Smith", + firstname: "Morty Elias", + lastname: "Smith Murray" + ) + + create( + :credit_note, + customer: another_customer, + invoice: create(:invoice, customer: another_customer) + ) + end + + it "returns credit notes by partially matching customer name" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "with term matching credit note by customer first name" do + let(:search_term) { customer.firstname.first(6) } + + let(:customer) do + create( + :customer, + name: "Rick Sanchez", + firstname: "Rick Ramon", + lastname: "Sanchez Spencer" + ) + end + + let!(:matching_credit_note) do + create(:credit_note, customer:, invoice: create(:invoice, customer:)) + end + + before do + another_customer = create( + :customer, + organization:, + name: "Morty Smith", + firstname: "Morty Elias", + lastname: "Smith Murray" + ) + + create( + :credit_note, + customer: another_customer, + invoice: create(:invoice, customer: another_customer) + ) + end + + it "returns credit notes by partially matching customer first name", aggregate_failures: false do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "with term matching credit note by customer last name" do + let(:search_term) { customer.lastname.first(8) } + + let(:customer) do + create( + :customer, + name: "Rick Sanchez", + firstname: "Rick Ramon", + lastname: "Sanchez Spencer" + ) + end + + let!(:matching_credit_note) do + create(:credit_note, customer:, invoice: create(:invoice, customer:)) + end + + before do + another_customer = create( + :customer, + organization:, + name: "Morty Smith", + firstname: "Morty Elias", + lastname: "Smith Murray" + ) + + create( + :credit_note, + customer: another_customer, + invoice: create(:invoice, customer: another_customer) + ) + end + + it "returns credit notes by partially matching customer last name" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "with term matching credit note by customer external id" do + let(:search_term) { matching_credit_note.customer.external_id.first(10) } + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create(:customer, organization:) + create(:credit_note, customer: another_customer) + end + + it "returns credit notes by partially matching customer external id" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + + context "with term matching credit note by customer email" do + let(:search_term) { matching_credit_note.customer.email.first(5) } + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create(:customer, organization:) + create(:credit_note, customer: another_customer) + end + + it "returns credit notes by partially matching customer email" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly matching_credit_note.id + end + end + end + + context "with multiple filters applied at the same time" do + let(:search_term) { credit_note.number.first(5) } + + let(:filters) do + { + currency: credit_note.currency, + customer_external_id: credit_note.customer.external_id, + customer_id: credit_note.customer.id, + reason: credit_note.reason, + credit_status: credit_note.credit_status, + refund_status: credit_note.refund_status, + invoice_number: credit_note.invoice.number, + issuing_date_from: credit_note.issuing_date, + issuing_date_to: credit_note.issuing_date, + amount_from: credit_note.total_amount_cents, + amount_to: credit_note.total_amount_cents + } + end + + let!(:credit_note) { create(:credit_note, total_amount_currency: "EUR", customer:) } + + before { create(:credit_note, total_amount_currency: "USD", customer:) } + + it "returns credit notes matching all provided filters" do + expect(result).to be_success + expect(result.credit_notes.pluck(:id)).to contain_exactly credit_note.id + end + end +end diff --git a/spec/queries/customers_query_spec.rb b/spec/queries/customers_query_spec.rb new file mode 100644 index 0000000..ecd80f3 --- /dev/null +++ b/spec/queries/customers_query_spec.rb @@ -0,0 +1,465 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CustomersQuery do + subject(:result) do + described_class.call(organization:, search_term:, pagination:, filters:) + end + + let(:returned_ids) { result.customers.pluck(:id) } + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { {} } + let(:organization) { create(:organization) } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization:) } + + let(:customer_first) do + create( + :customer, + organization:, + name: "defgh", + firstname: "John", + lastname: "Doe", + legal_name: "Legalname", + external_id: "11", + email: "1@example.com", + country: "US", + state: "CA", + zipcode: "90001", + billing_entity: billing_entity1, + currency: "USD", + tax_identification_number: "US123456789", + customer_type: "company" + ) + end + let(:customer_second) do + create( + :customer, + organization:, + name: "abcde", + firstname: "Jane", + lastname: "Smith", + legal_name: "other name", + external_id: "22", + email: "2@example.com", + country: "FR", + state: "Paris", + zipcode: "75001", + billing_entity: billing_entity1, + currency: "EUR", + customer_type: "individual" + ) + end + let(:customer_third) do + create( + :customer, + organization:, + account_type: "partner", + email: "3@example.com", + external_id: "33", + firstname: "Mary", + lastname: "Johnson", + legal_name: "Company name", + name: "presuv", + country: "DE", + state: "Berlin", + zipcode: "10115", + billing_entity: billing_entity2, + currency: "EUR", + customer_type: nil + ) + end + + before do + customer_first + customer_second + customer_third + + create(:customer_metadata, customer: customer_first, key: "id", value: "1") + create(:customer_metadata, customer: customer_first, key: "name", value: "John Doe") + create(:customer_metadata, customer: customer_second, key: "id", value: "2") + end + + it "returns all customers" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(customer_first.id) + expect(returned_ids).to include(customer_second.id) + expect(returned_ids).to include(customer_third.id) + end + + context "when filtering by customer_type" do + context "when filtering by company" do + let(:filters) { {customer_type: "company"} } + + it "returns company customers" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to eq [customer_first.id] + end + end + + context "when filtering by individual" do + let(:filters) { {customer_type: "individual"} } + + it "returns the customers with nil customer_type" do + expect(returned_ids).to eq([customer_second.id]) + end + end + + context "when filtering with invalid customer_type" do + let(:filters) { {customer_types: %w[invalid]} } + + it "returns the customers with nil customer_type" do + expect(returned_ids.count).to eq(3) + end + end + end + + context "when filtering by has_customer_type" do + context "when filtering by true" do + let(:filters) { {has_customer_type: true} } + + it "returns the customers with customer_type" do + expect(returned_ids).to match_array([customer_first.id, customer_second.id]) + end + end + + context "when filtering by false" do + let(:filters) { {has_customer_type: false} } + + it "returns the customers with nil customer_type" do + expect(returned_ids).to eq([customer_third.id]) + end + end + end + + context "when filtering by partner account_type" do + let(:filters) { {account_type: %w[partner]} } + + it "returns partner accounts" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to eq [customer_third.id] + end + end + + context "when filtering by customer account_type" do + let(:filters) { {account_type: %w[customer]} } + + it "returns customer accounts" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include customer_first.id + expect(returned_ids).to include customer_second.id + end + end + + context "when filtering by billing_entity_id" do + let(:filters) { {billing_entity_ids: [billing_entity2.id]} } + + it "returns customers for the specified billing entity" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(customer_third.id) + end + end + + context "when customers have the same values for the ordering criteria" do + let(:customer_second) do + create( + :customer, + organization:, + name: "abcde", + firstname: "Jane", + lastname: "Smith", + legal_name: "other name", + external_id: "22", + email: "2@example.com", + created_at: customer_first.created_at + ).tap do |customer| + customer.update! id: "00000000-0000-0000-0000-000000000000" + end + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(customer_first.id) + expect(returned_ids).to include(customer_second.id) + expect(returned_ids.index(customer_first.id)).to be > returned_ids.index(customer_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.customers.count).to eq(1) + expect(result.customers.current_page).to eq(2) + expect(result.customers.prev_page).to eq(1) + expect(result.customers.next_page).to be_nil + expect(result.customers.total_pages).to eq(2) + expect(result.customers.total_count).to eq(3) + end + end + + context "with search_term" do + context "when searching for name 'de'" do + let(:search_term) { "de" } + + it "returns only two customers" do + expect(returned_ids).to match_array([customer_first.id, customer_second.id]) + end + end + + context "when searching for firstname 'Jane'" do + let(:search_term) { "Jane" } + + it "returns only one customer" do + expect(returned_ids).to eq([customer_second.id]) + end + end + + context "when searching for lastname 'Johnson'" do + let(:search_term) { "Johnson" } + + it "returns only one customer" do + expect(returned_ids).to eq([customer_third.id]) + end + end + + context "when searching for legalname 'Company'" do + let(:search_term) { "Company" } + + it "returns only one customer" do + expect(returned_ids).to eq([customer_third.id]) + end + end + + context "when searching for external_id '11'" do + let(:search_term) { "11" } + + it "returns only one customer" do + expect(returned_ids).to eq([customer_first.id]) + end + end + + context "when searching for email '1@e'" do + let(:search_term) { "1@e" } + + it "returns only one customer" do + expect(returned_ids).to eq([customer_first.id]) + end + end + end + + context "when filtering by countries" do + let(:filters) { {countries: ["US", "FR"]} } + + it "returns only two customers" do + expect(returned_ids).to match_array([customer_first.id, customer_second.id]) + end + + context "when filtering by invalid country" do + let(:filters) { {countries: ["INVALID"]} } + + it "returns no customers" do + expect(result).not_to be_success + expect(result.error.messages[:countries]).to match({0 => [/^must be one of: AD, AE.*XK$/]}) + end + end + end + + context "when filtering by states" do + let(:filters) { {states: ["CA", "Paris"]} } + + it "returns only two customers" do + expect(returned_ids).to match_array([customer_first.id, customer_second.id]) + end + + context "when filtering by invalid state" do + let(:filters) { {states: "INVALID"} } + + it "returns no customers" do + expect(result).not_to be_success + expect(result.error.messages[:states]).to eq(["must be an array"]) + end + end + end + + context "when filtering by zipcodes" do + let(:filters) { {zipcodes: ["10115", "75001"]} } + + it "returns only two customers" do + expect(returned_ids).to match_array([customer_third.id, customer_second.id]) + end + + context "when filtering by invalid zipcode" do + let(:filters) { {zipcodes: "INVALID"} } + + it "returns no customers" do + expect(result).not_to be_success + expect(result.error.messages[:zipcodes]).to eq(["must be an array"]) + end + end + end + + context "when searching for active subscriptions" do + let(:filters) do + {active_subscriptions_count_from: from, active_subscriptions_count_to: to} + end + let(:subscriptionless_customer) do + create(:customer, organization:, billing_entity: billing_entity1) + end + + before do + subscriptionless_customer + create(:subscription, customer: customer_first) + 2.times do + create(:subscription, customer: customer_second) + end + 3.times do + create(:subscription, customer: customer_third) + end + end + + context "without subscriptions" do + let(:from) { 0 } + let(:to) { 0 } + + it "returns customers" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to eq([subscriptionless_customer.id]) + end + end + + context "with exact subscriptions count" do + let(:from) { 2 } + let(:to) { 2 } + + it "returns customers" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to eq([customer_second.id]) + end + end + + context "with subscriptions count more than a number" do + let(:from) { 1 } + let(:to) { nil } + + it "returns customers" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(customer_second.id) + expect(returned_ids).to include(customer_third.id) + end + end + + context "with subscriptions count in a range" do + let(:from) { 1 } + let(:to) { 2 } + + it "returns customers" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(customer_first.id) + expect(returned_ids).to include(customer_second.id) + end + end + + context "with subscriptions count less than a number" do + let(:from) { nil } + let(:to) { 2 } + + it "returns customers" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(customer_first.id) + expect(returned_ids).to include(subscriptionless_customer.id) + end + end + end + + context "when filtering by currencies" do + let(:filters) { {currencies: ["USD"]} } + + it "returns only two customers" do + expect(returned_ids).to match_array([customer_first.id]) + end + + context "when filtering by invalid currency" do + let(:filters) { {currencies: ["INVALID"]} } + + it "returns no customers" do + expect(result).not_to be_success + expect(result.error.messages[:currencies]).to match({0 => [/^must be one of: AED, AFN.*ZMW$/]}) + end + end + end + + context "when filtering by has_tax_identification_number" do + context "when filtering by true" do + let(:filters) { {has_tax_identification_number: true} } + + it "returns only the customer with a tax identification number" do + expect(returned_ids).to match_array([customer_first.id]) + end + end + + context "when filtering by false" do + let(:filters) { {has_tax_identification_number: false} } + + it "returns only the customers without a tax identification number" do + expect(returned_ids).to match_array([customer_second.id, customer_third.id]) + end + end + end + + context "when filtering by metadata" do + context "when filtering by presence" do + let(:filters) { {metadata: {id: "1"}} } + + it "returns only the customers with the metadata" do + expect(returned_ids).to match_array([customer_first.id]) + end + end + + context "when filtering by absence" do + let(:filters) { {metadata: {name: ""}} } + + it "returns only the customers without the metadata" do + expect(returned_ids).to match_array([customer_second.id, customer_third.id]) + end + end + + context "when matching multiple metadata" do + let(:filters) { {metadata: {id: "1", name: "John Doe"}} } + + it "returns only the customers with the metadata" do + expect(returned_ids).to match_array([customer_first.id]) + end + end + + context "when matching one but not the other" do + let(:filters) { {metadata: {id: "1", name: "Jane Smith"}} } + + it "returns only the customers with the metadata" do + expect(returned_ids).to be_empty + end + end + + context "when filtering by presence and absence" do + let(:filters) { {metadata: {id: "2", name: ""}} } + + it "returns only the customers with the metadata" do + expect(returned_ids).to match_array([customer_second.id]) + end + end + end + + context "when filters validation fails" do + let(:filters) { {account_type: %w[random]} } + + it "captures all validation errors" do + expect(result).not_to be_success + expect(result.error.messages[:account_type]).to eq({0 => ["must be one of: customer, partner"]}) + end + end +end diff --git a/spec/queries/dunning_campaigns_query_spec.rb b/spec/queries/dunning_campaigns_query_spec.rb new file mode 100644 index 0000000..0c7d4ed --- /dev/null +++ b/spec/queries/dunning_campaigns_query_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaignsQuery do + subject(:result) do + described_class.call(organization:, pagination:, search_term:, filters:, order:) + end + + let(:returned_ids) { result.dunning_campaigns.pluck(:id) } + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { nil } + let(:order) { nil } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:default_billing_entity) { organization.default_billing_entity } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:dunning_campaign_first) do + create(:dunning_campaign, organization:, name: "defgh", code: "11") + end + let(:dunning_campaign_second) do + create(:dunning_campaign, organization:, name: "abcde", code: "22") + end + + let(:dunning_campaign_third) do + create( + :dunning_campaign, + organization:, + name: "presuv", + code: "33" + ) + end + let(:dunning_campaign_fourth) do + create(:dunning_campaign, organization:, name: "qwerty", code: "44") + end + + before do + default_billing_entity.update!(applied_dunning_campaign: dunning_campaign_first) + billing_entity.update!(applied_dunning_campaign: dunning_campaign_fourth) + dunning_campaign_second + dunning_campaign_third + end + + it "returns all dunning campaigns ordered by name asc" do + expect(result.dunning_campaigns).to eq( + [dunning_campaign_second, dunning_campaign_first, dunning_campaign_third, dunning_campaign_fourth] + ) + end + + context "when dunning campaigns have the same values for the ordering criteria" do + let(:dunning_campaign_second) do + create( + :dunning_campaign, + organization:, + id: "00000000-0000-0000-0000-000000000000", + name: dunning_campaign_first.name, + code: "22", + created_at: dunning_campaign_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(4) + expect(returned_ids).to include(dunning_campaign_first.id) + expect(returned_ids).to include(dunning_campaign_second.id) + expect(returned_ids.index(dunning_campaign_first.id)).to be > returned_ids.index(dunning_campaign_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.dunning_campaigns.count).to eq(2) + expect(result.dunning_campaigns.current_page).to eq(2) + expect(result.dunning_campaigns.prev_page).to eq(1) + expect(result.dunning_campaigns.next_page).to be_nil + expect(result.dunning_campaigns.total_pages).to eq(2) + expect(result.dunning_campaigns.total_count).to eq(4) + end + end + + context "when searching for /de/ term" do + let(:search_term) { "de" } + + it "returns only two dunning campaigns" do + expect(result.dunning_campaigns).to eq([dunning_campaign_second, dunning_campaign_first]) + end + end + + context "with applied_to_organization is false" do + let(:filters) { {applied_to_organization: false} } + + it "returns second, third and fourth campaigns" do + expect(result.dunning_campaigns).to eq([dunning_campaign_second, dunning_campaign_third, dunning_campaign_fourth]) + end + end + + context "with applied_to_organization is true" do + let(:filters) { {applied_to_organization: true} } + + it "returns only the first dunning campaign" do + expect(result.dunning_campaigns).to eq([dunning_campaign_first]) + end + end + + context "with currency filter" do + let(:filters) { {currency: "USD"} } + + before do + create( + :dunning_campaign_threshold, + dunning_campaign: dunning_campaign_first, + currency: "USD" + ) + + create( + :dunning_campaign_threshold, + dunning_campaign: dunning_campaign_first, + currency: "GBP" + ) + + create( + :dunning_campaign_threshold, + dunning_campaign: dunning_campaign_third, + currency: "EUR" + ) + end + + it "returns only dunning campaigns with a threshold matching the currency" do + expect(result.dunning_campaigns).to eq([dunning_campaign_first]) + end + + context "with multiple currencies" do + let(:filters) { {currency: ["USD", "EUR"]} } + + let(:dunning_campaign_fourth) { create(:dunning_campaign, organization:) } + + before do + create( + :dunning_campaign_threshold, + dunning_campaign: dunning_campaign_fourth, + currency: "EUR" + ) + + create( + :dunning_campaign_threshold, + dunning_campaign: dunning_campaign_fourth, + currency: "USD" + ) + end + + it "returns only dunning campaigns with a threshold matching the currency" do + expect(result.dunning_campaigns).to eq( + [ + dunning_campaign_fourth, + dunning_campaign_first, + dunning_campaign_third + ] + ) + end + end + end + + context "with order on code" do + let(:order) { "code" } + + it "returns the dunning campaigns ordered by code" do + expect(result.dunning_campaigns).to eq( + [dunning_campaign_first, dunning_campaign_second, dunning_campaign_third, dunning_campaign_fourth] + ) + end + end +end diff --git a/spec/queries/entitlement/features_query_spec.rb b/spec/queries/entitlement/features_query_spec.rb new file mode 100644 index 0000000..679ad64 --- /dev/null +++ b/spec/queries/entitlement/features_query_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::FeaturesQuery do + let(:organization) { create(:organization) } + let!(:feature1) { create(:feature, organization:, code: "seats", name: "Number of seats") } + let!(:feature2) { create(:feature, organization:, code: "storage", name: "Storage") } + let!(:feature3) { create(:feature, organization:, code: "api_calls", name: "API Calls") } + let!(:other_organization_feature) { create(:feature, organization: create(:organization), code: "other") } + + describe "#call" do + subject { described_class.call(organization:, pagination:, search_term:, filters:) } + + let(:pagination) { {page: nil, limit: nil} } + let(:search_term) { nil } + let(:filters) { {} } + + it "returns features for the organization" do + result = subject + + expect(result).to be_success + expect(result.features).to contain_exactly(feature1, feature2, feature3) + end + + it "applies pagination" do + result = described_class.call( + organization:, + pagination: {page: 1, limit: 2} + ) + + expect(result).to be_success + expect(result.features.count).to eq(2) + end + + it "applies search term to name" do + result = described_class.call( + organization:, + search_term: "seats" + ) + + expect(result).to be_success + expect(result.features).to include(feature1) + expect(result.features).not_to include(feature2, feature3) + end + + it "applies search term to code" do + result = described_class.call( + organization:, + search_term: "storage" + ) + + expect(result).to be_success + expect(result.features).to include(feature2) + expect(result.features).not_to include(feature1, feature3) + end + + it "applies search term with partial matches" do + result = described_class.call( + organization:, + search_term: "api" + ) + + expect(result).to be_success + expect(result.features).to include(feature3) + expect(result.features).not_to include(feature1, feature2) + end + + it "applies consistent ordering" do + result = subject + + expect(result).to be_success + expect(result.features.to_a).to eq(result.features.order(created_at: :desc, id: :asc).to_a) + end + + context "with pagination parameters" do + let(:pagination) { {page: 1, limit: 1} } + + it "returns paginated results" do + result = subject + + expect(result).to be_success + expect(result.features.count).to eq(1) + expect(result.features.first.code).to eq(feature3.code) + end + + it "returns different results for different pages" do + result1 = described_class.call(organization:, pagination: {page: 1, limit: 1}) + result2 = described_class.call(organization:, pagination: {page: 2, limit: 1}) + + expect(result1.features).not_to eq(result2.features) + end + end + + context "with search term" do + let(:search_term) { "seats" } + + it "filters results by search term" do + result = subject + + expect(result).to be_success + expect(result.features).to contain_exactly(feature1) + end + + context "when search term is blank" do + let(:search_term) { "" } + + it "returns all features" do + result = subject + + expect(result).to be_success + expect(result.features).to contain_exactly(feature1, feature2, feature3) + end + end + end + + context "with filters" do + let(:filters) { {organization_id: organization.id} } + + it "applies filters" do + result = subject + + expect(result).to be_success + expect(result.features).to include(feature1, feature2, feature3) + expect(result.features).not_to include(other_organization_feature) + end + end + + context "when organization has no features" do + let(:empty_organization) { create(:organization) } + + it "returns empty result" do + result = described_class.call(organization: empty_organization) + + expect(result).to be_success + expect(result.features).to be_empty + end + end + + context "with invalid pagination" do + let(:pagination) { {page: -1, limit: -1} } + + it "still returns a successful result" do + result = subject + + expect(result).to be_success + expect(result.features).to be_present + end + end + end +end diff --git a/spec/queries/entitlement/subscription_entitlement_query_spec.rb b/spec/queries/entitlement/subscription_entitlement_query_spec.rb new file mode 100644 index 0000000..268e08d --- /dev/null +++ b/spec/queries/entitlement/subscription_entitlement_query_spec.rb @@ -0,0 +1,378 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionEntitlementQuery do + subject do + described_class.call( + organization:, + filters: { + subscription_id:, + plan_id: + } + ) + end + + let(:organization) { create(:organization) } + + let(:subscription_id) { subscription.id } + let(:plan_id) { subscription.plan.parent_id || subscription.plan.id } + + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:seats) { create(:feature, organization:, code: "seats", name: "Nb users") } + let(:seats_max) { create(:privilege, feature: seats, code: "max", name: "Max", value_type: "integer") } + let(:seats_reset) { create(:privilege, feature: seats, code: "reset", name: "Password Reset", value_type: "boolean") } + + let(:storage) { create(:feature, organization:, code: "storage", name: "Storage") } + let(:storage_limit) { create(:privilege, feature: storage, code: "limit", name: "Limit", value_type: "string") } + let(:storage_type) { create(:privilege, feature: storage, code: "type", name: "Type", value_type: "select", config: {select_options: ["rom", "ram"]}) } + + let(:support) { create(:feature, organization:, code: "support", name: "Premium Support") } + + let(:other_organization_feature) { create(:feature, organization: create(:organization), code: "other") } + + before do + storage_type + storage_limit + seats_reset + seats_max + support + other_organization_feature + + # Other data that should not be retrieved + other_plan = create(:plan, organization:) + create_feature_entitlement(other_plan, seats, {seats_max => 555, seats_reset => true}) + + other_subscription = create(:subscription, organization:, plan: other_plan) + create_feature_entitlement(other_subscription, support) + create_feature_entitlement(other_subscription, seats, {seats_max => 999}) + create_feature_entitlement(other_subscription, storage, {storage_limit => 999_888}) + create(:subscription_feature_removal, subscription: other_subscription, privilege: seats_reset) + end + + def create_feature_entitlement(parent, feature, privileges = {}) + entitlement = if parent.is_a? Plan + create(:entitlement, feature:, plan: parent) + else + create(:entitlement, feature:, subscription: parent, plan: nil) + end + + privileges.each do |privilege, value| + create(:entitlement_value, entitlement:, privilege:, value:) + end + end + + def expect_subject_to_match(expected) + expect(subject.count).to eq expected.count + + result = subject.map(&:to_h).index_by { |h| h[:code] } + + expected.each do |code, feature_expectations| + privileges_expectations = feature_expectations.delete(:privileges) || {} + feature_expectations[:code] = code + expect(result[code]).to include(feature_expectations.stringify_keys) + + expect(result[code][:privileges].count).to eq privileges_expectations.count + + privileges_expectations.each do |expected_privilege| + expect(result[code][:privileges][expected_privilege[:code]]).to include expected_privilege.stringify_keys + end + end + end + + describe "#call" do + context "when plan has no features" do + context "when subscription has no overrides" do + it "returns empty result" do + expect(subject).to be_empty + end + end + + context "when subscription has feature overrides" do + it "returns only subscription features" do + create_feature_entitlement(subscription, seats, {seats_max => 100, seats_reset => true}) + create_feature_entitlement(subscription, storage, {storage_limit => "100GB", storage_type => "ram"}) + create_feature_entitlement(subscription, support) + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100"}, + {code: "reset", value: "t"} + ]}, + "storage" => {privileges: [ + {code: "limit", value: "100GB"}, + {code: "type", value: "ram"} + ]}, + "support" => {privileges: []} + }) + end + end + end + + context "when plan has features" do + before do + create_feature_entitlement(plan, seats, {seats_max => 20, seats_reset => false}) + create_feature_entitlement(plan, storage, {storage_limit => "50GB", storage_type => "rom"}) + create_feature_entitlement(plan, support) + end + + context "when subscription has no overrides" do + it "returns only plans feature" do + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "20", plan_value: "20", subscription_value: nil}, + {code: "reset", value: "f", plan_value: "f", subscription_value: nil} + ]}, + "storage" => {privileges: [ + {code: "limit", value: "50GB", plan_value: "50GB", subscription_value: nil}, + {code: "type", value: "rom", plan_value: "rom", subscription_value: nil} + ]}, + "support" => {privileges: []} + }) + end + end + + context "when subscription has privilege value overrides" do + it "returns all features with overridden values" do + create_feature_entitlement(subscription, seats, {seats_max => 100, seats_reset => true}) + create_feature_entitlement(subscription, storage, {storage_type => "ram"}) + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: "20", subscription_value: "100"}, + {code: "reset", value: "t", plan_value: "f", subscription_value: "t"} + ]}, + "storage" => {privileges: [ + {code: "limit", value: "50GB", plan_value: "50GB", subscription_value: nil}, + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]}, + "support" => {privileges: []} + }) + end + end + + context "when a plan feature was removed" do + it "doesn't return the removed feature" do + create(:subscription_feature_removal, subscription:, feature: seats) + create(:subscription_feature_removal, subscription:, feature: support) + create_feature_entitlement(subscription, storage, {storage_type => "ram"}) + + expect_subject_to_match({ + "storage" => {privileges: [ + {code: "limit", value: "50GB", plan_value: "50GB", subscription_value: nil}, + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]} + }) + end + end + + context "when a plan feature privilege was removed" do + it "doesn't return the removed privilege" do + create(:subscription_feature_removal, subscription:, privilege: seats_reset) + create(:subscription_feature_removal, subscription:, privilege: storage_limit) + create(:subscription_feature_removal, subscription:, feature: support) + create_feature_entitlement(subscription, storage, {storage_type => "ram"}) + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "20", plan_value: "20", subscription_value: nil} + ]}, + "storage" => {privileges: [ + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]} + }) + end + end + end + + describe "soft deletion" do + before do + create_feature_entitlement(plan, seats, {seats_max => 20, seats_reset => false}) + create_feature_entitlement(plan, storage, {storage_limit => "50GB", storage_type => "rom"}) + create_feature_entitlement(plan, support) + + create_feature_entitlement(subscription, seats, {seats_max => 100, seats_reset => true}) + create_feature_entitlement(subscription, storage, {storage_type => "ram"}) + + create(:subscription_feature_removal, subscription:, privilege: storage_limit) + create(:subscription_feature_removal, subscription:, feature: support) + end + + context "when features are deleted" do + it "doesn't return the deleted features" do + seats.discard! + + expect_subject_to_match({ + "storage" => {privileges: [ + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]} + }) + end + end + + context "when privileges are deleted" do + it "doesn't return the deleted features" do + storage_type.discard! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: "20", subscription_value: "100"}, + {code: "reset", value: "t", plan_value: "f", subscription_value: "t"} + ]}, + "storage" => {privileges: []} + }) + end + end + + context "when plan entitlement is deleted" do + it "doesn't return the feature" do + # NOTE: Notice that we soft delete the entitlement but don't even cleanup the entitlement_values + Entitlement::Entitlement.where(plan:, feature: seats).discard_all! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: nil, subscription_value: "100"}, + {code: "reset", value: "t", plan_value: nil, subscription_value: "t"} + ]}, + "storage" => {privileges: [ + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]} + }) + end + end + + context "when subscription entitlement is deleted" do + it "doesn't return the feature" do + # NOTE: Notice that we soft delete the entitlement but don't even cleanup the entitlement_values + # To retrieve values, the entitlement relation must exist + Entitlement::Entitlement.where(subscription:, feature: seats).discard_all! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "20", plan_value: "20", subscription_value: nil}, + {code: "reset", value: "f", plan_value: "f", subscription_value: nil} + ]}, + "storage" => {privileges: [ + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]} + }) + end + end + + context "when plan entitlement value is deleted" do + it "doesn't return the privilege value from plan" do + Entitlement::EntitlementValue.where( + entitlement: Entitlement::Entitlement.where(plan:, feature: storage), + privilege: storage_type + ).discard_all! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: "20", subscription_value: "100"}, + {code: "reset", value: "t", plan_value: "f", subscription_value: "t"} + ]}, + "storage" => {privileges: [ + {code: "type", value: "ram", plan_value: nil, subscription_value: "ram"} + ]} + }) + end + end + + context "when subscription entitlement value is deleted" do + it "doesn't return the privilege value from subscription" do + Entitlement::EntitlementValue.where( + entitlement: Entitlement::Entitlement.where(subscription:, feature: storage), + privilege: storage_type + ).discard_all! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: "20", subscription_value: "100"}, + {code: "reset", value: "t", plan_value: "f", subscription_value: "t"} + ]}, + "storage" => {privileges: [ + {code: "type", value: "rom", plan_value: "rom", subscription_value: nil} + ]} + }) + end + end + + context "when plan and subscription entitlement value is deleted" do + it "doesn't return the privilege" do + Entitlement::EntitlementValue.where( + entitlement: Entitlement::Entitlement.where(plan:, feature: storage), + privilege: storage_type + ).discard_all! + + Entitlement::EntitlementValue.where( + entitlement: Entitlement::Entitlement.where(subscription:, feature: storage), + privilege: storage_type + ).discard_all! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: "20", subscription_value: "100"}, + {code: "reset", value: "t", plan_value: "f", subscription_value: "t"} + ]}, + "storage" => {privileges: []} + }) + end + end + + context "when subscription feature removal is deleted" do + it "returns the feature" do + Entitlement::SubscriptionFeatureRemoval.where(subscription:, feature: support).discard_all! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: "20", subscription_value: "100"}, + {code: "reset", value: "t", plan_value: "f", subscription_value: "t"} + ]}, + "storage" => {privileges: [ + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]}, + "support" => {privileges: []} + }) + end + end + + context "when subscription privilege removal is deleted" do + it "returns the feature" do + Entitlement::SubscriptionFeatureRemoval.where(subscription:, privilege: storage_limit).discard_all! + + expect_subject_to_match({ + "seats" => {name: "Nb users", privileges: [ + {code: "max", value: "100", plan_value: "20", subscription_value: "100"}, + {code: "reset", value: "t", plan_value: "f", subscription_value: "t"} + ]}, + "storage" => {privileges: [ + {code: "limit", value: "50GB", plan_value: "50GB", subscription_value: nil}, + {code: "type", value: "ram", plan_value: "rom", subscription_value: "ram"} + ]} + }) + end + end + end + + describe "ordering_date" do + before do + seats_plan_ent = Entitlement::Entitlement.create!(created_at: 1.day.ago, organization:, plan:, feature: seats) + Entitlement::Entitlement.create!(created_at: 9.days.ago, organization:, plan:, feature: support) + + seats_sub_ent = Entitlement::Entitlement.create!(created_at: Time.current, organization:, subscription:, feature: seats) + + Entitlement::EntitlementValue.create!(created_at: 5.days.ago, organization:, entitlement: seats_plan_ent, value: "100", privilege: seats_max) + + Entitlement::EntitlementValue.create!(created_at: 10.days.ago, organization:, entitlement: seats_plan_ent, value: "f", privilege: seats_reset) + Entitlement::EntitlementValue.create!(created_at: 2.days.ago, organization:, entitlement: seats_sub_ent, value: "t", privilege: seats_reset) + end + + it "returns the feature ordered by plan entitlement date" do + expect(subject.map(&:code)).to eq %w[support seats] + expect(subject.find { it.code == "seats" }.privileges.map(&:code)).to eq %w[reset max] + end + end + end +end diff --git a/spec/queries/events_query_spec.rb b/spec/queries/events_query_spec.rb new file mode 100644 index 0000000..4cc75df --- /dev/null +++ b/spec/queries/events_query_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EventsQuery do + subject(:events_query) { described_class.new(organization:, pagination:, filters:) } + + let(:organization) { create(:organization) } + let(:pagination) { nil } + let(:filters) { {} } + + let(:event) { create(:event, timestamp: 1.day.ago.to_date, organization:) } + + before { event } + + describe "call" do + it "returns a list of events" do + result = events_query.call + + expect(result).to be_success + expect(result.events.count).to eq(1) + expect(result.events).to eq([event]) + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 10} } + + it "applies the pagination" do + result = events_query.call + + expect(result).to be_success + expect(result.events.count).to eq(0) + expect(result.events.current_page).to eq(2) + end + end + + context "with code filter" do + let(:event2) { create(:event, organization:) } + let(:filters) { {code: event.code} } + + before { event2 } + + it "applies the filter" do + result = events_query.call + + expect(result).to be_success + expect(result.events.count).to eq(1) + end + end + + context "with external subscription id filter" do + let(:event2) { create(:event, organization:) } + let(:filters) { {external_subscription_id: event.external_subscription_id} } + + before { event2 } + + it "applies the filter" do + result = events_query.call + + expect(result).to be_success + expect(result.events.count).to eq(1) + end + end + + context "with timestamp filters" do + let(:filters) { + { + timestamp_from: 2.days.ago.iso8601.to_date.to_s, + timestamp_to: Date.tomorrow.iso8601.to_date.to_s + } + } + + it "applies the filter" do + result = events_query.call + + expect(result).to be_success + expect(result.events.count).to eq(1) + end + + context "when a timestamp value raises ArgumentError during iso8601 parsing" do + let(:filters) { {timestamp_from: "1" * 200} } + + it "returns a validation failure instead of raising" do + result = nil + expect { result = events_query.call }.not_to raise_error + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({timestamp_from: ["invalid_date"]}) + end + end + end + + context "with timestamp_from_started filter" do + let(:started_at) { 1.day.ago } + let(:subscription) { create(:subscription, organization:, started_at:) } + + let(:event_before) { create(:event, organization:, timestamp: started_at - 1.second, external_subscription_id: subscription.external_id) } + let(:event_after) { create(:event, organization:, timestamp: started_at + 1.second, external_subscription_id: subscription.external_id) } + let(:event_other_sub) { create(:event, organization:, timestamp: started_at + 1.minute) } + let(:old_event_other_sub) { create(:event, organization:, timestamp: started_at - 2.years) } + + before do + event_before + event_after + event_other_sub + old_event_other_sub + end + + context "when timestamp_from_started_at filters is true" do + let(:filters) do + { + timestamp_from_started_at: true, + external_subscription_id: subscription.external_id + } + end + + it "returns only events after started_at" do + result = events_query.call + + expect(result).to be_success + expect(result.events.ids).to contain_exactly(event_after.id) + end + end + + context "when timestamp_from is also set" do + let(:filters) do + { + timestamp_from: started_at - 1.year, + timestamp_from_started_at: true, + external_subscription_id: subscription.external_id + } + end + + it "uses subscription started_at" do + result = events_query.call + + expect(result).to be_failure + expect(result.error.messages).to eq({timestamp_from: ["cannot be used with timestamp_from_started_at"]}) + end + + context "when subscription_external_id is missing" do + let(:filters) do + { + timestamp_from: started_at - 1.year, + timestamp_from_started_at: true + } + end + + it "returns an error" do + result = events_query.call + expect(result).to be_failure + expect(result.error.messages).to eq({ + timestamp_from: ["cannot be used with timestamp_from_started_at"], + external_subscription_id: ["required with timestamp_from_started_at"] + }) + end + end + end + + context "when subscription_external_id is missing" do + let(:filters) do + { + timestamp_from_started_at: true + } + end + + it "ignores timestamp_from_started_at" do + result = events_query.call + expect(result).to be_failure + expect(result.error.messages).to eq({external_subscription_id: ["required with timestamp_from_started_at"]}) + end + end + end + end + + describe "event model" do + let(:model) { events_query.send :event_model } + + context "when organization is using postgres" do + let(:organization) { create(:organization, clickhouse_events_store: false) } + + it { expect(model).to eq(Event) } + + context "when `enriched` filter is true" do + let(:filters) { {enriched: true} } + + it { expect(model).to eq(Event) } + end + end + + context "when organization is not using clickhouse" do + let(:organization) { create(:organization, clickhouse_events_store: true) } + + it { expect(model).to eq(Clickhouse::EventsRaw) } + + context "when `enriched` filter is true" do + let(:filters) { {enriched: true} } + + it { expect(model).to eq(Clickhouse::EventsEnriched) } + end + end + end +end diff --git a/spec/queries/fees_query_spec.rb b/spec/queries/fees_query_spec.rb new file mode 100644 index 0000000..892d510 --- /dev/null +++ b/spec/queries/fees_query_spec.rb @@ -0,0 +1,393 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FeesQuery do + subject(:fees_query) { described_class.new(organization:, pagination:, filters:) } + + let(:organization) { create(:organization) } + let(:pagination) { nil } + let(:filters) { {} } + + describe "call" do + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:fee) { create(:fee, subscription:, invoice: nil) } + + before { fee } + + it "returns a list of fees" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees).to eq([fee]) + end + + context "with multiple fees" do + let(:fee2) { create(:fee, subscription:, invoice: nil, created_at: fee.created_at) } + + before do + fee2 + fee2.update! id: "00000000-0000-0000-0000-000000000000" + end + + it "returns a consistent list when 2 fees have the same created_at" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + expect(result.fees).to eq([fee2, fee]) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 10} } + + it "applies the pagination" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(0) + expect(result.fees.current_page).to eq(2) + end + end + + context "with subscription filter" do + let(:filters) { {external_subscription_id: subscription.external_id} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + end + + context "with customer filter" do + let(:filters) { {external_customer_id: customer.external_id} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when fee is for an add_on" do + let(:add_on) { create(:add_on, organization:) } + let(:applied_add_on) { create(:applied_add_on, customer:, add_on:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:fee) { create(:add_on_fee, applied_add_on:, invoice:) } + + let(:filters) { {external_customer_id: customer.external_id} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + end + end + + context "with currency filter" do + let(:filters) { {currency: fee.amount_currency} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + end + + context "with billable metric code filter" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:) } + + let(:fee) { create(:charge_fee, charge:, subscription:, invoice: nil) } + + let(:filters) { {billable_metric_code: billable_metric.code} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + end + + context "with fee_type filter" do + let(:filters) { {fee_type: fee.fee_type} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when fee_type is invalid" do + let(:filters) { {fee_type: "foo_bar"} } + + it "returns a failed result" do + result = fees_query.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:fee_type]).to include("value_is_invalid") + end + end + end + + context "with payment_status filter" do + let(:filters) { {payment_status: fee.payment_status} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when payment_status is invalid" do + let(:filters) { {payment_status: "foo_bar"} } + + it "returns a failed result" do + result = fees_query.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_status]).to include("value_is_invalid") + end + end + end + + context "with event_transaction_id filter" do + let(:fee) do + create(:fee, subscription:, invoice: nil, pay_in_advance_event_transaction_id: "transaction-id") + end + + let(:filters) { {event_transaction_id: "transaction-id"} } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when regenerated fees exist" do + let!(:regenerated_fee) do + create(:fee, subscription:, organization:, pay_in_advance_event_transaction_id: "transaction-id") + end + + it "includes both the original and the regenerated fee" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + expect(result.fees).to include(fee, regenerated_fee) + end + + context "when multiple void/regenerate cycles occur" do + let!(:second_regenerated_fee) do + create(:fee, subscription:, organization:, pay_in_advance_event_transaction_id: "transaction-id") + end + + it "includes all fees in the chain" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(3) + expect(result.fees).to match_array([fee, regenerated_fee, second_regenerated_fee]) + end + end + end + end + + context "with created_at filters" do + let(:filters) do + { + created_at_from: (fee.created_at - 1.minute).iso8601, + created_at_to: (fee.created_at + 1.minute).iso8601 + } + end + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when fee is not covered by range" do + let(:filters) do + { + created_at_from: (fee.created_at - 2.minutes).iso8601, + created_at_to: (fee.created_at - 2.minutes).iso8601 + } + end + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(0) + end + end + + context "with invalid date" do + let(:filters) { {created_at_from: "invalid_date_value"} } + + it "returns a failed result" do + result = fees_query.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:created_at_from]).to include("invalid_date") + end + end + end + + context "with succeeded_at filters" do + let(:filters) do + { + succeeded_at_from: (fee.succeeded_at - 1.minute).iso8601, + succeeded_at_to: (fee.succeeded_at + 1.minute).iso8601 + } + end + + let(:fee) { create(:fee, :succeeded, subscription:, invoice: nil) } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when fee is not covered by range" do + let(:filters) do + { + succeeded_at_from: (fee.succeeded_at - 2.minutes).iso8601, + succeeded_at_to: (fee.succeeded_at - 2.minutes).iso8601 + } + end + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(0) + end + end + + context "with invalid date" do + let(:filters) { {succeeded_at_from: "invalid_date_value"} } + + it "returns a failed result" do + result = fees_query.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:succeeded_at_from]).to include("invalid_date") + end + end + end + + context "with failed_at filters" do + let(:filters) do + { + failed_at_from: (fee.failed_at - 1.minute).iso8601, + failed_at_to: (fee.failed_at + 1.minute).iso8601 + } + end + + let(:fee) { create(:fee, :failed, subscription:, invoice: nil) } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when fee is not covered by range" do + let(:filters) do + { + failed_at_from: (fee.failed_at - 2.minutes).iso8601, + failed_at_to: (fee.failed_at - 2.minutes).iso8601 + } + end + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(0) + end + end + + context "with invalid date" do + let(:filters) { {failed_at_from: "invalid_date_value"} } + + it "returns a failed result" do + result = fees_query.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:failed_at_from]).to include("invalid_date") + end + end + end + + context "with refunded_at filters" do + let(:filters) do + { + refunded_at_from: (fee.refunded_at - 1.minute).iso8601, + refunded_at_to: (fee.refunded_at + 1.minute).iso8601 + } + end + + let(:fee) { create(:fee, :refunded, subscription:, invoice: nil) } + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + + context "when fee is not covered by range" do + let(:filters) do + { + refunded_at_from: (fee.refunded_at - 2.minutes).iso8601, + refunded_at_to: (fee.refunded_at - 2.minutes).iso8601 + } + end + + it "applies the filter" do + result = fees_query.call + + expect(result).to be_success + expect(result.fees.count).to eq(0) + end + end + + context "with invalid date" do + let(:filters) { {refunded_at_from: "invalid_date_value"} } + + it "returns a failed result" do + result = fees_query.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refunded_at_from]).to include("invalid_date") + end + end + end + end +end diff --git a/spec/queries/integration_collection_mappings_query_spec.rb b/spec/queries/integration_collection_mappings_query_spec.rb new file mode 100644 index 0000000..03322af --- /dev/null +++ b/spec/queries/integration_collection_mappings_query_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCollectionMappingsQuery do + subject(:result) { described_class.call(organization:, pagination:, filters:) } + + let(:returned_ids) { result.integration_collection_mappings.pluck(:id) } + let(:pagination) { nil } + let(:filters) { {} } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_second) { create(:netsuite_integration, organization:) } + let(:integration_third) { create(:netsuite_integration) } + + let(:netsuite_collection_mapping_first) do + create(:netsuite_collection_mapping, integration:, mapping_type: :fallback_item) + end + + let(:netsuite_collection_mapping_second) { create(:netsuite_collection_mapping, integration:, mapping_type: :coupon) } + + let(:netsuite_collection_mapping_third) do + create(:netsuite_collection_mapping, integration: integration_second, mapping_type: :subscription_fee) + end + + let(:netsuite_collection_mapping_fourth) do + create(:netsuite_collection_mapping, integration: integration_third, mapping_type: :minimum_commitment) + end + + before do + netsuite_collection_mapping_first + netsuite_collection_mapping_second + netsuite_collection_mapping_third + netsuite_collection_mapping_fourth + end + + context "when filters are empty" do + it "returns all mappings" do + expect(result.integration_collection_mappings.count).to eq(3) + expect(returned_ids).to include(netsuite_collection_mapping_first.id) + expect(returned_ids).to include(netsuite_collection_mapping_second.id) + expect(returned_ids).to include(netsuite_collection_mapping_third.id) + expect(returned_ids).not_to include(netsuite_collection_mapping_fourth.id) + end + end + + context "when integration collection mappings have the same values for the ordering criteria" do + let(:netsuite_collection_mapping_second) do + create( + :netsuite_collection_mapping, + integration:, + id: "00000000-0000-0000-0000-000000000000", + mapping_type: :coupon, + created_at: netsuite_collection_mapping_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(netsuite_collection_mapping_first.id) + expect(returned_ids).to include(netsuite_collection_mapping_second.id) + expect(returned_ids.index(netsuite_collection_mapping_first.id)).to be > returned_ids.index(netsuite_collection_mapping_second.id) + end + end + + context "when filtering by integration id" do + let(:filters) { {integration_id: integration.id} } + + it "returns two mappings" do + expect(result.integration_collection_mappings.count).to eq(2) + expect(returned_ids).to include(netsuite_collection_mapping_first.id) + expect(returned_ids).to include(netsuite_collection_mapping_second.id) + expect(returned_ids).not_to include(netsuite_collection_mapping_third.id) + expect(returned_ids).not_to include(netsuite_collection_mapping_fourth.id) + end + end +end diff --git a/spec/queries/integration_items_query_spec.rb b/spec/queries/integration_items_query_spec.rb new file mode 100644 index 0000000..74b8e6a --- /dev/null +++ b/spec/queries/integration_items_query_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationItemsQuery do + subject(:result) do + described_class.call(organization:, search_term:, pagination:, filters:) + end + + let(:returned_ids) { result.integration_items.pluck(:id) } + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { {} } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_second) { create(:netsuite_integration, organization:) } + let(:integration_third) { create(:netsuite_integration) } + + let(:integration_item_first) { create(:integration_item, item_type: "tax", integration:) } + let(:integration_item_second) { create(:integration_item, integration: integration_second) } + let(:integration_item_third) { create(:integration_item, integration: integration_third) } + let(:integration_item_fourth) { create(:integration_item, external_name: "Findme", integration:) } + + before do + integration_item_first + integration_item_second + integration_item_third + integration_item_fourth + end + + it "returns all integration items of an organization" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(integration_item_first.id) + expect(returned_ids).to include(integration_item_second.id) + expect(returned_ids).not_to include(integration_item_third.id) + expect(returned_ids).to include(integration_item_fourth.id) + end + + context "when integration items have the same values for the ordering criteria" do + let(:integration_item_second) do + create( + :integration_item, + id: "00000000-0000-0000-0000-000000000000", + integration: integration_second, + external_id: integration_item_first.external_id, + created_at: integration_item_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(integration_item_first.id) + expect(returned_ids).to include(integration_item_second.id) + expect(returned_ids.index(integration_item_first.id)).to be > returned_ids.index(integration_item_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.integration_items.count).to eq(1) + expect(result.integration_items.current_page).to eq(2) + expect(result.integration_items.prev_page).to eq(1) + expect(result.integration_items.next_page).to be_nil + expect(result.integration_items.total_pages).to eq(2) + expect(result.integration_items.total_count).to eq(3) + end + end + + context "when filtering by integration_id" do + let(:filters) { {integration_id: integration.id} } + + it "returns all integration items of an integration" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(integration_item_first.id) + expect(returned_ids).not_to include(integration_item_second.id) + expect(returned_ids).not_to include(integration_item_third.id) + expect(returned_ids).to include(integration_item_fourth.id) + end + end + + context "when filtering by item type" do + let(:filters) { {item_type: "tax"} } + + it "returns one integration item" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(integration_item_first.id) + expect(returned_ids).not_to include(integration_item_second.id) + expect(returned_ids).not_to include(integration_item_third.id) + expect(returned_ids).not_to include(integration_item_fourth.id) + end + end + + context "when searching by name" do + let(:search_term) { "Findme" } + + it "returns one integration item" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(integration_item_first.id) + expect(returned_ids).not_to include(integration_item_second.id) + expect(returned_ids).not_to include(integration_item_third.id) + expect(returned_ids).to include(integration_item_fourth.id) + end + end +end diff --git a/spec/queries/invoices_query_spec.rb b/spec/queries/invoices_query_spec.rb new file mode 100644 index 0000000..aeec4fe --- /dev/null +++ b/spec/queries/invoices_query_spec.rb @@ -0,0 +1,899 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoicesQuery do + subject(:result) do + described_class.call(organization:, pagination:, search_term:, filters:) + end + + let(:returned_ids) { result.invoices.pluck(:id) } + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { nil } + let(:organization) { create(:organization) } + let(:billing_entity1) { organization.default_billing_entity } + let(:billing_entity2) { create(:billing_entity, organization:) } + let(:customer_first) { create(:customer, organization:, name: "Rick Sanchez", firstname: "RickFirst", lastname: "SanchezLast", email: "pickle@hotmail.com") } + let(:customer_second) { create(:customer, organization:, name: "Morty Smith", firstname: "MortyFirst", lastname: "SmithLast", email: "ilovejessica@gmail.com") } + let(:invoice_first) do + create( + :invoice, + organization:, + billing_entity: billing_entity1, + status: "finalized", + payment_status: "succeeded", + customer: customer_first, + number: "1111111111", + issuing_date: 1.week.ago + ) + end + let(:invoice_second) do + create( + :invoice, + organization:, + billing_entity: billing_entity1, + status: "finalized", + payment_status: "pending", + customer: customer_second, + number: "2222222222", + issuing_date: 2.weeks.ago + ) + end + let(:invoice_third) do + create( + :invoice, + organization:, + billing_entity: billing_entity1, + status: "finalized", + payment_status: "failed", + payment_overdue: true, + customer: customer_first, + number: "3333333333", + issuing_date: 3.weeks.ago + ) + end + let(:invoice_fourth) do + create( + :invoice, + organization:, + billing_entity: billing_entity2, + status: "draft", + payment_status: "pending", + customer: customer_second, + number: "4444444444", + currency: "USD" + ) + end + let(:invoice_fifth) do + create( + :invoice, + :credit, + organization:, + billing_entity: billing_entity2, + status: "draft", + payment_status: "pending", + customer: customer_first, + number: "5555555555" + ) + end + let(:invoice_sixth) do + create( + :invoice, + :dispute_lost, + organization:, + billing_entity: billing_entity2, + payment_status: "pending", + customer: customer_first, + number: "6666666666" + ) + end + + before do + invoice_first + invoice_second + invoice_third + invoice_fourth + invoice_fifth + invoice_sixth + end + + it "returns all invoices" do + expect(result).to be_success + expect(returned_ids.count).to eq(6) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).to include(invoice_fifth.id) + expect(returned_ids).to include(invoice_sixth.id) + end + + context "when invoices have the same values for the ordering criteria" do + let(:invoice_second) do + create( + :invoice, + organization:, + id: "00000000-0000-0000-0000-000000000000", + status: "finalized", + payment_status: "pending", + customer: customer_second, + number: "2222222222", + issuing_date: invoice_first.issuing_date, + created_at: invoice_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(6) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids.index(invoice_first.id)).to be > returned_ids.index(invoice_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 3} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.invoices.count).to eq(3) + expect(result.invoices.current_page).to eq(2) + expect(result.invoices.prev_page).to eq(1) + expect(result.invoices.next_page).to be_nil + expect(result.invoices.total_pages).to eq(2) + expect(result.invoices.total_count).to eq(6) + end + end + + context "when filtering by draft status" do + let(:filters) { {status: "draft"} } + + it "returns 2 invoices" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).to include(invoice_fifth.id) + end + end + + context "when filtering by failed payment_status" do + let(:filters) { {payment_status: "failed"} } + + it "returns 1 invoices" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + + context "when filtering by succeeded and failed payment_status" do + let(:filters) { {payment_status: ["succeeded", "failed"]} } + + it "returns 1 invoices" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + + context "when filtering by payment dispute lost" do + let(:filters) { {payment_dispute_lost: true} } + + it "returns 1 invoices" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + expect(returned_ids).to include(invoice_sixth.id) + end + end + + context "when filtering by payment dispute lost false" do + let(:filters) { {payment_dispute_lost: false} } + + it "returns 1 invoices" do + expect(returned_ids.count).to eq(5) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).to include(invoice_fifth.id) + expect(returned_ids).not_to include(invoice_sixth.id) + end + end + + context "when filtering by payment overdue" do + let(:filters) { {payment_overdue: true} } + + it "returns expected invoices" do + expect(result.invoices.pluck(:id)).to eq([invoice_third.id]) + end + end + + context "when filtering by payment overdue false" do + let(:filters) { {payment_overdue: false} } + + it "returns expected invoices" do + expect(returned_ids.count).to eq(5) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).to include(invoice_fifth.id) + expect(returned_ids).to include(invoice_sixth.id) + end + end + + context "when filtering by partially_paid" do + let(:invoice_first) do + create( + :invoice, + organization:, + status: "finalized", + payment_status: "succeeded", + customer: customer_first, + number: "1111111111", + issuing_date: 1.week.ago, + total_amount_cents: 2000, + total_paid_amount_cents: 2000 + ) + end + let(:invoice_second) do + create( + :invoice, + organization:, + status: "finalized", + payment_status: "pending", + customer: customer_second, + number: "2222222222", + issuing_date: 2.weeks.ago, + total_amount_cents: 2000, + total_paid_amount_cents: 1500 + ) + end + + context "when partially_paid is true" do + let(:filters) { {partially_paid: true} } + + it "returns only partially paid invoices" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_first.id) + end + end + + context "when partially_paid is false" do + let(:filters) { {partially_paid: false} } + + it "returns only fully paid and unpaid invoices" do + expect(returned_ids.count).to eq(5) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).to include(invoice_first.id) + end + end + + context "when partially_paid is nil" do + let(:filters) { {partially_paid: nil} } + + it "returns all invoices" do + expect(returned_ids.count).to eq(6) + end + end + end + + context "when filtering by credit invoice_type" do + let(:filters) { {invoice_type: "credit"} } + + it "returns 1 invoice" do + expect(returned_ids).to eq [invoice_fifth.id] + end + end + + context "when filtering by USD currency" do + let(:filters) { {currency: "USD"} } + + it "returns 1 invoice" do + expect(returned_ids).to eq [invoice_fourth.id] + end + end + + context "when filtering by customer_external_id" do + let(:filters) { {customer_external_id: customer_second.external_id} } + + it "returns 2 invoices" do + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + expect(returned_ids).not_to include(invoice_sixth.id) + end + + context "with searching for /2222/ term" do + let(:search_term) { "2222" } + + it "returns 1 invoices" do + expect(result.invoices.count).to eq(1) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + end + + context "when filtering by issuing_date_from" do + let(:filters) { {issuing_date_from: 2.days.ago.iso8601.to_date.to_s} } + + it "returns 4 invoices" do + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).to include(invoice_fifth.id) + expect(returned_ids).to include(invoice_sixth.id) + end + + context "with invalid date" do + let(:filters) { {issuing_date_from: "invalid_date_value"} } + + it "returns a failed result" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:issuing_date_from]).to include("invalid_date") + end + end + end + + context "when filtering by issuing_date_to" do + let(:filters) { {issuing_date_to: 2.weeks.ago.iso8601.to_date.to_s} } + + it "returns 2 invoices" do + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + expect(returned_ids).not_to include(invoice_sixth.id) + end + + context "with invalid date" do + let(:filters) { {issuing_date_to: "invalid_date_value"} } + + it "returns a failed result" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:issuing_date_to]).to include("invalid_date") + end + end + end + + context "when filtering by issuing_date from and to" do + let(:filters) do + { + issuing_date_from: 2.weeks.ago.iso8601, + issuing_date_to: 1.week.ago.iso8601 + } + end + + it "returns 2 invoices" do + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + expect(returned_ids).not_to include(invoice_sixth.id) + end + end + + context "when searching for a part of an invoice id" do + let(:search_term) { invoice_fourth.id.scan(/.{10}/).first } + + it "returns 1 invoices" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + + context "when searching an invoice number" do + let(:search_term) { invoice_first.number } + + it "returns 1 invoices" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + + context "when searching a customer external id" do + let(:search_term) { customer_second.external_id } + + it "returns 2 invoices" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + + context "when searching for /rick/ term" do + let(:search_term) { "rick" } + + it "returns 3 invoices" do + expect(returned_ids.count).to eq(4) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).to include(invoice_fifth.id) + expect(returned_ids).to include(invoice_sixth.id) + end + end + + context "when searching for /gmail/ term" do + let(:search_term) { "gmail" } + + it "returns 2 invoices" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + + context "when searching for /44444444/ term" do + let(:search_term) { "44444444" } + let(:filters) { {customer_id: customer_second.id} } + + it "returns 1 invoices" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + end + end + + context "when searching for another customer with no invoice" do + let(:filters) { {customer_id: create(:customer, organization:).id} } + + it "returns 0 invoices" do + expect(returned_ids.count).to eq(0) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + end + end + + context 'when searching for lastname "SanchezLast"' do + let(:search_term) { "SanchezLast" } + + it "returns the correct invoices for this customer" do + expect(returned_ids.count).to eq(4) + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + expect(returned_ids).to include(invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id) + expect(returned_ids).to include(invoice_fifth.id) + expect(returned_ids).to include(invoice_sixth.id) + end + end + + context 'when searching for firstname "MortyFirst"' do + let(:search_term) { "MortyFirst" } + + it "returns the correct invoices for this customer" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + expect(returned_ids).not_to include(invoice_third.id) + expect(returned_ids).to include(invoice_fourth.id) + expect(returned_ids).not_to include(invoice_fifth.id) + expect(returned_ids).not_to include(invoice_sixth.id) + end + end + + context "when search_term matches invoice number and customer name across different invoices" do + let(:search_term) { "Rick" } + let!(:invoice_with_matching_number) do + create(:invoice, organization:, customer: customer_second, number: "RICK-001") + end + + it "returns invoices matched by customer name and by invoice number" do + expect(returned_ids).to contain_exactly( + invoice_first.id, + invoice_third.id, + invoice_fifth.id, + invoice_sixth.id, + invoice_with_matching_number.id + ) + end + end + + context "when amount filters applied" do + let(:filters) { {amount_from:, amount_to:} } + + let!(:invoices) do + (2..6).to_a.map do |i| + create(:invoice, total_amount_cents: i * 1_000, organization:) + end # from smallest to biggest + end + + context "when only amount from provided" do + let(:amount_from) { invoices.second.total_amount_cents } + let(:amount_to) { nil } + + it "returns invoices with total cents amount bigger or equal to provided value" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to match_array invoices[1..].pluck(:id) + end + end + + context "when only amount to provided" do + let(:amount_from) { 100 } + let(:amount_to) { invoices.fourth.total_amount_cents } + + it "returns invoices with total cents amount lower or equal to provided value" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to match_array invoices[..3].pluck(:id) + end + end + + context "when both amount from and amount to provided" do + let(:amount_from) { invoices.second.total_amount_cents } + let(:amount_to) { invoices.fourth.total_amount_cents } + + it "returns invoices with total cents amount in provided range" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to match_array invoices[1..3].pluck(:id) + end + end + + context "when amount from and amount to are provided as strings or decimals" do + let(:amount_from) { 0.5 } + let(:amount_to) { "3000.00" } + + it "returns invoices with total cents amount in provided range" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to match_array invoices[0..1].pluck(:id) + end + end + end + + context "when metadata filters applied" do + let(:filters) { {metadata:} } + + context "when single filter provided" do + context "when value is present" do + let(:metadata) { {red: 5} } + let(:matching_invoice) { create(:invoice, organization:) } + + before do + create(:invoice_metadata, invoice: matching_invoice, key: :red, value: 5) + + create(:invoice, organization:) do |invoice| + create(:invoice_metadata, invoice:) + end + end + + it "returns invoices with matching metadata filters" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to contain_exactly matching_invoice.id + end + end + + context "when value is absent" do + let(:metadata) { {red: ""} } + + let!(:matching_invoices) do + [ + create(:invoice, organization:), + create(:invoice, organization:) do |invoice| + create(:invoice_metadata, invoice:, key: :orange, value: 3) + end + ] + end + + before do + create(:invoice, organization:) do |invoice| + create(:invoice_metadata, invoice:, key: :red, value: 5) + end + + [invoice_first, invoice_second, invoice_third, invoice_fourth, invoice_fifth, invoice_sixth].each do |invoice| + create(:invoice_metadata, invoice:, key: :red, value: 5) + end + end + + it "returns invoices without provided key metadata or without metadata at all" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to match_array matching_invoices.pluck(:id) + end + end + end + + context "when multiple filters provided" do + let(:metadata) do + { + red: 5, + orange: 3, + green: "" + } + end + + let(:pagination) { {page: 1, limit: 2} } + let!(:matching_invoices) { create_list(:invoice, 3, organization:) } + + before do + matching_invoices.each do |invoice| + create(:invoice_metadata, invoice:, key: :red, value: 5) + create(:invoice_metadata, invoice:, key: :orange, value: 3) + end + + create(:invoice, organization:) do |invoice| + create(:invoice_metadata, invoice:, key: :red, value: 5) + create(:invoice_metadata, invoice:, key: :pink, value: 7) + end + + create(:invoice, organization:) + + create(:invoice, organization:) do |invoice| + create(:invoice_metadata, invoice:, key: :red, value: 5) + create(:invoice_metadata, invoice:, key: :orange, value: 3) + create(:invoice_metadata, invoice:, key: :green, value: 1) + end + end + + it "returns invoices with matching metadata filters" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to match_array matching_invoices[1..].pluck(:id) + expect(result.invoices.total_count).to eq matching_invoices.count + end + end + end + + context "with multiple filters applied at the same time" do + let(:search_term) { invoice.number.first(5) } + + let(:filters) do + { + currency: invoice.currency, + customer_external_id: invoice.customer.external_id, + customer_id: invoice.customer.id, + invoice_type: invoice.invoice_type, + issuing_date_from: invoice.issuing_date, + issuing_date_to: invoice.issuing_date, + status: invoice.status, + payment_status: invoice.payment_status, + payment_dispute_lost: invoice.payment_dispute_lost_at.present?, + payment_overdue: invoice.payment_overdue, + amount_from: invoice.total_amount_cents, + amount_to: invoice.total_amount_cents, + metadata: invoice.metadata.to_h { |item| [item.key, item.value] } + } + end + + let!(:invoice) { create(:invoice, currency: "EUR", organization:) } + + before { create(:invoice, currency: "USD", organization:) } + + it "returns invoices matching all provided filters" do + expect(result).to be_success + expect(result.invoices.pluck(:id)).to contain_exactly invoice.id + end + end + + context "when filtering by self_billed" do + let(:invoice_first) do + create( + :invoice, + :self_billed, + organization:, + status: "finalized", + payment_status: "succeeded", + customer: customer_first, + number: "1111111111", + issuing_date: 1.week.ago, + total_amount_cents: 2000, + total_paid_amount_cents: 2000 + ) + end + let(:invoice_second) do + create( + :invoice, + organization:, + status: "finalized", + payment_status: "pending", + customer: customer_second, + number: "2222222222", + issuing_date: 2.weeks.ago, + total_amount_cents: 2000, + total_paid_amount_cents: 1500, + self_billed: false + ) + end + + context "when self_billed is true" do + let(:filters) { {self_billed: true} } + + it "returns only self billed invoices" do + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).not_to include(invoice_second.id) + end + + context "when self_billed is false" do + let(:filters) { {self_billed: false} } + + it "returns only non self billed invoices" do + expect(returned_ids).not_to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + end + end + + context "when self_billed is nil" do + let(:filters) { {self_billed: nil} } + + it "returns all invoices" do + expect(returned_ids).to include(invoice_first.id) + expect(returned_ids).to include(invoice_second.id) + end + end + end + end + + context "when filtering by billing_entity_id" do + let(:filters) { {billing_entity_ids: [billing_entity1.id]} } + + it "returns invoices for the specified billing entity" do + expect(returned_ids).to include(invoice_first.id, invoice_second.id, invoice_third.id) + expect(returned_ids).not_to include(invoice_fourth.id, invoice_fifth.id, invoice_sixth.id) + end + end + + context "when filtering by subscription_id" do + let(:invoice_with_subscription_1) { create(:invoice, :subscription, organization:) } + let(:invoice_with_subscription_2) { create(:invoice, :subscription, organization:) } + + let(:filters) { {subscription_id: [invoice_with_subscription_1.subscriptions.first.id]} } + + before do + invoice_with_subscription_1 + invoice_with_subscription_2 + end + + it "returns invoices for the specified subscription" do + expect(returned_ids).to eq([invoice_with_subscription_1.id]) + end + end + + context "when filtering by settlements" do + let(:filters) { {settlements: settlements} } + + let(:credit_note) { create(:credit_note, invoice: invoice_first, customer: invoice_first.customer, organization:) } + + before do + create( + :invoice_settlement, + organization:, + billing_entity: invoice_first.billing_entity, + target_invoice: invoice_first, + settlement_type: :credit_note, + source_credit_note: credit_note + ) + + create( + :invoice_settlement, + organization:, + billing_entity: invoice_second.billing_entity, + target_invoice: invoice_second, + settlement_type: :payment, + source_payment: create(:payment) + ) + end + + context "when settlements is an array with credit_note" do + let(:settlements) { ["credit_note"] } + + it "returns invoices with a credit note settlement" do + expect(returned_ids).to eq([invoice_first.id]) + end + end + + context "when settlements is an array with payment" do + let(:settlements) { ["payment"] } + + it "returns invoices with a payment settlement" do + expect(returned_ids).to eq([invoice_second.id]) + end + end + + context "when settlements is a string with a single value" do + let(:settlements) { "credit_note" } + + it "returns invoices with a credit note settlement" do + expect(returned_ids).to eq([invoice_first.id]) + end + end + + context "when settlements is an array with multiple values" do + let(:settlements) { %w[credit_note payment] } + + it "returns invoices matching any provided settlement type" do + expect(returned_ids).to match_array([invoice_first.id, invoice_second.id]) + end + end + + context "when there are no matching settlements" do + let(:settlements) { ["payment"] } + + before do + InvoiceSettlement.where(settlement_type: :payment).delete_all + end + + it "returns no invoices" do + expect(returned_ids).to be_empty + end + end + + context "when settlement type is invalid" do + let(:settlements) { ["invalid_type"] } + + it "returns a validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:settlements]).to be_present + end + end + end + + context "with invoices in invisible statuses" do + let!(:generating_invoice) { create(:invoice, organization:, customer: customer_first, status: :generating, number: "GEN-1") } + let!(:open_invoice) { create(:invoice, organization:, customer: customer_first, status: :open, number: "OPEN-1") } + let!(:closed_invoice) { create(:invoice, organization:, customer: customer_first, status: :closed, number: "CLOSED-1") } + + it "excludes them from the default listing" do + expect(returned_ids).not_to include(generating_invoice.id, open_invoice.id, closed_invoice.id) + end + + context "when matched by the customer-search OR branch" do + let(:search_term) { "Rick" } + + it "still excludes them" do + expect(returned_ids).not_to include(generating_invoice.id, open_invoice.id, closed_invoice.id) + end + end + + context "when only a visible status is requested via filter" do + let(:filters) { {status: ["finalized"]} } + + it "returns only invoices in that status" do + expect(returned_ids).to match_array([invoice_first.id, invoice_second.id, invoice_third.id, invoice_sixth.id]) + end + end + end +end diff --git a/spec/queries/past_usage_query_spec.rb b/spec/queries/past_usage_query_spec.rb new file mode 100644 index 0000000..26cfb15 --- /dev/null +++ b/spec/queries/past_usage_query_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PastUsageQuery do + subject(:result) { described_class.call(organization:, pagination:, filters:) } + + let(:organization) { create(:organization) } + let(:pagination) { nil } + let(:filters) do + { + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id + } + end + + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:subscription2) { create(:subscription, customer:, plan:) } + + let(:invoice_subscription1) do + create( + :invoice_subscription, + charges_from_datetime: DateTime.parse("2023-08-17T00:00:00"), + charges_to_datetime: DateTime.parse("2023-09-16T23:59:59"), + subscription: + ) + end + + let(:invoice_subscription2) do + create( + :invoice_subscription, + charges_from_datetime: DateTime.parse("2023-07-17T00:00:00"), + charges_to_datetime: DateTime.parse("2023-08-16T23:59:59"), + subscription: + ) + end + + let(:invoice_subscription3) do + create( + :invoice_subscription, + charges_from_datetime: DateTime.parse("2023-07-17T00:00:00"), + charges_to_datetime: DateTime.parse("2023-08-16T23:59:59"), + subscription: subscription2 + ) + end + + before do + invoice_subscription1 + invoice_subscription2 + end + + it "returns a list of invoice_subscription" do + expect(result).to be_success + expect(result.usage_periods.count).to eq(2) + end + + context "when invoice subscriptions have the same values for the ordering criteria" do + let(:invoice_subscription2) do + create( + :invoice_subscription, + id: "00000000-0000-0000-0000-000000000000", + charges_from_datetime: invoice_subscription1.charges_from_datetime, + charges_to_datetime: invoice_subscription1.charges_to_datetime, + subscription:, + created_at: invoice_subscription1.created_at + ) + end + + it "returns a consistent list" do + result_invoice_subscriptions_ids = result.usage_periods.map(&:invoice_subscription).map(&:id) + + expect(result).to be_success + expect(result.usage_periods.count).to eq(2) + expect(result_invoice_subscriptions_ids).to include(invoice_subscription1.id) + expect(result_invoice_subscriptions_ids).to include(invoice_subscription2.id) + expect(result_invoice_subscriptions_ids.index(invoice_subscription1.id)) + .to be > result_invoice_subscriptions_ids.index(invoice_subscription2.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + before do + create( + :invoice_subscription, + charges_from_datetime: DateTime.parse("2023-06-17T00:00:00"), + charges_to_datetime: DateTime.parse("2023-07-16T23:59:59"), + subscription: + ) + end + + it "applies the pagination" do + expect(result).to be_success + expect(result.current_page).to eq(2) + expect(result.prev_page).to eq(1) + expect(result.next_page).to be_nil + expect(result.total_pages).to eq(2) + expect(result.total_count).to eq(3) + end + end + + context "when external_customer_id is missing" do + let(:filters) { {external_subscription_id: subscription.external_id} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:external_customer_id) + expect(result.error.messages[:external_customer_id]).to include("value_is_mandatory") + end + end + + context "when external_subscription_id is missing" do + let(:filters) { {external_customer_id: customer.external_id} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:external_subscription_id) + expect(result.error.messages[:external_subscription_id]).to include("value_is_mandatory") + end + end + + context "with fees belonging to multiple subscriptions" do + let(:billable_metric1) { create(:billable_metric, organization:) } + let(:billable_metric_code) { billable_metric1&.code } + + let(:billable_metric2) { create(:billable_metric, organization:) } + + let(:charge1) { create(:standard_charge, plan:, billable_metric: billable_metric1) } + let(:charge2) { create(:standard_charge, plan:, billable_metric: billable_metric2) } + + let(:fee1) { create(:charge_fee, charge: charge1, subscription:, invoice: invoice_subscription1.invoice) } + let(:fee2) { create(:charge_fee, charge: charge2, subscription: subscription2, invoice: invoice_subscription1.invoice) } + + let(:filters) do + { + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id + } + end + + before do + invoice_subscription3 + fee1 + fee2 + end + + it "filters the fees accordingly" do + expect(result).to be_success + expect(result.usage_periods.count).to eq(2) + expect(result.usage_periods.first.fees.count).to eq(1) + expect(result.usage_periods.first.fees.first.subscription).to eq(subscription) + end + end + + context "with billable_metric_code" do + let(:billable_metric1) { create(:billable_metric, organization:) } + let(:billable_metric_code) { billable_metric1&.code } + + let(:billable_metric2) { create(:billable_metric, organization:) } + + let(:charge1) { create(:standard_charge, plan:, billable_metric: billable_metric1) } + let(:charge2) { create(:standard_charge, plan:, billable_metric: billable_metric2) } + + let(:fee1) { create(:charge_fee, charge: charge1, subscription:, invoice: invoice_subscription1.invoice) } + let(:fee2) { create(:charge_fee, charge: charge2, subscription:, invoice: invoice_subscription1.invoice) } + + let(:filters) do + { + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + billable_metric_code: + } + end + + before do + fee1 + fee2 + end + + it "filters the fees accordingly" do + expect(result).to be_success + expect(result.usage_periods.count).to eq(2) + expect(result.usage_periods.first.fees.count).to eq(1) + end + + context "when billable metric is not found" do + let(:billable_metric_code) { "unknown_code" } + + it "returns a not found failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("billable_metric_not_found") + end + end + end + + context "with periods_count filter" do + let(:periods_count) { 1 } + let(:filters) do + { + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + periods_count: + } + end + + it "returns last requested periods" do + expect(result).to be_success + expect(result.usage_periods.count).to eq(1) + expect(result.usage_periods.first.invoice_subscription).to eq(invoice_subscription1) + end + + context "when periods_count is higher than billed period count" do + let(:periods_count) { 10 } + + it "returns all periods" do + expect(result).to be_success + expect(result.usage_periods.count).to eq(2) + end + end + end +end diff --git a/spec/queries/payment_methods_query_spec.rb b/spec/queries/payment_methods_query_spec.rb new file mode 100644 index 0000000..724d9e6 --- /dev/null +++ b/spec/queries/payment_methods_query_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethodsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:returned_ids) { result.payment_methods.pluck(:id) } + + let(:pagination) { nil } + let(:filters) { {} } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:payment_method_first) { create(:payment_method, organization:) } + let(:payment_method_second) { create(:payment_method, organization:, customer:) } + let(:payment_method_third) { create(:payment_method, organization:, deleted_at: Time.current) } + + before do + payment_method_first + payment_method_second + payment_method_third + end + + it "returns all active payment methods" do + expect(result).to be_success + expect(returned_ids).to contain_exactly(payment_method_first.id, payment_method_second.id) + end + + context "when payment methods have the same values for the ordering criteria" do + let(:payment_method_second) do + create( + :payment_method, + organization:, + customer:, + id: "00000000-0000-0000-0000-000000000000", + created_at: payment_method_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(payment_method_first.id) + expect(returned_ids).to include(payment_method_second.id) + expect(returned_ids.index(payment_method_first.id)).to be > returned_ids.index(payment_method_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 1} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.payment_methods.count).to eq(1) + expect(result.payment_methods.current_page).to eq(2) + expect(result.payment_methods.prev_page).to eq(1) + expect(result.payment_methods.next_page).to be_nil + expect(result.payment_methods.total_pages).to eq(2) + expect(result.payment_methods.total_count).to eq(2) + end + end + + context "when filtering by customer_id" do + let(:filters) { {external_customer_id: customer.external_id} } + + it "returns all payment methods of the customer" do + expect(result).to be_success + expect(returned_ids).to contain_exactly(payment_method_second.id) + end + + context "when a discarded customer has the same external_id" do + let(:discarded_customer) do + create(:customer, organization:, external_id: customer.external_id, deleted_at: Time.current) + end + let(:payment_method_of_discarded_customer) do + create(:payment_method, organization:, customer: discarded_customer) + end + + before { payment_method_of_discarded_customer } + + it "excludes payment methods of the discarded customer" do + expect(result).to be_success + expect(returned_ids).to contain_exactly(payment_method_second.id) + end + + context "when including deleted payment methods" do + let(:filters) { {external_customer_id: customer.external_id, with_deleted: true} } + + it "excludes payment methods of the discarded customer" do + expect(result).to be_success + expect(returned_ids).to contain_exactly(payment_method_second.id) + end + end + end + end + + context "when including deleted payment methods" do + let(:filters) { {with_deleted: true} } + + it "returns all payment methods" do + expect(result).to be_success + expect(returned_ids).to contain_exactly(payment_method_first.id, payment_method_second.id, payment_method_third.id) + end + end +end diff --git a/spec/queries/payment_receipts_query_spec.rb b/spec/queries/payment_receipts_query_spec.rb new file mode 100644 index 0000000..ebd82db --- /dev/null +++ b/spec/queries/payment_receipts_query_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceiptsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:returned_ids) { result.payment_receipts.pluck(:id) } + let(:pagination) { nil } + let(:filters) { nil } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice) { create(:invoice, organization:) } + let(:invoice2) { create(:invoice, organization:) } + let(:payment_request) { create(:payment_request, organization:) } + let(:payment_one) { create(:payment, payable: invoice) } + let(:payment_two) { create(:payment, payable: invoice2) } + let(:payment_three) { create(:payment, payable: payment_request) } + + let!(:payment_receipt_one) { create(:payment_receipt, payment: payment_one, organization:) } + let!(:payment_receipt_two) { create(:payment_receipt, payment: payment_two, organization:) } + let!(:payment_receipt_three) { create(:payment_receipt, payment: payment_three, organization:) } + + before do + create(:payment_receipt) + end + + it "returns all payment_receipts for the organization" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(payment_receipt_one.id) + expect(returned_ids).to include(payment_receipt_two.id) + expect(returned_ids).to include(payment_receipt_three.id) + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.payment_receipts.count).to eq(1) + expect(result.payment_receipts.current_page).to eq(2) + expect(result.payment_receipts.prev_page).to eq(1) + expect(result.payment_receipts.next_page).to be_nil + expect(result.payment_receipts.total_pages).to eq(2) + expect(result.payment_receipts.total_count).to eq(3) + end + end + + context "when filtering by invoice_id" do + let(:filters) { {invoice_id: invoice.id} } + + it "returns only payment_receipts for the specified invoice" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(payment_receipt_one.id) + end + end + + context "when filtering by invoice_id of a payment request" do + let(:filters) { {invoice_id: invoice_pr.id} } + let(:invoice_pr) { create(:invoice, organization:) } + + before do + create(:payment_request_applied_invoice, invoice: invoice_pr, payment_request:) + end + + it "returns only payment_receipts for the specified invoice" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(payment_receipt_three.id) + end + end + + context "when filtering with an invalid invoice_id" do + let(:filters) { {invoice_id: "invalid-uuid"} } + + it "returns a validation error" do + expect(result).not_to be_success + expect(result.error.messages[:invoice_id]).to include("is in invalid format") + end + end + + context "when no payment_receipts exist" do + before do + PaymentReceipt.delete_all + end + + it "returns an empty result set" do + expect(result).to be_success + expect(returned_ids).to be_empty + end + end +end diff --git a/spec/queries/payment_requests_query_spec.rb b/spec/queries/payment_requests_query_spec.rb new file mode 100644 index 0000000..04e98e0 --- /dev/null +++ b/spec/queries/payment_requests_query_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequestsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:returned_ids) { result.payment_requests.pluck(:id) } + + let(:pagination) { nil } + let(:filters) { {} } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:payment_request_first) { create(:payment_request, organization:) } + let(:payment_request_second) { create(:payment_request, organization:, customer:) } + + before do + payment_request_first + payment_request_second + end + + it "returns all payment requests" do + expect(result).to be_success + expect(result.payment_requests.pluck(:id)).to contain_exactly( + payment_request_first.id, + payment_request_second.id + ) + end + + context "when payment requests have the same values for the ordering criteria" do + let(:payment_request_second) do + create( + :payment_request, + organization:, + customer:, + id: "00000000-0000-0000-0000-000000000000", + created_at: payment_request_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(payment_request_first.id) + expect(returned_ids).to include(payment_request_second.id) + expect(returned_ids.index(payment_request_first.id)).to be > returned_ids.index(payment_request_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 1} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.payment_requests.count).to eq(1) + expect(result.payment_requests.current_page).to eq(2) + expect(result.payment_requests.prev_page).to eq(1) + expect(result.payment_requests.next_page).to be_nil + expect(result.payment_requests.total_pages).to eq(2) + expect(result.payment_requests.total_count).to eq(2) + end + end + + context "when filtering by customer_id" do + let(:filters) { {external_customer_id: customer.external_id} } + + it "returns all payment_requests of the customer" do + expect(result).to be_success + expect(result.payment_requests.pluck(:id)).to contain_exactly( + payment_request_second.id + ) + end + end + + context "when filtering by currency" do + let(:filters) { {currency: "BRL"} } + let(:payment_request_first) { create(:payment_request, organization:, amount_currency: "BRL") } + let(:payment_request_second) { create(:payment_request, organization:, customer:, amount_currency: "EUR") } + + it "returns only payment requests with matching currency" do + expect(result).to be_success + expect(result.payment_requests.pluck(:id)).to eq([payment_request_first.id]) + end + + context "when no payment requests match the currency" do + let(:filters) { {currency: "GBP"} } + + it "returns no payment requests" do + expect(result).to be_success + expect(result.payment_requests.count).to eq(0) + end + end + end + + context "when filtering by payment_status" do + context "when pending status" do + let(:filters) { {payment_status: :pending} } + + it "returns all payment_requests with status pending" do + expect(result).to be_success + expect(result.payment_requests.count).to eq(2) + expect(result.payment_requests.pluck(:id)).to contain_exactly( + payment_request_first.id, + payment_request_second.id + ) + end + end + + context "when succeeded status" do + let(:filters) { {payment_status: :succeeded} } + + before { payment_request_second.payment_succeeded! } + + it "returns all payment_requests with status pending" do + expect(result).to be_success + expect(result.payment_requests.count).to eq(1) + expect(result.payment_requests.pluck(:id)).to contain_exactly( + payment_request_second.id + ) + end + end + end +end diff --git a/spec/queries/payments_query_spec.rb b/spec/queries/payments_query_spec.rb new file mode 100644 index 0000000..6b206d0 --- /dev/null +++ b/spec/queries/payments_query_spec.rb @@ -0,0 +1,210 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:, search_term:) + end + + let(:returned_ids) { result.payments.pluck(:id) } + let(:pagination) { nil } + let(:filters) { nil } + let(:search_term) { nil } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice) { create(:invoice, organization:) } + let(:invoice2) { create(:invoice, organization:) } + let(:payment_request) { create(:payment_request, organization:) } + let(:payment_one) { create(:payment, payable: invoice) } + let(:payment_two) { create(:payment, payable: invoice2) } + let(:payment_three) { create(:payment, payable: payment_request) } + + before do + payment_one + payment_two + payment_three + end + + it "returns all payments for the organization" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(payment_one.id) + expect(returned_ids).to include(payment_two.id) + expect(returned_ids).to include(payment_three.id) + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.payments.count).to eq(1) + expect(result.payments.current_page).to eq(2) + expect(result.payments.prev_page).to eq(1) + expect(result.payments.next_page).to be_nil + expect(result.payments.total_pages).to eq(2) + expect(result.payments.total_count).to eq(3) + end + end + + context "with search_term" do + let(:customer) { create(:customer, firstname: "first", lastname: "last", external_id: "external_c_id", email: "email@example.com", name: "The name") } + let(:invoice) { create(:invoice, :finalized, organization:, customer:, number: "number-test-123") } + let(:invoice3) { create(:invoice, :finalized, organization:, customer:) } + let(:payment_one) { create(:payment, payable: invoice) } + let(:payment_two) { create(:payment, payable: invoice3) } + let(:payment_three) { create(:payment, payable: invoice2) } + let(:payment_four) { create(:payment, payable: payment_request) } + + before do + payment_one + payment_two + payment_three + payment_four + end + + context "when search_term is an id" do + let(:search_term) { payment_one.id } + + it "returns only payments for the specified id" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to contain_exactly(payment_one.id) + end + end + + context "when search_term is an invoice number" do + let(:search_term) { invoice.number } + + it "returns only payments for the specified invoice number" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to contain_exactly(payment_one.id) + end + end + + context "when search_term is a customer name" do + let(:search_term) { customer.name } + + it "returns only payments for the specified customer name" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to contain_exactly(payment_one.id, payment_two.id) + end + end + + context "when search_term is a customer email" do + let(:search_term) { customer.email } + + it "returns only payments for the specified customer email" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to contain_exactly(payment_one.id, payment_two.id) + end + end + + context "when search_term is a customer external id" do + let(:search_term) { customer.external_id } + + it "returns only payments for the specified customer external id" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to contain_exactly(payment_one.id, payment_two.id) + end + end + + context "when search_term is a customer firstname" do + let(:search_term) { customer.firstname } + + it "returns only payments for the specified customer firstname" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to contain_exactly(payment_one.id, payment_two.id) + end + end + + context "when search_term is a customer lastname" do + let(:search_term) { customer.lastname } + + it "returns only payments for the specified customer lastname" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to contain_exactly(payment_one.id, payment_two.id) + end + end + end + + context "when filtering by invoice_id" do + let(:filters) { {invoice_id: invoice.id} } + + it "returns only payments for the specified invoice" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(payment_one.id) + expect(returned_ids).not_to include(payment_two.id) + expect(returned_ids).not_to include(payment_three.id) + end + end + + context "when filtering by invoice_id of a payment request" do + let(:filters) { {invoice_id: invoice_pr.id} } + let(:invoice_pr) { create(:invoice, organization:) } + + before do + create(:payment_request_applied_invoice, invoice: invoice_pr, payment_request:) + end + + it "returns only payments for the specified invoice" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(payment_three.id) + end + end + + context "when filtering by external_customer_id" do + let(:filters) { {external_customer_id: customer.external_id} } + let(:customer) { create(:customer) } + let(:new_invoice) { create(:invoice, organization:, customer:) } + let(:new_payment) { create(:payment, payable: new_invoice) } + + before do + new_payment + end + + it "returns only payments for the specified external_customer_id" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(new_payment.id) + end + end + + context "when filtering by an invalid external_customer_id" do + let(:filters) { {external_customer_id: "invalid-external-id"} } + + it "returns an empty result set" do + expect(result).to be_success + expect(returned_ids).to be_empty + end + end + + context "when filtering with an invalid invoice_id" do + let(:filters) { {invoice_id: "invalid-uuid"} } + + it "returns a validation error" do + expect(result).not_to be_success + expect(result.error.messages[:invoice_id]).to include("is in invalid format") + end + end + + context "when no payments exist" do + before do + Payment.delete_all + end + + it "returns an empty result set" do + expect(result).to be_success + expect(returned_ids).to be_empty + end + end +end diff --git a/spec/queries/plans_query_spec.rb b/spec/queries/plans_query_spec.rb new file mode 100644 index 0000000..22d3004 --- /dev/null +++ b/spec/queries/plans_query_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PlansQuery do + subject(:result) do + described_class.call(organization:, pagination:, search_term:, filters:) + end + + let(:returned_ids) { result.plans.pluck(:id) } + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { nil } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan_first) { create(:plan, organization:, name: "defgh", code: "11") } + let(:plan_second) { create(:plan, organization:, name: "abcde", code: "22") } + let(:plan_third) { create(:plan, organization:, name: "presuv", code: "33") } + let(:plan_fourth) { create(:plan, organization:, name: "pending deletion", code: "44", pending_deletion: true) } + + before do + plan_first + plan_second + plan_third + plan_fourth + end + + it "returns all plans not pending for deletion" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(plan_first.id) + expect(returned_ids).to include(plan_second.id) + expect(returned_ids).to include(plan_third.id) + expect(returned_ids).not_to include(plan_fourth.id) + end + + context "when plans have the same values for the ordering criteria" do + let(:plan_second) do + create( + :plan, + organization:, + id: "00000000-0000-0000-0000-000000000000", + name: "abcde", + code: "22", + created_at: plan_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(plan_first.id) + expect(returned_ids).to include(plan_second.id) + expect(returned_ids.index(plan_first.id)).to be > returned_ids.index(plan_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.plans.count).to eq(1) + expect(result.plans.current_page).to eq(2) + expect(result.plans.prev_page).to eq(1) + expect(result.plans.next_page).to be_nil + expect(result.plans.total_pages).to eq(2) + expect(result.plans.total_count).to eq(3) + end + end + + context "when filtering to include pending for deletion plans" do + let(:filters) { {include_pending_deletion: true} } + + it "includes pending for deletion plans" do + expect(result).to be_success + expect(result.plans.count).to eq(4) + expect(result.plans).to include(plan_fourth) + end + end + + context "when searching for /de/ term" do + let(:search_term) { "de" } + + it "returns only two plans" do + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(plan_first.id) + expect(returned_ids).to include(plan_second.id) + expect(returned_ids).not_to include(plan_third.id) + end + end +end diff --git a/spec/queries/pricing_units_query_spec.rb b/spec/queries/pricing_units_query_spec.rb new file mode 100644 index 0000000..a3c2e78 --- /dev/null +++ b/spec/queries/pricing_units_query_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PricingUnitsQuery do + subject(:result) { described_class.call(organization:, search_term:, pagination:) } + + let(:organization) { create(:organization) } + let(:pagination) { nil } + let(:search_term) { nil } + + context "when no filters applied" do + let!(:pricing_units) do + [ + create(:pricing_unit, name: "Beta", organization:), + create(:pricing_unit, name: "Alpha", organization:, created_at: 2.days.ago), + create(:pricing_unit, name: "Alpha", organization:, created_at: 1.day.ago) + ] + end + + before { create(:pricing_unit) } + + it "returns all pricing units for the organization ordered by name asc, created_at desc" do + expect(result).to be_success + expect(result.pricing_units.pluck(:id)).to eq pricing_units.reverse.map(&:id) + end + end + + context "when pagination options provided" do + let(:pagination) { {page: 2, limit: 1} } + + let!(:pricing_units) do + [ + create(:pricing_unit, name: "Beta", organization:), + create(:pricing_unit, name: "Alpha", organization:, created_at: 2.days.ago), + create(:pricing_unit, name: "Alpha", organization:, created_at: 1.day.ago) + ] + end + + it "returns paginated pricing units" do + expect(result).to be_success + expect(result.pricing_units).to contain_exactly pricing_units.second + expect(result.pricing_units.current_page).to eq 2 + expect(result.pricing_units.total_pages).to eq 3 + expect(result.pricing_units.total_count).to eq 3 + end + end + + context "when search term filter applied" do + context "with term matching pricing unit by name" do + let!(:matching_pricing_unit) { create(:pricing_unit, name: "Cloud token", organization:) } + let(:search_term) { "Cloud" } + + before { create(:pricing_unit, name: "Credits", organization:) } + + it "returns pricing units by partially matching name" do + expect(result).to be_success + expect(result.pricing_units.pluck(:id)).to contain_exactly matching_pricing_unit.id + end + end + + context "with term matching pricing unit by code" do + let!(:matching_pricing_unit) { create(:pricing_unit, code: "cloud_token", organization:) } + let(:search_term) { "cloud" } + + before { create(:pricing_unit, code: "credits", organization:) } + + it "returns pricing units by partially matching code" do + expect(result).to be_success + expect(result.pricing_units.pluck(:id)).to contain_exactly matching_pricing_unit.id + end + end + + context "with term not matching any pricing unit" do + let(:search_term) { "NonExistent" } + + before { create(:pricing_unit, organization:) } + + it "returns empty result" do + expect(result).to be_success + expect(result.pricing_units).to be_empty + end + end + end + + context "when both search and pagination are applied" do + let(:pagination) { {page: 2, limit: 1} } + let(:search_term) { "Token" } + + let!(:matching_pricing_units) do + [ + create(:pricing_unit, name: "Cloud token", organization:), + create(:pricing_unit, name: "Compute token", organization:), + create(:pricing_unit, name: "Token", organization:) + ] + end + + before { create(:pricing_unit, organization:) } + + it "returns paginated and filtered pricing units" do + expect(result).to be_success + expect(result.pricing_units).to contain_exactly matching_pricing_units.second + expect(result.pricing_units.current_page).to eq 2 + expect(result.pricing_units.total_pages).to eq 3 + expect(result.pricing_units.total_count).to eq 3 + end + end +end diff --git a/spec/queries/quotes_query_spec.rb b/spec/queries/quotes_query_spec.rb new file mode 100644 index 0000000..44a957b --- /dev/null +++ b/spec/queries/quotes_query_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuotesQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:returned_ids) { result.quotes.pluck(:id) } + let(:pagination) { nil } + let(:filters) { {} } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:quote_draft) { create(:quote, :with_version, organization:, customer:, created_at: 8.days.ago) } + let(:quote_approved) { create(:quote, :with_version, version_trait: :approved, organization:, customer:, created_at: 6.days.ago) } + let(:quote_voided) { create(:quote, :with_version, version_trait: :voided, organization:, customer:, created_at: 4.days.ago) } + + before do + quote_draft + quote_approved + quote_voided + end + + it "returns all quotes" do + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(quote_draft.id) + expect(returned_ids).to include(quote_approved.id) + expect(returned_ids).to include(quote_voided.id) + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.quotes.count).to eq(1) + expect(result.quotes.current_page).to eq(2) + expect(result.quotes.prev_page).to eq(1) + expect(result.quotes.next_page).to be_nil + expect(result.quotes.total_pages).to eq(2) + expect(result.quotes.total_count).to eq(3) + end + end + + context "when filtering" do + describe "customers" do + context "when filtering by valid customer" do + let(:other_customer) { create(:customer, organization:) } + let(:other_quote) { create(:quote, :with_version, organization:, customer: other_customer) } + let(:filters) { {customers: [other_customer.id]} } + + before do + other_quote + end + + it "returns only one quote" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(other_quote.id) + end + end + + context "when filtering by invalid customer" do + let(:filters) { {customers: ["invalid_customer"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "statuses" do + context "when filtering by valid status" do + let(:filters) { {statuses: ["draft"]} } + + it "returns only one quote" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(quote_draft.id) + end + end + + context "when filtering by invalid status" do + let(:filters) { {statuses: ["invalid_status"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "numbers" do + context "when filtering by valid number" do + let(:other_quote) { create(:quote, :with_version, organization:) } + let(:filters) { {numbers: [other_quote.number]} } + + before do + other_quote + end + + it "returns only one quote" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(other_quote.id) + end + end + + context "when filtering by invalid number" do + let(:filters) { {numbers: ["invalid_number"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "date range" do + context "when filtering by valid date range" do + let(:filters) do + { + from_date: 2.days.ago.iso8601, + to_date: 1.day.ago.iso8601 + } + end + + it "returns quotes updated within the date range" do + expect(result).to be_success + expect(returned_ids.count).to eq(0) + end + end + + context "when filtering with invalid date format" do + let(:filters) do + { + from_date: "invalid_date", + to_date: "invalid_date" + } + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "owners" do + context "when filtering by valid owners" do + let(:membership) { create(:membership, organization:) } + let(:other_quote) { create(:quote, :with_version, organization:) } + let(:filters) { {owners: [membership.user.id]} } + + before do + QuoteOwner.create!(organization:, quote: other_quote, user: membership.user) + end + + it "returns only one quote" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(other_quote.id) + end + end + + context "when filtering by invalid owners" do + let(:filters) { {owners: ["invalid_owner"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "order_types" do + let(:one_off_quote) { create(:quote, :with_version, organization:, customer:, order_type: :one_off) } + + before do + one_off_quote + end + + context "when filtering by valid order_types" do + let(:filters) { {order_types: ["one_off"]} } + + it "returns only quotes with the matching order type" do + expect(result).to be_success + expect(returned_ids).to eq([one_off_quote.id]) + end + end + + context "when filtering by invalid order_types" do + let(:filters) { {order_types: ["invalid_order_type"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + end +end diff --git a/spec/queries/security_logs_query_spec.rb b/spec/queries/security_logs_query_spec.rb new file mode 100644 index 0000000..8f5ee15 --- /dev/null +++ b/spec/queries/security_logs_query_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SecurityLogsQuery, clickhouse: true do + subject(:result) { described_class.call(organization:, pagination:, filters:) } + + let(:returned_ids) { result.security_logs.pluck(:log_id) } + let(:organization) { security_log.organization } + let(:security_log) { create(:clickhouse_security_log, organization: premium_organization) } + let(:premium_organization) { create(:organization, premium_integrations: ["security_logs"]) } + let(:pagination) { {page: 1, limit: 10} } + let(:filters) { {to_date: Time.current} } + + before do + allow(License).to receive(:premium?).and_return(true) + security_log + end + + describe ".available?" do + subject { described_class.available? } + + include_context "with clickhouse availability" + + context "when clickhouse is available" do + it { is_expected.to be true } + end + + context "when clickhouse is not available" do + let(:clickhouse_enabled) { nil } + + it { is_expected.to be false } + end + end + + describe "#call" do + it "returns all security logs" do + expect(result.security_logs.count).to eq(1) + expect(returned_ids).to include(security_log.log_id) + end + + context "when to_date is missing" do + let(:filters) { {} } + + it "returns validation failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:to_date]).to eq(["value_is_mandatory"]) + end + end + + context "with old security logs" do + let(:old_security_log) do + create(:clickhouse_security_log, + organization: organization, + logged_at: 91.days.ago) + end + + before { old_security_log } + + it "excludes logs older than retention period" do + expect(result.security_logs.count).to eq(1) + expect(returned_ids).to eq([security_log.log_id]) + end + end + + context "with pagination" do + let(:pagination) { {page: 1, limit: 1} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.security_logs.count).to eq(1) + expect(result.security_logs.current_page).to eq(1) + expect(result.security_logs.prev_page).to be_nil + expect(result.security_logs.next_page).to be_nil + expect(result.security_logs.total_pages).to eq(1) + expect(result.security_logs.total_count).to eq(1) + end + end + + context "with from_date and to_date filters" do + it "returns expected security logs" do + filters = {from_date: security_log.logged_at + 1.day, to_date: security_log.logged_at + 2.days} + expect(described_class.call(organization:, pagination:, filters:).security_logs).to be_empty + + filters = {from_date: security_log.logged_at - 1.day, to_date: security_log.logged_at + 1.day} + expect(described_class.call(organization:, pagination:, filters:).security_logs.first.log_id).to eq(security_log.log_id) + + filters = {to_date: security_log.logged_at - 1.day} + expect(described_class.call(organization:, pagination:, filters:).security_logs).to be_empty + end + end + + context "with api_key_ids filter" do + it "returns expected security logs" do + filters = {api_key_ids: [security_log.api_key_id], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs.first.log_id).to eq(security_log.log_id) + + filters = {api_key_ids: ["other"], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs).to be_empty + end + end + + context "with user_ids filter" do + it "returns expected security logs" do + filters = {user_ids: [security_log.user_id], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs.first.log_id).to eq(security_log.log_id) + + filters = {user_ids: ["other"], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs).to be_empty + end + end + + context "with log_types filter" do + it "returns expected security logs" do + filters = {log_types: [security_log.log_type], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs.first.log_id).to eq(security_log.log_id) + + filters = {log_types: ["other"], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs).to be_empty + end + end + + context "with log_events filter" do + it "returns expected security logs" do + filters = {log_events: [security_log.log_event], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs.first.log_id).to eq(security_log.log_id) + + filters = {log_events: ["other"], to_date: Time.current} + expect(described_class.call(organization:, pagination:, filters:).security_logs).to be_empty + end + end + + context "when clickhouse is not available" do + before { ENV["LAGO_CLICKHOUSE_ENABLED"] = nil } + + after { ENV["LAGO_CLICKHOUSE_ENABLED"] = "true" } + + it "returns forbidden failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + + context "when security_logs is not enabled" do + let(:organization) { create(:organization, premium_integrations: []) } + + it "returns forbidden failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end +end diff --git a/spec/queries/subscriptions_query_spec.rb b/spec/queries/subscriptions_query_spec.rb new file mode 100644 index 0000000..6a9af13 --- /dev/null +++ b/spec/queries/subscriptions_query_spec.rb @@ -0,0 +1,484 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe SubscriptionsQuery do + subject(:result) { described_class.call(organization:, pagination:, filters:, search_term:) } + + let(:returned_ids) { result.subscriptions.pluck(:id) } + + let(:organization) { create(:organization) } + let(:pagination) { nil } + let(:filters) { {} } + let(:search_term) { nil } + + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + + before { subscription } + + it "returns a list of subscriptions" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([subscription]) + end + + context "when subscriptions have the same values for started_at" do + let(:subscription) { create(:subscription, customer:, plan:, started_at: 2.days.ago, created_at: 1.day.ago) } + let(:subscription_2) do + create( + :subscription, + customer:, + plan:, + id: "00000000-0000-0000-0000-000000000000", + started_at: subscription.started_at + ) + end + + before { subscription_2 } + + it "returns a list sorted by created_at DESC" do + expect(result).to be_success + expect(returned_ids).to eq([subscription_2.id, subscription.id]) + end + end + + context "when subscriptions have the same values for the ordering criteria" do + let(:subscription) { create(:subscription, customer:, plan:, started_at: 1.day.ago) } + let(:subscription_2) do + create( + :subscription, + customer:, + plan:, + id: "00000000-0000-0000-0000-000000000000", + started_at: subscription.started_at, + created_at: subscription.created_at + ) + end + + before { subscription_2 } + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids).to eq([subscription_2.id, subscription.id]) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 10} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(0) + expect(result.subscriptions.current_page).to eq(2) + end + end + + context "with search_term" do + let(:subscription) { create(:subscription, customer:, plan:, name: "Test Subscription") } + let(:subscription_2) { create(:subscription, customer:, plan:, name: "Test Subscription 2") } + + before { subscription_2 } + + context "when search_term is an id" do + let(:search_term) { subscription.id } + + it "returns only subscriptions for the specified id" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([subscription]) + end + end + + context "when search_term is a name" do + let(:search_term) { subscription_2.name } + + it "returns only subscriptions for the specified name" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([subscription_2]) + end + end + + context "when search_term is an external_id" do + let(:search_term) { subscription.external_id } + + it "returns only subscriptions for the specified external_id" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([subscription]) + end + end + + context "when search_term is a customer name" do + let(:search_term) { customer.name } + + it "returns only subscriptions for the specified customer name" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions).to match_array([subscription, subscription_2]) + end + end + + context "when search_term is a customer firstname" do + let(:search_term) { customer.firstname } + + it "returns only subscriptions for the specified customer firstname" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions).to match_array([subscription, subscription_2]) + end + end + + context "when search_term is a customer lastname" do + let(:search_term) { customer.lastname } + + it "returns only subscriptions for the specified customer lastname" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions).to match_array([subscription, subscription_2]) + end + end + + context "when search_term is a customer external_id" do + let(:search_term) { customer.external_id } + + it "returns only subscriptions for the specified customer external_id" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions).to match_array([subscription, subscription_2]) + end + end + + context "when has search_time and plan searchs" do + let(:search_term) { customer.firstname } + let(:filters) { {overriden: true} } + let(:plan2) { create(:plan, organization:, parent: plan) } + let(:subscription_3) { create(:subscription, customer:, plan: plan2, name: "Test Subscription 3") } + + before { subscription_3 } + + it "returns only subscriptions for the specified customer external_id" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to match_array([subscription_3]) + end + end + end + + context "with customer filter" do + let(:filters) { {external_customer_id: customer.external_id} } + + it "applies the filter" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + end + end + + context "with plan filter" do + let(:filters) { {plan_code: plan.code} } + + it "applies the filter" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + end + end + + context "with multiple status filter" do + let(:filters) { {status: [:active, :pending]} } + + it "returns correct subscriptions" do + create(:subscription, :pending, customer:, plan:) + create(:subscription, customer:, plan:, status: :canceled) + create(:subscription, customer:, plan:, status: :terminated) + + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions.active.count).to eq(1) + expect(result.subscriptions.pending.count).to eq(1) + expect(result.subscriptions.canceled.count).to eq(0) + expect(result.subscriptions.terminated.count).to eq(0) + end + end + + context "with pending status filter" do + let(:filters) { {status: [:pending]} } + + let(:subscription_1) do + create(:subscription, :pending, customer:, plan:, created_at: Time.zone.parse("2024-10-12T00:01:01")) + end + + let(:subscription_2) do + create(:subscription, :pending, customer:, plan:, created_at: Time.zone.parse("2024-10-10T00:01:01")) + end + + it "returns only pending subscriptions" do + subscription_1 + subscription_2 + create(:subscription, customer:, plan:, status: :canceled) + create(:subscription, customer:, plan:, status: :terminated) + + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions.active.count).to eq(0) + expect(result.subscriptions.pending.count).to eq(2) + expect(result.subscriptions.canceled.count).to eq(0) + expect(result.subscriptions.terminated.count).to eq(0) + expect(result.subscriptions.first).to eq(subscription_2) # sorted by subscription_at DESC + end + end + + context "with canceled status filter" do + let(:filters) { {status: [:canceled]} } + + it "returns only pending subscriptions" do + create(:subscription, :pending, customer:, plan:) + create(:subscription, customer:, plan:, status: :canceled) + create(:subscription, customer:, plan:, status: :terminated) + + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions.active.count).to eq(0) + expect(result.subscriptions.pending.count).to eq(0) + expect(result.subscriptions.canceled.count).to eq(1) + expect(result.subscriptions.terminated.count).to eq(0) + end + end + + context "with terminated status filter" do + let(:filters) { {status: [:terminated]} } + + it "returns only pending subscriptions" do + create(:subscription, :pending, customer:, plan:) + create(:subscription, customer:, plan:, status: :canceled) + create(:subscription, customer:, plan:, status: :terminated) + + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions.active.count).to eq(0) + expect(result.subscriptions.pending.count).to eq(0) + expect(result.subscriptions.canceled.count).to eq(0) + expect(result.subscriptions.terminated.count).to eq(1) + end + end + + context "with no status filter" do + it "returns all subscriptions" do + subscription_2 = create(:subscription, customer:, plan:, status: :terminated) + subscription_3 = create(:subscription, customer:, plan:, status: :canceled) + subscription_4 = create(:subscription, customer:, plan:, status: :pending) + + expect(result).to be_success + expect(result.subscriptions.count).to eq(4) + expect(result.subscriptions.active.count).to eq(1) + expect(result.subscriptions.pending.count).to eq(1) + expect(result.subscriptions.canceled.count).to eq(1) + expect(result.subscriptions.terminated.count).to eq(1) + expect(result.subscriptions).to match_array([subscription, subscription_2, subscription_3, subscription_4]) + end + end + + context "with overriden filter" do + let(:filters) { {} } + let(:plan) { create(:plan, organization:, parent: parent_plan) } + let(:parent_plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:subscription_2) { create(:subscription, customer:, plan: parent_plan) } + + before { [subscription, subscription_2] } + + context "when overriden is true" do + let(:filters) { {overriden: true} } + + it "returns only overriden subscriptions" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([subscription]) + end + end + + context "when overriden is false" do + let(:filters) { {overriden: false} } + + it "returns only non-overriden subscriptions" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([subscription_2]) + end + end + + context "without overriden filter" do + it "returns all subscriptions" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions).to match_array([subscription, subscription_2]) + end + end + end + + context "with currency filter" do + let(:eur_plan) { create(:plan, organization:, amount_currency: "EUR") } + let(:usd_plan) { create(:plan, organization:, amount_currency: "USD") } + let(:eur_subscription) { create(:subscription, customer:, plan: eur_plan) } + let(:usd_subscription) { create(:subscription, customer:, plan: usd_plan) } + let(:subscription) { nil } + + before do + eur_subscription + usd_subscription + end + + context "when currency filter is provided" do + let(:filters) { {currency: "EUR"} } + + it "returns only subscriptions with matching currency" do + expect(result).to be_success + expect(result.subscriptions).to eq([eur_subscription]) + end + end + + context "when currency filter is not provided" do + let(:filters) { {} } + + it "returns all subscriptions" do + expect(result).to be_success + expect(result.subscriptions).to match_array([eur_subscription, usd_subscription]) + end + end + end + + context "with exclude_next_subscriptions filter" do + let(:subscription) { create(:subscription, customer:, plan:, status: :active) } + let(:next_subscription) { create(:subscription, previous_subscription: subscription, customer:, plan:, status: :pending) } + let(:pending_subscription) { create(:subscription, :pending, customer:, plan:) } + let(:terminated_subscription) { create(:subscription, :terminated, customer:, plan:) } + + before do + subscription + next_subscription + pending_subscription + terminated_subscription + end + + context "when status filter is empty" do + let(:filters) { {exclude_next_subscriptions: true, status: []} } + + it "returns only subscriptions without next subscription" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(3) + expect(result.subscriptions).to match_array([subscription, pending_subscription, terminated_subscription]) + end + end + + context "when status filter is not empty" do + context "when status filter matches previous subscription status" do + let(:filters) { {exclude_next_subscriptions: true, status: [:active]} } + + it "returns only subscriptions without previous subscription" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([subscription]) + end + end + + context "when status filter matches next subscription status" do + let(:filters) { {exclude_next_subscriptions: true, status: [:pending]} } + + it "returns only subscriptions without next subscription" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions).to match_array([pending_subscription, next_subscription]) + end + end + + context "when status filter matches both previous and next subscription status" do + let(:filters) { {exclude_next_subscriptions: true, status: [:pending, :active]} } + + it "returns only subscriptions without next subscription" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(2) + expect(result.subscriptions).to match_array([subscription, pending_subscription]) + end + end + + context "when status filter does not match previous or next subscription status" do + let(:filters) { {exclude_next_subscriptions: true, status: [:terminated]} } + + it "returns only subscriptions without next subscription" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(1) + expect(result.subscriptions).to eq([terminated_subscription]) + end + end + end + + context "when status filter contains multiple statuses" do + let(:filters) { {exclude_next_subscriptions: true, status: [:pending, :active]} } + + let(:pending_without_previous) { create(:subscription, :pending, customer:, plan:) } + let(:active_without_previous) { create(:subscription, :active, customer:, plan:) } + let(:pending_with_terminated_previous) { create(:subscription, :pending, :with_previous_subscription, customer:, plan:) } + let(:active_with_terminated_previous) { create(:subscription, :active, :with_previous_subscription, customer:, plan:) } + let(:pending_with_pending_previous) { create(:subscription, :pending, :with_previous_subscription, customer:, plan:) } + let(:subscription) { create(:subscription, :terminated, customer:, plan:) } + + before do + pending_without_previous + active_without_previous + pending_with_terminated_previous.previous_subscription.update!(status: :terminated) + active_with_terminated_previous.previous_subscription.update!(status: :terminated) + pending_with_pending_previous.previous_subscription.update!(status: :pending) + end + + it "returns subscriptions without previous OR with non-matching previous and matching current" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(7) + expect(result.subscriptions).to match_array([ + next_subscription, + pending_subscription, + pending_without_previous, + active_without_previous, + pending_with_terminated_previous, + active_with_terminated_previous, + pending_with_pending_previous.previous_subscription + ]) + end + + it "excludes subscriptions with matching previous status" do + expect(result.subscriptions).not_to include(pending_with_pending_previous) + end + end + + context "when previous subscription is terminated" do + let(:subscription) { create(:subscription, customer:, plan:, status: :terminated) } + + it "returns all subscriptions" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(4) + expect(result.subscriptions).to match_array([subscription, next_subscription, pending_subscription, terminated_subscription]) + end + + context "when there is a search term" do + let(:search_term) { customer.name } + + it "returns all subscriptions" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(4) + expect(result.subscriptions).to match_array([subscription, next_subscription, pending_subscription, terminated_subscription]) + end + end + end + + context "when next subscription is canceled" do + let(:subscription) { create(:subscription, customer:, plan:, status: :active) } + let(:next_subscription) { create(:subscription, previous_subscription: subscription, customer:, plan:, status: :canceled) } + + it "returns all subscriptions" do + expect(result).to be_success + expect(result.subscriptions.count).to eq(4) + expect(result.subscriptions).to match_array([subscription, next_subscription, pending_subscription, terminated_subscription]) + end + end + end +end diff --git a/spec/queries/taxes_query_spec.rb b/spec/queries/taxes_query_spec.rb new file mode 100644 index 0000000..317f347 --- /dev/null +++ b/spec/queries/taxes_query_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe TaxesQuery do + subject(:result) do + described_class.call(organization:, pagination:, search_term:, filters:, order:) + end + + let(:returned_ids) { result.taxes.pluck(:id) } + + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { nil } + let(:order) { nil } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax_first) { create(:tax, :applied_to_billing_entity, organization:, name: "defgh", code: "11", rate: 10) } + let(:tax_second) { create(:tax, :applied_to_billing_entity, organization:, name: "abcde", code: "22", rate: 5) } + + let(:tax_third) do + create( + :tax, + organization:, + name: "presuv", + code: "33", + rate: 20 + ) + end + + let(:auto_generated_tax) do + create( + :tax, + :applied_to_billing_entity, + organization:, + name: "auto_generated", + code: "auto_generated", + rate: 0.0, + auto_generated: true + ) + end + + before do + tax_first + tax_second + tax_third + auto_generated_tax + end + + it "returns all taxes ordered by name asc" do + expect(result.taxes).to eq([tax_second, auto_generated_tax, tax_first, tax_third]) + end + + context "when taxes have the same values for the ordering criteria" do + let(:tax_second) do + create( + :tax, + organization:, + id: "00000000-0000-0000-0000-000000000000", + name: tax_first.name, + code: "22", + created_at: tax_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(4) + expect(returned_ids).to include(tax_first.id) + expect(returned_ids).to include(tax_second.id) + expect(returned_ids.index(tax_first.id)).to be > returned_ids.index(tax_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 3} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.taxes.count).to eq(1) + expect(result.taxes.current_page).to eq(2) + expect(result.taxes.prev_page).to eq(1) + expect(result.taxes.next_page).to be_nil + expect(result.taxes.total_pages).to eq(2) + expect(result.taxes.total_count).to eq(4) + end + end + + context "when searching for /de/ term" do + let(:search_term) { "de" } + + it "returns only two taxs" do + expect(result.taxes).to eq([tax_second, tax_first]) + end + end + + context "with a filter on not applied to BE" do + let(:filters) { {applied_to_organization: false} } + + it "returns only one bot applied tax" do + expect(result.taxes).to eq([tax_third]) + end + end + + context "with a filter on applied to BE" do + let(:filters) { {applied_to_organization: true} } + + it "returns all applied taxes" do + expect(result.taxes.count).to eq(3) + expect(result.taxes).to match_array([tax_first, tax_second, auto_generated_tax]) + end + end + + context "with a filter on auto generated" do + let(:filters) { {auto_generated: true} } + + it "returns only one tax" do + expect(result.taxes).to eq([auto_generated_tax]) + end + end + + context "with order on rate" do + let(:order) { "rate" } + + it "returns the taxes ordered by rate" do + expect(result.taxes).to eq([auto_generated_tax, tax_second, tax_first, tax_third]) + end + end +end diff --git a/spec/queries/usage_monitoring/alerts_query_spec.rb b/spec/queries/usage_monitoring/alerts_query_spec.rb new file mode 100644 index 0000000..8080b1d --- /dev/null +++ b/spec/queries/usage_monitoring/alerts_query_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::AlertsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:subscription) { create(:subscription, organization:) } + let(:alert_first) { create(:alert, organization:, subscription_external_id: subscription.external_id) } + let(:alert_second) { create(:billable_metric_current_usage_amount_alert, organization:, subscription_external_id: subscription.external_id) } + let(:alert_third) { create(:alert, organization:) } + let(:pagination) { {page: 1, limit: 5} } + let(:filters) { nil } + + before do + alert_first + alert_second + alert_third + end + + it "returns all alerts" do + expect(result.alerts.pluck(:id)).to contain_exactly(alert_first.id, alert_second.id, alert_third.id) + end + + context "with subscription_external_id" do + let(:filters) { {subscription_external_id: subscription.external_id} } + + it do + expect(result.alerts.pluck(:id)).to contain_exactly(alert_first.id, alert_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.alerts.count).to eq(1) + expect(result.alerts.current_page).to eq(2) + expect(result.alerts.prev_page).to eq(1) + expect(result.alerts.next_page).to be_nil + expect(result.alerts.total_pages).to eq(2) + expect(result.alerts.total_count).to eq(3) + end + end +end diff --git a/spec/queries/wallet_transaction_consumptions_query_spec.rb b/spec/queries/wallet_transaction_consumptions_query_spec.rb new file mode 100644 index 0000000..eeacf93 --- /dev/null +++ b/spec/queries/wallet_transaction_consumptions_query_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactionConsumptionsQuery do + subject(:result) do + described_class.call( + organization:, + pagination:, + filters: { + wallet_transaction_id:, + direction: + } + ) + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:pagination) { nil } + + describe "with invalid direction" do + let(:direction) { "invalid" } + let(:wallet_transaction_id) { SecureRandom.uuid } + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to include(:direction) + end + end + + describe "consumptions direction" do + let(:direction) { "consumptions" } + let(:wallet_transaction_id) { inbound_transaction.id } + let(:inbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + + context "with consumptions" do + let(:outbound_transaction1) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + let(:outbound_transaction2) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + let(:consumption1) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction1, + consumed_amount_cents: 500) + end + let(:consumption2) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction, + outbound_wallet_transaction: outbound_transaction2, + consumed_amount_cents: 300) + end + + before do + consumption1 + consumption2 + end + + it "returns consumptions for the inbound transaction" do + expect(result).to be_success + expect(result.wallet_transaction_consumptions.count).to eq(2) + expect(result.wallet_transaction_consumptions.pluck(:id)).to match_array([consumption1.id, consumption2.id]) + end + + context "with pagination" do + let(:pagination) { {page: 1, limit: 1} } + + it "applies pagination" do + expect(result).to be_success + expect(result.wallet_transaction_consumptions.count).to eq(1) + expect(result.wallet_transaction_consumptions.current_page).to eq(1) + expect(result.wallet_transaction_consumptions.total_pages).to eq(2) + expect(result.wallet_transaction_consumptions.total_count).to eq(2) + end + end + end + + context "when wallet_transaction is not found" do + let(:wallet_transaction_id) { SecureRandom.uuid } + + it "returns not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("wallet_transaction_not_found") + end + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, customer:, traceable: false) } + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({wallet: ["not_traceable"]}) + end + end + + context "when transaction type is outbound" do + let(:inbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({transaction_type: ["invalid_transaction_type"]}) + end + end + end + + describe "fundings direction" do + let(:direction) { "fundings" } + let(:wallet_transaction_id) { outbound_transaction.id } + let(:outbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + + context "with fundings" do + let(:inbound_transaction1) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + let(:inbound_transaction2) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + let(:funding1) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction1, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 500) + end + let(:funding2) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction2, + outbound_wallet_transaction: outbound_transaction, + consumed_amount_cents: 300) + end + + before do + funding1 + funding2 + end + + it "returns fundings for the outbound transaction" do + expect(result).to be_success + expect(result.wallet_transaction_consumptions.count).to eq(2) + expect(result.wallet_transaction_consumptions.pluck(:id)).to match_array([funding1.id, funding2.id]) + end + + context "with pagination" do + let(:pagination) { {page: 1, limit: 1} } + + it "applies pagination" do + expect(result).to be_success + expect(result.wallet_transaction_consumptions.count).to eq(1) + expect(result.wallet_transaction_consumptions.current_page).to eq(1) + expect(result.wallet_transaction_consumptions.total_pages).to eq(2) + expect(result.wallet_transaction_consumptions.total_count).to eq(2) + end + end + end + + context "when wallet_transaction is not found" do + let(:wallet_transaction_id) { SecureRandom.uuid } + + it "returns not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("wallet_transaction_not_found") + end + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, customer:, traceable: false) } + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({wallet: ["not_traceable"]}) + end + end + + context "when transaction type is inbound" do + let(:outbound_transaction) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({transaction_type: ["invalid_transaction_type"]}) + end + end + end +end diff --git a/spec/queries/wallet_transactions_query_spec.rb b/spec/queries/wallet_transactions_query_spec.rb new file mode 100644 index 0000000..ef0bd0d --- /dev/null +++ b/spec/queries/wallet_transactions_query_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactionsQuery do + subject(:result) do + described_class.call( + organization:, + wallet_id: wallet_id, + pagination:, + filters: + ) + end + + let(:returned_ids) { result.wallet_transactions.pluck(:id) } + + let(:wallet_id) { wallet.id } + let(:pagination) { nil } + let(:filters) { {} } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction_first) { create(:wallet_transaction, wallet:) } + let(:wallet_transaction_second) { create(:wallet_transaction, wallet:) } + let(:wallet_transaction_third) { create(:wallet_transaction, wallet:) } + let(:wallet_transaction_fourth) { create(:wallet_transaction) } + + before do + wallet_transaction_first + wallet_transaction_second + wallet_transaction_third + wallet_transaction_fourth + end + + it "returns all wallet transactions for a certain wallet" do + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(wallet_transaction_first.id) + expect(returned_ids).to include(wallet_transaction_second.id) + expect(returned_ids).to include(wallet_transaction_third.id) + expect(returned_ids).not_to include(wallet_transaction_fourth.id) + end + + context "when wallet transactions have the same values for the ordering criteria" do + let(:wallet_transaction_second) do + create( + :wallet_transaction, + wallet:, + id: "00000000-0000-0000-0000-000000000000", + created_at: wallet_transaction_first.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(wallet_transaction_first.id) + expect(returned_ids).to include(wallet_transaction_second.id) + expect(returned_ids.index(wallet_transaction_first.id)).to be > returned_ids.index(wallet_transaction_second.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(1) + expect(result.wallet_transactions.current_page).to eq(2) + expect(result.wallet_transactions.prev_page).to eq(1) + expect(result.wallet_transactions.next_page).to be_nil + expect(result.wallet_transactions.total_pages).to eq(2) + expect(result.wallet_transactions.total_count).to eq(3) + end + end + + context "when filtering by status" do + let(:wallet_transaction_third) { create(:wallet_transaction, wallet:, status: "pending") } + + let(:filters) { {status: "pending"} } + + it "returns only one wallet transaction" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(wallet_transaction_first.id) + expect(returned_ids).not_to include(wallet_transaction_second.id) + expect(returned_ids).to include(wallet_transaction_third.id) + expect(returned_ids).not_to include(wallet_transaction_fourth.id) + end + end + + context "when filtering by transaction type" do + let(:wallet_transaction_third) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + + let(:filters) { {transaction_type: "outbound"} } + + it "returns only one wallet transaction" do + expect(returned_ids.count).to eq(1) + expect(returned_ids).not_to include(wallet_transaction_first.id) + expect(returned_ids).not_to include(wallet_transaction_second.id) + expect(returned_ids).to include(wallet_transaction_third.id) + expect(returned_ids).not_to include(wallet_transaction_fourth.id) + end + end + + context "when transaction_status filter is present" do + # Override outer context transactions to avoid interference + let(:wallet_transaction_first) { nil } + let(:wallet_transaction_second) { nil } + let(:wallet_transaction_third) { nil } + + let(:purchased_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :purchased) + end + let(:granted_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :granted) + end + let(:voided_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :voided) + end + let(:invoiced_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :invoiced) + end + + before do + purchased_transaction + granted_transaction + voided_transaction + invoiced_transaction + end + + context "with purchased status" do + let(:filters) { {transaction_status: "purchased"} } + + it "returns only purchased transactions" do + expect(result).to be_success + expect(returned_ids).to eq([purchased_transaction.id]) + end + end + + context "with granted status" do + let(:filters) { {transaction_status: "granted"} } + + it "returns only granted transactions" do + expect(result).to be_success + expect(returned_ids).to eq([granted_transaction.id]) + end + end + + context "with voided status" do + let(:filters) { {transaction_status: "voided"} } + + it "returns only voided transactions" do + expect(result).to be_success + expect(returned_ids).to eq([voided_transaction.id]) + end + end + + context "with invoiced status" do + let(:filters) { {transaction_status: "invoiced"} } + + it "returns only invoiced transactions" do + expect(result).to be_success + expect(returned_ids).to eq([invoiced_transaction.id]) + end + end + + context "with invalid transaction_status" do + let(:filters) { {transaction_status: "invalid"} } + + it "returns all transactions" do + expect(result).to be_success + expect(returned_ids.count).to eq(4) + end + end + end + + context "when wallet is not found" do + let(:wallet_id) { "#{wallet.id}abc" } + + it "returns not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("wallet_not_found") + end + end +end diff --git a/spec/queries/wallets_query_spec.rb b/spec/queries/wallets_query_spec.rb new file mode 100644 index 0000000..d6d5d78 --- /dev/null +++ b/spec/queries/wallets_query_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletsQuery do + subject(:result) do + described_class.call(organization:, pagination:, filters:) + end + + let(:returned_ids) { result.wallets.pluck(:id) } + let(:organization) { create :organization } + let(:customer_1) { create :customer, organization:, external_id: "customer_1" } + let(:customer_2) { create :customer, organization:, external_id: "customer_2" } + let(:wallet_1) { create :wallet, customer: customer_1 } + let(:wallet_2) { create :wallet, customer: customer_1 } + let(:wallet_3) { create :wallet, customer: customer_2 } + let(:wallet_4) { create :wallet } + let(:pagination) { {page: 1, limit: 10} } + let(:filters) { nil } + + before do + wallet_1 + wallet_2 + wallet_3 + wallet_4 + end + + it "returns all wallets" do + expect(result.wallets.count).to eq(3) + expect(returned_ids).to include(wallet_1.id) + expect(returned_ids).to include(wallet_2.id) + expect(returned_ids).to include(wallet_3.id) + expect(returned_ids).not_to include(wallet_4.id) + end + + context "when wallets have the same values for the ordering criteria" do + let(:wallet_2) do + create( + :wallet, + customer: customer_1, + id: "00000000-0000-0000-0000-000000000000", + created_at: wallet_1.created_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(wallet_1.id) + expect(returned_ids).to include(wallet_2.id) + expect(returned_ids.index(wallet_1.id)).to be > returned_ids.index(wallet_2.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.wallets.count).to eq(1) + expect(result.wallets.current_page).to eq(2) + expect(result.wallets.prev_page).to eq(1) + expect(result.wallets.next_page).to be_nil + expect(result.wallets.total_pages).to eq(2) + expect(result.wallets.total_count).to eq(3) + end + end + + context "when filtering by currency" do + let(:filters) { {currency: "USD"} } + let(:wallet_1) { create(:wallet, customer: customer_1, currency: "USD") } + let(:wallet_2) { create(:wallet, customer: customer_1, currency: "EUR") } + + it "returns only wallets with matching currency" do + expect(result).to be_success + expect(returned_ids).to include(wallet_1.id) + expect(returned_ids).not_to include(wallet_2.id) + end + + context "when no wallets match the currency" do + let(:filters) { {currency: "GBP"} } + + it "returns no wallets" do + expect(result).to be_success + expect(result.wallets.count).to eq(0) + end + end + end + + context "when filtering by external_customer_id" do + let(:filters) { {external_customer_id: customer_1.external_id} } + + it "returns only two wallets" do + expect(result.wallets.count).to eq(2) + expect(returned_ids).to include(wallet_1.id) + expect(returned_ids).to include(wallet_2.id) + expect(returned_ids).not_to include(wallet_3.id) + expect(returned_ids).not_to include(wallet_4.id) + end + + context "when customer is not found" do + let(:filters) { {external_customer_id: "not_found_external_id"} } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("customer_not_found") + end + end + end +end diff --git a/spec/queries/webhook_endpoints_query_spec.rb b/spec/queries/webhook_endpoints_query_spec.rb new file mode 100644 index 0000000..da7c43b --- /dev/null +++ b/spec/queries/webhook_endpoints_query_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WebhookEndpointsQuery do + subject(:result) do + described_class.call(organization:, pagination:, search_term:) + end + + let(:returned_ids) { result.webhook_endpoints.pluck(:id) } + + let(:pagination) { nil } + let(:search_term) { nil } + + let(:organization) { create :organization } + let(:webhook_endpoint_first) { create(:webhook_endpoint, organization:, webhook_url: "https://www.getlago.com/webhooks") } + let(:webhook_endpoint_second) { create(:webhook_endpoint, organization:, webhook_url: "https://test.com/lago-webhooks") } + let(:webhook_endpoint_third) { create(:webhook_endpoint, organization:, webhook_url: "https://www.google.com/v1/hooks") } + let(:webhook_endpoint_fourth) { create(:webhook_endpoint, organization: create(:organization), webhook_url: "https://www.getlago.com/webhooks") } + + before do + organization.webhook_endpoints.destroy_all # organization factory creates a webhook endpoint + webhook_endpoint_first + webhook_endpoint_second + webhook_endpoint_third + webhook_endpoint_fourth + end + + it "returns all webhook endpoints of the organization" do + expect(result.webhook_endpoints.count).to eq(3) + expect(returned_ids).to include(webhook_endpoint_first.id) + expect(returned_ids).to include(webhook_endpoint_second.id) + expect(returned_ids).to include(webhook_endpoint_third.id) + expect(returned_ids).not_to include(webhook_endpoint_fourth.id) + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.webhook_endpoints.count).to eq(1) + expect(result.webhook_endpoints.current_page).to eq(2) + expect(result.webhook_endpoints.prev_page).to eq(1) + expect(result.webhook_endpoints.next_page).to be_nil + expect(result.webhook_endpoints.total_pages).to eq(2) + expect(result.webhook_endpoints.total_count).to eq(3) + end + end + + context "when searching for /lago/ term" do + let(:search_term) { "lago" } + + it "returns only two webhook_endpoints" do + expect(result.webhook_endpoints.count).to eq(2) + expect(returned_ids).to include(webhook_endpoint_first.id) + expect(returned_ids).to include(webhook_endpoint_second.id) + expect(returned_ids).not_to include(webhook_endpoint_third.id) + expect(returned_ids).not_to include(webhook_endpoint_fourth.id) + end + end +end diff --git a/spec/queries/webhooks_query_spec.rb b/spec/queries/webhooks_query_spec.rb new file mode 100644 index 0000000..e47c551 --- /dev/null +++ b/spec/queries/webhooks_query_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WebhooksQuery do + subject(:result) do + described_class.call(organization:, pagination:, search_term:, filters:) + end + + let(:returned_ids) { result.webhooks.pluck(:id) } + + let(:pagination) { nil } + let(:search_term) { nil } + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id} } + + let(:organization) { webhook_endpoint.organization.reload } + let(:webhook_endpoint) { create(:webhook_endpoint) } + let(:webhook_succeeded) { create(:webhook, :succeeded, webhook_endpoint:) } + let(:webhook_failed) { create(:webhook, :failed, webhook_endpoint:) } + let(:webhook_other_type) { create(:webhook, :succeeded, webhook_endpoint:, webhook_type: "invoice.generated") } + + before do + webhook_succeeded + webhook_failed + webhook_other_type + end + + it "returns all webhooks" do + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(webhook_succeeded.id) + expect(returned_ids).to include(webhook_failed.id) + expect(returned_ids).to include(webhook_other_type.id) + end + + context "when ordering by second criteria" do + let(:webhook_failed) do + create( + :webhook, + :failed, + webhook_endpoint:, + id: "00000000-0000-0000-0000-000000000000", + created_at: webhook_succeeded.created_at + 1.second, + updated_at: webhook_succeeded.updated_at + ) + end + + it "returns a consistent list" do + expect(result).to be_success + expect(returned_ids.count).to eq(3) + expect(returned_ids).to include(webhook_succeeded.id) + expect(returned_ids).to include(webhook_failed.id) + expect(returned_ids.index(webhook_succeeded.id)).to be > returned_ids.index(webhook_failed.id) + end + end + + context "with pagination" do + let(:pagination) { {page: 2, limit: 2} } + + it "applies the pagination" do + expect(result).to be_success + expect(result.webhooks.count).to eq(1) + expect(result.webhooks.current_page).to eq(2) + expect(result.webhooks.prev_page).to eq(1) + expect(result.webhooks.next_page).to be_nil + expect(result.webhooks.total_pages).to eq(2) + expect(result.webhooks.total_count).to eq(3) + end + end + + context "when text searching" do + context "when search for event id" do + let(:search_term) { webhook_succeeded.id.to_s } + + it "returns matching webhooks" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(webhook_succeeded.id) + end + end + + context "when search for resource id" do + let(:search_term) { webhook_succeeded.object_id.to_s } + + it "returns matching webhooks" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(webhook_succeeded.id) + end + end + end + + context "when filtering" do + describe "status" do + context "when filtering by valid status" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, statuses: ["failed"]} } + + it "returns only one webhook" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(webhook_failed.id) + end + end + + context "when filtering by invalid status" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, statuses: ["invalid_status"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "event type" do + context "when filtering by valid event type" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, event_types: ["invoice.generated"]} } + + it "returns only matching webhooks" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(webhook_other_type.id) + end + end + + context "when filtering by invalid event type" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, event_types: ["invalid.event"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "http status" do + context "when filtering by specific status code" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, http_statuses: ["200"]} } + + it "returns only matching webhooks" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(webhook_succeeded.id) + expect(returned_ids).to include(webhook_other_type.id) + end + end + + context "when filtering by wildcard status code" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, http_statuses: ["5xx"]} } + + it "returns only matching webhooks" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(webhook_failed.id) + end + end + + context "when filtering by status code range" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, http_statuses: ["200-205"]} } + + it "returns only matching webhooks" do + expect(result).to be_success + expect(returned_ids.count).to eq(2) + expect(returned_ids).to include(webhook_succeeded.id) + expect(returned_ids).to include(webhook_other_type.id) + end + end + + context "when filtering by timeout" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, http_statuses: ["timeout"]} } + let(:webhook_failed) { create(:webhook, :failed, webhook_endpoint:, http_status: nil) } + + it "returns only matching webhooks" do + expect(result).to be_success + expect(returned_ids.count).to eq(1) + expect(returned_ids).to include(webhook_failed.id) + end + end + + context "when filtering by invalid http status format" do + let(:filters) { {webhook_endpoint_id: webhook_endpoint.id, http_statuses: ["invalid_status"]} } + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + describe "date range" do + context "when filtering by valid date range" do + let(:filters) do + { + webhook_endpoint_id: webhook_endpoint.id, + from_date: 2.hours.ago, + to_date: 1.hour.ago + } + end + + it "returns webhooks updated within the date range" do + expect(result).to be_success + expect(returned_ids.count).to eq(0) + end + end + + context "when filtering with invalid date format" do + let(:filters) do + { + webhook_endpoint_id: webhook_endpoint.id, + from_date: "invalid_date", + to_date: "invalid_date" + } + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..f5c66c1 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +def pp(*args) + # Uncomment the following line if you can't find where you left a `pp` call + # ap caller.first + args.each do |arg| + ap arg, {sort_vars: false, sort_keys: false, indent: -2} + end +end + +# rubocop:disable Rails/Output +def pps(*args) + pp "--------------------------------------" + pp(*args) + pp "--------------------------------------" +end +# rubocop:enable Rails/Output diff --git a/spec/requests/admin/base_controller_spec.rb b/spec/requests/admin/base_controller_spec.rb new file mode 100644 index 0000000..b2a250f --- /dev/null +++ b/spec/requests/admin/base_controller_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::BaseController, type: [:controller, :admin] do + controller do + def index + render nothing: true + end + end + + describe "authenticate" do + it "validates the organization api key" do + request.headers["Authorization"] = "Bearer 123456" + + get :index + + expect(response).to have_http_status(:success) + end + + context "without authentication header" do + it "returns an authentication error" do + get :index + + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/admin/invoices_controller_spec.rb b/spec/requests/admin/invoices_controller_spec.rb new file mode 100644 index 0000000..402e72b --- /dev/null +++ b/spec/requests/admin/invoices_controller_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::InvoicesController, type: [:request, :admin] do + let(:invoice) { create(:invoice) } + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::GeneratePdfService).to receive(:call) + .with(invoice:, context: "admin") + .and_return(result) + end + + describe "POST /admin/invoices/:id/regenerate" do + it "regenerates the invoice PDF" do + admin_post("/admin/invoices/#{invoice.id}/regenerate") + + expect(Invoices::GeneratePdfService).to have_received(:call) + expect(response).to have_http_status(:success) + end + end +end diff --git a/spec/requests/admin/memberships_controller_spec.rb b/spec/requests/admin/memberships_controller_spec.rb new file mode 100644 index 0000000..2e79293 --- /dev/null +++ b/spec/requests/admin/memberships_controller_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::MembershipsController, type: [:request, :admin] do + let(:organization) { create(:organization) } + let(:user) { create(:user) } + + describe "POST /admin/memberships" do + let(:create_params) do + { + user_id: user.id, + organization_id: organization.id + } + end + + it "creates a membership" do + admin_post( + "/admin/memberships", + create_params + ) + + expect(response).to have_http_status(:success) + expect(json[:membership][:lago_user_id]).to eq(user.id) + expect(json[:membership][:lago_organization_id]).to eq(organization.id) + end + end +end diff --git a/spec/requests/admin/organizations_controller_spec.rb b/spec/requests/admin/organizations_controller_spec.rb new file mode 100644 index 0000000..13fa28a --- /dev/null +++ b/spec/requests/admin/organizations_controller_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::OrganizationsController, type: [:request, :admin] do + let(:organization) { create(:organization) } + + describe "PUT /admin/organizations/:id" do + let(:update_params) do + { + name: "FooBar", + premium_integrations: ["okta"] + } + end + + it "updates an organization" do + admin_put( + "/admin/organizations/#{organization.id}", + update_params + ) + + expect(response).to have_http_status(:success) + + expect(json[:organization][:name]).to eq("FooBar") + expect(json[:organization][:premium_integrations]).to include("okta") + + organization.reload + + expect(organization.name).to eq("FooBar") + expect(organization.premium_integrations).to include("okta") + end + end + + describe "POST /admin/organizations" do + let(:create_params) do + { + name: "NewCo", + email: "admin@newco.test", + premium_integrations: ["okta"] + } + end + + before do + create(:role, :admin) + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("ADMIN_API_KEY").and_return("super-secret") + end + + context "with a valid admin key" do + it "creates an organization and returns 201" do + headers = {"X-Admin-API-Key" => "super-secret"} + expect do + admin_post_without_bearer("/admin/organizations", create_params, headers) + end.to change(Organization, :count).by(1) + + expect(response).to have_http_status(:created) + expect(json[:organization][:name]).to eq("NewCo") + expect(json[:invite_url]).to be_present + expect(json[:organization][:premium_integrations]).to include("okta") + end + end + + context "with an invalid admin key" do + it "returns unauthorized" do + headers = {"X-Admin-API-Key" => "wrong"} + admin_post_without_bearer("/admin/organizations", create_params, headers) + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/api/base_controller_spec.rb b/spec/requests/api/base_controller_spec.rb new file mode 100644 index 0000000..3229295 --- /dev/null +++ b/spec/requests/api/base_controller_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::BaseController, type: :controller do + controller do + def index + render nothing: true + end + + def create + params.require(:input).permit(:value) + render nothing: true + end + + def track_api_key_usage? + action_name.to_sym != :create + end + end + + let(:api_key) { create(:api_key) } + + before do + allow(CurrentContext).to receive(:source=) + allow(CurrentContext).to receive(:api_key_id=) + end + + it "sets the context source to api" do + request.headers["Authorization"] = "Bearer #{api_key.value}" + + get :index + + expect(CurrentContext).to have_received(:source=).with("api") + expect(CurrentContext).to have_received(:api_key_id=).with(api_key.id) + end + + describe "#authenticate" do + before do + request.headers["Authorization"] = "Bearer #{api_key.value}" + get :index + end + + context "with valid authorization header" do + let(:api_key) { [create(:api_key), create(:api_key, :expiring)].sample } + + it "returns success response" do + expect(response).to have_http_status(:success) + end + end + + context "with invalid authentication header" do + let(:api_key) { create(:api_key, :expired) } + + it "returns an authentication error" do + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe "#track_api_key_usage", cache: :memory do + let(:api_key) { create(:api_key) } + let(:cache_key) { "api_key_last_used_#{api_key.id}" } + + before do + request.headers["Authorization"] = "Bearer #{api_key.value}" + freeze_time + end + + context "when accessed trackable endpoint" do + subject { get :index } + + it "caches when API key was last used" do + expect { subject }.to change { Rails.cache.read(cache_key) }.to Time.current.iso8601 + end + end + + context "when accessed non-trackable endpoint" do + subject { get :create } + + it "does not cache when API key was last used" do + expect { subject }.not_to change { Rails.cache.read(cache_key) }.from nil + end + end + end + + it "catches the missing parameters error" do + request.headers["Authorization"] = "Bearer #{api_key.value}" + + post :create + + expect(response).to have_http_status(:bad_request) + + json = JSON.parse(response.body, symbolize_names: true) + expect(json[:status]).to eq(400) + expect(json[:error]).to eq("BadRequest: param is missing or the value is empty or invalid: input") + end +end diff --git a/spec/requests/api/v1/activity_logs_controller_spec.rb b/spec/requests/api/v1/activity_logs_controller_spec.rb new file mode 100644 index 0000000..30d572f --- /dev/null +++ b/spec/requests/api/v1/activity_logs_controller_spec.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::ActivityLogsController, clickhouse: true do + let(:organization) { activity_log.organization } + let(:params) { {} } + + describe "GET /api/v1/activity_logs" do + subject { get_with_token(organization, "/api/v1/activity_logs", params) } + + let(:activity_log) { create(:clickhouse_activity_log) } + let(:invoice_activity_log) do + create( + :clickhouse_activity_log, + organization_id: organization.id, + external_customer_id: "ext_123", + external_subscription_id: "ext_456", + resource: invoice, + activity_type: "invoice.created", + activity_source: "front", + logged_at: 1.day.ago + ) + end + let(:invoice) { create(:invoice, organization:) } + + before do + activity_log + invoice_activity_log + end + + context "with free organization" do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "with premium organization", :premium do + include_examples "requires API permission", "activity_log", "read" + + it "returns activity logs" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(2) + expect(json[:activity_logs].map { |l| l[:activity_id] }).to include(activity_log.activity_id, invoice_activity_log.activity_id) + end + + context "when filtering by external_customer_id" do + let(:params) { {external_customer_id: activity_log.external_customer_id} } + + it "returns activity logs for the specified external customer" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:external_customer_id]).to eq(activity_log.external_customer_id) + end + end + + context "when filtering by external_subscription_id" do + let(:params) { {external_subscription_id: activity_log.external_subscription_id} } + + it "returns activity logs for the specified external subscription" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:external_subscription_id]).to eq(activity_log.external_subscription_id) + end + end + + context "when filtering by resource_id" do + let(:params) { {resource_ids: [activity_log.resource_id]} } + + it "returns activity logs for the specified resource" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:resource_id]).to eq(activity_log.resource_id) + end + end + + context "when filtering by resource_type" do + let(:params) { {resource_types: [activity_log.resource_type]} } + + it "returns activity logs for the specified resource type" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:resource_type]).to eq(activity_log.resource_type) + end + end + + context "when filtering by user_email" do + let(:params) { {user_emails: [activity_log.user.email]} } + + it "returns activity logs for the specified user email" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:user_email]).to eq(activity_log.user.email) + end + end + + context "when filtering by activity_type" do + let(:params) { {activity_types: [activity_log.activity_type]} } + + it "returns activity logs for the specified activity type" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:activity_type]).to eq(activity_log.activity_type) + end + end + + context "when filtering by activity_source" do + let(:params) { {activity_sources: [activity_log.activity_source]} } + + it "returns activity logs for the specified activity source" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:activity_source]).to eq(activity_log.activity_source) + end + end + + context "when filtering by from_date" do + let(:params) { {from_date: activity_log.logged_at.iso8601} } + + it "returns activity logs for the specified date range" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(activity_log.activity_id) + expect(json[:activity_logs].first[:logged_at]).to eq(activity_log.logged_at.iso8601) + end + end + + context "when filtering by to_date" do + let(:params) { {to_date: activity_log.logged_at.iso8601} } + + it "returns activity logs for the specified date range" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:activity_logs].first[:activity_id]).to eq(invoice_activity_log.activity_id) + expect(json[:activity_logs].first[:logged_at]).to eq(invoice_activity_log.logged_at.iso8601) + end + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + it "returns activity logs with correct meta data" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_logs].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end + end + + describe "GET /api/v1/activity_logs/:activity_id" do + subject { get_with_token(organization, "/api/v1/activity_logs/#{activity_log.activity_id}", params) } + + let(:activity_log) { create(:clickhouse_activity_log) } + + context "with free organization" do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "with premium organization", :premium do + include_examples "requires API permission", "activity_log", "read" + + it "returns activity logs" do + subject + + expect(response).to have_http_status(:success) + expect(json[:activity_log]).to include( + activity_id: activity_log.activity_id, + activity_source: activity_log.activity_source, + logged_at: activity_log.logged_at.iso8601 + ) + end + end + end +end diff --git a/spec/requests/api/v1/add_ons_controller_spec.rb b/spec/requests/api/v1/add_ons_controller_spec.rb new file mode 100644 index 0000000..52dec41 --- /dev/null +++ b/spec/requests/api/v1/add_ons_controller_spec.rb @@ -0,0 +1,193 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::AddOnsController do + let(:organization) { create(:organization) } + let(:tax) { create(:tax, organization:) } + + describe "POST /api/v1/add_ons" do + subject { post_with_token(organization, "/api/v1/add_ons", {add_on: create_params}) } + + let(:create_params) do + { + name: "add_on1", + invoice_display_name: "Addon 1 invoice name", + code: "add_on1_code", + amount_cents: 123, + amount_currency: "EUR", + description: "description", + tax_codes: [tax.code] + } + end + + include_examples "requires API permission", "add_on", "write" + + it "creates a add-on" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:add_on][:lago_id]).to be_present + expect(json[:add_on][:code]).to eq(create_params[:code]) + expect(json[:add_on][:name]).to eq(create_params[:name]) + expect(json[:add_on][:invoice_display_name]).to eq(create_params[:invoice_display_name]) + expect(json[:add_on][:created_at]).to be_present + expect(json[:add_on][:taxes].map { |t| t[:code] }).to contain_exactly(tax.code) + end + end + + describe "PUT /api/v1/add_ons/:code" do + subject do + put_with_token(organization, "/api/v1/add_ons/#{add_on_code}", {add_on: update_params}) + end + + let(:add_on) { create(:add_on, organization:) } + let(:add_on_code) { add_on.code } + let(:add_on_applied_tax) { create(:add_on_applied_tax, add_on:, tax:) } + let(:code) { "add_on_code" } + let(:tax2) { create(:tax, organization:) } + + let(:update_params) do + { + name: "add_on1", + invoice_display_name: "Addon 1 updated invoice name", + code:, + amount_cents: 123, + amount_currency: "EUR", + description: "description", + tax_codes: [tax2.code] + } + end + + before { add_on_applied_tax } + + include_examples "requires API permission", "add_on", "write" + + it "updates an add-on" do + subject + + expect(response).to have_http_status(:success) + expect(json[:add_on][:lago_id]).to eq(add_on.id) + expect(json[:add_on][:code]).to eq(update_params[:code]) + expect(json[:add_on][:invoice_display_name]).to eq(update_params[:invoice_display_name]) + expect(json[:add_on][:taxes].map { |t| t[:code] }).to contain_exactly(tax2.code) + end + + context "when add-on does not exist" do + let(:add_on_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when add-on code already exists in organization scope (validation error)" do + let!(:add_on2) { create(:add_on, organization:) } + let(:code) { add_on2.code } + + it "returns unprocessable_entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "GET /api/v1/add_ons/:code" do + subject { get_with_token(organization, "/api/v1/add_ons/#{add_on_code}") } + + let(:add_on) { create(:add_on, organization:) } + let(:add_on_code) { add_on.code } + + include_examples "requires API permission", "add_on", "read" + + it "returns a add-on" do + subject + + expect(response).to have_http_status(:success) + expect(json[:add_on][:lago_id]).to eq(add_on.id) + expect(json[:add_on][:invoice_display_name]).to eq(add_on.invoice_display_name) + expect(json[:add_on][:code]).to eq(add_on.code) + end + + context "when add-on does not exist" do + let(:add_on_code) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1/add_ons/:code" do + subject { delete_with_token(organization, "/api/v1/add_ons/#{add_on_code}") } + + let!(:add_on) { create(:add_on, organization:) } + let(:add_on_code) { add_on.code } + + include_examples "requires API permission", "add_on", "write" + + it "deletes a add-on" do + expect { subject }.to change(AddOn, :count).by(-1) + end + + it "returns deleted add-on" do + subject + + expect(response).to have_http_status(:success) + expect(json[:add_on][:lago_id]).to eq(add_on.id) + expect(json[:add_on][:code]).to eq(add_on.code) + end + + context "when add-on does not exist" do + let(:add_on_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/add_ons" do + subject { get_with_token(organization, "/api/v1/add_ons", params) } + + let(:add_on) { create(:add_on, organization:) } + let(:params) { {} } + + before { create(:add_on_applied_tax, add_on:, tax:) } + + include_examples "requires API permission", "add_on", "read" + + it "returns add-ons" do + subject + + expect(response).to have_http_status(:success) + expect(json[:add_ons].count).to eq(1) + expect(json[:add_ons].first[:lago_id]).to eq(add_on.id) + expect(json[:add_ons].first[:code]).to eq(add_on.code) + expect(json[:add_ons].first[:invoice_display_name]).to eq(add_on.invoice_display_name) + expect(json[:add_ons].first[:taxes].map { |t| t[:code] }).to contain_exactly(tax.code) + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + before { create(:add_on, organization:) } + + it "returns add-ons with correct meta data" do + subject + + expect(response).to have_http_status(:success) + expect(json[:add_ons].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end +end diff --git a/spec/requests/api/v1/analytics/gross_revenues_controller_spec.rb b/spec/requests/api/v1/analytics/gross_revenues_controller_spec.rb new file mode 100644 index 0000000..f007797 --- /dev/null +++ b/spec/requests/api/v1/analytics/gross_revenues_controller_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Analytics::GrossRevenuesController do + describe "GET /analytics/gross_revenue" do + subject { get_with_token(organization, "/api/v1/analytics/gross_revenue", params) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:params) { {} } + + before do + allow(Analytics::GrossRevenuesService).to receive(:call).and_call_original + end + + context "when licence is premium", :premium do + include_examples "requires API permission", "analytic", "read" + + it "returns the gross revenue" do + subject + + expect(response).to have_http_status(:success) + expect(json[:gross_revenues]).to eq([]) + expect(Analytics::GrossRevenuesService).to have_received(:call).with(organization, billing_entity_id: nil, currency: nil, months: nil, external_customer_id: nil) + end + + context "when sending params" do + let(:params) { {billing_entity_code: billing_entity.code} } + + it "calls the service with the billing_entity_id" do + subject + expect(Analytics::GrossRevenuesService).to have_received(:call).with(organization, billing_entity_id: billing_entity.id, currency: nil, months: nil, external_customer_id: nil) + end + end + end + + context "when licence is not premium" do + it "returns the gross revenue" do + subject + + expect(response).to have_http_status(:success) + expect(json[:gross_revenues]).to eq([]) + end + end + end +end diff --git a/spec/requests/api/v1/analytics/invoice_collections_controller_spec.rb b/spec/requests/api/v1/analytics/invoice_collections_controller_spec.rb new file mode 100644 index 0000000..cbdaae5 --- /dev/null +++ b/spec/requests/api/v1/analytics/invoice_collections_controller_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Analytics::InvoiceCollectionsController do # rubocop:disable Rails/FilePath + describe "GET /analytics/invoice_collection" do + subject { get_with_token(organization, "/api/v1/analytics/invoice_collection", params) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:params) { {} } + + before do + allow(Analytics::InvoiceCollectionsService).to receive(:call).and_call_original + end + + context "when licence is premium", :premium do + include_examples "requires API permission", "analytic", "read" + + it "returns the gross revenue" do + subject + + expect(response).to have_http_status(:success) + + month = DateTime.parse json[:invoice_collections].first[:month] + + expect(month).to eq(DateTime.current.beginning_of_month) + expect(json[:invoice_collections].first[:payment_status]).to eq(nil) + expect(json[:invoice_collections].first[:invoices_count]).to eq(0) + expect(json[:invoice_collections].first[:amount_cents]).to eq(0.0) + expect(json[:invoice_collections].first[:currency]).to eq(nil) + expect(Analytics::InvoiceCollectionsService).to have_received(:call).with(organization, billing_entity_id: nil, currency: nil, months: nil) + end + + context "when sending params" do + let(:params) { {billing_entity_code: billing_entity.code} } + + it "calls the service with the billing_entity_id" do + subject + expect(Analytics::InvoiceCollectionsService).to have_received(:call).with(organization, billing_entity_id: billing_entity.id, currency: nil, months: nil) + end + end + end + + context "when licence is not premium" do + it "returns forbidden status" do + subject + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/requests/api/v1/analytics/invoiced_usages_controller_spec.rb b/spec/requests/api/v1/analytics/invoiced_usages_controller_spec.rb new file mode 100644 index 0000000..6cae3f7 --- /dev/null +++ b/spec/requests/api/v1/analytics/invoiced_usages_controller_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Analytics::InvoicedUsagesController do # rubocop:disable Rails/FilePath + describe "GET /analytics/invoiced_usage" do + subject { get_with_token(organization, "/api/v1/analytics/invoiced_usage", params) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:params) { {} } + + before do + allow(Analytics::InvoicedUsagesService).to receive(:call).and_call_original + end + + context "when license is premium", :premium do + include_examples "requires API permission", "analytic", "read" + + it "returns the invoiced usage" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:invoiced_usages]).to eq([]) + expect(Analytics::InvoicedUsagesService).to have_received(:call).with(organization, billing_entity_id: nil, currency: nil, months: nil) + end + + context "when sending params" do + let(:params) { {billing_entity_code: billing_entity.code} } + + it "calls the service with the billing_entity_id" do + subject + expect(Analytics::InvoicedUsagesService).to have_received(:call).with(organization, billing_entity_id: billing_entity.id, currency: nil, months: nil) + end + end + end + + context "when license is not premium" do + it "returns forbidden status" do + subject + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/requests/api/v1/analytics/mrrs_controller_spec.rb b/spec/requests/api/v1/analytics/mrrs_controller_spec.rb new file mode 100644 index 0000000..2983e9b --- /dev/null +++ b/spec/requests/api/v1/analytics/mrrs_controller_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Analytics::MrrsController do # rubocop:disable Rails/FilePath + describe "GET /analytics/mrr" do + subject { get_with_token(organization, "/api/v1/analytics/mrr", params) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:params) { {} } + + before do + allow(Analytics::MrrsService).to receive(:call).and_call_original + end + + context "when license is premium", :premium do + include_examples "requires API permission", "analytic", "read" + + it "returns the mrr" do + subject + + expect(response).to have_http_status(:success) + + month = DateTime.parse json[:mrrs].first[:month] + + expect(month).to eq(DateTime.current.beginning_of_month) + expect(json[:mrrs].first[:currency]).to eq(nil) + expect(json[:mrrs].first[:amount_cents]).to eq(nil) + expect(Analytics::MrrsService).to have_received(:call).with(organization, billing_entity_id: nil, currency: nil, months: nil) + end + + context "when sending params" do + let(:params) { {billing_entity_code: billing_entity.code} } + + it "calls the service with the billing_entity_id" do + subject + expect(Analytics::MrrsService).to have_received(:call).with(organization, billing_entity_id: billing_entity.id, currency: nil, months: nil) + end + end + end + + context "when license is not premium" do + it "returns forbidden status" do + subject + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/requests/api/v1/analytics/overdue_balances_controller_spec.rb b/spec/requests/api/v1/analytics/overdue_balances_controller_spec.rb new file mode 100644 index 0000000..094bc22 --- /dev/null +++ b/spec/requests/api/v1/analytics/overdue_balances_controller_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Analytics::OverdueBalancesController do + describe "GET /analytics/overdue_balance" do + subject { get_with_token(organization, "/api/v1/analytics/overdue_balance", params) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization, created_at: DateTime.new(2023, 11, 1)) } + let(:billing_entity) { create(:billing_entity, organization: organization) } + let(:params) { {} } + + before do + allow(Analytics::OverdueBalancesService).to receive(:call).and_call_original + end + + include_examples "requires API permission", "analytic", "read" + + it "returns the overdue balance" do + travel_to(DateTime.new(2024, 1, 15)) do + create(:invoice, customer:, organization:) + i1 = create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 2.months.ago, total_amount_cents: 1000) + i2 = create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 5.days.ago, total_amount_cents: 2000) + i3 = create(:invoice, customer:, organization:, payment_overdue: true, payment_due_date: 1.day.ago, total_amount_cents: 3000) + + subject + + expect(response).to have_http_status(:success) + expect(json[:overdue_balances]).to match_array( + [ + { + month: "2023-11-01T00:00:00.000Z", + amount_cents: "1000.0", + currency: "EUR", + lago_invoice_ids: [i1.id] + }, + { + month: "2024-01-01T00:00:00.000Z", + amount_cents: "5000.0", + currency: "EUR", + lago_invoice_ids: match_array([i2.id, i3.id]) + } + ] + ) + end + expect(Analytics::OverdueBalancesService).to have_received(:call).with(organization, billing_entity_id: nil, currency: nil, months: nil, external_customer_id: nil) + end + + context "when sending params" do + let(:params) { {billing_entity_code: billing_entity.code} } + + it "calls the service with the billing_entity_id" do + subject + expect(Analytics::OverdueBalancesService).to have_received(:call).with(organization, billing_entity_id: billing_entity.id, currency: nil, months: nil, external_customer_id: nil) + end + end + end +end diff --git a/spec/requests/api/v1/api_logs_controller_spec.rb b/spec/requests/api/v1/api_logs_controller_spec.rb new file mode 100644 index 0000000..6827820 --- /dev/null +++ b/spec/requests/api/v1/api_logs_controller_spec.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::ApiLogsController, clickhouse: true do + subject { get_with_token(organization, path, params) } + + let(:organization) { api_log.organization } + let(:api_log) { create(:clickhouse_api_log) } + let(:params) { {} } + + describe "GET /api/v1/api_logs" do + let(:path) { "/api/v1/api_logs" } + + context "with free organization" do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "with premium organization", :premium do + include_examples "requires API permission", "api_log", "read" + + context "without filters" do + let(:plain_api_log) { create(:clickhouse_api_log, organization:) } + + before { plain_api_log } + + it "returns api logs" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(2) + expect(json[:api_logs].map { |l| l[:request_id] }).to include(api_log.request_id, plain_api_log.request_id) + end + end + + context "when filtering by from_date" do + let(:params) { {from_date: api_log.logged_at.iso8601} } + + it "returns api logs for the specified date range" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(api_log.request_id) + expect(json[:api_logs].first[:logged_at]).to eq(api_log.logged_at.iso8601) + end + end + + context "when filtering by to_date" do + let(:params) { {to_date: api_log.logged_at.iso8601} } + let(:later_api_log) { create(:clickhouse_api_log, organization:, logged_at: 1.day.ago) } + + before { later_api_log } + + it "returns api logs for the specified date range" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(later_api_log.request_id) + expect(json[:api_logs].first[:logged_at]).to eq(later_api_log.logged_at.iso8601) + end + end + + context "when filtering by http_methods" do + let(:params) { {http_methods: ["put"]} } + let(:put_api_log) { create(:clickhouse_api_log, organization:, http_method: "put") } + + before { put_api_log } + + it "returns api logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(put_api_log.request_id) + expect(json[:api_logs].first[:http_method]).to eq(put_api_log.http_method) + end + end + + context "when filtering by http_statuses" do + let(:params) { {http_statuses: [status]} } + + context "with success" do + let(:status) { "succeeded" } + + it "returns api logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(api_log.request_id) + expect(json[:api_logs].first[:http_status]).to eq(api_log.http_status) + end + end + + context "with failed" do + let(:status) { "failed" } + let(:failed_request_api_log) { create(:clickhouse_api_log, organization:, http_status: 404) } + + before { failed_request_api_log } + + it "returns api logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(failed_request_api_log.request_id) + expect(json[:api_logs].first[:http_status]).to eq(failed_request_api_log.http_status) + end + end + + context "with http status number" do + let(:status) { api_log.http_status } + + it "returns api logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(api_log.request_id) + expect(json[:api_logs].first[:http_status]).to eq(api_log.http_status) + end + end + end + + context "when filtering by api_version" do + let(:params) { {api_version: "v1"} } + + it "returns api logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(api_log.request_id) + expect(json[:api_logs].first[:api_version]).to eq(api_log.api_version) + end + end + + context "when filtering by request_paths" do + let(:params) { {request_paths: ["*billable_metrics*"]} } + let(:bm_api_log) { create(:clickhouse_api_log, organization:, request_path: "/v1/billable_metrics/") } + + before { bm_api_log } + + it "returns api logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:api_logs].first[:request_id]).to eq(bm_api_log.request_id) + expect(json[:api_logs].first[:request_path]).to eq(bm_api_log.request_path) + end + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + before do + create(:clickhouse_api_log, organization:) + end + + it "returns activity logs with correct meta data" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_logs].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end + end + + describe "GET /api/v1/api_logs/:request_id" do + let(:path) { "/api/v1/api_logs/#{api_log.request_id}" } + + context "with free organization" do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "with premium organization", :premium do + include_examples "requires API permission", "api_log", "read" + + it "returns api logs" do + subject + + expect(response).to have_http_status(:success) + expect(json[:api_log]).to include( + request_id: api_log.request_id, + http_method: api_log.http_method, + logged_at: api_log.logged_at.iso8601 + ) + end + end + end +end diff --git a/spec/requests/api/v1/applied_coupons_controller_spec.rb b/spec/requests/api/v1/applied_coupons_controller_spec.rb new file mode 100644 index 0000000..1f436e5 --- /dev/null +++ b/spec/requests/api/v1/applied_coupons_controller_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::AppliedCouponsController, :bullet do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + describe "POST /api/v1/applied_coupons" do + subject do + post_with_token(organization, "/api/v1/applied_coupons", {applied_coupon: params}) + end + + let(:params) do + { + external_customer_id: customer.external_id, + coupon_code: coupon.code + } + end + + let(:coupon) { create(:coupon, organization:) } + + before { create(:subscription, customer:) } + + include_examples "requires API permission", "applied_coupon", "write" + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:applied_coupon][:lago_id]).to be_present + expect(json[:applied_coupon][:lago_coupon_id]).to eq(coupon.id) + expect(json[:applied_coupon][:lago_customer_id]).to eq(customer.id) + expect(json[:applied_coupon][:external_customer_id]).to eq(customer.external_id) + expect(json[:applied_coupon][:amount_cents]).to eq(coupon.amount_cents) + expect(json[:applied_coupon][:amount_currency]).to eq(coupon.amount_currency) + expect(json[:applied_coupon][:expiration_at]).to be_nil + expect(json[:applied_coupon][:created_at]).to be_present + expect(json[:applied_coupon][:terminated_at]).to be_nil + end + + context "with invalid params" do + let(:params) do + {name: "Foo Bar"} + end + + it "returns an unprocessable_entity" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/applied_coupons" do + include_examples "a applied coupon index endpoint" do + subject { get_with_token(organization, "/api/v1/applied_coupons", params) } + + context "with external_customer_id filter" do + let(:params) { {external_customer_id: customer.external_id} } + + let(:other_customer) { create(:customer, organization:) } + let(:other_customer_applied_coupon) do + create(:applied_coupon, customer: other_customer, coupon: coupon_1) + end + + before { other_customer_applied_coupon } + + it "returns only the applied coupons for the specified customer" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons].count).to eq(2) + expect(json[:applied_coupons].pluck(:lago_id)).to match_array([applied_coupon_1.id, applied_coupon_2.id]) + end + + context "when no applied coupons match the external_customer_id" do + let(:params) { {external_customer_id: "non_existent_id"} } + + it "returns an empty array" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons]).to be_empty + end + end + end + end + end +end diff --git a/spec/requests/api/v1/billable_metrics_controller_spec.rb b/spec/requests/api/v1/billable_metrics_controller_spec.rb new file mode 100644 index 0000000..1b16564 --- /dev/null +++ b/spec/requests/api/v1/billable_metrics_controller_spec.rb @@ -0,0 +1,330 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::BillableMetricsController do + let(:organization) { create(:organization) } + + describe "POST /api/v1/billable_metrics" do + subject do + post_with_token( + organization, + "/api/v1/billable_metrics", + {billable_metric: create_params} + ) + end + + let(:create_params) do + { + name: "BM1", + code: "BM1_code", + description: "description", + aggregation_type: "sum_agg", + field_name: "amount_sum", + expression: "1 + 2", + recurring: true, + rounding_function: "round", + rounding_precision: 2 + } + end + + include_examples "requires API permission", "billable_metric", "write" + + it "creates a billable_metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metric][:lago_id]).to be_present + expect(json[:billable_metric][:code]).to eq(create_params[:code]) + expect(json[:billable_metric][:name]).to eq(create_params[:name]) + expect(json[:billable_metric][:created_at]).to be_present + expect(json[:billable_metric][:recurring]).to eq(create_params[:recurring]) + expect(json[:billable_metric][:expression]).to eq(create_params[:expression]) + expect(json[:billable_metric][:rounding_function]).to eq(create_params[:rounding_function]) + expect(json[:billable_metric][:rounding_precision]).to eq(create_params[:rounding_precision]) + expect(json[:billable_metric][:filters]).to eq([]) + end + + context "with weighted sum aggregation" do + let(:create_params) do + { + name: "BM1", + code: "BM1_code", + description: "description", + aggregation_type: "weighted_sum_agg", + field_name: "amount_sum", + recurring: true, + weighted_interval: "seconds" + } + end + + it "creates a billable_metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metric][:lago_id]).to be_present + expect(json[:billable_metric][:recurring]).to eq(create_params[:recurring]) + expect(json[:billable_metric][:aggregation_type]).to eq("weighted_sum_agg") + expect(json[:billable_metric][:weighted_interval]).to eq("seconds") + end + end + + context "with filters" do + let(:create_params) do + { + name: "BM1", + code: "BM1_code", + aggregation_type: "count_agg", + filters: [ + { + key: "key", + values: ["value1", "value2"] + } + ] + } + end + + it "creates a billable_metric with filters" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:billable_metric][:lago_id]).to be_present + expect(json[:billable_metric][:filters]).to eq([{key: "key", values: ["value1", "value2"]}]) + end + end + + context "with invalid input" do + let(:create_params) { "BL" } + + it "returns bad_request error" do + subject + expect(response).to have_http_status(:bad_request) + expect(json).to eq({status: 400, error: "BadRequest: param is missing or the value is empty or invalid: billable_metric"}) + end + end + end + + describe "PUT /api/v1/billable_metrics/:code" do + subject do + put_with_token( + organization, + "/api/v1/billable_metrics/#{billable_metric_code}", + {billable_metric: update_params} + ) + end + + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_code) { billable_metric.code } + let(:code) { "BM1_code" } + let(:update_params) do + { + name: "BM1", + code:, + description: "description", + aggregation_type: "sum_agg", + field_name: "amount_sum" + } + end + + include_examples "requires API permission", "billable_metric", "write" + + it "updates a billable_metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metric][:lago_id]).to eq(billable_metric.id) + expect(json[:billable_metric][:code]).to eq(update_params[:code]) + expect(json[:billable_metric][:filters]).to eq([]) + end + + context "when billable metric does not exist" do + let(:billable_metric_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when billable metric code already exists in organization scope (validation error)" do + let!(:another_metric) { create(:billable_metric, organization:) } + let(:code) { another_metric.code } + + it "returns unprocessable_entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "with weighted sum aggregation" do + let(:update_params) do + { + name: "BM1", + code: "BM1_code", + description: "description", + aggregation_type: "weighted_sum_agg", + field_name: "amount_sum", + recurring: true, + weighted_interval: "seconds" + } + end + + it "updates a billable_metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metric][:lago_id]).to be_present + expect(json[:billable_metric][:recurring]).to be_truthy + expect(json[:billable_metric][:aggregation_type]).to eq("weighted_sum_agg") + expect(json[:billable_metric][:weighted_interval]).to eq("seconds") + end + end + end + + describe "GET /api/v1/billable_metrics/:code" do + subject do + get_with_token(organization, "/api/v1/billable_metrics/#{billable_metric_code}") + end + + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_code) { billable_metric.code } + + include_examples "requires API permission", "billable_metric", "read" + + it "returns a billable metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metric][:lago_id]).to eq(billable_metric.id) + expect(json[:billable_metric][:code]).to eq(billable_metric.code) + end + + context "when billable metric does not exist" do + let(:billable_metric_code) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when billable metric is deleted" do + before { billable_metric.discard! } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1/billable_metrics/:code" do + subject do + delete_with_token(organization, "/api/v1/billable_metrics/#{billable_metric_code}") + end + + let!(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_code) { billable_metric.code } + + include_examples "requires API permission", "billable_metric", "write" + + it "deletes a billable_metric" do + expect { subject }.to change(BillableMetric, :count).by(-1) + end + + it "deletes alerts associated with billable_metric" do + create(:billable_metric_current_usage_amount_alert, billable_metric:, organization:) + expect { subject }.to change(organization.alerts, :count).by(-1) + end + + it "returns deleted billable_metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metric][:lago_id]).to eq(billable_metric.id) + expect(json[:billable_metric][:code]).to eq(billable_metric.code) + end + + context "when billable metric does not exist" do + let(:billable_metric_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/billable_metrics" do + subject { get_with_token(organization, "/api/v1/billable_metrics", params) } + + let!(:billable_metric) { create(:billable_metric, organization:) } + let(:params) { {} } + + include_examples "requires API permission", "billable_metric", "read" + + it "returns billable metrics" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metrics].count).to eq(1) + expect(json[:billable_metrics].first[:lago_id]).to eq(billable_metric.id) + expect(json[:billable_metrics].first[:code]).to eq(billable_metric.code) + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + before { create(:billable_metric, organization:) } + + it "returns billable metrics with correct meta data" do + subject + + expect(response).to have_http_status(:success) + expect(json[:billable_metrics].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end + + describe "POST /api/v1/billable_metrics/evaluate_expression" do + subject do + post_with_token( + organization, + "/api/v1/billable_metrics/evaluate_expression", + {expression:, event:} + ) + end + + let(:expression) { "round(event.properties.value)" } + let(:event) { {code: "bm_code", timestamp: Time.current.to_i, properties: {value: "2.4"}} } + + include_examples "requires API permission", "billable_metric", "write" + + context "with valid inputs" do + it "evaluates the expression" do + subject + + expect(response).to have_http_status(:success) + expect(json[:expression_result][:value]).to eq("2.0") + end + end + + context "with invalid inputs" do + let(:event) { {} } + let(:expression) { "" } + + it "returns unprocessable_entity error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:expression]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/requests/api/v1/billing_entities_controller_spec.rb b/spec/requests/api/v1/billing_entities_controller_spec.rb new file mode 100644 index 0000000..bea0d1c --- /dev/null +++ b/spec/requests/api/v1/billing_entities_controller_spec.rb @@ -0,0 +1,375 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::BillingEntitiesController do + let(:billing_entity1) { create(:billing_entity) } + let(:organization) { billing_entity1.organization } + let(:billing_entity2) { create(:billing_entity, organization:) } + let(:billing_entity3) { create(:billing_entity) } + let(:billing_entity4) { create(:billing_entity, :deleted, organization:) } + let(:billing_entity5) { create(:billing_entity, :archived, organization:) } + + describe "GET /api/v1/billing_entities" do + subject do + get_with_token(organization, "/api/v1/billing_entities") + end + + before do + billing_entity1 + billing_entity2 + billing_entity3 + billing_entity4 + billing_entity5 + end + + it "returns a list of active not archived billing entities" do + subject + expect(response).to be_successful + expect(json[:billing_entities].count).to eq(2) + expect(json[:billing_entities].map { |billing_entity| billing_entity[:lago_id] }).to include(billing_entity1.id, billing_entity2.id) + end + end + + describe "GET /api/v1/billing_entities/:code" do + subject do + get_with_token(organization, "/api/v1/billing_entities/#{billing_entity1.code}") + end + + it "returns a billing entity" do + subject + expect(response).to be_successful + expect(json[:billing_entity][:lago_id]).to eq(billing_entity1.id) + expect(json[:billing_entity]).to have_key :selected_invoice_custom_sections + end + + context "when the billing entity has applied taxes" do + let(:tax) { create(:tax) } + let(:applied_tax) { create(:billing_entity_applied_tax, billing_entity: billing_entity1, tax:) } + + before { applied_tax } + + it "returns the billing entity with the applied taxes" do + subject + expect(json[:billing_entity][:taxes].count).to eq(1) + end + end + + context "when the billing entity from another organization is requested" do + subject do + get_with_token(organization, "/api/v1/billing_entities/#{billing_entity3.code}") + end + + it "returns a 404" do + subject + expect(response).to be_not_found + end + end + + context "when the billing entity is archived" do + subject do + get_with_token(organization, "/api/v1/billing_entities/#{billing_entity5.code}") + end + + it "returns billing entity" do + subject + expect(response).to be_successful + expect(json[:billing_entity][:lago_id]).to eq(billing_entity5.id) + end + end + + context "when the billing entity is deleted" do + subject do + get_with_token(organization, "/api/v1/billing_entities/#{billing_entity4.code}") + end + + it "returns a 404" do + subject + expect(response).to be_not_found + end + end + end + + describe "POST /api/v1/billing_entities", :premium do + subject do + post_with_token(organization, "/api/v1/billing_entities", create_params) + end + + include_context "with mocked security logger" + + let(:organization) { create(:organization, premium_integrations: %w[multi_entities_enterprise]) } + + let(:create_params) do + { + billing_entity: { + code: billing_entity_code, + name: "New Name", + email: "new@email.com", + legal_name: "New Legal Name", + einvoicing: false, + legal_number: "1234567890", + tax_identification_number: "Tax-1234", + address_line1: "Calle de la Princesa 1", + address_line2: "Apt 1", + city: "Barcelona", + state: "Barcelona", + zipcode: "08001", + country: "ES", + default_currency: "EUR", + timezone: "Europe/Madrid", + document_numbering: "per_billing_entity", + document_number_prefix: "NEW-0001", + finalize_zero_amount_invoice: true, + net_payment_term: 10, + eu_tax_management: true, + logo:, + email_settings: ["invoice.finalized", "credit_note.created"], + billing_configuration: { + invoice_footer: "New Invoice Footer", + document_locale: "es", + invoice_grace_period: 10, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + } + } + } + end + + let(:billing_entity_code) { "NEW-0001" } + + let(:logo) do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + base64_logo = Base64.encode64(logo_file) + + "data:image/png;base64,#{base64_logo}" + end + + it "creates a billing entity" do + subject + expect(response).to be_successful + expect(json[:billing_entity][:lago_id]).to be_a(String) + expect(json[:billing_entity][:code]).to eq("NEW-0001") + expect(json[:billing_entity][:name]).to eq("New Name") + expect(json[:billing_entity][:email]).to eq("new@email.com") + expect(json[:billing_entity][:einvoicing]).to eq(false) + expect(json[:billing_entity][:legal_name]).to eq("New Legal Name") + expect(json[:billing_entity][:legal_number]).to eq("1234567890") + expect(json[:billing_entity][:tax_identification_number]).to eq("Tax-1234") + expect(json[:billing_entity][:address_line1]).to eq("Calle de la Princesa 1") + expect(json[:billing_entity][:address_line2]).to eq("Apt 1") + expect(json[:billing_entity][:city]).to eq("Barcelona") + expect(json[:billing_entity][:state]).to eq("Barcelona") + expect(json[:billing_entity][:zipcode]).to eq("08001") + expect(json[:billing_entity][:country]).to eq("ES") + expect(json[:billing_entity][:default_currency]).to eq("EUR") + expect(json[:billing_entity][:timezone]).to eq("Europe/Madrid") + expect(json[:billing_entity][:document_numbering]).to eq("per_billing_entity") + expect(json[:billing_entity][:document_number_prefix]).to eq("NEW-0001") + expect(json[:billing_entity][:finalize_zero_amount_invoice]).to eq(true) + expect(json[:billing_entity][:net_payment_term]).to eq(10) + expect(json[:billing_entity][:eu_tax_management]).to eq(true) + expect(json[:billing_entity][:email_settings]).to eq(["invoice.finalized", "credit_note.created"]) + expect(json[:billing_entity][:invoice_footer]).to eq("New Invoice Footer") + expect(json[:billing_entity][:document_locale]).to eq("es") + expect(json[:billing_entity][:invoice_grace_period]).to eq(10) + expect(json[:billing_entity][:subscription_invoice_issuing_date_anchor]).to eq("current_period_end") + expect(json[:billing_entity][:subscription_invoice_issuing_date_adjustment]).to eq("keep_anchor") + expect(json[:billing_entity][:logo_url]).to match(%r{.*/rails/active_storage/blobs/redirect/.*/logo}) + end + + it_behaves_like "produces a security log", "billing_entity.created" do + before { subject } + end + + context "when the logo is not provided" do + let(:logo) { nil } + + it "returns a 200" do + subject + expect(response).to be_successful + expect(json[:billing_entity][:logo_url]).to be_nil + end + end + + context "when the code is already taken" do + let(:billing_entity_code) { organization.default_billing_entity.code } + + it "returns a 422" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error]).to eq("Unprocessable Entity") + expect(json[:error_details]).to eq(code: ["value_already_exist"]) + end + end + + context "when the organization has no remaining billing entities" do + let(:organization) { create(:organization) } + + it "returns a 403" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + end + + describe "PUT /api/v1/billing_entities/:code", :premium do + subject do + put_with_token(organization, "/api/v1/billing_entities/#{billing_entity_code}", update_params) + end + + include_context "with mocked security logger" + + let(:billing_entity_code) { billing_entity1.code } + + let(:update_params) do + { + billing_entity: { + name: "New Name", + email: "new@email.com", + einvoicing: false, + legal_name: "New Legal Name", + legal_number: "1234567890", + tax_identification_number: "Tax-1234", + address_line1: "Calle de la Princesa 1", + address_line2: "Apt 1", + city: "Barcelona", + state: "Barcelona", + zipcode: "08001", + country: "ES", + default_currency: "EUR", + timezone: "Europe/Madrid", + document_numbering: "per_billing_entity", + document_number_prefix: "NEW-0001", + finalize_zero_amount_invoice: true, + net_payment_term: 10, + eu_tax_management: true, + logo: logo, + email_settings: ["invoice.finalized", "credit_note.created"], + billing_configuration: { + invoice_footer: "New Invoice Footer", + document_locale: "es", + invoice_grace_period: 10, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + } + } + } + end + + let(:logo) do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + base64_logo = Base64.encode64(logo_file) + + "data:image/png;base64,#{base64_logo}" + end + + it "updates the billing entity" do + subject + + expect(response).to be_successful + expect(billing_entity1.reload.name).to eq("New Name") + + expect(json[:billing_entity][:name]).to eq("New Name") + expect(json[:billing_entity][:email]).to eq("new@email.com") + expect(json[:billing_entity][:einvoicing]).to eq(false) + expect(json[:billing_entity][:legal_name]).to eq("New Legal Name") + expect(json[:billing_entity][:legal_number]).to eq("1234567890") + expect(json[:billing_entity][:tax_identification_number]).to eq("Tax-1234") + expect(json[:billing_entity][:address_line1]).to eq("Calle de la Princesa 1") + expect(json[:billing_entity][:address_line2]).to eq("Apt 1") + expect(json[:billing_entity][:city]).to eq("Barcelona") + expect(json[:billing_entity][:state]).to eq("Barcelona") + expect(json[:billing_entity][:zipcode]).to eq("08001") + expect(json[:billing_entity][:country]).to eq("ES") + expect(json[:billing_entity][:default_currency]).to eq("EUR") + expect(json[:billing_entity][:timezone]).to eq("Europe/Madrid") + expect(json[:billing_entity][:document_numbering]).to eq("per_billing_entity") + expect(json[:billing_entity][:document_number_prefix]).to eq("NEW-0001") + expect(json[:billing_entity][:finalize_zero_amount_invoice]).to eq(true) + expect(json[:billing_entity][:net_payment_term]).to eq(10) + expect(json[:billing_entity][:eu_tax_management]).to eq(true) + expect(json[:billing_entity][:email_settings]).to eq(["invoice.finalized", "credit_note.created"]) + expect(json[:billing_entity][:invoice_footer]).to eq("New Invoice Footer") + expect(json[:billing_entity][:document_locale]).to eq("es") + expect(json[:billing_entity][:invoice_grace_period]).to eq(10) + expect(json[:billing_entity][:subscription_invoice_issuing_date_anchor]).to eq("current_period_end") + expect(json[:billing_entity][:subscription_invoice_issuing_date_adjustment]).to eq("keep_anchor") + expect(json[:billing_entity][:logo_url]).to match(%r{.*/rails/active_storage/blobs/redirect/.*/logo}) + end + + it_behaves_like "produces a security log", "billing_entity.updated" do + before { subject } + end + + context "when updating the applicable invoice custom sections" do + let(:update_params) do + { + billing_entity: { + invoice_custom_section_codes: [custom_section.code] + } + } + end + + let(:custom_section) { create(:invoice_custom_section, organization:) } + + it "updates the applicable invoice custom sections" do + subject + + expect(response).to be_successful + expect(billing_entity1.reload.selected_invoice_custom_sections.count).to eq(1) + expect(billing_entity1.selected_invoice_custom_sections.first.code).to eq(custom_section.code) + expect(json[:billing_entity][:selected_invoice_custom_sections].count).to eq(1) + end + end + + context "when updating billing_entity taxes" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + let(:tax2) { create(:tax, organization:, code: "TAX_CODE_2") } + let(:update_params) do + { + billing_entity: { + tax_codes: [tax2.code] + } + } + end + + before do + create(:billing_entity_applied_tax, billing_entity: billing_entity1, tax: tax1) + end + + it "updates the taxes" do + subject + expect(billing_entity1.reload.taxes.count).to eq(1) + expect(billing_entity1.taxes.map(&:code)).to include("TAX_CODE_2") + end + + context "when the tax is not found" do + let(:update_params) do + { + billing_entity: { + tax_codes: ["NON_EXISTING_CODE"] + } + } + end + + it "returns a 404" do + subject + expect(response).to be_not_found + end + end + end + + context "when the billing entity is not found" do + let(:billing_entity_code) { "NON_EXISTING_CODE" } + + it "returns a 404" do + subject + expect(response).to be_not_found + end + end + end +end diff --git a/spec/requests/api/v1/coupons_controller_spec.rb b/spec/requests/api/v1/coupons_controller_spec.rb new file mode 100644 index 0000000..6280428 --- /dev/null +++ b/spec/requests/api/v1/coupons_controller_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::CouponsController do + let(:organization) { create(:organization) } + + describe "POST /api/v1/coupons" do + subject { post_with_token(organization, "/api/v1/coupons", {coupon: create_params}) } + + let(:billable_metric) { create(:billable_metric, organization:) } + let(:expiration_at) { Time.current + 15.days } + + let(:create_params) do + { + name: "coupon1", + code: "coupon1_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 123, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at:, + reusable: false, + applies_to: { + billable_metric_codes: [billable_metric.code] + } + } + end + + include_examples "requires API permission", "coupon", "write" + + it "creates a coupon" do + subject + + expect(response).to have_http_status(:success) + expect(json[:coupon][:lago_id]).to be_present + expect(json[:coupon][:code]).to eq(create_params[:code]) + expect(json[:coupon][:name]).to eq(create_params[:name]) + expect(json[:coupon][:created_at]).to be_present + expect(json[:coupon][:expiration_at]).to eq(expiration_at.iso8601) + expect(json[:coupon][:reusable]).to eq(false) + expect(json[:coupon][:limited_billable_metrics]).to eq(true) + expect(json[:coupon][:billable_metric_codes].first).to eq(billable_metric.code) + end + end + + describe "PUT /api/v1/coupons/:code" do + subject do + put_with_token(organization, "/api/v1/coupons/#{coupon_code}", {coupon: update_params}) + end + + let(:coupon) { create(:coupon, organization:) } + let(:code) { "coupon_code" } + let(:coupon_code) { coupon.code } + let(:expiration_at) { Time.current + 15.days } + let(:update_params) do + { + name: "coupon1", + code:, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 123, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: + } + end + + include_examples "requires API permission", "coupon", "write" + + it "updates a coupon" do + subject + + expect(response).to have_http_status(:success) + expect(json[:coupon][:lago_id]).to eq(coupon.id) + expect(json[:coupon][:code]).to eq(update_params[:code]) + expect(json[:coupon][:expiration_at]).to eq(expiration_at.iso8601) + end + + context "when coupon does not exist" do + let(:coupon_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when coupon code already exists in organization scope (validation error)" do + let!(:another_coupon) { create(:coupon, organization:) } + let(:code) { another_coupon.code } + + it "returns unprocessable_entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "GET /api/v1/coupons/:code" do + subject { get_with_token(organization, "/api/v1/coupons/#{coupon_code}") } + + let(:coupon) { create(:coupon, organization:) } + let(:coupon_code) { coupon.code } + + include_examples "requires API permission", "coupon", "read" + + it "returns a coupon" do + subject + + expect(response).to have_http_status(:success) + expect(json[:coupon][:lago_id]).to eq(coupon.id) + expect(json[:coupon][:code]).to eq(coupon.code) + end + + context "when coupon does not exist" do + let(:coupon_code) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1/coupons/:code" do + subject { delete_with_token(organization, "/api/v1/coupons/#{coupon_code}") } + + let!(:coupon) { create(:coupon, organization:) } + let(:coupon_code) { coupon.code } + + include_examples "requires API permission", "coupon", "write" + + it "deletes a coupon" do + expect { subject }.to change(Coupon, :count).by(-1) + end + + it "returns deleted coupon" do + subject + + expect(response).to have_http_status(:success) + expect(json[:coupon][:lago_id]).to eq(coupon.id) + expect(json[:coupon][:code]).to eq(coupon.code) + end + + context "when coupon does not exist" do + let(:coupon_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/coupons" do + subject { get_with_token(organization, "/api/v1/coupons", params) } + + let!(:coupon) { create(:coupon, organization:) } + let(:params) { {} } + + include_examples "requires API permission", "coupon", "read" + + it "returns coupons" do + subject + + expect(response).to have_http_status(:success) + expect(json[:coupons].count).to eq(1) + expect(json[:coupons].first[:lago_id]).to eq(coupon.id) + expect(json[:coupons].first[:code]).to eq(coupon.code) + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + before { create(:coupon, organization:) } + + it "returns coupons with correct meta data" do + subject + + expect(response).to have_http_status(:success) + expect(json[:coupons].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end +end diff --git a/spec/requests/api/v1/credit_notes/metadata_controller_spec.rb b/spec/requests/api/v1/credit_notes/metadata_controller_spec.rb new file mode 100644 index 0000000..2d21a5f --- /dev/null +++ b/spec/requests/api/v1/credit_notes/metadata_controller_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::CreditNotes::MetadataController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:credit_note) { create(:credit_note, customer:) } + + describe "POST /api/v1/credit_notes/:id/metadata" do + subject { post_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/metadata", {metadata: params}) } + + let(:credit_note_id) { credit_note.id } + let(:params) { {foo: "bar", baz: "qux"} } + + it_behaves_like "requires API permission", "credit_note", "write" + + context "when credit note is not found" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("credit_note") + end + end + + context "when credit note is draft" do + let(:credit_note) { create(:credit_note, :draft, customer:) } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("credit_note") + end + end + + context "when credit note has no metadata" do + it "creates metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(credit_note.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when credit note has existing metadata" do + before { create(:item_metadata, owner: credit_note, organization:, value: {old: "value", foo: "old"}) } + + it "replaces all metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(credit_note.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when params are empty" do + let(:params) { {} } + + before { create(:item_metadata, owner: credit_note, organization:, value: {old: "value"}) } + + it "replaces metadata with empty hash" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq({}) + expect(credit_note.reload.metadata.value).to eq({}) + end + end + + context "when params are empty and metadata does not exist" do + let(:params) { {} } + + it "creates metadata with empty hash" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq({}) + expect(credit_note.reload.metadata.value).to eq({}) + end + end + + context "when metadata param is not provided" do + subject { post_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/metadata", {}) } + + it "creates metadata with empty hash" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq({}) + expect(credit_note.reload.metadata.value).to eq({}) + end + end + end + + describe "PATCH /api/v1/credit_notes/:id/metadata" do + subject { patch_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/metadata", {metadata: params}) } + + let(:credit_note_id) { credit_note.id } + let(:params) { {foo: "bar", baz: "qux"} } + + it_behaves_like "requires API permission", "credit_note", "write" + + context "when credit note is not found" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("credit_note") + end + end + + context "when credit note has no metadata" do + it "creates metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(credit_note.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when credit note has existing metadata" do + before { create(:item_metadata, owner: credit_note, organization:, value: {"old" => "value", "foo" => "old"}) } + + it "merges metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(old: "value", foo: "bar", baz: "qux") + expect(credit_note.reload.metadata.value).to eq("old" => "value", "foo" => "bar", "baz" => "qux") + end + end + + context "when params are empty and metadata exists" do + let(:params) { {} } + + before { create(:item_metadata, owner: credit_note, organization:, value: {"old" => "value"}) } + + it "keeps existing metadata unchanged" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(old: "value") + expect(credit_note.reload.metadata.value).to eq("old" => "value") + end + end + + context "when params are empty and metadata does not exist" do + let(:params) { {} } + + it "does not create metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(credit_note.reload.metadata).to be_nil + end + end + + context "when metadata param is not provided" do + subject { patch_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/metadata", {}) } + + it "does not create metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(credit_note.reload.metadata).to be_nil + end + end + end + + describe "DELETE /api/v1/credit_notes/:id/metadata" do + subject { delete_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/metadata") } + + let(:credit_note_id) { credit_note.id } + + it_behaves_like "requires API permission", "credit_note", "write" + + context "when credit note is not found" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("credit_note") + end + end + + context "when credit note has metadata" do + before { create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar"}) } + + it "deletes all metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(credit_note.reload.metadata).to be_nil + end + end + + context "when credit note has no metadata" do + it "returns success with nil metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(credit_note.reload.metadata).to be_nil + end + end + end + + describe "DELETE /api/v1/credit_notes/:id/metadata/:key" do + subject { delete_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/metadata/#{key}") } + + let(:credit_note_id) { credit_note.id } + let(:key) { "foo" } + + it_behaves_like "requires API permission", "credit_note", "write" + + context "when credit note is not found" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("credit_note") + end + end + + context "when credit note has no metadata" do + it "returns not found error" do + subject + expect(response).to be_not_found_error("metadata") + end + end + + context "when key exists in metadata" do + before { create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar", "baz" => "qux"}) } + + it "deletes the key" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(baz: "qux") + expect(credit_note.reload.metadata.value).to eq("baz" => "qux") + end + end + + context "when key does not exist in metadata" do + before { create(:item_metadata, owner: credit_note, organization:, value: {"baz" => "qux"}) } + + it "returns success without changing metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(baz: "qux") + expect(credit_note.reload.metadata.value).to eq("baz" => "qux") + end + end + end +end diff --git a/spec/requests/api/v1/credit_notes_controller_spec.rb b/spec/requests/api/v1/credit_notes_controller_spec.rb new file mode 100644 index 0000000..1f7f4f0 --- /dev/null +++ b/spec/requests/api/v1/credit_notes_controller_spec.rb @@ -0,0 +1,765 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::CreditNotesController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, :with_tax_integration, organization:) } + let(:credit_note) { create(:credit_note, invoice:, customer:) } + let(:total_paid_amount_cents) { 120 } + let(:invoice) do + create( + :invoice, + organization:, + customer:, + payment_status: "succeeded", + currency: "EUR", + fees_amount_cents: 100, + taxes_amount_cents: 120, + total_amount_cents: 120, + total_paid_amount_cents: + ) + end + + describe "GET /api/v1/credit_notes/:id" do + subject { get_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}") } + + let(:credit_note_id) { credit_note.id } + let!(:credit_note_items) { create_list(:credit_note_item, 2, credit_note:) } + + include_examples "requires API permission", "credit_note", "read" + + it "returns a credit note" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:credit_note]).to include( + lago_id: credit_note.id, + sequential_id: credit_note.sequential_id, + number: credit_note.number, + lago_invoice_id: invoice.id, + invoice_number: invoice.number, + credit_status: credit_note.credit_status, + reason: credit_note.reason, + currency: credit_note.currency, + total_amount_cents: credit_note.total_amount_cents, + credit_amount_cents: credit_note.credit_amount_cents, + balance_amount_cents: credit_note.balance_amount_cents, + created_at: credit_note.created_at.iso8601, + updated_at: credit_note.updated_at.iso8601, + applied_taxes: [], + self_billed: invoice.self_billed + ) + + expect(json[:credit_note][:items].count).to eq(2) + + item = credit_note_items.first + expect(json[:credit_note][:items][0]).to include( + lago_id: item.id, + amount_cents: item.amount_cents, + amount_currency: item.amount_currency + ) + + expect(json[:credit_note][:items][0][:fee][:item]).to include( + type: item.fee.fee_type, + code: item.fee.item_code, + name: item.fee.item_name + ) + + expect(json[:credit_note][:customer][:lago_id]).to eq(customer.id) + expect(json[:credit_note][:customer][:integration_customers].count).to eq(1) + expect(json[:credit_note][:customer][:integration_customers].first[:lago_id]).to eq(customer.anrok_customer.id) + end + + context "when credit note does not exists" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when credit note is draft" do + let(:credit_note) { create(:credit_note, :draft) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when credit note belongs to another organization" do + let(:wrong_credit_note) { create(:credit_note) } + let(:credit_note_id) { wrong_credit_note.id } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "with metadata" do + before do + create( + :item_metadata, + owner: credit_note, + organization:, + value: {"foo" => "bar", "bar" => "", "baz" => nil, "" => "qux"} + ) + end + + it "returns metadata" do + subject + expect(json[:credit_note][:metadata]).to eq(foo: "bar", bar: "", baz: nil, "": "qux") + end + end + + context "without metadata" do + it "returns nil" do + subject + expect(json[:credit_note][:metadata]).to be_nil + end + end + + context "with empty metadata" do + before do + create(:item_metadata, owner: credit_note, organization:, value: {}) + end + + it "returns empty hash" do + subject + expect(json[:credit_note][:metadata]).to eq({}) + end + end + end + + describe "PUT /api/v1/credit_notes/:id" do + subject do + put_with_token( + organization, + "/api/v1/credit_notes/#{credit_note_id}", + credit_note: update_params + ) + end + + let(:credit_note_id) { credit_note.id } + let(:update_params) { {refund_status: "succeeded"} } + + include_examples "requires API permission", "credit_note", "write" + + context "when credit not exists" do + it "updates the credit note" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:lago_id]).to eq(credit_note.id) + expect(json[:credit_note][:refund_status]).to eq("succeeded") + expect(json[:credit_note][:customer][:lago_id]).to eq(customer.id) + end + end + + context "when credit note does not exist" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when provided refund status is invalid" do + let(:update_params) { {refund_status: "invalid_status"} } + + it "returns an unprocessable entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "with metadata" do + before do + create(:item_metadata, owner: credit_note, organization:, value: {"existing" => "value"}) + end + + context "when adding new keys" do + let(:update_params) { {metadata: {new: "data"}} } + + it "merges metadata (not replaces)" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:metadata]).to eq(existing: "value", new: "data") + end + end + + context "when updating existing keys" do + let(:update_params) { {metadata: {existing: "updated"}} } + + it "updates the key value" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:metadata]).to eq(existing: "updated") + end + end + + context "with empty metadata" do + let(:update_params) { {metadata: {}} } + + it "keeps existing metadata unchanged" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:metadata]).to eq(existing: "value") + end + end + end + + context "without existing metadata" do + let(:update_params) { {metadata: {foo: "bar", baz: "qux"}} } + + it "creates new metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:metadata]).to eq(foo: "bar", baz: "qux") + end + end + end + + describe "POST /api/v1/credit_notes/:id/download_pdf" do + subject do + post_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/download_pdf") + end + + let(:credit_note_id) { credit_note.id } + + include_examples "requires API permission", "credit_note", "write" + + it "enqueues a job to generate the PDF" do + subject + + expect(response).to have_http_status(:success) + expect(CreditNotes::GeneratePdfJob).to have_been_enqueued + end + + context "when a file is attached to the credit note" do + let(:credit_note) { create(:credit_note, :with_file, invoice:, customer:) } + + it "returns the credit note object" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note]).to be_present + end + end + + context "when credit note does not exist" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when credit note is draft" do + let(:credit_note) { create(:credit_note, :draft) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when credit note belongs to another organization" do + let(:wrong_credit_note) { create(:credit_note) } + let(:credit_note_id) { wrong_credit_note.id } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "POST /api/v1/credit_notes/:id/download_xml" do + subject do + post_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/download_xml") + end + + let(:credit_note_id) { credit_note.id } + + include_examples "requires API permission", "credit_note", "write" + + it "enqueues a job to generate the PDF" do + subject + + expect(response).to have_http_status(:success) + expect(CreditNotes::GenerateXmlJob).to have_been_enqueued + end + + context "when a file is attached to the credit note" do + let(:credit_note) { create(:credit_note, :with_file, invoice:, customer:) } + + it "returns the credit note object" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note]).to be_present + end + end + + context "when credit note does not exist" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when credit note is draft" do + let(:credit_note) { create(:credit_note, :draft) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when credit note belongs to another organization" do + let(:wrong_credit_note) { create(:credit_note) } + let(:credit_note_id) { wrong_credit_note.id } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/credit_notes" do + it_behaves_like "a credit note index endpoint" do + subject { get_with_token(organization, "/api/v1/credit_notes", params) } + + context "with external_customer_id filter" do + let(:params) { {external_customer_id: customer.external_id} } + let!(:credit_note) { create(:credit_note, customer:) } + + before do + another_customer = create(:customer, organization:) + create(:credit_note, customer: another_customer) + end + + it "returns credit notes of the customer" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to contain_exactly credit_note.id + end + + it "returns nested customer data" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].first[:customer][:lago_id]).to eq(customer.id) + + expect(json[:credit_notes].first[:customer][:billing_configuration].keys).to eq(%i[ + invoice_grace_period + payment_provider + payment_provider_code + document_locale + subscription_invoice_issuing_date_anchor + subscription_invoice_issuing_date_adjustment + ]) + end + end + end + end + + describe "POST /api/v1/credit_notes", :premium do + subject do + post_with_token(organization, "/api/v1/credit_notes", {credit_note: create_params}) + end + + let(:fee1) { create(:fee, invoice:) } + let(:fee2) { create(:charge_fee, invoice:) } + let(:invoice_id) { invoice.id } + + let(:create_params) do + { + invoice_id:, + reason: "duplicated_charge", + description: "Duplicated charge", + credit_amount_cents: 10, + refund_amount_cents: 5, + items: [ + { + fee_id: fee1.id, + amount_cents: 10 + }, + { + fee_id: fee2.id, + amount_cents: 5 + } + ] + } + end + + include_examples "requires API permission", "credit_note", "write" + + it "creates a credit note" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note]).to include( + credit_status: "available", + refund_status: "pending", + reason: "duplicated_charge", + description: "Duplicated charge", + currency: "EUR", + total_amount_cents: 15, + credit_amount_cents: 10, + balance_amount_cents: 10, + refund_amount_cents: 5, + applied_taxes: [] + ) + + expect(json[:credit_note][:customer][:lago_id]).to eq(customer.id) + expect(json[:credit_note][:items][0][:lago_id]).to be_present + expect(json[:credit_note][:items][0][:amount_cents]).to eq(10) + expect(json[:credit_note][:items][0][:amount_currency]).to eq("EUR") + expect(json[:credit_note][:items][0][:fee][:lago_id]).to eq(fee1.id) + + expect(json[:credit_note][:items][1][:lago_id]).to be_present + expect(json[:credit_note][:items][1][:amount_cents]).to eq(5) + expect(json[:credit_note][:items][1][:amount_currency]).to eq("EUR") + expect(json[:credit_note][:items][1][:fee][:lago_id]).to eq(fee2.id) + end + + context "when invoice is not found" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when total amount is zero" do + let(:create_params) do + { + invoice_id:, + reason: "duplicated_charge", + description: "Duplicated charge", + credit_amount_cents: 0, + refund_amount_cents: 0, + items: [ + { + fee_id: fee1.id, + amount_cents: 0 + }, + { + fee_id: fee2.id, + amount_cents: 0 + } + ] + } + end + + it "returns validation error" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:base]).to eq(["total_amount_must_be_positive"]) + end + end + + context "with metadata" do + let(:create_params) do + { + invoice_id:, + reason: "duplicated_charge", + description: "Duplicated charge", + credit_amount_cents: 10, + refund_amount_cents: 5, + items: [{fee_id: fee1.id, amount_cents: 10}, {fee_id: fee2.id, amount_cents: 5}], + metadata: {foo: "bar", baz: "qux"} + } + end + + it "creates credit note with metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:metadata]).to eq(foo: "bar", baz: "qux") + end + end + + context "with empty metadata" do + let(:create_params) do + { + invoice_id:, + reason: "duplicated_charge", + description: "Duplicated charge", + credit_amount_cents: 10, + refund_amount_cents: 5, + items: [{fee_id: fee1.id, amount_cents: 10}, {fee_id: fee2.id, amount_cents: 5}], + metadata: {} + } + end + + it "creates credit note with empty metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:metadata]).to eq({}) + end + end + + context "with offset_amount_cents" do + let(:total_paid_amount_cents) { 100 } # Leave 20 cents unpaid for offset + + let(:create_params) do + { + invoice_id:, + reason: "duplicated_charge", + description: "Duplicated charge", + credit_amount_cents: 10, + refund_amount_cents: 5, + offset_amount_cents: 8, + items: [ + {fee_id: fee1.id, amount_cents: 15}, + {fee_id: fee2.id, amount_cents: 8} + ] + } + end + + it "creates credit note with offset amount" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:offset_amount_cents]).to eq(8) + expect(json[:credit_note][:credit_amount_cents]).to eq(10) + expect(json[:credit_note][:refund_amount_cents]).to eq(5) + expect(json[:credit_note][:total_amount_cents]).to eq(23) # 10 + 5 + 8 + end + + it "creates an invoice settlement for the offset amount" do + expect { subject }.to change(InvoiceSettlement, :count).by(1) + + invoice_settlement = InvoiceSettlement.last + expect(invoice_settlement.target_invoice_id).to eq(invoice.id) + expect(invoice_settlement.amount_cents).to eq(8) + expect(invoice_settlement.settlement_type).to eq("credit_note") + end + end + + context "with only offset_amount_cents (no credit or refund)" do + let(:total_paid_amount_cents) { 100 } # Leave 20 cents unpaid for offset + + let(:create_params) do + { + invoice_id:, + reason: "duplicated_charge", + description: "Duplicated charge", + credit_amount_cents: 0, + refund_amount_cents: 0, + offset_amount_cents: 15, + items: [{fee_id: fee1.id, amount_cents: 15}] + } + end + + it "creates credit note with only offset amount" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:offset_amount_cents]).to eq(15) + expect(json[:credit_note][:credit_amount_cents]).to eq(0) + expect(json[:credit_note][:refund_amount_cents]).to eq(0) + expect(json[:credit_note][:total_amount_cents]).to eq(15) + end + end + + context "with credit invoices" do + let(:wallet) { create(:wallet, customer:, balance_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice: credit_invoice, organization:) } + let(:credit_fee) { create(:credit_fee, invoice: credit_invoice, wallet_transaction:, organization:, amount_cents: 100, taxes_amount_cents: 0) } + + context "when payment is pending" do + let(:credit_invoice) do + create(:invoice, organization:, customer:, invoice_type: :credit, payment_status: "pending", currency: "EUR", total_amount_cents: 100, fees_amount_cents: 100) + end + + context "with offset_amount_cents" do + let(:create_params) do + { + invoice_id: credit_invoice.id, + reason: "other", + offset_amount_cents: 100, + items: [{fee_id: credit_fee.id, amount_cents: 100}] + } + end + + it "creates credit note successfully" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:offset_amount_cents]).to eq(100) + end + end + + context "with credit_amount_cents" do + let(:create_params) do + { + invoice_id: credit_invoice.id, + reason: "other", + credit_amount_cents: 50, + items: [{fee_id: credit_fee.id, amount_cents: 50}] + } + end + + it "returns an error" do + subject + + expect(response).to have_http_status(:method_not_allowed) + end + end + end + + context "when payment failed" do + let(:credit_invoice) do + create(:invoice, organization:, customer:, + invoice_type: :credit, + payment_status: "failed", + currency: "EUR", + total_amount_cents: 100, + fees_amount_cents: 100) + end + + context "with offset_amount_cents" do + let(:create_params) do + { + invoice_id: credit_invoice.id, + reason: "other", + offset_amount_cents: 100, + items: [{fee_id: credit_fee.id, amount_cents: 100}] + } + end + + it "creates credit note successfully" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:offset_amount_cents]).to eq(100) + end + end + + context "with credit_amount_cents" do + let(:create_params) do + { + invoice_id: credit_invoice.id, + reason: "other", + credit_amount_cents: 50, + items: [{fee_id: credit_fee.id, amount_cents: 50}] + } + end + + it "returns an error" do + subject + + expect(response).to have_http_status(:method_not_allowed) + end + end + end + end + end + + describe "PUT /api/v1/credit_notes/:id/void" do + subject { put_with_token(organization, "/api/v1/credit_notes/#{credit_note_id}/void") } + + let(:credit_note_id) { credit_note.id } + + include_examples "requires API permission", "credit_note", "write" + + it "voids the credit note" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_note][:lago_id]).to eq(credit_note.id) + expect(json[:credit_note][:credit_status]).to eq("voided") + expect(json[:credit_note][:balance_amount_cents]).to eq(0) + expect(json[:credit_note][:customer][:lago_id]).to eq(customer.id) + end + + context "when credit note does not exist" do + let(:credit_note_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when credit note is not voidable" do + before { credit_note.update!(credit_amount_cents: 0, credit_status: :voided) } + + it "returns an unprocessable entity error" do + subject + expect(response).to have_http_status(:method_not_allowed) + end + end + end + + describe "POST /api/v1/credit_notes/estimate", :premium do + subject do + post_with_token( + organization, + "/api/v1/credit_notes/estimate", + {credit_note: estimate_params} + ) + end + + let(:fees) { create_list(:fee, 2, invoice:, amount_cents: 100) } + let(:invoice_id) { invoice.id } + let(:total_paid_amount_cents) { 0 } + + let(:estimate_params) do + { + invoice_id:, + items: fees.map { |f| {fee_id: f.id, amount_cents: 50} } + } + end + + include_examples "requires API permission", "credit_note", "write" + + it "returns the computed amounts for credit note creation" do + subject + + expect(response).to have_http_status(:success) + + estimated_credit_note = json[:estimated_credit_note] + expect(estimated_credit_note[:lago_invoice_id]).to eq(invoice.id) + expect(estimated_credit_note[:invoice_number]).to eq(invoice.number) + expect(estimated_credit_note[:currency]).to eq("EUR") + expect(estimated_credit_note[:taxes_amount_cents]).to eq(0) + expect(estimated_credit_note[:sub_total_excluding_taxes_amount_cents]).to eq(100) + expect(estimated_credit_note[:max_creditable_amount_cents]).to eq(100) + expect(estimated_credit_note[:max_refundable_amount_cents]).to eq(0) + expect(estimated_credit_note[:coupons_adjustment_amount_cents]).to eq(0) + expect(estimated_credit_note[:items].first[:amount_cents]).to eq(50) + expect(estimated_credit_note[:applied_taxes]).to be_blank + end + + context "with invalid invoice" do + let(:invoice) { create(:invoice) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/customers/applied_coupons_controller_spec.rb b/spec/requests/api/v1/customers/applied_coupons_controller_spec.rb new file mode 100644 index 0000000..4c859ba --- /dev/null +++ b/spec/requests/api/v1/customers/applied_coupons_controller_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::AppliedCouponsController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:external_id) { customer.external_id } + + describe "GET /api/v1/customers/:external_id/applied_coupons" do + include_examples "a applied coupon index endpoint" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}/applied_coupons", params) } + + let(:external_id) { customer.external_id } + + context "when customer external_id is unknown" do + let(:external_id) { "unknown" } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "when customer belongs to another organization" do + let(:customer) { create(:customer) } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + end + + describe "DELETE /api/v1/customers/:customer_external_id/applied_coupons/:id" do + subject do + delete_with_token( + organization, + "/api/v1/customers/#{external_id}/applied_coupons/#{identifier}" + ) + end + + let!(:applied_coupon) { create(:applied_coupon, customer:) } + let(:external_id) { customer.external_id } + let(:identifier) { applied_coupon.id } + + include_examples "requires API permission", "applied_coupon", "write" + + it "terminates the applied coupon" do + expect { subject } + .to change { applied_coupon.reload.status }.from("active").to("terminated") + end + + it "returns the applied_coupon" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupon][:lago_id]).to eq(applied_coupon.id) + end + + context "when customer does not exist" do + let(:external_id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when applied coupon does not exist" do + let(:identifier) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when coupon is not applied to customer" do + let(:other_applied_coupon) { create(:applied_coupon) } + let(:identifier) { other_applied_coupon.id } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/customers/credit_notes_controller_spec.rb b/spec/requests/api/v1/customers/credit_notes_controller_spec.rb new file mode 100644 index 0000000..ce70646 --- /dev/null +++ b/spec/requests/api/v1/customers/credit_notes_controller_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::CreditNotesController do + describe "GET /api/v1/customers/:external_id/credit_notes" do + it_behaves_like "a credit note index endpoint" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}/credit_notes", params) } + + let(:external_id) { customer.external_id } + + context "with invalid customer id" do + let(:external_id) { SecureRandom.uuid } + + it "returns an error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "when customer does not belongs to the organization" do + let(:customer) { create(:customer) } + + it "returns an error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + end + end +end diff --git a/spec/requests/api/v1/customers/invoices_controller_spec.rb b/spec/requests/api/v1/customers/invoices_controller_spec.rb new file mode 100644 index 0000000..e494250 --- /dev/null +++ b/spec/requests/api/v1/customers/invoices_controller_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::InvoicesController do + describe "GET /api/v1/customers/:external_id/invoices" do + it_behaves_like "an invoice index endpoint" do + subject { get_with_token(organization, "/api/v1/customers/#{customer.external_id}/invoices", params) } + + context "with invalid customer id" do + subject { get_with_token(organization, "/api/v1/customers/foo/invoices", {}) } + + it "returns a 404" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + end + end +end diff --git a/spec/requests/api/v1/customers/payment_methods_controller_spec.rb b/spec/requests/api/v1/customers/payment_methods_controller_spec.rb new file mode 100644 index 0000000..f4b225a --- /dev/null +++ b/spec/requests/api/v1/customers/payment_methods_controller_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::PaymentMethodsController do + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:external_id) { customer.external_id } + let(:payment_method) { create(:payment_method, customer:, organization:) } + + describe "GET /api/v1/customers/:external_id/payment_methods" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}/payment_methods", {}) } + + let(:second_payment_method) { create(:payment_method, organization:, customer:, is_default: false) } + + include_examples "requires API permission", "payment_method", "read" + + context "with unknown customer" do + let(:external_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "with customer from another organization" do + let(:customer) { create(:customer) } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "with payment methods" do + before do + payment_method + second_payment_method + end + + it "returns customer's payment methods" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_methods].count).to eq(2) + expect(json[:payment_methods].map { |r| r[:lago_id] }).to contain_exactly( + payment_method.id, + second_payment_method.id + ) + end + end + end + + describe "PUT /api/v1/customers/:external_id/payment_methods/:id/set_as_default" do + subject { put_with_token(organization, "/api/v1/customers/#{external_id}/payment_methods/#{payment_method.id}/set_as_default") } + + include_examples "requires API permission", "payment_method", "write" + + context "with unknown customer" do + let(:external_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "with unknown payment method" do + subject { put_with_token(organization, "/api/v1/customers/#{external_id}/payment_methods/invalid/set_as_default") } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("payment_method_not_found") + end + end + + context "when payment method is already default" do + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: true) } + let(:payment_method2) { create(:payment_method, customer:, organization:, is_default: false) } + let(:payment_method3) { create(:payment_method, customer:, organization:, is_default: false) } + + before do + payment_method + payment_method2 + payment_method3 + end + + it "returns valid payment method" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_method][:lago_id]).to eq(payment_method.id) + expect(json[:payment_method][:is_default]).to eq(true) + expect(payment_method.reload.is_default).to eq(true) + expect(payment_method2.reload.is_default).to eq(false) + expect(payment_method3.reload.is_default).to eq(false) + end + end + + context "when payment method is not default" do + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: false) } + let(:payment_method2) { create(:payment_method, customer:, organization:, is_default: true) } + let(:payment_method3) { create(:payment_method, customer:, organization:, is_default: false) } + + before do + payment_method + payment_method2 + payment_method3 + end + + it "sets payment method to default and returns valid payment method" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_method][:lago_id]).to eq(payment_method.id) + expect(json[:payment_method][:is_default]).to eq(true) + expect(payment_method.reload.is_default).to eq(true) + expect(payment_method2.reload.is_default).to eq(false) + expect(payment_method3.reload.is_default).to eq(false) + end + end + end + + describe "DELETE /api/v1/customers/:external_id/payment_methods/:id" do + subject do + delete_with_token(organization, "/api/v1/customers/#{external_id}/payment_methods/#{payment_method.id}") + end + + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: true) } + + include_examples "requires API permission", "payment_method", "write" + + context "with unknown customer" do + let(:external_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "with unknown payment method" do + subject do + delete_with_token(organization, "/api/v1/customers/#{external_id}/payment_methods/invalid") + end + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("payment_method_not_found") + end + end + + context "with payment method" do + it "sets payment method as NOT default" do + expect { subject } + .to change { payment_method.reload.is_default } + .from(true) + .to(false) + end + + it "soft deletes the payment method" do + freeze_time do + expect { subject }.to change(PaymentMethod, :count).by(-1) + .and change { payment_method.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "returns deleted payment method" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_method][:lago_id]).to eq(payment_method.id) + end + end + end +end diff --git a/spec/requests/api/v1/customers/payment_requests_controller_spec.rb b/spec/requests/api/v1/customers/payment_requests_controller_spec.rb new file mode 100644 index 0000000..2246274 --- /dev/null +++ b/spec/requests/api/v1/customers/payment_requests_controller_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::PaymentRequestsController do + describe "GET /api/v1/customers/:external_id/payment_requests" do + include_examples "a payment request index endpoint" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}/payment_requests", params) } + + let(:external_id) { customer.external_id } + + context "with unknown customer" do + let(:external_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "with customer from another organization" do + let(:customer) { create(:customer) } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + end + end +end diff --git a/spec/requests/api/v1/customers/payments_controller_spec.rb b/spec/requests/api/v1/customers/payments_controller_spec.rb new file mode 100644 index 0000000..455e2ea --- /dev/null +++ b/spec/requests/api/v1/customers/payments_controller_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::PaymentsController do + describe "GET /api/v1/customers/:external_id/payments" do + it_behaves_like "a payment index endpoint" do + subject { get_with_token(organization, "/api/v1/customers/#{customer.external_id}/payments", params) } + + context "with invalid customer id" do + subject { get_with_token(organization, "/api/v1/customers/foo/payments", {}) } + + it "returns a 404" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "with an invoice belonging to a different customer" do + let(:params) { {invoice_id: invoice.id} } + let(:invoice) { create(:invoice, organization:) } + + before do + create(:payment, payable: invoice) + end + + it "returns an empty result" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payments]).to be_empty + end + end + end + end +end diff --git a/spec/requests/api/v1/customers/projected_usage_controller_spec.rb b/spec/requests/api/v1/customers/projected_usage_controller_spec.rb new file mode 100644 index 0000000..02a3441 --- /dev/null +++ b/spec/requests/api/v1/customers/projected_usage_controller_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::ProjectedUsageController, :premium do + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization, :premium) } + + let(:plan) { create(:plan, interval: "monthly") } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + started_at: Time.zone.now - 2.years + ) + end + + describe "GET /customers/:customer_id/projected_usage" do + subject do + get_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/projected_usage", + params + ) + end + + let(:params) { {external_subscription_id: subscription.external_id} } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:metric) { create(:billable_metric, aggregation_type: "count_agg") } + + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric: metric, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + + before do + subscription + charge + tax + + travel_to(Time.parse("2025-07-02T10:00:00Z")) do + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: Time.zone.now + ) + end + end + + include_examples "requires API permission", "customer_usage", "read" + + describe "premium permissions" do + before do + organization.update!(premium_integrations:) + subject + end + + context "when organization has 'projected_usage' premium integration" do + let(:premium_integrations) { organization.premium_integrations.including("projected_usage") } + + context "when organization has 'projected_usage' premium integration" do + it "does not return 403 Forbidden" do + expect(response).not_to have_http_status(:forbidden) + end + end + end + + context "when organization does not have 'projected_usage' premium integration" do + let(:premium_integrations) { organization.premium_integrations.excluding("projected_usage") } + + context "when organization does not have 'projected_usage' premium integration" do + it "does not return 403 Forbidden" do + expect(response).to have_http_status(:forbidden) + expect(json).to match hash_including(code: "projected_usage_not_enabled") + end + end + end + end + + it "returns the projected usage for the customer" do + travel_to(Time.parse("2025-07-03T10:00:00Z")) do + subject + + expect(response).to have_http_status(:success) + + expect(json[:customer_projected_usage][:from_datetime]).to eq(Time.zone.today.beginning_of_month.beginning_of_day.iso8601) + expect(json[:customer_projected_usage][:to_datetime]).to eq(Time.zone.today.end_of_month.end_of_day.iso8601) + expect(json[:customer_projected_usage][:issuing_date]).to eq(Time.zone.today.end_of_month.iso8601) + expect(json[:customer_projected_usage][:amount_cents]).to eq(5) + expect(json[:customer_projected_usage][:currency]).to eq("EUR") + expect(json[:customer_projected_usage][:total_amount_cents]).to eq(6) + expect(json[:customer_projected_usage][:projected_amount_cents]).to be_present + + charge_usage = json[:customer_projected_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:name]).to eq(metric.name) + expect(charge_usage[:billable_metric][:code]).to eq(metric.code) + expect(charge_usage[:billable_metric][:aggregation_type]).to eq("count_agg") + expect(charge_usage[:charge][:charge_model]).to eq("graduated") + expect(charge_usage[:units]).to eq("4.0") + expect(charge_usage[:amount_cents]).to eq(5) + expect(charge_usage[:amount_currency]).to eq("EUR") + expect(charge_usage[:projected_units]).to be_present + expect(charge_usage[:projected_amount_cents]).to be_present + end + end + + context "when apply_taxes is false" do + let(:params) { {external_subscription_id: subscription.external_id, apply_taxes: false} } + + it "returns the projected usage for the customer without applying taxes" do + travel_to(Time.parse("2025-07-03T10:00:00Z")) do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_projected_usage][:amount_cents]).to eq(5) + expect(json[:customer_projected_usage][:taxes_amount_cents]).to eq(0) + expect(json[:customer_projected_usage][:total_amount_cents]).to eq(5) + expect(json[:customer_projected_usage][:projected_amount_cents]).to be_present + end + end + end + + context "when apply_taxes is true" do + let(:params) { {external_subscription_id: subscription.external_id, apply_taxes: true} } + + context "with a anrok provider" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:double_checker) { instance_double(Throttling::Base) } + + before { + integration_customer + allow(Throttling).to receive(:for).with(:anrok).and_return(double_checker) + allow(double_checker).to receive(:check).and_return(false) + } + + it "rescue from provider throttles" do + subject + expect(response).to have_http_status(:too_many_requests) + expect(response.body).to match(/anrok.*Try again later/) + end + end + end + + context "with filters" do + let(:filter_metric) { create(:billable_metric, aggregation_type: "count_agg", organization:) } + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric: filter_metric, key: "cloud", values: %w[aws google]) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: filter_metric, + properties: {amount: "0"} + ) + end + + let(:charge_filter_aws) { create(:charge_filter, charge:, properties: {amount: "10"}) } + let(:charge_filter_gcp) { create(:charge_filter, charge:, properties: {amount: "20"}) } + + let(:charge_filter_value_aws) do + create(:charge_filter_value, charge_filter: charge_filter_aws, billable_metric_filter:, values: ["aws"]) + end + + let(:charge_filter_value_gcp) do + create(:charge_filter_value, charge_filter: charge_filter_gcp, billable_metric_filter:, values: ["google"]) + end + + before do + subscription + charge + tax + charge_filter_value_aws + charge_filter_value_gcp + + travel_to(Time.parse("2025-07-02T10:00:00Z")) do + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "aws"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "google"} + ) + end + end + + it "returns the projected filters usage for the customer" do + travel_to(Time.parse("2025-07-03T10:00:00Z")) do + subject + + charge_usage = json[:customer_projected_usage][:charges_usage].first + filters_usage = charge_usage[:filters] + + aws_filter_data = filters_usage.find { |f| f[:values] && f[:values][:cloud] == ["aws"] } + gcp_filter_data = filters_usage.find { |f| f[:values] && f[:values][:cloud] == ["google"] } + + expect(charge_usage[:units]).to eq("4.0") + expect(charge_usage[:amount_cents]).to eq(5000) + expect(charge_usage[:projected_units]).to be_present + expect(charge_usage[:projected_amount_cents]).to be_present + + # Assertions for the AWS filter + expect(aws_filter_data[:units]).to eq("3.0") + expect(aws_filter_data[:amount_cents]).to eq(3000) + expect(aws_filter_data[:projected_units]).to eq("31.0") + expect(aws_filter_data[:projected_amount_cents]).to eq(31000) + + # Assertions for the GCP filter + expect(gcp_filter_data[:units]).to eq("1.0") + expect(gcp_filter_data[:amount_cents]).to eq(2000) + expect(gcp_filter_data[:projected_units]).to eq("10.33") + expect(gcp_filter_data[:projected_amount_cents]).to eq(20660) + end + end + end + + context "when customer does not belongs to the organization" do + let(:customer) { create(:customer) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/customers/subscriptions_controller_spec.rb b/spec/requests/api/v1/customers/subscriptions_controller_spec.rb new file mode 100644 index 0000000..87dfeec --- /dev/null +++ b/spec/requests/api/v1/customers/subscriptions_controller_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::SubscriptionsController do + describe "GET /api/v1/customers/:external_id/subscriptions" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}/subscriptions", params) } + + it_behaves_like "a subscription index endpoint" do + let(:external_id) { customer.external_id } + let(:subscription_2) { create(:subscription, customer: customer_2, organization:, plan:) } + let(:customer_2) { create(:customer, organization:) } + + before do + subscription_2 + end + + context "with unknown customer id" do + let(:external_id) { "unknown_customer_id" } + + it "returns an error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + + context "when customer does not belongs to the organization" do + let(:customer) { create(:customer) } + + it "returns an error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("customer_not_found") + end + end + end + end +end diff --git a/spec/requests/api/v1/customers/usage_controller_spec.rb b/spec/requests/api/v1/customers/usage_controller_spec.rb new file mode 100644 index 0000000..55d8cca --- /dev/null +++ b/spec/requests/api/v1/customers/usage_controller_spec.rb @@ -0,0 +1,1001 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::UsageController do + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + let(:plan) { create(:plan, interval: "monthly") } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + started_at: Time.zone.now - 2.years + ) + end + + describe "GET /customers/:customer_id/current_usage" do + subject do + get_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/current_usage", + params + ) + end + + let(:params) { {external_subscription_id: subscription.external_id} } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:metric) { create(:billable_metric, aggregation_type: "count_agg") } + + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric: metric, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + + before do + subscription + charge + tax + + create_list( + :event, + 4, + organization:, + customer:, + subscription:, + code: metric.code, + timestamp: Time.zone.now + ) + end + + include_examples "requires API permission", "customer_usage", "read" + + it "returns the usage for the customer" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:customer_usage][:from_datetime]).to eq(Time.zone.today.beginning_of_month.beginning_of_day.iso8601) + expect(json[:customer_usage][:to_datetime]).to eq(Time.zone.today.end_of_month.end_of_day.iso8601) + expect(json[:customer_usage][:issuing_date]).to eq(Time.zone.today.end_of_month.iso8601) + expect(json[:customer_usage][:amount_cents]).to eq(5) + expect(json[:customer_usage][:currency]).to eq("EUR") + expect(json[:customer_usage][:total_amount_cents]).to eq(6) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:name]).to eq(metric.name) + expect(charge_usage[:billable_metric][:code]).to eq(metric.code) + expect(charge_usage[:billable_metric][:aggregation_type]).to eq("count_agg") + expect(charge_usage[:charge][:charge_model]).to eq("graduated") + expect(charge_usage[:units]).to eq("4.0") + expect(charge_usage[:amount_cents]).to eq(5) + expect(charge_usage[:amount_currency]).to eq("EUR") + end + + context "when apply_taxes is false" do + let(:params) { {external_subscription_id: subscription.external_id, apply_taxes: false} } + + it "returns the usage for the customer without applying taxes" do + subject + + expect(response).to have_http_status(:success) + # With taxes disabled, fees_amount_cents remains 5 and no tax is added. + expect(json[:customer_usage][:amount_cents]).to eq(5) + expect(json[:customer_usage][:taxes_amount_cents]).to eq(0) + expect(json[:customer_usage][:total_amount_cents]).to eq(5) + end + end + + context "when apply_taxes is true" do + let(:params) { {external_subscription_id: subscription.external_id, apply_taxes: true} } + + context "with a anrok provider" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:double_checker) { instance_double(Throttling::Base) } + + before { + integration_customer + allow(Throttling).to receive(:for).with(:anrok).and_return(double_checker) + allow(double_checker).to receive(:check).and_return(false) + } + + it "rescue from provider throttles" do + subject + expect(response).to have_http_status(:too_many_requests) + expect(response.body).to match(/anrok.*Try again later/) + end + end + end + + context "with charge and group filtering" do + let(:metric_a) { create(:sum_billable_metric, organization:, field_name: "units") } + let(:metric_b) { create(:sum_billable_metric, organization:, field_name: "units") } + + let(:charge_a) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: metric_a, + organization:, + properties: {amount: "10", pricing_group_keys: ["cloud"]} + ) + end + + let(:charge_b) do + create(:standard_charge, plan: subscription.plan, billable_metric: metric_b, organization:, properties: {amount: "20"}) + end + + let(:charge) { nil } + + before do + charge_a + charge_b + + # Previous billing period events + create(:event, organization:, customer:, subscription:, code: metric_a.code, + timestamp: subscription.started_at + 1.day, properties: {cloud: "aws", units: 5}) + create(:event, organization:, customer:, subscription:, code: metric_a.code, + timestamp: subscription.started_at + 1.day, properties: {cloud: "gcp", units: 7}) + create(:event, organization:, customer:, subscription:, code: metric_b.code, + timestamp: subscription.started_at + 1.day, properties: {units: 3}) + + # Current billing period events + create(:event, organization:, customer:, subscription:, code: metric_a.code, + timestamp: Time.zone.now, properties: {cloud: "aws", units: 4}) + create(:event, organization:, customer:, subscription:, code: metric_a.code, + timestamp: Time.zone.now, properties: {cloud: "aws", units: 4}) + create(:event, organization:, customer:, subscription:, code: metric_a.code, + timestamp: Time.zone.now, properties: {cloud: "gcp", units: 6}) + create(:event, organization:, customer:, subscription:, code: metric_b.code, + timestamp: Time.zone.now, properties: {units: 2}) + end + + context "when filter_by_charge_code is provided" do + let(:params) { {external_subscription_id: subscription.external_id, filter_by_charge_code: charge_a.code, apply_taxes: false} } + + it "returns only the filtered charge with current period usage" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + # Current period only: aws(4+4) + gcp(6) = 14 units + expect(charge_usage[:units]).to eq("14.0") + expect(charge_usage[:amount_cents]).to eq(14_000) + end + end + + context "when filter_by_charge_code does not match any charge" do + let(:params) { {external_subscription_id: subscription.external_id, filter_by_charge_code: "nonexistent"} } + + it "returns not found" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "when filter_by_charge_code belongs to another organization" do + let(:other_charge) { create(:standard_charge) } + let(:params) { {external_subscription_id: subscription.external_id, filter_by_charge_code: other_charge.code} } + + it "returns not found" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "when filter_by_charge_id is provided" do + let(:params) { {external_subscription_id: subscription.external_id, filter_by_charge_id: charge_a.id, apply_taxes: false} } + + it "returns only the filtered charge with current period usage" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + expect(charge_usage[:units]).to eq("14.0") + expect(charge_usage[:amount_cents]).to eq(14_000) + end + end + + context "when filter_by_charge_id does not match any charge" do + let(:params) { {external_subscription_id: subscription.external_id, filter_by_charge_id: SecureRandom.uuid} } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "when filter_by_metric_code is provided" do + let(:params) { {external_subscription_id: subscription.external_id, filter_by_metric_code: metric_a.code, apply_taxes: false} } + + it "returns only charges for the given billable metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + expect(charge_usage[:units]).to eq("14.0") + expect(charge_usage[:amount_cents]).to eq(14_000) + end + end + + context "when filter_by_metric_code matches multiple charges sharing the same metric" do + let(:charge_a2) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: metric_a, + organization:, + properties: {amount: "5", pricing_group_keys: []} + ) + end + + let(:params) { {external_subscription_id: subscription.external_id, filter_by_metric_code: metric_a.code, apply_taxes: false} } + + before { charge_a2 } + + it "returns all charges for the metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + expect(json[:customer_usage][:charges_usage].map { |c| c[:billable_metric][:code] }).to all(eq(metric_a.code)) + + amounts = json[:customer_usage][:charges_usage].map { |c| c[:amount_cents] }.sort + expect(amounts).to eq([7_000, 14_000]) + end + end + + context "when filter_by_metric_code does not match any charge" do + let(:params) { {external_subscription_id: subscription.external_id, filter_by_metric_code: "nonexistent_metric"} } + + it "returns not found" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when filter_by_metric_code belongs to another organization" do + let(:other_metric) { create(:billable_metric) } + let(:params) { {external_subscription_id: subscription.external_id, filter_by_metric_code: other_metric.code} } + + it "returns not found" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when full_usage is true without filter" do + let(:params) { {external_subscription_id: subscription.external_id, full_usage: true} } + + it "returns method not allowed" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("full_usage_not_allowed") + end + end + + context "when full_usage is true with filter_by_charge" do + let(:params) do + { + external_subscription_id: subscription.external_id, + filter_by_charge_code: charge_a.code, + full_usage: true, + apply_taxes: false + } + end + + it "returns method not allowed without premium integration" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("full_usage_not_allowed") + end + + context "with granular_lifetime_usage premium integration", :premium do + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } + + it "returns usage aggregated from subscription start" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + # All periods: aws(5+4+4) + gcp(7+6) = 26 units + expect(charge_usage[:units]).to eq("26.0") + expect(charge_usage[:amount_cents]).to eq(26_000) + end + end + end + + context "when full_usage is true with filter_by_metric_code" do + let(:params) do + { + external_subscription_id: subscription.external_id, + filter_by_metric_code: metric_a.code, + full_usage: true, + apply_taxes: false + } + end + + it "returns method not allowed without premium integration" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("full_usage_not_allowed") + end + + context "with granular_lifetime_usage premium integration", :premium do + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } + + it "returns usage aggregated from subscription start" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + # All periods: aws(5+4+4) + gcp(7+6) = 26 units + expect(charge_usage[:units]).to eq("26.0") + expect(charge_usage[:amount_cents]).to eq(26_000) + end + end + end + + context "when filter_by_group is provided" do + let(:params) do + { + external_subscription_id: subscription.external_id, + filter_by_group: {cloud: "aws"}, + apply_taxes: false + } + end + + it "returns only usage matching the group filter for current period" do + subject + + expect(response).to have_http_status(:success) + + charge_usage = json[:customer_usage][:charges_usage].find { |c| c[:billable_metric][:code] == metric_a.code } + # Current period, cloud=aws only: 4 + 4 = 8 units + expect(charge_usage[:units]).to eq("8.0") + expect(charge_usage[:amount_cents]).to eq(8_000) + end + end + + context "when filter_by_group is provided with full_usage" do + let(:params) do + { + external_subscription_id: subscription.external_id, + filter_by_group: {cloud: "aws"}, + full_usage: true, + apply_taxes: false + } + end + + it "returns method not allowed without premium integration" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("full_usage_not_allowed") + end + + context "with granular_lifetime_usage premium integration", :premium do + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } + + it "returns group-filtered usage aggregated from subscription start" do + subject + + expect(response).to have_http_status(:success) + + charge_usage = json[:customer_usage][:charges_usage].find { |c| c[:billable_metric][:code] == metric_a.code } + # All periods, cloud=aws only: 5 + 4 + 4 = 13 units + expect(charge_usage[:units]).to eq("13.0") + expect(charge_usage[:amount_cents]).to eq(13_000) + end + end + end + + context "when charge_code is provided" do + let(:params) { {external_subscription_id: subscription.external_id, charge_code: charge_a.code, apply_taxes: false} } + + it "returns only the filtered charge with current period usage" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + expect(charge_usage[:units]).to eq("14.0") + expect(charge_usage[:amount_cents]).to eq(14_000) + end + end + + context "when charge_code does not match any charge" do + let(:params) { {external_subscription_id: subscription.external_id, charge_code: "nonexistent"} } + + it "returns not found" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "when charge_code belongs to another organization" do + let(:other_charge) { create(:standard_charge) } + let(:params) { {external_subscription_id: subscription.external_id, charge_code: other_charge.code} } + + it "returns not found" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "when charge_id is provided" do + let(:params) { {external_subscription_id: subscription.external_id, charge_id: charge_a.id, apply_taxes: false} } + + it "returns only the filtered charge with current period usage" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + expect(charge_usage[:units]).to eq("14.0") + expect(charge_usage[:amount_cents]).to eq(14_000) + end + end + + context "when charge_id does not match any charge" do + let(:params) { {external_subscription_id: subscription.external_id, charge_id: SecureRandom.uuid} } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "when billable_metric_code is provided" do + let(:params) { {external_subscription_id: subscription.external_id, billable_metric_code: metric_a.code, apply_taxes: false} } + + it "returns only charges for the given billable metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + expect(charge_usage[:units]).to eq("14.0") + expect(charge_usage[:amount_cents]).to eq(14_000) + end + end + + context "when billable_metric_code matches multiple charges sharing the same metric" do + let(:charge_a2) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: metric_a, + organization:, + properties: {amount: "5", pricing_group_keys: []} + ) + end + + let(:params) { {external_subscription_id: subscription.external_id, billable_metric_code: metric_a.code, apply_taxes: false} } + + before { charge_a2 } + + it "returns all charges for the metric" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + expect(json[:customer_usage][:charges_usage].map { |c| c[:billable_metric][:code] }).to all(eq(metric_a.code)) + + amounts = json[:customer_usage][:charges_usage].map { |c| c[:amount_cents] }.sort + expect(amounts).to eq([7_000, 14_000]) + end + end + + context "when billable_metric_code does not match any charge" do + let(:params) { {external_subscription_id: subscription.external_id, billable_metric_code: "nonexistent_metric"} } + + it "returns not found" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when billable_metric_code belongs to another organization" do + let(:other_metric) { create(:billable_metric) } + let(:params) { {external_subscription_id: subscription.external_id, billable_metric_code: other_metric.code} } + + it "returns not found" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when full_usage is true with charge_code" do + let(:params) do + { + external_subscription_id: subscription.external_id, + charge_code: charge_a.code, + full_usage: true, + apply_taxes: false + } + end + + it "returns method not allowed without premium integration" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("full_usage_not_allowed") + end + + context "with granular_lifetime_usage premium integration", :premium do + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } + + it "returns usage aggregated from subscription start" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + expect(charge_usage[:units]).to eq("26.0") + expect(charge_usage[:amount_cents]).to eq(26_000) + end + end + end + + context "when full_usage is true with billable_metric_code" do + let(:params) do + { + external_subscription_id: subscription.external_id, + billable_metric_code: metric_a.code, + full_usage: true, + apply_taxes: false + } + end + + it "returns method not allowed without premium integration" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("full_usage_not_allowed") + end + + context "with granular_lifetime_usage premium integration", :premium do + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } + + it "returns usage aggregated from subscription start" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:billable_metric][:code]).to eq(metric_a.code) + expect(charge_usage[:units]).to eq("26.0") + expect(charge_usage[:amount_cents]).to eq(26_000) + end + end + end + + context "when group is provided" do + let(:params) do + { + external_subscription_id: subscription.external_id, + group: {cloud: "aws"}, + apply_taxes: false + } + end + + it "returns only usage matching the group filter for current period" do + subject + + expect(response).to have_http_status(:success) + + charge_usage = json[:customer_usage][:charges_usage].find { |c| c[:billable_metric][:code] == metric_a.code } + expect(charge_usage[:units]).to eq("8.0") + expect(charge_usage[:amount_cents]).to eq(8_000) + end + end + + context "when group is provided with full_usage" do + let(:params) do + { + external_subscription_id: subscription.external_id, + group: {cloud: "aws"}, + full_usage: true, + apply_taxes: false + } + end + + it "returns method not allowed without premium integration" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("full_usage_not_allowed") + end + + context "with granular_lifetime_usage premium integration", :premium do + before { organization.update!(premium_integrations: %w[granular_lifetime_usage]) } + + it "returns group-filtered usage aggregated from subscription start" do + subject + + expect(response).to have_http_status(:success) + + charge_usage = json[:customer_usage][:charges_usage].find { |c| c[:billable_metric][:code] == metric_a.code } + expect(charge_usage[:units]).to eq("13.0") + expect(charge_usage[:amount_cents]).to eq(13_000) + end + end + end + end + + context "with filters" do + let(:filter_metric) { create(:billable_metric, aggregation_type: "count_agg", organization:) } + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric: filter_metric, key: "cloud", values: %w[aws google]) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: filter_metric, + properties: {amount: "0"} + ) + end + + let(:charge_filter_aws) { create(:charge_filter, charge:, properties: {amount: "10"}) } + let(:charge_filter_gcp) { create(:charge_filter, charge:, properties: {amount: "20"}) } + + let(:charge_filter_value_aws) do + create(:charge_filter_value, charge_filter: charge_filter_aws, billable_metric_filter:, values: ["aws"]) + end + + let(:charge_filter_value_gcp) do + create(:charge_filter_value, charge_filter: charge_filter_gcp, billable_metric_filter:, values: ["google"]) + end + + before do + subscription + charge + tax + charge_filter_value_aws + charge_filter_value_gcp + + create_list( + :event, + 3, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "aws"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "google"} + ) + end + + it "returns the filters usage for the customer" do + subject + + charge_usage = json[:customer_usage][:charges_usage].first + filters_usage = charge_usage[:filters] + + aws_filter_data = filters_usage.find { |f| f[:values] && f[:values][:cloud] == ["aws"] } + gcp_filter_data = filters_usage.find { |f| f[:values] && f[:values][:cloud] == ["google"] } + + expect(charge_usage[:units]).to eq("4.0") + expect(charge_usage[:amount_cents]).to eq(5000) + + # Assertions for the AWS filter + expect(aws_filter_data[:units]).to eq("3.0") + expect(aws_filter_data[:amount_cents]).to eq(3000) + + # Assertions for the GCP filter + expect(gcp_filter_data[:units]).to eq("1.0") + expect(gcp_filter_data[:amount_cents]).to eq(2000) + end + end + + context "with multiple filter values" do + let(:multi_filter_metric) { create(:billable_metric, aggregation_type: "count_agg", organization:) } + let(:billable_metric_filter_cloud) do + create(:billable_metric_filter, billable_metric: multi_filter_metric, key: "cloud", values: %w[aws google]) + end + let(:billable_metric_filter_region) do + create(:billable_metric_filter, billable_metric: multi_filter_metric, key: "region", values: %w[usa france]) + end + + let(:charge_filter_aws_usa) { create(:charge_filter, charge:, properties: {amount: "10"}) } + let(:charge_filter_aws_france) { create(:charge_filter, charge:, properties: {amount: "20"}) } + let(:charge_filter_google_usa) { create(:charge_filter, charge:, properties: {amount: "30"}) } + + let(:charge_filter_value11) do + create( + :charge_filter_value, + charge_filter: charge_filter_aws_usa, + billable_metric_filter: billable_metric_filter_cloud, + values: ["aws"] + ) + end + let(:charge_filter_value12) do + create( + :charge_filter_value, + charge_filter: charge_filter_aws_usa, + billable_metric_filter: billable_metric_filter_region, + values: ["usa"] + ) + end + + let(:charge_filter_value21) do + create( + :charge_filter_value, + charge_filter: charge_filter_aws_france, + billable_metric_filter: billable_metric_filter_cloud, + values: ["aws"] + ) + end + let(:charge_filter_value22) do + create( + :charge_filter_value, + charge_filter: charge_filter_aws_france, + billable_metric_filter: billable_metric_filter_region, + values: ["france"] + ) + end + + let(:charge_filter_value31) do + create( + :charge_filter_value, + charge_filter: charge_filter_google_usa, + billable_metric_filter: billable_metric_filter_cloud, + values: ["google"] + ) + end + let(:charge_filter_value32) do + create( + :charge_filter_value, + charge_filter: charge_filter_google_usa, + billable_metric_filter: billable_metric_filter_region, + values: ["usa"] + ) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric: multi_filter_metric, + properties: {amount: "0"} + ) + end + + before do + subscription + charge + tax + charge_filter_value11 + charge_filter_value12 + charge_filter_value21 + charge_filter_value22 + charge_filter_value31 + charge_filter_value32 + + create_list( + :event, + 2, + organization:, + customer:, + subscription:, + code: multi_filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "aws", region: "usa"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: multi_filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "aws", region: "france"} + ) + + create( + :event, + organization:, + customer:, + subscription:, + code: multi_filter_metric.code, + timestamp: Time.zone.now, + properties: {cloud: "google", region: "usa"} + ) + end + + it "returns the filters usage for the customer" do + subject + + charge_usage = json[:customer_usage][:charges_usage].first + filters_usage = charge_usage[:filters] + + aws_usa_data = filters_usage.find { |f| f[:values] && f[:values][:cloud] == ["aws"] && f[:values][:region] == ["usa"] } + aws_france_data = filters_usage.find { |f| f[:values] && f[:values][:cloud] == ["aws"] && f[:values][:region] == ["france"] } + google_usa_data = filters_usage.find { |f| f[:values] && f[:values][:cloud] == ["google"] && f[:values][:region] == ["usa"] } + + expect(charge_usage[:units]).to eq("4.0") + expect(charge_usage[:amount_cents]).to eq(7000) + + # Assertions for AWS/USA filter + expect(aws_usa_data[:units]).to eq("2.0") + expect(aws_usa_data[:amount_cents]).to eq(2000) + + # Assertions for AWS/France filter + expect(aws_france_data[:units]).to eq("1.0") + expect(aws_france_data[:amount_cents]).to eq(2000) + + # Assertions for Google/USA filter + expect(google_usa_data[:units]).to eq("1.0") + expect(google_usa_data[:amount_cents]).to eq(3000) + end + end + + context "when customer does not belongs to the organization" do + let(:customer) { create(:customer) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /customers/:customer_id/past_usage" do + subject do + get_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/past_usage", + params + ) + end + + let(:params) { {external_subscription_id: subscription.external_id, periods_count: 2} } + + let(:invoice_subscription) do + create( + :invoice_subscription, + charges_from_datetime: DateTime.parse("2023-08-17T00:00:00"), + charges_to_datetime: DateTime.parse("2023-09-16T23:59:59"), + subscription: + ) + end + + let(:billable_metric1) { create(:billable_metric, organization:) } + let(:billable_metric2) { create(:billable_metric, organization:) } + + let(:charge1) { create(:standard_charge, plan:, billable_metric: billable_metric1) } + let(:charge2) { create(:standard_charge, plan:, billable_metric: billable_metric2) } + + let(:invoice) { invoice_subscription.invoice } + + let(:fee1) { create(:charge_fee, charge: charge1, subscription:, invoice:) } + let(:fee2) { create(:charge_fee, charge: charge2, subscription:, invoice:) } + + before do + fee1 + fee2 + end + + include_examples "requires API permission", "customer_usage", "read" + + it "returns the past usage" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:usage_periods].count).to eq(1) + + usage = json[:usage_periods].first + expect(usage[:from_datetime]).to eq(invoice_subscription.charges_from_datetime.iso8601) + expect(usage[:to_datetime]).to eq(invoice_subscription.charges_to_datetime.iso8601) + expect(usage[:issuing_date]).to eq(invoice.issuing_date.iso8601) + expect(usage[:currency]).to eq(invoice.currency) + expect(usage[:amount_cents]).to eq(invoice.fees_amount_cents) + expect(usage[:total_amount_cents]).to eq(4) + expect(usage[:taxes_amount_cents]).to eq(4) + + expect(usage[:charges_usage].count).to eq(2) + + charge_usage = usage[:charges_usage].first + expect(charge_usage[:billable_metric][:name]).to eq(billable_metric1.name) + expect(charge_usage[:billable_metric][:code]).to eq(billable_metric1.code) + expect(charge_usage[:billable_metric][:aggregation_type]).to eq(billable_metric1.aggregation_type) + expect(charge_usage[:charge][:charge_model]).to eq(charge1.charge_model) + expect(charge_usage[:units]).to eq(fee1.units.to_s) + expect(charge_usage[:amount_cents]).to eq(fee1.amount_cents) + expect(charge_usage[:amount_currency]).to eq(fee1.currency) + end + + context "when missing external_subscription_id" do + let(:params) { {} } + + it "returns an unprocessable entity" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "with invalid billable metric code" do + let(:params) do + { + billable_metric_code: "invalid_code", + external_subscription_id: subscription.external_id + } + end + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/customers/wallets/alerts_controller_spec.rb b/spec/requests/api/v1/customers/wallets/alerts_controller_spec.rb new file mode 100644 index 0000000..f689e8b --- /dev/null +++ b/spec/requests/api/v1/customers/wallets/alerts_controller_spec.rb @@ -0,0 +1,396 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::Wallets::AlertsController do + let(:organization) { create(:organization) } + let(:customer_external_id) { customer.external_id } + let(:wallet_code) { wallet.code } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, organization:) } + let(:code) { "my-wallet-alert" } + let(:alert) { create(:wallet_balance_amount_alert, :processed, code:, wallet:, organization:) } + let(:deleted_alert) { create(:wallet_balance_amount_alert, :processed, deleted_at: Time.current, wallet:, organization:, thresholds: []) } + + before do + alert + deleted_alert + end + + RSpec.shared_examples "returns error if customer not found" do + let(:customer_external_id) { "not-found-id" } + + it do + subject + expect(response).to be_not_found_error("customer") + end + end + + RSpec.shared_examples "returns error if wallet not found" do + let(:wallet_code) { "not-found-code" } + + it do + subject + expect(response).to be_not_found_error("wallet") + end + end + + describe "GET /api/v1/customers/:external_id/wallets/:wallet_code/alerts" do + subject { get_with_token(organization, "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts") } + + it_behaves_like "requires API permission", "alert", "read" + it_behaves_like "returns error if customer not found" + it_behaves_like "returns error if wallet not found" + + context "when there are alerts" do + it "retrieves a paginated list of alerts" do + subject + expect(json[:alerts].sole).to include({ + code:, + lago_id: alert.id, + alert_type: "wallet_balance_amount", + previous_value: "800.0", + name: "General Alert", + created_at: be_present + }) + expect(json[:meta]).to eq({ + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + }) + end + end + + context "when there are no alerts" do + let(:alert) { nil } + + it do + subject + expect(json[:alerts]).to be_empty + expect(json[:meta][:total_count]).to eq 0 + end + end + end + + describe "POST /api/v1/customers/:external_id/wallets/:wallet_code/alerts" do + subject { post_with_token(organization, "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts", {alert: params}) } + + let(:alert) { nil } + let(:params) do + { + code: "test", + name: "New Wallet Alert", + alert_type: "wallet_balance_amount", + thresholds: [ + {code: :notice, value: 1000}, + {code: :warn, value: 5000}, + {code: :alert, value: 2000, recurring: true} + ] + } + end + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if customer not found" + it_behaves_like "returns error if wallet not found" + + it do + subject + + expect(json[:alert]).to include({ + lago_id: be_present, + code: "test", + name: "New Wallet Alert", + alert_type: "wallet_balance_amount", + previous_value: "0.0", + last_processed_at: be_nil, + created_at: be_present + }) + end + + context "when alert_type is wallet_credits_balance" do + let(:params) do + { + code: "credits-alert", + name: "Credits Balance Alert", + alert_type: "wallet_credits_balance", + thresholds: [{code: :notice, value: 100}] + } + end + + it "creates a wallet_credits_balance alert" do + subject + expect(json[:alert]).to include({ + lago_id: be_present, + code: "credits-alert", + alert_type: "wallet_credits_balance" + }) + end + end + + context "when alert_type is not a wallet type" do + let(:params) do + { + code: "test", + alert_type: "current_usage_amount", + thresholds: [{code: :notice, value: 1000}] + } + end + + it "returns validation error" do + subject + expect(json).to eq({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {alert_type: ["invalid_type"]}, + status: 422 + }) + end + end + + context "when payload is missing required param" do + %i[code thresholds].each do |field| + it "returns error when #{field} is missing" do + params.delete(field) + subject + expect(json).to match({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {field => array_including("value_is_mandatory")}, + status: 422 + }) + end + end + + it "returns error when alert_type is missing" do + params.delete(:alert_type) + subject + expect(json).to eq({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {alert_type: ["value_is_mandatory", "value_is_invalid"]}, + status: 422 + }) + end + end + + context "when alert_type is not supported" do + let(:params) do + { + code: "test", + alert_type: "not_supported", + thresholds: [{code: :notice, value: 1000}] + } + end + + it do + subject + expect(json).to eq({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {alert_type: ["invalid_type"]}, + status: 422 + }) + end + end + end + + describe "GET /api/v1/customers/:external_id/wallets/:wallet_code/alerts/:code" do + subject { get_with_token(organization, "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts/#{code}") } + + it_behaves_like "requires API permission", "alert", "read" + it_behaves_like "returns error if customer not found" + it_behaves_like "returns error if wallet not found" + + it do + subject + expect(json[:alert]).to include({ + code:, + lago_id: alert.id, + alert_type: "wallet_balance_amount", + previous_value: "800.0", + name: "General Alert", + created_at: be_present + }) + end + + context "when alert is not found" do + let(:alert) { nil } + + it do + subject + expect(response).to be_not_found_error("alert") + end + end + end + + describe "PUT /api/v1/customers/:external_id/wallets/:wallet_code/alerts/:code" do + subject { put_with_token(organization, "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts/#{code}", {alert: params}) } + + let(:params) do + { + code: "updated-code", + thresholds: [{code: :notice, value: 88_00}] + } + end + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if customer not found" + it_behaves_like "returns error if wallet not found" + + it "updates the alert" do + subject + + expect(json[:alert]).to include({ + lago_id: alert.id, + lago_organization_id: organization.id, + code: "updated-code", + name: "General Alert", + previous_value: "800.0", + last_processed_at: be_present, + created_at: be_present + }) + end + + context "when alert is not found" do + let(:alert) { nil } + + it do + subject + expect(response).to be_not_found_error("alert") + end + end + end + + describe "DELETE /api/v1/customers/:external_id/wallets/:wallet_code/alerts/:code" do + subject { delete_with_token(organization, "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts/#{code}") } + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if customer not found" + it_behaves_like "returns error if wallet not found" + + it "soft deletes the alert" do + subject + expect(alert.reload.deleted_at).to be_within(5.seconds).of(Time.current) + end + + context "when alert is not found" do + let(:alert) { nil } + + it do + subject + expect(response).to be_not_found_error("alert") + end + end + end + + describe "POST /api/v1/customers/:external_id/wallets/:wallet_code/alerts (batch)" do + subject { post_with_token(organization, "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts", params) } + + let(:alert) { nil } + let(:params) do + { + alerts: [ + { + code: "alert1", + name: "First Alert", + alert_type: "wallet_balance_amount", + thresholds: [{code: :notice, value: 1000}] + }, + { + code: "alert2", + alert_type: "wallet_credits_balance", + thresholds: [{value: 2000}] + } + ] + } + end + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if customer not found" + it_behaves_like "returns error if wallet not found" + + it "creates multiple alerts" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:alerts].count).to eq 2 + + expect(json[:alerts]).to match_array([ + include(code: "alert1"), + include(code: "alert2") + ]) + end + + context "when alerts are empty" do + let(:params) { {alerts: []} } + + it "returns a validation error" do + subject + expect(response).to have_http_status(:bad_request) + expect(json[:error]).to include("value is empty or invalid: alert") + end + end + + context "when several alerts are invalid" do + let(:params) do + { + alerts: [ + { + code: "duplicated", + alert_type: "wallet_balance_amount", + thresholds: [{value: 1000}] + }, + { + code: "alert2", + alert_type: "invalid_type", + thresholds: [{value: 2000}] + }, + { + code: "duplicated", + alert_type: "wallet_balance_amount", + thresholds: [{value: 3000}] + } + ] + } + end + + it "returns all the errors" do + expect { subject }.not_to change(UsageMonitoring::Alert, :count) + + expect(response).to have_http_status(:unprocessable_entity) + expect(json[:code]).to eq "validation_errors" + + expect(json[:error_details]).to match( + "1": match(params: params[:alerts][1], errors: include("invalid_type")), + "2": match(params: params[:alerts][2], errors: include("alert_already_exists")) + ) + end + end + end + + describe "DELETE /api/v1/customers/:external_id/wallets/:wallet_code/alerts" do + subject { delete_with_token(organization, "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts") } + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if customer not found" + it_behaves_like "returns error if wallet not found" + + it "soft deletes all alerts for the wallets" do + subject + + expect(response).to have_http_status(:ok) + end + + context "when there are no alerts" do + let(:alert) { nil } + + it "returns ok" do + subject + + expect(response).to have_http_status(:ok) + end + end + end +end diff --git a/spec/requests/api/v1/customers/wallets/metadata_controller_spec.rb b/spec/requests/api/v1/customers/wallets/metadata_controller_spec.rb new file mode 100644 index 0000000..1c09f5f --- /dev/null +++ b/spec/requests/api/v1/customers/wallets/metadata_controller_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::Wallets::MetadataController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:external_id) { customer.external_id } + let(:wallet) { create(:wallet, customer:, organization:) } + let(:wallet_id) { wallet.code } + + describe "POST /api/v1/customers/:customer_external_id/wallets/:code/metadata" do + it_behaves_like "a wallet metadata create endpoint" do + subject { post_with_token(organization, "/api/v1/customers/#{external_id}/wallets/#{wallet_id}/metadata", metadata_params) } + end + end + + describe "PATCH /api/v1/customers/:customer_external_id/wallets/:code/metadata" do + it_behaves_like "a wallet metadata update endpoint" do + subject { patch_with_token(organization, "/api/v1/customers/#{external_id}/wallets/#{wallet_id}/metadata", metadata_params) } + end + end + + describe "DELETE /api/v1/customers/:customer_external_id/wallets/:code/metadata" do + it_behaves_like "a wallet metadata destroy endpoint" do + subject { delete_with_token(organization, "/api/v1/customers/#{external_id}/wallets/#{wallet_id}/metadata") } + end + end + + describe "DELETE /api/v1/customers/:customer_external_id/wallets/:code/metadata/:key" do + it_behaves_like "a wallet metadata destroy key endpoint" do + subject { delete_with_token(organization, "/api/v1/customers/#{external_id}/wallets/#{wallet_id}/metadata/#{key}") } + end + end +end diff --git a/spec/requests/api/v1/customers/wallets_controller_spec.rb b/spec/requests/api/v1/customers/wallets_controller_spec.rb new file mode 100644 index 0000000..84f25b3 --- /dev/null +++ b/spec/requests/api/v1/customers/wallets_controller_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Customers::WalletsController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, currency: "EUR") } + let(:external_id) { customer.external_id } + let(:subscription) { create(:subscription, customer:) } + let(:expiration_at) { (Time.current + 1.year).iso8601 } + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:payment_method) { create(:payment_method, organization:, customer:) } + + before { subscription } + + describe "POST /api/v1/customers/:customer_external_id/wallets" do + it_behaves_like "a wallet create endpoint" do + subject do + post_with_token(organization, "/api/v1/customers/#{external_id}/wallets", {wallet: create_params}) + end + + context "when params[:external_customer_id] is empty" do + it "uses the route :customer_external_id to determine the customer" do + create_params.delete(:external_customer_id) + + subject + expect(json[:wallet][:external_customer_id]).to eq(external_id) + end + end + + context "when params[:external_customer_id] differs from the route :customer_external_id" do + it "uses the route :customer_external_id to determine the customer" do + create_params[:external_customer_id] = "external-customer-id" + + subject + expect(json[:wallet][:external_customer_id]).to eq(external_id) + end + end + + context "when a wallet with the same name already exists for the customer" do + let(:existing_wallet) { create(:wallet, customer:, name: "uniq wallet", code: "uniq_wallet") } + + before do + existing_wallet + create_params[:name] = existing_wallet.name + end + + context "when sending the same code in the create params" do + before { create_params[:code] = existing_wallet.code } + + it "returns a validation error" do + subject + expect(response).to have_http_status(:unprocessable_entity) + expect(json[:error_details]).to eq({code: ["value_already_exist"]}) + end + + context "when the existing wallet is terminated" do + let(:existing_wallet) { create(:wallet, customer:, code: "uniq_wallet", status: "terminated") } + + it "allows creating the wallet with the same code" do + subject + expect(json[:wallet][:code]).to eq(existing_wallet.code) + end + end + end + + context "when not sending any code in the create params, but having matching name" do + it "creates the wallet with a new unique code" do + subject + expect(json[:wallet][:code]).not_to eq(existing_wallet.code) + expect(json[:wallet][:code]).to include(existing_wallet.code) + end + end + end + end + + it_behaves_like "a wallet create endpoint with billing_entity_id" do + subject do + post_with_token(organization, "/api/v1/customers/#{external_id}/wallets", {wallet: create_params}) + end + end + end + + describe "PUT /api/v1/customers/:customer_external_id/wallets/:code" do + it_behaves_like "a wallet update endpoint" do + subject do + put_with_token( + organization, + "/api/v1/customers/#{external_id}/wallets/#{id}", + {wallet: update_params} + ) + end + + let(:id) { wallet.code } + + context "when a customer has multiple wallets with the same code" do + let(:other_wallet) { create(:wallet, customer:, code: wallet.code, status: "terminated") } + + before { other_wallet } + + it "updates the active wallet" do + subject + expect(wallet.reload.name).to eq(update_params[:name]) + expect(other_wallet.reload.name).not_to eq(update_params[:name]) + end + end + end + end + + describe "GET /api/v1/customers/:customer_external_id/wallets/:code" do + it_behaves_like "a wallet show endpoint" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}/wallets/#{id}") } + + let(:id) { wallet.code } + + context "when external_customer_id does not belong to the current organization" do + let(:other_org_customer) { create(:customer) } + let(:external_id) { other_org_customer.external_id } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when wallet code does not exist for the customer" do + let(:id) { "non-existing-code" } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when there are multiple wallets with the same code for the customer" do + let(:other_wallet) { create(:wallet, customer:, code: wallet.code, status: "terminated") } + + before { other_wallet } + + it "returns the active wallet" do + subject + expect(json[:wallet][:lago_id]).to eq(wallet.id) + end + end + end + end + + describe "DELETE /api/v1/customers/:customer_external_id/wallets/:code" do + it_behaves_like "a wallet terminate endpoint" do + subject { delete_with_token(organization, "/api/v1/customers/#{external_id}/wallets/#{id}") } + + let(:id) { wallet.code } + end + end + + describe "GET /api/v1/customers/:customer_external_id/wallets" do + it_behaves_like "a wallet index endpoint" do + subject do + get_with_token(organization, "/api/v1/customers/#{external_id}/wallets", params) + end + + context "when external_customer_id does not belong to the current organization" do + let(:other_org_customer) { create(:customer) } + let(:external_id) { other_org_customer.external_id } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + end +end diff --git a/spec/requests/api/v1/customers_controller_spec.rb b/spec/requests/api/v1/customers_controller_spec.rb new file mode 100644 index 0000000..cd21d9d --- /dev/null +++ b/spec/requests/api/v1/customers_controller_spec.rb @@ -0,0 +1,850 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::CustomersController do + describe "POST /api/v1/customers" do + subject { post_with_token(organization, "/api/v1/customers", {customer: create_params}) } + + let(:organization) { stripe_provider.organization } + let(:stripe_provider) { create(:stripe_provider) } + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar Inc.", + firstname: "Foo", + lastname: "Bar", + customer_type: "company", + currency: "EUR", + timezone: "America/New_York", + external_salesforce_id: "foobar" + } + end + + include_examples "requires API permission", "customer", "write" + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + expect(json[:customer][:name]).to eq(create_params[:name]) + expect(json[:customer][:firstname]).to eq(create_params[:firstname]) + expect(json[:customer][:lastname]).to eq(create_params[:lastname]) + expect(json[:customer][:customer_type]).to eq(create_params[:customer_type]) + expect(json[:customer][:created_at]).to be_present + expect(json[:customer][:currency]).to eq(create_params[:currency]) + expect(json[:customer][:external_salesforce_id]).to eq(create_params[:external_salesforce_id]) + expect(json[:customer][:account_type]).to eq("customer") + expect(json[:customer][:billing_entity_code]).to eq(organization.default_billing_entity.code) + end + + context "with premium features", :premium do + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + timezone: "America/New_York" + } + end + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer][:timezone]).to eq(create_params[:timezone]) + end + end + + context "with finalize_zero_amount_invoice" do + let(:create_params) do + { + external_id: SecureRandom.uuid, + finalize_zero_amount_invoice: "skip" + } + end + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer][:finalize_zero_amount_invoice]).to eq("skip") + end + end + + context "with billing configuration", :premium do + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + billing_configuration: { + invoice_grace_period: 3, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + payment_provider: "stripe", + payment_provider_code: stripe_provider.code, + provider_customer_id: "stripe_id", + document_locale: "fr", + provider_payment_methods: + } + } + end + + before do + stub_request(:post, "https://api.stripe.com/v1/checkout/sessions") + .to_return(status: 200, body: body.to_json, headers: {}) + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example.com"}) + + subject + end + + context "when provider payment methods are not present" do + let(:provider_payment_methods) { nil } + + it "returns a success" do + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + + billing = json[:customer][:billing_configuration] + + expect(billing).to be_present + expect(billing[:payment_provider]).to eq("stripe") + expect(billing[:payment_provider_code]).to eq(stripe_provider.code) + expect(billing[:provider_customer_id]).to eq("stripe_id") + expect(billing[:invoice_grace_period]).to eq(3) + expect(billing[:subscription_invoice_issuing_date_anchor]).to eq("current_period_end") + expect(billing[:subscription_invoice_issuing_date_adjustment]).to eq("keep_anchor") + expect(billing[:document_locale]).to eq("fr") + expect(billing[:provider_payment_methods]).to eq(%w[card]) + end + end + + context "when both provider payment methods are set" do + let(:provider_payment_methods) { %w[card sepa_debit] } + + it "returns a success" do + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + + billing = json[:customer][:billing_configuration] + + expect(billing).to be_present + expect(billing[:payment_provider]).to eq("stripe") + expect(billing[:payment_provider_code]).to eq(stripe_provider.code) + expect(billing[:provider_customer_id]).to eq("stripe_id") + expect(billing[:invoice_grace_period]).to eq(3) + expect(billing[:subscription_invoice_issuing_date_anchor]).to eq("current_period_end") + expect(billing[:subscription_invoice_issuing_date_adjustment]).to eq("keep_anchor") + expect(billing[:document_locale]).to eq("fr") + expect(billing[:provider_payment_methods]).to eq(%w[card sepa_debit]) + end + end + + context "when provider payment methods contain only card" do + let(:provider_payment_methods) { %w[card] } + + it "returns a success" do + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + + billing = json[:customer][:billing_configuration] + + expect(billing).to be_present + expect(billing[:payment_provider]).to eq("stripe") + expect(billing[:payment_provider_code]).to eq(stripe_provider.code) + expect(billing[:provider_customer_id]).to eq("stripe_id") + expect(billing[:invoice_grace_period]).to eq(3) + expect(billing[:subscription_invoice_issuing_date_anchor]).to eq("current_period_end") + expect(billing[:subscription_invoice_issuing_date_adjustment]).to eq("keep_anchor") + expect(billing[:document_locale]).to eq("fr") + expect(billing[:provider_payment_methods]).to eq(%w[card]) + end + end + + context "when provider payment methods contain only sepa_debit" do + let(:provider_payment_methods) { %w[sepa_debit] } + + it "returns a success" do + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + + billing = json[:customer][:billing_configuration] + + expect(billing).to be_present + expect(billing[:payment_provider]).to eq("stripe") + expect(billing[:payment_provider_code]).to eq(stripe_provider.code) + expect(billing[:provider_customer_id]).to eq("stripe_id") + expect(billing[:invoice_grace_period]).to eq(3) + expect(billing[:subscription_invoice_issuing_date_anchor]).to eq("current_period_end") + expect(billing[:subscription_invoice_issuing_date_adjustment]).to eq("keep_anchor") + expect(billing[:document_locale]).to eq("fr") + expect(billing[:provider_payment_methods]).to eq(%w[sepa_debit]) + end + end + end + + context "with account_type partner", :premium do + let(:organization) { create(:organization, premium_integrations: ["revenue_share"]) } + + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + account_type: "partner" + } + end + + it "returns a success" do + subject + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + expect(json[:customer][:account_type]).to eq(create_params[:account_type]) + end + end + + context "with integration_customers" do + let!(:integration) { create(:netsuite_integration, organization:, code: "netsuite") } + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + integration_customers: [ + { + integration_type: "netsuite", + integration_code: "netsuite", + sync_with_provider: true + } + ] + } + end + + it "creates customer with integration customer and returns a success" do + expect do + subject + end.to have_enqueued_job(IntegrationCustomers::CreateJob).with( + integration_customer_params: { + integration_type: "netsuite", + integration_code: "netsuite", + sync_with_provider: true + }, + integration:, + customer: a_kind_of(Customer) + ) + expect(response).to have_http_status(:success) + end + end + + context "with metadata" do + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + metadata: [ + { + key: "Hello", + value: "Hi", + display_in_invoice: true + } + ] + } + end + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + + metadata = json[:customer][:metadata] + expect(metadata).to be_present + expect(metadata.first[:key]).to eq("Hello") + expect(metadata.first[:value]).to eq("Hi") + expect(metadata.first[:display_in_invoice]).to eq(true) + end + end + + context "with invisible characters in email" do + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar Inc.", + email: "foo\u200Cbar@example.com", + firstname: "Foo", + lastname: "Bar", + customer_type: "company", + currency: "EUR", + timezone: "America/New_York" + } + end + + it "removes invisible characters from email" do + subject + expect(response).to have_http_status(:success) + expect(json[:customer][:email]).to eq("foobar@example.com") + end + + context "with full range of invisible characters" do + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar Inc.", + email: "foo\u200B\u200C\u200D\u00A0\u200E\u200Fbar@example.com", + firstname: "Foo", + lastname: "Bar", + customer_type: "company" + } + end + + it "removes all invisible characters from email" do + subject + expect(response).to have_http_status(:success) + expect(json[:customer][:email]).to eq("foobar@example.com") + end + end + end + + [ + { + params: "customer", + expected_status: :bad_request, + expected_response: {status: 400, error: "BadRequest: param is missing or the value is empty or invalid: customer"} + }, + { + params: {name: "Foo Bar", currency: "invalid"}, + expected_status: :unprocessable_content, + expected_response: { + status: 422, + code: "validation_errors", + error: "Unprocessable Entity", + error_details: { + currency: [ + "value_is_invalid" + ], + external_id: [ + "value_is_mandatory" + ] + } + } + } + ].each do |test| + context "with invalid params" do + let(:create_params) { test[:params] } + + it "returns an unprocessable_entity" do + subject + expect(response).to have_http_status(test[:expected_status]) + expect(json).to eq(test[:expected_response]) + end + end + end + + context "with invoice_custom_sections" do + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + skip_invoice_custom_sections:, + invoice_custom_section_codes: + } + end + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 2, organization:) } + + before do + create( + :billing_entity_applied_invoice_custom_section, + organization:, + billing_entity: organization.default_billing_entity, + invoice_custom_section: invoice_custom_sections[0] + ) + subject + end + + context "when sending custom invoice_custom_sections" do + let(:skip_invoice_custom_sections) { false } + let(:invoice_custom_section_codes) { invoice_custom_sections.map(&:code) } + + it "returns a success" do + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to be_present + expect(json[:customer][:external_id]).to eq(create_params[:external_id]) + + sections = json[:customer][:applicable_invoice_custom_sections] + expect(sections).to be_present + expect(sections.length).to eq(2) + expect(sections.map { |sec| sec[:code] }).to match_array(invoice_custom_section_codes) + end + end + + context "when sending skip_invoice_custom_sections AND invoice_custom_section_codes" do + let(:skip_invoice_custom_sections) { true } + let(:invoice_custom_section_codes) { invoice_custom_sections.map(&:code) } + + it "returns an error" do + expect(response).to have_http_status(:unprocessable_content) + + expect(json[:error_details][:invoice_custom_sections]).to include("skip_sections_and_selected_ids_sent_together") + end + end + end + + context "when billing_entity_code is provided" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:create_params) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + billing_entity_code: billing_entity.code + } + end + + it "creates customer associated to the provided billing_entity" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer][:billing_entity_code]).to eq(billing_entity.code) + end + end + end + + describe "GET /api/v1/customers/:customer_external_id/portal_url" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}/portal_url") } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:external_id) { customer.external_id } + + include_examples "requires API permission", "customer", "read" + + it "returns the portal url" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customer][:portal_url]).to include("/customer-portal/") + end + + context "when customer does not belongs to the organization" do + let(:customer) { create(:customer) } + + it "returns not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/customers" do + subject { get_with_token(organization, "/api/v1/customers", params) } + + let(:params) { {} } + let(:organization) { create(:organization) } + + before { create_pair(:customer, organization:) } + + include_examples "requires API permission", "customer", "read" + + it "returns all customers from organization" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:meta][:total_count]).to eq(2) + expect(json[:customers][0][:taxes]).not_to be_nil + end + + context "with account_type filters" do + let(:params) { {account_type: %w[partner]} } + + let(:partner) do + create(:customer, organization:, account_type: "partner") + end + + before { partner } + + it "returns partner customers" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(1) + expect(json[:customers].first[:lago_id]).to eq(partner.id) + end + end + + context "when filtering by customer_type" do + let(:params) { {customer_type: "company"} } + let!(:company) { create(:customer, organization:, customer_type: "company") } + let(:individual) { create(:customer, organization:, customer_type: "individual") } + + before { individual } + + context "when filtering by company" do + it "returns company customers" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(1) + expect(json[:customers].first[:lago_id]).to eq(company.id) + end + end + end + + context "when filtering by has_customer_type" do + let(:params) { {has_customer_type: true} } + let!(:company) { create(:customer, organization:, customer_type: "company") } + let!(:individual) { create(:customer, organization:, customer_type: "individual") } + + context "when filtering by true" do + it "returns customers with customer_type" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(2) + expect(json[:customers].pluck(:lago_id)).to match_array([company.id, individual.id]) + end + end + + context "when filtering by false and customer_type is provided" do + let(:params) { {has_customer_type: false, customer_type: "company"} } + + it "returns an error" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json).to match({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {customer_type: ["must be nil when has_customer_type is false"]}, + status: 422 + }) + end + end + end + + context "when filtering by billing_entity_code" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:params) { {billing_entity_codes: [billing_entity.code]} } + + before { customer } + + it "returns customers for the specified billing entity" do + subject + + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(1) + expect(json[:customers].first[:lago_id]).to eq(customer.id) + end + + context "when one of billing entities does not exist" do + let(:params) { {billing_entity_codes: [billing_entity.code, "non_existent_code"]} } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("billing_entity_not_found") + end + end + + context "with invalid billing entity codes" do + let(:params) { {billing_entity_codes: "invalid_code"} } + + it "ignores the parameter" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:customers].count).to eq(3) + expect(json[:customers].first[:lago_id]).to eq(customer.id) + end + end + + context "with two identical billing entity codes" do + let(:params) { {billing_entity_codes: [billing_entity.code, billing_entity.code]} } + + it "returns customers for the specified billing entity" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:customers].count).to eq(1) + expect(json[:customers].first[:lago_id]).to eq(customer.id) + end + end + end + + context "when filtering by countries" do + let(:params) { {countries: ["US", "FR"]} } + + let!(:customer) { create(:customer, organization:, country: "US") } + let!(:customer2) { create(:customer, organization:, country: "FR") } + + it "returns only two customers" do + subject + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(2) + expect(json[:customers].map { |customer| customer[:lago_id] }).to match_array([customer.id, customer2.id]) + end + + context "when filtering by invalid country" do + let(:params) { {countries: ["INVALID"]} } + + it "returns no customers" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json).to match({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {countries: {"0": [/must be one of: AD, .*XK$/]}}, + status: 422 + }) + end + end + end + + context "when filtering by states" do + let(:params) { {states: ["CA", "Paris"]} } + let!(:customer) { create(:customer, organization:, state: "CA") } + let!(:customer2) { create(:customer, organization:, state: "Paris") } + + it "returns only two customers" do + subject + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(2) + expect(json[:customers].map { |customer| customer[:lago_id] }).to match_array([customer.id, customer2.id]) + end + end + + context "when filtering by zipcodes" do + let(:params) { {zipcodes: ["10115", "75001"]} } + let!(:customer) { create(:customer, organization:, zipcode: "10115") } + let!(:customer2) { create(:customer, organization:, zipcode: "75001") } + + it "returns only two customers" do + subject + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(2) + expect(json[:customers].map { |customer| customer[:lago_id] }).to match_array([customer.id, customer2.id]) + end + end + + context "when filtering by currencies" do + let(:params) { {currencies: ["AED", "CAD"]} } + let!(:customer) { create(:customer, organization:, currency: "AED") } + let!(:customer2) { create(:customer, organization:, currency: "CAD") } + + it "returns only two customers" do + subject + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(2) + expect(json[:customers].map { |customer| customer[:lago_id] }).to match_array([customer.id, customer2.id]) + end + + context "when filtering by invalid currency" do + let(:params) { {currencies: ["INVALID"]} } + + it "returns no customers" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json).to match({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {currencies: {"0": [/must be one of: AED, AFN.*ZMW$/]}}, + status: 422 + }) + end + end + end + + context "when filtering by has_tax_identification_number" do + let(:params) { {has_tax_identification_number: true} } + let!(:customer) { create(:customer, organization:, tax_identification_number: "1234567890") } + + it "returns only the customer with a tax identification number" do + subject + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(1) + expect(json[:customers].map { |customer| customer[:lago_id] }).to match_array([customer.id]) + end + + context "when filtering by false" do + let(:params) { {has_tax_identification_number: false} } + + it "returns only the customer without a tax identification number" do + subject + expect(response).to have_http_status(:success) + expect(json[:customers].count).to eq(2) + expect(json[:customers].map { |customer| customer[:lago_id] }).not_to include(customer.id) + end + end + + context "when filtering by invalid value" do + let(:params) { {has_tax_identification_number: "invalid"} } + + it "returns no customers" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json).to match({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {has_tax_identification_number: ["must be one of: true, false"]}, + status: 422 + }) + end + end + end + + context "when filtering by metadata" do + let(:params) { {metadata: {is_synced: "true", last_synced_at: "2025-01-01", first_synced_at: ""}} } + let!(:customer) { create(:customer, organization:) } + + before do + create(:customer_metadata, customer:, key: "is_synced", value: "true") + create(:customer_metadata, customer:, key: "last_synced_at", value: "2025-01-01") + end + + it "returns only the customer with the specified metadata" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:customers].count).to eq(1) + expect(json[:customers].map { |customer| customer[:lago_id] }).to match_array([customer.id]) + end + + context "when filtering by invalid metadata" do + let(:params) { {metadata: {nested: {deeply: true}, is_synced: ["true"]}} } + + it "returns no customers" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json).to match({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {metadata: {is_synced: ["must be a string"], nested: ["must be a string"]}}, + status: 422 + }) + end + end + end + + context "when filtering by search_term" do + let(:params) { {search_term: "oo b"} } + let!(:customer) { create(:customer, organization:, name: "Foo Bar") } + + it "returns customers for the specified search_term" do + subject + + expect(response).to have_http_status(:ok) + + expect(json[:customers].count).to eq(1) + expect(json[:customers].first[:lago_id]).to eq(customer.id) + end + end + end + + describe "GET /api/v1/customers/:customer_id" do + subject { get_with_token(organization, "/api/v1/customers/#{external_id}") } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:external_id) { customer.external_id } + + context "when customer exists" do + include_examples "requires API permission", "customer", "read" + + it "returns the customer" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:customer][:lago_id]).to eq(customer.id) + expect(json[:customer][:taxes]).not_to be_nil + end + end + + context "when customer does not exist" do + let(:external_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1/customers/:customer_id" do + subject { delete_with_token(organization, "/api/v1/customers/#{external_id}") } + + let(:organization) { create(:organization) } + let!(:customer) { create(:customer, organization:) } + let(:external_id) { customer.external_id } + + include_examples "requires API permission", "customer", "write" + + it "deletes a customer" do + expect { subject }.to change(Customer, :count).by(-1) + end + + it "returns deleted customer" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:customer][:lago_id]).to eq(customer.id) + expect(json[:customer][:external_id]).to eq(customer.external_id) + end + + context "when customer does not exist" do + let(:external_id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "POST /api/v1/customers/:external_customer_id/checkout_url" do + subject do + post_with_token(organization, "/api/v1/customers/#{customer.external_id}/checkout_url") + end + + let(:organization) { create(:organization) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:customer) { create(:customer, organization:) } + + before do + create( + :stripe_customer, + customer_id: customer.id, + payment_provider: stripe_provider + ) + + customer.update!(payment_provider: "stripe", payment_provider_code: stripe_provider.code) + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example.com"}) + end + + include_examples "requires API permission", "customer", "write" + + it "returns the new generated checkout url" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:customer][:checkout_url]).to eq("https://example.com") + end + end +end diff --git a/spec/requests/api/v1/data_api/usages_controller_spec.rb b/spec/requests/api/v1/data_api/usages_controller_spec.rb new file mode 100644 index 0000000..49b5f2e --- /dev/null +++ b/spec/requests/api/v1/data_api/usages_controller_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::DataApi::UsagesController do # rubocop:disable Rails/FilePath + describe "GET /analytics/usage" do + subject { get_with_token(organization, "/api/v1/analytics/usage", params) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:params) { {currency: "EUR"} } + + let(:result) do + BaseService::Result.new.tap do |result| + result.usages = [{amount_currency: nil, amount_cents: nil}] + end + end + + before do + allow(DataApi::UsagesService).to receive(:call).and_return(result) + end + + context "when license is premium", :premium do + include_examples "requires API permission", "analytic", "read" + + it "returns the usage" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:usages].first[:amount_currency]).to eq(nil) + expect(json[:usages].first[:amount_cents]).to eq(nil) + expect(DataApi::UsagesService).to have_received(:call).with(organization, **params) + end + end + + context "when license is not premium" do + include_examples "requires API permission", "analytic", "read" + + it "returns the usage" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:usages].first[:amount_currency]).to eq(nil) + expect(json[:usages].first[:amount_cents]).to eq(nil) + expect(DataApi::UsagesService).to have_received(:call).with(organization, **params) + end + end + end +end diff --git a/spec/requests/api/v1/events_controller_spec.rb b/spec/requests/api/v1/events_controller_spec.rb new file mode 100644 index 0000000..045637d --- /dev/null +++ b/spec/requests/api/v1/events_controller_spec.rb @@ -0,0 +1,842 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::EventsController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:metric) { create(:billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let!(:subscription) { create(:subscription, customer:, organization:, plan:, started_at: 1.month.ago) } + + describe "POST /api/v1/events" do + subject do + post_with_token(organization, "/api/v1/events", event: create_params) + end + + let(:create_params) do + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + precise_total_amount_cents: "123.45", + properties: { + foo: "bar" + } + } + end + + include_examples "requires API permission", "event", "write" + + it "returns a success" do + expect { subject }.to change(Event, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:event][:external_subscription_id]).to eq(subscription.external_id) + end + + it "does not create an audit log", clickhouse: true do + expect { subject }.not_to change(Clickhouse::ApiLog, :count) + end + + context "with duplicated transaction_id" do + let!(:event) { create(:event, organization:, external_subscription_id: subscription.external_id) } + + let(:create_params) do + { + code: metric.code, + transaction_id: event.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + precise_total_amount_cents: "123.45", + properties: { + foo: "bar" + } + } + end + + it "returns a not found response" do + expect { subject }.not_to change(Event, :count) + + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "when sending wrong format for the timestamp" do + let(:create_params) do + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_s, + precise_total_amount_cents: "123.45", + properties: { + foo: "bar" + } + } + end + + it "returns a not found response" do + expect { subject }.not_to change(Event, :count) + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({timestamp: ["invalid_format"]}) + end + end + + context "with expression configured on billable metric" do + let(:metric) { create(:billable_metric, field_name: "value", organization:, expression: "event.properties.a + event.properties.b") } + let(:create_params) do + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + properties: { + a: "1", + b: "2" + } + } + end + + it "evaluates the expression and stores the result" do + expect { subject }.to change(Event, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:event][:properties]).to include(value: "3.0") + end + + context "when sending incomplete properties for expression" do + let(:create_params) do + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + properties: { + a: "1" + } + } + end + + it "fails with a 422 error" do + expect { subject }.not_to change(Event, :count) + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include("Variable: b not found") + end + end + end + end + + describe "POST /api/v1/events/batch" do + subject do + post_with_token(organization, "/api/v1/events/batch", events: batch_params) + end + + let(:batch_params) do + [ + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + precise_total_amount_cents: "123.45", + properties: { + foo: "bar" + } + } + ] + end + + include_examples "requires API permission", "event", "write" + + it "returns a success" do + expect { subject }.to change(Event, :count).by(1) + + expect(response).to have_http_status(:ok) + expect(json[:events].first[:external_subscription_id]).to eq(subscription.external_id) + end + + context "with invalid timestamp for one event" do + let(:batch_params) do + [ + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + precise_total_amount_cents: "123.45", + properties: { + foo: "bar" + } + }, + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_s, + precise_total_amount_cents: "123.45", + properties: { + foo: "bar" + } + } + ] + end + + it "returns an error indicating which event contained which error" do + expect { subject }.not_to change(Event, :count) + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({"1": {timestamp: ["invalid_format"]}}) + end + end + + context "with expression configured on billable metric" do + let(:metric) { create(:billable_metric, field_name: "value", organization:, expression: "event.properties.a + event.properties.b") } + let(:batch_params) { [create_params] } + let(:create_params) do + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + properties: { + a: "1", + b: "2" + } + } + end + + it "evaluates the expression and stores the result" do + expect { subject }.to change(Event, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:events].first[:properties]).to include(value: "3.0") + end + + context "when sending incomplete properties for expression" do + let(:create_params) do + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + timestamp: Time.current.to_i, + properties: { + a: "1" + } + } + end + + it "fails with a 422 error" do + expect { subject }.not_to change(Event, :count) + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include("0": "expression_evaluation_failed: Variable: b not found") + end + end + end + end + + describe "GET /api/v1/events" do + subject { get_with_token(organization, "/api/v1/events", params) } + + let!(:event) { create(:event, timestamp: 5.days.ago.to_date, organization:) } + + context "without params" do + let(:params) { {} } + + include_examples "requires API permission", "event", "read" + + it "returns events" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:events].count).to eq(1) + expect(json[:events].first[:lago_id]).to eq(event.id) + end + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + before { create(:event, organization:) } + + it "returns events with correct meta data" do + subject + + expect(response).to have_http_status(:ok) + + expect(json[:events].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + + context "with code" do + let(:params) { {code: event.code} } + + before { create(:event, organization:) } + + it "returns events" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:events].count).to eq(1) + expect(json[:events].first[:lago_id]).to eq(event.id) + end + end + + context "with external subscription id" do + let(:params) { {external_subscription_id: event.external_subscription_id} } + + before { create(:event, organization:) } + + it "returns events" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:events].count).to eq(1) + expect(json[:events].first[:lago_id]).to eq(event.id) + end + end + + context "with timestamp" do + let(:params) do + {timestamp_from: 2.days.ago.to_date, timestamp_to: Date.tomorrow.to_date} + end + + let!(:matching_event) { create(:event, timestamp: 1.day.ago.to_date, organization:) } + + before { create(:event, timestamp: 3.days.ago.to_date, organization:) } + + it "returns events with correct timestamp" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:events].count).to eq(1) + expect(json[:events].first[:lago_id]).to eq(matching_event.id) + end + end + + context "with timestamp_from_started_at" do + let(:started_at) { 1.day.ago } + let(:subscription) { create(:subscription, organization:, started_at:) } + let(:params) do + { + timestamp_from_started_at: true, + external_subscription_id: subscription.external_id + } + end + + it do + matching_event = create(:event, timestamp: started_at + 1.second, external_subscription_id: subscription.external_id, organization:) + create(:event, timestamp: started_at - 1.second, external_subscription_id: subscription.external_id, organization:) + + subject + + expect(response).to have_http_status(:ok) + expect(json[:events].map { it[:lago_id] }).to contain_exactly(matching_event.id) + end + end + + context "with timestamp_from_started_at set to false as string" do + let(:started_at) { 1.day.ago } + let(:subscription) { create(:subscription, organization:, started_at:) } + let(:params) do + { + timestamp_from: 10.days.ago.to_date, + timestamp_from_started_at: "false" + } + end + + it do + matching_event = create(:event, timestamp: started_at + 1.second, external_subscription_id: subscription.external_id, organization:) + other_event = create(:event, timestamp: started_at - 1.second, external_subscription_id: subscription.external_id, organization:) + + subject + + expect(response).to have_http_status(:ok) + expect(json[:events].map { it[:lago_id] }).to contain_exactly(event.id, other_event.id, matching_event.id) + end + end + + context "with clickhouse", clickhouse: true do + let(:params) { {} } + + before { organization.update!(clickhouse_events_store: true) } + + context "when event is raw" do + let(:event) do + Clickhouse::EventsRaw.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code, + timestamp: 5.days.ago.to_date, + properties: {} + ) + end + + it "returns an event" do + subject + + expect(response).to have_http_status(:ok) + + json_event = json[:events].sole + expect(json_event[:lago_subscription_id]).to eq event.subscription_id + expect(json_event[:lago_customer_id]).to eq event.customer_id + expect(json_event[:code]).to eq event.code + expect(json_event[:transaction_id]).to eq event.transaction_id + end + end + + context "when event is enriched" do + let(:event) do + Clickhouse::EventsEnriched.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code, + timestamp: 5.days.ago.to_date, + properties: {}, + enriched_at: DateTime.new(2025, 1, 1) + ) + end + + it "does not return any event" do + subject + expect(response).to have_http_status(:ok) + expect(json[:events]).to be_empty + end + end + end + end + + describe "GET /api/v1/events_enriched" do + subject { get_with_token(organization, "/api/v1/events_enriched", params) } + + context "without clickhouse" do + let(:params) { {} } + + it "returns an error" do + subject + expect(response).to have_http_status(:forbidden) + end + end + + context "with clickhouse", clickhouse: true do + let(:params) { {external_subscription_id: event.external_subscription_id} } + + before { organization.update!(clickhouse_events_store: true) } + + context "when event is raw" do + let(:event) do + Clickhouse::EventsRaw.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code, + timestamp: 5.days.ago.to_date, + properties: {} + ) + end + + it "does not return any event" do + subject + + expect(response).to have_http_status(:ok) + expect(response.headers["X-Lago-Endpoint-Status"]).to eq("beta") + expect(json[:events]).to be_empty + end + end + + context "when event is enriched" do + let(:event) do + Clickhouse::EventsEnriched.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code, + timestamp: 5.days.ago.to_date, + properties: {}, + enriched_at: DateTime.new(2025, 1, 1) + ) + end + + it "returns an event" do + subject + + expect(response).to have_http_status(:ok) + expect(response.headers["X-Lago-Endpoint-Status"]).to eq("beta") + + json_event = json[:events].sole + expect(json_event[:enriched_at]).to eq "2025-01-01T00:00:00.000Z" + expect(json_event[:code]).to eq event.code + expect(json_event[:transaction_id]).to eq event.transaction_id + expect(json_event).not_to have_key(:lago_subscription_id) + expect(json_event).not_to have_key(:lago_customer_id) + end + end + end + end + + describe "GET /api/v1/events/:id" do + subject { get_with_token(organization, "/api/v1/events/#{CGI.escapeURIComponent(transaction_id)}") } + + let(:event) { create(:event, organization_id: organization.id, transaction_id: event_transaction_id) } + let(:event_transaction_id) { SecureRandom.uuid } + let(:transaction_id) { event_transaction_id } + + before { event } + + include_examples "requires API permission", "event", "read" + + it "returns an event" do + subject + + expect(response).to have_http_status(:ok) + + %i[code transaction_id].each do |property| + expect(json[:event][property]).to eq event.attributes[property.to_s] + end + + expect(json[:event][:lago_subscription_id]).to eq event.subscription_id + expect(json[:event][:lago_customer_id]).to eq event.customer_id + end + + context "when transaction_id contains special characters" do + let(:event_transaction_id) { "1Az()[]?#._/|-/../" } + + it "returns an event" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:event][:transaction_id]).to eq event.transaction_id + end + end + + context "with a non-existing transaction_id" do + let(:transaction_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when event is deleted" do + before { event.discard! } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "with clickhouse", clickhouse: true do + let(:event) do + Clickhouse::EventsRaw.create!( + transaction_id: event_transaction_id, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code, + timestamp: 5.days.ago.to_date, + properties: {} + ) + end + + before { organization.update!(clickhouse_events_store: true) } + + it "returns an event" do + subject + + expect(response).to have_http_status(:ok) + + %i[code transaction_id].each do |property| + expect(json[:event][property]).to eq event.attributes[property.to_s] + end + + expect(json[:event][:lago_subscription_id]).to eq event.subscription_id + expect(json[:event][:lago_customer_id]).to eq event.customer_id + end + end + end + + describe "POST /api/v1/events/estimate_fees" do + subject do + post_with_token(organization, "/api/v1/events/estimate_fees", event: event_params) + end + + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric: metric) } + let(:tax) { create(:tax, organization:) } + + let(:event_params) do + { + code: metric.code, + external_subscription_id: subscription.external_id, + transaction_id: SecureRandom.uuid, + precise_total_amount_cents: "123.45", + properties: { + foo: "bar" + } + } + end + + before do + charge + tax + end + + include_examples "requires API permission", "event", "write" + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(1) + + fee = json[:fees].first + expect(fee[:lago_id]).to be_nil + expect(fee[:lago_group_id]).to be_nil + expect(fee[:item][:type]).to eq("charge") + expect(fee[:item][:code]).to eq(metric.code) + expect(fee[:item][:name]).to eq(metric.name) + expect(fee[:pay_in_advance]).to eq(true) + expect(fee[:amount_cents]).to be_an(Integer) + expect(fee[:amount_currency]).to eq("EUR") + expect(fee[:units]).to eq("1.0") + expect(fee[:events_count]).to eq(1) + end + + context "with taxes applied to the billing entity" do + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, billing_entity: customer.billing_entity, rate: 20.0) } + + it "returns fees with tax information" do + subject + + expect(response).to have_http_status(:success) + + fee = json[:fees].first + expect(fee[:taxes_amount_cents]).to be_positive + expect(fee[:taxes_rate]).to eq(20.0) + expect(fee[:applied_taxes].count).to eq(1) + expect(fee[:applied_taxes].first[:tax_rate]).to eq(20.0) + end + end + + context "with missing customer id" do + let(:event_params) do + { + code: metric.code, + external_subscription_id: nil, + properties: { + foo: "bar" + } + } + end + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when metric code does not match an pay_in_advance charge" do + let(:charge) { create(:standard_charge, plan:, billable_metric: metric) } + + let(:event_params) do + { + code: metric.code, + external_subscription_id: subscription.external_id, + properties: { + foo: "bar" + } + } + end + + it "returns a validation error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "POST /api/v1/events/batch_estimate_instant_fees" do + subject do + post_with_token(organization, "/api/v1/events/batch_estimate_instant_fees", events: batch_params) + end + + let(:metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric: metric, properties: {rate: "0.1", fixed_amount: "0"}) } + + let(:event_params) do + { + code: metric.code, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: SecureRandom.uuid, + properties: { + metric.field_name => 400 + } + } + end + + let(:batch_params) { [event_params] } + + before do + charge + end + + include_examples "requires API permission", "event", "write" + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(1) + + fee = json[:fees].first + expect(fee[:lago_id]).to be_nil + expect(fee[:lago_group_id]).to be_nil + expect(fee[:item][:type]).to eq("charge") + expect(fee[:item][:code]).to eq(metric.code) + expect(fee[:item][:name]).to eq(metric.name) + expect(fee[:amount_cents]).to eq("40.0") + expect(fee[:amount_currency]).to eq("EUR") + expect(fee[:units]).to eq("400.0") + expect(fee[:events_count]).to eq(1) + end + + context "with multiple events" do + let(:event2_params) do + { + code: metric.code, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: SecureRandom.uuid, + properties: { + metric.field_name => 300 + } + } + end + + let(:batch_params) { [event_params, event2_params] } + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(2) + fee1 = json[:fees].find { |f| f[:event_transaction_id] == event_params[:transaction_id] } + fee2 = json[:fees].find { |f| f[:event_transaction_id] == event2_params[:transaction_id] } + + expect(fee1[:lago_id]).to be_nil + expect(fee1[:lago_group_id]).to be_nil + expect(fee1[:item][:type]).to eq("charge") + expect(fee1[:item][:code]).to eq(metric.code) + expect(fee1[:item][:name]).to eq(metric.name) + expect(fee1[:amount_cents]).to eq("40.0") + expect(fee1[:amount_currency]).to eq("EUR") + expect(fee1[:units]).to eq("400.0") + expect(fee1[:events_count]).to eq(1) + expect(fee2[:lago_id]).to be_nil + expect(fee2[:lago_group_id]).to be_nil + expect(fee2[:item][:type]).to eq("charge") + expect(fee2[:item][:code]).to eq(metric.code) + expect(fee2[:item][:name]).to eq(metric.name) + expect(fee2[:amount_cents]).to eq("30.0") + expect(fee2[:amount_currency]).to eq("EUR") + expect(fee2[:units]).to eq("300.0") + expect(fee2[:events_count]).to eq(1) + end + end + end + + describe "POST /api/v1/events/estimate_instant_fees" do + subject do + post_with_token(organization, "/api/v1/events/estimate_instant_fees", event: event_params) + end + + let(:metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric: metric, properties: {rate: "0.1", fixed_amount: "0"}) } + + let(:event_params) do + { + code: metric.code, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: SecureRandom.uuid, + properties: { + metric.field_name => 400 + } + } + end + + before do + charge + end + + include_examples "requires API permission", "event", "write" + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(1) + + fee = json[:fees].first + expect(fee[:lago_id]).to be_nil + expect(fee[:lago_group_id]).to be_nil + expect(fee[:item][:type]).to eq("charge") + expect(fee[:item][:code]).to eq(metric.code) + expect(fee[:item][:name]).to eq(metric.name) + expect(fee[:amount_cents]).to eq("40.0") + expect(fee[:amount_currency]).to eq("EUR") + expect(fee[:units]).to eq("400.0") + expect(fee[:events_count]).to eq(1) + end + + context "with missing subscription id" do + let(:event_params) do + { + code: metric.code, + external_subscription_id: nil, + properties: { + foo: "bar" + } + } + end + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when metric code does not match an percentage charge" do + let(:charge) { create(:standard_charge, plan:, billable_metric: metric) } + + let(:event_params) do + { + code: metric.code, + external_subscription_id: subscription.external_id, + properties: { + foo: "bar" + } + } + end + + it "returns a validation error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + end +end diff --git a/spec/requests/api/v1/features/privileges_controller_spec.rb b/spec/requests/api/v1/features/privileges_controller_spec.rb new file mode 100644 index 0000000..fb4c55c --- /dev/null +++ b/spec/requests/api/v1/features/privileges_controller_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +RSpec.describe Api::V1::Features::PrivilegesController do + let(:organization) { create(:organization) } + let(:feature1) { create(:feature, organization:, code: "seats", name: "Number of seats", description: "Number of users of the account") } + let(:privilege1) { create(:privilege, feature: feature1, code: "max_admins", name: "", value_type: "integer") } + let(:privilege2) { create(:privilege, feature: feature1, code: "max", name: "Maximum", value_type: "integer") } + + before do + feature1 + privilege1 + privilege2 + end + + describe "DELETE /api/v1/features/:feature_code/privileges/:code" do + subject { delete_with_token(organization, "/api/v1/features/#{feature_code}/privileges/#{privilege_code}") } + + let(:feature_code) { feature1.code } + let(:privilege_code) { privilege1.code } + + it "discards the privilege" do + expect { subject }.to change { privilege1.reload.discarded? }.from(false).to(true) + end + + it "returns the feature without the discarded privilege" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:code]).to eq("seats") + expect(json[:feature][:privileges]).not_to include(:max_admins) + expect(json[:feature][:privileges]).to contain_exactly( + {code: "max", name: "Maximum", value_type: "integer", config: {}} + ) + end + + context "when feature does not exist" do + let(:feature_code) { "non_existent" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + + context "when privilege does not exist" do + let(:privilege_code) { "non_existent" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("privilege") + end + end + + context "when feature belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_feature) { create(:feature, organization: other_organization, code: "other_feature") } + let(:feature_code) { other_feature.code } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + + context "when privilege belongs to another feature" do + let(:other_feature) { create(:feature, organization:, code: "other_feature") } + let(:other_privilege) { create(:privilege, feature: other_feature, code: "other_privilege") } + let(:privilege_code) { other_privilege.code } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("privilege") + end + end + + context "when privilege is already discarded" do + before { privilege1.discard! } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("privilege") + end + end + end +end diff --git a/spec/requests/api/v1/features_controller_spec.rb b/spec/requests/api/v1/features_controller_spec.rb new file mode 100644 index 0000000..f3d058f --- /dev/null +++ b/spec/requests/api/v1/features_controller_spec.rb @@ -0,0 +1,503 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::FeaturesController do + let(:organization) { create(:organization) } + let(:feature1) { create(:feature, organization:, code: "seats", name: "Number of seats", description: "Number of users of the account") } + let(:feature2) { create(:feature, organization:, code: "storage", name: "Storage", description: "Storage space") } + let(:privilege1) { create(:privilege, feature: feature1, code: "max_admins", name: "", value_type: "integer") } + let(:privilege2) { create(:privilege, feature: feature1, code: "max", name: "Maximum", value_type: "integer") } + + before do + feature1 + feature2 + privilege1 + privilege2 + end + + def indexed_privileges + json[:feature][:privileges].index_by { it[:code].to_sym } + end + + describe "POST /api/v1/features" do + subject { post_with_token(organization, "/api/v1/features", params) } + + let(:params) do + { + feature: { + code: "new_feature", + name: "New Feature", + description: "A new feature", + privileges: [ + {code: "priv1", value_type: "string"}, + {code: "priv2", name: "Maximum", value_type: "integer"}, + {code: "priv3", value_type: "boolean"}, + {code: "priv4", name: "SELECT", value_type: "select", config: {select_options: %w[a b c]}} + ] + } + } + end + + it "creates a new feature with privileges" do + expect { subject }.to change(organization.features, :count).by(1) + .and change(organization.privileges, :count).by(4) + + expect(response).to have_http_status(:success) + expect(json[:feature][:code]).to eq("new_feature") + expect(json[:feature][:name]).to eq("New Feature") + expect(json[:feature][:description]).to eq("A new feature") + expect(json[:feature][:privileges]).to contain_exactly( + {code: "priv1", name: nil, value_type: "string", config: {}}, + {code: "priv2", name: "Maximum", value_type: "integer", config: {}}, + {code: "priv3", name: nil, value_type: "boolean", config: {}}, + {code: "priv4", name: "SELECT", value_type: "select", config: {select_options: %w[a b c]}} + ) + end + + context "when feature code already exists" do + let(:params) do + { + feature: { + code: "seats", # Already exists + name: "New Feature", + description: "A new feature" + } + } + end + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to include("validation_errors") + end + end + + context "when feature code is missing" do + let(:params) do + { + feature: { + name: "New Feature", + description: "A new feature" + } + } + end + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to include("validation_errors") + end + end + + context "when feature code is empty string" do + let(:params) do + { + feature: { + code: "", + name: "New Feature", + description: "A new feature" + } + } + end + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to include("validation_errors") + end + end + + context "when privilege value_type is invalid" do + let(:params) do + { + feature: { + code: "new_feature", + privileges: [ + {code: "max_admins", value_type: "invalid_type"} + ] + } + } + end + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:"privilege.value_type"]).to eq ["value_is_invalid"] + end + end + + context "when privilege code is empty string" do + let(:params) do + { + feature: { + code: "test", + name: "New Feature", + description: "A new feature", + privileges: [{ + code: " " + }] + } + } + end + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to include("validation_errors") + end + end + + context "when feature has no privileges" do + let(:params) do + { + feature: { + code: "new_feature", + name: "New Feature", + description: "A new feature" + } + } + end + + it "creates a feature without privileges" do + expect { subject }.to change(Entitlement::Feature, :count).by(1).and(not_change(Entitlement::Privilege, :count)) + + expect(response).to have_http_status(:success) + expect(json[:feature][:code]).to eq("new_feature") + expect(json[:feature][:privileges]).to eq([]) + end + end + + context "when feature name and description are optional" do + let(:params) do + { + feature: { + code: "new_feature", + privileges: [ + {code: "max_admins", value_type: "integer"} + ] + } + } + end + + it "creates a feature with only required attributes" do + expect { subject }.to change(Entitlement::Feature, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:feature][:code]).to eq("new_feature") + expect(json[:feature][:name]).to be_nil + expect(json[:feature][:description]).to be_nil + end + end + end + + describe "GET /api/v1/features" do + subject { get_with_token(organization, "/api/v1/features", params) } + + let(:params) { {} } + + it "returns a paginated list of features" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:features].length).to eq(2) + + feature_response = json[:features].find { |f| f[:code] == "seats" } + expect(feature_response).to include( + code: "seats", + name: "Number of seats", + description: "Number of users of the account" + ) + + expect(feature_response[:privileges]).to contain_exactly( + {code: "max_admins", name: "", value_type: "integer", config: {}}, + {code: "max", name: "Maximum", value_type: "integer", config: {}} + ) + end + + it "includes pagination metadata" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:meta]).to include(:current_page, :total_pages, :total_count) + end + + it "only returns features for the current organization" do + other_organization = create(:organization) + create(:feature, organization: other_organization, code: "other_feature") + + subject + + expect(response).to have_http_status(:ok) + feature_codes = json[:features].map { |f| f[:code] } + expect(feature_codes).not_to include("other_feature") + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + it "returns features with correct meta data" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:features].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + + context "with search_term" do + let(:params) { {search_term: "sto"} } + + it "returns features matching the search term" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:features].count).to eq(1) + expect(json[:features].first[:code]).to eq("storage") + end + end + end + + describe "GET /api/v1/features/:code" do + subject { get_with_token(organization, "/api/v1/features/#{feature_code}") } + + let(:feature_code) { feature1.code } + + it "returns a feature" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:code]).to eq("seats") + expect(json[:feature][:name]).to eq("Number of seats") + expect(json[:feature][:description]).to eq("Number of users of the account") + expect(json[:feature][:privileges]).to contain_exactly( + {code: "max_admins", name: "", value_type: "integer", config: {}}, + {code: "max", name: "Maximum", value_type: "integer", config: {}} + ) + end + + context "when feature does not exist" do + let(:feature_code) { "non_existent" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + + context "when feature is deleted" do + before { feature1.discard! } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + end + + describe "PATCH /api/v1/features/:code" do + subject { patch_with_token(organization, "/api/v1/features/#{feature_code}", params) } + + let(:feature_code) { feature1.code } + let(:params) do + { + feature: { + name: "Updated Feature Name", + description: "Updated feature description", + privileges: [ + {code: "max", name: "Max."} + ] + } + } + end + + it "updates the feature and privilege attributes" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:name]).to eq("Updated Feature Name") + expect(json[:feature][:description]).to eq("Updated feature description") + expect(indexed_privileges[:max][:name]).to eq("Max.") + expect(indexed_privileges[:max_admins][:name]).to eq("") # unchanged + end + + it "only updates provided attributes" do + original_name = feature1.name + params[:feature].delete(:name) + + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:name]).to eq(original_name) + expect(json[:feature][:description]).to eq("Updated feature description") + end + + it "creates privilege non existent privilege" do + params[:feature][:privileges] << {code: "nonexistent", name: "New Name"} + + subject + + expect(response).to have_http_status(:ok) + expect(indexed_privileges[:max][:name]).to eq("Max.") + expect(indexed_privileges[:nonexistent][:name]).to eq("New Name") + end + + context "when updating only feature attributes" do + let(:params) do + { + feature: { + name: "Updated Feature Name", + description: "Updated feature description" + } + } + end + + it "updates only feature attributes" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:name]).to eq("Updated Feature Name") + expect(json[:feature][:description]).to eq("Updated feature description") + expect(indexed_privileges[:max][:name]).to eq("Maximum") # unchanged + end + end + + context "when updating only privilege names" do + let(:params) do + { + feature: { + privileges: [ + {code: "max", name: "Max."}, + {code: "max_admins", name: "Max Admins"} + ] + } + } + end + + it "updates only privilege names" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:name]).to eq("Number of seats") # unchanged + expect(json[:feature][:description]).to eq("Number of users of the account") # unchanged + expect(indexed_privileges[:max][:name]).to eq("Max.") + expect(indexed_privileges[:max_admins][:name]).to eq("Max Admins") + end + end + + context "when feature does not exist" do + let(:feature_code) { "non_existent" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + + context "when feature belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_feature) { create(:feature, organization: other_organization, code: "other_feature") } + let(:feature_code) { other_feature.code } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + + context "when privilege name is empty" do + let(:params) do + { + feature: { + privileges: [ + {code: "max", name: ""} # Empty name is allowed + ] + } + } + end + + it "updates the privilege name to empty string" do + subject + + expect(response).to have_http_status(:ok) + expect(indexed_privileges[:max][:name]).to eq("") + end + end + + context "when feature name is empty" do + let(:params) do + { + feature: { + name: "" # Empty name is allowed + } + } + end + + it "updates the feature name to empty string" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:name]).to eq("") + end + end + end + + describe "DELETE /api/v1/features/:code" do + subject { delete_with_token(organization, "/api/v1/features/#{feature_code}") } + + let(:feature_code) { feature1.code } + + it "discards the feature" do + expect { subject }.to change { feature1.reload.discarded? }.from(false).to(true) + end + + it "discards all privileges associated with the feature" do + expect { subject }.to change { Entitlement::Privilege.kept.count }.by(-2) + end + + it "returns the discarded feature" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:feature][:code]).to eq("seats") + expect(json[:feature][:name]).to eq("Number of seats") + expect(json[:feature][:description]).to eq("Number of users of the account") + end + + context "when feature does not exist" do + let(:feature_code) { "non_existent" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + + context "when feature belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_feature) { create(:feature, organization: other_organization, code: "other_feature") } + let(:feature_code) { other_feature.code } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + + context "when feature is already discarded" do + before { feature1.discard! } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("feature") + end + end + end +end diff --git a/spec/requests/api/v1/fees_controller_spec.rb b/spec/requests/api/v1/fees_controller_spec.rb new file mode 100644 index 0000000..bd78980 --- /dev/null +++ b/spec/requests/api/v1/fees_controller_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::FeesController do + let(:organization) { create(:organization) } + + describe "GET /api/v1/fees/:id" do + subject { get_with_token(organization, "/api/v1/fees/#{fee_id}") } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:fee) { create(:fee, subscription:, invoice: nil) } + let(:fee_id) { fee.id } + + include_examples "requires API permission", "fee", "read" + + it "returns a fee" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fee]).to include( + lago_id: fee.id, + amount_cents: fee.amount_cents, + amount_currency: fee.amount_currency, + taxes_amount_cents: fee.taxes_amount_cents, + units: fee.units.to_s, + events_count: fee.events_count, + applied_taxes: [], + self_billed: false + ) + expect(json[:fee][:item]).to include( + type: fee.fee_type, + code: fee.item_code, + name: fee.item_name + ) + end + + context "when fee is an add-on fee" do + let(:invoice) { create(:invoice, organization:) } + let(:fee) { create(:add_on_fee, invoice:) } + + it "returns a fee" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fee]).to include( + lago_id: fee.id, + amount_cents: fee.amount_cents, + amount_currency: fee.amount_currency, + taxes_amount_cents: fee.taxes_amount_cents, + units: fee.units.to_s, + events_count: fee.events_count, + applied_taxes: [], + self_billed: invoice.self_billed + ) + expect(json[:fee][:item]).to include( + type: fee.fee_type, + code: fee.item_code, + name: fee.item_name + ) + end + end + + context "when fee does not exist" do + let(:fee_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when fee belongs to an other organization" do + let(:fee) { create(:fee) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "PUT /api/v1/fees/:id" do + subject { put_with_token(organization, "/api/v1/fees/#{fee_id}", fee: update_params) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:update_params) { {payment_status: "succeeded"} } + let(:fee_id) { fee.id } + + let(:fee) do + create(:charge_fee, fee_type: "charge", pay_in_advance: true, subscription:, invoice: nil) + end + + include_examples "requires API permission", "fee", "write" + + it "updates the fee" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fee]).to include( + lago_id: fee.reload.id, + amount_cents: fee.amount_cents, + amount_currency: fee.amount_currency, + taxes_amount_cents: fee.taxes_amount_cents, + units: fee.units.to_s, + events_count: fee.events_count, + payment_status: fee.payment_status, + created_at: fee.created_at&.iso8601, + succeeded_at: fee.succeeded_at&.iso8601, + failed_at: fee.failed_at&.iso8601, + refunded_at: fee.refunded_at&.iso8601, + amount_details: fee.amount_details, + applied_taxes: [] + ) + expect(json[:fee][:item]).to include( + type: fee.fee_type, + code: fee.item_code, + name: fee.item_name + ) + end + + context "when fee does not exist" do + let(:fee_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1/fees/:id" do + subject { delete_with_token(organization, "/api/v1/fees/#{fee_id}") } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:update_params) { {payment_status: "succeeded"} } + let(:fee_id) { fee.id } + + context "when fee exists" do + let(:fee) do + create(:charge_fee, fee_type: "charge", pay_in_advance: true, subscription:, invoice:) + end + let(:invoice) { nil } + + include_examples "requires API permission", "fee", "write" + + context "when fee does not attached to an invoice" do + it "deletes the fee" do + subject + expect(response).to have_http_status(:ok) + end + end + + context "when fee is attached to an invoice" do + let(:invoice) { create(:invoice, organization:, customer:) } + + it "dont delete the fee" do + subject + expect(response).to have_http_status(:method_not_allowed) + end + end + end + + context "when fee does not exist" do + let(:fee_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/fees" do + subject { get_with_token(organization, "/api/v1/fees", params) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let!(:fee) { create(:fee, subscription:, invoice: nil) } + + context "without params" do + let(:params) { {} } + + include_examples "requires API permission", "fee", "read" + + it "returns a list of fees" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:fees].count).to eq(1) + expect(json[:fees].first[:lago_id]).to eq(fee.id) + end + end + + context "with an invalid filter" do + let(:params) { {fee_type: "invalid_filter"} } + + it "returns an error response" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({fee_type: %w[value_is_invalid]}) + end + end + end +end diff --git a/spec/requests/api/v1/invoices_controller_spec.rb b/spec/requests/api/v1/invoices_controller_spec.rb new file mode 100644 index 0000000..4a61f1f --- /dev/null +++ b/spec/requests/api/v1/invoices_controller_spec.rb @@ -0,0 +1,1430 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::InvoicesController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + + before { tax } + + describe "POST /api/v1/invoices" do + subject { post_with_token(organization, "/api/v1/invoices", {invoice: create_params}) } + + let(:add_on_first) { create(:add_on, code: "first", organization:) } + let(:add_on_second) { create(:add_on, code: "second", amount_cents: 400, organization:) } + let(:customer_external_id) { customer.external_id } + let(:invoice_display_name) { "Invoice item #1" } + let(:create_params) do + { + external_customer_id: customer_external_id, + currency: "EUR", + fees: [ + { + add_on_code: add_on_first.code, + invoice_display_name:, + unit_amount_cents: 1200, + units: 2, + description: "desc-123", + tax_codes: [tax.code] + }, + { + add_on_code: add_on_second.code + } + ], + invoice_custom_section: {invoice_custom_section_codes: [section_1.code]} + } + end + + include_examples "requires API permission", "invoice", "write" + + it "creates an invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + lago_id: String, + issuing_date: Time.current.to_date.to_s, + invoice_type: "one_off", + fees_amount_cents: 2800, + taxes_amount_cents: 560, + total_amount_cents: 3360, + currency: "EUR" + ) + + fee = json[:invoice][:fees].find { |f| f[:item][:code] == "first" } + + expect(fee[:item][:invoice_display_name]).to eq(invoice_display_name) + expect(json[:invoice][:applied_taxes][0][:tax_code]).to eq(tax.code) + expect(json[:invoice][:applied_invoice_custom_sections].size).to eq(1) + expect(json[:invoice][:applied_invoice_custom_sections].first[:code]).to eq(section_1.code) + end + + context "when customer does not exist" do + let(:customer_external_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when add_on does not exist" do + let(:create_params) do + { + external_customer_id: customer_external_id, + currency: "EUR", + fees: [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123" + }, + { + add_on_code: "invalid" + } + ] + } + end + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when skip_psp is true" do + let(:create_params) do + { + external_customer_id: customer_external_id, + currency: "EUR", + skip_psp: true, + fees: [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2 + } + ] + } + end + + it "returns a success" do + subject + expect(response).to have_http_status(:success) + end + end + + context "when multi_entity_billing feature flag is enabled" do + let(:other_billing_entity) { create(:billing_entity, organization:) } + + before do + organization.enable_feature_flag!(:multi_entity_billing) + create(:tax, :applied_to_billing_entity, billing_entity: other_billing_entity, organization:, rate: 20) + end + + context "with a known billing_entity_code" do + let(:create_params) do + { + external_customer_id: customer_external_id, + currency: "EUR", + billing_entity_code: other_billing_entity.code, + fees: [{add_on_code: add_on_first.code, unit_amount_cents: 1200, units: 2}] + } + end + + it "stamps the invoice with the resolved billing entity" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:billing_entity_code]).to eq(other_billing_entity.code) + end + end + + context "with an unknown billing_entity_code" do + let(:create_params) do + { + external_customer_id: customer_external_id, + currency: "EUR", + billing_entity_code: "unknown_code", + fees: [{add_on_code: add_on_first.code, unit_amount_cents: 1200, units: 2}] + } + end + + it "returns a not found error" do + subject + + expect(response).to be_not_found_error("billing_entity") + end + end + + context "without billing_entity_code" do + let(:create_params) do + { + external_customer_id: customer_external_id, + currency: "EUR", + fees: [{add_on_code: add_on_first.code, unit_amount_cents: 1200, units: 2}] + } + end + + it "stamps the invoice with the customer's billing entity" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:billing_entity_code]).to eq(customer.billing_entity.code) + end + end + end + + context "when multi_entity_billing feature flag is disabled" do + let(:other_billing_entity) { create(:billing_entity, organization:) } + let(:create_params) do + { + external_customer_id: customer_external_id, + currency: "EUR", + billing_entity_code: other_billing_entity.code, + fees: [{add_on_code: add_on_first.code, unit_amount_cents: 1200, units: 2}] + } + end + + it "ignores billing_entity_code and falls back to the customer's billing entity" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:billing_entity_code]).to eq(customer.billing_entity.code) + end + end + end + + describe "PUT /api/v1/invoices/:id" do + subject do + put_with_token(organization, "/api/v1/invoices/#{invoice_id}", {invoice: update_params}) + end + + let(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_id) { invoice.id } + + let(:update_params) do + {payment_status: "succeeded"} + end + + include_examples "requires API permission", "invoice", "write" + + it "updates an invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + expect(json[:invoice][:payment_status]).to eq("succeeded") + end + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + + context "with metadata" do + let(:update_params) do + { + metadata: [ + { + key: "Hello", + value: "Hi" + } + ] + } + end + + it "returns a success" do + subject + + metadata = json[:invoice][:metadata] + expect(response).to have_http_status(:success) + + expect(json[:invoice][:lago_id]).to eq(invoice.id) + + expect(metadata).to be_present + expect(metadata.first[:key]).to eq("Hello") + expect(metadata.first[:value]).to eq("Hi") + end + end + end + + describe "GET /api/v1/invoices/:id" do + subject { get_with_token(organization, "/api/v1/invoices/#{invoice_id}") } + + let(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_id) { invoice.id } + + include_examples "requires API permission", "invoice", "read" + + it "returns an invoice" do + charge_filter = create(:charge_filter) + create(:fee, invoice_id: invoice.id, charge_filter:) + + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + lago_id: invoice.id, + payment_status: invoice.payment_status, + status: invoice.status, + prepaid_credit_amount_cents: 0, + prepaid_granted_credit_amount_cents: nil, + prepaid_purchased_credit_amount_cents: nil, + customer: Hash, + subscriptions: [], + credits: [], + applied_taxes: [], + applied_invoice_custom_sections: [] + ) + expect(json[:invoice][:fees].first).to include(lago_charge_filter_id: charge_filter.id) + end + + context "when customer has an integration customer" do + let!(:netsuite_customer) { create(:netsuite_customer, customer:) } + + it "returns an invoice with customer having integration customers" do + subject + + expect(json[:invoice][:customer][:integration_customers].first).to include(lago_id: netsuite_customer.id) + end + end + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoices belongs to an other organization" do + let(:invoice) { create(:invoice) } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoice has a fee for a deleted billable metric" do + let(:billable_metric) { create(:billable_metric, :deleted) } + let(:billable_metric_filter) { create(:billable_metric_filter, :deleted, billable_metric:) } + let(:charge_filter) do + create(:charge_filter, :deleted, charge:, properties: {amount: "10"}) + end + let(:charge_filter_value) do + create( + :charge_filter_value, + :deleted, + charge_filter:, + billable_metric_filter:, + values: [billable_metric_filter.values.first] + ) + end + let(:fee) { create(:charge_fee, invoice:, charge_filter:, charge:) } + + let(:charge) do + create(:standard_charge, :deleted, billable_metric:) + end + + before do + charge + fee + charge_filter_value + end + + it "returns the invoice with the deleted resources" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + lago_id: invoice.id, + payment_status: invoice.payment_status, + status: invoice.status, + customer: Hash, + subscriptions: [], + credits: [], + applied_taxes: [] + ) + + json_fee = json[:invoice][:fees].first + expect(json_fee[:lago_charge_filter_id]).to eq(charge_filter.id) + expect(json_fee[:item]).to include( + type: "charge", + code: billable_metric.code, + name: billable_metric.name + ) + end + end + end + + describe "GET /api/v1/invoices" do + it_behaves_like "an invoice index endpoint" do + subject { get_with_token(organization, "/api/v1/invoices", params) } + + [:external_customer_id, :customer_external_id].each do |param_name| + context "with #{param_name} params" do + let(:params) { {param_name => external_customer_id} } + + let!(:matching_invoice) { create(:invoice, customer:, organization:) } + let(:external_customer_id) { customer.external_id } + + before do + another_customer = create(:customer, organization:) + create(:invoice, customer: another_customer, organization:) + end + + it "returns invoices of the customer" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + + context "with deleted customer" do + let(:params) { {external_customer_id:} } + let(:customer) { create(:customer, :deleted, organization:) } + let(:external_customer_id) { customer.external_id } + let!(:matching_invoice) { create(:invoice, customer:, organization:) } + + it "returns the invoices of the customer" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + expect(json[:invoices].first[:customer][:lago_id]).to eq(customer.id) + end + end + end + end + end + + context "with N+1 query detection on customer associations", bullet: {n_plus_one_query: true, unused_eager_loading: false} do + let(:other_billing_entity) { create(:billing_entity, organization:) } + + before do + [customer.billing_entity, other_billing_entity].each do |billing_entity| + invoice_customer = create( + :customer, + organization:, + billing_entity:, + payment_provider: "stripe", + payment_provider_code: "stripe_code" + ) + create(:stripe_customer, customer: invoice_customer) + create(:netsuite_customer, customer: invoice_customer) + create(:hubspot_customer, customer: invoice_customer) + create(:customer_metadata, customer: invoice_customer, organization:) + + create(:invoice, customer: invoice_customer, organization:, billing_entity:) + end + end + + it "does not trigger N+1 queries on customer and nested associations" do + get_with_token(organization, "/api/v1/invoices", {}) + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(2) + json[:invoices].each do |invoice| + expect(invoice[:customer][:billing_configuration][:provider_customer_id]).to be_present + expect(invoice[:customer][:integration_customers]).to be_present + expect(invoice[:customer][:metadata]).to be_present + end + end + end + + context "with unknown params" do + before do + allow(Rails).to receive(:cache).and_return(ActiveSupport::Cache::MemoryStore.new) + create(:invoice, :draft, customer:, organization:) + create(:invoice, customer:, organization:) + end + + it "ignores unknown params for caching" do + # First request populates the cache + get_with_token(organization, "/api/v1/invoices", page: 1, per_page: 1) + expect(json[:meta][:total_count]).to eq(2) + + # Add a third invoice + create(:invoice, customer:, organization:) + + # Request with unknown param should return cached count (2), not fresh count (3) + get_with_token(organization, "/api/v1/invoices", page: 1, per_page: 1, unknown_param: "value") + expect(json[:meta][:total_count]).to eq(2) + end + end + end + + describe "PUT /api/v1/invoices/:id/refresh" do + subject { put_with_token(organization, "/api/v1/invoices/#{invoice_id}/refresh") } + + let(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_id) { invoice.id } + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoice is draft" do + let(:invoice) { create(:invoice, :draft, customer:, organization:) } + + include_examples "requires API permission", "invoice", "write" + + it "updates the invoice" do + expect { subject }.to change { invoice.reload.updated_at } + end + + it "returns the invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + end + end + + context "when invoice is finalized" do + let(:invoice) { create(:invoice, customer:, organization:) } + + it "does not update the invoice" do + expect { subject }.not_to change { invoice.reload.updated_at } + end + + it "returns the invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + end + end + end + + describe "PUT /api/v1/invoices/:id/finalize" do + subject { put_with_token(organization, "/api/v1/invoices/#{invoice_id}/finalize") } + + let(:invoice) { create(:invoice, :draft, customer:, organization:) } + let(:invoice_id) { invoice.id } + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoice is not draft" do + let(:invoice) { create(:invoice, customer:, status: :finalized, organization:) } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoice is draft" do + include_examples "requires API permission", "invoice", "write" + + it "finalizes the invoice" do + expect { subject }.to change { invoice.reload.status }.from("draft").to("finalized") + end + + it "returns the invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + end + end + end + + describe "POST /api/v1/invoices/:id/void" do + subject { post_with_token(organization, "/api/v1/invoices/#{invoice_id}/void", params) } + + let!(:invoice) { create(:invoice, status:, payment_status:, customer:, organization:) } + let(:invoice_id) { invoice.id } + let(:status) { :finalized } + let(:payment_status) { :pending } + let(:params) { {} } + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoice is draft" do + let(:status) { :draft } + + it "returns a method not allowed error" do + subject + expect(response).to have_http_status(:method_not_allowed) + end + end + + context "when invoice is voided" do + let(:status) { :voided } + + it "returns a method not allowed error" do + subject + expect(response).to have_http_status(:method_not_allowed) + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + context "when the payment status is succeeded" do + let(:payment_status) { :succeeded } + + it "voids the invoice" do + expect { subject }.to change { invoice.reload.status }.from("finalized").to("voided") + end + end + + context "when the payment status is not succeeded" do + let(:payment_status) { [:pending, :failed].sample } + + include_examples "requires API permission", "invoice", "write" + + it "voids the invoice" do + expect { subject }.to change { invoice.reload.status }.from("finalized").to("voided") + end + + it "returns the invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + end + end + end + + context "when passing credit note parameters", :premium do + let(:credit_amount) { 0 } + let(:refund_amount) { 0 } + let(:params) { {generate_credit_note: true, credit_amount: credit_amount, refund_amount: refund_amount} } + + it "calls the void service with all parameters" do + allow(Invoices::VoidService).to receive(:call).with( + invoice: instance_of(Invoice), + params: hash_including( + generate_credit_note: true, + credit_amount: credit_amount, + refund_amount: refund_amount + ) + ).and_call_original + + subject + + expect(Invoices::VoidService).to have_received(:call).with( + invoice: instance_of(Invoice), + params: hash_including( + generate_credit_note: true, + credit_amount: credit_amount, + refund_amount: refund_amount + ) + ) + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + expect(json[:invoice][:status]).to eq("voided") + expect(json[:invoice][:voided_at]).not_to be_nil + end + end + end + + describe "POST /api/v1/invoices/:id/lose_dispute" do + subject { post_with_token(organization, "/api/v1/invoices/#{invoice_id}/lose_dispute") } + + let(:invoice) { create(:invoice, status:, customer:, organization:) } + let(:invoice_id) { invoice.id } + let(:status) { :draft } + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoice exists" do + let(:invoice) { create(:invoice, customer:, organization:, status:) } + + context "when invoice is finalized" do + let(:status) { :finalized } + + include_examples "requires API permission", "invoice", "write" + + it "marks the dispute as lost" do + expect { subject }.to change { invoice.reload.payment_dispute_lost_at }.from(nil) + end + + it "returns the invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + end + end + + context "when invoice is voided" do + let(:status) { :voided } + + it "marks the dispute as lost" do + expect { subject }.to change { invoice.reload.payment_dispute_lost_at }.from(nil) + end + + it "returns the invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice][:lago_id]).to eq(invoice.id) + end + end + + context "when invoice is draft" do + let(:status) { :draft } + + it "returns method not allowed error" do + subject + expect(response).to have_http_status(:method_not_allowed) + end + end + + context "when invoice is generating" do + let(:status) { :generating } + + it "returns not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + end + + describe "POST /api/v1/invoices/:id/download_pdf" do + ["download", "download_pdf"].each do |route| + subject { post_with_token(organization, "/api/v1/invoices/#{invoice_id}/#{route}") } + + let(:invoice) { create(:invoice, customer:, organization:, status: invoice_status) } + let(:invoice_status) { :finalized } + let(:invoice_id) { invoice.id } + + include_examples "requires API permission", "invoice", "write" + + context "with /#{route}" do + context "without generated pdf" do + before do + allow(Invoices::GeneratePdfJob).to receive(:perform_later) + end + + it "calls generate pdf async" do + subject + + expect(Invoices::GeneratePdfJob).to have_received(:perform_later) + end + end + + context "when generated pdf" do + before do + allow(Invoices::GeneratePdfJob).to receive(:perform_later) + + invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + end + + it "does not regenerate" do + subject + + expect(Invoices::GeneratePdfJob).not_to have_received(:perform_later) + end + end + + context "when invoice is draft" do + let(:invoice_status) { :draft } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + end + end + + describe "POST /api/v1/invoices/:id/download_xml" do + subject { post_with_token(organization, "/api/v1/invoices/#{invoice_id}/download_xml") } + + let(:invoice) { create(:invoice, customer:, organization:, status: invoice_status) } + let(:invoice_status) { :finalized } + let(:invoice_id) { invoice.id } + + include_examples "requires API permission", "invoice", "write" + + context "without generated pdf" do + before do + allow(Invoices::GenerateXmlJob).to receive(:perform_later) + end + + it "calls generate pdf async" do + subject + + expect(Invoices::GenerateXmlJob).to have_received(:perform_later) + end + end + + context "with generated pdf" do + before do + allow(Invoices::GenerateXmlJob).to receive(:perform_later) + + invoice.xml_file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.xml"))), + filename: "invoice.xml", + content_type: "application/xml" + ) + end + + it "does not regenerate" do + subject + + expect(Invoices::GenerateXmlJob).not_to have_received(:perform_later) + end + end + + context "when invoice is draft" do + let(:invoice_status) { :draft } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "POST /api/v1/invoices/:id/retry_payment" do + subject { post_with_token(organization, "/api/v1/invoices/#{invoice_id}/retry_payment", payment_params) } + + let(:payment_params) { {} } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_id) { invoice.id } + let(:retry_service) { instance_double(Invoices::Payments::RetryService) } + + before do + allow(Invoices::Payments::RetryService).to receive(:new).and_return(retry_service) + allow(retry_service).to receive(:call).and_return(BaseService::Result.new) + end + + include_examples "requires API permission", "invoice", "write" + + it "calls retry service" do + subject + + expect(response).to have_http_status(:success) + expect(retry_service).to have_received(:call) + end + + context "with payment method" do + let(:payment_params) do + { + payment_method: { + payment_method_type: "manual" + } + } + end + + it "calls retry service" do + subject + + aggregate_failures do + expect(response).to have_http_status(:success) + expect(retry_service).to have_received(:call) + end + end + end + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoices belongs to an other organization" do + let(:invoice) { create(:invoice) } + + it "returns not found" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + describe "POST /api/v1/invoices/:id/retry" do + subject { post_with_token(organization, "/api/v1/invoices/#{invoice_id}/retry") } + + let!(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_id) { invoice.id } + let(:retry_service) { instance_double(Invoices::RetryService) } + let(:result) { BaseService::Result.new } + + before do + result.invoice = invoice + + allow(Invoices::RetryService).to receive(:new).and_return(retry_service) + allow(retry_service).to receive(:call).and_return(result) + end + + include_examples "requires API permission", "invoice", "write" + + it "calls retry service" do + subject + + expect(response).to have_http_status(:success) + expect(retry_service).to have_received(:call) + end + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when invoices belongs to an other organization" do + let(:invoice) { create(:invoice) } + + it "returns not found" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + describe "PUT /api/v1/invoices/:id/sync_salesforce_id" do + subject { put_with_token(organization, "/api/v1/invoices/#{invoice_id}/sync_salesforce_id") } + + let!(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_id) { invoice.id } + let(:result) { BaseService::Result.new } + + before do + result.invoice = invoice + allow(Invoices::SyncSalesforceIdService).to receive(:call).and_return(result) + end + + context "when invoice exists" do + include_examples "requires API permission", "invoice", "write" + + it "calls sync salesforce id service" do + subject + + expect(response).to have_http_status(:success) + expect(Invoices::SyncSalesforceIdService).to have_received(:call) + end + end + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "POST /api/v1/invoices/:id/payment_url" do + subject { post_with_token(organization, "/api/v1/invoices/#{invoice_id}/payment_url") } + + let!(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_id) { invoice.id } + let(:organization) { create(:organization) } + let(:stripe_provider) { create(:stripe_provider, organization:, code:) } + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + let(:code) { "stripe_1" } + + before do + create( + :stripe_customer, + customer_id: customer.id, + payment_provider: stripe_provider + ) + + customer.update!(payment_provider: "stripe") + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example.com"}) + end + + context "when invoice exists" do + include_examples "requires API permission", "invoice", "write" + + it "returns the generated payment url" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice_payment_details][:payment_url]).to eq("https://example.com") + end + end + + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "POST /api/v1/invoices/preview", :premium do + subject { post_with_token(organization, "/api/v1/invoices/preview", preview_params) } + + let(:plan) { create(:plan, organization:) } + let(:preview_params) do + { + customer: { + name: "test 1", + currency: "EUR", + tax_identification_number: "123456789" + }, + plan_code: plan.code, + billing_time: "anniversary" + } + end + + before { organization.update!(premium_integrations: ["preview"]) } + + it "creates a preview invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + billing_entity_code: organization.default_billing_entity.code, + invoice_type: "subscription", + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120, + currency: "EUR" + ) + end + + context "with exact time" do + let(:timestamp) { Time.zone.parse("15 Mar 2024") } + + it "creates a preview invoice" do + travel_to(timestamp) do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + billing_entity_code: organization.default_billing_entity.code, + invoice_type: "subscription", + issuing_date: "2024-04-15", + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120, + currency: "EUR" + ) + end + end + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan:, units: 2, charge_model: "standard", properties: {amount: "10"}) } + + before { fixed_charge } + + it "creates a preview invoice with fixed charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + fees_amount_cents: 2100, + taxes_amount_cents: 420, + total_amount_cents: 2520, + currency: "EUR" + ) + expect(json[:invoice][:fees]).to include( + hash_including( + item: hash_including( + type: "fixed_charge" + ), + amount_cents: 2000, + units: "2.0" + ) + ) + end + end + + context "when sending billing_entity_code" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:applied_tax) { create(:billing_entity_applied_tax, billing_entity:, tax:) } + let(:preview_params) do + { + customer: { + name: "test 1", + currency: "EUR", + tax_identification_number: "123456789" + }, + plan_code: plan.code, + billing_time: "anniversary", + billing_entity_code: billing_entity.code + } + end + + before { applied_tax } + + it "creates a preview invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + billing_entity_code: billing_entity.code, + invoice_type: "subscription", + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120, + currency: "EUR" + ) + end + + context "when billing entity does not exist" do + let(:preview_params) do + { + customer: { + name: "test 1", + currency: "EUR", + tax_identification_number: "123456789" + }, + plan_code: plan.code, + billing_time: "anniversary", + billing_entity_code: SecureRandom.uuid + } + end + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + context "when subscriptions are persisted" do + let(:customer) { create(:customer, organization:, external_id: "123456789") } + let(:subscription1) { create(:subscription, customer:, billing_time: "anniversary", subscription_at: Time.current) } + let(:subscription2) { create(:subscription, customer:, billing_time: "anniversary", subscription_at: Time.current) } + let(:preview_params) do + { + customer: { + external_id: "123456789" + }, + subscriptions: { + external_ids: [subscription1.external_id, subscription2.external_id] + } + } + end + + it "creates a preview invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + invoice_type: "subscription", + fees_amount_cents: 200, + taxes_amount_cents: 40, + total_amount_cents: 240, + currency: "EUR" + ) + end + + context "with exact time" do + let(:timestamp) { Time.zone.parse("15 Mar 2024") } + let(:subscription1) do + create( + :subscription, + customer:, + billing_time: "anniversary", + subscription_at: timestamp - 1.month - 5.days, + started_at: timestamp - 1.month - 5.days + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + billing_time: "anniversary", + subscription_at: timestamp - 1.month - 5.days, + started_at: timestamp - 1.month - 5.days + ) + end + + it "creates a preview invoice" do + travel_to(timestamp) do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + invoice_type: "subscription", + issuing_date: "2024-04-10", + fees_amount_cents: 200, + taxes_amount_cents: 40, + total_amount_cents: 240, + currency: "EUR" + ) + end + end + end + + context "when subscription's plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: subscription1.plan, units: 2, charge_model: "standard", properties: {amount: "10"}) } + let(:fixed_charge_event) { create(:fixed_charge_event, subscription: subscription1, fixed_charge:, units: 2) } + + before { fixed_charge_event } + + it "creates a preview invoice with fixed charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + fees_amount_cents: 2200, + taxes_amount_cents: 440, + total_amount_cents: 2640, + currency: "EUR" + ) + expect(json[:invoice][:fees]).to include( + hash_including( + item: hash_including( + type: "fixed_charge" + ), + amount_cents: 2000, + units: "2.0" + ) + ) + end + end + end + + context "when subscriptions are persisted but only one belongs to the customer" do + let(:customer) { create(:customer, organization:, external_id: "123456789") } + let(:subscription1) { create(:subscription, billing_time: "anniversary", subscription_at: Time.current) } + let(:subscription2) { create(:subscription, customer:, billing_time: "anniversary", subscription_at: Time.current) } + let(:preview_params) do + { + customer: { + external_id: "123456789" + }, + subscriptions: { + external_ids: [subscription1.external_id, subscription2.external_id] + } + } + end + + it "creates a preview invoice" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + invoice_type: "subscription", + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120, + currency: "EUR" + ) + end + end + + context "when subscriptions do not belong to the customer" do + let(:customer) { create(:customer, organization:, external_id: "123456789") } + let(:subscription1) { create(:subscription, billing_time: "anniversary", subscription_at: Time.current) } + let(:subscription2) { create(:subscription, billing_time: "anniversary", subscription_at: Time.current) } + let(:preview_params) do + { + customer: { + external_id: "123456789" + }, + subscriptions: { + external_ids: [subscription1.external_id, subscription2.external_id] + } + } + end + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when customer does not exist" do + let(:preview_params) do + { + customer: { + external_id: "unknown" + }, + plan_code: plan.code + } + end + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when coupons have invalid type" do + let(:preview_params) do + { + coupons: { + code: "unknown" + } + } + end + + it "returns a bad request error" do + subject + expect(response).to have_http_status(:bad_request) + expect(json[:error]).to eq "coupons_must_be_an_array" + end + end + + context "when subscriptions have invalid type" do + let(:preview_params) do + { + subscriptions: [] + } + end + + it "returns a bad request error" do + subject + expect(response).to have_http_status(:bad_request) + expect(json[:error]).to eq "subscriptions_must_be_an_object" + end + end + + context "with pending subscription starting in the future" do + let(:timestamp) { Time.zone.parse("15 Mar 2024") } + let(:future_start) { Time.zone.parse("1 Apr 2024") } + let(:customer) { create(:customer, organization:, external_id: "pending_customer") } + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: false) } + let(:pending_subscription) do + create( + :subscription, + customer:, + plan:, + status: :pending, + subscription_at: future_start, + billing_time: "calendar" + ) + end + let(:preview_params) do + { + customer: { + external_id: customer.external_id + }, + subscriptions: { + external_ids: [pending_subscription.external_id] + } + } + end + + before { pending_subscription } + + it "creates preview invoice for pending subscription with arrears billing" do + travel_to(timestamp) do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + invoice_type: "subscription", + issuing_date: "2024-05-01", + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120 + ) + end + end + + context "with in advance billing" do + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + + it "creates preview invoice for pending subscription" do + travel_to(timestamp) do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + invoice_type: "subscription", + issuing_date: "2024-04-01", + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120 + ) + end + end + end + + context "with anniversary billing" do + let(:future_start) { Time.zone.parse("8 Apr 2024") } + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + let(:pending_subscription) do + create( + :subscription, + customer:, + plan:, + status: :pending, + subscription_at: future_start, + billing_time: "anniversary" + ) + end + + it "creates preview invoice for pending subscription" do + travel_to(timestamp) do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + invoice_type: "subscription", + issuing_date: "2024-04-08", + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120 + ) + end + end + end + end + end +end diff --git a/spec/requests/api/v1/organizations_controller_spec.rb b/spec/requests/api/v1/organizations_controller_spec.rb new file mode 100644 index 0000000..5360782 --- /dev/null +++ b/spec/requests/api/v1/organizations_controller_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::OrganizationsController do + let(:organization) { create(:organization) } + let(:webhook_url) { Faker::Internet.url } + + describe "PUT /api/v1/organizations" do + subject do + put_with_token( + organization, + "/api/v1/organizations", + {organization: update_params} + ) + end + + let(:update_params) do + { + country: "pl", + default_currency: "EUR", + address_line1: "address1", + address_line2: "address2", + state: "state", + zipcode: "10000", + email: "mail@example.com", + city: "test_city", + legal_name: "test1", + legal_number: "123", + timezone: "Europe/Paris", + webhook_url:, + email_settings: ["invoice.finalized"], + document_number_prefix: "ORG-2", + finalize_zero_amount_invoice: false, + billing_configuration: { + invoice_footer: "footer", + invoice_grace_period: 3, + document_locale: "fr" + } + } + end + + include_examples "requires API permission", "organization", "write" + + it "updates an organization" do + put_with_token( + organization, + "/api/v1/organizations", + {organization: update_params} + ) + + expect(response).to have_http_status(:success) + + expect(json[:organization][:name]).to eq(organization.name) + expect(json[:organization][:default_currency]).to eq("EUR") + expect(json[:organization][:webhook_url]).to eq(webhook_url) + expect(json[:organization][:webhook_urls]).to eq([webhook_url]) + expect(json[:organization][:document_numbering]).to eq("per_customer") + expect(json[:organization][:document_number_prefix]).to eq("ORG-2") + expect(json[:organization][:finalize_zero_amount_invoice]).to eq(false) + # TODO(:timezone): Timezone update is turned off for now + # expect(json[:organization][:timezone]).to eq(update_params[:timezone]) + + billing = json[:organization][:billing_configuration] + expect(billing[:invoice_footer]).to eq("footer") + expect(billing[:document_locale]).to eq("fr") + + expect(json[:organization][:taxes]).not_to be_nil + end + + context "with premium features", :premium do + it "updates an organization" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:organization][:timezone]).to eq(update_params[:timezone]) + expect(json[:organization][:email_settings]).to eq(update_params[:email_settings]) + + billing = json[:organization][:billing_configuration] + expect(billing[:invoice_grace_period]).to eq(3) + end + end + end + + describe "GET /api/v1/organizations/grpc_token" do + subject { get_with_token(organization, "/api/v1/organizations/grpc_token") } + + include_examples "requires API permission", "organization", "read" + + it "returns the grpc_token" do + subject + + expect(response).to have_http_status(:success) + expect(json[:organization][:grpc_token]).not_to be_nil + end + end + + describe "GET /api/v1/organizations" do + subject { get_with_token(organization, "/api/v1/organizations") } + + include_examples "requires API permission", "organization", "read" + + it "returns the organization" do + subject + + expect(response).to have_http_status(:success) + expect(json[:organization][:name]).to eq(organization.name) + end + end +end diff --git a/spec/requests/api/v1/payment_receipts_controller_spec.rb b/spec/requests/api/v1/payment_receipts_controller_spec.rb new file mode 100644 index 0000000..a68b76a --- /dev/null +++ b/spec/requests/api/v1/payment_receipts_controller_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PaymentReceiptsController do + let(:organization) { create(:organization) } + + describe "GET /api/v1/payment_receipts" do + subject { get_with_token(organization, "/api/v1/payment_receipts", params) } + + let(:params) { {} } + + include_examples "requires API permission", "invoice", "read" + + it "returns organization's payments" do + invoice = create(:invoice, organization:) + invoice2 = create(:invoice, organization:) + payment_request = create(:payment_request, organization:) + first_payment = create(:payment, payable: invoice) + second_payment = create(:payment, payable: invoice2) + third_payment = create(:payment, payable: payment_request) + + first_payment_receipt = create(:payment_receipt, payment: first_payment, organization:) + second_payment_receipt = create(:payment_receipt, payment: second_payment, organization:) + third_payment_receipt = create(:payment_receipt, payment: third_payment, organization:) + create(:payment_receipt) + + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_receipts].count).to eq(3) + expect(json[:payment_receipts].map { |r| r[:lago_id] }).to contain_exactly( + first_payment_receipt.id, + second_payment_receipt.id, + third_payment_receipt.id + ) + end + + context "with a not found invoice" do + let(:params) { {invoice_id: SecureRandom.uuid} } + + before do + invoice = create(:invoice, organization:) + payment = create(:payment, payable: invoice) + create(:payment_receipt, payment:) + end + + it "returns an empty result" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_receipts]).to be_empty + end + end + + context "with invoice" do + let(:invoice) { create(:invoice, organization:) } + let(:invoice2) { create(:invoice, organization:) } + let(:params) { {invoice_id: invoice.id} } + let(:first_payment) { create(:payment, payable: invoice) } + let(:first_payment_receipt) { create(:payment_receipt, payment: first_payment, organization:) } + + let(:second_payment) { create(:payment, payable: invoice2) } + + before do + first_payment_receipt + create(:payment_receipt, payment: second_payment) + end + + it "returns invoices's payment receipts" do + subject + expect(response).to have_http_status(:success) + expect(json[:payment_receipts].map { |r| r[:lago_id] }).to contain_exactly(first_payment_receipt.id) + expect(json[:payment_receipts].first[:payment][:invoice_ids].first).to eq(invoice.id) + end + end + end + + describe "GET /api/v1/payment_receipts/:id" do + subject { get_with_token(organization, "/api/v1/payment_receipts/#{id}") } + + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment) { create(:payment, payable: invoice) } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:) } + + context "when payment receipt exists" do + let(:id) { payment_receipt.id } + + include_examples "requires API permission", "invoice", "read" + + it "returns the payment receipt" do + subject + expect(response).to have_http_status(:ok) + expect(json[:payment_receipt][:lago_id]).to eq(payment_receipt.id) + expect(json[:payment_receipt][:payment][:invoice_ids].first).to eq(invoice.id) + end + end + + context "when payment for a payment request exists" do + let(:payment_request) { create(:payment_request, customer:, organization:, invoices: [invoice]) } + let(:payment) { create(:payment, payable: payment_request) } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:) } + let(:id) { payment_receipt.id } + + include_examples "requires API permission", "invoice", "read" + + it "returns the payment receipt" do + subject + expect(response).to have_http_status(:ok) + expect(json[:payment_receipt][:lago_id]).to eq(payment_receipt.id) + expect(json[:payment_receipt][:payment][:invoice_ids].first).to eq(invoice.id) + end + end + + context "when payment does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/payment_requests_controller_spec.rb b/spec/requests/api/v1/payment_requests_controller_spec.rb new file mode 100644 index 0000000..0df7251 --- /dev/null +++ b/spec/requests/api/v1/payment_requests_controller_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PaymentRequestsController do + let(:organization) { create(:organization) } + + describe "POST /api/v1/payment_requests" do + subject do + post_with_token( + organization, + "/api/v1/payment_requests", + {payment_request: params} + ) + end + + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:params) do + { + email: customer.email, + external_customer_id: customer.external_id, + lago_invoice_ids: [invoice.id] + } + end + + let(:payment_request) { create(:payment_request, invoices: [invoice], customer:) } + + before do + allow(PaymentRequests::CreateService).to receive(:call).and_return( + BaseService::Result.new.tap { |r| r.payment_request = payment_request } + ) + end + + include_examples "requires API permission", "payment_request", "write" + + it "delegates to PaymentRequests::CreateService" do + subject + + expect(PaymentRequests::CreateService).to have_received(:call).with( + organization:, + params: { + email: customer.email, + external_customer_id: customer.external_id, + lago_invoice_ids: [invoice.id] + } + ) + + expect(response).to have_http_status(:success) + expect(json[:payment_request][:lago_id]).to eq(payment_request.id) + expect(json[:payment_request][:invoices].map { |i| i[:lago_id] }).to contain_exactly(invoice.id) + expect(json[:payment_request][:customer][:lago_id]).to eq(customer.id) + end + end + + describe "GET /api/v1/payment_requests" do + include_examples "a payment request index endpoint" do + subject { get_with_token(organization, "/api/v1/payment_requests", params) } + + context "with external_customer_id filter" do + let(:params) { {external_customer_id: customer.external_id} } + + before do + payment_request + end + + it "returns customer's payment requests" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_requests].map { |r| r[:lago_id] }).to contain_exactly( + payment_request.id + ) + expect(json[:payment_requests].first[:customer][:lago_id]).to eq(customer.id) + end + + context "with a not found customer" do + let(:params) { {external_customer_id: SecureRandom.uuid} } + + before do + payment_request + end + + it "returns an empty result" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_requests]).to be_empty + end + end + end + end + end + + describe "GET /api/v1/payment_requests/:id" do + subject { get_with_token(organization, "/api/v1/payment_requests/#{id}") } + + let(:payment_request) { create(:payment_request, invoices: [invoice], customer:) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + + context "when payment request exists" do + let(:id) { payment_request.id } + + include_examples "requires API permission", "payment_request", "read" + + it "returns the payment request" do + subject + expect(response).to have_http_status(:ok) + expect(json[:payment_request][:lago_id]).to eq(payment_request.id) + expect(json[:payment_request][:invoices].first[:lago_id]).to eq(invoice.id) + end + end + + context "when payment request does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/payments_controller_spec.rb b/spec/requests/api/v1/payments_controller_spec.rb new file mode 100644 index 0000000..9d425cf --- /dev/null +++ b/spec/requests/api/v1/payments_controller_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PaymentsController do + let(:organization) { create(:organization) } + + describe "POST /api/v1/payments" do + subject do + post_with_token( + organization, + "/api/v1/payments", + {payment: params} + ) + end + + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:params) do + { + invoice_id: invoice.id, + amount_cents: 100, + reference: "ref1" + } + end + + let(:payment) { create(:payment, payable: invoice) } + + context "when all parameters are valid" do + before do + allow(Payments::ManualCreateService).to receive(:call).and_return( + BaseService::Result.new.tap { |r| r.payment = payment } + ) + end + + include_examples "requires API permission", "payment", "write" + + it "delegates to Payments::ManualCreateService" do + subject + + expect(Payments::ManualCreateService).to have_received(:call).with(organization:, params:) + + expect(response).to have_http_status(:success) + expect(json[:payment][:lago_id]).to eq(payment.id) + expect(json[:payment][:invoice_ids].first).to eq(payment.payable.id) + end + end + + context "when amount_cents is missing or misspelled", :premium do + let(:params) do + { + invoice_id: invoice.id, + amount_in_cents: 100, + reference: "ref1" + } + end + + let(:error_details) { {amount_cents: %w[invalid_value]} } + + it "returns a bad request error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + + expect(json[:code]).to eq("validation_errors") + expect(json[:error_details]).to eq(error_details) + end + end + end + + describe "GET /api/v1/payments" do + it_behaves_like "a payment index endpoint" do + subject { get_with_token(organization, "/api/v1/payments", params) } + + context "with external customer id" do + let(:params) { {external_customer_id: customer.external_id} } + + let(:invoice_1) { create(:invoice, organization:, customer:) } + let(:invoice_2) { create(:invoice, organization:, customer: create(:customer, organization:)) } + let(:payment_1) { create(:payment, organization:, payable: invoice_1) } + let(:payment_2) { create(:payment, organization:, payable: invoice_2) } + + before do + payment_1 + payment_2 + end + + it "returns the payments of the customer" do + subject + expect(response).to have_http_status(:ok) + expect(json[:payments].count).to eq(1) + expect(json[:payments].first[:lago_id]).to eq(payment_1.id) + expect(json[:payments].first.keys).to eq(%i[ + lago_id + lago_customer_id + external_customer_id + invoice_ids + invoice_numbers + lago_payable_id + payable_type + amount_cents + amount_currency + status + payment_status + type + reference + payment_provider_code + payment_provider_type + external_payment_id + provider_payment_id + provider_customer_id + next_action + created_at + ]) + end + end + end + end + + describe "GET /api/v1/payments/:id" do + subject { get_with_token(organization, "/api/v1/payments/#{id}") } + + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment) { create(:payment, payable: invoice) } + + context "when payment exists" do + let(:id) { payment.id } + + include_examples "requires API permission", "payment", "read" + + it "returns the payment" do + subject + expect(response).to have_http_status(:ok) + expect(json[:payment][:lago_id]).to eq(payment.id) + expect(json[:payment][:invoice_ids].first).to eq(invoice.id) + expect(json[:payment].keys).to eq(%i[ + lago_id + lago_customer_id + external_customer_id + invoice_ids + invoice_numbers + lago_payable_id + payable_type + amount_cents + amount_currency + status + payment_status + type + reference + payment_provider_code + payment_provider_type + external_payment_id + provider_payment_id + provider_customer_id + next_action + created_at + ]) + end + end + + context "when payment for a payment request exits" do + let(:payment_request) { create(:payment_request, customer:, organization:, invoices: [invoice]) } + let(:payment) { create(:payment, payable: payment_request) } + let(:id) { payment.id } + + include_examples "requires API permission", "payment", "read" + + it "returns the payment" do + subject + expect(response).to have_http_status(:ok) + expect(json[:payment][:lago_id]).to eq(payment.id) + expect(json[:payment][:invoice_ids].first).to eq(invoice.id) + end + end + + context "when payment does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/plans/charges/filters_controller_spec.rb b/spec/requests/api/v1/plans/charges/filters_controller_spec.rb new file mode 100644 index 0000000..397612d --- /dev/null +++ b/spec/requests/api/v1/plans/charges/filters_controller_spec.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Plans::Charges::FiltersController do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[us eu]) } + + describe "GET /api/v1/plans/:plan_code/charges/:charge_code/filters" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters") } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"]) + end + + it "returns a list of charge filters" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filters]).to be_present + expect(json[:filters].length).to eq(1) + expect(json[:filters].first[:lago_id]).to eq(charge_filter.id) + end + + it "returns pagination metadata" do + subject + + expect(json[:meta]).to include( + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + ) + end + + context "when plan does not exist" do + subject { get_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}/filters") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code/filters") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + end + + describe "GET /api/v1/plans/:plan_code/charges/:charge_code/filters/:id" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters/#{charge_filter.id}") } + + let(:charge_filter) { create(:charge_filter, charge:, invoice_display_name: "US Region") } + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"]) + end + + it "returns the charge filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:lago_id]).to eq(charge_filter.id) + expect(json[:filter][:invoice_display_name]).to eq("US Region") + expect(json[:filter][:values]).to eq({region: ["us"]}) + end + + context "when plan does not exist" do + subject { get_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}/filters/#{charge_filter.id}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code/filters/#{charge_filter.id}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when charge filter does not exist" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters/#{SecureRandom.uuid}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge_filter") + end + end + end + + describe "POST /api/v1/plans/:plan_code/charges/:charge_code/filters" do + subject { post_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters", {filter: create_params}) } + + let(:create_params) do + { + invoice_display_name: "US Region Filter", + properties: {amount: "50"}, + values: {billable_metric_filter.key => ["us"]} + } + end + + it "creates a new charge filter" do + expect { subject }.to change { charge.filters.count }.by(1) + + expect(response).to have_http_status(:success) + expect(json[:filter][:invoice_display_name]).to eq("US Region Filter") + expect(json[:filter][:properties]).to include(amount: "50") + expect(json[:filter][:values]).to eq({region: ["us"]}) + end + + context "when plan does not exist" do + subject { post_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}/filters", {filter: create_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { post_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code/filters", {filter: create_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when values are missing" do + let(:create_params) do + { + invoice_display_name: "US Region Filter", + properties: {amount: "50"} + } + end + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_entity) + expect(json[:error_details]).to include(:values) + end + end + + context "with cascade_updates" do + subject do + post_with_token( + organization, + "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters", + {filter: create_params.merge(cascade_updates: true)} + ) + end + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric:, parent: charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(ChargeFilters::CascadeJob).to receive(:perform_later) + end + + it "triggers cascade to children" do + subject + + expect(response).to have_http_status(:success) + expect(ChargeFilters::CascadeJob).to have_received(:perform_later) + end + end + + context "with presentation_group_keys" do + let(:create_params) do + { + invoice_display_name: "US Region Filter", + properties: {amount: "50", presentation_group_keys: [{value: "region"}]}, + values: {billable_metric_filter.key => ["us"]} + } + end + + it "ignores presentation_group_keys" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:properties]).not_to have_key(:presentation_group_keys) + end + end + end + + describe "PUT /api/v1/plans/:plan_code/charges/:charge_code/filters/:id" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters/#{charge_filter.id}", {filter: update_params}) } + + let(:charge_filter) { create(:charge_filter, charge:, invoice_display_name: "Original Name", properties: {"amount" => "10"}) } + let(:update_params) do + { + invoice_display_name: "Updated Name", + properties: {amount: "100"} + } + end + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"]) + end + + it "updates the charge filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:invoice_display_name]).to eq("Updated Name") + expect(json[:filter][:properties]).to include(amount: "100") + end + + context "when plan does not exist" do + subject { put_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}/filters/#{charge_filter.id}", {filter: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code/filters/#{charge_filter.id}", {filter: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when charge filter does not exist" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters/#{SecureRandom.uuid}", {filter: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge_filter") + end + end + + context "with presentation_group_keys" do + let(:update_params) do + { + properties: {amount: "100", presentation_group_keys: [{value: "region"}]} + } + end + + it "ignores presentation_group_keys" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:properties]).not_to have_key(:presentation_group_keys) + end + end + end + + describe "DELETE /api/v1/plans/:plan_code/charges/:charge_code/filters/:id" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters/#{charge_filter.id}") } + + let(:charge_filter) { create(:charge_filter, charge:) } + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"]) + end + + before { charge_filter_value } + + it "soft deletes the charge filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:lago_id]).to eq(charge_filter.id) + expect(charge_filter.reload.deleted_at).to be_present + end + + it "soft deletes the charge filter values" do + subject + + expect(charge_filter_value.reload.deleted_at).to be_present + end + + context "when plan does not exist" do + subject { delete_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}/filters/#{charge_filter.id}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code/filters/#{charge_filter.id}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when charge filter does not exist" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}/filters/#{SecureRandom.uuid}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge_filter") + end + end + end +end diff --git a/spec/requests/api/v1/plans/charges_controller_spec.rb b/spec/requests/api/v1/plans/charges_controller_spec.rb new file mode 100644 index 0000000..49139b9 --- /dev/null +++ b/spec/requests/api/v1/plans/charges_controller_spec.rb @@ -0,0 +1,631 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Plans::ChargesController do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + + describe "GET /api/v1/plans/:plan_code/charges" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges") } + + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + + before { charge } + + it "returns a list of charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charges]).to be_present + expect(json[:charges].length).to eq(1) + expect(json[:charges].first[:lago_id]).to eq(charge.id) + expect(json[:charges].first[:code]).to eq(charge.code) + end + + it "returns pagination metadata" do + subject + + expect(json[:meta]).to include( + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + ) + end + + context "when plan does not exist" do + subject { get_with_token(organization, "/api/v1/plans/invalid_code/charges") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when plan has child charges (overrides)" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric:, parent: charge) } + + before { child_charge } + + it "only returns parent charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charges].length).to eq(1) + expect(json[:charges].first[:lago_id]).to eq(charge.id) + end + end + + context "with pagination" do + let(:charges) { create_list(:standard_charge, 3, plan:, organization:, billable_metric:) } + + before do + charge.destroy + charges + end + + it "returns paginated results" do + get_with_token(organization, "/api/v1/plans/#{plan.code}/charges?per_page=2&page=1") + + expect(response).to have_http_status(:success) + expect(json[:charges].length).to eq(2) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:total_pages]).to eq(2) + end + end + end + + describe "GET /api/v1/plans/:plan_code/charges/:code" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}") } + + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + + it "returns the charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:lago_id]).to eq(charge.id) + expect(json[:charge][:code]).to eq(charge.code) + expect(json[:charge][:charge_model]).to eq("standard") + expect(json[:charge][:lago_billable_metric_id]).to eq(billable_metric.id) + end + + context "when plan does not exist" do + subject { get_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + end + + describe "POST /api/v1/plans/:plan_code/charges" do + subject { post_with_token(organization, "/api/v1/plans/#{plan.code}/charges", {charge: create_params}) } + + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "new_charge_code", + charge_model: "standard", + invoice_display_name: "Test Charge", + properties: {amount: "100"} + } + end + + it "creates a new charge" do + expect { subject }.to change { plan.charges.count }.by(1) + + expect(response).to have_http_status(:success) + expect(json[:charge][:code]).to eq("new_charge_code") + expect(json[:charge][:charge_model]).to eq("standard") + expect(json[:charge][:invoice_display_name]).to eq("Test Charge") + expect(json[:charge][:lago_billable_metric_id]).to eq(billable_metric.id) + end + + context "when plan does not exist" do + subject { post_with_token(organization, "/api/v1/plans/invalid_code/charges", {charge: create_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when billable_metric does not exist" do + let(:create_params) do + { + billable_metric_id: "invalid_id", + code: "new_charge_code", + charge_model: "standard" + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("billable_metric") + end + end + + context "with filters" do + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "filtered_charge", + charge_model: "standard", + properties: {amount: "100"}, + filters: [ + { + invoice_display_name: "Filter 1", + properties: {amount: "50"}, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + ] + } + end + + it "creates a charge with filters" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:filters].length).to eq(1) + expect(json[:charge][:filters].first[:invoice_display_name]).to eq("Filter 1") + expect(json[:charge][:filters].first[:properties]).to include(amount: "50") + end + + context "when filter properties include presentation_group_keys" do + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "filtered_charge", + charge_model: "standard", + properties: {amount: "100"}, + filters: [ + { + invoice_display_name: "Filter 1", + properties: { + amount: "50", + presentation_group_keys: [ + {value: "region", options: {display_in_invoice: true}} + ] + }, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + ] + } + end + + it "ignores charge filter presentation_group_keys" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:filters].first[:properties]).not_to have_key(:presentation_group_keys) + end + end + end + + context "with taxes" do + let(:tax) { create(:tax, organization:) } + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "taxed_charge", + charge_model: "standard", + properties: {amount: "100"}, + tax_codes: [tax.code] + } + end + + it "creates a charge with taxes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:taxes]).to be_present + expect(json[:charge][:taxes].length).to eq(1) + expect(json[:charge][:taxes].first[:code]).to eq(tax.code) + end + end + + context "with applied_pricing_unit", :premium do + let(:pricing_unit) { create(:pricing_unit, organization:) } + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "priced_charge", + charge_model: "standard", + properties: {amount: "100"}, + applied_pricing_unit: {code: pricing_unit.code, conversion_rate: "2.5"} + } + end + + it "creates a charge with applied pricing unit" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:applied_pricing_unit]).to be_present + expect(json[:charge][:applied_pricing_unit][:code]).to eq(pricing_unit.code) + expect(json[:charge][:applied_pricing_unit][:conversion_rate]).to eq("2.5") + end + end + + context "with cascade_updates" do + subject { post_with_token(organization, "/api/v1/plans/#{plan.code}/charges", {charge: create_params.merge(cascade_updates: true)}) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + + before do + create(:subscription, plan: child_plan, status: :active) + allow(Charges::CreateChildrenJob).to receive(:perform_later) + end + + it "triggers cascade creation to children" do + subject + + expect(response).to have_http_status(:success) + expect(Charges::CreateChildrenJob).to have_received(:perform_later) + end + end + + context "with accepts_target_wallet" do + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "wallet_target_charge", + charge_model: "standard", + properties: {amount: "100"}, + accepts_target_wallet: true + } + end + + context "when license is not premium" do + it "ignores accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:accepts_target_wallet]).to be false + end + end + + context "when license is premium", :premium do + context "when events_targeting_wallets is not enabled" do + it "does not set accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:accepts_target_wallet]).to be false + end + end + + context "when events_targeting_wallets is enabled" do + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "sets accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:accepts_target_wallet]).to be true + end + end + end + end + + context "with presentation_group_keys" do + context "when presentation_group_keys is an empty array" do + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "new_charge_code", + charge_model: "standard", + properties: {amount: "100", presentation_group_keys: []} + } + end + + it "creates a charge without storing presentation_group_keys" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:properties][:presentation_group_keys]).to be_nil + end + end + + context "when presentation_group_keys contains only value" do + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "new_charge_code", + charge_model: "standard", + properties: {amount: "100", presentation_group_keys: [{value: "region"}]} + } + end + + it "creates a charge with presentation_group_keys" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:properties][:presentation_group_keys]).to eq([{value: "region"}]) + end + end + + context "when presentation_group_keys contains both value and options" do + let(:create_params) do + { + billable_metric_id: billable_metric.id, + code: "new_charge_code", + charge_model: "standard", + properties: { + amount: "100", + presentation_group_keys: [ + {value: "region", options: {display_in_invoice: true}}, + {value: "country"} + ] + } + } + end + + it "creates a charge with presentation_group_keys including options" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:properties][:presentation_group_keys]).to eq([ + {value: "region", options: {display_in_invoice: true}}, + {value: "country"} + ]) + end + end + end + end + + describe "PUT /api/v1/plans/:plan_code/charges/:code" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}", {charge: update_params}) } + + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:update_params) do + { + invoice_display_name: "Updated Charge Name", + charge_model: "standard", + properties: {amount: "200"} + } + end + + it "updates the charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:invoice_display_name]).to eq("Updated Charge Name") + expect(json[:charge][:properties][:amount]).to eq("200") + end + + context "when plan does not exist" do + subject { put_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}", {charge: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code", {charge: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when plan is attached to subscriptions" do + let(:subscription) { create(:subscription, plan:) } + + before { subscription } + + it "updates only allowed fields" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:invoice_display_name]).to eq("Updated Charge Name") + end + end + + context "with cascade_updates" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}", {charge: update_params.merge(cascade_updates: true)}) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric:, parent: charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(Charges::UpdateChildrenJob).to receive(:perform_later) + end + + it "passes cascade_updates to the service" do + subject + + expect(response).to have_http_status(:success) + expect(Charges::UpdateChildrenJob).to have_received(:perform_later) + end + end + + context "with accepts_target_wallet" do + let(:update_params) do + { + charge_model: "standard", + properties: {amount: "200"}, + accepts_target_wallet: true + } + end + + context "when license is not premium" do + it "ignores accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:accepts_target_wallet]).to be false + end + end + + context "when license is premium", :premium do + context "when events_targeting_wallets is not enabled" do + it "does not set accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:accepts_target_wallet]).to be false + end + end + + context "when events_targeting_wallets is enabled" do + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "sets accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:accepts_target_wallet]).to be true + end + end + end + end + + context "with presentation_group_keys" do + context "when presentation_group_keys contains only value" do + let(:update_params) do + { + charge_model: "standard", + properties: {amount: "200", presentation_group_keys: [{value: "region"}]} + } + end + + it "updates the charge with presentation_group_keys" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:properties][:presentation_group_keys]).to eq([{value: "region"}]) + end + end + + context "when presentation_group_keys contains both value and options" do + let(:update_params) do + { + charge_model: "standard", + properties: { + amount: "200", + presentation_group_keys: [ + {value: "region", options: {display_in_invoice: true}}, + {value: "country"} + ] + } + } + end + + it "updates the charge with presentation_group_keys including options" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:properties][:presentation_group_keys]).to eq([ + {value: "region", options: {display_in_invoice: true}}, + {value: "country"} + ]) + end + end + + context "when removing existing presentation_group_keys" do + let(:charge) do + create(:standard_charge, plan:, organization:, billable_metric:, + properties: {"amount" => "100", "presentation_group_keys" => [{"value" => "region"}]}) + end + let(:update_params) do + { + charge_model: "standard", + properties: {amount: "200", presentation_group_keys: []} + } + end + + it "removes presentation_group_keys from the charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:properties][:presentation_group_keys]).to be_nil + end + end + end + end + + describe "DELETE /api/v1/plans/:plan_code/charges/:code" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}") } + + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + + it "soft deletes the charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:lago_id]).to eq(charge.id) + expect(charge.reload.deleted_at).to be_present + end + + context "when plan does not exist" do + subject { delete_with_token(organization, "/api/v1/plans/invalid_code/charges/#{charge.code}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when charge does not exist" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/invalid_code") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "with cascade_updates" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge.code}", {charge: {cascade_updates: true}}) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric:, parent: charge) } + + before do + child_charge + allow(Charges::DestroyChildrenJob).to receive(:perform_later) + end + + it "cascades the deletion to children" do + subject + + expect(response).to have_http_status(:success) + expect(Charges::DestroyChildrenJob).to have_received(:perform_later).with(charge.id) + end + end + end +end diff --git a/spec/requests/api/v1/plans/entitlements/privileges_controller_spec.rb b/spec/requests/api/v1/plans/entitlements/privileges_controller_spec.rb new file mode 100644 index 0000000..1a7e991 --- /dev/null +++ b/spec/requests/api/v1/plans/entitlements/privileges_controller_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Plans::Entitlements::PrivilegesController do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:feature) { create(:feature, organization:) } + let(:privilege) { create(:privilege, organization:, feature:, code: "max_users") } + let(:privilege2) { create(:privilege, organization:, feature:, code: "max_admins") } + let(:entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, organization:) } + let(:entitlement_value2) { create(:entitlement_value, entitlement:, privilege: privilege2, organization:) } + + before do + entitlement_value + entitlement_value2 + end + + describe "DELETE #destroy" do + subject do + delete_with_token organization, "/api/v1/plans/#{plan.code}/entitlements/#{feature.code}/privileges/#{privilege.code}" + end + + it "deletes the specific privilege value from the entitlement" do + expect { subject }.to change(privilege.values, :count).by(-1) + + expect(response).to have_http_status(:success) + expect(json[:entitlement][:privileges].pluck(:code)).to eq(["max_admins"]) + end + + it "returns not found error when plan does not exist" do + delete_with_token organization, "/api/v1/plans/invalid_plan/entitlements/#{feature.code}/privileges/#{privilege.code}" + + expect(response).to be_not_found_error("plan") + end + + it "returns not found error when entitlement does not exist" do + delete_with_token organization, "/api/v1/plans/#{plan.code}/entitlements/invalid_feature/privileges/#{privilege.code}" + + expect(response).to be_not_found_error("entitlement") + end + + it "returns not found error when privilege does not exist" do + delete_with_token organization, "/api/v1/plans/#{plan.code}/entitlements/#{feature.code}/privileges/invalid_privilege" + + expect(response).to be_not_found_error("privilege") + end + + it "returns the updated entitlement in the response" do + subject + + expect(response).to have_http_status(:success) + + json = JSON.parse(response.body, symbolize_names: true) + expect(json[:entitlement][:code]).to eq(feature.code) + expect(json[:entitlement][:privileges].sole).to include({ + code: "max_admins", + value: entitlement_value2.value + }) + end + + context "when plan has children (subscription plan overrides)" do + it "always retrieve the parent plan" do + # NOTE: It should be possible to create entitlements on a child plan, + # but we want to tests that the controller retrieves only parents + override = create(:plan, organization:, code: plan.code, parent: plan) + override_entitlement = create(:entitlement, plan: override, feature:) + create(:entitlement_value, entitlement: override_entitlement, privilege:, value: 999, organization:) + + plan.update! deleted_at: Time.current + + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq "plan_not_found" + end + end + end +end diff --git a/spec/requests/api/v1/plans/entitlements_controller_spec.rb b/spec/requests/api/v1/plans/entitlements_controller_spec.rb new file mode 100644 index 0000000..a8945d5 --- /dev/null +++ b/spec/requests/api/v1/plans/entitlements_controller_spec.rb @@ -0,0 +1,476 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Plans::EntitlementsController do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + + describe "GET /api/v1/plans/:plan_code/entitlements" do + subject { get_with_token organization, "/api/v1/plans/#{plan.code}/entitlements" } + + let(:entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, value: 30, organization:) } + + before do + entitlement + entitlement_value + end + + it "returns a list of entitlements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].length).to eq(1) + expect(json[:entitlements].first[:privileges].sole[:value]).to eq(30) + end + + context "when plan has children (subscription plan overrides)" do + it "always retrieve the parent plan" do + # NOTE: It should be possible to create entitlements on a child plan, + # but we want to tests that the controller retrieves only parents + override = create(:plan, organization:, code: plan.code, parent: plan) + override_entitlement = create(:entitlement, plan: override, feature:) + create(:entitlement_value, entitlement: override_entitlement, privilege:, value: 999, organization:) + + plan.update! deleted_at: Time.current + + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq "plan_not_found" + end + end + end + + describe "GET /api/v1/plans/:plan_code/entitlements/:feature_code" do + subject { get_with_token organization, "/api/v1/plans/#{plan.code}/entitlements/#{feature.code}" } + + let(:entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, value: 30, organization:) } + + before do + entitlement + entitlement_value + end + + it "returns the entitlement" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlement][:code]).to eq("seats") + expect(json[:entitlement][:privileges].sole[:value]).to eq(30) + end + + it "returns not found error when plan does not exist" do + get_with_token organization, "/api/v1/plans/invalid_plan/entitlements/#{feature.code}" + + expect(response).to be_not_found_error("plan") + end + + it "returns not found error when entitlement does not exist" do + get_with_token organization, "/api/v1/plans/#{plan.code}/entitlements/invalid_feature" + + expect(response).to be_not_found_error("entitlement") + end + end + + describe "POST /api/v1/plans/:plan_code/entitlements" do + subject { post_with_token organization, "/api/v1/plans/#{plan.code}/entitlements", params } + + let(:params) do + { + "entitlements" => { + "seats" => { + "max" => 25 + } + } + } + end + + before do + feature + privilege + end + + it "creates entitlements for the plan" do + expect { subject }.to change { plan.entitlements.count }.by(1) + .and change(Entitlement::EntitlementValue, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].length).to eq(1) + expect(json[:entitlements].first[:privileges].sole[:value]).to eq(25) + end + + context "when plan has existing entitlements" do + let(:existing_entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:existing_value) { create(:entitlement_value, entitlement: existing_entitlement, privilege:, value: "10", organization:) } + + before do + existing_entitlement + existing_value + end + + it "replaces existing entitlements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements].first[:privileges].sole[:value]).to eq(25) + end + end + + context "when feature does not exist" do + let(:params) do + { + entitlements: { + "nonexistent_feature" => { + "max" => 25 + } + } + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("feature") + end + end + + context "when privilege does not exist" do + let(:params) do + { + entitlements: { + "seats" => { + "nonexistent_privilege" => 25 + } + } + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("privilege") + end + end + + context "when privilege value is invalid" do + let(:params) do + { + entitlements: { + "seats" => { + "max" => [12, 13] + } + } + } + end + + it "returns not found error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to eq("validation_errors") + expect(json[:error_details][:max_privilege_value]).to eq(["value_is_invalid"]) + end + end + + context "when privilege value is not in select_options" do + let(:params) do + { + entitlements: { + "seats" => { + "invitation" => "okta" + } + } + } + end + + before do + config = {select_options: ["email", "phone", "slack"]} + create(:privilege, organization:, feature:, code: "invitation", value_type: "select", config:) + end + + it "returns not found error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to eq("validation_errors") + expect(json[:error_details][:invitation_privilege_value]).to eq(["value_not_in_select_options"]) + end + end + + context "when plan does not exist" do + it "returns not found error" do + post_with_token organization, "/api/v1/plans/invalid_plan/entitlements", params + + expect(response).to be_not_found_error("plan") + end + end + + context "when entitlements params is empty" do + let(:params) do + { + entitlements: {} + } + end + + it "returns success with empty entitlements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to eq([]) + end + end + + context "when feature has multiple privileges" do + let(:privilege2) { create(:privilege, organization:, feature:, code: "max_admins", value_type: "integer") } + let(:params) do + { + entitlements: { + "seats" => { + "max" => 25, + "max_admins" => 5 + } + } + } + end + + before do + privilege2 + end + + it "creates entitlement values for all privileges" do + expect { subject }.to change(Entitlement::EntitlementValue, :count).by(2) + + expect(response).to have_http_status(:success) + expect(json[:entitlements].first[:privileges]).to contain_exactly({ + code: "max", + name: nil, + value_type: "integer", + value: 25, + config: {} + }, { + code: "max_admins", + name: nil, + value_type: "integer", + value: 5, + config: {} + }) + end + end + end + + describe "PATCH /api/v1/plans/:plan_code/entitlements" do + subject { patch_with_token organization, "/api/v1/plans/#{plan.code}/entitlements", params } + + let(:entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, value: "10", organization:) } + let(:params) do + { + "entitlements" => { + "seats" => { + "max" => 60 + } + } + } + end + + before do + entitlement + entitlement_value + end + + it "updates existing entitlement value" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].length).to eq(1) + expect(json[:entitlements].first[:privileges].sole[:value]).to eq(60) + end + + it "does not create new entitlement" do + expect { + subject + }.not_to change(Entitlement::Entitlement, :count) + end + + context "when privilege value does not exist" do + let(:privilege2) { create(:privilege, organization:, feature:, code: "max_admins", value_type: "integer") } + let(:params) do + { + "entitlements" => { + "seats" => { + "max_admins" => 30 + } + } + } + end + + before do + privilege2 + end + + it "creates new entitlement value" do + expect { + subject + }.to change(Entitlement::EntitlementValue, :count).by(1) + end + + it "creates entitlement value with correct value" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements].first[:privileges].find { it[:code] == "max_admins" }[:value]).to eq(30) + end + end + + context "when privilege value is invalid" do + let(:params) do + { + "entitlements" => { + "seats" => { + "max" => "one thousand!!" + } + } + } + end + + it "returns a validation error" do + subject + + expect(response).to have_http_status(:unprocessable_entity) + expect(json[:error_details][:max_privilege_value]).to eq(["value_is_invalid"]) + end + end + + context "when entitlement does not exist" do + let(:new_feature) { create(:feature, organization:, code: "storage") } + let(:new_privilege) { create(:privilege, organization:, feature: new_feature, code: "max_gb", value_type: "integer") } + let(:params) do + { + "entitlements" => { + "storage" => { + "max_gb" => 100 + } + } + } + end + + before do + new_feature + new_privilege + end + + it "creates new entitlement" do + expect { + subject + }.to change(Entitlement::Entitlement, :count).by(1) + end + + it "creates new entitlement value" do + expect { + subject + }.to change(Entitlement::EntitlementValue, :count).by(1) + end + end + + context "when feature does not exist" do + let(:params) do + { + "entitlements" => { + "nonexistent_feature" => { + "max" => 60 + } + } + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("feature") + end + end + + context "when privilege does not exist" do + let(:params) do + { + "entitlements" => { + "seats" => { + "nonexistent_privilege" => 60 + } + } + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("privilege") + end + end + + context "when plan does not exist" do + it "returns not found error" do + patch_with_token organization, "/api/v1/plans/invalid_plan/entitlements", params + + expect(response).to be_not_found_error("plan") + end + end + + context "when entitlements params is empty" do + let(:params) do + { + "entitlements" => {} + } + end + + it "returns success with existing entitlements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].first[:privileges].sole[:value]).to eq(10) + end + end + end + + describe "DELETE /api/v1/plans/:plan_code/entitlements/:feature_code" do + subject { delete_with_token organization, "/api/v1/plans/#{plan.code}/entitlements/#{feature.code}" } + + let(:entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, value: 30, organization:) } + + before do + entitlement + entitlement_value + end + + it "deletes the entitlement and its values" do + expect { subject }.to change(feature.entitlements, :count).by(-1) + .and change(feature.entitlement_values, :count).by(-1) + + expect(response).to have_http_status(:success) + expect(json[:entitlement][:code]).to eq "seats" + expect(json[:entitlement][:privileges].sole[:code]).to eq "max" + end + + it "returns not found error when plan does not exist" do + delete_with_token organization, "/api/v1/plans/invalid_plan/entitlements/#{feature.code}" + + expect(response).to be_not_found_error("plan") + end + + it "returns not found error when entitlement does not exist" do + delete_with_token organization, "/api/v1/plans/#{plan.code}/entitlements/invalid_feature" + + expect(response).to be_not_found_error("entitlement") + end + end +end diff --git a/spec/requests/api/v1/plans/fixed_charges_controller_spec.rb b/spec/requests/api/v1/plans/fixed_charges_controller_spec.rb new file mode 100644 index 0000000..ed1be67 --- /dev/null +++ b/spec/requests/api/v1/plans/fixed_charges_controller_spec.rb @@ -0,0 +1,359 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Plans::FixedChargesController do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + + describe "GET /api/v1/plans/:plan_code/fixed_charges" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges") } + + let(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + + before { fixed_charge } + + it "returns a list of fixed charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charges]).to be_present + expect(json[:fixed_charges].length).to eq(1) + expect(json[:fixed_charges].first[:lago_id]).to eq(fixed_charge.id) + expect(json[:fixed_charges].first[:code]).to eq(fixed_charge.code) + end + + it "returns pagination metadata" do + subject + + expect(json[:meta]).to include( + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + ) + end + + context "when plan does not exist" do + subject { get_with_token(organization, "/api/v1/plans/invalid_code/fixed_charges") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when plan has child fixed charges (overrides)" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, organization:, add_on:, parent: fixed_charge) } + + before { child_fixed_charge } + + it "only returns parent fixed charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charges].length).to eq(1) + expect(json[:fixed_charges].first[:lago_id]).to eq(fixed_charge.id) + end + end + + context "with pagination" do + let(:fixed_charges) { create_list(:fixed_charge, 3, plan:, organization:, add_on:) } + + before do + fixed_charge.destroy + fixed_charges + end + + it "returns paginated results" do + get_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges?per_page=2&page=1") + + expect(response).to have_http_status(:success) + expect(json[:fixed_charges].length).to eq(2) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:total_pages]).to eq(2) + end + end + end + + describe "GET /api/v1/plans/:plan_code/fixed_charges/:code" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/#{fixed_charge.code}") } + + let(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + + it "returns the fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:lago_id]).to eq(fixed_charge.id) + expect(json[:fixed_charge][:code]).to eq(fixed_charge.code) + expect(json[:fixed_charge][:charge_model]).to eq("standard") + expect(json[:fixed_charge][:lago_add_on_id]).to eq(add_on.id) + end + + context "when plan does not exist" do + subject { get_with_token(organization, "/api/v1/plans/invalid_code/fixed_charges/#{fixed_charge.code}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when fixed charge does not exist" do + subject { get_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/invalid_code") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("fixed_charge") + end + end + end + + describe "POST /api/v1/plans/:plan_code/fixed_charges" do + subject { post_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges", {fixed_charge: create_params}) } + + let(:create_params) do + { + add_on_id: add_on.id, + code: "new_fixed_charge_code", + charge_model: "standard", + invoice_display_name: "Test Fixed Charge", + units: 10, + properties: {amount: "100"} + } + end + + it "creates a new fixed charge" do + expect { subject }.to change { plan.fixed_charges.count }.by(1) + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:code]).to eq("new_fixed_charge_code") + expect(json[:fixed_charge][:charge_model]).to eq("standard") + expect(json[:fixed_charge][:invoice_display_name]).to eq("Test Fixed Charge") + expect(json[:fixed_charge][:lago_add_on_id]).to eq(add_on.id) + expect(json[:fixed_charge][:units]).to eq("10.0") + end + + context "when using add_on_code instead of add_on_id" do + let(:create_params) do + { + add_on_code: add_on.code, + code: "new_fixed_charge_code", + charge_model: "standard", + units: 5, + properties: {amount: "50"} + } + end + + it "creates a new fixed charge" do + expect { subject }.to change { plan.fixed_charges.count }.by(1) + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:lago_add_on_id]).to eq(add_on.id) + end + end + + context "when plan does not exist" do + subject { post_with_token(organization, "/api/v1/plans/invalid_code/fixed_charges", {fixed_charge: create_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when add_on does not exist" do + let(:create_params) do + { + add_on_id: "invalid_id", + code: "new_fixed_charge_code", + charge_model: "standard" + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("add_on") + end + end + + context "with taxes" do + let(:tax) { create(:tax, organization:) } + let(:create_params) do + { + add_on_id: add_on.id, + code: "taxed_fixed_charge", + charge_model: "standard", + units: 1, + properties: {amount: "100"}, + tax_codes: [tax.code] + } + end + + it "creates a fixed charge with taxes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:taxes]).to be_present + expect(json[:fixed_charge][:taxes].length).to eq(1) + expect(json[:fixed_charge][:taxes].first[:code]).to eq(tax.code) + end + end + + context "with cascade_updates" do + subject { post_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges", {fixed_charge: create_params.merge(cascade_updates: true)}) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + + before do + create(:subscription, plan: child_plan, status: :active) + allow(FixedCharges::CreateChildrenJob).to receive(:perform_later) + end + + it "triggers cascade creation to children" do + subject + + expect(response).to have_http_status(:success) + expect(FixedCharges::CreateChildrenJob).to have_received(:perform_later) + end + end + end + + describe "PUT /api/v1/plans/:plan_code/fixed_charges/:code" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/#{fixed_charge.code}", {fixed_charge: update_params}) } + + let(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + let(:update_params) do + { + invoice_display_name: "Updated Fixed Charge Name", + charge_model: "standard", + units: 20, + properties: {amount: "200"} + } + end + + it "updates the fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:invoice_display_name]).to eq("Updated Fixed Charge Name") + expect(json[:fixed_charge][:units]).to eq("20.0") + expect(json[:fixed_charge][:properties][:amount]).to eq("200") + end + + context "when plan does not exist" do + subject { put_with_token(organization, "/api/v1/plans/invalid_code/fixed_charges/#{fixed_charge.code}", {fixed_charge: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when fixed charge does not exist" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/invalid_code", {fixed_charge: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("fixed_charge") + end + end + + context "when plan is attached to subscriptions" do + let(:subscription) { create(:subscription, plan:) } + + before { subscription } + + it "updates only allowed fields" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:invoice_display_name]).to eq("Updated Fixed Charge Name") + end + end + + context "with cascade_updates" do + subject { put_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/#{fixed_charge.code}", {fixed_charge: update_params.merge(cascade_updates: true)}) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, organization:, add_on:, parent: fixed_charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_fixed_charge + allow(FixedCharges::UpdateChildrenJob).to receive(:perform_later) + end + + it "passes cascade_updates to the service" do + subject + + expect(response).to have_http_status(:success) + expect(FixedCharges::UpdateChildrenJob).to have_received(:perform_later) + end + end + end + + describe "DELETE /api/v1/plans/:plan_code/fixed_charges/:code" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/#{fixed_charge.code}") } + + let(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + + it "soft deletes the fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:lago_id]).to eq(fixed_charge.id) + expect(fixed_charge.reload.deleted_at).to be_present + end + + context "when plan does not exist" do + subject { delete_with_token(organization, "/api/v1/plans/invalid_code/fixed_charges/#{fixed_charge.code}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("plan") + end + end + + context "when fixed charge does not exist" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/invalid_code") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("fixed_charge") + end + end + + context "with cascade_updates" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan.code}/fixed_charges/#{fixed_charge.code}", {fixed_charge: {cascade_updates: true}}) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, organization:, add_on:, parent: fixed_charge) } + + before do + child_fixed_charge + allow(FixedCharges::DestroyChildrenJob).to receive(:perform_later) + end + + it "cascades the deletion to children" do + subject + + expect(response).to have_http_status(:success) + expect(FixedCharges::DestroyChildrenJob).to have_received(:perform_later).with(fixed_charge.id) + end + end + end +end diff --git a/spec/requests/api/v1/plans/metadata_controller_spec.rb b/spec/requests/api/v1/plans/metadata_controller_spec.rb new file mode 100644 index 0000000..7b2fa3a --- /dev/null +++ b/spec/requests/api/v1/plans/metadata_controller_spec.rb @@ -0,0 +1,252 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Plans::MetadataController do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + + describe "POST /api/v1/plans/:code/metadata" do + subject { post_with_token(organization, "/api/v1/plans/#{plan_code}/metadata", {metadata: params}) } + + let(:plan_code) { plan.code } + let(:params) { {foo: "bar", baz: "qux"} } + + it_behaves_like "requires API permission", "plan", "write" + + context "when plan is not found" do + let(:plan_code) { "invalid_code" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("plan") + end + end + + context "when plan has no metadata" do + it "creates metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(plan.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when plan has existing metadata" do + before { create(:item_metadata, owner: plan, organization:, value: {old: "value", foo: "old"}) } + + it "replaces all metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(plan.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when params are empty" do + let(:params) { {} } + + before { create(:item_metadata, owner: plan, organization:, value: {old: "value"}) } + + it "replaces metadata with empty hash" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq({}) + expect(plan.reload.metadata.value).to eq({}) + end + end + + context "when params are empty and metadata does not exist" do + let(:params) { {} } + + it "creates metadata with empty hash" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq({}) + expect(plan.reload.metadata.value).to eq({}) + end + end + + context "when metadata param is not provided" do + subject { post_with_token(organization, "/api/v1/plans/#{plan_code}/metadata", {}) } + + it "does not create metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(nil) + expect(plan.reload.metadata).to eq(nil) + end + end + end + + describe "PATCH /api/v1/plans/:code/metadata" do + subject { patch_with_token(organization, "/api/v1/plans/#{plan_code}/metadata", {metadata: params}) } + + let(:plan_code) { plan.code } + let(:params) { {foo: "bar", baz: "qux"} } + + it_behaves_like "requires API permission", "plan", "write" + + context "when plan is not found" do + let(:plan_code) { "invalid_code" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("plan") + end + end + + context "when plan has no metadata" do + it "creates metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(plan.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when plan has existing metadata" do + before { create(:item_metadata, owner: plan, organization:, value: {"old" => "value", "foo" => "old"}) } + + it "merges metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(old: "value", foo: "bar", baz: "qux") + expect(plan.reload.metadata.value).to eq("old" => "value", "foo" => "bar", "baz" => "qux") + end + end + + context "when params are empty and metadata exists" do + let(:params) { {} } + + before { create(:item_metadata, owner: plan, organization:, value: {"old" => "value"}) } + + it "keeps existing metadata unchanged" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(old: "value") + expect(plan.reload.metadata.value).to eq("old" => "value") + end + end + + context "when params are empty and metadata does not exist" do + let(:params) { {} } + + it "does not create metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(plan.reload.metadata).to be_nil + end + end + + context "when metadata param is not provided" do + subject { patch_with_token(organization, "/api/v1/plans/#{plan_code}/metadata", {}) } + + it "does not create metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(plan.reload.metadata).to be_nil + end + end + end + + describe "DELETE /api/v1/plans/:code/metadata" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan_code}/metadata") } + + let(:plan_code) { plan.code } + + it_behaves_like "requires API permission", "plan", "write" + + context "when plan is not found" do + let(:plan_code) { "invalid_code" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("plan") + end + end + + context "when plan has metadata" do + before { create(:item_metadata, owner: plan, organization:, value: {"foo" => "bar"}) } + + it "deletes all metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(plan.reload.metadata).to be_nil + end + end + + context "when plan has no metadata" do + it "returns success with nil metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(plan.reload.metadata).to be_nil + end + end + end + + describe "DELETE /api/v1/plans/:code/metadata/:key" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan_code}/metadata/#{key}") } + + let(:plan_code) { plan.code } + let(:key) { "foo" } + + it_behaves_like "requires API permission", "plan", "write" + + context "when plan is not found" do + let(:plan_code) { "invalid_code" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("plan") + end + end + + context "when plan has no metadata" do + it "returns not found error" do + subject + expect(response).to be_not_found_error("metadata") + end + end + + context "when key exists in metadata" do + before { create(:item_metadata, owner: plan, organization:, value: {"foo" => "bar", "baz" => "qux"}) } + + it "deletes the key" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(baz: "qux") + expect(plan.reload.metadata.value).to eq("baz" => "qux") + end + end + + context "when key does not exist in metadata" do + before { create(:item_metadata, owner: plan, organization:, value: {"baz" => "qux"}) } + + it "returns success without changing metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(baz: "qux") + expect(plan.reload.metadata.value).to eq("baz" => "qux") + end + end + end +end diff --git a/spec/requests/api/v1/plans_controller_spec.rb b/spec/requests/api/v1/plans_controller_spec.rb new file mode 100644 index 0000000..17aa814 --- /dev/null +++ b/spec/requests/api/v1/plans_controller_spec.rb @@ -0,0 +1,1203 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::PlansController do + let(:tax) { create(:tax, organization:) } + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:plan) { create(:plan, code: "plan_code") } + + describe "POST /api/v1/plans" do + subject { post_with_token(organization, "/api/v1/plans", {plan: create_params}) } + + let(:create_params) do + { + name: "P1", + invoice_display_name: "P1 invoice name", + code: "plan_code", + interval:, + description: "description", + amount_cents: 100, + amount_currency: "EUR", + trial_period: 1, + pay_in_advance: false, + minimum_commitment: { + amount_cents: 1000, + invoice_display_name: "Minimum commitment" + }, + charges: [ + { + billable_metric_id: billable_metric.id, + code: "charge_code", + charge_model: "standard", + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice", + properties: { + amount: "0.22" + }, + tax_codes:, + applied_pricing_unit: { + code: pricing_unit.code, + conversion_rate: 1.25 + } + } + ], + fixed_charges: [ + { + code: "fixed_charge_code", + invoice_display_name: "Fixed charge 1", + units: 1, + add_on_id: add_on.id, + charge_model: "standard", + pay_in_advance: true, + prorated: true, + properties: { + amount: "10" + }, + tax_codes: + } + ], + usage_thresholds: [ + amount_cents: 100, + threshold_display_name: "Threshold 1" + ] + } + end + let(:tax_codes) { [tax.code] } + let(:pricing_unit) { create(:pricing_unit, organization:) } + + context "when interval is empty" do + let(:interval) { nil } + + it "returns an error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({interval: %w[value_is_invalid]}) + end + end + + context "when interval is present" do + let(:interval) { "weekly" } + + include_examples "requires API permission", "plan", "write" + + it "creates a plan" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:plan][:lago_id]).to be_present + expect(json[:plan][:code]).to eq(create_params[:code]) + expect(json[:plan][:name]).to eq(create_params[:name]) + expect(json[:plan][:invoice_display_name]).to eq(create_params[:invoice_display_name]) + expect(json[:plan][:created_at]).to be_present + expect(json[:plan][:charges].first[:lago_id]).to be_present + expect(json[:plan][:charges].first[:code]).to eq("charge_code") + expect(json[:plan][:fixed_charges].first[:lago_id]).to be_present + expect(json[:plan][:fixed_charges].first[:code]).to eq("fixed_charge_code") + expect(json[:plan][:fixed_charges].first[:taxes].first[:code]).to eq(tax.code) + end + + context "when license is not premium" do + it "ignores premium fields" do + subject + + expect(response).to have_http_status(:success) + charge = json[:plan][:charges].first + expect(charge[:invoiceable]).to be true + expect(charge[:regroup_paid_fees]).to be_nil + expect(charge[:applied_pricing_unit]).to be_nil + end + + context "with accepts_target_wallet on charge" do + before do + create_params[:charges].first[:accepts_target_wallet] = true + end + + it "ignores accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:charges].first[:accepts_target_wallet]).to be false + end + end + end + + context "when license is premium", :premium do + it "updates premium fields" do + subject + + expect(response).to have_http_status(:success) + charge = json[:plan][:charges].first + expect(charge[:invoiceable]).to be false + expect(charge[:regroup_paid_fees]).to eq "invoice" + + expect(charge[:applied_pricing_unit]).to eq({ + conversion_rate: "1.25", + code: pricing_unit.code + }) + end + + context "with accepts_target_wallet on charge" do + before do + create_params[:charges].first[:accepts_target_wallet] = true + end + + context "when events_targeting_wallets is enabled" do + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "sets accepts_target_wallet on charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:charges].first[:accepts_target_wallet]).to be true + end + end + + context "when events_targeting_wallets is not enabled" do + it "does not set accepts_target_wallet on charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:charges].first[:accepts_target_wallet]).to be false + end + end + end + end + + context "with minimum commitment" do + context "when license is premium", :premium do + it "creates a plan with minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment][:lago_id]).to be_present + end + end + + context "when license is not premium" do + it "does not create minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment]).not_to be_present + end + end + end + + context "with usage thresholds" do + context "when license is premium", :premium do + context "when progressive billing premium integration is present" do + before do + organization.update!(premium_integrations: ["progressive_billing"]) + end + + it "creates a plan with usage thresholds" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:usage_thresholds].first[:lago_id]).to be_present + expect(json[:plan][:usage_thresholds].first[:amount_cents]).to eq(100) + expect(json[:plan][:applicable_usage_thresholds].first[:amount_cents]).to eq(100) + expect(json[:plan][:applicable_usage_thresholds].first[:threshold_display_name]).to eq("Threshold 1") + end + end + + context "when progressive billing premium integration is not present" do + it "does not create usage thresholds" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:usage_thresholds].count).to eq(0) + end + end + end + + context "when license is not premium" do + it "does not create usage thresholds" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:usage_thresholds].count).to eq(0) + end + end + end + + context "with graduated charges" do + let(:create_params) do + { + name: "P1", + code: "plan_code", + interval: "weekly", + description: "description", + amount_cents: 100, + amount_currency: "EUR", + trial_period: 1, + pay_in_advance: false, + charges: [ + { + billable_metric_id: billable_metric.id, + code: "graduated_charge_code", + charge_model: "graduated", + properties: { + graduated_ranges: [ + { + to_value: 1, + from_value: 0, + flat_amount: "0", + per_unit_amount: "0" + }, + { + to_value: nil, + from_value: 2, + flat_amount: "0", + per_unit_amount: "3200" + } + ] + } + } + ] + } + end + + it "creates a plan" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:plan][:lago_id]).to be_present + expect(json[:plan][:code]).to eq(create_params[:code]) + expect(json[:plan][:name]).to eq(create_params[:name]) + expect(json[:plan][:created_at]).to be_present + expect(json[:plan][:charges].first[:lago_id]).to be_present + end + end + + context "with graduated fixed charges" do + let(:create_params) do + { + name: "P1", + code: "plan_code", + interval: "weekly", + description: "description", + amount_cents: 100, + amount_currency: "EUR", + trial_period: 1, + pay_in_advance: false, + fixed_charges: [ + { + code: "graduated_fixed_charge_code", + invoice_display_name: "Fixed charge 1", + units: 1, + add_on_id: add_on.id, + charge_model: "graduated", + properties: { + graduated_ranges: [ + { + to_value: 1, + from_value: 0, + flat_amount: "0", + per_unit_amount: "0" + }, + { + to_value: nil, + from_value: 2, + flat_amount: "0", + per_unit_amount: "3200" + } + ] + } + } + ] + } + end + + it "creates a plan" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:plan][:lago_id]).to be_present + expect(json[:plan][:code]).to eq(create_params[:code]) + expect(json[:plan][:name]).to eq(create_params[:name]) + expect(json[:plan][:created_at]).to be_present + expect(json[:plan][:fixed_charges].first[:lago_id]).to be_present + end + end + + context "without charges" do + let(:create_params) do + { + name: "P1", + code: "plan_code", + interval: "weekly", + description: "description", + amount_cents: 100, + amount_currency: "EUR", + trial_period: 1, + pay_in_advance: false + } + end + + it "creates a plan" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:plan][:lago_id]).to be_present + expect(json[:plan][:code]).to eq(create_params[:code]) + expect(json[:plan][:name]).to eq(create_params[:name]) + expect(json[:plan][:created_at]).to be_present + expect(json[:plan][:charges].count).to eq(0) + expect(json[:plan][:fixed_charges].count).to eq(0) + end + end + + context "with unknown tax code on charge" do + let(:tax_codes) { ["unknown"] } + + it "returns a 404 response" do + subject + expect(response).to be_not_found_error("tax") + end + end + + context "with not found models for charges and fixed charges" do + context "when billable_metric for charge is not found" do + before { create_params[:charges].first[:billable_metric_id] = "unknown" } + + it "returns a 404 response" do + subject + expect(response).to be_not_found_error("billable_metrics") + end + end + + context "when add_on for fixed charge is not found" do + before { create_params[:fixed_charges].first[:add_on_id] = "unknown" } + + it "returns a 404 response" do + subject + expect(response).to be_not_found_error("add_ons") + end + end + end + end + end + + describe "PUT /api/v1/plans/:code" do + subject do + put_with_token( + organization, + "/api/v1/plans/#{plan_code}", + {plan: update_params} + ) + end + + let(:minimum_commitment) { create(:commitment, plan:) } + let(:plan) { create(:plan, organization:) } + let(:plan_code) { plan.code } + let(:code) { "plan_code" } + let(:tax_codes) { [tax.code] } + + let(:update_params) do + { + name: "P1", + code:, + interval: "weekly", + description: "description", + amount_cents: 100, + amount_currency: "EUR", + trial_period: 1, + pay_in_advance: false, + charges: charges_params, + fixed_charges: fixed_charges_params, + usage_thresholds: usage_thresholds_params + } + end + + let(:usage_thresholds_params) do + [ + { + amount_cents: 7_000, + threshold_display_name: "Updated threshold" + } + ] + end + + let(:charges_params) do + [ + { + billable_metric_id: billable_metric.id, + code: "charge_code", + charge_model: "standard", + properties: { + amount: "0.22" + }, + tax_codes: + } + ] + end + + let(:fixed_charges_params) do + [ + { + code: "fixed_charge_code", + units: 1, + add_on_id: add_on.id, + charge_model: "standard", + properties: { + amount: "10" + }, + tax_codes: + } + ] + end + + let(:minimum_commitment_params) do + { + minimum_commitment: { + amount_cents: 5000, + invoice_display_name: "Minimum commitment updated" + } + } + end + + include_examples "requires API permission", "plan", "write" + + it "updates a plan" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:lago_id]).to eq(plan.id) + expect(json[:plan][:code]).to eq(update_params[:code]) + expect(json[:plan][:entitlements]).to be_empty + end + + context "when plan does not exist" do + let(:plan_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when plan code already exists in organization scope (validation error)" do + let(:other_org_plan) { create(:plan, organization:) } + let(:code) { other_org_plan.code } + + it "returns unprocessable_entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "when license is not premium" do + let(:charges_params) do + [ + { + billable_metric_id: billable_metric.id, + code: "charge_code", + charge_model: "standard", + properties: { + amount: "0.22" + }, + tax_codes:, + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice" + } + ] + end + + it "ignores premium fields" do + subject + + expect(response).to have_http_status(:success) + charge = json[:plan][:charges].first + expect(charge[:pay_in_advance]).to be true + expect(charge[:invoiceable]).to be true + expect(charge[:regroup_paid_fees]).to be_nil + end + + context "with accepts_target_wallet on charge" do + before do + charges_params.first[:accepts_target_wallet] = true + end + + it "ignores accepts_target_wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:charges].first[:accepts_target_wallet]).to be false + end + end + end + + context "when license is premium", :premium do + let(:charges_params) do + [ + { + billable_metric_id: billable_metric.id, + code: "charge_code", + charge_model: "standard", + properties: { + amount: "0.22" + }, + tax_codes:, + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice" + } + ] + end + + before { organization.update!(premium_integrations: ["progressive_billing"]) } + + it "updates premium fields" do + subject + + expect(response).to have_http_status(:success) + charge = json[:plan][:charges].first + expect(charge[:pay_in_advance]).to be true + expect(charge[:invoiceable]).to be false + expect(charge[:regroup_paid_fees]).to eq "invoice" + + usage_threshold = json[:plan][:usage_thresholds].sole + expect(usage_threshold[:amount_cents]).to eq(7_000) + expect(usage_threshold[:threshold_display_name]).to eq("Updated threshold") + + applicable_usage_threshold = json[:plan][:applicable_usage_thresholds].sole + expect(applicable_usage_threshold[:amount_cents]).to eq(7_000) + expect(applicable_usage_threshold[:threshold_display_name]).to eq("Updated threshold") + end + + context "with accepts_target_wallet on charge" do + before do + charges_params.first[:accepts_target_wallet] = true + end + + context "when events_targeting_wallets is enabled" do + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "sets accepts_target_wallet on charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:charges].first[:accepts_target_wallet]).to be true + end + end + + context "when events_targeting_wallets is not enabled" do + it "does not set accepts_target_wallet on charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:charges].first[:accepts_target_wallet]).to be false + end + end + end + end + + context "when plan has no minimum commitment" do + context "when request contains minimum commitment params" do + before { update_params.merge!(minimum_commitment_params) } + + context "when license is premium", :premium do + it "creates minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment][:amount_cents]) + .to eq(update_params[:minimum_commitment][:amount_cents]) + end + end + + context "when license is not premium" do + it "does not create minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment]).to be_nil + end + end + end + + context "when request does not contain minimum commitment params" do + context "when license is premium", :premium do + it "does not create minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment]).to be_nil + end + end + + context "when license is not premium" do + it "does not create minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment]).to be_nil + end + end + end + end + + context "when plan has one minimum commitment" do + before { minimum_commitment } + + context "when request contains minimum commitment params" do + before { update_params.merge!(minimum_commitment_params) } + + context "when minimum commitment params are an empty hash" do + let(:minimum_commitment_params) { {minimum_commitment: {}} } + + context "when license is premium", :premium do + it "deletes minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment]).to be_nil + end + end + + context "when license is not premium" do + it "does not delete the minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment][:amount_cents]).to eq(minimum_commitment.amount_cents) + end + end + end + + context "when minimum commitment params are not an empty hash" do + context "when license is premium", :premium do + it "updates minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment][:amount_cents]) + .to eq(update_params[:minimum_commitment][:amount_cents]) + end + end + + context "when license is not premium" do + it "does not update minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment][:amount_cents]).to eq(minimum_commitment.amount_cents) + end + end + end + end + + context "when request does not contain minimum commitment params" do + context "when license is premium", :premium do + it "does not update minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment][:amount_cents]).to eq(minimum_commitment.amount_cents) + end + end + + context "when license is not premium" do + it "does not update minimum commitment" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:minimum_commitment][:amount_cents]).to eq(minimum_commitment.amount_cents) + end + end + end + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan:, invoice_display_name: "Fixed charge 1") } + let(:fixed_charges_params) do + [ + { + id: fixed_charge.id, + invoice_display_name: "Fixed charge 1 updated", + units: 1, + add_on_id: add_on.id, + charge_model: "standard", + properties: {amount: "15"}, + tax_codes: + }, + { + code: "fixed_charge_2_code", + invoice_display_name: "Fixed charge 2", + units: 1, + add_on_id: add_on.id, + charge_model: "standard", + properties: {amount: "10"} + } + ] + end + + before { fixed_charge } + + it "returns plan with updated fixed charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:fixed_charges].count).to eq(2) + expect(json[:plan][:fixed_charges].first[:invoice_display_name]).to eq("Fixed charge 1 updated") + expect(json[:plan][:fixed_charges].first[:taxes].first[:code]).to eq(tax.code) + expect(json[:plan][:fixed_charges].last[:invoice_display_name]).to eq("Fixed charge 2") + expect(json[:plan][:fixed_charges].last[:taxes]).to be_empty + end + end + + context "when adding a fixed charge" do + let(:plan) { create(:plan, organization:, interval: :weekly) } + let(:subscription) { create(:subscription, :active, :anniversary, plan:, started_at:, subscription_at: started_at) } + let(:started_at) { 3.days.ago } + + before { subscription } + + context "when apply_units_immediately is true" do + let(:fixed_charges_params) do + [ + { + code: "fixed_charge_2_code", + apply_units_immediately: true, + invoice_display_name: "Fixed charge 2", + units: 100, + add_on_id: add_on.id, + charge_model: "standard", + properties: {amount: "10"}, + tax_codes: [tax.code] + } + ] + end + + it "adds a fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:fixed_charges].count).to eq(1) + expect(json[:plan][:fixed_charges].first[:invoice_display_name]).to eq("Fixed charge 2") + expect(json[:plan][:fixed_charges].first[:units]).to eq("100.0") + expect(json[:plan][:fixed_charges].first[:taxes].first[:code]).to eq(tax.code) + end + + it "creates fixed charge events for all active subscriptions with current timestamp" do + expect { subject }.to change(FixedChargeEvent, :count).by(1) + + fixed_charge = FixedCharge.find(json[:plan][:fixed_charges].first[:lago_id]) + + expect(fixed_charge.events.first).to have_attributes( + subscription:, + fixed_charge:, + units: 100, + timestamp: be_within(5.seconds).of(Time.current) + ) + end + end + + context "when apply_units_immediately is false" do + let(:fixed_charges_params) do + [ + { + code: "fixed_charge_2_code", + apply_units_immediately: false, + invoice_display_name: "Fixed charge 2", + units: 100, + add_on_id: add_on.id, + charge_model: "standard", + properties: {amount: "10"} + } + ] + end + + it "adds a fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:fixed_charges].count).to eq(1) + expect(json[:plan][:fixed_charges].first[:invoice_display_name]).to eq("Fixed charge 2") + expect(json[:plan][:fixed_charges].first[:units]).to eq("100.0") + end + + it "creates fixed charge events for all active subscriptions with next billing period timestamp" do + expect { subject }.to change(FixedChargeEvent, :count).by(1) + + fixed_charge = FixedCharge.find(json[:plan][:fixed_charges].first[:lago_id]) + + expect(fixed_charge.events.first).to have_attributes( + subscription:, + fixed_charge:, + units: 100, + timestamp: be_within(1.second).of((started_at + 7.days).beginning_of_day) + ) + end + end + end + + context "when editing a fixed charge" do + let(:plan) { create(:plan, organization:, interval: :weekly) } + let(:subscription) { create(:subscription, :active, :anniversary, plan:, started_at:, subscription_at: started_at) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, units: 1) } + let(:started_at) { 3.days.ago } + + before { subscription } + + context "when apply_units_immediately is true" do + let(:fixed_charges_params) do + [ + { + id: fixed_charge.id, + apply_units_immediately: true, + units: 25, + properties: {amount: "10"} + } + ] + end + + it "updates a fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:fixed_charges].count).to eq(1) + expect(json[:plan][:fixed_charges].first[:units]).to eq("25.0") + end + + it "creates fixed charge events for all active subscriptions with current timestamp" do + expect { subject }.to change(FixedChargeEvent, :count).by(1) + + expect(fixed_charge.events.first).to have_attributes( + subscription:, + fixed_charge:, + units: 25, + timestamp: be_within(5.seconds).of(Time.current) + ) + end + end + + context "when apply_units_immediately is false" do + let(:fixed_charges_params) do + [ + { + id: fixed_charge.id, + apply_units_immediately: false, + units: 25, + properties: {amount: "10"} + } + ] + end + + it "updates a fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:fixed_charges].count).to eq(1) + expect(json[:plan][:fixed_charges].first[:units]).to eq("25.0") + end + + it "creates fixed charge events for all active subscriptions with next billing period timestamp" do + expect { subject }.to change(FixedChargeEvent, :count).by(1) + + expect(fixed_charge.events.first).to have_attributes( + subscription:, + fixed_charge:, + units: 25, + timestamp: be_within(1.second).of((started_at + 1.week).beginning_of_day) + ) + end + end + end + + describe "update conversion rate on charges", :premium do + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let!(:applied_pricing_unit) { create(:applied_pricing_unit, pricing_unitable: charge) } + + let(:charges_params) do + [ + { + id: charge.id, + charge_model: "standard", + billable_metric_id: billable_metric.id, + applied_pricing_unit: { + conversion_rate: "3.9" + } + } + ] + end + + it "updates conversion rate on charge's applied pricing unit" do + expect { subject }.to change { applied_pricing_unit.reload.conversion_rate }.to(3.9) + expect(response).to have_http_status(:success) + end + end + end + + describe "GET /api/v1/plans/:code" do + subject { get_with_token(organization, "/api/v1/plans/#{plan_code}") } + + let(:plan) { create(:plan, organization:) } + let(:plan_code) { plan.code } + + include_examples "requires API permission", "plan", "read" + + it "returns a plan" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:lago_id]).to eq(plan.id) + expect(json[:plan][:code]).to eq(plan.code) + end + + context "when plan is discarded" do + before do + plan.discard + end + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when plan has minimum commitment" do + before { create(:commitment, plan:) } + + it "returns a plan" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:lago_id]).to eq(plan.id) + expect(json[:plan][:code]).to eq(plan.code) + expect(json[:plan][:minimum_commitment][:lago_id]).to eq(plan.minimum_commitment.id) + end + end + + context "when plan has usage thresholds" do + before do + create(:usage_threshold, plan:) + create(:usage_threshold, :recurring, plan:) + end + + it "returns a plan" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:lago_id]).to eq(plan.id) + expect(json[:plan][:code]).to eq(plan.code) + expect(json[:plan][:usage_thresholds].count).to eq(2) + expect(json[:plan][:applicable_usage_thresholds].count).to eq(2) + end + end + + context "when plan has entitlements" do + before do + feature = create(:feature, organization:, code: :seats) + entitlement = create(:entitlement, plan:, feature:) + privileges = create_list(:privilege, 2, feature: feature) + create(:entitlement_value, privilege: privileges.first, entitlement: entitlement) + create(:entitlement_value, privilege: privileges.last, entitlement: entitlement) + end + + it "returns a plan" do + subject + + expect(response).to have_http_status(:success) + ent = json[:plan][:entitlements].sole + expect(ent[:code]).to eq "seats" + expect(ent[:privileges].count).to eq 2 + end + end + + context "when plan does not exist" do + let(:plan_code) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1/plans/:code" do + subject { delete_with_token(organization, "/api/v1/plans/#{plan_code}") } + + let(:plan) { create(:plan, organization:) } + let(:plan_code) { plan.code } + + include_examples "requires API permission", "plan", "write" + + context "when plan exists" do + it "marks plan as pending_deletion" do + expect { subject }.to change { plan.reload.pending_deletion }.from(false).to(true) + end + + it "marks children plan as pending_deletion" do + children_plan = create(:plan, parent_id: plan.id) + + expect { subject } + .to change { children_plan.reload.pending_deletion }.from(false).to(true) + end + + it "returns deleted plan" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:lago_id]).to eq(plan.id) + expect(json[:plan][:code]).to eq(plan.code) + expect(json[:plan][:applicable_usage_thresholds]).to be_empty + expect(json[:plan][:entitlements]).to be_empty + end + end + + context "when plan does not exist" do + let(:plan_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/plans" do + subject { get_with_token(organization, "/api/v1/plans?page=1&per_page=1") } + + let(:plan) { create(:plan, organization:) } + + before { create(:usage_threshold, plan:) } + + include_examples "requires API permission", "plan", "read" + + it "returns plans" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:plans].count).to eq(1) + expect(json[:plans].first[:lago_id]).to eq(plan.id) + expect(json[:plans].first[:code]).to eq(plan.code) + expect(json[:plans].first[:usage_thresholds].count).to eq(1) + expect(json[:plans].first[:applicable_usage_thresholds].count).to eq(1) + end + + context "when pending for deletion plan exists" do + subject { get_with_token(organization, "/api/v1/plans") } + + let(:plan_pending_for_deletion) do + create(:plan, organization:, pending_deletion: true) + end + + before { plan_pending_for_deletion } + + it "includes the plan in the response" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:plans].count).to eq(2) + expect(json[:plans].map { |p| p[:lago_id] }).to include(plan_pending_for_deletion.id) + end + end + + context "with pagination" do + before { create(:plan, organization:) } + + it "returns plans with correct meta data" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:plans].count).to eq(1) + expect(json[:plans].first[:entitlements]).to be_empty + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end + + describe "POST /api/v1/plans with metadata" do + subject { post_with_token(organization, "/api/v1/plans", {plan: create_params}) } + + let(:create_params) do + { + name: "Plan with metadata", + code: "plan_with_metadata", + interval: "monthly", + amount_cents: 100, + amount_currency: "EUR", + pay_in_advance: false, + charges: [], + metadata: {foo: "bar", baz: "qux"} + } + end + + include_examples "requires API permission", "plan", "write" + + it "creates a plan with metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:lago_id]).to be_present + expect(json[:plan][:code]).to eq("plan_with_metadata") + expect(json[:plan][:metadata]).to eq({foo: "bar", baz: "qux"}) + end + + context "when metadata is empty" do + let(:create_params) do + { + name: "Plan with empty metadata", + code: "plan_empty_metadata", + interval: "monthly", + amount_cents: 100, + amount_currency: "EUR", + pay_in_advance: false, + charges: [], + metadata: {} + } + end + + it "creates a plan with empty metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:metadata]).to eq({}) + end + end + + context "when metadata is not provided" do + let(:create_params) do + { + name: "Plan without metadata", + code: "plan_no_metadata", + interval: "monthly", + amount_cents: 100, + amount_currency: "EUR", + pay_in_advance: false, + charges: [] + } + end + + it "creates a plan without metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:plan][:metadata]).to eq(nil) + end + end + end +end diff --git a/spec/requests/api/v1/security_logs_controller_spec.rb b/spec/requests/api/v1/security_logs_controller_spec.rb new file mode 100644 index 0000000..50e25f6 --- /dev/null +++ b/spec/requests/api/v1/security_logs_controller_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::SecurityLogsController, clickhouse: true do + let!(:stored_ids) do + [ + {created_at: 1.hour.ago, logged_at: 1.hour.ago, log_type: "user", log_event: "user.signed_in", user:}, + {created_at: 2.hours.ago, logged_at: 2.hours.ago, log_type: "user", log_event: "other"}, + {created_at: 3.hours.ago, logged_at: 3.hours.ago, log_type: "api_key", log_event: "other", api_key:} + ].map { |data| create(:clickhouse_security_log, organization:, **data).log_id } + end + + let(:log_data) { {organization:, created_at: 1.day.ago, logged_at: 1.day.ago} } + let(:user) { create(:membership, organization:).user } + let(:api_key) { create(:api_key, organization:) } + let(:organization) { create(:organization) } + let(:params) { {} } + let(:returned_ids) { json[:security_logs].map { |l| l[:log_id] } } + + describe "GET /api/v1/security_logs" do + subject { get_with_token(organization, "/api/v1/security_logs", params) } + + let(:params) { {to_date: Time.current.iso8601} } + + context "with a free organization" do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "with a premium organization without the `security_logs` feature", :premium do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:code]).to eq("forbidden") + end + end + + context "with a premium organization with the `security_logs` feature", :premium do + before { organization.update!(premium_integrations: ["security_logs"]) } + + include_examples "requires API permission", "security_log", "read" + + context "without filters" do + it "returns security logs" do + subject + + expect(response).to have_http_status(:success) + expect(returned_ids).to eq(stored_ids) + end + end + + context "when to_date is missing" do + let(:params) { {} } + + it "returns a validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include(:to_date) + end + end + + context "when filtering by from_date" do + let(:params) { {to_date: Time.current.iso8601, from_date: 2.5.hours.ago.iso8601} } + + it "returns security logs for the specified date range" do + subject + + expect(response).to have_http_status(:success) + expect(returned_ids).to eq(stored_ids[..1]) + end + end + + context "when filtering by user_ids" do + let(:params) { {to_date: Time.current.iso8601, user_ids: [user.id]} } + + it "returns security logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(returned_ids).to contain_exactly(stored_ids.first) + end + end + + context "when filtering by api_key_ids" do + let(:params) { {to_date: Time.current.iso8601, api_key_ids: [api_key.id]} } + + it "returns security logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(returned_ids).to contain_exactly(stored_ids.last) + end + end + + context "when filtering by log_types" do + let(:params) { {to_date: Time.current.iso8601, log_types: ["user"]} } + + it "returns security logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(returned_ids).to eq(stored_ids[..1]) + end + end + + context "when filtering by log_events" do + let(:params) { {to_date: Time.current.iso8601, log_events: ["user.signed_in"]} } + + it "returns security logs for the specified filter" do + subject + + expect(response).to have_http_status(:success) + expect(returned_ids).to contain_exactly(stored_ids.first) + end + end + + context "with pagination" do + let(:params) { {to_date: Time.current.iso8601, page: 1, per_page: 1} } + + it "returns security logs with correct meta data" do + subject + + expect(response).to have_http_status(:success) + expect(returned_ids).to contain_exactly(stored_ids.first) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(3) + expect(json[:meta][:total_count]).to eq(3) + end + end + end + end + + describe "GET /api/v1/security_logs/:log_id" do + subject { get_with_token(organization, "/api/v1/security_logs/#{stored_ids.first}", params) } + + context "with a free organization" do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "with a premium organization without the `security_logs` feature", :premium do + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:code]).to eq("forbidden") + end + end + + context "with a premium organization with the `security_logs` feature", :premium do + before { organization.update!(premium_integrations: ["security_logs"]) } + + include_examples "requires API permission", "security_log", "read" + + it "returns security log" do + subject + + expect(response).to have_http_status(:success) + expect(json[:security_log][:log_id]).to eq(stored_ids.first) + end + + context "when security log does not exist" do + subject { get_with_token(organization, "/api/v1/security_logs/unknown", params) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("security_log") + end + end + end + end +end diff --git a/spec/requests/api/v1/subscriptions/alerts_controller_spec.rb b/spec/requests/api/v1/subscriptions/alerts_controller_spec.rb new file mode 100644 index 0000000..714e382 --- /dev/null +++ b/spec/requests/api/v1/subscriptions/alerts_controller_spec.rb @@ -0,0 +1,461 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Subscriptions::AlertsController do + let(:external_id) { "sub+1" } + let(:external_id_query_param) { external_id } + let(:code) { "my-alert" } + let(:organization) { create(:organization) } + let(:subscription) { create(:subscription, external_id:, customer: create(:customer, organization: organization)) } + let(:alert) { create(:alert, :processed, code:, subscription_external_id: external_id, organization:) } + let(:deleted_alert) { create(:alert, :processed, deleted_at: Time.current, subscription_external_id: external_id, organization:, thresholds: []) } + + before do + subscription + alert + deleted_alert + end + + RSpec.shared_examples "returns error if subscription not found" do + let(:external_id_query_param) { "not-found-id" } + + it do + subject + expect(response).to be_not_found_error("subscription") + end + end + + describe "GET /api/v1/subscriptions/:external_id/alerts" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/alerts") } + + it_behaves_like "requires API permission", "alert", "read" + it_behaves_like "returns error if subscription not found" + + context "when there are alerts" do + it "retrieves a paginated list of alerts" do + subject + expect(json[:alerts].sole).to include({ + code:, + lago_id: alert.id, + billable_metric: be_nil, + previous_value: "800.0", + name: "General Alert", + created_at: be_present + }) + expect(json[:meta]).to eq({ + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + }) + end + end + + context "when there is no alerts" do + let(:alert) { nil } + + it do + subject + expect(json[:alerts]).to be_empty + expect(json[:meta][:total_count]).to eq 0 + end + end + end + + describe "POST /api/v1/subscriptions/:external_id/alerts" do + subject { post_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/alerts", {alert: params}) } + + let(:alert) { nil } + let(:params) do + { + code: "test", + name: "New Alert", + alert_type: "current_usage_amount", + thresholds: [ + {code: :notice, value: 1000}, + {code: :warn, value: 5000}, + {code: :alert, value: 2000, recurring: true} + ] + } + end + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if subscription not found" + + it do + subject + + expect(json[:alert]).to include({ + lago_id: be_present, + code: "test", + name: "New Alert", + previous_value: "0.0", + last_processed_at: be_nil, + created_at: be_present + }) + end + + context "when code already exists for this subscription" do + it do + create(:billable_metric_current_usage_amount_alert, organization:, code: params[:code], subscription_external_id: external_id) + + subject + expect(json).to eq({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {code: ["value_already_exist"]}, + status: 422 + }) + end + end + + context "when payload is missing required param" do + [:code, :alert_type, :thresholds].each do |field| + it do + params.delete(field) + subject + expect(json).to match({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {field => array_including("value_is_mandatory")}, + status: 422 + }) + end + end + end + + context "when alert_type is not supported" do + let(:params) do + { + code: "test", + alert_type: "not_supported", + thresholds: [{code: :notice, value: 1000}] + } + end + + it do + subject + expect(json).to eq({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {alert_type: ["invalid_type"]}, + status: 422 + }) + end + end + + context "with billable_metric" do + let(:params) do + { + code: "bm", + alert_type: "billable_metric_current_usage_amount", + billable_metric_code: "bm_code", + thresholds: [{code: :alert, value: 1000, recurring: true}] + } + end + + it "creates a billable_metric_current_usage_amount alert" do + create(:billable_metric, code: "bm_code", organization:) + subject + expect(json[:alert]).to include({ + lago_id: be_present, + alert_type: "billable_metric_current_usage_amount", + code: "bm" + }) + end + + context "when billable_metric is not found" do + it do + subject + expect(response).to be_not_found_error("billable_metric") + end + end + end + end + + describe "GET /api/v1/subscriptions/:external_id/alerts/:code" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/alerts/#{code}") } + + it_behaves_like "requires API permission", "alert", "read" + it_behaves_like "returns error if subscription not found" + + it do + subject + expect(json[:alert]).to include({ + code:, + lago_id: alert.id, + previous_value: "800.0", + name: "General Alert", + created_at: be_present + }) + end + + context "when alert is not found" do + let(:alert) { nil } + + it do + subject + expect(response).to be_not_found_error("alert") + end + end + end + + describe "PUT /api/v1/subscriptions/:external_id/alerts/:code" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/alerts/#{code}", {alert: params}) } + + let(:params) do + { + code: "test", + thresholds: [{code: :notice, value: 88_00}] + } + end + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if subscription not found" + + it "updates the alert" do + subject + + expect(json[:alert]).to include({ + lago_id: alert.id, + lago_organization_id: organization.id, + code: "test", + name: "General Alert", # Not updated if not part of params + previous_value: "800.0", + last_processed_at: be_present, + created_at: be_present + }) + end + + context "when code already exists for this subscription" do + it "does not update the alert" do + create(:billable_metric_current_usage_amount_alert, organization:, code: params[:code], subscription_external_id: external_id) + + subject + expect(json).to eq({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {code: ["value_already_exist"]}, + status: 422 + }) + + expect(alert.reload.name).to eq "General Alert" + expect(alert.reload.code).to eq "my-alert" + end + end + + context "when trying to update alert_type" do + let(:params) do + { + code: "test", + alert_type: "billable_metric_current_usage_amount", + billable_metric_code: create(:billable_metric, organization:).code + } + end + + it do + subject + expect(json).to eq({ + code: "validation_errors", + error: "Unprocessable Entity", + error_details: {billable_metric: ["value_must_be_blank"]}, # Because `param[:alert_type] as ignored + status: 422 + }) + end + end + + context "with billable_metric" do + let(:alert) { create(:billable_metric_current_usage_amount_alert, :processed, code:, subscription_external_id: external_id, organization:) } + let(:params) do + { + code: "bm", + billable_metric_code: "bm_code", + thresholds: [{code: :alert, value: 1000, recurring: true}] + } + end + + it "updates the billable_metric of the alert" do + create(:billable_metric, code: "bm_code", organization:) + subject + expect(json[:alert]).to include({ + lago_id: alert.id, + alert_type: "billable_metric_current_usage_amount", + code: "bm", + billable_metric: hash_including({code: "bm_code"}) + }) + end + + context "when billable_metric is not found" do + it do + subject + expect(response).to be_not_found_error("billable_metric") + end + end + end + end + + describe "DELETE /api/v1/subscriptions/:external_id/alerts/:code" do + subject { delete_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/alerts/#{code}") } + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if subscription not found" + + it "soft deletes the invoice" do + subject + expect(alert.reload.deleted_at).to be_within(5.seconds).of(Time.current) + end + + context "when alert is not found" do + let(:alert) { nil } + + it do + subject + expect(response).to be_not_found_error("alert") + end + end + end + + describe "POST /api/v1/subscriptions/:external_id/alerts (batch)" do + subject { post_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/alerts", params) } + + let(:alert) { nil } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:params) do + { + alerts: [ + { + code: "alert1", + name: "First Alert", + alert_type: "current_usage_amount", + thresholds: [{code: :notice, value: 1000}] + }, + { + code: "alert2", + alert_type: "billable_metric_current_usage_amount", + billable_metric_code: billable_metric.code, + thresholds: [{value: 2000}] + } + ] + } + end + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if subscription not found" + + it "creates multiple alerts" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:alerts].count).to eq 2 + expect(json[:alerts].map { |a| a[:code] }).to eq %w[alert1 alert2] + end + + context "when one alert is invalid" do + let(:params) do + { + alerts: [ + { + code: "alert1", + alert_type: "current_usage_amount", + thresholds: [{value: 1000}] + }, + { + code: "alert2", + alert_type: "current_usage_amount", + thresholds: [{value: 2000}] + } + ] + } + end + + it "returns validation errors and creates no alerts" do + expect { subject }.not_to change(UsageMonitoring::Alert, :count) + expect(response).to have_http_status(:unprocessable_entity) + expect(json[:code]).to eq "validation_errors" + end + end + + context "when alerts is empty" do + let(:params) { {alerts: []} } + + it "returns a validation error" do + subject + expect(response).to have_http_status(:bad_request) + expect(json[:error]).to include("value is empty or invalid: alert") + end + end + + context "when there are several alerts invalid" do + let(:params) do + { + alerts: [ + { + code: "duplicated", + alert_type: "current_usage_amount", + thresholds: [{value: 1000}] + }, + { + code: "alert2", + alert_type: "invalid_type", + thresholds: [{value: 2000}] + }, + { + code: "billable_metric_not_found", + alert_type: "billable_metric_current_usage_amount", + billable_metric_code: "this_one_will_not_be_found", + thresholds: [{value: 10}] + }, + { + code: "duplicated", + alert_type: "billable_metric_current_usage_amount", + billable_metric_code: billable_metric.code, + thresholds: [{value: 11}] + } + ] + } + end + + it "returns all the errors" do + expect { subject }.not_to change(UsageMonitoring::Alert, :count) + expect(response).to have_http_status(:unprocessable_entity) + expect(json[:code]).to eq "validation_errors" + errors = json[:error_details] + alert_params = params[:alerts] + expect(errors[:"1"][:params]).to eq(alert_params[1]) + expect(errors[:"1"][:errors]).to include("invalid_type") + + expect(errors[:"2"][:params]).to eq(alert_params[2]) + expect(errors[:"2"][:errors]).to include("billable_metric_not_found") + + expect(errors[:"3"][:params]).to eq(alert_params[3]) + # type is already taken + expect(errors[:"3"][:errors]).to include("value_already_exist") + end + end + end + + describe "DELETE /api/v1/subscriptions/:external_id/alerts" do + subject { delete_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/alerts") } + + it_behaves_like "requires API permission", "alert", "write" + it_behaves_like "returns error if subscription not found" + + it "soft deletes all alerts for the subscription" do + subject + + expect(response).to have_http_status(:ok) + end + + context "when there are no alerts" do + let(:alert) { nil } + + it "returns ok" do + subject + + expect(response).to have_http_status(:ok) + end + end + end +end diff --git a/spec/requests/api/v1/subscriptions/charges/filters_controller_spec.rb b/spec/requests/api/v1/subscriptions/charges/filters_controller_spec.rb new file mode 100644 index 0000000..9af37ab --- /dev/null +++ b/spec/requests/api/v1/subscriptions/charges/filters_controller_spec.rb @@ -0,0 +1,451 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Subscriptions::Charges::FiltersController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[us eu]) } + let(:external_id) { "sub_123" } + let(:external_id_query_param) { external_id } + let(:subscription) { create(:subscription, customer:, plan:, external_id:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + + before do + subscription + charge + billable_metric_filter + end + + describe "GET /api/v1/subscriptions/:external_id/charges/:code/filters" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters") } + + let(:charge_filter) { create(:charge_filter, charge:, organization:) } + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"], organization:) + end + + it_behaves_like "requires API permission", "subscription", "read" + + it "returns a list of charge filters" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filters]).to be_present + expect(json[:filters].length).to eq(1) + expect(json[:filters].first[:lago_id]).to eq(charge_filter.id) + end + + it "returns pagination metadata" do + subject + + expect(json[:meta]).to include( + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + ) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when charge does not exist" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/invalid_code/filters") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when subscription has plan override with charge override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + let(:charge_filter) { create(:charge_filter, charge: overridden_charge, organization:) } + + before do + overridden_charge + end + + it "returns filters from the overridden charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filters].length).to eq(1) + expect(json[:filters].first[:lago_id]).to eq(charge_filter.id) + end + end + end + + describe "GET /api/v1/subscriptions/:external_id/charges/:code/filters/:id" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters/#{charge_filter.id}") } + + let(:charge_filter) { create(:charge_filter, charge:, organization:, invoice_display_name: "US Region") } + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"], organization:) + end + + it_behaves_like "requires API permission", "subscription", "read" + + it "returns the charge filter" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:lago_id]).to eq(charge_filter.id) + expect(json[:filter][:invoice_display_name]).to eq("US Region") + expect(json[:filter][:values]).to eq({region: ["us"]}) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when charge does not exist" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/invalid_code/filters/#{charge_filter.id}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when charge filter does not exist" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters/#{SecureRandom.uuid}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge_filter") + end + end + end + + describe "POST /api/v1/subscriptions/:external_id/charges/:code/filters" do + subject { post_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters", {filter: create_params}) } + + let(:create_params) do + { + invoice_display_name: "US Region Filter", + properties: {amount: "50"}, + values: {billable_metric_filter.key => ["us"]} + } + end + + context "with premium license", :premium do + it_behaves_like "requires API permission", "subscription", "write" + + it "creates a plan override, charge override, and charge filter" do + expect { subject } + .to change(Plan, :count).by(1) + .and change(Charge, :count).by(1) + .and change(ChargeFilter, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:filter][:invoice_display_name]).to eq("US Region Filter") + expect(json[:filter][:properties]).to include(amount: "50") + expect(json[:filter][:values]).to eq({region: ["us"]}) + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when charge does not exist" do + subject { post_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/invalid_code/filters", {filter: create_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when values are missing" do + let(:create_params) do + { + invoice_display_name: "US Region Filter", + properties: {amount: "50"} + } + end + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_entity) + expect(json[:error_details]).to include(:values) + end + end + + context "when subscription already has plan override with charge override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + before { overridden_charge } + + it "does not create a new plan or charge" do + expect { subject } + .to not_change(Plan, :count) + .and not_change(Charge, :count) + .and change(ChargeFilter, :count).by(1) + end + + it "creates the filter on the existing charge override" do + subject + + expect(response).to have_http_status(:success) + new_filter = ChargeFilter.find(json[:filter][:lago_id]) + expect(new_filter.charge_id).to eq(overridden_charge.id) + end + end + end + + context "without premium license" do + it "returns forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end + + describe "PUT /api/v1/subscriptions/:external_id/charges/:code/filters/:id" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters/#{charge_filter.id}", {filter: update_params}) } + + let(:charge_filter) { create(:charge_filter, charge:, organization:, invoice_display_name: "Original Name", properties: {"amount" => "10"}) } + let(:update_params) do + { + invoice_display_name: "Updated Name", + properties: {amount: "100"} + } + end + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"], organization:) + end + + context "with premium license", :premium do + it_behaves_like "requires API permission", "subscription", "write" + + it "creates a plan override and charge override, then updates the filter" do + expect { subject } + .to change(Plan, :count).by(1) + .and change(Charge, :count).by(1) + .and change(ChargeFilter, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:filter][:invoice_display_name]).to eq("Updated Name") + expect(json[:filter][:properties]).to include(amount: "100") + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when charge does not exist" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/invalid_code/filters/#{charge_filter.id}", {filter: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when charge filter does not exist" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters/#{SecureRandom.uuid}", {filter: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge_filter") + end + end + + context "when subscription already has plan override with charge and filter override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + let(:charge_filter) { create(:charge_filter, charge: overridden_charge, organization:, invoice_display_name: "Original Name", properties: {"amount" => "10"}) } + + before do + overridden_charge + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"], organization:) + end + + it "does not create new plan, charge, or filter" do + expect { subject } + .to not_change(Plan, :count) + .and not_change(Charge, :count) + .and not_change(ChargeFilter, :count) + end + + it "updates the existing filter override" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:lago_id]).to eq(charge_filter.id) + expect(json[:filter][:invoice_display_name]).to eq("Updated Name") + expect(json[:filter][:properties]).to include(amount: "100") + end + end + end + + context "without premium license" do + it "returns forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end + + describe "DELETE /api/v1/subscriptions/:external_id/charges/:code/filters/:id" do + subject { delete_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters/#{charge_filter.id}") } + + let(:charge_filter) { create(:charge_filter, charge:, organization:) } + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"], organization:) + end + + before { charge_filter_value } + + context "with premium license", :premium do + it_behaves_like "requires API permission", "subscription", "write" + + it "creates a plan override and charge override, then soft deletes the filter" do + expect { subject } + .to change(Plan, :count).by(1) + .and change(Charge, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:filter][:lago_id]).to be_present + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when charge does not exist" do + subject { delete_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/invalid_code/filters/#{charge_filter.id}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when charge filter does not exist" do + subject { delete_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}/filters/#{SecureRandom.uuid}") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge_filter") + end + end + + context "when subscription already has plan override with charge and filter override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + let(:charge_filter) { create(:charge_filter, charge: overridden_charge, organization:) } + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["us"], organization:) + end + + before { overridden_charge } + + it "does not create new plan or charge" do + expect { subject } + .to not_change(Plan, :count) + .and not_change(Charge, :count) + end + + it "soft deletes the existing filter override" do + subject + + expect(response).to have_http_status(:success) + expect(json[:filter][:lago_id]).to eq(charge_filter.id) + expect(charge_filter.reload.deleted_at).to be_present + end + + it "soft deletes the charge filter values" do + subject + + expect(charge_filter_value.reload.deleted_at).to be_present + end + end + end + + context "without premium license" do + it "returns forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/requests/api/v1/subscriptions/charges_controller_spec.rb b/spec/requests/api/v1/subscriptions/charges_controller_spec.rb new file mode 100644 index 0000000..90c731e --- /dev/null +++ b/spec/requests/api/v1/subscriptions/charges_controller_spec.rb @@ -0,0 +1,259 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Subscriptions::ChargesController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:external_id) { "sub_123" } + let(:external_id_query_param) { external_id } + let(:subscription) { create(:subscription, customer:, plan:, external_id:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + + before do + subscription + charge + end + + describe "GET /api/v1/subscriptions/:external_id/charges" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges") } + + it_behaves_like "requires API permission", "subscription", "read" + + it "returns a list of charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charges]).to be_present + expect(json[:charges].length).to eq(1) + expect(json[:charges].first[:lago_id]).to eq(charge.id) + expect(json[:charges].first[:code]).to eq(charge.code) + end + + it "returns pagination metadata" do + subject + + expect(json[:meta]).to include( + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + ) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when subscription has plan override with charges" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge) } + + before { overridden_charge } + + it "returns overridden charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charges].length).to eq(1) + expect(json[:charges].first[:lago_id]).to eq(overridden_charge.id) + expect(json[:charges].first[:lago_parent_id]).to eq(charge.id) + end + end + + context "with pagination" do + let(:charges) { create_list(:standard_charge, 3, plan:, organization:, billable_metric:) } + + before do + charge.discard + charges + end + + it "returns paginated results" do + get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges?per_page=2&page=1") + + expect(response).to have_http_status(:success) + expect(json[:charges].length).to eq(2) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:total_pages]).to eq(2) + end + end + + context "when charges have applied taxes" do + let(:tax) { create(:tax, organization:) } + + before { create(:charge_applied_tax, charge:, tax:) } + + it "includes taxes in the response" do + subject + + expect(json[:charges].first[:taxes]).to be_an(Array) + expect(json[:charges].first[:taxes]).not_to be_empty + expect(json[:charges].first[:taxes].first[:code]).to eq(tax.code) + end + end + end + + describe "GET /api/v1/subscriptions/:external_id/charges/:code" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}") } + + it_behaves_like "requires API permission", "subscription", "read" + + it "returns the charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:lago_id]).to eq(charge.id) + expect(json[:charge][:code]).to eq(charge.code) + expect(json[:charge][:charge_model]).to eq("standard") + expect(json[:charge][:lago_billable_metric_id]).to eq(billable_metric.id) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when charge does not exist" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/invalid_code") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when subscription has plan override with charge override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + before { overridden_charge } + + it "returns the overridden charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:lago_id]).to eq(overridden_charge.id) + expect(json[:charge][:lago_parent_id]).to eq(charge.id) + end + end + end + + describe "PUT /api/v1/subscriptions/:external_id/charges/:code" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/#{charge.code}", {charge: update_params}) } + + let(:update_params) do + { + invoice_display_name: "Updated Charge Name", + min_amount_cents: 500, + properties: {amount: "200"} + } + end + + context "with premium license", :premium do + it_behaves_like "requires API permission", "subscription", "write" + + it "creates a plan override and charge override" do + expect { subject }.to change(Plan, :count).by(1).and change(Charge, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:charge][:invoice_display_name]).to eq("Updated Charge Name") + expect(json[:charge][:min_amount_cents]).to eq(500) + expect(json[:charge][:properties][:amount]).to eq("200") + expect(json[:charge][:lago_parent_id]).to eq(charge.id) + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when charge does not exist" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/charges/invalid_code", {charge: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("charge") + end + end + + context "when subscription already has plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + before { overridden_charge } + + it "does not create a new plan" do + expect { subject }.not_to change(Plan, :count) + end + + it "updates the existing charge override" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:lago_id]).to eq(overridden_charge.id) + expect(json[:charge][:invoice_display_name]).to eq("Updated Charge Name") + expect(json[:charge][:min_amount_cents]).to eq(500) + end + end + + context "with taxes" do + let(:tax) { create(:tax, organization:) } + let(:update_params) do + { + invoice_display_name: "Taxed Charge", + tax_codes: [tax.code] + } + end + + it "creates a charge override with taxes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:charge][:taxes]).to be_present + expect(json[:charge][:taxes].length).to eq(1) + expect(json[:charge][:taxes].first[:code]).to eq(tax.code) + end + end + end + + context "without premium license" do + it "returns forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/requests/api/v1/subscriptions/entitlements/privileges_controller_spec.rb b/spec/requests/api/v1/subscriptions/entitlements/privileges_controller_spec.rb new file mode 100644 index 0000000..06ddb09 --- /dev/null +++ b/spec/requests/api/v1/subscriptions/entitlements/privileges_controller_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Subscriptions::Entitlements::PrivilegesController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + let(:entitlement) { create(:entitlement, subscription: subscription, plan: nil, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, value: 30) } + + describe "DELETE #destroy" do + subject { delete_with_token organization, "/api/v1/subscriptions/#{subscription.external_id}/entitlements/#{feature.code}/privileges/#{privilege.code}" } + + before do + entitlement + entitlement_value + end + + it "deletes the entitlement value" do + expect { subject }.to change(feature.entitlement_values, :count).by(-1) + + expect(response).to have_http_status(:success) + end + + it "does not delete the entitlement" do + expect { subject }.not_to change(feature.entitlements, :count) + end + + it "returns not found error when subscription does not exist" do + delete_with_token organization, "/api/v1/subscriptions/invalid_subscription/entitlements/#{feature.code}/privileges/#{privilege.code}" + + expect(response).to be_not_found_error("subscription") + end + + it "returns not found error when feature does not exist" do + delete_with_token organization, "/api/v1/subscriptions/#{subscription.external_id}/entitlements/invalid_feature/privileges/#{privilege.code}" + + expect(response).to be_not_found_error("feature") + end + + it "returns not found error when privilege does not exist" do + delete_with_token organization, "/api/v1/subscriptions/#{subscription.external_id}/entitlements/#{feature.code}/privileges/invalid_privilege" + + expect(response).to be_not_found_error("privilege") + end + + context "when privilege is from the plan" do + let(:plan_entitlement) { create(:entitlement, plan:, feature:) } + let(:plan_entitlement_value) { create(:entitlement_value, entitlement: plan_entitlement, privilege:, value: 10) } + + it "adds a privilege removal" do + plan_entitlement_value + expect { subject }.to change(subscription.entitlement_removals.where(privilege:), :count).from(0).to(1) + end + + context "when privilege removal already exists" do + before do + create(:subscription_feature_removal, subscription:, privilege: plan_entitlement_value.privilege) + end + + it "returns a success" do + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription).sole.privileges).to be_empty + expect { subject }.not_to change(subscription.entitlement_removals.where(privilege:), :count) + expect(response).to have_http_status(:success) + expect(json[:entitlements].sole[:privileges]).to be_empty + end + end + end + end +end diff --git a/spec/requests/api/v1/subscriptions/entitlements_controller_spec.rb b/spec/requests/api/v1/subscriptions/entitlements_controller_spec.rb new file mode 100644 index 0000000..e31bd5f --- /dev/null +++ b/spec/requests/api/v1/subscriptions/entitlements_controller_spec.rb @@ -0,0 +1,374 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Subscriptions::EntitlementsController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege1) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + let(:privilege2) { create(:privilege, organization:, feature:, code: "root?", value_type: "boolean") } + + describe "GET /api/v1/subscriptions/:external_id/entitlements" do + subject { get_with_token organization, "/api/v1/subscriptions/#{subscription.external_id}/entitlements" } + + let(:entitlement) { create(:entitlement, plan:, feature:) } + let(:entitlement_value1) { create(:entitlement_value, entitlement:, privilege: privilege1, value: 30) } + let(:sub_entitlement) { create(:entitlement, subscription_id: subscription.id, plan: nil, feature:) } + let(:entitlement_value2) { create(:entitlement_value, entitlement: sub_entitlement, privilege: privilege2, value: true) } + + before do + entitlement_value1 + entitlement_value2 + end + + it "returns a list of entitlements" do + subject + + expect(response).to have_http_status(:success) + se = json[:entitlements].sole + expect(se).to include({ + code: "seats", + name: "Feature Name", + description: "Feature Description", + overrides: {root?: true} + }) + expect(se[:privileges]).to contain_exactly({ + code: "root?", + name: nil, + value_type: "boolean", + config: {}, + value: true, + plan_value: nil, + override_value: true + }, { + code: "max", + name: nil, + value_type: "integer", + config: {}, + value: 30, + plan_value: 30, + override_value: nil + }) + end + + it "returns not found error when subscription does not exist" do + get_with_token organization, "/api/v1/subscriptions/invalid_subscription/entitlements" + + expect(response).to be_not_found_error("subscription") + end + + context "when there are subscriptions in :active, :pending, and :terminated status" do + let(:external_id) { "test" } + let(:active_subscription) { create(:subscription, organization:, customer:, plan:, status: :active, external_id:) } + let(:pending_subscription) { create(:subscription, organization:, customer:, plan:, status: :pending, external_id:) } + let(:terminated_subscription) { create(:subscription, organization:, customer:, plan:, status: :terminated, external_id:) } + + let(:sub_entitlement_active) { create(:entitlement, subscription_id: active_subscription.id, plan: nil, feature:) } + let(:entitlement_value_active) { create(:entitlement_value, entitlement: sub_entitlement_active, privilege: privilege1, value: 100) } + let(:sub_entitlement_pending) { create(:entitlement, subscription_id: pending_subscription.id, plan: nil, feature:) } + let(:entitlement_value_pending) { create(:entitlement_value, entitlement: sub_entitlement_pending, privilege: privilege1, value: 200) } + let(:sub_entitlement_terminated) { create(:entitlement, subscription_id: terminated_subscription.id, plan: nil, feature:) } + let(:entitlement_value_terminated) { create(:entitlement_value, entitlement: sub_entitlement_terminated, privilege: privilege1, value: 300) } + + before do + entitlement_value_active + entitlement_value_pending + entitlement_value_terminated + end + + it "returns entitlements for active subscription by default" do + get_with_token organization, "/api/v1/subscriptions/#{external_id}/entitlements" + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].first[:overrides][:max]).to eq(100) + end + + it "returns entitlements for pending subscription when subscription_status param is pending" do + get_with_token organization, "/api/v1/subscriptions/#{external_id}/entitlements", {subscription_status: "pending"} + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].first[:overrides][:max]).to eq(200) + end + + it "returns entitlements for terminated subscription when subscription_status param is terminated" do + get_with_token organization, "/api/v1/subscriptions/#{external_id}/entitlements", {subscription_status: "terminated"} + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].first[:overrides][:max]).to eq(300) + end + + context "when using old status param" do + it "returns entitlements for pending subscription when status param is pending" do + get_with_token organization, "/api/v1/subscriptions/#{external_id}/entitlements", {status: "pending"} + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].first[:overrides][:max]).to eq(200) + end + + it "returns entitlements for terminated subscription when status param is terminated" do + get_with_token organization, "/api/v1/subscriptions/#{external_id}/entitlements", {status: "terminated"} + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].first[:overrides][:max]).to eq(300) + end + end + end + end + + describe "PATCH /api/v1/subscriptions/:external_id/entitlements" do + subject { patch_with_token organization, "/api/v1/subscriptions/#{subscription.external_id}/entitlements", params } + + let(:entitlement) { create(:entitlement, plan: subscription.plan, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege: privilege1, value: "10", organization:) } + let(:params) do + { + "entitlements" => { + "seats" => { + "max" => 60 + } + } + } + end + + before do + feature + privilege1 + entitlement + entitlement_value + end + + it "updates existing entitlement value" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].length).to eq(1) + expect(json[:entitlements].first[:privileges].find { it[:code] == "max" }).to include({ + value: 60, + plan_value: 10, + override_value: 60 + }) + expect(json[:entitlements].first[:overrides]).to eq({ + max: 60 + }) + end + + it "does not create new entitlement" do + expect { + subject + }.to change(Entitlement::Entitlement, :count).from(1).to(2) + end + + context "when privilege value does not exist" do + let(:privilege2) { create(:privilege, organization:, feature:, code: "max_admins", value_type: "integer") } + let(:params) do + { + "entitlements" => { + "seats" => { + "max_admins" => 30 + } + } + } + end + + before do + privilege2 + end + + it "creates new entitlement value" do + expect { + subject + }.to change(Entitlement::EntitlementValue, :count).by(1) + end + + it "creates entitlement value with correct value" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements].first[:privileges].find { it[:code] == "max_admins" }[:value]).to eq(30) + end + end + + context "when feature was removed via entitlement removal" do + let(:params) do + { + "entitlements" => { + "seats" => { + "max" => 10 + } + } + } + end + + it "removes the removal to restore the feature" do + removal = create(:subscription_feature_removal, feature:, subscription:) + subject + expect(removal.reload).to be_discarded + expect(subscription.entitlements).to be_empty + end + end + + context "when entitlement does not exist" do + let(:new_feature) { create(:feature, organization:, code: "storage") } + let(:new_privilege) { create(:privilege, organization:, feature: new_feature, code: "max_gb", value_type: "integer") } + let(:params) do + { + "entitlements" => { + "storage" => { + "max_gb" => 100 + } + } + } + end + + before do + new_feature + new_privilege + end + + it "creates new entitlement with value" do + expect { + subject + }.to change(subscription.entitlements, :count).by(1) + sub_ent = subscription.entitlements.sole + expect(sub_ent.values.sole.value).to eq("100") + end + end + + context "when feature does not exist" do + let(:params) do + { + "entitlements" => { + "nonexistent_feature" => { + "max" => 60 + } + } + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("feature") + end + end + + context "when privilege does not exist" do + let(:params) do + { + "entitlements" => { + "seats" => { + "nonexistent_privilege" => 60 + } + } + } + end + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("privilege") + end + end + + context "when subscription does not exist" do + it "returns not found error" do + patch_with_token organization, "/api/v1/subscriptions/invalid_subscription/entitlements", params + + expect(response).to be_not_found_error("subscription") + end + end + + context "when entitlements params is empty" do + let(:params) do + { + "entitlements" => {} + } + end + + it "returns success with existing entitlements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_present + expect(json[:entitlements].first[:privileges].find { it[:code] == "max" }[:value]).to eq(10) + end + end + end + + describe "DELETE /api/v1/subscriptions/external_id/entitlements/:feature_code" do + subject { delete_with_token organization, "/api/v1/subscriptions/#{subscription.external_id}/entitlements/#{feature.code}" } + + let(:entitlement) { create(:entitlement, subscription_id: subscription.id, plan: nil, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege: privilege1, value: 30, organization:) } + + before do + entitlement + entitlement_value + end + + it "deletes the entitlement and its values" do + expect { subject }.to change(feature.entitlements, :count).by(-1) + .and change(feature.entitlement_values, :count).by(-1) + + expect(response).to have_http_status(:success) + end + + context "when feature is on plan too" do + let(:plan_entitlement) { create(:entitlement, plan:, feature:) } + + before do + plan_entitlement + end + + it "also add a feature removal" do + subject + expect(entitlement.reload).to be_discarded + expect(entitlement_value.reload).to be_discarded + expect(subscription.entitlement_removals.where(feature:)).to exist + end + + context "when subscription had privilege removal" do + it "cleans up the privilege removal" do + create(:entitlement_value, entitlement: plan_entitlement, privilege: privilege2, value: false) + privilege_removal = create(:subscription_feature_removal, privilege: privilege2, subscription:) + subject + expect(privilege_removal.reload).to be_discarded + expect(entitlement.reload).to be_discarded + expect(entitlement_value.reload).to be_discarded + expect(subscription.entitlement_removals.where(feature:)).to exist + end + end + + context "when feature was already removed via entitlement removal" do + it "returns a success" do + create(:subscription_feature_removal, feature:, subscription:) + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription)).to be_empty + subject + expect(response).to have_http_status(:success) + expect(json[:entitlements]).to be_empty + end + end + end + + it "returns not found error when subscription does not exist" do + delete_with_token organization, "/api/v1/subscriptions/invalid_subscription/entitlements/#{feature.code}" + + expect(response).to be_not_found_error("subscription") + end + + it "returns not found error when feature does not exist" do + delete_with_token organization, "/api/v1/subscriptions/#{subscription.external_id}/entitlements/invalid_feature" + + expect(response).to be_not_found_error("feature") + end + end +end diff --git a/spec/requests/api/v1/subscriptions/fixed_charges_controller_spec.rb b/spec/requests/api/v1/subscriptions/fixed_charges_controller_spec.rb new file mode 100644 index 0000000..3be5bb3 --- /dev/null +++ b/spec/requests/api/v1/subscriptions/fixed_charges_controller_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Subscriptions::FixedChargesController do + let(:external_id) { "sub+1" } + let(:external_id_query_param) { external_id } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:subscription) { create(:subscription, external_id:, customer:, plan:) } + let(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + let(:deleted_fixed_charge) { create(:fixed_charge, :deleted, plan:, organization:) } + + before do + subscription + fixed_charge + deleted_fixed_charge + end + + describe "GET /api/v1/subscriptions/:external_id/fixed_charges" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/fixed_charges") } + + it_behaves_like "requires API permission", "subscription", "read" + + context "when there are fixed charges" do + it "retrieves the list of fixed charges" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charges]).to be_present + expect(json[:fixed_charges].first).to include({ + lago_id: fixed_charge.id, + lago_add_on_id: fixed_charge.add_on_id, + invoice_display_name: fixed_charge.invoice_display_name, + add_on_code: fixed_charge.add_on.code, + created_at: fixed_charge.created_at.iso8601, + charge_model: fixed_charge.charge_model, + pay_in_advance: fixed_charge.pay_in_advance, + prorated: fixed_charge.prorated, + properties: fixed_charge.properties.symbolize_keys, + units: fixed_charge.units.to_s + }) + end + end + + it "returns pagination metadata" do + subject + + expect(json[:meta]).to include( + current_page: 1, + next_page: nil, + prev_page: nil, + total_pages: 1, + total_count: 1 + ) + end + + context "when there is only deleted fixed charges" do + let(:fixed_charge) { nil } + + it do + subject + expect(json[:fixed_charges]).to be_empty + end + end + + context "when fixed charges have applied taxes" do + let(:fixed_charge) { create(:fixed_charge, :with_applied_taxes, plan:, organization:) } + + it "includes taxes in the response" do + subject + expect(json[:fixed_charges].first).to include(:taxes) + expect(json[:fixed_charges].first[:taxes]).to be_an(Array) + expect(json[:fixed_charges].first[:taxes].first).to include( + lago_id: fixed_charge.applied_taxes.first.tax.id, + name: fixed_charge.applied_taxes.first.tax.name, + code: fixed_charge.applied_taxes.first.tax.code, + rate: fixed_charge.applied_taxes.first.tax.rate + ) + end + end + + context "when subscription is not found" do + let(:external_id_query_param) { "not-found-id" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("subscription") + end + end + end + + describe "GET /api/v1/subscriptions/:external_id/fixed_charges/:code" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/fixed_charges/#{fixed_charge.code}") } + + it_behaves_like "requires API permission", "subscription", "read" + + it "returns the fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:lago_id]).to eq(fixed_charge.id) + expect(json[:fixed_charge][:code]).to eq(fixed_charge.code) + expect(json[:fixed_charge][:charge_model]).to eq(fixed_charge.charge_model) + expect(json[:fixed_charge][:lago_add_on_id]).to eq(fixed_charge.add_on_id) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when fixed charge does not exist" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/fixed_charges/invalid_code") } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("fixed_charge") + end + end + + context "when subscription has plan override with fixed charge override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_fixed_charge) { create(:fixed_charge, plan: overridden_plan, organization:, add_on:, parent: fixed_charge, code: fixed_charge.code) } + + before { overridden_fixed_charge } + + it "returns the overridden fixed charge" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:lago_id]).to eq(overridden_fixed_charge.id) + expect(json[:fixed_charge][:lago_parent_id]).to eq(fixed_charge.id) + end + end + end + + describe "PUT /api/v1/subscriptions/:external_id/fixed_charges/:code" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/fixed_charges/#{fixed_charge.code}", {fixed_charge: update_params}) } + + let(:update_params) do + { + invoice_display_name: "Updated Fixed Charge Name", + units: "15" + } + end + + context "with premium license", :premium do + it_behaves_like "requires API permission", "subscription", "write" + + it "creates a plan override and fixed charge override" do + expect { subject }.to change(Plan, :count).by(1).and change(FixedCharge, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:invoice_display_name]).to eq("Updated Fixed Charge Name") + expect(json[:fixed_charge][:units]).to eq("15.0") + expect(json[:fixed_charge][:lago_parent_id]).to eq(fixed_charge.id) + end + + it "updates the subscription to use the overridden plan" do + subject + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when subscription does not exist" do + let(:external_id_query_param) { "invalid_external_id" } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("subscription") + end + end + + context "when fixed charge does not exist" do + subject { put_with_token(organization, "/api/v1/subscriptions/#{external_id_query_param}/fixed_charges/invalid_code", {fixed_charge: update_params}) } + + it "returns not found error" do + subject + + expect(response).to be_not_found_error("fixed_charge") + end + end + + context "when subscription already has plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan, external_id:) } + let(:overridden_fixed_charge) { create(:fixed_charge, plan: overridden_plan, organization:, add_on:, parent: fixed_charge, code: fixed_charge.code) } + + before { overridden_fixed_charge } + + it "does not create a new plan" do + expect { subject }.not_to change(Plan, :count) + end + + it "updates the existing fixed charge override" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:lago_id]).to eq(overridden_fixed_charge.id) + expect(json[:fixed_charge][:invoice_display_name]).to eq("Updated Fixed Charge Name") + expect(json[:fixed_charge][:units]).to eq("15.0") + end + end + + context "with taxes" do + let(:tax) { create(:tax, organization:) } + let(:update_params) do + { + invoice_display_name: "Taxed Fixed Charge", + tax_codes: [tax.code] + } + end + + it "creates a fixed charge override with taxes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:fixed_charge][:taxes]).to be_present + expect(json[:fixed_charge][:taxes].length).to eq(1) + expect(json[:fixed_charge][:taxes].first[:code]).to eq(tax.code) + end + end + end + + context "without premium license" do + it "returns forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + end + end + end +end diff --git a/spec/requests/api/v1/subscriptions/lifetime_usages_controller_spec.rb b/spec/requests/api/v1/subscriptions/lifetime_usages_controller_spec.rb new file mode 100644 index 0000000..be70f49 --- /dev/null +++ b/spec/requests/api/v1/subscriptions/lifetime_usages_controller_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Subscriptions::LifetimeUsagesController do + let!(:lifetime_usage) { create(:lifetime_usage, organization:, subscription:) } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, organization:, subscription_at:, customer:) } + let(:subscription_at) { Date.new(2022, 8, 22) } + let(:plan) { create(:plan) } + + before { create(:usage_threshold, plan:, amount_cents: 100) } + + describe "GET /api/v1/subscriptions/:subscription_external_id/lifetime_usage" do + subject { get_with_token(organization, "/api/v1/subscriptions/#{external_id}/lifetime_usage") } + + let(:external_id) { subscription.external_id } + + include_examples "requires API permission", "lifetime_usage", "read" + + it "returns the lifetime_usage" do + subject + + expect(response).to have_http_status(:success) + expect(json[:lifetime_usage][:lago_id]).to eq(lifetime_usage.id) + end + + it "includes the usage_thresholds" do + subject + + expect(response).to have_http_status(:success) + expect(json[:lifetime_usage][:lago_id]).to eq(lifetime_usage.id) + expect(json[:lifetime_usage][:usage_thresholds]).to eq([ + {amount_cents: 100, completion_ratio: 0.0, reached_at: nil} + ]) + end + + context "when subscription cannot be found" do + let(:external_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "PUT /api/v1/subscriptions/:subscription_external_id/lifetime_usage" do + subject do + put_with_token( + organization, + "/api/v1/subscriptions/#{external_id}/lifetime_usage", + {lifetime_usage: update_params} + ) + end + + let(:external_id) { subscription.external_id } + let(:update_params) { {external_historical_usage_amount_cents: 20} } + + context "when subscription exists" do + include_examples "requires API permission", "lifetime_usage", "write" + + it "updates the lifetime_usage" do + subject + + expect(response).to have_http_status(:success) + expect(json[:lifetime_usage][:lago_id]).to eq(lifetime_usage.id) + expect(json[:lifetime_usage][:external_historical_usage_amount_cents]).to eq(20) + end + end + + context "when subscription does not exist" do + let(:external_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/subscriptions_controller_spec.rb b/spec/requests/api/v1/subscriptions_controller_spec.rb new file mode 100644 index 0000000..0dda365 --- /dev/null +++ b/spec/requests/api/v1/subscriptions_controller_spec.rb @@ -0,0 +1,1950 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::SubscriptionsController, :premium do + let(:organization) { create(:organization, premium_integrations: %w[progressive_billing]) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 500, description: "desc") } + let(:plan_usage_threshold) { create(:usage_threshold, plan:, amount_cents: 10_00, threshold_display_name: "Init") } + let(:commitment_invoice_display_name) { "Overriden minimum commitment name" } + let(:commitment_amount_cents) { 1234 } + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:payment_method) { create(:payment_method, customer:, organization:) } + + before do + plan_usage_threshold + end + + describe "POST /api/v1/subscriptions" do + subject { post_with_token(organization, "/api/v1/subscriptions", body) } + + let(:body) { {subscription: params} } + let(:subscription_at) { Time.current.iso8601 } + let(:ending_at) { (Time.current + 1.year).iso8601 } + let(:plan_code) { plan.code } + let(:plan_amount_cents_override) { 100 } + + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + name: "subscription name", + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + ending_at:, + invoice_custom_section: { + invoice_custom_section_codes: [section_1.code] + }, + payment_method: { + payment_method_id: payment_method&.id, + payment_method_type: "provider" + }, + plan_overrides: { + amount_cents: plan_amount_cents_override, + name: "overridden name", + minimum_commitment: { + invoice_display_name: commitment_invoice_display_name, + amount_cents: commitment_amount_cents + } + } + } + end + + let(:override_amount_cents) { 777 } + let(:override_display_name) { "Overriden Threshold 12" } + + before do + customer + payment_method + end + + include_examples "requires API permission", "subscription", "write" + + it "returns a success" do + create(:plan, code: plan.code, parent_id: plan.id, organization:, description: "foo") + create(:entitlement, organization:, plan:) + + freeze_time do + subject + + expect(response).to have_http_status(:ok) + expect(json[:subscription]).to include( + lago_id: String, + external_id: String, + external_customer_id: customer.external_id, + lago_customer_id: customer.id, + plan_code: plan.code, + plan_amount_cents: plan_amount_cents_override, + plan_amount_currency: plan.amount_currency, + status: "active", + name: "subscription name", + started_at: String, + billing_time: "anniversary", + subscription_at: Time.current.iso8601, + ending_at: (Time.current + 1.year).iso8601, + previous_plan_code: nil, + next_plan_code: nil, + downgrade_plan_date: nil + ) + expect(json[:subscription][:entitlements]).to contain_exactly({ + code: "feature_1", + name: "Feature Name", + description: "Feature Description", + privileges: [], + overrides: {} + }) + expect(json[:subscription][:plan]).to include( + amount_cents: plan_amount_cents_override, + name: "overridden name", + description: "desc" + ) + expect(json[:subscription][:plan][:minimum_commitment]).to include( + invoice_display_name: commitment_invoice_display_name, + amount_cents: commitment_amount_cents + ) + expect(json[:subscription][:payment_method][:payment_method_type]).to eq("provider") + expect(json[:subscription][:payment_method][:payment_method_id]).to eq(payment_method.id) + end + end + + it "doesn't create a new customer" do + expect { subject }.not_to change(Customer, :count) + end + + context "when usage_thresholds is part of plan_override (legacy)" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + ending_at:, + plan_overrides: { + usage_thresholds: [ + amount_cents: override_amount_cents, + threshold_display_name: override_display_name + ] + } + } + end + + it "attaches the usage_thresholds to the child plan" do + subject + + expect(response).to have_http_status(:ok) + + expect(plan.usage_thresholds).to contain_exactly(plan_usage_threshold) + subscription = Subscription.find json[:subscription][:lago_id] + expect(subscription.plan.is_child?).to be true + expect(subscription.plan.usage_thresholds.sole.amount_cents).to eq override_amount_cents + expect(subscription.usage_thresholds).to be_empty + + expect(json[:subscription][:applicable_usage_thresholds]).to contain_exactly( + { + amount_cents: override_amount_cents, + threshold_display_name: override_display_name, + recurring: false + } + ) + expect(json[:subscription][:plan][:usage_thresholds]).to contain_exactly( + hash_including( + amount_cents: override_amount_cents, + threshold_display_name: override_display_name, + recurring: false + ) + ) + expect(json[:subscription][:plan][:applicable_usage_thresholds]).to contain_exactly({ + amount_cents: plan_usage_threshold.amount_cents, + threshold_display_name: plan_usage_threshold.threshold_display_name, + recurring: false + }) + end + end + + context "when usage_thresholds is part of subscription" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + ending_at:, + usage_thresholds: [ + amount_cents: override_amount_cents, + threshold_display_name: override_display_name + ], + plan_overrides: { + amount_cents: 99_99 + } + } + end + + it "attaches the usage_thresholds to the subscription" do + subject + + expect(response).to have_http_status(:ok) + + expect(plan.usage_thresholds).to contain_exactly(plan_usage_threshold) + subscription = Subscription.find json[:subscription][:lago_id] + expect(subscription.plan.is_child?).to be true + expect(subscription.plan.usage_thresholds).to be_empty + expect(subscription.usage_thresholds.sole.amount_cents).to eq override_amount_cents + + expect(json[:subscription][:applicable_usage_thresholds]).to contain_exactly( + { + amount_cents: override_amount_cents, + threshold_display_name: override_display_name, + recurring: false + } + ) + expect(json[:subscription][:plan][:usage_thresholds]).to be_empty + expect(json[:subscription][:plan][:applicable_usage_thresholds]).to contain_exactly({ + amount_cents: plan_usage_threshold.amount_cents, + threshold_display_name: plan_usage_threshold.threshold_display_name, + recurring: false + }) + end + end + + context "with external_customer_id, external_id and name as integer" do + let(:params) do + { + external_customer_id: 123, + plan_code:, + name: 456, + external_id: 789 + } + end + + it "returns a success" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:subscription]).to include( + lago_id: String, + external_customer_id: "123", + name: "456", + external_id: "789" + ) + end + + it "creates a new customer in the organization default billing entity" do + expect { subject }.to change(Customer, :count).by(1) + + customer = Customer.find_by(external_id: "123") + expect(customer.organization).to eq(organization) + expect(customer.billing_entity).to eq(organization.default_billing_entity) + end + + context "when passing billing_entity_code" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:params) do + { + external_customer_id: 123, + plan_code:, + name: 456, + external_id: 789, + billing_entity_code: billing_entity.code + } + end + + it "creates a new customer with the given billing entity" do + expect { subject }.to change(Customer, :count).by(1) + + customer = Customer.find_by(external_id: "123") + expect(customer.billing_entity).to eq(billing_entity) + end + + context "when billing entity does not exist" do + let(:params) do + { + external_customer_id: 123, + plan_code:, + name: 456, + external_id: 789, + billing_entity_code: SecureRandom.uuid + } + end + + it "returns a not_found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("billing_entity_not_found") + end + end + + context "when passing external_id from another billing entity" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + name: 456, + external_id: 789, + billing_entity_code: billing_entity.id + } + end + + it "uses the customer ignoring billing_entity" do + expect { subject }.not_to change(Customer, :count) + + customer.reload + expect(customer.billing_entity).to eq(organization.default_billing_entity) + end + end + end + end + + context "without external_customer_id" do + let(:params) do + { + plan_code:, + name: "subscription name", + external_id: SecureRandom.uuid + } + end + + it "returns an unprocessable_entity error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({external_customer_id: %w[value_is_mandatory]}) + end + end + + context "when binding the subscription to an explicit billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + billing_entity_code: billing_entity.code + } + end + + context "when multi_entity_billing flag is enabled" do + before { organization.enable_feature_flag!(:multi_entity_billing) } + + it "binds the subscription to the resolved billing entity" do + subject + + expect(response).to have_http_status(:ok) + subscription = Subscription.find_by(external_id: params[:external_id]) + expect(subscription.billing_entity_id).to eq(billing_entity.id) + end + + context "when binding via billing_entity_id instead of code" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + billing_entity_id: billing_entity.id + } + end + + it "binds the subscription to the resolved billing entity" do + subject + + expect(response).to have_http_status(:ok) + subscription = Subscription.find_by(external_id: params[:external_id]) + expect(subscription.billing_entity_id).to eq(billing_entity.id) + end + end + end + + context "when multi_entity_billing flag is disabled" do + it "ignores the param and persists subscription with no explicit billing entity" do + subject + + expect(response).to have_http_status(:ok) + subscription = Subscription.find_by(external_id: params[:external_id]) + expect(subscription.billing_entity_id).to be_nil + end + end + + context "without billing_entity_code" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + external_id: SecureRandom.uuid, + billing_time: "anniversary" + } + end + + before { organization.enable_feature_flag!(:multi_entity_billing) } + + it "persists subscription with no explicit billing entity" do + subject + + expect(response).to have_http_status(:ok) + subscription = Subscription.find_by(external_id: params[:external_id]) + expect(subscription.billing_entity_id).to be_nil + end + end + end + + context "with invalid plan code" do + let(:plan_code) { "#{plan.code}-invalid" } + + it "returns a not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "with invalid subscription_at" do + let(:subscription_at) { "hello" } + + it "returns an unprocessable_entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "with legacy subscription_date" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + name: "subscription name", + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at: subscription_at + } + end + + it "returns a success" do + subject + + expect(response).to have_http_status(:ok) + + expect(json[:subscription][:lago_id]).to be_present + expect(json[:subscription][:external_id]).to be_present + expect(json[:subscription][:external_customer_id]).to eq(customer.external_id) + expect(json[:subscription][:lago_customer_id]).to eq(customer.id) + expect(json[:subscription][:plan_code]).to eq(plan.code) + expect(json[:subscription][:plan_amount_cents]).to eq(plan.amount_cents) + expect(json[:subscription][:plan_amount_currency]).to eq(plan.amount_currency) + expect(json[:subscription][:status]).to eq("active") + expect(json[:subscription][:name]).to eq("subscription name") + expect(json[:subscription][:started_at]).to be_present + expect(json[:subscription][:billing_time]).to eq("anniversary") + expect(json[:subscription][:subscription_at]).to eq(Time.zone.parse(subscription_at).iso8601) + expect(json[:subscription][:previous_plan_code]).to be_nil + expect(json[:subscription][:next_plan_code]).to be_nil + expect(json[:subscription][:downgrade_plan_date]).to be_nil + end + end + + context "with payment pre-authorization" do + context "when the feature isn't enabled" do + let(:body) { {authorization: {}, subscription: params} } + + it "returns a forbidden error" do + subject + + expect(response).to have_http_status(:forbidden) + expect(json[:message]).to match(/beta_payment_authorization/) + end + end + + context "when the feature is enabled" do + let(:organization) { create(:organization, premium_integrations: ["beta_payment_authorization"]) } + let(:body) do + { + authorization: {amount_cents: "100", amount_currency: "USD"}, + subscription: params + } + end + let(:customer) { create(:customer, organization:, payment_provider: :stripe, external_id: "cust_12345") } + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: create(:stripe_provider, organization:), payment_method_id: "pm_12345") } + let(:stripe_pi) do + { + id: "pi_12345", + amount: "100", + amount_capturable: "100", + status: "requires_capture" + } + end + + before do + stripe_customer + stub_request(:post, "https://api.stripe.com/v1/payment_intents").and_return(status: 200, body: stripe_pi.to_json) + end + + it "returns a success" do + allow(PaymentProviders::CancelPaymentAuthorizationJob).to receive(:perform_later) + + subject + expect(json[:authorization]).to include(stripe_pi) + expect(json[:subscription]).to include(status: "active") + + expect(PaymentProviders::CancelPaymentAuthorizationJob).to have_received(:perform_later).with( + payment_provider: stripe_customer.payment_provider, id: stripe_pi[:id] + ) + end + + context "when parameters are incorrect" do + let(:body) do + { + authorization: {amount_cents: "100"}, + subscription: params + } + end + + it "returns an error" do + subject + + expect(response).to have_http_status(:bad_request) + expect(json[:error]).to eq "BadRequest: param is missing or the value is empty or invalid: amount_currency" + end + end + + context "when customer has no payment method" do + let(:provider_customer_id) { "cus_Rw5Qso78STEap3" } + let(:stripe_customer) { create(:stripe_customer, customer:, provider_customer_id:, payment_provider: create(:stripe_provider, organization:), payment_method_id: nil) } + let(:payment_method) { nil } + + context "when customer has a default payment method on Stripe" do + it do + stub_request(:get, %r{/v1/customers/#{provider_customer_id}$}).and_return( + status: 200, body: get_stripe_fixtures("customer_retrieve_response.json") + ) + stub_request(:get, %r{/v1/customers/#{provider_customer_id}/payment_methods}).and_return( + status: 200, body: get_stripe_fixtures("customer_list_payment_methods_empty_response.json") + ) + + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:payment_method_id]).to include "customer_has_no_payment_method" + end + end + end + + context "when the authorization failed (card declined)" do + it do + stripe_card_declined = get_stripe_fixtures("payment_intent_authorization_failed_response.json") + stub_request(:post, %r{/v1/payment_intents}).and_return( + status: 402, + body: stripe_card_declined, + headers: {"request-id" => "req_R6dwJQCrHDQkZr"} + ) + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to eq "provider_error" + expect(json[:provider][:code]).to start_with "stripe_account_" + expect(json[:error_details]).to include({ + code: "card_declined", + message: "Your card was declined.", + request_id: "req_R6dwJQCrHDQkZr", + http_status: 402 + }) + end + end + end + end + + context "with fixed charges override" do + let(:plan) { create(:plan, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, units: 2, charge_model: "standard", properties: {amount: "10"}) } + let(:tax) { create(:tax, organization:) } + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + name: "subscription name", + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + ending_at:, + plan_overrides: { + fixed_charges: [{ + id: fixed_charge.id, + units: "10", + invoice_display_name: "another name", + properties: {amount: "20"}, + tax_codes: [tax.code] + }] + } + } + end + + it "creates a subscription with overridden plan with fixed_charges, but does not send them in the response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:plan][:fixed_charges]).to be_nil + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.fixed_charges.count).to eq(1) + expect(subscription.fixed_charges.first.attributes.symbolize_keys).to include( + add_on_id: fixed_charge.add_on.id, + units: 10.0, + invoice_display_name: "another name", + charge_model: "standard", + properties: {"amount" => "20"}, + parent_id: fixed_charge.id + ) + end + + it "creates fixed charge events for the subscription" do + subject + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + fixed_charge_override = FixedCharge.find_by(parent_id: fixed_charge.id) + expect(subscription.fixed_charge_events.count).to eq(1) + expect(subscription.fixed_charge_events.first.fixed_charge_id).to eq(fixed_charge_override.id) + expect(subscription.fixed_charge_events.first.timestamp).to be_within(5.seconds).of(Time.current) + end + end + + context "with invoice_custom_section" do + let(:skip_invoice_custom_sections) { false } + let(:custom_section_codes) { ["section_code_1"] } + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:params) do + { + external_customer_id: 123, + plan_code:, + name: 456, + external_id: 789, + invoice_custom_section: { + skip_invoice_custom_sections:, + invoice_custom_section_codes: custom_section_codes + } + } + end + + context "when skip_invoice_custom_sections is true" do + let(:skip_invoice_custom_sections) { true } + + it "create the subscription without custom sections" do + subject + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.skip_invoice_custom_sections).to be_truthy + expect(subscription.applied_invoice_custom_sections.count).to be_zero + end + end + + context "when skip_invoice_custom_sections is false" do + let(:skip_invoice_custom_sections) { false } + + context "without invoice_custom_section_codes" do + let(:custom_section_codes) { [] } + + it "create the subscription without custom sections" do + subject + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.skip_invoice_custom_sections).to be_falsey + expect(subscription.applied_invoice_custom_sections.count).to be_zero + end + end + + context "with invoice_custom_section_codes" do + let(:custom_section_codes) { [section_1.code] } + + it "create the subscription with custom sections" do + subject + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.skip_invoice_custom_sections).to be_falsey + expect(subscription.applied_invoice_custom_sections.count).to eq(1) + expect(subscription.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id) + end + end + end + end + + context "with progressive_billing_disabled" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code:, + external_id: SecureRandom.uuid, + progressive_billing_disabled: true + } + end + + it "creates a subscription with progressive_billing_disabled set to true" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:progressive_billing_disabled]).to be(true) + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.progressive_billing_disabled).to be(true) + end + end + + context "with applied_invoice_custom_sections in response" do + it "includes applied_invoice_custom_sections in the serialized response" do + subject + + expect(response).to have_http_status(:success) + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(json[:subscription][:applied_invoice_custom_sections]).to be_an(Array) + expect(json[:subscription][:applied_invoice_custom_sections].count).to eq(subscription.applied_invoice_custom_sections.count) + end + end + + context "with activation_rules" do + let(:customer) { create(:customer, organization:, payment_provider: "stripe") } + let(:subscription_at) { (Time.current + 5.days).iso8601 } + + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + activation_rules: [{type: "payment", timeout_hours: 48}] + } + end + + context "when feature flag is enabled" do + before { organization.enable_feature_flag!(:payment_gated_subscriptions) } + + it "creates subscription with activation rules and returns them" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:subscription][:status]).to eq("pending") + expect(json[:subscription][:cancelation_reason]).to be_nil + expect(json[:subscription][:activated_at]).to be_nil + expect(json[:subscription][:activation_rules].size).to eq(1) + expect(json[:subscription][:activation_rules].first).to include( + lago_id: String, + type: "payment", + timeout_hours: 48, + status: "inactive", + expires_at: nil + ) + end + + context "when timeout_hours is omitted" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + activation_rules: [{type: "payment"}] + } + end + + it "persists rule with default timeout_hours of 0" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:subscription][:activation_rules].first[:timeout_hours]).to eq(0) + end + end + end + + context "when feature flag is disabled" do + it "persists rules but does not include them in response" do + subject + + expect(response).to have_http_status(:ok) + + subscription = Subscription.find(json[:subscription][:lago_id]) + expect(subscription.activation_rules.count).to eq(1) + + expect(json[:subscription]).not_to have_key(:activation_rules) + expect(json[:subscription]).not_to have_key(:cancelation_reason) + expect(json[:subscription]).not_to have_key(:activated_at) + end + end + + context "with invalid rule type" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + activation_rules: [{type: "unknown"}] + } + end + + it "returns an unprocessable_entity error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({activation_rules: %w[invalid_type]}) + end + end + + context "with negative timeout_hours" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + activation_rules: [{type: "payment", timeout_hours: -1}] + } + end + + it "returns an unprocessable_entity error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({timeout_hours: %w[value_must_be_positive_or_zero]}) + end + end + + context "with manual payment method" do + let(:params) do + { + external_customer_id: customer.external_id, + plan_code: plan.code, + external_id: SecureRandom.uuid, + billing_time: "anniversary", + subscription_at:, + activation_rules: [{type: "payment", timeout_hours: 48}], + payment_method: {payment_method_type: "manual"} + } + end + + it "returns an unprocessable_entity error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({payment_method: %w[invalid_for_payment_activation_rules]}) + end + end + + context "when customer has no payment provider" do + let(:customer) { create(:customer, organization:, payment_provider: nil) } + + it "returns an unprocessable_entity error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({payment_method: %w[no_linked_payment_provider]}) + end + end + end + end + + describe "DELETE /api/v1/subscriptions/:external_id" do + subject { delete_with_token(organization, "/api/v1/subscriptions/#{external_id}", params) } + + let(:subscription) { create(:subscription, customer:, plan:) } + let(:external_id) { subscription.external_id } + let(:params) { {} } + + include_examples "requires API permission", "subscription", "write" + + def test_termination(expected_on_termination_credit_note: nil, expected_on_termination_invoice: "generate") + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:lago_id]).to eq(subscription.id) + expect(json[:subscription][:status]).to eq("terminated") + expect(json[:subscription][:terminated_at]).to be_present + expect(json[:subscription][:on_termination_credit_note]).to eq(expected_on_termination_credit_note) + expect(json[:subscription][:on_termination_invoice]).to eq(expected_on_termination_invoice) + end + + it "terminates a subscription" do + test_termination(expected_on_termination_credit_note: nil) + end + + context "when plan is pay_in_arrears" do + let(:params) { {on_termination_credit_note: "credit"} } + + it "terminates subscription but ignores on_termination_credit_note" do + test_termination(expected_on_termination_credit_note: nil) + end + end + + context "when plan is pay_in_advance" do + let(:plan) { create(:plan, :pay_in_advance, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + + context "without on_termination_credit_note parameter" do + it "terminates subscription with credit note behavior" do + test_termination(expected_on_termination_credit_note: "credit") + end + end + + context "with on_termination_credit_note parameter" do + [nil, "", "credit"].each do |on_termination_credit_note| + context "when on_termination_credit_note is #{on_termination_credit_note.inspect}" do + let(:params) { {on_termination_credit_note:}.compact } + + it "terminates subscription with credit note behavior" do + test_termination(expected_on_termination_credit_note: "credit") + end + end + end + + context "when on_termination_credit_note is skip" do + let(:params) { {on_termination_credit_note: "skip"} } + + it "terminates subscription with skip behavior" do + test_termination(expected_on_termination_credit_note: "skip") + end + end + + context "when on_termination_credit_note is refund" do + let(:params) { {on_termination_credit_note: "refund"} } + + it "terminates subscription with refund behavior" do + test_termination(expected_on_termination_credit_note: "refund") + end + end + + context "with invalid on_termination_credit_note value" do + let(:params) { {on_termination_credit_note: "invalid"} } + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include( + on_termination_credit_note: ["invalid_value"] + ) + end + end + end + end + + context "with on_termination_invoice parameter" do + context "when on_termination_invoice is generate" do + let(:params) { {on_termination_invoice: "generate"} } + + it "terminates subscription with generate invoice behavior" do + test_termination(expected_on_termination_invoice: "generate") + end + end + + context "when on_termination_invoice is skip" do + let(:params) { {on_termination_invoice: "skip"} } + + it "terminates subscription with skip invoice behavior" do + test_termination(expected_on_termination_invoice: "skip") + end + end + + context "with invalid on_termination_invoice value" do + let(:params) { {on_termination_invoice: "invalid"} } + + it "returns validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include( + on_termination_invoice: ["invalid_value"] + ) + end + end + + context "with both on_termination_credit_note and on_termination_invoice parameters" do + let(:plan) { create(:plan, :pay_in_advance, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:params) { {on_termination_credit_note: "skip", on_termination_invoice: "skip"} } + + it "terminates subscription with both behaviors" do + test_termination(expected_on_termination_credit_note: "skip", expected_on_termination_invoice: "skip") + end + end + end + + context "when subscription is pending" do + let(:subscription) { create(:subscription, :pending, customer:, plan:) } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + + context "when status is given" do + let(:params) { {status: "pending"} } + + it "cancels the subscription" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:lago_id]).to eq(subscription.id) + expect(json[:subscription][:status]).to eq("canceled") + expect(json[:subscription][:canceled_at]).to be_present + end + end + end + + context "with not existing subscription" do + let(:external_id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "with applied_invoice_custom_sections in response" do + before { create(:subscription_applied_invoice_custom_section, subscription:) } + + it "includes applied_invoice_custom_sections as an array in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:applied_invoice_custom_sections]).to be_an(Array) + expect(json[:subscription][:applied_invoice_custom_sections].count).to eq(1) + end + end + end + + describe "PUT /api/v1/subscriptions/:external_id" do + subject do + put_with_token( + organization, + "/api/v1/subscriptions/#{external_id}", + params + ) + end + + let(:params) { {subscription: update_params} } + let(:subscription) { create(:subscription, :pending, customer:, plan:) } + let(:external_id) { subscription.external_id } + + let(:update_params) do + { + name: "subscription name new", + subscription_at: "2022-09-05T12:23:12Z", + invoice_custom_section: { + skip_invoice_custom_sections: false, + invoice_custom_section_codes: [section_1.code] + }, + plan_overrides: { + name: "Override", + invoice_display_name: "Override plan", + interval: "monthly", + description: "This plan is used to test the override functionality", + amount_cents: 200, + amount_currency: "USD", + charges: [ + { + applied_pricing_unit: {code: pricing_unit.code, conversion_rate: "2"}, + billable_metric_id: package_charge.billable_metric.id, + charge_model: "package", + id: package_charge.id, + invoice_display_name: "Setup", + invoiceable: true, + min_amount_cents: 6000, + properties: {amount: "60", free_units: 200, package_size: 2000}, + tax_codes: [tax.code] + }, + # This charge should be ignored as no id is provided + { + billable_metric_id: other_billable_metric.id, + charge_model: "package", + invoice_display_name: "Setup 2", + invoiceable: true, + min_amount_cents: 6000, + properties: {amount: "60", free_units: 200, package_size: 2000}, + tax_codes: [tax.code] + } + ], + minimum_commitment: { + invoice_display_name: commitment_invoice_display_name, + amount_cents: commitment_amount_cents, + tax_codes: [tax.code] + } + } + } + end + + let(:plan) { create(:plan, organization:, amount_cents: 500, description: "desc") } + let(:package_charge) { create(:package_charge, plan:, organization:) } + let(:other_billable_metric) { create(:billable_metric, organization:) } + let(:pricing_unit) { create(:pricing_unit, organization:, code: "ETH", short_name: "ETH") } + let(:applied_pricing_unit) { create(:applied_pricing_unit, pricing_unitable: package_charge, pricing_unit:, organization:) } + let(:tax) { create(:tax, organization:) } + let(:override_amount_cents) { 999 } + let(:override_display_name) { "Overridden Threshold 1" } + let(:usage_threshold) { create(:usage_threshold, plan:, created_at: 1.day.ago) } + + before do + subscription + usage_threshold + package_charge + applied_pricing_unit + end + + include_examples "requires API permission", "subscription", "write" + + it "updates a subscription" do + subject + + expect(response).to have_http_status(:success) + subscription = json[:subscription] + expect(subscription).to include( + lago_id: Regex::UUID, + name: "subscription name new", + subscription_at: "2022-09-05T12:23:12Z" + ) + + expect(subscription[:payment_method][:payment_method_type]).to eq("provider") + expect(subscription[:payment_method][:payment_method_id]).to eq(nil) + plan_json = subscription[:plan] + expect(plan_json).to include( + lago_id: Regex::UUID, + name: "Override", + invoice_display_name: "Override plan", + created_at: Regex::ISO8601_DATETIME, + code: a_kind_of(String), + interval: "monthly", + description: "This plan is used to test the override functionality", + amount_cents: 200, + amount_currency: "USD", + trial_period: nil, + pay_in_advance: false, + bill_charges_monthly: nil, + bill_fixed_charges_monthly: false, + customers_count: 0, + active_subscriptions_count: 0, + draft_invoices_count: 0, + parent_id: plan.id, + pending_deletion: false, + taxes: [], + usage_thresholds: [] + ) + expect(plan_json[:applicable_usage_thresholds]).to contain_exactly({ + threshold_display_name: usage_threshold.threshold_display_name, + amount_cents: usage_threshold.amount_cents, + recurring: false + }, { + threshold_display_name: plan_usage_threshold.threshold_display_name, + amount_cents: plan_usage_threshold.amount_cents, + recurring: false + }) + minimum_commitment = plan_json[:minimum_commitment] + expect(minimum_commitment).to match( + { + invoice_display_name: commitment_invoice_display_name, + amount_cents: commitment_amount_cents, + lago_id: Regex::UUID, + plan_code: a_kind_of(String), + interval: "monthly", + created_at: Regex::ISO8601_DATETIME, + updated_at: Regex::ISO8601_DATETIME, + taxes: [ + { + lago_id: Regex::UUID, + name: "VAT", + code: a_kind_of(String), + rate: 20.0, + description: "French Standard VAT", + applied_to_organization: false, + add_ons_count: 0, + customers_count: 0, + plans_count: 0, + charges_count: 0, + commitments_count: 0, + created_at: Regex::ISO8601_DATETIME + } + ] + } + ) + charges = plan_json[:charges] + expect(charges.length).to eq(1) + charge = charges.first + expect(charge).to match( + { + lago_id: Regex::UUID, + lago_billable_metric_id: package_charge.billable_metric.id, + code: package_charge.code, + invoice_display_name: "Setup", + billable_metric_code: package_charge.billable_metric.code, + created_at: Regex::ISO8601_DATETIME, + charge_model: "package", + invoiceable: true, + regroup_paid_fees: nil, + pay_in_advance: false, + prorated: false, + min_amount_cents: 6000, + accepts_target_wallet: false, + properties: { + amount: "60", + free_units: 200, + package_size: 2000 + }, + applied_pricing_unit: {conversion_rate: "2.0", code: pricing_unit.code}, + lago_parent_id: package_charge.id, + filters: [], + taxes: [ + { + lago_id: Regex::UUID, + name: "VAT", + code: a_kind_of(String), + rate: 20.0, + description: "French Standard VAT", + applied_to_organization: false, + add_ons_count: 0, + customers_count: 0, + plans_count: 0, + charges_count: 0, + commitments_count: 0, + created_at: Regex::ISO8601_DATETIME + } + ] + } + ) + end + + context "when updating usage_thresholds" do + let(:usage_thresholds) do + [{ + amount_cents: override_amount_cents, + threshold_display_name: override_display_name + }] + end + + context "when usage_thresholds are part of plan_overrides (legacy)" do + let(:update_params) do + { + name: "subscription name new", + plan_overrides: { + usage_thresholds: + } + } + end + + it "attaches the usage thresholds to the child plan" do + subject + + expect(response).to have_http_status(:success) + + subscription = Subscription.find_by(id: json[:subscription][:lago_id]) + expect(subscription.plan.is_child?).to be true + expect(subscription.plan.usage_thresholds.pluck(:amount_cents, :threshold_display_name)).to eq([[override_amount_cents, override_display_name]]) + expect(subscription.plan.parent.usage_thresholds.count).to eq 2 + expect(subscription.usage_thresholds).to be_empty + + expect(json[:subscription][:plan][:usage_thresholds]).to contain_exactly( + { + lago_id: Regex::UUID, + threshold_display_name: "Overridden Threshold 1", + amount_cents: 999, + recurring: false, + created_at: Regex::ISO8601_DATETIME, + updated_at: Regex::ISO8601_DATETIME + } + ) + expect(json[:subscription][:plan][:applicable_usage_thresholds].count).to eq 2 + expect(json[:subscription][:applicable_usage_thresholds]).to eq(json[:subscription][:plan][:usage_thresholds].map { |t| t.slice(:threshold_display_name, :amount_cents, :recurring) }) + end + end + + context "when usage_thresholds are part of subscription and has plan_overrides" do + let(:update_params) do + { + name: "subscription name new", + usage_thresholds:, + plan_overrides: { + name: "rename plan to create override" + } + } + end + + it "attaches the usage thresholds to the child plan" do + subject + + expect(response).to have_http_status(:success) + + subscription = Subscription.find_by(id: json[:subscription][:lago_id]) + expect(subscription.plan.is_child?).to be true + expect(subscription.plan.usage_thresholds).to be_empty + expect(subscription.plan.parent.usage_thresholds.count).to eq 2 + expect(subscription.usage_thresholds.pluck(:amount_cents, :threshold_display_name)).to eq([[override_amount_cents, override_display_name]]) + + expect(json[:subscription][:plan][:usage_thresholds]).to be_empty + expect(json[:subscription][:plan][:applicable_usage_thresholds].count).to eq 2 + expect(json[:subscription][:applicable_usage_thresholds]).to contain_exactly( + threshold_display_name: "Overridden Threshold 1", + amount_cents: 999, + recurring: false + ) + end + end + + context "when usage_thresholds are part of subscription without any plan_overrides" do + let(:update_params) do + { + name: "subscription name new", + usage_thresholds: + } + end + + it "attaches the usage thresholds to the child plan" do + subject + + expect(response).to have_http_status(:success) + + subscription = Subscription.find_by(id: json[:subscription][:lago_id]) + expect(subscription.plan.is_parent?).to be true + expect(subscription.plan.usage_thresholds.count).to eq 2 + expect(subscription.usage_thresholds.pluck(:amount_cents, :threshold_display_name)).to eq([[override_amount_cents, override_display_name]]) + + expect(json[:subscription][:plan][:usage_thresholds].count).to eq 2 + expect(json[:subscription][:plan][:applicable_usage_thresholds].count).to eq 2 + expect(json[:subscription][:applicable_usage_thresholds]).to contain_exactly( + threshold_display_name: "Overridden Threshold 1", + amount_cents: 999, + recurring: false + ) + end + end + + context "when only usage_thresholds are passed without any other parameters" do + let(:update_params) { {usage_thresholds:} } + + it "updates the usage thresholds without requiring other parameters" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:applicable_usage_thresholds]).to contain_exactly( + threshold_display_name: override_display_name, + amount_cents: override_amount_cents, + recurring: false + ) + end + end + end + + context "when the plan has already been overriden" do + let(:update_params) do + { + name: "subscription name new", + subscription_at: "2022-09-05T12:23:12Z", + plan_overrides: { + name: "Override", + invoice_display_name: "Override plan", + interval: "monthly", + description: "This plan is used to test the override functionality", + amount_cents: 400, + amount_currency: "USD", + charges: [ + { + billable_metric_id: overriden_package_charge.billable_metric.id, + charge_model: "package", + id: overriden_package_charge.id, + invoice_display_name: "Setup", + invoiceable: true, + min_amount_cents: 6000, + properties: {amount: "60", free_units: 200, package_size: 2000}, + tax_codes: [tax.code] + }, + { + applied_pricing_unit: {code: pricing_unit.code, conversion_rate: "40"}, + billable_metric_id: other_billable_metric.id, + charge_model: "package", + code: "new_charge_code", + invoice_display_name: "Setup 2", + invoiceable: true, + min_amount_cents: 6000, + properties: {amount: "60", free_units: 200, package_size: 2000}, + tax_codes: [tax.code] + } + ], + fixed_charges: [ + { + id: overriden_fixed_charge.id, + units: 90, + invoice_display_name: "Overridden Fixed Charge", + properties: {amount: "20"}, + tax_codes: [tax.code], + apply_units_immediately: true + } + ], + minimum_commitment: { + invoice_display_name: commitment_invoice_display_name, + amount_cents: commitment_amount_cents, + tax_codes: [tax.code] + }, + usage_thresholds: [ + { + id: overriden_usage_threshold.id, + amount_cents: override_amount_cents + }, + { + amount_cents: 4000, + threshold_display_name: "Threshold 2" + } + ] + } + } + end + + let(:subscription) { create(:subscription, :pending, customer:, plan: overriden_plan) } + let(:overriden_plan) { create(:plan, organization:, parent_id: plan.id) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:overriden_package_charge) { create(:package_charge, plan: overriden_plan, organization:, billable_metric:) } + let(:overriden_fixed_charge) { create(:fixed_charge, plan: overriden_plan, organization:, parent_id: fixed_charge) } + let(:fixed_charge) { create(:fixed_charge, plan:, organization:) } + let(:commitment) { create(:commitment, plan: overriden_plan) } + let(:overriden_usage_threshold) { create(:usage_threshold, plan: overriden_plan, threshold_display_name: "Threshold 1", amount_cents: 1000) } + let(:other_billable_metric) { create(:billable_metric, organization:) } + + before do + overriden_package_charge + overriden_fixed_charge + overriden_usage_threshold + commitment + other_billable_metric + end + + context "when progressive billing premium integration is present" do + before do + organization.update!(premium_integrations: ["progressive_billing"]) + end + + it "updates a subscription" do + subject + + expect(response).to have_http_status(:success) + subscription = json[:subscription] + expect(subscription).to include( + lago_id: Regex::UUID, + name: "subscription name new", + subscription_at: "2022-09-05T12:23:12Z" + ) + plan_json = subscription[:plan] + expect(plan_json).to include( + lago_id: Regex::UUID, + name: "Override", + invoice_display_name: "Override plan", + created_at: Regex::ISO8601_DATETIME, + code: a_kind_of(String), + interval: "monthly", + description: "This plan is used to test the override functionality", + amount_cents: 400, + amount_currency: "EUR", + trial_period: nil, + pay_in_advance: false, + bill_charges_monthly: nil, + bill_fixed_charges_monthly: false, + customers_count: 0, + active_subscriptions_count: 0, + draft_invoices_count: 0, + parent_id: plan.id, + pending_deletion: false, + taxes: [] + ) + minimum_commitment = plan_json[:minimum_commitment] + expect(minimum_commitment).to match( + { + invoice_display_name: commitment_invoice_display_name, + amount_cents: commitment_amount_cents, + lago_id: Regex::UUID, + plan_code: a_kind_of(String), + interval: "monthly", + created_at: Regex::ISO8601_DATETIME, + updated_at: Regex::ISO8601_DATETIME, + taxes: [ + { + lago_id: Regex::UUID, + name: "VAT", + code: a_kind_of(String), + rate: 20.0, + description: "French Standard VAT", + applied_to_organization: false, + add_ons_count: 0, + customers_count: 0, + plans_count: 0, + charges_count: 0, + commitments_count: 0, + created_at: Regex::ISO8601_DATETIME + } + ] + } + ) + charges = plan_json[:charges].sort_by { |charge| charge[:invoice_display_name] } + expect(charges.length).to eq(2) + + first_charge = charges.first + second_charge = charges.second + expect(first_charge).to match( + { + lago_id: Regex::UUID, + lago_billable_metric_id: overriden_package_charge.billable_metric.id, + code: overriden_package_charge.code, + invoice_display_name: "Setup", + billable_metric_code: overriden_package_charge.billable_metric.code, + created_at: Regex::ISO8601_DATETIME, + charge_model: "package", + invoiceable: true, + regroup_paid_fees: nil, + pay_in_advance: false, + prorated: false, + min_amount_cents: 0, + accepts_target_wallet: false, + properties: {amount: "60", free_units: 200, package_size: 2000}, + applied_pricing_unit: nil, + lago_parent_id: nil, + filters: [], + taxes: [ + { + lago_id: Regex::UUID, + name: "VAT", + code: a_kind_of(String), + rate: 20.0, + description: "French Standard VAT", + applied_to_organization: false, + add_ons_count: 0, + customers_count: 0, + plans_count: 0, + charges_count: 0, + commitments_count: 0, + created_at: Regex::ISO8601_DATETIME + } + ] + } + ) + expect(second_charge).to match( + { + lago_id: Regex::UUID, + lago_billable_metric_id: other_billable_metric.id, + code: "new_charge_code", + invoice_display_name: "Setup 2", + billable_metric_code: other_billable_metric.code, + created_at: Regex::ISO8601_DATETIME, + charge_model: "package", + invoiceable: true, + regroup_paid_fees: nil, + pay_in_advance: false, + prorated: false, + min_amount_cents: 6000, + applied_pricing_unit: {conversion_rate: "40.0", code: pricing_unit.code}, + accepts_target_wallet: false, + properties: {amount: "60", free_units: 200, package_size: 2000}, + lago_parent_id: nil, + filters: [], + taxes: [ + { + lago_id: Regex::UUID, + name: "VAT", + code: a_kind_of(String), + rate: 20.0, + description: "French Standard VAT", + applied_to_organization: false, + add_ons_count: 0, + customers_count: 0, + plans_count: 0, + charges_count: 0, + commitments_count: 0, + created_at: Regex::ISO8601_DATETIME + } + ] + } + ) + + usage_thresholds = plan_json[:usage_thresholds] + expect(usage_thresholds.length).to eq(2) + expect(usage_thresholds.first).to match( + { + # previous threshold was removed and a new one created + # so ID as changed and threshold was lost + lago_id: Regex::UUID, + threshold_display_name: nil, + amount_cents: 999, + recurring: false, + created_at: Regex::ISO8601_DATETIME, + updated_at: Regex::ISO8601_DATETIME + } + ) + expect(usage_thresholds.second).to match( + { + lago_id: Regex::UUID, + threshold_display_name: "Threshold 2", + amount_cents: 4000, + recurring: false, + created_at: Regex::ISO8601_DATETIME, + updated_at: Regex::ISO8601_DATETIME + } + ) + end + end + + it "creates fixed charge events for the subscription" do + subject + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.fixed_charge_events.count).to eq(1) + + expect(subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp, :units)) + .to contain_exactly( + [overriden_fixed_charge.id, be_within(1.second).of(subscription.started_at), 90] + ) + end + end + + context "with not existing subscription" do + let(:external_id) { SecureRandom.uuid } + + it "returns an not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when update invoice_custom_section is sent" do + context "with skip" do + let(:update_params) do + { + invoice_custom_section: { + skip_invoice_custom_sections: true + } + } + end + + before { subscription.update(skip_invoice_custom_sections: false) } + + it "updates skip_invoice_custom_sections" do + subject + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.skip_invoice_custom_sections).to be_truthy + end + end + + context "without skipping" do + context "with sections" do + let(:update_params) do + { + invoice_custom_section: { + skip_invoice_custom_sections: false, + invoice_custom_section_codes: [section_1.code] + } + } + end + + before { subscription.update(skip_invoice_custom_sections: true) } + + it "updates skip_invoice_custom_sections" do + subject + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.skip_invoice_custom_sections).to be_falsey + expect(subscription.applied_invoice_custom_sections.count).to eq(1) + expect(subscription.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id) + end + end + end + end + + context "when updating progressive_billing_disabled" do + let(:update_params) { {progressive_billing_disabled: true} } + let(:subscription) { create(:subscription, customer:, plan:) } + + it "updates progressive_billing_disabled" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:progressive_billing_disabled]).to be(true) + + subscription = Subscription.find_by(external_id: json[:subscription][:external_id]) + expect(subscription.progressive_billing_disabled).to be(true) + end + end + + context "with multuple subscriptions" do + let(:active_plan) { create(:plan, organization:, amount_cents: 5000, description: "desc") } + let(:active_subscription) do + create(:subscription, external_id: subscription.external_id, customer:, plan:) + end + + before { active_subscription } + + it "updates the active subscription" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:lago_id]).to eq(active_subscription.id) + expect(json[:subscription][:name]).to eq("subscription name new") + + expect(json[:subscription][:plan]).to include( + name: "Override" + ) + end + + context "with pending params" do + let(:params) { {subscription: update_params, status: "pending"} } + + it "updates the pending subscription" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:lago_id]).to eq(subscription.id) + expect(json[:subscription][:name]).to eq("subscription name new") + expect(json[:subscription][:subscription_at].to_s).to eq("2022-09-05T12:23:12Z") + + expect(json[:subscription][:plan]).to include( + name: "Override" + ) + end + end + end + + context "with applied_invoice_custom_sections in response" do + let(:update_params) { {name: "updated"} } + let(:subscription) { create(:subscription, customer:, plan:) } + + before { create(:subscription_applied_invoice_custom_section, subscription:) } + + it "includes applied_invoice_custom_sections as an array in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:applied_invoice_custom_sections]).to be_an(Array) + expect(json[:subscription][:applied_invoice_custom_sections].count).to eq(1) + end + end + + context "with activation_rules" do + let(:subscription) { create(:subscription, :pending, customer:, plan:, subscription_at: Time.current + 3.days) } + let(:update_params) { {activation_rules: [{type: "payment", timeout_hours: 24}]} } + + context "when feature flag is enabled" do + before { organization.enable_feature_flag!(:payment_gated_subscriptions) } + + it "persists and returns activation rules" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:activation_rules].size).to eq(1) + expect(json[:subscription][:activation_rules].first).to include( + lago_id: String, + type: "payment", + timeout_hours: 24, + status: "inactive" + ) + end + + context "when timeout_hours is omitted" do + let(:update_params) { {activation_rules: [{type: "payment"}]} } + + it "persists rule with default timeout_hours of 0" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:activation_rules].first[:timeout_hours]).to eq(0) + end + end + end + + context "when feature flag is disabled" do + it "persists rules but does not include them in response" do + subject + + expect(response).to have_http_status(:success) + + subscription.reload + expect(subscription.activation_rules.count).to eq(1) + + expect(json[:subscription]).not_to have_key(:activation_rules) + expect(json[:subscription]).not_to have_key(:cancelation_reason) + expect(json[:subscription]).not_to have_key(:activated_at) + end + end + + context "when subscription is active" do + let(:subscription) { create(:subscription, customer:, plan:) } + + it "returns a validation error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to eq({activation_rules: %w[subscription_not_pending]}) + end + end + + context "when subscription is incomplete" do + let(:subscription) { create(:subscription, :incomplete, customer:, plan:) } + let(:update_params) { {name: "new name"} } + + it "returns a method not allowed error" do + subject + + expect(response).to have_http_status(:method_not_allowed) + expect(json[:code]).to eq("subscription_incomplete") + end + end + end + end + + describe "GET /api/v1/subscriptions/:external_id" do + subject do + get_with_token(organization, "/api/v1/subscriptions/#{external_id}", params) + end + + let(:params) { {} } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:external_id) { subscription.external_id } + + include_examples "requires API permission", "subscription", "read" + + it "returns a subscription" do + create(:entitlement, :subscription, organization:, subscription:) + usage_threshold = create(:usage_threshold, :for_subscription, subscription:) + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription]).to include( + lago_id: subscription.id, + external_id: subscription.external_id + ) + expect(json[:subscription][:entitlements]).to contain_exactly({ + code: start_with("feature_"), + name: "Feature Name", + description: "Feature Description", + privileges: [], + overrides: {} + }) + expect(json[:subscription][:applicable_usage_thresholds]).to contain_exactly( + { + amount_cents: 100, + threshold_display_name: usage_threshold.threshold_display_name, + recurring: false + } + ) + expect(json[:subscription][:plan][:applicable_usage_thresholds]).to contain_exactly( + { + amount_cents: 10_00, + threshold_display_name: "Init", + recurring: false + } + ) + end + + context "when subscription does not exist" do + let(:external_id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when status is given" do + let(:params) { {status: "pending"} } + + let!(:matching_subscription) do + create(:subscription, customer:, plan:, status: :pending, external_id: subscription.external_id) + end + + it "returns the subscription with the given status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription]).to include( + lago_id: matching_subscription.id, + external_id: matching_subscription.external_id + ) + end + end + + context "with N+1 query detection", bullet: {n_plus_one_query: true, unused_eager_loading: false} do + before do + prev = create(:subscription, customer:, plan: create(:plan, organization:), status: :terminated) + nxt = create(:subscription, customer:, plan: create(:plan, organization:), status: :pending) + subscription.update!(previous_subscription: prev, next_subscriptions: [nxt]) + end + + it "does not trigger N+1 queries when serializing associations" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:previous_plan_code]).to be_present + expect(json[:subscription][:next_plan_code]).to be_present + end + end + + context "when there are multiple terminated subscriptions" do + let(:subscription) do + create(:subscription, customer:, plan:, status: :terminated, terminated_at: 10.days.ago) + end + + let(:matching_subscription) do + create( + :subscription, + customer:, + plan:, + external_id: subscription.external_id, + terminated_at: 5.days.ago, + status: :terminated + ) + end + + let(:params) { {status: "terminated"} } + + before do + matching_subscription + end + + it "returns the latest terminated subscription" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription]).to include( + lago_id: matching_subscription.id, + external_id: matching_subscription.external_id + ) + end + end + + context "with applied_invoice_custom_sections in response" do + before { create(:subscription_applied_invoice_custom_section, subscription:) } + + it "includes applied_invoice_custom_sections as an array in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscription][:applied_invoice_custom_sections]).to be_an(Array) + expect(json[:subscription][:applied_invoice_custom_sections].count).to eq(1) + end + end + end + + describe "GET /api/v1/subscriptions" do + subject { get_with_token(organization, "/api/v1/subscriptions", params) } + + it_behaves_like "a subscription index endpoint" do + context "when external customer id is given" do + let!(:subscription_2) { create(:subscription, customer: customer_2, organization:, plan:) } + let(:customer_2) { create(:customer, organization:) } + + let(:params) { {external_customer_id: customer_2.external_id} } + + it "returns subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(subscription_2.id) + end + end + end + end +end diff --git a/spec/requests/api/v1/taxes_controller_spec.rb b/spec/requests/api/v1/taxes_controller_spec.rb new file mode 100644 index 0000000..35b2b8c --- /dev/null +++ b/spec/requests/api/v1/taxes_controller_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::TaxesController do + let(:organization) { create(:organization) } + + describe "POST /api/v1/taxes" do + subject { post_with_token(organization, "/api/v1/taxes", {tax: create_params}) } + + let(:create_params) do + { + name: "tax", + code: "tax_code", + rate: 20.0, + description: "tax_description", + applied_to_organization: false + } + end + + include_examples "requires API permission", "tax", "write" + + it "creates a tax" do + expect { subject }.to change(Tax, :count).by(1) + + expect(response).to have_http_status(:success) + expect(json[:tax][:lago_id]).to be_present + expect(json[:tax][:code]).to eq(create_params[:code]) + expect(json[:tax][:name]).to eq(create_params[:name]) + expect(json[:tax][:rate]).to eq(create_params[:rate]) + expect(json[:tax][:description]).to eq(create_params[:description]) + expect(json[:tax][:created_at]).to be_present + expect(json[:tax][:applied_to_organization]).to eq(create_params[:applied_to_organization]) + end + end + + describe "PUT /api/v1/taxes/:code" do + subject do + put_with_token( + organization, + "/api/v1/taxes/#{tax_code}", + {tax: update_params} + ) + end + + let(:tax) { create(:tax, organization:) } + let(:tax_code) { tax.code } + let(:code) { "code_updated" } + let(:name) { "name_updated" } + let(:rate) { 15.0 } + let(:applied_to_organization) { false } + + let(:update_params) do + {code:, name:, rate:, applied_to_organization:} + end + + include_examples "requires API permission", "tax", "write" + + it "updates a tax" do + subject + + expect(response).to have_http_status(:success) + expect(json[:tax][:lago_id]).to eq(tax.id) + expect(json[:tax][:code]).to eq(update_params[:code]) + expect(json[:tax][:name]).to eq(update_params[:name]) + expect(json[:tax][:rate]).to eq(update_params[:rate]) + expect(json[:tax][:applied_to_organization]).to eq(update_params[:applied_to_organization]) + end + + context "when tax does not exist" do + let(:tax_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when tax code already exists in organization scope (validation error)" do + let(:tax2) { create(:tax, organization:) } + let(:code) { tax2.code } + + it "returns unprocessable_entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + end + + describe "GET /api/v1/taxes/:code" do + subject { get_with_token(organization, "/api/v1/taxes/#{tax_code}") } + + let(:tax) { create(:tax, organization:) } + let(:tax_code) { tax.code } + + include_examples "requires API permission", "tax", "read" + + it "returns a tax" do + subject + + expect(response).to have_http_status(:success) + expect(json[:tax][:lago_id]).to eq(tax.id) + expect(json[:tax][:code]).to eq(tax.code) + end + + context "when tax does not exist" do + let(:tax_code) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1/taxes/:code" do + subject { delete_with_token(organization, "/api/v1/taxes/#{tax_code}") } + + let!(:tax) { create(:tax, organization:) } + let(:tax_code) { tax.code } + + include_examples "requires API permission", "tax", "write" + + it "deletes a tax" do + expect { subject }.to change(Tax, :count).by(-1) + end + + it "returns deleted tax" do + subject + + expect(response).to have_http_status(:success) + expect(json[:tax][:lago_id]).to eq(tax.id) + expect(json[:tax][:code]).to eq(tax.code) + end + + context "when tax does not exist" do + let(:tax_code) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/taxes" do + subject { get_with_token(organization, "/api/v1/taxes?page=1&per_page=1") } + + let!(:tax) { create(:tax, organization:) } + + include_examples "requires API permission", "tax", "read" + + it "returns taxes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:taxes].count).to eq(1) + expect(json[:taxes].first[:lago_id]).to eq(tax.id) + expect(json[:taxes].first[:code]).to eq(tax.code) + end + + context "with pagination" do + before { create(:tax, organization:) } + + it "returns taxes with correct meta data" do + subject + + expect(response).to have_http_status(:success) + expect(json[:taxes].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + end +end diff --git a/spec/requests/api/v1/wallet_transactions_controller_spec.rb b/spec/requests/api/v1/wallet_transactions_controller_spec.rb new file mode 100644 index 0000000..5ca11df --- /dev/null +++ b/spec/requests/api/v1/wallet_transactions_controller_spec.rb @@ -0,0 +1,656 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::WalletTransactionsController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:, credits_balance: 10, balance_cents: 1000) } + let(:wallet_id) { wallet.id } + + before do + subscription + wallet + end + + describe "POST /api/v1/wallet_transactions" do + subject do + post_with_token( + organization, + "/api/v1/wallet_transactions", + {wallet_transaction: params} + ) + end + + let(:params) do + { + wallet_id:, + paid_credits: "10", + granted_credits: "10", + name: "Custom Top-up Name" + } + end + + include_examples "requires API permission", "wallet_transaction", "write" + + it "creates a wallet transactions" do + subject + + expect(response).to have_http_status(:success) + + wallet_transactions = json[:wallet_transactions] + + expect(wallet_transactions.count).to eq(2) + + expect(wallet_transactions.first[:payment_method][:payment_method_type]).to eq("provider") + expect(wallet_transactions.first[:payment_method][:payment_method_id]).to eq(nil) + expect(wallet_transactions.second[:payment_method][:payment_method_type]).to eq("provider") + expect(wallet_transactions.second[:payment_method][:payment_method_id]).to eq(nil) + + paid_transaction = wallet_transactions.first + granted_transaction = wallet_transactions.second + + expect(paid_transaction[:lago_id]).to be_present + expect(paid_transaction[:status]).to eq("pending") + expect(granted_transaction[:status]).to eq("settled") + expect(granted_transaction[:lago_id]).to be_present + expect(wallet_transactions).to all(include(name: "Custom Top-up Name", lago_wallet_id: wallet.id)) + end + + context "when paid credits is below the wallet minimum" do + it "returns an error" do + wallet.update!(paid_top_up_min_amount_cents: 20_00) + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:paid_credits]).to eq(["amount_below_minimum"]) + end + end + + context "with voided credits" do + let(:wallet) { create(:wallet, :with_inbound_transaction, customer:, credits_balance: 20, balance_cents: 2000) } + let(:params) do + { + wallet_id:, + voided_credits: "10" + } + end + + it "creates a wallet transactions" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:wallet_transactions].count).to eq(1) + expect(json[:wallet_transactions].first).to include( + lago_id: String, + status: "settled", + transaction_status: "voided", + lago_wallet_id: wallet.id + ) + expect(wallet.reload.credits_balance).to eq(10) + end + end + + context "when metadata is present" do + let(:params) do + { + wallet_id:, + paid_credits: "10", + granted_credits: "10", + voided_credits: "5", + metadata: [{"key" => "valid_value", "value" => "also_valid"}] + } + end + + it "creates the wallet transactions with correct data" do + subject + + expect(response).to have_http_status(:success) + + wallet_transactions = json[:wallet_transactions] + + expect(wallet_transactions.count).to eq(3) + expect(wallet_transactions).to all(include(metadata: [{key: "valid_value", value: "also_valid"}])) + end + end + + context "when priority is present" do + let(:params) do + { + wallet_id:, + paid_credits: "10", + granted_credits: "10", + priority: 1 + } + end + + it "creates the wallet transactions with correct priority" do + subject + + expect(response).to have_http_status(:success) + + wallet_transactions = json[:wallet_transactions] + + expect(wallet_transactions.count).to eq(2) + expect(wallet_transactions).to all(include(priority: 1)) + end + end + + context "when wallet does not exist" do + let(:wallet_id) { "#{wallet.id}123" } + + it "returns unprocessable_entity error" do + subject + expect(response).to have_http_status(:unprocessable_content) + end + end + + context "with invoice_custom_section" do + let(:params) do + { + wallet_id:, + paid_credits: "10", + name: "Custom Top-up Name", + invoice_custom_section: {invoice_custom_section_codes: ["section_code_1"]} + } + end + + before do + CurrentContext.source = "api" + create(:invoice_custom_section, organization:, code: "section_code_1") + end + + it "creates the wallet transactions with correct data" do + subject + + expect(response).to have_http_status(:success) + + wallet_transaction = WalletTransaction.find(json[:wallet_transactions].first[:lago_id]) + expect(wallet_transaction.applied_invoice_custom_sections.count).to eq(1) + expect(wallet_transaction.applied_invoice_custom_sections.first.invoice_custom_section.code).to eq("section_code_1") + end + end + end + + describe "GET /api/v1/wallet_transactions" do + subject do + get_with_token(organization, "/api/v1/wallets/#{wallet_id}/wallet_transactions", params) + end + + let(:params) { {} } + let(:wallet_transaction_first) { create(:wallet_transaction, wallet:) } + let(:wallet_transaction_second) { create(:wallet_transaction, wallet:) } + let(:wallet_transaction_third) { create(:wallet_transaction) } + + before do + wallet_transaction_first + wallet_transaction_second + wallet_transaction_third + end + + include_examples "requires API permission", "wallet_transaction", "read" + + it "returns wallet transactions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].count).to eq(2) + expect(json[:wallet_transactions].first[:lago_id]).to eq(wallet_transaction_second.id) + expect(json[:wallet_transactions].last[:lago_id]).to eq(wallet_transaction_first.id) + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + it "returns wallet transactions with correct meta data" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:wallet_transactions].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + + context "with status param" do + let(:params) { {status: "pending"} } + let(:wallet_transaction_second) { create(:wallet_transaction, wallet:, status: "pending") } + + it "returns wallet transactions with correct status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].count).to eq(1) + expect(json[:wallet_transactions].first[:lago_id]).to eq(wallet_transaction_second.id) + end + end + + context "with transaction type param" do + let(:params) { {transaction_type: "outbound"} } + let(:wallet_transaction_second) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + + it "returns wallet transactions with correct transaction type" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].count).to eq(1) + expect(json[:wallet_transactions].first[:lago_id]).to eq(wallet_transaction_second.id) + end + end + + context "with transaction_status filter" do + # Override outer context transactions to avoid interference + let(:wallet_transaction_first) { nil } + let(:wallet_transaction_second) { nil } + + let(:purchased_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :purchased) + end + let(:granted_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :granted) + end + let(:voided_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :voided) + end + let(:invoiced_transaction) do + create(:wallet_transaction, wallet:, transaction_status: :invoiced) + end + + before do + purchased_transaction + granted_transaction + voided_transaction + invoiced_transaction + end + + context "with purchased status" do + let(:params) { {transaction_status: "purchased"} } + + it "filters by purchased status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].pluck(:lago_id)).to eq([purchased_transaction.id]) + end + end + + context "with granted status" do + let(:params) { {transaction_status: "granted"} } + + it "filters by granted status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].pluck(:lago_id)).to eq([granted_transaction.id]) + end + end + + context "with voided status" do + let(:params) { {transaction_status: "voided"} } + + it "filters by voided status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].pluck(:lago_id)).to eq([voided_transaction.id]) + end + end + + context "with invoiced status" do + let(:params) { {transaction_status: "invoiced"} } + + it "filters by invoiced status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].pluck(:lago_id)).to eq([invoiced_transaction.id]) + end + end + + context "with invalid transaction_status value" do + let(:params) { {transaction_status: "invalid"} } + + it "ignores invalid transaction_status values" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transactions].count).to eq(4) + end + end + end + + context "when wallet does not exist" do + let(:wallet_id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/wallet_transactions/:id" do + subject do + get_with_token(organization, "/api/v1/wallet_transactions/#{wallet_transaction_id}", params) + end + + let(:params) { {} } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + let(:wallet_transaction_id) { wallet_transaction.id } + + include_examples "requires API permission", "wallet_transaction", "read" + + it "returns the wallet transaction" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transaction][:lago_id]).to eq(wallet_transaction.id) + end + + context "when wallet transaction belongs to another organization" do + let(:customer) { create(:customer, organization: create(:organization)) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when wallet_transaction does not exist" do + let(:wallet_transaction_id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "with applied_invoice_custom_sections in response" do + before { create(:wallet_transaction_applied_invoice_custom_section, wallet_transaction:) } + + it "includes applied_invoice_custom_sections in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transaction][:applied_invoice_custom_sections].count).to eq(1) + end + end + end + + describe "POST /api/v1/wallet_transactions/:id/payment_url" do + subject do + post_with_token(organization, "/api/v1/wallet_transactions/#{wallet_transaction_id}/payment_url") + end + + context "when wallet transaction exits" do + let(:wallet_transaction_id) { wallet_transaction.id } + let(:wallet_transaction) { create(:wallet_transaction, :with_invoice, wallet:, status: :pending, customer:) } + let(:wallet) { create(:wallet, customer:) } + let(:customer) { create(:customer, :with_stripe_payment_provider, organization:) } + let(:generated_payment_url) { "https://example.com" } + + before do + allow(::Stripe::Checkout::Session).to receive(:create).and_return({"url" => generated_payment_url}) + end + + include_examples "requires API permission", "wallet_transaction", "write" + + it "returns the generated payment url" do + subject + + expect(response).to have_http_status(:success) + expect(json).to match({ + wallet_transaction_payment_details: hash_including(payment_url: generated_payment_url) + }) + end + end + + context "when wallet_transaction does not exist" do + let(:wallet_transaction_id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "GET /api/v1/wallet_transactions/:id/consumptions" do + subject { get_with_token(organization, "/api/v1/wallet_transactions/#{wallet_transaction.id}/consumptions", params) } + + let(:params) { {} } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + + include_examples "requires API permission", "wallet_transaction", "read" + + context "with consumptions" do + let(:consumption1) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: wallet_transaction, + outbound_wallet_transaction: create(:wallet_transaction, wallet:, transaction_type: "outbound"), + consumed_amount_cents: 500) + end + let(:consumption2) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: wallet_transaction, + outbound_wallet_transaction: create(:wallet_transaction, wallet:, transaction_type: "outbound"), + consumed_amount_cents: 300) + end + + before do + consumption1 + consumption2 + end + + it "returns paginated consumptions with nested outbound transaction" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transaction_consumptions].count).to eq(2) + expect(json[:wallet_transaction_consumptions].first[:lago_id]).to eq(consumption2.id) + expect(json[:wallet_transaction_consumptions].first[:amount_cents]).to eq(300) + expect(json[:wallet_transaction_consumptions].first[:wallet_transaction]).to be_present + expect(json[:wallet_transaction_consumptions].first[:wallet_transaction][:lago_id]).to eq(consumption2.outbound_wallet_transaction_id) + expect(json[:meta]).to be_present + end + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 2} } + + before do + 3.times do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: wallet_transaction, + outbound_wallet_transaction: create(:wallet_transaction, wallet:, transaction_type: "outbound")) + end + end + + it "returns paginated results" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transaction_consumptions].count).to eq(2) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(3) + end + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, customer:, traceable: false) } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to eq("validation_errors") + expect(json[:error_details][:wallet]).to include("not_traceable") + end + end + + context "when transaction type is outbound" do + let(:wallet_transaction) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to eq("validation_errors") + expect(json[:error_details][:transaction_type]).to include("invalid_transaction_type") + end + end + + context "when wallet_transaction does not exist" do + let(:wallet_transaction) { build(:wallet_transaction, id: SecureRandom.uuid) } + + it "returns not_found error" do + subject + + expect(response).to be_not_found_error("wallet_transaction") + end + end + + context "when wallet_transaction belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_wallet) { create(:wallet, customer: other_customer, traceable: true) } + let(:wallet_transaction) { create(:wallet_transaction, wallet: other_wallet, transaction_type: "inbound", remaining_amount_cents: 10000) } + + it "returns not_found error" do + subject + + expect(response).to be_not_found_error("wallet_transaction") + end + end + end + + describe "GET /api/v1/wallet_transactions/:id/fundings" do + subject { get_with_token(organization, "/api/v1/wallet_transactions/#{wallet_transaction.id}/fundings", params) } + + let(:params) { {} } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, transaction_type: "outbound") } + + include_examples "requires API permission", "wallet_transaction", "read" + + context "with fundings" do + let(:inbound_transaction1) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + let(:inbound_transaction2) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + let(:funding1) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction1, + outbound_wallet_transaction: wallet_transaction, + consumed_amount_cents: 500) + end + let(:funding2) do + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound_transaction2, + outbound_wallet_transaction: wallet_transaction, + consumed_amount_cents: 300) + end + + before do + funding1 + funding2 + end + + it "returns paginated fundings with nested inbound transaction" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transaction_fundings].count).to eq(2) + expect(json[:wallet_transaction_fundings].first[:lago_id]).to eq(funding2.id) + expect(json[:wallet_transaction_fundings].first[:amount_cents]).to eq(300) + expect(json[:wallet_transaction_fundings].first[:wallet_transaction]).to be_present + expect(json[:wallet_transaction_fundings].first[:wallet_transaction][:lago_id]).to eq(inbound_transaction2.id) + expect(json[:meta]).to be_present + end + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 2} } + + before do + 3.times do + inbound = create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) + create(:wallet_transaction_consumption, + organization:, + inbound_wallet_transaction: inbound, + outbound_wallet_transaction: wallet_transaction) + end + end + + it "returns paginated results" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet_transaction_fundings].count).to eq(2) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(3) + end + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, customer:, traceable: false) } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to eq("validation_errors") + expect(json[:error_details][:wallet]).to include("not_traceable") + end + end + + context "when transaction type is inbound" do + let(:wallet_transaction) { create(:wallet_transaction, wallet:, transaction_type: "inbound", remaining_amount_cents: 10000) } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:code]).to eq("validation_errors") + expect(json[:error_details][:transaction_type]).to include("invalid_transaction_type") + end + end + + context "when wallet_transaction does not exist" do + let(:wallet_transaction) { build(:wallet_transaction, id: SecureRandom.uuid) } + + it "returns not_found error" do + subject + + expect(response).to be_not_found_error("wallet_transaction") + end + end + + context "when wallet_transaction belongs to another organization" do + let(:other_organization) { create(:organization) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:other_wallet) { create(:wallet, customer: other_customer, traceable: true) } + let(:wallet_transaction) { create(:wallet_transaction, wallet: other_wallet, transaction_type: "outbound") } + + it "returns not_found error" do + subject + + expect(response).to be_not_found_error("wallet_transaction") + end + end + end +end diff --git a/spec/requests/api/v1/wallets/metadata_controller_spec.rb b/spec/requests/api/v1/wallets/metadata_controller_spec.rb new file mode 100644 index 0000000..89261fe --- /dev/null +++ b/spec/requests/api/v1/wallets/metadata_controller_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::Wallets::MetadataController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, organization:) } + let(:wallet_id) { wallet.id } + + describe "POST /api/v1/wallets/:id/metadata" do + it_behaves_like "a wallet metadata create endpoint" do + subject { post_with_token(organization, "/api/v1/wallets/#{wallet_id}/metadata", metadata_params) } + end + end + + describe "PATCH /api/v1/wallets/:id/metadata" do + it_behaves_like "a wallet metadata update endpoint" do + subject { patch_with_token(organization, "/api/v1/wallets/#{wallet_id}/metadata", metadata_params) } + end + end + + describe "DELETE /api/v1/wallets/:id/metadata" do + it_behaves_like "a wallet metadata destroy endpoint" do + subject { delete_with_token(organization, "/api/v1/wallets/#{wallet_id}/metadata") } + end + end + + describe "DELETE /api/v1/wallets/:id/metadata/:key" do + it_behaves_like "a wallet metadata destroy key endpoint" do + subject { delete_with_token(organization, "/api/v1/wallets/#{wallet_id}/metadata/#{key}") } + end + end +end diff --git a/spec/requests/api/v1/wallets_controller_spec.rb b/spec/requests/api/v1/wallets_controller_spec.rb new file mode 100644 index 0000000..03af5b0 --- /dev/null +++ b/spec/requests/api/v1/wallets_controller_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::WalletsController do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, currency: "EUR") } + let(:subscription) { create(:subscription, customer:) } + let(:expiration_at) { (Time.current + 1.year).iso8601 } + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:payment_method) { create(:payment_method, organization:, customer:) } + + before { subscription } + + describe "POST /api/v1/wallets" do + it_behaves_like "a wallet create endpoint" do + subject do + post_with_token(organization, "/api/v1/wallets", {wallet: create_params}) + end + + context "when params[:external_customer_id] is empty" do + it "returns a validation error" do + create_params.delete(:external_customer_id) + + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:customer]).to eq ["customer_not_found"] + end + end + end + + it_behaves_like "a wallet create endpoint with billing_entity_id" do + subject do + post_with_token(organization, "/api/v1/wallets", {wallet: create_params}) + end + end + end + + describe "PUT /api/v1/wallets/:id" do + it_behaves_like "a wallet update endpoint" do + subject do + put_with_token( + organization, + "/api/v1/wallets/#{id}", + {wallet: update_params} + ) + end + + let(:id) { wallet.id } + end + end + + describe "GET /api/v1/wallets/:id" do + it_behaves_like "a wallet show endpoint" do + subject { get_with_token(organization, "/api/v1/wallets/#{id}") } + + let(:id) { wallet.id } + end + end + + describe "DELETE /api/v1/wallets/:id" do + it_behaves_like "a wallet terminate endpoint" do + subject { delete_with_token(organization, "/api/v1/wallets/#{id}") } + + let(:id) { wallet.id } + end + end + + describe "GET /api/v1/wallets" do + it_behaves_like "a wallet index endpoint" do + subject do + get_with_token(organization, "/api/v1/wallets?external_customer_id=#{external_id}", params) + end + + context "when external_customer_id does not belong to the current organization" do + let(:other_org_customer) { create(:customer) } + let(:external_id) { other_org_customer.external_id } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + end +end diff --git a/spec/requests/api/v1/webhook_endpoints_controller_spec.rb b/spec/requests/api/v1/webhook_endpoints_controller_spec.rb new file mode 100644 index 0000000..8c176d1 --- /dev/null +++ b/spec/requests/api/v1/webhook_endpoints_controller_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::WebhookEndpointsController do + describe "POST /api/v1/webhook_endpoints" do + subject do + post_with_token( + organization, + "/api/v1/webhook_endpoints", + {webhook_endpoint: create_params} + ) + end + + let(:organization) { create(:organization) } + let(:create_params) do + { + webhook_url: Faker::Internet.url, + signature_algo: "jwt", + name: "Test Webhook", + event_types: ["customer.created", "customer.updated"] + } + end + + include_context "with mocked security logger" + include_examples "requires API permission", "webhook_endpoint", "write" + + context "with valid parameters" do + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:webhook_endpoint][:webhook_url]).to eq(create_params[:webhook_url]) + expect(json[:webhook_endpoint][:signature_algo]).to eq("jwt") + expect(json[:webhook_endpoint][:name]).to eq(create_params[:name]) + expect(json[:webhook_endpoint][:event_types]).to eq(create_params[:event_types]) + end + end + + context "with event_types parameter provided" do + context "when event_types is invalid" do + let(:create_params) { + { + webhook_url: Faker::Internet.url, + event_types: "wrong" + } + } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include( + event_types: ["must_be_array"] + ) + end + end + + context "when event_types contains invalid types" do + let(:create_params) { + { + webhook_url: Faker::Internet.url, + event_types: ["wrong.type"] + } + } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include( + event_types: ["contains invalid types: [\"wrong.type\"]"] + ) + end + end + + context "when event_types is [*]" do + let(:create_params) { + { + webhook_url: Faker::Internet.url, + event_types: ["*"] + } + } + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + expect(json[:webhook_endpoint][:webhook_url]).to eq(create_params[:webhook_url]) + expect(json[:webhook_endpoint][:event_types]).to eq(nil) + end + end + end + + it_behaves_like "produces a security log", "webhook_endpoint.created" do + before { subject } + end + end + + describe "GET /api/v1/webhook_endpoints" do + subject { get_with_token(organization, "/api/v1/webhook_endpoints") } + + let(:organization) { create(:organization) } + + before { create_pair(:webhook_endpoint, organization:) } + + include_examples "requires API permission", "webhook_endpoint", "read" + + it "returns all webhook endpoints from organization" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:meta][:total_count]).to eq(3) + end + end + + describe "GET /api/v1/webhook_endpoints/:id" do + subject { get_with_token(organization, "/api/v1/webhook_endpoints/#{id}") } + + let(:webhook_endpoint) { create(:webhook_endpoint) } + let(:organization) { webhook_endpoint.organization.reload } + + context "with existing id" do + let(:id) { webhook_endpoint.id } + + include_examples "requires API permission", "webhook_endpoint", "read" + + it "returns the customer" do + subject + + expect(response).to have_http_status(:ok) + expect(json[:webhook_endpoint][:lago_id]).to eq(webhook_endpoint.id) + end + end + + context "with not existing id" do + let(:id) { SecureRandom.uuid } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "DELETE /api/v1webhook_endpoints/:id" do + subject { delete_with_token(organization, "/api/v1/webhook_endpoints/#{id}") } + + include_context "with mocked security logger" + + let!(:webhook_endpoint) { create(:webhook_endpoint) } + let(:organization) { webhook_endpoint.organization.reload } + + context "when webhook endpoint exists" do + let(:id) { webhook_endpoint.id } + + include_examples "requires API permission", "webhook_endpoint", "write" + + it "deletes a webhook endpoint" do + expect { subject }.to change(WebhookEndpoint, :count).by(-1) + end + + it "returns deleted webhook_endpoint" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:webhook_endpoint][:lago_id]).to eq(webhook_endpoint.id) + expect(json[:webhook_endpoint][:webhook_url]).to eq(webhook_endpoint.webhook_url) + expect(json[:webhook_endpoint][:name]).to eq(webhook_endpoint.name) + expect(json[:webhook_endpoint][:event_types]).to eq(webhook_endpoint.event_types) + end + + it_behaves_like "produces a security log", "webhook_endpoint.deleted" do + before { subject } + end + end + + context "when webhook endpoint does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end + + describe "PUT /api/v1/webhook_endpoints/:id" do + subject do + put_with_token( + organization, + "/api/v1/webhook_endpoints/#{id}", + {webhook_endpoint: update_params} + ) + end + + include_context "with mocked security logger" + + let(:webhook_endpoint) { create(:webhook_endpoint) } + let(:organization) { webhook_endpoint.organization.reload } + let(:update_params) do + { + webhook_url: "http://foo.bar", + signature_algo: "hmac", + name: "Updated Webhook", + event_types: ["invoice.created", "invoice.voided"] + } + end + + before { webhook_endpoint } + + context "when webhook endpoint exists" do + let(:id) { webhook_endpoint.id } + + include_examples "requires API permission", "webhook_endpoint", "write" + + context "when all parameters are provided" do + it "updates a webhook endpoint" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:webhook_endpoint][:webhook_url]).to eq("http://foo.bar") + expect(json[:webhook_endpoint][:signature_algo]).to eq("hmac") + expect(json[:webhook_endpoint][:name]).to eq("Updated Webhook") + expect(json[:webhook_endpoint][:event_types]).to eq(["invoice.created", "invoice.voided"]) + end + end + + context "when event_types is explicitly set to null" do + let(:webhook_endpoint) { create(:webhook_endpoint, event_types: ["customer.created"]) } + let(:update_params) { {event_types: nil} } + + it "updates a webhook endpoint" do + subject + + expect(response).to have_http_status(:success) + expect(json[:webhook_endpoint][:event_types]).to eq(nil) + end + end + + context "when event_types is explicitly set to empty array" do + let(:webhook_endpoint) { create(:webhook_endpoint, event_types: ["customer.created"]) } + let(:update_params) { {event_types: []} } + + it "updates a webhook endpoint" do + subject + + expect(response).to have_http_status(:success) + expect(json[:webhook_endpoint][:event_types]).to eq([]) + end + end + + context "when event_types is invalid" do + let(:update_params) { {event_types: "wrong"} } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include( + event_types: ["must_be_array"] + ) + end + end + + context "when event_types contains invalid types" do + let(:update_params) { {event_types: ["wrong.type"]} } + + it "returns unprocessable_content error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details]).to include( + event_types: ["contains invalid types: [\"wrong.type\"]"] + ) + end + end + + context "when event_types is [*]" do + let(:update_params) { {event_types: ["*"]} } + + it "returns a success" do + subject + + expect(response).to have_http_status(:success) + expect(json[:webhook_endpoint][:event_types]).to eq(nil) + end + end + + it_behaves_like "produces a security log", "webhook_endpoint.updated" do + before { subject } + end + + context "when only webhook_url is provided" do + let(:update_params) { {webhook_url: "http://foo.bar"} } + + it "updates webhook_url without resetting signature_algo" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:webhook_endpoint][:webhook_url]).to eq("http://foo.bar") + expect(json[:webhook_endpoint][:signature_algo]).to eq("jwt") + end + end + end + + context "when webhook endpoint does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + end +end diff --git a/spec/requests/api/v1/webhooks_controller_spec.rb b/spec/requests/api/v1/webhooks_controller_spec.rb new file mode 100644 index 0000000..d65d430 --- /dev/null +++ b/spec/requests/api/v1/webhooks_controller_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Api::V1::WebhooksController do + let(:organization) { create(:organization) } + + describe "GET /api/v1/webhooks/public_key" do + subject { get_with_token(organization, "/api/v1/webhooks/public_key") } + + include_examples "requires API permission", "webhook_jwt_public_key", "read" + + it "returns the public key used to verify webhook signatures" do + subject + + expect(response).to have_http_status(:success) + expect(response.body).to eq(Base64.encode64(RsaPublicKey.to_s)) + end + end + + describe "GET /api/v1/webhooks/json_public_key" do + subject { get_with_token(organization, "/api/v1/webhooks/json_public_key") } + + include_examples "requires API permission", "webhook_jwt_public_key", "read" + + it "returns the public key in JSON response used to verify webhook signatures" do + subject + + expect(response).to have_http_status(:success) + expect(json[:webhook][:public_key]).to eq(Base64.encode64(RsaPublicKey.to_s)) + end + end +end diff --git a/spec/requests/application_controller_spec.rb b/spec/requests/application_controller_spec.rb new file mode 100644 index 0000000..893beac --- /dev/null +++ b/spec/requests/application_controller_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApplicationController do + describe "GET /health" do + it "returns the application health check" do + get "/health" + + expect(response.status).to be(200) + expect(json[:message]).to eq("Success") + expect(json[:version]).to be_present + expect(json[:github_url]).to be_present + end + end + + describe "Missing resources" do + it "returns a 404 response" do + get "/not_found" + expect(response).to be_not_found_error("resource") + end + end +end diff --git a/spec/requests/data_api/base_controller_spec.rb b/spec/requests/data_api/base_controller_spec.rb new file mode 100644 index 0000000..307b983 --- /dev/null +++ b/spec/requests/data_api/base_controller_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::BaseController, type: :controller do + controller do + def index + render nothing: true + end + end + + before do + allow(CurrentContext).to receive(:source=) + allow(CurrentContext).to receive(:api_key_id=) + allow(CurrentContext).to receive(:email=) + end + + describe "#authenticate" do + context "with valid X-Data-API-Key header" do + before do + stub_const("ENV", ENV.to_hash.merge("LAGO_DATA_API_BEARER_TOKEN" => "test_api_key")) + request.headers["X-Data-API-Key"] = "test_api_key" + get :index + end + + it "returns success response" do + expect(response).to have_http_status(:success) + end + + it "sets current context email to nil" do + expect(CurrentContext).to have_received(:email=).with(nil) + end + end + + context "with missing X-Data-API-Key header" do + before do + stub_const("ENV", ENV.to_hash.merge("LAGO_DATA_API_BEARER_TOKEN" => "test_api_key")) + get :index + end + + it "returns unauthorized status" do + expect(response).to have_http_status(:unauthorized) + end + end + + context "with invalid X-Data-API-Key header" do + before do + stub_const("ENV", ENV.to_hash.merge("LAGO_DATA_API_BEARER_TOKEN" => "test_api_key")) + request.headers["X-Data-API-Key"] = "invalid_key" + get :index + end + + it "returns unauthorized status" do + expect(response).to have_http_status(:unauthorized) + end + end + + context "when LAGO_DATA_API_BEARER_TOKEN is not set" do + before do + stub_const("ENV", ENV.to_hash.merge("LAGO_DATA_API_BEARER_TOKEN" => nil)) + request.headers["X-Data-API-Key"] = "test_api_key" + get :index + end + + it "returns unauthorized status" do + expect(response).to have_http_status(:unauthorized) + end + end + end + + describe "#set_context_source" do + before do + stub_const("ENV", ENV.to_hash.merge("LAGO_DATA_API_BEARER_TOKEN" => "test_api_key")) + request.headers["X-Data-API-Key"] = "test_api_key" + get :index + end + + it "sets the context source to data" do + expect(CurrentContext).to have_received(:source=).with("data") + end + + it "sets the api_key_id to nil" do + expect(CurrentContext).to have_received(:api_key_id=).with(nil) + end + end +end diff --git a/spec/requests/data_api/v1/charges_controller_spec.rb b/spec/requests/data_api/v1/charges_controller_spec.rb new file mode 100644 index 0000000..f1849c0 --- /dev/null +++ b/spec/requests/data_api/v1/charges_controller_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::V1::ChargesController do # rubocop:disable Rails/FilePath + def post_with_data_api_key(path, params = {}) + headers = { + "Content-Type" => "application/json", + "Accept" => "application/json", + "X-Data-API-Key" => "test_api_key" + } + post(path, params: params.to_json, headers:) + end + describe "POST /data_api/v1/charges/bulk_forecasted_usage_amount" do + subject { post_with_data_api_key("/data_api/v1/charges/bulk_forecasted_usage_amount", params) } + + let(:charge1) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "10"} + ) + end + + let(:charge2) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "20"} + ) + end + + let(:charge_filter) { create(:charge_filter, charge: charge1) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:organization) { create(:organization) } + + let(:params) do + { + charges: [ + { + record_id: 1, + charge_id: charge1.id, + charge_filter_id: charge_filter.id, + units_conservative: 100, + units_realistic: 500, + units_optimistic: 1000 + }, + { + record_id: 2, + charge_id: charge2.id, + units_conservative: 200, + units_realistic: 600, + units_optimistic: 1200 + } + ] + } + end + + let(:result) do + BaseService::Result.new.tap do |result| + result.charge_amount_cents = 10 + result.subscription_amount_cents = 10 + result.total_amount_cents = 20 + end + end + + before do + allow(Charges::CalculatePriceService).to receive(:call).and_return(result) + stub_const("ENV", ENV.to_hash.merge("LAGO_DATA_API_BEARER_TOKEN" => "test_api_key")) + end + + context "when authenticated and premium", :premium do + context "when charges are found" do + it "returns the bulk forecasted usage amounts" do + subject + + expect(response).to have_http_status(:success) + + json_response = json + expect(json_response[:results]).to be_an(Array) + expect(json_response[:results].size).to eq(2) + expect(json_response[:processed_count]).to eq(2) + expect(json_response[:failed_count]).to eq(0) + expect(json_response[:failed_charges]).to be_empty + + first_result = json_response[:results].first + expect(first_result[:record_id]).to eq(1) + expect(first_result[:charge_id]).to eq(charge1.id) + expect(first_result[:charge_filter_id]).to eq(charge_filter.id) + expect(first_result).to have_key(:charge_amount_cents_conservative) + expect(first_result).to have_key(:charge_amount_cents_realistic) + expect(first_result).to have_key(:charge_amount_cents_optimistic) + + second_result = json_response[:results].second + expect(second_result[:record_id]).to eq(2) + expect(second_result[:charge_id]).to eq(charge2.id) + expect(second_result[:charge_filter_id]).to be_nil + + expect(Charges::CalculatePriceService).to have_received(:call).exactly(6).times + expect(Charges::CalculatePriceService).to have_received(:call).with(units: 100, charge: charge1, charge_filter: charge_filter) + expect(Charges::CalculatePriceService).to have_received(:call).with(units: 500, charge: charge1, charge_filter: charge_filter) + expect(Charges::CalculatePriceService).to have_received(:call).with(units: 1000, charge: charge1, charge_filter: charge_filter) + expect(Charges::CalculatePriceService).to have_received(:call).with(units: 200, charge: charge2, charge_filter: nil) + expect(Charges::CalculatePriceService).to have_received(:call).with(units: 600, charge: charge2, charge_filter: nil) + expect(Charges::CalculatePriceService).to have_received(:call).with(units: 1200, charge: charge2, charge_filter: nil) + end + end + + context "when no charges are provided" do + let(:params) { {charges: []} } + + it "returns a bad request error" do + subject + + expect(response).to have_http_status(:bad_request) + expect(json[:error]).to eq("No charges provided") + end + end + + context "when charge is not found" do + let(:params) do + { + charges: [ + { + record_id: 3, + charge_id: "nonexistent", + units_conservative: 100 + } + ] + } + end + + it "returns results with failed charges" do + subject + + expect(response).to have_http_status(:success) + + json_response = json + expect(json_response[:results]).to be_empty + expect(json_response[:failed_charges].size).to eq(1) + expect(json_response[:failed_charges].first[:record_id]).to eq(3) + expect(json_response[:failed_charges].first[:charge_id]).to eq("nonexistent") + expect(json_response[:failed_charges].first[:error]).to include("Charge not found") + expect(json_response[:processed_count]).to eq(0) + expect(json_response[:failed_count]).to eq(1) + end + end + + context "when charge_filter is not found" do + let(:params) do + { + charges: [ + { + record_id: 4, + charge_id: charge1.id, + charge_filter_id: "nonexistent", + units_conservative: 100 + } + ] + } + end + + it "returns results with failed charges" do + subject + + expect(response).to have_http_status(:success) + + json_response = json + expect(json_response[:results]).to be_empty + expect(json_response[:failed_charges].size).to eq(1) + expect(json_response[:failed_charges].first[:record_id]).to eq(4) + expect(json_response[:failed_charges].first[:charge_id]).to eq(charge1.id) + expect(json_response[:failed_charges].first[:error]).to include("ChargeFilter not found") + expect(json_response[:processed_count]).to eq(0) + expect(json_response[:failed_count]).to eq(1) + end + end + + context "when charges param is missing" do + let(:params) { {} } + + it "returns a bad request error" do + subject + + expect(response).to have_http_status(:bad_request) + expect(json[:error]).to eq("No charges provided") + end + end + + context "with mixed successful and failed charges" do + let(:params) do + { + charges: [ + { + record_id: 5, + charge_id: charge1.id, + units_conservative: 100 + }, + { + record_id: 6, + charge_id: "nonexistent", + units_conservative: 200 + } + ] + } + end + + it "returns partial results" do + subject + + expect(response).to have_http_status(:success) + + json_response = json + expect(json_response[:results].size).to eq(1) + expect(json_response[:failed_charges].size).to eq(1) + expect(json_response[:processed_count]).to eq(1) + expect(json_response[:failed_count]).to eq(1) + end + end + end + + context "when authenticated but not premium" do + it "returns forbidden status" do + subject + expect(response).to have_http_status(:forbidden) + expect(json[:code]).to eq("feature_unavailable") + end + end + + context "when not authenticated" do + def post_with_data_api_key(path, params = {}) + headers = { + "Content-Type" => "application/json", + "Accept" => "application/json" + } + post(path, params: params.to_json, headers:) + end + + it "returns unauthorized status" do + subject + expect(response).to have_http_status(:unauthorized) + end + end + end +end diff --git a/spec/requests/graphql_controller_spec.rb b/spec/requests/graphql_controller_spec.rb new file mode 100644 index 0000000..3e58ae0 --- /dev/null +++ b/spec/requests/graphql_controller_spec.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe GraphqlController do + describe "POST /graphql" do + before do + allow(CurrentContext).to receive(:source=) + allow(CurrentContext).to receive(:api_key_id=) + end + + context "when logging in" do + let(:membership) { create(:membership) } + let(:user) { membership.user } + + let(:mutation) do + <<~GQL + mutation($input: LoginUserInput!) { + loginUser(input: $input) { + token + user { + id + organizations { id name } + } + } + } + GQL + end + + before do + post "/graphql", + params: { + query: mutation, + variables: { + input: { + email: user.email, + password: "ILoveLago" + } + } + } + end + + it "returns GraphQL response" do + expect(response.status).to be(200) + expect(CurrentContext).to have_received(:source=).with("graphql") + expect(CurrentContext).to have_received(:api_key_id=).with(nil) + + json = JSON.parse(response.body) + expect(json["data"]["loginUser"]["token"]).to be_present + expect(json["data"]["loginUser"]["user"]["id"]).to eq(user.id) + expect(json["data"]["loginUser"]["user"]["organizations"].first["id"]).to eq(membership.organization_id) + end + + context "when membership is revoked" do + let(:membership) { create(:membership, :revoked) } + + it "returns an error" do + expect(response.status).to be(200) + + json = JSON.parse(response.body) + error = json["errors"].first + expect(error["extensions"]["code"]).to eq("unprocessable_entity") + expect(error.dig("extensions", "details", "base")).to eq ["incorrect_login_or_password"] + expect(error["extensions"]["status"]).to eq(422) + end + end + end + + context "with JWT token" do + let(:user) { create(:user) } + let(:query) do + <<~GRAPHQL + query { + currentUser { + id + premium + memberships { + status + organization { + id + } + } + } + } + GRAPHQL + end + + let(:token) do + Auth::TokenService.encode(user:) + end + + it "retrieves the current user" do + post "/graphql", + headers: { + "Authorization" => "Bearer #{token}" + }, + params: { + query: + } + + expect(response.status).to be(200) + expect(json[:data][:currentUser][:id]).to eq user.id + expect(json[:data][:currentUser][:memberships]).to be_empty + end + + context "when organization id header is set" do + context "when user is not part of organization" do + it "returns no membership" do + post "/graphql", + headers: { + "Authorization" => "Bearer #{token}", + "x-lago-organization" => SecureRandom.uuid + }, + params: { + query: + } + + expect(json[:data][:currentUser][:memberships]).to be_empty + end + end + + context "when user is part of organization" do + let(:admin_role) { create(:role, :admin) } + let(:membership) { create(:membership, user:) } + let(:organization) { membership.organization } + + before { create(:membership_role, membership:, role: admin_role) } + + it "returns the membership" do + post "/graphql", + headers: { + "Authorization" => "Bearer #{token}", + "x-lago-organization" => organization.id + }, + params: { + query: + } + + expect(json[:data][:currentUser][:memberships].sole[:organization][:id]).to eq organization.id + end + end + + context "when membership is revoked" do + let(:membership) { create(:membership, :revoked, user:) } + let(:organization) { membership.organization } + + it "returns the membership" do + post "/graphql", + headers: { + "Authorization" => "Bearer #{token}", + "x-lago-organization" => organization.id + }, + params: { + query: + } + + expect(json[:data][:currentUser][:memberships]).to be_empty + end + end + end + + context "when token is near expiration" do + it "renews the token" do + post( + "/graphql", + headers: { + "Authorization" => "Bearer #{JWT.encode({sub: user.id, exp: 30.minutes.from_now.to_i}, ENV["SECRET_KEY_BASE"], "HS256")}" + }, + params: { + query: + } + ) + + expect(response.status).to be(200) + expect(response.headers["x-lago-token"]).to be_present + end + end + end + + context "with customer portal token" do + let(:customer) { create(:customer) } + let(:query) do + <<~GQL + query { + customerPortalInvoices(limit: 5) { + collection { id } + metadata { currentPage, totalCount } + } + } + GQL + end + let(:token) do + ActiveSupport::MessageVerifier.new(ENV["SECRET_KEY_BASE"]).generate(customer.id, expires_in: 12.hours) + end + + it "retrieves the correct end user and returns success status code" do + post( + "/graphql", + headers: { + "customer-portal-token" => token + }, + params: { + query: + } + ) + + expect(response.status).to be(200) + end + end + + context "with query length validation" do + let(:user) { create(:user) } + let(:token) do + Auth::TokenService.encode(user:) + end + + it "rejects queries that exceed maximum length" do + long_query = "query { " + "a" * (GraphqlController::MAX_QUERY_LENGTH + 1) + " }" + + post "/graphql", + headers: { + "Authorization" => "Bearer #{token}" + }, + params: { + query: long_query + } + + expect(response.status).to be(200) + + json = JSON.parse(response.body) + expect(json["errors"]).to be_present + expect(json["errors"].first["message"]).to include("Max query length is 15000") + expect(json["errors"].first["extensions"]["code"]).to eq("query_is_too_large") + expect(json["errors"].first["extensions"]["status"]).to eq(413) + end + + it "accepts queries within maximum length" do + query = <<~GRAPHQL + query { + currentUser { + id + premium + memberships { + status + organization { + id + } + } + } + } + GRAPHQL + + post "/graphql", + headers: { + "Authorization" => "Bearer #{token}" + }, + params: { + query: + } + + expect(response.status).to be(200) + + expect(json["errors"]).not_to be_present + end + end + end +end diff --git a/spec/requests/webhooks_controller_spec.rb b/spec/requests/webhooks_controller_spec.rb new file mode 100644 index 0000000..2528c00 --- /dev/null +++ b/spec/requests/webhooks_controller_spec.rb @@ -0,0 +1,442 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WebhooksController do + describe "POST /stripe" do + let(:organization_id) { Faker::Internet.uuid } + let(:code) { "stripe_1" } + let(:signature) { "signature" } + let(:event_type) { "payment_intent.succeeded" } + + let(:event) do + JSON.parse(get_stripe_fixtures("webhooks/payment_intent_succeeded.json")) + end + + let(:payload) { event.merge(code:) } + let(:result) { BaseService::Result.new } + + before do + allow(InboundWebhooks::CreateService) + .to receive(:call) + .with( + organization_id:, + webhook_source: :stripe, + code:, + payload: payload.to_json, + signature:, + event_type: + ) + .and_return(result) + end + + it "handle stripe webhooks" do + post( + "/webhooks/stripe/#{organization_id}", + params: payload.to_json, + headers: { + "HTTP_STRIPE_SIGNATURE" => signature, + "Content-Type" => "application/json" + } + ) + + expect(response).to have_http_status(:success) + expect(InboundWebhooks::CreateService).to have_received(:call) + end + + context "when InboundWebhooks::CreateService is not successful" do + before do + result.record_validation_failure!(record: build(:inbound_webhook)) + end + + it "returns a bad request" do + post( + "/webhooks/stripe/#{organization_id}", + params: payload.to_json, + headers: { + "HTTP_STRIPE_SIGNATURE" => signature, + "Content-Type" => "application/json" + } + ) + + expect(response).to have_http_status(:bad_request) + expect(InboundWebhooks::CreateService).to have_received(:call) + end + end + end + + describe "POST /gocardless" do + let(:organization) { create(:organization) } + + let(:gocardless_provider) do + create( + :gocardless_provider, + organization:, + webhook_secret: "secrets" + ) + end + + let(:gocardless_service) { instance_double(PaymentProviders::GocardlessService) } + + let(:events) do + path = Rails.root.join("spec/fixtures/gocardless/events.json") + JSON.parse(File.read(path)) + end + + let(:result) do + result = BaseService::Result.new + result.events = events["events"].map { |event| GoCardlessPro::Resources::Event.new(event) } + result + end + + before do + allow(PaymentProviders::Gocardless::HandleIncomingWebhookService).to receive(:call) + .with( + organization_id: organization.id, + code: nil, + body: events.to_json, + signature: "signature" + ) + .and_return(result) + end + + it "handle gocardless webhooks" do + post( + "/webhooks/gocardless/#{gocardless_provider.organization_id}", + params: events.to_json, + headers: { + "Webhook-Signature" => "signature", + "Content-Type" => "application/json" + } + ) + + expect(response).to have_http_status(:success) + + expect(PaymentProviders::Gocardless::HandleIncomingWebhookService).to have_received(:call) + end + + context "when failing to handle gocardless event" do + let(:result) do + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") + end + + it "returns a bad request" do + post( + "/webhooks/gocardless/#{gocardless_provider.organization_id}", + params: events.to_json, + headers: { + "Webhook-Signature" => "signature", + "Content-Type" => "application/json" + } + ) + + expect(response).to have_http_status(:bad_request) + + expect(PaymentProviders::Gocardless::HandleIncomingWebhookService).to have_received(:call) + end + end + end + + describe "POST /adyen" do + let(:organization) { create(:organization) } + + let(:adyen_provider) do + create(:adyen_provider, organization:) + end + + let(:body) do + path = Rails.root.join("spec/fixtures/adyen/webhook_authorisation_response.json") + JSON.parse(File.read(path)) + end + + let(:result) do + result = BaseService::Result.new + result.body = body + result + end + + before do + allow(PaymentProviders::Adyen::HandleIncomingWebhookService).to receive(:call) + .with( + organization_id: organization.id, + code: nil, + body: body["notificationItems"].first&.dig("NotificationRequestItem") + ) + .and_return(result) + end + + it "handle adyen webhooks" do + post( + "/webhooks/adyen/#{adyen_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json" + } + ) + + expect(response).to have_http_status(:success) + expect(PaymentProviders::Adyen::HandleIncomingWebhookService).to have_received(:call) + end + + context "when failing to handle adyen event" do + let(:result) do + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") + end + + it "returns a bad request" do + post( + "/webhooks/adyen/#{adyen_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json" + } + ) + + expect(response).to have_http_status(:bad_request) + expect(PaymentProviders::Adyen::HandleIncomingWebhookService).to have_received(:call) + end + end + end + + describe "POST /cashfree" do + let(:organization) { create(:organization) } + + let(:cashfree_provider) do + create(:cashfree_provider, organization:) + end + + let(:body) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment.json") + JSON.parse(File.read(path)) + end + + let(:result) do + result = BaseService::Result.new + result.body = body + result + end + + before do + allow(PaymentProviders::Cashfree::HandleIncomingWebhookService).to receive(:call) + .with( + organization_id: organization.id, + code: nil, + body: body.to_json, + timestamp: "1629271506", + signature: "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" + ) + .and_return(result) + end + + it "handle cashfree webhooks" do + post( + "/webhooks/cashfree/#{cashfree_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "X-Cashfree-Timestamp" => "1629271506", + "X-Cashfree-Signature" => "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" + } + ) + + expect(response).to have_http_status(:success) + + expect(PaymentProviders::Cashfree::HandleIncomingWebhookService).to have_received(:call) + end + + context "when failing to handle cashfree event" do + let(:result) do + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") + end + + it "returns a bad request" do + post( + "/webhooks/cashfree/#{cashfree_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "X-Cashfree-Timestamp" => "1629271506", + "X-Cashfree-Signature" => "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" + } + ) + + expect(response).to have_http_status(:bad_request) + + expect(PaymentProviders::Cashfree::HandleIncomingWebhookService).to have_received(:call) + end + end + end + + describe "POST /moneyhash" do + let(:organization_id) { Faker::Internet.uuid } + let(:code) { "moneyhash_1" } + + let(:body) do + path = Rails.root.join("spec/fixtures/moneyhash/intent.processed.json") + JSON.parse(File.read(path)) + end + + let(:result) { BaseService::Result.new } + + before do + allow(InboundWebhooks::CreateService) + .to receive(:call) + .with( + organization_id:, + webhook_source: :moneyhash, + code:, + payload: body, + signature: "t=1743090080,v1=placeholder,v2=placeholder,v3=placeholder", + event_type: body["type"] + ) + .and_return(result) + end + + it "handle moneyhash webhooks" do + post( + "/webhooks/moneyhash/#{organization_id}?code=#{code}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "Moneyhash-Signature" => "t=1743090080,v1=placeholder,v2=placeholder,v3=placeholder" + } + ) + + expect(response).to have_http_status(:success) + + expect(InboundWebhooks::CreateService).to have_received(:call) + end + + context "when InboundWebhooks::CreateService is not successful" do + before do + result.record_validation_failure!(record: build(:inbound_webhook)) + end + + it "returns a bad request" do + post( + "/webhooks/moneyhash/#{organization_id}?code=#{code}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "Moneyhash-Signature" => "t=1743090080,v1=placeholder,v2=placeholder,v3=placeholder" + } + ) + + expect(response).to have_http_status(:bad_request) + expect(InboundWebhooks::CreateService).to have_received(:call) + end + end + end + + describe "POST /flutterwave" do + let(:organization) { create(:organization) } + + let(:flutterwave_provider) do + create(:flutterwave_provider, organization:) + end + + let(:body) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + charged_amount: 10000, + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, + card: { + first_6digits: "123456", + last_4digits: "7889", + issuer: "VERVE FIRST CITY MONUMENT BANK PLC", + country: "NG", + type: "VERVE" + }, + meta: { + lago_invoice_id: "12345", + lago_payable_type: "Invoice" + } + } + } + end + + let(:result) do + result = BaseService::Result.new + result.body = body + result + end + + before do + allow(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to receive(:call) + .with( + organization_id: organization.id, + code: nil, + body: body.to_json, + secret: "test_signature" + ) + .and_return(result) + end + + it "handles flutterwave webhooks" do + post( + "/webhooks/flutterwave/#{flutterwave_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "verif-hash" => "test_signature" + } + ) + + expect(response).to have_http_status(:success) + + expect(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to have_received(:call) + end + + context "when failing to handle flutterwave event" do + let(:result) do + BaseService::Result.new.service_failure!(code: "webhook_error", message: "Invalid payload") + end + + it "returns bad request status" do + post( + "/webhooks/flutterwave/#{flutterwave_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "verif-hash" => "test_signature" + } + ) + + expect(response).to have_http_status(:bad_request) + expect(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to have_received(:call) + end + end + + context "when service raises an unexpected error" do + before do + allow(PaymentProviders::Flutterwave::HandleIncomingWebhookService).to receive(:call) + .and_raise(StandardError.new("Unexpected error")) + end + + it "raises the error" do + expect do + post( + "/webhooks/flutterwave/#{flutterwave_provider.organization_id}", + params: body.to_json, + headers: { + "Content-Type" => "application/json", + "verif-hash" => "test_signature" + } + ) + end.to raise_error(StandardError, "Unexpected error") + end + end + end +end diff --git a/spec/scenarios/billing/billing_monthly_spec.rb b/spec/scenarios/billing/billing_monthly_spec.rb new file mode 100644 index 0000000..0bb2c55 --- /dev/null +++ b/spec/scenarios/billing/billing_monthly_spec.rb @@ -0,0 +1,461 @@ +# frozen_string_literal: true + +require "rails_helper" +describe "Billing Monthly Scenarios with all charges types" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + let(:plan) do + create( + :plan, + organization:, + amount_cents: 5_000_000, + interval: "monthly", + pay_in_advance: false + ) + end + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + let(:billable_metric_recurring) do + create( + :billable_metric, + organization:, + name: "Recurring", + code: "recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + let(:charge_metered_not_prorated_in_arrears) do + create( + :package_charge, + plan:, + billable_metric: billable_metric_metered, + properties: {amount: "100", package_size: 10, free_units: 0}, + prorated: false, + pay_in_advance: false + ) + end + let(:charge_metered_not_prorated_in_advance) do + create( + :package_charge, + plan:, + billable_metric: billable_metric_metered, + properties: {amount: "1000", package_size: 10, free_units: 2}, + prorated: false, + pay_in_advance: true + ) + end + let(:charge_recurring_prorated_in_arrears) do + create( + :charge, + plan:, + billable_metric: billable_metric_recurring, + properties: {amount: "5000"}, + prorated: true, + pay_in_advance: false + ) + end + let(:charge_recurring_prorated_in_advance) do + create( + :charge, + plan:, + billable_metric: billable_metric_recurring, + properties: {amount: "50000"}, + prorated: false, + pay_in_advance: true + ) + end + let(:add_on) { create(:add_on) } + let(:fixed_charge_not_prorated_in_arrears) { create(:fixed_charge, plan:, add_on:, units: 10, properties: {amount: "200"}, prorated: false, pay_in_advance: false) } + let(:fixed_charge_not_prorated_in_advance) { create(:fixed_charge, plan:, add_on:, units: 10, properties: {amount: "200"}, prorated: false, pay_in_advance: true) } + let(:fixed_charge_prorated_in_arrears) { create(:fixed_charge, plan:, add_on:, units: 10, properties: {amount: "200"}, prorated: true, pay_in_advance: false) } + let(:fixed_charge_prorated_in_advance) { create(:fixed_charge, plan:, add_on:, units: 10, properties: {amount: "200"}, prorated: true, pay_in_advance: true) } + + before do + charge_metered_not_prorated_in_arrears + charge_metered_not_prorated_in_advance + charge_recurring_prorated_in_arrears + charge_recurring_prorated_in_advance + fixed_charge_not_prorated_in_arrears + fixed_charge_not_prorated_in_advance + fixed_charge_prorated_in_arrears + fixed_charge_prorated_in_advance + end + + context "with calendar billing" do + # let's also have here a spec for boundaries that we have on invoice_subscriptions + let(:billing_time) { "calendar" } + # february leap year! + let(:subscription_time) { DateTime.new(2024, 2, 4) } + + it "work the whole year", transaction: false do + subscription_date = DateTime.new(2024, 2, 4) + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + subscription = customer.subscriptions.first + travel_to subscription_date + 10.minutes do + perform_all_enqueued_jobs + end + # it immediately creates invoice with pay_in_advance fixed_charges + expect(subscription.reload.invoices.count).to eq(1) + pay_in_advance_fixed_charges_invoice = subscription.invoices.first + expect(pay_in_advance_fixed_charges_invoice.fees.count).to eq(2) + # check fixed_charge fees + expect(pay_in_advance_fixed_charges_invoice.fees.fixed_charge.count).to eq(2) + # fixed_charge_not_prorated_in_advance: 200 * 100 * 10, fixed_charge_prorated_in_advance: 2000 * 100 * 10 * 26/29 + expect(pay_in_advance_fixed_charges_invoice.fees.fixed_charge.map(&:amount_cents).sort).to match_array([179_310, 200_000]) + # check invoice_subscription boundaries + invoice_subscription = pay_in_advance_fixed_charges_invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + from_datetime: DateTime.parse("2024-02-04T00:00:00Z"), + to_datetime: DateTime.parse("2024-02-04T00:00:00Z"), + charges_from_datetime: DateTime.parse("2024-02-04T00:00:00Z"), + charges_to_datetime: DateTime.parse("2024-02-04T00:00:00Z"), + fixed_charges_from_datetime: DateTime.parse("2024-02-04T00:00:00Z"), + fixed_charges_to_datetime: DateTime.parse("2024-02-04T00:00:00Z"), + timestamp: DateTime.parse("2024-02-04T00:00:01Z") + ) + # check pay_in_advance fixed_charge_fees boundaries + pay_in_advance_fixed_charge_fee = pay_in_advance_fixed_charges_invoice.fees.fixed_charge.where(fixed_charge_id: [fixed_charge_not_prorated_in_advance.id, fixed_charge_prorated_in_advance.id]).sample + expect(pay_in_advance_fixed_charge_fee.properties).to include( + "charges_from_datetime" => nil, + "charges_to_datetime" => nil, + "charges_duration" => nil, + "fixed_charges_from_datetime" => "2024-02-04T00:00:00.000Z", + "fixed_charges_to_datetime" => "2024-02-29T23:59:59.999Z", + "fixed_charges_duration" => 29 + ) + # 28th of Feb - before billing, no usage sent for usage charges + time = DateTime.new(2024, 2, 28) + travel_to(time) do + perform_billing + end + # old invoice + expect(subscription.reload.invoices.count).to eq(1) + + time = DateTime.new(2024, 3, 1) + travel_to(time) do + perform_billing + end + expect(subscription.reload.invoices.count).to eq(2) + last_invoice = subscription.invoices.order(:created_at).last + + expect(last_invoice.fees.fixed_charge.count).to eq(4) + # we have in arrears prorated, in arrears prorated, 2 in advance (sp we're charging full amount) + expect(last_invoice.fees.fixed_charge.map(&:amount_cents).sort).to match_array([179_310, 200_000, 200_000, 200_000]) + expect(last_invoice.fees.charge.count).to eq(0) + expect(last_invoice.fees.subscription.count).to eq(1) + subscription_fee_amount = (26.0 / 29 * 5_000_000).ceil + fixed_charges_amount = 179_310 + 200_000 + 200_000 + 200_000 + expect(last_invoice.total_amount_cents).to eq(subscription_fee_amount + fixed_charges_amount) + + # check invoice_subscription boundaries + last_invoice_inv_sub = last_invoice.invoice_subscriptions.first + expect(last_invoice_inv_sub).to have_attributes( + from_datetime: match_datetime("2024-02-04T00:00:00Z"), + to_datetime: match_datetime("2024-02-29T23:59:59Z"), + charges_from_datetime: match_datetime("2024-02-04T00:00:00Z"), + charges_to_datetime: match_datetime("2024-02-29T23:59:59Z"), + fixed_charges_from_datetime: match_datetime("2024-02-04T00:00:00Z"), + fixed_charges_to_datetime: match_datetime("2024-02-29T23:59:59Z"), + timestamp: match_datetime("2024-03-01T00:00:00Z") + ) + + # check pay_in_advance fixed_charge_fees boundaries + pay_in_advance_fixed_charge_fees = last_invoice.fees.fixed_charge.where(fixed_charge_id: [fixed_charge_not_prorated_in_advance.id, fixed_charge_prorated_in_advance.id]).sample + expect(pay_in_advance_fixed_charge_fees.properties).to include( + "charges_from_datetime" => nil, + "charges_to_datetime" => nil, + "charges_duration" => nil, + "fixed_charges_from_datetime" => match_datetime("2024-03-01T00:00:00Z"), + "fixed_charges_to_datetime" => match_datetime("2024-03-31T23:59:59Z"), + "fixed_charges_duration" => 31 + ) + + # check pay_in_arrears fixed_charge_fees boundaries + pay_in_arrears_fixed_charge_fees = last_invoice.fees.fixed_charge.where(fixed_charge_id: [fixed_charge_not_prorated_in_arrears.id, fixed_charge_prorated_in_arrears.id]).sample + expect(pay_in_arrears_fixed_charge_fees.properties).to include( + "charges_from_datetime" => nil, + "charges_to_datetime" => nil, + "charges_duration" => nil, + "fixed_charges_from_datetime" => "2024-02-04T00:00:00.000Z", + "fixed_charges_to_datetime" => "2024-02-29T23:59:59.999Z", + "fixed_charges_duration" => 29 + ) + + # travel to the middle of month and create events per each charge: + events_date = DateTime.new(2024, 3, 15) + travel_to(events_date) do + [billable_metric_metered, billable_metric_recurring].each do |billable_metric| + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + perform_all_enqueued_jobs + end + # we should create invoices for received pay_in_advance charges: + expect(subscription.reload.invoices.count).to eq(4) + last_invoices = subscription.invoices.order(:created_at).last(2) + expected_invoices_data = [ + { + charge_count: 1, + charge_ids: [charge_metered_not_prorated_in_advance.id], + total_amount_cents: 100_000 + }, + { + charge_count: 1, + charge_ids: [charge_recurring_prorated_in_advance.id], + total_amount_cents: 50_000_000 + } + ] + + actual_invoices_data = last_invoices.map do |invoice| + { + charge_count: invoice.fees.charge.count, + charge_ids: invoice.fees.charge.pluck(:charge_id), + total_amount_cents: invoice.total_amount_cents + } + end + + expect(actual_invoices_data).to match_array(expected_invoices_data) + + billing_time = DateTime.new(2024, 4, 1) + travel_to(billing_time) do + EventsRecord.connection.commit_db_transaction + perform_billing + end + expect(subscription.reload.invoices.count).to eq(5) + last_invoice = subscription.invoices.order(:created_at).last + expect(last_invoice.fees.fixed_charge.count).to eq(4) + expect(last_invoice.fees.fixed_charge.map(&:amount_cents).sort).to match_array([200_000, 200_000, 200_000, 200_000]) + fixed_charge_fees_sum = 4 * 200_000 + # note that charge_recurring_prorated_in_advance should be included, because since it's recurring, it has usage, + # which we're charging in_advance + expect(last_invoice.fees.charge.count).to eq(3) + expect(last_invoice.fees.charge.map(&:charge_id)).to match_array([charge_metered_not_prorated_in_arrears.id, charge_recurring_prorated_in_arrears.id, charge_recurring_prorated_in_advance.id]) + + # Note: prorated_fee_amount should be 500000 * 10 * 17/31, + # prorated_fee_amount = 2_741_935 - this is math correct, but service returns 2_741_940 because of rounding (10 * 17 / 31). + prorated_fee_amount = 2_741_940 + expected_charge_fees = [ + {charge_id: charge_metered_not_prorated_in_arrears.id, amount_cents: 10_000}, + {charge_id: charge_recurring_prorated_in_arrears.id, amount_cents: prorated_fee_amount}, + {charge_id: charge_recurring_prorated_in_advance.id, amount_cents: 50_000_000} + ] + actual_charge_fees = last_invoice.fees.charge.map do |fee| + { + charge_id: fee.charge_id, + amount_cents: fee.amount_cents + } + end + expect(actual_charge_fees).to match_array(expected_charge_fees) + expect(last_invoice.fees.subscription.count).to eq(1) + expect(last_invoice.fees.subscription.map(&:amount_cents)).to match_array([5_000_000]) + expect(last_invoice.total_amount_cents).to eq(5_000_000 + 50_000_000 + 10_000 + prorated_fee_amount + fixed_charge_fees_sum) + + # check boundaries + invoice_subscription = last_invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + from_datetime: match_datetime("2024-03-01T00:00:00Z"), + to_datetime: match_datetime("2024-03-31T23:59:59Z"), + charges_from_datetime: match_datetime("2024-03-01T00:00:00Z"), + charges_to_datetime: match_datetime("2024-03-31T23:59:59Z"), + fixed_charges_from_datetime: match_datetime("2024-03-01T00:00:00Z"), + fixed_charges_to_datetime: match_datetime("2024-03-31T23:59:59Z"), + timestamp: match_datetime("2024-04-01T00:00:00Z") + ) + # check pay_in_advance fixed_charge_fees boundaries + pay_in_advance_fixed_charge_fees = last_invoice.fees.fixed_charge.where(fixed_charge_id: [fixed_charge_not_prorated_in_advance.id, fixed_charge_prorated_in_advance.id]).sample + expect(pay_in_advance_fixed_charge_fees.properties).to include( + "charges_from_datetime" => nil, + "charges_to_datetime" => nil, + "charges_duration" => nil, + "fixed_charges_from_datetime" => "2024-04-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2024-04-30T23:59:59.999Z", + "fixed_charges_duration" => 30 + ) + # check pay_in_arrears fixed_charge_fees boundaries + pay_in_arrears_fixed_charge_fees = last_invoice.fees.fixed_charge.where(fixed_charge_id: [fixed_charge_not_prorated_in_arrears.id, fixed_charge_prorated_in_arrears.id]).sample + expect(pay_in_arrears_fixed_charge_fees.properties).to include( + "charges_from_datetime" => nil, + "charges_to_datetime" => nil, + "charges_duration" => nil, + "fixed_charges_from_datetime" => match_datetime("2024-03-01T00:00:00Z"), + "fixed_charges_to_datetime" => match_datetime("2024-03-31T23:59:59Z"), + "fixed_charges_duration" => 31 + ) + + # check charge fees boundaries + charge_fees = last_invoice.fees.charge.where(charge_id: [charge_metered_not_prorated_in_arrears.id, charge_recurring_prorated_in_arrears.id, charge_recurring_prorated_in_advance.id]).sample + expect(charge_fees.properties).to include( + "charges_from_datetime" => "2024-03-01T00:00:00.000Z", + "charges_to_datetime" => "2024-03-31T23:59:59.999Z", + "charges_duration" => 31, + "fixed_charges_from_datetime" => nil, + "fixed_charges_to_datetime" => nil, + "fixed_charges_duration" => nil + ) + + # travel to several dates in the next month and send usages + [DateTime.new(2024, 4, 10), DateTime.new(2024, 4, 30)].each do |date| + travel_to(date) do + [billable_metric_recurring, billable_metric_metered].each do |billable_metric| + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 20 + } + } + ) + end + perform_all_enqueued_jobs + end + end + + # we should create invoices for received pay_in_advance charges: + expect(subscription.reload.invoices.count).to eq(9) + last_invoices = subscription.invoices.order(:created_at).last(4) + expected_invoices_data = [ + { + charge_count: 1, + charge_ids: [charge_metered_not_prorated_in_advance.id], + total_amount_cents: 200_000 + }, + { + charge_count: 1, + charge_ids: [charge_recurring_prorated_in_advance.id], + total_amount_cents: 100_000_000 + }, + { + charge_count: 1, + charge_ids: [charge_metered_not_prorated_in_advance.id], + total_amount_cents: 200_000 + }, + { + charge_count: 1, + charge_ids: [charge_recurring_prorated_in_advance.id], + total_amount_cents: 100_000_000 + } + ] + + actual_invoices_data = last_invoices.map do |invoice| + { + charge_count: invoice.fees.charge.count, + charge_ids: invoice.fees.charge.pluck(:charge_id), + total_amount_cents: invoice.total_amount_cents + } + end + + expect(actual_invoices_data).to match_array(expected_invoices_data) + + billing_time = DateTime.new(2024, 5, 1) + travel_to(billing_time) do + perform_billing + end + expect(subscription.reload.invoices.count).to eq(10) + last_invoice = subscription.invoices.order(:created_at).last + expect(last_invoice.fees.fixed_charge.count).to eq(4) + expect(last_invoice.fees.fixed_charge.map(&:amount_cents).sort).to match_array([200_000, 200_000, 200_000, 200_000]) + fixed_charge_fees_sum = 4 * 200_000 + # note that charge_recurring_prorated_in_advance should be included, because since it's recurring, it has usage, + # which we're charging in_advance + expect(last_invoice.fees.charge.count).to eq(3) + expect(last_invoice.fees.charge.map(&:charge_id)).to match_array([charge_metered_not_prorated_in_arrears.id, charge_recurring_prorated_in_arrears.id, charge_recurring_prorated_in_advance.id]) + + # check amounts by charges + # prorated amount is: current usage: 500000 * 20 * 21/30 + 500000 * 20 * 1/30 + persisted usage: 500000 * 10 + prorated_fee_amount = 7_333_335 + 5000000 # 12_333_333 + + expected_charge_fees = [ + {charge_id: charge_metered_not_prorated_in_arrears.id, amount_cents: 40_000}, + {charge_id: charge_recurring_prorated_in_arrears.id, amount_cents: prorated_fee_amount}, + # 200_000_000 new usage + 50_000_000 accumulatedfrom previous month + {charge_id: charge_recurring_prorated_in_advance.id, amount_cents: 250_000_000} + ] + + actual_charge_fees = last_invoice.fees.charge.map do |fee| + { + charge_id: fee.charge_id, + amount_cents: fee.amount_cents + } + end + + expect(actual_charge_fees).to match_array(expected_charge_fees) + + expect(last_invoice.fees.subscription.count).to eq(1) + expect(last_invoice.fees.subscription.map(&:amount_cents)).to match_array([5_000_000]) + expect(last_invoice.total_amount_cents).to eq(5_000_000 + 250_000_000 + 40_000 + prorated_fee_amount + fixed_charge_fees_sum) + + # month without any events + billing_time = DateTime.new(2024, 6, 1) + travel_to(billing_time) do + perform_billing + end + expect(subscription.reload.invoices.count).to eq(11) + last_invoice = subscription.invoices.order(:created_at).last + expect(last_invoice.fees.fixed_charge.count).to eq(4) + expect(last_invoice.fees.fixed_charge.map(&:amount_cents).sort).to match_array([200_000, 200_000, 200_000, 200_000]) + fixed_charge_fees_sum = 4 * 200_000 + # note that charge_recurring_prorated_in_advance should be included, because since it's recurring, it has usage, + # which we're charging in_advance + expect(last_invoice.fees.charge.count).to eq(2) + expect(last_invoice.fees.charge.map(&:charge_id)).to match_array([charge_recurring_prorated_in_arrears.id, charge_recurring_prorated_in_advance.id]) + + # check amounts by charges + expected_charge_fees = [ + { + charge_id: charge_recurring_prorated_in_arrears.id, + # 50_000 * (10 + 20 + 20) = 25_000_000 + amount_cents: 25_000_000 + }, + { + charge_id: charge_recurring_prorated_in_advance.id, + # 200_000_000 new usage + 50_000_000 accumulated from previous month + amount_cents: 250_000_000 + } + ] + + actual_charge_fees = last_invoice.fees.charge.map do |fee| + { + charge_id: fee.charge_id, + amount_cents: fee.amount_cents + } + end + + expect(actual_charge_fees).to match_array(expected_charge_fees) + + expect(last_invoice.fees.subscription.count).to eq(1) + expect(last_invoice.fees.subscription.map(&:amount_cents)).to match_array([5_000_000]) + expect(last_invoice.total_amount_cents).to eq(5_000_000 + 250_000_000 + 25_000_000 + fixed_charge_fees_sum) + end + end +end diff --git a/spec/scenarios/charge_models/edit_with_filter_spec.rb b/spec/scenarios/charge_models/edit_with_filter_spec.rb new file mode 100644 index 0000000..9fc7aaa --- /dev/null +++ b/spec/scenarios/charge_models/edit_with_filter_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Change charge model with filters" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + let(:charge) { create(:standard_charge, billable_metric:, plan:) } + + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[aws gcp azure]) + end + let(:charge_filter) { create(:charge_filter, charge:, properties: {amount: "100"}) } + let(:charge_filter_value) { create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["aws"]) } + + before do + charge_filter_value + end + + it "allows the edition of the charge filter" do + update_plan( + plan, + {amount_cents: plan.amount_cents, + name: plan.name, + invoice_display_name: plan.invoice_display_name, + description: plan.description, + charges: [ + { + billable_metric_id: billable_metric.id, + id: charge.id, + invoice_display_name: charge.invoice_display_name, + charge_model: "graduated", + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "10", flat_amount: "0"}, + {from_value: 101, to_value: nil, per_unit_amount: "20", flat_amount: "0"} + ] + }, + filters: [ + { + invoice_display_name: charge_filter.invoice_display_name, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "12", flat_amount: "0"}, + {from_value: 101, to_value: nil, per_unit_amount: "22", flat_amount: "0"} + ] + }, + values: { + cloud: ["aws"] + } + } + ] + } + ]} + ) + + plan.reload + expect(plan.charges.first.charge_model).to eq("graduated") + expect(plan.charges.first.filters.count).to eq(1) + expect(plan.charges.first.filters.first.properties["graduated_ranges"][0]).to include( + "from_value" => 0, "to_value" => 100, "per_unit_amount" => "12", "flat_amount" => "0" + ) + expect(plan.charges.first.filters.first.properties["graduated_ranges"][1]).to include( + "from_value" => 101, "to_value" => nil, "per_unit_amount" => "22", "flat_amount" => "0" + ) + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/anniversary/monthly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/anniversary/monthly_spec.rb new file mode 100644 index 0000000..6de1540 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/anniversary/monthly_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "monthly" } + let(:subscription_time) { DateTime.new(2024, 2, 28, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "15"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "25"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 1.month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(983_000) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to(subscription_time + 1.month) do + perform_billing + end + + travel_to(subscription_time + 2.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 1.month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(983_000) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to(subscription_time + 1.month) do + perform_billing + end + + travel_to(subscription_time + 2.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/anniversary/quarterly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/anniversary/quarterly_spec.rb new file mode 100644 index 0000000..f7f40e1 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/anniversary/quarterly_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "quarterly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "15"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "25"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 3.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 3.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(983_000) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to(subscription_time + 3.months) do + perform_billing + end + + travel_to(subscription_time + 6.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 6.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 3.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 3.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(983_000) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to(subscription_time + 3.months) do + perform_billing + end + + travel_to(subscription_time + 6.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 6.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/anniversary/weekly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/anniversary/weekly_spec.rb new file mode 100644 index 0000000..3e12f1d --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/anniversary/weekly_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "weekly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "15"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "25"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 1.week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(983_000) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to(subscription_time + 1.week) do + perform_billing + end + + travel_to(subscription_time + 2.weeks) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.weeks) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 1.week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(983_000) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to(subscription_time + 1.week) do + perform_billing + end + + travel_to(subscription_time + 2.weeks) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.weeks) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/anniversary/yearly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/anniversary/yearly_spec.rb new file mode 100644 index 0000000..58747ba --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/anniversary/yearly_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true, + bill_charges_monthly: + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_advance_metered) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "advance_metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "recurring_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "yearly" } + let(:subscription_time) { DateTime.new(2024, 3, 5, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_advance_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + travel_to(subscription_time + 1.hour) do + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "2"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when billed monthly" do + let(:bill_charges_monthly) { true } + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time + 1.minute) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the last period" do + before do + (1..12).each do |i| + travel_to(subscription_time + i.month) do + if i == 5 + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "55"} + } + ) + end + + if i == 11 + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + end + + perform_billing + end + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.year) do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.sum(:amount_cents)).to eq(981_100) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/calendar/monthly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/calendar/monthly_spec.rb new file mode 100644 index 0000000..82a1149 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/calendar/monthly_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "monthly" } + let(:subscription_time) { DateTime.new(2024, 2, 28, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "15"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "25"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 1.month).beginning_of_month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.month).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(61_276) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to((subscription_time + 1.month).beginning_of_month) do + perform_billing + end + + travel_to((subscription_time + 2.months).beginning_of_month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.months).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 1.month).beginning_of_month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.month).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(61_276) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to((subscription_time + 1.month).beginning_of_month) do + perform_billing + end + + travel_to((subscription_time + 2.months).beginning_of_month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.months).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/calendar/quarterly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/calendar/quarterly_spec.rb new file mode 100644 index 0000000..5d13a2a --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/calendar/quarterly_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "quarterly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "15"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "25"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 3.months).beginning_of_quarter) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 3.months).beginning_of_quarter) do + expect(invoice.fees.commitment.first.amount_cents).to eq(210_582) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to((subscription_time + 3.months).beginning_of_quarter) do + perform_billing + end + + travel_to((subscription_time + 6.months).beginning_of_quarter) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 6.months).beginning_of_quarter) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 3.months).beginning_of_quarter) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 3.months).beginning_of_quarter) do + expect(invoice.fees.commitment.first.amount_cents).to eq(210_582) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to((subscription_time + 3.months).beginning_of_quarter) do + perform_billing + end + + travel_to((subscription_time + 6.months).beginning_of_quarter) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 6.months).beginning_of_quarter) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/calendar/weekly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/calendar/weekly_spec.rb new file mode 100644 index 0000000..5fee6bd --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/calendar/weekly_spec.rb @@ -0,0 +1,239 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "weekly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "15"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "25"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 1.week).beginning_of_week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.week).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(841_572) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to((subscription_time + 1.week).beginning_of_week) do + perform_billing + end + + travel_to((subscription_time + 2.weeks).beginning_of_week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.weeks).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 1.week).beginning_of_week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.week).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(841_572) + end + end + end + + context "when subscription is billed for the third period" do + before do + travel_to((subscription_time + 1.week).beginning_of_week) do + perform_billing + end + + travel_to((subscription_time + 2.weeks).beginning_of_week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.weeks).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(988_500) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance/calendar/yearly_spec.rb b/spec/scenarios/commitments/minimum/in_advance/calendar/yearly_spec.rb new file mode 100644 index 0000000..9db4df4 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance/calendar/yearly_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Advance", + code: "in_advance", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: true, + bill_charges_monthly: + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_advance_metered) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "advance_metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "recurring_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "yearly" } + let(:subscription_time) { DateTime.new(2024, 3, 5, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_advance_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + travel_to(subscription_time + 1.hour) do + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "2"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + + perform_billing + end + end + + context "when billed monthly" do + let(:bill_charges_monthly) { true } + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time + 1.minute) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the last period" do + before do + travel_to(subscription_time + 5.months) do + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "55"} + } + ) + end + + travel_to(subscription_time + 11.months) do + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + end + + # { DateTime.new(2025, 3, 01) } + travel_to((subscription_time + 12.months).beginning_of_month) do + perform_billing + end + + # { DateTime.new(2025, 01, 01) } + travel_to((subscription_time + 1.year).beginning_of_year) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + # { DateTime.new(2025, 01, 01) } + travel_to((subscription_time + 1.year).beginning_of_year) do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.sum(:amount_cents)).to eq(808_186) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_advance_spec.rb b/spec/scenarios/commitments/minimum/in_advance_spec.rb new file mode 100644 index 0000000..8ae380f --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_advance_spec.rb @@ -0,0 +1,754 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Advance Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:, currency: "EUR") } + + let(:plan) do + create( + :plan, + organization:, + amount_cents: 100_000, + amount_currency: "EUR", + interval: plan_interval, + pay_in_advance: true, + bill_charges_monthly: + ) + end + + let(:bill_charges_monthly) { false } + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + before do + minimum_commitment + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + perform_billing + end + end + + context "when plan is billed in advance" do + context "with weekly plan" do + let(:plan_interval) { "weekly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + let(:commitment_fee_amount_cents) { 642_857 } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 2, 6, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + + context "when subscription is terminated" do + let(:invoices) { Invoice.order(:sequential_id) } + let(:commitment_fees) { Fee.commitment.pluck(:amount_cents) } + + before do + travel_to(DateTime.new(2023, 2, 13, 10)) + perform_billing + + travel_to(DateTime.new(2023, 2, 15, 10)) + terminate_subscription(subscription) + perform_all_enqueued_jobs + end + + it "creates an invoice with minimum commitment fee" do + expect(invoices.first.fees.commitment.count).to eq(0) + expect(invoices.second.fees.commitment.count).to eq(1) + expect(invoices.third.fees.commitment.count).to eq(1) + expect(invoices.fourth.fees.commitment.count).to eq(1) + + expect(commitment_fees).to contain_exactly(328_571, 642_857, 900_000) + end + end + end + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + let(:commitment_fee_amount_cents) { 900_000 } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 2, 8, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + end + + context "with monthly plan" do + let(:plan_interval) { "monthly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:commitment_fee_amount_cents) { 803_571 } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 3, 1, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:commitment_fee_amount_cents) { 900_000 } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 3, 4, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + end + + context "with quarterly plan" do + let(:plan_interval) { "quarterly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:commitment_fee_amount_cents) { 560_000 } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 4, 1, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:commitment_fee_amount_cents) { 900_000 } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 5, 4, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + end + + context "with yearly plan and yearly charge" do + let(:plan_interval) { "yearly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2022, 2, 1) } + let(:commitment_fee_amount_cents) { 823_561 } + + context "when plan is charged yearly" do + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 1, 1, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + + context "when plan is charged monthly" do + let(:bill_charges_monthly) { true } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 1, 1, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2022, 2, 4) } + let(:commitment_fee_amount_cents) { 900_000 } + + context "when plan is charged yearly" do + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 2, 4, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + + context "when plan is charged monthly" do + let(:bill_charges_monthly) { true } + + context "when there is no previous period" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when there is a previous period" do + let(:current_period_start) { DateTime.new(2023, 2, 4, 10) } + + before do + travel_to(current_period_start) + + perform_billing + end + + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/anniversary/monthly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/anniversary/monthly_spec.rb new file mode 100644 index 0000000..afbb3e2 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/anniversary/monthly_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "monthly" } + let(:subscription_time) { DateTime.new(2024, 2, 28, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + end + + travel_to(subscription_time + 1.month) do + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(987_000) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 2.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(987_000) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 2.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/anniversary/quarterly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/anniversary/quarterly_spec.rb new file mode 100644 index 0000000..78b57f4 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/anniversary/quarterly_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "quarterly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + end + + travel_to(subscription_time + 3.months) do + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 3.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(987_000) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 6.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 6.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 3.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(987_000) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 6.months) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 6.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/anniversary/weekly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/anniversary/weekly_spec.rb new file mode 100644 index 0000000..3cf9647 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/anniversary/weekly_spec.rb @@ -0,0 +1,205 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "weekly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + end + + travel_to(subscription_time + 1.week) do + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(987_000) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 2.weeks) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.weeks) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + end + + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(987_000) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to(subscription_time + 2.weeks) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 2.weeks) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/anniversary/yearly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/anniversary/yearly_spec.rb new file mode 100644 index 0000000..4f5684b --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/anniversary/yearly_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false, + bill_charges_monthly: + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_advance_metered) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "advance_metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "recurring_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "anniversary" } + let(:plan_interval) { "yearly" } + let(:subscription_time) { DateTime.new(2024, 3, 1, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_advance_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + travel_to(subscription_time + 1.hour) do + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "2"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + end + end + + context "when billed monthly" do + let(:bill_charges_monthly) { true } + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time + 1.month + 1.day) do + perform_billing + + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the last period" do + before do + (1..12).each do |i| + travel_to(subscription_time + i.month) do + if i == 5 + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "55"} + } + ) + end + + if i == 11 + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + end + + perform_billing + end + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 1.year + 1.month) do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.sum(:amount_cents)).to eq(981_100) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/calendar/monthly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/calendar/monthly_spec.rb new file mode 100644 index 0000000..70a0788 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/calendar/monthly_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "monthly" } + let(:subscription_time) { DateTime.new(2024, 2, 28, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + end + + travel_to((subscription_time + 1.month).beginning_of_month) do + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.month).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(65_276) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 2.months).beginning_of_month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.months).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + + travel_to((subscription_time + 1.month).beginning_of_month) do + perform_billing + end + end + + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.month).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(65_276) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 2.months).beginning_of_month) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.months).beginning_of_month) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/calendar/quarterly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/calendar/quarterly_spec.rb new file mode 100644 index 0000000..f50a3af --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/calendar/quarterly_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "quarterly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + end + + travel_to((subscription_time + 3.months).beginning_of_quarter) do + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 3.months).beginning_of_quarter) do + expect(invoice.fees.commitment.first.amount_cents).to eq(214_582) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 6.months).beginning_of_quarter) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 6.months).beginning_of_quarter) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + + travel_to((subscription_time + 3.months).beginning_of_quarter) do + perform_billing + end + end + + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to(subscription_time + 3.months) do + expect(invoice.fees.commitment.first.amount_cents).to eq(214_582) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 6.months).beginning_of_quarter) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 6.months).beginning_of_quarter) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/calendar/weekly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/calendar/weekly_spec.rb new file mode 100644 index 0000000..dce6df2 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/calendar/weekly_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered_advance) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "metered_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "advance_recurring", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "weekly" } + let(:subscription_time) { DateTime.new(2024, 3, 12, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_metered_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + + create_event( + { + code: billable_metric_recurring_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + + create_event( + { + code: billable_metric_metered_advance.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "10"} + } + ) + end + + travel_to((subscription_time + 1.week).beginning_of_week) do + perform_billing + end + end + + context "when coupons are not applied" do + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.week).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(845_572) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 2.weeks).beginning_of_week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.weeks).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end + + context "when coupon is applied" do + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 1_000_000, + frequency: :forever + ) + end + + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + before do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 1_000_000} + ) + + travel_to((subscription_time + 1.week).beginning_of_week) do + perform_billing + end + end + + context "when subscription is billed for the first period" do + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.week).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(845_572) + end + end + end + + context "when subscription is billed for the second period" do + before do + travel_to((subscription_time + 2.weeks).beginning_of_week) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 2.weeks).beginning_of_week) do + expect(invoice.fees.commitment.first.amount_cents).to eq(989_000) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears/calendar/yearly_spec.rb b/spec/scenarios/commitments/minimum/in_arrears/calendar/yearly_spec.rb new file mode 100644 index 0000000..9427259 --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears/calendar/yearly_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + name: "In Arrears", + code: "in_arrears", + organization:, + amount_cents: 10_000, + interval: plan_interval, + pay_in_advance: false, + bill_charges_monthly: + ) + end + + let(:invoice) { subscription.reload.invoices.order(sequential_id: :desc).first } + let(:subscription) { customer.subscriptions.first.reload } + + let(:billable_metric_advance_metered) do + create( + :billable_metric, + organization:, + name: "Metered in advance", + code: "advance_metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_metered) do + create( + :billable_metric, + organization:, + name: "Metered in arrears", + code: "metered", + aggregation_type: "sum_agg", + field_name: "total", + recurring: false + ) + end + + let(:billable_metric_recurring_advance) do + create( + :billable_metric, + organization:, + name: "In advance recurring", + code: "recurring_advance", + aggregation_type: "sum_agg", + field_name: "total", + recurring: true + ) + end + + let(:billing_time) { "calendar" } + let(:plan_interval) { "yearly" } + let(:subscription_time) { DateTime.new(2024, 3, 5, 10) } + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + before do + minimum_commitment + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_advance_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + billable_metric: billable_metric_metered, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + create( + :standard_charge, + :pay_in_advance, + billable_metric: billable_metric_recurring_advance, + invoiceable: true, + plan:, + properties: {amount: "1"} + ) + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + travel_to(subscription_time + 1.hour) do + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "2"} + } + ) + + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "30"} + } + ) + end + + travel_to((subscription_time + 1.month).beginning_of_month) do + perform_billing + end + end + + context "when billed monthly" do + let(:bill_charges_monthly) { true } + + context "when subscription is billed for the first period" do + it "creates an invoice with no minimum commitment fee" do + travel_to(subscription_time + 1.minute) do + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is billed for the last period" do + before do + travel_to(subscription_time + 5.months) do + create_event( + { + code: billable_metric_advance_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "55"} + } + ) + end + + travel_to(subscription_time + 11.months) do + create_event( + { + code: billable_metric_metered.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {total: "1"} + } + ) + end + + travel_to((subscription_time + 1.year).beginning_of_year) do + perform_billing + end + end + + it "creates an invoice with minimum commitment fee" do + travel_to((subscription_time + 1.year).beginning_of_year) do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.sum(:amount_cents)).to eq(808_086) + end + end + end + end +end diff --git a/spec/scenarios/commitments/minimum/in_arrears_spec.rb b/spec/scenarios/commitments/minimum/in_arrears_spec.rb new file mode 100644 index 0000000..4654c4a --- /dev/null +++ b/spec/scenarios/commitments/minimum/in_arrears_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Minimum Commitments In Arrears Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:, currency: "EUR") } + + let(:plan) do + create( + :plan, + organization:, + amount_cents: 100_000, + amount_currency: "EUR", + interval: plan_interval, + pay_in_advance: false, + bill_charges_monthly: + ) + end + + let(:bill_charges_monthly) { false } + let(:invoice) { subscription.reload.invoices.first } + let(:subscription) { customer.subscriptions.first.reload } + + before do + minimum_commitment + + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + billing_times.each do |time| + travel_to(time) do + perform_billing + end + end + end + + shared_examples "a subscription billing" do + context "when plan has no minimum commitment" do + let(:minimum_commitment) { nil } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1) } + + it "creates an invoice without minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when minimum commitment amount is not reached" do + let(:minimum_commitment) { create(:commitment, :minimum_commitment, plan:, amount_cents: 1_000_000) } + + it "creates an invoice with minimum commitment fee" do + expect(invoice.fees.commitment.count).to eq(1) + expect(invoice.fees.commitment.first.amount_cents).to eq(commitment_fee_amount_cents) + end + end + end + + context "when plan is billed in arrears" do + context "with weekly plan" do + let(:plan_interval) { "weekly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + let(:billing_times) { [DateTime.new(2023, 2, 6, 1), DateTime.new(2023, 2, 6, 2)] } + let(:commitment_fee_amount_cents) { 642_857 } + + it_behaves_like "a subscription billing" + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + let(:billing_times) { [DateTime.new(2023, 2, 15, 1), DateTime.new(2023, 2, 15, 2)] } + let(:commitment_fee_amount_cents) { 900_000 } + + it_behaves_like "a subscription billing" + end + end + + context "with monthly plan" do + let(:plan_interval) { "monthly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:billing_times) { [DateTime.new(2023, 3, 1, 1), DateTime.new(2023, 3, 1, 2)] } + let(:commitment_fee_amount_cents) { 803_571 } + + it_behaves_like "a subscription billing" + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:billing_times) { [DateTime.new(2023, 3, 4, 1), DateTime.new(2023, 3, 4, 2)] } + let(:commitment_fee_amount_cents) { 900_000 } + + it_behaves_like "a subscription billing" + end + end + + context "with quarterly plan" do + let(:plan_interval) { "quarterly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:billing_times) { [DateTime.new(2023, 4, 1, 1), DateTime.new(2023, 4, 1, 2)] } + let(:commitment_fee_amount_cents) { 560_000 } + + it_behaves_like "a subscription billing" + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + let(:billing_times) { [DateTime.new(2023, 5, 4, 1), DateTime.new(2023, 5, 4, 2)] } + let(:commitment_fee_amount_cents) { 900_000 } + + it_behaves_like "a subscription billing" + end + end + + context "with yearly plan and yearly charge" do + let(:plan_interval) { "yearly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2022, 2, 1) } + let(:billing_times) { [DateTime.new(2023, 1, 1, 1), DateTime.new(2023, 1, 1, 2)] } + let(:commitment_fee_amount_cents) { 823_561 } + + it_behaves_like "a subscription billing" + + context "when plan is charged monthly" do + let(:bill_charges_monthly) { false } + + it_behaves_like "a subscription billing" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2022, 2, 4) } + let(:billing_times) { [DateTime.new(2023, 2, 4, 1), DateTime.new(2023, 2, 4, 2)] } + let(:commitment_fee_amount_cents) { 900_000 } + + context "when plan is charged yearly" do + it_behaves_like "a subscription billing" + end + + context "when plan is charged monthly" do + let(:bill_charges_monthly) { false } + + it_behaves_like "a subscription billing" + end + end + end + end +end diff --git a/spec/scenarios/coupons_breakdown_spec.rb b/spec/scenarios/coupons_breakdown_spec.rb new file mode 100644 index 0000000..a00f492 --- /dev/null +++ b/spec/scenarios/coupons_breakdown_spec.rb @@ -0,0 +1,328 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Coupons breakdown Spec", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + + before do + organization + stub_pdf_generation + end + + context "when there are multiple subscriptions and coupons of different kinds" do + it "creates an invoice for the expected period" do + create_metric({name: "Name", code: "bm1", aggregation_type: "sum_agg", field_name: "total1"}) + bm1 = organization.billable_metrics.find_by(code: "bm1") + + create_metric({name: "Name", code: "bm2", aggregation_type: "sum_agg", field_name: "total2"}) + bm2 = organization.billable_metrics.find_by(code: "bm2") + + create_metric({name: "Name", code: "bm3", aggregation_type: "sum_agg", field_name: "total3"}) + bm3 = organization.billable_metrics.find_by(code: "bm3") + + create_metric({name: "Name", code: "bm4", aggregation_type: "sum_agg", field_name: "total4"}) + bm4 = organization.billable_metrics.find_by(code: "bm4") + + create_metric({name: "Name", code: "bm5", aggregation_type: "sum_agg", field_name: "total5"}) + bm5 = organization.billable_metrics.find_by(code: "bm5") + + create_metric({name: "Name", code: "bm6", aggregation_type: "sum_agg", field_name: "total6"}) + bm6 = organization.billable_metrics.find_by(code: "bm6") + + create_metric({name: "Name", code: "bm7", aggregation_type: "sum_agg", field_name: "total7"}) + bm7 = organization.billable_metrics.find_by(code: "bm7") + + create_metric({name: "Name", code: "bm8", aggregation_type: "sum_agg", field_name: "total8"}) + bm8 = organization.billable_metrics.find_by(code: "bm8") + + travel_to(DateTime.new(2023, 1, 1)) do + create_tax({name: "Banking rates 1", code: "banking_rates1", rate: 10.0}) + create_tax({name: "Banking rates 2", code: "banking_rates2", rate: 20.0}) + + create_or_update_customer({external_id: "customer-12345"}) + + create_plan( + { + name: "P1", + code: "plan_code", + interval: "monthly", + amount_cents: 0, + amount_currency: "EUR", + pay_in_advance: false, + charges: [ + { + billable_metric_id: bm1.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates1").code] + }, + { + billable_metric_id: bm2.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates2").code] + }, + { + billable_metric_id: bm3.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates1").code] + }, + { + billable_metric_id: bm4.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates1").code] + } + ] + } + ) + plan = organization.plans.find_by(code: "plan_code") + + create_subscription( + { + external_customer_id: "customer-12345", + external_id: "sub_external_id", + plan_code: plan.code + } + ) + + create_plan( + { + name: "P2", + code: "plan_code2", + interval: "monthly", + amount_cents: 0, + amount_currency: "EUR", + pay_in_advance: false, + charges: [ + { + billable_metric_id: bm5.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates1").code] + }, + { + billable_metric_id: bm6.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates2").code] + }, + { + billable_metric_id: bm7.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates1").code] + }, + { + billable_metric_id: bm8.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "banking_rates1").code] + } + ] + } + ) + plan2 = organization.plans.find_by(code: "plan_code2") + + create_subscription( + { + external_customer_id: "customer-12345", + external_id: "sub_external_id2", + plan_code: plan2.code + } + ) + + create_coupon( + { + name: "coupon1", + code: "coupon1_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 2_000, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 50.days, + reusable: false, + applies_to: { + billable_metric_codes: [bm1.code, bm2.code] + } + } + ) + apply_coupon({external_customer_id: "customer-12345", coupon_code: "coupon1_code"}) + + create_coupon( + { + name: "coupon2", + code: "coupon2_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 1_000, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 50.days, + reusable: false, + applies_to: { + plan_codes: [plan2.code] + } + } + ) + apply_coupon({external_customer_id: "customer-12345", coupon_code: "coupon2_code"}) + + create_coupon( + { + name: "coupon3", + code: "coupon3_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 500, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 50.days, + reusable: false + } + ) + apply_coupon({external_customer_id: "customer-12345", coupon_code: "coupon3_code"}) + + # First subscription events + create_event( + { + code: bm1.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id", + properties: {total1: 10} + } + ) + create_event( + { + code: bm2.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id", + properties: {total2: 20} + } + ) + create_event( + { + code: bm3.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id", + properties: {total3: 30} + } + ) + create_event( + { + code: bm4.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id", + properties: {total4: 40} + } + ) + + # Second subscription events + create_event( + { + code: bm5.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id2", + properties: {total5: 10} + } + ) + create_event( + { + code: bm6.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id2", + properties: {total6: 20} + } + ) + create_event( + { + code: bm7.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id2", + properties: {total7: 30} + } + ) + create_event( + { + code: bm8.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id2", + properties: {total8: 40} + } + ) + end + + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + customer = organization.customers.find_by(external_id: "customer-12345") + invoice = customer.invoices.first + fees = invoice.fees + subscription1 = Subscription.find_by(external_id: "sub_external_id") + subscription2 = Subscription.find_by(external_id: "sub_external_id2") + sub1_fees = fees.charge.where(subscription: subscription1).joins(:charge) + sub2_fees = fees.charge.where(subscription: subscription2).joins(:charge) + + # Subscription 1 fees + expect(sub1_fees.where(charge: {billable_metric_id: bm1.id}).first).to have_attributes( + amount_cents: 1_000, + taxes_amount_cents: 32, + taxes_rate: 10.0, + precise_coupons_amount_cents: 676.47059 + ) + expect(sub1_fees.where(charge: {billable_metric_id: bm2.id}).first).to have_attributes( + amount_cents: 2_000, + taxes_amount_cents: 129, + taxes_rate: 20.0, + precise_coupons_amount_cents: 1352.94117 + ) + expect(sub1_fees.where(charge: {billable_metric_id: bm3.id}).first).to have_attributes( + amount_cents: 3_000, + taxes_amount_cents: 291, + taxes_rate: 10.0, + precise_coupons_amount_cents: 88.23529 + ) + expect(sub1_fees.where(charge: {billable_metric_id: bm4.id}).first).to have_attributes( + amount_cents: 4_000, + taxes_amount_cents: 388, + taxes_rate: 10.0, + precise_coupons_amount_cents: 117.64706 + ) + + # Subscription 2 fees + expect(sub2_fees.where(charge: {billable_metric_id: bm5.id}).first).to have_attributes( + amount_cents: 1_000, + taxes_amount_cents: 87, + taxes_rate: 10.0, + precise_coupons_amount_cents: 126.47059 + ) + expect(sub2_fees.where(charge: {billable_metric_id: bm6.id}).first).to have_attributes( + amount_cents: 2_000, + taxes_amount_cents: 349, + taxes_rate: 20.0, + precise_coupons_amount_cents: 252.94118 + ) + expect(sub2_fees.where(charge: {billable_metric_id: bm7.id}).first).to have_attributes( + amount_cents: 3_000, + taxes_amount_cents: 262, + taxes_rate: 10.0, + precise_coupons_amount_cents: 379.41176 + ) + expect(sub2_fees.where(charge: {billable_metric_id: bm8.id}).first).to have_attributes( + amount_cents: 4_000, + taxes_amount_cents: 349, + taxes_rate: 10.0, + precise_coupons_amount_cents: 505.88235 + ) + + expect(invoice.fees_amount_cents).to eq(20_000) + expect(invoice.coupons_amount_cents).to eq(3_500) + expect(invoice.sub_total_excluding_taxes_amount_cents).to eq(16_500) + expect(invoice.taxes_amount_cents).to eq(1_889) + expect(invoice.total_amount_cents).to eq(18_389) + end + end +end diff --git a/spec/scenarios/create_event_spec.rb b/spec/scenarios/create_event_spec.rb new file mode 100644 index 0000000..2b3f0a8 --- /dev/null +++ b/spec/scenarios/create_event_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Create Event Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:params) do + {code: billable_metric.code, transaction_id: SecureRandom.uuid} + end + + before { subscription } + + context "without external_subscription_id" do + it "returns the created event" do + result = create_event params + + expect(result["event"]).to be_present + expect(result["event"]["code"]).to eq(billable_metric.code) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + customer_id: nil, + subscription_id: nil, + external_subscription_id: nil + ) + end + end + + context "with unknown external_subscription_id" do + it "returns the created event" do + result = create_event(params.merge(external_subscription_id: "unknown")) + + expect(result["event"]).to be_present + expect(result["event"]["code"]).to eq(billable_metric.code) + expect(result["event"]["external_subscription_id"]).to eq("unknown") + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: "unknown" + ) + end + end + + context "with external_subscription_id from another organization" do + let(:organization2) { create(:organization, webhook_url: nil) } + let(:customer2) { create(:customer, organization: organization2) } + let(:subscription2) { create(:subscription, customer: customer2) } + + it "returns the created event" do + result = create_event(params.merge(external_subscription_id: subscription2.external_id)) + + expect(result["event"]).to be_present + expect(result["event"]["code"]).to eq(billable_metric.code) + expect(result["event"]["external_subscription_id"]).to eq(subscription2.external_id) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: subscription2.external_id + ) + end + end + + context "with valid external_subscription_id" do + it "creates the event successfully" do + expect do + create_event(params.merge(external_subscription_id: subscription.external_id)) + end.to change(Event, :count) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: subscription.external_id + ) + end + end + + context "with not yet started subscription" do + let(:subscription) { create(:subscription, customer:, started_at: 1.day.from_now) } + + it "returns the created event" do + result = create_event(params.merge(external_subscription_id: subscription.external_id)) + + expect(result["event"]).to be_present + expect(result["event"]["external_subscription_id"]).to eq(subscription.external_id) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: subscription.external_id + ) + end + end + + context "with subscription started in the same second" do + let(:subscription) { create(:subscription, customer:, started_at: Time.current) } + + it "returns the created event" do + expect do + create_event(params.merge(external_subscription_id: subscription.external_id)) + end.to change(Event, :count) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: subscription.external_id + ) + end + end + + context "with terminated subscription" do + let(:subscription) { create(:subscription, :terminated, customer:, terminated_at: 1.hour.ago) } + + it "returns the created event" do + result = create_event(params.merge(external_subscription_id: subscription.external_id)) + + expect(result["event"]).to be_present + expect(result["event"]["external_subscription_id"]).to eq(subscription.external_id) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: subscription.external_id + ) + end + end + + context "with subscription terminated in the same second" do + let(:subscription) { create(:subscription, :terminated, customer:, terminated_at: Time.current) } + + it "creates the event successfully" do + expect do + create_event(params.merge(external_subscription_id: subscription.external_id)) + end.to change(Event, :count) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: subscription.external_id + ) + end + end + + context "with terminated subscription but timestamp when active" do + let(:subscription) { create(:subscription, :terminated, customer:, terminated_at: 24.hours.ago) } + + it "creates the event successfully" do + expect do + create_event( + params.merge( + external_subscription_id: subscription.external_id, + timestamp: 24.hours.ago.to_i + ) + ) + end.to change(Event, :count) + + perform_all_enqueued_jobs + + event = organization.events.order(created_at: :asc).last + expect(event).to have_attributes( + code: billable_metric.code, + external_subscription_id: subscription.external_id + ) + end + end + + context "with external_subscription_id but multiple subscriptions" do + let(:subscription2) do + create( + :subscription, + :pending, + customer:, + external_id: subscription.external_id + ) + end + + before { subscription2 } + + it "creates the event" do + expect do + create_event(params.merge(external_subscription_id: subscription.external_id)) + end.to change { Event.where(external_subscription_id: subscription.external_id).count } + end + end + + context "with 2 charges for the same event" do + let(:charges) { create_list(:standard_charge, 2, plan:, billable_metric:) } + + before do + charges + organization.enable_feature_flag!(:postgres_enriched_events) + end + + it "creates the event" do + expect do + create_event(params.merge(external_subscription_id: subscription.external_id)) + end.to change(Event, :count).by(1).and change(EnrichedEvent, :count).by(2) + end + end +end diff --git a/spec/scenarios/credit_notes/credit_note_rounding_spec.rb b/spec/scenarios/credit_notes/credit_note_rounding_spec.rb new file mode 100644 index 0000000..e68502c --- /dev/null +++ b/spec/scenarios/credit_notes/credit_note_rounding_spec.rb @@ -0,0 +1,241 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Credit note rounding issues Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil, email_settings: []) } + let(:customer) { create(:customer, organization:) } + + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 25) } + let(:plan) { create(:plan, organization:, interval: :monthly, amount_cents:, pay_in_advance: true) } + + before do + tax + plan + end + + context "when the thing is greater" do + let(:amount_cents) { 20000 } + + it "handles the rounding issues" do + # Creates the subscription + travel_to(Time.zone.parse("2025-09-18T16:00:00Z")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: :anniversary + }) + end + + subscription = customer.subscriptions.last + invoice = customer.invoices.last + expect(invoice.fees_amount_cents).to eq(20000) + expect(invoice.taxes_amount_cents).to eq(5000) + expect(invoice.total_amount_cents).to eq(25000) + + # Finalize the invoice + travel_to(Time.zone.parse("2025-09-18T16:30:00Z")) do + update_invoice(invoice, {payment_status: "succeeded"}) + end + + # Terminate subscription + travel_to(Time.zone.parse("2025-09-18T16:40:00Z")) do + terminate_subscription(subscription) + end + + # Fetch the credit note + credit_note = customer.credit_notes.sole + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 19333, + taxes_amount_cents: 4833, + credit_amount_cents: 24166, + total_amount_cents: 24166 + ) + end + end + + context "when the other thing is greater" do + let(:amount_cents) { 16000 } + + it "handles the rounding issues" do + # Creates the subscription + travel_to(Time.zone.parse("2025-09-18T16:00:00Z")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: :anniversary + }) + end + + subscription = customer.subscriptions.last + invoice = customer.invoices.last + expect(invoice.fees_amount_cents).to eq(16000) + expect(invoice.taxes_amount_cents).to eq(4000) + expect(invoice.total_amount_cents).to eq(20000) + + # Finalize the invoice + travel_to(Time.zone.parse("2025-09-18T16:30:00Z")) do + update_invoice(invoice, {payment_status: "succeeded"}) + end + + # Terminate subscription + travel_to(Time.zone.parse("2025-09-18T16:40:00Z")) do + terminate_subscription(subscription) + end + + # Fetch the credit note + credit_note = customer.credit_notes.sole + item = credit_note.items.sole + expect(item).to have_attributes( + amount_cents: 15467, + precise_amount_cents: 0.1546666666e5 + ) + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 15467, + taxes_amount_cents: 3867, + credit_amount_cents: 19334, + total_amount_cents: 19334 + ) + expect(credit_note.applied_taxes.sole).to have_attributes( + amount_cents: 3867 + ) + end + end + + context "when total credit is different" do + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 23.33) } + let(:plan) { create(:plan, organization:, interval: :weekly, amount_cents: 2999, pay_in_advance: true) } + + it "handles the rounding issues" do + travel_to(Time.zone.parse("2025-10-06T16:00:00Z")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: :calendar + }) + end + + subscription = customer.subscriptions.last + invoice = customer.invoices.last + expect(invoice.fees_amount_cents).to eq(2999) + expect(invoice.taxes_amount_cents).to eq(700) + expect(invoice.total_amount_cents).to eq(2999 + 700) + + travel_to(Time.zone.parse("2025-10-06T16:30:00Z")) do + update_invoice(invoice, {payment_status: "succeeded"}) + terminate_subscription(subscription) + end + + credit_note = customer.credit_notes.sole + item = credit_note.items.sole + expect(item).to have_attributes( + amount_cents: 2571, + precise_amount_cents: 2570.57142 + ) + + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 2571, + taxes_amount_cents: 600, + credit_amount_cents: 3171, + total_amount_cents: 3171 + ) + expect(credit_note.applied_taxes.sole).to have_attributes( + amount_cents: 600 + ) + end + end + + context "with existing credit note" do + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:amount_cents) { 769_00 } + + it "handles the rounding issues" do + # Creates the subscription + travel_to(Time.zone.parse("2025-09-18T16:00:00Z")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: :anniversary + }) + end + + # subscription = customer.subscriptions.last + invoice = customer.invoices.last + expect(invoice.fees_amount_cents).to eq(769_00) + expect(invoice.taxes_amount_cents).to eq(153_80) + expect(invoice.total_amount_cents).to eq(922_80) + + # Finalize the invoice + travel_to(Time.zone.parse("2025-09-19T16:30:00Z")) do + update_invoice(invoice, {payment_status: "succeeded"}) + end + + # Create a credit note + travel_to(Time.zone.parse("2025-09-20T16:30:00Z")) do + create_credit_note({ + reason: :other, + invoice_id: invoice.id, + credit_amount_cents: 872_24, + refund_amount_cents: 0, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 726_86 + } + ] + }) + end + + credit_note = invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 726_86, + taxes_amount_cents: 145_37, + refund_amount_cents: 0, + total_amount_cents: 872_23, + coupons_adjustment_amount_cents: 0 + ) + + # Credit note was created before the rounding fix, so it has a different total amount + credit_note.update(total_amount_cents: 872_24, credit_amount_cents: 872_24) + + estimate = estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 42_14 + } + ] + }) + + # Create a new credit note with the remaining amount + travel_to(Time.zone.parse("2025-09-22T16:30:00Z")) do + create_credit_note({ + reason: :other, + invoice_id: invoice.id, + credit_amount_cents: estimate.dig("estimated_credit_note", "max_creditable_amount_cents"), # 50_57 + refund_amount_cents: 0, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 42_14 + } + ] + }) + end + + credit_note = invoice.credit_notes.order(created_at: :desc).first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 42_14, + taxes_amount_cents: 8_43, + refund_amount_cents: 0, + total_amount_cents: 50_57, + coupons_adjustment_amount_cents: 0 + ) + end + end +end diff --git a/spec/scenarios/credit_notes/credit_note_spec.rb b/spec/scenarios/credit_notes/credit_note_spec.rb new file mode 100644 index 0000000..37923cf --- /dev/null +++ b/spec/scenarios/credit_notes/credit_note_spec.rb @@ -0,0 +1,1107 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Create credit note Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil, email_settings: []) } + let(:customer) { create(:customer, organization:) } + + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 10) } + + let(:plan1) do + create( + :plan, + organization:, + interval: :monthly, + amount_cents: 17_900, + pay_in_advance: true + ) + end + + let(:plan2) do + create( + :plan, + organization:, + interval: :monthly, + amount_cents: 39_900, + pay_in_advance: true + ) + end + + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 20_000, + expiration: :no_expiration, + coupon_type: :fixed_amount, + frequency: :forever, + limited_plans: true + ) + end + + let(:coupon_target) do + create(:coupon_plan, coupon:, plan: plan2) + end + + let(:plan_tax) { create(:tax, organization:, name: "Plan Tax", rate: 10, applied_to_organization: false) } + let(:plan_applied_tax) { create(:plan_applied_tax, plan: plan2, tax: plan_tax) } + let(:plan_applied_tax2) { create(:plan_applied_tax, plan: plan2, tax:) } + + before do + tax + plan_applied_tax + plan_applied_tax2 + end + + it "Allows creation of partial credit note" do + # Creates two subscriptions + travel_to(Time.zone.parse("2022-12-19T12:00:00")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: "#{customer.external_id}_1", + plan_code: plan1.code, + billing_time: :anniversary} + ) + + create_subscription( + {external_customer_id: customer.external_id, + external_id: "#{customer.external_id}_2", + plan_code: plan2.code, + billing_time: :anniversary} + ) + end + + # Apply a coupon to the customer + travel_to(Time.zone.parse("2023-08-29T12:00:00")) do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon_target.coupon.code, + amount_cents: 250_00} + ) + end + + # Bill subscription on an anniversary date + travel_to(Time.zone.parse("2023-10-19T12:00:00")) do + perform_billing + end + + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees_amount_cents).to eq(578_00) + expect(invoice.coupons_amount_cents).to eq(250_00) + expect(invoice.taxes_rate).to eq(14.54268) + expect(invoice.taxes_amount_cents).to eq(47_70) + expect(invoice.total_amount_cents).to eq(375_70) + + fee1 = invoice.fees.find_by(amount_cents: 179_00) + expect(fee1.precise_coupons_amount_cents).to eq(0) + + fee2 = invoice.fees.find_by(amount_cents: 399_00) + expect(fee2.precise_coupons_amount_cents).to eq(250_00) + + travel_to(Time.zone.parse("2023-10-23T12:00:00")) do + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 12_00, reference: "ref1"} + ) + + # Estimate the credit notes amount on full fees + estimate_credit_note( + {invoice_id: invoice.id, + items: [ + { + fee_id: fee1.id, + amount_cents: fee1.amount_cents + }, + { + fee_id: fee2.id, + amount_cents: fee2.amount_cents + } + ]} + ) + + estimate = json[:estimated_credit_note] + expect(estimate[:taxes_amount_cents]).to eq(47_70) + expect(estimate[:sub_total_excluding_taxes_amount_cents]).to eq(328_00) + expect(estimate[:max_creditable_amount_cents]).to eq(375_70) + expect(estimate[:max_refundable_amount_cents]).to eq(12_00) + expect(estimate[:coupons_adjustment_amount_cents]).to eq(250_00) + expect(estimate[:taxes_rate]).to eq(14.54268) + + estimate_credit_note( + {invoice_id: invoice.id, + items: [ + { + fee_id: fee2.id, + amount_cents: 262_60 + } + ]} + ) + + # Estimate the credit notes amount on one partial fee + estimate = json[:estimated_credit_note] + expect(estimate[:taxes_amount_cents]).to eq(19_61) + expect(estimate[:sub_total_excluding_taxes_amount_cents]).to eq(98_06) + expect(estimate[:max_creditable_amount_cents]).to eq(117_67) + expect(estimate[:max_refundable_amount_cents]).to eq(12_00) + expect(estimate[:coupons_adjustment_amount_cents]).to eq(164_54) + expect(estimate[:taxes_rate]).to eq(20) + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 105_67, reference: "ref1"} + ) + + # Emit a credit note on only one fee + create_credit_note({invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 117_67, + items: [ + { + fee_id: fee2.id, + amount_cents: 262_60 + } + ]}) + end + + credit_note = invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 98_06, + taxes_amount_cents: 19_61, + refund_amount_cents: 117_67, + total_amount_cents: 117_67, + coupons_adjustment_amount_cents: 164_54 + ) + end + + context "when applying multiple time the same coupon" do + let(:plan) do + create( + :plan, + organization:, + interval: :monthly, + amount_cents: 1_999, + pay_in_advance: false + ) + end + + let(:charge1) do + create( + :standard_charge, + plan:, + min_amount_cents: 99_290 + ) + end + + let(:charge2) do + create( + :standard_charge, + plan:, + min_amount_cents: 299_770 + ) + end + + let(:charge3) do + create( + :standard_charge, + plan:, + min_amount_cents: 3_130 + ) + end + + let(:charge4) do + create( + :standard_charge, + plan:, + min_amount_cents: 6_460 + ) + end + + let(:charge5) do + create( + :standard_charge, + plan:, + min_amount_cents: 3_130 + ) + end + + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 10_00, + expiration: :no_expiration, + coupon_type: :fixed_amount, + frequency: :forever, + limited_plans: false, + reusable: true + ) + end + + before do + charge1 + charge2 + charge3 + charge4 + charge5 + end + + it "Allows creation of partial credit note" do + # Creates two subscriptions + travel_to(Time.zone.parse("2022-12-19T12:00:00")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: "#{customer.external_id}_1", + plan_code: plan.code, + billing_time: :anniversary} + ) + end + + # Apply a coupon twice to the customer + travel_to(Time.zone.parse("2023-08-29")) do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon.code, + amount_cents: 1_000} + ) + + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon.code, + amount_cents: 1_000} + ) + end + + # Bill subscription on an anniversary date + travel_to(Time.zone.parse("2023-10-19")) do + perform_billing + end + + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees_amount_cents).to eq(413_779) + expect(invoice.coupons_amount_cents).to eq(2_000) + expect(invoice.taxes_rate).to eq(10) + expect(invoice.taxes_amount_cents).to eq(41_178) + expect(invoice.total_amount_cents).to eq(452_957) + + fee1 = invoice.fees.find_by(amount_cents: charge1.min_amount_cents) + expect(fee1.precise_coupons_amount_cents).to eq(479.91802) + + fee2 = invoice.fees.find_by(amount_cents: charge2.min_amount_cents) + expect(fee2.precise_coupons_amount_cents).to eq(1_448.93772) + + fee3 = invoice.fees.find_by(amount_cents: charge3.min_amount_cents) + expect(fee3.precise_coupons_amount_cents).to eq(15.12884) + + fee4 = invoice.fees.find_by(amount_cents: charge4.min_amount_cents) + expect(fee4.precise_coupons_amount_cents).to eq(31.2244) + + fee5 = invoice.fees.find_by(amount_cents: charge5.min_amount_cents) + expect(fee5.precise_coupons_amount_cents).to eq(15.12884) + + fee6 = invoice.fees.find_by(amount_cents: plan.amount_cents) + expect(fee6.precise_coupons_amount_cents).to eq(9.66216) + + travel_to(Time.zone.parse("2023-10-23")) do + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 40, reference: "ref2"} + ) + + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: fee6.id, + amount_cents: 100 + }, + { + fee_id: fee2.id, + amount_cents: 100 + }, + { + fee_id: fee3.id, + amount_cents: 100 + }, + { + fee_id: fee4.id, + amount_cents: 100 + }, + { + fee_id: fee5.id, + amount_cents: 100 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate[:coupons_adjustment_amount_cents]).to eq(2) + expect(estimate[:sub_total_excluding_taxes_amount_cents]).to eq(4_98) + expect(estimate[:taxes_amount_cents]).to eq(49) + expect(estimate[:max_creditable_amount_cents]).to eq(5_47) + expect(estimate[:max_refundable_amount_cents]).to eq(40) + expect(estimate[:taxes_rate]).to eq(10) + end + end + end + + context "when creating credit note with possible rounding issues" do + context "when creating credit notes for small items with taxes, so sum of items with their taxes is bigger than invoice total amount" do + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + + context "when two similar items are refunded separately" do + let(:add_ons) { create_list(:add_on, 2, organization:, amount_cents: 68_33) } + + it "solves the rounding issue" do + # create a one off invoice with two addons and small amounts as feed + create_one_off_invoice(customer, add_ons, taxes: [tax.code]) + # invoice amount should be with taxes calculated on items sum: + invoice = customer.invoices.order(:created_at).last + expect(invoice.total_amount_cents).to eq(163_99) + expect(invoice.taxes_amount_cents).to eq(27_33) + fees = invoice.fees + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 500, reference: "ref3"} + ) + + # estimate and create credit notes for first item - full refund; the taxes are rounded to higher number + estimate_credit_note( + {invoice_id: invoice.id, + items: [ + { + fee_id: fees[0].id, + amount_cents: 68_33 + } + ]} + ) + + # Estimate the credit notes amount on one fee rounds the taxes to higher number + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 13_67, + precise_taxes_amount_cents: "1366.6", + sub_total_excluding_taxes_amount_cents: 68_33, + max_creditable_amount_cents: 82_00, + max_refundable_amount_cents: 5_00, + taxes_rate: 20.0 + ) + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 7700, reference: "ref3"} + ) + + # Emit a credit note on only one fee + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 82_00, + items: [ + { + fee_id: fees[0].id, + amount_cents: 68_33 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 68_33, + taxes_amount_cents: 13_67, + refund_amount_cents: 82_00, + total_amount_cents: 82_00, + precise_taxes_amount_cents: 1366.6, + precise_total: 8199.6, + taxes_rounding_adjustment: 0.4 + ) + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 8_000, reference: "ref3"} + ) + + # when issuing second credit note, it should be rounded to lower number + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: fees[1].id, + amount_cents: 68_33 + } + ] + }) + + estimate = json[:estimated_credit_note] + + expect(estimate).to include( + taxes_amount_cents: 13_66, + precise_taxes_amount_cents: "1366.2", + sub_total_excluding_taxes_amount_cents: 68_33, + max_creditable_amount_cents: 81_99, + max_refundable_amount_cents: 80_00, + taxes_rate: 20.0 + ) + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 1_99, reference: "ref3"} + ) + + # Emit a credit note on only one fee + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 81_99, + items: [ + { + fee_id: fees[1].id, + amount_cents: 68_33 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 68_33, + refund_amount_cents: 81_99, + total_amount_cents: 81_99, + taxes_amount_cents: 13_66, + precise_taxes_amount_cents: 1366.2, + precise_total: 8199.2, + taxes_rounding_adjustment: -0.2 + ) + end + end + + context "when four items are refunded separately, some whole, some in parts" do + let(:add_ons) { create_list(:add_on, 4, organization:, amount_cents: 68_33) } + + it "solves the rounding issue" do + # create a one off invoice with two addons and small amounts as feed + create_one_off_invoice(customer, add_ons, taxes: [tax.code]) + # invoice amount should be with taxes calculated on items sum: + invoice = customer.invoices.order(:created_at).last + expect(invoice.total_amount_cents).to eq(327_98) + expect(invoice.taxes_amount_cents).to eq(54_66) + fees = invoice.fees + invoice.update(payment_status: "succeeded") + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 300_00, reference: "ref3"} + ) + invoice.reload + + # estimate and create credit notes for first three items - full refund; the taxes are rounded to higher number + 3.times do |i| + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: fees[i].id, + amount_cents: 68_33 + } + ] + }) + + # Estimate the credit notes amount on one fee rounds the taxes to higher number + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 13_67, + precise_taxes_amount_cents: "1366.6", + sub_total_excluding_taxes_amount_cents: 68_33, + max_creditable_amount_cents: 82_00, + max_refundable_amount_cents: 82_00, + taxes_rate: 20.0 + ) + + # Emit a credit note on only one fee + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 82_00, + items: [ + { + fee_id: fees[i].id, + amount_cents: 68_33 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + refund_amount_cents: 82_00, + total_amount_cents: 82_00, + taxes_amount_cents: 13_67, + precise_taxes_amount_cents: 1366.6, + precise_total: 8199.6, + taxes_rounding_adjustment: 0.4 + ) + end + # this value is wrong because of all rounding because if we subtract issued credit notes from the invoice, it + # will result in 327_98 - 82_00 * 3 = 81_98 + expect(invoice.creditable_amount_cents).to eq(8200) + + # split last refundable item into three chunks, first's taxes are rounded to lower number + # next two are rounded to higher number + # cn_1 => 13.67, cn2 => 22.33, cn3 => 32.33 + # CN1 + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: fees[3].id, + amount_cents: 13_67 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 273, + precise_taxes_amount_cents: "273.4", + sub_total_excluding_taxes_amount_cents: 1367, + max_creditable_amount_cents: 1640, + max_refundable_amount_cents: 1640, + taxes_rate: 20.0 + ) + + # Emit a credit note on only one fee + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 1640, + items: [ + { + fee_id: fees[3].id, + amount_cents: 1367 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + refund_amount_cents: 1640, + total_amount_cents: 1640, + taxes_amount_cents: 273, + precise_taxes_amount_cents: 273.4 + ) + expect(credit_note.precise_total).to eq(1640.4) + expect(credit_note.taxes_rounding_adjustment).to eq(-0.4) + # real remaining: 81_98 - 16_40 = 65_58 + expect(invoice.creditable_amount_cents).to eq(6559) + + # cn_1 => 13.67, cn2 => 22.33, cn3 => 32.33 + # CN2 + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: fees[3].id, + amount_cents: 22_33 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 447, + precise_taxes_amount_cents: "446.6", + sub_total_excluding_taxes_amount_cents: 2233, + max_creditable_amount_cents: 2680, + max_refundable_amount_cents: 2680, + taxes_rate: 20.0 + ) + + # Emit a credit note on only one fee + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 2680, + items: [ + { + fee_id: fees[3].id, + amount_cents: 2233 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + refund_amount_cents: 2680, + total_amount_cents: 2680, + taxes_amount_cents: 447, + precise_taxes_amount_cents: 446.6, + precise_total: 2679.6, + taxes_rounding_adjustment: 0.4 + ) + # real remaining: 65_58 - 26_80 = 38_78 + expect(invoice.creditable_amount_cents).to eq(3880) + + # cn_1 => 13.67, cn2 => 22.33, cn3 => 32.33 + # CN3 + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: fees[3].id, + amount_cents: 32_33 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 6_45, + precise_taxes_amount_cents: "645.4", + sub_total_excluding_taxes_amount_cents: 32_33, + max_creditable_amount_cents: 38_78, + max_refundable_amount_cents: 10_80, # invoice.total_paid_amount_cents - invoice.credit_notes.sum(:refund_amount_cents) + taxes_rate: 20.0 + ) + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 27_98, reference: "ref3"} + ) + + # Emit a credit note on only one fee + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 3878, + items: [ + { + fee_id: fees[3].id, + amount_cents: 3233 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 32_33, + refund_amount_cents: 38_78, + total_amount_cents: 38_78, + taxes_amount_cents: 645, + precise_taxes_amount_cents: 645.4, + precise_total: 38_78.4, + taxes_rounding_adjustment: -0.4 + ) + + expect(invoice.creditable_amount_cents).to eq(0) + end + end + end + + context "when creating credit note with small items and applied coupons" do + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:plan_tax) { create(:tax, organization:, name: "Plan Tax", rate: 20, applied_to_organization: false) } + let(:plan) do + create( + :plan, + organization:, + interval: :monthly, + amount_cents: 1_999, + pay_in_advance: false + ) + end + + let(:charge1) do + create( + :standard_charge, + plan:, + min_amount_cents: 6833 + ) + end + + let(:charge2) do + create( + :standard_charge, + plan:, + min_amount_cents: 200_33 + ) + end + + let(:coupon) do + create( + :coupon, + organization:, + amount_cents: 10_00, + expiration: :no_expiration, + coupon_type: :fixed_amount, + frequency: :forever, + limited_plans: false, + reusable: true + ) + end + + before do + charge1 + charge2 + end + + it "calculates all roundings" do + # Creates two subscriptions + travel_to(DateTime.new(2022, 12, 19, 12)) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: "#{customer.external_id}_1", + plan_code: plan.code, + billing_time: :anniversary} + ) + end + + # Apply a coupon twice to the customer + travel_to(DateTime.new(2023, 8, 29)) do + apply_coupon( + {external_customer_id: customer.external_id, + coupon_code: coupon.code, + amount_cents: 10_00} + ) + end + + # Bill subscription on an anniversary date + travel_to(DateTime.new(2023, 10, 19)) do + perform_billing + end + + invoice = customer.invoices.order(created_at: :desc).first + # fees sum = 19_99 + 68_33 + 200_33 = 288_65 + # applied coupon - 10_00 + # subtotal before taxes - 278_65 + # taxes = 5573 + expect(invoice.total_amount_cents).to eq(334_38) + + # issue a CN for the full subscription fee - 19_99 before taxes and coupons + subscription_fee = invoice.fees.find(&:subscription?) + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: subscription_fee.id, + amount_cents: 19_99 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 3_86, + precise_taxes_amount_cents: "385.94932", + sub_total_excluding_taxes_amount_cents: 19_30, + max_creditable_amount_cents: 23_16, + coupons_adjustment_amount_cents: 69, + taxes_rate: 20.0 + ) + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 23_16, + items: [ + { + fee_id: subscription_fee.id, + amount_cents: 19_99 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + credit_amount_cents: 23_16, + total_amount_cents: 23_16, + taxes_amount_cents: 3_86, + precise_taxes_amount_cents: 385.94932, + precise_coupons_adjustment_amount_cents: 69.25342, + precise_total: 2315.6959, + taxes_rounding_adjustment: 0.05068 + ) + + # real remaining: 334_38 - 23_16 = 311_22 + expect(invoice.creditable_amount_cents).to eq(31122.253421098216) + + # issue a CN for the full first charge - 68_33 before taxes and coupons + first_charge = invoice.fees.find { |fee| fee.amount_cents == 68_33 } + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: first_charge.id, + amount_cents: 68_33 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 13_19, + precise_taxes_amount_cents: "1319.25547", + sub_total_excluding_taxes_amount_cents: 65_96, + max_creditable_amount_cents: 79_15, + coupons_adjustment_amount_cents: 2_37, + taxes_rate: 20.0 + ) + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 79_15, + items: [ + { + fee_id: first_charge.id, + amount_cents: 6833 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + credit_amount_cents: 79_15, + total_amount_cents: 79_15, + taxes_amount_cents: 13_19, + precise_taxes_amount_cents: 1319.25547, + precise_coupons_adjustment_amount_cents: 236.72267 + ) + expect(credit_note.precise_total).to eq(7915.5328) + expect(credit_note.taxes_rounding_adjustment).to eq(-0.25547) + # real remaining: 311_22 - 79_16 = 232_07 + expect(invoice.creditable_amount_cents).to eq(23206.97609561753) + + # issue a CN for the full last charge - 200_33 before taxes and coupons + last_charge = invoice.fees.find { |fee| fee.amount_cents == 200_33 } + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: last_charge.id, + amount_cents: 200_33 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate).to include( + taxes_amount_cents: 38_68, + precise_taxes_amount_cents: "3868.00001", + sub_total_excluding_taxes_amount_cents: 193_39, + max_creditable_amount_cents: 232_07, + coupons_adjustment_amount_cents: 6_94, + taxes_rate: 20.0 + ) + + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 232_07, + items: [ + { + fee_id: last_charge.id, + amount_cents: 200_33 + } + ] + }) + + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note).to have_attributes( + credit_amount_cents: 232_07, + total_amount_cents: 232_07, + taxes_amount_cents: 38_68, + precise_taxes_amount_cents: 3868.00001, + precise_coupons_adjustment_amount_cents: 694.0239, + precise_total: 23206.97611, + taxes_rounding_adjustment: -0.00001 + ) + + # real remaining: 232_07 - 23_207 = 0 + expect(invoice.creditable_amount_cents).to eq(0) + end + end + end + + context "when invoice is prepaid credit" do + it "behaves differently depending on the invoice payment status, wallet balance and wallet status" do + # Create a prepaid credit invoice for 15 credits + create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + invoice_requires_successful_payment: false # default + }) + wallet = customer.wallets.sole + + create_wallet_transaction({ + wallet_id: wallet.id, + paid_credits: "15" + }) + wt = WalletTransaction.find json[:wallet_transactions].first[:lago_id] + + expect(wt.status).to eq "pending" + expect(wt.transaction_status).to eq "purchased" + + # Customer does not have a payment_provider set yet + invoice = customer.invoices.credit.sole + expect(invoice.status).to eq "finalized" + + # it does not allow to create credit notes on invoices with payment status pending + expect(invoice.creditable_amount_cents).to eq 0 + expect(invoice.refundable_amount_cents).to eq 0 + + estimate_credit_note( + {invoice_id: invoice.id, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 15 + } + ]}, + raise_on_error: false + ) + expect(response).to have_http_status(:method_not_allowed) + + # it does not allow to create credit notes on invoices with payment status pending + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 15, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 15 + } + ] + }, raise_on_error: false) + expect(response).to have_http_status(:method_not_allowed) + + # pay the invoice + update_invoice(invoice, {payment_status: :succeeded}) + perform_all_enqueued_jobs + wallet.reload + expect(wallet.balance_cents).to eq 1500 + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 1500, reference: "ref3"} + ) + + invoice.reload + + # it allows to estimate a credit notes on credit invoices with payment status succeeded + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 10 + } + ] + }) + + estimate = json[:estimated_credit_note] + expect(estimate[:sub_total_excluding_taxes_amount_cents]).to eq(10) + expect(estimate[:max_refundable_amount_cents]).to eq(10) + expect(estimate[:max_creditable_amount_cents]).to eq(0) + + # it allows to create credit notes on credit invoices with payment status succeeded + # and voids the corresponding amount of credits in the associated active wallet + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + credit_amount_cents: 0, + refund_amount_cents: 500, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 500 + } + ] + }) + perform_all_enqueued_jobs + credit_note = invoice.credit_notes.order(:created_at).last + expect(credit_note.refund_amount_cents).to eq(500) + expect(credit_note.total_amount_cents).to eq(500) + wallet_transaction = wallet.wallet_transactions.order(:created_at).last + expect(wallet_transaction.status).to eq("settled") + expect(wallet_transaction.transaction_status).to eq("voided") + expect(wallet_transaction.credit_note_id).to eq(credit_note.id) + expect(wallet.reload.balance_cents).to eq(1000) + + # Void most of the remaining balance to leave only 5 cents + # Balance is 1000 cents (= 10 credits), void 9.95 credits to leave 5 cents + create_wallet_transaction({ + wallet_id: wallet.id, + voided_credits: "9.95" + }) + perform_all_enqueued_jobs + expect(wallet.reload.balance_cents).to eq(5) + + # when estimating a credit note with amount higher than the remaining balance, it throws an error + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 10 + } + ] + }, raise_on_error: false) + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("higher_than_wallet_balance") + + # when creating a credit note with amount higher than remaining balance, it throws an error + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + refund_amount_cents: 10, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 10 + } + ] + }, raise_on_error: false) + expect(response).to have_http_status(:unprocessable_content) + expect(response.body).to include("higher_than_wallet_balance") + + expect(wallet.reload.balance_cents).to eq(5) + + # when wallet is terminated, it does not allow to create credit notes + wallet.update(status: :terminated) + + estimate_credit_note({ + invoice_id: invoice.id, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 1 + } + ] + }, raise_on_error: false) + expect(response).to have_http_status(:method_not_allowed) + expect(response.body).to include("invalid_type_or_status") + + create_credit_note({ + invoice_id: invoice.id, + reason: :other, + refund_amount_cents: 1, + items: [ + { + fee_id: invoice.fees.first.id, + amount_cents: 1 + } + ] + }, raise_on_error: false) + expect(response).to have_http_status(:method_not_allowed) + expect(response.body).to include("invalid_type_or_status") + end + end +end diff --git a/spec/scenarios/current_usage/by_aggregation_type/count_agg_spec.rb b/spec/scenarios/current_usage/by_aggregation_type/count_agg_spec.rb new file mode 100644 index 0000000..2aff190 --- /dev/null +++ b/spec/scenarios/current_usage/by_aggregation_type/count_agg_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Aggregation - Count Scenarios", transaction: false do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with standard charge" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "5"}) + end + + it "returns the expected current usage" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:total_amount_cents]).to eq(1500) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("3.0") + expect(charge_usage[:events_count]).to eq(3) + expect(charge_usage[:amount_cents]).to eq(1500) + end + end + end + + context "with incremental events" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + end + + it "accumulates usage across events" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("1.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(1000) + + 2.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("3.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(3000) + end + end + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_aggregation_type/custom_agg_spec.rb b/spec/scenarios/current_usage/by_aggregation_type/custom_agg_spec.rb new file mode 100644 index 0000000..c17125c --- /dev/null +++ b/spec/scenarios/current_usage/by_aggregation_type/custom_agg_spec.rb @@ -0,0 +1,1207 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Aggregation - Custom Aggregation Scenarios", transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:custom_billable_metric, organization:, custom_aggregator:) } + + let(:custom_aggregator) do + <<~RUBY + def aggregate(event, previous_state, aggregation_properties) + previous_units = previous_state[:total_units] + event_units = BigDecimal(event.properties['value'] ? event.properties['value'] : 0) # 1 + certif = event.properties['certif'] + total_units = previous_units + event_units + ranges = aggregation_properties['ranges'] + + result_amount = ranges.reduce(0) do |amount, range| + to = range['to'] + to = BigDecimal(to.to_s) if to + + # Range was already reached + next amount if to && previous_units > to + + from = BigDecimal(range['from'].to_s) + certif_amount = BigDecimal(range[certif] ? range[certif].to_s : '0') + + if !to || total_units <= to + # Last matching range is reached + units_to_use = if previous_units >= from + # All new units are in the current range + event_units + else + # Takes only the new units in the current range + total_units - from + 1 + end + break amount += certif_amount * units_to_use + + else + # Range is not the last one + units_to_use = if previous_units >= from + # All remaining units in the range + to - previous_units + else + # All units in the range + to - from + 1 + end + + amount += certif_amount * units_to_use + end + + amount + end + { total_units: total_units, amount: result_amount } + end + RUBY + end + + let(:pay_in_advance) { false } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with first aggregation scenario" do + let(:standard_charge) do + create( + :standard_charge, + billable_metric:, + plan:, + pay_in_advance:, + properties: { + amount: "2", + custom_properties: { + ranges: [ + {from: 0, to: 1_000, third_party: "0.15", first_party: "0.12"}, + {from: 1_001, to: 20_000, third_party: "0.12", first_party: "0.10"}, + {from: 20_001, to: 50_000, third_party: "0.10", first_party: "0.08"}, + {from: 50_001, to: nil, third_party: "0.08", first_party: "0.06"} + ] + } + } + ) + end + + let(:custom_charge) do + create( + :custom_charge, + billable_metric:, + plan:, + pay_in_advance:, + properties: { + custom_properties: { + ranges: [ + {from: 0, to: 1_000, third_party: "0.15", first_party: "0.12"}, + {from: 1_001, to: 20_000, third_party: "0.12", first_party: "0.10"}, + {from: 20_001, to: 50_000, third_party: "0.10", first_party: "0.08"}, + {from: 50_001, to: nil, third_party: "0.08", first_party: "0.06"} + ] + } + } + ) + end + + before do + standard_charge + custom_charge + end + + context "when in arrears aggregation" do + it "create fees for each charges" do + travel_to(DateTime.new(2024, 2, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 6, 1)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(212) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("1.0") + expect(standard_usage[:amount_cents]).to eq(200) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("1.0") + expect(custom_usage[:amount_cents]).to eq(12) + end + + travel_to(DateTime.new(2024, 2, 6, 2)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 999, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(212_000) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("1000.0") + expect(standard_usage[:amount_cents]).to eq(200_000) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("1000.0") + expect(custom_usage[:amount_cents]).to eq(12_000) + end + + travel_to(DateTime.new(2024, 2, 6, 3)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1, + certif: "third_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(212_212) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("1001.0") + expect(standard_usage[:amount_cents]).to eq(200_200) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("1001.0") + expect(custom_usage[:amount_cents]).to eq(12_012) + end + + travel_to(DateTime.new(2024, 2, 6, 4)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(212_422) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("1002.0") + expect(standard_usage[:amount_cents]).to eq(200_400) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("1002.0") + expect(custom_usage[:amount_cents]).to eq(12_022) + end + + travel_to(DateTime.new(2024, 2, 6, 5)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 18998, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(4_202_002) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("20000.0") + expect(standard_usage[:amount_cents]).to eq(4_000_000) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("20000.0") + expect(custom_usage[:amount_cents]).to eq(202_002) + end + + travel_to(DateTime.new(2024, 2, 6, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(4_202_210) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("20001.0") + expect(standard_usage[:amount_cents]).to eq(4_000_200) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("20001.0") + expect(custom_usage[:amount_cents]).to eq(202_010) + end + + travel_to(DateTime.new(2024, 2, 6, 7)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 30_002, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(10_442_620) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("50003.0") + expect(standard_usage[:amount_cents]).to eq(10_000_600) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("50003.0") + expect(custom_usage[:amount_cents]).to eq(442_020) + end + end + + context "when recurring aggregation" do + let(:billable_metric) { create(:custom_billable_metric, organization:, custom_aggregator:, recurring: true) } + + it "create fees for each charges" do + travel_to(DateTime.new(2024, 2, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 6, 1)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(212) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("1.0") + expect(standard_usage[:amount_cents]).to eq(200) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("1.0") + expect(custom_usage[:amount_cents]).to eq(12) + end + + travel_to(DateTime.new(2024, 2, 6, 2)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 10, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(2_332) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("11.0") + expect(standard_usage[:amount_cents]).to eq(2_200) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("11.0") + expect(custom_usage[:amount_cents]).to eq(132) + end + + # Bill the subscription on it anniversary date + travel_to(DateTime.new(2024, 3, 1)) do + perform_billing + + expect(subscription.invoices.count).to eq(1) + + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(2_332) + expect(invoice.fees.count).to eq(3) + end + + # Send a new event after the billing + travel_to(DateTime.new(2024, 3, 2)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1000, + certif: "first_party" + } + } + ) + + fetch_current_usage(customer:) + + expect(json[:customer_usage][:total_amount_cents]).to eq(214_310) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("1011.0") + expect(standard_usage[:amount_cents]).to eq(202_200) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("1011.0") + expect(custom_usage[:amount_cents]).to eq(12_110) # 1000 * 0.12 + 11 * 0.12 + end + end + end + end + + context "when in advance aggregation" do + let(:pay_in_advance) { true } + + it "creates a fee per events" do + travel_to(DateTime.new(2024, 2, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 6, 1)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1, + certif: "first_party" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(2) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(2) + + standard_fee = subscription.fees.find_by(charge: standard_charge) + expect(standard_fee.amount_cents).to eq(200) + expect(standard_fee.events_count).to eq(1) + expect(standard_fee.units).to eq(1) + + custom_fee = subscription.fees.find_by(charge: custom_charge) + expect(custom_fee.amount_cents).to eq(12) + expect(custom_fee.events_count).to eq(1) + expect(custom_fee.units).to eq(1) + end + + travel_to(DateTime.new(2024, 2, 6, 2)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 10, + certif: "first_party" + } + } + ) + + expect(subscription.fees.count).to eq(4) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(4) + + standard_fee = subscription.fees.order(created_at: :desc).where(charge: standard_charge).first + expect(standard_fee.amount_cents).to eq(2000) + expect(standard_fee.events_count).to eq(1) + expect(standard_fee.units).to eq(10) + + custom_fee = subscription.fees.order(created_at: :desc).where(charge: custom_charge).first + expect(custom_fee.amount_cents).to eq(120) # 10 * 0.12 + expect(custom_fee.events_count).to eq(1) + expect(custom_fee.units).to eq(10) + end + + travel_to(DateTime.new(2024, 2, 6, 3)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1000, + certif: "third_party" + } + } + ) + + expect(subscription.fees.count).to eq(6) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(6) + + standard_fee = subscription.fees.order(created_at: :desc).where(charge: standard_charge).first + expect(standard_fee.amount_cents).to eq(200_000) + expect(standard_fee.events_count).to eq(1) + expect(standard_fee.units).to eq(1000) + + custom_fee = subscription.fees.order(created_at: :desc).where(charge: custom_charge).first + expect(custom_fee.amount_cents).to eq(14_967) # 989 * 0.15 + 11 * 0.12 + expect(custom_fee.events_count).to eq(1) + expect(custom_fee.units).to eq(1000) + end + + travel_to(DateTime.new(2024, 2, 6, 4)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(217_299) + expect(json[:customer_usage][:charges_usage].count).to eq(2) + + standard_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "standard" + end + expect(standard_usage[:units]).to eq("1011.0") + expect(standard_usage[:amount_cents]).to eq(202_200) + + custom_usage = json[:customer_usage][:charges_usage].find do |cu| + cu[:charge][:charge_model] == "custom" + end + expect(custom_usage[:units]).to eq("1011.0") + expect(custom_usage[:amount_cents]).to eq(15_099) # 11 * 0.12 + 989 * 0.15 + 11 * 0.12 + end + end + end + end + + context "with second aggregation scenario" do + let(:pay_in_advance) { true } + + let(:custom_aggregator) do + <<~RUBY + def aggregate(event, previous_state, aggregation_properties) + previous_units = previous_state[:total_units] + + ranges_property = aggregation_properties['ranges'] + amount_property = aggregation_properties['amount'] + rate_property = aggregation_properties['rate'] + min_amount_property = aggregation_properties['min_amount'] + fx_rate = BigDecimal(aggregation_properties['fx_rate'] ? aggregation_properties['fx_rate'].to_s : '1') + event_value = BigDecimal(event.properties['value'].to_s) + + total_units = previous_units + 1 + result_amount = BigDecimal('0') + + if ranges_property != nil + # The aggregation uses a range logic + range = ranges_property.find { |r| BigDecimal(r['from'].to_s) <= total_units && (r['to'].nil? || total_units <= BigDecimal(r['to'].to_s)) } + + if range['amount'] != nil + result_amount += BigDecimal(range['amount'].to_s) + else + result_amount += (event_value * BigDecimal(range['rate'].to_s) / 100) * fx_rate + end + elsif amount_property != nil + # The aggregation uses an amount logic + result_amount += BigDecimal(amount_property.to_s) + + elsif rate_property != nil + min_amount = BigDecimal(min_amount_property.to_s) + + # The aggregation uses a rate logic + amount = event_value * BigDecimal(rate_property.to_s) / 100 + amount = min_amount if amount < min_amount + + result_amount += amount + end + + { total_units: total_units, amount: result_amount } + end + RUBY + end + + let(:charge_filter_eur_inbound) do + end + + let(:billable_metric_currency_filter) do + create(:billable_metric_filter, billable_metric:, key: "currency", values: %w[gbp eur chf]) + end + + let(:billable_metric_direction_filter) do + create(:billable_metric_filter, billable_metric:, key: "direction", values: %w[inbound outbound]) + end + + let(:billable_metric_scheme_filter) do + create(:billable_metric_filter, billable_metric:, key: "scheme", values: %w[sepa swift bacs sic fps]) + end + + let(:charge) do + create( + :custom_charge, + billable_metric:, + plan:, + pay_in_advance:, + properties: {custom_properties: {}} + ) + end + + let(:eur_sepa_filter) do + create( + :charge_filter, + charge:, + properties: {custom_properties: {amount: "1"}} + ) + end + + let(:eur_swift_inbound_filter) do + create( + :charge_filter, + charge:, + properties: {custom_properties: {amount: "15"}} + ) + end + + let(:eur_swift_outbound_filter) do + create( + :charge_filter, + charge:, + properties: {custom_properties: {amount: "25"}} + ) + end + + let(:chf_filter) do + create( + :charge_filter, + charge:, + properties: { + custom_properties: { + ranges: [ + {from: 0, to: 10_000, rate: "0.4"}, + {from: 10_001, to: 15_000, rate: "0.3"}, + {from: 15_001, to: 22_000, rate: "0.25"}, + {from: 22_001, to: nil, rate: "0.2"} + ], + fx_rate: 0.88 + } + } + ) + end + + let(:gbp_swift_inbound_filter) do + create( + :charge_filter, + charge:, + properties: {custom_properties: {amount: "25"}} + ) + end + + let(:gbp_swift_outbound_filter) do + create( + :charge_filter, + charge:, + properties: {custom_properties: {rate: "0.2", min_amount: "25"}} + ) + end + + let(:gbp_domestic_fps_filter) do + create( + :charge_filter, + charge:, + properties: { + custom_properties: { + ranges: [ + {from: 0, to: 10_000, amount: "0.6"}, + {from: 10_001, to: 15_000, amount: "0.5"}, + {from: 15_001, to: 22_000, amount: "0.45"}, + {from: 22_001, to: nil, amount: "0.4"} + ] + } + } + ) + end + + let(:gbp_domestic_bacs_filter) do + create( + :charge_filter, + charge:, + properties: { + custom_properties: {amount: "25"} + } + ) + end + + before do + # EUR SEPA + create( + :charge_filter_value, + charge_filter: eur_sepa_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["eur"] + ) + create( + :charge_filter_value, + charge_filter: eur_sepa_filter, + billable_metric_filter: billable_metric_scheme_filter, + values: ["sepa"] + ) + + # EUR SWIFT inbound + create( + :charge_filter_value, + charge_filter: eur_swift_inbound_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["eur"] + ) + create( + :charge_filter_value, + charge_filter: eur_swift_inbound_filter, + billable_metric_filter: billable_metric_scheme_filter, + values: ["swift"] + ) + create( + :charge_filter_value, + charge_filter: eur_swift_inbound_filter, + billable_metric_filter: billable_metric_direction_filter, + values: ["inbound"] + ) + + # EUR SWIFT outbound + create( + :charge_filter_value, + charge_filter: eur_swift_outbound_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["eur"] + ) + create( + :charge_filter_value, + charge_filter: eur_swift_outbound_filter, + billable_metric_filter: billable_metric_scheme_filter, + values: ["swift"] + ) + create( + :charge_filter_value, + charge_filter: eur_swift_outbound_filter, + billable_metric_filter: billable_metric_direction_filter, + values: ["outbound"] + ) + + # CHF + create( + :charge_filter_value, + charge_filter: chf_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["chf"] + ) + + # GBP Swift inbound + create( + :charge_filter_value, + charge_filter: gbp_swift_inbound_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["gbp"] + ) + create( + :charge_filter_value, + charge_filter: gbp_swift_inbound_filter, + billable_metric_filter: billable_metric_scheme_filter, + values: ["swift"] + ) + create( + :charge_filter_value, + charge_filter: gbp_swift_inbound_filter, + billable_metric_filter: billable_metric_direction_filter, + values: ["inbound"] + ) + + # GBP Swift outbound + create( + :charge_filter_value, + charge_filter: gbp_swift_outbound_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["gbp"] + ) + create( + :charge_filter_value, + charge_filter: gbp_swift_outbound_filter, + billable_metric_filter: billable_metric_scheme_filter, + values: ["swift"] + ) + create( + :charge_filter_value, + charge_filter: gbp_swift_outbound_filter, + billable_metric_filter: billable_metric_direction_filter, + values: ["outbound"] + ) + + # GBP Domestic FPS + create( + :charge_filter_value, + charge_filter: gbp_domestic_fps_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["gbp"] + ) + create( + :charge_filter_value, + charge_filter: gbp_domestic_fps_filter, + billable_metric_filter: billable_metric_scheme_filter, + values: ["fps"] + ) + + # GBP Domestic BACS + create( + :charge_filter_value, + charge_filter: gbp_domestic_bacs_filter, + billable_metric_filter: billable_metric_currency_filter, + values: ["gbp"] + ) + create( + :charge_filter_value, + charge_filter: gbp_domestic_bacs_filter, + billable_metric_filter: billable_metric_scheme_filter, + values: ["bacs"] + ) + end + + it "create fees for each event" do + travel_to(DateTime.new(2024, 2, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + # GBP FPS inbound + travel_to(DateTime.new(2024, 2, 6, 1)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 10_000, + direction: "inbound", + scheme: "fps", + currency: "gbp" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(1) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(1) + + fee = subscription.fees.find_by(charge:) + expect(fee.amount_cents).to eq(60) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + travel_to(DateTime.new(2024, 2, 6, 3)) do + create( + :cached_aggregation, + organization:, + external_subscription_id: subscription.external_id, + timestamp: DateTime.new(2024, 2, 6, 2), + charge:, + charge_filter: gbp_domestic_fps_filter, + current_aggregation: 10_000, + max_aggregation: 10_000, + current_amount: 600_000 + ) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 10_000, + direction: "outbound", + scheme: "fps", + currency: "gbp" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(2) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(3) + + fee = subscription.fees.where(charge:).order(created_at: :desc).first + expect(fee.amount_cents).to eq(50) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + # GBP BACS inbound + travel_to(DateTime.new(2024, 2, 6, 4)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 2_000_000, + direction: "intbound", + scheme: "bacs", + currency: "gbp" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(3) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(4) + + fee = subscription.fees.where(charge:).order(created_at: :desc).first + expect(fee.amount_cents).to eq(2500) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + # GBP SWIFT + travel_to(DateTime.new(2024, 2, 6, 5)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 10_000, + direction: "outbound", + scheme: "swift", + currency: "gbp" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(4) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(5) + + fee = subscription.fees.where(charge:).order(created_at: :desc).first + expect(fee.amount_cents).to eq(2_500) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + travel_to(DateTime.new(2024, 2, 6, 5, 1)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 100_000, + direction: "outbound", + scheme: "swift", + currency: "gbp" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(5) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(6) + + fee = subscription.fees.where(charge:).order(created_at: :desc).first + expect(fee.amount_cents).to eq(20_000) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + # SEPA EUR + travel_to(DateTime.new(2024, 2, 6, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 10_000, + direction: "outbound", + scheme: "sepa", + currency: "eur" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(6) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(7) + + fee = subscription.fees.where(charge:).order(created_at: :desc).first + expect(fee.amount_cents).to eq(100) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + # CHF + travel_to(DateTime.new(2024, 2, 6, 7)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 100_000, + direction: "outbound", + scheme: "sic", + currency: "chf" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(7) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(8) + + fee = subscription.fees.where(charge:).order(created_at: :desc).first + expect(fee.amount_cents).to eq(35_200) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + travel_to(DateTime.new(2024, 2, 6, 9)) do + create( + :cached_aggregation, + organization:, + external_subscription_id: subscription.external_id, + timestamp: DateTime.new(2024, 2, 6, 8), + charge:, + charge_filter: chf_filter, + current_aggregation: 10_000, + max_aggregation: 10_000, + current_amount: 600_000 + ) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + properties: { + value: 1000, + direction: "outbound", + scheme: "sic", + currency: "chf" + } + } + ) + + perform_all_enqueued_jobs + + expect(subscription.fees.count).to eq(8) + expect(CachedAggregation.where(organization_id: organization.id).count).to eq(10) + + fee = subscription.fees.where(charge:).order(created_at: :desc).first + expect(fee.amount_cents).to eq(264) + expect(fee.events_count).to eq(1) + expect(fee.units).to eq(1) + end + + # Fetch current usage to make sure the aggregation is correct + travel_to(DateTime.new(2024, 2, 6, 10)) do + fetch_current_usage(customer:) + + expect(json[:customer_usage][:total_amount_cents]).to eq(60_772) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("8.0") + expect(charge_usage[:events_count]).to eq(8) + expect(charge_usage[:amount_cents]).to eq(60_772) + expect(charge_usage[:filters].count).to eq(9) + + gbp_domestic_fps_charge = charge_usage[:filters].find do |f| + f[:values] == gbp_domestic_fps_filter.to_h.symbolize_keys + end + expect(gbp_domestic_fps_charge[:events_count]).to eq(2) + expect(gbp_domestic_fps_charge[:units]).to eq("2.0") + expect(gbp_domestic_fps_charge[:amount_cents]).to eq(120) + + gbp_domestic_bacs_charge = charge_usage[:filters].find do |f| + f[:values] == gbp_domestic_bacs_filter.to_h.symbolize_keys + end + expect(gbp_domestic_bacs_charge[:events_count]).to eq(1) + expect(gbp_domestic_bacs_charge[:units]).to eq("1.0") + expect(gbp_domestic_bacs_charge[:amount_cents]).to eq(2500) + + gbp_swift_outbound_charge = charge_usage[:filters].find do |f| + f[:values] == gbp_swift_outbound_filter.to_h.symbolize_keys + end + expect(gbp_swift_outbound_charge[:events_count]).to eq(2) + expect(gbp_swift_outbound_charge[:units]).to eq("2.0") + expect(gbp_swift_outbound_charge[:amount_cents]).to eq(22_500) + + eur_sepa_charge = charge_usage[:filters].find do |f| + f[:values] == eur_sepa_filter.to_h.symbolize_keys + end + expect(eur_sepa_charge[:events_count]).to eq(1) + expect(eur_sepa_charge[:units]).to eq("1.0") + expect(eur_sepa_charge[:amount_cents]).to eq(100) + + eur_swift_inbound_charge = charge_usage[:filters].find do |f| + f[:values] == eur_swift_inbound_filter.to_h.symbolize_keys + end + expect(eur_swift_inbound_charge[:events_count]).to eq(0) + expect(eur_swift_inbound_charge[:units]).to eq("0.0") + expect(eur_swift_inbound_charge[:amount_cents]).to eq(0) + + eur_swift_outbound_charge = charge_usage[:filters].find do |f| + f[:values] == eur_swift_outbound_filter.to_h.symbolize_keys + end + expect(eur_swift_outbound_charge[:events_count]).to eq(0) + expect(eur_swift_outbound_charge[:units]).to eq("0.0") + expect(eur_swift_outbound_charge[:amount_cents]).to eq(0) + + chf_charge = charge_usage[:filters].find do |f| + f[:values] == chf_filter.to_h.symbolize_keys + end + expect(chf_charge[:events_count]).to eq(2) + expect(chf_charge[:units]).to eq("2.0") + expect(chf_charge[:amount_cents]).to eq(35_552) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_aggregation_type/latest_agg_spec.rb b/spec/scenarios/current_usage/by_aggregation_type/latest_agg_spec.rb new file mode 100644 index 0000000..6366fbb --- /dev/null +++ b/spec/scenarios/current_usage/by_aggregation_type/latest_agg_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Aggregation - Latest Scenarios", transaction: false do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:latest_billable_metric, organization:) } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with standard charge" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "5"}) + end + + it "returns the latest value as usage" do + travel_to(DateTime.new(2024, 3, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 5, 1)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {item_id: "10"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("10.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(5000) + end + + travel_to(DateTime.new(2024, 3, 5, 2)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {item_id: "3"} + } + ) + + fetch_current_usage(customer:) + # Latest replaces previous value + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("3.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(1500) + end + + travel_to(DateTime.new(2024, 3, 5, 3)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {item_id: "50"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("50.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(25_000) + expect(json[:customer_usage][:charges_usage].first[:events_count]).to eq(3) + end + end + end + + context "with zero usage" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "5"}) + end + + it "returns zero usage when no events" do + travel_to(DateTime.new(2024, 3, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 5)) do + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("0.0") + expect(charge_usage[:events_count]).to eq(0) + expect(charge_usage[:amount_cents]).to eq(0) + end + end + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_aggregation_type/max_agg_spec.rb b/spec/scenarios/current_usage/by_aggregation_type/max_agg_spec.rb new file mode 100644 index 0000000..aaebf69 --- /dev/null +++ b/spec/scenarios/current_usage/by_aggregation_type/max_agg_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Aggregation - Max Scenarios", transaction: false do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:max_billable_metric, organization:) } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with standard charge" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + end + + it "returns the max value as usage" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {item_id: "5"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("5.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(5000) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {item_id: "20"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("20.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(20_000) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {item_id: "3"} + } + ) + + fetch_current_usage(customer:) + # Max stays at 20 even though last event was 3 + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("20.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(20_000) + expect(json[:customer_usage][:charges_usage].first[:events_count]).to eq(3) + end + end + end + + context "with zero usage" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + end + + it "returns zero usage when no events" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("0.0") + expect(charge_usage[:events_count]).to eq(0) + expect(charge_usage[:amount_cents]).to eq(0) + end + end + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_aggregation_type/sum_agg_spec.rb b/spec/scenarios/current_usage/by_aggregation_type/sum_agg_spec.rb new file mode 100644 index 0000000..dc10fe7 --- /dev/null +++ b/spec/scenarios/current_usage/by_aggregation_type/sum_agg_spec.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Aggregation - Sum Scenarios", transaction: false do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization:) } + + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:sum_billable_metric, :recurring, organization:) } + + before { charge } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with in advance charge and groups" do + let(:charge) do + create( + :standard_charge, + billable_metric:, + plan:, + prorated: true, + pay_in_advance: true, + properties: { + amount: "29", + grouped_by: %w[key_1 key_2 key_3] + } + ) + end + + it "creates fees for each events" do + travel_to(DateTime.new(2024, 2, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 6, 1)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => 10, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + expect(Fee.count).to eq(1) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("10.0") + end + + travel_to(DateTime.new(2024, 2, 6, 2)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => -5, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + expect(Fee.count).to eq(2) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("5.0") + end + + travel_to(DateTime.new(2024, 2, 6, 3)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => 2, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + expect(Fee.count).to eq(3) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("7.0") + end + end + end + + context "with multiple subscriptions attached to the same plan" do + let(:charge) do + create( + :standard_charge, + billable_metric:, + plan:, + prorated: true, + pay_in_advance: true, + properties: { + amount: "29", + grouped_by: %w[key_1 key_2 key_3] + } + ) + end + + it "creates fees for each events" do + travel_to(DateTime.new(2024, 2, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "#{customer.external_id}_1", + plan_code: plan.code + } + ) + end + + subscription1 = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 1, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "#{customer.external_id}_2", + plan_code: plan.code + } + ) + end + + subscription2 = customer.subscriptions.order(:created_at).last + + travel_to(DateTime.new(2024, 2, 6, 0, 1)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription1.external_id, + properties: { + "item_id" => 10, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + expect(Fee.count).to eq(1) + + fetch_current_usage(customer:, subscription: subscription1) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("10.0") + end + + travel_to(DateTime.new(2024, 2, 6, 0, 2)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription1.external_id, + properties: { + "item_id" => -5, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + expect(Fee.count).to eq(2) + + fetch_current_usage(customer:, subscription: subscription1) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("5.0") + end + + travel_to(DateTime.new(2024, 2, 6, 0, 3)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription1.external_id, + properties: { + "item_id" => 2, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + expect(Fee.count).to eq(3) + + fetch_current_usage(customer:, subscription: subscription1) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("7.0") + end + + travel_to(DateTime.new(2024, 2, 6, 1, 0, 1)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription2.external_id, + properties: { + "item_id" => 10, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + fetch_current_usage(customer:, subscription: subscription2) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("10.0") + end + + travel_to(DateTime.new(2024, 2, 6, 1, 0, 2)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription2.external_id, + properties: { + "item_id" => -5, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + fetch_current_usage(customer:, subscription: subscription2) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("5.0") + end + + travel_to(DateTime.new(2024, 2, 6, 1, 0, 3)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription2.external_id, + properties: { + "item_id" => 2, + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + } + } + ) + + fetch_current_usage(customer:, subscription: subscription2) + expect(json[:customer_usage][:total_amount_cents]).to eq(24_000) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("7.0") + end + end + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_aggregation_type/unique_count_agg_spec.rb b/spec/scenarios/current_usage/by_aggregation_type/unique_count_agg_spec.rb new file mode 100644 index 0000000..c71ccb5 --- /dev/null +++ b/spec/scenarios/current_usage/by_aggregation_type/unique_count_agg_spec.rb @@ -0,0 +1,368 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Aggregation - Unique Count Scenarios", transaction: false do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization:) } + + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:unique_count_billable_metric, :recurring, organization:) } + let(:charge) do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "1", grouped_by: %w[key_1 key_2 key_3]}) + end + + before { charge } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + it "creates fees and keeps the units between periods" do + travel_to(DateTime.new(2024, 2, 6)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 7)) do + create_event({ + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => "001", + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "08" + }, + timestamp: Time.zone.parse("2024-02-07 00:00:00.000").to_f + }) + + create_event({ + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => "001", + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + }, + timestamp: Time.zone.parse("2024-02-07 00:00:02.000").to_f + }) + + create_event({ + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => "002", + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + }, + timestamp: Time.zone.parse("2024-02-07 00:00:03.000").to_f + }) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(300) + end + end + + context "with in advance charge and group by" do + let(:charge) do + create( + :standard_charge, + billable_metric:, + plan:, + prorated: true, + pay_in_advance: true, + properties: { + amount: "29", + grouped_by: %w[key_1 key_2 key_3] + } + ) + end + + it "creates fees for each events" do + travel_to(DateTime.new(2024, 2, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 1)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => "001", + "operation_type" => "remove", + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + }, + timestamp: Time.zone.parse("2024-02-01T01:00:00").to_f + } + ) + + expect(Fee.count).to eq(1) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => "001", + "operation_type" => "add", + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + }, + timestamp: Time.zone.parse("2024-02-01T01:00:10").to_f + } + ) + + expect(Fee.count).to eq(2) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(2900) + end + + travel_to(DateTime.new(2024, 2, 2)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => "001", + "operation_type" => "add", + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + }, + timestamp: Time.zone.parse("2024-02-02T01:00:00").to_f + } + ) + + expect(Fee.count).to eq(3) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(2900) + end + + travel_to(DateTime.new(2024, 2, 3)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + "item_id" => "001", + "operation_type" => "remove", + "key_1" => "2024", + "key_2" => "Feb", + "key_3" => "06" + }, + timestamp: Time.zone.parse("2024-02-03T01:00:00").to_f + } + ) + + expect(Fee.count).to eq(4) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(2900) + end + end + end + + context "with prorated in advance charge" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + prorated: true, + pay_in_advance: true, + properties: {amount: "1"} + ) + end + + it "returns the expected result" do + travel_to(DateTime.new(2024, 2, 23, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 23, 1, 2)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {"item_id" => "seat_1"}, + timestamp: Time.zone.parse("2024-02-23T01:02:00").to_f + } + ) + + expect(Fee.count).to eq(1) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(24) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("1.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(24) # (7 / 29) * 1 + end + + travel_to(DateTime.new(2024, 2, 29, 1, 1)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {"item_id" => "seat_1", "operation_type" => "remove"}, + timestamp: Time.zone.parse("2024-02-29T01:01:00").to_f + } + ) + + expect(Fee.count).to eq(2) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(24) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(24) # (7 / 29) * 1 + end + + travel_to(DateTime.new(2024, 2, 29, 1, 2)) do + # NOTE: Remove once again the seat, it should not impact the current usage + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {"item_id" => "seat_1", "operation_type" => "remove"}, + timestamp: Time.zone.parse("2024-02-29T01:02:00").to_f + } + ) + + expect(Fee.count).to eq(3) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(24) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(24) # (7 / 29) * 1 + end + end + end + + context "with in advance charge" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + prorated: false, + pay_in_advance: true, + properties: {amount: "1"} + ) + end + + it "returns the expected result" do + travel_to(DateTime.new(2024, 2, 23, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar", + started_at: Time.zone.parse("2024-02-01T01:00:00") + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2024, 2, 23, 1, 2)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {"item_id" => "seat_1"}, + timestamp: Time.zone.parse("2024-02-23T01:02:00").to_f + } + ) + + expect(Fee.count).to eq(1) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(100) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("1.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(100) + end + + travel_to(DateTime.new(2024, 2, 26, 1, 1)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {"item_id" => "seat_1", "operation_type" => "remove"}, + timestamp: Time.zone.parse("2024-02-26T01:01:00").to_f + } + ) + + expect(Fee.count).to eq(2) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(100) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(100) + end + + travel_to(DateTime.new(2024, 2, 26, 1, 2)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {"item_id" => "seat_2"}, + timestamp: Time.zone.parse("2024-02-26T01:01:00").to_f + } + ) + + expect(Fee.count).to eq(3) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(100) + expect(json[:customer_usage][:charges_usage].first[:units]).to eq("1.0") + expect(json[:customer_usage][:charges_usage].first[:amount_cents]).to eq(100) + end + end + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_aggregation_type/weighted_sum_agg_spec.rb b/spec/scenarios/current_usage/by_aggregation_type/weighted_sum_agg_spec.rb new file mode 100644 index 0000000..d3b67cd --- /dev/null +++ b/spec/scenarios/current_usage/by_aggregation_type/weighted_sum_agg_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Aggregation - Weighted Sum Scenarios", transaction: false do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization:) } + + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:weighted_sum_billable_metric, :recurring, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "1"}) } + + before { charge } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + it "creates fees and keeps the units between periods" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2023, 3, 5)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {value: "2500"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(217_742) + + # TODO: make sure Clickhouse has the same precision as Postgres + + expected_units = (store == :clickhouse) ? "2177.41935483870967741936" : "2177.4193548387096774" + expect(json[:customer_usage][:charges_usage][0][:units]).to eq(expected_units) + end + + travel_to(DateTime.new(2023, 4, 1)) do + expect { perform_billing } + .to change { subscription.reload.invoices.count }.from(0).to(1) + .and change { organization.reload.cached_aggregations.count }.from(0).to(1) + end + + invoice = subscription.invoices.first + expect(invoice.fees.charge.count).to eq(1) + + fee = invoice.fees.charge.first + expect(fee.amount_cents).to eq(217_742) + expect(fee.units.round).to eq(2177) + expect(fee.total_aggregated_units).to eq(2500) + + cached_aggregation = CachedAggregation.order(created_at: :desc).first + expect(cached_aggregation.current_aggregation).to eq(2500.0) + + travel_to(DateTime.new(2023, 4, 4)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {value: "-2000"} + } + ) + end + + travel_to(DateTime.new(2023, 4, 6)) do + create_event( + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {value: "-200"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(53_333) + + # TODO: make sure Clickhouse has the same precision as Postgres + expected_units = (store == :clickhouse) ? "533.33333333333338308117" : "533.3333333333333333" + expect(json[:customer_usage][:charges_usage][0][:units]).to eq(expected_units) + end + + travel_to(DateTime.new(2023, 5, 1)) do + expect { perform_billing } + .to change { subscription.reload.invoices.count }.from(1).to(2) + .and change { organization.reload.cached_aggregations.count }.from(1).to(2) + end + + invoice = subscription.invoices.order(:created_at).last + expect(invoice.fees.charge.count).to eq(1) + + fee = invoice.fees.charge.first + expect(fee.amount_cents).to eq(53_333) + expect(fee.units.round(5)).to eq(533.33333) + expect(fee.total_aggregated_units).to eq(300) + + cached_aggregation = CachedAggregation.order(:created_at).last + expect(cached_aggregation.current_aggregation).to eq(300.0) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/clickhouse/prorated_graduated_spec.rb b/spec/scenarios/current_usage/by_charge_model/clickhouse/prorated_graduated_spec.rb new file mode 100644 index 0000000..e71f845 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/clickhouse/prorated_graduated_spec.rb @@ -0,0 +1,1486 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Prorated Graduated Scenarios", clickhouse: true, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: true) } + let(:customer) { create(:customer, organization:, name: "aaaaaabcd") } + let(:tax) { create(:tax, organization:, rate: 0) } + + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:billable_metric, recurring: true, organization:, aggregation_type:, field_name:) } + + before { tax } + + describe "with sum_agg" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + describe "three ranges and one overflow case" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: 15, + per_unit_amount: "5", + flat_amount: "50" + }, + { + from_value: 16, + to_value: nil, + per_unit_amount: "2", + flat_amount: "0" + } + ] + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + + travel_to(DateTime.new(2023, 9, 10)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2"}, + value: "2", + decimal_value: 2, + timestamp: Time.zone.parse("2023-09-10 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_400) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_400) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2023, 9, 16)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "5"}, + value: "5", + decimal_value: 5, + timestamp: Time.zone.parse("2023-09-16 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_400) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_400) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("7.0") + end + + travel_to(DateTime.new(2023, 9, 20)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "-6"}, + value: "-6", + decimal_value: -6, + timestamp: Time.zone.parse("2023-09-20 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(16_567) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(16_567) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 9, 25)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"}, + value: "10", + decimal_value: 10, + timestamp: Time.zone.parse("2023-09-25 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_967) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_967) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("11.0") + end + + travel_to(DateTime.new(2023, 9, 26)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"}, + value: "4", + decimal_value: 4, + timestamp: Time.zone.parse("2023-09-26 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_300) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_300) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("15.0") + end + + travel_to(DateTime.new(2023, 9, 30)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "60"}, + value: "60", + decimal_value: 60, + timestamp: Time.zone.parse("2023-09-30 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(18_700) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 17)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "20"}, + value: "20", + decimal_value: 20, + timestamp: Time.zone.parse("2023-10-17 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("95.0") + end + end + + context "when there are old events before first invoice" do + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 12, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.active.order(created_at: :desc).first + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "12", + flat_amount: "0" + } + ] + } + ) + + travel_to(DateTime.new(2023, 12, 2)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"}, + value: "5", + decimal_value: 5, + timestamp: Time.zone.parse("2023-11-17 00:00:00.000") + ) + + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"}, + value: "5", + decimal_value: 5, + timestamp: Time.zone.parse("2023-11-17 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + end + + travel_to(DateTime.new(2024, 1, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(6_000) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2024, 1, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + end + + travel_to(DateTime.new(2024, 1, 6)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2"}, + value: "2", + decimal_value: 2, + timestamp: Time.zone.parse("2024-01-06 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(8_013) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(8_013) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("12.0") + end + end + end + + context "when there are old events before first invoice and subscription is terminated" do + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 10, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.active.order(created_at: :desc).first + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "10", + flat_amount: "0" + } + ] + } + ) + + travel_to(DateTime.new(2023, 10, 5)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "4"}, + value: "4", + decimal_value: 4, + timestamp: Time.zone.parse("2023-10-05 00:00:00.000") + ) + + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "3"}, + value: "3", + decimal_value: 3, + timestamp: Time.zone.parse("2023-10-05 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 11, 5)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "-1"}, + value: "-1", + decimal_value: -1, + timestamp: Time.zone.parse("2023-11-05 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 12, 7)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(1_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(1_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("6.0") + + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1"}, + value: "1", + decimal_value: 1, + timestamp: Time.zone.parse("2023-12-07 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(1_806) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(1_806) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("7.0") + + Subscriptions::TerminateService.call(subscription:) + perform_all_enqueued_jobs + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(258) + expect(invoice.issuing_date.iso8601).to eq("2023-12-07") + end + end + end + + context "when upgrade is performed" do + let(:plan_new) { create(:plan, organization:, amount_cents: 100) } + + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: 15, + per_unit_amount: "5", + flat_amount: "50" + }, + { + from_value: 16, + to_value: nil, + per_unit_amount: "2", + flat_amount: "0" + } + ] + } + ) + + travel_to(DateTime.new(2023, 9, 10)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2"}, + value: "2", + decimal_value: 2, + timestamp: Time.zone.parse("2023-09-10 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 9, 16)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "5"}, + value: "5", + decimal_value: 5, + timestamp: Time.zone.parse("2023-09-16 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 9, 20)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "-6"}, + value: "-6", + decimal_value: -6, + timestamp: Time.zone.parse("2023-09-20 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 9, 25)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"}, + value: "10", + decimal_value: 10, + timestamp: Time.zone.parse("2023-09-25 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 9, 26)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"}, + value: "4", + decimal_value: 4, + timestamp: Time.zone.parse("2023-09-26 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 9, 30)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "60"}, + value: "60", + decimal_value: 60, + timestamp: Time.zone.parse("2023-09-30 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(18_700) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 17)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "20"}, + value: "20", + decimal_value: 20, + timestamp: Time.zone.parse("2023-10-17 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("95.0") + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2023, 10, 18, 5, 20)) do + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan: plan_new, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: 15, + per_unit_amount: "5", + flat_amount: "50" + }, + { + from_value: 16, + to_value: nil, + per_unit_amount: "2", + flat_amount: "0" + } + ] + } + ) + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code + } + ) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + + invoice = subscription.invoices.order(created_at: :desc).first + expect(invoice.fees.charge.count).to eq(1) + + # 30226 (17 / 31 * 75 units) + 2.58 (2 / 31 * 20 units - prorated event in termination period) + expect(invoice.total_amount_cents).to eq(27_323) + end + + travel_to(DateTime.new(2023, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.order(created_at: :desc).first + invoice = subscription.invoices.order(created_at: :desc).first + + # (95 units * 14/31) -> 26_742 - charge fee + # 100 * 14/31 -> 45 -> subscription fee + expect(invoice.total_amount_cents).to eq(26_742 + 45) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(41_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(41_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("95.0") + end + end + end + end + end + + describe "with unique_count_agg" do + let(:aggregation_type) { "unique_count_agg" } + let(:field_name) { "amount" } + + describe "two ranges" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 1, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 2, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + + travel_to(DateTime.new(2025, 9, 10)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "1111", operation_type: "add" + }, + value: "1111", + timestamp: Time.zone.parse("2025-09-10 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2025, 9, 12)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "1111", operation_type: "remove" + }, + value: "1111", + timestamp: Time.zone.parse("2025-09-12 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_100) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_100) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + end + + travel_to(DateTime.new(2025, 9, 14)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "1111", operation_type: "add" + }, + value: "1111", + timestamp: Time.zone.parse("2025-09-14 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_667) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_667) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2025, 9, 15)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "2222", operation_type: "add" + }, + value: "2222", + timestamp: Time.zone.parse("2025-09-15 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2025, 9, 16)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "2222", operation_type: "add" + }, + value: "2222", + timestamp: Time.zone.parse("2025-09-16 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2025, 9, 20)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "3333", operation_type: "add" + }, + value: "3333", + timestamp: Time.zone.parse("2025-09-20 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(16_117) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(16_117) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("3.0") + end + + travel_to(DateTime.new(2025, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(16_117) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2025, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_000) # 100 + 10 + 50 + 5 + 5 + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("3.0") + end + + travel_to(DateTime.new(2025, 10, 17)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "4444", operation_type: "add" + }, + value: "4444", + timestamp: Time.zone.parse("2025-10-17 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_242) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_242) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + + travel_to(DateTime.new(2025, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_500) # 100 + 10 + 50 + 5 + 5 + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_500) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + end + end + + describe "two ranges and removal in another interval" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 1, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 2, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + + travel_to(DateTime.new(2023, 9, 10)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "1111", operation_type: "add" + }, + value: "1111", + timestamp: Time.zone.parse("2023-09-10 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(10_700) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 10, 17)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "1111", operation_type: "remove" + }, + value: "1111", + timestamp: Time.zone.parse("2023-10-17 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_548) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_548) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + end + + travel_to(DateTime.new(2023, 10, 19, 0, 0, 0)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "2222", operation_type: "add" + }, + value: "2222", + timestamp: Time.zone.parse("2023-10-19 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_968) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_968) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.order(created_at: :desc).first + + expect(invoice.total_amount_cents).to eq(10_968) + expect(subscription.reload.invoices.count).to eq(2) + end + + travel_to(DateTime.new(2023, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + end + end + + context "with multiple events on the same day" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + + travel_to(DateTime.new(2023, 10, 10)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "1111", operation_type: "add" + }, + value: "1111", + timestamp: Time.zone.parse("2023-10-10 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_710) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_710) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "2222", operation_type: "add" + }, + value: "2222", + timestamp: Time.zone.parse("2023-10-20 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_097) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_097) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "3333", operation_type: "add" + }, + value: "3333", + timestamp: Time.zone.parse("2023-10-20 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_484) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_484) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("3.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "4444", operation_type: "add" + }, + value: "4444", + timestamp: Time.zone.parse("2023-10-20 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_871) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_871) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "5555", operation_type: "add" + }, + value: "5555", + timestamp: Time.zone.parse("2023-10-20 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(12_258) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(12_258) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("5.0") + end + + travel_to(DateTime.new(2023, 10, 25)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "6666", operation_type: "add" + }, + value: "6666", + timestamp: Time.zone.parse("2023-10-25 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_371) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_371) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("6.0") + end + + travel_to(DateTime.new(2023, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(17_371) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(20_500) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(20_500) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("6.0") + end + end + + context "when there are old events before first invoice and subscription is terminated" do + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 10, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.active.order(created_at: :desc).first + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 1, + per_unit_amount: "5", + flat_amount: "10" + }, + { + from_value: 2, + to_value: nil, + per_unit_amount: "15", + flat_amount: "30" + } + ] + } + ) + + travel_to(DateTime.new(2023, 10, 5)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "1111", operation_type: "add" + }, + value: "1111", + timestamp: Time.zone.parse("2023-10-05 00:00:00.000") + ) + end + + travel_to(DateTime.new(2023, 12, 7)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(1_500) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(1_500) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + amount: "2222", operation_type: "add" + }, + value: "2222", + timestamp: Time.zone.parse("2023-12-07 00:00:00.000") + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(5_710) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(5_710) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + + Subscriptions::TerminateService.call(subscription:) + perform_all_enqueued_jobs + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(4_161) + expect(invoice.issuing_date.iso8601).to eq("2023-12-07") + end + end + end + end + + context "when item is removed before billing period" do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: true) } + let(:field_name) { "user_id" } + + let(:charge) do + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 1, per_unit_amount: "0", flat_amount: "0"}, + {from_value: 2, to_value: nil, per_unit_amount: "10", flat_amount: "0"} + ] + } + ) + end + + before { charge } + + it "does not count removed user in tier assignment" do + travel_to(DateTime.new(2024, 10, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 10, 5)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "73283", operation_type: "add"}, + value: "73283", + timestamp: Time.zone.parse("2024-10-05 10:00:00.000") + ) + end + + travel_to(DateTime.new(2024, 10, 6)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "73290", operation_type: "add"}, + value: "73290", + timestamp: Time.zone.parse("2024-10-06 10:00:00.000") + ) + end + + travel_to(DateTime.new(2024, 10, 7)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "78924", operation_type: "add"}, + value: "78924", + timestamp: Time.zone.parse("2024-10-07 10:00:00.000") + ) + end + + travel_to(DateTime.new(2024, 10, 25)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "73290", operation_type: "remove"}, + value: "73290", + timestamp: Time.zone.parse("2024-10-25 10:00:00.000") + ) + end + + travel_to(DateTime.new(2024, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(subscription.invoices.count).to eq(1) + expect(invoice).to be_present + end + + travel_to(DateTime.new(2024, 11, 15)) do + fetch_current_usage(customer:) + + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + expect(json[:customer_usage][:amount_cents]).to eq(1000) + end + + travel_to(DateTime.new(2024, 12, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.order(created_at: :desc).first + + expect(invoice.total_amount_cents).to eq(1000) + end + end + end + + # Customer use-case + context "with combinations of add and remove" do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: true) } + let(:field_name) { "user_id" } + + let(:charge) do + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 3, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 4, + to_value: nil, + per_unit_amount: "10", + flat_amount: "0" + } + ] + } + ) + end + + before { charge } + + it "returns expected usage" do + travel_to(Time.zone.parse("2025-05-21 04:31:39")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + end + + travel_to(DateTime.new(2025, 5, 21)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + user_id: "49303", operation_type: "add" + }, + value: "49303", + timestamp: Time.zone.parse("2025-05-21 00:00:00.000") + ) + end + + travel_to(DateTime.new(2025, 5, 31)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + user_id: "49067", operation_type: "add" + }, + value: "49067", + timestamp: Time.zone.parse("2025-05-31 00:00:00.000") + ) + end + + travel_to(DateTime.new(2025, 7, 23)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + user_id: "62741", operation_type: "add" + }, + value: "62741", + timestamp: Time.zone.parse("2025-07-23 00:00:00.000") + ) + end + + travel_to(DateTime.new(2025, 7, 24)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + user_id: "49067", operation_type: "remove" + }, + value: "49067", + timestamp: Time.zone.parse("2025-07-24 00:00:00.000") + ) + end + + travel_to(DateTime.new(2025, 7, 24)) do + Clickhouse::EventsEnriched.create!( + organization_id: organization.id, + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + user_id: "63011", operation_type: "add" + }, + value: "63011", + timestamp: Time.zone.parse("2025-07-24 00:00:00.000") + ) + end + + travel_to(Time.zone.parse("2025-08-19 18:00:00")) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(0) + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/dynamic_spec.rb b/spec/scenarios/current_usage/by_charge_model/dynamic_spec.rb new file mode 100644 index 0000000..ff976da --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/dynamic_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Dynamic Pricing Scenarios", transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:charge) do + create( + :dynamic_charge, + plan:, + billable_metric:, + properties: charge_properties + ) + end + + let(:charge_properties) { {} } + + before do + organization + customer + billable_metric + plan + charge + end + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2023, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + precise_total_amount_cents: "10.1", + properties: {item_id: "10"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(10) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + precise_total_amount_cents: "901.9", + properties: {item_id: "10"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(912) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("20.0") + end + end + + context "with grouping" do + let(:charge_properties) { {grouped_by: [:group_key]} } + + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2023, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + precise_total_amount_cents: "10.1", + properties: {item_id: "10", group_key: "value 1"} + } + ) + + fetch_current_usage(customer:) + + expect(json[:customer_usage][:total_amount_cents]).to eq(10) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + expect(json[:customer_usage][:charges_usage][0][:grouped_usage][0][:grouped_by]).to eq({group_key: "value 1"}) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + precise_total_amount_cents: "901.9", + properties: {item_id: "10", group_key: "value 2"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(912) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("20.0") + expect(json[:customer_usage][:charges_usage][0][:grouped_usage].size).to eq(2) + + expect(json[:customer_usage][:charges_usage][0][:grouped_usage]).to match_array( + [ + {amount_cents: 902, events_count: 1, total_aggregated_units: "10.0", units: "10.0", grouped_by: {group_key: "value 2"}, filters: [], pricing_unit_details: nil, presentation_breakdowns: []}, + {amount_cents: 10, events_count: 1, total_aggregated_units: "10.0", units: "10.0", grouped_by: {group_key: "value 1"}, filters: [], pricing_unit_details: nil, presentation_breakdowns: []} + ] + ) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/graduated_percentage_spec.rb b/spec/scenarios/current_usage/by_charge_model/graduated_percentage_spec.rb new file mode 100644 index 0000000..69b2ed8 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/graduated_percentage_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Graduated Percentage Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "amount") } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with basic graduated percentage ranges" do + before do + create( + :graduated_percentage_charge, + plan:, + billable_metric:, + properties: { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 100, rate: "5", flat_amount: "0"}, + {from_value: 101, to_value: nil, rate: "2", flat_amount: "10"} + ] + } + ) + end + + it "returns usage within first range" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "50"} + } + ) + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("50.0") + # 50 * 5% = 2.5 => 250 cents + expect(charge_usage[:amount_cents]).to eq(250) + end + end + + it "returns usage spanning multiple ranges" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "150"} + } + ) + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("150.0") + # Range 1: 100 * 5% + 0 = 5.0 + # Range 2: 50 * 2% + 10 = 11.0 + # Total: 16.0 => 1600 cents + expect(charge_usage[:amount_cents]).to eq(1600) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/graduated_spec.rb b/spec/scenarios/current_usage/by_charge_model/graduated_spec.rb new file mode 100644 index 0000000..9463866 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/graduated_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Graduated Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:sum_billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "amount") } + let(:recurring_sum_billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "amount", recurring: true) } + + before { tax } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + describe "with sum_agg" do + describe "non-prorated graduated with no events (ING-13 regression)" do + it "returns zero customer usage without raising" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + plan:, + billable_metric: sum_billable_metric, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "2", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "50"} + ] + } + ) + + travel_to(DateTime.new(2024, 3, 6)) do + # First call exercises the previously-crashing hydrate_non_persistable_fees path + # when the feature flag is enabled. + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(0) + + # Second call proves stability across repeated invocations, matching the + # daily DailyUsages::ComputeJob invocation pattern. + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(0) + end + end + end + + describe "prorated graduated with no events (ING-13 regression)" do + it "returns zero customer usage without raising (factory falls back from ProratedGraduatedService to GraduatedService)" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + plan:, + billable_metric: recurring_sum_billable_metric, + prorated: true, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "2", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "50"} + ] + } + ) + + travel_to(DateTime.new(2024, 3, 6)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(0) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(0) + end + end + end + end + + context "with basic graduated ranges" do + before do + create( + :graduated_charge, + plan:, + billable_metric:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 5, per_unit_amount: "10", flat_amount: "100"}, + {from_value: 6, to_value: nil, per_unit_amount: "5", flat_amount: "50"} + ] + } + ) + end + + it "returns usage within first range" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("3.0") + # 3 * 10 + 100 = 130 => 13000 cents + expect(charge_usage[:amount_cents]).to eq(13_000) + end + end + + it "returns usage spanning multiple ranges" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 8.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("8.0") + # Range 1: 5 * 10 + 100 = 150 + # Range 2: 3 * 5 + 50 = 65 + # Total: 215 => 21500 cents + expect(charge_usage[:amount_cents]).to eq(21_500) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/package_spec.rb b/spec/scenarios/current_usage/by_charge_model/package_spec.rb new file mode 100644 index 0000000..21595f0 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/package_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Package Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with basic package" do + before do + create(:package_charge, plan:, billable_metric:, properties: {amount: "100", free_units: 0, package_size: 10}) + end + + it "returns usage based on packages consumed" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("3.0") + # 3 units / 10 package_size = 1 package (ceil) * 100 = 10000 cents + expect(charge_usage[:amount_cents]).to eq(10_000) + end + end + + it "returns usage for full packages" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 15.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("15.0") + # 15 / 10 = 2 packages * 100 = 20000 cents + expect(charge_usage[:amount_cents]).to eq(20_000) + end + end + end + + context "with free units" do + before do + create(:package_charge, plan:, billable_metric:, properties: {amount: "100", free_units: 5, package_size: 10}) + end + + it "excludes free units from charges" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("3.0") + # 3 units within 5 free units, so no charge + expect(charge_usage[:amount_cents]).to eq(0) + end + end + + it "charges beyond free units" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 12.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("12.0") + # 12 - 5 free = 7 billable / 10 package_size = 1 package * 100 = 10000 cents + expect(charge_usage[:amount_cents]).to eq(10_000) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/percentage_spec.rb b/spec/scenarios/current_usage/by_charge_model/percentage_spec.rb new file mode 100644 index 0000000..f0c0195 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/percentage_spec.rb @@ -0,0 +1,567 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Percentage Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type:, field_name:) } + + before { tax } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + describe "with sum_agg" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + describe "with free_units_per_events and fixed_amount" do + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: {rate: "1", fixed_amount: "5", free_units_per_events: 3} + ) + + travel_to(DateTime.new(2023, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("20.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("30.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents]).to eq(510) + expect(json[:customer_usage][:total_amount_cents]).to eq(612) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("40.0") + end + end + end + + describe "with free_units_per_total_aggregation and fixed_amount" do + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: {rate: "1", fixed_amount: "5", free_units_per_total_aggregation: "15.0"} + ) + + travel_to(DateTime.new(2023, 3, 6, 0, 1)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + + travel_to(DateTime.new(2023, 3, 6, 0, 2)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("8.0") + end + + travel_to(DateTime.new(2023, 3, 6, 0, 3)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("12.0") + end + + travel_to(DateTime.new(2023, 3, 6, 0, 4)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents]).to eq(501) + expect(json[:customer_usage][:total_amount_cents]).to eq(601) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("16.0") + end + + travel_to(DateTime.new(2023, 3, 6, 0, 5)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents]).to eq(1011) + expect(json[:customer_usage][:total_amount_cents]).to eq(1213) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("26.0") + end + end + end + + describe "with free_units_per_events, free_units_per_total_aggregation and fixed_amount (events overage)" do + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "5", + free_units_per_events: 3, + free_units_per_total_aggregation: "15.0" + } + ) + + travel_to(DateTime.new(2023, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("3.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents]).to eq(501) + expect(json[:customer_usage][:total_amount_cents]).to eq(601) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + end + end + + describe "with free_units_per_events, free_units_per_total_aggregation and fixed_amount (total_agg overage)" do + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "5", + free_units_per_events: 3, + free_units_per_total_aggregation: "15.0" + } + ) + + travel_to(DateTime.new(2023, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents]).to eq(505) + expect(json[:customer_usage][:total_amount_cents]).to eq(606) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("20.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents]).to eq(1015) + expect(json[:customer_usage][:total_amount_cents]).to eq(1218) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("30.0") + end + end + end + + describe "with min and max per transaction amount", :premium do + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "1", + per_transaction_max_amount: "12", + per_transaction_min_amount: "1.75" + } + ) + + travel_to(DateTime.new(2023, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "100"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(240) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("100.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(200) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1000"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(1560) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1100.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(1300) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10000"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(3000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("11100.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(2500) + end + end + end + + describe "with min and max per transaction amount and pricing group keys", :premium do + it "returns the expected customer usage" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "1", + per_transaction_max_amount: "12", + per_transaction_min_amount: "1.75", + pricing_group_keys: ["region", "provider"] + } + ) + + travel_to(DateTime.new(2023, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "100", region: "US", provider: "AWS"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(240) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("100.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(200) + expect(json[:customer_usage][:charges_usage][0][:grouped_usage].first[:grouped_by]).to eq({region: "US", provider: "AWS"}) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1000", region: "US", provider: "AWS"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(1560) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1100.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(1300) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10000", region: "US", provider: "AWS"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(3000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("11100.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(2500) + end + end + end + + describe "with min and max per transaction amount and no events (ING-13 regression)", :premium do + it "returns a zero customer usage without raising NoMethodError" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "1", + per_transaction_max_amount: "12", + per_transaction_min_amount: "1.75" + } + ) + + travel_to(DateTime.new(2023, 3, 6)) do + # First call: when non_persistable_charge_cache_optimization is on, the cache + # middleware returns an empty hydrated-fee array. This used to crash in + # ChargeModels::PercentageService because result.aggregator was nil. + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(0) + + # Second call: exercises the cached-empty path again, matching how + # DailyUsages::ComputeJob repeatedly invoked the usage pipeline in production. + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + expect(json[:customer_usage][:charges_usage][0][:amount_cents]).to eq(0) + end + end + end + + describe "with min and max per transaction amount, pricing group keys and no events (ING-13 grouped regression)", :premium do + it "returns zero customer usage without raising NoMethodError through the grouped hydrate path" do + travel_to(DateTime.new(2023, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :percentage_charge, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "1", + per_transaction_max_amount: "12", + per_transaction_min_amount: "1.75", + pricing_group_keys: ["region", "provider"] + } + ) + + travel_to(DateTime.new(2023, 3, 6)) do + # First call exercises the grouped hydrate path: ChargeModels::GroupedService + # copies the outer aggregator onto each inner aggregation. Before the fix the + # outer result.aggregator stayed nil for grouped charges, reintroducing the + # NoMethodError despite the ungrouped path being patched. + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + + # Second call exercises the cached-empty grouped path, matching how + # DailyUsages::ComputeJob repeatedly invoked the usage pipeline in production. + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + end + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/prorated_graduated_spec.rb b/spec/scenarios/current_usage/by_charge_model/prorated_graduated_spec.rb new file mode 100644 index 0000000..91c37e2 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/prorated_graduated_spec.rb @@ -0,0 +1,1394 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Prorated Graduated Scenarios", transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:, name: "aaaaaabcd") } + let(:tax) { create(:tax, organization:, rate: 0) } + + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:billable_metric, recurring: true, organization:, aggregation_type:, field_name:) } + + before { tax } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + describe "with sum_agg" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + describe "three ranges and one overflow case" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: 15, + per_unit_amount: "5", + flat_amount: "50" + }, + { + from_value: 16, + to_value: nil, + per_unit_amount: "2", + flat_amount: "0" + } + ] + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + + travel_to(DateTime.new(2023, 9, 10)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_400) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_400) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2023, 9, 16)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "5"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_400) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_400) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("7.0") + end + + travel_to(DateTime.new(2023, 9, 20)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "-6"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(16_567) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(16_567) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 9, 25)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_967) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_967) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("11.0") + end + + travel_to(DateTime.new(2023, 9, 26)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_300) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_300) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("15.0") + end + + travel_to(DateTime.new(2023, 9, 30)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "60"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(18_700) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 17)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "20"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("95.0") + end + end + + context "when there are old events before first invoice" do + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 12, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.active.order(created_at: :desc).first + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "12", + flat_amount: "0" + } + ] + } + ) + + travel_to(DateTime.new(2023, 12, 2)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + timestamp: 1_699_336_493, ## November 2023 + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + timestamp: 1_699_336_493, ## November 2023 + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + end + + travel_to(DateTime.new(2024, 1, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(6_000) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2024, 1, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(6_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + end + + travel_to(DateTime.new(2024, 1, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(8_013) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(8_013) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("12.0") + end + end + end + + context "when there are old events before first invoice and subscription is terminated" do + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 10, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.active.order(created_at: :desc).first + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "10", + flat_amount: "0" + } + ] + } + ) + + travel_to(DateTime.new(2023, 10, 5)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "4"} + } + ) + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "3"} + } + ) + end + + travel_to(DateTime.new(2023, 11, 5)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "-1"} + } + ) + end + + travel_to(DateTime.new(2023, 12, 7)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(1_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(1_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("6.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(1_806) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(1_806) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("7.0") + + Subscriptions::TerminateService.call(subscription:) + perform_all_enqueued_jobs + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(258) + expect(invoice.issuing_date.iso8601).to eq("2023-12-07") + end + end + end + + context "when upgrade is performed" do + let(:plan_new) { create(:plan, organization:, amount_cents: 100) } + + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: 15, + per_unit_amount: "5", + flat_amount: "50" + }, + { + from_value: 16, + to_value: nil, + per_unit_amount: "2", + flat_amount: "0" + } + ] + } + ) + + travel_to(DateTime.new(2023, 9, 10)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2"} + } + ) + end + + travel_to(DateTime.new(2023, 9, 16)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "5"} + } + ) + end + + travel_to(DateTime.new(2023, 9, 20)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "-6"} + } + ) + end + + travel_to(DateTime.new(2023, 9, 25)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "10"} + } + ) + end + + travel_to(DateTime.new(2023, 9, 26)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4"} + } + ) + end + + travel_to(DateTime.new(2023, 9, 30)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "60"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(18_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(18_700) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(37_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("75.0") + end + + travel_to(DateTime.new(2023, 10, 17)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "20"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(38_935) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("95.0") + end + + subscription = customer.subscriptions.first + + travel_to(DateTime.new(2023, 10, 18, 5, 20)) do + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan: plan_new, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: 15, + per_unit_amount: "5", + flat_amount: "50" + }, + { + from_value: 16, + to_value: nil, + per_unit_amount: "2", + flat_amount: "0" + } + ] + } + ) + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code + } + ) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + + invoice = subscription.invoices.order(created_at: :desc).first + expect(invoice.fees.charge.count).to eq(1) + + # 30226 (17 / 31 * 75 units) + 2.58 (2 / 31 * 20 units - prorated event in termination period) + expect(invoice.total_amount_cents).to eq(27_323) + end + + travel_to(DateTime.new(2023, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.order(created_at: :desc).first + invoice = subscription.invoices.order(created_at: :desc).first + + # (95 units * 14/31) -> 26_742 - charge fee + # 100 * 14/31 -> 45 -> subscription fee + expect(invoice.total_amount_cents).to eq(26_742 + 45) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(41_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(41_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("95.0") + end + end + end + end + end + + describe "with unique_count_agg" do + let(:aggregation_type) { "unique_count_agg" } + let(:field_name) { "amount" } + + describe "two ranges" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 1, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 2, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + + travel_to(DateTime.new(2023, 9, 10)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1111", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 9, 12)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1111", operation_type: "remove"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_100) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_100) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + end + + travel_to(DateTime.new(2023, 9, 14)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1111", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_667) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_667) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 9, 15)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2222", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2023, 9, 16)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2222", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(15_933) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2023, 9, 20)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "3333", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(16_117) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(16_117) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("3.0") + end + + travel_to(DateTime.new(2023, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(16_117) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_000) # 100 + 10 + 50 + 5 + 5 + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("3.0") + end + + travel_to(DateTime.new(2023, 10, 17)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4444", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_242) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_242) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + + travel_to(DateTime.new(2023, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_500) # 100 + 10 + 50 + 5 + 5 + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_500) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + end + end + + describe "two ranges and removal in another interval" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 1, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 2, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + + travel_to(DateTime.new(2023, 9, 10)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1111", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_700) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 10, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(10_700) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 10, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 10, 17)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1111", operation_type: "remove"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_548) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_548) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + end + + travel_to(DateTime.new(2023, 10, 19, 0, 0, 0)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2222", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_968) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_968) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.order(created_at: :desc).first + + expect(invoice.total_amount_cents).to eq(10_968) + expect(subscription.reload.invoices.count).to eq(2) + end + + travel_to(DateTime.new(2023, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + end + end + + context "with multiple events on the same day" do + it "returns the expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 9, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + + travel_to(DateTime.new(2023, 10, 10)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "1111", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(10_710) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(10_710) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "2222", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_097) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_097) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "3333", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_484) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_484) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("3.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "4444", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(11_871) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(11_871) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("4.0") + end + + travel_to(DateTime.new(2023, 10, 20)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "5555", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(12_258) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(12_258) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("5.0") + end + + travel_to(DateTime.new(2023, 10, 25)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {amount: "6666", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(17_371) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(17_371) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("6.0") + end + + travel_to(DateTime.new(2023, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(invoice.total_amount_cents).to eq(17_371) + expect(subscription.reload.invoices.count).to eq(1) + end + + travel_to(DateTime.new(2023, 11, 5)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(20_500) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(20_500) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("6.0") + end + end + + context "when there are old events before first invoice and subscription is terminated" do + it "returns expected invoice and usage amounts" do + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(DateTime.new(2023, 10, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.active.order(created_at: :desc).first + + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 1, + per_unit_amount: "5", + flat_amount: "10" + }, + { + from_value: 2, + to_value: nil, + per_unit_amount: "15", + flat_amount: "30" + } + ] + } + ) + + travel_to(DateTime.new(2023, 10, 5)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1111", operation_type: "add"} + } + ) + end + + travel_to(DateTime.new(2023, 12, 7)) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(1_500) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(1_500) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("1.0") + + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "2222", operation_type: "add"} + } + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(5_710) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(5_710) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + + Subscriptions::TerminateService.call(subscription:) + perform_all_enqueued_jobs + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(4_161) + expect(invoice.issuing_date.iso8601).to eq("2023-12-07") + end + end + end + end + + # Regression test: Users removed in previous billing period should not occupy tier slots. + # + # Reported bug: In ProratedGraduatedService.compute_amount, full_sum (which determines + # tier boundaries) was incrementing for ALL add events, including those with + # prorated_value = 0. This caused users removed before the billing period to still + # "occupy" a tier slot, pushing active users into higher (more expensive) tiers. + # + # Reproduction steps: + # - Graduated tiers: 0-3 users free, 4+ users at $10/user + # - Period 1: Add 5 users, then remove 1 → ends with 4 active users + # - Period 2: No new events, check usage + # - Expected: 4 units, $10 (3 free + 1 at $10) + # - Bug: 4 units, $20 (removed user's add event occupies tier slot) + context "when item is removed before billing period" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:field_name) { "user_id" } + + let(:charge) do + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 1, per_unit_amount: "0", flat_amount: "0"}, + {from_value: 2, to_value: nil, per_unit_amount: "10", flat_amount: "0"} + ] + } + ) + end + + before { charge } + + it "does not count removed user in tier assignment" do + travel_to(DateTime.new(2024, 10, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 10, 5)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "73283", operation_type: "add"} + } + ) + end + + travel_to(DateTime.new(2024, 10, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "73290", operation_type: "add"} + } + ) + end + + travel_to(DateTime.new(2024, 10, 7)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "78924", operation_type: "add"} + } + ) + end + + travel_to(DateTime.new(2024, 10, 25)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "73290", operation_type: "remove"} + } + ) + end + + travel_to(DateTime.new(2024, 11, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + + expect(subscription.invoices.count).to eq(1) + expect(invoice).to be_present + end + + travel_to(DateTime.new(2024, 11, 15)) do + fetch_current_usage(customer:) + + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("2.0") + expect(json[:customer_usage][:amount_cents]).to eq(1000) + end + + travel_to(DateTime.new(2024, 12, 1)) do + perform_billing + + subscription = customer.subscriptions.first + invoice = subscription.invoices.order(created_at: :desc).first + + expect(invoice.total_amount_cents).to eq(1000) + end + end + end + + # Customer use-case + context "with combinations of add and remove" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:field_name) { "user_id" } + + let(:charge) do + create( + :graduated_charge, + billable_metric:, + prorated: true, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 3, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 4, + to_value: nil, + per_unit_amount: "10", + flat_amount: "0" + } + ] + } + ) + end + + before { charge } + + it "returns expected usage" do + travel_to(Time.zone.parse("2025-05-21 04:31:39")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + end + + travel_to(DateTime.new(2025, 5, 21)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "49303", operation_type: "add"} + } + ) + end + + travel_to(DateTime.new(2025, 5, 31)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "49067", operation_type: "add"} + } + ) + end + + travel_to(DateTime.new(2025, 7, 23)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "62741", operation_type: "add"} + } + ) + end + + travel_to(DateTime.new(2025, 7, 24)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "49067", operation_type: "remove"} + } + ) + end + + travel_to(DateTime.new(2025, 7, 24)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {user_id: "63011", operation_type: "add"} + } + ) + end + + travel_to(Time.zone.parse("2025-08-19 18:00:00")) do + fetch_current_usage(customer:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(0) + end + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/standard_spec.rb b/spec/scenarios/current_usage/by_charge_model/standard_spec.rb new file mode 100644 index 0000000..67952e1 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/standard_spec.rb @@ -0,0 +1,170 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Standard Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with basic usage" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "12.5"}) + end + + it "returns the expected current usage" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 4.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("4.0") + expect(charge_usage[:events_count]).to eq(4) + expect(charge_usage[:amount_cents]).to eq(5000) + end + end + end + + context "with grouped_by" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10", grouped_by: ["region"]}) + end + + it "returns grouped usage" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 2.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {region: "us"} + } + ) + end + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {region: "eu"} + } + ) + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("3.0") + expect(charge_usage[:amount_cents]).to eq(3000) + + grouped_usage = charge_usage[:grouped_usage] + expect(grouped_usage.count).to eq(2) + + us_group = grouped_usage.find { |g| g[:grouped_by] == {region: "us"} } + expect(us_group[:units]).to eq("2.0") + expect(us_group[:amount_cents]).to eq(2000) + + eu_group = grouped_usage.find { |g| g[:grouped_by] == {region: "eu"} } + expect(eu_group[:units]).to eq("1.0") + expect(eu_group[:amount_cents]).to eq(1000) + end + end + end + + context "with filters" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "hours") } + + before do + cloud_filter = create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[aws gcp]) + charge = create(:standard_charge, plan:, billable_metric:, properties: {amount: "0.12"}) + create(:charge_filter, charge:, properties: {amount: "0.10"}, invoice_display_name: "GCP") + .tap { |cf| create(:charge_filter_value, charge_filter: cf, billable_metric_filter: cloud_filter, values: ["gcp"]) } + create(:charge_filter, charge:, properties: {amount: "0.08"}, invoice_display_name: "AWS") + .tap { |cf| create(:charge_filter_value, charge_filter: cf, billable_metric_filter: cloud_filter, values: ["aws"]) } + end + + it "returns filtered usage" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {cloud: "aws", hours: 100} + } + ) + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {cloud: "gcp", hours: 200} + } + ) + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + # AWS: 100 * 0.08 = 800 cents, GCP: 200 * 0.10 = 2000 cents + expect(charge_usage[:amount_cents]).to eq(2800) + + filters = charge_usage[:filters] + aws_filter = filters.find { |f| f[:values] == {cloud: ["aws"]} } + expect(aws_filter[:units]).to eq("100.0") + expect(aws_filter[:amount_cents]).to eq(800) + + gcp_filter = filters.find { |f| f[:values] == {cloud: ["gcp"]} } + expect(gcp_filter[:units]).to eq("200.0") + expect(gcp_filter[:amount_cents]).to eq(2000) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/by_charge_model/volume_spec.rb b/spec/scenarios/current_usage/by_charge_model/volume_spec.rb new file mode 100644 index 0000000..e4046f5 --- /dev/null +++ b/spec/scenarios/current_usage/by_charge_model/volume_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Charge Models - Volume Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with basic volume ranges" do + before do + create( + :volume_charge, + plan:, + billable_metric:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "5", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "2", flat_amount: "50"} + ] + } + ) + end + + it "returns usage within first range" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 5.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("5.0") + # 5 * 5 + 100 = 125 => 12500 cents + expect(charge_usage[:amount_cents]).to eq(12_500) + end + end + + it "returns usage in second range" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + 15.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("15.0") + # Volume: all units priced at second range: 15 * 2 + 50 = 80 => 8000 cents + expect(charge_usage[:amount_cents]).to eq(8_000) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/pricing_unit_spec.rb b/spec/scenarios/current_usage/pricing_unit_spec.rb new file mode 100644 index 0000000..6a2ac84 --- /dev/null +++ b/spec/scenarios/current_usage/pricing_unit_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Current usage pricing unit Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:pricing_unit) { create(:pricing_unit, organization:, code: "credits", short_name: "CRD") } + + around { |test| travel_to(DateTime.new(2024, 3, 5)) { test.run } } + + before do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with standard charge and events" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "credits") } + + before do + charge = create(:standard_charge, plan:, billable_metric:, properties: {amount: "1.0"}) + create( + :applied_pricing_unit, + organization:, + pricing_unit:, + pricing_unitable: charge, + conversion_rate: 0.5 + ) + end + + it "returns pricing_unit_details in current usage" do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {credits: 100} + } + ) + end + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + # 300 units × 1.0 pricing_unit/unit = 300 pricing_units × 0.5 EUR/pricing_unit = 150 EUR = 15000 cents + expect(charge_usage[:units]).to eq("300.0") + expect(charge_usage[:events_count]).to eq(3) + expect(charge_usage[:amount_cents]).to eq(15_000) + + pricing_details = charge_usage[:pricing_unit_details] + expect(pricing_details).not_to be_nil + expect(pricing_details[:short_name]).to eq("CRD") + expect(pricing_details[:conversion_rate]).to eq("0.5") + # 300 units × 1.0 = 300 pricing_units = 30000 pricing_unit cents + expect(pricing_details[:amount_cents]).to eq(30_000) + end + end + + context "with zero usage" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + before do + charge = create(:standard_charge, plan:, billable_metric:, properties: {amount: "5.0"}) + create( + :applied_pricing_unit, + organization:, + pricing_unit:, + pricing_unitable: charge, + conversion_rate: 0.5 + ) + end + + it "returns pricing_unit_details with zero values" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("0.0") + expect(charge_usage[:events_count]).to eq(0) + expect(charge_usage[:amount_cents]).to eq(0) + + pricing_details = charge_usage[:pricing_unit_details] + expect(pricing_details).not_to be_nil + expect(pricing_details[:short_name]).to eq("CRD") + expect(pricing_details[:conversion_rate]).to eq("0.5") + expect(pricing_details[:amount_cents]).to eq(0) + end + end + + context "with grouped_by" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "credits") } + + before do + charge = create(:standard_charge, plan:, billable_metric:, properties: {amount: "1.0", grouped_by: ["region"]}) + create( + :applied_pricing_unit, + organization:, + pricing_unit:, + pricing_unitable: charge, + conversion_rate: 0.5 + ) + end + + it "returns pricing_unit_details at top level and in grouped_usage" do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {credits: 100, region: "us"} + } + ) + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {credits: 200, region: "eu"} + } + ) + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + # Total: 300 units × 1.0 × 0.5 = 150 EUR = 15000 cents + expect(charge_usage[:amount_cents]).to eq(15_000) + + pricing_details = charge_usage[:pricing_unit_details] + expect(pricing_details).not_to be_nil + expect(pricing_details[:short_name]).to eq("CRD") + expect(pricing_details[:amount_cents]).to eq(30_000) + + grouped_usage = charge_usage[:grouped_usage] + expect(grouped_usage.count).to eq(2) + + us_group = grouped_usage.find { |g| g[:grouped_by] == {region: "us"} } + expect(us_group[:units]).to eq("100.0") + expect(us_group[:amount_cents]).to eq(5_000) + expect(us_group[:pricing_unit_details]).not_to be_nil + expect(us_group[:pricing_unit_details][:short_name]).to eq("CRD") + expect(us_group[:pricing_unit_details][:amount_cents]).to eq(10_000) + + eu_group = grouped_usage.find { |g| g[:grouped_by] == {region: "eu"} } + expect(eu_group[:units]).to eq("200.0") + expect(eu_group[:amount_cents]).to eq(10_000) + expect(eu_group[:pricing_unit_details]).not_to be_nil + expect(eu_group[:pricing_unit_details][:short_name]).to eq("CRD") + expect(eu_group[:pricing_unit_details][:amount_cents]).to eq(20_000) + end + end + + context "without applied_pricing_unit" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + end + + it "returns nil pricing_unit_details" do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("1.0") + expect(charge_usage[:amount_cents]).to eq(1000) + expect(charge_usage[:pricing_unit_details]).to be_nil + end + end + end + end +end diff --git a/spec/scenarios/current_usage/with_identical_charge_filters_spec.rb b/spec/scenarios/current_usage/with_identical_charge_filters_spec.rb new file mode 100644 index 0000000..629a5a3 --- /dev/null +++ b/spec/scenarios/current_usage/with_identical_charge_filters_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Regression test for ISSUE-1799: ClickHouse query fails with empty Tuple() +# when ignored_filters contains empty hashes or hashes with all-empty-array +# values. These states should not occur — charge filters should always have +# values and duplicates should not exist — but missing validations allow them +# in production. The store-level defensive guards prevent invalid SQL. +describe "Current Usage - Filters with empty ignored_filters entries", transaction: false do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "value") } + + # Filters with no ChargeFilterValue records should not exist but can due + # to missing validations. They produce {} in ignored_filters. + context "when charge filters have no values" do + before do + cloud_filter = create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[aws gcp]) + + charge = create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + + create(:charge_filter, charge:, properties: {amount: "5"}, invoice_display_name: "Empty A") + create(:charge_filter, charge:, properties: {amount: "8"}, invoice_display_name: "Empty B") + create(:charge_filter, charge:, properties: {amount: "3"}, invoice_display_name: "AWS") + .tap { |cf| create(:charge_filter_value, charge_filter: cf, billable_metric_filter: cloud_filter, values: ["aws"]) } + end + + it "returns current usage without SQL errors" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {cloud: "aws", value: 10} + } + ) + + fetch_current_usage(customer:) + + expect(json[:customer_usage][:charges_usage].first[:filters].count).to eq(4) + end + end + end + + # Duplicate filters with identical values should not exist but can due + # to missing validations. The child's subtraction zeroes out all arrays, + # producing {"cloud" => []} in ignored_filters. + context "when a child filter's values are a subset of the parent's" do + before do + cloud_filter = create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[aws gcp]) + + charge = create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + + create(:charge_filter, charge:, properties: {amount: "5"}, invoice_display_name: "All clouds") + .tap { |cf| create(:charge_filter_value, charge_filter: cf, billable_metric_filter: cloud_filter, values: %w[aws gcp]) } + create(:charge_filter, charge:, properties: {amount: "3"}, invoice_display_name: "AWS only") + .tap { |cf| create(:charge_filter_value, charge_filter: cf, billable_metric_filter: cloud_filter, values: ["aws"]) } + end + + it "returns current usage without SQL errors" do + travel_to(DateTime.new(2024, 3, 5)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(DateTime.new(2024, 3, 6)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {cloud: "aws", value: 10} + } + ) + + fetch_current_usage(customer:) + + expect(json[:customer_usage][:charges_usage].first[:filters].count).to eq(3) + end + end + end + end + end +end diff --git a/spec/scenarios/current_usage/zero_fees_spec.rb b/spec/scenarios/current_usage/zero_fees_spec.rb new file mode 100644 index 0000000..2fcecc9 --- /dev/null +++ b/spec/scenarios/current_usage/zero_fees_spec.rb @@ -0,0 +1,648 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Current usage zero fees Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + around { |test| travel_to(DateTime.new(2024, 3, 5)) { test.run } } + + before do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + def expect_zero_charge_usage(charge_usage, charge_model:) + expect(charge_usage[:units]).to eq("0.0") + expect(charge_usage[:total_aggregated_units]).to eq("0.0") + expect(charge_usage[:events_count]).to eq(0) + expect(charge_usage[:amount_cents]).to eq(0) + expect(charge_usage[:amount_currency]).to eq("EUR") + expect(charge_usage[:pricing_unit_details]).to be_nil + expect(charge_usage[:charge][:charge_model]).to eq(charge_model) + expect(charge_usage[:charge][:lago_id]).to be_present + expect(charge_usage[:charge][:invoice_display_name]).to be_present + expect(charge_usage[:billable_metric][:lago_id]).to eq(billable_metric.id) + expect(charge_usage[:billable_metric][:name]).to eq(billable_metric.name) + expect(charge_usage[:billable_metric][:code]).to eq(billable_metric.code) + expect(charge_usage[:billable_metric][:aggregation_type]).to eq(billable_metric.aggregation_type) + expect(charge_usage[:grouped_usage]).to eq([]) + end + + def expect_zero_filter(filter) + expect(filter[:units]).to eq("0.0") + expect(filter[:total_aggregated_units]).to eq("0.0") + expect(filter[:events_count]).to eq(0) + expect(filter[:amount_cents]).to eq(0) + expect(filter[:pricing_unit_details]).to be_nil + end + + def expect_zero_customer_usage(customer_usage) + expect(customer_usage[:amount_cents]).to eq(0) + expect(customer_usage[:total_amount_cents]).to eq(0) + expect(customer_usage[:taxes_amount_cents]).to eq(0) + expect(customer_usage[:currency]).to eq("EUR") + expect(customer_usage[:from_datetime]).to be_present + expect(customer_usage[:to_datetime]).to be_present + expect(customer_usage[:issuing_date]).to be_present + end + + [true, false].each do |ff_enabled| + context "with non_persistable_charge_cache_optimization #{ff_enabled ? "enabled" : "disabled"}" do + before do + organization.enable_feature_flag!(:non_persistable_charge_cache_optimization) if ff_enabled + end + + context "with standard charge model" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "20"}) + end + + it "returns zero usage" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect_zero_charge_usage(charge_usage, charge_model: "standard") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with graduated charge model" do + before do + create( + :graduated_charge, + plan:, + billable_metric:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "2", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "50"} + ] + } + ) + end + + it "returns zero usage" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect_zero_charge_usage(charge_usage, charge_model: "graduated") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with package charge model" do + before do + create( + :package_charge, + plan:, + billable_metric:, + properties: {amount: "100", free_units: 10, package_size: 10} + ) + end + + it "returns zero usage" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect_zero_charge_usage(charge_usage, charge_model: "package") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with percentage charge model" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + before do + create( + :percentage_charge, + plan:, + billable_metric:, + properties: {rate: "0.05", fixed_amount: "2"} + ) + end + + it "returns zero usage" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect_zero_charge_usage(charge_usage, charge_model: "percentage") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with volume charge model" do + before do + create( + :volume_charge, + plan:, + billable_metric:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}, + {from_value: 101, to_value: nil, per_unit_amount: "1", flat_amount: "0"} + ] + } + ) + end + + it "returns zero usage" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect_zero_charge_usage(charge_usage, charge_model: "volume") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with graduated_percentage charge model", :premium do + before do + create( + :graduated_percentage_charge, + plan:, + billable_metric:, + properties: { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 10, rate: "1", flat_amount: "100"}, + {from_value: 11, to_value: nil, rate: "0.5", flat_amount: "50"} + ] + } + ) + end + + it "returns zero usage" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect_zero_charge_usage(charge_usage, charge_model: "graduated_percentage") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with multiple charge models on the same plan" do + let(:sum_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "20"}) + create( + :graduated_charge, + plan:, + billable_metric: sum_metric, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "2", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "50"} + ] + } + ) + end + + it "returns zero usage for each charge" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(2) + + charge_models = customer_usage[:charges_usage].map { |cu| cu[:charge][:charge_model] } + expect(charge_models).to match_array(%w[standard graduated]) + + customer_usage[:charges_usage].each do |charge_usage| + expect(charge_usage[:units]).to eq("0.0") + expect(charge_usage[:total_aggregated_units]).to eq("0.0") + expect(charge_usage[:events_count]).to eq(0) + expect(charge_usage[:amount_cents]).to eq(0) + expect(charge_usage[:amount_currency]).to eq("EUR") + expect(charge_usage[:filters]).to eq([]) + expect(charge_usage[:grouped_usage]).to eq([]) + end + end + end + + context "with pricing group keys" do + before do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "20", grouped_by: ["region"]} + ) + end + + it "returns zero usage with a zero grouped_usage entry" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("0.0") + expect(charge_usage[:total_aggregated_units]).to eq("0.0") + expect(charge_usage[:events_count]).to eq(0) + expect(charge_usage[:amount_cents]).to eq(0) + expect(charge_usage[:amount_currency]).to eq("EUR") + expect(charge_usage[:filters]).to eq([]) + + grouped_usage = charge_usage[:grouped_usage] + expect(grouped_usage.count).to eq(1) + + group = grouped_usage.first + expect(group[:grouped_by]).to eq({region: nil}) + expect(group[:units]).to eq("0.0") + expect(group[:total_aggregated_units]).to eq("0.0") + expect(group[:events_count]).to eq(0) + expect(group[:amount_cents]).to eq(0) + expect(group[:filters]).to eq([]) + expect(group[:pricing_unit_details]).to be_nil + end + + context "when one group has usage" do + it "returns grouped_usage with non-zero and zero groups" do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {region: "europe"} + } + ) + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:amount_cents]).to eq(2000) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("1.0") + expect(charge_usage[:amount_cents]).to eq(2000) + + grouped_usage = charge_usage[:grouped_usage] + expect(grouped_usage.count).to eq(1) + + europe_group = grouped_usage.find { |g| g[:grouped_by] == {region: "europe"} } + expect(europe_group[:units]).to eq("1.0") + expect(europe_group[:total_aggregated_units]).to eq("1.0") + expect(europe_group[:events_count]).to eq(1) + expect(europe_group[:amount_cents]).to eq(2000) + expect(europe_group[:filters]).to eq([]) + expect(europe_group[:pricing_unit_details]).to be_nil + end + end + + context "when multiple groups have usage" do + it "returns grouped_usage for each group" do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {region: "europe"} + } + ) + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {region: "usa"} + } + ) + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:amount_cents]).to eq(4000) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("2.0") + expect(charge_usage[:amount_cents]).to eq(4000) + + grouped_usage = charge_usage[:grouped_usage] + expect(grouped_usage.count).to eq(2) + + europe_group = grouped_usage.find { |g| g[:grouped_by] == {region: "europe"} } + expect(europe_group[:units]).to eq("1.0") + expect(europe_group[:events_count]).to eq(1) + expect(europe_group[:amount_cents]).to eq(2000) + + usa_group = grouped_usage.find { |g| g[:grouped_by] == {region: "usa"} } + expect(usa_group[:units]).to eq("1.0") + expect(usa_group[:events_count]).to eq(1) + expect(usa_group[:amount_cents]).to eq(2000) + end + end + end + + context "with charge filters" do + let(:region) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) } + + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) } + let(:europe_filter) { create(:charge_filter, charge:, properties: {amount: "20"}) } + let(:usa_filter) { create(:charge_filter, charge:, properties: {amount: "50"}) } + + before do + create(:charge_filter_value, charge_filter: europe_filter, billable_metric_filter: region, values: ["europe"]) + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + it "returns zero fees for all filters and catch-all" do + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect_zero_customer_usage(customer_usage) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect_zero_charge_usage(charge_usage, charge_model: "standard") + + filters = charge_usage[:filters] + expect(filters.count).to eq(3) + + filters.each { |filter| expect_zero_filter(filter) } + + filter_values = filters.map { |f| f[:values] } + expect(filter_values).to match_array([{region: ["europe"]}, {region: ["usa"]}, nil]) + + europe = filters.find { |f| f[:values] == {region: ["europe"]} } + expect(europe[:invoice_display_name]).to eq(europe_filter.invoice_display_name) + + usa = filters.find { |f| f[:values] == {region: ["usa"]} } + expect(usa[:invoice_display_name]).to eq(usa_filter.invoice_display_name) + + catch_all = filters.find { |f| f[:values].nil? } + expect(catch_all[:invoice_display_name]).to be_nil + end + + context "when one filter has usage" do + it "returns non-zero fee for that filter and zero for others" do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {region: "europe"} + } + ) + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:amount_cents]).to eq(2000) + expect(customer_usage[:total_amount_cents]).to eq(2000) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("1.0") + expect(charge_usage[:total_aggregated_units]).to eq("1.0") + expect(charge_usage[:events_count]).to eq(1) + expect(charge_usage[:amount_cents]).to eq(2000) + expect(charge_usage[:amount_currency]).to eq("EUR") + + filters = charge_usage[:filters] + expect(filters.count).to eq(3) + + europe = filters.find { |f| f[:values] == {region: ["europe"]} } + expect(europe[:units]).to eq("1.0") + expect(europe[:total_aggregated_units]).to eq("1.0") + expect(europe[:events_count]).to eq(1) + expect(europe[:amount_cents]).to eq(2000) + + zero_filters = filters.reject { |f| f[:values] == {region: ["europe"]} } + expect(zero_filters.count).to eq(2) + zero_filters.each { |filter| expect_zero_filter(filter) } + + zero_filter_values = zero_filters.map { |f| f[:values] } + expect(zero_filter_values).to match_array([{region: ["usa"]}, nil]) + end + end + + context "when fetching usage twice" do + it "returns consistent results" do + fetch_current_usage(customer:) + first_response = json[:customer_usage] + + fetch_current_usage(customer:) + second_response = json[:customer_usage] + + expect(second_response[:amount_cents]).to eq(first_response[:amount_cents]) + expect(second_response[:total_amount_cents]).to eq(first_response[:total_amount_cents]) + expect(second_response[:charges_usage].count).to eq(first_response[:charges_usage].count) + + first_filters = first_response[:charges_usage].first[:filters] + second_filters = second_response[:charges_usage].first[:filters] + expect(second_filters.count).to eq(first_filters.count) + + first_filters.zip(second_filters).each do |first_filter, second_filter| + expect(second_filter[:units]).to eq(first_filter[:units]) + expect(second_filter[:events_count]).to eq(first_filter[:events_count]) + expect(second_filter[:amount_cents]).to eq(first_filter[:amount_cents]) + expect(second_filter[:values]).to eq(first_filter[:values]) + end + end + end + end + + context "with events but zero amount (standard charge with amount 0)" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "0"}) + end + + it "returns non-zero units and events with zero amount" do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:amount_cents]).to eq(0) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("3.0") + expect(charge_usage[:total_aggregated_units]).to eq("3.0") + expect(charge_usage[:events_count]).to eq(3) + expect(charge_usage[:amount_cents]).to eq(0) + expect(charge_usage[:charge][:charge_model]).to eq("standard") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with events but zero amount (package charge with free units covering usage)" do + before do + create( + :package_charge, + plan:, + billable_metric:, + properties: {amount: "100", free_units: 50, package_size: 10} + ) + end + + it "returns non-zero units and events with zero amount when within free units" do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:amount_cents]).to eq(0) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("3.0") + expect(charge_usage[:total_aggregated_units]).to eq("3.0") + expect(charge_usage[:events_count]).to eq(3) + expect(charge_usage[:amount_cents]).to eq(0) + expect(charge_usage[:charge][:charge_model]).to eq("package") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with events but zero units (sum_agg with zero-value properties)" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + end + + it "returns non-zero events with zero units and zero amount" do + 3.times do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {value: 0} + } + ) + end + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:amount_cents]).to eq(0) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("0.0") + expect(charge_usage[:total_aggregated_units]).to eq("0.0") + expect(charge_usage[:events_count]).to eq(3) + expect(charge_usage[:amount_cents]).to eq(0) + expect(charge_usage[:charge][:charge_model]).to eq("standard") + expect(charge_usage[:filters]).to eq([]) + end + end + + context "with charge filters where one filter has zero amount" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "hours") } + + let(:cloud) { create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[aws gcp]) } + + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "0.12"}) } + let(:aws_filter) { create(:charge_filter, charge:, properties: {amount: "0"}) } + let(:gcp_filter) { create(:charge_filter, charge:, properties: {amount: "0.10"}) } + + before do + create(:charge_filter_value, charge_filter: aws_filter, billable_metric_filter: cloud, values: ["aws"]) + create(:charge_filter_value, charge_filter: gcp_filter, billable_metric_filter: cloud, values: ["gcp"]) + end + + it "returns mixed zero/non-zero amounts across filters" do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {cloud: "aws", hours: 100} + } + ) + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: {cloud: "gcp", hours: 200} + } + ) + + fetch_current_usage(customer:) + + customer_usage = json[:customer_usage] + expect(customer_usage[:amount_cents]).to eq(2000) + expect(customer_usage[:charges_usage].count).to eq(1) + + charge_usage = customer_usage[:charges_usage].first + expect(charge_usage[:units]).to eq("300.0") + expect(charge_usage[:events_count]).to eq(2) + expect(charge_usage[:amount_cents]).to eq(2000) + + filters = charge_usage[:filters] + expect(filters.count).to eq(3) + + aws = filters.find { |f| f[:values] == {cloud: ["aws"]} } + expect(aws[:units]).to eq("100.0") + expect(aws[:total_aggregated_units]).to eq("100.0") + expect(aws[:events_count]).to eq(1) + expect(aws[:amount_cents]).to eq(0) + + gcp = filters.find { |f| f[:values] == {cloud: ["gcp"]} } + expect(gcp[:units]).to eq("200.0") + expect(gcp[:total_aggregated_units]).to eq("200.0") + expect(gcp[:events_count]).to eq(1) + expect(gcp[:amount_cents]).to eq(2000) + + catch_all = filters.find { |f| f[:values].nil? } + expect_zero_filter(catch_all) + end + end + end + end +end diff --git a/spec/scenarios/customer_usage_spec.rb b/spec/scenarios/customer_usage_spec.rb new file mode 100644 index 0000000..9756e6d --- /dev/null +++ b/spec/scenarios/customer_usage_spec.rb @@ -0,0 +1,361 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Customer usage Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:, currency: "EUR") } + + let(:plan) { create(:plan, organization:, amount_cents: 700, pay_in_advance: false, interval: "yearly") } + + context "with start date in the past" do + it "retrieve the customer usage" do + travel_to(DateTime.new(2023, 8, 8, 9, 30)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + subscription_at: DateTime.new(2023, 1, 1, 9, 30).iso8601 + } + ) + + subscription = customer.subscriptions.first + fetch_current_usage(customer:, subscription:) + + expect(json[:customer_usage][:from_datetime]).to eq("2023-01-01T09:30:00Z") + expect(json[:customer_usage][:to_datetime]).to eq("2023-12-31T23:59:59Z") + end + end + + context "with Europe/Berlin timezone" do + let(:timezone) { "Europe/Berlin" } + + it "retrieve the customer usage" do + travel_to(DateTime.new(2023, 8, 8, 9, 30)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + subscription_at: DateTime.new(2023, 1, 1, 9, 30).iso8601 + } + ) + + subscription = customer.subscriptions.first + fetch_current_usage(customer:, subscription:) + + expect(json[:customer_usage][:from_datetime]).to eq("2023-01-01T09:30:00Z") + expect(json[:customer_usage][:to_datetime]).to eq("2023-12-31T22:59:59Z") + end + end + end + + context "with America/Los_Angeles timezone" do + let(:timezone) { "America/Los_Angeles" } + + it "retrieve the customer usage" do + travel_to(DateTime.new(2023, 8, 8, 9, 30)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + subscription_at: DateTime.new(2023, 1, 1, 9, 30).iso8601 + } + ) + + subscription = customer.subscriptions.first + fetch_current_usage(customer:, subscription:) + + expect(json[:customer_usage][:from_datetime]).to eq("2023-01-01T09:30:00Z") + expect(json[:customer_usage][:to_datetime]).to eq("2024-01-01T07:59:59Z") + end + end + end + end + + context "with filter_by_charge and filter_by_group filtering" do + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + + let(:billable_metric_1) { create(:sum_billable_metric, organization:, field_name: "units") } + let(:billable_metric_2) { create(:sum_billable_metric, organization:, field_name: "units") } + + let(:charge_1) do + create( + :standard_charge, + plan:, + billable_metric: billable_metric_1, + properties: {amount: "10", pricing_group_keys: ["user"]} + ) + end + let(:charge_2) do + create( + :standard_charge, + plan:, + billable_metric: billable_metric_2, + properties: {amount: "5", pricing_group_keys: ["user"]} + ) + end + + let(:subscription) do + sub = nil + travel_to(DateTime.new(2024, 3, 1, 10, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + sub = customer.subscriptions.first + end + sub + end + + before do + charge_1 + charge_2 + subscription + travel_to(DateTime.new(2024, 3, 5, 10, 0)) do + # Send 10 events for charge_1's metric with user 0..9 + 10.times do |i| + create_event( + { + code: billable_metric_1.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {user: i.to_s, units: 5} + } + ) + end + + # Send 10 events for charge_2's metric with user 0..9 + 10.times do |i| + create_event( + { + code: billable_metric_2.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {user: i.to_s, units: 3} + } + ) + end + end + end + + it "with filter_by_group returns one fee per charge filtered to that group" do + travel_to(DateTime.new(2024, 3, 10, 10, 0)) do + result = Invoices::CustomerUsageService.call( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_group: {user: ["0"]}) + ) + + expect(result).to be_success + + fees = result.usage.fees + expect(fees.size).to eq(2) + + fee_1 = fees.find { |f| f.charge_id == charge_1.id } + expect(fee_1.units).to eq(5) + expect(fee_1.events_count).to eq(1) + expect(fee_1.amount_cents).to eq(5_000) # 5 units * 10 amount * 100 cents + + fee_2 = fees.find { |f| f.charge_id == charge_2.id } + expect(fee_2.units).to eq(3) + expect(fee_2.events_count).to eq(1) + expect(fee_2.amount_cents).to eq(1_500) # 3 units * 5 amount * 100 cents + end + end + + it "with filter_by_charge returns all grouped usage for that charge" do + travel_to(DateTime.new(2024, 3, 10, 10, 0)) do + result = Invoices::CustomerUsageService.call( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_id: charge_1.id) + ) + + expect(result).to be_success + + fees = result.usage.fees + # 10 groups (user 0..9), all for charge_1 + expect(fees.size).to eq(10) + expect(fees.map(&:charge_id).uniq).to eq([charge_1.id]) + + fees.each do |fee| + expect(fee.units).to eq(5) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(5_000) + end + + expect(fees.map { |f| f.grouped_by["user"] }).to match_array( + (0..9).map(&:to_s) + ) + end + end + end + + context "with multi-level pricing_group_keys filtering by workspace" do + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false, interval: "monthly") } + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "units") } + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "10", pricing_group_keys: %w[workspace user]} + ) + end + + let(:subscription) do + sub = nil + travel_to(DateTime.new(2024, 3, 1, 10, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + sub = customer.subscriptions.first + end + sub + end + + before do + charge + subscription + travel_to(DateTime.new(2024, 3, 5, 10, 0)) do + # Send 10 events for different users across two workspaces + # workspace_a: users 0..4, workspace_b: users 5..9 + 10.times do |i| + workspace = (i < 5) ? "workspace_a" : "workspace_b" + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {workspace:, user: i.to_s, units: 3} + } + ) + end + end + end + + it "filtering by workspace still returns fees divided by user" do + travel_to(DateTime.new(2024, 3, 10, 10, 0)) do + result = Invoices::CustomerUsageService.call( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_group: {workspace: ["workspace_a"]}) + ) + + expect(result).to be_success + + fees = result.usage.fees + # 5 users in workspace_a, each with their own fee grouped by user + expect(fees.size).to eq(5) + + fees.each do |fee| + expect(fee.charge_id).to eq(charge.id) + expect(fee.units).to eq(3) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(3_000) # 3 units * 10 amount * 100 cents + end + + expect(fees.map { |f| f.grouped_by["user"] }).to match_array( + (0..4).map(&:to_s) + ) + end + end + + it "filtering by the other workspace returns only its users" do + travel_to(DateTime.new(2024, 3, 10, 10, 0)) do + result = Invoices::CustomerUsageService.call( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_group: {workspace: ["workspace_b"]}) + ) + + expect(result).to be_success + + fees = result.usage.fees + expect(fees.size).to eq(5) + + fees.each do |fee| + expect(fee.charge_id).to eq(charge.id) + expect(fee.units).to eq(3) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(3_000) + end + + expect(fees.map { |f| f.grouped_by["user"] }).to match_array( + (5..9).map(&:to_s) + ) + end + end + + it "without filter returns fees grouped by both workspace and user" do + travel_to(DateTime.new(2024, 3, 10, 10, 0)) do + result = Invoices::CustomerUsageService.call( + customer:, + subscription:, + apply_taxes: false, + with_cache: false + ) + + expect(result).to be_success + + fees = result.usage.fees + # 10 unique workspace+user combinations + expect(fees.size).to eq(10) + + fees.each do |fee| + expect(fee.charge_id).to eq(charge.id) + expect(fee.units).to eq(3) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(3_000) + expect(fee.grouped_by.keys).to match_array(%w[workspace user]) + end + end + end + + it "with skip_grouping returns a single aggregated fee" do + travel_to(DateTime.new(2024, 3, 10, 10, 0)) do + result = Invoices::CustomerUsageService.call( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(skip_grouping: true) + ) + + expect(result).to be_success + + fees = result.usage.fees + # All 10 events aggregated into a single fee + expect(fees.size).to eq(1) + expect(fees.first.charge_id).to eq(charge.id) + expect(fees.first.units).to eq(30) # 10 events * 3 units each + expect(fees.first.events_count).to eq(10) + expect(fees.first.amount_cents).to eq(30_000) # 30 units * 10 amount * 100 cents + expect(fees.first.grouped_by).to eq({}) + end + end + end +end diff --git a/spec/scenarios/customers/create_partner_and_run_billing_spec.rb b/spec/scenarios/customers/create_partner_and_run_billing_spec.rb new file mode 100644 index 0000000..83b8bb4 --- /dev/null +++ b/spec/scenarios/customers/create_partner_and_run_billing_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Create partner and run billing Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil, document_numbering: "per_organization", premium_integrations: ["revenue_share"]) } + let(:billing_entity) { organization.default_billing_entity } + let(:partner) { create(:customer, organization:, billing_entity:) } + let(:customers) { create_list(:customer, 2, organization:, billing_entity:) } + let(:plan) { create(:plan, organization:) } + let(:metric) { create(:latest_billable_metric, organization:) } + let(:params) do + {code: metric.code, transaction_id: SecureRandom.uuid} + end + + before do + billing_entity.update!(document_numbering: "per_billing_entity") + end + + it "allows to switch customer to partner before customer has assigned plans" do + expect do + create_or_update_customer( + { + external_id: partner.external_id, + account_type: "partner" + } + ) + partner.reload + end.to change(partner, :account_type).from("customer").to("partner") + .and change(partner, :exclude_from_dunning_campaign).from(false).to(true) + + create_subscription( + { + external_customer_id: partner.external_id, + external_id: partner.external_id, + plan_code: plan.code + } + ) + + expect do + create_or_update_customer( + { + external_id: partner.external_id, + account_type: "customer" + } + ) + end.not_to change(partner.reload, :account_type) + end + + it "creates partner-specific invoices without payments, with partner numbering, excluded from analytics" do + create_or_update_customer( + { + external_id: partner.external_id, + account_type: "partner" + } + ) + + ### 24 Apr: Create subscriptions + charges. + apr24 = Time.zone.parse("2024-04-24") + travel_to(apr24) do + create( + :package_charge, + plan: plan, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + invoiceable: true, + properties: { + amount: "2", + free_units: 1000, + package_size: 1000 + } + ) + + customers.each do |customer| + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + create_subscription( + { + external_customer_id: partner.external_id, + external_id: partner.external_id, + plan_code: plan.code + } + ) + end + + ### 25 Apr: Ingest events for Plan 1. + apr24 = Time.zone.parse("2024-04-24") + travel_to(apr24) do + plan.subscriptions.each do |subscription| + create_event( + params.merge( + external_subscription_id: subscription.external_id + ) + ) + end + perform_all_enqueued_jobs + end + + # May 1st: Billing run; check invoice numbering + may1 = Time.zone.parse("2024-05-01") + travel_to(may1) do + organization.update!(created_at: 1.month.ago) + perform_billing + expect(billing_entity.invoices.count).to eq(3) + expect(partner.invoices.count).to eq(1) + + perform_billing + expect(billing_entity.invoices.count).to eq(3) + expect(partner.invoices.count).to eq(1) + + partner_invoice = partner.invoices.first + expect(partner_invoice.self_billed).to eq(true) + expect(partner_invoice.number).to eq("#{billing_entity.document_number_prefix}-001-001") + + customers_invoices = customers.map(&:invoices).flatten + expect(customers_invoices.map(&:self_billed)).not_to include(true) + expect(customers_invoices.map do |inv| + inv.number.gsub("#{billing_entity.document_number_prefix}-202405-", "") + end.uniq.sort).to eq(["001", "002"]) + end + + # June 1st: Billing run; check invoice numbering + june1 = Time.zone.parse("2024-06-01") + travel_to(june1) do + perform_billing + expect(billing_entity.invoices.count).to eq(6) + expect(partner.invoices.count).to eq(2) + + partner_invoice = partner.invoices.where(created_at: june1).first + expect(partner_invoice.self_billed).to eq(true) + expect(partner_invoice.number).to eq("#{billing_entity.document_number_prefix}-001-002") + + customers_invoices = customers.map { |c| c.invoices.where(created_at: june1) }.flatten + expect(customers_invoices.map(&:self_billed).uniq).to eq([false]) + expect(customers_invoices.map do |inv| + inv.number.gsub("#{billing_entity.document_number_prefix}-202406-", "") + end.uniq.sort).to eq(["003", "004"]) + end + perform_overdue_balance_update + + # check payments + expect(partner.invoices.map(&:payments).flatten).to be_empty + + # check analytics + may_org_invoices = organization.invoices.where(self_billed: false, created_at: may1) + june_org_invoices = organization.invoices.where(self_billed: false, created_at: june1) + + months = (Time.zone.now.beginning_of_month - Time.zone.parse("2024-04-01")).second.in_months.round + 1 + + # invoice_collection + get_analytics(organization:, analytics_type: "invoice_collection", months:) + collection = json[:invoice_collections] + may_stats = collection.find { |el| el[:month] == "2024-05-01T00:00:00.000Z" } + june_stats = collection.find { |el| el[:month] == "2024-06-01T00:00:00.000Z" } + + expect(may_stats[:invoices_count]).to eq(2) + expect(may_stats[:amount_cents]).to eq(may_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + expect(june_stats[:invoices_count]).to eq(2) + expect(june_stats[:amount_cents]).to eq(june_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + + # gross_revenue + get_analytics(organization:, analytics_type: "gross_revenue", months:) + collection = json[:gross_revenues] + may_stats = collection.find { |el| el[:month] == "2024-05-01T00:00:00.000Z" } + june_stats = collection.find { |el| el[:month] == "2024-06-01T00:00:00.000Z" } + + expect(may_stats[:invoices_count].to_i).to eq(2) + expect(may_stats[:amount_cents]).to eq(may_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + expect(june_stats[:invoices_count].to_i).to eq(2) + expect(june_stats[:amount_cents]).to eq(june_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + + # mrr + get_analytics(organization:, analytics_type: "mrr", months:) + collection = json[:mrrs] + # We have different time format for mrr - is it alright? + may_stats = collection.find { |el| el[:month] == "2024-05-01T00:00:00.000+00:00" } + june_stats = collection.find { |el| el[:month] == "2024-06-01T00:00:00.000+00:00" } + + expect(may_stats[:amount_cents].to_i).to eq(may_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + expect(june_stats[:amount_cents].to_i).to eq(june_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + + # overdue_balance + get_analytics(organization:, analytics_type: "overdue_balance", months:) + collection = json[:overdue_balances] + may_stats = collection.find { |el| el[:month] == "2024-05-01T00:00:00.000Z" } + june_stats = collection.find { |el| el[:month] == "2024-06-01T00:00:00.000Z" } + + expect(may_stats[:lago_invoice_ids].sort).to match(may_org_invoices.map(&:id).sort) + expect(may_stats[:amount_cents].to_i).to eq(may_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + expect(june_stats[:lago_invoice_ids].sort).to match(june_org_invoices.map(&:id).sort) + expect(june_stats[:amount_cents].to_i).to eq(june_org_invoices.sum(:sub_total_including_taxes_amount_cents)) + end +end diff --git a/spec/scenarios/customers/customer_eu_taxes_spec.rb b/spec/scenarios/customers/customer_eu_taxes_spec.rb new file mode 100644 index 0000000..5e77b94 --- /dev/null +++ b/spec/scenarios/customers/customer_eu_taxes_spec.rb @@ -0,0 +1,406 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Add customer-specific taxes" do + let(:organization) { create(:organization, country: "FR", eu_tax_management: false, billing_entities: [create(:billing_entity, country: "FR")]) } + let(:plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 149_00) } + + let(:american_attributes) do + { + name: "John", + country: "US", + address_line1: "123 Main St", + address_line2: "", + state: "Colorado", + city: "Denver", + zipcode: "80095", + currency: "USD" + } + end + + let(:french_attributes) do + { + name: "Jean", + country: "FR", + address_line1: "123 Avenue du General", + address_line2: "", + state: "", + city: "Paris", + zipcode: "75018", + currency: "EUR" + } + end + + let(:italian_attributes) do + { + country: "IT", + address_line1: "123 Via Marconi", + address_line2: "", + state: "", + city: "Roma", + zipcode: "00146", + currency: "EUR" + } + end + + def enable_eu_tax_management! + Organizations::UpdateService.call!(organization:, params: {eu_tax_management: true}) + end + + include_context "with webhook tracking" + + context "when customer are created after the feature was enabled" do + it "create taxes" do + enable_eu_tax_management! + + create_or_update_customer(american_attributes.merge(external_id: "user_usa_123")) + expect(Customer.find_by(external_id: "user_usa_123").taxes.sole.code).to eq "lago_eu_tax_exempt" + + create_or_update_customer(french_attributes.merge(external_id: "user_fr_123")) + expect(Customer.find_by(external_id: "user_fr_123").taxes.sole.code).to eq "lago_eu_fr_standard" + + create_or_update_customer(italian_attributes.merge(external_id: "user_it_123")) + expect(Customer.find_by(external_id: "user_it_123").taxes.sole.code).to eq "lago_eu_it_standard" + + webhooks_sent.clear + # Update customer to provide an INVALID EU VAT identifier + # Nothing changes and no API call is made + create_or_update_customer({external_id: "user_it_123", tax_identification_number: "IT123"}) + expect(Customer.find_by(external_id: "user_it_123").taxes.reload.sole.code).to eq "lago_eu_it_standard" + expect(webhooks_sent.find { it["webhook_type"] == "customer.vies_check" }.dig("customer", "vies_check")).to eq({ + "valid" => false, + "valid_format" => false + }) + + webhooks_sent.clear + # Update customer to provide a valid EU VAT identifier + # A call is made to VIES api, we mock the service rather than the HTTP call because it's a SOAP API + # This customer now have 0% vat + mock_vies_check!("IT12345678901") + create_or_update_customer({external_id: "user_it_123", tax_identification_number: "IT12345678901"}) + expect(Customer.find_by(external_id: "user_it_123").taxes.reload.sole.code).to eq "lago_eu_reverse_charge" + expect(webhooks_sent.find { it["webhook_type"] == "customer.vies_check" }.dig("customer", "vies_check")).to eq({ + "countryCode" => "IT", + "vatNumber" => "IT12345678901" + }) + + mock_vies_check!("FR12345678901") + create_or_update_customer({external_id: "user_fr_123", tax_identification_number: "FR12345678901"}) + expect(Customer.find_by(external_id: "user_fr_123").taxes.sole.code).to eq "lago_eu_reverse_charge" + + customer = Customer.find_by(external_id: "user_it_123") + # If I had a custom tax for this Customer + # It removes the automatic VAT + create_tax({name: "Banking rates", code: "banking_rates", rate: 1.3}) + create_or_update_customer({external_id: customer.external_id, tax_codes: ["banking_rates"]}) + expect(customer.taxes.sole.code).to eq "banking_rates" + + # Make an invoice with this tax + addon = create(:add_on, code: :test, organization:) + create_one_off_invoice(customer, [addon], taxes: ["banking_rates"]) + expect(customer.invoices.sole.taxes.sole.code).to eq "banking_rates" + + # Then, remove the tax_identification_number for the customer + # The custom tax is overridden by the default VAT of the country, even if an invoice used the previous taxes + create_or_update_customer({external_id: customer.external_id, tax_identification_number: nil}) + expect(customer.taxes.sole.code).to eq "lago_eu_it_standard" + end + end + + context "when VIES returns an error" do + let(:retry_job) { class_double(Customers::ViesCheckJob) } + + it "does not change taxes but send the webhook" do + enable_eu_tax_management! + + create_or_update_customer(french_attributes.merge(external_id: "user_fr_123")) + expect(Customer.find_by(external_id: "user_fr_123").taxes.sole.code).to eq "lago_eu_fr_standard" + + webhooks_sent.clear + vat_number = "FR12345678901" + allow_any_instance_of(Valvat).to receive(:exists?) # rubocop:disable RSpec/AnyInstance + .and_raise(::Valvat::RateLimitError.new("rate limit exceeded", Valvat::Lookup::VIES)) + + allow(Customers::ViesCheckJob).to receive(:set).and_return retry_job + allow(retry_job).to receive(:perform_later) + + create_or_update_customer({external_id: "user_fr_123", tax_identification_number: vat_number}) + + expect(Customer.find_by(external_id: "user_fr_123").taxes.reload.sole.code).to eq "lago_eu_fr_standard" + expect(webhooks_sent.find { it["webhook_type"] == "customer.vies_check" }.dig("customer", "vies_check")).to eq({ + "valid" => false, + "valid_format" => true, + "error" => "The VIES web service returned the error: rate limit exceeded" + }) + end + end + + context "when VIES fails and invoice is blocked until retry succeeds" do + let(:vat_number) { "IT12345678901" } + let(:retry_job) { class_double(Customers::ViesCheckJob) } + + def setup_customer_with_pending_vies_check! + enable_eu_tax_management! + + create_or_update_customer(italian_attributes.merge(external_id: "user_it_123")) + customer = Customer.find_by(external_id: "user_it_123") + expect(customer.taxes.sole.code).to eq "lago_eu_it_standard" + + # Update with VAT number - VIES fails + allow_any_instance_of(Valvat).to receive(:exists?) # rubocop:disable RSpec/AnyInstance + .and_raise(::Valvat::RateLimitError.new("rate limit exceeded", Valvat::Lookup::VIES)) + allow(Customers::ViesCheckJob).to receive(:set).and_return(retry_job) + allow(retry_job).to receive(:perform_later) + + create_or_update_customer({external_id: "user_it_123", tax_identification_number: vat_number}) + + expect(customer.reload.pending_vies_check).to be_present + expect(customer.vies_check_in_progress?).to be true + + customer + end + + def resolve_vies_check!(customer) + mock_vies_check!(vat_number) + Customers::ViesCheckJob.perform_now(customer) + + expect(customer.reload.pending_vies_check).to be_nil + expect(customer.vies_check_in_progress?).to be false + + perform_enqueued_jobs + end + + def expect_pending_invoice(invoice) + expect(invoice.status).to eq "pending" + expect(invoice.tax_status).to eq "pending" + + # Fees should exist but have no taxes applied yet + invoice.fees.each do |fee| + expect(fee.applied_taxes).to be_empty + expect(fee.taxes_amount_cents).to eq 0 + end + end + + def expect_finalized_invoice_with_reverse_charge(invoice) + invoice.reload + expect(invoice.status).to eq "finalized" + expect(invoice.tax_status).to eq "succeeded" + + # Reverse charge: 0% tax + expect(invoice.taxes_amount_cents).to eq 0 + expect(invoice.applied_taxes.sole.tax_code).to eq "lago_eu_reverse_charge" + + invoice.fees.each do |fee| + next if fee.amount_cents.zero? + + expect(fee.applied_taxes.sole.tax_code).to eq "lago_eu_reverse_charge" + expect(fee.taxes_amount_cents).to eq 0 + end + end + + context "with subscription pay-in-advance plan invoice" do + it "blocks finalization and applies reverse charge after VIES succeeds" do + customer = setup_customer_with_pending_vies_check! + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: plan.code + }) + + invoice = customer.invoices.sole + expect_pending_invoice(invoice) + + resolve_vies_check!(customer) + expect_finalized_invoice_with_reverse_charge(invoice) + end + end + + context "with pay-in-advance charge invoice" do + let(:billable_metric) { create(:billable_metric, organization:, field_name: "item_id") } + let(:pay_in_arrear_plan) { create(:plan, organization:, pay_in_advance: false, amount_cents: 0) } + + it "blocks finalization and applies reverse charge after VIES succeeds" do + customer = setup_customer_with_pending_vies_check! + + create(:standard_charge, :pay_in_advance, billable_metric:, plan: pay_in_arrear_plan, + invoiceable: true, properties: {amount: "100"}) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: pay_in_arrear_plan.code + }) + + # Send event that triggers pay-in-advance charge invoice + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_#{customer.external_id}", + properties: {item_id: "item_1"} + }) + + charge_invoice = customer.invoices.order(created_at: :desc).first + expect(charge_invoice.fees.charge.count).to eq 1 + expect_pending_invoice(charge_invoice) + + resolve_vies_check!(customer) + expect_finalized_invoice_with_reverse_charge(charge_invoice) + end + end + + context "with one-off invoice" do + it "blocks finalization and applies reverse charge after VIES succeeds" do + customer = setup_customer_with_pending_vies_check! + + addon = create(:add_on, code: :test_addon, organization:, amount_cents: 5000) + create_one_off_invoice(customer, [addon]) + + invoice = customer.invoices.sole + expect_pending_invoice(invoice) + + webhooks_sent.clear + resolve_vies_check!(customer) + # Process webhook jobs enqueued via after_commit + perform_all_enqueued_jobs + expect_finalized_invoice_with_reverse_charge(invoice) + + # One-off invoices must use the one_off_created webhook type + expect(webhooks_sent.find { it["webhook_type"] == "invoice.one_off_created" }).to be_present + expect(webhooks_sent.find { it["webhook_type"] == "invoice.created" }).to be_nil + end + end + + context "with subscription periodic billing invoice" do + let(:pay_in_arrear_plan) { create(:plan, organization:, pay_in_advance: false, amount_cents: 1000) } + + it "blocks finalization and applies reverse charge after VIES succeeds" do + customer = setup_customer_with_pending_vies_check! + + travel_to(DateTime.new(2024, 1, 1, 0, 0)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: pay_in_arrear_plan.code + }) + end + + # Trigger periodic billing + travel_to(DateTime.new(2024, 2, 1, 0, 0)) do + perform_billing + end + + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.invoice_type).to eq "subscription" + expect_pending_invoice(invoice) + + resolve_vies_check!(customer) + expect_finalized_invoice_with_reverse_charge(invoice) + end + end + end + + context "when customer are created before the feature was enabled" do + it "does not create taxes until the customer is updated" do + create_or_update_customer(american_attributes.merge(external_id: "user_usa_123")) + expect(Customer.find_by(external_id: "user_usa_123").taxes).to be_empty + + enable_eu_tax_management! + expect(Customer.find_by(external_id: "user_usa_123").taxes).to be_empty + + create_or_update_customer({external_id: "user_usa_123", tax_identification_number: "US-111"}) # Not EU VAT + expect(Customer.find_by(external_id: "user_usa_123").taxes.sole.code).to eq "lago_eu_tax_exempt" + end + end + + context "when customer changes country" do + it "updates taxes" do + enable_eu_tax_management! + + create_or_update_customer(french_attributes.merge({external_id: "user_moving"})) + customer = Customer.find_by(external_id: "user_moving") + expect(customer.reload.taxes.sole.code).to eq "lago_eu_fr_standard" + + create_or_update_customer({external_id: customer.external_id, country: "DE"}) + expect(customer.reload.taxes.sole.code).to eq "lago_eu_de_standard" + end + end + + context "when customer have an invoice with other taxes" do + it "does not affect the customer taxes" do + enable_eu_tax_management! + + create_or_update_customer(italian_attributes.merge(external_id: "user_it_123")) + customer = Customer.find_by(external_id: "user_it_123") + expect(customer.taxes.sole.code).to eq "lago_eu_it_standard" + + # Make an invoice with another tax + create_tax({name: "Banking rates", code: "banking_rates", rate: 1.3}) + addon = create(:add_on, code: :test, organization:) + create_one_off_invoice(customer, [addon], taxes: ["banking_rates"]) + expect(customer.invoices.sole.taxes.sole.code).to eq "banking_rates" + + # The customer tax is unaffected + expect(customer.taxes.sole.code).to eq "lago_eu_it_standard" + end + end + + context "when organization has a default tax" do + it "does not affect the customer taxes" do + enable_eu_tax_management! + organization.taxes.where(code: "lago_eu_fr_standard").update!(applied_to_organization: true) + + mock_vies_check!("IT12345678901") + create_or_update_customer(italian_attributes.merge( + external_id: "user_it_123", tax_identification_number: "IT12345678901" + )) + customer = Customer.find_by(external_id: "user_it_123") + expect(customer.taxes.reload.sole.code).to eq "lago_eu_reverse_charge" + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: plan.code + }) + + # The default organization tax is not applied + expect(customer.invoices.sole.taxes.sole.code).to eq "lago_eu_reverse_charge" + end + end + + context "when charge has a dedicated tax" do + it "does not affect the customer taxes" do + enable_eu_tax_management! + billable_metric = create(:billable_metric, organization:, field_name: "item_id") + charge = create(:standard_charge, :pay_in_advance, billable_metric:, plan:) + create(:charge_applied_tax, charge:, tax: Tax.find_by(code: "lago_eu_fr_standard")) + + mock_vies_check!("IT12345678901") + create_or_update_customer(italian_attributes.merge( + external_id: "user_it_123", tax_identification_number: "IT12345678901" + )) + customer = Customer.find_by(external_id: "user_it_123") + expect(customer.taxes.reload.sole.code).to eq "lago_eu_reverse_charge" + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: plan.code + }) + expect(customer.invoices.sole.taxes.sole.code).to eq "lago_eu_reverse_charge" + + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_#{customer.external_id}" + }) + + # The Advance fee charge has the charge taxes even if the customer has a different tax + advance_fee_invoice = customer.invoices.order(created_at: :desc).first + expect(advance_fee_invoice.taxes.sole.code).to eq "lago_eu_fr_standard" + expect(advance_fee_invoice.fees.charge.sole.taxes.sole.code).to eq "lago_eu_fr_standard" + expect(customer.taxes.reload.sole.code).to eq "lago_eu_reverse_charge" + end + end +end diff --git a/spec/scenarios/customers/update_invoice_grace_period_spec.rb b/spec/scenarios/customers/update_invoice_grace_period_spec.rb new file mode 100644 index 0000000..88bccb7 --- /dev/null +++ b/spec/scenarios/customers/update_invoice_grace_period_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Update Customer Invoice Grace Period Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:plan) { create(:plan, pay_in_advance: true, organization:) } + + before do + stub_pdf_generation + end + + it "updates the grace period of the customer" do + ### 15 Dec: Create subscription + charge. + dec15 = DateTime.new(2022, 12, 15) + + travel_to(dec15) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + dec_invoice = subscription.invoices.first + expect(dec_invoice).to be_draft + + ### 1 Jan: Billing + jan1 = DateTime.new(2023, 1, 1) + + travel_to(jan1) do + expect do + create_or_update_customer( + { + external_id: customer.external_id, + billing_configuration: {invoice_grace_period: 0} + } + ) + end.to change { ActionMailer::Base.deliveries.count }.by(1) + + expect(customer.reload.invoice_grace_period).to eq(0) + expect(dec_invoice.reload).to be_finalized + end + end +end diff --git a/spec/scenarios/daily_usages/clickhouse/renew_daily_usage_spec.rb b/spec/scenarios/daily_usages/clickhouse/renew_daily_usage_spec.rb new file mode 100644 index 0000000..5fff6df --- /dev/null +++ b/spec/scenarios/daily_usages/clickhouse/renew_daily_usage_spec.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Daily Usage last_received_event_on Scenario (Clickhouse)", :premium, cache: :redis, clickhouse: true, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, premium_integrations:, clickhouse_events_store: true) } + let(:premium_integrations) { %w[revenue_analytics lifetime_usage] } + let(:plan) { create(:plan, organization:, name: "Test Plan", code: "test_plan", amount_cents: 10_00) } + let(:customer) { create(:customer, external_id: "cust_daily_usage_ch", organization:) } + + let(:billable_metric) { create(:sum_billable_metric, organization:, code: "ops", field_name: "ops_count") } + let(:charge) { create(:standard_charge, billable_metric:, plan:, amount_currency: "EUR", properties: {amount: "5"}) } + + let(:subscription_external_id) { "sub_daily_usage_ch" } + + def send_event!(params) + create_event({ + transaction_id: "tr_#{SecureRandom.hex(16)}" + }.merge(params)) + + # In production, the Kafka consumer triggers FlagRefreshedJob. + # Since there's no Kafka in tests, we call it directly. + subscription = organization.subscriptions.find_by(external_id: params[:external_subscription_id]) + Subscriptions::FlagRefreshedJob.perform_now(subscription.id) + end + + before { charge } + + it "tracks last_received_event_on through event lifecycle" do + travel_to(DateTime.new(2025, 1, 1)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.sole + expect(subscription.last_received_event_on).to be_nil + + travel_to(DateTime.new(2025, 1, 5, 12, 0, 0)) do + send_event!(code: billable_metric.code, properties: {ops_count: 10}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 5)) + + travel_to(DateTime.new(2025, 1, 7, 14, 0, 0)) do + send_event!(code: billable_metric.code, properties: {ops_count: 5}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 7)) + end + + context "with tricky timezone and event at midnight boundary" do + let(:customer) { create(:customer, external_id: "cust_daily_usage_ch", organization:, timezone: "Asia/Kolkata") } + + it "sets last_received_event_on in customer timezone" do + travel_to(DateTime.new(2025, 1, 1)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.sole + + # Event at 00:01 IST on Jan 6 (= 2025-01-05 18:31 UTC) + travel_to(Time.zone.parse("2025-01-05 18:31:00")) do + send_event!(code: billable_metric.code, properties: {ops_count: 7}, external_subscription_id: subscription_external_id) + end + + subscription.reload + # In IST (UTC+5:30), 18:31 UTC is 00:01 Jan 6 → date is Jan 6 + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 6)) + end + end +end diff --git a/spec/scenarios/daily_usages/fill_history_spec.rb b/spec/scenarios/daily_usages/fill_history_spec.rb new file mode 100644 index 0000000..e73e5a8 --- /dev/null +++ b/spec/scenarios/daily_usages/fill_history_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Daily Usages: Fill History", :premium, :time_travel, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, email_settings: [], premium_integrations: ["revenue_analytics"]) } + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: true) } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:, pay_in_advance: true, properties: {amount: "1"}) } + let(:subscription) { customer.subscriptions.first } + + before { charge } + + it "fills daily usage history" do + mar_18 = DateTime.new(2025, 3, 18, 11) + mar_19 = DateTime.new(2025, 3, 19, 11) + + apr_16 = DateTime.new(2025, 4, 16, 11) + apr_17 = DateTime.new(2025, 4, 17, 11) + apr_18 = DateTime.new(2025, 4, 18, 11) + apr_19 = DateTime.new(2025, 4, 19, 11) + apr_20 = DateTime.new(2025, 4, 20, 11) + may_18 = DateTime.new(2025, 5, 18, 11) + + travel_to(mar_18) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary"} + ) + end + + (mar_18..apr_18).each do |date| + travel_to(date + 1.minute) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {"item_id" => 1} + } + ) + end + end + + travel_back + + DailyUsages::FillHistoryService.call!(subscription:, from_date: mar_18.to_date, to_date: apr_20.to_date) + + expect(DailyUsage.count).to eq(34) # 34 days from mar_18 to apr_20 + + first_daily_usage = DailyUsage.find_by(usage_date: mar_18.to_date) + second_daily_usage = DailyUsage.find_by(usage_date: mar_19.to_date) + + second_last_daily_usage = DailyUsage.find_by(usage_date: apr_16.to_date) + last_daily_usage = DailyUsage.find_by(usage_date: apr_17.to_date) + + first_next_period_daily_usage = DailyUsage.find_by(usage_date: apr_18.to_date) + second_next_period_daily_usage = DailyUsage.find_by(usage_date: apr_19.to_date) + + expect(first_daily_usage).to have_attributes( + usage_date: mar_18.to_date, + from_datetime: mar_18, + to_datetime: apr_18.beginning_of_day - 1.second, + usage: match(including("amount_cents" => 100)), + usage_diff: {} + ) + + expect(second_daily_usage).to have_attributes( + usage_date: mar_19.to_date, + from_datetime: mar_18, + to_datetime: apr_18.beginning_of_day - 1.second, + usage: match(including("amount_cents" => 200)), + usage_diff: match(including("amount_cents" => 100)) + ) + + expect(second_last_daily_usage).to have_attributes( + usage_date: apr_16.to_date, + from_datetime: mar_18, + to_datetime: apr_18.beginning_of_day - 1.second, + usage: match(including("amount_cents" => 3000)), + usage_diff: match(including("amount_cents" => 100)) + ) + + expect(last_daily_usage).to have_attributes( + usage_date: apr_17.to_date, + from_datetime: mar_18, + to_datetime: apr_18.beginning_of_day - 1.second, + usage: match(including("amount_cents" => 3100)), + usage_diff: match(including("amount_cents" => 100)) + ) + + expect(first_next_period_daily_usage).to have_attributes( + usage_date: apr_18.to_date, + from_datetime: apr_18.beginning_of_day, + to_datetime: may_18.beginning_of_day - 1.second, + usage: match(including("amount_cents" => 100)), + usage_diff: match(including("amount_cents" => 100)) + ) + + expect(second_next_period_daily_usage).to have_attributes( + usage_date: apr_19.to_date, + from_datetime: apr_18.beginning_of_day, + to_datetime: may_18.beginning_of_day - 1.second, + usage: match(including("amount_cents" => 100)), + usage_diff: match(including("amount_cents" => 0)) + ) + end + + context "with recurring metric and prorated charge" do + let(:billable_metric) { create(:sum_billable_metric, :recurring, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:, properties: {amount: "1"}, prorated: true) } + + it "fills daily usage history" do + started_at = DateTime.new(2025, 4, 1, 11) + from = DateTime.new(2025, 5, 1, 11) + to = DateTime.new(2025, 5, 31, 11) + + travel_to(started_at) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar"} + ) + end + + travel_to(started_at + 1.day) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {"item_id" => 1} + } + ) + end + + travel_to(DateTime.new(2025, 5, 1, 10)) do + perform_billing + end + + travel_back + + DailyUsages::FillHistoryService.call!(subscription:, from_date: from.to_date, to_date: to.to_date) + + usages = DailyUsage.where(usage_date: from.to_date..to.to_date) + expect(usages.count).to eq(31) + expect(usages.pluck(Arel.sql("usage['amount_cents']")).uniq).to eq([100]) + end + end + + context "with timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/New_York") } + let(:billable_metric) { create(:sum_billable_metric, :recurring, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:, properties: {amount: "1"}, prorated: true) } + + it "fills daily usage history" do + started_at = DateTime.new(2025, 4, 30, 3) + from = DateTime.new(2025, 4, 1, 3) + to = DateTime.new(2025, 5, 31, 3) + + travel_to(started_at) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar"} + ) + end + + travel_to(started_at + 1.hour) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {"item_id" => 1} + } + ) + end + + travel_to(DateTime.new(2025, 5, 1, 10)) do + perform_billing + end + + travel_back + + DailyUsages::FillHistoryService.call!(subscription:, from_date: from.to_date, to_date: to.to_date) + + expect(DailyUsage.count).to eq(32) + expect(DailyUsage.order(usage_date: :asc).first.usage_date).to eq(Date.new(2025, 4, 30)) + expect(DailyUsage.order(usage_date: :asc).last.usage_date).to eq(Date.new(2025, 5, 31)) + end + end +end diff --git a/spec/scenarios/daily_usages/renew_daily_usage_spec.rb b/spec/scenarios/daily_usages/renew_daily_usage_spec.rb new file mode 100644 index 0000000..2df2f20 --- /dev/null +++ b/spec/scenarios/daily_usages/renew_daily_usage_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Daily Usage last_received_event_on Scenario", :premium, cache: :redis do + let(:organization) { create(:organization, webhook_url: nil, premium_integrations:) } + let(:premium_integrations) { %w[revenue_analytics lifetime_usage] } + let(:plan) { create(:plan, organization:, name: "Test Plan", code: "test_plan", amount_cents: 10_00) } + let(:customer) { create(:customer, external_id: "cust_daily_usage", organization:) } + + let(:billable_metric) { create(:sum_billable_metric, organization:, code: "ops", field_name: "ops_count") } + let(:charge) { create(:standard_charge, billable_metric:, plan:, amount_currency: "EUR", properties: {amount: "5"}) } + + let(:subscription_external_id) { "sub_daily_usage" } + + def send_event!(params) + create_event({ + transaction_id: "tr_#{SecureRandom.hex(16)}" + }.merge(params)) + end + + before { charge } + + it "tracks last_received_event_on through event lifecycle" do + travel_to(DateTime.new(2025, 1, 1)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.sole + expect(subscription.last_received_event_on).to be_nil + + # Sending an event sets last_received_event_on to today in customer's timezone + travel_to(DateTime.new(2025, 1, 5, 12, 0, 0)) do + send_event!(code: billable_metric.code, properties: {ops_count: 10}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 5)) + + # Running job at midnight same day (Jan 6 00:05 UTC) — queries for last_received_event_on = yesterday (Jan 5) + # This matches! So daily usage for Jan 5 is computed. + travel_to(DateTime.new(2025, 1, 6, 0, 5, 0)) do + perform_usage_update + end + + expect(DailyUsage.where(subscription:).count).to eq(1) + + # Sending another event updates the date + travel_to(DateTime.new(2025, 1, 7, 14, 0, 0)) do + send_event!(code: billable_metric.code, properties: {ops_count: 5}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 7)) + end + + # Scenario: Multi-day lifecycle with late-arriving events and idle days. + # + # Day 1 (Jan 5): 12:00 - event_0 received + # Day 2 (Jan 6): 00:15 - recalculate → includes event_0 + # Day 3 (Jan 7): 00:15 - no recalculation (last_received_event_on = Jan 5 < yesterday Jan 6) + # Day 4 (Jan 8): 12:00 - event_1 received + # Day 5 (Jan 9): 00:01 - event_2 received (backdated to Jan 7) + # 00:10 - event_3 received + # 00:15 - recalculate → includes event_0, event_1, event_2 but not event_3 + # Day 6 (Jan 10): 00:15 - recalculate → includes event_3 + # + # Key behavior tested: the >= condition on last_received_event_on allows the subscription + # to be selected on day 5 even though last_received_event_on is today (Jan 9), not yesterday (Jan 8). + it "handles multi-day lifecycle with late-arriving events and idle days" do + travel_to(DateTime.new(2025, 1, 1)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.sole + + ## Day 1 (Jan 5): event_0 received at 12:00 + travel_to(DateTime.new(2025, 1, 5, 12, 0, 0)) do + send_event!(code: billable_metric.code, properties: {ops_count: 10}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 5)) + + ## Day 2 (Jan 6): 00:15 - recalculate usage, includes event_0 + # last_received_event_on = Jan 5 >= yesterday (Jan 5) → match + travel_to(DateTime.new(2025, 1, 6, 0, 15, 0)) do + perform_usage_update + end + + expect(DailyUsage.where(subscription:).count).to eq(1) + day1_usage = DailyUsage.where(subscription:, usage_date: Date.new(2025, 1, 5)).sole + # event_0: 10 ops * 5 EUR = 5000 cents + expect(day1_usage.usage["amount_cents"]).to eq(5000) + + ## Day 3 (Jan 7): 00:15 - no recalculation + # last_received_event_on = Jan 5 >= yesterday (Jan 6) → false → subscription NOT selected + travel_to(DateTime.new(2025, 1, 7, 0, 15, 0)) do + perform_usage_update + end + + expect(DailyUsage.where(subscription:).count).to eq(1) # still only 1 record + + ## Day 4 (Jan 8): event_1 received at 12:00 + travel_to(DateTime.new(2025, 1, 8, 12, 0, 0)) do + send_event!(code: billable_metric.code, properties: {ops_count: 5}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 8)) + + ## Day 5 (Jan 9): event_2 at 00:01 (backdated to Jan 7) + # event_2 is received today but its timestamp is 2 days ago + travel_to(DateTime.new(2025, 1, 9, 0, 1, 0)) do + send_event!( + code: billable_metric.code, + properties: {ops_count: 3}, + external_subscription_id: subscription_external_id, + timestamp: DateTime.new(2025, 1, 7, 12, 0, 0).to_i + ) + end + + subscription.reload + # last_received_event_on is set to current date (Jan 9), not the event's backdated timestamp + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 9)) + + ## Day 5 (Jan 9): 00:15 - recalculate usage + # last_received_event_on = Jan 9 >= yesterday (Jan 8) → match (this is why >= matters!) + # Includes event_0 (10) + event_1 (5) + event_2 (3) = 18 ops * 5 EUR = 9000 cents + # event_3 is NOT yet received + travel_to(DateTime.new(2025, 1, 9, 0, 15, 0)) do + perform_usage_update + end + + expect(DailyUsage.where(subscription:).count).to eq(2) + day4_usage = DailyUsage.where(subscription:, usage_date: Date.new(2025, 1, 8)).sole + expect(day4_usage.usage["amount_cents"]).to eq(9000) + + ## Day 5 (Jan 9): event_3 received (after the job ran) + travel_to(DateTime.new(2025, 1, 9, 0, 20, 0)) do + send_event!(code: billable_metric.code, properties: {ops_count: 7}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 9)) + + ## Day 6 (Jan 10): 00:15 - recalculate usage, includes event_3 + # last_received_event_on = Jan 9 >= yesterday (Jan 9) → match + # Includes all events: event_0 (10) + event_1 (5) + event_2 (3) + event_3 (7) = 25 ops * 5 EUR = 12500 cents + travel_to(DateTime.new(2025, 1, 10, 0, 15, 0)) do + perform_usage_update + end + + expect(DailyUsage.where(subscription:).count).to eq(3) + day5_usage = DailyUsage.where(subscription:, usage_date: Date.new(2025, 1, 9)).sole + expect(day5_usage.usage["amount_cents"]).to eq(12_500) + end + + # Scenario: event arrives at 00:01 local time (just after midnight), job runs at 00:02. + # The event belongs to the NEW day so the job looking for "yesterday" won't pick it up. + # The next day's job run WILL process it. + # + # Using Asia/Kolkata (UTC+5:30) — a non-standard half-hour offset timezone. + # 2025-01-05 18:31 UTC = 2025-01-06 00:01 IST → event date is Jan 6 in customer TZ + # 2025-01-05 18:32 UTC = 2025-01-06 00:02 IST → job queries last_received_event_on = Jan 5 → no match (it's Jan 6) + # 2025-01-06 18:32 UTC = 2025-01-07 00:02 IST → job queries last_received_event_on = Jan 6 → match! + context "with tricky timezone and event at midnight boundary" do + let(:customer) { create(:customer, external_id: "cust_daily_usage", organization:, timezone: "Asia/Kolkata") } + + it "defers midnight event to next day's daily usage computation" do + travel_to(DateTime.new(2025, 1, 1)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.sole + + # Send an earlier event on Jan 4 (IST) so there's something to compute + # 2025-01-04 12:00 UTC = 2025-01-04 17:30 IST + travel_to(Time.zone.parse("2025-01-04 12:00:00")) do + send_event!(code: billable_metric.code, properties: {ops_count: 3}, external_subscription_id: subscription_external_id) + end + + subscription.reload + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 4)) + + # Job runs at 00:05 IST on Jan 5 (= 2025-01-04 18:35 UTC) + # Queries last_received_event_on = Jan 4 (yesterday in IST) → match + travel_to(Time.zone.parse("2025-01-04 18:35:00")) do + perform_usage_update + end + + expect(DailyUsage.where(subscription:).count).to eq(1) + jan4_usage = DailyUsage.where(subscription:).last + expect(jan4_usage.usage_date).to eq(Date.new(2025, 1, 4)) + + # Event_a arrives at 00:01 IST on Jan 6 (= 2025-01-05 18:31 UTC) + travel_to(Time.zone.parse("2025-01-05 18:31:00")) do + send_event!(code: billable_metric.code, properties: {ops_count: 7}, external_subscription_id: subscription_external_id) + end + + subscription.reload + # last_received_event_on = Jan 6 (customer's local date) + expect(subscription.last_received_event_on).to eq(Date.new(2025, 1, 6)) + + # Job runs at 00:02 IST on Jan 6 (= 2025-01-05 18:32 UTC) + # Queries last_received_event_on = Jan 5 (yesterday in IST) + # But last_received_event_on is Jan 6 → NO match → subscription NOT selected + travel_to(Time.zone.parse("2025-01-05 18:32:00")) do + perform_usage_update + end + + # No new daily usage was created for Jan 5 + expect(DailyUsage.where(subscription:, usage_date: Date.new(2025, 1, 5)).count).to eq(0) + + # Next day: job runs at 00:02 IST on Jan 7 (= 2025-01-06 18:32 UTC) + # Queries last_received_event_on = Jan 6 → MATCH → subscription selected + # Computes daily usage for Jan 6 which now includes event_a + travel_to(Time.zone.parse("2025-01-06 18:32:00")) do + perform_usage_update + end + + jan6_usage = DailyUsage.where(subscription:, usage_date: Date.new(2025, 1, 6)).first + expect(jan6_usage).to be_present + # Jan 6 usage should include both events (3 + 7 = 10 ops_count → 50 amount_cents at rate "5") + expect(jan6_usage.usage["amount_cents"]).to eq(5000) + end + end +end diff --git a/spec/scenarios/daily_usages/yearly_plan_with_monthly_fixed_charges_spec.rb b/spec/scenarios/daily_usages/yearly_plan_with_monthly_fixed_charges_spec.rb new file mode 100644 index 0000000..d3ee1e3 --- /dev/null +++ b/spec/scenarios/daily_usages/yearly_plan_with_monthly_fixed_charges_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Scenario: Yearly plan with bill_fixed_charges_monthly terminates mid-period +# +# Preconditions: +# - Organization with premium feature 'revenue_analytics' +# - Yearly plan with bill_fixed_charges_monthly: true and a fixed charge +# +# Steps: +# 1. Create subscription in a non-first month of the yearly period (March) +# 2. Terminate subscription +# +# Expected: +# - Invoice is created with fixed charge fees only +# - InvoiceSubscription has nil charges_from_datetime/charges_to_datetime +# (no usage charges to bill in non-first month of yearly period) +# - DailyUsages::FillFromInvoiceJob completes without error +# - No DailyUsage record is created (no charge boundaries to record) +describe "Daily Usage for yearly plan with monthly fixed charges", :premium, cache: :redis do + let(:organization) { create(:organization, webhook_url: nil, premium_integrations: %w[revenue_analytics]) } + let(:customer) { create(:customer, external_id: "cust_yearly_fc", organization:) } + let(:add_on) { create(:add_on, organization:, amount_cents: 2_499_00, amount_currency: "EUR") } + + let(:plan) do + create( + :plan, + organization:, + amount_cents: 0, + amount_currency: "EUR", + interval: "yearly", + pay_in_advance: false, + bill_fixed_charges_monthly: true + ) + end + + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + units: 1, + properties: {amount: "2499"} + ) + end + + before { fixed_charge } + + it "does not fail when terminating subscription in non-first month of yearly period" do + # Create subscription in March (non-first month for calendar yearly plan) + travel_to(DateTime.new(2025, 3, 1)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_yearly_fc", + plan_code: plan.code, + billing_time: "calendar" + }) + end + + subscription = customer.subscriptions.sole + + # Terminate subscription — triggers invoice with invoicing_reason: :subscription_terminating + # The InvoiceSubscription will have nil charges_from_datetime/charges_to_datetime + # because should_fill_charges_boundaries? returns false in non-first month + travel_to(DateTime.new(2025, 3, 15)) do + terminate_subscription(subscription) + end + + # Verify invoice was created with fixed charge fees + invoice = subscription.invoices.order(:created_at).last + expect(invoice).to be_present + expect(invoice.status).to eq("finalized") + + # Verify the invoice_subscription has nil charge boundaries + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription.charges_from_datetime).to be_nil + expect(invoice_subscription.charges_to_datetime).to be_nil + + # Verify no daily usage was created (no charge boundaries to record) + expect(DailyUsage.where(subscription:).count).to eq(0) + end +end diff --git a/spec/scenarios/delete_customer_spec.rb b/spec/scenarios/delete_customer_spec.rb new file mode 100644 index 0000000..8d746f4 --- /dev/null +++ b/spec/scenarios/delete_customer_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Delete Customer Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:plan) { create(:plan, pay_in_advance: true, organization:, amount_cents: 1000) } + let(:metric) { create(:billable_metric, organization:) } + + it "deletes the customer and terminate relations" do + ### 15 Dec: Create subscription + charge. + dec15 = DateTime.new(2022, 12, 15) + + travel_to(dec15) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "3"}) + end + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + dec_invoice = subscription.invoices.first + expect(dec_invoice).to be_draft + + ### 1 Jan: Billing + jan1 = DateTime.new(2023, 1, 1) + + travel_to(jan1) do + perform_billing + expect(subscription.invoices.count).to eq(2) + end + + jan_invoice = subscription.invoices.order(created_at: :desc).first + expect(jan_invoice).to be_draft + + ### 10 Jan: Delete Plan + jan20 = DateTime.new(2023, 1, 20) + + travel_to(jan20) do + # Downgrade subscription + downgrade_plan = create(:plan, organization:, amount_cents: 500) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: downgrade_plan.code, + subscription_at: "2023-02-01T00:00:00Z" + } + ) + pending_subscription = customer.subscriptions.pending.first + + # Create coupon and apply it to customer + create_coupon( + { + name: "coupon1", + code: "coupon1_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 123, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 15.days, + reusable: false + } + ) + apply_coupon( + { + external_customer_id: customer.external_id, + coupon_code: "coupon1_code" + } + ) + applied_coupon = customer.applied_coupons.active.first + + # Create wallet + create_wallet( + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at: (Time.current + 15.days).iso8601 + } + ) + wallet = customer.wallets.active.first + + delete_customer(customer) + + # Customer is discarded + expect(customer.reload).to be_discarded + + perform_all_enqueued_jobs + + # Subscription is terminated + expect(subscription.reload).to be_terminated + + # Pending subscription is canceled + expect(pending_subscription.reload).to be_canceled + + # Applied coupon is terminated + expect(applied_coupon.reload).to be_terminated + + # Wallet is terminated + expect(wallet.reload).to be_terminated + + # A new termination invoice has been created + expect(subscription.invoices.count).to eq(3) + term_invoice = subscription.invoices.order(created_at: :desc).first + expect(term_invoice).to be_finalized + + # Draft invoices are now finalized + expect(dec_invoice.reload).to be_finalized + expect(jan_invoice.reload).to be_finalized + end + end +end diff --git a/spec/scenarios/delete_plan_spec.rb b/spec/scenarios/delete_plan_spec.rb new file mode 100644 index 0000000..dbcd822 --- /dev/null +++ b/spec/scenarios/delete_plan_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Delete Plan Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:plan) { create(:plan, pay_in_advance: true, organization:, amount_cents: 1000) } + let(:metric) { create(:billable_metric, organization:) } + + it "deletes the plan, terminates subscriptions and finalizes draft invoices" do + ### 15 Dec: Create subscription + charge. + dec15 = DateTime.new(2022, 12, 15) + + travel_to(dec15) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "3"}) + create(:plan, pay_in_advance: true, organization:, amount_cents: 1000, parent_id: plan.id) + end + + subscription = customer.subscriptions.first + dec_invoice = subscription.invoices.first + expect(dec_invoice).to be_draft + + ### 1 Jan: Billing + jan1 = DateTime.new(2023, 1, 1) + + travel_to(jan1) do + perform_billing + expect(subscription.invoices.count).to eq(2) + end + + jan_invoice = subscription.invoices.order(created_at: :desc).first + expect(jan_invoice).to be_draft + + ### 10 Jan: Delete Plan + jan20 = DateTime.new(2023, 1, 20) + + travel_to(jan20) do + overridden_plan = plan.children.first + delete_with_token(organization, "/api/v1/plans/#{plan.code}") + + # Plan is pending deletion + expect(plan.reload).to be_pending_deletion + expect(overridden_plan.reload).to be_pending_deletion + + perform_all_enqueued_jobs + + # Plan is now discarded + expect(plan.reload).not_to be_pending_deletion + expect(overridden_plan.reload).not_to be_pending_deletion + expect(plan).to be_discarded + expect(overridden_plan).to be_discarded + + # Subscription is terminated + expect(subscription.reload).to be_terminated + + # A new termination invoice has been created + expect(subscription.invoices.count).to eq(3) + term_invoice = subscription.invoices.order(created_at: :desc).first + expect(term_invoice).to be_finalized + + # Draft invoices are now finalized + expect(dec_invoice.reload).to be_finalized + expect(jan_invoice.reload).to be_finalized + end + end +end diff --git a/spec/scenarios/dunning/dunning_campaign_v1_spec.rb b/spec/scenarios/dunning/dunning_campaign_v1_spec.rb new file mode 100644 index 0000000..9078567 --- /dev/null +++ b/spec/scenarios/dunning/dunning_campaign_v1_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Dunning Campaign v1", :premium do + let(:organization) do + create(:organization, name: "JC AI", premium_integrations: %w[auto_dunning]) + end + + let(:billing_entity) do + create(:billing_entity, organization:, name: "ACME Corp", email_settings: [], applied_dunning_campaign: dunning_campaign) + end + + let(:dunning_campaign) do + create(:dunning_campaign, organization:, + max_attempts: 2, days_between_attempts: 2) + end + let(:dunning_campaign_threshold) do + create(:dunning_campaign_threshold, dunning_campaign:, amount_cents: 150_00, currency: "EUR") + end + let(:stripe_cus_id) { "cus_123456789" } + let(:stripe_pm_id) { "pm_123456" } + + let(:stripe_provider) { create(:stripe_provider, organization:) } + + let(:customer) do + create( + :customer, + organization:, + billing_entity:, + payment_provider: :stripe, + payment_provider_code: stripe_provider.code, + net_payment_term: 2 + ) + end + + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_provider, provider_customer_id: stripe_cus_id) } + + let(:external_subscription_id) { "sub_overdue-dunning-campaign-v1" } + let(:plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 149_00) } + + let(:webhooks_sent) { [] } + + include_context "with webhook tracking" + + before do + stub_pdf_generation + stripe_customer + dunning_campaign_threshold + + stub_request(:get, "https://api.stripe.com/v1/customers/#{stripe_customer.provider_customer_id}") + .and_return(status: 200, body: get_stripe_fixtures("customer_retrieve_response.json") do |h| + h[:invoice_settings][:default_payment_method] = stripe_pm_id + end) + stub_request(:get, "https://api.stripe.com/v1/customers/#{stripe_customer.provider_customer_id}/payment_methods/pm_123456") + .and_return(status: 200, body: get_stripe_fixtures("retrieve_payment_method_response.json") do |h| + h[:id] = stripe_pm_id + h[:customer] = stripe_cus_id + end) + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .and_return( + status: 402, + body: lambda { |_req| + get_stripe_fixtures("payment_intent_card_declined_response.json") do |h| + h[:error][:payment_intent][:id] = "pi_#{SecureRandom.hex}" + end + } + ) + stub_request(:post, "https://api.stripe.com/v1/checkout/sessions") + .and_return(status: 200, body: {url: "https://stripe.com/checkout/session/cs_test_123"}.to_json) + + WebMock.after_request do |request_signature, response| + if request_signature.uri.path.match?(%r{/v1/payment_intents}) + request_body_hash = if request_signature.url_encoded? + Rack::Utils.parse_nested_query(request_signature.body) + elsif request_signature.body.json_encoded? + JSON.parse(request_signature.body) + end + + Jobs::MockStripeWebhookEventJob.perform_later( + organization, + request_body_hash, + JSON.parse(response.body) + ) + end + end + end + + it "retries overdue invoices" do + travel_to(DateTime.new(2025, 1, 1, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + + expect(webhooks_sent.map { it["webhook_type"] }).to eq(%w[ + subscription.started + invoice.created + invoice.generated + invoice.payment_failure + ]) + invoice = customer.invoices.sole + expect(invoice.payment_status).to eq("failed") + expect(invoice.payment_due_date).to eq(Date.new(2025, 1, 3)) + end + + # The day after payment_due_date, the invoice should be marked as overdue + travel_to(DateTime.new(2025, 1, 4, 13)) do + perform_overdue_balance_update + + invoice = customer.invoices.sole + expect(invoice).to be_payment_overdue + expect(customer.overdue_balance_cents).to eq(149_00) + + # Performing dunning has no effect because the threshold is 150 and we have only 149 overdue + perform_dunning + expect(customer.payment_requests.count).to eq(0) + end + + # Create a one-off invoice to reach the threshold + travel_to(DateTime.new(2025, 1, 4, 18)) do + addon = create(:add_on, organization:) + create_one_off_invoice(customer, [addon], units: 3) + perform_all_enqueued_jobs + + oneoff = customer.invoices.one_off.sole + expect(oneoff.payment_status).to eq("failed") + expect(oneoff.payment_due_date).to eq(Date.new(2025, 1, 6)) + end + + travel_to(DateTime.new(2025, 1, 7, 10)) do + perform_overdue_balance_update + expect(customer.invoices.one_off.sole).to be_payment_overdue + + expect(ActionMailer::Base.deliveries.count).to eq(0) + perform_dunning + # NOTE: Email is sent twice: first synchronously after Stripe response, and then when the webhook is received + expect(ActionMailer::Base.deliveries.count).to eq(2) + expect(ActionMailer::Base.deliveries.map(&:subject)).to all eq "Your overdue balance from ACME Corp" + + mail = ActionMailer::Base.deliveries.last + expect(mail.subject).to eq "Your overdue balance from ACME Corp" + + pr = customer.payment_requests.sole + expect(pr.amount_cents).to eq(155_00) + end + + # The next 2 days nothing happens + [DateTime.new(2025, 1, 8, 10), DateTime.new(2025, 1, 9, 10)].each do |date| + travel_to(date) do + perform_overdue_balance_update + perform_dunning + expect(ActionMailer::Base.deliveries.count).to eq(2) # nothing new + end + end + + # The day after, we make another attempt + travel_to(DateTime.new(2025, 1, 10, 10)) do + perform_overdue_balance_update + perform_dunning + expect(ActionMailer::Base.deliveries.count).to eq(4) + + expect(customer.payment_requests.reload.map(&:amount_cents)).to all eq 155_00 + end + + # After the last attempt the invoice are still overdue but we don't try anymore + travel_to(DateTime.new(2025, 1, 13, 13)) do + perform_overdue_balance_update + perform_dunning + expect(ActionMailer::Base.deliveries.count).to eq(4) # Nothing new + end + end +end diff --git a/spec/scenarios/emails/invoice_email_activity_spec.rb b/spec/scenarios/emails/invoice_email_activity_spec.rb new file mode 100644 index 0000000..60b6186 --- /dev/null +++ b/spec/scenarios/emails/invoice_email_activity_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Invoice Email Activity Logging", :capture_kafka_messages do + let(:email) { "customer@example.com" } + let(:email_settings) { ["invoice.finalized"] } + let(:organization) { create(:organization, webhook_url: nil) } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:customer) { create(:customer, organization:, email:) } + let(:email_messages) { kafka_messages.select { |msg| JSON.parse(msg[:payload])["activity_type"] == "email.sent" } } + + before do + # Create a pay-in-advance subscription which generates and finalizes an invoice immediately + Subscriptions::CreateService.call(customer:, plan:, params: {external_id: SecureRandom.uuid}) + + # Enable email scenario for the billing entity + organization.default_billing_entity.tap { |be| be.update!(email:, email_settings:) } + + # Enable activity logging (requires Kafka/ClickHouse in production) + stub_const("Utils::EmailActivityLog::AVAILABLE", true) + stub_const("Utils::EmailActivityLog::TOPIC", "activity_logs") + + perform_all_enqueued_jobs + end + + # Pretend License is premium for these tests + around do |example| + old_premium = License.premium? + License.instance_variable_set(:@premium, true) + example.run + ensure + License.instance_variable_set(:@premium, old_premium) + end + + context "when invoice is finalized" do + it "logs email activity to Kafka" do + expect(email_messages.size).to eq(1) + + payload = JSON.parse(email_messages.first[:payload]) + expect(payload).to include( + "activity_type" => "email.sent", + "activity_source" => "system", + "resource_type" => "Invoice" + ) + end + end + + context "when email scenario is disabled" do + let(:email_settings) { [] } + + it "does not log email activity" do + expect(email_messages).to be_empty + end + end + + context "when customer has no email" do + let(:email) { nil } + + it "does not log email activity" do + expect(email_messages).to be_empty + end + end + + context "when License is not premium" do + around do |example| + License.instance_variable_set(:@premium, false) + example.run + end + + it "does not log email activity" do + expect(email_messages).to be_empty + end + end +end diff --git a/spec/scenarios/estimate_events_spec.rb b/spec/scenarios/estimate_events_spec.rb new file mode 100644 index 0000000..205d3fe --- /dev/null +++ b/spec/scenarios/estimate_events_spec.rb @@ -0,0 +1,786 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Estimate In Advance Events" do + [ + :postgres, + :clickhouse + ].each do |store| + context "with #{store} store", clickhouse: store == :clickhouse do + let(:organization) { create(:organization, webhook_url: nil, clickhouse_events_store: store == :clickhouse) } + let(:customer) { create(:customer, organization: organization) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + + let(:metric) { create(:billable_metric, organization:) } + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: true, + properties: {amount: "10"} + ) + end + + before { charge } + + context "with a count aggregation" do + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1000) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1000) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + end + end + + context "with a sum aggregation" do + let(:metric) { create(:sum_billable_metric, organization:) } + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(4000) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(4000) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + end + + context "when billable metric is recurring" do + let(:metric) { create(:sum_billable_metric, :recurring, organization:) } + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2024-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Create an event + travel_to(Time.zone.parse("2024-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(4000) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(4000) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + end + end + + context "when charge model is dynamic" do + let(:charge) do + create( + :dynamic_charge, + plan:, + billable_metric: metric, + pay_in_advance: true + ) + end + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 1}, + precise_total_amount_cents: 200 + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(200) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 1}, + precise_total_amount_cents: 200 + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 1}, + precise_total_amount_cents: 200 + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(200) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + end + end + + context "when charge model is percentage", :premium do + let(:charge) do + create( + :percentage_charge, + plan:, + billable_metric: metric, + pay_in_advance: true, + properties: {rate: "0.5", per_transaction_min_amount: "12"} + ) + end + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1200) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 20_000} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(10_000) + expect(fee["units"]).to eq("20000.0") + expect(fee["events_count"]).to eq(1) + end + end + + context "when billable metric is recurring" do + let(:metric) { create(:sum_billable_metric, :recurring, organization:) } + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2024-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Create an event + travel_to(Time.zone.parse("2024-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1200) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 20_000} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(10_000) + expect(fee["units"]).to eq("20000.0") + expect(fee["events_count"]).to eq(1) + end + end + end + + context "with free units" do + let(:charge) do + create( + :percentage_charge, + plan:, + billable_metric: metric, + pay_in_advance: true, + properties: {rate: "50", free_units_per_events: 1} + ) + end + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(200) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + end + end + end + end + + context "with a prorated sum aggregation" do + let(:metric) { create(:sum_billable_metric, :recurring, organization:) } + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: true, + properties: {amount: "10"}, + prorated: true + ) + end + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(3867) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => 4} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(3600) + expect(fee["units"]).to eq("4.0") + expect(fee["events_count"]).to eq(1) + end + end + end + + context "with a unique_count aggregation" do + let(:metric) { create(:unique_count_billable_metric, organization:) } + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1000) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("0.0") + expect(fee["events_count"]).to eq(1) + end + + travel_to(Time.zone.parse("2025-09-05")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "5678"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1000) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + travel_to(Time.zone.parse("2025-09-05")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234", :operation_type => "remove"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("0.0") + expect(fee["events_count"]).to eq(1) + end + end + + context "when billable metric is recurring" do + let(:metric) { create(:unique_count_billable_metric, :recurring, organization:) } + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2024-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Create an event + travel_to(Time.zone.parse("2024-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + end + + # Estimate event with a pre-existing one + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("0.0") + expect(fee["events_count"]).to eq(1) + end + + # Estimate event without a pre-existing one + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "9876"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1000) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("0.0") + expect(fee["events_count"]).to eq(1) + end + + travel_to(Time.zone.parse("2025-09-05")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "5678"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(1000) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + travel_to(Time.zone.parse("2025-09-05")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234", :operation_type => "remove"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("0.0") + expect(fee["events_count"]).to eq(1) + end + end + end + end + + context "with a prorated unique_count aggregation" do + let(:metric) { create(:unique_count_billable_metric, :recurring, organization:) } + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: true, + properties: {amount: "10"}, + prorated: true + ) + end + + it "returns the estimated price of the events, taking care of the existing ones" do + travel_to(Time.zone.parse("2025-09-01")) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.last + + # Estimate event without existing events + travel_to(Time.zone.parse("2025-09-02")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(967) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + # Create an event + travel_to(Time.zone.parse("2025-09-03")) do + create_event({ + transaction_id: SecureRandom.uuid, + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + end + + # Estimate a new event with an existing one + travel_to(Time.zone.parse("2025-09-04")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("0.0") + expect(fee["events_count"]).to eq(1) + end + + travel_to(Time.zone.parse("2025-09-05")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "5678"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(867) + expect(fee["units"]).to eq("1.0") + expect(fee["events_count"]).to eq(1) + end + + travel_to(Time.zone.parse("2025-09-05")) do + result = estimate_event({ + code: metric.code, + external_subscription_id: subscription.external_id, + properties: {metric.field_name => "1234", :operation_type => "remove"} + }) + + fee = result[:fees].first + expect(fee["amount_cents"]).to eq(0) + expect(fee["units"]).to eq("0.0") + expect(fee["events_count"]).to eq(1) + end + end + end + end + end +end diff --git a/spec/scenarios/events_targeting_wallets_spec.rb b/spec/scenarios/events_targeting_wallets_spec.rb new file mode 100644 index 0000000..59e2986 --- /dev/null +++ b/spec/scenarios/events_targeting_wallets_spec.rb @@ -0,0 +1,525 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Events Targeting Wallets Scenarios", transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + describe "pay in arrears charges with wallet targeting", :premium do + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + accepts_target_wallet: true, + properties: {amount: "10"} + ) + end + + let(:wallet1) { create(:wallet, :with_inbound_transaction, customer:, code: "wallet_1", name: "Wallet 1", balance_cents: 20_000, credits_balance: 200.0) } + let(:wallet2) { create(:wallet, :with_inbound_transaction, customer:, code: "wallet_2", name: "Wallet 2", balance_cents: 25_000, credits_balance: 250.0) } + + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + charge + end + + it "groups fees by target_wallet_code and applies credits from correct wallets" do + jan15 = DateTime.new(2023, 1, 15) + + travel_to(jan15) do + wallet1 + wallet2 + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_wallet_test", + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.find_by(external_id: "sub_wallet_test") + + # Send events with different target_wallet_code values + travel_to(jan15 + 1.day) do + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "10", target_wallet_code: "wallet_1"} + }) + + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "5", target_wallet_code: "wallet_1"} + }) + + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "20", target_wallet_code: "wallet_2"} + }) + + # Refresh wallets to update ongoing balance + recalculate_wallet_balances + + # Verify ongoing balance reflects targeted usage + # wallet_1: 15 units * $10 = $150 ongoing usage + expect(wallet1.reload.ongoing_usage_balance_cents).to eq(15_000) + # wallet_2: 20 units * $10 = $200 ongoing usage + expect(wallet2.reload.ongoing_usage_balance_cents).to eq(20_000) + end + + # Bill the subscription at end of month + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + # Verify invoice has correct grouped fees + invoice = subscription.invoices.first + expect(invoice).to be_present + + charge_fees = invoice.fees.charge + expect(charge_fees.count).to eq(2) + + wallet1_fee = charge_fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_1" } + wallet2_fee = charge_fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_2" } + + expect(wallet1_fee.units).to eq(15) + expect(wallet1_fee.amount_cents).to eq(15_000) + + expect(wallet2_fee.units).to eq(20) + expect(wallet2_fee.amount_cents).to eq(20_000) + + # Verify credits were applied from correct wallets + expect(invoice.prepaid_credit_amount_cents).to eq(35_000) + + # wallet_1: had $200, used $150, should have $50 left + expect(wallet1.reload.balance_cents).to eq(5_000) + + # wallet_2: had $250, used $200, should have $50 left + expect(wallet2.reload.balance_cents).to eq(5_000) + + # Verify wallet transactions + wallet1_tx = wallet1.wallet_transactions.where(invoice:) + wallet2_tx = wallet2.wallet_transactions.where(invoice:) + + expect(wallet1_tx.count).to eq(1) + expect(wallet1_tx.first.amount_cents).to eq(15_000) + + expect(wallet2_tx.count).to eq(1) + expect(wallet2_tx.first.amount_cents).to eq(20_000) + end + + context "with pricing_group_keys and wallet targeting combined" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + accepts_target_wallet: true, + properties: { + amount: "5", + pricing_group_keys: ["region"] + } + ) + end + + it "groups fees by both pricing_group_keys and target_wallet_code and applies credits correctly" do + jan15 = DateTime.new(2023, 1, 15) + + travel_to(jan15) do + wallet1 + wallet2 + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_combined", + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.find_by(external_id: "sub_combined") + + # Send events with different combinations of region and target_wallet_code + travel_to(jan15 + 1.day) do + # wallet_1, region: eu - 10 units * $5 = $50 + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "10", region: "eu", target_wallet_code: "wallet_1"} + }) + + # wallet_1, region: us - 15 units * $5 = $75 + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "15", region: "us", target_wallet_code: "wallet_1"} + }) + + # wallet_2, region: eu - 20 units * $5 = $100 + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "20", region: "eu", target_wallet_code: "wallet_2"} + }) + + # Refresh wallets to check ongoing balance + recalculate_wallet_balances + + # wallet_1: $50 (eu) + $75 (us) = $125 ongoing usage + expect(wallet1.reload.ongoing_usage_balance_cents).to eq(12_500) + # wallet_2: $100 (eu) ongoing usage + expect(wallet2.reload.ongoing_usage_balance_cents).to eq(10_000) + end + + # Bill at end of month + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + invoice = subscription.invoices.first + charge_fees = invoice.fees.charge + + # Should have 3 fees: (wallet_1, eu), (wallet_1, us), (wallet_2, eu) + expect(charge_fees.count).to eq(3) + + wallet1_eu_fee = charge_fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_1" && f.grouped_by["region"] == "eu" } + wallet1_us_fee = charge_fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_1" && f.grouped_by["region"] == "us" } + wallet2_eu_fee = charge_fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_2" && f.grouped_by["region"] == "eu" } + + expect(wallet1_eu_fee.units).to eq(10) + expect(wallet1_eu_fee.amount_cents).to eq(5_000) + + expect(wallet1_us_fee.units).to eq(15) + expect(wallet1_us_fee.amount_cents).to eq(7_500) + + expect(wallet2_eu_fee.units).to eq(20) + expect(wallet2_eu_fee.amount_cents).to eq(10_000) + + # Verify credits applied from correct wallets + # wallet_1: had $200, used $125 ($50 + $75), should have $75 left + expect(wallet1.reload.balance_cents).to eq(7_500) + + # wallet_2: had $250, used $100, should have $150 left + expect(wallet2.reload.balance_cents).to eq(15_000) + + # Verify wallet transactions + wallet1_tx = wallet1.wallet_transactions.where(invoice:) + wallet2_tx = wallet2.wallet_transactions.where(invoice:) + + expect(wallet1_tx.count).to eq(1) + expect(wallet1_tx.first.amount_cents).to eq(12_500) + + expect(wallet2_tx.count).to eq(1) + expect(wallet2_tx.first.amount_cents).to eq(10_000) + end + end + end + + describe "pay in advance charges with wallet targeting", :premium do + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + let(:charge) do + create( + :standard_charge, + :pay_in_advance, + invoiceable: true, + plan:, + billable_metric:, + accepts_target_wallet: true, + properties: {amount: "10"} + ) + end + + let(:wallet1) { create(:wallet, :with_inbound_transaction, customer:, code: "wallet_1", name: "Wallet 1", balance_cents: 15_000, credits_balance: 150.0) } + let(:wallet2) { create(:wallet, :with_inbound_transaction, customer:, code: "wallet_2", name: "Wallet 2", balance_cents: 10_000, credits_balance: 100.0) } + + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + charge + end + + it "creates pay_in_advance fees and deducts from correct wallets" do + jan15 = DateTime.new(2023, 1, 15) + + travel_to(jan15) do + wallet1 + wallet2 + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_advance", + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.find_by(external_id: "sub_advance") + + # Send events - each should create a pay_in_advance fee and deduct from targeted wallet + travel_to(jan15 + 1.day) do + expect do + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "10", target_wallet_code: "wallet_1"} + }) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee1 = subscription.fees.order(created_at: :desc).first + expect(fee1.pay_in_advance).to eq(true) + expect(fee1.units).to eq(10) + expect(fee1.amount_cents).to eq(10_000) + expect(fee1.grouped_by["target_wallet_code"]).to eq("wallet_1") + + # wallet_1 should have $100 deducted (had $150, now $50) + expect(wallet1.reload.balance_cents).to eq(5_000) + # wallet_2 should be unchanged + expect(wallet2.reload.balance_cents).to eq(10_000) + end + + travel_to(jan15 + 2.days) do + expect do + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "5", target_wallet_code: "wallet_2"} + }) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee2 = subscription.fees.order(created_at: :desc).first + expect(fee2.pay_in_advance).to eq(true) + expect(fee2.units).to eq(5) + expect(fee2.amount_cents).to eq(5_000) + expect(fee2.grouped_by["target_wallet_code"]).to eq("wallet_2") + + # wallet_1 should still have $50 + expect(wallet1.reload.balance_cents).to eq(5_000) + # wallet_2 should have $50 deducted (had $100, now $50) + expect(wallet2.reload.balance_cents).to eq(5_000) + end + end + end + + describe "events without target_wallet_code on accepting charge", :premium do + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + accepts_target_wallet: true, + properties: {amount: "10"} + ) + end + + let(:wallet1) { create(:wallet, :with_inbound_transaction, customer:, code: "wallet_1", name: "Wallet 1", balance_cents: 15_000, credits_balance: 150.0) } + let(:default_wallet) { create(:wallet, :with_inbound_transaction, customer:, code: "default_wallet", name: "Default Wallet", balance_cents: 10_000, credits_balance: 100.0, priority: 1) } + + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + charge + end + + it "applies credits from targeted wallet and default wallet for non-targeted fees" do + jan15 = DateTime.new(2023, 1, 15) + + travel_to(jan15) do + wallet1 + default_wallet + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_mixed", + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.find_by(external_id: "sub_mixed") + + travel_to(jan15 + 1.day) do + # Event with wallet + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "10", target_wallet_code: "wallet_1"} + }) + + # Event without wallet + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "5"} + }) + + # Refresh wallets to check ongoing balance + recalculate_wallet_balances + + # wallet_1 should have ongoing usage for targeted events + expect(wallet1.reload.ongoing_usage_balance_cents).to eq(10_000) + # default_wallet should have ongoing usage for non-targeted events + expect(default_wallet.reload.ongoing_usage_balance_cents).to eq(5_000) + end + + # Bill at end of month + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + invoice = subscription.invoices.first + charge_fees = invoice.fees.charge + + expect(charge_fees.count).to eq(2) + + wallet_fee = charge_fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_1" } + no_wallet_fee = charge_fees.find { |f| f.grouped_by.empty? || f.grouped_by["target_wallet_code"].nil? } + + expect(wallet_fee.units).to eq(10) + expect(wallet_fee.amount_cents).to eq(10_000) + + expect(no_wallet_fee.units).to eq(5) + expect(no_wallet_fee.amount_cents).to eq(5_000) + + # wallet_1 should have $100 deducted for targeted fee (had $150, now $50) + expect(wallet1.reload.balance_cents).to eq(5_000) + + # default_wallet should have $50 deducted for non-targeted fee (had $100, now $50) + expect(default_wallet.reload.balance_cents).to eq(5_000) + + # Verify wallet transactions + wallet1_tx = wallet1.wallet_transactions.where(invoice:) + default_wallet_tx = default_wallet.wallet_transactions.where(invoice:) + + expect(wallet1_tx.count).to eq(1) + expect(wallet1_tx.first.amount_cents).to eq(10_000) + + expect(default_wallet_tx.count).to eq(1) + expect(default_wallet_tx.first.amount_cents).to eq(5_000) + end + end + + describe "events with target_wallet_code when feature is disabled", :premium do + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + accepts_target_wallet: false, + properties: {amount: "10"} + ) + end + + let(:wallet1) { create(:wallet, :with_inbound_transaction, customer:, code: "wallet_1", name: "Wallet 1", balance_cents: 20_000, credits_balance: 200.0, priority: 1) } + let(:wallet2) { create(:wallet, :with_inbound_transaction, customer:, code: "wallet_2", name: "Wallet 2", balance_cents: 25_000, credits_balance: 250.0, priority: 2) } + + before do + # Organization does NOT have events_targeting_wallets enabled + organization.update!(premium_integrations: []) + charge + end + + it "ignores target_wallet_code and applies standard wallet logic" do + jan15 = DateTime.new(2023, 1, 15) + + travel_to(jan15) do + wallet1 + wallet2 + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_no_targeting", + plan_code: plan.code + }) + end + + subscription = customer.subscriptions.find_by(external_id: "sub_no_targeting") + + # Send events with target_wallet_code - should be ignored + travel_to(jan15 + 1.day) do + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "10", target_wallet_code: "wallet_1"} + }) + + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "5", target_wallet_code: "wallet_1"} + }) + + create_event({ + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {value: "20", target_wallet_code: "wallet_2"} + }) + + # Refresh wallets + recalculate_wallet_balances + + # Without wallet targeting, all usage should be attributed to oldest wallet (wallet1) + # Total: 35 units * $10 = $350 + expect(wallet1.reload.ongoing_usage_balance_cents).to eq(35_000) + expect(wallet2.reload.ongoing_usage_balance_cents).to eq(0) + end + + # Bill at end of month + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + invoice = subscription.invoices.first + expect(invoice).to be_present + + charge_fees = invoice.fees.charge + + # Should have only 1 fee (not grouped by target_wallet_code) + expect(charge_fees.count).to eq(1) + + fee = charge_fees.first + expect(fee.units).to eq(35) + expect(fee.amount_cents).to eq(35_000) + expect(fee.grouped_by).to be_empty + + # Credits applied using standard logic - oldest wallet first (wallet1) + # wallet1 had $200, total fee is $350, so wallet1 should be depleted + expect(wallet1.reload.balance_cents).to eq(0) + + # Remaining $150 should come from wallet2 (had $250, now $100) + expect(wallet2.reload.balance_cents).to eq(10_000) + + # Verify wallet transactions + wallet1_tx = wallet1.wallet_transactions.where(invoice:) + wallet2_tx = wallet2.wallet_transactions.where(invoice:) + + expect(wallet1_tx.count).to eq(1) + expect(wallet1_tx.first.amount_cents).to eq(20_000) + + expect(wallet2_tx.count).to eq(1) + expect(wallet2_tx.first.amount_cents).to eq(15_000) + end + end +end diff --git a/spec/scenarios/fees/recurring_fee_downgrade_spec.rb b/spec/scenarios/fees/recurring_fee_downgrade_spec.rb new file mode 100644 index 0000000..c0d1b49 --- /dev/null +++ b/spec/scenarios/fees/recurring_fee_downgrade_spec.rb @@ -0,0 +1,268 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Recurring Fees Subscription Downgrade" do + let(:organization) { create(:organization, webhook_url: "http://fees.test/wh") } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:unique_count_billable_metric, :recurring, organization:, code: "seats") } + let(:plan) { create(:plan, organization:, name: "Premium plus", amount_cents: 99.99, pay_in_advance: true) } + let(:external_subscription_id) { SecureRandom.uuid } + let(:charge) do + create(:charge, { + plan:, + billable_metric:, + invoiceable:, + pay_in_advance:, + prorated: true, + properties: {amount: "30", grouped_by:} + }) + end + + def send_event!(item_id) + create_event( + { + code: billable_metric.code, + transaction_id: "tr_#{SecureRandom.hex(16)}", + external_subscription_id:, + properties: {"item_id" => item_id} + } + ) + end + + before do + charge + WebMock.stub_request(:post, "http://fees.test/wh").to_return(status: 200, body: "", headers: {}) + end + + describe "when downgrading subscription" do + let(:invoiceable) { false } + let(:pay_in_advance) { true } + let(:grouped_by) { ["item_id"] } + let(:plan_2) { create(:plan, organization:, name: "downgraded", amount_cents: 49.99, pay_in_advance: true) } + + before do + create(:charge, { + plan: plan_2, + billable_metric:, + invoiceable:, + pay_in_advance:, + prorated: true, + properties: {amount: "60", grouped_by:} + }) + end + + context "when all subscriptions are calendar" do + let(:billing_time) { "calendar" } + + it "performs subscription downgrade and billing correctly" do + travel_to(DateTime.new(2024, 6, 1, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code, + billing_time: + } + ) + perform_billing + end + + travel_to(DateTime.new(2024, 6, 5, 0, 0)) do + send_event! "user_1" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 5, 59)) do + send_event! "user_2" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 6)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_2.code, + billing_time: + } + ) + + expect(customer.subscriptions.order(created_at: :asc).first).to be_active + expect(customer.invoices.count).to eq(1) + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(plan_2.code) + expect(new_subscription).to be_pending + expect(Fee.where(invoice_id: nil, created_at: ...Time.current).count).to eq 2 + expect(Fee.where(invoice_id: nil, created_at: Time.current..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 6, 19, 0, 0)) do + send_event! "user_3" + send_event! "user_4" + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(customer.subscriptions.order(created_at: :asc).first).to be_terminated + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(plan_2.code) + expect(new_subscription).to be_active + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 4 + end + end + end + + context "when all subscriptions are anniversary" do + let(:billing_time) { "anniversary" } + + it "performs subscription downgrade and billing correctly" do + travel_to(DateTime.new(2024, 6, 4, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code, + billing_time: + } + ) + perform_billing + end + + travel_to(DateTime.new(2024, 6, 5, 0, 0)) do + send_event! "user_1" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 5, 59)) do + send_event! "user_2" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 6)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_2.code, + billing_time: + } + ) + + expect(customer.subscriptions.order(created_at: :asc).first).to be_active + expect(customer.invoices.count).to eq(1) + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(plan_2.code) + expect(new_subscription).to be_pending + + expect(Fee.where(invoice_id: nil, created_at: ...Time.current).count).to eq 2 + expect(Fee.where(invoice_id: nil, created_at: Time.current..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 6, 19, 0, 0)) do + send_event! "user_3" + send_event! "user_4" + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 7, 4, 14)) do + perform_billing + expect(customer.subscriptions.order(created_at: :asc).first).to be_terminated + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(plan_2.code) + expect(new_subscription).to be_active + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 4 + end + end + end + end + + context "when testing the boundaries" do + let(:invoiceable) { true } + let(:pay_in_advance) { true } + let(:grouped_by) { ["item_id"] } + let(:plan_2) { create(:plan, organization:, name: "downgraded", amount_cents: 49.99, pay_in_advance: true) } + + let(:charge) do + create(:charge, { + plan:, + billable_metric:, + invoiceable: true, + pay_in_advance: false, + prorated: true, + properties: {amount: "31", grouped_by:} + }) + end + + before do + create(:charge, { + plan: plan_2, + billable_metric:, + invoiceable: true, + pay_in_advance: false, + prorated: true, + properties: {amount: "60", grouped_by:} + }) + end + + context "when all subscriptions are calendar" do + let(:billing_time) { "calendar" } + + # this proves a bug :melting: + # last invoice should have fee.charge.first.amount_cents == 31 + # because total we have 1 recurring unit that is active the whole month, so the total should be 31. + # instead, it's 32 + it "performs subscription downgrade and billing correctly" do + travel_to(DateTime.new(2024, 6, 1, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code, + billing_time: + } + ) + perform_billing + end + + travel_to(DateTime.new(2024, 6, 15, 10, 5, 59)) do + send_event! "user_2" + end + + travel_to(DateTime.new(2024, 7, 1, 0, 0)) do + perform_billing + end + + travel_to(DateTime.new(2024, 7, 15, 10, 6)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_2.code, + billing_time: + } + ) + + expect(customer.subscriptions.order(created_at: :asc).first).to be_active + expect(customer.invoices.count).to eq(2) + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(plan_2.code) + expect(new_subscription).to be_pending + expect(Fee.where(created_at: ...Time.current, fee_type: "charge").count).to eq 1 + expect(Fee.where(created_at: Time.current.., fee_type: "charge").count).to eq 0 + end + + travel_to(DateTime.new(2024, 8, 1, 0, 0)) do + perform_billing + termination_invoice = customer.invoices.order(:created_at).last + expect(termination_invoice.fees.charge.last.amount_cents).to eq(3100) + end + end + end + end +end diff --git a/spec/scenarios/fees/recurring_fee_upgrade_spec.rb b/spec/scenarios/fees/recurring_fee_upgrade_spec.rb new file mode 100644 index 0000000..fd97e2c --- /dev/null +++ b/spec/scenarios/fees/recurring_fee_upgrade_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Recurring Fees Subscription Upgrade" do + let(:organization) { create(:organization, webhook_url: "http://fees.test/wh") } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:unique_count_billable_metric, :recurring, organization:, code: "seats") } + let(:plan) { create(:plan, organization:, name: "Basic", amount_cents: 49.99, pay_in_advance: true) } + let(:external_subscription_id) { SecureRandom.uuid } + let(:charge) do + create(:charge, { + plan:, + billable_metric:, + invoiceable:, + pay_in_advance:, + prorated: true, + properties: {amount: "30", grouped_by:} + }) + end + + def send_event!(item_id) + create_event( + { + code: billable_metric.code, + transaction_id: "tr_#{SecureRandom.hex(16)}", + external_subscription_id:, + properties: {"item_id" => item_id} + } + ) + end + + before do + charge + WebMock.stub_request(:post, "http://fees.test/wh").to_return(status: 200, body: "", headers: {}) + end + + describe "when upgrading subscription" do + let(:invoiceable) { false } + let(:pay_in_advance) { true } + let(:grouped_by) { ["item_id"] } + let(:plan_2) { create(:plan, organization:, name: "Upgraded", amount_cents: 99.99, pay_in_advance: true) } + + before do + create(:charge, { + plan: plan_2, + billable_metric:, + invoiceable:, + pay_in_advance:, + prorated: true, + properties: {amount: "60", grouped_by:} + }) + end + + context "when all subscriptions are calendar" do + let(:billing_time) { "calendar" } + + it "performs subscription upgrade and billing correctly" do + travel_to(DateTime.new(2024, 6, 1, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code, + billing_time: + } + ) + perform_billing + end + + travel_to(DateTime.new(2024, 6, 5, 0, 0)) do + send_event! "user_1" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 5, 59)) do + send_event! "user_2" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 6)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_2.code, + billing_time: + } + ) + + expect(customer.subscriptions.order(created_at: :asc).first).to be_terminated + expect(customer.invoices.count).to eq(2) + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(plan_2.code) + expect(new_subscription).to be_active + + expect(Fee.where(invoice_id: nil, created_at: ...Time.current).count).to eq 2 + expect(Fee.where(invoice_id: nil, created_at: Time.current..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 6, 19, 0, 0)) do + send_event! "user_3" + send_event! "user_4" + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 4 + end + end + end + + context "when all subscriptions are anniversary" do + let(:billing_time) { "anniversary" } + + it "performs subscription upgrade and billing correctly" do + travel_to(DateTime.new(2024, 6, 4, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code, + billing_time: + } + ) + perform_billing + end + + travel_to(DateTime.new(2024, 6, 5, 0, 0)) do + send_event! "user_1" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 5, 59)) do + send_event! "user_2" + end + + travel_to(DateTime.new(2024, 6, 15, 10, 6)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_2.code, + billing_time: + } + ) + + expect(customer.subscriptions.order(created_at: :asc).first).to be_terminated + expect(customer.invoices.count).to eq(2) + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(plan_2.code) + expect(new_subscription).to be_active + + expect(Fee.where(invoice_id: nil, created_at: ...Time.current).count).to eq 2 + expect(Fee.where(invoice_id: nil, created_at: Time.current..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 6, 19, 0, 0)) do + send_event! "user_3" + send_event! "user_4" + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 0 + end + + travel_to(DateTime.new(2024, 7, 4, 14)) do + perform_billing + expect(Fee.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 4 + end + end + end + end +end diff --git a/spec/scenarios/fees/recurring_fees_spec.rb b/spec/scenarios/fees/recurring_fees_spec.rb new file mode 100644 index 0000000..2a44988 --- /dev/null +++ b/spec/scenarios/fees/recurring_fees_spec.rb @@ -0,0 +1,439 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Recurring Non Invoiceable Fees" do + let(:organization) { create(:organization, webhook_url: "http://fees.test/wh") } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:billable_metric) { create(:unique_count_billable_metric, :recurring, organization:, code: "seats") } + let(:plan) { create(:plan, organization:, amount_cents: 49.99, pay_in_advance: true) } + let(:external_subscription_id) { SecureRandom.uuid } + let(:charge) do + create(:charge, { + plan:, + billable_metric:, + invoiceable:, + pay_in_advance:, + prorated: true, + properties: {amount: "30", grouped_by:} + }) + end + let(:subscription) { customer.subscriptions.first } + + def send_event!(item_id) + create_event( + { + code: billable_metric.code, + transaction_id: "tr_#{SecureRandom.hex(16)}", + external_subscription_id:, + properties: {"item_id" => item_id} + } + ) + end + + before do + charge + WebMock.stub_request(:post, "http://fees.test/wh").to_return(status: 200, body: "", headers: {}) + end + + context "when charge is pay in advance" do + let(:pay_in_advance) { true } + + context "with invoiceable = false" do + let(:invoiceable) { false } + + context "without grace period" do + # rubocop:disable RSpec/ExpectInHook + before do + travel_to(Time.zone.parse("2024-06-05T12:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + (1..5).each do |i| + travel_to(DateTime.new(2024, 6, 10 + i, 10)) do + send_event! "user_#{i}" + expect(subscription.fees.charge.count).to eq(i) + expect(subscription.fees.charge.order(created_at: :desc).first.amount_cents).to eq((21 - i) * 100) + end + end + end + # rubocop:enable RSpec/ExpectInHook + + context "without grouped_by" do + let(:grouped_by) { nil } + + it "creates one fee for all events", transaction: false do + travel_to(Time.zone.parse("2024-07-01T00:10:00")) do # BILLING DAY ! + perform_billing + + expect(subscription.invoices.count).to eq 2 + + recurring_fee = Fee.where(subscription:, charge:, created_at: Time.current.to_date..).sole + expect(recurring_fee.units).to eq 5 + expect(recurring_fee.invoice_id).to be_nil + expect(recurring_fee.amount_cents).to eq(30 * 5 * 100) + end + + travel_to(Time.zone.parse("2024-07-12T01:10:00")) do + send_event! "user_july_1" + send_event! "user_july_2" + end + + travel_to(Time.zone.parse("2024-08-01T01:10:00")) do # August BILLING DAY ! + expect(Fee.where(subscription:, charge:, created_at: Time.current.to_date..).count).to eq 0 + + perform_billing + + expect(subscription.invoices.count).to eq 3 + + expect(a_request(:post, "http://fees.test/wh").with( + body: hash_including(webhook_type: "fee.created", fee: hash_including({ + "units" => "7.0", + "from_date" => "2024-08-01T00:00:00+00:00", + "to_date" => "2024-08-31T23:59:59+00:00" + })) + )).to have_been_made.once + + recurring_fee = Fee.where(subscription:, charge:, created_at: Time.current.to_date..).sole + expect(recurring_fee.units).to eq 7 + expect(recurring_fee.invoice_id).to be_nil + expect(recurring_fee.amount_cents).to eq(30 * 7 * 100) + end + + # Test termination of subscription + travel_to(Time.zone.parse("2024-08-15T01:10:00")) do + terminate_subscription(subscription) + perform_billing + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq 4 + recurring_fee = Fee.where(subscription:, charge:, created_at: Time.current.beginning_of_month..).sole + expect(recurring_fee.units).to eq 7 + expect(recurring_fee.invoice_id).to be_nil + expect(recurring_fee.amount_cents).to eq(30 * 7 * 100) + end + end + end + + context "with grouped_by on unique field_name" do + let(:grouped_by) { ["item_id"] } + + it "creates a fee per event" do + travel_to(Time.zone.parse("2024-07-01T00:10:00")) do # July BILLING DAY ! + expect(Fee.where(subscription:, charge:, created_at: Time.current.to_date..).count).to eq 0 + + perform_billing + expect(subscription.invoices.count).to eq 2 + + recurring_fees = Fee.where(subscription:, charge:, created_at: Time.current.to_date..) + expect(recurring_fees.count).to eq 5 + expect(recurring_fees).to all(have_attributes(units: 1, invoice_id: nil, pay_in_advance: true, amount_cents: 30 * 100)) + end + + travel_to(Time.zone.parse("2024-07-12T01:10:00")) do + send_event! "user_july_1" + send_event! "user_july_2" + end + + travel_to(Time.zone.parse("2024-08-01T01:10:00")) do # August BILLING DAY ! + expect(Fee.where(subscription:, charge:, created_at: Time.current.to_date..).count).to eq 0 + + WebMock.reset_executed_requests! + + perform_billing + expect(subscription.invoices.count).to eq 3 + + expect(a_request(:post, "http://fees.test/wh").with( + body: hash_including(webhook_type: "fee.created", fee: hash_including({ + "lago_invoice_id" => nil, + "units" => "1.0", + "from_date" => "2024-08-01T00:00:00+00:00", + "to_date" => "2024-08-31T23:59:59+00:00" + })) + )).to have_been_made.times(7) + + recurring_fees = Fee.where(subscription:, charge:, created_at: Time.current.to_date..) + expect(recurring_fees.count).to eq 7 + expect(recurring_fees).to all(have_attributes(units: 1, invoice_id: nil, pay_in_advance: true, amount_cents: 30 * 100)) + end + + # Test termination of subscription + travel_to(Time.zone.parse("2024-08-15T01:10:00")) do + terminate_subscription(subscription) + perform_billing + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq 4 + recurring_fees = Fee.where(subscription:, charge:, created_at: Time.current.beginning_of_month..) + expect(recurring_fees.count).to eq(7) + expect(recurring_fees).to all(have_attributes(units: 1, invoice_id: nil, pay_in_advance: true, amount_cents: 30 * 100)) + end + end + end + end + + context "with grace period" do + let(:organization) { create(:organization, webhook_url: "http://fees.test/wh") } + let(:billing_entity) { create(:billing_entity, organization:, invoice_grace_period: 3) } + let(:grouped_by) { ["item_id"] } + + it "creates the recurring fees without the grace period" do + travel_to(Time.zone.parse("2024-06-05T12:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.draft.count).to eq(1) + end + + travel_to(DateTime.new(2024, 6, 10, 10)) do + send_event! "user_1" + send_event! "user_2" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(2) + expect(subscription.fees.charge.order(created_at: :desc)).to all(have_attributes(amount_cents: 2100)) + end + + travel_to(Time.zone.parse("2024-07-01T00:10:00")) do # July BILLING DAY ! + expect(Fee.where(subscription:, charge:, created_at: Time.current.to_date..).count).to eq 0 + + WebMock.reset_executed_requests! + perform_billing + + expect(a_request(:post, "http://fees.test/wh").with( + body: hash_including(webhook_type: "fee.created", fee: hash_including({ + "lago_invoice_id" => nil, + "units" => "1.0", + "from_date" => "2024-07-01T00:00:00+00:00", + "to_date" => "2024-07-31T23:59:59+00:00" + })) + )).to have_been_made.times(2) + + expect(subscription.invoices.draft.count).to eq 2 + expect(subscription.invoices).to all(have_attributes(status: "draft")) + + recurring_fees = Fee.where(subscription:, charge:, created_at: Time.current.beginning_of_month..) + expect(recurring_fees.count).to eq 2 + expect(recurring_fees).to all(have_attributes(units: 1, invoice_id: nil, pay_in_advance: true, amount_cents: 30 * 100)) + end + + travel_to(Time.zone.parse("2024-07-04T01:10:00")) do + WebMock.reset_executed_requests! + perform_finalize_refresh + + expect(a_request(:post, "http://fees.test/wh").with( + body: hash_including(webhook_type: "fee.created", fee: hash_including({ + "lago_invoice_id" => nil + })) + )).not_to have_been_made + + expect(subscription.invoices.draft.count).to eq 0 + expect(subscription.invoices.finalized.count).to eq 2 + expect(Fee.where(subscription:, charge:, created_at: Time.current.beginning_of_month..).count).to eq 2 + end + + # Test termination of subscription + travel_to(Time.zone.parse("2024-07-05T01:30:00")) do + terminate_subscription(subscription) + perform_billing + expect(subscription.reload).to be_terminated + expect(subscription.invoices.draft.count).to eq 1 + expect(subscription.invoices.finalized.count).to eq 2 + expect(Fee.where(subscription:, charge:, created_at: Time.current.beginning_of_month..).count).to eq 2 + end + end + end + end + + context "with invoiceable = true" do + let(:invoiceable) { true } + + # rubocop:disable RSpec/ExpectInHook + before do + travel_to(Time.zone.parse("2024-06-05T12:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + (1..5).each do |i| + travel_to(DateTime.new(2024, 6, 10 + i, 10)) do + send_event! "user_#{i}" + expect(subscription.invoices.count).to eq(i + 1) + expect(subscription.invoices.order(created_at: :desc).first.fees.sole.amount_cents).to eq((21 - i) * 100) + end + end + end + # rubocop:enable RSpec/ExpectInHook + + context "without grouped_by" do + let(:grouped_by) { nil } + + it "creates one fee for all events", transaction: false do + travel_to(Time.zone.parse("2024-07-01T00:10:00")) do # BILLING DAY ! + perform_billing + + expect(subscription.invoices.count).to eq 7 + + renewal_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fee = renewal_invoice.fees.charge.sole + expect(recurring_fee.units).to eq 5 + expect(recurring_fee.pay_in_advance).to be_falsey + expect(recurring_fee.amount_cents).to eq(30 * 5 * 100) + end + + # Test termination of subscription + travel_to(Time.zone.parse("2024-07-15T01:10:00")) do + terminate_subscription(subscription) + perform_billing + expect(subscription.reload).to be_terminated + renewal_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fees = renewal_invoice.fees.charge + expect(recurring_fees.count).to eq 0 + end + end + end + + context "with grouped_by on unique field_name" do + let(:grouped_by) { ["item_id"] } + + it "creates a fee per event" do + travel_to(Time.zone.parse("2024-07-01T00:10:00")) do # BILLING DAY ! + perform_billing + + expect(subscription.invoices.count).to eq 7 + + recurring_fees = Fee.where(subscription:, charge:, created_at: Time.current.to_date..) + expect(recurring_fees.count).to eq 5 + + renewal_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fees = renewal_invoice.fees.charge + expect(recurring_fees.count).to eq 5 + expect(recurring_fees).to all(have_attributes(units: 1, pay_in_advance: false, amount_cents: 30 * 100)) + end + + # Test termination of subscription + travel_to(Time.zone.parse("2024-07-15T01:10:00")) do + terminate_subscription(subscription) + perform_billing + expect(subscription.reload).to be_terminated + renewal_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fees = renewal_invoice.fees.charge + expect(recurring_fees.count).to eq 0 + end + end + end + end + end + + context "when charge is pay in arrears" do + let(:pay_in_advance) { false } + + context "with invoiceable = true" do + let(:invoiceable) { true } + + # rubocop:disable RSpec/ExpectInHook + before do + travel_to(Time.zone.parse("2024-06-05T12:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + (1..5).each do |i| + travel_to(DateTime.new(2024, 6, 10 + i, 10)) do + send_event! "user_#{i}" + end + end + expect(subscription.invoices.count).to eq 1 + end + # rubocop:enable RSpec/ExpectInHook + + context "without grouped_by" do + let(:grouped_by) { nil } + + it "creates one fee for all events", transaction: false do + travel_to(Time.zone.parse("2024-07-01T00:10:00")) do # BILLING DAY ! + perform_billing + + expect(subscription.invoices.count).to eq 2 + + renewal_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fee = renewal_invoice.fees.charge.sole + expect(recurring_fee.units).to eq 5 + expect(recurring_fee.pay_in_advance).to be_falsey + expect(recurring_fee.amount_cents).to eq((20 + 19 + 18 + 17 + 16) * 100) + end + + # Test termination of subscription + travel_to(Time.zone.parse("2024-07-15T01:10:00")) do + terminate_subscription(subscription) + perform_billing + expect(subscription.reload).to be_terminated + renewal_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fee = renewal_invoice.fees.charge.sole + expect(recurring_fee.units).to eq 5 + expect(recurring_fee.pay_in_advance).to be_falsey + expect(recurring_fee.amount_cents).to eq(7258) + end + end + end + + context "with grouped_by on unique field_name" do + let(:grouped_by) { ["item_id"] } + + it "creates a fee per event" do + travel_to(Time.zone.parse("2024-07-01T00:10:00")) do # BILLING DAY ! + perform_billing + + expect(subscription.invoices.count).to eq 2 + + recurring_fees = Fee.where(subscription:, charge:, created_at: Time.current.to_date..) + expect(recurring_fees.count).to eq 5 + + renewal_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fees = renewal_invoice.fees.charge + expect(recurring_fees.count).to eq 5 + expect(recurring_fees).to all(have_attributes(units: 1, pay_in_advance: false)) + expect(recurring_fees.map(&:amount_cents).sort).to eq([20, 19, 18, 17, 16].sort.map { |i| i * 100 }) + end + + # Test termination of subscription + travel_to(Time.zone.parse("2024-07-15T01:10:00")) do + terminate_subscription(subscription) + perform_billing + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq 3 + termination_invoice = subscription.invoices.order(created_at: :desc).first + recurring_fees = termination_invoice.fees.charge + expect(recurring_fees.count).to eq 5 + expect(recurring_fees).to all(have_attributes(units: 1, pay_in_advance: false)) + expect(recurring_fees.map(&:amount_cents).sort).to eq([1452, 1452, 1452, 1452, 1452]) + end + end + end + end + end +end diff --git a/spec/scenarios/fixed_charges/pay_in_advance_units_change_spec.rb b/spec/scenarios/fixed_charges/pay_in_advance_units_change_spec.rb new file mode 100644 index 0000000..a3e96e7 --- /dev/null +++ b/spec/scenarios/fixed_charges/pay_in_advance_units_change_spec.rb @@ -0,0 +1,1406 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Pay in advance fixed charge units change mid-period", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:, timezone: "UTC") } + let(:add_on) { create(:add_on, organization:) } + let(:plan) do + create( + :plan, + organization:, + amount_cents: 0, + interval: "monthly", + pay_in_advance: true + ) + end + + # Fixed charge: $10 per unit, 10 units, pay in advance, not prorated + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + units: 10, + properties: {amount: "10"}, + prorated: false, + pay_in_advance: true + ) + end + + describe "when units change mid-period with apply_units_immediately: true" do + let(:subscription_date) { DateTime.new(2024, 3, 1) } + let(:subscription) { customer.subscriptions.first } + + before do + fixed_charge + + # Create subscription at the start of the month + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + # Process the initial invoice + travel_to subscription_date + 1.minute do + perform_all_enqueued_jobs + end + end + + it "generates initial invoice with 10 units" do + expect(subscription.invoices.count).to eq(1) + initial_invoice = subscription.invoices.first + + expect(initial_invoice.fees.fixed_charge.count).to eq(1) + fee = initial_invoice.fees.fixed_charge.first + + # 10 units * $10 = $100 = 10000 cents + expect(fee.units).to eq(10) + expect(fee.amount_cents).to eq(10_000) + + # Verify invoice total matches sum of fees + expect(initial_invoice.fees_amount_cents).to eq(initial_invoice.fees.sum(:amount_cents)) + expect(initial_invoice.fees_amount_cents).to eq(10_000) + end + + context "when decreasing units from 10 to 5" do + before do + travel_to subscription_date + 5.days do + update_plan( + plan, + { + fixed_charges: [{ + id: fixed_charge.id, + units: 5, + apply_units_immediately: true, + properties: {amount: "10"} + }] + } + ) + + perform_all_enqueued_jobs + end + end + + it "creates a new fixed charge event with units 5" do + events = FixedChargeEvent.where(subscription:, fixed_charge:).order(:created_at) + expect(events.count).to eq(2) + expect(events.last.units).to eq(5) + end + + it "generates a zero-amount invoice when units decrease" do + # After decreasing units, we expect a new invoice with zero amount + # because we don't refund pay-in-advance fixed charges + expect(subscription.reload.invoices.count).to eq(2) + + adjustment_invoice = subscription.invoices.order(:created_at).last + expect(adjustment_invoice.fees.count).to eq(1) + expect(adjustment_invoice.fees_amount_cents).to eq(0) + end + end + + context "when increasing units from 10 to 15" do + before do + travel_to subscription_date + 5.days do + update_plan( + plan, + { + fixed_charges: [{ + id: fixed_charge.id, + units: 15, + apply_units_immediately: true, + properties: {amount: "10"} + }] + } + ) + perform_all_enqueued_jobs + end + end + + it "creates a new fixed charge event with units 15" do + events = FixedChargeEvent.where(subscription:, fixed_charge:).order(:created_at) + expect(events.count).to eq(2) + expect(events.last.units).to eq(15) + end + + it "generates an invoice for the additional units only (delta billing)" do + # After increasing units from 10 to 15, we expect a new invoice + # for the 5 additional units only: 5 * $10 = $50 = 5000 cents + expect(subscription.reload.invoices.count).to eq(2) + + adjustment_invoice = subscription.invoices.order(:created_at).last + expect(adjustment_invoice.fees.fixed_charge.count).to eq(1) + + fee = adjustment_invoice.fees.fixed_charge.first + expect(fee.units).to eq(5) # Only the delta + expect(fee.amount_cents).to eq(5_000) # 5 units * $10 = $50 + + # Verify invoice total matches sum of fees + expect(adjustment_invoice.fees_amount_cents).to eq(adjustment_invoice.fees.sum(:amount_cents)) + expect(adjustment_invoice.fees_amount_cents).to eq(5_000) + end + end + + context "when decreasing then increasing units (10 -> 5 -> 15)" do + before do + # First decrease to 5 + travel_to subscription_date + 5.days do + update_plan( + plan, + { + fixed_charges: [{ + id: fixed_charge.id, + units: 5, + apply_units_immediately: true, + properties: {amount: "10"} + }] + } + ) + perform_all_enqueued_jobs + end + + # Then increase to 15 + travel_to subscription_date + 10.days do + update_plan( + plan, + { + fixed_charges: [{ + id: fixed_charge.id, + units: 15, + apply_units_immediately: true, + properties: {amount: "10"} + }] + } + ) + perform_all_enqueued_jobs + end + end + + it "creates fixed charge events for each change" do + events = FixedChargeEvent.where(subscription:, fixed_charge:).order(:created_at) + expect(events.count).to eq(3) + expect(events.map(&:units)).to eq([10, 5, 15]) + end + + it "generates invoice for delta from originally paid units (not current units)" do + # After all changes: + # - Initial: paid for 10 units + # - Decrease to 5: no refund, so still paid for 10 units + # - Increase to 15: should charge for 15 - 10 = 5 units only + invoices = subscription.reload.invoices.order(:created_at) + + # We expect 3 invoices: + # 1. Initial invoice (10 units, $100) + # 2. Decrease invoice (0 amount - no refund) + # 3. Increase invoice (5 units delta, $50) + expect(invoices.count).to eq(3) + + initial_invoice = invoices.first + expect(initial_invoice.fees.fixed_charge.first.units).to eq(10) + expect(initial_invoice.fees.fixed_charge.first.amount_cents).to eq(10_000) + expect(initial_invoice.fees_amount_cents).to eq(initial_invoice.fees.sum(:amount_cents)) + expect(initial_invoice.fees_amount_cents).to eq(10_000) + + decrease_invoice = invoices.second + expect(decrease_invoice.fees.count).to eq(1) + expect(decrease_invoice.fees_amount_cents).to eq(0) + + increase_invoice = invoices.last + # This is the critical assertion: we should only charge for 5 units (15 - 10), + # NOT 10 units (15 - 5), because we never refunded when going from 10 to 5 + expect(increase_invoice.fees.fixed_charge.first.units).to eq(5) + expect(increase_invoice.fees.fixed_charge.first.amount_cents).to eq(5_000) + expect(increase_invoice.fees_amount_cents).to eq(increase_invoice.fees.sum(:amount_cents)) + expect(increase_invoice.fees_amount_cents).to eq(5_000) + end + end + + context "when increasing units multiple times (10 -> 15 -> 20)" do + before do + # First increase to 15 + travel_to subscription_date + 5.days do + update_plan( + plan, + { + fixed_charges: [{ + id: fixed_charge.id, + units: 15, + apply_units_immediately: true, + properties: {amount: "10"} + }] + } + ) + perform_all_enqueued_jobs + end + + # Then increase to 20 + travel_to subscription_date + 10.days do + update_plan( + plan, + { + fixed_charges: [{ + id: fixed_charge.id, + units: 20, + apply_units_immediately: true, + properties: {amount: "10"} + }] + } + ) + perform_all_enqueued_jobs + end + end + + it "generates invoices for each delta increase" do + invoices = subscription.reload.invoices.order(:created_at) + + # We expect 3 invoices: + # 1. Initial invoice (10 units, $100) + # 2. First increase invoice (5 units delta: 15 - 10, $50) + # 3. Second increase invoice (5 units delta: 20 - 15, $50) + expect(invoices.count).to eq(3) + + initial_invoice = invoices.first + expect(initial_invoice.fees.fixed_charge.first.units).to eq(10) + expect(initial_invoice.fees.fixed_charge.first.amount_cents).to eq(10_000) + expect(initial_invoice.fees_amount_cents).to eq(initial_invoice.fees.sum(:amount_cents)) + expect(initial_invoice.fees_amount_cents).to eq(10_000) + + first_increase_invoice = invoices.second + expect(first_increase_invoice.fees.fixed_charge.first.units).to eq(5) + expect(first_increase_invoice.fees.fixed_charge.first.amount_cents).to eq(5_000) + expect(first_increase_invoice.fees_amount_cents).to eq(first_increase_invoice.fees.sum(:amount_cents)) + expect(first_increase_invoice.fees_amount_cents).to eq(5_000) + + second_increase_invoice = invoices.last + expect(second_increase_invoice.fees.fixed_charge.first.units).to eq(5) + expect(second_increase_invoice.fees.fixed_charge.first.amount_cents).to eq(5_000) + expect(second_increase_invoice.fees_amount_cents).to eq(second_increase_invoice.fees.sum(:amount_cents)) + expect(second_increase_invoice.fees_amount_cents).to eq(5_000) + end + end + end + + describe "when multiple fixed charges are updated at once via plan update" do + let(:add_on2) { create(:add_on, organization:) } + let(:subscription_date) { DateTime.new(2024, 3, 1) } + let(:subscription) { customer.subscriptions.first } + + # Second fixed charge: $20 per unit, 5 units, pay in advance + let(:fixed_charge2) do + create( + :fixed_charge, + plan:, + add_on: add_on2, + units: 5, + properties: {amount: "20"}, + pay_in_advance: true + ) + end + + before do + fixed_charge + fixed_charge2 + + # Create subscription at the start of the month + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_multi_#{customer.external_id}", + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + # Process the initial invoice + travel_to subscription_date + 1.minute do + perform_all_enqueued_jobs + end + end + + it "generates initial invoice with fees for both fixed charges" do + expect(subscription.invoices.count).to eq(1) + initial_invoice = subscription.invoices.first + + expect(initial_invoice.fees.fixed_charge.count).to eq(2) + + fee1 = initial_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge) + fee2 = initial_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge2) + + # First fixed charge: 10 units * $10 = $100 + expect(fee1.units).to eq(10) + expect(fee1.amount_cents).to eq(10_000) + + # Second fixed charge: 5 units * $20 = $100 + expect(fee2.units).to eq(5) + expect(fee2.amount_cents).to eq(10_000) + + # Verify invoice total matches sum of fees ($100 + $100 = $200) + expect(initial_invoice.fees_amount_cents).to eq(initial_invoice.fees.sum(:amount_cents)) + expect(initial_invoice.fees_amount_cents).to eq(20_000) + end + + context "when both fixed charges are updated via plan update" do + before do + travel_to subscription_date + 5.days do + # Update plan with both fixed charges having apply_units_immediately: true + update_plan( + plan, + { + fixed_charges: [ + { + id: fixed_charge.id, + units: 15, + apply_units_immediately: true, + properties: {amount: "10"} + }, + { + id: fixed_charge2.id, + units: 10, + apply_units_immediately: true, + properties: {amount: "20"} + } + ] + } + ) + perform_all_enqueued_jobs + end + end + + it "generates a SINGLE invoice with fees for both fixed charge deltas" do + invoices = subscription.reload.invoices.order(:created_at) + + # We expect 2 invoices: + # 1. Initial invoice (both fixed charges) + # 2. ONE invoice with both fixed charges units deltas + expect(invoices.count).to eq(2) + + batched_invoice = invoices.last + expect(batched_invoice.fees.count).to eq(2) + + fee1 = batched_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge) + fee2 = batched_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge2) + + # First fixed charge delta: 15 - 10 = 5 units * $10 = $50 + expect(fee1.units).to eq(5) + expect(fee1.amount_cents).to eq(5_000) + + # Second fixed charge delta: 10 - 5 = 5 units * $20 = $100 + expect(fee2.units).to eq(5) + expect(fee2.amount_cents).to eq(10_000) + + # Verify invoice total matches sum of fees ($50 + $100 = $150) + expect(batched_invoice.fees_amount_cents).to eq(batched_invoice.fees.sum(:amount_cents)) + expect(batched_invoice.fees_amount_cents).to eq(15_000) + end + end + end + + describe "when adding and updating fix charges with apply units immediately" do + let(:add_on2) { create(:add_on, organization:) } + let(:subscription_date) { DateTime.new(2024, 3, 1) } + let(:subscription) { customer.subscriptions.first } + + before do + fixed_charge + + # Create subscription at the start of the month with one fixed charge + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_add_update_#{customer.external_id}", + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + # Process the initial invoice + travel_to subscription_date + 1.minute do + perform_all_enqueued_jobs + end + end + + context "when updating existing fixed charge AND adding a new fixed charge" do + before do + travel_to subscription_date + 5.days do + # Update the plan to: + # 1. Update existing fixed charge from 10 to 15 units + # 2. Add a new fixed charge with 8 units at $5 each + update_plan( + plan, + { + fixed_charges: [ + { + id: fixed_charge.id, + units: 15, + apply_units_immediately: true, + properties: {amount: "10"} + }, + { + add_on_id: add_on2.id, + invoice_display_name: "New Fixed Charge", + charge_model: "standard", + units: 8, + properties: {amount: "5"}, + pay_in_advance: true, + apply_units_immediately: true + } + ] + } + ) + perform_all_enqueued_jobs + end + end + + it "generates a SINGLE invoice with fees for both updated and new fixed charges" do + new_fixed_charge = plan.fixed_charges.find_by(add_on: add_on2) + invoices = subscription.reload.invoices.order(:created_at) + + # We expect 2 invoices: + # 1. Initial invoice (original fixed charge only) + # 2. ONE invoice with delta for updated + full for new + expect(invoices.count).to eq(2) + + combined_invoice = invoices.last + expect(combined_invoice.fees.count).to eq(2) + + updated_fixed_charge_fee = combined_invoice.fees.fixed_charge.find_by(fixed_charge:) + new_fixed_charge_fee = combined_invoice.fees.fixed_charge.find_by(fixed_charge: new_fixed_charge) + + # Updated fixed charge: delta only (15 - 10 = 5 units * $10 = $50) + expect(updated_fixed_charge_fee.units).to eq(5) + expect(updated_fixed_charge_fee.amount_cents).to eq(5_000) + + # New fixed charge: full amount (8 units * $5 = $40) + expect(new_fixed_charge_fee.units).to eq(8) + expect(new_fixed_charge_fee.amount_cents).to eq(4_000) + + # Total: $50 + $40 = $90 + expect(combined_invoice.fees_amount_cents).to eq(combined_invoice.fees.sum(:amount_cents)) + expect(combined_invoice.fees_amount_cents).to eq(9_000) + end + end + + context "when only adding a new fixed charge (no updates to existing)" do + before do + travel_to subscription_date + 5.days do + # Add a new fixed charge without updating the existing one + update_plan( + plan, + { + fixed_charges: [ + { + id: fixed_charge.id, + units: 10, # Same as before + properties: {amount: "10"} + }, + { + add_on_id: add_on2.id, + invoice_display_name: "New Fixed Charge", + charge_model: "standard", + units: 6, + properties: {amount: "15"}, + pay_in_advance: true, + apply_units_immediately: true + } + ] + } + ) + perform_all_enqueued_jobs + end + end + + it "generates an invoice only for the new fixed charge" do + new_fixed_charge = plan.fixed_charges.find_by(add_on: add_on2) + invoices = subscription.reload.invoices.order(:created_at) + + expect(invoices.count).to eq(2) + + new_charge_invoice = invoices.last + expect(new_charge_invoice.fees.count).to eq(1) + + fee = new_charge_invoice.fees.fixed_charge.first + expect(fee.fixed_charge).to eq(new_fixed_charge) + + # New fixed charge: 6 units * $15 = $90 + expect(fee.units).to eq(6) + expect(fee.amount_cents).to eq(9_000) + + expect(new_charge_invoice.fees_amount_cents).to eq(9_000) + end + end + end + + describe "when updating fixed charge with apply changes on next period" do + let(:subscription_date) { DateTime.new(2024, 3, 1) } + let(:subscription) { customer.subscriptions.first } + + before do + fixed_charge + + # Create subscription at the start of the month + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_next_period_#{customer.external_id}", + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + # Process the initial invoice + travel_to subscription_date + 1.minute do + perform_all_enqueued_jobs + end + + travel_to subscription_date + 5.days do + # Update the fixed charge units WITHOUT apply_units_immediately + update_plan( + plan, + { + fixed_charges: [{ + id: fixed_charge.id, + units: 15, + # No apply_units_immediately - changes apply next period + properties: {amount: "10"} + }] + } + ) + perform_all_enqueued_jobs + end + end + + it "does NOT generate a new invoice mid-period" do + invoices = subscription.reload.invoices.order(:created_at) + + # Only the initial invoice should exist + expect(invoices.count).to eq(1) + + initial_invoice = invoices.first + expect(initial_invoice.fees.fixed_charge.count).to eq(1) + expect(initial_invoice.fees.fixed_charge.first.units).to eq(10) + expect(initial_invoice.fees_amount_cents).to eq(10_000) + end + + it "creates a fixed charge event for the updated charge at next billing period" do + events = FixedChargeEvent.where(subscription:, fixed_charge:).order(:timestamp) + + expect(events.count).to eq(2) + expect(events.first.units).to eq(10) + expect(events.first.timestamp).to be < subscription_date.end_of_month + expect(events.last.units).to eq(15) + expect(events.last.timestamp).to be > subscription_date.end_of_month + end + end + + describe "when adding fixed charge with apply changes on next period" do + let(:add_on2) { create(:add_on, organization:) } + let(:subscription_date) { DateTime.new(2024, 3, 1) } + let(:subscription) { customer.subscriptions.first } + + before do + fixed_charge + + # Create subscription at the start of the month + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_next_period_#{customer.external_id}", + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + # Process the initial invoice + travel_to subscription_date + 1.minute do + perform_all_enqueued_jobs + end + + travel_to subscription_date + 5.days do + # Add a new fixed charge without apply_units_immediately + update_plan( + plan, + { + fixed_charges: [ + { + id: fixed_charge.id, + units: 10, # No change to existing + properties: {amount: "10"} + }, + { + add_on_id: add_on2.id, + invoice_display_name: "New Fixed Charge", + charge_model: "standard", + units: 8, + properties: {amount: "5"}, + pay_in_advance: true + # No apply_units_immediately + } + ] + } + ) + perform_all_enqueued_jobs + end + end + + it "does NOT generate a new invoice mid-period" do + invoices = subscription.reload.invoices.order(:created_at) + + # Only the initial invoice should exist + expect(invoices.count).to eq(1) + end + + it "creates a fixed charge event for the new charge at next billing period" do + new_fixed_charge = plan.fixed_charges.find_by(add_on: add_on2) + events = FixedChargeEvent.where(subscription:, fixed_charge: new_fixed_charge).order(:timestamp) + + expect(events.count).to eq(1) + expect(events.first.units).to eq(8) + # Event should be scheduled for next billing period + expect(events.first.timestamp).to be > subscription_date.end_of_month + end + end + + describe "when updating multiple fixed charges units with children plans" do + let(:add_on2) { create(:add_on, organization:) } + let(:subscription_date) { DateTime.new(2024, 3, 1) } + + # Second fixed charge: $20 per unit, 5 units, pay in advance + let(:fixed_charge2) do + create( + :fixed_charge, + plan:, + add_on: add_on2, + units: 5, + properties: {amount: "20"}, + pay_in_advance: true + ) + end + + # Parent plan setup + let(:parent_plan) { plan } + let(:parent_subscription) { customer.subscriptions.first } + + # Second customer for child subscription + let(:customer2) { create(:customer, organization:, timezone: "UTC") } + let(:child_subscription) { customer2.subscriptions.first } + + before do + fixed_charge + fixed_charge2 + + # Create parent subscription + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_parent_#{customer.external_id}", + plan_code: parent_plan.code, + billing_time: "calendar" + } + ) + + # Create child subscription using plan_overrides (creates a child plan) + create_subscription( + { + external_customer_id: customer2.external_id, + external_id: "sub_child_#{customer2.external_id}", + plan_code: parent_plan.code, + billing_time: "calendar", + plan_overrides: { + name: "Child Plan Override", + amount_cents: 5000 + } + } + ) + end + + # Process initial invoices + travel_to subscription_date + 1.minute do + perform_all_enqueued_jobs + end + end + + it "generates initial invoices for both parent and child subscriptions" do + expect(parent_subscription.invoices.count).to eq(1) + expect(child_subscription.invoices.count).to eq(1) + + parent_invoice = parent_subscription.invoices.first + child_invoice = child_subscription.invoices.first + + parent_fixed_charge_fee_1 = parent_invoice.fees.fixed_charge.find_by(fixed_charge:) + parent_fixed_charge_fee_2 = parent_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge2) + + expect(parent_fixed_charge_fee_1.units).to eq(10) + expect(parent_fixed_charge_fee_2.units).to eq(5) + + child_fixed_charge_1 = child_subscription.fixed_charges.find_by(parent: fixed_charge) + child_fixed_charge_2 = child_subscription.fixed_charges.find_by(parent: fixed_charge2) + child_fixed_charge_fee_1 = child_invoice.fees.fixed_charge.find_by(fixed_charge: child_fixed_charge_1) + child_fixed_charge_fee_2 = child_invoice.fees.fixed_charge.find_by(fixed_charge: child_fixed_charge_2) + + expect(child_fixed_charge_fee_1.units).to eq(10) + expect(child_fixed_charge_fee_2.units).to eq(5) + end + + context "when parent plan fixed charge is updated with apply_units_immediately and cascade" do + let(:child_fixed_charge1) { child_subscription.fixed_charges.find_by(parent: fixed_charge) } + let(:child_fixed_charge2) { child_subscription.fixed_charges.find_by(parent: fixed_charge2) } + + before do + travel_to subscription_date + 5.days do + # Update parent plan with cascade + update_plan( + parent_plan, + { + cascade_updates: true, + fixed_charges: [ + { + id: fixed_charge.id, + units: 25, + apply_units_immediately: true, + properties: {amount: "10"}, + charge_model: "standard" + }, + { + id: fixed_charge2.id, + units: 15, + apply_units_immediately: true, + properties: {amount: "20"}, + charge_model: "standard" + } + ] + } + ) + perform_all_enqueued_jobs + end + end + + it "updates the child fixed charges units" do + expect(child_fixed_charge1.reload.units).to eq(25) + expect(child_fixed_charge2.reload.units).to eq(15) + end + + it "creates fixed charge events for both parent and child subscriptions" do + # Parent events + parent_events_1 = FixedChargeEvent.where(subscription: parent_subscription, fixed_charge:).order(:timestamp) + expect(parent_events_1.count).to eq(2) + expect(parent_events_1.last.units).to eq(25) + + parent_events_2 = FixedChargeEvent.where(subscription: parent_subscription, fixed_charge: fixed_charge2).order(:timestamp) + expect(parent_events_2.count).to eq(2) + expect(parent_events_2.last.units).to eq(15) + + # Child events + child_events_1 = FixedChargeEvent.where(subscription: child_subscription, fixed_charge: child_fixed_charge1).order(:timestamp) + expect(child_events_1.count).to eq(2) + expect(child_events_1.last.units).to eq(25) + + child_events_2 = FixedChargeEvent.where(subscription: child_subscription, fixed_charge: child_fixed_charge2).order(:timestamp) + expect(child_events_2.count).to eq(2) + expect(child_events_2.last.units).to eq(15) + end + + it "generates a single delta invoices for each parent and child subscriptions" do + # Parent should have 2 invoices (initial + delta for both fixed charges) + parent_invoices = parent_subscription.reload.invoices.order(:created_at) + expect(parent_invoices.count).to eq(2) + + parent_delta_invoice = parent_invoices.last + expect(parent_delta_invoice.fees.count).to eq(2) + + parent_fixed_charge_fee_1 = parent_delta_invoice.fees.fixed_charge.find_by(fixed_charge:) + parent_fixed_charge_fee_2 = parent_delta_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge2) + + expect(parent_fixed_charge_fee_1.units).to eq(15) # 25 - 10 = 15 + expect(parent_fixed_charge_fee_1.amount_cents).to eq(15_000) + expect(parent_fixed_charge_fee_2.units).to eq(10) # 15 - 5 = 10 + expect(parent_fixed_charge_fee_2.amount_cents).to eq(20_000) + + # Child should also have 2 invoices (initial + delta for both fixed charges) + child_invoices = child_subscription.reload.invoices.order(:created_at) + expect(child_invoices.count).to eq(2) + + child_delta_invoice = child_invoices.last + expect(child_delta_invoice.fees.count).to eq(2) + + child_delta_invoice.fees.fixed_charge.find_by(fixed_charge: child_fixed_charge1) + child_delta_invoice.fees.fixed_charge.find_by(fixed_charge: child_fixed_charge2) + + expect(parent_fixed_charge_fee_1.units).to eq(15) # 25 - 10 = 15 + expect(parent_fixed_charge_fee_1.amount_cents).to eq(15_000) + expect(parent_fixed_charge_fee_2.units).to eq(10) # 15 - 5 = 10 + expect(parent_fixed_charge_fee_2.amount_cents).to eq(20_000) + end + end + + context "when parent plan fixed charge is updated WITHOUT cascade" do + before do + travel_to subscription_date + 5.days do + # Update parent plan WITHOUT cascade + update_plan( + parent_plan, + { + cascade_updates: false, + fixed_charges: [{ + id: fixed_charge.id, + units: 15, + apply_units_immediately: true, + properties: {amount: "10"}, + charge_model: "standard" + }] + } + ) + perform_all_enqueued_jobs + end + end + + it "does NOT update the child fixed charge units" do + child_fixed_charge1 = child_subscription.fixed_charges.find_by(parent: fixed_charge) + + expect(fixed_charge.reload.units).to eq(15) + expect(child_fixed_charge1.reload.units).to eq(10) # Unchanged + end + + it "generates delta invoice only for parent subscription" do + # Parent should have 2 invoices + parent_invoices = parent_subscription.reload.invoices.order(:created_at) + expect(parent_invoices.count).to eq(2) + + # Child should still have only 1 invoice (initial only) + child_invoices = child_subscription.reload.invoices.order(:created_at) + expect(child_invoices.count).to eq(1) + end + end + end + + describe "when fixed charge was overridden on subscription creation" do + let(:subscription_date) { DateTime.new(2024, 3, 1) } + let(:subscription) { customer.subscriptions.first } + + # Parent plan setup + let(:parent_plan) { plan } + + before do + fixed_charge + + travel_to subscription_date do + # Create child subscription using plan_overrides (creates a child plan) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_child_#{customer.external_id}", + plan_code: parent_plan.code, + billing_time: "calendar", + plan_overrides: { + name: "Child Plan Override", + fixed_charges: [ + { + id: fixed_charge.id, + units: 50 + } + ] + } + } + ) + + # Process initial invoices + perform_all_enqueued_jobs + end + end + + it "generates initial invoice with overridden fixed charge units" do + expect(subscription.invoices.count).to eq(1) + initial_invoice = subscription.invoices.first + + expect(initial_invoice.fees.fixed_charge.count).to eq(1) + fee = initial_invoice.fees.fixed_charge.first + + # 50 units * $10 = $500 = 50000 cents + expect(fee.units).to eq(50) + expect(fee.amount_cents).to eq(50_000) + + # Verify invoice total matches sum of fees + expect(initial_invoice.fees_amount_cents).to eq(50_000) + end + + context "when parent plan fixed charge is updated with apply_units_immediately and cascade" do + let(:child_fixed_charge) { subscription.fixed_charges.find_by(parent: fixed_charge) } + + before do + travel_to subscription_date + 5.days do + # Update parent plan with cascade + update_plan( + parent_plan, + { + cascade_updates: true, + fixed_charges: [{ + id: fixed_charge.id, + units: 30, + apply_units_immediately: true, + properties: {amount: "10"}, + charge_model: "standard" + }] + } + ) + + perform_all_enqueued_jobs + end + end + + it "does not update the child fixed charge units" do + expect(child_fixed_charge.reload.units).to eq(50) + end + + it "does not generate a new invoice" do + expect(subscription.invoices.count).to eq(1) + end + end + end + + describe "when adding multiple fixed charges with children plans" do + let(:add_on2) { create(:add_on, organization:) } + let(:add_on3) { create(:add_on, organization:) } + let(:subscription_date) { DateTime.new(2024, 3, 1) } + + # Parent plan setup + let(:parent_plan) { plan } + let(:parent_subscription) { customer.subscriptions.first } + + # Second customer for child subscription + let(:customer2) { create(:customer, organization:, timezone: "UTC") } + let(:child_subscription) { customer2.subscriptions.first } + + before do + fixed_charge + + # Create parent subscription + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_parent_#{customer.external_id}", + plan_code: parent_plan.code, + billing_time: "calendar" + } + ) + + # Create child subscription using plan_overrides (creates a child plan) + create_subscription( + { + external_customer_id: customer2.external_id, + external_id: "sub_child_#{customer2.external_id}", + plan_code: parent_plan.code, + billing_time: "calendar", + plan_overrides: { + name: "Child Plan Override", + amount_cents: 5000 + } + } + ) + end + + # Process initial invoices + travel_to subscription_date + 1.minute do + perform_all_enqueued_jobs + end + end + + it "generates initial invoices for both parent and child subscriptions" do + expect(parent_subscription.invoices.count).to eq(1) + expect(child_subscription.invoices.count).to eq(1) + + parent_invoice = parent_subscription.invoices.first + child_invoice = child_subscription.invoices.first + + expect(parent_invoice.fees.fixed_charge.count).to eq(1) + expect(parent_invoice.fees.fixed_charge.first.units).to eq(10) + + expect(child_invoice.fees.fixed_charge.count).to eq(1) + expect(child_invoice.fees.fixed_charge.first.units).to eq(10) + end + + context "when update parent plan with new fixed charges with apply_units_immediately and cascade" do + let(:fixed_charge2) { parent_plan.fixed_charges.find_by(add_on: add_on2) } + let(:fixed_charge3) { parent_plan.fixed_charges.find_by(add_on: add_on3) } + let(:child_fixed_charge2) { child_subscription.fixed_charges.find_by(parent: fixed_charge2) } + let(:child_fixed_charge3) { child_subscription.fixed_charges.find_by(parent: fixed_charge3) } + + before do + travel_to subscription_date + 5.days do + # Update parent plan with cascade + update_plan( + parent_plan, + { + cascade_updates: true, + fixed_charges: [ + { + add_on_id: add_on2.id, + invoice_display_name: "New Fixed Charge", + charge_model: "standard", + units: 8, + properties: {amount: "5"}, + pay_in_advance: true, + apply_units_immediately: true + }, + { + add_on_id: add_on3.id, + invoice_display_name: "New Fixed Charge 2", + charge_model: "standard", + units: 33, + properties: {amount: "2"}, + pay_in_advance: true, + apply_units_immediately: true + } + ] + } + ) + perform_all_enqueued_jobs + end + end + + it "updates the child fixed charges units" do + expect(child_fixed_charge2.reload.units).to eq(8) + expect(child_fixed_charge3.reload.units).to eq(33) + end + + it "creates fixed charge events for both parent and child subscriptions" do + # Parent events + parent_events_2 = FixedChargeEvent.where(subscription: parent_subscription, fixed_charge: fixed_charge2).order(:timestamp) + expect(parent_events_2.count).to eq(1) + expect(parent_events_2.last.units).to eq(8) + + parent_events_3 = FixedChargeEvent.where(subscription: parent_subscription, fixed_charge: fixed_charge3).order(:timestamp) + expect(parent_events_3.count).to eq(1) + expect(parent_events_3.last.units).to eq(33) + + # Child events + child_events_2 = FixedChargeEvent.where(subscription: child_subscription, fixed_charge: child_fixed_charge2).order(:timestamp) + expect(child_events_2.count).to eq(1) + expect(child_events_2.last.units).to eq(8) + + child_events_3 = FixedChargeEvent.where(subscription: child_subscription, fixed_charge: child_fixed_charge3).order(:timestamp) + expect(child_events_3.count).to eq(1) + expect(child_events_3.last.units).to eq(33) + end + + it "generates a single delta invoices for each, parent and child subscriptions" do + # Parent should have 2 invoices (initial + delta for both fixed charges) + parent_invoices = parent_subscription.reload.invoices.order(:created_at) + expect(parent_invoices.count).to eq(2) + + parent_delta_invoice = parent_invoices.last + expect(parent_delta_invoice.fees.count).to eq(2) + + parent_fixed_charge_fee_2 = parent_delta_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge2) + parent_fixed_charge_fee_3 = parent_delta_invoice.fees.fixed_charge.find_by(fixed_charge: fixed_charge3) + + expect(parent_fixed_charge_fee_2.units).to eq(8) + expect(parent_fixed_charge_fee_2.amount_cents).to eq(4000) + expect(parent_fixed_charge_fee_3.units).to eq(33) + expect(parent_fixed_charge_fee_3.amount_cents).to eq(6600) + + # Child should also have 2 invoices (initial + delta for both fixed charges) + child_invoices = child_subscription.reload.invoices.order(:created_at) + expect(child_invoices.count).to eq(2) + + child_delta_invoice = child_invoices.last + expect(child_delta_invoice.fees.count).to eq(2) + + child_fixed_charge_fee_2 = child_delta_invoice.fees.fixed_charge.find_by(fixed_charge: child_fixed_charge2) + child_fixed_charge_fee_3 = child_delta_invoice.fees.fixed_charge.find_by(fixed_charge: child_fixed_charge3) + + expect(child_fixed_charge_fee_2.units).to eq(8) + expect(child_fixed_charge_fee_2.amount_cents).to eq(4000) + expect(child_fixed_charge_fee_3.units).to eq(33) + expect(child_fixed_charge_fee_3.amount_cents).to eq(6600) + end + end + + context "when parent plan fixed charge is created WITHOUT cascade" do + before do + travel_to subscription_date + 5.days do + # Update parent plan WITHOUT cascade + update_plan( + parent_plan, + { + cascade_updates: false, + fixed_charges: [{ + add_on_id: add_on2.id, + invoice_display_name: "New Fixed Charge", + charge_model: "standard", + units: 8, + properties: {amount: "5"}, + pay_in_advance: true, + apply_units_immediately: true + }] + } + ) + perform_all_enqueued_jobs + end + end + + it "does NOT update the child fixed charge units" do + expect(child_subscription.fixed_charges.count).to eq(1) + end + + it "generates delta invoice only for parent subscription" do + # Parent should have 2 invoices + parent_invoices = parent_subscription.reload.invoices.order(:created_at) + expect(parent_invoices.count).to eq(2) + + # Child should still have only 1 invoice (initial only) + child_invoices = child_subscription.reload.invoices.order(:created_at) + expect(child_invoices.count).to eq(1) + end + end + end + + describe "when updating subscription with plan_overrides creates child fixed charge" do + # Regression test: When a subscription is updated with plan_overrides, + # it creates a new child plan with new fixed charges (different IDs). + # The delta billing should correctly find the previous fee from the parent + # fixed charge, not just by the new fixed charge ID. + let(:subscription_date) { DateTime.new(2024, 12, 1) } + let(:subscription) { customer.subscriptions.first } + + # Fixed charge: $10 per unit, 5 units, pay in advance + let(:parent_fixed_charge) { fixed_charge } + + before do + parent_fixed_charge + + travel_to subscription_date do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_override_#{customer.external_id}", + plan_code: plan.code, + billing_time: "calendar", + subscription_at: subscription_date.iso8601 + } + ) + perform_all_enqueued_jobs + end + end + + it "generates initial invoice for 10 units with full period boundaries (Dec 1-31)" do + expect(subscription.invoices.count).to eq(1) + initial_invoice = subscription.invoices.first + + expect(initial_invoice.fees.fixed_charge.count).to eq(1) + fee = initial_invoice.fees.fixed_charge.first + + # 10 units * $10 = $100 = 10000 cents + expect(fee.units).to eq(10) + expect(fee.amount_cents).to eq(10_000) + + # Fee boundaries should be Dec 1 - Dec 31 (full period) + expect(fee.properties["fixed_charges_from_datetime"]).to eq("2024-12-01T00:00:00.000Z") + expect(fee.properties["fixed_charges_to_datetime"]).to eq("2024-12-31T23:59:59.999Z") + end + + context "when subscription is updated with plan_overrides to increase units to 15 with apply_units_immediately" do + before do + travel_to subscription_date + 1.hour do + update_subscription( + subscription, + { + plan_overrides: { + fixed_charges: [{ + id: parent_fixed_charge.id, + units: 15, + apply_units_immediately: true, + charge_model: "standard", + properties: {amount: "10"} + }] + } + } + ) + + perform_all_enqueued_jobs + end + end + + it "creates a child plan with overridden fixed charge" do + child_plan = subscription.reload.plan + expect(child_plan.parent_id).to eq(plan.id) + + child_fixed_charge = child_plan.fixed_charges.first + expect(child_fixed_charge.parent_id).to eq(parent_fixed_charge.id) + expect(child_fixed_charge.units).to eq(15) + end + + it "creates a fixed charge event for the child fixed charge" do + child_fixed_charge = subscription.reload.plan.fixed_charges.first + events = FixedChargeEvent.where(subscription:, fixed_charge: child_fixed_charge).order(:created_at) + + expect(events.count).to eq(1) + expect(events.first.units).to eq(15) + end + + it "generates a delta invoice for only 5 units (15 - 10), not 15 units" do + invoices = subscription.reload.invoices.order(:created_at) + + # Should have 2 invoices: + # 1. Initial invoice (10 units, $100) + # 2. Delta invoice (5 units, $50) - NOT 15 units + expect(invoices.count).to eq(2) + + initial_invoice = invoices.first + expect(initial_invoice.fees.fixed_charge.first.units).to eq(10) + expect(initial_invoice.fees.fixed_charge.first.amount_cents).to eq(10_000) + + delta_invoice = invoices.last + expect(delta_invoice.fees.fixed_charge.count).to eq(1) + + delta_fee = delta_invoice.fees.fixed_charge.first + # KEY ASSERTION: Should only be 10 units (delta), not 15 units (full amount) + # The bug is that it was generating 15 units because it couldn't find the + # previous fee since the fixed charge IDs are different (parent vs child) + expect(delta_fee.units).to eq(5) # 15 - 10 = 5 + expect(delta_fee.amount_cents).to eq(5_000) # 5 * $10 = $50 + end + + it "has correct boundaries on the delta fee (same period as initial)" do + delta_invoice = subscription.reload.invoices.order(:created_at).last + delta_fee = delta_invoice.fees.fixed_charge.first + + # Same billing period boundaries as initial fee + expect(delta_fee.properties["fixed_charges_from_datetime"]).to eq("2024-12-01T00:00:00.000Z") + expect(delta_fee.properties["fixed_charges_to_datetime"]).to eq("2024-12-31T23:59:59.999Z") + end + end + + context "when subscription is updated to decrease units (10 -> 3) via plan_overrides" do + before do + travel_to subscription_date + 1.hour do + update_subscription( + subscription, + { + plan_overrides: { + fixed_charges: [{ + id: parent_fixed_charge.id, + units: 3, + apply_units_immediately: true, + charge_model: "standard", + properties: {amount: "10"} + }] + } + } + ) + + perform_all_enqueued_jobs + end + end + + it "generates a zero-fee invoice for decrease (no refund on pay-in-advance)" do + invoices = subscription.reload.invoices.order(:created_at) + + # Should have 2 invoices: + # 1. Initial invoice (10 units, $100) + # 2. Decrease invoice (0 fee - no refund, no extra charge) + expect(invoices.count).to eq(2) + + initial_invoice = invoices.first + expect(initial_invoice.fees.fixed_charge.first.units).to eq(10) + + # The decrease invoice should NOT have any fees with positive units + # because we already paid for 10 units and are decreasing to 3 + decrease_invoice = invoices.last + expect(decrease_invoice.fees.fixed_charge.count).to eq(1) + expect(decrease_invoice.fees.fixed_charge.first.units).to eq(0) + end + end + + context "when subscription is updated twice via plan_overrides (10 -> 3 -> 15)" do + before do + # First update: decrease from 10 to 3 (no refund expected) + travel_to subscription_date + 1.hour do + update_subscription( + subscription, + { + plan_overrides: { + fixed_charges: [{ + id: parent_fixed_charge.id, + units: 3, + apply_units_immediately: true, + charge_model: "standard", + properties: {amount: "10"} + }] + } + } + ) + + perform_all_enqueued_jobs + end + + # Second update: increase from 3 to 15 + # Should only charge for delta from max paid (10), not from current (3) + travel_to subscription_date + 2.hours do + # After first override, the subscription has a child plan + child_fixed_charge = subscription.reload.plan.fixed_charges.first + + update_subscription( + subscription, + { + plan_overrides: { + fixed_charges: [{ + id: child_fixed_charge.id, + units: 15, + apply_units_immediately: true, + charge_model: "standard", + properties: {amount: "10"} + }] + } + } + ) + + perform_all_enqueued_jobs + end + end + + it "generates correct invoices respecting previously paid units" do + invoices = subscription.reload.invoices.order(:created_at) + + # When fixed properly: + # - Initial invoice (10 units, $100) + # - Decrease invoice (0 fee - no refund) + # - Increase invoice (5 units delta: 15 - 10, NOT 15 - 3) + # + # Bug behavior: generates wrong fee amounts because it can't find + # fees from parent fixed charge when calculating delta + expect(invoices.count).to eq(3) + + initial_invoice = invoices.first + expect(initial_invoice.fees.fixed_charge.first.units).to eq(10) + expect(initial_invoice.fees.fixed_charge.first.amount_cents).to eq(10_000) + + decrease_invoice = invoices.second + expect(decrease_invoice.fees.count).to eq(1) + expect(decrease_invoice.fees.first.units).to eq(0) + + # Increase invoice: should charge for 5 units only (15 - 10) + # NOT 12 units (15 - 3), because we already paid for 10 units + increase_invoice = invoices.last + increase_fee = increase_invoice.fees.fixed_charge.first + expect(increase_fee.units).to eq(5) # 15 - 10 = 5 + expect(increase_fee.amount_cents).to eq(5_000) + end + end + end +end diff --git a/spec/scenarios/invoices/adjusted_charge_fees_spec.rb b/spec/scenarios/invoices/adjusted_charge_fees_spec.rb new file mode 100644 index 0000000..78670a6 --- /dev/null +++ b/spec/scenarios/invoices/adjusted_charge_fees_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Adjusted Charge Fees Scenario", :premium, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, email_settings: "") } + + let(:customer) { create(:customer, organization:, invoice_grace_period: 5) } + let(:subscription_at) { DateTime.new(2022, 7, 19, 12, 12) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "custom") } + let(:unit_precise_amount) { nil } + + let(:adjusted_fee_params) do + { + invoice_display_name: "test-name-25", + unit_precise_amount:, + units: 3 + } + end + + let(:monthly_plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 12_900, + pay_in_advance: false + ) + end + + context "with adjusted units" do + it "creates invoices correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create( + :standard_charge, + plan: monthly_plan, + billable_metric:, + properties: {amount: "5"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + end + + travel_to(Time.zone.parse("2023-07-23T10:12")) do + create_event( + { + external_subscription_id: customer.external_id, + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + timestamp: Time.current.to_i, + properties: {billable_metric.field_name => 0} + } + ) + end + + # NOTE: August 19th: Bill subscription + travel_to(Time.zone.parse("2023-08-19T12:12")) do + perform_billing + + invoice = customer.invoices.order(created_at: :desc).first + fee = Fee.charge.where(invoice:).first + + expect(invoice.status).to eq("draft") + expect(invoice.total_amount_cents).to eq(12_900) + + AdjustedFees::CreateService.call(invoice:, params: adjusted_fee_params.merge(fee_id: fee.id)) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(12_900 + 1_500) + end + + # NOTE: August 20th: Refresh and finalize invoice + travel_to(Time.zone.parse("2023-08-20T12:12")) do + invoice = customer.invoices.order(created_at: :desc).first + + Invoices::RefreshDraftJob.perform_later(invoice:) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(12_900 + 1_500) + + Invoices::FinalizeJob.perform_later(invoice) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("finalized") + expect(invoice.reload.total_amount_cents).to eq(12_900 + 1_500) + end + end + end + + context "with adjusted amount" do + let(:unit_precise_amount) { "150.00" } + + it "creates invoices correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create( + :standard_charge, + plan: monthly_plan, + billable_metric:, + properties: {amount: "10"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + end + + travel_to(Time.zone.parse("2023-07-23T10:12")) do + create_event( + { + external_subscription_id: customer.external_id, + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + timestamp: Time.current.to_i, + properties: {billable_metric.field_name => 0} + } + ) + end + + # NOTE: August 19th: Bill subscription + travel_to(Time.zone.parse("2023-08-19T12:12")) do + perform_billing + + invoice = customer.invoices.order(created_at: :desc).first + fee = Fee.charge.where(invoice:).first + + expect(invoice.status).to eq("draft") + expect(invoice.total_amount_cents).to eq(12_900) + + AdjustedFees::CreateService.call(invoice:, params: adjusted_fee_params.merge(fee_id: fee.id)) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(12_900 + 45_000) + end + + # NOTE: August 20th: Refresh and finalize invoice + travel_to(Time.zone.parse("2023-08-20T12:12")) do + invoice = customer.invoices.order(created_at: :desc).first + + Invoices::RefreshDraftJob.perform_later(invoice:) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(12_900 + 45_000) + + Invoices::FinalizeJob.perform_later(invoice) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("finalized") + expect(invoice.reload.total_amount_cents).to eq(12_900 + 45_000) + end + end + end +end diff --git a/spec/scenarios/invoices/adjusted_subscription_fees_spec.rb b/spec/scenarios/invoices/adjusted_subscription_fees_spec.rb new file mode 100644 index 0000000..075ec43 --- /dev/null +++ b/spec/scenarios/invoices/adjusted_subscription_fees_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Adjusted Subscription Fees Scenario", :premium, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, email_settings: "") } + + let(:customer) { create(:customer, organization:, invoice_grace_period: 5) } + let(:subscription_at) { DateTime.new(2023, 7, 19, 12, 12) } + let(:unit_precise_amount) { nil } + + let(:adjusted_fee_params) do + { + invoice_display_name: "test-name-25", + unit_precise_amount:, + units: 3 + } + end + + let(:monthly_plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 12_900, + pay_in_advance: false + ) + end + + context "with adjusted units" do + it "creates invoices correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + end + + # NOTE: August 19th: Bill subscription + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + perform_billing + + invoice = customer.invoices.order(created_at: :desc).first + fee = invoice.fees.first + + expect(invoice.status).to eq("draft") + expect(invoice.total_amount_cents).to eq(12_900) + + AdjustedFees::CreateService.call(invoice:, params: adjusted_fee_params.merge(fee_id: fee.id)) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(38_700) + end + + # NOTE: August 20th: Refresh and finalize invoice + travel_to(DateTime.new(2023, 8, 20, 12, 12)) do + invoice = customer.invoices.order(created_at: :desc).first + + Invoices::RefreshDraftJob.perform_later(invoice:) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(38_700) + + Invoices::FinalizeJob.perform_later(invoice) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("finalized") + expect(invoice.reload.total_amount_cents).to eq(38_700) + end + end + end + + context "with adjusted amount" do + let(:unit_precise_amount) { "150.00" } + + it "creates invoices correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + end + + # NOTE: August 19th: Bill subscription + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + perform_billing + + invoice = customer.invoices.order(created_at: :desc).first + fee = invoice.fees.first + + expect(invoice.status).to eq("draft") + expect(invoice.total_amount_cents).to eq(12_900) + + AdjustedFees::CreateService.call(invoice:, params: adjusted_fee_params.merge(fee_id: fee.id)) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(45_000) + end + + # NOTE: August 20th: Refresh and finalize invoice + travel_to(DateTime.new(2023, 8, 20, 12, 12)) do + invoice = customer.invoices.order(created_at: :desc).first + + Invoices::RefreshDraftJob.perform_later(invoice:) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.total_amount_cents).to eq(45_000) + + Invoices::FinalizeJob.perform_later(invoice) + perform_all_enqueued_jobs + + expect(invoice.reload.status).to eq("finalized") + expect(invoice.reload.total_amount_cents).to eq(45_000) + end + end + end +end diff --git a/spec/scenarios/invoices/advance_charges_dates_spec.rb b/spec/scenarios/invoices/advance_charges_dates_spec.rb new file mode 100644 index 0000000..3c5a12c --- /dev/null +++ b/spec/scenarios/invoices/advance_charges_dates_spec.rb @@ -0,0 +1,369 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Advance Charges Invoices Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax_rate) { 20 } + let(:billable_metric) { create(:unique_count_billable_metric, organization:, code: "cards", recurring: true) } + let(:plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 49) } + let(:plan_upgrade) { create(:plan, organization:, pay_in_advance: true, amount_cents: 259) } + let(:external_subscription_id) { "sub_#{SecureRandom.hex}" } + let(:bm_amount) { 30.12 } + + def send_card_event!(item_id = SecureRandom.uuid) + api_response = create_event({ + code: billable_metric.code, + transaction_id: "tr_#{SecureRandom.hex(10)}", + external_customer_id: customer.external_id, + external_subscription_id:, + properties: {item_id:} + }) + Fee.where(pay_in_advance_event_id: api_response.dig("event", "lago_id")).sole.id + end + + def billing_periods_hash(invoice) + ::V1::InvoiceSerializer.new( + invoice, + root_name: "invoice", includes: [:billing_periods] + ).serialize[:billing_periods] + end + + before do + create(:tax, organization:, rate: tax_rate) + create(:standard_charge, regroup_paid_fees: "invoice", pay_in_advance: true, invoiceable: false, prorated: true, billable_metric:, plan:, properties: {amount: bm_amount.to_s, grouped_by: nil}) + create(:standard_charge, regroup_paid_fees: "invoice", pay_in_advance: true, invoiceable: false, prorated: true, billable_metric:, plan: plan_upgrade, properties: {amount: bm_amount.to_s, grouped_by: nil}) + end + + context "when subscription is upgraded, renewed and terminated" do + it do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + initial_subscription = customer.subscriptions.sole + fees = [] + + # Create an event but keep it unpaid + travel_to(DateTime.new(2024, 6, 12, 10)) do + fees << send_card_event!("card_1") + expect(initial_subscription.fees.charge.where(invoice_id: nil).count).to eq(1) + end + + upgraded_subscription = nil + # Upgrade the subscription (so previous one is terminated) + travel_to(DateTime.new(2024, 7, 7, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_upgrade.code + } + ) + + upgraded_subscription = customer.subscriptions.where.not(id: initial_subscription.id).sole + + expect(initial_subscription.reload).to be_terminated + expect(customer.invoices.count).to eq(2) # initial sub invoice + upgraded sub invoice + expect(upgraded_subscription.fees.charge.count).to eq 0 + end + + # Create an event but keep it unpaid + travel_to(DateTime.new(2024, 7, 22, 10)) do + fees << send_card_event!("card_2") + # one fee for each subscription + expect(initial_subscription.fees.charge.where(invoice_id: nil).count).to eq(1) + expect(upgraded_subscription.fees.charge.where(invoice_id: nil).count).to eq(1) + end + + # In october, both fees are finally marked as paid + travel_to(DateTime.new(2024, 10, 2, 10)) do + fees.each do |fee_id| + update_fee(fee_id, {payment_status: :succeeded}) + end + end + + # In november, these fees should be added to the advance_charge invoice + travel_to(DateTime.new(2024, 11, 1, 10)) do + perform_billing + invoice = customer.invoices.where(invoice_type: :advance_charges).sole + invoice_fees = invoice.fees.order(:created_at) + expect(invoice_fees.count).to eq(2) + + # Notice that the periods in the fees relate to the CREATION of the fees + expect(invoice_fees.first.properties).to eq({ + "timestamp" => "2024-06-12T10:00:00.000Z", + "to_datetime" => "2024-06-30T23:59:59.999Z", + "from_datetime" => "2024-06-05T00:00:00.000Z", + "charges_duration" => 30, + "charges_to_datetime" => "2024-06-30T23:59:59.999Z", + "charges_from_datetime" => "2024-06-05T10:00:00.000Z", + "fixed_charges_to_datetime" => nil, + "fixed_charges_from_datetime" => nil, + "fixed_charges_duration" => nil + }) + expect(invoice_fees.second.properties).to eq({ + "timestamp" => "2024-07-22T10:00:00.000Z", + "to_datetime" => "2024-07-31T23:59:59.999Z", + "from_datetime" => "2024-07-07T00:00:00.000Z", + "charges_duration" => 31, + "charges_to_datetime" => "2024-07-31T23:59:59.999Z", + "charges_from_datetime" => "2024-07-07T10:00:00.000Z", + "fixed_charges_to_datetime" => nil, + "fixed_charges_from_datetime" => nil, + "fixed_charges_duration" => nil + }) + + invoice_periods = billing_periods_hash(invoice) + expect(invoice_periods.count).to eq(2) + expect(invoice_periods).to all include({ + subscription_from_datetime: "2024-10-01T00:00:00Z", + subscription_to_datetime: "2024-10-31T23:59:59Z", + charges_from_datetime: "2024-10-01T00:00:00Z", + charges_to_datetime: "2024-10-31T23:59:59Z", + invoicing_reason: "in_advance_charge_periodic" + }) + end + + # Some more events + fees = [] + travel_to(DateTime.new(2024, 11, 18, 10)) do + fees << send_card_event!("card_3") + fees << send_card_event!("card_4") + end + travel_to(DateTime.new(2024, 12, 12, 10)) do + fees << send_card_event!("card_5") + end + + travel_to(DateTime.new(2025, 1, 1, 10)) do + expect(customer.invoices.where(invoice_type: :advance_charges).count).to eq 1 + perform_billing # No new advance_charges invoice should be created + expect(customer.invoices.where(invoice_type: :advance_charges).count).to eq 1 + end + + # Next year, the card_3 and card_5 fees are marked as paid + travel_to(DateTime.new(2025, 1, 7, 10)) do + update_fee(fees.first, {payment_status: :succeeded}) + update_fee(fees.last, {payment_status: :succeeded}) + end + + # The subscription is terminated. Customer have no other subscriptions + travel_to(DateTime.new(2025, 1, 22, 10)) do + terminate_subscription(upgraded_subscription) + invoice = customer.invoices.where(invoice_type: :advance_charges).max_by(&:created_at) + expect(invoice.fees.count).to eq(2) + + invoice_periods = billing_periods_hash(invoice) + expect(invoice_periods.count).to eq(1) + expect(invoice_periods.first).to include({ + subscription_from_datetime: "2025-01-01T00:00:00Z", + subscription_to_datetime: "2025-01-22T10:00:00Z", + charges_from_datetime: "2025-01-01T00:00:00Z", + charges_to_datetime: "2025-01-22T10:00:00Z", + invoicing_reason: "in_advance_charge_periodic" + }) + end + + # Note: if a fee is marked as paid after the last subscription with THIS external_id was terminated + # it will never be attached to an invoice + end + end + + context "when subscription is downgraded, renewed and terminated" do + it do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_upgrade.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + initial_subscription = customer.subscriptions.sole + fees = [] + + # Create an event but keep it unpaid + travel_to(DateTime.new(2024, 6, 12, 10)) do + fees << send_card_event!("card_1") + expect(initial_subscription.fees.charge.where(invoice_id: nil).count).to eq(1) + end + + downgraded_subscription = nil + # Upgrade the subscription (so previous one is terminated) + travel_to(DateTime.new(2024, 7, 7, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + + downgraded_subscription = customer.subscriptions.where.not(id: initial_subscription.id).sole + + expect(initial_subscription.reload).to be_active + expect(downgraded_subscription.reload).to be_pending + expect(customer.invoices.count).to eq(1) # initial sub invoice, nothing happens on downgrade + expect(downgraded_subscription.fees.charge.count).to eq 0 + end + + # Create an event but keep it unpaid + travel_to(DateTime.new(2024, 7, 22, 10)) do + fees << send_card_event!("card_2") + # one fee for each subscription + expect(initial_subscription.fees.charge.where(invoice_id: nil).count).to eq(2) + expect(downgraded_subscription.fees.charge.where(invoice_id: nil).count).to eq(0) + end + + # It's billing day + travel_to(DateTime.new(2024, 8, 1, 10)) do + perform_billing + + expect(customer.invoices.count).to eq(2) # initial + renew + expect(initial_subscription.reload).to be_terminated + expect(downgraded_subscription.reload).to be_active + end + + # In october, both fees are finally marked as paid + travel_to(DateTime.new(2024, 10, 2, 10)) do + fees.each do |fee_id| + update_fee(fee_id, {payment_status: :succeeded}) + end + end + + # In november, these fees should be added to the advance_charge invoice + travel_to(DateTime.new(2024, 11, 1, 10)) do + perform_billing + invoice = customer.invoices.where(invoice_type: :advance_charges).sole + invoice_fees = invoice.fees.order(:created_at) + expect(invoice_fees.count).to eq(2) + + # Notice that the periods in the fees relate to the CREATION of the fees + expect(invoice_fees.first.properties).to eq({ + "timestamp" => "2024-06-12T10:00:00.000Z", + "to_datetime" => "2024-06-30T23:59:59.999Z", + "from_datetime" => "2024-06-05T00:00:00.000Z", + "charges_duration" => 30, + "charges_to_datetime" => "2024-06-30T23:59:59.999Z", + "charges_from_datetime" => "2024-06-05T10:00:00.000Z", + "fixed_charges_to_datetime" => nil, + "fixed_charges_from_datetime" => nil, + "fixed_charges_duration" => nil + }) + expect(invoice_fees.second.properties).to eq({ + "timestamp" => "2024-07-22T10:00:00.000Z", + "to_datetime" => "2024-07-31T23:59:59.999Z", + "from_datetime" => "2024-07-01T00:00:00.000Z", + "charges_duration" => 31, + "charges_to_datetime" => "2024-07-31T23:59:59.999Z", + "charges_from_datetime" => "2024-07-01T00:00:00.000Z", + "fixed_charges_to_datetime" => nil, + "fixed_charges_from_datetime" => nil, + "fixed_charges_duration" => nil + }) + + invoice_periods = billing_periods_hash(invoice) + expect(invoice_periods.count).to eq(1) # only initial subscription had fees, so only on InvoiceSubscription + expect(invoice_periods).to all include({ + subscription_from_datetime: "2024-10-01T00:00:00Z", + subscription_to_datetime: "2024-10-31T23:59:59Z", + charges_from_datetime: "2024-10-01T00:00:00Z", + charges_to_datetime: "2024-10-31T23:59:59Z", + invoicing_reason: "in_advance_charge_periodic" + }) + end + end + end + + context "when subscription is upgraded but new sub has no events" do + it do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + initial_subscription = customer.subscriptions.sole + fees = [] + + # Create an event but keep it unpaid + travel_to(DateTime.new(2024, 6, 12, 10)) do + fees << send_card_event!("card_1") + expect(initial_subscription.fees.charge.where(invoice_id: nil).count).to eq(1) + end + + upgraded_subscription = nil + # Upgrade the subscription (so previous one is terminated) + travel_to(DateTime.new(2024, 7, 7, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_upgrade.code + } + ) + + upgraded_subscription = customer.subscriptions.where.not(id: initial_subscription.id).sole + + expect(initial_subscription.reload).to be_terminated + expect(customer.invoices.count).to eq(2) # initial sub invoice + upgraded sub invoice + expect(upgraded_subscription.fees.charge.count).to eq 0 + end + + # In october, the fee from initial sub is finally marked as paid + travel_to(DateTime.new(2024, 10, 2, 10)) do + fees.each do |fee_id| + update_fee(fee_id, {payment_status: :succeeded}) + end + end + + # In november, this fee should be added to the advance_charge invoice + travel_to(DateTime.new(2024, 11, 1, 10)) do + perform_billing + invoice = customer.invoices.where(invoice_type: :advance_charges).sole + invoice_fees = invoice.fees.order(:created_at) + + # Notice that the periods in the fees relate to the CREATION of the fees + expect(invoice_fees.first.properties).to eq({ + "timestamp" => "2024-06-12T10:00:00.000Z", + "to_datetime" => "2024-06-30T23:59:59.999Z", + "from_datetime" => "2024-06-05T00:00:00.000Z", + "charges_duration" => 30, + "charges_to_datetime" => "2024-06-30T23:59:59.999Z", + "charges_from_datetime" => "2024-06-05T10:00:00.000Z", + "fixed_charges_to_datetime" => nil, + "fixed_charges_from_datetime" => nil, + "fixed_charges_duration" => nil + }) + + invoice_periods = billing_periods_hash(invoice) + expect(invoice_periods).to all include({ + subscription_from_datetime: "2024-10-01T00:00:00Z", + subscription_to_datetime: "2024-10-31T23:59:59Z", + charges_from_datetime: "2024-10-01T00:00:00Z", + charges_to_datetime: "2024-10-31T23:59:59Z", + invoicing_reason: "in_advance_charge_periodic" + }) + end + end + end +end diff --git a/spec/scenarios/invoices/advance_charges_spec.rb b/spec/scenarios/invoices/advance_charges_spec.rb new file mode 100644 index 0000000..8c42657 --- /dev/null +++ b/spec/scenarios/invoices/advance_charges_spec.rb @@ -0,0 +1,394 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Advance Charges Invoices Scenarios", transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax_rate) { 20 } + let(:billable_metric) { create(:unique_count_billable_metric, organization:, code: "cards", recurring: true) } + let(:plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 49) } + let(:external_subscription_id) { SecureRandom.uuid } + let(:bm_amount) { 30.12 } + + def send_card_event!(item_id = SecureRandom.uuid) + create_event({ + code: billable_metric.code, + transaction_id: "tr_#{SecureRandom.hex(10)}", + external_customer_id: customer.external_id, + external_subscription_id:, + properties: {item_id:} + }) + end + + before do + create(:tax, :applied_to_billing_entity, organization:, rate: tax_rate) + create(:standard_charge, regroup_paid_fees: "invoice", pay_in_advance: true, invoiceable: false, prorated: true, billable_metric:, plan:, properties: {amount: bm_amount.to_s, grouped_by: nil}) + end + + context "when subscription is renewed" do + it "generates an invoice with the correct charges" do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + subscription = customer.subscriptions.sole + + (1..5).each do |i| + travel_to(DateTime.new(2024, 6, 10 + i, 10)) do + send_card_event! "card_#{i}" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(i) + expect(subscription.fees.charge.order(created_at: :desc).first.amount_cents).to eq ((bm_amount * (30 - 10 - (i - 1)) / 30) * 100).round + end + end + + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq 5 + subscription.fees.charge.order(created_at: :asc).limit(3).update!( + payment_status: :succeeded, + succeeded_at: DateTime.new(2024, 6, 20, 0, 10) + ) + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(customer.invoices.count).to eq(3) + # The 2 pending fees are not attached to the invoice + expect(subscription.fees.charge.where(invoice_id: nil, created_at: ..Time.current.beginning_of_month).count).to eq 2 + expect(subscription.fees.charge.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 1 # recurring fee + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges).sole + expect(advance_charges_invoice.fees_amount_cents).to eq(2008 + 1908 + 1807) + expect(advance_charges_invoice.applied_taxes.first.taxable_base_amount_cents).to eq(2008 + 1908 + 1807) + end + + travel_to(DateTime.new(2024, 7, 10, 10)) do + # Mark fees created in June + recurring fee for July as payment succeeded + Fee.where(invoice_id: nil).update!( + payment_status: :succeeded, + succeeded_at: Time.current + ) + end + + travel_to(DateTime.new(2024, 8, 1, 0, 10)) do + perform_billing + expect(customer.invoices.count).to eq(5) + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges).order(created_at: :desc).first + expect(advance_charges_invoice.fees.count).to eq 3 + expect(advance_charges_invoice.fees.charge.where(created_at: ..DateTime.new(2024, 7, 1)).count).to eq 2 + expect(advance_charges_invoice.fees_amount_cents).to eq((5 * bm_amount * 100) + 1707 + 1606) + + expect(advance_charges_invoice.total_amount_cents).to eq 22047 # Invoices::ComputeAmountsFromFees would return 22048 + expect(advance_charges_invoice.taxes_amount_cents).to eq 3674 # Invoices::ComputeAmountsFromFees would return 3675 + expect(advance_charges_invoice.fees_amount_cents).to eq 18373 + + expect(advance_charges_invoice.sub_total_excluding_taxes_amount_cents).to eq 18373 # == fees_amount_cents + expect(advance_charges_invoice.applied_taxes.first.taxable_base_amount_cents).to eq(18373) + expect(advance_charges_invoice.sub_total_including_taxes_amount_cents).to eq 22047 # == fees_amount_cents + taxes_amount_cents == total_amount_cents + + expect(advance_charges_invoice.coupons_amount_cents).to eq 0 + expect(advance_charges_invoice.credit_notes_amount_cents).to eq 0 + expect(advance_charges_invoice.prepaid_credit_amount_cents).to eq 0 + expect(advance_charges_invoice.progressive_billing_credit_amount_cents).to eq 0 + end + end + + context "when regrouped fee is succeeded just before the billing run" do + it "generates an invoice with the correct charges" do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + subscription = customer.subscriptions.sole + + travel_to(DateTime.new(2024, 7, 1, 0, 1)) do + send_card_event! "card_1" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(1) + expect(subscription.fees.charge.order(created_at: :desc).first.amount_cents).to eq (bm_amount * 100).round + subscription.fees.charge.order(created_at: :asc).update!( + payment_status: :succeeded, + succeeded_at: DateTime.new(2024, 7, 1, 0, 1) + ) + end + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(customer.invoices.count).to eq(2) + expect(customer.invoices.where(invoice_type: :advance_charges).count).to eq 0 + end + + travel_to(DateTime.new(2024, 8, 1, 0, 10)) do + perform_billing + expect(customer.invoices.count).to eq(4) + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges).order(created_at: :desc).first + expect(advance_charges_invoice.fees.count).to eq 1 + expect(advance_charges_invoice.fees.charge.where(created_at: ..DateTime.new(2024, 7, 1).end_of_day).count).to eq 1 + expect(advance_charges_invoice.fees_amount_cents).to eq(bm_amount * 100) + + expect(advance_charges_invoice.total_amount_cents).to eq 3614 + expect(advance_charges_invoice.taxes_amount_cents).to eq 602 + + expect(advance_charges_invoice.sub_total_excluding_taxes_amount_cents).to eq 3012 # == fees_amount_cents + expect(advance_charges_invoice.applied_taxes.first.taxable_base_amount_cents).to eq(3012) + expect(advance_charges_invoice.sub_total_including_taxes_amount_cents).to eq 3614 # == fees_amount_cents + taxes_amount_cents == total_amount_cents + + expect(advance_charges_invoice.coupons_amount_cents).to eq 0 + expect(advance_charges_invoice.credit_notes_amount_cents).to eq 0 + expect(advance_charges_invoice.prepaid_credit_amount_cents).to eq 0 + expect(advance_charges_invoice.progressive_billing_credit_amount_cents).to eq 0 + end + end + end + + context "with multiple regrouped fees and the one that is succeeded just before the billing run" do + it "generates an invoice with the correct charges" do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + subscription = customer.subscriptions.sole + + (1..4).each do |i| + travel_to(DateTime.new(2024, 6, 10 + i, 10)) do + send_card_event! "card_#{i}" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(i) + expect(subscription.fees.charge.order(created_at: :desc).first.amount_cents).to eq ((bm_amount * (30 - 10 - (i - 1)) / 30) * 100).round + end + end + + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq 4 + subscription.fees.charge.order(created_at: :asc).limit(2).update!( + payment_status: :succeeded, + succeeded_at: DateTime.new(2024, 6, 20, 0, 10) + ) + travel_to(DateTime.new(2024, 7, 1, 0, 1)) do + send_card_event! "card_5" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(5) + expect(subscription.fees.charge.order(created_at: :desc).first.amount_cents).to eq (bm_amount * 100).round + end + subscription.fees.charge.order(created_at: :desc).limit(1).update!( + payment_status: :succeeded, + succeeded_at: DateTime.new(2024, 7, 1, 0, 1) + ) + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(customer.invoices.count).to eq(3) + # The 2 pending fees are not attached to the invoice + expect(subscription.fees.charge.where(invoice_id: nil, created_at: ..Time.current.beginning_of_month).count).to eq 2 + expect(subscription.fees.charge.where(invoice_id: nil, created_at: Time.current.beginning_of_month..).count).to eq 2 # recurring fee + new fee + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges).sole + expect(advance_charges_invoice.fees_amount_cents).to eq(2008 + 1908) + expect(advance_charges_invoice.applied_taxes.first.taxable_base_amount_cents).to eq(2008 + 1908) + end + + travel_to(DateTime.new(2024, 7, 10, 10)) do + # Mark fees created in June + recurring fee for July as payment succeeded + Fee.where(invoice_id: nil).update!( + payment_status: :succeeded, + succeeded_at: Time.current + ) + end + + travel_to(DateTime.new(2024, 8, 1, 0, 10)) do + perform_billing + expect(customer.invoices.count).to eq(5) + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges).order(created_at: :desc).first + expect(advance_charges_invoice.fees.count).to eq 4 + expect(advance_charges_invoice.fees.charge.where(created_at: ..DateTime.new(2024, 7, 1)).count).to eq 2 + expect(advance_charges_invoice.fees_amount_cents).to eq((4 * bm_amount * 100) + 1807 + 1707 + (bm_amount * 100)) + + expect(advance_charges_invoice.total_amount_cents).to eq 22288 # Invoices::ComputeAmountsFromFees would return 22048 + expect(advance_charges_invoice.taxes_amount_cents).to eq 3714 # Invoices::ComputeAmountsFromFees would return 3675 + expect(advance_charges_invoice.fees_amount_cents).to eq 18574 + + expect(advance_charges_invoice.sub_total_excluding_taxes_amount_cents).to eq 18574 # == fees_amount_cents + expect(advance_charges_invoice.applied_taxes.first.taxable_base_amount_cents).to eq(18574) + expect(advance_charges_invoice.sub_total_including_taxes_amount_cents).to eq 22288 # == fees_amount_cents + taxes_amount_cents == total_amount_cents + + expect(advance_charges_invoice.coupons_amount_cents).to eq 0 + expect(advance_charges_invoice.credit_notes_amount_cents).to eq 0 + expect(advance_charges_invoice.prepaid_credit_amount_cents).to eq 0 + expect(advance_charges_invoice.progressive_billing_credit_amount_cents).to eq 0 + end + end + end + end + + context "when subscription is upgraded" do + let(:plan_upgrade) { create(:plan, organization:, pay_in_advance: true, amount_cents: 259) } + + before do + create(:standard_charge, regroup_paid_fees: "invoice", pay_in_advance: true, invoiceable: false, prorated: true, billable_metric:, plan: plan_upgrade, properties: {amount: "60", grouped_by: nil}) + end + + it "generates an invoice with the correct charges" do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + subscription = customer.subscriptions.sole + + travel_to(DateTime.new(2024, 6, 10, 10)) do + send_card_event! "card_1" + send_card_event! "card_2" + send_card_event! "card_3" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(3) + subscription.fees.charge.where(invoice_id: nil).update!( + payment_status: :succeeded, + succeeded_at: Time.current + ) + end + + upgraded_subscription = nil + + travel_to(DateTime.new(2024, 6, 15, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_upgrade.code + } + ) + + upgraded_subscription = customer.subscriptions.where.not(id: subscription.id).sole + expect(customer.invoices.count).to eq(3) + expect(upgraded_subscription.fees.charge.count).to eq 0 + advance_charges = customer.invoices.where(invoice_type: :advance_charges).sole + expect(advance_charges.fees.count).to eq(3) + end + + travel_to(DateTime.new(2024, 6, 20, 10)) do + send_card_event! "card_4" + expect(upgraded_subscription.fees.charge.where(invoice_id: nil).count).to eq(1) + upgraded_subscription.fees.charge.where(invoice_id: nil).update!( + payment_status: :succeeded, + succeeded_at: Time.current + ) + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + + expect(customer.invoices.count).to eq(5) + recurring_fee = upgraded_subscription.fees.charge.where(invoice_id: nil, created_at: Time.current.all_day).sole + expect(recurring_fee.units).to eq 4 + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges, created_at: Time.current.all_day).order(created_at: :desc).first + expect(advance_charges_invoice.fees.count).to eq(1) + expect(Fee.where(invoice_id: nil).excluding(recurring_fee).count).to eq 0 + end + end + end + + context "when subscription is downgraded" do + let(:plan_downgrade) { create(:plan, organization:, pay_in_advance: true, amount_cents: 19) } + + before do + create(:standard_charge, regroup_paid_fees: "invoice", pay_in_advance: true, invoiceable: false, prorated: true, billable_metric:, plan: plan_downgrade, properties: {amount: "15", grouped_by: nil}) + end + + it "generates an invoice with the correct charges" do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + expect(customer.invoices.count).to eq(1) + end + + subscription = customer.subscriptions.sole + + travel_to(DateTime.new(2024, 6, 10, 10)) do + send_card_event! "card_1" + send_card_event! "card_2" + send_card_event! "card_3" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(3) + subscription.fees.charge.where(invoice_id: nil).update!( + payment_status: :succeeded, + succeeded_at: Time.current + ) + end + + downgraded_subscription = nil + + travel_to(DateTime.new(2024, 6, 15, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan_downgrade.code + } + ) + perform_billing + + downgraded_subscription = customer.subscriptions.where.not(id: subscription.id).sole + expect(customer.invoices.count).to eq(1) + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq 3 + expect(downgraded_subscription.fees.charge.where(invoice_id: nil).count).to eq 0 + expect(subscription).to be_active + expect(downgraded_subscription).to be_pending + end + + travel_to(DateTime.new(2024, 6, 20, 10)) do + send_card_event! "card_4" + expect(downgraded_subscription.fees.charge.where(invoice_id: nil).count).to eq(0) + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq 4 + subscription.fees.charge.where(invoice_id: nil).update!( + payment_status: :succeeded, + succeeded_at: Time.current + ) + end + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + + expect(customer.invoices.count).to eq(3) + recurring_fee = subscription.fees.charge.where(invoice_id: nil, created_at: Time.current.all_day).sole + expect(recurring_fee.units).to eq 4 + + recurring_fee = downgraded_subscription.fees.charge.where(invoice_id: nil, created_at: Time.current.all_day) + expect(recurring_fee.count).to eq 0 + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges, created_at: Time.current.all_day).order(created_at: :desc).first + expect(advance_charges_invoice.fees.count).to eq(4) + expect(Fee.where(invoice_id: nil).excluding(recurring_fee).count).to eq 1 + end + end + end +end diff --git a/spec/scenarios/invoices/advance_charges_taxes_spec.rb b/spec/scenarios/invoices/advance_charges_taxes_spec.rb new file mode 100644 index 0000000..4a8efc4 --- /dev/null +++ b/spec/scenarios/invoices/advance_charges_taxes_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Advance Charges Invoices Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax_rate_1) { 9.3 } + let(:tax_rate_2) { 12 } + let(:billable_metric_cards) { create(:unique_count_billable_metric, organization:, code: "cards", recurring: true) } + let(:billable_metric_transfer) { create(:sum_billable_metric, organization:, code: "transfer", recurring: false) } + let(:plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 49) } + let(:external_subscription_id) { "sub_" + SecureRandom.uuid } + + def send_card_event!(item_id = SecureRandom.uuid) + send_event!(code: billable_metric_cards.code, item_id:) + end + + def send_card_transfer!(item_id = SecureRandom.uuid) + send_event!(code: billable_metric_transfer.code, item_id:) + end + + def send_event!(code:, item_id:) + create_event({ + code:, + transaction_id: "tr_#{SecureRandom.hex(10)}", + external_customer_id: customer.external_id, + external_subscription_id:, + properties: {item_id:} + }) + end + + before do + create(:tax, :applied_to_billing_entity, organization:, rate: tax_rate_1, code: "tax-1", description: "VAT 1", name: "VAT") + create(:tax, :applied_to_billing_entity, organization:, rate: tax_rate_2, code: "tax-2", description: "VAT 2", name: "bug VAT") + create(:standard_charge, billable_metric: billable_metric_cards, regroup_paid_fees: "invoice", pay_in_advance: true, invoiceable: false, prorated: true, plan:, properties: {amount: "30", grouped_by: nil}) + create(:standard_charge, billable_metric: billable_metric_transfer, regroup_paid_fees: "invoice", pay_in_advance: true, invoiceable: false, prorated: false, plan:, properties: {amount: "1", grouped_by: nil}) + end + + context "when subscription is renewed" do + it "generates an invoice with the correct taxes" do + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + end + + subscription = customer.subscriptions.sole + + (1..3).each do |i| + travel_to(DateTime.new(2024, 6, 10 + i, 1)) do + send_card_event! "card_#{i}" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(i) + created_fee = subscription.fees.charge.order(created_at: :desc).first + + expect(created_fee.amount_cents).to eq((21 - i) * 100) + expect(created_fee.taxes_rate).to eq(tax_rate_1 + tax_rate_2) + expect(created_fee.applied_taxes.count).to eq(2) + expect(created_fee.applied_taxes.find { it.tax_code == "tax-1" }.amount_cents).to eq (created_fee.amount_cents * tax_rate_1 / 100).round + expect(created_fee.applied_taxes.find { it.tax_code == "tax-2" }.amount_cents).to eq (created_fee.amount_cents * tax_rate_2 / 100).round + end + end + + travel_to(DateTime.new(2024, 6, 22, 1)) do + send_card_event! "transfer_1" + send_card_event! "transfer_2" + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq(5) + + fees = subscription.fees.charge.order(created_at: :desc).first(2) + fees.each do |fee| + expect(fee.applied_taxes.count).to eq(2) + end + end + + expect(subscription.fees.charge.where(invoice_id: nil).count).to eq 5 + subscription.fees.charge.order(created_at: :asc).update!( + payment_status: :succeeded, + succeeded_at: DateTime.new(2024, 6, 24) + ) + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + expect(customer.invoices.count).to eq(3) # 2 subscription invoices, 1 advance_charges invoice + + advance_charges_invoice = customer.invoices.where(invoice_type: :advance_charges).sole + expect(advance_charges_invoice.applied_taxes.count).to eq(2) + + total_tax_1 = 0 + total_tax_2 = 0 + advance_charges_invoice.fees.flat_map(&:applied_taxes).each do |applied_tax| + if applied_tax.tax_code == "tax-1" + total_tax_1 += applied_tax.amount_cents + elsif applied_tax.tax_code == "tax-2" + total_tax_2 += applied_tax.amount_cents + end + end + expect(advance_charges_invoice.applied_taxes.find { it.tax_code == "tax-1" }.amount_cents).to eq total_tax_1 + expect(advance_charges_invoice.applied_taxes.find { it.tax_code == "tax-2" }.amount_cents).to eq total_tax_2 + end + end + end +end diff --git a/spec/scenarios/invoices/filters_and_grouped_by_spec.rb b/spec/scenarios/invoices/filters_and_grouped_by_spec.rb new file mode 100644 index 0000000..ae95ef7 --- /dev/null +++ b/spec/scenarios/invoices/filters_and_grouped_by_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Invoices for charges with filters and grouped by" do + let(:organization) { create(:organization, webhook_url: nil, email_settings: []) } + + let(:customer) { create(:customer, organization:) } + + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "value") } + let(:billable_metric_filter1) do + create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[aws gcp azure]) + end + let(:billable_metric_filter2) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[us europe asia]) + end + + let(:plan) { create(:plan, organization:, amount_cents: 0, interval: "monthly", pay_in_advance: false) } + let(:charge) do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + end + + let(:charge_filter) { create(:charge_filter, charge:, properties: {amount: "12", grouped_by: %w[country]}) } + let(:charge_filter_value1) do + create(:charge_filter_value, charge_filter:, billable_metric_filter: billable_metric_filter1, values: %w[aws]) + end + let(:charge_filter_value2) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: billable_metric_filter2, + values: [ChargeFilterValue::ALL_FILTER_VALUES] + ) + end + + before do + charge_filter_value1 + charge_filter_value2 + end + + it "creates a new invoice for charges with filters and grouped by" do + # Create a subscription + travel_to(Time.zone.parse("2024-02-25T10:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + end + + subscription = customer.subscriptions.first + + # Send an event matching a filter and a group + travel_to(Time.zone.parse("2024-02-28T10:00:00")) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {cloud: "aws", region: "us", country: "us", value: 10} + } + ) + end + + travel_to(Time.zone.parse("2024-03-01T10:00:00")) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {cloud: "aws", region: "europe", country: "france", value: 10} + } + ) + end + + travel_to(Time.zone.parse("2024-03-02T10:00:00")) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {cloud: "aws", region: "asia", value: 10} + } + ) + end + + travel_to(Time.zone.parse("2024-03-03T10:00:00")) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {cloud: "gcp", region: "asia", country: "china", value: 10} + } + ) + end + + # Fetch the current usage + travel_to(Time.zone.parse("2024-03-04T10:00:00")) do + fetch_current_usage(customer:) + + expect(json[:customer_usage][:total_amount_cents]).to eq(46_000) + + expect(json[:customer_usage][:charges_usage].count).to eq(1) + charge_usage = json[:customer_usage][:charges_usage].first + expect(charge_usage[:units]).to eq("40.0") + expect(charge_usage[:events_count]).to eq(4) + expect(charge_usage[:amount_cents]).to eq(46_000) + + expect(charge_usage[:grouped_usage].count).to eq(4) + us_group = charge_usage[:grouped_usage].find { |group| group[:grouped_by][:country] == "us" } + expect(us_group[:amount_cents]).to eq(12_000) + expect(us_group[:events_count]).to eq(1) + expect(us_group[:units]).to eq("10.0") + expect(us_group[:filters].count).to eq(1) + expect(us_group[:filters].first[:units]).to eq("10.0") + expect(us_group[:filters].first[:values]).to eq(cloud: %w[aws], region: [ChargeFilterValue::ALL_FILTER_VALUES]) + + france_group = charge_usage[:grouped_usage].find { |group| group[:grouped_by][:country] == "france" } + expect(france_group[:amount_cents]).to eq(12_000) + expect(france_group[:events_count]).to eq(1) + expect(france_group[:units]).to eq("10.0") + expect(france_group[:filters].count).to eq(1) + expect(france_group[:filters].first[:units]).to eq("10.0") + expect(france_group[:filters].first[:values]).to eq( + cloud: %w[aws], + region: [ChargeFilterValue::ALL_FILTER_VALUES] + ) + + empty_group = charge_usage[:grouped_usage].find { |group| group[:grouped_by][:country].nil? } + expect(empty_group[:amount_cents]).to eq(12_000) + expect(empty_group[:events_count]).to eq(1) + expect(empty_group[:units]).to eq("10.0") + expect(empty_group[:filters].count).to eq(1) + + aws_filter = empty_group[:filters].find do |filter| + filter[:values] == {cloud: ["aws"], region: [ChargeFilterValue::ALL_FILTER_VALUES]} + end + expect(aws_filter[:units]).to eq("10.0") + expect(aws_filter[:values]).to eq(cloud: %w[aws], region: [ChargeFilterValue::ALL_FILTER_VALUES]) + + empty_filter = charge_usage[:filters].find { |filter| filter[:values].nil? } + expect(empty_filter[:amount_cents]).to eq(10_000) + expect(empty_filter[:events_count]).to eq(1) + expect(empty_filter[:units]).to eq("10.0") + expect(empty_filter[:values]).to be_nil + end + + # Run the billing job + travel_to(Time.zone.parse("2024-03-25T10:00:00")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + + invoice = subscription.invoices.last + expect(invoice.total_amount_cents).to eq(46_000) + + expect(invoice.fees.charge.count).to eq(4) + us_fee = invoice.fees.charge.find { |fee| fee.grouped_by["country"] == "us" } + expect(us_fee.amount_cents).to eq(12_000) + expect(us_fee.events_count).to eq(1) + expect(us_fee.units).to eq(10.0) + expect(us_fee.charge_filter).to eq(charge_filter) + + france_fee = invoice.fees.charge.find { |fee| fee.grouped_by["country"] == "france" } + expect(france_fee.amount_cents).to eq(12_000) + expect(france_fee.events_count).to eq(1) + expect(france_fee.units).to eq(10.0) + expect(france_fee.charge_filter).to eq(charge_filter) + + ungrouped_fee = invoice.fees.charge.find { |fee| fee.grouped_by["country"].nil? } + expect(ungrouped_fee.amount_cents).to eq(12_000) + expect(ungrouped_fee.events_count).to eq(1) + expect(ungrouped_fee.units).to eq(10.0) + expect(ungrouped_fee.charge_filter).to eq(charge_filter) + + empty_filter = invoice.fees.charge.find { |fee| fee.charge_filter_id.nil? } + expect(empty_filter.amount_cents).to eq(10_000) + expect(empty_filter.events_count).to eq(1) + expect(empty_filter.units).to eq(10) + expect(empty_filter.charge_filter).to be_nil + end + end +end diff --git a/spec/scenarios/invoices/invoice_numbering_spec.rb b/spec/scenarios/invoices/invoice_numbering_spec.rb new file mode 100644 index 0000000..099516e --- /dev/null +++ b/spec/scenarios/invoices/invoice_numbering_spec.rb @@ -0,0 +1,1144 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Invoice Numbering Scenario", transaction: false do + let(:customer_first) { create(:customer, organization:, billing_entity: billing_entity_first) } + let(:customer_second) { create(:customer, organization:, billing_entity: billing_entity_first) } + let(:customer_third) { create(:customer, organization:, billing_entity: billing_entity_first) } + let(:subscription_at) { DateTime.new(2023, 7, 19, 12, 12) } + + let(:organization) do + create(:organization, document_number_prefix: "ORG-1", webhook_url: nil) + end + + let(:billing_entity_first) do + create( + :billing_entity, + organization:, + document_numbering: "per_customer", + timezone: "Europe/Paris", + email_settings: [], + document_number_prefix: "BENT-1" + ) + end + + let(:monthly_plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 12_900, + pay_in_advance: true + ) + end + let(:yearly_plan) do + create( + :plan, + organization:, + interval: "yearly", + amount_cents: 100_000, + pay_in_advance: true + ) + end + + before do + organization.webhook_endpoints.destroy_all + end + + it "creates invoice numbers correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: customer_first.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: customer_second.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_third.external_id, + external_id: customer_third.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([1, 1, 1]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-001 BENT-1-002-001 BENT-1-003-001]) + end + + # NOTE: August 19th: Bill subscription + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([2, 2, 2]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-002 BENT-1-002-002 BENT-1-003-002]) + end + + # NOTE: September 19th: Bill subscription + travel_to(DateTime.new(2023, 9, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([3, 3, 3]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-003 BENT-1-002-003 BENT-1-003-003]) + end + + # NOTE: October 19th: Switching to per_organization numbering and Bill subscription + travel_to(DateTime.new(2023, 10, 19, 12, 12)) do + update_organization({document_numbering: "per_organization", document_number_prefix: "BENT-11"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([4, 4, 4]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([10, 11, 12]) + expect(numbers).to match_array(%w[BENT-11-202310-010 BENT-11-202310-011 BENT-11-202310-012]) + end + + # NOTE: November 19th: Switching to per_customer numbering and Bill subscription + travel_to(DateTime.new(2023, 11, 19, 12, 12)) do + update_organization({document_numbering: "per_customer"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([5, 5, 5]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-11-001-005 BENT-11-002-005 BENT-11-003-005]) + end + + # NOTE: November 22: New subscription for second customer + time = DateTime.new(2023, 11, 22, 12, 12) + travel_to(time) do + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: "new_external_id", + plan_code: yearly_plan.code, + billing_time: "anniversary", + subscription_at: time.iso8601 + } + ) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.first.sequential_id).to eq(6) + expect(invoices.first.organization_sequential_id).to be_zero + expect(invoices.first.billing_entity_sequential_id).to be_nil + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-001-001 + BENT-1-002-001 + BENT-1-003-001 + BENT-1-001-002 + BENT-1-002-002 + BENT-1-003-002 + BENT-1-001-003 + BENT-1-002-003 + BENT-1-003-003 + BENT-11-202310-010 + BENT-11-202310-011 + BENT-11-202310-012 + BENT-11-001-005 + BENT-11-002-005 + BENT-11-003-005 + BENT-11-002-006 + ] + ) + end + + # NOTE: December 19th: Switching to per_organization numbering and Bill subscription + travel_to(DateTime.new(2023, 12, 19, 12, 12)) do + update_organization({document_numbering: "per_organization"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([6, 6, 7]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([17, 18, 19]) + expect(numbers).to match_array(%w[BENT-11-202312-017 BENT-11-202312-018 BENT-11-202312-019]) + end + + # NOTE: January 19th 2024: Billing subscription + travel_to(DateTime.new(2024, 1, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([7, 7, 8]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([20, 21, 22]) + expect(numbers).to match_array(%w[BENT-11-202401-020 BENT-11-202401-021 BENT-11-202401-022]) + end + end + + context "with organization timezone" do + it "creates invoice numbers correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: customer_first.external_id, + plan_code: monthly_plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: customer_second.external_id, + plan_code: monthly_plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_third.external_id, + external_id: customer_third.external_id, + plan_code: monthly_plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601 + } + ) + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([1, 1, 1]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-001 BENT-1-002-001 BENT-1-003-001]) + end + + # NOTE: August 1st: Bill subscription + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([2, 2, 2]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-002 BENT-1-002-002 BENT-1-003-002]) + end + + # NOTE: September 1st: Bill subscription + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([3, 3, 3]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-003 BENT-1-002-003 BENT-1-003-003]) + end + + timezone = "Europe/Paris" + customer_first.update(timezone:) + customer_second.update(timezone:) + customer_third.update(timezone:) + + # NOTE: October 1st: Switching to per_organization numbering and Bill subscription + travel_to(DateTime.new(2023, 9, 30, 23, 10)) do + update_organization({document_numbering: "per_organization", document_number_prefix: "BENT-11"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([4, 4, 4]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([10, 11, 12]) + expect(numbers).to match_array(%w[BENT-11-202310-010 BENT-11-202310-011 BENT-11-202310-012]) + end + end + end + + context "with grace period and per_customer numbering" do + let(:customer_second) { create(:customer, organization:, invoice_grace_period: 2) } + + it "creates invoice numbers correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: customer_first.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: customer_second.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_third.external_id, + external_id: customer_third.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([1, nil, 1]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-001 BENT-1-DRAFT BENT-1-003-001]) + end + + # NOTE: Jul 20th: New subscription for the first customer + time = subscription_at + 1.day + travel_to(time) do + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: "new_external_id", + plan_code: yearly_plan.code, + billing_time: "anniversary", + subscription_at: time.iso8601 + } + ) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.first.sequential_id).to eq(2) + expect(invoices.first.organization_sequential_id).to be_zero + expect(invoices.first.billing_entity_sequential_id).to be_nil + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-001-001 + BENT-1-DRAFT + BENT-1-003-001 + BENT-1-001-002 + ] + ) + end + + # NOTE: Jul 21st: New subscription for the second customer + time = subscription_at + 2.days + travel_to(time) do + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: "new_external_id_2", + plan_code: yearly_plan.code, + billing_time: "anniversary", + subscription_at: time.iso8601 + } + ) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.first.sequential_id).to be_nil + expect(invoices.first.organization_sequential_id).to be_zero + expect(invoices.first.billing_entity_sequential_id).to be_nil + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-001-001 + BENT-1-DRAFT + BENT-1-003-001 + BENT-1-001-002 + BENT-1-DRAFT + ] + ) + end + + travel_to(time + 1.hour) do + draft_invoice1 = customer_second.reload.invoices.draft.order(created_at: :asc).first + + finalize_invoice(draft_invoice1) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-001-001 + BENT-1-002-001 + BENT-1-003-001 + BENT-1-001-002 + BENT-1-DRAFT + ] + ) + end + + travel_to(time + 2.hours) do + draft_invoice2 = customer_second.reload.invoices.draft.order(created_at: :asc).last + + finalize_invoice(draft_invoice2) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-001-001 + BENT-1-002-001 + BENT-1-003-001 + BENT-1-001-002 + BENT-1-002-002 + ] + ) + end + + # NOTE: August 19th: Bill subscription + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = organization.reload.invoices.order(created_at: :desc).pluck(:number) + + expect(sequential_ids).to match_array([3, nil, 2]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers) + .to match_array( + %w[ + BENT-1-001-001 + BENT-1-002-001 + BENT-1-003-001 + BENT-1-001-002 + BENT-1-002-002 + BENT-1-001-003 + BENT-1-DRAFT + BENT-1-003-002 + ] + ) + end + + # NOTE: September 19th: Bill subscription + travel_to(DateTime.new(2023, 9, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = organization.reload.invoices.order(created_at: :desc).pluck(:number) + + expect(sequential_ids).to match_array([4, nil, 3]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers) + .to match_array( + %w[ + BENT-1-001-001 + BENT-1-002-001 + BENT-1-003-001 + BENT-1-001-002 + BENT-1-002-002 + BENT-1-001-003 + BENT-1-DRAFT + BENT-1-003-002 + BENT-1-001-004 + BENT-1-DRAFT + BENT-1-003-003 + ] + ) + end + end + end + + context "with grace period and per billing entity numbering" do + let(:customer_second) { create(:customer, organization:, invoice_grace_period: 2) } + + let(:organization) do + create( + :organization, + document_numbering: "per_organization", + document_number_prefix: "ORG-1", + webhook_url: nil + ) + end + + let(:billing_entity_first) do + create( + :billing_entity, + organization:, + document_numbering: "per_billing_entity", + document_number_prefix: "BENT-1", + timezone: "Europe/Paris", + email_settings: [] + ) + end + + it "creates invoice numbers correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: customer_first.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: customer_second.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_third.external_id, + external_id: customer_third.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([1, nil, 1]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([1, nil, 2]) + expect(numbers).to match_array(%w[BENT-1-202307-001 BENT-1-DRAFT BENT-1-202307-002]) + end + + # NOTE: Jul 20th: New subscription for the first customer + time = subscription_at + 1.day + travel_to(time) do + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: "new_external_id", + plan_code: yearly_plan.code, + billing_time: "anniversary", + subscription_at: time.iso8601 + } + ) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.first.sequential_id).to eq(2) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(invoices.first.organization_sequential_id).to eq(0) + expect(invoices.first.billing_entity_sequential_id).to eq(3) + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-202307-001 + BENT-1-DRAFT + BENT-1-202307-002 + BENT-1-202307-003 + ] + ) + end + + # NOTE: Jul 21st: New subscription for the second customer + time = subscription_at + 2.days + travel_to(time) do + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: "new_external_id_2", + plan_code: yearly_plan.code, + billing_time: "anniversary", + subscription_at: time.iso8601 + } + ) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.first.sequential_id).to be_nil + expect(invoices.first.organization_sequential_id).to be_zero + expect(invoices.first.billing_entity_sequential_id).to be_nil + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-202307-001 + BENT-1-DRAFT + BENT-1-202307-002 + BENT-1-202307-003 + BENT-1-DRAFT + ] + ) + end + + travel_to(time + 1.hour) do + draft_invoice1 = customer_second.reload.invoices.draft.order(created_at: :asc).first + + finalize_invoice(draft_invoice1) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-202307-001 + BENT-1-202307-004 + BENT-1-202307-002 + BENT-1-202307-003 + BENT-1-DRAFT + ] + ) + end + + travel_to(time + 2.hours) do + draft_invoice2 = customer_second.reload.invoices.draft.order(created_at: :asc).last + + finalize_invoice(draft_invoice2) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-202307-001 + BENT-1-202307-004 + BENT-1-202307-002 + BENT-1-202307-003 + BENT-1-202307-005 + ] + ) + end + + # NOTE: August 19th: Bill subscription + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = organization.reload.invoices.order(created_at: :desc).pluck(:number) + + expect(sequential_ids).to match_array([3, nil, 2]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([6, nil, 7]) + expect(numbers) + .to match_array( + %w[ + BENT-1-202307-001 + BENT-1-202307-004 + BENT-1-202307-002 + BENT-1-202307-003 + BENT-1-202307-005 + BENT-1-202308-006 + BENT-1-DRAFT + BENT-1-202308-007 + ] + ) + end + + # NOTE: September 19th: Bill subscription + travel_to(DateTime.new(2023, 9, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = organization.reload.invoices.order(created_at: :desc).pluck(:number) + + expect(sequential_ids).to match_array([4, nil, 3]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([8, nil, 9]) + expect(numbers) + .to match_array( + %w[ + BENT-1-202307-001 + BENT-1-202307-004 + BENT-1-202307-002 + BENT-1-202307-003 + BENT-1-202307-005 + BENT-1-202308-006 + BENT-1-DRAFT + BENT-1-202308-007 + BENT-1-202309-008 + BENT-1-DRAFT + BENT-1-202309-009 + ] + ) + end + + travel_to(DateTime.new(2023, 9, 20, 12, 12)) do + draft_invoice1 = customer_second.reload.invoices.draft.order(created_at: :asc).first + + finalize_invoice(draft_invoice1) + + invoices = organization.reload.invoices.order(created_at: :desc) + + expect(invoices.pluck(:number)) + .to match_array( + %w[ + BENT-1-202307-001 + BENT-1-202307-004 + BENT-1-202307-002 + BENT-1-202307-003 + BENT-1-202307-005 + BENT-1-202308-006 + BENT-1-202309-010 + BENT-1-202308-007 + BENT-1-202309-008 + BENT-1-DRAFT + BENT-1-202309-009 + ] + ) + end + end + end + + context "with partner customer", :premium do + let(:customer_third) { create(:customer, organization:, billing_entity: billing_entity_first, account_type: "partner") } + + before { organization.update!(premium_integrations: ["revenue_share"]) } + + it "creates invoice numbers correctly" do + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: customer_first.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: customer_second.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_third.external_id, + external_id: customer_third.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([1, 1, 1]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-001 BENT-1-002-001 BENT-1-003-001]) + end + + # NOTE: August 19th: Bill subscription + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([2, 2, 2]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-002 BENT-1-002-002 BENT-1-003-002]) + end + + # NOTE: September 19th: Bill subscription + travel_to(DateTime.new(2023, 9, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([3, 3, 3]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-003 BENT-1-002-003 BENT-1-003-003]) + end + + # NOTE: October 19th: Switching to per_organization numbering and Bill subscription + travel_to(DateTime.new(2023, 10, 19, 12, 12)) do + update_organization({document_numbering: "per_organization", document_number_prefix: "BENT-11"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([4, 4, 4]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([7, 8, nil]) + expect(numbers).to match_array(%w[BENT-11-202310-007 BENT-11-202310-008 BENT-11-003-004]) + end + + # NOTE: November 19th: Switching to per_customer numbering and Bill subscription + travel_to(DateTime.new(2023, 11, 19, 12, 12)) do + update_organization({document_numbering: "per_customer"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([5, 5, 5]) + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-11-001-005 BENT-11-002-005 BENT-11-003-005]) + end + + # NOTE: December 19th: Switching to per_organization numbering and Bill subscription + travel_to(DateTime.new(2023, 12, 19, 12, 12)) do + update_organization({document_numbering: "per_organization"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(3) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([6, 6, 6]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([11, 12, nil]) + expect(numbers).to match_array(%w[BENT-11-202312-011 BENT-11-202312-012 BENT-11-003-006]) + end + end + end + + context "with multiple billing entities" do + let(:organization) do + create( + :organization, + document_numbering: "per_customer", + billing_entities: [billing_entity_first, billing_entity_second] + ) + end + + let(:billing_entity_first) do + create(:billing_entity, document_numbering: "per_customer", document_number_prefix: "BENT-1") + end + + let(:billing_entity_second) do + create(:billing_entity, document_numbering: "per_customer", document_number_prefix: "BENT-2") + end + + let(:customer_fourth) { create(:customer, organization:, billing_entity: billing_entity_second) } + let(:customer_fifth) { create(:customer, organization:, billing_entity: billing_entity_second) } + + it "creates invoice numbers correctly" do + # NOTE: Jul 19th: create the subscriptions + travel_to(subscription_at) do + # First billing entity customers + create_subscription( + { + external_customer_id: customer_first.external_id, + external_id: customer_first.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_second.external_id, + external_id: customer_second.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_third.external_id, + external_id: customer_third.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + # Second billing entity customers + create_subscription( + { + external_customer_id: customer_fourth.external_id, + external_id: customer_fourth.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + create_subscription( + { + external_customer_id: customer_fifth.external_id, + external_id: customer_fifth.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + invoices = organization.invoices.order(created_at: :desc).limit(5) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([1, 1, 1, 1, 1]) + expect(organization_sequential_ids).to match_array([0, 0, 0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-001 BENT-1-002-001 BENT-1-003-001 BENT-2-004-001 BENT-2-005-001]) + end + + # NOTE: August 19th: Bill subscriptions + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(5) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([2, 2, 2, 2, 2]) + expect(organization_sequential_ids).to match_array([0, 0, 0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-002 BENT-1-002-002 BENT-1-003-002 BENT-2-004-002 BENT-2-005-002]) + end + + # NOTE: September 19th: Bill subscriptions + travel_to(DateTime.new(2023, 9, 19, 12, 12)) do + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(5) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([3, 3, 3, 3, 3]) + expect(organization_sequential_ids).to match_array([0, 0, 0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-1-001-003 BENT-1-002-003 BENT-1-003-003 BENT-2-004-003 BENT-2-005-003]) + end + + # NOTE: October 19th: Switching 1st billing entity to per_billing_entity numbering and Bill subscriptions + travel_to(DateTime.new(2023, 10, 19, 12, 12)) do + # TODO: the endpoint to update the billing entity is not available yet, + # updating organzation document_numbering to "per_organization" + # will update organization's default billing entity document_numbering + # to "per_billing_entity". + update_organization({document_numbering: "per_organization", document_number_prefix: "ORG-99"}) + update_billing_entity(billing_entity_first, {document_numbering: "per_billing_entity", document_number_prefix: "BENT-11"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(5) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([4, 4, 4, 4, 4]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([10, 11, 12, nil, nil]) + expect(numbers).to match_array(%w[BENT-11-202310-010 BENT-11-202310-011 BENT-11-202310-012 BENT-2-004-004 BENT-2-005-004]) + end + + # NOTE: November 19th: Switching 2nd billing entity to per_biling_entity numbering and Bill subscriptions + travel_to(DateTime.new(2023, 11, 19, 12, 12)) do + update_billing_entity(billing_entity_second, document_numbering: "per_billing_entity", document_number_prefix: "BENT-22") + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(5) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([5, 5, 5, 5, 5]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([13, 14, 15, 9, 10]) + expect(numbers).to match_array(%w[BENT-11-202311-013 BENT-11-202311-014 BENT-11-202311-015 BENT-22-202311-009 BENT-22-202311-010]) + end + + # NOTE: December 19th: Switching all to per_customer numbering and Bill subscriptions + travel_to(DateTime.new(2023, 12, 19, 12, 12)) do + # TODO: the endpoint to update the billing entity is not available yet, + # updating organzation document_numbering to "per_customer" + # will update organization's default billing entity document_numbering + # to "per_customer". + update_organization({document_numbering: "per_customer"}) + update_billing_entity(billing_entity_second, {document_numbering: "per_customer"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(5) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([6, 6, 6, 6, 6]) + expect(organization_sequential_ids).to match_array([0, 0, 0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([nil, nil, nil, nil, nil]) + expect(numbers).to match_array(%w[BENT-11-001-006 BENT-11-002-006 BENT-11-003-006 BENT-22-004-006 BENT-22-005-006]) + end + + # NOTE: January 19th 2024: Switching all to per_billing_entity numbering and Bill subscriptions + travel_to(DateTime.new(2024, 1, 19, 12, 12)) do + # TODO: the endpoint to update the billing entity is not available yet, + # updating organzation document_numbering to "per_organization" + # will update organization's default billing entity document_numbering + # to "per_billing_entity". + update_organization({document_numbering: "per_organization"}) + update_billing_entity(billing_entity_first, {document_numbering: "per_billing_entity"}) + update_billing_entity(billing_entity_second, {document_numbering: "per_billing_entity"}) + + perform_billing + + invoices = organization.invoices.order(created_at: :desc).limit(5) + sequential_ids = invoices.pluck(:sequential_id) + organization_sequential_ids = invoices.pluck(:organization_sequential_id) + billing_entity_sequential_ids = invoices.pluck(:billing_entity_sequential_id) + numbers = invoices.pluck(:number) + + expect(sequential_ids).to match_array([7, 7, 7, 7, 7]) + # we won't be filling organization_sequential_id as soon as we switch to per_billing_entity numbering + expect(organization_sequential_ids).to match_array([0, 0, 0, 0, 0]) + expect(billing_entity_sequential_ids).to match_array([19, 20, 21, 13, 14]) + expect(numbers).to match_array(%w[BENT-11-202401-019 BENT-11-202401-020 BENT-11-202401-021 BENT-22-202401-013 BENT-22-202401-014]) + end + end + end +end diff --git a/spec/scenarios/invoices/invoice_payments_spec.rb b/spec/scenarios/invoices/invoice_payments_spec.rb new file mode 100644 index 0000000..aa65a5c --- /dev/null +++ b/spec/scenarios/invoices/invoice_payments_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Invoice Payments Scenarios" do + let(:webhook_url) { "https://test.co/lago" } + let(:organization) do + create(:organization, + name: "JC AI", + premium_integrations: %w[auto_dunning], + email_settings: [], + webhook_url:) + end + + let(:external_subscription_id) { "sub_payment-failed" } + let(:plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 149_00) } + + let(:webhooks_sent) { [] } + + let(:customer) { create(:customer, organization:, net_payment_term: 2) } + + include_context "with Stripe configured for customer" + + before do + stub_pdf_generation + + stub_request(:post, webhook_url).with do |req| + webhooks_sent << JSON.parse(req.body.dup) + true + end.and_return(status: 200) + + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .and_return( + status: 402, + body: get_stripe_fixtures("payment_intent_card_declined_response.json") + ) + end + + it "retries overdue invoices" do + travel_to(DateTime.new(2025, 1, 1, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + + payment_failure_webhook = webhooks_sent.find { it["webhook_type"] == "invoice.payment_failure" } + + expect(payment_failure_webhook["payment_provider_invoice_payment_error"]["error_details"]).to include({ + "code" => "card_declined", + "message" => "Your card was declined." + }) + end + end +end diff --git a/spec/scenarios/invoices/invoices_spec.rb b/spec/scenarios/invoices/invoices_spec.rb new file mode 100644 index 0000000..6555efb --- /dev/null +++ b/spec/scenarios/invoices/invoices_spec.rb @@ -0,0 +1,1795 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Invoices Scenarios" do + let(:organization) { create(:organization, webhook_url: nil, email_settings: []) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + + before { tax } + + context "when pay in advance subscription with free trial used on several subscriptions" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 3500, pay_in_advance: true, trial_period: 7) } + + it "creates an invoice for the expected period" do + travel_to(Time.zone.parse("2024-03-04T21:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + expect(subscription.invoices.count).to eq(0) + + travel_to(Time.zone.parse("2024-03-11T22:00:00")) do + perform_billing + end + + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(2371) # (31 - 3 - 7) * 35 / 31 + + travel_to(Time.zone.parse("2024-03-11T23:00:00")) do + terminate_subscription(subscription) + end + + term_invoice = subscription.invoices.order(created_at: :desc).first + expect(term_invoice.total_amount_cents).to eq(0) + + expect(invoice.reload.credit_notes.count).to eq(1) + + credit_note = invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 23_71, + taxes_amount_cents: 0, + total_amount_cents: 23_71 + ) + + travel_to(Time.zone.parse("2024-03-11T23:05:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + subscription = customer.subscriptions.active.first + invoice = subscription.invoices.first + expect(invoice.fees_amount_cents).to eq(2371) # (31 - 4 - 6) * 35 / 31 + end + end + end + + context "when timezone is negative and not the same day as UTC" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:customer) { create(:customer, organization:, timezone: "America/Denver") } # UTC-6 + let(:plan) { create(:plan, organization:, amount_cents: 700, pay_in_advance: true, interval: "weekly") } + + it "creates an invoice for the expected period" do + travel_to(Time.zone.parse("2023-06-16T05:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(400) # 4 days + end + end + end + + context "when timezone is negative but same day as UTC" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:customer) { create(:customer, organization:, timezone: "America/Halifax") } # UTC-3 + let(:plan) { create(:plan, organization:, amount_cents: 700, pay_in_advance: true, interval: "weekly") } + + it "creates an invoice for the expected period" do + travel_to(Time.zone.parse("2023-06-16T05:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(300) # 3 days + end + end + end + + context "when timezone is positive but same day as UTC" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:customer) { create(:customer, organization:, timezone: "Europe/Paris") } # UTC+2 + let(:plan) { create(:plan, organization:, amount_cents: 700, pay_in_advance: true, interval: "weekly") } + + it "creates an invoice for the expected period" do + travel_to(Time.zone.parse("2023-06-16T20:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(300) # 3 days + end + end + end + + context "when timezone is positive and not the same day as UTC" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:customer) { create(:customer, organization:, timezone: "Asia/Karachi") } # UTC+5 + let(:plan) { create(:plan, organization:, amount_cents: 700, pay_in_advance: true, interval: "weekly") } + + it "creates an invoice for the expected period" do + travel_to(Time.zone.parse("2023-06-16T20:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(200) # 2 days + end + end + end + + context "when invoice boundaries should cover leap month february" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 700, pay_in_advance: true, interval: "monthly") } + + it "creates an invoice for the expected period" do + travel_to(Time.zone.parse("2023-06-16T05:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-01T12:12:00")) do + perform_billing + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime.iso8601).to eq("2024-02-01T00:00:00Z") + expect(invoice_subscription.to_datetime.iso8601).to eq("2024-02-29T23:59:59Z") + expect(invoice_subscription.charges_from_datetime.iso8601).to eq("2024-01-01T00:00:00Z") + expect(invoice_subscription.charges_to_datetime.iso8601).to eq("2024-01-31T23:59:59Z") + + expect(invoice.total_amount_cents).to eq(700) + end + end + end + + context "when subscription is upgraded without grace period" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 0) } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:plan_new) { create(:plan, organization:, amount_cents: 2000) } + let(:metric) { create(:latest_billable_metric, organization:) } + + it "creates invoices with correctly attached amounts and reasons" do + ### 24 Apr: Create subscription + charge. + apr24_10 = Time.zone.parse("2024-04-24T10:00:00") + + travel_to(apr24_10) do + create( + :package_charge, + plan: plan_new, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + invoiceable: true, + properties: { + amount: "2", + free_units: 1000, + package_size: 1000 + } + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.active.first + + ### 24 Apr: Upgrade subscription + apr24_11 = Time.zone.parse("2024-04-24T11:00:00") + + travel_to(apr24_11) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code + } + ) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice.status).to eq("finalized") + expect(invoice.total_amount_cents).to eq(0) + expect(invoice_subscription.invoicing_reason).to eq("subscription_terminating") + expect(invoice_subscription.from_datetime.iso8601).to eq("2024-04-24T00:00:00Z") + expect(invoice_subscription.to_datetime.iso8601).to eq("2024-04-24T11:00:00Z") + expect(invoice_subscription.charges_from_datetime.iso8601).to eq("2024-04-24T10:00:00Z") + expect(invoice_subscription.charges_to_datetime.iso8601).to eq("2024-04-24T11:00:00Z") + end + + latest_subscription = customer.subscriptions.active.order(created_at: :desc).first + + ### 26 Apr: Terminate subscription + apr26_11 = Time.zone.parse("2024-04-26T11:00:00") + + travel_to(apr26_11) do + expect { + terminate_subscription(latest_subscription) + }.to change { latest_subscription.reload.status }.from("active").to("terminated") + .and change { latest_subscription.invoices.count }.from(0).to(1) + + invoice = latest_subscription.invoices.first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice.status).to eq("finalized") + expect(invoice.total_amount_cents).to eq(240) # (2000/30) x 3 + tax + expect(invoice_subscription.invoicing_reason).to eq("subscription_terminating") + expect(invoice_subscription.from_datetime.iso8601).to eq("2024-04-24T00:00:00Z") + expect(invoice_subscription.to_datetime.iso8601).to eq("2024-04-26T11:00:00Z") + expect(invoice_subscription.charges_from_datetime.iso8601).to eq("2024-04-24T11:00:00Z") + expect(invoice_subscription.charges_to_datetime.iso8601).to eq("2024-04-26T11:00:00Z") + end + end + end + + context "when subscription is terminated with a grace period" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:metric) { create(:billable_metric, organization:) } + + it "does not update the invoice amount on refresh" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "3"}) + end + + subscription = customer.subscriptions.first + + ### 20 Dec: Terminate subscription + refresh. + dec20 = Time.zone.parse("2022-12-20T06:00:00") + + travel_to(dec20) do + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(233) # 12 / 31 * 6 + + # Refresh invoice + expect { + refresh_invoice(invoice) + }.not_to change { invoice.reload.total_amount_cents } + end + end + end + + context "when pay in arrear subscription with recurring charges is terminated" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "does bill the charges" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + properties: {amount: "3"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + travel_to(Time.zone.parse("2022-12-16T10:12")) do + create_event( + { + external_subscription_id: customer.external_id, + transaction_id: SecureRandom.uuid, + code: metric.code, + timestamp: Time.current.to_i, + properties: {metric.field_name => 0} + } + ) + end + + subscription = customer.subscriptions.first + + ### 20 Dec: Terminate subscription + refresh. + dec20 = Time.zone.parse("2022-12-20 06:00:00") + + travel_to(dec20) do + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + expect(invoice.fees.charge.count).to eq(1) + end + end + end + + context "when pay in arrear subscription with recurring and prorated charges is terminated" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "does bill the charges", transaction: true do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "3"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2022-12-16T10:12")) do + create_event( + { + external_subscription_id: customer.external_id, + transaction_id: SecureRandom.uuid, + code: metric.code, + timestamp: Time.current.to_i, + properties: {metric.field_name => 10} + } + ) + # we need to commit the transaction, so the event will also be visible with ActiveRecord::Base.connection + EventsRecord.connection.commit_db_transaction + end + + ### 20 Dec: Terminate subscription + refresh. + dec20 = Time.zone.parse("2022-12-20 06:00:00") + + travel_to(dec20) do + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(484) + end + end + end + + context "when pay in arrear subscription with no charges is terminated" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000, interval: "yearly") } + + it "creates subscription fee and adds it to the invoice" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + ### 20 Dec: Terminate subscription + refresh. + dec20 = Time.zone.parse("2022-12-20 06:00:00") + + travel_to(dec20) do + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + expect(invoice.fees.subscription.count).to eq(1) + end + end + end + + context "when pay in arrear subscription with recurring charges is upgraded and new plan does not contain same BM" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:plan_new) { create(:plan, organization:, amount_cents: 2000) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "does bill the charges" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + properties: {amount: "3"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2022-12-16T10:12")) do + create_event( + { + external_subscription_id: customer.external_id, + transaction_id: SecureRandom.uuid, + code: metric.code, + timestamp: Time.current.to_i, + properties: {metric.field_name => 0} + } + ) + end + + ### 20 Dec: Upgrade subscription + dec20 = Time.zone.parse("2022-12-20 06:00:00") + + travel_to(dec20) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code + } + ) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + expect(invoice.fees.charge.count).to eq(1) + end + end + end + + context "when pay in arrear subscription with recurring charges is upgraded and new plan contains same BM" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:plan_new) { create(:plan, organization:, amount_cents: 2000) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "does not bill the charges" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + properties: {amount: "3"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + ### 20 Dec: Upgrade subscription + dec20 = Time.zone.parse("2022-12-20 06:00:00") + + travel_to(dec20) do + create( + :standard_charge, + plan: plan_new, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + properties: {amount: "3"} + ) + + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code + } + ) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + expect(invoice.fees.charge.count).to eq(0) + end + end + end + + context "when pay in advance subscription with recurring and prorated charges is terminated" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "does not bill the charges" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: true, + prorated: true, + properties: {amount: "3"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + + ### 20 Dec: Terminate subscription + refresh. + dec20 = Time.zone.parse("2022-12-20 06:00:00") + + travel_to(dec20) do + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(0).to(1) + + invoice = subscription.invoices.first + expect(invoice.fees.charge.count).to eq(0) + end + end + end + + context "when pay in advance subscription is upgraded" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 2_900, pay_in_advance: true) } + let(:plan_new) { create(:plan, organization:, amount_cents: 29_000, pay_in_advance: true) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "bills fees correctly", transaction: false do + travel_to(Time.zone.parse("2024-01-01T00:00:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create( + :standard_charge, + plan: plan_new, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-01-02T00:00:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end + + travel_to(Time.zone.parse("2024-02-01T00:00:00")) do + perform_billing + end + + travel_to(Time.zone.parse("2024-02-12T06:00:00")) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code, + billing_time: "calendar" + } + ) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(2).to(3) + + invoice = customer.subscriptions.active.first.invoices.order(created_at: :desc).first + credit_note = customer.credit_notes.first + + expect(credit_note.credit_amount_cents).to eq(1_800) + expect(invoice.total_amount_cents).to eq(18_000 + 190 - 1_800) # 11/29 x 500 = 172 + end + + travel_to(Time.zone.parse("2024-03-01T12:12:00")) do + perform_billing + + invoice = customer.subscriptions.active.first.invoices.order(created_at: :desc).first + + expect(invoice.total_amount_cents).to eq((29_000 + (18.fdiv(29) * 500)).round) + end + end + end + + context "when pay in arrear subscription is upgraded" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 2_900, pay_in_advance: false) } + let(:plan_new) { create(:plan, organization:, amount_cents: 29_000, pay_in_advance: false) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "bills fees correctly", transaction: false do + travel_to(Time.zone.parse("2024-01-01T00:00:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create( + :standard_charge, + plan: plan_new, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-01-02T00:00:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end + + travel_to(Time.zone.parse("2024-02-01T00:00:00")) do + perform_billing + end + + travel_to(Time.zone.parse("2024-02-12T06:00:00")) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code, + billing_time: "calendar" + } + ) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(1).to(2) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + + expect(terminated_invoice.total_amount_cents).to eq((1_100 + (11.fdiv(29) * 500)).round) # 11 + 10/29 x 5 + end + + travel_to(Time.zone.parse("2024-03-01T12:12:00")) do + perform_billing + + invoice = customer.subscriptions.active.first.invoices.order(created_at: :desc).first + + expect(invoice.total_amount_cents).to eq((18_000 + (18.fdiv(29) * 500)).round) + end + end + end + + context "when pay in arrear plan events are ingested on the plan change date" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false) } + let(:plan_new) { create(:plan, organization:, amount_cents: 0, pay_in_advance: false) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: false, field_name: "amount") + end + + it "bills fees correctly" do + travel_to(Time.zone.parse("2024-01-10T06:20:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + properties: {amount: "0"} + ) + + create( + :standard_charge, + plan: plan_new, + billable_metric: metric, + pay_in_advance: false, + prorated: false, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-01-10T08:20:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "10"} + } + ) + + fetch_current_usage(customer:, subscription:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10.0") + end + + travel_to(Time.zone.parse("2024-01-10T08:30:00")) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_new.code, + billing_time: "anniversary" + } + ) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(0).to(1) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + active_subscription = customer.reload.subscriptions.active.order(created_at: :desc).first + + expect(terminated_invoice.total_amount_cents).to eq(0) + + fetch_current_usage(customer:, subscription: active_subscription) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + end + + active_subscription = customer.subscriptions.active.first + + travel_to(Time.zone.parse("2024-01-10T08:35:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: active_subscription.external_id, + properties: {amount: "10000"} + } + ) + + fetch_current_usage(customer:, subscription:) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(1_000_000) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(1_000_000) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("10000.0") + end + + travel_to(Time.zone.parse("2024-01-10T08:40:00")) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + perform_all_enqueued_jobs + }.to change { active_subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(1).to(2) + + terminated_invoice = active_subscription.invoices.order(created_at: :desc).first + active_subscription = customer.reload.subscriptions.active.order(created_at: :desc).first + + expect(terminated_invoice.total_amount_cents).to eq(1_000_000) + + fetch_current_usage(customer:, subscription: active_subscription) + expect(json[:customer_usage][:amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:total_amount_cents].round(2)).to eq(0) + expect(json[:customer_usage][:charges_usage][0][:units]).to eq("0.0") + end + end + end + + context "when pay in advance subscription is terminated" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 2_900, pay_in_advance: true) } + let(:plan_new) { create(:plan, organization:, amount_cents: 29_000, pay_in_advance: true) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "bills fees correctly", transaction: false do + travel_to(Time.zone.parse("2024-01-01T00:00:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create( + :standard_charge, + plan: plan_new, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-01-02T00:00:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end + + travel_to(Time.zone.parse("2024-02-01T00:00:00")) do + perform_billing + end + + travel_to(Time.zone.parse("2024-02-12T06:00:00")) do + expect { + terminate_subscription(subscription) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(2).to(3) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + credit_note = customer.credit_notes.first + + expect(credit_note.credit_amount_cents).to eq(1_700) + expect(terminated_invoice.total_amount_cents).to eq(0) # 12/29 x 500 = 207 - 207(CN) + expect(terminated_invoice.fees_amount_cents).to eq(207) + expect(terminated_invoice.credit_notes_amount_cents).to eq(207) + end + end + end + + context "when pay in advance subscription is terminated on the same day" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 2_900, pay_in_advance: true) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:metric) { create(:billable_metric, organization:) } + + it "bills fees correctly" do + travel_to(Time.zone.parse("2024-02-01T03:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + subscription = customer.subscriptions.first + first_invoice = subscription.invoices.first + + travel_to(Time.zone.parse("2024-02-01T18:00:00")) do + expect { + terminate_subscription(subscription) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(1).to(2) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + credit_note = customer.credit_notes.first + + expect(first_invoice.reload.credit_notes.count).to eq(1) + expect(credit_note.credit_amount_cents).to eq(2_800) # Only one day is billed + expect(terminated_invoice.total_amount_cents).to eq(0) # There are no charges + end + end + + context "with usage events" do + it "bills the usage correctly" do + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "12"}) + + travel_to(Time.zone.parse("2024-02-01 03:00:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + subscription = customer.subscriptions.first + first_invoice = subscription.invoices.first + + travel_to(Time.zone.parse("2024-02-01 10:00:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id + } + ) + end + + travel_to(Time.zone.parse("2024-02-01 18:00:00")) do + expect { + terminate_subscription(subscription) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(1).to(2) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + credit_note = customer.credit_notes.sole + + expect(first_invoice.reload.credit_notes.count).to eq(1) + expect(credit_note.credit_amount_cents).to eq(2_800) # Only one day is billed + expect(terminated_invoice.fees.charge.sole.total_amount_cents).to eq(1200) + end + end + end + end + + context "when pay in advance subscription with grace period is terminated", :premium do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:plan) { create(:plan, organization:, amount_cents: 2_900, pay_in_advance: true) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + let(:adjusted_fee_params) do + { + unit_precise_amount: "5.00", + units: 3 + } + end + + it "bills fees correctly", transaction: false do + travel_to(Time.zone.parse("2024-01-01T00:00:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + first_invoice = customer.invoices.draft.first + subscription = customer.subscriptions.first + + finalize_invoice(first_invoice) + + travel_to(Time.zone.parse("2024-01-02T00:00:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end + + travel_to(Time.zone.parse("2024-02-01T00:00:00")) do + perform_billing + + finalize_invoice(subscription.invoices.order(created_at: :desc).first) + end + + travel_to(Time.zone.parse("2024-02-12T06:00:00")) do + expect { + terminate_subscription(subscription) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(2).to(3) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + credit_note = customer.credit_notes.first + + # credit notes are not applied on draft invoice + expect(credit_note.credit_amount_cents).to eq(1_700) + expect(credit_note.balance_amount_cents).to eq(1_700) + expect(terminated_invoice.total_amount_cents).to eq(207) # 12/29 x 500 = 207 + expect(terminated_invoice.fees_amount_cents).to eq(207) + expect(terminated_invoice.credit_notes_amount_cents).to eq(0) + + # In terminated invoice there is only one fee that is charge kind + fee = terminated_invoice.fees.charge.first + + AdjustedFees::CreateService.call(invoice: terminated_invoice, params: adjusted_fee_params.merge(fee_id: fee.id)) + credit_note = credit_note.reload + terminated_invoice = terminated_invoice.reload + + expect(credit_note.credit_amount_cents).to eq(1_700) + expect(credit_note.balance_amount_cents).to eq(1_700) + expect(terminated_invoice.total_amount_cents).to eq(1_500) + expect(terminated_invoice.fees_amount_cents).to eq(1_500) + expect(terminated_invoice.credit_notes_amount_cents).to eq(0) + + finalize_invoice(terminated_invoice) + credit_note = credit_note.reload + terminated_invoice = terminated_invoice.reload + + # after finalizing draft invoice, credit notes got applied + expect(credit_note.credit_amount_cents).to eq(1_700) + expect(credit_note.balance_amount_cents).to eq(200) + expect(terminated_invoice.total_amount_cents).to eq(0) # 1500 - 1500(CN) + expect(terminated_invoice.fees_amount_cents).to eq(1_500) + expect(terminated_invoice.credit_notes_amount_cents).to eq(1_500) + end + end + + context "with updated fee with attached credit note" do + let(:adjusted_fee_params) do + { + unit_precise_amount: "0.50", + units: 18 # 50 x 18 = 900 + } + end + + it "bills fees correctly" do + travel_to(Time.zone.parse("2024-02-12T06:00:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + first_invoice = customer.invoices.draft.first + + expect(customer.credit_notes.count).to eq(0) + expect(first_invoice.total_amount_cents).to eq(1_800) + expect(first_invoice.fees_amount_cents).to eq(1_800) + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-12T21:00:00")) do + expect { + terminate_subscription(subscription) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(1).to(2) + + first_invoice = first_invoice.reload + + expect(customer.credit_notes.count).to eq(1) + credit_note = customer.credit_notes.first + + expect(credit_note.credit_amount_cents).to eq(1_700) + expect(credit_note.balance_amount_cents).to eq(1_700) + expect(first_invoice.total_amount_cents).to eq(1_800) + expect(first_invoice.fees_amount_cents).to eq(1_800) + expect(first_invoice.credit_notes_amount_cents).to eq(0) + + # There is only one fee that is subscription kind + fee = first_invoice.fees.subscription.first + + AdjustedFees::CreateService.call(invoice: first_invoice, params: adjusted_fee_params.merge(fee_id: fee.id)) + credit_note = credit_note.reload + first_invoice = first_invoice.reload + + expect(credit_note.credit_amount_cents).to eq(850) + expect(credit_note.balance_amount_cents).to eq(850) + expect(first_invoice.total_amount_cents).to eq(900) + expect(first_invoice.fees_amount_cents).to eq(900) + expect(first_invoice.credit_notes_amount_cents).to eq(0) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + credit_note = customer.credit_notes.first + + # credit notes are not applied on draft invoice + expect(credit_note.credit_amount_cents).to eq(850) + expect(credit_note.balance_amount_cents).to eq(850) + expect(terminated_invoice.total_amount_cents).to eq(0) # There are no charges in a period + expect(terminated_invoice.fees_amount_cents).to eq(0) + expect(terminated_invoice.credit_notes_amount_cents).to eq(0) + end + end + end + + context "with updated fee equal to zero" do + let(:adjusted_fee_params) do + { + unit_precise_amount: 0, + units: 1 + } + end + + it "bills fees correctly" do + travel_to(Time.zone.parse("2024-02-12 06:00:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + first_invoice = customer.invoices.draft.first + + expect(customer.credit_notes.count).to eq(0) + expect(first_invoice.total_amount_cents).to eq(1_800) + expect(first_invoice.fees_amount_cents).to eq(1_800) + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-12T21:00:00")) do + expect { + terminate_subscription(subscription) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(1).to(2) + + first_invoice = first_invoice.reload + + expect(customer.credit_notes.count).to eq(1) + credit_note = customer.credit_notes.first + + expect(credit_note.credit_amount_cents).to eq(1_700) + expect(credit_note.balance_amount_cents).to eq(1_700) + expect(first_invoice.total_amount_cents).to eq(1_800) + expect(first_invoice.fees_amount_cents).to eq(1_800) + expect(first_invoice.credit_notes_amount_cents).to eq(0) + + # There is only one fee that is subscription kind + fee = first_invoice.fees.subscription.first + + AdjustedFees::CreateService.call(invoice: first_invoice, params: adjusted_fee_params.merge(fee_id: fee.id)) + credit_note = credit_note.reload + first_invoice = first_invoice.reload + + expect(credit_note.credit_amount_cents).to eq(0) + expect(credit_note.balance_amount_cents).to eq(0) + expect(first_invoice.total_amount_cents).to eq(0) + expect(first_invoice.fees_amount_cents).to eq(0) + expect(first_invoice.credit_notes_amount_cents).to eq(0) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + credit_note = customer.credit_notes.first + + # credit notes are not applied on draft invoice + expect(credit_note.credit_amount_cents).to eq(0) + expect(credit_note.balance_amount_cents).to eq(0) + expect(terminated_invoice.total_amount_cents).to eq(0) # There are no charges in a period + expect(terminated_invoice.fees_amount_cents).to eq(0) + expect(terminated_invoice.credit_notes_amount_cents).to eq(0) + end + end + end + end + + context "when pay in arrear subscription is terminated" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 2_900, pay_in_advance: false) } + let(:plan_new) { create(:plan, organization:, amount_cents: 29_000, pay_in_advance: false) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 0) } + let(:metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + it "bills fees correctly", transaction: false do + travel_to(Time.zone.parse("2024-01-01T00:00:00")) do + create( + :standard_charge, + plan:, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create( + :standard_charge, + plan: plan_new, + billable_metric: metric, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + } + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-01-02T00:00:00")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end + + travel_to(Time.zone.parse("2024-02-12T06:00:00")) do + expect { + terminate_subscription(subscription) + perform_all_enqueued_jobs + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { customer.invoices.count }.from(0).to(1) + + terminated_invoice = subscription.invoices.order(created_at: :desc).first + + expect(terminated_invoice.total_amount_cents).to eq((1_200 + (12.fdiv(29) * 500)).round) # 12 + 12/29 x 5 + end + end + end + + context "when invoice is paid in advance and grace period" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:plan) { create(:plan, pay_in_advance: true, organization:, amount_cents: 1000) } + let(:metric) { create(:billable_metric, organization:) } + + it "terminates the pay in advance subscription with credit note lesser than amount" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "3"}) + end + + subscription_invoice = Invoice.draft.first + subscription = subscription_invoice.subscriptions.first + expect(subscription_invoice.total_amount_cents).to eq(658) # 17 days - From 15th Dec. to 31st Dec. + + ### 17 Dec: Create event + refresh. + travel_to(Time.zone.parse("2022-12-17")) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code + ) + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code + ) + + expect { + refresh_invoice(subscription_invoice) + }.not_to change { subscription_invoice.reload.total_amount_cents } + end + + ### 20 Dec: Terminate subscription + refresh. + dec20 = Time.zone.parse("2022-12-20") + + travel_to(dec20) do + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription_invoice.reload.credit_notes.count }.from(0).to(1) + .and change { subscription.invoices.count }.from(1).to(2) + + # Draft credit note is created (31 - 20) * 548 / 17.0 * 1.2 = 425.5 rounded at 426 + credit_note = subscription_invoice.credit_notes.first + expect(credit_note).to be_draft + expect(credit_note.credit_amount_cents).to eq(426) + expect(credit_note.balance_amount_cents).to eq(426) + expect(credit_note.total_amount_cents).to eq(426) + + # Invoice for termination is created + termination_invoice = subscription.invoices.order(created_at: :desc).first + + # Total amount does not reflect the credit note as it's not finalized. + expect(termination_invoice.total_amount_cents).to eq(720) + expect(termination_invoice.credits.count).to eq(0) + expect(termination_invoice.credit_notes.count).to eq(0) + + # Refresh pay in advance invoice + expect { + refresh_invoice(subscription_invoice) + }.not_to change { subscription_invoice.reload.total_amount_cents } + expect(credit_note.reload.total_amount_cents).to eq(426) + + # Refresh termination invoice + expect { + refresh_invoice(termination_invoice) + }.not_to change { termination_invoice.reload.total_amount_cents } + + # Finalize pay in advance invoice + expect { + finalize_invoice(subscription_invoice) + }.to change { subscription_invoice.reload.status }.from("draft").to("finalized") + .and change { credit_note.reload.status }.from("draft").to("finalized") + + expect(subscription_invoice.total_amount_cents).to eq(658) + + # Finalize termination invoice + expect { + finalize_invoice(termination_invoice) + }.to change { termination_invoice.reload.status }.from("draft").to("finalized") + + # Total amount should reflect the credit note 720 - 426 + expect(termination_invoice.total_amount_cents).to eq(294) + end + end + + it "terminates the pay in advance subscription with credit note greater than amount" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + customer: + } + ) + + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "1"}) + end + + subscription_invoice = Invoice.draft.first + subscription = subscription_invoice.subscriptions.sole + expect(subscription_invoice.total_amount_cents).to eq(658) # 17 days - From 15th Dec. to 31st Dec. + + ### 17 Dec: Create event + refresh. + travel_to(Time.zone.parse("2022-12-17")) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: metric.code + ) + + expect { + refresh_invoice(subscription_invoice) + }.not_to change { subscription_invoice.reload.total_amount_cents } + end + + ### 20 Dec: Terminate subscription + refresh. + dec20 = Time.zone.parse("2022-12-20") + + travel_to(dec20) do + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription_invoice.reload.credit_notes.count }.from(0).to(1) + .and change { subscription.invoices.count }.from(1).to(2) + + # Credit note is created (31 - 20) * 548 / 17.0 * 1.2 = 425.5 rounded at 426 + credit_note = subscription_invoice.reload.credit_notes.first + expect(credit_note.credit_amount_cents).to eq(426) + expect(credit_note.balance_amount_cents).to eq(426) + expect(credit_note.total_amount_cents).to eq(426) + + # Invoice for termination is created + termination_invoice = subscription.invoices.order(created_at: :desc).first + + # Total amount does not reflect the credit note as it's not finalized. + expect(termination_invoice.total_amount_cents).to eq(120) + expect(termination_invoice.credits.count).to eq(0) + expect(termination_invoice.credit_notes.count).to eq(0) + + # Refresh pay in advance invoice + expect { + refresh_invoice(subscription_invoice) + }.not_to change { subscription_invoice.reload.total_amount_cents } + expect(credit_note.reload.credit_amount_cents).to eq(426) + + # Refresh termination invoice + expect { + refresh_invoice(termination_invoice) + }.not_to change { termination_invoice.reload.total_amount_cents } + + # Finalize pay in advance invoice + expect { + finalize_invoice(subscription_invoice) + }.to change { subscription_invoice.reload.status }.from("draft").to("finalized") + .and change { credit_note.reload.status }.from("draft").to("finalized") + + expect(subscription_invoice.total_amount_cents).to eq(658) + + # Finalize termination invoice + expect { + finalize_invoice(termination_invoice) + }.to change { termination_invoice.reload.status }.from("draft").to("finalized") + + # Total amount should reflect the credit note (120 - 425) + expect(termination_invoice.total_amount_cents).to eq(0) + end + end + + it "refreshes and finalizes invoices" do + ### 15 Dec: Create subscription + charge. + dec15 = Time.zone.parse("2022-12-15") + + travel_to(dec15) do + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "1"}) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + customer: + } + ) + end + + invoice = Invoice.draft.first + subscription = invoice.subscriptions.first + expect(invoice.total_amount_cents).to eq(658) # 17 days - From 15th Dec. to 31st Dec. + + ### 16 Dec: Create event + refresh. + travel_to(Time.zone.parse("2022-12-16")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id + } + ) + + # Paid in advance invoice amount does not change. + expect { + refresh_invoice(invoice) + }.not_to change { invoice.reload.total_amount_cents } + end + + ### 17 Dec: Create event + refresh. + travel_to(Time.zone.parse("2022-12-17")) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id + } + ) + + # Paid in advance invoice amount does not change. + expect { + refresh_invoice(invoice) + }.not_to change { invoice.reload.total_amount_cents } + end + + ### 1 Jan: Billing + refresh + finalize. + travel_to(Time.zone.parse("2023-01-01")) do + perform_billing + + expect(subscription.invoices.count).to eq(2) + new_invoice = subscription.invoices.order(created_at: :desc).first + expect(new_invoice.total_amount_cents).to eq(1440) # (1000 + 200) * 1.2 + + # Create event for Dec 18. + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + timestamp: Time.zone.parse("2022-12-18").to_i, + organization_id: organization.id, + external_subscription_id: subscription.external_id + } + ) + + # Paid in advance invoice amount does not change. + expect { + refresh_invoice(invoice) + }.not_to change { invoice.reload.total_amount_cents } + + # Usage invoice amount is updated. + expect { + refresh_invoice(new_invoice) + }.to change { new_invoice.reload.total_amount_cents }.from(1440).to(1560) # (1000 + 200 + 100) * 1.2 + + # Finalize invoices. + expect { + finalize_invoice(invoice) + }.to change { invoice.reload.status }.from("draft").to("finalized") + + expect { + finalize_invoice(new_invoice) + }.to change { new_invoice.reload.status }.from("draft").to("finalized") + + expect(invoice.total_amount_cents).to eq(658) + expect(new_invoice.total_amount_cents).to eq(1560) + end + end + + context "when upgrading from pay in arrear to pay in advance plan" do + let(:pay_in_arrear_plan) { create(:plan, organization:, amount_cents: 1000) } + let(:pay_in_advance_plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 1000) } + + it "creates two draft invoices" do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: pay_in_arrear_plan.code + } + ) + + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "1"}) + + # Upgrade to pay in advance plan + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: pay_in_advance_plan.code + } + ) + + expect(customer.invoices.draft.count).to eq(1) + + pay_in_arrear_subscription = customer.subscriptions.terminated.first + pay_in_arrear_invoice = pay_in_arrear_subscription.invoices.first + + # Paid in advance invoice amount does not change. + expect { + refresh_invoice(pay_in_arrear_invoice) + }.not_to change { pay_in_arrear_invoice.reload.total_amount_cents } + end + end + + context "when invoice grace period is removed", :premium do + let(:organization) { create(:organization, webhook_url: nil, invoice_grace_period: 3) } + let(:plan) { create(:plan, pay_in_advance: true, organization:, amount_cents: 1000) } + + it "finalizes draft invoices" do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "1"}) + + invoice = Invoice.draft.first + + params = { + external_id: customer.external_id, + billing_configuration: {invoice_grace_period: 0} + } + + expect { + create_or_update_customer(params) + }.to change { customer.reload.invoice_grace_period }.from(3).to(0) + .and change { invoice.reload.status }.from("draft").to("finalized") + end + end + end +end diff --git a/spec/scenarios/invoices/invoicing_with_prepaid_credits_spec.rb b/spec/scenarios/invoices/invoicing_with_prepaid_credits_spec.rb new file mode 100644 index 0000000..52f7d67 --- /dev/null +++ b/spec/scenarios/invoices/invoicing_with_prepaid_credits_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Invoicing with prepaid credits", :premium do + let(:organization) { create(:organization, :with_static_values, webhook_url: nil) } + let(:customer) { create(:customer, :with_static_values, organization:) } + let(:billable_metric_1) { create(:billable_metric, organization:, code: "count_1") } + let(:billable_metric_2) { create(:billable_metric, organization:, code: "count_2") } + let(:other_billable_metric) { create(:sum_billable_metric, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:tax) { create(:tax, rate: 24, organization:) } + let(:external_subscription_id) { SecureRandom.uuid } + + before do + charge_1 = create(:standard_charge, plan:, billable_metric: billable_metric_1, pay_in_advance: true, properties: {amount: "14.29"}) + create(:charge_applied_tax, charge: charge_1, tax:) + + charge_2 = create(:standard_charge, plan:, billable_metric: billable_metric_2, pay_in_advance: true, properties: {amount: "14.27"}) + create(:charge_applied_tax, charge: charge_2, tax:) + + create(:standard_charge, plan:, billable_metric: other_billable_metric, pay_in_advance: true, properties: {amount: "10"}) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + }) + end + + context "with limitations" do + it "invoices a customer with prepaid credits" do + wallet = setup_wallet + + expect(customer.invoices.count).to eq(0) + + test_invoice_with_non_applicable_billable_metric(wallet) + test_invoice_with_applicable_billable_metric(wallet) + end + + private + + def setup_wallet + create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR", + granted_credits: "39.00", + invoice_requires_successful_payment: false, + applies_to: {billable_metric_codes: [billable_metric_1.code, billable_metric_2.code]} + }, as: :model) + end + + def test_invoice_with_non_applicable_billable_metric(wallet) + expect do + create_event({ + code: other_billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: external_subscription_id, + properties: {item_id: "1"} + }) + end.to change { customer.invoices.count }.by(1) + + invoice = customer.invoices.last + expect(invoice.prepaid_credit_amount_cents).to eq(0) + end + + def test_invoice_with_applicable_billable_metric(wallet) + expect do + create_event({ + code: billable_metric_1.code, + external_customer_id: customer.external_id, + external_subscription_id: external_subscription_id, + properties: {} + }) + end.to change { customer.invoices.count }.by(1) + + invoice = customer.invoices.order(:created_at).last + + expect(invoice.total_amount_cents).to eq(0) + expect(invoice.sub_total_including_taxes_amount.to_d).to eq(17.72) + expect(invoice.prepaid_credit_amount.to_d).to eq(17.72) + + wallet.reload + + expect(wallet.credits_balance).to eq(21.28) + expect(wallet.balance.to_d).to eq(21.28) + + expect do + create_event({ + code: billable_metric_2.code, + external_customer_id: customer.external_id, + external_subscription_id: external_subscription_id, + properties: {} + }) + end.to change { customer.invoices.count }.by(1) + + invoice = customer.invoices.order(:created_at).last + + expect(invoice.total_amount.to_d).to eq(0) + expect(invoice.sub_total_including_taxes_amount.to_d).to eq(17.69) + expect(invoice.prepaid_credit_amount.to_d).to eq(17.69) + + wallet.reload + + expect(wallet.credits_balance).to eq(3.59) + expect(wallet.balance.to_d).to eq(3.59) + end + end +end diff --git a/spec/scenarios/invoices/negative_total_with_prepaid_credits_spec.rb b/spec/scenarios/invoices/negative_total_with_prepaid_credits_spec.rb new file mode 100644 index 0000000..b6420a5 --- /dev/null +++ b/spec/scenarios/invoices/negative_total_with_prepaid_credits_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "rails_helper" + +# This test verifies that prepaid credits are correctly capped to the invoice total +# when fees have fractional amounts that round differently at the fee vs invoice level. +# +# The bug occurred because the prepaid credit cap calculation used precise tax amounts +# while the invoice total used rounded amounts, potentially causing negative totals. +# +# SCENARIO 1: Mixed zero and non-zero amount fees +# ═══════════════════════════════════════════════════════════════════════════════════ +# +# FEES (with 40% tax) INVOICE +# ┌─────────────────────────────────┐ +# │ 6× small fees │ ┌─────────────────────────┐ +# │ amount_cents: 0 │ │ fees_amount: 2 │ +# │ precise_amount: 0.4 │ │ taxes_amount: 1 │ +# │ taxes_precise: 0.16 │──────────────│ total: 3 │ +# │ cap per fee: 0 + 0.16 = 0.16 │ │ │ +# └─────────────────────────────────┘ │ │ +# ┌─────────────────────────────────┐ │ │ +# │ 2× large fees │ │ │ +# │ amount_cents: 1 │──────────────│ │ +# │ precise_amount: 1.0 │ └─────────────────────────┘ +# │ taxes_precise: 0.4 │ +# │ cap per fee: 1 + 0.4 = 1.4 │ +# └─────────────────────────────────┘ +# +# Old uncapped calculation: 6×0.16 + 2×1.4 = 3.76 → rounds to 4 (exceeds total!) +# Fixed capped calculation: min(sum of caps, invoice total) = 3 +# +# SCENARIO 2: Identical fees with fractional precise amounts +# ═══════════════════════════════════════════════════════════════════════════════════ +# +# FEES (with 40% tax) INVOICE +# ┌─────────────────────────────────┐ +# │ 8× identical fees │ ┌─────────────────────────┐ +# │ amount_cents: 1 │ │ fees_amount: 8 │ +# │ precise_amount: 1.4 │──────────────│ taxes_amount: 3 │ +# │ taxes_precise: 0.56 │ │ total: 11 │ +# │ cap per fee: 1 + 0.56 = 1.56 │ │ │ +# └─────────────────────────────────┘ └─────────────────────────┘ +# +# Old uncapped calculation: 8×1.56 = 12.48 → rounds to 12 (exceeds total!) +# Fixed capped calculation: min(sum of caps, invoice total) = 11 +# +describe "Prepaid credits capping with fractional fee amounts", :premium do + let(:organization) { create(:organization, :with_static_values, webhook_url: nil) } + let(:customer) { create(:customer, :with_static_values, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:tax) { create(:tax, rate: 40, organization:) } + let(:external_subscription_id) { SecureRandom.uuid } + + context "with mixed zero and non-zero amount fees" do + before do + (1..6).each do |i| + metric = create(:billable_metric, organization:, code: "small_metric_#{i}") + charge = create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "0.004"}) + create(:charge_applied_tax, charge:, tax:) + end + + (1..2).each do |i| + metric = create(:billable_metric, organization:, code: "large_metric_#{i}") + charge = create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "0.01"}) + create(:charge_applied_tax, charge:, tax:) + end + end + + it "caps prepaid credits at invoice total of 3" do + travel_to Time.zone.local(2025, 1, 1, 0, 0, 0) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code, + billing_time: "anniversary" + }) + + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR", + granted_credits: "100", + invoice_requires_successful_payment: false + }, as: :model) + + (1..6).each do |i| + create_event({ + code: "small_metric_#{i}", + external_customer_id: customer.external_id, + external_subscription_id: external_subscription_id, + properties: {} + }) + end + + (1..2).each do |i| + create_event({ + code: "large_metric_#{i}", + external_customer_id: customer.external_id, + external_subscription_id: external_subscription_id, + properties: {} + }) + end + + travel_to Time.zone.local(2025, 2, 1, 0, 0, 0) + perform_billing + + invoice = customer.invoices.where(invoice_type: :subscription).sole + + small_fees = invoice.fees.charge.where(amount_cents: 0) + large_fees = invoice.fees.charge.where("amount_cents > 0") + + expect(small_fees.count).to eq(6) + expect(large_fees.count).to eq(2) + + small_fees.each do |fee| + expect(fee.amount_cents).to eq(0) + expect(fee.precise_amount_cents).to eq(0.4) + expect(fee.taxes_amount_cents).to eq(0) + expect(fee.taxes_precise_amount_cents).to eq(0.16) + end + + large_fees.each do |fee| + expect(fee.amount_cents).to eq(1) + expect(fee.precise_amount_cents).to eq(1.0) + expect(fee.taxes_amount_cents).to eq(0) + expect(fee.taxes_precise_amount_cents).to eq(0.4) + end + + expect(invoice.fees_amount_cents).to eq(2) + expect(invoice.taxes_amount_cents).to eq(1) + expect(invoice.sub_total_including_taxes_amount_cents).to eq(3) + + expect(invoice.prepaid_credit_amount_cents).to eq(3) + expect(invoice.total_amount_cents).to eq(0) + + expect(wallet.reload.balance_cents).to eq(9997) + end + end + + context "with identical fees having fractional precise amounts" do + before do + (1..8).each do |i| + metric = create(:billable_metric, organization:, code: "metric_#{i}") + charge = create(:standard_charge, plan:, billable_metric: metric, properties: {amount: "0.014"}) + create(:charge_applied_tax, charge:, tax:) + end + end + + it "caps prepaid credits at invoice total of 11" do + travel_to Time.zone.local(2025, 1, 1, 0, 0, 0) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code, + billing_time: "anniversary" + }) + + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR", + granted_credits: "100", + invoice_requires_successful_payment: false + }, as: :model) + + (1..8).each do |i| + create_event({ + code: "metric_#{i}", + external_customer_id: customer.external_id, + external_subscription_id: external_subscription_id, + properties: {} + }) + end + + travel_to Time.zone.local(2025, 2, 1, 0, 0, 0) + perform_billing + + invoice = customer.invoices.where(invoice_type: :subscription).sole + + expect(invoice.fees.charge.count).to eq(8) + + invoice.fees.charge.each do |fee| + expect(fee.amount_cents).to eq(1) + expect(fee.precise_amount_cents).to eq(1.4) + expect(fee.taxes_amount_cents).to eq(0) + expect(fee.taxes_precise_amount_cents).to eq(0.56) + end + + expect(invoice.fees_amount_cents).to eq(8) + expect(invoice.taxes_amount_cents).to eq(3) + expect(invoice.sub_total_including_taxes_amount_cents).to eq(11) + + expect(invoice.prepaid_credit_amount_cents).to eq(11) + expect(invoice.total_amount_cents).to eq(0) + + expect(wallet.reload.balance_cents).to eq(9989) + end + end +end diff --git a/spec/scenarios/invoices/one_off_spec.rb b/spec/scenarios/invoices/one_off_spec.rb new file mode 100644 index 0000000..2fac162 --- /dev/null +++ b/spec/scenarios/invoices/one_off_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "One-off invoices" do + let(:organization) { create(:organization, webhook_url: false) } + + describe "tax application" do + let(:customer) { create(:customer, organization:) } + let(:addon) { create(:add_on, organization:, amount_cents: 10_000) } + + context "with explicit tax_codes in the payload" do + let(:tax1) { create(:tax, organization:, code: "vat_20", name: "VAT 20%", rate: 20.0) } + let(:tax2) { create(:tax, organization:, code: "vat_10", name: "VAT 10%", rate: 10.0) } + let(:default_tax) { create(:tax, organization:, code: "default_tax", name: "Default", rate: 25.0) } + + before do + create(:billing_entity_applied_tax, tax: default_tax, billing_entity: customer.billing_entity) + end + + it "applies the explicit taxes instead of derived taxes" do + create_one_off_invoice(customer, [addon], taxes: [tax1.code, tax2.code]) + + invoice = customer.invoices.sole + expect(invoice.status).to eq "finalized" + + fee = invoice.fees.sole + expect(fee.applied_taxes.count).to eq 2 + expect(fee.applied_taxes.map(&:tax_code)).to match_array [tax1.code, tax2.code] + + # 10_000 * 20% + 10_000 * 10% = 3_000 + expect(fee.taxes_amount_cents).to eq 3_000 + expect(invoice.taxes_amount_cents).to eq 3_000 + end + end + + context "without explicit tax_codes (derived from billing entity taxes)" do + let(:default_tax) { create(:tax, organization:, code: "default_tax", name: "Default", rate: 15.0) } + + before do + create(:billing_entity_applied_tax, billing_entity: customer.billing_entity, tax: default_tax) + end + + it "applies the derived taxes from the billing entity" do + create_one_off_invoice(customer, [addon]) + + invoice = customer.invoices.sole + expect(invoice.status).to eq "finalized" + expect(invoice.taxes_amount_cents).to eq 1_500 + + fee = invoice.fees.sole + expect(fee.applied_taxes.sole.tax_code).to eq "default_tax" + expect(fee.taxes_amount_cents).to eq 1_500 + end + end + + context "with customer-specific taxes" do + let(:customer_tax) { create(:tax, organization:, code: "customer_vat", name: "Customer VAT", rate: 8.0) } + let(:billing_entity_tax) { create(:tax, organization:, code: "be_default", name: "BE Default", rate: 25.0) } + + before do + create(:billing_entity_applied_tax, billing_entity: customer.billing_entity, tax: billing_entity_tax) + create(:customer_applied_tax, customer:, tax: customer_tax) + end + + it "applies customer taxes over billing entity taxes when no explicit tax_codes are provided" do + create_one_off_invoice(customer, [addon]) + + invoice = customer.invoices.sole + fee = invoice.fees.sole + expect(fee.applied_taxes.sole.tax_code).to eq "customer_vat" + expect(fee.taxes_amount_cents).to eq 800 + expect(invoice.taxes_amount_cents).to eq 800 + end + + it "applies explicit tax_codes over customer taxes when provided" do + explicit_tax = create(:tax, organization:, code: "explicit_vat", name: "Explicit", rate: 5.0) + create_one_off_invoice(customer, [addon], taxes: [explicit_tax.code]) + + invoice = customer.invoices.sole + fee = invoice.fees.sole + expect(fee.applied_taxes.sole.tax_code).to eq "explicit_vat" + expect(fee.taxes_amount_cents).to eq 500 + end + end + + context "when customer belongs to not default billing entity" do + let(:secondary_billing_entity) { create(:billing_entity, organization:) } + let(:tax_1) { create(:tax, organization:, code: "default_tax", name: "Default", rate: 20.0) } + let(:tax_2) { create(:tax, organization:, code: "secondary_vat", name: "Secondary VAT", rate: 12.0) } + let(:customer) { create(:customer, organization:, billing_entity: secondary_billing_entity) } + let(:addon) { create(:add_on, organization:, amount_cents: 10_000) } + + before do + create(:billing_entity_applied_tax, billing_entity: organization.default_billing_entity, tax: tax_1) + create(:billing_entity_applied_tax, billing_entity: secondary_billing_entity, tax: tax_2) + end + + it "applies the customer billing entity's tax rate" do + create_one_off_invoice(customer, [addon]) + + invoice = customer.invoices.sole + expect(invoice.status).to eq "finalized" + + fee = invoice.fees.sole + expect(fee.applied_taxes.sole.tax_code).to eq "secondary_vat" + expect(fee.taxes_amount_cents).to eq 1_200 # 10_000 * 12% + expect(invoice.taxes_amount_cents).to eq 1_200 + end + end + end +end diff --git a/spec/scenarios/invoices/preview_spec.rb b/spec/scenarios/invoices/preview_spec.rb new file mode 100644 index 0000000..538811a --- /dev/null +++ b/spec/scenarios/invoices/preview_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Invoice Preview Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil, email_settings: [], premium_integrations: ["preview"]) } + + context "when charge has a spending minimum" do + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 23) } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + min_amount_cents: 233_700, + properties: {amount: "1"} + ) + end + let(:subscription) do + create( + :subscription, + customer:, + plan:, + started_at: DateTime.parse("2026-03-01"), + subscription_at: DateTime.parse("2026-03-01"), + ending_at: DateTime.parse("2026-03-31"), + billing_time: :calendar + ) + end + + before do + charge + tax + + create( + :event, + organization:, + customer:, + subscription:, + code: billable_metric.code, + timestamp: DateTime.parse("2026-03-02 10:00:00"), + properties: {billable_metric.field_name => 16.25} + ) + end + + it "computes fees_amount_cents based on the correct billing period duration" do + travel_to(DateTime.parse("2026-03-03 12:00:00")) do + # First call current_usage to populate the charge cache with a fee + # whose timestamp is March 3 (mid-period, not the billing boundary). + fetch_current_usage(customer:, subscription:) + + # Now call invoice preview. The cache returns the fee from current_usage + # which has a stale timestamp (March 3). The true-up calculation should + # still use the correct billing period duration (31 days for March). + # + # Expected: + # charge fee = 16.25 units * 1 EUR = 1625 cents + # min_amount = 233700 cents (full March, no proration) + # true_up = 233700 - 1625 = 232075 cents + # total fees = 233700 cents + # tax 23% = 53751 cents + # total = 287451 cents + post_with_token( + organization, + "/api/v1/invoices/preview", + { + customer: {external_id: customer.external_id}, + subscriptions: {external_ids: [subscription.external_id]} + } + ) + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + fees_amount_cents: 233_700, + taxes_amount_cents: 53_751, + total_amount_cents: 287_451 + ) + end + end + end + + context "when wallet has allowed_fee_types restriction" do + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 2_000) } + let(:wallet) { create(:wallet, customer:, organization:, balance_cents: 500, credits_balance: 5.0, allowed_fee_types: %w[fixed_charge]) } + let(:preview_params) do + { + customer: {external_id: customer.external_id}, + plan_code: plan.code, + billing_time: "calendar" + } + end + + context "when the preview invoice contains only subscription fees" do + before { wallet } + + it "does not calculate wallet credit to subscription fees" do + travel_to(DateTime.parse("2026-03-01 12:00:00")) do + post_with_token(organization, "/api/v1/invoices/preview", preview_params) + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + fees_amount_cents: 2_000, + prepaid_credit_amount_cents: 0, + total_amount_cents: 2_000 + ) + end + end + end + + context "when the preview invoice contains a mix of matching and non-matching fees" do + let(:fixed_charge) { create(:fixed_charge, plan:, units: 1, charge_model: "standard", properties: {amount: "3"}) } + + before do + fixed_charge + wallet + end + + it "calculates wallet credit only to matching fee types, capped at the fee amount" do + travel_to(DateTime.parse("2026-03-01 12:00:00")) do + # subscription fee = 2000 cents (full March, calendar billing) + # fixed_charge fee = 1 unit * 3 EUR = 300 cents + # wallet 500 cents is restricted to fixed_charge fees, + # so it can only consume against the 300 cents fixed_charge fee + # prepaid_credit_amount_cents = 300, NOT 500 + post_with_token(organization, "/api/v1/invoices/preview", preview_params) + + expect(response).to have_http_status(:success) + expect(json[:invoice]).to include( + fees_amount_cents: 2_300, + prepaid_credit_amount_cents: 300, + total_amount_cents: 2_000 + ) + expect(wallet.reload.balance_cents).to eq(500) + end + end + end + end +end diff --git a/spec/scenarios/invoices/progressive_billing_spec.rb b/spec/scenarios/invoices/progressive_billing_spec.rb new file mode 100644 index 0000000..af5c498 --- /dev/null +++ b/spec/scenarios/invoices/progressive_billing_spec.rb @@ -0,0 +1,398 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Progressive billing invoices", :premium, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, email_settings: [], premium_integrations: ["progressive_billing"]) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:plan) { create(:plan, organization: organization, interval: "monthly", amount_cents: 31_00, pay_in_advance: false) } + let(:upgrade_plan) { create(:plan, organization: organization, interval: "monthly", amount_cents: 62_000, pay_in_advance: false) } + let(:downgrade_plan) { create(:plan, organization: organization, interval: "monthly", amount_cents: 31, pay_in_advance: false) } + let(:customer) { create(:customer, organization:, billing_entity:, invoice_grace_period:) } + let(:billable_metric) { create(:billable_metric, organization: organization, field_name: "total", aggregation_type: "sum_agg") } + let(:charge) { create(:charge, plan: plan, billable_metric: billable_metric, charge_model: "standard", properties: {"amount" => "0.0002"}) } + let(:usage_threshold) { create(:usage_threshold, plan: plan, amount_cents: 20000) } + let(:usage_threshold2) { create(:usage_threshold, plan: plan, amount_cents: 50000) } + let(:invoice_grace_period) { nil } + + before do + usage_threshold + charge + end + + it "generates an invoice in the middle of the month and a final invoice at the end of the month" do + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 5.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + expect(Invoice.last.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 15.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + progressive_billing_invoice = subscription.invoices.first + expect(progressive_billing_invoice.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 1.month do + perform_billing + expect(Invoice.count).to eq(2) + recurring_invoice = subscription.invoices.order(:created_at).last + expect(recurring_invoice.total_amount_cents).to eq(31_00 + 20_000) + expect(recurring_invoice.fees_amount_cents).to eq(31_00 + 40_000) + expect(recurring_invoice.progressive_billing_credit_amount_cents).to eq(20_000) + end + end + + context "with grace period enabled" do + let(:invoice_grace_period) { 2 } + + it "generates an invoice in the middle of the month and a draft invoice at the end of the month" do + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 5.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + expect(Invoice.last.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 15.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + progressive_billing_invoice = subscription.invoices.first + expect(progressive_billing_invoice.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 1.month do + perform_billing + expect(Invoice.count).to eq(2) + recurring_invoice = subscription.invoices.order(:created_at).last + expect(recurring_invoice).to be_draft + expect(recurring_invoice.total_amount_cents).to eq(31_00 + 20_000) + expect(recurring_invoice.fees_amount_cents).to eq(31_00 + 40_000) + expect(recurring_invoice.progressive_billing_credit_amount_cents).to eq(20_000) + + refresh_invoice(recurring_invoice) + expect(recurring_invoice).to be_draft + expect(recurring_invoice.total_amount_cents).to eq(31_00 + 20_000) + expect(recurring_invoice.fees_amount_cents).to eq(31_00 + 40_000) + expect(recurring_invoice.progressive_billing_credit_amount_cents).to eq(20_000) + end + end + end + + it "generates an invoice in the middle of the month and terminates the subscription before the end of the month" do + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 15.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + expect(Invoice.last.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 17.days do + terminate_subscription(subscription) + expect(Invoice.count).to eq(2) + termination_invoice = subscription.invoices.order(:created_at).last + expect(termination_invoice.total_amount_cents).to eq(1800) + expect(termination_invoice.fees_amount_cents).to eq(21800) + expect(termination_invoice.progressive_billing_credit_amount_cents).to eq(20000) + end + end + + it "generates an invoice in the middle of the month and upgrades the subscription before the end of the month" do + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 15.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + expect(Invoice.last.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 17.days do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: upgrade_plan.code + } + ) + expect(Invoice.count).to eq(2) + termination_invoice = subscription.invoices.order(:created_at).last + expect(termination_invoice.total_amount_cents).to eq(1700) + expect(termination_invoice.fees_amount_cents).to eq(21700) + expect(termination_invoice.progressive_billing_credit_amount_cents).to eq(20000) + end + end + + it "generates an invoice during the grace period and finalizes it at the end of the next month" do + billing_entity.update!(invoice_grace_period: 30) + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 1.month + 2.hours do + perform_billing + expect(Invoice.count).to eq(1) + subscription_invoice_1 = subscription.invoices.first + expect(subscription_invoice_1.total_amount_cents).to eq(31_00) + expect(subscription_invoice_1.progressive_billing_credit_amount_cents).to eq(0) + expect(subscription_invoice_1.status).to eq("draft") + end + + travel_to time_0 + 1.month + 15.days do + ingest_event(subscription, billable_metric, 3000000) + expect(Invoice.count).to eq(2) + progressive_invoice = subscription.invoices.order(:created_at).last + expect(progressive_invoice.total_amount_cents).to eq(60000) + end + + travel_to time_0 + 2.months do + perform_finalize_refresh + perform_billing + expect(Invoice.count).to eq(3) + expect(CreditNote.count).to eq(0) + subscription_invoice_1 = subscription.invoices.order(:created_at).subscription.first + expect(subscription_invoice_1.total_amount_cents).to eq(31_00) + expect(subscription_invoice_1.progressive_billing_credit_amount_cents).to eq(0) + expect(subscription_invoice_1.status).to eq("finalized") + expect(subscription_invoice_1.fees_amount_cents).to eq(31_00) + + subscription_invoice_2 = subscription.invoices.order(:created_at).subscription.last + expect(subscription_invoice_2.total_amount_cents).to eq(3100) + expect(subscription_invoice_2.progressive_billing_credit_amount_cents).to eq(60000) + expect(subscription_invoice_2.status).to eq("draft") + expect(subscription_invoice_2.fees_amount_cents).to eq(631_00) + end + end + + it "generates invoices for multiple usage thresholds within the same billing period" do + usage_threshold2 + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 15.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 20.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + end + + travel_to time_0 + 25.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(2) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(40000) + end + + travel_to time_0 + 1.month do + perform_billing + expect(Invoice.count).to eq(3) + subscription_invoice = subscription.invoices.subscription.last + expect(subscription_invoice.total_amount_cents).to eq(31_00) + expect(subscription_invoice.progressive_billing_credit_amount_cents).to eq(60000) + end + end + + it "generates only the final invoice at the end of the month" do + time_0 = Time.current.beginning_of_month + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 1.month do + perform_billing + expect(Invoice.count).to eq(1) + expect(subscription.invoices.subscription.first.total_amount_cents).to eq(31_00) + end + end + + it "generates progressive billing invoices only once when not recurring" do + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + # First billing period + travel_to time_0 + 15.days do + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(1) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(20000) + end + + travel_to time_0 + 1.month do + perform_billing + expect(Invoice.count).to eq(2) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(31_00) + expect(subscription.invoices.order(:created_at).last.fees_amount_cents).to eq(31_00 + 20000) + end + + # Second billing period + travel_to time_0 + 1.month + 15.days do + ingest_event(subscription, billable_metric, 2000000) + expect(Invoice.count).to eq(2) + end + + travel_to time_0 + 2.months do + perform_billing + expect(Invoice.count).to eq(3) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(431_00) + end + + # Third billing period + travel_to time_0 + 2.months + 15.days do + ingest_event(subscription, billable_metric, 3000000) + expect(Invoice.count).to eq(3) + end + + travel_to time_0 + 3.months do + perform_billing + expect(Invoice.count).to eq(4) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(631_00) + end + end + + it "generates correct invoices when there is a combination of single thresholds and recurring" do + usage_threshold.update(amount_cents: 200) + create(:usage_threshold, plan: plan, amount_cents: 500) + create(:usage_threshold, plan: plan, amount_cents: 700) + create(:usage_threshold, plan: plan, amount_cents: 1000, recurring: true) + date_0 = DateTime.new(2022, 12, 1) + travel_to date_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + # First billing period + travel_to date_0 + 5.days do + ingest_event(subscription, billable_metric, 11000) + expect(Invoice.count).to eq(1) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(220) + end + + travel_to date_0 + 10.days do + ingest_event(subscription, billable_metric, 11000) + expect(Invoice.count).to eq(1) + end + + travel_to date_0 + 15.days do + ingest_event(subscription, billable_metric, 11000) + expect(Invoice.count).to eq(2) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(440) + end + + travel_to date_0 + 20.days do + ingest_event(subscription, billable_metric, 11000) + expect(Invoice.count).to eq(3) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(220) + end + + travel_to date_0 + 25.days do + ingest_event(subscription, billable_metric, 110000) + expect(Invoice.count).to eq(4) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(2200) + end + + travel_to date_0 + 27.days do + ingest_event(subscription, billable_metric, 110000) + expect(Invoice.count).to eq(5) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(2200) + end + + # second billing period + travel_to date_0 + 1.month do + perform_billing + expect(Invoice.count).to eq(6) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(3100) + expect(subscription.invoices.order(:created_at).last.progressive_billing_credit_amount_cents).to eq(5280) + end + + travel_to date_0 + 1.month + 5.days do + ingest_event(subscription, billable_metric, 110000) + expect(Invoice.count).to eq(7) + expect(subscription.invoices.order(:created_at).last.total_amount_cents).to eq(2200) + end + end +end diff --git a/spec/scenarios/invoices/recurring_fees_spec.rb b/spec/scenarios/invoices/recurring_fees_spec.rb new file mode 100644 index 0000000..de04847 --- /dev/null +++ b/spec/scenarios/invoices/recurring_fees_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Recurring fee invoice inclusion after upgrade" do + let(:organization) { create(:organization, webhook_url: "http://lago.test/wh") } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:sum_billable_metric, :recurring, organization:) } + let(:original_plan) { create(:plan, organization:) } + let(:upgraded_plan) { create(:plan, organization:) } + let(:external_subscription_id) { SecureRandom.uuid } + + let(:charge) do + create( + :charge, + plan: original_plan, + billable_metric:, + prorated: true, + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice", + properties: {amount: "1"} + ) + end + + before do + WebMock + .stub_request(:post, "http://lago.test/wh") + .to_return(status: 200, body: "", headers: {}) + + charge + end + + it "includes the recurring fee in the end-of-period invoice after subscription upgrade" do + travel_to Time.zone.parse("2024-12-30T03:55:00") do + # Step 1: Create a subscription with a start date in the past + create_subscription( + {external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: original_plan.code, + subscription_at: 2.weeks.ago} + ) + subscription = customer.subscriptions.first + + WebMock.reset_executed_requests! + + # Step 2: Send an event in the past and verify fee creation + create_event( + {transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {"item_id" => 1}, + timestamp: 1.week.ago.to_i} + ) + + fee_from_date = subscription.subscription_at.beginning_of_day.to_time.iso8601 + fee_to_date = subscription.subscription_at.end_of_month.to_time.iso8601 + + expect( + a_request(:post, "http://lago.test/wh") + .with( + body: hash_including( + webhook_type: "fee.created", fee: hash_including( + { + "units" => "1.0", + "from_date" => fee_from_date, + "to_date" => fee_to_date + } + ) + ) + ) + ).to have_been_made.once + + fee = Fee.where(subscription:, charge:, created_at: Time.current.to_date..).sole + expect(fee).to be_present + + # Step 3: Duplicate the original plan with a higher price + upgraded_plan.update!(amount_cents: original_plan.amount_cents + 10_00) + + # Step 4: Upgrade the subscription to the new plan + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: upgraded_plan.code + } + ) + + # Step 5: Mark the original fee as succeeded + update_fee(fee.id, {payment_status: "succeeded"}) + expect(fee.reload.payment_status).to eq("succeeded") + + # Step 6: Verify the fee is included in the end-of-period invoice + terminate_subscription(subscription) + + fee_invoice = customer.invoices.find_by(invoice_type: "advance_charges") + + expect(fee_invoice).to be_present + expect(fee_invoice.fees).to include(fee) + end + end +end diff --git a/spec/scenarios/invoices/void_invoice_spec.rb b/spec/scenarios/invoices/void_invoice_spec.rb new file mode 100644 index 0000000..c8d8eba --- /dev/null +++ b/spec/scenarios/invoices/void_invoice_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Void Invoice Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000, pay_in_advance: true) } + + before do + tax + stub_pdf_generation + end + + context "when voiding a basic invoice" do + it "marks the invoice as voided" do + travel_to(DateTime.new(2023, 1, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + expect(invoice).to be_present + expect(invoice).to be_finalized + + void_invoice(invoice, {generate_credit_note: true, credit_amount: 1200, refund_amount: 0}) + + invoice.reload + expect(invoice).to be_voided + expect(invoice.voided_at).to be_present + expect(invoice.credit_notes.count).to eq(1) + + credit_note = invoice.credit_notes.first + expect(credit_note).to be_present + expect(credit_note.credit_status).to eq("available") + expect(credit_note.credit_amount_cents).to eq(1200) + expect(credit_note.refund_amount_cents).to eq(0) + expect(credit_note.total_amount_cents).to eq(1200) + expect(credit_note.status).to eq("finalized") + end + end + + context "when voiding a fully paid invoice" do + it "voids the invoice and creates a credit note with refund" do + # Create a subscription + travel_to(DateTime.new(2023, 1, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_paid_#{customer.external_id}", + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + expect(invoice).to be_present + expect(invoice).to be_finalized + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: invoice.total_amount_cents, reference: "payment_ref_1"} + ) + + void_invoice(invoice, {generate_credit_note: true, credit_amount: 0, refund_amount: invoice.total_amount_cents}) + + invoice.reload + expect(invoice.payment_status).to eq("succeeded") + expect(invoice).to be_voided + expect(invoice.voided_at).to be_present + expect(invoice.credit_notes.count).to eq(1) + + credit_note = invoice.credit_notes.first + expect(credit_note).to be_present + expect(credit_note.credit_amount_cents).to eq(0) + expect(credit_note.refund_amount_cents).to eq(invoice.total_amount_cents) + expect(credit_note.total_amount_cents).to eq(invoice.total_amount_cents) + expect(credit_note.status).to eq("finalized") + end + end + + context "when voiding an invoice with partial credit and refund" do + it "creates a partial credit note and a voided credit note for the remaining amount" do + # Create a subscription + travel_to(DateTime.new(2023, 1, 1)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "sub_partial_#{customer.external_id}", + plan_code: plan.code + } + ) + end + + subscription = customer.subscriptions.first + invoice = subscription.invoices.first + expect(invoice).to be_present + expect(invoice).to be_finalized + + Payments::ManualCreateService.call( + organization:, + params: {invoice_id: invoice.id, amount_cents: 300, reference: "payment_ref_partial"} + ) + + invoice.reload + expect(invoice.payment_status).to eq("pending") + + total_amount = invoice.total_amount_cents + partial_amount = total_amount / 2 + credit_amount = partial_amount - 300 + refund_amount = 300 + + void_invoice(invoice, {generate_credit_note: true, credit_amount: credit_amount, refund_amount: refund_amount}) + + invoice.reload + expect(invoice).to be_voided + expect(invoice.voided_at).to be_present + expect(invoice.credit_notes.count).to eq(2) + + first_credit_note = invoice.credit_notes.order(created_at: :asc).first + expect(first_credit_note).to be_present + expect(first_credit_note.credit_amount_cents).to eq(credit_amount) + expect(first_credit_note.refund_amount_cents).to eq(refund_amount) + expect(first_credit_note.total_amount_cents).to eq(partial_amount) + expect(first_credit_note.status).to eq("finalized") + expect(first_credit_note.credit_status).to eq("available") + expect(first_credit_note).not_to be_voided + + second_credit_note = invoice.credit_notes.order(created_at: :asc).last + expect(second_credit_note).to be_present + expect(second_credit_note.total_amount_cents).to eq(total_amount - partial_amount) + expect(second_credit_note.refund_amount_cents).to eq(0) + expect(second_credit_note.status).to eq("finalized") + expect(second_credit_note.credit_status).to eq("voided") + expect(second_credit_note).to be_voided + end + end +end diff --git a/spec/scenarios/lifetime_usages/without_progressive_billing_spec.rb b/spec/scenarios/lifetime_usages/without_progressive_billing_spec.rb new file mode 100644 index 0000000..2005ca0 --- /dev/null +++ b/spec/scenarios/lifetime_usages/without_progressive_billing_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Lifetime usage without progressive billing", :premium, :time_travel do + let(:organization) { create(:organization, webhook_url: nil, email_settings: [], premium_integrations: ["lifetime_usage", "progressive_billing"]) } + let(:plan) { create(:plan, organization: organization, interval: "monthly", amount_cents: 31_00, pay_in_advance: false) } + + let(:customer) { create(:customer, organization: organization) } + let(:billable_metric) { create(:billable_metric, organization: organization, field_name: "total", aggregation_type: "sum_agg") } + let(:charge) { create(:charge, plan: plan, billable_metric: billable_metric, charge_model: "standard", properties: {"amount" => "0.01"}) } + let(:threshold_amount_cents) { 1000 } + + before do + charge + end + + it "calculates lifetime usage without generating invoices" do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + subscription = customer.subscriptions.first + + lifetime_usage = subscription.lifetime_usage + expect(lifetime_usage).not_to be_nil + + pass_time 4.days + + ingest_event(subscription, billable_metric, 1000000) + + expect(lifetime_usage.reload.current_usage_amount_cents).to eq(1_000_000) + expect(Invoice.count).to eq(0) + + pass_time 1.day + + ingest_event(subscription, billable_metric, 1000000) + expect(lifetime_usage.reload.current_usage_amount_cents).to eq(2_000_000) + expect(Invoice.count).to eq(0) + + pass_time 29.days + + expect(Invoice.count).to eq(1) + + invoice = Invoice.sole + + expect(invoice.total_amount_cents).to eq(31_00 + 2_000_000) + expect(invoice.invoice_type).to eq("subscription") + end + + it "calculates lifetime usage and does not issue progressive billing invoices once added to the plan" do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + subscription = customer.subscriptions.first + + lifetime_usage = subscription.lifetime_usage + expect(lifetime_usage).not_to be_nil + + pass_time 4.days + + ingest_event(subscription, billable_metric, 1000000) + + expect(lifetime_usage.reload.current_usage_amount_cents).to eq(1_000_000) + expect(Invoice.count).to eq(0) + + pass_time 1.day + + ingest_event(subscription, billable_metric, 1000000) + expect(lifetime_usage.reload.current_usage_amount_cents).to eq(2_000_000) + expect(Invoice.count).to eq(0) + + pass_time 29.days + + expect(Invoice.count).to eq(1) + + invoice = Invoice.sole + + expect(invoice.total_amount_cents).to eq(31_00 + 2_000_000) + expect(invoice.invoice_type).to eq("subscription") + + pass_time 1.day + update_plan(plan, {usage_thresholds: [ + { + amount_cents: threshold_amount_cents + } + ]}) + + pass_time 2.days + + expect(Invoice.count).to eq(1) + + ingest_event(subscription, billable_metric, 1000000) + expect(lifetime_usage.reload.current_usage_amount_cents).to eq(1_000_000) + expect(lifetime_usage.reload.invoiced_usage_amount_cents).to eq(2_000_000) + expect(Invoice.count).to eq(1) + end +end diff --git a/spec/scenarios/pay_in_advance_charges_spec.rb b/spec/scenarios/pay_in_advance_charges_spec.rb new file mode 100644 index 0000000..870af94 --- /dev/null +++ b/spec/scenarios/pay_in_advance_charges_spec.rb @@ -0,0 +1,1505 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Pay in advance charges Scenarios", transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:aggregation_type) { "count_agg" } + let(:field_name) { nil } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type:, field_name:) } + + describe "with count_agg / standard" do + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :standard_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: {amount: "10"} + ) + + subscription = customer.subscriptions.first + + ### 15 February: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # NOTE: This does not seem to matter + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 4.days) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(1000) + expect(fee.amount_details).to eq({}) + end + + ### 17 february: Send an other event. + feb17 = DateTime.new(2023, 2, 17) + + travel_to(feb17) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(1000) + end + end + end + + describe "with unique_count_agg / standard" do + let(:aggregation_type) { "unique_count_agg" } + let(:field_name) { "unique_id" } + + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :standard_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: {amount: "12"} + ) + + subscription = customer.subscriptions.order(created_at: :desc).first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # NOTE: This does not seem to matter + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 10.days, properties: {unique_id: "id_1"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {unique_id: "id_1"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(1200) + expect(fee.amount_details).to eq({}) + end + + ### 16 february: Send an event. + feb16 = DateTime.new(2023, 2, 16) + + travel_to(feb16) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {unique_id: "id_1", operation_type: "remove"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(0) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(0) + end + + ### 17 february: Send an other event. + feb17 = DateTime.new(2023, 2, 17) + + travel_to(feb17) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {unique_id: "id_1"} + } + ) + end.to change { subscription.reload.fees.count }.from(2).to(3) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(0) + end + + ### 18 february: Send an other event. + feb18 = DateTime.new(2023, 2, 18) + + travel_to(feb18) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {unique_id: "id_2"} + } + ) + end.to change { subscription.reload.fees.count }.from(3).to(4) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(1200) + end + + ### 19 february: Send an event with the same unique id. It creates a 0 amount fee. + feb19 = DateTime.new(2023, 2, 19) + travel_to(feb19) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {unique_id: "id_2"} + } + ) + end.to change { subscription.reload.fees.count }.from(4).to(5) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(0) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(0) + end + + ### 20 february: Send an other event. + feb20 = DateTime.new(2023, 2, 20) + + travel_to(feb20) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {unique_id: "id_3"} + } + ) + end.to change { subscription.reload.fees.count }.from(5).to(6) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(1200) + end + + ### 21 february: Send an event. + feb21 = DateTime.new(2023, 2, 21) + + travel_to(feb21) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {unique_id: "id_3", operation_type: "remove"} + } + ) + end.to change { subscription.reload.fees.count }.from(6).to(7) + + fee = subscription.fees.order(created_at: :desc).first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(0) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(0) + end + end + end + + describe "with sum_agg / standard" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + it "creates a pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :standard_charge, + :pay_in_advance, + invoiceable: true, + plan:, + billable_metric:, + properties: {amount: "1"} + ) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # NOTE: This does not seem to matter + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 4.days, properties: {amount: "5000"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "10"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).not_to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(10) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(1000) + expect(fee.amount_details).to eq({}) + end + + travel_to(DateTime.new(2023, 2, 17)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "-4"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(0) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(0) + end + + travel_to(DateTime.new(2023, 2, 18)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "8"} + } + ) + end.to change { subscription.reload.fees.count }.from(2).to(3) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(4) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(400) + end + end + + context "when there is matching filter" do + let(:transaction_id) { "#{SecureRandom.uuid}test" } + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe]) + end + + it "creates a pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :standard_charge, + :pay_in_advance, + invoiceable: true, + plan:, + billable_metric:, + properties: {amount: "0"} + ) + charge_filter = create(:charge_filter, charge:, properties: {amount: "20"}) + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["europe"]) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # NOTE: This does not seem to matter + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 4.days, properties: {amount: "50", region: "europe"}) + + travel_to(feb15) do + create_event( + { + code: billable_metric.code, + transaction_id:, + external_subscription_id: subscription.external_id, + properties: {amount: "10", region: "europe"} + } + ) + + expect(subscription.reload.fees.count).to eq(1) + event = Event.find_by(transaction_id:) + expect(CachedAggregation.find_by(event_transaction_id: event.transaction_id).current_aggregation).to eq(10) + + fee = subscription.fees.first + + expect(fee.invoice_id).not_to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(10) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(20_000) + expect(fee.charge_filter_id).to eq(charge_filter.id) + end + end + end + + context "when there is no matching filter" do + let(:transaction_id) { "#{SecureRandom.uuid}test" } + let(:cloud_metric_filter) do + create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[AWS]) + end + let(:region_metric_filter) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe]) + end + + it "creates a pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :standard_charge, + :pay_in_advance, + invoiceable: true, + plan:, + billable_metric:, + properties: {amount: "10"} + ) + charge_filter = create(:charge_filter, charge:, properties: {amount: "20"}) + create(:charge_filter_value, charge_filter:, billable_metric_filter: region_metric_filter, values: ["europe"]) + create(:charge_filter_value, charge_filter:, billable_metric_filter: cloud_metric_filter, values: ["AWS"]) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # NOTE: This does not seem to matter + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15, properties: {amount: "50", region: "africa", cloud: "AWS"}) + + travel_to(feb15) do + create_event( + { + code: billable_metric.code, + transaction_id:, + external_subscription_id: subscription.external_id, + properties: {amount: "10", region: "africa", cloud: "AWS"} + } + ) + + expect(Event.find_by(transaction_id:).metadata["current_aggregation"]).to be_nil + expect(subscription.reload.fees.count).to eq(1) + expect(subscription.invoices.count).to eq(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).not_to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(10) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(10_000) + expect(fee.charge_filter_id).to be_nil + end + end + end + end + + describe "with sum_agg / package" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :package_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: {amount: "100", free_units: 3, package_size: 2} + ) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # Test create events until the 18th so we make this event is after that + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 4.days, properties: {amount: "5"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "3"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(3) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(0) # free units + expect(fee.amount_details).to eq({}) + end + + travel_to(DateTime.new(2023, 2, 17)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(10_000) + end + + travel_to(DateTime.new(2023, 2, 18)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "2"} + } + ) + end.to change { subscription.reload.fees.count }.from(2).to(3) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(2) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(10_000) + end + end + end + + describe "with sum_agg / graduated" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :graduated_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "0.02", + flat_amount: "0.01" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # Test create events until the 18th so we make this event is after that + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 4.days, properties: {amount: "5"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "3"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(3) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(2 * 3 + 1) # 3 events * 0.02 + 0.01 flat fee + expect(fee.amount_details).to eq({}) + end + + travel_to(DateTime.new(2023, 2, 17)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(2 * 1) + end + + travel_to(DateTime.new(2023, 2, 18)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "2"} + } + ) + end.to change { subscription.reload.fees.count }.from(2).to(3) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(2) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(2 * 1 + 1 * 1 + 1) + end + end + end + + # TODO: Add free_units calculation fix + describe "with sum_agg / percentage" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + describe "with free_units_per_events" do + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :percentage_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: { + rate: "5", + fixed_amount: "1", + free_units_per_events: 2 + } + ) + + subscription = customer.subscriptions.first + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # NOTE: This does not seem to matter + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: DateTime.new(2023, 2, 17), properties: {amount: "5"}) + + travel_to(DateTime.new(2023, 2, 14)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "8"} + } + ) + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 8, + events_count: 1, + amount_cents: 0, + amount_details: { + "rate" => "5.0", "units" => "8.0", "free_units" => "8.0", "paid_units" => "0.0", + "free_events" => "1.0", "paid_events" => "0.0", "fixed_fee_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0", "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + } + ) + end + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 5, + events_count: 1, + amount_cents: 0, + amount_details: { + "rate" => "5.0", "units" => "5.0", "free_units" => "5.0", "paid_units" => "0.0", + "free_events" => "1.0", "paid_events" => "0.0", "fixed_fee_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0", "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + } + ) + end + + travel_to(DateTime.new(2023, 2, 16)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "3"} + } + ) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 3, + events_count: 1, + amount_cents: 100 + 15, + amount_details: { + "rate" => "5.0", "units" => "3.0", "free_units" => "0.0", "paid_units" => "3.0", + "free_events" => "0.0", "paid_events" => "1.0", "fixed_fee_unit_amount" => "1.0", + "per_unit_total_amount" => "0.15", "fixed_fee_total_amount" => "1.0", + "min_max_adjustment_total_amount" => "0.0" + } + ) + end + end + end + + describe "with free_units_per_total_aggregation" do + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :percentage_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: { + rate: "5", + fixed_amount: "1", + free_units_per_total_aggregation: "120.0" + } + ) + + subscription = customer.subscriptions.first + + ### 14 february: Send an event. + travel_to(DateTime.new(2023, 2, 14)) do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "100"} + } + ) + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 100, + events_count: 1, + amount_cents: 0, + amount_details: { + "rate" => "5.0", "units" => "100.0", "free_units" => "100.0", "paid_units" => "0.0", + "free_events" => "1.0", "paid_events" => "0.0", "fixed_fee_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0", "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + } + ) + end + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + # NOTE: This does not seem to matter + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 4.days, properties: {amount: "50"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "10"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 10, + events_count: 1, + amount_cents: 0, + amount_details: { + "rate" => "5.0", "units" => "10.0", "free_units" => "10.0", "paid_units" => "0.0", + "free_events" => "1.0", "paid_events" => "0.0", "fixed_fee_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0", "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + } + ) + end + + ### 16 february: Send an event. + feb16 = DateTime.new(2023, 2, 16) + + travel_to(feb16) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "20"} + } + ) + end.to change { subscription.reload.fees.count }.from(2).to(3) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 20, + events_count: 1, + amount_cents: 150, + amount_details: { + "rate" => "5.0", "units" => "20.0", "free_units" => "10.0", "paid_units" => "10.0", + "free_events" => "0.0", "paid_events" => "1.0", "fixed_fee_unit_amount" => "1.0", + "per_unit_total_amount" => "0.5", "fixed_fee_total_amount" => "1.0", + "min_max_adjustment_total_amount" => "0.0" + } + ) + end + + ### 17 february: Send an event. + feb17 = DateTime.new(2023, 2, 17) + + travel_to(feb17) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "20"} + } + ) + end.to change { subscription.reload.fees.count }.from(3).to(4) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 20, + events_count: 1, + amount_cents: 200, + amount_details: { + "rate" => "5.0", "units" => "20.0", "free_units" => "0.0", "paid_units" => "20.0", + "free_events" => "0.0", "paid_events" => "1.0", "fixed_fee_unit_amount" => "1.0", + "per_unit_total_amount" => "1.0", "fixed_fee_total_amount" => "1.0", + "min_max_adjustment_total_amount" => "0.0" + } + ) + end + end + end + + describe "with min / max per transaction", :premium do + it "creates a pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :percentage_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "0.5", + per_transaction_max_amount: "2", + per_transaction_min_amount: "1.75" + } + ) + + subscription = customer.subscriptions.first + + ### 14 february: Send an event. + travel_to(DateTime.new(2023, 2, 14)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "100"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 100, + events_count: 1, + amount_cents: 175 # Apply minimum amount + ) + end + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 5.days, properties: {amount: "333"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1000"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 1_000, + events_count: 1, + amount_cents: 200 # Apply maximum amount + ) + end + + ### 16 february: Send an event. + feb16 = DateTime.new(2023, 2, 16) + + travel_to(feb16) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "10000"} + } + ) + end.to change { subscription.reload.fees.count }.from(2).to(3) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 10_000, + events_count: 1, + amount_cents: 200 # Apply maximum amount + ) + + fetch_current_usage(customer:) + + # NOTE: Notice that our event in the future is taken into account in current usage + expect(json[:customer_usage][:charges_usage].sole[:units]).to eq("11433.0") + expect(json[:customer_usage][:charges_usage].sole[:total_aggregated_units]).to eq("11433.0") + expect(json[:customer_usage][:total_amount_cents]).to eq(775) + end + end + end + + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :percentage_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: { + rate: "5", + fixed_amount: "1", + free_units_per_total_aggregation: "3.0" + } + ) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(5) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(100 + 2 * 5) # 2 units not free + end + + travel_to(DateTime.new(2023, 2, 17)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(215 - 110) + end + end + end + + describe "with sum_agg / dynamic" do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + it "creates an pay_in_advance fee" do + ### 26 September: Create subscription. + sept26 = Time.zone.parse("2024-09-26") + + travel_to(sept26) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :dynamic_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric: + ) + + subscription = customer.subscriptions.first + + ### 28th September: Send an event. + sept28 = Time.zone.parse("2024-09-28") + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: Time.zone.parse("2024-09-30 11:00"), properties: {amount: "9876", precise_total_amount_cents: 9875.6823759}) + + travel_to(sept28) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: 5}, + precise_total_amount_cents: "1234.56" + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(5) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(1235) + end + + travel_to(Time.zone.parse("2024-09-30")) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "1"}, + precise_total_amount_cents: "2.1" + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(1) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(2) + end + end + end + + describe "with count_agg / percentage" do + let(:aggregation_type) { "count_agg" } + let(:field_name) { "amount" } + + describe "with free_units_per_events" do + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :percentage_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: { + rate: "1", + fixed_amount: "1", + free_units_per_events: 1 + } + ) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 1.second, properties: {amount: "333"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "5"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee).to have_attributes( + invoice_id: nil, + charge_id: charge.id, + fee_type: "charge", + pay_in_advance: true, + units: 1, + events_count: 1, + amount_cents: 0 + ) + end + end + end + end + + describe "with sum_agg / graduated_percentage", :premium do + let(:aggregation_type) { "sum_agg" } + let(:field_name) { "amount" } + + it "creates an pay_in_advance fee" do + ### 24 january: Create subscription. + jan24 = DateTime.new(2023, 1, 24) + + travel_to(jan24) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + charge = create( + :graduated_percentage_charge, + :pay_in_advance, + invoiceable: false, + plan:, + billable_metric:, + properties: { + graduated_percentage_ranges: + [{"rate" => "20", "to_value" => 100, "from_value" => 0, "flat_amount" => "10"}, + {"rate" => "30", "to_value" => 200, "from_value" => 101, "flat_amount" => "7"}, + {"rate" => "40", "to_value" => nil, "from_value" => 201, "flat_amount" => "5"}] + } + ) + + subscription = customer.subscriptions.first + + ### 15 february: Send an event. + feb15 = DateTime.new(2023, 2, 15) + + # Create events with timestamp AFTER the current event to invoice, to ensure `event.timestamp` is used as upper bound. + create(:event, code: billable_metric.code, organization:, subscription:, timestamp: feb15 + 5.days, properties: {amount: "333"}) + + travel_to(feb15) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "50"} + } + ) + end.to change { subscription.reload.fees.count }.from(0).to(1) + + fee = subscription.fees.first + + expect(fee.invoice_id).to be_nil + expect(fee.charge_id).to eq(charge.id) + expect(fee.pay_in_advance).to eq(true) + expect(fee.units).to eq(50) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(50 * 20 + 1000) # flat fee = 10, which is 1000 cents + expect(fee.amount_details).to eq( + {"graduated_percentage_ranges" => [ + {"rate" => "20.0", "units" => "50.0", "to_value" => 100, "from_value" => 0, "flat_unit_amount" => "10.0", + "per_unit_total_amount" => "0.4", "total_with_flat_amount" => "20.0"} + ]} + ) + end + + travel_to(DateTime.new(2023, 2, 17)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "100"} + } + ) + end.to change { subscription.reload.fees.count }.from(1).to(2) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(100) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(50 * 20 + 50 * 30 + 700) # this transactions goes into two tiers and we are including flat fee for the second tier + expect(fee.amount_details).to eq( + {"graduated_percentage_ranges" => [ + {"rate" => "20.0", "units" => "50.0", "to_value" => 100, "from_value" => 0, "flat_unit_amount" => "0.0", + "per_unit_total_amount" => "0.2", "total_with_flat_amount" => "10.0"}, + {"rate" => "30.0", "units" => "50.0", "to_value" => 200, "from_value" => 101, "flat_unit_amount" => "7.0", + "per_unit_total_amount" => "0.44", "total_with_flat_amount" => "22.0"} + ]} + ) + end + + travel_to(DateTime.new(2023, 2, 19)) do + expect do + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {amount: "300"} + } + ) + end.to change { subscription.reload.fees.count }.from(2).to(3) + + fee = subscription.fees.order(created_at: :desc).first + expect(fee.units).to eq(300) + expect(fee.events_count).to eq(1) + expect(fee.amount_cents).to eq(50 * 30 + 250 * 40 + 500) # this transactions goes into two tiers and we are including flat fee for the second tier + expect(fee.amount_details).to eq( + {"graduated_percentage_ranges" => [ + {"rate" => "20.0", "units" => "0.0", "to_value" => 100, "from_value" => 0, "flat_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0", "total_with_flat_amount" => "0.0"}, + {"rate" => "30.0", "units" => "50.0", "to_value" => 200, "from_value" => 101, "flat_unit_amount" => "0.0", + "per_unit_total_amount" => "0.3", "total_with_flat_amount" => "15.0"}, + {"rate" => "40.0", "units" => "250.0", "to_value" => nil, "from_value" => 201, "flat_unit_amount" => "5.0", + "per_unit_total_amount" => "0.42", "total_with_flat_amount" => "105.0"} + ]} + ) + end + end + end +end diff --git a/spec/scenarios/plans/cascade_filter_updates_spec.rb b/spec/scenarios/plans/cascade_filter_updates_spec.rb new file mode 100644 index 0000000..851162f --- /dev/null +++ b/spec/scenarios/plans/cascade_filter_updates_spec.rb @@ -0,0 +1,176 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Tests filter-level cascade for create, update, and destroy operations. +# Each filter operation cascades only that specific filter to child charges +RSpec.describe "Cascade filter updates", :premium do + include ScenariosHelper + + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + let(:billable_metric) { create(:billable_metric, organization:, code: "storage") } + let(:bm_filter) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[us eu asia]) + end + + before { bm_filter } + + # Sets up a parent plan with a charge and two filters, a subscription with + # a charge override, and returns the key objects for assertions. + def setup_plan_with_subscription + create_plan({ + name: "Enterprise", + code: "enterprise", + interval: "monthly", + amount_cents: 0, + amount_currency: "EUR", + pay_in_advance: false, + charges: [ + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + code: "storage_charge", + pay_in_advance: false, + properties: {amount: "0"}, + filters: [ + { + invoice_display_name: "US region", + properties: {amount: "10"}, + values: {region: ["us"]} + }, + { + invoice_display_name: "EU region", + properties: {amount: "20"}, + values: {region: ["eu"]} + } + ] + } + ] + }) + + parent_plan = organization.plans.find_by(code: "enterprise") + parent_charge = parent_plan.charges.first + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_enterprise", + plan_code: "enterprise" + }) + + subscription = organization.subscriptions.find_by(external_id: "sub_enterprise") + + update_subscription_charge(subscription, "storage_charge", { + invoice_display_name: "My storage", + properties: {amount: "0"} + }) + + subscription.reload + child_charge = subscription.plan.charges.find_by(code: "storage_charge") + + {parent_plan:, parent_charge:, child_charge:} + end + + it "cascades rapid-fire filter updates independently" do + ctx = setup_plan_with_subscription + parent_plan = ctx[:parent_plan] + parent_charge = ctx[:parent_charge] + child_charge = ctx[:child_charge] + filter_us = parent_charge.filters.find_by(invoice_display_name: "US region") + filter_eu = parent_charge.filters.find_by(invoice_display_name: "EU region") + child_filter_us = child_charge.filters.find_by(invoice_display_name: "US region") + child_filter_eu = child_charge.filters.find_by(invoice_display_name: "EU region") + + # Queue multiple filter updates without executing jobs + update_plan_charge_filter( + parent_plan, parent_charge.code, filter_us.id, + {properties: {amount: "15"}, cascade_updates: true}, + perform_jobs: false + ) + + update_plan_charge_filter( + parent_plan, parent_charge.code, filter_eu.id, + {properties: {amount: "25"}, cascade_updates: true}, + perform_jobs: false + ) + + # Child is unchanged before jobs run + expect(child_filter_us.reload.properties).to eq({"amount" => "10"}) + expect(child_filter_eu.reload.properties).to eq({"amount" => "20"}) + + # Each filter update enqueued its own independent CascadeJob + perform_all_enqueued_jobs + + expect(child_filter_us.reload.properties).to eq({"amount" => "15"}) + expect(child_filter_eu.reload.properties).to eq({"amount" => "25"}) + end + + it "cascades filter creation to child charges" do + ctx = setup_plan_with_subscription + parent_plan = ctx[:parent_plan] + parent_charge = ctx[:parent_charge] + child_charge = ctx[:child_charge] + + expect(child_charge.filters.count).to eq(2) + + create_plan_charge_filter(parent_plan, parent_charge.code, { + invoice_display_name: "Asia region", + properties: {amount: "30"}, + values: {region: ["asia"]}, + cascade_updates: true + }) + + child_charge.reload + expect(child_charge.filters.count).to eq(3) + + child_filter_asia = child_charge.filters.find_by(invoice_display_name: "Asia region") + expect(child_filter_asia.properties).to eq({"amount" => "30"}) + expect(child_filter_asia.to_h).to eq({"region" => ["asia"]}) + end + + it "cascades filter deletion to child charges" do + ctx = setup_plan_with_subscription + parent_plan = ctx[:parent_plan] + parent_charge = ctx[:parent_charge] + child_charge = ctx[:child_charge] + filter_eu = parent_charge.filters.find_by(invoice_display_name: "EU region") + + expect(child_charge.filters.count).to eq(2) + + delete_plan_charge_filter(parent_plan, parent_charge.code, filter_eu.id) + + # Destroy via API doesn't pass cascade_updates through the helper, + # so cascade manually to test the destroy path + ChargeFilters::CascadeService.call!( + charge: parent_charge, + action: "destroy", + filter_values: {"region" => ["eu"]} + ) + + child_charge.reload + expect(child_charge.filters.count).to eq(1) + expect(child_charge.filters.first.invoice_display_name).to eq("US region") + end + + it "does not overwrite a customer-customized filter" do + ctx = setup_plan_with_subscription + parent_plan = ctx[:parent_plan] + parent_charge = ctx[:parent_charge] + child_charge = ctx[:child_charge] + filter_us = parent_charge.filters.find_by(invoice_display_name: "US region") + child_filter_us = child_charge.filters.find_by(invoice_display_name: "US region") + + # Customer customizes the US filter on their subscription + child_filter_us.update!(properties: {"amount" => "99"}) + + # Admin updates the same filter on the parent plan + update_plan_charge_filter( + parent_plan, parent_charge.code, filter_us.id, + {properties: {amount: "15"}, cascade_updates: true} + ) + + # Customer's override is preserved + expect(child_filter_us.reload.properties).to eq({"amount" => "99"}) + end +end diff --git a/spec/scenarios/plans/create_and_update_filters_spec.rb b/spec/scenarios/plans/create_and_update_filters_spec.rb new file mode 100644 index 0000000..446e608 --- /dev/null +++ b/spec/scenarios/plans/create_and_update_filters_spec.rb @@ -0,0 +1,225 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Create and edit plans with charge filters" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "value") } + + let(:steps_bm_filter) do + create(:billable_metric_filter, billable_metric:, key: "steps", values: %w[0-25 26-50 51-100]) + end + let(:image_size_bm_filter) do + create(:billable_metric_filter, billable_metric:, key: "image_size", values: %w[1024x1024 512x152]) + end + let(:model_name_bm_filter) do + create(:billable_metric_filter, billable_metric:, key: "model_name", values: %w[llama-1 llama-2 llama-3]) + end + + before do + steps_bm_filter + image_size_bm_filter + model_name_bm_filter + end + + it "allows the creation and update of plans with charge filters" do + # Create a plan with a charge and filters + travel_to(Time.zone.parse("2024-03-27T12:00:00")) do + create_plan( + {name: "Filtered Plan", + code: "filtered_plan", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + charges: [ + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + properties: {amount: "0"}, + filters: [ + { + invoice_display_name: "f1", + properties: {amount: "10"}, + values: {image_size: ["512x152"], steps: ["0-25"], model_name: ["llama-2"]} + }, + { + invoice_display_name: "f2", + properties: {amount: "5"}, + values: {image_size: ["512x152"], steps: ["0-25"]} + }, + { + invoice_display_name: "f3", + properties: {amount: "5"}, + values: { + image_size: [ChargeFilterValue::ALL_FILTER_VALUES], + steps: [ChargeFilterValue::ALL_FILTER_VALUES] + } + }, + { + invoice_display_name: "f4", + properties: {amount: "2.5"}, + values: { + image_size: [ChargeFilterValue::ALL_FILTER_VALUES] + } + } + ] + } + ]} + ) + end + + plan = organization.plans.find_by(code: "filtered_plan") + expect(plan.charges.count).to eq(1) + + charge = plan.charges.first + expect(charge.filters.count).to eq(4) + + # Update the typo on the charge filter values + travel_to(Time.zone.parse("2024-03-27T14:00:00")) do + update_metric( + billable_metric, + {filters: [ + {key: "image_size", values: %w[1024x1024 512x512]}, + {key: "steps", values: %w[0-25 26-50 51-100]}, + {key: "model_name", values: %w[llama-1 llama-2 llama-3]} + ]} + ) + end + + charge.reload + f1 = charge.filters.find_by(invoice_display_name: "f1") + expect(f1.to_h.keys).to match_array(%w[steps model_name]) + + f2 = charge.filters.find_by(invoice_display_name: "f2") + expect(f2.to_h.keys).to eq(%w[steps]) + + f3 = charge.filters.find_by(invoice_display_name: "f3") + expect(f3.to_h.keys).to match_array(%w[image_size steps]) + + f4 = charge.filters.find_by(invoice_display_name: "f4") + expect(f4.to_h.keys).to eq(%w[image_size]) + + # Update the plan to fix the filters + travel_to(Time.zone.parse("2024-03-27T16:00:00")) do + update_plan( + plan, + {name: "Filtered Plan", + code: "filtered_plan", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + charges: [ + { + billable_metric_id: billable_metric.id, + id: charge.id, + charge_model: "standard", + properties: {amount: "0"}, + filters: [ + { + invoice_display_name: "f2", + properties: {amount: "5"}, + values: {image_size: ["512x512"], steps: ["0-25"]} + }, + { + invoice_display_name: "f3", + properties: {amount: "5"}, + values: { + image_size: [ChargeFilterValue::ALL_FILTER_VALUES], + steps: [ChargeFilterValue::ALL_FILTER_VALUES] + } + }, + { + invoice_display_name: "f4", + properties: {amount: "2.5"}, + values: { + image_size: [ChargeFilterValue::ALL_FILTER_VALUES] + } + }, + { + invoice_display_name: "f1", + properties: {amount: "10"}, + values: {image_size: ["512x512"], steps: ["0-25"], model_name: ["llama-2"]} + }, + { + invoice_display_name: "f5", + properties: {amount: "1"}, + values: {image_size: ["1024x1024"]} + } + ] + } + ]} + ) + + plan.reload + charge = plan.charges.first + expect(charge.filters.count).to eq(5) + end + + # TODO: send events to check the filters are working + travel_to(Time.zone.parse("2024-03-28T12:00:00")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary"} + ) + end + + subscription = organization.subscriptions.find_by(external_id: customer.external_id) + expect(subscription).to be_present + + travel_to(Time.zone.parse("2024-03-29T12:00:00")) do + # Send an event with more values than the filters + create_event( + {code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + value: 10, + image_size: "512x512", + steps: "0-25", + model: "llama-3" + }} + ) + + create_event( + {code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id, + properties: { + value: 10, + image: "512x512", + step: "0-25", + model: "llama-3" + }} + ) + + fetch_current_usage(customer:) + expect(json[:customer_usage][:total_amount_cents]).to eq(5000) + expect(json[:customer_usage][:charges_usage].count).to eq(1) + + charges_usage = json[:customer_usage][:charges_usage].first + expect(charges_usage[:filters].count).to eq(6) + + f2_filter = charges_usage[:filters].find { it[:invoice_display_name] == "f2" } + expect(f2_filter[:amount_cents]).to eq(5000) + expect(f2_filter[:units]).to eq("10.0") + expect(f2_filter[:events_count]).to eq(1) + expect(f2_filter[:invoice_display_name]).to eq("f2") + + charges_usage[:filters].reject { [nil, "f2"].include?(it[:invoice_display_name]) }.each do |filter| + expect(filter[:amount_cents]).to eq(0) + expect(filter[:units]).to eq("0.0") + expect(filter[:events_count]).to eq(0) + end + + default_filter = charges_usage[:filters].find { it[:invoice_display_name].nil? } + expect(default_filter[:amount_cents]).to eq(0) + expect(default_filter[:units]).to eq("10.0") + expect(default_filter[:events_count]).to eq(1) + end + end +end diff --git a/spec/scenarios/plans/independent_charge_management_spec.rb b/spec/scenarios/plans/independent_charge_management_spec.rb new file mode 100644 index 0000000..1970ed9 --- /dev/null +++ b/spec/scenarios/plans/independent_charge_management_spec.rb @@ -0,0 +1,397 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "Independent charge and filter management", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "value") } + + let(:region_bm_filter) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[us eu asia]) + end + let(:tier_bm_filter) do + create(:billable_metric_filter, billable_metric:, key: "tier", values: %w[basic pro enterprise]) + end + + before do + region_bm_filter + tier_bm_filter + end + + describe "Plan level: updating charges and filters" do + it "produces the same result whether updating plan all-at-once or via independent endpoints" do + # === APPROACH 1: Create plan with charges and filters all-at-once === + create_plan({ + name: "Plan All At Once", + code: "plan_all_at_once", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + charges: [ + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + code: "all_at_once_charge", + pay_in_advance: false, + properties: {amount: "10"}, + filters: [ + { + invoice_display_name: "US Basic", + properties: {amount: "5"}, + values: {region: ["us"], tier: ["basic"]} + }, + { + invoice_display_name: "EU Pro", + properties: {amount: "15"}, + values: {region: ["eu"], tier: ["pro"]} + } + ] + } + ] + }) + + plan_all_at_once = organization.plans.find_by(code: "plan_all_at_once") + + # === APPROACH 2: Create plan, then add charges and filters independently === + create_plan({ + name: "Plan Independent", + code: "plan_independent", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + charges: [] + }) + + plan_independent = organization.plans.find_by(code: "plan_independent") + + # Create charge independently + create_plan_charge(plan_independent, { + billable_metric_id: billable_metric.id, + charge_model: "standard", + code: "independent_charge", + pay_in_advance: false, + properties: {amount: "10"} + }) + + plan_independent.reload + charge = plan_independent.charges.find_by(code: "independent_charge") + + # Create filters independently + create_plan_charge_filter(plan_independent, charge.code, { + invoice_display_name: "US Basic", + properties: {amount: "5"}, + values: {region: ["us"], tier: ["basic"]} + }) + + create_plan_charge_filter(plan_independent, charge.code, { + invoice_display_name: "EU Pro", + properties: {amount: "15"}, + values: {region: ["eu"], tier: ["pro"]} + }) + + # === COMPARE RESULTS === + plan_all_at_once.reload + plan_independent.reload + + charge_all_at_once = plan_all_at_once.charges.first + charge_independent = plan_independent.charges.first + + # Both should have the same structure + expect(charge_all_at_once.charge_model).to eq(charge_independent.charge_model) + expect(charge_all_at_once.properties).to eq(charge_independent.properties) + expect(charge_all_at_once.filters.count).to eq(charge_independent.filters.count) + + # Compare filters by invoice_display_name + %w[US\ Basic EU\ Pro].each do |filter_name| + filter_all = charge_all_at_once.filters.find_by(invoice_display_name: filter_name) + filter_ind = charge_independent.filters.find_by(invoice_display_name: filter_name) + + expect(filter_all.properties).to eq(filter_ind.properties) + expect(filter_all.to_h).to eq(filter_ind.to_h) + end + end + + it "allows updating charges and filters independently with same result as plan update" do + # Create initial plan with charge and filter + create_plan({ + name: "Update Test Plan", + code: "update_test_plan", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + charges: [ + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + code: "test_charge", + pay_in_advance: false, + properties: {amount: "10"}, + filters: [ + { + invoice_display_name: "Original Filter", + properties: {amount: "5"}, + values: {region: ["us"]} + } + ] + } + ] + }) + + plan = organization.plans.find_by(code: "update_test_plan") + charge = plan.charges.first + filter = charge.filters.first + + # Update charge independently + update_plan_charge(plan, charge.code, { + charge_model: "standard", + properties: {amount: "20"}, + min_amount_cents: 100 + }) + + charge.reload + expect(charge.properties["amount"]).to eq("20") + expect(charge.min_amount_cents).to eq(100) + + # Update filter independently + update_plan_charge_filter(plan, charge.code, filter.id, { + invoice_display_name: "Updated Filter", + properties: {amount: "25"} + }) + + filter.reload + expect(filter.invoice_display_name).to eq("Updated Filter") + expect(filter.properties["amount"]).to eq("25") + + # Add new filter independently + create_plan_charge_filter(plan, charge.code, { + invoice_display_name: "New Filter", + properties: {amount: "30"}, + values: {region: ["eu"]} + }) + + charge.reload + expect(charge.filters.count).to eq(2) + expect(charge.filters.pluck(:invoice_display_name)).to match_array(["Updated Filter", "New Filter"]) + + # Delete filter independently + new_filter = charge.filters.find_by(invoice_display_name: "New Filter") + delete_plan_charge_filter(plan, charge.code, new_filter.id) + + charge.reload + expect(charge.filters.count).to eq(1) + expect(charge.filters.first.invoice_display_name).to eq("Updated Filter") + end + end + + describe "Subscription level: updating charges and filters with overrides" do + let(:base_plan) do + create(:plan, organization:, name: "Base Plan", code: "base_plan", amount_cents: 10_000) + end + let(:charge) do + create(:standard_charge, plan: base_plan, billable_metric:, code: "base_charge", properties: {"amount" => "10"}) + end + let(:charge_filter) do + create(:charge_filter, charge:, organization:, invoice_display_name: "Base Filter", properties: {"amount" => "5"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter: region_bm_filter, values: ["us"], organization:) + end + end + + before do + charge + charge_filter + end + + it "produces the same override structure whether updating subscription all-at-once or via independent endpoints" do + # Create two subscriptions on the same base plan + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_all_at_once", + plan_code: base_plan.code + }) + sub_all_at_once = organization.subscriptions.find_by(external_id: "sub_all_at_once") + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_independent", + plan_code: base_plan.code + }) + sub_independent = organization.subscriptions.find_by(external_id: "sub_independent") + + # Both should be on the base plan initially + expect(sub_all_at_once.plan_id).to eq(base_plan.id) + expect(sub_independent.plan_id).to eq(base_plan.id) + + # === APPROACH 1: Update subscription with charges all-at-once === + update_subscription(sub_all_at_once, { + plan_overrides: { + charges: [ + { + id: charge.id, + invoice_display_name: "Overridden Charge", + properties: {amount: "50"}, + filters: [ + { + invoice_display_name: "Overridden Filter", + properties: {amount: "25"}, + values: {region: ["us"]} + }, + { + invoice_display_name: "New Sub Filter", + properties: {amount: "35"}, + values: {region: ["eu"]} + } + ] + } + ] + } + }) + + sub_all_at_once.reload + + # === APPROACH 2: Update subscription via independent endpoints === + update_subscription_charge(sub_independent, charge.code, { + invoice_display_name: "Overridden Charge", + properties: {amount: "50"}, + filters: [ + { + invoice_display_name: "Overridden Filter", + properties: {amount: "25"}, + values: {region: ["us"]} + } + ] + }) + + sub_independent.reload + + # Add new filter independently + create_subscription_charge_filter(sub_independent, charge.code, { + invoice_display_name: "New Sub Filter", + properties: {amount: "35"}, + values: {region: ["eu"]} + }) + + sub_independent.reload + + # === COMPARE OVERRIDE STRUCTURES === + # Both subscriptions should now have plan overrides + expect(sub_all_at_once.plan.parent_id).to eq(base_plan.id) + expect(sub_independent.plan.parent_id).to eq(base_plan.id) + + # Get the overridden charges + charge_override_1 = sub_all_at_once.plan.charges.find_by(code: charge.code) + charge_override_2 = sub_independent.plan.charges.find_by(code: charge.code) + + # Both charge overrides should point to the same parent + expect(charge_override_1.parent_id).to eq(charge.id) + expect(charge_override_2.parent_id).to eq(charge.id) + + # Both should have the same overridden properties + expect(charge_override_1.invoice_display_name).to eq(charge_override_2.invoice_display_name) + expect(charge_override_1.properties).to eq(charge_override_2.properties) + + # Both should have the same number of filters + expect(charge_override_1.filters.count).to eq(charge_override_2.filters.count) + + # Compare filters by invoice_display_name + ["Overridden Filter", "New Sub Filter"].each do |filter_name| + filter_1 = charge_override_1.filters.find_by(invoice_display_name: filter_name) + filter_2 = charge_override_2.filters.find_by(invoice_display_name: filter_name) + + expect(filter_1).to be_present, "Filter '#{filter_name}' not found in subscription 1" + expect(filter_2).to be_present, "Filter '#{filter_name}' not found in subscription 2" + expect(filter_1.properties).to eq(filter_2.properties) + expect(filter_1.to_h).to eq(filter_2.to_h) + end + + # Original charge and filter should remain unchanged + charge.reload + charge_filter.reload + expect(charge.properties["amount"]).to eq("10") + expect(charge_filter.properties["amount"]).to eq("5") + end + + it "allows updating and deleting subscription filters independently" do + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_filter_ops", + plan_code: base_plan.code + }) + sub = organization.subscriptions.find_by(external_id: "sub_filter_ops") + + # Update filter via subscription endpoint (creates override) + update_subscription_charge_filter(sub, charge.code, charge_filter.id, { + invoice_display_name: "Updated Sub Filter", + properties: {amount: "99"} + }) + + sub.reload + + # Should have created plan and charge override + expect(sub.plan.parent_id).to eq(base_plan.id) + charge_override = sub.plan.charges.find_by(code: charge.code) + expect(charge_override.parent_id).to eq(charge.id) + + # Filter should be updated on the override + filter_override = charge_override.filters.first + expect(filter_override.invoice_display_name).to eq("Updated Sub Filter") + expect(filter_override.properties["amount"]).to eq("99") + + # Original filter should be unchanged + charge_filter.reload + expect(charge_filter.invoice_display_name).to eq("Base Filter") + expect(charge_filter.properties["amount"]).to eq("5") + + # Add a new filter + create_subscription_charge_filter(sub, charge.code, { + invoice_display_name: "Additional Filter", + properties: {amount: "77"}, + values: {region: ["asia"]} + }) + + charge_override.reload + expect(charge_override.filters.count).to eq(2) + + # Delete the additional filter + additional_filter = charge_override.filters.find_by(invoice_display_name: "Additional Filter") + delete_subscription_charge_filter(sub, charge.code, additional_filter.id) + + charge_override.reload + expect(charge_override.filters.count).to eq(1) + expect(charge_override.filters.first.invoice_display_name).to eq("Updated Sub Filter") + end + + it "deleting a filter from parent creates override and soft-deletes the copied filter" do + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub_delete_parent", + plan_code: base_plan.code + }) + sub = organization.subscriptions.find_by(external_id: "sub_delete_parent") + + # Delete the parent's filter via subscription endpoint + delete_subscription_charge_filter(sub, charge.code, charge_filter.id) + + sub.reload + + # Should have created override chain + expect(sub.plan.parent_id).to eq(base_plan.id) + charge_override = sub.plan.charges.find_by(code: charge.code) + expect(charge_override.parent_id).to eq(charge.id) + + # The override should have a soft-deleted filter (copied then deleted) + expect(charge_override.filters.count).to eq(0) + expect(ChargeFilter.unscoped.where(charge_id: charge_override.id).count).to eq(1) + deleted_filter = ChargeFilter.unscoped.find_by(charge_id: charge_override.id) + expect(deleted_filter.deleted_at).to be_present + + # Parent filter should remain unchanged + charge_filter.reload + expect(charge_filter.deleted_at).to be_nil + expect(charge_filter.invoice_display_name).to eq("Base Filter") + end + end +end diff --git a/spec/scenarios/regenerate_advance_charges_spec.rb b/spec/scenarios/regenerate_advance_charges_spec.rb new file mode 100644 index 0000000..d47884c --- /dev/null +++ b/spec/scenarios/regenerate_advance_charges_spec.rb @@ -0,0 +1,204 @@ +# frozen_string_literal: true + +require "rails_helper" + +# End-to-end scenario: regenerating a voided advance_charges invoice. +# +# Flow: +# 1. Create a plan with a pay-in-advance, non-invoiceable charge (regroup_paid_fees: invoice) +# 2. Subscribe a customer and ingest events that produce pay-in-advance fees +# 3. Run billing to group those fees into an advance_charges invoice +# 4. Void the invoice +# 5. Regenerate it with adjusted fee params +# +# This exercises three fixes: +# - the idx_pay_in_advance_duplication_guard_charge unique index now allows +# duplicate pay_in_advance_event_transaction_id on regenerated fees +# - create_invoice_subscriptions now runs for any voided invoice that had them, +# not only subscription-type invoices +# - AdjustedFees::CreateService only calls RefreshDraftService for draft +# subscription invoices, avoiding forbidden_failure! on advance_charges +# +describe "Regenerate Voided Advance Charges Invoice Scenarios", :with_pdf_generation_stub, transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax_rate) { 20 } + let(:billable_metric) { create(:unique_count_billable_metric, organization:, code: "cards", recurring: true) } + let(:plan) { create(:plan, organization:, pay_in_advance: true, amount_cents: 49) } + let(:external_subscription_id) { SecureRandom.uuid } + + def send_card_event!(item_id = SecureRandom.uuid) + create_event({ + code: billable_metric.code, + transaction_id: "tr_#{SecureRandom.hex(10)}", + external_customer_id: customer.external_id, + external_subscription_id:, + properties: {item_id:} + }) + end + + def create_advance_charges_invoice + travel_to(DateTime.new(2024, 6, 5, 10)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: external_subscription_id, + plan_code: plan.code + } + ) + perform_billing + end + + subscription = customer.subscriptions.sole + + (1..3).each do |i| + travel_to(DateTime.new(2024, 6, 10 + i, 10)) do + send_card_event! "card_#{i}" + end + end + + subscription.fees.charge.where(invoice_id: nil).update!( + payment_status: :succeeded, + succeeded_at: DateTime.new(2024, 6, 20, 0, 10) + ) + + travel_to(DateTime.new(2024, 7, 1, 0, 10)) do + perform_billing + end + + invoice = customer.invoices.where(invoice_type: :advance_charges).sole + expect(invoice).to be_finalized + expect(invoice.fees.count).to eq(3) + expect(invoice.invoice_subscriptions).not_to be_empty + + invoice + end + + before do + create(:tax, :applied_to_billing_entity, organization:, rate: tax_rate) + create( + :standard_charge, + regroup_paid_fees: "invoice", + pay_in_advance: true, + invoiceable: false, + prorated: true, + billable_metric:, + plan:, + properties: {amount: "30.12", grouped_by: nil} + ) + end + + context "when regenerating with fee id (primary UI path)" do + it "regenerates a voided advance_charges invoice with adjusted fees" do + advance_charges_invoice = create_advance_charges_invoice + subscription = customer.subscriptions.sole + original_fee = advance_charges_invoice.fees.first + + void_invoice(advance_charges_invoice) + expect(advance_charges_invoice).to be_voided + + fees_params = [ + { + id: original_fee.id, + subscription_id: subscription.id, + units: 5, + unit_amount_cents: 20.0 + } + ] + + result = Invoices::RegenerateFromVoidedService.call( + voided_invoice: advance_charges_invoice, + fees_params: + ) + + expect(result).to be_success + + regenerated_invoice = result.invoice + expect(regenerated_invoice.invoice_type).to eq("advance_charges") + expect(regenerated_invoice).to be_finalized + expect(regenerated_invoice.invoice_subscriptions).not_to be_empty + + regenerated_fee = regenerated_invoice.fees.first + expect(regenerated_fee.pay_in_advance_event_transaction_id).to eq(original_fee.pay_in_advance_event_transaction_id) + expect(regenerated_fee.units).to eq(5) + expect(regenerated_fee.unit_amount_cents).to eq(2000) + expect(regenerated_fee.amount_cents).to eq(10_000) + + # Original fee on the voided invoice is untouched + expect(original_fee.reload.pay_in_advance_event_transaction_id).to be_present + end + end + + context "when multiple void/regenerate cycles occur" do + it "preserves pay_in_advance_event_transaction_id across the chain" do + advance_charges_invoice = create_advance_charges_invoice + subscription = customer.subscriptions.sole + original_fee = advance_charges_invoice.fees.first + original_transaction_id = original_fee.pay_in_advance_event_transaction_id + + # First cycle: void → regenerate + void_invoice(advance_charges_invoice) + + first_result = Invoices::RegenerateFromVoidedService.call( + voided_invoice: advance_charges_invoice, + fees_params: [{id: original_fee.id, subscription_id: subscription.id, units: 5, unit_amount_cents: 20.0}] + ) + expect(first_result).to be_success + + first_regenerated_invoice = first_result.invoice + first_regenerated_fee = first_regenerated_invoice.fees.first + expect(first_regenerated_fee.pay_in_advance_event_transaction_id).to eq(original_transaction_id) + + # Second cycle: void the regenerated invoice → regenerate again + void_invoice(first_regenerated_invoice) + expect(first_regenerated_invoice).to be_voided + + second_result = Invoices::RegenerateFromVoidedService.call( + voided_invoice: first_regenerated_invoice, + fees_params: [{id: first_regenerated_fee.id, subscription_id: subscription.id, units: 8, unit_amount_cents: 15.0}] + ) + expect(second_result).to be_success + + second_regenerated_invoice = second_result.invoice + second_regenerated_fee = second_regenerated_invoice.fees.first + + # pay_in_advance_event_transaction_id is preserved across all regenerations + expect(second_regenerated_fee.pay_in_advance_event_transaction_id).to eq(original_transaction_id) + expect(second_regenerated_fee.units).to eq(8) + expect(second_regenerated_fee.unit_amount_cents).to eq(1500) + end + end + + context "when regenerating without fee id (charge-based path)" do + it "creates fees via invoice_subscriptions and adjusts them" do + advance_charges_invoice = create_advance_charges_invoice + subscription = customer.subscriptions.sole + charge = subscription.plan.charges.first + + void_invoice(advance_charges_invoice) + expect(advance_charges_invoice).to be_voided + + fees_params = [ + { + subscription_id: subscription.id, + charge_id: charge.id, + units: 5, + unit_amount_cents: 20.0 + } + ] + + result = Invoices::RegenerateFromVoidedService.call( + voided_invoice: advance_charges_invoice, + fees_params: + ) + + expect(result).to be_success + + regenerated_invoice = result.invoice + expect(regenerated_invoice.invoice_type).to eq("advance_charges") + expect(regenerated_invoice).to be_finalized + expect(regenerated_invoice.invoice_subscriptions).not_to be_empty + expect(regenerated_invoice.fees.count).to be >= 1 + end + end +end diff --git a/spec/scenarios/spending_minimum_spec.rb b/spec/scenarios/spending_minimum_spec.rb new file mode 100644 index 0000000..fb3f27b --- /dev/null +++ b/spec/scenarios/spending_minimum_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Spending Minimum Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:plan) { create(:plan, pay_in_advance: true, organization:, amount_cents: 5000) } + let(:metric) { create(:billable_metric, organization:) } + + before { tax } + + context "when invoice grace period" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + + it "creates expected credit note and invoice" do + ### 8 Jan: Create subscription + travel_to(DateTime.new(2023, 1, 8, 8)) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + }.to change(Invoice, :count).by(1) + + create( + :standard_charge, + plan:, + billable_metric: metric, + properties: {amount: "8"}, + min_amount_cents: 1000 + ) + end + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(4645) # 60 / 31 * 24 + + travel_to(DateTime.new(2023, 2, 1, 6)) do + perform_billing + end + + last_invoice = subscription.invoices.order(created_at: :desc).first + + ### 25 Feb: Create event and Terminate subscription + travel_to(DateTime.new(2023, 2, 25, 6)) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id + } + ) + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(2).to(3) + + term_invoice = subscription.invoices.order(created_at: :desc).first + expect(term_invoice).to be_draft + + expect(term_invoice.fees.count).to eq(2) + usage_fee = term_invoice.fees.where(true_up_parent_fee_id: nil).first + true_up_fee = usage_fee.true_up_fee + + expect(usage_fee).to have_attributes( + amount_cents: 800, + taxes_amount_cents: 160, + units: 1 + ) + + # True up fee is pro-rated for 25/28 days. + expect(true_up_fee).to have_attributes( + amount_cents: 93, # 1000 / 28.0 * 25 - 800 + taxes_amount_cents: 19, + units: 1 + ) + + expect(term_invoice).to have_attributes( + fees_amount_cents: 893, + taxes_amount_cents: 179, + credit_notes_amount_cents: 0, + total_amount_cents: 1072 + ) + + # Refresh pay in advance invoice + refresh_invoice(last_invoice) + + credit_note = last_invoice.credit_notes.first + expect(credit_note).to be_draft + expect(credit_note.reload).to have_attributes( + sub_total_excluding_taxes_amount_cents: 536, + credit_amount_cents: 643, + taxes_amount_cents: 107, + total_amount_cents: 643 + ) + + # Refresh termination invoice + expect { + refresh_invoice(term_invoice) + }.not_to change { term_invoice.reload.total_amount_cents } + + # Finalize pay in advance invoice + expect { + finalize_invoice(last_invoice) + }.to change { last_invoice.reload.status }.from("draft").to("finalized") + .and change { credit_note.reload.status }.from("draft").to("finalized") + + # Finalize termination invoice + expect { + finalize_invoice(term_invoice) + }.to change { term_invoice.reload.status }.from("draft").to("finalized") + + credit_note = last_invoice.credit_notes.first + expect(credit_note.total_amount_cents).to eq(643) # 60.0 / 28 * 3 + + expect(term_invoice).to have_attributes( + fees_amount_cents: 893, + taxes_amount_cents: 179, + credit_notes_amount_cents: 643, + total_amount_cents: 429 # 893 + 179 - 643 + ) + end + end + end + + context "when filters" do + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric: metric, key: "region", values: %w[europe usa]) + end + + it "creates expected credit note and invoice" do + ### 8 Jan: Create subscription + travel_to(DateTime.new(2023, 1, 8)) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + }.to change(Invoice, :count).by(1) + + charge = create( + :standard_charge, + plan:, + billable_metric: metric, + properties: {amount: "0"}, + min_amount_cents: 10_000 + ) + + europe_filter = create(:charge_filter, charge:, properties: {amount: "20"}) + create(:charge_filter_value, charge_filter: europe_filter, billable_metric_filter:, values: ["europe"]) + + usa_filter = create(:charge_filter, charge:, properties: {amount: "50"}) + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter:, values: ["usa"]) + end + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(4645) # 60 / 31 * 24 + + travel_to(DateTime.new(2023, 2, 1, 6)) do + perform_billing + end + + ### 25 Feb: Create event and Terminate subscription + travel_to(DateTime.new(2023, 2, 25, 8)) do + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: { + region: "usa" + } + } + ) + + create_event( + { + code: metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: { + region: "europe" + } + } + ) + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(2).to(3) + + term_invoice = subscription.invoices.order(created_at: :desc).first + expect(term_invoice).to be_finalized + expect(term_invoice.fees.count).to eq(4) + + usage_fees = term_invoice.fees.where(true_up_parent_fee_id: nil) + expect(usage_fees.count).to eq(3) + expect(usage_fees.pluck(:amount_cents)).to contain_exactly(0, 2000, 5000) + + true_up_fee = term_invoice.fees.where.not(true_up_parent_fee_id: nil).first + # True up fee is pro-rated for 25/28 days. + expect(true_up_fee).to have_attributes( + amount_cents: 1929, # 10000 / 28.0 * 25 - 2000 - 5000 + taxes_amount_cents: 386, + units: 1 + ) + + expect(term_invoice).to have_attributes( + fees_amount_cents: 8929, # 1929 + 2000 + 5000 + taxes_amount_cents: 1786, + credit_notes_amount_cents: 643, + total_amount_cents: 10_072 + ) + end + end + end +end diff --git a/spec/scenarios/subscriptions/activation_spec.rb b/spec/scenarios/subscriptions/activation_spec.rb new file mode 100644 index 0000000..60cdec9 --- /dev/null +++ b/spec/scenarios/subscriptions/activation_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Subscriptions Activation Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + + let(:timezone) { "America/Bogota" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false + ) + end + + # subscription_at must be on a different date than creation_time in the customer's timezone (America/Bogota, UTC-5) + # creation_time: 2023-08-24 00:07 UTC → 2023-08-23 19:07 Bogota + # subscription_at: 2023-08-25 10:00 UTC → 2023-08-25 05:00 Bogota (different day) + let(:creation_time) { DateTime.new(2023, 8, 24, 0, 7) } + let(:subscription_at) { DateTime.new(2023, 8, 25, 10, 0) } + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge } + + it "activates the subscription when it reaches its subscription date" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_pending + end + + travel_to(subscription_at) do + Subscriptions::ActivateAllPendingService.call!(timestamp: Time.current.to_i) + + expect(subscription.reload).to be_active + end + end + + context "with activation rules on a future-dated subscription" do + let(:pay_in_advance_plan) do + create(:plan, organization:, interval: "monthly", pay_in_advance: true, amount_cents: 1000) + end + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) { create(:stripe_customer, payment_provider: stripe_provider, customer:) } + let(:payment_method) { create(:payment_method, customer:) } + + before do + create(:tax, :applied_to_billing_entity, organization:, rate: 0) + customer.update!(payment_provider: :stripe, payment_provider_code: stripe_provider.code) + stripe_customer + payment_method + + allow_any_instance_of(::PaymentProviders::Stripe::Payments::CreateService) # rubocop:disable RSpec/AnyInstance + .to receive(:create_payment_intent) + .and_return( + Stripe::PaymentIntent.construct_from( + id: "pi_#{SecureRandom.hex(12)}", + status: "processing", + amount: 1000, + currency: "eur" + ) + ) + end + + it "stores rules as inactive, then evaluates and gates on activation date" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: "gated-future-sub", + plan_code: pay_in_advance_plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601, + activation_rules: [{type: "payment", timeout_hours: 48}] + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_pending + expect(subscription.activation_rules.sole).to be_inactive + end + + travel_to(subscription_at) do + Subscriptions::ActivateAllPendingService.call!(timestamp: Time.current.to_i) + perform_all_enqueued_jobs + + subscription.reload + expect(subscription).to be_incomplete + expect(subscription.activation_rules.sole).to be_pending + end + end + end + + it "generates a pay in advance invoice for the fixed charge" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_pending + end + + travel_to(subscription_at) do + Subscriptions::ActivateAllPendingService.call!(timestamp: Time.current.to_i) + perform_enqueued_jobs + + expect(subscription.reload).to be_active + + expect(subscription.invoices.count).to eq(1) + expect(subscription.invoices.first.fees.count).to eq(1) + expect(subscription.invoices.first.fees.first.fixed_charge.id).to eq(fixed_charge.id) + end + end +end diff --git a/spec/scenarios/subscriptions/billing_boundaries_spec.rb b/spec/scenarios/subscriptions/billing_boundaries_spec.rb new file mode 100644 index 0000000..917b4b8 --- /dev/null +++ b/spec/scenarios/subscriptions/billing_boundaries_spec.rb @@ -0,0 +1,1301 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Boundaries Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan_interval) { :monthly } + let(:plan_monthly_charges) { false } + let(:plan_monthly_fixed_charges) { false } + let(:plan_in_advance) { false } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + + let(:billing_time) { "anniversary" } + + let(:plan) do + create( + :plan, + organization:, + interval: plan_interval, + pay_in_advance: plan_in_advance, + bill_charges_monthly: plan_monthly_charges, + bill_fixed_charges_monthly: plan_monthly_fixed_charges + ) + end + + before do + charge + fixed_charge + end + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-03-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + + # April billing + travel_to(Time.zone.parse("2024-04-30T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-03-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-04-29T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-03-31T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-03-31T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + end + + context "with plans in advance and all charges are in arrears" do + let(:plan_in_advance) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-30T00:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + expect(subscription.invoices.count).to eq(1) + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-30T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-03-29T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-30T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-30T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # March billing + travel_to(Time.zone.parse("2024-03-30T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-03-30T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-04-29T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-03-29T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-29T23:59:59Z") + + # April billing + travel_to(Time.zone.parse("2024-04-30T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-04-30T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-05-29T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-03-30T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-03-30T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + end + end + + context "when interval is yearly" do + let(:plan_interval) { :yearly } + + it "creates invoices once a year" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # April billing + travel_to(Time.zone.parse("2024-04-30T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # next year billing + travel_to(Time.zone.parse("2025-01-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + end + + context "when charges are billed monthly" do + let(:plan_monthly_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-01T00:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + # when only charges are billed monthly, fixed charge boundaries are nil + expect(invoice_subscription.fixed_charges_from_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_to_datetime).to eq(nil) + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_to_datetime).to eq(nil) + + # April billing + travel_to(Time.zone.parse("2024-04-30T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-03-31T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_to_datetime).to eq(nil) + + # next year billing + travel_to(Time.zone.parse("2025-01-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-12-31T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + end + end + + context "when fixed charges are billed monthly" do + let(:plan_monthly_fixed_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-01T00:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to eq(nil) + expect(invoice_subscription.charges_to_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to eq(nil) + expect(invoice_subscription.charges_to_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + + # April billing + travel_to(Time.zone.parse("2024-04-30T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to eq(nil) + expect(invoice_subscription.charges_to_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-03-31T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + + # next year billing + travel_to(Time.zone.parse("2025-01-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-12-31T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + end + end + + context "when both charges and fixed charges are billed monthly" do + let(:plan_monthly_charges) { true } + let(:plan_monthly_fixed_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-01T00:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + + # next year billing + travel_to(Time.zone.parse("2025-01-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-12-31T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-12-31T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + end + end + + context "when plan is in advance and charges are billed monthly" do + let(:plan_in_advance) { true } + let(:plan_monthly_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + expect(subscription.invoices.count).to eq(1) + + # First invoice - subscription creation (pay in advance) + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-01-31T01:00:00Z") + + travel_to(Time.zone.parse("2024-02-01T00:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + + # February billing - second invoice (charges only) + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_to_datetime).to eq(nil) + + # next year billing + travel_to(Time.zone.parse("2025-01-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2025-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2026-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-12-31T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + end + end + + context "when plan is in advance and fixed charges are billed monthly" do + let(:plan_in_advance) { true } + let(:plan_monthly_fixed_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + expect(subscription.invoices.count).to eq(1) + + # First invoice - subscription creation (pay in advance) + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-01-31T01:00:00Z") + + # February billing - second invoice (fixed charges only) + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to eq(nil) + expect(invoice_subscription.charges_to_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # next year billing + travel_to(Time.zone.parse("2025-01-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2025-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2026-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-12-31T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + end + end + end + + context "when interval is semiannual" do + let(:plan_interval) { :semiannual } + + it "creates invoices twice a year" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # April billing + travel_to(Time.zone.parse("2024-04-30T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # May billing + travel_to(Time.zone.parse("2024-05-31T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # June billing + travel_to(Time.zone.parse("2024-06-30T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # July billing + travel_to(Time.zone.parse("2024-07-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + + # August billing + travel_to(Time.zone.parse("2024-08-30T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # Next year Jan billing + travel_to(Time.zone.parse("2025-01-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-07-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-07-31T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-07-31T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2025-01-30T23:59:59Z") + end + + context "when charges are billed monthly" do + let(:plan_monthly_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-01T00:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + # TODO: only in semiannual these dates are not following the behaviour where previous billing period is provided. + # expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to eq(nil) + # TODO: only in semiannual these dates are not following the behaviour where previous billing period is provided. + # expect(invoice_subscription.fixed_charges_to_datetime).to eq(nil) + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + # TODO: only in semiannual these dates are not following the behaviour where previous billing period is provided. + # expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to eq(nil) + # TODO: only in semiannual these dates are not following the behaviour where previous billing period is provided. + # expect(invoice_subscription.fixed_charges_to_datetime).to eq(nil) + + # July billing + travel_to(Time.zone.parse("2024-07-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-06-30T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + end + end + + context "when fixed charges are billed monthly" do + let(:plan_monthly_fixed_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.charges_from_datetime).to eq(nil) + expect(invoice_subscription.charges_to_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.charges_from_datetime).to eq(nil) + expect(invoice_subscription.charges_to_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + + # July billing + travel_to(Time.zone.parse("2024-07-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-06-30T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + end + end + + context "when both charges and fixed charges are billed monthly" do + let(:plan_monthly_charges) { true } + let(:plan_monthly_fixed_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + travel_to(Time.zone.parse("2024-02-01T00:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-30T23:59:59Z") + + # July billing + travel_to(Time.zone.parse("2024-07-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-06-30T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-06-30T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + end + end + + context "when plan is in advance and charges are billed monthly" do + let(:plan_in_advance) { true } + let(:plan_monthly_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + expect(subscription.invoices.count).to eq(1) + + # First invoice - subscription creation (pay in advance) + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-01-31T01:00:00Z") + + travel_to(Time.zone.parse("2024-02-01T00:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 10 + } + } + ) + end + + # February billing - second invoice (charges only) + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_to_datetime).to eq(nil) + + # July billing (6 months after subscription started) + travel_to(Time.zone.parse("2024-07-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-07-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-06-30T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + end + end + + context "when plan is in advance and fixed charges are billed monthly" do + let(:plan_in_advance) { true } + let(:plan_monthly_fixed_charges) { true } + + it "creates invoices" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + expect(subscription.invoices.count).to eq(1) + + # First invoice - subscription creation (pay in advance) + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-07-30T23:59:59Z") + + # February billing - second invoice (fixed charges only) + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-02-29T00:00:00Z") + expect(invoice_subscription.charges_from_datetime).to eq(nil) + expect(invoice_subscription.charges_to_datetime).to eq(nil) + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-28T23:59:59Z") + + # July billing (6 months after subscription started) + travel_to(Time.zone.parse("2024-07-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-07-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2025-01-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-06-30T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + end + end + end + + context "when interval is quarterly" do + let(:plan_interval) { :quarterly } + + it "creates invoices four times a year" do + travel_to(Time.zone.parse("2024-01-31T01:00:00Z")) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time:} + ) + end + + subscription = customer.subscriptions.first + + # February billing + travel_to(Time.zone.parse("2024-02-29T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # March billing + travel_to(Time.zone.parse("2024-03-31T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # April billing + travel_to(Time.zone.parse("2024-04-30T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-31T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-04-29T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-31T01:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-04-29T23:59:59Z") + + # May billing + travel_to(Time.zone.parse("2024-05-31T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # June billing + travel_to(Time.zone.parse("2024-06-30T02:00:00Z")) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + + # July billing + travel_to(Time.zone.parse("2024-07-31T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.by(1) + end + + invoice = subscription.invoices.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-04-30T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-04-30T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-04-30T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-07-30T23:59:59Z") + end + # NOTE: there are no quarterly with charges monthly! + end + + context "with progressive billing thresholds", :premium, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, premium_integrations: ["progressive_billing"]) } + let(:plan_interval) { :monthly } + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "amount") } + let(:progressive_charge) do + create(:standard_charge, plan:, billable_metric:, properties: {"amount" => "2"}) + end + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 20_000) } + + before do + progressive_charge + usage_threshold + end + + it "creates invoices with correct boundaries when progressive billing thresholds are crossed" do + # Start subscription on Jan 15 + travel_to(Time.zone.parse("2024-01-15T10:00:00Z")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + subscription = customer.subscriptions.first + + # February billing - regular billing (no progressive billing yet) + travel_to(Time.zone.parse("2024-02-15T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.subscription.count }.by(1) + end + + invoice = subscription.invoices.subscription.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-15T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-02-14T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-15T10:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-14T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-15T10:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-14T23:59:59Z") + + progressive_invoices_before = Invoice.progressive_billing.count + + # Send enough usage to cross threshold (progressive billing invoice) + travel_to(Time.zone.parse("2024-02-20T10:00:00Z")) do + ingest_event(subscription, billable_metric, 100) + perform_all_enqueued_jobs + expect(Invoice.progressive_billing.count).to eq(progressive_invoices_before + 1) + end + + progressive_invoice = Invoice.progressive_billing.order(created_at: :desc).first + # Progressive billing invoice should have been created + expect(progressive_invoice.total_amount_cents).to be > 0 + progressive_invoice_subscription = progressive_invoice.invoice_subscriptions.first + expect(progressive_invoice_subscription.from_datetime).to match_datetime("2024-02-15T00:00:00Z") + expect(progressive_invoice_subscription.to_datetime).to match_datetime("2024-03-14T23:59:59Z") + expect(progressive_invoice_subscription.charges_from_datetime).to match_datetime("2024-02-15T00:00:00Z") + expect(progressive_invoice_subscription.charges_to_datetime).to match_datetime("2024-03-14T23:59:59Z") + expect(progressive_invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-15T00:00:00Z") + expect(progressive_invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-14T23:59:59Z") + + # March billing - end of period with progressive billing credits + travel_to(Time.zone.parse("2024-03-15T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.subscription.count }.by(1) + end + + invoice = subscription.invoices.subscription.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-02-15T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-03-14T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-15T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-03-14T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-15T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-03-14T23:59:59Z") + + # Verify progressive billing credits were applied (amount should match the progressive invoice) + expect(invoice.progressive_billing_credit_amount_cents).to eq(progressive_invoice.total_amount_cents) + end + end + + context "with charges paid in advance" do + let(:plan_interval) { :monthly } + let(:billing_time) { "calendar" } + + let(:sum_billable_metric) { create(:sum_billable_metric, organization:, field_name: "amount") } + let(:recurring_billable_metric) { create(:sum_billable_metric, :recurring, organization:, field_name: "seats") } + + # Charge combinations: + # 1. pay_in_advance: false, recurring: false (default arrears charge) + let(:arrears_charge) do + create(:standard_charge, plan:, billable_metric: sum_billable_metric, properties: {amount: "1"}) + end + + # 2. pay_in_advance: true, recurring: false (advance charge, not recurring) + let(:advance_charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: true, properties: {amount: "2"}) + end + + # 3. pay_in_advance: false, recurring: true (arrears recurring charge) + let(:arrears_recurring_charge) do + create(:standard_charge, plan:, billable_metric: recurring_billable_metric, properties: {amount: "5"}) + end + + # 4. pay_in_advance: true, recurring: true (advance recurring charge) + let(:advance_recurring_charge) do + create( + :standard_charge, + :pay_in_advance, + plan:, + billable_metric: recurring_billable_metric, + invoiceable: true, + properties: {amount: "10"} + ) + end + + before do + arrears_charge + advance_charge + arrears_recurring_charge + advance_recurring_charge + end + + it "creates invoices with correct boundaries for different charge types" do + # Start subscription on Jan 1 + travel_to(Time.zone.parse("2024-01-01T10:00:00Z")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + subscription = customer.subscriptions.first + + # Send usage for arrears charge (sum_billable_metric) + travel_to(Time.zone.parse("2024-01-10T10:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: sum_billable_metric.code, + properties: {sum_billable_metric.field_name => 100} + } + ) + end + + # Send usage for pay_in_advance charge + travel_to(Time.zone.parse("2024-01-15T10:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: {billable_metric.field_name => 50} + } + ) + end + + # Send usage for recurring billable metrics + travel_to(Time.zone.parse("2024-01-20T10:00:00Z")) do + create_event( + { + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + code: recurring_billable_metric.code, + properties: {recurring_billable_metric.field_name => 10} + } + ) + end + + expect(subscription.invoices.subscription.count).to eq(2) # pay in advance events + + # February billing - end of January period + travel_to(Time.zone.parse("2024-02-01T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.subscription.count }.by(1) + end + + invoice = subscription.invoices.subscription.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + # Verify boundaries are set correctly for the billing period invoice + expect(invoice_subscription.from_datetime).to match_datetime("2024-01-01T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-01-31T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-01-01T10:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-01-31T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-01-01T10:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-01-31T23:59:59Z") + + # Verify the invoice has fees for charges + expect(invoice.fees.charge.count).to be >= 1 + + # March billing - second period + travel_to(Time.zone.parse("2024-03-01T02:00:00Z")) do + expect { perform_billing }.to change { subscription.reload.invoices.subscription.count }.by(1) + end + + invoice = subscription.invoices.subscription.order(created_at: :desc).first + invoice_subscription = invoice.invoice_subscriptions.first + + expect(invoice_subscription.from_datetime).to match_datetime("2024-02-01T00:00:00Z") + expect(invoice_subscription.to_datetime).to match_datetime("2024-02-29T23:59:59Z") + expect(invoice_subscription.charges_from_datetime).to match_datetime("2024-02-01T00:00:00Z") + expect(invoice_subscription.charges_to_datetime).to match_datetime("2024-02-29T23:59:59Z") + expect(invoice_subscription.fixed_charges_from_datetime).to match_datetime("2024-02-01T00:00:00Z") + expect(invoice_subscription.fixed_charges_to_datetime).to match_datetime("2024-02-29T23:59:59Z") + end + end +end diff --git a/spec/scenarios/subscriptions/billing_spec.rb b/spec/scenarios/subscriptions/billing_spec.rb new file mode 100644 index 0000000..b9737c9 --- /dev/null +++ b/spec/scenarios/subscriptions/billing_spec.rb @@ -0,0 +1,1125 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Billing Subscriptions Scenario" do + let(:organization) { create(:organization, webhook_url: nil) } + + let(:timezone) { "UTC" } + let(:customer) { create(:customer, organization:, timezone:, currency: "GBP") } + + let(:plan_monthly_charges) { false } + let(:plan) do + create( + :plan, + organization:, + amount_cents: 5_000_000, + amount_currency: "GBP", + interval: plan_interval, + pay_in_advance: false, + bill_charges_monthly: plan_monthly_charges + ) + end + + shared_examples "a subscription billing without duplicated invoices" do + it "creates an invoice" do + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + subscription = customer.subscriptions.first + + # Does not create invoices before the billing day + before_billing_times.each do |time| + travel_to(time) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + end + + # Create only one invoice on billing day + expect do + billing_times.each do |time| + travel_to(time) do + perform_billing + end + end + end.to change { subscription.reload.invoices.count }.from(0).to(1) + + # Does not create invoices after the billing day + after_billing_times.each do |time| + travel_to(time) do + expect { perform_billing }.not_to change { subscription.reload.invoices.count } + end + end + end + end + + shared_examples "a subscription billing on every billing day" do + it "creates an invoice" do + # Create the subscription + travel_to(subscription_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: + } + ) + end + + subscription = customer.subscriptions.first + + # Create an invoice on each billing day + expect do + billing_times.each do |time| + travel_to(time) do + perform_billing + end + end + end.to change { subscription.reload.invoices.count }.from(0).to(billing_times.count) + end + end + + context "with weekly plan" do + let(:plan_interval) { "weekly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + + let(:before_billing_times) { [DateTime.new(2023, 2, 5)] } + let(:billing_times) { [DateTime.new(2023, 2, 6, 1), DateTime.new(2023, 2, 6, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 2, 7, 1)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Asia/Kolkata" } + let(:subscription_time) { DateTime.new(2023, 2, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 2, 12, 18, 0) # 12th of Feb 18:00 UTC - 12th of Feb 23:30 Asia/Kolkata + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 2, 12, 19, 0), # 12th of Feb 19:00 UTC - 13th of Feb 00:30 Asia/Kolkata + DateTime.new(2023, 2, 13, 0, 0), # 13th of Feb 00:00 UTC - 13th of Feb 05:30 Asia/Kolkata + DateTime.new(2023, 2, 13, 18, 0) # 13th of Feb 18:00 UTC - 13th of Feb 23:30 Asia/Kolkata + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 2, 13, 19, 0)] # 13th of Feb 19:00 UTC - 14th of Feb 00:30 Asia/Kolkata + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2023, 2, 1, 6, 10) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 2, 13, 4, 0) # 13th of Feb 04:00 UTC - 12th of Feb 23:00 America/Bogota + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 2, 13, 5, 0), # 13th of Feb 05:00 UTC - 13th of Feb 00:00 America/Bogota + DateTime.new(2023, 2, 13, 6, 0), # 13th of Feb 06:00 UTC - 13th of Feb 1:00 America/Bogota + DateTime.new(2023, 2, 13, 3, 0), # 13th of Feb 23:00 UTC - 13th of Feb 18:00 America/Bogota + DateTime.new(2023, 2, 14, 4, 0) # 14th of Feb 04:00 UTC - 13th of Feb 23:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 2, 14, 5, 0)] # 14th of Feb 05:00 UTC - 14th of Feb 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + + let(:before_billing_times) { [DateTime.new(2023, 2, 14)] } + let(:billing_times) { [DateTime.new(2023, 2, 15, 1), DateTime.new(2023, 2, 15, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 2, 16, 1)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Europe/Paris" } + let(:subscription_time) { DateTime.new(2023, 5, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 5, 22, 21, 0) # 22sd of May 21:00 UTC - 22sd of May 23:00 Europe/Paris + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 5, 22, 22, 0), # 22sd of May 22:00 UTC - 23rd of May 00:00 Europe/Paris + DateTime.new(2023, 5, 23, 20, 0), # 23rd of May 20:00 UTC - 23rd of May 22:00 Europe/Paris + DateTime.new(2023, 5, 23, 21, 0), # 23rd of May 21:00 UTC - 23rd of May 23:00 Europe/Paris + DateTime.new(2023, 5, 23, 22, 10) # 23rd of May 22:59 UTC - 24th of May 00:59 Europe/Paris + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 5, 24, 0, 10)] # 24th of May 00:10 UTC - 24th of May 02:10 Europe/Paris + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2023, 2, 1, 6, 10) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 2, 15, 4, 0) # 15th of Feb 04:00 UTC - 14th of Feb 23:00 America/Bogota + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 2, 15, 5, 0), # 15th of Feb 05:00 UTC - 15th of Feb 00:00 America/Bogota + DateTime.new(2023, 2, 15, 6, 0), # 15th of Feb 06:00 UTC - 15th of Feb 1:00 America/Bogota + DateTime.new(2023, 2, 15, 3, 0), # 15th of Feb 23:00 UTC - 15th of Feb 18:00 America/Bogota + DateTime.new(2023, 2, 16, 4, 0) # 16th of Feb 04:00 UTC - 15th of Feb 23:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 2, 16, 5, 0)] # 16th of Feb 05:00 UTC - 16th of Feb 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + end + + context "with monthly plan" do + let(:plan_interval) { "monthly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2023, 2, 28)] } + let(:billing_times) { [DateTime.new(2023, 3, 1, 1), DateTime.new(2023, 3, 1, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 3, 2)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Asia/Kolkata" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + + let(:before_billing_times) do + [DateTime.new(2023, 2, 28, 18, 0)] # 28 of Feb 18:00 UTC - 28 Feb 23:30 Asia/Kolkata + end + let(:billing_times) do + [ + DateTime.new(2023, 2, 28, 19, 0), # 28 of Feb 19:00 UTC - 1st of Mar 00:30 Asia/Kolkata + DateTime.new(2023, 3, 1, 0, 0), # 1st of Mar 00:00 UTC - 1st of Mar 05:30 Asia/Kolkata + DateTime.new(2023, 3, 1, 18, 0) # 1st of Mar 18:00 UTC - 1st of Mar 23:30 Asia/Kolkata + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 3, 1, 19, 0), # 1st of Mar 19:00 UTC - 2nd of Mar 00:30 Asia/Kolkata + DateTime.new(2023, 3, 2, 0, 0) # 2nd of Mar 00:00 UTC - 2nd of Mar 05:30 Asia/Kolkata + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2023, 2, 2, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 3, 1, 0, 0)] # 1st of Mar 00:00 UTC - 28th of Feb 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 1, 5, 0), # 1st of Mar 05:00 UTC - 1st of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 1, 6, 0), # 1st of Mar 06:00 UTC - 1st of Mar 01:00 America/Bogota + DateTime.new(2023, 3, 1, 0, 0), # 2nd of Mar 00:00 UTC - 1st of Mar 19:00 America/Bogota + DateTime.new(2023, 3, 2, 4, 0) # 2nd of Mar 04:00 UTC - 1st of Mar 23:00 America/Bogota + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 3, 2, 5, 0), # 2nd of Mar 05:00 UTC - 2nd of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 3, 5, 0) # 3th of Mar 05:00 UTC - 3th of Mar 00:00 America/Bogota + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2023, 3, 3)] } + let(:billing_times) { [DateTime.new(2023, 3, 4, 1), DateTime.new(2023, 3, 4, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 3, 5)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "when subscription started on a 31st" do + let(:subscription_time) { DateTime.new(2023, 3, 31) } + + let(:before_billing_times) { [DateTime.new(2023, 4, 29)] } + let(:billing_times) { [DateTime.new(2023, 4, 30)] } + let(:after_billing_times) { [DateTime.new(2023, 5, 1)] } + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with anniversary on a 31st" do + let(:billing_times) do + [ + DateTime.new(2023, 1, 31, 1), + DateTime.new(2023, 2, 28, 1), + DateTime.new(2023, 3, 31, 1), + DateTime.new(2023, 4, 30, 1), + DateTime.new(2023, 5, 31, 1), + DateTime.new(2023, 6, 30, 1), + DateTime.new(2023, 7, 31, 2), + DateTime.new(2023, 8, 31, 2), + DateTime.new(2023, 9, 30, 2), + DateTime.new(2023, 10, 31, 2), + DateTime.new(2023, 11, 30, 2), + DateTime.new(2023, 12, 31, 2), + DateTime.new(2024, 1, 31, 2), + DateTime.new(2024, 2, 29, 2), + DateTime.new(2024, 3, 31, 2) + ] + end + + let(:subscription_time) { DateTime.new(2022, 12, 31) } + + it_behaves_like "a subscription billing on every billing day" + end + + context "with anniversary on a 30" do + let(:billing_times) do + [ + DateTime.new(2023, 1, 30, 1), + DateTime.new(2023, 2, 28, 1), + DateTime.new(2023, 3, 30, 1), + DateTime.new(2023, 4, 30, 1), + DateTime.new(2023, 5, 30, 1), + DateTime.new(2023, 6, 30, 1), + DateTime.new(2023, 7, 30, 2), + DateTime.new(2023, 8, 30, 2), + DateTime.new(2023, 9, 30, 2), + DateTime.new(2023, 10, 30, 2), + DateTime.new(2023, 11, 30, 2), + DateTime.new(2023, 12, 30, 2), + DateTime.new(2024, 2, 29, 2), + DateTime.new(2024, 3, 30, 2) + ] + end + + let(:subscription_time) { DateTime.new(2022, 4, 30) } + + it_behaves_like "a subscription billing on every billing day" + end + + context "with anniversary on a 28 of february" do + let(:billing_times) do + [ + DateTime.new(2023, 1, 28, 1), + DateTime.new(2023, 2, 28, 1), + DateTime.new(2023, 3, 28, 1), + DateTime.new(2023, 4, 28, 1), + DateTime.new(2023, 5, 28, 1), + DateTime.new(2023, 6, 28, 1), + DateTime.new(2023, 7, 28, 2), + DateTime.new(2023, 8, 28, 2), + DateTime.new(2023, 9, 28, 2), + DateTime.new(2023, 10, 28, 2), + DateTime.new(2023, 11, 28, 2), + DateTime.new(2023, 12, 28, 2) + ] + end + + let(:subscription_time) { DateTime.new(2022, 2, 28) } + + it_behaves_like "a subscription billing on every billing day" + end + + context "with UTC+ timezone" do + let(:timezone) { "Asia/Kolkata" } + let(:subscription_time) { DateTime.new(2023, 2, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 3, 1, 18, 0) # 1st of Mar 18:00 UTC - 1st of Mar 23:30 Asia/Kolkata + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 1, 19, 0), # 1st of Mar 19:00 UTC - 2nd of Mar 00:30 Asia/Kolkata + DateTime.new(2023, 3, 2, 0, 0), # 2nd of Mar 00:00 UTC - 2nd of Mar 05:30 Asia/Kolkata + DateTime.new(2023, 3, 2, 18, 0) # 2nd of Mar 18:00 UTC - 2nd of Mar 23:30 Asia/Kolkata + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 3, 2, 19, 0)] # 2nd of Mar 19:00 UTC - 3rd of Mar 00:30 Asia/Kolkata + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2023, 2, 2, 5) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 3, 1, 23, 0), # 1st of Mar 23:00 UTC - 1st of Mar 18:00 America/Bogota + DateTime.new(2023, 3, 2, 4, 0) # 2nd of Mar 04:00 UTC - 1st of Mar 23:00 America/Bogota + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 2, 6, 0), # 2nd of Mar 06:00 UTC - 2nd of Mar 01:00 America/Bogota + DateTime.new(2023, 3, 1, 7, 0), # 2nd of Mar 07:00 UTC - 2nd of Mar 02:00 America/Bogota + DateTime.new(2023, 3, 3, 0, 0) # 3rd of Mar 00:00 UTC - 2nd of Mar 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 3, 3, 5, 0)] # 3rd of Mar 05:00 UTC - 3rd of Mar 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + end + + context "with quarterly plan" do + let(:plan_interval) { "quarterly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2023, 3, 1)] } + let(:billing_times) { [DateTime.new(2023, 4, 1, 1), DateTime.new(2023, 4, 1, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 5, 1)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Asia/Kolkata" } + let(:subscription_time) { DateTime.new(2023, 2, 1) } + + let(:before_billing_times) do + [DateTime.new(2023, 3, 31, 18, 0)] # 31 of Mar 18:00 UTC - 31 Mar 23:30 Asia/Kolkata + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 31, 19, 0), # 31 of Mar 19:00 UTC - 1st of Apr 00:30 Asia/Kolkata + DateTime.new(2023, 4, 1, 0, 0), # 1st of Apr 00:00 UTC - 1st of Apr 05:30 Asia/Kolkata + DateTime.new(2023, 4, 1, 18, 0) # 1st of Apr 18:00 UTC - 1st of Apr 23:30 Asia/Kolkata + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 4, 1, 19, 0), # 1st of Apr 19:00 UTC - 2nd of Apr 00:30 Asia/Kolkata + DateTime.new(2023, 4, 2, 0, 0) # 2nd of Apr 00:00 UTC - 2nd of Apr 05:30 Asia/Kolkata + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2023, 2, 2, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 4, 1, 0, 0)] # 1st of Apr 00:00 UTC - 31th of Mar 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 4, 1, 5, 0), # 1st of Apr 05:00 UTC - 1st of Apr 00:00 America/Bogota + DateTime.new(2023, 4, 1, 6, 0), # 1st of Apr 06:00 UTC - 1st of Apr 01:00 America/Bogota + DateTime.new(2023, 4, 2, 0, 0), # 2nd of Apr 00:00 UTC - 1st of Apr 19:00 America/Bogota + DateTime.new(2023, 4, 2, 4, 0) # 2nd of Apr 04:00 UTC - 1st of Apr 23:00 America/Bogota + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 4, 2, 5, 0), # 2nd of Apr 05:00 UTC - 2nd of Apr 00:00 America/Bogota + DateTime.new(2023, 4, 3, 5, 0) # 3th of Apr 05:00 UTC - 3th of Apr 00:00 America/Bogota + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2023, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2023, 3, 4)] } + let(:billing_times) { [DateTime.new(2023, 5, 4, 1), DateTime.new(2023, 5, 4, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 5, 5)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "when subscription started on a 31st" do + let(:subscription_time) { DateTime.new(2023, 3, 31) } + + let(:before_billing_times) { [DateTime.new(2023, 6, 29)] } + let(:billing_times) { [DateTime.new(2023, 6, 30)] } + let(:after_billing_times) { [DateTime.new(2023, 7, 1)] } + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with anniversary on a 31st" do + let(:billing_times) do + [ + DateTime.new(2023, 3, 31, 1), + DateTime.new(2023, 6, 30, 1), + DateTime.new(2023, 9, 30, 2), + DateTime.new(2023, 12, 31, 2) + ] + end + + let(:subscription_time) { DateTime.new(2022, 12, 31) } + + it_behaves_like "a subscription billing on every billing day" + end + + context "with anniversary on a 30" do + let(:billing_times) do + [ + DateTime.new(2023, 1, 30, 1), + DateTime.new(2023, 4, 30, 1), + DateTime.new(2023, 7, 30, 2), + DateTime.new(2023, 10, 30, 2) + ] + end + + let(:subscription_time) { DateTime.new(2022, 4, 30) } + + it_behaves_like "a subscription billing on every billing day" + end + + context "with anniversary on a 28 of february" do + let(:billing_times) do + [ + DateTime.new(2023, 2, 28, 1), + DateTime.new(2023, 5, 28, 1), + DateTime.new(2023, 8, 28, 2), + DateTime.new(2023, 11, 28, 2) + ] + end + + let(:subscription_time) { DateTime.new(2022, 2, 28) } + + it_behaves_like "a subscription billing on every billing day" + end + + context "with UTC+ timezone" do + let(:timezone) { "Asia/Kolkata" } + let(:subscription_time) { DateTime.new(2023, 2, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 5, 1, 18, 0) # 1st of May 18:00 UTC - 1st of May 23:30 Asia/Kolkata + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 5, 1, 19, 0), # 1st of May 19:00 UTC - 2nd of May 00:30 Asia/Kolkata + DateTime.new(2023, 5, 2, 0, 0), # 2nd of May 00:00 UTC - 2nd of May 05:30 Asia/Kolkata + DateTime.new(2023, 5, 2, 18, 0) # 2nd of May 18:00 UTC - 2nd of May 23:30 Asia/Kolkata + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 5, 2, 19, 0)] # 2nd of May 19:00 UTC - 3rd of May 00:30 Asia/Kolkata + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2023, 2, 2, 5) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 5, 1, 23, 0), # 1st of May 23:00 UTC - 1st of May 18:00 America/Bogota + DateTime.new(2023, 5, 2, 4, 0) # 2nd of May 04:00 UTC - 1st of May 23:00 America/Bogota + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 5, 2, 6, 0), # 2nd of May 06:00 UTC - 2nd of May 01:00 America/Bogota + DateTime.new(2023, 5, 1, 7, 0), # 2nd of May 07:00 UTC - 2nd of May 02:00 America/Bogota + DateTime.new(2023, 5, 3, 0, 0) # 3rd of May 00:00 UTC - 2nd of May 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 5, 3, 5, 0)] # 3rd of Mar 05:00 UTC - 3rd of Mar 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + end + + context "with yearly plan" do + let(:plan_interval) { "yearly" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2022, 2, 1) } + + let(:before_billing_times) { [DateTime.new(2022, 12, 31)] } + let(:billing_times) { [DateTime.new(2023, 1, 1, 1), DateTime.new(2023, 1, 1, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 1, 2)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Europe/Paris" } + let(:subscription_time) { DateTime.new(2022, 4, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2022, 12, 31, 21, 0) # 31th of Dec 21:00 UTC - 31th of Dec 23:00 Europe/Paris + ] + end + let(:billing_times) do + [ + DateTime.new(2022, 12, 31, 23, 0), # 31th of Dec 23:00 UTC - 1st of Jan 01:00 Europe/Paris + DateTime.new(2023, 1, 1, 20, 0), # 1st of Jan 20:00 UTC - 1st of Jan 22:00 Europe/Paris + DateTime.new(2023, 1, 1, 21, 0) # 1st of Jan 21:00 UTC - 1st of Jan 23:00 Europe/Paris + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 1, 1, 22, 59), # 1st of Jan 22:59 UTC - 2nd of Jan 00:59 Europe/Paris + DateTime.new(2023, 1, 2, 0, 10) # 2nd of Jan 00:10 UTC - 2nd of Jan 02:10 Europe/Paris + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 4, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 1, 1, 0, 0)] # 1st of Jan 00:00 UTC - 31th of Dec 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 1, 1, 5, 0), # 1st of Jan 05:00 UTC - 1st of Jan 00:00 America/Bogota + DateTime.new(2023, 1, 1, 6, 0), # 1st of Jan 06:00 UTC - 1st of Jan 01:00 America/Bogota + DateTime.new(2023, 1, 2, 0, 0) # 2nd of Jan 00:00 UTC - 1st of Jan 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 1, 2, 5, 0)] # 2nd of Jan 05:00 UTC - 2nd of Jan 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2022, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2023, 1, 1), DateTime.new(2023, 2, 3)] } + let(:billing_times) { [DateTime.new(2023, 2, 4, 1), DateTime.new(2023, 2, 4, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 2, 5), DateTime.new(2023, 3, 4)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "when subscription started on a 29th of February" do + let(:subscription_time) { DateTime.new(2020, 2, 29) } + + let(:before_billing_times) { [DateTime.new(2023, 1, 28), DateTime.new(2023, 2, 27)] } + let(:billing_times) { [DateTime.new(2023, 2, 28, 1), DateTime.new(2023, 2, 28, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 3, 1), DateTime.new(2023, 4, 29)] } + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC+ timezone" do + let(:timezone) { "Europe/Paris" } + let(:subscription_time) { DateTime.new(2022, 4, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 4, 1, 21, 0) # 1st of April 21:00 UTC - 1st of April 23:00 Europe/Paris + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 4, 2, 23, 0), # 1st of April 23:00 UTC - 2nd of April 01:00 Europe/Paris + DateTime.new(2023, 4, 2, 20, 0), # 2nd of April 20:00 UTC - 2nd of April 22:00 Europe/Paris + DateTime.new(2023, 4, 2, 21, 0), # 2nd of April 21:00 UTC - 2nd of April 23:00 Europe/Paris + DateTime.new(2023, 4, 2, 22, 10) # 2nd of April 22:59 UTC - 3rd of April 00:59 Europe/Paris + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 4, 3, 0, 10) # 3rd of April 00:10 UTC - 3rd of April 02:10 Europe/Paris + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 4, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 2, 4, 0, 0)] # 4th of Feb 00:00 UTC - 3rd Feb 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 2, 4, 5, 0), # 4th of Feb 05:00 UTC - 4th of Feb 00:00 America/Bogota + DateTime.new(2023, 2, 4, 6, 0), # 4th of Feb 06:00 UTC - 4th of Feb 01:00 America/Bogota + DateTime.new(2023, 2, 5, 0, 0) # 5th of Feb 00:00 UTC - 4th of Feb 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 2, 5, 5, 0)] # 5th of Feb 05:00 UTC - 5th of Feb 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + end + + context "with semiannual plan" do + let(:plan_interval) { "semiannual" } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + let(:subscription_time) { DateTime.new(2022, 2, 1) } + + let(:before_billing_times) { [DateTime.new(2022, 6, 30)] } + let(:billing_times) { [DateTime.new(2022, 7, 1, 1), DateTime.new(2022, 7, 1, 2)] } + let(:after_billing_times) { [DateTime.new(2022, 7, 2)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Europe/Paris" } + let(:subscription_time) { DateTime.new(2022, 4, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2022, 6, 30, 21, 0) # 30th of Jun 21:00 UTC - 30th of Jun 23:00 Europe/Paris + ] + end + let(:billing_times) do + [ + DateTime.new(2022, 6, 30, 23, 0), # 30th of Jun 23:00 UTC - 1st of Jul 01:00 Europe/Paris + DateTime.new(2022, 7, 1, 20, 0), # 1st of Jul 20:00 UTC - 1st of Jul 22:00 Europe/Paris + DateTime.new(2022, 7, 1, 21, 0) # 1st of Jul 21:00 UTC - 1st of Jul 23:00 Europe/Paris + ] + end + let(:after_billing_times) do + [ + DateTime.new(2022, 7, 1, 22, 59), # 1st of Jul 22:59 UTC - 2nd of Jul 00:59 Europe/Paris + DateTime.new(2022, 7, 2, 0, 10) # 2nd of Jul 00:10 UTC - 2nd of Jul 02:10 Europe/Paris + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 4, 19) } + + let(:before_billing_times) do + [DateTime.new(2022, 7, 1, 0, 0)] # 1st of Jul 00:00 UTC - 30th of Jun 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2022, 7, 1, 5, 0), # 1st of Jul 05:00 UTC - 1st of Jul 00:00 America/Bogota + DateTime.new(2022, 7, 1, 6, 0), # 1st of Jul 06:00 UTC - 1st of Jul 01:00 America/Bogota + DateTime.new(2022, 7, 2, 0, 0) # 2nd of Jul 00:00 UTC - 1st of Jul 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2022, 7, 2, 5, 0)] # 2nd of Jul 05:00 UTC - 2nd of Jul 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + let(:subscription_time) { DateTime.new(2022, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2022, 7, 1), DateTime.new(2022, 8, 3)] } + let(:billing_times) { [DateTime.new(2022, 8, 4, 1), DateTime.new(2022, 8, 4, 2)] } + let(:after_billing_times) { [DateTime.new(2022, 8, 5), DateTime.new(2022, 9, 4)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "when subscription started on a 29th of February" do + let(:subscription_time) { DateTime.new(2020, 2, 29) } + + let(:before_billing_times) { [DateTime.new(2023, 6, 28), DateTime.new(2023, 7, 27)] } + let(:billing_times) { [DateTime.new(2023, 8, 29, 1), DateTime.new(2023, 8, 29, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 10, 1), DateTime.new(2023, 11, 29)] } + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC+ timezone" do + let(:timezone) { "Europe/Paris" } + let(:subscription_time) { DateTime.new(2022, 4, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2022, 9, 1, 21, 0) # 1st of September 21:00 UTC - 1st of September 23:00 Europe/Paris + ] + end + let(:billing_times) do + [ + DateTime.new(2022, 10, 1, 23, 0), # 1st of October 23:00 UTC - 2nd of October 01:00 Europe/Paris + DateTime.new(2022, 10, 1, 20, 0), # 2nd of October 20:00 UTC - 2nd of October 22:00 Europe/Paris + DateTime.new(2022, 10, 2, 21, 0), # 2nd of October 21:00 UTC - 2nd of October 23:00 Europe/Paris + DateTime.new(2022, 10, 2, 22, 10) # 2nd of October 22:59 UTC - 3rd of October 00:59 Europe/Paris + ] + end + let(:after_billing_times) do + [ + DateTime.new(2022, 10, 3, 0, 10) # 3rd of October 00:10 UTC - 3rd of October 02:10 Europe/Paris + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 4, 19) } + + let(:before_billing_times) do + [DateTime.new(2022, 2, 4, 0, 0)] # 4th of Feb 00:00 UTC - 3rd Feb 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2022, 8, 4, 5, 0), # 4th of Aug 05:00 UTC - 4th of Aug 00:00 America/Bogota + DateTime.new(2022, 8, 4, 6, 0), # 4th of Aug 06:00 UTC - 4th of Aug 01:00 America/Bogota + DateTime.new(2022, 8, 5, 0, 0) # 5th of Aug 00:00 UTC - 4th of Aug 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2022, 8, 5, 5, 0)] # 5th of Aug 05:00 UTC - 5th of Aug 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + end + + context "with semiannual plan and monthly charge" do + let(:plan_interval) { "semiannual" } + let(:plan_monthly_charges) { true } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + + let(:subscription_time) { DateTime.new(2022, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2021, 12, 31)] } + let(:billing_times) { [DateTime.new(2022, 7, 1, 1), DateTime.new(2022, 7, 1, 2)] } + let(:after_billing_times) { [DateTime.new(2022, 7, 2)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Asia/Kolkata" } + let(:subscription_time) { DateTime.new(2023, 2, 2) } + + let(:before_billing_times) do + [DateTime.new(2023, 2, 28, 18, 0)] # 28 of Feb 18:00 UTC - 28 Feb 23:30 Asia/Kolkata + end + let(:billing_times) do + [ + DateTime.new(2023, 2, 28, 19, 0), # 28 of Feb 19:00 UTC - 1st of Mar 00:30 Asia/Kolkata + DateTime.new(2023, 3, 1, 0, 0), # 1st of Mar 00:00 UTC - 1st of Mar 05:30 Asia/Kolkata + DateTime.new(2023, 3, 1, 18, 0) # 1st of Mar 18:00 UTC - 1st of Mar 23:30 Asia/Kolkata + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 3, 1, 19, 0), # 1st of Mar 19:00 UTC - 2nd of Mar 00:30 Asia/Kolkata + DateTime.new(2023, 3, 2, 0, 0) # 2nd of Mar 00:00 UTC - 2nd of Mar 05:30 Asia/Kolkata + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 2, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 3, 1, 0, 0)] # 1st of Mar 00:00 UTC - 28th of Feb 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 1, 5, 0), # 1st of Mar 05:00 UTC - 1st of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 1, 6, 0), # 1st of Mar 06:00 UTC - 1st of Mar 01:00 America/Bogota + DateTime.new(2023, 3, 1, 0, 0), # 2nd of Mar 00:00 UTC - 1st of Mar 19:00 America/Bogota + DateTime.new(2023, 3, 2, 4, 0) # 2nd of Mar 04:00 UTC - 1st of Mar 23:00 America/Bogota + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 3, 2, 5, 0), # 2nd of Mar 05:00 UTC - 2nd of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 3, 5, 0) # 3th of Mar 05:00 UTC - 3th of Mar 00:00 America/Bogota + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + + let(:subscription_time) { DateTime.new(2022, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2023, 1, 3)] } + let(:billing_times) { [DateTime.new(2023, 1, 4, 1), DateTime.new(2023, 1, 4, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 1, 5)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "when subscription started on a 31st" do + let(:subscription_time) { DateTime.new(2023, 3, 31) } + + let(:before_billing_times) { [DateTime.new(2023, 4, 29)] } + let(:billing_times) { [DateTime.new(2023, 4, 30)] } + let(:after_billing_times) { [DateTime.new(2023, 5, 1)] } + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC+ timezone" do + let(:timezone) { "Europe/Paris" } + let(:subscription_time) { DateTime.new(2022, 4, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 4, 1, 21, 0) # 1st of April 21:00 UTC - 1st of April 23:00 Europe/Paris + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 4, 2, 22, 0), # 1st of April 23:00 UTC - 2nd of April 01:00 Europe/Paris + DateTime.new(2023, 4, 2, 20, 0), # 2nd of April 20:00 UTC - 2nd of April 22:00 Europe/Paris + DateTime.new(2023, 4, 2, 21, 0), # 2nd of April 21:00 UTC - 2nd of April 23:00 Europe/Paris + DateTime.new(2023, 4, 2, 22, 10) # 2nd of April 22:59 UTC - 3rd of April 00:59 Europe/Paris + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 4, 3, 0, 10) # 3rd of April 00:10 UTC - 3rd of April 02:10 Europe/Paris + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 4, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 3, 4, 0, 0)] # 4th of Mar 00:00 UTC - 3rd Feb 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 4, 5, 0), # 4th of Mar 05:00 UTC - 4th of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 4, 6, 0), # 4th of Mar 06:00 UTC - 4th of Mar 01:00 America/Bogota + DateTime.new(2023, 3, 5, 0, 0) # 5th of Mar 00:00 UTC - 4th of Mar 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 3, 5, 5, 0)] # 5th of Mar 05:00 UTC - 5th of Mar 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + end + + context "with yearly plan and monthly charge" do + let(:plan_interval) { "yearly" } + let(:plan_monthly_charges) { true } + + context "with calendar billing" do + let(:billing_time) { "calendar" } + + let(:subscription_time) { DateTime.new(2022, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2021, 12, 31)] } + let(:billing_times) { [DateTime.new(2023, 1, 1, 1), DateTime.new(2023, 1, 1, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 1, 2)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "with UTC+ timezone" do + let(:timezone) { "Asia/Kolkata" } + let(:subscription_time) { DateTime.new(2023, 2, 2) } + + let(:before_billing_times) do + [DateTime.new(2023, 2, 28, 18, 0)] # 28 of Feb 18:00 UTC - 28 Feb 23:30 Asia/Kolkata + end + let(:billing_times) do + [ + DateTime.new(2023, 2, 28, 19, 0), # 28 of Feb 19:00 UTC - 1st of Mar 00:30 Asia/Kolkata + DateTime.new(2023, 3, 1, 0, 0), # 1st of Mar 00:00 UTC - 1st of Mar 05:30 Asia/Kolkata + DateTime.new(2023, 3, 1, 18, 0) # 1st of Mar 18:00 UTC - 1st of Mar 23:30 Asia/Kolkata + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 3, 1, 19, 0), # 1st of Mar 19:00 UTC - 2nd of Mar 00:30 Asia/Kolkata + DateTime.new(2023, 3, 2, 0, 0) # 2nd of Mar 00:00 UTC - 2nd of Mar 05:30 Asia/Kolkata + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 2, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 3, 1, 0, 0)] # 1st of Mar 00:00 UTC - 28th of Feb 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 1, 5, 0), # 1st of Mar 05:00 UTC - 1st of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 1, 6, 0), # 1st of Mar 06:00 UTC - 1st of Mar 01:00 America/Bogota + DateTime.new(2023, 3, 1, 0, 0), # 2nd of Mar 00:00 UTC - 1st of Mar 19:00 America/Bogota + DateTime.new(2023, 3, 2, 4, 0) # 2nd of Mar 04:00 UTC - 1st of Mar 23:00 America/Bogota + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 3, 2, 5, 0), # 2nd of Mar 05:00 UTC - 2nd of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 3, 5, 0) # 3th of Mar 05:00 UTC - 3th of Mar 00:00 America/Bogota + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + + let(:subscription_time) { DateTime.new(2022, 2, 4) } + + let(:before_billing_times) { [DateTime.new(2023, 1, 3)] } + let(:billing_times) { [DateTime.new(2023, 1, 4, 1), DateTime.new(2023, 1, 4, 2)] } + let(:after_billing_times) { [DateTime.new(2023, 1, 5)] } + + it_behaves_like "a subscription billing without duplicated invoices" + + context "when subscription started on a 31st" do + let(:subscription_time) { DateTime.new(2023, 3, 31) } + + let(:before_billing_times) { [DateTime.new(2023, 4, 29)] } + let(:billing_times) { [DateTime.new(2023, 4, 30)] } + let(:after_billing_times) { [DateTime.new(2023, 5, 1)] } + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC+ timezone" do + let(:timezone) { "Europe/Paris" } + let(:subscription_time) { DateTime.new(2022, 4, 2) } + + let(:before_billing_times) do + [ + DateTime.new(2023, 4, 1, 21, 0) # 1st of April 21:00 UTC - 1st of April 23:00 Europe/Paris + ] + end + let(:billing_times) do + [ + DateTime.new(2023, 4, 2, 22, 0), # 1st of April 23:00 UTC - 2nd of April 01:00 Europe/Paris + DateTime.new(2023, 4, 2, 20, 0), # 2nd of April 20:00 UTC - 2nd of April 22:00 Europe/Paris + DateTime.new(2023, 4, 2, 21, 0), # 2nd of April 21:00 UTC - 2nd of April 23:00 Europe/Paris + DateTime.new(2023, 4, 2, 22, 10) # 2nd of April 22:59 UTC - 3rd of April 00:59 Europe/Paris + ] + end + let(:after_billing_times) do + [ + DateTime.new(2023, 4, 3, 0, 10) # 3rd of April 00:10 UTC - 3rd of April 02:10 Europe/Paris + ] + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + + context "with UTC- timezone" do + let(:timezone) { "America/Bogota" } + let(:subscription_time) { DateTime.new(2022, 2, 4, 19) } + + let(:before_billing_times) do + [DateTime.new(2023, 3, 4, 0, 0)] # 4th of Mar 00:00 UTC - 3rd Feb 19:00 America/Bogota + end + let(:billing_times) do + [ + DateTime.new(2023, 3, 4, 5, 0), # 4th of Mar 05:00 UTC - 4th of Mar 00:00 America/Bogota + DateTime.new(2023, 3, 4, 6, 0), # 4th of Mar 06:00 UTC - 4th of Mar 01:00 America/Bogota + DateTime.new(2023, 3, 5, 0, 0) # 5th of Mar 00:00 UTC - 4th of Mar 19:00 America/Bogota + ] + end + let(:after_billing_times) do + [DateTime.new(2023, 3, 5, 5, 0)] # 5th of Mar 05:00 UTC - 5th of Mar 00:00 America/Bogota + end + + it_behaves_like "a subscription billing without duplicated invoices" + end + end + end +end diff --git a/spec/scenarios/subscriptions/downgrade_spec.rb b/spec/scenarios/subscriptions/downgrade_spec.rb new file mode 100644 index 0000000..e49ae89 --- /dev/null +++ b/spec/scenarios/subscriptions/downgrade_spec.rb @@ -0,0 +1,462 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Subscription Downgrade Scenario", transaction: false do + let(:organization) { create(:organization, webhook_url: false) } + + let(:customer) { create(:customer, organization:) } + + let(:monthly_plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 12_900, + pay_in_advance: true + ) + end + + let(:yearly_plan) do + create( + :plan, + organization:, + interval: "yearly", + amount_cents: 118_800, + pay_in_advance: true + ) + end + + let(:subscription_at) { DateTime.new(2023, 7, 19, 12, 12) } + + it "downgrades and bill subscriptions" do + subscription = nil + + # NOTE: Jul 19th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + + invoice = subscription.invoices.last + expect(invoice.fees_amount_cents).to eq(monthly_plan.amount_cents) + expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq("2023-07-19T00:00:00Z") + expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq("2023-08-18T23:59:59Z") + end + + # NOTE: August 19th: Bill subscription + travel_to(DateTime.new(2023, 8, 19, 12, 12)) do + expect { perform_billing }.to change { subscription.reload.invoices.count } + + expect(subscription.invoices.count).to eq(2) + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees_amount_cents).to eq(monthly_plan.amount_cents) + expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq("2023-08-19T00:00:00Z") + expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq("2023-09-18T23:59:59Z") + expect(invoice.invoice_subscriptions.first.charges_from_datetime.iso8601).to eq("2023-07-19T12:12:00Z") + expect(invoice.invoice_subscriptions.first.charges_to_datetime.iso8601).to eq("2023-08-18T23:59:59Z") + end + + # NOTE: September 19th: Bill subscription + travel_to(DateTime.new(2023, 9, 19, 12, 12)) do + expect { perform_billing }.to change { subscription.reload.invoices.count } + + expect(subscription.invoices.count).to eq(3) + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees_amount_cents).to eq(monthly_plan.amount_cents) + expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq("2023-09-19T00:00:00Z") + expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq("2023-10-18T23:59:59Z") + expect(invoice.invoice_subscriptions.first.charges_from_datetime.iso8601).to eq("2023-08-19T00:00:00Z") + expect(invoice.invoice_subscriptions.first.charges_to_datetime.iso8601).to eq("2023-09-18T23:59:59Z") + end + + # NOTE: October 19th: Bill subscription + travel_to(DateTime.new(2023, 10, 19, 12, 12)) do + expect { perform_billing }.to change { subscription.reload.invoices.count } + + expect(subscription.invoices.count).to eq(4) + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees_amount_cents).to eq(monthly_plan.amount_cents) + expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq("2023-10-19T00:00:00Z") + expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq("2023-11-18T23:59:59Z") + expect(invoice.invoice_subscriptions.first.charges_from_datetime.iso8601).to eq("2023-09-19T00:00:00Z") + expect(invoice.invoice_subscriptions.first.charges_to_datetime.iso8601).to eq("2023-10-18T23:59:59Z") + end + + # NOTE: On November 9th: Downgrade to the yearly plan + travel_to(DateTime.new(2023, 11, 9, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: yearly_plan.code, + billing_time: "anniversary" + } + ) + + expect(subscription.reload).to be_active + expect(subscription.invoices.count).to eq(4) + end + + # NOTE: November 19th: Bill subscription. Old subscription is terminated and pending one is activated + travel_to(DateTime.new(2023, 11, 19, 12, 12)) do + expect { perform_billing }.to change { subscription.reload.invoices.count } + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq(5) + expect(customer.invoices.count).to eq(5) + + new_subscription = subscription.reload.next_subscription + + expect(new_subscription.reload).to be_active + expect(new_subscription.invoices.count).to eq(1) + + new_sub_invoice = new_subscription.invoices.order(created_at: :asc).last + # There are 243 days from new sub started_at until old subscription subscription_at. Also, 2024 is a leap year + # Also for old pay in advance plan there are no charges so total amount is zero + expect(new_sub_invoice.fees_amount_cents).to eq(0 + (yearly_plan.amount_cents.fdiv(366) * 243).round) + expect(new_subscription.invoice_subscriptions.order(created_at: :desc).first.from_datetime.iso8601) + .to eq("2023-11-19T00:00:00Z") + expect(new_subscription.invoice_subscriptions.order(created_at: :desc).first.to_datetime.iso8601) + .to eq("2024-07-18T23:59:59Z") + end + end + + context "when there are fixed charges" do + let(:plan) { create(:plan, :monthly, pay_in_advance: false, amount_cents: 10000, organization:) } + let(:plan_downgrade) { create(:plan, :monthly, pay_in_advance: false, amount_cents: 1000, organization:) } + let(:add_ons) { create_list(:add_on, 3, organization:) } + let(:fixed_charges_plan) { + [ + create(:fixed_charge, plan:, add_on: add_ons[0], properties: {amount: "1"}, units: 10, pay_in_advance:, prorated:), + create(:fixed_charge, plan:, add_on: add_ons[1], properties: {amount: "3"}, units: 5, pay_in_advance:, prorated:) + ] + } + let(:fixed_charges_plan_downgrade) { + [ + create(:fixed_charge, plan: plan_downgrade, add_on: add_ons[1], properties: {amount: "10"}, units: 10, pay_in_advance:, prorated:), + create(:fixed_charge, plan: plan_downgrade, add_on: add_ons[2], properties: {amount: "20", units: 1}, pay_in_advance:, prorated:) + ] + } + + before do + fixed_charges_plan + fixed_charges_plan_downgrade + end + + context "when fixed charges are in_advance" do + let(:pay_in_advance) { true } + + context "when fixed charges are prorated" do + let(:prorated) { true } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000 * 13 / 31, 1500 * 13 / 31]) + + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(1).to(2) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 23, 59, 59)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_downgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_active + end + new_subscription = subscription.reload.next_subscription + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # we still need to charge subscription fee for the old plan + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(2).to(3) + end + + # note: this invoice includes both subscriptions: old and new + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.invoice_subscriptions.map(&:subscription)).to match_array([subscription, new_subscription]) + # this invoice contains subscription fee of the old plan + expect(invoice.fees.subscription.count).to eq(1) + expect(subscription).to be_terminated + + expect(new_subscription.reload).to be_active + # and fixed_charges of the new plan + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-09-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-09-30T23:59:59.999Z" + ) + end + end + + context "when fixed charges are not prorated" do + let(:prorated) { false } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(1).to(2) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 23, 59, 59)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_downgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_active + end + new_subscription = subscription.reload.next_subscription + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # we still need to charge subscription fee for the old plan + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(2).to(3) + end + + # note: this invoice includes both subscriptions: old and new + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.invoice_subscriptions.map(&:subscription)).to match_array([subscription, new_subscription]) + # this invoice contains subscription fee of the old plan + expect(invoice.fees.subscription.count).to eq(1) + expect(subscription).to be_terminated + + expect(new_subscription.reload).to be_active + # and fixed_charges of the new plan + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-09-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-09-30T23:59:59.999Z" + ) + end + end + end + + context "when fixed charges are in_arrears" do + let(:pay_in_advance) { false } + + context "when fixed charges are prorated" do + let(:prorated) { true } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(0) + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(0).to(1) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000 * 13 / 31, 1500 * 13 / 31]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-07-19T12:12:00.000Z", + "fixed_charges_to_datetime" => "2023-07-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 23, 59, 59)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_downgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_active + end + new_subscription = subscription.reload.next_subscription + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # Now we do charge the old plan pay in arrears + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(1).to(2) + end + + # note: this invoice includes only old sub, because there is nothing to charge in the new one + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.invoice_subscriptions.map(&:subscription)).to match_array([subscription]) + # this invoice contains subscription fee of the old plan + # and pay in arrears fixed_charges + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + # why in this case do we have one more day? :shocked: + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + expect(subscription).to be_terminated + + expect(new_subscription.reload).to be_active + expect(new_subscription.invoices.count).to eq(0) + + travel_to(DateTime.new(2023, 10, 1, 0, 0)) do + # finally charge the new plan (we're in arrears charges); prev invoice is counted for both subscriptions + expect { perform_billing }.to change { new_subscription.reload.invoices.count }.from(0).to(1) + end + invoice = new_subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-09-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-09-30T23:59:59.999Z" + ) + end + end + + context "when fixed charges are not prorated" do + let(:prorated) { false } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(0) + + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(0).to(1) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-07-19T12:12:00.000Z", + "fixed_charges_to_datetime" => "2023-07-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 23, 59, 59)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_downgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_active + end + new_subscription = subscription.reload.next_subscription + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # Now we do charge the old plan + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(1).to(2) + end + + # note: this invoice includes only old sub, because there is nothing to charge in the new one + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.invoice_subscriptions.map(&:subscription)).to match_array([subscription]) + # this invoice contains subscription fee of the old plan + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + expect(subscription).to be_terminated + + expect(new_subscription.reload).to be_active + expect(new_subscription.invoices.count).to eq(0) + + travel_to(DateTime.new(2023, 10, 1, 0, 0)) do + # finally charge the new plan (we're in arrears charges); prev invoice is counted for both subscriptions + expect { perform_billing }.to change { new_subscription.reload.invoices.count }.from(0).to(1) + end + invoice = new_subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-09-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-09-30T23:59:59.999Z" + ) + end + end + end + end +end diff --git a/spec/scenarios/subscriptions/free_trial_billing_spec.rb b/spec/scenarios/subscriptions/free_trial_billing_spec.rb new file mode 100644 index 0000000..c0f4efd --- /dev/null +++ b/spec/scenarios/subscriptions/free_trial_billing_spec.rb @@ -0,0 +1,535 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Free Trial Billing Subscriptions Scenario" do + let(:timezone) { "UTC" } + let(:organization) { create(:organization, webhook_url: nil) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:customer) { create(:customer, organization:, timezone:) } + let(:plan) do + create( + :plan, + organization:, + trial_period:, + amount_cents: 5_000_000, + pay_in_advance: true + ) + end + + def create_customer_subscription! + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + + def create_usage_event! + create_event( + { + code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: customer.external_id + } + ) + end + + context "without free trial" do + let(:trial_period) { 0 } + + it "bills the customer at the beginning of the subscription" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(1) + expect(customer.invoices.first.fees.subscription).to exist + end + end + end + + context "with free trial" do + let(:trial_period) { 10 } + + it "bills the customer at the end of the free trial" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + end + subscription = customer.subscriptions.sole + + # Ensure nothing happened + travel_to(Time.zone.parse("2024-03-10T12:12:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(0) + end + + # NOTE: The subscription was started at 12:12:00, so the trial period ends exactly at 12:12:00 + # This ensure that Subscriptions::FreeTrialBillingService grabs subscriptions that + # ended in the last hour. + travel_to(Time.zone.parse("2024-03-15T12:02:00")) do + expect(subscription).to be_in_trial_period + perform_billing + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-15T13:02:00")) do + expect(subscription).not_to be_in_trial_period + perform_billing + expect(customer.reload.invoices.count).to eq(1) + invoice = customer.reload.invoices.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(2_741_935) # (31 - 4 - 10) / 31 * 5000000 = 2741935 + end + end + + # NOTE: This only happens if the customer was billed at the beginning of the free trial + # BEFORE the feature to bill at the end of the free trial was implemented + it "does not bill the customer if it was already billed at the beginning of the trial" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + + plan.update! trial_period: 0 # disable trial to force billing + BillSubscriptionJob.perform_now(customer.subscriptions.to_a, Time.current, invoicing_reason: :subscription_starting) + expect(customer.reload.invoices.count).to eq(1) + + plan.update! trial_period: 10 + end + + # Ensure nothing happened + travel_to(Time.zone.parse("2024-03-10T12:12:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + end + + travel_to(Time.zone.parse("2024-03-15T15:00:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + end + + travel_to(Time.zone.parse("2024-03-20T12:12:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + end + end + end + + context "with a plan upgrade during the trial" do + let(:trial_period) { 10 } + + it "bills the subscription of the upgraded plan at the end of the trial" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + perform_billing + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-08")) { create_usage_event! } + + # Upgrade to a new plan + # It create an invoice with the old plan because there was some usage + travel_to(Time.zone.parse("2024-03-10T12:12:00")) do + upgrade_plan = create(:plan, organization:, trial_period: 13, amount_cents: 10_000_000, pay_in_advance: true) + create(:standard_charge, plan: upgrade_plan, billable_metric:, properties: {amount: "12"}) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: upgrade_plan.code + } + ) + perform_billing + expect(customer.reload.invoices.count).to eq(1) + invoice = customer.invoices.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + end + + travel_to(Time.zone.parse("2024-03-11")) { create_usage_event! } + + # After plan.trial_period days, nothing happens + travel_to(Time.zone.parse("2024-03-15T13:00:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + end + + # Using plan.started_at + upgrade_plan.trial_period days, the trial ends + travel_to(Time.zone.parse("2024-03-18T13:00:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(4_516_129) # (31 - 4 - 13) / 31 * 10000000 + end + + travel_to(Time.zone.parse("2024-04-01T12:12:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(3) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(2) + expect(invoice.fees.charge.first.amount_cents).to eq(1200) + expect(invoice.fees.subscription.first.amount_cents).to eq(10_000_000) + end + end + + context "when the upgrade happens on day one" do + it "bills the usage instantly and the subscription of the upgraded plan at the end of trial period" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + perform_billing + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-05T13:00:00")) { create_usage_event! } + + # Upgrade to a new plan + # It create an invoice with the old plan because there was some usage + travel_to(Time.zone.parse("2024-03-05T13:15:00")) do + upgrade_plan = create(:plan, organization:, trial_period: 13, amount_cents: 10_000_000, pay_in_advance: true) + create(:standard_charge, plan: upgrade_plan, billable_metric:, properties: {amount: "12"}) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: upgrade_plan.code + } + ) + perform_billing + expect(customer.reload.invoices.count).to eq(1) + invoice = customer.invoices.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + end + + travel_to(Time.zone.parse("2024-03-05T15:00:00")) { create_usage_event! } + travel_to(Time.zone.parse("2024-03-05T15:01:00")) { create_usage_event! } + + # Using plan.started_at + upgrade_plan.trial_period days, the trial ends + travel_to(Time.zone.parse("2024-03-18T13:00:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(4_516_129) # (31 - 4 - 13) / 31 * 10000000 + end + + travel_to(Time.zone.parse("2024-04-01T12:12:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(3) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(2) + expect(invoice.fees.charge.first.amount_cents).to eq(2400) + expect(invoice.fees.subscription.first.amount_cents).to eq(10_000_000) + end + end + end + + context "with a grace period" do + it "bills the usage instantly and the subscription of the upgraded plan at the end of trial period" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + customer.update! invoice_grace_period: 2 + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + perform_billing + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-05T13:00:00")) { create_usage_event! } + + # Upgrade to a new plan + # It create an invoice with the old plan because there was some usage + travel_to(Time.zone.parse("2024-03-05T13:15:00")) do + upgrade_plan = create(:plan, organization:, trial_period: 13, amount_cents: 10_000_000, pay_in_advance: true) + create(:standard_charge, plan: upgrade_plan, billable_metric:, properties: {amount: "12"}) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: upgrade_plan.code + } + ) + perform_billing + expect(customer.reload.invoices.count).to eq(1) + invoice = customer.invoices.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + expect(invoice.status).to eq("draft") + end + + travel_to(Time.zone.parse("2024-03-07T18:00:00")) do + Clock::FinalizeInvoicesJob.perform_later + perform_all_enqueued_jobs + + invoice = customer.invoices.reload.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + expect(invoice.status).to eq("finalized") + end + + travel_to(Time.zone.parse("2024-03-05T15:00:00")) { create_usage_event! } + travel_to(Time.zone.parse("2024-03-05T15:01:00")) { create_usage_event! } + + # Using plan.started_at + upgrade_plan.trial_period days, the trial ends + travel_to(Time.zone.parse("2024-03-18T13:00:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(4_516_129) # (31 - 4 - 13) / 31 * 10000000 + end + + travel_to(Time.zone.parse("2024-04-01T12:12:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(3) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(2) + expect(invoice.fees.charge.first.amount_cents).to eq(2400) + expect(invoice.fees.subscription.first.amount_cents).to eq(10_000_000) + end + end + end + end + + context "with free trial > billing period" do + let(:trial_period) { 45 } + + it "bills subscription at the end of the free trial" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-10")) { create_usage_event! } + + travel_to(Time.zone.parse("2024-04-01")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + invoice = customer.invoices.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + end + + travel_to(Time.zone.parse("2024-04-19T13:01:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(2) + free_trial_invoice = customer.invoices.order(created_at: :desc).first + expect(free_trial_invoice.fees.count).to eq(1) + expect(free_trial_invoice.fees.subscription.first.amount_cents).to eq(2_000_000) # 5_000_000 * 12 / 30 + end + end + + context "with a grace period" do + it "bills the customer at the end of the free trial but finalize after grace period" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + customer.update! invoice_grace_period: 2 + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-10")) { create_usage_event! } + + travel_to(Time.zone.parse("2024-04-01")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + invoice = customer.invoices.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + end + + travel_to(Time.zone.parse("2024-04-19T13:01:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(2_000_000) # 5_000_000 * 12 / 30 + expect(invoice.status).to eq("draft") + end + + # Ensure charge fees are not added when refreshing the invoice + travel_to(Time.zone.parse("2024-04-21T13:22:00")) do + invoice = customer.invoices.order(created_at: :desc).first + Invoices::RefreshDraftJob.perform_later(invoice:) + perform_all_enqueued_jobs + expect(customer.reload.invoices.count).to eq(2) + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(2_000_000) + expect(invoice.status).to eq("draft") + + Clock::FinalizeInvoicesJob.perform_later + perform_all_enqueued_jobs + + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(2_000_000) + expect(invoice.status).to eq("finalized") + end + end + end + + context "with a plan with minimum commitment" do + it "bills minimum commitment on billing day, despite being in trial" do + travel_to(Time.zone.parse("2024-03-05T12:12:00")) do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000_000) + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-10")) { create_usage_event! } + + travel_to(Time.zone.parse("2024-04-01")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + invoice = customer.invoices.sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + end + + travel_to(Time.zone.parse("2024-04-10")) { create_usage_event! } + + travel_to(Time.zone.parse("2024-04-19T13:01:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(2_000_000) # 5_000_000 * 12 / 30 + end + + travel_to(Time.zone.parse("2024-05-01")) do + perform_billing + expect(customer.reload.invoices.count).to eq(3) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(3) + expect(invoice.fees.subscription.first.amount_cents).to eq(5_000_000) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + # The minimum commitment true up look at usage in previous month, + # when the trial ended and the customer paid only 2_000_000 in subscription fee + expect(invoice.fees.commitment.first.amount_cents).to eq(10_000_000 - 2_000_000 - 1000) + end + end + end + end + + context "with free trial ending on billing day" do + let(:trial_period) { 10 } + let(:timezone) { "Europe/Paris" } + + it "bills subscription and usage-based charges" do + start_time = Time.zone.parse("2024-03-22T01:12:00") + travel_to(start_time) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-23")) { create_usage_event! } + + expect(customer.reload.invoices.count).to eq(0) + + # NOTE: Subscriptions::OrganizationBillingService will bill the subscription because it's billing day + # Subscriptions::FreeTrialBillingService will ignore it because the trial ends at 12:12:00 + # + # Time.current: 31 Mar 2024 22:01:00 UTC +00:00 + # Time.current.in_time_zone(timezone): 01 Apr 2024 00:01:00 CEST +02:00 + # sub.trial_end_datetime: 01 Apr 2024 01:12:00 UTC +00:00 + billing_day = Time.parse("2024-04-01T00:01:00").in_time_zone(timezone) + travel_to(billing_day) do + perform_billing + invoice = customer.invoices.order(created_at: :desc).sole + expect(invoice.fees.subscription.first.amount_cents).to eq(5_000_000) # full fee, trial is over + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + end + + # NOTE: After the trial ends, we don't invoice again because it was done above + # but we terminate the trial and send the webhook + travel_to(Time.zone.parse("2024-04-01T13:11:00")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + expect(customer.subscriptions.sole.trial_ended_at).to match_datetime(start_time + trial_period.days) + end + end + + context "with customer with a timezone" do + let(:trial_period) { 10 } + let(:timezone) { "Asia/Tokyo" } + + it "follows customer timezone for billing" do + # Trial ends on April 1st, 2024 in UTC + # but April 2nd, 2024 in Asia/Tokyo + start_time = Time.parse("2024-03-22T18:12:00 UTC").in_time_zone(timezone) + travel_to(start_time) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-28")) { create_usage_event! } + + expect(customer.reload.invoices.count).to eq(0) + + # NOTE: Billing day in Asia/Tokyo + travel_to(Time.parse("2024-03-31T15:10:00 UTC")) do # 2024-04-01T00:10:00 Asia/Tokyo + perform_billing + invoice = customer.invoices.order(created_at: :desc).sole + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.first.amount_cents).to eq(1000) + end + + # April 1st in both timezone, nothing should happen + travel_to(Time.parse("2024-04-01T13:11:00 UTC")) do + perform_billing + expect(customer.reload.invoices.count).to eq(1) + end + + travel_to(Time.parse("2024-04-01T19:11:00 UTC")) do # April 2nd, 2024 04:11:00 Asia/Tokyo, trial ended + perform_billing + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(4_833_333) # Trial ends on the 2nd in customer tz + end + end + end + + context "with SubscriptionsBillerJob running after FreeTrialSubscriptionsBillerJob" do + it "bills subscription and usage-based charges" do + start_time = Time.zone.parse("2024-03-22T12:12:00") + travel_to(start_time) do + create_customer_subscription! + expect(customer.reload.invoices.count).to eq(0) + end + + travel_to(Time.zone.parse("2024-03-23")) { create_usage_event! } + + expect(customer.reload.invoices.count).to eq(0) + + travel_to(Time.zone.parse("2024-04-01T13:01:00")) do + Clock::FreeTrialSubscriptionsBillerJob.perform_later + perform_all_enqueued_jobs + + invoice = customer.invoices.order(created_at: :desc).sole + expect(customer.subscriptions.sole.trial_ended_at).to match_datetime(start_time + trial_period.days) + # NOTE: The charge are not billed because FreeTrialBillingService use `skip_charges: true` + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.subscription.first.amount_cents).to eq(5_000_000) # full fee, trial is over + end + + # NOTE: A new invoice is created because the end of trial invoice is created with `recurring: false` + # Only the usage-based is charged because subscription was already billed + # see Invoices::CalculateFeesService.should_create_subscription_fee? + travel_to(Time.zone.parse("2024-04-01T15:11:00")) do + Clock::SubscriptionsBillerJob.perform_later + perform_all_enqueued_jobs + + expect(customer.reload.invoices.count).to eq(2) + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.fees.count).to eq(1) + expect(invoice.fees.charge.sole.amount_cents).to eq(1000) + end + end + end + end +end diff --git a/spec/scenarios/subscriptions/multiple_upgrade_spec.rb b/spec/scenarios/subscriptions/multiple_upgrade_spec.rb new file mode 100644 index 0000000..3c3934a --- /dev/null +++ b/spec/scenarios/subscriptions/multiple_upgrade_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Multiple Subscription Upgrade Scenario" do + let(:organization) { create(:organization, webhook_url: nil, email_settings: []) } + + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 25) } + + let(:plan1) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 1_000, + pay_in_advance: true + ) + end + + let(:plan2) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 1_500, + pay_in_advance: true + ) + end + + let(:plan3) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 1_900, + pay_in_advance: true + ) + end + + let(:subscription_at) { Time.zone.parse("2024-03-05T12:12:00") } + + before { tax } + + context "with calendar billing" do + it "upgrades and bill subscriptions" do + subscription = nil + + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan1.code, + billing_time: "calendar" + } + ) + + expect(customer.invoices.count).to eq(1) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + + invoice = subscription.invoices.first + expect(invoice.fees_amount_cents).to eq(871) # 1000 / 31 * (31 - 4) + end + + travel_to(Time.zone.parse("2024-03-12T12:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan2.code, + billing_time: "calendar" + } + ) + + expect(customer.invoices.count).to eq(2) + expect(subscription.reload).to be_terminated + + subscription = customer.subscriptions.order(created_at: :desc).first + expect(subscription).to be_active + + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees_amount_cents).to eq(968) # 1500 / 31 * 20 + + expect(customer.credit_notes.count).to eq(1) + credit_note = customer.credit_notes.first + expect(credit_note.credit_amount_cents).to eq(806) # 1000 / 31 * 20 * 1.25 + end + + travel_to(Time.zone.parse("2024-03-12T13:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan3.code, + billing_time: "calendar" + } + ) + + perform_all_enqueued_jobs + expect(customer.invoices.count).to eq(3) + expect(subscription.reload).to be_terminated + + subscription = customer.subscriptions.order(created_at: :desc).first + expect(subscription).to be_active + + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees_amount_cents).to eq(1226) # 1900 / 31 * 20 + + expect(customer.credit_notes.count).to eq(2) + credit_note = customer.credit_notes.order(created_at: :desc).first + expect(credit_note.credit_amount_cents).to eq(1210) # 1500 / 31 * 20 * 1.25 + end + end + end + + context "with anniversary billing" do + it "upgrades and bill subscriptions" do + subscription = nil + + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan1.code, + billing_time: "anniversary" + } + ) + + expect(customer.invoices.count).to eq(1) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + + invoice = subscription.invoices.first + expect(invoice.fees_amount_cents).to eq(1000) + end + + travel_to(Time.zone.parse("2024-03-12T12:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan2.code, + billing_time: "anniversary" + } + ) + + expect(customer.invoices.count).to eq(2) + expect(subscription.reload).to be_terminated + + subscription = customer.subscriptions.order(created_at: :desc).first + expect(subscription).to be_active + + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees_amount_cents).to eq(1161) # 1500 / 31 * 24 + + expect(customer.credit_notes.count).to eq(1) + credit_note = customer.credit_notes.first + expect(credit_note.credit_amount_cents).to eq(968) # 1000 / 31 * 24 * 1.25 + end + + travel_to(Time.zone.parse("2024-03-12T13:12:00")) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan3.code, + billing_time: "anniversary" + } + ) + + perform_all_enqueued_jobs + expect(customer.invoices.count).to eq(3) + expect(subscription.reload).to be_terminated + + subscription = customer.subscriptions.order(created_at: :desc).first + expect(subscription).to be_active + + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees_amount_cents).to eq(1471) # 1900 / 31 * 24 + + expect(customer.credit_notes.count).to eq(2) + credit_note = customer.credit_notes.order(created_at: :desc).first + expect(credit_note.credit_amount_cents).to eq(1451) # 1500 / 31 * 24 * 1.25 + end + end + end +end diff --git a/spec/scenarios/subscriptions/payment_gated_activation_spec.rb b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb new file mode 100644 index 0000000..8538291 --- /dev/null +++ b/spec/scenarios/subscriptions/payment_gated_activation_spec.rb @@ -0,0 +1,331 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Payment Gated Subscription Activation Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) { create(:stripe_customer, payment_provider: stripe_provider, customer:) } + let(:payment_method) { create(:payment_method, customer:) } + let(:payment_intent_id) { "pi_#{SecureRandom.hex(12)}" } + + let(:plan) do + create(:plan, organization:, interval: "monthly", pay_in_advance: true, amount_cents: 1000) + end + + let(:subscription_params) do + { + external_customer_id: customer.external_id, + external_id: "gated-sub-#{SecureRandom.hex(4)}", + plan_code: plan.code, + billing_time: "calendar", + activation_rules: [{type: "payment", timeout_hours: 48}] + } + end + + before do + create(:tax, :applied_to_billing_entity, organization:, rate: 0) + customer.update!(payment_provider: :stripe, payment_provider_code: stripe_provider.code) + stripe_customer + payment_method + + # Stub Stripe to return processing — payment stays pending, subscription remains incomplete + allow_any_instance_of(::PaymentProviders::Stripe::Payments::CreateService) # rubocop:disable RSpec/AnyInstance + .to receive(:create_payment_intent) + .and_return( + Stripe::PaymentIntent.construct_from( + id: payment_intent_id, + status: "processing", + amount: 1000, + currency: "eur" + ) + ) + end + + def simulate_stripe_webhook(status:) + payment = Payment.order(created_at: :desc).first + payment.update!(provider_payment_id: payment_intent_id) + + # Stub payment method retrieval triggered by SetPaymentMethodAndCreateReceiptJob + stub_request(:get, %r{https://api.stripe.com/v1/payment_methods/.*}) + .and_return(status: 200, body: {id: "pm_test", object: "payment_method", type: "card"}.to_json) + + event_type = (status == "succeeded") ? "payment_intent.succeeded" : "payment_intent.payment_failed" + + PaymentProviders::Stripe::HandleEventService.call!( + organization:, + event_json: { + id: "evt_#{SecureRandom.hex(10)}", + object: "event", + type: event_type, + data: { + object: { + id: payment_intent_id, + object: "payment_intent", + status: status.to_s, + payment_method: "pm_test", + metadata: { + lago_invoice_id: payment.payable_id, + lago_customer_id: customer.id + } + } + } + }.to_json + ) + perform_all_enqueued_jobs + end + + describe "new subscription with payment successful" do + it "creates incomplete subscription, then activates on payment success" do + # Stage 1: Create subscription — goes incomplete, invoice open + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + expect(subscription.started_at).to be_present + expect(subscription.activated_at).to be_nil + expect(subscription.activation_rules.sole).to be_pending + + invoice = subscription.invoices.sole + expect(invoice).to be_open + expect(invoice.fees.subscription.count).to eq(1) + + # Stage 2: Stripe webhook — payment succeeded + simulate_stripe_webhook(status: "succeeded") + + subscription.reload + expect(subscription).to be_active + expect(subscription.activated_at).to be_present + expect(subscription.activation_rules.sole).to be_satisfied + + expect(invoice.reload).to be_finalized + expect(invoice.number).not_to include("DRAFT") + end + end + + describe "payment failure: subscription canceled" do + it "creates incomplete subscription, then cancels on payment failure" do + # Stage 1: Create subscription — goes incomplete + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + + invoice = subscription.invoices.sole + expect(invoice).to be_open + + # Stage 2: Stripe webhook — payment failed + simulate_stripe_webhook(status: "failed") + + subscription.reload + expect(subscription).to be_canceled + expect(subscription.cancelation_reason).to eq("payment_failed") + expect(subscription.activated_at).to be_nil + expect(subscription.activation_rules.sole).to be_failed + + expect(invoice.reload).to be_closed + end + end + + describe "backdated subscription: rules ignored" do + it "activates immediately without evaluating rules" do + params = subscription_params.merge(subscription_at: 5.days.ago.iso8601) + + create_subscription(params) + + subscription = customer.subscriptions.sole + expect(subscription).to be_active + expect(subscription.activation_rules.count).to eq(0) + expect(subscription.invoices).to be_empty + end + end + + describe "with trial period" do + let(:plan) do + create(:plan, organization:, interval: "monthly", pay_in_advance: true, amount_cents: 1000, trial_period: 30) + end + + context "when plan has no pay-in-advance fixed charges" do + it "activates immediately because there is nothing to collect" do + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_active + expect(subscription.activation_rules.sole).to be_not_applicable + expect(subscription.invoices).to be_empty + end + end + + context "when plan has pay-in-advance fixed charges" do + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "gates on the fixed charge invoice" do + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + expect(subscription.activation_rules.sole).to be_pending + end + end + end + + describe "zero-amount gated invoice (no charge to collect)" do + let(:plan) do + create(:plan, organization:, interval: "monthly", pay_in_advance: true, amount_cents: 0) + end + + it "marks the rule satisfied and activates without going through the payment chain" do + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_active + expect(subscription.activation_rules.sole).to be_satisfied + + invoice = subscription.invoices.sole + expect(invoice).to be_finalized + expect(invoice.total_amount_cents).to eq(0) + expect(invoice.payment_status).to eq("succeeded") + end + end + + describe "pay-in-arrears plan with pay-in-advance fixed charges" do + let(:plan) do + create(:plan, organization:, interval: "monthly", pay_in_advance: false, amount_cents: 1000) + end + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "gates on the fixed charge only invoice" do + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + expect(subscription.activation_rules.sole).to be_pending + + invoice = subscription.invoices.sole + expect(invoice).to be_open + expect(invoice.fees.fixed_charge.count).to be_positive + expect(invoice.fees.subscription.count).to eq(0) + end + end + + describe "gated subscription with pending VIES check" do + let(:vat_number) { "IT12345678901" } + let(:organization) do + create(:organization, country: "FR", webhook_url: nil, eu_tax_management: true, + billing_entities: [create(:billing_entity, country: "FR", eu_tax_management: true)]) + end + let(:billing_entity) { organization.billing_entities.first } + let(:customer) do + create(:customer, organization:, billing_entity:, country: "IT", currency: "EUR", + tax_identification_number: vat_number) + end + + before do + create(:pending_vies_check, customer:, tax_identification_number: vat_number) + end + + it "stays gated until VIES resolves, then activates on payment success" do + # Stage 1: Create subscription — invoice goes :open with tax_status :pending (VIES blocks taxes) + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + expect(subscription.activation_rules.sole).to be_pending + + invoice = subscription.invoices.sole + expect(invoice).to be_open + expect(invoice.tax_status).to eq("pending") + + # Stage 2: VIES resolves — FinalizePendingViesInvoiceService applies taxes and triggers payment + mock_vies_check!(vat_number) + Customers::ViesCheckJob.perform_now(customer) + perform_all_enqueued_jobs + + invoice.reload + expect(invoice.tax_status).to eq("succeeded") + expect(invoice).to be_open + + # Stage 3: Stripe webhook — payment succeeded, subscription activates + simulate_stripe_webhook(status: "succeeded") + + subscription.reload + expect(subscription).to be_active + expect(subscription.activation_rules.sole).to be_satisfied + expect(invoice.reload).to be_finalized + end + end + + describe "gated subscription with provider tax failure" do + let(:tax_integration) { create(:anrok_integration, organization:) } + let(:tax_integration_customer) { create(:anrok_customer, integration: tax_integration, customer:) } + let(:anrok_client) { instance_double(LagoHttpClient::Client) } + let(:anrok_finalized_endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:anrok_draft_endpoint) { "https://api.nango.dev/v1/anrok/draft_invoices" } + let(:failure_body) { File.read(Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json")) } + let(:success_body_template) { JSON.parse(File.read(Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json"))) } + + before do + tax_integration_customer + allow(LagoHttpClient::Client).to receive(:new).and_call_original + allow(LagoHttpClient::Client).to receive(:new).with(anrok_finalized_endpoint, anything).and_return(anrok_client) + allow(LagoHttpClient::Client).to receive(:new).with(anrok_draft_endpoint, anything).and_return(anrok_client) + stub_anrok_response(failure_body) + end + + def stub_anrok_response(body) + response = instance_double(Net::HTTPOK) + allow(response).to receive(:body).and_return(body) + allow(anrok_client).to receive(:post_with_response).and_return(response) + end + + def success_body_for(invoice) + body = success_body_template.deep_dup + body["succeededInvoices"].first["fees"].first["item_id"] = invoice.fees.first.id + body.to_json + end + + it "fails on tax error, retries successfully, then activates on payment success" do + # Stage 1: Create subscription — Anrok fails → invoice :failed + create_subscription(subscription_params) + perform_all_enqueued_jobs + + subscription = customer.subscriptions.sole + expect(subscription).to be_incomplete + expect(subscription.activation_rules.sole).to be_pending + + invoice = subscription.invoices.sole + expect(invoice).to be_failed + expect(invoice.tax_status).to eq("failed") + + # Stage 2: Re-stub Anrok to succeed, then retry. Invoice goes :open with taxes + # applied; PullTaxesAndApplyService triggers payment for the gated case. + stub_anrok_response(success_body_for(invoice)) + Invoices::RetryService.call!(invoice:) + perform_all_enqueued_jobs + + invoice.reload + expect(invoice).to be_open + expect(invoice.tax_status).to eq("succeeded") + + # Stage 3: Stripe webhook — payment succeeded, subscription activates + simulate_stripe_webhook(status: "succeeded") + + subscription.reload + expect(subscription).to be_active + expect(subscription.activation_rules.sole).to be_satisfied + expect(invoice.reload).to be_finalized + end + end +end diff --git a/spec/scenarios/subscriptions/progressive_billing_enablement_spec.rb b/spec/scenarios/subscriptions/progressive_billing_enablement_spec.rb new file mode 100644 index 0000000..17fa5f5 --- /dev/null +++ b/spec/scenarios/subscriptions/progressive_billing_enablement_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Progressive Billing enablement", :premium, transaction: false do + let(:timezone) { "UTC" } + let(:organization) { create(:organization, webhook_url: nil, premium_integrations: ["progressive_billing"]) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + + let(:customer) { create(:customer, organization:, timezone:) } + let(:plan) do + create( + :plan, + organization:, + amount_cents: 5_000_000 + ) + end + + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) } + let(:usage_threshold) { create(:usage_threshold, plan: plan, amount_cents: 20000) } + + before do + charge + end + + context "when enabled when usage has already been accumulating" do + it "correctly calculates thresholds and generates correct progressive billing invoices" do + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + subscription = customer.subscriptions.first + + # no invoice expected to be generated + travel_to time_0 + 5.days + + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(0) + + # update plan and add a threshold + travel_to time_0 + 6.days + + update_plan(plan, {usage_thresholds: [ + { + amount_cents: usage_threshold.amount_cents + } + ]}) + + perform_all_enqueued_jobs + perform_usage_update + + expect(Invoice.count).to eq(1) + invoice = Invoice.last + + expect(invoice.invoice_type).to eq("progressive_billing") + expect(invoice.total_amount_cents).to eq(1000000 * 10 * 100) + + travel_to time_0 + 1.month + + perform_billing + expect(Invoice.count).to eq(2) + + recurring_invoice = subscription.invoices.order(:created_at).last + expect(recurring_invoice.total_amount_cents).to eq(5_000_000) + expect(recurring_invoice.fees_amount_cents).to eq(5_000_000 + 1000000 * 10 * 100) + expect(recurring_invoice.progressive_billing_credit_amount_cents).to eq(1000000 * 10 * 100) + end + end + + context "when enabled when usage has already been invoiced" do + it "correctly calculates thresholds and generates correct progressive billing invoices" do + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 + + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + + subscription = customer.subscriptions.first + + # no invoice expected to be generated + travel_to time_0 + 5.days + + ingest_event(subscription, billable_metric, 1000000) + expect(Invoice.count).to eq(0) + + travel_to time_0 + 31.days + perform_billing + expect(Invoice.count).to eq(1) + + invoice = Invoice.last + expect(invoice.invoice_type).to eq("subscription") + expect(invoice.total_amount_cents).to eq(5_000_000 + 1000000 * 10 * 100) + + # update plan and add a threshold + travel_to time_0 + 36.days + + update_plan(plan, {usage_thresholds: [ + { + amount_cents: usage_threshold.amount_cents + } + ]}) + + perform_all_enqueued_jobs + perform_usage_update + + expect(Invoice.count).to eq(1) + end + end +end diff --git a/spec/scenarios/subscriptions/terminate_ended_spec.rb b/spec/scenarios/subscriptions/terminate_ended_spec.rb new file mode 100644 index 0000000..0f0b6ee --- /dev/null +++ b/spec/scenarios/subscriptions/terminate_ended_spec.rb @@ -0,0 +1,514 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Subscriptions Termination Scenario" do + let(:organization) { create(:organization, webhook_url: nil, email_settings: "") } + + let(:timezone) { "Europe/Paris" } + let(:customer) { create(:customer, organization:, timezone:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 1000, + pay_in_advance: false + ) + end + + let(:creation_time) { Time.zone.parse("2023-09-05T00:00:00") } + let(:subscription_at) { Time.zone.parse("2023-09-05T00:00:00") } + let(:ending_at) { Time.zone.parse("2023-09-06T00:00:00") } + + context "when timezone is Europe/Paris" do + it "terminates the subscription when it reaches its ending date" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(67) # 1000 / 30 + expect(invoice.issuing_date.iso8601).to eq("2023-09-06") + end + end + end + + context "when timezone is Asia/Bangkok" do + let(:timezone) { "Asia/Bangkok" } + let(:creation_time) { DateTime.new(2023, 9, 5, 0, 0) } + let(:subscription_at) { DateTime.new(2023, 9, 5, 0, 0) } + let(:ending_at) { DateTime.new(2023, 9, 6, 0, 0) } + + it "terminates the subscription when it reaches its ending date" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(67) # 1000 / 30 + expect(invoice.issuing_date.iso8601).to eq("2023-09-06") + end + end + end + + context "when timezone is America/Bogota" do + let(:timezone) { "America/Bogota" } + + it "terminates the subscription when it reaches its ending date" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(67) # 1000 / 30 + expect(invoice.issuing_date.iso8601).to eq("2023-09-05") + end + end + end + + context "when ending at is the same as billing date" do + let(:ending_at) { DateTime.new(2023, 10, 5, 0, 0) } + + it "bills correctly previous billing period if it has not been billed yet" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(1000) + expect(invoice.issuing_date.iso8601).to eq("2023-10-05") + end + end + + context "when plan is pay in advance" do + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 1000, + pay_in_advance: true + ) + end + + it "does not issue credit note and does not bill previous period" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(2) + expect(customer.credit_notes.count).to eq(0) + expect(invoice.total_amount_cents).to eq(0) + expect(invoice.issuing_date.iso8601).to eq("2023-10-05") + end + end + end + + context "when ending_at is not set and subscription is terminated on the day of creation" do + it "bills correctly only 1 day" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: nil + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(creation_time + 5.hours) do + Subscriptions::TerminateService.call(subscription:) + + perform_all_enqueued_jobs + + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(33) + expect(invoice.issuing_date.iso8601).to eq("2023-09-05") + end + end + end + + context "with America/Bogota timezone" do + let(:timezone) { "America/Bogota" } + + it "bills correctly previous billing period if it has not been billed yet" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at - 5.hours) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(1000) + expect(invoice.issuing_date.iso8601).to eq("2023-10-04") + end + end + end + + context "with Asia/Bangkok timezone" do + let(:timezone) { "Asia/Bangkok" } + + it "bills correctly previous billing period if it has not been billed yet" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at - 5.hours) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(1000) + expect(invoice.issuing_date.iso8601).to eq("2023-10-05") + end + end + end + + context "when billing time is calendar" do + let(:creation_time) { DateTime.new(2023, 8, 1, 0, 0) } + let(:subscription_at) { DateTime.new(2023, 8, 1, 0, 0) } + let(:ending_at) { DateTime.new(2023, 10, 1, 0, 0) } + + it "bills correctly previous billing period if it has not been billed yet" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(1000) + expect(invoice.issuing_date.iso8601).to eq("2023-10-01") + end + end + + context "when plan is pay in advance" do + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 1000, + pay_in_advance: true + ) + end + + it "does not issue credit note and does not bill previous period" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + perform_billing + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(3) + expect(customer.credit_notes.count).to eq(0) + expect(invoice.total_amount_cents).to eq(0) + expect(invoice.issuing_date.iso8601).to eq("2023-10-01") + end + end + end + + context "with already triggered subscription job" do + it "bills correctly the previous period since billing job is not performed on ending day" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601, + ending_at: ending_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(ending_at + 5.minutes) do + perform_billing + + expect(subscription.reload).to be_active + expect(subscription.reload.invoices.count).to eq(0) + end + + travel_to(ending_at + 15.minutes) do + Clock::TerminateEndedSubscriptionsJob.perform_now + + perform_all_enqueued_jobs + + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(1000) + expect(invoice.issuing_date.iso8601).to eq("2023-10-01") + end + end + end + + context "with already triggered subscription job and if ending_at is not set" do + it "bills correctly only one day for manual termination case" do + subscription = nil + + travel_to(creation_time) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar", + subscription_at: subscription_at.iso8601, + ending_at: nil + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + end + + Organization.update_all(webhook_url: nil) # rubocop:disable Rails/SkipsModelValidations + WebhookEndpoint.destroy_all + + travel_to(ending_at + 5.minutes) do + perform_billing + + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_active + expect(subscription.reload.invoices.count).to eq(1) + expect(invoice.total_amount_cents).to eq(1000) + expect(invoice.issuing_date.iso8601).to eq("2023-10-01") + end + + travel_to(ending_at + 15.minutes) do + Subscriptions::TerminateService.call(subscription:) + + perform_all_enqueued_jobs + + invoice = subscription.invoices.order(created_at: :desc).first + + expect(subscription.reload).to be_terminated + expect(subscription.reload.invoices.count).to eq(2) + expect(invoice.total_amount_cents).to eq(32) + expect(invoice.issuing_date.iso8601).to eq("2023-10-01") + end + end + end + end + end +end diff --git a/spec/scenarios/subscriptions/terminate_manually_spec.rb b/spec/scenarios/subscriptions/terminate_manually_spec.rb new file mode 100644 index 0000000..ec74140 --- /dev/null +++ b/spec/scenarios/subscriptions/terminate_manually_spec.rb @@ -0,0 +1,366 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Subscription manual termination" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:, timezone: "UTC") } + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "item_count") } + let(:plan) { create(:plan, :pay_in_advance, organization:, amount_cents: 100_00) } + let(:subscription_date) { DateTime.new(2025, 2, 8) } + let(:termination_date) { DateTime.new(2025, 2, 21) } + let(:events_date) { DateTime.new(2025, 2, 10) } + + before do + create(:tax, :applied_to_billing_entity, organization:, rate: 20) + create(:standard_charge, billable_metric:, plan:, properties: {amount: "1"}) + end + + def credit_note + @credit_note ||= subscription_invoice.credit_notes.sole + end + + def subscription + @subscription ||= customer.subscriptions.find_by(external_id: customer.external_id) + end + + def subscription_invoice + @subscription_invoice ||= subscription.invoices.order(:number).first + end + + def billed_invoice + @billed_invoice ||= subscription.invoices.order(:number).last + end + + def create_subscription(params = {}) + travel_to(subscription_date) do + super( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }.merge(params) + ) + end + end + + def terminate_subscription_without_jobs(subscription, params = {}, raise_on_error: true) + travel_to(termination_date) do + terminate_subscription(subscription, params:, perform_jobs: false, raise_on_error:) + end + subscription.reload + # we don't have a subscription invoice for pending or pay-in-arrears subscriptions + subscription_invoice&.reload + end + + # 5 events * 10 item_count * 1 euro = 50 euro + def add_events_to_subscription + travel_to(events_date) do + 5.times do + create_event( + {code: billable_metric.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: subscription.external_id, + properties: {"item_count" => 10}} + ) + end + end + end + + def perform_termination_jobs + travel_to(termination_date) do + perform_all_enqueued_jobs + end + end + + def expect_charge_fees_to_be_billed + charge_fee = billed_invoice.fees.charge.sole + + expect(charge_fee).to be_present + expect(charge_fee.amount_cents).to eq(50_00) + expect(charge_fee.taxes_amount_cents).to eq(10_00) + end + + def expect_credit_note_to_be_created(amount_cents) + expect(subscription.credit_notes.count).to eq(1) + expect(subscription.on_termination_credit_note).to eq("credit") + expect(subscription_invoice.credit_notes.sole.credit_amount_cents).to eq(amount_cents) + end + + def expect_pay_in_advance_to_be_billed + expect(subscription.status).to eq("active") + + expect(subscription_invoice.credit_notes.count).to eq(0) + + # (21 days out of 28 days = 75% of the month = 75 euro) + 20% tax = 90 euro + expect(subscription_invoice.taxes_amount_cents).to eq(15_00) + expect(subscription_invoice.total_amount_cents).to eq(90_00) + end + + def expect_subscription_to_be_terminated(on_termination_credit_note: "credit", on_termination_invoice: "generate") + expect(subscription.status).to eq("terminated") + expect(subscription.on_termination_credit_note).to eq(on_termination_credit_note) + expect(subscription.on_termination_invoice).to eq(on_termination_invoice) + end + + def expect_credit_note_to_be_created(refund_amount_cents: 0) + # (7 unused days = 7 / 28 * 100 = 25 euro) + 20% tax = 30 euro + total_amount_cents = 30_00 + credit_amount_cents = total_amount_cents - refund_amount_cents + expect(credit_note.total_amount_cents).to eq(total_amount_cents) + expect(credit_note.credit_amount_cents).to eq(credit_amount_cents) + expect(credit_note.refund_amount_cents).to eq(refund_amount_cents) + expect(credit_note.balance_amount_cents).to eq(credit_amount_cents) + end + + context "with pay-in-advance subscription" do + context "when terminating with default behaviors" do + it "creates credit note for unconsumed subscription fee and generates invoice" do + create_subscription + expect_pay_in_advance_to_be_billed + + add_events_to_subscription + + terminate_subscription_without_jobs(subscription) + + expect_subscription_to_be_terminated + expect_credit_note_to_be_created + + perform_termination_jobs + + expect(subscription.invoices.count).to eq(2) + expect(billed_invoice.status).to eq("finalized") + expect(billed_invoice.sub_total_excluding_taxes_amount_cents).to eq(50_00) + expect(billed_invoice.taxes_amount_cents).to eq(10_00) + expect(billed_invoice.credit_notes_amount_cents).to eq(30_00) + expect(billed_invoice.total_amount_cents).to eq(30_00) + + expect_charge_fees_to_be_billed + + expect(credit_note.reload.balance_amount_cents).to eq(0) + end + end + + context "when terminating with `on_termination_credit_note=refund`" do + it "creates credit note for unconsumed subscription fee", :premium do + create_subscription + expect_pay_in_advance_to_be_billed + + add_events_to_subscription + + create_payment(customer, subscription_invoice, 75_00) + + terminate_subscription_without_jobs(subscription, {on_termination_credit_note: "refund"}) + + expect_subscription_to_be_terminated(on_termination_credit_note: "refund") + + # We started on the 8th and terminated on the 21st, so we have: + # - 14 days of used subscription (60 euros) + # - 7 days of unused subscription (30 euros) + # Since we paid 75 euros, we will refund 15 euros and credit 15 euros. + expect_credit_note_to_be_created(refund_amount_cents: 15_00) + + perform_termination_jobs + + expect(subscription.invoices.count).to eq(2) + expect(billed_invoice.status).to eq("finalized") + expect(billed_invoice.sub_total_excluding_taxes_amount_cents).to eq(50_00) + expect(billed_invoice.taxes_amount_cents).to eq(10_00) + expect(billed_invoice.credit_notes_amount_cents).to eq(15_00) + expect(billed_invoice.total_amount_cents).to eq(45_00) + + expect_charge_fees_to_be_billed + + expect(credit_note.reload.balance_amount_cents).to eq(0) + end + end + + context "when terminating with `on_termination_credit_note=skip`" do + it "skips credit note creation for unconsumed subscription fee" do + create_subscription + expect_pay_in_advance_to_be_billed + + add_events_to_subscription + + terminate_subscription_without_jobs(subscription, {on_termination_credit_note: "skip"}) + + expect_subscription_to_be_terminated(on_termination_credit_note: "skip") + + expect(subscription_invoice.credit_notes.count).to eq(0) + + perform_termination_jobs + + expect(subscription.invoices.count).to eq(2) + expect(billed_invoice.status).to eq("finalized") + expect(billed_invoice.sub_total_excluding_taxes_amount_cents).to eq(50_00) + expect(billed_invoice.taxes_amount_cents).to eq(10_00) + expect(billed_invoice.credit_notes_amount_cents).to eq(0) + expect(billed_invoice.total_amount_cents).to eq(60_00) + end + end + + context "when terminating with `on_termination_invoice=skip`" do + it "skips invoice creation for charge usage" do + create_subscription + expect_pay_in_advance_to_be_billed + + add_events_to_subscription + + terminate_subscription_without_jobs(subscription, {on_termination_invoice: "skip"}) + + expect_subscription_to_be_terminated(on_termination_invoice: "skip") + expect_credit_note_to_be_created + + perform_termination_jobs + + expect(subscription.invoices.count).to eq(1) + end + end + + context "when subscription is terminated on the same day it was created" do + let(:termination_date) { subscription_date + 12.hours } + let(:events_date) { subscription_date + 5.hours } + + it "handles same-day termination correctly" do + create_subscription + expect_pay_in_advance_to_be_billed + + add_events_to_subscription + + terminate_subscription_without_jobs(subscription) + + expect(subscription.status).to eq("terminated") + expect(subscription.on_termination_credit_note).to eq("credit") + + # (20 unused days = 20 / 28 * 100 = 71.43 euro) + 20% tax = 85.71 euro + credit_note = subscription_invoice.credit_notes.sole + + # 7142.85714 rounded to 7143, then AdjustAmountsWithRoundingService subtracts 1 + expect(credit_note.items.sum(&:amount_cents)).to eq 71_43 + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 71_43, + taxes_amount_cents: 14_29, + credit_amount_cents: 85_72, + balance_amount_cents: 85_72, + total_amount_cents: 85_72 + ) + expect(credit_note.items.sum(&:amount_cents) + credit_note.applied_taxes.sum(:amount_cents)).to eq(credit_note.total_amount_cents) + + perform_termination_jobs + + expect(subscription.invoices.count).to eq(2) + expect(billed_invoice.status).to eq("finalized") + + # 50 euro charge fee + expect(billed_invoice.sub_total_excluding_taxes_amount_cents).to eq(50_00) + expect(billed_invoice.taxes_amount_cents).to eq(10_00) + expect(billed_invoice.credit_notes_amount_cents).to eq(60_00) + expect(billed_invoice.total_amount_cents).to eq(0) + + expect_charge_fees_to_be_billed + expect(credit_note.reload.balance_amount_cents).to eq(25_72) # 71_43 + 14_29 - 60 + end + end + + context "when terminating pending subscription" do + it "cancels the subscription and ignores credit note parameter" do + create_subscription(subscription_at: 5.days.from_now) + + expect(subscription.status).to eq("pending") + expect(subscription_invoice).to be_nil + + terminate_subscription_without_jobs(subscription, {status: "pending", on_termination_credit_note: "credit"}) + + expect(response).to have_http_status(:ok) + + expect(subscription.reload.status).to eq("canceled") + expect(subscription.on_termination_credit_note).to be_nil + expect(subscription.canceled_at).to be_present + end + end + end + + context "with pay-in-arrears subscription" do + let(:plan) { create(:plan, organization:, amount_cents: 100_00) } + + context "when trying to set on_termination_credit_note parameter" do + it "ignores the parameter since pay-in-arrears plans don't generate credit notes" do + create_subscription + + expect(subscription.status).to eq("active") + expect(subscription_invoice).to be_nil + + add_events_to_subscription + + terminate_subscription_without_jobs(subscription, {on_termination_credit_note: "credit"}) + + expect(subscription.status).to eq("terminated") + expect(subscription.on_termination_credit_note).to be_nil + + perform_termination_jobs + + expect(subscription.invoices.count).to eq(1) + expect(billed_invoice.status).to eq("finalized") + + # 50 euro subscription fee + 50 euro charge fee = 100 euro + expect(billed_invoice.sub_total_excluding_taxes_amount_cents).to eq(100_00) + expect(billed_invoice.taxes_amount_cents).to eq(20_00) + expect(billed_invoice.credit_notes_amount_cents).to eq(0) + expect(billed_invoice.total_amount_cents).to eq(120_00) + + subscription_fee = billed_invoice.fees.subscription.sole + expect(subscription_fee).to be_present + expect(subscription_fee.amount_cents).to eq(50_00) + expect(subscription_fee.taxes_amount_cents).to eq(10_00) + + expect_charge_fees_to_be_billed + end + end + + context "when terminating with `on_termination_invoice=skip`" do + it "skips invoice creation for charge usage" do + create_subscription + + add_events_to_subscription + + terminate_subscription_without_jobs(subscription, {on_termination_invoice: "skip"}) + + expect_subscription_to_be_terminated(on_termination_credit_note: nil, on_termination_invoice: "skip") + + perform_termination_jobs + + expect(subscription.invoices.count).to eq(0) + end + end + end + + context "when the parameters are invalid" do + it "returns a validation error" do + create_subscription + + # when the on_termination_credit_note is invalid + terminate_subscription_without_jobs(subscription, {on_termination_credit_note: "invalid"}, raise_on_error: false) + + expect(response).to have_http_status(:unprocessable_content) + expect(json).to eq( + {code: "validation_errors", error: "Unprocessable Entity", error_details: {on_termination_credit_note: ["invalid_value"]}, status: 422} + ) + + # when the on_termination_invoice is invalid + terminate_subscription_without_jobs(subscription, {on_termination_invoice: "invalid"}, raise_on_error: false) + + expect(response).to have_http_status(:unprocessable_content) + expect(json).to eq( + {code: "validation_errors", error: "Unprocessable Entity", error_details: {on_termination_invoice: ["invalid_value"]}, status: 422} + ) + + # when the subscription is not found + delete_with_token(organization, "/api/v1/subscriptions/#{SecureRandom.uuid}") + + expect(response).to have_http_status(:not_found) + expect(json).to eq( + {code: "subscription_not_found", error: "Not Found", status: 404} + ) + end + end +end diff --git a/spec/scenarios/subscriptions/upgrade_spec.rb b/spec/scenarios/subscriptions/upgrade_spec.rb new file mode 100644 index 0000000..ac3226d --- /dev/null +++ b/spec/scenarios/subscriptions/upgrade_spec.rb @@ -0,0 +1,581 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Subscription Upgrade Scenario", transaction: false do + let(:organization) { create(:organization, webhook_url: false, email_settings: []) } + + let(:customer) { create(:customer, organization:) } + + let(:monthly_plan) do + create( + :plan, + organization:, + interval: "monthly", + amount_cents: 1000, + pay_in_advance: true + ) + end + + let(:yearly_plan) do + create( + :plan, + organization:, + interval: "yearly", + amount_cents: 12_000, + pay_in_advance: true + ) + end + + let(:subscription_at) { DateTime.new(2023, 6, 29, 12, 12) } + + it "upgrades and bill subscriptions on a regular basis" do + subscription = nil + + # NOTE: Jun 29th: create the subscription + travel_to(subscription_at) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: monthly_plan.code, + billing_time: "anniversary", + subscription_at: subscription_at.iso8601 + } + ) + + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + + invoice = subscription.invoices.last + expect(invoice.fees_amount_cents).to eq(monthly_plan.amount_cents) + expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq("2023-06-29T00:00:00Z") + expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq("2023-07-28T23:59:59Z") + end + + # NOTE: July 29th: Bill subscription + travel_to(DateTime.new(2023, 7, 29, 12, 12)) do + expect { perform_billing }.to change { subscription.reload.invoices.count } + + expect(subscription.invoices.count).to eq(2) + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees_amount_cents).to eq(monthly_plan.amount_cents) + expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq("2023-07-29T00:00:00Z") + expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq("2023-08-28T23:59:59Z") + expect(invoice.invoice_subscriptions.first.charges_from_datetime.iso8601).to eq("2023-06-29T12:12:00Z") + expect(invoice.invoice_subscriptions.first.charges_to_datetime.iso8601).to eq("2023-07-28T23:59:59Z") + end + + # NOTE: August 29th: Bill subscription + travel_to(DateTime.new(2023, 8, 29, 12, 12)) do + expect { perform_billing }.to change { subscription.reload.invoices.count } + + expect(subscription.invoices.count).to eq(3) + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees_amount_cents).to eq(monthly_plan.amount_cents) + expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq("2023-08-29T00:00:00Z") + expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq("2023-09-28T23:59:59Z") + expect(invoice.invoice_subscriptions.first.charges_from_datetime.iso8601).to eq("2023-07-29T00:00:00Z") + expect(invoice.invoice_subscriptions.first.charges_to_datetime.iso8601).to eq("2023-08-28T23:59:59Z") + end + + # NOTE: On september 28th: Upgrade to the yearly plan + travel_to(DateTime.new(2023, 9, 28, 5, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: yearly_plan.code, + billing_time: "anniversary" + } + ) + + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq(4) + expect(customer.invoices.count).to eq(4) + + # expect(invoice.invoice_subscriptions.first.from_datetime.iso8601).to eq('2023-08-29T00:00:00Z') + # expect(invoice.invoice_subscriptions.first.to_datetime.iso8601).to eq('2023-09-28T23:59:59Z') + expect(subscription.invoice_subscriptions.order(created_at: :desc).first.charges_from_datetime.iso8601) + .to eq("2023-08-29T00:00:00Z") + expect(subscription.invoice_subscriptions.order(created_at: :desc).first.charges_to_datetime.iso8601) + .to eq("2023-09-28T05:00:00Z") + + new_subscription = customer.subscriptions.order(created_at: :asc).last + expect(new_subscription.plan.code).to eq(yearly_plan.code) + expect(new_subscription).to be_active + expect(new_subscription.invoices.count).to eq(1) + + invoice = new_subscription.invoices.last + + expect(customer.credit_notes.first.credit_amount_cents).to eq(32) # 1000 / 31 + + number_of_days = (DateTime.new(2024, 6, 29, 0, 0) - DateTime.new(2023, 9, 28, 0, 0)).to_i + single_day_price = 12_000.fdiv(366) + + expect(invoice.fees_amount_cents).to eq((number_of_days * single_day_price).round) + end + end + + context "when there are fixed charges" do + let(:plan) { create(:plan, :monthly, pay_in_advance: false, amount_cents: 100, organization:) } + let(:plan_upgrade) { create(:plan, :monthly, pay_in_advance: false, amount_cents: 10000, organization:) } + let(:add_ons) { create_list(:add_on, 3, organization:) } + let(:fixed_charges_plan) { + [ + create(:fixed_charge, plan:, add_on: add_ons[0], properties: {amount: "1"}, units: 10, pay_in_advance:, prorated:), + create(:fixed_charge, plan:, add_on: add_ons[1], properties: {amount: "3"}, units: 5, pay_in_advance:, prorated:) + ] + } + let(:fixed_charges_plan_upgrade) { + [ + create(:fixed_charge, plan: plan_upgrade, add_on: add_ons[1], properties: {amount: "10"}, units: 10, pay_in_advance:, prorated:), + create(:fixed_charge, plan: plan_upgrade, add_on: add_ons[2], properties: {amount: "20"}, units: 1, pay_in_advance:, prorated:) + ] + } + let(:subscription_at) { DateTime.new(2023, 7, 19, 12, 12) } + + before do + fixed_charges_plan + fixed_charges_plan_upgrade + end + + context "when fixed charges are in_advance" do + let(:pay_in_advance) { true } + + context "when fixed charges are prorated" do + # In this case we have fixed_charges that were paid_in advance in the previous subscription + # so they were already partially paid. When prorating new subscription charges, we should take in account the + # already paid amount + let(:prorated) { true } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees.fixed_charge.count).to eq(2) + # prorated in advance - created immediately + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000 * 13 / 31, 1500 * 13 / 31]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-07-19T12:12:00.000Z", + "fixed_charges_to_datetime" => "2023-07-31T23:59:59.999Z" + ) + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(1).to(2) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + # prorated in advance - created at the beginning of the month + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 12, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_upgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq(3) + new_subscription = subscription.reload.next_subscription + # it a new invoice with charges for old subscription - pay_in_arrears subscription fee + + # prorated charges for the new plan + invoice = subscription.invoices.order(:created_at).last + expect(invoice.invoice_subscriptions.count).to eq(2) + expect(invoice.invoice_subscriptions.map(&:subscription)).to match_array([subscription, new_subscription]) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.subscription.map(&:amount_cents)).to match_array([(100 * 20 / 31.0).round]) + end + new_subscription = subscription.reload.next_subscription + expect(new_subscription).to be_active + expect(new_subscription.invoices.count).to be(1) + invoice = new_subscription.invoices.first + expect(invoice.fees.fixed_charge.count).to eq(2) + # old_plan: + # create(:fixed_charge, plan:, add_on: add_ons[1], properties: {amount: "3"}, units: 5, pay_in_advance:, prorated:) + # new_plan: + # create(:fixed_charge, plan: plan_upgrade, add_on: add_ons[1], properties: {amount: "10"}, units: 10, pay_in_advance:, prorated:), + # fixed_charge for add_on 1 was already prorated in the beginning of month for the full month, + # 1500 has been paid, but it was only actually active 20 days. so when prorating the same add_on + # with the new price we should deduct the amount that was already paid (remaining of already paid amount) + prorated_new_price = (10000 * 11 / 31.0).round + already_paid_for_this_period_this_charge = (1500 * 11 / 31.0).round + left_to_pay_existing_addon = prorated_new_price - already_paid_for_this_period_this_charge + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([left_to_pay_existing_addon, (2000 * 11 / 31.0).round]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-21T12:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # Now we do charge the full month, as it's pay in advance + expect { perform_billing }.to change { new_subscription.reload.invoices.count }.from(1).to(2) + new_sub_invoice = new_subscription.invoices.order(created_at: :asc).last + expect(new_sub_invoice.fees.fixed_charge.count).to eq(2) + expect(new_sub_invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(new_sub_invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-09-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-09-30T23:59:59.999Z" + ) + end + end + + context "when upgrade happens at the same day when sub starts" do + let(:subscription_at) { DateTime.new(2025, 12, 22, 12, 12) } + + it "calculates all fees" do + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees.fixed_charge.count).to eq(2) + # create(:fixed_charge, plan:, add_on: add_ons[0], properties: {amount: "1"}, units: 10, pay_in_advance:, prorated:), + # create(:fixed_charge, plan:, add_on: add_ons[1], properties: {amount: "3"}, units: 5, pay_in_advance:, prorated:) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([(1000.0 * 10 / 31).round, (1500.0 * 10 / 31).round]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2025-12-22T12:12:00.000Z", + "fixed_charges_to_datetime" => "2025-12-31T23:59:59.999Z" + ) + + travel_to(subscription_at + 1.hour) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_upgrade.code, + billing_time: "calendar" + } + ) + end + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq(2) + new_subscription = subscription.reload.next_subscription + expect(new_subscription).to be_active + expect(new_subscription.invoices.count).to be(1) + invoice = new_subscription.invoices.first + expect(invoice.fees.fixed_charge.count).to eq(2) + # create(:fixed_charge, plan: plan_upgrade, add_on: add_ons[1], properties: {amount: "10"}, units: 10, pay_in_advance:, prorated:), + # create(:fixed_charge, plan: plan_upgrade, add_on: add_ons[2], properties: {amount: "20"}, units: 1, pay_in_advance:, prorated:) + # old fee was prorated for the same number of days, so we fully deduct it + old_fee_prorated_amount = (1500.0 * 10 / 31).round + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([(10000.0 * 10 / 31).round - old_fee_prorated_amount, 2000 * 10 / 31]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2025-12-22T13:12:00.000Z", + "fixed_charges_to_datetime" => "2025-12-31T23:59:59.999Z" + ) + end + end + + context "when original fee was prorated for less than a month" do + let(:subscription_at) { DateTime.new(2025, 12, 10, 12, 12) } + let(:upgrade_at) { DateTime.new(2025, 12, 22, 12, 12) } + + it "calculates all fees" do + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([(1000.0 * 22 / 31).round, (1500.0 * 22 / 31).round]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2025-12-10T12:12:00.000Z", + "fixed_charges_to_datetime" => "2025-12-31T23:59:59.999Z" + ) + travel_to(upgrade_at) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_upgrade.code, + billing_time: "calendar" + } + ) + end + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq(2) + new_subscription = subscription.reload.next_subscription + expect(new_subscription).to be_active + expect(new_subscription.invoices.count).to be(1) + invoice = new_subscription.invoices.first + expect(invoice.fees.fixed_charge.count).to eq(2) + # old fee was prorated for 22 days out of 31, so we need to get "price of one day" and multiply by the number of days in the new period + old_fee_prorated_amount = (1500.0 * 22 / 31).round + old_fee_covers_current_period = (old_fee_prorated_amount * 10.0 / 22).round + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([(10000.0 * 10 / 31).round - old_fee_covers_current_period, (2000 * 10 / 31).round]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2025-12-22T12:12:00.000Z", + "fixed_charges_to_datetime" => "2025-12-31T23:59:59.999Z" + ) + end + end + end + + context "when fixed charges are not prorated" do + let(:prorated) { false } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(1) + invoice = subscription.invoices.first + expect(invoice.fees.fixed_charge.count).to eq(2) + # charges are not prorated + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-07-19T12:12:00.000Z", + "fixed_charges_to_datetime" => "2023-07-31T23:59:59.999Z" + ) + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(1).to(2) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + # charges are not prorated + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 12, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_upgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_terminated + new_subscription = subscription.reload.next_subscription + expect(subscription.invoices.count).to eq(3) + # it creates only subscription invoice for the old plan and fees for the new plan + invoice = subscription.invoices.order(:created_at).last + expect(invoice.invoice_subscriptions.count).to eq(2) + expect(invoice.invoice_subscriptions.map(&:subscription)).to match_array([subscription, new_subscription]) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.subscription.map(&:amount_cents)).to match_array([(100 * 20 / 31.0).round]) + # this invoice include full fixed charges for the new plan + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-21T12:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + end + new_subscription = subscription.reload.next_subscription + expect(new_subscription).to be_active + expect(new_subscription.invoices.count).to be(1) + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # Now we do charge the new month for the new subscription (paid in advance) + expect { perform_billing }.to change { new_subscription.reload.invoices.count }.from(1).to(2) + new_sub_invoice = new_subscription.invoices.order(created_at: :asc).last + expect(new_sub_invoice.fees.fixed_charge.count).to eq(2) + expect(new_sub_invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(new_sub_invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-09-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-09-30T23:59:59.999Z" + ) + end + end + end + end + + context "when fixed charges are in_arrears" do + let(:pay_in_advance) { false } + + context "when fixed charges are prorated" do + let(:prorated) { true } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(0) + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(0).to(1) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000 * 13 / 31, 1500 * 13 / 31]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-07-19T12:12:00.000Z", + "fixed_charges_to_datetime" => "2023-07-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 12, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_upgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq(2) + # it creates fees for pay in arrears prorated charges + termination_invoice = subscription.invoices.order(:created_at).last + # the charges were active 21 days out of 31, because upgrade happened on 21st... + expect(termination_invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000 * 21 / 31, 1500 * 21 / 31]) + expect(termination_invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-21T12:00:00.000Z" + ) + end + new_subscription = subscription.reload.next_subscription + expect(new_subscription).to be_active + expect(new_subscription.invoices.count).to be(0) + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # Now we do charge the rest of the month for the new subscription + expect { perform_billing }.to change { new_subscription.reload.invoices.count }.from(0).to(1) + new_sub_invoice = new_subscription.invoices.first + expect(new_sub_invoice.fees.fixed_charge.count).to eq(2) + expect(new_sub_invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000 * 11 / 31, (2000 * 11 / 31.0).round]) + expect(new_sub_invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-21T12:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + end + + travel_to(DateTime.new(2023, 10, 1, 0, 0)) do + # finally charge the full subscription + expect { perform_billing }.to change { new_subscription.reload.invoices.count }.from(1).to(2) + end + invoice = new_subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-09-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-09-30T23:59:59.999Z" + ) + end + end + + context "when fixed charges are not prorated" do + let(:prorated) { false } + + it "calculates all fees" do + # 2023, 7, 19, 12, 12 + travel_to(subscription_at) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "calendar" + }) + end + subscription = customer.subscriptions.first + expect(subscription).to be_active + expect(subscription.invoices.count).to eq(0) + travel_to(DateTime.new(2023, 8, 1, 0, 0)) do + expect { perform_billing }.to change { subscription.reload.invoices.count }.from(0).to(1) + end + + invoice = subscription.invoices.order(created_at: :asc).last + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-07-19T12:12:00.000Z", + "fixed_charges_to_datetime" => "2023-07-31T23:59:59.999Z" + ) + + travel_to(DateTime.new(2023, 8, 21, 12, 0, 0)) do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan_upgrade.code, + billing_time: "calendar" + } + ) + + expect(subscription.reload).to be_terminated + expect(subscription.invoices.count).to eq(2) + # it creates fees for pay in arrears full charges + termination_invoice = subscription.invoices.order(:created_at).last + expect(termination_invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([1000, 1500]) + expect(termination_invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-21T12:00:00.000Z" + ) + end + new_subscription = subscription.reload.next_subscription + expect(new_subscription).to be_active + expect(new_subscription.invoices.count).to be(0) + + travel_to(DateTime.new(2023, 9, 1, 0, 0)) do + # pay in arrears full charges for the end of the month when it was prorated + expect { perform_billing }.to change { new_subscription.reload.invoices.count }.from(0).to(1) + new_sub_invoice = new_subscription.invoices.first + expect(new_sub_invoice.fees.fixed_charge.count).to eq(2) + expect(new_sub_invoice.fees.fixed_charge.map(&:amount_cents)).to match_array([10000, 2000]) + expect(new_sub_invoice.fees.fixed_charge.sample.properties).to include( + "fixed_charges_from_datetime" => "2023-08-21T12:00:00.000Z", + "fixed_charges_to_datetime" => "2023-08-31T23:59:59.999Z" + ) + end + end + end + end + end +end diff --git a/spec/scenarios/taxes_on_invoice_spec.rb b/spec/scenarios/taxes_on_invoice_spec.rb new file mode 100644 index 0000000..cedc482 --- /dev/null +++ b/spec/scenarios/taxes_on_invoice_spec.rb @@ -0,0 +1,356 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Taxes on Invoice Scenarios", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + + before do + stub_pdf_generation + organization + end + + context "when timezone is negative and not the same day as UTC" do + it "creates an invoice for the expected period" do + travel_to(DateTime.new(2023, 1, 1)) do + create_tax({name: "Banking rates", code: "banking_rates", rate: 10.0}) + create_tax({name: "Sales tax - FR", code: "sales_tax_fr", rate: 0.0}) + create_tax({name: "Sales tax", code: "sales_tax", rate: 20.0}) + + create_or_update_customer({external_id: "customer-1"}) + + create_metric({name: "FX Transfers", code: "fx_transfers", aggregation_type: "sum_agg", field_name: "total"}) + fx_transfers = organization.billable_metrics.find_by(code: "fx_transfers") + create_metric({name: "Cards", code: "cards", aggregation_type: "count_agg"}) + cards = organization.billable_metrics.find_by(code: "cards") + + create_plan( + { + name: "P1", + code: "plan_code", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + tax_codes: ["banking_rates"], + charges: [ + { + billable_metric_id: fx_transfers.id, + charge_model: "standard", + properties: {amount: "1"}, + tax_codes: [organization.taxes.find_by(code: "sales_tax_fr").code] + }, + { + billable_metric_id: cards.id, + charge_model: "standard", + min_amount_cents: 5000, + properties: {amount: "30"}, + tax_codes: [organization.taxes.find_by(code: "sales_tax").code] + } + ] + } + ) + plan = organization.plans.find_by(code: "plan_code") + + create_subscription( + { + external_customer_id: "customer-1", + external_id: "sub_external_id", + plan_code: plan.code + } + ) + + create_coupon( + { + name: "coupon1", + code: "coupon1_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 1000, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 15.days, + reusable: false + } + ) + apply_coupon({external_customer_id: "customer-1", coupon_code: "coupon1_code"}) + + create_event( + { + code: fx_transfers.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id", + properties: {total: 50} + } + ) + + create_event( + { + code: cards.code, + transaction_id: SecureRandom.uuid, + external_subscription_id: "sub_external_id" + } + ) + end + + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + customer = organization.customers.find_by(external_id: "customer-1") + invoice = customer.invoices.first + fees = invoice.fees + + expect(invoice.fees.count).to eq(4) + + # Subscription fee + expect(fees.subscription.first).to have_attributes( + amount_cents: 10_000, + taxes_amount_cents: 950, + taxes_rate: 10.0, + # fee amount cents * coupon amount / total fees amount (100 * 10 / 200) + precise_coupons_amount_cents: 500 + ) + + fx_transfers = organization.billable_metrics.find_by(code: "fx_transfers") + cards = organization.billable_metrics.find_by(code: "cards") + + # FX Transfers fee + fx_transfers_fee = fees.charge.find_by(charge: fx_transfers.charges.first) + expect(fx_transfers_fee).to have_attributes( + amount_cents: 5000, + taxes_amount_cents: 0, + taxes_rate: 0.0, + precise_coupons_amount_cents: 250, # 50 * 10 / 200 + events_count: 1, + units: 50 + ) + + # Cards fee + cards_fee = fees.charge.where(true_up_parent_fee_id: nil).find_by(charge: cards.charges.first) + expect(cards_fee).to have_attributes( + amount_cents: 3000, + taxes_amount_cents: 570, + taxes_rate: 20.0, + precise_coupons_amount_cents: 150, # 30 * 10 / 200 + events_count: 1, + units: 1 + ) + + # True up - Cards fee + true_up_cards_fee = fees.charge.where.not(true_up_parent_fee_id: nil).find_by(charge: cards.charges.first) + expect(true_up_cards_fee).to have_attributes( + amount_cents: 2000, + taxes_amount_cents: 380, + taxes_rate: 20.0, + precise_coupons_amount_cents: 100, # 20 * 10 / 200 + events_count: 0, + units: 1 + ) + + expect(invoice).to have_attributes( + fees_amount_cents: 20_000, + coupons_amount_cents: 1000, + taxes_amount_cents: 1900, + sub_total_excluding_taxes_amount_cents: 19_000, + sub_total_including_taxes_amount_cents: 20_900, + total_amount_cents: 20_900 + ) + end + end + + context "when coupons amount is greater than fees total amount" do + it "creates an invoice for the expected period" do + travel_to(DateTime.new(2023, 1, 1)) do + create_tax({name: "Banking rates", code: "banking_rates", rate: 10.0}) + + create_or_update_customer({external_id: "customer-1"}) + + create_plan( + { + name: "P1", + code: "plan_code", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + tax_codes: ["banking_rates"], + charges: [] + } + ) + plan = organization.plans.find_by(code: "plan_code") + + create_subscription( + { + external_customer_id: "customer-1", + external_id: "sub_external_id", + plan_code: plan.code + } + ) + + create_coupon( + { + name: "coupon1", + code: "coupon1_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 1000, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 15.days, + reusable: false + } + ) + apply_coupon({external_customer_id: "customer-1", coupon_code: "coupon1_code"}) + + create_coupon( + { + name: "coupon2", + code: "coupon2_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 11_000, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 15.days, + reusable: false + } + ) + apply_coupon({external_customer_id: "customer-1", coupon_code: "coupon2_code"}) + end + + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + customer = organization.customers.find_by(external_id: "customer-1") + invoice = customer.invoices.first + fees = invoice.fees + + expect(invoice.fees.count).to eq(1) + + # Subscription fee + expect(fees.subscription.first).to have_attributes( + amount_cents: 10_000, + taxes_amount_cents: 0, + taxes_rate: 10.0, + # fee amount cents * coupon amount / total fees amount (100 * 10 / 100) + precise_coupons_amount_cents: 10_000 + ) + end + end + + context "when there are multiple subscriptions and coupons are covering total amount" do + it "creates an invoice for the expected period" do + travel_to(DateTime.new(2023, 1, 1)) do + create_tax({name: "Banking rates", code: "banking_rates", rate: 10.0}) + + create_or_update_customer({external_id: "customer-1"}) + + create_plan( + { + name: "P1", + code: "plan_code", + interval: "monthly", + amount_cents: 10_000, + amount_currency: "EUR", + pay_in_advance: false, + tax_codes: ["banking_rates"], + charges: [] + } + ) + plan = organization.plans.find_by(code: "plan_code") + + create_subscription( + { + external_customer_id: "customer-1", + external_id: "sub_external_id", + plan_code: plan.code + } + ) + + create_plan( + { + name: "P2", + code: "plan_code2", + interval: "monthly", + amount_cents: 5_000, + amount_currency: "EUR", + pay_in_advance: false, + tax_codes: ["banking_rates"], + charges: [] + } + ) + plan2 = organization.plans.find_by(code: "plan_code2") + + create_subscription( + { + external_customer_id: "customer-1", + external_id: "sub_external_id2", + plan_code: plan2.code + } + ) + + create_coupon( + { + name: "coupon1", + code: "coupon1_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 10_000, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 15.days, + reusable: false + } + ) + apply_coupon({external_customer_id: "customer-1", coupon_code: "coupon1_code"}) + + create_coupon( + { + name: "coupon2", + code: "coupon2_code", + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 10_000, + amount_currency: "EUR", + expiration: "time_limit", + expiration_at: Time.current + 15.days, + reusable: false + } + ) + apply_coupon({external_customer_id: "customer-1", coupon_code: "coupon2_code"}) + end + + travel_to(DateTime.new(2023, 2, 1)) do + perform_billing + end + + customer = organization.customers.find_by(external_id: "customer-1") + invoice = customer.invoices.first + fees = invoice.fees + subscription1 = Subscription.find_by(external_id: "sub_external_id") + subscription2 = Subscription.find_by(external_id: "sub_external_id2") + + expect(invoice.fees.count).to eq(2) + + # Subscription fee1 + expect(fees.subscription.where(subscription: subscription1).first).to have_attributes( + amount_cents: 10_000, + taxes_amount_cents: 0, + taxes_rate: 10.0, + # fee amount cents * coupon amount / total fees amount (100 * 10 / 100) + precise_coupons_amount_cents: 10_000 + ) + + # Subscription fee2 + expect(fees.subscription.where(subscription: subscription2).first).to have_attributes( + amount_cents: 5_000, + taxes_amount_cents: 0, + taxes_rate: 10.0, + # fee amount cents * coupon amount / total fees amount (50 * 5 / 50) + precise_coupons_amount_cents: 5_000 + ) + end + end +end diff --git a/spec/scenarios/terminate_pay_in_advance_spec.rb b/spec/scenarios/terminate_pay_in_advance_spec.rb new file mode 100644 index 0000000..874a259 --- /dev/null +++ b/spec/scenarios/terminate_pay_in_advance_spec.rb @@ -0,0 +1,257 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Terminate Pay in Advance Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:plan) { create(:plan, pay_in_advance: true, organization:, amount_cents: 5000) } + let(:metric) { create(:billable_metric, organization:) } + + before { tax } + + it "creates expected credit note and invoice" do + ### 8 Feb: Create and terminate subscription + feb8 = DateTime.new(2023, 2, 8) + + travel_to(feb8) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + }.to change(Invoice, :count).by(1) + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(4500) # 60 / 28 * 21 + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + .and change { sub_invoice.reload.credit_notes.count }.from(0).to(1) + + term_invoice = subscription.invoices.order(sequential_id: :desc).first + expect(term_invoice.total_amount_cents).to eq(0) + + credit_note = sub_invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 3571, + taxes_amount_cents: 714, + credit_amount_cents: 4285, + total_amount_cents: 4285 # 60.0 / 28 * 20 + ) + end + end + + context "when customer is in UTC+ timezone" do + let(:customer) { create(:customer, organization:, timezone: "Asia/Tokyo") } + + it "creates expected credit note and invoice" do + ### 8 Feb: Create and terminate subscription + feb8 = DateTime.new(2023, 2, 8) + + travel_to(feb8) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + }.to change(Invoice, :count).by(1) + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(4500) # 60 / 28 * 21 + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + .and change { sub_invoice.reload.credit_notes.count }.from(0).to(1) + + term_invoice = subscription.invoices.order(sequential_id: :desc).first + expect(term_invoice.total_amount_cents).to eq(0) + + credit_note = sub_invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 3571, + taxes_amount_cents: 714, + credit_amount_cents: 4285, + total_amount_cents: 4285 # 60.0 / 28 * 20 + ) + end + end + end + + context "when customer is in UTC- timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/Los_Angeles") } + + it "creates expected credit note and invoice" do + ### 8 Feb: Create and terminate subscription + feb8 = DateTime.new(2023, 2, 8) + + travel_to(feb8) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + }.to change(Invoice, :count).by(1) + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(4715) # 60 / 28 * 22 + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + .and change { sub_invoice.reload.credit_notes.count }.from(0).to(1) + + term_invoice = subscription.invoices.order(sequential_id: :desc).first + expect(term_invoice.total_amount_cents).to eq(0) + + credit_note = sub_invoice.credit_notes.first + expect(credit_note.total_amount_cents).to eq(4500) # 3750 + (3750 * 20 / 100) + end + end + end + + context "when subscription billing is anniversary" do + it "creates expected credit note and invoice" do + ### 8 Feb: Create and terminate subscription + feb8 = DateTime.new(2023, 2, 8) + + travel_to(feb8) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + }.to change(Invoice, :count).by(1) + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(6000) # Full period is billed + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + .and change { sub_invoice.reload.credit_notes.count }.from(0).to(1) + + term_invoice = subscription.invoices.order(sequential_id: :desc).first + expect(term_invoice.total_amount_cents).to eq(0) + + credit_note = sub_invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 4821, + taxes_amount_cents: 964, + credit_amount_cents: 5785, + total_amount_cents: 5785 + ) + end + end + + context "when customer is in UTC+ timezone" do + let(:customer) { create(:customer, organization:, timezone: "Asia/Tokyo") } + + it "creates expected credit note and invoice" do + ### 8 Feb: Create and terminate subscription + feb8 = DateTime.new(2023, 2, 8) + + travel_to(feb8) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + }.to change(Invoice, :count).by(1) + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(6000) # Full period is billed + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + .and change { sub_invoice.reload.credit_notes.count }.from(0).to(1) + + term_invoice = subscription.invoices.order(sequential_id: :desc).first + expect(term_invoice.total_amount_cents).to eq(0) + + credit_note = sub_invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 4821, + taxes_amount_cents: 964, + credit_amount_cents: 5785, + total_amount_cents: 5785 + ) + end + end + end + + context "when customer is in UTC- timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/Los_Angeles") } + + it "creates expected credit note and invoice" do + ### 8 Feb: Create and terminate subscription + feb8 = DateTime.new(2023, 2, 8) + + travel_to(feb8) do + expect { + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + billing_time: "anniversary" + } + ) + }.to change(Invoice, :count).by(1) + + subscription = customer.subscriptions.find_by(external_id: customer.external_id) + sub_invoice = subscription.invoices.first + expect(sub_invoice.total_amount_cents).to eq(6000) # Full period is billed + + expect { + terminate_subscription(subscription) + }.to change { subscription.reload.status }.from("active").to("terminated") + .and change { subscription.invoices.count }.from(1).to(2) + .and change { sub_invoice.reload.credit_notes.count }.from(0).to(1) + + term_invoice = subscription.invoices.order(sequential_id: :desc).first + expect(term_invoice.total_amount_cents).to eq(0) + + credit_note = sub_invoice.credit_notes.first + expect(credit_note).to have_attributes( + sub_total_excluding_taxes_amount_cents: 4821, + taxes_amount_cents: 964, + credit_amount_cents: 5785, + total_amount_cents: 5785 + ) + end + end + end + end +end diff --git a/spec/scenarios/usage_monitoring/subscription_alerts_spec.rb b/spec/scenarios/usage_monitoring/subscription_alerts_spec.rb new file mode 100644 index 0000000..e56d7b0 --- /dev/null +++ b/spec/scenarios/usage_monitoring/subscription_alerts_spec.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Subscriptions Alerting Scenario", :premium, cache: :redis do + let(:organization) { create(:organization, premium_integrations:) } + let(:premium_integrations) { [] } + let(:plan) { create(:plan, organization:, name: "Premium Plan", code: "premium_plan", amount_cents: 49_00) } + let(:customer) { create(:customer, external_id: "cust#{external_id}", organization:) } + + let(:billable_metric) { create(:sum_billable_metric, organization:, code: "ops", field_name: "ops_count") } + let(:charge) { create(:standard_charge, billable_metric:, plan:, amount_currency: "EUR", properties: {amount: "5"}) } + + let(:bm_2) { create(:sum_billable_metric, organization:, code: "api", field_name: "api_count") } + let(:charge_2) { create(:standard_charge, billable_metric: bm_2, plan:, amount_currency: "EUR", properties: {amount: "100"}) } + + let(:external_id) { "alerting-v1" } + let(:subscription_external_id) { "sub_#{external_id}" } + + include_context "with webhook tracking" + + def send_event!(params) + create_event({ + transaction_id: "tr_#{SecureRandom.hex(16)}" + }.merge(params)) + end + + before do + charge + charge_2 + end + + it "monitors activity and trigger alerts" do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + create_alert(subscription_external_id, {alert_type: :current_usage_amount, code: :simple, thresholds: [ + {value: 15_00, code: :warn}, + {value: 30_00, code: :warn}, + {value: 50_00, code: :alert}, + {value: 1230_00, code: :block} + ]}) + alert = UsageMonitoring::Alert.find(json[:alert][:lago_id]) + + create_alert(subscription_external_id, {alert_type: :billable_metric_current_usage_amount, billable_metric_code: bm_2.code, code: :bm, + thresholds: [ + {value: 15_00, code: :warn}, + {value: 30_00, code: :warn}, + {value: 50_00, code: :alert}, + {value: 1230_00, code: :block} + ]}) + alert_on_bm = UsageMonitoring::Alert.find(json[:alert][:lago_id]) + + # NOTE: Creating alerts flags the subscription as active + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 1 + perform_usage_update + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 0 + + send_event!(code: billable_metric.code, properties: {ops_count: 2}, external_subscription_id: subscription_external_id) + # SubscriptionActivity is created by PostProcessEvents + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 1 + + perform_usage_update + + expect(UsageMonitoring::TriggeredAlert.where(alert:).count).to eq(0) + + send_event!(code: billable_metric.code, properties: {ops_count: 2}, external_subscription_id: subscription_external_id) + send_event!(code: billable_metric.code, properties: {ops_count: 2}, external_subscription_id: subscription_external_id) + + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 1 + perform_usage_update + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 0 + + ta = alert.triggered_alerts.sole + expect(ta.current_value).to eq(3000) + expect(ta.previous_value).to eq(1000) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to eq([ + {code: "warn", value: "1500.0", recurring: false}, + {code: "warn", value: "3000.0", recurring: false} + ]) + + webhooks_sent.find { |w| w[:webhook_type] == "alert.triggered" }.tap do |webhook| + expect(webhook[:object_type]).to eq("triggered_alert") + expect(webhook[:triggered_alert]).to include({ + lago_id: ta.id, + current_value: "3000.0", + previous_value: "1000.0", + triggered_at: String + }) + end + + # WITH EVENTS ON CHARGE WITH SPECIAL ALERT + send_event!(code: bm_2.code, properties: {api_count: 4}, external_subscription_id: subscription_external_id) + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 1 + perform_usage_update + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 0 + + expect(alert.triggered_alerts.count).to eq 2 + expect(alert_on_bm.triggered_alerts.count).to eq 1 + expect(webhooks_sent.count { |w| w.dig(:triggered_alert, :alert_type) == "current_usage_amount" }).to eq 2 + expect(webhooks_sent.count { |w| w.dig(:triggered_alert, :alert_type) == "billable_metric_current_usage_amount" }).to eq 1 + end + + context "with recurring thresholds" do + it "sends alert forever" do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + create_alert(subscription_external_id, {alert_type: :current_usage_amount, code: :simple, thresholds: [ + {value: 15_00, code: :warn}, + {value: 30_00, code: :warn}, + {value: 10_00, code: :alert, recurring: true} + ]}) + alert = UsageMonitoring::Alert.find(json[:alert][:lago_id]) + + send_event!(code: billable_metric.code, properties: {ops_count: 7}, external_subscription_id: subscription_external_id) + + perform_usage_update + expect(UsageMonitoring::TriggeredAlert.where(alert:).count).to eq(1) + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 0 + + ta = alert.triggered_alerts.sole + expect(ta.current_value).to eq(3500) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to eq([ + {code: "warn", value: "1500.0", recurring: false}, + {code: "warn", value: "3000.0", recurring: false} + ]) + + send_event!(code: billable_metric.code, properties: {ops_count: 4}, external_subscription_id: subscription_external_id) + + perform_usage_update + expect(UsageMonitoring::TriggeredAlert.where(alert:).count).to eq(2) + ta = alert.triggered_alerts.order(:created_at).last + expect(ta.current_value).to eq(5500) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to eq([ + {code: "alert", value: "4000.0", recurring: true}, + {code: "alert", value: "5000.0", recurring: true} + ]) + end + end + + context "with only a recurring threshold (no one-time thresholds)" do + it "triggers alerts at each recurring step without raising" do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + create_alert(subscription_external_id, { + alert_type: :current_usage_amount, + code: :recurring_only, + thresholds: [ + {value: 10_00, code: :alert, recurring: true} + ] + }) + alert = UsageMonitoring::Alert.find(json[:alert][:lago_id]) + + send_event!(code: billable_metric.code, properties: {ops_count: 7}, external_subscription_id: subscription_external_id) + perform_usage_update + + expect(alert.triggered_alerts.count).to eq(1) + ta = alert.triggered_alerts.sole + expect(ta.current_value).to eq(3_500) + expect(ta.previous_value).to eq(0) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to eq([ + {code: "alert", value: "1000.0", recurring: true}, + {code: "alert", value: "2000.0", recurring: true}, + {code: "alert", value: "3000.0", recurring: true} + ]) + + webhooks_sent.find { |w| w[:webhook_type] == "alert.triggered" }.tap do |webhook| + expect(webhook[:object_type]).to eq("triggered_alert") + expect(webhook[:triggered_alert]).to include({ + lago_id: ta.id, + current_value: "3500.0", + previous_value: "0.0", + triggered_at: String + }) + end + + send_event!(code: billable_metric.code, properties: {ops_count: 4}, external_subscription_id: subscription_external_id) + perform_usage_update + + expect(alert.triggered_alerts.count).to eq(2) + ta = alert.triggered_alerts.order(:created_at).last + expect(ta.current_value).to eq(5500) + expect(ta.previous_value).to eq(3500) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to eq([ + {code: "alert", value: "4000.0", recurring: true}, + {code: "alert", value: "5000.0", recurring: true} + ]) + end + end + + context "with billable_metric_current_usage_units alert" do + it do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + + create_alert(subscription_external_id, { + alert_type: :billable_metric_current_usage_units, + code: :bm_units, + billable_metric_code: billable_metric.code, + thresholds: [ + {value: 90, code: :warn} + ] + }) + alert_on_bm_units = UsageMonitoring::Alert.find(json[:alert][:lago_id]) + + send_event!(code: billable_metric.code, properties: {ops_count: 89}, external_subscription_id: subscription_external_id) + perform_usage_update + expect(alert_on_bm_units.triggered_alerts.count).to eq 0 + + send_event!(code: billable_metric.code, properties: {ops_count: 5}, external_subscription_id: subscription_external_id) + perform_usage_update + expect(alert_on_bm_units.triggered_alerts.count).to eq 1 + + ta = alert_on_bm_units.triggered_alerts.sole + + expect(ta.current_value).to eq(94) + expect(ta.previous_value).to eq(89) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to eq([ + {code: "warn", value: "90.0", recurring: false} + ]) + + webhooks_sent.find { |w| w[:webhook_type] == "alert.triggered" }.tap do |webhook| + expect(webhook[:object_type]).to eq("triggered_alert") + expect(webhook[:triggered_alert]).to include({ + lago_id: ta.id, + current_value: "94.0", + previous_value: "89.0", + triggered_at: String + }) + end + end + end + + context "with lifetime_usage alerts" do + let(:premium_integrations) { %i[lifetime_usage progressive_billing] } + + it do + travel_to(DateTime.new(2025, 1, 1)) do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + end + + create_alert(subscription_external_id, { + alert_type: :lifetime_usage_amount, + code: :lifetime, + thresholds: [{value: 150_00, code: :warn}] + }) + lifetime_alert = UsageMonitoring::Alert.find(json[:alert][:lago_id]) + + [DateTime.new(2025, 1, 1), DateTime.new(2025, 2, 1)].each do |month| + travel_to month + 5.days do + send_event!(code: billable_metric.code, properties: {ops_count: 10}, external_subscription_id: subscription_external_id) + perform_usage_update + end + travel_to((month + 1.month).beginning_of_month) do + perform_billing + expect(organization.triggered_alerts.count).to eq 0 + end + end + + travel_to DateTime.new(2025, 3, 15) do + send_event!(code: billable_metric.code, properties: {ops_count: 11}, external_subscription_id: subscription_external_id) + perform_usage_update + end + + expect(organization.triggered_alerts.count).to eq 1 + ta = organization.triggered_alerts.sole + expect(ta.usage_monitoring_alert_id).to eq lifetime_alert.id + expect(ta.current_value).to eq 155_00 + expect(ta.crossed_thresholds).to eq [{"code" => "warn", "value" => "15000.0", "recurring" => false}] + end + end + + context "when there is no alert" do + it "does not track activity" do + create_subscription({ + external_customer_id: customer.external_id, + external_id: subscription_external_id, + plan_code: plan.code + }) + webhooks_sent = [] + subscription = customer.subscriptions.sole + + send_event!(code: billable_metric.code, properties: {ops_count: 20}, external_subscription_id: subscription_external_id) + expect(UsageMonitoring::SubscriptionActivity.where(subscription:).count).to eq 0 + + perform_usage_update + + expect(webhooks_sent).to be_empty + end + end +end diff --git a/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_not_provided/when_the_wallet_has_a_name/renders_the_wallet_name_on_the_invoice.html.snap b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_not_provided/when_the_wallet_has_a_name/renders_the_wallet_name_on_the_invoice.html.snap new file mode 100644 index 0000000..e77d1cd --- /dev/null +++ b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_not_provided/when_the_wallet_has_a_name/renders_the_wallet_name_on_the_invoice.html.snap @@ -0,0 +1,96 @@ + + + +
+
+

Advance invoice

+
+
+
+ + + + + + + + + + + + + +
Invoice NumberACM-8924-001-001
Issue DateJan 02, 2025
Payment term0 days
+
+
+
+
+
+
+
+
From
+
ACME Corporation
+
123 Business St
+
Suite 100
+
+ 94105 + ,   + San Francisco +
+
CA
+
United States
+
billing@acme.com
+
+
+
Bill to
+
Doe Corp - John Doe
+
1234567890
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
john.doe@example.com
+
+
+
+

€100.00

+
Due Jan 02, 2025
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Prepaid credits - My wallet100.01.0€100.00
+ + + + + + +
Total€100.00
+
+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_not_provided/when_the_wallet_has_no_name/renders_the_default_name_on_the_invoice.html.snap b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_not_provided/when_the_wallet_has_no_name/renders_the_default_name_on_the_invoice.html.snap new file mode 100644 index 0000000..c67237f --- /dev/null +++ b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_not_provided/when_the_wallet_has_no_name/renders_the_default_name_on_the_invoice.html.snap @@ -0,0 +1,96 @@ + + + +
+
+

Advance invoice

+
+
+
+ + + + + + + + + + + + + +
Invoice NumberACM-8924-001-001
Issue DateJan 02, 2025
Payment term0 days
+
+
+
+
+
+
+
+
From
+
ACME Corporation
+
123 Business St
+
Suite 100
+
+ 94105 + ,   + San Francisco +
+
CA
+
United States
+
billing@acme.com
+
+
+
Bill to
+
Doe Corp - John Doe
+
1234567890
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
john.doe@example.com
+
+
+
+

€100.00

+
Due Jan 02, 2025
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Prepaid credits100.01.0€100.00
+ + + + + + +
Total€100.00
+
+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_provided/renders_the_transaction_name_on_the_invoice/Initial_top-up.html.snap b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_provided/renders_the_transaction_name_on_the_invoice/Initial_top-up.html.snap new file mode 100644 index 0000000..f584653 --- /dev/null +++ b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_provided/renders_the_transaction_name_on_the_invoice/Initial_top-up.html.snap @@ -0,0 +1,96 @@ + + + +
+
+

Advance invoice

+
+
+
+ + + + + + + + + + + + + +
Invoice NumberACM-8924-001-001
Issue DateJan 02, 2025
Payment term0 days
+
+
+
+
+
+
+
+
From
+
ACME Corporation
+
123 Business St
+
Suite 100
+
+ 94105 + ,   + San Francisco +
+
CA
+
United States
+
billing@acme.com
+
+
+
Bill to
+
Doe Corp - John Doe
+
1234567890
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
john.doe@example.com
+
+
+
+

€100.00

+
Due Jan 02, 2025
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Initial top-up100.01.0€100.00
+ + + + + + +
Total€100.00
+
+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_provided/renders_the_transaction_name_on_the_invoice/Top-up.html.snap b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_provided/renders_the_transaction_name_on_the_invoice/Top-up.html.snap new file mode 100644 index 0000000..b933644 --- /dev/null +++ b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_name/when_the_transaction_name_is_provided/renders_the_transaction_name_on_the_invoice/Top-up.html.snap @@ -0,0 +1,96 @@ + + + +
+
+

Advance invoice

+
+
+
+ + + + + + + + + + + + + +
Invoice NumberACM-8924-001-002
Issue DateJan 03, 2025
Payment term0 days
+
+
+
+
+
+
+
+
From
+
ACME Corporation
+
123 Business St
+
Suite 100
+
+ 94105 + ,   + San Francisco +
+
CA
+
United States
+
billing@acme.com
+
+
+
Bill to
+
Doe Corp - John Doe
+
1234567890
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
john.doe@example.com
+
+
+
+

€200.00

+
Due Jan 03, 2025
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Top-up200.01.0€200.00
+ + + + + + +
Total€200.00
+
+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_rounding/rounds_the_amount_field_when_handling_paid_credits.html.snap b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_rounding/rounds_the_amount_field_when_handling_paid_credits.html.snap new file mode 100644 index 0000000..cd21e70 --- /dev/null +++ b/spec/scenarios/wallets/__snapshots__/Wallet_Transaction_with_rounding/rounds_the_amount_field_when_handling_paid_credits.html.snap @@ -0,0 +1,96 @@ + + + +
+
+

Advance invoice

+
+
+
+ + + + + + + + + + + + + +
Invoice NumberACM-8924-001-001
Issue DateJan 01, 2025
Payment term0 days
+
+
+
+
+
+
+
+
From
+
ACME Corporation
+
123 Business St
+
Suite 100
+
+ 94105 + ,   + San Francisco +
+
CA
+
United States
+
billing@acme.com
+
+
+
Bill to
+
Doe Corp - John Doe
+
1234567890
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
john.doe@example.com
+
+
+
+

€17.97

+
Due Jan 01, 2025
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Prepaid credits - Wallet117.971.0€17.97
+ + + + + + +
Total€17.97
+
+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/scenarios/wallets/alerting/api_error_handling_spec.rb b/spec/scenarios/wallets/alerting/api_error_handling_spec.rb new file mode 100644 index 0000000..027ae03 --- /dev/null +++ b/spec/scenarios/wallets/alerting/api_error_handling_spec.rb @@ -0,0 +1,216 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Alert API Error Handling", :premium, transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + def create_test_wallet(code: "test-wallet") + create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Test Wallet", + code:, + currency: "EUR", + granted_credits: "100", + invoice_requires_successful_payment: false + }, as: :model) + end + + describe "duplicate alert type for wallet" do + it "returns error when creating duplicate alert type for same wallet" do + wallet = create_test_wallet + + create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_balance_amount, + code: :first_alert, + thresholds: [{value: 50_00, code: :warn}] + }) + + # Creating a second wallet_balance_amount alert for the same wallet should fail + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/#{wallet.code}/alerts", + {alert: { + alert_type: :wallet_balance_amount, + code: :second_alert, + thresholds: [{value: 30_00, code: :critical}] + }} + ) + end + + expect(response[:status]).to eq(422) + expect(response[:code]).to eq("validation_errors") + expect(response[:error_details]).to include("base" => ["alert_already_exists"]) + end + end + + describe "invalid alert type for wallet" do + it "returns error when using subscription alert type on wallet" do + wallet = create_test_wallet + + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/#{wallet.code}/alerts", + {alert: { + alert_type: :current_usage_amount, + code: :invalid_type, + thresholds: [{value: 100_00, code: :warn}] + }} + ) + end + + expect(response[:status]).to eq(422) + expect(response[:code]).to eq("validation_errors") + expect(response[:error_details]).to include("alert_type" => ["invalid_type"]) + end + end + + describe "missing required fields" do + it "returns error when code is missing" do + wallet = create_test_wallet + + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/#{wallet.code}/alerts", + {alert: { + alert_type: :wallet_balance_amount, + thresholds: [{value: 50_00, code: :warn}] + }} + ) + end + + expect(response[:status]).to eq(422) + expect(response[:code]).to eq("validation_errors") + expect(response[:error_details]).to include("code" => ["value_is_mandatory"]) + end + + it "returns error when thresholds is missing" do + wallet = create_test_wallet + + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/#{wallet.code}/alerts", + {alert: { + alert_type: :wallet_balance_amount, + code: :missing_thresholds + }} + ) + end + + expect(response[:status]).to eq(422) + expect(response[:code]).to eq("validation_errors") + expect(response[:error_details]).to include("thresholds" => ["value_is_mandatory"]) + end + + it "returns error when alert_type is missing" do + wallet = create_test_wallet + + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/#{wallet.code}/alerts", + {alert: { + code: :missing_type, + thresholds: [{value: 50_00, code: :warn}] + }} + ) + end + + expect(response[:status]).to eq(422) + expect(response[:code]).to eq("validation_errors") + expect(response[:error_details]).to include("alert_type" => ["value_is_mandatory", "value_is_invalid"]) + end + end + + describe "invalid thresholds" do + it "returns error with duplicate threshold values" do + wallet = create_test_wallet + + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/#{wallet.code}/alerts", + {alert: { + alert_type: :wallet_balance_amount, + code: :duplicate_thresholds, + thresholds: [ + {value: 100_00, code: :first}, + {value: 100_00, code: :duplicate}, + {value: 50_00, code: :third} + ] + }} + ) + end + + expect(response[:status]).to eq(422) + expect(response[:code]).to eq("validation_errors") + expect(response[:error_details]).to include("thresholds" => ["duplicate_threshold_values"]) + end + end + + describe "unsupported alert type" do + it "returns error for unknown alert type" do + wallet = create_test_wallet + + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/#{wallet.code}/alerts", + {alert: { + alert_type: :not_a_real_type, + code: :unknown, + thresholds: [{value: 50_00, code: :warn}] + }} + ) + end + + expect(response[:status]).to eq(422) + expect(response[:code]).to eq("validation_errors") + expect(response[:error_details]).to include("alert_type" => ["invalid_type"]) + end + end + + describe "wallet not found" do + it "returns not found error for non-existent wallet" do + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/#{customer.external_id}/wallets/non-existent-wallet/alerts", + {alert: { + alert_type: :wallet_balance_amount, + code: :test, + thresholds: [{value: 50_00, code: :warn}] + }} + ) + end + + expect(response[:status]).to eq(404) + expect(response[:code]).to eq("wallet_not_found") + end + end + + describe "customer not found" do + it "returns not found error for non-existent customer" do + response = api_call(raise_on_error: false) do + post_with_token( + organization, + "/api/v1/customers/non-existent-customer/wallets/some-wallet/alerts", + {alert: { + alert_type: :wallet_balance_amount, + code: :test, + thresholds: [{value: 50_00, code: :warn}] + }} + ) + end + + expect(response[:status]).to eq(404) + expect(response[:code]).to eq("customer_not_found") + end + end +end diff --git a/spec/scenarios/wallets/alerting/wallet_balance_amount_alert_spec.rb b/spec/scenarios/wallets/alerting/wallet_balance_amount_alert_spec.rb new file mode 100644 index 0000000..c6b69a7 --- /dev/null +++ b/spec/scenarios/wallets/alerting/wallet_balance_amount_alert_spec.rb @@ -0,0 +1,300 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Balance Amount Alerts", :premium, transaction: false do + include_context "with webhook tracking" + + let(:organization) { create(:organization, webhook_url: "https://example.com") } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "amount") } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + # Pay-in-advance charge creates invoices immediately and deducts from wallet + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, properties: {amount: "1"}) } + + def send_event!(subscription, amount) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric.field_name => amount} + }) + perform_usage_update + end + + def create_test_wallet(granted_credits: "100", rate_amount: "1") + create_wallet({ + external_customer_id: customer.external_id, + rate_amount:, + name: "Test Wallet", + code: "test-wallet", + currency: "EUR", + granted_credits:, + invoice_requires_successful_payment: false + }, as: :model) + end + + before { charge } + + describe "basic threshold crossing (decreasing direction)" do + it "triggers alert when wallet balance drops below threshold" do + wallet = create_test_wallet(granted_credits: "100") + expect(wallet.balance_cents).to eq(100_00) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_balance_amount, + code: :low_balance, + name: "Low Balance Alert", + thresholds: [ + {value: 80_00, code: :warn}, + {value: 50_00, code: :critical}, + {value: 20_00, code: :emergency} + ] + }, as: :model) + + expect(alert.alert_type).to eq("wallet_balance_amount") + expect(alert.direction).to eq("decreasing") + expect(alert.thresholds.count).to eq(3) + + # First event - establishes baseline (pay-in-advance invoice deducts from wallet) + send_event!(subscription, 10) + recalculate_wallet_balances + + wallet.reload + expect(wallet.balance_cents).to eq(90_00) + + alert.reload + expect(alert.previous_value).to eq(90_00) + expect(alert.triggered_alerts.count).to eq(0) + + # Second event - crosses the $80 threshold (balance goes from $90 to $70) + send_event!(subscription, 20) + recalculate_wallet_balances + + wallet.reload + expect(wallet.balance_cents).to eq(70_00) + + expect(alert.triggered_alerts.count).to eq(1) + ta = alert.triggered_alerts.sole + expect(ta.current_value).to eq(70_00) + expect(ta.previous_value).to eq(90_00) + expect(ta.crossed_thresholds).to eq([ + {"code" => "warn", "value" => "8000.0", "recurring" => false} + ]) + + webhook = webhooks_sent.find { |w| w[:webhook_type] == "alert.triggered" } + expect(webhook).to be_present + expect(webhook[:object_type]).to eq("triggered_alert") + expect(webhook[:triggered_alert]).to include({ + lago_id: ta.id, + alert_type: "wallet_balance_amount", + current_value: "7000.0", + previous_value: "9000.0", + alert_code: "low_balance", + alert_name: "Low Balance Alert" + }) + expect(webhook[:triggered_alert][:crossed_thresholds]).to eq([ + {"code" => "warn", "value" => "8000.0", "recurring" => false} + ]) + end + end + + describe "multiple threshold crossing at once" do + it "triggers alert with all crossed thresholds" do + wallet = create_test_wallet(granted_credits: "100") + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_balance_amount, + code: :multi_threshold, + thresholds: [ + {value: 90_00, code: :notice}, + {value: 70_00, code: :warn}, + {value: 50_00, code: :critical} + ] + }, as: :model) + + # First event - establishes baseline at $95 + send_event!(subscription, 5) + recalculate_wallet_balances + wallet.reload + expect(wallet.balance_cents).to eq(95_00) + alert.reload + expect(alert.previous_value).to eq(95_00) + + # Second event - crosses all three thresholds at once (balance goes from $95 to $40) + send_event!(subscription, 55) + recalculate_wallet_balances + + wallet.reload + expect(wallet.balance_cents).to eq(40_00) + + expect(alert.triggered_alerts.count).to eq(1) + ta = alert.triggered_alerts.sole + expect(ta.current_value).to eq(40_00) + expect(ta.crossed_thresholds.map { |t| t["code"] }).to contain_exactly("notice", "warn", "critical") + end + end + + describe "no alert when threshold not crossed" do + it "does not trigger alert when balance stays above threshold" do + wallet = create_test_wallet(granted_credits: "100") + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_balance_amount, + code: :low_balance, + thresholds: [{value: 50_00, code: :warn}] + }, as: :model) + + # First event - establishes baseline at $90 + send_event!(subscription, 10) + recalculate_wallet_balances + wallet.reload + expect(wallet.balance_cents).to eq(90_00) + alert.reload + expect(alert.previous_value).to eq(90_00) + + # Second event - balance goes from $90 to $80, still above $50 threshold + send_event!(subscription, 10) + recalculate_wallet_balances + + wallet.reload + expect(wallet.balance_cents).to eq(80_00) + + expect(alert.triggered_alerts.count).to eq(0) + expect(webhooks_sent.none? { |w| w[:webhook_type] == "alert.triggered" }).to be true + end + end + + describe "recurring threshold" do + it "triggers alert each time recurring threshold is crossed" do + wallet = create_test_wallet(granted_credits: "100") + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + # Initial threshold at $80, then recurring every $20 below that + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_balance_amount, + code: :recurring_alert, + thresholds: [ + {value: 80_00, code: :initial}, + {value: 20_00, code: :low, recurring: true} + ] + }, as: :model) + + # First event - establishes baseline at $95 + send_event!(subscription, 5) + recalculate_wallet_balances + wallet.reload + expect(wallet.balance_cents).to eq(95_00) + alert.reload + expect(alert.previous_value).to eq(95_00) + + # Second event - balance goes from $95 to $70, crossing $80 one-time threshold + send_event!(subscription, 25) + recalculate_wallet_balances + + wallet.reload + expect(wallet.balance_cents).to eq(70_00) + + expect(alert.triggered_alerts.count).to eq(1) + ta1 = alert.triggered_alerts.first + expect(ta1.crossed_thresholds).to eq([ + {"code" => "initial", "value" => "8000.0", "recurring" => false} + ]) + + # Third event - balance goes from $70 to $50, crossing $60 recurring threshold + send_event!(subscription, 20) + recalculate_wallet_balances + + wallet.reload + expect(wallet.balance_cents).to eq(50_00) + + alert.reload + expect(alert.triggered_alerts.count).to eq(2) + ta2 = alert.triggered_alerts.order(:created_at).last + expect(ta2.crossed_thresholds).to eq([ + {"code" => "low", "value" => "6000.0", "recurring" => true} + ]) + end + end + + describe "progressive usage consumption" do + it "triggers alerts progressively as balance decreases" do + wallet = create_test_wallet(granted_credits: "100") + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_balance_amount, + code: :progressive, + thresholds: [ + {value: 70_00, code: :notice}, + {value: 40_00, code: :warn}, + {value: 10_00, code: :critical} + ] + }, as: :model) + + # First event - establishes baseline at $90 + send_event!(subscription, 10) + recalculate_wallet_balances + wallet.reload + expect(wallet.balance_cents).to eq(90_00) + alert.reload + expect(alert.previous_value).to eq(90_00) + + # Second event - balance goes from $90 to $80, no thresholds crossed + send_event!(subscription, 10) + recalculate_wallet_balances + wallet.reload + expect(wallet.balance_cents).to eq(80_00) + expect(alert.triggered_alerts.count).to eq(0) + + # Third event - balance goes from $80 to $60, crosses $70 threshold + send_event!(subscription, 20) + recalculate_wallet_balances + wallet.reload + expect(wallet.balance_cents).to eq(60_00) + expect(alert.triggered_alerts.count).to eq(1) + expect(alert.triggered_alerts.sole.crossed_thresholds.map { |t| t["code"] }).to eq(["notice"]) + + # Fourth event - balance goes from $60 to $30, crosses $40 threshold + send_event!(subscription, 30) + recalculate_wallet_balances + wallet.reload + expect(wallet.balance_cents).to eq(30_00) + expect(alert.triggered_alerts.count).to eq(2) + expect(alert.triggered_alerts.order(:created_at).last.crossed_thresholds.map { |t| t["code"] }).to eq(["warn"]) + end + end +end diff --git a/spec/scenarios/wallets/alerting/wallet_credits_balance_alert_spec.rb b/spec/scenarios/wallets/alerting/wallet_credits_balance_alert_spec.rb new file mode 100644 index 0000000..97c78b9 --- /dev/null +++ b/spec/scenarios/wallets/alerting/wallet_credits_balance_alert_spec.rb @@ -0,0 +1,215 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Credits Balance Alerts", :premium, transaction: false do + include_context "with webhook tracking" + + let(:organization) { create(:organization, webhook_url: "https://example.com") } + let(:customer) { create(:customer, organization:) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "amount") } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + # Pay-in-advance charge creates invoices immediately and deducts from wallet + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, properties: {amount: "1"}) } + + def send_event!(subscription, amount) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric.field_name => amount} + }) + perform_usage_update + end + + before { charge } + + describe "credits balance threshold crossing with 1:1 rate" do + it "triggers alert when credits balance drops below threshold" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Credits Wallet", + code: "credits-wallet", + currency: "EUR", + granted_credits: "100", + invoice_requires_successful_payment: false + }, as: :model) + + expect(wallet.credits_balance).to eq(100) + expect(wallet.balance_cents).to eq(100_00) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_credits_balance, + code: :low_credits, + name: "Low Credits Alert", + thresholds: [ + {value: 80, code: :notice}, + {value: 50, code: :warn} + ] + }, as: :model) + + expect(alert.alert_type).to eq("wallet_credits_balance") + expect(alert.direction).to eq("decreasing") + + # First event - establishes baseline at 90 credits + send_event!(subscription, 10) + recalculate_wallet_balances + wallet.reload + expect(wallet.credits_balance).to eq(90) + alert.reload + expect(alert.previous_value).to eq(90) + + # Second event - balance goes from 90 to 70 credits, crosses 80 threshold + send_event!(subscription, 20) + recalculate_wallet_balances + + wallet.reload + expect(wallet.credits_balance).to eq(70) + + expect(alert.triggered_alerts.count).to eq(1) + ta = alert.triggered_alerts.sole + expect(ta.current_value).to eq(70) + expect(ta.crossed_thresholds).to eq([ + {"code" => "notice", "value" => "80.0", "recurring" => false} + ]) + + webhook = webhooks_sent.find { |w| w[:webhook_type] == "alert.triggered" } + expect(webhook).to be_present + expect(webhook[:triggered_alert]).to include({ + alert_type: "wallet_credits_balance", + current_value: "70.0", + alert_code: "low_credits" + }) + end + end + + describe "credits with different rate" do + it "calculates correct credits based on rate" do + # Rate of $2 = 1 credit, so 50 credits = $100 + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "2", + name: "Premium Credits Wallet", + code: "premium-wallet", + currency: "EUR", + granted_credits: "50", + invoice_requires_successful_payment: false + }, as: :model) + + expect(wallet.credits_balance).to eq(50) + expect(wallet.balance_cents).to eq(100_00) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_credits_balance, + code: :credits_low, + thresholds: [ + {value: 40, code: :notice}, + {value: 30, code: :warn} + ] + }, as: :model) + + # First event ($10 = 5 credits consumed) - establishes baseline at 45 credits + send_event!(subscription, 10) + recalculate_wallet_balances + wallet.reload + expect(wallet.credits_balance).to eq(45) + alert.reload + expect(alert.previous_value).to eq(45) + + # Second event ($30 = 15 credits consumed) - balance goes from 45 to 30 credits + send_event!(subscription, 30) + recalculate_wallet_balances + + wallet.reload + expect(wallet.balance_cents).to eq(60_00) + expect(wallet.credits_balance).to eq(30) + + expect(alert.triggered_alerts.count).to eq(1) + ta = alert.triggered_alerts.sole + expect(ta.current_value).to eq(30) + expect(ta.crossed_thresholds.map { |t| t["code"] }).to contain_exactly("notice", "warn") + end + end + + describe "multiple thresholds with recurring" do + it "triggers recurring threshold each time it is crossed" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Recurring Credits Wallet", + code: "recurring-wallet", + currency: "EUR", + granted_credits: "100", + invoice_requires_successful_payment: false + }, as: :model) + + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code + }) + subscription = customer.subscriptions.sole + + # Initial threshold at 90 credits, then recurring every 10 credits below that + alert = create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_credits_balance, + code: :recurring_credits, + thresholds: [ + {value: 90, code: :initial}, + {value: 10, code: :low, recurring: true} + ] + }, as: :model) + + # First event - establishes baseline at 95 credits + send_event!(subscription, 5) + recalculate_wallet_balances + wallet.reload + expect(wallet.credits_balance).to eq(95) + alert.reload + expect(alert.previous_value).to eq(95) + + # Second event - balance goes from 95 to 70 credits, crosses 90 one-time threshold + send_event!(subscription, 25) + recalculate_wallet_balances + wallet.reload + expect(wallet.credits_balance).to eq(70) + expect(alert.triggered_alerts.count).to eq(1) + expect(alert.triggered_alerts.sole.crossed_thresholds).to eq([ + {"code" => "initial", "value" => "90.0", "recurring" => false}, + {"code" => "low", "value" => "70.0", "recurring" => true}, + {"code" => "low", "value" => "80.0", "recurring" => true} + ]) + + # Third event - balance goes from 70 to 50 credits, crosses multiple recurring thresholds + send_event!(subscription, 20) + recalculate_wallet_balances + wallet.reload + expect(wallet.credits_balance).to eq(50) + + alert.reload + expect(alert.triggered_alerts.count).to eq(2) + ta2 = alert.triggered_alerts.order(:created_at).last + # Recurring thresholds are crossed starting from the initial (90) going down by step (10) + expect(ta2.crossed_thresholds).to eq([ + {"code" => "low", "value" => "50.0", "recurring" => true}, + {"code" => "low", "value" => "60.0", "recurring" => true}, + {"code" => "low", "value" => "70.0", "recurring" => true} + ]) + end + end +end diff --git a/spec/scenarios/wallets/alerting/wallet_credits_ongoing_balance_alert_spec.rb b/spec/scenarios/wallets/alerting/wallet_credits_ongoing_balance_alert_spec.rb new file mode 100644 index 0000000..b75636e --- /dev/null +++ b/spec/scenarios/wallets/alerting/wallet_credits_ongoing_balance_alert_spec.rb @@ -0,0 +1,492 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Credits Ongoing Balance Alerts", :premium, transaction: false do + include_context "with webhook tracking" + + let(:organization) { create(:organization, webhook_url: "https://example.com") } + let(:customer) { create(:customer, organization:, invoice_grace_period: 5) } + + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "amount") } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "1"}) } + + def send_event!(subscription, amount) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric.field_name => amount} + }) + perform_usage_update + end + + def create_test_wallet(**params) + create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + granted_credits: "100", + name: "Test Wallet", + code: "test-wallet", + currency: "EUR", + invoice_requires_successful_payment: false, + **params + }, as: :model) + end + + def create_test_subscription(**params) + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code, + **params + }) + + customer.subscriptions.sole + end + + def create_test_wallet_alert(wallet, **params) + create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_credits_ongoing_balance, + code: :ongoing_balance_alert, + name: "Credits Ongoing Balance Alert", + thresholds: [ + {value: 75, code: :warn}, + {value: 50, code: :critical}, + {value: 25, code: :emergency} + ], + **params + }, as: :model) + end + + def alert_webhooks + webhooks_sent.select { it[:webhook_type] == "alert.triggered" } + end + + def expect_alert_to_be_triggered(alert, **params) + triggered = alert.triggered_alerts.order(:created_at).last + expect(triggered).to have_attributes(params) + + webhook = alert_webhooks.last + expect(webhook).to include( + object_type: "triggered_alert", + triggered_alert: include({ + lago_id: triggered.id, + alert_type: "wallet_credits_ongoing_balance", + alert_code: "ongoing_balance_alert", + alert_name: "Credits Ongoing Balance Alert", + current_value: params[:current_value].to_f.to_s, + previous_value: params[:previous_value].to_f.to_s, + crossed_thresholds: params[:crossed_thresholds] + }) + ) + end + + before { charge } + + describe "basic functionality" do + it "triggers alert when credits ongoing balance goes down" do + wallet = create_test_wallet(granted_credits: "100") + expect(wallet.credits_ongoing_balance).to eq(100) + + subscription = create_test_subscription + alert = create_test_wallet_alert( + wallet, + thresholds: [{value: 50, code: :alert, recurring: false}] + ) + + expect(alert).to have_attributes( + alert_type: "wallet_credits_ongoing_balance", + direction: "decreasing", + thresholds: match_array([ + have_attributes({value: 50, code: "alert", recurring: false}) + ]) + ) + + # First event - no alerts + send_event!(subscription, 25) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(75) + expect(alert.reload.previous_value).to eq(75) + expect(alert.triggered_alerts.count).to eq(0) + + # Second event - one-time alert + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(45) + expect(alert.reload.previous_value).to eq(45) + + expect_alert_to_be_triggered( + alert, + current_value: 45, + previous_value: 75, + crossed_thresholds: [{"code" => "alert", "value" => "50.0", "recurring" => false}] + ) + + # Third event - no alert + send_event!(subscription, 20) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(25) + expect(alert.reload.previous_value).to eq(25) + expect(alert.triggered_alerts.count).to eq(1) + end + + context "when there were credits ongoing balance changes before first alert" do + it "triggers alerts correctly" do + wallet = create_test_wallet(granted_credits: "50") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + # Top up wallet + create_wallet_transaction({ + wallet_id: wallet.id, + granted_credits: "50", + name: "Top-up" + }, as: :model) + + # Send event - first alert + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(70) + expect(alert.reload.previous_value).to eq(70) + + expect_alert_to_be_triggered( + alert, + current_value: 70, + previous_value: 100, + crossed_thresholds: [{"code" => "warn", "value" => "75.0", "recurring" => false}] + ) + end + end + + context "when no credits ongoing balance changes happened before first alert" do + it "triggers alerts correctly" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + # Send event - first alert + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(70) + expect(alert.reload.previous_value).to eq(70) + + expect_alert_to_be_triggered( + alert, + current_value: 70, + previous_value: 100, + crossed_thresholds: [{"code" => "warn", "value" => "75.0", "recurring" => false}] + ) + end + end + end + + describe "thresholds crossing" do + context "when one threshold is crossed" do + it "triggers the alert" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(70) + expect(alert.reload.previous_value).to eq(70) + + expect_alert_to_be_triggered( + alert, + current_value: 70, + previous_value: 100, + crossed_thresholds: [{"code" => "warn", "value" => "75.0", "recurring" => false}] + ) + end + end + + context "when multiple thresholds are crossed at once" do + it "triggers one alert with multiple crossed_thersholds" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 70) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(30) + expect(alert.reload.previous_value).to eq(30) + + expect_alert_to_be_triggered( + alert, + current_value: 30, + previous_value: 100, + crossed_thresholds: [ + {"code" => "warn", "value" => "75.0", "recurring" => false}, + {"code" => "critical", "value" => "50.0", "recurring" => false} + ] + ) + end + end + + context "when multiple thresholds are crossed over time" do + it "triggers alert multiple times" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + # First threshold + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(70) + expect(alert.reload.previous_value).to eq(70) + + expect_alert_to_be_triggered( + alert, + current_value: 70, + previous_value: 100, + crossed_thresholds: [ + {"code" => "warn", "value" => "75.0", "recurring" => false} + ] + ) + + # Second threshold + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(40) + expect(alert.reload.previous_value).to eq(40) + + expect_alert_to_be_triggered( + alert, + current_value: 40, + previous_value: 70, + crossed_thresholds: [ + {"code" => "critical", "value" => "50.0", "recurring" => false} + ] + ) + + # Third threshold + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(10) + expect(alert.reload.previous_value).to eq(10) + + expect_alert_to_be_triggered( + alert, + current_value: 10, + previous_value: 40, + crossed_thresholds: [ + {"code" => "emergency", "value" => "25.0", "recurring" => false} + ] + ) + end + end + + context "when no thresholds are crossed" do + it "triggers one alert with multiple crossed_thersholds" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(90) + expect(alert.reload.previous_value).to eq(90) + + expect(alert.triggered_alerts.count).to eq(0) + expect(alert_webhooks).to be_empty + end + end + end + + describe "thresholds types" do + context "when only one-time thresholds are defined" do + it "triggers the one-time alert" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(70) + expect(alert.reload.previous_value).to eq(70) + + expect_alert_to_be_triggered( + alert, + current_value: 70, + previous_value: 100, + crossed_thresholds: [{"code" => "warn", "value" => "75.0", "recurring" => false}] + ) + end + end + + context "when a recurring threshold is defined" do + it "triggers the one-time and recurring alerts" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + + alert = create_test_wallet_alert( + wallet, + thresholds: [ + {value: 80, code: :one_time, recurring: false}, + {value: 10, code: :recurring, recurring: true} + ] + ) + + # First event - one-time alert + send_event!(subscription, 25) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(75) + expect(alert.reload.previous_value).to eq(75) + + expect_alert_to_be_triggered( + alert, + current_value: 75, + previous_value: 100, + crossed_thresholds: [{"code" => "one_time", "value" => "80.0", "recurring" => false}] + ) + + # Second event - recurring alert + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(65) + expect(alert.reload.previous_value).to eq(65) + + expect_alert_to_be_triggered( + alert, + current_value: 65, + previous_value: 75, + crossed_thresholds: [{"code" => "recurring", "value" => "70.0", "recurring" => true}] + ) + + # Third event - recurring alert + send_event!(subscription, 20) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(45) + expect(alert.reload.previous_value).to eq(45) + + expect_alert_to_be_triggered( + alert, + current_value: 45, + previous_value: 65, + crossed_thresholds: [ + {"code" => "recurring", "value" => "50.0", "recurring" => true}, + {"code" => "recurring", "value" => "60.0", "recurring" => true} + ] + ) + end + end + + context "when negative thresholds are defined" do + it "triggers positive and negative alerts" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + + alert = create_test_wallet_alert( + wallet, + thresholds: [ + {value: 50, code: :positive, recurring: false}, + {value: -50, code: :negative, recurring: false} + ] + ) + + # First event - positive alert, credits ongoing balance goes down to 40 + send_event!(subscription, 55) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(45) + expect(alert.reload.previous_value).to eq(45) + + expect_alert_to_be_triggered( + alert, + current_value: 45, + previous_value: 100, + crossed_thresholds: [{"code" => "positive", "value" => "50.0", "recurring" => false}] + ) + + # Second event - negative alert, credits ongoing balance goes down to -60 + send_event!(subscription, 100) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(-55) + expect(alert.reload.previous_value).to eq(-55) + + expect_alert_to_be_triggered( + alert, + current_value: -55, + previous_value: 45, + crossed_thresholds: [{"code" => "negative", "value" => "-50.0", "recurring" => false}] + ) + end + end + + context "when credits ongoing balance goes negative with a recurring threshold" do + it "triggers recurring alerts when credits ongoing balance goes below 0" do + wallet = create_test_wallet(granted_credits: "20") + subscription = create_test_subscription + + alert = create_test_wallet_alert( + wallet, + thresholds: [ + {value: 15, code: :one_time, recurring: false}, + {value: 10, code: :recurring, recurring: true} + ] + ) + + # First event - one-time alert + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(10) + expect(alert.reload.previous_value).to eq(10) + + expect_alert_to_be_triggered( + alert, + current_value: 10, + previous_value: 20, + crossed_thresholds: [{"code" => "one_time", "value" => "15.0", "recurring" => false}] + ) + + # Second event - positive recurring alert at $5 + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(0) + expect(alert.reload.previous_value).to eq(0) + + expect_alert_to_be_triggered( + alert, + current_value: 0, + previous_value: 10, + crossed_thresholds: [{"code" => "recurring", "value" => "5.0", "recurring" => true}] + ) + + # Third event - negative recurring alert at -$5 + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.credits_ongoing_balance).to eq(-10) + expect(alert.reload.previous_value).to eq(-10) + + expect_alert_to_be_triggered( + alert, + current_value: -10, + previous_value: 0, + crossed_thresholds: [{"code" => "recurring", "value" => "-5.0", "recurring" => true}] + ) + end + end + end +end diff --git a/spec/scenarios/wallets/alerting/wallet_ongoing_balance_amount_alert_spec.rb b/spec/scenarios/wallets/alerting/wallet_ongoing_balance_amount_alert_spec.rb new file mode 100644 index 0000000..f4724d0 --- /dev/null +++ b/spec/scenarios/wallets/alerting/wallet_ongoing_balance_amount_alert_spec.rb @@ -0,0 +1,492 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Ongoing Balance Amount Alerts", :premium, transaction: false do + include_context "with webhook tracking" + + let(:organization) { create(:organization, webhook_url: "https://example.com") } + let(:customer) { create(:customer, organization:, invoice_grace_period: 5) } + + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "amount") } + let(:plan) { create(:plan, organization:, amount_cents: 0) } + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "1"}) } + + def send_event!(subscription, amount) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric.field_name => amount} + }) + perform_usage_update + end + + def create_test_wallet(**params) + create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + granted_credits: "100", + name: "Test Wallet", + code: "test-wallet", + currency: "EUR", + invoice_requires_successful_payment: false, + **params + }, as: :model) + end + + def create_test_subscription(**params) + create_subscription({ + external_customer_id: customer.external_id, + external_id: "sub-1", + plan_code: plan.code, + **params + }) + + customer.subscriptions.sole + end + + def create_test_wallet_alert(wallet, **params) + create_wallet_alert(customer.external_id, wallet.code, { + alert_type: :wallet_ongoing_balance_amount, + code: :ongoing_balance_alert, + name: "Ongoing Balance Alert", + thresholds: [ + {value: 75_00, code: :warn}, + {value: 50_00, code: :critical}, + {value: 25_00, code: :emergency} + ], + **params + }, as: :model) + end + + def alert_webhooks + webhooks_sent.select { it[:webhook_type] == "alert.triggered" } + end + + def expect_alert_to_be_triggered(alert, **params) + triggered = alert.triggered_alerts.order(:created_at).last + expect(triggered).to have_attributes(params) + + webhook = alert_webhooks.last + expect(webhook).to include( + object_type: "triggered_alert", + triggered_alert: include({ + lago_id: triggered.id, + alert_type: "wallet_ongoing_balance_amount", + alert_code: "ongoing_balance_alert", + alert_name: "Ongoing Balance Alert", + current_value: params[:current_value].to_f.to_s, + previous_value: params[:previous_value].to_f.to_s, + crossed_thresholds: params[:crossed_thresholds] + }) + ) + end + + before { charge } + + describe "basic functionality" do + it "triggers alert when ongoing balance goes down" do + wallet = create_test_wallet(granted_credits: "100") + expect(wallet.ongoing_balance_cents).to eq(100_00) + + subscription = create_test_subscription + alert = create_test_wallet_alert( + wallet, + thresholds: [{value: 50_00, code: :alert, recurring: false}] + ) + + expect(alert).to have_attributes( + alert_type: "wallet_ongoing_balance_amount", + direction: "decreasing", + thresholds: match_array([ + have_attributes({value: 50_00, code: "alert", recurring: false}) + ]) + ) + + # First event - no alerts + send_event!(subscription, 25) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(75_00) + expect(alert.reload.previous_value).to eq(75_00) + expect(alert.triggered_alerts.count).to eq(0) + + # Second event - one-time alert + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(45_00) + expect(alert.reload.previous_value).to eq(45_00) + + expect_alert_to_be_triggered( + alert, + current_value: 45_00, + previous_value: 75_00, + crossed_thresholds: [{"code" => "alert", "value" => "5000.0", "recurring" => false}] + ) + + # Third event - no alert + send_event!(subscription, 20) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(25_00) + expect(alert.reload.previous_value).to eq(25_00) + expect(alert.triggered_alerts.count).to eq(1) + end + + context "when there were ongoing balance changes before first alert" do + it "triggers alerts correctly" do + wallet = create_test_wallet(granted_credits: "50") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + # Top up wallet + create_wallet_transaction({ + wallet_id: wallet.id, + granted_credits: "50", + name: "Top-up" + }, as: :model) + + # Send event - first alert + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(70_00) + expect(alert.reload.previous_value).to eq(70_00) + + expect_alert_to_be_triggered( + alert, + current_value: 70_00, + previous_value: 100_00, + crossed_thresholds: [{"code" => "warn", "value" => "7500.0", "recurring" => false}] + ) + end + end + + context "when no ongoing balance changes happened before first alert" do + it "triggers alerts correctly" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + # Send event - first alert + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(70_00) + expect(alert.reload.previous_value).to eq(70_00) + + expect_alert_to_be_triggered( + alert, + current_value: 70_00, + previous_value: 100_00, + crossed_thresholds: [{"code" => "warn", "value" => "7500.0", "recurring" => false}] + ) + end + end + end + + describe "thresholds crossing" do + context "when one threshold is crossed" do + it "triggers the alert" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(70_00) + expect(alert.reload.previous_value).to eq(70_00) + + expect_alert_to_be_triggered( + alert, + current_value: 70_00, + previous_value: 100_00, + crossed_thresholds: [{"code" => "warn", "value" => "7500.0", "recurring" => false}] + ) + end + end + + context "when multiple thresholds are crossed at once" do + it "triggers one alert with multiple crossed_thersholds" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 70) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(30_00) + expect(alert.reload.previous_value).to eq(30_00) + + expect_alert_to_be_triggered( + alert, + current_value: 30_00, + previous_value: 100_00, + crossed_thresholds: [ + {"code" => "warn", "value" => "7500.0", "recurring" => false}, + {"code" => "critical", "value" => "5000.0", "recurring" => false} + ] + ) + end + end + + context "when multiple thresholds are crossed over time" do + it "triggers alert multiple times" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + # First threshold + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(70_00) + expect(alert.reload.previous_value).to eq(70_00) + + expect_alert_to_be_triggered( + alert, + current_value: 70_00, + previous_value: 100_00, + crossed_thresholds: [ + {"code" => "warn", "value" => "7500.0", "recurring" => false} + ] + ) + + # Second threshold + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(40_00) + expect(alert.reload.previous_value).to eq(40_00) + + expect_alert_to_be_triggered( + alert, + current_value: 40_00, + previous_value: 70_00, + crossed_thresholds: [ + {"code" => "critical", "value" => "5000.0", "recurring" => false} + ] + ) + + # Third threshold + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(10_00) + expect(alert.reload.previous_value).to eq(10_00) + + expect_alert_to_be_triggered( + alert, + current_value: 10_00, + previous_value: 40_00, + crossed_thresholds: [ + {"code" => "emergency", "value" => "2500.0", "recurring" => false} + ] + ) + end + end + + context "when no thresholds are crossed" do + it "triggers one alert with multiple crossed_thersholds" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(90_00) + expect(alert.reload.previous_value).to eq(90_00) + + expect(alert.triggered_alerts.count).to eq(0) + expect(alert_webhooks).to be_empty + end + end + end + + describe "thresholds types" do + context "when only one-time thresholds are defined" do + it "triggers the one-time alert" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + alert = create_test_wallet_alert(wallet) + + send_event!(subscription, 30) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(70_00) + expect(alert.reload.previous_value).to eq(70_00) + + expect_alert_to_be_triggered( + alert, + current_value: 70_00, + previous_value: 100_00, + crossed_thresholds: [{"code" => "warn", "value" => "7500.0", "recurring" => false}] + ) + end + end + + context "when a recurring threshold is defined" do + it "triggers the one-time and recurring alerts" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + + alert = create_test_wallet_alert( + wallet, + thresholds: [ + {value: 80_00, code: :one_time, recurring: false}, + {value: 10_00, code: :recurring, recurring: true} + ] + ) + + # First event - one-time alert + send_event!(subscription, 25) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(75_00) + expect(alert.reload.previous_value).to eq(75_00) + + expect_alert_to_be_triggered( + alert, + current_value: 75_00, + previous_value: 100_00, + crossed_thresholds: [{"code" => "one_time", "value" => "8000.0", "recurring" => false}] + ) + + # Second event - recurring alert + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(65_00) + expect(alert.reload.previous_value).to eq(65_00) + + expect_alert_to_be_triggered( + alert, + current_value: 65_00, + previous_value: 75_00, + crossed_thresholds: [{"code" => "recurring", "value" => "7000.0", "recurring" => true}] + ) + + # Third event - recurring alert + send_event!(subscription, 20) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(45_00) + expect(alert.reload.previous_value).to eq(45_00) + + expect_alert_to_be_triggered( + alert, + current_value: 45_00, + previous_value: 65_00, + crossed_thresholds: [ + {"code" => "recurring", "value" => "5000.0", "recurring" => true}, + {"code" => "recurring", "value" => "6000.0", "recurring" => true} + ] + ) + end + end + + context "when negative thresholds are defined" do + it "triggers positive and negative alerts" do + wallet = create_test_wallet(granted_credits: "100") + subscription = create_test_subscription + + alert = create_test_wallet_alert( + wallet, + thresholds: [ + {value: 50_00, code: :positive, recurring: false}, + {value: -50_00, code: :negative, recurring: false} + ] + ) + + # First event - positive alert, ongoing balance goes down to $40 + send_event!(subscription, 55) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(45_00) + expect(alert.reload.previous_value).to eq(45_00) + + expect_alert_to_be_triggered( + alert, + current_value: 45_00, + previous_value: 100_00, + crossed_thresholds: [{"code" => "positive", "value" => "5000.0", "recurring" => false}] + ) + + # Second event - negative alert, ongoing balance goes down to -$60 + send_event!(subscription, 100) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(-55_00) + expect(alert.reload.previous_value).to eq(-55_00) + + expect_alert_to_be_triggered( + alert, + current_value: -55_00, + previous_value: 45_00, + crossed_thresholds: [{"code" => "negative", "value" => "-5000.0", "recurring" => false}] + ) + end + end + + context "when ongoing balance goes negative with a recurring threshold" do + it "triggers recurring alerts when ongoing balance goes below 0" do + wallet = create_test_wallet(granted_credits: "20") + subscription = create_test_subscription + + alert = create_test_wallet_alert( + wallet, + thresholds: [ + {value: 15_00, code: :one_time, recurring: false}, + {value: 10_00, code: :recurring, recurring: true} + ] + ) + + # First event - one-time alert + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(10_00) + expect(alert.reload.previous_value).to eq(10_00) + + expect_alert_to_be_triggered( + alert, + current_value: 10_00, + previous_value: 20_00, + crossed_thresholds: [{"code" => "one_time", "value" => "1500.0", "recurring" => false}] + ) + + # Second event - positive recurring alert at $5 + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(0) + expect(alert.reload.previous_value).to eq(0) + + expect_alert_to_be_triggered( + alert, + current_value: 0, + previous_value: 10_00, + crossed_thresholds: [{"code" => "recurring", "value" => "500.0", "recurring" => true}] + ) + + # Third event - negative recurring alert at -$5 + send_event!(subscription, 10) + recalculate_wallet_balances + + expect(wallet.reload.ongoing_balance_cents).to eq(-10_00) + expect(alert.reload.previous_value).to eq(-10_00) + + expect_alert_to_be_triggered( + alert, + current_value: -10_00, + previous_value: 0, + crossed_thresholds: [{"code" => "recurring", "value" => "-500.0", "recurring" => true}] + ) + end + end + end +end diff --git a/spec/scenarios/wallets/balance_spec.rb b/spec/scenarios/wallets/balance_spec.rb new file mode 100644 index 0000000..f5b5f8a --- /dev/null +++ b/spec/scenarios/wallets/balance_spec.rb @@ -0,0 +1,967 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Use wallet's credits and recalculate balances", :premium, transaction: false do + let(:organization) { create(:organization, webhook_url: nil, email_settings: [], premium_integrations: ["progressive_billing"]) } + let(:billing_entity) { create(:billing_entity, organization:, invoice_grace_period: 10) } + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 1_00, pay_in_advance: false) } + let(:billable_metric) { create(:billable_metric, organization:, field_name: "total", aggregation_type: "sum_agg") } + let(:charge) { create(:charge, plan:, billable_metric:, charge_model: "standard", properties: {"amount" => "1"}) } + let(:customer) { create(:customer, organization:, billing_entity:) } + + def ingest_event(subscription, amount, billable_metric_code = nil, filter = nil) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric_code || billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric.field_name => amount, :filter => filter} + }) + perform_usage_update + end + + def expect_to_be_a_topup_transaction(transaction, amount:, credit_amount:, metadata: []) + expect(transaction).to be_present + expect(transaction.source).to eq("manual") + expect(transaction.transaction_type).to eq("inbound") + expect(transaction.transaction_status).to eq("granted") + expect(transaction.status).to eq("settled") + expect(transaction.credit_amount).to eq(credit_amount) + expect(transaction.amount).to eq(amount) + expect(transaction.metadata).to eq(metadata) + end + + def expect_to_be_an_invoiced_transaction(transaction, amount:, credit_amount:, metadata: []) + expect(transaction).to be_present + expect(transaction.source).to eq("manual") + expect(transaction.transaction_type).to eq("outbound") + expect(transaction.transaction_status).to eq("invoiced") + expect(transaction.status).to eq("settled") + expect(transaction.credit_amount).to eq(credit_amount) + expect(transaction.amount).to eq(amount) + expect(transaction.metadata).to eq(metadata) + end + + def create_wallet_with_defaults(granted_credits: "10", rate_amount: "1", transaction_metadata: [], applies_to: {}) + create_wallet({ + external_customer_id: customer.external_id, + rate_amount: rate_amount, + name: "Wallet1", + currency: "EUR", + granted_credits:, + invoice_requires_successful_payment: false, # default + transaction_metadata: transaction_metadata, + applies_to: + }, as: :model) + end + + context "when a wallet created for a user with plain plan and usage-based charge" do + before do + charge + end + + it "recalculates wallet's balance" do + time_0 = DateTime.new(2022, 11, 30) + wallet = nil + travel_to time_0 do + # Create a wallet with 10$ + wallet = create_wallet_with_defaults + expect(wallet.credits_balance).to eq 10 + expect(wallet.balance_cents).to eq 1000 + expect(wallet.ongoing_balance_cents).to eq 1000 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + + expect_to_be_a_topup_transaction(wallet.wallet_transactions.first, amount: 10, credit_amount: 10) + end + + # create a subscription + time_1 = time_0 + 1.day + travel_to time_1 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + # ingest events that would not use all wallet balance + # the balance is not changed, but ongoing balance is updated + travel_to time_1 + 5.days do + ingest_event(subscription, 5) + expect(subscription.invoices.count).to eq(0) + expect(customer.reload.awaiting_wallet_refresh).to eq true + recalculate_wallet_balances + expect(customer.reload.awaiting_wallet_refresh).to eq false + wallet.reload + expect(wallet.credits_balance).to eq 10 + expect(wallet.balance_cents).to eq 1000 + expect(wallet.ongoing_balance_cents).to eq 500 + expect(wallet.ongoing_usage_balance_cents).to eq 500 + expect(wallet.credits_ongoing_balance).to eq 5 + expect(wallet.credits_ongoing_usage_balance).to eq 5 + + expect(wallet.wallet_transactions.count).to eq(1) + end + + # billing run; the invoice stays in draft: + # balance is not changed, ongoing balance takes into account the draft invoice + # (total amount including the subscription fee is 6$) + time_2 = time_1 + 1.month + travel_to time_2 do + perform_billing + expect(subscription.invoices.count).to eq(1) + expect(subscription.invoices.first.status).to eq("draft") + expect(customer.reload.awaiting_wallet_refresh).to eq true + recalculate_wallet_balances + expect(customer.reload.awaiting_wallet_refresh).to eq false + wallet.reload + expect(wallet.credits_balance).to eq 10 + expect(wallet.balance_cents).to eq 1000 + expect(wallet.ongoing_balance_cents).to eq 400 + expect(wallet.credits_ongoing_balance).to eq 4 + expect(wallet.ongoing_usage_balance_cents).to eq 600 + expect(wallet.credits_ongoing_usage_balance).to eq 6 + + expect(wallet.wallet_transactions.count).to eq(1) + end + + # ingest some events for the new billing_period + # current usage = 6$ draft invoice + 3$ new usage = 9$ + travel_to time_2 + 5.days do + ingest_event(subscription, 3) + expect(customer.reload.awaiting_wallet_refresh).to eq true + recalculate_wallet_balances + expect(customer.reload.awaiting_wallet_refresh).to eq false + wallet.reload + expect(wallet.credits_balance).to eq 10 + expect(wallet.balance_cents).to eq 1000 + expect(wallet.ongoing_balance_cents).to eq 100 + expect(wallet.credits_ongoing_balance).to eq 1 + expect(wallet.ongoing_usage_balance_cents).to eq 900 + expect(wallet.credits_ongoing_usage_balance).to eq 9 + + expect(wallet.wallet_transactions.count).to eq(1) + end + + # 11th day of the billing period; the invoice is finalized + # invoice sum = 6$ is deducted from the balance, + # no need to recalculate balance as it's recalculated when credits are applied + # remaining current usage is 3$ + travel_to time_2 + 10.days do + perform_finalize_refresh + expect(subscription.invoices.count).to eq(1) + expect(subscription.invoices.first.status).to eq("finalized") + wallet.reload + expect(wallet.credits_balance).to eq 4 + expect(wallet.balance_cents).to eq 400 + expect(wallet.ongoing_balance_cents).to eq 100 + expect(wallet.credits_ongoing_balance).to eq 1 + expect(wallet.ongoing_usage_balance_cents).to eq 300 + expect(wallet.credits_ongoing_usage_balance).to eq 3 + + expect(wallet.wallet_transactions.length).to eq(2) + expect_to_be_an_invoiced_transaction(wallet.wallet_transactions.max_by(&:created_at), amount: 6, credit_amount: 6) + end + end + end + + context "with pay in advance charges and taxes" do + let(:charge) { create(:charge, :pay_in_advance, plan: plan, billable_metric: billable_metric, charge_model: "standard", properties: {"amount" => "1"}) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization: organization, rate: 10, billing_entity:) } + + before do + charge + tax + end + + it "recalculates wallet's balance" do + # Create a wallet with 100$ + wallet = create_wallet_with_defaults(granted_credits: "100") + expect(wallet.credits_balance).to eq 100 + expect(wallet.balance_cents).to eq 10000 + expect(wallet.ongoing_balance_cents).to eq 10000 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + + # create a subscription + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.first + + # ingest events that would not use all wallet balance + # the invoice is issued, the balance is changed + travel_to time_0 + 5.days do + ingest_event(subscription, 50) + expect(subscription.invoices.count).to eq(1) + recalculate_wallet_balances + wallet.reload + expect(wallet.credits_balance).to eq 45 + expect(wallet.balance_cents).to eq 4500 + expect(wallet.ongoing_balance_cents).to eq 4500 + expect(wallet.credits_ongoing_balance).to eq 45 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + + transactions = wallet.wallet_transactions + expect(transactions.length).to eq(2) + # expect_to_be_an_invoiced_transaction(transactions.first, amount: 4500, credit_amount: 45) + end + + # when the subscription invoice is generated it is not paid straight ahead with the wallet + travel_to time_0 + 1.month do + perform_billing + expect(subscription.invoices.count).to eq(2) + recalculate_wallet_balances + wallet.reload + expect(wallet.credits_balance).to eq 45 + expect(wallet.balance_cents).to eq 4500 + expect(wallet.ongoing_balance_cents).to eq 4390 + expect(wallet.credits_ongoing_balance).to eq 43.9 + expect(wallet.ongoing_usage_balance_cents).to eq 110 + expect(wallet.credits_ongoing_usage_balance).to eq 1.1 + end + end + end + + context "with 'normal' plan, with pay in advance charges plan and with threshold usage recurring set on plan" do + let(:plan1) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: false) } + let(:charge1) { create(:charge, plan: plan1, billable_metric:, charge_model: "standard", properties: {"amount" => "1"}) } + + let(:plan2) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: false) } + let(:charge2) { create(:charge, :pay_in_advance, plan: plan2, billable_metric:, charge_model: "standard", properties: {"amount" => "2"}) } + + let(:plan3) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: false) } + let(:charge3) { create(:charge, plan: plan3, billable_metric:, charge_model: "standard", properties: {"amount" => "10"}) } + let(:usage_threshold) { create(:usage_threshold, plan: plan3, amount_cents: 200_00, recurring: true) } + + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 10, billing_entity:) } + + before { [charge1, charge2, charge3, usage_threshold, tax] } + + it "recalculates wallet's balance" do + # create all subscriptions + time_0 = DateTime.new(2022, 11, 30) + wallet = nil + travel_to time_0 do + wallet = create_wallet_with_defaults(rate_amount: "10", granted_credits: "100", transaction_metadata: [{key: "transaction_id", value: "123"}]) + expect(wallet.credits_balance).to eq 100 + expect(wallet.balance_cents).to eq 1000_00 + expect(wallet.ongoing_balance_cents).to eq 1000_00 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + + transactions = wallet.wallet_transactions + expect(transactions.length).to eq(1) + + expect_to_be_a_topup_transaction(transactions.first, amount: 1000, credit_amount: 100, metadata: [{"key" => "transaction_id", "value" => "123"}]) + end + + travel_to time_0 + 1.day do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id + "1", + plan_code: plan1.code + } + ) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id + "2", + plan_code: plan2.code + } + ) + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id + "3", + plan_code: plan3.code + } + ) + end + subscription1 = customer.subscriptions.where(plan_id: plan1.id).first + subscription2 = customer.subscriptions.where(plan_id: plan2.id).first + subscription3 = customer.subscriptions.where(plan_id: plan3.id).first + + # ingest first events that would affect all subscriptions: + # units = 10 + # sub1 total = 10 * 1 = 10 + 10% tax = 11 + # sub2 total = 10 * 2 = 20 + 10% tax = 22 - will be billed immediately + # sub3 total = 10 * 10 = 100 + 10% tax = 110 + travel_to time_0 + 5.days do + ingest_event(subscription1, 10) + ingest_event(subscription2, 10) + ingest_event(subscription3, 10) + expect(customer.invoices.count).to eq(1) + expect(subscription2.invoices.count).to eq(1) + expect(subscription2.invoices.first.total_amount_cents).to eq(0) + expect(subscription2.invoices.first.sub_total_including_taxes_amount_cents).to eq(2200) + recalculate_wallet_balances + wallet.reload + # wallet balance in cents = 1000 - 22 = 978 + # ongoing balance in cents = 978 - 11 - 110 = 857 + expect(wallet.credits_balance).to eq 97.8 + expect(wallet.balance_cents).to eq 978_00 + expect(wallet.ongoing_balance_cents).to eq 857_00 + expect(wallet.credits_ongoing_balance).to eq 85.7 + expect(wallet.ongoing_usage_balance_cents).to eq 121_00 + expect(wallet.credits_ongoing_usage_balance).to eq 12.1 + + transactions = wallet.wallet_transactions.sort_by(&:created_at) + expect(transactions.length).to eq(2) + + expect_to_be_an_invoiced_transaction(transactions.last, amount: 22, credit_amount: 2.2) + end + + # ingest second events that would affect all subscriptions + # units = 10 + # sub1 total = 10 * 1 = 10 + 10% tax = 11 + # sub2 total = 10 * 2 = 20 + 10% tax = 22 - will be billed immediately + # sub3 total = 10 * 10 = 100 + 10% tax = 110 - this time the progressive billing threshold is reached at 200 (110 + 110) + travel_to time_0 + 10.days do + ingest_event(subscription1, 10) + ingest_event(subscription2, 10) + ingest_event(subscription3, 10) + perform_usage_update + expect(customer.invoices.count).to eq(3) + expect(subscription2.invoices.count).to eq(2) + expect(subscription2.invoices.order(created_at: :asc).last.sub_total_including_taxes_amount_cents).to eq(22_00) + expect(subscription3.invoices.count).to eq(1) + expect(subscription3.invoices.first.sub_total_including_taxes_amount_cents).to eq(220_00) + # we don't need to force refreshing wallets, because when invoices are triggered, the wallet balances are recalculated + wallet.reload + # wallet balance in cents = 978 - 22 - 220 = 736 + # ongoing balance in cents = 736 - 22 = 714 + expect(wallet.credits_balance).to eq 73.6 + expect(wallet.balance_cents).to eq 736_00 + expect(wallet.ongoing_balance_cents).to eq 714_00 + expect(wallet.credits_ongoing_balance).to eq 71.4 + expect(wallet.ongoing_usage_balance_cents).to eq 22_00 + expect(wallet.credits_ongoing_usage_balance).to eq 2.2 + + transactions = wallet.wallet_transactions + expect(transactions.length).to eq(4) + + third_transaction = transactions.find { |t| t.invoice_id == subscription2.invoices.last.id } + expect_to_be_an_invoiced_transaction(third_transaction, amount: 22, credit_amount: 2.2) + + fourth_transaction = transactions.find { |t| t.invoice_id == subscription3.invoices.last.id } + expect_to_be_an_invoiced_transaction(fourth_transaction, amount: 220, credit_amount: 22) + end + + # ingest third event only affecting third subscription + # units = 20 + # sub3 total = 10 * 20 = 200 + 10% tax = 220 - recurring threshold will be reached again + travel_to time_0 + 15.days do + ingest_event(subscription3, 20) + perform_usage_update + perform_all_enqueued_jobs + expect(customer.invoices.count).to eq(4) + expect(subscription3.invoices.count).to eq(2) + expect(subscription3.invoices.order(created_at: :asc).last.sub_total_including_taxes_amount_cents).to eq(220_00) + # when an invoice is issued, the wallet balances are recalculated + wallet.reload + # wallet balance in cents = 736 - 220 = 516 + # ongoing balance in cents = 516 - 22 = 494 + expect(wallet.credits_balance).to eq 51.6 + expect(wallet.balance_cents).to eq 516_00 + expect(wallet.ongoing_balance_cents).to eq 494_00 + expect(wallet.credits_ongoing_balance).to eq 49.4 + expect(wallet.ongoing_usage_balance_cents).to eq 22_00 + expect(wallet.credits_ongoing_usage_balance).to eq 2.2 + + transactions = wallet.wallet_transactions + expect(transactions.length).to eq(5) + + fifth_transaction = transactions.max_by(&:created_at) + expect_to_be_an_invoiced_transaction(fifth_transaction, amount: 220, credit_amount: 22) + end + end + end + + context "with multiple threshold usages set on plan" do + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: false) } + let(:charge) { create(:charge, plan:, billable_metric:, charge_model: "standard", properties: {"amount" => "10"}) } + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 200_00, recurring: false) } + let(:usage_threshold2) { create(:usage_threshold, plan:, amount_cents: 500_00, recurring: false) } + let(:usage_threshold3) { create(:usage_threshold, plan:, amount_cents: 200_00, recurring: true) } + let!(:another_billable_metric) { create(:billable_metric, organization:, field_name: "total", aggregation_type: "sum_agg") } + let!(:another_charge) { create(:charge, plan:, billable_metric: another_billable_metric, charge_model: "standard", properties: {"amount" => "10"}) } + + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 10, billing_entity:) } + + before do + [ + charge, + usage_threshold, + usage_threshold2, + usage_threshold3, + tax, + another_charge + ] + end + + it "recalculates wallet's balance" do + # Create a wallet with 1000$ + wallet = create_wallet_with_defaults( + rate_amount: "10", + granted_credits: "100" + ) + + expect(wallet.credits_balance).to eq 100 + expect(wallet.balance_cents).to eq 1000_00 + expect(wallet.ongoing_balance_cents).to eq 1000_00 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + + # create all subscriptions + time_0 = DateTime.new(2022, 12, 1) + travel_to time_0 do + create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id + "1", + plan_code: plan.code + } + ) + end + subscription = customer.subscriptions.where(plan_id: plan.id).first + + # ingest first events - no thresholds triggered + # units = 10 + # total = 10 * 10 = 100 + 10% tax = 110 + travel_to time_0 + 5.days do + ingest_event(subscription, 10) + expect(customer.invoices.count).to eq(0) + recalculate_wallet_balances + wallet.reload + # wallet balance in cents = 1000 + # ongoing balance in cents = 1000 - 110 = 890 + expect(wallet.credits_balance).to eq 100 + expect(wallet.balance_cents).to eq 1000_00 + expect(wallet.ongoing_balance_cents).to eq 890_00 + expect(wallet.credits_ongoing_balance).to eq 89.0 + expect(wallet.ongoing_usage_balance_cents).to eq 110_00 + expect(wallet.credits_ongoing_usage_balance).to eq 11.0 + end + + # ingest second events that would trigger first threshold + # units = 10 + # total = 10 * 10 = 100 + 10% tax = 110 - this time the progressive billing threshold is reached + travel_to time_0 + 10.days do + ingest_event(subscription, 10) + perform_usage_update + expect(customer.invoices.count).to eq(1) + expect(subscription.invoices.count).to eq(1) + expect(subscription.invoices.first.sub_total_including_taxes_amount_cents).to eq(220_00) + # no need to force refreshing wallets, because the invoice with applied credits is generated - wallet is refreshed + wallet.reload + # wallet balance in cents = 1000 - 220 = 780 + # ongoing balance in cents = 780 + expect(wallet.credits_balance).to eq 78 + expect(wallet.balance_cents).to eq 780_00 + expect(wallet.ongoing_balance_cents).to eq 780_00 + expect(wallet.credits_ongoing_balance).to eq 78.0 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + end + + # ingest third event only reaching the recurring threshold + # units = 20 + # sub3 total = 10 * 20 = 200 + 10% tax = 330 - second threshold is reached + travel_to time_0 + 15.days do + ingest_event(subscription, 30) + perform_usage_update + expect(customer.invoices.count).to eq(2) + expect(subscription.invoices.count).to eq(2) + expect(subscription.invoices.order(created_at: :asc).last.sub_total_including_taxes_amount_cents).to eq(330_00) + # no need to force refreshing wallets, because the invoice with applied credits is generated - wallet is refreshed + wallet.reload + # wallet balance in cents = 780 - 330 = 450 + # ongoing balance in cents = 450 + expect(wallet.credits_balance).to eq 45 + expect(wallet.balance_cents).to eq 450_00 + expect(wallet.ongoing_balance_cents).to eq 450_00 + expect(wallet.credits_ongoing_balance).to eq 45 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + end + + # recurring threshold is reached + travel_to time_0 + 20.days do + ingest_event(subscription, 20) + perform_usage_update + expect(subscription.invoices.count).to eq(3) + expect(subscription.invoices.order(created_at: :asc).last.sub_total_including_taxes_amount_cents).to eq(220_00) + # no need to force refreshing wallets, because the invoice with applied credits is generated - wallet is refreshed + wallet.reload + # wallet balance in cents = 450 - 220 = 230 + # ongoing balance in cents = 230 + expect(wallet.credits_balance).to eq 23 + expect(wallet.balance_cents).to eq 230_00 + expect(wallet.ongoing_balance_cents).to eq 230_00 + expect(wallet.credits_ongoing_balance).to eq 23 + expect(wallet.ongoing_usage_balance_cents).to eq 0 + expect(wallet.credits_ongoing_usage_balance).to eq 0 + end + + travel_to time_0 + 25.days do + ingest_event(subscription, 1) + perform_usage_update + end + + travel_to time_0 + 1.month do + perform_billing + recalculate_wallet_balances + + expect(subscription.invoices.draft.count).to eq(1) + + draft_invoice = subscription.invoices.draft.first + expect(draft_invoice.total_amount_cents).not_to eq(0.0) + expect(draft_invoice.fees.find { |f| f.precise_coupons_amount_cents != 0.0 }).to be_present + + wallet.reload + expect(wallet.credits_balance).to eq 23 + expect(wallet.balance_cents).to eq 230_00 + expect(wallet.ongoing_balance_cents).to eq 219_00 + expect(wallet.credits_ongoing_balance).to eq 21.9 + expect(wallet.ongoing_usage_balance_cents).to eq 1100 + expect(wallet.credits_ongoing_usage_balance).to eq 1.1 + end + end + end + + describe "multiple wallets" do + def expect_wallet(wallet, balance:, credits:, balance_usage:, credits_usage:, ongoing_balance:, ongoing_credits:) + wallet.reload + expect(wallet.balance_cents).to eq balance + expect(wallet.credits_balance).to eq credits + expect(wallet.ongoing_usage_balance_cents).to eq balance_usage + expect(wallet.credits_ongoing_usage_balance).to eq credits_usage + expect(wallet.ongoing_balance_cents).to eq ongoing_balance + expect(wallet.credits_ongoing_balance).to eq ongoing_credits + end + + let(:bm_storage) { create(:sum_billable_metric, name: "Storage", organization:, field_name: "total") } + let(:bm_seats) { create(:sum_billable_metric, name: "Seats", organization:, field_name: "total") } + let(:bm_api) { create(:sum_billable_metric, name: "API", organization:, field_name: "total") } + let(:bm_sms) { create(:sum_billable_metric, name: "SMS", organization:, field_name: "total") } + + before do + [bm_storage, bm_seats, bm_api, bm_sms].each do |bm| + create(:standard_charge, plan:, billable_metric: bm, properties: {"amount" => "1"}) + end + end + + context "when each wallet limited to 1 billable metric" do + it "applies each metric usage to its corresponding wallet by priority" do + time_0 = DateTime.new(2022, 12, 1) + wallets = [] + travel_to time_0 do + wallets << create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W1", + currency: "EUR", + granted_credits: "10", + priority: 1, + applies_to: {billable_metric_codes: [bm_storage.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + wallets << create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W2", + currency: "EUR", + granted_credits: "20", + priority: 2, + applies_to: {billable_metric_codes: [bm_seats.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + wallets << create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W3", + currency: "EUR", + granted_credits: "30", + priority: 3, + applies_to: {billable_metric_codes: [bm_api.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + wallets << create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W4", + currency: "EUR", + granted_credits: "40", + priority: 4, + applies_to: {billable_metric_codes: [bm_sms.code]}, + invoice_requires_successful_payment: false + }, as: :model) + end + + travel_to time_0 + 1.day do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 5.days do + ingest_event(subscription, 10, bm_storage.code) + ingest_event(subscription, 20, bm_seats.code) + ingest_event(subscription, 30, bm_api.code) + ingest_event(subscription, 40, bm_sms.code) + + recalculate_wallet_balances + + expect_wallet(wallets[0], balance: 1000, balance_usage: 1000, ongoing_balance: 0, credits: 10, credits_usage: 10, ongoing_credits: 0) + expect_wallet(wallets[1], balance: 2000, balance_usage: 2000, ongoing_balance: 0, credits: 20, credits_usage: 20, ongoing_credits: 0) + expect_wallet(wallets[2], balance: 3000, balance_usage: 3000, ongoing_balance: 0, credits: 30, credits_usage: 30, ongoing_credits: 0) + expect_wallet(wallets[3], balance: 4000, balance_usage: 4000, ongoing_balance: 0, credits: 40, credits_usage: 40, ongoing_credits: 0) + end + end + end + + context "when each wallet limited to 2 billable metrics" do + it "applies usage once per metric following wallets priority" do + time_0 = DateTime.new(2022, 12, 1) + w1 = w2 = w3 = w4 = nil + travel_to time_0 do + w1 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W1", + currency: "EUR", + granted_credits: "10", + priority: 1, + applies_to: {billable_metric_codes: [bm_storage.code, bm_seats.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + w2 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W2", + currency: "EUR", + granted_credits: "20", + priority: 2, + applies_to: {billable_metric_codes: [bm_seats.code, bm_api.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + w3 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W3", + currency: "EUR", + granted_credits: "30", + priority: 3, + applies_to: {billable_metric_codes: [bm_api.code, bm_sms.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + w4 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W4", + currency: "EUR", + granted_credits: "40", + priority: 4, + applies_to: {billable_metric_codes: [bm_sms.code, bm_storage.code]}, + invoice_requires_successful_payment: false + }, as: :model) + end + + travel_to time_0 + 1.day do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 5.days do + ingest_event(subscription, 10, bm_storage.code) + ingest_event(subscription, 20, bm_seats.code) + ingest_event(subscription, 30, bm_api.code) + ingest_event(subscription, 40, bm_sms.code) + + recalculate_wallet_balances + + # W1: storage+seats -> applies 10 + 20 = 30 -> 10 - 30 = -20 + expect_wallet(w1, balance: 1000, balance_usage: 3000, ongoing_balance: -2000, credits: 10, credits_usage: 30, ongoing_credits: -20) + # W2: seats already applied, applies API 30 -> 20 - 30 = -10 + expect_wallet(w2, balance: 2000, balance_usage: 3000, ongoing_balance: -1000, credits: 20, credits_usage: 30, ongoing_credits: -10) + # W3: api already applied, applies SMS 40 -> 30 - 40 = -10 + expect_wallet(w3, balance: 3000, balance_usage: 4000, ongoing_balance: -1000, credits: 30, credits_usage: 40, ongoing_credits: -10) + # W4: sms and storage already applied -> nothing -> stays 40 + expect_wallet(w4, balance: 4000, balance_usage: 0, ongoing_balance: 4000, credits: 40, credits_usage: 0, ongoing_credits: 40) + end + end + end + + context "when each wallet limited to 2 billable metrics with filtered events" do + it "applies usage once per metric even when events include filters" do + time_0 = DateTime.new(2022, 12, 1) + w1 = w2 = w3 = w4 = nil + travel_to time_0 do + w1 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W1", + currency: "EUR", + granted_credits: "10", + priority: 1, + applies_to: {billable_metric_codes: [bm_storage.code, bm_seats.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + w2 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W2", + currency: "EUR", + granted_credits: "20", + priority: 2, + applies_to: {billable_metric_codes: [bm_seats.code, bm_api.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + w3 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W3", + currency: "EUR", + granted_credits: "30", + priority: 3, + applies_to: {billable_metric_codes: [bm_api.code, bm_sms.code]}, + invoice_requires_successful_payment: false + }, as: :model) + + w4 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W4", + currency: "EUR", + granted_credits: "40", + priority: 4, + applies_to: {billable_metric_codes: [bm_sms.code, bm_storage.code]}, + invoice_requires_successful_payment: false + }, as: :model) + end + + travel_to time_0 + 1.day do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 5.days do + # For each billable metric, send two events with a "filter" value to simulate + # the scratch scenario (filter_01 and filter_02) while keeping totals equal + # to 10, 20, 30, 40 respectively. + ingest_event(subscription, 7, bm_storage.code, "filter_01") + ingest_event(subscription, 3, bm_storage.code, "filter_02") + + ingest_event(subscription, 17, bm_seats.code, "filter_01") + ingest_event(subscription, 3, bm_seats.code, "filter_02") + + ingest_event(subscription, 27, bm_api.code, "filter_01") + ingest_event(subscription, 3, bm_api.code, "filter_02") + + ingest_event(subscription, 37, bm_sms.code, "filter_01") + ingest_event(subscription, 3, bm_sms.code, "filter_02") + + recalculate_wallet_balances + + # W1: storage+seats -> applies 10 + 20 = 30 -> 10 - 30 = -20 + expect_wallet(w1, balance: 1000, balance_usage: 3000, ongoing_balance: -2000, credits: 10, credits_usage: 30, ongoing_credits: -20) + # W2: seats already applied, applies API 30 -> 20 - 30 = -10 + expect_wallet(w2, balance: 2000, balance_usage: 3000, ongoing_balance: -1000, credits: 20, credits_usage: 30, ongoing_credits: -10) + # W3: api already applied, applies SMS 40 -> 30 - 40 = -10 + expect_wallet(w3, balance: 3000, balance_usage: 4000, ongoing_balance: -1000, credits: 30, credits_usage: 40, ongoing_credits: -10) + # W4: sms and storage already applied -> nothing -> stays 40 + expect_wallet(w4, balance: 4000, balance_usage: 0, ongoing_balance: 4000, credits: 40, credits_usage: 0, ongoing_credits: 40) + end + end + end + + context "when wallets are limited to charges (fee type)" do + it "applies all charges to the first wallet limited to charge type" do + time_0 = DateTime.new(2022, 12, 1) + w1 = w2 = w3 = w4 = nil + travel_to time_0 do + w1 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W1", + currency: "EUR", + granted_credits: "10", + priority: 1, + applies_to: {fee_types: ["subscription"]}, + invoice_requires_successful_payment: false + }, as: :model) + + w2 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W2", + currency: "EUR", + granted_credits: "20", + priority: 2, + applies_to: {fee_types: ["charge"]}, + invoice_requires_successful_payment: false + }, as: :model) + + w3 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W3", + currency: "EUR", + granted_credits: "30", + priority: 3, + applies_to: {fee_types: ["charge"]}, + invoice_requires_successful_payment: false + }, as: :model) + + w4 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W4", + currency: "EUR", + granted_credits: "40", + priority: 4, + applies_to: {fee_types: ["charge"]}, + invoice_requires_successful_payment: false + }, as: :model) + end + + travel_to time_0 + 1.day do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 5.days do + ingest_event(subscription, 10, bm_storage.code) + ingest_event(subscription, 20, bm_seats.code) + ingest_event(subscription, 30, bm_api.code) + ingest_event(subscription, 40, bm_sms.code) + + recalculate_wallet_balances + + # Total usage = 100 + # Second wallet takes it all, cause 1st limited to subscription fee only + expect_wallet(w1, balance: 1000, balance_usage: 0, ongoing_balance: 1000, credits: 10, credits_usage: 0, ongoing_credits: 10) + expect_wallet(w2, balance: 2000, balance_usage: 100_00, ongoing_balance: -8000, credits: 20, credits_usage: 100, ongoing_credits: -80) + expect_wallet(w3, balance: 3000, balance_usage: 0, ongoing_balance: 3000, credits: 30, credits_usage: 0, ongoing_credits: 30) + expect_wallet(w4, balance: 4000, balance_usage: 0, ongoing_balance: 4000, credits: 40, credits_usage: 0, ongoing_credits: 40) + end + end + end + + context "when wallet has no limitations" do + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 10_00, recurring: false) } + + before do + usage_threshold + end + + it "apply unrestricted rule to first wallet only" do + time_0 = DateTime.new(2022, 12, 1) + w1 = w2 = w3 = w4 = nil + travel_to time_0 do + w1 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W1", + currency: "EUR", + granted_credits: "10", + priority: 1, + invoice_requires_successful_payment: false + }, as: :model) + + w2 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W2", + currency: "EUR", + granted_credits: "20", + priority: 2, + invoice_requires_successful_payment: false + }, as: :model) + + w3 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W3", + currency: "EUR", + granted_credits: "30", + priority: 3, + invoice_requires_successful_payment: false + }, as: :model) + + w4 = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "W4", + currency: "EUR", + granted_credits: "40", + priority: 4, + invoice_requires_successful_payment: false + }, as: :model) + end + + travel_to time_0 + 1.day do + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + end + subscription = customer.subscriptions.first + + travel_to time_0 + 5.days do + ingest_event(subscription, 10, bm_storage.code) + ingest_event(subscription, 20, bm_seats.code) + ingest_event(subscription, 30, bm_api.code) + ingest_event(subscription, 40, bm_sms.code) + + recalculate_wallet_balances + + # Total usage = 100 - 10(progressive billing already billed) = 90 + # First (unrestricted) wallet takes it all + expect_wallet(w1, balance: 0, balance_usage: 9000, ongoing_balance: -9000, credits: 0, credits_usage: 90, ongoing_credits: -90) + expect_wallet(w2, balance: 2000, balance_usage: 0, ongoing_balance: 2000, credits: 20, credits_usage: 0, ongoing_credits: 20) + expect_wallet(w3, balance: 3000, balance_usage: 0, ongoing_balance: 3000, credits: 30, credits_usage: 0, ongoing_credits: 30) + expect_wallet(w4, balance: 4000, balance_usage: 0, ongoing_balance: 4000, credits: 40, credits_usage: 0, ongoing_credits: 40) + end + end + end + end +end diff --git a/spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb b/spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb new file mode 100644 index 0000000..9b9d388 --- /dev/null +++ b/spec/scenarios/wallets/customer_wallets_balance_refresh_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Use wallet's credits and recalculate balances", transaction: false do + subject(:wallets) { refresh_service.wallets } + + let(:refresh_service) { Customers::RefreshWalletsService.call(customer:, include_generating_invoices:) } + let(:include_generating_invoices) { true } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, started_at: 2.years.ago) } + + let(:timestamp) { Time.current } + + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:billable_metric2) { create(:billable_metric, aggregation_type: "count_agg") } + let(:billable_metric3) { create(:billable_metric, aggregation_type: "count_agg") } + + let(:first_charge) do + create(:standard_charge, plan: subscription.plan, billable_metric:, properties: {amount: "10"}) + end + + let(:second_charge) do + create(:standard_charge, plan: subscription.plan, billable_metric: billable_metric2, properties: {amount: "5"}) + end + + let(:wallet_attrs) do + { + customer:, + balance_cents: 1000, + ongoing_balance_cents: 0, + ongoing_usage_balance_cents: 0, + credits_balance: 10.0, + credits_ongoing_balance: 0, + credits_ongoing_usage_balance: 0, + ready_to_be_refreshed: true + } + end + + let(:wallet) { create(:wallet, wallet_attrs) } + let(:wallet2) { create(:wallet, wallet_attrs) } + let(:wallet3) { create(:wallet, wallet_attrs.merge({name: "wallet 3"})) } + let(:wallet_target) { create(:wallet_target, wallet:, billable_metric:) } + let(:wallet_target2) { create(:wallet_target, wallet: wallet2, billable_metric: billable_metric2) } + let(:wallet_target3) { create(:wallet_target, wallet: wallet3, billable_metric: billable_metric3) } + + let(:events) do + create_list( + :event, 3, + organization:, + subscription:, + customer:, + code: billable_metric.code, + timestamp: + ).push( + create( + :event, + organization:, + subscription:, + customer:, + code: billable_metric2.code, + timestamp: + ) + ).push( + create( + :event, + organization:, + subscription:, + customer:, + code: billable_metric3.code, + timestamp: + ) + ) + end + + context "with multiple wallets with restrictions" do + before do + wallet + wallet2 + wallet3 + wallet_target + wallet_target2 + first_charge + second_charge + events + end + + it "returns all active wallets" do + expect(wallets).to match_array(customer.wallets.active) + end + + ## + # wallet 1 + # 1000 - 3000(from events) = -2000 + # wallet 2 + # 1000 - 500(from event) = 500 + ## + it "updates the correct ongoing balances for each wallet" do + expect_wallet(wallet, ongoing_usage: 3000, credits_usage: 30, ongoing: -2000, credits: -20) + expect_wallet(wallet2, ongoing_usage: 500, credits_usage: 5, ongoing: 500, credits: 5) + end + + context "when there is paid in advance charges" do + let(:third_charge) do + create(:standard_charge, :pay_in_advance, + invoiceable: false, + plan: subscription.plan, + billable_metric: billable_metric3, + properties: {amount: "9999"}) + end + + before do + third_charge + end + + it "updates the correct ongoing balances for each wallet" do + expect_wallet(wallet, ongoing_usage: 3000, credits_usage: 30, ongoing: -2000, credits: -20) + expect_wallet(wallet2, ongoing_usage: 500, credits_usage: 5, ongoing: 500, credits: 5) + expect_wallet(wallet3, ongoing_usage: 0, credits_usage: 0, ongoing: 1000, credits: 10) + end + end + + # PROGRESSIVE BILLING + context "when there is a progressive billing invoice" do + let(:billable_metric3) { create(:billable_metric, aggregation_type: "count_agg") } + let(:invoice_type) { :progressive_billing } + let(:charges_to_datetime) { timestamp + 1.week } + let(:charges_from_datetime) { timestamp - 1.week } + + let(:invoice_subscription) do + create(:invoice_subscription, + subscription:, + charges_from_datetime:, + charges_to_datetime:) + end + + let(:invoice) { invoice_subscription.invoice } + + let(:fee) do + create( + :charge_fee, + charge: second_charge, + subscription:, + precise_coupons_amount_cents: 10, + invoice:, + amount_cents: 100, + taxes_amount_cents: 10 + ) + end + let(:third_charge) do + create(:standard_charge, plan: subscription.plan, billable_metric: billable_metric3, properties: {amount: "33"}) + end + let(:fee2) do + create( + :charge_fee, + charge: third_charge, + subscription:, + precise_coupons_amount_cents: 10, + invoice:, + amount_cents: 100, + taxes_amount_cents: 10 + ) + end + + before do + fee + fee2 + invoice.update!(invoice_type:, fees_amount_cents: 210, total_amount_cents: 210) + end + + ## + # wallet 1 + # 1000 - 3000(from events) = -2000 #untouched + # wallet 2 + # fee 2 is not taken into account because of the wallet restrictions + # 1000 - 500(from event) - 100(from invoice) ( 100 + 10(tax) - 10(already paid) ) + # = 400 # progressive billing invoice + ## + it "updates wallet ongoing balances including progressive billing invoice" do + expect_wallet(wallet, ongoing_usage: 3000, credits_usage: 30, ongoing: -2000, credits: -20) + expect_wallet(wallet2, ongoing_usage: 400, credits_usage: 4, ongoing: 600, credits: 6) + end + end + + context "when there is a draft invoice" do + let(:draft_invoice) do + create( + :invoice, + status: :draft, + issuing_date: DateTime.now, + customer:, + organization: customer.organization + ) + end + + let(:fee) do + create( + :charge_fee, + charge: second_charge, + subscription:, + precise_coupons_amount_cents: 70, # simulate progressive billing + invoice: draft_invoice, + amount_cents: 100, + taxes_amount_cents: 10 + ) + end + + before do + fee + draft_invoice.update!(fees_amount_cents: 110, total_amount_cents: 110) + end + + ## + # wallet 1 + # 1000 - 3000(from events) = -2000 #untouched + # wallet 2 + # 1000 - 500(from event) - 110(from draft invoice) + 70 (already paid) + # = 540 # progressive billing invoice + ## + it "updates wallet ongoing balances including progressive billing invoice" do + expect_wallet(wallet, ongoing_usage: 3000, credits_usage: 30, ongoing: -2000, credits: -20) + expect_wallet(wallet2, ongoing_usage: 540, credits_usage: 5.4, ongoing: 460, credits: 4.6) + end + end + end + + def expect_wallet(wallet, ongoing_usage:, credits_usage:, ongoing:, credits:) + w = wallets.find(wallet.id) + expect(w.ongoing_usage_balance_cents).to eq ongoing_usage + expect(w.credits_ongoing_usage_balance).to eq credits_usage + expect(w.ongoing_balance_cents).to eq ongoing + expect(w.credits_ongoing_balance).to eq credits + end +end diff --git a/spec/scenarios/wallets/topup_rule_with_limits_spec.rb b/spec/scenarios/wallets/topup_rule_with_limits_spec.rb new file mode 100644 index 0000000..4888e43 --- /dev/null +++ b/spec/scenarios/wallets/topup_rule_with_limits_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Top up with wallet limits", :premium, transaction: false do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 600, pay_in_advance: true) } + + context "when recurring rule has ignore limits enabled" do + it "creates top up that exceeds wallet limits" do + allow_any_instance_of(::PaymentProviders::Stripe::Payments::CreateService).to receive(:create_payment_intent) # rubocop:disable RSpec/AnyInstance + .and_return( + Stripe::PaymentIntent.construct_from( + id: "ch_#{SecureRandom.hex(6)}", + status: :succeeded, + amount: 1000, + currency: "EUR" + ) + ) + + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + granted_credits: "10", + paid_top_up_min_amount_cents: 100_00, + recurring_transaction_rules: [ + { + trigger: "threshold", + paid_credits: "10", + method: "fixed", + threshold_credits: "5", + ignore_paid_top_up_limits: true + } + ] + }, as: :model) + + setup_stripe_for(customer:) + + subscription = nil + travel_to(Time.zone.parse("2025-09-01T22:00:00")) do + subscription = create_subscription( + { + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code, + interval: "calendar" + }, as: :model + ) + end + + expect(subscription.invoices.count).to eq 1 + + invoice = subscription.invoices.sole + + expect(invoice.prepaid_credit_amount_cents).to eq(600) + + wallet.reload + + expect(wallet.credits_balance).to eq 14 + end + end +end diff --git a/spec/scenarios/wallets/topup_with_name_spec.rb b/spec/scenarios/wallets/topup_with_name_spec.rb new file mode 100644 index 0000000..b5ebf6b --- /dev/null +++ b/spec/scenarios/wallets/topup_with_name_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Transaction with name", :premium do + let(:organization) { create(:organization, :with_static_values, webhook_url: nil) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + def within_first_day(&block) + travel_to Time.zone.local(2025, 1, 2, 0, 0, 0) do + yield + end + end + + def within_second_day(&block) + travel_to Time.zone.local(2025, 1, 3, 0, 0, 0) do + yield + end + end + + def test_invoice_on_wallet_creation(attrs: {}, snapshot_name: nil) + wallet = within_first_day do + create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR", + paid_credits: "100", + **attrs + }, as: :model) + end + + expect(wallet.rate_amount).to eq(1) + + wallet_transactions = wallet.wallet_transactions + expect(wallet_transactions.count).to eq(1) + expect(wallet_transactions.first.name).to eq(attrs[:transaction_name]) + + invoice = customer.invoices.credit.sole + expect(invoice.file.download).to match_html_snapshot(name: snapshot_name) + + wallet + end + + context "when the transaction name is provided" do + it "renders the transaction name on the invoice" do + wallet = test_invoice_on_wallet_creation( + attrs: {transaction_name: "Initial top-up"}, + snapshot_name: "Initial top-up" + ) + + wt = within_second_day do + create_wallet_transaction({ + wallet_id: wallet.id, + paid_credits: "200", + metadata: [{key: "transaction_id", value: "123"}], + name: "Top-up" + }, as: :model).first + end + + expect(wt.status).to eq "pending" + expect(wt.transaction_status).to eq "purchased" + expect(wt.invoice_requires_successful_payment).to be false + expect(wt.credit_amount).to eq(200) + expect(wt.amount).to eq(200) + expect(wt.name).to eq("Top-up") + expect(wt.metadata).to eq([{"key" => "transaction_id", "value" => "123"}]) + + top_up_invoice = customer.invoices.credit.order(:created_at).last + expect(top_up_invoice.file.download).to match_html_snapshot(name: "Top-up") + end + end + + context "when the transaction name is not provided" do + context "when the wallet has a name" do + it "renders the wallet name on the invoice" do + test_invoice_on_wallet_creation(attrs: {name: "My wallet"}) + end + end + + context "when the wallet has no name" do + it "renders the default name on the invoice" do + test_invoice_on_wallet_creation + end + end + end +end diff --git a/spec/scenarios/wallets/topup_with_open_invoices_spec.rb b/spec/scenarios/wallets/topup_with_open_invoices_spec.rb new file mode 100644 index 0000000..65706e8 --- /dev/null +++ b/spec/scenarios/wallets/topup_with_open_invoices_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Transaction with invoice after payment", :premium do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + context "when the wallet does not require successful payment before invoicing" do + it "allows wallet transaction to require successful payment" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + granted_credits: "10", + invoice_requires_successful_payment: false # default + }, as: :model) + + expect(wallet.credits_balance).to eq 10 + + wt = create_wallet_transaction({ + wallet_id: wallet.id, + paid_credits: "15", + invoice_requires_successful_payment: true + }, as: :model).first + + expect(wt.status).to eq "pending" + expect(wt.transaction_status).to eq "purchased" + expect(wt.invoice_requires_successful_payment).to be true + + # Customer does not have a payment_provider set yet + invoice = customer.invoices.credit.sole + expect(invoice.status).to eq "open" + expect(invoice.payment_status).to eq "pending" + expect(invoice.number).to end_with "-DRAFT" + expect(invoice.total_amount_cents).to eq 1500 + expect(invoice.file?).to be false + + setup_stripe_for(customer:) + + allow_any_instance_of(::PaymentProviders::Stripe::Payments::CreateService).to receive(:create_payment_intent) # rubocop:disable RSpec/AnyInstance + .and_return( + Stripe::PaymentIntent.construct_from( + id: "ch_#{SecureRandom.hex(6)}", + status: :succeeded, + amount: invoice.total_amount_cents, + currency: invoice.currency + ) + ) + Invoices::Payments::CreateService.call_async(invoice:) + perform_all_enqueued_jobs + + invoice.reload + expect(invoice.status).to eq "finalized" + expect(invoice.payment_status).to eq "succeeded" + expect(invoice.number).to end_with "-001-001" + expect(invoice.file?).to be true + + wt.reload + expect(wt.status).to eq "settled" + expect(wt.settled_at).not_to be_nil + + wallet.reload + expect(wallet.credits_balance).to eq 25 + end + + context "when there is a payment failure" do + it "keeps the invoice invisible" do + setup_stripe_for(customer:) + allow_any_instance_of(::PaymentProviders::Stripe::Payments::CreateService).to receive(:create_payment_intent) # rubocop:disable RSpec/AnyInstance + .and_return( + Stripe::PaymentIntent.construct_from( + id: "ch_#{SecureRandom.hex(6)}", + status: :failed, + amount: 1500, + currency: "EUR" + ) + ) + + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + granted_credits: "10", + invoice_requires_successful_payment: false # default + }, as: :model) + + wt = create_wallet_transaction({ + wallet_id: wallet.id, + paid_credits: "15", + invoice_requires_successful_payment: true + }, as: :model).first + + # Customer does not have a payment_provider set yet + invoice = customer.invoices.credit.sole + expect(invoice.status).to eq "open" + expect(invoice.payment_status).to eq "failed" + expect(invoice.number).to end_with "-DRAFT" + expect(invoice.file?).to be false + + wt.reload + expect(wt.status).to eq "failed" + expect(wt.settled_at).to be_nil + + wallet.reload + expect(wallet.credits_balance).to eq 10 + end + end + end +end diff --git a/spec/scenarios/wallets/topup_with_priority_spec.rb b/spec/scenarios/wallets/topup_with_priority_spec.rb new file mode 100644 index 0000000..0666f0d --- /dev/null +++ b/spec/scenarios/wallets/topup_with_priority_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Transaction with priority" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:customer) { create(:customer, organization:) } + + context "when creating a wallet with transaction_priority" do + it "sets the priority on the initial wallet transactions" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR", + paid_credits: "100", + granted_credits: "50", + transaction_priority: 5 + }, as: :model) + + expect(wallet.wallet_transactions.count).to eq(2) + expect(wallet.wallet_transactions.pluck(:priority)).to eq([5, 5]) + end + end + + context "when creating a wallet without transaction_priority" do + it "uses the default priority on the initial wallet transactions" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR", + paid_credits: "100", + granted_credits: "50" + }, as: :model) + + expect(wallet.wallet_transactions.count).to eq(2) + expect(wallet.wallet_transactions.pluck(:priority)).to eq([50, 50]) + end + end + + context "when creating a wallet transaction with priority" do + it "sets the priority on the wallet transaction" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR" + }, as: :model) + + wallet_transactions = create_wallet_transaction({ + wallet_id: wallet.id, + paid_credits: "100", + granted_credits: "50", + priority: 10 + }, as: :model) + + expect(wallet_transactions.count).to eq(2) + expect(wallet_transactions.pluck(:priority)).to eq([10, 10]) + end + end +end diff --git a/spec/scenarios/wallets/topup_with_rounding_spec.rb b/spec/scenarios/wallets/topup_with_rounding_spec.rb new file mode 100644 index 0000000..c9b6f10 --- /dev/null +++ b/spec/scenarios/wallets/topup_with_rounding_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Transaction with rounding", :premium do + let(:organization) { create(:organization, :with_static_values, webhook_url: nil) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + around do |test| + # Set the time to have a fixed issue date in invoice + travel_to Time.zone.local(2025, 1, 1, 0, 0, 0) do + test.run + end + end + + it "rounds the amount field when handling paid_credits" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + invoice_requires_successful_payment: false + }, as: :model) + + expect(wallet.rate_amount).to eq(1) + + wt = create_wallet_transaction({ + wallet_id: wallet.id, + paid_credits: "17.9699999999999988631316", + invoice_requires_successful_payment: false, + metadata: [{key: "transaction_id", value: "123"}] + }, as: :model).first + + expect(wt.status).to eq "pending" + expect(wt.transaction_status).to eq "purchased" + expect(wt.invoice_requires_successful_payment).to be false + expect(wt.credit_amount).to eq(17.97) + expect(wt.amount).to eq(17.97) + expect(wt.metadata).to eq([{"key" => "transaction_id", "value" => "123"}]) + + # Customer does not have a payment_provider set yet + invoice = customer.invoices.credit.sole + expect(invoice.file.download).to match_html_snapshot + + expect(invoice.status).to eq "finalized" + expect(invoice.payment_status).to eq "pending" + expect(invoice.total_amount_cents).to eq 1797 + + # mark invoice as paid + update_invoice(invoice, {payment_status: "succeeded"}) + perform_all_enqueued_jobs + + wt.reload + expect(wt.status).to eq "settled" + expect(wt.settled_at).not_to be_nil + + wallet.reload + expect(wallet.credits_balance).to eq 17.97 + expect(wallet.balance_cents).to eq 1797 + end + + it "does not apply rounding handling granted_credits" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + invoice_requires_successful_payment: false + }, as: :model) + + expect(wallet.rate_amount).to eq(1) + + wt = create_wallet_transaction({ + wallet_id: wallet.id, + granted_credits: "17.9699999999999988631316", + invoice_requires_successful_payment: false + }, as: :model).first + + expect(wt.status).to eq "settled" + expect(wt.transaction_status).to eq "granted" + expect(wt.invoice_requires_successful_payment).to be false + expect(wt.credit_amount).to eq(17.96999) + expect(wt.amount).to eq(17.97) + + perform_all_enqueued_jobs + + wallet.reload + expect(wallet.credits_balance).to eq 17.96999 + expect(wallet.balance.to_d).to eq 17.97 + end +end diff --git a/spec/scenarios/wallets/voiding_wallet_credits_spec.rb b/spec/scenarios/wallets/voiding_wallet_credits_spec.rb new file mode 100644 index 0000000..be05036 --- /dev/null +++ b/spec/scenarios/wallets/voiding_wallet_credits_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Voiding wallet credits", :premium do + let(:organization) { create(:organization, :with_static_values, webhook_url: nil) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + around do |test| + # Set the time to have a fixed issue date in invoice + travel_to Time.zone.local(2025, 1, 1, 0, 0, 0), &test + end + + it "voids a wallet credit" do + wallet = create_wallet({ + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + granted_credits: "100", + invoice_requires_successful_payment: false + }, as: :model) + + transactions = create_wallet_transaction({ + wallet_id: wallet.id, + voided_credits: "14.28444999" + }, as: :model) + + expect(transactions.count).to eq(1) + transaction = transactions.first + expect(transaction.status).to eq("settled") + expect(transaction.transaction_status).to eq("voided") + expect(transaction.amount).to eq(14.28) + expect(transaction.credit_amount).to eq(14.28444) + + wallet.reload + expect(wallet.credits_balance).to eq(85.71556) + # FIXME + expect(wallet.balance.to_d).to eq(85.72) + end +end diff --git a/spec/scenarios/wallets/wallet_traceability_spec.rb b/spec/scenarios/wallets/wallet_traceability_spec.rb new file mode 100644 index 0000000..75efe53 --- /dev/null +++ b/spec/scenarios/wallets/wallet_traceability_spec.rb @@ -0,0 +1,1034 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Wallet Traceability Scenarios" do + let(:organization) { create(:organization, webhook_url: nil) } + let(:billing_entity) { create(:billing_entity, organization:, invoice_grace_period: 0) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 0, pay_in_advance: false) } + let(:billable_metric) { create(:billable_metric, organization:, field_name: "total", aggregation_type: "sum_agg") } + let(:charge) { create(:charge, plan:, billable_metric:, charge_model: "standard", properties: {"amount" => "1"}) } + + before { charge } + + def create_traceable_wallet(rate_amount: "1") + params = { + external_customer_id: customer.external_id, + rate_amount:, + name: "Traceable Wallet", + currency: "EUR", + granted_credits: "0", + invoice_requires_successful_payment: false + } + + wallet = create_wallet(params, as: :model) + wallet.update!(traceable: true) + wallet + end + + def create_non_traceable_wallet(rate_amount: "1") + params = { + external_customer_id: customer.external_id, + rate_amount:, + name: "Non-Traceable Wallet", + currency: "EUR", + granted_credits: "0", + invoice_requires_successful_payment: false + } + + wallet = create_wallet(params, as: :model) + wallet.update!(traceable: false) + wallet + end + + def top_up_wallet(wallet, granted_credits: nil, paid_credits: nil) + params = {wallet_id: wallet.id} + params[:granted_credits] = granted_credits if granted_credits + params[:paid_credits] = paid_credits if paid_credits + + create_wallet_transaction(params, as: :model) + end + + def void_credits(wallet, amount) + transactions = create_wallet_transaction({ + wallet_id: wallet.id, + voided_credits: amount.to_s + }, as: :model) + transactions.find { |t| t.transaction_status == "voided" } + end + + def get_fundings(wallet_transaction) + get_with_token(organization, "/api/v1/wallet_transactions/#{wallet_transaction.id}/fundings") + json[:wallet_transaction_fundings] + end + + def get_consumptions(wallet_transaction) + get_with_token(organization, "/api/v1/wallet_transactions/#{wallet_transaction.id}/consumptions") + json[:wallet_transaction_consumptions] + end + + def setup_subscription + create_subscription({ + external_customer_id: customer.external_id, + external_id: customer.external_id, + plan_code: plan.code + }) + customer.subscriptions.first + end + + def ingest_usage(subscription, amount) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric.field_name => amount} + }) + perform_usage_update + end + + describe "Invoice Consumption Scenarios" do + describe "Example 1: Simple Consumption (Single Source)" do + # Customer tops up $100, then invoice consumes $40: + # + # INBOUND OUTBOUND + # ┌─────────────────────────┐ ┌─────────────────────────┐ + # │ TX1 (granted) │ │ TX2 (invoiced) │ + # │ amount: $100 │────$40─────▶ │ amount: $40 │ + # │ remaining: $60 │ │ │ + # └─────────────────────────┘ └─────────────────────────┘ + # + # Join table: + # │ TX1 │ TX2 │ $40 │ + + it "creates a single consumption record linking inbound to outbound" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + tx1 = wallet.wallet_transactions.inbound.first + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 40) + end + + travel_to(time_0 + 1.month) do + perform_billing + invoice = subscription.invoices.first + expect(invoice.total_amount_cents).to eq(0) + end + + tx2 = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).first + expect(tx2).to be_present + expect(tx2.amount).to eq(40) + + fundings = get_fundings(tx2) + expect(fundings.count).to eq(1) + expect(fundings.first[:wallet_transaction][:lago_id]).to eq(tx1.id) + expect(fundings.first[:amount_cents]).to eq(4000) + + expect(tx1.reload.remaining_amount_cents).to eq(6000) + end + end + + describe "Example 2: Consumption Spanning Multiple Inbounds" do + # Customer has two top-ups ($30 granted, $50 granted), then invoice consumes $60: + # + # INBOUND OUTBOUND + # ┌─────────────────────────┐ + # │ TX1 (granted) │ ┌─────────────────────────┐ + # │ amount: $30 │────$30─────▶│ TX3 (invoiced) │ + # │ remaining: $0 │ ┌──▶│ amount: $60 │ + # └─────────────────────────┘ │ └─────────────────────────┘ + # ┌─────────────────────────┐ │ + # │ TX2 (granted) │ │ + # │ amount: $50 │───$30───┘ + # │ remaining: $20 │ + # └─────────────────────────┘ + # + # Join table: + # │ TX1 │ TX3 │ $30 │ (first inbound consumed first - FIFO) + # │ TX2 │ TX3 │ $30 │ (then second inbound) + + it "creates consumption records from both inbounds following FIFO order" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + tx2 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "30") + tx1 = wallet.wallet_transactions.inbound.first + end + + travel_to(time_0 + 1.hour) do + top_up_wallet(wallet, granted_credits: "50") + tx2 = wallet.wallet_transactions.inbound.order(created_at: :desc).first + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 60) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx3 = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).first + + fundings = get_fundings(tx3) + expect(fundings.count).to eq(2) + + tx1_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx1.id } + tx2_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx2.id } + + expect(tx1_funding[:amount_cents]).to eq(3000) + expect(tx2_funding[:amount_cents]).to eq(3000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(2000) + end + end + + describe "Example 3: Multiple Outbounds from Same Inbound" do + # Customer tops up $100, then has two invoices ($25 and $35): + # + # INBOUND OUTBOUND + # ┌─────────────────────────┐ ┌─────────────────────────┐ + # │ TX1 (granted) │────$25──────▶│ TX2 (invoiced) │ + # │ amount: $100 │ │ amount: $25 │ + # │ remaining: $40 │ └─────────────────────────┘ + # │ │ ┌─────────────────────────┐ + # │ │────$35──────▶│ TX3 (invoiced) │ + # │ │ │ amount: $35 │ + # └─────────────────────────┘ └─────────────────────────┘ + # + # Join table: + # │ TX1 │ TX2 │ $25 │ + # │ TX1 │ TX3 │ $35 │ + + it "creates separate consumption records for each invoice from the same inbound" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + tx1 = wallet.wallet_transactions.inbound.first + + subscription = setup_subscription + end + + # First billing period - $25 usage + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 25) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx2 = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).first + + # Second billing period - $35 usage + travel_to(time_0 + 1.month + 5.days) do + ingest_usage(subscription, 35) + end + + travel_to(time_0 + 2.months) do + perform_billing + end + + tx3 = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).order(created_at: :desc).first + + fundings_tx2 = get_fundings(tx2) + expect(fundings_tx2.count).to eq(1) + expect(fundings_tx2.first[:wallet_transaction][:lago_id]).to eq(tx1.id) + expect(fundings_tx2.first[:amount_cents]).to eq(2500) + + fundings_tx3 = get_fundings(tx3) + expect(fundings_tx3.count).to eq(1) + expect(fundings_tx3.first[:wallet_transaction][:lago_id]).to eq(tx1.id) + expect(fundings_tx3.first[:amount_cents]).to eq(3500) + + consumptions = get_consumptions(tx1) + expect(consumptions.count).to eq(2) + + expect(tx1.reload.remaining_amount_cents).to eq(4000) + end + end + + describe "Example 4: Complex Scenario with Priority" do + # Customer has: $20 granted (priority 1), $25 granted (priority 2 older), + # $25 granted (priority 2 newer), $30 granted (priority 2 newest). + # Then invoice consumes $80: + # + # Consumption order: TX1 (prio 1) → TX2 (prio 2, oldest) → TX3 (prio 2, newer) → TX4 (prio 2, newest) + # + # Join table: + # │ TX1 │ TX5 │ $20 │ (granted, priority 1) + # │ TX2 │ TX5 │ $25 │ (granted, priority 2, oldest) + # │ TX3 │ TX5 │ $25 │ (granted, priority 2, newer) + # │ TX4 │ TX5 │ $10 │ (granted, priority 2, newest) + + it "consumes in order: priority first, then FIFO" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + tx2 = nil + tx3 = nil + tx4 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_traceable_wallet + + # TX1: priority 1, high priority (consumed first) + transactions1 = top_up_wallet(wallet, granted_credits: "20") + tx1 = transactions1.find(&:inbound?) + tx1.update!(priority: 1) + + # TX2: priority 2, created "3 days ago" (oldest of prio 2) + transactions2 = top_up_wallet(wallet, granted_credits: "25") + tx2 = transactions2.find(&:inbound?) + tx2.update!(priority: 2, created_at: 3.days.ago) + + # TX3: priority 2, created "1 day ago" (newer than TX2) + transactions3 = top_up_wallet(wallet, granted_credits: "25") + tx3 = transactions3.find(&:inbound?) + tx3.update!(priority: 2, created_at: 1.day.ago) + + # TX4: priority 2, created now (newest of prio 2) + transactions4 = top_up_wallet(wallet, granted_credits: "30") + tx4 = transactions4.find(&:inbound?) + tx4.update!(priority: 2) + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 80) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx5 = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).first + + fundings = get_fundings(tx5) + expect(fundings.count).to eq(4) + + tx1_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx1.id } + tx2_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx2.id } + tx3_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx3.id } + tx4_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx4.id } + + expect(tx1_funding[:amount_cents]).to eq(2000) + expect(tx2_funding[:amount_cents]).to eq(2500) + expect(tx3_funding[:amount_cents]).to eq(2500) + expect(tx4_funding[:amount_cents]).to eq(1000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(0) + expect(tx3.reload.remaining_amount_cents).to eq(0) + expect(tx4.reload.remaining_amount_cents).to eq(2000) + end + end + + describe "Invoice Prepaid Credit Breakdown" do + # When invoice consumes from both granted and purchased transactions, + # the invoice should track the breakdown separately: + # - prepaid_granted_credit_amount_cents: amount from granted transactions + # - prepaid_purchased_credit_amount_cents: amount from purchased transactions + # + # INBOUND OUTBOUND + # ┌─────────────────────────┐ + # │ TX1 (granted) │ ┌─────────────────────────┐ + # │ amount: $30 │────$30─────▶│ TX3 (invoiced) │ + # │ remaining: $0 │ ┌──▶│ amount: $80 │ + # └─────────────────────────┘ │ └─────────────────────────┘ + # ┌─────────────────────────┐ │ + # │ TX2 (purchased) │ │ + # │ amount: $70 │───$50───┘ + # │ remaining: $20 │ + # └─────────────────────────┘ + # + # Invoice breakdown: + # - prepaid_granted_credit_amount_cents: 3000 + # - prepaid_purchased_credit_amount_cents: 5000 + + it "tracks granted and purchased amounts separately on invoice" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + tx2 = nil + subscription = nil + invoice = nil + + travel_to(time_0) do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "30") + tx1 = wallet.wallet_transactions.inbound.where(transaction_status: :granted).first + end + + travel_to(time_0 + 1.hour) do + top_up_wallet(wallet, paid_credits: "70") + tx2 = wallet.wallet_transactions.inbound.where(transaction_status: :purchased).first + + # Mark the credit invoice as paid so the purchased transaction becomes settled + credit_invoice = customer.invoices.credit.sole + update_invoice(credit_invoice, {payment_status: "succeeded"}) + perform_all_enqueued_jobs + + tx2.reload + expect(tx2.status).to eq("settled") + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 80) + end + + travel_to(time_0 + 1.month) do + perform_billing + invoice = subscription.invoices.subscription.first + end + + expect(invoice.prepaid_granted_credit_amount_cents).to eq(3000) + expect(invoice.prepaid_purchased_credit_amount_cents).to eq(5000) + + tx3 = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).first + fundings = get_fundings(tx3) + expect(fundings.count).to eq(2) + + tx1_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx1.id } + tx2_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx2.id } + + expect(tx1_funding[:amount_cents]).to eq(3000) + expect(tx2_funding[:amount_cents]).to eq(5000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(2000) + end + end + end + + describe "Voiding Credit Scenarios" do + describe "Simple voiding" do + # Customer tops up $100, then voids $40: + # + # INBOUND OUTBOUND + # ┌─────────────────────────┐ ┌─────────────────────────┐ + # │ TX1 (granted) │ │ TX2 (voided) │ + # │ amount: $100 │────$40─────▶ │ amount: $40 │ + # │ remaining: $60 │ │ │ + # └─────────────────────────┘ └─────────────────────────┘ + # + # Join table: + # │ TX1 │ TX2 │ $40 │ + + it "creates consumption record when voiding credits" do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + tx1 = wallet.wallet_transactions.inbound.first + + tx2 = void_credits(wallet, 40) + + fundings = get_fundings(tx2) + expect(fundings.count).to eq(1) + expect(fundings.first[:wallet_transaction][:lago_id]).to eq(tx1.id) + expect(fundings.first[:amount_cents]).to eq(4000) + + expect(tx1.reload.remaining_amount_cents).to eq(6000) + end + end + + describe "Voiding spanning multiple inbounds" do + # Customer has two top-ups ($30, $50), then voids $60: + # + # INBOUND OUTBOUND + # ┌─────────────────────────┐ + # │ TX1 (granted) │ ┌─────────────────────────┐ + # │ amount: $30 │────$30─────▶│ TX3 (voided) │ + # │ remaining: $0 │ ┌──▶│ amount: $60 │ + # └─────────────────────────┘ │ └─────────────────────────┘ + # ┌─────────────────────────┐ │ + # │ TX2 (granted) │ │ + # │ amount: $50 │───$30───┘ + # │ remaining: $20 │ + # └─────────────────────────┘ + + it "creates consumption records from multiple inbounds when voiding" do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "30") + tx1 = wallet.wallet_transactions.inbound.first + + top_up_wallet(wallet, granted_credits: "50") + tx2 = wallet.wallet_transactions.inbound.order(created_at: :desc).first + + tx3 = void_credits(wallet, 60) + + fundings = get_fundings(tx3) + expect(fundings.count).to eq(2) + + tx1_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx1.id } + tx2_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx2.id } + + expect(tx1_funding[:amount_cents]).to eq(3000) + expect(tx2_funding[:amount_cents]).to eq(3000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(2000) + end + end + + describe "Multiple voids from same inbound" do + # Customer tops up $100, then voids $25 and $35: + # + # INBOUND OUTBOUND + # ┌─────────────────────────┐ ┌─────────────────────────┐ + # │ TX1 (granted) │────$25──────▶│ TX2 (voided) │ + # │ amount: $100 │ │ amount: $25 │ + # │ remaining: $40 │ └─────────────────────────┘ + # │ │ ┌─────────────────────────┐ + # │ │────$35──────▶│ TX3 (voided) │ + # │ │ │ amount: $35 │ + # └─────────────────────────┘ └─────────────────────────┘ + + it "creates separate consumption records for each void" do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + tx1 = wallet.wallet_transactions.inbound.first + + tx2 = void_credits(wallet, 25) + tx3 = void_credits(wallet, 35) + + fundings_tx2 = get_fundings(tx2) + expect(fundings_tx2.count).to eq(1) + expect(fundings_tx2.first[:amount_cents]).to eq(2500) + + fundings_tx3 = get_fundings(tx3) + expect(fundings_tx3.count).to eq(1) + expect(fundings_tx3.first[:amount_cents]).to eq(3500) + + consumptions = get_consumptions(tx1) + expect(consumptions.count).to eq(2) + + expect(tx1.reload.remaining_amount_cents).to eq(4000) + end + end + + describe "Voiding with priority ordering" do + # Customer has multiple inbounds with different priorities, then voids credits: + # TX1 (priority 1), TX2 (priority 2), TX3 (priority 2) + # Void $50 - should consume TX1 first (priority 1), then TX2/TX3 in FIFO order + + it "respects priority ordering when voiding" do + wallet = create_traceable_wallet + + transactions1 = top_up_wallet(wallet, granted_credits: "20") + tx1 = transactions1.find(&:inbound?) + tx1.update!(priority: 1) + + transactions2 = top_up_wallet(wallet, granted_credits: "25") + tx2 = transactions2.find(&:inbound?) + tx2.update!(priority: 2, created_at: 2.days.ago) + + transactions3 = top_up_wallet(wallet, granted_credits: "25") + tx3 = transactions3.find(&:inbound?) + tx3.update!(priority: 2, created_at: 1.day.ago) + + void_tx = void_credits(wallet, 50) + + fundings = get_fundings(void_tx) + expect(fundings.count).to eq(3) + + tx1_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx1.id } + tx2_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx2.id } + tx3_funding = fundings.find { |f| f[:wallet_transaction][:lago_id] == tx3.id } + + expect(tx1_funding[:amount_cents]).to eq(2000) + expect(tx2_funding[:amount_cents]).to eq(2500) + expect(tx3_funding[:amount_cents]).to eq(500) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(0) + expect(tx3.reload.remaining_amount_cents).to eq(2000) + end + end + end + + describe "Edge cases" do + describe "exact balance consumption" do + it "fully consumes inbound when invoice amount matches exactly" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + tx1 = nil + subscription = nil + + travel_to(time_0) do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "50") + tx1 = wallet.wallet_transactions.inbound.first + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 50) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + tx2 = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).first + + fundings = get_fundings(tx2) + expect(fundings.count).to eq(1) + expect(fundings.first[:amount_cents]).to eq(5000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + end + end + + describe "insufficient balance for voiding" do + it "fails when trying to void more than available balance" do + wallet = create_traceable_wallet + top_up_wallet(wallet, granted_credits: "30") + + expect { + create_wallet_transaction({ + wallet_id: wallet.id, + voided_credits: "50" + }, raise_on_error: false) + }.not_to change(WalletTransactionConsumption, :count) + + expect(json[:status]).to eq(422) + expect(json[:error]).to eq("Unprocessable Entity") + expect(json[:code]).to eq("validation_errors") + end + end + + describe "non-integer wallet rate" do + # With rate_amount: "0.5", 100 credits = 50 EUR (5000 cents) + + it "tracks consumption based on amount_cents using wallet rate" do + wallet = create_traceable_wallet(rate_amount: "0.5") + top_up_wallet(wallet, granted_credits: "100") + tx1 = wallet.wallet_transactions.inbound.first + + # With rate 0.5, 100 credits = 50 EUR (5000 cents) + expect(tx1.remaining_amount_cents).to eq(5000) + + # Void 60 credits = 30 EUR (3000 cents) + tx2 = void_credits(wallet, 60) + # Void 40 credits = 20 EUR (2000 cents) + tx3 = void_credits(wallet, 40) + + fundings_tx2 = get_fundings(tx2) + expect(fundings_tx2.first[:amount_cents]).to eq(3000) + + fundings_tx3 = get_fundings(tx3) + expect(fundings_tx3.first[:amount_cents]).to eq(2000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + end + end + end + + describe "Multiple Wallets Scenarios" do + let(:billable_metric2) { create(:billable_metric, organization:, field_name: "total", aggregation_type: "sum_agg") } + let(:charge2) { create(:charge, plan:, billable_metric: billable_metric2, charge_model: "standard", properties: {"amount" => "1"}) } + + before { charge2 } + + def create_wallet_with_applies_to(applies_to:, granted_credits: "0", traceable: true) + params = { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet", + currency: "EUR", + invoice_requires_successful_payment: false, + applies_to: + } + if traceable + params[:granted_credits] = granted_credits + end + + wallet = create_wallet(params, as: :model) + if !traceable + wallet.update!(traceable: false) + create_wallet_transaction({ + wallet_id: wallet.id, + granted_credits: granted_credits + }) + end + wallet + end + + describe "Invoice consumption spanning multiple wallets" do + # Customer has two wallets: + # - Wallet 1: $30 (applies to billable_metric) + # - Wallet 2: $50 (applies to billable_metric2) + # Invoice consumes $25 from each metric: + # + # WALLET 1 OUTBOUND + # ┌─────────────────────────┐ + # │ TX1 (granted) │ ┌─────────────────────────┐ + # │ amount: $30 │────$25─────▶│ TX3 (invoiced) │ + # │ remaining: $5 │ │ amount: $25 │ + # └─────────────────────────┘ └─────────────────────────┘ + # + # WALLET 2 OUTBOUND + # ┌─────────────────────────┐ + # │ TX2 (granted) │ ┌─────────────────────────┐ + # │ amount: $50 │────$25─────▶│ TX4 (invoiced) │ + # │ remaining: $25 │ │ amount: $25 │ + # └─────────────────────────┘ └─────────────────────────┘ + # + # Invoice breakdown: + # - prepaid_granted_credit_amount_cents: 5000 (from both wallets) + + it "creates consumption records for each wallet and sums prepaid breakdown" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + wallet2 = nil + tx1 = nil + tx2 = nil + subscription = nil + invoice = nil + + travel_to(time_0) do + wallet1 = create_wallet_with_applies_to( + applies_to: {billable_metric_codes: [billable_metric.code]}, + granted_credits: "30" + ) + tx1 = wallet1.wallet_transactions.inbound.first + end + + travel_to(time_0 + 1.hour) do + wallet2 = create_wallet_with_applies_to( + applies_to: {billable_metric_codes: [billable_metric2.code]}, + granted_credits: "50" + ) + tx2 = wallet2.wallet_transactions.inbound.first + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + # Ingest $25 usage for each metric + ingest_usage(subscription, 25) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric2.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric2.field_name => 25} + }) + perform_usage_update + end + + travel_to(time_0 + 1.month) do + perform_billing + invoice = subscription.invoices.subscription.first + end + + # Verify invoice prepaid breakdown sums from both wallets + expect(invoice.prepaid_credit_amount_cents).to eq(5000) + expect(invoice.prepaid_granted_credit_amount_cents).to eq(5000) + + # Verify outbound transactions created for each wallet + tx3 = wallet1.wallet_transactions.outbound.where(transaction_status: :invoiced).first + tx4 = wallet2.wallet_transactions.outbound.where(transaction_status: :invoiced).first + + expect(tx3.amount_cents).to eq(2500) + expect(tx4.amount_cents).to eq(2500) + + # Verify consumption records + fundings_tx3 = get_fundings(tx3) + expect(fundings_tx3.count).to eq(1) + expect(fundings_tx3.first[:amount_cents]).to eq(2500) + expect(fundings_tx3.first[:wallet_transaction][:lago_id]).to eq(tx1.id) + + fundings_tx4 = get_fundings(tx4) + expect(fundings_tx4.count).to eq(1) + expect(fundings_tx4.first[:amount_cents]).to eq(2500) + expect(fundings_tx4.first[:wallet_transaction][:lago_id]).to eq(tx2.id) + + # Verify remaining amounts + expect(tx1.reload.remaining_amount_cents).to eq(500) + expect(tx2.reload.remaining_amount_cents).to eq(2500) + end + end + + describe "Mixed traceable and non-traceable wallets" do + # When one wallet is traceable and another is not, + # prepaid credit breakdown should NOT be set on the invoice. + + it "does not set prepaid breakdown when not all wallets are traceable" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + wallet2 = nil + subscription = nil + invoice = nil + + travel_to(time_0) do + wallet1 = create_wallet_with_applies_to( + applies_to: {billable_metric_codes: [billable_metric.code]}, + granted_credits: "30", + traceable: true + ) + end + + travel_to(time_0 + 1.hour) do + # Create second wallet but do NOT set traceable + wallet2 = create_wallet_with_applies_to( + applies_to: {billable_metric_codes: [billable_metric2.code]}, + granted_credits: "50", + traceable: false + ) + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 25) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric2.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric2.field_name => 25} + }) + perform_usage_update + end + + travel_to(time_0 + 1.month) do + perform_billing + invoice = subscription.invoices.subscription.first + end + + # Prepaid credits were applied + expect(invoice.prepaid_credit_amount_cents).to eq(5000) + + # But breakdown is NOT set because not all wallets are traceable + expect(invoice.prepaid_granted_credit_amount_cents).to be_nil + expect(invoice.prepaid_purchased_credit_amount_cents).to be_nil + + # Consumption records exist only for traceable wallet + tx3 = wallet1.wallet_transactions.outbound.where(transaction_status: :invoiced).first + fundings_tx3 = get_fundings(tx3) + expect(fundings_tx3.count).to eq(1) + + # Non-traceable wallet has no consumption records + tx4 = wallet2.wallet_transactions.outbound.where(transaction_status: :invoiced).first + expect(tx4.fundings).to be_empty + end + end + + describe "Multiple wallets with priority-based consumption" do + # Customer has two unrestricted wallets with different priorities: + # - Wallet 1: $30, priority 1 (higher priority) + # - Wallet 2: $50, priority 2 + # Invoice consumes $60: + # - First: $30 from Wallet 1 (fully consumed) + # - Then: $30 from Wallet 2 + + it "consumes from wallets in priority order" do + time_0 = DateTime.new(2022, 12, 1) + wallet1 = nil + wallet2 = nil + tx1 = nil + tx2 = nil + subscription = nil + invoice = nil + + travel_to(time_0) do + params1 = { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Priority 1 Wallet", + currency: "EUR", + granted_credits: "30", + priority: 1, + invoice_requires_successful_payment: false + } + wallet1 = create_wallet(params1, as: :model) + wallet1.update!(traceable: true) + # Manually set remaining_amount_cents since traceable was set after transaction creation + tx1 = wallet1.wallet_transactions.inbound.first + tx1.update!(remaining_amount_cents: tx1.amount_cents) + end + + travel_to(time_0 + 1.hour) do + params2 = { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Priority 2 Wallet", + currency: "EUR", + granted_credits: "50", + priority: 2, + invoice_requires_successful_payment: false + } + wallet2 = create_wallet(params2, as: :model) + wallet2.update!(traceable: true) + # Manually set remaining_amount_cents since traceable was set after transaction creation + tx2 = wallet2.wallet_transactions.inbound.first + tx2.update!(remaining_amount_cents: tx2.amount_cents) + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 60) + end + + travel_to(time_0 + 1.month) do + perform_billing + invoice = subscription.invoices.subscription.first + end + + expect(invoice.prepaid_credit_amount_cents).to eq(6000) + expect(invoice.prepaid_granted_credit_amount_cents).to eq(6000) + + # Wallet 1 (priority 1) fully consumed + tx3 = wallet1.wallet_transactions.outbound.where(transaction_status: :invoiced).first + expect(tx3.amount_cents).to eq(3000) + + fundings_tx3 = get_fundings(tx3) + expect(fundings_tx3.count).to eq(1) + expect(fundings_tx3.first[:amount_cents]).to eq(3000) + + # Wallet 2 (priority 2) partially consumed + tx4 = wallet2.wallet_transactions.outbound.where(transaction_status: :invoiced).first + expect(tx4.amount_cents).to eq(3000) + + fundings_tx4 = get_fundings(tx4) + expect(fundings_tx4.count).to eq(1) + expect(fundings_tx4.first[:amount_cents]).to eq(3000) + + expect(tx1.reload.remaining_amount_cents).to eq(0) + expect(tx2.reload.remaining_amount_cents).to eq(2000) + end + end + end + + describe "Non-traceable wallet scenarios (self-hosted pre-migration)" do + # These tests ensure non-traceable wallets still work correctly for + # self-hosted instances that have not yet run the traceability migration. + + describe "Invoice consumption with non-traceable wallet" do + it "deducts from wallet without creating consumption records or prepaid breakdown" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + subscription = nil + invoice = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + subscription = setup_subscription + end + + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 40) + end + + travel_to(time_0 + 1.month) do + perform_billing + invoice = subscription.invoices.subscription.first + end + + expect(invoice.prepaid_credit_amount_cents).to eq(4000) + expect(invoice.prepaid_granted_credit_amount_cents).to be_nil + expect(invoice.prepaid_purchased_credit_amount_cents).to be_nil + + tx = wallet.wallet_transactions.outbound.where(transaction_status: :invoiced).first + expect(tx).to be_present + expect(tx.amount_cents).to eq(4000) + expect(tx.fundings).to be_empty + + expect(wallet.reload.balance_cents).to eq(6000) + end + end + + describe "Voiding credits with non-traceable wallet" do + it "voids credits without creating consumption records" do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + create_wallet_transaction({ + wallet_id: wallet.id, + voided_credits: "40" + }) + + tx = wallet.wallet_transactions.outbound.where(transaction_status: :voided).first + expect(tx).to be_present + expect(tx.amount_cents).to eq(4000) + expect(tx.fundings).to be_empty + + expect(wallet.reload.balance_cents).to eq(6000) + end + end + + describe "Multiple billing periods with non-traceable wallet" do + it "deducts across billing periods without consumption tracking" do + time_0 = DateTime.new(2022, 12, 1) + wallet = nil + subscription = nil + + travel_to(time_0) do + wallet = create_non_traceable_wallet + top_up_wallet(wallet, granted_credits: "100") + + subscription = setup_subscription + end + + # First billing period - $25 usage + travel_to(time_0 + 5.days) do + ingest_usage(subscription, 25) + end + + travel_to(time_0 + 1.month) do + perform_billing + end + + invoice1 = subscription.invoices.subscription.first + expect(invoice1.prepaid_credit_amount_cents).to eq(2500) + expect(invoice1.prepaid_granted_credit_amount_cents).to be_nil + + # Second billing period - $35 usage + travel_to(time_0 + 1.month + 5.days) do + ingest_usage(subscription, 35) + end + + travel_to(time_0 + 2.months) do + perform_billing + end + + invoice2 = subscription.invoices.subscription.order(created_at: :desc).first + expect(invoice2.prepaid_credit_amount_cents).to eq(3500) + expect(invoice2.prepaid_granted_credit_amount_cents).to be_nil + + expect(wallet.reload.balance_cents).to eq(4000) + expect(WalletTransactionConsumption.where(organization_id: organization.id).count).to eq(0) + end + end + end +end diff --git a/spec/serializers/e_invoices/credit_notes/factur_x/builder_spec.rb b/spec/serializers/e_invoices/credit_notes/factur_x/builder_spec.rb new file mode 100644 index 0000000..2123417 --- /dev/null +++ b/spec/serializers/e_invoices/credit_notes/factur_x/builder_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::CreditNotes::FacturX::Builder do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, credit_note:) + end + end + + let(:credit_note) { create(:credit_note, total_amount_currency: "EUR", credit_amount: 1) } + let(:credit_note_item1) { create(:credit_note_item, credit_note:, fee:, precise_amount_cents: 1000) } + let(:credit_note_item2) { create(:credit_note_item, credit_note:, fee: fee2, precise_amount_cents: 2500) } + let(:fee) { create(:fee, units: 5, amount: 10, precise_unit_amount: 2) } + let(:fee2) { create(:fee, units: 1, amount: 25, precise_unit_amount: 25) } + + before do + credit_note_item1 + credit_note_item2 + + credit_note.reload + end + + describe ".serialize" do + context "when CrossIndustryInvoice tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//rsm:CrossIndustryInvoice") + end + end + + context "when ExchangedDocument tag" do + let(:root) { "//rsm:CrossIndustryInvoice/rsm:ExchangedDocument" } + + it "contains the tag" do + expect(subject).to contains_xml_node(root) + end + + context "with credit note info" do + context "when ID" do + it "contains the info" do + expect(subject).to contains_xml_node("#{root}/ram:ID") + .with_value(credit_note.number) + end + end + + context "when TypeCode" do + it "contains the info" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode") + .with_value(described_class::CREDIT_NOTE) + end + end + + context "when IssueDateTime" do + it "contains the info" do + expect(subject).to contains_xml_node("#{root}/ram:IssueDateTime/udt:DateTimeString") + .with_value(credit_note.issuing_date.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when IncludedNote" do + it "contains the notes" do + expect(subject.xpath("#{root}/ram:IncludedNote/ram:Content").length).to eq(3) + end + end + end + end + + context "when SupplyChainTradeTransaction tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//rsm:CrossIndustryInvoice/rsm:SupplyChainTradeTransaction") + end + end + + context "when IncludedSupplyChainTradeLineItem tags" do + it "has all fees" do + expect( + subject.xpath( + "//rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem" + ).length + ).to eq(credit_note.fees.count) + end + + context "with negative values" do + context "with BilledQuantity" do + it "is negative" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:BilledQuantity" + ).with_value(-fee.units) + end + end + + context "with LineTotalAmount" do + it "is negative" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:LineTotalAmount" + ).with_value(-fee.amount) + end + end + end + end + + context "when ApplicableHeaderTradeAgreement tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeAgreement") + end + + it "contains SpecifiedTaxRegistration tag by default" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeAgreement//ram:SpecifiedTaxRegistration/ram:ID") + .with_value(credit_note.billing_entity.tax_identification_number) + .with_attribute("schemeID", "VA") + end + end + + context "when ApplicableHeaderTradeDelivery tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeDelivery") + end + + it "contains OccurrenceDateTime" do + expect(subject).to contains_xml_node("//ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString") + .with_value(credit_note.created_at.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when ApplicableHeaderTradeSettlement tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeSettlement") + end + + it "contains InvoiceCurrencyCode" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeSettlement/ram:InvoiceCurrencyCode") + .with_value(credit_note.currency) + end + end + + context "when SpecifiedTradeSettlementPaymentMeans tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradeSettlementPaymentMeans") + end + + it "contains TypeCode" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradeSettlementPaymentMeans//ram:TypeCode") + .with_value(described_class::STANDARD_PAYMENT) + end + + it "contains Information" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradeSettlementPaymentMeans//ram:Information") + .with_value(I18n.t("invoice.e_invoicing.standard_payment")) + end + end + + context "when ApplicableTradeTax tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:ApplicableTradeTax" } + + let(:invoice) { create(:invoice) } + let(:credit_note) { create(:credit_note, invoice:) } + let(:credit_note_item0) { create(:credit_note_item, credit_note:, fee: fee0, precise_amount_cents: 500) } + let(:credit_note_item1) { create(:credit_note_item, credit_note:, fee: fee1, precise_amount_cents: 500) } + let(:credit_note_item2) { create(:credit_note_item, credit_note:, fee: fee2, precise_amount_cents: 100) } + let(:credit_note_item3) { create(:credit_note_item, credit_note:, fee: fee3, precise_amount_cents: 300) } + let(:credit_note_item4) { create(:credit_note_item, credit_note:, fee: fee4, precise_amount_cents: 600) } + let(:fee0) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 500, taxes_precise_amount_cents: 0) } + let(:fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 500, taxes_precise_amount_cents: 0) } + let(:fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 100, taxes_precise_amount_cents: 5) } + let(:fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 300, taxes_precise_amount_cents: 15) } + let(:fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_amount_cents: 600, taxes_precise_amount_cents: 60) } + let(:credit_note_applied_tax1) { create(:credit_note_applied_tax, credit_note:, tax_rate: 5.0, amount_cents: 20, base_amount_cents: 400) } + let(:credit_note_applied_tax2) { create(:credit_note_applied_tax, credit_note:, tax_rate: 10.0, amount_cents: 60, base_amount_cents: 600) } + + before do + credit_note_item0 + credit_note_item1 + credit_note_item2 + credit_note_item3 + credit_note_item4 + credit_note_applied_tax1 + credit_note_applied_tax2 + end + + it "contains ApplicableTradeTax tags" do + expect(subject.xpath(root).length).to eq(3) + end + + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/ram:CalculatedAmount").with_value("0.00") + expect(subject).to contains_xml_node("#{root}[1]/ram:BasisAmount").with_value("-10.00") + expect(subject).to contains_xml_node("#{root}[1]/ram:CategoryCode").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/ram:RateApplicablePercent").with_value("0.00") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/ram:CalculatedAmount").with_value("-0.20") + expect(subject).to contains_xml_node("#{root}[2]/ram:BasisAmount").with_value("-4.00") + expect(subject).to contains_xml_node("#{root}[2]/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/ram:RateApplicablePercent").with_value("5.00") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/ram:CalculatedAmount").with_value("-0.60") + expect(subject).to contains_xml_node("#{root}[3]/ram:BasisAmount").with_value("-6.00") + expect(subject).to contains_xml_node("#{root}[3]/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/ram:RateApplicablePercent").with_value("10.00") + end + end + end + + context "when SpecifiedTradeAllowanceCharge tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:SpecifiedTradeAllowanceCharge" } + + let(:invoice) { create(:invoice, coupons_amount_cents: 100) } + let(:credit_note) { create(:credit_note, invoice:, precise_coupons_adjustment_amount_cents: 100) } + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_coupons_amount_cents: 100, precise_amount_cents: 2000, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_coupons_amount_cents: 10, precise_amount_cents: 100, taxes_precise_amount_cents: 4.75) } + let(:invoice_fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_coupons_amount_cents: 10, precise_amount_cents: 300, taxes_precise_amount_cents: 14.25) } + let(:invoice_fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_coupons_amount_cents: 30, precise_amount_cents: 600, taxes_precise_amount_cents: 57) } + let(:credit_note_item1) { create(:credit_note_item, credit_note:, fee: invoice_fee1, precise_amount_cents: 1000) } + let(:credit_note_item2) { create(:credit_note_item, credit_note:, fee: invoice_fee2, precise_amount_cents: 100) } + let(:credit_note_item3) { create(:credit_note_item, credit_note:, fee: invoice_fee3, precise_amount_cents: 300) } + let(:credit_note_item4) { create(:credit_note_item, credit_note:, fee: invoice_fee4, precise_amount_cents: 600) } + + before do + credit_note_item1 + credit_note_item2 + credit_note_item3 + credit_note_item4 + end + + it "contains SpecifiedTradeAllowanceCharge tags" do + expect(subject.xpath(root).length).to eq(3) + end + + # For credit_note, allowances are turned into charges + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/ram:ChargeIndicator/udt:Indicator").with_value(described_class::INVOICE_CHARGE) + expect(subject).to contains_xml_node("#{root}[1]/ram:ActualAmount").with_value("0.50") + expect(subject).to contains_xml_node("#{root}[1]/ram:Reason") + expect(subject).to contains_xml_node("#{root}[1]/ram:CategoryTradeTax/ram:CategoryCode").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/ram:CategoryTradeTax/ram:RateApplicablePercent").with_value("0.00") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/ram:ChargeIndicator/udt:Indicator").with_value(described_class::INVOICE_CHARGE) + expect(subject).to contains_xml_node("#{root}[2]/ram:ActualAmount").with_value("0.20") + expect(subject).to contains_xml_node("#{root}[2]/ram:Reason") + expect(subject).to contains_xml_node("#{root}[2]/ram:CategoryTradeTax/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/ram:CategoryTradeTax/ram:RateApplicablePercent").with_value("5.00") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/ram:ChargeIndicator/udt:Indicator").with_value(described_class::INVOICE_CHARGE) + expect(subject).to contains_xml_node("#{root}[3]/ram:ActualAmount").with_value("0.30") + expect(subject).to contains_xml_node("#{root}[3]/ram:Reason") + expect(subject).to contains_xml_node("#{root}[3]/ram:CategoryTradeTax/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/ram:CategoryTradeTax/ram:RateApplicablePercent").with_value("10.00") + end + end + end + + context "when SpecifiedTradePaymentTerms tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms") + end + + it "contains Description tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms/ram:Description") + .with_value("Credit note - immediate settlement") + end + + it "contains DueDateDateTime tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms//udt:DateTimeString") + .with_value(credit_note.created_at.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when SpecifiedTradeSettlementHeaderMonetarySummation tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:SpecifiedTradeSettlementHeaderMonetarySummation" } + + let(:invoice) { create(:invoice, coupons_amount_cents: 100) } + let(:credit_note) { create(:credit_note, invoice:, precise_coupons_adjustment_amount_cents: 100, taxes_amount: 10, total_amount: 20, credit_amount: 10) } + + it "contains the tag" do + expect(subject).to contains_xml_node(root) + end + + it "contains LineTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:LineTotalAmount").with_value("-35.00") + end + + it "contains ChargeTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:ChargeTotalAmount").with_value("1.00") + end + + it "contains AllowanceTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:AllowanceTotalAmount").with_value("0.00") + end + + it "contains TaxBasisTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:TaxBasisTotalAmount").with_value("-34.00") + end + + it "contains TaxTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:TaxTotalAmount").with_value("-10.00") + end + + it "contains GrandTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:GrandTotalAmount").with_value("-20.00") + end + + it "contains DuePayableAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:DuePayableAmount").with_value("-10.00") + end + end + end +end diff --git a/spec/serializers/e_invoices/credit_notes/ubl/builder_spec.rb b/spec/serializers/e_invoices/credit_notes/ubl/builder_spec.rb new file mode 100644 index 0000000..e2e8055 --- /dev/null +++ b/spec/serializers/e_invoices/credit_notes/ubl/builder_spec.rb @@ -0,0 +1,362 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::CreditNotes::Ubl::Builder do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, credit_note:) + end + end + + let(:credit_note) { create(:credit_note, invoice:, total_amount_currency: "EUR", credit_amount: 1) } + let(:invoice) { create(:invoice, number: "LAGO-TEST-123") } + + before do + credit_note.reload + end + + describe ".serialize" do + context "when CreditNote tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//*[local-name()='CreditNote']") + end + + it "contains UBLVersionID tag" do + expect(subject).to contains_xml_node("//cbc:UBLVersionID").with_value(2.1) + end + + it "contains CustomizationID tag" do + expect(subject).to contains_xml_node("//cbc:CustomizationID").with_value("urn:cen.eu:en16931:2017") + end + + it "contains header tags" do + expect(subject).to contains_xml_node("//cbc:ID").with_value(credit_note.number) + expect(subject).to contains_xml_node("//cbc:IssueDate").with_value(credit_note.issuing_date) + expect(subject).to contains_xml_node("//cbc:CreditNoteTypeCode").with_value(described_class::CREDIT_NOTE) + expect(subject).to contains_xml_node("//cbc:DocumentCurrencyCode").with_value(credit_note.currency) + end + + context "when Note tags" do + let(:path) { "//*[local-name()='CreditNote']/cbc:Note" } + + it "contains multiple notes" do + expect(subject.xpath(path).length).to eq(3) + end + + context "with messages" do + it "contains credit note id" do + expect(subject).to contains_xml_node("#{path}[1]").with_value("Credit Note ID: #{credit_note.id}") + end + + it "contains original invoice id" do + expect(subject).to contains_xml_node("#{path}[2]").with_value("Original Invoice: #{credit_note.invoice.number}") + end + + it "contains reason" do + expect(subject).to contains_xml_node("#{path}[3]").with_value("Reason: #{credit_note.reason}") + end + end + end + end + + context "when BillingReference tag" do + it "contains the tags" do + expect(subject).to contains_xml_node("//cac:BillingReference") + expect(subject).to contains_xml_node("//cac:BillingReference/cac:InvoiceDocumentReference") + end + + it "contains invoice information" do + expect(subject).to contains_xml_node("//cac:InvoiceDocumentReference/cbc:ID").with_value(credit_note.invoice.number) + expect(subject).to contains_xml_node("//cac:InvoiceDocumentReference/cbc:IssueDate").with_value(credit_note.invoice.issuing_date) + end + end + + context "when AccountingSupplierParty tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:AccountingSupplierParty") + end + + it "contains the tax registration" do + expect(subject).to contains_xml_node("//cac:AccountingSupplierParty//cac:PartyTaxScheme") + end + + context "when credit invoice" do + let(:invoice) { create(:invoice, invoice_type: :credit, number: "LAGO-TEST-123") } + + it "does not contains PartyTaxScheme tag" do + expect(subject).not_to contains_xml_node("//cac:AccountingSupplierParty//cac:PartyTaxScheme") + end + end + end + + context "when AccountingCustomerParty tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:AccountingCustomerParty") + end + end + + context "when PaymentMeans tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:PaymentMeans/cbc:PaymentMeansCode") + .with_value(described_class::STANDARD_PAYMENT) + end + end + + context "when PaymentTerms tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:PaymentTerms/cbc:Note") + .with_value("Credit note - immediate settlement") + end + end + + context "when AllowanceCharge tags" do + let(:root) { "//cac:AllowanceCharge" } + + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_coupons_amount_cents: 100, precise_amount_cents: 2000, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_coupons_amount_cents: 10, precise_amount_cents: 100, taxes_precise_amount_cents: 4.75) } + let(:invoice_fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_coupons_amount_cents: 10, precise_amount_cents: 300, taxes_precise_amount_cents: 14.25) } + let(:invoice_fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_coupons_amount_cents: 30, precise_amount_cents: 600, taxes_precise_amount_cents: 57) } + let(:credit_note_item1) { create(:credit_note_item, credit_note:, fee: invoice_fee1, precise_amount_cents: 1000) } + let(:credit_note_item2) { create(:credit_note_item, credit_note:, fee: invoice_fee2, precise_amount_cents: 100) } + let(:credit_note_item3) { create(:credit_note_item, credit_note:, fee: invoice_fee3, precise_amount_cents: 300) } + let(:credit_note_item4) { create(:credit_note_item, credit_note:, fee: invoice_fee4, precise_amount_cents: 600) } + let(:invoice) { create(:invoice, coupons_amount_cents: 100) } + let(:credit_note) { create(:credit_note, invoice:, precise_coupons_adjustment_amount_cents: 100) } + + before do + credit_note_item1 + credit_note_item2 + credit_note_item3 + credit_note_item4 + end + + it "contains all charges" do + expect(subject.xpath(root).length).to eq(3) + end + + # For credit_note, allowances are turned into charges + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/cbc:ChargeIndicator").with_value(described_class::INVOICE_CHARGE) + expect(subject).to contains_xml_node("#{root}[1]/cbc:AllowanceChargeReason") + expect(subject).to contains_xml_node("#{root}[1]/cbc:Amount").with_value("0.50").with_attribute("currencyID", "EUR") + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:ID").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:Percent").with_value("0.00") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/cbc:ChargeIndicator").with_value(described_class::INVOICE_CHARGE) + expect(subject).to contains_xml_node("#{root}[2]/cbc:AllowanceChargeReason") + expect(subject).to contains_xml_node("#{root}[2]/cbc:Amount").with_value("0.20") + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:Percent").with_value("5.00") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/cbc:ChargeIndicator").with_value(described_class::INVOICE_CHARGE) + expect(subject).to contains_xml_node("#{root}[3]/cbc:AllowanceChargeReason") + expect(subject).to contains_xml_node("#{root}[3]/cbc:Amount").with_value("0.30") + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:Percent").with_value("10.00") + end + end + end + + context "when TaxTotal tag" do + before do + credit_note.update(precise_taxes_amount_cents: 1000) + end + + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:TaxTotal") + end + + it "contains TaxAmount tag" do + expect(subject).to contains_xml_node("//cac:TaxTotal/cbc:TaxAmount") + .with_value("-10.00") + .with_attribute("currencyID", credit_note.currency) + end + end + + context "when TaxSubtotal tag" do + let(:root) { "//cac:TaxTotal/cac:TaxSubtotal" } + + context "with multiple taxes" do + let(:invoice) { create(:invoice) } + let(:credit_note) { create(:credit_note, invoice:) } + let(:credit_note_item0) { create(:credit_note_item, credit_note:, fee: fee0, precise_amount_cents: 500) } + let(:credit_note_item1) { create(:credit_note_item, credit_note:, fee: fee1, precise_amount_cents: 500) } + let(:credit_note_item2) { create(:credit_note_item, credit_note:, fee: fee2, precise_amount_cents: 100) } + let(:credit_note_item3) { create(:credit_note_item, credit_note:, fee: fee3, precise_amount_cents: 300) } + let(:credit_note_item4) { create(:credit_note_item, credit_note:, fee: fee4, precise_amount_cents: 600) } + let(:fee0) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 500, taxes_precise_amount_cents: 0) } + let(:fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 500, taxes_precise_amount_cents: 0) } + let(:fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 100, taxes_precise_amount_cents: 5) } + let(:fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 300, taxes_precise_amount_cents: 15) } + let(:fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_amount_cents: 600, taxes_precise_amount_cents: 60) } + let(:credit_note_applied_tax1) { create(:credit_note_applied_tax, credit_note:, tax_rate: 5.0, amount_cents: 20, base_amount_cents: 400) } + let(:credit_note_applied_tax2) { create(:credit_note_applied_tax, credit_note:, tax_rate: 10.0, amount_cents: 60, base_amount_cents: 600) } + + before do + credit_note_item0 + credit_note_item1 + credit_note_item2 + credit_note_item3 + credit_note_item4 + credit_note_applied_tax1 + credit_note_applied_tax2 + end + + it "contains TaxSubtotal tags" do + expect(subject.xpath(root).length).to eq(3) + end + + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/cbc:TaxableAmount").with_value("-10.00") + expect(subject).to contains_xml_node("#{root}[1]/cbc:TaxAmount").with_value("0.00") + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:ID").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:Percent").with_value("0.00") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/cbc:TaxableAmount").with_value("-4.00") + expect(subject).to contains_xml_node("#{root}[2]/cbc:TaxAmount").with_value("-0.20") + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:Percent").with_value("5.00") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/cbc:TaxableAmount").with_value("-6.00") + expect(subject).to contains_xml_node("#{root}[3]/cbc:TaxAmount").with_value("-0.60") + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:Percent").with_value("10.00") + end + end + end + + context "when credit invoice" do + let(:invoice) { create(:invoice, invoice_type: :credit) } + let(:credit_note) { create(:credit_note, invoice:) } + let(:fee0) { create(:fee, invoice:, fee_type: :credit, taxes_rate: 0.0, precise_amount_cents: 500, taxes_precise_amount_cents: 0) } + let(:credit_note_item0) { create(:credit_note_item, credit_note:, fee: fee0, precise_amount_cents: 500) } + + before do + credit_note_item0 + end + + it "contains TaxSubtotal tags" do + expect(subject.xpath(root).length).to eq(1) + end + + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/cbc:TaxableAmount").with_value("-5.00") + expect(subject).to contains_xml_node("#{root}[1]/cbc:TaxAmount").with_value("0.00") + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:ID").with_value(described_class::O_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:TaxExemptionReasonCode").with_value(described_class::O_VAT_EXEMPTION) + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:TaxExemptionReason").with_value("Not subject to VAT") + expect(subject).not_to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:Percent") + end + end + end + + context "when LegalMonetaryTotal tag" do + before do + create(:credit_note_item, credit_note:, precise_amount_cents: 500) + create(:credit_note_item, credit_note:, precise_amount_cents: 500) + credit_note.update(precise_coupons_adjustment_amount_cents: 100, precise_taxes_amount_cents: 90) + + credit_note.reload + end + + context "when LineExtensionAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:LineExtensionAmount") + .with_value("-10.00") + .with_attribute("currencyID", credit_note.currency) + end + end + + context "when TaxExclusiveAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount") + .with_value("-9.00") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when TaxInclusiveAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount") + .with_value("-9.90") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when AllowanceTotalAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount") + .with_value("0.00") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when ChargeTotalAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:ChargeTotalAmount") + .with_value("1.00") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when PrepaidAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:PrepaidAmount") + .with_value("0.00") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when PayableAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:PayableAmount") + .with_value("-9.90") + .with_attribute("currencyID", invoice.currency) + end + end + end + + context "when CreditNoteLine tags" do + let(:root) { "//cac:CreditNoteLine" } + + let(:credit_note_item1) { create(:credit_note_item, credit_note:, precise_amount_cents: 100, amount: 10, fee: item_fee1) } + let(:credit_note_item2) { create(:credit_note_item, credit_note:, precise_amount_cents: 250, amount: 25, fee: item_fee2) } + let(:item_fee1) { create(:fee, units: 5, precise_amount_cents: 100, precise_unit_amount: 2, amount_currency: "EUR") } + let(:item_fee2) { create(:fee, units: 1, precise_amount_cents: 250, precise_unit_amount: 25, amount_currency: "EUR") } + + before do + credit_note_item1 + credit_note_item2 + end + + it "contains fee tags" do + expect(subject.xpath(root).length).to eq(2) + end + + context "with one tag per fee" do + it "contains the first fee info" do + expect(subject).to contains_xml_node("#{root}[1]/cbc:ID").with_value("1") + expect(subject).to contains_xml_node("#{root}[1]/cbc:CreditedQuantity").with_value("-5.00").with_attribute("unitCode", described_class::UNIT_CODE) + expect(subject).to contains_xml_node("#{root}[1]/cbc:LineExtensionAmount").with_value("-10.00").with_attribute("currencyID", "EUR") + end + + it "contains the second fee info" do + expect(subject).to contains_xml_node("#{root}[2]/cbc:ID").with_value("2") + expect(subject).to contains_xml_node("#{root}[2]/cbc:CreditedQuantity").with_value("-1.00").with_attribute("unitCode", described_class::UNIT_CODE) + expect(subject).to contains_xml_node("#{root}[2]/cbc:LineExtensionAmount").with_value("-25.00").with_attribute("currencyID", "EUR") + end + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/applicable_trade_tax_spec.rb b/spec/serializers/e_invoices/factur_x/applicable_trade_tax_spec.rb new file mode 100644 index 0000000..b9538fa --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/applicable_trade_tax_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::ApplicableTradeTax do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, tax_category:, tax_rate:, basis_amount:, tax_amount:) + end + end + + let(:tax_category) { described_class::S_CATEGORY } + let(:tax_rate) { 20.00 } + let(:basis_amount) { 10 } + let(:tax_amount) { basis_amount * (tax_rate / 100) } + + let(:root) { "//ram:ApplicableTradeTax" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Tax Information 20.00% VAT") + end + + it "have the tax calculated amount" do + expect(subject).to contains_xml_node("#{root}/ram:CalculatedAmount").with_value("2.00") + end + + it "has the tax type code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value("VAT") + end + + it "has the tax basis amount" do + expect(subject).to contains_xml_node("#{root}/ram:BasisAmount").with_value("10.00") + end + + it "has the category code" do + expect(subject).to contains_xml_node("#{root}/ram:CategoryCode").with_value(described_class::S_CATEGORY) + end + + it "has the rate applicable percent" do + expect(subject).to contains_xml_node("#{root}/ram:RateApplicablePercent").with_value("20.00") + end + + context "when category code is O" do + let(:tax_category) { described_class::O_CATEGORY } + + it "has ExemptionReasonCode" do + expect(subject).to contains_xml_node("#{root}/ram:ExemptionReasonCode").with_value(described_class::O_VAT_EXEMPTION) + end + + it "does not has rate applicable percent" do + expect(subject).not_to contains_xml_node("#{root}/ram:RateApplicablePercent") + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/cross_industry_invoice_spec.rb b/spec/serializers/e_invoices/factur_x/cross_industry_invoice_spec.rb new file mode 100644 index 0000000..50d511e --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/cross_industry_invoice_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::CrossIndustryInvoice do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:) do + end + end + end + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Exchange Document Context") + end + + context "with ExchangedDocumentContext tag" do + it "have the document schema version number" do + expect(subject).to contains_xml_node( + "//rsm:ExchangedDocumentContext/ram:GuidelineSpecifiedDocumentContextParameter/ram:ID" + ).with_value("urn:cen.eu:en16931:2017") + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/header_spec.rb b/spec/serializers/e_invoices/factur_x/header_spec.rb new file mode 100644 index 0000000..b7b3815 --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/header_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::Header do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, resource:, type_code:, notes:) + end + end + + let(:invoice) { create(:invoice, issuing_date: issuing_date.to_date) } + let(:resource) { invoice } + let(:type_code) { described_class::COMMERCIAL_INVOICE } + let(:notes) { ["Invoice ID: #{invoice.id}", "Allow multiple notes"] } + let(:issuing_date) { "20250316" } + + let(:root) { "//rsm:ExchangedDocument" } + + before { invoice } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Exchange Document Header") + end + + context "when invoice" do + it "expects to have the invoice number" do + expect(subject).to contains_xml_node("#{root}/ram:ID").with_value(invoice.number) + end + + it "expects to have a type code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value(described_class::COMMERCIAL_INVOICE) + end + + it "expects to have invoice issuing date" do + expect(subject).to contains_xml_node("#{root}/ram:IssueDateTime/udt:DateTimeString") + .with_value(issuing_date) + .with_attribute("format", described_class::CCYYMMDD) + end + + context "with notes" do + it "expects to have first included note" do + expect(subject).to contains_xml_node("#{root}/ram:IncludedNote[1]/ram:Content") + .with_value("Invoice ID: #{invoice.id}") + end + + it "expects to have last included notes" do + expect(subject).to contains_xml_node("#{root}/ram:IncludedNote[2]/ram:Content") + .with_value("Allow multiple notes") + end + end + end + + context "when credit note" do + let(:credit_note) { create(:credit_note, invoice:, issuing_date: issuing_date.to_date) } + let(:issuing_date) { "20250317" } + let(:resource) { credit_note } + let(:type_code) { described_class::CREDIT_NOTE } + + it "expects to have the credit note number" do + expect(subject).to contains_xml_node("#{root}/ram:ID").with_value(credit_note.number) + end + + it "expects to have a type code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value(described_class::CREDIT_NOTE) + end + + it "expects to have invoice issuing date" do + expect(subject).to contains_xml_node("#{root}/ram:IssueDateTime/udt:DateTimeString") + .with_value(issuing_date) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when payment" do + let(:payment) { create(:payment, payable: invoice) } + let(:payment_receipt) { create(:payment_receipt, payment:, created_at:) } + let(:created_at) { "20251022" } + let(:resource) { payment_receipt } + let(:type_code) { described_class::PAYMENT_RECEIPT } + + it "expects to have the payment receipt number" do + expect(subject).to contains_xml_node("#{root}/ram:ID").with_value(payment_receipt.number) + end + + it "expects to have a type code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value(described_class::PAYMENT_RECEIPT) + end + + it "expects to have invoice issuing date" do + expect(subject).to contains_xml_node("#{root}/ram:IssueDateTime/udt:DateTimeString") + .with_value(created_at) + .with_attribute("format", described_class::CCYYMMDD) + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/invoice_reference_spec.rb b/spec/serializers/e_invoices/factur_x/invoice_reference_spec.rb new file mode 100644 index 0000000..9a52494 --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/invoice_reference_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::InvoiceReference do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, invoice_reference:) do + end + end + end + + let(:invoice_reference) { "TES-ABCD-202510-001" } + + let(:root) { "//ram:InvoiceReferencedDocument" } + + describe ".call" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Invoice reference") + end + + it "have Description" do + expect(subject).to contains_xml_node("#{root}/ram:IssuerAssignedID") + .with_value(invoice_reference) + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/line_item_spec.rb b/spec/serializers/e_invoices/factur_x/line_item_spec.rb new file mode 100644 index 0000000..aaa25d4 --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/line_item_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::LineItem do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, resource:, data:) + end + end + + let(:resource) { create(:invoice) } + let(:data) do + described_class::Data.new( + line_id:, + name: fee.item_name, + description: fee.invoice_name, + charge_amount: fee.precise_unit_amount, + billed_quantity: fee.units, + category_code: described_class::S_CATEGORY, + rate_percent:, + line_total_amount: fee.amount + ) + end + let(:fee) { create(:fee, precise_unit_amount: 0.059, taxes_rate:, fee_type:) } + let(:taxes_rate) { 20.00 } + let(:rate_percent) { taxes_rate } + let(:fee_type) { :subscription } + let(:line_id) { 1 } + + let(:root) { "//ram:IncludedSupplyChainTradeLineItem" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Line Item #{line_id}: #{fee.invoice_name}") + end + + it "have the line id" do + expect(subject).to contains_xml_node("#{root}/ram:AssociatedDocumentLineDocument/ram:LineID") + .with_value(line_id) + end + + it "have the item name" do + expect(subject).to contains_xml_node("#{root}/ram:SpecifiedTradeProduct/ram:Name").with_value(fee.item_name) + end + + context "when Description tag" do + it "uses description data field" do + expect(subject).to contains_xml_node("#{root}/ram:SpecifiedTradeProduct/ram:Description").with_value(fee.invoice_name) + end + end + + it "have the item unit amount" do + expect(subject).to contains_xml_node( + "#{root}/ram:SpecifiedLineTradeAgreement/ram:NetPriceProductTradePrice/ram:ChargeAmount" + ).with_value("0.059") + end + + context "with BilledQuantity" do + let(:xpath) { "#{root}/ram:SpecifiedLineTradeDelivery/ram:BilledQuantity" } + + it "have the item units" do + expect(subject).to contains_xml_node(xpath) + .with_value(fee.units) + .with_attribute("unitCode", "C62") + end + end + + context "with CategoryCode" do + let(:xpath) { "#{root}/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:CategoryCode" } + + context "when taxes are not zero" do + it "has the S category code" do + expect(subject).to contains_xml_node(xpath).with_value("S") + end + end + end + + context "when RateApplicablePercent" do + it "have the item taxes rate" do + expect(subject).to contains_xml_node( + "#{root}/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent" + ).with_value(fee.taxes_rate) + end + + context "when rate_percent is not available" do + let(:rate_percent) { nil } + + it "doesnt have the tag" do + expect(subject).not_to contains_xml_node( + "#{root}/ram:SpecifiedLineTradeSettlement/ram:ApplicableTradeTax/ram:RateApplicablePercent" + ) + end + end + end + + it "have the item total amount" do + expect(subject).to contains_xml_node( + "#{root}/ram:SpecifiedLineTradeSettlement/ram:SpecifiedTradeSettlementLineMonetarySummation/ram:LineTotalAmount" + ).with_value(fee.amount) + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/monetary_summation_spec.rb b/spec/serializers/e_invoices/factur_x/monetary_summation_spec.rb new file mode 100644 index 0000000..6ea0d4d --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/monetary_summation_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::MonetarySummation do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, resource:, amounts:) do + end + end + end + + let(:resource) { create(:invoice, currency: "USD") } + let(:amounts) do + described_class::Amounts.new( + line_total_amount: Money.new(100000), + charges_amount: Money.new(1000), + allowances_amount: Money.new(1000), + tax_basis_amount: Money.new(99000), + tax_amount: Money.new(19884), + grand_total_amount: Money.new(118884), + prepaid_amount: Money.new(2186), + due_payable_amount: Money.new(118884) + ) + end + + let(:root) { "//ram:SpecifiedTradeSettlementHeaderMonetarySummation" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Monetary Summation") + end + + it "have LineTotalAmount" do + expect(subject).to contains_xml_node("#{root}/ram:LineTotalAmount") + .with_value("1000.00") + end + + it "have ChargeTotalAmount and AllowanceTotalAmount" do + expect(subject).to contains_xml_node("#{root}/ram:ChargeTotalAmount") + .with_value("10.00") + expect(subject).to contains_xml_node("#{root}/ram:AllowanceTotalAmount") + .with_value("10.00") + end + + it "have TaxBasisTotalAmount" do + expect(subject).to contains_xml_node("#{root}/ram:TaxBasisTotalAmount") + .with_value("990.00") + end + + it "have TaxTotalAmount" do + expect(subject).to contains_xml_node("#{root}/ram:TaxTotalAmount") + .with_value("198.84") + .with_attribute("currencyID", "USD") + end + + it "have GrandTotalAmount" do + expect(subject).to contains_xml_node("#{root}/ram:GrandTotalAmount") + .with_value("1188.84") + end + + it "have TotalPrepaidAmount" do + expect(subject).to contains_xml_node("#{root}/ram:TotalPrepaidAmount") + .with_value("21.86") + end + + it "have DuePayableAmount" do + expect(subject).to contains_xml_node("#{root}/ram:DuePayableAmount") + .with_value("1188.84") + end + + context "when resource is credit note" do + let(:resource) { create(:credit_note, total_amount_currency: "EUR") } + + it "have TaxTotalAmount" do + expect(subject).to contains_xml_node("#{root}/ram:TaxTotalAmount") + .with_value("198.84") + .with_attribute("currencyID", "EUR") + end + end + + context "with default amount values" do + let(:amounts) do + described_class::Amounts.new( + line_total_amount: Money.new(100000), + tax_basis_amount: Money.new(99000), + tax_amount: Money.new(19884), + grand_total_amount: Money.new(118884), + due_payable_amount: Money.new(118884) + ) + end + + it "have ChargeTotalAmount and AllowanceTotalAmount as zero" do + expect(subject).to contains_xml_node("#{root}/ram:ChargeTotalAmount") + .with_value("0.00") + expect(subject).to contains_xml_node("#{root}/ram:AllowanceTotalAmount") + .with_value("0.00") + end + + it "does not have TotalPrepaidAmount" do + expect(subject).not_to contains_xml_node("#{root}/ram:TotalPrepaidAmount") + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/payment_terms_spec.rb b/spec/serializers/e_invoices/factur_x/payment_terms_spec.rb new file mode 100644 index 0000000..025e2fc --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/payment_terms_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::PaymentTerms do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, due_date:, description:) do + end + end + end + + let(:due_date) { "20250316".to_date } + let(:description) { "This is just a description, I can write anything" } + + let(:root) { "//ram:SpecifiedTradePaymentTerms" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Terms") + end + + it "have Description" do + expect(subject).to contains_xml_node("#{root}/ram:Description") + .with_value(description) + end + + it "have DueDate" do + expect(subject).to contains_xml_node("#{root}/ram:DueDateDateTime/udt:DateTimeString") + .with_value("20250316") + .with_attribute("format", 102) + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/trade_agreement_spec.rb b/spec/serializers/e_invoices/factur_x/trade_agreement_spec.rb new file mode 100644 index 0000000..85cd4d0 --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/trade_agreement_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::TradeAgreement do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, resource:, options:) + end + end + + let(:options) { described_class::Options.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:resource) { create(:invoice, customer:, organization:, billing_entity:, invoice_type:) } + let(:invoice_type) { :subscription } + let(:customer) do + create(:customer, + organization:, + name: "Its Mii") + end + let(:billing_entity) do + create(:billing_entity, + organization:, + code: "BE_CODE", + legal_name: "BE Legal Name", + zipcode: "60192460", + address_line1: "Rue quelque part", + address_line2: "Tourne au deuxième angle", + city: "Eine Stadt", + country: "BR", + tax_identification_number: "BR987654321") + end + + let(:root) { "//ram:ApplicableHeaderTradeAgreement" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Applicable Header Trade Agreement") + end + + context "with seller information" do + let(:seller_root) { "#{root}/ram:SellerTradeParty" } + + it "have the line id" do + expect(subject).to contains_xml_node("#{seller_root}/ram:ID") + .with_value(billing_entity.code) + end + + it "have the name" do + expect(subject).to contains_xml_node("#{seller_root}/ram:Name") + .with_value(billing_entity.legal_name) + end + + context "when address info" do + let(:seller_address_root) { "#{seller_root}/ram:PostalTradeAddress" } + + it "have the address" do + expect(subject).to contains_xml_node("#{seller_address_root}/ram:PostcodeCode") + .with_value(billing_entity.zipcode) + expect(subject).to contains_xml_node("#{seller_address_root}/ram:LineOne") + .with_value(billing_entity.address_line1) + expect(subject).to contains_xml_node("#{seller_address_root}/ram:LineTwo") + .with_value(billing_entity.address_line2) + expect(subject).to contains_xml_node("#{seller_address_root}/ram:CityName") + .with_value(billing_entity.city) + expect(subject).to contains_xml_node("#{seller_address_root}/ram:CountryID") + .with_value(billing_entity.country) + end + end + + context "when tax info" do + let(:seller_tax_root) { "#{seller_root}/ram:SpecifiedTaxRegistration" } + + it "have the tax id" do + expect(subject).to contains_xml_node("#{seller_tax_root}/ram:ID") + .with_value(billing_entity.tax_identification_number) + .with_attribute("schemeID", "VA") + end + + context "with tax_registration false" do + let(:options) { described_class::Options.new(tax_registration: false) } + + it "dont have the tax id" do + expect(subject).not_to contains_xml_node("#{seller_tax_root}/ram:ID") + end + end + end + end + + context "when buyer information" do + let(:buyer_root) { "#{root}/ram:BuyerTradeParty" } + + it "have the name" do + expect(subject).to contains_xml_node("#{buyer_root}/ram:Name") + .with_value(customer.name) + end + + context "when address info" do + let(:buyer_address_root) { "#{buyer_root}/ram:PostalTradeAddress" } + + it "have the address" do + expect(subject).to contains_xml_node("#{buyer_address_root}/ram:PostcodeCode") + .with_value(customer.zipcode) + expect(subject).to contains_xml_node("#{buyer_address_root}/ram:LineOne") + .with_value(customer.address_line1) + expect(subject).to contains_xml_node("#{buyer_address_root}/ram:LineTwo") + .with_value(customer.address_line2) + expect(subject).to contains_xml_node("#{buyer_address_root}/ram:CityName") + .with_value(customer.city) + expect(subject).to contains_xml_node("#{buyer_address_root}/ram:CountryID") + .with_value(customer.country) + end + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/trade_allowance_charge_spec.rb b/spec/serializers/e_invoices/factur_x/trade_allowance_charge_spec.rb new file mode 100644 index 0000000..520eef7 --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/trade_allowance_charge_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::TradeAllowanceCharge do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, resource:, indicator:, tax_rate:, amount:) do + end + end + end + + let(:indicator) { described_class::INVOICE_DISCOUNT } + let(:resource) { create(:invoice) } + let(:tax_rate) { 19.00 } + let(:amount) { Money.new(1000) } + + let(:root) { "//ram:SpecifiedTradeAllowanceCharge" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Allowance/Charge - Discount 19.00% portion") + end + + context "when discount" do + it "use discount indicator" do + expect(subject).to contains_xml_node("#{root}/ram:ChargeIndicator/udt:Indicator") + .with_value(false) + end + end + + context "when charge" do + let(:indicator) { described_class::INVOICE_CHARGE } + + it "use charge indicator" do + expect(subject).to contains_xml_node("#{root}/ram:ChargeIndicator/udt:Indicator") + .with_value(false) + end + end + + it "has the ActualAmount" do + expect(subject).to contains_xml_node("#{root}/ram:ActualAmount") + .with_value("10.00") + end + + it "has the Reason" do + expect(subject).to contains_xml_node("#{root}/ram:Reason") + .with_value("Discount 19.00% portion") + end + + context "with CategoryTradeTax" do + let(:trade_tax_root) { "#{root}/ram:CategoryTradeTax" } + + it "has the TypeCode" do + expect(subject).to contains_xml_node("#{trade_tax_root}/ram:TypeCode") + .with_value("VAT") + end + + context "with tax_category" do + it "has the S tax category code" do + expect(subject).to contains_xml_node("#{trade_tax_root}/ram:CategoryCode").with_value("S") + end + + context "when taxes are zero" do + let(:tax_rate) { 0.00 } + + it "has the Z category code" do + expect(subject).to contains_xml_node("#{trade_tax_root}/ram:CategoryCode").with_value("Z") + end + end + end + + it "has the RateApplicablePercent" do + expect(subject).to contains_xml_node("#{trade_tax_root}/ram:RateApplicablePercent") + .with_value("19.00") + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/trade_delivery_spec.rb b/spec/serializers/e_invoices/factur_x/trade_delivery_spec.rb new file mode 100644 index 0000000..c5b408f --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/trade_delivery_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::TradeDelivery do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, delivery_date:) + end + end + + let(:delivery_date) { "20250316".to_date } + + let(:root) { "//ram:ApplicableHeaderTradeDelivery" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Applicable Header Trade Delivery") + end + + context "when OccurrenceDateTime" do + let(:xpath) { "#{root}/ram:ActualDeliverySupplyChainEvent/ram:OccurrenceDateTime/udt:DateTimeString" } + + it "have the delivery date" do + expect(subject).to contains_xml_node(xpath).with_value("20250316").with_attribute("format", 102) + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/trade_settlement_payment_spec.rb b/spec/serializers/e_invoices/factur_x/trade_settlement_payment_spec.rb new file mode 100644 index 0000000..dc5b11a --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/trade_settlement_payment_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::TradeSettlementPayment do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, resource:, type:, amount:) + end + end + + let(:resource) { create(:invoice, currency: "USD") } + let(:type) { described_class::STANDARD_PAYMENT } + let(:amount) { Money.new(1000) } + + let(:root) { "//ram:SpecifiedTradeSettlementPaymentMeans" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + context "when STANDARD_PAYMENT" do + let(:type) { described_class::STANDARD_PAYMENT } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Means: Standard payment") + end + + it "have the payment code and information" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value(type) + expect(subject).to contains_xml_node("#{root}/ram:Information").with_value("Standard payment") + end + end + + context "when PREPAID_PAYMENT" do + let(:type) { described_class::PREPAID_PAYMENT } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Means: Prepaid credits") + end + + it "have the payment code and information" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value(type) + expect(subject).to contains_xml_node("#{root}/ram:Information").with_value("Prepaid credits of USD 10.00 applied") + end + end + + context "when CREDIT_NOTE_PAYMENT" do + let(:type) { described_class::CREDIT_NOTE_PAYMENT } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Means: Credit notes") + end + + it "have the payment code and information" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value(type) + expect(subject).to contains_xml_node("#{root}/ram:Information").with_value("Credit notes of USD 10.00 applied") + end + end + + context "when CREDIT_CARD_PAYMENT" do + let(:resource) { create(:payment, provider_payment_method_data: {"type" => "card", "brand" => "visa", "last4" => "1234"}) } + let(:type) { described_class::CREDIT_CARD_PAYMENT } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Means: Credit Card Payment") + end + + it "have the payment code and information" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode").with_value(type) + expect(subject).to contains_xml_node("#{root}/ram:Information").with_value("Credit card payment received on #{resource.created_at}") + end + + it "have the card attributes" do + expect(subject).to contains_xml_node("#{root}/ram:ApplicableTradeSettlementFinancialCard/ram:ID").with_value(resource.card_last_digits) + end + end + end +end diff --git a/spec/serializers/e_invoices/factur_x/trade_settlement_spec.rb b/spec/serializers/e_invoices/factur_x/trade_settlement_spec.rb new file mode 100644 index 0000000..ec432ef --- /dev/null +++ b/spec/serializers/e_invoices/factur_x/trade_settlement_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::FacturX::TradeSettlement do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, resource:) do + end + end + end + + let(:resource) { create(:invoice, currency: "EUR") } + + let(:root) { "//ram:ApplicableHeaderTradeSettlement" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Applicable Header Trade Settlement") + end + + it "have the invoice currency" do + expect(subject).to contains_xml_node("#{root}/ram:InvoiceCurrencyCode") + .with_value("EUR") + end + + context "when resource is credit note" do + let(:resource) { create(:credit_note, total_amount_currency: "EUR") } + + it "have the invoice currency" do + expect(subject).to contains_xml_node("#{root}/ram:InvoiceCurrencyCode") + .with_value("EUR") + end + end + end +end diff --git a/spec/serializers/e_invoices/invoices/factur_x/builder_spec.rb b/spec/serializers/e_invoices/invoices/factur_x/builder_spec.rb new file mode 100644 index 0000000..e541261 --- /dev/null +++ b/spec/serializers/e_invoices/invoices/factur_x/builder_spec.rb @@ -0,0 +1,401 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Invoices::FacturX::Builder do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, invoice:) + end + end + + let(:invoice) { create(:invoice, invoice_type:, currency: "USD", total_amount_cents: 3000, payment_due_date: "20250316".to_date) } + let(:invoice_type) { :one_off } + let(:invoice_fee1) { create(:fee, invoice:, units: 5, amount: 10, precise_unit_amount: 2) } + let(:invoice_fee2) { create(:fee, invoice:, units: 1, amount: 25, precise_unit_amount: 25) } + + before do + invoice_fee1 + invoice_fee2 + end + + describe ".serialize" do + context "when CrossIndustryInvoice tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//rsm:CrossIndustryInvoice") + end + end + + context "when ExchangedDocument tag" do + let(:root) { "//rsm:CrossIndustryInvoice/rsm:ExchangedDocument" } + + it "contains the tag" do + expect(subject).to contains_xml_node(root) + end + + context "with credit note info" do + context "when ID" do + it "contains the info" do + expect(subject).to contains_xml_node("#{root}/ram:ID") + .with_value(invoice.number) + end + end + + context "when TypeCode" do + context "when credit invoice" do + let(:invoice_type) { :credit } + + it "contains the PREPAID_CREDIT code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode") + .with_value(described_class::PREPAID_INVOICE) + end + end + + context "when self_billed invoice" do + before { invoice.update(self_billed: true) } + + it "contains the SELF_BILLED_INVOICE code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode") + .with_value(described_class::SELF_BILLED_INVOICE) + end + end + + context "when other invoice types" do + it "contains the COMMERCIAL_INVOICE code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode") + .with_value(described_class::COMMERCIAL_INVOICE) + end + end + end + + context "when IssueDateTime" do + it "contains the info" do + expect(subject).to contains_xml_node("#{root}/ram:IssueDateTime/udt:DateTimeString") + .with_value(invoice.issuing_date.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when IncludedNote" do + it "contains the notes" do + expect(subject.xpath("#{root}/ram:IncludedNote/ram:Content").length).to eq(1) + end + end + end + end + + context "when SupplyChainTradeTransaction tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//rsm:SupplyChainTradeTransaction") + end + end + + context "when IncludedSupplyChainTradeLineItem tags" do + it "has all fees" do + expect( + subject.xpath( + "//rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem" + ).length + ).to eq(invoice.fees.count) + end + + context "with BilledQuantity" do + it "contains the info" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:BilledQuantity" + ).with_value(invoice_fee1.units) + end + end + + context "with LineTotalAmount" do + it "contains the info" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:LineTotalAmount" + ).with_value(invoice_fee1.amount) + end + end + end + + context "when ApplicableHeaderTradeAgreement tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeAgreement") + end + + it "contains SpecifiedTaxRegistration tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeAgreement//ram:SpecifiedTaxRegistration/ram:ID") + .with_value(invoice.billing_entity.tax_identification_number) + .with_attribute("schemeID", "VA") + end + + context "when credit invoice" do + let(:invoice_type) { :credit } + + it "do not contain SpecifiedTaxRegistration tag" do + expect(subject).not_to contains_xml_node("//ram:ApplicableHeaderTradeAgreement//ram:SpecifiedTaxRegistration/ram:ID") + end + end + end + + context "when ApplicableHeaderTradeDelivery tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeDelivery") + end + + context "when one_off invoice" do + let(:invoice_type) { :one_off } + + it "contains OccurrenceDateTime" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeDelivery//udt:DateTimeString") + .with_value(invoice.created_at.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when credit invoice" do + let(:invoice_type) { :credit } + + it "contains OccurrenceDateTime" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeDelivery//udt:DateTimeString") + .with_value(invoice.created_at.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when subscription invoice" do + let(:invoice_type) { :subscription } + let(:invoice_subscription) { create(:invoice_subscription, :boundaries, invoice:, subscription: subscription) } + let(:subscription) { create(:subscription, started_at: "2025-03-16".to_date) } + + before { invoice_subscription } + + it "contains OccurrenceDateTime" do + travel_to(subscription.started_at + 1.month) do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeDelivery//udt:DateTimeString") + .with_value(Time.zone.today.beginning_of_month.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + end + end + + context "when ApplicableHeaderTradeSettlement tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeSettlement") + end + + it "contains InvoiceCurrencyCode" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeSettlement/ram:InvoiceCurrencyCode") + .with_value(invoice.currency) + end + end + + context "when SpecifiedTradeSettlementPaymentMeans tags" do + before do + invoice.update(prepaid_credit_amount: 10, credit_notes_amount: 20) + end + + it "contains the tags" do + expect( + subject.xpath( + "//ram:ApplicableHeaderTradeSettlement/ram:SpecifiedTradeSettlementPaymentMeans" + ).length + ).to eq(3) + end + + it "has one for STANDARD_PAYMENT" do + expect(subject).to contains_xml_node( + "//ram:SpecifiedTradeSettlementPaymentMeans[1]/ram:TypeCode" + ).with_value(described_class::STANDARD_PAYMENT) + expect(subject).to contains_xml_node( + "//ram:SpecifiedTradeSettlementPaymentMeans[1]/ram:Information" + ) + end + + it "has one for PREPAID_PAYMENT" do + expect(subject).to contains_xml_node( + "//ram:SpecifiedTradeSettlementPaymentMeans[2]/ram:TypeCode" + ).with_value(described_class::PREPAID_PAYMENT) + expect(subject).to contains_xml_node( + "//ram:SpecifiedTradeSettlementPaymentMeans[2]/ram:Information" + ) + end + + it "has one for CREDIT_NOTE_PAYMENT" do + expect(subject).to contains_xml_node( + "//ram:SpecifiedTradeSettlementPaymentMeans[3]/ram:TypeCode" + ).with_value(described_class::CREDIT_NOTE_PAYMENT) + expect(subject).to contains_xml_node( + "//ram:SpecifiedTradeSettlementPaymentMeans[3]/ram:Information" + ) + end + end + + context "when ApplicableTradeTax tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:ApplicableTradeTax" } + + let(:invoice) { create(:invoice, coupons_amount_cents: 100, invoice_type:) } + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 1000, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 100, taxes_precise_amount_cents: 4.75) } + let(:invoice_fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 300, taxes_precise_amount_cents: 14.25) } + let(:invoice_fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_amount_cents: 600, taxes_precise_amount_cents: 57) } + + before do + invoice_fee1 + invoice_fee2 + invoice_fee3 + invoice_fee4 + end + + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/ram:CalculatedAmount").with_value("0.00") + expect(subject).to contains_xml_node("#{root}[1]/ram:BasisAmount").with_value("9.50") + expect(subject).to contains_xml_node("#{root}[1]/ram:CategoryCode").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/ram:RateApplicablePercent").with_value("0.00") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/ram:CalculatedAmount").with_value("0.19") + expect(subject).to contains_xml_node("#{root}[2]/ram:BasisAmount").with_value("3.80") + expect(subject).to contains_xml_node("#{root}[2]/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/ram:RateApplicablePercent").with_value("5.00") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/ram:CalculatedAmount").with_value("0.57") + expect(subject).to contains_xml_node("#{root}[3]/ram:BasisAmount").with_value("5.70") + expect(subject).to contains_xml_node("#{root}[3]/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/ram:RateApplicablePercent").with_value("10.00") + end + end + end + + context "when SpecifiedTradeAllowanceCharge tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:SpecifiedTradeAllowanceCharge" } + + let(:invoice) { create(:invoice, coupons_amount_cents: 100, invoice_type:) } + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 1000, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 100, taxes_precise_amount_cents: 4.75) } + let(:invoice_fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 300, taxes_precise_amount_cents: 14.25) } + let(:invoice_fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_amount_cents: 600, taxes_precise_amount_cents: 57) } + + before do + invoice_fee1 + invoice_fee2 + invoice_fee3 + invoice_fee4 + end + + it "contains SpecifiedTradeAllowanceCharge tags" do + expect(subject.xpath(root).length).to eq(3) + end + + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/ram:ChargeIndicator/udt:Indicator").with_value(described_class::INVOICE_DISCOUNT) + expect(subject).to contains_xml_node("#{root}[1]/ram:ActualAmount").with_value("0.50") + expect(subject).to contains_xml_node("#{root}[1]/ram:Reason") + expect(subject).to contains_xml_node("#{root}[1]/ram:CategoryTradeTax/ram:CategoryCode").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/ram:CategoryTradeTax/ram:RateApplicablePercent").with_value("0.00") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/ram:ChargeIndicator/udt:Indicator").with_value(described_class::INVOICE_DISCOUNT) + expect(subject).to contains_xml_node("#{root}[2]/ram:ActualAmount").with_value("0.20") + expect(subject).to contains_xml_node("#{root}[2]/ram:Reason") + expect(subject).to contains_xml_node("#{root}[2]/ram:CategoryTradeTax/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/ram:CategoryTradeTax/ram:RateApplicablePercent").with_value("5.00") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/ram:ChargeIndicator/udt:Indicator").with_value(described_class::INVOICE_DISCOUNT) + expect(subject).to contains_xml_node("#{root}[3]/ram:ActualAmount").with_value("0.30") + expect(subject).to contains_xml_node("#{root}[3]/ram:Reason") + expect(subject).to contains_xml_node("#{root}[3]/ram:CategoryTradeTax/ram:CategoryCode").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/ram:CategoryTradeTax/ram:RateApplicablePercent").with_value("10.00") + end + end + + context "when all fees have zero precise_amount_cents" do + let(:invoice) { create(:invoice, coupons_amount_cents: 100, invoice_type:) } + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 0, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { nil } + let(:invoice_fee3) { nil } + let(:invoice_fee4) { nil } + + it "does not raise an error" do + expect { subject }.not_to raise_error + end + + it "does not contain SpecifiedTradeAllowanceCharge tags" do + expect(subject.xpath(root).length).to eq(0) + end + end + end + + context "when SpecifiedTradePaymentTerms tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms") + end + + it "contains Description tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms/ram:Description") + end + + it "contains DueDateDateTime tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms//udt:DateTimeString") + .with_value(invoice.payment_due_date.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when SpecifiedTradeSettlementHeaderMonetarySummation tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:SpecifiedTradeSettlementHeaderMonetarySummation" } + + let(:invoice) do + create(:invoice, + invoice_type:, + coupons_amount_cents: 100, + fees_amount: 35, + sub_total_excluding_taxes_amount: 35, + taxes_amount: 5, + sub_total_including_taxes_amount: 40, + prepaid_credit_amount: 5, + credit_notes_amount: 5, + total_amount: 30) + end + + it "contains the tag" do + expect(subject).to contains_xml_node(root) + end + + it "contains LineTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:LineTotalAmount").with_value("35.00") + end + + it "contains ChargeTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:ChargeTotalAmount").with_value("0.00") + end + + it "contains AllowanceTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:AllowanceTotalAmount").with_value("1.00") + end + + it "contains TaxBasisTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:TaxBasisTotalAmount").with_value("35.00") + end + + it "contains TaxTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:TaxTotalAmount").with_value("5.00") + end + + it "contains GrandTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:GrandTotalAmount").with_value("40.00") + end + + it "contains DuePayableAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:DuePayableAmount").with_value("30.00") + end + end + end +end diff --git a/spec/serializers/e_invoices/invoices/ubl/builder_spec.rb b/spec/serializers/e_invoices/invoices/ubl/builder_spec.rb new file mode 100644 index 0000000..ba1c9c0 --- /dev/null +++ b/spec/serializers/e_invoices/invoices/ubl/builder_spec.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Invoices::Ubl::Builder do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, invoice:) + end + end + + let(:invoice) { create(:invoice, invoice_type:, currency: "USD", total_amount_cents: 3000, payment_due_date: "20250316".to_date, taxes_amount_cents: 2500) } + let(:invoice_type) { :one_off } + let(:invoice_fee1) { create(:fee, invoice:, units: 5, amount: 10, precise_unit_amount: 2, amount_currency: "EUR") } + let(:invoice_fee2) { create(:fee, invoice:, units: 1, amount: 25, precise_unit_amount: 25, amount_currency: "EUR") } + + before do + invoice_fee1 + invoice_fee2 + end + + describe ".serialize" do + context "when Invoice tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//*[local-name()='Invoice']") + end + + it "has UBLVersionID tag" do + expect(subject).to contains_xml_node("//cbc:UBLVersionID").with_value(2.1) + end + + it "has CustomizationID tag" do + expect(subject).to contains_xml_node("//cbc:CustomizationID").with_value("urn:cen.eu:en16931:2017") + end + + it "has ID tag" do + expect(subject).to contains_xml_node("//cbc:ID").with_value(invoice.number) + end + + it "has IssueDate tag" do + expect(subject).to contains_xml_node("//cbc:IssueDate").with_value(invoice.issuing_date) + end + + context "when InvoiceTypeCode tag" do + context "when credit invoice" do + let(:invoice_type) { :credit } + + it "contains the PREPAID_CREDIT code" do + expect(subject).to contains_xml_node("//cbc:InvoiceTypeCode") + .with_value(described_class::PREPAID_INVOICE) + end + end + + context "when self_billed invoice" do + before { invoice.update(self_billed: true) } + + it "contains the SELF_BILLED_INVOICE code" do + expect(subject).to contains_xml_node("//cbc:InvoiceTypeCode") + .with_value(described_class::SELF_BILLED_INVOICE) + end + end + + context "when other invoice types" do + it "contains the COMMERCIAL_INVOICE code" do + expect(subject).to contains_xml_node("//cbc:InvoiceTypeCode") + .with_value(described_class::COMMERCIAL_INVOICE) + end + end + end + + it "has DocumentCurrencyCode tag" do + expect(subject).to contains_xml_node("//cbc:DocumentCurrencyCode").with_value(invoice.currency) + end + end + + context "when AccountingSupplierParty tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:AccountingSupplierParty") + end + + context "with credit invoice" do + let(:invoice_type) { :credit } + + it "does not contains PartyTaxScheme tag" do + expect(subject).not_to contains_xml_node("//cac:AccountingSupplierParty//cac:PartyTaxScheme") + end + end + + context "with other invoice types" do + it "does contains PartyTaxScheme tag" do + expect(subject).to contains_xml_node("//cac:AccountingSupplierParty//cac:PartyTaxScheme") + end + end + end + + context "when AccountingCustomerParty tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:AccountingSupplierParty") + end + end + + context "when Delivery tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:Delivery") + end + + context "when invoice types" do + context "when one_off" do + let(:invoice_type) { :one_off } + + it "contains the delivery date" do + expect(subject).to contains_xml_node("//cac:Delivery/cbc:ActualDeliveryDate") + .with_value(invoice.created_at.strftime(described_class::DATEFORMAT)) + end + end + + context "when credit" do + let(:invoice_type) { :credit } + + it "contains the delivery date" do + expect(subject).to contains_xml_node("//cac:Delivery/cbc:ActualDeliveryDate") + .with_value(invoice.created_at.strftime(described_class::DATEFORMAT)) + end + end + + context "when subscription" do + let(:invoice_type) { :subscription } + let(:invoice_subscription1) { create(:invoice_subscription, :boundaries, invoice:, subscription: subscription1) } + let(:invoice_subscription2) { create(:invoice_subscription, :boundaries, invoice:, subscription: subscription2) } + let(:subscription1) { create(:subscription, started_at: "2025-03-16".to_date) } + let(:subscription2) { create(:subscription, started_at: "2025-03-26".to_date) } + + before do + invoice_subscription1 + invoice_subscription2 + end + + it "have the first date of subscription start" do + travel_to(Time.zone.parse("2025-04-16")) do + expect(subject).to contains_xml_node("//cac:Delivery/cbc:ActualDeliveryDate") + .with_value("2025-04-01") + end + end + end + end + end + + context "when PaymentMeans tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:PaymentMeans") + end + + it "has the STANDARD_PAYMENT" do + expect(subject).to contains_xml_node("//cac:PaymentMeans//cbc:PaymentMeansCode").with_value(1) + end + end + + context "when PaymentTerms tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:PaymentTerms") + end + + context "with prepaid and credit notes" do + before do + invoice.update(net_payment_term: 2, prepaid_credit_amount: 10, credit_notes_amount: 20) + end + + it "contains the payment information on note" do + expect(subject).to contains_xml_node("//cac:PaymentTerms/cbc:Note").with_value( + "Payment term 2 days, Prepaid credits of USD 10.00 applied, and Credit notes of USD 20.00 applied" + ) + end + end + end + + context "when AllowanceCharge tag" do + let(:root) { "//cac:AllowanceCharge" } + + let(:invoice) { create(:invoice, coupons_amount_cents: 100, invoice_type:) } + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 1000, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 100, taxes_precise_amount_cents: 4.75) } + let(:invoice_fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 300, taxes_precise_amount_cents: 14.25) } + let(:invoice_fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_amount_cents: 600, taxes_precise_amount_cents: 57) } + + before do + invoice_fee1 + invoice_fee2 + invoice_fee3 + invoice_fee4 + end + + it "contains AllowanceCharge tags" do + expect(subject.xpath(root).length).to eq(3) + end + + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/cbc:ChargeIndicator").with_value(described_class::INVOICE_DISCOUNT) + expect(subject).to contains_xml_node("#{root}[1]/cbc:AllowanceChargeReason") + expect(subject).to contains_xml_node("#{root}[1]/cbc:Amount").with_value("0.50") + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:ID").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:Percent").with_value("0.00") + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cac:TaxScheme/cbc:ID").with_value("VAT") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/cbc:ChargeIndicator").with_value(described_class::INVOICE_DISCOUNT) + expect(subject).to contains_xml_node("#{root}[2]/cbc:AllowanceChargeReason") + expect(subject).to contains_xml_node("#{root}[2]/cbc:Amount").with_value("0.20") + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:Percent").with_value("5.00") + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cac:TaxScheme/cbc:ID").with_value("VAT") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/cbc:ChargeIndicator").with_value(described_class::INVOICE_DISCOUNT) + expect(subject).to contains_xml_node("#{root}[3]/cbc:AllowanceChargeReason") + expect(subject).to contains_xml_node("#{root}[3]/cbc:Amount").with_value("0.30") + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:Percent").with_value("10.00") + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cac:TaxScheme/cbc:ID").with_value("VAT") + end + end + + context "when all fees have zero precise_amount_cents" do + let(:invoice) { create(:invoice, coupons_amount_cents: 100, invoice_type:) } + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 0, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { nil } + let(:invoice_fee3) { nil } + let(:invoice_fee4) { nil } + + it "does not raise an error" do + expect { subject }.not_to raise_error + end + + it "does not contain AllowanceCharge tags" do + expect(subject.xpath(root).length).to eq(0) + end + end + end + + context "when TaxTotal tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:TaxTotal") + end + + it "contains TaxAmount tag" do + expect(subject).to contains_xml_node("//cac:TaxTotal/cbc:TaxAmount") + .with_value(Money.new(invoice.taxes_amount)) + .with_attribute("currencyID", invoice.currency) + end + end + + context "when TaxSubtotal tag" do + let(:root) { "//cac:TaxTotal/cac:TaxSubtotal" } + + let(:invoice) { create(:invoice, coupons_amount_cents: 100, invoice_type:) } + let(:invoice_fee1) { create(:fee, invoice:, taxes_rate: 0.0, precise_amount_cents: 1000, taxes_precise_amount_cents: 0) } + let(:invoice_fee2) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 100, taxes_precise_amount_cents: 4.75) } + let(:invoice_fee3) { create(:fee, invoice:, taxes_rate: 5.0, precise_amount_cents: 300, taxes_precise_amount_cents: 14.25) } + let(:invoice_fee4) { create(:fee, invoice:, taxes_rate: 10.0, precise_amount_cents: 600, taxes_precise_amount_cents: 57) } + + before do + invoice_fee1 + invoice_fee2 + invoice_fee3 + invoice_fee4 + end + + it "contains TaxSubtotal tags" do + expect(subject.xpath(root).length).to eq(3) + end + + context "with one tag per tax rate" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/cbc:TaxableAmount").with_value("9.50") + expect(subject).to contains_xml_node("#{root}[1]/cbc:TaxAmount").with_value("0.00") + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:ID").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/cac:TaxCategory/cbc:Percent").with_value("0.00") + end + + it "contains 5.00% rate" do + expect(subject).to contains_xml_node("#{root}[2]/cbc:TaxableAmount").with_value("3.80") + expect(subject).to contains_xml_node("#{root}[2]/cbc:TaxAmount").with_value("0.19") + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[2]/cac:TaxCategory/cbc:Percent").with_value("5.00") + end + + it "contains 10.00% rate" do + expect(subject).to contains_xml_node("#{root}[3]/cbc:TaxableAmount").with_value("5.70") + expect(subject).to contains_xml_node("#{root}[3]/cbc:TaxAmount").with_value("0.57") + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:ID").with_value(described_class::S_CATEGORY) + expect(subject).to contains_xml_node("#{root}[3]/cac:TaxCategory/cbc:Percent").with_value("10.00") + end + end + end + + context "when LegalMonetaryTotal tag" do + before do + invoice.update( + fees_amount: 10, + sub_total_excluding_taxes_amount: 11, + sub_total_including_taxes_amount: 12, + coupons_amount: 6, + progressive_billing_credit_amount: 7, + prepaid_credit_amount: 7, + credit_notes_amount: 7, + total_amount: 15 + ) + end + + context "when LineExtensionAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:LineExtensionAmount") + .with_value(invoice.fees_amount) + .with_attribute("currencyID", invoice.currency) + end + end + + context "when TaxExclusiveAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:TaxExclusiveAmount") + .with_value(invoice.sub_total_excluding_taxes_amount) + .with_attribute("currencyID", invoice.currency) + end + end + + context "when TaxInclusiveAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:TaxInclusiveAmount") + .with_value(invoice.sub_total_including_taxes_amount) + .with_attribute("currencyID", invoice.currency) + end + end + + context "when AllowanceTotalAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:AllowanceTotalAmount") + .with_value("13.00") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when ChargeTotalAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:ChargeTotalAmount") + .with_value("0.00") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when PrepaidAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:PrepaidAmount") + .with_value("14.00") + .with_attribute("currencyID", invoice.currency) + end + end + + context "when PayableAmount tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:LegalMonetaryTotal/cbc:PayableAmount") + .with_value(invoice.total_amount) + .with_attribute("currencyID", invoice.currency) + end + end + end + + context "when InvoiceLine tags" do + let(:root) { "//cac:InvoiceLine" } + + it "contains fee tags" do + expect(subject.xpath(root).length).to eq(2) + end + + context "with one tag per fee" do + it "contains the first fee info" do + expect(subject).to contains_xml_node("#{root}[1]/cbc:ID").with_value("1") + expect(subject).to contains_xml_node("#{root}[1]/cbc:InvoicedQuantity").with_value("5.00").with_attribute("unitCode", described_class::UNIT_CODE) + expect(subject).to contains_xml_node("#{root}[1]/cbc:LineExtensionAmount").with_value("10.00").with_attribute("currencyID", "EUR") + end + + it "contains the second fee info" do + expect(subject).to contains_xml_node("#{root}[2]/cbc:ID").with_value("2") + expect(subject).to contains_xml_node("#{root}[2]/cbc:InvoicedQuantity").with_value("1.00").with_attribute("unitCode", described_class::UNIT_CODE) + expect(subject).to contains_xml_node("#{root}[2]/cbc:LineExtensionAmount").with_value("25.00").with_attribute("currencyID", "EUR") + end + end + end + end +end diff --git a/spec/serializers/e_invoices/payments/factur_x/builder_spec.rb b/spec/serializers/e_invoices/payments/factur_x/builder_spec.rb new file mode 100644 index 0000000..514168b --- /dev/null +++ b/spec/serializers/e_invoices/payments/factur_x/builder_spec.rb @@ -0,0 +1,293 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Payments::FacturX::Builder do + subject do + xml_document(:factur_x) do |xml| + described_class.serialize(xml:, payment:) + end + end + + let(:organization) { create(:organization, premium_integrations: %w[issue_receipts]) } + let(:billing_entity) { create(:billing_entity, tax_identification_number: "MAR1234BR") } + let(:customer) { create(:customer, billing_entity:) } + let(:invoice) { create(:invoice, total_amount_cents: 1000, number: "INV-24680-OIC-E") } + let(:payment) do + create(:payment, + customer:, + payment_type:, + payable: invoice, + currency: "BRL", + amount_cents: 1000, + reference:, + provider_payment_method_data: {last4: "4321"}) + end + let(:payment_type) { "manual" } + let(:reference) { "its a payment" } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:) } + + before do + payment + payment_receipt + end + + describe ".serialize" do + context "when CrossIndustryInvoice tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//rsm:CrossIndustryInvoice") + end + end + + context "when ExchangedDocument tag" do + let(:root) { "//rsm:CrossIndustryInvoice/rsm:ExchangedDocument" } + + it "contains the tag" do + expect(subject).to contains_xml_node(root) + end + + context "with credit note info" do + context "when ID" do + it "contains the info" do + expect(subject).to contains_xml_node("#{root}/ram:ID") + .with_value(payment_receipt.number) + end + end + + context "when TypeCode" do + it "contains the PAYMENT_RECEIPT code" do + expect(subject).to contains_xml_node("#{root}/ram:TypeCode") + .with_value(described_class::PAYMENT_RECEIPT) + end + end + + context "when IssueDateTime" do + it "contains the info" do + expect(subject).to contains_xml_node("#{root}/ram:IssueDateTime/udt:DateTimeString") + .with_value(payment.created_at.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when IncludedNote" do + it "contains the notes" do + expect(subject.xpath("#{root}/ram:IncludedNote/ram:Content").length).to eq(1) + end + end + end + end + + context "when SupplyChainTradeTransaction tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//rsm:SupplyChainTradeTransaction") + end + end + + context "when IncludedSupplyChainTradeLineItem tags" do + it "has a single item" do + expect( + subject.xpath( + "//rsm:SupplyChainTradeTransaction/ram:IncludedSupplyChainTradeLineItem" + ).length + ).to eq(1) + end + + context "with Product Name" do + it "contains the info" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:Name" + ).with_value("Payment Received") + end + end + + context "with ChargeAmount" do + it "contains the info" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:ChargeAmount" + ).with_value(payment.amount) + end + end + + context "with BilledQuantity" do + it "contains the info" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:BilledQuantity" + ).with_value(1) + end + end + + context "with ApplicableTradeTax" do + it "contains the CategoryCode" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:ApplicableTradeTax//ram:CategoryCode" + ).with_value(described_class::Z_CATEGORY) + end + + it "contains the RateApplicablePercent" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:ApplicableTradeTax//ram:RateApplicablePercent" + ).with_value("0.0") + end + end + + context "with LineTotalAmount" do + it "contains the info" do + expect(subject).to contains_xml_node( + "//ram:IncludedSupplyChainTradeLineItem[1]//ram:LineTotalAmount" + ).with_value(payment.amount) + end + end + end + + context "when ApplicableHeaderTradeAgreement tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeAgreement") + end + + it "contains SpecifiedTaxRegistration tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeAgreement//ram:SpecifiedTaxRegistration/ram:ID") + .with_value(billing_entity.tax_identification_number) + .with_attribute("schemeID", "VA") + end + end + + context "when ApplicableHeaderTradeDelivery tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeDelivery") + end + + it "contains OccurrenceDateTime" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeDelivery//udt:DateTimeString") + .with_value(payment.created_at.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when ApplicableHeaderTradeSettlement tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeSettlement") + end + + it "contains InvoiceCurrencyCode" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeSettlement/ram:InvoiceCurrencyCode") + .with_value(payment.currency) + end + end + + context "when SpecifiedTradeSettlementPaymentMeans tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradeSettlementPaymentMeans") + end + + it "contains Information" do + expect(subject).to contains_xml_node("//ram:ApplicableHeaderTradeSettlement/ram:InvoiceCurrencyCode") + .with_value("BRL") + end + + context "when payment is manual" do + let(:payment_type) { "manual" } + + it "contains TypeCode" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode") + .with_value(described_class::STANDARD_PAYMENT) + end + + it "does not contains card attributes" do + expect(subject).not_to contains_xml_node("//ram:ApplicableHeaderTradeSettlement/ram:ApplicableTradeSettlementFinancialCard") + end + end + + context "when payment is using provider" do + let(:payment_type) { "provider" } + let(:reference) { nil } + + it "contains TypeCode" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradeSettlementPaymentMeans/ram:TypeCode") + .with_value(described_class::CREDIT_CARD_PAYMENT) + end + + context "when ApplicableTradeSettlementFinancialCard tag" do + it "contains ID" do + expect(subject).to contains_xml_node("//ram:ApplicableTradeSettlementFinancialCard/ram:ID") + .with_value(payment.card_last_digits) + end + end + end + end + + context "when ApplicableTradeTax tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:ApplicableTradeTax" } + + context "with a single tag" do + it "contains 0.00% rate" do + expect(subject).to contains_xml_node("#{root}[1]/ram:CalculatedAmount").with_value("0.00") + expect(subject).to contains_xml_node("#{root}[1]/ram:BasisAmount").with_value("10.00") + expect(subject).to contains_xml_node("#{root}[1]/ram:CategoryCode").with_value(described_class::Z_CATEGORY) + expect(subject).to contains_xml_node("#{root}[1]/ram:RateApplicablePercent").with_value("0.00") + end + end + end + + context "when SpecifiedTradePaymentTerms tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms") + end + + it "contains Description tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms/ram:Description") + end + + it "contains DueDateDateTime tag" do + expect(subject).to contains_xml_node("//ram:SpecifiedTradePaymentTerms//udt:DateTimeString") + .with_value(payment.created_at.strftime(described_class::DATEFORMAT)) + .with_attribute("format", described_class::CCYYMMDD) + end + end + + context "when SpecifiedTradeSettlementHeaderMonetarySummation tag" do + let(:root) { "//ram:ApplicableHeaderTradeSettlement//ram:SpecifiedTradeSettlementHeaderMonetarySummation" } + + it "contains the tag" do + expect(subject).to contains_xml_node(root) + end + + it "contains LineTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:LineTotalAmount").with_value("10.00") + end + + it "contains ChargeTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:ChargeTotalAmount").with_value("0.00") + end + + it "contains AllowanceTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:AllowanceTotalAmount").with_value("0.00") + end + + it "contains TaxBasisTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:TaxBasisTotalAmount").with_value("10.00") + end + + it "contains TaxTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:TaxTotalAmount").with_value("0.00") + end + + it "contains GrandTotalAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:GrandTotalAmount").with_value("10.00") + end + + it "contains DuePayableAmount tag" do + expect(subject).to contains_xml_node("#{root}/ram:DuePayableAmount").with_value("0.00") + end + end + + context "when InvoiceReferencedDocument tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//ram:InvoiceReferencedDocument") + end + + it "contains the IssuerAssignedID" do + expect(subject).to contains_xml_node("//ram:IssuerAssignedID").with_value(invoice.number) + end + end + end +end diff --git a/spec/serializers/e_invoices/payments/ubl/builder_spec.rb b/spec/serializers/e_invoices/payments/ubl/builder_spec.rb new file mode 100644 index 0000000..01baab9 --- /dev/null +++ b/spec/serializers/e_invoices/payments/ubl/builder_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Payments::Ubl::Builder do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, payment:) + end + end + + let(:organization) { create(:organization, premium_integrations: %w[issue_receipts]) } + let(:billing_entity) { create(:billing_entity, tax_identification_number: "MAR1234BR") } + let(:customer) { create(:customer, billing_entity:) } + let(:invoice) { create(:invoice, total_amount_cents: 1000, number: "INV-24680-OIC-E") } + let(:payment) do + create(:payment, + customer:, + payment_type:, + payable: invoice, + currency: "BRL", + amount_cents: 1000, + reference:, + provider_payment_method_data: {last4: "4321"}) + end + let(:payment_type) { "manual" } + let(:reference) { "its a payment" } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:) } + + before do + payment + payment_receipt + end + + describe ".serialize" do + context "when ApplicationResponse tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//*[local-name()='ApplicationResponse']") + end + + it "has UBLVersionID tag" do + expect(subject).to contains_xml_node("//cbc:UBLVersionID").with_value(2.1) + end + + it "has CustomizationID tag" do + expect(subject).to contains_xml_node("//cbc:CustomizationID").with_value("urn:oasis:names:specification:ubl:xpath:ApplicationResponse-2.4") + end + + it "has ProfileID tag" do + expect(subject).to contains_xml_node("//cbc:ProfileID").with_value("urn:oasis:names:specification:ubl:schema:xsd:ApplicationResponse-2") + end + + it "has ID tag" do + expect(subject).to contains_xml_node("//cbc:ID").with_value(payment_receipt.number) + end + + it "has IssueDate tag" do + expect(subject).to contains_xml_node("//cbc:IssueDate").with_value(payment.created_at.strftime(described_class::DATEFORMAT)) + end + + it "has Note tag" do + expect(subject).to contains_xml_node("//cbc:Note") + end + end + + context "when SenderParty tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:SenderParty") + end + end + + context "when ReceiverParty tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:ReceiverParty") + end + end + + context "when DocumentResponse tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:DocumentResponse") + end + + context "when Response tag" do + it "contains the tag" do + expect(subject).to contains_xml_node("//cac:Response") + end + + it "contains ResponseCode tag" do + expect(subject).to contains_xml_node("//cac:Response/cbc:ResponseCode").with_value(described_class::PAID) + end + + it "contains Description tag" do + expect(subject).to contains_xml_node("//cac:Response/cbc:Description") + end + + it "contains EffectiveDate tag" do + expect(subject).to contains_xml_node("//cac:Response/cbc:EffectiveDate").with_value(payment.created_at.strftime(described_class::DATEFORMAT)) + end + end + + context "when DocumentReference tag" do + it "has one tag per invoice in payment" do + expect( + subject.xpath( + "//cac:DocumentReference" + ).length + ).to eq(payment.invoices.count) + end + + it "has ID tag" do + expect(subject).to contains_xml_node("//cac:DocumentReference/cbc:ID").with_value(invoice.number) + end + + it "has IssueDate tag" do + expect(subject).to contains_xml_node("//cac:DocumentReference/cbc:IssueDate").with_value(invoice.issuing_date.strftime(described_class::DATEFORMAT)) + end + + it "has DocumentTypeCode tag" do + expect(subject).to contains_xml_node("//cac:DocumentReference/cbc:DocumentTypeCode").with_value(described_class::COMMERCIAL_INVOICE) + end + + it "has DocumentType tag" do + expect(subject).to contains_xml_node("//cac:DocumentReference/cbc:DocumentType").with_value("Invoice") + end + + it "has DocumentDescription tag" do + expect(subject).to contains_xml_node("//cac:DocumentReference/cbc:DocumentDescription").with_value("Invoice ID from payment system: #{invoice.id}") + end + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/allowance_charge_spec.rb b/spec/serializers/e_invoices/ubl/allowance_charge_spec.rb new file mode 100644 index 0000000..0dd972d --- /dev/null +++ b/spec/serializers/e_invoices/ubl/allowance_charge_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::AllowanceCharge do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:, indicator:, tax_rate:, amount:) + end + end + + let(:indicator) { described_class::INVOICE_DISCOUNT } + let(:resource) { invoice } + let(:invoice) { create(:invoice, currency: "USD") } + let(:tax_rate) { 19.00 } + let(:amount) { Money.new(1000) } + + let(:root) { "//cac:AllowanceCharge" } + + before { invoice } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Allowances and Charges - Discount 19.00% portion") + end + + it "has the ChargeIndicator" do + expect(subject).to contains_xml_node("#{root}/cbc:ChargeIndicator").with_value(indicator) + end + + it "has the AllowanceChargeReason" do + expect(subject).to contains_xml_node("#{root}/cbc:AllowanceChargeReason") + .with_value("Discount 19.00% portion") + end + + it "has the Amount" do + expect(subject).to contains_xml_node("#{root}/cbc:Amount") + .with_value("10.00") + .with_attribute("currencyID", "USD") + end + + context "when TaxCategory" do + let(:xpath) { "#{root}/cac:TaxCategory" } + + it "has the ID" do + expect(subject).to contains_xml_node("#{xpath}/cbc:ID").with_value("S") + end + + it "has the Percent" do + expect(subject).to contains_xml_node("#{xpath}/cbc:Percent").with_value("19.00") + end + + context "when TaxScheme" do + it "has the ID" do + expect(subject).to contains_xml_node("#{xpath}/cac:TaxScheme/cbc:ID").with_value("VAT") + end + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/billing_reference_spec.rb b/spec/serializers/e_invoices/ubl/billing_reference_spec.rb new file mode 100644 index 0000000..7396f95 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/billing_reference_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::BillingReference do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:) + end + end + + let(:resource) { invoice } + let(:issuing_date) { "2025-03-16".to_date } + let(:invoice) { create(:invoice, issuing_date:) } + let(:root) { "//cac:BillingReference" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Reference to Original Invoice") + end + + context "when InvoiceDocumentReference" do + let(:xpath) { "#{root}/cac:InvoiceDocumentReference" } + + it "have the ID" do + expect(subject).to contains_xml_node("#{xpath}/cbc:ID").with_value(invoice.number) + end + + it "have the IssueDate" do + expect(subject).to contains_xml_node("#{xpath}/cbc:IssueDate").with_value(invoice.issuing_date) + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/customer_party_spec.rb b/spec/serializers/e_invoices/ubl/customer_party_spec.rb new file mode 100644 index 0000000..cd53699 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/customer_party_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::CustomerParty do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:) + end + end + + let(:resource) { invoice } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:customer) do + create(:customer, + organization:, + address_line1: "Streets of Tomorrow", + address_line2: "on AC", + city: "SP", + zipcode: "123654789", + country: "BR", + name: "Andre") + end + + let(:root) { "//cac:AccountingCustomerParty/cac:Party" } + + before { invoice } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Customer Party") + end + + context "with customer" do + context "with PostalAddress" do + let(:xpath) { "#{root}/cac:PostalAddress" } + + it "expects to have street name" do + expect(subject).to contains_xml_node("#{xpath}/cbc:StreetName").with_value(customer.address_line1) + expect(subject).to contains_xml_node("#{xpath}/cbc:AdditionalStreetName").with_value(customer.address_line2) + end + + it "expects to have city" do + expect(subject).to contains_xml_node("#{xpath}/cbc:CityName").with_value(customer.city) + end + + it "expects to have zipcode" do + expect(subject).to contains_xml_node("#{xpath}/cbc:PostalZone").with_value(customer.zipcode) + end + + it "expects to have country" do + expect(subject).to contains_xml_node("#{xpath}/cac:Country/cbc:IdentificationCode") + .with_value(customer.country) + end + end + + context "with PartyLegalEntity" do + let(:xpath) { "#{root}/cac:PartyLegalEntity" } + + it "expects to have registration name" do + expect(subject).to contains_xml_node("#{xpath}/cbc:RegistrationName").with_value(customer.name) + end + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/delivery_spec.rb b/spec/serializers/e_invoices/ubl/delivery_spec.rb new file mode 100644 index 0000000..cd758b4 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/delivery_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::Delivery do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, delivery_date:) + end + end + + let(:resource) { invoice } + let(:delivery_date) { "2025-03-16".to_date } + + let(:root) { "//cac:Delivery" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Delivery Information") + end + + context "when OccurrenceDateTime" do + let(:xpath) { "#{root}/cbc:ActualDeliveryDate" } + + it "have the delivery_date" do + expect(subject).to contains_xml_node(xpath).with_value(delivery_date) + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/document_response_spec.rb b/spec/serializers/e_invoices/ubl/document_response_spec.rb new file mode 100644 index 0000000..8916e0e --- /dev/null +++ b/spec/serializers/e_invoices/ubl/document_response_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::DocumentResponse do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, response:, document:) + end + end + + let(:invoice) { create(:invoice) } + let(:response) do + described_class::Response.new( + code: described_class::PAID, + description: "This is a test response PD", + date: "2025-03-16".to_date + ) + end + let(:document) do + described_class::Document.new( + id: invoice.id, + issue_date: invoice.issuing_date, + type_code: described_class::COMMERCIAL_INVOICE, + type: "Invoice", + description: "Original invoice reference: #{invoice.id}" + ) + end + + let(:root) { "//cac:DocumentResponse" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Document Response") + end + + it "contains DocumentResponse tag" do + expect(subject).to contains_xml_node(root) + end + + context "with Response" do + let(:xpath) { "#{root}/cac:Response" } + + it "expects to have ResponseCode" do + expect(subject).to contains_xml_node("#{xpath}/cbc:ResponseCode").with_value(response.code) + end + + it "expects to have Description" do + expect(subject).to contains_xml_node("#{xpath}/cbc:Description").with_value(response.description) + end + + it "expects to have EffectiveDate" do + expect(subject).to contains_xml_node("#{xpath}/cbc:EffectiveDate").with_value("2025-03-16") + end + end + + context "with DocumentReference" do + let(:xpath) { "#{root}/cac:DocumentReference" } + + it "expects to have ID" do + expect(subject).to contains_xml_node("#{xpath}/cbc:ID").with_value(document.id) + end + + it "expects to have IssueDate" do + expect(subject).to contains_xml_node("#{xpath}/cbc:IssueDate").with_value(document.issue_date) + end + + it "expects to have DocumentTypeCode" do + expect(subject).to contains_xml_node("#{xpath}/cbc:DocumentTypeCode").with_value(document.type_code) + end + + it "expects to have DocumentType" do + expect(subject).to contains_xml_node("#{xpath}/cbc:DocumentType").with_value(document.type) + end + + it "expects to have DocumentDescription" do + expect(subject).to contains_xml_node("#{xpath}/cbc:DocumentDescription").with_value(document.description) + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/header_spec.rb b/spec/serializers/e_invoices/ubl/header_spec.rb new file mode 100644 index 0000000..1446185 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/header_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::Header do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:, type_code:) + end + end + + let(:resource) { create(:invoice, issuing_date: issuing_date.to_date, currency:) } + let(:issuing_date) { "2025-03-16" } + let(:currency) { "USD" } + let(:type_code) { described_class::COMMERCIAL_INVOICE } + + before { resource } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Invoice Header Information") + end + + it "expects to have the resource number" do + expect(subject).to contains_xml_node("//cbc:ID").with_value(resource.number) + end + + it "expects to have resource issuing date" do + expect(subject).to contains_xml_node("//cbc:IssueDate").with_value(issuing_date) + end + + context "with type_codes" do + [ + described_class::COMMERCIAL_INVOICE, + described_class::PREPAID_INVOICE, + described_class::SELF_BILLED_INVOICE + ].each do |invoice_type| + context "when Invoice #{invoice_type}" do + let(:type_code) { invoice_type } + + it "expects to have a type code" do + expect(subject).to contains_xml_node("//cbc:InvoiceTypeCode").with_value(invoice_type) + end + end + end + + [ + described_class::CREDIT_NOTE + ].each do |credit_note_type| + context "when Credit Note #{credit_note_type}" do + let(:type_code) { credit_note_type } + + it "expects to have a type code" do + expect(subject).to contains_xml_node("//cbc:CreditNoteTypeCode").with_value(credit_note_type) + end + end + end + end + + it "expects to have resource currency" do + expect(subject).to contains_xml_node("//cbc:DocumentCurrencyCode") + .with_value(resource.currency) + end + end +end diff --git a/spec/serializers/e_invoices/ubl/line_item_spec.rb b/spec/serializers/e_invoices/ubl/line_item_spec.rb new file mode 100644 index 0000000..85c5f6a --- /dev/null +++ b/spec/serializers/e_invoices/ubl/line_item_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::LineItem do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:, data:) + end + end + + let(:resource) { nil } + let(:data_type) { :invoice } + let(:item_category) { described_class::S_CATEGORY } + let(:item_rate_percent) { 20.0 } + let(:data) do + described_class::Data.new( + type: data_type, + line_id: 1, + quantity: 2, + line_extension_amount: 0.118, + currency: "USD", + item_name: "item name", + item_category:, + item_rate_percent:, + item_description: "fee description", + price_amount: 0.059 + ) + end + + let(:root) { "//cac:InvoiceLine" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Line Item 1: fee description") + end + + it "have the line id" do + expect(subject).to contains_xml_node("#{root}/cbc:ID").with_value(1) + end + + context "with item units" do + context "when InvoicedQuantity" do + it "have the item units" do + expect(subject).to contains_xml_node("#{root}/cbc:InvoicedQuantity").with_value("2.00").with_attribute("unitCode", "C62") + end + end + + context "when CreditedQuantity" do + let(:data_type) { :credit_note } + let(:root) { "//cac:CreditNoteLine" } + + it "have the item units" do + expect(subject).to contains_xml_node("#{root}/cbc:CreditedQuantity").with_value("2.00").with_attribute("unitCode", "C62") + end + end + end + + it "have the item total amount" do + expect(subject).to contains_xml_node("#{root}/cbc:LineExtensionAmount").with_value("0.12").with_attribute("currencyID", "USD") + end + + context "when Item" do + it "have the item name" do + expect(subject).to contains_xml_node("#{root}/cac:Item/cbc:Name").with_value("item name") + end + + context "with ClassifiedTaxCategory" do + context "with Category ID" do + let(:xpath) { "#{root}/cac:Item/cac:ClassifiedTaxCategory/cbc:ID" } + + it "has the category code" do + expect(subject).to contains_xml_node(xpath).with_value("S") + end + end + + context "when Percent" do + it "have the item taxes rate" do + expect(subject).to contains_xml_node( + "#{root}/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent" + ).with_value("20.0") + end + + context "with outside of tax range" do + let(:item_rate_percent) { nil } + + it "do not have percent tag" do + expect(subject).not_to contains_xml_node( + "#{root}/cac:Item/cac:ClassifiedTaxCategory/cbc:Percent" + ) + end + end + end + + it "have the item taxes scheme" do + expect(subject).to contains_xml_node( + "#{root}/cac:Item/cac:ClassifiedTaxCategory/cac:TaxScheme/cbc:ID" + ).with_value("VAT") + end + end + + context "when AdditionalItemProperty" do + it "have the item description" do + expect(subject).to contains_xml_node( + "#{root}/cac:Item/cac:AdditionalItemProperty/cbc:Name" + ).with_value("Description") + + expect(subject).to contains_xml_node( + "#{root}/cac:Item/cac:AdditionalItemProperty/cbc:Value" + ).with_value("fee description") + end + end + end + + context "when Price" do + it "have the item unit amount" do + expect(subject).to contains_xml_node("#{root}/cac:Price/cbc:PriceAmount") + .with_value("0.059") + .with_attribute("currencyID", "USD") + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/monetary_total_spec.rb b/spec/serializers/e_invoices/ubl/monetary_total_spec.rb new file mode 100644 index 0000000..e7a0d40 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/monetary_total_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::MonetaryTotal do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:, amounts:) do + end + end + end + + let(:resource) { invoice } + let(:amounts) do + described_class::Amounts.new( + line_extension_amount: 1000, + tax_exclusive_amount: 990, + tax_inclusive_amount: 1188.84, + allowance_total_amount: 10, + charge_total_amount: 0, + prepaid_amount: 21.86, + payable_amount: 1188.84 + ) + end + let(:invoice) do + create(:invoice, currency: "USD") + end + + let(:root) { "//cac:LegalMonetaryTotal" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Legal Monetary Total") + end + + it "have LineExtensionAmount" do + expect(subject).to contains_xml_node("#{root}/cbc:LineExtensionAmount") + .with_value("1000.00") + .with_attribute("currencyID", "USD") + end + + it "have ChargeTotalAmount and AllowanceTotalAmount" do + expect(subject).to contains_xml_node("#{root}/cbc:ChargeTotalAmount") + .with_value("0.00") + .with_attribute("currencyID", "USD") + expect(subject).to contains_xml_node("#{root}/cbc:AllowanceTotalAmount") + .with_value("10.00") + .with_attribute("currencyID", "USD") + end + + it "have TaxExclusiveAmount" do + expect(subject).to contains_xml_node("#{root}/cbc:TaxExclusiveAmount") + .with_value("990.00") + .with_attribute("currencyID", "USD") + end + + it "have TaxInclusiveAmount" do + expect(subject).to contains_xml_node("#{root}/cbc:TaxInclusiveAmount") + .with_value("1188.84") + .with_attribute("currencyID", "USD") + end + + it "have PrepaidAmount" do + expect(subject).to contains_xml_node("#{root}/cbc:PrepaidAmount") + .with_value("21.86") + .with_attribute("currencyID", "USD") + end + + it "have PayableAmount" do + expect(subject).to contains_xml_node("#{root}/cbc:PayableAmount") + .with_value("1188.84") + .with_attribute("currencyID", "USD") + end + end +end diff --git a/spec/serializers/e_invoices/ubl/payment_means_spec.rb b/spec/serializers/e_invoices/ubl/payment_means_spec.rb new file mode 100644 index 0000000..66000cb --- /dev/null +++ b/spec/serializers/e_invoices/ubl/payment_means_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::PaymentMeans do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, type:, amount:) + end + end + + let(:resource) { invoice } + let(:invoice) { create(:invoice, currency: "USD") } + let(:type) { described_class::STANDARD_PAYMENT } + let(:amount) { Money.new(1000) } + + let(:root) { "//cac:PaymentMeans" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + context "when STANDARD" do + let(:type) { described_class::STANDARD_PAYMENT } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Means: Standard payment") + end + + it "have the payment code and information" do + expect(subject).to contains_xml_node("#{root}/cbc:PaymentMeansCode").with_value(type) + end + end + + context "when PREPAID" do + let(:type) { described_class::PREPAID_PAYMENT } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Means: Prepaid credits") + end + + it "have the payment code and information" do + expect(subject).to contains_xml_node("#{root}/cbc:PaymentMeansCode").with_value(type) + end + end + + context "when CREDIT_NOTE" do + let(:type) { described_class::CREDIT_NOTE_PAYMENT } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Means: Credit notes") + end + + it "have the payment code and information" do + expect(subject).to contains_xml_node("#{root}/cbc:PaymentMeansCode").with_value(type) + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/payment_terms_spec.rb b/spec/serializers/e_invoices/ubl/payment_terms_spec.rb new file mode 100644 index 0000000..8e9e0c0 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/payment_terms_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::PaymentTerms do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, note:) + end + end + + let(:note) { "Payment term 1 days" } + + let(:root) { "//cac:PaymentTerms" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Payment Terms") + end + + it "have Note with payment term days" do + expect(subject).to contains_xml_node("#{root}/cbc:Note").with_value(note) + end + end +end diff --git a/spec/serializers/e_invoices/ubl/receiver_party_spec.rb b/spec/serializers/e_invoices/ubl/receiver_party_spec.rb new file mode 100644 index 0000000..d85c3d7 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/receiver_party_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::ReceiverParty do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:) + end + end + + let(:resource) { create(:payment, customer:) } + let(:customer) do + create( + :customer, + name: "Customer Jr", + address_line1: "Somewhere", + address_line2: "Turn around, wrong way", + city: "Some city", + zipcode: "09876 CE", + country: "BR" + ) + end + + let(:root) { "//cac:ReceiverParty" } + + before { resource } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Receiver Party") + end + + it "contains PartyName tag" do + expect(subject).to contains_xml_node("#{root}/cac:PartyName/cbc:Name").with_value(customer.name) + end + + context "with PostalAddress" do + let(:xpath) { "#{root}/cac:PostalAddress" } + + it "expects to have street name" do + expect(subject).to contains_xml_node("#{xpath}/cbc:StreetName").with_value(customer.address_line1) + expect(subject).to contains_xml_node("#{xpath}/cbc:AdditionalStreetName").with_value(customer.address_line2) + end + + it "expects to have city" do + expect(subject).to contains_xml_node("#{xpath}/cbc:CityName").with_value(customer.city) + end + + it "expects to have zipcode" do + expect(subject).to contains_xml_node("#{xpath}/cbc:PostalZone").with_value(customer.zipcode) + end + + it "expects to have country" do + expect(subject).to contains_xml_node("#{xpath}/cac:Country/cbc:IdentificationCode") + .with_value(customer.country) + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/sender_party_spec.rb b/spec/serializers/e_invoices/ubl/sender_party_spec.rb new file mode 100644 index 0000000..e7f35e8 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/sender_party_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::SenderParty do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:) + end + end + + let(:resource) { create(:payment, customer:) } + let(:customer) { create(:customer, billing_entity:) } + let(:billing_entity) do + create( + :billing_entity, + code: "test_be", + name: "Test BE", + address_line1: "Somewhere", + address_line2: "Far Beyond", + city: "A City", + zipcode: "1234-FE", + country: "BR", + tax_identification_number: "1234BR5678", + email: "lago-be@test.com" + ) + end + + let(:root) { "//cac:SenderParty" } + + before { resource } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Sender Party") + end + + it "contains PartyIdentification tag" do + expect(subject).to contains_xml_node("#{root}/cac:PartyIdentification/cbc:ID").with_value(billing_entity.code) + end + + it "contains PartyName tag" do + expect(subject).to contains_xml_node("#{root}/cac:PartyName/cbc:Name").with_value(billing_entity.name) + end + + context "with PostalAddress" do + let(:xpath) { "#{root}/cac:PostalAddress" } + + it "expects to have street name" do + expect(subject).to contains_xml_node("#{xpath}/cbc:StreetName").with_value(billing_entity.address_line1) + expect(subject).to contains_xml_node("#{xpath}/cbc:AdditionalStreetName").with_value(billing_entity.address_line2) + end + + it "expects to have city" do + expect(subject).to contains_xml_node("#{xpath}/cbc:CityName").with_value(billing_entity.city) + end + + it "expects to have zipcode" do + expect(subject).to contains_xml_node("#{xpath}/cbc:PostalZone").with_value(billing_entity.zipcode) + end + + it "expects to have country" do + expect(subject).to contains_xml_node("#{xpath}/cac:Country/cbc:IdentificationCode") + .with_value(billing_entity.country) + end + end + + context "with PartyTaxScheme" do + let(:xpath) { "#{root}/cac:PartyTaxScheme" } + + it "expects to have company id" do + expect(subject).to contains_xml_node("#{xpath}/cbc:CompanyID").with_value(billing_entity.tax_identification_number) + end + + it "expects to have tax scheme id" do + expect(subject).to contains_xml_node("#{xpath}/cac:TaxScheme/cbc:ID").with_value("VAT") + end + end + + it "contains Contact tag" do + expect(subject).to contains_xml_node("#{root}/cac:Contact/cbc:Name").with_value(billing_entity.name) + expect(subject).to contains_xml_node("#{root}/cac:Contact/cbc:ElectronicMail").with_value(billing_entity.email) + end + end +end diff --git a/spec/serializers/e_invoices/ubl/supplier_party_spec.rb b/spec/serializers/e_invoices/ubl/supplier_party_spec.rb new file mode 100644 index 0000000..0958c5d --- /dev/null +++ b/spec/serializers/e_invoices/ubl/supplier_party_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::SupplierParty do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:, options:) + end + end + + let(:options) { described_class::Options.new } + let(:resource) { invoice } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice) { create(:invoice, organization:, billing_entity:, invoice_type:) } + let(:invoice_type) { :subscription } + let(:billing_entity) do + create(:billing_entity, + organization:, + code: "BE_CODE", + legal_name: "BE Legal Name", + zipcode: "60192460", + address_line1: "Rue quelque part", + address_line2: "Tourne au deuxième angle", + city: "Eine Stadt", + country: "BR", + tax_identification_number: "BR987654321") + end + + let(:root) { "//cac:AccountingSupplierParty/cac:Party" } + + before { invoice } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Supplier Party") + end + + context "with billing entity" do + context "with PostalAddress" do + let(:xpath) { "#{root}/cac:PostalAddress" } + + it "expects to have street name" do + expect(subject).to contains_xml_node("#{xpath}/cbc:StreetName").with_value(billing_entity.address_line1) + expect(subject).to contains_xml_node("#{xpath}/cbc:AdditionalStreetName").with_value(billing_entity.address_line2) + end + + it "expects to have city" do + expect(subject).to contains_xml_node("#{xpath}/cbc:CityName").with_value(billing_entity.city) + end + + it "expects to have zipcode" do + expect(subject).to contains_xml_node("#{xpath}/cbc:PostalZone").with_value(billing_entity.zipcode) + end + + it "expects to have country" do + expect(subject).to contains_xml_node("#{xpath}/cac:Country/cbc:IdentificationCode") + .with_value(billing_entity.country) + end + end + + context "with PartyTaxScheme" do + let(:xpath) { "#{root}/cac:PartyTaxScheme" } + + it "expects to have company id" do + expect(subject).to contains_xml_node("#{xpath}/cbc:CompanyID").with_value(billing_entity.tax_identification_number) + end + + it "expects to have tax scheme id" do + expect(subject).to contains_xml_node("#{xpath}/cac:TaxScheme/cbc:ID").with_value("VAT") + end + + context "with tax_registration as false" do + let(:options) { described_class::Options.new(tax_registration: false) } + + it "does not have the tag" do + expect(subject).not_to contains_xml_node("#{root}/cac:PartyTaxScheme") + end + end + end + + context "with PartyLegalEntity" do + let(:xpath) { "#{root}/cac:PartyLegalEntity" } + + it "expects to have registration name" do + expect(subject).to contains_xml_node("#{xpath}/cbc:RegistrationName").with_value(billing_entity.legal_name) + end + + it "expects to have company id" do + expect(subject).to contains_xml_node("#{xpath}/cbc:CompanyID").with_value(billing_entity.tax_identification_number) + end + end + end + end +end diff --git a/spec/serializers/e_invoices/ubl/tax_subtotal_spec.rb b/spec/serializers/e_invoices/ubl/tax_subtotal_spec.rb new file mode 100644 index 0000000..3cccd26 --- /dev/null +++ b/spec/serializers/e_invoices/ubl/tax_subtotal_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Ubl::TaxSubtotal do + subject do + xml_document(:ubl) do |xml| + described_class.serialize(xml:, resource:, tax_category:, tax_rate:, basis_amount:, tax_amount:) + end + end + + let(:resource) { invoice } + let(:tax_category) { described_class::S_CATEGORY } + let(:tax_rate) { 20.00 } + let(:basis_amount) { 10 } + let(:tax_amount) { basis_amount * (tax_rate / 100) } + let(:invoice) { create(:invoice) } + + let(:root) { "//cac:TaxSubtotal" } + + describe ".serialize" do + it { is_expected.not_to be_nil } + + it "contains section name as comment" do + expect(subject).to contains_xml_comment("Tax Information 20.00% VAT") + end + + it "have the tax calculated amount" do + expect(subject).to contains_xml_node("#{root}/cbc:TaxAmount").with_value("2.00").with_attribute("currencyID", "EUR") + end + + context "when TaxableAmount" do + it "has the tax taxable amount" do + expect(subject).to contains_xml_node("#{root}/cbc:TaxableAmount").with_value("10.00").with_attribute("currencyID", "EUR") + end + end + + it "has the tax type scheme" do + expect(subject).to contains_xml_node("#{root}/cac:TaxCategory/cac:TaxScheme/cbc:ID").with_value("VAT") + end + + context "with tax_category" do + let(:xpath) { "#{root}/cac:TaxCategory/cbc:ID" } + + it "has the S tax category code" do + expect(subject).to contains_xml_node(xpath).with_value(tax_category) + end + + it "has the tax rate applicable percent" do + expect(subject).to contains_xml_node("#{root}/cac:TaxCategory/cbc:Percent").with_value("20.00") + end + + context "when O category code" do + let(:tax_category) { described_class::O_CATEGORY } + + it "has the O category code" do + expect(subject).to contains_xml_node(xpath).with_value(tax_category) + end + + it "has TaxExemptionReasonCode and TaxExemptionReason" do + expect(subject).to contains_xml_node("#{root}/cac:TaxCategory/cbc:TaxExemptionReasonCode").with_value("VATEX-EU-O") + expect(subject).to contains_xml_node("#{root}/cac:TaxCategory/cbc:TaxExemptionReason").with_value("Not subject to VAT") + end + + it "does not has Percent" do + expect(subject).not_to contains_xml_node("#{root}/cac:TaxCategory/cbc:Percent") + end + end + end + end +end diff --git a/spec/serializers/error_serializer_spec.rb b/spec/serializers/error_serializer_spec.rb new file mode 100644 index 0000000..13ee0a3 --- /dev/null +++ b/spec/serializers/error_serializer_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ErrorSerializer do + subject(:serializer) { described_class.new(error) } + + let(:error) { StandardError.new("Something went wrong") } + + it "has a default serialization" do + expect(serializer.error).to eq(error) + expect(serializer.serialize).to eq( + message: "Something went wrong" + ) + end +end diff --git a/spec/serializers/model_serializer_spec.rb b/spec/serializers/model_serializer_spec.rb new file mode 100644 index 0000000..4592740 --- /dev/null +++ b/spec/serializers/model_serializer_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +RSpec.describe ModelSerializer do + let(:serializer) { described_class.new(model, options) } + + let(:model) { double } + + describe "#include?" do + # rubocop:disable RSpec/PredicateMatcher + context "when includes is blank" do + let(:options) { {includes: []} } + + it "returns false" do + expect(serializer.include?(:id)).to be_falsey + end + end + + context "when includes is not blank" do + let(:model) { double } + + context "with flat includes" do + let(:options) { {includes: [:id, :name]} } + + it "returns true when the value is included" do + expect(serializer.include?(:id)).to be_truthy + end + + it "returns false when the value is not included" do + expect(serializer.include?(:email)).to be_falsey + end + end + + context "with nested includes" do + let(:options) { {includes: [:id, {name: [:first, :last]}]} } + + it "returns true for included attributes" do + expect(serializer.include?(:id)).to be_truthy + end + + it "returns true for included associations" do + expect(serializer.include?(:name)).to be_truthy + end + + it "returns false for nested attributes" do + expect(serializer.include?(:first)).to be_falsey + end + + it "returns false for unknown attributes" do + expect(serializer.include?(:foo)).to be_falsey + end + end + end + # rubocop:enable RSpec/PredicateMatcher + end + + describe "#included_relations" do + context "when includes is blank" do + let(:options) { {includes: []} } + + it "returns an empty array" do + expect(serializer.included_relations(:id)).to eq([]) + end + + context "with a default value" do + let(:options) { {includes: []} } + + it "returns an empty array" do + expect(serializer.included_relations(:id, default: [:id])).to eq([:id]) + end + end + end + + context "when includes is not blank" do + context "with flat includes" do + let(:options) { {includes: [:id, :name]} } + + it "returns an empty array for symbols" do + expect(serializer.included_relations(:id)).to eq([]) + end + + context "with a default value" do + let(:options) { {includes: [:id, :name]} } + + it "returns an empty array" do + expect(serializer.included_relations(:name, default: [:first, :last])).to eq([:first, :last]) + end + end + end + + context "with nested includes" do + let(:options) { {includes: [:id, {name: [:first, :last]}]} } + + it "returns an array of included attributes" do + expect(serializer.included_relations(:name)).to eq([:first, :last]) + end + end + + context "when include is not found" do + let(:options) { {includes: [:id]} } + + it "returns an empty array" do + expect(serializer.included_relations(:name)).to eq([]) + end + end + end + end +end diff --git a/spec/serializers/v1/activity_log_serializer_spec.rb b/spec/serializers/v1/activity_log_serializer_spec.rb new file mode 100644 index 0000000..80bc038 --- /dev/null +++ b/spec/serializers/v1/activity_log_serializer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::ActivityLogSerializer, clickhouse: true do + subject(:serializer) { described_class.new(activity_log, root_name: "activity_log") } + + let(:activity_log) { create(:clickhouse_activity_log) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["activity_log"]["activity_id"]).to eq(activity_log.activity_id) + expect(result["activity_log"]["activity_type"]).to eq(activity_log.activity_type) + expect(result["activity_log"]["activity_source"]).to eq(activity_log.activity_source) + expect(result["activity_log"]["activity_object"]).to eq(activity_log.activity_object) + expect(result["activity_log"]["activity_object_changes"]).to eq(activity_log.activity_object_changes) + expect(result["activity_log"]["user_email"]).to eq(activity_log.user.email) + expect(result["activity_log"]["resource_id"]).to eq(activity_log.resource_id) + expect(result["activity_log"]["resource_type"]).to eq(activity_log.resource_type) + expect(result["activity_log"]["external_customer_id"]).to eq(activity_log.external_customer_id) + expect(result["activity_log"]["external_subscription_id"]).to eq(activity_log.external_subscription_id) + expect(result["activity_log"]["logged_at"]).to eq(activity_log.logged_at.iso8601) + expect(result["activity_log"]["created_at"]).to eq(activity_log.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/add_on_serializer_spec.rb b/spec/serializers/v1/add_on_serializer_spec.rb new file mode 100644 index 0000000..59fd41d --- /dev/null +++ b/spec/serializers/v1/add_on_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::AddOnSerializer do + subject(:serializer) { described_class.new(add_on, root_name: "add_on") } + + let(:add_on) { create(:add_on) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["add_on"]["lago_id"]).to eq(add_on.id) + expect(result["add_on"]["name"]).to eq(add_on.name) + expect(result["add_on"]["invoice_display_name"]).to eq(add_on.invoice_display_name) + expect(result["add_on"]["code"]).to eq(add_on.code) + expect(result["add_on"]["amount_cents"]).to eq(add_on.amount_cents) + expect(result["add_on"]["amount_currency"]).to eq(add_on.amount_currency) + expect(result["add_on"]["description"]).to eq(add_on.description) + expect(result["add_on"]["created_at"]).to eq(add_on.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/analytics/gross_revenue_serializer_spec.rb b/spec/serializers/v1/analytics/gross_revenue_serializer_spec.rb new file mode 100644 index 0000000..3d1ba6a --- /dev/null +++ b/spec/serializers/v1/analytics/gross_revenue_serializer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Analytics::GrossRevenueSerializer do + subject(:serializer) { described_class.new(gross_revenue, root_name: "gross_revenue") } + + let(:gross_revenue) do + { + "month" => "2024-06-01T00:00:00Z", + "amount_cents" => 100, + "currency" => "EUR", + "invoices_count" => 1 + } + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the gross revenue" do + expect(result["gross_revenue"]).to eq( + { + "month" => "2024-06-01T00:00:00Z", + "amount_cents" => 100, + "currency" => "EUR", + "invoices_count" => 1 + } + ) + end +end diff --git a/spec/serializers/v1/analytics/invoice_collection_serializer_spec.rb b/spec/serializers/v1/analytics/invoice_collection_serializer_spec.rb new file mode 100644 index 0000000..998aba8 --- /dev/null +++ b/spec/serializers/v1/analytics/invoice_collection_serializer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Analytics::InvoiceCollectionSerializer do + subject(:serializer) { described_class.new(invoice_collection, root_name: "invoice_collection") } + + let(:invoice_collection) do + { + "month" => Time.current.beginning_of_month.iso8601, + "payment_status" => "succeeded", + "invoices_count" => 1, + "currency" => "EUR", + "amount_cents" => 100 + } + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the invoice collection" do + expect(result["invoice_collection"]["month"]).to eq(Time.current.beginning_of_month.iso8601) + expect(result["invoice_collection"]["payment_status"]).to eq("succeeded") + expect(result["invoice_collection"]["invoices_count"]).to eq(1) + expect(result["invoice_collection"]["currency"]).to eq("EUR") + expect(result["invoice_collection"]["amount_cents"]).to eq(100) + end +end diff --git a/spec/serializers/v1/analytics/invoiced_usage_serializer_spec.rb b/spec/serializers/v1/analytics/invoiced_usage_serializer_spec.rb new file mode 100644 index 0000000..b5d7ab3 --- /dev/null +++ b/spec/serializers/v1/analytics/invoiced_usage_serializer_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Analytics::InvoicedUsageSerializer do + subject(:serializer) { described_class.new(invoiced_usage, root_name: "invoiced_usage") } + + let(:invoiced_usage) do + { + "month" => Time.current.beginning_of_month.iso8601, + "code" => "count_bm", + "currency" => "EUR", + "amount_cents" => 100 + } + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the gross revenue" do + expect(result["invoiced_usage"]["month"]).to eq(Time.current.beginning_of_month.iso8601) + expect(result["invoiced_usage"]["code"]).to eq("count_bm") + expect(result["invoiced_usage"]["currency"]).to eq("EUR") + expect(result["invoiced_usage"]["amount_cents"]).to eq(100) + end +end diff --git a/spec/serializers/v1/analytics/mrr_serializer_spec.rb b/spec/serializers/v1/analytics/mrr_serializer_spec.rb new file mode 100644 index 0000000..3320a35 --- /dev/null +++ b/spec/serializers/v1/analytics/mrr_serializer_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Analytics::MrrSerializer do + subject(:serializer) { described_class.new(mrr, root_name: "mrr") } + + let(:mrr) do + { + "month" => Time.current.beginning_of_month.iso8601, + "amount_cents" => 100, + "currency" => "EUR" + } + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the gross revenue" do + expect(result["mrr"]["month"]).to eq(Time.current.beginning_of_month.iso8601) + expect(result["mrr"]["amount_cents"]).to eq(100) + expect(result["mrr"]["currency"]).to eq("EUR") + end +end diff --git a/spec/serializers/v1/analytics/overdue_balance_serializer_spec.rb b/spec/serializers/v1/analytics/overdue_balance_serializer_spec.rb new file mode 100644 index 0000000..fbeb7fa --- /dev/null +++ b/spec/serializers/v1/analytics/overdue_balance_serializer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Analytics::OverdueBalanceSerializer do + subject(:serializer) { described_class.new(overdue_balance, root_name: "overdue_balance") } + + let(:overdue_balance) do + { + "month" => "2024-06-01T00:00:00Z", + "amount_cents" => 100, + "currency" => "EUR", + "lago_invoice_ids" => "[\"1\", \"2\", \"3\"]" + } + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the overdue balance" do + expect(result["overdue_balance"]).to eq( + { + "month" => "2024-06-01T00:00:00Z", + "amount_cents" => 100, + "currency" => "EUR", + "lago_invoice_ids" => ["1", "2", "3"] + } + ) + end +end diff --git a/spec/serializers/v1/api_log_serializer_spec.rb b/spec/serializers/v1/api_log_serializer_spec.rb new file mode 100644 index 0000000..f385e48 --- /dev/null +++ b/spec/serializers/v1/api_log_serializer_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::ApiLogSerializer, clickhouse: true do + subject(:serializer) { described_class.new(api_log, root_name: "api_log") } + + let(:api_log) { create(:clickhouse_api_log) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["api_log"]["request_id"]).to eq(api_log.request_id) + expect(result["api_log"]["client"]).to eq(api_log.client) + expect(result["api_log"]["http_method"]).to eq(api_log.http_method) + expect(result["api_log"]["http_status"]).to eq(api_log.http_status) + expect(result["api_log"]["request_origin"]).to eq(api_log.request_origin) + expect(result["api_log"]["request_path"]).to eq(api_log.request_path) + expect(result["api_log"]["request_body"]).to eq(api_log.request_body) + expect(result["api_log"]["request_response"]).to eq(api_log.request_response) + expect(result["api_log"]["api_version"]).to eq(api_log.api_version) + expect(result["api_log"]["logged_at"]).to eq(api_log.logged_at.iso8601) + expect(result["api_log"]["created_at"]).to eq(api_log.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/applicable_usage_threshold_serializer_spec.rb b/spec/serializers/v1/applicable_usage_threshold_serializer_spec.rb new file mode 100644 index 0000000..843ffd5 --- /dev/null +++ b/spec/serializers/v1/applicable_usage_threshold_serializer_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::ApplicableUsageThresholdSerializer do + subject(:serializer) { described_class.new(usage_threshold, root_name: "applicable_usage_threshold") } + + let(:usage_threshold) { create(:usage_threshold) } + + it "serializes the object without lago_id, created_at, and updated_at" do + result = JSON.parse(serializer.to_json) + + expect(result["applicable_usage_threshold"]).to eq( + "threshold_display_name" => usage_threshold.threshold_display_name, + "amount_cents" => usage_threshold.amount_cents, + "recurring" => usage_threshold.recurring? + ) + end +end diff --git a/spec/serializers/v1/applied_coupon_serializer_spec.rb b/spec/serializers/v1/applied_coupon_serializer_spec.rb new file mode 100644 index 0000000..982f462 --- /dev/null +++ b/spec/serializers/v1/applied_coupon_serializer_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::AppliedCouponSerializer do + subject(:serializer) { described_class.new(applied_coupon, root_name: "applied_coupon", includes: %i[credits]) } + + let(:applied_coupon) { create(:applied_coupon) } + let(:credit) { create(:credit, amount_cents: 50, applied_coupon:) } + + before { credit } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + credit = applied_coupon.credits.first + + expect(result["applied_coupon"]["lago_id"]).to eq(applied_coupon.id) + expect(result["applied_coupon"]["lago_coupon_id"]).to eq(applied_coupon.coupon.id) + expect(result["applied_coupon"]["coupon_code"]).to eq(applied_coupon.coupon.code) + expect(result["applied_coupon"]["coupon_name"]).to eq(applied_coupon.coupon.name) + expect(result["applied_coupon"]["coupon_description"]).to eq(applied_coupon.coupon.description) + expect(result["applied_coupon"]["coupon_status"]).to eq(applied_coupon.coupon.status) + expect(result["applied_coupon"]["coupon_deleted_at"]).to eq(applied_coupon.coupon.deleted_at&.iso8601) + expect(result["applied_coupon"]["lago_customer_id"]).to eq(applied_coupon.customer.id) + expect(result["applied_coupon"]["external_customer_id"]).to eq(applied_coupon.customer.external_id) + expect(result["applied_coupon"]["status"]).to eq(applied_coupon.status) + expect(result["applied_coupon"]["amount_cents"]).to eq(applied_coupon.amount_cents) + expect(result["applied_coupon"]["amount_cents_remaining"]) + .to eq(applied_coupon.amount_cents - applied_coupon.credits.sum(:amount_cents)) + expect(result["applied_coupon"]["amount_currency"]).to eq(applied_coupon.amount_currency) + expect(result["applied_coupon"]["percentage_rate"]).to eq(applied_coupon.percentage_rate) + expect(result["applied_coupon"]["frequency"]).to eq(applied_coupon.frequency) + expect(result["applied_coupon"]["frequency_duration"]).to eq(applied_coupon.frequency_duration) + expect(result["applied_coupon"]["frequency_duration_remaining"]) + .to eq(applied_coupon.frequency_duration_remaining) + expect(result["applied_coupon"]["expiration_at"]).to eq(applied_coupon.coupon.expiration_at&.iso8601) + expect(result["applied_coupon"]["created_at"]).to eq(applied_coupon.created_at&.iso8601) + expect(result["applied_coupon"]["terminated_at"]).to eq(applied_coupon.terminated_at&.iso8601) + + expect(result["applied_coupon"]["credits"].first["lago_id"]).to eq(credit.id) + expect(result["applied_coupon"]["credits"].first["amount_cents"]).to eq(credit.amount_cents) + expect(result["applied_coupon"]["credits"].first["amount_currency"]).to eq(credit.amount_currency) + expect(result["applied_coupon"]["credits"].first["item"]["type"]).to eq(credit.item_type) + expect(result["applied_coupon"]["credits"].first["item"]["code"]).to eq(credit.item_code) + expect(result["applied_coupon"]["credits"].first["item"]["name"]).to eq(credit.item_name) + expect(result["applied_coupon"]["credits"].first["invoice"]["payment_status"]) + .to eq(credit.invoice.payment_status) + expect(result["applied_coupon"]["credits"].first["invoice"]["lago_id"]).to eq(credit.invoice.id) + end +end diff --git a/spec/serializers/v1/applied_invoice_custom_section_serializer_spec.rb b/spec/serializers/v1/applied_invoice_custom_section_serializer_spec.rb new file mode 100644 index 0000000..3ee9717 --- /dev/null +++ b/spec/serializers/v1/applied_invoice_custom_section_serializer_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::AppliedInvoiceCustomSectionSerializer do + subject(:serializer) { described_class.new(applied_invoice_custom_section) } + + let(:subscription) { create(:subscription) } + let(:applied_invoice_custom_section) do + create(:subscription_applied_invoice_custom_section, subscription:) + end + + describe "#serialize" do + let(:invoice_custom_section) { applied_invoice_custom_section.invoice_custom_section } + + it "serializes the applied invoice custom section correctly" do + serialized_data = serializer.serialize + + expect(serialized_data[:lago_id]).to eq(applied_invoice_custom_section.id) + expect(serialized_data[:created_at]).to eq(applied_invoice_custom_section.created_at.iso8601) + expect(serialized_data[:invoice_custom_section_id]).to eq(applied_invoice_custom_section.invoice_custom_section_id) + expect(serialized_data[:invoice_custom_section]).to eq( + lago_id: invoice_custom_section.id, + code: invoice_custom_section.code, + name: invoice_custom_section.name, + description: invoice_custom_section.description, + details: invoice_custom_section.details, + display_name: invoice_custom_section.display_name, + organization_id: invoice_custom_section.organization_id + ) + end + end +end diff --git a/spec/serializers/v1/applied_usage_threshold_serializer_spec.rb b/spec/serializers/v1/applied_usage_threshold_serializer_spec.rb new file mode 100644 index 0000000..cee4dc6 --- /dev/null +++ b/spec/serializers/v1/applied_usage_threshold_serializer_spec.rb @@ -0,0 +1,25 @@ +# frozen_String_literal: true + +require "rails_helper" + +RSpec.describe V1::AppliedUsageThresholdSerializer do + subject(:serializer) { described_class.new(applied_usage_threshold, root_name: "applied_usage_threshold") } + + let(:applied_usage_threshold) { create(:applied_usage_threshold) } + + it "serialize the object" do + result = JSON.parse(serializer.to_json) + + expect(result["applied_usage_threshold"]).to include( + "lifetime_usage_amount_cents" => applied_usage_threshold.lifetime_usage_amount_cents, + "created_at" => applied_usage_threshold.created_at.iso8601 + ) + + expect(result["applied_usage_threshold"]["usage_threshold"]).to include( + "lago_id" => applied_usage_threshold.usage_threshold.id, + "threshold_display_name" => applied_usage_threshold.usage_threshold.threshold_display_name, + "amount_cents" => applied_usage_threshold.usage_threshold.amount_cents, + "recurring" => applied_usage_threshold.usage_threshold.recurring + ) + end +end diff --git a/spec/serializers/v1/billable_metric_expression_result_serializer_spec.rb b/spec/serializers/v1/billable_metric_expression_result_serializer_spec.rb new file mode 100644 index 0000000..2b7c716 --- /dev/null +++ b/spec/serializers/v1/billable_metric_expression_result_serializer_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::BillableMetricExpressionResultSerializer do + subject(:serializer) { described_class.new(billable_metric_expression_result, root_name: "expression_result") } + + let(:billable_metric_expression_result) { BaseService::Result.new.tap { it.evaluation_result = "1.0" } } + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the object" do + expect(result["expression_result"]["value"]).to eq("1.0") + end +end diff --git a/spec/serializers/v1/billable_metric_filter_serializer_spec.rb b/spec/serializers/v1/billable_metric_filter_serializer_spec.rb new file mode 100644 index 0000000..554cdc0 --- /dev/null +++ b/spec/serializers/v1/billable_metric_filter_serializer_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::BillableMetricFilterSerializer do + subject(:serializer) { described_class.new(billable_metric_filter, root_name: "billable_metric_filter") } + + let(:billable_metric_filter) { create(:billable_metric_filter) } + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the object" do + expect(result["billable_metric_filter"]).to include( + "key" => billable_metric_filter.key, + "values" => billable_metric_filter.values.sort + ) + end +end diff --git a/spec/serializers/v1/billable_metric_serializer_spec.rb b/spec/serializers/v1/billable_metric_serializer_spec.rb new file mode 100644 index 0000000..713283d --- /dev/null +++ b/spec/serializers/v1/billable_metric_serializer_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::BillableMetricSerializer do + subject(:serializer) { described_class.new(billable_metric, root_name: "billable_metric", includes:) } + + let(:billable_metric) { create(:weighted_sum_billable_metric) } + let(:result) { JSON.parse(serializer.to_json) } + + let(:includes) { %i[] } + + it "serializes the object" do + expect(result["billable_metric"]["lago_id"]).to eq(billable_metric.id) + expect(result["billable_metric"]["name"]).to eq(billable_metric.name) + expect(result["billable_metric"]["code"]).to eq(billable_metric.code) + expect(result["billable_metric"]["description"]).to eq(billable_metric.description) + expect(result["billable_metric"]["aggregation_type"]).to eq(billable_metric.aggregation_type) + expect(result["billable_metric"]["field_name"]).to eq(billable_metric.field_name) + expect(result["billable_metric"]["created_at"]).to eq(billable_metric.created_at.iso8601) + expect(result["billable_metric"]["rounding_function"]).to eq(billable_metric.rounding_function) + expect(result["billable_metric"]["rounding_precision"]).to eq(billable_metric.rounding_precision) + expect(result["billable_metric"]["weighted_interval"]).to eq(billable_metric.weighted_interval) + expect(result["billable_metric"]["expression"]).to eq(billable_metric.expression) + + expect(result["billable_metric"]["filters"]).to eq([]) + end + + context "with counters inclusion" do + let(:includes) { %i[counters] } + + it "returns a zero count for number of active subscriptions" do + terminated_subscription = create(:subscription, :terminated) + create(:standard_charge, plan: terminated_subscription.plan, billable_metric:) + + subscription = create(:subscription) + create(:standard_charge, plan: subscription.plan, billable_metric:) + + expect(result["billable_metric"]["active_subscriptions_count"]).to eq(0) + end + + it "returns a zero count for number of draft invoices" do + customer = create(:customer, organization: billable_metric.organization) + subscription = create(:subscription) + subscription2 = create(:subscription) + charge = create(:standard_charge, plan: subscription.plan, billable_metric:) + charge2 = create(:standard_charge, plan: subscription2.plan, billable_metric:) + + invoice = create(:invoice, customer:, organization: billable_metric.organization) + create(:fee, invoice:, charge:) + + draft_invoice = create(:invoice, :draft, customer:, organization: billable_metric.organization) + create(:fee, invoice: draft_invoice, charge: charge2) + create(:fee, invoice: draft_invoice, charge: charge2) + + expect(result["billable_metric"]["draft_invoices_count"]).to eq(0) + end + + it "returns a zero number of plans" do + plan = create(:plan, organization: billable_metric.organization) + create(:standard_charge, billable_metric:, plan:) + + expect(result["billable_metric"]["plans_count"]).to eq(0) + end + end +end diff --git a/spec/serializers/v1/billing_entity_serializer_spec.rb b/spec/serializers/v1/billing_entity_serializer_spec.rb new file mode 100644 index 0000000..97badcc --- /dev/null +++ b/spec/serializers/v1/billing_entity_serializer_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::BillingEntitySerializer do + subject(:serializer) { described_class.new(billing_entity, root_name: "billing_entity", includes: includes_options) } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:result) { JSON.parse(serializer.to_json) } + let(:includes_options) { nil } + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + + before do + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section:) + end + + it "serializes the billing entity" do + billing_entity_serialized = result["billing_entity"] + + expect(billing_entity_serialized.fetch("lago_id")).to eq(billing_entity.id) + expect(billing_entity_serialized.fetch("code")).to eq(billing_entity.code) + expect(billing_entity_serialized.fetch("name")).to eq(billing_entity.name) + expect(billing_entity_serialized.fetch("default_currency")).to eq(billing_entity.default_currency) + expect(billing_entity_serialized.fetch("created_at")).to eq(billing_entity.created_at.iso8601) + expect(billing_entity_serialized.fetch("updated_at")).to eq(billing_entity.updated_at.iso8601) + expect(billing_entity_serialized.fetch("country")).to eq(billing_entity.country) + expect(billing_entity_serialized.fetch("address_line1")).to eq(billing_entity.address_line1) + expect(billing_entity_serialized.fetch("address_line2")).to eq(billing_entity.address_line2) + expect(billing_entity_serialized.fetch("city")).to eq(billing_entity.city) + expect(billing_entity_serialized.fetch("state")).to eq(billing_entity.state) + expect(billing_entity_serialized.fetch("zipcode")).to eq(billing_entity.zipcode) + expect(billing_entity_serialized.fetch("email")).to eq(billing_entity.email) + expect(billing_entity_serialized.fetch("einvoicing")).to eq(billing_entity.einvoicing) + expect(billing_entity_serialized.fetch("legal_name")).to eq(billing_entity.legal_name) + expect(billing_entity_serialized.fetch("legal_number")).to eq(billing_entity.legal_number) + expect(billing_entity_serialized.fetch("timezone")).to eq(billing_entity.timezone) + expect(billing_entity_serialized.fetch("net_payment_term")).to eq(billing_entity.net_payment_term) + expect(billing_entity_serialized.fetch("email_settings")).to eq(billing_entity.email_settings) + expect(billing_entity_serialized.fetch("document_numbering")).to eq(billing_entity.document_numbering) + expect(billing_entity_serialized.fetch("document_number_prefix")).to eq(billing_entity.document_number_prefix) + expect(billing_entity_serialized.fetch("tax_identification_number")).to eq(billing_entity.tax_identification_number) + expect(billing_entity_serialized.fetch("finalize_zero_amount_invoice")).to eq(billing_entity.finalize_zero_amount_invoice) + expect(billing_entity_serialized.fetch("invoice_footer")).to eq(billing_entity.invoice_footer) + expect(billing_entity_serialized.fetch("invoice_grace_period")).to eq(billing_entity.invoice_grace_period) + expect(billing_entity_serialized.fetch("subscription_invoice_issuing_date_adjustment")).to eq(billing_entity.subscription_invoice_issuing_date_adjustment) + expect(billing_entity_serialized.fetch("subscription_invoice_issuing_date_anchor")).to eq(billing_entity.subscription_invoice_issuing_date_anchor) + expect(billing_entity_serialized.fetch("document_locale")).to eq(billing_entity.document_locale) + expect(billing_entity_serialized.fetch("is_default")).to eq(billing_entity.organization.default_billing_entity.id == billing_entity.id) + expect(billing_entity_serialized.fetch("eu_tax_management")).to eq(billing_entity.eu_tax_management) + expect(billing_entity_serialized.fetch("logo_url")).to eq(billing_entity.logo_url) + expect(billing_entity_serialized["taxes"]).to be_nil + expect(billing_entity_serialized["selected_invoice_custom_sections"]).to be_nil + end + + context "when including invoice custom sections" do + let(:includes_options) { [:selected_invoice_custom_sections] } + + it "serializes the applicable invoice custom sections" do + billing_entity_serialized = result["billing_entity"] + expect(billing_entity_serialized["selected_invoice_custom_sections"].count).to eq(1) + expect(billing_entity_serialized["selected_invoice_custom_sections"].first.fetch("lago_id")).to eq(invoice_custom_section.id) + end + end + + context "when including taxes" do + let(:includes_options) { [:taxes] } + + it "serializes the taxes" do + billing_entity_serialized = result["billing_entity"] + expect(billing_entity_serialized.fetch("taxes")).to be_empty + end + + context "when billing entity has applied taxes" do + let(:tax) { create(:tax) } + let(:applied_tax) { create(:billing_entity_applied_tax, billing_entity:, tax:) } + + before { applied_tax } + + it "serializes the applied taxes" do + billing_entity_serialized = result["billing_entity"] + expect(billing_entity_serialized.fetch("taxes").count).to eq(1) + + serialized_tax = billing_entity_serialized.fetch("taxes").first + expect(serialized_tax.fetch("lago_id")).to eq(tax.id) + expect(serialized_tax.fetch("code")).to eq(tax.code) + expect(serialized_tax.fetch("name")).to eq(tax.name) + expect(serialized_tax.fetch("rate")).to eq(tax.rate) + expect(serialized_tax.fetch("description")).to eq(tax.description) + expect(serialized_tax.fetch("created_at")).to eq(tax.created_at.iso8601) + end + end + end +end diff --git a/spec/serializers/v1/charge_filter_serializer_spec.rb b/spec/serializers/v1/charge_filter_serializer_spec.rb new file mode 100644 index 0000000..c649817 --- /dev/null +++ b/spec/serializers/v1/charge_filter_serializer_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::ChargeFilterSerializer do + subject(:serializer) { described_class.new(charge_filter, root_name: "filter") } + + let(:charge_filter) { create(:charge_filter, properties:) } + let(:properties) { {"amount" => "1000"} } + let(:filter) { create(:billable_metric_filter) } + + let(:filter_value) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: [filter.values.first] + ) + end + + before { filter_value } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["filter"]["lago_id"]).to eq(charge_filter.id) + expect(result["filter"]["charge_code"]).to eq(charge_filter.charge.code) + expect(result["filter"]["invoice_display_name"]).to eq(charge_filter.invoice_display_name) + expect(result["filter"]["properties"]).to eq(charge_filter.properties) + expect(result["filter"]["values"]).to eq( + { + filter.key => filter_value.values + } + ) + end + + # TODO(pricing_group_keys): remove after deprecation of grouped_by + context "with grouped_by" do + let(:properties) { {"amount" => "1000", "grouped_by" => ["user_id"]} } + + it "serializes the grouped_by properties" do + result = JSON.parse(serializer.to_json) + expect(result["filter"]["properties"]["grouped_by"]).to eq(["user_id"]) + expect(result["filter"]["properties"]["pricing_group_keys"]).to eq(["user_id"]) + end + end + + context "with pricing_group_keys" do + let(:properties) { {"amount" => "1000", "pricing_group_keys" => ["user_id"]} } + + it "serializes the grouped_by properties" do + result = JSON.parse(serializer.to_json) + expect(result["filter"]["properties"]["grouped_by"]).to eq(["user_id"]) + expect(result["filter"]["properties"]["pricing_group_keys"]).to eq(["user_id"]) + end + end +end diff --git a/spec/serializers/v1/charge_serializer_spec.rb b/spec/serializers/v1/charge_serializer_spec.rb new file mode 100644 index 0000000..e5fb2b2 --- /dev/null +++ b/spec/serializers/v1/charge_serializer_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::ChargeSerializer do + subject(:result) { JSON.parse(serializer.to_json) } + + let(:serializer) { described_class.new(charge, root_name: "charge", includes: %i[taxes]) } + + let(:charge) { create(:standard_charge, properties:) } + let(:properties) { {"amount" => "1000"} } + + it "serializes the object" do + expect(result["charge"]["lago_id"]).to eq(charge.id) + expect(result["charge"]["lago_billable_metric_id"]).to eq(charge.billable_metric_id) + expect(result["charge"]["code"]).to eq(charge.code) + expect(result["charge"]["invoice_display_name"]).to eq(charge.invoice_display_name) + expect(result["charge"]["billable_metric_code"]).to eq(charge.billable_metric.code) + expect(result["charge"]["created_at"]).to eq(charge.created_at.iso8601) + expect(result["charge"]["charge_model"]).to eq(charge.charge_model) + expect(result["charge"]["pay_in_advance"]).to eq(charge.pay_in_advance) + expect(result["charge"]["accepts_target_wallet"]).to eq(charge.accepts_target_wallet) + expect(result["charge"]["properties"]).to eq(charge.properties) + expect(result["charge"]["filters"]).to eq([]) + expect(result["charge"]["applied_pricing_unit"]).to eq nil + expect(result["charge"]["lago_parent_id"]).to eq(charge.parent_id) + expect(result["charge"]["taxes"]).to eq([]) + end + + context "when charge configured to use pricing units" do + let!(:applied_pricing_unit) { create(:applied_pricing_unit, pricing_unitable: charge) } + + it "serializes the object" do + expect(result["charge"]["lago_id"]).to eq(charge.id) + expect(result["charge"]["lago_billable_metric_id"]).to eq(charge.billable_metric_id) + expect(result["charge"]["invoice_display_name"]).to eq(charge.invoice_display_name) + expect(result["charge"]["billable_metric_code"]).to eq(charge.billable_metric.code) + expect(result["charge"]["created_at"]).to eq(charge.created_at.iso8601) + expect(result["charge"]["charge_model"]).to eq(charge.charge_model) + expect(result["charge"]["pay_in_advance"]).to eq(charge.pay_in_advance) + expect(result["charge"]["properties"]).to eq(charge.properties) + expect(result["charge"]["filters"]).to eq([]) + expect(result["charge"]["taxes"]).to eq([]) + + expect(result["charge"]["applied_pricing_unit"]).to eq({ + "conversion_rate" => applied_pricing_unit.conversion_rate.to_s, + "code" => applied_pricing_unit.pricing_unit.code + }) + end + end + + # TODO(pricing_group_keys): remove after deprecation of grouped_by + context "with grouped_by" do + let(:properties) { {"amount" => "1000", "grouped_by" => ["user_id"]} } + + it "serializes the grouped_by properties" do + expect(result["charge"]["properties"]["grouped_by"]).to eq(["user_id"]) + expect(result["charge"]["properties"]["pricing_group_keys"]).to eq(["user_id"]) + end + end + + context "with pricing_group_keys" do + let(:properties) { {"amount" => "1000", "pricing_group_keys" => ["user_id"]} } + + it "serializes the grouped_by properties" do + expect(result["charge"]["properties"]["grouped_by"]).to eq(["user_id"]) + expect(result["charge"]["properties"]["pricing_group_keys"]).to eq(["user_id"]) + end + end +end diff --git a/spec/serializers/v1/commitment_serializer_spec.rb b/spec/serializers/v1/commitment_serializer_spec.rb new file mode 100644 index 0000000..51ff163 --- /dev/null +++ b/spec/serializers/v1/commitment_serializer_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::CommitmentSerializer do + subject(:serializer) do + described_class.new(commitment, root_name: "commitment", includes: %i[taxes]) + end + + let(:commitment) { create(:commitment) } + let(:tax) { create(:tax, organization: commitment.plan.organization) } + let(:commitment_applied_tax) { create(:commitment_applied_tax, commitment:, tax:) } + + let(:commitment_hash) do + { + "lago_id" => commitment.id, + "plan_code" => commitment.plan.code, + "invoice_display_name" => commitment.invoice_display_name, + "commitment_type" => commitment.commitment_type, + "amount_cents" => commitment.amount_cents, + "interval" => commitment.plan.interval, + "created_at" => commitment.created_at.iso8601, + "updated_at" => commitment.updated_at.iso8601 + } + end + + let(:commitment_tax_hash) do + { + "lago_id" => tax.id, + "name" => tax.name, + "code" => tax.code, + "rate" => tax.rate, + "description" => tax.description, + "applied_to_organization" => tax.applied_to_organization, + "commitments_count" => 0 + } + end + + before { commitment_applied_tax } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["commitment"]).to include(commitment_hash) + end + + it "serializes taxes" do + result = JSON.parse(serializer.to_json) + + expect(result["commitment"]["taxes"].first).to include(commitment_tax_hash) + end +end diff --git a/spec/serializers/v1/coupon_serializer_spec.rb b/spec/serializers/v1/coupon_serializer_spec.rb new file mode 100644 index 0000000..c3cb76c --- /dev/null +++ b/spec/serializers/v1/coupon_serializer_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::CouponSerializer do + subject(:serializer) { described_class.new(coupon, root_name: "coupon") } + + let(:coupon_plan) { create(:coupon_plan) } + let(:coupon) { coupon_plan.coupon } + + before { coupon_plan } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["coupon"]["lago_id"]).to eq(coupon.id) + expect(result["coupon"]["name"]).to eq(coupon.name) + expect(result["coupon"]["code"]).to eq(coupon.code) + expect(result["coupon"]["description"]).to eq(coupon.description) + expect(result["coupon"]["amount_cents"]).to eq(coupon.amount_cents) + expect(result["coupon"]["amount_currency"]).to eq(coupon.amount_currency) + expect(result["coupon"]["limited_plans"]).to eq(coupon.limited_plans) + expect(result["coupon"]["limited_billable_metrics"]).to eq(coupon.limited_billable_metrics) + expect(result["coupon"]["expiration"]).to eq(coupon.expiration) + expect(result["coupon"]["expiration_at"]).to eq(coupon.expiration_at&.iso8601) + expect(result["coupon"]["created_at"]).to eq(coupon.created_at.iso8601) + expect(result["coupon"]["terminated_at"]).to eq(coupon.terminated_at&.iso8601) + expect(result["coupon"]["plan_codes"].first).to eq(coupon_plan.plan.code) + expect(result["coupon"]["billable_metric_codes"]).to eq([]) + end + + context "when plan has childs" do + before do + child_plan = create(:plan, parent: coupon_plan.plan, code: coupon_plan.plan.code) + create(:coupon_plan, coupon:, plan: child_plan) + end + + it "only list parent plans" do + result = JSON.parse(serializer.to_json) + + expect(result["coupon"]["plan_codes"]).to eq([coupon_plan.plan.code]) + end + end +end diff --git a/spec/serializers/v1/credit_note_serializer_spec.rb b/spec/serializers/v1/credit_note_serializer_spec.rb new file mode 100644 index 0000000..7d8f8d1 --- /dev/null +++ b/spec/serializers/v1/credit_note_serializer_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::CreditNoteSerializer do + subject(:serializer) do + described_class.new(credit_note, root_name: "credit_note", includes:) + end + + let(:includes) { [:items, :error_details, {customer: [:integration_customers]}] } + let(:credit_note) { create(:credit_note) } + let(:error_detail) { create(:error_detail, owner: credit_note) } + let(:customer) { credit_note.customer } + let(:item) { create(:credit_note_item, credit_note:) } + + before do + error_detail + item + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["credit_note"]).to include( + "lago_id" => credit_note.id, + "billing_entity_code" => credit_note.invoice.billing_entity.code, + "sequential_id" => credit_note.sequential_id, + "number" => credit_note.number, + "lago_invoice_id" => credit_note.invoice_id, + "invoice_number" => credit_note.invoice.number, + "issuing_date" => credit_note.issuing_date.iso8601, + "credit_status" => credit_note.credit_status, + "refund_status" => credit_note.refund_status, + "reason" => credit_note.reason, + "description" => credit_note.description, + "currency" => credit_note.currency, + "total_amount_cents" => credit_note.total_amount_cents, + "precise_total_amount_cents" => credit_note.precise_total&.to_s, + "taxes_amount_cents" => credit_note.taxes_amount_cents, + "precise_taxes_amount_cents" => credit_note.precise_taxes_amount_cents&.to_s, + "sub_total_excluding_taxes_amount_cents" => credit_note.sub_total_excluding_taxes_amount_cents, + "balance_amount_cents" => credit_note.balance_amount_cents, + "credit_amount_cents" => credit_note.credit_amount_cents, + "refund_amount_cents" => credit_note.refund_amount_cents, + "offset_amount_cents" => credit_note.offset_amount_cents, + "coupons_adjustment_amount_cents" => credit_note.coupons_adjustment_amount_cents, + "created_at" => credit_note.created_at.iso8601, + "updated_at" => credit_note.updated_at.iso8601, + "file_url" => credit_note.file_url, + "xml_url" => credit_note.xml_url, + "error_details" => [{ + "lago_id" => error_detail.id, + "error_code" => error_detail.error_code, + "details" => error_detail.details + }], + "self_billed" => credit_note.invoice.self_billed + ) + + expect(result["credit_note"].keys).to include("customer", "items") + expect(result["credit_note"]["customer"].keys).to include("integration_customers") + end +end diff --git a/spec/serializers/v1/credit_notes/applied_tax_serializer_spec.rb b/spec/serializers/v1/credit_notes/applied_tax_serializer_spec.rb new file mode 100644 index 0000000..12f44a9 --- /dev/null +++ b/spec/serializers/v1/credit_notes/applied_tax_serializer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::CreditNotes::AppliedTaxSerializer do + subject(:serializer) { described_class.new(applied_tax, root_name: "applied_tax") } + + let(:applied_tax) { create(:credit_note_applied_tax) } + + before { applied_tax } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["applied_tax"]["lago_id"]).to eq(applied_tax.id) + expect(result["applied_tax"]["lago_credit_note_id"]).to eq(applied_tax.credit_note_id) + expect(result["applied_tax"]["lago_tax_id"]).to eq(applied_tax.tax_id) + expect(result["applied_tax"]["tax_name"]).to eq(applied_tax.tax_name) + expect(result["applied_tax"]["tax_code"]).to eq(applied_tax.tax_code) + expect(result["applied_tax"]["tax_rate"]).to eq(applied_tax.tax_rate) + expect(result["applied_tax"]["tax_description"]).to eq(applied_tax.tax_description) + expect(result["applied_tax"]["amount_cents"]).to eq(applied_tax.amount_cents) + expect(result["applied_tax"]["amount_currency"]).to eq(applied_tax.amount_currency) + expect(result["applied_tax"]["created_at"]).to eq(applied_tax.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/credit_notes/estimate_serializer_spec.rb b/spec/serializers/v1/credit_notes/estimate_serializer_spec.rb new file mode 100644 index 0000000..8f58fb6 --- /dev/null +++ b/spec/serializers/v1/credit_notes/estimate_serializer_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::CreditNotes::EstimateSerializer do + subject(:serializer) { described_class.new(estimated_credit_note, root_name:) } + + let(:root_name) { "estimated_credit_note" } + + let(:estimated_credit_note) do + build(:credit_note).tap do |credit_note| + credit_note_items.each { |i| credit_note.items << i } + applied_taxes.each { |t| credit_note.applied_taxes << t } + end + end + + let(:credit_note_items) { build_list(:credit_note_item, 2, amount_cents: 100) } + let(:applied_taxes) { build_list(:credit_note_applied_tax, 2) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result[root_name]["lago_invoice_id"]).to eq(estimated_credit_note.invoice_id) + expect(result[root_name]["invoice_number"]).to eq(estimated_credit_note.invoice.number) + expect(result[root_name]["currency"]).to eq(estimated_credit_note.currency) + expect(result[root_name]["taxes_amount_cents"]).to eq(estimated_credit_note.taxes_amount_cents) + expect(result[root_name]["sub_total_excluding_taxes_amount_cents"]) + .to eq(estimated_credit_note.sub_total_excluding_taxes_amount_cents) + expect(result[root_name]["max_creditable_amount_cents"]).to eq(estimated_credit_note.credit_amount_cents) + expect(result[root_name]["max_refundable_amount_cents"]).to eq(estimated_credit_note.refund_amount_cents) + expect(result[root_name]["coupons_adjustment_amount_cents"]) + .to eq(estimated_credit_note.coupons_adjustment_amount_cents) + expect(result[root_name]["taxes_rate"]).to eq(estimated_credit_note.taxes_rate) + + expect(result[root_name]["items"].count).to eq(2) + expect(result[root_name]["applied_taxes"].count).to eq(2) + end +end diff --git a/spec/serializers/v1/credit_notes/payment_provider_refund_error_serializer_spec.rb b/spec/serializers/v1/credit_notes/payment_provider_refund_error_serializer_spec.rb new file mode 100644 index 0000000..e03c398 --- /dev/null +++ b/spec/serializers/v1/credit_notes/payment_provider_refund_error_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::CreditNotes::PaymentProviderRefundErrorSerializer do + subject(:serializer) { described_class.new(credit_note, options) } + + let(:credit_note) { create(:credit_note) } + let(:options) do + { + "provider_customer_id" => "customer", + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_credit_note_id"]).to eq(credit_note.id) + expect(result["data"]["lago_customer_id"]).to eq(credit_note.customer.id) + expect(result["data"]["external_customer_id"]).to eq(credit_note.customer.external_id) + expect(result["data"]["provider_customer_id"]).to eq(options[:provider_customer_id]) + expect(result["data"]["payment_provider"]).to eq(credit_note.customer.payment_provider) + expect(result["data"]["payment_provider_code"]).to eq(credit_note.customer.payment_provider_code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/credit_serializer_spec.rb b/spec/serializers/v1/credit_serializer_spec.rb new file mode 100644 index 0000000..a8f1790 --- /dev/null +++ b/spec/serializers/v1/credit_serializer_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::CreditSerializer do + subject(:serializer) { described_class.new(credit, root_name: "credit") } + + let(:credit) { create(:credit) } + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the object" do + expect(result["credit"]["lago_id"]).to eq(credit.id) + expect(result["credit"]["amount_cents"]).to eq(credit.amount_cents) + expect(result["credit"]["amount_currency"]).to eq(credit.amount_currency) + expect(result["credit"]["before_taxes"]).to eq(false) + expect(result["credit"]["item"]["lago_item_id"]).to eq(credit.item_id) + expect(result["credit"]["item"]["type"]).to eq("coupon") + expect(result["credit"]["item"]["code"]).to eq(credit.coupon.code) + expect(result["credit"]["item"]["name"]).to eq(credit.coupon.name) + expect(result["credit"]["item"]["description"]).to eq(credit.coupon.description) + expect(result["credit"]["invoice"]["payment_status"]).to eq(credit.invoice.payment_status) + expect(result["credit"]["invoice"]["lago_id"]).to eq(credit.invoice.id) + end + + context "with credit note credit" do + let(:credit) do + c = create(:credit_note_credit) + c.credit_note.update!(description: "DESCRIPTION") + c + end + + it "serializes the object" do + expect(result["credit"]["lago_id"]).to eq(credit.id) + expect(result["credit"]["amount_cents"]).to eq(200) + expect(result["credit"]["amount_currency"]).to eq("EUR") + expect(result["credit"]["item"]["lago_item_id"]).to eq(credit.credit_note.id) + expect(result["credit"]["item"]["type"]).to eq("credit_note") + expect(result["credit"]["item"]["code"]).to eq(credit.credit_note.number) + expect(result["credit"]["item"]["name"]).to eq(credit.credit_note.invoice.number) + expect(result["credit"]["item"]["description"]).to eq("DESCRIPTION") + end + end +end diff --git a/spec/serializers/v1/customer_serializer_spec.rb b/spec/serializers/v1/customer_serializer_spec.rb new file mode 100644 index 0000000..81ae9f1 --- /dev/null +++ b/spec/serializers/v1/customer_serializer_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::CustomerSerializer do + subject(:serializer) do + described_class.new(customer, root_name: "customer", includes: %i[taxes integration_customers applicable_invoice_custom_sections error_details]) + end + + let(:result) { JSON.parse(serializer.to_json) } + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + let(:customer) { create(:customer, :with_salesforce_integration, shipping_city: "Paris", shipping_address_line1: "test1", shipping_zipcode: "002") } + let(:metadata) { create(:customer_metadata, customer:) } + let(:tax) { create(:tax, organization: customer.organization) } + let(:customer_applied_tax) { create(:customer_applied_tax, customer:, tax:) } + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + + let(:error_detail) { create(:error_detail, owner: customer) } + + before do + metadata + customer_applied_tax + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section:) + error_detail + end + + it "serializes the object" do + expect(result["customer"]).to include( + "lago_id" => customer.id, + "billing_entity_code" => customer.billing_entity.code, + "external_id" => customer.external_id, + "account_type" => customer.account_type, + "name" => customer.name, + "firstname" => customer.firstname, + "lastname" => customer.lastname, + "customer_type" => customer.customer_type, + "sequential_id" => customer.sequential_id, + "slug" => customer.slug, + "created_at" => customer.created_at.iso8601, + "updated_at" => customer.updated_at.iso8601, + "country" => customer.country, + "address_line1" => customer.address_line1, + "address_line2" => customer.address_line2, + "state" => customer.state, + "zipcode" => customer.zipcode, + "email" => customer.email, + "city" => customer.city, + "url" => customer.url, + "phone" => customer.phone, + "logo_url" => customer.logo_url, + "legal_name" => customer.legal_name, + "legal_number" => customer.legal_number, + "currency" => customer.currency, + "timezone" => customer.timezone, + "applicable_timezone" => customer.applicable_timezone, + "net_payment_term" => customer.net_payment_term, + "finalize_zero_amount_invoice" => customer.finalize_zero_amount_invoice, + "tax_identification_number" => customer.tax_identification_number, + "taxes" => customer.taxes.map { hash_including("lago_id" => it.id) }, + "integration_customers" => customer.integration_customers.map { hash_including("lago_id" => it.id) }, + "applicable_invoice_custom_sections" => customer.applicable_invoice_custom_sections.map do + hash_including("lago_id" => it.id) + end, + "skip_invoice_custom_sections" => false, + "billing_configuration" => { + "payment_provider" => customer.payment_provider, + "payment_provider_code" => customer.payment_provider_code, + "invoice_grace_period" => customer.invoice_grace_period, + "document_locale" => customer.document_locale, + "subscription_invoice_issuing_date_anchor" => customer.subscription_invoice_issuing_date_anchor, + "subscription_invoice_issuing_date_adjustment" => customer.subscription_invoice_issuing_date_adjustment + }, + "shipping_address" => { + "address_line1" => "test1", + "address_line2" => nil, + "city" => "Paris", + "zipcode" => "002", + "state" => nil, + "country" => nil + }, + "metadata" => [{ + "lago_id" => metadata.id, + "key" => metadata.key, + "value" => metadata.value, + "display_in_invoice" => metadata.display_in_invoice, + "created_at" => metadata.created_at.iso8601 + }], + "error_details" => [ + { + "lago_id" => error_detail.id, + "error_code" => error_detail.error_code, + "details" => error_detail.details + } + ] + ) + end + + context "with a stripe customer" do + let(:stripe_customer) { create(:stripe_customer, customer:) } + + before do + stripe_customer + customer.update!(payment_provider: "stripe") + end + + it "serializes the object" do + expect(result["customer"]["billing_configuration"]["provider_customer_id"]).to eq(stripe_customer.provider_customer_id) + expect(result["customer"]["billing_configuration"]["provider_payment_methods"]).to eq(stripe_customer.provider_payment_methods) + end + end + + context "with a VIES check" do + subject(:serializer) { described_class.new(customer, root_name: "customer", includes: %i[vies_check], vies_check: {custom_hash: "yes"}) } + + let(:customer) { create(:customer, :with_salesforce_integration, tax_identification_number: "IT12345678901") } + + it "adds vies_check to customer" do + expect(result["customer"]["vies_check"]).to eq({"custom_hash" => "yes"}) + end + end +end diff --git a/spec/serializers/v1/customers/charge_usage_serializer_spec.rb b/spec/serializers/v1/customers/charge_usage_serializer_spec.rb new file mode 100644 index 0000000..b37f13f --- /dev/null +++ b/spec/serializers/v1/customers/charge_usage_serializer_spec.rb @@ -0,0 +1,376 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Customers::ChargeUsageSerializer do + subject(:serializer) { described_class.new(usage, root_name: "charges") } + + let(:charge) { create(:standard_charge) } + let(:result) { JSON.parse(serializer.to_json) } + let(:billable_metric) { charge.billable_metric } + let(:subscription) { create(:subscription, plan: charge.plan) } + let(:from_datetime) { Date.new(2025, 7, 1) } + let(:to_datetime) { Date.new(2025, 7, 10) } # 10 day period for clean ratios + let(:fixed_date) { Date.new(2025, 7, 5) } # 5 days passed, ratio = 0.5 + + let(:total_days) { (to_datetime - from_datetime).to_i + 1 } + let(:charges_duration) { total_days } + let(:days_passed) { (fixed_date - from_datetime).to_i + 1 } + let(:ratio) { days_passed.to_f / charges_duration } + + let(:pricing_unit_usage) { nil } + let(:presentation_breakdowns) { [] } + + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "10", + total_aggregated_units: "10", + events_count: 12, + amount_cents: 100, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + invoice_display_name: charge.invoice_display_name, + lago_id: billable_metric.id, + name: billable_metric.name, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + grouped_by: {"card_type" => "visa"}, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ) + ] + end + + before do + allow(Date).to receive(:current).and_return(fixed_date) + allow(Time).to receive(:current).and_return(fixed_date.to_time) + end + + it "serializes the fee" do + expect(result["charges"].first).to include( + "units" => "10.0", + "events_count" => 12, + "amount_cents" => 100, + "pricing_unit_details" => nil, + "amount_currency" => "EUR", + "charge" => { + "lago_id" => charge.id, + "charge_model" => charge.charge_model, + "invoice_display_name" => charge.invoice_display_name + }, + "billable_metric" => { + "lago_id" => billable_metric.id, + "name" => billable_metric.name, + "code" => billable_metric.code, + "aggregation_type" => billable_metric.aggregation_type + }, + "filters" => [], + "presentation_breakdowns" => [], + "grouped_usage" => [ + { + "amount_cents" => 100, + "pricing_unit_details" => nil, + "events_count" => 12, + "units" => "10.0", + "total_aggregated_units" => "10.0", + "grouped_by" => {"card_type" => "visa"}, + "filters" => [], + "presentation_breakdowns" => [] + } + ] + ) + end + + context "when contains presentation breakdowns" do + let(:presentation_breakdowns) do + [ + build(:presentation_breakdown, presentation_by: {"card_type" => "visa"}, units: "8"), + build(:presentation_breakdown, presentation_by: {"card_type" => "mastercard"}, units: "1"), + build(:presentation_breakdown, presentation_by: {"country" => "pt"}, units: "3") + ] + end + + it "serializes the breakdowns" do + expect(result["charges"].first["presentation_breakdowns"]).to eq([]) + expect(result["charges"].first["grouped_usage"].first["presentation_breakdowns"]).to match_array( + [ + {"presentation_by" => {"card_type" => "visa"}, "units" => "8.0"}, + {"presentation_by" => {"card_type" => "mastercard"}, "units" => "1.0"}, + {"presentation_by" => {"country" => "pt"}, "units" => "3.0"} + ] + ) + end + end + + context "when usage contains two objects, one with grouped_by and other without grouped_by" do + let(:other_charge) { create(:standard_charge, plan: charge.plan) } + let(:other_billable_metric) { other_charge.billable_metric } + let(:empty_group_presentation_breakdowns) { [] } + let(:visa_group_presentation_breakdowns) { [] } + + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription:, + billable_metric: billable_metric, + charge: charge, + units: "2", + total_aggregated_units: "2", + events_count: 2, + amount_cents: 20, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + invoice_display_name: charge.invoice_display_name, + lago_id: billable_metric.id, + name: billable_metric.name, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + grouped_by: {}, + charge_filter: nil, + pricing_unit_usage: pricing_unit_usage, + presentation_breakdowns: empty_group_presentation_breakdowns + ), + OpenStruct.new( + charge_id: other_charge.id, + subscription:, + billable_metric: other_billable_metric, + charge: other_charge, + units: "8", + total_aggregated_units: "8", + events_count: 10, + amount_cents: 80, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + invoice_display_name: other_charge.invoice_display_name, + lago_id: other_billable_metric.id, + name: other_billable_metric.name, + code: other_billable_metric.code, + aggregation_type: other_billable_metric.aggregation_type, + grouped_by: {"card_type" => "visa"}, + charge_filter: nil, + pricing_unit_usage: pricing_unit_usage, + presentation_breakdowns: visa_group_presentation_breakdowns + ) + ] + end + + it "serializes grouped usage including empty group" do + expect(result["charges"].length).to eq(2) + + empty_group_charge = result["charges"].find { |c| c.dig("charge", "lago_id") == charge.id } + visa_group_charge = result["charges"].find { |c| c.dig("charge", "lago_id") == other_charge.id } + + expect(empty_group_charge).to include( + "units" => "2.0", + "total_aggregated_units" => "2.0", + "events_count" => 2, + "amount_cents" => 20, + "grouped_usage" => [], + "presentation_breakdowns" => [] + ) + + expect(visa_group_charge).to include( + "units" => "8.0", + "total_aggregated_units" => "8.0", + "events_count" => 10, + "amount_cents" => 80, + "grouped_usage" => [ + { + "amount_cents" => 80, + "pricing_unit_details" => nil, + "events_count" => 10, + "units" => "8.0", + "total_aggregated_units" => "8.0", + "grouped_by" => {"card_type" => "visa"}, + "filters" => [], + "presentation_breakdowns" => [] + } + ] + ) + end + + context "when contains presentation breakdowns" do + let(:empty_group_presentation_breakdowns) do + [ + build(:presentation_breakdown, presentation_by: {"card_type" => "visa"}, units: "8"), + build(:presentation_breakdown, presentation_by: {"country" => "pt"}, units: "3") + ] + end + let(:visa_group_presentation_breakdowns) do + [ + build(:presentation_breakdown, presentation_by: {"card_type" => "mastercard"}, units: "1") + ] + end + + it "serializes breakdowns for ungrouped and grouped usage" do + empty_group_charge = result["charges"].find { |c| c.dig("charge", "lago_id") == charge.id } + visa_group_charge = result["charges"].find { |c| c.dig("charge", "lago_id") == other_charge.id } + + expect(empty_group_charge["grouped_usage"]).to eq([]) + expect(empty_group_charge["presentation_breakdowns"]).to match_array( + [ + {"presentation_by" => {"card_type" => "visa"}, "units" => "8.0"}, + {"presentation_by" => {"country" => "pt"}, "units" => "3.0"} + ] + ) + + expect(visa_group_charge["presentation_breakdowns"]).to eq([]) + expect(visa_group_charge["grouped_usage"].first["presentation_breakdowns"]).to match_array( + [ + {"presentation_by" => {"card_type" => "mastercard"}, "units" => "1.0"} + ] + ) + end + end + end + + context "when charge configured to use pricing units" do + let(:pricing_unit_usage) do + PricingUnitUsage.new(amount_cents: 200, conversion_rate: 0.5, short_name: "CR") + end + + it "serializes the fee" do + expect(result["charges"].first).to include( + "units" => "10.0", + "total_aggregated_units" => "10.0", + "events_count" => 12, + "amount_cents" => 100, + "pricing_unit_details" => { + "amount_cents" => 200, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "amount_currency" => "EUR", + "charge" => { + "lago_id" => charge.id, + "charge_model" => charge.charge_model, + "invoice_display_name" => charge.invoice_display_name + }, + "billable_metric" => { + "lago_id" => billable_metric.id, + "name" => billable_metric.name, + "code" => billable_metric.code, + "aggregation_type" => billable_metric.aggregation_type + }, + "filters" => [], + "presentation_breakdowns" => [], + "grouped_usage" => [ + { + "amount_cents" => 100, + "pricing_unit_details" => { + "amount_cents" => 200, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "events_count" => 12, + "units" => "10.0", + "total_aggregated_units" => "10.0", + "grouped_by" => {"card_type" => "visa"}, + "filters" => [], + "presentation_breakdowns" => [] + } + ] + ) + end + end + + describe "#filters" do + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric: billable_metric) } + let(:charge_filter) { create(:charge_filter, charge: charge, invoice_display_name: nil) } + let(:usage) do + Array.new(3) do + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "10.0", + events_count: 12, + amount_cents: 100, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: {"card_type" => "visa"}, + charge_filter:, + charge_filter_id: charge_filter.id, + pricing_unit_usage:, + presentation_breakdowns: + ) + end + end + + it "returns filters array with projected values" do + expect(result["charges"].first["filters"].first).to include( + "units" => "30.0", + "amount_cents" => 300, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + + expect(result["charges"].first["grouped_usage"].first["filters"].first).to include( + "units" => "30.0", + "amount_cents" => 300, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + end + + context "when charge configured to use pricing units" do + let(:pricing_unit_usage) do + PricingUnitUsage.new(amount_cents: 200, conversion_rate: 0.5, short_name: "CR") + end + + it "returns filters array" do + expect(result["charges"].first["filters"].first).to include( + "units" => "30.0", + "amount_cents" => 300, + "pricing_unit_details" => { + "amount_cents" => 600, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + + expect(result["charges"].first["grouped_usage"].first["filters"].first).to include( + "units" => "30.0", + "amount_cents" => 300, + "pricing_unit_details" => { + "amount_cents" => 600, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + end + end + end +end diff --git a/spec/serializers/v1/customers/metadata_serializer_spec.rb b/spec/serializers/v1/customers/metadata_serializer_spec.rb new file mode 100644 index 0000000..f94435c --- /dev/null +++ b/spec/serializers/v1/customers/metadata_serializer_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Customers::MetadataSerializer do + subject(:serializer) { described_class.new(metadata, root_name: "metadata") } + + let(:metadata) { create(:customer_metadata) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["metadata"]["lago_id"]).to eq(metadata.id) + expect(result["metadata"]["key"]).to eq(metadata.key) + expect(result["metadata"]["value"]).to eq(metadata.value) + expect(result["metadata"]["display_in_invoice"]).to eq(metadata.display_in_invoice) + expect(result["metadata"]["created_at"]).to eq(metadata.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/customers/past_usage_serializer_spec.rb b/spec/serializers/v1/customers/past_usage_serializer_spec.rb new file mode 100644 index 0000000..9ed7e37 --- /dev/null +++ b/spec/serializers/v1/customers/past_usage_serializer_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Customers::PastUsageSerializer do + subject(:serializer) { described_class.new(usage, root_name: "usage_period", includes: [:charges_usage]) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + + let(:invoice_subscription) do + create( + :invoice_subscription, + charges_from_datetime: DateTime.parse("2023-08-17T00:00:00"), + charges_to_datetime: DateTime.parse("2023-09-16T23:59:59"), + subscription: + ) + end + + let(:billable_metric1) { create(:billable_metric, organization:) } + let(:billable_metric2) { create(:billable_metric, organization:) } + + let(:charge1) { create(:standard_charge, plan:, billable_metric: billable_metric1) } + let(:charge2) { create(:standard_charge, plan:, billable_metric: billable_metric2) } + + let(:invoice) { invoice_subscription.invoice } + + let(:fee1) { create(:charge_fee, charge: charge1, invoice:, presentation_breakdowns: [build(:presentation_breakdown)]) } + let(:fee2) { create(:charge_fee, charge: charge2, invoice:) } + + let(:usage) { OpenStruct.new(invoice_subscription:, fees: [fee1, fee2]) } + + it "serializes the past usage" do + result = JSON.parse(serializer.to_json) + + expect(result["usage_period"]).to include( + "from_datetime" => "2023-08-17T00:00:00Z", + "to_datetime" => "2023-09-16T23:59:59Z", + "issuing_date" => invoice.issuing_date.iso8601, + "currency" => invoice.currency, + "amount_cents" => invoice.fees_amount_cents, + "total_amount_cents" => invoice.fees_amount_cents + invoice.fees.sum(:taxes_amount_cents), + "taxes_amount_cents" => invoice.fees.sum(:taxes_amount_cents), + "lago_invoice_id" => invoice.id + ) + + expect(result["usage_period"]["charges_usage"].count).to eq(2) + expect(result["usage_period"]["charges_usage"].first["presentation_breakdowns"]).to eq([{"presentation_by" => {"department" => "engineering"}, "units" => "60.0"}]) + expect(result["usage_period"]["charges_usage"].second["presentation_breakdowns"]).to eq([]) + end +end diff --git a/spec/serializers/v1/customers/presentation_breakdown_builder_spec.rb b/spec/serializers/v1/customers/presentation_breakdown_builder_spec.rb new file mode 100644 index 0000000..ab72284 --- /dev/null +++ b/spec/serializers/v1/customers/presentation_breakdown_builder_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Customers::PresentationBreakdownBuilder do + subject(:result) { described_class.call(fees, filter:) } + + let(:breakdown_class) { Struct.new(:presentation_by, :units, keyword_init: true) } + let(:fee_class) { Struct.new(:presentation_breakdowns, :grouped_by, keyword_init: true) } + let(:filter) { described_class::UNGROUPED } + + let(:fees) do + [ + fee_class.new( + grouped_by: nil, + presentation_breakdowns: [ + breakdown_class.new(presentation_by: {"cloud" => "aws"}, units: "1.2"), + breakdown_class.new(presentation_by: {"cloud" => "gcp"}, units: "3") + ] + ), + fee_class.new( + grouped_by: nil, + presentation_breakdowns: [ + breakdown_class.new(presentation_by: {"cloud" => "aws"}, units: "0.3") + ] + ) + ] + end + + it "returns one entry per breakdown with stringified units" do + expect(result).to eq([ + {presentation_by: {"cloud" => "aws"}, units: "1.2"}, + {presentation_by: {"cloud" => "gcp"}, units: "3"}, + {presentation_by: {"cloud" => "aws"}, units: "0.3"} + ]) + end + + context "when a fee has no presentation_breakdowns" do + let(:fees) { [fee_class.new(grouped_by: nil, presentation_breakdowns: [])] } + + it "returns an empty array" do + expect(result).to eq([]) + end + end + + describe "filtering" do + let(:ungrouped_fee) do + fee_class.new( + grouped_by: nil, + presentation_breakdowns: [breakdown_class.new(presentation_by: {"region" => "us"}, units: "1")] + ) + end + let(:grouped_fee) do + fee_class.new( + grouped_by: {"region" => "eu"}, + presentation_breakdowns: [breakdown_class.new(presentation_by: {"region" => "eu"}, units: "2")] + ) + end + let(:fees) { [ungrouped_fee, grouped_fee] } + + context "when filter is UNGROUPED" do + let(:filter) { described_class::UNGROUPED } + + it "includes only fees with blank grouped_by" do + expect(result).to eq([ + {presentation_by: {"region" => "us"}, units: "1"} + ]) + end + end + + context "when filter is GROUPED" do + let(:filter) { described_class::GROUPED } + + it "includes only fees with present grouped_by" do + expect(result).to eq([ + {presentation_by: {"region" => "eu"}, units: "2"} + ]) + end + end + + context "when filter is ALL" do + let(:filter) { described_class::ALL } + + it "includes breakdowns from all fees regardless of grouped_by" do + expect(result).to eq([ + {presentation_by: {"region" => "us"}, units: "1"}, + {presentation_by: {"region" => "eu"}, units: "2"} + ]) + end + end + end +end diff --git a/spec/serializers/v1/customers/projected_charge_usage_serializer_spec.rb b/spec/serializers/v1/customers/projected_charge_usage_serializer_spec.rb new file mode 100644 index 0000000..10177c5 --- /dev/null +++ b/spec/serializers/v1/customers/projected_charge_usage_serializer_spec.rb @@ -0,0 +1,834 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Customers::ProjectedChargeUsageSerializer do + subject(:serializer) { described_class.new(usage, root_name: "charges") } + + let(:charge) { create(:standard_charge) } + let(:result) { JSON.parse(serializer.to_json) } + let(:billable_metric) { charge.billable_metric } + let(:subscription) { create(:subscription, plan: charge.plan) } + let(:from_datetime) { Date.new(2025, 7, 1) } + let(:to_datetime) { Date.new(2025, 7, 10) } # 10 day period for clean ratios + let(:fixed_date) { Date.new(2025, 7, 5) } # 5 days passed, ratio = 0.5 + + let(:total_days) { (to_datetime - from_datetime).to_i + 1 } + let(:charges_duration) { total_days } + let(:days_passed) { (fixed_date - from_datetime).to_i + 1 } + let(:ratio) { days_passed.to_f / charges_duration } + + let(:is_recurring) { false } + let(:expected_projected_units) do + if is_recurring + BigDecimal(10) + else + (ratio > 0) ? (BigDecimal(10) / BigDecimal(ratio.to_s)).round(1) : BigDecimal(0) + end + end + let(:expected_projected_amount_cents) do + if is_recurring + 100 + else + (ratio > 0) ? (BigDecimal(100) / BigDecimal(ratio.to_s)).round.to_i : 0 + end + end + let(:expected_pricing_unit_projected_amount_cents) do + if is_recurring + 200 + else + (ratio > 0) ? (BigDecimal(200) / BigDecimal(ratio.to_s)).round.to_i : 0 + end + end + let(:greater_expected_pricing_unit_projected_amount_cents) do + if is_recurring + 600 + else + (ratio > 0) ? (BigDecimal(600) / BigDecimal(ratio.to_s)).round.to_i : 0 + end + end + let(:pricing_unit_usage) { nil } + let(:presentation_breakdowns) { [] } + + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "10", + events_count: 12, + amount_cents: 100, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + invoice_display_name: charge.invoice_display_name, + lago_id: billable_metric.id, + name: billable_metric.name, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + grouped_by: {"card_type" => "visa"}, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ) + ] + end + + before do + allow(Date).to receive(:current).and_return(fixed_date) + allow(Time).to receive(:current).and_return(fixed_date.to_time) + end + + it "serializes the projected fee" do + projection_result = instance_double( + "ProjectionResult", + projected_units: expected_projected_units, + projected_amount_cents: expected_projected_amount_cents, + projected_pricing_unit_amount_cents: expected_pricing_unit_projected_amount_cents + ) + + allow(::Fees::ProjectionService).to receive(:call).and_return( + instance_double("ServiceResult", raise_if_error!: projection_result) + ) + + expect(result["charges"].first).to include( + "units" => "10.0", + "projected_units" => expected_projected_units.to_s, + "events_count" => 12, + "amount_cents" => 100, + "projected_amount_cents" => expected_projected_amount_cents, + "pricing_unit_details" => nil, + "amount_currency" => "EUR", + "charge" => { + "lago_id" => charge.id, + "charge_model" => charge.charge_model, + "invoice_display_name" => charge.invoice_display_name + }, + "billable_metric" => { + "lago_id" => billable_metric.id, + "name" => billable_metric.name, + "code" => billable_metric.code, + "aggregation_type" => billable_metric.aggregation_type + }, + "filters" => [], + "presentation_breakdowns" => [], + "grouped_usage" => [ + { + "amount_cents" => 100, + "projected_amount_cents" => expected_projected_amount_cents, + "pricing_unit_details" => nil, + "events_count" => 12, + "units" => "10.0", + "projected_units" => expected_projected_units.to_s, + "grouped_by" => {"card_type" => "visa"}, + "filters" => [], + "presentation_breakdowns" => [] + } + ] + ) + end + + context "when contains presentation breakdowns" do + let(:presentation_breakdowns) do + [ + build(:presentation_breakdown, presentation_by: {"card_type" => "visa"}, units: "7"), + build(:presentation_breakdown, presentation_by: {"card_type" => "mastercard"}, units: "1"), + build(:presentation_breakdown, presentation_by: {"country" => "br"}, units: "3") + ] + end + + it "serializes the breakdowns" do + projection_result = instance_double( + "ProjectionResult", + projected_units: expected_projected_units, + projected_amount_cents: expected_projected_amount_cents, + projected_pricing_unit_amount_cents: expected_pricing_unit_projected_amount_cents + ) + + allow(::Fees::ProjectionService).to receive(:call).and_return( + instance_double("ServiceResult", raise_if_error!: projection_result) + ) + + expect(result["charges"].first["presentation_breakdowns"]).to eq([]) + + expect(result["charges"].first["grouped_usage"].first["presentation_breakdowns"]).to match_array( + [ + {"presentation_by" => {"card_type" => "visa"}, "units" => "7.0"}, + {"presentation_by" => {"card_type" => "mastercard"}, "units" => "1.0"}, + {"presentation_by" => {"country" => "br"}, "units" => "3.0"} + ] + ) + end + end + + context "when charge configured to use pricing units" do + let(:pricing_unit_usage) do + PricingUnitUsage.new(amount_cents: 200, conversion_rate: 0.5, short_name: "CR") + end + + it "serializes the projected fee with pricing units" do + projection_result = instance_double( + "ProjectionResult", + projected_units: expected_projected_units, + projected_amount_cents: expected_projected_amount_cents, + projected_pricing_unit_amount_cents: expected_pricing_unit_projected_amount_cents + ) + + allow(::Fees::ProjectionService).to receive(:call).and_return( + instance_double("ServiceResult", raise_if_error!: projection_result) + ) + + expect(result["charges"].first).to include( + "units" => "10.0", + "projected_units" => expected_projected_units.to_s, + "events_count" => 12, + "amount_cents" => 100, + "projected_amount_cents" => expected_projected_amount_cents, + "pricing_unit_details" => { + "amount_cents" => 200, + "projected_amount_cents" => expected_pricing_unit_projected_amount_cents, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "amount_currency" => "EUR", + "charge" => { + "lago_id" => charge.id, + "charge_model" => charge.charge_model, + "invoice_display_name" => charge.invoice_display_name + }, + "billable_metric" => { + "lago_id" => billable_metric.id, + "name" => billable_metric.name, + "code" => billable_metric.code, + "aggregation_type" => billable_metric.aggregation_type + }, + "filters" => [], + "presentation_breakdowns" => [], + "grouped_usage" => [ + { + "amount_cents" => 100, + "projected_amount_cents" => expected_projected_amount_cents, + "pricing_unit_details" => { + "amount_cents" => 200, + "projected_amount_cents" => expected_pricing_unit_projected_amount_cents, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "events_count" => 12, + "units" => "10.0", + "projected_units" => expected_projected_units.to_s, + "grouped_by" => {"card_type" => "visa"}, + "filters" => [], + "presentation_breakdowns" => [] + } + ] + ) + end + end + + describe "#filters" do + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric: billable_metric) } + let(:charge_filter) { create(:charge_filter, charge: charge, invoice_display_name: nil) } + let(:usage) do + Array.new(3) do + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "10.0", + events_count: 12, + amount_cents: 100, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: {"card_type" => "visa"}, + charge_filter:, + charge_filter_id: charge_filter.id, + pricing_unit_usage:, + presentation_breakdowns: + ) + end + end + + let(:expected_filter_projected_units) do + if is_recurring + BigDecimal(30) + else + (ratio > 0) ? (BigDecimal(30) / BigDecimal(ratio.to_s)).round(2) : BigDecimal(0) + end + end + let(:expected_filter_projected_amount_cents) do + if is_recurring + 300 + else + (ratio > 0) ? (300 / BigDecimal(ratio.to_s)).round.to_i : 0 + end + end + + it "returns projected filters array" do + individual_projection_result = instance_double( + "ProjectionResult", + projected_units: expected_filter_projected_units / 3, + projected_amount_cents: expected_filter_projected_amount_cents / 3, + projected_pricing_unit_amount_cents: greater_expected_pricing_unit_projected_amount_cents / 3 + ) + + allow(::Fees::ProjectionService).to receive(:call).and_return( + instance_double("ServiceResult", raise_if_error!: individual_projection_result) + ) + + expect(result["charges"].first["filters"].first).to include( + "units" => "30.0", + "projected_units" => expected_filter_projected_units.to_s, + "amount_cents" => 300, + "projected_amount_cents" => expected_filter_projected_amount_cents, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + + expect(result["charges"].first["grouped_usage"].first["filters"].first).to include( + "units" => "30.0", + "projected_units" => expected_filter_projected_units.to_s, + "amount_cents" => 300, + "projected_amount_cents" => expected_filter_projected_amount_cents, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + end + + context "when charge configured to use pricing units" do + let(:pricing_unit_usage) do + PricingUnitUsage.new(amount_cents: 200, conversion_rate: 0.5, short_name: "CR") + end + + it "returns projected filters array with pricing units" do + individual_projection_result = instance_double( + "ProjectionResult", + projected_units: expected_filter_projected_units / 3, + projected_amount_cents: expected_filter_projected_amount_cents / 3, + projected_pricing_unit_amount_cents: greater_expected_pricing_unit_projected_amount_cents / 3 + ) + + allow(::Fees::ProjectionService).to receive(:call).and_return( + instance_double("ServiceResult", raise_if_error!: individual_projection_result) + ) + + expect(result["charges"].first["filters"].first).to include( + "units" => "30.0", + "amount_cents" => 300, + "projected_units" => expected_filter_projected_units.to_s, + "projected_amount_cents" => expected_filter_projected_amount_cents, + "pricing_unit_details" => { + "amount_cents" => 600, + "projected_amount_cents" => greater_expected_pricing_unit_projected_amount_cents, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + + expect(result["charges"].first["grouped_usage"].first["filters"].first).to include( + "units" => "30.0", + "amount_cents" => 300, + "projected_units" => expected_filter_projected_units.to_s, + "projected_amount_cents" => expected_filter_projected_amount_cents, + "pricing_unit_details" => { + "amount_cents" => 600, + "projected_amount_cents" => greater_expected_pricing_unit_projected_amount_cents, + "short_name" => "CR", + "conversion_rate" => "0.5" + }, + "events_count" => 36, + "invoice_display_name" => charge_filter.invoice_display_name, + "values" => {} + ) + end + end + end + + describe "multiple charge filters" do + let(:charge_filter_1) { create(:charge_filter, charge: charge, invoice_display_name: "Filter 1") } + let(:charge_filter_2) { create(:charge_filter, charge: charge, invoice_display_name: "Filter 2") } + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "5.0", + events_count: 8, + amount_cents: 50, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: nil, + charge_filter: charge_filter_1, + charge_filter_id: charge_filter_1.id, + pricing_unit_usage:, + presentation_breakdowns: + ), + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "7.0", + events_count: 10, + amount_cents: 70, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: nil, + charge_filter: charge_filter_2, + charge_filter_id: charge_filter_2.id, + pricing_unit_usage:, + presentation_breakdowns: + ) + ] + end + + it "handles multiple filters with different projection calculations" do + projection_result_1 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(10), + projected_amount_cents: 100, + projected_pricing_unit_amount_cents: 150 + ) + + projection_result_2 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(14), + projected_amount_cents: 140, + projected_pricing_unit_amount_cents: 210 + ) + + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[0]]).and_return(projection_result_1) + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[1]]).and_return(projection_result_2) + + filters = result["charges"].first["filters"] + + expect(filters.size).to eq(2) + + expect(filters[0]).to include( + "units" => "5.0", + "projected_units" => "10.0", + "amount_cents" => 50, + "projected_amount_cents" => 100, + "events_count" => 8, + "invoice_display_name" => "Filter 1", + "values" => {} + ) + + expect(filters[1]).to include( + "units" => "7.0", + "projected_units" => "14.0", + "amount_cents" => 70, + "projected_amount_cents" => 140, + "events_count" => 10, + "invoice_display_name" => "Filter 2", + "values" => {} + ) + end + end + + describe "multiple grouped usage scenarios" do + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "3.0", + events_count: 5, + amount_cents: 30, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: {"region" => "us-east", "tier" => "premium"}, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ), + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "4.0", + events_count: 7, + amount_cents: 40, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: {"region" => "us-west", "tier" => "standard"}, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ), + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "5.0", + events_count: 8, + amount_cents: 50, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: {"region" => "eu-central", "tier" => "premium"}, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ) + ] + end + + it "handles multiple groups with independent projection calculations" do + projection_result_1 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(6), + projected_amount_cents: 60, + projected_pricing_unit_amount_cents: 90 + ) + + projection_result_2 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(8), + projected_amount_cents: 80, + projected_pricing_unit_amount_cents: 120 + ) + + projection_result_3 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(10), + projected_amount_cents: 100, + projected_pricing_unit_amount_cents: 150 + ) + + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[0]]).and_return(projection_result_1) + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[1]]).and_return(projection_result_2) + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[2]]).and_return(projection_result_3) + + grouped_usage = result["charges"].first["grouped_usage"] + + expect(grouped_usage.size).to eq(3) + + expect(grouped_usage[0]).to include( + "units" => "3.0", + "projected_units" => "6.0", + "amount_cents" => 30, + "projected_amount_cents" => 60, + "events_count" => 5, + "grouped_by" => {"region" => "us-east", "tier" => "premium"} + ) + + expect(grouped_usage[1]).to include( + "units" => "4.0", + "projected_units" => "8.0", + "amount_cents" => 40, + "projected_amount_cents" => 80, + "events_count" => 7, + "grouped_by" => {"region" => "us-west", "tier" => "standard"} + ) + + expect(grouped_usage[2]).to include( + "units" => "5.0", + "projected_units" => "10.0", + "amount_cents" => 50, + "projected_amount_cents" => 100, + "events_count" => 8, + "grouped_by" => {"region" => "eu-central", "tier" => "premium"} + ) + end + end + + describe "mixed filtering and grouping" do + let(:charge_filter) { create(:charge_filter, charge: charge, invoice_display_name: "Mixed Filter") } + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "2.0", + events_count: 3, + amount_cents: 20, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: {"datacenter" => "dc1"}, + charge_filter: charge_filter, + charge_filter_id: charge_filter.id, + pricing_unit_usage:, + presentation_breakdowns: + ), + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "3.0", + events_count: 4, + amount_cents: 30, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: {"datacenter" => "dc2"}, + charge_filter: charge_filter, + charge_filter_id: charge_filter.id, + pricing_unit_usage:, + presentation_breakdowns: + ) + ] + end + + it "correctly handles fees with both filters and grouping" do + projection_result_1 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(4), + projected_amount_cents: 40, + projected_pricing_unit_amount_cents: 60 + ) + + projection_result_2 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(6), + projected_amount_cents: 60, + projected_pricing_unit_amount_cents: 90 + ) + + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[0]]).and_return(projection_result_1) + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[1]]).and_return(projection_result_2) + + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[0]]).and_return(projection_result_1) + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[1]]).and_return(projection_result_2) + + charge_result = result["charges"].first + + expect(charge_result["filters"].size).to eq(1) + expect(charge_result["filters"].first).to include( + "units" => "5.0", + "projected_units" => "10.0", + "amount_cents" => 50, + "projected_amount_cents" => 100, + "events_count" => 7, + "invoice_display_name" => "Mixed Filter", + "values" => {} + ) + + expect(charge_result["grouped_usage"].size).to eq(2) + expect(charge_result["grouped_usage"][0]).to include( + "units" => "2.0", + "projected_units" => "4.0", + "amount_cents" => 20, + "projected_amount_cents" => 40, + "grouped_by" => {"datacenter" => "dc1"} + ) + expect(charge_result["grouped_usage"][1]).to include( + "units" => "3.0", + "projected_units" => "6.0", + "amount_cents" => 30, + "projected_amount_cents" => 60, + "grouped_by" => {"datacenter" => "dc2"} + ) + end + end + + describe "multiple charges with different calculations" do + let(:charge_2) { create(:standard_charge) } + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "10.0", + events_count: 15, + amount_cents: 100, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: nil, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ), + OpenStruct.new( + charge_id: charge_2.id, + subscription: subscription, + billable_metric: charge_2.billable_metric, + charge: charge_2, + units: "20.0", + events_count: 25, + amount_cents: 200, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: nil, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ) + ] + end + + it "handles multiple charges with independent calculations" do + projection_result_1 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(20), + projected_amount_cents: 200, + projected_pricing_unit_amount_cents: 300 + ) + + projection_result_2 = instance_double( + "ProjectionResult", + projected_units: BigDecimal(40), + projected_amount_cents: 400, + projected_pricing_unit_amount_cents: 600 + ) + + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[0]]).and_return(projection_result_1) + allow(::Fees::ProjectionService).to receive(:call!).with(fees: [usage[1]]).and_return(projection_result_2) + + charges = result["charges"] + expect(charges.size).to eq(2) + + expect(charges[0]).to include( + "units" => "10.0", + "projected_units" => "20.0", + "amount_cents" => 100, + "projected_amount_cents" => 200, + "events_count" => 15 + ) + expect(charges[0]["charge"]["lago_id"]).to eq(charge.id) + + expect(charges[1]).to include( + "units" => "20.0", + "projected_units" => "40.0", + "amount_cents" => 200, + "projected_amount_cents" => 400, + "events_count" => 25 + ) + expect(charges[1]["charge"]["lago_id"]).to eq(charge_2.id) + end + end + + describe "memoization behavior" do + let(:usage) do + [ + OpenStruct.new( + charge_id: charge.id, + subscription: subscription, + billable_metric: billable_metric, + charge: charge, + units: "5.0", + events_count: 8, + amount_cents: 50, + amount_currency: "EUR", + properties: { + "from_datetime" => from_datetime.to_s, + "to_datetime" => to_datetime.to_s, + "charges_duration" => charges_duration + }, + grouped_by: nil, + charge_filter: nil, + pricing_unit_usage:, + presentation_breakdowns: + ) + ] + end + + it "calls projection service only once per unique fee set" do + projection_result = instance_double( + "ProjectionResult", + projected_units: BigDecimal(10), + projected_amount_cents: 100, + projected_pricing_unit_amount_cents: 150 + ) + + allow(::Fees::ProjectionService).to receive(:call!).with(fees: usage).and_return(projection_result) + + result + + charge_result = result["charges"].first + expect(charge_result).to include( + "projected_units" => "10.0", + "projected_amount_cents" => 100 + ) + + expect(::Fees::ProjectionService).to have_received(:call!).with(fees: usage).once + end + end + + describe "recurring charges" do + let(:is_recurring) { true } + + before do + allow(charge.billable_metric).to receive(:recurring?).and_return(true) + end + + it "returns current values for recurring charges" do + projection_result = instance_double( + "ProjectionResult", + projected_units: BigDecimal(10), + projected_amount_cents: 100, + projected_pricing_unit_amount_cents: 200 + ) + + allow(::Fees::ProjectionService).to receive(:call).and_return( + instance_double("ServiceResult", raise_if_error!: projection_result) + ) + + expect(result["charges"].first).to include( + "units" => "10.0", + "projected_units" => "10.0", + "projected_amount_cents" => 100 + ) + end + end +end diff --git a/spec/serializers/v1/customers/projected_usage_serializer_spec.rb b/spec/serializers/v1/customers/projected_usage_serializer_spec.rb new file mode 100644 index 0000000..c46aa35 --- /dev/null +++ b/spec/serializers/v1/customers/projected_usage_serializer_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Customers::ProjectedUsageSerializer do + subject(:serializer) { described_class.new(usage, root_name: "customer_projected_usage") } + + let(:fixed_date) { Date.new(2025, 7, 2) } + let(:result) { JSON.parse(serializer.to_json) } + let(:from_datetime) { fixed_date.beginning_of_month } + let(:to_datetime) { fixed_date.end_of_month } + let(:issuing_date) { fixed_date.end_of_month } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization: organization) } + let(:plan) { create(:plan, organization: organization) } + let(:subscription) { create(:subscription, customer: customer, plan: plan) } + let(:billable_metric) { create(:billable_metric, organization: organization) } + let(:charge) { create(:standard_charge, plan: plan, billable_metric: billable_metric) } + + let(:usage) do + SubscriptionUsage.new( + from_datetime: from_datetime.iso8601, + to_datetime: to_datetime.iso8601, + issuing_date: issuing_date.iso8601, + amount_cents: 5, + currency: "EUR", + total_amount_cents: 6, + taxes_amount_cents: 1, + fees: [ + OpenStruct.new( + billable_metric: billable_metric, + charge: charge, + charge_id: charge.id, + subscription: subscription, + units: "4.0", + amount_cents: 5, + amount_currency: "EUR", + events_count: 1, + charge_filter: nil, + grouped_by: {}, + properties: { + "from_datetime" => from_datetime.iso8601, + "to_datetime" => to_datetime.iso8601, + "charges_duration" => 30 + }, + presentation_breakdowns: [] + ) + ] + ) + end + + before do + allow(Date).to receive(:current).and_return(fixed_date) + allow(Time).to receive(:current).and_return(fixed_date.to_time) + + projection_result = instance_double( + "Fees::ProjectionService::Result", + projected_units: BigDecimal("60.0"), + projected_amount_cents: 75, + projected_pricing_unit_amount_cents: BigDecimal(0) + ) + + allow(::Fees::ProjectionService).to receive(:call).and_return( + instance_double("BaseService::Result", raise_if_error!: projection_result) + ) + end + + it "serializes the projected customer usage" do + expect(result["customer_projected_usage"]["from_datetime"]).to eq(from_datetime.iso8601) + expect(result["customer_projected_usage"]["to_datetime"]).to eq(to_datetime.iso8601) + expect(result["customer_projected_usage"]["issuing_date"]).to eq(issuing_date.iso8601) + expect(result["customer_projected_usage"]["currency"]).to eq("EUR") + expect(result["customer_projected_usage"]["taxes_amount_cents"]).to eq(1) + expect(result["customer_projected_usage"]["amount_cents"]).to eq(5) + expect(result["customer_projected_usage"]["total_amount_cents"]).to eq(6) + expect(result["customer_projected_usage"]["projected_amount_cents"]).to eq(75) + + charge_usage = result["customer_projected_usage"]["charges_usage"].first + expect(charge_usage["billable_metric"]["name"]).to eq(billable_metric.name) + expect(charge_usage["billable_metric"]["code"]).to eq(billable_metric.code) + expect(charge_usage["billable_metric"]["aggregation_type"]).to eq(billable_metric.aggregation_type) + expect(charge_usage["charge"]["charge_model"]).to eq(charge.charge_model) + expect(charge_usage["units"]).to eq("4.0") + expect(charge_usage["projected_units"]).to eq("60.0") + expect(charge_usage["amount_cents"]).to eq(5) + expect(charge_usage["projected_amount_cents"]).to eq(75) + expect(charge_usage["amount_currency"]).to eq("EUR") + end +end diff --git a/spec/serializers/v1/customers/usage_serializer_spec.rb b/spec/serializers/v1/customers/usage_serializer_spec.rb new file mode 100644 index 0000000..d159464 --- /dev/null +++ b/spec/serializers/v1/customers/usage_serializer_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Customers::UsageSerializer do + subject(:serializer) { described_class.new(usage, root_name: "customer_usage", includes: [:charges_usage]) } + + let(:fixed_date) { Date.new(2025, 7, 2) } + let(:result) { JSON.parse(serializer.to_json) } + let(:from_datetime) { fixed_date.beginning_of_month } + let(:to_datetime) { fixed_date.end_of_month } + let(:issuing_date) { fixed_date.end_of_month } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization: organization) } + let(:plan) { create(:plan, organization: organization) } + let(:subscription) { create(:subscription, customer: customer, plan: plan) } + let(:billable_metric) { create(:billable_metric, organization: organization) } + let(:charge) { create(:standard_charge, plan: plan, billable_metric: billable_metric) } + + let(:usage) do + SubscriptionUsage.new( + from_datetime: from_datetime.iso8601, + to_datetime: to_datetime.iso8601, + issuing_date: issuing_date.iso8601, + amount_cents: 5, + currency: "EUR", + total_amount_cents: 6, + taxes_amount_cents: 1, + fees: [ + OpenStruct.new( + billable_metric: billable_metric, + charge: charge, + charge_id: charge.id, + subscription: subscription, + units: "4.0", + amount_cents: 5, + amount_currency: "EUR", + events_count: 1, + charge_filter: nil, + grouped_by: {}, + properties: { + "from_datetime" => from_datetime.iso8601, + "to_datetime" => to_datetime.iso8601, + "charges_duration" => 30 + }, + presentation_breakdowns: [] + ) + ] + ) + end + + it "serializes the customer usage" do + expect(result["customer_usage"]["from_datetime"]).to eq(from_datetime.iso8601) + expect(result["customer_usage"]["to_datetime"]).to eq(to_datetime.iso8601) + expect(result["customer_usage"]["issuing_date"]).to eq(issuing_date.iso8601) + expect(result["customer_usage"]["currency"]).to eq("EUR") + expect(result["customer_usage"]["taxes_amount_cents"]).to eq(1) + expect(result["customer_usage"]["amount_cents"]).to eq(5) + expect(result["customer_usage"]["total_amount_cents"]).to eq(6) + + charge_usage = result["customer_usage"]["charges_usage"].first + expect(charge_usage["billable_metric"]["name"]).to eq(billable_metric.name) + expect(charge_usage["billable_metric"]["code"]).to eq(billable_metric.code) + expect(charge_usage["billable_metric"]["aggregation_type"]).to eq(billable_metric.aggregation_type) + expect(charge_usage["charge"]["charge_model"]).to eq(charge.charge_model) + expect(charge_usage["units"]).to eq("4.0") + expect(charge_usage["amount_cents"]).to eq(5) + expect(charge_usage["amount_currency"]).to eq("EUR") + end +end diff --git a/spec/serializers/v1/dunning_campaign_finished_serializer_spec.rb b/spec/serializers/v1/dunning_campaign_finished_serializer_spec.rb new file mode 100644 index 0000000..a95f0c8 --- /dev/null +++ b/spec/serializers/v1/dunning_campaign_finished_serializer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::DunningCampaignFinishedSerializer do + subject(:serializer) { described_class.new(customer, params) } + + let(:customer) { create(:customer) } + let(:params) do + { + root_name: "dunning_campaign", + dunning_campaign_code: "campaign_code" + } + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["dunning_campaign"]["external_customer_id"]).to eq(customer.external_id) + expect(result["dunning_campaign"]["dunning_campaign_code"]).to eq("campaign_code") + expect(result["dunning_campaign"]["overdue_balance_cents"]).to eq(customer.overdue_balance_cents) + expect(result["dunning_campaign"]["overdue_balance_currency"]).to eq(customer.currency) + + # Deprecated fields that must be kept for backward compatibility + expect(result["dunning_campaign"]["external_customer_id"]).to eq(customer.external_id) + end +end diff --git a/spec/serializers/v1/entitlement/feature_serializer_spec.rb b/spec/serializers/v1/entitlement/feature_serializer_spec.rb new file mode 100644 index 0000000..a69c999 --- /dev/null +++ b/spec/serializers/v1/entitlement/feature_serializer_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::Entitlement::FeatureSerializer do + subject { described_class.new(feature) } + + let(:organization) { create(:organization) } + let(:feature) do + create(:feature, + organization:, + code: "seats", + name: "Number of seats", + description: "Number of users of the account") + end + let(:max_admins) { create(:privilege, feature:, code: "max_admins", value_type: "integer") } + let(:max) { create(:privilege, feature:, code: "max", name: "Maximum", value_type: "integer") } + + before do + max + max_admins + end + + describe "#serialize" do + it "serializes the feature with privileges" do + result = subject.serialize + + expect(result).to include( + code: "seats", + name: "Number of seats", + description: "Number of users of the account", + created_at: feature.created_at.iso8601 + ) + + expect(result[:privileges]).to contain_exactly( + { + code: "max_admins", + name: nil, + value_type: "integer", + config: {} + }, + { + code: "max", + name: "Maximum", + value_type: "integer", + config: {} + } + ) + end + + it "includes all required fields" do + result = subject.serialize + + expect(result.keys).to match_array([:code, :name, :description, :privileges, :created_at]) + end + + it "formats created_at as ISO8601" do + result = subject.serialize + + expect(result[:created_at]).to eq(feature.created_at.iso8601) + end + + context "when feature has no privileges" do + subject { described_class.new(feature_without_privileges) } + + let(:feature_without_privileges) do + create(:feature, + organization:, + code: "no_privileges", + name: "No Privileges", + description: "Feature without privileges") + end + + it "returns empty privileges hash" do + result = subject.serialize + + expect(result[:privileges]).to eq([]) + end + end + end +end diff --git a/spec/serializers/v1/entitlement/plan_entitlement_serializer_spec.rb b/spec/serializers/v1/entitlement/plan_entitlement_serializer_spec.rb new file mode 100644 index 0000000..ddc741f --- /dev/null +++ b/spec/serializers/v1/entitlement/plan_entitlement_serializer_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::Entitlement::PlanEntitlementSerializer do + subject { described_class.new(entitlement) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege) { create(:privilege, :integer_type, feature:, organization:) } + let(:privilege2) { create(:privilege, :boolean_type, feature:, organization:) } + let(:privilege3) { create(:privilege, :string_type, feature:, organization:) } + let(:privilege4) { create(:privilege, :select_type, feature:, organization:) } + + let(:entitlement) { create(:entitlement, organization:, feature:, plan:) } + let(:entitlement_value) { create(:entitlement_value, value: "30", entitlement:, privilege:, organization:) } + let(:entitlement_value2) { create(:entitlement_value, value: "false", entitlement:, privilege: privilege2, organization:) } + let(:entitlement_value3) { create(:entitlement_value, value: :str, entitlement:, privilege: privilege3, organization:) } + let(:entitlement_value4) { create(:entitlement_value, value: "option1", entitlement:, privilege: privilege4, organization:) } + + describe "#serialize" do + before do + entitlement_value + entitlement_value2 + entitlement_value3 + entitlement_value4 + end + + it "serializes the entitlement correctly" do + result = subject.serialize + + expect(result).to include( + code: "seats", + name: feature.name, + description: feature.description + ) + expect(result[:privileges]).to contain_exactly( + {code: "int", name: nil, value_type: "integer", value: 30, config: {}}, + {code: "bool", name: nil, value_type: "boolean", value: false, config: {}}, + {code: "str", name: nil, value_type: "string", value: "str", config: {}}, + {code: "opt", name: nil, value_type: "select", value: "option1", config: { + "select_options" => ["option1", "option2", "option3"] + }} + ) + end + end +end diff --git a/spec/serializers/v1/entitlement/subscription_entitlement_serializer_spec.rb b/spec/serializers/v1/entitlement/subscription_entitlement_serializer_spec.rb new file mode 100644 index 0000000..5bfbb73 --- /dev/null +++ b/spec/serializers/v1/entitlement/subscription_entitlement_serializer_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::Entitlement::SubscriptionEntitlementSerializer do + subject(:serializer) do + ::CollectionSerializer.new( + collection, + described_class, + collection_name: "entitlements" + ) + end + + let(:organization) { create(:organization) } + let(:collection) { Entitlement::SubscriptionEntitlement.for_subscription(subscription) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege1) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + let(:privilege2) { create(:privilege, organization:, feature:, code: "reset", value_type: "string") } + let(:privilege3) { create(:privilege, organization:, feature:, code: "root?", value_type: "boolean") } + + let(:entitlement) { create(:entitlement, plan:, feature:) } + let(:entitlement_value1) { create(:entitlement_value, entitlement:, privilege: privilege1, value: 30, created_at: 2.days.ago) } + let(:entitlement_value2) { create(:entitlement_value, entitlement:, privilege: privilege2, value: :email) } + + let(:sub_entitlement) { create(:entitlement, subscription:, plan: nil, feature:) } + let(:entitlement_value3) { create(:entitlement_value, entitlement: sub_entitlement, privilege: privilege3, value: true, created_at: 3.days.ago) } + let(:entitlement_value25) { create(:entitlement_value, entitlement: sub_entitlement, privilege: privilege2, value: :slack) } + + let(:feature2) { create(:feature, organization:, code: "storage", name: nil, description: nil) } + let(:privilege4) { create(:privilege, organization:, feature: feature2, code: "limit", name: "L", value_type: "integer") } + let(:entitlement2) { create(:entitlement, plan:, feature: feature2, created_at: 12.years.ago) } + let(:entitlement_value4) { create(:entitlement_value, entitlement: entitlement2, privilege: privilege4, value: 100) } + let(:entitlement25) { create(:entitlement, plan: nil, subscription:, feature: feature2, created_at: 1.year.ago) } + let(:entitlement_value45) { create(:entitlement_value, entitlement: entitlement25, privilege: privilege4, value: 999) } + + before do + entitlement_value1 + entitlement_value2 + entitlement_value25 + entitlement_value3 + entitlement_value4 + entitlement_value45 + end + + describe "#serialize" do + subject { serializer.serialize } + + it "returns the correct structure" do + expect(subject).to have_key(:entitlements) + expect(subject[:entitlements]).to be_an(Array) + expect(subject[:entitlements].length).to eq(2) + end + + it "groups entitlements by feature" do + seats = subject[:entitlements].find { |e| e[:code] == "seats" }.deep_symbolize_keys + + expect(seats).to include({ + code: "seats", + name: "Feature Name", + description: "Feature Description" + }) + expect(seats[:privileges]).to contain_exactly({ + code: "root?", + name: nil, + value_type: "boolean", + config: {}, + value: true, + plan_value: nil, + override_value: true + }, { + code: "max", + name: nil, + value_type: "integer", + config: {}, + value: 30, + plan_value: 30, + override_value: nil + }, { + code: "reset", + name: nil, + value_type: "string", + config: {}, + value: "slack", + plan_value: "email", + override_value: "slack" + }) + expect(seats[:overrides]).to eq({ + reset: "slack", + root?: true + }) + + # Privileges are sorted by EntitlementValue.created_at + expect(seats[:privileges].map { |p| p[:code] }).to eq([entitlement_value3.privilege.code, entitlement_value1.privilege.code, entitlement_value2.privilege.code]) + + storage = subject[:entitlements].find { |e| e[:code] == "storage" }.deep_symbolize_keys + + expect(storage).to include({ + code: "storage", + name: nil, + description: nil + }) + expect(storage[:privileges]).to contain_exactly({ + code: "limit", + name: "L", + value_type: "integer", + config: {}, + value: 999, + plan_value: 100, + override_value: 999 + }) + expect(storage[:overrides]).to eq({limit: 999}) + + # Features are sorted by Entitlement.created_at + expect(subject[:entitlements].map { |p| p[:code] }).to eq([feature2.code, feature.code]) + end + + context "when there are no entitlements" do + let(:collection) { [] } + + it "returns empty array" do + expect(subject[:entitlements]).to eq([]) + end + end + + context "when subscription has no overrides" do + let(:subscription_without_override) { create(:subscription, organization:, plan:) } + let(:collection) { + Entitlement::SubscriptionEntitlement.for_subscription(subscription_without_override) + } + + it "returns the same entitlements as plan" do + expect(subject[:entitlements].map { it[:overrides] }).to all be_empty + expect(subject[:entitlements].map { it[:code] }).to eq %w[storage seats] + expect(subject[:entitlements].map { it[:privileges] }.flatten).to contain_exactly({ + code: "limit", + name: "L", + value_type: "integer", + config: {}, + value: 100, + plan_value: 100, + override_value: nil + }, { + code: "max", + name: nil, + value_type: "integer", + config: {}, + value: 30, + plan_value: 30, + override_value: nil + }, { + code: "reset", + name: nil, + value_type: "string", + config: {}, + value: "email", + plan_value: "email", + override_value: nil + }) + end + end + + context "when feature has no entitlements" do + let(:other_sub) { create(:subscription, organization:, plan:) } + let(:collection) { + Entitlement::SubscriptionEntitlement.for_subscription(other_sub) + } + + before do + api_v2 = create(:feature, organization:, code: "api_v2") + create(:entitlement, organization:, feature: api_v2, plan: nil, subscription: other_sub, created_at: 1.hour.from_now) + end + + it "returns the same entitlements as plan" do + expect(subject[:entitlements].map { it[:code] }).to eq %w[storage seats api_v2] + expect(subject[:entitlements].last[:overrides]).to be_empty + expect(subject[:entitlements].last[:privileges]).to be_empty + end + end + end +end diff --git a/spec/serializers/v1/error_detail_serializer_spec.rb b/spec/serializers/v1/error_detail_serializer_spec.rb new file mode 100644 index 0000000..8299caa --- /dev/null +++ b/spec/serializers/v1/error_detail_serializer_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::ErrorDetailSerializer do + subject(:serializer) { described_class.new(error_detail, root_name: "error_detail") } + + let(:error_detail) { create(:error_detail) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["error_detail"]).to include( + "lago_id" => error_detail.id, + "error_code" => error_detail.error_code, + "details" => error_detail.details + ) + end +end diff --git a/spec/serializers/v1/errors/error_serializer_factory_spec.rb b/spec/serializers/v1/errors/error_serializer_factory_spec.rb new file mode 100644 index 0000000..898deaa --- /dev/null +++ b/spec/serializers/v1/errors/error_serializer_factory_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::Errors::ErrorSerializerFactory do + subject(:serializer) { described_class.new_instance(error) } + + describe ".new_instance" do + context "when error is a Stripe error" do + let(:error) { ::Stripe::StripeError.new } + + it "returns a StripeErrorSerializer instance" do + expect(serializer).to be_a(V1::Errors::StripeErrorSerializer) + end + end + + context "when error is not a Stripe error" do + let(:error) { StandardError.new } + + it "returns a base ErrorSerializer instance" do + expect(serializer).to be_a(ErrorSerializer) + end + end + end +end diff --git a/spec/serializers/v1/errors/stripe_error_serializer_spec.rb b/spec/serializers/v1/errors/stripe_error_serializer_spec.rb new file mode 100644 index 0000000..6c3bfd0 --- /dev/null +++ b/spec/serializers/v1/errors/stripe_error_serializer_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::Errors::StripeErrorSerializer do + describe "#serialize" do + subject(:serializer) { described_class.new(error) } + + let(:error) do + Stripe::StripeError.new( + "Your card was declined.", + code: "card_declined", + http_headers: {"request-id" => "req_123"}, + http_status: 402, + http_body: '{"error": {"type": "card_error"}}' + ) + end + + it "serializes the Stripe error with all attributes" do + expect(serializer.serialize).to eq({ + code: "card_declined", + message: "Your card was declined.", + request_id: "req_123", + http_status: 402, + http_body: {"error" => {"type" => "card_error"}} + }) + end + + context "when http_body is nil" do + before do + allow(error).to receive(:http_body).and_return(nil) + end + + it "returns empty object for http_body" do + expect(serializer.serialize[:http_body]).to eq({}) + end + end + end +end diff --git a/spec/serializers/v1/event_enriched_serializer_spec.rb b/spec/serializers/v1/event_enriched_serializer_spec.rb new file mode 100644 index 0000000..748aff6 --- /dev/null +++ b/spec/serializers/v1/event_enriched_serializer_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::EventEnrichedSerializer, clickhouse: true do + subject(:serializer) { described_class.new(event, root_name: "event") } + + let(:event) do + create( + :clickhouse_events_enriched, + transaction_id: "tx_123", + external_subscription_id: "sub_456", + code: "api_call", + timestamp: Time.zone.parse("2024-01-15T10:30:45.123Z"), + enriched_at: Time.zone.parse("2024-01-15T10:30:50.456Z"), + value: "42.5", + decimal_value: BigDecimal("42.5"), + precise_total_amount_cents: BigDecimal("1234.567"), + properties: {region: "us-east-1"} + ) + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the enriched event" do + expect(result["event"]).to include( + "transaction_id" => "tx_123", + "external_subscription_id" => "sub_456", + "code" => "api_call", + "timestamp" => "2024-01-15T10:30:45.123Z", + "enriched_at" => "2024-01-15T10:30:50.456Z", + "value" => "42.5", + "decimal_value" => "42.5", + "precise_total_amount_cents" => "1234.567", + "properties" => {"region" => "us-east-1"} + ) + end + + context "when enriched_at is nil" do + let(:event) { create(:clickhouse_events_enriched, enriched_at: nil) } + + it "serializes enriched_at as nil" do + expect(result["event"]["enriched_at"]).to be_nil + end + end + + context "when precise_total_amount_cents is nil" do + let(:event) { create(:clickhouse_events_enriched, precise_total_amount_cents: nil) } + + it "serializes precise_total_amount_cents as nil" do + expect(result["event"]["precise_total_amount_cents"]).to be_nil + end + end +end diff --git a/spec/serializers/v1/event_error_serializer_spec.rb b/spec/serializers/v1/event_error_serializer_spec.rb new file mode 100644 index 0000000..0617a0f --- /dev/null +++ b/spec/serializers/v1/event_error_serializer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::EventErrorSerializer do + subject(:serializer) { described_class.new(event_error, root_name: "event_error") } + + let(:event_error) do + OpenStruct.new( + error: {transaction_id: ["value_already_exist"]}, + event: create(:received_event) + ) + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes object" do + expect(result["event_error"]).to include( + "status" => 422, + "error" => "Unprocessable entity", + "message" => '{"transaction_id":["value_already_exist"]}' + ) + + expect(result["event_error"]["event"]).to include( + "lago_id" => event_error.event.id, + "transaction_id" => event_error.event.transaction_id, + "lago_customer_id" => nil, + "code" => event_error.event.code, + "timestamp" => event_error.event.timestamp.iso8601(3), + "properties" => event_error.event.properties, + "lago_subscription_id" => nil, + "external_subscription_id" => event_error.event.external_subscription_id, + "created_at" => event_error.event.created_at.iso8601 + ) + end +end diff --git a/spec/serializers/v1/event_serializer_spec.rb b/spec/serializers/v1/event_serializer_spec.rb new file mode 100644 index 0000000..44c091c --- /dev/null +++ b/spec/serializers/v1/event_serializer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::EventSerializer do + subject(:serializer) { described_class.new(event, root_name: "event") } + + let(:event) do + create( + :event, + customer_id: nil, + subscription_id: nil, + precise_total_amount_cents: "123.6", + properties: { + item_value: "12" + } + ) + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the event" do + expect(result["event"]).to include( + "lago_id" => event.id, + "transaction_id" => event.transaction_id, + "lago_customer_id" => event.customer_id, + "code" => event.code, + "timestamp" => event.timestamp.iso8601(3), + "precise_total_amount_cents" => "123.6", + "properties" => event.properties, + "lago_subscription_id" => event.subscription_id, + "external_subscription_id" => event.external_subscription_id, + "created_at" => event.created_at.iso8601 + ) + end +end diff --git a/spec/serializers/v1/events_validation_errors_serializer_spec.rb b/spec/serializers/v1/events_validation_errors_serializer_spec.rb new file mode 100644 index 0000000..9a70a95 --- /dev/null +++ b/spec/serializers/v1/events_validation_errors_serializer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::EventsValidationErrorsSerializer do + subject(:serializer) { described_class.new(errors, root_name: "events_errors") } + + let(:errors) do + { + invalid_code: [SecureRandom.uuid], + missing_aggregation_property: [SecureRandom.uuid], + missing_group_key: [SecureRandom.uuid], + invalid_filter_values: [SecureRandom.uuid] + }.with_indifferent_access + end + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the validation errors" do + expect(result["events_errors"]).to include( + "invalid_code" => Array, + "missing_aggregation_property" => Array, + "missing_group_key" => Array, + "invalid_filter_values" => Array + ) + end +end diff --git a/spec/serializers/v1/fee_serializer_spec.rb b/spec/serializers/v1/fee_serializer_spec.rb new file mode 100644 index 0000000..3a81273 --- /dev/null +++ b/spec/serializers/v1/fee_serializer_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::FeeSerializer do + subject(:serializer) { described_class.new(fee, root_name: "fee", includes: inclusion) } + + let(:fee) do + create( + :fee, + properties: { + from_datetime: Time.current, + to_datetime: Time.current + }, + presentation_breakdowns: [build(:presentation_breakdown)] + ) + end + + let(:inclusion) { [] } + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the fee" do + expect(result["fee"]).to include( + "lago_id" => fee.id, + "lago_charge_id" => fee.charge_id, + "lago_charge_filter_id" => fee.charge_filter_id, + "lago_invoice_id" => fee.invoice_id, + "lago_true_up_fee_id" => fee.true_up_fee&.id, + "lago_true_up_parent_fee_id" => fee.true_up_parent_fee_id, + "lago_subscription_id" => fee.subscription_id, + "external_subscription_id" => fee.subscription&.external_id, + "lago_customer_id" => fee.customer&.id, + "external_customer_id" => fee.customer&.external_id, + "amount_cents" => fee.amount_cents, + "amount_currency" => fee.amount_currency, + "taxes_amount_cents" => fee.taxes_amount_cents, + "taxes_rate" => fee.taxes_rate, + "total_aggregated_units" => fee.total_aggregated_units, + "total_amount_cents" => fee.total_amount_cents, + "total_amount_currency" => fee.amount_currency, + "precise_amount" => fee.precise_amount_cents.fdiv(100.to_d).to_s, + "taxes_precise_amount" => fee.taxes_precise_amount_cents.fdiv(100.to_d).to_s, + "precise_total_amount" => fee.precise_total_amount_cents.fdiv(100.to_d).to_s, + "units" => fee.units.to_s, + "precise_unit_amount" => fee.precise_unit_amount.to_s, + "precise_coupons_amount_cents" => fee.precise_coupons_amount_cents.to_s, + "sub_total_excluding_taxes_amount_cents" => fee.sub_total_excluding_taxes_amount_cents.round, + "sub_total_excluding_taxes_precise_amount_cents" => fee.sub_total_excluding_taxes_precise_amount_cents.to_s, + "pay_in_advance" => fee.subscription.plan.pay_in_advance, + "invoiceable" => true, + "events_count" => fee.events_count, + "payment_status" => fee.payment_status, + "created_at" => fee.created_at&.iso8601, + "succeeded_at" => fee.succeeded_at&.iso8601, + "failed_at" => fee.failed_at&.iso8601, + "refunded_at" => fee.refunded_at&.iso8601, + "amount_details" => fee.amount_details, + "self_billed" => fee.invoice.self_billed, + "pricing_unit_details" => nil, + "presentation_breakdowns" => [{"presentation_by" => {"department" => "engineering"}, "units" => BigDecimal(60).to_s}] + ) + expect(result["fee"]["item"]).to include( + "type" => fee.fee_type, + "code" => fee.item_code, + "name" => fee.item_name, + "description" => fee.item_description, + "invoice_display_name" => fee.invoice_name, + "filter_invoice_display_name" => fee.charge_filter&.display_name, + "filters" => nil, + "lago_item_id" => fee.item_id, + "item_type" => fee.item_type, + "grouped_by" => fee.grouped_by + ) + + expect(result["fee"]["from_date"]).not_to be_nil + expect(result["fee"]["to_date"]).not_to be_nil + end + + context "when fee is not attached to an invoice" do + let(:fee) { create(:fee, invoice: nil) } + + it "serialize self_billed as false" do + expect(result["fee"]).to include( + "lago_invoice_id" => nil, + "self_billed" => false + ) + end + end + + context "when fee is charge" do + let(:charge) { charge_filter.charge } + let(:charge_filter) { create(:charge_filter) } + + let(:fee) do + create( + :charge_fee, + charge:, + charge_filter:, + properties: { + charges_from_datetime: Time.current, + charges_to_datetime: Time.current + } + ) + end + + it "serializes the fees with dates boundaries" do + expect(result["fee"]["from_date"]).not_to be_nil + expect(result["fee"]["to_date"]).not_to be_nil + expect(result["fee"]["item"]).to include( + "type" => fee.fee_type, + "code" => fee.item_code, + "name" => fee.item_name, + "invoice_display_name" => fee.invoice_name, + "filter_invoice_display_name" => fee.filter_display_name, + "lago_item_id" => fee.item_id, + "item_type" => fee.item_type + ) + end + + context "with pay in advance charge" do + let(:timestamp) { DateTime.new(2023, 12, 13, 0, 0) } + let(:fee) do + create( + :charge_fee, + charge:, + properties: { + charges_from_datetime: (timestamp - 1.month).beginning_of_day, + charges_to_datetime: (timestamp - 1.day).end_of_day + } + ) + end + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice: fee.invoice, + subscription: fee.subscription, + timestamp: + ) + end + + before do + invoice_subscription + charge.update!(pay_in_advance: true) + fee.subscription.update!( + started_at: timestamp - 1.year, + billing_time: "anniversary", + subscription_at: timestamp + ) + end + + it "serializes the fees with dates boundaries" do + expect(result["fee"]["from_date"]).to eq("2023-12-13T00:00:00+00:00") + expect(result["fee"]["to_date"]).to eq("2024-01-12T23:59:59+00:00") + expect(result["fee"]["item"]).to include( + "type" => fee.fee_type, + "code" => fee.item_code, + "name" => fee.item_name, + "invoice_display_name" => fee.invoice_name, + "filter_invoice_display_name" => fee.filter_display_name, + "lago_item_id" => fee.item_id, + "item_type" => fee.item_type + ) + end + end + end + + context "when fee is add_on" do + let(:fee) { create(:add_on_fee) } + + it "does not serializes the fees with date boundaries" do + expect(result["fee"]["from_date"]).to be_nil + expect(result["fee"]["to_date"]).to be_nil + end + end + + context "when fee is fixed_charge" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:from_datetime) { "2024-03-30T00:00:00+00:00" } + let(:to_datetime) { "2024-04-29T23:59:59+00:00" } + + let(:fee) do + create( + :fixed_charge_fee, + subscription:, + fixed_charge:, + properties: { + fixed_charges_from_datetime: Time.zone.parse(from_datetime), + fixed_charges_to_datetime: Time.zone.parse(to_datetime) + } + ) + end + + it "serializes the fee with fixed_charge date boundaries" do + expect(result["fee"]["lago_fixed_charge_id"]).to eq(fixed_charge.id) + expect(result["fee"]["from_date"]).not_to be_nil + expect(result["fee"]["to_date"]).not_to be_nil + expect(result["fee"]["from_date"]).to eq(from_datetime) + expect(result["fee"]["to_date"]).to eq(to_datetime) + expect(result["fee"]["item"]).to include( + "type" => "fixed_charge", + "code" => fixed_charge.add_on.code, + "name" => fixed_charge.add_on.name + ) + expect(result["fee"]["pay_in_advance"]).to eq(false) + end + + context "with pay_in_advance fixed charge" do + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + let(:fee) do + create( + :fixed_charge_fee, + subscription:, + fixed_charge:, + pay_in_advance: true, + properties: { + fixed_charges_from_datetime: Time.zone.parse(from_datetime), + fixed_charges_to_datetime: Time.zone.parse(to_datetime) + } + ) + end + + it "serializes pay_in_advance fixed charge with date boundaries" do + expect(result["fee"]["lago_fixed_charge_id"]).to eq(fixed_charge.id) + expect(result["fee"]["from_date"]).not_to be_nil + expect(result["fee"]["to_date"]).not_to be_nil + expect(result["fee"]["from_date"]).to eq(from_datetime) + expect(result["fee"]["to_date"]).to eq(to_datetime) + expect(result["fee"]["pay_in_advance"]).to eq(true) + expect(result["fee"]["event_transaction_id"]).to be_nil + end + end + end + + context "when fee is one_off" do + let(:fee) { create(:one_off_fee) } + + it "does not serializes the fees with date boundaries" do + expect(result["fee"]["from_date"]).to be_nil + expect(result["fee"]["to_date"]).to be_nil + end + end + + context "when pay_in_advance attributes are included" do + let(:inclusion) { %i[pay_in_advance] } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, organization:, plan:) } + let(:charge) { create(:standard_charge, :pay_in_advance, plan:) } + + let(:event) do + create( + :event, + subscription_id: subscription.id, + organization_id: organization.id, + customer_id: customer.id + ) + end + + let(:fee) do + create(:charge_fee, pay_in_advance: true, subscription:, charge:, pay_in_advance_event_id: event.id, pay_in_advance_event_transaction_id: event.transaction_id) + end + + it "serializes the pay_in_advance charge attributes" do + expect(result["fee"]).to include( + "lago_subscription_id" => subscription.id, + "external_subscription_id" => subscription.external_id, + "lago_customer_id" => customer.id, + "external_customer_id" => customer.external_id, + "event_transaction_id" => fee.pay_in_advance_event_transaction_id, + "pay_in_advance" => true, + "invoiceable" => true + ) + end + end + + context "when pricing_unit_usage attributes are included" do + let!(:pricing_unit_usage) { create(:pricing_unit_usage, fee:) } + + it "serializes the pricing_unit_usage" do + expect(result["fee"]["pricing_unit_details"]).to be_present + expect(result["fee"]["pricing_unit_details"]).to include( + "lago_pricing_unit_id" => pricing_unit_usage.pricing_unit_id, + "short_name" => pricing_unit_usage.short_name, + "amount_cents" => pricing_unit_usage.amount_cents + ) + end + end +end diff --git a/spec/serializers/v1/fees/applied_tax_serializer_spec.rb b/spec/serializers/v1/fees/applied_tax_serializer_spec.rb new file mode 100644 index 0000000..acc1b56 --- /dev/null +++ b/spec/serializers/v1/fees/applied_tax_serializer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Fees::AppliedTaxSerializer do + subject(:serializer) { described_class.new(applied_tax, root_name: "applied_tax") } + + let(:applied_tax) { create(:fee_applied_tax) } + + before { applied_tax } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["applied_tax"]["lago_id"]).to eq(applied_tax.id) + expect(result["applied_tax"]["lago_fee_id"]).to eq(applied_tax.fee_id) + expect(result["applied_tax"]["lago_tax_id"]).to eq(applied_tax.tax_id) + expect(result["applied_tax"]["tax_name"]).to eq(applied_tax.tax_name) + expect(result["applied_tax"]["tax_code"]).to eq(applied_tax.tax_code) + expect(result["applied_tax"]["tax_rate"]).to eq(applied_tax.tax_rate) + expect(result["applied_tax"]["tax_description"]).to eq(applied_tax.tax_description) + expect(result["applied_tax"]["amount_cents"]).to eq(applied_tax.amount_cents) + expect(result["applied_tax"]["amount_currency"]).to eq(applied_tax.amount_currency) + expect(result["applied_tax"]["created_at"]).to eq(applied_tax.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/fixed_charge_serializer_spec.rb b/spec/serializers/v1/fixed_charge_serializer_spec.rb new file mode 100644 index 0000000..1286a40 --- /dev/null +++ b/spec/serializers/v1/fixed_charge_serializer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::FixedChargeSerializer do + subject(:result) { JSON.parse(serializer.to_json) } + + let(:serializer) { described_class.new(fixed_charge, root_name: "fixed_charge", includes: %i[taxes]) } + let(:fixed_charge) { create(:fixed_charge, properties:) } + let(:properties) { {"amount" => "1000"} } + + it "serializes the object" do + expect(result["fixed_charge"]["lago_id"]).to eq(fixed_charge.id) + expect(result["fixed_charge"]["lago_add_on_id"]).to eq(fixed_charge.add_on_id) + expect(result["fixed_charge"]["code"]).to eq(fixed_charge.code) + expect(result["fixed_charge"]["invoice_display_name"]).to eq(fixed_charge.invoice_display_name) + expect(result["fixed_charge"]["add_on_code"]).to eq(fixed_charge.add_on.code) + expect(result["fixed_charge"]["created_at"]).to eq(fixed_charge.created_at.iso8601) + expect(result["fixed_charge"]["charge_model"]).to eq(fixed_charge.charge_model) + expect(result["fixed_charge"]["pay_in_advance"]).to eq(fixed_charge.pay_in_advance) + expect(result["fixed_charge"]["prorated"]).to eq(fixed_charge.prorated) + expect(result["fixed_charge"]["properties"]).to eq(fixed_charge.properties) + expect(result["fixed_charge"]["taxes"]).to eq([]) + expect(result["fixed_charge"]["units"]).to eq(fixed_charge.units.to_s) + expect(result["fixed_charge"]["lago_parent_id"]).to eq(fixed_charge.parent_id) + end + + context "when fixed charge has taxes" do + let(:fixed_charge) { create(:fixed_charge, :with_applied_taxes, properties:, taxes:) } + let(:taxes) { create_pair(:tax) } + + it "serializes the object" do + expect(result["fixed_charge"]["taxes"].map { |tax| tax["lago_id"] }).to match_array(taxes.map(&:id)) + end + end +end diff --git a/spec/serializers/v1/integration_customer_serializer_spec.rb b/spec/serializers/v1/integration_customer_serializer_spec.rb new file mode 100644 index 0000000..eeeee8c --- /dev/null +++ b/spec/serializers/v1/integration_customer_serializer_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::IntegrationCustomerSerializer do + subject(:serializer) { described_class.new(integration_customer, root_name: "integration_customer") } + + let(:integration_customer) { create(:netsuite_customer) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["integration_customer"]).to include( + "lago_id" => integration_customer.id, + "external_customer_id" => integration_customer.external_customer_id, + "type" => "netsuite", + "sync_with_provider" => integration_customer.sync_with_provider, + "subsidiary_id" => integration_customer.subsidiary_id + ) + end + + describe "#type" do + subject(:type_call) { serializer.__send__(:type) } + + let(:integration_customer) { create(:netsuite_customer) } + + context "when customer is a netsuite customer" do + it "returns netsuite" do + expect(subject).to eq("netsuite") + end + end + + context "when customer is an anrok customer" do + let(:integration_customer) { create(:anrok_customer) } + + it "returns anrok" do + expect(subject).to eq("anrok") + end + end + + context "when customer is a xero customer" do + let(:integration_customer) { create(:xero_customer) } + + it "returns xero" do + expect(subject).to eq("xero") + end + end + + context "when customer is a hubspot customer" do + let(:integration_customer) { create(:hubspot_customer) } + + it "returns hubspot" do + expect(subject).to eq("hubspot") + end + end + + context "when customer is a salesforce customer" do + let(:integration_customer) { create(:salesforce_customer) } + + it "returns salesforce" do + expect(subject).to eq("salesforce") + end + end + end +end diff --git a/spec/serializers/v1/integrations/customer_error_serializer_spec.rb b/spec/serializers/v1/integrations/customer_error_serializer_spec.rb new file mode 100644 index 0000000..f5ae8c8 --- /dev/null +++ b/spec/serializers/v1/integrations/customer_error_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Integrations::CustomerErrorSerializer do + subject(:serializer) { described_class.new(customer, options) } + + let(:integration_customer) { create(:netsuite_customer) } + let(:customer) { integration_customer.customer } + let(:options) do + { + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + }, + "provider" => "netsuite", + "provider_code" => integration_customer.integration.code + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_customer_id"]).to eq(customer.id) + expect(result["data"]["external_customer_id"]).to eq(customer.external_id) + expect(result["data"]["accounting_provider"]).to eq(options[:provider]) + expect(result["data"]["accounting_provider_code"]).to eq(integration_customer.integration.code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/integrations/provider_error_serializer_spec.rb b/spec/serializers/v1/integrations/provider_error_serializer_spec.rb new file mode 100644 index 0000000..c954ca1 --- /dev/null +++ b/spec/serializers/v1/integrations/provider_error_serializer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Integrations::ProviderErrorSerializer do + subject(:serializer) { described_class.new(integration, options) } + + let(:integration) { create(:netsuite_integration) } + + let(:options) do + { + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + }, + "provider" => "netsuite", + "provider_code" => integration.code + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_integration_id"]).to eq(integration.id) + expect(result["data"]["provider"]).to eq(options[:provider]) + expect(result["data"]["provider_code"]).to eq(integration.code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/integrations/taxes/customer_error_serializer_spec.rb b/spec/serializers/v1/integrations/taxes/customer_error_serializer_spec.rb new file mode 100644 index 0000000..c502782 --- /dev/null +++ b/spec/serializers/v1/integrations/taxes/customer_error_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Integrations::Taxes::CustomerErrorSerializer do + subject(:serializer) { described_class.new(customer, options) } + + let(:integration_customer) { create(:netsuite_customer) } + let(:customer) { integration_customer.customer } + let(:options) do + { + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + }, + "provider" => "netsuite", + "provider_code" => integration_customer.integration.code + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_customer_id"]).to eq(customer.id) + expect(result["data"]["external_customer_id"]).to eq(customer.external_id) + expect(result["data"]["tax_provider"]).to eq(options[:provider]) + expect(result["data"]["tax_provider_code"]).to eq(integration_customer.integration.code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/integrations/taxes/fee_error_serializer_spec.rb b/spec/serializers/v1/integrations/taxes/fee_error_serializer_spec.rb new file mode 100644 index 0000000..bbd4b41 --- /dev/null +++ b/spec/serializers/v1/integrations/taxes/fee_error_serializer_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Integrations::Taxes::FeeErrorSerializer do + subject(:serializer) { described_class.new(integration, options) } + + let(:integration) { create(:anrok_integration) } + let(:options) do + { + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + }, + "event_transaction_id" => "123", + "lago_charge_id" => "456" + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["tax_provider_code"]).to eq(integration.code) + expect(result["data"]["lago_charge_id"]).to eq("456") + expect(result["data"]["event_transaction_id"]).to eq("123") + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/invoice_custom_section_serializer_spec.rb b/spec/serializers/v1/invoice_custom_section_serializer_spec.rb new file mode 100644 index 0000000..7f270fc --- /dev/null +++ b/spec/serializers/v1/invoice_custom_section_serializer_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::InvoiceCustomSectionSerializer do + subject(:serializer) { described_class.new(invoice_custom_section, root_name: "invoice_custom_section") } + + let(:invoice_custom_section) { create(:invoice_custom_section) } + + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the section" do + expect(result["invoice_custom_section"]).to include( + "lago_id" => invoice_custom_section.id, + "code" => invoice_custom_section.code, + "name" => invoice_custom_section.name, + "description" => invoice_custom_section.description, + "details" => invoice_custom_section.details, + "display_name" => invoice_custom_section.display_name, + "organization_id" => invoice_custom_section.organization_id + ) + end +end diff --git a/spec/serializers/v1/invoice_serializer_spec.rb b/spec/serializers/v1/invoice_serializer_spec.rb new file mode 100644 index 0000000..c912798 --- /dev/null +++ b/spec/serializers/v1/invoice_serializer_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::InvoiceSerializer do + subject(:serializer) { described_class.new(invoice, root_name: "invoice", includes:) } + + let(:includes) { %i[metadata error_details] } + + let(:invoice) { create(:invoice) } + + let(:metadata) { create(:invoice_metadata, invoice:) } + let(:error_details1) { create(:error_detail, owner: invoice) } + let(:error_details2) { create(:error_detail, owner: invoice, deleted_at: Time.current) } + + before do + metadata + error_details1 + error_details2 + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["invoice"]).to include( + "lago_id" => invoice.id, + "billing_entity_code" => invoice.billing_entity.code, + "sequential_id" => invoice.sequential_id, + "number" => invoice.number, + "issuing_date" => invoice.issuing_date.iso8601, + "payment_due_date" => invoice.payment_due_date.iso8601, + "net_payment_term" => invoice.net_payment_term, + "invoice_type" => invoice.invoice_type, + "status" => invoice.status, + "payment_status" => invoice.payment_status, + "payment_dispute_lost_at" => invoice.payment_dispute_lost_at, + "payment_overdue" => invoice.payment_overdue, + "currency" => invoice.currency, + "fees_amount_cents" => invoice.fees_amount_cents, + "progressive_billing_credit_amount_cents" => invoice.progressive_billing_credit_amount_cents, + "coupons_amount_cents" => invoice.coupons_amount_cents, + "credit_notes_amount_cents" => invoice.credit_notes_amount_cents, + "prepaid_credit_amount_cents" => invoice.prepaid_credit_amount_cents, + "prepaid_granted_credit_amount_cents" => invoice.prepaid_granted_credit_amount_cents, + "prepaid_purchased_credit_amount_cents" => invoice.prepaid_purchased_credit_amount_cents, + "taxes_amount_cents" => invoice.taxes_amount_cents, + "sub_total_excluding_taxes_amount_cents" => invoice.sub_total_excluding_taxes_amount_cents, + "sub_total_including_taxes_amount_cents" => invoice.sub_total_including_taxes_amount_cents, + "total_amount_cents" => invoice.total_amount_cents, + "total_due_amount_cents" => invoice.total_due_amount_cents, + "file_url" => invoice.file_url, + "xml_url" => invoice.xml_url, + "error_details" => [ + { + "lago_id" => error_details1.id, + "error_code" => error_details1.error_code, + "details" => error_details1.details + } + ], + "version_number" => 4, + "self_billed" => invoice.self_billed, + "created_at" => invoice.created_at.iso8601, + "updated_at" => invoice.updated_at.iso8601 + ) + + expect(result["invoice"]["metadata"].first).to include( + "lago_id" => metadata.id, + "key" => metadata.key, + "value" => metadata.value + ) + end + + context "when invoice is a progressive_billing invoice" do + let(:invoice) { create(:invoice, invoice_type: :progressive_billing) } + let(:applied_usage_threshold) { create(:applied_usage_threshold, invoice:) } + + before { applied_usage_threshold } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["invoice"]["applied_usage_thresholds"].count).to eq(1) + end + end + + context "when including billing periods" do + let(:includes) { %i[billing_periods] } + let(:invoice_subscription) { create(:invoice_subscription, :boundaries, invoice:) } + + before { invoice_subscription } + + it "serializes the invoice_subscription" do + result = JSON.parse(serializer.to_json) + + expect(result["invoice"]["billing_periods"]).to be_present + end + end + + context "when including subscriptions with multiple subscriptions" do + let(:includes) { %i[subscriptions billing_periods] } + let(:organization) { invoice.organization } + let(:customer) { invoice.customer } + + let(:plan_zebra) { create(:plan, organization:, name: "Zebra Plan", invoice_display_name: nil) } + let(:plan_alpha) { create(:plan, organization:, name: "Alpha Plan", invoice_display_name: nil) } + + let(:subscription_zebra) { create(:subscription, customer:, plan: plan_zebra, name: nil) } + let(:subscription_alpha) { create(:subscription, customer:, plan: plan_alpha, name: nil) } + let(:subscription_custom) { create(:subscription, customer:, plan: plan_zebra, name: "AAA Custom") } + + before do + create(:invoice_subscription, :boundaries, invoice:, subscription: subscription_zebra) + create(:invoice_subscription, :boundaries, invoice:, subscription: subscription_alpha) + create(:invoice_subscription, :boundaries, invoice:, subscription: subscription_custom) + end + + it "orders subscriptions alphabetically by invoice_name" do + result = JSON.parse(serializer.to_json) + + expect(result["invoice"]["subscriptions"].map { |s| s["name"] }).to eq([ + "AAA Custom", + nil, + nil + ]) + end + + it "orders billing_periods alphabetically by subscription invoice_name" do + result = JSON.parse(serializer.to_json) + + billing_period_subscription_ids = result["invoice"]["billing_periods"].pluck("lago_subscription_id") + + expect(billing_period_subscription_ids).to eq([ + subscription_custom.id, + subscription_alpha.id, + subscription_zebra.id + ]) + end + end + + context "when includes fees" do + let(:fee1) { create(:fee, invoice:, presentation_breakdowns: [build(:presentation_breakdown)]) } + let(:fee2) { create(:fee, invoice:) } + + let(:includes) { %i[fees] } + + before do + fee1 + fee2 + end + + it "returns fees and presentation breakdowns" do + result = JSON.parse(serializer.to_json) + + expect(result["invoice"]["fees"].count).to eq(2) + expect(result["invoice"]["fees"].first["presentation_breakdowns"]).to eq([{"presentation_by" => {"department" => "engineering"}, "units" => "60.0"}]) + expect(result["invoice"]["fees"].second["presentation_breakdowns"]).to eq([]) + end + end + + context "when the tax was deleted" do + let(:includes) { %i[applied_taxes] } + + it "still return the tax_id" do + organization = invoice.organization + tax = create(:tax, organization:) + create(:invoice_applied_tax, invoice:, tax:) + + tax.discard! + invoice.reload + result = JSON.parse(serializer.to_json) + + expect(result["invoice"]["applied_taxes"].sole["lago_tax_id"]).to be_present + end + end +end diff --git a/spec/serializers/v1/invoices/applied_invoice_custom_section_serializer_spec.rb b/spec/serializers/v1/invoices/applied_invoice_custom_section_serializer_spec.rb new file mode 100644 index 0000000..1093c07 --- /dev/null +++ b/spec/serializers/v1/invoices/applied_invoice_custom_section_serializer_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::Invoices::AppliedInvoiceCustomSectionSerializer do + subject(:serializer) { described_class.new(applied_invoice_custom_section) } + + let(:invoice) { create(:invoice) } + let(:applied_invoice_custom_section) do + create(:applied_invoice_custom_section, + invoice:, + code: "custom_code", + details: "custom_details", + display_name: "Custom Display Name", + created_at: Time.current) + end + + describe "#serialize" do + it "serializes the applied invoice custom section correctly" do + serialized_data = serializer.serialize + + expect(serialized_data).to include( + lago_id: applied_invoice_custom_section.id, + lago_invoice_id: applied_invoice_custom_section.invoice_id, + code: "custom_code", + details: "custom_details", + display_name: "Custom Display Name", + created_at: applied_invoice_custom_section.created_at.iso8601 + ) + end + end +end diff --git a/spec/serializers/v1/invoices/applied_tax_serializer_spec.rb b/spec/serializers/v1/invoices/applied_tax_serializer_spec.rb new file mode 100644 index 0000000..37f4d7d --- /dev/null +++ b/spec/serializers/v1/invoices/applied_tax_serializer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Invoices::AppliedTaxSerializer do + subject(:serializer) { described_class.new(applied_tax, root_name: "applied_tax") } + + let(:applied_tax) { create(:invoice_applied_tax) } + + before { applied_tax } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["applied_tax"]["lago_id"]).to eq(applied_tax.id) + expect(result["applied_tax"]["lago_invoice_id"]).to eq(applied_tax.invoice_id) + expect(result["applied_tax"]["lago_tax_id"]).to eq(applied_tax.tax_id) + expect(result["applied_tax"]["tax_name"]).to eq(applied_tax.tax_name) + expect(result["applied_tax"]["tax_code"]).to eq(applied_tax.tax_code) + expect(result["applied_tax"]["tax_rate"]).to eq(applied_tax.tax_rate) + expect(result["applied_tax"]["tax_description"]).to eq(applied_tax.tax_description) + expect(result["applied_tax"]["amount_cents"]).to eq(applied_tax.amount_cents) + expect(result["applied_tax"]["amount_currency"]).to eq(applied_tax.amount_currency) + expect(result["applied_tax"]["fees_amount_cents"]).to eq(applied_tax.fees_amount_cents) + expect(result["applied_tax"]["created_at"]).to eq(applied_tax.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/invoices/billing_period_serializer_spec.rb b/spec/serializers/v1/invoices/billing_period_serializer_spec.rb new file mode 100644 index 0000000..4741851 --- /dev/null +++ b/spec/serializers/v1/invoices/billing_period_serializer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Invoices::BillingPeriodSerializer do + subject(:serializer) { described_class.new(invoice_subscription, root_name: "billing_period") } + + let(:invoice_subscription) { build(:invoice_subscription, :boundaries) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["billing_period"]["lago_subscription_id"]).to eq(invoice_subscription.subscription_id) + expect(result["billing_period"]["external_subscription_id"]).to eq(invoice_subscription.subscription.external_id) + expect(result["billing_period"]["lago_plan_id"]).to eq(invoice_subscription.subscription.plan_id) + expect(result["billing_period"]["subscription_from_datetime"]).to eq(invoice_subscription.from_datetime.iso8601) + expect(result["billing_period"]["subscription_to_datetime"]).to eq(invoice_subscription.to_datetime.iso8601) + expect(result["billing_period"]["charges_from_datetime"]).to eq(invoice_subscription.charges_from_datetime.iso8601) + expect(result["billing_period"]["charges_to_datetime"]).to eq(invoice_subscription.charges_to_datetime.iso8601) + expect(result["billing_period"]["fixed_charges_from_datetime"]).to eq(invoice_subscription.fixed_charges_from_datetime.iso8601) + expect(result["billing_period"]["fixed_charges_to_datetime"]).to eq(invoice_subscription.fixed_charges_to_datetime.iso8601) + expect(result["billing_period"]["invoicing_reason"]).to eq(invoice_subscription.invoicing_reason) + end + + context "when legacy invoice subscription without fixed charges boundaries" do + let(:invoice_subscription) { build(:invoice_subscription, :boundaries, fixed_charges_from_datetime: nil, fixed_charges_to_datetime: nil) } + + it "serializes the object without fixed charges boundaries" do + result = JSON.parse(serializer.to_json) + + expect(result["billing_period"]["lago_subscription_id"]).to eq(invoice_subscription.subscription_id) + expect(result["billing_period"]["fixed_charges_from_datetime"]).to be_nil + expect(result["billing_period"]["fixed_charges_to_datetime"]).to be_nil + end + end +end diff --git a/spec/serializers/v1/invoices/metadata_serializer_spec.rb b/spec/serializers/v1/invoices/metadata_serializer_spec.rb new file mode 100644 index 0000000..c69ea5c --- /dev/null +++ b/spec/serializers/v1/invoices/metadata_serializer_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Invoices::MetadataSerializer do + subject(:serializer) { described_class.new(metadata, root_name: "metadata") } + + let(:metadata) { create(:invoice_metadata) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["metadata"]["lago_id"]).to eq(metadata.id) + expect(result["metadata"]["key"]).to eq(metadata.key) + expect(result["metadata"]["value"]).to eq(metadata.value) + expect(result["metadata"]["created_at"]).to eq(metadata.created_at.iso8601) + end +end diff --git a/spec/serializers/v1/invoices/payment_dispute_lost_serializer_spec.rb b/spec/serializers/v1/invoices/payment_dispute_lost_serializer_spec.rb new file mode 100644 index 0000000..d7c06bf --- /dev/null +++ b/spec/serializers/v1/invoices/payment_dispute_lost_serializer_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Invoices::PaymentDisputeLostSerializer do + subject(:serializer) { described_class.new(invoice, options) } + + let(:invoice) { create(:invoice, :dispute_lost) } + + context "when options are present" do + let(:options) do + { + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["invoice"]["lago_id"]).to eq(invoice.id) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end + end + + context "when options are not present" do + let(:options) do + {} + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["invoice"]["lago_id"]).to eq(invoice.id) + expect(result["data"].key?("provider_error")).to eq(false) + end + end +end diff --git a/spec/serializers/v1/lifetime_usage_serializer_spec.rb b/spec/serializers/v1/lifetime_usage_serializer_spec.rb new file mode 100644 index 0000000..d2f9c6e --- /dev/null +++ b/spec/serializers/v1/lifetime_usage_serializer_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::LifetimeUsageSerializer do + subject(:serializer) { described_class.new(lifetime_usage, root_name: "lifetime_usage", includes: %i[usage_thresholds]) } + + let(:lifetime_usage) { create(:lifetime_usage, organization:, subscription:, historical_usage_amount_cents:, invoiced_usage_amount_cents:, current_usage_amount_cents:) } + let(:historical_usage_amount_cents) { 15 } + let(:invoiced_usage_amount_cents) { 12 } + let(:current_usage_amount_cents) { 18 } + let(:subscription) { create(:subscription) } + let(:organization) { subscription.organization } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + expect(result["lifetime_usage"]).to include( + "lago_id" => lifetime_usage.id, + "lago_subscription_id" => lifetime_usage.subscription.id, + "external_subscription_id" => lifetime_usage.subscription.external_id, + "external_historical_usage_amount_cents" => historical_usage_amount_cents, + "invoiced_usage_amount_cents" => invoiced_usage_amount_cents, + "current_usage_amount_cents" => current_usage_amount_cents + ) + end + + context "with usage_thresholds in the plan" do + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, customer:) } + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 100) } + let(:usage_threshold2) { create(:usage_threshold, plan:, amount_cents: 200) } + + let(:applied_usage_threshold) { create(:applied_usage_threshold, lifetime_usage_amount_cents: 120, usage_threshold: usage_threshold, invoice:) } + + let(:invoice) { create(:invoice, organization:, customer:) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:) } + + let(:current_usage_amount_cents) { 120 } + + before do + usage_threshold + usage_threshold2 + invoice_subscription + applied_usage_threshold + end + + it "serializes the usage_thresholds" do + result = JSON.parse(serializer.to_json) + expect(result["lifetime_usage"]).to include( + "lago_id" => lifetime_usage.id, + "usage_thresholds" => [ + {"amount_cents" => 100, "completion_ratio" => 1.0, "reached_at" => applied_usage_threshold.created_at.iso8601(3)}, + {"amount_cents" => 200, "completion_ratio" => 0.47, "reached_at" => nil} + ] + ) + end + end +end diff --git a/spec/serializers/v1/organization_serializer_spec.rb b/spec/serializers/v1/organization_serializer_spec.rb new file mode 100644 index 0000000..ee797f2 --- /dev/null +++ b/spec/serializers/v1/organization_serializer_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::OrganizationSerializer do + subject(:serializer) do + described_class.new(org, root_name: "organization", includes: %i[taxes]) + end + + let(:webhook_urls) { org.webhook_endpoints.map(&:webhook_url) } + let(:org) { create(:organization) } + let(:tax) { create(:tax, organization: org, applied_to_organization: true) } + + before { tax } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["organization"]["name"]).to eq(org.name) + expect(result["organization"]["slug"]).to eq(org.slug) + expect(result["organization"]["default_currency"]).to eq(org.default_currency) + expect(result["organization"]["created_at"]).to eq(org.created_at.iso8601) + expect(result["organization"]["webhook_url"]).to eq(webhook_urls.first) + expect(result["organization"]["webhook_urls"]).to eq(webhook_urls) + expect(result["organization"]["country"]).to eq(org.country) + expect(result["organization"]["address_line1"]).to eq(org.address_line1) + expect(result["organization"]["address_line2"]).to eq(org.address_line2) + expect(result["organization"]["state"]).to eq(org.state) + expect(result["organization"]["zipcode"]).to eq(org.zipcode) + expect(result["organization"]["email"]).to eq(org.email) + expect(result["organization"]["city"]).to eq(org.city) + expect(result["organization"]["legal_name"]).to eq(org.legal_name) + expect(result["organization"]["legal_number"]).to eq(org.legal_number) + expect(result["organization"]["billing_configuration"]["invoice_footer"]).to eq(org.invoice_footer) + expect(result["organization"]["billing_configuration"]["invoice_grace_period"]).to eq(org.invoice_grace_period) + expect(result["organization"]["billing_configuration"]["document_locale"]).to eq(org.document_locale) + expect(result["organization"]["tax_identification_number"]).to eq(org.tax_identification_number) + expect(result["organization"]["timezone"]).to eq(org.timezone) + expect(result["organization"]["net_payment_term"]).to eq(org.net_payment_term) + expect(result["organization"]["finalize_zero_amount_invoice"]).to eq(org.finalize_zero_amount_invoice) + expect(result["organization"]["taxes"].count).to eq(1) + end +end diff --git a/spec/serializers/v1/payment_method_serializer_spec.rb b/spec/serializers/v1/payment_method_serializer_spec.rb new file mode 100644 index 0000000..b3af052 --- /dev/null +++ b/spec/serializers/v1/payment_method_serializer_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentMethodSerializer do + subject(:serializer) do + described_class.new( + payment_method, + root_name: "payment_method" + ) + end + + let(:payment_method) { create(:payment_method) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["payment_method"]).to include( + "lago_id" => payment_method.id, + "is_default" => payment_method.is_default, + "payment_provider_code" => payment_method.payment_provider&.code, + "payment_provider_name" => payment_method.payment_provider&.name, + "payment_provider_type" => "stripe", + "provider_method_id" => payment_method.provider_method_id, + "created_at" => payment_method.created_at.iso8601 + ) + end +end diff --git a/spec/serializers/v1/payment_providers/customer_checkout_serializer_spec.rb b/spec/serializers/v1/payment_providers/customer_checkout_serializer_spec.rb new file mode 100644 index 0000000..ca097fd --- /dev/null +++ b/spec/serializers/v1/payment_providers/customer_checkout_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentProviders::CustomerCheckoutSerializer do + subject(:serializer) { described_class.new(customer, options) } + + let(:customer) { create(:customer) } + let(:options) do + {"checkout_url" => "https://example.com"}.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_customer_id"]).to eq(customer.id) + expect(result["data"]["external_customer_id"]).to eq(customer.external_id) + expect(result["data"]["payment_provider"]).to eq(customer.payment_provider) + expect(result["data"]["payment_provider_code"]).to eq(customer.payment_provider_code) + expect(result["data"]["checkout_url"]).to eq("https://example.com") + end +end diff --git a/spec/serializers/v1/payment_providers/customer_error_serializer_spec.rb b/spec/serializers/v1/payment_providers/customer_error_serializer_spec.rb new file mode 100644 index 0000000..8e5cd6b --- /dev/null +++ b/spec/serializers/v1/payment_providers/customer_error_serializer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentProviders::CustomerErrorSerializer do + subject(:serializer) { described_class.new(customer, options) } + + let(:customer) { create(:customer) } + let(:options) do + { + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_customer_id"]).to eq(customer.id) + expect(result["data"]["external_customer_id"]).to eq(customer.external_id) + expect(result["data"]["payment_provider"]).to eq(customer.payment_provider) + expect(result["data"]["payment_provider_code"]).to eq(customer.payment_provider_code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/payment_providers/error_serializer_spec.rb b/spec/serializers/v1/payment_providers/error_serializer_spec.rb new file mode 100644 index 0000000..f512548 --- /dev/null +++ b/spec/serializers/v1/payment_providers/error_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentProviders::ErrorSerializer do + subject(:serializer) { described_class.new(payment_provider, options) } + + let(:payment_provider) { create(:stripe_provider) } + let(:options) do + { + "provider_error" => { + "source" => "stripe", + "action" => "payment_provider.register_webhook", + "error_message" => "message", + "error_code" => nil + } + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_payment_provider_id"]).to eq(payment_provider.id) + expect(result["data"]["payment_provider_code"]).to eq(payment_provider.code) + expect(result["data"]["payment_provider_name"]).to eq(payment_provider.name) + expect(result["data"]["source"]).to eq("stripe") + expect(result["data"]["action"]).to eq("payment_provider.register_webhook") + expect(result["data"]["provider_error"]).to eq({"error_message" => "message", "error_code" => nil}) + end +end diff --git a/spec/serializers/v1/payment_providers/invoice_payment_error_serializer_spec.rb b/spec/serializers/v1/payment_providers/invoice_payment_error_serializer_spec.rb new file mode 100644 index 0000000..b25a52f --- /dev/null +++ b/spec/serializers/v1/payment_providers/invoice_payment_error_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentProviders::InvoicePaymentErrorSerializer do + subject(:serializer) { described_class.new(invoice, options) } + + let(:invoice) { create(:invoice) } + let(:options) do + { + "provider_customer_id" => "customer", + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_invoice_id"]).to eq(invoice.id) + expect(result["data"]["lago_customer_id"]).to eq(invoice.customer.id) + expect(result["data"]["external_customer_id"]).to eq(invoice.customer.external_id) + expect(result["data"]["provider_customer_id"]).to eq(options[:provider_customer_id]) + expect(result["data"]["payment_provider"]).to eq(invoice.customer.payment_provider) + expect(result["data"]["payment_provider_code"]).to eq(invoice.customer.payment_provider_code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/payment_providers/invoice_payment_serializer_spec.rb b/spec/serializers/v1/payment_providers/invoice_payment_serializer_spec.rb new file mode 100644 index 0000000..3537006 --- /dev/null +++ b/spec/serializers/v1/payment_providers/invoice_payment_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentProviders::InvoicePaymentSerializer do + subject(:serializer) { described_class.new(invoice, options) } + + let(:invoice) { create(:invoice) } + let(:options) do + {"payment_url" => "https://example.com"}.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_customer_id"]).to eq(invoice.customer.id) + expect(result["data"]["external_customer_id"]).to eq(invoice.customer.external_id) + expect(result["data"]["payment_provider"]).to eq(invoice.customer.payment_provider) + expect(result["data"]["lago_invoice_id"]).to eq(invoice.id) + expect(result["data"]["payment_url"]).to eq("https://example.com") + end +end diff --git a/spec/serializers/v1/payment_providers/payment_request_payment_error_serializer_spec.rb b/spec/serializers/v1/payment_providers/payment_request_payment_error_serializer_spec.rb new file mode 100644 index 0000000..80453dc --- /dev/null +++ b/spec/serializers/v1/payment_providers/payment_request_payment_error_serializer_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentProviders::PaymentRequestPaymentErrorSerializer do + subject(:serializer) { described_class.new(payment_request, options) } + + let(:payment_request) { create(:payment_request, organization:, customer:, invoices: [invoice]) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:options) do + { + "provider_customer_id" => "customer", + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_payment_request_id"]).to eq(payment_request.id) + expect(result["data"]["lago_invoice_ids"]).to eq([invoice.id]) + expect(result["data"]["lago_customer_id"]).to eq(customer.id) + expect(result["data"]["external_customer_id"]).to eq(customer.external_id) + expect(result["data"]["provider_customer_id"]).to eq(options[:provider_customer_id]) + expect(result["data"]["payment_provider"]).to eq(customer.payment_provider) + expect(result["data"]["payment_provider_code"]).to eq(customer.payment_provider_code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/payment_providers/wallet_transaction_payment_error_serializer_spec.rb b/spec/serializers/v1/payment_providers/wallet_transaction_payment_error_serializer_spec.rb new file mode 100644 index 0000000..a50da6c --- /dev/null +++ b/spec/serializers/v1/payment_providers/wallet_transaction_payment_error_serializer_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentProviders::WalletTransactionPaymentErrorSerializer do + subject(:serializer) { described_class.new(wallet_transaction, options) } + + let(:wallet_transaction) { create(:wallet_transaction) } + let(:options) do + { + "provider_customer_id" => "customer", + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_wallet_transaction_id"]).to eq(wallet_transaction.id) + expect(result["data"]["lago_customer_id"]).to eq(wallet_transaction.wallet.customer.id) + expect(result["data"]["external_customer_id"]).to eq(wallet_transaction.wallet.customer.external_id) + expect(result["data"]["provider_customer_id"]).to eq(options[:provider_customer_id]) + expect(result["data"]["payment_provider"]).to eq(wallet_transaction.wallet.customer.payment_provider) + expect(result["data"]["payment_provider_code"]).to eq(wallet_transaction.wallet.customer.payment_provider_code) + expect(result["data"]["provider_error"]).to eq(options[:provider_error]) + end +end diff --git a/spec/serializers/v1/payment_providers/wallet_transaction_payment_serializer_spec.rb b/spec/serializers/v1/payment_providers/wallet_transaction_payment_serializer_spec.rb new file mode 100644 index 0000000..993eca2 --- /dev/null +++ b/spec/serializers/v1/payment_providers/wallet_transaction_payment_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe V1::PaymentProviders::WalletTransactionPaymentSerializer do + subject(:serializer) { described_class.new(wallet_transaction, options) } + + let(:wallet_transaction) { create(:wallet_transaction, :with_invoice) } + let(:options) do + {"payment_url" => "https://example.com"}.with_indifferent_access + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["data"]["lago_customer_id"]).to eq(wallet_transaction.invoice.customer.id) + expect(result["data"]["external_customer_id"]).to eq(wallet_transaction.invoice.customer.external_id) + expect(result["data"]["payment_provider"]).to eq(wallet_transaction.invoice.customer.payment_provider) + expect(result["data"]["lago_wallet_transaction_id"]).to eq(wallet_transaction.id) + expect(result["data"]["payment_url"]).to eq("https://example.com") + end +end diff --git a/spec/serializers/v1/payment_receipt_serializer_spec.rb b/spec/serializers/v1/payment_receipt_serializer_spec.rb new file mode 100644 index 0000000..3422fa5 --- /dev/null +++ b/spec/serializers/v1/payment_receipt_serializer_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentReceiptSerializer do + subject(:serializer) do + described_class.new(payment_receipt, root_name: "payment_receipt") + end + + let(:payment_receipt) { create(:payment_receipt) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["payment_receipt"]).to include( + "lago_id" => payment_receipt.id, + "number" => payment_receipt.number, + "created_at" => payment_receipt.created_at.iso8601, + "file_url" => payment_receipt.file_url, + "xml_url" => payment_receipt.xml_url + ) + + expect(result["payment_receipt"]["payment"]).to include( + "lago_id" => payment_receipt.payment.id, + "amount_cents" => payment_receipt.payment.amount_cents, + "amount_currency" => payment_receipt.payment.amount_currency + ) + end +end diff --git a/spec/serializers/v1/payment_request_serializer_spec.rb b/spec/serializers/v1/payment_request_serializer_spec.rb new file mode 100644 index 0000000..70e214c --- /dev/null +++ b/spec/serializers/v1/payment_request_serializer_spec.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentRequestSerializer do + subject(:serializer) do + described_class.new( + payment_request, + root_name: "payment_request", + includes: %i[customer invoices] + ) + end + + let(:invoice) { create(:invoice) } + let(:payment_request) { create(:payment_request, invoices: [invoice]) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["payment_request"]).to include( + "lago_id" => payment_request.id, + "email" => payment_request.email, + "amount_cents" => payment_request.amount_cents, + "amount_currency" => payment_request.amount_currency, + "payment_status" => payment_request.payment_status, + "created_at" => payment_request.created_at.iso8601, + "customer" => hash_including("lago_id" => payment_request.customer.id), + "invoices" => [ + hash_including("lago_id" => invoice.id) + ] + ) + end +end diff --git a/spec/serializers/v1/payment_serializer_spec.rb b/spec/serializers/v1/payment_serializer_spec.rb new file mode 100644 index 0000000..c38835e --- /dev/null +++ b/spec/serializers/v1/payment_serializer_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PaymentSerializer do + subject(:serializer) do + described_class.new(payment, root_name: "payment", includes:) + end + + context "when payable is an invoice" do + let(:payment) { create(:payment) } + + context "when includes is empty" do + let(:includes) { [] } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["payment"].keys).to eq(%w[ + lago_id + lago_customer_id + external_customer_id + invoice_ids + invoice_numbers + lago_payable_id + payable_type + amount_cents + amount_currency + status + payment_status + type + reference + payment_provider_code + payment_provider_type + external_payment_id + provider_payment_id + provider_customer_id + next_action + created_at + ]) + + # NOTE: Ensure all fields from PaymentSerializer before refactor are set + expect(result["payment"]).to include( + "lago_id" => payment.id, + "invoice_ids" => [payment.payable.id], + "invoice_numbers" => [payment.payable.number], + "amount_cents" => payment.amount_cents, + "amount_currency" => payment.amount_currency, + "payment_status" => payment.payable_payment_status, + "type" => payment.payment_type, + "reference" => payment.reference, + "external_payment_id" => payment.provider_payment_id, + "created_at" => payment.created_at.iso8601 + ) + + # NOTE: Ensure all fields from `RequiresActionSerializer` are still set + expect(result["payment"]).to include( + "lago_id" => payment.id, + "amount_cents" => payment.amount_cents, + "amount_currency" => payment.amount_currency, + "status" => payment.status, + "lago_payable_id" => payment.payable_id, + "lago_customer_id" => payment.payable.customer_id, + "external_customer_id" => payment.payable.customer.external_id, + "provider_customer_id" => payment.payment_provider_customer.provider_customer_id, + "payment_provider_code" => payment.payment_provider.code, + "payment_provider_type" => "PaymentProviders::StripeProvider", + "provider_payment_id" => payment.provider_payment_id, + "next_action" => {} + ) + end + end + + context "when includes payment_receipt is set" do + let(:payment_receipt) { create(:payment_receipt) } + let(:payment) { payment_receipt.payment } + let(:includes) { %i[payment_receipt] } + + it "includes the payment receipt" do + result = JSON.parse(serializer.to_json) + + expect(result["payment"]["payment_receipt"]).to include( + "lago_id" => payment_receipt.id, + "number" => payment_receipt.number, + "created_at" => payment_receipt.created_at.iso8601 + ) + end + end + end + + context "when payable is a payment request" do + let(:payment) { create(:payment, payable: payment_request) } + let(:payment_request) { create(:payment_request, payment_status: "succeeded") } + + before do + create(:payment_request_applied_invoice, payment_request:) + end + + context "when includes is empty" do + let(:includes) { [] } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["payment"]).to include( + "lago_id" => payment.id, + "invoice_ids" => payment_request.invoice_ids, + "invoice_numbers" => payment_request.invoices.pluck(:number), + "amount_cents" => payment.amount_cents, + "amount_currency" => payment.amount_currency, + "payment_status" => payment.payable_payment_status, + "type" => payment.payment_type, + "reference" => payment.reference, + "external_payment_id" => payment.provider_payment_id, + "created_at" => payment.created_at.iso8601 + ) + end + end + + context "when includes payment_receipt is set" do + let(:includes) { %i[payment_receipt] } + let!(:payment_receipt) { create(:payment_receipt, payment:) } + + it "includes the payment receipt" do + result = JSON.parse(serializer.to_json) + + expect(result["payment"]["payment_receipt"]).to include( + "lago_id" => payment_receipt.id, + "number" => payment_receipt.number, + "created_at" => payment_receipt.created_at.iso8601 + ) + end + end + end +end diff --git a/spec/serializers/v1/plan_serializer_spec.rb b/spec/serializers/v1/plan_serializer_spec.rb new file mode 100644 index 0000000..95c8a58 --- /dev/null +++ b/spec/serializers/v1/plan_serializer_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PlanSerializer do + subject(:serializer) do + described_class.new( + plan, + root_name: "plan", + includes: %i[charges fixed_charges entitlements taxes minimum_commitment usage_thresholds] + ) + end + + let(:plan) { create(:plan) } + let(:customer) { create(:customer, organization: plan.organization) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:charge) { create(:standard_charge, plan:) } + let(:usage_threshold) { create(:usage_threshold, plan:) } + + before do + subscription + charge + usage_threshold + end + + context "when plan has one minimium commitment" do + let(:commitment) { create(:commitment, plan:) } + + before { commitment } + + it "serializes the object" do + overridden_plan = create(:plan, parent_id: plan.id) + customer2 = create(:customer, organization: plan.organization) + create(:subscription, customer: customer2, plan: overridden_plan) + + result = JSON.parse(serializer.to_json) + + expect(result["plan"]).to include( + "lago_id" => plan.id, + "name" => plan.name, + "invoice_display_name" => plan.invoice_display_name, + "created_at" => plan.created_at.iso8601, + "code" => plan.code, + "interval" => plan.interval, + "description" => plan.description, + "amount_cents" => plan.amount_cents, + "amount_currency" => plan.amount_currency, + "trial_period" => plan.trial_period, + "pay_in_advance" => plan.pay_in_advance, + "bill_charges_monthly" => plan.bill_charges_monthly, + "bill_fixed_charges_monthly" => plan.bill_fixed_charges_monthly, + "customers_count" => 0, + "active_subscriptions_count" => 0, + "draft_invoices_count" => 0, + "parent_id" => nil, + "pending_deletion" => false, + "taxes" => [] + ) + + expect(result["plan"]["charges"].first).to include( + "lago_id" => charge.id + ) + + expect(result["plan"]["entitlements"]).to be_empty + + expect(result["plan"]["usage_thresholds"].first).to include( + "lago_id" => usage_threshold.id, + "threshold_display_name" => usage_threshold.threshold_display_name, + "amount_cents" => usage_threshold.amount_cents, + "recurring" => usage_threshold.recurring?, + "created_at" => usage_threshold.created_at.iso8601, + "updated_at" => usage_threshold.updated_at.iso8601 + ) + + expect(result["plan"]["minimum_commitment"]).to include( + "lago_id" => commitment.id, + "plan_code" => commitment.plan.code, + "invoice_display_name" => commitment.invoice_display_name, + "amount_cents" => commitment.amount_cents, + "interval" => commitment.plan.interval, + "created_at" => commitment.created_at.iso8601, + "updated_at" => commitment.updated_at.iso8601, + "taxes" => [] + ) + expect(result["plan"]["minimum_commitment"]).not_to include( + "commitment_type" => "minimum_commitment" + ) + end + end + + context "when plan has no minimium commitment" do + it "serializes the object" do + overridden_plan = create(:plan, parent_id: plan.id) + customer2 = create(:customer, organization: plan.organization) + create(:subscription, customer: customer2, plan: overridden_plan) + + result = JSON.parse(serializer.to_json) + + expect(result["plan"]).to include( + "lago_id" => plan.id, + "name" => plan.name, + "invoice_display_name" => plan.invoice_display_name, + "created_at" => plan.created_at.iso8601, + "code" => plan.code, + "interval" => plan.interval, + "description" => plan.description, + "amount_cents" => plan.amount_cents, + "amount_currency" => plan.amount_currency, + "trial_period" => plan.trial_period, + "pay_in_advance" => plan.pay_in_advance, + "bill_charges_monthly" => plan.bill_charges_monthly, + "customers_count" => 0, + "active_subscriptions_count" => 0, + "draft_invoices_count" => 0, + "parent_id" => nil, + "taxes" => [] + ) + + expect(result["plan"]["charges"].first).to include( + "lago_id" => charge.id + ) + + expect(result["plan"]["usage_thresholds"].first).to include( + "lago_id" => usage_threshold.id, + "threshold_display_name" => usage_threshold.threshold_display_name, + "amount_cents" => usage_threshold.amount_cents, + "recurring" => usage_threshold.recurring?, + "created_at" => usage_threshold.created_at.iso8601, + "updated_at" => usage_threshold.updated_at.iso8601 + ) + + expect(result["plan"]["minimum_commitment"]).to be_nil + end + end + + context "when plan has entitlements" do + let(:feature) { create(:feature, organization: plan.organization, code: "seats", name: "Seats", description: "Nb users") } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:entitlement) { create(:entitlement, feature:, plan:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, value: 100) } + + before { entitlement_value } + + it "serializes the entitlements" do + result = JSON.parse(serializer.to_json) + expect(result["plan"]["entitlements"].count).to eq 1 + expect(result["plan"]["entitlements"].first).to eq({ + "code" => "seats", + "name" => "Seats", + "description" => "Nb users", + "privileges" => [ + { + "code" => "max", + "name" => nil, + "value" => 100, + "config" => {}, + "value_type" => "integer" + } + ] + }) + end + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan:) } + let(:tax) { create(:tax, organization: plan.organization) } + let(:fixed_charge_tax) { create(:fixed_charge_applied_tax, fixed_charge:, tax:) } + + before do + fixed_charge + fixed_charge_tax + end + + it "serializes the fixed charges" do + result = JSON.parse(serializer.to_json) + expect(result["plan"]["fixed_charges"].count).to eq(1) + expect(result["plan"]["fixed_charges"].first).to include({ + "lago_id" => fixed_charge.id, + "lago_add_on_id" => fixed_charge.add_on_id, + "invoice_display_name" => fixed_charge.invoice_display_name, + "add_on_code" => fixed_charge.add_on.code + }) + expect(result["plan"]["fixed_charges"].first["taxes"].count).to eq(1) + expect(result["plan"]["fixed_charges"].first["taxes"].first).to include({ + "lago_id" => tax.id, + "name" => tax.name, + "code" => tax.code, + "rate" => tax.rate + }) + end + end + + context "when applicable_usage_thresholds is included" do + subject(:serializer) do + described_class.new( + plan, + root_name: "plan", + includes: %i[applicable_usage_thresholds] + ) + end + + it "serializes applicable_usage_thresholds without lago_id, created_at, and updated_at" do + result = JSON.parse(serializer.to_json) + + expect(result["plan"]["applicable_usage_thresholds"].count).to eq(1) + expect(result["plan"]["applicable_usage_thresholds"].first).to eq( + "threshold_display_name" => usage_threshold.threshold_display_name, + "amount_cents" => usage_threshold.amount_cents, + "recurring" => usage_threshold.recurring? + ) + end + end +end diff --git a/spec/serializers/v1/pricing_unit_usage_serializer_spec.rb b/spec/serializers/v1/pricing_unit_usage_serializer_spec.rb new file mode 100644 index 0000000..8574f13 --- /dev/null +++ b/spec/serializers/v1/pricing_unit_usage_serializer_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::PricingUnitUsageSerializer do + subject(:serializer) { described_class.new(pricing_unit_usage, root_name: "pricing_unit_usage") } + + let(:pricing_unit_usage) { create(:pricing_unit_usage) } + let(:result) { JSON.parse(serializer.to_json) } + + it "serializes the pricing unit usage" do + expect(result["pricing_unit_usage"]).to include( + "lago_pricing_unit_id" => pricing_unit_usage.pricing_unit_id, + "pricing_unit_code" => pricing_unit_usage.pricing_unit.code, + "short_name" => pricing_unit_usage.short_name, + "amount_cents" => pricing_unit_usage.amount_cents, + "precise_amount_cents" => pricing_unit_usage.precise_amount_cents.to_s, + "unit_amount_cents" => pricing_unit_usage.unit_amount_cents, + "precise_unit_amount" => pricing_unit_usage.precise_unit_amount.to_s, + "conversion_rate" => pricing_unit_usage.conversion_rate.to_s + ) + end +end diff --git a/spec/serializers/v1/security_log_serializer_spec.rb b/spec/serializers/v1/security_log_serializer_spec.rb new file mode 100644 index 0000000..bfc1f64 --- /dev/null +++ b/spec/serializers/v1/security_log_serializer_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::SecurityLogSerializer, clickhouse: true do + subject(:serializer) { described_class.new(security_log, root_name: "security_log") } + + let(:security_log) { create(:clickhouse_security_log) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + aggregate_failures do + expect(result["security_log"]["log_id"]).to eq(security_log.log_id) + expect(result["security_log"]["log_type"]).to eq(security_log.log_type) + expect(result["security_log"]["log_event"]).to eq(security_log.log_event) + expect(result["security_log"]["user_email"]).to eq(security_log.user.email) + expect(result["security_log"]["logged_at"]).to eq(security_log.logged_at.iso8601) + expect(result["security_log"]["created_at"]).to eq(security_log.created_at.iso8601) + expect(result["security_log"]["resources"]).to eq(security_log.resources) + expect(result["security_log"]["device_info"]).to eq(security_log.device_info) + end + end +end diff --git a/spec/serializers/v1/subscription_serializer_spec.rb b/spec/serializers/v1/subscription_serializer_spec.rb new file mode 100644 index 0000000..40fd846 --- /dev/null +++ b/spec/serializers/v1/subscription_serializer_spec.rb @@ -0,0 +1,279 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::SubscriptionSerializer do + subject(:serializer) { described_class.new(subscription, root_name: "subscription", includes:) } + + let(:started_at) { Time.zone.parse("2024-04-23 10:02:03") } + let(:ending_at) { Time.zone.parse("2024-06-30") } + let(:subscription) do + create(:subscription, created_at: started_at, started_at:, ending_at:) + end + + let(:includes) { %i[customer plan entitlements] } + + context "when plan has one minimium commitment" do + let(:commitment) { create(:commitment, plan: subscription.plan) } + + before { commitment } + + it "serializes the object" do + travel_to(Time.zone.parse("2024-05-28")) do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]).to include( + "lago_id" => subscription.id, + "external_id" => subscription.external_id, + "lago_customer_id" => subscription.customer_id, + "external_customer_id" => subscription.customer.external_id, + "name" => subscription.name, + "plan_code" => subscription.plan.code, + "plan_amount_cents" => subscription.plan.amount_cents, + "plan_amount_currency" => subscription.plan.amount_currency, + "status" => subscription.status, + "billing_time" => subscription.billing_time, + "created_at" => "2024-04-23T10:02:03Z", + "ending_at" => ending_at.iso8601, + "trial_ended_at" => nil, + "started_at" => "2024-04-23T10:02:03.000Z", + "current_billing_period_started_at" => "2024-05-01T00:00:00Z", + "current_billing_period_ending_at" => "2024-05-31T23:59:59Z" + ) + expect(result["subscription"]["payment_method"]["payment_method_id"]).to eq(nil) + expect(result["subscription"]["payment_method"]["payment_method_type"]).to eq("provider") + + expect(result["subscription"]["customer"]["lago_id"]).to be_present + expect(result["subscription"]["plan"]["lago_id"]).to be_present + + expect(result["subscription"]["plan"]["minimum_commitment"]).to include( + "lago_id" => commitment.id, + "plan_code" => commitment.plan.code, + "invoice_display_name" => commitment.invoice_display_name, + "amount_cents" => commitment.amount_cents, + "interval" => commitment.plan.interval, + "created_at" => commitment.created_at.iso8601, + "updated_at" => commitment.updated_at.iso8601, + "taxes" => [] + ) + expect(result["subscription"]["plan"]["minimum_commitment"]).not_to include( + "commitment_type" => "minimum_commitment" + ) + end + end + + context "when overriding default plan relations" do + let(:includes) { [plan: [:minimum_commitment]] } + + it "serializes the object with the right relations" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]["lago_id"]).to eq(subscription.id) + expect(result["subscription"]["plan"]).to be_present + expect(result["subscription"]["plan"]["minimum_commitment"]).to be_present + expect(result["subscription"]["plan"]["charges"]).to be_nil + end + end + end + + context "when plan has no minimium commitment" do + it "serializes the object" do + travel_to(Time.zone.parse("2024-05-28")) do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]).to include( + "lago_id" => subscription.id, + "external_id" => subscription.external_id, + "lago_customer_id" => subscription.customer_id, + "external_customer_id" => subscription.customer.external_id, + "name" => subscription.name, + "plan_code" => subscription.plan.code, + "plan_amount_cents" => subscription.plan.amount_cents, + "plan_amount_currency" => subscription.plan.amount_currency, + "status" => subscription.status, + "billing_time" => subscription.billing_time, + "created_at" => started_at.iso8601, + "ending_at" => ending_at.iso8601, + "trial_ended_at" => nil, + "current_billing_period_started_at" => "2024-05-01T00:00:00Z", + "current_billing_period_ending_at" => "2024-05-31T23:59:59Z", + "progressive_billing_disabled" => false + ) + + expect(result["subscription"]["customer"]["lago_id"]).to be_present + expect(result["subscription"]["plan"]["minimum_commitment"]).to be_nil + end + end + end + + context "when including usage threshold" do + subject(:serializer) do + described_class.new( + subscription, + root_name: "subscription", + includes: %i[usage_threshold], + usage_threshold: + ) + end + + let(:usage_threshold) { create(:usage_threshold, plan: subscription.plan) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]["usage_threshold"]).to be_present + end + end + + context "when including applied_invoice_custom_sections" do + subject(:serializer) do + described_class.new( + subscription, + root_name: "subscription", + includes: %i[applied_invoice_custom_sections] + ) + end + + before { create(:subscription_applied_invoice_custom_section, subscription:) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]["applied_invoice_custom_sections"]).to be_present + end + end + + context "when terminated with credit note" do + let(:plan) { create(:plan, :pay_in_advance) } + let(:subscription) { create(:subscription, :terminated, plan:, on_termination_credit_note: :credit) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]["on_termination_credit_note"]).to eq("credit") + expect(result["subscription"]["on_termination_invoice"]).to eq("generate") + expect(result["subscription"]["terminated_at"]).to be_present + expect(result["subscription"]["status"]).to eq("terminated") + end + end + + context "when terminated with skip invoice" do + let(:subscription) { create(:subscription, :terminated, on_termination_invoice: :skip) } + + it "serializes the object with skip invoice behavior" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]["on_termination_invoice"]).to eq("skip") + expect(result["subscription"]["terminated_at"]).to be_present + expect(result["subscription"]["status"]).to eq("terminated") + end + end + + context "when subscription has entitlements" do + let(:feature) { create(:feature, organization: subscription.organization, code: "seats", name: "Seats", description: "Nb users") } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:plan_entitlement) { create(:entitlement, feature:, plan: subscription.plan) } + let(:plan_entitlement_value) { create(:entitlement_value, entitlement: plan_entitlement, privilege:, value: 12) } + let(:sub_entitlement) { create(:entitlement, feature:, plan: nil, subscription:) } + let(:sub_entitlement_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege:, value: 99) } + + before { + plan_entitlement_value + sub_entitlement_value + } + + it "serializes the entitlements" do + result = JSON.parse(serializer.to_json) + expect(result["subscription"]["entitlements"].count).to eq 1 + expect(result["subscription"]["entitlements"].first).to eq({ + "code" => "seats", + "name" => "Seats", + "description" => "Nb users", + "privileges" => [ + { + "code" => "max", + "name" => nil, + "value" => 99, + "plan_value" => 12, + "override_value" => 99, + "config" => {}, + "value_type" => "integer" + } + ], + "overrides" => {"max" => 99} + }) + end + end + + context "when subscription has fixed charges" do + let(:plan) { create(:plan) } + let(:fixed_charge) { create(:fixed_charge, plan:) } + let(:subscription) { create(:subscription, plan:) } + + before { fixed_charge } + + it "does not serialize the fixed charges" do + result = JSON.parse(serializer.to_json) + expect(result["subscription"]["plan"]["fixed_charges"]).to be_nil + end + end + + context "when payment_gated_subscriptions feature flag is enabled" do + let(:organization) { create(:organization, feature_flags: ["payment_gated_subscriptions"]) } + let(:activated_at) { Time.zone.parse("2026-04-13T10:00:00Z") } + let(:subscription) do + create( + :subscription, + organization:, + cancelation_reason: Subscription::CANCELATION_REASONS[:payment_failed], + activated_at: + ) + end + + it "serializes cancelation_reason and activated_at" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]).to include( + "cancelation_reason" => Subscription::CANCELATION_REASONS[:payment_failed], + "activated_at" => activated_at.iso8601 + ) + end + + context "when subscription does not have activation_rules" do + it "serializes activation_rules as an empty array" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]["activation_rules"]).to eq([]) + end + end + + context "when subscription has activation_rules" do + let(:activation_rule) { create(:subscription_activation_rule, subscription:, organization:) } + + before { activation_rule } + + it "serializes activation_rules" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]["activation_rules"]).to contain_exactly( + include( + "lago_id" => activation_rule.id, + "type" => Subscription::ActivationRule::TYPES[:payment], + "timeout_hours" => activation_rule.timeout_hours, + "status" => activation_rule.status + ) + ) + end + end + end + + context "when payment_gated_subscriptions feature flag is disabled" do + it "does not serialize feature-flagged fields" do + result = JSON.parse(serializer.to_json) + + expect(result["subscription"]).not_to have_key("cancelation_reason") + expect(result["subscription"]).not_to have_key("activated_at") + expect(result["subscription"]).not_to have_key("activation_rules") + end + end +end diff --git a/spec/serializers/v1/subscriptions/activation_rule_serializer_spec.rb b/spec/serializers/v1/subscriptions/activation_rule_serializer_spec.rb new file mode 100644 index 0000000..a6fa277 --- /dev/null +++ b/spec/serializers/v1/subscriptions/activation_rule_serializer_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Subscriptions::ActivationRuleSerializer do + subject(:serializer) { described_class.new(activation_rule, root_name: "activation_rule") } + + let(:activation_rule) { create(:subscription_activation_rule) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["activation_rule"]).to include( + "lago_id" => activation_rule.id, + "type" => Subscription::ActivationRule::TYPES[:payment], + "timeout_hours" => activation_rule.timeout_hours, + "status" => activation_rule.status, + "expires_at" => nil, + "created_at" => activation_rule.created_at.iso8601, + "updated_at" => activation_rule.updated_at.iso8601 + ) + end + + context "when expires_at is set" do + let(:expires_at) { Time.zone.parse("2026-04-13T10:00:00Z") } + let(:activation_rule) { create(:subscription_activation_rule, expires_at:) } + + it "serializes expires_at as iso8601" do + result = JSON.parse(serializer.to_json) + + expect(result["activation_rule"]["expires_at"]).to eq(expires_at.iso8601) + end + end +end diff --git a/spec/serializers/v1/tax_serializer_spec.rb b/spec/serializers/v1/tax_serializer_spec.rb new file mode 100644 index 0000000..35a907e --- /dev/null +++ b/spec/serializers/v1/tax_serializer_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::TaxSerializer do + subject(:serializer) { described_class.new(tax, root_name: "tax") } + + let(:tax) { create(:tax) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["tax"]).to include( + "lago_id" => tax.id, + "name" => tax.name, + "code" => tax.code, + "rate" => tax.rate, + "description" => tax.description, + "add_ons_count" => 0, + "customers_count" => 0, + "plans_count" => 0, + "charges_count" => 0, + "created_at" => tax.created_at.iso8601 + ) + end +end diff --git a/spec/serializers/v1/usage_monitoring/alert_serializer_spec.rb b/spec/serializers/v1/usage_monitoring/alert_serializer_spec.rb new file mode 100644 index 0000000..3101076 --- /dev/null +++ b/spec/serializers/v1/usage_monitoring/alert_serializer_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::UsageMonitoring::AlertSerializer do + subject(:serializer) { described_class.new(alert, root_name: "alert") } + + let(:alert) { create(:alert, :processed, subscription_external_id: "ext-id", recurring_threshold: 33, thresholds: [10, 12], code: :yolo) } + let(:result) { JSON.parse(serializer.to_json) } + + before { alert } + + it "serializes the object" do + payload = result["alert"] + expect(payload["lago_id"]).to eq(alert.id) + expect(payload["external_subscription_id"]).to eq("ext-id") + expect(payload["lago_wallet_id"]).to be_nil + expect(payload["name"]).to eq("General Alert") + expect(payload["code"]).to eq("yolo") + expect(payload["alert_type"]).to eq("current_usage_amount") + expect(payload["direction"]).to eq("increasing") + expect(payload["thresholds"]).to eq([ + {"code" => "warn10", "value" => "10.0", "recurring" => false}, + {"code" => "warn12", "value" => "12.0", "recurring" => false}, + {"code" => "rec", "value" => "33.0", "recurring" => true} + ]) + expect(payload["previous_value"]).to eq("800.0") + expect(payload["last_processed_at"]).to eq("2000-01-01T12:00:00Z") + expect(payload["billable_metric"]).to be_nil + + # Deprecated fields that must be kept for backward compatibility + expect(payload["subscription_external_id"]).to eq("ext-id") + end + + context "with billable_metric_current_usage_amount alert" do + let(:alert) { create(:billable_metric_current_usage_amount_alert) } + + it "has the billable_metric_id the object" do + payload = result["alert"]["billable_metric"] + expect(payload["lago_id"]).to eq alert.billable_metric.id + expect(payload["code"]).to eq alert.billable_metric.code + expect(payload["field_name"]).to be_nil + end + end + + context "with wallet_balance_amount alert" do + let(:alert) { create(:wallet_balance_amount_alert, :processed, code: :wallet_alert) } + + it "serializes the wallet alert" do + payload = result["alert"] + expect(payload["lago_id"]).to eq(alert.id) + expect(payload["external_subscription_id"]).to be_nil + expect(payload["lago_wallet_id"]).to eq(alert.wallet_id) + expect(payload["alert_type"]).to eq("wallet_balance_amount") + expect(payload["direction"]).to eq("decreasing") + expect(payload["code"]).to eq("wallet_alert") + end + end +end diff --git a/spec/serializers/v1/usage_monitoring/triggered_alert_serializer_spec.rb b/spec/serializers/v1/usage_monitoring/triggered_alert_serializer_spec.rb new file mode 100644 index 0000000..6d96813 --- /dev/null +++ b/spec/serializers/v1/usage_monitoring/triggered_alert_serializer_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::UsageMonitoring::TriggeredAlertSerializer do + subject(:serializer) { described_class.new(triggered_alert, root_name: "triggered_alert") } + + let(:triggered_alert) { create(:triggered_alert, alert:, subscription:, triggered_at: DateTime.new(2000, 1, 1, 12, 0, 0)) } + let(:subscription) { create(:subscription, external_id: "ext-id", customer: create(:customer, external_id: "cust-ext-id")) } + + before { triggered_alert } + + context "with usage_amount alert" do + let(:alert) { create(:usage_current_amount_alert, subscription_external_id: "ext-id", code: "first") } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + payload = result["triggered_alert"] + expect(payload["lago_id"]).to eq(triggered_alert.id) + expect(payload["lago_organization_id"]).to eq(triggered_alert.organization_id) + expect(payload["lago_alert_id"]).to eq(triggered_alert.alert.id) + expect(payload["lago_subscription_id"]).to eq(triggered_alert.subscription.id) + expect(payload["external_subscription_id"]).to eq("ext-id") + expect(payload["lago_wallet_id"]).to be_nil + expect(payload["external_customer_id"]).to eq("cust-ext-id") + expect(payload["billable_metric_code"]).to be_nil + expect(payload["alert_name"]).to eq("General Alert") + expect(payload["alert_code"]).to eq("first") + expect(payload["alert_type"]).to eq("current_usage_amount") + expect(payload["current_value"]).to eq("3000.0") + expect(payload["previous_value"]).to eq("1000.0") + expect(payload["crossed_thresholds"]).to eq([ + {"code" => "warn", "value" => "2000.0", "recurring" => false}, + {"code" => "repeat", "value" => "2500.0", "recurring" => true} + ]) + expect(payload["triggered_at"]).to eq("2000-01-01T12:00:00Z") + + # Deprecated fields that must be kept for backward compatibility + expect(payload["subscription_external_id"]).to eq("ext-id") + expect(payload["customer_external_id"]).to eq("cust-ext-id") + end + end + + context "with billable_metric_current_usage_amount alert" do + let(:alert) { create(:billable_metric_current_usage_amount_alert, subscription_external_id: "ext-id") } + + it "has the billable_metric_id the object" do + result = JSON.parse(serializer.to_json) + + payload = result["triggered_alert"] + expect(payload["billable_metric_code"]).to eq alert.billable_metric.code + end + + context "when billable_metric and alert are deleted" do + it "retrieves the alert correctly" do + alert.billable_metric.discard! + alert.discard! + triggered_alert.reload + result = JSON.parse(serializer.to_json) + payload = result["triggered_alert"] + expect(payload["lago_alert_id"]).to eq alert.id + expect(payload["billable_metric_code"]).to eq alert.billable_metric.code + end + end + end + + context "with wallet_balance_amount alert" do + let(:customer) { create(:customer, external_id: "wallet-cust-ext-id") } + let(:wallet) { create(:wallet, customer:, organization: customer.organization) } + let(:alert) { create(:wallet_balance_amount_alert, wallet:, organization: wallet.organization, code: "wallet-alert") } + let(:triggered_alert) do + create(:triggered_alert, alert:, subscription: nil, wallet:, triggered_at: DateTime.new(2000, 1, 1, 12, 0, 0)) + end + + it "serializes the wallet triggered alert" do + result = JSON.parse(serializer.to_json) + + payload = result["triggered_alert"] + expect(payload["lago_id"]).to eq(triggered_alert.id) + expect(payload["lago_subscription_id"]).to be_nil + expect(payload["external_subscription_id"]).to be_nil + expect(payload["lago_wallet_id"]).to eq(wallet.id) + expect(payload["external_customer_id"]).to eq("wallet-cust-ext-id") + expect(payload["alert_type"]).to eq("wallet_balance_amount") + expect(payload["alert_code"]).to eq("wallet-alert") + end + end +end diff --git a/spec/serializers/v1/usage_threshold_serializer_spec.rb b/spec/serializers/v1/usage_threshold_serializer_spec.rb new file mode 100644 index 0000000..ceb183e --- /dev/null +++ b/spec/serializers/v1/usage_threshold_serializer_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::UsageThresholdSerializer do + subject(:serializer) { described_class.new(usage_threshold, root_name: "usage_threshold") } + + let(:usage_threshold) { create(:usage_threshold) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["usage_threshold"]).to include( + "lago_id" => usage_threshold.id, + "threshold_display_name" => usage_threshold.threshold_display_name, + "amount_cents" => usage_threshold.amount_cents, + "recurring" => usage_threshold.recurring?, + "created_at" => usage_threshold.created_at.iso8601, + "updated_at" => usage_threshold.updated_at.iso8601 + ) + end +end diff --git a/spec/serializers/v1/wallet_serializer_spec.rb b/spec/serializers/v1/wallet_serializer_spec.rb new file mode 100644 index 0000000..571755a --- /dev/null +++ b/spec/serializers/v1/wallet_serializer_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::WalletSerializer do + subject(:serializer) { described_class.new(wallet, root_name: "wallet", includes: %i[limitations recurring_transaction_rules applied_invoice_custom_sections]) } + + let(:wallet) { create(:wallet, :with_top_up_limits, allowed_fee_types: %w[charge]) } + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, wallet:) } + let(:wallet_target) { create(:wallet_target, wallet:) } + let(:applied_invoice_custom_section) { create(:wallet_applied_invoice_custom_section, wallet:) } + + before do + recurring_transaction_rule + wallet_target + applied_invoice_custom_section + end + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet"]).to include( + "lago_id" => wallet.id, + "lago_customer_id" => wallet.customer_id, + "external_customer_id" => wallet.customer.external_id, + "status" => wallet.status, + "currency" => wallet.currency, + "name" => wallet.name, + "priority" => wallet.priority, + "rate_amount" => wallet.rate_amount.to_s, + "created_at" => wallet.created_at.iso8601, + "expiration_at" => wallet.expiration_at&.iso8601, + "last_balance_sync_at" => wallet.last_balance_sync_at&.iso8601, + "last_consumed_credit_at" => wallet.last_consumed_credit_at&.iso8601, + "terminated_at" => wallet.terminated_at, + "credits_balance" => wallet.credits_balance.to_s, + "balance_cents" => wallet.balance_cents, + "credits_ongoing_balance" => wallet.credits_ongoing_balance.to_s, + "credits_ongoing_usage_balance" => wallet.credits_ongoing_usage_balance.to_s, + "ongoing_balance_cents" => wallet.ongoing_balance_cents, + "ongoing_usage_balance_cents" => wallet.ongoing_usage_balance_cents, + "consumed_credits" => wallet.consumed_credits.to_s, + "invoice_requires_successful_payment" => wallet.invoice_requires_successful_payment, + "paid_top_up_min_amount_cents" => wallet.paid_top_up_min_amount_cents, + "paid_top_up_max_amount_cents" => wallet.paid_top_up_max_amount_cents + ) + expect(result["wallet"]["applies_to"]["fee_types"]).to eq(%w[charge]) + expect(result["wallet"]["applies_to"]["billable_metric_codes"]).to eq([wallet_target.billable_metric.code]) + expect(result["wallet"]["recurring_transaction_rules"].first["lago_id"]).to eq(recurring_transaction_rule.id) + expect(result["wallet"]["applied_invoice_custom_sections"].first["lago_id"]).to eq(applied_invoice_custom_section.id) + expect(result["wallet"]["payment_method"]["payment_method_id"]).to eq(nil) + expect(result["wallet"]["payment_method"]["payment_method_type"]).to eq("provider") + end + + describe "recurring_transaction_rules filtering" do + let(:active_rule) { create(:recurring_transaction_rule, wallet:) } + let(:active_future_expiration_rule) { create(:recurring_transaction_rule, wallet:, expiration_at: 1.day.from_now) } + let(:active_past_expiration_rule) { create(:recurring_transaction_rule, wallet:, expiration_at: 1.day.ago) } + let(:terminated_rule) { create(:recurring_transaction_rule, wallet:, status: :terminated) } + + before do + active_rule + active_future_expiration_rule + active_past_expiration_rule + terminated_rule + end + + it "includes only active rules that have not expired" do + result = JSON.parse(serializer.to_json) + ids = result["wallet"]["recurring_transaction_rules"].map { |r| r["lago_id"] } + + expect(ids).to match_array([recurring_transaction_rule.id, active_rule.id, active_future_expiration_rule.id]) + end + end +end diff --git a/spec/serializers/v1/wallet_transaction_consumption_serializer_spec.rb b/spec/serializers/v1/wallet_transaction_consumption_serializer_spec.rb new file mode 100644 index 0000000..cf02def --- /dev/null +++ b/spec/serializers/v1/wallet_transaction_consumption_serializer_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::WalletTransactionConsumptionSerializer do + subject(:serializer) do + described_class.new(consumption, root_name: "wallet_transaction_consumption", includes:) + end + + let(:consumption) { create(:wallet_transaction_consumption) } + let(:includes) { [] } + + context "when includes is empty" do + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction_consumption"]).to eq( + "lago_id" => consumption.id, + "amount_cents" => consumption.consumed_amount_cents, + "credit_amount" => consumption.credit_amount, + "created_at" => consumption.created_at.iso8601 + ) + end + end + + context "when includes inbound_wallet_transaction is set" do + let(:includes) { %i[inbound_wallet_transaction] } + + it "includes the inbound wallet transaction as wallet_transaction" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction_consumption"]["wallet_transaction"]).to include( + "lago_id" => consumption.inbound_wallet_transaction.id, + "transaction_type" => "inbound" + ) + end + end + + context "when includes outbound_wallet_transaction is set" do + let(:includes) { %i[outbound_wallet_transaction] } + + it "includes the outbound wallet transaction as wallet_transaction" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction_consumption"]["wallet_transaction"]).to include( + "lago_id" => consumption.outbound_wallet_transaction.id, + "transaction_type" => "outbound" + ) + end + end +end diff --git a/spec/serializers/v1/wallet_transaction_serializer_spec.rb b/spec/serializers/v1/wallet_transaction_serializer_spec.rb new file mode 100644 index 0000000..576972e --- /dev/null +++ b/spec/serializers/v1/wallet_transaction_serializer_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::WalletTransactionSerializer do + subject(:serializer) do + described_class.new(wallet_transaction, root_name: "wallet_transaction", includes:) + end + + let(:wallet_transaction) { create(:wallet_transaction) } + let(:includes) { [] } + + context "when includes is empty" do + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction"]).to include( + "lago_id" => wallet_transaction.id, + "lago_wallet_id" => wallet_transaction.wallet_id, + "lago_invoice_id" => nil, + "lago_credit_note_id" => nil, + "lago_voided_invoice_id" => nil, + "status" => wallet_transaction.status, + "source" => wallet_transaction.source, + "transaction_status" => wallet_transaction.transaction_status, + "transaction_type" => wallet_transaction.transaction_type, + "amount" => wallet_transaction.amount.to_s, + "credit_amount" => wallet_transaction.credit_amount.to_s, + "remaining_amount_cents" => wallet_transaction.remaining_amount_cents, + "remaining_credit_amount" => wallet_transaction.remaining_credit_amount, + "priority" => wallet_transaction.priority, + "settled_at" => wallet_transaction.settled_at&.iso8601, + "failed_at" => wallet_transaction.failed_at&.iso8601, + "created_at" => wallet_transaction.created_at.iso8601, + "invoice_requires_successful_payment" => wallet_transaction.invoice_requires_successful_payment?, + "metadata" => wallet_transaction.metadata, + "name" => "Custom Transaction Name" + ) + expect(result["wallet_transaction"]["payment_method"]["payment_method_id"]).to eq(nil) + expect(result["wallet_transaction"]["payment_method"]["payment_method_type"]).to eq("provider") + end + end + + context "when transaction has an invoice and a credit note" do + let(:wallet_transaction) { create(:wallet_transaction, :with_invoice, :with_credit_note) } + + it "serializes the invoice id" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction"]).to include( + "lago_invoice_id" => wallet_transaction.invoice.id, + "lago_credit_note_id" => wallet_transaction.credit_note.id + ) + end + end + + context "when transaction has a voided_invoice" do + let(:voided_invoice) { create(:invoice) } + let(:wallet_transaction) { create(:wallet_transaction, voided_invoice:) } + + it "serializes the voided_invoice_id" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction"]).to include( + "lago_voided_invoice_id" => voided_invoice.id + ) + end + end + + context "when includes wallet is set" do + let(:includes) { %i[wallet] } + let(:wallet) { wallet_transaction.wallet } + + it "includes the wallet" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction"]["wallet"]).to include( + "lago_id" => wallet.id, + "status" => wallet.status, + "created_at" => wallet.created_at.iso8601, + "expiration_at" => wallet.expiration_at&.iso8601 + ) + end + end + + context "when includes applied_invoice_custom_sections is set" do + let(:includes) { %i[applied_invoice_custom_sections] } + let(:invoice_custom_section) { create(:wallet_transaction_applied_invoice_custom_section, wallet_transaction:) } + + before { invoice_custom_section } + + it "includes the invoice_custom_sections" do + result = JSON.parse(serializer.to_json) + + expect(result["wallet_transaction"]["applied_invoice_custom_sections"].first).to include( + "lago_id" => invoice_custom_section.id + ) + end + end +end diff --git a/spec/serializers/v1/wallets/recurring_transaction_rule_serializer_spec.rb b/spec/serializers/v1/wallets/recurring_transaction_rule_serializer_spec.rb new file mode 100644 index 0000000..09e618b --- /dev/null +++ b/spec/serializers/v1/wallets/recurring_transaction_rule_serializer_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::Wallets::RecurringTransactionRuleSerializer do + subject(:serializer) { described_class.new(recurring_transaction_rule, root_name: "recurring_transaction_rule") } + + let(:recurring_transaction_rule) { create(:recurring_transaction_rule) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["recurring_transaction_rule"]).to include( + "lago_id" => recurring_transaction_rule.id, + "method" => recurring_transaction_rule.method, + "trigger" => recurring_transaction_rule.trigger, + "interval" => recurring_transaction_rule.interval, + "paid_credits" => recurring_transaction_rule.paid_credits.to_s, + "started_at" => recurring_transaction_rule.started_at&.iso8601, + "expiration_at" => recurring_transaction_rule.expiration_at&.iso8601, + "status" => recurring_transaction_rule.status, + "target_ongoing_balance" => recurring_transaction_rule.target_ongoing_balance, + "threshold_credits" => recurring_transaction_rule.threshold_credits.to_s, + "granted_credits" => recurring_transaction_rule.granted_credits.to_s, + "created_at" => recurring_transaction_rule.created_at.iso8601, + "invoice_requires_successful_payment" => recurring_transaction_rule.invoice_requires_successful_payment, + "transaction_metadata" => recurring_transaction_rule.transaction_metadata, + "transaction_name" => "Recurring Transaction Rule", + "ignore_paid_top_up_limits" => recurring_transaction_rule.ignore_paid_top_up_limits, + "applied_invoice_custom_sections" => recurring_transaction_rule.applied_invoice_custom_sections + ) + expect(result["recurring_transaction_rule"]["payment_method"]["payment_method_id"]).to eq(nil) + expect(result["recurring_transaction_rule"]["payment_method"]["payment_method_type"]).to eq("provider") + end +end diff --git a/spec/serializers/v1/webhook_endpoint_serializer_spec.rb b/spec/serializers/v1/webhook_endpoint_serializer_spec.rb new file mode 100644 index 0000000..3f22c7d --- /dev/null +++ b/spec/serializers/v1/webhook_endpoint_serializer_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::V1::WebhookEndpointSerializer do + subject(:serializer) { described_class.new(webhook_endpoint, root_name: "webhook_endpoint") } + + let(:webhook_endpoint) { create(:webhook_endpoint) } + + it "serializes the object" do + result = JSON.parse(serializer.to_json) + + expect(result["webhook_endpoint"]["lago_organization_id"]).to eq(webhook_endpoint.organization_id) + expect(result["webhook_endpoint"]["webhook_url"]).to eq(webhook_endpoint.webhook_url) + expect(result["webhook_endpoint"]["created_at"]).to eq(webhook_endpoint.created_at.iso8601) + expect(result["webhook_endpoint"]["signature_algo"]).to eq(webhook_endpoint.signature_algo) + expect(result["webhook_endpoint"]["name"]).to eq(webhook_endpoint.name) + expect(result["webhook_endpoint"]["event_types"]).to eq(webhook_endpoint.event_types) + end +end diff --git a/spec/services/add_ons/apply_taxes_service_spec.rb b/spec/services/add_ons/apply_taxes_service_spec.rb new file mode 100644 index 0000000..4ce9abf --- /dev/null +++ b/spec/services/add_ons/apply_taxes_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AddOns::ApplyTaxesService do + subject(:apply_service) { described_class.new(add_on:, tax_codes:) } + + let(:organization) { create(:organization) } + let(:add_on) { create(:add_on, organization:) } + let(:tax1) { create(:tax, organization:, code: "tax1") } + let(:tax2) { create(:tax, organization:, code: "tax2") } + let(:tax_codes) { [tax1.code, tax2.code] } + + describe "call" do + it "applies taxes to the add_on" do + expect { apply_service.call }.to change { add_on.applied_taxes.count }.from(0).to(2) + end + + it "returns applied taxes" do + result = apply_service.call + expect(result.applied_taxes.count).to eq(2) + end + + context "when add_on is not found" do + let(:add_on) { nil } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("add_on_not_found") + end + end + + context "when tax is not found" do + let(:tax_codes) { ["unknown"] } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when applied tax is already present" do + it "does not create a new applied tax" do + create(:add_on_applied_tax, add_on:, tax: tax1, organization:) + expect { apply_service.call }.to change { add_on.applied_taxes.count }.from(1).to(2) + end + end + + context "when trying to apply twice the same tax" do + let(:tax_codes) { [tax1.code, tax1.code] } + + it "assigns it only once" do + expect { apply_service.call }.to change { add_on.applied_taxes.count }.from(0).to(1) + end + end + end +end diff --git a/spec/services/add_ons/create_service_spec.rb b/spec/services/add_ons/create_service_spec.rb new file mode 100644 index 0000000..7d482df --- /dev/null +++ b/spec/services/add_ons/create_service_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AddOns::CreateService do + subject(:create_service) { described_class.new(create_args) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:add_on_code) { "free-beer-for-us" } + let(:tax) { create(:tax, organization:) } + + describe "create" do + let(:create_args) do + { + name: "Super Add-on", + invoice_display_name: "Super Add-on Invoice Name", + code: add_on_code, + description: "This is description", + organization_id: organization.id, + amount_cents: 100, + amount_currency: "EUR", + tax_codes: [tax.code] + } + end + + before do + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "creates an add-on" do + expect { create_service.call } + .to change(AddOn, :count).by(1) + + add_on = AddOn.order(:created_at).last + expect(add_on.taxes.pluck(:code)).to eq([tax.code]) + end + + it "calls SegmentTrackJob" do + add_on = create_service.call.add_on + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "add_on_created", + properties: { + addon_code: add_on.code, + addon_name: add_on.name, + addon_invoice_display_name: add_on.invoice_display_name, + organization_id: add_on.organization_id + } + ) + end + + context "with code already used by a deleted add_on" do + it "creates an add_on with the same code" do + create(:add_on, :deleted, organization:, code: add_on_code) + + expect { create_service.call }.to change(AddOn, :count).by(1) + + add_ons = organization.add_ons.with_discarded + expect(add_ons.count).to eq(2) + expect(add_ons.pluck(:code).uniq).to eq([add_on_code]) + end + end + + context "with validation error" do + before do + create( + :add_on, + organization:, + code: "free-beer-for-us" + ) + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + end +end diff --git a/spec/services/add_ons/destroy_service_spec.rb b/spec/services/add_ons/destroy_service_spec.rb new file mode 100644 index 0000000..0136cbe --- /dev/null +++ b/spec/services/add_ons/destroy_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AddOns::DestroyService do + subject(:destroy_service) { described_class.new(add_on:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:add_on) { create(:add_on, organization:) } + + describe "#call" do + before { add_on } + + it "soft deletes the add-on" do + expect { destroy_service.call }.to change(AddOn, :count).by(-1) + .and change { add_on.reload.deleted_at }.from(nil) + end + + context "when there are fixed charges associated with the add-on" do + let(:fixed_charges) { create_list(:fixed_charge, 2, add_on:) } + + before { fixed_charges } + + it "soft deletes the add-on and the fixed charges" do + expect { destroy_service.call }.to change(AddOn, :count).by(-1) + .and change { add_on.reload.deleted_at }.from(nil) + .and change(FixedCharge, :count).by(-2) + .and change { fixed_charges.map(&:reload).map(&:deleted_at) }.from([nil, nil]).to(all(be_present)) + end + + context "when failed to discard fixed charges" do + before do + allow(add_on.fixed_charges).to receive(:update_all).and_raise(ActiveRecord::RecordInvalid.new(fixed_charges.first)) + end + + it "does not soft delete the add-on" do + result = destroy_service.call + + expect(result).not_to be_success + expect(add_on.reload.deleted_at).to be_nil + end + end + end + + context "when add-on is not found" do + let(:add_on) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("add_on_not_found") + end + end + end +end diff --git a/spec/services/add_ons/update_service_spec.rb b/spec/services/add_ons/update_service_spec.rb new file mode 100644 index 0000000..4f9d2ce --- /dev/null +++ b/spec/services/add_ons/update_service_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AddOns::UpdateService do + subject(:add_ons_service) { described_class.new(add_on:, params: update_args) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:add_on) { create(:add_on, organization:) } + let(:tax) { create(:tax, organization:) } + let(:add_on_applied_tax) { create(:add_on_applied_tax, add_on:, tax:) } + let(:tax2) { create(:tax, organization:) } + + before { add_on_applied_tax } + + describe "call" do + before { add_on } + + let(:update_args) do + { + id: add_on.id, + name: "new name", + invoice_display_name: "new invoice name", + code: "code", + description: "desc", + amount_cents: 100, + amount_currency: "EUR", + tax_codes: + } + end + let(:tax_codes) { [tax2.code] } + + it "updates the add-on" do + result = add_ons_service.call + expect(result).to be_success + + expect(result.add_on.name).to eq("new name") + expect(result.add_on.invoice_display_name).to eq("new invoice name") + expect(result.add_on.description).to eq("desc") + expect(result.add_on.amount_cents).to eq(100) + expect(result.add_on.amount_currency).to eq("EUR") + expect(result.add_on.taxes.map { |t| t[:code] }).to contain_exactly(tax2.code) + end + + context "when tax is not found" do + let(:tax_codes) { ["unknown"] } + + it "returns an error" do + result = add_ons_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "with validation error" do + let(:update_args) do + { + id: add_on.id, + name: nil, + code: "code", + amount_cents: 100, + amount_currency: "EUR" + } + end + + it "returns an error" do + result = add_ons_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + + context "when attached to an applied add on" do + let(:update_args) do + { + id: add_on.id, + name: "new name", + description: "new desc", + code: "new code" + } + end + + it "updates all given attributes" do + create(:applied_add_on, add_on:) + result = add_ons_service.call + + expect(result.add_on.name).to eq("new name") + expect(result.add_on.description).to eq("new desc") + expect(result.add_on.code).to eq("new code") + end + end + end +end diff --git a/spec/services/adjusted_fees/create_service_spec.rb b/spec/services/adjusted_fees/create_service_spec.rb new file mode 100644 index 0000000..6f9ee54 --- /dev/null +++ b/spec/services/adjusted_fees/create_service_spec.rb @@ -0,0 +1,566 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AdjustedFees::CreateService do + subject(:create_service) { described_class.new(invoice:, params:) } + + let(:customer) { create(:customer) } + let(:invoice) { create(:invoice, :subscription, :draft, customer:, subscriptions: [subscription], organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, plan:, customer:) } + let(:organization) { customer.organization } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan: subscription.plan) } + let(:charge_filter) { create(:charge_filter, charge:) } + + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, charge_filter:) } + let(:code) { "tax_code" } + let(:params) do + { + fee_id: fee.id, + units: 5, + unit_precise_amount: 12.002, + invoice_display_name: "new-dis-name" + } + end + + describe "#call" do + before do + allow(Invoices::RefreshDraftService) + .to receive(:call).with(invoice: invoice) + .and_return(BaseService::Result.new) + end + + context "when license is premium", :premium do + it "creates an adjusted fee" do + expect { create_service.call }.to change(AdjustedFee, :count).by(1) + end + + it "returns adjusted fee in the result" do + result = create_service.call + expect(result.adjusted_fee).to be_a(AdjustedFee) + end + + it "returns fee in the result" do + result = create_service.call + expect(result.fee).to be_a(Fee) + end + + it "calls the RefreshDraft service" do + create_service.call + + expect(Invoices::RefreshDraftService).to have_received(:call) + end + + it "populates precise and not precise values for the created adjusted fee" do + result = create_service.call + expect(result.adjusted_fee).to have_attributes( + units: 5, + unit_amount_cents: 1200, + unit_precise_amount_cents: 1200.2 + ) + end + + context "when invoice is NOT in draft status" do + before { invoice.finalized! } + + it "returns forbidden status" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + + context "when there is invalid charge model but amount is adjusted" do + let(:percentage_charge) { create(:percentage_charge) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge: percentage_charge) } + + it "returns success response" do + result = create_service.call + + expect(result).to be_success + end + end + + context "when there is invalid charge model and display name is adjusted" do + let(:percentage_charge) { create(:percentage_charge) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge: percentage_charge) } + let(:params) do + { + fee_id: fee.id, + invoice_display_name: "new-dis-name" + } + end + + it "returns success response" do + result = create_service.call + + expect(result).to be_success + end + end + + context "when there is invalid charge model and units are adjusted" do + let(:percentage_charge) { create(:percentage_charge) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge: percentage_charge) } + let(:params) do + { + fee_id: fee.id, + units: 5, + invoice_display_name: "new-dis-name" + } + end + + it "returns error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:charge]).to eq(["invalid_charge_model"]) + end + end + + context "when fee belongs to another invoice" do + let(:fee) { create(:charge_fee) } + + it "returns error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("fee_not_found") + end + end + + context "when adjusted fee already exists" do + let(:adjusted_fee) { create(:adjusted_fee, fee:) } + + before { adjusted_fee } + + it "returns validation error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:adjusted_fee]).to eq(["already_exists"]) + end + end + + context "when adjusting without fee" do + let(:fee) { nil } + let(:params) do + { + units: 5, + unit_precise_amount: 12.002, + invoice_display_name: "new-dis-name", + subscription_id: subscription.id, + charge_id: charge.id, + charge_filter_id: charge_filter.id + } + end + + it "creates an adjusted fee and a fee" do + expect { create_service.call } + .to change(AdjustedFee, :count).by(1) + .and change(Fee, :count).by(1) + end + + it "returns adjusted fee in the result" do + result = create_service.call + expect(result.adjusted_fee) + .to be_a(AdjustedFee) + .and have_attributes( + fee: Fee, + invoice:, + subscription:, + charge:, + adjusted_units: false, + adjusted_amount: true, + invoice_display_name: "new-dis-name", + fee_type: "charge", + units: 5, + unit_amount_cents: 1200, + unit_precise_amount_cents: 1200.2, + grouped_by: {}, + charge_filter: + ) + end + + it "returns fee in the result" do + result = create_service.call + expect(result.fee) + .to be_a(Fee) + .and have_attributes( + organization:, + invoice:, + subscription:, + invoiceable: charge, + charge:, + charge_filter:, + grouped_by: {}, + fee_type: "charge", + payment_status: "pending", + events_count: 0, + amount_currency: invoice.currency, + amount_cents: 0, + precise_amount_cents: 0.to_d, + unit_amount_cents: 0, + precise_unit_amount: 0.to_d, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.to_d, + units: 0, + total_aggregated_units: 0, + properties: Hash, + amount_details: {} + ) + end + + it "calls the RefreshDraft service" do + create_service.call + + expect(Invoices::RefreshDraftService).to have_received(:call) + end + + context "when adjusting a dynamic charge" do + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:dynamic_charge, billable_metric:, plan: subscription.plan) } + + it "creates an adjusted fee and a fee" do + expect { create_service.call } + .to change(AdjustedFee, :count).by(1) + .and change(Fee, :count).by(1) + end + end + + context "when a fee exists with the attributes" do + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, charge_filter:) } + let(:params) do + { + units: 5, + unit_precise_amount: 12.002, + invoice_display_name: "new-dis-name", + subscription_id: subscription.id, + charge_id: fee.charge_id, + charge_filter_id: fee.charge_filter_id + } + end + + it "creates an adjusted fee for the fee" do + result = create_service.call + expect(result.adjusted_fee) + .to be_a(AdjustedFee) + .and have_attributes(fee:) + end + end + + context "when subscription_id does not belongs to the invoice" do + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, charge_filter:) } + let(:params) do + { + units: 5, + unit_precise_amount: 12.002, + invoice_display_name: "new-dis-name", + subscription_id: "invalid_id", + charge_id: fee.charge_id, + charge_filter_id: fee.charge_filter_id + } + end + + it "returns a not found error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("subscription_not_found") + end + end + + context "when charge_id does not belongs to the invoice" do + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, charge_filter:) } + let(:params) do + { + units: 5, + unit_precise_amount: 12.002, + invoice_display_name: "new-dis-name", + subscription_id: subscription.id, + charge_id: "invalid_id", + charge_filter_id: fee.charge_filter_id + } + end + + it "returns a not found error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("charge_not_found") + end + end + + context "when charge_filter_id does not belongs to the invoice" do + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, charge_filter:) } + let(:params) do + { + units: 5, + unit_precise_amount: 12.002, + invoice_display_name: "new-dis-name", + subscription_id: subscription.id, + charge_id: charge.id, + charge_filter_id: "invalid_id" + } + end + + it "returns a not found error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("charge_filter_not_found") + end + end + end + + context "when adjusting fixed charge fees" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:fixed_charge_fee) { create(:fixed_charge_fee, invoice:, subscription:, fixed_charge:) } + let(:params) do + { + fee_id: fixed_charge_fee.id, + units: 10, + unit_precise_amount: 15.5, + invoice_display_name: "Fixed charge adjusted" + } + end + + it "creates an adjusted fee for fixed charge" do + expect { create_service.call }.to change(AdjustedFee, :count).by(1) + end + + it "returns adjusted fee with fixed_charge set" do + result = create_service.call + expect(result.adjusted_fee).to be_a(AdjustedFee) + expect(result.adjusted_fee).to have_attributes( + fixed_charge:, + charge: nil, + fee_type: "fixed_charge", + units: 10, + unit_amount_cents: 1550, + unit_precise_amount_cents: 1550.0, + invoice_display_name: "Fixed charge adjusted" + ) + end + + it "returns fee in the result" do + result = create_service.call + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + fixed_charge:, + charge: nil, + fee_type: "fixed_charge", + units: 0.0, + unit_amount_cents: 0, + precise_amount_cents: 200.0000000001, + properties: hash_including( + "fixed_charges_from_datetime" => fixed_charge_fee.properties["fixed_charges_from_datetime"], + "fixed_charges_to_datetime" => fixed_charge_fee.properties["fixed_charges_to_datetime"] + ) + ) + end + + it "calls the RefreshDraft service" do + create_service.call + + expect(Invoices::RefreshDraftService).to have_received(:call) + end + + context "when adjusting units only" do + let(:params) do + { + fee_id: fixed_charge_fee.id, + units: 10, + invoice_display_name: "Fixed charge adjusted" + } + end + + it "sets adjusted_units to true" do + result = create_service.call + expect(result.adjusted_fee).to have_attributes( + adjusted_units: true, + adjusted_amount: false + ) + end + end + + context "when adjusting both units and amount" do + it "sets adjusted_amount to true" do + result = create_service.call + expect(result.adjusted_fee).to have_attributes( + adjusted_units: false, + adjusted_amount: true + ) + end + end + + context "when creating fee from scratch for fixed charge" do + let(:params) do + { + units: 8, + unit_precise_amount: 20.5, + invoice_display_name: "New fixed charge fee", + subscription_id: subscription.id, + fixed_charge_id: fixed_charge.id + } + end + + it "creates an adjusted fee and a fee" do + expect { create_service.call } + .to change(AdjustedFee, :count).by(1) + .and change(Fee, :count).by(1) + end + + it "returns adjusted fee with correct attributes" do + result = create_service.call + expect(result.adjusted_fee).to be_a(AdjustedFee) + expect(result.adjusted_fee).to have_attributes( + fixed_charge:, + fee_type: "fixed_charge", + units: 8, + unit_amount_cents: 2050, + unit_precise_amount_cents: 2050.0 + ) + end + + it "returns fee with correct attributes" do + result = create_service.call + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + fixed_charge:, + fee_type: "fixed_charge", + invoiceable: fixed_charge + ) + end + end + + context "when fixed_charge does not exist" do + let(:params) do + { + units: 8, + subscription_id: subscription.id, + fixed_charge_id: "invalid_id" + } + end + + it "returns a not found error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("fixed_charge_not_found") + end + end + + context "when prorated graduated fixed charge with units adjustment" do + let(:fixed_charge) do + create( + :fixed_charge, + :graduated, + plan: subscription.plan, + add_on:, + prorated: true, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "1.5", + flat_amount: "15" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "2", + flat_amount: "20" + } + ] + } + ) + end + let(:params) do + { + fee_id: fixed_charge_fee.id, + units: 20 + } + end + + it "returns success" do + result = create_service.call + + expect(result).to be_success + expect(result.adjusted_fee).to be_a(AdjustedFee) + expect(result.adjusted_fee).to have_attributes( + adjusted_units: true, + adjusted_amount: false, + units: 20 + ) + end + end + + context "when prorated graduated fixed charge with amount adjustment" do + let(:fixed_charge) do + create( + :fixed_charge, + :graduated, + plan: subscription.plan, + add_on:, + prorated: true + ) + end + let(:params) do + { + fee_id: fixed_charge_fee.id, + units: 10, + unit_precise_amount: 15.5, + invoice_display_name: "Fixed charge adjusted" + } + end + + it "returns success" do + result = create_service.call + expect(result).to be_success + end + end + end + end + + context "when called from Invoices::RegenerateFromVoidedService flow (with regenerating_voided: true)" do + it "returns success without calling RefreshDraftService" do + result = described_class.new(invoice:, params:, regenerating_voided: true).call + + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.adjusted_fee).to be_a(AdjustedFee) + expect(Invoices::RefreshDraftService).not_to have_received(:call) + end + end + + context "when license is not premium" do + it "returns forbidden status" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + + context "when called from Invoices::RegenerateFromVoidedService flow (with regenerating_voided: true)" do + it "skips license check" do + result = described_class.new(invoice:, params:, regenerating_voided: true).call + expect(result).to be_success + end + end + end + end +end diff --git a/spec/services/adjusted_fees/destroy_service_spec.rb b/spec/services/adjusted_fees/destroy_service_spec.rb new file mode 100644 index 0000000..7b823b2 --- /dev/null +++ b/spec/services/adjusted_fees/destroy_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AdjustedFees::DestroyService do + subject(:destroy_service) { described_class.new(fee:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invoice) { create(:invoice, status: :draft, organization:) } + let(:fee) { create(:charge_fee, invoice:) } + let(:adjusted_fee) { create(:adjusted_fee, invoice:, fee:) } + + describe "#call" do + before do + adjusted_fee + allow(Invoices::RefreshDraftService) + .to receive(:call).with(invoice:) + .and_return(BaseService::Result.new) + end + + it "destroys the adjusted fee" do + expect { destroy_service.call }.to change(AdjustedFee, :count).by(-1) + end + + it "calls the RefreshDraft service" do + destroy_service.call + + expect(Invoices::RefreshDraftService).to have_received(:call) + end + + context "when adjusted fee is not found" do + before { adjusted_fee.update!(fee: nil) } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("adjusted_fee_not_found") + end + end + + context "when fee is not found" do + let(:fee) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("fee_not_found") + end + end + end +end diff --git a/spec/services/adjusted_fees/estimate_service_spec.rb b/spec/services/adjusted_fees/estimate_service_spec.rb new file mode 100644 index 0000000..a53fe43 --- /dev/null +++ b/spec/services/adjusted_fees/estimate_service_spec.rb @@ -0,0 +1,432 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AdjustedFees::EstimateService do + subject(:result) { described_class.call(invoice:, params:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + let(:invoice) do + create( + :invoice, + :voided, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:fee_subscription) do + create( + :fee, + invoice: invoice, + subscription:, + fee_type: :subscription, + precise_unit_amount: 20.00, + units: 10 + ) + end + + before do + fee_subscription + end + + context "when fee does not exist" do + let(:params) do + { + invoice_subscription_id: subscription.id, + fee_id: "invalid_id", + units: 5 + } + end + + it "returns not found error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("fee_not_found") + end + end + + context "when adjusting subscription fees" do + context "when adjusting invoice display name" do + let(:params) do + { + fee_id: fee_subscription.id, + subscription_id: fee_subscription.subscription_id, + invoice_display_name: "new-dis-name" + } + end + + it "returns adjusted fee in the result" do + expect(result.fee).to be_a(Fee) + expect(result.fee.invoice_display_name).to eq "new-dis-name" + expect(result.fee.units).to eq 10 + expect(result.fee.precise_unit_amount).to eq 20.00 + end + end + + context "when adjusting units" do + let(:params) do + { + fee_id: fee_subscription.id, + subscription_id: fee_subscription.subscription_id, + units: 5, + invoice_display_name: "new-dis-name" + } + end + + it "returns adjusted fee in the result" do + expect(result.fee).to be_a(Fee) + expect(result.fee.invoice_display_name).to eq "new-dis-name" + expect(result.fee.units).to eq 5 + end + end + + context "when adjusting units and unit amount" do + let(:params) do + { + fee_id: fee_subscription.id, + subscription_id: fee_subscription.subscription_id, + units: 15, + unit_precise_amount: 12.002, + invoice_display_name: "new-dis-name" + } + end + + it "returns adjusted fee in the result" do + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + units: 15.0, + unit_amount_cents: 1200, + precise_unit_amount: 12.002, + invoice_display_name: "new-dis-name" + ) + end + end + end + + context "when adjusting fixed charge fees" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + properties: {amount: "25"} + ) + end + + let(:fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + precise_unit_amount: 25.00, + units: 8 + ) + end + + context "when adjusting invoice display name only" do + let(:params) do + { + fee_id: fixed_charge_fee.id, + invoice_display_name: "Adjusted fixed charge", + # Service expects to always receive units in params when adjusting fees + units: 8 + } + end + + it "returns adjusted fee with updated display name" do + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + invoice_display_name: "Adjusted fixed charge", + units: 8.0, + precise_unit_amount: 25.00 + ) + end + end + + context "when adjusting units only" do + let(:params) do + { + fee_id: fixed_charge_fee.id, + units: 12, + invoice_display_name: "Adjusted units" + } + end + + it "returns adjusted fee with new units" do + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + units: 12.0, + invoice_display_name: "Adjusted units" + ) + end + end + + context "when adjusting units and unit amount" do + let(:params) do + { + fee_id: fixed_charge_fee.id, + units: 20, + unit_precise_amount: 18.5, + invoice_display_name: "Adjusted amount" + } + end + + it "returns adjusted fee with new units and amount" do + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + fixed_charge:, + invoiceable: fixed_charge, + units: 20.0, + unit_amount_cents: 1850, + precise_unit_amount: 18.5, + invoice_display_name: "Adjusted amount" + ) + end + end + + context "when creating empty fee for fixed charge" do + let(:params) do + { + invoice_subscription_id: subscription.id, + fixed_charge_id: fixed_charge.id, + units: 5, + unit_precise_amount: 30.0 + } + end + + it "returns a new fee" do + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + units: 5.0, + unit_amount_cents: 3000, + precise_unit_amount: 30.0 + ) + end + + it "sets fixed charge boundaries in properties" do + invoice_subscription = invoice.invoice_subscriptions.find_by(subscription_id: subscription.id) + + expect(invoice_subscription.fixed_charges_from_datetime).to be_present + expect(invoice_subscription.fixed_charges_to_datetime).to be_present + + expect(result.fee.properties).to eq( + "timestamp" => invoice_subscription.timestamp.iso8601(3), + "fixed_charges_from_datetime" => invoice_subscription.fixed_charges_from_datetime.iso8601(3), + "fixed_charges_to_datetime" => invoice_subscription.fixed_charges_to_datetime.iso8601(3) + ) + end + end + + context "when fixed_charge does not exist" do + let(:params) do + { + invoice_subscription_id: subscription.id, + fixed_charge_id: "invalid_id", + units: 5 + } + end + + it "returns not found error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("fixed_charge_not_found") + end + end + end + + context "when adjusting charge fees" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:) } + let(:charge_fee) do + create( + :charge_fee, + invoice:, + subscription:, + charge:, + precise_unit_amount: 15.00, + units: 5 + ) + end + + context "when adjusting units only" do + let(:params) do + { + fee_id: charge_fee.id, + units: 8, + invoice_display_name: "Adjusted charge" + } + end + + it "returns adjusted fee with new units" do + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + charge:, + invoiceable: charge, + fee_type: "charge", + units: 8.0, + invoice_display_name: "Adjusted charge" + ) + end + end + + context "when adjusting units and amount" do + let(:params) do + { + fee_id: charge_fee.id, + units: 10, + unit_precise_amount: 20.5 + } + end + + it "returns adjusted fee with new units and amount" do + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + charge:, + invoiceable: charge, + fee_type: "charge", + units: 10.0, + unit_amount_cents: 2050, + precise_unit_amount: 20.5 + ) + end + end + + context "when charge has invalid model for unit adjustment" do + let(:charge) { create(:percentage_charge, plan:) } + let(:params) do + { + fee_id: charge_fee.id, + units: 10 + } + end + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:charge]).to eq(["invalid_charge_model"]) + end + end + + context "when charge has invalid model but adjusting amount" do + let(:charge) { create(:percentage_charge, plan:) } + let(:params) do + { + fee_id: charge_fee.id, + units: 10, + unit_precise_amount: 15.5 + } + end + + it "returns success" do + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + charge:, + invoiceable: charge, + fee_type: "charge", + units: 10.0, + unit_amount_cents: 1550, + precise_unit_amount: 15.5 + ) + end + end + + context "when creating empty fee for a charge" do + let(:params) do + { + invoice_subscription_id: subscription.id, + charge_id: charge.id, + units: 5, + unit_precise_amount: 30.0 + } + end + + it "returns a new fee" do + expect(result.fee).to be_a(Fee) + expect(result.fee).not_to be_persisted + expect(result.fee).to have_attributes( + id: String, + charge:, + invoiceable: charge, + fee_type: "charge", + units: 5.0, + unit_amount_cents: 3000, + precise_unit_amount: 30.0 + ) + end + + it "sets charge boundaries in properties" do + invoice_subscription = invoice.invoice_subscriptions.find_by(subscription_id: subscription.id) + + expect(invoice_subscription.charges_from_datetime).to be_present + expect(invoice_subscription.charges_to_datetime).to be_present + + expect(result.fee.properties).to eq( + "timestamp" => invoice_subscription.timestamp.iso8601(3), + "charges_from_datetime" => invoice_subscription.charges_from_datetime.iso8601(3), + "charges_to_datetime" => invoice_subscription.charges_to_datetime.iso8601(3) + ) + end + end + + context "when charge does not exist" do + let(:params) do + { + invoice_subscription_id: subscription.id, + charge_id: "invalid_id", + units: 5 + } + end + + it "returns not found error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("charge_not_found") + end + end + end +end diff --git a/spec/services/admin/organizations/update_service_spec.rb b/spec/services/admin/organizations/update_service_spec.rb new file mode 100644 index 0000000..1c2bdd2 --- /dev/null +++ b/spec/services/admin/organizations/update_service_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Admin::Organizations::UpdateService do + subject(:update_service) { described_class.new(organization:, params:) } + + let(:organization) { create(:organization) } + + let(:params) do + { + name: "FooBar", + premium_integrations: ["okta"] + } + end + + describe "#call" do + it "updates the organization" do + result = update_service.call + + expect(result.organization.name).to eq("FooBar") + expect(result.organization.premium_integrations).to include("okta") + + organization.reload + + expect(organization.reload.name).to eq("FooBar") + expect(organization.premium_integrations).to include("okta") + end + + context "when organization is nil" do + let(:organization) { nil } + + it "returns a not found error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + end +end diff --git a/spec/services/ai_conversations/fetch_messages_service_spec.rb b/spec/services/ai_conversations/fetch_messages_service_spec.rb new file mode 100644 index 0000000..b2fd455 --- /dev/null +++ b/spec/services/ai_conversations/fetch_messages_service_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AiConversations::FetchMessagesService do + subject(:service) { described_class.new(ai_conversation:) } + + let(:ai_conversation) { create(:ai_conversation, mistral_conversation_id: "conv_123") } + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + end + + describe "#call" do + let(:response_body) do + { + "messages" => [ + { + "type" => "message.output", + "content" => "Hello", + "created_at" => "2024-01-01T12:00:00Z" + }, + { + "type" => "message.output", + "content" => "Hi there!", + "created_at" => "2024-01-01T12:01:00Z" + } + ] + } + end + + before do + allow(http_client).to receive(:get).and_return(response_body) + end + + it "fetches messages from Mistral API" do + result = service.call + + expect(result).to be_success + expect(result.messages).to eq(response_body["messages"]) + expect(http_client).to have_received(:get) + .with(headers: {"Authorization" => "Bearer #{ENV.fetch("MISTRAL_API_KEY")}"}) + end + + context "when API request fails" do + let(:http_error) do + LagoHttpClient::HttpError.new( + 500, + {message: "API error"}.to_json, + URI("https://api.mistral.ai/v1/conversations/conv_123/messages") + ) + end + + before do + allow(http_client).to receive(:get).and_raise(http_error) + end + + it "does not raise an error" do + result = service.call + + expect(result).to be_success + expect(result.error).to be_nil + end + end + + context "when conversation ID is missing" do + let(:ai_conversation) { create(:ai_conversation, mistral_conversation_id: nil) } + + it "returns empty messages array" do + result = service.call + + expect(result).to be_success + expect(result.messages).to eq([]) + end + end + end +end diff --git a/spec/services/ai_conversations/stream_service_spec.rb b/spec/services/ai_conversations/stream_service_spec.rb new file mode 100644 index 0000000..23ececa --- /dev/null +++ b/spec/services/ai_conversations/stream_service_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AiConversations::StreamService do + subject(:service) { described_class.new(ai_conversation:, message:) } + + let(:ai_conversation) { create(:ai_conversation) } + let(:message) { "Hello, how are you?" } + + let(:mcp_client_mock) { instance_double(LagoMcpClient::Client) } + let(:mistral_agent_mock) { instance_double(LagoMcpClient::Mistral::Agent) } + + before do + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("LAGO_MCP_SERVER_URL").and_return("http://localhost:3001") + + allow(LagoMcpClient::Client).to receive(:new).and_return(mcp_client_mock) + allow(LagoMcpClient::Mistral::Agent).to receive(:new).and_return(mistral_agent_mock) + allow(LagoMcpClient::Config).to receive(:new).and_return(instance_double(LagoMcpClient::Config)) + + allow(mcp_client_mock).to receive(:setup!) + allow(mistral_agent_mock).to receive(:setup!) + allow(mistral_agent_mock).to receive(:conversation_id).and_return(nil) + allow(LagoApiSchema.subscriptions).to receive(:trigger) + end + + describe "#call" do + context "when streaming succeeds" do + before do + allow(mistral_agent_mock).to receive(:chat) + end + + it "returns the ai_conversation" do + result = service.call + + expect(result).to be_success + expect(result.ai_conversation).to eq(ai_conversation) + end + + it "streams chunks and notifies completion" do + chunks = ["Hello", " ", "world", "!"] + + allow(mistral_agent_mock).to receive(:chat) do |_msg, &block| + chunks.each { |chunk| block.call(chunk) } + end + + service.call + + chunks.each do |chunk| + expect(LagoApiSchema.subscriptions).to have_received(:trigger).with( + :ai_conversation_streamed, + {id: ai_conversation.id}, + {chunk:, done: false} + ) + end + + expect(LagoApiSchema.subscriptions).to have_received(:trigger).with( + :ai_conversation_streamed, + {id: ai_conversation.id}, + {chunk: nil, done: true} + ) + end + + it "ignores nil chunks" do + allow(mistral_agent_mock).to receive(:chat) do |_msg, &block| + block.call("text") + block.call(nil) + block.call("more") + end + + service.call + + expect(LagoApiSchema.subscriptions).to have_received(:trigger).with( + :ai_conversation_streamed, + {id: ai_conversation.id}, + {chunk: "text", done: false} + ) + + expect(LagoApiSchema.subscriptions).to have_received(:trigger).with( + :ai_conversation_streamed, + {id: ai_conversation.id}, + {chunk: "more", done: false} + ) + + expect(LagoApiSchema.subscriptions).to have_received(:trigger).with( + :ai_conversation_streamed, + {id: ai_conversation.id}, + {chunk: nil, done: true} + ) + end + + it "passes conversation_id to the mistral agent" do + allow(mistral_agent_mock).to receive(:chat) + + service.call + + expect(LagoMcpClient::Mistral::Agent).to have_received(:new).with( + client: mcp_client_mock, + conversation_id: ai_conversation.mistral_conversation_id + ) + end + + context "when mistral agent returns a new conversation_id" do + let(:ai_conversation) { create(:ai_conversation, mistral_conversation_id: nil) } + let(:new_conversation_id) { "new-mistral-conv-456" } + + before do + allow(mistral_agent_mock).to receive(:chat) + allow(mistral_agent_mock).to receive(:conversation_id).and_return(new_conversation_id) + end + + it "saves the conversation_id to the ai_conversation" do + expect { service.call }.to change { ai_conversation.reload.mistral_conversation_id } + .from(nil).to(new_conversation_id) + end + end + + context "when mistral agent returns the same conversation_id" do + let(:existing_conversation_id) { "existing-conv-123" } + let(:ai_conversation) { create(:ai_conversation, mistral_conversation_id: existing_conversation_id) } + + before do + allow(mistral_agent_mock).to receive(:chat) + allow(mistral_agent_mock).to receive(:conversation_id).and_return(existing_conversation_id) + end + + it "does not update the ai_conversation" do + allow(ai_conversation).to receive(:update!) + service.call + + expect(ai_conversation).not_to have_received(:update!) + end + end + end + + context "when MCP server URL is missing" do + before do + allow(ENV).to receive(:[]).with("LAGO_MCP_SERVER_URL").and_return(nil) + end + + it "returns forbidden_failure" do + result = service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + + it "does not initialize clients" do + service.call + expect(LagoMcpClient::Client).not_to have_received(:new) + end + end + + context "when an error occurs during streaming" do + it "logs the error and sends it to frontend" do + error = StandardError.new("Connection timeout") + allow(mistral_agent_mock).to receive(:chat).and_raise(error) + + service.call + + expect(LagoApiSchema.subscriptions).to have_received(:trigger).with( + :ai_conversation_streamed, + {id: ai_conversation.id}, + {chunk: "Error: Connection timeout", done: true} + ) + end + + it "returns service_failure result" do + error = StandardError.new("Test error") + allow(mistral_agent_mock).to receive(:chat).and_raise(error) + + result = service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("stream_service_error") + end + end + end +end diff --git a/spec/services/analytics/gross_revenues_service_spec.rb b/spec/services/analytics/gross_revenues_service_spec.rb new file mode 100644 index 0000000..1212b31 --- /dev/null +++ b/spec/services/analytics/gross_revenues_service_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::GrossRevenuesService do + let(:service) { described_class.new(organization) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is premium", :premium do + it "returns success" do + expect(service_call).to be_success + end + end + + context "when licence is not premium" do + it "returns success" do + expect(service_call).to be_success + end + end + end +end diff --git a/spec/services/analytics/invoice_collections_service_spec.rb b/spec/services/analytics/invoice_collections_service_spec.rb new file mode 100644 index 0000000..bdb4877 --- /dev/null +++ b/spec/services/analytics/invoice_collections_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::InvoiceCollectionsService do + let(:service) { described_class.new(organization) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is premium", :premium do + it "returns success" do + expect(service_call).to be_success + end + end + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/analytics/invoiced_usages_service_spec.rb b/spec/services/analytics/invoiced_usages_service_spec.rb new file mode 100644 index 0000000..6e93ce3 --- /dev/null +++ b/spec/services/analytics/invoiced_usages_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::InvoicedUsagesService do + let(:service) { described_class.new(organization) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is premium", :premium do + it "returns success" do + expect(service_call).to be_success + end + end + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/analytics/mrrs_service_spec.rb b/spec/services/analytics/mrrs_service_spec.rb new file mode 100644 index 0000000..51917bd --- /dev/null +++ b/spec/services/analytics/mrrs_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::MrrsService do + let(:service) { described_class.new(organization) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is premium", :premium do + it "returns success" do + expect(service_call).to be_success + end + end + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/analytics/overdue_balances_service_spec.rb b/spec/services/analytics/overdue_balances_service_spec.rb new file mode 100644 index 0000000..19f759c --- /dev/null +++ b/spec/services/analytics/overdue_balances_service_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Analytics::OverdueBalancesService do + let(:service) { described_class.new(organization) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe "#call" do + subject(:service_call) { service.call } + + it "returns success" do + expect(service_call).to be_success + end + + it "calls Analytics::OverdueBalance" do + allow(Analytics::OverdueBalance).to receive(:find_all_by) + service_call + expect(Analytics::OverdueBalance).to have_received(:find_all_by).with(organization.id) + end + end +end diff --git a/spec/services/api_keys/cache_service_spec.rb b/spec/services/api_keys/cache_service_spec.rb new file mode 100644 index 0000000..550f6bc --- /dev/null +++ b/spec/services/api_keys/cache_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKeys::CacheService, cache: :redis do + subject(:cache_service) { described_class.new(auth_token, with_cache:) } + + let(:auth_token) { "token" } + let(:with_cache) { false } + + describe "#cache_key" do + it "returns the cache key" do + expect(cache_service.cache_key).to eq("api_key/#{described_class::CACHE_KEY_VERSION}/token") + end + end + + describe "#expire_cache" do + it "deletes the cached value" do + allow(Rails.cache).to receive(:delete).with(cache_service.cache_key) + + cache_service.expire_cache + + expect(Rails.cache).to have_received(:delete).with(cache_service.cache_key) + end + end + + describe "#expire_all_cache" do + let(:organization) { create(:organization, api_keys:) } + let(:api_keys) { create_list(:api_key, 3) } + + it "deletes all cached values" do + allow(Rails.cache).to receive(:delete) + + described_class.expire_all_cache(organization) + + expect(Rails.cache).to have_received(:delete).exactly(3).times + end + end + + describe "#call" do + let(:organization) { create(:organization, api_keys: [api_key]) } + let(:api_key) { create(:api_key) } + let(:auth_token) { api_key.value } + + before { organization } + + it "returns the api_key and the organization" do + expect(cache_service.call).to eq([api_key, organization]) + end + + context "when cache is enabled" do + let(:with_cache) { true } + + before { Rails.cache.clear } + + it "returns the api_key and the organization and create the cache" do + expect(cache_service.call).to eq([api_key, organization]) + + expect(Rails.cache.read(cache_service.cache_key)).to be_present + end + + context "when cache exists" do + before { cache_service.call } + + it "returns the api_key and the organization" do + expect(cache_service.call).to eq([api_key, organization]) + end + end + + context "when cached value is expired" do + let(:api_key) { create(:api_key, expires_at: Time.current - 10.minutes) } + + before do + Rails.cache.write(cache_service.cache_key, { + api_key: api_key.attributes, + organization: organization.attributes + }.to_json) + end + + it "does not return the cache key and organization" do + expect(cache_service.call).to eq([nil, nil]) + end + end + end + end +end diff --git a/spec/services/api_keys/create_service_spec.rb b/spec/services/api_keys/create_service_spec.rb new file mode 100644 index 0000000..36f8ce7 --- /dev/null +++ b/spec/services/api_keys/create_service_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKeys::CreateService do + include_context "with mocked security logger" + + describe "#call" do + subject(:service_result) { described_class.call(params) } + + let(:name) { Faker::Lorem.words.join(" ") } + let(:organization) { create(:organization) } + + context "with premium organization", :premium do + context "when permissions hash is provided" do + let(:params) { {permissions:, name:, organization:} } + let(:permissions) { {"add_on" => ["read", "write"], "customer" => []} } + + before { organization.update!(premium_integrations:) } + + context "when organization has api permissions addon" do + let(:premium_integrations) { ["api_permissions"] } + + it "creates a new API key" do + expect { service_result }.to change(ApiKey, :count).by(1) + end + + it "sends an API key created email" do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :created) + .with(hash_including(params: {api_key: instance_of(ApiKey)})) + end + + it_behaves_like "produces a security log", "api_key.created" do + before { service_result } + end + end + + context "when organization has no api permissions addon" do + let(:premium_integrations) { [] } + + it "does not create an API key" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "does not send an API key created email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :created) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + expect(service_result.error.code).to eq("premium_integration_missing") + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + end + + context "when permissions hash is missing" do + let(:params) { {name:, organization:} } + + before { organization.update!(premium_integrations:) } + + context "when organization has api permissions addon" do + let(:premium_integrations) { ["api_permissions"] } + + it "creates a new API key" do + expect { service_result }.to change(ApiKey, :count).by(1) + end + + it "sends an API key created email" do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :created) + .with(hash_including(params: {api_key: instance_of(ApiKey)})) + end + end + + context "when organization has no api permissions addon" do + let(:premium_integrations) { [] } + + it "creates a new API key" do + expect { service_result }.to change(ApiKey, :count).by(1) + end + + it "sends an API key created email" do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :created) + .with(hash_including(params: {api_key: instance_of(ApiKey)})) + end + end + end + end + + context "with free organization" do + context "when permissions hash is provided" do + let(:params) { {permissions:, name:, organization:} } + let(:permissions) { ApiKey.default_permissions } + + before { organization.update!(premium_integrations:) } + + context "when organization has api permissions addon" do + let(:premium_integrations) { ["api_permissions"] } + + it "does not create an API key" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "does not send an API key created email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :created) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + + context "when organization has no api permissions addon" do + let(:premium_integrations) { [] } + + it "does not create an API key" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "does not send an API key created email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :created) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + end + + context "when permissions hash is missing" do + let(:params) { {name:, organization:} } + + before { organization.update!(premium_integrations:) } + + context "when organization has api permissions addon" do + let(:premium_integrations) { ["api_permissions"] } + + it "does not create an API key" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "does not send an API key created email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :created) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + + context "when organization has no api permissions addon" do + let(:premium_integrations) { [] } + + it "does not create an API key" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "does not send an API key created email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :created) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + end + end + end +end diff --git a/spec/services/api_keys/destroy_service_spec.rb b/spec/services/api_keys/destroy_service_spec.rb new file mode 100644 index 0000000..775e4a9 --- /dev/null +++ b/spec/services/api_keys/destroy_service_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKeys::DestroyService do + include_context "with mocked security logger" + + describe "#call" do + subject(:service_result) { described_class.call(api_key) } + + context "when API key is missing" do + let(:api_key) { nil } + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::NotFoundFailure) + expect(service_result.error.error_code).to eq("api_key_not_found") + end + + it "does not send an API key destroyed email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :destroyed) + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + + context "when API key is present" do + let!(:api_key) { create(:api_key) } + + context "when organization has another non-expiring key" do + before do + create(:api_key, organization: api_key.organization) + freeze_time + end + + it "expires the API key with current time" do + expect { subject }.to change(api_key, :expires_at).to(Time.current) + end + + it "sends an API key destroyed email" do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :destroyed).with hash_including(params: {api_key:}) + end + + it_behaves_like "produces a security log", "api_key.deleted" do + before { service_result } + end + end + + context "when organization has no another non-expiring key" do + before { create(:api_key, :expired, organization: api_key.organization) } + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ValidationFailure) + expect(service_result.error.messages.values.flatten).to include("last_non_expiring_api_key") + end + + it "does not expire the key" do + expect { subject }.not_to change(api_key, :expires_at).from(nil) + end + + it "does not send an API key destroyed email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :destroyed) + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + end + end +end diff --git a/spec/services/api_keys/rotate_service_spec.rb b/spec/services/api_keys/rotate_service_spec.rb new file mode 100644 index 0000000..a332b2c --- /dev/null +++ b/spec/services/api_keys/rotate_service_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKeys::RotateService do + include_context "with mocked security logger" + + describe "#call" do + subject(:service_result) { described_class.call(api_key:, params:) } + + let(:params) { {expires_at:, name:} } + let(:name) { Faker::Lorem.words.join(" ") } + + context "when API key is provided" do + let!(:api_key) { create(:api_key) } + let(:organization) { api_key.organization } + + context "when preferred expiration date is provided" do + let(:expires_at) { generate(:future_date) } + + context "with premium organization", :premium do + it "expires the API key with preferred date" do + expect { service_result } + .to change { api_key.reload.expires_at&.iso8601 } + .to(expires_at.iso8601) + end + + it "creates a new API key for organization" do + expect { service_result }.to change(ApiKey, :count).by(1) + + expect(service_result.api_key) + .to be_persisted.and have_attributes(organization:, name:) + end + + it "sends an API key rotated email" do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:}) + end + + it_behaves_like "produces a security log", "api_key.rotated" do + before { service_result } + end + end + + context "with free organization" do + it "does not creates a new API key for organization" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "does not send an API key rotated email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :rotated) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + expect(service_result.error.code).to eq("cannot_rotate_with_provided_date") + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + end + + context "when preferred expiration date is missing" do + let(:expires_at) { nil } + + before { freeze_time } + + context "with premium organization", :premium do + it "expires the API key with current time" do + expect { service_result }.to change(api_key, :expires_at).to(Time.current) + end + + it "creates a new API key for organization" do + expect { service_result }.to change(ApiKey.unscoped, :count).by(1) + + expect(service_result.api_key) + .to be_persisted.and have_attributes(organization:, name:) + end + + it "sends an API key rotated email" do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:}) + end + end + + context "with free organization" do + it "expires the API key with current time" do + expect { service_result }.to change(api_key, :expires_at).to(Time.current) + end + + it "creates a new API key for organization" do + expect { service_result }.to change(ApiKey.unscoped, :count).by(1) + + expect(service_result.api_key) + .to be_persisted.and have_attributes(organization:, name:) + end + + it "sends an API key rotated email" do + expect { service_result } + .to have_enqueued_mail(ApiKeyMailer, :rotated).with hash_including(params: {api_key:}) + end + end + end + end + + context "when API key is missing" do + let(:api_key) { nil } + let(:expires_at) { double } + + it "does not creates a new API key for organization" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "does not send an API key rotated email" do + expect { service_result }.not_to have_enqueued_mail(ApiKeyMailer, :rotated) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::NotFoundFailure) + expect(service_result.error.error_code).to eq("api_key_not_found") + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + end +end diff --git a/spec/services/api_keys/track_usage_service_spec.rb b/spec/services/api_keys/track_usage_service_spec.rb new file mode 100644 index 0000000..d811989 --- /dev/null +++ b/spec/services/api_keys/track_usage_service_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKeys::TrackUsageService, cache: :memory do + describe "#call" do + subject { described_class.call } + + let(:used_api_key) { create(:api_key) } + let(:unused_api_key) { create(:api_key) } + let(:last_used_at) { 1.hour.ago.iso8601 } + let(:cache_key) { "api_key_last_used_#{used_api_key.id}" } + + before { Rails.cache.write(cache_key, last_used_at) } + + it "updates when API key was last used" do + expect { subject }.to change { used_api_key.reload.last_used_at&.iso8601 }.to last_used_at + end + + it "clears cache after processing" do + expect { subject }.to change { Rails.cache.exist?(cache_key) }.to(false) + end + + it "does not update unused key" do + expect { subject }.not_to change { unused_api_key.reload.last_used_at } + end + end +end diff --git a/spec/services/api_keys/update_service_spec.rb b/spec/services/api_keys/update_service_spec.rb new file mode 100644 index 0000000..c58c583 --- /dev/null +++ b/spec/services/api_keys/update_service_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ApiKeys::UpdateService, :premium do + subject(:service_result) { described_class.call(api_key:, params:) } + + include_context "with mocked security logger" + + let(:name) { Faker::Lorem.words.join(" ") } + + context "when API key is provided" do + let!(:api_key) { create(:api_key) } + let(:organization) { api_key.organization } + + context "when permissions hash is provided" do + let(:params) { {permissions:, name:} } + let(:permissions) { api_key.permissions.merge("add_on" => ["read"]) } + + before { organization.update!(premium_integrations:) } + + context "when organization has api permissions addon" do + let(:premium_integrations) { ["api_permissions"] } + + it "updates the API key" do + expect { service_result }.to change { api_key.reload.permissions }.to(permissions) + end + + it_behaves_like "produces a security log", "api_key.updated" do + before { service_result } + end + end + + context "when organization has no api permissions addon" do + let(:premium_integrations) { [] } + + it "does not update an API key" do + expect { service_result }.not_to change(api_key, :permissions) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ForbiddenFailure) + expect(service_result.error.code).to eq("premium_integration_missing") + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end + end + + context "when permissions hash is missing" do + let(:params) { {name:} } + + before { organization.update!(premium_integrations:) } + + context "when organization has api permissions addon" do + let(:premium_integrations) { ["api_permissions"] } + + it "updates the API key" do + expect { service_result }.to change(api_key, :name).to(name) + end + end + + context "when organization has no api permissions addon" do + let(:premium_integrations) { [] } + + it "updates the API key" do + expect { service_result }.to change(api_key, :name).to(name) + end + end + end + end + + context "when API key is missing" do + let(:api_key) { nil } + let(:params) { {name:} } + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::NotFoundFailure) + expect(service_result.error.error_code).to eq("api_key_not_found") + end + + it_behaves_like "does not produce a security log" do + before { service_result } + end + end +end diff --git a/spec/services/applied_coupons/amount_service_spec.rb b/spec/services/applied_coupons/amount_service_spec.rb new file mode 100644 index 0000000..5dd45d5 --- /dev/null +++ b/spec/services/applied_coupons/amount_service_spec.rb @@ -0,0 +1,168 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedCoupons::AmountService do + subject(:amount_service) do + described_class.new(applied_coupon:, base_amount_cents:) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:base_amount_cents) { 300 } + let(:coupon) { create(:coupon, organization:) } + let(:applied_coupon) { create(:applied_coupon, amount_cents: 12, coupon:, customer:) } + + describe "call" do + it "calculates amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(12) + end + + context "when base_amount_cents is equal to 0" do + let(:base_amount_cents) { 0 } + + it "limits the amount to the invoice amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(0) + end + end + + context "when coupon amount is higher than invoice amount" do + let(:base_amount_cents) { 6 } + + it "limits the amount to the invoice amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(6) + end + end + + context "when coupon is partially used" do + before do + create( + :credit, + applied_coupon:, + amount_cents: 6 + ) + end + + it "applies the remaining amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(6) + end + end + + context "when coupon is percentage" do + let(:coupon) { create(:coupon, coupon_type: "percentage", percentage_rate: 10.00) } + + let(:applied_coupon) do + create(:applied_coupon, coupon:, percentage_rate: 20.00) + end + + it "calculates amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(60) + end + end + + context "when coupon is recurring and fixed amount" do + let(:coupon) { create(:coupon, frequency: "recurring", frequency_duration: 3) } + + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + frequency: "recurring", + frequency_duration: 3, + frequency_duration_remaining: 3, + amount_cents: 12 + ) + end + + it "calculates amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(12) + end + + context "when coupon amount is higher than invoice amount" do + let(:base_amount_cents) { 6 } + + it "limits the amount to the invoice amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(6) + end + end + end + + context "when coupon is forever and fixed amount" do + let(:coupon) { create(:coupon, frequency: "forever", frequency_duration: 0) } + + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + frequency: "forever", + frequency_duration: 0, + frequency_duration_remaining: 0, + amount_cents: 12 + ) + end + + it "calculates amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(12) + end + + context "when coupon amount is higher than invoice amount" do + let(:base_amount_cents) { 6 } + + it "limits the amount to the invoice amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(6) + end + end + end + + context "when coupon is recurring and percentage" do + let(:coupon) do + create(:coupon, frequency: "recurring", frequency_duration: 3, coupon_type: "percentage", percentage_rate: 10) + end + + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + frequency: "recurring", + frequency_duration: 3, + frequency_duration_remaining: 3, + percentage_rate: 20.00 + ) + end + + it "calculates amount" do + result = amount_service.call + + expect(result).to be_success + expect(result.amount).to eq(60) + end + end + end +end diff --git a/spec/services/applied_coupons/create_service_spec.rb b/spec/services/applied_coupons/create_service_spec.rb new file mode 100644 index 0000000..69038c4 --- /dev/null +++ b/spec/services/applied_coupons/create_service_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedCoupons::CreateService do + subject(:create_service) do + described_class.new(customer:, coupon:, params:) + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:customer) { create(:customer, organization:) } + let(:coupon) { create(:coupon, status: "active", organization:) } + + let(:amount_cents) { nil } + let(:amount_currency) { nil } + let(:percentage_rate) { nil } + + let(:params) do + { + amount_cents:, + amount_currency:, + percentage_rate: + } + end + + let(:create_subscription) { customer.present? } + + before do + create(:subscription, customer:) if create_subscription + end + + describe "create" do + let(:create_result) { create_service.call } + + it "applied the coupon to the customer" do + expect { create_result }.to change(AppliedCoupon, :count).by(1) + + expect(create_result.applied_coupon.customer).to eq(customer) + expect(create_result.applied_coupon.coupon).to eq(coupon) + expect(create_result.applied_coupon.amount_cents).to eq(coupon.amount_cents) + expect(create_result.applied_coupon.amount_currency).to eq(coupon.amount_currency) + end + + it "produces an activity log" do + applied_coupon = described_class.call(customer:, coupon:, params:).applied_coupon + + expect(Utils::ActivityLog).to have_produced("applied_coupon.created").after_commit.with(applied_coupon) + end + + context "when coupon type is percentage" do + let(:coupon) do + create( + :coupon, + status: "active", + organization:, + coupon_type: "percentage", + percentage_rate: 10.00 + ) + end + + let(:percentage_rate) { 20.00 } + + before { customer.update!(currency: nil) } + + it "applies the coupon to the customer" do + expect { create_result }.to change(AppliedCoupon, :count).by(1) + end + + it "sets correct percentage rate" do + expect(create_result.applied_coupon.percentage_rate).to eq(20.00) + end + + it "does not try to update customer currency" do + expect(create_result.applied_coupon.customer.currency).to eq nil + end + end + + context "when an other coupon is already applied to the customer" do + let(:other_coupon) { create(:coupon, status: "active", organization:) } + + before { create(:applied_coupon, customer:, coupon:) } + + it "applied the coupon to the customer" do + expect { create_result }.to change(AppliedCoupon, :count).by(1) + + expect(create_result.applied_coupon.customer).to eq(customer) + expect(create_result.applied_coupon.coupon).to eq(coupon) + expect(create_result.applied_coupon.organization).to eq(organization) + expect(create_result.applied_coupon.amount_cents).to eq(coupon.amount_cents) + expect(create_result.applied_coupon.amount_currency).to eq(coupon.amount_currency) + end + end + + context "with overridden amount" do + let(:amount_cents) { 123 } + let(:amount_currency) { "EUR" } + + it { expect(create_result.applied_coupon.amount_cents).to eq(123) } + it { expect(create_result.applied_coupon.amount_currency).to eq("EUR") } + + context "when currency does not match" do + let(:amount_currency) { "NOK" } + + before { customer.update!(currency: "EUR") } + + it "fails" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::ValidationFailure) + expect(create_result.error.messages.keys).to include(:currency) + expect(create_result.error.messages[:currency]).to include("currencies_does_not_match") + end + end + end + + context "when customer is not found" do + let(:customer) { nil } + + it "returns a not found error" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::NotFoundFailure) + expect(create_result.error.message).to eq("customer_not_found") + end + end + + context "when coupon is not found" do + let(:coupon) { nil } + + it "returns a not found error" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::NotFoundFailure) + expect(create_result.error.message).to eq("coupon_not_found") + end + end + + context "when coupon is already applied to the customer and is not reusable" do + let(:coupon) { create(:coupon, status: "active", organization:, reusable: false) } + + before { create(:applied_coupon, customer:, coupon:) } + + it "fails" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::ValidationFailure) + expect(create_result.error.messages.keys).to include(:coupon) + expect(create_result.error.messages[:coupon]).to include("coupon_is_not_reusable") + end + end + + context "when coupon is already applied with the plan limitation" do + let(:plan) { create(:plan, organization:) } + let(:coupon_old) { create(:coupon, status: "active", organization:, limited_plans: true) } + let(:coupon) { create(:coupon, status: "active", organization:, limited_plans: true) } + let(:coupon_plan_old) { create(:coupon_plan, coupon: coupon_old, plan:) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan:) } + + before do + coupon_plan_old + create(:applied_coupon, customer:, coupon: coupon_old) + end + + context "when newly applied coupon has the same plan limitation" do + before { coupon_plan } + + it "fails" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(create_result.error.code).to eq("plan_overlapping") + end + end + + context "when newly applied coupon has the BM limitation that overlaps with already applied plan limitation" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:coupon) { create(:coupon, status: "active", organization:, limited_billable_metrics: true) } + let(:coupon_billable_metric) { create(:coupon_billable_metric, coupon:, billable_metric:) } + + before do + charge + coupon_billable_metric + end + + it "fails" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(create_result.error.code).to eq("plan_overlapping") + end + end + + context "when newly applied coupon has the plan limitation that overlaps with already applied BM limitation" do + let(:coupon_old) { create(:coupon, status: "active", organization:, limited_billable_metrics: true) } + let(:coupon_bm_old) { create(:coupon_billable_metric, coupon: coupon_old, billable_metric:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:coupon) { create(:coupon, status: "active", organization:, limited_plans: true) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan:) } + + before do + charge + coupon_bm_old + coupon_plan + end + + it "fails" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(create_result.error.code).to eq("plan_overlapping") + end + end + end + + context "when coupon is inactive" do + before { coupon.terminated! } + + it "returns a not found error" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::NotFoundFailure) + expect(create_result.error.message).to eq("coupon_not_found") + end + end + + context "when currency of coupon does not match customer currency" do + let(:coupon) { create(:coupon, status: "active", organization:, amount_currency: "NOK") } + + before { customer.update!(currency: "EUR") } + + it "fails" do + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::ValidationFailure) + expect(create_result.error.messages.keys).to include(:currency) + expect(create_result.error.messages[:currency]).to include("currencies_does_not_match") + end + end + + context "when customer does not have a currency" do + let(:create_subscription) { false } + let(:amount_currency) { "NOK" } + + before { customer.update!(currency: nil) } + + it "assigns the coupon currency to the customer" do + create_result + + expect(customer.reload.currency).to eq(amount_currency) + end + end + + context "when frequency is overridden to recurring without frequency_duration" do + let(:coupon) do + create(:coupon, status: "active", organization:, frequency: "once", frequency_duration: nil) + end + + let(:params) do + { + amount_cents:, + amount_currency:, + percentage_rate:, + frequency: "recurring" + } + end + + it "fails with a validation error" do + expect { create_result }.not_to change(AppliedCoupon, :count) + + expect(create_result).not_to be_success + expect(create_result.error).to be_a(BaseService::ValidationFailure) + expect(create_result.error.messages[:frequency_duration]).to eq(["value_is_mandatory", "is not a number"]) + expect(create_result.error.messages[:frequency_duration_remaining]).to eq(["value_is_mandatory", "is not a number"]) + end + end + end +end diff --git a/spec/services/applied_coupons/lock_service_spec.rb b/spec/services/applied_coupons/lock_service_spec.rb new file mode 100644 index 0000000..0d9a8d2 --- /dev/null +++ b/spec/services/applied_coupons/lock_service_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedCoupons::LockService do + subject(:lock_service) { described_class.new(customer:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + describe "#call" do + it "takes an advisory lock" do + expect(lock_service).not_to be_locked + + lock_service.call do + expect(lock_service).to be_locked + end + + expect(lock_service).not_to be_locked + end + end +end diff --git a/spec/services/applied_coupons/recredit_service_spec.rb b/spec/services/applied_coupons/recredit_service_spec.rb new file mode 100644 index 0000000..fd0db63 --- /dev/null +++ b/spec/services/applied_coupons/recredit_service_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedCoupons::RecreditService do + subject(:recredit_service) { described_class.new(credit:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:coupon) { create(:coupon, organization:) } + let(:invoice) { create(:invoice, customer:, organization:, status: :finalized) } + let(:applied_coupon) { create(:applied_coupon, coupon:, customer:, organization:) } + let(:credit) { create(:credit, invoice:, applied_coupon:, amount_cents: 100) } + + describe "#call" do + context "when applied_coupon is not found" do + let(:credit) { create(:credit, invoice:, applied_coupon: nil) } + + it "returns a not_found failure" do + result = recredit_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("applied_coupon") + end + end + + context "when applied_coupon is terminated" do + let(:applied_coupon) do + create(:applied_coupon, + coupon:, + customer:, + organization:, + status: :terminated, + terminated_at: Time.current) + end + + context "when it should be reactivated" do + it "reactivates the coupon" do + expect { + recredit_service.call + }.to change { applied_coupon.reload.status }.from("terminated").to("active") + .and change { applied_coupon.reload.terminated_at }.to(nil) + end + + it "uses with_lock to prevent race conditions" do + allow(applied_coupon).to receive(:with_lock).and_call_original + recredit_service.call + expect(applied_coupon).to have_received(:with_lock) + end + end + + context "when it is a forever coupon" do + let(:applied_coupon) do + create(:applied_coupon, + coupon:, + customer:, + organization:, + status: :terminated, + terminated_at: Time.current, + frequency: :forever) + end + + it "does not reactivate the coupon" do + expect { + recredit_service.call + }.not_to change { applied_coupon.reload.status } + end + end + + context "when the original coupon is terminated" do + let(:coupon) { create(:coupon, organization:, status: :terminated) } + + it "does not reactivate the coupon" do + expect { + recredit_service.call + }.not_to change { applied_coupon.reload.status } + end + end + end + + context "when applied_coupon is recurring" do + let(:applied_coupon) do + create(:applied_coupon, + coupon:, + customer:, + organization:, + frequency: :recurring, + frequency_duration: 3, + frequency_duration_remaining: 2) + end + + it "increments frequency_duration_remaining" do + expect { + recredit_service.call + }.to change { applied_coupon.reload.frequency_duration_remaining }.from(2).to(3) + end + + it "uses with_lock to prevent race conditions" do + allow(applied_coupon).to receive(:with_lock).and_call_original + recredit_service.call + expect(applied_coupon).to have_received(:with_lock) + end + end + end +end diff --git a/spec/services/applied_coupons/terminate_service_spec.rb b/spec/services/applied_coupons/terminate_service_spec.rb new file mode 100644 index 0000000..ba2daef --- /dev/null +++ b/spec/services/applied_coupons/terminate_service_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedCoupons::TerminateService do + subject(:terminate_service) { described_class.new(applied_coupon:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon) { create(:coupon, status: "active", organization:) } + let(:applied_coupon) { create(:applied_coupon, coupon:) } + + describe "#call" do + it "terminates the applied coupon" do + result = terminate_service.call + + expect(result).to be_success + expect(result.applied_coupon).to be_terminated + end + + it "produces an activity log" do + described_class.call(applied_coupon:) + + expect(Utils::ActivityLog).to have_produced("applied_coupon.deleted").with(applied_coupon) + end + + context "when applied coupon is already terminated" do + before { applied_coupon.mark_as_terminated! } + + it "does not impact the applied coupon" do + terminated_at = applied_coupon.reload.terminated_at + result = terminate_service.call + + expect(result).to be_success + expect(result.applied_coupon).to be_terminated + expect(result.applied_coupon.terminated_at).to eq(terminated_at) + end + end + end +end diff --git a/spec/services/applied_pricing_units/create_service_spec.rb b/spec/services/applied_pricing_units/create_service_spec.rb new file mode 100644 index 0000000..e229187 --- /dev/null +++ b/spec/services/applied_pricing_units/create_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedPricingUnits::CreateService do + let(:create_service) { described_class.new(charge:, params:) } + + describe "#create_applied_pricing_unit?" do + subject { create_service.create_applied_pricing_unit? } + + let(:charge) { build_stubbed(:standard_charge) } + + context "when premium", :premium do + context "when params are present" do + let(:params) { {code: "credits", conversion_rate: 1.5} } + + it "returns true" do + expect(subject).to be true + end + end + + context "when params are missing" do + let(:params) { {} } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when freemium" do + context "when params are present" do + let(:params) { {code: "credits", conversion_rate: 1.5} } + + it "returns false" do + expect(subject).to be false + end + end + + context "when params are missing" do + let(:params) { {} } + + it "returns false" do + expect(subject).to be false + end + end + end + end + + describe "#call" do + subject(:result) { create_service.call } + + context "when charge is missing" do + let(:charge) { nil } + let(:params) { {} } + + it "fails with charge not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("charge_not_found") + end + + it "does not create applied pricing unit" do + expect { subject }.not_to change(AppliedPricingUnit, :count) + end + end + + context "when charge is present" do + let(:organization) { create(:organization) } + let(:charge) { create(:standard_charge, organization:) } + + context "when applied pricing unit should not be created" do + let(:params) { {} } + + it "does not create applied pricing unit and return empty result" do + expect { subject }.not_to change(AppliedPricingUnit, :count) + expect(result).to be_success + end + end + + context "when applied pricing unit should be created", :premium do + let!(:pricing_unit) { create(:pricing_unit, organization:) } + + context "when params are valid" do + let(:params) { {code: pricing_unit.code, conversion_rate: 1.5} } + + it "creates an applied pricing unit" do + expect { subject } + .to change { charge.reload.applied_pricing_unit } + .to(AppliedPricingUnit) + end + + it "sets the correct attributes" do + applied_pricing_unit = result.charge.applied_pricing_unit + expect(applied_pricing_unit.pricing_unit).to eq(pricing_unit) + expect(applied_pricing_unit.organization).to eq(organization) + expect(applied_pricing_unit.conversion_rate).to eq(1.5) + end + end + + context "when params are invalid" do + let(:params) { {code: "non-existing-code", conversion_rate: -1} } + + it "fails with validation error" do + expect(result).to be_failure + + expect(result.error.messages).to match( + conversion_rate: ["value_is_out_of_range"], + pricing_unit: ["relation_must_exist"] + ) + end + + it "does not create an applied pricing unit" do + expect { subject }.not_to change(AppliedPricingUnit, :count) + end + end + end + end + end +end diff --git a/spec/services/applied_pricing_units/update_service_spec.rb b/spec/services/applied_pricing_units/update_service_spec.rb new file mode 100644 index 0000000..f87ce65 --- /dev/null +++ b/spec/services/applied_pricing_units/update_service_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe AppliedPricingUnits::UpdateService do + let(:update_service) { described_class.new(charge:, cascade_options:, params:) } + + describe ".call" do + subject(:result) { update_service.call } + + context "when charge is missing" do + let(:charge) { nil } + let(:cascade_options) { {} } + let(:params) { {} } + + it "fails with charge not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("charge_not_found") + end + end + + context "when charge is present" do + let(:charge) { create(:standard_charge) } + + context "when charge has no applied pricing unit associated" do + let(:cascade_options) { {} } + let(:params) { {} } + + it "return empty result" do + expect(result).to be_success + end + end + + context "when charge has applied pricing unit associated" do + let(:cascade_options) { {cascade: true, equal_applied_pricing_unit_rate:} } + let(:params) { {conversion_rate:} } + + let!(:applied_pricing_unit) do + create(:applied_pricing_unit, pricing_unitable: charge, conversion_rate: 1) + end + + context "when applied pricing unit should be updated" do + let(:equal_applied_pricing_unit_rate) { true } + + context "when params are valid" do + let(:conversion_rate) { 1.5 } + + it "updates applied pricing unit's rate" do + expect { subject } + .to change { applied_pricing_unit.reload.conversion_rate } + .to(conversion_rate) + end + end + + context "when params are invalid" do + let(:conversion_rate) { -1 } + + it "fails with validation error" do + expect(result).to be_failure + expect(result.error.messages).to match(conversion_rate: ["value_is_out_of_range"]) + end + + it "does not update applied pricing unit's rate" do + expect { subject }.not_to change { applied_pricing_unit.reload.conversion_rate } + end + end + end + + context "when applied pricing unit should not be updated" do + let(:equal_applied_pricing_unit_rate) { false } + + context "when params are valid" do + let(:conversion_rate) { 1.5 } + + it "does not update applied pricing unit's rate" do + expect { subject }.not_to change { applied_pricing_unit.reload.conversion_rate } + end + end + + context "when params are invalid" do + let(:conversion_rate) { -1 } + + it "does not update applied pricing unit's rate" do + expect { subject }.not_to change { applied_pricing_unit.reload.conversion_rate } + end + end + end + end + end + end + + describe "#update_conversion_rate?" do + subject { update_service.update_conversion_rate? } + + let(:charge) { create(:applied_pricing_unit).pricing_unitable } + let(:cascade_options) { {cascade:, equal_applied_pricing_unit_rate:} } + + context "when params are present" do + let(:params) { {conversion_rate: rand(0.5..10)} } + + context "when cascade is true" do + let(:cascade) { true } + + context "when cascade equal conversion rate is true" do + let(:equal_applied_pricing_unit_rate) { true } + + it "returns true" do + expect(subject).to be true + end + end + + context "when cascade equal conversion rate is false" do + let(:equal_applied_pricing_unit_rate) { false } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when cascade is false" do + let(:cascade) { false } + + context "when cascade equal conversion rate is true" do + let(:equal_applied_pricing_unit_rate) { true } + + it "returns true" do + expect(subject).to be true + end + end + + context "when cascade equal conversion rate is false" do + let(:equal_applied_pricing_unit_rate) { false } + + it "returns true" do + expect(subject).to be true + end + end + end + end + + context "when params are missing" do + let(:params) { {} } + + context "when cascade is true" do + let(:cascade) { true } + + context "when cascade equal conversion rate is true" do + let(:equal_applied_pricing_unit_rate) { true } + + it "returns false" do + expect(subject).to be false + end + end + + context "when cascade equal conversion rate is false" do + let(:equal_applied_pricing_unit_rate) { false } + + it "returns false" do + expect(subject).to be false + end + end + end + + context "when cascade is false" do + let(:cascade) { false } + + context "when cascade equal conversion rate is true" do + let(:equal_applied_pricing_unit_rate) { true } + + it "returns false" do + expect(subject).to be false + end + end + + context "when cascade equal conversion rate is false" do + let(:equal_applied_pricing_unit_rate) { false } + + it "returns false" do + expect(subject).to be false + end + end + end + end + end +end diff --git a/spec/services/auth/google_service_spec.rb b/spec/services/auth/google_service_spec.rb new file mode 100644 index 0000000..da5b1be --- /dev/null +++ b/spec/services/auth/google_service_spec.rb @@ -0,0 +1,253 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Auth::GoogleService do + subject(:service) { described_class.new } + + before do + ENV["GOOGLE_AUTH_CLIENT_ID"] = "client_id" + ENV["GOOGLE_AUTH_CLIENT_SECRET"] = "client_secret" + end + + describe "#authorize_url" do + it "returns the authorize url" do + request = Rack::Request.new(Rack::MockRequest.env_for("http://example.com")) + result = service.authorize_url(request) + + expect(result).to be_success + expect(result.url).to include("https://accounts.google.com/o/oauth2/auth") + end + + context "when google auth is not set up" do + before do + ENV["GOOGLE_AUTH_CLIENT_ID"] = nil + ENV["GOOGLE_AUTH_CLIENT_SECRET"] = nil + end + + it "returns a service failure" do + request = Rack::Request.new(Rack::MockRequest.env_for("http://example.com")) + result = service.authorize_url(request) + + expect(result).not_to be_success + expect(result.error.code).to eq("google_auth_missing_setup") + end + end + end + + describe "#login" do + let(:authorizer) { instance_double(Google::Auth::UserAuthorizer) } + let(:oidc_verifier) { instance_double(Google::Auth::IDTokens) } + let(:authorizer_response) { instance_double(Google::Auth::UserRefreshCredentials, id_token: "id_token") } + let(:oidc_response) do + {"email" => "foo@bar.com"} + end + + before do + allow(Google::Auth::UserAuthorizer).to receive(:new).and_return(authorizer) + allow(authorizer).to receive(:get_credentials_from_code).and_return(authorizer_response) + allow(Google::Auth::IDTokens).to receive(:verify_oidc).and_return(oidc_response) + end + + context "when user exists" do + before do + user = create(:user, email: "foo@bar.com", password: "foobar") + create(:membership, :active, user:) + allow(UserDevices::RegisterService).to receive(:call!) + end + + it "registers the user device" do + result = service.login("code") + + expect(UserDevices::RegisterService).to have_received(:call!).with(user: result.user) + end + + it "logins the user" do + result = service.login("code") + + expect(result).to be_success + expect(result.user).to be_a(User) + expect(result.token).to be_present + + decoded = Auth::TokenService.decode(token: result.token) + expect(decoded["login_method"]).to eq(Organizations::AuthenticationMethods::GOOGLE_OAUTH) + end + end + + context "when user does not exist" do + it "returns a validation failure" do + result = service.login("code") + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("user_does_not_exist") + end + end + + context "when login method is not allowed" do + let(:user) { create(:user, email: "foo@bar.com", password: "foobar") } + let(:membership) { create(:membership, :active, user:) } + + before do + membership.organization.disable_google_oauth_authentication! + end + + it "returns a validation failure" do + result = service.login("code") + + expect(result).not_to be_success + expect(result.error.messages).to match(google_oauth: ["login_method_not_authorized"]) + end + end + + context "when user does not have active memberships" do + before do + create(:user, email: "foo@bar.com") + end + + it "returns a validation failure" do + result = service.login("code") + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("user_does_not_exist") + end + end + + context "when google auth is not set up" do + before do + ENV["GOOGLE_AUTH_CLIENT_ID"] = nil + ENV["GOOGLE_AUTH_CLIENT_SECRET"] = nil + end + + it "returns a service failure" do + result = service.login("code") + + expect(result).not_to be_success + expect(result.error.code).to eq("google_auth_missing_setup") + end + end + end + + describe "#register_user" do + let(:authorizer) { instance_double(Google::Auth::UserAuthorizer) } + let(:oidc_verifier) { instance_double(Google::Auth::IDTokens) } + let(:authorizer_response) { instance_double(Google::Auth::UserRefreshCredentials, id_token: "id_token") } + let(:oidc_response) do + {"email" => "foo@bar.com"} + end + + before do + create(:role, :admin) + allow(Google::Auth::UserAuthorizer).to receive(:new).and_return(authorizer) + allow(authorizer).to receive(:get_credentials_from_code).and_return(authorizer_response) + allow(Google::Auth::IDTokens).to receive(:verify_oidc).and_return(oidc_response) + allow(UserDevices::RegisterService).to receive(:call!) + end + + it "registers the user device" do + result = service.register_user("code", "Foobar") + + expect(UserDevices::RegisterService).to have_received(:call!).with(user: result.user, skip_log: true) + end + + it "register the user" do + result = service.register_user("code", "Foobar") + + expect(result).to be_success + expect(result.user).to be_a(User) + expect(result.token).to be_present + + decoded = Auth::TokenService.decode(token: result.token) + expect(decoded["login_method"]).to eq(Organizations::AuthenticationMethods::GOOGLE_OAUTH) + end + + context "when user already exists" do + before { create(:user, email: "foo@bar.com") } + + it "returns a validation failure" do + result = service.register_user("code", "FooBar") + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("user_already_exists") + end + end + + context "when google auth is not set up" do + before do + ENV["GOOGLE_AUTH_CLIENT_ID"] = nil + ENV["GOOGLE_AUTH_CLIENT_SECRET"] = nil + end + + it "returns a service failure" do + result = service.register_user("code", "FooBar") + + expect(result).not_to be_success + expect(result.error.code).to eq("google_auth_missing_setup") + end + end + end + + describe "#accept_invite" do + let(:invite) { create(:invite) } + let(:authorizer) { instance_double(Google::Auth::UserAuthorizer) } + let(:oidc_verifier) { instance_double(Google::Auth::IDTokens) } + let(:authorizer_response) { instance_double(Google::Auth::UserRefreshCredentials, id_token: "id_token") } + let(:oidc_response) do + {"email" => invite.email} + end + + before do + invite + allow(Google::Auth::UserAuthorizer).to receive(:new).and_return(authorizer) + allow(authorizer).to receive(:get_credentials_from_code).and_return(authorizer_response) + allow(Google::Auth::IDTokens).to receive(:verify_oidc).and_return(oidc_response) + end + + it "accepts the invite" do + result = service.accept_invite("code", invite.token) + + expect(result).to be_success + expect(result.user).to be_a(User) + expect(result.user.email).to eq(invite.email) + expect(result.token).to be_present + + decoded = Auth::TokenService.decode(token: result.token) + expect(decoded["login_method"]).to eq(Organizations::AuthenticationMethods::GOOGLE_OAUTH) + end + + context "when invite does not exists" do + it "returns a not found failure" do + result = service.accept_invite("code", "not_a_valid_token") + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invite_not_found") + end + end + + context "when invite email is different from google email" do + let(:oidc_response) do + {"email" => "foo@bar.com"} + end + + it "returns a validation failure" do + result = service.accept_invite("code", invite.token) + + expect(result).not_to be_success + expect(result.error.messages[:base]).to include("invite_email_mistmatch") + end + end + + context "when google auth is not set up" do + before do + ENV["GOOGLE_AUTH_CLIENT_ID"] = nil + ENV["GOOGLE_AUTH_CLIENT_SECRET"] = nil + end + + it "returns a service failure" do + result = service.accept_invite("code", "FooBar") + + expect(result).not_to be_success + expect(result.error.code).to eq("google_auth_missing_setup") + end + end + end +end diff --git a/spec/services/auth/okta/accept_invite_service_spec.rb b/spec/services/auth/okta/accept_invite_service_spec.rb new file mode 100644 index 0000000..32702db --- /dev/null +++ b/spec/services/auth/okta/accept_invite_service_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Auth::Okta::AcceptInviteService, :premium, cache: :memory do + subject(:service) { described_class.new(invite_token:, code:, state:) } + + let(:organization) { create(:organization, premium_integrations: ["okta"]) } + let(:okta_integration) { create(:okta_integration, domain: "bar.com", organization_name: "foo", organization:) } + let(:invite) { create(:invite, email: "foo@bar.com", organization:) } + let(:invite_token) { invite.token } + let(:lago_http_client) { instance_double(LagoHttpClient::Client) } + let(:okta_token_response) { OpenStruct.new(body: {access_token: "access_token"}) } + let(:okta_userinfo_response) { OpenStruct.new({email: "foo@bar.com"}) } + let(:code) { "code" } + let(:state) { SecureRandom.uuid } + + before do + okta_integration + invite_token + + organization.enable_okta_authentication! + + Rails.cache.write(state, "foo@bar.com") if state.present? + + allow(LagoHttpClient::Client).to receive(:new).and_return(lago_http_client) + allow(lago_http_client).to receive(:post_url_encoded).and_return(okta_token_response) + allow(lago_http_client).to receive(:get).and_return(okta_userinfo_response) + end + + describe "#call" do + it "creates user, membership, authenticate user and mark invite as accepted" do + result = service.call + + expect(result).to be_success + expect(result.user.email).to eq("foo@bar.com") + expect(result.token).to be_present + expect(invite.reload).to be_accepted + + decoded = Auth::TokenService.decode(token: result.token) + expect(decoded["login_method"]).to eq(Organizations::AuthenticationMethods::OKTA) + end + + context "when code is not provided" do + let(:code) { nil } + + it "returns an error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({base: ["code_not_found"]}) + end + end + + context "when state is not provided" do + let(:state) { nil } + + it "returns an error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({base: ["state_not_found"]}) + end + end + + context "when state is not found" do + before do + Rails.cache.clear + end + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("state_not_found") + end + end + + context "when domain is not configured with an integration" do + let(:okta_integration) { nil } + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("domain_not_configured") + end + end + + context "when pending invite does not exists" do + let(:invite) { create(:invite, email: "foo@bar.com", status: :accepted) } + + it "returns a failure result" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("invite_not_found") + end + end + + context "when okta userinfo email is different from the state one" do + let(:okta_userinfo_response) { OpenStruct.new({email: "foo@test.com"}) } + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("okta_userinfo_error") + end + end + end +end diff --git a/spec/services/auth/okta/authorize_service_spec.rb b/spec/services/auth/okta/authorize_service_spec.rb new file mode 100644 index 0000000..b17a2fb --- /dev/null +++ b/spec/services/auth/okta/authorize_service_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Auth::Okta::AuthorizeService do + subject(:service) { described_class.new(email:) } + + let(:organization) { create(:organization) } + let(:okta_integration) { create(:okta_integration) } + let(:email) { "foo@#{okta_integration.domain}" } + + before { okta_integration } + + describe "#authorize" do + it "returns an authorize url" do + result = service.call + + expect(result).to be_success + expect(result.url).to include(okta_integration.organization_name.downcase) + expect(result.url).to include(okta_integration.client_id) + end + + context "when domain is not configured with an integration" do + let(:email) { "foo@bar.com" } + + it "returns a failure result" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("domain_not_configured") + end + end + + context "with invite token" do + subject(:service) { described_class.new(email:, invite_token: invite.token) } + + let(:invite) { create(:invite, email:) } + + it "returns an authorize url" do + result = service.call + + expect(result).to be_success + expect(result.url).to include(okta_integration.organization_name.downcase) + expect(result.url).to include(okta_integration.client_id) + end + + context "when invite email is different from the email" do + let(:invite) { create(:invite, email: "foo@b.com") } + + it "returns a failure result" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("invite_email_mismatch") + end + end + + context "when pending invite does not exists" do + let(:invite) { create(:invite, email:, status: :accepted) } + + it "returns a failure result" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("invite_not_found") + end + end + end + end +end diff --git a/spec/services/auth/okta/login_service_spec.rb b/spec/services/auth/okta/login_service_spec.rb new file mode 100644 index 0000000..71ee5dd --- /dev/null +++ b/spec/services/auth/okta/login_service_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Auth::Okta::LoginService, cache: :memory do + let(:service) { described_class.new(code:, state:) } + let(:okta_integration) { create(:okta_integration, domain: "bar.com", organization_name: "foo") } + let(:lago_http_client) { instance_double(LagoHttpClient::Client) } + let(:okta_token_response) { OpenStruct.new(body: {access_token: "access_token"}) } + let(:okta_userinfo_response) { OpenStruct.new({email: "foo@bar.com"}) } + let(:state) { SecureRandom.uuid } + let(:code) { "code" } + + before do + okta_integration + + Rails.cache.write(state, "foo@bar.com") if state.present? + + if okta_integration + okta_integration.organization.premium_integrations << "okta" + okta_integration.organization.save! + okta_integration.organization.enable_okta_authentication! + end + + allow(LagoHttpClient::Client).to receive(:new).and_return(lago_http_client) + allow(lago_http_client).to receive(:post_url_encoded).and_return(okta_token_response) + allow(lago_http_client).to receive(:get).and_return(okta_userinfo_response) + end + + describe "#call", :premium do + before { allow(UserDevices::RegisterService).to receive(:call!) } + + it "registers the user device" do + result = service.call + + expect(UserDevices::RegisterService).to have_received(:call!).with(user: result.user) + end + + it "creates user, membership and authenticate user" do + result = service.call + + expect(result).to be_success + expect(result.user.email).to eq("foo@bar.com") + expect(result.token).to be_present + + decoded = Auth::TokenService.decode(token: result.token) + expect(decoded["login_method"]).to eq(Organizations::AuthenticationMethods::OKTA) + end + + context "when code is not provided" do + let(:code) { nil } + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({base: ["code_not_found"]}) + end + end + + context "when state is not provided" do + let(:state) { nil } + + it "returns error" do + result = service.call + expect(result).not_to be_success + expect(result.error.messages).to eq({base: ["state_not_found"]}) + end + end + + context "when the login method is not allowed" do + let(:user) { create(:user, email: "foo@bar.com") } + let(:membership) { create(:membership, user:, organization: okta_integration.organization) } + + before { okta_integration.organization.disable_okta_authentication! } + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages).to match(okta: ["login_method_not_authorized"]) + end + end + + context "when domain is not configured with an integration" do + let(:okta_integration) { nil } + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("domain_not_configured") + end + end + + context "when okta userinfo email is different from the state one" do + let(:okta_userinfo_response) { OpenStruct.new({email: "foo@test.com"}) } + + it "returns error" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages.values.flatten).to include("okta_userinfo_error") + end + end + + context "when user already exists" do + let(:user) { create(:user, email: "foo@bar.com") } + + before { user } + + it "does not create a new user" do + expect { service.call }.not_to change(User, :count) + end + end + + context "when membership already exists" do + let(:user) { create(:user, email: "foo@bar.com") } + let(:membership) { create(:membership, user:, organization: okta_integration.organization) } + + before { membership } + + it "does not create a new membership" do + expect { service.call }.not_to change(Membership, :count) + end + end + end +end diff --git a/spec/services/auth/superset_service_spec.rb b/spec/services/auth/superset_service_spec.rb new file mode 100644 index 0000000..18ce80d --- /dev/null +++ b/spec/services/auth/superset_service_spec.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Auth::SupersetService do + subject(:service) { described_class.new(organization:, user:) } + + let(:organization) { create(:organization, name: "Test Org") } + let(:user) { nil } + + let(:superset_url) { "http://localhost:8089" } + let(:superset_username) { "admin" } + let(:superset_password) { "admin" } + + before do + stub_const("ENV", ENV.to_h.merge( + "SUPERSET_URL" => superset_url, + "SUPERSET_USERNAME" => superset_username, + "SUPERSET_PASSWORD" => superset_password + )) + end + + describe ".call" do + let(:access_token) { "access_token_123" } + let(:csrf_token) { "csrf_token_456" } + let(:guest_token_1) { "guest_token_dashboard_1" } + let(:guest_token_2) { "guest_token_dashboard_2" } + let(:embedded_uuid_1) { "embedded-uuid-1" } + let(:embedded_uuid_2) { "embedded-uuid-2" } + + let(:auth_response) { {access_token:}.to_json } + let(:csrf_response) { {result: csrf_token}.to_json } + let(:dashboards_response) do + { + result: [ + {id: "1", dashboard_title: "Dashboard 1"}, + {id: "2", dashboard_title: "Dashboard 2"} + ] + }.to_json + end + + let(:embedded_exists_response_1) { {result: {uuid: embedded_uuid_1}}.to_json } + let(:embedded_create_response_2) { {result: {uuid: embedded_uuid_2}}.to_json } + let(:guest_token_response_1) { {token: guest_token_1}.to_json } + let(:guest_token_response_2) { {token: guest_token_2}.to_json } + + context "when authentication and dashboard processing is successful" do + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .with( + body: {username: superset_username, password: superset_password, provider: "db"}.to_json + ) + .to_return( + status: 200, + body: auth_response, + headers: {"Content-Type" => "application/json"} + ) + + stub_request(:get, "#{superset_url}/api/v1/security/csrf_token/") + .to_return(status: 200, body: csrf_response) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/") + .to_return(status: 200, body: dashboards_response) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/1/embedded") + .to_return(status: 200, body: embedded_exists_response_1) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/2/embedded") + .to_return(status: 404, body: {}.to_json) + + stub_request(:post, "#{superset_url}/api/v1/dashboard/2/embedded") + .with(body: {allowed_domains: []}.to_json) + .to_return(status: 200, body: embedded_create_response_2) + + stub_request(:post, "#{superset_url}/api/v1/security/guest_token/") + .with(body: hash_including(resources: [{id: "1", type: "dashboard"}])) + .to_return(status: 200, body: guest_token_response_1) + + stub_request(:post, "#{superset_url}/api/v1/security/guest_token/") + .with(body: hash_including(resources: [{id: "2", type: "dashboard"}])) + .to_return(status: 200, body: guest_token_response_2) + end + + it "returns success with all dashboards" do + result = service.call + + expect(result).to be_success + expect(result.dashboards).to be_an(Array) + expect(result.dashboards.size).to eq(2) + + dashboard_1 = result.dashboards.find { |d| d[:id] == "1" } + expect(dashboard_1[:dashboard_title]).to eq("Dashboard 1") + expect(dashboard_1[:embedded_id]).to eq(embedded_uuid_1) + expect(dashboard_1[:guest_token]).to eq(guest_token_1) + + dashboard_2 = result.dashboards.find { |d| d[:id] == "2" } + expect(dashboard_2[:dashboard_title]).to eq("Dashboard 2") + expect(dashboard_2[:embedded_id]).to eq(embedded_uuid_2) + expect(dashboard_2[:guest_token]).to eq(guest_token_2) + end + end + + context "when custom user info is provided" do + let(:user) do + { + first_name: "John", + last_name: "Doe", + username: "john.doe" + } + end + + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .to_return(status: 200, body: auth_response, headers: {"Content-Type" => "application/json"}) + + stub_request(:get, "#{superset_url}/api/v1/security/csrf_token/") + .to_return(status: 200, body: csrf_response) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/") + .to_return(status: 200, body: {result: [{id: "1", dashboard_title: "Test"}]}.to_json) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/1/embedded") + .to_return(status: 200, body: {result: {uuid: embedded_uuid_1}}.to_json) + + stub_request(:post, "#{superset_url}/api/v1/security/guest_token/") + .with( + body: hash_including( + user: { + first_name: "John", + last_name: "Doe", + username: "john.doe" + } + ) + ) + .to_return(status: 200, body: {token: guest_token_1}.to_json) + end + + it "uses the provided user info" do + result = service.call + + expect(result).to be_success + expect(result.dashboards.size).to eq(1) + end + end + + context "when authentication fails" do + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .to_return(status: 401, body: "Invalid credentials") + end + + it "returns a service failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("superset_auth_failed") + expect(result.error.error_message).to include("Failed to authenticate with Superset") + end + end + + context "when authentication succeeds but no access token is returned" do + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .to_return(status: 200, body: {}.to_json, headers: {"Content-Type" => "application/json"}) + end + + it "returns a service failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("superset_auth_failed") + expect(result.error.error_message).to include("No access token received from Superset") + end + end + + context "when getting CSRF token fails" do + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .to_return(status: 200, body: auth_response, headers: {"Content-Type" => "application/json"}) + + stub_request(:get, "#{superset_url}/api/v1/security/csrf_token/") + .to_return(status: 500, body: {message: "Internal error"}.to_json) + end + + it "returns a service failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("superset_csrf_failed") + expect(result.error.error_message).to include("Failed to get CSRF token") + end + end + + context "when fetching dashboards fails" do + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .to_return(status: 200, body: auth_response, headers: {"Content-Type" => "application/json"}) + + stub_request(:get, "#{superset_url}/api/v1/security/csrf_token/") + .to_return(status: 200, body: csrf_response) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/") + .to_return(status: 500, body: {message: "Internal error"}.to_json) + end + + it "returns a service failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("superset_fetch_dashboards_failed") + expect(result.error.error_message).to include("Failed to fetch dashboards") + end + end + + context "when creating embedded config fails" do + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .to_return(status: 200, body: auth_response, headers: {"Content-Type" => "application/json"}) + + stub_request(:get, "#{superset_url}/api/v1/security/csrf_token/") + .to_return(status: 200, body: csrf_response) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/") + .to_return(status: 200, body: {result: [{id: "1", dashboard_title: "Test"}]}.to_json) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/1/embedded") + .to_return(status: 404, body: {}.to_json) + + stub_request(:post, "#{superset_url}/api/v1/dashboard/1/embedded") + .to_return(status: 500, body: {message: "Failed to create"}.to_json) + end + + it "returns success with empty dashboards array" do + result = service.call + + expect(result).to be_success + expect(result.dashboards).to be_empty + end + end + + context "when no dashboards exist" do + before do + stub_request(:post, "#{superset_url}/api/v1/security/login") + .to_return(status: 200, body: auth_response, headers: {"Content-Type" => "application/json"}) + + stub_request(:get, "#{superset_url}/api/v1/security/csrf_token/") + .to_return(status: 200, body: csrf_response) + + stub_request(:get, "#{superset_url}/api/v1/dashboard/") + .to_return(status: 200, body: {result: []}.to_json) + end + + it "returns success with empty dashboards array" do + result = service.call + + expect(result).to be_success + expect(result.dashboards).to eq([]) + end + end + + context "when environment variables are missing" do + before do + stub_const("ENV", ENV.to_h.except("SUPERSET_URL")) + end + + it "returns a service failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("superset_missing_configuration") + expect(result.error.error_message).to include("Superset configuration is incomplete") + expect(result.error.error_message).to include("SUPERSET_URL") + end + end + end +end diff --git a/spec/services/auth/token_service_spec.rb b/spec/services/auth/token_service_spec.rb new file mode 100644 index 0000000..13273a1 --- /dev/null +++ b/spec/services/auth/token_service_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Auth::TokenService do + let(:user) { create(:user) } + let(:user_id) { user.id } + let(:extra) { {login_method: Organizations::AuthenticationMethods::EMAIL_PASSWORD} } + let(:token) { described_class.encode(user:, **extra) } + + before { token } + + describe "self.encode" do + subject { described_class.encode(**params) } + + context "with an user instance" do + let(:params) { {user:} } + + it { is_expected.to be_present } + + it "produces the correct token for the user" do + token = subject + expect(described_class.decode(token:)["sub"]).to eq(user.id) + end + end + + context "with user_id" do + let(:params) { {user_id:} } + + it { is_expected.to be_present } + + it "produces the correct token for the user" do + token = subject + expect(described_class.decode(token:)["sub"]).to eq(user_id) + end + end + + context "with extra auth info" do + let(:params) { {user:, **extra} } + + it "produces the token with extra auth info" do + token = subject + expect(described_class.decode(token:)["login_method"]).to eq(Organizations::AuthenticationMethods::EMAIL_PASSWORD) + end + end + + context "without user info" do + let(:params) { {user: nil, user_id: nil} } + + it { is_expected.to be_nil } + end + end + + describe "self.decode" do + subject { described_class.decode(token:) } + + context "with token" do + it { is_expected.to include("sub" => user.id, "login_method" => Organizations::AuthenticationMethods::EMAIL_PASSWORD) } + end + + context "without token" do + let(:token) { nil } + + it { is_expected.to be_nil } + end + end + + describe "self.renew" do + subject { described_class.renew(token:) } + + context "with token" do + it { is_expected.to be_present } + + it "creates a new token" do + travel_to(Time.current + 10.minutes) do + expect(subject).not_to eq(token) + end + end + + it "renews with the same info" do + travel_to(Time.current + 10.minutes) do + old = described_class.decode(token:) + renew = described_class.decode(token: subject) + + expect(renew["sub"]).to eq(old["sub"]) + expect(renew["login_method"]).to eq(old["login_method"]) + expect(renew["exp"].to_i).to be > old["exp"].to_i + end + end + end + + context "without token" do + let(:token) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/services/base_result_spec.rb b/spec/services/base_result_spec.rb new file mode 100644 index 0000000..17b4fca --- /dev/null +++ b/spec/services/base_result_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BaseResult do + subject(:result) { described_class.new } + + it_behaves_like "a result object" + + describe "#[]" do + let(:result_class) { described_class[:property] } + + it { expect(result_class.new).to be_a(described_class) } + + it "defines the attributes" do + expect(result_class.new).to respond_to(:property) + expect(result_class.new).to respond_to(:property=) + end + + context "with multiple properties" do + let(:result_class) { described_class[:property, :another_property] } + + it "defines the attributes" do + expect(result_class.new).to respond_to(:property) + expect(result_class.new).to respond_to(:property=) + expect(result_class.new).to respond_to(:another_property) + expect(result_class.new).to respond_to(:another_property=) + end + end + end + + describe ".==" do + subject(:result) { result_class.new.tap { it.property = "value" } } + + let(:result_class) { described_class[:property] } + let(:other_result) { result_class.new.tap { it.property = "value" } } + + it { expect(result).to eq(other_result) } + + context "when the properties are different" do + let(:other_result) { result_class.new.tap { it.property = "different_value" } } + + it { expect(result).not_to eq(other_result) } + end + + context "when the properties are nil" do + let(:other_result) { result_class.new } + + it { expect(result).not_to eq(other_result) } + end + + context "when one result is a failure" do + let(:other_result) { result_class.new.not_found_failure!(resource: "property") } + + it { expect(result).not_to eq(other_result) } + end + + context "when results are the same failed result" do + it "returns true" do + expect(result.not_found_failure!(resource: "property")) + .to eq(other_result.not_found_failure!(resource: "property")) + end + end + + context "when one result is an other failure" do + let(:other_result) { result_class.new.not_found_failure!(resource: "property") } + + it { expect(result.not_found_failure!(resource: "values")).not_to eq(other_result) } + end + + context "when one result is a different result class" do + let(:other_result_class) { described_class[:another_property] } + let(:other_result) { other_result_class.new.tap { it.another_property = "value" } } + + it { expect(result).not_to eq(other_result) } + end + end +end diff --git a/spec/services/base_service_spec.rb b/spec/services/base_service_spec.rb new file mode 100644 index 0000000..b57bb85 --- /dev/null +++ b/spec/services/base_service_spec.rb @@ -0,0 +1,179 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ::BaseService do + subject(:service) { described_class.new } + + it { is_expected.to be_a(AfterCommitEverywhere) } + it { is_expected.to respond_to(:call) } + it { is_expected.to respond_to(:call_async) } + it { is_expected.to respond_to(:call_with_middlewares) } + + it { is_expected.to use_middleware(Middlewares::LogTracerMiddleware) } + + context "with current_user" do + it "assigns the current_user to the result" do + result = described_class.new.send :result + + expect(result).to be_a(::BaseService::Result) + end + + it "does not assign the current_user to the result if it isn't a User" do + result = described_class.new([]).send :result + + expect(result.user).to be_nil + end + end + + context "with activity_loggable" do + let(:service_class) do + action = activity_loggable_action + after_commit = activity_loggable_after_commit + Class.new(described_class) do + const_set(:Result, BaseResult[:subscription]) + + activity_loggable(action: action, record: -> { subscription }, after_commit:) + + def initialize(subscription:) + @subscription = subscription + super() + end + + def call + subscription.update!(name: "Updated Subscription") + + result.subscription = subscription + result + end + + private + + attr_reader :subscription + end + end + + let(:subscription) { create(:subscription, name: "My Subscription") } + let(:activity_loggable_after_commit) { false } + + def test_service_with_activity_loggable(after_commit:, action_match_updated: false) + expect(service_class).to use_middleware(Middlewares::ActivityLogMiddleware) + + allow(Utils::ActivityLog).to receive(:produce).and_wrap_original do |m, *args, **kwargs, &block| + if action_match_updated + # For "updated" actions, `Utils::ActivityLog#produce` will execute `BaseService#call` method so subscription is not yet updated here + expect(subscription.name).to eq("My Subscription") + else + # For other actions, `Utils::ActivityLog#produce` is executed after `BaseService#call` method so subscription is already updated here + expect(subscription.name).to eq("Updated Subscription") + end + + result = m.call(*args, **kwargs, &block) + + # Test that `Utils::ActivityLog#produce` returns the result of the service call + expect(result).to be_success + expect(result.subscription).to eq(subscription) + expect(result.subscription.name).to eq("Updated Subscription") + + result + end + + result = service_class.call(subscription:) + + expect(Utils::ActivityLog).to have_received(:produce).with(subscription, activity_loggable_action, after_commit:) + + expect(result).to be_success + expect(result.subscription).to eq(subscription) + expect(result.subscription.name).to eq("Updated Subscription") + end + + context "when action matches /updated/" do + let(:activity_loggable_action) { "subscription.updated" } + + context "when after_commit is true" do + let(:activity_loggable_after_commit) { true } + + it "produces the activity log after commit" do + test_service_with_activity_loggable(after_commit: true, action_match_updated: true) + end + end + + context "when after_commit is false" do + let(:activity_loggable_after_commit) { false } + + it "produces the activity log before commit" do + test_service_with_activity_loggable(after_commit: false, action_match_updated: true) + end + end + end + + context "when action does not match /updated/" do + let(:activity_loggable_action) { "subscription.created" } + + context "when after_commit is true" do + let(:activity_loggable_after_commit) { true } + + it "produces the activity log" do + test_service_with_activity_loggable(after_commit: true, action_match_updated: false) + end + end + + context "when after_commit is false" do + let(:activity_loggable_after_commit) { false } + + it "produces the activity log" do + test_service_with_activity_loggable(after_commit: false, action_match_updated: false) + end + end + end + end + + describe ".use" do + let(:middleware) { Class.new(Middlewares::BaseMiddleware) } + let(:service) { Class.new(BaseService) } + + it "adds a middleware to the service" do + service.use(middleware) + + expect(service.middlewares.map(&:first)).to include(middleware) + end + + context "when adding multiple time the same middleware" do + before { service.use(middleware) } + + it "raises an error" do + expect { service.use(middleware) }.to raise_error(Middlewares::AlreadyAddedError) + end + + context "when on conflict append" do + it "adds the middleware a second time" do + service.use(middleware, on_conflict: :append) + + expect(service.middlewares.map(&:first)).to include(middleware, middleware) + end + end + + context "when on conflict replace" do + it "adds the middleware a second time" do + service.use(middleware, count: 2, on_conflict: :replace) + + expect(service.middlewares.map(&:first)).to include(middleware) + + found_middleware = service.middlewares.find { |m| m.first == middleware } + expect(found_middleware.last[:count]).to eq(2) + end + end + + context "when on conflict ignore" do + it "adds the middleware a second time" do + service.use(middleware, count: 2, on_conflict: :ignore) + + expect(service.middlewares.map(&:first)).to include(middleware) + + found_middleware = service.middlewares.find { |m| m.first == middleware } + expect(found_middleware.last).to eq({}) + end + end + end + end +end diff --git a/spec/services/billable_metric_filters/create_or_update_batch_service_spec.rb b/spec/services/billable_metric_filters/create_or_update_batch_service_spec.rb new file mode 100644 index 0000000..88eb879 --- /dev/null +++ b/spec/services/billable_metric_filters/create_or_update_batch_service_spec.rb @@ -0,0 +1,453 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetricFilters::CreateOrUpdateBatchService do + subject(:service) { described_class.call(billable_metric:, filters_params:) } + + let(:billable_metric) { create(:billable_metric) } + + context "when filter params is empty" do + let(:filters_params) { {} } + + it "does not create any filters" do + expect { service }.not_to change(BillableMetricFilter, :count) + end + + it "does not enqueue the refresh draft invoices job" do + expect { service }.not_to have_enqueued_job(BillableMetricFilters::RefreshDraftInvoicesJob) + end + + context "when there are existing filters" do + let(:filter) { create(:billable_metric_filter, billable_metric:, key: "region") } + + let(:charge) { create(:standard_charge, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + let(:filter_value) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: [filter.values.first] + ) + end + + before do + create(:billable_metric_filter, billable_metric:, key: "cloud") + filter_value + end + + it "discards all filters and the related values" do + expect { service }.to change { BillableMetricFilter.with_discarded.discarded.count }.from(0).to(2) + .and change { ChargeFilterValue.with_discarded.discarded.count }.from(0).to(1) + .and change { ChargeFilter.with_discarded.discarded.count }.from(0).to(1) + end + + context "when a charge_filter has filter_values from multiple billable_metric_filters" do + let(:other_filter) { create(:billable_metric_filter, billable_metric:, key: "cloud") } + let(:other_filter_value) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: other_filter, + values: [other_filter.values.first] + ) + end + + before { other_filter_value } + + it "discards all filters, all filter_values, and the shared charge_filter" do + expect { service }.to change { BillableMetricFilter.with_discarded.discarded.count }.from(0).to(3) + .and change { ChargeFilterValue.with_discarded.discarded.count }.from(0).to(2) + .and change { ChargeFilter.with_discarded.discarded.count }.from(0).to(1) + end + end + end + end + + context "with new filters" do + let(:filters_params) do + [ + { + key: "region", + values: %w[Europe US] + }, + { + key: "cloud", + values: %w[aws gcp] + } + ] + end + + it "enqueues the refresh draft invoices job" do + expect { service }.to have_enqueued_job(BillableMetricFilters::RefreshDraftInvoicesJob) + .with(billable_metric.id) + end + + it "creates the filters" do + expect { service }.to change(BillableMetricFilter, :count).by(2) + + filter1 = billable_metric.filters.find_by(key: "region") + expect(filter1).to have_attributes( + key: "region", + values: %w[Europe US] + ) + + filter2 = billable_metric.filters.find_by(key: "cloud") + expect(filter2).to have_attributes( + key: "cloud", + values: %w[aws gcp] + ) + end + + context "when filter param has duplicate values" do + let(:filters_params) do + [{key: "region", values: %w[US US Europe Europe]}] + end + + it "stores deduplicated values" do + service + + expect(billable_metric.filters.find_by(key: "region").values).to eq(%w[US Europe]) + end + end + + context "when any of multiple filter params has blank values" do + let(:filters_params) do + [ + {key: "region", values: %w[US]}, + {key: "cloud", values: []} + ] + end + + it "returns a validation failure" do + result = service + + expect(result).not_to be_success + expect(result.error.messages[:values]).to eq(["value_is_mandatory"]) + end + + it "does not persist the valid filter" do + expect { service }.not_to change(BillableMetricFilter, :count) + end + + it "does not enqueue the refresh draft invoices job" do + expect { service }.not_to have_enqueued_job(BillableMetricFilters::RefreshDraftInvoicesJob) + end + end + end + + context "with existing filters" do + let(:filters_params) do + [ + { + key: "region", + values: %w[Europe US Asia Africa] + } + ] + end + + let(:filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[Europe US Asia]) } + + before { filter } + + it "updates the filters" do + expect { service }.not_to change(BillableMetricFilter, :count) + + expect(filter.reload).to have_attributes( + key: "region", + values: %w[Europe US Asia Africa] + ) + end + + context "when filter_param has the same values as the existing filter" do + let(:filters_params) do + [{key: "region", values: %w[Europe US Asia]}] + end + + it "leaves the filter values unchanged" do + expect { service }.not_to change(BillableMetricFilter, :count) + expect(filter.reload.values).to eq(%w[Europe US Asia]) + end + end + + context "when a value is removed" do + let(:filters_params) do + [ + { + key: "region", + values: %w[Europe] + } + ] + end + + let!(:filter_value) do + create( + :charge_filter_value, + billable_metric_filter: filter, + values: ["US"] + ) + end + + it "discards the removed value" do + expect { service }.not_to change(BillableMetricFilter, :count) + + expect(filter.reload).to have_attributes( + key: "region", + values: %w[Europe] + ) + + expect(filter_value.reload).to be_discarded + end + + context "when a filter_value has both kept and removed values" do + let!(:partial_filter_value) do + create( + :charge_filter_value, + billable_metric_filter: filter, + values: %w[US Europe] + ) + end + + it "trims the partial filter_value and discards the fully-removed one in the same batch" do + service + + expect(partial_filter_value.reload).not_to be_discarded + expect(partial_filter_value.values).to eq(%w[Europe]) + + expect(filter_value.reload).to be_discarded + end + end + + context "when the discarded filter_value's charge_filter has a kept filter_value from another filter" do + let(:other_filter) { create(:billable_metric_filter, billable_metric:, key: "cloud", values: %w[aws gcp]) } + let(:charge) { create(:standard_charge, billable_metric:) } + let(:shared_charge_filter) { create(:charge_filter, charge:) } + + let!(:shared_cfv_region) do + create( + :charge_filter_value, + charge_filter: shared_charge_filter, + billable_metric_filter: filter, + values: %w[US] + ) + end + + let!(:shared_cfv_cloud) do + create( + :charge_filter_value, + charge_filter: shared_charge_filter, + billable_metric_filter: other_filter, + values: %w[aws] + ) + end + + let(:filters_params) do + [ + {key: "region", values: %w[Europe]}, + {key: "cloud", values: %w[aws gcp]} + ] + end + + it "discards the filter_value but keeps the shared charge_filter" do + service + + expect(shared_cfv_region.reload).to be_discarded + expect(shared_cfv_cloud.reload).not_to be_discarded + expect(shared_charge_filter.reload).not_to be_discarded + end + end + + context "when removing all values" do + let(:filters_params) do + [] + end + + let(:charge) { create(:standard_charge, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + + before do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: ["US"] + ) + + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: ["Europe"] + ) + end + + it "discards the removed value" do + expect { service }.to change(BillableMetricFilter, :count).by(-1) + + expect(filter.reload).to be_discarded + expect(filter.filter_values.with_discarded).to all(be_discarded) + end + end + end + + context "when a filter is removed" do + let(:filters_params) do + [ + { + key: "country", + values: %w[USA France Germany] + } + ] + end + + it "discards the removed filter" do + expect { service }.not_to change(BillableMetricFilter, :count) + + expect(filter.reload).to be_discarded + end + + context "when the removed filter has filter_values and a charge_filter" do + let(:charge) { create(:standard_charge, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + let!(:filter_value) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: ["US"] + ) + end + + it "discards the filter, its filter_values, and the emptied charge_filter" do + service + + expect(filter.reload).to be_discarded + expect(filter_value.reload).to be_discarded + expect(charge_filter.reload).to be_discarded + end + end + end + + context "with new, existing, and removed filters together" do + let(:filters_params) do + [ + {key: "region", values: %w[Europe US Asia Africa]}, + {key: "cloud", values: %w[aws gcp]} + ] + end + + let!(:filter_to_remove) do + create(:billable_metric_filter, billable_metric:, key: "country", values: %w[Australia]) + end + + it "creates new, updates existing, and discards missing filters" do + service + + expect(filter.reload).to have_attributes(values: %w[Europe US Asia Africa]) + expect(filter_to_remove.reload).to be_discarded + expect(billable_metric.filters.find_by(key: "cloud")).to have_attributes(values: %w[aws gcp]) + end + end + end + + context "with unrelated records present" do + let(:other_billable_metric) { create(:billable_metric) } + let!(:other_filter) { create(:billable_metric_filter, billable_metric: other_billable_metric, key: "region") } + let(:other_charge) { create(:standard_charge, billable_metric: other_billable_metric) } + let!(:other_charge_filter) { create(:charge_filter, charge: other_charge) } + let!(:other_filter_value) do + create( + :charge_filter_value, + charge_filter: other_charge_filter, + billable_metric_filter: other_filter, + values: [other_filter.values.first] + ) + end + + context "when discarding all filters of the billable_metric" do + let(:filters_params) { {} } + + let(:filter) { create(:billable_metric_filter, billable_metric:) } + let(:charge) { create(:standard_charge, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + let(:filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter: filter, values: [filter.values.first]) + end + + before { filter_value } + + it "discards exactly one of each: filter, filter_value, charge_filter" do + expect { service }.to change(BillableMetricFilter.kept, :count).by(-1) + .and change(ChargeFilterValue.kept, :count).by(-1) + .and change(ChargeFilter.kept, :count).by(-1) + end + + it "leaves the other billable_metric's filter, filter_value and charge_filter untouched" do + service + + expect(other_filter.reload).not_to be_discarded + expect(other_filter_value.reload).not_to be_discarded + expect(other_charge_filter.reload).not_to be_discarded + end + end + + context "when removing values from one filter" do + let(:filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[US Europe]) } + let(:filters_params) { [{key: "region", values: %w[Europe]}] } + + let(:cfv_to_discard) do + create(:charge_filter_value, billable_metric_filter: filter, values: %w[US]) + end + + let!(:cfv_unaffected) do + create(:charge_filter_value, billable_metric_filter: filter, values: %w[Europe]) + end + + let(:unrelated_charge) { create(:standard_charge, billable_metric:) } + let!(:unrelated_charge_filter) { create(:charge_filter, charge: unrelated_charge) } + + let!(:already_discarded_cfv) do + create(:charge_filter_value, billable_metric_filter: filter, values: %w[US]).tap(&:discard!) + end + + before do + filter + cfv_to_discard + end + + it "discards only the filter_value whose values are being removed" do + expect { service }.to change(ChargeFilterValue.kept, :count).by(-1) + end + + it "preserves filter_values whose values aren't being removed" do + service + + expect(cfv_unaffected.reload).not_to be_discarded + expect(cfv_unaffected.values).to eq(%w[Europe]) + end + + it "does not touch charge_filters of unrelated charges in the same billable_metric" do + expect { service }.not_to change { unrelated_charge_filter.reload.discarded? } + end + + it "does not modify deleted_at on already-discarded filter_values" do + original_deleted_at = already_discarded_cfv.deleted_at + service + expect(already_discarded_cfv.reload.deleted_at).to eq(original_deleted_at) + end + + it "does not discard pre-existing emptied charge_filters unrelated to this run" do + expect { service }.not_to change { already_discarded_cfv.charge_filter.reload.discarded? } + end + + it "leaves the other billable_metric's records untouched" do + service + + expect(other_filter.reload).not_to be_discarded + expect(other_filter_value.reload).not_to be_discarded + expect(other_charge_filter.reload).not_to be_discarded + end + end + end +end diff --git a/spec/services/billable_metric_filters/destroy_all_service_spec.rb b/spec/services/billable_metric_filters/destroy_all_service_spec.rb new file mode 100644 index 0000000..742a5a5 --- /dev/null +++ b/spec/services/billable_metric_filters/destroy_all_service_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +RSpec.describe BillableMetricFilters::DestroyAllService do + subject(:destroy_service) { described_class.new(billable_metric) } + + let(:billable_metric) { create(:billable_metric, :deleted) } + let(:plan) { create(:plan, organization: billable_metric.organization) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:filters) { create_list(:billable_metric_filter, 2, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + let(:filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter: filters.first) + end + + before { filter_value } + + describe "#call" do + it "soft deletes all related filters" do + freeze_time do + expect { destroy_service.call }.to change { billable_metric.filters.reload.kept.count }.from(2).to(0) + .and change { filter_value.reload.reload.deleted_at }.from(nil).to(Time.current) + end + end + + context "when the billable metric is not deleted" do + let(:billable_metric) { create(:billable_metric) } + + it "does not delete the filters" do + expect { destroy_service.call }.not_to change { billable_metric.filters.reload.kept.count } + end + end + + context "when the billable metric is nil" do + let(:billable_metric) { nil } + let(:filter_value) { nil } + + it "returns an empty result" do + result = destroy_service.call + expect(result).to be_success + expect(result.billable_metric).to be_nil + end + end + end +end diff --git a/spec/services/billable_metrics/aggregation_factory_spec.rb b/spec/services/billable_metrics/aggregation_factory_spec.rb new file mode 100644 index 0000000..5a1cec5 --- /dev/null +++ b/spec/services/billable_metrics/aggregation_factory_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::AggregationFactory do + subject(:factory) { described_class } + + let(:billable_metric) { create(billable_aggregation, recurring:) } + let(:billable_aggregation) { :billable_metric } + let(:recurring) { false } + + let(:charge) { build(:standard_charge, billable_metric:, pay_in_advance:, prorated:) } + let(:pay_in_advance) { false } + let(:prorated) { false } + + let(:subscription) { create(:subscription, started_at: DateTime.parse("2023-03-15")) } + let(:boundaries) do + { + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day + } + end + + let(:current_usage) { false } + + let(:result) { factory.new_instance(charge:, current_usage:, subscription:, boundaries:) } + + describe "#new_instance" do + context "with count_agg aggregation" do + let(:billable_aggregation) { :billable_metric } + + it { expect(result).to be_a(BillableMetrics::Aggregations::CountService) } + end + + context "with latest_agg aggregation" do + let(:billable_aggregation) { :latest_billable_metric } + + it { expect(result).to be_a(BillableMetrics::Aggregations::LatestService) } + + context "when pay_in_advance" do + let(:pay_in_advance) { true } + + it { expect { result }.to raise_error(NotImplementedError) } + + context "when current usage" do + let(:current_usage) { true } + + it { expect(result).to be_a(BillableMetrics::Aggregations::LatestService) } + end + end + end + + context "with max_agg aggregation" do + let(:billable_aggregation) { :max_billable_metric } + + it { expect(result).to be_a(BillableMetrics::Aggregations::MaxService) } + + context "when pay_in_advance" do + let(:pay_in_advance) { true } + + it { expect { result }.to raise_error(NotImplementedError) } + + context "when current usage" do + let(:current_usage) { true } + + it { expect(result).to be_a(BillableMetrics::Aggregations::MaxService) } + end + end + end + + context "with sum_agg aggregation" do + let(:billable_aggregation) { :sum_billable_metric } + + it { expect(result).to be_a(BillableMetrics::Aggregations::SumService) } + + context "when prorated" do + let(:prorated) { true } + let(:recurring) { true } + + it { expect(result).to be_a(BillableMetrics::ProratedAggregations::SumService) } + end + end + + context "with unique_count_agg aggregation" do + let(:billable_aggregation) { :unique_count_billable_metric } + + it { expect(result).to be_a(BillableMetrics::Aggregations::UniqueCountService) } + + context "when prorated" do + let(:prorated) { true } + let(:recurring) { true } + + it { expect(result).to be_a(BillableMetrics::ProratedAggregations::UniqueCountService) } + end + end + + context "with weighted_sum_agg aggregation" do + let(:billable_aggregation) { :weighted_sum_billable_metric } + + it { expect(result).to be_a(BillableMetrics::Aggregations::WeightedSumService) } + + context "when pay_in_advance" do + let(:pay_in_advance) { true } + + it { expect { result }.to raise_error(NotImplementedError) } + + context "when current usage" do + let(:current_usage) { true } + + it { expect(result).to be_a(BillableMetrics::Aggregations::WeightedSumService) } + end + end + end + + context "with custom_agg aggregation" do + let(:billable_aggregation) { :custom_billable_metric } + + it { expect(result).to be_a(BillableMetrics::Aggregations::CustomService) } + end + end +end diff --git a/spec/services/billable_metrics/aggregations/apply_rounding_service_spec.rb b/spec/services/billable_metrics/aggregations/apply_rounding_service_spec.rb new file mode 100644 index 0000000..f7dfe1f --- /dev/null +++ b/spec/services/billable_metrics/aggregations/apply_rounding_service_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::ApplyRoundingService do + subject(:rounding_service) { described_class.new(billable_metric:, units:) } + + let(:rounding_function) { "round" } + let(:rounding_precision) { 2 } + + let(:billable_metric) do + create(:billable_metric, rounding_precision:, rounding_function:) + end + + let(:units) { 123.456 } + + describe "#call" do + let(:result) { rounding_service.call } + + context "with round function" do + it "returns the rounded units" do + expect(result.units).to eq(123.46) + end + + context "without precision" do + let(:rounding_precision) { nil } + + it "applies the rounding to the integer value" do + expect(result.units).to eq(123) + end + end + + context "with negative precision" do + let(:rounding_precision) { -2 } + + it "applies the rounding" do + expect(result.units).to eq(100) + end + end + end + + context "with ceil function" do + let(:rounding_function) { "ceil" } + + it "returns the rounded units" do + expect(result.units).to eq(123.46) + end + + context "without precision" do + let(:rounding_precision) { nil } + + it "applies the rounding to the integer value" do + expect(result.units).to eq(124) + end + end + + context "with negative precision" do + let(:rounding_precision) { -2 } + + it "applies the rounding" do + expect(result.units).to eq(200) + end + end + end + + context "with floor function" do + let(:rounding_function) { "floor" } + + it "returns the rounded units" do + expect(result.units).to eq(123.45) + end + + context "without precision" do + let(:rounding_precision) { nil } + + it "applies the rounding to the integer value" do + expect(result.units).to eq(123) + end + end + + context "with negative precision" do + let(:rounding_precision) { -2 } + + it "applies the rounding" do + expect(result.units).to eq(100) + end + end + end + + context "without rounding function" do + let(:rounding_function) { nil } + + it "returns the units" do + expect(result.units).to eq(units) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/base_service_spec.rb b/spec/services/billable_metrics/aggregations/base_service_spec.rb new file mode 100644 index 0000000..29b4968 --- /dev/null +++ b/spec/services/billable_metrics/aggregations/base_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::BaseService do + describe ".null_result" do + subject(:null_result) { described_class.null_result(result, **args) } + + let(:result) { BaseService::Result.new } + let(:args) { {} } + + context "without keyword arguments" do + it "returns a result with zero values" do + expect(null_result.aggregation).to eq(0) + expect(null_result.count).to eq(0) + expect(null_result.current_usage_units).to eq(0) + expect(null_result.options).to eq({running_total: []}) + expect(null_result.grouped_by).to be_nil + end + + it "populates and returns the provided result" do + expect(null_result).to be(result) + end + end + + context "with grouped_by_keys" do + let(:args) { {grouped_by_keys: %w[region plan]} } + + it "sets grouped_by with nil values for each key" do + expect(null_result.grouped_by).to eq({"region" => nil, "plan" => nil}) + expect(null_result.aggregation).to eq(0) + expect(null_result.count).to eq(0) + expect(null_result.current_usage_units).to eq(0) + expect(null_result.options).to eq({running_total: []}) + end + end + + context "with empty grouped_by_keys" do + let(:args) { {grouped_by_keys: []} } + + it "sets grouped_by to an empty hash" do + expect(null_result.grouped_by).to eq({}) + expect(null_result.aggregation).to eq(0) + expect(null_result.count).to eq(0) + end + end + + context "with apply_aggregation and grouped_by_keys" do + let(:args) { {grouped_by_keys: %w[region], apply_aggregation: true} } + + it "wraps a null result inside aggregations" do + expect(null_result.aggregations.size).to eq(1) + + inner = null_result.aggregations.first + expect(inner.grouped_by).to eq({"region" => nil}) + expect(inner.aggregation).to eq(0) + expect(inner.count).to eq(0) + expect(inner.current_usage_units).to eq(0) + expect(inner.options).to eq({running_total: []}) + end + end + + context "with apply_aggregation and no grouped_by_keys" do + let(:args) { {apply_aggregation: true} } + + it "returns a flat null result without aggregations wrapper" do + expect(null_result.aggregation).to eq(0) + expect(null_result.count).to eq(0) + expect(null_result.current_usage_units).to eq(0) + expect(null_result.options).to eq({running_total: []}) + expect(null_result.grouped_by).to be_nil + end + end + end + + describe "#empty_results" do + subject(:empty_results) { aggregator.empty_results } + + let(:aggregator) do + described_class.new( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: {from_datetime: Time.current, to_datetime: Time.current}, + filters: + ) + end + + let(:subscription) { create(:subscription) } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:filters) { {} } + + context "without grouped_by" do + it "returns the aggregator's result zeroed out with the aggregator attached" do + expect(empty_results.aggregator).to be(aggregator) + expect(empty_results.aggregation).to eq(0) + expect(empty_results.count).to eq(0) + expect(empty_results.current_usage_units).to eq(0) + expect(empty_results.options).to eq({running_total: []}) + end + end + + context "with grouped_by in the filters" do + let(:filters) { {grouped_by: %w[region provider]} } + + it "wraps a null result inside aggregations for each group key and keeps the aggregator on the outer result" do + expect(empty_results.aggregator).to be(aggregator) + expect(empty_results.aggregations.size).to eq(1) + + inner = empty_results.aggregations.first + expect(inner.grouped_by).to eq({"region" => nil, "provider" => nil}) + expect(inner.aggregation).to eq(0) + expect(inner.count).to eq(0) + expect(inner.current_usage_units).to eq(0) + expect(inner.options).to eq({running_total: []}) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/count_service_spec.rb b/spec/services/billable_metrics/aggregations/count_service_spec.rb new file mode 100644 index 0000000..b4797e1 --- /dev/null +++ b/spec/services/billable_metrics/aggregations/count_service_spec.rb @@ -0,0 +1,416 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::CountService do + subject(:count_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime: + }, + filters:, + bypass_aggregation: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:bypass_aggregation) { false } + let(:filters) do + {event: pay_in_advance_event, grouped_by:, presentation_by:, matching_filters:, ignored_filters:} + end + + let(:subscription) { create(:subscription, organization:) } + let(:organization) { create(:organization) } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:presentation_by) { nil } + let(:matching_filters) { {} } + let(:ignored_filters) { [] } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "count_agg" + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { (Time.current - 1.month).beginning_of_day } + let(:to_datetime) { Time.current.end_of_day } + + let(:pay_in_advance_event) { nil } + + let(:event_list) do + create_list( + :event, + 4, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day + ) + end + + before do + event_list + end + + it "aggregates the events" do + result = count_service.aggregate + + expect(result.aggregation).to eq(4) + end + + context "when events are out of bounds" do + let(:to_datetime) { Time.zone.now - 2.days } + + it "does not take events into account" do + result = count_service.aggregate + + expect(result.aggregation).to eq(0) + end + end + + context "when filters are given" do + let(:matching_filters) { {cloud: ["AWS"], region: ["europe"]} } + + let(:event_list) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12, + cloud: "AWS", + region: "europe" + } + ), + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 8, + cloud: "AWS", + region: "europe" + } + ), + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12, + cloud: "AWS", + region: "africa" + } + ) + ] + end + + it "aggregates the events" do + result = count_service.aggregate + + expect(result.aggregation).to eq(2) + end + end + + context "when pay_in_advance aggregation" do + let(:pay_in_advance_event) { create(:event, subscription_id: subscription.id, customer_id: customer.id) } + + it "assigns an pay_in_advance aggregation" do + result = count_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(1) + end + end + + context "with presentation group keys" do + let(:presentation_by) { ["cloud"] } + + let(:event_list) do + create_list( + :event, + 3, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day, + properties: {"cloud" => "aws"} + ) + create_list( + :event, + 1, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day, + properties: {"cloud" => "gcp"} + ) + end + + it "returns the aggregations per group" do + result = count_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"cloud" => "aws"}, value: 3}, + {groups: {"cloud" => "gcp"}, value: 1} + ]) + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + let(:event_list) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day, + properties: {"agent_name" => "frodo", "cloud" => "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day, + properties: {"agent_name" => "frodo", "cloud" => "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day, + properties: {"agent_name" => "frodo", "cloud" => "gcp"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day, + properties: {"agent_name" => "aragorn", "cloud" => "aws"} + ) + ] + end + + it "returns the aggregations per group" do + result = count_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 2}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 1}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 1} + ]) + end + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns a default empty result" do + result = count_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.current_usage_units).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + describe ".per_event_aggregation" do + it "aggregates per events" do + result = count_service.per_event_aggregation + + expect(result.event_aggregation).to eq([1, 1, 1, 1]) + end + + context "with grouped_by_values" do + before do + event_list.first.update!(properties: {"scheme" => "visa"}) + end + + it "takes the groups into account" do + result = count_service.per_event_aggregation(grouped_by_values: {"scheme" => "visa"}) + + expect(result.event_aggregation).to eq([1]) + end + end + + context "when including event value" do + it "includes the event value in the result" do + result = count_service.per_event_aggregation(include_event_value: true) + + expect(result.event_aggregation).to eq([1, 1, 1, 1, 1]) + end + end + end + + describe ".grouped_by_aggregation" do + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn frodo gimli legolas] } + + let(:event_list) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + agent_name: + } + ) + end + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: {} + ) + ] + end + + it "returns a grouped aggregations" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(5) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] || "" }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(1) + expect(aggregation.count).to eq(1) + expect(aggregation.current_usage_units).to eq(1) + + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index - 1]) if index.positive? + expect(aggregation.options[:running_total]).to eq([]) + end + end + + context "without events" do + let(:event_list) { [] } + + it "returns an empty result" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "with free units per events" do + it "returns a result with free units" do + result = count_service.aggregate(options: {free_units_per_events: 10}) + + expect(result.aggregations.count).to eq(5) + + result.aggregations.each_with_index do |aggregation, index| + expect(aggregation.options[:running_total]).to eq([1]) + end + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns an empty result" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + end + + context "with clickhouse event store", :clickhouse do + let(:event_store_class) { Events::Stores::ClickhouseStore } + let(:organization) { create(:organization, clickhouse_events_store: true) } + + let(:event_list) do + create_list( + :clickhouse_events_enriched, + 4, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day + ) + end + + it "aggregates the events" do + result = count_service.aggregate + + expect(result.aggregation).to eq(4) + end + + context "with deduplication" do + let(:organization) { create(:organization, clickhouse_events_store: true, clickhouse_deduplication_enabled: true) } + + let(:event_list) do + create_list( + :clickhouse_events_enriched, + 4, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 1.day, + transaction_id: "123456" + ) + end + + it "aggregates the events" do + result = count_service.aggregate + + expect(result.aggregation).to eq(1) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/custom_service_spec.rb b/spec/services/billable_metrics/aggregations/custom_service_spec.rb new file mode 100644 index 0000000..4ba342d --- /dev/null +++ b/spec/services/billable_metrics/aggregations/custom_service_spec.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::CustomService do + subject(:custom_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime: + }, + filters:, + bypass_aggregation: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:bypass_aggregation) { false } + let(:filters) { {grouped_by:, matching_filters:, ignored_filters:, event:} } + + let(:subscription) { create(:subscription) } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + + let(:grouped_by) { nil } + let(:matching_filters) { nil } + let(:ignored_filters) { nil } + let(:event) { nil } + + let(:billable_metric) do + create(:custom_billable_metric, organization:, custom_aggregator:) + end + let(:custom_aggregator) do + <<~RUBY + def aggregate(event, previous_state, aggregation_properties) + previous_units = previous_state[:total_units] + event_units = BigDecimal(event.properties['value'].to_s) + storage_zone = event.properties['storage_zone'] + total_units = previous_units + event_units + ranges = aggregation_properties['ranges'] + + result_amount = ranges.each_with_object(0) do |range, amount| + # Range was already reached + next amount if range['to'] && previous_units > range['to'] + + zone_amount = BigDecimal(range[storage_zone] || '0') + + if !range['to'] || total_units <= range['to'] + # Last matching range is reached + units_to_use = if previous_units > range['from'] + # All new units are in the current range + event_units + else + # Takes only the new units in the current range + total_units - range['from'] + end + break amount += zone_amount * units_to_use + + else + # Range is not the last one + units_to_use = if previous_units > range['from'] + # All remaining units in the range + range['to'] - previous_units + else + # All units in the range + range['to'] - range['from'] + end + + amount += zone_amount * units_to_use + end + + amount + end + { total_units: total_units, amount: result_amount } + end + RUBY + end + + let(:charge) { create(:standard_charge, billable_metric:, properties: charge_properties) } + let(:charge_properties) do + { + amount: "10", + custom_properties: { + ranges: [ + {from: 0, to: 10, storage_eu: "0", storage_us: "0", storage_asia: "0"}, + {from: 10, to: 20, storage_eu: "0.10", storage_us: "0.20", storage_asia: "0.30"}, + {from: 20, to: nil, storage_eu: "0.20", storage_us: "0.30", storage_asia: "0.40"} + ] + } + } + end + + let(:from_datetime) { (Time.current - 1.month).beginning_of_day } + let(:to_datetime) { Time.current.end_of_day } + + let(:event_list) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 4.days, + properties: {value: 1, storage_zone: "storage_eu"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 3.days, + properties: {value: 10, storage_zone: "storage_asia"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 2.days, + properties: {value: 35, storage_zone: "storage_us"} + ) + ] + end + + before do + event_list + end + + it "aggregates the events" do + result = custom_service.aggregate + + expect(result.aggregation).to eq(46) + expect(result.count).to eq(3) + expect(result.options).to eq({}) + expect(result.custom_aggregation).to eq({total_units: 46, amount: 8.1}) + end + + context "when there are no events" do + let(:event_list) { [] } + + it "returns an empty state" do + result = custom_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.options).to eq({}) + expect(result.custom_aggregation).to eq({total_units: 0, amount: 0}) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns a default empty result" do + result = custom_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.current_usage_units).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + context "when the charge is payed in advance" do + let(:charge) { create(:standard_charge, billable_metric:, properties: charge_properties, pay_in_advance: true) } + + let(:event_list) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: Time.zone.now - 4.days, + properties: {value: 11, storage_zone: "storage_eu"} + ) + ] + end + let(:event) { event_list.first } + + it "aggregates the events" do + result = custom_service.aggregate + + expect(result.aggregation).to eq(11) + expect(result.count).to eq(1) + expect(result.options).to eq({}) + expect(result.custom_aggregation).to eq({total_units: 11.0, amount: 0.1}) + + expect(result.pay_in_advance_aggregation).to eq(11) + expect(result.current_aggregation).to eq(11.0) + expect(result.max_aggregation).to eq(11.0) + expect(result.units_applied).to eq(11.0) + expect(result.current_amount).to eq(0.1) + end + + context "with a cached aggregation" do + before do + create( + :cached_aggregation, + organization:, + charge:, + external_subscription_id: subscription.external_id, + timestamp: Time.zone.now - 4.days, + current_aggregation: 11.0, + max_aggregation: 11.0, + current_amount: 0.1 + ) + end + + it "aggregates the events with the cached aggregation" do + result = custom_service.aggregate + + expect(result.aggregation).to eq(11) + expect(result.count).to eq(1) + expect(result.options).to eq({}) + expect(result.custom_aggregation).to eq({total_units: 11.0, amount: 0.4}) + + expect(result.pay_in_advance_aggregation).to eq(11) + expect(result.current_aggregation).to eq(22.0) + expect(result.max_aggregation).to eq(22.0) + expect(result.units_applied).to eq(11.0) + expect(result.current_amount).to eq(0.5) + end + end + end + + context "when the charge is a standard with grouped by properties" do + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn frodo gimli legolas] } + + let(:event_list) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 2.days, + properties: { + agent_name:, + value: 11, + storage_zone: "storage_eu" + } + ) + end + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 2.days, + properties: {value: 11, storage_zone: "storage_eu"} + ) + ] + end + + it "aggregates the events in groups" do + result = custom_service.aggregate + + expect(result.aggregations.count).to eq(5) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] || "" }.each_with_index do |aggregation, _index| + expect(aggregation.aggregation).to eq(11) + expect(aggregation.count).to eq(1) + expect(aggregation.current_usage_units).to eq(11) + expect(aggregation.custom_aggregation).to eq({total_units: 11, amount: 0.1}) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns an empty result" do + result = custom_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + end + + context "when the billable metric is recurring" do + let(:billable_metric) do + create(:custom_billable_metric, :recurring, organization:, custom_aggregator:) + end + + it "aggregates the events" do + result = custom_service.aggregate + + expect(result.aggregation).to eq(46) + expect(result.count).to eq(3) + expect(result.options).to eq({}) + expect(result.custom_aggregation).to eq({total_units: 46, amount: 8.1}) + end + + context "with a cached aggregation from a previous period" do + before do + create( + :cached_aggregation, + organization:, + charge:, + external_subscription_id: subscription.external_id, + timestamp: from_datetime - 1.day, + current_aggregation: 11.0, + max_aggregation: 11.0, + current_amount: 0.1 + ) + end + + it "aggregates the events with the cached aggregation" do + result = custom_service.aggregate + + expect(result.aggregation).to eq(57) + expect(result.count).to eq(3) + expect(result.options).to eq({}) + expect(result.custom_aggregation).to eq({total_units: 57.0, amount: 11.5}) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/latest_service_spec.rb b/spec/services/billable_metrics/aggregations/latest_service_spec.rb new file mode 100644 index 0000000..093ff0a --- /dev/null +++ b/spec/services/billable_metrics/aggregations/latest_service_spec.rb @@ -0,0 +1,436 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::LatestService do + subject(:latest_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime: + }, + filters:, + bypass_aggregation: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:bypass_aggregation) { false } + let(:filters) { {grouped_by:, presentation_by:, matching_filters:, ignored_filters:} } + + let(:subscription) { create(:subscription) } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:presentation_by) { nil } + let(:matching_filters) { {} } + let(:ignored_filters) { [] } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "latest_agg", + field_name: "total_count" + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { (Time.current - 1.month).beginning_of_day } + let(:to_datetime) { Time.current.end_of_day } + + let(:events) do + [ + create_list( + :event, + 4, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 2.days, + properties: { + total_count: 18 + } + ), + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 1.day, + properties: { + total_count: 14 + } + ) + ].flatten + end + + before { events } + + it "aggregates the events" do + result = latest_service.aggregate + + expect(result.aggregation).to eq(14) + expect(result.count).to eq(5) + end + + context "when events are out of bounds" do + let(:to_datetime) { Time.current - 3.days } + + it "does not take events into account" do + result = latest_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + end + end + + context "when properties is not found on events" do + before do + billable_metric.update!(field_name: "foo_bar") + end + + it "counts as zero" do + result = latest_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + end + end + + context "when properties is a float" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current, + properties: { + total_count: 14.2 + } + ) + ] + end + + it "aggregates the events" do + result = latest_service.aggregate + + expect(result.aggregation).to eq(14.2) + end + end + + context "when properties is negative" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current, + properties: { + total_count: -5 + } + ) + ] + end + + it "returns zero" do + result = latest_service.aggregate + + expect(result.aggregation).to eq(0) + end + end + + context "when properties is missing" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current + ) + ] + end + + it "ignores the event" do + result = latest_service.aggregate + + expect(result).to be_success + expect(result.aggregation).to eq(0) + end + end + + context "when filters are given" do + let(:matching_filters) { {region: ["europe"]} } + + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 2.seconds, + properties: { + total_count: 12, + region: "europe" + } + ), + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 1.second, + properties: { + total_count: 8, + region: "europe" + } + ), + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 1.second, + properties: { + total_count: 12, + region: "africa" + } + ) + ].flatten + end + + it "aggregates the events" do + result = latest_service.aggregate + + expect(result.aggregation).to eq(8) + expect(result.count).to eq(2) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns a default empty result" do + result = latest_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.current_usage_units).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + describe ".grouped_by_aggregation" do + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn frodo gimli legolas] } + + let(:events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12, + agent_name: + } + ) + end + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12 + } + ) + ] + end + + it "returns a grouped aggregations" do + result = latest_service.aggregate + + expect(result.aggregations.count).to eq(5) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] || "" }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(12) + expect(aggregation.count).to eq(1) + + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index - 1]) if index.positive? + end + end + + context "without events" do + let(:events) { [] } + + it "returns an empty result" do + result = latest_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns an empty result" do + result = latest_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + end + + context "with presentation group keys" do + let(:presentation_by) { ["cloud"] } + + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 2.days, + properties: { + total_count: 18, + cloud: "aws" + } + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 1.day, + properties: { + total_count: 14, + cloud: "aws" + } + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 1.day, + properties: { + total_count: -5, + cloud: "gcp" + } + ) + ] + end + + it "returns the aggregations per group" do + result = latest_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"cloud" => "gcp"}, value: BigDecimal(-5)} + ]) + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 2.days, + properties: { + total_count: 10, + agent_name: "frodo", + cloud: "aws" + } + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 1.day, + properties: { + total_count: 12, + agent_name: "frodo", + cloud: "gcp" + } + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.current - 1.day, + properties: { + total_count: 3, + agent_name: "aragorn", + cloud: "aws" + } + ) + ] + end + + it "returns the aggregations per group" do + result = latest_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: BigDecimal(12)}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: BigDecimal(3)} + ]) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/max_service_spec.rb b/spec/services/billable_metrics/aggregations/max_service_spec.rb new file mode 100644 index 0000000..ff80252 --- /dev/null +++ b/spec/services/billable_metrics/aggregations/max_service_spec.rb @@ -0,0 +1,421 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::MaxService do + subject(:max_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime: + }, + filters:, + bypass_aggregation: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:bypass_aggregation) { false } + let(:filters) { {grouped_by:, presentation_by:, matching_filters:, ignored_filters:} } + + let(:subscription) { create(:subscription) } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:presentation_by) { nil } + let(:matching_filters) { {} } + let(:ignored_filters) { [] } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "max_agg", + field_name: "total_count" + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { (Time.current - 1.month).beginning_of_day } + let(:to_datetime) { Time.current.end_of_day } + + let(:events) do + [ + create_list( + :event, + 4, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 2.days, + properties: { + total_count: rand(10) + } + ), + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12 + } + ) + ].flatten + end + + before { events } + + it "aggregates the events" do + result = max_service.aggregate + + expect(result.aggregation).to eq(12) + expect(result.count).to eq(5) + end + + context "when events are out of bounds" do + let(:to_datetime) { Time.zone.now - 3.days } + + it "does not take events into account" do + result = max_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns a default empty result" do + result = max_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.current_usage_units).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + context "when properties is not found on events" do + before do + billable_metric.update!(field_name: "foo_bar") + end + + it "counts as zero" do + result = max_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + end + end + + context "when properties is a float" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 14.2 + } + ) + ] + end + + it "aggregates the events" do + result = max_service.aggregate + + expect(result.aggregation).to eq(14.2) + end + end + + context "when properties is not a number" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: "foo_bar" + } + ) + ] + end + + it "ignores the event" do + result = max_service.aggregate + + expect(result).to be_success + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + end + end + + context "when properties is missing" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day + ) + ] + end + + it "ignore the event" do + result = max_service.aggregate + + expect(result).to be_success + expect(result.count).to eq(0) + expect(result.aggregation).to eq(0) + end + end + + context "when filters are given" do + let(:matching_filters) { {region: ["europe"]} } + + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 8, + region: "europe" + } + ), + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12, + region: "africa" + } + ) + ] + end + + it "aggregates the events" do + result = max_service.aggregate + + expect(result.aggregation).to eq(8) + expect(result.count).to eq(1) + end + end + + describe ".per_event_aggregation" do + it "aggregates per events" do + result = max_service.per_event_aggregation + + expect(result.event_aggregation).to eq([0, 0, 0, 0, 12]) + end + + context "with grouped_by_values" do + let(:event) { events.first } + + before do + event.update!(properties: event.properties.merge(scheme: "visa")) + end + + it "takes the groups into account" do + result = max_service.per_event_aggregation(grouped_by_values: {"scheme" => "visa"}) + + expect(result.event_aggregation).to eq([event.properties["total_count"]]) + end + end + end + + describe ".grouped_by_aggregation" do + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn frodo gimli legolas] } + + let(:events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12, + agent_name: + } + ) + end + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: { + total_count: 12 + } + ) + ] + end + + it "returns a grouped aggregations" do + result = max_service.aggregate + + expect(result.aggregations.count).to eq(5) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] || "" }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(12) + expect(aggregation.count).to eq(1) + + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index - 1]) if index.positive? + end + end + + context "without events" do + let(:events) { [] } + + it "returns an empty result" do + result = max_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns an empty result" do + result = max_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + end + + context "with presentation group keys" do + let(:presentation_by) { ["cloud"] } + + let(:events) do + [ + create_list( + :event, + 3, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: {total_count: 10, cloud: "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: {total_count: 12, cloud: "gcp"} + ) + ].flatten + end + + it "returns the aggregations per group" do + result = max_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"cloud" => "aws"}, value: 10}, + {groups: {"cloud" => "gcp"}, value: 12} + ]) + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + let(:events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: {total_count: 2, agent_name: "frodo", cloud: "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: {total_count: 7, agent_name: "frodo", cloud: "gcp"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: Time.zone.now - 1.day, + properties: {total_count: 3, agent_name: "aragorn", cloud: "aws"} + ) + ] + end + + it "returns the aggregations per group" do + result = max_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 2}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 7}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 3} + ]) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/sum_service_spec.rb b/spec/services/billable_metrics/aggregations/sum_service_spec.rb new file mode 100644 index 0000000..1622e81 --- /dev/null +++ b/spec/services/billable_metrics/aggregations/sum_service_spec.rb @@ -0,0 +1,1096 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::SumService, transaction: false do + subject(:sum_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime: + }, + filters:, + bypass_aggregation: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:bypass_aggregation) { false } + let(:filters) do + {event: pay_in_advance_event, grouped_by:, presentation_by:, charge_filter:, matching_filters:, ignored_filters:} + end + + let(:subscription) { create(:subscription, started_at: Time.current.beginning_of_month - 6.months) } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:presentation_by) { nil } + let(:charge_filter) { nil } + let(:matching_filters) { nil } + let(:ignored_filters) { nil } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "sum_agg", + field_name: "total_count" + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { subscription.started_at + 5.months } + let(:to_datetime) { subscription.started_at + 6.months } + let(:pay_in_advance_event) { nil } + let(:options) do + {free_units_per_events: 2, free_units_per_total_aggregation: 30} + end + + let(:old_events) do + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: subscription.started_at + 3.months, + properties: { + total_count: 2.5 + } + ) + end + + let(:latest_events) do + create_list( + :event, + 4, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12 + } + ) + end + + before do + old_events + latest_events + end + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(48) + expect(result.pay_in_advance_aggregation).to be_zero + expect(result.count).to eq(4) + expect(result.options).to eq({running_total: [12, 24]}) + end + + context "when billable metric is recurring" do + before { billable_metric.update!(recurring: true) } + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(53) + expect(result.pay_in_advance_aggregation).to be_zero + expect(result.count).to eq(6) + expect(result.options).to eq({running_total: [2.5, 5]}) + end + end + + context "when options are not present" do + let(:options) { {} } + + it "returns an empty running total array" do + result = sum_service.aggregate(options:) + expect(result.options).to eq({running_total: []}) + end + end + + context "when option values are nil" do + let(:options) do + {free_units_per_events: nil, free_units_per_total_aggregation: nil} + end + + it "returns an empty running total array" do + result = sum_service.aggregate(options:) + expect(result.options).to eq({running_total: []}) + end + end + + context "when free_units_per_events is nil" do + let(:options) do + {free_units_per_events: nil, free_units_per_total_aggregation: 30} + end + + it "returns running total based on per total aggregation" do + result = sum_service.aggregate(options:) + expect(result.options).to eq({running_total: [12, 24, 36]}) + end + end + + context "when free_units_per_total_aggregation is nil" do + let(:options) do + {free_units_per_events: 2, free_units_per_total_aggregation: nil} + end + + it "returns running total based on per events" do + result = sum_service.aggregate(options:) + expect(result.options).to eq({running_total: [12, 24]}) + end + end + + context "when charge is dynamic" do + let(:charge) { create(:dynamic_charge, billable_metric:) } + + it "computes the precise_total_amount_cents" do + result = sum_service.aggregate(options:) + expect(result.precise_total_amount_cents).to be_zero + end + + context "with events that specify a precise_total_amount_cents" do + let(:old_events) do + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: subscription.started_at + 3.months, + properties: { + total_count: 2.5 + }, + precise_total_amount_cents: 12 + ) + end + let(:latest_events) do + create_list( + :event, + 4, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12 + }, + precise_total_amount_cents: 10 + ) + end + + it "computes the precise_total_amount_cents" do + result = sum_service.aggregate(options:) + expect(result.aggregation).to eq(4 * 12) + expect(result.precise_total_amount_cents).to eq(4 * 10) + end + end + + context "when filters are given" do + let(:matching_filters) { {region: ["europe"]} } + + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12, + region: "europe" + }, + precise_total_amount_cents: 5 + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 8, + region: "europe" + }, + precise_total_amount_cents: 7 + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12, + region: "africa" + }, + precise_total_amount_cents: 9 + ) + end + + it "aggregates the events matching the filter" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(20) + expect(result.count).to eq(2) + expect(result.precise_total_amount_cents).to eq(12) + expect(result.options).to eq({running_total: [12, 20]}) + end + end + end + + context "when events are out of bounds" do + let(:latest_events) do + create_list( + :event, + 4, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime + 1.day, + properties: { + total_count: 12 + } + ) + end + + it "does not take events into account" do + result = sum_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + context "when properties is not found on events" do + before do + billable_metric.update!(field_name: "foo_bar") + end + + it "counts as zero" do + result = sum_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + context "when properties is a float" do + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 4.5 + } + ) + end + + it "aggregates the events" do + result = sum_service.aggregate + + expect(result.aggregation).to eq(52.5) + end + end + + context "when properties is not a number" do + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: "foo_bar" + } + ) + end + + it "ignores the event" do + result = sum_service.aggregate + + expect(result).to be_success + expect(result.aggregation).to eq(48) + expect(result.count).to eq(4) + end + end + + context "when current usage context and charge is pay in advance" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 3.days, + properties: { + total_count: 4 + } + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: latest_events.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 3.days, + current_aggregation: "4", + max_aggregation: "6" + ) + end + + before do + billable_metric.update!(recurring: true) + cached_aggregation + end + + it "returns period maximum as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(11) + end + + context "when cached aggregation does not exist" do + let(:latest_events) { nil } + let(:cached_aggregation) { nil } + + before { billable_metric.update!(recurring: false) } + + it "returns zero as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(0) + end + end + end + + context "with non persisted event" do + let(:options) do + {free_units_per_events: 4, free_units_per_total_aggregation: 30, is_pay_in_advance: true} + end + + let(:latest_events) do + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12 + } + ) + end + + let(:event) do + build( + :common_event, + subscription:, + organization:, + billable_metric:, + properties: { + billable_metric.field_name => 10 + }, + persisted: false + ) + end + + let(:filters) { {grouped_by:, matching_filters:, ignored_filters:, event:} } + + it "returns period maximum as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.options[:running_total]).to eq([12.0, 24.0, 34.0]) + end + end + + context "when filters are given" do + let(:matching_filters) { {region: ["europe"]} } + + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12, + region: "europe" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 8, + region: "europe" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12, + region: "africa" + } + ) + end + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(20) + expect(result.count).to eq(2) + expect(result.options).to eq({running_total: [12, 20]}) + end + end + + context "when filter is given" do + let(:filter) do + create(:billable_metric_filter, billable_metric:, key: "region", values: ["north america", "europe", "africa"]) + end + let(:matching_filters) { {"region" => ["europe"]} } + let(:ignored_filters) { [] } + let(:charge_filter) { create(:charge_filter, charge:) } + let(:filter_value) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: ["europe"] + ) + end + + before do + filter_value + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12, + region: "europe" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 8, + region: "europe" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12, + region: "africa" + } + ) + end + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(20) + expect(result.count).to eq(2) + expect(result.options).to eq({running_total: [12, 20]}) + end + end + + context "when subscription was upgraded in the period" do + let(:old_subscription) do + create( + :subscription, + external_id: subscription.external_id, + organization:, + customer:, + started_at: from_datetime - 10.days, + terminated_at: from_datetime, + status: :terminated + ) + end + + before do + old_subscription + subscription.update!(previous_subscription: old_subscription) + billable_metric.update!(recurring: true) + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription: old_subscription, + timestamp: from_datetime - 5.days, + properties: { + total_count: 10 + } + ) + end + + it "returns the correct number" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(63) + end + end + + context "when event is given" do + let(:old_events) { nil } + let(:latest_events) { nil } + let(:pay_in_advance_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 2.days, + properties: + ) + end + + let(:properties) { {total_count: 10} } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(10) + end + + context "when current period aggregation is greater than period maximum" do + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 3.days, + properties: { + total_count: -6 + } + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 3.days, + current_aggregation: "4", + max_aggregation: "10" + ) + end + + before { cached_aggregation } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(4) + end + end + + context "when current period aggregation is less than period maximum" do + let(:properties) { {total_count: -2} } + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 3.days, + properties: { + total_count: -6 + } + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: latest_events.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 3.days, + current_aggregation: "4", + max_aggregation: "10" + ) + end + + before { cached_aggregation } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(0) + end + end + + context "when properties is a float" do + let(:properties) { {total_count: 12.4} } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(12.4) + end + end + + context "when event property does not match metric field name" do + let(:properties) { {final_count: 10} } + + it "assigns 0 as pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to be_zero + end + end + + context "when event is missing properties" do + let(:properties) { {} } + + it "assigns 0 as pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to be_zero + end + end + + context "when a precise_total_amount_cents is present" do + let(:charge) do + create( + :dynamic_charge, + billable_metric: + ) + end + + let(:pay_in_advance_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 2.days, + properties:, + precise_total_amount_cents: 1234.02 + ) + end + + it "computes the precise_total_amount_cents" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(10) + expect(result.precise_total_amount_cents).to eq(1234.02) + expect(result.pay_in_advance_precise_total_amount_cents).to eq(1234.02) + end + end + end + + context "when rounding is configured" do + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "sum_agg", + field_name: "total_count", + rounding_function: "ceil", + rounding_precision: 2 + ) + end + + before do + latest_events.last.update!(properties: {total_count: 12.434}) + end + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(48.44) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns a default empty result" do + result = sum_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.current_usage_units).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + describe ".per_event_aggregation" do + it "aggregates per events" do + result = sum_service.per_event_aggregation + + expect(result.event_aggregation).to eq([12, 12, 12, 12]) + end + + context "with grouped_by_values" do + let(:event) { latest_events.first } + + before do + event.update!(properties: event.properties.merge(scheme: "visa")) + end + + it "takes the groups into account" do + result = sum_service.per_event_aggregation(grouped_by_values: {"scheme" => "visa"}) + + expect(result.event_aggregation).to eq([12]) + end + end + + context "when including event value" do + let(:event) do + build( + :common_event, + subscription:, + organization:, + billable_metric:, + properties: { + billable_metric.field_name => 10 + } + ) + end + + let(:filters) { {grouped_by:, matching_filters:, ignored_filters:, event:} } + + it "includes the event value in the result" do + result = sum_service.per_event_aggregation(include_event_value: true) + + expect(result.event_aggregation).to eq([12, 12, 12, 12, 10]) + end + end + end + + describe ".grouped_by aggregation" do + let(:grouped_by) { ["agent_name"] } + + let(:agent_names) { %w[aragorn frodo gimli legolas] } + + let(:old_events) { [] } + + let(:latest_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: { + total_count: 12, + agent_name: + } + ) + end + end + + it "returns a grouped aggregations" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(4) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(12) + expect(aggregation.count).to eq(1) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + expect(aggregation.options[:running_total]).to eq([12]) + end + end + + context "without events" do + let(:latest_events) { [] } + + it "returns an empty result" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns an empty result" do + result = sum_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "when current usage context and charge is pay in advance" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 3.days, + current_aggregation: "4", + max_aggregation: "6" + ) + end + + before do + billable_metric.update!(recurring: true) + cached_aggregation + end + + it "returns period maximum as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(4) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(12) + expect(aggregation.count).to eq(1) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + + context "when cached aggregation does not exist" do + let(:latest_events) { nil } + let(:cached_aggregation) { nil } + + before { billable_metric.update!(recurring: false) } + + it "returns an empty result" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.current_usage_units).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + end + + context "when rounding is configured" do + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "sum_agg", + field_name: "total_count", + rounding_function: "ceil", + rounding_precision: 2 + ) + end + + let(:last_event) do + latest_events.last.tap do |e| + e.update!(properties: {total_count: 12.434, agent_name: e.properties["agent_name"]}) + end + end + + before { last_event } + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(4) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + if aggregation.grouped_by["agent_name"] == last_event.properties["agent_name"] + expect(aggregation.aggregation).to eq(12.44) + else + expect(aggregation.aggregation).to eq(12) + end + expect(aggregation.count).to eq(1) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + end + + context "with free units per events" do + it "returns a result with free units" do + result = sum_service.aggregate(options: {free_units_per_events: 10}) + + expect(result.aggregations.count).to eq(4) + + result.aggregations.each_with_index do |aggregation, index| + expect(aggregation.options[:running_total]).to eq([12]) + end + end + end + end + + context "with presentation group keys" do + let(:presentation_by) { ["cloud"] } + let(:old_events) { [] } + + let(:latest_events) do + [ + create_list( + :event, + 3, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: {total_count: 10, cloud: "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: {total_count: 12, cloud: "gcp"} + ) + ].flatten + end + + it "returns the aggregations per group" do + result = sum_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"cloud" => "aws"}, value: 30}, + {groups: {"cloud" => "gcp"}, value: 12} + ]) + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + let(:latest_events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: {total_count: 2, agent_name: "frodo", cloud: "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: {total_count: 7, agent_name: "frodo", cloud: "gcp"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 1.day, + properties: {total_count: 3, agent_name: "aragorn", cloud: "aws"} + ) + ] + end + + it "returns the aggregations per group" do + result = sum_service.aggregate + + expect(result.breakdowns).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 2}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 7}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 3} + ]) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/unique_count_service_spec.rb b/spec/services/billable_metrics/aggregations/unique_count_service_spec.rb new file mode 100644 index 0000000..6424439 --- /dev/null +++ b/spec/services/billable_metrics/aggregations/unique_count_service_spec.rb @@ -0,0 +1,905 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::UniqueCountService, transaction: false do + subject(:count_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime: + }, + filters:, + bypass_aggregation: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:bypass_aggregation) { false } + let(:filters) do + {event: pay_in_advance_event, grouped_by:, presentation_by:, charge_filter:, matching_filters:, ignored_filters:} + end + + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary + ) + end + + let(:pay_in_advance_event) { nil } + let(:subscription_at) { DateTime.parse("2022-06-09") } + let(:started_at) { subscription_at } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:presentation_by) { nil } + let(:charge_filter) { nil } + let(:matching_filters) { nil } + let(:ignored_filters) { nil } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + field_name: "unique_id", + recurring: true + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { DateTime.parse("2022-07-09 00:00:00 UTC") } + let(:to_datetime) { DateTime.parse("2022-08-08 23:59:59 UTC") } + + let(:added_at) { from_datetime - 1.month } + let(:unique_count_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: SecureRandom.uuid} + ) + end + + before { unique_count_event } + + describe "#aggregate" do + let(:result) { count_service.aggregate } + + context "with presentation group keys" do + let(:presentation_by) { ["cloud"] } + + let(:unique_count_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "001", cloud: "aws"} + ) + end + + let(:new_unique_count_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: "002", cloud: "gcp"} + ) + end + + before { new_unique_count_event } + + it "returns the aggregations per group" do + expect(result.breakdowns).to match_array([ + {groups: {"cloud" => "aws"}, value: 1}, + {groups: {"cloud" => "gcp"}, value: 1} + ]) + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + let(:unique_count_event) { nil } + + let(:unique_count_events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "001", agent_name: "frodo", cloud: "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: "002", agent_name: "frodo", cloud: "gcp"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "003", agent_name: "aragorn", cloud: "aws"} + ) + ] + end + + before do + unique_count_events + end + + it "returns the aggregations per group" do + expect(result.breakdowns).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 1}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 1}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 1}, + {groups: {"agent_name" => nil, "cloud" => "gcp"}, value: 1} + ]) + end + end + end + + context "when there is persisted event and event added in period" do + let(:new_unique_count_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid} + ) + end + + before { new_unique_count_event } + + it "returns the correct number" do + expect(result.aggregation).to eq(2) + end + end + + context "when there is persisted event and event added in period but billable metric is not recurring" do + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + field_name: "unique_id", + recurring: false + ) + end + let(:new_unique_count_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid} + ) + end + + before { new_unique_count_event } + + it "returns only the number of events ingested in the current period" do + expect(result.aggregation).to eq(1) + end + end + + context "with persisted metric on full period" do + it "returns the number of persisted metric" do + expect(result.aggregation).to eq(1) + end + + context "when subscription was terminated in the period" do + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary, + terminated_at: to_datetime, + status: :terminated + ) + end + let(:to_datetime) { DateTime.parse("2022-07-24 23:59:59") } + + it "returns the correct number" do + expect(result.aggregation).to eq(1) + end + end + + context "when subscription was upgraded in the period" do + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary, + terminated_at: to_datetime, + status: :terminated + ) + end + let(:to_datetime) { DateTime.parse("2022-07-24 23:59:59") } + + before do + create( + :subscription, + previous_subscription: subscription, + organization:, + customer:, + started_at: to_datetime + ) + end + + it "returns the correct number" do + expect(result.aggregation).to eq(1) + end + end + + context "when subscription was started in the period" do + let(:started_at) { DateTime.parse("2022-08-01") } + let(:from_datetime) { started_at } + + it "returns the correct number" do + expect(result.aggregation).to eq(1) + end + end + + context "when plan is pay in advance" do + before do + subscription.plan.update!(pay_in_advance: true) + end + + it "returns the correct number" do + expect(result.aggregation).to eq(1) + end + end + end + + context "with persisted metrics added in the period" do + let(:added_at) { from_datetime + 15.days } + + it "returns the correct number" do + expect(result.aggregation).to eq(1) + end + + context "when added on the first day of the period" do + let(:added_at) { from_datetime } + + it "returns the correct number" do + expect(result.aggregation).to eq(1) + end + end + end + + context "with persisted metrics terminated in the period" do + it "returns the correct number" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 15.days, + properties: { + unique_id: unique_count_event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(0) + end + + context "when removed on the last day of the period" do + it "returns the correct number" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: to_datetime, + properties: { + unique_id: unique_count_event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(0) + end + end + end + + context "with persisted metrics added and terminated in the period" do + let(:added_at) { from_datetime + 1.day } + + it "returns the correct number" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 1.day, + properties: { + unique_id: unique_count_event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(0) + end + + context "when added and removed the same day" do + let(:added_at) { from_datetime + 1.day } + + it "returns a correct number" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at.end_of_day, + properties: { + unique_id: unique_count_event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(0) + end + end + end + + context "when current usage context and charge is pay in advance" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + let(:previous_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: {unique_id: "000"} + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: previous_event.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: previous_event.timestamp, + current_aggregation: "1", + max_aggregation: "3" + ) + end + + before { cached_aggregation } + + it "returns period maximum as aggregation" do + result = count_service.aggregate(options:) + + expect(result.aggregation).to eq(4) + end + + context "when cached aggregation does not exist" do + let(:cached_aggregation) { nil } + let(:previous_event) { nil } + + before { billable_metric.update!(recurring: false) } + + it "returns zero as aggregation" do + result = count_service.aggregate(options:) + + expect(result.aggregation).to eq(0) + end + end + end + + context "when event is given" do + let(:properties) { {unique_id: unique_count_event.properties["unique_id"]} } + let(:pay_in_advance_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: + ) + end + + before { pay_in_advance_event } + + it "assigns an pay_in_advance aggregation" do + result = count_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(0) + end + + context "when charge filter is used" do + let(:properties) { {unique_id: "111", region: "europe"} } + + let(:filter) do + create( + :billable_metric_filter, + billable_metric:, + key: "region", + values: ["north america", "europe", "africa"] + ) + end + let(:matching_filters) { {"region" => ["europe"]} } + let(:ignored_filters) { [] } + let(:charge_filter) { create(:charge_filter, charge:) } + let(:filter_value) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: ["europe"] + ) + end + + before { filter_value } + + it "assigns an pay_in_advance aggregation" do + result = count_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(1) + end + end + + context "when event is missing properties" do + let(:properties) { {} } + + it "assigns 0 as pay_in_advance aggregation" do + result = count_service.aggregate + + expect(result.pay_in_advance_aggregation).to be_zero + end + end + + context "when current period aggregation is greater than period maximum" do + let(:properties) { {unique_id: "003"} } + + let(:previous_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: {unique_id: "001"} + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: previous_event.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: previous_event.timestamp, + current_aggregation: "2", + max_aggregation: "2" + ) + end + + before { cached_aggregation } + + it "assigns a pay_in_advance aggregation" do + result = count_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(1) + end + end + + context "when current period aggregation is less than period maximum" do + let(:previous_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: {unique_id: "000"} + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: previous_event.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: previous_event.timestamp, + current_aggregation: "4", + max_aggregation: "7" + ) + end + + before { cached_aggregation } + + it "assigns a pay_in_advance aggregation" do + result = count_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(0) + end + end + end + + context "when bypass_aggregation is set to true and metric is not recurring" do + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + field_name: "unique_id", + recurring: false + ) + end + let(:bypass_aggregation) { true } + + it "returns a default empty result" do + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.current_usage_units).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + end + + describe ".grouped_by_aggregation" do + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn frodo] } + let(:unique_count_event) { nil } + + context "when there is persisted event and event added in period" do + let(:unique_count_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: { + unique_id: SecureRandom.uuid, + agent_name: + } + ) + end + end + + let(:new_unique_count_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: { + unique_id: SecureRandom.uuid, + agent_name: + } + ) + end + end + + before do + unique_count_events + new_unique_count_events + end + + it "returns the correct result" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.count).to eq(2) + expect(aggregation.aggregation).to eq(2) + expect(aggregation.options[:running_total]).to eq([]) + end + end + + context "when billable metric is not recurring" do + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + field_name: "unique_id", + recurring: false + ) + end + + it "returns only the number of events ingested in the current period" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.count).to eq(1) + expect(aggregation.aggregation).to eq(1) + end + end + end + + context "with free units per events" do + it "returns a result with free units" do + result = count_service.aggregate(options: {free_units_per_events: 10}) + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each_with_index do |aggregation, index| + expect(aggregation.options[:running_total]).to eq([1, 2]) + end + end + end + end + + context "without events in the period" do + let(:unique_count_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: { + unique_id: SecureRandom.uuid, + agent_name: + } + ) + end + end + + before { unique_count_events } + + it "returns only the number of events persisted events" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.count).to eq(1) + expect(aggregation.aggregation).to eq(1) + end + end + end + + context "without events" do + let(:unique_count_event) { nil } + + it "returns an empty result" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "when bypass_aggregation is set to true and metric is not recurring" do + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + field_name: "unique_id", + recurring: false + ) + end + let(:bypass_aggregation) { true } + + it "returns an empty result" do + result = count_service.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "when current usage context and charge is pay in advance" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + + let(:unique_count_event) { nil } + + let(:unique_count_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: { + unique_id: SecureRandom.uuid, + agent_name: + } + ) + end + end + + let(:previous_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_customer_id: customer.external_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: { + unique_id: SecureRandom.uuid, + agent_name: + } + ) + end + end + + let(:cached_aggregations) do + agent_names.map.with_index do |agent_name, index| + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: previous_events[index].transaction_id, + external_subscription_id: subscription.external_id, + timestamp: previous_events[index].timestamp, + current_aggregation: "1", + max_aggregation: "3", + grouped_by: {"agent_name" => agent_name} + ) + end + end + + before do + unique_count_events + cached_aggregations + end + + it "returns period maximum as aggregation" do + result = count_service.aggregate(options:) + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.count).to eq(2) + expect(aggregation.aggregation).to eq(4) + end + end + + context "when cached aggregation does not exist" do + let(:cached_aggregations) { nil } + + before { billable_metric.update!(recurring: false) } + + it "returns an empty result" do + result = count_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + end + end + + describe ".per_event_aggregation" do + let(:added_at) { from_datetime } + + it "aggregates per events added in the period" do + result = count_service.per_event_aggregation + + expect(result.event_aggregation).to eq([1]) + end + + context "with grouped_by_values" do + before do + unique_count_event.update!(properties: unique_count_event.properties.merge(scheme: "visa")) + end + + it "takes the groups into account" do + result = count_service.per_event_aggregation(grouped_by_values: {"scheme" => "visa"}) + + expect(result.event_aggregation).to eq([1]) + end + end + + context "when including event value" do + let(:event) do + build( + :common_event, + subscription:, + organization:, + billable_metric:, + properties: { + billable_metric.field_name => "1234", + "operation_type" => "add" + } + ) + end + + let(:filters) { {grouped_by:, presentation_by:, matching_filters:, ignored_filters:, event:} } + let(:presentation_by) { nil } + + it "includes the event value in the result" do + result = count_service.per_event_aggregation(include_event_value: true) + + expect(result.event_aggregation).to eq([1, 1]) + end + end + end +end diff --git a/spec/services/billable_metrics/aggregations/weighted_sum_service_spec.rb b/spec/services/billable_metrics/aggregations/weighted_sum_service_spec.rb new file mode 100644 index 0000000..925df93 --- /dev/null +++ b/spec/services/billable_metrics/aggregations/weighted_sum_service_spec.rb @@ -0,0 +1,591 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Aggregations::WeightedSumService, transaction: false do + subject(:aggregator) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime:, + charges_duration: + }, + filters:, + bypass_aggregation: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:bypass_aggregation) { false } + let(:filters) { {grouped_by:, presentation_by:, matching_filters:, ignored_filters:} } + + let(:subscription) { create(:subscription, started_at: DateTime.parse("2023-04-01 22:22:22")) } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:matching_filters) { nil } + let(:ignored_filters) { nil } + let(:presentation_by) { nil } + + let(:billable_metric) { create(:weighted_sum_billable_metric, organization:) } + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { Time.zone.parse("2023-08-01 00:00:00.000") } + let(:to_datetime) { Time.zone.parse("2023-08-31 23:59:59.999") } + let(:charges_duration) { 31 } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 2}, + {timestamp: Time.zone.parse("2023-08-01 01:00:00"), value: 3}, + {timestamp: Time.zone.parse("2023-08-01 01:30:00"), value: 1}, + {timestamp: Time.zone.parse("2023-08-01 02:00:00"), value: -4}, + {timestamp: Time.zone.parse("2023-08-01 04:00:00"), value: -2}, + {timestamp: Time.zone.parse("2023-08-01 05:00:00"), value: 10}, + {timestamp: Time.zone.parse("2023-08-01 05:30:00"), value: -10} + ] + end + + before do + events_values.each do |values| + properties = {value: values[:value]} + properties[:region] = values[:region] if values[:region] + properties[:agent_name] = values[:agent_name] if values[:agent_name] + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription:, + customer:, + timestamp: values[:timestamp], + properties: + ) + end + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregation.round(5).to_s).to eq("0.02218") + expect(result.count).to eq(7) + end + + context "when using presentation_by" do + let(:presentation_by) { ["region"] } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 2, region: "eu"}, + {timestamp: Time.zone.parse("2023-08-01 01:00:00"), value: 3, region: "us"}, + {timestamp: Time.zone.parse("2023-08-01 02:00:00"), value: -1, region: "eu"} + ] + end + + it "returns aggregations per group" do + result = aggregator.aggregate + + region_eu = result.breakdowns.find { |b| b[:groups]["region"] == "eu" } + expect(region_eu[:value].round(5).to_s).to eq("1.00269") + + region_us = result.breakdowns.find { |b| b[:groups]["region"] == "us" } + expect(region_us[:value].round(5).to_s).to eq("2.99597") + end + end + + context "with a single event" do + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 1000} + ] + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregation.round(5).to_s).to eq("1000.0") + expect(result.count).to eq(1) + end + end + + context "with no events" do + let(:events_values) { [] } + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregation.round(5).to_s).to eq("0.0") + expect(result.count).to eq(0) + expect(result.options).to eq({}) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns a default empty result" do + result = aggregator.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + expect(result.current_usage_units).to eq(0) + expect(result.options).to eq({running_total: []}) + end + end + + context "with events with the same timestamp" do + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 3}, + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 3} + ] + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregation).to eq(6) + expect(result.count).to eq(2) + end + end + + context "when billable metric is recurring" do + let(:billable_metric) { create(:weighted_sum_billable_metric, :recurring, organization:) } + + let(:events_values) { [] } + + let(:cached_aggregation) do + create( + :cached_aggregation, + charge:, + organization:, + external_subscription_id: subscription.external_id, + timestamp: from_datetime - 1.day, + current_aggregation: 1000 + ) + end + + before { cached_aggregation } + + it "uses the persisted recurring value as initial value" do + result = aggregator.aggregate + + expect(result.aggregation.to_s).to eq("1000.0") + expect(result.count).to eq(0) + expect(result.variation).to eq(0) + expect(result.total_aggregated_units).to eq(1000) + expect(result.recurring_updated_at).to eq(from_datetime) + end + + context "without cached_aggregation" do + let(:cached_aggregation) {} + + it "falls back on 0" do + result = aggregator.aggregate + + expect(result.aggregation.round(5).to_s).to eq("0.0") + expect(result.count).to eq(0) + expect(result.variation).to eq(0) + expect(result.total_aggregated_units).to eq(0) + expect(result.recurring_updated_at).to eq(from_datetime) + end + + context "with events attached to a previous subcription" do + let(:previous_subscription) do + create( + :subscription, + :terminated, + started_at: DateTime.parse("2022-01-01 22:22:22"), + terminated_at: DateTime.parse("2023-04-01 22:22:21") + ) + end + + let(:customer) { previous_subscription.customer } + + let(:subscription) do + create( + :subscription, + started_at: DateTime.parse("2023-04-01 22:22:22"), + previous_subscription:, + customer:, + external_id: previous_subscription.external_id + ) + end + + before do + subscription + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription: previous_subscription, + customer:, + timestamp: Time.zone.parse("2023-03-01 22:22:22"), + properties: {value: 10} + ) + end + + it "uses previous events as latest value" do + result = aggregator.aggregate + + expect(result.aggregation.round(5).to_s).to eq("10.0") + expect(result.count).to eq(0) + expect(result.variation).to eq(0) + expect(result.total_aggregated_units).to eq(10) + expect(result.recurring_updated_at).to eq(from_datetime) + end + + context "when using presentation_by" do + let(:presentation_by) { ["region"] } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 2, region: "eu"}, + {timestamp: Time.zone.parse("2023-08-01 01:00:00"), value: 3, region: "eu"} + ] + end + + it "returns aggregations per group" do + result = aggregator.aggregate + + region_eu = result.breakdowns.find { |b| b[:groups]["region"] == "eu" } + expect(region_eu[:value].round(5).to_s).to eq("4.99597") + + region_nil = result.breakdowns.find { |b| b[:groups]["region"].nil? } + expect(region_nil[:value].round(5).to_s).to eq("10.0") + end + end + end + end + + context "with events" do + let(:events_values) do + [ + {timestamp: DateTime.parse("2023-08-01 00:00:00.000"), value: 2}, + {timestamp: DateTime.parse("2023-08-01 01:00:00"), value: 3}, + {timestamp: DateTime.parse("2023-08-01 01:30:00"), value: 1}, + {timestamp: DateTime.parse("2023-08-01 02:00:00"), value: -4}, + {timestamp: DateTime.parse("2023-08-01 04:00:00"), value: -2}, + {timestamp: DateTime.parse("2023-08-01 05:00:00"), value: 10}, + {timestamp: DateTime.parse("2023-08-01 05:30:00"), value: -10} + ] + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregation.round(5).to_s).to eq("1000.02218") + expect(result.count).to eq(7) + expect(result.variation).to eq(0) + expect(result.total_aggregated_units).to eq(1000) + expect(result.recurring_updated_at).to eq("2023-08-01 05:30:00") + end + end + end + + context "with filters" do + let(:matching_filters) { {region: ["europe"]} } + + let(:events_values) do + [ + {timestamp: DateTime.parse("2023-08-01 00:00:00.000"), value: 1000, region: "europe"} + ] + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregation.to_s).to eq("1000.0") + expect(result.count).to eq(1) + end + end + + describe ".grouped_by aggregation" do + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn frodo] } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 2, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-08-01 01:00:00"), value: 3, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-08-01 01:30:00"), value: 1, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-08-01 02:00:00"), value: -4, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-08-01 04:00:00"), value: -2, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-08-01 05:00:00"), value: 10, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-08-01 05:30:00"), value: -10, agent_name: "aragorn"}, + + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 2, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-08-01 01:00:00"), value: 3, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-08-01 01:30:00"), value: 1, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-08-01 02:00:00"), value: -4, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-08-01 04:00:00"), value: -2, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-08-01 05:00:00"), value: 10, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-08-01 05:30:00"), value: -10, agent_name: "frodo"} + ] + end + + it "returns a grouped aggregations" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation.round(5).to_s).to eq("0.02218") + expect(aggregation.count).to eq(7) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + + context "with no events" do + let(:events_values) { [] } + + it "returns an empty result" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "when bypass_aggregation is set to true" do + let(:bypass_aggregation) { true } + + it "returns an empty result" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + end + + context "with events with the same timestamp" do + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 3, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 3, agent_name: "aragorn"}, + + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 3, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-08-01 00:00:00.000"), value: 3, agent_name: "frodo"} + ] + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation.round(5)).to eq(6) + expect(aggregation.count).to eq(2) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + end + + context "when billable metric is recurring" do + let(:billable_metric) { create(:weighted_sum_billable_metric, :recurring, organization:) } + + let(:events_values) { [] } + + let(:cached_aggregations) do + [ + create( + :cached_aggregation, + organization:, + charge:, + external_subscription_id: subscription.external_id, + timestamp: from_datetime - 1.day, + current_aggregation: 1000, + grouped_by: {"agent_name" => "aragorn"} + ), + + create( + :cached_aggregation, + organization:, + charge:, + external_subscription_id: subscription.external_id, + timestamp: from_datetime - 1.day, + current_aggregation: 1000, + grouped_by: {"agent_name" => "frodo"} + ) + ] + end + + before { cached_aggregations } + + it "uses the persisted recurring value as initial value" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation.to_s).to eq("1000.0") + expect(aggregation.count).to eq(0) + expect(aggregation.variation).to eq(0) + expect(aggregation.total_aggregated_units).to eq(1000) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + expect(aggregation.recurring_updated_at).to eq(from_datetime) + end + end + + context "without cached aggregation events" do + let(:cached_aggregations) {} + + it "returns an empty result" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(1) + + aggregation = result.aggregations.first + expect(aggregation.aggregation).to eq(0) + expect(aggregation.count).to eq(0) + expect(aggregation.grouped_by).to eq({"agent_name" => nil}) + end + + context "with events attached to a previous subcription" do + let(:previous_subscription) do + create( + :subscription, + :terminated, + started_at: DateTime.parse("2022-01-01 22:22:22"), + terminated_at: DateTime.parse("2023-04-01 22:22:21") + ) + end + + let(:customer) { previous_subscription.customer } + + let(:subscription) do + create( + :subscription, + started_at: DateTime.parse("2023-04-01 22:22:22"), + previous_subscription:, + customer:, + external_id: previous_subscription.external_id + ) + end + + before do + subscription + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription: previous_subscription, + customer:, + timestamp: Time.zone.parse("2023-03-01 22:22:22"), + properties: {value: 10, agent_name: "aragorn"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + subscription: previous_subscription, + customer:, + timestamp: Time.zone.parse("2023-03-01 22:22:22"), + properties: {value: 10, agent_name: "frodo"} + ) + end + + it "uses previous events as latest value" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation.to_s).to eq("10.0") + expect(aggregation.count).to eq(0) + expect(aggregation.variation).to eq(0) + expect(aggregation.total_aggregated_units).to eq(10) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + expect(aggregation.recurring_updated_at).to eq(from_datetime) + end + end + end + end + + context "with events" do + let(:events_values) do + [ + {timestamp: DateTime.parse("2023-08-01 00:00:00.000"), value: 2, agent_name: "aragorn"}, + {timestamp: DateTime.parse("2023-08-01 01:00:00"), value: 3, agent_name: "aragorn"}, + {timestamp: DateTime.parse("2023-08-01 01:30:00"), value: 1, agent_name: "aragorn"}, + {timestamp: DateTime.parse("2023-08-01 02:00:00"), value: -4, agent_name: "aragorn"}, + {timestamp: DateTime.parse("2023-08-01 04:00:00"), value: -2, agent_name: "aragorn"}, + {timestamp: DateTime.parse("2023-08-01 05:00:00"), value: 10, agent_name: "aragorn"}, + {timestamp: DateTime.parse("2023-08-01 05:30:00"), value: -10, agent_name: "aragorn"}, + + {timestamp: DateTime.parse("2023-08-01 00:00:00.000"), value: 2, agent_name: "frodo"}, + {timestamp: DateTime.parse("2023-08-01 01:00:00"), value: 3, agent_name: "frodo"}, + {timestamp: DateTime.parse("2023-08-01 01:30:00"), value: 1, agent_name: "frodo"}, + {timestamp: DateTime.parse("2023-08-01 02:00:00"), value: -4, agent_name: "frodo"}, + {timestamp: DateTime.parse("2023-08-01 04:00:00"), value: -2, agent_name: "frodo"}, + {timestamp: DateTime.parse("2023-08-01 05:00:00"), value: 10, agent_name: "frodo"}, + {timestamp: DateTime.parse("2023-08-01 05:30:00"), value: -10, agent_name: "frodo"} + ] + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation.round(5).to_s).to eq("1000.02218") + expect(aggregation.count).to eq(7) + expect(aggregation.variation).to eq(0) + expect(aggregation.total_aggregated_units).to eq(1000) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + expect(aggregation.recurring_updated_at).to eq("2023-08-01 05:30:00") + end + end + end + end + + context "with filters" do + let(:matching_filters) { {region: ["europe"]} } + + let(:events_values) do + [ + { + timestamp: DateTime.parse("2023-08-01 00:00:00.000"), + value: 1000, + region: "europe", + agent_name: "aragorn" + }, + {timestamp: DateTime.parse("2023-08-01 00:00:00.000"), value: 1000, region: "europe", agent_name: "frodo"} + ] + end + + it "aggregates the events" do + result = aggregator.aggregate + + expect(result.aggregations.count).to eq(2) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, _index| + expect(aggregation.aggregation.round(5).to_s).to eq("1000.0") + expect(aggregation.count).to eq(1) + end + end + end + end +end diff --git a/spec/services/billable_metrics/breakdown/sum_service_spec.rb b/spec/services/billable_metrics/breakdown/sum_service_spec.rb new file mode 100644 index 0000000..ec75bde --- /dev/null +++ b/spec/services/billable_metrics/breakdown/sum_service_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Breakdown::SumService, transaction: false do + subject(:service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime:, + charges_duration: 31 + }, + filters: { + matching_filters:, + ignored_filters: + } + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary + ) + end + + let(:subscription_at) { Time.zone.parse("2022-12-01 00:00:00") } + let(:started_at) { subscription_at } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:matching_filters) { nil } + let(:ignored_filters) { nil } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "sum_agg", + field_name: "total_count", + recurring: true + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { Time.zone.parse("2023-05-01 00:00:00") } + let(:to_datetime) { Time.zone.parse("2023-05-31 23:59:59") } + + let(:old_events) do + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: subscription.started_at + 3.months, + properties: { + total_count: 2.5 + } + ) + end + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 12 + } + ) + end + + before do + old_events + latest_events + end + + describe "#breakdown" do + let(:result) { service.breakdown.breakdown } + + context "with persisted metric on full period" do + it "returns the detail the persisted metrics" do + expect(result.count).to eq(2) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(5) + expect(item.duration).to eq(31) + expect(item.total_duration).to eq(31) + + item = result.last + expect(item.date.to_s).to eq((from_datetime + 25.days).to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(12) + expect(item.duration).to eq(6) + expect(item.total_duration).to eq(31) + end + + context "when subscription was terminated in the period" do + let(:latest_events) { nil } + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary, + terminated_at: to_datetime, + status: :terminated + ) + end + let(:to_datetime) { Time.zone.parse("2023-05-30 23:59:59") } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(5) + expect(item.duration).to eq(30) + expect(item.total_duration).to eq(31) + end + end + + context "when subscription was started in the period" do + let(:started_at) { Time.zone.parse("2023-05-03") } + let(:old_events) { nil } + let(:from_datetime) { started_at } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq((from_datetime + 25.days).to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(12) + expect(item.duration).to eq(4) + expect(item.total_duration).to eq(31) + end + end + end + end +end diff --git a/spec/services/billable_metrics/breakdown/unique_count_service_spec.rb b/spec/services/billable_metrics/breakdown/unique_count_service_spec.rb new file mode 100644 index 0000000..9961d9c --- /dev/null +++ b/spec/services/billable_metrics/breakdown/unique_count_service_spec.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::Breakdown::UniqueCountService, transaction: false do + subject(:service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime:, + charges_duration: (to_datetime - from_datetime).fdiv(1.day).round + }, + filters: { + matching_filters:, + ignored_filters: + } + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + + let(:organization) { create(:organization) } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + field_name: "unique_id", + recurring: true + ) + end + + let(:plan) do + create( + :plan, + organization: + ) + end + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric: + ) + end + + let(:subscription) do + create( + :subscription, + organization:, + plan:, + started_at:, + subscription_at:, + billing_time: :anniversary + ) + end + + let(:subscription_at) { Time.zone.parse("2022-06-09") } + let(:started_at) { subscription_at } + let(:matching_filters) { nil } + let(:ignored_filters) { nil } + + let(:from_datetime) { Time.zone.parse("2022-07-09 00:00:00 UTC") } + let(:to_datetime) { Time.zone.parse("2022-08-08 23:59:59 UTC") } + + let(:added_at) { from_datetime - 1.month } + let(:removed_at) { nil } + let(:added_event) do + create( + :event, + organization_id: organization.id, + timestamp: added_at, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: {unique_id: "111"} + ) + end + + let(:removed_event) do + next nil unless removed_at + + create( + :event, + organization_id: organization.id, + timestamp: removed_at, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: {unique_id: "111", operation_type: "remove"} + ) + end + + before do + added_event + removed_event + end + + describe "#breakdown" do + let(:result) { service.breakdown.breakdown } + + context "with persisted metric on full period" do + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(31) + expect(item.total_duration).to eq(31) + end + + context "when subscription was terminated in the period" do + let(:subscription) do + create( + :subscription, + organization:, + plan:, + started_at:, + subscription_at:, + billing_time: :anniversary, + terminated_at: to_datetime, + status: :terminated + ) + end + let(:to_datetime) { Time.zone.parse("2022-07-24 23:59:59") } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(16) + expect(item.total_duration).to eq(16) + end + end + + context "when subscription was upgraded in the period" do + let(:subscription) do + create( + :subscription, + organization:, + started_at:, + subscription_at:, + billing_time: :anniversary, + terminated_at: to_datetime, + status: :terminated + ) + end + let(:to_datetime) { Time.zone.parse("2022-07-24 23:59:59") } + + before do + create( + :subscription, + previous_subscription: subscription, + organization:, + plan:, + started_at: to_datetime + ) + end + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(16) + expect(item.total_duration).to eq(16) + end + + context "with calendar subscription and pay in advance" do + let(:subscription) do + create( + :subscription, + organization:, + plan:, + started_at:, + subscription_at:, + billing_time: :calendar, + terminated_at: to_datetime, + status: :terminated + ) + end + + before { subscription.plan.update!(pay_in_advance: true) } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(16) + expect(item.total_duration).to eq(16) + end + end + end + + context "when subscription was started in the period" do + let(:started_at) { Time.zone.parse("2022-08-01") } + let(:from_datetime) { started_at } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(8) + expect(item.total_duration).to eq(8) + end + end + end + + context "with persisted metrics added in the period" do + let(:added_at) { from_datetime + 15.days } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(added_at.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(16) + expect(item.total_duration).to eq(31) + end + + context "when added on the first day of the period" do + let(:added_at) { from_datetime } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(from_datetime.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(31) + expect(item.total_duration).to eq(31) + end + end + end + + context "with persisted metrics terminated in the period" do + let(:removed_at) { to_datetime - 15.days } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(removed_at.to_date.to_s) + expect(item.action).to eq("remove") + expect(item.amount).to eq(1) + expect(item.duration).to eq(16) + expect(item.total_duration).to eq(31) + end + + context "when removed on the last day of the period" do + let(:removed_at) { to_datetime } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(to_datetime.to_date.to_s) + expect(item.action).to eq("remove") + expect(item.amount).to eq(1) + expect(item.duration).to eq(31) + expect(item.total_duration).to eq(31) + end + end + end + + context "with persisted metrics added and terminated in the period" do + let(:added_at) { from_datetime + 1.day } + let(:removed_at) { to_datetime - 1.day } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(added_at.to_date.to_s) + expect(item.action).to eq("add_and_removed") + expect(item.amount).to eq(1) + expect(item.duration).to eq(29) + expect(item.total_duration).to eq(31) + end + + context "when added and removed the same day" do + let(:added_at) { from_datetime + 1.day } + let(:removed_at) { added_at.end_of_day } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(added_at.to_date.to_s) + expect(item.action).to eq("add_and_removed") + expect(item.amount).to eq(1) + expect(item.duration).to eq(1) + expect(item.total_duration).to eq(31) + end + end + + context "when added, removed and added again" do + let(:added_at) { from_datetime + 1.day } + let(:removed_at) { added_at.end_of_day } + let(:new_event) do + create( + :event, + organization_id: organization.id, + timestamp: to_datetime - 1.day, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: {unique_id: "111"} + ) + end + + before { new_event } + + it "returns the detail the persisted metrics" do + expect(result.count).to eq(1) + + item = result.first + expect(item.date.to_s).to eq(added_at.to_date.to_s) + expect(item.action).to eq("add") + expect(item.amount).to eq(1) + expect(item.duration).to eq(3) + expect(item.total_duration).to eq(31) + end + end + end + end +end diff --git a/spec/services/billable_metrics/create_service_spec.rb b/spec/services/billable_metrics/create_service_spec.rb new file mode 100644 index 0000000..8c8321a --- /dev/null +++ b/spec/services/billable_metrics/create_service_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::CreateService do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "create" do + before do + allow(SegmentTrackJob).to receive(:perform_later) + end + + let(:create_args) do + { + name: "New Metric", + code: "new_metric", + description: "New metric description", + organization_id: organization.id, + aggregation_type: "count_agg", + expression: "1 + 2", + rounding_function: "ceil", + rounding_precision: 2, + recurring: false + } + end + + it "creates a billable metric" do + expect { described_class.call(create_args) } + .to change(BillableMetric, :count).by(1) + end + + context "with code already used by a deleted metric" do + it "creates a billable metric with the same code" do + create(:billable_metric, organization:, code: "new_metric", deleted_at: Time.current) + + expect { described_class.call(create_args) } + .to change(BillableMetric, :count).by(1) + + metrics = organization.billable_metrics.with_discarded + expect(metrics.count).to eq(2) + expect(metrics.pluck(:code).uniq).to eq(["new_metric"]) + end + end + + context "with filters arguments" do + let(:create_args) do + { + name: "New Metric", + code: "new_metric", + description: "New metric description", + organization_id: organization.id, + aggregation_type: "count_agg", + recurring: false, + filters: + } + end + + let(:filters) do + [ + { + key: "cloud", + values: %w[aws google] + } + ] + end + + it "creates billable metric's filters" do + expect { described_class.call(create_args) } + .to change(BillableMetricFilter, :count).by(1) + end + + context "with invalid filters" do + let(:filters) { [{key: "foo"}] } + + it "returns an error if a filter is invalid" do + result = described_class.call(create_args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:values]).to eq(["value_is_mandatory"]) + end + end + end + + it "calls SegmentTrackJob" do + metric = described_class.call(create_args).billable_metric + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "billable_metric_created", + properties: { + code: metric.code, + name: metric.name, + description: metric.description, + aggregation_type: metric.aggregation_type, + aggregation_property: metric.field_name, + organization_id: metric.organization_id + } + ) + end + + it "produces an activity log" do + metric = described_class.call(create_args).billable_metric + + expect(Utils::ActivityLog).to have_produced("billable_metric.created").after_commit.with(metric) + end + + context "with validation error" do + before do + create( + :billable_metric, + code: create_args[:code], + organization: membership.organization + ) + end + + it "returns an error" do + result = described_class.call(create_args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + + context "with custom aggregation" do + let(:create_args) do + { + name: "New Metric", + code: "new_metric", + description: "New metric description", + organization_id: organization.id, + aggregation_type: "custom_agg", + recurring: false + } + end + + it "returns a forbidden failure" do + result = described_class.call(create_args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end +end diff --git a/spec/services/billable_metrics/destroy_service_spec.rb b/spec/services/billable_metrics/destroy_service_spec.rb new file mode 100644 index 0000000..c5105d2 --- /dev/null +++ b/spec/services/billable_metrics/destroy_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::DestroyService do + subject(:destroy_service) { described_class.new(metric: billable_metric) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:subscription) { create(:subscription) } + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:) } + + before do + charge + + allow(BillableMetrics::DeleteEventsJob).to receive(:perform_later).and_call_original + allow(Invoices::RefreshDraftService).to receive(:call) + end + + describe "#call" do + it "soft deletes the billable metric" do + freeze_time do + expect { destroy_service.call }.to change(BillableMetric, :count).by(-1) + .and change { billable_metric.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "soft deletes all the related charges" do + freeze_time do + expect { destroy_service.call }.to change { charge.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "soft deletes all the related alerts" do + alert = create(:billable_metric_current_usage_amount_alert, billable_metric:, organization:) + freeze_time do + expect { destroy_service.call }.to change { alert.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "enqueues a BillableMetricFilters::DestroyAllJob" do + expect { destroy_service.call } + .to have_enqueued_job(BillableMetricFilters::DestroyAllJob).with(billable_metric.id) + end + + it "enqueues a BillableMetrics::DeleteEventsJob" do + expect do + destroy_service.call + end.to have_enqueued_job(BillableMetrics::DeleteEventsJob).with(billable_metric) + end + + it "marks invoice as ready to be refreshed" do + invoice = create(:invoice, :draft) + create(:invoice_subscription, subscription:, invoice:) + + expect { destroy_service.call }.to change { invoice.reload.ready_to_be_refreshed }.to(true) + end + + context "when billable metric is not found" do + it "returns an error" do + result = described_class.new(metric: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("billable_metric_not_found") + end + end + end + + describe ".call" do + it "produces an activity log" do + described_class.call(metric: billable_metric) + + expect(Utils::ActivityLog).to have_produced("billable_metric.deleted").after_commit.with(billable_metric) + end + end +end diff --git a/spec/services/billable_metrics/evaluate_expression_service_spec.rb b/spec/services/billable_metrics/evaluate_expression_service_spec.rb new file mode 100644 index 0000000..2cb18c7 --- /dev/null +++ b/spec/services/billable_metrics/evaluate_expression_service_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::EvaluateExpressionService do + subject(:evaluate_service) { described_class.new(expression:, event:) } + + let(:expression) { "round(event.properties.value * event.properties.units)" } + let(:event) do + { + "code" => "test_code", + "timestamp" => Time.current.to_i, + "properties" => { + "value" => 10.4, + "units" => 2 + } + } + end + + describe "#call" do + it "returns the result of the evaluated expression" do + result = evaluate_service.call + + expect(result).to be_success + expect(result.evaluation_result).to eq(21.0) + end + + context "when the expression is missing" do + let(:expression) { nil } + + it "returns a validation error" do + result = evaluate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:expression]).to include("value_is_mandatory") + end + end + + context "when the expression is invalid" do + let(:expression) { "invalid_expression" } + + it "returns a validation error" do + result = evaluate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:expression]).to include("invalid_expression") + end + end + + context "when timestamp is missing" do + let(:expression) { "event.timestamp" } + let(:event) do + { + "code" => "test_code", + "properties" => {} + } + end + + it "uses current time as fallback" do + freeze_time do + result = evaluate_service.call + + expect(result).to be_success + expect(result.evaluation_result).to eq(Time.current.to_i) + end + end + end + + context "when the event failed to evaluate" do + let(:event) do + { + "code" => "test_code", + "timestamp" => Time.current.to_i, + "properties" => { + "value" => "invalid_value", + "units" => 2 + } + } + end + + it "returns a validation error" do + result = evaluate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:event]).to include("invalid_event") + end + + context "when the event is missing" do + let(:event) { nil } + + it "returns a validation error" do + result = evaluate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:event]).to include("invalid_event") + end + end + end + end +end diff --git a/spec/services/billable_metrics/prorated_aggregations/sum_service_spec.rb b/spec/services/billable_metrics/prorated_aggregations/sum_service_spec.rb new file mode 100644 index 0000000..9c5cc7c --- /dev/null +++ b/spec/services/billable_metrics/prorated_aggregations/sum_service_spec.rb @@ -0,0 +1,940 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::ProratedAggregations::SumService, transaction: false do + subject(:sum_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime:, + charges_duration: 31 + }, + filters: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:filters) { {event: pay_in_advance_event, grouped_by:, presentation_by:, matching_filters:, ignored_filters:} } + + let(:subscription) { create(:subscription, started_at: Time.zone.parse("2022-12-01 00:00:00")) } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:presentation_by) { nil } + let(:matching_filters) { {} } + let(:ignored_filters) { [] } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "sum_agg", + field_name: "total_count", + recurring: true + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { Time.zone.parse("2023-05-01 00:00:00") } + let(:to_datetime) { Time.zone.parse("2023-05-31 23:59:59") } + let(:pay_in_advance_event) { nil } + let(:options) { {} } + + let(:old_events) do + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: subscription.started_at + 3.months, + properties: { + total_count: 2.5 + } + ) + end + let(:latest_events) do + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 12 + } + ) + end + + before do + old_events + latest_events + end + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(9.64517) # 5 + (12*6/31) + (12*6/31) + expect(result.pay_in_advance_aggregation).to be_zero + expect(result.count).to eq(4) + end + + context "with presentation group keys" do + let(:presentation_by) { ["cloud"] } + + let(:latest_events) do + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 12, + cloud: "aws" + } + ) + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 7, + cloud: "gcp" + } + ) + ] + end + + it "returns the presentation breakdowns" do + result = sum_service.aggregate(options:) + + aws = result.breakdowns.find { |b| b[:groups]["cloud"] == "aws" } + gcp = result.breakdowns.find { |b| b[:groups]["cloud"] == "gcp" } + + expect(aws[:value]).to eq(24) + expect(gcp[:value]).to eq(7) + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + let(:latest_events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 12, + agent_name: "aragorn", + cloud: "aws" + } + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 7, + agent_name: "aragorn", + cloud: "gcp" + } + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 3, + agent_name: "frodo", + cloud: "aws" + } + ) + ] + end + + it "returns the presentation breakdowns per group" do + result = sum_service.aggregate(options:) + + aragorn_aws = result.breakdowns.find { |b| b[:groups] == {"agent_name" => "aragorn", "cloud" => "aws"} } + aragorn_gcp = result.breakdowns.find { |b| b[:groups] == {"agent_name" => "aragorn", "cloud" => "gcp"} } + frodo_aws = result.breakdowns.find { |b| b[:groups] == {"agent_name" => "frodo", "cloud" => "aws"} } + + expect(aragorn_aws[:value]).to eq(12) + expect(aragorn_gcp[:value]).to eq(7) + expect(frodo_aws[:value]).to eq(3) + end + end + end + + context "when aggregation is performed on billing date for pay in advance case" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: false} + end + + it "aggregates the events without proration" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(29) + expect(result.pay_in_advance_aggregation).to be_zero + expect(result.count).to eq(4) + end + end + + context "when events are out of bounds" do + let(:latest_events) do + create_list( + :event, + 4, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime + 1.day, + properties: { + total_count: 12 + } + ) + end + + it "does not take events into account" do + result = sum_service.aggregate + + expect(result.aggregation).to eq(5) + expect(result.count).to eq(2) + end + end + + context "when properties is not found on events" do + before do + billable_metric.update!(field_name: "foo_bar") + end + + it "counts as zero" do + result = sum_service.aggregate + + expect(result.aggregation).to eq(0) + expect(result.count).to eq(0) + end + end + + context "when properties is a float" do + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 30.days, + properties: { + total_count: 4.5 + } + ) + end + + it "aggregates the events" do + result = sum_service.aggregate + + expect(result.aggregation).to eq(9.64517 + 0.14516) + end + end + + context "when properties is not a number" do + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 30.days, + properties: { + total_count: "foo_bar" + } + ) + end + + it "ignores the event" do + result = sum_service.aggregate + + expect(result).to be_success + expect(result.aggregation).to eq(9.64517) # 5 + (12*6/31) + (12*6/31) + expect(result.count).to eq(4) + end + end + + context "when current usage context and charge is pay in arrear" do + let(:options) do + {is_pay_in_advance: false, is_current_usage: true} + end + + it "returns period maximum as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(9.64517) + expect(result.current_usage_units).to eq(29) + end + + context "when rounding is configured" do + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "sum_agg", + field_name: "total_count", + recurring: true, + rounding_function: "ceil", + rounding_precision: 2 + ) + end + + before do + latest_events.last.update!(properties: {total_count: 12.434}) + end + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(9.73) + expect(result.current_usage_units).to eq(29.44) + end + end + end + + context "when current usage context and charge is pay in advance" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 3.days, + properties: { + total_count: 4 + } + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization: billable_metric.organization, + charge:, + external_subscription_id: subscription.external_id, + event_transaction_id: latest_events.transaction_id, + timestamp: latest_events.timestamp, + current_aggregation: "4", + max_aggregation: "6", + max_aggregation_with_proration: "3.8" + ) + end + + before { cached_aggregation } + + it "returns period maximum as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(8.8) + expect(result.current_usage_units).to eq(9) + end + + context "when cached aggregation does not exist" do + let(:latest_events) { nil } + let(:cached_aggregation) { nil } + + it "returns zero as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(5) + expect(result.current_usage_units).to eq(5) + end + end + end + + context "when current usage context and charge is pay in advance and just upgraded" do + let(:from_datetime) { Time.zone.parse("2023-05-15 00:00:00") } + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + let(:latest_events) { nil } + + it "returns correct values" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq((5 * 17.fdiv(31)).ceil(5)) + expect(result.current_usage_units).to eq(5) + end + end + + context "when current usage context and charge is pay in advance and just upgraded and new event in period" do + let(:from_datetime) { Time.zone.parse("2023-05-15 00:00:00") } + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 10.days, + properties: { + total_count: 4 + } + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization: billable_metric.organization, + charge:, + external_subscription_id: subscription.external_id, + event_transaction_id: latest_events.transaction_id, + timestamp: latest_events.timestamp, + current_aggregation: "4", + max_aggregation: "6", + max_aggregation_with_proration: "3.8" + ) + end + + before { cached_aggregation } + + it "returns correct values" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq((5 * 17.fdiv(31)).ceil(5) + 3.8) + expect(result.current_usage_units).to eq(9) + end + end + + context "when filters are given" do + let(:matching_filters) { {region: ["europe"]} } + + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 30.days, + properties: { + total_count: 12, + region: "europe" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 30.days, + properties: { + total_count: 8, + region: "europe" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 30.days, + properties: { + total_count: 12, + region: "africa" + } + ) + end + + it "aggregates the events" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(0.64517) # (1/31 * 8) + (1/31 * 12) + expect(result.count).to eq(2) + end + end + + context "when subscription was upgraded in the period" do + let(:old_subscription) do + create( + :subscription, + external_id: subscription.external_id, + organization:, + customer:, + started_at: from_datetime - 10.days, + terminated_at: from_datetime, + status: :terminated + ) + end + + before do + old_subscription + subscription.update!(previous_subscription: old_subscription) + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription: old_subscription, + timestamp: from_datetime - 5.days, + properties: { + total_count: 10 + } + ) + end + + it "returns the correct number" do + result = sum_service.aggregate(options:) + + expect(result.aggregation).to eq(19.64517) # 10 + 5 + (6/31*12) + (6/31*12) + end + end + + context "when event is given" do + let(:old_events) { nil } + let(:latest_events) { nil } + let(:pay_in_advance_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 29.days, + properties: + ) + end + + let(:properties) { {total_count: 10} } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(0.64517) + end + + context "when current period aggregation is greater than period maximum" do + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 28.days, + properties: { + total_count: -6 + } + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization: billable_metric.organization, + charge:, + external_subscription_id: subscription.external_id, + event_transaction_id: latest_events.transaction_id, + timestamp: latest_events.timestamp, + current_aggregation: "4", + max_aggregation: "10", + max_aggregation_with_proration: "3.2" + ) + end + + before { cached_aggregation } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(0.25807) # 4 * (2/31) + end + end + + context "when current period aggregation is less than period maximum" do + let(:properties) { {total_count: -2} } + let(:latest_events) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 28.days, + properties: { + total_count: -6 + } + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization: billable_metric.organization, + charge:, + external_subscription_id: subscription.external_id, + event_transaction_id: latest_events.transaction_id, + timestamp: latest_events.timestamp, + current_aggregation: "4", + max_aggregation: "10", + max_aggregation_with_proration: "3.2" + ) + end + + before { cached_aggregation } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(0) + expect(result.units_applied).to eq("-2") + end + end + + context "when properties is a float" do + let(:properties) { {total_count: 12.4} } + + it "assigns a pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to eq(0.8) # 2/31*12.4 + end + end + + context "when event property does not match metric field name" do + let(:properties) { {final_count: 10} } + + it "assigns 0 as pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to be_zero + end + end + + context "when event is missing properties" do + let(:properties) { {} } + + it "assigns 0 as pay_in_advance aggregation" do + result = sum_service.aggregate + + expect(result.pay_in_advance_aggregation).to be_zero + end + end + end + + describe ".per_event_aggregation" do + it "aggregates per events" do + sum_service.options = {} + result = sum_service.per_event_aggregation + + expect(result.event_aggregation).to eq([5, 12, 12]) + expect(result.event_prorated_aggregation.map { |el| el.round(5) }).to eq([5, 2.32258, 2.32258]) + end + + context "with grouped_by_values" do + let(:old_event) { old_events.first } + let(:latest_event) { latest_events.last } + + before do + latest_event.update!(properties: latest_event.properties.merge(scheme: "visa")) + old_event.update!(properties: old_event.properties.merge(scheme: "visa")) + end + + it "takes the groups into account" do + sum_service.options = {} + result = sum_service.per_event_aggregation(grouped_by_values: {"scheme" => "visa"}) + + expect(result.event_aggregation).to eq([5, 12]) + expect(result.event_prorated_aggregation.map { |el| el.round(5) }).to eq([5, 2.32258]) + end + end + end + + describe ".grouped_by aggregation" do + let(:grouped_by) { ["agent_name"] } + + let(:agent_names) { %w[aragorn frodo gimli legolas] } + + let(:old_events) do + agent_names.map do |agent_name| + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: subscription.started_at + 3.months, + properties: { + total_count: 2.5, + agent_name: + } + ) + end.flatten + end + + let(:latest_events) do + agent_names.map do |agent_name| + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 12, + agent_name: + } + ) + end.flatten + end + + it "returns a grouped aggregations" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(4) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(9.64517) # 5 + (12*6/31) + (12*6/31) + expect(aggregation.count).to eq(4) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + + context "when current usage" do + let(:options) { {is_pay_in_advance:, is_current_usage: true} } + let(:is_pay_in_advance) { false } + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn] } + + let(:old_events) do + agent_names.map do |agent_name| + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: subscription.started_at + 3.months, + properties: { + total_count: 2.5, + agent_name: + } + ) + end.flatten + end + + let(:latest_events) do + agent_names.map do |agent_name| + create_list( + :event, + 2, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: from_datetime + 25.days, + properties: { + total_count: 12, + agent_name: + } + ) + end.flatten + end + + context "when charge is pay in arrear" do + it "returns period maximum as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(9.64517) # 5 + (12*6/31) + (12*6/31) + expect(aggregation.count).to eq(4) + expect(aggregation.current_usage_units).to eq(29) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + end + + context "when charge is pay in advance" do + let(:is_pay_in_advance) { true } + + let(:latest_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 3.days, + properties: { + total_count: 4, + agent_name: + } + ) + end.flatten + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization: billable_metric.organization, + charge:, + external_subscription_id: subscription.external_id, + event_transaction_id: latest_events.first.transaction_id, + timestamp: latest_events.first.timestamp, + current_aggregation: "4", + max_aggregation: "6", + max_aggregation_with_proration: "3.8", + grouped_by: {"agent_name" => agent_names.first} + ) + end + + before { cached_aggregation } + + it "returns period maximum as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(8.8) + expect(aggregation.current_usage_units).to eq(9) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + + context "when cached aggregation does not exist" do + let(:latest_events) { nil } + let(:cached_aggregation) { nil } + + it "returns zero as aggregation" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq(5) + expect(aggregation.current_usage_units).to eq(5) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + end + end + + context "when charge is pay in advance and just upgraded" do + let(:from_datetime) { Time.zone.parse("2023-05-15 00:00:00") } + let(:is_pay_in_advance) { true } + let(:latest_events) { nil } + + it "returns correct values" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq((5 * 17.fdiv(31)).ceil(5)) + expect(aggregation.current_usage_units).to eq(5) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + end + + context "when charge is pay in advance and just upgraded and new event in period" do + let(:from_datetime) { Time.zone.parse("2023-05-15 00:00:00") } + let(:is_pay_in_advance) { true } + + let(:latest_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + customer:, + subscription:, + timestamp: to_datetime - 10.days, + properties: { + total_count: 4, + agent_name: + } + ) + end.flatten + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization: billable_metric.organization, + charge:, + external_subscription_id: subscription.external_id, + event_transaction_id: latest_events.first.transaction_id, + timestamp: latest_events.first.timestamp, + current_aggregation: "4", + max_aggregation: "6", + max_aggregation_with_proration: "3.8", + grouped_by: {"agent_name" => agent_names.first} + ) + end + + before { cached_aggregation } + + it "returns correct values" do + result = sum_service.aggregate(options:) + + expect(result.aggregations.count).to eq(1) + + result.aggregations.sort_by { |a| a.grouped_by["agent_name"] }.each_with_index do |aggregation, index| + expect(aggregation.aggregation).to eq((5 * 17.fdiv(31)).ceil(5) + 3.8) + expect(aggregation.current_usage_units).to eq(9) + expect(aggregation.grouped_by["agent_name"]).to eq(agent_names[index]) + end + end + end + end + end +end diff --git a/spec/services/billable_metrics/prorated_aggregations/unique_count_service_spec.rb b/spec/services/billable_metrics/prorated_aggregations/unique_count_service_spec.rb new file mode 100644 index 0000000..e32675a --- /dev/null +++ b/spec/services/billable_metrics/prorated_aggregations/unique_count_service_spec.rb @@ -0,0 +1,1340 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::ProratedAggregations::UniqueCountService, transaction: false do + subject(:unique_count_service) do + described_class.new( + event_store_class:, + charge:, + subscription:, + boundaries: { + from_datetime:, + to_datetime:, + charges_duration: 31 + }, + filters: + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + let(:filters) { {event: pay_in_advance_event, grouped_by:, presentation_by:, matching_filters:, ignored_filters:} } + + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary + ) + end + + let(:pay_in_advance_event) { nil } + let(:options) { {} } + let(:subscription_at) { Time.zone.parse("2022-06-09") } + let(:started_at) { subscription_at } + let(:organization) { subscription.organization } + let(:customer) { subscription.customer } + let(:grouped_by) { nil } + let(:presentation_by) { nil } + let(:matching_filters) { nil } + let(:ignored_filters) { nil } + + let(:billable_metric) do + create( + :billable_metric, + organization:, + aggregation_type: "unique_count_agg", + field_name: "unique_id", + recurring: true + ) + end + + let(:charge) do + create( + :standard_charge, + billable_metric: + ) + end + + let(:from_datetime) { Time.zone.parse("2022-07-09 00:00:00 UTC") } + let(:to_datetime) { Time.zone.parse("2022-08-08 23:59:59 UTC") } + + let(:added_at) { from_datetime - 1.month } + let(:event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: SecureRandom.uuid} + ) + end + + before { event } + + describe "#aggregate" do + let(:result) { unique_count_service.aggregate(options:) } + + context "with presentation group keys" do + let(:presentation_by) { ["cloud"] } + + let(:event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "001", cloud: "aws"} + ) + end + + let(:new_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: "002", cloud: "gcp"} + ) + end + + before { new_event } + + it "returns the presentation breakdowns" do + expect(result.breakdowns).to match_array([ + {groups: {"cloud" => "aws"}, value: 1}, + {groups: {"cloud" => "gcp"}, value: 1} + ]) + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + let(:event) { nil } + + let(:unique_count_events) do + [ + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "003", agent_name: "frodo", cloud: "aws"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: "004", agent_name: "frodo", cloud: "gcp"} + ), + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "005", agent_name: "aragorn", cloud: "aws"} + ) + ] + end + + before { unique_count_events } + + it "returns the presentation breakdowns per group" do + expect(result.breakdowns).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 1}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 1}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 1}, + {groups: {"agent_name" => nil, "cloud" => "gcp"}, value: 1} + ]) + end + end + end + + context "with persisted metric on full period" do + it "returns the number of persisted metric" do + expect(result.aggregation).to eq(1) + end + + context "when there is persisted event and event added in period" do + let(:new_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid} + ) + end + + before { new_event } + + it "returns the correct number" do + expect(result.aggregation).to eq((1 + 21.fdiv(31)).ceil(5)) + end + end + + context "when subscription was terminated in the period" do + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary, + terminated_at: to_datetime, + status: :terminated + ) + end + let(:to_datetime) { Time.zone.parse("2022-07-24 23:59:59") } + + it "returns the prorata of the full duration" do + expect(result.aggregation).to eq(16.fdiv(31).ceil(5)) + end + end + + context "when subscription was upgraded in the period" do + let(:subscription) do + create( + :subscription, + started_at:, + subscription_at:, + billing_time: :anniversary, + terminated_at: Time.zone.parse("2022-07-24 12:59:59"), + status: :terminated + ) + end + let(:to_datetime) { Time.zone.parse("2022-07-23 23:59:59") } + + before do + create( + :subscription, + previous_subscription: subscription, + organization:, + customer:, + started_at: Time.zone.parse("2022-07-24 12:59:59") + ) + end + + it "returns the prorata of the full duration" do + expect(result.aggregation).to eq(15.fdiv(31).ceil(5)) + end + end + + context "when subscription was started in the period" do + let(:started_at) { Time.zone.parse("2022-08-01") } + let(:from_datetime) { started_at } + + it "returns the prorata of the full duration" do + expect(result.aggregation).to eq(8.fdiv(31).ceil(5)) + end + end + + context "when filters are used" do + let(:event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "111", region: "europe"} + ) + end + + let(:matching_filters) { {region: ["europe"]} } + + it "returns the number of persisted metric" do + expect(result.aggregation).to eq(1) + end + end + + context "when plan is pay in advance" do + before do + subscription.plan.update!(pay_in_advance: true) + end + + it "returns the number of persisted metric" do + expect(result.aggregation).to eq(1) + end + end + end + + context "with persisted metrics added in the period" do + let(:event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 15.days, + properties: {unique_id: SecureRandom.uuid} + ) + end + + it "returns the prorata of the full duration" do + expect(result.aggregation).to eq(16.fdiv(31).ceil(5)) + end + + context "when added on the first day of the period" do + let(:event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime, + properties: {unique_id: SecureRandom.uuid} + ) + end + + it "returns the full duration" do + expect(result.aggregation).to eq(1) + end + end + end + + context "with persisted metrics terminated in the period" do + it "returns the prorata of the full duration" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 15.days, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(16.fdiv(31).ceil(5)) + end + + context "when removed on the last day of the period" do + it "returns the full duration" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(1) + end + end + end + + context "with persisted metrics added and terminated in the period" do + let(:added_at) { from_datetime + 1.day } + + it "returns the prorata of the full duration" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 1.day, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(29.fdiv(31).ceil(5)) + end + + context "when added and removed the same day multiple times" do + let(:added_at) { from_datetime + 1.hour } + + it "returns a 1 day duration" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 1.hour, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 2.hours, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "add" + } + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 3.hours, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + expect(result.aggregation).to eq(1.fdiv(31).ceil(5)) + end + + context "when added and removed the same day multiple days" do + it "returns the correct number" do + events_params = [ + {timestamp: added_at, id: event.properties["unique_id"], operation_type: "add"}, + {timestamp: added_at + 1.hour, id: event.properties["unique_id"], operation_type: "remove"}, + {timestamp: added_at + 5.days, id: event.properties["unique_id"], operation_type: "add"}, + {timestamp: added_at + 5.days + 1.hour, id: event.properties["unique_id"], operation_type: "remove"}, + {timestamp: added_at + 10.days, id: event.properties["unique_id"], operation_type: "add"}, + {timestamp: added_at + 10.days + 1.hour, id: event.properties["unique_id"], operation_type: "remove"} + ] + + events_params.each do |event_params| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: event_params[:timestamp], + properties: {unique_id: event_params[:id], operation_type: event_params[:operation_type]} + ) + end + expect(result.aggregation).to eq(3.fdiv(31).ceil(5)) + end + end + end + end + + context "when current usage context and charge is pay in arrear" do + let(:options) do + {is_pay_in_advance: false, is_current_usage: true} + end + + it "returns correct result" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid} + ) + + expect(result.aggregation).to eq((1 + 21.fdiv(31)).ceil(5)) + expect(result.current_usage_units).to eq(2) + end + + context "when added and removed several times a day during multiple days" do + it "returns the correct result" do + # 0 day: add (month ago - 1 day) + # 1st day: add, add, remove, add + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 1.day, + properties: {unique_id: event.properties["unique_id"]} + ) + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 1.day + 1.hour, + properties: {unique_id: event.properties["unique_id"]} + ) + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 1.day + 2.hours, + properties: {unique_id: event.properties["unique_id"], operation_type: "remove"} + ) + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 1.day + 3.hours, + properties: {unique_id: event.properties["unique_id"]} + ) + + # 3rd day: add, remove + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 3.days + 1.hour, + properties: {unique_id: event.properties["unique_id"]} + ) + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 3.days + 2.hours, + properties: {unique_id: event.properties["unique_id"], operation_type: "remove"} + ) + + expect(result.aggregation).to eq(4.fdiv(31).ceil(5)) + # NOTE: current_usage_units is 0 because there are no "active" events in the period + expect(result.current_usage_units).to eq(0) + end + end + end + + context "when current usage context and charge is pay in advance" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + + let(:previous_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: {unique_id: "000"} + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: previous_event.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + current_aggregation: "1", + max_aggregation: "1", + max_aggregation_with_proration: "0.8" + ) + end + + before { cached_aggregation } + + it "returns period maximum as aggregation" do + expect(result.aggregation).to eq(1.8) + expect(result.current_usage_units).to eq(2) + end + + context "when cached aggregation does not exist" do + let(:cached_aggregation) { nil } + + it "returns only the past aggregation" do + expect(result.aggregation).to eq(1) + expect(result.current_usage_units).to eq(1) + end + end + end + + context "when event is given" do + let(:properties) { {unique_id: SecureRandom.uuid} } + let(:pay_in_advance_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: + ) + end + + before { pay_in_advance_event } + + it "assigns an pay_in_advance aggregation" do + expect(result.pay_in_advance_aggregation).to eq(21.fdiv(31).ceil(5)) + end + + context "when event is missing properties" do + let(:properties) { {} } + + it "assigns 0 as pay_in_advance aggregation" do + expect(result.pay_in_advance_aggregation).to be_zero + end + end + + context "when current period aggregation is greater than period maximum" do + let(:previous_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: {unique_id: "000"} + ) + end + + before { previous_event } + + it "assigns a pay_in_advance aggregation" do + expect(result.pay_in_advance_aggregation).to eq(21.fdiv(31).ceil(5)) + end + end + + context "when current period aggregation is less than period maximum" do + let(:previous_event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: {unique_id: "000"} + ) + end + + let(:cached_aggregation) do + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: previous_event.transaction_id, + external_subscription_id: subscription.external_id, + timestamp: previous_event.timestamp, + current_aggregation: "4", + max_aggregation: "7", + max_aggregation_with_proration: "5.8" + ) + end + + before { cached_aggregation } + + it "assigns a pay_in_advance aggregation" do + expect(result.pay_in_advance_aggregation).to eq(0) + expect(result.units_applied).to eq(1) + end + end + end + end + + describe "#grouped_by_aggregation" do + let(:result) { unique_count_service.aggregate(options:) } + let(:grouped_by) { ["agent_name"] } + let(:agent_names) { %w[aragorn frodo] } + let(:event) { nil } + + let(:events) do + agent_names.each do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "000", agent_name:} + ) + end + end + + before { events } + + context "with persisted metric on full period" do + it "returns the number of persisted metric" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.count).to eq(1) + expect(aggregation.aggregation).to eq(1) + expect(aggregation.full_units_number).to eq(1) + end + end + + context "when there is persisted event and event added in period" do + let(:new_events) do + agent_names.each do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid, agent_name:} + ) + end + end + + before { new_events } + + it "returns the correct number" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq((1 + 21.fdiv(31)).ceil(5)) + expect(aggregation.full_units_number).to eq(2) + end + end + end + + context "when filters are used" do + let(:events) do + agent_names.each do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "111", region: "europe", agent_name:} + ) + end + end + + let(:matching_filters) { {region: ["europe"]} } + + it "returns the number of persisted metric" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(1) + expect(aggregation.full_units_number).to eq(1) + end + end + end + end + + context "with persisted metrics added in the period" do + let(:added_at) { from_datetime + 15.days } + + it "returns the prorata of the full duration" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(16.fdiv(31).ceil(5)) + expect(aggregation.full_units_number).to eq(1) + end + end + + context "when added on the first day of the period" do + let(:added_at) { from_datetime } + + it "returns the full duration" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(1) + expect(aggregation.full_units_number).to eq(1) + end + end + end + end + + context "with persisted metrics terminated in the period" do + it "returns the prorata of the full duration" do + agent_names.each do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 15.days, + properties: {unique_id: "000", region: "europe", agent_name:, operation_type: "remove"} + ) + end + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(16.fdiv(31).ceil(5)) + end + end + + context "when removed on the last day of the period" do + it "returns the full duration" do + agent_names.each do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime, + properties: {unique_id: "111", region: "europe", agent_name:, operation_type: "remove"} + ) + end + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(1) + end + end + end + + context "when added and removed the same day multiple times" do + it "does not count the same day multiple times" do + times_and_actions = [ + [from_datetime + 1.day, "add"], + [from_datetime + 1.day + 1.hour, "remove"], + [from_datetime + 1.day + 2.hours, "add"], + [from_datetime + 1.day + 2.hours + 1.second, "remove"], + [from_datetime + 1.day + 3.hours, "add"], + [from_datetime + 2.days, "remove"], + [from_datetime + 2.days + 1.hour, "add"], + [from_datetime + 2.days + 2.hours, "remove"] # 2022-07-11 + ] + agent_names.each do |agent_name| + times_and_actions.each do |timestamp, action| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp:, + properties: {unique_id: "000", agent_name:, operation_type: action} + ) + end + end + + # aggregated by agents + expect(result.aggregations.count).to eq(2) + + # As result of all merged events we have this table: + # [ + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-06-09T00:00:00.000Z", "operation_type" => "add", "rn" => 1, "is_ignored" => false, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-06-09T00:00:00.000Z", "operation_type" => "add", "rn" => 1, "is_ignored" => false, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-07-10T01:00:00.000Z", "operation_type" => "remove", "rn" => 2, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-07-10T01:00:00.000Z", "operation_type" => "remove", "rn" => 2, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-07-10T02:00:00.000Z", "operation_type" => "add", "rn" => 3, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-07-10T02:00:00.000Z", "operation_type" => "add", "rn" => 3, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-07-10T02:00:01.000Z", "operation_type" => "remove", "rn" => 4, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-07-10T02:00:01.000Z", "operation_type" => "remove", "rn" => 4, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-07-10T03:00:00.000Z", "operation_type" => "add", "rn" => 5, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-07-10T03:00:00.000Z", "operation_type" => "add", "rn" => 5, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-07-11T00:00:00.000Z", "operation_type" => "remove", "rn" => 6, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-07-11T00:00:00.000Z", "operation_type" => "remove", "rn" => 6, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-07-11T01:00:00.000Z", "operation_type" => "add", "rn" => 7, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-07-11T01:00:00.000Z", "operation_type" => "add", "rn" => 7, "is_ignored" => true, "previous_not_ignored_operation_type" => "add"}, + # {"g_0" => "aragorn", "property" => "000", "timestamp" => "2022-07-11T02:00:00.000Z", "operation_type" => "remove", "rn" => 8, "is_ignored" => false, "previous_not_ignored_operation_type" => "remove"}, + # {"g_0" => "frodo", "property" => "000", "timestamp" => "2022-07-11T02:00:00.000Z", "operation_type" => "remove", "rn" => 8, "is_ignored" => false, "previous_not_ignored_operation_type" => "remove"} + # ] + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(3.fdiv(31).ceil(5)) # subscription starts on 9th, so we have 3 days of usage + end + end + end + end + + context "with persisted metrics added and terminated in the period" do + let(:added_at) { from_datetime + 1.day } + + it "returns the prorata of the full duration" do + agent_names.each do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 1.day, + properties: {unique_id: "000", agent_name:, operation_type: "remove"} + ) + end + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(29.fdiv(31).ceil(5)) + end + end + + context "when added and removed the same day" do + let(:added_at) { from_datetime + 1.day } + + it "returns a 1 day duration" do + agent_names.each do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at.end_of_day, + properties: {unique_id: "000", agent_name:, operation_type: "remove"} + ) + end + + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(1.fdiv(31).ceil(5)) + end + end + end + end + + context "when current usage context and charge is pay in arrear" do + let(:options) do + {is_pay_in_advance: false, is_current_usage: true} + end + let(:new_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid, agent_name:} + ) + end + end + + before { new_events } + + it "returns correct result" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq((1 + 21.fdiv(31)).ceil(5)) + expect(aggregation.current_usage_units).to eq(2) + end + end + end + + context "when current usage context and charge is pay in advance" do + let(:options) do + {is_pay_in_advance: true, is_current_usage: true} + end + + let(:previous_events) do + agent_names.map do |agent_name| + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + properties: { + unique_id: "111", + agent_name: + } + ) + end + end + + let(:cached_aggregations) do + agent_names.map.with_index do |agent_name, index| + create( + :cached_aggregation, + organization:, + charge:, + event_transaction_id: previous_events[index].transaction_id, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 5.days, + current_aggregation: "1", + max_aggregation: "1", + max_aggregation_with_proration: "0.8", + grouped_by: {agent_name:} + ) + end + end + + before { cached_aggregations } + + it "returns period maximum as aggregation" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(1.8) + expect(aggregation.current_usage_units).to eq(2) + end + end + + context "when cached aggregation does not exist" do + let(:cached_aggregations) { nil } + + it "returns only the past aggregation" do + expect(result.aggregations.count).to eq(2) + + result.aggregations.each do |aggregation| + expect(aggregation.grouped_by.keys).to include("agent_name") + expect(aggregation.grouped_by["agent_name"]).to eq("frodo").or eq("aragorn") + expect(aggregation.aggregation).to eq(1) + expect(aggregation.current_usage_units).to eq(1) + expect(aggregation.full_units_number).to eq(1) + end + end + end + end + end + + describe ".per_event_aggregation" do + before { unique_count_service.options = {} } + + context "with event added in the period" do + let(:added_at) { from_datetime + 10.days } + + it "aggregates per events" do + result = unique_count_service.per_event_aggregation + + expect(result.event_aggregation).to eq([1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([21.fdiv(31).ceil(5)]) + end + + context "with grouped_by_values" do + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: SecureRandom.uuid, scheme: "visa"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 20.days, + properties: {unique_id: "111"} + ) + end + + it "takes the groups into account" do + result = unique_count_service.per_event_aggregation(grouped_by_values: {"scheme" => "visa"}) + + expect(result.event_aggregation).to eq([1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([21.fdiv(31).ceil(5)]) + end + + context "when sending multiple events per day" do + let(:event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "property_1"} + ) + end + + it "aggregates per events" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "property_1", scheme: "visa"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at, + properties: {unique_id: "property_1", scheme: "visa"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 1.hour, + properties: {unique_id: "property_1", scheme: "visa", operation_type: "remove"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 2.hours, + properties: {unique_id: "property_1", scheme: "visa", operation_type: "remove"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 3.hours, + properties: {unique_id: "property_1", scheme: "visa", operation_type: "add"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 1.day, + properties: {unique_id: "property_1", scheme: "visa", operation_type: "add"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 1.day + 1.hour, + properties: {unique_id: "property_1", scheme: "visa", operation_type: "remove"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at + 1.day + 2.hours, + properties: {unique_id: "property_1", scheme: "visa", operation_type: "add"} + ) + + result = unique_count_service.per_event_aggregation(grouped_by_values: {"scheme" => "visa"}) + + # as result of this events we have: + # 1. timestamp: added_at, operation_type: add, property: SecureRandom.uuid, scheme: visa + # 2. timestamp: from_datetime + 20.days, operation_type: add, property: '1111' + # 3. timestamp: added_at, operation_type: add, property: property_1 + # 4. timestamp: added_at, operation_type: add, property: property_1, scheme: visa + # 5. timestamp: added_at, operation_type: add, property: property_1, scheme: visa (ignored) + # 6. timestamp: added_at + 1.hour, operation_type: remove, property: property_1, scheme: visa (ignored) + # 7. timestamp: added_at + 2.hours, operation_type: remove, property: property_1, scheme: visa (ignored) + # 8. timestamp: added_at + 3.hours, operation_type: add, property: property_1, scheme: visa (ignored) + # 9. timestamp: added_at + 1.day, operation_type: add, property: property_1, scheme: visa (ignored) + # 10. timestamp: added_at + 1.day, operation_type: remove, property: property_1, scheme: visa (ignored) + # 11. timestamp: added_at + 1.day, operation_type: add, property: property_1, scheme: visa (ignored) + # when grouping by scheme Visa + taking into account minimal length of proration (1 day), some events are ignored, + # so we have 2 uniq properties: property_1, SecureRandom.uuid; '1111' doesn't have scheme visa, so it's excluded + # length of proration is 21 days for property_1, 21 days for SecureRandom.uuid (becuase random is just added once, property_1 + # is added and removed multiple times, but the last action is add, so we merge all the events into one add) + + expect(result.event_aggregation).to eq([1, 1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([21.fdiv(31).ceil(5), 21.fdiv(31).ceil(5)]) + end + end + end + end + + context "with persisted metrics removed in the period" do + it "aggregates per events" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 15.days, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + result = unique_count_service.per_event_aggregation + + expect(result.event_aggregation).to eq([1, -1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([16.fdiv(31).ceil(5), 0.0]) + end + + context "when removed on the last day of the period" do + it "aggregates per events" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + result = unique_count_service.per_event_aggregation + + expect(result.event_aggregation).to eq([1, -1]) + expect(result.event_prorated_aggregation).to eq([1, 0.0]) + end + end + end + + context "with persisted metrics added and removed in the period" do + let(:added_at) { from_datetime + 1.day } + + it "aggregates per events" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: to_datetime - 1.day, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + result = unique_count_service.per_event_aggregation + + expect(result.event_aggregation).to eq([1, -1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([29.fdiv(31).ceil(5), 0.0]) + end + + context "when added and removed the same day" do + let(:added_at) { from_datetime + 1.day } + + it "aggregates per events" do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: added_at.end_of_day, + properties: { + unique_id: event.properties["unique_id"], + operation_type: "remove" + } + ) + + result = unique_count_service.per_event_aggregation + + expect(result.event_aggregation).to eq([1, -1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([1.fdiv(31).ceil(5), 0.0]) + end + end + end + + context "with multiple events added in the period and with one added and removed during period" do + let(:added_at) { from_datetime + 10.days } + + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 20.days, + properties: {unique_id: "111"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: (from_datetime + 20.days).end_of_day, + properties: { + unique_id: "111", + operation_type: "remove" + } + ) + end + + it "aggregates per events" do + result = unique_count_service.per_event_aggregation + + first = 21.fdiv(31).ceil(5) + second = 1.fdiv(31).ceil(5) + + expect(result.event_aggregation).to eq([1, 1, 1, -1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([first, first, second, 0.0]) + end + end + + context "with multiple events added and removed in the period and with one persisted" do + before do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 10.days, + properties: {unique_id: SecureRandom.uuid} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: from_datetime + 20.days, + properties: {unique_id: "111"} + ) + + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + timestamp: (from_datetime + 20.days).end_of_day, + properties: { + unique_id: "111", + operation_type: "remove" + } + ) + end + + it "aggregates per events" do + result = unique_count_service.per_event_aggregation + + second = 21.fdiv(31).ceil(5) + third = 1.fdiv(31).ceil(5) + + expect(result.event_aggregation).to eq([1, 1, 1, -1]) + expect(result.event_prorated_aggregation.map { |el| el.ceil(5) }).to eq([1, second, third, 0.0]) + end + end + end +end diff --git a/spec/services/billable_metrics/update_service_spec.rb b/spec/services/billable_metrics/update_service_spec.rb new file mode 100644 index 0000000..9a23917 --- /dev/null +++ b/spec/services/billable_metrics/update_service_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillableMetrics::UpdateService do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:billable_metric) { create(:billable_metric, organization:) } + let(:params) do + { + name: "New Metric", + code: "new_metric", + description: "New metric description", + aggregation_type: "sum_agg", + field_name: "field_value", + expression: "1 + 3", + rounding_function: "ceil", + rounding_precision: 2 + }.tap do |p| + p[:filters] = filters unless filters.nil? + end + end + let(:filters) { nil } + + describe ".call" do + it "updates the billable metric" do + result = described_class.call(billable_metric:, params:) + expect(result).to be_success + + metric = result.billable_metric + expect(metric).to have_attributes( + id: billable_metric.id, + name: "New Metric", + code: "new_metric", + aggregation_type: "sum_agg", + rounding_function: "ceil", + rounding_precision: 2, + expression: "1 + 3" + ) + end + + it "produces an activity log" do + described_class.call(billable_metric:, params:) + + expect(Utils::ActivityLog).to have_produced("billable_metric.updated").after_commit.with(billable_metric) + end + + context "with filters arguments" do + let(:filters) do + [ + { + key: "cloud", + values: %w[aws google] + } + ] + end + + it "updates billable metric's filters" do + expect { described_class.call(billable_metric:, params:) }.to change { billable_metric.filters.reload.count }.from(0).to(1) + end + end + + context "with validation errors" do + let(:params) do + { + name: nil, + code: "new_metric", + description: "New metric description", + aggregation_type: "count_agg" + } + end + + it "returns an error" do + result = described_class.call(billable_metric:, params:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + + context "when billable metric is not found" do + let(:billable_metric) { nil } + + it "returns an error" do + result = described_class.call(billable_metric:, params:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("billable_metric_not_found") + end + end + + context "with custom aggregation" do + let(:params) { {aggregation_type: "custom_agg"} } + + it "returns a forbidden failure" do + result = described_class.call(billable_metric:, params:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "when billable metric is linked to plan" do + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:) } + + before { charge } + + it "updates only name and description" do + result = described_class.call(billable_metric:, params:) + + expect(result).to be_success + + expect(result.billable_metric).to have_attributes( + name: "New Metric", + description: "New metric description" + ) + + expect(result.billable_metric).not_to have_attributes( + code: "new_metric", + aggregation_type: "sum_agg", + field_name: "field_value", + rounding_function: "ceil", + rounding_precision: 2 + ) + end + end + end +end diff --git a/spec/services/billing_entities/change_eu_tax_management_service_spec.rb b/spec/services/billing_entities/change_eu_tax_management_service_spec.rb new file mode 100644 index 0000000..abebce8 --- /dev/null +++ b/spec/services/billing_entities/change_eu_tax_management_service_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::ChangeEuTaxManagementService do + subject(:service) { described_class.new(billing_entity:, eu_tax_management:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:eu_tax_management) { true } + + describe "#call" do + before do + allow(Taxes::AutoGenerateService).to receive(:call) + end + + context "when enabling EU tax management" do + context "when billing entity is in the EU" do + before do + billing_entity.update!(country: "FR") + end + + it "enables EU tax management" do + result = service.call + + expect(result).to be_success + expect(result.billing_entity.eu_tax_management).to eq(true) + end + + it "calls the taxes auto generate service" do + service.call + + expect(Taxes::AutoGenerateService).to have_received(:call).with(organization:) + end + end + + context "when billing entity is outside the EU" do + before do + billing_entity.update!(country: "US") + end + + it "returns a validation failure" do + result = service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({eu_tax_management: ["billing_entity_must_be_in_eu"]}) + end + + it "does not call the taxes auto generate service" do + service.call + + expect(Taxes::AutoGenerateService).not_to have_received(:call) + end + end + end + + context "when disabling EU tax management" do + let(:eu_tax_management) { false } + + before do + billing_entity.update!(eu_tax_management: true) + end + + it "disables EU tax management" do + result = service.call + + expect(result).to be_success + expect(result.billing_entity.eu_tax_management).to eq(false) + end + + it "does not call the taxes auto generate service" do + service.call + + expect(Taxes::AutoGenerateService).not_to have_received(:call) + end + end + + context "when billing entity is not provided" do + let(:billing_entity) { nil } + + it "returns a not found failure" do + result = service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + end + end + end +end diff --git a/spec/services/billing_entities/change_invoice_numbering_service_spec.rb b/spec/services/billing_entities/change_invoice_numbering_service_spec.rb new file mode 100644 index 0000000..f5e531f --- /dev/null +++ b/spec/services/billing_entities/change_invoice_numbering_service_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::ChangeInvoiceNumberingService do + subject(:result) { described_class.call(billing_entity:, document_numbering:) } + + let(:billing_entity) { create(:billing_entity, document_numbering: "per_customer") } + let(:organization) { billing_entity.organization } + let(:document_numbering) { "per_billing_entity" } + + describe "#call" do + it "updates the billing_entity's document_numbering" do + expect(result).to be_success + expect(result.billing_entity).to be_per_billing_entity + end + + context "when document_numbering is not changing" do + let(:document_numbering) { "per_customer" } + + it "returns early without making changes" do + expect(result).to be_success + expect(result.billing_entity).to be_per_customer + end + end + + context "when changing from per_customer to per_billing_entity" do + let(:customer) { create(:customer, billing_entity:) } + let(:invoice1) { create(:invoice, customer:, organization:, billing_entity:, status: "finalized", self_billed: false) } + let(:invoice2) { create(:invoice, customer:, organization:, billing_entity:, status: "finalized", self_billed: false) } + let(:invoice3) { create(:invoice, customer:, organization:, billing_entity:, status: "draft", self_billed: false) } + let(:voided_invoice) { create(:invoice, customer:, organization:, billing_entity:, status: "voided", self_billed: false) } + let(:self_billed_invoice) { create(:invoice, customer:, organization:, billing_entity:, status: "finalized", self_billed: true) } + + before do + invoice1 + invoice2 + invoice3 + self_billed_invoice + voided_invoice + end + + it "updates the billing_entity sequential id for the latest invoice" do + expect { + result + }.to change { voided_invoice.reload.billing_entity_sequential_id }.to(3) + + expect(billing_entity).to be_per_billing_entity + end + + it "only counts non-self-billed invoices with generated numbers" do + expect(result).to be_success + expect(voided_invoice.reload.billing_entity_sequential_id).to eq(3) + end + + context "when last created invoice already has a billing_entity_sequential_id" do + let(:voided_invoice) do + create(:invoice, customer:, organization:, billing_entity:, status: "voided", self_billed: false, billing_entity_sequential_id: 1, created_at: 1.day.from_now) + end + + it "changes the billing_entity_sequential_id on the latest invoice without it" do + expect(result).to be_success + expect(invoice2.reload.billing_entity_sequential_id).to eq(3) + end + end + end + + context "when changing from per_billing_entity to per_customer" do + let(:billing_entity) { create(:billing_entity, document_numbering: "per_billing_entity") } + let(:document_numbering) { "per_customer" } + + it "updates the billing_entity's document_numbering without other changes" do + expect(result).to be_success + expect(result.billing_entity).to be_per_customer + end + end + end +end diff --git a/spec/services/billing_entities/create_service_spec.rb b/spec/services/billing_entities/create_service_spec.rb new file mode 100644 index 0000000..52d3734 --- /dev/null +++ b/spec/services/billing_entities/create_service_spec.rb @@ -0,0 +1,280 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::CreateService do + subject(:result) { described_class.call(organization:, params:) } + + include_context "with mocked security logger" + + let(:organization) { create :organization } + let(:params) do + { + name: "Billing Entity", + code: "billing-entity" + } + end + + it "produces an activity log" do + billing_entity = result.billing_entity + + expect(Utils::ActivityLog).to have_produced("billing_entities.created").after_commit.with(billing_entity) + end + + context "when lago freemium" do + it "returns an error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it_behaves_like "does not produce a security log" do + before { result } + end + + context "when the organization does not have active billing entities" do + before do + organization.billing_entities.each(&:discard) + end + + it "creates a billing entity" do + expect(result).to be_success + expect(result.billing_entity).to be_persisted + expect(result.billing_entity.name).to eq("Billing Entity") + expect(result.billing_entity.code).to eq("billing-entity") + end + + it_behaves_like "produces a security log", "billing_entity.created" do + before { result } + end + + it "does not set eu_tax_management when not provided" do + expect(result).to be_success + expect(result.billing_entity.eu_tax_management).to be false + end + + it "sets eu_tax_management when explicitly provided" do + params[:eu_tax_management] = true + params[:country] = "fr" + expect(result).to be_success + expect(result.billing_entity.eu_tax_management).to be true + end + + it "does not set premium attributes" do + params.merge!( + { + timezone: "Europe/Paris", + email_settings: ["invoice.finalized"], + billing_configuration: { + invoice_grace_period: 15, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + } + } + ) + + expect(result).to be_success + expect(result.billing_entity).to be_persisted + expect(result.billing_entity.invoice_grace_period).to eq(0) + expect(result.billing_entity.timezone).to eq("UTC") + expect(result.billing_entity.email_settings).to be_empty + expect(result.billing_entity.subscription_invoice_issuing_date_anchor).to eq("next_period_start") + expect(result.billing_entity.subscription_invoice_issuing_date_adjustment).to eq("align_with_finalization_date") + end + + context "when an id is provided in the params hash" do + it "creates a billing entity with the provided id" do + params[:id] = organization.id + + expect(result).to be_success + expect(result.billing_entity.id).to eq(organization.id) + end + end + end + end + + context "when lago premium", :premium do + context "when no multi_entity premium feature is enabled" do + it "returns an error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "when multi_entities_pro premium feature is enabled" do + let(:organization) do + create(:organization, premium_integrations: ["multi_entities_pro"]) + end + + it "creates a billing entity with default document_numbering" do + expect(organization.billing_entities.count).to eq(1) + expect(result).to be_success + expect(result.billing_entity).to be_persisted + expect(result.billing_entity.name).to eq("Billing Entity") + expect(result.billing_entity.code).to eq("billing-entity") + expect(result.billing_entity.document_numbering).to eq("per_customer") + end + + context "when creating billing entity with full data" do + let(:params) do + { + name: "Billing Entity", + code: "billing-entity", + address_line1: "Address Line 1", + address_line2: "Address Line 2", + city: "City", + country: "fr", + default_currency: "CHF", + document_number_prefix: "ENT-1234", + document_numbering: "per_customer", + email: "test@lago.com", + einvoicing: true, + finalize_zero_amount_invoice: true, + legal_name: "Legal Name", + legal_number: "Legal Number", + net_payment_term: 90, + state: "State", + tax_identification_number: "EU123456789", + vat_rate: 1, + zipcode: "12345", + timezone: "Europe/Paris", + email_settings: ["invoice.finalized", "credit_note.created"], + billing_configuration: { + invoice_grace_period: 15, + invoice_footer: "Invoice Footer", + document_locale: "fr", + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + }, + eu_tax_management: true, + logo: "data:image/png;base64,#{Base64.encode64(File.read("spec/factories/images/logo.png"))}" + } + end + + before do + allow(Taxes::AutoGenerateService).to receive(:call) + end + + it "creates a billing entity with full data" do + expect(result).to be_success + expect(result.billing_entity).to be_persisted + expect(result.billing_entity.name).to eq("Billing Entity") + expect(result.billing_entity.address_line1).to eq("Address Line 1") + expect(result.billing_entity.address_line2).to eq("Address Line 2") + expect(result.billing_entity.city).to eq("City") + expect(result.billing_entity.country).to eq("FR") + expect(result.billing_entity.default_currency).to eq("CHF") + expect(result.billing_entity.document_number_prefix).to eq("ENT-1234") + expect(result.billing_entity.document_numbering).to eq("per_customer") + expect(result.billing_entity.email).to eq("test@lago.com") + expect(result.billing_entity.einvoicing).to eq(true) + expect(result.billing_entity.finalize_zero_amount_invoice).to eq(true) + expect(result.billing_entity.legal_name).to eq("Legal Name") + expect(result.billing_entity.legal_number).to eq("Legal Number") + expect(result.billing_entity.net_payment_term).to eq(90) + expect(result.billing_entity.state).to eq("State") + expect(result.billing_entity.tax_identification_number).to eq("EU123456789") + expect(result.billing_entity.vat_rate).to eq(1) + expect(result.billing_entity.zipcode).to eq("12345") + expect(result.billing_entity.timezone).to eq("Europe/Paris") + expect(result.billing_entity.email_settings).to eq(["invoice.finalized", "credit_note.created"]) + expect(result.billing_entity.invoice_grace_period).to eq(15) + expect(result.billing_entity.invoice_footer).to eq("Invoice Footer") + expect(result.billing_entity.document_locale).to eq("fr") + expect(result.billing_entity.subscription_invoice_issuing_date_anchor).to eq("current_period_end") + expect(result.billing_entity.subscription_invoice_issuing_date_adjustment).to eq("keep_anchor") + expect(result.billing_entity.eu_tax_management).to eq(true) + expect(result.billing_entity.logo).to be_attached + expect(Taxes::AutoGenerateService).to have_received(:call).with(organization:) + end + end + + context "when document_number_prefix is lowercase" do + it "converts document_number_prefix to uppercase" do + params[:document_number_prefix] = "abc" + + expect(result).to be_success + expect(result.billing_entity.document_number_prefix).to eq("ABC") + end + end + + context "when document_number_prefix is invalid" do + before { params[:document_number_prefix] = "aaaaaaaaaaaaaaa" } + + it "returns an error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:document_number_prefix]).to eq(["value_is_too_long"]) + end + end + + context "when billing entity outside the EU and eu_tax_management is true" do + let(:tax_auto_generate_service) { instance_double(Taxes::AutoGenerateService) } + + before do + params[:country] = "us" + params[:eu_tax_management] = true + + allow(Taxes::AutoGenerateService).to receive(:new).and_return(tax_auto_generate_service) + allow(tax_auto_generate_service).to receive(:call) + end + + it "returns an error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({eu_tax_management: ["billing_entity_must_be_in_eu"]}) + expect(tax_auto_generate_service).not_to have_received(:call) + end + end + + context "with validation errors" do + before do + params[:country] = "---" + end + + it "returns an error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:country]).to eq(["not_a_valid_country_code"]) + end + end + + context "when max billing entities limit is reached" do + it "returns an error" do + create(:billing_entity, organization:) + + expect(organization.billing_entities.count).to eq(2) + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end + + context "when multi_entities_enterprise premium feature is enabled" do + let(:organization) do + create(:organization, premium_integrations: ["multi_entities_enterprise"]) + end + + it "creates a billing entity" do + create(:billing_entity, organization:) + + expect(organization.billing_entities.count).to eq(2) + expect(result).to be_success + expect(result.billing_entity).to be_persisted + expect(result.billing_entity.name).to eq("Billing Entity") + end + + context "when record is invalid" do + let(:params) { {name: nil, code: nil} } + + it "returns an error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + end +end diff --git a/spec/services/billing_entities/resolve_service_spec.rb b/spec/services/billing_entities/resolve_service_spec.rb new file mode 100644 index 0000000..f49f801 --- /dev/null +++ b/spec/services/billing_entities/resolve_service_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe BillingEntities::ResolveService do + subject(:result) { described_class.call(organization:, billing_entity_code:) } + + let(:organization) { create(:organization) } + + context "when organization has no active billing entity" do + let(:billing_entity_code) { organization.all_billing_entities.first.code } + + before do + organization.billing_entities.update_all(archived_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + it "returns not found failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + expect(result.error.error_code).to eq("billing_entity_not_found") + end + end + + context "when billing_entity_code is not provided" do + let(:billing_entity_code) { nil } + + let(:billing_entities) { create_list(:billing_entity, 3, organization:) } + + before do + billing_entities + organization.billing_entities.first.discard! + end + + it "returns organization's default billing entity" do + expect(result).to be_success + expect(result.billing_entity).to eq(organization.default_billing_entity) + end + end + + context "when billing_entity_code is provided" do + let(:billing_entity_code) { "123" } + + context "when billing entity is not found" do + it "returns not found failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + expect(result.error.error_code).to eq("billing_entity_not_found") + end + end + + context "when billing entity is found" do + let(:billing_entity_1) { create(:billing_entity, organization:) } + let(:billing_entity_2) { create(:billing_entity, organization:, code: billing_entity_code) } + + before do + billing_entity_1 + billing_entity_2 + end + + it "returns billing entity" do + expect(result).to be_success + expect(result.billing_entity).to eq(billing_entity_2) + end + end + end +end diff --git a/spec/services/billing_entities/taxes/apply_taxes_service_spec.rb b/spec/services/billing_entities/taxes/apply_taxes_service_spec.rb new file mode 100644 index 0000000..6c6a305 --- /dev/null +++ b/spec/services/billing_entities/taxes/apply_taxes_service_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::Taxes::ApplyTaxesService do + subject(:service) { described_class.new(billing_entity:, tax_codes:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:tax_codes) { ["TAX_CODE_1", "TAX_CODE_2"] } + + describe "#call" do + context "when tax codes exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + let(:tax2) { create(:tax, organization:, code: "TAX_CODE_2") } + + before do + tax1 + tax2 + end + + it "creates applied taxes for the billing entity" do + expect { service.call }.to change(billing_entity.applied_taxes, :count).by(2) + expect(billing_entity.applied_taxes.pluck(:tax_id)).to match_array([tax1.id, tax2.id]) + end + + context "when billing_entity already have taxes applied" do + before do + billing_entity.applied_taxes.create!(tax: tax1, organization:) + end + + it "does not create duplicate applied taxes" do + expect { service.call }.to change(billing_entity.applied_taxes, :count).by(1) + end + end + + it "enqueues the refresh draft invoices job" do + expect { service.call }.to have_enqueued_job(BillingEntities::Taxes::RefreshDraftInvoicesJob) + .with(billing_entity.id) + end + end + + context "when some tax codes do not exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + + before { tax1 } + + it "fails with a not_found_failure" do + result = service.call + expect(result).not_to be_success + expect(result.error.message).to eq("tax_not_found") + end + + it "does not create any applied taxes" do + service.call + expect(billing_entity.applied_taxes.pluck(:tax_id)).to eq([]) + end + end + + context "when tax_codes is empty" do + let(:tax_codes) { [] } + + it "returns a successful result with no applied taxes" do + result = service.call + expect(result).to be_success + end + + it "does not create any applied taxes" do + expect { service.call }.not_to change(billing_entity.applied_taxes, :count) + end + + it "does not enqueue the refresh draft invoices job" do + expect { service.call }.not_to have_enqueued_job(BillingEntities::Taxes::RefreshDraftInvoicesJob) + end + end + + context "when tax_codes is nil" do + let(:tax_codes) { nil } + + it "returns a successful result with no applied taxes" do + result = service.call + expect(result).to be_success + end + + it "does not create any applied taxes" do + expect { service.call }.not_to change(billing_entity.applied_taxes, :count) + end + end + end +end diff --git a/spec/services/billing_entities/taxes/manage_taxes_service_spec.rb b/spec/services/billing_entities/taxes/manage_taxes_service_spec.rb new file mode 100644 index 0000000..25fe4b0 --- /dev/null +++ b/spec/services/billing_entities/taxes/manage_taxes_service_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::Taxes::ManageTaxesService do + subject(:service) { described_class.new(billing_entity:, tax_codes:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + let(:tax2) { create(:tax, organization:, code: "TAX_CODE_2") } + + describe "#call" do + context "when sending tax codes" do + let(:tax_codes) { ["TAX_CODE_1", "TAX_CODE_2"] } + + before do + tax1 + tax2 + end + + it "applies taxes to the billing entity" do + service.call + + expect(billing_entity.reload.taxes).to eq([tax1, tax2]) + end + + context "when some tax codes do not exist" do + let(:tax_codes) { ["TAX_CODE_1", "TAX_CODE_3"] } + + it "returns a not_found_failure" do + result = service.call + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when billing_entity had another tax applied" do + let(:tax3) { create(:tax, organization:, code: "TAX_CODE_3") } + + before do + create(:billing_entity_applied_tax, billing_entity:, tax: tax3) + end + + it "removes the other tax and applies the new ones" do + service.call + + expect(billing_entity.taxes).to eq([tax1, tax2]) + end + end + + it "enqueues the refresh draft invoices job" do + expect { service.call }.to have_enqueued_job(BillingEntities::Taxes::RefreshDraftInvoicesJob) + .with(billing_entity.id) + end + + context "when tax codes contain duplicates" do + let(:tax_codes) { ["TAX_CODE_1", "TAX_CODE_2", "TAX_CODE_1"] } + + it "applies each tax only once" do + service.call + + expect(billing_entity.taxes).to eq([tax1, tax2]) + end + end + + context "when tax codes have different case" do + let(:tax_codes) { ["tax_code_1", "TAX_CODE_2"] } + + it "matches tax codes case-insensitively" do + result = service.call + + expect(result).to be_success + expect(result.taxes).to eq([tax1, tax2]) + expect(result.applied_taxes.count).to eq(2) + + expect(billing_entity.applied_taxes.pluck(:organization_id).uniq).to eq([organization.id]) + end + end + end + + context "when sending empty tax codes" do + let(:tax_codes) { [] } + + before do + create(:billing_entity_applied_tax, billing_entity:, tax: tax1) + create(:billing_entity_applied_tax, billing_entity:, tax: tax2) + end + + it "removes taxes from the billing entity" do + result = service.call + + expect(result).to be_success + expect(result.taxes).to be_empty + expect(result.applied_taxes).to be_empty + + expect(billing_entity.applied_taxes).to be_empty + end + end + + context "when tax_codes is nil" do + let(:tax_codes) { nil } + + before do + create(:billing_entity_applied_tax, billing_entity:, tax: tax1) + create(:billing_entity_applied_tax, billing_entity:, tax: tax2) + end + + it "removes taxes from the billing entity" do + result = service.call + + expect(result).to be_success + expect(result.taxes).to be_empty + expect(result.applied_taxes).to be_empty + + expect(billing_entity.applied_taxes).to be_empty + end + end + + context "when billing_entity is invalid" do + let(:billing_entity) { nil } + let(:tax_codes) { ["TAX_CODE_1"] } + + it "returns a not_found_failure" do + result = service.call + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("billing_entity_not_found") + end + end + end +end diff --git a/spec/services/billing_entities/taxes/remove_taxes_service_spec.rb b/spec/services/billing_entities/taxes/remove_taxes_service_spec.rb new file mode 100644 index 0000000..018fe9b --- /dev/null +++ b/spec/services/billing_entities/taxes/remove_taxes_service_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::Taxes::RemoveTaxesService do + subject(:service) { described_class.new(billing_entity:, tax_codes:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:tax_codes) { ["TAX_CODE_1", "TAX_CODE_2"] } + + describe "#call" do + context "when tax codes exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + let(:tax2) { create(:tax, organization:, code: "TAX_CODE_2") } + + before do + billing_entity.applied_taxes.create!(tax: tax1, organization_id: organization.id) + billing_entity.applied_taxes.create!(tax: tax2, organization_id: organization.id) + end + + it "removes the specified taxes from the billing entity" do + expect { service.call }.to change(billing_entity.applied_taxes, :count).by(-2) + end + + it "returns a successful result" do + result = service.call + expect(result).to be_success + end + + it "enqueues the refresh draft invoices job" do + expect { service.call }.to have_enqueued_job(BillingEntities::Taxes::RefreshDraftInvoicesJob) + .with(billing_entity.id) + end + + context "when some taxes are not applied to the billing entity" do + before do + billing_entity.applied_taxes.where(tax: tax2).destroy_all + end + + it "removes only the applied taxes" do + expect { service.call }.to change(billing_entity.applied_taxes, :count).by(-1) + end + + it "returns a successful result" do + result = service.call + expect(result).to be_success + end + end + end + + context "when some tax codes do not exist in the organization" do + let(:tax1) { create(:tax, organization:, code: "TAX_CODE_1") } + + before { tax1 } + + it "fails with a not_found_failure" do + result = service.call + expect(result).not_to be_success + expect(result.error.message).to eq("tax_not_found") + end + + it "does not remove any applied taxes" do + expect { service.call }.not_to change(billing_entity.applied_taxes, :count) + end + end + + context "when tax_codes is empty" do + let(:tax_codes) { [] } + + it "returns a successful result" do + result = service.call + expect(result).to be_success + end + + it "does not remove any applied taxes" do + expect { service.call }.not_to change(billing_entity.applied_taxes, :count) + end + end + + context "when tax_codes is nil" do + let(:tax_codes) { nil } + + it "returns a successful result" do + result = service.call + expect(result).to be_success + end + + it "does not remove any applied taxes" do + expect { service.call }.not_to change(billing_entity.applied_taxes, :count) + end + end + end +end diff --git a/spec/services/billing_entities/update_applied_dunning_campaign_service_spec.rb b/spec/services/billing_entities/update_applied_dunning_campaign_service_spec.rb new file mode 100644 index 0000000..821fd99 --- /dev/null +++ b/spec/services/billing_entities/update_applied_dunning_campaign_service_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::UpdateAppliedDunningCampaignService do + subject(:update_service) { described_class.new(billing_entity:, applied_dunning_campaign_id:) } + + include_context "with mocked security logger" + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:second_billing_entity) { create(:billing_entity, organization:) } + let(:dunning_campaign_1) { create(:dunning_campaign, organization:) } + let(:dunning_campaign_2) { create(:dunning_campaign, organization:) } + let(:customer_1) { create(:customer, organization:, billing_entity:, last_dunning_campaign_attempt: 2, last_dunning_campaign_attempt_at: 1.day.ago) } + let(:customer_2) { create(:customer, organization:, billing_entity:, last_dunning_campaign_attempt: 1, last_dunning_campaign_attempt_at: 1.day.ago, applied_dunning_campaign: dunning_campaign_2) } + let(:customer_3) { create(:customer, organization:, billing_entity: second_billing_entity, last_dunning_campaign_attempt: 2, last_dunning_campaign_attempt_at: 1.day.ago, applied_dunning_campaign: dunning_campaign_1) } + let(:customer_4) { create(:customer, organization:, billing_entity: second_billing_entity, last_dunning_campaign_attempt: 1, last_dunning_campaign_attempt_at: 1.day.ago) } + + describe "#call" do + context "when billing entity exists" do + before do + customer_1 + customer_2 + customer_3 + customer_4 + end + + context "when updating applied dunning campaign to nil" do + let(:applied_dunning_campaign_id) { nil } + + context "when billing entity has no applied dunning campaign" do + it "does not update the billing entity" do + expect { update_service.call }.to not_change { billing_entity.reload.applied_dunning_campaign } + end + + it "does not reset customer attempts" do + expect { update_service.call }.to not_change { customer_1.reload.last_dunning_campaign_attempt } + .and not_change { customer_2.reload.last_dunning_campaign_attempt } + .and not_change { customer_3.reload.last_dunning_campaign_attempt } + .and not_change { customer_4.reload.last_dunning_campaign_attempt } + end + end + + context "when billing entity has an applied dunning campaign" do + before do + billing_entity.update!(applied_dunning_campaign: dunning_campaign_1) + end + + it "removes the applied dunning campaign" do + expect { update_service.call } + .to change { billing_entity.reload.applied_dunning_campaign } + .from(dunning_campaign_1) + .to(nil) + end + + it "resets customer attempts only on fallback customers for this billing entity" do + expect { + update_service.call + }.to change { customer_1.reload.last_dunning_campaign_attempt } + .from(2) + .to(0) + .and not_change { customer_2.reload.last_dunning_campaign_attempt } + .and not_change { customer_3.reload.last_dunning_campaign_attempt } + .and not_change { customer_4.reload.last_dunning_campaign_attempt } + end + end + end + + context "when dunning campaign is provided" do + let(:applied_dunning_campaign_id) { dunning_campaign_2.id } + + it "returns success" do + result = update_service.call + expect(result).to be_success + expect(result.billing_entity).to eq(billing_entity) + expect(result.billing_entity.applied_dunning_campaign).to eq(dunning_campaign_2) + end + + context "when billing entity has no applied dunning campaign" do + it "sets the new dunning campaign" do + expect { update_service.call } + .to change { billing_entity.reload.applied_dunning_campaign } + .from(nil) + .to(dunning_campaign_2) + end + + it_behaves_like "produces a security log", "billing_entity.updated" do + before { update_service.call } + end + + it "resets only fallback customers of this billing entity attempts" do + expect { update_service.call } + .to change { customer_1.reload.last_dunning_campaign_attempt } + .from(2) + .to(0) + .and not_change { customer_2.reload.last_dunning_campaign_attempt } + .and not_change { customer_3.reload.last_dunning_campaign_attempt } + .and not_change { customer_4.reload.last_dunning_campaign_attempt } + end + end + + context "when billing entity has a different applied dunning campaign" do + before do + billing_entity.update!(applied_dunning_campaign: dunning_campaign_1) + end + + it "updates to the new dunning campaign" do + expect { update_service.call } + .to change { billing_entity.reload.applied_dunning_campaign } + .from(dunning_campaign_1) + .to(dunning_campaign_2) + end + + it "resets customer attempts only on fallback customers for this billing entity" do + expect { update_service.call } + .to change { customer_1.reload.last_dunning_campaign_attempt } + .from(2) + .to(0) + .and not_change { customer_2.reload.last_dunning_campaign_attempt } + .and not_change { customer_3.reload.last_dunning_campaign_attempt } + .and not_change { customer_4.reload.last_dunning_campaign_attempt } + end + end + + context "when billing entity has the same applied dunning campaign" do + before do + billing_entity.update!(applied_dunning_campaign: dunning_campaign_2) + end + + it "does not update the billing entity" do + expect { update_service.call }.not_to change { billing_entity.reload.applied_dunning_campaign } + end + + it_behaves_like "does not produce a security log" do + before { update_service.call } + end + + it "does not reset customer attempts" do + expect { update_service.call }.to not_change { customer_1.reload.last_dunning_campaign_attempt } + .and not_change { customer_2.reload.last_dunning_campaign_attempt } + .and not_change { customer_3.reload.last_dunning_campaign_attempt } + .and not_change { customer_4.reload.last_dunning_campaign_attempt } + end + end + end + end + + context "when billing entity is nil" do + let(:billing_entity) { nil } + let(:organization) { create(:organization) } + let(:dunning_campaign) { dunning_campaign_1 } + let(:applied_dunning_campaign_id) { dunning_campaign_1.id } + + it "returns a not found failure" do + result = update_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + end + end + + context "when dunning campaign is not found" do + let(:applied_dunning_campaign_id) { "nonexistent-id" } + + it "returns a not found failure" do + result = update_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("dunning_campaign_not_found") + end + end + end +end diff --git a/spec/services/billing_entities/update_invoice_issuing_date_settings_service_spec.rb b/spec/services/billing_entities/update_invoice_issuing_date_settings_service_spec.rb new file mode 100644 index 0000000..c34eea5 --- /dev/null +++ b/spec/services/billing_entities/update_invoice_issuing_date_settings_service_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::UpdateInvoiceIssuingDateSettingsService do + include ActiveJob::TestHelper + + subject(:update_service) { described_class.new(billing_entity:, params:) } + + let(:billing_entity) { create(:billing_entity, invoice_grace_period: 9) } + let(:organization) { billing_entity.organization } + let(:customer) { create(:customer, organization:, net_payment_term: 5) } + let(:params) do + { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date", + invoice_grace_period: 15 + } + end + + describe "#call" do + let!(:invoice_draft) do + create( + :invoice, + customer:, + billing_entity:, + status: :draft, + issuing_date: DateTime.parse("19 Jun 2022").to_date, + applied_grace_period: 9 + ) + end + + context "with premium feature", :premium do + it "updates invoice issuing date settings on billing_entity" do + update_service.call + + billing_entity.reload + + expect(billing_entity.invoice_grace_period).to eq(15) + expect(billing_entity.subscription_invoice_issuing_date_anchor).to eq("current_period_end") + expect(billing_entity.subscription_invoice_issuing_date_adjustment).to eq("align_with_finalization_date") + end + + it "updates issuing_date and payment_due_date on draft invoices" do + expect { update_service.call }.to enqueue_job(Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityJob).with( + billing_entity, + subscription_invoice_issuing_date_anchor: "next_period_start", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date", + invoice_grace_period: 9 + ) + end + end + + context "without premium feature" do + it "does not update invoice_grace_period on billing_entity" do + update_service.call + + billing_entity.reload + + expect(billing_entity.invoice_grace_period).not_to eq(15) + expect(billing_entity.subscription_invoice_issuing_date_anchor).to eq("current_period_end") + expect(billing_entity.subscription_invoice_issuing_date_adjustment).to eq("align_with_finalization_date") + end + + it "updates issuing_date and payment_due_date on draft invoices" do + expect { update_service.call }.not_to change { invoice_draft.reload.issuing_date } + end + end + + context "when grace_period is the same as the current one on the billing_entity" do + let(:grace_period) { billing_entity.invoice_grace_period } + + it "does not update issuing_date on draft invoices" do + expect { update_service.call }.not_to change { invoice_draft.reload.issuing_date } + end + end + end +end diff --git a/spec/services/billing_entities/update_invoice_payment_due_date_service_spec.rb b/spec/services/billing_entities/update_invoice_payment_due_date_service_spec.rb new file mode 100644 index 0000000..47b3cc1 --- /dev/null +++ b/spec/services/billing_entities/update_invoice_payment_due_date_service_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::UpdateInvoicePaymentDueDateService do + subject(:update_service) { described_class.new(billing_entity:, net_payment_term:) } + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:customer) { create(:customer, organization:, net_payment_term: customer_net_payment_term) } + let(:customer_net_payment_term) { nil } + let(:net_payment_term) { 30 } + + describe "#call" do + let(:draft_invoice) do + create(:invoice, status: :draft, customer:, organization:, issuing_date: DateTime.parse("21 Jun 2022"), billing_entity:) + end + let(:finalized_invoice) { create(:invoice, status: :finalized, customer:, organization:, issuing_date: DateTime.parse("21 Jun 2022"), billing_entity:) } + + before do + draft_invoice + finalized_invoice + end + + it "updates invoice payment_due_date" do + result = update_service.call + expect(result.billing_entity.net_payment_term).to eq(30) + end + + it "updates only draft invoice payment_due_date" do + expect { update_service.call }.to change { draft_invoice.reload.payment_due_date } + .from(DateTime.parse("21 Jun 2022")) + .to(DateTime.parse("21 Jun 2022") + net_payment_term.days) + .and not_change(finalized_invoice.reload, :payment_due_date) + end + + it "updates draft invoice net_payment_date" do + expect { update_service.call }.to change { draft_invoice.reload.net_payment_term } + .from(0).to(30).and not_change { finalized_invoice.reload.net_payment_term } + end + + context "when customer has their own net_payment_term" do + let(:customer_net_payment_term) { 10 } + + it "doesn't update fields" do + expect { update_service.call }.not_to change { draft_invoice.reload.payment_due_date } + expect { update_service.call }.not_to change { draft_invoice.reload.net_payment_term } + end + end + + context "when drat invoice belongs to another billing entity" do + let(:another_billing_entity) { create(:billing_entity) } + let(:draft_invoice) do + create(:invoice, status: :draft, customer:, organization:, issuing_date: DateTime.parse("21 Jun 2022"), billing_entity: another_billing_entity) + end + + it "doesn't update draft invoice net_payment_term" do + expect { update_service.call }.not_to change { draft_invoice.reload.net_payment_term } + end + + it "doesn't update draft invoice payment_due_date" do + expect { update_service.call }.not_to change { draft_invoice.reload.payment_due_date } + end + end + end +end diff --git a/spec/services/billing_entities/update_service_spec.rb b/spec/services/billing_entities/update_service_spec.rb new file mode 100644 index 0000000..3fb90bf --- /dev/null +++ b/spec/services/billing_entities/update_service_spec.rb @@ -0,0 +1,445 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BillingEntities::UpdateService do + subject(:update_service) { described_class.new(billing_entity:, params:) } + + include_context "with mocked security logger" + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + + let(:timezone) { nil } + let(:email_settings) { [] } + let(:invoice_grace_period) { 0 } + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:logo) { nil } + let(:country) { "fr" } + let(:einvoicing) { true } + + let(:params) do + { + name: "New Name", + legal_name: "Foobar", + legal_number: "1234", + tax_identification_number: "2246", + email: "foo@bar.com", + einvoicing:, + address_line1: "Line 1", + address_line2: "Line 2", + state: "Foobar", + zipcode: "FOO1234", + city: "Foobar", + default_currency: "EUR", + country:, + timezone:, + logo:, + email_settings:, + billing_configuration: { + invoice_footer: "invoice footer", + document_locale: "fr", + invoice_grace_period:, + subscription_invoice_issuing_date_anchor:, + subscription_invoice_issuing_date_adjustment: + } + } + end + + describe "#call" do + it "updates the billing_entity" do + result = update_service.call + + expect(result.billing_entity.name).to eq("New Name") + expect(result.billing_entity.legal_name).to eq("Foobar") + expect(result.billing_entity.legal_number).to eq("1234") + expect(result.billing_entity.tax_identification_number).to eq("2246") + expect(result.billing_entity.email).to eq("foo@bar.com") + expect(result.billing_entity.address_line1).to eq("Line 1") + expect(result.billing_entity.einvoicing).to eq(true) + expect(result.billing_entity.address_line2).to eq("Line 2") + expect(result.billing_entity.state).to eq("Foobar") + expect(result.billing_entity.zipcode).to eq("FOO1234") + expect(result.billing_entity.city).to eq("Foobar") + expect(result.billing_entity.country).to eq("FR") + expect(result.billing_entity.default_currency).to eq("EUR") + expect(result.billing_entity.timezone).to eq("UTC") + + expect(result.billing_entity.invoice_footer).to eq("invoice footer") + expect(result.billing_entity.document_locale).to eq("fr") + end + + context "with email containing unicode lookalike characters" do + let(:params) { {email: "hello@something\u2013other.com"} } + + it "sanitizes the email before saving" do + result = update_service.call + expect(result.billing_entity.email).to eq("hello@something-other.com") + end + end + + it_behaves_like "produces a security log", "billing_entity.updated" do + before { update_service.call } + end + + context "when tax_codes are changed" do + let(:old_tax) { create(:tax, organization:, code: "old_tax") } + let(:new_tax) { create(:tax, organization:, code: "new_tax") } + let(:kept_tax) { create(:tax, organization:, code: "kept_tax") } + let(:params) { {tax_codes: %w[new_tax kept_tax]} } + + before do + create(:billing_entity_applied_tax, billing_entity:, tax: old_tax) + create(:billing_entity_applied_tax, billing_entity:, tax: kept_tax) + new_tax + end + + it_behaves_like "produces a security log", "billing_entity.updated" do + before { update_service.call } + end + end + + it "produces an activity log" do + described_class.call(billing_entity:, params:) + + expect(Utils::ActivityLog).to have_produced("billing_entities.updated").after_commit.with(billing_entity) + end + + context "when document_number_prefix is sent" do + before { params[:document_number_prefix] = "abc" } + + it "converts document_number_prefix to upcase version" do + result = update_service.call + + expect(result.billing_entity.document_number_prefix).to eq("ABC") + end + end + + context "when finalize_zero_amount_invoice is sent" do + before { params[:finalize_zero_amount_invoice] = "false" } + + it "sets finalize_zero_amount_invoice" do + result = update_service.call + + expect(result.billing_entity.finalize_zero_amount_invoice).to eq(false) + end + end + + context "when document_number_prefix is invalid" do + before { params[:document_number_prefix] = "aaaaaaaaaaaaaaa" } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:document_number_prefix]).to eq(["value_is_too_long"]) + end + end + + context "with premium features", :premium do + let(:timezone) { "Europe/Paris" } + let(:email_settings) { ["invoice.finalized"] } + + it "updates the billing_entity" do + result = update_service.call + + expect(result.billing_entity.timezone).to eq("Europe/Paris") + expect(result.billing_entity.subscription_invoice_issuing_date_anchor).to eq("current_period_end") + expect(result.billing_entity.subscription_invoice_issuing_date_adjustment).to eq("keep_anchor") + end + + context "when updating invoice grace period" do + let(:customer) { create(:customer, billing_entity:) } + + let(:invoice_grace_period) { 2 } + + it "triggers async updates grace_period of invoices" do + old_invoice_grace_period = billing_entity.invoice_grace_period + result = update_service.call + + expect(result.billing_entity.invoice_grace_period).to eq(2) + expect(Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityJob).to have_been_enqueued.with( + billing_entity, + invoice_grace_period: old_invoice_grace_period, + subscription_invoice_issuing_date_anchor: "next_period_start", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date" + ) + end + end + + context "when updating net_payment_term" do + let(:customer) { create(:customer, billing_entity:) } + + let(:params) do + { + net_payment_term: 2 + } + end + + before do + allow(BillingEntities::UpdateInvoicePaymentDueDateService).to receive(:call).and_call_original + end + + it "updates the corresponding draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + result = update_service.call + expect(result).to be_success + + expect(result.billing_entity.net_payment_term).to eq(2) + expect(BillingEntities::UpdateInvoicePaymentDueDateService).to have_received(:call).with(billing_entity:, net_payment_term: 2) + end + end + end + end + + context "with base64 logo" do + let(:logo) do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + base64_logo = Base64.encode64(logo_file) + + "data:image/png;base64,#{base64_logo}" + end + + it "updates the billing_entity with logo" do + result = update_service.call + expect(result.billing_entity.logo.blob).not_to be_nil + end + end + + context "when logo is set but then removed" do + let(:logo) do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + base64_logo = Base64.encode64(logo_file) + + "data:image/png;base64,#{base64_logo}" + end + + it "removes the logo" do + update_service.call + result = described_class.new(billing_entity:, params: {logo: nil}).call + expect(result.billing_entity.logo.blob).to be_nil + end + end + + context "with validation errors" do + context "when invalid country" do + let(:country) { "---" } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:country]).to eq(["not_a_valid_country_code"]) + end + end + end + + context "when enable einvoicing" do + context "when country is not supported" do + let(:country) { "BR" } + let(:einvoicing) { true } + + before { billing_entity.update(einvoicing: true) } + + it "returns an error" do + result = update_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:einvoicing]).to eq(["country_not_supported"]) + end + end + + context "when country is nil" do + let(:country) { nil } + let(:einvoicing) { true } + + before { billing_entity.update(einvoicing: true) } + + it "returns an error" do + result = update_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:einvoicing]).to eq(["country_must_be_present"]) + end + end + end + + context "with eu tax management" do + context "with org within the EU" do + let(:params) { {eu_tax_management: true, country: "fr"} } + + before do + allow(BillingEntities::ChangeEuTaxManagementService).to receive(:call).and_call_original + end + + it "calls the taxes auto generate service" do + result = update_service.call + + expect(result).to be_success + expect(BillingEntities::ChangeEuTaxManagementService) + .to have_received(:call) + .with(billing_entity:, eu_tax_management: true) + end + end + + context "with org outside the EU" do + let(:params) { {eu_tax_management: true, country: "us"} } + + before do + allow(BillingEntities::ChangeEuTaxManagementService).to receive(:call).and_call_original + end + + it "calls the taxes auto generate service" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({eu_tax_management: ["billing_entity_must_be_in_eu"]}) + expect(BillingEntities::ChangeEuTaxManagementService) + .to have_received(:call) + .with(billing_entity:, eu_tax_management: true) + end + end + + context "with org is outside the EU but feature is already enabled" do + let(:params) { {eu_tax_management: false} } + + before do + billing_entity.country = "us" + billing_entity.eu_tax_management = true + allow(BillingEntities::ChangeEuTaxManagementService).to receive(:call).and_call_original + end + + it "can disable eu_tax_management" do + result = update_service.call + + expect(result).to be_success + expect(BillingEntities::ChangeEuTaxManagementService) + .to have_received(:call) + end + end + end + + context "when updating invoice_custom_sections" do + let(:params) { {invoice_custom_section_ids: [invoice_custom_section_1.id, invoice_custom_section_2.id]} } + + let(:invoice_custom_section_1) { create(:invoice_custom_section, organization:) } + let(:invoice_custom_section_2) { create(:invoice_custom_section, organization:) } + + it "updates the billing_entity" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to contain_exactly(invoice_custom_section_1, invoice_custom_section_2) + end + + context "when removing a section" do + let(:params) { {invoice_custom_section_ids: [invoice_custom_section_1.id]} } + + before do + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "removes the section" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to contain_exactly(invoice_custom_section_1) + end + end + + context "when adding a section" do + let(:params) { {invoice_custom_section_ids: [invoice_custom_section_1.id, invoice_custom_section_2.id]} } + + before do + create(:billing_entity_applied_invoice_custom_section, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "adds the section" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to contain_exactly(invoice_custom_section_1, invoice_custom_section_2) + end + end + + context "when removing all sections" do + let(:params) { {invoice_custom_section_ids: []} } + + it "removes all sections" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to be_empty + end + end + + context "when invoice_custom_section_codes are provided" do + let(:params) do + {invoice_custom_section_codes: [invoice_custom_section_1.code, invoice_custom_section_2.code]} + end + + it "updates the billing_entity" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to contain_exactly(invoice_custom_section_1, invoice_custom_section_2) + end + + context "when removing a section" do + let(:params) { {invoice_custom_section_codes: [invoice_custom_section_1.code]} } + + before do + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "removes the section" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to contain_exactly(invoice_custom_section_1) + end + end + + context "when adding a section" do + let(:params) { {invoice_custom_section_codes: [invoice_custom_section_1.code, invoice_custom_section_2.code]} } + + before do + create(:billing_entity_applied_invoice_custom_section, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "adds the section" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to contain_exactly(invoice_custom_section_1, invoice_custom_section_2) + end + end + + context "when removing all sections" do + let(:params) { {invoice_custom_section_cods: []} } + + it "removes all sections" do + result = update_service.call + + expect(result.billing_entity.selected_invoice_custom_sections).to be_empty + end + end + end + end + + context "when billing_entity is not provided" do + let(:billing_entity) { nil } + + it "raises an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + end + + it_behaves_like "does not produce a security log" do + before { update_service.call } + end + end + end +end diff --git a/spec/services/cache_service_spec.rb b/spec/services/cache_service_spec.rb new file mode 100644 index 0000000..63e08f1 --- /dev/null +++ b/spec/services/cache_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CacheService do + let(:test_cache_service_class) do + Class.new(described_class) do + def initialize(key_suffix = nil, expires_in: nil) + @key_suffix = key_suffix + super(nil, expires_in: expires_in) + end + + def cache_key + "test_cache_service:#{@key_suffix}" + end + end + end + + describe "#call" do + let(:cache_service) { test_cache_service_class.new("test", expires_in: nil) } + let(:cache_key) { cache_service.cache_key } + let(:cached_value) { "cached_value" } + let(:new_value) { "new_value" } + + context "when cache exists" do + before do + allow(Rails.cache).to receive(:read).with(cache_key).and_return(cached_value) + end + + it "returns cached value without calling the block" do + block_called = false + result = cache_service.call { + block_called = true + new_value + } + + expect(result).to eq(cached_value) + expect(block_called).to be false + end + end + + context "when cache does not exist" do + before do + allow(Rails.cache).to receive(:read).with(cache_key).and_return(nil) + allow(Rails.cache).to receive(:write) + end + + it "calls the block and caches the result" do + result = cache_service.call { new_value } + + expect(result).to eq(new_value) + expect(Rails.cache).to have_received(:write).with(cache_key, new_value, expires_in: nil) + end + end + + context "when expires_in is zero" do + let(:cache_service) { test_cache_service_class.new("test", expires_in: 0) } + + before do + allow(Rails.cache).to receive(:read).with(cache_key).and_return(nil) + allow(Rails.cache).to receive(:write) + end + + it "calls the block but does not cache the result" do + result = cache_service.call { new_value } + + expect(result).to eq(new_value) + expect(Rails.cache).not_to have_received(:write) + end + end + end + + describe "#expire_cache" do + let(:cache_service) { test_cache_service_class.new("test") } + let(:cache_key) { cache_service.cache_key } + + before do + allow(Rails.cache).to receive(:delete) + end + + it "deletes the cache" do + cache_service.expire_cache + + expect(Rails.cache).to have_received(:delete).with(cache_key) + end + end + + describe ".expire_cache" do + it "creates an instance and calls expire_cache" do + test_class = test_cache_service_class + instance = instance_double(test_class) + + allow(test_class).to receive(:new).with("test").and_return(instance) + allow(instance).to receive(:expire_cache) + + test_class.expire_cache("test") + + expect(instance).to have_received(:expire_cache) + end + end + + describe "#cache_key" do + it "raises NotImplementedError when called on the base class" do + expect { described_class.new.cache_key }.to raise_error(NotImplementedError) + end + end +end diff --git a/spec/services/charge_filters/cascade_service_spec.rb b/spec/services/charge_filters/cascade_service_spec.rb new file mode 100644 index 0000000..bb24bb0 --- /dev/null +++ b/spec/services/charge_filters/cascade_service_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::CascadeService do + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:bm_filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[us eu]) } + let(:parent_plan) { create(:plan, organization:) } + let(:parent_charge) { create(:standard_charge, plan: parent_plan, billable_metric:, properties: {amount: "0"}) } + + let(:child_plan) { create(:plan, organization:, parent: parent_plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, billable_metric:, parent: parent_charge, properties: {amount: "0"}) } + + let(:child_filter) do + filter = create(:charge_filter, charge: child_charge, invoice_display_name: "US region", properties: {amount: "10"}) + create(:charge_filter_value, charge_filter: filter, billable_metric_filter: bm_filter, values: ["us"]) + filter + end + + before do + create(:subscription, plan: child_plan, status: :active) + + parent_filter = create(:charge_filter, charge: parent_charge, invoice_display_name: "US region", properties: {amount: "10"}) + create(:charge_filter_value, charge_filter: parent_filter, billable_metric_filter: bm_filter, values: ["us"]) + + child_filter + end + + describe "#call" do + context "with update action" do + subject(:service) do + described_class.call( + charge: parent_charge, + action: "update", + filter_values: {"region" => ["us"]}, + old_properties: {"amount" => "10"}, + new_properties: {"amount" => "15"}, + invoice_display_name: "US region updated" + ) + end + + it "updates the matching child filter" do + service + + expect(child_filter.reload).to have_attributes( + properties: {"amount" => "15"}, + invoice_display_name: "US region updated" + ) + end + + context "when child filter was customized" do + let!(:child_filter) do + filter = create(:charge_filter, charge: child_charge, invoice_display_name: "Custom", properties: {amount: "99"}) + create(:charge_filter_value, charge_filter: filter, billable_metric_filter: bm_filter, values: ["us"]) + filter + end + + it "does not update the customized filter properties" do + service + + expect(child_filter.reload.properties).to eq({"amount" => "99"}) + end + + context "when new properties include pricing_group_keys" do + subject(:service) do + described_class.call( + charge: parent_charge, + action: "update", + filter_values: {"region" => ["us"]}, + old_properties: {"amount" => "10"}, + new_properties: {"amount" => "15", "pricing_group_keys" => ["agent_name"]}, + invoice_display_name: "US region updated" + ) + end + + it "cascades pricing_group_keys even though properties are customized" do + service + + expect(child_filter.reload.properties).to eq({"amount" => "99", "pricing_group_keys" => ["agent_name"]}) + end + end + + context "when new properties remove pricing_group_keys" do + subject(:service) do + described_class.call( + charge: parent_charge, + action: "update", + filter_values: {"region" => ["us"]}, + old_properties: {"amount" => "10", "pricing_group_keys" => ["old_key"]}, + new_properties: {"amount" => "15"}, + invoice_display_name: "US region updated" + ) + end + + let!(:child_filter) do + filter = create( + :charge_filter, + charge: child_charge, + invoice_display_name: "Custom", + properties: {"amount" => "99", "pricing_group_keys" => ["old_key"]} + ) + create(:charge_filter_value, charge_filter: filter, billable_metric_filter: bm_filter, values: ["us"]) + filter + end + + it "removes pricing_group_keys from the customized filter" do + service + + expect(child_filter.reload.properties).to eq({"amount" => "99"}) + end + end + end + + context "when child has no matching filter" do + before do + child_charge.filters.each do |f| + f.values.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + f.discard! + end + end + + it "succeeds without error" do + expect(service).to be_success + end + end + end + + context "with create action" do + subject(:service) do + described_class.call( + charge: parent_charge, + action: "create", + filter_values: {"region" => ["eu"]}, + new_properties: {"amount" => "20"}, + invoice_display_name: "EU region" + ) + end + + it "creates the filter on the child charge" do + expect { service }.to change { child_charge.filters.reload.count }.by(1) + + new_filter = child_charge.filters.find_by(invoice_display_name: "EU region") + expect(new_filter.properties).to eq({"amount" => "20"}) + expect(new_filter.to_h).to eq({"region" => ["eu"]}) + end + + context "when child already has the filter" do + before do + existing = create(:charge_filter, charge: child_charge, properties: {amount: "20"}) + create(:charge_filter_value, charge_filter: existing, billable_metric_filter: bm_filter, values: ["eu"]) + end + + it "does not create a duplicate" do + expect { service }.not_to change { child_charge.filters.reload.count } + end + end + end + + context "with destroy action" do + subject(:service) do + described_class.call( + charge: parent_charge, + action: "destroy", + filter_values: {"region" => ["us"]} + ) + end + + it "discards the matching child filter and its values" do + service + + expect(child_filter.reload).to be_discarded + expect(child_filter.values.kept).to be_empty + end + + context "when child has no matching filter" do + before do + child_charge.filters.each do |f| + f.values.update_all(deleted_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + f.discard! + end + end + + it "succeeds without error" do + expect(service).to be_success + end + end + end + + context "when child charge has no active subscription" do + subject(:service) do + described_class.call( + charge: parent_charge, + action: "update", + filter_values: {"region" => ["us"]}, + old_properties: {"amount" => "10"}, + new_properties: {"amount" => "15"}, + invoice_display_name: "US region" + ) + end + + before do + Subscription.update_all(status: :terminated) # rubocop:disable Rails/SkipsModelValidations + end + + it "does not update the child filter" do + service + + expect(child_filter.reload.properties).to eq({"amount" => "10"}) + end + end + end +end diff --git a/spec/services/charge_filters/create_or_update_batch_service_spec.rb b/spec/services/charge_filters/create_or_update_batch_service_spec.rb new file mode 100644 index 0000000..9a35116 --- /dev/null +++ b/spec/services/charge_filters/create_or_update_batch_service_spec.rb @@ -0,0 +1,537 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::CreateOrUpdateBatchService do + subject(:service) { described_class.call(charge:, filters_params:, cascade_options:) } + + let(:charge) { create(:standard_charge) } + let(:filters_params) { {} } + let(:cascade_options) { {} } + + let(:card_location_filter) do + create( + :billable_metric_filter, + billable_metric: charge.billable_metric, + key: "card_location", + values: %w[domestic international] + ) + end + + let(:scheme_filter) do + create( + :billable_metric_filter, + billable_metric: charge.billable_metric, + key: "scheme", + values: %w[visa mastercard] + ) + end + + let(:card_type_filter) do + create( + :billable_metric_filter, + billable_metric: charge.billable_metric, + key: "card_type", + values: %w[debit credit] + ) + end + + context "when filter values hash is empty" do + let(:filters_params) do + [ + { + values: {}, + invoice_display_name: "Invalid filter", + properties: {amount: "10"} + } + ] + end + + before { card_location_filter } + + it "returns a validation failure" do + expect(service).not_to be_success + expect(service.error).to be_a(BaseService::ValidationFailure) + expect(service.error.messages[:values]).to eq(["value_is_mandatory"]) + end + + it "does not create any filters" do + expect { service }.not_to change(ChargeFilter, :count) + end + end + + context "when filter params is empty" do + it "does not create any filters" do + expect { service }.not_to change(ChargeFilter, :count) + end + + context "when there are existing filters" do + let(:filter) { create(:charge_filter, charge:) } + + let(:filter_value) do + create( + :charge_filter_value, + charge_filter: filter, + billable_metric_filter: card_location_filter, + values: [card_location_filter.values.first] + ) + end + + before { filter_value } + + it "discards all filters and the related values" do + expect { service }.to change { filter.reload.discarded? }.to(true) + .and change { filter_value.reload.discarded? }.to(true) + end + + context "with cascade_updates set to true and existing filters" do + let(:charge_parent) { create(:standard_charge) } + let(:filter_extra) { create(:charge_filter, charge:) } + let(:filter_parent) { create(:charge_filter, charge: charge_parent) } + let(:filter_value_extra) do + create( + :charge_filter_value, + charge_filter: filter_extra, + billable_metric_filter: card_location_filter, + values: [card_location_filter.values.second] + ) + end + let(:filter_value_parent) do + create( + :charge_filter_value, + charge_filter: filter_parent, + billable_metric_filter: card_location_filter, + values: [card_location_filter.values.first] + ) + end + let(:cascade_options) do + { + cascade: true, + parent_filters: charge_parent.filters.map(&:attributes) + } + end + + before do + filter_value_extra + filter_value_parent + end + + it "discards all filters and the related values that are inherited from parent" do + expect { service }.to change { filter.reload.discarded? }.to(true) + .and change { filter_value.reload.discarded? }.to(true) + end + + it "does not discard filters and the related values that are defined on child" do + service + + expect(filter_extra.reload.discarded?).to eq(false) + expect(filter_value_extra.reload.discarded?).to eq(false) + end + end + end + end + + context "with new filters" do + let(:filters_params) do + [ + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => ["visa"] + }, + invoice_display_name: "Visa domestic card payment", + properties: {amount: "10"} + }, + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => ["visa"], + card_type_filter.key => ["debit"] + }, + invoice_display_name: "Visa debit domestic card payment", + properties: {amount: "20", pricing_group_keys: ["region"]} + } + ] + end + + it "creates the filters and their values" do + expect { service }.to change(ChargeFilter, :count).by(2) + + filter1 = charge.filters.find_by(invoice_display_name: "Visa domestic card payment") + expect(filter1).to have_attributes( + invoice_display_name: "Visa domestic card payment", + properties: {"amount" => "10"} + ) + expect(filter1.values.count).to eq(2) + expect(filter1.values.pluck(:values).flatten).to match_array(%w[domestic visa]) + + filter2 = charge.filters.find_by(invoice_display_name: "Visa debit domestic card payment") + expect(filter2).to have_attributes( + invoice_display_name: "Visa debit domestic card payment", + properties: {"amount" => "20", "pricing_group_keys" => ["region"]} + ) + expect(filter2.values.count).to eq(3) + expect(filter2.values.pluck(:values).flatten).to match_array(%w[domestic visa debit]) + end + + context "when filters properties contain not relevant values" do + let(:charge) { create(:graduated_charge) } + let(:filters_params) do + [ + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => ["visa"] + }, + invoice_display_name: "Visa domestic card payment", + properties: {amount: "10", graduated_ranges: [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "200"}]} + }, + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => ["visa"], + card_type_filter.key => ["debit"] + }, + invoice_display_name: "Visa debit domestic card payment", + properties: {amount: "20", graduated_ranges: [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "200"}]} + } + ] + end + + it "removes the not relevant values from the properties" do + expect { service }.to change(ChargeFilter, :count).by(2) + + filter1 = charge.filters.find_by(invoice_display_name: "Visa domestic card payment") + expect(filter1.properties).to eq("graduated_ranges" => [ + {"from_value" => 0, "to_value" => nil, "per_unit_amount" => "0", "flat_amount" => "200"} + ]) + + filter2 = charge.filters.find_by(invoice_display_name: "Visa debit domestic card payment") + expect(filter2.properties).to eq("graduated_ranges" => [ + {"from_value" => 0, "to_value" => nil, "per_unit_amount" => "0", "flat_amount" => "200"} + ]) + end + end + + context "when filter properties contain presentation_group_keys" do + let(:filters_params) do + [ + { + values: {card_location_filter.key => ["domestic"], scheme_filter.key => ["visa"]}, + invoice_display_name: "Visa domestic card payment", + properties: {amount: "10", presentation_group_keys: [{"value" => "region"}]} + } + ] + end + + it "strips presentation_group_keys from the stored properties" do + expect { service }.to change(ChargeFilter, :count).by(1) + + filter = charge.filters.find_by(invoice_display_name: "Visa domestic card payment") + expect(filter.properties).to eq("amount" => "10") + end + end + end + + context "with existing filters" do + let(:filter) { create(:charge_filter, charge:) } + let(:filter_values) do + [ + create( + :charge_filter_value, + charge_filter: filter, + billable_metric_filter: card_location_filter, + values: ["domestic"] + ), + create( + :charge_filter_value, + charge_filter: filter, + billable_metric_filter: scheme_filter, + values: ["visa"] + ) + ] + end + + let(:filters_params) do + [ + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => ["visa"] + }, + invoice_display_name: "New display name", + properties: {amount: "20"}.merge(pricing_group_keys) + } + ] + end + + let(:pricing_group_keys) { {pricing_group_keys: ["region"]} } + + before { filter_values } + + it "updates the filter" do + expect { service }.not_to change(ChargeFilter, :count) + expect(filter.reload).to have_attributes( + invoice_display_name: "New display name", + properties: {"amount" => "20", "pricing_group_keys" => ["region"]} + ) + expect(filter.values.count).to eq(2) + expect(filter.values.pluck(:values).flatten).to match_array(%w[domestic visa]) + end + + context "when changing filter values" do + let(:filters_params) do + [ + { + values: { + card_location_filter.key => ["international"], + scheme_filter.key => ["mastercard"] + }, + invoice_display_name: "New display name", + properties: {amount: "20"} + } + ] + end + + it "creates a new filter and removes the existing one" do + result = service + + expect(result.filters.count).to eq(1) + expect(filter.reload).to be_discarded + + new_filter = result.filters.first + expect(new_filter.values.count).to eq(2) + expect(new_filter.values.pluck(:values).flatten).to match_array(%w[international mastercard]) + end + end + + context "when adding a value into filter values" do + let(:filters_params) do + [ + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => %w[visa mastercard] + }, + invoice_display_name: "New display name", + properties: {amount: "20"} + } + ] + end + + it "creates a new filter and removes the existing one" do + result = service + + expect(result.filters.count).to eq(1) + expect(filter.reload).to be_discarded + + new_filter = result.filters.first + expect(new_filter.values.count).to eq(2) + expect(new_filter.values.pluck(:values).flatten).to match_array(%w[domestic visa mastercard]) + end + end + + context "with cascading option" do + let(:charge_parent) { create(:standard_charge) } + let(:filter_extra) { create(:charge_filter, charge:) } + let(:filter_parent) { create(:charge_filter, properties: filter.properties, charge: charge_parent) } + let(:filter_value_extra) do + create( + :charge_filter_value, + charge_filter: filter_extra, + billable_metric_filter: card_location_filter, + values: [card_location_filter.values.second] + ) + end + let(:filter_values_parent) do + [ + create( + :charge_filter_value, + charge_filter: filter_parent, + billable_metric_filter: card_location_filter, + values: ["domestic"] + ), + create( + :charge_filter_value, + charge_filter: filter_parent, + billable_metric_filter: scheme_filter, + values: ["visa"] + ) + ] + end + let(:cascade_options) do + { + cascade: true, + parent_filters: charge_parent.filters.map(&:attributes) + } + end + + before do + filter_values_parent + filter_value_extra + end + + it "updates the filter if child and parent properties are the same" do + expect { service }.not_to change(ChargeFilter, :count) + + expect(filter.reload).to have_attributes( + invoice_display_name: "New display name", + properties: {"amount" => "20", "pricing_group_keys" => ["region"]} + ) + expect(filter.values.count).to eq(2) + expect(filter.values.pluck(:values).flatten).to match_array(%w[domestic visa]) + end + + context "when properties are already overridden" do + let(:properties) { {amount: "755"} } + let(:pricing_group_keys) { {} } + let(:filter_parent) { create(:charge_filter, properties:, charge: charge_parent) } + + it "does not update the filter" do + expect { service }.not_to change(ChargeFilter, :count) + + expect(filter.reload).to have_attributes( + invoice_display_name: nil, + properties: charge.properties + ) + expect(filter.values.count).to eq(2) + expect(filter.values.pluck(:values).flatten).to match_array(%w[domestic visa]) + end + + context "when properties contains a pricing_group_keys attribute" do + let(:pricing_group_keys) { {pricing_group_keys: ["region"]} } + + it "updates the filter" do + expect { service }.not_to change(ChargeFilter, :count) + + expect(filter.reload.pricing_group_keys).to eq(["region"]) + expect(filter.values.count).to eq(2) + expect(filter.values.pluck(:values).flatten).to match_array(%w[domestic visa]) + end + + context "when filters already have a pricing_group_keys value" do + let(:filter) { create(:charge_filter, charge:, properties: {amount: "755", pricing_group_keys: ["cloud"]}) } + + it "updates the filter" do + expect { service }.not_to change(ChargeFilter, :count) + + expect(filter.reload.pricing_group_keys).to eq(["region"]) + expect(filter.values.count).to eq(2) + expect(filter.values.pluck(:values).flatten).to match_array(%w[domestic visa]) + end + end + end + + context "when filters already has a pricing_group_keys value" do + let(:filter) { create(:charge_filter, charge:, properties: {amount: "755", pricing_group_keys: ["cloud"]}) } + let(:pricing_group_keys) { {} } + + it "updates the filter" do + expect { service }.not_to change(ChargeFilter, :count) + + expect(filter.reload.pricing_group_keys).to be_nil + expect(filter.values.count).to eq(2) + expect(filter.values.pluck(:values).flatten).to match_array(%w[domestic visa]) + end + end + + context "when child filter has float-drifted property values" do + let(:filter) do + create(:charge_filter, charge:, properties: {"amount" => "0.0011574074099999999"}) + end + let(:filter_parent) do + create(:charge_filter, properties: {"amount" => "0.00115740741"}, charge: charge_parent) + end + let(:pricing_group_keys) { {} } + + it "treats them as equal and applies the cascade update" do + expect { service }.not_to change(ChargeFilter, :count) + + expect(filter.reload).to have_attributes( + invoice_display_name: "New display name", + properties: {"amount" => "20"} + ) + end + + it "does not re-persist the drifted value on subsequent cascades" do + # First cascade: parent properties unchanged, child has drifted value + # Without normalization this would skip the cascade and re-save the drifted value + unchanged_filters_params = [ + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => ["visa"] + }, + invoice_display_name: nil, + properties: {amount: "0.00115740741"} + } + ] + + described_class.call(charge:, filters_params: unchanged_filters_params, cascade_options:) + + # The filter should not have been touched — properties stay as they were + # because the cascade recognized them as equivalent and applied the update + expect(filter.reload).to have_attributes( + invoice_display_name: nil, + properties: {"amount" => "0.00115740741"} + ) + end + end + end + + context "when changing filter values" do + let(:filters_params) do + [ + { + values: { + card_location_filter.key => ["international"], + scheme_filter.key => ["mastercard"] + }, + invoice_display_name: "New display name", + properties: {amount: "20"} + } + ] + end + + it "creates a new filter and removes only the one that matches with parent" do + result = service + + expect(result.filters.count).to eq(1) + expect(filter.reload).to be_discarded + expect(filter_value_extra.reload).not_to be_discarded + + new_filter = result.filters.first + expect(new_filter.values.count).to eq(2) + expect(new_filter.values.pluck(:values).flatten).to match_array(%w[international mastercard]) + end + end + + context "when adding a value into filter values" do + let(:filters_params) do + [ + { + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => %w[visa mastercard] + }, + invoice_display_name: "New display name", + properties: {amount: "20"} + } + ] + end + + it "creates a new filter and removes only the one that matches with parent" do + result = service + + expect(result.filters.count).to eq(1) + expect(filter.reload).to be_discarded + expect(filter_value_extra.reload).not_to be_discarded + + new_filter = result.filters.first + expect(new_filter.values.count).to eq(2) + expect(new_filter.values.pluck(:values).flatten).to match_array(%w[domestic visa mastercard]) + end + end + end + end +end diff --git a/spec/services/charge_filters/create_service_spec.rb b/spec/services/charge_filters/create_service_spec.rb new file mode 100644 index 0000000..81e414d --- /dev/null +++ b/spec/services/charge_filters/create_service_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::CreateService do + subject(:service) { described_class.call(charge:, params:) } + + let(:charge) { create(:standard_charge) } + let(:params) { {} } + + let(:card_location_filter) do + create( + :billable_metric_filter, + billable_metric: charge.billable_metric, + key: "card_location", + values: %w[domestic international] + ) + end + + let(:scheme_filter) do + create( + :billable_metric_filter, + billable_metric: charge.billable_metric, + key: "scheme", + values: %w[visa mastercard] + ) + end + + describe "#call" do + context "when charge is nil" do + let(:charge) { nil } + + it "returns not found failure" do + expect(service).not_to be_success + expect(service.error).to be_a(BaseService::NotFoundFailure) + expect(service.error.resource).to eq("charge") + end + end + + context "when values are empty" do + let(:params) do + { + invoice_display_name: "Test Filter", + properties: {amount: "10"}, + values: {} + } + end + + before { card_location_filter } + + it "returns a validation failure" do + expect(service).not_to be_success + expect(service.error).to be_a(BaseService::ValidationFailure) + expect(service.error.messages[:values]).to eq(["value_is_mandatory"]) + end + end + + context "when values are missing" do + let(:params) do + { + invoice_display_name: "Test Filter", + properties: {amount: "10"} + } + end + + before { card_location_filter } + + it "returns a validation failure" do + expect(service).not_to be_success + expect(service.error).to be_a(BaseService::ValidationFailure) + expect(service.error.messages[:values]).to eq(["value_is_mandatory"]) + end + end + + context "with valid params" do + let(:params) do + { + invoice_display_name: "Domestic Visa", + properties: {amount: "50"}, + values: { + card_location_filter.key => ["domestic"], + scheme_filter.key => ["visa"] + } + } + end + + it "creates a charge filter" do + expect { service }.to change(ChargeFilter, :count).by(1) + expect(service).to be_success + + charge_filter = service.charge_filter + expect(charge_filter).to have_attributes( + invoice_display_name: "Domestic Visa", + properties: {"amount" => "50"}, + organization_id: charge.organization_id + ) + end + + it "creates charge filter values" do + expect { service }.to change(ChargeFilterValue, :count).by(2) + + charge_filter = service.charge_filter + expect(charge_filter.values.count).to eq(2) + expect(charge_filter.to_h).to eq({ + "card_location" => ["domestic"], + "scheme" => ["visa"] + }) + end + end + + context "with graduated charge model" do + let(:charge) { create(:graduated_charge) } + let(:params) do + { + invoice_display_name: "Domestic Filter", + properties: { + amount: "10", + graduated_ranges: [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "200"}] + }, + values: {card_location_filter.key => ["domestic"]} + } + end + + it "filters properties based on charge model" do + expect(service).to be_success + + charge_filter = service.charge_filter + expect(charge_filter.properties).to eq( + "graduated_ranges" => [ + {"from_value" => 0, "to_value" => nil, "per_unit_amount" => "0", "flat_amount" => "200"} + ] + ) + expect(charge_filter.properties).not_to have_key("amount") + end + end + + context "with pricing_group_keys in properties" do + let(:params) do + { + invoice_display_name: "Grouped Filter", + properties: {amount: "30", pricing_group_keys: ["region"]}, + values: {card_location_filter.key => ["domestic"]} + } + end + + it "preserves pricing_group_keys in properties" do + expect(service).to be_success + + charge_filter = service.charge_filter + expect(charge_filter.properties).to eq({ + "amount" => "30", + "pricing_group_keys" => ["region"] + }) + end + end + + context "with presentation_group_keys in properties" do + let(:params) do + { + invoice_display_name: "Domestic Filter", + properties: {amount: "50", presentation_group_keys: [{value: "region"}]}, + values: {card_location_filter.key => ["domestic"]} + } + end + + it "ignores presentation_group_keys" do + expect(service).to be_success + + charge_filter = service.charge_filter + expect(charge_filter.properties).to eq({"amount" => "50"}) + expect(charge_filter.properties).not_to have_key("presentation_group_keys") + end + end + end +end diff --git a/spec/services/charge_filters/destroy_service_spec.rb b/spec/services/charge_filters/destroy_service_spec.rb new file mode 100644 index 0000000..bb684ea --- /dev/null +++ b/spec/services/charge_filters/destroy_service_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::DestroyService do + subject(:service) { described_class.call(charge_filter:) } + + let(:charge) { create(:standard_charge) } + let(:charge_filter) { create(:charge_filter, charge:) } + + let(:card_location_filter) do + create( + :billable_metric_filter, + billable_metric: charge.billable_metric, + key: "card_location", + values: %w[domestic international] + ) + end + + describe "#call" do + context "when charge_filter is nil" do + subject(:service) { described_class.call(charge_filter: nil) } + + it "returns not found failure" do + expect(service).not_to be_success + expect(service.error).to be_a(BaseService::NotFoundFailure) + expect(service.error.resource).to eq("charge_filter") + end + end + + context "with valid charge_filter" do + let(:filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter: card_location_filter, values: ["domestic"]) + end + + before { filter_value } + + it "soft deletes the charge filter" do + expect { service }.to change { charge_filter.reload.discarded? }.from(false).to(true) + expect(service).to be_success + expect(service.charge_filter).to eq(charge_filter) + end + + it "soft deletes the charge filter values" do + expect { service }.to change { filter_value.reload.discarded? }.from(false).to(true) + end + + it "returns the discarded charge filter" do + result = service + expect(result.charge_filter.deleted_at).to be_present + end + end + + context "with cascade_updates" do + subject(:service) { described_class.call(charge_filter:, cascade_updates: true) } + + let(:child_plan) { create(:plan, organization: charge.organization, parent: charge.plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization: charge.organization, billable_metric: charge.billable_metric, parent: charge) } + let(:filter_value) { create(:charge_filter_value, charge_filter:, billable_metric_filter: card_location_filter, values: ["domestic"]) } + + before do + filter_value + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(ChargeFilters::CascadeJob).to receive(:perform_later) + end + + it "triggers filter-level cascade via ChargeFilters::CascadeJob" do + service + + expect(ChargeFilters::CascadeJob).to have_received(:perform_later).with( + charge.id, + "destroy", + hash_including("card_location"), + nil, + nil, + nil + ) + end + end + + context "without cascade_updates when charge has children" do + let(:child_plan) { create(:plan, organization: charge.organization, parent: charge.plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization: charge.organization, billable_metric: charge.billable_metric, parent: charge) } + let(:filter_value) { create(:charge_filter_value, charge_filter:, billable_metric_filter: card_location_filter, values: ["domestic"]) } + + before do + filter_value + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(Charges::UpdateChildrenJob).to receive(:perform_later) + end + + it "does not trigger cascade update" do + service + + expect(Charges::UpdateChildrenJob).not_to have_received(:perform_later) + end + end + end +end diff --git a/spec/services/charge_filters/event_matching_service_spec.rb b/spec/services/charge_filters/event_matching_service_spec.rb new file mode 100644 index 0000000..e037fa1 --- /dev/null +++ b/spec/services/charge_filters/event_matching_service_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::EventMatchingService do + subject(:service_result) { described_class.call(charge:, event:) } + + let(:organization) { create(:organization) } + + let(:event_properties) do + { + payment_method: "card", + card_location: "domestic", + scheme: "visa", + card_type: "credit", + card_number: 2 + } + end + + let(:event) do + create( + :event, + organization_id: organization.id, + code: billable_metric.code, + properties: event_properties + ) + end + + let(:billable_metric) { create(:billable_metric, organization:) } + + let(:charge) { create(:standard_charge, billable_metric:) } + + let(:payment_method) do + create(:billable_metric_filter, billable_metric:, key: "payment_method", values: %i[card virtual_card transfer]) + end + let(:card_location) { create(:billable_metric_filter, billable_metric:, key: "card_location", values: %i[domestic]) } + let(:scheme) { create(:billable_metric_filter, billable_metric:, key: "scheme", values: %i[visa mastercard]) } + let(:card_type) { create(:billable_metric_filter, billable_metric:, key: "card_type", values: %i[credit debit]) } + let(:card_number) { create(:billable_metric_filter, billable_metric:, key: "card_number", values: %i[1 2 3]) } + + let(:filter1) { create(:charge_filter, charge:) } + let(:filter1_values) do + [ + create(:charge_filter_value, values: ["card"], billable_metric_filter: payment_method, charge_filter: filter1), + create(:charge_filter_value, values: ["domestic"], billable_metric_filter: card_location, charge_filter: filter1), + create( + :charge_filter_value, + values: %w[visa mastercard], + billable_metric_filter: scheme, + charge_filter: filter1 + ) + ] + end + + let(:filter2) { create(:charge_filter, charge:) } + let(:filter2_values) do + [ + create(:charge_filter_value, values: ["card"], billable_metric_filter: payment_method, charge_filter: filter2), + create(:charge_filter_value, values: ["domestic"], billable_metric_filter: card_location, charge_filter: filter2), + create( + :charge_filter_value, + values: %w[visa mastercard], + billable_metric_filter: scheme, + charge_filter: filter2 + ), + create(:charge_filter_value, values: ["credit"], billable_metric_filter: card_type, charge_filter: filter2), + create(:charge_filter_value, values: ["2"], billable_metric_filter: card_number, charge_filter: filter2) + ] + end + + before do + filter1_values + filter2_values + end + + it "returns the filter matching the most properties" do + expect(service_result.charge_filter).to eq(filter2) + end + + context "when event does not match any filter" do + let(:event_properties) { {} } + + it "returns nil" do + expect(service_result.charge_filter).to be_nil + end + end + + context "with an ALL_FILTER_VALUES filter" do + let(:filter2_values) do + [ + create(:charge_filter_value, values: ["card"], billable_metric_filter: payment_method, charge_filter: filter2), + create(:charge_filter_value, values: ["domestic"], billable_metric_filter: card_location, charge_filter: filter2), + create( + :charge_filter_value, + values: [ChargeFilterValue::ALL_FILTER_VALUES], + billable_metric_filter: scheme, + charge_filter: filter2 + ), + create(:charge_filter_value, values: ["credit"], billable_metric_filter: card_type, charge_filter: filter2), + create(:charge_filter_value, values: ["2"], billable_metric_filter: card_number, charge_filter: filter2) + ] + end + + it "returns the filter matching the most properties" do + expect(service_result.charge_filter).to eq(filter2) + end + end +end diff --git a/spec/services/charge_filters/matching_and_ignored_service_spec.rb b/spec/services/charge_filters/matching_and_ignored_service_spec.rb new file mode 100644 index 0000000..cfa4753 --- /dev/null +++ b/spec/services/charge_filters/matching_and_ignored_service_spec.rb @@ -0,0 +1,261 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::MatchingAndIgnoredService do + subject(:service_result) { described_class.call(charge:, filter: current_filter) } + + let(:billable_metric) { create(:billable_metric) } + let(:charge) { create(:standard_charge, billable_metric:) } + + let(:filter_steps) { create(:billable_metric_filter, billable_metric:, key: "steps", values: %w[25 50 75 100]) } + let(:filter_size) { create(:billable_metric_filter, billable_metric:, key: "size", values: %w[512 1024]) } + let(:filter_model) do + create(:billable_metric_filter, billable_metric:, key: "model", values: %w[llama-1 llama-2 llama-3 llama-4]) + end + + let(:f1) { create(:charge_filter, charge:, invoice_display_name: "f1") } + let(:f1_values) do + [ + create(:charge_filter_value, values: ["25"], billable_metric_filter: filter_steps, charge_filter: f1), + create(:charge_filter_value, values: ["512"], billable_metric_filter: filter_size, charge_filter: f1), + create(:charge_filter_value, values: ["llama-2"], billable_metric_filter: filter_model, charge_filter: f1) + ] + end + + let(:f2) { create(:charge_filter, charge:, invoice_display_name: "f2") } + let(:f2_values) do + [ + create(:charge_filter_value, values: ["25"], billable_metric_filter: filter_steps, charge_filter: f2), + create(:charge_filter_value, values: ["512"], billable_metric_filter: filter_size, charge_filter: f2) + ] + end + + let(:f3) { create(:charge_filter, charge:, invoice_display_name: "f3") } + let(:f3_values) do + [ + create( + :charge_filter_value, + values: [ChargeFilterValue::ALL_FILTER_VALUES], + billable_metric_filter: filter_steps, + charge_filter: f3 + ), + create( + :charge_filter_value, + values: [ChargeFilterValue::ALL_FILTER_VALUES], + billable_metric_filter: filter_size, + charge_filter: f3 + ) + ] + end + + let(:f4) { create(:charge_filter, charge:, invoice_display_name: "f4") } + let(:f4_values) do + [ + create( + :charge_filter_value, + values: [ChargeFilterValue::ALL_FILTER_VALUES], + billable_metric_filter: filter_size, + charge_filter: f4 + ) + ] + end + + let(:f5) { create(:charge_filter, charge:, invoice_display_name: "f5") } + let(:f5_values) do + [ + create( + :charge_filter_value, + values: ["512"], + billable_metric_filter: filter_size, + charge_filter: f5 + ) + ] + end + + before do + f1 + f1_values + f2 + f2_values + f3 + f3_values + f4 + f4_values + f5 + f5_values + end + + describe "for f1" do + let(:current_filter) { f1 } + + it "returns a formatted hash" do + expect(service_result.matching_filters).to eq({"size" => %w[512], "steps" => %w[25], "model" => %w[llama-2]}) + expect(service_result.ignored_filters).to eq([]) + end + end + + describe "for f2" do + let(:current_filter) { f2 } + + it "returns a formatted hash" do + expect(service_result.matching_filters).to eq({"size" => %w[512], "steps" => %w[25]}) + expect(service_result.ignored_filters).to eq( + [ + {"model" => %w[llama-2], "size" => %w[512], "steps" => %w[25]}, + {"size" => ["1024"], "steps" => %w[50 75 100]} + ] + ) + end + end + + describe "for f3" do + let(:current_filter) { f3 } + + it "returns a formatted hash" do + expect(service_result.matching_filters).to eq({"size" => %w[512 1024], "steps" => %w[25 50 75 100]}) + expect(service_result.ignored_filters).to eq( + [ + {"model" => ["llama-2"], "size" => ["512"], "steps" => ["25"]}, + {"size" => ["512"], "steps" => ["25"]} + ] + ) + end + end + + describe "for f4" do + let(:current_filter) { f4 } + + it "returns a formatted hash" do + expect(service_result.matching_filters).to eq({"size" => %w[512 1024]}) + expect(service_result.ignored_filters).to eq( + [ + {"model" => ["llama-2"], "size" => ["512"], "steps" => ["25"]}, + {"size" => ["512"], "steps" => ["25"]}, + {"size" => %w[512 1024], "steps" => %w[25 50 75 100]}, + {"size" => ["512"]} + ] + ) + end + end + + describe "for f5" do + let(:current_filter) { f5 } + + it "returns a formatted hash" do + expect(service_result.matching_filters).to eq({"size" => %w[512]}) + expect(service_result.ignored_filters).to eq( + [ + {"model" => ["llama-2"], "size" => ["512"], "steps" => ["25"]}, + {"size" => ["512"], "steps" => ["25"]}, + {"size" => %w[512 1024], "steps" => %w[25 50 75 100]}, + {"size" => ["1024"]} + ] + ) + end + end + + # The following contexts cover edge cases where ignored_filters contains + # empty hashes or hashes with all-empty-array values. These states should + # not occur in normal usage — charge filters should always have values, + # and duplicate filters should not exist — but missing validations allow + # them in production. The store-level defensive guards (ISSUE-1799) prevent + # these from producing invalid SQL. + + context "when a filter has no values" do + subject(:service_result) { described_class.call(charge: isolated_charge, filter: current_filter) } + + let(:isolated_charge) { create(:standard_charge, billable_metric:) } + + let(:empty_a) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "empty_a") } + let(:empty_b) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "empty_b") } + let(:with_values) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "with_values") } + + before do + empty_a + empty_b + create(:charge_filter_value, values: ["512"], billable_metric_filter: filter_size, charge_filter: with_values) + end + + describe "for empty_a" do + let(:current_filter) { empty_a } + + it "produces empty hashes in ignored_filters from other empty filters" do + expect(service_result.matching_filters).to eq({}) + expect(service_result.ignored_filters).to eq( + [ + {}, + {"size" => ["512"]} + ] + ) + end + end + + describe "for with_values" do + let(:current_filter) { with_values } + + it "does not include empty filters as children" do + expect(service_result.matching_filters).to eq({"size" => ["512"]}) + expect(service_result.ignored_filters).to eq([]) + end + end + end + + context "when a child's values are a subset of the parent's" do + subject(:service_result) { described_class.call(charge: isolated_charge, filter: current_filter) } + + let(:isolated_charge) { create(:standard_charge, billable_metric:) } + + let(:parent_filter) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "parent") } + let(:subset_child) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "subset_child") } + let(:partial_overlap) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "partial") } + + before do + create(:charge_filter_value, values: %w[512 1024], billable_metric_filter: filter_size, charge_filter: parent_filter) + create(:charge_filter_value, values: ["512"], billable_metric_filter: filter_size, charge_filter: subset_child) + create(:charge_filter_value, values: ["512"], billable_metric_filter: filter_size, charge_filter: partial_overlap) + create(:charge_filter_value, values: ["25"], billable_metric_filter: filter_steps, charge_filter: partial_overlap) + end + + describe "for parent_filter" do + let(:current_filter) { parent_filter } + + it "produces all-empty-values entry from subset child and keeps different-key children intact" do + expect(service_result.matching_filters).to eq({"size" => %w[512 1024]}) + expect(service_result.ignored_filters).to eq( + [ + {"size" => []}, + {"size" => ["512"], "steps" => ["25"]} + ] + ) + end + end + end + + context "when two filters have identical keys and values" do + subject(:service_result) { described_class.call(charge: isolated_charge, filter: current_filter) } + + let(:isolated_charge) { create(:standard_charge, billable_metric:) } + + let(:filter_a) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "filter_a") } + let(:filter_b) { create(:charge_filter, charge: isolated_charge, invoice_display_name: "filter_b") } + + before do + create(:charge_filter_value, values: ["512"], billable_metric_filter: filter_size, charge_filter: filter_a) + create(:charge_filter_value, values: ["25"], billable_metric_filter: filter_steps, charge_filter: filter_a) + create(:charge_filter_value, values: ["512"], billable_metric_filter: filter_size, charge_filter: filter_b) + create(:charge_filter_value, values: ["25"], billable_metric_filter: filter_steps, charge_filter: filter_b) + end + + describe "for filter_a" do + let(:current_filter) { filter_a } + + it "produces all-empty-values entry from the identical sibling" do + expect(service_result.matching_filters).to eq({"size" => ["512"], "steps" => ["25"]}) + expect(service_result.ignored_filters).to eq( + [{"size" => [], "steps" => []}] + ) + end + end + end +end diff --git a/spec/services/charge_filters/update_service_spec.rb b/spec/services/charge_filters/update_service_spec.rb new file mode 100644 index 0000000..d0bf635 --- /dev/null +++ b/spec/services/charge_filters/update_service_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeFilters::UpdateService do + subject(:service) { described_class.call(charge_filter:, params:) } + + let(:charge) { create(:standard_charge) } + let(:charge_filter) { create(:charge_filter, charge:, invoice_display_name: "Original Name", properties: {"amount" => "10"}) } + let(:params) { {} } + + let(:card_location_filter) do + create( + :billable_metric_filter, + billable_metric: charge.billable_metric, + key: "card_location", + values: %w[domestic international] + ) + end + + describe "#call" do + context "when charge_filter is nil" do + subject(:service) { described_class.call(charge_filter: nil, params: {}) } + + it "returns not found failure" do + expect(service).not_to be_success + expect(service.error).to be_a(BaseService::NotFoundFailure) + expect(service.error.resource).to eq("charge_filter") + end + end + + context "when updating invoice_display_name and properties" do + let(:params) do + { + invoice_display_name: "New Display Name", + properties: {amount: "200"} + } + end + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter: card_location_filter, values: ["domestic"]) + end + + it "updates both attributes" do + expect(service).to be_success + expect(charge_filter.reload).to have_attributes( + invoice_display_name: "New Display Name", + properties: {"amount" => "200"} + ) + end + end + + context "with presentation_group_keys in properties" do + let(:params) do + { + properties: {amount: "200", presentation_group_keys: [{value: "region"}]} + } + end + + it "ignores presentation_group_keys" do + expect(service).to be_success + expect(charge_filter.reload.properties).to eq({"amount" => "200"}) + expect(charge_filter.reload.properties).not_to have_key("presentation_group_keys") + end + end + + context "with graduated charge model" do + let(:charge) { create(:graduated_charge) } + let(:charge_filter) { create(:charge_filter, charge:, properties: {"graduated_ranges" => [{"from_value" => 0, "to_value" => nil, "per_unit_amount" => "0", "flat_amount" => "100"}]}) } + let(:params) do + { + properties: { + amount: "10", + graduated_ranges: [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "200"}] + } + } + end + + it "filters properties based on charge model" do + expect(service).to be_success + expect(charge_filter.reload.properties).to eq( + "graduated_ranges" => [ + {"from_value" => 0, "to_value" => nil, "per_unit_amount" => "0", "flat_amount" => "200"} + ] + ) + end + end + + context "with cascade_updates" do + subject(:service) { described_class.call(charge_filter:, params:, cascade_updates: true) } + + let(:child_plan) { create(:plan, organization: charge.organization, parent: charge.plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization: charge.organization, billable_metric: charge.billable_metric, parent: charge) } + let(:params) { {properties: {amount: "150"}} } + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter: card_location_filter, values: ["domestic"]) + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(ChargeFilters::CascadeJob).to receive(:perform_later) + end + + it "triggers filter-level cascade via ChargeFilters::CascadeJob" do + service + + expect(ChargeFilters::CascadeJob).to have_received(:perform_later).with( + charge.id, + "update", + hash_including("card_location"), + hash_including("amount"), + hash_including("amount"), + anything + ) + end + end + + context "without cascade_updates when charge has children" do + let(:child_plan) { create(:plan, organization: charge.organization, parent: charge.plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization: charge.organization, billable_metric: charge.billable_metric, parent: charge) } + let(:params) { {properties: {amount: "150"}} } + + before do + create(:charge_filter_value, charge_filter:, billable_metric_filter: card_location_filter, values: ["domestic"]) + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(Charges::UpdateChildrenJob).to receive(:perform_later) + end + + it "does not trigger cascade update" do + service + + expect(Charges::UpdateChildrenJob).not_to have_received(:perform_later) + end + end + end +end diff --git a/spec/services/charge_models/amount_details/range_graduated_percentage_service_spec.rb b/spec/services/charge_models/amount_details/range_graduated_percentage_service_spec.rb new file mode 100644 index 0000000..a127aa5 --- /dev/null +++ b/spec/services/charge_models/amount_details/range_graduated_percentage_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::AmountDetails::RangeGraduatedPercentageService do + subject(:service) { described_class.new(range:, total_units:) } + + let(:total_units) { 15 } + let(:range) do + { + from_value: 7, + to_value: 10, + rate: "2.9", + flat_amount: "2" + } + end + + it "returns expected amount details" do + expect(service.call).to eq( + { + from_value: 7, + to_value: 10, + flat_unit_amount: 2, + rate: 2.9, + units: "4.0", + per_unit_total_amount: "0.116", + total_with_flat_amount: 2.116 + } + ) + end + + context "when total units <= range to_value" do + let(:range) do + { + from_value: 11, + to_value: 20, + rate: "1", + flat_amount: "1" + } + end + + it "returns expected amount details" do + expect(service.call).to eq( + { + from_value: 11, + to_value: 20, + flat_unit_amount: 1, + rate: 1, + units: "5.0", + per_unit_total_amount: "0.05", + total_with_flat_amount: 1.05 + } + ) + end + end +end diff --git a/spec/services/charge_models/amount_details/range_graduated_service_spec.rb b/spec/services/charge_models/amount_details/range_graduated_service_spec.rb new file mode 100644 index 0000000..8c3f3b6 --- /dev/null +++ b/spec/services/charge_models/amount_details/range_graduated_service_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::AmountDetails::RangeGraduatedService do + subject(:service) { described_class.new(range:, total_units:) } + + let(:total_units) { 15 } + let(:range) do + { + from_value: 0, + to_value: 10, + per_unit_amount: "10", + flat_amount: "2" + } + end + + it "returns expected amount details" do + expect(service.call).to eq( + { + from_value: 0, + to_value: 10, + flat_unit_amount: 2, + per_unit_amount: 10, + units: "10.0", + per_unit_total_amount: 100, + total_with_flat_amount: 102 + } + ) + end + + context "when total units <= range to_value" do + let(:range) do + { + from_value: 11, + to_value: 20, + per_unit_amount: "8", + flat_amount: "1" + } + end + + it "returns expected amount details" do + expect(service.call).to eq( + { + from_value: 11, + to_value: 20, + flat_unit_amount: 1, + per_unit_amount: 8, + units: "5.0", + per_unit_total_amount: 40, + total_with_flat_amount: 41 + } + ) + end + end + + context "with decimal adjacent model" do + subject(:service) { described_class.new(range:, total_units:, adjacent_model: true) } + + context "when total units exhaust the tier" do + let(:total_units) { 1.5 } + let(:range) do + {from_value: 0.1, to_value: 1, per_unit_amount: "5", flat_amount: "0"} + end + + it "returns to_value - from_value as units" do + expect(service.call[:units]).to eq("0.9") + expect(service.call[:per_unit_total_amount]).to eq(BigDecimal("4.5")) + end + end + + context "when total units are within the tier" do + let(:total_units) { 0.5 } + let(:range) do + {from_value: 0.1, to_value: 2, per_unit_amount: "5", flat_amount: "0"} + end + + it "returns total_units - from_value as units" do + expect(service.call[:units]).to eq("0.4") + expect(service.call[:per_unit_total_amount]).to eq(BigDecimal("2.0")) + end + end + + context "when from_value is zero" do + let(:total_units) { 0.05 } + let(:range) do + {from_value: 0, to_value: 0.1, per_unit_amount: "10", flat_amount: "0"} + end + + it "returns total_units as units" do + expect(service.call[:units]).to eq("0.05") + expect(service.call[:per_unit_total_amount]).to eq(BigDecimal("0.5")) + end + end + end +end diff --git a/spec/services/charge_models/build_default_properties_service_spec.rb b/spec/services/charge_models/build_default_properties_service_spec.rb new file mode 100644 index 0000000..ce32cb1 --- /dev/null +++ b/spec/services/charge_models/build_default_properties_service_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::BuildDefaultPropertiesService do + subject(:service) { described_class.new(charge_model) } + + describe "call" do + context "when standard charge model" do + let(:charge_model) { :standard } + + it "returns standard default properties" do + expect(service.call).to eq({amount: "0"}) + end + end + + context "when graduated charge model" do + let(:charge_model) { :graduated } + + it "returns graduated default properties" do + expect(service.call).to eq( + { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0", + flat_amount: "0" + } + ] + } + ) + end + end + + context "when package charge model" do + let(:charge_model) { :package } + + it "returns package default properties" do + expect(service.call).to eq( + { + package_size: 1, + amount: "0", + free_units: 0 + } + ) + end + end + + context "when percentage charge model" do + let(:charge_model) { :percentage } + + it "returns percentage default properties" do + expect(service.call).to eq({rate: "0"}) + end + end + + context "when volume charge model" do + let(:charge_model) { :volume } + + it "returns volume default properties" do + expect(service.call).to eq( + { + volume_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0", + flat_amount: "0" + } + ] + } + ) + end + end + + context "when graduated_percentage charge model" do + let(:charge_model) { :graduated_percentage } + + it "returns graduated_percentage default properties" do + expect(service.call).to eq( + { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: nil, + rate: "0", + fixed_amount: "0", + flat_amount: "0" + } + ] + } + ) + end + end + + context "when dynamic charge model" do + let(:charge_model) { :dynamic } + + it "returns dynamic default properties" do + expect(service.call).to eq({}) + end + end + end +end diff --git a/spec/services/charge_models/custom_service_spec.rb b/spec/services/charge_models/custom_service_spec.rb new file mode 100644 index 0000000..12c10fc --- /dev/null +++ b/spec/services/charge_models/custom_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +Rspec.describe ChargeModels::CustomService do + subject(:apply_custom_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + let(:aggregation_result) { BaseService::Result.new } + let(:aggregation) { 10 } + let(:total_aggregated_units) { nil } + let(:full_units_number) { BigDecimal("10.0") } + + let(:charge) { create(:custom_charge, billable_metric:) } + let(:billable_metric) { create(:custom_billable_metric) } + + before do + aggregation_result.aggregation = aggregation + aggregation_result.total_aggregated_units = total_aggregated_units if total_aggregated_units + aggregation_result.full_units_number = full_units_number if full_units_number + aggregation_result.custom_aggregation = {amount: 20, units: BigDecimal("10.0")} + end + + it "applies the charge model to the value" do + expect(apply_custom_service.amount).to eq(20) + expect(apply_custom_service.unit_amount).to eq(2) + end +end diff --git a/spec/services/charge_models/dynamic_service_spec.rb b/spec/services/charge_models/dynamic_service_spec.rb new file mode 100644 index 0000000..a2aab4f --- /dev/null +++ b/spec/services/charge_models/dynamic_service_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::DynamicService do + subject(:apply_dynamic_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.precise_total_amount_cents = precise_total_amount_cents + end + + let(:aggregation_result) { BaseService::Result.new } + + let(:charge) { create(:dynamic_charge) } + + let(:aggregation) { 20 } + let(:precise_total_amount_cents) { BigDecimal("40.2") } + + it "applies the model to the values" do + expect(apply_dynamic_service.amount).to eq(0.402) + expect(apply_dynamic_service.unit_amount).to eq(0.0201) + end + + context "when aggregation is zero" do + let(:aggregation) { 0 } + + it "applies the model to the values" do + expect(apply_dynamic_service.amount).to eq(0) + expect(apply_dynamic_service.unit_amount).to eq(0) + end + end +end diff --git a/spec/services/charge_models/factory_spec.rb b/spec/services/charge_models/factory_spec.rb new file mode 100644 index 0000000..fe61073 --- /dev/null +++ b/spec/services/charge_models/factory_spec.rb @@ -0,0 +1,171 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::Factory do + subject(:factory) { described_class } + + let(:charge) { build(:standard_charge) } + + describe "#new_instance" do + let(:aggregation_result) { BaseService::Result.new } + let(:properties) { charge.properties } + + let(:result) { factory.new_instance(chargeable: charge, aggregation_result:, properties:) } + + context "when chargeable is not a charge or a fixed charge" do + let(:chargeable) { build(:fee) } + + it "raises an error" do + expect { factory.new_instance(chargeable:, aggregation_result:, properties:) }.to raise_error(NotImplementedError) + end + end + + context "when chargeable is a charge" do + context "with standard charge model" do + it { expect(result).to be_a(ChargeModels::StandardService) } + + context "when charge is grouped" do + let(:charge) { build(:standard_charge, properties: {grouped_by: ["cloud"]}) } + let(:aggregation_result) { BaseService::Result.new.tap { |r| r.aggregations = [BaseService::Result.new] } } + + it { expect(result).to be_a(ChargeModels::GroupedService) } + end + + context "when charge accepts target wallet" do + let(:charge) { build(:standard_charge, accepts_target_wallet: true) } + let(:aggregation_result) { BaseService::Result.new.tap { |r| r.aggregations = [BaseService::Result.new] } } + + it { expect(result).to be_a(ChargeModels::GroupedService) } + end + end + + context "with graduated charge model" do + let(:charge) { build(:graduated_charge) } + + it { expect(result).to be_a(ChargeModels::GraduatedService) } + + context "when charge is prorated" do + let(:charge) { build(:graduated_charge, prorated: true) } + let(:aggregation_result) { BaseService::Result.new.tap { |r| r.aggregator = [BaseService::Result.new] } } + + it { expect(result).to be_a(ChargeModels::ProratedGraduatedService) } + end + + context "when charge is prorated, but we are forecasting amounts" do + let(:charge) { build(:graduated_charge, prorated: true) } + + it { expect(result).to be_a(ChargeModels::GraduatedService) } + end + end + + context "with graduated_percentage charge model" do + let(:charge) { build(:graduated_percentage_charge) } + + it { expect(result).to be_a(ChargeModels::GraduatedPercentageService) } + end + + context "with package charge model" do + let(:charge) { build(:package_charge) } + + it { expect(result).to be_a(ChargeModels::PackageService) } + end + + context "with percentage charge model" do + let(:charge) { build(:percentage_charge) } + + it { expect(result).to be_a(ChargeModels::PercentageService) } + end + + context "with volume charge model" do + let(:charge) { build(:volume_charge) } + + it { expect(result).to be_a(ChargeModels::VolumeService) } + end + + context "with dynamic charge model" do + let(:charge) { build(:dynamic_charge) } + + it { expect(result).to be_a(ChargeModels::DynamicService) } + end + + context "with custom charge model" do + let(:charge) { build(:custom_charge) } + + it { expect(result).to be_a(ChargeModels::CustomService) } + end + end + + context "when chargeable is a fixed charge" do + context "with standard charge model" do + let(:charge) { build(:fixed_charge, charge_model: :standard) } + + it { expect(result).to be_a(ChargeModels::StandardService) } + end + + context "with graduated charge model" do + let(:charge) { build(:fixed_charge, charge_model: :graduated) } + + it { expect(result).to be_a(ChargeModels::GraduatedService) } + end + + context "with volume charge model" do + let(:charge) { build(:fixed_charge, charge_model: :volume) } + + it { expect(result).to be_a(ChargeModels::VolumeService) } + end + end + end + + describe ".in_advance_charge_model_class" do + let(:result) { factory.in_advance_charge_model_class(chargeable: charge) } + + context "when chargeable is a charge" do + context "with standard charge model" do + it { expect(result).to eq(ChargeModels::StandardService) } + end + + context "with graduated charge model" do + let(:charge) { build(:graduated_charge) } + + it { expect(result).to eq(ChargeModels::GraduatedService) } + end + + context "with graduated_percentage charge model" do + let(:charge) { build(:graduated_percentage_charge) } + + it { expect(result).to eq(ChargeModels::GraduatedPercentageService) } + end + + context "with package charge model" do + let(:charge) { build(:package_charge) } + + it { expect(result).to eq(ChargeModels::PackageService) } + end + + context "with percentage charge model" do + let(:charge) { build(:percentage_charge) } + + it { expect(result).to eq(ChargeModels::PercentageService) } + end + + context "with volume charge model" do + let(:charge) { build(:volume_charge) } + + it { expect { result }.to raise_error(NotImplementedError) } + end + + context "with dynamic charge model" do + let(:charge) { build(:dynamic_charge) } + + it { expect(result).to eq(ChargeModels::DynamicService) } + end + + context "with custom charge model" do + let(:charge) { build(:custom_charge) } + + it { expect(result).to eq(ChargeModels::CustomService) } + end + end + end +end diff --git a/spec/services/charge_models/filter_properties/charge_service_spec.rb b/spec/services/charge_models/filter_properties/charge_service_spec.rb new file mode 100644 index 0000000..204e70b --- /dev/null +++ b/spec/services/charge_models/filter_properties/charge_service_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::FilterProperties::ChargeService do + subject(:filter_service) { described_class.new(chargeable:, properties:) } + + let(:charge_model) { nil } + let(:billable_metric) { build(:billable_metric) } + let(:chargeable) { build(:charge, charge_model:, billable_metric:) } + + let(:properties) do + { + amount: 100, + grouped_by: %w[location], + graduated_ranges: [{from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}], + graduated_percentage_ranges: [{from_value: 0, to_value: 100, percentage: "2"}], + free_units: 10, + package_size: 10, + rate: "0.0555", + fixed_amount: "2", + free_units_per_events: 10, + free_units_per_total_aggregation: 10, + per_transaction_max_amount: 100, + per_transaction_min_amount: 10, + volume_ranges: [{from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}], + custom_properties: + } + end + + let(:custom_properties) { {rate: "20"} } + + describe "#call" do + context "without charge_model" do + it "returns empty hash" do + expect(filter_service.call.properties).to eq({}) + end + end + + context "with standard charge_model" do + let(:charge_model) { "standard" } + + it "filters the properties" do + properties = filter_service.call.properties + expect(properties.keys).to include("amount", "pricing_group_keys") + expect(properties["amount"]).to eq(100) + expect(properties["pricing_group_keys"]).to eq(["location"]) + end + + # TODO(pricing_group_keys): remove after deprecation + context "when grouped_by contains empty string" do + let(:properties) { {amount: 100, grouped_by: ["", ""]} } + + it "set grouped_by to nil" do + expect(filter_service.call.properties[:grouped_by]).to be_nil + expect(filter_service.call.properties[:pricing_group_keys]).to be_empty + end + end + + context "when pricing_group_keys contains empty string" do + let(:properties) { {amount: 100, pricing_group_keys: ["", ""]} } + + it { expect(filter_service.call.properties[:pricing_group_keys]).to be_empty } + end + + # TODO(pricing_group_keys): remove after deprecation + context "with both grouped_by and pricing_group_keys" do + let(:properties) { {amount: 100, grouped_by: %w[location], pricing_group_keys: %w[customer]} } + + it "gives precedence to pricing_group_keys" do + expect(filter_service.call.properties[:grouped_by]).to be_nil + expect(filter_service.call.properties[:pricing_group_keys]).to eq(["customer"]) + end + end + end + + context "with graduated charge_model" do + let(:charge_model) { "graduated" } + + it { expect(filter_service.call.properties.keys).to include("graduated_ranges") } + end + + context "with graduated_percentage charge_model" do + let(:charge_model) { "graduated_percentage" } + + it { expect(filter_service.call.properties.keys).to include("graduated_percentage_ranges") } + end + + context "with package charge_model" do + let(:charge_model) { "package" } + + it { expect(filter_service.call.properties.keys).to include("amount", "free_units", "package_size") } + end + + context "with percentage charge_model" do + let(:charge_model) { "percentage" } + + it do + expect(filter_service.call.properties.keys).to include( + "rate", + "fixed_amount", + "free_units_per_events", + "free_units_per_total_aggregation", + "per_transaction_max_amount", + "per_transaction_min_amount" + ) + end + end + + context "with volume charge_model" do + let(:charge_model) { "volume" } + + it { expect(filter_service.call.properties.keys).to include("volume_ranges") } + end + + context "with custom billable metric" do + let(:billable_metric) { build(:custom_billable_metric) } + + it { expect(filter_service.call.properties.keys).to include("custom_properties") } + it { expect(filter_service.call.properties[:custom_properties]).to be_a(Hash) } + + context "when custom_properties is a string" do + let(:custom_properties) { '{"rate": 20}' } + + it { expect(filter_service.call.properties.keys).to include("custom_properties") } + it { expect(filter_service.call.properties[:custom_properties]).to eq("rate" => 20) } + + context "when properties failed to parse" do + let(:custom_properties) { "rate: 20" } + + it { expect(filter_service.call.properties.keys).to include("custom_properties") } + it { expect(filter_service.call.properties[:custom_properties]).to eq({}) } + end + end + end + end +end diff --git a/spec/services/charge_models/filter_properties/fixed_charge_service_spec.rb b/spec/services/charge_models/filter_properties/fixed_charge_service_spec.rb new file mode 100644 index 0000000..77c2852 --- /dev/null +++ b/spec/services/charge_models/filter_properties/fixed_charge_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::FilterProperties::FixedChargeService do + subject(:filter_service) { described_class.new(chargeable:, properties:) } + + let(:charge_model) { nil } + let(:chargeable) { build(:fixed_charge, charge_model:) } + + let(:properties) do + { + amount: 100, + grouped_by: %w[location], + graduated_ranges: [{from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}], + graduated_percentage_ranges: [{from_value: 0, to_value: 100, percentage: "2"}], + free_units: 10, + package_size: 10, + rate: "0.0555", + fixed_amount: "2", + free_units_per_events: 10, + free_units_per_total_aggregation: 10, + per_transaction_max_amount: 100, + per_transaction_min_amount: 10, + volume_ranges: [{from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}], + custom_properties: + } + end + + let(:custom_properties) { {custom: "prop"} } + + describe "#call" do + context "without charge_model" do + it "returns empty hash" do + expect(filter_service.call.properties).to eq({}) + end + end + + context "with standard charge_model" do + let(:charge_model) { "standard" } + + it "filters the properties" do + properties = filter_service.call.properties + expect(properties.keys).to include("amount") + expect(properties["amount"]).to eq(100) + end + end + + context "with graduated charge_model" do + let(:charge_model) { "graduated" } + + it { expect(filter_service.call.properties.keys).to include("graduated_ranges") } + end + + context "with volume charge_model" do + let(:charge_model) { "volume" } + + it { expect(filter_service.call.properties.keys).to include("volume_ranges") } + end + end +end diff --git a/spec/services/charge_models/filter_properties_service_spec.rb b/spec/services/charge_models/filter_properties_service_spec.rb new file mode 100644 index 0000000..a479fab --- /dev/null +++ b/spec/services/charge_models/filter_properties_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::FilterPropertiesService do + subject(:filter_service) { described_class.new(chargeable:, properties:) } + + let(:properties) { {"amount" => 100} } + + describe "#call" do + context "with a charge" do + let(:chargeable) { build(:charge, charge_model: "standard") } + + before do + allow(ChargeModels::FilterProperties::ChargeService) + .to receive(:call) + .and_call_original + end + + it "delegates to ChargeService" do + filter_service.call + + expect(ChargeModels::FilterProperties::ChargeService) + .to have_received(:call) + .with(chargeable:, properties:) + end + + it "returns filtered properties" do + result = filter_service.call + + expect(result.properties).to eq(properties) + end + end + + context "with a fixed charge" do + let(:chargeable) { build(:fixed_charge, charge_model: "standard") } + + before do + allow(ChargeModels::FilterProperties::FixedChargeService) + .to receive(:call) + .and_call_original + end + + it "delegates to FixedChargeService" do + filter_service.call + + expect(ChargeModels::FilterProperties::FixedChargeService) + .to have_received(:call) + .with(chargeable:, properties:) + end + + it "returns filtered properties" do + result = filter_service.call + + expect(result.properties).to eq(properties) + end + end + + context "with an unsupported resource" do + let(:chargeable) { Object.new } + + it "raises ArgumentError" do + expect { filter_service.call }.to raise_error(ArgumentError, "Unsupported chargeable type: Object") + end + end + end +end diff --git a/spec/services/charge_models/graduated_percentage_service_spec.rb b/spec/services/charge_models/graduated_percentage_service_spec.rb new file mode 100644 index 0000000..d58c2bb --- /dev/null +++ b/spec/services/charge_models/graduated_percentage_service_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::GraduatedPercentageService, :premium do + subject(:apply_graduated_percentage_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + let(:aggregation_result) do + BaseService::Result.new.tap do |r| + r.aggregation = aggregation + r.count = aggregation_count + end + end + + let(:charge) do + create( + :graduated_percentage_charge, + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: 10, + flat_amount: "200", + rate: "1" + }, + { + from_value: 11, + to_value: 20, + flat_amount: "300", + rate: "2" + }, + { + from_value: 21, + to_value: nil, + flat_amount: "400", + rate: "3" + } + ] + } + ) + end + + context "when aggregation is 0" do + let(:aggregation) { 0 } + let(:aggregation_count) { 0 } + + it "does not apply the flat amount" do + expect(apply_graduated_percentage_service.amount).to eq(0) + expect(apply_graduated_percentage_service.unit_amount).to eq(0) + expect(apply_graduated_percentage_service.amount_details).to eq( + { + graduated_percentage_ranges: [ + { + flat_unit_amount: 0, + from_value: 0, + to_value: 10, + per_unit_total_amount: "0.0", + total_with_flat_amount: 0, + rate: 1.0, + units: "0.0" + } + ] + } + ) + end + end + + context "when aggregation is 1" do + let(:aggregation) { 1 } + let(:aggregation_count) { 1 } + + it "applies a unit amount for 1 and the flat rate for 1" do + # NOTE: 200 + 1 * 0.01 + expect(apply_graduated_percentage_service.amount).to eq(200.01) + expect(apply_graduated_percentage_service.unit_amount).to eq(200.01) + expect(apply_graduated_percentage_service.amount_details).to eq( + { + graduated_percentage_ranges: [ + { + flat_unit_amount: 200, + from_value: 0, + to_value: 10, + per_unit_total_amount: "0.01", + total_with_flat_amount: 200.01, + rate: 1.0, + units: "1.0" + } + ] + } + ) + end + end + + context "when aggregation is 10" do + let(:aggregation) { 10 } + let(:aggregation_count) { 1 } + + it "applies all unit amount up to the top bound" do + # NOTE: 200 + 10 * 0.01 + expect(apply_graduated_percentage_service.amount).to eq(200.1) + expect(apply_graduated_percentage_service.unit_amount).to eq(20.01) + expect(apply_graduated_percentage_service.amount_details).to eq( + { + graduated_percentage_ranges: [ + { + flat_unit_amount: 200, + from_value: 0, + to_value: 10, + per_unit_total_amount: "0.1", + total_with_flat_amount: 200.1, + rate: 1.0, + units: "10.0" + } + ] + } + ) + end + end + + context "when aggregation is 11" do + let(:aggregation) { 11 } + let(:aggregation_count) { 1 } + + it "applies next ranges flat amount" do + # NOTE: 200 + 300 + 10 * 0.01 + 1 * 0.02 + expect(apply_graduated_percentage_service.amount).to eq(500.12) + expect(apply_graduated_percentage_service.unit_amount.round(2)).to eq(45.47) + expect(apply_graduated_percentage_service.amount_details).to eq( + { + graduated_percentage_ranges: [ + { + flat_unit_amount: 200, + from_value: 0, + to_value: 10, + per_unit_total_amount: "0.1", + total_with_flat_amount: 200.1, + rate: 1.0, + units: "10.0" + }, + { + flat_unit_amount: 300, + from_value: 11, + to_value: 20, + per_unit_total_amount: "0.02", + total_with_flat_amount: 300.02, + rate: 2.0, + units: "1.0" + } + ] + } + ) + end + end + + context "when aggregation is 12" do + let(:aggregation) { 12 } + let(:aggregation_count) { 1 } + + it "applies next ranges flat amount and range units amount" do + # NOTE: 200 + 300 + 10 * 0.01 + 2 * 0.02 + expect(apply_graduated_percentage_service.amount).to eq(500.14) + expect(apply_graduated_percentage_service.unit_amount.round(2)).to eq(41.68) + expect(apply_graduated_percentage_service.amount_details).to eq( + { + graduated_percentage_ranges: [ + { + flat_unit_amount: 200, + from_value: 0, + to_value: 10, + per_unit_total_amount: "0.1", + total_with_flat_amount: 200.1, + rate: 1.0, + units: "10.0" + }, + { + flat_unit_amount: 300, + from_value: 11, + to_value: 20, + per_unit_total_amount: "0.04", + total_with_flat_amount: 300.04, + rate: 2.0, + units: "2.0" + } + ] + } + ) + end + end + + context "when aggregation is 21" do + let(:aggregation) { 21 } + let(:aggregation_count) { 1 } + + it "applies last unit amount for more unit in last step" do + # NOTE: 200 + 300 + 400 + 10 * 0.01 + 10 * 0.02 + 1 * 0.03 + expect(apply_graduated_percentage_service.amount).to eq(900.33) + expect(apply_graduated_percentage_service.unit_amount.round(2)).to eq(42.87) + expect(apply_graduated_percentage_service.amount_details).to eq( + { + graduated_percentage_ranges: [ + { + flat_unit_amount: 200, + from_value: 0, + to_value: 10, + per_unit_total_amount: "0.1", + total_with_flat_amount: 200.1, + rate: 1.0, + units: "10.0" + }, + { + flat_unit_amount: 300, + from_value: 11, + to_value: 20, + per_unit_total_amount: "0.2", + total_with_flat_amount: 300.2, + rate: 2.0, + units: "10.0" + }, + { + flat_unit_amount: 400, + from_value: 21, + to_value: nil, + per_unit_total_amount: "0.03", + total_with_flat_amount: 400.03, + rate: 3.0, + units: "1.0" + } + ] + } + ) + end + end +end diff --git a/spec/services/charge_models/graduated_service_spec.rb b/spec/services/charge_models/graduated_service_spec.rb new file mode 100644 index 0000000..2cf73b8 --- /dev/null +++ b/spec/services/charge_models/graduated_service_spec.rb @@ -0,0 +1,319 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::GraduatedService do + subject(:apply_graduated_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + before do + aggregation_result.aggregation = aggregation + end + + let(:aggregation_result) { BaseService::Result.new } + + let(:charge) do + create( + :graduated_charge, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "10", + flat_amount: "2" + }, + { + from_value: 11, + to_value: 20, + per_unit_amount: "5", + flat_amount: "3" + }, + { + from_value: 21, + to_value: nil, + per_unit_amount: "5", + flat_amount: "3" + } + ] + } + ) + end + + context "when aggregation is 0" do + let(:aggregation) { 0 } + + it "returns expected amount" do + expect(apply_graduated_service.amount).to eq(0) + expect(apply_graduated_service.unit_amount).to eq(0) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 0, + from_value: 0, + to_value: 10, + per_unit_total_amount: 0, + total_with_flat_amount: 0, + per_unit_amount: 0, + units: "0.0" + } + ] + } + ) + end + end + + context "when aggregation is 1" do + let(:aggregation) { 1 } + + it "returns expected amount" do + expect(apply_graduated_service.amount).to eq(12) + expect(apply_graduated_service.unit_amount).to eq(12) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 2, + from_value: 0, + to_value: 10, + per_unit_total_amount: 10, + total_with_flat_amount: 12, + per_unit_amount: 10, + units: "1.0" + } + ] + } + ) + end + end + + context "when aggregation is 10" do + let(:aggregation) { 10 } + + it "returns expected amount" do + expect(apply_graduated_service.amount).to eq(102) + expect(apply_graduated_service.unit_amount).to eq(10.2) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 2, + from_value: 0, + to_value: 10, + per_unit_total_amount: 100, + total_with_flat_amount: 102, + per_unit_amount: 10, + units: "10.0" + } + ] + } + ) + end + end + + context "when aggregation is 11" do + let(:aggregation) { 11 } + + it "returns expected amount" do + expect(apply_graduated_service.amount).to eq(110) + expect(apply_graduated_service.unit_amount).to eq(10) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 2, + from_value: 0, + to_value: 10, + per_unit_total_amount: 100, + total_with_flat_amount: 102, + per_unit_amount: 10, + units: "10.0" + }, + { + flat_unit_amount: 3, + from_value: 11, + to_value: 20, + per_unit_total_amount: 5, + total_with_flat_amount: 8, + per_unit_amount: 5, + units: "1.0" + } + ] + } + ) + end + end + + context "when aggregation is 12" do + let(:aggregation) { 12 } + + it "returns expected amount" do + expect(apply_graduated_service.amount).to eq(115) + expect(apply_graduated_service.unit_amount.round(5)).to eq(9.58333) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 2, + from_value: 0, + to_value: 10, + per_unit_total_amount: 100, + total_with_flat_amount: 102, + per_unit_amount: 10, + units: "10.0" + }, + { + flat_unit_amount: 3, + from_value: 11, + to_value: 20, + per_unit_total_amount: 10, + total_with_flat_amount: 13, + per_unit_amount: 5, + units: "2.0" + } + ] + } + ) + end + end + + context "when aggregation is 21" do + let(:aggregation) { 21 } + + it "returns expected amount" do + expect(apply_graduated_service.amount).to eq(163) + expect(apply_graduated_service.unit_amount.round(2)).to eq(7.76) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 2, + from_value: 0, + to_value: 10, + per_unit_total_amount: 100, + total_with_flat_amount: 102, + per_unit_amount: 10, + units: "10.0" + }, + { + flat_unit_amount: 3, + from_value: 11, + to_value: 20, + per_unit_total_amount: 50, + total_with_flat_amount: 53, + per_unit_amount: 5, + units: "10.0" + }, + { + flat_unit_amount: 3, + from_value: 21, + to_value: nil, + per_unit_total_amount: 5, + total_with_flat_amount: 8, + per_unit_amount: 5, + units: "1.0" + } + ] + } + ) + end + end + + context "with decimal adjacent ranges" do + let(:charge) do + create( + :graduated_charge, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 0.1, per_unit_amount: "10", flat_amount: "0"}, + {from_value: 0.1, to_value: 1, per_unit_amount: "5", flat_amount: "0"}, + {from_value: 1, to_value: nil, per_unit_amount: "2", flat_amount: "0"} + ] + } + ) + end + + context "when aggregation is within first tier (0.05)" do + let(:aggregation) { 0.05 } + + it "returns expected amount" do + expect(apply_graduated_service.amount).to eq(0.5) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 0, + from_value: 0, + to_value: 0.1, + per_unit_amount: 10, + per_unit_total_amount: 0.5, + total_with_flat_amount: 0.5, + units: "0.05" + } + ] + } + ) + end + end + + context "when aggregation spans two tiers (0.5)" do + let(:aggregation) { 0.5 } + + it "returns expected amount" do + # First tier: 0.1 units * 10 = 1.0 + # Second tier: 0.4 units * 5 = 2.0 + expect(apply_graduated_service.amount).to eq(3.0) + expect(apply_graduated_service.amount_details).to eq( + { + graduated_ranges: [ + { + flat_unit_amount: 0, + from_value: 0, + to_value: 0.1, + per_unit_amount: 10, + per_unit_total_amount: 1.0, + total_with_flat_amount: 1.0, + units: "0.1" + }, + { + flat_unit_amount: 0, + from_value: 0.1, + to_value: 1, + per_unit_amount: 5, + per_unit_total_amount: 2.0, + total_with_flat_amount: 2.0, + units: "0.4" + } + ] + } + ) + end + end + end + + context "when charge is a fixed charge" do + let(:aggregation) { 21 } + let(:charge) do + build(:fixed_charge, charge_model: :graduated, properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "10", flat_amount: "2"}, + {from_value: 11, to_value: 20, per_unit_amount: "5", flat_amount: "3"}, + {from_value: 21, to_value: nil, per_unit_amount: "5", flat_amount: "3"} + ] + }) + end + + it "applies the charge model to the value" do + # 2 + 100 + 3 + 50 + 3 + 5 = 163 + expect(apply_graduated_service.amount).to eq(163) + expect(apply_graduated_service.unit_amount.round(2)).to eq((163 / 21.0).round(2)) + end + end +end diff --git a/spec/services/charge_models/grouped_service_spec.rb b/spec/services/charge_models/grouped_service_spec.rb new file mode 100644 index 0000000..11deb94 --- /dev/null +++ b/spec/services/charge_models/grouped_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::GroupedService do + subject(:apply_grouped_service) do + described_class.apply( + charge_model:, + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + context "with standard charge model" do + let(:charge_model) { ChargeModels::StandardService } + + let(:aggregation_result) do + BaseService::Result.new.tap do |result| + result.aggregations = group_results.map do |group_result| + BaseService::Result.new.tap do |aggregation| + aggregation.aggregation = group_result[:aggregation] + aggregation.count = group_result[:count] + aggregation.grouped_by = group_result[:grouped_by] + end + end + end + end + + let(:group_results) do + [ + { + grouped_by: {"cloud" => "aws"}, + aggregation: 10, + count: 2 + }, + { + grouped_by: {"cloud" => "gcp"}, + aggregation: 20, + count: 7 + } + ] + end + + let(:charge) do + create( + :standard_charge, + charge_model: "standard", + properties: { + amount: "5.12345" + } + ) + end + + it "applies the charge model to the values" do + expect(apply_grouped_service.grouped_results.count).to eq(group_results.count) + + group_results.each_with_index do |group_result, index| + result = apply_grouped_service.grouped_results[index] + + expect(result.units).to eq(group_result[:aggregation]) + expect(result.current_usage_units).to eq(nil) + expect(result.full_units_number).to eq(nil) + expect(result.count).to eq(group_result[:count]) + expect(result.amount).to eq(group_result[:aggregation] * BigDecimal("5.12345")) + expect(result.unit_amount).to eq(5.12345) + expect(result.amount_details).to eq({}) + expect(result.grouped_by).to eq(group_result[:grouped_by]) + end + end + end + + context "with dynamic charge model" do + let(:charge_model) { ChargeModels::DynamicService } + let(:charge) { create(:dynamic_charge) } + + let(:aggregation_result) do + BaseService::Result.new.tap do |result| + result.aggregations = group_results.map do |group_result| + BaseService::Result.new.tap do |aggregation| + aggregation.aggregation = group_result[:aggregation] + aggregation.precise_total_amount_cents = group_result[:precise_total_amount_cents] + aggregation.count = group_result[:count] + aggregation.grouped_by = group_result[:grouped_by] + end + end + end + end + + let(:group_results) do + [ + { + grouped_by: {"cloud" => "aws"}, + aggregation: 10, + count: 2, + precise_total_amount_cents: BigDecimal("12") + }, + { + grouped_by: {"cloud" => "gcp"}, + aggregation: 20, + count: 7, + precise_total_amount_cents: BigDecimal("9") + } + ] + end + + it "applies the charge model to the values" do + expect(apply_grouped_service.grouped_results.count).to eq(group_results.count) + + group_results.each_with_index do |group_result, index| + result = apply_grouped_service.grouped_results[index] + + expect(result.units).to eq(group_result[:aggregation]) + expect(result.current_usage_units).to eq(nil) + expect(result.full_units_number).to eq(nil) + expect(result.count).to eq(group_result[:count]) + expect(result.unit_amount).to eq(group_result[:precise_total_amount_cents] / group_result[:aggregation] / 100) + expect(result.amount_details).to eq({}) + expect(result.grouped_by).to eq(group_result[:grouped_by]) + end + end + end +end diff --git a/spec/services/charge_models/package_service_spec.rb b/spec/services/charge_models/package_service_spec.rb new file mode 100644 index 0000000..fcde842 --- /dev/null +++ b/spec/services/charge_models/package_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::PackageService do + subject(:apply_package_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + before do + aggregation_result.aggregation = aggregation + end + + let(:aggregation_result) { BaseService::Result.new } + let(:aggregation) { 121 } + + let(:charge) do + create( + :package_charge, + properties: { + amount: "100", + package_size: 10, + free_units: 0 + } + ) + end + + it "applies the package size to the value" do + expect(apply_package_service.amount).to eq(1300) + expect(apply_package_service.unit_amount.round(2)).to eq(10.74) + expect(apply_package_service.amount_details).to eq( + { + free_units: "0.0", + paid_units: "121.0", + per_package_size: 10, + per_package_unit_amount: 100 + } + ) + end + + context "with free_units" do + before { charge.properties["free_units"] = 10 } + + it "substracts the free units from the value" do + expect(apply_package_service.amount).to eq(1200) + expect(apply_package_service.unit_amount.round(2)).to eq(10.81) + expect(apply_package_service.amount_details).to eq( + { + free_units: "10.0", + paid_units: "111.0", + per_package_size: 10, + per_package_unit_amount: 100 + } + ) + end + + context "when free units is higher than the value" do + before { charge.properties["free_units"] = 200 } + + it "substracts the free units from the value" do + expect(apply_package_service.amount).to eq(0) + expect(apply_package_service.unit_amount).to eq(0) + expect(apply_package_service.amount_details).to eq( + { + free_units: "200.0", + paid_units: "0.0", + per_package_size: 10, + per_package_unit_amount: 100 + } + ) + end + end + end +end diff --git a/spec/services/charge_models/percentage_service_spec.rb b/spec/services/charge_models/percentage_service_spec.rb new file mode 100644 index 0000000..8070a70 --- /dev/null +++ b/spec/services/charge_models/percentage_service_spec.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::PercentageService do + subject(:apply_percentage_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.count = 4 + aggregation_result.options = {running_total:} + end + + let(:running_total) { [50, 150, 400] } + let(:aggregation_result) { BaseService::Result.new } + let(:fixed_amount) { "2.0" } + let(:aggregation) { 800 } + let(:free_units_per_events) { 3 } + let(:free_units_per_total_aggregation) { "250.0" } + let(:per_transaction_max_amount) { nil } + let(:per_transaction_min_amount) { nil } + + let(:rate) { "1.3" } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + + let(:charge) do + create( + :percentage_charge, + organization:, + plan:, + properties: { + rate:, + fixed_amount:, + free_units_per_events:, + free_units_per_total_aggregation:, + per_transaction_max_amount:, + per_transaction_min_amount: + } + ) + end + + context "when aggregation value is 0" do + let(:aggregation) { 0 } + + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(0) + expect(apply_percentage_service.unit_amount).to eq(0) + expect(apply_percentage_service.amount_details).to eq( + { + units: "0.0", + free_units: "250.0", + paid_units: "0.0", + free_events: 2, + rate: 1.3, + per_unit_total_amount: 0, + paid_events: 2, + fixed_fee_unit_amount: 2, + fixed_fee_total_amount: "0.0", + min_max_adjustment_total_amount: "0.0" + } + ) + end + end + + context "when fixed amount value is 0" do + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(11.15) + expect(apply_percentage_service.unit_amount).to eq(0.0139375) # 11.15 / 800 + expect(apply_percentage_service.amount_details).to eq( + { + units: "800.0", + free_units: "250.0", + paid_units: "550.0", + free_events: 2, + rate: 1.3, + per_unit_total_amount: 7.15, # (800 - 250) * (1.3 / 100), + paid_events: 2, + fixed_fee_unit_amount: 2, + fixed_fee_total_amount: "4.0", # (4 - 2) * 2.0 + min_max_adjustment_total_amount: "0.0" + } + ) + end + end + + context "with small units amount" do + let(:running_total) { [] } + let(:fixed_amount) { nil } + let(:aggregation) { 4 } + let(:free_units_per_events) { nil } + let(:free_units_per_total_aggregation) { nil } + let(:per_transaction_max_amount) { nil } + let(:per_transaction_min_amount) { nil } + let(:rate) { "2.9" } + + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(0.116) + expect(apply_percentage_service.unit_amount).to eq(0.029) # 0.116 / 4 + expect(apply_percentage_service.amount_details).to match hash_including( + units: "4.0", + paid_units: "4.0", + rate: 2.9, + per_unit_total_amount: 0.116, # 4 * 0.029 + paid_events: 4 + ) + end + end + + context "when rate is 0" do + let(:running_total) { [] } + let(:free_units_per_events) { nil } + let(:free_units_per_total_aggregation) { nil } + let(:rate) { "0" } + let(:expected_fixed_amount) { (4 - 0) * 2.0 } + + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(8) + expect(apply_percentage_service.unit_amount).to eq(0.01) + expect(apply_percentage_service.amount_details).to eq( + { + units: "800.0", + free_units: "0.0", + paid_units: "800.0", + free_events: 0, + rate: 0, + per_unit_total_amount: 0, + paid_events: 4, + fixed_fee_unit_amount: 2, + fixed_fee_total_amount: "8.0", + min_max_adjustment_total_amount: "0.0" + } + ) + end + end + + context "when free_units_per_events is nil" do + let(:free_units_per_events) { nil } + + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(11.15) # (800 - 250) * (1.3 / 100) + (4 - 2) * 2.0 + expect(apply_percentage_service.unit_amount).to eq(0.0139375) + expect(apply_percentage_service.amount_details).to eq( + { + units: "800.0", + free_units: "250.0", + paid_units: "550.0", + free_events: 2, + rate: 1.3, + per_unit_total_amount: 7.15, + paid_events: 2, + fixed_fee_unit_amount: 2, + fixed_fee_total_amount: "4.0", + min_max_adjustment_total_amount: "0.0" + } + ) + end + end + + context "when free_units_per_total_aggregation is nil" do + let(:free_units_per_total_aggregation) { nil } + + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(7.2) + expect(apply_percentage_service.unit_amount).to eq(0.009) + expect(apply_percentage_service.amount_details).to eq( + { + units: "800.0", + free_units: "400.0", + paid_units: "400.0", + free_events: 3, + rate: 1.3, + per_unit_total_amount: 5.2, # (800 - 400) * (1.3 / 100) + paid_events: 1, + fixed_fee_unit_amount: 2, + fixed_fee_total_amount: "2.0", # (4 - 3) * 2.0 + min_max_adjustment_total_amount: "0.0" + } + ) + end + end + + context "when free units are not set" do + let(:free_units_per_total_aggregation) { nil } + let(:free_units_per_events) { nil } + let(:running_total) { [] } + + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(18.4) + expect(apply_percentage_service.unit_amount).to eq(0.023) + expect(apply_percentage_service.amount_details).to eq( + { + units: "800.0", + free_units: "0.0", + paid_units: "800.0", + free_events: 0, + rate: 1.3, + per_unit_total_amount: 10.4, # 800 * (1.3 / 100) + paid_events: 4, + fixed_fee_unit_amount: 2, + fixed_fee_total_amount: "8.0", # 4 * 2.0 + min_max_adjustment_total_amount: "0.0" + } + ) + end + end + + context "when free_units_per_total_aggregation > last running total" do + let(:free_units_per_total_aggregation) { "500.0" } + let(:expected_percentage_amount) { (800 - 400) * (1.3 / 100) } + let(:expected_fixed_amount) { (4 - 3) * 2.0 } + + it "returns expected amount" do + expect(apply_percentage_service.amount).to eq(7.2) + expect(apply_percentage_service.unit_amount).to eq(0.009) + expect(apply_percentage_service.amount_details).to eq( + { + units: "800.0", + free_units: "400.0", + paid_units: "400.0", + free_events: 3, + rate: 1.3, + per_unit_total_amount: 5.2, # (800 - 400) * (1.3 / 100) + paid_events: 1, + fixed_fee_unit_amount: 2, + fixed_fee_total_amount: "2.0", # (4 - 3) * 2.0 + min_max_adjustment_total_amount: "0.0" + } + ) + end + end + + context "when free_units_count > number of events" do + let(:free_units_per_events) { 5 } + let(:free_units_per_total_aggregation) { nil } + let(:aggregation) { 400 } + + it "returns 0" do + expect(apply_percentage_service.amount).to eq(0) + end + end + + context "when applying min / max amount per transaction" do + let(:per_transaction_max_amount) { "12" } + let(:per_transaction_min_amount) { "1.75" } + + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:aggregator) do + BillableMetrics::Aggregations::SumService.new( + event_store_class:, + charge:, + subscription:, + boundaries: nil + ) + end + + let(:event_store_class) { Events::Stores::PostgresStore } + + let(:aggregation) { 10_090 } + + let(:fixed_amount) { "0" } + let(:free_units_per_events) { nil } + let(:free_units_per_total_aggregation) { "0" } + let(:rate) { "2.99" } + + let(:per_event_aggregation) { BaseService::Result.new.tap { |r| r.event_aggregation = [10, 80, 10_000] } } + let(:running_total) { [] } + + before do + aggregation_result.aggregator = aggregator + aggregation_result.count = 3 + + allow(aggregator).to receive(:per_event_aggregation).and_return(per_event_aggregation) + end + + it "does not apply max and min if not premium" do + expect(apply_percentage_service.amount).to eq(301.691) # (10 + 80 + 10000) * 0.0299 + end + + context "when premium", :premium do + it "applies the min and max per transaction" do + # 1.75 (min as 10 * 0.0299 < 1.75) + 2.392 + 12 (max as 10000 * 0.0299 > 12) + expect(apply_percentage_service.amount).to eq(16.142) + end + + context "with fixed_amount" do + let(:fixed_amount) { "2.0" } + + it "applies the min and max per transaction" do + # 2.299 (10 * 0.0299 + 2 > 1.75) + 4.392 + 12 (max as 10000 * 0.0299 > 12) + expect(apply_percentage_service.amount).to eq(18.691) + end + end + + context "with free units per events" do + let(:free_units_per_events) { 2 } + + it "applies the min and max only on paying transaction" do + # 10000 * 0.01 > 12 + expect(apply_percentage_service.amount).to eq(12) + end + end + + context "with free units per total aggregation" do + let(:free_units_per_total_aggregation) { "30" } + + it "takes the free amount into account" do + # 1.794 ((10 + 80 - 40) * 0.0299) + 12 (max as 10000 * 0.0299 > 12) + expect(apply_percentage_service.amount).to eq(13.794) + end + end + + context "when both free units per events and per total aggregation are applied" do + let(:free_units_per_events) { 3 } + let(:free_units_per_total_aggregation) { "10000" } + + it "takes the free amounts into account" do + # (10 + 80 + 10000 - 10000) * 0.0299 + expect(apply_percentage_service.amount).to eq(2.691) + end + end + end + end +end diff --git a/spec/services/charge_models/prorated_graduated_service_spec.rb b/spec/services/charge_models/prorated_graduated_service_spec.rb new file mode 100644 index 0000000..c04d60a --- /dev/null +++ b/spec/services/charge_models/prorated_graduated_service_spec.rb @@ -0,0 +1,357 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::ProratedGraduatedService do + subject(:apply_graduated_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:aggregation_result) { BaseService::Result.new } + let(:billable_metric) { create(:sum_billable_metric, recurring: true) } + let(:aggregation) { 5.96667 } + let(:aggregator) do + BillableMetrics::ProratedAggregations::SumService.new( + event_store_class:, + charge:, + subscription:, + boundaries: nil + ) + end + let(:event_store_class) { Events::Stores::PostgresStore } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [5, 5, 10, -6] + r.event_prorated_aggregation = [3.5, 2.66667, 2, -2.2] + end + end + let(:charge) do + create( + :graduated_charge, + billable_metric:, + organization:, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + end + + before do + aggregation_result.aggregator = aggregator + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 14 + aggregation_result.current_usage_units = 14 + + allow(aggregator).to receive(:per_event_aggregation).and_return(per_event_aggregation) + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(197.33) + expect(apply_graduated_service.unit_amount.round(2)).to eq(14.10) # 197.33 / 14 + expect(apply_graduated_service.amount_details).to eq({}) + end + + context "with event that cannot be fully placed into the range" do + let(:aggregation) { 3.86667 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [2, 5, 10, -6] + r.event_prorated_aggregation = [1.4, 2.66667, 2, -2.2] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 11 + aggregation_result.current_usage_units = 11 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(184.33) + expect(apply_graduated_service.unit_amount.round(2)).to eq(16.76) # 184.33 / 11 + expect(apply_graduated_service.amount_details).to eq({}) + end + end + + context "with final number of units equals to zero" do + let(:aggregation) { 1.613 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [1, 4, 1, -5, -1] + r.event_prorated_aggregation = [0.7097, 1.54839, 0.2258, -0.80645, -0.0645] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 0 + aggregation_result.current_usage_units = 0 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(165.81) + expect(apply_graduated_service.unit_amount).to eq(0) + expect(apply_graduated_service.amount_details).to eq({}) + end + end + + context "with negative event that results in changing range" do + let(:aggregation) { 2.5 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [5, -2] + r.event_prorated_aggregation = [3.5, -1] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 3 + aggregation_result.current_usage_units = 3 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(125) + expect(apply_graduated_service.unit_amount.round(2)).to eq(41.67) + expect(apply_graduated_service.amount_details).to eq({}) + end + + context "with overflow and changing ranges" do + let(:aggregation) { 3.2 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [4, 2, -3] + r.event_prorated_aggregation = [2.8, 1, -0.6] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 3 + aggregation_result.current_usage_units = 3 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(180.5) + expect(apply_graduated_service.unit_amount.round(2)).to eq(60.17) + expect(apply_graduated_service.amount_details).to eq({}) + end + end + + context "with multiple overflows in both directions" do + let(:aggregation) { 4.9 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [5, 2, -4, 10] + r.event_prorated_aggregation = [3.5, 1, -1.6, 2] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 13 + aggregation_result.current_usage_units = 13 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(190) + expect(apply_graduated_service.unit_amount.round(2)).to eq(14.62) + expect(apply_graduated_service.amount_details).to eq({}) + end + end + end + + context "with negative event that results in negative total amount" do + let(:aggregation) { -31.33 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [5, -100] + r.event_prorated_aggregation = [2, -33.33] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = -95 + aggregation_result.current_usage_units = -95 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(0) + expect(apply_graduated_service.unit_amount).to eq(0) + expect(apply_graduated_service.amount_details).to eq({}) + end + + context "with only one range used" do + let(:aggregation) { -31.73 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [4, -100] + r.event_prorated_aggregation = [1.6, -33.33] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = -96 + aggregation_result.current_usage_units = -96 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(0) + expect(apply_graduated_service.unit_amount).to eq(0) + expect(apply_graduated_service.amount_details).to eq({}) + end + end + end + + context "when only one range is used" do + let(:aggregation) { 0.7 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [1] + r.event_prorated_aggregation = [0.7] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 1 + aggregation_result.current_usage_units = 1 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.round(2)).to eq(107) + expect(apply_graduated_service.unit_amount).to eq(107) + expect(apply_graduated_service.amount_details).to eq({}) + end + + context "with two ranges where first unit fully covers first range" do + let(:charge) do + create( + :graduated_charge, + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 1, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 2, + to_value: nil, + per_unit_amount: "5", + flat_amount: "50" + } + ] + } + ) + end + + it "calculates the amount correctly and second flat fee is not applied" do + expect(apply_graduated_service.amount.round(2)).to eq(107) + end + end + end + + context "with three ranges and one overflow" do + let(:aggregation) { 6.36 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [2, 5, 10, -6, 4, 60] + r.event_prorated_aggregation = [1.4, 2.5, 2, -2.2, 0.667, 2] + end + end + let(:charge) do + create( + :graduated_charge, + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "10", + flat_amount: "100" + }, + { + from_value: 6, + to_value: 15, + per_unit_amount: "5", + flat_amount: "50" + }, + { + from_value: 16, + to_value: nil, + per_unit_amount: "2", + flat_amount: "0" + } + ] + } + ) + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 75 + aggregation_result.current_usage_units = 75 + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.ceil(2)).to eq(191.34) + expect(apply_graduated_service.unit_amount.round(2)).to eq(2.55) + expect(apply_graduated_service.amount_details).to eq({}) + end + + context "when there are two overflows" do + let(:aggregation) { 75 } + let(:per_event_aggregation) do + BaseService::Result.new.tap do |r| + r.event_aggregation = [75] + r.event_prorated_aggregation = [75] + end + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.full_units_number = 75 + aggregation_result.current_usage_units = 75 + end + + it "calculates the amount correctly" do + expect(apply_graduated_service.amount.round(2)).to eq(370) + end + + it "returns expected amount" do + expect(apply_graduated_service.amount.ceil(2)).to eq(370) + expect(apply_graduated_service.unit_amount.round(2)).to eq(4.93) + expect(apply_graduated_service.amount_details).to eq({}) + end + end + end +end diff --git a/spec/services/charge_models/standard_service_spec.rb b/spec/services/charge_models/standard_service_spec.rb new file mode 100644 index 0000000..18a72df --- /dev/null +++ b/spec/services/charge_models/standard_service_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::StandardService do + subject(:apply_standard_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + before do + aggregation_result.aggregation = aggregation + aggregation_result.total_aggregated_units = total_aggregated_units if total_aggregated_units + aggregation_result.full_units_number = full_units_number if full_units_number + end + + let(:aggregation_result) { BaseService::Result.new } + let(:aggregation) { 10 } + let(:total_aggregated_units) { nil } + let(:full_units_number) { nil } + + let(:charge) do + create( + :standard_charge, + charge_model: "standard", + properties: { + amount: "5.12345" + } + ) + end + + it "applies the charge model to the value" do + expect(apply_standard_service.amount).to eq(51.2345) + expect(apply_standard_service.unit_amount).to eq(5.12345) + end + + context "when aggregation result contains total_aggregated_units" do + let(:total_aggregated_units) { 10 } + + it "assigns the total_aggregated_units to the result" do + expect(apply_standard_service.total_aggregated_units).to eq(total_aggregated_units) + end + end + + context "when aggregation result contains full_units_number" do + let(:full_units_number) { 100 } + + it "applies the charge model to the value" do + expect(apply_standard_service.unit_amount).to eq(0.512345) + end + end + + context "when charge is a fixed charge" do + let(:charge) { build(:fixed_charge, charge_model: :standard, properties: {amount: "10"}) } + + it "applies the charge model to the value" do + expect(apply_standard_service.amount).to eq(100) + expect(apply_standard_service.unit_amount).to eq(10) + end + end +end diff --git a/spec/services/charge_models/volume_service_spec.rb b/spec/services/charge_models/volume_service_spec.rb new file mode 100644 index 0000000..fcd934f --- /dev/null +++ b/spec/services/charge_models/volume_service_spec.rb @@ -0,0 +1,190 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeModels::VolumeService do + subject(:apply_volume_service) do + described_class.apply( + charge:, + aggregation_result:, + properties: charge.properties, + period_ratio: 1.0 + ) + end + + before do + aggregation_result.aggregation = aggregation + end + + let(:aggregation_result) { BaseService::Result.new } + + let(:charge) do + create( + :volume_charge, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "10"}, + {from_value: 101, to_value: 200, per_unit_amount: "1", flat_amount: "0"}, + {from_value: 201, to_value: nil, per_unit_amount: "0.5", flat_amount: "50"} + ] + } + ) + end + + context "when aggregation is 0" do + let(:aggregation) { 0 } + + it "does not apply the flat amount" do + expect(apply_volume_service.amount).to eq(0) + expect(apply_volume_service.unit_amount).to eq(0) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 0.0, + per_unit_amount: 0.0, + per_unit_total_amount: 0.0 + } + ) + end + end + + context "when aggregation is 1" do + let(:aggregation) { 1 } + + it "applies a unit amount for 1 and the flat amount" do + expect(apply_volume_service.amount).to eq(12) + expect(apply_volume_service.unit_amount).to eq(12) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 10, + per_unit_amount: "2.0", + per_unit_total_amount: 2 + } + ) + end + end + + context "when aggregation is the limit of the first range" do + let(:aggregation) { 100 } + + it "applies unit amount for the first range and the flat amount" do + expect(apply_volume_service.amount).to eq(210) + expect(apply_volume_service.unit_amount).to eq(2.1) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 10, + per_unit_amount: "2.0", + per_unit_total_amount: 200 + } + ) + end + end + + context "when aggregation is in the between of first and second range" do + let(:aggregation) { 100.5 } + + it "applies unit amount for the second range and no flat amount" do + expect(apply_volume_service.amount).to eq(100.5) + expect(apply_volume_service.unit_amount).to eq(1) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 0, + per_unit_amount: "1.0", + per_unit_total_amount: 100.5 + } + ) + end + end + + context "when aggregation is the lower limit of the second range" do + let(:aggregation) { 101 } + + it "applies unit amount the second range and no flat amount" do + expect(apply_volume_service.amount).to eq(101) + expect(apply_volume_service.unit_amount).to eq(1) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 0, + per_unit_amount: "1.0", + per_unit_total_amount: 101 + } + ) + end + end + + context "when aggregation is the uper limit of the second range" do + let(:aggregation) { 200 } + + it "applies unit amount the second range and no flat amount" do + expect(apply_volume_service.amount).to eq(200) + expect(apply_volume_service.unit_amount).to eq(1) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 0, + per_unit_amount: "1.0", + per_unit_total_amount: 200 + } + ) + end + end + + context "when aggregation is the above the lower limit of the last range" do + let(:aggregation) { 300 } + + it "applies unit amount the second range and no flat amount" do + expect(apply_volume_service.amount).to eq(200) + expect(apply_volume_service.unit_amount.round(2)).to eq(0.67) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 50, + per_unit_amount: "0.5", + per_unit_total_amount: 150 + } + ) + end + end + + context "when charge is prorated" do + let(:aggregation) { 198.6 } + let(:billable_metric) { create(:sum_billable_metric, recurring: true) } + + before do + charge.update!(prorated: true, billable_metric:) + aggregation_result.full_units_number = 300 + end + + it "applies unit amount the third range" do + expect(apply_volume_service.amount).to eq(149.3) + expect(apply_volume_service.unit_amount.round(2)).to eq(0.50) + expect(apply_volume_service.amount_details).to eq( + { + flat_unit_amount: 50, + per_unit_amount: "0.331", + per_unit_total_amount: 99.3 + } + ) + end + end + + context "when charge is a fixed charge" do + let(:aggregation) { 210 } + let(:charge) do + build( + :fixed_charge, + charge_model: :volume, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "10"}, + {from_value: 101, to_value: 200, per_unit_amount: "1", flat_amount: "0"}, + {from_value: 201, to_value: nil, per_unit_amount: "0.5", flat_amount: "50"} + ] + } + ) + end + + it "applies the charge model to the value" do + # 50 + 210 * 0.5 = 155 + expect(apply_volume_service.amount).to eq(155) + expect(apply_volume_service.unit_amount.round(2)).to eq((155 / 210.0).round(2)) + end + end +end diff --git a/spec/services/charges/apply_pay_in_advance_charge_model_service_spec.rb b/spec/services/charges/apply_pay_in_advance_charge_model_service_spec.rb new file mode 100644 index 0000000..0624a5b --- /dev/null +++ b/spec/services/charges/apply_pay_in_advance_charge_model_service_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::ApplyPayInAdvanceChargeModelService do + let(:charge_service) { described_class.new(charge:, aggregation_result:, properties:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, :pay_in_advance, plan:) } + let(:subscription) { create(:subscription, plan:) } + + let(:aggregation_result) do + BillableMetrics::Aggregations::BaseService::Result.new.tap do |result| + result.aggregation = 10 + result.pay_in_advance_aggregation = 1 + result.count = 5 + result.options = {} + result.aggregator = aggregator + result.pay_in_advance_event = pay_in_advance_event + end + end + let(:properties) { {} } + + let(:aggregator) do + BillableMetrics::Aggregations::CountService.new( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription: nil, + boundaries: nil + ) + end + + let(:pay_in_advance_event) do + source = create( + :event, + external_subscription_id: subscription.external_id, + external_customer_id: subscription.external_id, + organization_id: organization.id, + properties: {} + ) + Events::CommonFactory.new_instance(source:) + end + + describe "#call" do + context "when charge is not pay_in_advance" do + let(:charge) { create(:standard_charge) } + + it "returns an error" do + result = charge_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("apply_charge_model_error") + expect(result.error.error_message).to eq("Charge is not pay_in_advance") + end + end + + shared_examples "a charge model" do + it "delegates to the charge model service" do + previous_agg_result = BillableMetrics::Aggregations::BaseService::Result.new.tap do |result| + result.aggregation = 9 + result.count = 4 + result.options = {} + result.aggregator = aggregator + result.pay_in_advance_event = pay_in_advance_event + end + + allow(charge_model_class).to receive(:apply) + .with(charge:, aggregation_result:, properties:) + .and_return(BaseService::Result.new.tap { |r| r.amount = 10 }) + + allow(charge_model_class).to receive(:apply) + .with(charge:, aggregation_result: previous_agg_result, properties: properties.merge(exclude_event: true)) + .and_return(BaseService::Result.new.tap { |r| r.amount = 8 }) + + result = charge_service.call + + expect(result.units).to eq(1) + expect(result.count).to eq(1) + expect(result.amount).to eq(200) # In cents + expect(result.precise_amount).to eq(200.0) # In cents + expect(result.unit_amount).to eq(2) + end + + context "when the event is not persisted" do + before { pay_in_advance_event.persisted = false } + + it "delegates to the charge model service" do + non_persisted_agg_result = BillableMetrics::Aggregations::BaseService::Result.new.tap do |result| + result.aggregation = 11 + result.count = 6 + result.options = {} + result.aggregator = aggregator + result.pay_in_advance_event = pay_in_advance_event + end + + allow(charge_model_class).to receive(:apply) + .with(charge:, aggregation_result:, properties:) + .and_return(BaseService::Result.new.tap { |r| r.amount = 8 }) + + allow(charge_model_class).to receive(:apply) + .with(charge:, aggregation_result: non_persisted_agg_result, properties: properties.merge(include_event_value: true)) + .and_return(BaseService::Result.new.tap { |r| r.amount = 10 }) + + result = charge_service.call + + expect(result.units).to eq(1) + expect(result.count).to eq(1) + expect(result.amount).to eq(2_00) # In cents + expect(result.precise_amount).to eq(2_00.0) # In cents + expect(result.unit_amount).to eq(2) + expect(result.amount_details).to be_nil + end + end + end + + describe "when standard charge model" do + let(:charge_model_class) { ChargeModels::StandardService } + + it_behaves_like "a charge model" + end + + describe "when graduated charge model" do + let(:charge) do + create( + :graduated_charge, + :pay_in_advance, + plan:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + let(:charge_model_class) { ChargeModels::GraduatedService } + + it_behaves_like "a charge model" + end + + describe "when package charge model" do + let(:charge) { create(:package_charge, :pay_in_advance, plan:) } + let(:charge_model_class) { ChargeModels::PackageService } + + it_behaves_like "a charge model" + end + + describe "when percentage charge model" do + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:) } + let(:charge_model_class) { ChargeModels::PercentageService } + + it_behaves_like "a charge model" + end + + describe "when graduated percentage charge model", :premium do + let(:charge) do + create( + :graduated_percentage_charge, + :pay_in_advance, + plan:, + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: nil, + flat_amount: "0.01", + rate: "2" + } + ] + } + ) + end + + let(:charge_model_class) { ChargeModels::GraduatedPercentageService } + + it_behaves_like "a charge model" + end + + describe "when dynamic charge model" do + let(:charge) { create(:dynamic_charge, :pay_in_advance, plan:) } + let(:charge_model_class) { ChargeModels::DynamicService } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:aggregator) do + BillableMetrics::Aggregations::SumService.new( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: nil + ) + end + + it_behaves_like "a charge model" + end + end +end diff --git a/spec/services/charges/apply_taxes_service_spec.rb b/spec/services/charges/apply_taxes_service_spec.rb new file mode 100644 index 0000000..e94fb46 --- /dev/null +++ b/spec/services/charges/apply_taxes_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::ApplyTaxesService do + subject(:apply_service) { described_class.new(charge:, tax_codes:) } + + let(:plan) { create(:plan) } + let(:charge) { create(:standard_charge, plan:) } + let(:tax1) { create(:tax, organization: plan.organization, code: "tax1") } + let(:tax2) { create(:tax, organization: plan.organization, code: "tax2") } + let(:tax_codes) { [tax1.code, tax2.code] } + + describe "call" do + it "applies taxes to the charge" do + expect { apply_service.call }.to change { charge.applied_taxes.count }.from(0).to(2) + end + + it "unassigns existing taxes" do + existing = create(:charge_applied_tax, charge:) + apply_service.call + expect(Charge::AppliedTax.find_by(id: existing.id)).to be_nil + end + + it "returns applied taxes" do + result = apply_service.call + expect(result.applied_taxes.count).to eq(2) + end + + context "when charge is not found" do + let(:charge) { nil } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("charge_not_found") + end + end + + context "when tax is not found" do + let(:tax_codes) { ["unknown"] } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when applied tax is already present" do + it "does not create a new applied tax" do + create(:charge_applied_tax, charge:, tax: tax1) + expect { apply_service.call }.to change { charge.applied_taxes.count }.from(1).to(2) + end + end + + context "when trying to apply twice the same tax" do + let(:tax_codes) { [tax1.code, tax1.code] } + + it "assigns it only once" do + expect { apply_service.call }.to change { charge.applied_taxes.count }.from(0).to(1) + end + end + end +end diff --git a/spec/services/charges/bulk_forecasted_usage_amount_service_spec.rb b/spec/services/charges/bulk_forecasted_usage_amount_service_spec.rb new file mode 100644 index 0000000..b884793 --- /dev/null +++ b/spec/services/charges/bulk_forecasted_usage_amount_service_spec.rb @@ -0,0 +1,347 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::BulkForecastedUsageAmountService do + subject(:service) { described_class.new(charges_data: charges_data) } + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization: organization) } + let(:plan) { create(:plan, organization: organization, amount_cents: 1000) } + let(:charge1) do + create( + :standard_charge, + plan: plan, + billable_metric: billable_metric, + properties: {amount: "10"} + ) + end + let(:charge2) do + create( + :standard_charge, + plan: plan, + billable_metric: billable_metric, + properties: {amount: "20"} + ) + end + let(:charge_filter) { create(:charge_filter, charge: charge1) } + + let(:price_result) do + BaseService::Result.new.tap do |result| + result.charge_amount_cents = 10 + result.subscription_amount_cents = 10 + result.total_amount_cents = 20 + end + end + + before do + allow(Charges::CalculatePriceService).to receive(:call).and_return(price_result) + end + + describe "#call" do + context "when charges_data is empty" do + let(:charges_data) { [] } + + it "returns empty results" do + result = service.call + + expect(result).to be_success + expect(result.results).to eq([]) + expect(result.failed_charges).to eq([]) + expect(result.processed_count).to eq(0) + expect(result.failed_count).to eq(0) + end + end + + context "when processing valid charges with all percentile keys" do + let(:charges_data) do + [ + { + record_id: 1, + charge_id: charge1.id, + charge_filter_id: charge_filter.id, + units_conservative: 100, + units_realistic: 500, + units_optimistic: 1000 + }, + { + record_id: 2, + charge_id: charge2.id, + units_conservative: 200, + units_realistic: 600, + units_optimistic: 1200 + } + ] + end + + it "returns successful results for all charges" do + result = service.call + + expect(result).to be_success + expect(result.results.size).to eq(2) + expect(result.failed_charges).to be_empty + expect(result.processed_count).to eq(2) + expect(result.failed_count).to eq(0) + end + + it "includes all percentile amounts in results" do + result = service.call + + first_result = result.results.first + expect(first_result[:record_id]).to eq(1) + expect(first_result[:charge_id]).to eq(charge1.id) + expect(first_result[:charge_filter_id]).to eq(charge_filter.id) + expect(first_result[:charge_amount_cents_conservative]).to eq(1000) + expect(first_result[:charge_amount_cents_realistic]).to eq(1000) + expect(first_result[:charge_amount_cents_optimistic]).to eq(1000) + expect(first_result[:subscription_amount_cents_conservative]).to eq(1000) + expect(first_result[:subscription_amount_cents_realistic]).to eq(1000) + expect(first_result[:subscription_amount_cents_optimistic]).to eq(1000) + expect(first_result[:total_amount_cents_conservative]).to eq(2000) + expect(first_result[:total_amount_cents_realistic]).to eq(2000) + expect(first_result[:total_amount_cents_optimistic]).to eq(2000) + + second_result = result.results.second + expect(second_result[:record_id]).to eq(2) + expect(second_result[:charge_id]).to eq(charge2.id) + expect(second_result[:charge_filter_id]).to be_nil + end + + it "calls CalculatePriceService for each percentile" do + service.call + + expect(Charges::CalculatePriceService).to have_received(:call).exactly(6).times + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 100, + charge: charge1, + charge_filter: charge_filter + ) + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 500, + charge: charge1, + charge_filter: charge_filter + ) + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 1000, + charge: charge1, + charge_filter: charge_filter + ) + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 200, + charge: charge2, + charge_filter: nil + ) + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 600, + charge: charge2, + charge_filter: nil + ) + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 1200, + charge: charge2, + charge_filter: nil + ) + end + + it "multiplies amounts by 100" do + result = service.call + + first_result = result.results.first + expect(first_result[:charge_amount_cents_conservative]).to eq( + price_result.charge_amount_cents * 100 + ) + expect(first_result[:subscription_amount_cents_conservative]).to eq( + price_result.subscription_amount_cents * 100 + ) + expect(first_result[:total_amount_cents_conservative]).to eq( + price_result.total_amount_cents * 100 + ) + end + + it "logs response summary" do + allow(Rails.logger).to receive(:info) + + service.call + + expect(Rails.logger).to have_received(:info).with( + /\[ChargesController\] Response summary:/ + ) + end + end + + context "when processing charges with partial percentile keys" do + let(:charges_data) do + [ + { + record_id: 3, + charge_id: charge1.id, + units_conservative: 100 + } + ] + end + + it "only includes amounts for provided percentile keys" do + result = service.call + + expect(result).to be_success + first_result = result.results.first + expect(first_result).to have_key(:charge_amount_cents_conservative) + expect(first_result).not_to have_key(:charge_amount_cents_realistic) + expect(first_result).not_to have_key(:charge_amount_cents_optimistic) + end + + it "calls CalculatePriceService only for provided percentiles" do + service.call + + expect(Charges::CalculatePriceService).to have_received(:call).once + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 100, + charge: charge1, + charge_filter: nil + ) + end + end + + context "when charge is not found" do + let(:charges_data) do + [ + { + record_id: 4, + charge_id: "nonexistent", + units_conservative: 100 + } + ] + end + + it "adds charge to failed_charges" do + result = service.call + + expect(result).to be_success + expect(result.results).to be_empty + expect(result.failed_charges.size).to eq(1) + expect(result.failed_charges.first[:record_id]).to eq(4) + expect(result.failed_charges.first[:charge_id]).to eq("nonexistent") + expect(result.failed_charges.first[:error]).to include("Charge not found") + expect(result.processed_count).to eq(0) + expect(result.failed_count).to eq(1) + end + end + + context "when charge_filter is not found" do + let(:charges_data) do + [ + { + record_id: 5, + charge_id: charge1.id, + charge_filter_id: "nonexistent", + units_conservative: 100 + } + ] + end + + it "adds charge to failed_charges" do + result = service.call + + expect(result).to be_success + expect(result.results).to be_empty + expect(result.failed_charges.size).to eq(1) + expect(result.failed_charges.first[:record_id]).to eq(5) + expect(result.failed_charges.first[:charge_id]).to eq(charge1.id) + expect(result.failed_charges.first[:error]).to include("ChargeFilter not found") + expect(result.processed_count).to eq(0) + expect(result.failed_count).to eq(1) + end + end + + context "with mixed successful and failed charges" do + let(:charges_data) do + [ + { + record_id: 6, + charge_id: charge1.id, + units_conservative: 100 + }, + { + record_id: 7, + charge_id: "nonexistent", + units_conservative: 200 + }, + { + record_id: 8, + charge_id: charge2.id, + units_realistic: 300 + } + ] + end + + it "returns partial results with both successful and failed charges" do + result = service.call + + expect(result).to be_success + expect(result.results.size).to eq(2) + expect(result.failed_charges.size).to eq(1) + expect(result.processed_count).to eq(2) + expect(result.failed_count).to eq(1) + + expect(result.results.first[:record_id]).to eq(6) + expect(result.results.second[:record_id]).to eq(8) + expect(result.failed_charges.first[:record_id]).to eq(7) + end + end + + context "when processing charges without charge_filter_id" do + let(:charges_data) do + [ + { + record_id: 10, + charge_id: charge1.id, + units_conservative: 100 + } + ] + end + + it "passes nil charge_filter to CalculatePriceService" do + service.call + + expect(Charges::CalculatePriceService).to have_received(:call).with( + units: 100, + charge: charge1, + charge_filter: nil + ) + end + + it "includes nil charge_filter_id in results" do + result = service.call + + first_result = result.results.first + expect(first_result[:charge_filter_id]).to be_nil + end + end + + context "when bulk loading charges and charge_filters" do + let(:charges_data) do + [ + {record_id: 11, charge_id: charge1.id, units_conservative: 100}, + {record_id: 12, charge_id: charge2.id, units_conservative: 200}, + {record_id: 13, charge_id: charge1.id, units_conservative: 300} + ] + end + + it "loads charges only once" do + allow(Charge).to receive(:where).and_call_original + + service.call + + expect(Charge).to have_received(:where).with(id: [charge1.id, charge2.id]) + end + + it "processes all charges successfully" do + result = service.call + + expect(result).to be_success + expect(result.results.size).to eq(3) + expect(result.failed_charges).to be_empty + end + end + end +end diff --git a/spec/services/charges/calculate_price_service_spec.rb b/spec/services/charges/calculate_price_service_spec.rb new file mode 100644 index 0000000..99e1527 --- /dev/null +++ b/spec/services/charges/calculate_price_service_spec.rb @@ -0,0 +1,228 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::CalculatePriceService do + subject(:calculate_price_service) { described_class.new(units:, charge:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:, amount_cents: 1000) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:units) { 5 } + + describe "#call" do + context "when there is no charge for the billable metric" do + let(:charge) { nil } + + it "fails with a validation error" do + result = calculate_price_service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("charge_not_found") + end + end + + context "when there is a standard charge" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "10"} + ) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + expect(result.charge_amount_cents).to eq(50) # 5 units * 10 + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1050) + end + end + + context "when charge has pricing_group_keys but no grouped data" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: { + pricing_group_keys: ["region"], + amount: "10" + } + ) + end + + it "calculates using base pricing without grouped service" do + result = calculate_price_service.call + + expect(result.charge_amount_cents).to eq(50) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1050) + end + end + + context "when there is a graduated charge" do + let(:charge) do + create( + :graduated_charge, + plan:, + billable_metric:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 2, per_unit_amount: "2", flat_amount: "0"}, + {from_value: 3, to_value: nil, per_unit_amount: "3", flat_amount: "0"} + ] + } + ) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + # First range: 2 units * 2 = 4 + # Second range: 3 units * 3 = 9 + # Total charge: 13 + expect(result.charge_amount_cents).to eq(13) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1013) + end + end + + context "when there is a package charge" do + let(:charge) do + create( + :package_charge, + plan:, + billable_metric:, + properties: { + package_size: 2, + amount: "10", + free_units: 1 + } + ) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + # 5 units - 1 free unit = 4 paid units + # 4 paid units / 2 package size = 2 packages + # 2 packages * 10 = 20 + expect(result.charge_amount_cents).to eq(20) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1020) + end + end + + context "when there is a volume charge" do + let(:charge) do + create(:volume_charge, + plan:, + billable_metric:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 2, per_unit_amount: "2", flat_amount: "0"}, + {from_value: 3, to_value: nil, per_unit_amount: "3", flat_amount: "0"} + ] + }) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + # All 5 units fall into the second range + # 5 units * 3 = 15 + expect(result.charge_amount_cents).to eq(15) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1015) + end + end + + context "when there is a percentage charge" do + let(:charge) do + create( + :percentage_charge, + plan:, + billable_metric:, + properties: { + rate: "10" + } + ) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + expect(result.charge_amount_cents).to eq(0.5) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1000.5) + end + end + + context "when there is a graduated percentage charge", :premium do + let(:charge) do + create( + :graduated_percentage_charge, + plan:, + billable_metric:, + properties: { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 2, rate: "10", flat_amount: "10"}, + {from_value: 3, to_value: nil, rate: "20", flat_amount: "20"} + ] + } + ) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + # First range: 2 units * 0.1 = 0.2 + # Second range: 3 units * 0.2 = 0.6 + # Total charge: 0.8 + expect(result.charge_amount_cents).to eq(30.8) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1030.8) + end + end + + context "when there is a dynamic charge", :premium do + let(:billable_metric) { create(:sum_billable_metric, organization:) } + + let(:charge) do + create( + :dynamic_charge, + plan:, + billable_metric: + ) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + expect(result.charge_amount_cents).to eq(0) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1000) + end + end + + context "when there is a custom charge", :premium do + let(:billable_metric) { create(:custom_billable_metric, organization:) } + + let(:charge) do + create(:custom_charge, plan:, billable_metric:) + end + + it "calculates the total amount correctly" do + result = calculate_price_service.call + + expect(result.charge_amount_cents).to eq(0) + expect(result.subscription_amount_cents).to eq(1000) + expect(result.total_amount_cents).to eq(1000) + end + end + end +end diff --git a/spec/services/charges/create_children_service_spec.rb b/spec/services/charges/create_children_service_spec.rb new file mode 100644 index 0000000..2baeb58 --- /dev/null +++ b/spec/services/charges/create_children_service_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::CreateChildrenService do + subject(:create_service) { described_class.new(child_ids:, charge:, payload:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, organization:, plan:, billable_metric:) } + + let(:child_plan) { create(:plan, organization:, parent_id:) } + let(:parent_id) { plan.id } + let(:child_ids) { child_plan.id } + + let(:payload) { {} } + + let(:billable_metric_filters) { create_list(:billable_metric_filter, 2, billable_metric:) } + let(:billable_metric_filter) { billable_metric_filters.first } + + before do + charge + child_plan + end + + describe "#call" do + context "when charge is not found" do + let(:charge) { nil } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("charge_not_found") + end + end + + context "when child charge is successfully added" do + let(:payload) do + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + pay_in_advance: false, + prorated: false, + invoiceable: false, + min_amount_cents: 10, + filters: [ + { + invoice_display_name: "Card filter", + properties: {amount: "90"}, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + ] + } + end + + it "creates new charge" do + expect { create_service.call }.to change(Charge, :count).by(1) + end + + it "does not touch plan" do + freeze_time do + expect { create_service.call }.not_to change { child_plan.reload.updated_at } + end + end + + it "sets correctly attributes" do + create_service.call + + stored_charge = child_plan.reload.charges.first + + expect(stored_charge).to have_attributes( + organization_id: organization.id, + prorated: false, + pay_in_advance: false, + parent_id: charge.id, + properties: {"amount" => "0"} + ) + + expect(stored_charge.filters.first).to have_attributes( + invoice_display_name: "Card filter", + properties: {"amount" => "90"} + ) + + expect(stored_charge.filters.first.values.first).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: [billable_metric_filter.values.first] + ) + end + end + end +end diff --git a/spec/services/charges/create_service_spec.rb b/spec/services/charges/create_service_spec.rb new file mode 100644 index 0000000..6e6b962 --- /dev/null +++ b/spec/services/charges/create_service_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::CreateService do + let(:create_service) { described_class.new(plan:, params:) } + + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + + describe "#call" do + subject(:result) { create_service.call } + + context "when plan is not found" do + let(:plan) { nil } + let(:params) { {} } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "when billable metric is not found" do + let(:params) { {billable_metric_id: "non-existing-id"} } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("billable_metric_not_found") + end + end + + context "when a charge with the same code already exists on the plan" do + let(:sum_billable_metric) { create(:sum_billable_metric, organization:, recurring: true) } + + let(:params) do + { + billable_metric_id: sum_billable_metric.id, + code: "existing_code", + charge_model: "standard", + properties: {amount: "100"} + } + end + + before do + create(:standard_charge, plan:, organization:, billable_metric: sum_billable_metric, code: "existing_code") + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({code: ["value_already_exist"]}) + end + end + + context "when plan exists" do + let(:sum_billable_metric) { create(:sum_billable_metric, organization:, recurring: true) } + + context "when params are invalid" do + let(:params) do + { + billable_metric_id: sum_billable_metric.id, + code: "invalid_charge", + charge_model: "graduated_percentage", + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: 10, + rate: "3", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + rate: "2", + flat_amount: "3" + } + ] + } + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:charge_model]).to eq(["graduated_percentage_requires_premium_license"]) + end + + it "does not create charge" do + expect { subject }.not_to change(Charge, :count) + end + end + + context "when params are valid" do + let!(:parent_charge) { create(:standard_charge) } + let(:pricing_unit) { create(:pricing_unit, organization:) } + let(:billable_metric_filter) do + create( + :billable_metric_filter, + billable_metric: sum_billable_metric, + key: "payment_method", + values: %w[card physical] + ) + end + let(:properties) { {} } + + let(:params) do + { + applied_pricing_unit: applied_pricing_unit_params, + billable_metric_id: sum_billable_metric.id, + code: "my_charge_code", + charge_model: "standard", + pay_in_advance: false, + prorated: true, + invoiceable: true, + parent_id: parent_charge.id, + min_amount_cents: 10, + accepts_target_wallet: true, + filters: [ + { + invoice_display_name: "Card filter", + properties: {amount: "90"}, + values: {billable_metric_filter.key => ["card"]} + } + ], + properties: properties + } + end + + let(:applied_pricing_unit_params) do + { + code: pricing_unit.code, + conversion_rate: rand(0.1..5.0) + } + end + + it "creates new charge" do + expect { subject }.to change(Charge, :count).by(1) + end + + it "sets correctly attributes" do + subject + + created_charge = plan.reload.charges.first + expect(created_charge).to have_attributes( + organization_id: organization.id, + code: "my_charge_code", + prorated: true, + pay_in_advance: false, + parent_id: parent_charge.id, + properties: {"amount" => "0"} + ) + + created_filter = created_charge.filters.first + expect(created_filter).to have_attributes( + invoice_display_name: "Card filter", + properties: {"amount" => "90"} + ) + + created_filter_value = created_charge.filters.first.values.first + expect(created_filter_value).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: ["card"] + ) + end + + context "when presentation_group_keys are present in properties" do + let(:properties) do + {amount: "0", presentation_group_keys: [{"value" => "department"}, {"value" => "region"}]} + end + + it "sets correctly attributes" do + subject + + created_charge = plan.reload.charges.first + expect(created_charge).to have_attributes( + properties: {"amount" => "0", "presentation_group_keys" => [{"value" => "department"}, {"value" => "region"}]} + ) + end + end + + context "when premium", :premium do + it "assigns premium attributes values from params" do + expect(result.charge) + .to be_persisted + .and have_attributes(invoiceable: true, min_amount_cents: 10) + end + + context "with accepts_target_wallet" do + context "when events_targeting_wallets is enabled" do + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "sets accepts_target_wallet from params" do + expect(result.charge).to be_persisted + expect(result.charge.accepts_target_wallet).to be true + end + end + + context "when events_targeting_wallets is not enabled" do + it "does not set accepts_target_wallet" do + expect(result.charge).to be_persisted + expect(result.charge.accepts_target_wallet).to be false + end + end + end + + context "when applied pricing unit params are valid" do + it "creates applied pricing unit" do + expect { subject }.to change(AppliedPricingUnit, :count).by(1) + end + end + + context "when applied pricing unit params are invalid" do + let(:applied_pricing_unit_params) do + { + code: "non-existing-code", + conversion_rate: -5 + } + end + + it "fails with a validation error" do + expect(result).to be_failure + + expect(result.error.messages).to match( + conversion_rate: ["value_is_out_of_range"], + pricing_unit: ["relation_must_exist"] + ) + end + + it "does not create charge" do + expect { subject }.not_to change(Charge, :count) + end + + it "does not create applied pricing unit" do + expect { subject }.not_to change(AppliedPricingUnit, :count) + end + end + end + + context "when freemium" do + it "assigns premium attributes default values no matter of values in params" do + expect(result.charge) + .to be_persisted + .and have_attributes(invoiceable: true, min_amount_cents: 0) + end + + it "does not create applied pricing units" do + expect { subject }.not_to change(AppliedPricingUnit, :count) + end + + it "ignores accepts_target_wallet from params" do + expect(result.charge).to be_persisted + expect(result.charge.accepts_target_wallet).to be false + end + end + end + end + end +end diff --git a/spec/services/charges/destroy_children_service_spec.rb b/spec/services/charges/destroy_children_service_spec.rb new file mode 100644 index 0000000..b8b26e0 --- /dev/null +++ b/spec/services/charges/destroy_children_service_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::DestroyChildrenService do + subject(:destroy_service) { described_class.new(charge) } + + let(:billable_metric) { create(:billable_metric) } + let(:organization) { billable_metric.organization } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, :deleted, plan:, billable_metric:) } + + let(:child_plan) { create(:plan, organization:, parent_id:) } + let(:parent_id) { plan.id } + let(:charge_parent_id) { charge.id } + let(:subscription) { create(:subscription, plan: child_plan) } + let(:child_charge) do + create( + :standard_charge, + plan_id: child_plan.id, + parent_id: charge_parent_id, + billable_metric_id: billable_metric.id, + properties: {amount: "300"} + ) + end + + before do + child_charge + subscription + end + + describe "#call" do + it "soft deletes the charge" do + freeze_time do + expect { destroy_service.call }.to change(Charge, :count).by(-1) + .and change { child_charge.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "does not touch plan" do + freeze_time do + expect { destroy_service.call }.not_to change { child_plan.reload.updated_at } + end + end + + context "when charge is not found" do + let(:charge) { nil } + let(:child_charge) { nil } + + it "returns an empty result" do + result = destroy_service.call + + expect(result).to be_success + expect(result.charge).to be_nil + end + end + + context "when charge is not deleted" do + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + + it "returns an empty result" do + result = destroy_service.call + + expect(result).to be_success + expect(result.charge).to be_nil + end + end + end +end diff --git a/spec/services/charges/destroy_service_spec.rb b/spec/services/charges/destroy_service_spec.rb new file mode 100644 index 0000000..ef47ee8 --- /dev/null +++ b/spec/services/charges/destroy_service_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::DestroyService do + subject(:destroy_service) { described_class.new(charge:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:subscription) { create(:subscription) } + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:) } + + let(:filters) { create_list(:billable_metric_filter, 2, billable_metric:) } + let(:charge_filter) { create(:charge_filter, charge:) } + let(:filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter: filters.first) + end + + before do + charge + filter_value + end + + describe "#call" do + it "soft deletes the charge" do + freeze_time do + expect { destroy_service.call }.to change(Charge, :count).by(-1) + .and change { charge.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "soft deletes all related filters" do + freeze_time do + expect { destroy_service.call }.to change { charge_filter.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "soft deletes all related filter values" do + freeze_time do + expect { destroy_service.call }.to change { filter_value.reload.deleted_at }.from(nil).to(Time.current) + end + end + + context "when charge is not found" do + it "returns an error" do + result = described_class.new(charge: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("charge_not_found") + end + end + + context "with cascade_updates" do + subject(:destroy_service) { described_class.new(charge:, cascade_updates: true) } + + let(:child_plan) { create(:plan, organization:, parent: subscription.plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, billable_metric:, parent: charge) } + + before do + child_charge + allow(Charges::DestroyChildrenJob).to receive(:perform_later) + end + + it "enqueues Charges::DestroyChildrenJob" do + destroy_service.call + + expect(Charges::DestroyChildrenJob).to have_received(:perform_later).with(charge.id) + end + + context "when charge has no children" do + before { child_charge.update!(parent_id: nil) } + + it "does not enqueue Charges::DestroyChildrenJob" do + destroy_service.call + + expect(Charges::DestroyChildrenJob).not_to have_received(:perform_later) + end + end + end + + context "without cascade_updates when charge has children" do + let(:child_plan) { create(:plan, organization:, parent: subscription.plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, billable_metric:, parent: charge) } + + before do + child_charge + allow(Charges::DestroyChildrenJob).to receive(:perform_later) + end + + it "does not enqueue Charges::DestroyChildrenJob" do + destroy_service.call + + expect(Charges::DestroyChildrenJob).not_to have_received(:perform_later) + end + end + end +end diff --git a/spec/services/charges/estimate_instant/percentage_service_spec.rb b/spec/services/charges/estimate_instant/percentage_service_spec.rb new file mode 100644 index 0000000..5edfac6 --- /dev/null +++ b/spec/services/charges/estimate_instant/percentage_service_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::EstimateInstant::PercentageService do + subject { described_class.new(properties:, units:) } + + let(:properties) do + { + "rate" => rate, + "fixed_amount" => fixed_amount, + "per_transaction_max_amount" => per_transaction_max_amount, + "per_transaction_min_amount" => per_transaction_min_amount + } + end + let(:units) { 0 } + let(:rate) { 0 } + let(:fixed_amount) { nil } + let(:per_transaction_max_amount) { nil } + let(:per_transaction_min_amount) { nil } + + describe "call" do + it "returns zero amounts" do + result = subject.call + expect(result.amount).to be_zero + expect(result.units).to be_zero + end + + context "when units is negative" do + let(:units) { -1 } + + it "returns zero amounts" do + result = subject.call + expect(result.amount).to be_zero + expect(result.units).to be_zero + end + end + + context "when units and rate are positive" do + let(:units) { 20 } + let(:rate) { 2.99 } + + it "returns the percentage amount" do + result = subject.call + expect(result.amount).to eq(0.598) + expect(result.units).to eq(20) + end + + context "when fixed_amount is configured" do + let(:fixed_amount) { 10 } + + it "includes the fixed amount" do + result = subject.call + expect(result.amount).to eq(10.598) + expect(result.units).to eq(20) + end + end + + context "when a maximum is set" do + let(:per_transaction_max_amount) { 0.1 } + + it "returns the percentage amount capped at the max" do + result = subject.call + expect(result.amount).to eq(0.1) + expect(result.units).to eq(20) + end + end + + context "when a minimum is set" do + let(:per_transaction_min_amount) { 5.5 } + + it "returns the percentage amount and at least the min" do + result = subject.call + expect(result.amount).to eq(5.5) + expect(result.units).to eq(20) + end + end + end + end + + context "with all combinations of testcases" do + let(:test_cases) do + # array consisting of units, rate, fixed_amount, max, min, expected_amount + [ + [100, 2, nil, nil, nil, 2], + [100, 0, nil, nil, nil, 0], + [100, 0, 12, nil, nil, 12], + [100, 0, 2, 15, 0, 2], + [100, 15, 3, 2, 1, 2], + [100, 15, 0, nil, 16, 16], + [0, 12, 2, nil, nil, 2], + [0, 12, 2, nil, 13, 13] + ] + end + + it "validates all testcases" do + test_cases.each do |arr| + expected_amount = arr.pop + units, rate, fixed_amount, per_transaction_max_amount, per_transaction_min_amount = *arr + properties = { + "rate" => rate, + "fixed_amount" => fixed_amount, + "per_transaction_max_amount" => per_transaction_max_amount, + "per_transaction_min_amount" => per_transaction_min_amount + } + expect(described_class.call(properties:, units:).amount).to eq(expected_amount) + end + end + end +end diff --git a/spec/services/charges/estimate_instant/standard_service_spec.rb b/spec/services/charges/estimate_instant/standard_service_spec.rb new file mode 100644 index 0000000..e543e90 --- /dev/null +++ b/spec/services/charges/estimate_instant/standard_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::EstimateInstant::StandardService do + subject { described_class.new(properties:, units:) } + + let(:properties) do + { + "amount" => amount + } + end + + let(:amount) { nil } + let(:units) { 0 } + + describe "call" do + it "returns zero amounts" do + result = subject.call + expect(result.amount).to be_zero + expect(result.units).to be_zero + end + + context "when units is negative" do + let(:units) { -1 } + + it "returns zero amounts" do + result = subject.call + expect(result.amount).to be_zero + expect(result.units).to be_zero + end + end + + context "when units and amount are positive" do + let(:units) { 20 } + let(:amount) { "20" } + + it "returns the percentage amount" do + result = subject.call + expect(result.amount).to eq(400) + expect(result.units).to eq(20) + end + end + end +end diff --git a/spec/services/charges/generate_code_service_spec.rb b/spec/services/charges/generate_code_service_spec.rb new file mode 100644 index 0000000..c4a004c --- /dev/null +++ b/spec/services/charges/generate_code_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::GenerateCodeService do + subject(:result) { described_class.call(plan:, billable_metric:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:, code: "api_calls") } + + describe "#call" do + context "when no charges exist for the plan" do + it "generates code without suffix" do + expect(result.code).to eq("api_calls") + end + end + + context "when a charge exists with the base code" do + before do + create(:standard_charge, plan:, billable_metric:, code: "api_calls") + end + + it "generates code with suffix _2" do + expect(result.code).to eq("api_calls_2") + end + end + + context "when charges exist with numeric suffixes" do + before do + create(:standard_charge, plan:, billable_metric:, code: "api_calls") + create(:standard_charge, plan:, billable_metric:, code: "api_calls_2") + end + + it "generates code with next available suffix" do + expect(result.code).to eq("api_calls_3") + end + end + + context "when multiple charges exist with gaps in suffixes" do + before do + create(:standard_charge, plan:, billable_metric:, code: "api_calls") + create(:standard_charge, plan:, billable_metric:, code: "api_calls_3") + create(:standard_charge, plan:, billable_metric:, code: "api_calls_5") + end + + it "generates code with suffix one greater than the maximum" do + expect(result.code).to eq("api_calls_6") + end + end + + context "when charges exist with similar but non-matching prefixes" do + before do + other_billable_metric = create(:billable_metric, organization:, code: "api_calls_premium") + create(:standard_charge, plan:, billable_metric: other_billable_metric, code: "api_calls_premium") + end + + it "generates code without suffix" do + expect(result.code).to eq("api_calls") + end + end + + context "when charges exist without numeric suffixes" do + before do + create(:standard_charge, plan:, billable_metric:, code: "api_calls_custom") + end + + it "generates code without suffix" do + expect(result.code).to eq("api_calls") + end + end + + context "when child charges exist with base code" do + let(:parent_plan) { create(:plan, organization:) } + let(:parent_charge) { create(:standard_charge, plan: parent_plan, billable_metric:, code: "api_calls") } + + before do + create(:standard_charge, plan:, billable_metric:, code: "api_calls", parent: parent_charge) + end + + it "ignores child charges and generates code without suffix" do + expect(result.code).to eq("api_calls") + end + end + end +end diff --git a/spec/services/charges/override_service_spec.rb b/spec/services/charges/override_service_spec.rb new file mode 100644 index 0000000..68f95be --- /dev/null +++ b/spec/services/charges/override_service_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::OverrideService do + subject(:override_service) { described_class.new(charge:, params:) } + + let(:organization) { create(:organization) } + + describe "#call" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:tax) { create(:tax, organization:) } + + let(:charge) do + create( + :standard_charge, + organization:, + billable_metric:, + properties: {amount: "300"} + ) + end + let(:plan) { create(:plan, organization:) } + let(:params) do + { + id: charge.id, + plan_id: plan.id, + # invoice_display_name: 'invoice display name', + min_amount_cents: 1000, + properties: {amount: "200"}, + tax_codes: [tax.code] + } + end + + before { charge } + + context "when lago freemium" do + it "returns without overriding the charge" do + expect { override_service.call }.not_to change(Charge, :count) + end + end + + context "when lago premium", :premium do + it "creates a charge based on the given charge" do + applied_tax = create(:charge_applied_tax, charge:) + + expect(charge.taxes).to contain_exactly(applied_tax.tax) + + expect { override_service.call }.to change(Charge, :count).by(1) + + new_charge = Charge.order(:created_at).last + expect(new_charge).to have_attributes( + amount_currency: new_charge.amount_currency, + billable_metric_id: new_charge.billable_metric.id, + charge_model: new_charge.charge_model, + invoiceable: new_charge.invoiceable, + parent_id: charge.id, + pay_in_advance: new_charge.pay_in_advance, + prorated: new_charge.prorated, + # Overriden attributes + plan_id: plan.id, + # invoice_display_name: 'invoice display name', + min_amount_cents: 1000, + properties: {"amount" => "200"} + ) + expect(new_charge.taxes).to contain_exactly(tax) + end + + context "with charge filters" do + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + + let(:charge) do + create( + :standard_charge, + billable_metric:, + properties: {amount: "300"} + ) + end + + let(:filters) do + [ + create( + :charge_filter, + charge:, + properties: {amount: "10"} + ), + create( + :charge_filter, + charge:, + properties: {amount: "20"} + ) + ] + end + + let(:filter_values) do + [ + create( + :charge_filter_value, + charge_filter: filters.first, + billable_metric_filter:, + values: [billable_metric_filter.values.first] + ), + create( + :charge_filter_value, + charge_filter: filters.second, + billable_metric_filter:, + values: [billable_metric_filter.values.second] + ) + ] + end + + let(:params) do + { + id: charge.id, + plan_id: plan.id, + min_amount_cents: 1000, + properties: {amount: "200"}, + tax_codes: [tax.code], + filters: [ + { + properties: {amount: "10"}, + invoice_display_name: "invoice display name", + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + ] + } + end + + before { filter_values } + + it "creates a charge based on the given charge" do + expect { override_service.call }.to change(Charge, :count).by(1) + + charge = Charge.order(:created_at).last + + expect(charge.filters.count).to eq(1) + expect(charge.filters.with_discarded.discarded.count).to eq(1) + expect(charge.filters.first).to have_attributes( + { + invoice_display_name: "invoice display name", + properties: {"amount" => "10"} + } + ) + expect(charge.filters.first.values.count).to eq(1) + expect(charge.filters.first.values.first).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: [billable_metric_filter.values.first] + ) + end + end + + context "with applied pricing unit" do + let(:params) do + { + id: charge.id, + plan_id: plan.id, + min_amount_cents: 1000, + properties: {amount: "200"}, + tax_codes: [tax.code], + applied_pricing_unit: { + conversion_rate: 5 + } + } + end + + before do + create( + :applied_pricing_unit, + pricing_unitable: charge, + conversion_rate: 1.1, + pricing_unit: create(:pricing_unit, organization:) + ) + end + + it "creates a charge based on the given charge" do + result = override_service.call + + expect(result).to be_success + expect(result.charge.applied_pricing_unit.conversion_rate).to eq 5 + end + + it "does not change parent charge" do + expect { override_service.call }.not_to change { charge.reload.attributes } + end + end + end + end +end diff --git a/spec/services/charges/pay_in_advance/amount_details_calculator_spec.rb b/spec/services/charges/pay_in_advance/amount_details_calculator_spec.rb new file mode 100644 index 0000000..dcec10e --- /dev/null +++ b/spec/services/charges/pay_in_advance/amount_details_calculator_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::PayInAdvance::AmountDetailsCalculator do + let(:amount_details_calculator) { described_class.new(charge:, applied_charge_model:, applied_charge_model_excluding_event:) } + + let(:charge) { create(:standard_charge, :pay_in_advance) } + let(:applied_charge_model) { instance_double("AppliedChargeModel", amount_details: all_charges_details) } + let(:applied_charge_model_excluding_event) { instance_double("AppliedChargeModel", amount_details: charges_details_without_last_event) } + + context "when charge model does not support pay in advance amount details" do + let(:all_charges_details) { nil } + let(:charges_details_without_last_event) { nil } + + it "returns an empty hash" do + expect(amount_details_calculator.call).to eq({}) + end + end + + context "when charge model is percentage" do + let(:charge) { create(:percentage_charge, :pay_in_advance) } + let(:charge_model) { "percentage" } + let(:all_charges_details) do + { + rate: 0.1, + fixed_fee_unit_amount: 100, + units: 10, + free_units: 2, + paid_units: 8, + free_events: 1, + paid_events: 9, + fixed_fee_total_amount: 1000, + min_max_adjustment_total_amount: 50, + per_unit_total_amount: 800 + } + end + let(:charges_details_without_last_event) do + { + rate: 0.1, + fixed_fee_unit_amount: 100, + units: 8, + free_units: 1, + paid_units: 7, + free_events: 1, + paid_events: 8, + fixed_fee_total_amount: 800, + min_max_adjustment_total_amount: 40, + per_unit_total_amount: 700 + } + end + + it "calculates percentage charge details" do + expected_details = { + rate: 0.1, + fixed_fee_unit_amount: 100, + units: "2.0", + free_units: "1.0", + paid_units: "1.0", + free_events: "0.0", + paid_events: "1.0", + fixed_fee_total_amount: "200.0", + min_max_adjustment_total_amount: "10.0", + per_unit_total_amount: "100.0" + } + expect(amount_details_calculator.call).to eq(expected_details) + end + end + + context "when charge model is graduated_percentage", :premium do + let(:charge) { create(:graduated_percentage_charge, :pay_in_advance) } + let(:all_charges_details) do + { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 100, flat_unit_amount: 5, rate: 0.1, units: 10, total_with_flat_amount: 100}, + {from_value: 100, to_value: 200, flat_unit_amount: 20, rate: 0.2, units: 20, total_with_flat_amount: 400} + ] + } + end + let(:charges_details_without_last_event) do + { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 100, flat_unit_amount: 5, rate: 0.1, units: 5, total_with_flat_amount: 50}, + {from_value: 100, to_value: 200, flat_unit_amount: 10, rate: 0.2, units: 10, total_with_flat_amount: 200} + ] + } + end + + it "calculates graduated percentage charge details" do + expected_details = { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 100, flat_unit_amount: 0, rate: 0.1, units: "5.0", per_unit_total_amount: "10.0", total_with_flat_amount: 50}, + {from_value: 100, to_value: 200, flat_unit_amount: 10, rate: 0.2, units: "10.0", per_unit_total_amount: "20.0", total_with_flat_amount: 200} + ] + } + expect(amount_details_calculator.call).to eq(expected_details) + end + + context "when first event covers all tiers" do + let(:all_charges_details) do + { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 100, flat_unit_amount: 10, rate: 0.1, units: 10, total_with_flat_amount: 100}, + {from_value: 100, to_value: 200, flat_unit_amount: 20, rate: 0.2, units: 20, total_with_flat_amount: 400} + ] + } + end + let(:charges_details_without_last_event) do + { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 100, flat_unit_amount: 0, rate: 0.1, units: 0, total_with_flat_amount: 0} + ] + } + end + + it "calculates graduated percentage charge details" do + expected_details = { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 100, flat_unit_amount: 10, rate: 0.1, units: "10.0", per_unit_total_amount: "10.0", total_with_flat_amount: 100}, + {from_value: 100, to_value: 200, flat_unit_amount: 20, rate: 0.2, units: "20.0", per_unit_total_amount: "20.0", total_with_flat_amount: 400} + ] + } + expect(amount_details_calculator.call).to eq(expected_details) + end + end + end +end diff --git a/spec/services/charges/pay_in_advance_aggregation_service_spec.rb b/spec/services/charges/pay_in_advance_aggregation_service_spec.rb new file mode 100644 index 0000000..425140f --- /dev/null +++ b/spec/services/charges/pay_in_advance_aggregation_service_spec.rb @@ -0,0 +1,333 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::PayInAdvanceAggregationService do + subject(:agg_service) do + described_class.new(charge:, boundaries:, properties:, event:, charge_filter:) + end + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type:, field_name: "item_id") } + let(:charge) { create(:standard_charge, billable_metric:, pay_in_advance: true) } + let(:charge_filter) { nil } + let(:aggregation_type) { "count_agg" } + let(:event) { create(:event, organization:, external_subscription_id: subscription.external_id, timestamp: subscription.started_at + 3.days + 1.hour) } + let(:properties) { {} } + + let(:customer) { create(:customer, organization:) } + + let(:subscription) do + create(:subscription, customer:, started_at: DateTime.parse("2023-03-15")) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: subscription.started_at.end_of_month.end_of_day - subscription.started_at.beginning_of_day, + timestamp: subscription.started_at.end_of_month.to_i + ) + end + + let(:agg_result) { BaseService::Result.new } + + describe "#call" do + describe "when count aggregation" do + let(:count_service) { instance_double(BillableMetrics::Aggregations::CountService, aggregate: agg_result) } + + it "delegates to the count aggregation service" do + allow(BillableMetrics::Aggregations::CountService).to receive(:new).and_return(count_service) + + agg_service.call + + expect(BillableMetrics::Aggregations::CountService).to have_received(:new) + .with( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: { + event:, + charge_id: charge.id + } + ) + + expect(count_service).to have_received(:aggregate).with( + options: {free_units_per_events: 0, free_units_per_total_aggregation: 0} + ) + end + + # TODO(pricing_group_keys): remove after deprecation of grouped_by + describe "when charge model has grouped_by property" do + let(:charge) do + create( + :standard_charge, + billable_metric:, + pay_in_advance: true, + properties: {"grouped_by" => ["operator"], "amount" => "100"} + ) + end + + let(:event) do + create( + :event, + organization:, + external_subscription_id: subscription.external_id, + properties: {"operator" => "foo"} + ) + end + + it "delegates to the count aggregation service" do + allow(BillableMetrics::Aggregations::CountService).to receive(:new).and_return(count_service) + + agg_service.call + + expect(BillableMetrics::Aggregations::CountService).to have_received(:new) + .with( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: { + event:, + charge_id: charge.id, + grouped_by_values: {"operator" => "foo"} + } + ) + + expect(count_service).to have_received(:aggregate).with( + options: {free_units_per_events: 0, free_units_per_total_aggregation: 0} + ) + end + end + + describe "when charge model has pricing_group_keys property" do + let(:charge) do + create( + :standard_charge, + billable_metric:, + pay_in_advance: true, + properties: {"pricing_group_keys" => ["operator"], "amount" => "100"} + ) + end + + let(:event) do + create( + :event, + organization:, + external_subscription_id: subscription.external_id, + properties: {"operator" => "foo"} + ) + end + + it "delegates to the count aggregation service" do + allow(BillableMetrics::Aggregations::CountService).to receive(:new).and_return(count_service) + + agg_service.call + + expect(BillableMetrics::Aggregations::CountService).to have_received(:new) + .with( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: { + event:, + charge_id: charge.id, + grouped_by_values: {"operator" => "foo"} + } + ) + + expect(count_service).to have_received(:aggregate).with( + options: {free_units_per_events: 0, free_units_per_total_aggregation: 0} + ) + end + end + + describe "when charge accepts_target_wallet", :premium do + let(:charge) do + create( + :standard_charge, + billable_metric:, + pay_in_advance: true, + accepts_target_wallet: true + ) + end + + let(:event) do + create( + :event, + organization:, + external_subscription_id: subscription.external_id, + properties: {"target_wallet_code" => "my_wallet"} + ) + end + + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "includes target_wallet_code in grouped_by_values" do + allow(BillableMetrics::Aggregations::CountService).to receive(:new).and_return(count_service) + + agg_service.call + + expect(BillableMetrics::Aggregations::CountService).to have_received(:new) + .with( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: { + event:, + charge_id: charge.id, + grouped_by_values: {"target_wallet_code" => "my_wallet"} + } + ) + + expect(count_service).to have_received(:aggregate).with( + options: {free_units_per_events: 0, free_units_per_total_aggregation: 0} + ) + end + end + + describe "when charge filter is present" do + let(:charge_filter) { create(:charge_filter, charge:) } + let(:filter) { create(:billable_metric_filter, billable_metric: charge.billable_metric) } + + let(:filter_value) do + create( + :charge_filter_value, + charge_filter:, + billable_metric_filter: filter, + values: [filter.values.first] + ) + end + + before { filter_value } + + it "delegates to the count aggregation service" do + allow(BillableMetrics::Aggregations::CountService).to receive(:new).and_return(count_service) + + agg_service.call + + expect(BillableMetrics::Aggregations::CountService).to have_received(:new) + .with( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: { + event:, + charge_id: charge.id, + charge_filter:, + matching_filters: charge_filter.to_h, + ignored_filters: [] + } + ) + + expect(count_service).to have_received(:aggregate).with( + options: {free_units_per_events: 0, free_units_per_total_aggregation: 0} + ) + end + end + end + + describe "when sum aggregation" do + let(:aggregation_type) { "sum_agg" } + let(:sum_service) { instance_double(BillableMetrics::Aggregations::SumService, aggregate: agg_result) } + let(:properties) do + {"free_units_per_events" => "3", "free_units_per_total_aggregation" => "50"} + end + + it "delegates to the sum aggregation service" do + allow(BillableMetrics::Aggregations::SumService).to receive(:new).and_return(sum_service) + + agg_service.call + + expect(BillableMetrics::Aggregations::SumService).to have_received(:new) + .with( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: { + event:, + charge_id: charge.id + } + ) + + expect(sum_service).to have_received(:aggregate).with( + options: {free_units_per_events: 3, free_units_per_total_aggregation: 50} + ) + end + end + + describe "when unique_count aggregation" do + let(:aggregation_type) { "unique_count_agg" } + let(:unique_count_service) do + instance_double(BillableMetrics::Aggregations::UniqueCountService, aggregate: agg_result) + end + + it "delegates to the sum aggregation service" do + allow(BillableMetrics::Aggregations::UniqueCountService).to receive(:new).and_return(unique_count_service) + + agg_service.call + + expect(BillableMetrics::Aggregations::UniqueCountService).to have_received(:new) + .with( + event_store_class: Events::Stores::PostgresStore, + charge:, + subscription:, + boundaries: { + from_datetime: boundaries.charges_from_datetime, + to_datetime: boundaries.charges_to_datetime, + charges_duration: boundaries.charges_duration, + max_timestamp: event.timestamp + }, + filters: { + event:, + charge_id: charge.id + } + ) + + expect(unique_count_service).to have_received(:aggregate).with( + options: {free_units_per_events: 0, free_units_per_total_aggregation: 0} + ) + end + end + end +end diff --git a/spec/services/charges/update_children_service_spec.rb b/spec/services/charges/update_children_service_spec.rb new file mode 100644 index 0000000..2c7d2d8 --- /dev/null +++ b/spec/services/charges/update_children_service_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::UpdateChildrenService do + subject(:update_service) do + described_class.new( + charge:, + params:, + old_parent_attrs:, + old_parent_filters_attrs:, + old_parent_applied_pricing_unit_attrs:, + child_ids: + ) + end + + let(:billable_metric) { create(:billable_metric) } + let(:organization) { billable_metric.organization } + let(:plan) { create(:plan, organization:) } + let(:old_parent_attrs) { charge&.attributes } + let(:old_parent_filters_attrs) { charge&.filters&.map(&:attributes) } + let(:old_parent_applied_pricing_unit_attrs) { charge&.applied_pricing_unit&.attributes } + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + amount_currency: "USD", + properties: { + amount: "300" + } + ) + end + let(:billable_metric_filter) do + create( + :billable_metric_filter, + billable_metric:, + key: "payment_method", + values: %w[card physical] + ) + end + + let(:child_plan) { create(:plan, organization:, parent_id:) } + let(:parent_id) { plan.id } + let(:charge_parent_id) { charge.id } + let(:child_ids) { [child_charge&.id] } + let(:child_charge) do + create( + :standard_charge, + plan_id: child_plan.id, + parent_id: charge_parent_id, + billable_metric_id: billable_metric.id, + properties: {amount: "300"} + ) + end + let(:params) do + { + id: charge&.id, + billable_metric_id: billable_metric.id, + charge_model: "standard", + pay_in_advance: true, + prorated: true, + invoiceable: false, + properties: { + amount: "400" + }, + applied_pricing_unit: {conversion_rate: 2.5}, + filters: [ + { + invoice_display_name: "Card filter", + properties: {amount: "90"}, + values: {billable_metric_filter.key => ["card"]} + } + ] + } + end + + before do + charge && create(:applied_pricing_unit, pricing_unitable: charge, conversion_rate: 1.1) + child_charge && create(:applied_pricing_unit, pricing_unitable: child_charge, conversion_rate: 1.1) + end + + describe "#call" do + context "when charge is not found" do + let(:charge) { nil } + let(:child_charge) { nil } + + it "returns an empty result" do + result = update_service.call + + expect(result).to be_success + expect(result.charge).to be_nil + end + end + + context "when charge has children that has not been modified" do + it "updates child charge" do + update_service.call + + expect(child_charge.reload).to have_attributes( + properties: {"amount" => "400"} + ) + + expect(child_charge.filters.first).to have_attributes( + invoice_display_name: "Card filter", + properties: {"amount" => "90"} + ) + expect(child_charge.filters.first.values.first).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: ["card"] + ) + expect(child_charge.applied_pricing_unit.conversion_rate).to eq 2.5 + end + + it "does not touch plan" do + freeze_time do + expect { update_service.call }.not_to change { child_plan.reload.updated_at } + end + end + + it "does not issue an extra child charge update from filter saves" do + child_charge_updates = [] + + callback = lambda do |_name, _start, _finish, _id, payload| + sql = payload[:sql] + next unless sql.match?(/\AUPDATE\s+"charges"/i) + + binds = payload[:type_casted_binds] || payload[:binds] + next unless Array(binds).include?(child_charge.id) + + child_charge_updates << sql + end + + ActiveSupport::Notifications.subscribed(callback, "sql.active_record") do + update_service.call + end + + expect(child_charge_updates.size).to eq(1) + end + end + + context "when charge has children that has been modified" do + let(:child_charge) do + create( + :standard_charge, + plan_id: child_plan.id, + parent_id: charge_parent_id, + billable_metric_id: billable_metric.id, + properties: {amount: "500"} + ) + end + + it "does not update charge properties" do + update_service.call + + expect(child_charge.reload).to have_attributes( + properties: {"amount" => "500"} + ) + end + end + + context "when charge has no children" do + let(:child_charge) do + create( + :standard_charge, + plan_id: child_plan.id, + parent_id: nil, + billable_metric_id: billable_metric.id, + properties: {amount: "300"} + ) + end + + before do + allow(Charges::UpdateService).to receive(:call!).and_call_original + end + + it "does not call the update service" do + update_service.call + + expect(Charges::UpdateService).not_to have_received(:call!) + end + + it "does not update charge properties" do + update_service.call + + expect(child_charge.reload).to have_attributes( + properties: {"amount" => "300"} + ) + end + end + end +end diff --git a/spec/services/charges/update_service_spec.rb b/spec/services/charges/update_service_spec.rb new file mode 100644 index 0000000..f9a33d8 --- /dev/null +++ b/spec/services/charges/update_service_spec.rb @@ -0,0 +1,456 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::UpdateService do + let(:update_service) { described_class.new(charge:, params:, cascade_options:, cascade_updates:) } + let(:cascade_updates) { false } + + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + let(:cascade_options) do + { + cascade: false + } + end + + describe "#call" do + subject(:result) { update_service.call } + + context "when charge is missing" do + let(:charge) { nil } + let(:params) { {} } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("charge_not_found") + end + end + + context "when updating code to one that already exists on the plan" do + let(:sum_billable_metric) { create(:sum_billable_metric, organization:, recurring: true) } + let(:charge) do + create(:standard_charge, plan:, organization:, billable_metric: sum_billable_metric, code: "original_code") + end + let(:cascade_options) { {cascade: false} } + let(:params) do + { + id: charge.id, + billable_metric_id: sum_billable_metric.id, + charge_model: "standard", + code: "taken_code", + properties: {amount: "100"} + } + end + + before do + create(:standard_charge, plan:, organization:, billable_metric: sum_billable_metric, code: "taken_code") + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({code: ["value_already_exist"]}) + end + end + + context "when charge exists" do + let(:sum_billable_metric) { create(:sum_billable_metric, organization:, recurring: true) } + let(:charge) do + create( + :standard_charge, + plan:, + organization:, + billable_metric_id: sum_billable_metric.id, + amount_currency: "USD", + properties: { + amount: "300" + } + ) + end + let(:billable_metric_filter) do + create( + :billable_metric_filter, + billable_metric: sum_billable_metric, + key: "payment_method", + values: %w[card physical] + ) + end + let(:params) do + { + id: charge.id, + billable_metric_id: sum_billable_metric.id, + charge_model: "standard", + pay_in_advance: true, + prorated: true, + invoiceable: false, + accepts_target_wallet: true, + properties: { + amount: "400" + }.merge(pricing_group_keys).merge(presentation_group_keys), + applied_pricing_unit: applied_pricing_unit_params, + filters: [ + { + invoice_display_name: "Card filter", + properties: {amount: "90"}, + values: {billable_metric_filter.key => ["card"]} + } + ] + } + end + + let(:applied_pricing_unit_params) do + { + conversion_rate: 2.5 + } + end + + let(:presentation_group_keys) { {} } + let(:pricing_group_keys) { {} } + + before { create(:applied_pricing_unit, pricing_unitable: charge, conversion_rate: 1.1) } + + it "updates existing charge" do + subject + + expect(charge.reload).to have_attributes( + prorated: true, + properties: {"amount" => "400"} + ) + + expect(charge.filters.first).to have_attributes( + invoice_display_name: "Card filter", + properties: {"amount" => "90"} + ) + expect(charge.filters.first.values.first).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: ["card"] + ) + end + + it "does not update premium attributes" do + subject + + expect(charge.reload).to have_attributes(pay_in_advance: true, invoiceable: true, accepts_target_wallet: false) + end + + context "when premium", :premium do + it "saves premium attributes" do + subject + + expect(charge.reload).to have_attributes(pay_in_advance: true, invoiceable: false) + end + + context "with accepts_target_wallet" do + context "when events_targeting_wallets is enabled" do + before do + charge.organization.update!(premium_integrations: ["events_targeting_wallets"]) + end + + it "updates accepts_target_wallet to true" do + expect { subject }.to change { charge.reload.accepts_target_wallet }.from(false).to(true) + end + + context "when accepts_target_wallet is false in params" do + let(:params) do + { + id: charge.id, + charge_model: "standard", + accepts_target_wallet: false, + properties: {amount: "400"} + } + end + + it "updates accepts_target_wallet to false" do + charge.update!(accepts_target_wallet: true) + + expect { subject }.to change { charge.reload.accepts_target_wallet }.from(true).to(false) + end + end + + context "when accepts_target_wallet is nil in params" do + let(:params) do + { + id: charge.id, + charge_model: "standard", + properties: {amount: "400"} + } + end + + it "does not update accepts_target_wallet" do + charge.update!(accepts_target_wallet: true) + + expect { subject }.not_to change { charge.reload.accepts_target_wallet } + end + end + end + + context "when events_targeting_wallets is not enabled" do + it "does not update accepts_target_wallet" do + expect { subject }.not_to change { charge.reload.accepts_target_wallet } + end + end + end + end + + context "with code in the params" do + let(:params) do + { + id: charge.id, + charge_model: "standard", + code: "updated_code", + properties: {amount: "400"} + } + end + + it "updates charge code" do + expect { subject }.to change { charge.reload.code }.to("updated_code") + end + + context "when plan is attached to subscriptions" do + before { create(:subscription, plan:) } + + it "does not update charge code" do + expect { subject }.not_to change { charge.reload.code } + end + end + end + + context "when cascade is true" do + let(:cascade_options) do + { + cascade: true, + parent_filters: [], + equal_properties: true, + equal_applied_pricing_unit_rate: true + } + end + + it "updates charge properties and filters" do + subject + + expect(charge.reload).to have_attributes(properties: {"amount" => "400"}) + + expect(charge.filters.first).to have_attributes( + invoice_display_name: "Card filter", + properties: {"amount" => "90"} + ) + expect(charge.filters.first.values.first).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: ["card"] + ) + end + + it "updates applied pricing unit's conversion rate" do + expect { subject }.to change(charge.applied_pricing_unit, :conversion_rate).to(2.5) + end + + context "with code in the params" do + let(:params) do + { + id: charge.id, + charge_model: "standard", + code: "new_charge_code", + properties: {amount: "400"} + } + end + + it "updates charge code" do + expect { subject }.to change { charge.reload.code }.to("new_charge_code") + end + end + + context "with presentation_group_keys in the properties" do + let(:presentation_group_keys) do + {presentation_group_keys: [{"value" => "region", "options" => {"display_in_invoice" => true}}]} + end + + it "apply the value to the charge" do + expect { subject }.to change { charge.reload.properties["presentation_group_keys"] } + .from(nil).to([{"value" => "region", "options" => {"display_in_invoice" => true}}]) + end + end + + context "with pricing_group_keys in the properties" do + let(:pricing_group_keys) { {pricing_group_keys: ["cloud"]} } + + it "apply the value to the charge" do + expect { subject }.to change { charge.reload.pricing_group_keys } + .from(nil).to(["cloud"]) + end + end + + context "with charge properties already overridden" do + let(:cascade_options) do + { + cascade: true, + parent_filters: [], + equal_properties: false + } + end + + it "does not update charge properties" do + expect { subject }.not_to change { charge.reload.properties } + end + + context "with presentation_group_keys in the properties" do + let(:presentation_group_keys) do + {presentation_group_keys: [{"value" => "region", "options" => {"display_in_invoice" => true}}]} + end + + it "apply the value to the charge" do + expect { subject }.to change { charge.reload.properties["presentation_group_keys"] } + .from(nil).to([{"value" => "region", "options" => {"display_in_invoice" => true}}]) + end + + context "when charge has a presentation_group_keys" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric_id: sum_billable_metric.id, + amount_currency: "USD", + properties: { + amount: "300", + presentation_group_keys: [{value: "department"}] + } + ) + end + + it "overrides the keys" do + expect { subject }.to change { charge.reload.properties["presentation_group_keys"] } + .from([{"value" => "department"}]) + .to([{"value" => "region", "options" => {"display_in_invoice" => true}}]) + end + end + end + + context "with pricing_group_keys in the properties" do + let(:pricing_group_keys) { {pricing_group_keys: ["cloud"]} } + + it "apply the value to the charge" do + expect { subject }.to change { charge.reload.pricing_group_keys } + .from(nil).to(["cloud"]) + end + + context "when charge has a pricing_group_keys" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric_id: sum_billable_metric.id, + amount_currency: "USD", + properties: { + amount: "300", + pricing_group_keys: ["region"] + } + ) + end + + it "overrides the keys" do + expect { subject }.to change { charge.reload.pricing_group_keys } + .from(["region"]).to(["cloud"]) + end + end + end + + context "with legacy grouped_by in the properties" do + let(:pricing_group_keys) { {grouped_by: ["cloud"]} } + + it "apply the value to the charge" do + expect { subject }.to change { charge.reload.pricing_group_keys } + .from(nil).to(["cloud"]) + end + + context "when charge has a grouped_by" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric_id: sum_billable_metric.id, + amount_currency: "USD", + properties: { + amount: "300", + grouped_by: ["region"] + } + ) + end + + it "overrides the keys" do + expect { subject }.to change { charge.reload.pricing_group_keys } + .from(["region"]).to(["cloud"]) + end + end + end + end + + context "when applied pricing unit params are invalid" do + let(:applied_pricing_unit_params) do + { + conversion_rate: -1 + } + end + + it "fails with a validation error" do + expect(result).to be_failure + expect(result.error.messages).to match(conversion_rate: ["value_is_out_of_range"]) + end + + it "does not update applied pricing unit's conversion rate" do + expect { subject }.not_to change { charge.applied_pricing_unit.reload.conversion_rate } + end + end + end + + context "with cascade_updates" do + let(:cascade_updates) { true } + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric: sum_billable_metric, parent: charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(Charges::UpdateChildrenJob).to receive(:perform_later) + end + + it "triggers cascade update via Charges::UpdateChildrenJob" do + subject + + expect(Charges::UpdateChildrenJob).to have_received(:perform_later).with( + params: hash_including("charge_model", "properties", "filters"), + old_parent_attrs: hash_including("id" => charge.id), + old_parent_filters_attrs: array_including, + old_parent_applied_pricing_unit_attrs: anything + ) + end + + context "when charge has no children" do + before { child_charge.update!(parent_id: nil) } + + it "does not trigger cascade update" do + subject + + expect(Charges::UpdateChildrenJob).not_to have_received(:perform_later) + end + end + end + + context "without cascade_updates when charge has children" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_charge) { create(:standard_charge, plan: child_plan, organization:, billable_metric: sum_billable_metric, parent: charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_charge + allow(Charges::UpdateChildrenJob).to receive(:perform_later) + end + + it "does not trigger cascade update" do + subject + + expect(Charges::UpdateChildrenJob).not_to have_received(:perform_later) + end + end + end + end +end diff --git a/spec/services/charges/validators/graduated_percentage_service_spec.rb b/spec/services/charges/validators/graduated_percentage_service_spec.rb new file mode 100644 index 0000000..eb4a055 --- /dev/null +++ b/spec/services/charges/validators/graduated_percentage_service_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::Validators::GraduatedPercentageService do + subject(:validation_service) { described_class.new(charge:) } + + let(:charge) { build(:graduated_percentage_charge, properties:) } + let(:properties) { {graduated_percentage_ranges: ranges} } + let(:ranges) do + [ + { + from_value: 0, + to_value: 10, + rate: "3", + flat_amount: "0" + }, + { + from_value: 11, + to_value: 20, + rate: "2", + flat_amount: "20" + }, + { + from_value: 21, + to_value: nil, + rate: "1", + flat_amount: "30" + } + ] + end + + describe ".valid?" do + it { expect(validation_service).to be_valid } + + context "when billable metric is latest_agg" do + let(:billable_metric) { create(:latest_billable_metric) } + let(:charge) { build(:graduated_percentage_charge, properties:, billable_metric:) } + let(:properties) do + { + graduated_percentage_ranges: ranges + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:billable_metric) + expect(validation_service.result.error.messages[:billable_metric]).to include("invalid_value") + end + end + + context "with ranges validation" do + let(:ranges) { [] } + + it "ensures the presences of ranges" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_percentage_ranges) + expect(validation_service.result.error.messages[:graduated_percentage_ranges]) + .to include("missing_graduated_percentage_ranges") + end + + context "when ranges does not starts at 0" do + let(:ranges) { [{from_value: -1, to_value: 100}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_percentage_ranges) + expect(validation_service.result.error.messages[:graduated_percentage_ranges]) + .to include("invalid_graduated_percentage_ranges") + end + end + + context "when ranges does not ends at infinity" do + let(:ranges) { [{from_value: 0, to_value: 100}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_percentage_ranges) + expect(validation_service.result.error.messages[:graduated_percentage_ranges]) + .to include("invalid_graduated_percentage_ranges") + end + end + + context "when ranges have holes" do + let(:ranges) do + [ + {from_value: 0, to_value: 100}, + {from_value: 120, to_value: 1000} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_percentage_ranges) + expect(validation_service.result.error.messages[:graduated_percentage_ranges]) + .to include("invalid_graduated_percentage_ranges") + end + end + + context "when ranges are overlapping" do + let(:ranges) do + [ + {from_value: 0, to_value: 100}, + {from_value: 90, to_value: 1000} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_percentage_ranges) + expect(validation_service.result.error.messages[:graduated_percentage_ranges]) + .to include("invalid_graduated_percentage_ranges") + end + end + end + + context "with rate validation" do + let(:ranges) { [{from_value: 0, to_value: nil, rate:, flat_amount: "0"}] } + + context "with no range rate" do + let(:rate) { nil } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:rate) + expect(validation_service.result.error.messages[:rate]).to include("invalid_rate") + end + end + + context "with invalid range rate" do + let(:rate) { "foo" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:rate) + expect(validation_service.result.error.messages[:rate]).to include("invalid_rate") + end + end + + context "with negative range rate" do + let(:rate) { "-2" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:rate) + expect(validation_service.result.error.messages[:rate]).to include("invalid_rate") + end + end + end + + context "with flat amount validation" do + let(:ranges) { [{from_value: 0, to_value: nil, rate: 2, flat_amount:}] } + + context "with no range flat amount" do + let(:flat_amount) { nil } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + context "with invalid range flat amount" do + let(:flat_amount) { "foo" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + context "with negative range flat amount" do + let(:flat_amount) { "-4" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + end + + it_behaves_like "pricing_group_keys property validation" do + let(:properties) do + {"graduated_percentage_ranges" => ranges}.merge(grouping_properties) + end + end + + it_behaves_like "presentation_group_keys property validation" do + let(:properties) do + {"graduated_percentage_ranges" => ranges}.merge(grouping_properties) + end + end + end +end diff --git a/spec/services/charges/validators/graduated_service_spec.rb b/spec/services/charges/validators/graduated_service_spec.rb new file mode 100644 index 0000000..c2d8b87 --- /dev/null +++ b/spec/services/charges/validators/graduated_service_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::Validators::GraduatedService do + subject(:validation_service) { described_class.new(charge:) } + + let(:charge) { build(:graduated_charge, properties:) } + + let(:properties) { {"graduated_ranges" => ranges} } + let(:ranges) do + [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 11, + to_value: 20, + per_unit_amount: "10", + flat_amount: "20" + }, + { + from_value: 21, + to_value: nil, + per_unit_amount: "15", + flat_amount: "30" + } + ] + end + + describe ".valid?" do + it { expect(validation_service).to be_valid } + + context "with empty ranges" do + let(:ranges) { [] } + + it "ensures the presence of ranges" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_ranges) + expect(validation_service.result.error.messages[:graduated_ranges]).to include("missing_graduated_ranges") + end + end + + context "with no ranges" do + let(:ranges) { nil } + + it "ensures the presence of ranges" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_ranges) + expect(validation_service.result.error.messages[:graduated_ranges]).to include("missing_graduated_ranges") + end + end + + context "when ranges does not starts at 0" do + let(:ranges) do + [{from_value: -1, to_value: 100}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_ranges) + expect(validation_service.result.error.messages[:graduated_ranges]).to include("invalid_graduated_ranges") + end + end + + context "when ranges does not ends at infinity" do + let(:ranges) do + [{from_value: 0, to_value: 100}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_ranges) + expect(validation_service.result.error.messages[:graduated_ranges]).to include("invalid_graduated_ranges") + end + end + + context "when ranges have holes" do + let(:ranges) do + [ + {from_value: 0, to_value: 100}, + {from_value: 120, to_value: 100} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_ranges) + expect(validation_service.result.error.messages[:graduated_ranges]).to include("invalid_graduated_ranges") + end + end + + context "when ranges are overlapping" do + let(:ranges) do + [ + {from_value: 0, to_value: 100}, + {from_value: 90, to_value: 100} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:graduated_ranges) + expect(validation_service.result.error.messages[:graduated_ranges]).to include("invalid_graduated_ranges") + end + end + + context "with no range per unit amount" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: nil, flat_amount: "0"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_unit_amount) + expect(validation_service.result.error.messages[:per_unit_amount]).to include("invalid_amount") + end + end + + context "with invalid range per unit amount" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "foo", flat_amount: "0"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_unit_amount) + expect(validation_service.result.error.messages[:per_unit_amount]).to include("invalid_amount") + end + end + + context "with negative range per unit amount" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "-2", flat_amount: 0}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_unit_amount) + expect(validation_service.result.error.messages[:per_unit_amount]).to include("invalid_amount") + end + end + + context "with no range flat amount" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: nil}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + context "with invalid range flat amount" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "foo"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + context "with negative range flat amount" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "-2"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + context "with decimal adjacent ranges" do + let(:ranges) do + [ + {from_value: 0, to_value: 0.1, per_unit_amount: "10", flat_amount: "0"}, + {from_value: 0.1, to_value: 1, per_unit_amount: "5", flat_amount: "0"}, + {from_value: 1, to_value: nil, per_unit_amount: "2", flat_amount: "0"} + ] + end + + it { expect(validation_service).to be_valid } + end + + context "with decimal adjacent ranges that have a gap" do + let(:ranges) do + [ + {from_value: 0, to_value: 0.1, per_unit_amount: "10", flat_amount: "0"}, + {from_value: 0.5, to_value: nil, per_unit_amount: "5", flat_amount: "0"} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error.messages[:graduated_ranges]).to include("invalid_graduated_ranges") + end + end + + it_behaves_like "pricing_group_keys property validation" do + let(:properties) { {"graduated_ranges" => ranges}.merge(grouping_properties) } + end + + it_behaves_like "presentation_group_keys property validation" do + let(:properties) { {"graduated_ranges" => ranges}.merge(grouping_properties) } + end + end +end diff --git a/spec/services/charges/validators/package_service_spec.rb b/spec/services/charges/validators/package_service_spec.rb new file mode 100644 index 0000000..207d4c1 --- /dev/null +++ b/spec/services/charges/validators/package_service_spec.rb @@ -0,0 +1,208 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::Validators::PackageService do + subject(:validation_service) { described_class.new(charge:) } + + let(:charge) { build(:package_charge, properties: package_properties) } + + let(:package_properties) do + { + package_size: 10, + free_units: 10, + amount: "100" + } + end + + describe ".valid?" do + it { expect(validation_service).to be_valid } + + context "without amount" do + let(:package_properties) do + { + package_size: 10, + free_units: 10 + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:amount) + expect(validation_service.result.error.messages[:amount]).to include("invalid_amount") + end + end + + context "when amount is not numeric" do + let(:package_properties) do + { + package_size: 10, + free_units: 10, + amount: "foo" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:amount) + expect(validation_service.result.error.messages[:amount]).to include("invalid_amount") + end + end + + context "with negative amount" do + let(:package_properties) do + { + package_size: 10, + free_units: 10, + amount: "-3" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:amount) + expect(validation_service.result.error.messages[:amount]).to include("invalid_amount") + end + end + + context "without package size" do + let(:package_properties) do + { + free_units: 10, + amount: "100" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:package_size) + expect(validation_service.result.error.messages[:package_size]).to include("invalid_package_size") + end + end + + context "when package size is not numeric" do + let(:package_properties) do + { + package_size: "foo", + free_units: 10, + amount: "100" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:package_size) + expect(validation_service.result.error.messages[:package_size]).to include("invalid_package_size") + end + end + + context "with negative package size" do + let(:package_properties) do + { + package_size: -3, + free_units: 10, + amount: "100" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:package_size) + expect(validation_service.result.error.messages[:package_size]).to include("invalid_package_size") + end + end + + context "with zero package size" do + let(:package_properties) do + { + package_size: 0, + free_units: 10, + amount: "100" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:package_size) + expect(validation_service.result.error.messages[:package_size]).to include("invalid_package_size") + end + end + + context "without free units size" do + let(:package_properties) do + { + package_size: 10, + amount: "100" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:free_units) + expect(validation_service.result.error.messages[:free_units]).to include("invalid_free_units") + end + end + + context "when free units are not numeric" do + let(:package_properties) do + { + package_size: 10, + free_units: "foo", + amount: "100" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:free_units) + expect(validation_service.result.error.messages[:free_units]).to include("invalid_free_units") + end + end + + context "with negative free units" do + let(:package_properties) do + { + package_size: 10, + free_units: -3, + amount: "100" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:free_units) + expect(validation_service.result.error.messages[:free_units]).to include("invalid_free_units") + end + end + + it_behaves_like "pricing_group_keys property validation" do + let(:package_properties) do + { + package_size: 10, + free_units: 10, + amount: "100" + }.merge(grouping_properties) + end + end + + it_behaves_like "presentation_group_keys property validation" do + let(:package_properties) do + { + package_size: 10, + free_units: 10, + amount: "100" + }.merge(grouping_properties) + end + end + end +end diff --git a/spec/services/charges/validators/percentage_service_spec.rb b/spec/services/charges/validators/percentage_service_spec.rb new file mode 100644 index 0000000..75bc6f4 --- /dev/null +++ b/spec/services/charges/validators/percentage_service_spec.rb @@ -0,0 +1,353 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::Validators::PercentageService do + subject(:validation_service) { described_class.new(charge:) } + + let(:charge) { build(:percentage_charge, properties: percentage_properties) } + + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "2" + } + end + + describe ".valid?" do + it { expect(validation_service).to be_valid } + + context "when billable metric is latest_agg" do + let(:billable_metric) { create(:latest_billable_metric) } + let(:charge) { build(:percentage_charge, properties: percentage_properties, billable_metric:) } + let(:percentage_properties) do + { + rate: 0.25, + fixed_amount: "2" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:billable_metric) + expect(validation_service.result.error.messages[:billable_metric]).to include("invalid_value") + end + end + + context "without rate" do + let(:percentage_properties) do + { + fixed_amount: "2" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:rate) + expect(validation_service.result.error.messages[:rate]).to include("invalid_rate") + end + end + + context "when given rate is not string" do + let(:percentage_properties) do + { + rate: 0.25, + fixed_amount: "2" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:rate) + expect(validation_service.result.error.messages[:rate]).to include("invalid_rate") + end + end + + context "when rate cannot be converted to numeric format" do + let(:percentage_properties) do + { + rate: "bla", + fixed_amount: "2" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:rate) + expect(validation_service.result.error.messages[:rate]).to include("invalid_rate") + end + end + + context "with negative rate" do + let(:percentage_properties) do + { + rate: "-0.50", + fixed_amount: "2" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:rate) + expect(validation_service.result.error.messages[:rate]).to include("invalid_rate") + end + end + + context "when rate is zero" do + let(:percentage_properties) do + {rate: "0.00", fixed_amount: "2"} + end + + it { expect(validation_service).to be_valid } + end + + context "when free_units_per_events is not an integer" do + let(:percentage_properties) do + { + rate: "0.25", + free_units_per_events: "foo" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:free_units_per_events) + expect(validation_service.result.error.messages[:free_units_per_events]) + .to include("invalid_free_units_per_events") + end + end + + context "when free_units_per_events is negative amount" do + let(:percentage_properties) do + { + rate: "0.25", + free_units_per_events: -1 + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:free_units_per_events) + expect(validation_service.result.error.messages[:free_units_per_events]) + .to include("invalid_free_units_per_events") + end + end + + context "when fixed amount and free_units_per_total_aggregation cannot be converted to numeric" do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "bla", + free_units_per_total_aggregation: "bla" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to eq( + [ + :fixed_amount, + :free_units_per_total_aggregation + ] + ) + expect(validation_service.result.error.messages[:fixed_amount]) + .to include("invalid_fixed_amount") + expect(validation_service.result.error.messages[:free_units_per_total_aggregation]) + .to include("invalid_free_units_per_total_aggregation") + end + end + + context "when given fixed amount and free_units_per_total_aggregation are not string" do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: 2, + free_units_per_total_aggregation: 1 + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to eq( + [ + :fixed_amount, + :free_units_per_total_aggregation + ] + ) + expect(validation_service.result.error.messages[:fixed_amount]) + .to include("invalid_fixed_amount") + expect(validation_service.result.error.messages[:free_units_per_total_aggregation]) + .to include("invalid_free_units_per_total_aggregation") + end + end + + context "when given fixed amount is not string" do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: 2 + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:fixed_amount) + expect(validation_service.result.error.messages[:fixed_amount]).to include("invalid_fixed_amount") + end + end + + context "with negative fixed amount, free_units_per_events and free_units_per_total_aggregation" do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "-2", + free_units_per_events: "-1", + free_units_per_total_aggregation: "-1" + } + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to eq( + [ + :fixed_amount, + :free_units_per_events, + :free_units_per_total_aggregation + ] + ) + expect(validation_service.result.error.messages[:fixed_amount]) + .to include("invalid_fixed_amount") + expect(validation_service.result.error.messages[:free_units_per_events]) + .to include("invalid_free_units_per_events") + expect(validation_service.result.error.messages[:free_units_per_total_aggregation]) + .to include("invalid_free_units_per_total_aggregation") + end + end + + context "without fixed_amount, free_units_per_events and free_units_per_total_aggregation" do + let(:percentage_properties) do + { + rate: "0.25" + } + end + + it { expect(validation_service).to be_valid } + end + + context "with per_transaction_min_amount", :premium do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "2", + per_transaction_min_amount: + } + end + + context "when it is not a string" do + let(:per_transaction_min_amount) { 2 } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_transaction_min_amount) + expect(validation_service.result.error.messages[:per_transaction_min_amount]) + .to include("invalid_amount") + end + end + + context "when it is negative" do + let(:per_transaction_min_amount) { "-3" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_transaction_min_amount) + expect(validation_service.result.error.messages[:per_transaction_min_amount]) + .to include("invalid_amount") + end + end + end + + context "with per_transaction_max_amount", :premium do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "2", + per_transaction_max_amount: + } + end + + context "when it is not a string" do + let(:per_transaction_max_amount) { 2 } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_transaction_max_amount) + expect(validation_service.result.error.messages[:per_transaction_max_amount]) + .to include("invalid_amount") + end + end + + context "when it is negative" do + let(:per_transaction_max_amount) { "-3" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_transaction_max_amount) + expect(validation_service.result.error.messages[:per_transaction_max_amount]) + .to include("invalid_amount") + end + end + end + + context "with both per_transaction_min_amount and per_transaction_max_amount", :premium do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "2", + per_transaction_min_amount: "3", + per_transaction_max_amount: "2" + } + end + + it "ensures that max is higher than min" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_transaction_max_amount) + expect(validation_service.result.error.messages[:per_transaction_max_amount]) + .to include("per_transaction_max_lower_than_per_transaction_min") + end + end + + it_behaves_like "pricing_group_keys property validation" do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "2" + }.merge(grouping_properties) + end + end + + it_behaves_like "presentation_group_keys property validation" do + let(:percentage_properties) do + { + rate: "0.25", + fixed_amount: "2" + }.merge(grouping_properties) + end + end + end +end diff --git a/spec/services/charges/validators/standard_service_spec.rb b/spec/services/charges/validators/standard_service_spec.rb new file mode 100644 index 0000000..d689a24 --- /dev/null +++ b/spec/services/charges/validators/standard_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::Validators::StandardService do + subject(:validation_service) { described_class.new(charge:) } + + let(:charge) { build(:standard_charge, properties:) } + let(:properties) { {} } + + describe ".valid?" do + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:amount) + expect(validation_service.result.error.messages[:amount]).to include("invalid_amount") + end + + context "when amount is not an integer" do + let(:properties) { {amount: "Foo"} } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:amount) + expect(validation_service.result.error.messages[:amount]).to include("invalid_amount") + end + end + + context "when amount is negative" do + let(:properties) { {amount: "-12"} } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:amount) + expect(validation_service.result.error.messages[:amount]).to include("invalid_amount") + end + end + + context "with an applicable amount" do + let(:properties) { {amount: "12"} } + + it { expect(validation_service).to be_valid } + end + + it_behaves_like "pricing_group_keys property validation" do + let(:properties) { {"amount" => "12"}.merge(grouping_properties) } + end + + it_behaves_like "presentation_group_keys property validation" do + let(:properties) { {"amount" => "12"}.merge(grouping_properties) } + end + end +end diff --git a/spec/services/charges/validators/volume_service_spec.rb b/spec/services/charges/validators/volume_service_spec.rb new file mode 100644 index 0000000..4646b2d --- /dev/null +++ b/spec/services/charges/validators/volume_service_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Charges::Validators::VolumeService do + subject(:validation_service) { described_class.new(charge:) } + + let(:charge) { build(:volume_charge, properties:) } + + let(:properties) { {volume_ranges: ranges} } + let(:ranges) do + [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "0", + flat_amount: "0" + }, + { + from_value: 11, + to_value: 20, + per_unit_amount: "10", + flat_amount: "20" + }, + { + from_value: 21, + to_value: nil, + per_unit_amount: "15", + flat_amount: "30" + } + ] + end + + describe ".validate" do + it { expect(validation_service).to be_valid } + + context "with empty ranges" do + let(:ranges) { [] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:volume_ranges) + expect(validation_service.result.error.messages[:volume_ranges]).to include("missing_volume_ranges") + end + end + + context "when ranges does not starts at 0" do + let(:ranges) do + [{from_value: -1, to_value: 100}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:volume_ranges) + expect(validation_service.result.error.messages[:volume_ranges]).to include("invalid_volume_ranges") + end + end + + context "when ranges does not ends at infinity" do + let(:ranges) do + [{from_value: 0, to_value: 100}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:volume_ranges) + expect(validation_service.result.error.messages[:volume_ranges]).to include("invalid_volume_ranges") + end + end + + context "when ranges have holes" do + let(:ranges) do + [ + {from_value: 0, to_value: 100}, + {from_value: 120, to_value: 100} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:volume_ranges) + expect(validation_service.result.error.messages[:volume_ranges]).to include("invalid_volume_ranges") + end + end + + context "when ranges are overlapping" do + let(:ranges) do + [ + {from_value: 0, to_value: 100}, + {from_value: 90, to_value: 100} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:volume_ranges) + expect(validation_service.result.error.messages[:volume_ranges]).to include("invalid_volume_ranges") + end + end + + context "with no range per unit amount cents" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: nil, flat_amount: "0"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_unit_amount) + expect(validation_service.result.error.messages[:per_unit_amount]).to include("invalid_amount") + end + end + + context "with invalid range per unit amount cents" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "foo", flat_amount: "0"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_unit_amount) + expect(validation_service.result.error.messages[:per_unit_amount]).to include("invalid_amount") + end + end + + context "with negative range per unit amount cents" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "-2", flat_amount: 0}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:per_unit_amount) + expect(validation_service.result.error.messages[:per_unit_amount]).to include("invalid_amount") + end + end + + context "with no range flat amount cents" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: nil}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + context "with invalid range flat amount cents" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "foo"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + context "with negative range flat amount cents" do + let(:ranges) do + [{from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "-2"}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:flat_amount) + expect(validation_service.result.error.messages[:flat_amount]).to include("invalid_amount") + end + end + + it_behaves_like "pricing_group_keys property validation" do + let(:properties) { {"volume_ranges" => ranges}.merge(grouping_properties) } + end + + it_behaves_like "presentation_group_keys property validation" do + let(:properties) { {"volume_ranges" => ranges}.merge(grouping_properties) } + end + end +end diff --git a/spec/services/commitments/apply_taxes_service_spec.rb b/spec/services/commitments/apply_taxes_service_spec.rb new file mode 100644 index 0000000..c6ee565 --- /dev/null +++ b/spec/services/commitments/apply_taxes_service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitments::ApplyTaxesService do + subject(:apply_service) { described_class.new(commitment:, tax_codes:) } + + let(:commitment) { create(:commitment, plan:) } + let(:plan) { create(:plan, organization:) } + let(:organization) { create(:organization) } + let(:tax1) { create(:tax, organization:, code: "tax1") } + let(:tax2) { create(:tax, organization:, code: "tax2") } + let(:tax_codes) { [tax1.code, tax2.code] } + + describe "call" do + it "applies taxes to the commitment" do + expect { apply_service.call }.to change { commitment.applied_taxes.count }.from(0).to(2) + end + + it "returns applied taxes" do + result = apply_service.call + expect(result.applied_taxes.count).to eq(2) + end + + context "when commitment is not found" do + let(:commitment) { nil } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("commitment_not_found") + end + end + + context "when tax is not found" do + let(:tax_codes) { ["unknown"] } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when applied tax is already present" do + it "does not create a new applied tax" do + create(:commitment_applied_tax, commitment:, tax: tax1) + expect { apply_service.call }.to change { commitment.applied_taxes.count }.from(1).to(2) + end + end + + context "when trying to apply twice the same tax" do + let(:tax_codes) { [tax1.code, tax1.code] } + + it "assigns it only once" do + expect { apply_service.call }.to change { commitment.applied_taxes.count }.from(0).to(1) + end + end + end +end diff --git a/spec/services/commitments/calculate_amount_service_spec.rb b/spec/services/commitments/calculate_amount_service_spec.rb new file mode 100644 index 0000000..f07d2f2 --- /dev/null +++ b/spec/services/commitments/calculate_amount_service_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitments::CalculateAmountService do + subject(:apply_service) { described_class.new(commitment:, invoice_subscription:) } + + let(:invoice_subscription) do + create(:invoice_subscription, subscription:, from_datetime:, to_datetime:, timestamp:) + end + + let(:subscription) { create(:subscription, customer:, plan:, billing_time:, subscription_at:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:, interval:) } + let(:billing_time) { :calendar } + + describe "call" do + context "when plan has weekly interval" do + let(:amount_cents) { 3_000 } + let(:interval) { :weekly } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:subscription_at) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-07T23:59:59") } + let(:timestamp) { DateTime.parse("2024-01-08T10:00:00") } + + context "when subscription is calendar" do + let(:billing_time) { :calendar } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + before { commitment } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(commitment.amount_cents) + end + end + end + + context "when subscription is anniversary" do + let(:billing_time) { :anniversary } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + context "when it is full period" do + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(commitment.amount_cents) + end + end + + context "when it is not full period" do + let(:to_datetime) { DateTime.parse("2024-01-06T23:59:59") } + let(:timestamp) { DateTime.parse("2024-01-07T10:00:00") } + + it "returns prorated result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(2_571) + end + end + end + end + end + + context "when plan has monthly interval" do + let(:amount_cents) { 20_000 } + let(:interval) { :monthly } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:subscription_at) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-31T23:59:59") } + let(:timestamp) { DateTime.parse("2024-02-05T10:00:00") } + + context "when subscription is calendar" do + let(:billing_time) { :calendar } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(commitment.amount_cents) + end + end + end + + context "when subscription is anniversary" do + let(:billing_time) { :anniversary } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + context "when it is full period" do + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(commitment.amount_cents) + end + end + + context "when it is not full period" do + let(:to_datetime) { DateTime.parse("2024-01-30T23:59:59") } + let(:timestamp) { DateTime.parse("2024-02-04T10:00:00") } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(19_355) + end + end + end + end + end + + context "when plan has quarterly interval" do + let(:amount_cents) { 40_000 } + let(:interval) { :quarterly } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:subscription_at) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-03-31T23:59:59") } + let(:timestamp) { DateTime.parse("2024-04-05T10:00:00") } + + context "when subscription is calendar" do + let(:billing_time) { :calendar } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + before { commitment } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(commitment.amount_cents) + end + end + end + + context "when subscription is anniversary" do + let(:billing_time) { :anniversary } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + context "when it is full period" do + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(commitment.amount_cents) + end + end + + context "when it is not full period" do + let(:to_datetime) { DateTime.parse("2024-03-30T23:59:59") } + let(:timestamp) { DateTime.parse("2024-04-04T10:00:00") } + + it "returns prorated result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(39_560) + end + end + end + end + end + + context "when plan has yearly interval" do + let(:amount_cents) { 200_000 } + let(:interval) { :yearly } + let(:from_datetime) { DateTime.parse("2024-01-15T00:00:00") } + let(:subscription_at) { DateTime.parse("2024-01-15T00:00:00") } + + context "when subscription is calendar" do + let(:billing_time) { :calendar } + let(:to_datetime) { DateTime.parse("2024-12-31T23:59:59") } + let(:timestamp) { DateTime.parse("2025-01-05T10:00:00") } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + before { commitment } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(192_350) + end + end + end + + context "when subscription is anniversary" do + let(:billing_time) { :anniversary } + let(:to_datetime) { DateTime.parse("2025-01-14T23:59:59") } + let(:timestamp) { DateTime.parse("2025-01-19T10:00:00") } + + context "when there is no commitment" do + let(:commitment) { nil } + + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(0) + end + end + + context "when a commitment exists for a plan" do + let(:commitment) { create(:commitment, plan:, amount_cents:) } + + context "when it is full period" do + it "returns result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(commitment.amount_cents) + end + end + + context "when it is not full period" do + let(:to_datetime) { DateTime.parse("2025-01-13T23:59:59") } + let(:timestamp) { DateTime.parse("2025-01-18T10:00:00") } + + it "returns prorated result" do + result = apply_service.call + + expect(result.commitment_amount_cents).to eq(199_454) + end + end + end + end + end + end +end diff --git a/spec/services/commitments/calculate_prorated_coefficient_service_spec.rb b/spec/services/commitments/calculate_prorated_coefficient_service_spec.rb new file mode 100644 index 0000000..d61ca86 --- /dev/null +++ b/spec/services/commitments/calculate_prorated_coefficient_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitments::CalculateProratedCoefficientService do + let(:service) { described_class.new(commitment:, invoice_subscription:) } + let(:commitment) { create(:commitment, plan:) } + let(:plan) { create(:plan, organization:) } + let(:organization) { create(:organization) } + let(:subscription) { create(:subscription, customer:, plan:, started_at:) } + let(:customer) { create(:customer, organization:) } + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime:, + charges_from_datetime:, + charges_to_datetime:, + timestamp: + ) + end + + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:started_at) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-31T23:59:59") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-01-31T23:59:59") } + let(:timestamp) { DateTime.parse("2024-02-01T10:00:00") } + + describe "#proration_coefficient" do + subject(:apply_service) { service.proration_coefficient } + + context "with whole period" do + it "returns proration coefficient" do + expect(apply_service.proration_coefficient).to eq(1.0) + end + end + + context "with partial period" do + let(:from_datetime) { DateTime.parse("2024-01-15T00:00:00") } + + it "returns proration coefficient" do + expect(apply_service.proration_coefficient).to eq(0.5483870967741935) + end + end + + context "when subscription is terminated" do + let(:from_datetime) { DateTime.current.beginning_of_day } + let(:started_at) { DateTime.current } + let(:to_datetime) { nil } + let(:days_in_month) { Date.current.end_of_month.day } + + before do + Subscriptions::TerminateService.call(subscription:, async: false) + end + + it "returns proration coefficient" do + invoice_subscription = subscription.invoice_subscriptions.reload.last + apply_service = described_class.new(commitment:, invoice_subscription:).proration_coefficient + + expect(apply_service.proration_coefficient).to eq(1 / days_in_month.to_f) + end + end + end +end diff --git a/spec/services/commitments/dates_service_spec.rb b/spec/services/commitments/dates_service_spec.rb new file mode 100644 index 0000000..56ef239 --- /dev/null +++ b/spec/services/commitments/dates_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitments::DatesService do + let(:commitment) { create(:commitment) } + let(:invoice_subscription) { create(:invoice_subscription, subscription:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:, pay_in_advance:) } + + describe ".new_instance" do + subject(:new_instance_call) { described_class.new_instance(commitment:, invoice_subscription:) } + + context "when plan is paid in arrears" do + let(:pay_in_advance) { false } + + it "returns a dates service object" do + expect(new_instance_call).to be_a Commitments::Minimum::InArrears::DatesService + end + end + + context "when plan is paid in advance" do + let(:pay_in_advance) { true } + + it "returns a dates service object" do + expect(new_instance_call).to be_a Commitments::Minimum::InAdvance::DatesService + end + end + end + + describe "#call" do + subject(:service) { described_class.new_instance(commitment:, invoice_subscription:) } + + let(:pay_in_advance) { [false, true].sample } + let(:terminated_service) { instance_double("Subscriptions::TerminatedDatesService") } + + before do + allow(Subscriptions::TerminatedDatesService).to receive(:new).and_return(terminated_service) + allow(terminated_service).to receive(:call).and_return(nil) + + service.call + end + + context "when subscription is terminated" do + let(:subscription) { create(:subscription, :terminated, customer:, plan:) } + + it "calls terminated dates service" do + expect(Subscriptions::TerminatedDatesService).to have_received(:new) + end + end + + context "when subscription is not terminated" do + it "does not call terminated dates service" do + expect(Subscriptions::TerminatedDatesService).not_to have_received(:new) + end + end + end +end diff --git a/spec/services/commitments/minimum/calculate_true_up_fee_service_spec.rb b/spec/services/commitments/minimum/calculate_true_up_fee_service_spec.rb new file mode 100644 index 0000000..fe9e4a7 --- /dev/null +++ b/spec/services/commitments/minimum/calculate_true_up_fee_service_spec.rb @@ -0,0 +1,4508 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitments::Minimum::CalculateTrueUpFeeService do + subject(:service) { described_class.new_instance(invoice_subscription:) } + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime:, + charges_from_datetime:, + charges_to_datetime:, + fixed_charges_from_datetime:, + fixed_charges_to_datetime:, + timestamp: + ) + end + + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2025-01-01T10:00:00") } + let(:subscription) { create(:subscription, customer:, plan:, billing_time:, subscription_at:) } + let(:customer) { create(:customer, organization:) } + let(:subscription_at) { DateTime.parse("2024-01-01T00:00:00") } + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:, pay_in_advance:, interval:, bill_charges_monthly:, bill_fixed_charges_monthly:) } + let(:billing_time) { :calendar } + let(:bill_charges_monthly) { false } + let(:bill_fixed_charges_monthly) { false } + let(:pay_in_advance) { false } + let(:interval) { :yearly } + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: false) } + let(:fixed_charge_pay_in_advance) { create(:fixed_charge, :pay_in_advance, plan:) } + + describe "#call" do + subject(:service_call) { service.call } + + context "when plan is paid in arrears" do + let(:pay_in_advance) { false } + + context "when plan has no minimum commitment" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when plan has minimum commitment" do + let(:commitment) { create(:commitment, plan:, amount_cents: commitment_amount_cents) } + let(:commitment_amount_cents) { 200 } + + before { commitment } + + context "when there are no fees" do + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(commitment_amount_cents) + end + end + + context "when there are subscription fees" do + let(:charge) { create(:standard_charge) } + + before do + create( + :fee, + subscription: invoice_subscription.subscription, + invoice: invoice_subscription.invoice, + amount_cents: 200 + ) + + create( + :charge_fee, + subscription: invoice_subscription.subscription, + invoice: invoice_subscription.invoice, + charge:, + amount_cents: 300, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + + create( + :fixed_charge_fee, + subscription: invoice_subscription.subscription, + invoice: invoice_subscription.invoice, + fixed_charge:, + amount_cents: 150, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + context "when subscription is anniversary" do + let(:billing_time) { :anniversary } + + context "when plan has yearly interval" do + let(:interval) { :yearly } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2025-01-01T10:00:00") } + + context "when charges and fixed charges are billed yearly" do + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when charges are billed monthly" do + let(:bill_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + let(:invoice_subscription_previous) do + create( + :invoice_subscription, + subscription:, + from_datetime: DateTime.parse("2024-01-01T00:00:00"), + to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-12-31T23:59:59.999"), + timestamp: DateTime.parse("2024-02-01T10:00:00") + ) + end + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + + context "when fixed charges are billed monthly" do + let(:bill_fixed_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when plan has semiannual interval" do + let(:interval) { :semiannual } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-06-30T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-06-30T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-06-30T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-07-01T10:00:00") } + + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when charges are billed monthly" do + let(:bill_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + let(:invoice_subscription_previous) do + create( + :invoice_subscription, + subscription:, + from_datetime: DateTime.parse("2024-01-01T00:00:00"), + to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-06-30T23:59:59.999"), + timestamp: DateTime.parse("2024-07-01T10:00:00") + ) + end + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: invoice_subscription_previous.invoice, + subscription: invoice_subscription_previous.subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + + context "when fixed charges are billed monthly" do + let(:bill_fixed_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + + context "when plan has quarterly interval" do + let(:interval) { :quarterly } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-03-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-03-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-03-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-04-01T10:00:00") } + + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: DateTime.parse("2024-04-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-06-30T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 3.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 3.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when plan has monthly interval" do + let(:interval) { :monthly } + let(:from_datetime) { DateTime.parse("2024-02-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-02-29T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-02-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-02-29T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-02-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-02-29T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-03-01T10:00:00") } + + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: DateTime.parse("2024-03-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-03-31T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.month, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.month + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when plan has weekly interval" do + let(:interval) { :weekly } + let(:from_datetime) { DateTime.parse("2024-02-05T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-02-11T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-02-05T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-02-11T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-02-05T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-02-11T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-02-12T10:00:00") } + + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: DateTime.parse("2024-02-12T00:00:00"), + charges_to_datetime: DateTime.parse("2024-02-18T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.week, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.week + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + end + + context "when subscription is calendar" do + let(:billing_time) { :calendar } + + context "when plan has yearly interval" do + let(:interval) { :yearly } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2025-01-01T10:00:00") } + + context "when charges and fixed charges are billed yearly" do + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when charges are billed monthly" do + let(:bill_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + let(:invoice_subscription_previous) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + timestamp: DateTime.parse("2024-02-01T10:00:00") + ) + end + + before do + create( + :fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice + ) + + create( + :charge_fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice, + charge:, + amount_cents: 300, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + context "when subscription starts at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + end + + context "when subscription does not start at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-02T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_323) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_323) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + end + end + + context "when fixed charges are billed monthly" do + let(:bill_fixed_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + let(:invoice_subscription_previous) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + timestamp: DateTime.parse("2024-02-01T10:00:00") + ) + end + + before do + create( + :fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice + ) + + create( + :charge_fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice, + charge:, + amount_cents: 300, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + context "when subscription starts at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + end + + context "when subscription does not start at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-02T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_323) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_323) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_823) + end + end + end + end + end + + context "when plan has semiannual interval" do + let(:interval) { :semiannual } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-06-30T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-06-30T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-06-30T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-07-01T10:00:00") } + + context "when plan is billed semiannually" do + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when charges are billed monthly" do + let(:bill_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + let(:invoice_subscription_previous) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + timestamp: DateTime.parse("2024-02-01T10:00:00") + ) + end + + before do + create( + :fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice + ) + + create( + :charge_fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice, + charge:, + amount_cents: 300, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + context "when subscription starts at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + end + + context "when subscription does not start at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-02T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_295) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_295) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + end + end + + context "when fixed charges are billed monthly" do + let(:bill_fixed_charges_monthly) { true } + let(:commitment_amount_cents) { 10_000 } + + let(:invoice_subscription_previous) do + create( + :invoice_subscription, + subscription:, + from_datetime:, + to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2024-01-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-01-31T23:59:59.999"), + timestamp: DateTime.parse("2024-02-01T10:00:00") + ) + end + + before do + create( + :fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice + ) + + create( + :charge_fee, + subscription: invoice_subscription_previous.subscription, + invoice: invoice_subscription_previous.invoice, + charge:, + amount_cents: 300, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + context "when subscription starts at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_350) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + end + + context "when subscription does not start at the beginning of the period" do + let(:from_datetime) { DateTime.parse("2024-01-02T00:00:00") } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 6.months, + charges_to_datetime: charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_295) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 6.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 6.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_295) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_795) + end + end + end + end + end + + context "when plan has quarterly interval" do + let(:interval) { :quarterly } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-03-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-03-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-03-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-04-01T10:00:00") } + + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: DateTime.parse("2024-04-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-06-30T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 3.months, + fixed_charges_to_datetime: fixed_charges_to_datetime + 3.months + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when plan has monthly interval" do + let(:interval) { :monthly } + let(:from_datetime) { DateTime.parse("2024-02-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-02-29T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-02-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-02-29T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-02-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-02-29T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-03-01T10:00:00") } + + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: DateTime.parse("2024-03-01T00:00:00"), + charges_to_datetime: DateTime.parse("2024-03-31T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: DateTime.parse("2024-03-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-03-31T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when plan has weekly interval" do + let(:interval) { :weekly } + let(:from_datetime) { DateTime.parse("2024-02-05T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-02-11T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-02-05T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-02-11T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-02-05T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-02-11T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-02-12T10:00:00") } + + context "when fees total amount is greater or equal than the commitment amount" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + let(:commitment_amount_cents) { 10_000 } + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime: DateTime.parse("2024-02-12T00:00:00"), + charges_to_datetime: DateTime.parse("2024-02-18T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for current period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500, + properties: { + charges_from_datetime:, + charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: DateTime.parse("2024-02-12T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-02-18T23:59:59.999") + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for current period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime:, + fixed_charges_to_datetime: + } + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "returns result with amount cents" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + end + end + end + end + + context "when plan is paid in advance" do + let(:pay_in_advance) { true } + + context "when plan has no minimum commitment" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when plan has minimum commitment" do + let(:commitment) { create(:commitment, plan:, amount_cents: commitment_amount_cents) } + let(:commitment_amount_cents) { 10_000 } + + before { commitment } + + context "when there is no previous invoice subscription" do + it "returns result with zero amount cents" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "when subscription is anniversary" do + let(:billing_time) { :anniversary } + + context "when plan has yearly interval" do + let(:interval) { :yearly } + let(:subscription_at) { DateTime.parse("2023-01-01T00:00:00") } + + # Current invoice subscription (2nd year) + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2025-01-01T10:00:00") } + + # Previous invoice subscription (1st year) - fees from this period are counted for the true-up + # For pay-in-advance, this invoice was generated at the START of the period (2023-01-01) + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + from_datetime: DateTime.parse("2023-01-01T00:00:00"), + to_datetime: DateTime.parse("2023-12-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2023-01-01T00:00:00"), + charges_to_datetime: DateTime.parse("2023-12-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2023-01-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2023-12-31T23:59:59.999"), + timestamp: DateTime.parse("2023-01-01T10:00:00") + ) + end + + context "when charges and fixed charges are billed yearly" do + # For pay-in-advance plans: + # - Subscription fee is on the PREVIOUS invoice (paid at start of period) + # - Charge/fixed charge fees (in arrears) are on the CURRENT invoice + # (billed when the next period starts) + before do + # Subscription fee on previous invoice - this links the previous period + create( + :fee, + subscription: previous_invoice_subscription.subscription, + invoice: previous_invoice_subscription.invoice, + amount_cents: 200, + properties: { + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime + } + ) + end + + context "when fees total amount is greater or equal than the commitment amount" do + before do + # Charge fee for previous period - on CURRENT invoice (billed in arrears) + create( + :charge_fee, + subscription:, + invoice: invoice_subscription.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 5000, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + # Fixed charge fee for previous period - on CURRENT invoice (billed in arrears) + create( + :fixed_charge_fee, + subscription:, + invoice: invoice_subscription.invoice, + fixed_charge:, + amount_cents: 4900, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "returns result with zero amount cents" do + # Total fees: 200 (subscription) + 5000 (charge) + 4900 (fixed_charge) = 11_000 + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + before do + # Charge fee for previous period - on CURRENT invoice (billed in arrears) + create( + :charge_fee, + subscription:, + invoice: invoice_subscription.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 300, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + # Fixed charge fee for previous period - on CURRENT invoice (billed in arrears) + create( + :fixed_charge_fee, + subscription:, + invoice: invoice_subscription.invoice, + fixed_charge:, + amount_cents: 150, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + end + + # Total fees: 200 (subscription) + 300 (charge) + 150 (fixed_charge) = 650 + # commitment: 10_000, true-up: 10_000 - 650 = 9_350 + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance, plan:), + amount_cents: 500, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance charge for the previous period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance, plan:), + amount_cents: 500, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + end + + it "counts it" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance charge from another period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance, plan:), + amount_cents: 500 + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(9_350) + end + end + + context "with an in-advance fixed charge for the previous period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "counts it" do + expect(service_call.amount_cents).to eq(8_850) + end + end + + context "with an in-advance fixed charge from another period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 500 + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(9_350) + end + end + end + end + + context "when charges are billed monthly" do + let(:bill_charges_monthly) { true } + + # For monthly charges on yearly plan: + # - Subscription fee is on the yearly invoice (previous_invoice_subscription) + # - Charge fees are on monthly invoices throughout the year + # + # Since bill_charges_monthly: true, the FetchInvoicesService queries by + # charges_from_datetime/charges_to_datetime, which are monthly, + # so it returns the monthly invoices. + + # Create 12 monthly invoice_subscriptions for charges + let(:monthly_invoice_subscriptions) do + (1..12).map do |month| + start_date = DateTime.parse("2023-#{month.to_s.rjust(2, "0")}-01T00:00:00") + end_date = start_date.end_of_month.change(hour: 23, min: 59, sec: 59) + + create( + :invoice_subscription, + subscription:, + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime, + charges_from_datetime: start_date, + charges_to_datetime: end_date, + fixed_charges_from_datetime: previous_invoice_subscription.from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.to_datetime, + timestamp: (start_date + 1.month).change(hour: 10) + ) + end + end + + before do + # Subscription fee on previous yearly invoice + create( + :fee, + subscription: previous_invoice_subscription.subscription, + invoice: previous_invoice_subscription.invoice, + amount_cents: 200, + properties: { + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime + } + ) + end + + context "when fees total amount is greater or equal than the commitment amount" do + before do + # Create charge fees on each monthly invoice (total: 12 * 900 = 10800) + monthly_invoice_subscriptions.each do |monthly_is| + create( + :charge_fee, + subscription:, + invoice: monthly_is.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 900, + properties: { + charges_from_datetime: monthly_is.charges_from_datetime, + charges_to_datetime: monthly_is.charges_to_datetime + } + ) + end + end + + it "returns result with zero amount cents" do + # Total fees: 200 (subscription) + 10800 (charges) = 11000 + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + before do + # Create charge fees on each monthly invoice (total: 12 * 50 = 600) + monthly_invoice_subscriptions.each do |monthly_is| + create( + :charge_fee, + subscription:, + invoice: monthly_is.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 50, + properties: { + charges_from_datetime: monthly_is.charges_from_datetime, + charges_to_datetime: monthly_is.charges_to_datetime + } + ) + end + end + + # Total fees: 200 (subscription) + 600 (charges) = 800 + # commitment: 10_000, true-up: 10_000 - 800 = 9_200 + + it "returns true-up amount" do + expect(service_call.amount_cents).to eq(9_200) + end + end + end + + context "when fixed charges are billed monthly" do + let(:bill_fixed_charges_monthly) { true } + + # For monthly fixed charges on yearly plan (bill_charges_monthly: false): + # - Subscription fee is on the yearly invoice (previous_invoice_subscription) + # - Charge fees are billed yearly (on the current invoice) + # - Fixed charge fees are on monthly invoices throughout the year + # + # The invoice_subscriptions have: + # - charges_from_datetime/charges_to_datetime = YEARLY (full year) + # - fixed_charges_from_datetime/fixed_charges_to_datetime = MONTHLY (each month) + # + # Since bill_fixed_charges_monthly: true, the FetchInvoicesService queries by + # fixed_charges_from_datetime/fixed_charges_to_datetime which are monthly, + # so it finds all 12 monthly invoices. + + # Create 12 monthly invoice_subscriptions for fixed charges + # These have YEARLY charges dates but MONTHLY fixed_charges dates + let(:monthly_invoice_subscriptions) do + (1..12).map do |month| + fixed_start = DateTime.parse("2023-#{month.to_s.rjust(2, "0")}-01T00:00:00") + fixed_end = fixed_start.end_of_month.change(hour: 23, min: 59, sec: 59, usec: 999999) + + create( + :invoice_subscription, + subscription:, + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime, + # Charges dates are YEARLY (same as the commitment period) + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime, + # Fixed charges dates are MONTHLY + fixed_charges_from_datetime: fixed_start, + fixed_charges_to_datetime: fixed_end, + timestamp: (fixed_start + 1.month).change(hour: 10) + ) + end + end + + before do + # Subscription fee on previous yearly invoice + create( + :fee, + subscription: previous_invoice_subscription.subscription, + invoice: previous_invoice_subscription.invoice, + amount_cents: 200, + properties: { + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime + } + ) + end + + context "when fees total amount is greater or equal than the commitment amount" do + before do + # Charge fee on current invoice (billed yearly in arrears) + create( + :charge_fee, + subscription:, + invoice: monthly_invoice_subscriptions.last.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 5000, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + # Create fixed charge fees on each monthly invoice (total: 12 * 500 = 6000) + monthly_invoice_subscriptions.each do |monthly_is| + create( + :fixed_charge_fee, + subscription:, + invoice: monthly_is.invoice, + fixed_charge:, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: monthly_is.fixed_charges_from_datetime, + fixed_charges_to_datetime: monthly_is.fixed_charges_to_datetime + } + ) + end + end + + it "returns result with zero amount cents" do + # Total fees: 200 (subscription) + 5000 (charge) + 6000 (fixed_charges) = 11200 + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + before do + # Charge fee on the last monthly invoice + create( + :charge_fee, + subscription:, + invoice: monthly_invoice_subscriptions.last.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 300, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + # Create fixed charge fees on each monthly invoice (total: 12 * 30 = 360) + monthly_invoice_subscriptions.each do |monthly_is| + create( + :fixed_charge_fee, + subscription:, + invoice: monthly_is.invoice, + fixed_charge:, + amount_cents: 30, + properties: { + fixed_charges_from_datetime: monthly_is.fixed_charges_from_datetime, + fixed_charges_to_datetime: monthly_is.fixed_charges_to_datetime + } + ) + end + end + + # Total fees: 200 (subscription) + 300 (charge) + 360 (fixed_charges) = 860 + # commitment: 10_000, true-up: 10_000 - 860 = 9_140 + + it "returns true-up amount" do + expect(service_call.amount_cents).to eq(9_140) + end + end + end + + context "when charges and fixed charges are billed monthly" do + let(:bill_charges_monthly) { true } + let(:bill_fixed_charges_monthly) { true } + + # For both monthly on yearly plan: + # - Subscription fee is on the yearly invoice (previous_invoice_subscription) + # - Charge fees are on monthly invoices + # - Fixed charge fees are on monthly invoices + # + # Since bill_charges_monthly: true, the FetchInvoicesService queries by + # charges_from_datetime/charges_to_datetime which are monthly, so it finds + # all 12 monthly invoices. + # + # Since bill_fixed_charges_monthly: true, the FetchInvoicesService queries by + # fixed_charges_from_datetime/fixed_charges_to_datetime which are monthly, + # so it finds all 12 monthly invoices. + # + # Both charge_fees and fixed_charge_fees are counted. + + # Create 12 monthly invoice_subscriptions + let(:monthly_invoice_subscriptions) do + (1..12).map do |month| + start_date = DateTime.parse("2023-#{month.to_s.rjust(2, "0")}-01T00:00:00") + end_date = start_date.end_of_month.change(hour: 23, min: 59, sec: 59, usec: 999999) + + create( + :invoice_subscription, + subscription:, + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime, + charges_from_datetime: start_date, + charges_to_datetime: end_date, + fixed_charges_from_datetime: start_date, + fixed_charges_to_datetime: end_date, + timestamp: (start_date + 1.month).change(hour: 10) + ) + end + end + + before do + # Subscription fee on previous yearly invoice + create( + :fee, + subscription: previous_invoice_subscription.subscription, + invoice: previous_invoice_subscription.invoice, + amount_cents: 200, + properties: { + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime + } + ) + end + + context "when fees total amount is greater or equal than the commitment amount" do + before do + # Create fees on each monthly invoice + monthly_invoice_subscriptions.each do |monthly_is| + create( + :charge_fee, + subscription:, + invoice: monthly_is.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 500, + properties: { + charges_from_datetime: monthly_is.charges_from_datetime, + charges_to_datetime: monthly_is.charges_to_datetime + } + ) + + create( + :fixed_charge_fee, + subscription:, + invoice: monthly_is.invoice, + fixed_charge:, + amount_cents: 400, + properties: { + fixed_charges_from_datetime: monthly_is.fixed_charges_from_datetime, + fixed_charges_to_datetime: monthly_is.fixed_charges_to_datetime + } + ) + end + end + + it "returns result with zero amount cents" do + # Total fees: 200 (subscription) + 6000 (charges) + 4800 (fixed_charges) = 11000 + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the commitment amount" do + before do + # Create fees on each monthly invoice + monthly_invoice_subscriptions.each do |monthly_is| + create( + :charge_fee, + subscription:, + invoice: monthly_is.invoice, + charge: create(:standard_charge, plan:), + amount_cents: 40, + properties: { + charges_from_datetime: monthly_is.charges_from_datetime, + charges_to_datetime: monthly_is.charges_to_datetime + } + ) + + create( + :fixed_charge_fee, + subscription:, + invoice: monthly_is.invoice, + fixed_charge:, + amount_cents: 20, + properties: { + fixed_charges_from_datetime: monthly_is.fixed_charges_from_datetime, + fixed_charges_to_datetime: monthly_is.fixed_charges_to_datetime + } + ) + end + end + + # Total fees: 200 (subscription) + 480 (charges) + 240 (fixed_charges) = 920 + # commitment: 10_000, true-up: 10_000 - 920 = 9_080 + + it "returns true-up amount" do + expect(service_call.amount_cents).to eq(9_080) + end + end + end + end + end + + context "when subscription is calendar" do + let(:billing_time) { :calendar } + + context "when plan has yearly interval" do + let(:interval) { :yearly } + let(:commitment_amount_cents) { 10_000 } + let(:subscription_at) { DateTime.parse("2023-03-15T00:00:00") } + + # Year 1 (partial): Mar 15, 2023 - Dec 31, 2023 (subscription started mid-year) + # Year 2 (full): Jan 1, 2024 - Dec 31, 2024 + # Commitment is evaluated at year 2, looking at year 1's fees + + context "when there is no previous invoice subscription" do + # First invoice of subscription - no commitment fee should be charged + let(:from_datetime) { DateTime.parse("2023-03-15T00:00:00") } + let(:to_datetime) { DateTime.parse("2023-12-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2023-03-15T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2023-12-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2023-03-15T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2023-12-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-01-01T10:00:00") } + + context "with charge fees" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 2000, + properties: { + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime + } + ) + + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: false, + charge: create(:standard_charge), + amount_cents: 1500, + properties: { + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime + } + ) + end + + it "returns zero (no commitment evaluation on first invoice)" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "with fixed charge fees" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 2000, + properties: { + fixed_charges_from_datetime: invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: invoice_subscription.fixed_charges_to_datetime + } + ) + + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: false, + fixed_charge:, + amount_cents: 1500, + properties: { + fixed_charges_from_datetime: invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "returns zero (no commitment evaluation on first invoice)" do + expect(service_call.amount_cents).to eq(0) + end + end + end + + context "when there is a previous invoice subscription" do + # Second invoice - commitment for year 1 is evaluated + # Previous period (year 1): Mar 15, 2023 - Dec 31, 2023 (292 days out of 365) + # Current period (year 2): Jan 1, 2024 - Dec 31, 2024 + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + from_datetime: DateTime.parse("2023-03-15T00:00:00"), + to_datetime: DateTime.parse("2023-12-31T23:59:59.999"), + charges_from_datetime: DateTime.parse("2023-03-15T00:00:00"), + charges_to_datetime: DateTime.parse("2023-12-31T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2023-03-15T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2023-12-31T23:59:59.999"), + timestamp: DateTime.parse("2023-03-15T10:00:00") + ) + end + + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-12-31T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-01-01T10:00:00") } + + # Proration: 292 days (Mar 15 - Dec 31) / 365 days (full year) = 292/365 ≈ 0.8 + # Prorated commitment: 10000 * 292/365 = 8000 + + # Subscription fee on previous invoice (500) - common to all scenarios + before do + create( + :fee, + subscription: previous_invoice_subscription.subscription, + invoice: previous_invoice_subscription.invoice, + amount_cents: 500, + properties: { + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime + } + ) + end + + context "when fees total amount is greater or equal than the prorated commitment" do + before do + # In-advance charge (4000) + arrears charge (4000) = 8000 + 500 sub = 8500 >= 8000 + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 4000, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + create( + :charge_fee, + invoice: invoice_subscription.invoice, + subscription:, + pay_in_advance: false, + charge: create(:standard_charge), + amount_cents: 4000, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + end + + it "returns zero" do + # fees = 500 (subscription) + 4000 (in-advance) + 4000 (arrears) = 8500 >= 8000 + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the prorated commitment" do + before do + # In-advance charge (1500) + arrears charge (1000) = 2500 + 500 sub = 3000 < 8000 + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 1500, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + create( + :charge_fee, + invoice: invoice_subscription.invoice, + subscription:, + pay_in_advance: false, + charge: create(:standard_charge), + amount_cents: 1000, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + end + + # Base true_up: 8000 - 3000 = 5000 + + it "returns true-up amount" do + expect(service_call.amount_cents).to eq(5000) + end + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 1000, + properties: { + charges_from_datetime: charges_from_datetime + 1.year, + charges_to_datetime: charges_to_datetime + 1.year + } + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(5000) + end + end + + context "with an in-advance charge for the previous period" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 1000, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + end + + it "counts it" do + # 8000 - (3000 + 1000) = 4000 + expect(service_call.amount_cents).to eq(4000) + end + end + + context "with an in-advance charge from another period (no dates)" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 1000 + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(5000) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 1000, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.year, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.year + } + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(5000) + end + end + + context "with an in-advance fixed charge for the previous period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 1000, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "counts it" do + # 8000 - (3000 + 1000) = 4000 + expect(service_call.amount_cents).to eq(4000) + end + end + + context "with an in-advance fixed charge from another period (no dates)" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 1000 + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(5000) + end + end + end + + context "with fixed charge fees only" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 1500, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + + create( + :fixed_charge_fee, + invoice: invoice_subscription.invoice, + subscription:, + pay_in_advance: false, + fixed_charge:, + amount_cents: 1000, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "returns true-up amount" do + # fees = 500 (subscription) + 1500 (in-advance) + 1000 (arrears) = 3000 + # true_up = 8000 - 3000 = 5000 + expect(service_call.amount_cents).to eq(5000) + end + end + end + end + + context "when plan has weekly interval" do + let(:interval) { :weekly } + let(:commitment_amount_cents) { 3_000 } + let(:subscription_at) { DateTime.parse("2024-01-02T00:00:00") } + + # Week 1 (partial): Jan 2-7 (subscription started Jan 2, not Jan 1) + # Week 2 (full): Jan 8-14 + # Commitment is evaluated at week 2, looking at week 1's fees + + context "when there is no previous invoice subscription" do + # First invoice of subscription - no commitment fee should be charged + let(:from_datetime) { DateTime.parse("2024-01-02T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-07T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-02T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-01-07T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-02T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-01-07T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-01-08T10:00:00") } + + context "with charge fees" do + before do + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 700, + properties: { + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime + } + ) + + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: false, + charge: create(:standard_charge), + amount_cents: 500, + properties: { + charges_from_datetime: invoice_subscription.charges_from_datetime, + charges_to_datetime: invoice_subscription.charges_to_datetime + } + ) + end + + it "returns zero (no commitment evaluation on first invoice)" do + expect(service_call.amount_cents).to eq(0) + end + end + + context "with fixed charge fees" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 700, + properties: { + fixed_charges_from_datetime: invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: invoice_subscription.fixed_charges_to_datetime + } + ) + + create( + :fixed_charge_fee, + invoice: nil, + subscription:, + pay_in_advance: false, + fixed_charge:, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "returns zero (no commitment evaluation on first invoice)" do + expect(service_call.amount_cents).to eq(0) + end + end + end + + context "when there is a previous invoice subscription" do + # Second invoice - commitment for week 1 is evaluated + # Previous period (week 1): Jan 2-7 (6 days out of 7-day week) + # Current period (week 2): Jan 8-14 + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + from_datetime: DateTime.parse("2024-01-02T00:00:00"), + to_datetime: DateTime.parse("2024-01-07T23:59:59.999"), + charges_from_datetime: DateTime.parse("2024-01-02T00:00:00"), + charges_to_datetime: DateTime.parse("2024-01-07T23:59:59.999"), + fixed_charges_from_datetime: DateTime.parse("2024-01-02T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2024-01-07T23:59:59.999"), + timestamp: DateTime.parse("2024-01-02T10:00:00") + ) + end + + let(:from_datetime) { DateTime.parse("2024-01-08T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-01-14T23:59:59.999") } + let(:charges_from_datetime) { DateTime.parse("2024-01-08T00:00:00") } + let(:charges_to_datetime) { DateTime.parse("2024-01-14T23:59:59.999") } + let(:fixed_charges_from_datetime) { DateTime.parse("2024-01-08T00:00:00") } + let(:fixed_charges_to_datetime) { DateTime.parse("2024-01-14T23:59:59.999") } + let(:timestamp) { DateTime.parse("2024-01-08T10:00:00") } + + # Proration: 6 days (Jan 2-7) / 7 days (full week) = 6/7 + # Prorated commitment: 3000 * 6/7 = 2571 + + # Subscription fee on previous invoice (200) - common to all scenarios + before do + create( + :fee, + subscription: previous_invoice_subscription.subscription, + invoice: previous_invoice_subscription.invoice, + amount_cents: 200, + properties: { + from_datetime: previous_invoice_subscription.from_datetime, + to_datetime: previous_invoice_subscription.to_datetime + } + ) + end + + context "when fees total amount is greater or equal than the prorated commitment" do + before do + # In-advance charge (1500) + arrears charge (1000) = 2500 + 200 sub = 2700 >= 2571 + create( + :charge_fee, + invoice: nil, + subscription:, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 1500, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + create( + :charge_fee, + invoice: invoice_subscription.invoice, + subscription:, + pay_in_advance: false, + charge: create(:standard_charge), + amount_cents: 1000, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + end + + it "returns zero" do + # fees = 200 (subscription) + 1500 (in-advance) + 1000 (arrears) = 2700 >= 2571 + expect(service_call.amount_cents).to eq(0) + end + end + + context "when fees total amount is smaller than the prorated commitment" do + before do + # In-advance charge (700) + arrears charge (500) = 1200 + 200 sub = 1400 < 2571 + create( + :charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 700, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + + create( + :charge_fee, + invoice: invoice_subscription.invoice, + subscription: subscription, + pay_in_advance: false, + charge: create(:standard_charge), + amount_cents: 500, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + end + + # Base true_up: 2571 - 1400 = 1171 + + it "returns true-up amount" do + expect(service_call.amount_cents).to eq(1171) + end + + context "with an in-advance charge for the next period" do + before do + create( + :charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 300, + properties: { + charges_from_datetime: charges_from_datetime + 1.week, + charges_to_datetime: charges_to_datetime + 1.week + } + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(1171) + end + end + + context "with an in-advance charge for the previous period" do + before do + create( + :charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 300, + properties: { + charges_from_datetime: previous_invoice_subscription.charges_from_datetime, + charges_to_datetime: previous_invoice_subscription.charges_to_datetime + } + ) + end + + it "counts it" do + # 2571 - (1400 + 300) = 871 + expect(service_call.amount_cents).to eq(871) + end + end + + context "with an in-advance charge from another period (no dates)" do + before do + create( + :charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + charge: create(:standard_charge, :pay_in_advance), + amount_cents: 300 + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(1171) + end + end + + context "with an in-advance fixed charge for the next period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 300, + properties: { + fixed_charges_from_datetime: fixed_charges_from_datetime + 1.week, + fixed_charges_to_datetime: fixed_charges_to_datetime + 1.week + } + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(1171) + end + end + + context "with an in-advance fixed charge for the previous period" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 300, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "counts it" do + # 2571 - (1400 + 300) = 871 + expect(service_call.amount_cents).to eq(871) + end + end + + context "with an in-advance fixed charge from another period (no dates)" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 300 + ) + end + + it "does not count it" do + expect(service_call.amount_cents).to eq(1171) + end + end + end + + context "with fixed charge fees only" do + before do + create( + :fixed_charge_fee, + invoice: nil, + subscription: subscription, + pay_in_advance: true, + fixed_charge: fixed_charge_pay_in_advance, + amount_cents: 700, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + + create( + :fixed_charge_fee, + invoice: invoice_subscription.invoice, + subscription: subscription, + pay_in_advance: false, + fixed_charge: fixed_charge, + amount_cents: 500, + properties: { + fixed_charges_from_datetime: previous_invoice_subscription.fixed_charges_from_datetime, + fixed_charges_to_datetime: previous_invoice_subscription.fixed_charges_to_datetime + } + ) + end + + it "returns true-up amount" do + # fees = 200 (subscription) + 700 (in-advance) + 500 (arrears) = 1400 + # true_up = 2571 - 1400 = 1171 + expect(service_call.amount_cents).to eq(1171) + end + end + end + end + end + end + end + end +end diff --git a/spec/services/commitments/override_service_spec.rb b/spec/services/commitments/override_service_spec.rb new file mode 100644 index 0000000..db8c66a --- /dev/null +++ b/spec/services/commitments/override_service_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Commitments::OverrideService do + subject(:override_service) { described_class.new(commitment:, params:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + let(:plan) { create(:plan, organization:) } + let(:params) do + { + plan_id: plan.id, + invoice_display_name: "invoice display name", + amount_cents: 1000, + tax_codes: [tax.code] + } + end + + let(:tax) { create(:tax, organization:) } + let(:commitment) { build(:commitment, plan:) } + + context "when lago freemium" do + it "returns without overriding the commitment" do + expect { override_service.call }.not_to change(Commitment, :count) + end + end + + context "when lago premium", :premium do + it "creates a commitment based on the given commitment" do + expect { override_service.call }.to change(Commitment, :count).by(1) + + commitment = Commitment.order(:created_at).last + + expect(commitment.taxes).to contain_exactly(tax) + expect(commitment).to have_attributes( + plan_id: plan.id, + invoice_display_name: "invoice display name", + amount_cents: 1000 + ) + expect(commitment.taxes).to contain_exactly(tax) + end + end + end +end diff --git a/spec/services/coupons/create_service_spec.rb b/spec/services/coupons/create_service_spec.rb new file mode 100644 index 0000000..48f6179 --- /dev/null +++ b/spec/services/coupons/create_service_spec.rb @@ -0,0 +1,351 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Coupons::CreateService do + subject(:create_service) { described_class.new(create_args) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon_code) { "free-beer" } + + describe "create" do + let(:expiration_at) { (Time.current + 3.days).end_of_day } + let(:create_args) do + { + name: "Super Coupon", + code: coupon_code, + description: "This is a description", + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at: + } + end + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + + it "produces an activity log" do + coupon = described_class.call(create_args).coupon + + expect(Utils::ActivityLog).to have_produced("coupon.created").after_commit.with(coupon) + end + + context "with code already used by a deleted coupon" do + it "creates an coupon with the same code" do + create(:coupon, :deleted, organization:, code: coupon_code) + + expect { create_service.call }.to change(Coupon, :count).by(1) + + coupons = organization.coupons.with_discarded + expect(coupons.count).to eq(2) + expect(coupons.pluck(:code).uniq).to eq([coupon_code]) + end + end + + context "when coupon type is percentage" do + let(:create_args) do + { + name: "Super Coupon", + code: "free-beer", + organization_id: organization.id, + coupon_type: "percentage", + frequency: "once", + percentage_rate: 20.00, + expiration: "time_limit", + expiration_at: (Time.current + 3.days).iso8601 + } + end + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + end + + context "with non-ISO8601 expiration_at" do + let(:expiration_at) { "2064-12-13 12:00:00" } + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + end + + context "with validation error" do + before do + create(:coupon, organization:, code: coupon_code) + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + + context "with expiration_at in the past" do + let(:expiration_at) { (Time.current - 3.days).end_of_day } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + end + end + + context "with invalid expiration_at" do + let(:expiration_at) { "invalid-date" } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + end + end + + context "when frequency is recurring without frequency_duration" do + let(:create_args) do + { + name: "Super Coupon", + code: coupon_code, + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "recurring", + amount_cents: 100, + amount_currency: "EUR", + expiration: "no_expiration", + reusable: false + } + end + + it "fails with a validation error" do + expect { create_service.call }.not_to change(Coupon, :count) + + result = create_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:frequency_duration]).to eq(["value_is_mandatory", "is not a number"]) + end + end + + context "with plan limitations in graphql context" do + let(:plan) { create(:plan, organization:) } + let(:create_args) do + { + name: "Super Coupon", + code: "free-beer", + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at:, + applies_to: { + plan_ids: [plan.id] + } + } + end + + before { CurrentContext.source = "graphql" } + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + + it "creates a coupon target" do + expect { create_service.call } + .to change(CouponTarget, :count).by(1) + end + end + + context "with plan limitations in api context" do + let(:plan) { create(:plan, organization:) } + let(:create_args) do + { + name: "Super Coupon", + code: "free-beer", + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at:, + applies_to: { + plan_codes: [plan.code] + } + } + end + + before { CurrentContext.source = "api" } + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + + it "creates a coupon target" do + expect { create_service.call } + .to change(CouponTarget, :count).by(1) + end + + context "when a parent plan has childs" do + let(:child_plan) { create(:plan, organization:, parent: plan, code: plan.code) } + + before { child_plan } + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + + it "creates a coupon target" do + expect { create_service.call } + .to change(CouponTarget, :count).by(2) + end + end + end + + context "with billable metric limitations in graphql context" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:create_args) do + { + name: "Super Coupon", + code: "free-beer", + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at:, + applies_to: { + billable_metric_ids: [billable_metric.id] + } + } + end + + before { CurrentContext.source = "graphql" } + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + + it "creates a coupon target" do + expect { create_service.call } + .to change(CouponTarget, :count).by(1) + end + + context "with multiple limitation types" do + let(:plan) { create(:plan, organization:) } + let(:create_args) do + { + name: "Super Coupon", + code: "free-beer", + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at:, + applies_to: { + billable_metric_ids: [billable_metric.id], + plan_ids: [plan.id] + } + } + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("only_one_limitation_type_per_coupon_allowed") + end + end + + context "with invalid billable metric" do + let(:create_args) do + { + name: "Super Coupon", + code: "free-beer", + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at:, + applies_to: { + billable_metric_ids: [billable_metric.id, "invalid"] + } + } + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billable_metrics_not_found") + end + end + end + + context "with billable metric limitations in api context" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:create_args) do + { + name: "Super Coupon", + code: "free-beer", + organization_id: organization.id, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at:, + applies_to: { + billable_metric_codes: [billable_metric.code] + } + } + end + + before { CurrentContext.source = "api" } + + it "creates a coupon" do + expect { create_service.call } + .to change(Coupon, :count).by(1) + end + + it "creates a coupon target" do + expect { create_service.call } + .to change(CouponTarget, :count).by(1) + end + end + end +end diff --git a/spec/services/coupons/destroy_service_spec.rb b/spec/services/coupons/destroy_service_spec.rb new file mode 100644 index 0000000..722ab9d --- /dev/null +++ b/spec/services/coupons/destroy_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Coupons::DestroyService do + subject(:destroy_service) { described_class.new(coupon:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:coupon) { create(:coupon, organization:) } + let(:coupon_plan) { create(:coupon_plan, coupon:) } + + describe "#call" do + before do + coupon + end + + it "soft deletes the coupon" do + freeze_time do + expect { destroy_service.call }.to change(Coupon, :count).by(-1) + .and change { coupon.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "soft deletes all the related coupon_plans" do + freeze_time do + expect { destroy_service.call }.to change { coupon_plan.reload.deleted_at } + .from(nil).to(Time.current) + end + end + + it "produces an activity log" do + described_class.call(coupon:) + + expect(Utils::ActivityLog).to have_produced("coupon.deleted").after_commit.with(coupon) + end + + context "with applied coupons" do + it "terminates applied coupons" do + applied_coupon = create(:applied_coupon, coupon:) + result = destroy_service.call + + expect(result).to be_success + expect(applied_coupon.reload).to be_terminated + end + end + + context "when coupon is not found" do + let(:coupon) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("coupon_not_found") + end + end + end +end diff --git a/spec/services/coupons/preview_service_spec.rb b/spec/services/coupons/preview_service_spec.rb new file mode 100644 index 0000000..b0bbc3f --- /dev/null +++ b/spec/services/coupons/preview_service_spec.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Coupons::PreviewService do + subject(:preview_service) { described_class.new(invoice:, applied_coupons:) } + + let(:invoice) do + build( + :invoice, + fees_amount_cents: 100, + sub_total_excluding_taxes_amount_cents: 100, + currency: "EUR", + customer: subscription.customer + ) + end + + let(:subscription) do + build( + :subscription, + plan:, + billing_time: :calendar, + subscription_at: started_at, + started_at:, + created_at:, + status: :active + ) + end + let(:started_at) { Time.zone.now - 2.years } + let(:created_at) { started_at } + + describe "#call" do + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:fee) { build(:fee, amount_cents: 100, invoice:, subscription:) } + let(:applied_coupon) do + build( + :applied_coupon, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { build(:coupon, coupon_type: "percentage", percentage_rate: 10.00) } + let(:applied_coupon_latest) do + build( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + percentage_rate: 20.00 + ) + end + let(:applied_coupons) do + [ + applied_coupon, + applied_coupon_latest + ] + end + + let(:plan) { create(:plan, interval: "monthly") } + + before do + invoice.subscriptions = [subscription] + invoice.fees = [fee] + applied_coupon + applied_coupon_latest + end + + it "updates the invoice accordingly" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(28) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(72) + expect(result.invoice.credits.length).to eq(2) + end + + context "when first coupon covers the invoice" do + let(:invoice) do + build( + :invoice, + fees_amount_cents: 5, + sub_total_excluding_taxes_amount_cents: 5, + currency: "EUR", + customer: subscription.customer + ) + end + + before { fee.amount_cents = 5 } + + it "updates the invoice accordingly and spends only the first coupon" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(5) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(0) + expect(result.invoice.credits.length).to eq(1) + end + end + + context "when both coupons are fixed amount" do + let(:coupon_latest) { build(:coupon, coupon_type: "fixed_amount") } + let(:applied_coupon_latest) do + create( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency + ) + end + + it "updates the invoice accordingly" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(30) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(70) + expect(result.invoice.credits.length).to eq(2) + end + end + + context "when both coupons are percentage" do + let(:coupon) { build(:coupon, coupon_type: "percentage", percentage_rate: 10.00) } + let(:applied_coupon) do + build( + :applied_coupon, + coupon:, + customer: subscription.customer, + percentage_rate: 15.00 + ) + end + + it "updates the invoice accordingly" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(32) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(68) + expect(result.invoice.credits.length).to eq(2) + end + end + + context "when coupon has a difference currency" do + let(:applied_coupons) { [applied_coupon] } + let(:applied_coupon) do + build( + :applied_coupon, + customer: subscription.customer, + amount_cents: 10, + amount_currency: "NOK" + ) + end + + it "ignores the coupon" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.credits.length).to be_zero + end + end + + context "when both coupons have plan limitations which are not applicable" do + let(:coupon) { build(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan) { build(:coupon_plan, coupon:, plan: create(:plan)) } + let(:applied_coupon) do + build( + :applied_coupon, + coupon:, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { build(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan_latest) { build(:coupon_plan, coupon: coupon_latest, plan: create(:plan)) } + let(:applied_coupon_latest) do + build( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency + ) + end + + before do + coupon_plan + coupon_plan_latest + end + + it "ignores coupons" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(0) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(100) + expect(result.invoice.credits.length).to be_zero + end + end + + context "when only one coupon is applicable due to plan limitations" do + let(:coupon) { build(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan) { build(:coupon_plan, coupon:, plan: create(:plan)) } + let(:applied_coupon) do + build( + :applied_coupon, + coupon:, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { build(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan_latest) { build(:coupon_plan, coupon: coupon_latest, plan:) } + let(:applied_coupon_latest) do + build( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency + ) + end + + before do + coupon_plan + coupon_plan_latest + coupon.plans = [] + coupon_latest.plans = [plan] + end + + it "ignores only one coupon and applies the other one" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(20) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(80) + expect(result.invoice.credits.length).to eq(1) + end + end + + context "when both coupons are applicable due to plan limitations" do + let(:coupon) { build(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan) { build(:coupon_plan, coupon:, plan:) } + let(:applied_coupon) do + build( + :applied_coupon, + coupon:, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { build(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan_latest) { build(:coupon_plan, coupon: coupon_latest, plan:) } + let(:applied_coupon_latest) do + build( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency + ) + end + + before do + coupon_plan + coupon_plan_latest + coupon.plans = [plan] + coupon_latest.plans = [plan] + end + + it "applies two coupons" do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(30) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(70) + expect(result.invoice.credits.length).to eq(2) + end + end + end +end diff --git a/spec/services/coupons/terminate_service_spec.rb b/spec/services/coupons/terminate_service_spec.rb new file mode 100644 index 0000000..e152bf5 --- /dev/null +++ b/spec/services/coupons/terminate_service_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Coupons::TerminateService do + subject(:terminate_service) { described_class.new(coupon) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:coupon) { create(:coupon, organization:) } + + describe "terminate" do + it "terminates the coupon" do + result = terminate_service.call + + expect(result).to be_success + expect(result.coupon).to be_terminated + end + + context "when coupon is already terminated" do + before { coupon.mark_as_terminated! } + + it "does not impact the coupon" do + terminated_at = coupon.terminated_at + result = terminate_service.call + + expect(result).to be_success + expect(result.coupon).to be_terminated + expect(result.coupon.terminated_at).to eq(terminated_at) + end + end + end + + describe "terminate_all_expired" do + let(:to_expire_coupons) do + create_list( + :coupon, + 3, + organization:, + status: "active", + expiration: "time_limit", + expiration_at: Time.current - 30.days, + created_at: Time.zone.now - 40.days + ) + end + + let(:to_keep_active_coupons) do + create_list( + :coupon, + 3, + organization:, + status: "active", + expiration: "time_limit", + expiration_at: Time.current + 15.days, + created_at: Time.zone.now + ) + end + + before do + to_expire_coupons + to_keep_active_coupons + + described_class.terminate_all_expired + end + + it "terminates the expired coupons" do + expect(Coupon.terminated.count).to eq(3) + end + end +end diff --git a/spec/services/coupons/update_service_spec.rb b/spec/services/coupons/update_service_spec.rb new file mode 100644 index 0000000..dc90038 --- /dev/null +++ b/spec/services/coupons/update_service_spec.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Coupons::UpdateService do + subject(:update_service) { described_class.new(coupon:, params:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:coupon) { create(:coupon, organization:) } + + let(:params) do + { + name:, + coupon_type: "fixed_amount", + frequency: "once", + amount_cents: 100, + amount_currency: "EUR", + expiration: "time_limit", + reusable: false, + expiration_at:, + applies_to: + } + end + + let(:name) { "new name" } + let(:expiration_at) { Time.current + 30.days } + let(:applies_to) { nil } + + describe "#call" do + it "updates the coupon" do + result = update_service.call + + expect(result).to be_success + + expect(result.coupon.name).to eq("new name") + expect(result.coupon.amount_cents).to eq(100) + expect(result.coupon.amount_currency).to eq("EUR") + expect(result.coupon.expiration).to eq("time_limit") + expect(result.coupon.reusable).to eq(false) + expect(result.coupon.expiration_at.to_s).to eq(expiration_at.to_s) + end + + it "produces an activity log" do + described_class.call(coupon:, params:) + + expect(Utils::ActivityLog).to have_produced("coupon.updated").after_commit.with(coupon) + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + + context "when frequency is recurring without frequency_duration" do + let(:params) do + { + name:, + coupon_type: "fixed_amount", + frequency: "recurring", + amount_cents: 100, + amount_currency: "EUR", + expiration: "no_expiration", + reusable: false + } + end + + it "fails with a validation error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:frequency_duration]).to eq(["value_is_mandatory", "is not a number"]) + end + end + + context "with new plan limitations" do + let(:plan) { create(:plan, organization:) } + let(:plan_second) { create(:plan, organization:) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan:) } + let(:applies_to) { {plan_ids: [plan.id, plan_second.id]} } + + before do + CurrentContext.source = "graphql" + + plan_second + coupon_plan + end + + it "creates new coupon target" do + expect { update_service.call }.to change(CouponTarget, :count).by(1) + end + + context "with API context" do + before { CurrentContext.source = "api" } + + let(:applies_to) { {plan_codes: [plan.code, plan_second.code]} } + + it "creates new coupon target using plan code" do + expect { update_service.call }.to change(CouponTarget, :count).by(1) + end + end + end + + context "with coupon plans to delete" do + let(:plan) { create(:plan, organization:) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan:) } + let(:applies_to) { {plan_ids: []} } + + before do + CurrentContext.source = "graphql" + + coupon_plan + end + + it "deletes a coupon plan" do + expect { update_service.call }.to change(CouponTarget, :count).by(-1) + end + end + + context "with new billable metric limitations" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_second) { create(:billable_metric, organization:) } + let(:coupon_billable_metric) { create(:coupon_billable_metric, coupon:, billable_metric:) } + let(:applies_to) { {billable_metric_ids: [billable_metric.id, billable_metric_second.id]} } + + before do + CurrentContext.source = "graphql" + + billable_metric_second + coupon_billable_metric + end + + it "creates new coupon target" do + expect { update_service.call }.to change(CouponTarget, :count).by(1) + end + + context "with API context" do + before { CurrentContext.source = "api" } + + let(:applies_to) { {billable_metric_codes: [billable_metric.code, billable_metric_second.code]} } + + it "creates new coupon target using billable metric code" do + expect { update_service.call }.to change(CouponTarget, :count).by(1) + end + end + + context "with multiple limitation types" do + let(:plan) { create(:plan, organization:) } + let(:applies_to) do + { + billable_metric_ids: [billable_metric.id, billable_metric_second.id], + plan_ids: [plan.id] + } + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("only_one_limitation_type_per_coupon_allowed") + end + end + + context "with invalid billable metric" do + let(:applies_to) do + { + billable_metric_ids: [billable_metric.id, billable_metric_second.id, "invalid"] + } + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billable_metrics_not_found") + end + end + end + + context "with coupon billable metrics to delete" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:coupon_billable_metric) { create(:coupon_billable_metric, coupon:, billable_metric:) } + let(:applies_to) { {plan_ids: []} } + + before do + CurrentContext.source = "graphql" + + coupon_billable_metric + end + + it "deletes a coupon billable metric" do + expect { update_service.call }.to change(CouponTarget, :count).by(-1) + end + end + + context "when coupon is not found" do + let(:coupon) { nil } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("coupon_not_found") + end + end + end +end diff --git a/spec/services/coupons/validate_service_spec.rb b/spec/services/coupons/validate_service_spec.rb new file mode 100644 index 0000000..97b8d16 --- /dev/null +++ b/spec/services/coupons/validate_service_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require "rails_helper" +RSpec.describe Coupons::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:expiration_at) { Time.current + 10.days } + let(:args) do + { + organization_id: organization.id, + name: "name", + code: "code", + coupon_type: "fixed_amount", + amount_cents: 100, + amount_currency: "EUR", + frequency: "once", + expiration: "time_limit", + expiration_at: + } + end + + describe "#valid?" do + it "returns true" do + expect(validate_service).to be_valid + end + + context "when expiration_at is invalid" do + let(:expiration_at) { Time.current - 10.days } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + end + end + end +end diff --git a/spec/services/credit_notes/adjust_amounts_with_rounding_service_spec.rb b/spec/services/credit_notes/adjust_amounts_with_rounding_service_spec.rb new file mode 100644 index 0000000..f6cf845 --- /dev/null +++ b/spec/services/credit_notes/adjust_amounts_with_rounding_service_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::AdjustAmountsWithRoundingService do + subject(:adjust_service) { described_class.new(credit_note:) } + + describe "#call" do + let(:invoice) do + create( + :invoice, + total_amount_cents: 25000, + taxes_amount_cents: 5000, + fees_amount_cents: 20000, + total_paid_amount_cents: 25000, + taxes_rate: 25, + payment_status: :succeeded + ) + end + + let(:fee) do + create( + :fee, + invoice:, + amount_cents: 20000, + taxes_rate: 25 + ) + end + + let(:credit_note) do + build( + :credit_note, + invoice:, + taxes_amount_cents: 4833, + credit_amount_cents: 24167, + total_amount_cents: 24167 + ) + end + + let(:item) do + build( + :credit_note_item, + amount_cents: 19333, + precise_amount_cents: 19333 + ) + end + + before do + fee + credit_note.items << item + end + + it "adjust the total and credit amount" do + result = adjust_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note).to have_attributes( + taxes_amount_cents: 4833, + sub_total_excluding_taxes_amount_cents: 19333, + credit_amount_cents: 24166, + total_amount_cents: 24166 + ) + end + + context "when rounding diff is negative" do + let(:credit_note) do + build( + :credit_note, + invoice:, + taxes_amount_cents: 2, + credit_amount_cents: 9, + total_amount_cents: 9, + taxes_rate: 20 + ) + end + + let(:item) do + build( + :credit_note_item, + amount_cents: 8, + precise_amount_cents: 7.6 + ) + end + + it "adds a cent to total" do + result = adjust_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note.items.first.amount_cents).to eq(8) + expect(credit_note).to have_attributes( + taxes_amount_cents: 2, + sub_total_excluding_taxes_amount_cents: 8, + credit_amount_cents: 10, + total_amount_cents: 10 + ) + expect(credit_note.items.sum(&:amount_cents)).to eq(8) + expect(credit_note.items.sum(&:precise_amount_cents).round).to eq(8) + end + end + end +end diff --git a/spec/services/credit_notes/apply_taxes_service_spec.rb b/spec/services/credit_notes/apply_taxes_service_spec.rb new file mode 100644 index 0000000..0b4f790 --- /dev/null +++ b/spec/services/credit_notes/apply_taxes_service_spec.rb @@ -0,0 +1,321 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::ApplyTaxesService do + subject(:apply_service) { described_class.new(invoice:, items:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + currency: "EUR", + fees_amount_cents: 120, + coupons_amount_cents: 20, + taxes_amount_cents: 20, + total_amount_cents: 120, + payment_status: :succeeded, + taxes_rate: 20, + version_number: 3 + ) + end + + let(:fee1) do + create( + :fee, + invoice:, + amount_cents: 100, + taxes_amount_cents: 12, + taxes_rate: 12, + precise_coupons_amount_cents: (20 * 100).fdiv(120) + ) + end + + let(:fee2) do + create( + :fee, + invoice:, + amount_cents: 20, + taxes_amount_cents: 4, + taxes_rate: 20, + precise_coupons_amount_cents: (20 * 20).fdiv(120) + ) + end + + let(:items) do + [ + build( + :credit_note_item, + credit_note: nil, + fee: fee1, + amount_cents: 20, + precise_amount_cents: 20, + amount_currency: invoice.currency + ), + build( + :credit_note_item, + credit_note: nil, + fee: fee2, + amount_cents: 50, + precise_amount_cents: 50, + amount_currency: invoice.currency + ) + ] + end + + context "when local taxes are applied" do + let(:tax1) { create(:tax, organization:, code: "tax1", rate: 12) } + let(:tax2) { create(:tax, organization:, code: "tax2", rate: 8) } + + let(:fee_applied_tax11) do + create( + :fee_applied_tax, + tax: tax1, + tax_code: tax1.code, + fee: fee1, + amount_cents: (fee1.sub_total_excluding_taxes_amount_cents * tax1.rate).fdiv(100) + ) + end + + let(:fee_applied_tax21) do + create( + :fee_applied_tax, + tax: tax1, + tax_code: tax1.code, + fee: fee2, + amount_cents: (fee2.sub_total_excluding_taxes_amount_cents * tax1.rate).fdiv(100) + ) + end + + let(:fee_applied_tax22) do + create( + :fee_applied_tax, + tax: tax2, + tax_code: tax2.code, + fee: fee2, + amount_cents: (fee2.sub_total_excluding_taxes_amount_cents * tax2.rate).fdiv(100) + ) + end + + let(:invoice_applied_taxes) { + [ + create(:invoice_applied_tax, tax_code: tax1.code, tax_rate: tax1.rate, tax: tax1, invoice:), + create(:invoice_applied_tax, tax_code: tax2.code, tax_rate: tax2.rate, tax: tax2, invoice:) + ] + } + + before do + invoice_applied_taxes + fee_applied_tax11 + fee_applied_tax21 + fee_applied_tax22 + end + + describe "call" do + it "creates applied taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes.sort_by(&:tax_code) + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes[0]).to have_attributes( + credit_note: nil, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 7 + ) + + expect(applied_taxes[1]).to have_attributes( + credit_note: nil, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 8, + amount_currency: invoice.currency, + amount_cents: 3 + ) + + expect(result.taxes_amount_cents.round).to eq(10) + expect(result.taxes_rate).to eq(17.71429) + expect(result.coupons_adjustment_amount_cents.round).to eq(12) + end + end + end + + context "when taxes from tax provider are applied" do + let(:provider_tax_1) { OpenStruct.new(name: "provider tax 1", type: "providerTax1", rate: 12.0, code: "provider_tax_1") } + let(:provider_tax_2) { OpenStruct.new(name: "provider tax 2", type: "providerTax2", rate: 8.0, code: "provider_tax_2") } + + let(:fee_applied_tax11) do + create( + :fee_applied_tax, + :with_provider_tax, + tax: nil, + provider_tax_breakdown_object: provider_tax_1, + fee: fee1 + ) + end + + let(:fee_applied_tax21) do + create( + :fee_applied_tax, + :with_provider_tax, + tax: nil, + provider_tax_breakdown_object: provider_tax_1, + fee: fee2 + ) + end + + let(:fee_applied_tax22) do + create( + :fee_applied_tax, + :with_provider_tax, + tax: nil, + provider_tax_breakdown_object: provider_tax_2, + fee: fee2 + ) + end + + let(:invoice_applied_taxes) { + [ + create(:invoice_applied_tax, :with_provider_tax, provider_tax_breakdown_object: provider_tax_1, tax: nil, invoice:), + create(:invoice_applied_tax, :with_provider_tax, provider_tax_breakdown_object: provider_tax_2, tax: nil, invoice:) + ] + } + + before do + invoice_applied_taxes + fee_applied_tax11 + fee_applied_tax21 + fee_applied_tax22 + end + + context "when coupons are applied" do + describe "call" do + it "creates applied taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes.sort_by(&:tax_code) + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes[0]).to have_attributes( + credit_note: nil, + tax: nil, + tax_description: provider_tax_1.type, + tax_code: provider_tax_1.code, + tax_name: provider_tax_1.name, + tax_rate: provider_tax_1.rate, + amount_currency: invoice.currency, + amount_cents: 7 + ) + + expect(applied_taxes[1]).to have_attributes( + credit_note: nil, + tax: nil, + tax_description: provider_tax_2.type, + tax_code: provider_tax_2.code, + tax_name: provider_tax_2.name, + tax_rate: provider_tax_2.rate, + amount_currency: invoice.currency, + amount_cents: 3 + ) + + expect(result.taxes_amount_cents.round).to eq(10) + expect(result.taxes_rate).to eq(17.71429) + expect(result.coupons_adjustment_amount_cents.round).to eq(12) + end + end + end + + context "when there are plain fees without coupons" do + let(:fee1) do + create( + :fee, + invoice:, + amount_cents: 100, + taxes_amount_cents: 20, + taxes_rate: 20, + precise_coupons_amount_cents: 0 + ) + end + + let(:fee2) do + create( + :fee, + invoice:, + amount_cents: 50, + taxes_amount_cents: 2, + taxes_rate: 10, + precise_coupons_amount_cents: 0 + ) + end + + let(:provider_tax_1) { OpenStruct.new(name: "provider tax 1", type: "providerTax1", rate: 20.0, code: "provider_tax_1") } + let(:provider_tax_2) { OpenStruct.new(name: "provider tax 2", type: "providerTax2", rate: 10.0, code: "provider_tax_2") } + + describe "call" do + it "creates applied taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes.sort_by(&:tax_code) + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes[0]).to have_attributes( + credit_note: nil, + tax: nil, + tax_description: provider_tax_1.type, + tax_code: provider_tax_1.code, + tax_name: provider_tax_1.name, + tax_rate: provider_tax_1.rate, + amount_currency: invoice.currency, + amount_cents: 14 + ) + + expect(applied_taxes[1]).to have_attributes( + credit_note: nil, + tax: nil, + tax_description: provider_tax_2.type, + tax_code: provider_tax_2.code, + tax_name: provider_tax_2.name, + tax_rate: provider_tax_2.rate, + amount_currency: invoice.currency, + amount_cents: 5 + ) + + expect(result.taxes_amount_cents.round).to eq(19) + expect(result.taxes_rate).to eq(27.14286) + expect(result.coupons_adjustment_amount_cents.round).to eq(0) + end + end + end + end + + context "when no taxes are applied on the invoice" do + describe "call" do + it "succeeds" do + result = apply_service.call + expect(result).to be_success + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(0) + expect(result.taxes_amount_cents.round).to eq(0) + expect(result.taxes_rate).to eq(0) + expect(result.coupons_adjustment_amount_cents.round).to eq(12) + end + end + end +end diff --git a/spec/services/credit_notes/create_from_progressive_billing_invoice_spec.rb b/spec/services/credit_notes/create_from_progressive_billing_invoice_spec.rb new file mode 100644 index 0000000..29b02cb --- /dev/null +++ b/spec/services/credit_notes/create_from_progressive_billing_invoice_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::CreateFromProgressiveBillingInvoice do + subject(:credit_service) { described_class.new(progressive_billing_invoice:, amount:, reason:) } + + let(:reason) { :other } + let(:amount) { 0 } + let(:invoice_type) { :progressive_billing } + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:tax) { create(:tax, organization:, rate: 20) } + + let(:progressive_billing_invoice) do + create( + :invoice, + customer:, + organization:, + currency: "EUR", + fees_amount_cents: 120, + total_amount_cents: 120, + invoice_type: + ) + end + + let(:fee1) do + create( + :fee, + invoice: progressive_billing_invoice, + amount_cents: 80, + taxes_amount_cents: 16, + taxes_rate: 20 + ) + end + + let(:fee2) do + create( + :fee, + invoice: progressive_billing_invoice, + amount_cents: 40, + taxes_amount_cents: 8, + taxes_rate: 20 + ) + end + + let(:fee1_applied_tax) { create(:fee_applied_tax, tax:, fee: fee1) } + let(:fee2_applied_tax) { create(:fee_applied_tax, tax:, fee: fee2) } + let(:invoice_applied_tax) { create(:invoice_applied_tax, invoice: progressive_billing_invoice, tax:) } + + before do + progressive_billing_invoice + fee1 + fee2 + fee1_applied_tax + fee2_applied_tax + invoice_applied_tax + end + + describe "#call" do + it "does nothing when amount is zero" do + expect { credit_service.call }.not_to change(CreditNote, :count) + end + + context "with amount greater than zero" do + let(:amount) { 100 } + + context "when called with a subscription invoice" do + let(:invoice_type) { :subscription } + + it "fails when the passed in invoice is not a progressive billing invoice" do + result = credit_service.call + expect(result).not_to be_success + end + end + + context "when credit_amount_cents is zero" do + let(:amount) { 102 } + + let(:cn_ats_result) do + BaseService::Result.new.tap do |result| + result.coupons_adjustment_amount_cents = 102.0 + result.taxes_amount_cents = 0.0 + result.precise_taxes_amount_cents = 0.0 + end + end + + before do + allow(CreditNotes::ApplyTaxesService).to receive(:call).once.and_return(cn_ats_result) + end + + it "does not create a credit note" do + expect { credit_service.call }.not_to change(CreditNote, :count) + end + end + + it "creates a credit note for all required fees" do + result = credit_service.call + credit_note = result.credit_note + + expect(credit_note.credit_amount_cents).to eq(120) + expect(credit_note.items.size).to eq(2) + + credit_fee1 = credit_note.items.find { |i| i.fee == fee1 } + expect(credit_fee1.amount_cents).to eq(80) + credit_fee2 = credit_note.items.find { |i| i.fee == fee2 } + expect(credit_fee2.amount_cents).to eq(20) + expect(credit_note.applied_taxes.length).to eq(1) + expect(credit_note.applied_taxes.first.tax_code).to eq(invoice_applied_tax.tax_code) + expect(credit_note.applied_taxes.first.tax_id).to eq(tax.id) + end + + # this scenario is possible with multiple progressive billing invoices, when on latest progressive billing invice we try to refund + # sum of all PB invoices + context "when called with amount higher then sum of creditable amounts on fees" do + let(:amount) { 130 } + + it "fails with error" do + result = credit_service.call + expect(result).not_to be_success + expect(result.error.code).to eq("creditable_amount_is_less_than_requested") + end + end + end + end +end diff --git a/spec/services/credit_notes/create_from_termination_spec.rb b/spec/services/credit_notes/create_from_termination_spec.rb new file mode 100644 index 0000000..c1063f1 --- /dev/null +++ b/spec/services/credit_notes/create_from_termination_spec.rb @@ -0,0 +1,1177 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::CreateFromTermination do + subject(:create_service) { described_class.new(subscription:, context:, **kwargs) } + + let(:kwargs) { {} } + + let(:started_at) { Time.zone.parse("2022-09-01 10:00") } + let(:subscription_at) { Time.zone.parse("2022-09-01 10:00") } + let(:terminated_at) { Time.zone.parse("2022-10-15 10:00") } + + let(:customer) { create(:customer, **(customer_timezone ? {timezone: customer_timezone} : {})) } + let(:customer_timezone) { nil } + let(:organization) { customer.organization } + let(:context) { nil } + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + status: :terminated, + subscription_at:, + started_at:, + terminated_at:, + billing_time: :calendar + ) + end + let(:plan) do + create( + :plan, + :pay_in_advance, + organization:, + amount_cents: plan_amount_cents, + **(trial_period ? {trial_period:} : {}) + ) + end + let(:plan_amount_cents) { 31_00 } + let(:trial_period) { nil } + let(:tax) { create(:tax, organization:, rate: tax_rate) } + let(:tax_rate) { 20 } + let(:coupon_amount) { 0 } + + let(:fee_and_invoice) { generate_invoice_and_fee(plan_amount_cents) } + let(:invoice) { fee_and_invoice[:invoice] } + let(:subscription_fee) { fee_and_invoice[:subscription_fee] } + let(:invoice_applied_tax) { fee_and_invoice[:invoice_applied_tax] } + let(:paid_amount) { 0 } + + before { fee_and_invoice } + + def generate_invoice(fees_amount_cents:, coupons_amount_cents:, at:) + amount_after_coupons = fees_amount_cents - coupons_amount_cents + invoice_taxes_amount_cents = (amount_after_coupons * tax.rate / 100).round + sub_total_including_taxes_amount_cents = amount_after_coupons + invoice_taxes_amount_cents + total_amount_cents = sub_total_including_taxes_amount_cents + invoice = create( + :invoice, + organization:, + customer:, + currency: "EUR", + coupons_amount_cents:, + sub_total_excluding_taxes_amount_cents: amount_after_coupons, + sub_total_including_taxes_amount_cents: sub_total_including_taxes_amount_cents, + fees_amount_cents:, + total_amount_cents: total_amount_cents, + total_paid_amount_cents: paid_amount, + payment_status: ((paid_amount == total_amount_cents) ? :succeeded : :pending), + created_at: at + ) + create(:invoice_applied_tax, invoice:, tax:, tax_rate: tax.rate, amount_cents: invoice_taxes_amount_cents) + + invoice + end + + def generate_subscription_fee(invoice:, amount_cents:, coupons_amount_cents:, at:, plan_amount_cents:) + taxes_amount_cents = (amount_cents - coupons_amount_cents) * tax.rate / 100 + subscription_fee = create( + :fee, + subscription:, + invoice:, + amount_cents:, + taxes_amount_cents: taxes_amount_cents, + precise_amount_cents: amount_cents, + precise_coupons_amount_cents: coupons_amount_cents, + taxes_precise_amount_cents: taxes_amount_cents, + taxes_rate: tax.rate, + created_at: at, + **(plan_amount_cents ? {amount_details: {plan_amount_cents:}} : {}) + ) + create(:fee_applied_tax, tax:, fee: subscription_fee, amount_cents: taxes_amount_cents) + subscription_fee + end + + def generate_second_subscription_fee(invoice:, amount_cents:, at:) + second_subscription = create(:subscription, customer:, plan:, subscription_at:, started_at:, billing_time: :calendar) + second_taxes_amount_cents = (amount_cents * tax.rate / 100).round + second_subscription_fee = create(:fee, + subscription: second_subscription, + invoice:, + amount_cents: amount_cents, + taxes_amount_cents: second_taxes_amount_cents, + precise_amount_cents: amount_cents, + taxes_precise_amount_cents: second_taxes_amount_cents, + created_at: at) + create(:fee_applied_tax, tax:, fee: second_subscription_fee, amount_cents: second_taxes_amount_cents) + end + + def generate_invoice_and_fee(amount_cents, coupons_amount_cents: coupon_amount, at: started_at, plan_amount_cents: nil, with_second_subscription: false) + fees_amount_cents = with_second_subscription ? amount_cents * 2 : amount_cents + invoice = generate_invoice(fees_amount_cents: fees_amount_cents, coupons_amount_cents:, at:) + subscription_fee = generate_subscription_fee(invoice:, amount_cents:, coupons_amount_cents:, at:, plan_amount_cents:) + generate_second_subscription_fee(invoice:, amount_cents:, at:) if with_second_subscription + + { + subscription_fee:, + invoice: + } + end + + def expect_credit_note_to_be_properly_defined( + credit_note, + precise_item_amount_cents:, + total_amount_cents:, + tax_amount_cents:, + refund_amount_cents:, + credit_amount_cents:, + offset_amount_cents:, + fee: + ) + expect(credit_note).to be_available + expect(credit_note).to be_order_change + + expect(credit_note.total_amount_cents).to eq(total_amount_cents) + expect(credit_note.total_amount_currency).to eq("EUR") + expect(credit_note.refund_amount_cents).to eq(refund_amount_cents) + expect(credit_note.refund_amount_currency).to eq("EUR") + expect(credit_note.credit_amount_cents).to eq(credit_amount_cents) + expect(credit_note.credit_amount_currency).to eq("EUR") + expect(credit_note.offset_amount_cents).to eq(offset_amount_cents) + expect(credit_note.offset_amount_currency).to eq("EUR") + expect(credit_note.taxes_amount_cents).to eq(tax_amount_cents) + expect(credit_note.balance_amount_cents).to eq(credit_amount_cents) + expect(credit_note.balance_amount_currency).to eq("EUR") + expect(credit_note.applied_taxes.length).to eq(1) + expect(credit_note.applied_taxes.first.tax_code).to eq(tax.code) + + expect(credit_note.items.size).to eq(1) + + credit_note_item = credit_note.items.sole + expect(credit_note_item.fee).to eq(fee) + expect(credit_note_item.organization).to eq(organization) + expect(credit_note_item.amount_cents).to eq(precise_item_amount_cents.round) + expect(credit_note_item.precise_amount_cents).to eq(precise_item_amount_cents) + expect(credit_note_item.amount_currency).to eq("EUR") + end + + def test_credit_note_creation_from_termination(expectations:) + total_amount_cents = expectations.fetch(:total_amount_cents) + precise_item_amount_cents = expectations.fetch(:precise_item_amount_cents) + tax_amount_cents = expectations.fetch(:tax_amount_cents) + refund_amount_cents = expectations.fetch(:refund_amount_cents, 0) + credit_amount_cents = expectations.fetch(:credit_amount_cents, 0) + offset_amount_cents = expectations.fetch(:offset_amount_cents, 0) + fee = expectations.fetch(:fee, subscription_fee) + + refund_amount_cents ||= 0 + fee ||= subscription_fee + + result = create_service.call + + expect(result).to be_success + expect(result).to be_a(CreditNotes::CreateService::Result) + + credit_note = result.credit_note + + expect_credit_note_to_be_properly_defined( + credit_note, + total_amount_cents:, + precise_item_amount_cents:, + tax_amount_cents:, + refund_amount_cents:, + credit_amount_cents:, + offset_amount_cents:, + fee: + ) + + credit_note + end + + describe "#call" do + it "creates a credit note" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + credit_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20 + }) + end + + context "with amount details attached to the fee" do + let(:fee_and_invoice) { generate_invoice_and_fee(62_00, plan_amount_cents: 62_00) } + + it "creates a credit note based on the amount details" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €32.00 (16 × €2.00/day) + # ------ + # Subtotal €32.00 + # Tax (20%) €6.40 + # ------ + # Total creditable €38.40 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 38_40, + credit_amount_cents: 38_40, + precise_item_amount_cents: 32_00, + tax_amount_cents: 6_40 + }) + end + end + + context "when refund is requested" do + subject(:create_service) { described_class.new(subscription:, on_termination: :refund, context:) } + + let(:fee_and_invoice) { generate_invoice_and_fee(plan_amount_cents, with_second_subscription: true) } + + context "when invoice is fully paid" do + let(:paid_amount) { 7440 } + + it "creates a credit note with refund for full amount" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €74.40 + # Subscription portion (50%) €37.20 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €19.20 (€37.20 - €18.00) + # + # FINAL AMOUNTS + # Refund amount €19.20 + # Credit amount €0.00 (€19.20 - €19.20) + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 19_20, + credit_amount_cents: 0, + fee: subscription_fee + }) + end + end + + context "when invoice is partially paid" do + context "when payment covers the subscription fee" do + let(:paid_amount) { 55_80 } + + it "creates a credit note with refund and credit" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €55.80 + # Subscription portion (50%) €27.90 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €9.90 (€27.90 - €18.00) + # + # FINAL AMOUNTS + # Refund amount €9.90 + # Credit amount €9.30 (€19.20 - €9.90) + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 9_90, + credit_amount_cents: 9_30, + fee: subscription_fee + }) + end + end + + context "when payment does not cover the subscription fee" do + let(:paid_amount) { 9_30 } + + it "creates a credit note without refund" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €9.30 + # Subscription portion (50%) €4.65 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €0.00 (€4.65 - €18.00, min 0) + # + # FINAL AMOUNTS + # Refund amount €0.00 + # Credit amount €19.20 (€19.20 - €0.00) + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 0, + credit_amount_cents: 19_20, + fee: subscription_fee + }) + end + end + + context "when there are credit notes on the fee" do + let(:paid_amount) { 55_80 } + let(:credit_note) do + create(:credit_note, customer:, invoice:, credit_amount_cents: 2_00, taxes_amount_cents: 40, refund_amount_cents: 0) + end + let(:credit_note_item) do + create(:credit_note_item, credit_note:, + fee: subscription_fee, amount_cents: 2_00, precise_amount_cents: 2_00) + end + + before { credit_note_item } + + context "when there are no coupons" do + it "creates a credit note with refund and credit" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # Previous credit notes -€2.00 + # ------ + # Subtotal €14.00 + # Tax (20%) €2.80 + # ------ + # Total creditable €16.80 + # + # REFUND CALCULATION + # Invoice total paid €55.80 + # Subscription portion (50%) €27.90 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €9.90 (€27.90 - €18.00) + # + # FINAL AMOUNTS + # Refund amount €9.90 + # Credit amount €6.90 (€16.80 - €9.90) + # ------ + # Total credit note €16.80 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 16_80, + precise_item_amount_cents: 14_00, + tax_amount_cents: 2_80, + refund_amount_cents: 9_90, + credit_amount_cents: 6_90, + fee: subscription_fee + }) + end + end + + context "when there are coupons" do + let(:coupon_amount) { 5_00 } + let(:fee_and_invoice) do + generate_invoice_and_fee( + 31_00, + plan_amount_cents: 31_00, + coupons_amount_cents: coupon_amount, + with_second_subscription: true + ) + end + + it "creates a credit note with refund and credit" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # Previous credit notes -€2.00 + # ------ + # Subtotal €14.00 + # Coupons (14€/31€ * 5€) -€2.26 + # Tax (20%) €2.35 + # ------ + # Total creditable €14.09 + # + # REFUND CALCULATION + # Invoice total paid €55.80 + # Subscription portion (45.61%) €25.45 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Coupons (15€/31€ * 5€) -€2.42 + # Tax (20%) €2.52 + # ------ + # Total used €15.10 + # + # Available for refund €10.35 (€25.45 - €15.10) + # + # FINAL AMOUNTS + # Refund amount €10.35 + # Credit amount €3.74 (€14.09 - €10.35) + # ------ + # Total credit note €14.09 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 14_09, + precise_item_amount_cents: 14_00, + tax_amount_cents: 2_35, + refund_amount_cents: 10_35, + credit_amount_cents: 3_74, + fee: subscription_fee + }) + end + end + end + end + + context "when invoice has not been paid" do + let(:paid_amount) { 0 } + + it "creates a credit note with no refund amount" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice not paid €0.00 + # + # FINAL AMOUNTS + # Refund amount €0.00 + # Credit amount €19.20 + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 0, + credit_amount_cents: 19_20, + fee: subscription_fee + }) + end + end + + context "when it's an upgrade" do + subject(:create_service) { described_class.new(subscription:, on_termination: :refund, upgrade: true, context:) } + + it "raises NotImplementedError" do + expect { create_service.call }.to raise_error(NotImplementedError) + end + end + + context "when subscription has trial period" do + let(:trial_period) { 41 } + let(:paid_amount) { 19_20 } + let(:fee_and_invoice) { generate_invoice_and_fee(31_00) } + + it "accounts for trial period in refund calculation" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €19.20 + # + # Used subscription (4 days after trial) €4.00 + # ------ + # Used subtotal €4.00 + # Tax (20%) €0.80 + # ------ + # Total used €4.80 + # + # Available for refund €14.40 (€19.20 - €4.80) + # + # FINAL AMOUNTS + # Refund amount €14.40 + # Credit amount €4.80 (€19.20 - €14.40) + # ------ + # Total credit note €19.20 + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 14_40, + credit_amount_cents: 4_80, + fee: subscription_fee + }) + end + end + end + + context "when offset is requested" do + subject(:create_service) { described_class.new(subscription:, on_termination: :offset, context:) } + + let(:fee_and_invoice) { generate_invoice_and_fee(plan_amount_cents, with_second_subscription: true) } + + context "when invoice is fully paid" do + let(:paid_amount) { 7440 } + + it "creates a credit note without offset amount" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €74.40 + # Subscription portion (50%) €37.20 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €19.20 (€37.20 - €18.00) + # + # FINAL AMOUNTS + # Refund amount €19.20 + # Offset amount €0.00 (€19.20 - €19.20) + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 19_20, + offset_amount_cents: 0, + fee: subscription_fee + }) + end + end + + context "when invoice is partially paid" do + context "when payment covers the subscription fee" do + let(:paid_amount) { 55_80 } + + it "creates a credit note with refund and credit" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €55.80 + # Subscription portion (50%) €27.90 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €9.90 (€27.90 - €18.00) + # + # FINAL AMOUNTS + # Refund amount €9.90 + # Offset amount €9.30 (€19.20 - €9.90) + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 9_90, + offset_amount_cents: 9_30, + fee: subscription_fee + }) + end + end + + context "when payment does not cover the subscription fee" do + let(:paid_amount) { 9_30 } + + it "creates a credit note without refund" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €9.30 + # Subscription portion (50%) €4.65 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €0.00 (€4.65 - €18.00, min 0) + # + # FINAL AMOUNTS + # Refund amount €0.00 + # Offset amount €19.20 (€19.20 - €0.00) + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 0, + offset_amount_cents: 19_20, + fee: subscription_fee + }) + end + end + + context "when there are credit notes on the fee" do + let(:paid_amount) { 55_80 } + let(:credit_note) do + create(:credit_note, customer:, invoice:, credit_amount_cents: 2_00, taxes_amount_cents: 40, refund_amount_cents: 0) + end + let(:credit_note_item) do + create(:credit_note_item, credit_note:, + fee: subscription_fee, amount_cents: 2_00, precise_amount_cents: 2_00) + end + + before { credit_note_item } + + context "when there are no coupons" do + it "creates a credit note with refund and offset" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # Previous credit notes -€2.00 + # ------ + # Subtotal €14.00 + # Tax (20%) €2.80 + # ------ + # Total creditable €16.80 + # + # REFUND CALCULATION + # Invoice total paid €55.80 + # Subscription portion (50%) €27.90 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total used €18.00 + # + # Available for refund €9.90 (€27.90 - €18.00) + # + # FINAL AMOUNTS + # Refund amount €9.90 + # Offset amount €6.90 (€16.80 - €9.90) + # ------ + # Total credit note €16.80 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 16_80, + precise_item_amount_cents: 14_00, + tax_amount_cents: 2_80, + refund_amount_cents: 9_90, + offset_amount_cents: 6_90, + fee: subscription_fee + }) + end + end + + context "when there are coupons" do + let(:coupon_amount) { 5_00 } + let(:fee_and_invoice) do + generate_invoice_and_fee( + 31_00, + plan_amount_cents: 31_00, + coupons_amount_cents: coupon_amount, + with_second_subscription: true + ) + end + + it "creates a credit note with refund and offset" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # Previous credit notes -€2.00 + # ------ + # Subtotal €14.00 + # Coupons (14€/31€ * 5€) -€2.26 + # Tax (20%) €2.35 + # ------ + # Total creditable €14.09 + # + # REFUND CALCULATION + # Invoice total paid €55.80 + # Subscription portion (45.61%) €25.45 + # + # Used subscription (15 days) €15.00 + # ------ + # Used subtotal €15.00 + # Coupons (15€/31€ * 5€) -€2.42 + # Tax (20%) €2.52 + # ------ + # Total used €15.10 + # + # Available for refund €10.35 (€25.45 - €15.10) + # + # FINAL AMOUNTS + # Refund amount €10.35 + # Offset amount €3.74 (€14.09 - €10.35) + # ------ + # Total credit note €14.09 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 14_09, + precise_item_amount_cents: 14_00, + tax_amount_cents: 2_35, + refund_amount_cents: 10_35, + offset_amount_cents: 3_74, + fee: subscription_fee + }) + end + end + end + end + + context "when invoice has not been paid" do + let(:paid_amount) { 0 } + + it "creates a credit note with no refund amount" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice not paid €0.00 + # + # FINAL AMOUNTS + # Refund amount €0.00 + # Offset amount €19.20 + # ------ + # Total credit note €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 0, + offset_amount_cents: 19_20, + fee: subscription_fee + }) + end + end + + context "when it's an upgrade" do + subject(:create_service) { described_class.new(subscription:, on_termination: :offset, upgrade: true, context:) } + + it "raises NotImplementedError" do + expect { create_service.call }.to raise_error(NotImplementedError) + end + end + + context "when subscription has trial period" do + let(:trial_period) { 41 } + let(:paid_amount) { 19_20 } + let(:fee_and_invoice) { generate_invoice_and_fee(31_00) } + + it "accounts for trial period in refund and offset calculation" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + # + # REFUND CALCULATION + # Invoice total paid €19.20 + # + # Used subscription (4 days after trial) €4.00 + # ------ + # Used subtotal €4.00 + # Tax (20%) €0.80 + # ------ + # Total used €4.80 + # + # Available for refund €14.40 (€19.20 - €4.80) + # + # FINAL AMOUNTS + # Refund amount €14.40 + # Offset amount €4.80 (€19.20 - €14.40) + # ------ + # Total credit note €19.20 + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + refund_amount_cents: 14_40, + offset_amount_cents: 4_80, + fee: subscription_fee + }) + end + end + end + + context "when invoice is voided" do + before { invoice.void! } + + it "does not create a credit note" do + expect { create_service.call }.not_to change(CreditNote, :count) + end + end + + context "when fee amount is zero" do + let(:plan_amount_cents) { 0 } + + it "does not create a credit note" do + expect do + expect(create_service.call.credit_note).to be_nil + end.not_to change(CreditNote, :count) + end + end + + context "when multiple fees" do + let(:fee_and_invoice_2) do + generate_invoice_and_fee(62_00, at: Time.zone.parse("2022-10-01 10:00"), plan_amount_cents: 62_00) + end + let(:invoice_2) { fee_and_invoice_2[:invoice] } + let(:subscription_fee_2) { fee_and_invoice_2[:subscription_fee] } + + before { fee_and_invoice_2 } + + it "takes the last fee as reference" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €32.00 (16 × €2.00/day) + # ------ + # Subtotal €32.00 + # Tax (20%) €6.40 + # ------ + # Total creditable €38.40 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 38_40, + credit_amount_cents: 38_40, + precise_item_amount_cents: 32_00, + tax_amount_cents: 6_40, + fee: subscription_fee_2 + }) + end + end + + context "when existing credit notes on the fee" do + let(:credit_note) do + create( + :credit_note, + customer: subscription.customer, + invoice: subscription_fee.invoice, + credit_amount_cents: 10_00, + taxes_amount_cents: 2_00 + ) + end + let(:credit_note_item) do + create(:credit_note_item, credit_note:, + fee: subscription_fee, amount_cents: 10_00, precise_amount_cents: 10_00) + end + + before { credit_note_item } + + it "takes the remaining creditable amount" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # Previous credit notes -€10.00 + # ------ + # Subtotal €6.00 + # Tax (20%) €1.20 + # ------ + # Total creditable €7.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 7_20, + credit_amount_cents: 7_20, + precise_item_amount_cents: 6_00, + tax_amount_cents: 1_20 + }) + end + end + + context "when plan has trial period ending after terminated_at" do + let(:trial_period) { 46 } + + it "excludes the trial from the credit amount" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (15 days) €15.00 (excluding trial) + # ------ + # Subtotal €15.00 + # Tax (20%) €3.00 + # ------ + # Total creditable €18.00 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 18_00, + credit_amount_cents: 18_00, + precise_item_amount_cents: 15_00, + tax_amount_cents: 3_00 + }) + end + + context "when trial ends after the end of the billing period" do + let(:trial_period) { 120 } + + it "does not creates a credit note" do + expect { create_service.call }.not_to change(CreditNote, :count) + end + end + end + + context "when plan has been upgraded" do + let(:kwargs) { {upgrade: true} } + + it "calculates credit note correctly" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (17 days) €17.00 (upgrade calculation) + # ------ + # Subtotal €17.00 + # Tax (20%) €3.40 + # ------ + # Total creditable €20.40 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 20_40, + credit_amount_cents: 20_40, + precise_item_amount_cents: 17_00, + tax_amount_cents: 3_40 + }) + end + end + + context "with a different timezone" do + let(:started_at) { Time.zone.parse("2022-09-01 12:00") } + let(:terminated_at) { Time.zone.parse("2022-10-15 01:00") } + + context "when timezone shift is UTC -" do + let(:customer_timezone) { "America/Los_Angeles" } + + it "takes the timezone into account" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (17 days) €17.00 (timezone adjusted) + # ------ + # Subtotal €17.00 + # Tax (20%) €3.40 + # ------ + # Total creditable €20.40 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 20_40, + credit_amount_cents: 20_40, + precise_item_amount_cents: 17_00, + tax_amount_cents: 3_40 + }) + end + end + + context "when timezone shift is UTC +" do + let(:customer_timezone) { "Europe/Paris" } + + it "takes the timezone into account" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 (timezone adjusted) + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 19_20, + credit_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20 + }) + end + end + end + + context "with rounding at max precision" do + let(:started_at) { Time.zone.parse("2023-01-30 10:00") } + let(:subscription_at) { Time.zone.parse("2023-01-30 10:00") } + let(:terminated_at) { Time.zone.parse("2023-03-14 10:00") } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + status: :terminated, + subscription_at:, + started_at:, + terminated_at:, + billing_time: :anniversary + ) + end + + let(:plan_amount_cents) { 99_9 } + let(:tax_rate) { 0 } + + it "creates a credit note" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (15 days) €4.995 (15/30 × €9.99) + # ------ + # Subtotal €4.995 + # Tax (0%) €0.00 + # ------ + # Total creditable €4.99 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 4_99, + credit_amount_cents: 4_99, + precise_item_amount_cents: 4_99.49999, + tax_amount_cents: 0 + }) + end + end + + context "when coupon covers the entire fee amount" do + let(:coupon_amount) { plan_amount_cents } + + it "does not create a credit note" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # Coupon allocation (16/31) -€16.00 + # ------ + # Subtotal €0.00 + # Tax (20%) €0.00 + # ------ + # Total creditable €0.00 + # + # The coupon adjustment equals the full credit amount, + # resulting in zero amounts. Without the guard clause, + # this would raise an error in CreateService. + + result = create_service.call + + expect(result).to be_success + expect(result.credit_note).to be_nil + end + end + + context "with a coupon applied to the invoice" do + let(:coupon_amount) { 10_00 } + + it "takes the coupon into account" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # Coupon allocation (16/31) -€5.16 + # ------ + # Subtotal €10.84 + # Tax (20%) €2.17 + # ------ + # Total creditable €13.01 + + test_credit_note_creation_from_termination(expectations: { + total_amount_cents: 13_01, + credit_amount_cents: 13_01, + precise_item_amount_cents: 16_00, + tax_amount_cents: 2_17 + }) + end + end + + context "when 'preview' context provided" do + let(:context) { :preview } + + it "builds a credit note" do + # CREDITABLE AMOUNT CALCULATION + # Unused subscription (16 days) €16.00 + # ------ + # Subtotal €16.00 + # Tax (20%) €3.20 + # ------ + # Total creditable €19.20 + + credit_note = test_credit_note_creation_from_termination( + expectations: { + total_amount_cents: 19_20, + credit_amount_cents: 19_20, + precise_item_amount_cents: 16_00, + tax_amount_cents: 3_20, + fee: subscription_fee + } + ) + + expect(credit_note).to be_a(CreditNote).and be_new_record + expect(credit_note.items).to all be_new_record + end + + it "does not persist any credit note" do + expect { create_service.call }.not_to change(CreditNote, :count) + end + + it "does not persist any credit note item" do + expect { create_service.call }.not_to change(CreditNoteItem, :count) + end + end + end +end diff --git a/spec/services/credit_notes/create_service_spec.rb b/spec/services/credit_notes/create_service_spec.rb new file mode 100644 index 0000000..772ef3e --- /dev/null +++ b/spec/services/credit_notes/create_service_spec.rb @@ -0,0 +1,983 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::CreateService do + subject(:create_service) do + described_class.new( + invoice:, + items:, + description: nil, + credit_amount_cents:, + refund_amount_cents:, + automatic:, + context:, + **args + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:args) { {} } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + currency: "EUR", + total_amount_cents: 24, + total_paid_amount_cents: 6, + payment_status: :succeeded, + taxes_rate: 20, + version_number: 2 + ) + end + + let(:tax) { create(:tax, organization:, rate: 20) } + + let(:automatic) { true } + let(:context) { nil } + let(:fee1) { create(:fee, invoice:, amount_cents: 10, taxes_amount_cents: 1, taxes_rate: 20) } + let(:fee2) { create(:fee, invoice:, amount_cents: 10, taxes_amount_cents: 1, taxes_rate: 20) } + let(:credit_amount_cents) { 12 } + let(:refund_amount_cents) { 6 } + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 10 + }, + { + fee_id: fee2.id, + amount_cents: 5 + } + ] + end + + before do + create(:fee_applied_tax, tax:, fee: fee1) + create(:fee_applied_tax, tax:, fee: fee2) if fee2 + create(:invoice_applied_tax, tax:, invoice:) if invoice + end + + describe "#call" do + subject(:result) { create_service.call } + + let(:credit_note) { subject.credit_note } + + it "creates a credit note" do + result = create_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note.invoice).to eq(invoice) + expect(credit_note.customer).to eq(invoice.customer) + expect(credit_note.issuing_date.to_s).to eq(Time.zone.today.to_s) + + expect(credit_note.coupons_adjustment_amount_cents).to eq(0) + expect(credit_note.taxes_amount_cents).to eq(3) + expect(credit_note.taxes_rate).to eq(20) + expect(credit_note.applied_taxes.count).to eq(1) + + expect(credit_note.total_amount_currency).to eq(invoice.currency) + expect(credit_note.total_amount_cents).to eq(18) + + expect(credit_note.credit_amount_currency).to eq(invoice.currency) + expect(credit_note.credit_amount_cents).to eq(12) + expect(credit_note.balance_amount_currency).to eq(invoice.currency) + expect(credit_note.balance_amount_cents).to eq(12) + expect(credit_note.credit_status).to eq("available") + + expect(credit_note.refund_amount_currency).to eq(invoice.currency) + expect(credit_note.refund_amount_cents).to eq(6) + expect(credit_note.refund_status).to eq("pending") + + expect(credit_note).to be_other + + expect(credit_note.items.count).to eq(2) + item1 = credit_note.items.order(created_at: :asc).first + expect(item1.fee).to eq(fee1) + expect(item1.amount_cents).to eq(10) + expect(item1.amount_currency).to eq(invoice.currency) + + item2 = credit_note.items.order(created_at: :asc).last + expect(item2.fee).to eq(fee2) + expect(item2.amount_cents).to eq(5) + expect(item2.amount_currency).to eq(invoice.currency) + end + + it "creates a credit note without metadata" do + result = create_service.call + + expect(result).to be_success + expect(result.credit_note.reload.metadata).to be_nil + expect(Metadata::ItemMetadata.count).to eq(0) + end + + context "with metadata" do + let(:args) { {metadata: {"key1" => "value1", "key2" => "value2"}} } + + it "creates a credit note with metadata" do + result = create_service.call + + expect(result).to be_success + + credit_note = result.credit_note.reload + expect(credit_note.metadata).to be_present + expect(credit_note.metadata.value).to eq({"key1" => "value1", "key2" => "value2"}) + expect(credit_note.metadata.organization_id).to eq(organization.id) + expect(credit_note.metadata.owner).to eq(credit_note) + end + + it "creates ItemMetadata record" do + expect { create_service.call }.to change(Metadata::ItemMetadata, :count).by(1) + end + end + + it "enqueues SegmentTrackJob after commit" do + expect { subject }.to have_enqueued_job_after_commit(SegmentTrackJob).with do |params| + expect(params).to match(membership_id: CurrentContext.membership, + event: "credit_note_issued", + properties: { + organization_id: organization.id, + credit_note_id: credit_note.id, + invoice_id: invoice.id, + credit_note_method: "both" + }) + end + end + + it "delivers a webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with do |event, job_credit_note| + expect(event).to eq("credit_note.created") + expect(job_credit_note).to eq(credit_note) + end + end + + it "enqueues a CreditNotes::GenerateDocumentsJob after commit" do + expect { subject }.to have_enqueued_job_after_commit(CreditNotes::GenerateDocumentsJob).with do |job_credit_note| + expect(job_credit_note).to eq(credit_note) + end + end + + it "delivers an email after commit" do + expect { subject }.to have_enqueued_job_after_commit(SendEmailJob).with do |email, job_credit_note| + expect(email).to eq(credit_note.billing_entity.email) + expect(job_credit_note).to eq(credit_note) + end + end + + it "produces an activity log" do + result = create_service.call + + expect(Utils::ActivityLog).to have_produced("credit_note.created").with(result.credit_note) + end + + it_behaves_like "syncs credit note" do + let(:service_call) { subject } + end + + context "when customer has tax_provider set up" do + let(:customer) { create(:customer, :with_tax_integration, organization:) } + + it "sync with tax provider after commit" do + expect { subject }.to have_enqueued_job_after_commit(CreditNotes::ProviderTaxes::ReportJob).with do |**kwargs| + expect(kwargs[:credit_note]).to eq(credit_note) + end + end + end + + context "when billing_entity does not have right email settings" do + before { invoice.billing_entity.update!(email_settings: []) } + + it "does not enqueue an SendEmailJob" do + expect { subject }.not_to have_enqueued_job(SendEmailJob) + end + end + + context "with invalid items" do + let(:credit_amount_cents) { 10 } + let(:refund_amount_cents) { 15 } + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 10 + }, + { + fee_id: fee2.id, + amount_cents: 15 + } + ] + end + + it "returns a failed result" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:amount_cents) + expect(result.error.messages[:amount_cents]).to eq( + %w[ + higher_than_remaining_fee_amount + ] + ) + end + end + + context "when items are missing" do + let(:items) {} + + it "returns a failed result" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:items) + expect(result.error.messages[:items]).to eq( + %w[ + must_be_an_array + ] + ) + end + end + + context "with a refund, a payment and a succeeded invoice" do + let(:payment) { create(:payment, payable: invoice) } + + before { payment } + + it "enqueues a refund job after commit" do + expect { subject }.to have_enqueued_job_after_commit(CreditNotes::Refunds::StripeCreateJob).with do |job_credit_note| + expect(job_credit_note).to eq(credit_note) + end + end + + context "when Gocardless provider" do + let(:gocardless_provider) { create(:gocardless_provider) } + let(:gocardless_customer) { create(:gocardless_customer) } + let(:payment) do + create( + :payment, + payable: invoice, + payment_provider: gocardless_provider, + payment_provider_customer: gocardless_customer + ) + end + + it "enqueues a refund job after commit" do + expect { subject }.to have_enqueued_job_after_commit(CreditNotes::Refunds::GocardlessCreateJob).with do |job_credit_note| + expect(job_credit_note).to eq(credit_note) + end + end + end + + context "when credit note does not have refund amount" do + let(:credit_amount_cents) { 15 } + let(:refund_amount_cents) { 0 } + + it "does not enqueue a refund job" do + expect { subject }.not_to have_enqueued_job(CreditNotes::Refunds::StripeCreateJob) + end + end + end + + context "with customer timezone" do + before { invoice.customer.update!(timezone: "America/Los_Angeles") } + + let(:timestamp) { DateTime.parse("2022-11-25 01:00:00").to_i } + + it "assigns the issuing date in the customer timezone" do + travel_to(DateTime.parse("2022-11-25 01:00:00")) do + result = create_service.call + + expect(result.credit_note.issuing_date.to_s).to eq("2022-11-24") + end + end + end + + context "when invoice is not found" do + let(:invoice) { nil } + let(:items) { [] } + + it "returns a failure" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("invoice_not_found") + end + end + + context "when invoice is not automatic" do + let(:automatic) { false } + let(:credit_amount_cents) { 18 } + let(:refund_amount_cents) { 0 } + + it "returns a failure" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + + context "with a valid license", :premium do + it "returns a success" do + result = create_service.call + expect(result).to be_success + end + + context "when invoice is draft" do + let(:invoice) do + create( + :invoice, + :draft, + organization:, + customer:, + currency: "EUR", + fees_amount_cents: 20, + total_amount_cents: 24, + payment_status: :succeeded, + taxes_rate: 20 + ) + end + + it "creates a draft credit note" do + result = create_service.call + + expect(result).to be_success + expect(result.credit_note).to be_draft + end + + it "does not deliver a webhook" do + create_service.call + expect(SendWebhookJob).not_to have_been_enqueued.with("credit_note.created", CreditNote) + end + + it "does not call SegmentTrackJob" do + expect { subject }.not_to have_enqueued_job(SegmentTrackJob) + end + end + + context "when invoice is legacy" do + let(:invoice) do + create( + :invoice, + currency: "EUR", + sub_total_excluding_taxes_amount_cents: 20, + total_amount_cents: 24, + payment_status: :succeeded, + taxes_rate: 20, + version_number: 1 + ) + end + + it "returns a failure" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_type_or_status") + end + end + end + end + + context "when invoice is v3 with coupons" do + let(:invoice) do + create( + :invoice, + organization:, + customer:, + currency: "EUR", + fees_amount_cents: 20, + coupons_amount_cents: 10, + taxes_amount_cents: 2, + total_amount_cents: 12, + total_paid_amount_cents: 12, + payment_status: :succeeded, + taxes_rate: 20, + version_number: 3 + ) + end + + let(:fee1) do + create(:fee, invoice:, amount_cents: 10, taxes_amount_cents: 1, taxes_rate: 20, precise_coupons_amount_cents: 5) + end + + let(:fee2) do + create(:fee, invoice:, amount_cents: 10, taxes_amount_cents: 1, taxes_rate: 20, precise_coupons_amount_cents: 5) + end + + let(:credit_amount_cents) { 6 } + let(:refund_amount_cents) { 3 } + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 10 + }, + { + fee_id: fee2.id, + amount_cents: 5 + } + ] + end + + it "takes coupons amount into account" do + result = create_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note).to have_attributes( + invoice:, + customer: invoice.customer, + currency: invoice.currency, + credit_status: "available", + refund_status: "pending", + reason: "other", + sub_total_excluding_taxes_amount_cents: 8, + total_amount_cents: 10, + credit_amount_cents: 7, + refund_amount_cents: 3, + balance_amount_cents: 7, + coupons_adjustment_amount_cents: 8, + taxes_amount_cents: 2, + taxes_rate: 20 + ) + expect(credit_note.items.sum(:amount_cents)).to eq(credit_note.items.sum(:precise_amount_cents)) + expect(credit_note.applied_taxes.count).to eq(1) + + expect(credit_note.items.count).to eq(2) + + item1 = credit_note.items.order(created_at: :asc).first + expect(item1).to have_attributes( + fee: fee1, + amount_cents: 10, + amount_currency: invoice.currency, + precise_amount_cents: 10 + ) + + item2 = credit_note.items.order(created_at: :asc).last + expect(item2).to have_attributes( + fee: fee2, + amount_cents: 5, + amount_currency: invoice.currency + ) + end + end + + context "when invoice is credit", :premium do + let(:invoice) do + create( + :invoice, + :credit, + organization:, + customer:, + currency: "EUR", + fees_amount_cents: 1000, + total_amount_cents: 1200, + total_paid_amount_cents: 1200, + payment_status: :succeeded + ) + end + let(:wallet) { create(:wallet, customer:, balance_cents: 1200, rate_amount:, credits_balance: 1, traceable: false) } + let(:rate_amount) { 10 } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + let(:fee1) { create(:credit_fee, invoice:, invoiceable: wallet_transaction, amount_cents: 1000, taxes_rate: 20) } + let(:fee2) { nil } + let(:credit_amount_cents) { 0 } + let(:refund_amount_cents) { 1200 } + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 1000 + } + ] + end + let(:automatic) { false } + + before do + wallet + end + + it "creates credit note and voids corresponding amount of credits from the wallet" do + result = create_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note.refund_amount_cents).to eq(1200) + wallet_transaction = wallet.wallet_transactions.order(:created_at).last + # we're refunding 12_00 cents -> 12 euros, the rate of the wallet is 10, 1 credit = 10 euros, so credit amount in the transaction is 1.2, while the money amount is 12 + expect(wallet_transaction.credit_amount).to eq(1.2) + expect(wallet_transaction.amount).to eq(12) + expect(wallet_transaction.transaction_status).to eq("voided") + expect(wallet_transaction.transaction_type).to eq("outbound") + expect(wallet.reload.balance_cents).to eq(0) + end + + context "with different rate amount" do + let(:rate_amount) { 20 } + + it "calculates correct credits amount" do + result = create_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note.refund_amount_cents).to eq(1200) + wallet_transaction = wallet.wallet_transactions.order(:created_at).last + expect(wallet_transaction.credit_amount).to eq(0.6) + expect(wallet_transaction.amount).to eq(12) + end + end + + context "when wallet is traceable" do + let(:wallet) do + create(:wallet, + customer:, + balance_cents: 1500, + rate_amount:, + credits_balance: 1.5, + traceable: true) + end + let(:wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 12, + credit_amount: 1.2, + remaining_amount_cents: 1200) + end + let!(:other_inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 5, + credit_amount: 0.5, + remaining_amount_cents: 500, + priority: 1) + end + + it "consumes from the specific inbound transaction linked to the invoice" do + result = create_service.call + + expect(result).to be_success + + outbound_transaction = wallet.wallet_transactions.outbound.order(:created_at).last + expect(outbound_transaction).to be_present + + consumption = outbound_transaction.fundings.first + expect(consumption.inbound_wallet_transaction).to eq(wallet_transaction) + expect(consumption.consumed_amount_cents).to eq(1200) + + expect(wallet_transaction.reload.remaining_amount_cents).to eq(0) + expect(other_inbound_transaction.reload.remaining_amount_cents).to eq(500) + end + end + + context "when wallet is terminated" do + let(:wallet) { create :wallet, customer:, balance_cents: 1000, rate_amount:, status: :terminated } + + it "returns error" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_type_or_status") + end + end + + context "when associated wallet balance is less than requested sum" do + let(:wallet) { create :wallet, customer:, balance_cents: 500, rate_amount: } + + it "returns error" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:amount_cents) + end + end + + context "when creating credit_note with credit amount" do + let(:credit_amount_cents) { 10 } + + it "returns error" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:credit_amount_cents) + end + end + end + + context "when invoice is voided" do + let(:invoice) do + create( + :invoice, + organization:, + customer:, + currency: "EUR", + fees_amount_cents: 1000, + total_amount_cents: 1000, + total_paid_amount_cents: 1000, + payment_status: :succeeded, + status: :voided + ) + end + + it "creates a credit note with finalized status instead of voided" do + result = create_service.call + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note.invoice).to eq(invoice) + expect(credit_note.status).to eq("finalized") + expect(invoice.status).to eq("voided") + end + end + + context "when 'preview' context provided" do + let(:context) { :preview } + + it "builds a credit note" do + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note).to be_a(CreditNote).and be_new_record + expect(credit_note.invoice).to eq(invoice) + expect(credit_note.customer).to eq(invoice.customer) + expect(credit_note.issuing_date.to_s).to eq(Time.zone.today.to_s) + + expect(credit_note.coupons_adjustment_amount_cents).to eq(0) + expect(credit_note.taxes_amount_cents).to eq(3) + expect(credit_note.taxes_rate).to eq(20) + expect(credit_note.applied_taxes.size).to eq(1) + + expect(credit_note.total_amount_currency).to eq(invoice.currency) + expect(credit_note.total_amount_cents).to eq(18) + + expect(credit_note.credit_amount_currency).to eq(invoice.currency) + expect(credit_note.credit_amount_cents).to eq(12) + expect(credit_note.balance_amount_currency).to eq(invoice.currency) + expect(credit_note.balance_amount_cents).to eq(12) + expect(credit_note.credit_status).to eq("available") + + expect(credit_note.refund_amount_currency).to eq(invoice.currency) + expect(credit_note.refund_amount_cents).to eq(6) + expect(credit_note.refund_status).to eq("pending") + + expect(credit_note).to be_other + + expect(credit_note.items.size).to eq(2) + expect(credit_note.items).to all be_new_record + + item1 = credit_note.items.first + expect(item1.fee).to eq(fee1) + expect(item1.amount_cents).to eq(10) + expect(item1.amount_currency).to eq(invoice.currency) + + item2 = credit_note.items.second + expect(item2.fee).to eq(fee2) + expect(item2.amount_cents).to eq(5) + expect(item2.amount_currency).to eq(invoice.currency) + end + + it "does not persist any credit note" do + expect { subject }.not_to change(CreditNote, :count) + end + + it "does not persist any credit note item" do + expect { subject }.not_to change(CreditNoteItem, :count) + end + + it "does not call SegmentTrackJob" do + expect { subject }.not_to have_enqueued_job(SegmentTrackJob) + end + + it "does not deliver a webhook" do + subject + + expect(SendWebhookJob).not_to have_been_enqueued.with("credit_note.created", CreditNote) + expect(CreditNotes::GenerateDocumentsJob).not_to have_been_enqueued + end + + it "does not send an email" do + expect { subject }.not_to have_enqueued_job(SendEmailJob) + end + + it "does not persist any metadata" do + expect { subject }.not_to change(Metadata::ItemMetadata, :count) + end + + context "with metadata" do + let(:args) { {metadata: {"key1" => "value1"}} } + + it "does not persist metadata" do + expect { subject }.not_to change(Metadata::ItemMetadata, :count) + end + + it "builds metadata as new record" do + result = create_service.call + + expect(result).to be_success + expect(result.credit_note.metadata).to be_present + expect(result.credit_note.metadata).to be_new_record + expect(result.credit_note.metadata.value).to eq({"key1" => "value1"}) + end + end + end + + context "when total amount is zero" do + let(:credit_amount_cents) { 0 } + let(:refund_amount_cents) { 0 } + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 0 + }, + { + fee_id: fee2.id, + amount_cents: 0 + } + ] + end + + it "returns a failure" do + result = create_service.call + + expect(result).not_to be_success + expect(CreditNote.count).to eq(0) + + expect(result.error.messages).to eq(base: ["total_amount_must_be_positive"]) + end + end + + context "when reason is invalid" do + let(:args) { {reason: "invalid"} } + + it "returns a failure" do + result = create_service.call + + expect(result).not_to be_success + expect(CreditNote.count).to eq(0) + + expect(result.error.messages).to eq(reason: ["value_is_invalid"]) + end + end + + context "with refund only adjustements" do + let(:tax) { create(:tax, organization:, rate: 25) } + + let(:invoice) do + create( + :invoice, + total_amount_cents: 25000, + taxes_amount_cents: 5000, + fees_amount_cents: 20000, + total_paid_amount_cents: 25000, + taxes_rate: 25, + payment_status: :succeeded + ) + end + + let(:fee1) do + create( + :fee, + invoice:, + amount_cents: 20000, + taxes_rate: 25 + ) + end + + let(:fee2) { nil } + + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 19333.33 + } + ] + end + + let(:refund_amount_cents) { 24166 } + let(:credit_amount_cents) { 0 } + + it "estimates the credit note" do + result = create_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note).to have_attributes( + currency: invoice.currency, + sub_total_excluding_taxes_amount_cents: 19333, + credit_amount_cents: 0, + refund_amount_cents: 24166, + coupons_adjustment_amount_cents: 0, + taxes_amount_cents: 4833, + taxes_rate: 25 + ) + end + end + + context "with offset_amount_cents" do + let(:credit_amount_cents) { 0 } + let(:refund_amount_cents) { 0 } + let(:args) { {offset_amount_cents: 18} } + + it "creates credit note with offset amount and invoice settlement" do + result = nil + expect { result = create_service.call }.to change(InvoiceSettlement, :count).by(1) + + expect(result).to be_success + expect(result.credit_note.offset_amount_cents).to eq(18) + expect(result.credit_note.total_amount_cents).to eq(18) + + invoice_settlement = InvoiceSettlement.last + expect(invoice_settlement.amount_cents).to eq(18) + expect(invoice_settlement.settlement_type).to eq("credit_note") + expect(invoice_settlement.target_invoice).to eq(invoice) + end + + it "does not create invoice settlement when offset is zero" do + create_service_with_args = described_class.new( + invoice:, items:, reason: "other", + credit_amount_cents: 10, refund_amount_cents: 0, offset_amount_cents: 0 + ) + expect { create_service_with_args.call }.not_to change(InvoiceSettlement, :count) + end + + it "does not create invoice settlement in preview mode" do + preview_service = described_class.new( + invoice:, items:, reason: "other", + credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 18, context: :preview + ) + expect { preview_service.call }.not_to change(InvoiceSettlement, :count) + end + end + + context "with credit invoices", :premium do + let(:wallet) { create(:wallet, customer:, balance_cents: 1000) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + let(:fee1) { create(:credit_fee, invoice:, invoiceable: wallet_transaction, amount_cents: 1000, taxes_rate: 20) } + let(:fee2) { nil } + let(:items) { [{fee_id: fee1.id, amount_cents: 1000}] } + let(:automatic) { false } + + before { wallet } + + context "when payment is pending" do + let(:invoice) do + create(:invoice, :credit, organization:, customer:, currency: "EUR", + fees_amount_cents: 1000, total_amount_cents: 1200, payment_status: :pending) + end + + it "allows offset_amount_cents only" do + service = described_class.new( + invoice:, items: [{fee_id: fee1.id, amount_cents: 1000}], + reason: "other", + credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 1200 + ) + result = service.call + + expect(result).to be_success + expect(result.credit_note.offset_amount_cents).to eq(1200) + expect(result.credit_note.credit_amount_cents).to eq(0) + expect(result.credit_note.refund_amount_cents).to eq(0) + end + + it "rejects credit_amount_cents" do + service = described_class.new( + invoice:, items:, reason: "other", + credit_amount_cents: 500, refund_amount_cents: 0 + ) + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_type_or_status") + end + + it "rejects refund_amount_cents" do + service = described_class.new( + invoice:, items:, reason: "other", + credit_amount_cents: 0, refund_amount_cents: 500 + ) + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_type_or_status") + end + end + + context "when payment failed" do + let(:invoice) do + create(:invoice, :credit, organization:, customer:, currency: "EUR", + fees_amount_cents: 1000, total_amount_cents: 1200, payment_status: :failed) + end + + it "allows offset_amount_cents only" do + service = described_class.new( + invoice:, items: [{fee_id: fee1.id, amount_cents: 1000}], + reason: "other", + credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 1200 + ) + result = service.call + + expect(result).to be_success + expect(result.credit_note.offset_amount_cents).to eq(1200) + expect(result.credit_note.credit_amount_cents).to eq(0) + expect(result.credit_note.refund_amount_cents).to eq(0) + end + + it "rejects credit_amount_cents" do + service = described_class.new( + invoice:, items:, reason: "other", + credit_amount_cents: 500, refund_amount_cents: 0 + ) + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_type_or_status") + end + + it "rejects refund_amount_cents" do + service = described_class.new( + invoice:, items:, reason: "other", + credit_amount_cents: 0, refund_amount_cents: 500 + ) + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_type_or_status") + end + end + end + end +end diff --git a/spec/services/credit_notes/estimate_service_spec.rb b/spec/services/credit_notes/estimate_service_spec.rb new file mode 100644 index 0000000..aae5ede --- /dev/null +++ b/spec/services/credit_notes/estimate_service_spec.rb @@ -0,0 +1,325 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::EstimateService, :premium do + subject(:estimate_service) { described_class.new(invoice: invoice&.reload, items:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + currency: "EUR", + fees_amount_cents: 20, + coupons_amount_cents: 10, + taxes_amount_cents: 2, + total_amount_cents: 12, + payment_status: :succeeded, + taxes_rate: 20, + version_number: 3 + ) + end + let(:tax) { create(:tax, organization:, rate: 20) } + + let(:params) { {invoice_id: invoice&.id, amount_cents: 9, reference: "ref1"} } + + context "when invoice is subscription" do + let(:fee1) do + create(:fee, invoice:, amount_cents: 10, taxes_amount_cents: 1, taxes_rate: 20, precise_coupons_amount_cents: 5) + end + + let(:fee2) do + create(:fee, invoice:, amount_cents: 10, taxes_amount_cents: 1, taxes_rate: 20, precise_coupons_amount_cents: 5) + end + + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 10 + }, + { + fee_id: fee2.id, + amount_cents: 5 + } + ] + end + + before do + create(:fee_applied_tax, tax:, fee: fee1) + create(:fee_applied_tax, tax:, fee: fee2) if fee2 + create(:invoice_applied_tax, tax:, invoice:) if invoice + Payments::ManualCreateService.call(organization:, params:) + end + + it "estimates the credit and refund amount" do + result = estimate_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note).to have_attributes( + invoice:, + customer:, + currency: invoice.currency, + sub_total_excluding_taxes_amount_cents: 8, + credit_amount_cents: 9, + refund_amount_cents: 9, + coupons_adjustment_amount_cents: 8, + taxes_amount_cents: 1, + total_amount_cents: 9, + taxes_rate: 20 + ) + + expect(credit_note.applied_taxes.size).to eq(1) + + expect(credit_note.items.size).to eq(2) + + item1 = credit_note.items.first + expect(item1).to have_attributes( + fee: fee1, + amount_cents: 10, + amount_currency: invoice.currency + ) + + item2 = credit_note.items.last + expect(item2).to have_attributes( + fee: fee2, + amount_cents: 5, + amount_currency: invoice.currency + ) + end + + context "with invalid items" do + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 10 + }, + { + fee_id: fee2.id, + amount_cents: 15 + } + ] + end + + it "returns a failed result" do + result = estimate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:amount_cents) + expect(result.error.messages[:amount_cents]).to eq( + %w[ + higher_than_remaining_fee_amount + ] + ) + end + end + + context "with missing items" do + let(:items) {} + + it "returns a failed result" do + result = estimate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:items) + expect(result.error.messages[:items]).to eq( + %w[ + must_be_an_array + ] + ) + end + end + + context "when invoice is legacy" do + let(:invoice) do + create( + :invoice, + currency: "EUR", + sub_total_excluding_taxes_amount_cents: 20, + total_amount_cents: 24, + payment_status: :succeeded, + taxes_rate: 20, + version_number: 1 + ) + end + + it "returns a failure" do + result = estimate_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_type_or_status") + end + end + + context "with rounding adjustment" do + let(:tax) { create(:tax, organization:, rate: 25) } + + let(:invoice) do + create( + :invoice, + total_amount_cents: 25000, + taxes_amount_cents: 5000, + fees_amount_cents: 20000, + total_paid_amount_cents: 25000, + taxes_rate: 25, + payment_status: :succeeded + ) + end + + let(:fee1) do + create( + :fee, + invoice:, + amount_cents: 20000, + taxes_rate: 25 + ) + end + + let(:fee2) { nil } + + let(:items) do + [ + { + fee_id: fee1.id, + amount_cents: 19333 + } + ] + end + + let(:params) { {invoice_id: invoice&.id, amount_cents: 25000, reference: "ref1"} } + + it "estimates the credit note" do + result = estimate_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note).to have_attributes( + currency: invoice.currency, + sub_total_excluding_taxes_amount_cents: 19333, + credit_amount_cents: 24166, + refund_amount_cents: 24166, + coupons_adjustment_amount_cents: 0, + taxes_amount_cents: 4833, + taxes_rate: 25 + ) + end + end + end + + context "when invoice is not found" do + let(:invoice) { nil } + let(:items) { [] } + + it "returns a failure" do + result = estimate_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("invoice_not_found") + end + end + + context "when invoice is a prepaid credit invoice" do + let(:invoice) do + create( + :invoice, + invoice_type: :credit, + organization:, + customer:, + currency: "EUR", + fees_amount_cents: 200, + total_amount_cents: 200, + total_paid_amount_cents: 200, + payment_status: :succeeded, + version_number: 3 + ) + end + let(:wallet) { create(:wallet, customer:, balance_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + let(:credit_fee) { create(:credit_fee, invoice:, invoiceable: wallet_transaction) } + let(:params) { {invoice_id: invoice.id, amount_cents: 12, reference: "ref2"} } + + let(:items) do + [ + { + fee_id: credit_fee.id, + amount_cents: 50 + } + ] + end + + before do + credit_fee + Payments::ManualCreateService.call(organization:, params:) + end + + context "when wallet for the credits is active" do + it "estimates the credit and refund amount not higher than wallet.balance_cents" do + result = estimate_service.call + + expect(result).to be_success + + credit_note = result.credit_note + expect(credit_note).to have_attributes( + currency: invoice.currency, + sub_total_excluding_taxes_amount_cents: 50, + credit_amount_cents: 0, + refund_amount_cents: 50, + coupons_adjustment_amount_cents: 0, + taxes_amount_cents: 0, + taxes_rate: 0 + ) + end + + context "when estimating with amount higher than in the active wallet" do + let(:items) do + [ + { + fee_id: credit_fee.id, + amount_cents: 150 + } + ] + end + + it "returns a failure" do + result = estimate_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:amount_cents) + expect(result.error.messages[:amount_cents]).to eq( + %w[ + higher_than_wallet_balance + ] + ) + end + end + end + + context "when wallet for the credits is not active" do + let(:wallet) { create(:wallet, customer:, balance_cents: 10, status: :terminated) } + + it "estimates the credit and refund amount hot higher than wallet.balance_amount_cents" do + result = estimate_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + end +end diff --git a/spec/services/credit_notes/generate_pdf_service_spec.rb b/spec/services/credit_notes/generate_pdf_service_spec.rb new file mode 100644 index 0000000..7db3598 --- /dev/null +++ b/spec/services/credit_notes/generate_pdf_service_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::GeneratePdfService do + subject(:credit_note_generate_service) { described_class.new(credit_note:, context:) } + + let(:organization) { create(:organization, name: "LAGO") } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:credit_note) { create(:credit_note, invoice:, customer:) } + let(:fee) { create(:fee, invoice:) } + let(:credit_note_item) { create(:credit_note_item, credit_note:, fee:) } + let(:context) { nil } + + before do + credit_note_item + + stub_pdf_generation + allow(Utils::PdfGenerator).to receive(:call).and_call_original + end + + describe ".call" do + it "generates the credit note synchronously" do + result = credit_note_generate_service.call + + expect(result.credit_note.file).to be_present + end + + it "uses credit_note template" do + credit_note_generate_service.call + + expect(Utils::PdfGenerator).to have_received(:call).with(template: "credit_notes/credit_note", context: credit_note) + end + + it "produces an activity log" do + result = credit_note_generate_service.call + + expect(Utils::ActivityLog).to have_produced("credit_note.generated").with(result.credit_note) + end + + context "when credit note is for self billed invoice" do + let(:invoice) { create(:invoice, :self_billed, customer:, organization:) } + let(:credit_note) { create(:credit_note, invoice:, customer:) } + + it "uses self billed template" do + credit_note_generate_service.call + + expect(Utils::PdfGenerator).to have_received(:call).with(template: "credit_notes/self_billed", context: credit_note) + end + end + + context "with preferred locale" do + before do + customer.update!(document_locale: "fr") + + allow(I18n).to receive(:with_locale).and_yield + end + + it "sets the correct document locale" do + credit_note_generate_service.call + expect(I18n).to have_received(:with_locale).with(:fr) + end + end + + context "when using temp files" do + let(:pdf_tempfile) { instance_double(Tempfile).as_null_object } + let(:blank_pdf_path) { Rails.root.join("spec/fixtures/blank.pdf") } + + before do + allow(pdf_tempfile).to receive(:path).and_return(blank_pdf_path) + allow(Tempfile).to receive(:new).and_call_original + allow(Tempfile).to receive(:new).with([credit_note.number, ".pdf"]).and_return(pdf_tempfile) + end + + it "unlink the pdf file at the end" do + described_class.call(credit_note:, context:) + + expect(pdf_tempfile).to have_received(:unlink) + end + + context "with einvoicing enabled" do + let(:xml_tempfile) { instance_double(Tempfile).as_null_object } + + before do + invoice.billing_entity.update(country: "FR", einvoicing: true) + + allow(Tempfile).to receive(:new).with([credit_note.number, ".xml"]).and_return(xml_tempfile) + allow(Utils::PdfAttachmentService).to receive(:call) + end + + it "unlink all files at the end" do + described_class.call(credit_note:, context:) + + expect(pdf_tempfile).to have_received(:unlink) + expect(xml_tempfile).to have_received(:unlink) + end + end + end + + context "when einvoicing is enabled" do + let(:fake_xml) { "content" } + let(:country) { nil } + let(:create_xml_result) { BaseService::Result.new.tap { |result| result.xml = fake_xml } } + + before do + credit_note.billing_entity.update(country:, einvoicing: true) + + allow(EInvoices::CreditNotes::FacturX::CreateService).to receive(:call).and_return(create_xml_result) + allow(Utils::PdfAttachmentService).to receive(:call) + end + + context "with FR country" do + let(:country) { "FR" } + + it "generates the invoice with attached facturx xml synchronously" do + result = described_class.call(credit_note:, context:) + + expect(EInvoices::CreditNotes::FacturX::CreateService).to have_received(:call) + expect(Utils::PdfAttachmentService).to have_received(:call) + expect(result.credit_note.file).to be_present + end + end + end + + context "with not found credit_note" do + let(:credit_note) { nil } + let(:credit_note_item) { nil } + + it "returns a result with error" do + result = credit_note_generate_service.call + + expect(result.success).to be_falsey + expect(result.error.error_code).to eq("credit_note_not_found") + end + end + + context "when credit_note is draft" do + let(:credit_note) { create(:credit_note, :draft, invoice:, customer:) } + + it "returns a not found error" do + result = credit_note_generate_service.call + + expect(result.success).to be_falsey + expect(result.error.error_code).to eq("credit_note_not_found") + end + end + + context "with already generated file" do + before do + credit_note.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "credit_note.pdf", + content_type: "application/pdf" + ) + end + + it "does not generate the pdf" do + allow(LagoHttpClient::Client).to receive(:new) + + credit_note_generate_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + end + end + + context "when context is API" do + let(:context) { "api" } + + it "calls the SendWebhook job" do + expect do + credit_note_generate_service.call + end.to have_enqueued_job(SendWebhookJob) + end + end + + context "when context is admin" do + let(:context) { "admin" } + + it "calls the SendWebhook job" do + expect do + credit_note_generate_service.call + end.to have_enqueued_job(SendWebhookJob) + end + end + end +end diff --git a/spec/services/credit_notes/generate_xml_service_spec.rb b/spec/services/credit_notes/generate_xml_service_spec.rb new file mode 100644 index 0000000..cc67550 --- /dev/null +++ b/spec/services/credit_notes/generate_xml_service_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::GenerateXmlService, type: :service do + let(:context) { "graphql" } + + let(:organization) { create(:organization, name: "LAGO") } + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", einvoicing:) } + let(:einvoicing) { true } + + let(:credit_note) { create(:credit_note, status:, billing_entity:) } + let(:status) { :finalized } + + let(:xml_service) { EInvoices::CreditNotes::Ubl::CreateService } + let(:fake_xml) { "content" } + let(:create_xml_result) { BaseService::Result.new.tap { |result| result.xml = fake_xml } } + let(:blank_xml_path) { Rails.root.join("spec/fixtures/blank.xml") } + + before do + credit_note + end + + shared_examples "dont generate" do |section| + it "does not generate the xml" do + described_class.call(credit_note:, context:) + + expect(xml_service).not_to have_received(:call) + end + end + + describe "#call" do + before do + allow(xml_service).to receive(:call) + .with(credit_note:) + .and_return(create_xml_result) + end + + it "generates the xml synchronously" do + result = described_class.call(credit_note:, context:) + + expect(result.credit_note.xml_file).to be_present + end + + context "when using temp files" do + let(:xml_tempfile) { instance_double(Tempfile).as_null_object } + + before do + allow(Tempfile).to receive(:new).with([credit_note.number, ".xml"]).and_return(xml_tempfile) + allow(xml_tempfile).to receive(:path).and_return(blank_xml_path) + end + + it "removes the temp file at the end" do + described_class.call(credit_note:, context:) + + expect(xml_tempfile).to have_received(:unlink) + end + + context "when error happens" do + before do + allow(credit_note).to receive(:save).and_raise(ActiveRecord::RecordInvalid.new) + end + + it "always removes the temp file" do + expect { + described_class.call(credit_note:, context:) + }.to raise_error(ActiveRecord::RecordInvalid) + + expect(xml_tempfile).to have_received(:unlink) + end + end + end + + context "when cant generate" do + context "with credit_note not found" do + let(:credit_note) { nil } + + it "results in error" do + result = described_class.call(credit_note:, context:) + + expect(result.success).to be_falsey + expect(result.error.error_code).to eq("credit_note_not_found") + end + end + + context "with credit_note as draft" do + let(:status) { :draft } + + it "results in error" do + result = described_class.call(credit_note:, context:) + + expect(result.success).to be_falsey + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("is_draft") + end + end + + context "with already generated a file" do + before do + credit_note.xml_file.attach( + io: StringIO.new(File.read(blank_xml_path)), + filename: "credit_note.xml", + content_type: "application/xml" + ) + end + + it_behaves_like "dont generate" + end + + context "when country is not allowed" do + before do + billing_entity.country = "BR" + billing_entity.save!(validate: false) + end + + it_behaves_like "dont generate" + end + + context "when einvoicing is disabled" do + let(:einvoicing) { false } + + it_behaves_like "dont generate" + end + end + end +end diff --git a/spec/services/credit_notes/provider_taxes/report_service_spec.rb b/spec/services/credit_notes/provider_taxes/report_service_spec.rb new file mode 100644 index 0000000..0d711a4 --- /dev/null +++ b/spec/services/credit_notes/provider_taxes/report_service_spec.rb @@ -0,0 +1,152 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::ProviderTaxes::ReportService do + subject(:report_service) { described_class.new(credit_note:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + :voided, + :with_subscriptions, + customer:, + organization:, + subscriptions: [subscription], + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + let(:credit_note) do + create( + :credit_note, + :with_tax_error, + customer:, + organization:, + invoice: + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: "standard", billable_metric:) } + + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:fee_charge) do + create( + :fee, + invoice:, + charge:, + fee_type: :charge, + total_aggregated_units: 100, + amount_cents: 1_000 + ) + end + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json") + File.read(path) + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + fee_subscription + fee_charge + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when credit note does not exist" do + it "returns an error" do + result = described_class.new(credit_note: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("credit_note_not_found") + end + end + + context "when credit note is successfully synced" do + it "returns successful result" do + result = report_service.call + + expect(result).to be_success + expect(result.credit_note.id).to eq(credit_note.id) + expect(result.credit_note.integration_resources.last.external_id).not_to be_nil + expect(result.credit_note.integration_resources.last.integration_id).to eq(integration.id) + end + + it "discards previous tax errors" do + expect { report_service.call } + .to change(credit_note.error_details.tax_error, :count).from(1).to(0) + end + end + + context "when failed result is returned" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "returns validation error" do + result = report_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(credit_note.reload.integration_resources.where(integration_id: integration.id).count).to eq(0) + end + + it "resolves old tax error and creates new one" do + old_error_id = credit_note.reload.error_details.order(created_at: :asc).last.id + + report_service.call + + expect(credit_note.error_details.tax_error.order(created_at: :asc).last.id).not_to eql(old_error_id) + expect(credit_note.error_details.tax_error.count).to be(1) + expect(credit_note.error_details.tax_error.order(created_at: :asc).last).not_to be_discarded + end + end + end +end diff --git a/spec/services/credit_notes/recredit_service_spec.rb b/spec/services/credit_notes/recredit_service_spec.rb new file mode 100644 index 0000000..aac0027 --- /dev/null +++ b/spec/services/credit_notes/recredit_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::RecreditService do + subject(:service) { described_class.new(credit:) } + + let(:credit_note) { credit.credit_note } + + context "when credit note is nil" do + let(:credit) { create(:credit) } + + it "returns a failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("credit_note_not_found") + end + end + + context "when credit note is voided" do + let(:credit) { create(:credit_note_credit) } + + before do + credit_note.update! credit_status: :voided + end + + it "returns a failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("credit_note_voided") + end + end + + context "when credit note can be recredited" do + let(:credit) { create(:credit_note_credit) } + let(:amount_cents) { credit_note.balance_amount_cents } + let(:amount_cents_recredited) { credit_note.balance_amount_cents + credit.amount_cents } + + before do + credit_note.update! credit_status: :consumed + end + + it "recredits the credit note" do + expect { service.call } + .to change { credit_note.reload.balance_amount_cents } + .from(amount_cents).to(amount_cents_recredited) + + expect(service.call).to be_success + end + + it "updates credit note credit status to available" do + expect { service.call }.to change { credit_note.reload.credit_status }.from("consumed").to("available") + end + end +end diff --git a/spec/services/credit_notes/refresh_draft_service_spec.rb b/spec/services/credit_notes/refresh_draft_service_spec.rb new file mode 100644 index 0000000..28f5762 --- /dev/null +++ b/spec/services/credit_notes/refresh_draft_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::RefreshDraftService do + subject(:refresh_service) { described_class.new(credit_note:, fee:, old_fee_values:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, organization:, rate: 20) } + let(:invoice) { create(:invoice, organization:, customer:, fees_amount_cents: 100, coupons_amount_cents: 20) } + let(:old_fee_values) do + [ + { + credit_note_item_id: credit_note_item.id, + fee_amount_cents: credit_note_item.fee&.amount_cents + } + ] + end + + describe "#call" do + let(:status) { :draft } + let(:fee) { create(:fee, invoice:, taxes_rate: 20, amount_cents: 100, precise_coupons_amount_cents: 20) } + let(:fee_applied_tax) { create(:fee_applied_tax, tax:, fee:, amount_cents: 0) } + let(:invoice_applied_tax) { create(:invoice_applied_tax, tax:, invoice:) } + let(:credit_note_item) { create(:credit_note_item, credit_note:, fee: create(:fee, invoice:, taxes_rate: 0)) } + let(:credit_note) do + create( + :credit_note, + invoice:, + status:, + taxes_rate: 0, + taxes_amount_cents: 0, + credit_amount_cents: 100, + balance_amount_cents: 100, + total_amount_cents: 100 + ) + end + + before do + fee_applied_tax + invoice_applied_tax + credit_note_item + end + + context "when credit_note is finalized" do + let(:status) { :finalized } + + it "does not refresh it" do + expect { refresh_service.call }.not_to change(credit_note, :updated_at) + end + end + + it "assigns credit note to the fee" do + expect { refresh_service.call }.to change { credit_note.reload.items.pluck(:fee_id) }.to([fee.id]) + end + + it "updates vat amounts of the credit note" do + expect { refresh_service.call } + .to change { credit_note.reload.taxes_amount_cents }.from(0).to(8) + .and change(credit_note, :coupons_adjustment_amount_cents).from(0).to(10) + .and change(credit_note, :credit_amount_cents).from(100).to(48) + .and change(credit_note, :balance_amount_cents).from(100).to(48) + .and change(credit_note, :total_amount_cents).from(100).to(48) + end + end +end diff --git a/spec/services/credit_notes/refunds/adyen_service_spec.rb b/spec/services/credit_notes/refunds/adyen_service_spec.rb new file mode 100644 index 0000000..e50c886 --- /dev/null +++ b/spec/services/credit_notes/refunds/adyen_service_spec.rb @@ -0,0 +1,332 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::Refunds::AdyenService do + subject(:adyen_service) { described_class.new(credit_note) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:adyen_payment_provider) { create(:adyen_provider, organization:, code:) } + let(:adyen_customer) { create(:adyen_customer, customer:) } + let(:adyen_client) { instance_double(Adyen::Client) } + let(:modifications_api) { Adyen::ModificationsApi.new(adyen_client, 70) } + let(:checkout) { Adyen::Checkout.new(adyen_client, 70) } + let(:refunds_response) { generate(:adyen_refunds_response) } + let(:code) { "adyen_1" } + let(:payment) do + create( + :payment, + payment_provider: adyen_payment_provider, + payment_provider_customer: adyen_customer, + amount_cents: 200, + amount_currency: "CHF", + payable: credit_note.invoice + ) + end + + let(:credit_note) do + create( + :credit_note, + customer:, + invoice:, + refund_amount_cents: 134, + refund_amount_currency: "CHF", + refund_status: :pending + ) + end + + describe "#create" do + before do + payment + + allow(Adyen::Client).to receive(:new) + .and_return(adyen_client) + allow(adyen_client).to receive(:checkout) + .and_return(checkout) + allow(checkout).to receive(:modifications_api) + .and_return(modifications_api) + allow(modifications_api).to receive(:refund_captured_payment) + .and_return(refunds_response) + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "creates a adyen refund and a refund" do + result = adyen_service.create + + expect(result).to be_success + + expect(result.refund.id).to be_present + + expect(result.refund.credit_note).to eq(credit_note) + expect(result.refund.payment).to eq(payment) + expect(result.refund.payment_provider).to eq(adyen_payment_provider) + expect(result.refund.payment_provider_customer).to eq(adyen_customer) + expect(result.refund.amount_cents).to eq(134) + expect(result.refund.amount_currency).to eq("CHF") + expect(result.refund.status).to eq("pending") + expect(result.refund.provider_refund_id).to eq(refunds_response.response["pspReference"]) + + expect(result.credit_note).not_to be_succeeded + expect(result.credit_note.refunded_at).not_to be_present + end + + it "call SegmentTrackJob" do + adyen_service.create + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + refund_status: "pending" + } + ) + end + + context "with an error on adyen" do + before do + allow(modifications_api).to receive(:refund_captured_payment) + .and_raise(Adyen::AdyenError.new(nil, nil, "error")) + end + + it "delivers an error webhook" do + expect { adyen_service.create } + .to raise_error(Adyen::AdyenError) + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: adyen_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: nil + } + ) + end + + it "produces an activity log" do + expect { adyen_service.create } + .to raise_error(Adyen::AdyenError) + + expect(Utils::ActivityLog).to have_produced("credit_note.refund_failure").with(credit_note) + end + end + + context "when credit note does not have a refund amount" do + let(:credit_note) do + create( + :credit_note, + customer:, + refund_amount_cents: 0, + refund_amount_currency: "CHF" + ) + end + + it "does not create a refund" do + result = adyen_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(modifications_api).not_to have_received(:refund_captured_payment) + end + end + + context "when invoice does not have a payment" do + let(:payment) { nil } + + it "does not create a refund" do + result = adyen_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(modifications_api).not_to have_received(:refund_captured_payment) + end + end + + context "when dispute was lost" do + let(:invoice) { create(:invoice, :dispute_lost, customer:, organization:) } + + it "does not create a refund" do + result = adyen_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(modifications_api).not_to have_received(:refund_captured_payment) + end + end + + context "when payment provider customer was discarded" do + before { adyen_customer.discard } + + it "creates a adyen refund and a refund" do + result = adyen_service.create + + expect(result).to be_success + + expect(result.refund.id).to be_present + + expect(result.refund.credit_note).to eq(credit_note) + expect(result.refund.payment).to eq(payment) + expect(result.refund.payment_provider).to eq(adyen_payment_provider) + expect(result.refund.payment_provider_customer).to eq(adyen_customer) + expect(result.refund.amount_cents).to eq(134) + expect(result.refund.amount_currency).to eq("CHF") + expect(result.refund.status).to eq("pending") + expect(result.refund.provider_refund_id).to eq(refunds_response.response["pspReference"]) + + expect(result.credit_note).not_to be_succeeded + expect(result.credit_note.refunded_at).not_to be_present + end + end + end + + describe "#update_status" do + let(:refund) do + create(:refund, credit_note:) + end + + before { credit_note.pending! } + + it "updates the refund status" do + result = adyen_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "succeeded" + ) + + expect(result).to be_success + + expect(result.refund).to eq(refund) + expect(result.refund.status).to eq("succeeded") + + expect(result.credit_note).to be_succeeded + end + + it "calls SegmentTrackJob" do + allow(SegmentTrackJob).to receive(:perform_later) + + adyen_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "succeeded" + ) + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + refund_status: "succeeded" + } + ) + end + + context "when refund is not found" do + let(:refund) { nil } + + it "returns an empty result" do + result = adyen_service.update_status( + provider_refund_id: "foo", + status: "succeeded" + ) + + expect(result).to be_success + expect(result.refund).to be_nil + end + + context "with invoice id in metadata" do + it "returns an empty result" do + result = adyen_service.update_status( + provider_refund_id: "foo", + status: "succeeded", + metadata: {lago_invoice_id: SecureRandom.uuid} + ) + + expect(result).to be_success + expect(result.refund).to be_nil + end + + context "when invoice belongs to lago" do + let(:invoice) { create(:invoice) } + + it "returns a not found failure" do + result = adyen_service.update_status( + provider_refund_id: "re_123456", + status: "succeeded", + metadata: {lago_invoice_id: invoice.id} + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("adyen_refund_not_found") + end + end + end + end + + context "when status is not valid" do + it "fails" do + result = adyen_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "invalid" + ) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refund_status]).to include("value_is_invalid") + end + end + + context "when status is failed" do + before do + adyen_customer + end + + it "delivers an error webhook" do + result = adyen_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "failed" + ) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("refund_failed") + expect(result.error.error_message).to eq("Refund failed to perform") + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: adyen_customer.provider_customer_id, + provider_error: { + message: "Payment refund failed", + error_code: nil + } + ) + end + + it "produces an activity log" do + adyen_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "failed" + ) + + expect(Utils::ActivityLog).to have_produced("credit_note.refund_failure").with(credit_note) + end + end + end +end diff --git a/spec/services/credit_notes/refunds/gocardless_service_spec.rb b/spec/services/credit_notes/refunds/gocardless_service_spec.rb new file mode 100644 index 0000000..cd0f211 --- /dev/null +++ b/spec/services/credit_notes/refunds/gocardless_service_spec.rb @@ -0,0 +1,326 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::Refunds::GocardlessService do + subject(:gocardless_service) { described_class.new(credit_note) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:organization) { customer.organization } + let(:gocardless_payment_provider) { create(:gocardless_provider, organization:, code:) } + let(:gocardless_customer) { create(:gocardless_customer, customer:) } + let(:gocardless_client) { instance_double(GoCardlessPro::Client) } + let(:gocardless_refunds_service) { instance_double(GoCardlessPro::Services::RefundsService) } + let(:code) { "gocardless_1" } + let(:payment) do + create( + :payment, + payment_provider: gocardless_payment_provider, + payment_provider_customer: gocardless_customer, + amount_cents: 200, + amount_currency: "CHF", + payable: credit_note.invoice + ) + end + + let(:credit_note) do + create( + :credit_note, + customer:, + invoice:, + refund_amount_cents: 134, + refund_amount_currency: "CHF", + refund_status: :pending + ) + end + + describe "#create" do + before do + payment + + allow(GoCardlessPro::Client).to receive(:new) + .and_return(gocardless_client) + allow(gocardless_client).to receive(:refunds) + .and_return(gocardless_refunds_service) + allow(gocardless_refunds_service).to receive(:create) + .and_return(GoCardlessPro::Resources::Refund.new( + "id" => "re_123456", + "amount" => 134, + "currency" => "chf", + "status" => "paid" + )) + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "creates a gocardless refund" do + result = gocardless_service.create + + expect(result).to be_success + + expect(result.refund.id).to be_present + + expect(result.refund.credit_note).to eq(credit_note) + expect(result.refund.payment).to eq(payment) + expect(result.refund.payment_provider).to eq(gocardless_payment_provider) + expect(result.refund.payment_provider_customer).to eq(gocardless_customer) + expect(result.refund.amount_cents).to eq(134) + expect(result.refund.amount_currency).to eq("CHF") + expect(result.refund.status).to eq("paid") + expect(result.refund.provider_refund_id).to eq("re_123456") + + expect(result.credit_note).to be_succeeded + end + + it "call SegmentTrackJob" do + gocardless_service.create + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + refund_status: "paid" + } + ) + end + + context "with an error on gocardless" do + before do + allow(gocardless_refunds_service).to receive(:create) + .and_raise(GoCardlessPro::Error.new("code" => "code", "message" => "error")) + end + + it "delivers an error webhook" do + expect { gocardless_service.create } + .to raise_error(GoCardlessPro::Error) + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: gocardless_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + } + ) + end + + it "produces an activity log" do + expect { gocardless_service.create } + .to raise_error(GoCardlessPro::Error) + + expect(Utils::ActivityLog).to have_produced("credit_note.refund_failure").with(credit_note) + end + end + + context "with a validation error on gocardless" do + before do + allow(gocardless_refunds_service).to receive(:create) + .and_raise(GoCardlessPro::ValidationError.new("code" => "code", "message" => "error")) + end + + it "delivers an error webhook and returns an empty result" do + expect(gocardless_service.create).to be_success + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: gocardless_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + } + ) + end + end + + context "when credit note does not have a refund amount" do + let(:credit_note) do + create( + :credit_note, + customer:, + refund_amount_cents: 0, + refund_amount_currency: "CHF" + ) + end + + it "does not create a refund" do + result = gocardless_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(gocardless_refunds_service).not_to have_received(:create) + end + end + + context "when invoice does not have a payment" do + let(:payment) { nil } + + it "does not create a refund" do + result = gocardless_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(gocardless_refunds_service).not_to have_received(:create) + end + end + + context "when dispute was lost" do + let(:invoice) { create(:invoice, :dispute_lost, customer:, organization:) } + + it "does not create a refund" do + result = gocardless_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(gocardless_refunds_service).not_to have_received(:create) + end + end + + context "when payment provider customer was discarded" do + before { gocardless_customer.discard } + + it "creates a gocardless refund" do + result = gocardless_service.create + + expect(result).to be_success + + expect(result.refund.id).to be_present + + expect(result.refund.credit_note).to eq(credit_note) + expect(result.refund.payment).to eq(payment) + expect(result.refund.payment_provider).to eq(gocardless_payment_provider) + expect(result.refund.payment_provider_customer).to eq(gocardless_customer) + expect(result.refund.amount_cents).to eq(134) + expect(result.refund.amount_currency).to eq("CHF") + expect(result.refund.status).to eq("paid") + expect(result.refund.provider_refund_id).to eq("re_123456") + + expect(result.credit_note).to be_succeeded + end + end + end + + describe "#update_status" do + let(:refund) do + create(:refund, credit_note:) + end + + before do + payment + refund + credit_note.pending! + end + + it "updates the refund status" do + result = gocardless_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "paid" + ) + + expect(result).to be_success + + expect(result.refund).to eq(refund) + expect(result.refund.status).to eq("paid") + + expect(result.credit_note).to be_succeeded + end + + it "calls SegmentTrackJob" do + allow(SegmentTrackJob).to receive(:perform_later) + + gocardless_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "paid" + ) + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + refund_status: "paid" + } + ) + end + + context "when refund is not found" do + it "returns an empty result" do + result = gocardless_service.update_status( + provider_refund_id: "foo", + status: "paid" + ) + + expect(result).to be_success + expect(result.refund).to be_nil + end + end + + context "when status is not valid" do + it "fails" do + result = gocardless_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "invalid" + ) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refund_status]).to include("value_is_invalid") + end + end + + context "when status is failed" do + before do + gocardless_service + end + + it "delivers an error webhook" do + result = gocardless_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "failed" + ) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("refund_failed") + expect(result.error.error_message).to eq("Refund failed to perform") + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: gocardless_customer.provider_customer_id, + provider_error: { + message: "Payment refund failed", + error_code: nil + } + ) + end + + it "produces an activity log" do + gocardless_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "failed" + ) + + expect(Utils::ActivityLog).to have_produced("credit_note.refund_failure").with(credit_note) + end + end + end +end diff --git a/spec/services/credit_notes/refunds/stripe_service_spec.rb b/spec/services/credit_notes/refunds/stripe_service_spec.rb new file mode 100644 index 0000000..caecdac --- /dev/null +++ b/spec/services/credit_notes/refunds/stripe_service_spec.rb @@ -0,0 +1,396 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::Refunds::StripeService do + subject(:stripe_service) { described_class.new(credit_note) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:stripe_payment_provider) { create(:stripe_provider, organization:, code:) } + let(:stripe_customer) { create(:stripe_customer, customer:) } + let(:code) { "stripe_1" } + let(:payment) do + create( + :payment, + payment_provider: stripe_payment_provider, + payment_provider_customer: stripe_customer, + amount_cents: 200, + amount_currency: "CHF", + payable_payment_status: "succeeded", + payable: credit_note.invoice + ) + end + + let(:credit_note) do + create( + :credit_note, + customer:, + invoice:, + refund_amount_cents: 134, + refund_amount_currency: "CHF", + refund_status: :pending + ) + end + + describe "#create" do + before do + payment + + allow(Stripe::Refund).to receive(:create) + .and_return( + Stripe::Refund.construct_from( + id: "re_123456", + status: "succeeded", + amount: 134, + currency: "chf" + ) + ) + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "creates a stripe refund and a refund" do + result = stripe_service.create + + expect(result).to be_success + + expect(result.refund.id).to be_present + + expect(result.refund.credit_note).to eq(credit_note) + expect(result.refund.payment).to eq(payment) + expect(result.refund.payment_provider).to eq(stripe_payment_provider) + expect(result.refund.payment_provider_customer).to eq(stripe_customer) + expect(result.refund.amount_cents).to eq(134) + expect(result.refund.amount_currency).to eq("CHF") + expect(result.refund.status).to eq("succeeded") + expect(result.refund.provider_refund_id).to eq("re_123456") + + expect(result.credit_note).to be_succeeded + expect(result.credit_note.refunded_at).to be_present + end + + it "call SegmentTrackJob" do + stripe_service.create + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + refund_status: "succeeded" + } + ) + end + + context "with a payment request for an invoice" do + let(:payment_request) { create(:payment_request, payment_status: 1, customer: credit_note.customer) } + let(:applied_payment_request) { create(:payment_request_applied_invoice, payment_request:, invoice: credit_note.invoice) } + let(:payment) do + create( + :payment, + payment_provider: stripe_payment_provider, + payment_provider_customer: stripe_customer, + amount_cents: 200, + amount_currency: "CHF", + payable_payment_status: "succeeded", + payable: payment_request + ) + end + + before { applied_payment_request } + + it "creates a stripe refund and a refund" do + result = stripe_service.create + + expect(result).to be_success + + expect(result.refund.id).to be_present + + expect(result.refund.credit_note).to eq(credit_note) + expect(result.refund.payment).to eq(payment) + expect(result.refund.payment_provider).to eq(stripe_payment_provider) + expect(result.refund.payment_provider_customer).to eq(stripe_customer) + expect(result.refund.amount_cents).to eq(134) + expect(result.refund.amount_currency).to eq("CHF") + expect(result.refund.status).to eq("succeeded") + expect(result.refund.provider_refund_id).to eq("re_123456") + + expect(result.credit_note).to be_succeeded + expect(result.credit_note.refunded_at).to be_present + end + end + + context "with an error on stripe" do + let(:error_message) { "error" } + + before do + allow(Stripe::Refund).to receive(:create) + .and_raise(::Stripe::InvalidRequestError.new(error_message, {}, code: error_message)) + end + + it "delivers an error webhook" do + result = stripe_service.create + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("stripe_error") + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: stripe_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "error" + } + ) + end + + it "produces an activity log" do + stripe_service.create + + expect(Utils::ActivityLog).to have_produced("credit_note.refund_failure").with(credit_note) + end + + context "when error is about non refundable payment method" do + let(:error_message) { described_class::INVALID_PAYMENT_METHOD_ERROR } + + it "returns a success result" do + result = stripe_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: stripe_customer.provider_customer_id, + provider_error: { + message: error_message, + error_code: error_message + } + ) + end + end + end + + context "when credit note does not have a refund amount" do + let(:credit_note) do + create( + :credit_note, + customer:, + refund_amount_cents: 0, + refund_amount_currency: "CHF" + ) + end + + it "does not create a refund" do + result = stripe_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(Stripe::Refund).not_to have_received(:create) + end + end + + context "when invoice does not have a payment" do + let(:payment) { nil } + + it "does not create a refund" do + result = stripe_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(Stripe::Refund).not_to have_received(:create) + end + end + + context "when payment provider customer was discarded" do + before { stripe_customer.discard } + + it "creates a stripe refund and a refund" do + result = stripe_service.create + + expect(result).to be_success + + expect(result.refund.id).to be_present + + expect(result.refund.credit_note).to eq(credit_note) + expect(result.refund.payment).to eq(payment) + expect(result.refund.payment_provider).to eq(stripe_payment_provider) + expect(result.refund.payment_provider_customer).to eq(stripe_customer) + expect(result.refund.amount_cents).to eq(134) + expect(result.refund.amount_currency).to eq("CHF") + expect(result.refund.status).to eq("succeeded") + expect(result.refund.provider_refund_id).to eq("re_123456") + + expect(result.credit_note).to be_succeeded + expect(result.credit_note.refunded_at).to be_present + end + end + + context "when dispute was lost" do + let(:invoice) { create(:invoice, :dispute_lost, customer:, organization:) } + + it "does not create a refund" do + result = stripe_service.create + + expect(result).to be_success + + expect(result.credit_note).to eq(credit_note) + expect(result.refund).to be_nil + + expect(Stripe::Refund).not_to have_received(:create) + end + end + end + + describe "#update_status" do + let(:refund) do + create(:refund, credit_note:) + end + + before { credit_note.pending! } + + it "updates the refund status" do + result = stripe_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "succeeded" + ) + + expect(result).to be_success + + expect(result.refund).to eq(refund) + expect(result.refund.status).to eq("succeeded") + + expect(result.credit_note).to be_succeeded + end + + it "calls SegmentTrackJob" do + allow(SegmentTrackJob).to receive(:perform_later) + + stripe_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "succeeded" + ) + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + refund_status: "succeeded" + } + ) + end + + context "when refund is not found" do + let(:refund) { nil } + + it "returns an empty result" do + result = stripe_service.update_status( + provider_refund_id: "foo", + status: "succeeded" + ) + + expect(result).to be_success + expect(result.refund).to be_nil + end + + context "with invoice id in metadata" do + it "returns an empty result" do + result = stripe_service.update_status( + provider_refund_id: "foo", + status: "succeeded", + metadata: {lago_invoice_id: SecureRandom.uuid} + ) + + expect(result).to be_success + expect(result.refund).to be_nil + end + + context "when invoice belongs to lago" do + let(:invoice) { create(:invoice) } + + it "returns a not found failure" do + result = stripe_service.update_status( + provider_refund_id: "re_123456", + status: "succeeded", + metadata: {lago_invoice_id: invoice.id} + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("stripe_refund_not_found") + end + end + end + end + + context "when status is not valid" do + it "fails" do + result = stripe_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "invalid" + ) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refund_status]).to include("value_is_invalid") + end + end + + context "when status is failed" do + before do + stripe_customer + end + + it "delivers an error webhook" do + result = stripe_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "failed" + ) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("refund_failed") + expect(result.error.error_message).to eq("Refund failed to perform") + + expect(SendWebhookJob).to have_been_enqueued + .with( + "credit_note.provider_refund_failure", + credit_note, + provider_customer_id: stripe_customer.provider_customer_id, + provider_error: { + message: "Payment refund failed", + error_code: nil + } + ) + end + + it "produces an activity log" do + stripe_service.update_status( + provider_refund_id: refund.provider_refund_id, + status: "failed" + ) + + expect(Utils::ActivityLog).to have_produced("credit_note.refund_failure").with(credit_note) + end + end + end +end diff --git a/spec/services/credit_notes/update_service_spec.rb b/spec/services/credit_notes/update_service_spec.rb new file mode 100644 index 0000000..53c5768 --- /dev/null +++ b/spec/services/credit_notes/update_service_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::UpdateService do + subject(:credit_note_service) { described_class.new(credit_note:, partial_metadata:, **params) } + + let(:credit_note) { create(:credit_note) } + let(:partial_metadata) { false } + + let(:params) do + {refund_status: "succeeded"} + end + + it "updates the credit note status" do + result = credit_note_service.call + + expect(result).to be_success + expect(result.credit_note.refund_status).to eq("succeeded") + expect(result.credit_note.refunded_at).to be_present + end + + it "call SegmentTrackJob" do + allow(SegmentTrackJob).to receive(:perform_later) + + credit_note_service.call + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "refund_status_changed", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + refund_status: "succeeded" + } + ) + end + + context "with invalid refund status" do + let(:params) do + {refund_status: "foo_bar"} + end + + it "returns an error" do + result = credit_note_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:refund_status) + expect(result.error.messages[:refund_status]).to include("value_is_invalid") + end + end + + context "when credit_note is draft" do + let(:credit_note) { create(:credit_note, :draft) } + + it "returns a failure" do + result = credit_note_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("credit_note_not_found") + end + end + + describe "metadata" do + let(:organization) { credit_note.organization } + + context "when deleting metadata" do + let(:params) { {metadata: nil} } + + before { create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar"}) } + + it "deletes metadata" do + result = credit_note_service.call + + expect(result).to be_success + expect(credit_note.reload.metadata).to be_nil + end + end + + context "when creating metadata" do + let(:params) { {metadata: {"foo" => "bar"}} } + + it "creates metadata" do + expect { credit_note_service.call }.to change(Metadata::ItemMetadata, :count).by(1) + expect(credit_note.reload.metadata.value).to eq({"foo" => "bar"}) + end + end + + context "when replacing metadata" do + let(:params) { {metadata: {"baz" => "qux"}} } + + before { create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar"}) } + + it "replaces metadata" do + result = credit_note_service.call + + expect(result).to be_success + expect(credit_note.reload.metadata.value).to eq({"baz" => "qux"}) + end + end + + context "when merging metadata" do + let(:partial_metadata) { true } + let(:params) { {metadata: {"baz" => "qux"}} } + + before { create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar"}) } + + it "merges metadata" do + result = credit_note_service.call + + expect(result).to be_success + expect(credit_note.reload.metadata.value).to eq({"foo" => "bar", "baz" => "qux"}) + end + end + end +end diff --git a/spec/services/credit_notes/validate_item_service_spec.rb b/spec/services/credit_notes/validate_item_service_spec.rb new file mode 100644 index 0000000..fefc5c6 --- /dev/null +++ b/spec/services/credit_notes/validate_item_service_spec.rb @@ -0,0 +1,195 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::ValidateItemService do + subject(:validator) { described_class.new(result, item:) } + + let(:result) { BaseService::Result.new } + let(:amount_cents) { 10 } + let(:credit_amount_cents) { 10 } + let(:refund_amount_cents) { 0 } + let(:credit_note) do + create( + :credit_note, + invoice:, + customer:, + credit_amount_cents:, + refund_amount_cents: + ) + end + let(:item) do + build( + :credit_note_item, + credit_note:, + amount_cents:, + fee: + ) + end + + let(:invoice) { create(:invoice, total_amount_cents: 120) } + let(:customer) { invoice.customer } + + let(:fee) { create(:fee, invoice:, amount_cents: 100, taxes_rate: 20) } + + describe ".call" do + it "validates the item" do + expect(validator).to be_valid + end + + context "when fee is missing" do + let(:fee) { nil } + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("fee") + end + end + + context "when amount is negative" do + let(:amount_cents) { -3 } + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:amount_cents]).to eq(["invalid_value"]) + end + end + + context "when amount is zero" do + let(:amount_cents) { 0 } + + it "passes the validation" do + expect(validator).to be_valid + end + end + + context "when amount is higher than fee amount" do + let(:amount_cents) { fee.amount_cents + 10 } + + before do + create(:fee, invoice:, amount_cents: 100, taxes_rate: 20, taxes_amount_cents: 20) + end + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:amount_cents]).to eq(["higher_than_remaining_fee_amount"]) + end + end + + context "when reaching fee creditable amount" do + before do + create(:credit_note_item, fee:, amount_cents: 99) + create(:fee, invoice:, amount_cents: 100, taxes_rate: 20, taxes_amount_cents: 20) + end + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:amount_cents]).to eq(["higher_than_remaining_fee_amount"]) + end + end + + context "with offset amounts" do + it "includes offset amounts in total credit note calculation" do + create(:credit_note, invoice:, customer:, + credit_amount_cents: 30, refund_amount_cents: 20, offset_amount_cents: 15, status: :finalized) + item.amount_cents = 20 + expect(validator).to be_valid + end + + it "validates successfully when within remaining amount after offsets" do + create(:credit_note, invoice:, customer:, + credit_amount_cents: 30, refund_amount_cents: 20, offset_amount_cents: 40, status: :finalized) + item.amount_cents = 50 + expect(validator).to be_valid + end + + it "considers only offset amounts when credit and refund are zero" do + create(:credit_note, invoice:, customer:, + credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 25, status: :finalized) + item.amount_cents = 15 + expect(validator).to be_valid + end + + it "ignores draft credit notes with offset amounts" do + create(:credit_note, invoice:, customer:, + credit_amount_cents: 50, refund_amount_cents: 30, offset_amount_cents: 20, status: :draft) + item.amount_cents = 30 + expect(validator).to be_valid + end + end + + context "with credit invoices and wallets" do + let(:wallet) { create(:wallet, customer:, balance_cents: 1000) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, remaining_amount_cents: 1000) } + let(:fee) { create(:fee, invoice:, fee_type: :credit, invoiceable: wallet_transaction, amount_cents: 1000) } + + before { wallet } + + it "allows offsetting full amount when cancelling prepaid credits with pending payment" do + invoice.update!(invoice_type: :credit, total_amount_cents: 1000, payment_status: :pending) + create(:credit_note, invoice:, customer:, + credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 1000, status: :finalized) + item.amount_cents = 1000 + expect(validator).to be_valid + end + + it "allows offsetting full amount when cancelling prepaid credits with failed payment" do + invoice.update!(invoice_type: :credit, total_amount_cents: 1000, payment_status: :failed) + create(:credit_note, invoice:, customer:, + credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 1000, status: :finalized) + item.amount_cents = 1000 + expect(validator).to be_valid + end + + it "allows offsetting full amount with succeeded payment" do + invoice.update!(invoice_type: :credit, total_amount_cents: 500, payment_status: :succeeded) + fee.update!(amount_cents: 500) + wallet_transaction.update!(remaining_amount_cents: 500) + create(:credit_note, invoice:, customer:, + credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 500, status: :finalized) + item.amount_cents = 500 + expect(validator).to be_valid + end + + it "rejects amount exceeding remaining amount" do + invoice.update!(invoice_type: :credit, total_amount_cents: 2000, payment_status: :succeeded) + fee.update!(amount_cents: 2000) + wallet_transaction.update!(remaining_amount_cents: 800) + item.amount_cents = 1500 + + expect(validator).not_to be_valid + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:amount_cents]).to eq(["higher_than_wallet_balance"]) + end + + context "when wallet is not traceable" do + let(:wallet) { create(:wallet, customer:, balance_cents: 1000, traceable: false) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, remaining_amount_cents: nil) } + + it "allows offsetting up to wallet balance" do + invoice.update!(invoice_type: :credit, total_amount_cents: 1000, payment_status: :succeeded) + item.amount_cents = 1000 + expect(validator).to be_valid + end + + it "rejects amount exceeding wallet balance" do + invoice.update!(invoice_type: :credit, total_amount_cents: 2000, payment_status: :succeeded) + fee.update!(amount_cents: 2000) + item.amount_cents = 1500 + + expect(validator).not_to be_valid + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:amount_cents]).to eq(["higher_than_wallet_balance"]) + end + end + end + end +end diff --git a/spec/services/credit_notes/validate_service_spec.rb b/spec/services/credit_notes/validate_service_spec.rb new file mode 100644 index 0000000..518297d --- /dev/null +++ b/spec/services/credit_notes/validate_service_spec.rb @@ -0,0 +1,446 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::ValidateService do + subject(:validator) { described_class.new(result, item: credit_note) } + + let(:result) { BaseService::Result.new } + let(:amount_cents) { 10 } + let(:credit_amount_cents) { 12 } + let(:refund_amount_cents) { 0 } + let(:precise_taxes_amount_cents) { 2 } + let(:precise_coupons_adjustment_amount_cents) { 0 } + let(:offset_amount_cents) { 0 } + + let(:credit_note) do + create( + :credit_note, + invoice:, + customer:, + credit_amount_cents:, + refund_amount_cents:, + offset_amount_cents:, + precise_coupons_adjustment_amount_cents:, + precise_taxes_amount_cents: + ) + end + + let(:item) do + create( + :credit_note_item, + credit_note:, + amount_cents:, + precise_amount_cents: amount_cents, + fee: + ) + end + + let(:invoice) { create(:invoice, total_amount_cents: 120, total_paid_amount_cents:) } + let(:customer) { invoice.customer } + let(:total_paid_amount_cents) { 0 } + + let(:fee) do + create( + :fee, + invoice:, + amount_cents: 100, + taxes_rate: 20 + ) + end + + before do + item + credit_note.reload + end + + describe ".call" do + it "validates the credit_note" do + expect(validator).to be_valid + end + + context "when invoice is not paid" do + let(:invoice_status) { "pending" } + + context "when paid amount equals the total amount" do + let(:refund_amount_cents) { 2 } + let(:total_paid_amount_cents) { 120 } + + it "fails the validation" do + expect(validator).not_to be_valid + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refund_amount_cents]).to eq(["cannot_refund_unpaid_invoice"]) + end + end + + context "when paid amount does not equal the total amount" do + let(:refund_amount_cents) { 0 } + + it "validates the credit_note" do + expect(validator).to be_valid + end + end + end + + context "when amount does not matches items" do + let(:amount_cents) { 1 } + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["does_not_match_item_amounts"]) + end + end + + context "when credit amount is higher than invoice amount" do + let(:credit_amount_cents) { 250 } + + before do + create(:fee, invoice:, amount_cents: 100, taxes_rate: 20, taxes_amount_cents: 20) + end + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:credit_amount_cents]).to eq(["higher_than_remaining_invoice_amount"]) + end + + context "when the difference is due to rounding" do + let(:credit_amount_cents) { 241 } + let(:amount_cents) { 239 } + + it "passes the validation" do + expect(validator).to be_valid + end + end + end + + context "when refund amount is positive but invoice is not paid" do + let(:refund_amount_cents) { 200 } + + before do + create(:fee, invoice:, amount_cents: 100, taxes_rate: 20, taxes_amount_cents: 20) + end + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refund_amount_cents]).to eq(["cannot_refund_unpaid_invoice"]) + end + end + + context "when refund amount is higher than invoice amount" do + let(:refund_amount_cents) { 200 } + let(:total_paid_amount_cents) { 20 } + + before do + invoice.payment_succeeded! + create(:fee, invoice:, amount_cents: 100, taxes_rate: 20, taxes_amount_cents: 20) + end + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refund_amount_cents]).to eq(["higher_than_remaining_invoice_amount"]) + end + end + + context "when reaching invoice creditable amount" do + before do + create(:credit_note, invoice:, total_amount_cents: 99) + end + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:credit_amount_cents]).to eq(["higher_than_remaining_invoice_amount"]) + end + end + + context "when reaching invoice refundable amount" do + before do + invoice.payment_succeeded! + create(:credit_note, invoice:, total_amount_cents: 119, refund_amount_cents: 199, credit_amount_cents: 0) + end + + let(:credit_amount_cents) { 0 } + let(:refund_amount_cents) { 10 } + let(:total_paid_amount_cents) { 5 } + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:refund_amount_cents]).to eq(["higher_than_remaining_invoice_amount"]) + end + end + + context "when total amount is higher than invoice amount" do + before do + create( + :credit_note, + invoice:, + credit_amount_cents: 86, + refund_amount_cents: 33, + total_amount_cents: 119 + ) + end + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["higher_than_remaining_invoice_amount"]) + end + end + + context "when invoice is v3 with coupons" do + let(:invoice) do + create( + :invoice, + currency: "EUR", + fees_amount_cents: 100, + coupons_amount_cents: 10, + taxes_amount_cents: 18, + total_amount_cents: 108, + payment_status: :succeeded, + taxes_rate: 20, + version_number: 3 + ) + end + + let(:amount_cents) { 20 } + let(:credit_amount_cents) { 22 } + let(:refund_amount_cents) { 0 } + let(:precise_taxes_amount_cents) { 3.6 } + let(:precise_coupons_adjustment_amount_cents) { 2 } + + it "validates the credit_note" do + expect(validator).to be_valid + end + + context "when amount does not matches items" do + let(:amount_cents) { 1 } + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["does_not_match_item_amounts"]) + end + end + + context "when total amount is zero" do + let(:credit_amount_cents) { 0 } + let(:refund_amount_cents) { 0 } + let(:amount_cents) { 0 } + let(:precise_taxes_amount_cents) { 0 } + let(:precise_coupons_adjustment_amount_cents) { 0 } + + it "fails the validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["total_amount_must_be_positive"]) + end + end + end + + context "with rounding adjustment" do + let(:invoice) do + create( + :invoice, + total_amount_cents: 25000, + taxes_amount_cents: 5000, + fees_amount_cents: 20000, + total_paid_amount_cents: 25000, + taxes_rate: 25 + ) + end + + let(:fee) do + create( + :fee, + invoice:, + amount_cents: 20000, + taxes_rate: 25 + ) + end + + let(:amount_cents) { 19333 } + let(:credit_amount_cents) { 24166 } + let(:precise_taxes_amount_cents) { 4833.3333 } + + it "passes the validation" do + expect(validator).to be_valid + end + end + + context "when the difference is due to rounding" do + let(:credit_amount_cents) { 241 } + let(:amount_cents) { 239 } + + before do + create(:fee, invoice:, amount_cents: 100, taxes_rate: 20, taxes_amount_cents: 20) + end + + it "passes the validation" do + expect(validator).to be_valid + end + end + + context "when credit amount exceeds remaining after offset" do + let(:credit_amount_cents) { 100 } + let(:precise_taxes_amount_cents) { 0 } + + before do + # With offset of 22, only 98 is creditable (120 - 22) + create( + :credit_note, + invoice:, + customer:, + credit_amount_cents: 0, + refund_amount_cents: 0, + offset_amount_cents: 22, # we have an offset of 1 cent because of rounding issues(so 21 would pass) + status: :finalized + ) + end + + it "fails validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:credit_amount_cents]).to eq(["higher_than_remaining_invoice_amount"]) + end + end + + context "when offset_amount_cents exceeds invoice due amount" do + let(:credit_amount_cents) { 0 } + let(:offset_amount_cents) { 24 } + let(:invoice) { + create(:invoice, + total_amount_cents: 12, + total_paid_amount_cents: 12) + } + + it "fails validation" do + expect(validator).not_to be_valid + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:offset_amount_cents]).to eq(["higher_than_remaining_invoice_amount"]) + end + end + + context "with combined credit, refund, and offset amounts" do + let(:amount_cents) { 50 } + let(:offset_amount_cents) { 20 } + let(:credit_amount_cents) { 10 } + let(:refund_amount_cents) { 20 } + let(:total_paid_amount_cents) { 50 } + let(:precise_taxes_amount_cents) { 0 } + + before do + invoice.payment_succeeded! + end + + it "includes all three in total_amount_cents calculation" do + expect(validator).to be_valid + end + end + + context "when total with offset is zero" do + let(:amount_cents) { 0 } + let(:offset_amount_cents) { 0 } + let(:credit_amount_cents) { 0 } + let(:refund_amount_cents) { 0 } + let(:precise_taxes_amount_cents) { 0 } + + it "fails validation" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["total_amount_must_be_positive"]) + end + end + + context "when invoice is type credit and payment status is succeeded" do + let(:invoice) { + create(:invoice, + :credit, + total_amount_cents: 12, + total_paid_amount_cents: 12, + payment_status: :succeeded) + } + + context "when credit_amount_cents is set" do + let(:credit_amount_cents) { 12 } + + it "fails validation for credit invoice with credit_amount" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:credit_amount_cents]).to eq(["cannot_credit_invoice"]) + end + end + + context "when offset_amount_cents is set not covering the full invoice" do + let(:credit_amount_cents) { 0 } + let(:offset_amount_cents) { 7 } + + it "fails validation for credit invoice with offset_amount_cents when is paid" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:offset_amount_cents]).to eq(["cannot_apply_to_paid_invoice"]) + end + end + + context "when offset_amount_cents is set fully covering the invoice" do + let(:credit_amount_cents) { 0 } + let(:offset_amount_cents) { 12 } + + it "fails validation for credit invoice with offset_amount_cents when is paid" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:offset_amount_cents]).to eq(["cannot_apply_to_paid_invoice"]) + end + end + end + + context "when invoice is type credit but not paid" do + let(:invoice) { + create(:invoice, + :credit, + total_amount_cents: 12, + total_paid_amount_cents: 0, + payment_status: :pending) + } + + context "when offset_amount_cents is set not covering the full invoice" do + let(:credit_amount_cents) { 0 } + let(:offset_amount_cents) { 7 } + + it "fails validation for credit invoice" do + expect(validator).not_to be_valid + + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:offset_amount_cents]).to eq(["not_equal_to_total_amount"]) + end + end + + context "when offset_amount_cents is set fully covering the invoice" do + let(:credit_amount_cents) { 0 } + let(:offset_amount_cents) { 12 } + + it "passes the validation" do + expect(validator).to be_valid + end + end + end + end +end diff --git a/spec/services/credit_notes/void_service_spec.rb b/spec/services/credit_notes/void_service_spec.rb new file mode 100644 index 0000000..9aa4883 --- /dev/null +++ b/spec/services/credit_notes/void_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CreditNotes::VoidService do + subject(:void_service) { described_class.new(credit_note:) } + + let(:credit_note) { create(:credit_note) } + + describe "#call" do + it "voids the credit_note" do + result = void_service.call + + expect(result).to be_success + + expect(result.credit_note).to be_voided + expect(result.credit_note.voided_at).to be_present + expect(result.credit_note.balance_amount_cents).to eq(0) + end + + context "when credit note is nil" do + let(:credit_note) { nil } + + it "returns a failure" do + result = void_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("credit_note") + end + end + + context "when credit note is draft" do + let(:credit_note) { create(:credit_note, :draft) } + + it "returns a failure" do + result = void_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("credit_note") + end + end + + context "when the credit note is not voidable" do + let(:credit_note) { create(:credit_note, credit_status: :voided) } + + it "returns a failure" do + result = void_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("no_voidable_amount") + end + end + end +end diff --git a/spec/services/credits/allocate_prepaid_credits_by_wallets_service_spec.rb b/spec/services/credits/allocate_prepaid_credits_by_wallets_service_spec.rb new file mode 100644 index 0000000..70972cb --- /dev/null +++ b/spec/services/credits/allocate_prepaid_credits_by_wallets_service_spec.rb @@ -0,0 +1,288 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Credits::AllocatePrepaidCreditsByWalletsService do + let(:invoice) do + create( + :invoice, + customer:, + currency: "EUR", + total_amount_cents: amount_cents + ) + end + let(:fee) { + create(:charge_fee, invoice:, subscription:, + amount_cents: fee_amount_cents, precise_amount_cents: fee_amount_cents, + taxes_precise_amount_cents: 0) + } + let(:amount_cents) { 100 } + let(:fee_amount_cents) { 100 } + + let(:normal_wallet) do + create(:wallet, :with_inbound_transaction, name: "normal", customer:, balance_cents: 1000, credits_balance: 10.0) + end + + let(:priority_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49) + end + + let(:limited_charge_wallet) do + create(:wallet, :with_inbound_transaction, name: "limited charge", customer:, balance_cents: 1000, credits_balance: 10.0, allowed_fee_types: %w[charge]) + end + + let(:priority_limited_charge_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority limited charge", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49, allowed_fee_types: %w[charge]) + end + + let(:limited_subscription_wallet) do + create(:wallet, :with_inbound_transaction, name: "limited subscription", customer:, balance_cents: 1000, credits_balance: 10.0, allowed_fee_types: %w[subscription]) + end + + let(:priority_limited_subscription_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority limited subscription", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49, allowed_fee_types: %w[subscription]) + end + let(:wallets) do + [ + normal_wallet, + priority_wallet, + limited_charge_wallet, + priority_limited_charge_wallet, + limited_subscription_wallet, + priority_limited_subscription_wallet + ] + end + let(:customer) { create(:customer) } + let(:subscription) { create(:subscription, customer:) } + + before do + wallets + fee + subscription + end + + describe "#call" do + subject(:result) { described_class.call(invoice:) } + + it "returns the calculated allocation as wallet_transactions hash" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({priority_wallet => 100}) + end + + it "does not persist anything or take side-effects" do + allow(Customers::LockService).to receive(:call) + allow(Wallets::Balance::DecreaseService).to receive(:call) + allow(WalletTransactions::TrackConsumptionService).to receive(:call!) + + expect { subject }.not_to change(WalletTransaction, :count) + expect(SendWebhookJob).not_to have_been_enqueued + + expect(Customers::LockService).not_to have_received(:call) + expect(Wallets::Balance::DecreaseService).not_to have_received(:call) + expect(WalletTransactions::TrackConsumptionService).not_to have_received(:call!) + expect(priority_wallet.reload.balance_cents).to eq(1000) + end + + context "when customer has no applicable wallets" do + let(:wallets) { [] } + + it "returns success with an empty hash" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({}) + end + end + + context "when priority wallet credits are less than invoice amount" do + let(:amount_cents) { 1500 } + let(:fee_amount_cents) { 1500 } + + it "drains priority wallets first, then lower-priority wallets" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({ + priority_wallet => 1000, + priority_limited_charge_wallet => 500 + }) + end + end + + context "with fee type limitations" do + let(:subscription_fees) { [fee, fee2] } + let(:amount_cents) { 110 } + let(:fee) { create(:fee, invoice:, subscription:, amount_cents: 60, precise_amount_cents: 60, taxes_precise_amount_cents: 6) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 40, precise_amount_cents: 40, taxes_precise_amount_cents: 4) } + + before { subscription_fees } + + it "applies a single unrestricted wallet against the full invoice" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({priority_wallet => 110}) + end + + context "when wallet credits are less than invoice amount" do + let(:amount_cents) { 5150 } + let(:fee) { create(:fee, invoice:, subscription:, amount_cents: 3500, precise_amount_cents: 3500, taxes_precise_amount_cents: 100) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 1500, precise_amount_cents: 1500, taxes_precise_amount_cents: 50) } + + it "splits across all wallets honoring fee_type restrictions" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({ + priority_wallet => 1000, + priority_limited_charge_wallet => 1000, + priority_limited_subscription_wallet => 1000, + normal_wallet => 1000, + limited_charge_wallet => 550, + limited_subscription_wallet => 600 + }) + end + end + end + + context "with billable metric limitations" do + let(:limited_bm_wallet) do + create(:wallet, :with_inbound_transaction, name: "limited bm wallet", customer:, balance_cents: 1000, credits_balance: 10.0) + end + let(:priority_limited_bm_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority limited bm wallet", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49) + end + let(:wallets) do + [ + normal_wallet, + limited_subscription_wallet, + priority_limited_subscription_wallet, + limited_bm_wallet, + priority_limited_bm_wallet, + priority_limited_charge_wallet, + priority_wallet, + limited_charge_wallet + ] + end + let(:subscription_fees) { [fee, fee2] } + let(:amount_cents) { 110 } + let(:fee) { create(:fee, invoice:, subscription:, amount_cents: 60, precise_amount_cents: 60, taxes_precise_amount_cents: 6) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 40, precise_amount_cents: 40, taxes_precise_amount_cents: 4, charge:) } + let(:charge) { create(:standard_charge, organization: wallets.first.organization, billable_metric:) } + let(:billable_metric) { create(:billable_metric, organization: wallets.first.organization) } + + before do + subscription_fees + create(:wallet_target, wallet: limited_bm_wallet, billable_metric:) + create(:wallet_target, wallet: priority_limited_bm_wallet, billable_metric:) + end + + it "honors wallet_targets, splitting consumption per fee key" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({ + priority_limited_subscription_wallet => 66, + priority_limited_bm_wallet => 44 + }) + end + + context "when precise fees have decimals and is not matching invoice.total_amount_cents" do + # invoice.total_amount_cents is 114 + let(:amount_cents) { 114.4 } + let(:subscription_fees) { [fee2] } + + let(:fee2) do + create( + :charge_fee, + invoice:, + subscription:, + amount_cents: 44, + precise_amount_cents: 44, + taxes_precise_amount_cents: 4.4, + charge: + ) + end + + it "rounds the decimals" do + expect(result).to be_success + expect(result.wallet_transactions.values.sum).to eq(114) + end + end + end + + context "when wallet is limited to a fee processed last" do + let(:fee) { nil } + let(:amount_cents) { 680 } + + let(:wallet_limited_billable_metric) { create(:billable_metric, organization: customer.organization) } + let(:bm_wallet) do + create(:wallet, :with_inbound_transaction, customer:, balance_cents: 66, credits_balance: 0.66) + end + let(:wallets) { [bm_wallet] } + + before do + uuid = SecureRandom.uuid + 10.times do |i| + billable_metric = (i == 9) ? wallet_limited_billable_metric : create(:billable_metric, organization: customer.organization) + charge = create(:standard_charge, organization: customer.organization, billable_metric: billable_metric) + create( + :charge_fee, + id: "#{uuid[..-2]}#{i}", + invoice:, + subscription:, + charge:, + amount_cents: 60, precise_amount_cents: 60, + taxes_precise_amount_cents: 8.456, taxes_amount_cents: 8 + ) + end + create(:wallet_target, wallet: bm_wallet, billable_metric: wallet_limited_billable_metric) + end + + it "applies credits based on fee cap, not fee processing order" do + expect(result).to be_success + expect(result.wallet_transactions.size).to eq(1) + expect(result.wallet_transactions[bm_wallet]).to eq(66) + end + end + + context "when precise tax rounding causes fee caps to be slightly below invoice total" do + let(:normal_wallet) do + create(:wallet, :with_inbound_transaction, name: "normal", customer:, balance_cents: 200_000, credits_balance: 2000.0) + end + let(:wallets) { [normal_wallet] } + let(:amount_cents) { 106_826 } + let(:fee) { nil } + + before do + create(:charge_fee, invoice:, subscription:, + amount_cents: 50_000, precise_amount_cents: 50_000, + taxes_amount_cents: 3413, taxes_precise_amount_cents: BigDecimal("3412.7")) + create(:charge_fee, invoice:, subscription:, + amount_cents: 50_000, precise_amount_cents: 50_000, + taxes_amount_cents: 3413, taxes_precise_amount_cents: BigDecimal("3412.7")) + end + + it "applies the full invoice amount without a rounding gap" do + expect(result).to be_success + expect(result.wallet_transactions.values.sum).to eq(106_826) + end + end + + context "when wallet currency does not match invoice currency" do + let(:wallets) { [eur_wallet, usd_wallet] } + let(:eur_wallet) do + create(:wallet, :with_inbound_transaction, name: "eur wallet", customer:, balance_cents: 1000, currency: "EUR") + end + let(:usd_wallet) do + create(:wallet, :with_inbound_transaction, name: "usd wallet", customer:, balance_cents: 1000, currency: "USD") + end + + it "only includes wallets matching the invoice currency" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({eur_wallet => 100}) + end + end + + context "when no wallets match the invoice currency" do + let(:wallets) do + [create(:wallet, name: "usd wallet", customer:, balance_cents: 1000, currency: "USD")] + end + + it "returns success with an empty hash" do + expect(result).to be_success + expect(result.wallet_transactions).to eq({}) + end + end + end +end diff --git a/spec/services/credits/applied_coupon_service_spec.rb b/spec/services/credits/applied_coupon_service_spec.rb new file mode 100644 index 0000000..449e422 --- /dev/null +++ b/spec/services/credits/applied_coupon_service_spec.rb @@ -0,0 +1,534 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Credits::AppliedCouponService do + subject(:credit_service) do + described_class.new(invoice:, applied_coupon:) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + currency: "EUR", + sub_total_excluding_taxes_amount_cents: base_amount_cents + ) + end + let(:base_amount_cents) { 300 } + + let(:coupon) { create(:coupon, organization:) } + let(:applied_coupon) { create(:applied_coupon, amount_cents: 12, coupon:, customer:) } + + let(:fee1) { create(:fee, amount_cents: base_amount_cents / 3 * 2, invoice:) } + let(:fee2) { create(:fee, amount_cents: base_amount_cents / 3, invoice:) } + + before do + fee1 + fee2 + end + + context "without lock" do + describe "call" do + it "fails with a service failure" do + result = credit_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("no_lock_acquired") + end + end + end + + context "with lock acquired" do + around do |spec| + customer.with_advisory_lock("COUPONS-#{customer.id}", &spec) + end + + describe "call" do + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(12) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.before_taxes).to eq(true) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(8) + expect(fee2.reload.precise_coupons_amount_cents).to eq(4) + end + + it "terminates the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).to be_terminated + end + + context "when base_amount_cents is equal to 0" do + let(:base_amount_cents) { 0 } + + it "limits the credit amount to the invoice amount" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(0) + end + end + + context "when coupon amount is higher than invoice amount" do + let(:base_amount_cents) { 6 } + + it "limits the credit amount to the invoice amount" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(6) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(4) + expect(fee2.reload.precise_coupons_amount_cents).to eq(2) + end + + it "does not terminate the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).not_to be_terminated + end + end + + context "when credit has already been applied" do + before do + create( + :credit, + invoice:, + applied_coupon:, + amount_cents: 12, + amount_currency: "EUR" + ) + end + + it "does not create another credit" do + expect { credit_service.call } + .not_to change(Credit, :count) + end + end + + context "when coupon is partially used" do + before do + create( + :credit, + applied_coupon:, + amount_cents: 6 + ) + end + + it "applies the remaining amount" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(6) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(4) + expect(fee2.reload.precise_coupons_amount_cents).to eq(2) + end + + it "terminates the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).to be_terminated + end + end + + context "when coupon is percentage" do + let(:coupon) { create(:coupon, coupon_type: "percentage", percentage_rate: 10.00) } + + let(:applied_coupon) do + create(:applied_coupon, coupon:, percentage_rate: 20.00) + end + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(60) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(40) + expect(fee2.reload.precise_coupons_amount_cents).to eq(20) + end + + it "terminates the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).to be_terminated + end + end + + context "when coupon is recurring and fixed amount" do + let(:coupon) { create(:coupon, frequency: "recurring", frequency_duration: 3) } + + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + frequency: "recurring", + frequency_duration: 3, + frequency_duration_remaining: 3, + amount_cents: 12 + ) + end + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(12) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.applied_coupon.frequency_duration).to eq(3) + expect(result.credit.applied_coupon.frequency_duration_remaining).to eq(2) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(8) + expect(fee2.reload.precise_coupons_amount_cents).to eq(4) + end + + it "does not terminate the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).not_to be_terminated + end + + context "when coupon amount is higher than invoice amount" do + let(:base_amount_cents) { 6 } + + it "limits the credit amount to the invoice amount" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(6) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(4) + expect(fee2.reload.precise_coupons_amount_cents).to eq(2) + end + end + end + + context "when coupon is forever and fixed amount" do + let(:coupon) { create(:coupon, frequency: "forever", frequency_duration: 0) } + + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + frequency: "forever", + frequency_duration: 0, + frequency_duration_remaining: 0, + amount_cents: 12 + ) + end + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(12) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.applied_coupon.frequency_duration).to eq(0) + expect(result.credit.applied_coupon.frequency_duration_remaining).to eq(0) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(8) + expect(fee2.reload.precise_coupons_amount_cents).to eq(4) + end + + it "does not terminate the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).not_to be_terminated + end + + context "when coupon amount is higher than invoice amount" do + let(:base_amount_cents) { 6 } + + it "limits the credit amount to the invoice amount" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(6) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(4) + expect(fee2.reload.precise_coupons_amount_cents).to eq(2) + end + end + end + + context "when coupon is recurring and percentage" do + let(:coupon) do + create(:coupon, frequency: "recurring", frequency_duration: 3, coupon_type: "percentage", percentage_rate: 10) + end + + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + frequency: "recurring", + frequency_duration: 3, + frequency_duration_remaining: 3, + percentage_rate: 20.00 + ) + end + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(60) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.applied_coupon.frequency_duration).to eq(3) + expect(result.credit.applied_coupon.frequency_duration_remaining).to eq(2) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(40) + expect(fee2.reload.precise_coupons_amount_cents).to eq(20) + end + + it "does not terminate the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).not_to be_terminated + end + + context "when frequency duration becomes zero" do + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + frequency: "recurring", + frequency_duration: 3, + frequency_duration_remaining: 1, + percentage_rate: 20.00 + ) + end + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(60) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.applied_coupon.frequency_duration).to eq(3) + expect(result.credit.applied_coupon.frequency_duration_remaining).to eq(0) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(40) + expect(fee2.reload.precise_coupons_amount_cents).to eq(20) + end + + it "terminates the applied coupon" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload).to be_terminated + end + end + end + + context "when currencies does not match" do + let(:applied_coupon) do + create( + :applied_coupon, + customer:, + amount_cents: 10, + amount_currency: "NOK" + ) + end + + it "does not create a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit).to be_nil + end + + context "when coupon is percentage" do + let(:coupon) { create(:coupon, organization:, coupon_type: :percentage, percentage_rate: 10) } + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + customer:, + percentage_rate: 10, + amount_currency: "NOK" + ) + end + + it "creates a credit regardless of currency" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit).to be_present + expect(result.credit.amount_currency).to eq("EUR") + end + end + end + + context "when coupon have plan limitations" do + let(:coupon) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:plan) { create(:plan, organization:) } + let(:coupon_target) { create(:coupon_plan, coupon:, plan:) } + + let(:subscription) { create(:subscription, plan:, customer:) } + let(:fee1) { create(:fee, amount_cents: base_amount_cents, invoice:, subscription:) } + + before { coupon_target } + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(12) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.before_taxes).to eq(true) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(12) + expect(fee2.reload.precise_coupons_amount_cents).to eq(0) + end + + context "when plan limitation does not applies" do + let(:subscription) { create(:subscription, customer:) } + + it "does not create a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit).to be_nil + end + end + end + + context "when coupon have billable metric limitations" do + let(:coupon) { create(:coupon, coupon_type: "fixed_amount", limited_billable_metrics: true) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:) } + + let(:coupon_target) { create(:coupon_billable_metric, coupon:, billable_metric:) } + + let(:subscription) { create(:subscription, plan:, customer:) } + let(:fee1) { create(:charge_fee, charge:, amount_cents: base_amount_cents, invoice:, subscription:) } + + before { coupon_target } + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(12) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.before_taxes).to eq(true) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(12) + expect(fee2.reload.precise_coupons_amount_cents).to eq(0) + end + + context "with multiple fees and progressive billing credits already applied" do + let(:fee3) { create(:fee, amount_cents: base_amount_cents / 3, invoice:, subscription:) } + let(:fee1) do + create( + :charge_fee, + charge:, + amount_cents: base_amount_cents / 6, + invoice:, + subscription:, + precise_coupons_amount_cents: 15 # weighted prog. billing credits + ) + end + let(:fee2) do + create( + :charge_fee, + charge:, + amount_cents: base_amount_cents / 2, + invoice:, + subscription:, + precise_coupons_amount_cents: 45 # weighted prog.billing credits + ) + end + + before { fee3 } + + it "creates a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit.amount_cents).to eq(12) + expect(result.credit.amount_currency).to eq("EUR") + expect(result.credit.invoice).to eq(invoice) + expect(result.credit.applied_coupon).to eq(applied_coupon) + expect(result.credit.before_taxes).to eq(true) + + expect(fee1.reload.precise_coupons_amount_cents).to eq(18) + expect(fee2.reload.precise_coupons_amount_cents).to eq(54) + expect(fee3.reload.precise_coupons_amount_cents).to eq(0) + end + end + + context "when plan limitation does not applies" do + let(:charge) { create(:standard_charge, plan:) } + + it "does not create a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credit).to be_nil + end + end + end + + context "when frequency_duration_remaining is already 0" do + let(:coupon) { create(:coupon, organization:, frequency: "recurring", frequency_duration: 3) } + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + customer:, + amount_cents: 12, + frequency: "recurring", + frequency_duration: 3, + frequency_duration_remaining: 0 + ) + end + + it "does not decrement frequency_duration_remaining below 0" do + result = credit_service.call + + expect(result).to be_success + expect(applied_coupon.reload.frequency_duration_remaining).to eq(0) + end + end + end + end +end diff --git a/spec/services/credits/applied_coupons_service_spec.rb b/spec/services/credits/applied_coupons_service_spec.rb new file mode 100644 index 0000000..c2802d5 --- /dev/null +++ b/spec/services/credits/applied_coupons_service_spec.rb @@ -0,0 +1,359 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Credits::AppliedCouponsService do + subject(:credit_service) { described_class.new(invoice:) } + + let(:invoice) do + create( + :invoice, + fees_amount_cents: 100, + sub_total_excluding_taxes_amount_cents: 100, + currency: "EUR", + customer: subscription.customer + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + billing_time: :calendar, + subscription_at: started_at, + started_at:, + created_at:, + status: :active + ) + end + let(:started_at) { Time.zone.now - 2.years } + let(:created_at) { started_at } + + describe "#call" do + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:fee) { create(:fee, amount_cents: 100, invoice:, subscription:) } + let(:applied_coupon) do + create( + :applied_coupon, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { create(:coupon, coupon_type: "percentage", percentage_rate: 10.00) } + let(:applied_coupon_latest) do + create( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + percentage_rate: 20.00, + created_at: applied_coupon.created_at + 1.day + ) + end + + let(:plan) { create(:plan, interval: "monthly") } + + before do + create(:invoice_subscription, invoice:, subscription:) + fee + applied_coupon + applied_coupon_latest + end + + it "updates the invoice accordingly" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(28) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(72) + expect(result.invoice.credits.count).to eq(2) + end + + context "when first coupon covers the invoice" do + let(:invoice) do + create( + :invoice, + fees_amount_cents: 5, + sub_total_excluding_taxes_amount_cents: 5, + currency: "EUR", + customer: subscription.customer + ) + end + + before { fee.update!(amount_cents: 5) } + + it "updates the invoice accordingly and spends only the first coupon" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(5) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(0) + expect(result.invoice.credits.count).to eq(1) + end + end + + context "when both coupons are fixed amount" do + let(:coupon_latest) { create(:coupon, coupon_type: "fixed_amount") } + let(:applied_coupon_latest) do + create( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency, + created_at: applied_coupon.created_at + 1.day + ) + end + + it "updates the invoice accordingly" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(30) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(70) + expect(result.invoice.credits.count).to eq(2) + end + end + + context "when both coupons are percentage" do + let(:coupon) { create(:coupon, coupon_type: "percentage", percentage_rate: 10.00) } + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + customer: subscription.customer, + percentage_rate: 15.00 + ) + end + + it "updates the invoice accordingly" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(32) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(68) + expect(result.invoice.credits.count).to eq(2) + end + end + + context "when coupon has a difference currency" do + let(:applied_coupon) do + create( + :applied_coupon, + customer: subscription.customer, + amount_cents: 10, + amount_currency: "NOK" + ) + end + + before { applied_coupon_latest.update!(status: :terminated) } + + it "ignores the coupon" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.credits.count).to be_zero + end + end + + context "when both coupons have plan limitations which are not applicable" do + let(:coupon) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan: create(:plan)) } + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan_latest) { create(:coupon_plan, coupon: coupon_latest, plan: create(:plan)) } + let(:applied_coupon_latest) do + create( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency, + created_at: applied_coupon.created_at + 1.day + ) + end + + before do + coupon_plan + coupon_plan_latest + end + + it "ignores coupons" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(0) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(100) + expect(result.invoice.credits.count).to be_zero + end + end + + context "when only one coupon is applicable due to plan limitations" do + let(:coupon) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan: create(:plan)) } + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan_latest) { create(:coupon_plan, coupon: coupon_latest, plan:) } + let(:applied_coupon_latest) do + create( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency, + created_at: applied_coupon.created_at + 1.day + ) + end + + before do + coupon_plan + coupon_plan_latest + end + + it "ignores only one coupon and applies the other one" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(20) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(80) + expect(result.invoice.credits.count).to eq(1) + end + end + + context "when both coupons are applicable due to plan limitations" do + let(:coupon) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan:) } + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + customer: subscription.customer, + amount_cents: 10, + amount_currency: plan.amount_currency + ) + end + let(:coupon_latest) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan_latest) { create(:coupon_plan, coupon: coupon_latest, plan:) } + let(:applied_coupon_latest) do + create( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency, + created_at: applied_coupon.created_at + 1.day + ) + end + + before do + coupon_plan + coupon_plan_latest + end + + it "applies two coupons" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(30) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(70) + expect(result.invoice.credits.count).to eq(2) + end + end + + context "when there is combination of coupon limited to plans and coupon limited to billable metrics" do + let(:coupon) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan) { create(:coupon_plan, coupon:, plan:) } + let(:applied_coupon) do + create( + :applied_coupon, + coupon:, + customer: subscription.customer, + amount_cents: 82, + amount_currency: plan.amount_currency + ) + end + let(:coupon_middle) { create(:coupon, coupon_type: "fixed_amount", limited_billable_metrics: true) } + let(:billable_metric) { create(:billable_metric, organization: invoice.organization) } + let(:charge) { create(:standard_charge, billable_metric:) } + let(:coupon_bm_middle) do + create(:coupon_billable_metric, coupon: coupon_middle, billable_metric:) + end + let(:applied_coupon_middle) do + create( + :applied_coupon, + coupon: coupon_middle, + customer: subscription.customer, + amount_cents: 5, + amount_currency: plan.amount_currency, + created_at: applied_coupon.created_at + 2.hours + ) + end + let(:coupon_latest) { create(:coupon, coupon_type: "fixed_amount", limited_plans: true) } + let(:coupon_plan_latest) { create(:coupon_plan, coupon: coupon_latest, plan: create(:plan)) } + let(:applied_coupon_latest) do + create( + :applied_coupon, + coupon: coupon_latest, + customer: subscription.customer, + amount_cents: 20, + amount_currency: plan.amount_currency, + created_at: applied_coupon.created_at + 1.day + ) + end + let(:fee_middle) do + create( + :fee, + invoice:, + charge:, + amount_cents: 12, + amount_currency: "EUR", + taxes_amount_cents: 3 + ) + end + let(:fee) do + create( + :fee, + invoice:, + subscription:, + amount_cents: 75, + amount_currency: "EUR", + taxes_amount_cents: 5 + ) + end + + before do + coupon_plan + coupon_plan_latest + charge + coupon_bm_middle + applied_coupon_middle + fee_middle + end + + it "applies two coupons" do + result = credit_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(80) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(20) + expect(result.invoice.credits.count).to eq(2) + end + end + end +end diff --git a/spec/services/credits/applied_prepaid_credits_service_spec.rb b/spec/services/credits/applied_prepaid_credits_service_spec.rb new file mode 100644 index 0000000..c5c0cb0 --- /dev/null +++ b/spec/services/credits/applied_prepaid_credits_service_spec.rb @@ -0,0 +1,702 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Credits::AppliedPrepaidCreditsService do + let(:invoice) do + create( + :invoice, + customer:, + currency: "EUR", + total_amount_cents: amount_cents + ) + end + let(:fee) { + create(:charge_fee, invoice:, subscription:, + amount_cents: fee_amount_cents, precise_amount_cents: fee_amount_cents, + taxes_precise_amount_cents: 0) + } + let(:amount_cents) { 100 } + let(:fee_amount_cents) { 100 } + + let(:normal_wallet) do + create(:wallet, :with_inbound_transaction, name: "normal", customer:, balance_cents: 1000, credits_balance: 10.0) + end + + let(:priority_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49) + end + + let(:limited_charge_wallet) do + create(:wallet, :with_inbound_transaction, name: "limited charge", customer:, balance_cents: 1000, credits_balance: 10.0, allowed_fee_types: %w[charge]) + end + + let(:priority_limited_charge_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority limited charge", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49, allowed_fee_types: %w[charge]) + end + + let(:limited_subscription_wallet) do + create(:wallet, :with_inbound_transaction, name: "limited subscription", customer:, balance_cents: 1000, credits_balance: 10.0, allowed_fee_types: %w[subscription]) + end + + let(:priority_limited_subscription_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority limited subscription", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49, allowed_fee_types: %w[subscription]) + end + let(:wallets) do + [ + normal_wallet, + priority_wallet, + limited_charge_wallet, + priority_limited_charge_wallet, + limited_subscription_wallet, + priority_limited_subscription_wallet + ] + end + let(:customer) { create(:customer) } + let(:subscription) { create(:subscription, customer:) } + + before do + wallets + fee + subscription + end + + describe "#call" do + subject(:result) { described_class.call(invoice:) } + + it "calculates prepaid credit" do + expect(result).to be_success + expect(result.prepaid_credit_amount_cents).to eq(100) + expect(invoice.prepaid_credit_amount_cents).to eq(100) + end + + context "when customer has no applicable wallets" do + let(:wallets) { [] } + + it "returns early with empty values and no side effects" do + expect(result).to be_success + expect(result.prepaid_credit_amount_cents).to eq(0) + expect(result.wallet_transactions).to eq([]) + expect(invoice.prepaid_credit_amount_cents).to eq(0) + end + end + + it "creates wallet transaction" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(1) + expect(result.wallet_transactions.first.wallet_id).to eq(priority_wallet.id) + expect(result.wallet_transactions.first.amount).to eq(1.0) + expect(result.wallet_transactions.first).to be_invoiced + expect(result.prepaid_credit_amount_cents).to eq(100) + expect(invoice.prepaid_credit_amount_cents).to eq(100) + end + + it "updates wallet balance" do + subject + wallet = priority_wallet.reload + + expect(wallet.id).to eq(priority_wallet.id) + expect(wallet.balance_cents).to eq(900) + expect(wallet.credits_balance).to eq(9.0) + + [ + normal_wallet, + limited_charge_wallet, + priority_limited_charge_wallet, + limited_subscription_wallet, + priority_limited_subscription_wallet + ].each do |w| + expect(w.reload.balance_cents).to eq(1000) + end + end + + it "enqueues a SendWebhookJob" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob) + .with("wallet_transaction.created", WalletTransaction) + end + + it "produces an activity log" do + wallet_transaction = result.wallet_transactions.first + + expect(Utils::ActivityLog).to have_produced("wallet_transaction.created").after_commit.with(wallet_transaction) + end + + context "when priority wallet credits are less than invoice amount" do + let(:amount_cents) { 1500 } + let(:fee_amount_cents) { 1500 } + + it "creates wallet transactions" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(2) + + wallet_transaction_1 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_wallet.id } + wallet_transaction_2 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_charge_wallet.id } + + expect(wallet_transaction_1.amount).to eq(10.0) + expect(wallet_transaction_2.amount).to eq(5.0) + expect(result.prepaid_credit_amount_cents).to eq(1500) + expect(invoice.prepaid_credit_amount_cents).to eq(1500) + end + + it "updates wallets balance" do + subject + wallet_priority = priority_wallet.reload + wallet_priority_limited_charge = priority_limited_charge_wallet.reload + + expect(wallet_priority.balance).to eq(0.0) + expect(wallet_priority.credits_balance).to eq(0.0) + expect(wallet_priority_limited_charge.balance_cents).to eq(500) + expect(wallet_priority_limited_charge.credits_balance).to eq(5.0) + [normal_wallet, + limited_charge_wallet, + limited_subscription_wallet, + priority_limited_subscription_wallet].each do |w| + expect(w.reload.balance_cents).to eq(1000) + end + end + end + + context "when already applied" do + let(:wallet_transaction) { create(:wallet_transaction, wallet: wallets.first, invoice:, transaction_type: "outbound") } + + before { wallet_transaction } + + it "returns error" do + expect(result).not_to be_success + expect(result.error.code).to eq("already_applied") + expect(result.error.error_message).to eq("Prepaid credits already applied") + end + end + + context "with fee type limitations" do + let(:subscription_fees) { [fee, fee2] } + let(:amount_cents) { 110 } + let(:fee) { create(:fee, invoice:, subscription:, amount_cents: 60, precise_amount_cents: 60, taxes_precise_amount_cents: 6) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 40, precise_amount_cents: 40, taxes_precise_amount_cents: 4) } + + before { subscription_fees } + + it "creates wallet transaction" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(1) + expect(result.wallet_transactions.first.wallet_id).to eq(priority_wallet.id) + expect(result.wallet_transactions.first.amount).to eq(1.10) + expect(result.prepaid_credit_amount_cents).to eq(110) + expect(invoice.prepaid_credit_amount_cents).to eq(110) + end + + it "updates wallet balance" do + subject + wallet = priority_wallet.reload + + expect(wallet.balance_cents).to eq(890) + expect(wallet.credits_balance).to eq(8.90) + [normal_wallet, + limited_charge_wallet, + priority_limited_charge_wallet, + limited_subscription_wallet, + priority_limited_subscription_wallet].each do |w| + expect(w.reload.balance_cents).to eq(1000) + end + end + + context "when wallet credits are less than invoice amount" do + let(:amount_cents) { 5150 } + let(:fee) { create(:fee, invoice:, subscription:, amount_cents: 3500, precise_amount_cents: 3500, taxes_precise_amount_cents: 100) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 1500, precise_amount_cents: 1500, taxes_precise_amount_cents: 50) } + + it "creates wallet transaction" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(6) + wallet_transaction_1 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_wallet.id } + wallet_transaction_2 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_charge_wallet.id } + wallet_transaction_3 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_subscription_wallet.id } + wallet_transaction_4 = result.wallet_transactions.detect { |tx| tx.wallet_id == normal_wallet.id } + wallet_transaction_5 = result.wallet_transactions.detect { |tx| tx.wallet_id == limited_charge_wallet.id } + wallet_transaction_6 = result.wallet_transactions.detect { |tx| tx.wallet_id == limited_subscription_wallet.id } + + expect(wallet_transaction_1.amount).to eq(10.0) + expect(wallet_transaction_2.amount).to eq(10.0) + expect(wallet_transaction_3.amount).to eq(10.0) + expect(wallet_transaction_4.amount).to eq(10.0) + expect(wallet_transaction_5.amount).to eq(5.5) + expect(wallet_transaction_6.amount).to eq(6.0) + expect(result.prepaid_credit_amount_cents).to eq(5150) + expect(invoice.prepaid_credit_amount_cents).to eq(5150) + end + + it "updates wallet balance" do + subject + + expect(normal_wallet.reload.balance_cents).to eq(0) + expect(priority_wallet.reload.balance_cents).to eq(0) + expect(limited_charge_wallet.reload.balance_cents).to eq(450) + expect(priority_limited_charge_wallet.reload.balance_cents).to eq(0) + expect(limited_subscription_wallet.reload.balance_cents).to eq(400) + expect(priority_limited_subscription_wallet.reload.balance_cents).to eq(0) + end + end + end + + context "with billable metric limitations" do + let(:limited_bm_wallet) do + create(:wallet, :with_inbound_transaction, name: "limited bm wallet", customer:, balance_cents: 1000, credits_balance: 10.0) + end + let(:priority_limited_bm_wallet) do + create(:wallet, :with_inbound_transaction, name: "priority limited bm wallet", customer:, balance_cents: 1000, credits_balance: 10.0, priority: 49) + end + let(:wallets) do + [ + normal_wallet, + limited_subscription_wallet, + priority_limited_subscription_wallet, + limited_bm_wallet, + priority_limited_bm_wallet, + priority_limited_charge_wallet, + priority_wallet, + limited_charge_wallet + ] + end + let(:subscription_fees) { [fee, fee2] } + let(:amount_cents) { 110 } + let(:fee) { create(:fee, invoice:, subscription:, amount_cents: 60, precise_amount_cents: 60, taxes_precise_amount_cents: 6) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 40, precise_amount_cents: 40, taxes_precise_amount_cents: 4, charge:) } + let(:charge) { create(:standard_charge, organization: wallets.first.organization, billable_metric:) } + let(:billable_metric) { create(:billable_metric, organization: wallets.first.organization) } + let(:wallet_targets) do + create(:wallet_target, wallet: limited_bm_wallet, billable_metric:) + create(:wallet_target, wallet: priority_limited_bm_wallet, billable_metric:) + end + + before do + subscription_fees + wallet_targets + end + + it "creates wallet transaction" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(2) + + wallet_transaction_1 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_subscription_wallet.id } + wallet_transaction_2 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_bm_wallet.id } + + expect(wallet_transaction_1.amount).to eq(0.66) + expect(wallet_transaction_2.amount).to eq(0.44) + expect(wallet_transaction_1).to be_invoiced + expect(wallet_transaction_2).to be_invoiced + expect(result.prepaid_credit_amount_cents).to eq(110) + expect(invoice.prepaid_credit_amount_cents).to eq(110) + end + + it "updates wallet balance" do + subject + wallet_priority_limited_subscription = priority_limited_subscription_wallet.reload + wallet_priority_limited_bm = priority_limited_bm_wallet.reload + expect(wallet_priority_limited_subscription.balance_cents).to eq(934) + expect(wallet_priority_limited_subscription.credits_balance).to eq(9.34) + expect(wallet_priority_limited_bm.balance_cents).to eq(956) + expect(wallet_priority_limited_bm.credits_balance).to eq(9.56) + + [ + normal_wallet, + limited_bm_wallet, + priority_limited_charge_wallet, + priority_wallet, + limited_charge_wallet + ].each do |w| + expect(w.reload.balance_cents).to eq(1000) + end + end + + context "when precise fees have decimals" do + let(:amount_cents) { 114.4 } + let(:subscription_fees) { [fee2] } + + let(:fee2) do + create( + :charge_fee, + invoice:, + subscription:, + amount_cents: 44, + precise_amount_cents: 44, + taxes_precise_amount_cents: 4.4, + charge: + ) + end + + it "rounds the decimals" do + expect(result).to be_success + expect(result.prepaid_credit_amount_cents).to eq(114) + end + end + + context "when wallet credits are less than invoice amount" do + let(:subscription_fees) { [fee, fee2] } + let(:amount_cents) { 10_000 } + let(:fee) { create(:fee, invoice:, subscription:, amount_cents: 2_000, precise_amount_cents: 2_000, taxes_precise_amount_cents: 200) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 1_000, precise_amount_cents: 1_000, taxes_precise_amount_cents: 100, charge:) } + + it "creates wallet transaction" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(5) + + wallet_transaction_1 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_subscription_wallet.id } + wallet_transaction_2 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_bm_wallet.id } + wallet_transaction_3 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_limited_charge_wallet.id } + wallet_transaction_4 = result.wallet_transactions.detect { |tx| tx.wallet_id == priority_wallet.id } + wallet_transaction_5 = result.wallet_transactions.detect { |tx| tx.wallet_id == normal_wallet.id } + + expect(wallet_transaction_1.amount).to eq(10) + expect(wallet_transaction_2.amount).to eq(10) + expect(wallet_transaction_3.amount).to eq(1) + expect(wallet_transaction_4.amount).to eq(10) + expect(wallet_transaction_5.amount).to eq(2) + expect(result.prepaid_credit_amount_cents).to eq(3300) + expect(invoice.prepaid_credit_amount_cents).to eq(3300) + end + + it "updates wallet balance" do + subject + + expect(normal_wallet.reload.balance_cents).to eq(800) + expect(priority_wallet.reload.balance_cents).to eq(0) + expect(limited_charge_wallet.reload.balance_cents).to eq(1000) + expect(priority_limited_charge_wallet.reload.balance_cents).to eq(900) + expect(limited_subscription_wallet.reload.balance_cents).to eq(1000) + expect(priority_limited_subscription_wallet.reload.balance_cents).to eq(0) + expect(limited_bm_wallet.reload.balance_cents).to eq(1000) + expect(priority_limited_bm_wallet.reload.balance_cents).to eq(0) + end + end + end + + context "when wallet is limited to a fee processed last" do + let(:fee) { nil } + let(:amount_cents) { 680 } + + let(:wallet_limited_billable_metric) { create(:billable_metric, organization: customer.organization) } + let(:wallet) do + create(:wallet, :with_inbound_transaction, customer:, balance_cents: 6600, credits_balance: 66.0) + end + let(:wallet_target) { create(:wallet_target, wallet: wallet, billable_metric: wallet_limited_billable_metric) } + let(:wallets) { [wallet] } + + before do + uuid = SecureRandom.uuid + 10.times do |i| + billable_metric = (i == 9) ? wallet_limited_billable_metric : create(:billable_metric, organization: customer.organization) + charge = create(:standard_charge, organization: customer.organization, billable_metric: billable_metric) + create( + :charge_fee, + id: "#{uuid[..-2]}#{i}", # enforce database order to avoid flaky tests + invoice:, + subscription:, + charge: charge, + amount_cents: 60, precise_amount_cents: 60.4, taxes_precise_amount_cents: 8.456, taxes_amount_cents: 8 + ) + end + wallet_target + end + + it "applies credits based on fee cap, not fee processing order" do + expect(result).to be_success + expect(result.prepaid_credit_amount_cents).to eq(68) + expect(invoice.prepaid_credit_amount_cents).to eq(68) + end + end + + context "when wallet is traceable" do + let(:wallets) { [traceable_wallet] } + let(:traceable_wallet) do + create(:wallet, name: "traceable", customer:, balance_cents: 1000, credits_balance: 10.0, traceable: true) + end + let!(:inbound_transaction) do + create(:wallet_transaction, + wallet: traceable_wallet, + organization: traceable_wallet.organization, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000) + end + + it "tracks consumption from inbound transactions" do + expect { result }.to change(WalletTransactionConsumption, :count).by(1) + end + + it "creates consumption record linking inbound and outbound" do + result + + consumption = WalletTransactionConsumption.last + expect(consumption.inbound_wallet_transaction).to eq(inbound_transaction) + expect(consumption.outbound_wallet_transaction).to eq(result.wallet_transactions.first) + expect(consumption.consumed_amount_cents).to eq(100) + end + + it "decrements remaining_amount_cents on inbound transaction" do + result + + expect(inbound_transaction.reload.remaining_amount_cents).to eq(900) + end + + it "sets prepaid_granted_credit_amount_cents on invoice" do + result + + expect(invoice.prepaid_granted_credit_amount_cents).to eq(100) + expect(invoice.prepaid_purchased_credit_amount_cents).to be_nil + end + + context "when inbound transaction is purchased" do + let(:inbound_transaction) do + create(:wallet_transaction, + wallet: traceable_wallet, + organization: traceable_wallet.organization, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000) + end + + before { inbound_transaction } + + it "sets prepaid_purchased_credit_amount_cents on invoice" do + result + + expect(invoice.prepaid_granted_credit_amount_cents).to be_nil + expect(invoice.prepaid_purchased_credit_amount_cents).to eq(100) + end + end + + context "when consuming from both granted and purchased transactions" do + let(:amount_cents) { 500 } + let(:fee_amount_cents) { 500 } + + let!(:inbound_transaction) do + create(:wallet_transaction, + wallet: traceable_wallet, + organization: traceable_wallet.organization, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 3, + credit_amount: 3, + remaining_amount_cents: 300) + end + + let(:purchased_transaction) do + create(:wallet_transaction, + wallet: traceable_wallet, + organization: traceable_wallet.organization, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 7, + credit_amount: 7, + remaining_amount_cents: 700) + end + + before do + inbound_transaction + purchased_transaction + end + + it "sets both breakdown amounts on invoice" do + result + + expect(invoice.prepaid_granted_credit_amount_cents).to eq(300) + expect(invoice.prepaid_purchased_credit_amount_cents).to eq(200) + end + end + end + + context "when wallet is not traceable" do + let(:wallets) { [non_traceable_wallet] } + let(:non_traceable_wallet) do + create(:wallet, name: "non-traceable", customer:, balance_cents: 1000, credits_balance: 10.0, traceable: false) + end + + it "does not create consumption records" do + expect { result }.not_to change(WalletTransactionConsumption, :count) + end + + it "does not set breakdown amounts on invoice" do + result + + expect(invoice.prepaid_granted_credit_amount_cents).to be_nil + expect(invoice.prepaid_purchased_credit_amount_cents).to be_nil + end + end + + context "when customer has both traceable and non-traceable wallets" do + let(:wallets) { [traceable_wallet, non_traceable_wallet] } + let(:traceable_wallet) do + create(:wallet, name: "traceable", customer:, balance_cents: 500, credits_balance: 5.0, traceable: true) + end + let(:non_traceable_wallet) do + create(:wallet, name: "non-traceable", customer:, balance_cents: 500, credits_balance: 5.0, traceable: false) + end + let(:inbound_transaction) do + create(:wallet_transaction, + wallet: traceable_wallet, + organization: traceable_wallet.organization, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 5, + credit_amount: 5, + remaining_amount_cents: 500) + end + + before { inbound_transaction } + + it "does not set breakdown amounts on invoice" do + result + + expect(invoice.prepaid_granted_credit_amount_cents).to be_nil + expect(invoice.prepaid_purchased_credit_amount_cents).to be_nil + end + end + + context "when customer has multiple traceable wallets" do + let(:amount_cents) { 500 } + let(:fee_amount_cents) { 500 } + let(:wallets) { [traceable_wallet1, traceable_wallet2] } + let(:traceable_wallet1) do + create(:wallet, name: "traceable1", customer:, balance_cents: 300, credits_balance: 3.0, traceable: true, priority: 1) + end + let(:traceable_wallet2) do + create(:wallet, name: "traceable2", customer:, balance_cents: 400, credits_balance: 4.0, traceable: true, priority: 2) + end + let(:inbound_transaction1) do + create(:wallet_transaction, + wallet: traceable_wallet1, + organization: traceable_wallet1.organization, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 3, + credit_amount: 3, + remaining_amount_cents: 300) + end + let(:inbound_transaction2) do + create(:wallet_transaction, + wallet: traceable_wallet2, + organization: traceable_wallet2.organization, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 4, + credit_amount: 4, + remaining_amount_cents: 400) + end + + before do + inbound_transaction1 + inbound_transaction2 + end + + it "creates consumption records for both wallets" do + expect { result }.to change(WalletTransactionConsumption, :count).by(2) + end + + it "sums breakdown amounts from both wallets" do + result + + expect(invoice.prepaid_granted_credit_amount_cents).to eq(300) + expect(invoice.prepaid_purchased_credit_amount_cents).to eq(200) + end + + it "decrements remaining_amount_cents on both inbound transactions" do + result + + expect(inbound_transaction1.reload.remaining_amount_cents).to eq(0) + expect(inbound_transaction2.reload.remaining_amount_cents).to eq(200) + end + end + + context "when precise tax rounding causes fee caps to be slightly below invoice total" do + let(:normal_wallet) do + create(:wallet, :with_inbound_transaction, name: "normal", customer:, balance_cents: 200_000, credits_balance: 2000.0) + end + let(:wallets) { [normal_wallet] } + let(:amount_cents) { 106_826 } + let(:fee) { nil } + + before do + create(:charge_fee, invoice:, subscription:, + amount_cents: 50_000, precise_amount_cents: 50_000, + taxes_amount_cents: 3413, taxes_precise_amount_cents: BigDecimal("3412.7")) + create(:charge_fee, invoice:, subscription:, + amount_cents: 50_000, precise_amount_cents: 50_000, + taxes_amount_cents: 3413, taxes_precise_amount_cents: BigDecimal("3412.7")) + end + + it "applies the full invoice amount without rounding gap" do + expect(result).to be_success + expect(result.prepaid_credit_amount_cents).to eq(106_826) + expect(invoice.prepaid_credit_amount_cents).to eq(106_826) + end + end + + context "when wallet currency does not match invoice currency" do + let(:wallets) { [eur_wallet, usd_wallet] } + let(:eur_wallet) do + create(:wallet, :with_inbound_transaction, name: "eur wallet", customer:, balance_cents: 1000, currency: "EUR") + end + let(:usd_wallet) do + create(:wallet, :with_inbound_transaction, name: "usd wallet", customer:, balance_cents: 1000, currency: "USD") + end + + it "only applies credits from wallets matching the invoice currency" do + expect(result).to be_success + expect(result.wallet_transactions.count).to eq(1) + expect(result.wallet_transactions.first.wallet_id).to eq(eur_wallet.id) + expect(result.prepaid_credit_amount_cents).to eq(100) + end + end + + context "when no wallets match the invoice currency" do + let(:wallets) do + [create(:wallet, name: "usd wallet", customer:, balance_cents: 1000, currency: "USD")] + end + + it "returns early with no credits applied" do + expect(result).to be_success + expect(result.prepaid_credit_amount_cents).to eq(0) + expect(result.wallet_transactions).to eq([]) + end + end + + context "when there is a concurrent lock" do + before do + stub_const("Customers::LockService::ACQUIRE_LOCK_TIMEOUT", 1.second) + end + + around do |test| + with_advisory_lock("customer-#{customer.id}-prepaid_credit", lock_released_after:) do + test.run + end + end + + context "when it fails to acquire the lock" do + let(:lock_released_after) { 2.seconds } + + it "raises a Customers::FailedToAcquireLock error" do + expect { subject }.to raise_error(Customers::FailedToAcquireLock) + end + end + + context "when the lock is acquired" do + let(:lock_released_after) { 0.6.seconds } + + it "creates the invoice" do + expect { subject }.not_to raise_error + end + end + end + end +end diff --git a/spec/services/credits/credit_note_service_spec.rb b/spec/services/credits/credit_note_service_spec.rb new file mode 100644 index 0000000..5b6e207 --- /dev/null +++ b/spec/services/credits/credit_note_service_spec.rb @@ -0,0 +1,187 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Credits::CreditNoteService do + subject(:credit_service) { described_class.new(invoice:, context:) } + + let(:invoice) do + create( + :invoice, + customer:, + currency: "EUR", + total_amount_cents: amount_cents + ) + end + + let(:amount_cents) { 100 } + let(:subscription) { create(:subscription, customer:) } + let(:subscription_fees) { [fee1, fee2] } + let(:fee1) { create(:fee, invoice:, subscription:, amount_cents: 60, taxes_amount_cents: 0) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 40, taxes_amount_cents: 0) } + let(:customer) { create(:customer) } + let(:context) { nil } + + let(:credit_note1) do + create( + :credit_note, + total_amount_cents: 20, + balance_amount_cents: 20, + credit_amount_cents: 20, + customer: + ) + end + + let(:credit_note2) do + create( + :credit_note, + total_amount_cents: 50, + balance_amount_cents: 50, + credit_amount_cents: 50, + customer: + ) + end + + before do + credit_note1 + credit_note2 + subscription_fees + end + + describe "concurrent invoice processing", transaction: false do + let(:amount_cents) { 3100 } + let(:credit_cents) { 1000 } + let(:customer1) { create(:customer) } + + let(:invoice1) { create(:invoice, customer: customer1, total_amount_cents: amount_cents) } + let(:invoice2) { create(:invoice, customer: customer1, total_amount_cents: amount_cents) } + let(:invoice3) { create(:invoice, customer: customer1, total_amount_cents: amount_cents) } + + let(:credit_note1) { create(:credit_note, customer: customer1, balance_amount_cents: credit_cents) } + let(:credit_note2) { create(:credit_note, customer: customer1, balance_amount_cents: credit_cents) } + let(:credit_note3) { create(:credit_note, customer: customer1, balance_amount_cents: credit_cents) } + + before do + credit_note1 + credit_note2 + credit_note3 + end + + it "applies credit notes correctly under concurrent access" do + threads = [invoice1, invoice2, invoice3].map do |invoice| + Thread.new do + described_class.call(invoice:) + end + end + + threads.each(&:join) + + expect(credit_note1.reload.balance_amount_cents).to eq(0) + expect(credit_note2.reload.balance_amount_cents).to eq(0) + expect(credit_note3.reload.balance_amount_cents).to eq(0) + expect( + invoice1.credits.sum(:amount_cents) + invoice2.credits.sum(:amount_cents) + invoice3.credits.sum(:amount_cents) + ).to eq(credit_cents * 3) + end + end + + describe ".call" do + it "creates a list of credits" do + result = credit_service.call + + expect(result).to be_success + expect(result.credits.count).to eq(2) + + credit1 = result.credits.first + expect(credit1.invoice).to eq(invoice) + expect(credit1.credit_note).to eq(credit_note1) + expect(credit1.amount_cents).to eq(20) + expect(credit1.amount_currency).to eq("EUR") + expect(credit1.before_taxes).to eq(false) + expect(credit_note1.reload.balance_amount_cents).to be_zero + expect(credit_note1).to be_consumed + + credit2 = result.credits.last + expect(credit2.invoice).to eq(invoice) + expect(credit2.credit_note).to eq(credit_note2) + expect(credit2.amount_cents).to eq(50) + expect(credit2.amount_currency).to eq("EUR") + expect(credit2.before_taxes).to eq(false) + expect(credit_note2.reload.balance_amount_cents).to be_zero + expect(credit_note1).to be_consumed + + expect(invoice.credit_notes_amount_cents).to eq(70) + + expect(fee1.reload.precise_credit_notes_amount_cents).to eq(42) + expect(fee2.reload.precise_credit_notes_amount_cents).to eq(28) + end + + it "creates credits in the database" do + expect { credit_service.call }.to change(Credit, :count).by(2) + end + + context "when preview mode" do + let(:context) { :preview } + + it "does not create credits in the database" do + expect { credit_service.call }.not_to change(Credit, :count) + end + end + + context "when invoice amount is 0" do + let(:amount_cents) { 0 } + + it "does not create a credit" do + result = credit_service.call + + expect(result).to be_success + expect(result.credits.count).to eq(0) + end + end + + context "when credit notes have a different currency than the invoice" do + let(:credit_note_usd) do + create( + :credit_note, + total_amount_cents: 30, + total_amount_currency: "USD", + balance_amount_cents: 30, + balance_amount_currency: "USD", + credit_amount_cents: 30, + credit_amount_currency: "USD", + customer: + ) + end + + before { credit_note_usd } + + it "only applies credit notes matching the invoice currency" do + result = credit_service.call + + expect(result).to be_success + expect(result.credits.count).to eq(2) + expect(result.credits.map(&:credit_note)).to match_array([credit_note1, credit_note2]) + expect(credit_note_usd.reload.balance_amount_cents).to eq(30) + end + end + + context "when credit amount is higher than invoice amount" do + let(:amount_cents) { 10 } + + it "creates a credit with partial credit note amount" do + result = credit_service.call + + expect(result).to be_success + expect(result.credits.count).to eq(1) + + credit = result.credits.first + expect(credit.invoice).to eq(invoice) + expect(credit.credit_note).to eq(credit_note1) + expect(credit.amount_cents).to eq(10) + expect(credit.amount_currency).to eq("EUR") + expect(credit_note1.reload.balance_amount_cents).to eq(10) + expect(credit_note1).to be_available + end + end + end +end diff --git a/spec/services/credits/progressive_billing_service_spec.rb b/spec/services/credits/progressive_billing_service_spec.rb new file mode 100644 index 0000000..969e697 --- /dev/null +++ b/spec/services/credits/progressive_billing_service_spec.rb @@ -0,0 +1,556 @@ +# frozen_string_literal: true + +require "rails_helper" + +Rspec.describe Credits::ProgressiveBillingService do + subject(:credit_service) { described_class.new(invoice:) } + + let(:subscription) { create(:subscription, customer_id: customer.id) } + let(:organization) { subscription.organization } + let(:customer) { create(:customer) } + let(:subscriptions) { [subscription] } + + let(:invoice) do + create(:invoice, + :subscription, + customer:, + organization:, + sub_total_excluding_taxes_amount_cents: 1000, + subscriptions: subscriptions) + end + + let(:subscription_fees) { [subscription_fee1, subscription_fee2] } + let(:subscription_fee1) { create(:charge_fee, invoice:, subscription:, amount_cents: 500) } + let(:subscription_fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 500) } + + before do + invoice + invoice.invoice_subscriptions.each { |is| is.update!(charges_from_datetime: invoice.issuing_date - 1.month, timestamp: invoice.issuing_date, charges_to_datetime: invoice.issuing_date) } + subscription_fees + end + + context "without progressive billing invoices" do + describe "#call" do + it "does not apply any credit to the invoice" do + result = credit_service.call + expect(result.credits).to be_empty + expect(invoice.progressive_billing_credit_amount_cents).to be_zero + end + end + end + + context "with one progressive billing invoice for the sole subscription" do + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day, + fees_amount_cents: 20 + ) + end + + let(:progressive_billing_fee) { + create(:charge_fee, + amount_cents: 20, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice) + } + + before do + progressive_billing_invoice + progressive_billing_fee + progressive_billing_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice.issuing_date, + timestamp: progressive_billing_invoice.issuing_date + ) + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + expect(credit.amount_cents).to eq(20) + expect(invoice.progressive_billing_credit_amount_cents).to eq(20) + expect(subscription_fee1.reload.precise_coupons_amount_cents).to eq(20) + expect(subscription_fee2.reload.precise_coupons_amount_cents).to eq(0) + end + + context "when progressive billing credits are greater than amount cents" do + let(:subscription_fee1) { create(:charge_fee, invoice:, subscription:, amount_cents: 19) } + + it "applies correctly one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + expect(credit.amount_cents).to eq(19) + expect(invoice.progressive_billing_credit_amount_cents).to eq(19) + expect(subscription_fee1.reload.precise_coupons_amount_cents).to eq(19) + expect(subscription_fee2.reload.precise_coupons_amount_cents).to eq(0) + end + end + + context "with additional subscription fee" do + let(:subscription_fees) { [subscription_fee1, subscription_fee2, subscription_fee3] } + let(:subscription_fee1) { create(:charge_fee, invoice:, subscription:, amount_cents: 300) } + let(:subscription_fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 300) } + let(:subscription_fee3) { create(:fee, invoice:, subscription:, amount_cents: 400) } + + it "calculate correctly credits and weighted amounts" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + + expect(credit.amount_cents).to eq(20) + expect(invoice.progressive_billing_credit_amount_cents).to eq(20) + + expect(subscription_fee1.reload.precise_coupons_amount_cents).to eq(20) + expect(subscription_fee2.reload.precise_coupons_amount_cents).to eq(0) + expect(subscription_fee3.reload.precise_coupons_amount_cents).to eq(0) + end + end + + context "when progressive billing invoice has the same charges as the final invoice" do + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day, + fees_amount_cents: 40 + ) + end + + let(:progressive_billing_fee2) { + create(:charge_fee, + amount_cents: 20, + charge: subscription_fee2.charge, + invoice: progressive_billing_invoice) + } + + before { progressive_billing_fee2 } + + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + expect(credit.amount_cents).to eq(40) + expect(invoice.progressive_billing_credit_amount_cents).to eq(40) + expect(subscription_fee1.reload.precise_coupons_amount_cents).to eq(20) + expect(subscription_fee2.reload.precise_coupons_amount_cents).to eq(20) + end + end + end + end + + context "with multiple progressive billing invoices for the sole subscription" do + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 2.days, + created_at: invoice.issuing_date - 2.days, + fees_amount_cents: 20 + ) + end + + let(:progressive_billing_invoice2) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day, + fees_amount_cents: 200 + ) + end + let(:progressive_billing_fee) do + create(:charge_fee, + amount_cents: 20, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice) + end + let(:progressive_billing_fee2) do + create(:charge_fee, + amount_cents: 200, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice2) + end + + before do + progressive_billing_fee + progressive_billing_fee2 + progressive_billing_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice.issuing_date, + timestamp: progressive_billing_invoice.issuing_date + ) + progressive_billing_invoice2.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice2.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice2.issuing_date, + timestamp: progressive_billing_invoice2.issuing_date + ) + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + sole_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice2 } + expect(sole_credit.amount_cents).to eq(200) + + expect(invoice.progressive_billing_credit_amount_cents).to eq(200) + end + end + end + + context "with multiple progressive billing invoices on the same date for the sole subscription" do + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day, + fees_amount_cents: 20 + ) + end + + let(:progressive_billing_invoice2) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day + 10.minutes, + fees_amount_cents: 200 + ) + end + let(:progressive_billing_fee) do + create(:charge_fee, + amount_cents: 20, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice) + end + let(:progressive_billing_fee2) do + create(:charge_fee, + amount_cents: 200, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice2) + end + + before do + progressive_billing_fee + progressive_billing_fee2 + progressive_billing_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice.issuing_date, + timestamp: progressive_billing_invoice.issuing_date + ) + progressive_billing_invoice2.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice2.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice2.issuing_date, + timestamp: progressive_billing_invoice2.issuing_date + ) + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + sole_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice2 } + expect(sole_credit.amount_cents).to eq(200) + + expect(invoice.progressive_billing_credit_amount_cents).to eq(200) + end + end + end + + context "with multiple progressive billing invoices for the sole subscription with an amount higher than the subscription charges" do + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 3.days, + created_at: invoice.issuing_date - 3.days, + fees_amount_cents: 20 + ) + end + + let(:progressive_billing_invoice2) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 2.days, + created_at: invoice.issuing_date - 2.days, + fees_amount_cents: 1000 + ) + end + + let(:progressive_billing_invoice3) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day, + fees_amount_cents: 2000 + ) + end + let(:progressive_billing_fee) do + create(:charge_fee, + amount_cents: 20, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice) + end + let(:progressive_billing_fee2) do + create(:charge_fee, + amount_cents: 1_000, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice2) + end + let(:progressive_billing_fee3) do + create(:charge_fee, + amount_cents: 2_000, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice3) + end + + before do + progressive_billing_fee + progressive_billing_fee2 + progressive_billing_fee3 + + progressive_billing_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice.issuing_date, + timestamp: progressive_billing_invoice.issuing_date + ) + progressive_billing_invoice2.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice2.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice2.issuing_date, + timestamp: progressive_billing_invoice2.issuing_date + ) + progressive_billing_invoice3.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice3.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice3.issuing_date, + timestamp: progressive_billing_invoice3.issuing_date + ) + end + + describe "#call" do + it "applies the last credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + sole_credit = result.credits.find { |credit| credit.progressive_billing_invoice == progressive_billing_invoice3 } + expect(sole_credit.amount_cents).to eq(500) + + expect(invoice.progressive_billing_credit_amount_cents).to eq(500) + end + + it "creates credit notes for the remainder of the progressive billed invoices" do + expect { credit_service.call }.to change(CreditNote, :count).by(1) + # we were able to credit 1000 from the invoice, this means we've got 20 and 200 remaining respectively + expect(progressive_billing_invoice3.credit_notes.size).to eq(1) + + first = progressive_billing_invoice3.credit_notes.sole + expect(first.credit_amount_cents).to eq(1_500) # 2000 - 500 - targeting specific fee + end + end + end + + context "with one progressive billing invoice for one subscription and one without" do + let(:subscription2) { create(:subscription, customer_id: customer.id) } + let(:subscriptions) { [subscription, subscription2] } + + let(:subscription_fees) { [subscription_fee1, subscription_fee2, subscription2_fee1, subscription2_fee2] } + let(:subscription2_fee1) { create(:charge_fee, invoice:, subscription: subscription2, amount_cents: 500) } + let(:subscription2_fee2) { create(:charge_fee, invoice:, subscription: subscription2, amount_cents: 500) } + + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day, + fees_amount_cents: 20 + ) + end + let(:progressive_billing_fee) do + create(:charge_fee, + amount_cents: 20, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice) + end + + before do + progressive_billing_invoice + progressive_billing_fee + progressive_billing_invoice.invoice_subscriptions.each do |is| + is.update!( + charges_from_datetime: progressive_billing_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice.issuing_date, + timestamp: progressive_billing_invoice.issuing_date + ) + end + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + expect(credit.amount_cents).to eq(20) + expect(credit.progressive_billing_invoice).to eq(progressive_billing_invoice) + expect(invoice.progressive_billing_credit_amount_cents).to eq(20) + end + end + end + + context "with one progressive billing invoice outside the current billing boundaries for the sole subscription" do + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 2.months, + fees_amount_cents: 20 + ) + end + + let(:progressive_billing_fee) do + create(:charge_fee, + amount_cents: 20, + invoice: progressive_billing_invoice) + end + + before do + progressive_billing_invoice + progressive_billing_fee + progressive_billing_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice.issuing_date, + timestamp: progressive_billing_invoice.issuing_date + ) + end + + describe "#call" do + it "applies no credit to the invoice" do + result = credit_service.call + expect(result.credits).to be_empty + expect(invoice.progressive_billing_credit_amount_cents).to eq(0) + end + end + end + + context "with a spy on Subscriptions::ProgressiveBilledAmount" do + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + issuing_date: invoice.issuing_date - 1.day, + created_at: invoice.issuing_date - 1.day, + fees_amount_cents: 20 + ) + end + + let(:progressive_billing_fee) { + create(:charge_fee, + amount_cents: 20, + charge: subscription_fee1.charge, + invoice: progressive_billing_invoice) + } + + let(:dummy_result) do + BaseService::Result.new.tap do |r| + r.to_ + end + end + + before do + progressive_billing_invoice + progressive_billing_fee + progressive_billing_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_billing_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_billing_invoice.issuing_date, + timestamp: progressive_billing_invoice.issuing_date + ) + + allow(Subscriptions::ProgressiveBilledAmount).to receive(:call).and_wrap_original do |original_method, *args, **kwargs, &block| + result = original_method.call(*args, **kwargs, &block) + expect(result).to receive(:to_credit_amount).and_call_original # rubocop:disable RSpec/ExpectInHook,RSpec/MessageSpies + result + end + end + + describe "#call" do + it "applies one credit to the invoice" do + result = credit_service.call + expect(result.credits.size).to eq(1) + credit = result.credits.sole + expect(credit.amount_cents).to eq(20) + expect(invoice.progressive_billing_credit_amount_cents).to eq(20) + + expect(subscription_fee1.reload.precise_coupons_amount_cents).to eq(20) + expect(subscription_fee2.reload.precise_coupons_amount_cents).to eq(0) + end + end + end +end diff --git a/spec/services/customer_portal/customer_update_service_spec.rb b/spec/services/customer_portal/customer_update_service_spec.rb new file mode 100644 index 0000000..6942571 --- /dev/null +++ b/spec/services/customer_portal/customer_update_service_spec.rb @@ -0,0 +1,162 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CustomerPortal::CustomerUpdateService do + subject(:result) { described_class.call(customer:, args: update_args) } + + let(:customer) { create :customer } + + let(:update_args) do + { + customer_type: "individual", + document_locale: "es", + name: "Updated customer name", + firstname: "Updated customer firstname", + lastname: "Updated customer lastname", + legal_name: "Updated customer legal_name", + tax_identification_number: "2246", + email: "customer@email.test", + address_line1: "Updated customer address line1", + address_line2: "Updated customer address line2", + zipcode: "Updated customer zipcode", + city: "Updated customer city", + state: "Updated customer state", + country: "PT", + shipping_address: { + address_line1: "Updated customer shipping address line1", + address_line2: "Updated customer shipping address line2", + zipcode: "Updated customer shipping zipcode", + city: "Updated customer shipping city", + state: "Updated customer shipping state", + country: "PT" + } + } + end + + it "updates the customer" do + expect(result).to be_success + + updated_customer = result.customer + + expect(updated_customer.customer_type).to eq(update_args[:customer_type]) + expect(updated_customer.name).to eq(update_args[:name]) + expect(updated_customer.firstname).to eq(update_args[:firstname]) + expect(updated_customer.lastname).to eq(update_args[:lastname]) + expect(updated_customer.legal_name).to eq(update_args[:legal_name]) + expect(updated_customer.tax_identification_number).to eq(update_args[:tax_identification_number]) + expect(updated_customer.email).to eq(update_args[:email]) + expect(updated_customer.document_locale).to eq(update_args[:document_locale]) + + expect(updated_customer.address_line1).to eq(update_args[:address_line1]) + expect(updated_customer.address_line2).to eq(update_args[:address_line2]) + expect(updated_customer.zipcode).to eq(update_args[:zipcode]) + expect(updated_customer.city).to eq(update_args[:city]) + expect(updated_customer.state).to eq(update_args[:state]) + expect(updated_customer.country).to eq(update_args[:country]) + + shipping_address = update_args[:shipping_address] + expect(updated_customer.shipping_address_line1).to eq(shipping_address[:address_line1]) + expect(updated_customer.shipping_address_line2).to eq(shipping_address[:address_line2]) + expect(updated_customer.shipping_zipcode).to eq(shipping_address[:zipcode]) + expect(updated_customer.shipping_city).to eq(shipping_address[:city]) + expect(updated_customer.shipping_state).to eq(shipping_address[:state]) + expect(updated_customer.shipping_country).to eq(shipping_address[:country].upcase) + end + + context "when partialy updating" do + let(:update_args) do + { + name: "Updated customer name", + shipping_address: { + address_line1: "Updated customer shipping address line1" + } + } + end + + it "updates only the updated args" do + expect { result }.not_to change { customer.reload.email } + + expect(result).to be_success + expect(result.customer.name).to eq(update_args[:name]) + expect(result.customer.shipping_address_line1).to eq(update_args[:shipping_address][:address_line1]) + end + end + + context "with email containing unicode lookalike characters" do + let(:update_args) do + { + email: "hello@something\u2013other.com" + } + end + + it "sanitizes the email before saving" do + expect(result.customer.email).to eq("hello@something-other.com") + end + end + + context "when organization has eu tax management" do + let(:organization) { customer.organization } + let(:tax_code) { "lago_eu_fr_standard" } + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new } + + before do + create(:tax, organization:, code: "lago_eu_fr_standard", rate: 20.0) + organization.update!(eu_tax_management: true) + + eu_tax_result.tax_code = tax_code + allow(Customers::EuAutoTaxesService).to receive(:call).and_return(eu_tax_result) + end + + it "assigns the right tax to the customer" do + expect(result).to be_success + + tax = result.customer.taxes.first + expect(tax.code).to eq(tax_code) + end + + context "when eu tax code is not applicable" do + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new.not_allowed_failure!(code: "") } + + it "does not apply tax" do + expect(result).to be_success + + expect(result.customer.taxes).to eq([]) + end + end + + context "when applying taxes fails" do + let(:apply_taxes_result) do + BaseService::Result.new.not_found_failure!(resource: "tax") + end + + before do + allow(Customers::ApplyTaxesService).to receive(:call).and_return(apply_taxes_result) + end + + it "returns a service error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + end + + context "when customer is not found" do + let(:customer) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + + context "with validation error" do + let(:update_args) { {country: "invalid country code"} } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:country]).to eq(["not_a_valid_country_code"]) + end + end +end diff --git a/spec/services/customer_portal/generate_url_service_spec.rb b/spec/services/customer_portal/generate_url_service_spec.rb new file mode 100644 index 0000000..a53c181 --- /dev/null +++ b/spec/services/customer_portal/generate_url_service_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe CustomerPortal::GenerateUrlService do + subject(:generate_url_service) { described_class.new(customer:) } + + let(:customer) { create(:customer) } + + describe "#call" do + it "generates valid customer portal url" do + result = generate_url_service.call + + message = result.url.split("/customer-portal/")[1] + public_authenticator = ActiveSupport::MessageVerifier.new(ENV["SECRET_KEY_BASE"]) + + expect(result.url).to include("/customer-portal/") + expect(public_authenticator.verify(message)).to eq(customer.id) + end + + context "when customer does not exist" do + let(:customer) { nil } + + it "returns an error" do + result = generate_url_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + end +end diff --git a/spec/services/customers/apply_taxes_service_spec.rb b/spec/services/customers/apply_taxes_service_spec.rb new file mode 100644 index 0000000..a0d526e --- /dev/null +++ b/spec/services/customers/apply_taxes_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::ApplyTaxesService do + subject(:apply_service) { described_class.new(customer:, tax_codes:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:tax1) { create(:tax, organization:, code: "tax1") } + let(:tax2) { create(:tax, organization:, code: "tax2") } + let(:tax_codes) { [tax1.code, tax2.code] } + + describe "call" do + it "applies taxes to the customer" do + expect { apply_service.call }.to change { customer.applied_taxes.count }.from(0).to(2) + end + + it "marks invoices as ready to be refreshed" do + invoice = create(:invoice, :draft, customer:) + + expect { apply_service.call }.to change { invoice.reload.ready_to_be_refreshed }.to(true) + end + + it "returns applied taxes" do + result = apply_service.call + expect(result.applied_taxes.count).to eq(2) + end + + context "when customer is not found" do + let(:customer) { nil } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + + context "when tax is not found" do + let(:tax_codes) { ["unknown"] } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when applied tax is already present" do + it "does not create a new applied tax" do + create(:customer_applied_tax, customer:, tax: tax1) + expect { apply_service.call }.to change { customer.applied_taxes.count }.from(1).to(2) + end + end + + context "when trying to apply twice the same tax" do + let(:tax_codes) { [tax1.code, tax1.code] } + + it "assigns it only once" do + expect { apply_service.call }.to change { customer.applied_taxes.count }.from(0).to(1) + end + end + end +end diff --git a/spec/services/customers/create_service_spec.rb b/spec/services/customers/create_service_spec.rb new file mode 100644 index 0000000..f665670 --- /dev/null +++ b/spec/services/customers/create_service_spec.rb @@ -0,0 +1,354 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::CreateService do + subject(:result) { described_class.call(**create_args) } + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:external_id) { SecureRandom.uuid } + + let(:create_args) do + { + organization_id: organization.id, + external_id:, + name: "Foo Bar", + currency: "EUR", + timezone: "Europe/Paris", + invoice_grace_period: 2, + billing_configuration: { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + }, + shipping_address: { + address_line1: "line1", + address_line2: "line2", + city: "Paris", + zipcode: "123456", + state: "foobar", + country: "FR" + } + } + end + + before do + allow(SendWebhookJob).to receive(:perform_later) + allow(CurrentContext).to receive(:source).and_return("graphql") + end + + it "creates a new customer" do + expect(result).to be_success + + customer = result.customer + expect(customer.id).to be_present + expect(customer.organization_id).to eq(organization.id) + expect(customer.billing_entity_id).to eq(billing_entity.id) + expect(customer.external_id).to eq(create_args[:external_id]) + expect(customer.name).to eq(create_args[:name]) + expect(customer.currency).to eq("EUR") + expect(customer.timezone).to be_nil + expect(customer.invoice_grace_period).to be_nil + expect(customer.subscription_invoice_issuing_date_anchor).to eq("current_period_end") + expect(customer.subscription_invoice_issuing_date_adjustment).to eq("keep_anchor") + expect(customer).to be_customer_account + expect(customer).not_to be_exclude_from_dunning_campaign + + shipping_address = create_args[:shipping_address] + expect(customer.shipping_address_line1).to eq(shipping_address[:address_line1]) + expect(customer.shipping_address_line2).to eq(shipping_address[:address_line2]) + expect(customer.shipping_city).to eq(shipping_address[:city]) + expect(customer.shipping_zipcode).to eq(shipping_address[:zipcode]) + expect(customer.shipping_state).to eq(shipping_address[:state]) + expect(customer.shipping_country).to eq(shipping_address[:country]) + end + + it "calls SendWebhookJob with customer.created" do + result + + expect(SendWebhookJob).to have_received(:perform_later).with("customer.created", result.customer) + end + + it "produces an activity log" do + result + + expect(Utils::ActivityLog).to have_produced("customer.created").after_commit.with(result.customer) + end + + context "when organization has multiple billing entities" do + let(:billing_entity_2) { create(:billing_entity, organization:) } + + before { billing_entity_2 } + + it "creates a customer assigned to the organization's default billing entity" do + expect(result).to be_success + expect(result.customer.billing_entity).to be_present + expect(result.customer.billing_entity).to eq(organization.default_billing_entity) + end + + context "with billing_entity_code" do + before do + create_args.merge!(billing_entity_code: billing_entity_2.code) + end + + it "creates a new customer" do + expect(result).to be_success + expect(result.customer.billing_entity).to eq(billing_entity_2) + end + end + end + + context "when organization has no active billing entity" do + before do + organization.billing_entities.update_all(archived_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + it "return a failed result" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + end + end + + context "when billing_entity_code belongs to an archived billing entity" do + let(:billing_entity_2) { create(:billing_entity, organization:) } + + before do + billing_entity_2.update!(archived_at: Time.current) + create_args.merge!(billing_entity_code: billing_entity_2.code) + end + + it "return a failed result" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + end + end + + context "with premium features", :premium do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + firstname: "First", + lastname: "Last", + organization_id: organization.id, + timezone: "Europe/Paris", + invoice_grace_period: 2 + } + end + + it "creates a new customer" do + expect(result).to be_success + + customer = result.customer + expect(customer.firstname).to eq(create_args[:firstname]) + expect(customer.lastname).to eq(create_args[:lastname]) + expect(customer.customer_type).to be_nil + expect(customer.timezone).to eq("Europe/Paris") + expect(customer.invoice_grace_period).to eq(2) + end + + context "with revenue share feature enabled and account_type 'partner'" do + let(:organization) do + create(:organization, premium_integrations: ["revenue_share"]) + end + + before do + create_args.merge!(account_type: "partner") + end + + it "creates a customer as partner_account" do + expect(result).to be_success + expect(result.customer).to be_partner_account + expect(result.customer).to be_exclude_from_dunning_campaign + end + end + end + + context "with customer_type" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + customer_type: "individual", + organization_id: organization.id + } + end + + it "creates customer with customer_type" do + expect(result).to be_success + expect(result.customer.customer_type).to eq(create_args[:customer_type]) + end + end + + context "with metadata" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + organization_id: organization.id, + currency: "EUR", + metadata: [ + { + key: "manager name", + value: "John", + display_in_invoice: true + }, + { + key: "manager address", + value: "Test", + display_in_invoice: false + } + ] + } + end + + it "creates customer with metadata" do + expect(result).to be_success + expect(result.customer.metadata.count).to eq(2) + end + end + + context "when customer already exists" do + let(:customer) do + create(:customer, organization:, external_id: create_args[:external_id]) + end + + before { customer } + + it "return a failed result" do + expect(result).to be_failure + end + end + + context "with validation error" do + let(:create_args) do + { + name: "Foo Bar" + } + end + + it "return a failed result" do + expect(result).to be_failure + end + end + + context "with stripe payment provider" do + before do + create( + :stripe_provider, + organization: + ) + end + + context "with provider customer id" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + organization_id: organization.id, + payment_provider: "stripe", + provider_customer: {provider_customer_id: "cus_12345"} + } + end + + it "creates a payment provider customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to eq("stripe") + expect(result.customer.stripe_customer).to be_present + expect(result.customer.stripe_customer.provider_customer_id).to eq("cus_12345") + end + end + end + + context "with gocardless payment provider" do + before do + create( + :gocardless_provider, + organization: + ) + end + + context "with provider customer id" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + organization_id: organization.id, + payment_provider: "gocardless", + provider_customer: {provider_customer_id: "cus_12345"} + } + end + + it "creates a payment provider customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to eq("gocardless") + expect(result.customer.gocardless_customer).to be_present + expect(result.customer.gocardless_customer.provider_customer_id).to eq("cus_12345") + end + end + + context "with sync option enabled" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + organization_id: organization.id, + payment_provider: "gocardless", + provider_customer: {sync_with_provider: true} + } + end + + it "creates a payment provider customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to eq("gocardless") + expect(result.customer.gocardless_customer).to be_present + end + end + end + + context "with account_type 'partner'" do + before do + create_args.merge!(account_type: "partner") + end + + it "creates a customer as customer_account" do + expect(result).to be_success + expect(result.customer).to be_customer_account + expect(result.customer).not_to be_exclude_from_dunning_campaign + end + end + + context "when organization has eu tax management" do + let(:tax_code) { "lago_eu_fr_standard" } + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new } + + before do + create(:tax, organization:, code: "lago_eu_fr_standard", rate: 20.0) + organization.update(eu_tax_management: true) + + eu_tax_result.tax_code = tax_code + allow(Customers::EuAutoTaxesService).to receive(:call).and_return(eu_tax_result) + end + + it "assigns the right tax to the customer" do + expect(result).to be_success + + tax = result.customer.taxes.first + expect(tax.code).to eq("lago_eu_fr_standard") + end + + context "when eu tax code is not applicable" do + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new.not_allowed_failure!(code: "") } + + it "does not apply tax" do + expect(result).to be_success + expect(result.customer.taxes).to eq([]) + end + end + end +end diff --git a/spec/services/customers/destroy_service_spec.rb b/spec/services/customers/destroy_service_spec.rb new file mode 100644 index 0000000..33ffb0e --- /dev/null +++ b/spec/services/customers/destroy_service_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::DestroyService do + subject(:destroy_service) { described_class.new(customer:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + before do + customer + end + + describe "#call" do + it "soft deletes the customer" do + freeze_time do + expect { destroy_service.call }.to change(Customer, :count).by(-1) + .and change { customer.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "enqueues a job to terminates the customer resources" do + destroy_service.call + + expect(Customers::TerminateRelationsJob).to have_been_enqueued + .with(customer_id: customer.id) + end + + it "produces an activity log" do + described_class.call(customer:) + + expect(Utils::ActivityLog).to have_produced("customer.deleted").after_commit.with(customer) + end + + context "when customer is not found" do + let(:customer) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + end +end diff --git a/spec/services/customers/eu_auto_taxes_service_spec.rb b/spec/services/customers/eu_auto_taxes_service_spec.rb new file mode 100644 index 0000000..cc22d23 --- /dev/null +++ b/spec/services/customers/eu_auto_taxes_service_spec.rb @@ -0,0 +1,335 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::EuAutoTaxesService do + subject(:eu_tax_service) { described_class.new(customer:, new_record:, tax_attributes_changed:) } + + let(:organization) { create(:organization, country: "IT", eu_tax_management: true) } + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", eu_tax_management: true) } + let(:customer) { create(:customer, organization:, billing_entity:, tax_identification_number:, zipcode: nil) } + let(:new_record) { true } + let(:tax_attributes_changed) { true } + let(:tax_identification_number) { nil } + + describe ".call" do + context "when eu_tax_management is false" do + let(:organization) { create(:organization, country: "IT", eu_tax_management: false) } + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", eu_tax_management: false) } + + it "returns error" do + result = eu_tax_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("eu_tax_not_applicable") + end + end + + context "when customer is updated and there are eu taxes" do + let(:new_record) { false } + let(:tax_attributes_changed) { false } + let(:applied_tax) { create(:customer_applied_tax, tax:, customer:) } + let(:tax) { create(:tax, organization:, code: "lago_eu_tax_exempt") } + + before { applied_tax } + + it "returns error" do + result = eu_tax_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("eu_tax_not_applicable") + end + end + + context "when customer is updated and there are no eu taxes" do + let(:new_record) { false } + let(:tax_attributes_changed) { false } + let(:applied_tax) { create(:customer_applied_tax, tax:, customer:) } + let(:tax) { create(:tax, organization:, code: "unknown_eu_tax_exempt") } + + before do + applied_tax + customer.update!(country: "DE") + end + + it "returns the customer country tax code" do + result = eu_tax_service.call + + expect(result.tax_code).to eq("lago_eu_de_standard") + end + end + + context "when tax_identification_number is blank" do + let(:tax_identification_number) { nil } + + before { customer.update!(country: "DE") } + + it "returns the customer country tax code" do + result = eu_tax_service.call + + expect(result.tax_code).to eq("lago_eu_de_standard") + end + + it "does not enqueue ViesCheckJob" do + eu_tax_service.call + + expect(Customers::ViesCheckJob).not_to have_been_enqueued + end + end + + context "when tax_identification_number is present" do + let(:tax_identification_number) { "IT12345678901" } + + it "creates a PendingViesCheck" do + expect { eu_tax_service.call }.to change(PendingViesCheck, :count).by(1) + + pending_check = customer.pending_vies_check + expect(pending_check).to have_attributes( + organization: customer.organization, + billing_entity: customer.billing_entity, + tax_identification_number: customer.tax_identification_number, + attempts_count: 0 + ) + end + + it "enqueues ViesCheckJob" do + eu_tax_service.call + + expect(Customers::ViesCheckJob).to have_been_enqueued.with(customer) + end + + it "returns a failure result with vies_check_pending code" do + result = eu_tax_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("vies_check_pending") + end + + context "when a PendingViesCheck already exists" do + before { create(:pending_vies_check, customer:, attempts_count: 3) } + + it "resets the existing check" do + expect { eu_tax_service.call }.not_to change(PendingViesCheck, :count) + + pending_check = customer.pending_vies_check.reload + expect(pending_check.attempts_count).to eq(0) + end + end + end + + context "with non B2B (no TIN)" do + let(:tax_identification_number) { nil } + + context "when the customer has no country" do + before { customer.update(country: nil) } + + it "returns the billing entity country tax code" do + result = eu_tax_service.call + + expect(result.tax_code).to eq("lago_eu_fr_standard") + end + end + + context "when the customer country is in europe" do + before { customer.update(country: "DE") } + + it "returns the customer country tax code" do + result = eu_tax_service.call + + expect(result.tax_code).to eq("lago_eu_de_standard") + end + end + + context "when the customer country is out of europe" do + before { customer.update(country: "US") } + + it "returns the tax exempt tax code" do + result = eu_tax_service.call + + expect(result.tax_code).to eq("lago_eu_tax_exempt") + end + end + end + + context "when customer is in a special territory" do + shared_examples "a special territory tax assignment" do |country:, zipcode:, expected_tax_code:| + before { customer.update(country:, zipcode:) } + + it "assigns #{expected_tax_code}" do + result = eu_tax_service.call + expect(result.tax_code).to eq(expected_tax_code) + end + end + + context "when B2B customer (non-France territories apply exception regardless)" do + let(:tax_identification_number) { "IT12345678901" } + + it_behaves_like "a special territory tax assignment", + country: "ES", zipcode: "35001", expected_tax_code: "lago_eu_es_exception_canary_islands" + it_behaves_like "a special territory tax assignment", + country: "ES", zipcode: "38314", expected_tax_code: "lago_eu_es_exception_canary_islands" + it_behaves_like "a special territory tax assignment", + country: "ES", zipcode: "51001", expected_tax_code: "lago_eu_es_exception_ceuta" + it_behaves_like "a special territory tax assignment", + country: "ES", zipcode: "52001", expected_tax_code: "lago_eu_es_exception_melilla" + it_behaves_like "a special territory tax assignment", + country: "AT", zipcode: "6691", expected_tax_code: "lago_eu_at_exception_jungholz" + it_behaves_like "a special territory tax assignment", + country: "AT", zipcode: "6992", expected_tax_code: "lago_eu_at_exception_mittelberg" + it_behaves_like "a special territory tax assignment", + country: "AT", zipcode: "6991", expected_tax_code: "lago_eu_at_exception_mittelberg" + it_behaves_like "a special territory tax assignment", + country: "IT", zipcode: "23041", expected_tax_code: "lago_eu_it_exception_livigno" + it_behaves_like "a special territory tax assignment", + country: "IT", zipcode: "22061", expected_tax_code: "lago_eu_it_exception_campione_d_italia" + it_behaves_like "a special territory tax assignment", + country: "DE", zipcode: "78266", expected_tax_code: "lago_eu_de_exception_busingen_am_hochrhein" + it_behaves_like "a special territory tax assignment", + country: "DE", zipcode: "27498", expected_tax_code: "lago_eu_de_exception_heligoland" + it_behaves_like "a special territory tax assignment", + country: "PT", zipcode: "9500", expected_tax_code: "lago_eu_pt_exception_azores" + it_behaves_like "a special territory tax assignment", + country: "PT", zipcode: "9000", expected_tax_code: "lago_eu_pt_exception_madeira" + it_behaves_like "a special territory tax assignment", + country: "GR", zipcode: "63086", expected_tax_code: "lago_eu_gr_exception_mount_athos" + it_behaves_like "a special territory tax assignment", + country: "FI", zipcode: "22000", expected_tax_code: "lago_eu_fi_exception_aland_islands" + end + + context "when B2C customer (non-France territories apply exception regardless)" do + let(:tax_identification_number) { nil } + + it_behaves_like "a special territory tax assignment", + country: "ES", zipcode: "35001", expected_tax_code: "lago_eu_es_exception_canary_islands" + it_behaves_like "a special territory tax assignment", + country: "AT", zipcode: "6691", expected_tax_code: "lago_eu_at_exception_jungholz" + it_behaves_like "a special territory tax assignment", + country: "IT", zipcode: "23041", expected_tax_code: "lago_eu_it_exception_livigno" + it_behaves_like "a special territory tax assignment", + country: "DE", zipcode: "78266", expected_tax_code: "lago_eu_de_exception_busingen_am_hochrhein" + it_behaves_like "a special territory tax assignment", + country: "PT", zipcode: "9500", expected_tax_code: "lago_eu_pt_exception_azores" + it_behaves_like "a special territory tax assignment", + country: "GR", zipcode: "63086", expected_tax_code: "lago_eu_gr_exception_mount_athos" + end + + context "when B2B customer in France DOM-TOM (exception rate applies)" do + let(:tax_identification_number) { "IT12345678901" } + + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97200", expected_tax_code: "lago_eu_fr_exception_martinique" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97100", expected_tax_code: "lago_eu_fr_exception_guadeloupe" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97412", expected_tax_code: "lago_eu_fr_exception_reunion" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97300", expected_tax_code: "lago_eu_fr_exception_guyane" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97600", expected_tax_code: "lago_eu_fr_exception_mayotte" + end + + context "when B2C customer in France DOM-TOM (standard rate applies)" do + let(:tax_identification_number) { nil } + + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97200", expected_tax_code: "lago_eu_fr_standard" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97100", expected_tax_code: "lago_eu_fr_standard" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97412", expected_tax_code: "lago_eu_fr_standard" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97300", expected_tax_code: "lago_eu_fr_standard" + it_behaves_like "a special territory tax assignment", + country: "FR", zipcode: "97600", expected_tax_code: "lago_eu_fr_standard" + end + + context "when territory is detected" do + let(:tax_identification_number) { "IT12345678901" } + + before { customer.update(country: "ES", zipcode: "35001") } + + it "does not enqueue ViesCheckJob" do + eu_tax_service.call + expect(Customers::ViesCheckJob).not_to have_been_enqueued + end + + it "does not send a webhook" do + eu_tax_service.call + expect(SendWebhookJob).not_to have_been_enqueued + end + + context "when a pending VIES check exists" do + before { create(:pending_vies_check, customer:) } + + it "destroys the pending VIES check" do + expect { eu_tax_service.call }.to change(PendingViesCheck, :count).by(-1) + end + end + end + + context "when zipcode contains spaces" do + let(:tax_identification_number) { nil } + + it "normalizes the zipcode before matching" do + customer.update(country: "ES", zipcode: " 35 001 ") + result = eu_tax_service.call + expect(result.tax_code).to eq("lago_eu_es_exception_canary_islands") + end + end + + context "when customer relocates from mainland to special territory" do + let(:new_record) { false } + let(:tax_attributes_changed) { true } + let(:tax_identification_number) { nil } + let(:applied_tax) { create(:customer_applied_tax, tax:, customer:) } + let(:tax) { create(:tax, organization:, code: "lago_eu_es_standard") } + + before do + applied_tax + customer.update(country: "ES", zipcode: "35001") + end + + it "detects the territory and assigns the exception tax code" do + result = eu_tax_service.call + expect(result.tax_code).to eq("lago_eu_es_exception_canary_islands") + end + end + + context "when customer has an invalid VAT number in a special territory" do + let(:tax_identification_number) { "INVALID123" } + + before { customer.update(country: "FR", zipcode: "97100") } + + it "skips special territory detection and schedules async VIES check" do + result = eu_tax_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("vies_check_pending") + expect(Customers::ViesCheckJob).to have_been_enqueued.with(customer) + end + end + + context "when territory is not detected" do + let(:tax_identification_number) { nil } + + it "falls through when zipcode does not match any exception" do + customer.update(country: "ES", zipcode: "28001") + result = eu_tax_service.call + expect(result.tax_code).to eq("lago_eu_es_standard") + end + + it "falls through when customer has no zipcode" do + customer.update(country: "DE") + result = eu_tax_service.call + expect(result.tax_code).to eq("lago_eu_de_standard") + end + + it "falls through when customer has no country" do + customer.update(country: nil, zipcode: "35001") + result = eu_tax_service.call + expect(result.tax_code).to eq("lago_eu_fr_standard") + end + end + end + end +end diff --git a/spec/services/customers/generate_checkout_url_service_spec.rb b/spec/services/customers/generate_checkout_url_service_spec.rb new file mode 100644 index 0000000..8dc5eb5 --- /dev/null +++ b/spec/services/customers/generate_checkout_url_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::GenerateCheckoutUrlService do + subject(:generate_checkout_url_service) { described_class.new(customer:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + describe ".call" do + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer_service) { instance_double(PaymentProviderCustomers::StripeService) } + + context "when payment provider is linked" do + before do + create( + :stripe_customer, + customer_id: customer.id, + payment_provider: stripe_provider + ) + + customer.update(payment_provider: "stripe") + + allow(PaymentProviderCustomers::StripeService).to receive(:new) + .and_return(stripe_customer_service) + + allow(stripe_customer_service).to receive(:generate_checkout_url) + .with(send_webhook: false) + .and_return(OpenStruct.new(checkout_url: "http://foo.bar")) + end + + it "returns the generated checkout url" do + result = generate_checkout_url_service.call + + expect(result.checkout_url).to eq("http://foo.bar") + end + end + + context "when customer is blank" do + it "returns an error" do + result = described_class.new(customer: nil).call + + expect(result).not_to be_success + expect(result.error.message).to eq("customer_not_found") + end + end + + context "when payment provider is blank" do + it "returns an error" do + result = generate_checkout_url_service.call + + expect(result).not_to be_success + end + end + end +end diff --git a/spec/services/customers/lock_service_spec.rb b/spec/services/customers/lock_service_spec.rb new file mode 100644 index 0000000..b28e0ed --- /dev/null +++ b/spec/services/customers/lock_service_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::LockService do + let(:lock_service) { described_class.new(customer:, scope: :prepaid_credit, timeout_seconds:) } + let(:customer) { create(:customer) } + let(:timeout_seconds) { 5.seconds } + + describe ".new" do + context "with invalid scope" do + it "raises ArgumentError" do + expect do + described_class.new(customer:, scope: :invalid_scope) + end.to raise_error(ArgumentError, /Invalid scope: invalid_scope/) + end + end + end + + describe "#call" do + subject { lock_service.call } + + context "when lock can be acquired" do + it "takes an advisory lock" do + expect(lock_service).not_to be_locked + + lock_service.call do + expect(lock_service).to be_locked + end + + expect(lock_service).not_to be_locked + end + end + + context "when lock cannot be acquired", transaction: false do + let(:timeout_seconds) { 0.seconds } + + around do |test| + with_advisory_lock("customer-#{customer.id}-prepaid_credit", lock_released_after: 2.seconds) do + test.run + end + end + + it "raises a Customers::FailedToAcquireLock error" do + expect do + lock_service.call { nil } + end.to raise_error(Customers::FailedToAcquireLock, "Failed to acquire lock customer-#{customer.id}-prepaid_credit") + end + end + end + + describe "#locked?" do + subject { lock_service.locked? } + + context "when the lock is taken" do + it "returns true" do + lock_service.call do + expect(subject).to be true + end + end + end + + context "when the lock is not taken" do + it "returns false" do + expect(subject).to be false + end + end + end +end diff --git a/spec/services/customers/manage_invoice_custom_sections_service_spec.rb b/spec/services/customers/manage_invoice_custom_sections_service_spec.rb new file mode 100644 index 0000000..9b4a08b --- /dev/null +++ b/spec/services/customers/manage_invoice_custom_sections_service_spec.rb @@ -0,0 +1,249 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::ManageInvoiceCustomSectionsService do + subject(:result) { described_class.call(customer:, section_ids:, skip_invoice_custom_sections:, section_codes:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + let(:invoice_custom_section_1) { create(:invoice_custom_section, organization:) } + let(:invoice_custom_section_2) { create(:invoice_custom_section, organization:) } + let(:invoice_custom_section_3) { create(:invoice_custom_section, organization:) } + let(:skip_invoice_custom_sections) { nil } + let(:section_ids) { nil } + let(:section_codes) { nil } + + describe "#call" do + context "when customer is not found" do + let(:customer) { nil } + + it "returns not found failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("customer_not_found") + end + end + + context "when sending section_ids and section_codes together" do + let(:section_ids) { [invoice_custom_section_1.id] } + let(:section_codes) { [invoice_custom_section_1.code] } + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.message).to include("section_ids_and_section_codes_sent_together") + end + end + + context "when sending section_ids" do + context "when sending skip_invoice_custom_sections: true AND selected_ids" do + let(:skip_invoice_custom_sections) { true } + let(:section_ids) { [1, 2, 3] } + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.message).to include("skip_sections_and_selected_ids_sent_together") + end + end + + context "when updating selected_invoice_custom_sections" do + context "when section_ids match customer's applicable sections" do + let(:section_ids) { [invoice_custom_section_1.id] } + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_1) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "returns the result without changes" do + expect(result).to be_success + expect(customer.applicable_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_1) + end + end + + context "when section_ids match organization's selected sections" do + let(:section_ids) { [invoice_custom_section_2.id] } + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_1) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "still sets selected invoice_custom_sections as custom" do + expect(result).to be_success + expect(customer.selected_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_2) + expect(customer.applicable_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_2) + end + end + + context "when section_ids are totally custom" do + let(:section_ids) { [invoice_custom_section_3.id] } + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_1) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "assigns customer sections" do + expect(result).to be_success + expect(customer.selected_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_3) + expect(customer.applicable_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_3) + end + end + + context "when setting invoice_custom_sections_ids when previously customer had skip_invoice_custom_sections" do + let(:section_ids) { [] } + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_1) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + customer.update!(skip_invoice_custom_sections: true) + end + + it "sets skip_invoice_custom_sections to false" do + expect(result).to be_success + expect(customer.reload.skip_invoice_custom_sections).to be false + expect(customer.selected_invoice_custom_sections.reload).to be_empty + expect(customer.applicable_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_2) + end + end + end + + context "when an ActiveRecord::RecordInvalid error is raised" do + let(:section_ids) { [invoice_custom_section_2.id] } + + before do + allow(customer).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(customer)) + end + + it "returns record validation failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + context "when sending section_codes" do + context "when sending skip_invoice_custom_sections: true AND selected_codes" do + let(:skip_invoice_custom_sections) { true } + let(:section_codes) { [] } + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.message).to include("skip_sections_and_selected_ids_sent_together") + end + end + + context "when updating selected_invoice_custom_sections" do + context "when section_codes match customer's applicable sections" do + let(:section_codes) { [invoice_custom_section_1.code] } + + it "returns the result without changes" do + expect(result).to be_success + expect(customer.applicable_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_1) + end + end + + context "when section_ids are totally custom" do + let(:section_codes) { [invoice_custom_section_3.code] } + + it "assigns customer sections" do + expect(result).to be_success + expect(customer.selected_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_3) + expect(customer.applicable_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_3) + end + end + + context "when setting invoice_custom_sections_ids when previously customer had skip_invoice_custom_sections" do + let(:section_codes) { [] } + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_1) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + customer.update!(skip_invoice_custom_sections: true) + end + + it "sets skip_invoice_custom_sections to false" do + expect(result).to be_success + expect(customer.reload.skip_invoice_custom_sections).to be false + expect(customer.selected_invoice_custom_sections.reload).to be_empty + expect(customer.applicable_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_2) + end + end + end + end + + context "when updating customer to skip_invoice_custom_sections" do + let(:skip_invoice_custom_sections) { true } + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_1) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_2) + end + + it "sets skip_invoice_custom_sections to true" do + expect(result).to be_success + expect(customer.reload.skip_invoice_custom_sections).to be true + expect(customer.selected_invoice_custom_sections.reload).to be_empty + expect(customer.applicable_invoice_custom_sections.reload).to be_empty + end + end + + context "when assigning section_ids and customer has system_generated sections" do + let(:section_ids) { [invoice_custom_section_1.id] } + + let(:system_generated_section) do + create(:invoice_custom_section, organization:, section_type: :system_generated) + end + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: system_generated_section) + end + + it "keeps system_generated sections and adds selected manual ones" do + expect(result).to be_success + expect(customer.selected_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_1, system_generated_section) + end + end + + context "when assigning section_codes and customer has system_generated sections" do + let(:section_codes) { [invoice_custom_section_1.code] } + + let(:system_generated_section) do + create(:invoice_custom_section, organization:, section_type: :system_generated) + end + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: system_generated_section) + end + + it "keeps system_generated sections and adds selected manual ones" do + expect(result).to be_success + expect(customer.selected_invoice_custom_sections.reload).to contain_exactly(invoice_custom_section_1, system_generated_section) + end + end + + context "when clearing all manual sections but customer has system_generated" do + let(:section_ids) { [] } + + let(:system_generated_section) do + create(:invoice_custom_section, organization: customer.organization, section_type: :system_generated) + end + + before do + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: invoice_custom_section_1) + create(:customer_applied_invoice_custom_section, customer:, organization:, billing_entity:, invoice_custom_section: system_generated_section) + end + + it "removes manual but keeps system_generated sections" do + expect(result).to be_success + expect(customer.manual_selected_invoice_custom_sections.reload).to be_empty + expect(customer.selected_invoice_custom_sections.reload).to match_array([system_generated_section]) + end + end + end +end diff --git a/spec/services/customers/metadata/update_service_spec.rb b/spec/services/customers/metadata/update_service_spec.rb new file mode 100644 index 0000000..999c567 --- /dev/null +++ b/spec/services/customers/metadata/update_service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::Metadata::UpdateService do + subject(:update_service) { described_class.new(customer:, params:) } + + let(:customer) { create(:customer) } + let(:customer_metadata) { create(:customer_metadata, customer:) } + let(:another_customer_metadata) { create(:customer_metadata, customer:, key: "test", value: "1") } + let(:params) do + [ + { + id: customer_metadata.id, + key: "new key", + value: "new value", + display_in_invoice: true + }, + { + key: "Added key", + value: "Added value", + display_in_invoice: true + } + ] + end + + describe "#call" do + before do + customer_metadata + another_customer_metadata + end + + it "updates existing metadata" do + result = update_service.call + + metadata_keys = result.customer.metadata.pluck(:key) + metadata_ids = result.customer.metadata.pluck(:id) + + expect(result.customer.metadata.count).to eq(2) + expect(metadata_keys).to include("new key") + expect(metadata_ids).to include(customer_metadata.id) + end + + it "adds new metadata" do + result = update_service.call + + metadata_keys = result.customer.metadata.pluck(:key) + + expect(result.customer.metadata.count).to eq(2) + expect(metadata_keys).to include("Added key") + end + + it "sanitizes not needed metadata" do + result = update_service.call + + metadata_ids = result.customer.metadata.pluck(:id) + + expect(result.customer.metadata.count).to eq(2) + expect(metadata_ids).not_to include(another_customer_metadata.id) + end + end +end diff --git a/spec/services/customers/refresh_wallets_service_spec.rb b/spec/services/customers/refresh_wallets_service_spec.rb new file mode 100644 index 0000000..e548b8c --- /dev/null +++ b/spec/services/customers/refresh_wallets_service_spec.rb @@ -0,0 +1,345 @@ +# frozen_string_literal: true + +RSpec.describe Customers::RefreshWalletsService do + describe "#call" do + subject(:result) { described_class.call(customer:, include_generating_invoices:) } + + let(:include_generating_invoices) { false } + let(:customer) { create(:customer, awaiting_wallet_refresh: true) } + let(:organization) { customer.organization } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:pay_in_advance_billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + + let(:subscriptions) do + [ + create(:subscription, organization:, customer:, started_at: Time.zone.now - 2.years), + create(:subscription, organization:, customer:, started_at: Time.zone.now - 1.year) + ] + end + + before do + create( + :wallet, + customer:, + balance_cents: 1000, + ongoing_balance_cents: 1000, + ongoing_usage_balance_cents: 0, + credits_balance: 10.0, + credits_ongoing_balance: 10.0, + credits_ongoing_usage_balance: 0 + ) + + create(:wallet, :terminated, customer:) + + subscriptions.map do |subscription| + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "3", presentation_group_keys: [{value: "cloud"}]} + ) + + create( + :standard_charge, + plan: subscription.plan, + billable_metric: pay_in_advance_billable_metric, + properties: {amount: "1", presentation_group_keys: [{value: "region"}]}, + pay_in_advance: true, + invoiceable: true + ) + end + + create_pair( + :event, + organization:, + subscription: subscriptions.first, + customer:, + code: billable_metric.code + ) + + create( + :event, + organization:, + subscription: subscriptions.second, + customer:, + code: billable_metric.code + ) + + create( + :event, + organization:, + subscription: subscriptions.second, + customer:, + code: pay_in_advance_billable_metric.code + ) + end + + it "calls Wallets::Balance::RefreshOngoingUsageService for each active wallet" do + allow(Wallets::Balance::RefreshOngoingUsageService).to receive(:call!).and_call_original + + subject + + expect(Wallets::Balance::RefreshOngoingUsageService) + .to have_received(:call!) + .exactly(customer.wallets.active.count).times + end + + it "refreshes the wallet balances" do + expect(result).to be_success + expect(result.wallets).to match_array(customer.wallets.active) + + wallet = result.wallets.first + expect(wallet.ongoing_usage_balance_cents).to eq 900 + expect(wallet.credits_ongoing_usage_balance).to eq 9.0 + expect(wallet.ongoing_balance_cents).to eq 100 + expect(wallet.credits_ongoing_balance).to eq 1.0 + end + + it "marks customer as not awaiting wallet refresh" do + expect { subject }.to change(customer, :awaiting_wallet_refresh).from(true).to(false) + end + + context "when charges have presentation_group_keys" do + before do + allow(Invoices::CustomerUsageService).to receive(:call!).and_call_original + allow(BillableMetrics::AggregationFactory).to receive(:new_instance).and_call_original + end + + it "calls CustomerUsageService with UsageFilters::WITHOUT_PRESENTATION_FILTER" do + subject + + customer.active_subscriptions.each do |subscription| + expect(Invoices::CustomerUsageService).to have_received(:call!).with( + customer:, + subscription:, + usage_filters: UsageFilters::WITHOUT_PRESENTATION_FILTER + ) + end + end + + it "calls AggregationFactory with presentation_by as empty array" do + subject + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).at_least(:once) do |args| + next unless args[:filters].key?(:presentation_by) + + expect(args[:filters][:presentation_by]).to eq([]) + end + end + end + + describe "current usage calculation" do + let(:charges_to_datetime) { 1.week.from_now } + let(:charges_from_datetime) { 1.week.ago } + + before do + subscriptions.each do |subscription| + create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) do |invoice_subscription| + create( + :charge_fee, + subscription:, + precise_coupons_amount_cents: 0, + invoice: invoice_subscription.invoice, + amount_cents: 100, + taxes_amount_cents: 10 + ) + + invoice_subscription.invoice.update!( + invoice_type: :progressive_billing, + fees_amount_cents: 110, + total_amount_cents: 110, + status: :generating + ) + end + end + end + + context "when generating invoices are included" do + let(:include_generating_invoices) { true } + + it "returns current usage for customer including generating invoices" do + expect(result).to be_success + + wallet = result.wallets.first + expect(wallet.ongoing_usage_balance_cents).to eq 680 + expect(wallet.credits_ongoing_usage_balance).to eq 6.8 + expect(wallet.ongoing_balance_cents).to eq 320 + expect(wallet.credits_ongoing_balance).to eq 3.2 + end + end + + context "when generating invoices are excluded" do + let(:include_generating_invoices) { false } + + it "returns current usage for customer excluding generating invoices" do + expect(result).to be_success + + wallet = result.wallets.first + expect(wallet.ongoing_usage_balance_cents).to eq 900 + expect(wallet.credits_ongoing_usage_balance).to eq 9.0 + expect(wallet.ongoing_balance_cents).to eq 100 + expect(wallet.credits_ongoing_balance).to eq 1.0 + end + end + end + + context "when failed to calculate current usage" do + before do + create(:anrok_customer, customer:) + + allow(Integrations::Aggregator::Taxes::Invoices::CreateDraftService) + .to receive(:call) + .and_return( + BaseService::Result.new.service_failure!( + code: "customerAddressCouldNotResolve", + message: "Customer address could not resolve" + ) + ) + end + + it "fails with an error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:tax_error]).to eq(["customerAddressCouldNotResolve: Customer address could not resolve"]) + end + end + + context "when target_wallet_ids is provided" do + subject(:result) { described_class.call(customer:, include_generating_invoices:, target_wallet_ids: [target_wallet.id]) } + + let!(:target_wallet) do + create( + :wallet, + customer:, + balance_cents: 1000, + ongoing_balance_cents: 1000, + ongoing_usage_balance_cents: 0, + credits_balance: 10.0, + credits_ongoing_balance: 10.0, + credits_ongoing_usage_balance: 0 + ) + end + + let!(:other_wallet) do + create( + :wallet, + customer:, + balance_cents: 2000, + ongoing_balance_cents: 2000, + ongoing_usage_balance_cents: 0, + credits_balance: 20.0, + credits_ongoing_balance: 20.0, + credits_ongoing_usage_balance: 0 + ) + end + + it "only calls RefreshOngoingUsageService for targeted wallets" do + allow(Wallets::Balance::RefreshOngoingUsageService).to receive(:call!).and_call_original + + subject + + expect(Wallets::Balance::RefreshOngoingUsageService) + .to have_received(:call!).once + expect(Wallets::Balance::RefreshOngoingUsageService) + .to have_received(:call!).with(hash_including(wallet: target_wallet)) + end + + it "only updates last_ongoing_balance_sync_at for targeted wallets" do + subject + + expect(target_wallet.reload.last_ongoing_balance_sync_at).not_to be_nil + expect(other_wallet.reload.last_ongoing_balance_sync_at).to be_nil + end + + it "returns all active wallets in the result" do + expect(result).to be_success + expect(result.wallets).to match_array(customer.wallets.active) + end + end + + context "when there are wallet billable metric limitations" do + subject(:result) { described_class.call(customer: targeted_customer, include_generating_invoices: false) } + + let(:targeted_customer) { create(:customer, organization: targeted_org, awaiting_wallet_refresh: true) } + let(:targeted_org) { create(:organization) } + let(:billable_metric1) { create(:billable_metric, organization: targeted_org, aggregation_type: "count_agg") } + let(:billable_metric2) { create(:billable_metric, organization: targeted_org, aggregation_type: "count_agg") } + + let(:targeted_subscription) { create(:subscription, organization: targeted_org, customer: targeted_customer, started_at: Time.zone.now - 1.year) } + + let(:charge1) do + create(:standard_charge, plan: targeted_subscription.plan, billable_metric: billable_metric1, properties: {amount: "3"}) + end + + let(:charge2) do + create(:standard_charge, plan: targeted_subscription.plan, billable_metric: billable_metric2, properties: {amount: "5"}) + end + + let(:targeted_wallet) do + create( + :wallet, + customer: targeted_customer, + balance_cents: 1000, + ongoing_balance_cents: 1000, + ongoing_usage_balance_cents: 0, + credits_balance: 10.0, + credits_ongoing_balance: 10.0, + credits_ongoing_usage_balance: 0, + priority: 1 + ) + end + + let(:unrestricted_wallet) do + create( + :wallet, + customer: targeted_customer, + balance_cents: 2000, + ongoing_balance_cents: 2000, + ongoing_usage_balance_cents: 0, + credits_balance: 20.0, + credits_ongoing_balance: 20.0, + credits_ongoing_usage_balance: 0, + priority: 2 + ) + end + + let(:wallet_target) { create(:wallet_target, wallet: targeted_wallet, billable_metric: billable_metric1, organization: targeted_org) } + + before do + charge1 + charge2 + targeted_wallet + unrestricted_wallet + wallet_target + + # 2 events for billable_metric1 -> 2 * $3 = $6 = 600 cents + create_list(:event, 2, organization: targeted_org, subscription: targeted_subscription, customer: targeted_customer, code: billable_metric1.code) + + # 1 event for billable_metric2 -> 1 * $5 = $5 = 500 cents + create(:event, organization: targeted_org, subscription: targeted_subscription, customer: targeted_customer, code: billable_metric2.code) + end + + it "only counts targeted billable metric fees for the targeted wallet" do + expect(result).to be_success + + # targeted_wallet has wallet_target for billable_metric1 only + # So it should only count fees for billable_metric1: 600 cents + expect(targeted_wallet.reload.ongoing_usage_balance_cents).to eq(600) + expect(targeted_wallet.credits_ongoing_usage_balance).to eq(6.0) + expect(targeted_wallet.ongoing_balance_cents).to eq(400) + expect(targeted_wallet.credits_ongoing_balance).to eq(4.0) + end + + it "counts remaining fees for the unrestricted wallet" do + expect(result).to be_success + + # unrestricted_wallet should count fees for billable_metric2: 500 cents + # (billable_metric1 fees are already allocated to targeted_wallet) + expect(unrestricted_wallet.reload.ongoing_usage_balance_cents).to eq(500) + expect(unrestricted_wallet.credits_ongoing_usage_balance).to eq(5.0) + expect(unrestricted_wallet.ongoing_balance_cents).to eq(1500) + expect(unrestricted_wallet.credits_ongoing_balance).to eq(15.0) + end + end + end +end diff --git a/spec/services/customers/terminate_relations_service_spec.rb b/spec/services/customers/terminate_relations_service_spec.rb new file mode 100644 index 0000000..72a99bd --- /dev/null +++ b/spec/services/customers/terminate_relations_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::TerminateRelationsService do + subject(:terminate_service) { described_class.new(customer:) } + + let(:customer) { create(:customer, :deleted) } + + context "with an active subscription" do + let(:subscription) { create(:subscription, customer:) } + + before { subscription } + + it "terminates the subscription" do + freeze_time do + expect { terminate_service.call } + .to change { subscription.reload.status }.from("active").to("terminated") + .and change(subscription, :terminated_at).from(nil).to(Time.current) + end + end + end + + context "with a pending subscription" do + let(:subscription) { create(:subscription, :pending, customer:) } + + before { subscription } + + it "cancels the subscription" do + freeze_time do + expect { terminate_service.call } + .to change { subscription.reload.status }.from("pending").to("canceled") + .and change(subscription, :canceled_at).from(nil).to(Time.current) + end + end + end + + context "with draft invoices" do + let(:subscription) { create(:subscription, customer:) } + let(:invoices) { create_list(:invoice, 2, :draft, customer:) } + + before do + create(:invoice_subscription, invoice: invoices.first, subscription:, invoicing_reason: :subscription_starting) + create(:invoice_subscription, invoice: invoices.last, subscription:, invoicing_reason: :subscription_periodic) + end + + it "enqueues finalize jobs for the invoices" do + expect do + terminate_service.call + end.to have_enqueued_job(Invoices::FinalizeJob).exactly(:twice) + end + end + + context "with an applied coupon" do + let(:applied_coupon) { create(:applied_coupon, customer:) } + + before { applied_coupon } + + it "terminates the applied coupon" do + terminate_service.call + + expect(applied_coupon.reload).to be_terminated + end + end + + context "with an active wallet" do + let(:wallet) { create(:wallet, customer:) } + + before { wallet } + + it "terminates the wallet" do + terminate_service.call + + expect(wallet.reload).to be_terminated + end + end +end diff --git a/spec/services/customers/update_currency_service_spec.rb b/spec/services/customers/update_currency_service_spec.rb new file mode 100644 index 0000000..0b5f069 --- /dev/null +++ b/spec/services/customers/update_currency_service_spec.rb @@ -0,0 +1,166 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::UpdateCurrencyService do + subject(:currency_service) { described_class.new(customer:, currency:, customer_update:) } + + let(:customer) { create(:customer, currency: nil) } + let(:currency) { "USD" } + let(:customer_update) { false } + + describe "#call" do + it "assigns the currency to the customer" do + result = currency_service.call + + expect(result).to be_success + expect(customer.reload.currency).to eq(currency) + end + + context "when customer is not found" do + let(:customer) { nil } + + it "returns a failure" do + result = currency_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("customer") + end + end + + context "when customer currency is the same as the provided one" do + let(:customer) { create(:customer, currency: "EUR") } + let(:currency) { customer.currency } + + it "returns a success" do + expect(currency_service.call).to be_success + + expect(customer.reload.currency).to eq(currency) + end + end + + context "when customer already has a currency" do + let(:customer) { create(:customer, currency: "EUR") } + + it "returns a failure" do + result = currency_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:currency]).to eq(["currencies_does_not_match"]) + end + + context "when in customer update" do + let(:customer_update) { true } + + it "assigns the currency to the customer" do + result = currency_service.call + + expect(result).to be_success + expect(customer.reload.currency).to eq(currency) + end + + context "when customer is not editable" do + before { create(:subscription, customer:) } + + it "returns a failure" do + result = currency_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:currency]).to eq(["currencies_does_not_match"]) + end + end + end + end + + context "when customer is not editable" do + before { create(:subscription, customer:) } + + it "returns a failure" do + result = currency_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:currency]).to eq(["currencies_does_not_match"]) + end + end + + context "when providing an invalid currency" do + let(:currency) { "INVALID" } + + it "returns a failure" do + result = currency_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:currency]).to eq(["value_is_invalid"]) + end + end + + context "when multi_currency flag is enabled" do + before { customer.organization.enable_feature_flag!(:multi_currency) } + + context "when customer_update is false (billing object creation)" do + let(:customer_update) { false } + + context "when customer has no currency" do + let(:customer) { create(:customer, currency: nil) } + + it "sets the currency" do + result = currency_service.call + + expect(result).to be_success + expect(customer.reload.currency).to eq(currency) + end + end + + context "when customer already has a different currency" do + let(:customer) { create(:customer, currency: "EUR") } + + it "returns success without updating customer currency" do + result = currency_service.call + + expect(result).to be_success + expect(customer.reload.currency).to eq("EUR") + end + end + end + + context "when customer_update is true (direct API update)" do + let(:customer_update) { true } + let(:customer) { create(:customer, currency: "EUR") } + + it "allows the currency update" do + result = currency_service.call + + expect(result).to be_success + expect(customer.reload.currency).to eq(currency) + end + + context "when customer has invoices in a different currency" do + before { create(:invoice, customer:, currency: "EUR", status: :finalized) } + + it "allows the currency update" do + result = currency_service.call + + expect(result).to be_success + expect(customer.reload.currency).to eq(currency) + end + end + + context "when customer is not editable" do + before { create(:subscription, customer:) } + + it "allows the currency update" do + result = currency_service.call + + expect(result).to be_success + expect(customer.reload.currency).to eq(currency) + end + end + end + end + end +end diff --git a/spec/services/customers/update_invoice_issuing_date_settings_service_spec.rb b/spec/services/customers/update_invoice_issuing_date_settings_service_spec.rb new file mode 100644 index 0000000..bd14e40 --- /dev/null +++ b/spec/services/customers/update_invoice_issuing_date_settings_service_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::UpdateInvoiceIssuingDateSettingsService do + subject(:update_service) { described_class.new(customer:, params:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:params) do + { + billing_configuration: { + subscription_invoice_issuing_date_anchor:, + subscription_invoice_issuing_date_adjustment: + }, + invoice_grace_period: + } + end + + let(:invoice_grace_period) { 2 } + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + describe "#call" do + let(:invoice_to_be_finalized) do + create(:invoice, status: :draft, customer:, issuing_date: DateTime.parse("19 Jun 2022").to_date, expected_finalization_date: DateTime.parse("19 Jun 2022").to_date, organization:) + end + + let(:invoice_to_not_be_finalized) do + create(:invoice, status: :draft, customer:, issuing_date: DateTime.parse("21 Jun 2022").to_date, expected_finalization_date: DateTime.parse("21 Jun 2022").to_date, organization:) + end + + before do + invoice_to_be_finalized + invoice_to_not_be_finalized + end + + context "with premium feature", :premium do + it "updates invoice issuing date settings on customer" do + update_service.call + + customer.reload + + expect(customer.invoice_grace_period).to eq(2) + expect(customer.subscription_invoice_issuing_date_anchor).to eq("next_period_start") + expect(customer.subscription_invoice_issuing_date_adjustment).to eq("align_with_finalization_date") + end + + it "finalizes corresponding draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + result = update_service.call + + expect(result.customer.invoice_grace_period).to eq(2) + expect(Invoices::FinalizeJob).not_to have_been_enqueued.with(invoice_to_not_be_finalized) + expect(Invoices::FinalizeJob).to have_been_enqueued.with(invoice_to_be_finalized) + end + end + + it "updates issuing_date on draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("23 Jun 2022")) + end + end + + context "when customer has net_payment_term" do + let(:customer) { create(:customer, organization:, net_payment_term: 3) } + + it "updates issuing_date on draft invoices with payment term" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("26 Jun 2022")) + end + end + end + + context "when grace period is the same" do + let(:invoice_grace_period) { customer.invoice_grace_period } + let(:subscription_invoice_issuing_date_anchor) {} + let(:subscription_invoice_issuing_date_adjustment) {} + + it "does not finalize any draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + update_service.call + + expect(Invoices::FinalizeJob).not_to have_been_enqueued + end + end + + it "does not update issuing_date on draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.not_to change { invoice_to_not_be_finalized.reload.issuing_date } + end + end + end + + context "when clearing grace period" do + before do + customer.update(invoice_grace_period: 0) + end + + let(:invoice_grace_period) { nil } + + it "clears the grace period" do + expect { update_service.call }.to change(customer, :invoice_grace_period).from(0).to(nil) + end + end + end + + context "without premium feature" do + it "does not update invoice grace period on customer" do + update_service.call + + customer.reload + + expect(customer.invoice_grace_period).to eq(nil) + expect(customer.subscription_invoice_issuing_date_anchor).to eq("next_period_start") + expect(customer.subscription_invoice_issuing_date_adjustment).to eq("align_with_finalization_date") + end + end + + context "with issuing date preferences", :premium do + let(:recurring) { true } + + before do + create(:invoice_subscription, invoice: invoice_to_be_finalized, organization:, recurring:) + create(:invoice_subscription, invoice: invoice_to_not_be_finalized, organization:, recurring:) + end + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:invoice_grace_period) { 2 } + + it "updates issuing_date on draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("20 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.expected_finalization_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("20 Jun 2022")) + end + end + end + + context "with current_period_end + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + let(:invoice_grace_period) { 2 } + + it "updates issuing_date on draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.expected_finalization_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("23 Jun 2022")) + end + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:invoice_grace_period) { 2 } + + it "updates issuing_date on draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.not_to change { + [ + invoice_to_not_be_finalized.reload.issuing_date, + invoice_to_not_be_finalized.reload.payment_due_date + ] + } + + expect(invoice_to_not_be_finalized.expected_finalization_date).to eq(DateTime.parse("23 Jun 2022")) + end + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + let(:invoice_grace_period) { 2 } + + it "updates issuing_date on draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.expected_finalization_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("23 Jun 2022")) + end + end + end + + context "with innoice_grace_period set up" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:invoice_grace_period) { 2 } + + it "updates issuing_date on draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("17 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.expected_finalization_date } + .to(DateTime.parse("20 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("17 Jun 2022")) + end + end + end + + context "with no preferences set on the customer level" do + let(:billing_entity) do + create( + :billing_entity, + subscription_invoice_issuing_date_anchor: "next_period_start", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + ) + end + + let(:customer) do + create( + :customer, + organization:, + billing_entity:, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date", + invoice_grace_period: 2 + ) + end + + let(:subscription_invoice_issuing_date_anchor) {} + let(:subscription_invoice_issuing_date_adjustment) {} + let(:invoice_grace_period) {} + + it "updates issuing_date on draft invoices based on billing entity settings" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("19 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.expected_finalization_date } + .to(DateTime.parse("22 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("19 Jun 2022")) + end + end + end + + context "when invoice is not recurring" do + let(:recurring) { false } + + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:invoice_grace_period) { 2 } + + it "ignores all issuing date preferences" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + expect { update_service.call }.to change { invoice_to_not_be_finalized.reload.issuing_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.expected_finalization_date } + .to(DateTime.parse("23 Jun 2022")) + .and change { invoice_to_not_be_finalized.reload.payment_due_date } + .to(DateTime.parse("23 Jun 2022")) + end + end + end + end + end +end diff --git a/spec/services/customers/update_invoice_payment_due_date_service_spec.rb b/spec/services/customers/update_invoice_payment_due_date_service_spec.rb new file mode 100644 index 0000000..5ffa971 --- /dev/null +++ b/spec/services/customers/update_invoice_payment_due_date_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::UpdateInvoicePaymentDueDateService do + subject(:update_service) { described_class.new(customer:, net_payment_term:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:billing_entity) { create(:billing_entity, organization:, net_payment_term: 15) } + let(:customer) { create(:customer, organization:, billing_entity:, net_payment_term: customer_net_payment_term) } + let(:net_payment_term) { 30 } + let(:customer_net_payment_term) { nil } + + describe "#call" do + let(:draft_invoice) do + create( + :invoice, + organization:, + billing_entity:, + customer:, + status: :draft, + issuing_date: DateTime.parse("21 Jun 2022"), + net_payment_term: customer.applicable_net_payment_term + ) + end + + before do + draft_invoice + end + + it "updates invoice payment_due_date" do + expect { update_service.call }.to change { draft_invoice.reload.payment_due_date } + .from(DateTime.parse("21 Jun 2022")) + .to(DateTime.parse("21 Jun 2022") + net_payment_term.days) + end + + context "when the customer already has the same net_payment_term and the new value is nil" do + let(:customer_net_payment_term) { 20 } + let(:net_payment_term) { nil } + + it "sets the payment_due_date of the draft_invoice to the billing entity level value" do + expect { update_service.call }.to change { draft_invoice.reload.payment_due_date } + .from(DateTime.parse("21 Jun 2022")) + .to(DateTime.parse("21 Jun 2022") + billing_entity.net_payment_term.days) + end + + it "sets the net_payment_term of the draft_invoice to the org level value" do + expect { update_service.call }.to change { draft_invoice.reload.net_payment_term } + .from(customer_net_payment_term) + .to(billing_entity.net_payment_term) + end + end + end +end diff --git a/spec/services/customers/update_service_spec.rb b/spec/services/customers/update_service_spec.rb new file mode 100644 index 0000000..aa72a7c --- /dev/null +++ b/spec/services/customers/update_service_spec.rb @@ -0,0 +1,804 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::UpdateService do + subject(:customers_service) { described_class.new(customer:, args: update_args) } + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:payment_provider_code) { "stripe_1" } + + describe "call" do + let(:customer) do + create( + :customer, + organization:, + billing_entity:, + payment_provider: "stripe", + payment_provider_code: + ) + end + + let(:external_id) { SecureRandom.uuid } + + let(:update_args) do + { + id: customer.id, + name: "Updated customer name", + firstname: "Updated customer firstname", + lastname: "Updated customer lastname", + customer_type: "individual", + tax_identification_number: "2246", + net_payment_term: 8, + external_id:, + shipping_address: { + city: "Paris" + }, + account_type: account_type, + billing_configuration: { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + } + } + end + + let(:account_type) { "customer" } + + it "updates a customer and calls SendWebhookJob" do + allow(SendWebhookJob).to receive(:perform_later) + + result = customers_service.call + updated_customer = result.customer + expect(updated_customer.name).to eq(update_args[:name]) + expect(updated_customer.firstname).to eq(update_args[:firstname]) + expect(updated_customer.lastname).to eq(update_args[:lastname]) + expect(updated_customer.customer_type).to eq(update_args[:customer_type]) + expect(updated_customer.tax_identification_number).to eq(update_args[:tax_identification_number]) + expect(updated_customer.subscription_invoice_issuing_date_anchor).to eq("current_period_end") + expect(updated_customer.subscription_invoice_issuing_date_adjustment).to eq("keep_anchor") + + shipping_address = update_args[:shipping_address] + expect(updated_customer.shipping_city).to eq(shipping_address[:city]) + expect(SendWebhookJob).to have_received(:perform_later).with("customer.updated", updated_customer) + end + + it "produces an activity log" do + described_class.call(customer:, args: update_args) + + expect(Utils::ActivityLog).to have_produced("customer.updated").after_commit.with(customer) + end + + context "with email containing unicode lookalike characters" do + let(:update_args) do + { + id: customer.id, + email: "hello@something\u2013other.com" + } + end + + it "sanitizes the email before saving" do + result = customers_service.call + expect(result.customer.email).to eq("hello@something-other.com") + end + end + + context "when updating the billing entity reference" do + let(:billing_entity_2) { create(:billing_entity, organization:) } + + let(:update_args) do + { + id: customer.id, + name: "Updated customer name", + billing_entity_code: billing_entity_2.code + } + end + + it "updates the billing entity" do + result = customers_service.call + expect(result).to be_success + expect(result.customer.billing_entity).to eq(billing_entity_2) + end + + context "when billing entity is archived" do + before { billing_entity_2.update!(archived_at: Time.current) } + + it "fails" do + result = customers_service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + end + end + + context "when customer is attached to a subscription" do + before do + create(:subscription, customer:) + end + + it "does not update the billing entity" do + result = customers_service.call + expect(result).to be_success + expect(result.customer.billing_entity).to eq(billing_entity) + end + + context "when multi_entity_billing feature flag is enabled" do + before { organization.enable_feature_flag!(:multi_entity_billing) } + + it "updates the billing entity" do + result = customers_service.call + expect(result).to be_success + expect(result.customer.billing_entity).to eq(billing_entity_2) + end + end + end + end + + context "when updating account_type to partner" do + let(:customer) do + create( + :customer, + organization:, + exclude_from_dunning_campaign: false, + applied_dunning_campaign: dunning_campaign + ) + end + + let(:dunning_campaign) { create(:dunning_campaign) } + + let(:organization) do + create(:organization, premium_integrations: ["auto_dunning"]) + end + + let(:account_type) { "partner" } + + it "does not change the account_type" do + result = customers_service.call + + updated_customer = result.customer + expect(updated_customer.name).to eq(update_args[:name]) + expect(updated_customer).to be_customer_account + expect(updated_customer).not_to be_exclude_from_dunning_campaign + expect(updated_customer.applied_dunning_campaign).to eq dunning_campaign + end + end + + context "with premium features", :premium do + let(:update_args) do + { + id: customer.id, + name: "Updated customer name", + timezone: "Europe/Paris", + billing_configuration: { + invoice_grace_period: 3 + }, + account_type: + } + end + + it "updates a customer" do + result = customers_service.call + + updated_customer = result.customer + expect(updated_customer.timezone).to eq("Europe/Paris") + expect(updated_customer.invoice_grace_period).to eq(3) + end + + context "when revenue_share feature is enabled and updates account_type to partner" do + let(:organization) do + create(:organization, premium_integrations: %w[revenue_share auto_dunning]) + end + + let(:customer) do + create( + :customer, + organization:, + exclude_from_dunning_campaign: false, + applied_dunning_campaign: dunning_campaign + ) + end + + let(:dunning_campaign) { create(:dunning_campaign) } + + let(:account_type) { "partner" } + + it "updates the customer as partner" do + result = customers_service.call + + updated_customer = result.customer + expect(updated_customer.name).to eq(update_args[:name]) + expect(updated_customer).to be_partner_account + expect(updated_customer).to be_exclude_from_dunning_campaign + expect(updated_customer.applied_dunning_campaign).to be_nil + end + + context "when customer is attached to a subscription" do + before do + create(:subscription, customer:) + end + + it "does not update the account_type" do + result = customers_service.call + + updated_customer = result.customer + expect(updated_customer).to be_customer_account + end + end + end + end + + context "with metadata" do + let(:customer_metadata) { create(:customer_metadata, customer:) } + let(:another_customer_metadata) { create(:customer_metadata, customer:, key: "test", value: "1") } + let(:update_args) do + { + id: customer.id, + name: "Updated customer name", + metadata: [ + { + id: customer_metadata.id, + key: "new key", + value: "new value", + display_in_invoice: true + }, + { + key: "Added key", + value: "Added value", + display_in_invoice: true + } + ] + } + end + + before do + customer_metadata + another_customer_metadata + end + + it "updates metadata" do + result = customers_service.call + + metadata_keys = result.customer.metadata.pluck(:key) + metadata_ids = result.customer.metadata.pluck(:id) + + expect(result.customer.metadata.count).to eq(2) + expect(metadata_keys).to eq(["new key", "Added key"]) + expect(metadata_ids).to include(customer_metadata.id) + expect(metadata_ids).not_to include(another_customer_metadata.id) + end + end + + context "with validation error" do + let(:external_id) { nil } + + it "returns an error" do + result = customers_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:external_id]).to eq(["value_is_mandatory"]) + end + end + + context "when attached to a subscription" do + let(:account_type) { "partner" } + + before do + subscription = create(:subscription, customer:) + customer.update!(currency: subscription.plan.amount_currency) + end + + it "updates only the name" do + result = customers_service.call + + updated_customer = result.customer + expect(updated_customer.name).to eq("Updated customer name") + expect(updated_customer.external_id).to eq(customer.external_id) + expect(updated_customer.account_type).to eq customer.account_type + end + + context "when updating the currency" do + let(:update_args) do + { + id: customer.id, + currency: "CAD" + } + end + + it "fails" do + result = customers_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:currency) + expect(result.error.messages[:currency]).to include("currencies_does_not_match") + end + end + end + + context "when updating payment provider" do + let(:update_args) do + { + id: customer.id, + name: "Updated customer name", + external_id:, + payment_provider: "stripe", + payment_provider_code: + } + end + + before do + create(:stripe_provider, organization: customer.organization, code: payment_provider_code) + + allow(PaymentProviderCustomers::UpdateService) + .to receive(:call) + .with(customer) + .and_return(BaseService::Result.new) + end + + it "creates a payment provider customer" do + result = customers_service.call + expect(result).to be_success + + updated_customer = result.customer + expect(updated_customer.payment_provider).to eq("stripe") + expect(updated_customer.stripe_customer).to be_present + end + + it "does not call payment provider customer update service" do + customers_service.call + expect(PaymentProviderCustomers::UpdateService).not_to have_received(:call).with(customer) + end + + context "with provider customer id" do + let(:update_args) do + { + id: customer.id, + external_id: SecureRandom.uuid, + name: "Foo Bar", + organization_id: organization.id, + payment_provider: "stripe", + provider_customer: {provider_customer_id: "cus_12345"} + } + end + + it "calls payment provider customer update service" do + customers_service.call + expect(PaymentProviderCustomers::UpdateService).to have_received(:call).with(customer) + end + + it "creates a payment provider customer" do + result = customers_service.call + + expect(result).to be_success + + customer = result.customer + expect(customer.id).to be_present + expect(customer.payment_provider).to eq("stripe") + expect(customer.stripe_customer).to be_present + expect(customer.stripe_customer.provider_customer_id).to eq("cus_12345") + end + + context "when removing a provider customer id" do + let(:update_args) do + { + id: customer.id, + external_id: SecureRandom.uuid, + name: "Foo Bar", + organization_id: organization.id, + payment_provider: nil, + provider_customer: {provider_customer_id: nil} + } + end + + let(:stripe_customer) { create(:stripe_customer, customer:) } + + before do + stripe_customer + customer.update!(payment_provider: "stripe") + end + + it "removes the provider customer id" do + result = customers_service.call + + expect(result).to be_success + + result_customer = result.customer + expect(result_customer.id).to eq(customer.id) + expect(result_customer.payment_provider).to be_nil + + expect(result_customer.stripe_customer).to eq(stripe_customer) + expect(result_customer.stripe_customer.provider_customer_id).to be_nil + end + end + end + end + + context "when removing payment provider" do + let(:stripe_provider) { create(:stripe_provider, organization:, code: payment_provider_code) } + let(:customer) do + create( + :customer, + organization:, + billing_entity:, + payment_provider: "stripe", + payment_provider_code: + ) + end + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_provider) } + let(:payment_method) do + create(:payment_method, customer:, payment_provider: stripe_provider, payment_provider_customer: stripe_customer) + end + + let(:update_args) do + { + id: customer.id, + organization_id: organization.id, + payment_provider: nil, + provider_customer: nil, + payment_provider_code: nil + } + end + + before { payment_method } + + it "sets the customer parameters to nil" do + result = customers_service.call + + expect(result).to be_success + + customer = result.customer + expect(customer.payment_provider).to be_nil + expect(customer.provider_customer).to be_nil + expect(customer.payment_provider_code).to be_nil + end + + # NOTE: This describes a scenario with incorrect behavior that currently exists. + # The previous provider customer is not discarded + it "does not discard the provider customer" do + result = customers_service.call + + expect(result).to be_success + expect(stripe_customer.reload).not_to be_discarded + end + + it "discards the payment methods" do + result = customers_service.call + + expect(result).to be_success + expect(payment_method.reload).to be_discarded + end + end + + context "when partialy updating", :premium do + let(:stripe_customer) { create(:stripe_customer, customer:, provider_payment_methods: %w[sepa_debit]) } + + let(:update_args) do + { + id: customer.id, + invoice_grace_period: 8 + } + end + + before { stripe_customer } + + it "updates only the updated args" do + result = customers_service.call + + expect(result).to be_success + expect(result.customer.invoice_grace_period).to eq(update_args[:invoice_grace_period]) + + expect(result.customer.stripe_customer.provider_payment_methods).to eq(%w[sepa_debit]) + end + end + + context "when updating net payment term" do + it "updates the net payment term of all draft invoices" do + create(:invoice, :draft, customer:, net_payment_term: 30) + create(:invoice, customer:, net_payment_term: 30) + create(:invoice, :draft, customer:, net_payment_term: 30) + + result = customers_service.call + + expect(result).to be_success + expect(result.customer.invoices.draft.pluck(:net_payment_term)).to eq([8, 8]) + end + end + + context "when updating invoice_custom_sections" do + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 4, organization:) } + + before do + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: invoice_custom_sections[0]) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_sections[2]) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: invoice_custom_sections[3]) + end + + context "when customer is set to skip_invoice_custom_sections: true" do + let(:update_args) do + { + id: customer.id, + skip_invoice_custom_sections: true + } + end + + it "clears customer selected invoice custom sections" do + result = customers_service.call + expect(result).to be_success + expect(customer.reload.selected_invoice_custom_sections).to be_empty + expect(customer.applicable_invoice_custom_sections).to be_empty + end + end + + context "when setting to invoice custom sections that match with organization selected invoice custom sections" do + let(:update_args) do + { + id: customer.id, + configurable_invoice_custom_section_ids: [] + } + end + + it "assigns organization sections to customer" do + result = customers_service.call + expect(result).to be_success + expect(customer.reload.selected_invoice_custom_sections).to be_empty + expect(customer.applicable_invoice_custom_sections.ids).to match_array(invoice_custom_sections[2..3].map(&:id)) + end + end + + context "when setting custom invoice_custom_sections for the customer" do + let(:update_args) do + { + id: customer.id, + configurable_invoice_custom_section_ids: invoice_custom_sections[1..2].map(&:id) + } + end + + it "assigns customer sections" do + result = customers_service.call + expect(result).to be_success + expect(customer.reload.selected_invoice_custom_sections.ids).to match_array(invoice_custom_sections[1..2].map(&:id)) + end + end + + context "when setting custom invoice_custom_sections for the customer with skipped invoice_custom_sections" do + let(:update_args) do + { + id: customer.id, + configurable_invoice_custom_section_ids: invoice_custom_sections[1..2].map(&:id) + } + end + + before { customer.update!(skip_invoice_custom_sections: true) } + + it "updates skip_invoice_custom_sections to false" do + result = customers_service.call + expect(result).to be_success + expect(customer.reload.skip_invoice_custom_sections).to be false + expect(customer.selected_invoice_custom_sections.ids).to match_array(invoice_custom_sections[1..2].map(&:id)) + end + end + + context "when sending both: skip_invoice_custom_sections and applicable_invoice_custom_section_ids" do + let(:update_args) do + { + id: customer.id, + skip_invoice_custom_sections: true, + configurable_invoice_custom_section_ids: invoice_custom_sections[1..2].map(&:id) + } + end + + it "returns an error" do + result = customers_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:invoice_custom_sections]).to include("skip_sections_and_selected_ids_sent_together") + end + end + end + + context "when organization has eu tax management" do + let(:tax_code) { "lago_eu_fr_standard" } + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new } + + before do + create(:tax, organization:, code: "lago_eu_fr_standard", rate: 20.0) + organization.update(eu_tax_management: true) + + eu_tax_result.tax_code = tax_code + allow(Customers::EuAutoTaxesService).to receive(:call).and_return(eu_tax_result) + end + + it "assigns the right tax to the customer" do + result = customers_service.call + + expect(result).to be_success + + tax = result.customer.taxes.first + expect(tax.code).to eq(tax_code) + end + + context "when eu tax code is not applicable" do + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new.not_allowed_failure!(code: "") } + + it "does not apply tax" do + result = customers_service.call + + expect(result).to be_success + + expect(result.customer.taxes).to eq([]) + end + end + end + + context "when dunning campaign data is provided" do + let(:customer) do + create( + :customer, + organization:, + applied_dunning_campaign: dunning_campaign, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: 2.days.ago + ) + end + let(:dunning_campaign) { create(:dunning_campaign) } + + let(:update_args) do + { + id: customer.id, + applied_dunning_campaign_id: dunning_campaign.id, + exclude_from_dunning_campaign: true + } + end + + it "does not update auto dunning config" do + expect { customers_service.call } + .to not_change(customer, :applied_dunning_campaign_id) + .and not_change(customer, :exclude_from_dunning_campaign) + .and not_change(customer, :last_dunning_campaign_attempt) + .and not_change { customer.last_dunning_campaign_attempt_at.iso8601 } + + expect(customers_service.call).to be_success + end + + context "with auto_dunning premium integration", :premium do + let(:customer) do + create( + :customer, + organization:, + exclude_from_dunning_campaign: true, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: 2.days.ago + ) + end + + let(:organization) do + create(:organization, premium_integrations: ["auto_dunning"]) + end + + let(:update_args) do + {applied_dunning_campaign_id: dunning_campaign.id} + end + + it "updates auto dunning config" do + expect { customers_service.call } + .to change(customer, :applied_dunning_campaign_id).to(dunning_campaign.id) + .and change(customer, :exclude_from_dunning_campaign).to(false) + .and change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(customers_service.call).to be_success + end + + context "with exclude from dunning campaign" do + let(:customer) do + create( + :customer, + organization:, + applied_dunning_campaign: dunning_campaign, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: 2.days.ago + ) + end + + let(:update_args) do + {exclude_from_dunning_campaign: true} + end + + it "updates auto dunning config" do + expect { customers_service.call } + .to change(customer, :applied_dunning_campaign_id).to(nil) + .and change(customer, :exclude_from_dunning_campaign).to(true) + .and change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(customers_service.call).to be_success + end + end + + context "with applied_dunning_campaign_id nil" do + let(:customer) do + create( + :customer, + organization:, + applied_dunning_campaign: dunning_campaign, + exclude_from_dunning_campaign: false, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: 2.days.ago + ) + end + + let(:update_args) { {applied_dunning_campaign_id: nil} } + + it "updates auto dunning config" do + expect { customers_service.call } + .to change(customer, :applied_dunning_campaign_id).to(nil) + .and not_change(customer, :exclude_from_dunning_campaign) + .and change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(customers_service.call).to be_success + end + end + + context "when dunning campaign can not be found" do + let(:customer) do + create( + :customer, + organization:, + applied_dunning_campaign: dunning_campaign, + exclude_from_dunning_campaign: false, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: 2.days.ago + ) + end + + let(:update_args) { {applied_dunning_campaign_id: "not_found_id"} } + + it "does not update auto dunning config" do + expect { customers_service.call } + .to not_change(customer, :applied_dunning_campaign_id) + .and not_change(customer, :exclude_from_dunning_campaign) + .and not_change(customer, :last_dunning_campaign_attempt) + .and not_change(customer, :last_dunning_campaign_attempt_at) + + result = customers_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("dunning_campaign_not_found") + end + end + end + end + + context "with error details" do + before do + create(:error_detail, owner: customer, organization:, error_code: :tax_error) + end + + context "when address fields change" do + let(:update_args) { {id: customer.id, address_line1: "New Address"} } + + it "deletes the tax_error error_details" do + result = customers_service.call + + expect(result).to be_success + expect(customer.reload.error_details.count).to be_zero + end + end + + context "when non-address fields change" do + let(:update_args) { {id: customer.id, name: "New Name"} } + + it "does not discard tax_error error_details" do + result = customers_service.call + + expect(result).to be_success + expect(customer.reload.error_details.count).to eq(1) + end + end + end + end +end diff --git a/spec/services/customers/upsert_from_api_service_spec.rb b/spec/services/customers/upsert_from_api_service_spec.rb new file mode 100644 index 0000000..ec8c03b --- /dev/null +++ b/spec/services/customers/upsert_from_api_service_spec.rb @@ -0,0 +1,1399 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::UpsertFromApiService do + subject(:result) { described_class.call(organization:, params: create_args) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:membership) { create(:membership, organization:) } + let(:external_id) { SecureRandom.uuid } + + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + firstname: "First", + lastname: "Last", + tax_identification_number: "123456789", + billing_configuration: { + document_locale: "fr", + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + }, + shipping_address: { + address_line1: "line1", + address_line2: "line2", + city: "Paris", + zipcode: "123456", + state: "foobar", + country: "FR" + } + } + end + + before do + allow(SendWebhookJob).to receive(:perform_later) + allow(CurrentContext).to receive(:source).and_return("api") + end + + it "creates a new customer" do + expect(result).to be_success + + customer = result.customer + expect(customer.id).to be_present + expect(customer.organization_id).to eq(organization.id) + expect(customer.external_id).to eq(create_args[:external_id]) + expect(customer.name).to eq(create_args[:name]) + expect(customer.firstname).to eq(create_args[:firstname]) + expect(customer.lastname).to eq(create_args[:lastname]) + expect(customer.customer_type).to be_nil + expect(customer.currency).to eq(create_args[:currency]) + expect(customer.tax_identification_number).to eq(create_args[:tax_identification_number]) + expect(customer.timezone).to be_nil + expect(customer).to be_customer_account + expect(customer).not_to be_exclude_from_dunning_campaign + + billing = create_args[:billing_configuration] + expect(customer.document_locale).to eq(billing[:document_locale]) + expect(customer.invoice_grace_period).to be_nil + expect(result.customer.subscription_invoice_issuing_date_anchor).to eq("current_period_end") + expect(result.customer.subscription_invoice_issuing_date_adjustment).to eq("keep_anchor") + expect(customer.skip_invoice_custom_sections).to eq(false) + + shipping_address = create_args[:shipping_address] + expect(customer.shipping_address_line1).to eq(shipping_address[:address_line1]) + expect(customer.shipping_address_line2).to eq(shipping_address[:address_line2]) + expect(customer.shipping_city).to eq(shipping_address[:city]) + expect(customer.shipping_zipcode).to eq(shipping_address[:zipcode]) + expect(customer.shipping_state).to eq(shipping_address[:state]) + expect(customer.shipping_country).to eq(shipping_address[:country]) + end + + it "creates customer with the default billing entity" do + expect(result).to be_success + expect(result.customer.billing_entity).to eq(billing_entity) + end + + it "creates customer with correctly persisted attributes" do + expect(result).to be_success + + customer = Customer.find_by(external_id:) + billing = create_args[:billing_configuration] + + expect(customer).to have_attributes( + organization_id: organization.id, + external_id: create_args[:external_id], + name: create_args[:name], + currency: create_args[:currency], + timezone: nil, + document_locale: billing[:document_locale], + invoice_grace_period: nil, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor" + ) + end + + it "calls SendWebhookJob with customer.created" do + customer = result.customer + + expect(SendWebhookJob).to have_received(:perform_later).with("customer.created", customer) + end + + it "produces an activity log" do + result = described_class.call(organization:, params: create_args) + + expect(Utils::ActivityLog).to have_produced("customer.created").after_commit.with(result.customer) + end + + context "when organization has multiple billing entities" do + let(:billing_entity_2) { create(:billing_entity, organization:) } + + before { billing_entity_2 } + + it "creates a customer assigned to the organization's default billing entity" do + expect(result).to be_success + expect(result.customer.billing_entity).to be_present + expect(result.customer.billing_entity).to eq(organization.default_billing_entity) + end + + context "with billing_entity_code" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + billing_entity_code: billing_entity_2.code + } + end + + it "creates a new customer" do + expect(result).to be_success + expect(result.customer.billing_entity).to eq(billing_entity_2) + end + end + end + + context "when organization has no active billing entity" do + before do + organization.billing_entities.update_all(archived_at: Time.current) # rubocop:disable Rails/SkipsModelValidations + end + + it "return a failed result" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + end + end + + context "when billing_entity_code belongs to an archived billing entity" do + let(:billing_entity_2) { create(:billing_entity, organization:) } + + before do + billing_entity_2.update!(archived_at: Time.current) + create_args.merge!(billing_entity_code: billing_entity_2.code) + end + + it "return a failed result" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("billing_entity") + end + end + + context "with account_type 'partner'" do + before do + create_args.merge!(account_type: "partner") + end + + it "creates a customer as customer_account" do + expect(result).to be_success + + customer = result.customer + expect(customer).to be_customer_account + expect(customer).not_to be_exclude_from_dunning_campaign + end + end + + context "with email nil" do + let(:create_args) do + { + external_id:, + email: nil, + billing_configuration: { + document_locale: "fr" + } + } + end + + it "creates customer with email nil" do + expect(result).to be_success + expect(result.customer.email).to be_nil + end + end + + context "with invalid email" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + email: "@missingusername.com", + tax_identification_number: "123456789", + billing_configuration: { + document_locale: "fr" + }, + shipping_address: { + address_line1: "line1", + address_line2: "line2", + city: "Paris", + zipcode: "123456", + state: "foobar", + country: "FR" + } + } + end + + it "fails to create customer with wrong email" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:email) + expect(result.error.messages[:email]).to include("invalid_email_format") + end + end + + context "with email containing unicode lookalike characters" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + email: "hello@something\u2013other.com" + } + end + + it "sanitizes the email before saving" do + expect(result).to be_success + expect(result.customer.email).to eq("hello@something-other.com") + end + end + + context "with external_id already used by a deleted customer" do + it "creates a customer with the same external_id" do + create(:customer, :deleted, organization:, external_id:) + + expect { result }.to change(Customer, :count).by(1) + + customers = organization.customers.with_discarded + expect(customers.count).to eq(2) + expect(customers.pluck(:external_id).uniq).to eq([external_id]) + end + end + + context "with an external_id already in use in a not-default billing entity" do + let(:customer) do + create(:customer, organization:, billing_entity: billing_entity_2, external_id:) + end + + let(:billing_entity_2) { create(:billing_entity, organization:) } + + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + billing_entity_code: billing_entity.code + } + end + + before { customer } + + it "updates the billing_entity of the customer" do + expect(result).to be_success + expect(result.customer).to eq(customer) + expect(result.customer.billing_entity).to eq(billing_entity) + end + + context "when the customer already has an invoice" do + before do + create(:invoice, customer: customer) + end + + it "does not update the billing_entity of the customer" do + expect(result).to be_success + expect(result.customer).to eq(customer) + expect(result.customer.billing_entity).to eq(billing_entity_2) + end + + context "when multi_entity_billing feature flag is enabled" do + before { organization.enable_feature_flag!(:multi_entity_billing) } + + it "updates the billing_entity of the customer" do + expect(result).to be_success + expect(result.customer).to eq(customer) + expect(result.customer.billing_entity).to eq(billing_entity) + end + end + end + + context "when not sending billing_entity_code" do + let(:create_args) do + { + external_id:, + name: "Updated name" + } + end + + it "does not update the billing_entity of the customer" do + expect(result).to be_success + expect(result.customer).to eq(customer) + expect(result.customer.billing_entity).to eq(billing_entity_2) + end + end + end + + context "with customer_type" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + customer_type: "company" + } + end + + it "creates customer with correct customer_type" do + expect(result).to be_success + + expect(result.customer.customer_type).to eq(create_args[:customer_type]) + end + + context "with invalid customer_type" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + customer_type: "default_type" + } + end + + it "fails to create customer" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:customer_type) + expect(result.error.messages[:customer_type]).to include("value_is_invalid") + end + end + end + + context "with metadata" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + billing_configuration: { + document_locale: "fr" + }, + metadata: [ + { + key: "manager name", + value: "John", + display_in_invoice: true + }, + { + key: "manager address", + value: "Test", + display_in_invoice: false + } + ] + } + end + + it "creates customer with metadata" do + expect(result).to be_success + expect(result.customer.metadata.count).to eq(2) + end + end + + context "with finalize_zero_amount_invoice" do + let(:create_args) do + { + external_id:, + finalize_zero_amount_invoice: "skip" + } + end + + it "creates customer with finalize_zero_amount_invoice" do + expect(result).to be_success + expect(result.customer.finalize_zero_amount_invoice).to eq("skip") + end + + context "with nil value for finalize_zero_amount_invoice" do + let(:create_args) do + { + external_id:, + finalize_zero_amount_invoice: nil + } + end + + it "creates customer with finalize_zero_amount_invoice set to the default value" do + expect(result).to be_success + expect(result.customer.finalize_zero_amount_invoice).to eq("inherit") + end + end + + context "with incorrect value of finalize_zero_amount_invoice" do + let(:create_args) do + { + external_id:, + finalize_zero_amount_invoice: "bad value" + } + end + + it "fails with validation error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:finalize_zero_amount_invoice) + expect(result.error.messages[:finalize_zero_amount_invoice]).to include("invalid_value") + end + end + end + + context "with premium features", :premium do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + timezone: "Europe/Paris", + billing_configuration: { + invoice_grace_period: 3 + } + } + end + + it "creates a new customer" do + expect(result).to be_success + expect(result.customer.timezone).to eq(create_args[:timezone]) + expect(result.customer.invoice_grace_period).to eq(3) + end + + context "with revenue share feature enabled and account_type 'partner'" do + let(:organization) do + create(:organization, premium_integrations: ["revenue_share"]) + end + + before do + create_args.merge!(account_type: "partner") + end + + it "creates a customer as partner_account" do + expect(result).to be_success + expect(result.customer).to be_partner_account + expect(result.customer).to be_exclude_from_dunning_campaign + end + + context "when updating a customer that already have an invoice" do + let(:customer) do + create(:customer, organization:, account_type: "customer", external_id:) + end + + let(:invoice) { create(:invoice, customer: customer) } + + before { invoice } + + it "doesn't update customer to partner" do + expect(result).to be_success + expect(result.customer).to be_customer_account + end + end + + context "with invalid account_type" do + before { create_args.merge!(account_type: "invalid") } + + it "fails to create customer" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:account_type) + expect(result.error.messages[:account_type]).to include("value_is_invalid") + end + end + end + end + + context "with invoice_custom_sections params" do + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + firstname: "First", + lastname: "Last", + invoice_custom_section_codes: [invoice_custom_section.code] + } + end + + it "creates customer with invoice_custom_sections" do + expect(result).to be_success + + customer = result.customer + expect(customer.selected_invoice_custom_sections.count).to eq(1) + expect(customer.selected_invoice_custom_sections.first).to eq(invoice_custom_section) + expect(customer.skip_invoice_custom_sections).to eq(false) + end + end + + context "when customer already exists" do + let(:customer) do + create( + :customer, + organization:, + external_id:, + email: "foo@bar.com" + ) + end + + before { customer } + + it "updates the customer" do + expect(result).to be_success + expect(result.customer).to eq(customer) + expect(result.customer.name).to eq(create_args[:name]) + expect(result.customer.external_id).to eq(create_args[:external_id]) + + # NOTE: It should not erase exsting properties + expect(result.customer.country).to eq(customer.country) + expect(result.customer.address_line1).to eq(customer.address_line1) + expect(result.customer.address_line2).to eq(customer.address_line2) + expect(result.customer.state).to eq(customer.state) + expect(result.customer.zipcode).to eq(customer.zipcode) + expect(result.customer.email).to eq(customer.email) + expect(result.customer.city).to eq(customer.city) + expect(result.customer.url).to eq(customer.url) + expect(result.customer.phone).to eq(customer.phone) + expect(result.customer.logo_url).to eq(customer.logo_url) + expect(result.customer.legal_name).to eq(customer.legal_name) + expect(result.customer.legal_number).to eq(customer.legal_number) + end + + it "calls SendWebhookJob with customer.updated" do + result + + expect(SendWebhookJob).to have_received(:perform_later).with("customer.updated", customer) + end + + it "produces an activity log" do + result = described_class.call(organization:, params: create_args) + + expect(Utils::ActivityLog).to have_produced("customer.updated").after_commit.with(result.customer) + end + + context "with provider customer" do + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider:) } + let(:stripe_customer_result) { BaseService::Result.new } + + before do + allow(Stripe::Customer).to receive(:update).and_return(stripe_customer_result) + stripe_customer + customer.update!(payment_provider: "stripe") + end + + it "updates the customer" do + expect(result).to be_success + expect(result.customer).to eq(customer) + expect(result.customer.name).to eq(create_args[:name]) + expect(result.customer.external_id).to eq(create_args[:external_id]) + expect(result.customer.document_locale).to eq(create_args[:billing_configuration][:document_locale]) + end + end + + context "with metadata" do + let(:customer_metadata) { create(:customer_metadata, customer:) } + let(:another_customer_metadata) { create(:customer_metadata, customer:, key: "test", value: "1") } + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + billing_configuration: { + document_locale: "fr" + }, + metadata: [ + { + id: customer_metadata.id, + key: "new key", + value: "new value", + display_in_invoice: true + }, + { + key: "Added key", + value: "Added value", + display_in_invoice: true + } + ] + } + end + + before do + customer_metadata + another_customer_metadata + end + + it "updates metadata" do + metadata_keys = result.customer.metadata.pluck(:key) + metadata_ids = result.customer.metadata.pluck(:id) + + expect(result.customer.metadata.count).to eq(2) + expect(metadata_keys).to eq(["new key", "Added key"]) + expect(metadata_ids).to include(customer_metadata.id) + expect(metadata_ids).not_to include(another_customer_metadata.id) + end + + context "when more than five metadata objects are provided" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + billing_configuration: { + document_locale: "fr" + }, + metadata: [ + { + id: customer_metadata.id, + key: "new key", + value: "new value", + display_in_invoice: true + }, + { + key: "Added key1", + value: "Added value1", + display_in_invoice: true + }, + { + key: "Added key2", + value: "Added value2", + display_in_invoice: true + }, + { + key: "Added key3", + value: "Added value3", + display_in_invoice: true + }, + { + key: "Added key4", + value: "Added value4", + display_in_invoice: true + }, + { + key: "Added key5", + value: "Added value5", + display_in_invoice: true + } + ] + } + end + + it "fails to create customer with metadata" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:metadata) + expect(result.error.messages[:metadata]).to include("invalid_count") + end + end + end + + context "with integration customers" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "EUR", + billing_configuration: { + document_locale: "fr" + }, + integration_customers: + } + end + + context "when there are netusite and anrok customer sent" do + let(:integration_customers) do + [ + { + external_customer_id: "12345", + integration_type: "netsuite", + integration_code: "code1", + subsidiary_id: "1", + sync_with_provider: true + }, + { + external_customer_id: "65432", + integration_type: "anrok", + integration_code: "code3", + sync_with_provider: true + } + ] + end + + it "creates customer with integration customers" do + expect(result).to be_success + expect(result.customer).to be_persisted + # FIXME: should we test the integration customers? + end + end + + context "when there are multiple integration customers of the same type" do + let(:integration_customers) do + [ + { + external_customer_id: "12345", + integration_type: "netsuite", + integration_code: "code1", + subsidiary_id: "1", + sync_with_provider: true + }, + { + external_customer_id: "02346", + integration_type: "netsuite", + integration_code: "code2", + subsidiary_id: "1", + sync_with_provider: true + }, + { + external_customer_id: "65432", + integration_type: "anrok", + integration_code: "code3", + sync_with_provider: true + } + ] + end + + it "fails to create customer with integration customers" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:integration_customers) + expect(result.error.messages[:integration_customers]).to include("invalid_count_per_integration_type") + end + end + end + + context "when attached to a subscription" do + let(:create_args) do + { + external_id:, + name: "Foo Bar", + currency: "CAD" + } + end + + before do + subscription = create(:subscription, customer:) + customer.update!(currency: subscription.plan.amount_currency) + end + + it "fails is we change the subscription" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:currency) + expect(result.error.messages[:currency]).to include("currencies_does_not_match") + end + end + + context "when updating invoice grace period", :premium do + let(:create_args) do + { + external_id:, + billing_configuration: {invoice_grace_period: 2} + } + end + + before do + allow(Customers::UpdateInvoiceIssuingDateSettingsService).to receive(:call).and_call_original + end + + it "calls UpdateInvoiceIssuingDateSettingsService" do + result + + expect(Customers::UpdateInvoiceIssuingDateSettingsService).to have_received(:call).with(customer:, params: create_args) + end + end + + context "when updating email to nil" do + let(:create_args) do + { + external_id:, + email: nil + } + end + + it "updates customer to not have email" do + expect(result).to be_success + expect(result.customer.email).to be_nil + end + end + end + + context "with validation error" do + let(:create_args) do + { + name: "Foo Bar" + } + end + + it "return a failed result" do + expect(result).to be_failure + end + end + + context "with stripe configuration" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + billing_configuration: { + payment_provider: "stripe", + payment_provider_code: "stripe_1", + provider_customer_id: "stripe_id" + } + } + end + + context "when payment provider does not exist" do + let(:error_messages) { {base: ["payment_provider_not_found"]} } + + it "fails to create customer" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq(error_messages) + end + end + + context "when payment provider exists" do + before { create(:stripe_provider, organization:, code: "stripe_1") } + + it "creates a stripe customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to eq("stripe") + expect(result.customer.stripe_customer).to be_present + expect(result.customer.stripe_customer.id).to be_present + expect(result.customer.stripe_customer.provider_customer_id).to eq("stripe_id") + end + end + + context "when customer already exists" do + let(:payment_provider) { "stripe" } + let(:payment_provider_code) { "stripe_1" } + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + billing_configuration: { + payment_provider:, + payment_provider_code:, + provider_customer_id: "stripe_id" + } + } + end + let(:customer) do + create( + :customer, + organization:, + billing_entity:, + external_id: create_args[:external_id], + email: "foo@bar.com", + payment_provider_code: nil, + payment_provider: nil + ) + end + + before { customer } + + context "when payment provider exists" do + let(:stripe_provider) { create(:stripe_provider, code: payment_provider_code, organization:) } + + before { stripe_provider } + + it "updates the customer" do + expect(result).to be_success + expect(result.customer).to eq(customer) + + # NOTE: It should not erase exsting properties + expect(result.customer.payment_provider).to eq("stripe") + expect(result.customer.stripe_customer).to be_present + expect(result.customer.stripe_customer.id).to be_present + expect(result.customer.stripe_customer.provider_customer_id).to eq("stripe_id") + end + end + + context "when payment provider does not exists" do + it "fails" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to include("payment_provider_not_found") + end + end + + context "when payment_provider is invalid" do + let(:payment_provider) { "foo" } + + it "fails" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to include("payment_provider_not_found") + end + end + + context "when payment_provider is not sent" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + billing_configuration: { + sync_with_provider: true + } + } + end + + it "updates the customer and reset payment_provider attribute" do + expect(result).to be_success + expect(result.customer).to eq(customer) + + # NOTE: It should not erase existing properties + expect(result.customer.payment_provider).to eq(nil) + expect(result.customer.stripe_customer).not_to be_present + end + end + + context "when removing the payment provider" do + let(:stripe_provider) { create(:stripe_provider, organization:, code: "stripe_1") } + let(:external_id) { SecureRandom.uuid } + let(:customer) do + create( + :customer, + organization:, + external_id:, + payment_provider: "stripe", + payment_provider_code: "stripe_1" + ) + end + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_provider) } + let(:payment_method) { create(:payment_method, customer:, payment_provider_customer: stripe_customer) } + let(:create_args) do + { + external_id:, + billing_configuration: { + payment_provider: nil + } + } + end + + before { payment_method } + + it "removes the payment provider from customer" do + expect(result).to be_success + + expect(result.customer.payment_provider).to be_nil + end + + it "does not discard the provider customer" do + expect(result).to be_success + + expect(stripe_customer.reload).not_to be_discarded + end + + it "discards the old provider customer's payment methods" do + expect(result).to be_success + + expect(payment_method.reload).to be_discarded + end + end + + context "when switching from stripe to gocardless" do + let(:stripe_provider) { create(:stripe_provider, organization:, code: "stripe_1") } + let(:external_id) { SecureRandom.uuid } + let(:customer) do + create( + :customer, + organization:, + external_id:, + payment_provider: "stripe", + payment_provider_code: "stripe_1" + ) + end + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_provider) } + let(:payment_method) { create(:payment_method, customer:, payment_provider_customer: stripe_customer) } + + before do + payment_method + create(:gocardless_provider, organization:, code: "gocardless_1") + end + + context "when provider_customer_id is sent" do + let(:create_args) do + { + external_id:, + billing_configuration: { + payment_provider: "gocardless", + payment_provider_code: "gocardless_1", + provider_customer_id: "gocardless_id" + } + } + end + + it "creates the gocardless provider customer" do + expect(result).to be_success + + expect(result.customer.payment_provider).to eq("gocardless") + expect(result.customer.payment_provider_code).to eq("gocardless_1") + expect(result.customer.provider_customer.provider_customer_id).to eq("gocardless_id") + end + + it "does not discard the provider customer" do + expect(result).to be_success + + expect(stripe_customer.reload).not_to be_discarded + end + + it "discards the old provider customer's payment methods" do + expect(result).to be_success + + expect(payment_method.reload).to be_discarded + end + end + + context "when provider_customer_id is not sent" do + let(:create_args) do + { + external_id:, + billing_configuration: { + sync_with_provider: true, + payment_provider: "gocardless", + payment_provider_code: "gocardless_1" + } + } + end + + # NOTE: This describes a scenario with incorrect behavior that currently exists. + # The new provider customer does not get created and the previous one is not discarded + it "does not create the gocardless provider customer" do + expect(result).to be_success + + expect(result.customer.payment_provider).to eq("gocardless") + expect(result.customer.payment_provider_code).to eq("gocardless_1") + expect(result.customer.provider_customer).to be_nil + end + + it "does not discard the provider customer" do + expect(result).to be_success + + expect(stripe_customer.reload).not_to be_discarded + end + + it "does not discard the old provider customer's payment methods" do + expect(result).to be_success + + expect(payment_method.reload).not_to be_discarded + end + end + end + + context "when changing the connected stripe account" do + let(:old_stripe_provider) { create(:stripe_provider, organization:, code: "stripe_1") } + let(:new_stripe_provider) { create(:stripe_provider, organization:, code: "stripe_2") } + let(:external_id) { SecureRandom.uuid } + let(:customer) do + create( + :customer, + organization:, + external_id:, + payment_provider: "stripe", + payment_provider_code: "stripe_1" + ) + end + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: old_stripe_provider) } + let(:payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer: stripe_customer, + payment_provider: old_stripe_provider + ) + end + + before do + payment_method + new_stripe_provider + end + + context "when provider_customer_id is sent" do + let(:create_args) do + { + external_id:, + billing_configuration: { + payment_provider: "stripe", + payment_provider_code: "stripe_2", + provider_customer_id: "stripe_2_id" + } + } + end + + # NOTE: This assumes that the provider_customer_id exists on stripe + # and the update is performed succesfully + before do + allow(Stripe::Customer).to receive(:update).and_return(BaseService::Result.new) + end + + it "updates the stripe provider_code and provider_customer_id" do + expect(result).to be_success + + expect(result.customer.payment_provider).to eq("stripe") + expect(result.customer.payment_provider_code).to eq("stripe_2") + expect(result.customer.provider_customer.provider_customer_id).to eq("stripe_2_id") + end + + it "does not discard the provider customer" do + expect(result).to be_success + + expect(stripe_customer.reload).not_to be_discarded + end + + it "discards the old payment methods" do + expect(result).to be_success + + expect(payment_method.reload).to be_discarded + end + end + + # NOTE: This is a scenario with incorrect behavior that currently exists. + # The old customer ID doesn't exist on the new Stripe account, causing an error + # when trying to update the customer on Stripe. + context "when provider_customer_id is not sent" do + let(:create_args) do + { + external_id:, + billing_configuration: { + sync_with_provider: true, + payment_provider: "stripe", + payment_provider_code: "stripe_2" + } + } + end + + before do + allow(Stripe::Customer).to receive(:update).and_raise( + Stripe::InvalidRequestError.new( + "No such customer: '#{stripe_customer.provider_customer_id}'", + "id", + http_status: 404, + code: "resource_missing" + ) + ) + end + + it "fails with a third party error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.error_code).to include("resource_missing") + end + end + + context "when provider_customer_id is set to nil" do + let(:create_args) do + { + external_id:, + billing_configuration: { + provider_customer_id: nil, + payment_provider: "stripe", + payment_provider_code: "stripe_2" + } + } + end + + # NOTE: This bypasses an issue with the check: + # + # if customer.provider_customer&.provider_customer_id + # PaymentProviderCustomers::UpdateService.call(customer) + # end + # + # Since customer is not reloaded, it still checks the previous provider_customer state, + # which has a provider_customer_id + before do + allow(Stripe::Customer).to receive(:update).and_return(BaseService::Result.new) + end + + it "updates the stripe provider code" do + expect(result).to be_success + + expect(result.customer.payment_provider).to eq("stripe") + expect(result.customer.payment_provider_code).to eq("stripe_2") + expect(result.customer.provider_customer.provider_customer_id).to be_nil + end + + it "does not discard the provider customer" do + expect(result).to be_success + + expect(stripe_customer.reload).not_to be_discarded + end + + it "discards the old payment methods" do + expect(result).to be_success + + expect(payment_method.reload).to be_discarded + end + end + end + end + end + + context "with gocardless configuration" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + billing_configuration: { + payment_provider: "gocardless", + provider_customer_id: "gocardless_id" + } + } + end + + context "when payment provider does not exist" do + let(:error_messages) { {base: ["payment_provider_not_found"]} } + + it "fails to create customer" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq(error_messages) + end + end + + context "when payment provider exists" do + before { create(:gocardless_provider, organization:, code: "gocardless_1") } + + it "creates a gocardless customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to eq("gocardless") + expect(result.customer.gocardless_customer).to be_present + expect(result.customer.gocardless_customer.id).to be_present + expect(result.customer.gocardless_customer.provider_customer_id).to eq("gocardless_id") + end + end + end + + context "with unknown payment provider" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + billing_configuration: { + payment_provider: "foo" + } + } + end + + it "does not create a payment provider customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to be_nil + expect(result.customer.stripe_customer).to be_nil + expect(result.customer.gocardless_customer).to be_nil + end + end + + context "when billing configuration is not provided" do + it "does not creates a payment provider customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to be_nil + expect(result.customer.stripe_customer).not_to be_present + expect(result.customer.gocardless_customer).not_to be_present + end + + context "when customer is updated" do + before do + create( + :customer, + organization:, + billing_entity:, + external_id: create_args[:external_id], + payment_provider: nil, + payment_provider_code: nil, + email: "foo@bar.com" + ) + end + + it "does not create a payment provider customer" do + expect(result).to be_success + expect(result.customer.id).to be_present + expect(result.customer.payment_provider).to be_nil + expect(result.customer.stripe_customer).not_to be_present + expect(result.customer.gocardless_customer).not_to be_present + end + end + end + + context "when organization has eu tax management" do + let(:tax_code) { "lago_eu_fr_standard" } + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new } + + before do + create(:tax, organization:, code: "lago_eu_fr_standard", rate: 20.0) + organization.update(eu_tax_management: true) + + eu_tax_result.tax_code = tax_code + allow(Customers::EuAutoTaxesService).to receive(:call).and_return(eu_tax_result) + end + + it "assigns the right tax to the customer" do + expect(result).to be_success + + tax = result.customer.taxes.first + expect(tax.code).to eq(tax_code) + end + + context "when eu tax code is not applicable" do + let(:eu_tax_result) { Customers::EuAutoTaxesService::Result.new.not_allowed_failure!(code: "") } + + it "does not apply tax" do + expect(result).to be_success + expect(result.customer.taxes).to eq([]) + end + end + end + + context "with tax_codes" do + let(:create_args) do + { + external_id: SecureRandom.uuid, + name: "Foo Bar", + organization_id: organization.id + } + end + + it "creates customer with tax_codes" do + create_args[:tax_codes] = ["123456789"] + create(:tax, organization:, code: "123456789") + + expect(result).to be_success + expect(result.customer.taxes.count).to eq(1) + expect(result.customer.taxes.first.code).to eq("123456789") + end + + it "updates customer with tax_codes" do + create_args[:tax_codes] = [] + tax = create(:tax, organization:, code: "987654321") + customer = create(:customer, organization:, external_id: create_args[:external_id]) + create(:customer_applied_tax, customer:, tax:) + + expect(result).to be_success + expect(result.customer.taxes.count).to eq(0) + end + end + + context "with error details" do + let(:customer) do + create(:customer, organization:, external_id:, address_line1: "Old Address") + end + + before do + create(:error_detail, owner: customer, organization:, error_code: :tax_error) + end + + context "when address fields change" do + before { create_args[:address_line1] = "New Address" } + + it "discards tax_error error_details" do + expect(result).to be_success + expect(customer.error_details.count).to be_zero + end + end + + context "when non-address fields change" do + let(:customer) do + create( + :customer, + organization:, external_id:, + shipping_address_line1: create_args.dig(:shipping_address, :address_line1), + shipping_address_line2: create_args.dig(:shipping_address, :address_line2), + shipping_city: create_args.dig(:shipping_address, :city), + shipping_zipcode: create_args.dig(:shipping_address, :zipcode), + shipping_state: create_args.dig(:shipping_address, :state), + shipping_country: create_args.dig(:shipping_address, :country)&.upcase + ) + end + + before { create_args[:name] = "New Name" } + + it "does not discard tax_error error_details" do + expect(result).to be_success + expect(customer.error_details.count).to eq(1) + end + end + end +end diff --git a/spec/services/customers/vies_check_service_spec.rb b/spec/services/customers/vies_check_service_spec.rb new file mode 100644 index 0000000..8d31ef3 --- /dev/null +++ b/spec/services/customers/vies_check_service_spec.rb @@ -0,0 +1,256 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Customers::ViesCheckService do + subject(:vies_check_service) { described_class.new(customer:) } + + let(:organization) { create(:organization, country: "IT", eu_tax_management: true) } + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", eu_tax_management: true) } + let(:customer) { create(:customer, organization:, billing_entity:, tax_identification_number:, zipcode: nil, country: "DE") } + let(:tax_identification_number) { "IT12345678901" } + + shared_examples "a VIES check error" do |error_type:, fault_string: nil, http_status: 200| + raise "Please provide either fault_string or a http_status that is not 200" if fault_string.nil? && http_status == 200 + + let(:full_error_message) { "The VIES web service returned the error: #{fault_string || http_status}" } + + before do + body = if http_status == 200 + "env:Server#{fault_string || full_error_message}" + else + "ERROR" + end + stub_request(:post, "https://ec.europa.eu/taxation_customs/vies/services/checkVatService") + .to_return(status: http_status, body: body) + end + + it "returns an error" do + result = vies_check_service.call + + expect(result).not_to be_success + expect(result.tax_code).to be_nil + expect(result.error.code).to eq("vies_check_failed") + expect(result.error.message).to eq("vies_check_failed: #{full_error_message}") + end + + it "sends a vies_check webhook with error details" do + expect { vies_check_service.call } + .to have_enqueued_job(SendWebhookJob) + .with("customer.vies_check", customer, vies_check: {valid: false, valid_format: true, error: full_error_message}) + end + + it "creates a pending_vies_check record" do + expect { vies_check_service.call }.to change(PendingViesCheck, :count).by(1) + + pending_check = customer.pending_vies_check + expect(pending_check).to have_attributes( + organization: customer.organization, + billing_entity: customer.billing_entity, + tax_identification_number: customer.tax_identification_number, + attempts_count: 1, + last_error_type: error_type, + last_error_message: full_error_message + ) + expect(pending_check.last_attempt_at).to be_present + end + + it "returns the pending_vies_check in the result" do + result = vies_check_service.call + + expect(result.pending_vies_check).to be_present + expect(result.pending_vies_check.attempts_count).to eq(1) + end + + context "when pending_vies_check already exists" do + let(:existing_check) { create(:pending_vies_check, customer:, attempts_count: 2, last_error_type: "unknown") } + + before { existing_check } + + it "updates the existing record and increments attempts_count" do + expect { vies_check_service.call }.not_to change(PendingViesCheck, :count) + + existing_check.reload + expect(existing_check.attempts_count).to eq(3) + expect(existing_check.last_error_type).to eq(error_type) + end + end + end + + describe ".call" do + let(:vies_response) { {} } + + context "when VIES check is successful" do + before do + allow_any_instance_of(Valvat).to receive(:exists?).and_return(vies_response) # rubocop:disable RSpec/AnyInstance + end + + context "when eu_tax_management is disabled" do + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", eu_tax_management: false) } + let(:vies_response) { nil } + + it "returns not_allowed_failure" do + result = vies_check_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("eu_tax_not_applicable") + end + end + + context "when vat number is invalid" do + let(:tax_identification_number) { "invalid_vat_number" } + let(:vies_response) { false } + + before do + allow_any_instance_of(Valvat).to receive(:exists?).and_call_original # rubocop:disable RSpec/AnyInstance + end + + it "returns the customer country tax code and vies_check details" do + result = vies_check_service.call + + expect(result.tax_code).to eq("lago_eu_de_standard") + expect(result.vies_check).to eq({valid: false, valid_format: false}) + end + + it "sends a vies_check webhook with error details" do + expect { vies_check_service.call } + .to have_enqueued_job(SendWebhookJob) + .with("customer.vies_check", customer, vies_check: {valid: false, valid_format: false}) + end + end + + context "with same country as the billing_entity" do + let(:vies_response) { {country_code: "FR"} } + + it "returns the organization country tax code" do + result = vies_check_service.call + + expect(result.tax_code).to eq("lago_eu_fr_standard") + end + + it "returns the vies_check response" do + result = vies_check_service.call + + expect(result.vies_check).to eq(vies_response) + end + + it "sends a vies_check webhook" do + expect { vies_check_service.call } + .to have_enqueued_job(SendWebhookJob) + .with("customer.vies_check", customer, vies_check: vies_response) + end + + context "when a pending_vies_check exists from a previous failure" do + let(:pending_check) { create(:pending_vies_check, customer:) } + + before { pending_check } + + it "deletes the pending_vies_check record" do + expect { vies_check_service.call }.to change(PendingViesCheck, :count).by(-1) + expect(customer.reload.pending_vies_check).to be_nil + end + end + end + + context "with a different country from the billing_entity one" do + let(:vies_response) { {country_code: "DE"} } + + it "returns the reverse charge tax" do + result = vies_check_service.call + + expect(result.tax_code).to eq("lago_eu_reverse_charge") + end + end + + context "when country has exceptions" do + let(:vies_response) { {country_code: "FR"} } + + context "when customer has no zipcode" do + it "returns the customer country standard tax" do + result = vies_check_service.call + expect(result.tax_code).to eq("lago_eu_fr_standard") + end + end + + context "when customer has a zipcode" do + context "when zipcode has applicable exceptions" do + before { customer.update(country: "FR", zipcode: "97412") } + + it "returns the exception tax code" do + result = vies_check_service.call + expect(result.tax_code).to eq("lago_eu_fr_exception_reunion") + end + end + + context "when zipcode has no applicable exceptions" do + before { customer.update(country: "FR", zipcode: "12345") } + + it "returns the customer counrty standard tax" do + result = vies_check_service.call + expect(result.tax_code).to eq("lago_eu_fr_standard") + end + end + end + end + + context "when VIES returns nil (TIN present but Valvat returns nil/falsy)" do + let(:vies_response) { false } + + context "when the customer has no country" do + before { customer.update(country: nil) } + + it "returns the billing entity country tax code" do + result = vies_check_service.call + + expect(result.tax_code).to eq("lago_eu_fr_standard") + end + end + + context "when the customer country is in europe" do + it "returns the customer country tax code" do + result = vies_check_service.call + + expect(result.tax_code).to eq("lago_eu_de_standard") + end + end + + context "when the customer country is out of europe" do + before { customer.update(country: "US") } + + it "returns the tax exempt tax code" do + result = vies_check_service.call + + expect(result.tax_code).to eq("lago_eu_tax_exempt") + end + end + end + end + + { + "MS_MAX_CONCURRENT_REQ" => "rate_limit", + "MS_UNAVAILABLE" => "member_state_unavailable", + "SERVICE_UNAVAILABLE" => "service_unavailable", + "TIMEOUT" => "timeout", + "VAT_BLOCKED" => "blocked", + "INVALID_REQUESTER_INFO" => "invalid_requester" + }.each do |fault_string, error_type| + context "when VIES check raises #{error_type} error" do + it_behaves_like "a VIES check error", + fault_string:, + error_type: + end + end + + [ + 307, + 400, + 500 + ].each do |http_status| + context "when VIES check raises HTTPError with #{http_status}" do + it_behaves_like "a VIES check error", + http_status: http_status, + error_type: "service_unavailable" + end + end + end +end diff --git a/spec/services/daily_usages/compute_all_service_spec.rb b/spec/services/daily_usages/compute_all_service_spec.rb new file mode 100644 index 0000000..3ec9eee --- /dev/null +++ b/spec/services/daily_usages/compute_all_service_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::ComputeAllService do + subject(:compute_service) { described_class.new(timestamp:) } + + let(:timestamp) { Time.zone.parse("2024-10-22 00:05:00") } + + let(:organization) { create(:organization, premium_integrations:) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:subscriptions) { create_list(:subscription, 5, customer:, last_received_event_on: timestamp.to_date - 1.day) } + + let(:premium_integrations) do + ["revenue_analytics"] + end + + before { subscriptions } + + describe "#call" do + it "enqueues a job to compute the daily usage" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:).at(a_value_between(0.minutes.from_now, 30.minutes.from_now)) + end + end + + context "when LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS is set" do + before { stub_const("ENV", ENV.to_h.merge("LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS" => "60")) } + + it "uses the configured interval" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:).at(a_value_between(0.seconds.from_now, 60.seconds.from_now)) + end + end + end + + context "when LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS is negative" do + before { stub_const("ENV", ENV.to_h.merge("LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS" => "-100")) } + + it "falls back to the default interval" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:).at(a_value_between(0.minutes.from_now, 30.minutes.from_now)) + end + end + end + + context "when LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS is zero" do + before { stub_const("ENV", ENV.to_h.merge("LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS" => "0")) } + + it "falls back to the default interval" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:).at(a_value_between(0.minutes.from_now, 30.minutes.from_now)) + end + end + end + + context "when LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS is non-numeric" do + before { stub_const("ENV", ENV.to_h.merge("LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS" => "invalid")) } + + it "falls back to the default interval" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:).at(a_value_between(0.minutes.from_now, 30.minutes.from_now)) + end + end + end + + context "when LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS is blank" do + before { stub_const("ENV", ENV.to_h.merge("LAGO_DAILY_USAGE_SCHEDULING_JITTER_SECONDS" => "")) } + + it "falls back to the default interval" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:).at(a_value_between(0.minutes.from_now, 30.minutes.from_now)) + end + end + end + + context "when subscription usage was already computed" do + before { create(:daily_usage, subscription: subscriptions.first, customer: subscriptions.first.customer, usage_date: timestamp.to_date - 1.day) } + + it "does not enqueue any job" do + expect(compute_service.call).to be_success + expect(DailyUsages::ComputeJob).not_to have_been_enqueued.with(subscriptions.first, timestamp:) + subscriptions[1..].each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:).at(a_value_between(0.minutes.from_now, 30.minutes.from_now)) + end + end + end + + context "when the organization has a timezone" do + let(:organization) { create(:organization, timezone: "America/Sao_Paulo", premium_integrations:) } + + before do + billing_entity.update(timezone: "America/Sao_Paulo") + end + + it "takes the timezone into account" do + expect(compute_service.call).to be_success + expect(DailyUsages::ComputeJob).not_to have_been_enqueued + end + + context "when the day starts in the timezone" do + let(:timestamp) { Time.zone.parse("2024-10-22 03:05:00") } + + it "enqueues a job to compute the daily usage" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:) + end + end + end + end + + context "when the customer has a timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/Sao_Paulo") } + + it "takes the timezone into account" do + expect(compute_service.call).to be_success + expect(DailyUsages::ComputeJob).not_to have_been_enqueued + end + + context "when the day starts in the timezone" do + let(:timestamp) { Time.zone.parse("2024-10-22 03:05:00") } + + it "enqueues a job to compute the daily usage" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:) + end + end + end + end + + context "when last_received_event_on is nil" do + let(:subscriptions) { create_list(:subscription, 5, customer:, last_received_event_on: nil) } + + it "does not enqueue any job" do + expect(compute_service.call).to be_success + expect(DailyUsages::ComputeJob).not_to have_been_enqueued + end + end + + context "when last_received_event_on is today" do + let(:subscriptions) { create_list(:subscription, 5, customer:, last_received_event_on: timestamp.to_date) } + + it "does enqueue jobs" do + expect(compute_service.call).to be_success + subscriptions.each do |subscription| + expect(DailyUsages::ComputeJob).to have_been_enqueued.with(subscription, timestamp:) + end + end + end + + context "when last_received_event_on is stale" do + let(:subscriptions) { create_list(:subscription, 5, customer:, last_received_event_on: timestamp.to_date - 5.days) } + + it "does not enqueue any job" do + expect(compute_service.call).to be_success + expect(DailyUsages::ComputeJob).not_to have_been_enqueued + end + end + + context "when revenue_analytics premium integration flag is not present" do + let(:premium_integrations) { [] } + + it "does not enqueue any job" do + expect(compute_service.call).to be_success + expect(DailyUsages::ComputeJob).not_to have_been_enqueued + end + end + end +end diff --git a/spec/services/daily_usages/compute_diff_service_presentation_breakdowns_spec.rb b/spec/services/daily_usages/compute_diff_service_presentation_breakdowns_spec.rb new file mode 100644 index 0000000..5a476ba --- /dev/null +++ b/spec/services/daily_usages/compute_diff_service_presentation_breakdowns_spec.rb @@ -0,0 +1,425 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::ComputeDiffService do + subject(:diff_service) { described_class.new(daily_usage:, previous_daily_usage:) } + + let(:daily_usage) { create(:daily_usage, usage:) } + let(:previous_daily_usage) { create(:daily_usage, usage: previous_usage) } + + def charge_usage(lago_id:, presentation_breakdowns: [], grouped_usage: []) + { + "charge" => {"lago_id" => lago_id}, + "units" => "0.0", + "events_count" => 0, + "amount_cents" => 0, + "filters" => [], + "grouped_usage" => grouped_usage, + "presentation_breakdowns" => presentation_breakdowns + } + end + + def grouped_usage(grouped_by:, presentation_breakdowns:) + { + "grouped_by" => grouped_by, + "units" => "0.0", + "events_count" => 0, + "amount_cents" => 0, + "filters" => [], + "presentation_breakdowns" => presentation_breakdowns + } + end + + def usage_payload(charges_usage:) + { + "amount_cents" => 0, + "taxes_amount_cents" => 0, + "charges_usage" => charges_usage + } + end + + describe "presentation_breakdowns" do + context "when a presentation breakdown exists in both snapshots" do + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.1"}, + {"presentation_by" => {"region" => "eu"}, "units" => "0.4"} + ] + ) + ] + ) + end + + let(:previous_usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.0"} + ] + ) + ] + ) + end + + it "diffs non-grouped usage presentation_breakdowns by presentation_by" do + result = diff_service.call + + expect(result).to be_success + + diff_charge = result.usage_diff.fetch("charges_usage").first + expect(diff_charge.fetch("presentation_breakdowns")).to eq( + [ + {"presentation_by" => {"region" => "us"}, "units" => "0.1"}, + {"presentation_by" => {"region" => "eu"}, "units" => "0.4"} + ] + ) + end + end + + context "when presentation breakdowns are nested under grouped_usage" do + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + grouped_usage: [ + grouped_usage( + grouped_by: {"country" => nil}, + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us-east-1"}, "units" => "1.0"}, + {"presentation_by" => {"region" => "us-east-2"}, "units" => "0.1"} + ] + ), + grouped_usage( + grouped_by: {"country" => "us"}, + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us-east-1"}, "units" => "0.4"} + ] + ) + ] + ) + ] + ) + end + + let(:previous_usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + grouped_usage: [ + grouped_usage( + grouped_by: {"country" => nil}, + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us-east-1"}, "units" => "1.0"} + ] + ) + ] + ) + ] + ) + end + + it "diffs grouped_usage presentation_breakdowns by presentation_by" do + result = diff_service.call + expect(result).to be_success + + charge_diff = result.usage_diff.fetch("charges_usage").first + grouped_nil = charge_diff.fetch("grouped_usage").find { |gu| gu["grouped_by"] == {"country" => nil} } + + expect(grouped_nil.fetch("presentation_breakdowns")).to eq( + [ + {"presentation_by" => {"region" => "us-east-1"}, "units" => "0.0"}, + {"presentation_by" => {"region" => "us-east-2"}, "units" => "0.1"} + ] + ) + + grouped_us = charge_diff.fetch("grouped_usage").find { |gu| gu["grouped_by"] == {"country" => "us"} } + expect(grouped_us.fetch("presentation_breakdowns")).to eq( + [ + {"presentation_by" => {"region" => "us-east-1"}, "units" => "0.4"} + ] + ) + end + end + + context "when a charge is deleted between snapshots" do + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.5"}, + {"presentation_by" => {"region" => "eu"}, "units" => "0.2"} + ] + ) + ] + ) + end + + let(:previous_usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.0"} + ] + ), + charge_usage( + lago_id: "charge-c", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "2.0"} + ] + ) + ] + ) + end + + it "diffs presentation_breakdowns only for the overlapping charge" do + diff = diff_service.call.usage_diff + charge_a = diff.fetch("charges_usage").first + + expect(charge_a.fetch("presentation_breakdowns")).to eq( + [ + {"presentation_by" => {"region" => "us"}, "units" => "0.5"}, + {"presentation_by" => {"region" => "eu"}, "units" => "0.2"} + ] + ) + end + end + + context "when a new charge is added between snapshots" do + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.5"} + ] + ), + charge_usage( + lago_id: "charge-b", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "eu"}, "units" => "0.5"} + ] + ) + ] + ) + end + + let(:previous_usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.0"} + ] + ) + ] + ) + end + + it "keeps the new charge presentation_breakdowns unchanged" do + diff = diff_service.call.usage_diff + + charge_a = diff.fetch("charges_usage").find { |cu| cu.dig("charge", "lago_id") == "charge-a" } + expect(charge_a.fetch("presentation_breakdowns")).to eq( + [{"presentation_by" => {"region" => "us"}, "units" => "0.5"}] + ) + + charge_b = diff.fetch("charges_usage").find { |cu| cu.dig("charge", "lago_id") == "charge-b" } + expect(charge_b.fetch("presentation_breakdowns")).to eq( + [{"presentation_by" => {"region" => "eu"}, "units" => "0.5"}] + ) + end + end + + context "when all charges are replaced between snapshots" do + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-b", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "2.0"} + ] + ) + ] + ) + end + + let(:previous_usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.0"} + ] + ) + ] + ) + end + + it "does not diff presentation_breakdowns when there is no overlap" do + diff = diff_service.call.usage_diff + charge_b = diff.fetch("charges_usage").first + + expect(charge_b.fetch("presentation_breakdowns")).to eq( + [{"presentation_by" => {"region" => "us"}, "units" => "2.0"}] + ) + end + end + + context "when charges are both added and deleted between snapshots" do + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "2.0"} + ] + ), + charge_usage( + lago_id: "charge-c", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "eu"}, "units" => "1.0"} + ] + ) + ] + ) + end + + let(:previous_usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.0"} + ] + ), + charge_usage( + lago_id: "charge-b", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "3.0"} + ] + ) + ] + ) + end + + it "diffs presentation_breakdowns for the common charge and keeps new ones" do + diff = diff_service.call.usage_diff + + charge_a = diff.fetch("charges_usage").find { |cu| cu.dig("charge", "lago_id") == "charge-a" } + expect(charge_a.fetch("presentation_breakdowns")).to eq( + [{"presentation_by" => {"region" => "us"}, "units" => "1.0"}] + ) + + charge_c = diff.fetch("charges_usage").find { |cu| cu.dig("charge", "lago_id") == "charge-c" } + expect(charge_c.fetch("presentation_breakdowns")).to eq( + [{"presentation_by" => {"region" => "eu"}, "units" => "1.0"}] + ) + end + end + + context "when previous_daily_usage is nil" do + let(:previous_daily_usage) { nil } + + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.5"} + ] + ) + ] + ) + end + + let(:previous_usage) { nil } + + it "returns the current usage as diff (including presentation_breakdowns)" do + expect(diff_service.call.usage_diff).to eq(usage) + end + end + + context "when previous_daily_usage is not provided" do + subject(:diff_service) { described_class.new(daily_usage:) } + + let(:subscription) { create(:subscription) } + let(:from_datetime) { Time.zone.parse("2022-07-01T00:00:00Z") } + let(:to_datetime) { Time.zone.parse("2022-07-31T23:59:59Z") } + + let(:daily_usage) do + build( + :daily_usage, + subscription:, + from_datetime:, + to_datetime:, + usage_date: Date.new(2022, 7, 15), + usage: + ) + end + + let(:usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.5"} + ] + ) + ] + ) + end + + let(:previous_usage) do + usage_payload( + charges_usage: [ + charge_usage( + lago_id: "charge-a", + presentation_breakdowns: [ + {"presentation_by" => {"region" => "us"}, "units" => "1.0"} + ] + ) + ] + ) + end + + before do + create( + :daily_usage, + subscription:, + from_datetime:, + to_datetime:, + usage_date: Date.new(2022, 7, 14), + usage: previous_usage + ) + end + + it "automatically finds the previous usage and diffs presentation_breakdowns" do + diff = diff_service.call.usage_diff + expect(diff.fetch("charges_usage").first.fetch("presentation_breakdowns")).to eq( + [{"presentation_by" => {"region" => "us"}, "units" => "0.5"}] + ) + end + end + end +end diff --git a/spec/services/daily_usages/compute_diff_service_spec.rb b/spec/services/daily_usages/compute_diff_service_spec.rb new file mode 100644 index 0000000..648f6a3 --- /dev/null +++ b/spec/services/daily_usages/compute_diff_service_spec.rb @@ -0,0 +1,775 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::ComputeDiffService do + subject(:diff_service) { described_class.new(daily_usage:, previous_daily_usage:) } + + let(:daily_usage) { create(:daily_usage, usage:) } + let(:previous_daily_usage) { create(:daily_usage, usage: previous_usage) } + + let(:usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 123, + "taxes_amount_cents" => 20, + "total_amount_cents" => 143, + "charges_usage" => [ + { + "units" => "1.5", + "events_count" => 11, + "amount_cents" => 123, + "amount_currency" => "EUR", + "charge" => { + "lago_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "charge_model" => "graduated", + "invoice_display_name" => "Setup" + }, + "billable_metric" => { + "lago_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "name" => "Storage", + "code" => "storage", + "aggregation_type" => "sum_agg" + }, + "filters" => [ + { + "units" => "1.4", + "amount_cents" => 122, + "events_count" => 10, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + }, + { + "units" => "0.1", + "amount_cents" => 1, + "events_count" => 1, + "invoice_display_name" => "AWS eu-east-2", + "values" => { + "region" => "us-east-2" + } + } + ], + "grouped_usage" => [ + { + "amount_cents" => 101, + "events_count" => 6, + "units" => "1.1", + "grouped_by" => {"country" => nil}, + "filters" => [ + { + "units" => "1.0", + "amount_cents" => 100, + "events_count" => 5, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + }, + { + "units" => "0.1", + "amount_cents" => 1, + "events_count" => 1, + "invoice_display_name" => "AWS eu-east-2", + "values" => { + "region" => "us-east-2" + } + } + ] + }, + { + "amount_cents" => 22, + "events_count" => 5, + "units" => "0.4", + "grouped_by" => {"country" => "us"}, + "filters" => [ + { + "units" => "0.4", + "amount_cents" => 22, + "events_count" => 5, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + } + ] + } + ] + } + ] + } + end + + let(:previous_usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-01", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 100, + "taxes_amount_cents" => 15, + "total_amount_cents" => 115, + "charges_usage" => [ + { + "units" => "1.0", + "events_count" => 5, + "amount_cents" => 100, + "amount_currency" => "EUR", + "charge" => { + "lago_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "charge_model" => "graduated", + "invoice_display_name" => "Setup" + }, + "billable_metric" => { + "lago_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "name" => "Storage", + "code" => "storage", + "aggregation_type" => "sum_agg" + }, + "filters" => [ + { + "units" => "1.0", + "amount_cents" => 100, + "events_count" => 5, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + } + ], + "grouped_usage" => [ + { + "amount_cents" => 100, + "events_count" => 5, + "units" => "1.0", + "grouped_by" => {"country" => nil}, + "filters" => [ + { + "units" => "1.0", + "amount_cents" => 100, + "events_count" => 5, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + } + ] + } + ] + } + ] + } + end + + it "computes the diff between the two daily usages" do + result = diff_service.call + + expect(result).to be_success + expect(result.usage_diff).to eq( + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 23, + "taxes_amount_cents" => 5, + "total_amount_cents" => 28, + "charges_usage" => [ + { + "units" => "0.5", + "events_count" => 6, + "amount_cents" => 23, + "amount_currency" => "EUR", + "charge" => { + "lago_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "charge_model" => "graduated", + "invoice_display_name" => "Setup" + }, + "billable_metric" => { + "lago_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "name" => "Storage", + "code" => "storage", + "aggregation_type" => "sum_agg" + }, + "filters" => [ + { + "units" => "0.4", + "amount_cents" => 22, + "events_count" => 5, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + }, + { + "units" => "0.1", + "amount_cents" => 1, + "events_count" => 1, + "invoice_display_name" => "AWS eu-east-2", + "values" => { + "region" => "us-east-2" + } + } + ], + "grouped_usage" => [ + { + "amount_cents" => 1, + "events_count" => 1, + "units" => "0.1", + "grouped_by" => {"country" => nil}, + "filters" => [ + { + "units" => "0.0", + "amount_cents" => 0, + "events_count" => 0, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + }, + { + "units" => "0.1", + "amount_cents" => 1, + "events_count" => 1, + "invoice_display_name" => "AWS eu-east-2", + "values" => { + "region" => "us-east-2" + } + } + ] + }, + { + "amount_cents" => 22, + "events_count" => 5, + "units" => "0.4", + "grouped_by" => {"country" => "us"}, + "filters" => [ + { + "units" => "0.4", + "amount_cents" => 22, + "events_count" => 5, + "invoice_display_name" => "AWS eu-east-1", + "values" => { + "region" => "us-east-1" + } + } + ] + } + ] + } + ] + } + ) + end + + context "when a charge is deleted between snapshots" do + let(:charge_a_id) { "aaaa1111-1a90-1a90-1a90-1a901a901a90" } + let(:charge_c_id) { "cccc3333-1a90-1a90-1a90-1a901a901a90" } + + let(:usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 150, + "taxes_amount_cents" => 15, + "total_amount_cents" => 165, + "charges_usage" => [ + { + "units" => "1.5", + "events_count" => 8, + "amount_cents" => 150, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + let(:previous_usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-01", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 300, + "taxes_amount_cents" => 30, + "total_amount_cents" => 330, + "charges_usage" => [ + { + "units" => "1.0", + "events_count" => 5, + "amount_cents" => 100, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + }, + { + "units" => "2.0", + "events_count" => 10, + "amount_cents" => 200, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_c_id, "charge_model" => "standard", "invoice_display_name" => "Storage"}, + "billable_metric" => {"lago_id" => "bm-c", "name" => "Storage", "code" => "storage", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + it "derives top-level amounts from per-charge diffs, ignoring the deleted charge" do + result = diff_service.call + + expect(result).to be_success + + diff = result.usage_diff + + expect(diff["amount_cents"]).to eq(50) + expect(diff["taxes_amount_cents"]).to eq(5) + expect(diff["total_amount_cents"]).to eq(55) + + expect(diff["charges_usage"].size).to eq(1) + charge_a_diff = diff["charges_usage"].first + expect(charge_a_diff["charge"]["lago_id"]).to eq(charge_a_id) + expect(charge_a_diff["amount_cents"]).to eq(50) + expect(charge_a_diff["units"]).to eq("0.5") + expect(charge_a_diff["events_count"]).to eq(3) + end + end + + context "when a new charge is added between snapshots" do + let(:charge_a_id) { "aaaa1111-1a90-1a90-1a90-1a901a901a90" } + let(:charge_b_id) { "bbbb2222-1a90-1a90-1a90-1a901a901a90" } + + let(:usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 200, + "taxes_amount_cents" => 20, + "total_amount_cents" => 220, + "charges_usage" => [ + { + "units" => "1.5", + "events_count" => 8, + "amount_cents" => 150, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + }, + { + "units" => "0.5", + "events_count" => 3, + "amount_cents" => 50, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_b_id, "charge_model" => "standard", "invoice_display_name" => "Storage"}, + "billable_metric" => {"lago_id" => "bm-b", "name" => "Storage", "code" => "storage", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + let(:previous_usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-01", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 100, + "taxes_amount_cents" => 10, + "total_amount_cents" => 110, + "charges_usage" => [ + { + "units" => "1.0", + "events_count" => 5, + "amount_cents" => 100, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + it "includes the new charge's full amount in the diff" do + result = diff_service.call + + expect(result).to be_success + + diff = result.usage_diff + + expect(diff["amount_cents"]).to eq(100) + expect(diff["taxes_amount_cents"]).to eq(10) + expect(diff["total_amount_cents"]).to eq(110) + + expect(diff["charges_usage"].size).to eq(2) + + charge_a_diff = diff["charges_usage"].find { |cu| cu["charge"]["lago_id"] == charge_a_id } + expect(charge_a_diff["amount_cents"]).to eq(50) + expect(charge_a_diff["units"]).to eq("0.5") + expect(charge_a_diff["events_count"]).to eq(3) + + charge_b_diff = diff["charges_usage"].find { |cu| cu["charge"]["lago_id"] == charge_b_id } + expect(charge_b_diff["amount_cents"]).to eq(50) + expect(charge_b_diff["units"]).to eq("0.5") + expect(charge_b_diff["events_count"]).to eq(3) + end + end + + context "when all charges are replaced between snapshots" do + let(:charge_a_id) { "aaaa1111-1a90-1a90-1a90-1a901a901a90" } + let(:charge_b_id) { "bbbb2222-1a90-1a90-1a90-1a901a901a90" } + + let(:usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 80, + "taxes_amount_cents" => 8, + "total_amount_cents" => 88, + "charges_usage" => [ + { + "units" => "2.0", + "events_count" => 4, + "amount_cents" => 80, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_b_id, "charge_model" => "standard", "invoice_display_name" => "Storage"}, + "billable_metric" => {"lago_id" => "bm-b", "name" => "Storage", "code" => "storage", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + let(:previous_usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-01", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 100, + "taxes_amount_cents" => 10, + "total_amount_cents" => 110, + "charges_usage" => [ + { + "units" => "1.0", + "events_count" => 5, + "amount_cents" => 100, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + it "does not deduct any previous taxes since no charges overlap" do + result = diff_service.call + + diff = result.usage_diff + + expect(diff["amount_cents"]).to eq(80) + expect(diff["taxes_amount_cents"]).to eq(8) + expect(diff["total_amount_cents"]).to eq(88) + + charge_b_diff = diff["charges_usage"].first + expect(charge_b_diff["charge"]["lago_id"]).to eq(charge_b_id) + expect(charge_b_diff["amount_cents"]).to eq(80) + end + end + + context "when previous amount_cents is zero" do + let(:charge_a_id) { "aaaa1111-1a90-1a90-1a90-1a901a901a90" } + + let(:usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 50, + "taxes_amount_cents" => 5, + "total_amount_cents" => 55, + "charges_usage" => [ + { + "units" => "1.0", + "events_count" => 3, + "amount_cents" => 50, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + let(:previous_usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-01", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 0, + "taxes_amount_cents" => 0, + "total_amount_cents" => 0, + "charges_usage" => [ + { + "units" => "0.0", + "events_count" => 0, + "amount_cents" => 0, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + it "skips ratio calculation and deducts full previous taxes" do + result = diff_service.call + + diff = result.usage_diff + + expect(diff["amount_cents"]).to eq(50) + expect(diff["taxes_amount_cents"]).to eq(5) + expect(diff["total_amount_cents"]).to eq(55) + end + end + + context "when charges are both added and deleted between snapshots" do + let(:charge_a_id) { "aaaa1111-1a90-1a90-1a90-1a901a901a90" } + let(:charge_b_id) { "bbbb2222-1a90-1a90-1a90-1a901a901a90" } + let(:charge_c_id) { "cccc3333-1a90-1a90-1a90-1a901a901a90" } + + let(:usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 250, + "taxes_amount_cents" => 25, + "total_amount_cents" => 275, + "charges_usage" => [ + { + "units" => "2.0", + "events_count" => 8, + "amount_cents" => 200, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + }, + { + "units" => "1.0", + "events_count" => 2, + "amount_cents" => 50, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_c_id, "charge_model" => "standard", "invoice_display_name" => "Bandwidth"}, + "billable_metric" => {"lago_id" => "bm-c", "name" => "Bandwidth", "code" => "bandwidth", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + let(:previous_usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-01", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 300, + "taxes_amount_cents" => 30, + "total_amount_cents" => 330, + "charges_usage" => [ + { + "units" => "1.0", + "events_count" => 5, + "amount_cents" => 100, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_a_id, "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + }, + { + "units" => "3.0", + "events_count" => 10, + "amount_cents" => 200, + "amount_currency" => "EUR", + "charge" => {"lago_id" => charge_b_id, "charge_model" => "standard", "invoice_display_name" => "Storage"}, + "billable_metric" => {"lago_id" => "bm-b", "name" => "Storage", "code" => "storage", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + it "prorates taxes based on overlapping charge ratio and includes new charge fully" do + # Previous: A(100) + B(200) = 300, taxes = 30 + # Current: A(200) + C(50) = 250, taxes = 25 + # Only charge A overlaps: 100/300 = 1/3 of previous taxes = 10 + # Diff amount: A(200-100) + C(50) = 150 + # Diff taxes: 25 - 10 = 15 + result = diff_service.call + + diff = result.usage_diff + + expect(diff["amount_cents"]).to eq(150) + expect(diff["taxes_amount_cents"]).to eq(15) + expect(diff["total_amount_cents"]).to eq(165) + + expect(diff["charges_usage"].size).to eq(2) + + charge_a_diff = diff["charges_usage"].find { |cu| cu["charge"]["lago_id"] == charge_a_id } + expect(charge_a_diff["amount_cents"]).to eq(100) + expect(charge_a_diff["units"]).to eq("1.0") + expect(charge_a_diff["events_count"]).to eq(3) + + charge_c_diff = diff["charges_usage"].find { |cu| cu["charge"]["lago_id"] == charge_c_id } + expect(charge_c_diff["amount_cents"]).to eq(50) + expect(charge_c_diff["units"]).to eq("1.0") + expect(charge_c_diff["events_count"]).to eq(2) + end + end + + context "when previous_daily_usage is not provided" do + subject(:diff_service) { described_class.new(daily_usage:) } + + let(:subscription) { create(:subscription) } + let(:from_datetime) { Time.zone.parse("2022-07-01T00:00:00Z") } + let(:to_datetime) { Time.zone.parse("2022-07-31T23:59:59Z") } + + let(:daily_usage) do + create(:daily_usage, subscription:, from_datetime:, to_datetime:, usage_date: Date.new(2022, 7, 15), usage:) + end + + let(:usage) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-02", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 150, + "taxes_amount_cents" => 0, + "total_amount_cents" => 150, + "charges_usage" => [ + { + "units" => "1.5", + "events_count" => 8, + "amount_cents" => 150, + "amount_currency" => "EUR", + "charge" => {"lago_id" => "aaaa1111-1a90-1a90-1a90-1a901a901a90", "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + let(:previous_usage_data) do + { + "from_datetime" => "2022-07-01T00:00:00Z", + "to_datetime" => "2022-07-31T23:59:59Z", + "issuing_date" => "2022-08-01", + "lago_invoice_id" => "1a901a90-1a90-1a90-1a90-1a901a901a90", + "currency" => "EUR", + "amount_cents" => 100, + "taxes_amount_cents" => 0, + "total_amount_cents" => 100, + "charges_usage" => [ + { + "units" => "1.0", + "events_count" => 5, + "amount_cents" => 100, + "amount_currency" => "EUR", + "charge" => {"lago_id" => "aaaa1111-1a90-1a90-1a90-1a901a901a90", "charge_model" => "standard", "invoice_display_name" => "API Calls"}, + "billable_metric" => {"lago_id" => "bm-a", "name" => "API Calls", "code" => "api_calls", "aggregation_type" => "sum_agg"}, + "filters" => [], + "grouped_usage" => [] + } + ] + } + end + + before do + create( + :daily_usage, + subscription:, + from_datetime:, + to_datetime:, + usage_date: Date.new(2022, 7, 14), + usage: previous_usage_data + ) + end + + it "automatically finds the previous daily usage from the database" do + result = diff_service.call + + diff = result.usage_diff + + expect(diff["amount_cents"]).to eq(50) + expect(diff["charges_usage"].first["amount_cents"]).to eq(50) + expect(diff["charges_usage"].first["units"]).to eq("0.5") + expect(diff["charges_usage"].first["events_count"]).to eq(3) + end + end + + context "when the previous daily usage is nil" do + let(:previous_daily_usage) { nil } + + it "returns the current usage as diff" do + result = diff_service.call + + expect(result).to be_success + expect(result.usage_diff).to eq(usage) + end + end +end diff --git a/spec/services/daily_usages/compute_service_spec.rb b/spec/services/daily_usages/compute_service_spec.rb new file mode 100644 index 0000000..5042880 --- /dev/null +++ b/spec/services/daily_usages/compute_service_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::ComputeService do + subject(:compute_service) { described_class.new(subscription:, timestamp:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: properties) } + let(:plan) { create(:plan, organization:) } + let(:subscription) do + create(:subscription, :calendar, customer:, plan:, started_at: timestamp - 1.year, subscription_at: timestamp - 1.year) + end + let(:properties) do + { + amount: "100" + }.merge(presentation_group_keys) + end + let(:presentation_group_keys) { {} } + let(:timestamp) { Time.zone.parse("2024-10-22 00:05:00") } + let(:usage_date) { Date.parse("2024-10-21") } + + let(:event) do + create( + :event, + organization:, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: timestamp - 2.hours, + created_at: timestamp - 2.hours, + properties: { + "region" => "eu-central" + } + ) + end + + describe "#call" do + before { charge } + + context "when there is no usage" do + it "does not create a daily usage" do + travel_to(timestamp) do + expect { compute_service.call }.not_to change(DailyUsage, :count) + end + end + end + + context "when there is usage" do + before { event } + + context "when usage contains charges with no consumption due to filters" do + it "does not include fees with no consumption" do + billable_metric_filter = create(:billable_metric_filter, billable_metric:) + charge_filter = create(:charge_filter, charge:, properties: {amount: "10"}) + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: [billable_metric_filter.values.first]) + + travel_to(timestamp) do + expect { compute_service.call }.to change(DailyUsage, :count).by(1) + daily_usage = DailyUsage.order(created_at: :asc).last + expect(daily_usage.usage["charges_usage"].count).to eq(1) + end + end + end + + context "when the only consumed charge is free (zero amount)" do + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "0"}) } + + it "creates a daily usage based on consumed units" do + travel_to(timestamp) do + expect { compute_service.call }.to change(DailyUsage, :count).by(1) + + daily_usage = DailyUsage.order(created_at: :asc).last + expect(daily_usage.usage["charges_usage"].count).to eq(1) + expect(daily_usage.usage["amount_cents"]).to eq(0) + end + end + end + + it "creates a daily usage" do + travel_to(timestamp) do + expect { compute_service.call }.to change(DailyUsage, :count).by(1) + + daily_usage = DailyUsage.order(created_at: :asc).last + expect(daily_usage).to have_attributes( + organization_id: organization.id, + customer_id: customer.id, + subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + usage: Hash, + usage_diff: Hash, + usage_date: Date.parse("2024-10-21") + ) + expect(daily_usage.refreshed_at).to match_datetime(timestamp) + expect(daily_usage.from_datetime).to match_datetime(timestamp.beginning_of_month) + expect(daily_usage.to_datetime).to match_datetime(timestamp.end_of_month) + end + end + + context "when charges contains presentation group keys" do + let(:presentation_group_keys) do + {"presentation_group_keys" => [{"value" => "region"}]} + end + + it "creates a daily usage including presentation group keys" do + travel_to(timestamp) do + expect { compute_service.call }.to change(DailyUsage, :count).by(1) + + daily_usage = DailyUsage.order(created_at: :asc).last + expect(daily_usage.usage["charges_usage"].first["presentation_breakdowns"]).to eq([{"units" => "1.0", "presentation_by" => {"region" => "eu-central"}}]) + end + end + end + + context "when a daily usage already exists" do + let(:existing_daily_usage) do + create(:daily_usage, subscription:, organization:, customer:, usage_date:) + end + + before { existing_daily_usage } + + it "returns the existing daily usage" do + result = compute_service.call + + expect(result).to be_success + expect(result.daily_usage).to eq(existing_daily_usage) + end + + context "when the billing_entity has a timezone" do + let(:billing_entity) do + create(:billing_entity, organization:, timezone: "America/Sao_Paulo") + end + + let(:existing_daily_usage) do + create(:daily_usage, subscription:, organization:, customer:, usage_date: usage_date - 4.hours) + end + + it "takes the timezone into account" do + result = compute_service.call + + expect(result).to be_success + expect(result.daily_usage).to eq(existing_daily_usage) + end + end + + context "when the customer has a timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/Sao_Paulo") } + + let(:existing_daily_usage) do + create(:daily_usage, subscription:, organization:, customer:, usage_date: usage_date - 4.hours) + end + + it "takes the timezone into account" do + result = compute_service.call + + expect(result).to be_success + expect(result.daily_usage).to eq(existing_daily_usage) + end + end + end + + context "when timestamp is on subscription billing day" do + let(:subscription) do + create(:subscription, :anniversary, customer:, plan:, started_at: 1.year.ago, subscription_at: 1.year.ago) + end + + let(:timestamp) { subscription.subscription_at + 1.year } + + it "does not create a daily usage" do + expect { compute_service.call }.not_to change(DailyUsage, :count) + end + end + + context "when subscription is terminated after the timestamp" do + # Ensure that we terminate he subscription and ingest event at the same billing period + let(:termination_date) { Time.current.beginning_of_month - 2.days } + let(:subscription) do + create(:subscription, :terminated, :calendar, customer:, plan:, started_at: 1.year.ago, terminated_at: termination_date) + end + + let(:timestamp) { termination_date - 1.day } + + it "creates a daily usage" do + travel_to("2024-11-24") do + result = compute_service.call + + expect(result).to be_success + + daily_usage = result.daily_usage + expect(daily_usage).to have_attributes( + organization_id: organization.id, + customer_id: customer.id, + subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + usage: Hash, + usage_diff: Hash, + usage_date: timestamp.to_date - 1.day + ) + expect(daily_usage.refreshed_at).to match_datetime(timestamp) + expect(daily_usage.from_datetime).to match_datetime(timestamp.beginning_of_month) + expect(daily_usage.to_datetime).to match_datetime(subscription.terminated_at) + end + end + end + + context "with customer timezone" do + let(:customer) { create(:customer, organization:, timezone: "Australia/Sydney") } + let(:timestamp) { Time.zone.parse("2024-10-21 15:05:00") } + + it "creates a daily usage with expected usage_date" do + travel_to(timestamp) do + expect { compute_service.call }.to change(DailyUsage, :count).by(1) + + daily_usage = DailyUsage.order(created_at: :asc).last + # Timestamp is 22 Oct 2024 02:05:00 AEDT + expect(daily_usage.usage_date).to eq(Date.parse("2024-10-21")) + end + end + end + end + end +end diff --git a/spec/services/daily_usages/fill_from_invoice_service_spec.rb b/spec/services/daily_usages/fill_from_invoice_service_spec.rb new file mode 100644 index 0000000..9c34caf --- /dev/null +++ b/spec/services/daily_usages/fill_from_invoice_service_spec.rb @@ -0,0 +1,410 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::FillFromInvoiceService do + subject(:fill_service) { described_class.new(invoice:, subscriptions:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, timezone: "America/New_York") } + let(:subscription) { create(:subscription, customer:) } + let(:subscriptions) { [subscription] } + + let(:timestamp) { Time.parse("2025-07-01 04:10:02.000000000 UTC") } + + let(:invoice) do + create( + :invoice, + organization:, + issuing_date: Time.zone.at(timestamp).to_date, + customer: + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + timestamp:, + from_datetime: Time.parse("2025-06-06 04:00:00.000000000 +0000"), + to_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000"), + charges_from_datetime: Time.parse("2025-06-07 04:00:00.000000000 +0000"), + charges_to_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000") + ) + end + + let(:usage_date) do + invoice_subscription.charges_to_datetime.in_time_zone(invoice.customer.applicable_timezone).to_date + end + + before { invoice_subscription } + + describe "#call" do + context "when there is no usage" do + it "does not create a daily usage" do + travel_to(timestamp) do + expect { fill_service.call }.not_to change(DailyUsage, :count) + end + end + end + + # More context in spec/scenarios/daily_usages/yearly_plan_with_monthly_fixed_charges_spec.rb + context "when invoice_subscription has nil charges_from_datetime" do + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + timestamp:, + from_datetime: Time.parse("2025-06-06 04:00:00.000000000 +0000"), + to_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000"), + charges_from_datetime: nil, + charges_to_datetime: nil + ) + end + + before do + charge = create(:standard_charge, plan: subscription.plan) + create(:charge_fee, invoice:, charge:, units: 12, amount_cents: 1200, subscription:) + end + + it "skips the invoice_subscription" do + travel_to(timestamp) do + expect { fill_service.call }.not_to change(DailyUsage, :count) + end + end + end + + # TODO: investigate why this happens + context "when invoice_subscription has charges_from_datetime > charges_to_datetime" do + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + timestamp:, + from_datetime: Time.parse("2025-06-06 04:00:00.000000000 +0000"), + to_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000"), + charges_from_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000"), + charges_to_datetime: Time.parse("2025-06-07 04:00:00.000000000 +0000") + ) + end + + before do + charge = create(:standard_charge, plan: subscription.plan) + create(:charge_fee, invoice:, charge:, units: 12, amount_cents: 1200, subscription:) + end + + it "skips the invoice_subscription" do + travel_to(timestamp) do + expect { fill_service.call }.not_to change(DailyUsage, :count) + end + end + end + + context "when there is usage" do + before do + charge = create(:standard_charge, plan: subscription.plan) + create(:charge_fee, invoice:, charge:, units: 12, amount_cents: 1200, subscription:) + end + + it "creates daily usages for the subscriptions" do + travel_to(timestamp) do + expect { fill_service.call }.to change(DailyUsage, :count).by(1) + + daily_usage = subscription.daily_usages.order(:created_at).last + expect(daily_usage).to have_attributes( + organization:, + customer:, + subscription:, + external_subscription_id: subscription.external_id, + usage: Hash, + from_datetime: invoice_subscription.charges_from_datetime.change(usec: 0), + to_datetime: invoice_subscription.charges_to_datetime.change(usec: 0), + refreshed_at: invoice_subscription.timestamp, + usage_diff: Hash, + usage_date: + ) + end + end + + context "when invoice contains fees with 0 units" do + it "does not include those fees in the usage" do + charge = create(:standard_charge, plan: subscription.plan) + create(:charge_fee, invoice:, charge:, units: 0, amount_cents: 0, subscription:) + + travel_to(timestamp) do + expect { fill_service.call }.to change(DailyUsage, :count).by(1) + daily_usage = subscription.daily_usages.order(:created_at).last + expect(daily_usage.usage["charges_usage"].count).to eq(1) + end + end + end + + context "when the daily usage already exists" do + before do + create( + :daily_usage, + organization:, + customer:, + subscription:, + external_subscription_id: subscription.external_id, + from_datetime: invoice_subscription.charges_from_datetime.change(usec: 0), + to_datetime: invoice_subscription.charges_to_datetime.change(usec: 0), + refreshed_at: invoice_subscription.timestamp, + usage_date: + ) + end + + it "does not create a new daily usage" do + expect { fill_service.call }.not_to change(DailyUsage, :count) + end + end + + context "when multiples subscriptions are passed to the service" do + let(:subscription2) { create(:subscription, customer:) } + let(:subscriptions) { [subscription, subscription2] } + + let(:invoice_subscription2) do + create( + :invoice_subscription, + subscription: subscription2, + invoice:, + timestamp:, + from_datetime: Time.parse("2025-06-06 04:00:00.000000000 +0000"), + to_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000"), + charges_from_datetime: Time.parse("2025-06-07 04:00:00.000000000 +0000"), + charges_to_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000") + ) + end + + before { invoice_subscription2 } + + it "creates daily usages for all the subscriptions" do + expect { fill_service.call }.to change(DailyUsage, :count).by(2) + end + + context "when only one subscription has to be updated" do + let(:subscriptions) { [subscription] } + + it "creates daily usages for the subscriptions" do + expect { fill_service.call }.to change(DailyUsage, :count).by(1) + + daily_usage = subscription.daily_usages.order(:created_at).last + expect(daily_usage).to have_attributes( + organization:, + customer:, + subscription:, + external_subscription_id: subscription.external_id, + usage: Hash, + from_datetime: invoice_subscription.charges_from_datetime.change(usec: 0), + to_datetime: invoice_subscription.charges_to_datetime.change(usec: 0), + refreshed_at: invoice_subscription.timestamp, + usage_diff: Hash, + usage_date: + ) + end + end + end + end + end + + describe "#existing_daily_usage" do + context "when no daily usage exists" do + it "returns nil" do + result = fill_service.send(:existing_daily_usage, invoice_subscription) + expect(result).to be_nil + end + end + + context "when no matching daily usage exists" do + before do + create( + :daily_usage, + organization: invoice.organization, + customer: invoice.customer, + subscription: subscription, + external_subscription_id: subscription.external_id, + from_datetime: invoice_subscription.charges_from_datetime, + to_datetime: invoice_subscription.charges_to_datetime, + refreshed_at: invoice_subscription.timestamp, + usage_date: + ) + end + + it "returns nil" do + result = fill_service.send(:existing_daily_usage, invoice_subscription) + expect(result).to be_nil + end + end + + context "when a matching daily usage exists" do + let!(:existing_usage) do + create( + :daily_usage, + organization: invoice.organization, + customer: invoice.customer, + subscription: subscription, + external_subscription_id: subscription.external_id, + from_datetime: invoice_subscription.charges_from_datetime.change(usec: 0), + to_datetime: invoice_subscription.charges_to_datetime.change(usec: 0), + refreshed_at: invoice_subscription.timestamp, + usage_date: + ) + end + + it "returns the existing daily usage" do + result = fill_service.send(:existing_daily_usage, invoice_subscription) + expect(result).to eq(existing_usage) + end + end + end + + describe "#invoice_usage" do + subject(:usage) { fill_service.send(:invoice_usage, subscription, invoice_subscription) } + + let(:charge) { create(:standard_charge, plan: subscription.plan) } + + it "returns an OpenStruct with correct datetime attributes" do + expect(usage.from_datetime).to eq(invoice_subscription.charges_from_datetime.change(usec: 0)) + expect(usage.to_datetime).to eq(invoice_subscription.charges_to_datetime.change(usec: 0)) + end + + it "returns the issuing_date as an ISO8601 string" do + expect(usage.issuing_date).to eq(invoice.issuing_date.iso8601) + end + + context "when invoice contains fees that should be excluded" do + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + subscription:, + units: 10, + amount_cents: 1000, + taxes_amount_cents: 100 + ) + end + + let(:in_advance_fee) do + create( + :charge_fee, + subscription:, + pay_in_advance: true, + pay_in_advance_event_transaction_id: 1, + properties: { + "charges_from_datetime" => invoice_subscription.charges_from_datetime.iso8601(3), + "charges_to_datetime" => invoice_subscription.charges_to_datetime.iso8601(3) + } + ) + end + + before do + charge_fee + in_advance_fee + create(:fee, invoice:, subscription:) + end + + it "includes fees with positive units belonging to the subscription and in advance fees" do + result = fill_service.send(:invoice_usage, subscription, invoice_subscription) + + expect(result.fees.count).to eq(2) + expect(result.fees).to contain_exactly(charge_fee, in_advance_fee) + expect(result.total_amount_cents).to eq(1302) + end + end + end + + describe "#in_advance_fees" do + subject(:in_advance_fees) { fill_service.send(:in_advance_fees, subscription, invoice_subscription) } + + context "when invoice_subscription times have only seconds" do + let(:timestamp) { Time.zone.parse("2024-12-01T00:00:00") } + let(:end_timestamp) { Time.zone.parse("2024-12-31T23:59:59") } + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription: subscription, + invoice: invoice, + timestamp: timestamp, + from_datetime: timestamp, + to_datetime: end_timestamp, + charges_from_datetime: timestamp, + charges_to_datetime: end_timestamp + ) + end + + before do + create( + :charge_fee, + subscription: subscription, + pay_in_advance: true, + pay_in_advance_event_transaction_id: 1, + properties: { + "charges_from_datetime" => invoice_subscription.charges_from_datetime.iso8601, + "charges_to_datetime" => invoice_subscription.charges_to_datetime.iso8601 + } + ) + end + + it "returns the matching in-advance fee" do + fees = in_advance_fees.to_a + + expect(fees.count).to eq(1) + expect(fees.first.subscription_id).to eq(subscription.id) + end + end + + context "when invoice_subscription times have miliseconds" do + let(:timestamp) { Time.zone.parse("2024-12-01T00:00:00.000") } + let(:end_timestamp) { Time.zone.parse("2024-12-31T23:59:59.000") } + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription: subscription, + invoice: invoice, + timestamp: timestamp, + from_datetime: timestamp, + to_datetime: end_timestamp, + charges_from_datetime: timestamp, + charges_to_datetime: end_timestamp + ) + end + + before do + create( + :charge_fee, + subscription: subscription, + pay_in_advance: true, + pay_in_advance_event_transaction_id: 1, + properties: { + "charges_from_datetime" => invoice_subscription.charges_from_datetime.iso8601(3), + "charges_to_datetime" => invoice_subscription.charges_to_datetime.iso8601(3) + } + ) + end + + it "returns the matching in-advance fee" do + fees = in_advance_fees.to_a + + expect(fees.count).to eq(1) + expect(fees.first.subscription_id).to eq(subscription.id) + end + end + end + + describe "#usage_date" do + subject(:local_date) { fill_service.send(:usage_date, invoice_subscription) } + + it "returns the charges_to_datetime in the customer's timezone as a date" do + # It is still June 30th in America/New_York timezone + # even if charges_to_datetime: Time.parse("2025-07-01 03:59:59.999999000 +0000") + expect(local_date).to eq(Date.parse("2025-06-30")) + expect(local_date).not_to eq(Date.parse("2025-07-01")) + end + end +end diff --git a/spec/services/daily_usages/fill_history_service_spec.rb b/spec/services/daily_usages/fill_history_service_spec.rb new file mode 100644 index 0000000..3866c35 --- /dev/null +++ b/spec/services/daily_usages/fill_history_service_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DailyUsages::FillHistoryService do + let(:service) { described_class.new(subscription:, from_date:, to_date:) } + + describe "#call" do + subject(:call_service) { service.call } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:subscription_started_at) { Time.zone.parse("2024-10-01 00:00:00") } + let(:subscription) do + create( + :subscription, + :calendar, + customer:, + plan:, + started_at: subscription_started_at, + subscription_at: subscription_started_at + ) + end + let(:from_date) { Date.parse("2024-10-15") } + let(:to_date) { Date.parse("2024-10-15") } + + context "when the only consumed charge is free (zero amount)" do + before do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "0"}) + create( + :event, + organization:, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: Time.zone.parse("2024-10-15 10:00:00"), + created_at: Time.zone.parse("2024-10-15 10:00:00") + ) + end + + it "creates a daily usage based on consumed units" do + travel_to(Time.zone.parse("2024-10-16 12:00:00")) do + expect { call_service }.to change(DailyUsage, :count).by(1) + + daily_usage = DailyUsage.order(created_at: :asc).last + expect(daily_usage).to have_attributes( + organization_id: organization.id, + customer_id: customer.id, + subscription_id: subscription.id, + usage_date: Date.parse("2024-10-15") + ) + expect(daily_usage.usage["amount_cents"]).to eq(0) + expect(daily_usage.usage["charges_usage"].count).to eq(1) + end + end + end + + context "when there is no usage at all" do + before { create(:standard_charge, plan:, billable_metric:) } + + it "does not create a daily usage" do + travel_to(Time.zone.parse("2024-10-16 12:00:00")) do + expect { call_service }.not_to change(DailyUsage, :count) + end + end + end + + context "when an existing daily_usage covers a date in the middle of the range" do + let(:from_date) { Date.parse("2024-10-14") } + let(:to_date) { Date.parse("2024-10-16") } + let(:existing_daily_usage) do + create( + :daily_usage, + organization:, + customer:, + subscription:, + external_subscription_id: subscription.external_id, + usage_date: Date.parse("2024-10-15"), + from_datetime: Time.zone.parse("2024-10-01 00:00:00"), + to_datetime: Time.zone.parse("2024-10-31 23:59:59.999999"), + usage: {"amount_cents" => 0, "taxes_amount_cents" => 0, "total_amount_cents" => 0, "charges_usage" => []} + ) + end + + before do + create(:standard_charge, plan:, billable_metric:) + create( + :event, + organization:, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: Time.zone.parse("2024-10-14 10:00:00"), + created_at: Time.zone.parse("2024-10-14 10:00:00") + ) + existing_daily_usage + end + + it "uses the existing daily_usage as the baseline for the next iteration's diff" do + allow(DailyUsages::ComputeDiffService).to receive(:call).and_call_original + + travel_to(Time.zone.parse("2024-10-17 12:00:00")) { call_service } + + expect(DailyUsages::ComputeDiffService).to have_received(:call) + .with(hash_including(previous_daily_usage: existing_daily_usage)) + end + + it "does not overwrite the existing daily_usage" do + travel_to(Time.zone.parse("2024-10-17 12:00:00")) do + expect { call_service }.to change(DailyUsage, :count).by(2) + end + expect(DailyUsage.find_by(usage_date: Date.parse("2024-10-15"))).to eq(existing_daily_usage) + end + end + end + + describe "#to" do + subject(:to) { service.to } + + let(:subscription) { create(:subscription, started_at: Time.current - 1.month) } + let(:from_date) { Time.zone.today - 2.weeks } + let(:to_date) { nil } + + context "when subscription is terminated" do + before { Subscriptions::TerminateService.call(subscription:) } + + let(:to_date) { Time.zone.today + 1.week } + + it "returns the terminated_at date" do + expect(subject).to eq(subscription.terminated_at.to_date) + end + end + + context "when subscription is active" do + context "when to_date is provided" do + let(:to_date) { Time.zone.today + 1.week } + + it "returns the to_date date" do + expect(subject).to eq(to_date) + end + end + + context "when to_date is nil" do + it "returns yesterday" do + expect(subject).to eq(Time.zone.yesterday) + end + end + end + end +end diff --git a/spec/services/data_api/mrrs/plans_service_spec.rb b/spec/services/data_api/mrrs/plans_service_spec.rb new file mode 100644 index 0000000..55a90c4 --- /dev/null +++ b/spec/services/data_api/mrrs/plans_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::Mrrs::PlansService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/mrrs_plans.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/mrrs/#{organization.id}/plans/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected mrrs plans" do + expect(service_call).to be_success + expect(service_call.data_mrrs_plans["mrrs_plans"].count).to eq(4) + expect(service_call.data_mrrs_plans["mrrs_plans"].first).to eq( + { + "dt" => "2025-02-25", + "amount_currency" => "EUR", + "plan_id" => "8f550d3e-1234-4f4d-a752-61b0f98a9ef7", + "active_customers_count" => 1, + "mrr" => 1000000.0, + "mrr_share" => 0.0279, + "plan_name" => "Tondr", + "organization_id" => "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_code" => "custom_plan_tondr", + "plan_deleted_at" => nil, + "plan_interval" => "monthly", + "active_customers_share" => 0.009 + } + ) + expect(service_call.data_mrrs_plans["meta"]).to eq( + { + "current_page" => 1, + "next_page" => 2, + "prev_page" => 0, + "total_count" => 100, + "total_pages" => 5 + } + ) + end + end + end +end diff --git a/spec/services/data_api/mrrs_service_spec.rb b/spec/services/data_api/mrrs_service_spec.rb new file mode 100644 index 0000000..b198d55 --- /dev/null +++ b/spec/services/data_api/mrrs_service_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::MrrsService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/mrrs.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/mrrs/#{organization.id}/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected mrrs" do + expect(service_call).to be_success + expect(service_call.mrrs.count).to eq(4) + expect(service_call.mrrs.first).to eq( + { + "organization_id" => "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt" => "2023-11-01", + "end_of_period_dt" => "2023-11-30", + "amount_currency" => "EUR", + "starting_mrr" => 0, + "ending_mrr" => 23701746, + "mrr_new" => 25016546, + "mrr_expansion" => 0, + "mrr_contraction" => 0, + "mrr_churn" => -1314800, + "mrr_change" => 23701746 + } + ) + end + end + end +end diff --git a/spec/services/data_api/prepaid_credits_service_spec.rb b/spec/services/data_api/prepaid_credits_service_spec.rb new file mode 100644 index 0000000..2e9968c --- /dev/null +++ b/spec/services/data_api/prepaid_credits_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::PrepaidCreditsService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/prepaid_credits.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/prepaid_credits/#{organization.id}/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected prepaid credits" do + expect(service_call).to be_success + expect(service_call.prepaid_credits.count).to eq(3) + expect(service_call.prepaid_credits.first).to eq( + { + "organization_id" => "5e6eb312-1e25-40d7-83b8-4ee117b74255", + "start_of_period_dt" => "2023-12-01", + "end_of_period_dt" => "2023-12-31", + "amount_currency" => "EUR", + "purchased_amount" => 0.0, + "offered_amount" => 0.0, + "consumed_amount" => 120.45, + "voided_amount" => 0.0, + "purchased_credits_quantity" => 0.0, + "offered_credits_quantity" => 0.0, + "consumed_credits_quantity" => 120.45, + "voided_credits_quantity" => 0.0 + } + ) + end + end + end +end diff --git a/spec/services/data_api/revenue_streams/customers_service_spec.rb b/spec/services/data_api/revenue_streams/customers_service_spec.rb new file mode 100644 index 0000000..e652d71 --- /dev/null +++ b/spec/services/data_api/revenue_streams/customers_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::RevenueStreams::CustomersService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/revenue_streams_customers.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/revenue_streams/#{organization.id}/customers/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected revenue streams customers" do + expect(service_call).to be_success + expect(service_call.data_revenue_streams_customers["revenue_streams_customers"].count).to eq(4) + expect(service_call.data_revenue_streams_customers["revenue_streams_customers"].first).to eq( + { + "amount_currency" => "EUR", + "customer_deleted_at" => nil, + "customer_id" => "e4676e50-1234-4606-bcdb-42effbc2b635", + "customer_name" => "Penny", + "external_customer_id" => "2537afc4-1234-4abb-89b7-d9b28c35780b", + "gross_revenue_amount_cents" => 124628322, + "gross_revenue_share" => 0.1185, + "net_revenue_amount_cents" => 124628322, + "net_revenue_share" => 0.1185, + "organization_id" => "c0047031-41b6-4386-a10b-0a36f787c84f" + } + ) + expect(service_call.data_revenue_streams_customers["meta"]).to eq( + { + "current_page" => 1, + "next_page" => 2, + "prev_page" => 0, + "total_count" => 100, + "total_pages" => 5 + } + ) + end + end + end +end diff --git a/spec/services/data_api/revenue_streams/plans_service_spec.rb b/spec/services/data_api/revenue_streams/plans_service_spec.rb new file mode 100644 index 0000000..68a5416 --- /dev/null +++ b/spec/services/data_api/revenue_streams/plans_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::RevenueStreams::PlansService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/revenue_streams_plans.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/revenue_streams/#{organization.id}/plans/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected revenue streams plans" do + expect(service_call).to be_success + expect(service_call.data_revenue_streams_plans["revenue_streams_plans"].count).to eq(4) + expect(service_call.data_revenue_streams_plans["revenue_streams_plans"].first).to eq( + { + "plan_id" => "8d39f27f-8371-43ea-a327-c9579e70eeb3", + "amount_currency" => "EUR", + "plan_code" => "custom_plan_penny", + "plan_deleted_at" => nil, + "customers_count" => 1, + "gross_revenue_amount_cents" => 120735293, + "net_revenue_amount_cents" => 120735293, + "organization_id" => "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "plan_name" => "Penny", + "plan_interval" => "monthly", + "customers_share" => 0.0055, + "gross_revenue_share" => 0.1148, + "net_revenue_share" => 0.1148 + } + ) + expect(service_call.data_revenue_streams_plans["meta"]).to eq( + { + "current_page" => 1, + "next_page" => 2, + "prev_page" => 0, + "total_count" => 100, + "total_pages" => 5 + } + ) + end + end + end +end diff --git a/spec/services/data_api/revenue_streams_service_spec.rb b/spec/services/data_api/revenue_streams_service_spec.rb new file mode 100644 index 0000000..a18fbdc --- /dev/null +++ b/spec/services/data_api/revenue_streams_service_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::RevenueStreamsService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/revenue_streams.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/revenue_streams/#{organization.id}/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected revenue streams" do + expect(service_call).to be_success + expect(service_call.revenue_streams.count).to eq(12) + expect(service_call.revenue_streams.first).to eq( + { + "amount_currency" => "EUR", + "commitment_fee_amount_cents" => 0, + "coupons_amount_cents" => 0, + "end_of_period_dt" => "2024-01-31", + "gross_revenue_amount_cents" => 46256357, + "net_revenue_amount_cents" => 46256357, + "one_off_fee_amount_cents" => 0, + "organization_id" => "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt" => "2024-01-01", + "subscription_fee_amount_cents" => 25681455, + "usage_based_fee_amount_cents" => 20574902 + } + ) + end + end + end +end diff --git a/spec/services/data_api/usages/aggregated_amounts_service_spec.rb b/spec/services/data_api/usages/aggregated_amounts_service_spec.rb new file mode 100644 index 0000000..a3d4d48 --- /dev/null +++ b/spec/services/data_api/usages/aggregated_amounts_service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::Usages::AggregatedAmountsService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages_aggregated_amounts.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/aggregated_amounts/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected aggregated amounts usage" do + expect(service_call).to be_success + expect(service_call.aggregated_amounts_usages.count).to eq(3) + expect(service_call.aggregated_amounts_usages.first).to eq( + { + "start_of_period_dt" => "2024-01-01", + "end_of_period_dt" => "2024-01-31", + "amount_currency" => "EUR", + "amount_cents" => 26600 + } + ) + end + end + end +end diff --git a/spec/services/data_api/usages/forecasted_service_spec.rb b/spec/services/data_api/usages/forecasted_service_spec.rb new file mode 100644 index 0000000..c2e1914 --- /dev/null +++ b/spec/services/data_api/usages/forecasted_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::Usages::ForecastedService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages_forecasted.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/forecasted/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected forecasted usage" do + expect(service_call).to be_success + expect(service_call.forecasted_usages.count).to eq(1) + eq( + { + "start_of_period_dt" => "2025-06-27T06:46:28.300Z", + "end_of_period_dt" => "2025-06-28T06:46:28.300Z", + "amount_currency" => "EUR", + "units" => 100, + "amount_cents" => 1000, + "units_forecast_conservative" => 100, + "units_forecast_realistic" => 100, + "units_forecast_optimistic" => 100, + "amount_cents_forecast_conservative" => 1000, + "amount_cents_forecast_realistic" => 1000, + "amount_cents_forecast_optimistic" => 1000 + } + ) + end + end + end + + describe "#action_path" do + subject(:service_path) { service.send(:action_path) } + + it "returns the correct forecasted path" do + expect(service_path).to eq("usages/#{organization.id}/forecasted/") + end + end +end diff --git a/spec/services/data_api/usages/invoiced_service_spec.rb b/spec/services/data_api/usages/invoiced_service_spec.rb new file mode 100644 index 0000000..8852815 --- /dev/null +++ b/spec/services/data_api/usages/invoiced_service_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::Usages::InvoicedService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages_invoiced.json") } + let(:params) { {} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/invoiced/") + .to_return(status: 200, body: body_response, headers: {}) + end + + describe "#call" do + subject(:service_call) { service.call } + + context "when licence is not premium" do + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error.code).to eq("feature_unavailable") + end + end + + context "when licence is premium", :premium do + it "returns expected invoiced usage" do + expect(service_call).to be_success + expect(service_call.invoiced_usages.count).to eq(4) + expect(service_call.invoiced_usages.first).to eq( + { + "organization_id" => "2537afc4-0e7c-4abb-89b7-d9b28c35780b", + "start_of_period_dt" => "2024-01-01", + "end_of_period_dt" => "2024-01-31", + "billable_metric_code" => "account_members", + "amount_currency" => "EUR", + "amount_cents" => 26600 + } + ) + end + end + end +end diff --git a/spec/services/data_api/usages_service_spec.rb b/spec/services/data_api/usages_service_spec.rb new file mode 100644 index 0000000..2cc77db --- /dev/null +++ b/spec/services/data_api/usages_service_spec.rb @@ -0,0 +1,217 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataApi::UsagesService do + let(:service) { described_class.new(organization, **params) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:body_response) { File.read("spec/fixtures/lago_data_api/usages.json") } + let(:from_date) { Date.current - 60.days } + + let(:usage_json) do + { + "start_of_period_dt" => "2024-01-01", + "end_of_period_dt" => "2024-01-31", + "billable_metric_code" => "account_members", + "is_billable_metric_deleted" => false, + "amount_currency" => "EUR", + "amount_cents" => 26600, + "units" => 266 + } + end + + describe "#call" do + subject(:service_call) { service.call } + + let(:params) { {} } + + context "when licence is not premium" do + let(:query) { {time_granularity: "daily", start_of_period_dt: Date.current - 30.days} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/") + .with(query:) + .to_return(status: 200, body: body_response, headers: {}) + end + + context "when billable metric is not deleted" do + it "returns usages" do + expect(service_call).to be_success + expect(service_call.usages.count).to eq(3) + expect(service_call.usages.first).to eq(usage_json) + end + end + + context "when billable metric is deleted" do + before do + create(:billable_metric, :discarded, organization:, code: "account_members") + usage_json["is_billable_metric_deleted"] = true + end + + it "returns usages" do + expect(service_call).to be_success + expect(service_call.usages.count).to eq(3) + expect(service_call.usages.first).to eq(usage_json) + end + end + end + + context "when licence is premium", :premium do + let(:query) { {time_granularity: "daily"} } + + before do + stub_request(:get, "#{ENV["LAGO_DATA_API_URL"]}/usages/#{organization.id}/") + .with(query:) + .to_return(status: 200, body: body_response, headers: {}) + end + + context "when billable metric is not deleted" do + it "returns usages" do + expect(service_call).to be_success + expect(service_call.usages.count).to eq(3) + expect(service_call.usages.first).to eq(usage_json) + end + end + + context "when billable metric is deleted" do + before do + create(:billable_metric, :discarded, organization:, code: "account_members") + usage_json["is_billable_metric_deleted"] = true + end + + it "returns usages" do + expect(service_call).to be_success + expect(service_call.usages.count).to eq(3) + expect(service_call.usages.first).to eq(usage_json) + end + end + end + end + + describe "#filtered_params" do + subject(:filtered_params) { service.send(:filtered_params) } + + context "when licence is not premium" do + context "when additional params are provided" do + let(:params) do + { + billable_metric_code: "code", + time_granularity: "weekly", + from_date: Date.current - 60.days, + additional_param: "value" + } + end + + it "returns default params with daily granularity and 30 days back start date" do + expect(filtered_params).to eq( + time_granularity: "daily", + start_of_period_dt: Date.current - 30.days, + billable_metric_code: "code" + ) + end + end + + context "when no params are provided" do + let(:params) { {} } + + it "returns default params with daily granularity and 30 days back start date" do + expect(filtered_params).to eq( + time_granularity: "daily", + start_of_period_dt: Date.current - 30.days + ) + end + end + + context "when time_granularity is not provided" do + let(:params) { {start_of_period_dt: Date.current - 30.days} } + + it "adds default daily time granularity to params" do + expect(filtered_params).to eq( + time_granularity: "daily", + start_of_period_dt: Date.current - 30.days + ) + end + end + end + + context "when licence is premium", :premium do + let(:params) { {time_granularity: "monthly"} } + + it "returns params with time granularity preserved" do + expect(filtered_params).to eq(params) + end + + context "when additional params are provided" do + let(:params) { {time_granularity: "monthly", additional_param: "value", from_date:} } + + it "includes the additional params in the filtered params" do + expect(filtered_params).to eq( + time_granularity: "monthly", + from_date:, + additional_param: "value" + ) + end + end + + context "when time_granularity is not provided" do + let(:params) { {} } + + it "adds default daily time granularity to params" do + expect(filtered_params).to eq(time_granularity: "daily") + end + end + end + end + + describe "#action_path" do + subject(:action_path) { service.send(:action_path) } + + let(:params) { {} } + + it "returns the correct API path for the organization" do + expect(action_path).to eq("usages/#{organization.id}/") + end + end + + describe "#discarded_billable_metrics_codes" do + subject(:discarded_codes) { service.send(:discarded_billable_metrics_codes) } + + let(:params) { {} } + + context "when there are discarded billable metrics" do + before do + create(:billable_metric, organization: organization, code: "active_metric") + create(:billable_metric, :discarded, organization: organization, code: "deleted_metric") + end + + it "returns the codes of discarded billable metrics" do + expect(discarded_codes).to match_array(["deleted_metric"]) + end + end + + context "when there are no discarded billable metrics" do + before do + create(:billable_metric, organization: organization, code: "active_metric") + end + + it "returns an empty array" do + expect(discarded_codes).to be_empty + end + end + + context "when there are discarded metrics from other organizations" do + let(:other_organization) { create(:organization) } + + before do + create(:billable_metric, organization: organization, code: "active_metric") + create(:billable_metric, :discarded, organization: organization, code: "deleted_metric") + create(:billable_metric, :discarded, organization: other_organization, code: "other_org_deleted") + end + + it "only returns the codes of discarded billable metrics for the current organization" do + expect(discarded_codes).to contain_exactly("deleted_metric") + end + end + end +end diff --git a/spec/services/data_exports/combine_parts_service_spec.rb b/spec/services/data_exports/combine_parts_service_spec.rb new file mode 100644 index 0000000..35a36ea --- /dev/null +++ b/spec/services/data_exports/combine_parts_service_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::CombinePartsService do + subject(:result) { described_class.call(data_export:) } + + let(:data_export) { create :data_export, :processing, resource_type: "invoice_fees" } + let(:data_export_part) { create :data_export_part, data_export:, csv_lines:, index: 1 } + let(:csv_lines) do + <<~CSV + 292ef60b-9e0c-42e7-9f50-44d5af4162ec,TWI-2B86-170-001,2024-06-06,cc16e6d5-b5e1-4e2c-9ad3-62b3ee4be302,charge,group,group,charge 1 description,group,Converted to EUR,"{:models=>""model_1""}",ff6c279c-9f6c-4962-987e-270936d52310,all_charges,2024-05-08T00:00:00+00:00,2024-06-06T12:48:59+00:00,USD,100.0,10.0,50,10000 + CSV + end + + before do + data_export_part + end + + describe "#call" do + context "when there is only 1 part" do + it "adds the correct headers" do + expected_csv = <<~CSV + invoice_lago_id,invoice_number,invoice_issuing_date,fee_lago_id,fee_item_type,fee_item_code,fee_item_name,fee_item_description,fee_item_invoice_display_name,fee_item_filter_invoice_display_name,fee_item_grouped_by,subscription_external_id,subscription_plan_code,fee_from_date_utc,fee_to_date_utc,fee_amount_currency,fee_units,fee_precise_unit_amount,fee_taxes_amount_cents,fee_total_amount_cents + 292ef60b-9e0c-42e7-9f50-44d5af4162ec,TWI-2B86-170-001,2024-06-06,cc16e6d5-b5e1-4e2c-9ad3-62b3ee4be302,charge,group,group,charge 1 description,group,Converted to EUR,"{:models=>""model_1""}",ff6c279c-9f6c-4962-987e-270936d52310,all_charges,2024-05-08T00:00:00+00:00,2024-06-06T12:48:59+00:00,USD,100.0,10.0,50,10000 + CSV + + expect(result).to be_success + + # deal with encoding (using download would use 8-bit ASCII) + content = nil + data_export.file.open do |file| + content = File.read file + end + expect(content).to eq(expected_csv) + end + + it "marks the export as complete" do + expect(result.data_export).to be_completed + end + + it "sends an email" do + expect { result } + .to have_enqueued_mail(DataExportMailer, :completed) + .with(params: {data_export:}, args: []) + end + end + + context "when there are multiple parts" do + let(:data_export_part2) { create :data_export_part, data_export:, csv_lines: csv_lines2, index: 2 } + let(:csv_lines2) do + <<~CSV + 392ef60b-9e0c-42e7-9f50-44d5af4162ec,TWI-2B86-170-001,2024-06-06,cc16e6d5-b5e1-4e2c-9ad3-62b3ee4be302,charge,group,group,charge 1 description,group,Converted to EUR,"{:models=>""model_1""}",ff6c279c-9f6c-4962-987e-270936d52310,all_charges,2024-05-08T00:00:00+00:00,2024-06-06T12:48:59+00:00,USD,100.0,10.0,50,10000 + CSV + end + + before { data_export_part2 } + + it "combines the parts into 1 file in the right order" do + expected_csv = <<~CSV + invoice_lago_id,invoice_number,invoice_issuing_date,fee_lago_id,fee_item_type,fee_item_code,fee_item_name,fee_item_description,fee_item_invoice_display_name,fee_item_filter_invoice_display_name,fee_item_grouped_by,subscription_external_id,subscription_plan_code,fee_from_date_utc,fee_to_date_utc,fee_amount_currency,fee_units,fee_precise_unit_amount,fee_taxes_amount_cents,fee_total_amount_cents + 292ef60b-9e0c-42e7-9f50-44d5af4162ec,TWI-2B86-170-001,2024-06-06,cc16e6d5-b5e1-4e2c-9ad3-62b3ee4be302,charge,group,group,charge 1 description,group,Converted to EUR,"{:models=>""model_1""}",ff6c279c-9f6c-4962-987e-270936d52310,all_charges,2024-05-08T00:00:00+00:00,2024-06-06T12:48:59+00:00,USD,100.0,10.0,50,10000 + 392ef60b-9e0c-42e7-9f50-44d5af4162ec,TWI-2B86-170-001,2024-06-06,cc16e6d5-b5e1-4e2c-9ad3-62b3ee4be302,charge,group,group,charge 1 description,group,Converted to EUR,"{:models=>""model_1""}",ff6c279c-9f6c-4962-987e-270936d52310,all_charges,2024-05-08T00:00:00+00:00,2024-06-06T12:48:59+00:00,USD,100.0,10.0,50,10000 + CSV + + expect(result).to be_success + + content = nil + data_export.file.open do |file| + content = File.read file + end + expect(content).to eq(expected_csv) + end + end + end +end diff --git a/spec/services/data_exports/create_part_service_spec.rb b/spec/services/data_exports/create_part_service_spec.rb new file mode 100644 index 0000000..f58ea90 --- /dev/null +++ b/spec/services/data_exports/create_part_service_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::CreatePartService do + subject(:result) { described_class.call(data_export:, object_ids:, index:) } + + let(:data_export) { create :data_export, resource_type: "invoices", format: "csv" } + + let(:index) { 1 } + let(:object_ids) { [uuid] } + let(:uuid) { SecureRandom.uuid } + + it "creates 1 part" do + expect { result }.to change(DataExportPart, :count).by(1) + expect(result).to be_success + expect(result.data_export_part.index).to eq(index) + expect(result.data_export_part.object_ids).to eq(object_ids) + expect(data_export.reload.data_export_parts.sole).to eq(result.data_export_part) + end + + it "enqueues a job for this part" do + expect { result }.to have_enqueued_job(DataExports::ProcessPartJob).on_queue("default") + end +end diff --git a/spec/services/data_exports/create_service_spec.rb b/spec/services/data_exports/create_service_spec.rb new file mode 100644 index 0000000..836551d --- /dev/null +++ b/spec/services/data_exports/create_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::CreateService do + subject(:result) do + described_class.call(organization:, user:, format:, resource_type:, resource_query:) + end + + include_context "with mocked security logger" + + let(:organization) { create(:organization) } + let(:user) { create(:user) } + let(:membership) { create(:membership, user:, organization:) } + + let(:format) { "csv" } + let(:resource_type) { "invoices" } + let(:resource_query) do + { + "search_term" => "service 1", + "filters" => { + "currency" => "USD" + } + } + end + + before do + membership + allow(DataExports::ExportResourcesJob).to receive(:perform_later) + end + + it "creates a new data export record" do + expect(result).to be_success + + data_export = result.data_export + expect(data_export.id).to be_present + expect(data_export.organization_id).to eq(organization.id) + expect(data_export.membership_id).to eq(membership.id) + expect(data_export.format).to eq("csv") + expect(data_export.resource_type).to eq("invoices") + expect(data_export.resource_query).to match(resource_query) + expect(data_export.status).to eq("pending") + end + + it "calls ExportResourcesJob" do + data_export = result.data_export + + expect(DataExports::ExportResourcesJob) + .to have_received(:perform_later) + .with(data_export) + end + + it_behaves_like "produces a security log", "export.created" do + before { result } + end +end diff --git a/spec/services/data_exports/csv/credit_note_items_spec.rb b/spec/services/data_exports/csv/credit_note_items_spec.rb new file mode 100644 index 0000000..d76006a --- /dev/null +++ b/spec/services/data_exports/csv/credit_note_items_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::Csv::CreditNoteItems do + describe "#call" do + subject(:result) { described_class.new(data_export_part:).call } + + let(:data_export) { create(:data_export, resource_type: "credit_note_items") } + let(:credit_notes) { create_pair(:credit_note, :with_items) } + + let(:data_export_part) do + create(:data_export_part, data_export:, object_ids: credit_notes.pluck(:id)) + end + + let(:expected_rows) do + credit_notes.flat_map(&:items).map do |item| + [ + item.credit_note.id, + item.credit_note.number, + item.credit_note.invoice.number, + item.credit_note.issuing_date.iso8601, + item.id, + item.fee.id, + item.amount_currency, + item.amount_cents + ].map(&:to_s) + end + end + + before { create(:credit_note, :with_items) } + + after do + file = result.csv_file + file.close + File.unlink(file.path) + end + + it "adds serialized credit note items to csv" do + expect(result).to be_success + parsed_rows = CSV.parse(result.csv_file, nil_value: "") + expect(parsed_rows).to eq(expected_rows) + end + end +end diff --git a/spec/services/data_exports/csv/credit_notes_spec.rb b/spec/services/data_exports/csv/credit_notes_spec.rb new file mode 100644 index 0000000..9aba24a --- /dev/null +++ b/spec/services/data_exports/csv/credit_notes_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::Csv::CreditNotes do + describe "#call" do + subject(:result) { described_class.new(data_export_part:).call } + + let(:data_export) { create(:data_export, resource_type: "credit_notes", organization:) } + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:credit_notes) { create_pair(:credit_note, billing_entity:) } + + let(:data_export_part) do + create(:data_export_part, data_export:, object_ids: credit_notes.pluck(:id)) + end + + let(:expected_rows) do + credit_notes.map do |credit_note| + [ + credit_note.id, + credit_note.sequential_id, + credit_note.invoice.self_billed, + credit_note.issuing_date.iso8601, + credit_note.customer.id, + credit_note.customer.external_id, + credit_note.customer.name, + credit_note.customer.email, + credit_note.customer.country, + credit_note.customer.tax_identification_number, + credit_note.number, + credit_note.invoice.number, + credit_note.credit_status, + credit_note.refund_status, + credit_note.reason, + credit_note.description, + credit_note.currency, + credit_note.total_amount_cents, + credit_note.taxes_amount_cents, + credit_note.sub_total_excluding_taxes_amount_cents, + credit_note.coupons_adjustment_amount_cents, + credit_note.offset_amount_cents, + credit_note.credit_amount_cents, + credit_note.balance_amount_cents, + credit_note.refund_amount_cents, + credit_note.file_url + ].map(&:to_s) + end + end + + before { create(:credit_note) } + + after do + file = result.csv_file + file.close + File.unlink(file.path) + end + + it "adds serialized credit notes to csv" do + expect(result).to be_success + parsed_rows = CSV.parse(result.csv_file, nil_value: "") + expect(parsed_rows).to eq(expected_rows) + end + + context "when organization has multiple billing_entities" do + let(:billing_entity2) { create(:billing_entity, organization:) } + let(:credit_note) { create(:credit_note, billing_entity:) } + let(:data_export_part) do + create(:data_export_part, data_export:, object_ids: [credit_note.id]) + end + let(:expected_rows) do + [[ + credit_note.id, + credit_note.sequential_id, + credit_note.invoice.self_billed, + credit_note.issuing_date.iso8601, + credit_note.customer.id, + credit_note.customer.external_id, + credit_note.customer.name, + credit_note.customer.email, + credit_note.customer.country, + credit_note.customer.tax_identification_number, + credit_note.number, + credit_note.invoice.number, + credit_note.credit_status, + credit_note.refund_status, + credit_note.reason, + credit_note.description, + credit_note.currency, + credit_note.total_amount_cents, + credit_note.taxes_amount_cents, + credit_note.sub_total_excluding_taxes_amount_cents, + credit_note.coupons_adjustment_amount_cents, + credit_note.offset_amount_cents, + credit_note.credit_amount_cents, + credit_note.balance_amount_cents, + credit_note.refund_amount_cents, + credit_note.file_url, + credit_note.billing_entity.code + ].map(&:to_s)] + end + + before do + billing_entity2 + end + + it "adds serialized credit notes to csv" do + expect(result).to be_success + parsed_rows = CSV.parse(result.csv_file, nil_value: "") + expect(parsed_rows).to eq(expected_rows) + end + end + end +end diff --git a/spec/services/data_exports/csv/invoice_fees_spec.rb b/spec/services/data_exports/csv/invoice_fees_spec.rb new file mode 100644 index 0000000..3d2ed32 --- /dev/null +++ b/spec/services/data_exports/csv/invoice_fees_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::Csv::InvoiceFees do + let(:data_export) do + create :data_export, :processing, resource_type: "invoice_fees", resource_query: + end + + let(:data_export_part) do + data_export.data_export_parts.create(index: 1, object_ids: [invoice.id], organization_id: data_export.organization_id) + end + + let(:resource_query) do + { + currency:, + customer_id:, + customer_external_id:, + invoice_type:, + issuing_date_from:, + issuing_date_to:, + payment_dispute_lost:, + payment_overdue:, + payment_status:, + search_term:, + status: + } + end + + let(:currency) { "EUR" } + let(:customer_external_id) { "custext123" } + let(:customer_id) { "customer-lago-id-123" } + let(:invoice_type) { "credit" } + let(:issuing_date_from) { "2023-12-25" } + let(:issuing_date_to) { "2024-07-01" } + let(:payment_dispute_lost) { false } + let(:payment_overdue) { true } + let(:payment_status) { "pending" } + let(:search_term) { "service ABC" } + let(:status) { "finalized" } + + let(:invoice_serializer_klass) { class_double("V1::InvoiceSerializer") } + let(:fee_serializer_klass) { class_double("V1::FeeSerializer") } + let(:subscription_serializer_klass) { class_double("V1::SubscriptionSerializer") } + + let(:invoice_serializer) do + instance_double("V1::InvoiceSerializer", serialize: serialized_invoice) + end + + let(:fee_serializer) do + instance_double("V1::FeeSerializer", serialize: serialized_fee) + end + + let(:invoice) { create :invoice } + let(:fee) { create :fee, invoice: } + + let(:serialized_invoice) do + { + lago_id: "292ef60b-9e0c-42e7-9f50-44d5af4162ec", + number: "TWI-2B86-170-001", + issuing_date: "2024-06-06" + } + end + + let(:serialized_fee) do + { + lago_id: "cc16e6d5-b5e1-4e2c-9ad3-62b3ee4be302", + item: { + type: "charge", + code: "group", + name: "group", + description: "charge 1 description", + invoice_display_name: "group", + filter_invoice_display_name: "Converted to EUR", + grouped_by: {models: "model_1"} + }, + taxes_amount_cents: 50, + total_amount_cents: 10000, + total_amount_currency: "USD", + units: "100.0", + precise_unit_amount: "10.0", + from_date: "2024-05-08T00:00:00+00:00", + to_date: "2024-06-06T12:48:59+00:00" + } + end + + before do + invoice + fee + + allow(invoice_serializer_klass) + .to receive(:new) + .and_return(invoice_serializer) + + allow(fee_serializer_klass) + .to receive(:new) + .and_return(fee_serializer) + end + + describe "#call" do + subject(:result) do + described_class.new( + data_export_part:, + invoice_serializer_klass:, + fee_serializer_klass: + ).call + end + + it "generates the correct CSV output" do + expected_csv = <<~CSV + 292ef60b-9e0c-42e7-9f50-44d5af4162ec,TWI-2B86-170-001,2024-06-06,cc16e6d5-b5e1-4e2c-9ad3-62b3ee4be302,charge,group,group,charge 1 description,group,Converted to EUR,"{models: ""model_1""}",#{fee.subscription.external_id},#{fee.subscription.plan.code},2024-05-08T00:00:00+00:00,2024-06-06T12:48:59+00:00,USD,100.0,10.0,50,10000 + CSV + + expect(result).to be_success + + file = result.csv_file + generated_csv = file.read + + file.close + File.unlink(file.path) + + expect(generated_csv).to eq(expected_csv) + end + end +end diff --git a/spec/services/data_exports/csv/invoices_spec.rb b/spec/services/data_exports/csv/invoices_spec.rb new file mode 100644 index 0000000..a3b747b --- /dev/null +++ b/spec/services/data_exports/csv/invoices_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::Csv::Invoices, :premium do + let(:data_export) { create :data_export, :processing, resource_query:, organization: } + + let(:data_export_part) { data_export.data_export_parts.create(object_ids: [invoice.id], index: 1, organization:) } + + let(:resource_query) do + { + currency:, + customer_external_id:, + customer_id:, + invoice_type:, + issuing_date_from:, + issuing_date_to:, + payment_dispute_lost:, + payment_overdue:, + payment_status:, + search_term:, + self_billed:, + status: + } + end + + let(:currency) { "EUR" } + let(:customer_id) { "customer-lago-id-123" } + let(:customer_external_id) { "custext123" } + let(:invoice_type) { "credit" } + let(:issuing_date_from) { "2023-12-25" } + let(:issuing_date_to) { "2024-07-01" } + let(:payment_dispute_lost) { false } + let(:payment_overdue) { true } + let(:payment_status) { "pending" } + let(:search_term) { "service ABC" } + let(:self_billed) { false } + let(:status) { "finalized" } + + let(:serializer_klass) { class_double("V1::InvoiceSerializer") } + let(:invoice_serializer) do + instance_double("V1::InvoiceSerializer", serialize: serialized_invoice) + end + let(:organization) { create(:organization, premium_integrations: ["progressive_billing"]) } + let(:invoice) { create :invoice, organization: } + let(:serialized_invoice) do + { + lago_id: "invoice-lago-id-123", + sequential_id: "SEQ123", + issuing_date: "2023-01-01", + self_billed: false, + customer: { + name: "customer name", + email: "customer@eamil.com", + lago_id: "customer-lago-id-456", + external_id: "CUST123", + country: "US", + tax_identification_number: "123456789" + }, + number: "INV123", + invoice_type: "credit", + payment_status: "pending", + status: "finalized", + file_url: "http://api.lago.com/invoice.pdf", + currency: "USD", + fees_amount_cents: 70000, + coupons_amount_cents: 1655, + taxes_amount_cents: 10500, + credit_notes_amount_cents: 334, + prepaid_credit_amount_cents: 1000, + total_amount_cents: 77511, + payment_due_date: "2023-02-01", + payment_dispute_lost_at: "2023-12-22", + payment_overdue: false, + total_due_amount_cents: 27511, + total_paid_amount_cents: 50000, + total_offsetted_credit_note_amount_cents: 334, + progressive_billing_credit_amount_cents: 999, + billing_entity_code: "the-test-bil-ent" + } + end + + before do + invoice + create(:credit_note, offset_amount_cents: 334, invoice:) + + allow(serializer_klass) + .to receive(:new) + .and_return(invoice_serializer) + end + + describe "#call" do + subject(:result) do + described_class.new(data_export_part:, serializer_klass:).call + end + + it "generates the correct CSV output" do + expected_csv = <<~CSV + invoice-lago-id-123,SEQ123,false,2023-01-01,customer-lago-id-456,CUST123,customer name,customer@eamil.com,US,123456789,INV123,credit,pending,finalized,http://api.lago.com/invoice.pdf,USD,70000,1655,10500,334,1000,77511,2023-02-01,2023-12-22,false,27511,50000,334,999 + CSV + + expect(result).to be_success + file = result.csv_file + generated_csv = file.read + + file.close + File.unlink(file.path) + expect(generated_csv).to eq(expected_csv) + end + + context "when organization has multiple billing_entities" do + let(:billing_entity) { create(:billing_entity, organization:) } + + before { billing_entity } + + it "adds billing_entity_code to the csv" do + expected_csv = <<~CSV + invoice-lago-id-123,SEQ123,false,2023-01-01,customer-lago-id-456,CUST123,customer name,customer@eamil.com,US,123456789,INV123,credit,pending,finalized,http://api.lago.com/invoice.pdf,USD,70000,1655,10500,334,1000,77511,2023-02-01,2023-12-22,false,27511,50000,334,999,the-test-bil-ent + CSV + + expect(result).to be_success + file = result.csv_file + generated_csv = file.read + + file.close + File.unlink(file.path) + expect(generated_csv).to eq(expected_csv) + end + end + end + + describe "offset amount preloading" do + let(:invoice1) { create(:invoice, organization:) } + let(:invoice2) { create(:invoice, organization:) } + let(:data_export_part) do + data_export.data_export_parts.create( + object_ids: [invoice1.id, invoice2.id], + index: 1, + organization: + ) + end + + before do + create(:credit_note, invoice: invoice1, offset_amount_cents: 100) + create(:credit_note, invoice: invoice2, offset_amount_cents: 200) + end + + it "uses preloaded offset amounts without additional queries during CSV generation" do + service = described_class.new(data_export_part:) + + # Preloading should result in only ONE query to credit_notes (the GROUP BY SUM query) + query_count = 0 + counter = ->(_name, _start, _finish, _id, payload) { + query_count += 1 if /SELECT SUM.*offset_amount_cents.*FROM.*credit_notes/i.match?(payload[:sql]) + } + + result = nil + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + result = service.call + expect(result).to be_success + end + + expect(query_count).to eq(1), "Expected single query to credit_notes table, but got #{query_count}" + + result.csv_file.close + File.unlink(result.csv_file.path) + end + end +end diff --git a/spec/services/data_exports/export_resources_service_spec.rb b/spec/services/data_exports/export_resources_service_spec.rb new file mode 100644 index 0000000..45009de --- /dev/null +++ b/spec/services/data_exports/export_resources_service_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::ExportResourcesService do + subject(:result) { described_class.call(data_export:, batch_size:) } + + let(:organization) { data_export.organization } + let(:batch_size) { 100 } + let(:data_export) { create :data_export, resource_type: "invoices", format: "csv" } + + let(:issuing_date) { Date.new(2023, 12, 1) } + + let(:invoice) { create(:invoice, organization:, issuing_date:) } + + before do + invoice + end + + describe "#call" do + it "updates the data export status to processing" do + allow(data_export).to receive(:processing!) + + result + expect(data_export).to have_received(:processing!) + end + + it "splits up the data export into parts" do + result + expect(data_export.data_export_parts).not_to be_empty + # only 1 export part should be create + part = data_export.data_export_parts.sole + expect(part.object_ids).to eq([invoice.id]) + end + + context "when there are many invoices" do + # small batch size for easier testing + let(:batch_size) { 2 } + let(:invoice2) { create(:invoice, organization:, issuing_date: issuing_date + 1.day) } + let(:invoice3) { create(:invoice, organization:, issuing_date: issuing_date + 2.days) } + let(:invoice4) { create(:invoice, organization:, issuing_date: issuing_date + 3.days) } + let(:invoice5) { create(:invoice, organization:, issuing_date: issuing_date + 4.days) } + + before do + invoice2 + invoice3 + invoice4 + invoice5 + end + + it "splits up into many parts" do + result + + expect(data_export.data_export_parts.size).to eq(3) + part1 = data_export.data_export_parts.find_by index: 0 + part2 = data_export.data_export_parts.find_by index: 1 + part3 = data_export.data_export_parts.find_by index: 2 + expect(part1.object_ids).to eq([invoice5.id, invoice4.id]) + expect(part2.object_ids).to eq([invoice3.id, invoice2.id]) + expect(part3.object_ids).to eq([invoice.id]) + end + end + + it "returns the data export result" do + expect(result).to be_success + + expect(result.data_export).to be_processing + expect(result.data_export.file).not_to be_present + end + + context "when the data export is expired" do + let(:data_export) { create(:data_export, expires_at: 1.hour.ago) } + + it "returns a service failure result" do + expect(result).not_to be_success + expect(result.error.code).to eq("data_export_expired") + end + end + + context "when the data export is already processed" do + let(:data_export) { create(:data_export, :processing) } + + it "returns a service failure result" do + expect(result).not_to be_success + expect(result.error.code).to eq("data_export_processed") + end + end + + context "when an error occurs during processing" do + before do + allow(data_export) + .to receive(:transaction) + .and_raise(StandardError.new("error_message")) + end + + it "returns a service failure result" do + expect(result).not_to be_success + expect(result.error.code).to eq("error_message") + expect(data_export).to be_failed + end + end + + context "when resource type is credit_notes with types filter" do + let(:data_export) do + create(:data_export, resource_type: "credit_notes", format: "csv", resource_query: {"types" => ["credit"]}) + end + + let!(:credit_note_credit) do + create(:credit_note, organization:, credit_amount_cents: 100, refund_amount_cents: 0, offset_amount_cents: 0) + end + + let!(:credit_note_refund) do + create(:credit_note, organization:, credit_amount_cents: 0, refund_amount_cents: 100, offset_amount_cents: 0) + end + + it "only exports credit notes matching the types filter" do + expect(result).to be_success + + part = data_export.data_export_parts.sole + expect(part.object_ids).to include(credit_note_credit.id) + expect(part.object_ids).not_to include(credit_note_refund.id) + end + end + + context "when resource type is not supported" do + let(:data_export) { create :data_export, resource_type: "unknown" } + + it "returns a service failure result" do + expect(result).not_to be_success + expect(result.error.code).to eq( + "'unknown' resource not supported" + ) + expect(data_export).to be_failed + end + end + end +end diff --git a/spec/services/data_exports/process_part_service_spec.rb b/spec/services/data_exports/process_part_service_spec.rb new file mode 100644 index 0000000..2bad826 --- /dev/null +++ b/spec/services/data_exports/process_part_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DataExports::ProcessPartService do + subject(:result) { described_class.call(data_export_part:) } + + let(:data_export) { create :data_export, resource_type: "invoices", format: "csv" } + let(:data_export_part) { create :data_export_part, data_export:, object_ids: [invoice.id] } + let(:invoice) { create :invoice } + let(:serialized_invoice) do + { + lago_id: "invoice-lago-id-123", + sequential_id: "SEQ123", + issuing_date: "2023-01-01", + self_billed: false, + customer: { + name: "customer name", + email: "customer@eamil.com", + lago_id: "customer-lago-id-456", + external_id: "CUST123", + country: "US", + tax_identification_number: "123456789" + }, + number: "INV123", + invoice_type: "credit", + payment_status: "pending", + status: "finalized", + file_url: "http://api.lago.com/invoice.pdf", + currency: "USD", + fees_amount_cents: 70000, + coupons_amount_cents: 1655, + taxes_amount_cents: 10500, + credit_notes_amount_cents: 334, + prepaid_credit_amount_cents: 1000, + total_amount_cents: 77511, + payment_due_date: "2023-02-01", + payment_dispute_lost_at: "2023-12-22", + payment_overdue: false, + total_due_amount_cents: 27511, + total_paid_amount_cents: 50000, + total_offsetted_credit_note_amount_cents: 334 + } + end + let(:invoice_serializer) do + instance_double("V1::InvoiceSerializer", serialize: serialized_invoice) + end + + before do + create(:credit_note, offset_amount_cents: 334, invoice:) + allow(V1::InvoiceSerializer) + .to receive(:new) + .and_return(invoice_serializer) + end + + describe "#call" do + it "processes the part" do + expected_csv = <<~CSV + invoice-lago-id-123,SEQ123,false,2023-01-01,customer-lago-id-456,CUST123,customer name,customer@eamil.com,US,123456789,INV123,credit,pending,finalized,http://api.lago.com/invoice.pdf,USD,70000,1655,10500,334,1000,77511,2023-02-01,2023-12-22,false,27511,50000,334 + CSV + expect(result).to be_success + expect(result.data_export_part.csv_lines).to eq(expected_csv) + end + + it "enqueues a job when the last part is completed" do + expect { result }.to have_enqueued_job(DataExports::CombinePartsJob).with(data_export_part.data_export) + end + end + + context "when other parts have not been complete" do + let(:other_part) { create :data_export_part, data_export:, object_ids: [invoice.id], index: 2 } + + before { other_part } + + it "does not enqueue a job" do + expect { result }.not_to have_enqueued_job(DataExports::CombinePartsJob).with(data_export_part.data_export) + end + end +end diff --git a/spec/services/dunning_campaigns/bulk_process_service_spec.rb b/spec/services/dunning_campaigns/bulk_process_service_spec.rb new file mode 100644 index 0000000..0ccfe17 --- /dev/null +++ b/spec/services/dunning_campaigns/bulk_process_service_spec.rb @@ -0,0 +1,558 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::BulkProcessService do + subject(:result) { described_class.call } + + let(:currency) { "EUR" } + + context "when premium features are enabled", :premium do + let(:organization) { create :organization, premium_integrations: %w[auto_dunning] } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create :customer, organization:, billing_entity:, currency: } + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + currency:, + payment_overdue: true, + total_amount_cents: 50_00 + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + currency:, + payment_overdue: true, + total_amount_cents: 1_00 + ) + end + + context "when billing_entity has an applied dunning campaign" do + let(:dunning_campaign) { create :dunning_campaign, organization: } + + let(:dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign:, + currency:, + amount_cents: 50_99 + ) + end + + before do + dunning_campaign + dunning_campaign_threshold + billing_entity.update!(applied_dunning_campaign: dunning_campaign) + end + + context "when a customer has overdue balance exceeding threshold in same currency" do + before do + invoice_1 + invoice_2 + end + + it "enqueues an ProcessAttemptJob with the customer and threshold" do + expect(result).to be_success + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued + .with(customer:, dunning_campaign_threshold:) + end + + context "when organization does not have auto_dunning feature enabled" do + let(:organization) { create(:organization, premium_integrations: []) } + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when maximum attempts are reached" do + let(:customer) { create :customer, organization:, billing_entity:, last_dunning_campaign_attempt: 5 } + + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + max_attempts: 5 + ) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when not enough days have passed since last attempt" do + let(:customer) { create :customer, organization:, billing_entity:, last_dunning_campaign_attempt_at: 3.days.ago } + + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + days_between_attempts: 4 + ) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when enough days have passed since last attempt" do + let(:customer) { create :customer, organization:, billing_entity:, last_dunning_campaign_attempt_at: 4.days.ago - 1.second } + + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + days_between_attempts: 4 + ) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "enqueues an ProcessAttemptJob with the customer and threshold" do + expect(result).to be_success + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued + .with(customer:, dunning_campaign_threshold:) + end + end + end + + context "when customer has overdue balance below threshold" do + before do + invoice_1 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when there is no matching threshold for customer overdue balance" do + let(:dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign:, + currency: "GBP", + amount_cents: 1 + ) + end + + before do + invoice_1 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when customer has an applied dunning campaign overwriting billing entity's default campaign" do + let(:customer) do + create( + :customer, + organization:, + billing_entity:, + currency:, + applied_dunning_campaign: customer_dunning_campaign + ) + end + + let(:customer_dunning_campaign) do + create(:dunning_campaign, organization:) + end + + let(:customer_dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign: customer_dunning_campaign, + currency:, + amount_cents: 49_99 + ) + end + + before do + customer_dunning_campaign + customer_dunning_campaign_threshold + end + + context "when a customer has overdue balance exceeding threshold in same currency" do + before do + invoice_1 + end + + it "enqueues an ProcessAttemptJob with the customer and customer's campaign threshold" do + expect(result).to be_success + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued + .with(customer:, dunning_campaign_threshold: customer_dunning_campaign_threshold) + end + end + + context "when customer has overdue balance below threshold" do + before do + invoice_2 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when there is no matching threshold for customer overdue balance" do + let(:customer_dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign:, + currency: "GBP", + amount_cents: 1 + ) + end + + before do + invoice_1 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + end + + context "when customer is excluded from dunning campaigns" do + let(:customer) { create :customer, organization:, billing_entity:, currency:, exclude_from_dunning_campaign: true } + + context "when a customer has overdue balance exceeding threshold in same currency" do + before do + invoice_1 + invoice_2 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + end + + context "when customer has no overdue invoices" do + let(:customer_without_overdue) { create :customer, organization:, billing_entity:, currency: } + + before do + create(:invoice, organization:, customer: customer_without_overdue, currency:, payment_overdue: false, total_amount_cents: 100_00) + create(:invoice, organization:, customer:, currency:, payment_overdue: true, total_amount_cents: 51_00) + end + + it "excludes customer without overdue invoices from eligible customers" do + eligible = described_class.new.send(:eligible_customers) + + expect(eligible).to include(customer) + expect(eligible).not_to include(customer_without_overdue) + end + end + end + + context "when customer has an applied dunning campaign" do + let(:customer) do + create( + :customer, + organization:, + billing_entity:, + currency:, + applied_dunning_campaign: dunning_campaign + ) + end + + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + let(:dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign:, + currency:, + amount_cents: 49_99 + ) + end + + before do + dunning_campaign + dunning_campaign_threshold + end + + context "when a customer has overdue balance exceeding threshold in same currency" do + before do + invoice_1 + end + + it "enqueues an ProcessAttemptJob with the customer and customer's campaign threshold" do + expect(result).to be_success + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued + .with(customer:, dunning_campaign_threshold:) + end + + context "when organization does not have auto_dunning feature enabled" do + let(:organization) { create(:organization, premium_integrations: []) } + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when maximum attempts are reached" do + let(:customer) { create :customer, organization:, billing_entity:, last_dunning_campaign_attempt: 5 } + + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + max_attempts: 5 + ) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when not enough days have passed since last attempt" do + let(:customer) { create :customer, organization:, billing_entity:, last_dunning_campaign_attempt_at: 3.days.ago } + + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + days_between_attempts: 4 + ) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when enough days have passed since last attempt" do + let(:customer) { create :customer, organization:, billing_entity:, last_dunning_campaign_attempt_at: 4.days.ago - 1.second } + + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + days_between_attempts: 4 + ) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "enqueues an ProcessAttemptJob with the customer and threshold" do + expect(result).to be_success + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued + .with(customer:, dunning_campaign_threshold:) + end + end + end + + context "when customer has overdue balance below threshold" do + before do + invoice_2 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when there is no matching threshold for customer overdue balance" do + let(:dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign:, + currency: "GBP", + amount_cents: 1 + ) + end + + before do + invoice_1 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + end + + context "when neither billing_entity nor customer has an applied dunning campaign" do + let(:dunning_campaign) { create :dunning_campaign, organization: } + + let(:dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign:, + currency:, + amount_cents: 1 + ) + end + + before do + dunning_campaign_threshold + invoice_1 + end + + it "does not queue a job for the customer" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when organization has multiple billing entities with different applied dunning campaigns" do + let(:billing_entity_1) { create :billing_entity, organization:, applied_dunning_campaign: dunning_campaign_1 } + let(:billing_entity_2) { create :billing_entity, organization:, applied_dunning_campaign: dunning_campaign_2 } + let(:customer_1) { create :customer, organization:, billing_entity: billing_entity_1, currency: } + let(:customer_2) { create :customer, organization:, billing_entity: billing_entity_2, currency: } + let(:customer_3) { create :customer, organization:, billing_entity: billing_entity, currency:, applied_dunning_campaign: dunning_campaign_1 } + + let(:dunning_campaign_1) { create :dunning_campaign, organization: } + let(:dunning_campaign_2) { create :dunning_campaign, organization: } + + let(:dunning_campaign_threshold_1) do + create( + :dunning_campaign_threshold, + dunning_campaign: dunning_campaign_1, + currency:, + amount_cents: 50_99 + ) + end + + let(:dunning_campaign_threshold_2) do + create( + :dunning_campaign_threshold, + dunning_campaign: dunning_campaign_2, + currency:, + amount_cents: 49_99 + ) + end + + before do + dunning_campaign_threshold_1 + dunning_campaign_threshold_2 + end + + context "when all customers have overdue balances exceeding all thresholds" do + before do + create(:invoice, organization:, customer: customer, currency:, payment_overdue: true, total_amount_cents: 100_00) + create(:invoice, organization:, customer: customer_1, currency:, payment_overdue: true, total_amount_cents: 60_00) + create(:invoice, organization:, customer: customer_2, currency:, payment_overdue: true, total_amount_cents: 51_00) + create(:invoice, organization:, customer: customer_3, currency:, payment_overdue: true, total_amount_cents: 51_00) + end + + it "enqueues ProcessAttemptJob for both customers with their respective thresholds" do + expect(result).to be_success + expect(DunningCampaigns::ProcessAttemptJob) + .not_to have_been_enqueued.with(hash_including(customer: customer)) + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued.with(customer: customer_1, dunning_campaign_threshold: dunning_campaign_threshold_1) + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued.with(customer: customer_2, dunning_campaign_threshold: dunning_campaign_threshold_2) + expect(DunningCampaigns::ProcessAttemptJob) + .to have_been_enqueued.with(customer: customer_3, dunning_campaign_threshold: dunning_campaign_threshold_1) + end + end + end + + context "when maximum attempts are reached" do + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + max_attempts: 3, + days_between_attempts: 5 + ) + end + + let(:dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign:, + currency:, + amount_cents: 500 + ) + end + + let(:customer) do + create :customer, + organization:, + billing_entity:, + currency:, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: + end + + before do + dunning_campaign_threshold + billing_entity.update!(applied_dunning_campaign: dunning_campaign) + create(:invoice, organization:, customer:, currency:, payment_overdue: true, total_amount_cents: 600) + end + + context "when not enough days have passed since last attempt" do + let(:last_dunning_campaign_attempt_at) { 3.days.ago } + + it "does not send the campaign finished webhook" do + result + expect(SendWebhookJob).not_to have_been_enqueued + end + + it "does not enqueue a process attempt job" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + + context "when enough days have passed since last attempt" do + let(:last_dunning_campaign_attempt_at) { 6.days.ago } + + it "does not enqueue a process attempt job" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end + end + end + end + + it "does not queue jobs" do + result + expect(DunningCampaigns::ProcessAttemptJob).not_to have_been_enqueued + end +end diff --git a/spec/services/dunning_campaigns/create_service_spec.rb b/spec/services/dunning_campaigns/create_service_spec.rb new file mode 100644 index 0000000..cef6022 --- /dev/null +++ b/spec/services/dunning_campaigns/create_service_spec.rb @@ -0,0 +1,153 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::CreateService do + subject(:create_service) { described_class.new(organization:, params:) } + + let(:organization) { create :organization } + let(:billing_entity) { organization.default_billing_entity } + let(:params) do + { + name: "Dunning Campaign", + code: "dunning-campaign", + days_between_attempts: 1, + max_attempts: 3, + description: "Dunning Campaign Description", + applied_to_organization:, + thresholds: + } + end + + let(:applied_to_organization) { false } + + let(:thresholds) do + [ + {amount_cents: 10000, currency: "USD"}, + {amount_cents: 20000, currency: "EUR"} + ] + end + + describe "#call" do + context "when lago freemium" do + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it "does not update the dunning campaign" do + expect { create_service.call }.not_to change(DunningCampaign, :count) + end + end + + context "when lago premium", :premium do + context "when no auto_dunning premium integration" do + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "when auto_dunning premium integration" do + let(:organization) do + create(:organization, premium_integrations: ["auto_dunning"]) + end + + it "creates a dunning campaign" do + expect { create_service.call }.to change(DunningCampaign, :count).by(1) + .and change(DunningCampaignThreshold, :count).by(2) + end + + it "returns dunning campaign in the result" do + result = create_service.call + expect(result.dunning_campaign).to be_a(DunningCampaign) + expect(result.dunning_campaign.thresholds.first).to be_a(DunningCampaignThreshold) + expect(result.dunning_campaign.bcc_emails).to eq([]) + end + + context "when bcc_emails" do + it do + result = described_class.new(organization:, params: params.merge(bcc_emails: ["earl@example.com"])).call + + expect(result.dunning_campaign.bcc_emails).to eq(["earl@example.com"]) + end + end + + context "with a previous dunning campaign set as applied on default billing entity" do + let(:dunning_campaign_2) do + create(:dunning_campaign, organization:) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign_2) } + + it "does not change previous dunning campaign applied on default billing entity" do + expect { create_service.call } + .not_to change(billing_entity, :applied_dunning_campaign) + end + end + + context "with applied_to_organization true" do + let(:applied_to_organization) { true } + + it "updates the default billing entity with applied_dunning_campaign" do + result = create_service.call + + expect(result).to be_success + expect(organization.default_billing_entity.applied_dunning_campaign).to eq(result.dunning_campaign) + end + + context "with a previous dunning campaign set as applied on default billing entity" do + let(:dunning_campaign_2) do + create(:dunning_campaign, organization:) + end + + before { billing_entity.update!(applied_dunning_campaign: dunning_campaign_2) } + + it "changes applied_dunning_campaign_id on the default billing entity" do + result = create_service.call + expect(result).to be_success + expect(billing_entity.reload.applied_dunning_campaign).to eq(result.dunning_campaign) + end + end + + it "stops and resets counters on customers" do + customer = create(:customer, organization:, last_dunning_campaign_attempt: 1, last_dunning_campaign_attempt_at: Time.current) + + expect { create_service.call }.to change { customer.reload.last_dunning_campaign_attempt }.from(1).to(0) + .and change { customer.last_dunning_campaign_attempt_at }.from(a_value).to(nil) + end + end + + context "with validation error" do + before do + create(:dunning_campaign, organization:, code: "dunning-campaign") + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + + context "without thresholds" do + let(:thresholds) { [] } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:thresholds]).to eq(["can't be blank"]) + end + end + end + end + end +end diff --git a/spec/services/dunning_campaigns/destroy_service_spec.rb b/spec/services/dunning_campaigns/destroy_service_spec.rb new file mode 100644 index 0000000..339f7bc --- /dev/null +++ b/spec/services/dunning_campaigns/destroy_service_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::DestroyService do + subject(:destroy_service) { described_class.new(dunning_campaign:) } + + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + + let(:dunning_campaign) { create(:dunning_campaign, organization:) } + let(:dunning_campaign_threshold) { create(:dunning_campaign_threshold, dunning_campaign:) } + + before { dunning_campaign_threshold } + + describe "#call" do + subject(:result) { destroy_service.call } + + context "when dunning campaign is not found" do + let(:dunning_campaign) { nil } + let(:dunning_campaign_threshold) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("dunning_campaign_not_found") + end + end + + context "when lago freemium" do + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it "does not delete the dunning campaign" do + expect { result }.not_to change(dunning_campaign, :deleted_at) + end + end + + context "when lago premium", :premium do + context "when no auto_dunning premium integration" do + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it "does not delete the dunning campaign" do + expect { result }.not_to change(dunning_campaign, :deleted_at) + end + end + + context "when auto_dunning premium integration" do + let(:organization) do + create(:organization, premium_integrations: ["auto_dunning"]) + end + + it "resets last attempt on customers" do + customer = create(:customer, organization:, applied_dunning_campaign: dunning_campaign, last_dunning_campaign_attempt: 1) + + expect { destroy_service.call }.to change { customer.reload.last_dunning_campaign_attempt }.from(1) + .to(0).and change { customer.applied_dunning_campaign_id }.from(dunning_campaign.id).to(nil) + end + + it "soft deletes the dunning campaign" do + freeze_time do + expect { destroy_service.call }.to change(DunningCampaign, :count).by(-1) + .and change { dunning_campaign.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "soft deletes the dunning campaign threshold" do + freeze_time do + expect { destroy_service.call }.to change(DunningCampaignThreshold, :count).by(-1) + .and change { dunning_campaign_threshold.reload.deleted_at }.from(nil).to(Time.current) + end + end + + context "when dunning campaign was applied on billing_entity" do + before { organization.default_billing_entity.update!(applied_dunning_campaign: dunning_campaign) } + + it "resets the applied dunning campaign on the billing entity" do + expect { destroy_service.call }.to change { organization.default_billing_entity.reload.applied_dunning_campaign_id }.from(dunning_campaign.id).to(nil) + end + end + end + end + end +end diff --git a/spec/services/dunning_campaigns/process_attempt_service_spec.rb b/spec/services/dunning_campaigns/process_attempt_service_spec.rb new file mode 100644 index 0000000..c0493a5 --- /dev/null +++ b/spec/services/dunning_campaigns/process_attempt_service_spec.rb @@ -0,0 +1,237 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::ProcessAttemptService do + subject(:result) { described_class.call(customer:, dunning_campaign_threshold:) } + + let(:customer) { create :customer, organization:, currency: } + let(:organization) { create :organization } + let(:billing_entity) { organization.default_billing_entity } + let(:currency) { "EUR" } + let(:dunning_campaign) { create :dunning_campaign, organization: } + let(:dunning_campaign_threshold) do + create :dunning_campaign_threshold, dunning_campaign:, currency:, amount_cents: 99_00 + end + + let(:payment_request) { create :payment_request, organization: } + + let(:payment_request_result) do + BaseService::Result.new.tap do |result| + result.payment_request = payment_request + result.customer = customer + end + end + + before do + billing_entity.update!(applied_dunning_campaign: dunning_campaign) + allow(PaymentRequests::CreateService) + .to receive(:call) + .and_return(payment_request_result) + end + + context "when premium features are enabled", :premium do + let(:organization) { create :organization, premium_integrations: %w[auto_dunning] } + + let(:invoice_1) { create :invoice, organization:, customer:, currency:, payment_overdue: false } + let(:invoice_2) { create :invoice, organization:, customer:, currency:, payment_overdue: true, total_amount_cents: 99_00 } + let(:invoice_3) { create :invoice, organization:, customer:, currency: "USD", payment_overdue: true } + let(:invoice_4) { create :invoice, currency:, payment_overdue: true } + + before do + invoice_1 + invoice_2 + invoice_3 + invoice_4 + end + + it "returns a successful result with customer and payment request object" do + expect(result).to be_success + expect(result.customer).to eq customer + expect(result.payment_request).to eq payment_request + end + + it "creates a payment request with customer overdue invoices" do + result + + expect(PaymentRequests::CreateService) + .to have_received(:call) + .with( + organization:, + params: { + external_customer_id: customer.external_id, + lago_invoice_ids: [invoice_2.id] + }, + dunning_campaign: + ) + end + + it "updates customer last dunning attempt data" do + freeze_time do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).by(1) + .and change(customer, :last_dunning_campaign_attempt_at).to(Time.zone.now) + end + end + + context "when dunning campaign max attempt is reached" do + let(:customer) do + create( + :customer, + organization:, + currency:, + last_dunning_campaign_attempt: dunning_campaign.max_attempts, + last_dunning_campaign_attempt_at: dunning_campaign.days_between_attempts.days.ago + ) + end + + it "does nothing" do + result + expect(PaymentRequests::CreateService).not_to have_received(:call) + end + end + + context "when max attempts is reached after processing" do + let(:customer) do + create( + :customer, + organization:, + currency:, + last_dunning_campaign_attempt: dunning_campaign.max_attempts - 1 + ) + end + + it "sends the campaign finished webhook" do + expect { result }.to have_enqueued_job(SendWebhookJob) + .with("dunning_campaign.finished", customer, {dunning_campaign_code: dunning_campaign.code}) + end + + it "still creates a payment request" do + result + expect(PaymentRequests::CreateService).to have_received(:call) + end + end + + context "when max attempts is not yet reached after processing" do + let(:customer) do + create( + :customer, + organization:, + currency:, + last_dunning_campaign_attempt: 0 + ) + end + + it "does not send the campaign finished webhook" do + result + expect(SendWebhookJob).not_to have_been_enqueued + end + end + + context "when the campaign threshold is not reached" do + let(:dunning_campaign_threshold) do + create :dunning_campaign_threshold, dunning_campaign:, currency:, amount_cents: 99_01 + end + + it "does nothing" do + result + expect(PaymentRequests::CreateService).not_to have_received(:call) + end + end + + context "when the campaign is not applicable anymore" do + let(:customer) do + create :customer, organization:, currency:, applied_dunning_campaign: + end + + let(:applied_dunning_campaign) { create :dunning_campaign, organization: } + let(:applied_dunning_campaign_threshold) do + create( + :dunning_campaign_threshold, + dunning_campaign: applied_dunning_campaign, + currency:, + amount_cents: 10_00 + ) + end + + it "does nothing" do + result + expect(PaymentRequests::CreateService).not_to have_received(:call) + end + end + + context "when the customer is excluded from auto dunning" do + let(:customer) do + create :customer, organization:, currency:, exclude_from_dunning_campaign: true + end + + it "does nothing" do + result + expect(PaymentRequests::CreateService).not_to have_received(:call) + end + end + + context "when days between attempts has not passed" do + let(:customer) do + create( + :customer, + organization:, + currency:, + last_dunning_campaign_attempt_at: 9.days.ago + ) + end + + let(:dunning_campaign) do + create( + :dunning_campaign, + organization:, + days_between_attempts: 10 + ) + end + + before do + billing_entity.update!(applied_dunning_campaign: dunning_campaign) + end + + it "does nothing" do + result + expect(PaymentRequests::CreateService).not_to have_received(:call) + end + end + + context "when payment request creation fails" do + before do + payment_request_result.service_failure!(code: "error", message: "failure") + end + + it "does not update customer last dunning campaign attempt data" do + expect { result } + .to not_change(customer.reload, :last_dunning_campaign_attempt) + .and not_change(customer.reload, :last_dunning_campaign_attempt_at) + .and raise_error(BaseService::ServiceFailure) + end + end + + context "when a customer has invoices that are not ready for payment processing" do + let(:invoice_5) { create :invoice, organization:, customer:, currency:, payment_overdue: true, ready_for_payment_processing: false, total_amount_cents: 99_00 } + + before { invoice_5 } + + it "creates payment only for ready_for_processing invoice" do + expect(result.payment_request).to eq payment_request + expect(PaymentRequests::CreateService).to have_received(:call) + .with(organization:, + params: { + external_customer_id: customer.external_id, + lago_invoice_ids: [invoice_2.id] + }, + dunning_campaign:) + end + end + end + + it "does nothing" do + result + expect(PaymentRequests::CreateService).not_to have_received(:call) + end +end diff --git a/spec/services/dunning_campaigns/update_service_spec.rb b/spec/services/dunning_campaigns/update_service_spec.rb new file mode 100644 index 0000000..fd47da7 --- /dev/null +++ b/spec/services/dunning_campaigns/update_service_spec.rb @@ -0,0 +1,526 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe DunningCampaigns::UpdateService do + subject(:update_service) { described_class.new(organization:, dunning_campaign:, params:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:billing_entity_2) { create(:billing_entity, organization:) } + let(:membership) { create(:membership, organization:) } + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + let(:params) { {applied_to_organization: false} } + + describe "#call" do + subject(:result) { update_service.call } + + before do + billing_entity.update!(applied_dunning_campaign: dunning_campaign) + end + + context "when lago freemium" do + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it "does not change the applied dunning campaign on the billing entity" do + expect { result }.to not_change(billing_entity, :applied_dunning_campaign_id) + end + end + + context "when lago premium", :premium do + context "when no auto_dunning premium integration" do + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it "does not change the applied dunning campaign on the billing entity" do + expect { result }.to not_change(billing_entity, :applied_dunning_campaign_id) + end + end + + context "when auto_dunning premium integration" do + let(:organization) do + create(:organization, premium_integrations: ["auto_dunning"]) + end + + let(:dunning_campaign_threshold) do + create(:dunning_campaign_threshold, dunning_campaign:) + end + + let(:params) do + { + name: "Updated Dunning Campaign", + code: "updated-dunning-campaign", + days_between_attempts: Faker::Number.number(digits: 2), + max_attempts: Faker::Number.number(digits: 2), + description: "Updated Dunning Campaign Description", + thresholds: thresholds_input + } + end + + let(:thresholds_input) do + [ + { + id: dunning_campaign_threshold.id, + amount_cents: 999_99, + currency: "GBP" + }, + { + amount_cents: 5_55, + currency: "CHF" + } + ] + end + + let(:customer_defaulting) do + create( + :customer, + currency: dunning_campaign_threshold.currency, + applied_dunning_campaign: nil, + last_dunning_campaign_attempt: 4, + last_dunning_campaign_attempt_at: 1.day.ago, + organization: organization, + billing_entity: billing_entity + ) + end + let(:customer_assigned) do + create( + :customer, + currency: dunning_campaign_threshold.currency, + applied_dunning_campaign: dunning_campaign, + last_dunning_campaign_attempt: 4, + last_dunning_campaign_attempt_at: 1.day.ago, + organization: organization, + billing_entity: billing_entity + ) + end + let(:customer_from_another_billing_entity) do + create( + :customer, + currency: dunning_campaign_threshold.currency, + applied_dunning_campaign: nil, + last_dunning_campaign_attempt: 4, + last_dunning_campaign_attempt_at: 1.day.ago, + organization: organization, + billing_entity: billing_entity_2 + ) + end + + it "updates the dunning campaign" do + expect(result).to be_success + expect(result.dunning_campaign.name).to eq(params[:name]) + expect(result.dunning_campaign.bcc_emails).to eq([]) + expect(result.dunning_campaign.code).to eq(params[:code]) + expect(result.dunning_campaign.days_between_attempts).to eq(params[:days_between_attempts]) + expect(result.dunning_campaign.max_attempts).to eq(params[:max_attempts]) + expect(result.dunning_campaign.description).to eq(params[:description]) + + expect(result.dunning_campaign.thresholds.count).to eq(2) + expect(result.dunning_campaign.thresholds.find(dunning_campaign_threshold.id)) + .to have_attributes({amount_cents: 999_99, currency: "GBP"}) + expect(result.dunning_campaign.thresholds.where.not(id: dunning_campaign_threshold.id).first) + .to have_attributes({amount_cents: 5_55, currency: "CHF"}) + end + + context "when bcc_emails is set and should be reset" do + let(:dunning_campaign) { create(:dunning_campaign, organization:, bcc_emails: ["earl@example.com"]) } + let(:params) do + { + name: "Updated Dunning Campaign", + bcc_emails: [] + } + end + + it "updates the dunning campaign" do + expect(result).to be_success + expect(result.dunning_campaign.name).to eq(params[:name]) + expect(result.dunning_campaign.bcc_emails).to eq([]) + end + end + + shared_examples "resets customer last dunning campaign attempt fields" do |customer_name| + let(:customer) { send(customer_name) } + + before { customer } + + it "resets the customer's dunning campaign fields" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(result).to be_success + end + end + + shared_examples "does not reset customer last dunning campaign attempt fields" do |customer_name| + let(:customer) { send(customer_name) } + + before { customer } + + it "does not reset the customer's dunning campaign fields" do + expect { result && customer.reload } + .to not_change { customer.last_dunning_campaign_attempt } + .and not_change { customer.last_dunning_campaign_attempt_at&.to_i } + + expect(result).to be_success + end + end + + context "when threshold amount_cents changes and does not apply anymore to the customer" do + let(:thresholds_input) do + [ + { + id: dunning_campaign_threshold.id, + amount_cents: threshold_amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:threshold_amount_cents) { 999_99 } + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (threshold_amount_cents - 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + include_examples "resets customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to billing entity" do + include_examples "resets customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer defaults to the campaign applied to another billing entity" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_from_another_billing_entity + end + end + + context "when threshold currency changes and does not apply anymore to the customer" do + let(:thresholds_input) do + [ + { + id: dunning_campaign_threshold.id, + amount_cents: dunning_campaign_threshold.amount_cents, + currency: "GBP" + } + ] + end + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: dunning_campaign_threshold.amount_cents + 1, + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + include_examples "resets customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "resets customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer defaults to the campaign applied to another billing entity" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_from_another_billing_entity + end + end + + context "when threshold amount_cents changes but it still applies to the customer" do + let(:thresholds_input) do + [ + { + id: dunning_campaign_threshold.id, + amount_cents: threshold_amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:threshold_amount_cents) { 50_00 } + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (threshold_amount_cents + 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + include_examples "does not reset customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer defaults to the campaign applied to another billing entity" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_from_another_billing_entity + end + end + + context "when threshold currency changes but it still applies to the customer" do + let(:thresholds_input) do + [ + { + id: not_matching_threshold.id, + amount_cents: 999_99, + currency: "GBP" + }, + { + id: dunning_campaign_threshold.id, + amount_cents: dunning_campaign_threshold.amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:not_matching_threshold) do + create(:dunning_campaign_threshold, dunning_campaign:, currency: "CHF") + end + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: dunning_campaign_threshold.amount_cents + 1, + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + include_examples "does not reset customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer defaults to the campaign applied to another billing entity" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_from_another_billing_entity + end + end + + context "when a threshold is discarded and the campaign does not apply anymore to the customer" do + let(:thresholds_input) { [] } # No thresholds remain. + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (dunning_campaign_threshold.amount_cents + 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + include_examples "resets customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "resets customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer defaults to the campaign applied to another billing entity" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_from_another_billing_entity + end + end + + context "when a threshold is discarded and replaced with one that still applies to the customer" do + let(:thresholds_input) do + [ + { + amount_cents: threshold_amount_cents, + currency: dunning_campaign_threshold.currency + } + ] + end + + let(:threshold_amount_cents) { 1_00 } + + before do + create( + :invoice, + organization:, + customer:, + payment_overdue: true, + total_amount_cents: (threshold_amount_cents + 1), + currency: dunning_campaign_threshold.currency + ) + end + + context "when the campaign is assigned to the customer" do + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + include_examples "does not reset customer last dunning campaign attempt fields", :customer_assigned + end + + context "when the customer defaults to the campaign applied to organization" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_defaulting + end + + context "when the customer defaults to the campaign applied to another billing entity" do + include_examples "does not reset customer last dunning campaign attempt fields", :customer_from_another_billing_entity + end + end + + context "when the input does not include a thresholds" do + let(:dunning_campaign_threshold_to_be_deleted) do + create(:dunning_campaign_threshold, dunning_campaign:, currency: "EUR") + end + + before { dunning_campaign_threshold_to_be_deleted } + + it "deletes the thresholds not in the input" do + expect(result).to be_success + expect(result.dunning_campaign.thresholds.count).to eq(2) + expect(result.dunning_campaign.thresholds.find_by(id: dunning_campaign_threshold_to_be_deleted.id)).to be_nil + expect(dunning_campaign_threshold_to_be_deleted.reload).to be_discarded + end + end + + context "with applied_to_organization false" do + let(:params) { {applied_to_organization: false} } + + before do + customer_assigned.reload + customer_defaulting.reload + customer_from_another_billing_entity.reload + end + + it "unassigns dunning_campaign from the default billing entity" do + expect { result }.to change { organization.default_billing_entity.applied_dunning_campaign_id } + .from(dunning_campaign.id).to(nil) + end + + it "resets the defaulting customers last dunning campaign attempt fields" do + expect { result }.to change { customer_defaulting.reload.last_dunning_campaign_attempt }.to(0) + .and change(customer_defaulting, :last_dunning_campaign_attempt_at).to(nil) + end + + it "does not reset the customers from another billing entity last dunning campaign attempt fields" do + expect { result }.to not_change { customer_from_another_billing_entity.reload.last_dunning_campaign_attempt } + .and not_change { customer_from_another_billing_entity.last_dunning_campaign_attempt_at } + end + + it "does not reset the assigned customers last dunning campaign attempt fields" do + expect { result }.to not_change { customer_assigned.reload.last_dunning_campaign_attempt } + .and not_change { customer_assigned.last_dunning_campaign_attempt_at } + end + end + + context "with applied_to_organization true" do + let(:params) { {applied_to_organization: true} } + + let(:dunning_campaign) do + create(:dunning_campaign, organization:) + end + + before do + billing_entity.update!(applied_dunning_campaign: nil) + end + + it "updates applied_dunning_campaign_id on the default billing entity" do + expect { result }.to change { organization.default_billing_entity.applied_dunning_campaign_id } + .from(nil).to(dunning_campaign.id) + end + + context "with a previous dunning campaign is applied to the default billing entity" do + let(:dunning_campaign_2) do + create(:dunning_campaign, organization:) + end + + before do + billing_entity.update!(applied_dunning_campaign: dunning_campaign_2) + end + + it "changes applied_dunning_campaign_id on the default billing entity" do + expect { result }.to change { organization.default_billing_entity.applied_dunning_campaign_id } + .from(dunning_campaign_2.id).to(dunning_campaign.id) + end + end + + it "stops and resets counters on customers" do + customer = create(:customer, organization:, last_dunning_campaign_attempt: 1, last_dunning_campaign_attempt_at: Time.current) + + expect { result }.to change { customer.reload.last_dunning_campaign_attempt }.from(1).to(0) + .and change { customer.last_dunning_campaign_attempt_at }.from(a_value).to(nil) + end + + it "resets last attempt" do + customer = create( + :customer, + organization:, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: Time.zone.now + ) + + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + end + end + + context "with no dunning campaign record" do + let(:dunning_campaign) { nil } + let(:thresholds_input) { nil } + + it "returns a failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("dunning_campaign_not_found") + end + end + end + end + end +end diff --git a/spec/services/e_invoices/credit_notes/factur_x/create_service_spec.rb b/spec/services/e_invoices/credit_notes/factur_x/create_service_spec.rb new file mode 100644 index 0000000..79ea073 --- /dev/null +++ b/spec/services/e_invoices/credit_notes/factur_x/create_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::CreditNotes::FacturX::CreateService, type: :service do + let(:credit_note) { create(:credit_note) } + let(:xml_builder_double) { instance_double(Nokogiri::XML::Builder, to_xml: xml_content) } + let(:xml_content) { "content" } + + describe "#call" do + context "when credit_note exists" do + it "builds the XML" do + allow(Nokogiri::XML::Builder).to receive(:new).with(encoding: "UTF-8") + .and_yield(xml_builder_double).and_return(xml_builder_double) + + allow(EInvoices::CreditNotes::FacturX::Builder).to receive(:serialize) + .with(xml: xml_builder_double, credit_note:) + + result = described_class.new(credit_note:).call + expect(result).to be_success + expect(result.xml).to be(xml_content) + end + end + + context "without credit_note" do + let(:credit_note) { nil } + + it "returns a failed result" do + result = described_class.new(credit_note:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("credit_note_not_found") + end + end + end +end diff --git a/spec/services/e_invoices/credit_notes/ubl/create_service_spec.rb b/spec/services/e_invoices/credit_notes/ubl/create_service_spec.rb new file mode 100644 index 0000000..22c850a --- /dev/null +++ b/spec/services/e_invoices/credit_notes/ubl/create_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::CreditNotes::Ubl::CreateService, type: :service do + let(:credit_note) { create(:credit_note) } + let(:xml_builder_double) { instance_double(Nokogiri::XML::Builder, to_xml: xml_content) } + let(:xml_content) { "content" } + + describe "#call" do + context "when credit_note exists" do + it "builds the XML" do + allow(Nokogiri::XML::Builder).to receive(:new).with(encoding: "UTF-8") + .and_yield(xml_builder_double).and_return(xml_builder_double) + + allow(EInvoices::CreditNotes::Ubl::Builder).to receive(:serialize) + .with(xml: xml_builder_double, credit_note:) + + result = described_class.new(credit_note:).call + expect(result).to be_success + expect(result.xml).to be(xml_content) + end + end + + context "without credit_note" do + let(:credit_note) { nil } + + it "returns a failed result" do + result = described_class.new(credit_note:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("credit_note_not_found") + end + end + end +end diff --git a/spec/services/e_invoices/invoices/factur_x/create_service_spec.rb b/spec/services/e_invoices/invoices/factur_x/create_service_spec.rb new file mode 100644 index 0000000..bee56c7 --- /dev/null +++ b/spec/services/e_invoices/invoices/factur_x/create_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Invoices::FacturX::CreateService do + let(:invoice) { create(:invoice) } + let(:xml_builder_double) { instance_double(Nokogiri::XML::Builder, to_xml: xml_content) } + let(:xml_content) { "content" } + + describe "#call" do + context "when invoice exists" do + it "builds the XML" do + allow(Nokogiri::XML::Builder).to receive(:new).with(encoding: "UTF-8") + .and_yield(xml_builder_double).and_return(xml_builder_double) + + allow(EInvoices::Invoices::FacturX::Builder).to receive(:serialize) + .with(xml: xml_builder_double, invoice:) + + result = described_class.new(invoice:).call + expect(result).to be_success + expect(result.xml).to be(xml_content) + end + end + + context "without invoice" do + let(:invoice) { nil } + + it "returns a failed result" do + result = described_class.new(invoice:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("invoice_not_found") + end + end + end +end diff --git a/spec/services/e_invoices/invoices/ubl/create_service_spec.rb b/spec/services/e_invoices/invoices/ubl/create_service_spec.rb new file mode 100644 index 0000000..1419864 --- /dev/null +++ b/spec/services/e_invoices/invoices/ubl/create_service_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Invoices::Ubl::CreateService do + let(:invoice) { create(:invoice) } + let(:xml_builder_double) { instance_double(Nokogiri::XML::Builder, to_xml: xml_content) } + let(:xml_content) { "content" } + + describe "#call" do + context "when invoice exists" do + it "builds the XML" do + allow(Nokogiri::XML::Builder).to receive(:new).with(encoding: "UTF-8") + .and_yield(xml_builder_double).and_return(xml_builder_double) + + allow(EInvoices::Invoices::Ubl::Builder).to receive(:serialize) + .with(xml: xml_builder_double, invoice:) + + result = described_class.new(invoice:).call + expect(result).to be_success + expect(result.xml).to be(xml_content) + end + end + + context "without invoice" do + let(:invoice) { nil } + + it "returns a failed result" do + result = described_class.new(invoice:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("invoice_not_found") + end + end + end +end diff --git a/spec/services/e_invoices/payments/factur_x/create_service_spec.rb b/spec/services/e_invoices/payments/factur_x/create_service_spec.rb new file mode 100644 index 0000000..f3752f5 --- /dev/null +++ b/spec/services/e_invoices/payments/factur_x/create_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Payments::FacturX::CreateService, :premium do + let(:organization) { create(:organization, premium_integrations: %w[issue_receipts]) } + let(:payment) { create(:payment, organization:) } + let(:xml_builder_double) { instance_double(Nokogiri::XML::Builder, to_xml: xml_content) } + let(:xml_content) { "content" } + + describe "#call" do + context "when payment exists" do + it "builds the XML" do + allow(Nokogiri::XML::Builder).to receive(:new).with(encoding: "UTF-8") + .and_yield(xml_builder_double).and_return(xml_builder_double) + + allow(EInvoices::Payments::FacturX::Builder).to receive(:serialize) + .with(xml: xml_builder_double, payment:) + + result = described_class.new(payment:).call + expect(result).to be_success + expect(result.xml).to be(xml_content) + end + end + + context "without payment" do + let(:payment) { nil } + + it "returns a failed result" do + result = described_class.new(payment:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("payment_not_found") + end + end + + context "when issue_receipts is not enabled" do + before do + organization.update(premium_integrations: []) + end + + it "returns a failed result" do + result = described_class.new(payment:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.message).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/e_invoices/payments/ubl/create_service_spec.rb b/spec/services/e_invoices/payments/ubl/create_service_spec.rb new file mode 100644 index 0000000..5a8c833 --- /dev/null +++ b/spec/services/e_invoices/payments/ubl/create_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EInvoices::Payments::Ubl::CreateService, :premium do + let(:organization) { create(:organization, premium_integrations: %w[issue_receipts]) } + let(:payment) { create(:payment, organization:) } + let(:xml_builder_double) { instance_double(Nokogiri::XML::Builder, to_xml: xml_content) } + let(:xml_content) { "content" } + + describe "#call" do + context "when payment exists" do + it "builds the XML" do + allow(Nokogiri::XML::Builder).to receive(:new).with(encoding: "UTF-8") + .and_yield(xml_builder_double).and_return(xml_builder_double) + + allow(EInvoices::Payments::Ubl::Builder).to receive(:serialize) + .with(xml: xml_builder_double, payment:) + + result = described_class.new(payment:).call + expect(result).to be_success + expect(result.xml).to be(xml_content) + end + end + + context "without payment" do + let(:payment) { nil } + + it "returns a failed result" do + result = described_class.new(payment:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("payment_not_found") + end + end + + context "when issue_receipts is not enabled" do + before do + organization.update(premium_integrations: []) + end + + it "returns a failed result" do + result = described_class.new(payment:).call + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.message).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/emails/resend_service_spec.rb b/spec/services/emails/resend_service_spec.rb new file mode 100644 index 0000000..3aff00a --- /dev/null +++ b/spec/services/emails/resend_service_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Emails::ResendService do + subject(:service) { described_class.new(resource:, to:, cc:, bcc:) } + + let(:to) { nil } + let(:cc) { nil } + let(:bcc) { nil } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, email: "customer@example.com") } + let(:billing_entity) { customer.billing_entity } + + before do + billing_entity.update!(email: "billing@example.com") + billing_entity.email_settings = ["invoice.finalized", "credit_note.created", "payment_receipt.created"] + billing_entity.save! + allow(License).to receive(:premium?).and_return(true) + end + + describe "#call" do + context "when resource is nil" do + let(:resource) { nil } + + it "returns a not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("resource") + end + end + + context "with an invoice" do + let(:resource) { create(:invoice, organization:, customer:, status:) } + + context "when invoice is finalized" do + let(:status) { :finalized } + + it "sends the email successfully" do + expect do + result = service.call + expect(result).to be_success + end.to have_enqueued_mail(InvoiceMailer, :created) + end + + context "with custom recipients" do + let(:to) { ["custom@example.com", "another@example.com"] } + let(:cc) { ["cc@example.com"] } + let(:bcc) { ["bcc@example.com"] } + + it "sends the email with custom recipients" do + expect do + result = service.call + expect(result).to be_success + end.to have_enqueued_mail(InvoiceMailer, :created) + end + end + end + + context "when invoice is draft" do + let(:status) { :draft } + + it "returns a not allowed failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoice_not_finalized") + end + end + + context "when premium license is not available" do + let(:status) { :finalized } + + before { allow(License).to receive(:premium?).and_return(false) } + + it "returns a forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("premium_license_required") + end + end + + context "when email settings are disabled" do + let(:status) { :finalized } + + before do + billing_entity.email_settings = [] + billing_entity.save! + end + + it "returns a not allowed failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("email_settings_disabled") + end + end + + context "when billing entity has no email" do + let(:status) { :finalized } + + before { billing_entity.update!(email: nil) } + + it "returns a validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:billing_entity]).to include("must have email configured") + end + end + + context "when custom recipient has invalid email format" do + let(:status) { :finalized } + let(:to) { ["invalid-email"] } + + it "returns a validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:to]).to include("invalid email format: invalid-email") + end + end + + context "when customer has no email and no custom recipient" do + let(:status) { :finalized } + + before { customer.update!(email: nil) } + + it "returns a validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:to]).to include("must have at least one recipient") + end + end + end + + context "with a credit note" do + let(:invoice) { create(:invoice, organization:, customer:, status: :finalized) } + let(:resource) { create(:credit_note, invoice:, customer:, status:) } + + context "when credit note is finalized" do + let(:status) { :finalized } + + it "sends the email successfully" do + expect do + result = service.call + expect(result).to be_success + end.to have_enqueued_mail(CreditNoteMailer, :created) + end + end + + context "when credit note is draft" do + let(:status) { :draft } + + it "returns a not allowed failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("credit_note_not_finalized") + end + end + end + + context "with a payment receipt" do + let(:invoice) { create(:invoice, organization:, customer:, status: :finalized) } + let(:payment) { create(:payment, payable: invoice) } + let(:resource) { create(:payment_receipt, payment:, organization:) } + + it "sends the email successfully without status check" do + expect do + result = service.call + expect(result).to be_success + end.to have_enqueued_mail(PaymentReceiptMailer, :created) + end + end + end +end diff --git a/spec/services/entitlement/feature_create_service_spec.rb b/spec/services/entitlement/feature_create_service_spec.rb new file mode 100644 index 0000000..99bbaae --- /dev/null +++ b/spec/services/entitlement/feature_create_service_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::FeatureCreateService do + subject { described_class.call(organization:, params:) } + + let(:organization) { create(:organization) } + let(:params) do + { + code: "seats", + name: "Number of seats", + description: "Number of users of the account", + privileges: [ + {code: "max_admins", value_type: "integer"}, + {code: "max", name: "Maximum", value_type: "integer"} + ] + } + end + + describe "#call", :premium do + it "creates a feature with the provided attributes" do + expect { subject }.to change(Entitlement::Feature, :count).by(1) + + result = subject + expect(result).to be_success + expect(result.feature.code).to eq("seats") + expect(result.feature.name).to eq("Number of seats") + expect(result.feature.description).to eq("Number of users of the account") + expect(result.feature.organization).to eq(organization) + end + + it "trims code" do + params[:code] = " seats " + params[:privileges] = [{code: " test "}] + result = subject + expect(result.feature.code).to eq "seats" + expect(result.feature.privileges.sole.code).to eq "test" + end + + it "produces an activity log" do + result = subject + expect(Utils::ActivityLog).to have_produced("feature.created").after_commit.with(result.feature) + end + + it "sends feature.created webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("feature.created", instance_of(Entitlement::Feature)) + end + + it "creates privileges for the feature" do + expect { subject }.to change(Entitlement::Privilege, :count).by(2) + + result = subject + expect(result).to be_success + + privileges = result.feature.privileges + expect(privileges.count).to eq(2) + + max_admins_privilege = privileges.find_by(code: "max_admins") + expect(max_admins_privilege.value_type).to eq("integer") + expect(max_admins_privilege.name).to be_nil + + max_privilege = privileges.find_by(code: "max") + expect(max_privilege.value_type).to eq("integer") + expect(max_privilege.name).to eq("Maximum") + end + + context "when organization is nil" do + let(:organization) { nil } + + it "returns a not found failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("organization") + end + end + + context "when feature code is invalid" do + let(:params) do + { + code: "", # Invalid empty code + name: "Number of seats", + description: "Number of users of the account" + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq ["value_is_mandatory"] + end + end + + context "when feature code already exists" do + before do + create(:feature, organization:, code: "seats") + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq ["value_already_exist"] + end + end + + context "when privilege value_type is not set" do + let(:params) do + { + code: "seats", + name: "Number of seats", + description: "Number of users of the account", + privileges: [ + {code: "max_admins"} + ] + } + end + + it "defaults to string" do + result = subject + + expect(result).to be_success + expect(result.feature.privileges.sole.value_type).to eq "string" + end + end + + context "when privilege code is duplicated" do + let(:params) do + { + code: "seats", + privileges: [ + {code: "max_admins"}, + {code: "max_admins"} + ] + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:"privilege.code"]).to eq ["value_is_duplicated"] + end + end + + context "when privilege value_type is invalid" do + let(:params) do + { + code: "seats", + name: "Number of seats", + description: "Number of users of the account", + privileges: [ + {code: "max_admins", value_type: "invalid_type"} + ] + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:"privilege.value_type"]).to eq ["value_is_invalid"] + end + end + + context "when privilege code is invalid" do + let(:params) do + { + code: "seats", + name: "Number of seats", + description: "Number of users of the account", + privileges: [ + {value_type: "integer"} # Invalid empty code + ] + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:"privilege.code"]).to eq ["value_is_mandatory"] + end + end + + context "when feature has no privileges" do + let(:params) do + { + code: "seats", + name: "Number of seats", + description: "Number of users of the account" + } + end + + it "creates a feature without privileges" do + expect { subject }.to change(Entitlement::Feature, :count).by(1).and(not_change(Entitlement::Privilege, :count)) + + result = subject + expect(result).to be_success + expect(result.feature.privileges).to be_empty + end + end + + context "when feature name and description, and privilege name are optional" do + let(:params) do + { + code: "seats", + privileges: [ + {code: "max_admins", value_type: "integer"} + ] + } + end + + it "creates a feature with only required attributes" do + expect { subject }.to change(Entitlement::Feature, :count).by(1) + + result = subject + expect(result).to be_success + expect(result.feature.code).to eq("seats") + expect(result.feature.name).to be_nil + expect(result.feature.description).to be_nil + end + end + + context "when privilege has config" do + let(:params) do + { + code: "sso", + privileges: [ + { + code: "provider", + name: "Provider Name", + value_type: "select", + config: {select_options: %w[okta ad google custom]} + } + ] + } + end + + it "creates privilege with config" do + expect { subject }.to change(Entitlement::Privilege, :count).by(1) + + result = subject + expect(result).to be_success + + privilege = result.feature.privileges.first + expect(privilege.code).to eq("provider") + expect(privilege.name).to eq("Provider Name") + expect(privilege.value_type).to eq("select") + expect(privilege.config).to eq({"select_options" => %w[okta ad google custom]}) + end + end + end +end diff --git a/spec/services/entitlement/feature_destroy_service_spec.rb b/spec/services/entitlement/feature_destroy_service_spec.rb new file mode 100644 index 0000000..35b1a5b --- /dev/null +++ b/spec/services/entitlement/feature_destroy_service_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::FeatureDestroyService do + subject { described_class.call(feature:) } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:privilege1) { create(:privilege, feature:, code: "max_admins", value_type: "integer") } + let(:privilege2) { create(:privilege, feature:, code: "has_root", value_type: "boolean") } + + before do + privilege1 + privilege2 + feature.reload + end + + describe "#call", :premium do + it "discards the feature" do + expect { subject }.to change { feature.reload.discarded? }.from(false).to(true) + end + + it "discards all privileges associated with the feature" do + expect { subject }.to change(feature.privileges, :count).by(-2) + end + + it "returns the feature in the result" do + result = subject + + expect(result).to be_success + expect(result.feature).to eq(feature) + end + + it "sends feature.deleted webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("feature.deleted", feature) + end + + it "produces an activity log" do + result = subject + expect(Utils::ActivityLog).to have_produced("feature.deleted").after_commit.with(result.feature) + end + + context "when feature is nil" do + it "returns a not found failure" do + result = described_class.call(feature: nil) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("feature") + end + end + + context "when feature is already discarded" do + before { feature.discard! } + + it "still succeeds" do + expect { subject }.to raise_error(Discard::RecordNotDiscarded) + end + end + + context "when feature has no privileges" do + before do + privilege1.discard! + privilege2.discard! + end + + it "still discards the feature successfully" do + expect { subject }.to change { feature.reload.discarded? }.from(false).to(true) + end + end + + context "when feature is attached to a plan" do + let(:entitlement) { create(:entitlement, feature:) } + let(:privilege1_value) { create(:entitlement_value, entitlement:, privilege: privilege1, value: 10) } + let(:privilege2_value) { create(:entitlement_value, entitlement:, privilege: privilege2, value: true) } + + before do + privilege1_value + privilege2_value + end + + it "discard all values and entitlement and send webhooks" do + expect { subject }.to change(feature.entitlement_values, :count).from(2).to(0) + .and change(feature.entitlements, :count).from(1).to(0) + .and have_enqueued_job(SendWebhookJob).with("feature.deleted", feature) + .and have_enqueued_job(SendWebhookJob).with("plan.updated", entitlement.plan) + end + + it "produces plan.updated logs" do + subject + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(entitlement.plan) + end + end + end +end diff --git a/spec/services/entitlement/feature_update_service_spec.rb b/spec/services/entitlement/feature_update_service_spec.rb new file mode 100644 index 0000000..7d95d4c --- /dev/null +++ b/spec/services/entitlement/feature_update_service_spec.rb @@ -0,0 +1,576 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::FeatureUpdateService do + subject { described_class.call(feature:, params:, partial:) } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:privilege1) { create(:privilege, feature:, code: "max", name: "Maximum") } + let(:privilege2) { create(:privilege, feature:, code: "min", name: "Minimum") } + let(:privilege3) { create(:privilege, feature:, code: "opt", name: "Optional") } + let(:params) { {} } + let(:partial) { false } + + before do + privilege1 + privilege2 + privilege3 + end + + describe "#call", :premium do + context "when update is full" do + let(:partial) { false } + + context "when privilege code is duplicated" do + let(:params) do + { + code: "seats", + privileges: [ + {code: "max_admins"}, + {code: "max_admins"} + ] + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:"privilege.code"]).to eq ["value_is_duplicated"] + end + end + + context "when updating feature attributes" do + let(:params) do + { + name: "Updated Feature Name", + description: "Updated feature description" + } + end + + it "updates the feature name and description" do + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq("Updated Feature Name") + expect(result.feature.description).to eq("Updated feature description") + end + + it "sends feature.updated webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("feature.updated", feature) + end + + it "produces an activity log" do + result = subject + expect(Utils::ActivityLog).to have_produced("feature.updated").after_commit.with(result.feature) + end + + it "only updates provided attributes" do + original_name = feature.name + params.delete(:name) + + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq(original_name) + expect(result.feature.description).to eq("Updated feature description") + end + end + + context "when updating privileges" do + let(:params) do + { + privileges: [ + {code: "max", name: "Max."}, + {code: "min", name: "Min."} + ] + } + end + + it "updates the privilege names and delete missing privileges" do + result = subject + + expect(result).to be_success + expect(privilege1.reload.name).to eq("Max.") + expect(privilege2.reload.name).to eq("Min.") + expect(privilege3.reload.deleted_at).to be_present + expect(feature.privileges.pluck(:code)).to match_array(%w[max min]) + end + + it "only updates provided privilege attributes" do + original_name = privilege1.name + params[:privileges] = [ + {code: "max"}, + {code: "min", name: "Min."} + ] + + result = subject + + expect(result).to be_success + expect(privilege1.reload.name).to eq(original_name) + expect(privilege2.reload.name).to eq("Min.") + end + end + + context "when updating both feature and privileges" do + let(:params) do + { + name: "Updated Feature Name", + description: "Updated feature description", + privileges: [ + {code: "max", name: "Max."} + ] + } + end + + it "updates both feature and privilege attributes" do + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq("Updated Feature Name") + expect(result.feature.description).to eq("Updated feature description") + expect(privilege1.reload.name).to eq("Max.") + expect(feature.privileges.reload.count).to eq(1) + expect(feature.privileges.pluck(:code)).to eq(%w[max]) + end + end + + context "when updating select_options of a privilege" do + let(:privilege3) { create(:privilege, feature:, code: "opt", value_type: "select", config: {select_options: %w[zero one]}) } + + let(:params) do + { + privileges: [ + {code: "opt", config: {select_options: %w[one two three]}} + ] + } + end + + it "appends the new options" do + result = subject + + expect(result).to be_success + expect(privilege3.reload.config["select_options"]).to eq %w[zero one two three] + end + end + + context "when deleting privileges with associated entitlement values" do + let(:entitlement) { create(:entitlement, feature:) } + let(:privilege1_value) { create(:entitlement_value, entitlement:, privilege: privilege1, value: "10") } + let(:privilege2_value) { create(:entitlement_value, entitlement:, privilege: privilege2, value: "true") } + let(:privilege3_value) { create(:entitlement_value, entitlement:, privilege: privilege3, value: "option1") } + + let(:params) do + { + privileges: [ + {code: "max", name: "Max."} + ] + } + end + + before do + privilege1_value + privilege2_value + privilege3_value + end + + it "soft deletes entitlement values for removed privileges" do + result = subject + + expect(result).to be_success + expect(Entitlement::EntitlementValue.with_discarded.count).to eq(3) + expect(Entitlement::EntitlementValue.count).to eq(1) # only privilege1_value remains + expect(privilege1_value.reload).to be_present + expect(privilege2_value.reload).to be_discarded + expect(privilege3_value.reload).to be_discarded + end + + it "soft deletes the removed privileges" do + result = subject + + expect(result).to be_success + expect(Entitlement::Privilege.with_discarded.count).to eq(3) + expect(Entitlement::Privilege.count).to eq(1) # only privilege1 remains + expect(privilege1.reload).to be_present + expect(privilege2.reload).to be_discarded + expect(privilege3.reload).to be_discarded + end + end + + context "when new privileges is provided" do + let(:new_privilege_code) { " new_privilege " } + let(:params) do + { + privileges: [ + {code: new_privilege_code, name: "New Privilege"} + ] + } + end + + it "creates a new privilege" do + result = subject + + expect(result).to be_success + expect(feature.privileges.reload.count).to eq(1) + expect(feature.privileges.sole.code).to eq("new_privilege") + expect(feature.privileges.sole.name).to eq("New Privilege") + expect(feature.privileges.sole.value_type).to eq("string") + end + + context "when new privilege params are invalid" do + let(:params) do + { + privileges: [ + {code: new_privilege_code, name: "New Privilege", value_type: "invalid_type"} + ] + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to include("privilege.value_type": ["value_is_invalid"]) + end + end + end + + context "when feature is nil" do + let(:params) { {name: "Updated Name"} } + + it "returns a not found failure" do + result = described_class.call(feature: nil, params:, partial:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("feature") + end + end + + context "when privilege name is empty" do + let(:params) do + { + privileges: [ + {code: "max", name: ""} # Empty name is allowed + ] + } + end + + it "updates the privilege name to empty string" do + result = subject + + expect(result).to be_success + expect(privilege1.reload.name).to eq("") + end + end + + context "when feature name is empty" do + let(:params) { {name: ""} } + + it "updates the feature name to empty string" do + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq("") + end + end + + context "when feature is attached to a plan" do + let(:params) { {} } + let(:entitlement) { create(:entitlement, feature:) } + let(:privilege1_value) { create(:entitlement_value, entitlement:, privilege: privilege1, value: 10) } + let(:privilege2_value) { create(:entitlement_value, entitlement:, privilege: privilege2, value: true) } + + before do + privilege1_value + privilege2_value + end + + it "sends plan.updated webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("plan.updated", entitlement.plan) + end + + it "produces plan.updated logs" do + subject + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(entitlement.plan) + end + end + + shared_examples "discards all privileges" do + it "discards all existing privileges" do + result = subject + + expect(result).to be_success + expect(feature.privileges.reload.count).to eq(0) + end + end + + context "when no privileges are provided" do + let(:params) { {name: "Updated Name"} } + + it_behaves_like "discards all privileges" + end + + context "when privileges parameter is empty hash" do + let(:params) { {name: "Updated Name", privileges: {}} } + + it_behaves_like "discards all privileges" + end + + context "when privileges parameter is nil" do + let(:params) { {name: "Updated Name", privileges: nil} } + + it_behaves_like "discards all privileges" + end + end + + describe "when update is partial" do + let(:partial) { true } + + context "when privilege code is duplicated" do + let(:params) do + { + code: "seats", + privileges: [ + {code: "max_admins"}, + {code: "max_admins"} + ] + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:"privilege.code"]).to eq ["value_is_duplicated"] + end + end + + context "when updating feature attributes" do + let(:params) do + { + name: "Updated Feature Name", + description: "Updated feature description" + } + end + + it "updates the feature name and description" do + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq("Updated Feature Name") + expect(result.feature.description).to eq("Updated feature description") + end + + it "sends feature.updated webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("feature.updated", feature) + end + + it "produces an activity log" do + result = subject + expect(Utils::ActivityLog).to have_produced("feature.updated").after_commit.with(result.feature) + end + + it "only updates provided attributes" do + original_name = feature.name + params.delete(:name) + + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq(original_name) + expect(result.feature.description).to eq("Updated feature description") + end + end + + context "when updating privilege names" do + let(:params) do + { + privileges: [ + {code: "max", name: "Max."}, + {code: "min", name: "Min."} + ] + } + end + + it "updates the privilege names" do + result = subject + + expect(result).to be_success + expect(privilege1.reload.name).to eq("Max.") + expect(privilege2.reload.name).to eq("Min.") + end + + it "only updates privileges that exist" do + params[:privileges] << {code: "nonexistent", name: "New Name"} + + result = subject + + expect(result).to be_success + expect(privilege1.reload.name).to eq("Max.") + expect(privilege2.reload.name).to eq("Min.") + end + + it "only updates provided privilege attributes" do + original_name = privilege1.name + params[:privileges] = [ + {code: "max"}, + {code: "min", name: "Min."} + ] + + result = subject + + expect(result).to be_success + expect(privilege1.reload.name).to eq(original_name) + expect(privilege2.reload.name).to eq("Min.") + end + end + + context "when updating select_options of a privilege" do + let(:privilege3) { create(:privilege, feature:, code: "opt", value_type: "select", config: {select_options: %w[zero one]}) } + + let(:params) do + { + privileges: [ + {code: "opt", config: {select_options: %w[one two three]}} + ] + } + end + + it "appends the new options" do + result = subject + + expect(result).to be_success + expect(privilege3.reload.config["select_options"]).to eq %w[zero one two three] + end + end + + context "when updating both feature and privileges" do + let(:params) do + { + name: "Updated Feature Name", + description: "Updated feature description", + privileges: [ + {code: "max", name: "Max."} + ] + } + end + + it "updates both feature and privilege attributes" do + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq("Updated Feature Name") + expect(result.feature.description).to eq("Updated feature description") + expect(privilege1.reload.name).to eq("Max.") + expect(privilege2.reload.name).to eq("Minimum") # unchanged + end + end + + context "when feature is nil" do + let(:params) { {name: "Updated Name"} } + + it "returns a not found failure" do + result = described_class.call(feature: nil, params:, partial:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("feature") + end + end + + context "when privilege name is empty" do + let(:params) do + { + privileges: [ + {code: "max", name: ""} # Empty name is allowed + ] + } + end + + it "updates the privilege name to empty string" do + result = subject + + expect(result).to be_success + expect(privilege1.reload.name).to eq("") + end + end + + context "when feature name is empty" do + let(:params) { {name: ""} } + + it "updates the feature name to empty string" do + result = subject + + expect(result).to be_success + expect(result.feature.name).to eq("") + end + end + + context "when new privileges is provided" do + let(:new_privilege_code) { "new_privilege" } + let(:params) do + { + privileges: [ + {code: new_privilege_code, name: "New Privilege"} + ] + } + end + + it "creates a new privilege" do + result = subject + + expect(result).to be_success + expect(feature.privileges.reload.count).to eq(4) # 3 existing + 1 new + p = feature.privileges.find_by(code: new_privilege_code) + expect(p.name).to eq("New Privilege") + expect(p.value_type).to eq("string") + end + + context "when new privilege params are invalid" do + let(:params) do + { + privileges: [ + {code: new_privilege_code, name: "New Privilege", value_type: "invalid_type"} + ] + } + end + + it "returns a validation failure" do + result = subject + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to include("privilege.value_type": ["value_is_invalid"]) + end + end + end + + context "when feature is attached to a plan" do + let(:params) { {} } + let(:entitlement) { create(:entitlement, feature:) } + let(:privilege1_value) { create(:entitlement_value, entitlement:, privilege: privilege1, value: 10) } + let(:privilege2_value) { create(:entitlement_value, entitlement:, privilege: privilege2, value: true) } + + before do + privilege1_value + privilege2_value + end + + it "sends plan.updated webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("plan.updated", entitlement.plan) + end + + it "produces plan.updated logs" do + subject + allow(Utils::ActivityLog).to receive(:produce_after_commit).and_call_original + end + end + end + end +end diff --git a/spec/services/entitlement/plan_entitlement_destroy_service_spec.rb b/spec/services/entitlement/plan_entitlement_destroy_service_spec.rb new file mode 100644 index 0000000..4d6844d --- /dev/null +++ b/spec/services/entitlement/plan_entitlement_destroy_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::PlanEntitlementDestroyService do + subject(:result) { described_class.call(entitlement:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:feature) { create(:feature, organization:) } + let(:privilege) { create(:privilege, organization:, feature:) } + let(:entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, organization:) } + + before do + entitlement_value + end + + describe "#call", :premium do + it "returns success" do + expect(result).to be_success + end + + it "soft deletes the entitlement" do + expect { result }.to change(feature.entitlements, :count).by(-1) + end + + it "soft deletes all entitlement values" do + expect { result }.to change(feature.entitlement_values, :count).by(-1) + end + + it "returns the entitlement in the result" do + expect(result.entitlement).to eq(entitlement) + end + + it "sends `plan.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("plan.updated", plan) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(plan) + end + + context "when entitlement is nil" do + subject(:result) { described_class.call(entitlement: nil) } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("entitlement_not_found") + end + end + + context "when entitlement is already deleted" do + before do + entitlement.discard! + end + + it "still soft deletes the entitlement values" do + expect { result }.to raise_error(Discard::RecordNotDiscarded) + end + end + end +end diff --git a/spec/services/entitlement/plan_entitlement_privilege_destroy_service_spec.rb b/spec/services/entitlement/plan_entitlement_privilege_destroy_service_spec.rb new file mode 100644 index 0000000..0d71eb9 --- /dev/null +++ b/spec/services/entitlement/plan_entitlement_privilege_destroy_service_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::PlanEntitlementPrivilegeDestroyService do + subject(:result) { described_class.call(entitlement:, privilege_code:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:feature) { create(:feature, organization:) } + let(:privilege) { create(:privilege, organization:, feature:, code: "max") } + let(:privilege2) { create(:privilege, organization:, feature:, code: "max_admins") } + let(:entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, organization:) } + let(:entitlement_value2) { create(:entitlement_value, entitlement:, privilege: privilege2, organization:) } + let(:privilege_code) { "max" } + + before do + entitlement_value + entitlement_value2 + end + + describe "#call", :premium do + it "returns success" do + expect(result).to be_success + end + + it "soft deletes the specific entitlement value" do + expect { result }.to change(feature.entitlement_values, :count).by(-1) + end + + it "does not delete other entitlement values" do + result + expect(entitlement.values.kept.count).to eq(1) + expect(entitlement.values.kept.first.privilege).to eq(privilege2) + end + + it "returns the entitlement in the result" do + expect(result.entitlement).to eq(entitlement) + expect(result.entitlement.values).to be_loaded + result.entitlement.values.all? do |value| + expect(value.association(:privilege)).to be_loaded + end + expect(result.entitlement.values.ids).to contain_exactly(entitlement_value2.id) + end + + it "sends `plan.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("plan.updated", plan) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(plan) + end + + context "when entitlement is nil" do + subject(:result) { described_class.call(entitlement: nil, privilege_code: privilege_code) } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("entitlement_not_found") + end + end + + context "when privilege code does not exist" do + let(:privilege_code) { "nonexistent" } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("privilege_not_found") + end + end + + context "when entitlement value is already deleted" do + before do + entitlement_value.discard! + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("privilege_not_found") + end + end + end +end diff --git a/spec/services/entitlement/plan_entitlements_update_service-full_spec.rb b/spec/services/entitlement/plan_entitlements_update_service-full_spec.rb new file mode 100644 index 0000000..c4f3c0a --- /dev/null +++ b/spec/services/entitlement/plan_entitlements_update_service-full_spec.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::PlanEntitlementsUpdateService do + subject(:result) { described_class.call(organization:, plan:, entitlements_params:, partial: false) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + let(:entitlements_params) do + { + "seats" => { + "max" => 25 + } + } + end + + before do + feature + privilege + end + + describe "#call", :premium do + it "returns success" do + expect(result).to be_success + end + + it "creates entitlements for the plan" do + expect { result }.to change { plan.entitlements.count }.by(1) + end + + it "creates entitlement values" do + expect { result }.to change(Entitlement::EntitlementValue, :count).by(1) + end + + it "returns the entitlements in the result" do + expect(result.entitlements).to be_present + expect(result.entitlements.count).to eq(1) + end + + it "sends `plan.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("plan.updated", plan) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(plan) + end + + it "creates the entitlement with correct values" do + result + entitlement = plan.entitlements.first + entitlement_value = entitlement.values.first + + expect(entitlement.feature).to eq(feature) + expect(entitlement_value.privilege).to eq(privilege) + expect(entitlement_value.value).to eq("25") + end + + context "when plan has existing entitlements" do + let(:existing_entitlement) { create(:entitlement, organization:, plan:) } + let(:existing_value) { create(:entitlement_value, entitlement: existing_entitlement, privilege:, value: "10", organization:) } + + before do + existing_entitlement + existing_value + end + + it "deletes existing entitlements and their values" do + result + expect(existing_value.reload.deleted_at).to be_present + expect(existing_entitlement.reload.deleted_at).to be_present + end + + it "creates new entitlements" do + result + new_entitlement = plan.entitlements.sole + new_value = new_entitlement.values.sole + + expect(new_entitlement).not_to eq(existing_entitlement) + expect(new_value.value).to eq("25") + end + end + + context "when entitlements_params is empty" do + let(:entitlements_params) { {} } + + it "returns success" do + expect(result).to be_success + end + + it "does not create any entitlements" do + expect { result }.not_to change { plan.entitlements.count } + end + end + + context "when feature has multiple privileges" do + let(:privilege2) { create(:privilege, organization:, feature:, code: "max_admins", value_type: "integer") } + let(:entitlements_params) do + { + "seats" => { + "max" => 25, + "max_admins" => 5 + } + } + end + + before do + privilege2 + end + + it "creates entitlement values for all privileges" do + expect { result }.to change(Entitlement::EntitlementValue, :count).by(2) + end + + it "creates correct values for each privilege" do + result + entitlement = plan.entitlements.first + values = entitlement.values.index_by(&:privilege) + + expect(values[privilege].value).to eq("25") + expect(values[privilege2].value).to eq("5") + end + end + + context "when feature does not exist" do + let(:entitlements_params) do + { + "nonexistent_feature" => { + "max" => 25 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("feature_not_found") + end + end + + context "when privilege does not exist" do + let(:entitlements_params) do + { + "seats" => { + "nonexistent_privilege" => 25 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("privilege_not_found") + end + end + + context "when plan is nil" do + subject(:result) { described_class.call(organization:, plan: nil, entitlements_params:, partial: false) } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "when feature has no privileges in payload" do + let(:entitlements_params) do + { + "seats" => {} + } + end + + it "creates entitlement without values" do + expect { result }.to change { plan.entitlements.count }.by(1).and(not_change(Entitlement::EntitlementValue, :count)) + end + end + + context "when value is boolean" do + let(:privilege) { create(:privilege, organization:, feature:, code: "enabled", value_type: "boolean") } + let(:entitlements_params) do + { + "seats" => { + "enabled" => true + } + } + end + + it "converts boolean to string" do + result + entitlement_value = plan.entitlements.first.values.first + expect(entitlement_value.value).to eq("t") + end + end + + context "when value is string" do + let(:privilege) { create(:privilege, organization:, feature:, code: "provider", value_type: "string") } + let(:entitlements_params) do + { + "seats" => { + "provider" => "okta" + } + } + end + + it "converts string to string" do + result + entitlement_value = plan.entitlements.first.values.first + expect(entitlement_value.value).to eq("okta") + end + end + + context "when privilege value is invalid" do + let(:entitlements_params) do + { + "seats" => { + "max" => [12, 13] + } + } + end + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a BaseService::ValidationFailure + expect(result.error.messages[:max_privilege_value]).to eq(["value_is_invalid"]) + end + end + + context "when privilege value is nil" do + let(:entitlements_params) do + { + "seats" => { + "max" => nil + } + } + end + + it "returns a validation failure with privilege-prefixed errors" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({"privilege.value": ["value_is_mandatory"]}) + end + end + + context "when privilege value is not in select_options" do + let(:privilege) { create(:privilege, organization:, feature:, code: "invitation", value_type: "select", config: {select_options: ["email", "phone", "slack"]}) } + let(:entitlements_params) do + { + "seats" => { + "invitation" => "okta" + } + } + end + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a BaseService::ValidationFailure + expect(result.error.messages[:invitation_privilege_value]).to eq(["value_not_in_select_options"]) + end + end + + context "with bullet gem to detect N+1 queries", bullet: {unused_eager_loading: false} do + context "when updating multiple features with multiple privileges" do + let(:feature) { create(:feature, organization:) } + let(:feature2) { create(:feature, organization:, code: "storage") } + let(:feature3) { create(:feature, organization:, code: "api") } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:privilege2) { create(:privilege, feature:, code: "max_admins", value_type: "integer") } + let(:privilege3) { create(:privilege, organization:, feature: feature2, code: "max_storage", value_type: "integer") } + let(:privilege4) { create(:privilege, organization:, feature: feature2, code: "max_bandwidth", value_type: "integer") } + let(:privilege5) { create(:privilege, organization:, feature: feature3, code: "max_requests", value_type: "integer") } + let(:privilege6) { create(:privilege, organization:, feature: feature3, code: "max_rate_limit", value_type: "integer") } + let(:entitlement2) { create(:entitlement, organization:, plan:, feature: feature2) } + let(:entitlement3) { create(:entitlement, organization:, plan:, feature: feature3) } + let(:entitlement_value2) { create(:entitlement_value, entitlement: entitlement2, privilege: privilege3, organization:, value: "100") } + let(:entitlement_value3) { create(:entitlement_value, entitlement: entitlement3, privilege: privilege5, organization:, value: "1000") } + let(:entitlements_params) do + { + feature.code => { + privilege.code => 60, + privilege2.code => 5 + }, + feature2.code => { + privilege3.code => 200, + privilege4.code => 50 + }, + feature3.code => { + privilege5.code => 2000, + privilege6.code => 100 + } + } + end + + before do + feature2 + feature3 + privilege3 + privilege4 + privilege5 + privilege6 + entitlement2 + entitlement3 + entitlement_value2 + entitlement_value3 + + allow(SendWebhookJob).to receive(:perform_after_commit) + end + + it "does not trigger N+1 queries when updating multiple features and privileges" do + result + + expect(result).to be_success + expect(result.entitlements.count).to eq(3) + end + end + end + end +end diff --git a/spec/services/entitlement/plan_entitlements_update_service-partial_spec.rb b/spec/services/entitlement/plan_entitlements_update_service-partial_spec.rb new file mode 100644 index 0000000..81f01a5 --- /dev/null +++ b/spec/services/entitlement/plan_entitlements_update_service-partial_spec.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::PlanEntitlementsUpdateService do + subject(:result) { described_class.call(organization:, plan:, entitlements_params:, partial: true) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:feature) { create(:feature, organization:) } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:privilege2) { create(:privilege, feature:, code: "max_admins", value_type: "integer") } + let(:entitlement) { create(:entitlement, plan:, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, organization:, value: 10) } + let(:entitlements_params) do + { + feature.code => { + privilege.code => 60 + } + } + end + + before do + privilege + privilege2 + entitlement + entitlement_value + end + + describe "#call", :premium do + it "returns success" do + expect(result).to be_success + end + + it "updates existing entitlement value" do + expect { result }.to change { entitlement_value.reload.value }.from("10").to("60") + end + + it "does not create new entitlement" do + expect { result }.not_to change(Entitlement::Entitlement, :count) + end + + it "does not create new entitlement value" do + expect { result }.not_to change(Entitlement::EntitlementValue, :count) + end + + it "returns entitlements in the result" do + expect(result.entitlements).to include(entitlement) + end + + it "sends `plan.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("plan.updated", plan) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(plan) + end + + context "when privilege value does not exist" do + let(:entitlements_params) do + { + feature.code => { + privilege2.code => 30 + } + } + end + + it "creates new entitlement value" do + expect { result }.to change(Entitlement::EntitlementValue, :count).by(1) + end + + it "creates entitlement value with correct attributes" do + ent_value = result.entitlements.find { it.entitlement_feature_id == feature.id } + .values.find { it.entitlement_privilege_id == privilege2.id } + + expect(ent_value.value).to eq("30") + end + end + + context "when entitlement does not exist" do + let(:new_feature) { create(:feature, organization:) } + let(:new_privilege) { create(:privilege, organization:, feature: new_feature, code: "max_users", value_type: "integer") } + let(:entitlements_params) do + { + new_feature.code => { + new_privilege.code => 50 + } + } + end + + it "creates new entitlement" do + expect { result }.to change(Entitlement::Entitlement, :count).by(1) + end + + it "creates new entitlement value" do + expect { result }.to change(Entitlement::EntitlementValue, :count).by(1) + end + + it "creates entitlement with correct attributes" do + ent_value = result.entitlements.find { it.entitlement_feature_id == new_feature.id } + .values.find { it.entitlement_privilege_id == new_privilege.id } + + expect(ent_value.value).to eq("50") + expect(ent_value.organization).to eq(organization) + expect(ent_value.entitlement.plan).to eq(plan) + expect(ent_value.entitlement.feature).to eq(new_feature) + expect(ent_value.entitlement.organization).to eq(organization) + end + end + + context "when updating multiple features" do + let(:feature2) { create(:feature, organization:) } + let(:privilege3) { create(:privilege, organization:, feature: feature2, code: "max_storage", value_type: "integer") } + let(:entitlement2) { create(:entitlement, organization:, plan:, feature: feature2) } + let(:entitlement_value2) { create(:entitlement_value, entitlement: entitlement2, privilege: privilege3, organization:, value: "100") } + let(:entitlements_params) do + { + feature.code => { + privilege.code => 60 + }, + feature2.code => { + privilege3.code => 200 + } + } + end + + before do + entitlement2 + entitlement_value2 + end + + it "updates both entitlement values" do + result + expect(entitlement_value.reload.value).to eq("60") + expect(entitlement_value2.reload.value).to eq("200") + end + + it "returns both entitlements in the result" do + expect(result.entitlements).to include(entitlement, entitlement2) + end + end + + context "when plan is nil" do + let(:result) { described_class.call(organization:, plan: nil, entitlements_params:, partial: true) } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "when feature does not exist" do + let(:entitlements_params) do + { + "nonexistent_feature" => { + privilege.code => 60 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("feature_not_found") + end + end + + context "when privilege does not exist" do + let(:entitlements_params) do + { + feature.code => { + "nonexistent_privilege" => 60 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("privilege_not_found") + end + end + + context "when value is invalid for select privilege" do + let(:select_privilege) { create(:privilege, organization:, feature:, code: "provider", value_type: "select", config: {"select_options" => ["okta", "ad"]}) } + let(:entitlements_params) do + { + feature.code => { + select_privilege.code => "invalid_option" + } + } + end + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error.messages[:provider_privilege_value]).to eq ["value_not_in_select_options"] + end + end + + context "when entitlements_params is empty" do + let(:entitlements_params) { {} } + + it "returns success" do + expect(result).to be_success + end + + it "does not change any entitlement values" do + expect { result }.not_to change { entitlement_value.reload.value } + end + end + + context "with bullet gem to detect N+1 queries", bullet: {unused_eager_loading: false} do + context "when updating multiple features with multiple privileges" do + let(:feature) { create(:feature, organization:) } + let(:feature2) { create(:feature, organization:, code: "storage") } + let(:feature3) { create(:feature, organization:, code: "api") } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:privilege2) { create(:privilege, feature:, code: "max_admins", value_type: "integer") } + let(:privilege3) { create(:privilege, organization:, feature: feature2, code: "max_storage", value_type: "integer") } + let(:privilege4) { create(:privilege, organization:, feature: feature2, code: "max_bandwidth", value_type: "integer") } + let(:privilege5) { create(:privilege, organization:, feature: feature3, code: "max_requests", value_type: "integer") } + let(:privilege6) { create(:privilege, organization:, feature: feature3, code: "max_rate_limit", value_type: "integer") } + let(:entitlement2) { create(:entitlement, organization:, plan:, feature: feature2) } + let(:entitlement3) { create(:entitlement, organization:, plan:, feature: feature3) } + let(:entitlement_value2) { create(:entitlement_value, entitlement: entitlement2, privilege: privilege3, organization:, value: "100") } + let(:entitlement_value3) { create(:entitlement_value, entitlement: entitlement3, privilege: privilege5, organization:, value: "1000") } + let(:entitlements_params) do + { + feature.code => { + privilege.code => 60, + privilege2.code => 5 + }, + feature2.code => { + privilege3.code => 200, + privilege4.code => 50 + }, + feature3.code => { + privilege5.code => 2000, + privilege6.code => 100 + } + } + end + + before do + feature2 + feature3 + privilege3 + privilege4 + privilege5 + privilege6 + entitlement2 + entitlement3 + entitlement_value2 + entitlement_value3 + + allow(SendWebhookJob).to receive(:perform_after_commit) + end + + it "does not trigger N+1 queries when updating multiple features and privileges" do + result + + expect(result).to be_success + expect(result.entitlements.count).to eq(3) + end + end + end + end +end diff --git a/spec/services/entitlement/privilege_destroy_service_spec.rb b/spec/services/entitlement/privilege_destroy_service_spec.rb new file mode 100644 index 0000000..91b6fff --- /dev/null +++ b/spec/services/entitlement/privilege_destroy_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::PrivilegeDestroyService do + subject { described_class.call(privilege:) } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:privilege) { create(:privilege, feature:, code: "max_admins", value_type: "integer") } + + before do + privilege.reload + end + + describe "#call", :premium do + it "discards the privilege" do + expect { subject }.to change { privilege.reload.discarded? }.from(false).to(true) + end + + it "returns the privilege in the result" do + result = subject + + expect(result).to be_success + expect(result.privilege).to eq(privilege) + end + + it "sends feature.updated webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("feature.updated", feature) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("feature.updated").after_commit.with(feature) + end + + context "when privilege is nil" do + it "returns a not found failure" do + result = described_class.call(privilege: nil) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("privilege") + end + end + + context "when privilege is already discarded" do + before { privilege.discard! } + + it "still succeeds" do + expect { subject }.to raise_error(Discard::RecordNotDiscarded) + end + end + + context "when privilege has entitlements" do + let(:entitlement) { create(:entitlement, feature:) } + let(:entitlement_value) { create(:entitlement_value, entitlement:, privilege:, value: "10") } + + before do + entitlement_value + end + + it "discards all related entitlement values" do + expect { subject }.to change(Entitlement::EntitlementValue, :count).by(-1) + end + + it "sends `plan.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("plan.updated", entitlement.plan) + end + + it "produces plan.updated logs" do + subject + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(entitlement.plan) + end + end + end +end diff --git a/spec/services/entitlement/subscription_entitlement_core_update_service_spec.rb b/spec/services/entitlement/subscription_entitlement_core_update_service_spec.rb new file mode 100644 index 0000000..327bd93 --- /dev/null +++ b/spec/services/entitlement/subscription_entitlement_core_update_service_spec.rb @@ -0,0 +1,283 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionEntitlementCoreUpdateService do + subject(:result) { described_class.call(subscription:, plan:, feature: seats, plan_entitlement:, sub_entitlement:, privilege_params:, partial:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:plan_entitlement) { plan.entitlements.includes(values: :privilege).find_by(feature: seats) } + let(:sub_entitlement) { subscription.entitlements.includes(values: :privilege).find_by(feature: seats) } + + let(:seats) { create(:feature, organization:, code: "seats", name: "Nb users") } + let(:seats_max) { create(:privilege, feature: seats, code: "max", name: "Max", value_type: "integer") } + let(:seats_reset) { create(:privilege, feature: seats, code: "reset", name: "Password Reset", value_type: "boolean") } + let(:seats_signin) { create(:privilege, feature: seats, code: "signin", name: "Sign In", value_type: "select", config: {select_options: ["password", "okta"]}) } + + let(:same_code_feature) { create(:feature, organization: create(:organization), code: "seats", name: "Nb users") } + + before do + seats_reset + seats_max + same_code_feature + end + + def expect_entitlement_to_match(privilege_params) + ent = Entitlement::SubscriptionEntitlement.for_subscription(subscription).find { it.code == "seats" } + ent_values = ent.privileges.map do |priv| + [priv.code, Utils::Entitlement.cast_value(priv.value, priv.value_type)] + end.to_h + expect(ent_values).to eq privilege_params + end + + describe "#call" do + context "when plan has no feature" do + context "when subscription has no feature" do + let(:privilege_params) { {seats_max.code => 100, seats_reset.code => true} } + + context "when partial" do + let(:partial) { true } + + it "adds the feature to the subscription" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params) + sub_ent = subscription.entitlements.includes(values: :privilege).sole + expect(sub_ent.feature).to eq seats + expect(sub_ent.values.count).to eq 2 + expect(sub_ent.values.map { it.privilege.code }).to match_array privilege_params.keys + end + end + + context "when full" do + let(:partial) { false } + + it "adds the feature to the subscription" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params) + sub_ent = subscription.entitlements.includes(values: :privilege).sole + expect(sub_ent.feature).to eq seats + expect(sub_ent.values.count).to eq 2 + expect(sub_ent.values.map { it.privilege.code }).to match_array privilege_params.keys + end + end + end + + context "when subscription already has feature" do + let(:privilege_params) { {seats_max.code => 100, seats_reset.code => true} } + let(:sub_entitlement) { create(:entitlement, subscription:, plan: nil, feature: seats) } + let(:max_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege: seats_max, value: 2) } + let(:reset_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege: seats_reset, value: false) } + let(:signin_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege: seats_signin, value: "okta") } + + before do + max_value + reset_value + signin_value + end + + context "when partial" do + let(:partial) { true } + + it "updates the existing values" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params.merge(seats_signin.code => "okta")) + expect(subscription.entitlements.count).to eq 1 + expect(max_value.reload.value).to eq "100" + expect(reset_value.reload.value).to eq "t" + expect(signin_value.reload.value).to eq "okta" + expect(signin_value).not_to be_discarded + end + end + + context "when full" do + let(:partial) { false } + + it "adds the feature to the subscription" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params) + expect(subscription.entitlements.count).to eq 1 + expect(max_value.reload.value).to eq "100" + expect(reset_value.reload.value).to eq "t" + expect(signin_value.reload).to be_discarded + end + end + end + end + + context "when plan has the feature" do + let(:privilege_params) { {seats_max.code => 100, seats_reset.code => true} } + + let(:plan_entitlement) { create(:entitlement, plan:, feature: seats) } + let(:max_value) { create(:entitlement_value, entitlement: plan_entitlement, privilege: seats_max, value: 2) } + let(:reset_value) { create(:entitlement_value, entitlement: plan_entitlement, privilege: seats_reset, value: false) } + let(:signin_value) { create(:entitlement_value, entitlement: plan_entitlement, privilege: seats_signin, value: "okta") } + + # Create discarded feature and privilege removal + let(:discarded_seats_removal) { create(:subscription_feature_removal, feature: seats, subscription:, deleted_at: 1.day.ago) } + let(:discarded_max_removal) { create(:subscription_feature_removal, privilege: seats_max, subscription:, deleted_at: 1.day.ago) } + let(:discarded_reset_removal) { create(:subscription_feature_removal, privilege: seats_reset, subscription:, deleted_at: 1.day.ago) } + let(:discarded_signin_removal) { create(:subscription_feature_removal, privilege: seats_signin, subscription:, deleted_at: 1.day.ago) } + + before do + max_value + reset_value + signin_value + discarded_seats_removal + discarded_max_removal + discarded_reset_removal + discarded_signin_removal + end + + context "when privilege_params match plan values in a different order" do + let(:privilege_params) { {seats_signin.code => "okta", seats_reset.code => false, seats_max.code => 2} } + + context "when subscription has no overrides" do + let(:partial) { false } + + it "removes subscription overrides and restores plan defaults" do + expect(result).to be_success + expect(subscription.entitlements.count).to eq 0 + end + end + + context "when subscription already has overrides" do + let(:partial) { false } + let(:sub_entitlement) { create(:entitlement, feature: seats, subscription:, plan: nil) } + let(:sub_max_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege: seats_max, value: 50) } + + before { sub_max_value } + + it "discards subscription entitlement and restores plan defaults" do + expect(result).to be_success + expect(sub_entitlement.reload).to be_discarded + expect(sub_max_value.reload).to be_discarded + end + end + end + + context "when subscription has no entitlements" do + context "when partial" do + let(:partial) { true } + + it "adds overrides to the subscription" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params.merge(seats_signin.code => "okta")) + sub_ent = subscription.entitlements.includes(values: :privilege).sole + expect(sub_ent.values.map(&:value)).to contain_exactly("100", "t") + expect(sub_ent.values.map { it.privilege.code }).to contain_exactly(seats_max.code, seats_reset.code) + end + end + + context "when full" do + let(:partial) { false } + + it "adds overrides to the subscription and create a privilege removal" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params) + sub_ent = subscription.entitlements.includes(values: :privilege).sole + expect(sub_ent.values.map(&:value)).to contain_exactly("100", "t") + expect(sub_ent.values.map { it.privilege.code }).to contain_exactly(seats_max.code, seats_reset.code) + + expect(subscription.entitlement_removals.count).to eq 1 + expect(subscription.entitlement_removals.where(privilege: seats_signin)).to exist + end + end + end + + context "when subscription already has overrides" do + let(:sub_entitlement) { create(:entitlement, feature: seats, subscription:, plan: nil) } + let(:sub_max_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege: seats_max, value: 50) } + let(:sub_signin_value) { create(:entitlement_value, entitlement: sub_entitlement, privilege: seats_signin, value: "password") } + + before do + sub_max_value + sub_signin_value + end + + context "when partial" do + let(:partial) { true } + + it "adds overrides to the subscription" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params.merge(seats_signin.code => "password")) + expect(subscription.entitlements.count).to eq 1 + expect(sub_max_value.reload.value).to eq "100" + expect(sub_entitlement.values.map(&:value)).to contain_exactly("100", "t", "password") + expect(sub_entitlement.values.map { it.privilege.code }).to contain_exactly(seats_max.code, seats_reset.code, seats_signin.code) + end + end + + context "when full" do + let(:partial) { false } + + it "adds overrides to the subscription and create a privilege removal" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params) + sub_ent = subscription.entitlements.includes(values: :privilege).sole + expect(sub_ent.values.map(&:value)).to contain_exactly("100", "t") + expect(sub_ent.values.map { it.privilege.code }).to contain_exactly(seats_max.code, seats_reset.code) + + expect(subscription.entitlement_removals.count).to eq 1 + expect(subscription.entitlement_removals.where(privilege: seats_signin)).to exist + expect(sub_signin_value.reload).to be_discarded + end + end + end + + context "when subscription has privilege removals" do + let(:max_removal) { create(:subscription_feature_removal, privilege: seats_max, subscription:) } + let(:reset_removal) { create(:subscription_feature_removal, privilege: seats_reset, subscription:) } + let(:signin_removal) { create(:subscription_feature_removal, privilege: seats_signin, subscription:) } + + before do + max_removal + reset_removal + signin_removal + end + + context "when partial" do + let(:partial) { true } + + it "adds overrides to the subscription" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params) + expect(subscription.entitlements.count).to eq 1 + + sub_entitlement = subscription.entitlements.includes(values: :privilege).sole + expect(sub_entitlement.values.map(&:value)).to contain_exactly("100", "t") + expect(sub_entitlement.values.map { it.privilege.code }).to contain_exactly(seats_max.code, seats_reset.code) + + expect(max_removal.reload).to be_discarded + expect(reset_removal.reload).to be_discarded + + # QUESTION: Feature is not in plan, should this be cleaned up? + expect(signin_removal.reload).not_to be_discarded + end + end + + context "when full" do + let(:partial) { false } + + it "adds overrides to the subscription" do + expect(result).to be_success + expect_entitlement_to_match(privilege_params) + expect(subscription.entitlements.count).to eq 1 + + sub_entitlement = subscription.entitlements.includes(values: :privilege).sole + expect(sub_entitlement.values.map(&:value)).to contain_exactly("100", "t") + expect(sub_entitlement.values.map { it.privilege.code }).to contain_exactly(seats_max.code, seats_reset.code) + + expect(max_removal.reload).to be_discarded + expect(reset_removal.reload).to be_discarded + + # QUESTION: Feature is not in plan, should this be cleaned up? + expect(signin_removal.reload).not_to be_discarded + end + end + end + end + end +end diff --git a/spec/services/entitlement/subscription_entitlement_update_service_spec.rb b/spec/services/entitlement/subscription_entitlement_update_service_spec.rb new file mode 100644 index 0000000..d8b51be --- /dev/null +++ b/spec/services/entitlement/subscription_entitlement_update_service_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionEntitlementUpdateService do + subject(:result) do + described_class.call( + subscription:, + feature_code: feature_code, + privilege_params: privilege_params, + partial: partial + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + + let(:feature_code) { feature.code } + let(:privilege_params) { {"max" => 25} } + let(:partial) { false } + + before do + feature + privilege + end + + describe "#call", :premium do + context "when successful" do + let(:plan_entitlement) { create(:entitlement, organization:, plan:, feature: feature) } + + before do + plan_entitlement + + allow(Entitlement::SubscriptionEntitlementCoreUpdateService).to receive(:call!).and_return(true) + end + + it "calls the inner service with expected arguments" do + result + + expect(Entitlement::SubscriptionEntitlementCoreUpdateService).to have_received(:call!).with( + subscription: subscription, + plan: plan, # no parent plan created here, so it's the subscription plan + feature: feature, + plan_entitlement:, + sub_entitlement: nil, + privilege_params: privilege_params.with_indifferent_access, + partial: false + ) + end + + it "sends `subscription.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("subscription.updated", subscription) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("subscription.updated").after_commit.with(subscription) + end + + it "returns success" do + expect(result).to be_success + end + end + + context "when subscription is nil" do + let(:subscription) { nil } + + it "returns not found failure for subscription" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when feature is not found" do + let(:feature_code) { "nonexistent_feature" } + + it "returns not found failure for feature and does not call inner service" do + allow(Entitlement::SubscriptionEntitlementCoreUpdateService).to receive(:call!).and_return(true) + expect(result).not_to be_success + expect(result.error.error_code).to eq("feature_not_found") + expect(Entitlement::SubscriptionEntitlementCoreUpdateService).not_to have_received(:call!) + end + end + + context "when privilege is not found" do + before do + allow(Entitlement::SubscriptionEntitlementCoreUpdateService).to receive(:call!).and_raise( + ActiveRecord::RecordNotFound.new("Couldn't find Entitlement::Privilege") + ) + end + + it "returns not found failure for privilege" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("privilege_not_found") + end + end + + context "when value is invalid" do + let(:privilege_params) do + { + "max" => "invalid" + } + end + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error.messages).to eq({max_privilege_value: ["value_is_invalid"]}) + end + end + end +end diff --git a/spec/services/entitlement/subscription_entitlements_update_service-full_spec.rb b/spec/services/entitlement/subscription_entitlements_update_service-full_spec.rb new file mode 100644 index 0000000..0e6b624 --- /dev/null +++ b/spec/services/entitlement/subscription_entitlements_update_service-full_spec.rb @@ -0,0 +1,368 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionEntitlementsUpdateService do + subject(:result) { described_class.call(subscription:, entitlements_params:, partial: false) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + let(:entitlements_params) do + { + "seats" => { + "max" => 25 + } + } + end + + let(:feature2) { create(:feature, code: "storage", organization:) } + let(:privilege2) { create(:privilege, feature: feature2, code: "limit", value_type: "integer") } + let(:privilege3) { create(:privilege, feature: feature2, code: "allow_overage", value_type: "boolean") } + + before do + feature + privilege + end + + describe "#call", :premium do + it "returns success" do + expect(result).to be_success + end + + it "creates entitlements for the subscription" do + expect { result }.to change { subscription.entitlements.count }.by(1) + end + + it "creates entitlement values" do + expect { result }.to change(Entitlement::EntitlementValue, :count).by(1) + end + + it "sends `subscription.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("subscription.updated", subscription) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("subscription.updated").after_commit.with(subscription) + end + + it "creates the entitlement with correct values" do + result + entitlement = subscription.entitlements.first + entitlement_value = entitlement.values.first + + expect(entitlement.feature).to eq(feature) + expect(entitlement_value.privilege).to eq(privilege) + expect(entitlement_value.value).to eq("25") + end + + context "when plan already has the feature" do + let(:existing_entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:existing_value) { create(:entitlement_value, entitlement: existing_entitlement, privilege:, value: "10", organization:) } + + it "creates an override" do + result + + expect(result).to be_success + expect(existing_value.value).to eq "10" + expect(subscription.entitlements.sole.values.sole.value).to eq("25") + end + end + + context "when plan already has the same feature and privilege values" do + let(:existing_entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:existing_value) { create(:entitlement_value, entitlement: existing_entitlement, privilege:, value: "25", organization:) } + + before { existing_value } + + it "does not create an override" do + result + + expect(result).to be_success + expect(existing_value.value).to eq "25" + expect(subscription.entitlements.reload).to be_empty + end + + context "when there is an override with a different value" do + let(:existing_override_entitlement) { create(:entitlement, organization:, plan: nil, subscription:, feature:) } + let(:existing_override_value) { create(:entitlement_value, entitlement: existing_override_entitlement, privilege:, value: "3453453", organization:) } + + it "removes the subscription override" do + existing_override_value + result + + expect(existing_override_entitlement.reload.deleted_at).to be_present + expect(existing_override_value.reload.deleted_at).to be_present + expect(subscription.entitlements.reload).to be_empty + + final_ent = Entitlement::SubscriptionEntitlement.for_subscription(subscription).find { it.code == "seats" } + priv = final_ent.privileges.find { it.code == "max" } + expect(priv.plan_value).to eq("25") + expect(priv.subscription_value).to be_nil + end + end + + context "when feature was removed" do + let(:removal) { create(:subscription_feature_removal, feature: existing_entitlement.feature, subscription:) } + + before { removal } + + it "removes the feature removal" do + result + + expect(removal.reload.deleted_at).to be_present + expect(subscription.entitlements.reload).to be_empty + + final_ent = Entitlement::SubscriptionEntitlement.for_subscription(subscription).find { it.code == "seats" } + priv = final_ent.privileges.find { it.code == "max" } + expect(priv.plan_value).to eq("25") + expect(priv.subscription_value).to be_nil + end + + context "when the value is different from plan" do + let(:entitlements_params) do + { + "seats" => { + "max" => 3 + } + } + end + + it "restore the feature and create an override" do + result + + expect(removal.reload.deleted_at).to be_present + expect(subscription.entitlements.reload.sole.values.sole.value).to eq "3" + end + end + end + end + + context "when subscription has existing entitlements" do + let(:existing_entitlement) { create(:entitlement, organization:, subscription_id: subscription.id, plan: nil, feature:) } + let(:existing_value) { create(:entitlement_value, entitlement: existing_entitlement, privilege:, value: "10", organization:) } + + before do + existing_value + end + + it "replaces existing entitlements" do + result + + expect(result).to be_success + expect(existing_value.reload.value).to eq("25") + end + end + + context "when plan has a feature but it's not part of the params anymore" do + let(:existing_entitlement) { create(:entitlement, organization:, plan:, feature:) } + let(:existing_value) { create(:entitlement_value, entitlement: existing_entitlement, privilege:, value: "10", organization:) } + + let(:entitlements_params) do + { + feature2.code => { + privilege3.code => false + } + } + end + + before do + existing_value + end + + it "creates a SuscriptionFeatureRemoval" do + result + expect(result).to be_success + expect(existing_entitlement.reload.deleted_at).to be_nil + expect(existing_value.reload.deleted_at).to be_nil + expect(Entitlement::SubscriptionFeatureRemoval.where(feature:, subscription:).count).to eq(1) + + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription).map(&:code)).to eq([feature2.code]) + end + end + + context "when subscription has an extra feature but it's not part of the params anymore" do + let(:existing_entitlement) { create(:entitlement, organization:, plan: nil, subscription:, feature:) } + let(:existing_value) { create(:entitlement_value, entitlement: existing_entitlement, privilege:, value: "10", organization:) } + + let(:entitlements_params) do + { + feature2.code => { + privilege3.code => false + } + } + end + + before do + existing_value + end + + it "removes the override" do + result + expect(result).to be_success + expect(existing_entitlement.reload.deleted_at).to be_present + expect(existing_value.reload.deleted_at).to be_present + expect(Entitlement::SubscriptionFeatureRemoval.where(feature:, subscription:).count).to eq(0) + + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription).map(&:code)).to eq([feature2.code]) + end + end + + context "when subscription has a feature override but one privilege is missing" do + let(:entitlement) { create(:entitlement, feature: feature2, plan: nil, subscription:) } + let(:entitlement_value2) { create(:entitlement_value, entitlement:, privilege: privilege2, value: "100") } + let(:entitlement_value3) { create(:entitlement_value, entitlement:, privilege: privilege3, value: true) } + + let(:entitlements_params) do + { + feature2.code => { + privilege3.code => false + } + } + end + + before do + entitlement_value2 + entitlement_value3 + end + + it "removes the privilege value" do + result + expect(entitlement_value2.reload.deleted_at).to be_present + expect(entitlement_value3.reload.value).to eq("f") + end + end + + context "when subscription has a feature from plan but one privilege is missing" do + let(:entitlement) { create(:entitlement, feature: feature2, plan:) } + let(:entitlement_value2) { create(:entitlement_value, entitlement:, privilege: privilege2, value: "100") } + let(:entitlement_value3) { create(:entitlement_value, entitlement:, privilege: privilege3, value: true) } + + let(:entitlements_params) do + { + feature2.code => { + privilege3.code => false + } + } + end + + before do + entitlement_value2 + entitlement_value3 + end + + it "creates a privilege removal" do + expect(subscription.entitlements.where(feature: feature2)).not_to exist + result + expect(subscription.entitlement_removals.where(privilege: privilege2)).to exist + + sub_ent = subscription.entitlements.where(feature: feature2).sole + expect(sub_ent.values.sole.value).to eq("f") + end + end + + context "when plan has a feature with privilege but subscriptions has privilege removals" do + let(:entitlement) { create(:entitlement, feature: feature2, plan:) } + let(:entitlement_value2) { create(:entitlement_value, entitlement:, privilege: privilege2, value: "100") } + let(:entitlement_value3) { create(:entitlement_value, entitlement:, privilege: privilege3, value: true) } + + let(:privilege2_removal) { create(:subscription_feature_removal, subscription:, privilege: privilege2) } + let(:privilege3_removal) { create(:subscription_feature_removal, subscription:, privilege: privilege3) } + + before do + entitlement_value2 + entitlement_value3 + privilege2_removal + privilege3_removal + end + + context "when the entire feature is removed" do + let(:entitlements_params) { {} } + + it "discard the privilege removals and add the feature removal" do + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription).sole.privileges).to be_empty + result + expect(Entitlement::SubscriptionEntitlement.for_subscription(subscription)).to be_empty + expect(subscription.entitlements).to be_empty + expect(privilege2_removal.reload).to be_discarded + expect(privilege3_removal.reload).to be_discarded + expect(subscription.entitlement_removals.where(feature: feature2)).to exist + end + end + end + + context "when subscription does not exist" do + let(:subscription) { nil } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when feature does not exist" do + let(:entitlements_params) do + { + "nonexistent_feature" => { + "max" => 25 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("feature_not_found") + end + end + + context "when privilege does not exist" do + let(:entitlements_params) do + { + "seats" => { + "nonexistent_privilege" => 25 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("privilege_not_found") + end + end + + context "when privilege value is nil" do + let(:entitlements_params) do + { + "seats" => { + "max" => nil + } + } + end + + it "returns a validation failure with privilege-prefixed errors" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({"privilege.value": ["value_is_mandatory"]}) + end + end + + context "when value is invalid" do + let(:entitlements_params) do + { + "seats" => { + "max" => "invalid" + } + } + end + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error.messages).to eq({max_privilege_value: ["value_is_invalid"]}) + end + end + end +end diff --git a/spec/services/entitlement/subscription_entitlements_update_service-partial_spec.rb b/spec/services/entitlement/subscription_entitlements_update_service-partial_spec.rb new file mode 100644 index 0000000..b1983bc --- /dev/null +++ b/spec/services/entitlement/subscription_entitlements_update_service-partial_spec.rb @@ -0,0 +1,122 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionEntitlementsUpdateService do + subject(:result) { described_class.call(subscription:, entitlements_params:, partial: true) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:feature) { create(:feature, organization:, code: "seats") } + let(:privilege) { create(:privilege, organization:, feature:, code: "max", value_type: "integer") } + let(:entitlements_params) do + { + "seats" => { + "max" => 25 + } + } + end + + let(:feature2) { create(:feature, code: "storage", organization:) } + let(:privilege2) { create(:privilege, feature: feature2, code: "limit", value_type: "integer") } + let(:privilege3) { create(:privilege, feature: feature2, code: "allow_overage", value_type: "boolean") } + let(:entitlement) { create(:entitlement, feature:, plan:) } + let(:entitlement_value2) { create(:entitlement_value, entitlement:, privilege: privilege2, value: "100") } + let(:entitlement_value3) { create(:entitlement_value, entitlement:, privilege: privilege3, value: true) } + + before do + feature + privilege + entitlement_value2 + entitlement_value3 + end + + describe "#call", :premium do + it "returns success" do + expect(result).to be_success + end + + it "creates entitlements for the subscription" do + expect { result }.to change { subscription.entitlements.count }.by(1) + end + + it "creates entitlement values" do + expect { result }.to change(Entitlement::EntitlementValue, :count).by(1) + end + + it "sends `subscription.updated` webhook" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("subscription.updated", subscription) + end + + it "produces an activity log" do + subject + expect(Utils::ActivityLog).to have_produced("subscription.updated").after_commit.with(subscription) + end + + it "creates the entitlement with correct values and leave existing untouched" do + result + + expect(entitlement.reload.values.map(&:value)).to contain_exactly("100", "t") + new_entitlement = subscription.entitlements.order(:created_at).last + expect(new_entitlement.feature).to eq(feature) + expect(new_entitlement.values.sole.privilege).to eq privilege + expect(new_entitlement.values.sole.value).to eq "25" + end + + context "when subscription does not exist" do + let(:subscription) { nil } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when feature does not exist" do + let(:entitlements_params) do + { + "nonexistent_feature" => { + "max" => 25 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("feature_not_found") + end + end + + context "when privilege does not exist" do + let(:entitlements_params) do + { + "seats" => { + "nonexistent_privilege" => 25 + } + } + end + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("privilege_not_found") + end + end + + context "when value is invalid" do + let(:entitlements_params) do + { + "seats" => { + "max" => "invalid" + } + } + end + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error.messages).to eq({max_privilege_value: ["value_is_invalid"]}) + end + end + end +end diff --git a/spec/services/entitlement/subscription_feature_privilege_remove_service_spec.rb b/spec/services/entitlement/subscription_feature_privilege_remove_service_spec.rb new file mode 100644 index 0000000..35020ff --- /dev/null +++ b/spec/services/entitlement/subscription_feature_privilege_remove_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionFeaturePrivilegeRemoveService do + subject(:result) { described_class.call(subscription:, feature_code:, privilege_code:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:feature) { create(:feature, organization:, code: "test_feature") } + let(:privilege) { create(:privilege, feature:, code: "max") } + let(:feature_code) { feature.code } + let(:privilege_code) { privilege.code } + + describe "#call", :premium do + context "when subscription is nil" do + let(:subscription) { nil } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("subscription") + end + end + + context "when feature is not found" do + let(:feature_code) { "nonexistent_feature" } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("feature") + end + end + + context "when privilege is not found" do + let(:privilege_code) { "nonexistent_privilege" } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("privilege") + end + end + + context "when privilege is not on subscription or plan" do + before { privilege } + + it "succeeds and returns feature code" do + expect(result).to be_success + expect(result.feature_code).to eq(feature_code) + expect(result.privilege_code).to eq(privilege_code) + end + + it "does not create subscription feature removal" do + expect { result }.not_to change(subscription.entitlement_removals, :count) + end + + it "sends webhook" do + expect { result }.to have_enqueued_job_after_commit(SendWebhookJob).with("subscription.updated", subscription) + end + + it "produces an activity log" do + result + expect(Utils::ActivityLog).to have_produced("subscription.updated").after_commit.with(subscription) + end + end + + context "when privilege is on plan but not on subscription" do + let(:plan_entitlement) { create(:entitlement, feature:, plan:) } + let(:plan_value) { create(:entitlement_value, entitlement: plan_entitlement, value: 100, privilege:) } + + before { plan_value } + + it "creates a subscription privilege removal" do + expect { result }.to change(subscription.entitlement_removals.where(privilege:), :count).from(0).to(1) + + expect(result).to be_success + + removal = subscription.entitlement_removals.sole + expect(removal.organization).to eq(organization) + expect(removal.privilege).to eq(privilege) + end + + context "when privilege is already removed" do + it "succeeds" do + create(:subscription_feature_removal, subscription:, privilege:) + expect { result }.not_to change(subscription.entitlement_removals.where(privilege:), :count) + expect(result).to be_success + end + end + end + + context "when privilege is on subscription" do + let(:subscription_entitlement) { create(:entitlement, feature:, subscription:, plan: nil) } + let(:entitlement_value) { create(:entitlement_value, entitlement: subscription_entitlement, privilege:, value: "10") } + + before do + entitlement_value + end + + it "succeeds and returns feature code" do + expect(result).to be_success + expect(result.feature_code).to eq(feature_code) + expect(result.privilege_code).to eq(privilege_code) + end + + it "soft deletes the entitlement values" do + expect { result }.to change { entitlement_value.reload.deleted_at }.from(nil) + end + + it "does not discards the subscription entitlement" do + expect { result }.not_to change { subscription_entitlement.reload.discarded? } + end + end + end +end diff --git a/spec/services/entitlement/subscription_feature_remove_service_spec.rb b/spec/services/entitlement/subscription_feature_remove_service_spec.rb new file mode 100644 index 0000000..e994422 --- /dev/null +++ b/spec/services/entitlement/subscription_feature_remove_service_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Entitlement::SubscriptionFeatureRemoveService do + subject(:result) { described_class.call(subscription:, feature_code:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:feature) { create(:feature, organization:, code: "test_feature") } + let(:feature_code) { feature.code } + + describe "#call", :premium do + context "when subscription is nil" do + let(:subscription) { nil } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("subscription") + end + end + + context "when feature is not found" do + let(:feature_code) { "nonexistent_feature" } + + it "returns not found failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("feature") + end + end + + context "when feature is not on subscription or plan" do + before { feature } + + it "succeeds and returns feature code" do + expect(result).to be_success + expect(result.feature_code).to eq(feature_code) + end + + it "does not create subscription feature removal" do + expect { result }.not_to change(Entitlement::SubscriptionFeatureRemoval, :count) + end + + it "sends webhook" do + expect { result }.to have_enqueued_job_after_commit(SendWebhookJob).with("subscription.updated", subscription) + end + + it "produces an activity log" do + result + expect(Utils::ActivityLog).to have_produced("subscription.updated").after_commit.with(subscription) + end + end + + context "when feature is on plan but not on subscription" do + let(:plan_entitlement) { create(:entitlement, feature:, plan:) } + + before { plan_entitlement } + + it "creates a subscription feature removal" do + expect { result }.to change(organization.subscription_feature_removals, :count).by(1) + + expect(result).to be_success + + removal = Entitlement::SubscriptionFeatureRemoval.last + expect(removal.organization).to eq(organization) + expect(removal.subscription).to eq(subscription) + expect(removal.feature).to eq(feature) + end + + context "when the feature is already removed" do + it "succeeds" do + create(:subscription_feature_removal, subscription:, feature:) + expect { result }.not_to change(subscription.entitlement_removals.where(feature:), :count) + expect(result).to be_success + end + end + end + + context "when feature is on subscription" do + let(:subscription_entitlement) { create(:entitlement, feature:, subscription:, plan: nil) } + let(:privilege) { create(:privilege, feature:, code: "max", value_type: "integer") } + let(:entitlement_value) { create(:entitlement_value, entitlement: subscription_entitlement, privilege:, value: "10") } + + before do + entitlement_value + subscription_entitlement.reload + end + + it "succeeds and returns feature code" do + expect(result).to be_success + expect(result.feature_code).to eq(feature_code) + end + + it "soft deletes the entitlement values" do + expect { result }.to change { entitlement_value.reload.deleted_at }.from(nil) + end + + it "discards the subscription entitlement" do + expect { result }.to change { subscription_entitlement.reload.discarded? }.from(false).to(true) + end + + context "when feature is also on plan" do + let(:privilege2) { create(:privilege, feature:, code: "max_admin", value_type: "integer") } + let(:plan_entitlement) { create(:entitlement, feature:, plan:) } + let(:plan_entitlement_value2) { create(:entitlement_value, privilege: privilege2, entitlement: plan_entitlement, value: "10") } + let(:privilege_removal) { create(:subscription_feature_removal, privilege: privilege2, subscription:) } + + before do + plan_entitlement_value2 + privilege_removal + end + + it "creates a subscription feature removal" do + expect { result }.to change(organization.subscription_feature_removals.where(feature:), :count).by(1) + + removal = organization.subscription_feature_removals.order(created_at: :desc).first + expect(removal.organization).to eq(organization) + expect(removal.subscription).to eq(subscription) + expect(removal.feature).to eq(feature) + end + + it "soft deletes the entitlement values and discards entitlement" do + expect { result }.to change { entitlement_value.reload.deleted_at }.from(nil) + .and change { subscription_entitlement.reload.discarded? }.from(false).to(true) + end + + it "cleans up existing privilege removals" do + expect { result }.to change { privilege_removal.reload.discarded? }.from(false).to(true) + end + end + + context "when plan has parent plan with the feature" do + let(:parent_plan) { create(:plan, organization:) } + let(:child_plan) { create(:plan, organization:, parent: parent_plan) } + let(:subscription) { create(:subscription, organization:, customer:, plan: child_plan) } + let(:parent_entitlement) { create(:entitlement, feature:, plan: parent_plan) } + let(:subscription_entitlement) { create(:entitlement, feature:, subscription:, plan: nil) } + + before do + parent_entitlement + entitlement_value + subscription_entitlement.reload + end + + it "creates a subscription feature removal" do + expect { result }.to change(Entitlement::SubscriptionFeatureRemoval, :count).by(1) + + removal = Entitlement::SubscriptionFeatureRemoval.last + expect(removal.organization).to eq(organization) + expect(removal.subscription).to eq(subscription) + expect(removal.feature).to eq(feature) + end + end + end + end +end diff --git a/spec/services/error_details/create_service_spec.rb b/spec/services/error_details/create_service_spec.rb new file mode 100644 index 0000000..693f706 --- /dev/null +++ b/spec/services/error_details/create_service_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ErrorDetails::CreateService do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:owner) { create(:invoice, organization:, customer:) } + + describe "#call" do + subject(:service_call) { described_class.call(params:, owner:, organization:) } + + let(:params) do + { + error_code: "tax_error", + details: {"tax_error" => "taxDateTooFarInFuture"} + } + end + + context "when created successfully" do + context "when all - owner and organization are provided" do + it "creates an error_detail" do + expect { service_call }.to change(ErrorDetail, :count).by(1) + end + + it "returns created error_detail" do + result = service_call + + expect(result).to be_success + expect(result.error_details.owner_id).to eq(owner.id) + expect(result.error_details.owner_type).to eq(owner.class.to_s) + expect(result.error_details.organization_id).to eq(organization.id) + expect(result.error_details.details).to eq(params[:details]) + end + end + end + + context "when not created successfully" do + context "when no owner is provided" do + subject(:service_call) { described_class.call(params:, organization:, owner: nil) } + + it "does not create an error_detail" do + expect { service_call }.not_to change(ErrorDetail, :count) + end + + it "returns error for error_detail" do + result = service_call + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to include("owner_not_found") + end + end + + context "when error code is not registered in enum" do + subject(:service_call) { described_class.call(params:, owner:, organization:) } + + let(:params) do + { + error_code: "this_error_code_will_never_achieve_its_goal", + details: {"this_error_code_will_never_achieve_its_goal" => "does not matter what we send here"} + } + end + + it "does not create an error_detail" do + expect { service_call }.not_to change(ErrorDetail, :count) + end + + it "returns error for error_detail" do + result = service_call + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.message).to eq('Validation errors: {"error_code":["value_is_invalid"]}') + end + end + end + end +end diff --git a/spec/services/events/billing_period_filter_service_spec.rb b/spec/services/events/billing_period_filter_service_spec.rb new file mode 100644 index 0000000..b854b0d --- /dev/null +++ b/spec/services/events/billing_period_filter_service_spec.rb @@ -0,0 +1,1113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::BillingPeriodFilterService do + subject(:filter_service) { described_class.new(subscription:, boundaries:) } + + shared_examples "recurring billable metric filtering" do + let(:recurring_billable_metric) { create(:sum_billable_metric, :recurring, organization:) } + let(:recurring_charge) { create(:standard_charge, plan:, billable_metric: recurring_billable_metric) } + + let(:charge_filter) { create(:charge_filter, charge: recurring_charge) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric: recurring_billable_metric, key: "region", values: ["eu", "us"]) } + + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["eu"]) + end + + before do + recurring_charge + charge_filter_value + end + + context "when it is the first billing period" do + let(:started_at) { boundaries.charges_from_datetime } + + it "returns empty hash" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "when previous fees exist" do + let(:fee) { create(:charge_fee, subscription:, charge: recurring_charge, charge_filter:, units: 2.4) } + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice: fee.invoice, + subscription:, + organization:, + charges_from_datetime: boundaries.charges_from_datetime - 1.month + ) + end + + before { invoice_subscription } + + it "returns only charge/filter pairs from previous fees" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({recurring_charge.id => [charge_filter.id]}) + end + end + + context "when no previous fees exist" do + let(:invoice) { create(:invoice, organization:) } + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + organization:, + charges_from_datetime: boundaries.charges_from_datetime - 1.month + ) + end + + before { invoice_subscription } + + it "returns empty hash" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "when previous fees exist and have no units" do + let(:invoice) { create(:invoice, organization:) } + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + organization:, + charges_from_datetime: boundaries.charges_from_datetime - 1.month + ) + end + + let(:fee) { create(:charge_fee, subscription:, charge: recurring_charge, charge_filter:, units: 0, invoice:) } + + before { invoice_subscription } + + it "returns empty hash" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "when subscription has previous_subscription_id" do + let(:old_plan) { create(:plan, organization:) } + let(:previous_subscription) do + create(:subscription, :terminated, organization:, customer:, plan: old_plan, external_id: "sub_id", started_at: started_at - 1.month) + end + let(:subscription) do + create( + :subscription, + organization:, + customer:, + plan:, + started_at:, + subscription_at: started_at, + external_id: "sub_id", + previous_subscription: previous_subscription + ) + end + + context "when no filters on either side" do + let(:charge_filter) { nil } + let(:charge_filter_value) { nil } + let(:recurring_charge) { create(:standard_charge, plan:, billable_metric: recurring_billable_metric) } + let(:old_charge) { create(:standard_charge, plan: old_plan, billable_metric: recurring_billable_metric) } + + before do + create( + :charge_fee, + subscription: previous_subscription, + charge: old_charge, + charge_filter_id: nil, + created_at: started_at - 1.day, + units: 2.4 + ) + end + + it "returns current charge with nil filter" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({recurring_charge.id => [nil]}) + end + end + + context "when old has filters but current does not" do + let(:charge_filter) { nil } + let(:charge_filter_value) { nil } + let(:recurring_charge) { create(:standard_charge, plan:, billable_metric: recurring_billable_metric) } + let(:old_charge) { create(:standard_charge, plan: old_plan, billable_metric: recurring_billable_metric) } + let(:old_filter) { create(:charge_filter, charge: old_charge) } + + before do + create( + :charge_fee, + subscription: previous_subscription, + charge: old_charge, + charge_filter: old_filter, + created_at: started_at - 1.day, + units: 2.4 + ) + end + + it "returns current charge with nil filter" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({recurring_charge.id => [nil]}) + end + end + + context "when old has filters with no units and current does not" do + let(:charge_filter) { nil } + let(:charge_filter_value) { nil } + let(:recurring_charge) { create(:standard_charge, plan:, billable_metric: recurring_billable_metric) } + let(:old_charge) { create(:standard_charge, plan: old_plan, billable_metric: recurring_billable_metric) } + let(:old_filter) { create(:charge_filter, charge: old_charge) } + + before do + create( + :charge_fee, + subscription: previous_subscription, + charge: old_charge, + charge_filter: old_filter, + created_at: started_at - 1.day, + units: 0 + ) + end + + it "returns empty hash" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "when old has no filters but current has filters" do + let(:old_charge) { create(:standard_charge, plan: old_plan, billable_metric: recurring_billable_metric) } + + before do + create( + :charge_fee, + subscription: previous_subscription, + charge: old_charge, + charge_filter_id: nil, + created_at: started_at - 1.day, + units: 2.4 + ) + end + + it "returns all current filter IDs plus nil" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({recurring_charge.id => contain_exactly(charge_filter.id, nil)}) + end + end + + context "when both have filters" do + let(:old_charge) { create(:standard_charge, plan: old_plan, billable_metric: recurring_billable_metric) } + let(:old_filter) { create(:charge_filter, charge: old_charge) } + + before do + create( + :charge_fee, + subscription: previous_subscription, + charge: old_charge, + charge_filter: old_filter, + created_at: started_at - 1.day, + units: 2.4 + ) + end + + it "returns all current filter IDs plus nil" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({recurring_charge.id => contain_exactly(charge_filter.id, nil)}) + end + end + + context "when traversing a chain of subscriptions" do + let(:oldest_plan) { create(:plan, organization:) } + let(:oldest_subscription) do + create(:subscription, :terminated, organization:, customer:, plan: oldest_plan, external_id: "sub_id", started_at: started_at - 2.months) + end + let(:previous_subscription) do + create(:subscription, :terminated, organization:, customer:, plan: old_plan, external_id: "sub_id", started_at: started_at - 1.month, previous_subscription: oldest_subscription) + end + let(:oldest_charge) { create(:standard_charge, plan: oldest_plan, billable_metric: recurring_billable_metric) } + + let(:fee) do + create( + :charge_fee, + subscription: oldest_subscription, + charge: oldest_charge, + charge_filter_id: nil, + created_at: started_at - 2.months + 1.day, + units: 2.4 + ) + end + + before { fee } + + it "picks up fees from the entire chain" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({recurring_charge.id => contain_exactly(charge_filter.id, nil)}) + end + + context "when previous fees have no units" do + let(:fee) do + create( + :charge_fee, + subscription: oldest_subscription, + charge: oldest_charge, + charge_filter_id: nil, + created_at: started_at - 2.months + 1.day, + units: 0 + ) + end + + it "returns empty hash" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + end + + context "when no previous fees exist for recurring BMs" do + it "returns empty hash" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "when previous fees include both nil and non-nil charge_filter_id" do + let(:old_charge) { create(:standard_charge, plan: old_plan, billable_metric: recurring_billable_metric) } + let(:old_filter) { create(:charge_filter, charge: old_charge) } + + before do + create( + :charge_fee, + subscription: previous_subscription, + charge: old_charge, + charge_filter: old_filter, + created_at: started_at - 1.day, + units: 2.4 + ) + create( + :charge_fee, + subscription: previous_subscription, + charge: old_charge, + charge_filter_id: nil, + created_at: started_at - 1.day, + units: 2.4 + ) + end + + it "returns all current filter IDs plus nil" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({recurring_charge.id => contain_exactly(charge_filter.id, nil)}) + end + end + end + + context "when previous fee has a discarded charge_filter" do + let(:fee) { create(:charge_fee, subscription:, charge: recurring_charge, charge_filter:) } + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice: fee.invoice, + subscription:, + organization:, + charges_from_datetime: boundaries.charges_from_datetime - 1.month + ) + end + + before do + invoice_subscription + charge_filter.discard! + end + + it "excludes the discarded filter from results" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:subscription) do + create( + :subscription, + organization:, + customer:, + plan:, + started_at:, + subscription_at: started_at, + external_id: "sub_id" + ) + end + + let(:started_at) { Time.zone.parse("2022-01-01 00:01") } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:charge_filter) { nil } + let(:charge_filter_value) { nil } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: Time.zone.parse("2022-03-01 00:00:00"), + to_datetime: Time.zone.parse("2022-03-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2022-03-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2022-03-31 23:59:59"), + charges_duration: 31.days, + timestamp: Time.zone.parse("2022-04-02 00:00").end_of_month.to_i + ) + end + + before { charge } + + describe "#call" do + context "when relying on event codes" do + it "returns the filtered charge_ids" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + + context "with events matching the boundaries" do + before do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + timestamp: boundaries.charges_from_datetime + 5.days, + code: billable_metric.code, + properties: {"region" => charge_filter_value&.values&.first} + ) + + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + timestamp: boundaries.charges_from_datetime + 5.days, + code: billable_metric.code, + properties: {"region" => charge_filter_value&.values&.last} + ) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil]}) + end + + context "with multiple charges for the same billable_metric" do + let(:charge_2) { create(:standard_charge, plan:, billable_metric:) } + + before { charge_2 } + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil], charge_2.id => [nil]}) + end + end + + context "with multiple billable metrics" do + let(:billable_metric_2) { create(:billable_metric, organization:) } + let(:charge_2) { create(:standard_charge, plan:, billable_metric: billable_metric_2) } + + before do + charge_2 + + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + timestamp: boundaries.charges_from_datetime + 10.days, + code: billable_metric_2.code, + properties: {"region" => charge_filter_value&.values&.first} + ) + end + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil], charge_2.id => [nil]}) + end + end + + context "with charge filters" do + let(:charge_filter) { create(:charge_filter, charge:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: ["eu", "us"]) } + + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["eu"]) + end + + let(:charge_filter2) { create(:charge_filter, charge:) } + + before { charge_filter2 } + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({charge.id => contain_exactly(charge_filter.id, charge_filter2.id, nil)}) + end + end + end + + context "with recurring billable metric" do + let(:recurring_billable_metric) { create(:sum_billable_metric, :recurring, organization:) } + let(:recurring_charge) { create(:standard_charge, plan:, billable_metric: recurring_billable_metric) } + + let(:charge_filter) { create(:charge_filter, charge: recurring_charge) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric: recurring_billable_metric, key: "region", values: ["eu", "us"]) } + + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["eu"]) + end + + before do + recurring_charge + charge_filter_value + end + + it "returns recurring charge_ids even without events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({recurring_charge.id => [charge_filter.id, nil]}) + end + end + + context "with events that does not match the boundaries" do + before do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + timestamp: boundaries.charges_from_datetime - 5.days, + code: billable_metric.code + ) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "with unknown event codes" do + before do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + timestamp: boundaries.charges_from_datetime + 5.days, + code: "unknown_code" + ) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + end + + context "when relying on clickhouse enriched events", clickhouse: true do + let(:organization) do + create(:organization, clickhouse_events_store: true, pre_filter_events: true) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + + context "with events matching the boundaries" do + let(:events) do + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge.id, + charge_version: charge.updated_at, + charge_filter_id: charge_filter&.id, + charge_filter_version: charge_filter&.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.first}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge.id, + charge_version: charge.updated_at, + charge_filter_id: charge_filter&.id, + charge_filter_version: charge_filter&.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.last}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + end + + before { events } + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil]}) + end + + context "with multiple charges for the same billable_metric" do + let(:charge_2) { create(:standard_charge, plan:, billable_metric:) } + + let(:events) do + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge.id, + charge_version: charge.updated_at, + charge_filter_id: charge_filter&.id, + charge_filter_version: charge_filter&.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.first}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge_2.id, + charge_version: charge_2.updated_at, + charge_filter_id: charge_filter&.id, + charge_filter_version: charge_filter&.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.last}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil], charge_2.id => [nil]}) + end + end + + context "with multiple billable metrics" do + let(:billable_metric_2) { create(:billable_metric, organization:) } + let(:charge_2) { create(:standard_charge, plan:, billable_metric: billable_metric_2) } + + before do + charge_2 + + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge_2.id, + charge_version: charge_2.updated_at, + charge_filter_id: charge_filter&.id, + charge_filter_version: charge_filter&.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.last}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + end + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil], charge_2.id => [nil]}) + end + end + + context "with charge filters" do + let(:charge_filter) { create(:charge_filter, charge:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: ["eu", "us"]) } + + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["eu"]) + end + + let(:charge_filter2) { create(:charge_filter, charge:) } + + before { charge_filter2 } + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({charge.id => contain_exactly(charge_filter.id)}) + end + + context "when events matches the default bucket" do + let(:events) do + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge.id, + charge_version: charge.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.first}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge.id, + charge_version: charge.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.last}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + end + + before { charge_filter } + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({charge.id => [nil]}) + end + end + end + end + + context "with recurring billable metric" do + it_behaves_like "recurring billable metric filtering" + end + + context "with unknown charges" do + before do + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: SecureRandom.uuid, + charge_version: boundaries.charges_from_datetime - 3.days, + charge_filter_id: charge_filter&.id, + charge_filter_version: charge_filter&.updated_at, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.last}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "with events that does not match the boundaries" do + before do + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id: SecureRandom.uuid, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge.id, + charge_version: charge.updated_at, + timestamp: boundaries.charges_from_datetime - 5.days, + properties: {"region" => charge_filter_value&.values&.first}, + value: "12", + decimal_value: 12.0, + precise_total_amount_cents: nil + ) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + end + + context "when relying on Postgres enriched events" do + let(:organization) do + create(:organization, pre_filter_events: true) + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + + context "with events matching the boundaries" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.first} + ), + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.last} + ) + ] + end + + let(:enriched_events) do + events.map do |event| + create( + :enriched_event, + event:, + subscription:, + value: 12, + decimal_value: 12.0, + charge:, + charge_filter_id: charge_filter&.id + ) + end + end + + before { enriched_events } + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil]}) + end + + context "with multiple charges for the same billable_metric" do + let(:charge_2) { create(:standard_charge, plan:, billable_metric:) } + + let(:enriched_events) do + [ + create( + :enriched_event, + event: events.first, + subscription:, + value: 12, + decimal_value: 12.0, + charge: + ), + create( + :enriched_event, + event: events.last, + subscription:, + value: 12, + decimal_value: 12.0, + charge: charge_2 + ) + ] + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil], charge_2.id => [nil]}) + end + end + + context "with multiple billable metrics" do + let(:billable_metric_2) { create(:billable_metric, organization:) } + let(:charge_2) { create(:standard_charge, plan:, billable_metric: billable_metric_2) } + + let(:events) do + [ + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.first} + ), + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric_2.code, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.last} + ) + ] + end + + let(:enriched_events) do + [ + create( + :enriched_event, + event: events.first, + subscription:, + value: 12, + decimal_value: 12.0, + charge: + ), + create( + :enriched_event, + event: events.last, + subscription:, + value: 12, + decimal_value: 12.0, + charge: charge_2 + ) + ] + end + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({charge.id => [nil], charge_2.id => [nil]}) + end + end + + context "with charge filters" do + let(:charge_filter) { create(:charge_filter, charge:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: ["eu", "us"]) } + + let(:charge_filter_value) do + create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: ["eu"]) + end + + let(:charge_filter2) { create(:charge_filter, charge:) } + + before { charge_filter2 } + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({charge.id => contain_exactly(charge_filter.id)}) + end + + context "when events matches the default bucket" do + let(:enriched_events) do + [ + create( + :enriched_event, + event: events.first, + subscription:, + value: 12, + decimal_value: 12.0, + charge: + ), + create( + :enriched_event, + event: events.last, + subscription:, + value: 12, + decimal_value: 12.0, + charge: + ) + ] + end + + before { charge_filter } + + it "returns charges and filters for all billable metrics with matching events" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to match({charge.id => [nil]}) + end + end + end + end + + context "with recurring billable metric" do + it_behaves_like "recurring billable metric filtering" + end + + context "with unknown charges" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: boundaries.charges_from_datetime + 5.days, + properties: {"region" => charge_filter_value&.values&.first} + ) + ] + end + + let(:enriched_events) do + events.map do |event| + create( + :enriched_event, + event:, + subscription:, + value: 12, + decimal_value: 12.0, + charge: create(:standard_charge) + ) + end + end + + before do + enriched_events + end + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + + context "with events that does not match the boundaries" do + let(:events) do + [ + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp: boundaries.charges_from_datetime - 5.days, + properties: {"region" => charge_filter_value&.values&.first} + ) + ] + end + + let(:enriched_events) do + events.map do |event| + create( + :enriched_event, + event:, + subscription:, + value: 12, + decimal_value: 12.0, + charge: + ) + end + end + + before { enriched_events } + + it "returns filtered charges" do + result = filter_service.call + + expect(result).to be_success + expect(result.charges).to eq({}) + end + end + end + end +end diff --git a/spec/services/events/calculate_expression_service_spec.rb b/spec/services/events/calculate_expression_service_spec.rb new file mode 100644 index 0000000..b111d4c --- /dev/null +++ b/spec/services/events/calculate_expression_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::CalculateExpressionService do + describe "#call" do + subject(:service_call) { described_class.new(organization: organization, event: event).call } + + let(:organization) { create(:organization) } + let(:event) { create(:event, organization: organization, timestamp: Time.current, code: code, properties: properties) } + let(:code) { "test_code" } + let(:properties) { {"left" => "1", "right" => "2"} } + let(:expression) { nil } + let(:field_name) { "result" } + let(:billable_metric) { create(:billable_metric, organization: organization, code: code, field_name: field_name, expression: expression) } + + before do + billable_metric + end + + context "when there is no expression configured" do + it "does not modify the event properties" do + expect(service_call).to be_success + expect(service_call.event.properties).to eq(properties) + end + end + + context "when an expression is configured for the billable metric" do + let(:expression) { "event.properties.left + event.properties.right" } + + it "evaluates the expression and updates the event properties" do + expect(service_call).to be_success + expect(service_call.event.properties[field_name]).to eq(3) + end + end + end +end diff --git a/spec/services/events/common_factory_spec.rb b/spec/services/events/common_factory_spec.rb new file mode 100644 index 0000000..12a2a6d --- /dev/null +++ b/spec/services/events/common_factory_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::CommonFactory do + describe ".new_instance" do + context "when source is an instance of Events::Common" do + let(:source) { build(:common_event) } + + it "returns the source" do + expect(described_class.new_instance(source:)).to eq(source) + end + end + + context "when source is a hash" do + let(:source) { build(:common_event).as_json } + + it "returns a new instance of Events::Common" do + new_instance = described_class.new_instance(source:) + + expect(new_instance.id).to be_nil + expect(new_instance.organization_id).to eq(source["organization_id"]) + expect(new_instance.transaction_id).to eq(source["transaction_id"]) + expect(new_instance.external_subscription_id).to eq(source["external_subscription_id"]) + # Keep in mind that we need the milliseconds precision! + expect(new_instance.timestamp).to eq(Time.zone.at(source["timestamp"].to_f)) + expect(new_instance.code).to eq(source["code"]) + expect(new_instance.properties).to eq(source["properties"]) + end + end + + context "when source is an instance of Event" do + let(:source) { create(:event) } + + it "returns a new instance of Events::Common" do + new_instance = described_class.new_instance(source:) + + expect(new_instance.id).to eq(source.id) + expect(new_instance.organization_id).to eq(source.organization_id) + expect(new_instance.transaction_id).to eq(source.transaction_id) + expect(new_instance.external_subscription_id).to eq(source.external_subscription_id) + expect(new_instance.timestamp).to eq(source.timestamp) + expect(new_instance.code).to eq(source.code) + expect(new_instance.properties).to eq(source.properties) + end + end + + context "when source is an instance of Clickhouse::EventsRaw", clickhouse: true do + let(:source) { create(:clickhouse_events_raw) } + + it "returns a new instance of Events::Common" do + new_instance = described_class.new_instance(source:) + + expect(new_instance.id).to be_nil + expect(new_instance.organization_id).to eq(source.organization_id) + expect(new_instance.transaction_id).to eq(source.transaction_id) + expect(new_instance.external_subscription_id).to eq(source.external_subscription_id) + expect(new_instance.timestamp).to eq(source.timestamp) + expect(new_instance.code).to eq(source.code) + expect(new_instance.properties).to eq(source.properties) + end + end + end +end diff --git a/spec/services/events/create_batch_service_spec.rb b/spec/services/events/create_batch_service_spec.rb new file mode 100644 index 0000000..bfe90f5 --- /dev/null +++ b/spec/services/events/create_batch_service_spec.rb @@ -0,0 +1,385 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::CreateBatchService do + subject(:create_batch_service) do + described_class.new( + organization:, + events_params:, + timestamp: creation_timestamp, + metadata: + ) + end + + let(:organization) { create(:organization) } + let(:timestamp) { Time.parse("2024-01-01T01:02:03.123456Z").to_f } + let(:code) { "sum_agg" } + let(:metadata) { {} } + let(:creation_timestamp) { Time.current.to_f } + let(:precise_total_amount_cents) { "123.34" } + let(:external_subscription_id) { "sub_12345" } + let(:events_params) { build_params } + + def build_params(count: 3, &block) + events = [] + count.times do |i| + event = { + external_customer_id: "cust_#{i}", + external_subscription_id:, + code:, + transaction_id: "txn_#{i}", + precise_total_amount_cents:, + properties: {foo: "bar_#{i}"}, + timestamp: + } + yield(event, i) if block_given? + + events << event + end + + {events:} + end + + def test_validation_failure(expected_errors) + result = nil + + expect { result = create_batch_service.call }.not_to change(Event, :count) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq expected_errors + end + + describe ".call" do + it "creates all events" do + result = nil + + expect { result = create_batch_service.call }.to change(Event, :count).by(3) + + expect(result).to be_success + end + + it "persists events with correct attributes" do + result = create_batch_service.call + + expect(result).to be_success + + expect(result.events.count).to eq(3) + result.events.each_with_index do |event, index| + reloaded = Event.find(event.id) + expected_params = events_params[:events][index] + + expect(reloaded.organization_id).to eq(organization.id) + expect(reloaded.code).to eq("sum_agg") + expect(reloaded.transaction_id).to eq(expected_params[:transaction_id]) + expect(reloaded.external_subscription_id).to eq(expected_params[:external_subscription_id]) + expect(reloaded.properties).to eq({"foo" => "bar_#{index}"}) + expect(reloaded.precise_total_amount_cents).to eq(BigDecimal("123.34")) + expect(reloaded.timestamp).to eq(Time.parse("2024-01-01T01:02:03.123456Z")) + end + end + + it "enqueues post processing jobs with correct event arguments" do + result = create_batch_service.call + + expect(Events::PostProcessJob).to have_been_enqueued.exactly(3).times + result.events.each do |event| + expect(Events::PostProcessJob).to have_been_enqueued.with(event:) + end + end + + context "when no events are provided" do + let(:events_params) { build_params(count: 0) } + + it "returns a no_events error" do + test_validation_failure({events: ["no_events"]}) + end + end + + context "when events count is at the limit" do + let(:events_params) { build_params(count: 100) } + + it "returns a too big error" do + result = nil + + expect { result = create_batch_service.call }.to change(Event, :count).by(100) + + expect(result).to be_success + end + end + + context "when events count is too big" do + let(:events_params) { build_params(count: 101) } + + it "returns a too big error" do + test_validation_failure({events: ["too_many_events"]}) + end + end + + context "with at least one invalid event" do + context "with duplicate transaction_id in consecutive positions" do + let(:events_params) { build_params(count: 2) { |event| event[:transaction_id] = "duplicate_txn" } } + + it "returns a duplicate transaction_id error for the second event" do + test_validation_failure({1 => {transaction_id: ["value_already_exist"]}}) + end + end + + # We have different error/issues depending on whether another error is saved afterward as PG will roll-back the + # transaction when an error occurs due to duplicate. + [:beginning, :middle, :end].each do |duplicate_event_occurence| + context "with already existing event (#{duplicate_event_occurence})" do + let(:existing_event) do + create(:event, organization:, transaction_id: SecureRandom.uuid, external_subscription_id:) + end + let(:duplicate_event_index) do + case duplicate_event_occurence + when :beginning then 0 + when :middle then 2 + when :end then 4 + end + end + let(:events_params) do + build_params(count: 5) do |event, index| + event[:transaction_id] = existing_event.transaction_id if index == duplicate_event_index + end + end + + before { existing_event } + + it "returns a duplicate transaction_id error for the duplicate event" do + test_validation_failure({duplicate_event_index => {transaction_id: ["value_already_exist"]}}) + end + end + end + end + + context "with duplicate transaction_id in non-consecutive positions" do + let(:events_params) do + build_params(count: 4) do |event, index| + event[:transaction_id] = "duplicate_txn" if index == 0 || index == 2 + end + end + + it "returns a duplicate transaction_id error for the duplicate at index 2" do + test_validation_failure({2 => {transaction_id: ["value_already_exist"]}}) + end + end + + context "with multiple duplicate transaction_ids" do + let(:events_params) do + build_params(count: 4) do |event, index| + event[:transaction_id] = "duplicate_txn_a" if index == 0 || index == 2 + event[:transaction_id] = "duplicate_txn_b" if index == 1 || index == 3 + end + end + + it "returns duplicate errors for all duplicates" do + test_validation_failure({ + 2 => {transaction_id: ["value_already_exist"]}, + 3 => {transaction_id: ["value_already_exist"]} + }) + end + end + + context "with three events having the same transaction_id" do + let(:events_params) { build_params(count: 3) { |event| event[:transaction_id] = "duplicate_txn" } } + + it "returns errors for the second and third events" do + test_validation_failure({ + 1 => {transaction_id: ["value_already_exist"]}, + 2 => {transaction_id: ["value_already_exist"]} + }) + end + end + + context "with already existing event" do + let(:existing_event) do + create(:event, organization:, transaction_id: "existing_txn", external_subscription_id:) + end + let(:events_params) do + build_params(count: 2) do |event, index| + event[:transaction_id] = "existing_txn" if index == 1 + end + + before { existing_event } + + it "returns an error for the duplicate" do + test_validation_failure({1 => {transaction_id: ["value_already_exist"]}}) + end + end + + context "with already existing event at first position" do + let(:existing_event) do + create(:event, organization:, transaction_id: "existing_txn", external_subscription_id:) + end + let(:events_params) do + build_params(count: 2) do |event, index| + event[:transaction_id] = "existing_txn" if index == 0 + end + end + + before { existing_event } + + it "returns an error for the first event" do + test_validation_failure({0 => {transaction_id: ["value_already_exist"]}}) + end + end + end + + context "when timestamp is not present in the payload" do + let(:events_params) { build_params(count: 1) { it.delete(:timestamp) } } + + it "creates an event by setting the timestamp to the current datetime" do + result = create_batch_service.call + + expect(result).to be_success + expect(result.events.first.timestamp).to eq(Time.zone.at(creation_timestamp)) + end + end + + context "when timestamp is given as string" do + let(:timestamp) { Time.current.to_f.to_s } + let(:events_params) { build_params(count: 1) { |event| event[:timestamp] = timestamp } } + + it "creates an event by setting timestamp" do + result = create_batch_service.call + + expect(result).to be_success + expect(result.events.first.timestamp).to eq(Time.zone.at(timestamp.to_f)) + end + end + + context "when timestamp is in a wrong format" do + let(:timestamp) { Time.current.to_s } + let(:events_params) { build_params(count: 1) { |event| event[:timestamp] = timestamp } } + + it "returns an error" do + test_validation_failure({0 => {timestamp: ["invalid_format"]}}) + end + end + + context "with an expression configured on the billable metric" do + let(:billable_metric) { create(:billable_metric, code:, organization:, field_name: "result", expression: "concat(event.properties.foo, '-bar')") } + + before do + billable_metric + end + + it "creates an event and updates the field name with the result of the expression" do + result = create_batch_service.call + + expect(result).to be_success + result.events.each_with_index { |event, index| expect(event.properties["result"]).to eq("bar_#{index}-bar") } + end + + context "when not all the event properties are not provided" do + let(:events_params) { build_params(count: 1) { |event| event[:properties] = {} } } + + it "returns a failure when the expression fails to evaluate" do + test_validation_failure({0 => "expression_evaluation_failed: Variable: foo not found"}) + end + end + end + + context "when timestamp is sent with decimal precision" do + let(:timestamp) { DateTime.parse("2023-09-04T15:45:12.344Z").to_f } + let(:events_params) { build_params(count: 1) { |event| event[:timestamp] = timestamp } } + + it "creates an event by keeping the millisecond precision" do + result = create_batch_service.call + + expect(result).to be_success + expect(result.events.first.timestamp.iso8601(3)).to eq("2023-09-04T15:45:12.344Z") + end + end + + context "when kafka is configured", :capture_kafka_messages do + let(:events_params) { build_params(count: 2) } + + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = "kafka" + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] = "raw_events" + end + + after do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] = nil + end + + it "produces all events on kafka in bulk with correct message format" do + freeze_time do + create_batch_service.call + + expect(karafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(2) + + messages.each_with_index do |message, index| + expected_params = events_params[:events][index] + + expect(message[:topic]).to eq("raw_events") + expect(message[:key]).to eq("#{organization.id}-#{external_subscription_id}") + + payload = JSON.parse(message[:payload]) + expect(payload["organization_id"]).to eq(organization.id) + expect(payload["external_subscription_id"]).to eq(external_subscription_id) + expect(payload["transaction_id"]).to eq(expected_params[:transaction_id]) + expect(payload["code"]).to eq(code) + expect(payload["precise_total_amount_cents"]).to eq(precise_total_amount_cents) + expect(payload["properties"]).to eq(expected_params[:properties].stringify_keys) + expect(payload["timestamp"]).to eq(timestamp.to_s) + expect(payload["ingested_at"]).to eq(Time.zone.now.iso8601[...-1]) + expect(payload["source"]).to eq("http_ruby") + expect(payload["source_metadata"]).to eq({"api_post_processed" => true}) + end + end + end + end + end + + context "when clickhouse is enabled on the organization" do + let(:organization) { create(:organization, clickhouse_events_store: true) } + + it "does not store the event in postgres" do + result = nil + + expect { result = create_batch_service.call }.not_to change(Event, :count) + expect(result).to be_success + end + + it "does not enqueue a post processing job" do + expect { create_batch_service.call }.not_to have_enqueued_job(Events::PostProcessJob) + end + + context "when kafka is configured", :capture_kafka_messages do + let(:events_params) { build_params(count: 2) } + + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = "kafka" + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] = "raw_events" + end + + after do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] = nil + end + + it "produces all events on kafka with api_post_processed set to false" do + freeze_time do + create_batch_service.call + + expect(karafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(2) + + messages.each do |message| + payload = JSON.parse(message[:payload]) + expect(payload["source_metadata"]).to eq({"api_post_processed" => false}) + end + end + end + end + end + end + end +end diff --git a/spec/services/events/create_service_spec.rb b/spec/services/events/create_service_spec.rb new file mode 100644 index 0000000..29142ab --- /dev/null +++ b/spec/services/events/create_service_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::CreateService do + subject(:create_service) do + described_class.new( + organization:, + params: create_args, + timestamp: creation_timestamp, + metadata: + ) + end + + let(:organization) { create(:organization) } + + let(:code) { "sum_agg" } + let(:external_subscription_id) { SecureRandom.uuid } + let(:timestamp) { Time.current.to_f } + let(:transaction_id) { SecureRandom.uuid } + let(:precise_total_amount_cents) { nil } + + let(:creation_timestamp) { Time.current.to_f } + + let(:create_args) do + { + external_subscription_id:, + code:, + transaction_id:, + precise_total_amount_cents:, + properties: {foo: "bar"}, + timestamp: + } + end + + let(:metadata) { {} } + + describe "#call" do + it "creates an event" do + result = nil + + expect { result = create_service.call }.to change(Event, :count).by(1) + + expect(result).to be_success + expect(result.event).to have_attributes( + external_subscription_id:, + transaction_id:, + code:, + timestamp: Time.zone.at(timestamp), + properties: {"foo" => "bar"}, + precise_total_amount_cents: nil + ) + end + + it "enqueues a post processing job" do + expect { create_service.call }.to have_enqueued_job(Events::PostProcessJob) + end + + context "when event already exists" do + let(:existing_event) do + create( + :event, + organization:, + transaction_id: create_args[:transaction_id], + external_subscription_id: + ) + end + + before { existing_event } + + it "returns an error" do + result = 0 + + expect { result = create_service.call }.not_to change(Event, :count) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:transaction_id) + expect(result.error.messages[:transaction_id]).to include("value_already_exist") + end + end + + context "when timestamp is not present in the payload" do + let(:timestamp) { nil } + + it "creates an event by setting the timestamp to the current datetime" do + result = create_service.call + + expect(result).to be_success + expect(result.event.timestamp).to eq(Time.zone.at(creation_timestamp)) + end + end + + context "when timestamp is given as string" do + let(:timestamp) { Time.current.to_f.to_s } + + it "creates an event by setting timestamp" do + result = create_service.call + + expect(result).to be_success + expect(result.event.timestamp).to eq(Time.zone.at(timestamp.to_f)) + end + end + + context "when timestamp is sent with decimal precision" do + let(:timestamp) { DateTime.parse("2023-09-04T15:45:12.344Z").to_f } + + it "creates an event by keeping the millisecond precision" do + result = create_service.call + + expect(result).to be_success + expect(result.event.timestamp.iso8601(3)).to eq("2023-09-04T15:45:12.344Z") + end + end + + context "when timestamp is given in a wrong format" do + let(:timestamp) { Time.current.to_s } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error.messages).to include({timestamp: ["invalid_format"]}) + end + end + + context "when kafka is configured", :capture_kafka_messages do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = "kafka" + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] = "raw_events" + end + + it "produces the event on kafka" do + create_service.call + + expect(karafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(1) + end + end + end + + context "with an expression configured on the billable metric" do + let(:billable_metric) { create(:billable_metric, code:, organization:, field_name: "result", expression: "event.properties.left + event.properties.right") } + + let(:create_args) do + { + external_subscription_id:, + code:, + transaction_id:, + precise_total_amount_cents:, + properties: {left: "1", right: "2"}, + timestamp: + } + end + + before do + billable_metric + end + + it "creates an event and updates the field name with the result of the expression" do + result = create_service.call + + expect(result).to be_success + expect(result.event.properties["result"]).to eq("3.0") + end + + context "when not all the event properties are not provided" do + let(:create_args) do + { + external_subscription_id:, + code:, + transaction_id:, + precise_total_amount_cents:, + properties: {}, + timestamp: + } + end + + it "returns a service failure when the expression fails to evaluate" do + result = create_service.call + + expect(result).to be_failure + end + end + end + + context "with a precise_total_amount_cents" do + let(:precise_total_amount_cents) { "123.45" } + + it "creates an event with the precise_total_amount_cents" do + result = create_service.call + + expect(result).to be_success + expect(result.event.precise_total_amount_cents).to eq(123.45) + end + + context "when precise_total_amount_cents is not a valid decimal value" do + let(:precise_total_amount_cents) { "asdfa" } + + it "creates an event" do + result = create_service.call + + expect(result).to be_success + expect(result.event.precise_total_amount_cents).to eq(0) + end + end + end + end +end diff --git a/spec/services/events/enrich_service_spec.rb b/spec/services/events/enrich_service_spec.rb new file mode 100644 index 0000000..b72a7af --- /dev/null +++ b/spec/services/events/enrich_service_spec.rb @@ -0,0 +1,478 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::EnrichService do + subject(:enrich_service) do + described_class.new(event:, subscription:, billable_metric:, charges_and_filters:, persist:) + end + + let(:organization) { create(:organization) } + let(:subscription) { create(:subscription, organization:) } + let(:plan) { subscription.plan } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:charges_and_filters) { {charge => charge_filter} } + let(:charge_filter) { nil } + let(:persist) { true } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12 + } + ) + end + + describe "call" do + it "creates an enriched event" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event).to have_attributes( + code: billable_metric.code, + transaction_id: event.transaction_id, + timestamp: be_within(1.second).of(event.timestamp), + organization_id: organization.id, + value: "12", + decimal_value: 12.0, + enriched_at: be_within(1.second).of(Time.current), + charge_filter_id: nil, + charge_id: charge.id, + event_id: event.id, + external_subscription_id: subscription.external_id, + plan_id: plan.id, + grouped_by: {} + ) + end + + context "with a precise_total_amount_cents set on the event" do + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12 + }, + precise_total_amount_cents: BigDecimal("1200.12") + ) + end + + it "creates an enriched event with the precise_total_amount_cents" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.precise_total_amount_cents).to eq(BigDecimal("1200.12")) + end + end + + context "when billable metric is uses a count aggregation" do + let(:billable_metric) { create(:billable_metric, organization:) } + + it "creates an enriched event with value at 1" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event).to have_attributes( + value: "1", + decimal_value: 1.0 + ) + end + end + + context "when event property is missing" do + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: {} + ) + end + + it "creates an enriched event with value at 0" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event).to have_attributes( + value: "0", + decimal_value: 0.0 + ) + end + end + + context "when event property is invalid" do + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => "invalid_value" + } + ) + end + + it "creates an enriched event with value at 0" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event).to have_attributes( + value: "invalid_value", + decimal_value: 0.0 + ) + end + end + + context "when billable metric uses a unique count aggregation" do + let(:billable_metric) { create(:unique_count_billable_metric, organization:) } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => "foo_bar" + } + ) + end + + it "creates an enriched event with the right value and the operation type" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.value).to eq("foo_bar") + expect(enriched_event.operation_type).to eq("add") + end + + context "when the event operation type is passed" do + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => "foo_bar", + "operation_type" => "remove" + } + ) + end + + it "creates an enriched event with the right operation type" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.value).to eq("foo_bar") + expect(enriched_event.operation_type).to eq("remove") + end + end + + context "when the event operation type is unknown" do + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => "foo_bar", + "operation_type" => "invalid" + } + ) + end + + it "creates an enriched event without the operation type" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.value).to eq("foo_bar") + expect(enriched_event.operation_type).to be_nil + end + end + end + + context "when charges defines a pricing group key" do + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "120", pricing_group_keys: %w[cloud provider]}) } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12, + "cloud" => "aws", + "provider" => "visa" + } + ) + end + + it "creates an enriched event" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.grouped_by).to eq({"cloud" => "aws", "provider" => "visa"}) + end + + context "when event does not hold the group keys" do + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12 + } + ) + end + + it "creates an enriched event" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.grouped_by).to eq({"cloud" => nil, "provider" => nil}) + end + end + end + + context "when the charge has the accepts_target_wallet flag set to true", :premium do + let(:organization) { create(:organization, premium_integrations: ["events_targeting_wallets"]) } + let(:charge) { create(:standard_charge, plan:, billable_metric:, accepts_target_wallet: true) } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12, + "target_wallet_code" => "wallet1234" + }, + precise_total_amount_cents: BigDecimal("1200.12") + ) + end + + it "creates an enriched event with the target wallet code" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.target_wallet_code).to eq("wallet1234") + expect(enriched_event.grouped_by).to eq({"target_wallet_code" => "wallet1234"}) + end + + context "when the event does not have a target wallet code" do + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12, + "region" => "eu" + } + ) + end + + it "creates an enriched event without the target wallet code" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.target_wallet_code).to be_nil + expect(enriched_event.grouped_by).to eq({}) + end + end + + context "when charges defines a pricing group key" do + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "120", pricing_group_keys: %w[cloud provider]}, + accepts_target_wallet: true + ) + end + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12, + "cloud" => "aws", + "provider" => "visa", + "target_wallet_code" => "wallet123" + } + ) + end + + it "creates an enriched event with both groups and target wallet code" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event.target_wallet_code).to eq("wallet123") + expect(enriched_event.grouped_by).to eq({"cloud" => "aws", "provider" => "visa", "target_wallet_code" => "wallet123"}) + end + end + end + + context "when event matches a charge filter" do + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[us eu]) } + let(:charge_filter) { create(:charge_filter, charge:) } + let(:charge_filter_value) { create(:charge_filter_value, charge_filter:, billable_metric_filter:, values: %w[eu]) } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12, + "region" => "eu" + } + ) + end + + before do + charge_filter_value + end + + it "creates an enriched event" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event).to have_attributes( + charge_id: charge.id, + charge_filter_id: charge_filter.id, + grouped_by: {}, + value: "12", + decimal_value: 12.0 + ) + end + + context "when filter has pricing group keys" do + let(:charge_filter) { create(:charge_filter, charge:, properties: {amount: "120", pricing_group_keys: %w[cloud provider]}) } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12, + "region" => "eu", + "cloud" => "aws", + "provider" => "visa" + } + ) + end + + it "creates an enriched event" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event).to have_attributes( + charge_id: charge.id, + charge_filter_id: charge_filter.id, + grouped_by: { + "cloud" => "aws", + "provider" => "visa" + } + ) + end + end + end + + context "when multiple charges matches the event" do + let(:charge2) { create(:standard_charge, plan:, billable_metric:) } + let(:charges_and_filters) { {charge => nil, charge2 => nil} } + + it "creates an enriched event for each charge" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(2) + + expect(result.enriched_events.pluck(:charge_id)).to match_array([charge.id, charge2.id]) + end + end + + context "when persist flag is false" do + let(:persist) { false } + + it "creates an enriched event without persisting it" do + result = enrich_service.call + + expect(result).to be_success + expect(result.enriched_events.count).to eq(1) + + enriched_event = result.enriched_events.first + expect(enriched_event).not_to be_persisted + end + end + end +end diff --git a/spec/services/events/kafka_producer_service_spec.rb b/spec/services/events/kafka_producer_service_spec.rb new file mode 100644 index 0000000..91967bd --- /dev/null +++ b/spec/services/events/kafka_producer_service_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +RSpec.describe Events::KafkaProducerService, :capture_kafka_messages do + subject(:producer_service) { described_class.new(events:, organization:) } + + let(:events) { create_list(:event, 2, organization:) } + let(:organization) { create(:organization) } + + describe "#call" do + context "with Kafka config" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = "kafka" + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] = "raw_events" + end + + it "produces all events on kafka in bulk" do + freeze_time do + producer_service.call + + expect(karafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(2) + + events.each_with_index do |event, index| + expect(messages[index]).to eq( + topic: "raw_events", + key: "#{organization.id}-#{event.external_subscription_id}", + payload: { + organization_id: organization.id, + external_customer_id: event.external_customer_id, + external_subscription_id: event.external_subscription_id, + transaction_id: event.transaction_id, + timestamp: event.timestamp.to_f.to_s, + code: event.code, + precise_total_amount_cents: event.precise_total_amount_cents.present? ? event.precise_total_amount_cents.to_s : "0.0", + properties: event.properties, + ingested_at: Time.zone.now.iso8601[...-1], + source: "http_ruby", + source_metadata: { + api_post_processed: true + } + }.to_json + ) + end + end + end + end + + context "with a single event" do + let(:events) { create(:event, organization:) } + + it "wraps the single event in an array and produces it" do + producer_service.call + + expect(karafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(1) + end + end + end + end + + context "without Kafka config" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] = nil + end + + it "does not produce events on kafka" do + producer_service.call + + expect(karafka_producer).not_to have_received(:produce_many_async) + end + end + end +end diff --git a/spec/services/events/pay_in_advance_service_spec.rb b/spec/services/events/pay_in_advance_service_spec.rb new file mode 100644 index 0000000..f995f2b --- /dev/null +++ b/spec/services/events/pay_in_advance_service_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::PayInAdvanceService do + let(:in_advance_service) { described_class.new(event:) } + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, started_at:) } + let(:event_properties) { {} } + let(:timestamp) { Time.current - 1.second } + let(:code) { billable_metric&.code } + let(:external_subscription_id) { subscription.external_id } + let(:started_at) { Time.current - 3.days } + + let(:event) do + build( + :common_event, + id: SecureRandom.uuid, + organization_id: organization.id, + code:, + external_subscription_id: subscription.external_id, + properties: event_properties + ) + end + + describe "#call" do + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: false) } + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "item_id") + end + + let(:event_properties) { {billable_metric.field_name => "12"} } + + before { charge } + + it "enqueues a job to perform the pay_in_advance aggregation" do + expect { in_advance_service.call }.to have_enqueued_job(Fees::CreatePayInAdvanceJob) + end + + context "when charge is invoiceable" do + before { charge.update!(invoiceable: true) } + + it "does not enqueue a job to perform the pay_in_advance aggregation" do + expect { in_advance_service.call }.not_to have_enqueued_job(Fees::CreatePayInAdvanceJob) + end + end + + context "when multiple charges have the billable metric" do + before { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: false) } + + it "enqueues a job for each charge" do + expect { in_advance_service.call }.to have_enqueued_job(Fees::CreatePayInAdvanceJob).twice + end + end + + context "when event matches a pay_in_advance charge that is invoiceable" do + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: true) } + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "item_id") + end + + let(:event_properties) { {billable_metric.field_name => "12"} } + + before { charge } + + it "enqueues a job to create the pay_in_advance charge invoice" do + expect { in_advance_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceChargeJob) + end + + context "when charge is not invoiceable" do + before { charge.update!(invoiceable: false) } + + it "does not enqueue a job to create the pay_in_advance charge invoice" do + expect { in_advance_service.call } + .not_to have_enqueued_job(Invoices::CreatePayInAdvanceChargeJob) + end + end + + context "when multiple charges have the billable metric" do + before { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: true) } + + it "enqueues a job for each charge" do + expect { in_advance_service.call } + .to have_enqueued_job(Invoices::CreatePayInAdvanceChargeJob).twice + end + end + + context "when value for sum_agg is negative" do + let(:event_properties) { {billable_metric.field_name => "-5"} } + + it "enqueues a job" do + expect { in_advance_service.call } + .to have_enqueued_job(Invoices::CreatePayInAdvanceChargeJob) + end + end + + context "when event field name does not batch the BM one" do + let(:event_properties) { {"wrong_field_name" => "-5"} } + + it "does not enqueue a job" do + expect { in_advance_service.call } + .not_to have_enqueued_job(Invoices::CreatePayInAdvanceChargeJob) + end + end + end + + context "when fees exists with the same transaction_id" do + before do + create( + :fee, + subscription:, + invoice: nil, + pay_in_advance_event_transaction_id: event.transaction_id + ) + end + + it "does not enqueue a job" do + expect do + expect { in_advance_service.call }.not_to have_enqueued_job(Fees::CreatePayInAdvanceJob) + end.not_to have_enqueued_job(Invoices::CreatePayInAdvanceChargeJob) + end + end + + context "when event is comming from kafka" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] ||= "kafla:9092" + ENV["LAGO_KAFKA_RAW_EVENTS_TOPIC"] ||= "raw_events" + + event.id = nil + end + + it "does not process the event" do + expect do + expect { in_advance_service.call }.not_to have_enqueued_job(Fees::CreatePayInAdvanceJob) + end.not_to have_enqueued_job(Invoices::CreatePayInAdvanceChargeJob) + end + + context "when organization is using clickhouse" do + before { organization.update!(clickhouse_events_store: true) } + + it "enqueues a job to perform the pay_in_advance aggregation" do + expect { in_advance_service.call }.to have_enqueued_job(Fees::CreatePayInAdvanceJob) + end + end + end + end +end diff --git a/spec/services/events/post_process_service_spec.rb b/spec/services/events/post_process_service_spec.rb new file mode 100644 index 0000000..5d44e1f --- /dev/null +++ b/spec/services/events/post_process_service_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::PostProcessService do + subject(:process_service) { described_class.new(event:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:, started_at:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:) } + + let(:started_at) { Time.current - 3.days } + let(:external_subscription_id) { subscription.external_id } + let(:code) { billable_metric&.code } + let(:timestamp) { Time.current - 1.second } + let(:event_properties) { {} } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id:, + timestamp:, + code:, + properties: event_properties + ) + end + + before do + charge + create(:wallet, customer:) + end + + describe "#call" do + it "marks customer as awaiting wallet refresh" do + expect { process_service.call }.to change { customer.reload.awaiting_wallet_refresh }.from(false).to(true) + end + + it "tracks subscription activity" do + allow(UsageMonitoring::TrackSubscriptionActivityService).to receive(:call) + + process_service.call + + expected_date = Time.current.in_time_zone(customer.applicable_timezone).to_date + expect(UsageMonitoring::TrackSubscriptionActivityService).to have_received(:call) + .with(subscription:, organization:, date: expected_date) + end + + context "with events enrichment" do + it "does not create an enriched event" do + expect { process_service.call }.not_to change(EnrichedEvent, :count) + end + + context "when the feature flag is enabled" do + let(:organization) { create(:organization, feature_flags: [:postgres_enriched_events]) } + + it "creates enriched event" do + expect { process_service.call }.to change(EnrichedEvent, :count).by(1) + end + end + end + + context "when subscription is incomplete" do + let(:subscription) do + create(:subscription, :incomplete, organization:, customer:, plan:, started_at:) + end + + it "does not enqueue a pay in advance job" do + expect { process_service.call }.not_to have_enqueued_job(Events::PayInAdvanceJob) + end + + it "does not track subscription activity" do + allow(UsageMonitoring::TrackSubscriptionActivityService).to receive(:call) + + process_service.call + + expect(UsageMonitoring::TrackSubscriptionActivityService).not_to have_received(:call) + end + end + + context "when event matches an pay_in_advance charge" do + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: false) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "item_id") } + let(:event_properties) { {billable_metric.field_name => "12"} } + + before { charge } + + it "enqueues a job to perform the pay_in_advance aggregation" do + expect { process_service.call }.to have_enqueued_job(Events::PayInAdvanceJob) + end + end + + describe "#check_targeted_wallets", :premium do + let(:charge) { create(:standard_charge, plan:, billable_metric:, organization:) } + let(:accepts_target_wallet) { false } + let(:event_properties) { {"target_wallet_code" => target_wallet_code} } + let(:target_wallet_code) { "my_wallet" } + + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + charge.update!(accepts_target_wallet:) + end + + context "when events_targeting_wallets feature is not enabled" do + before do + organization.update!(premium_integrations: []) + end + + it "does not send error webhook" do + expect { process_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when target_wallet_code is not present in event properties" do + let(:event_properties) { {} } + + it "does not send error webhook" do + expect { process_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when charge does not accept wallet target" do + let(:accepts_target_wallet) { false } + + it "does not send error webhook" do + expect { process_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when charge accepts wallet target" do + let(:accepts_target_wallet) { true } + + context "when wallet with target code exists" do + before do + create(:wallet, customer:, code: target_wallet_code) + end + + it "does not send error webhook" do + expect { process_service.call }.not_to have_enqueued_job(SendWebhookJob).with("event.error", anything, anything) + end + end + + context "when active wallet with target code does not exist" do + let(:wallet) { create(:wallet, customer:, code: target_wallet_code, status: :terminated) } + + before { wallet } + + it "sends error webhook with target_wallet_code_not_found" do + expect { process_service.call }.to have_enqueued_job(SendWebhookJob) + .with("event.error", event, {error: {target_wallet_code: ["target_wallet_code_not_found"]}}) + end + end + end + end + end +end diff --git a/spec/services/events/post_validation_service_spec.rb b/spec/services/events/post_validation_service_spec.rb new file mode 100644 index 0000000..ad28ce2 --- /dev/null +++ b/spec/services/events/post_validation_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::PostValidationService, transaction: false do + subject(:validation_service) { described_class.new(organization:) } + + let(:organization) { create(:organization) } + + let(:invalid_code_event) do + create( + :event, + organization:, + code: Faker::Name.name.underscore, + created_at: Time.current.beginning_of_hour - 25.minutes + ) + end + + let(:billable_metric) do + create( + :sum_billable_metric, + organization: + ) + end + + let(:missing_aggregation_property_event) do + create( + :event, + organization:, + code: billable_metric.code, + properties: {}, + created_at: Time.current.beginning_of_hour - 25.minutes + ) + end + + let(:negative_aggregation_property_event) do + create( + :event, + organization:, + code: billable_metric.code, + properties: {billable_metric.field_name => -12}, + created_at: Time.current.beginning_of_hour - 25.minutes + ) + end + + let(:billable_metric_with_filter) do + create( + :billable_metric, + organization: + ) + end + + let(:billable_metric_filter) do + create( + :billable_metric_filter, + billable_metric: billable_metric_with_filter, + key: "region", + values: %w[eu-west-1 us-east-1] + ) + end + + let(:invalid_filter_values_event) do + create( + :event, + organization:, + code: billable_metric_with_filter.code, + properties: {billable_metric_filter.key => "us-west-4"}, + created_at: Time.current.beginning_of_hour - 25.minutes + ) + end + + before do + invalid_code_event + missing_aggregation_property_event + negative_aggregation_property_event + invalid_filter_values_event + + Scenic.database.refresh_materialized_view( + Events::LastHourMv.table_name, + concurrently: false, + cascade: false + ) + end + + describe ".call" do + context "when does not belong to the organization" do + before { allow(SendWebhookJob).to receive(:perform_later) } + + let(:other_organization) { create(:organization) } + + it "does not send the webhook" do + described_class.new(organization: other_organization).call + expect(SendWebhookJob).not_to have_received(:perform_later) + end + end + + it "checks last hour events returns the list of transaction_id" do + result = validation_service.call + + expect(result.errors[:invalid_code]).to include(invalid_code_event.transaction_id) + expect(result.errors[:missing_aggregation_property]) + .to include(missing_aggregation_property_event.transaction_id) + expect(result.errors[:missing_aggregation_property]) + .not_to include(negative_aggregation_property_event.transaction_id) + expect(result.errors[:invalid_filter_values]).to include(invalid_filter_values_event.transaction_id) + end + + it "delivers a webhook with the list of transaction_id" do + validation_service.call + + expect(SendWebhookJob).to have_been_enqueued + .with( + "events.errors", + organization, + errors: { + invalid_code: [invalid_code_event.transaction_id], + missing_aggregation_property: [missing_aggregation_property_event.transaction_id], + invalid_filter_values: [invalid_filter_values_event.transaction_id] + } + ) + end + end +end diff --git a/spec/services/events/re_enrich_all_service_spec.rb b/spec/services/events/re_enrich_all_service_spec.rb new file mode 100644 index 0000000..8658e39 --- /dev/null +++ b/spec/services/events/re_enrich_all_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::ReEnrichAllService do + let(:re_enrich_service) { described_class.new(subscription:) } + + let(:organization) { create(:organization) } + let(:subscription) { create(:subscription, organization:) } + let(:plan) { subscription.plan } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: { + billable_metric.field_name => 12 + }, + timestamp: Time.current + ) + end + + before do + event + charge + end + + describe "#call" do + it "creates the relevant enriched_events" do + result = nil + + expect { result = re_enrich_service.call }.to change(EnrichedEvent, :count).by(1) + expect(result).to be_success + end + + context "with pre-existing enriched events" do + before { create_list(:enriched_event, 3, subscription:, event:) } + + it "removes the enriched_events and creates the relevant one" do + result = nil + + expect { result = re_enrich_service.call }.to change(EnrichedEvent, :count).by(-2) + expect(result).to be_success + + # Make sure the enriched event matched the event + enriched_event = EnrichedEvent.find_by(event_id: event.id) + expect(enriched_event).to have_attributes( + charge_id: charge.id, + charge_filter_id: nil, + grouped_by: {}, + value: "12", + decimal_value: 12.0 + ) + end + end + + context "with clickhouse events store" do + let(:organization) { create(:organization, clickhouse_events_store: true) } + + it "returns success" do + result = nil + + expect { result = re_enrich_service.call }.not_to change(EnrichedEvent, :count) + expect(result).to be_success + end + end + end +end diff --git a/spec/services/events/stores/clickhouse/clean_duplicated_enriched_expanded_service_spec.rb b/spec/services/events/stores/clickhouse/clean_duplicated_enriched_expanded_service_spec.rb new file mode 100644 index 0000000..2697f46 --- /dev/null +++ b/spec/services/events/stores/clickhouse/clean_duplicated_enriched_expanded_service_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService, :clickhouse do + subject(:service) { described_class.new(subscription:, codes:, timeout:) } + + let(:organization) { create(:organization, clickhouse_events_store: true) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:, started_at: 1.month.ago) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan:) } + let(:charge_filter) { nil } + let(:codes) { [] } + let(:timeout) { nil } + + let(:transaction_id) { SecureRandom.uuid } + let(:event_timestamp) { Time.current.change(usec: 0) } + let(:base_enriched_at) { Time.current.change(usec: 0) - 10.minutes } + + describe "#call" do + before do + 3.times do |i| + create( + :clickhouse_events_enriched_expanded, + organization_id: organization.id, + subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + charge_id: charge.id, + charge_filter_id: charge_filter&.id || "", + transaction_id: transaction_id, + timestamp: event_timestamp, + code: billable_metric.code, + enriched_at: base_enriched_at + i.minutes + ) + end + + # Non-duplicated event (single occurrence, different transaction_id) + create( + :clickhouse_events_enriched_expanded, + organization_id: organization.id, + subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + charge_id: charge.id, + charge_filter_id: charge_filter&.id || "", + transaction_id: SecureRandom.uuid, + timestamp: event_timestamp, + code: billable_metric.code, + enriched_at: base_enriched_at + ) + end + + it "removes duplicated events and keeps the latest enriched_at" do + result = service.call + + expect(result).to be_success + expect(result.queries).to be_empty + + remaining = ::Clickhouse::EventsEnrichedExpanded.where( + organization_id: organization.id, + subscription_id: subscription.id, + transaction_id: transaction_id, + timestamp: event_timestamp + ) + + expect(remaining.count).to eq(1) + expect(remaining.first.enriched_at).to match_datetime(base_enriched_at + 2.minutes) + end + + it "does not delete non-duplicated events" do + service.call + + all_remaining = ::Clickhouse::EventsEnrichedExpanded.where( + organization_id: organization.id, + subscription_id: subscription.id + ) + + # 1 keeper from the duplicated group + 1 non-duplicated event + expect(all_remaining.count).to eq(2) + end + + context "with codes filter" do + let(:codes) { [billable_metric.code] } + let(:other_metric) { create(:billable_metric, organization:) } + let(:other_charge) { create(:standard_charge, billable_metric: other_metric, plan:) } + + before do + create( + :clickhouse_events_enriched_expanded, + organization_id: organization.id, + subscription_id: subscription.id, + external_subscription_id: subscription.external_id, + charge_id: other_charge.id, + charge_filter_id: "", + transaction_id: transaction_id, + timestamp: event_timestamp, + code: other_metric.code, + enriched_at: base_enriched_at - 1.minute + ) + end + + it "only removes duplicates matching the specified codes" do + result = service.call + + expect(result).to be_success + expect(::Clickhouse::EventsEnrichedExpanded.where(code: other_metric.code).count).to eq(1) + end + end + + context "with timeout" do + let(:timeout) { 30 } + + let(:connection) { instance_double(ActiveRecord::ConnectionAdapters::AbstractAdapter) } + + before do + allow(Events::Stores::Utils::ClickhouseConnection).to receive(:connection_with_retry).and_yield(connection) + allow(connection).to receive(:select_one).and_return({"duplicated_count" => 1}) + allow(connection).to receive(:execute) + end + + it "includes max_execution_time setting in the delete query" do + service.call + + expect(connection).to have_received(:execute) do |sql| + expect(sql).to include("SETTINGS max_execution_time=30") + end + end + + it "returns the removed count" do + result = service.call + + expect(result).to be_success + expect(result.duplicated_count).to eq(1) + expect(result.queries).to be_empty + end + end + + context "without timeout" do + let(:connection) { instance_double(ActiveRecord::ConnectionAdapters::AbstractAdapter) } + + before do + allow(Events::Stores::Utils::ClickhouseConnection).to receive(:connection_with_retry).and_yield(connection) + allow(connection).to receive(:select_one).and_return({"duplicated_count" => 1}) + allow(connection).to receive(:execute) + end + + it "does not include max_execution_time setting in the delete query" do + service.call + + expect(connection).to have_received(:execute) do |sql| + expect(sql).not_to include("max_execution_time") + end + end + end + + context "when delete times out" do + let(:timeout) { 5 } + + let(:connection) { instance_double(ActiveRecord::ConnectionAdapters::AbstractAdapter) } + + before do + allow(Events::Stores::Utils::ClickhouseConnection).to receive(:connection_with_retry).and_yield(connection) + allow(connection).to receive(:select_one).and_return({"duplicated_count" => 1}) + allow(connection).to receive(:execute).and_raise(Net::ReadTimeout, "timeout exceeded") + end + + it "captures the SQL in queries without raising" do + result = service.call + + expect(result).to be_success + expect(result.queries.size).to eq(1) + expect(result.queries.first).to include("DELETE FROM events_enriched_expanded") + end + end + + context "with charge filter" do + let(:charge_filter) { create(:charge_filter, charge:) } + + it "only removes duplicates matching the specified charge filter" do + result = service.call + + expect(result).to be_success + + remaining = ::Clickhouse::EventsEnrichedExpanded.where( + organization_id: organization.id, + subscription_id: subscription.id, + transaction_id: transaction_id, + timestamp: event_timestamp + ) + expect(remaining.count).to eq(1) + expect(remaining.first.enriched_at).to match_datetime(base_enriched_at + 2.minutes) + end + end + end +end diff --git a/spec/services/events/stores/clickhouse/clean_duplicated_service_spec.rb b/spec/services/events/stores/clickhouse/clean_duplicated_service_spec.rb new file mode 100644 index 0000000..2ecd809 --- /dev/null +++ b/spec/services/events/stores/clickhouse/clean_duplicated_service_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Events::Stores::Clickhouse::CleanDuplicatedService, :clickhouse do + subject(:clean_service) { described_class.new(subscription:, timestamp:) } + + let(:organization) { create(:organization, clickhouse_events_store: true) } + let(:subscription) { create(:subscription, organization:) } + let(:timestamp) { Time.current } + + # ReplacingMergeTree dedupes rows sharing the ORDER BY tuple at merge time, which prevents + # the issue the service is supposed to clean. + # TRUNCATE before each test gives us a clean slate (no inactive parts/mutations), + # and inserting all rows in a single block with `optimize_on_insert=0` keeps every duplicate + # inside the same data part so the engine has nothing to merge across. + before do + ::Clickhouse::EventsEnriched.connection.execute("TRUNCATE TABLE events_enriched") + end + + def insert_enriched_events(rows) + conn = ::Clickhouse::EventsEnriched.connection + values = rows.map { |row| + "(" + [ + conn.quote(row[:organization_id]), + conn.quote(row[:external_subscription_id]), + conn.quote(row[:code]), + conn.quote(row[:timestamp].utc.strftime("%Y-%m-%d %H:%M:%S.%3N")), + conn.quote(row[:transaction_id]), + "{}", + "'21.0'", + conn.quote(row[:enriched_at].utc.strftime("%Y-%m-%d %H:%M:%S.%3N")) + ].join(", ") + ")" + }.join(", ") + + sql = <<~SQL.squish + INSERT INTO events_enriched + (organization_id, external_subscription_id, code, timestamp, transaction_id, properties, value, enriched_at) + VALUES #{values} + SQL + + conn.execute(sql, format: nil, settings: {optimize_on_insert: 0}) + end + + describe "#call" do + let(:transaction_id) { SecureRandom.uuid } + let(:timestamp) { Time.current.change(usec: 0) } + let(:base_enriched_at) { Time.current.change(usec: 0) - 10.minutes } + + let(:duplicated_rows) do + Array.new(3) do |i| + { + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: transaction_id, + timestamp: timestamp, + code: "event_code", + enriched_at: base_enriched_at + i.minutes + } + end + end + + before do + insert_enriched_events(duplicated_rows) + allow(Subscriptions::ChargeCacheService).to receive(:expire_for_subscription) + end + + it "removes duplicated events" do + expect(::Clickhouse::EventsEnriched.where(transaction_id:, timestamp:).count).to eq(3) + + result = clean_service.call + + expect(result).to be_success + expect(::Clickhouse::EventsEnriched.where(transaction_id:, timestamp:).count).to eq(1) + + event = ::Clickhouse::EventsEnriched.find_by(transaction_id:, timestamp:) + expect(event.enriched_at).to match_datetime(base_enriched_at + 2.minutes) + + expect(Subscriptions::ChargeCacheService).to have_received(:expire_for_subscription).with(subscription) + end + + context "when events share the same transaction_id but have different codes" do + let(:other_code) { "other_event_code" } + + before do + insert_enriched_events([{ + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: transaction_id, + timestamp: timestamp, + code: other_code, + enriched_at: base_enriched_at + }]) + end + + it "does not delete events with a different code" do + result = clean_service.call + + expect(result).to be_success + expect(::Clickhouse::EventsEnriched.where(transaction_id:, timestamp:, code: "event_code").count).to eq(1) + expect(::Clickhouse::EventsEnriched.where(transaction_id:, timestamp:, code: other_code).count).to eq(1) + end + end + + context "when duplicate events share the same enriched_at timestamp" do + let(:duplicated_rows) do + Array.new(2) do + { + organization_id: organization.id, + external_subscription_id: subscription.external_id, + transaction_id: transaction_id, + timestamp: timestamp, + code: "event_code", + enriched_at: base_enriched_at + } + end + end + + it "deletes all copies including the keeper" do + expect(::Clickhouse::EventsEnriched.where(transaction_id:, timestamp:).count).to eq(2) + + result = clean_service.call + + expect(result).to be_success + expect(::Clickhouse::EventsEnriched.where(transaction_id:, timestamp:).count).to eq(0) + end + end + end +end diff --git a/spec/services/events/stores/clickhouse/enriched_store_migration/comparison_service_spec.rb b/spec/services/events/stores/clickhouse/enriched_store_migration/comparison_service_spec.rb new file mode 100644 index 0000000..daa3fe4 --- /dev/null +++ b/spec/services/events/stores/clickhouse/enriched_store_migration/comparison_service_spec.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::Stores::Clickhouse::EnrichedStoreMigration::ComparisonService do + subject(:service) { described_class.new(subscription:, deduplicate:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:deduplicate) { false } + + let(:billable_metric) { create(:billable_metric, organization:, code: "api_calls", aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan:, billable_metric:, organization:) } + + let(:fee_attributes) do + { + charge:, + charge_filter_id: nil, + grouped_by: {}, + properties: {"charges_from_datetime" => "2026-04-01T00:00:00Z", "charges_to_datetime" => "2026-04-30T23:59:59Z"} + } + end + + describe "#call" do + let(:legacy_fee) do + Fee.new( + fee_attributes.merge( + units: 10, + amount_cents: 1000, + events_count: 5, + total_aggregated_units: 10 + ) + ) + end + + let(:enriched_fee) do + Fee.new( + fee_attributes.merge( + units: 10, + amount_cents: 1000, + events_count: 5, + total_aggregated_units: 10 + ) + ) + end + + let(:legacy_usage) { SubscriptionUsage.new(fees: [legacy_fee]) } + let(:enriched_usage) { SubscriptionUsage.new(fees: [enriched_fee]) } + let(:legacy_result) { BaseService::LegacyResult.new.tap { |r| r.usage = legacy_usage } } + let(:enriched_result) { BaseService::LegacyResult.new.tap { |r| r.usage = enriched_usage } } + + before do + allow(Invoices::CustomerUsageService).to receive(:call) + .and_return(legacy_result, enriched_result) + end + + context "when fees match" do + it "returns zero diffs with timing and fee metadata" do + result = service.call + + expect(result).to be_success + expect(result.diff_count).to eq(0) + expect(result.legacy_elapsed).to be_a(Float) + expect(result.enriched_elapsed).to be_a(Float) + + detail = result.fee_details.first + expect(detail).to be_a(described_class::FeeDetail) + expect(detail.status).to eq("match") + expect(detail.billable_metric_code).to eq("api_calls") + expect(detail.aggregation_type).to eq("count_agg") + expect(detail.charge_model).to eq("standard") + expect(detail.from).to eq("2026-04-01T00:00:00Z") + expect(detail.to).to eq("2026-04-30T23:59:59Z") + end + end + + context "when fees have differences" do + let(:enriched_fee) do + Fee.new( + fee_attributes.merge( + units: 12, + amount_cents: 1200, + events_count: 6, + total_aggregated_units: 12 + ) + ) + end + + it "returns the diffs" do + result = service.call + + expect(result).to be_success + expect(result.diff_count).to eq(1) + + detail = result.fee_details.first + expect(detail.status).to eq("diff") + expect(detail.diffs).to match( + units: described_class::FieldDiff.new(legacy: 10.0, enriched: 12.0), + amount_cents: described_class::FieldDiff.new(legacy: 1000, enriched: 1200), + events_count: described_class::FieldDiff.new(legacy: 5, enriched: 6), + total_aggregated_units: described_class::FieldDiff.new(legacy: 10.0, enriched: 12.0) + ) + end + end + + context "when a fee exists only in legacy" do + let(:enriched_usage) { SubscriptionUsage.new(fees: []) } + + it "reports the fee as only_in_legacy" do + result = service.call + + expect(result).to be_success + expect(result.diff_count).to eq(1) + expect(result.fee_details.first.status).to eq("only_in_legacy") + end + end + + context "when a fee exists only in enriched" do + let(:legacy_usage) { SubscriptionUsage.new(fees: []) } + + it "reports the fee as only_in_enriched" do + result = service.call + + expect(result).to be_success + expect(result.diff_count).to eq(1) + expect(result.fee_details.first.status).to eq("only_in_enriched") + end + end + + context "when the service completes successfully" do + it "does not alter the organization state" do + original_flags = organization.feature_flags.dup + original_dedup = organization.clickhouse_deduplication_enabled + original_pre_filter = organization.pre_filter_events + + service.call + organization.reload + + expect(organization.feature_flags).to eq(original_flags) + expect(organization.clickhouse_deduplication_enabled).to eq(original_dedup) + expect(organization.pre_filter_events).to eq(original_pre_filter) + end + end + + context "when legacy computation fails" do + let(:legacy_result) do + BaseService::LegacyResult.new.tap { |r| r.service_failure!(code: "legacy_error", message: "legacy computation broke") } + end + + it "returns the original error" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("legacy_error") + expect(result.error.message).to include("legacy computation broke") + end + + it "does not alter the organization state" do + original_flags = organization.feature_flags.dup + original_dedup = organization.clickhouse_deduplication_enabled + original_pre_filter = organization.pre_filter_events + + service.call + organization.reload + + expect(organization.feature_flags).to eq(original_flags) + expect(organization.clickhouse_deduplication_enabled).to eq(original_dedup) + expect(organization.pre_filter_events).to eq(original_pre_filter) + end + end + + context "when enriched computation fails" do + let(:enriched_result) do + BaseService::LegacyResult.new.tap { |r| r.service_failure!(code: "enriched_error", message: "enriched computation broke") } + end + + it "returns the original error" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("enriched_error") + expect(result.error.message).to include("enriched computation broke") + end + + it "does not alter the organization state" do + original_flags = organization.feature_flags.dup + original_dedup = organization.clickhouse_deduplication_enabled + original_pre_filter = organization.pre_filter_events + + service.call + organization.reload + + expect(organization.feature_flags).to eq(original_flags) + expect(organization.clickhouse_deduplication_enabled).to eq(original_dedup) + expect(organization.pre_filter_events).to eq(original_pre_filter) + end + end + end +end diff --git a/spec/services/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_service_spec.rb b/spec/services/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_service_spec.rb new file mode 100644 index 0000000..0e58b2c --- /dev/null +++ b/spec/services/events/stores/clickhouse/enriched_store_migration/subscription_orchestrator_service_spec.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::Stores::Clickhouse::EnrichedStoreMigration::SubscriptionOrchestratorService do + subject(:service) { described_class.new(subscription_migration:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:migration) { create(:enriched_store_migration, :processing, organization:) } + + let(:comparison_service) { Events::Stores::Clickhouse::EnrichedStoreMigration::ComparisonService } + + let(:comparison_result) do + result = comparison_service::Result.new + result.diff_count = diff_count + result.fee_details = fee_details + result + end + + let(:diff_count) { 0 } + let(:fee_details) { [] } + + before do + allow(comparison_service).to receive(:call).and_return(comparison_result) + allow(Events::Stores::Clickhouse::EnrichedStoreMigration::OrchestratorJob) + .to receive(:perform_later) + end + + describe "#call" do + context "when pending with no diffs" do + let(:subscription_migration) do + create(:enriched_store_subscription_migration, + enriched_store_migration: migration, + organization:, + subscription:) + end + + it "transitions to completed via fast path" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_completed + expect(Events::Stores::Clickhouse::EnrichedStoreMigration::OrchestratorJob) + .to have_received(:perform_later).with(migration) + end + end + + context "when pending with diffs and codes to reprocess" do + let(:diff_count) { 1 } + let(:fee_details) do + [ + comparison_service::FeeDetail.new( + charge_id: "c1", charge_filter_id: nil, grouped_by: {}, + billable_metric_code: "api_calls", aggregation_type: "count_agg", + charge_model: "standard", from: nil, to: nil, + status: "diff", legacy: nil, enriched: nil, diffs: {} + ) + ] + end + + let(:subscription_migration) do + create(:enriched_store_subscription_migration, + enriched_store_migration: migration, + organization:, + subscription:) + end + + let(:reprocess_result) do + result = Events::Stores::Clickhouse::ReEnrichSubscriptionEventsService::Result.new + result.events_count = 42 + result.batch_count = 1 + result + end + + before do + allow(Events::Stores::Clickhouse::ReEnrichSubscriptionEventsService) + .to receive(:call).and_return(reprocess_result) + allow(Events::Stores::Clickhouse::EnrichedStoreMigration::WaitForEnrichmentJob) + .to receive(:perform_later) + end + + it "assigns billable_metric_codes and transitions to waiting_for_enrichment" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_waiting_for_enrichment + expect(subscription_migration.billable_metric_codes).to eq(["api_calls"]) + expect(subscription_migration.events_reprocessed_count).to eq(42) + expect(Events::Stores::Clickhouse::EnrichedStoreMigration::WaitForEnrichmentJob) + .to have_received(:perform_later).with(subscription_migration) + end + end + + context "when pending with diffs but no codes" do + let(:diff_count) { 1 } + let(:fee_details) do + [ + comparison_service::FeeDetail.new( + charge_id: "c1", charge_filter_id: nil, grouped_by: {}, + billable_metric_code: nil, aggregation_type: nil, + charge_model: "standard", from: nil, to: nil, + status: "diff", legacy: nil, enriched: nil, diffs: {} + ) + ] + end + + let(:subscription_migration) do + create(:enriched_store_subscription_migration, + enriched_store_migration: migration, + organization:, + subscription:) + end + + it "transitions to failed" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_failed + expect(subscription_migration.error_message).to include("no billable metric codes") + end + end + + context "when deduplicating successfully" do + let(:billable_metric_codes) { ["code1"] } + + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :deduplicating, + enriched_store_migration: migration, + organization:, + subscription:, + billable_metric_codes:) + end + + let(:dedup_result) do + result = Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService::Result.new + result.duplicated_count = 5 + result.queries = [] + result + end + + before do + allow(Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService) + .to receive(:call).and_return(dedup_result) + end + + it "transitions to completed after validating" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_completed + expect(subscription_migration.duplicates_removed_count).to eq(5) + end + end + + context "when deduplicating with timeout" do + let(:billable_metric_codes) { ["code1"] } + + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :deduplicating, + enriched_store_migration: migration, + organization:, + subscription:, + billable_metric_codes:) + end + + let(:dedup_result) do + result = Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService::Result.new + result.duplicated_count = 100 + result.queries = ["DELETE FROM events_enriched_expanded WHERE ..."] + result + end + + before do + allow(Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService) + .to receive(:call).and_return(dedup_result) + end + + it "transitions to dedup_paused with pending queries" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_dedup_paused + expect(subscription_migration.dedup_pending_queries).to eq(["DELETE FROM events_enriched_expanded WHERE ..."]) + end + end + + context "when deduplicating with failure" do + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :deduplicating, + enriched_store_migration: migration, + organization:, + subscription:) + end + + before do + failed_result = Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService::Result.new + failed_result.service_failure!(code: :dedup_failure, message: "Deduplication failed") + allow(Events::Stores::Clickhouse::CleanDuplicatedEnrichedExpandedService).to receive(:call).and_return(failed_result) + end + + it "transitions to failed" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_failed + expect(subscription_migration.error_message).to eq("dedup_failure: Deduplication failed") + end + end + + context "when validating with no diffs" do + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :validating, + enriched_store_migration: migration, + organization:, + subscription:) + end + + it "transitions to completed" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_completed + expect(Events::Stores::Clickhouse::EnrichedStoreMigration::OrchestratorJob) + .to have_received(:perform_later).with(migration) + end + end + + context "when validating with diffs" do + let(:diff_count) { 2 } + let(:fee_details) do + [ + comparison_service::FeeDetail.new( + charge_id: "c1", charge_filter_id: nil, grouped_by: {}, + billable_metric_code: "api_calls", aggregation_type: "count_agg", + charge_model: "standard", from: nil, to: nil, + status: "diff", legacy: nil, enriched: nil, diffs: {} + ), + comparison_service::FeeDetail.new( + charge_id: "c2", charge_filter_id: nil, grouped_by: {}, + billable_metric_code: "storage", aggregation_type: "sum_agg", + charge_model: "standard", from: nil, to: nil, + status: "diff", legacy: nil, enriched: nil, diffs: {} + ) + ] + end + + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :validating, + enriched_store_migration: migration, + organization:, + subscription:) + end + + it "transitions to failed" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_failed + expect(subscription_migration.error_message).to include("2 diff(s) remain") + end + end + + context "when comparison service fails" do + let(:subscription_migration) do + create(:enriched_store_subscription_migration, + enriched_store_migration: migration, + organization:, + subscription:) + end + + before do + failed_result = comparison_service::Result.new + failed_result.service_failure!(code: "error", message: "something broke") + allow(comparison_service).to receive(:call).and_return(failed_result) + end + + it "transitions to failed" do + service.call + subscription_migration.reload + expect(subscription_migration).to be_failed + expect(subscription_migration.error_message).to include("something broke") + end + end + + context "when in an unactionable state" do + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :waiting_for_enrichment, + enriched_store_migration: migration, + organization:, + subscription:) + end + + it "returns a failed result" do + result = service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code.to_s).to eq("invalid_status") + expect(result.error.error_message).to eq("Unprocessable status: waiting_for_enrichment") + end + end + end +end diff --git a/spec/services/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_service_spec.rb b/spec/services/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_service_spec.rb new file mode 100644 index 0000000..93155f3 --- /dev/null +++ b/spec/services/events/stores/clickhouse/enriched_store_migration/wait_for_enrichment_service_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::Stores::Clickhouse::EnrichedStoreMigration::WaitForEnrichmentService, clickhouse: {clean_before: true} do + subject(:service) { described_class.new(subscription_migration:, attempt:, max_attempts:) } + + let(:organization) { create(:organization) } + let(:migration) { create(:enriched_store_migration, :processing, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:, code: "code1") } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:subscription) { create(:subscription, organization:, plan:) } + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :waiting_for_enrichment, + enriched_store_migration: migration, + organization:, + subscription:, + events_reprocessed_count:, + billable_metric_codes: ["code1"]) + end + + let(:attempt) { 1 } + let(:max_attempts) { 10 } + let(:events_reprocessed_count) { 3 } + + def create_enriched_expanded_event(transaction_id: SecureRandom.uuid, event_charge: charge) + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id:, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: plan.id, + code: billable_metric.code, + aggregation_type: billable_metric.aggregation_type, + charge_id: event_charge.id, + charge_version: event_charge.updated_at, + charge_filter_id: "", + timestamp: Time.current, + properties: {}, + grouped_by: {}, + value: "1", + decimal_value: 1.to_d, + precise_total_amount_cents: nil, + enriched_at: Time.current + ) + end + + describe "#call" do + context "when enriched events are ready" do + before do + 3.times { create_enriched_expanded_event } + end + + it "transitions to deduplicating and returns ready status" do + result = service.call + + subscription_migration.reload + expect(result.status).to eq(:ready) + expect(result.enriched_count).to eq(3) + expect(subscription_migration).to be_deduplicating + expect(subscription_migration.attempts).to eq(1) + + expect(Events::Stores::Clickhouse::EnrichedStoreMigration::SubscriptionOrchestratorJob) + .to have_been_enqueued.with(subscription_migration) + end + end + + context "when a single event produces multiple enriched_expanded rows" do + let(:charge_2) { create(:standard_charge, plan:, billable_metric:) } + let(:events_reprocessed_count) { 1 } + + before do + transaction_id = SecureRandom.uuid + create_enriched_expanded_event(transaction_id:, event_charge: charge) + create_enriched_expanded_event(transaction_id:, event_charge: charge_2) + end + + it "counts distinct events, not total rows" do + result = service.call + + expect(result.status).to eq(:ready) + expect(result.enriched_count).to eq(1) + end + end + + context "when enriched events are not ready" do + before do + create_enriched_expanded_event + end + + it "returns not_ready status without state transition" do + result = service.call + + subscription_migration.reload + expect(result.status).to eq(:not_ready) + expect(result.enriched_count).to eq(1) + expect(subscription_migration).to be_waiting_for_enrichment + expect(subscription_migration.attempts).to eq(1) + end + end + + context "when max attempts reached" do + let(:attempt) { 10 } + + before do + create_enriched_expanded_event + end + + it "transitions to failed" do + result = service.call + + subscription_migration.reload + expect(result.status).to eq(:max_attempts_reached) + expect(subscription_migration).to be_failed + expect(subscription_migration.attempts).to eq(10) + expect(subscription_migration.error_message).to include("10 attempts") + end + end + + context "when subscription migration is not in waiting_for_enrichment state" do + let(:subscription_migration) do + create(:enriched_store_subscription_migration, :comparing, + enriched_store_migration: migration, + organization:, + subscription:) + end + + it "returns without action" do + result = service.call + expect(result).to be_success + expect(result.status).to be_nil + expect(subscription_migration.reload).to be_comparing + end + end + end +end diff --git a/spec/services/events/stores/clickhouse/pre_enrichment_check_service_spec.rb b/spec/services/events/stores/clickhouse/pre_enrichment_check_service_spec.rb new file mode 100644 index 0000000..6cfa3bd --- /dev/null +++ b/spec/services/events/stores/clickhouse/pre_enrichment_check_service_spec.rb @@ -0,0 +1,264 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Events::Stores::Clickhouse::PreEnrichmentCheckService do + subject(:service) do + described_class.new(organization:, reprocess:, batch_size: 1000, sleep_seconds: 0) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:reprocess) { false } + + let(:plan) { create(:plan, organization:) } + let(:started_at) { Time.zone.parse("2024-12-01") } + let(:subscription) do + create(:subscription, organization:, customer:, plan:, started_at:) + end + + before { subscription } + + describe "#call" do + context "when organization has no matching charges" do + it "returns empty subscriptions_to_reprocess" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + + context "with recurring BM subscriptions" do + let(:billable_metric) { create(:sum_billable_metric, :recurring, organization:, code: "recurring_metric") } + + before { create(:standard_charge, plan:, billable_metric:, organization:, created_at: started_at - 1.month) } + + context "when subscription started before cutoff and has active subscription with same external_id" do + let(:started_at) { Time.zone.parse("2025-11-01") } + + it "includes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({subscription.id => ["recurring_metric"]}) + end + end + + context "when subscription started after cutoff" do + let(:started_at) { Time.zone.parse("2025-12-01") } + + it "excludes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + + context "when subscription external_id has no active subscription" do + let(:subscription) do + create(:subscription, :terminated, organization:, customer:, plan:, started_at:) + end + + it "excludes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + end + + context "with pricing_group_keys subscriptions" do + let(:billable_metric) { create(:billable_metric, organization:, code: "grouped_metric") } + + before do + create( + :standard_charge, + plan:, + billable_metric:, + organization:, + properties: {amount: "100", pricing_group_keys: ["region"]}, + created_at: started_at - 1.month + ) + end + + context "when active subscription started before cutoff" do + let(:started_at) { Time.zone.parse("2026-03-01") } + + it "includes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({subscription.id => ["grouped_metric"]}) + end + end + + context "when subscription is terminated" do + let(:subscription) do + create(:subscription, :terminated, organization:, customer:, plan:, started_at:) + end + + it "excludes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + + context "when subscription started after cutoff" do + let(:started_at) { Time.zone.parse("2026-03-10") } + + it "excludes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + + context "when pricing_group_keys is on charge_filter but not on charge" do + let(:started_at) { Time.zone.parse("2026-03-01") } + let(:billable_metric) { create(:billable_metric, organization:, code: "filter_grouped_metric") } + + let(:charge) do + create(:standard_charge, plan:, billable_metric:, organization:, created_at: started_at - 1.month) + end + + before do + create(:charge_filter, charge:, properties: {amount: "50", pricing_group_keys: ["zone"]}, created_at: started_at - 1.day) + end + + it "includes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({subscription.id => ["filter_grouped_metric"]}) + end + end + end + + context "with new_charge_or_filters subscriptions" do + let(:billable_metric) { create(:billable_metric, organization:, code: "new_charge_metric") } + let(:charge) { create(:standard_charge, plan:, billable_metric:, organization:, created_at: started_at + 1.month) } + + before { charge } + + context "when charge was created after subscription started" do + it "includes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({subscription.id => ["new_charge_metric"]}) + end + end + + context "when charge was created before subscription started" do + let(:charge) { create(:standard_charge, plan:, billable_metric:, organization:, created_at: started_at - 1.month) } + + it "excludes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + + context "when subscription is terminated" do + let(:subscription) do + create(:subscription, :terminated, organization:, customer:, plan:, started_at:) + end + + it "excludes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + + context "when charge_filter was created after subscription started" do + let(:charge) { create(:standard_charge, plan:, billable_metric:, organization:, created_at: started_at - 1.month) } + + before do + create(:charge_filter, charge:, created_at: started_at + 1.day) + end + + it "includes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({subscription.id => ["new_charge_metric"]}) + end + end + + context "when charge_filter was created before subscription started" do + let(:charge) { create(:standard_charge, plan:, billable_metric:, organization:, created_at: started_at - 1.month) } + + before do + create(:charge_filter, charge:, created_at: started_at - 1.day) + end + + it "excludes the subscription" do + result = service.call + + expect(result).to be_success + expect(result.subscriptions_to_reprocess).to eq({}) + end + end + end + + context "when subscription matches multiple criteria" do + let(:recurring_metric) { create(:sum_billable_metric, :recurring, organization:, code: "recurring_metric") } + let(:grouped_metric) { create(:billable_metric, organization:, code: "grouped_metric") } + let(:shared_metric) { create(:sum_billable_metric, :recurring, organization:, code: "shared_metric") } + + before do + create(:standard_charge, plan:, billable_metric: recurring_metric, organization:) + + create( + :standard_charge, + plan:, + billable_metric: grouped_metric, + organization:, + properties: {amount: "100", pricing_group_keys: ["region"]} + ) + + create( + :standard_charge, + plan:, + billable_metric: shared_metric, + organization:, + properties: {amount: "100", pricing_group_keys: ["zone"]} + ) + end + + it "merges BM codes without duplicates" do + result = service.call + + expect(result).to be_success + codes = result.subscriptions_to_reprocess[subscription.id] + expect(codes).to match_array(["recurring_metric", "shared_metric", "grouped_metric"]) + end + end + + context "when reprocess is true" do + let(:reprocess) { true } + let(:billable_metric) { create(:sum_billable_metric, :recurring, organization:, code: "recurring_metric") } + let(:started_at) { Time.zone.parse("2025-11-01") } + + before do + create(:standard_charge, plan:, billable_metric:, organization:) + end + + it "enqueues PreEnrichmentCheckJob for each subscription" do + service.call + + expect(Events::Stores::Clickhouse::PreEnrichmentCheckJob).to have_been_enqueued + .with(subscription_id: subscription.id, codes: ["recurring_metric"], batch_size: 1000, sleep_seconds: 0) + end + end + end +end diff --git a/spec/services/events/stores/clickhouse/re_enrich_subscription_events_service_spec.rb b/spec/services/events/stores/clickhouse/re_enrich_subscription_events_service_spec.rb new file mode 100644 index 0000000..2012c61 --- /dev/null +++ b/spec/services/events/stores/clickhouse/re_enrich_subscription_events_service_spec.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Events::Stores::Clickhouse::ReEnrichSubscriptionEventsService, :clickhouse do + subject(:service) do + described_class.new( + subscription:, + codes:, + reprocess:, + batch_size:, + sleep_seconds: 0 + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, status:, organization:, customer:, plan:, started_at: 1.month.ago, terminated_at:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:codes) { [] } + let(:reprocess) { true } + let(:batch_size) { 1000 } + + let(:kafka_producer) { instance_double(WaterDrop::Producer) } + + let(:status) { :active } + let(:terminated_at) { nil } + + before do + allow(Karafka).to receive(:producer).and_return(kafka_producer) + allow(kafka_producer).to receive(:produce_many_async) + allow(ENV).to receive(:fetch).and_call_original + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("LAGO_KAFKA_RAW_EVENTS_TOPIC").and_return("test-topic") + end + + describe "#call" do + let!(:event) do + create( + :clickhouse_events_raw, + organization:, + subscription:, + billable_metric:, + external_customer_id: customer.external_id, + timestamp: 2.weeks.ago, + properties: {"key" => "value"}, + precise_total_amount_cents: "12.5" + ) + end + + it "produces Kafka messages with expected payload structure" do + result = service.call + + expect(result).to be_success + expect(result.events_count).to eq(1) + expect(result.batch_count).to eq(1) + + expect(kafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(1) + + message = messages.first + expect(message[:topic]).to eq("test-topic") + expect(message[:key]).to eq("#{organization.id}-#{subscription.external_id}") + + payload = JSON.parse(message[:payload]) + expect(payload).to include( + "organization_id" => organization.id, + "external_customer_id" => customer.external_id, + "external_subscription_id" => subscription.external_id, + "transaction_id" => event.transaction_id, + "code" => billable_metric.code, + "precise_total_amount_cents" => "12.5", + "properties" => {"key" => "value"}, + "source" => Events::KafkaProducerService::EVENT_SOURCE, + "source_metadata" => { + "reprocess" => true, + "api_post_processed" => true + } + ) + end + end + + context "with code filtering" do + let(:codes) { [billable_metric.code] } + let(:other_metric) { create(:billable_metric, organization:) } + + before do + create( + :clickhouse_events_raw, + organization:, + subscription:, + billable_metric: other_metric, + external_customer_id: customer.external_id, + timestamp: 2.weeks.ago + ) + end + + it "only produces messages for matching codes" do + result = service.call + + expect(result).to be_success + expect(result.events_count).to eq(1) + + expect(kafka_producer).to have_received(:produce_many_async) do |messages| + codes_in_messages = messages.map { |m| JSON.parse(m[:payload])["code"] } + expect(codes_in_messages).to eq([billable_metric.code]) + end + end + end + + context "with terminated subscription" do + let(:terminated_at) { 1.week.ago } + let(:status) { :terminated } + + before do + # Create events after terminated_at + create( + :clickhouse_events_raw, + organization:, + subscription:, + billable_metric:, + external_customer_id: customer.external_id, + timestamp: 1.day.ago + ) + end + + it "excludes events after terminated_at" do + result = service.call + + expect(result).to be_success + expect(result.events_count).to eq(1) + + expect(kafka_producer).to have_received(:produce_many_async) do |messages| + transaction_ids = messages.map { |m| JSON.parse(m[:payload])["transaction_id"] } + expect(transaction_ids).to eq([event.transaction_id]) + end + end + end + + context "with reprocess set to false" do + let(:reprocess) { false } + + it "reflects reprocess flag in source_metadata" do + service.call + + expect(kafka_producer).to have_received(:produce_many_async) do |messages| + payload = JSON.parse(messages.first[:payload]) + expect(payload["source_metadata"]["reprocess"]).to be(false) + end + end + end + + context "with duplicated events" do + let(:event_timestamp) { 2.weeks.ago } + let(:shared_transaction_id) { SecureRandom.uuid } + + let!(:event) do + create( + :clickhouse_events_raw, + organization:, + subscription:, + billable_metric:, + external_customer_id: customer.external_id, + transaction_id: shared_transaction_id, + timestamp: event_timestamp, + ingested_at: 2.days.ago, + properties: {"key" => "old_value"} + ) + end + + before do + create( + :clickhouse_events_raw, + organization:, + subscription:, + billable_metric:, + external_customer_id: customer.external_id, + transaction_id: shared_transaction_id, + timestamp: event_timestamp, + ingested_at: 1.day.ago, + properties: {"key" => "new_value"} + ) + end + + it "only produces one message for the most recently ingested event" do + result = service.call + + expect(result).to be_success + expect(result.events_count).to eq(1) + + expect(kafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(1) + + payload = JSON.parse(messages.first[:payload]) + expect(payload["properties"]).to eq({"key" => "new_value"}) + end + end + end + + context "with multiple batches" do + let(:batch_size) { 1 } + + before do + create( + :clickhouse_events_raw, + organization:, + subscription:, + billable_metric:, + external_customer_id: customer.external_id, + timestamp: 1.week.ago + ) + end + + it "produces correct counts" do + result = service.call + + expect(result).to be_success + expect(result.events_count).to eq(2) + expect(result.batch_count).to eq(2) + expect(kafka_producer).to have_received(:produce_many_async).twice + end + end + + context "with timestamp precision" do + let(:timestamp) do + time = subscription.started_at.dup + time + 3.days + 0.299.seconds + end + + let!(:event) do + create( + :clickhouse_events_raw, + organization:, + subscription:, + billable_metric:, + external_customer_id: customer.external_id, + timestamp:, + properties: {"key" => "value"}, + precise_total_amount_cents: "12.5" + ) + end + + it "produces Kafka messages with expected payload structure" do + result = service.call + + expect(result).to be_success + expect(result.events_count).to eq(1) + expect(result.batch_count).to eq(1) + + expect(kafka_producer).to have_received(:produce_many_async) do |messages| + expect(messages.size).to eq(1) + + message = messages.first + expect(message[:topic]).to eq("test-topic") + expect(message[:key]).to eq("#{organization.id}-#{subscription.external_id}") + + payload = JSON.parse(message[:payload]) + expect(payload).to include( + "organization_id" => organization.id, + "external_customer_id" => customer.external_id, + "external_subscription_id" => subscription.external_id, + "transaction_id" => event.transaction_id, + "timestamp" => timestamp.strftime("%s.%3N"), + "code" => billable_metric.code, + "precise_total_amount_cents" => "12.5", + "properties" => {"key" => "value"}, + "source" => Events::KafkaProducerService::EVENT_SOURCE, + "source_metadata" => { + "reprocess" => true, + "api_post_processed" => true + } + ) + end + end + end + end +end diff --git a/spec/services/events/stores/clickhouse_enriched_store_spec.rb b/spec/services/events/stores/clickhouse_enriched_store_spec.rb new file mode 100644 index 0000000..a1db87f --- /dev/null +++ b/spec/services/events/stores/clickhouse_enriched_store_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +require_relative "shared_examples/an_event_store" + +RSpec.describe Events::Stores::ClickhouseEnrichedStore, clickhouse: {clean_before: true} do + def create_event(timestamp:, value:, properties: {}, transaction_id: SecureRandom.uuid, code: billable_metric.code, charge_filter: nil, enriched_at: nil, event_charge: nil) + effective_charge = event_charge || charge + + grouped_values = if events_grouped_by.present? + events_grouped_by.index_with({}) { properties[it] || "" } + end + + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id:, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: subscription.plan_id, + code:, + aggregation_type: billable_metric.aggregation_type, + charge_id: effective_charge.id, + charge_version: effective_charge.updated_at, + charge_filter_id: charge_filter&.id || "", + charge_filter_version: charge_filter&.updated_at, + timestamp:, + properties: properties.merge(billable_metric.field_name => value).compact, + grouped_by: grouped_values, + value:, + decimal_value: value&.to_i&.to_d, + precise_total_amount_cents: value, + enriched_at: + ) + end + + alias_method :create_enriched_event, :create_event + + def format_timestamp(timestamp, precision: 3) + Time.zone.parse(timestamp).strftime("%Y-%m-%d %H:%M:%S.%#{precision}L") + end + + context "without deduplication" do + it_behaves_like "an event store", with_event_duplication: false, excluding_features: + [:grouped_sum_breakdown, :grouped_count_breakdown, :grouped_last_breakdown, :grouped_max_breakdown, :grouped_unique_count_breakdown, :grouped_weighted_sum_breakdown] + end + + context "with deduplication" do + it_behaves_like "an event store", with_event_duplication: true, excluding_features: + [:grouped_sum_breakdown, :grouped_count_breakdown, :grouped_last_breakdown, :grouped_max_breakdown, :grouped_unique_count_breakdown, :grouped_weighted_sum_breakdown] + end +end diff --git a/spec/services/events/stores/clickhouse_store_spec.rb b/spec/services/events/stores/clickhouse_store_spec.rb new file mode 100644 index 0000000..3407de8 --- /dev/null +++ b/spec/services/events/stores/clickhouse_store_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "rails_helper" + +require_relative "shared_examples/an_event_store" + +RSpec.describe Events::Stores::ClickhouseStore, clickhouse: {clean_before: true} do + def create_event(timestamp:, value:, properties: {}, transaction_id: SecureRandom.uuid, code: billable_metric.code, charge_filter: nil, enriched_at: nil, event_charge: nil) + Clickhouse::EventsEnriched.create!( + transaction_id: transaction_id, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code:, + timestamp: timestamp, + properties: properties.merge(billable_metric.field_name => value).compact, + value: value, + decimal_value: value&.to_i&.to_d, + precise_total_amount_cents: value, + enriched_at: Time.current + ) + end + + def create_enriched_event(timestamp:, value:, properties: {}, transaction_id: SecureRandom.uuid, code: billable_metric.code, charge_filter: nil, enriched_at: nil) + Clickhouse::EventsEnrichedExpanded.create!( + transaction_id:, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + plan_id: subscription.plan_id, + code:, + aggregation_type: billable_metric.aggregation_type, + charge_id: charge.id, + charge_version: charge.updated_at, + charge_filter_id: charge_filter&.id, + charge_filter_version: charge_filter&.updated_at, + timestamp:, + properties:, + value:, + decimal_value: value&.to_i&.to_d, + precise_total_amount_cents: nil + ) + end + + def format_timestamp(timestamp, precision: 3) + Time.zone.parse(timestamp).strftime("%Y-%m-%d %H:%M:%S.%#{precision}L") + end + + context "without deduplication" do + it_behaves_like "an event store", with_event_duplication: false + end + + context "with deduplication" do + it_behaves_like "an event store" + + # Regression test for https://github.com/getlago/lago-api/pull/5359 + # + # Two rows share the same (transaction_id, timestamp) but carry different + # filterable properties. Deduplication via argMax(enriched_at) must resolve + # to a single row FIRST — otherwise the same logical event could be counted + # in multiple filter/group buckets (once per property value it ever had). + describe "filters applied after deduplication" do + subject(:event_store) do + described_class.new( + code: billable_metric.code, + subscription:, + boundaries:, + filters: { + grouped_by: nil, + grouped_by_values: nil, + matching_filters: matching_filters, + ignored_filters: [], + charge_id: charge.id, + charge_filter: nil + }, + deduplicate: true + ) + end + + let(:billable_metric) { create(:billable_metric, field_name: "value", code: "bm:code") } + let(:organization) { billable_metric.organization } + let(:charge) { create(:standard_charge, organization:, billable_metric:) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, started_at: DateTime.parse("2023-03-15")) } + let(:subscription_started_at) { subscription.started_at.beginning_of_day } + let(:boundaries) do + { + from_datetime: subscription_started_at, + to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 31 + } + end + + let(:transaction_id) { SecureRandom.uuid } + let(:timestamp) { subscription_started_at + 1.day } + + before do + Clickhouse::EventsEnriched.create!( + transaction_id:, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp:, + properties: {"value" => 1, "region" => "europe"}, + value: 1, + decimal_value: 1.to_d, + precise_total_amount_cents: 1, + enriched_at: 1.minute.ago + ) + + Clickhouse::EventsEnriched.create!( + transaction_id:, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + timestamp:, + properties: {"value" => 1, "region" => "asia"}, + value: 1, + decimal_value: 1.to_d, + precise_total_amount_cents: 1, + enriched_at: Time.current + ) + end + + context "when the filter matches only the earlier (superseded) property value" do + let(:matching_filters) { {"region" => ["europe"]} } + + it "does not count the event (latest enrichment is asia, filter excludes it)" do + expect(event_store.count).to eq(0) + end + end + + context "when the filter matches only the latest property value" do + let(:matching_filters) { {"region" => ["asia"]} } + + it "counts the deduplicated event exactly once" do + expect(event_store.count).to eq(1) + end + end + end + end +end diff --git a/spec/services/events/stores/postgres_store_spec.rb b/spec/services/events/stores/postgres_store_spec.rb new file mode 100644 index 0000000..d5e0a2b --- /dev/null +++ b/spec/services/events/stores/postgres_store_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +require_relative "shared_examples/an_event_store" + +RSpec.describe Events::Stores::PostgresStore do + it_behaves_like "an event store", with_event_duplication: false do + def create_event(timestamp:, value:, properties: {}, transaction_id: SecureRandom.uuid, code: billable_metric.code, charge_filter: nil, enriched_at: nil, event_charge: nil) + create( + :event, + transaction_id: transaction_id, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + external_customer_id: customer.external_id, + code:, + timestamp: timestamp, + properties: properties.merge(billable_metric.field_name => value), + precise_total_amount_cents: value + ) + end + + def create_enriched_event(timestamp:, value:, properties: {}, transaction_id: SecureRandom.uuid, code: billable_metric.code, charge_filter: nil, enriched_at: nil) + event = create( + :event, + transaction_id:, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + external_customer_id: customer.external_id, + code:, + timestamp:, + properties: + ) + + create( + :enriched_event, + subscription:, + event:, + charge:, + charge_filter_id: charge_filter&.id, + value:, + decimal_value: value&.to_i&.to_d + ) + end + + def format_timestamp(timestamp, precision: nil) + Time.zone.parse(timestamp) + end + end +end diff --git a/spec/services/events/stores/shared_examples/an_event_store.rb b/spec/services/events/stores/shared_examples/an_event_store.rb new file mode 100644 index 0000000..7cf003b --- /dev/null +++ b/spec/services/events/stores/shared_examples/an_event_store.rb @@ -0,0 +1,2592 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an event store" do |with_event_duplication: true, excluding_features: []| + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + matching_filters:, + ignored_filters:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:billable_metric) { create(:billable_metric, field_name: "value", code: "bm:code") } + let(:organization) { billable_metric.organization } + let(:charge) { create(:standard_charge, organization:, billable_metric:) } + let(:charge_filter) { nil } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, started_at:) } + + let(:started_at) { DateTime.parse("2023-03-15") } + let(:code) { billable_metric.code } + + let(:subscription_started_at) { subscription.started_at.beginning_of_day } + let(:boundaries) do + { + from_datetime: subscription_started_at, + to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 31 + } + end + + let(:grouped_by) { nil } + let(:grouped_by_values) { nil } + let(:with_grouped_by_values) { nil } + let(:events_grouped_by) { grouped_by } + let(:matching_filters) { {} } + let(:ignored_filters) { [] } + + let(:events) do + events = [ + create_event( + timestamp: subscription_started_at + 1.day, + value: 1, + properties: {"region" => "europe", "country" => "france", "city" => "paris"}, + charge_filter:, + transaction_id: SecureRandom.uuid + ), + create_event( + timestamp: subscription_started_at + 2.days, + value: 2, + properties: {}, + transaction_id: SecureRandom.uuid + ), + create_event( + timestamp: subscription_started_at + 3.days, + value: 3, + properties: {"region" => "europe", "country" => "france"}, + charge_filter:, + transaction_id: SecureRandom.uuid + ), + create_event( + timestamp: subscription_started_at + 4.days, + value: 4, + properties: {}, + transaction_id: SecureRandom.uuid + ), + create_event( + timestamp: subscription_started_at + 5.days, + value: with_event_duplication ? 10 : 5, + properties: {"region" => "europe", "country" => "united kingdom", "city" => "london"}, + transaction_id: SecureRandom.uuid + ) + ] + + if with_event_duplication + last_event = events.pop + + attributes = { + timestamp: last_event.timestamp, + value: 5, + properties: last_event.properties, + transaction_id: last_event.transaction_id + } + + if last_event.respond_to?(:charge_filter_id) + attributes[:charge_filter] = last_event.charge_filter_id.present? ? charge_filter : nil + end + + if last_event.respond_to?(:enriched_at) + attributes[:enriched_at] = Time.current + 1.second + end + + events << create_event(**attributes) + end + + events + end + + def create_european_event(country:, city:, value:, timestamp:, charge_filter: nil) + create_event( + timestamp:, + value:, + properties: {"region" => "europe", "country" => country, "city" => city}, + transaction_id: SecureRandom.uuid, + charge_filter: + ) + end + + def create_events_for_filters + create_european_event(country: "united kingdom", city: "manchester", value: -1, timestamp: subscription_started_at + 6.days, charge_filter:) + create_european_event(country: "france", city: "cambridge", value: -2, timestamp: subscription_started_at + 7.days, charge_filter:) + create_european_event(country: "france", city: "caen", value: -3, timestamp: subscription_started_at + 8.days) + create_european_event(country: "germany", city: "berlin", value: -4, timestamp: subscription_started_at + 9.days) + create_european_event(country: "united kingdom", city: "cambridge", value: -5, timestamp: subscription_started_at + 10.days) + end + + define_singleton_method(:include_feature?) do |feature| + !excluding_features.include?(feature) + end + + before { events } + + if include_feature?(:events) + describe "#events" do + it "returns the events" do + retrieved_events = event_store.events.to_a + + expect(retrieved_events.count).to eq(5) + expect(retrieved_events).to match_array(events) + # we need to check value because the duplicate has the same id so array equality is not sufficiant + expect(retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s }).to match_array(["1", "2", "3", "4", "5"]) + end + + context "when ordered is true" do + it "returns the events ordered by timestamp" do + retrieved_events = event_store.events(ordered: true) + + expect(retrieved_events).to eq(events) + # we need to check value because the duplicate has the same id so array equality is not sufficiant + expect(retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s }).to eq(["1", "2", "3", "4", "5"]) + end + end + + context "with events before from_datetime" do + before do + create_event( + timestamp: subscription_started_at - 1.day, + value: 0, + properties: {"region" => "europe", "country" => "france"}, + transaction_id: SecureRandom.uuid + ) + end + + it "excludes events before from_datetime by default" do + retrieved_events = event_store.events.to_a + values = retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s } + + expect(retrieved_events.count).to eq(5) + expect(values).not_to include("0") + expect(values).to match_array(["1", "2", "3", "4", "5"]) + end + + context "when use_from_boundary is false" do + before { event_store.use_from_boundary = false } + + it "includes events before from_datetime" do + retrieved_events = event_store.events.to_a + values = retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s } + + expect(retrieved_events.count).to eq(6) + expect(values).to match_array(["0", "1", "2", "3", "4", "5"]) + end + + context "when force_from is true" do + it "excludes events before from_datetime" do + retrieved_events = event_store.events(force_from: true).to_a + values = retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s } + + expect(retrieved_events.count).to eq(5) + expect(values).not_to include("0") + expect(values).to match_array(["1", "2", "3", "4", "5"]) + end + end + end + end + + context "with events after to_datetime" do + let(:boundaries) do + { + from_datetime: subscription_started_at, + to_datetime: subscription_started_at + 3.days + 12.hours, + charges_duration: 31 + } + end + + it "excludes events after to_datetime" do + retrieved_events = event_store.events.to_a + values = retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s } + + expect(retrieved_events.count).to eq(3) + expect(values).to match_array(["1", "2", "3"]) + end + end + + context "with max_timestamp boundary" do + let(:boundaries) do + { + from_datetime: subscription_started_at, + to_datetime: subscription.started_at.end_of_month.end_of_day, + max_timestamp: subscription_started_at + 3.days + 12.hours, + charges_duration: 31 + } + end + + it "uses max_timestamp instead of to_datetime" do + retrieved_events = event_store.events.to_a + values = retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s } + + expect(retrieved_events.count).to eq(3) + expect(values).to match_array(["1", "2", "3"]) + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the filtered events" do + retrieved_events = event_store.events.to_a + values = retrieved_events.map { |e| e.properties[billable_metric.field_name].to_s } + + # We include: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, caen -> -3 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # - europe, united kingdom, manchester -> -1 + # Then exclude: + # - europe, france, caen -> -3 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # We should have 4 events: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, manchester -> -1 + expect(retrieved_events.count).to eq(4) + expect(values).to match_array(["1", "3", "-1", "-2"]) + end + end + end + end + + if include_feature?(:count) + describe "#count" do + it "returns the number of unique events" do + expect(event_store.count).to eq(5) + end + + context "with grouped_by_values" do + let(:grouped_by_values) { {"region" => "europe"} } + let(:events_grouped_by) { ["region"] } + + it "returns the number of unique events" do + expect(event_store.count).to eq(3) + end + + context "when grouped_by_values value is nil" do + let(:grouped_by_values) { {"region" => nil} } + + it "returns the number of unique events" do + expect(event_store.count).to eq(2) + end + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the number of unique events" do + # We include: + # - europe, france, + # - europe, france, paris + # - europe, france, caen + # - europe, france, cambridge + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # - europe, united kingdom, manchester + # Then exclude: + # - europe, france, caen + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # We should have 4 events: + # - europe, france, + # - europe, france, paris + # - europe, france, cambridge + # - europe, united kingdom, manchester + expect(event_store.count).to eq(4) + end + + # We faced an issue where Arel caused a Stack Level Too Deep error due to how the request `OR` conditons are build. + # This test is used to ensure that we can handle this situation. + # This test fails when using the Arel version. + context "when there are many filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"], "city" => ["paris", "london", "cambridge", "caen", "manchester"]} } + let(:ignored_filters) do + Array.new(200) do |i| + {"region" => [Faker::Alphanumeric.alphanumeric(number: 10)], "city" => [Faker::Alphanumeric.alphanumeric(number: 10)]} + end + end + + # This function is used to simulate a nested stack. Otherwise we'll reach the Clickhouse query size limits + # before reaching a stack error. + def within_nested_stack(stack_number, &block) + if stack_number > 0 + within_nested_stack(stack_number - 1, &block) + else + yield + end + end + + it "does not raise an error" do + within_nested_stack(8200) do + expect do + event_store.count + end.not_to raise_error + end + end + end + + # Charge filters with no values or duplicate values should not exist but + # can due to missing validations. They produce empty hashes or hashes with + # all-empty-array values in ignored_filters, which would generate invalid + # SQL (e.g., empty Tuple() in ClickHouse) without the defensive guards. + context "when ignored_filters contains empty and all-empty-values entries" do + let(:ignored_filters) do + [ + {}, + {"city" => [], "country" => []}, + {"city" => ["caen"]}, + {"city" => ["cambridge", "london"], "country" => ["united kingdom"]} + ] + end + + it "returns the number of unique events ignoring empty entries" do + expect(event_store.count).to eq(4) + end + end + end + + context "with max timestamp" do + let(:boundaries) do + { + from_datetime: subscription.started_at.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + max_timestamp: subscription.started_at.beginning_of_day.end_of_day + 2.days, + charges_duration: 31 + } + end + + it "returns the number of unique events" do + expect(event_store.count).to eq(2) + end + end + + if with_event_duplication + context "with only duplicated transaction_id" do + before do + event = events.first + + create_event( + timestamp: subscription_started_at + 5.days, + value: 1, + properties: {}, + transaction_id: event.transaction_id + ) + end + + it "takes the event into account" do + expect(event_store.count).to eq(6) + end + end + end + end + end + + if include_feature?(:with_grouped_by_values) + describe "#with_grouped_by_values" do + let(:with_grouped_by_values) { {"region" => "europe"} } + let(:events_grouped_by) { ["region"] } + + it "applies the grouped_by_values in the block" do + event_store.with_grouped_by_values(with_grouped_by_values) do + expect(event_store.count).to eq(3) + end + end + end + end + + if include_feature?(:distinct_codes) + describe "#distinct_codes" do + before do + create_event( + timestamp: subscription_started_at + (1..10).to_a.sample.days, + value: "value", + transaction_id: SecureRandom.uuid, + code: "other_code" + ) + end + + it "returns the distinct event codes" do + expect(event_store.distinct_codes).to match_array([code, "other_code"]) + end + end + end + + if include_feature?(:grouped_count) + describe "#grouped_count" do + let(:grouped_by) { %w[region] } + + it "returns the number of unique events grouped by the provided group" do + result = event_store.grouped_count + + expect(result).to match_array([{groups: {"region" => nil}, value: 2}, {groups: {"region" => "europe"}, value: 3}]) + end + + context "with multiple groups" do + let(:grouped_by) { %w[region country] } + + it "returns the number of unique events grouped by the provided groups" do + result = event_store.grouped_count + + expect(result).to match_array([ + {groups: {"country" => "france", "region" => "europe"}, value: 2}, + {groups: {"country" => nil, "region" => nil}, value: 2}, + {groups: {"country" => "united kingdom", "region" => "europe"}, value: 1} + ]) + end + end + end + end + + if include_feature?(:sum_precise_total_amount_cents) + describe "#sum_precise_total_amount_cents" do + it "returns the sum of precise_total_amount_cent values" do + expect(event_store.sum_precise_total_amount_cents).to eq(15) + end + + context "without events" do + let(:events) { [] } + + it "returns zero" do + expect(event_store.sum_precise_total_amount_cents).to eq(0) + end + end + end + end + + if include_feature?(:grouped_sum_precise_total_amount_cents) + describe "#grouped_sum_precise_total_amount_cents" do + let(:grouped_by) { %w[region] } + + it "returns the sum of values grouped by the provided group" do + result = event_store.grouped_sum_precise_total_amount_cents + + expect(result).to match_array([{groups: {"region" => nil}, value: 6}, {groups: {"region" => "europe"}, value: 9}]) + end + + context "with multiple groups" do + let(:grouped_by) { %w[region country] } + + it "returns the sum of values grouped by the provided groups" do + result = event_store.grouped_sum_precise_total_amount_cents + + expect(result).to match_array([ + {groups: {"country" => "united kingdom", "region" => "europe"}, value: 5}, + {groups: {"country" => nil, "region" => nil}, value: 6}, + {groups: {"country" => "france", "region" => "europe"}, value: 4} + ]) + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + let(:grouped_by) { %w[region country] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the sum filtered and grouped" do + result = event_store.grouped_sum_precise_total_amount_cents + + # We include: + # - europe, france, + # - europe, france, paris + # - europe, france, caen + # - europe, france, cambridge + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # - europe, united kingdom, manchester + # Then exclude: + # - europe, france, caen + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # We should have 2 events: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, manchester -> -1 + expect(result).to match_array([ + {groups: {"country" => "united kingdom", "region" => "europe"}, value: -1}, + {groups: {"country" => "france", "region" => "europe"}, value: 2} + ]) + end + end + end + end + + if include_feature?(:active_unique_property?) + describe "#active_unique_property?" do + before { event_store.aggregation_property = billable_metric.field_name } + + it "returns false when no previous events exist" do + event = create_event(timestamp: subscription_started_at + 2.days, value: 999) + expect(event_store).not_to be_active_unique_property(event) + end + + context "when event is already active" do + it "returns true if the event property is active" do + event = create_event(timestamp: subscription_started_at + 3.days, value: 2) + + expect(event_store).to be_active_unique_property(event) + end + end + + context "with a previous removed event" do + before do + create_event(timestamp: subscription_started_at + 2.days + 1.hour, value: 2, properties: {operation_type: "remove"}) + end + + it "returns false" do + event = create_event(timestamp: subscription_started_at + 3.days, value: 2) + + expect(event_store).not_to be_active_unique_property(event) + end + end + end + end + + if include_feature?(:unique_count) + describe "#unique_count" do + it "returns the number of unique active event properties" do + create_event(timestamp: subscription_started_at + 2.days + 1.hour, value: 2, properties: {operation_type: "remove"}) + + event_store.aggregation_property = billable_metric.field_name + + expect(event_store.unique_count).to eq(4) # 5 events added / 1 removed + end + end + end + + if include_feature?(:prorated_unique_count) + describe "#prorated_unique_count" do + before do + event_store.aggregation_property = billable_metric.field_name + end + + it "returns the number of unique active event properties" do + create_event( + timestamp: boundaries[:from_datetime] + 0.days, + value: "2" + ) + + create_event( + timestamp: (boundaries[:from_datetime] + 0.days).end_of_day, + properties: { + operation_type: "remove" + }, + value: "2" + ) + + # NOTE: Events calculation: 16/31 + 1/31 + 15/31 + 14/31 + 13/31 + 12/31 + # Events: + # 1 => added on 0 day, never removed => 16/31 + # 2 => added on 0 day, removed on 0 day => 1/31 + # 2 => added on 1 day, never removed => 15/31 + # 3 => added on 2 day, never removed => 14/31 + # 4 => added on 3 day, never removed => 13/31 + # 5 => added on 4 day, never removed => 12/31 + expect(event_store.prorated_unique_count.round(3)).to eq(2.29) + end + + context "with multiple events at the same day" do + it "returns the number of unique active event properties merged within one day" do + event_params = [ + {timestamp: boundaries[:from_datetime], operation_type: "remove"}, + {timestamp: boundaries[:from_datetime] + 1.hour, operation_type: "add"}, + {timestamp: boundaries[:from_datetime] + 2.hours, operation_type: "remove"}, + {timestamp: boundaries[:from_datetime] + 3.hours, operation_type: "add"}, + {timestamp: boundaries[:from_datetime] + 1.day, operation_type: "remove"}, + {timestamp: boundaries[:from_datetime] + 1.day + 1.hour, operation_type: "add"}, + {timestamp: boundaries[:from_datetime] + 2.days + 1.hour, operation_type: "remove"} + ] + + event_params.each do |params| + create_event( + timestamp: params[:timestamp], + properties: { + operation_type: params[:operation_type] + }, + value: "2" + ) + end + + # NOTE: Events calculation: 3/31 + # Events: + # 1 => added on 0 day, never removed => 16/31 + # 2 => added on 0 day, removed on 2 day => 3/31 + # 3 => added on 2 day, never removed => 14/31 + # 4 => added on 3 day, never removed => 13/31 + # 5 => added on 4 day, never removed => 12/31 + expect(event_store.prorated_unique_count.round(3)).to eq(1.871) # 16/31 + 3/31 + 14/31 + 13/31 + 12/31 + end + end + end + end + + if include_feature?(:prorated_unique_count_breakdown) + describe "#prorated_unique_count_breakdown" do + before do + event_store.aggregation_property = billable_metric.field_name + end + + it "returns the breakdown of add and remove of unique event properties" do + create_event( + timestamp: boundaries[:from_datetime] + 1.day, + value: "2" + ) + + create_event( + timestamp: boundaries[:from_datetime] + 1.day, + value: "30" + ) + + create_event( + timestamp: (boundaries[:from_datetime] + 1.day).end_of_day, + properties: { + operation_type: "remove" + }, + value: "2" + ) + + result = event_store.prorated_unique_count_breakdown + expect(result.count).to eq(7) + + # Ensure consistent ordering with 2 events with the same timestamp + expect(result.map { it["property"] }).to eq(%w[1 2 30 2 3 4 5]) + + grouped_result = result.group_by { |r| r["property"] } + + # NOTE: group with property 1 + group = grouped_result["1"] + expect(group.count).to eq(1) + expect(group.first["prorated_value"].round(3)).to eq(0.516) # 16/31 + expect(group.first["operation_type"]).to eq("add") + + # NOTE: group with property 2 (added and removed) + group = grouped_result["2"] + expect(group.first["prorated_value"].round(3)).to eq(0.032) # 1/31 + expect(group.last["prorated_value"].round(3)).to eq(0.484) # 15/31 + expect(group.count).to eq(2) + + # NOTE: group with property 3 + group = grouped_result["3"] + expect(group.count).to eq(1) + expect(group.first["prorated_value"].round(3)).to eq(0.452) # 14/31 + expect(group.first["operation_type"]).to eq("add") + + # NOTE: group with property 4 + group = grouped_result["4"] + expect(group.count).to eq(1) + expect(group.first["prorated_value"].round(3)).to eq(0.419) # 13/31 + expect(group.first["operation_type"]).to eq("add") + + # NOTE: group with property 5 + group = grouped_result["5"] + expect(group.count).to eq(1) + expect(group.first["prorated_value"].round(3)).to eq(0.387) # 12/31 + expect(group.first["operation_type"]).to eq("add") + end + end + end + + if include_feature?(:grouped_unique_count) + describe "#grouped_unique_count" do + let(:grouped_by) { %w[region country city] } + let(:started_at) { Time.zone.parse("2023-03-01") } + + before do + event_store.aggregation_property = billable_metric.field_name + end + + it "returns the unique count of event properties" do + result = event_store.grouped_unique_count + + expect(result).to match_array([ + {groups: {"city" => nil, "country" => "france", "region" => "europe"}, value: 1}, + {groups: {"city" => "paris", "country" => "france", "region" => "europe"}, value: 1}, + {groups: {"city" => "london", "country" => "united kingdom", "region" => "europe"}, value: 1}, + {groups: {"city" => nil, "country" => nil, "region" => nil}, value: 2} + ]) + end + + context "with no events" do + let(:events) { [] } + + it "returns the unique count of event properties" do + result = event_store.grouped_unique_count + expect(result.count).to eq(0) + end + end + end + end + + if include_feature?(:grouped_prorated_unique_count) + describe "#grouped_prorated_unique_count" do + let(:grouped_by) { %w[agent_name other] } + let(:events_grouped_by) { ["agent_name", "other"] } + let(:started_at) { Time.zone.parse("2023-03-01") } + + let(:events) do + [ + create_event( + timestamp: boundaries[:from_datetime] + 1.day, + properties: { + "agent_name" => "frodo" + }, + value: "2" + ), + create_event( + timestamp: boundaries[:from_datetime] + 1.day, + properties: { + "agent_name" => "aragorn" + }, + value: "2" + ), + create_event( + timestamp: (boundaries[:from_datetime] + 1.day).end_of_day, + properties: { + "agent_name" => "aragorn", + "operation_type" => "remove" + }, + value: "2" + ), + create_event( + timestamp: boundaries[:from_datetime] + 2.days, + value: "2" + ) + ] + end + + before do + event_store.aggregation_property = billable_metric.field_name + end + + it "returns the unique count of event properties" do + result = event_store.grouped_prorated_unique_count + + expect(result.count).to eq(3) + + null_group = result.find { |v| v[:groups]["agent_name"].nil? } + expect(null_group[:groups]["other"]).to be_nil + expect(null_group[:value].round(3)).to eq(0.935) # 29/31 + + # NOTE: Events calculation: [1/31, 30/31] + expect((result - [null_group]).map { |r| r[:value].round(3) }).to contain_exactly(0.032, 0.968) + end + + context "with no events" do + let(:events) { [] } + + it "returns the unique count of event properties" do + result = event_store.grouped_prorated_unique_count + expect(result.count).to eq(0) + end + end + end + end + + if include_feature?(:events_values) + describe "#events_values" do + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the value attached to each event" do + expect(event_store.events_values).to eq([1, 2, 3, 4, 5]) + end + + context "with limit" do + it "returns the value attached to each event" do + expect(event_store.events_values(limit: 2)).to eq([1, 2]) + end + end + + context "when exclude_event is true" do + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + matching_filters:, + ignored_filters:, + event:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:event) do + create_event(timestamp: subscription_started_at + 1.day, value: 6) + end + + it "excludes current event but returns the value attached to other events" do + event + + expect(event_store.events_values(exclude_event: true)).to eq([1, 2, 3, 4, 5]) + end + end + + context "with events before from_datetime" do + before do + create_event( + timestamp: subscription_started_at - 1.day, + value: 0, + properties: {"region" => "europe", "country" => "france"}, + transaction_id: SecureRandom.uuid + ) + end + + it "excludes values from events before from_datetime by default" do + expect(event_store.events_values).to eq([1, 2, 3, 4, 5]) + end + + context "when use_from_boundary is false" do + before { event_store.use_from_boundary = false } + + it "includes values from events before from_datetime" do + expect(event_store.events_values).to eq([0, 1, 2, 3, 4, 5]) + end + + context "when force_from is true" do + it "excludes values from events before from_datetime" do + expect(event_store.events_values(force_from: true)).to eq([1, 2, 3, 4, 5]) + end + end + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the filtered event values" do + # We include: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, caen -> -3 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # - europe, united kingdom, manchester -> -1 + # Then exclude: + # - europe, france, caen -> -3 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # We should have 4 events: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, manchester -> -1 + expect(event_store.events_values).to eq([1, 3, -1, -2]) + end + end + end + end + + if include_feature?(:last_event) + describe "#last_event" do + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the last event" do + expect(event_store.last_event.transaction_id).to eq(events.last.transaction_id) + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the last filtered event" do + # We include: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, caen -> -3 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # - europe, united kingdom, manchester -> -1 + # Then exclude: + # - europe, france, caen -> -3 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # We should have 4 events: + # - europe, france, paris -> 1 (day +1) + # - europe, france, -> 3 (day +3) + # - europe, united kingdom, manchester -> -1 (day +6) + # - europe, france, cambridge -> -2 (day +7) + # Last event is france, cambridge with value -2 + expect(event_store.last_event.properties[billable_metric.field_name].to_i).to eq(-2) + end + end + end + end + + if include_feature?(:grouped_last_event) + describe "#grouped_last_event" do + let(:grouped_by) { %w[region] } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the last events grouped by the provided group" do + result = event_store.grouped_last_event + + expect(result).to match_array([ + {groups: {"region" => nil}, timestamp: format_timestamp("2023-03-19 00:00:00.000"), value: 4}, + {groups: {"region" => "europe"}, timestamp: format_timestamp("2023-03-20 00:00:00.000"), value: 5} + ]) + end + + context "with multiple groups" do + let(:grouped_by) { %w[region country] } + + it "returns the last events grouped by the provided groups" do + result = event_store.grouped_last_event + + expect(result).to match_array([ + {groups: {"country" => "france", "region" => "europe"}, timestamp: format_timestamp("2023-03-18 00:00:00.000"), value: 3}, + {groups: {"country" => nil, "region" => nil}, timestamp: format_timestamp("2023-03-19 00:00:00.000"), value: 4}, + {groups: {"country" => "united kingdom", "region" => "europe"}, timestamp: format_timestamp("2023-03-20 00:00:00.000"), value: 5} + ]) + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + let(:grouped_by) { %w[region country] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the last events filtered and grouped" do + result = event_store.grouped_last_event + + # We include: + # - europe, france, + # - europe, france, paris + # - europe, france, caen + # - europe, france, cambridge + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # - europe, united kingdom, manchester + # Then exclude: + # - europe, france, caen + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # We should have 4 events: + # - europe, france, + # - europe, france, paris + # - europe, france, cambridge + # - europe, united kingdom, manchester + # We keep last event for each group: + # - europe, france, cambridge + # - europe, united kingdom, manchester + expect(result).to match_array( + [ + { + groups: {"country" => "france", "region" => "europe"}, + timestamp: format_timestamp("2023-03-22T00:00:00.000Z"), + value: -2 + }, + { + groups: {"country" => "united kingdom", "region" => "europe"}, + timestamp: format_timestamp("2023-03-21T00:00:00.000Z"), + value: -1 + } + ] + ) + end + end + end + end + + if include_feature?(:max) + describe "#max" do + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the max value" do + expect(event_store.max).to eq(5) + end + + context "with grouped_by_values" do + let(:grouped_by_values) { {"region" => "europe"} } + let(:events_grouped_by) { ["region"] } + + it "returns the max value" do + expect(event_store.max).to eq(5) + end + + context "when grouped_by_values value is nil" do + let(:grouped_by_values) { {"region" => nil} } + + it "returns the max value" do + expect(event_store.max).to eq(4) + end + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the max value filtered" do + # We include: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, caen -> -3 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # - europe, united kingdom, manchester -> -1 + # Then exclude: + # - europe, france, caen -> -3 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # We should have 4 events: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, manchester -> -1 + # Max value is 3 + expect(event_store.max).to eq(3) + end + end + end + end + + if include_feature?(:grouped_max) + describe "#grouped_max" do + let(:grouped_by) { %w[region] } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the max values grouped by the provided group" do + result = event_store.grouped_max + + expect(result).to match_array([ + {groups: {"region" => nil}, value: 4}, + {groups: {"region" => "europe"}, value: 5} + ]) + end + + context "with multiple groups" do + let(:grouped_by) { %w[region country] } + + it "returns the max values grouped by the provided groups" do + result = event_store.grouped_max + + expect(result).to match_array([ + {groups: {"country" => "france", "region" => "europe"}, value: 3}, + {groups: {"country" => nil, "region" => nil}, value: 4}, + {groups: {"country" => "united kingdom", "region" => "europe"}, value: 5} + ]) + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + let(:grouped_by) { %w[region country] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the max events filtered and grouped" do + result = event_store.grouped_max + + # We include: + # - europe, france, + # - europe, france, paris + # - europe, france, caen + # - europe, france, cambridge + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # - europe, united kingdom, manchester + # Then exclude: + # - europe, france, caen + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # We should have 2 events: + # - europe, france, + # - europe, france, paris + # - europe, france, cambridge + # - europe, united kingdom, manchester + # We keep "max" event for each group: + # - europe, france, + # - europe, united kingdom, manchester + expect(result).to match_array([ + {groups: {"country" => "united kingdom", "region" => "europe"}, value: -1}, + {groups: {"country" => "france", "region" => "europe"}, value: 3} + ]) + end + end + end + end + + if include_feature?(:last) + describe "#last" do + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the last event value" do + expect(event_store.last).to eq(5) + end + + context "when there's no events" do + let(:events) { [] } + + it "returns nil" do + expect(event_store.last).to be_nil + end + end + + context "when the last event does not have a value" do + let(:events) do + [create_event(timestamp: subscription_started_at + 1.day, value: nil)] + end + + it "returns nil" do + expect(event_store.last).to be_nil + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the last filtered event value" do + # We include: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, caen -> -3 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # - europe, united kingdom, manchester -> -1 + # Then exclude: + # - europe, france, caen -> -3 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # We should have 4 events: + # - europe, france, paris -> 1 (day +1) + # - europe, france, -> 3 (day +3) + # - europe, united kingdom, manchester -> -1 (day +6) + # - europe, france, cambridge -> -2 (day +7) + # Last value is -2 + expect(event_store.last).to eq(-2) + end + end + end + end + + if include_feature?(:grouped_last) + describe "#grouped_last" do + let(:grouped_by) { %w[region] } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the value attached to each event prorated on the provided duration" do + result = event_store.grouped_last + + expect(result).to match_array([ + {groups: {"region" => nil}, value: 4}, + {groups: {"region" => "europe"}, value: 5} + ]) + end + + context "with multiple groups" do + let(:grouped_by) { %w[region country] } + + it "returns the last value for each provided groups" do + result = event_store.grouped_last + + expect(result).to match_array([ + {groups: {"country" => nil, "region" => nil}, value: 4}, + {groups: {"country" => "france", "region" => "europe"}, value: 3}, + {groups: {"country" => "united kingdom", "region" => "europe"}, value: 5} + ]) + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + let(:grouped_by) { %w[region country] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the last values filtered and grouped" do + result = event_store.grouped_last + + # We include: + # - europe, france, + # - europe, france, paris + # - europe, france, caen + # - europe, france, cambridge + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # - europe, united kingdom, manchester + # Then exclude: + # - europe, france, caen + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # We should have 2 events: + # - europe, france, + # - europe, france, paris + # - europe, france, cambridge + # - europe, united kingdom, manchester + # We keep last event for each group: + # - europe, france, cambridge + # - europe, united kingdom, manchester + expect(result).to match_array([ + {groups: {"country" => "united kingdom", "region" => "europe"}, value: -1}, + {groups: {"country" => "france", "region" => "europe"}, value: -2} + ]) + end + end + end + end + + if include_feature?(:sum) + describe "#sum" do + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the sum of event properties" do + expect(event_store.sum).to eq(15) + end + + if with_event_duplication + context "with only duplicated transaction_id" do + before do + event = events.first + + create_event(timestamp: subscription_started_at + 5.days, value: 100, transaction_id: event.transaction_id) + end + + it "takes the event into account" do + expect(event_store.sum).to eq(115) # New event value added to the previous one + end + end + end + + context "with events before from_datetime" do + before do + create_event( + timestamp: subscription_started_at - 1.day, + value: 100, + properties: {"region" => "europe", "country" => "france"}, + transaction_id: SecureRandom.uuid + ) + end + + it "excludes events before from_datetime by default" do + expect(event_store.sum).to eq(15) + end + + context "when use_from_boundary is false" do + before { event_store.use_from_boundary = false } + + it "includes events before from_datetime" do + expect(event_store.sum).to eq(115) + end + + context "when force_from is true" do + it "excludes events before from_datetime" do + # Note: #sum doesn't use force_from directly, it goes through events_cte_queries + # which respects use_from_boundary. This test verifies the boundary is applied. + event_store.use_from_boundary = true + expect(event_store.sum).to eq(15) + end + end + end + end + + context "with events after to_datetime" do + let(:boundaries) do + { + from_datetime: subscription_started_at, + to_datetime: subscription_started_at + 3.days + 12.hours, + charges_duration: 31 + } + end + + it "excludes events after to_datetime" do + # Only events with values 1, 2, 3 are within the boundary + expect(event_store.sum).to eq(6) + end + end + + context "with max_timestamp boundary" do + let(:boundaries) do + { + from_datetime: subscription_started_at, + to_datetime: subscription.started_at.end_of_month.end_of_day, + max_timestamp: subscription_started_at + 3.days + 12.hours, + charges_duration: 31 + } + end + + it "uses max_timestamp instead of to_datetime" do + # Only events with values 1, 2, 3 are within the boundary + expect(event_store.sum).to eq(6) + end + end + end + end + + if include_feature?(:grouped_sum) + describe "#grouped_sum" do + let(:grouped_by) { %w[region] } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the sum of values grouped by the provided group" do + result = event_store.grouped_sum + + expect(result).to match_array([ + {groups: {"region" => nil}, value: 6}, + {groups: {"region" => "europe"}, value: 9} + ]) + end + + context "with multiple groups" do + let(:grouped_by) { %w[region country] } + + it "returns the sum of values grouped by the provided groups" do + result = event_store.grouped_sum + + expect(result).to match_array([ + {groups: {"country" => nil, "region" => nil}, value: 6}, + {groups: {"country" => "united kingdom", "region" => "europe"}, value: 5}, + {groups: {"country" => "france", "region" => "europe"}, value: 4} + ]) + end + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + let(:grouped_by) { %w[region country] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the sum filtered and grouped" do + result = event_store.grouped_sum + + # We include: + # - europe, france, + # - europe, france, paris + # - europe, france, caen + # - europe, france, cambridge + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # - europe, united kingdom, manchester + # Then exclude: + # - europe, france, caen + # - europe, united kingdom, cambridge + # - europe, united kingdom, london + # We should have 2 events: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, manchester -> -1 + expect(result).to match_array([ + {groups: {"country" => "united kingdom", "region" => "europe"}, value: -1}, + {groups: {"country" => "france", "region" => "europe"}, value: 2} + ]) + end + end + end + end + + if include_feature?(:grouped_sum_breakdown) + describe "#grouped_sum with breakdown columns" do + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + presentation_by: ["cloud"], + matching_filters:, + ignored_filters:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:events) { [] } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + context "without grouped_by" do + before do + 3.times { create_event(timestamp: subscription_started_at + 1.day, value: 10, properties: {"cloud" => "aws"}) } + create_event(timestamp: subscription_started_at + 1.day, value: 12, properties: {"cloud" => "gcp"}) + end + + it "returns the sum breakdown by presentation_by" do + result = event_store.grouped_sum(["cloud"]) + + expect(result).to match_array([ + {groups: {"cloud" => "aws"}, value: 30}, + {groups: {"cloud" => "gcp"}, value: 12} + ]) + end + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + before do + create_event(timestamp: subscription_started_at + 1.day, value: 2, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 1.day, value: 7, properties: {"agent_name" => "frodo", "cloud" => "gcp"}) + create_event(timestamp: subscription_started_at + 1.day, value: 3, properties: {"agent_name" => "aragorn", "cloud" => "aws"}) + end + + it "returns the sum breakdown per group" do + result = event_store.grouped_sum(["agent_name", "cloud"]) + + expect(result).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 2}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 7}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 3} + ]) + end + end + end + end + + if include_feature?(:grouped_count_breakdown) + describe "#grouped_count with breakdown columns" do + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + presentation_by: ["cloud"], + matching_filters:, + ignored_filters:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:events) { [] } + + context "without grouped_by" do + before do + 3.times { create_event(timestamp: subscription_started_at + 1.day, value: 10, properties: {"cloud" => "aws"}) } + create_event(timestamp: subscription_started_at + 1.day, value: 12, properties: {"cloud" => "gcp"}) + end + + it "returns the count breakdown by presentation_by" do + result = event_store.grouped_count(["cloud"]) + + expect(result).to match_array([ + {groups: {"cloud" => "aws"}, value: 3}, + {groups: {"cloud" => "gcp"}, value: 1} + ]) + end + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + before do + 2.times { create_event(timestamp: subscription_started_at + 1.day, value: 2, properties: {"agent_name" => "frodo", "cloud" => "aws"}) } + create_event(timestamp: subscription_started_at + 1.day, value: 7, properties: {"agent_name" => "frodo", "cloud" => "gcp"}) + create_event(timestamp: subscription_started_at + 1.day, value: 3, properties: {"agent_name" => "aragorn", "cloud" => "aws"}) + end + + it "returns the count breakdown per group" do + result = event_store.grouped_count(["agent_name", "cloud"]) + + expect(result).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 2}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 1}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 1} + ]) + end + end + end + end + + if include_feature?(:grouped_last_breakdown) + describe "#grouped_last with breakdown columns" do + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + presentation_by: ["cloud"], + matching_filters:, + ignored_filters:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:events) { [] } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + context "without grouped_by" do + before do + create_event(timestamp: subscription_started_at + 1.day, value: 10, properties: {"cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 2.days, value: 12, properties: {"cloud" => "gcp"}) + end + + it "returns the latest value per cloud" do + result = event_store.grouped_last(["cloud"]) + + expect(result).to match_array([ + {groups: {"cloud" => "gcp"}, value: 12} + ]) + end + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + before do + create_event(timestamp: subscription_started_at + 1.day, value: 2, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 2.days, value: 7, properties: {"agent_name" => "frodo", "cloud" => "gcp"}) + create_event(timestamp: subscription_started_at + 1.day + 1.second, value: 3, properties: {"agent_name" => "aragorn", "cloud" => "aws"}) + end + + it "returns the latest value per group and cloud" do + result = event_store.grouped_last(["agent_name", "cloud"]) + + expect(result).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 7}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 3} + ]) + end + end + end + end + + if include_feature?(:grouped_max_breakdown) + describe "#grouped_max with breakdown columns" do + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + presentation_by: ["cloud"], + matching_filters:, + ignored_filters:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:events) { [] } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + context "without grouped_by" do + before do + 3.times { create_event(timestamp: subscription_started_at + 1.day, value: 10, properties: {"cloud" => "aws"}) } + create_event(timestamp: subscription_started_at + 1.day, value: 12, properties: {"cloud" => "gcp"}) + end + + it "returns the max value per cloud" do + result = event_store.grouped_max(["cloud"]) + + expect(result).to match_array([ + {groups: {"cloud" => "aws"}, value: 10}, + {groups: {"cloud" => "gcp"}, value: 12} + ]) + end + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + before do + create_event(timestamp: subscription_started_at + 1.day, value: 2, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 1.day, value: 7, properties: {"agent_name" => "frodo", "cloud" => "gcp"}) + create_event(timestamp: subscription_started_at + 1.day, value: 3, properties: {"agent_name" => "aragorn", "cloud" => "aws"}) + end + + it "returns the max value per group and cloud" do + result = event_store.grouped_max(["agent_name", "cloud"]) + + expect(result).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 2}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 7}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 3} + ]) + end + end + end + end + + if include_feature?(:grouped_unique_count_breakdown) + describe "#grouped_unique_count with breakdown columns" do + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + presentation_by: ["cloud"], + matching_filters:, + ignored_filters:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:events) { [] } + + before do + event_store.aggregation_property = billable_metric.field_name + end + + context "without grouped_by" do + before do + create_event(timestamp: subscription_started_at + 1.day, value: 1, properties: {"cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 2.days, value: 2, properties: {"cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 3.days, value: 3, properties: {"cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 1.day, value: 1, properties: {"cloud" => "gcp"}) + end + + it "returns the unique count breakdown by presentation_by" do + result = event_store.grouped_unique_count(["cloud"]) + + expect(result).to match_array([ + {groups: {"cloud" => "aws"}, value: 3}, + {groups: {"cloud" => "gcp"}, value: 1} + ]) + end + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + before do + create_event(timestamp: subscription_started_at + 1.day, value: 1, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 2.days, value: 2, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: subscription_started_at + 1.day, value: 1, properties: {"agent_name" => "frodo", "cloud" => "gcp"}) + create_event(timestamp: subscription_started_at + 1.day, value: 1, properties: {"agent_name" => "aragorn", "cloud" => "aws"}) + end + + it "returns the unique count breakdown per group" do + result = event_store.grouped_unique_count(["agent_name", "cloud"]) + + expect(result).to match_array([ + {groups: {"agent_name" => "frodo", "cloud" => "aws"}, value: 2}, + {groups: {"agent_name" => "frodo", "cloud" => "gcp"}, value: 1}, + {groups: {"agent_name" => "aragorn", "cloud" => "aws"}, value: 1} + ]) + end + end + end + end + + if include_feature?(:grouped_weighted_sum_breakdown) + describe "#grouped_weighted_sum with breakdown columns" do + subject(:event_store) do + described_class.new( + code:, + subscription:, + boundaries:, + filters: { + grouped_by:, + grouped_by_values:, + presentation_by: ["cloud"], + matching_filters:, + ignored_filters:, + charge_id: charge&.id, + charge_filter: charge_filter + }, + deduplicate: with_event_duplication + ) + end + + let(:events) { [] } + let(:started_at) { Time.zone.parse("2023-03-01") } + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + context "without grouped_by" do + before do + create_event(timestamp: Time.zone.parse("2023-03-05 00:00:00"), value: 2, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, properties: {"cloud" => "aws"}) + + create_event(timestamp: Time.zone.parse("2023-03-05 00:00:00"), value: 2, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, properties: {"cloud" => "gcp"}) + end + + it "returns the weighted sum breakdown by presentation_by" do + result = event_store.grouped_weighted_sum(["cloud"]) + + expect(result.size).to eq(2) + expect(result.map { |r| r[:groups] }).to match_array([{"cloud" => "aws"}, {"cloud" => "gcp"}]) + result.each { |r| expect(r[:value].round(5)).to eq(0.02218) } + end + end + + context "with grouped_by" do + let(:grouped_by) { ["agent_name"] } + + before do + create_event(timestamp: Time.zone.parse("2023-03-05 00:00:00"), value: 2, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, properties: {"agent_name" => "frodo", "cloud" => "aws"}) + + create_event(timestamp: Time.zone.parse("2023-03-05 00:00:00"), value: 2, properties: {"agent_name" => "aragorn", "cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, properties: {"agent_name" => "aragorn", "cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, properties: {"agent_name" => "aragorn", "cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, properties: {"agent_name" => "aragorn", "cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, properties: {"agent_name" => "aragorn", "cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, properties: {"agent_name" => "aragorn", "cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, properties: {"agent_name" => "aragorn", "cloud" => "gcp"}) + end + + it "returns the weighted sum breakdown per group" do + result = event_store.grouped_weighted_sum(["agent_name", "cloud"]) + + expect(result.map { |r| r[:groups] }).to match_array([ + {"agent_name" => "frodo", "cloud" => "aws"}, + {"agent_name" => "aragorn", "cloud" => "gcp"} + ]) + result.each { |r| expect(r[:value].round(5)).to eq(0.02218) } + end + end + + context "with no events" do + it "returns an empty array" do + result = event_store.grouped_weighted_sum(["cloud"]) + + expect(result).to eq([]) + end + end + + context "with initial values" do + let(:initial_values) do + [ + {groups: {"cloud" => "aws"}, value: 1000}, + {groups: {"cloud" => "gcp"}, value: 1000} + ] + end + + before do + create_event(timestamp: Time.zone.parse("2023-03-05 00:00:00"), value: 2, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, properties: {"cloud" => "aws"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, properties: {"cloud" => "aws"}) + + create_event(timestamp: Time.zone.parse("2023-03-05 00:00:00"), value: 2, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, properties: {"cloud" => "gcp"}) + create_event(timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, properties: {"cloud" => "gcp"}) + end + + it "uses the initial values in the aggregation" do + result = event_store.grouped_weighted_sum(["cloud"], initial_values:) + + expect(result.map { |r| r[:groups] }).to match_array([{"cloud" => "aws"}, {"cloud" => "gcp"}]) + result.each { |r| expect(r[:value].round(5)).to eq(1000.02218) } + end + end + end + end + + if include_feature?(:sum_date_breakdown) + describe "#sum_date_breakdown" do + it "returns the sum grouped by day" do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + expect(event_store.sum_date_breakdown).to eq( + events.map do |e| + { + date: e.timestamp.to_date, + value: e.properties[billable_metric.field_name].to_i + } + end + ) + end + end + end + + if include_feature?(:prorated_events_values) + describe "#prorated_events_values" do + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the values attached to each event with prorata on period duration" do + expect(event_store.prorated_events_values(31).map { |v| v.round(3) }).to eq( + [0.516, 0.968, 1.355, 1.677, 1.935] + ) + end + + context "with filters" do + let(:matching_filters) { {"region" => ["europe"], "country" => ["france", "united kingdom"]} } + let(:ignored_filters) { [{"city" => ["caen"]}, {"city" => ["cambridge", "london"], "country" => ["united kingdom"]}] } + + let(:charge_filter) { create(:charge_filter, charge:) } + + before { create_events_for_filters } + + it "returns the filtered prorated event values" do + # We include: + # - europe, france, -> 3 + # - europe, france, paris -> 1 + # - europe, france, caen -> -3 + # - europe, france, cambridge -> -2 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # - europe, united kingdom, manchester -> -1 + # Then exclude: + # - europe, france, caen -> -3 + # - europe, united kingdom, cambridge -> -5 + # - europe, united kingdom, london -> 5 + # We should have 4 events: + # - europe, france, paris -> 1 (day +1) -> 1 * 16/31 ≈ 0.516 + # - europe, france, -> 3 (day +3) -> 3 * 14/31 ≈ 1.355 + # - europe, united kingdom, manchester -> -1 (day +6) -> -1 * 11/31 ≈ -0.355 + # - europe, france, cambridge -> -2 (day +7) -> -2 * 10/31 ≈ -0.645 + expect(event_store.prorated_events_values(31).map { |v| v.round(3) }).to eq( + [0.516, 1.355, -0.355, -0.645] + ) + end + end + end + end + + if include_feature?(:prorated_sum) + describe "#prorated_sum" do + it "returns the prorated sum of event properties" do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + expect(event_store.prorated_sum(period_duration: 31).round(5)).to eq(6.45161) + end + + context "with persisted_duration" do + it "returns the prorated sum of event properties" do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + expect(event_store.prorated_sum(period_duration: 31, persisted_duration: 10).round(5)).to eq(4.83871) + end + end + end + end + + if include_feature?(:grouped_prorated_sum) + describe "#grouped_prorated_sum" do + let(:grouped_by) { %w[region] } + + it "returns the prorated sum of event properties" do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + result = event_store.grouped_prorated_sum(period_duration: 31) + + expect(result).to match_array([ + {groups: {"region" => nil}, value: within(0.00001).of(2.64516)}, + {groups: {"region" => "europe"}, value: within(0.00001).of(3.80645)} + ]) + end + + context "with persisted_duration" do + it "returns the prorated sum of event properties" do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + result = event_store.grouped_prorated_sum(period_duration: 31, persisted_duration: 10) + + expect(result).to match_array([ + {groups: {"region" => nil}, value: within(0.00001).of(1.93548)}, + {groups: {"region" => "europe"}, value: within(0.00001).of(2.90322)} + ]) + end + end + + context "with multiple groups" do + let(:grouped_by) { %w[region country] } + + it "returns the sum of values grouped by the provided groups" do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + result = event_store.grouped_prorated_sum(period_duration: 31) + + expect(result).to match_array( + [ + { + groups: {"country" => "united kingdom", "region" => "europe"}, + value: within(0.00001).of(1.93548) + }, + { + groups: {"country" => nil, "region" => nil}, + value: within(0.00001).of(2.64516) + }, + { + groups: {"country" => "france", "region" => "europe"}, + value: within(0.00001).of(1.87096) + } + ] + ) + end + end + end + end + + if include_feature?(:weighted_sum) + describe "#weighted_sum" do + let(:started_at) { Time.zone.parse("2023-03-01") } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 2}, + {timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3}, + {timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1}, + {timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4}, + {timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2}, + {timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10}, + {timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10} + ] + end + + let(:events) do + events_values.map do |values| + properties = {} + properties[:region] = values[:region] if values[:region] + + create_event( + value: values[:value], + timestamp: values[:timestamp], + properties:, + charge_filter: values[:charge_filter] + ) + end + end + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the weighted sum of event properties" do + expect(event_store.weighted_sum.round(5)).to eq(0.02218) + end + + context "with a single event" do + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 1000} + ] + end + + it "returns the weighted sum of event properties" do + expect(event_store.weighted_sum.round(5)).to eq(870.96774) # 4 / 31 * 0 + 27 / 31 * 1000 + end + end + + context "with no events" do + let(:events_values) { [] } + + it "returns the weighted sum of event properties" do + expect(event_store.weighted_sum.round(5)).to eq(0.0) + end + end + + context "with events with the same timestamp" do + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 3}, + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 3} + ] + end + + it "returns the weighted sum of event properties" do + expect(event_store.weighted_sum.round(5)).to eq(5.22581) # 4 / 31 * 0 + 27 / 31 * 6 + end + end + + context "with initial value" do + let(:initial_value) { 1000 } + + it "uses the initial value in the aggregation" do + expect(event_store.weighted_sum(initial_value:).round(5)).to eq(1000.02218) + end + + context "without events" do + let(:events_values) { [] } + + it "uses only the initial value in the aggregation" do + expect(event_store.weighted_sum(initial_value:).round(5)).to eq(1000.0) + end + end + end + + context "with filters" do + let(:matching_filters) { {region: ["europe"]} } + + let(:charge_filter) { create(:charge_filter, charge:) } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-04 00:00:00.000"), value: 1000, region: "us"}, + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 1000, region: "europe", charge_filter:} + ] + end + + it "returns the weighted sum of event properties scoped to the group" do + expect(event_store.weighted_sum.round(5)).to eq(870.96774) # 4 / 31 * 0 + 27 / 31 * 1000 + end + end + + context "when from_datetime has microsecond precision and event has millisecond precision" do + let(:boundaries) do + { + from_datetime: Time.zone.parse("2023-03-01 00:00:00.000000") + 0.000285, + to_datetime: Time.zone.parse("2023-03-31").end_of_day, + charges_duration: 31 + } + end + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-01 00:00:00.000"), value: 5} + ] + end + + it "includes the event in the weighted sum" do + result = event_store.weighted_sum + expect(result.round(5)).to eq(5.0) + end + end + end + end + + if include_feature?(:weighted_sum_breakdown) + describe "#weighted_sum_breakdown" do + let(:started_at) { Time.zone.parse("2023-03-01") } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 2}, + {timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3}, + {timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1}, + {timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4}, + {timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2}, + {timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10}, + {timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10} + ] + end + + let(:events) do + events_values.map do |values| + properties = {} + properties[:region] = values[:region] if values[:region] + + create_event( + value: values[:value], + timestamp: values[:timestamp], + properties:, + charge_filter: values[:charge_filter] + ) + end + end + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the weighted sum of event properties" do + expected_breakdown = [ + [format_timestamp("2023-03-01T00:00:00.000Z", precision: 5), 0.0, 0.0, 345600, 0.0], + [format_timestamp("2023-03-05T00:00:00.000Z", precision: 5), 2, 2, 3600, within(0.00001).of(0.00268)], + [format_timestamp("2023-03-05T01:00:00.000Z", precision: 5), 3, 5, 1800, within(0.00001).of(0.00336)], + [format_timestamp("2023-03-05T01:30:00.000Z", precision: 5), 1, 6, 1800, within(0.00001).of(0.00403)], + [format_timestamp("2023-03-05T02:00:00.000Z", precision: 5), -4, 2, 7200, within(0.00001).of(0.00537)], + [format_timestamp("2023-03-05T04:00:00.000Z", precision: 5), -2, 0.0, 3600, 0.0], + [format_timestamp("2023-03-05T05:00:00.000Z", precision: 5), 10, 10, 1800, within(0.00001).of(0.00672)], + [format_timestamp("2023-03-05T05:30:00.000Z", precision: 5), -10, 0.0, 2313000, 0.0], + [format_timestamp("2023-04-01T00:00:00.000Z", precision: 5), 0.0, 0.0, 0.0, 0.0] + ] + expect(event_store.weighted_sum_breakdown).to match(expected_breakdown) + end + + context "with a single event" do + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 1000} + ] + end + + it "returns the weighted sum of event properties" do + expected_breakdown = [ + [format_timestamp("2023-03-01T00:00:00.000Z", precision: 5), 0.0, 0.0, 345600, 0.0], + [format_timestamp("2023-03-05T00:00:00.000Z", precision: 5), 1000, 1000, 2332800, within(0.00001).of(870.96774)], + [format_timestamp("2023-04-01T00:00:00.000Z", precision: 5), 0.0, 1000, 0.0, 0.0] + ] + expect(event_store.weighted_sum_breakdown).to match(expected_breakdown) + end + end + + context "with no events" do + let(:events_values) { [] } + + it "returns the weighted sum of event properties" do + expect(event_store.weighted_sum_breakdown).to match([ + [format_timestamp("2023-03-01T00:00:00.000Z", precision: 5), 0.0, 0.0, 2678400, 0.0], + [format_timestamp("2023-04-01T00:00:00.000Z", precision: 5), 0.0, 0.0, 0.0, 0.0] + ]) + end + end + + context "with events with the same timestamp" do + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 3}, + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 3} + ] + end + + it "returns the weighted sum of event properties" do + expected_breakdown = [ + [format_timestamp("2023-03-01T00:00:00.000Z", precision: 5), 0, 0, 345600, 0.0], + [format_timestamp("2023-03-05T00:00:00.000Z", precision: 5), 3, 3, 0, 0.0], + [format_timestamp("2023-03-05T00:00:00.000Z", precision: 5), 3, 6, 2332800, within(0.00001).of(5.22580)], + [format_timestamp("2023-04-01T00:00:00.000Z", precision: 5), 0.0, 6, 0.0, 0.0] + ] + expect(event_store.weighted_sum_breakdown).to match(expected_breakdown) + end + end + + context "with initial value" do + let(:initial_value) { 1000 } + + it "uses the initial value in the aggregation" do + expected_breakdown = [ + [format_timestamp("2023-03-01T00:00:00.000Z", precision: 5), 1000, 1000, 345600, within(0.00001).of(129.03225)], + [format_timestamp("2023-03-05T00:00:00.000Z", precision: 5), 2, 1002, 3600, within(0.00001).of(1.34677)], + [format_timestamp("2023-03-05T01:00:00.000Z", precision: 5), 3, 1005, 1800, within(0.00001).of(0.67540)], + [format_timestamp("2023-03-05T01:30:00.000Z", precision: 5), 1, 1006, 1800, within(0.00001).of(0.67607)], + [format_timestamp("2023-03-05T02:00:00.000Z", precision: 5), -4, 1002, 7200, within(0.00001).of(2.69354)], + [format_timestamp("2023-03-05T04:00:00.000Z", precision: 5), -2, 1000, 3600, within(0.00001).of(1.34408)], + [format_timestamp("2023-03-05T05:00:00.000Z", precision: 5), 10, 1010, 1800, within(0.00001).of(0.67876)], + [format_timestamp("2023-03-05T05:30:00.000Z", precision: 5), -10, 1000, 2313000, within(0.00001).of(863.57526)], + [format_timestamp("2023-04-01T00:00:00.000Z", precision: 5), 0.0, 1000, 0.0, 0.0] + ] + expect(event_store.weighted_sum_breakdown(initial_value:)).to match(expected_breakdown) + end + + context "without events" do + let(:events_values) { [] } + + it "uses only the initial value in the aggregation" do + expected_breakdown = [ + [format_timestamp("2023-03-01T00:00:00.000Z", precision: 5), 1000, 1000, 2678400, 1000], + [format_timestamp("2023-04-01T00:00:00.000Z", precision: 5), 0.0, 1000, 0.0, 0.0] + ] + expect(event_store.weighted_sum_breakdown(initial_value:)).to match(expected_breakdown) + end + end + end + + context "with filters" do + let(:matching_filters) { {region: ["europe"]} } + + let(:charge_filter) { create(:charge_filter, charge:) } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-04 00:00:00.000"), value: 1000, region: "us"}, + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 1000, region: "europe", charge_filter:} + ] + end + + it "returns the weighted sum of event properties scoped to the group" do + expected_breakdown = [ + [format_timestamp("2023-03-01T00:00:00.000Z", precision: 5), 0, 0, 345600, 0.0], + [format_timestamp("2023-03-05T00:00:00.000Z", precision: 5), 1000, 1000, 2332800, within(0.00001).of(870.96774)], + [format_timestamp("2023-04-01T00:00:00.000Z", precision: 5), 0.0, 1000, 0.0, 0.0] + ] + expect(event_store.weighted_sum_breakdown).to match(expected_breakdown) + end + end + end + end + + if include_feature?(:grouped_weighted_sum) + describe "#grouped_weighted_sum" do + let(:grouped_by) { %w[agent_name other] } + + let(:started_at) { Time.zone.parse("2023-03-01") } + + let(:events_values) do + [ + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 2, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, agent_name: "frodo"}, + {timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, agent_name: "frodo"}, + + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 2, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10, agent_name: "aragorn"}, + {timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10, agent_name: "aragorn"}, + + {timestamp: Time.zone.parse("2023-03-05 00:00:00.000"), value: 2}, + {timestamp: Time.zone.parse("2023-03-05 01:00:00"), value: 3}, + {timestamp: Time.zone.parse("2023-03-05 01:30:00"), value: 1}, + {timestamp: Time.zone.parse("2023-03-05 02:00:00"), value: -4}, + {timestamp: Time.zone.parse("2023-03-05 04:00:00"), value: -2}, + {timestamp: Time.zone.parse("2023-03-05 05:00:00"), value: 10}, + {timestamp: Time.zone.parse("2023-03-05 05:30:00"), value: -10} + ] + end + + let(:events) do + events_values.map do |values| + properties = {} + properties["region"] = values[:region] if values[:region] + properties["agent_name"] = values[:agent_name] if values[:agent_name] + + create_event( + timestamp: values[:timestamp], + value: values[:value], + properties: + ) + end + end + + before do + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + end + + it "returns the weighted sum of event properties" do + result = event_store.grouped_weighted_sum + + expect(result.count).to eq(3) + + null_group = result.find { |v| v[:groups]["agent_name"].nil? } + expect(null_group[:groups]["agent_name"]).to be_nil + expect(null_group[:groups]["other"]).to be_nil + expect(null_group[:value].round(5)).to eq(0.02218) + + (result - [null_group]).each do |row| + expect(row[:groups]["agent_name"]).not_to be_nil + expect(row[:groups]["other"]).to be_nil + expect(row[:value].round(5)).to eq(0.02218) + end + end + + context "with no events" do + let(:events_values) { [] } + + it "returns the weighted sum of event properties" do + result = event_store.grouped_weighted_sum + + expect(result.count).to eq(0) + end + end + + context "with initial values" do + let(:initial_values) do + [ + {groups: {"agent_name" => "frodo", "other" => nil}, value: 1000}, + {groups: {"agent_name" => "aragorn", "other" => nil}, value: 1000}, + {groups: {"agent_name" => nil, "other" => nil}, value: 1000} + ] + end + + it "uses the initial value in the aggregation" do + result = event_store.grouped_weighted_sum(initial_values:) + + expect(result.count).to eq(3) + + null_group = result.find { |v| v[:groups]["agent_name"].nil? } + expect(null_group[:groups]["agent_name"]).to be_nil + expect(null_group[:groups]["other"]).to be_nil + expect(null_group[:value].round(5)).to eq(1000.02218) + + (result - [null_group]).each do |row| + expect(row[:groups]["agent_name"]).not_to be_nil + expect(row[:groups]["other"]).to be_nil + expect(row[:value].round(5)).to eq(1000.02218) + end + end + + context "without events" do + let(:events_values) { [] } + + it "uses only the initial value in the aggregation" do + result = event_store.grouped_weighted_sum(initial_values:) + + expect(result.count).to eq(3) + + null_group = result.find { |v| v[:groups]["agent_name"].nil? } + expect(null_group[:groups]["agent_name"]).to be_nil + expect(null_group[:groups]["other"]).to be_nil + expect(null_group[:value].round(5)).to eq(1000) + + (result - [null_group]).each do |row| + expect(row[:groups]["agent_name"]).not_to be_nil + expect(row[:groups]["other"]).to be_nil + expect(row[:value].round(5)).to eq(1000) + end + end + end + end + end + end + + describe "recurring metric with previous subscription" do + let(:previous_plan) { create(:plan, organization:) } + let(:previous_charge) { create(:standard_charge, organization:, billable_metric:, plan: previous_plan) } + let(:previous_subscription) do + create(:subscription, plan: previous_plan, organization:, customer:, status: :terminated, started_at: started_at - 3.months) + end + let(:subscription) do + create( + :subscription, + customer:, + started_at:, + previous_subscription:, + external_id: previous_subscription.external_id + ) + end + + let(:previous_events) do + # Events before subscription.started_at with previous charge (different charge_id) + create_event( + timestamp: subscription_started_at - 2.days, + value: 100, + properties: {"region" => "europe", "country" => "france"}, + transaction_id: SecureRandom.uuid, + event_charge: previous_charge + ) + + create_event( + timestamp: subscription_started_at - 1.day, + value: 50, + properties: {"region" => "europe"}, + transaction_id: SecureRandom.uuid, + event_charge: previous_charge + ) + end + + before do + event_store.use_from_boundary = false + event_store.aggregation_property = billable_metric.field_name + event_store.numeric_property = true + + previous_events + end + + it "includes events from before subscription.started_at" do + expect(event_store.count).to eq(7) # 5 from current period + 2 from before + expect(event_store.sum).to eq(165) # 15 from current period + 100 + 50 from before + end + + context "with charge filters" do + let(:charge_filter) { create(:charge_filter, charge:) } + let(:previous_charge_filter) { create(:charge_filter, charge: previous_charge) } + let(:matching_filters) { {"region" => ["europe"], "country" => ["france"]} } + + let(:previous_events) do + # Previous event matching the filter + create_event( + timestamp: subscription_started_at - 1.day, + value: 200, + properties: {"region" => "europe", "country" => "france"}, + transaction_id: SecureRandom.uuid, + event_charge: previous_charge, + charge_filter: previous_charge_filter + ) + + # Previous event NOT matching the filter + create_event( + timestamp: subscription_started_at - 2.days, + value: 999, + properties: {"region" => "asia", "country" => "japan"}, + transaction_id: SecureRandom.uuid, + event_charge: previous_charge, + charge_filter: previous_charge_filter + ) + end + + it "includes only filtered events from before subscription.started_at" do + expect(event_store.count).to eq(3) # 2 from current period + 1 from before + expect(event_store.sum).to eq(204) # 4 from current period + 200 from before (999 (asia/japan) doesn't match) + end + end + end + + if include_feature?(:distinct_charges_and_filters) + describe "#distinct_charges_and_filters" do + let(:charge_filter) { create(:charge_filter, charge:) } + + let(:events) { nil } + + before do + create_enriched_event( + timestamp: boundaries[:from_datetime] + 12.days, + value: 12, + properties: {billable_metric.field_name => 12}, + charge_filter: + ) + end + + it "returns distinct charges and filters" do + expect(event_store.distinct_charges_and_filters).to match_array([[charge.id, charge_filter.id]]) + end + + context "when charge_filter is nil" do + let(:charge_filter) { nil } + + it "returns the distinct event codes" do + expect(event_store.distinct_charges_and_filters).to match_array([[charge.id, nil]]) + end + end + end + end +end diff --git a/spec/services/events/stores/store_factory_spec.rb b/spec/services/events/stores/store_factory_spec.rb new file mode 100644 index 0000000..78240cc --- /dev/null +++ b/spec/services/events/stores/store_factory_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::Stores::StoreFactory do + subject(:store_instance) { described_class.new_instance(organization:, **arguments) } + + let(:organization) { create(:organization, clickhouse_events_store:, feature_flags:) } + let(:clickhouse_events_store) { false } + let(:feature_flags) { [] } + + let(:arguments) do + time = Time.current + + { + subscription: create(:subscription, organization:), + boundaries: { + from_datetime: time.beginning_of_month, + to_datetime: time.end_of_month, + period_duration: time.end_of_month.day + }, + code: "some_bm_code", + filters: {} + } + end + + describe "#new_instance" do + it "returns an instance of a Postgres store" do + expect(store_instance).to be_a(Events::Stores::PostgresStore) + end + + context "when clickhouse is enabled" do + around do |example| + previous_value = ENV["LAGO_CLICKHOUSE_ENABLED"] + ENV["LAGO_CLICKHOUSE_ENABLED"] = "true" + example.run + ENV["LAGO_CLICKHOUSE_ENABLED"] = previous_value + end + + it "returns an instance of a Postgres store" do + expect(store_instance).to be_a(Events::Stores::PostgresStore) + end + + context "when organization has the clickhoise flag" do + let(:clickhouse_events_store) { true } + + it "returns an instance of a Clickhouse store" do + expect(store_instance).to be_a(Events::Stores::ClickhouseStore) + end + + context "when enriched_events_aggregation feature flag is enabled" do + let(:feature_flags) { ["enriched_events_aggregation"] } + + it "returns an instance of a ClickhouseEnrichedStore" do + expect(store_instance).to be_a(Events::Stores::ClickhouseEnrichedStore) + end + end + end + end + end +end diff --git a/spec/services/events/validate_creation_service_spec.rb b/spec/services/events/validate_creation_service_spec.rb new file mode 100644 index 0000000..4ebce63 --- /dev/null +++ b/spec/services/events/validate_creation_service_spec.rb @@ -0,0 +1,272 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Events::ValidateCreationService do + subject(:validate_event) do + described_class.call( + organization:, + event_params:, + customer:, + subscriptions: [subscription] + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let!(:subscription) { create(:subscription, customer:, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:transaction_id) { SecureRandom.uuid } + let(:event_params) do + {external_subscription_id: subscription.external_id, code: billable_metric.code, transaction_id:} + end + + describe ".call" do + context "when customer has only one active subscription and external_subscription_id is not given" do + it "does not return any validation errors" do + result = validate_event + expect(result).to be_success + end + end + + context "when customer has only one active subscription and customer is not given" do + let(:event_params) do + {code: billable_metric.code, external_subscription_id: subscription.external_id, transaction_id:} + end + + it "does not return any validation errors" do + result = validate_event + expect(result).to be_success + end + end + + context "when customer has two active subscriptions" do + before { create(:subscription, customer:, organization:) } + + let(:event_params) do + {code: billable_metric.code, external_subscription_id: subscription.external_id, transaction_id:} + end + + it "does not return any validation errors" do + result = validate_event + expect(result).to be_success + end + end + + context "when customer is not given but subscription is present" do + let(:event_params) do + {code: billable_metric.code, external_subscription_id: subscription.external_id, transaction_id:} + end + + let(:validate_event) do + described_class.call( + organization:, + event_params:, + customer: nil, + subscriptions: [subscription] + ) + end + + it "does not return any validation errors" do + result = validate_event + expect(result).to be_success + end + end + + context "when there are two active subscriptions but external_subscription_id is not given" do + let(:subscription2) { create(:subscription, customer:, organization:) } + let(:event_params) { {code: billable_metric.code, transaction_id:} } + + let(:validate_event) do + described_class.call( + organization:, + event_params:, + customer:, + subscriptions: [subscription, subscription2] + ) + end + + it "returns a subscription_not_found error" do + result = validate_event + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("subscription_not_found") + end + end + + context "when there are two active subscriptions but external_subscription_id is invalid" do + let(:event_params) do + { + code: billable_metric.code, + external_subscription_id: SecureRandom.uuid, + external_customer_id: customer.external_id, + transaction_id: + } + end + + let(:subscription2) { create(:subscription, customer:, organization:) } + + let(:validate_event) do + described_class.call( + organization:, + event_params:, + customer:, + subscriptions: [subscription, subscription2] + ) + end + + it "returns a not found error" do + result = validate_event + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("subscription_not_found") + end + end + + context "when there is one active subscription with the same external_id" do + let(:subscription) do + create(:subscription, customer:, organization:, external_id:, status: :terminated) + end + let(:external_id) { SecureRandom.uuid } + let(:event_params) do + { + code: billable_metric.code, + external_subscription_id: external_id, + external_customer_id: customer.external_id, + transaction_id: + } + end + + before do + subscription + create(:subscription, customer:, organization:, external_id:) + end + + it "does not return any validation errors" do + result = validate_event + expect(result).to be_success + end + end + + context "when transaction_id is already used" do + before do + create( + :event, + transaction_id:, + external_subscription_id: subscription.external_id, + subscription_id: subscription.id, + organization_id: organization.id + ) + end + + it "returns a validation error" do + result = validate_event + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:transaction_id) + expect(result.error.messages[:transaction_id]).to include("value_is_missing_or_already_exists") + end + end + + context "when code does not exist" do + let(:event_params) do + {external_subscription_id: subscription.external_id, code: "event_code", transaction_id:} + end + + it "returns an event_not_found error" do + result = validate_event + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billable_metric_not_found") + end + end + + context "when field_name value is not a number" do + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:event_params) do + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + item_id: "test" + }, + transaction_id: + } + end + + it "returns an value_is_not_valid_number error" do + result = validate_event + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:properties) + expect(result.error.messages[:properties]).to include("value_is_not_valid_number") + end + + context "when field_name cannot be found" do + let(:event_params) do + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: { + invalid_key: "test" + }, + transaction_id: + } + end + + it "does not raise error" do + result = validate_event + + expect(result).to be_success + end + end + + context "when properties are missing" do + let(:event_params) do + { + code: billable_metric.code, + external_subscription_id: subscription.external_id, + transaction_id: + } + end + + it "does not raise error" do + result = validate_event + + expect(result).to be_success + end + end + end + + context "when timestamp is in a wrong format" do + let(:event_params) do + {external_subscription_id: subscription.external_id, code: billable_metric.code, transaction_id:, timestamp: "2025-01-01"} + end + + it "returns a timestamp_is_not_valid error" do + result = validate_event + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:timestamp) + expect(result.error.messages[:timestamp]).to include("invalid_format") + end + end + + context "when timestamp is valid" do + let(:event_params) do + {external_subscription_id: subscription.external_id, code: billable_metric.code, transaction_id:, timestamp: Time.current.to_i + 0.11} + end + + it "does not raise any errors" do + result = validate_event + expect(result).to be_success + end + end + end +end diff --git a/spec/services/fees/apply_provider_taxes_service_spec.rb b/spec/services/fees/apply_provider_taxes_service_spec.rb new file mode 100644 index 0000000..0619ca7 --- /dev/null +++ b/spec/services/fees/apply_provider_taxes_service_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::ApplyProviderTaxesService do + subject(:apply_service) { described_class.new(fee:, fee_taxes:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + let(:invoice) { create(:invoice, organization:, customer:) } + + let(:fee) do + create(:fee, invoice:, amount_cents: 1000, precise_amount_cents: 1000.0, precise_coupons_amount_cents:, + taxes_amount_cents: 0, taxes_precise_amount_cents: 0.0, taxes_rate: 0, taxes_base_rate: 0.0) + end + let(:precise_coupons_amount_cents) { 0 } + + let(:fee_taxes) do + OpenStruct.new( + tax_amount_cents: 170, + tax_breakdown: [ + OpenStruct.new(name: "tax 2", type: "type2", rate: "0.12", tax_amount: 120), + OpenStruct.new(name: "tax 3", type: "type3", rate: "0.05", tax_amount: 50) + ] + ) + end + + before do + fee_taxes + end + + describe "call" do + context "when there is no applied taxes yet" do + it "creates applied_taxes based on the provider taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.map(&:tax_code)).to contain_exactly("tax_2", "tax_3") + expect(fee).to have_attributes(taxes_amount_cents: 170, taxes_precise_amount_cents: 170.0, taxes_rate: 17) + end + + context "when there is tax deduction" do + let(:fee_taxes) do + OpenStruct.new( + tax_amount_cents: 136, + tax_breakdown: [ + OpenStruct.new(name: "tax 2", type: "type2", rate: "0.12", tax_amount: 96), + OpenStruct.new(name: "tax 3", type: "type3", rate: "0.05", tax_amount: 40) + ] + ) + end + + it "creates applied_taxes based on the provider taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.map(&:tax_code)).to contain_exactly("tax_2", "tax_3") + expect(fee).to have_attributes( + taxes_amount_cents: 136, + taxes_precise_amount_cents: 136.0, + taxes_rate: 17, + taxes_base_rate: 0.8 + ) + end + end + + context "when taxes are paid by the seller" do + let(:fee_taxes) do + OpenStruct.new( + tax_amount_cents: 0, + tax_breakdown: [OpenStruct.new(name: "Tax", type: "tax", rate: "0.00", tax_amount: 0)] + ) + end + + it "does not create applied_taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + expect(fee).to have_attributes( + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0, + taxes_rate: 0 + ) + end + end + end + + context "when fee already have taxes" do + before { create(:fee_applied_tax, fee:) } + + it "does not re-apply taxes" do + expect do + result = apply_service.call + + expect(result).to be_success + end.not_to change { fee.applied_taxes.count } + end + end + + context "when applying taxes with specific provider rules" do + special_rules = + [ + {received_type: "notCollecting", expected_name: "Not collecting", tax_code: "not_collecting"}, + {received_type: "productNotTaxed", expected_name: "Product not taxed", tax_code: "product_not_taxed"}, + {received_type: "jurisNotTaxed", expected_name: "Juris not taxed", tax_code: "juris_not_taxed"} + ] + special_rules.each do |applied_rule| + context "when tax provider returned specific rule applied to fees - #{applied_rule[:expected_name]}" do + let(:fee_taxes) do + OpenStruct.new( + tax_amount_cents: 0, + tax_breakdown: [ + OpenStruct.new(name: applied_rule[:expected_name], type: applied_rule[:received_type], rate: "0.00", tax_amount: 0) + ] + ) + end + + it "creates applied_taxes based on the provider rules" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + applied_tax = applied_taxes.first + expect(applied_tax).to have_attributes( + tax_code: applied_rule[:tax_code], + tax_name: applied_rule[:expected_name], + tax_description: applied_rule[:received_type] + ) + expect(fee).to have_attributes(taxes_amount_cents: 0, taxes_precise_amount_cents: 0.0, taxes_rate: 0) + end + end + end + end + end +end diff --git a/spec/services/fees/apply_provider_taxes_to_standalone_fees_service_spec.rb b/spec/services/fees/apply_provider_taxes_to_standalone_fees_service_spec.rb new file mode 100644 index 0000000..a1a2566 --- /dev/null +++ b/spec/services/fees/apply_provider_taxes_to_standalone_fees_service_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::ApplyProviderTaxesToStandaloneFeesService do + subject(:service) { described_class.new(customer:, fees:, currency: "EUR") } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + let(:fee1) { create(:fee, amount_cents: 1000, precise_amount_cents: 1000, taxes_amount_cents: 0, taxes_precise_amount_cents: 0) } + let(:fee2) { create(:fee, amount_cents: 500, precise_amount_cents: 500, taxes_amount_cents: 0, taxes_precise_amount_cents: 0) } + let(:fees) { [fee1, fee2] } + + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + end + + describe "#call" do + context "when provider returns taxes successfully" do + let(:body) do + { + succeededInvoices: [{ + id: "inv_123", + fees: [ + {item_id: fee1.id, item_code: "code_1", amount_cents: 1000, tax_amount_cents: 100, + tax_breakdown: [{name: "VAT", rate: "0.10", tax_amount: 100, type: "tax"}]}, + {item_id: fee2.id, item_code: "code_2", amount_cents: 500, tax_amount_cents: 50, + tax_breakdown: [{name: "VAT", rate: "0.10", tax_amount: 50, type: "tax"}]} + ] + }], + failedInvoices: [] + }.to_json + end + + before do + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "applies provider taxes to each fee" do + result = service.call + + expect(result).to be_success + + expect(fee1.applied_taxes.count).to eq(1) + expect(fee1.applied_taxes.first.tax_name).to eq("VAT") + expect(fee1.taxes_amount_cents).to eq(100) + + expect(fee2.applied_taxes.count).to eq(1) + expect(fee2.applied_taxes.first.tax_name).to eq("VAT") + expect(fee2.taxes_amount_cents).to eq(50) + end + end + + context "when provider returns a failure" do + before do + allow(lago_client).to receive(:post_with_response) + .and_raise(LagoHttpClient::HttpError.new(500, "error", "http://test")) + end + + it "returns success without applying taxes" do + result = service.call + + expect(result).to be_success + + expect(fee1.applied_taxes).to be_empty + expect(fee1.taxes_amount_cents).to eq(0) + + expect(fee2.applied_taxes).to be_empty + expect(fee2.taxes_amount_cents).to eq(0) + end + end + end +end diff --git a/spec/services/fees/apply_taxes_service_spec.rb b/spec/services/fees/apply_taxes_service_spec.rb new file mode 100644 index 0000000..a5e2257 --- /dev/null +++ b/spec/services/fees/apply_taxes_service_spec.rb @@ -0,0 +1,357 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::ApplyTaxesService do + subject(:apply_service) { described_class.new(fee:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + + let(:invoice) { create(:invoice, organization:, customer:) } + + let(:fee) { create(:fee, invoice:, amount_cents: 1000, precise_amount_cents: 1000.0, precise_coupons_amount_cents:) } + let(:precise_coupons_amount_cents) { 0 } + + let(:tax1) { create(:tax, organization:, rate: 10, applied_to_organization: false) } + let(:tax2) { create(:tax, organization:, rate: 12, applied_to_organization: false) } + let(:tax3) { create(:tax, organization:, rate: 5, applied_to_organization: true) } + + before do + tax1 + tax2 + tax3 + create(:billing_entity_applied_tax, billing_entity:, tax: tax3) + end + + describe "call" do + context "when tax_codes parameter" do + let(:tax_codes) { [tax2.code, tax3.code] } + + it "creates applied_taxes based on the customer taxes" do + result = described_class.new(fee:, tax_codes:).call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.map(&:tax_code)).to contain_exactly(tax2.code, tax3.code) + expect(fee).to have_attributes(taxes_amount_cents: 170, taxes_precise_amount_cents: 170.0, taxes_rate: 17) + end + end + + context "when fee is commitment type with taxes" do + let(:commitment) { create(:commitment, :minimum_commitment, plan:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:organization) { create(:organization) } + + let(:fee) do + create( + :minimum_commitment_fee, + invoice:, + amount_cents: 1000, + precise_amount_cents: 1000.0, + commitment:, + subscription: + ) + end + + before { create(:commitment_applied_tax, commitment:, tax: tax2) } + + it "creates applied_taxes based on the commitment taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 12, + amount_currency: plan.amount_currency, + amount_cents: 120, + precise_amount_cents: 120.0 + ) + end + end + + context "when fee is add_on type with taxes" do + let(:add_on) { create(:add_on, organization:) } + let(:applied_tax2) { create(:add_on_applied_tax, add_on:, tax: tax2) } + let(:subscription) { create(:subscription, organization:, customer:) } + + let(:fee) do + create(:add_on_fee, invoice:, amount_cents: 1000, precise_amount_cents: 1000.0, add_on:, subscription:) + end + + before { applied_tax2 } + + it "creates applied_taxes based on the add_on taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 12, + amount_currency: fee.currency, + amount_cents: 120, + precise_amount_cents: 120.0 + ) + end + end + + context "when fee is a charge type with taxes applied to the plan" do + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, plan:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + + let(:fee) do + create(:charge_fee, invoice:, amount_cents: 1000, precise_amount_cents: 1000.0, charge:, subscription:) + end + + let(:applied_tax) { create(:plan_applied_tax, plan:, tax: tax1) } + + before { applied_tax } + + it "creates applied_taxes based on the plan taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 10, + amount_currency: fee.currency, + amount_cents: 100, + precise_amount_cents: 100.0 + ) + end + + context "when taxes are applied to the charge" do + let(:applied_tax2) { create(:charge_applied_tax, charge:, tax: tax2) } + + before { applied_tax2 } + + it "creates applied_taxes based on the plan taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 12, + amount_currency: fee.currency, + amount_cents: 120, + precise_amount_cents: 120.0 + ) + end + end + + context "when fee is a subscription type with taxes applied to the plan" do + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + let(:fee) { create(:fee, invoice:, amount_cents: 1000, precise_amount_cents: 1000.0, subscription:) } + let(:applied_tax) { create(:plan_applied_tax, plan:, tax: tax1) } + + before { applied_tax } + + it "creates applied_taxes based on the plan taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 10, + amount_currency: fee.currency, + amount_cents: 100, + precise_amount_cents: 100.0 + ) + end + end + end + + context "when customer has applied_taxes" do + let(:applied_tax1) { create(:customer_applied_tax, customer:, tax: tax1) } + let(:applied_tax2) { create(:customer_applied_tax, customer:, tax: tax2) } + + before do + applied_tax1 + applied_tax2 + end + + it "creates applied_taxes based on the customer taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 10, + amount_currency: fee.currency, + amount_cents: 100, + precise_amount_cents: 100.0 + ) + + expect(applied_taxes[1]).to have_attributes( + fee:, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 12, + amount_currency: fee.currency, + amount_cents: 120, + precise_amount_cents: 120.0 + ) + + expect(fee).to have_attributes( + taxes_amount_cents: 220, + taxes_precise_amount_cents: 220.0, + taxes_rate: 22 + ) + end + end + + context "when a coupon amount is applied to the fee" do + let(:precise_coupons_amount_cents) { 100 } + + it "takes the coupon amount into account" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax3, + tax_description: tax3.description, + tax_code: tax3.code, + tax_name: tax3.name, + tax_rate: 5, + amount_currency: fee.currency, + amount_cents: 45, + precise_amount_cents: 45.0 + ) + + expect(fee).to have_attributes( + taxes_amount_cents: 45, # (1000 - 100) * 5 / 100 + taxes_precise_amount_cents: 45.0, # (1000 - 100) * 5 / 100 + taxes_rate: 5 + ) + end + end + + it "creates applied_taxes based on the billing entity taxes" do + result = apply_service.call + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax3, + tax_description: tax3.description, + tax_code: tax3.code, + tax_name: tax3.name, + tax_rate: 5, + amount_currency: fee.currency, + amount_cents: 50, + precise_amount_cents: 50.0 + ) + + expect(fee).to have_attributes( + taxes_amount_cents: 50, + taxes_precise_amount_cents: 50.0, + taxes_rate: 5 + ) + end + + context "when fee already have taxes" do + before { create(:fee_applied_tax, fee:, tax: tax1) } + + it "does not reaply taxes" do + expect do + result = apply_service.call + + expect(result).to be_success + end.not_to change { fee.applied_taxes.count } + end + end + + context "when fee is a fixed charge type with taxes" do + let(:fixed_charge) { create(:fixed_charge, organization:) } + let(:fee) { create(:fixed_charge_fee, invoice:, amount_cents: 1000, precise_amount_cents: 1000.0, fixed_charge:) } + let(:applied_tax) { create(:fixed_charge_applied_tax, fixed_charge:, tax: tax1) } + + before { applied_tax } + + it "creates applied_taxes based on the fixed charge taxes" do + result = apply_service.call + expect(result).to be_success + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + + expect(applied_taxes[0]).to have_attributes( + fee:, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 10, + amount_currency: fee.currency, + amount_cents: 100, + precise_amount_cents: 100.0 + ) + end + end + end +end diff --git a/spec/services/fees/build_pay_in_advance_fixed_charge_service_spec.rb b/spec/services/fees/build_pay_in_advance_fixed_charge_service_spec.rb new file mode 100644 index 0000000..d5f1d6c --- /dev/null +++ b/spec/services/fees/build_pay_in_advance_fixed_charge_service_spec.rb @@ -0,0 +1,681 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::BuildPayInAdvanceFixedChargeService, :premium do + subject(:result) do + described_class.call(subscription:, fixed_charge:, fixed_charge_event:, timestamp:) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + let(:add_on) { create(:add_on, organization:) } + let(:subscription) do + create( + :subscription, + organization:, + customer:, + plan:, + status: :active, + started_at: Time.zone.parse("2024-03-01") + ) + end + + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + units: 10, + properties: {amount: "10"}, + prorated: false, + pay_in_advance: true + ) + end + + let(:timestamp) { Time.zone.parse("2024-03-15").to_i } + + context "when there are no existing fees (fixed charge added)" do + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + it "creates a fee for all units" do + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(10) + expect(result.fee.amount_cents).to eq(10_000) # 10 units * $10 = $100 + end + end + + context "when units increase (delta is positive)" do + let(:boundaries) { + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + } + + let(:existing_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 10, + amount_cents: 10_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 15, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + existing_fee + end + + it "creates a fee for only the delta units" do + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(5) # 15 - 10 = 5 delta units + expect(result.fee.amount_cents).to eq(5_000) # 5 units * $10 = $50 + end + end + + context "when units decrease (delta is negative)" do + let(:boundaries) do + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + end + + let(:existing_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 10, + amount_cents: 10_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 5, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + existing_fee + end + + it "creates a zero-amount fee (no refund)" do + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(0) + expect(result.fee.amount_cents).to eq(0) + end + end + + context "when units stay the same (delta is zero)" do + let(:boundaries) do + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + end + + let(:existing_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 10, + amount_cents: 10_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + existing_fee + end + + it "creates a zero-amount fee (no refund)" do + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(0) + expect(result.fee.amount_cents).to eq(0) + end + end + + context "when there are multiple previous fees in the billing period" do + let(:boundaries) do + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + end + + let(:first_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 10, + amount_cents: 10_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:second_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 5, # Delta from first increase (10 -> 15 = 5) + amount_cents: 5_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + # Increasing from 15 to 20 + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 20, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + first_fee + second_fee + end + + it "calculates delta from total already paid units" do + expect(result).to be_success + expect(result.fee).to be_present + # Already paid: 10 + 5 = 15, new units: 20, delta: 5 + expect(result.fee.units).to eq(5) + expect(result.fee.amount_cents).to eq(5_000) + end + end + + context "when there are multiple previous fees with decrease in the billing period" do + let(:boundaries) do + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + end + + let(:first_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 10, + amount_cents: 10_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:second_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 0, # Delta from decrease (10 -> 5 = -5) + amount_cents: 0, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + # Increasing from 5 to 20 + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 20, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + first_fee + second_fee + end + + it "calculates delta from total already paid units" do + expect(result).to be_success + expect(result.fee).to be_present + # Already paid: 10 + 0 = 10, new units: 20, delta: 10 + expect(result.fee.units).to eq(10) + expect(result.fee.amount_cents).to eq(10_000) + end + end + + context "with prorated fixed charge" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + units: 10, + properties: {amount: "10"}, + prorated: true, + pay_in_advance: true + ) + end + + context "when there are no existing fees (fixed charge added mid-period)" do + let(:timestamp) { Time.zone.parse("2024-03-15").to_i } + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + it "creates a prorated fee for all units" do + # March has 31 days. From March 15 to March 31 is 17 days (31 - 15 + 1) + # Proration coefficient = 17/31 ≈ 0.548 + # Amount = 10 units * $10 * (17/31) ≈ $54.84 + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(10) + expect(result.fee.amount_cents).to eq(5484) # 10 * 1000 * (17/31) = 5483.87 rounded to 5484 + end + end + + context "when fixed charge is added on the first day of the billing period" do + let(:timestamp) { Time.zone.parse("2024-03-01").to_i } + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + it "creates a fee with full amount (no proration)" do + # March 1 to March 31 is 31 days + # Proration coefficient = 31/31 = 1.0 + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(10) + expect(result.fee.amount_cents).to eq(10_000) + end + end + + context "when fixed charge is added on the last day of the billing period" do + let(:timestamp) { Time.zone.parse("2024-03-31").to_i } + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + it "creates a minimally prorated fee" do + # Only 1 day remaining in period + # Proration coefficient = 1/31 ≈ 0.032 + # Amount = 10 * $10 * (1/31) ≈ $3.23 + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(10) + expect(result.fee.amount_cents).to eq(323) # 10 * 1000 * (1/31) = 322.58 rounded to 323 + end + end + + context "when units increase mid-period (delta is positive)" do + let(:timestamp) { Time.zone.parse("2024-03-20").to_i } + let(:boundaries) do + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + end + + let(:existing_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 10, + amount_cents: 10_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 15, + timestamp: Time.zone.at(timestamp) + ) + end + + before { existing_fee } + + it "creates a prorated fee for the delta units only" do + # From March 20 to March 31 is 12 days (31 - 20 + 1) + # Proration coefficient = 12/31 ≈ 0.387 + # Delta = 15 - 10 = 5 units + # Amount = 5 * $10 * (12/31) ≈ $19.35 + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(5) + expect(result.fee.amount_cents).to eq(1935) # 5 * 1000 * (12/31) = 1935.48 rounded to 1935 + end + end + + context "when units decrease (delta is negative)" do + let(:timestamp) { Time.zone.parse("2024-03-20").to_i } + let(:boundaries) do + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + end + + let(:existing_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 10, + amount_cents: 10_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 5, + timestamp: Time.zone.at(timestamp) + ) + end + + before { existing_fee } + + it "creates a zero-amount fee (no refund for prorated charges)" do + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(0) + expect(result.fee.amount_cents).to eq(0) + end + end + + context "when there are multiple increases throughout the billing period" do + let(:timestamp) { Time.zone.parse("2024-03-25").to_i } + let(:boundaries) do + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + end + + let(:first_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 5, + amount_cents: 5_000, # First increase at beginning of period + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:second_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge:, + fee_type: :fixed_charge, + units: 5, # Second increase mid-period (5 -> 10 = delta of 5) + amount_cents: 2_500, # Already prorated for some portion + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 15, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + first_fee + second_fee + end + + it "calculates prorated delta from total already paid units" do + # Already paid: 5 + 5 = 10 units + # New units: 15, delta: 5 + # From March 25 to March 31 is 7 days (31 - 25 + 1) + # Proration coefficient = 7/31 ≈ 0.226 + # Amount = 5 * $10 * (7/31) ≈ $11.29 + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(5) + expect(result.fee.amount_cents).to eq(1129) # 5 * 1000 * (7/31) = 1129.03 rounded to 1129 + end + end + + context "with weekly plan interval" do + let(:plan) { create(:plan, organization:, interval: "weekly", pay_in_advance: true) } + let(:subscription) do + create( + :subscription, + organization:, + customer:, + plan:, + status: :active, + started_at: Time.zone.parse("2024-03-04") # Monday + ) + end + let(:timestamp) { Time.zone.parse("2024-03-07").to_i } # Thursday + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + it "prorates correctly for weekly billing period" do + # Week starts March 4 (Mon), ends March 10 (Sun) = 7 days + # From March 7 (Thu) to March 10 (Sun) is 4 days + # Proration coefficient = 4/7 ≈ 0.571 + # Amount = 10 * $10 * (4/7) ≈ $57.14 + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(10) + expect(result.fee.amount_cents).to eq(5714) # 10 * 1000 * (4/7) = 5714.29 rounded to 5714 + end + end + + context "with yearly plan interval" do + let(:plan) { create(:plan, organization:, interval: "yearly", pay_in_advance: true) } + let(:subscription) do + create( + :subscription, + organization:, + customer:, + plan:, + status: :active, + started_at: Time.zone.parse("2024-01-01") + ) + end + let(:timestamp) { Time.zone.parse("2024-07-01").to_i } # Mid-year + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + it "prorates correctly for yearly billing period" do + # 2024 is a leap year with 366 days + # From July 1 to Dec 31 is 184 days + # Proration coefficient = 184/366 ≈ 0.503 + # Amount = 10 * $10 * (184/366) ≈ $50.27 + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(10) + expect(result.fee.amount_cents).to eq(5027) # 10 * 1000 * (184/366) = 5027.32 rounded to 5027 + end + end + end + + context "when units increase and previous fee belongs to parent fixed charge" do + let(:parent_fixed_charge) do + create(:fixed_charge, add_on:, units: 5, properties: {amount: "10"}, prorated: false, pay_in_advance: true) + end + + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + units: 15, + properties: {amount: "10"}, + prorated: false, + pay_in_advance: true, + parent: parent_fixed_charge + ) + end + + let(:boundaries) { + Subscriptions::DatesService.fixed_charge_pay_in_advance_interval(timestamp, subscription) + } + + let(:existing_fee) do + create( + :fee, + organization:, + subscription:, + fixed_charge: parent_fixed_charge, + fee_type: :fixed_charge, + units: 5, + amount_cents: 5_000, + properties: { + "fixed_charges_from_datetime" => boundaries[:fixed_charges_from_datetime].iso8601(3), + "fixed_charges_to_datetime" => boundaries[:fixed_charges_to_datetime].iso8601(3) + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 15, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + existing_fee + end + + it "creates a fee for only the delta units (15 - 5 = 10)" do + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.units).to eq(10) # 15 - 5 = 10 delta units + expect(result.fee.amount_cents).to eq(10_000) # 10 units * $10 = $100 + end + end +end diff --git a/spec/services/fees/charge_service_spec.rb b/spec/services/fees/charge_service_spec.rb new file mode 100644 index 0000000..eb49976 --- /dev/null +++ b/spec/services/fees/charge_service_spec.rb @@ -0,0 +1,4469 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::ChargeService, :premium do + subject(:charge_subscription_service) do + described_class.new( + invoice:, + charge:, + subscription:, + boundaries:, + context:, + apply_taxes:, + filtered_aggregations: + ) + end + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:context) { :finalize } + let(:apply_taxes) { false } + let(:filtered_aggregations) { nil } + + let(:subscription) do + create( + :subscription, + organization:, + status: :active, + started_at: Time.zone.parse("2022-03-15"), + customer: + ) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.to_date.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + timestamp: subscription.started_at.end_of_month.end_of_day + 1.second, + charges_duration: ( + subscription.started_at.end_of_month.end_of_day - subscription.started_at.beginning_of_month + ).fdiv(1.day).ceil + ) + end + + let(:invoice) do + create(:invoice, customer:, organization:) + end + + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "20" + } + ) + end + + describe ".call" do + context "without filters" do + it "creates a fee" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to be_zero + end + + context "with an event" do + let(:event) do + create( + :event, + organization: subscription.organization, + subscription:, + code: billable_metric.code, + timestamp: boundaries.charges_to_datetime - 2.days + ) + end + + before { event } + + it "creates a fee" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + organization_id: organization.id, + billing_entity_id: invoice.customer.billing_entity_id, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 2000, + precise_amount_cents: 2000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 2000, + precise_unit_amount: 20, + events_count: 1, + payment_status: "pending" + ) + end + + it "sets correct boundaries on the fee properties" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first.properties).to include( + "charges_from_datetime" => "2022-03-15T00:00:00.000Z", + "charges_to_datetime" => "2022-03-31T23:59:59.999Z", + "charges_duration" => 31, + "fixed_charges_from_datetime" => nil, + "fixed_charges_to_datetime" => nil, + "fixed_charges_duration" => nil + ) + end + + it "persists fee" do + expect { charge_subscription_service.call }.to change(Fee, :count) + end + + context "when charge uses presentation_group_keys" do + let(:event) do + create( + :event, + organization: subscription.organization, + subscription:, + code: billable_metric.code, + timestamp: boundaries.charges_to_datetime - 2.days, + properties: {region: "apac", value: 0} + ) + end + + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "0", + presentation_group_keys: [{value: "region"}] + } + ) + end + + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa apac]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: { + amount: "0", + presentation_group_keys: [{value: "region"}] + } + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: { + amount: "0", + presentation_group_keys: [{value: "region"}] + } + ) + end + + before do + create(:charge_filter_value, charge_filter: europe_filter, billable_metric_filter: region, values: ["europe"]) + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", value: 10} + ) + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", value: 5} + ) + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "apac", value: 3} + ) + end + + it "builds presentation_breakdowns on each persisted fee" do + expect { charge_subscription_service.call }.to change(Fee, :count).from(0).to(3) + + result = charge_subscription_service.call + + europe_fee = result.fees.find { |f| f.charge_filter_id == europe_filter.id } + usa_fee = result.fees.find { |f| f.charge_filter_id == usa_filter.id } + catch_all_fee = result.fees.find { |f| f.charge_filter_id.nil? } + + expect(europe_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "europe"}]) + expect(usa_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "usa"}]) + expect(catch_all_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "apac"}]) + + expect(europe_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([10.0]) + expect(usa_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([5.0]) + expect(catch_all_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([3.0]) + + expect(result.fees.flat_map(&:presentation_breakdowns)).to all(have_attributes(organization_id: organization.id)) + end + end + + context "with preview context" do + let(:context) { :invoice_preview } + + it "does not persist fee" do + expect { charge_subscription_service.call }.not_to change(Fee, :count) + end + end + end + + context "with grouped standard charge" do + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "20", + pricing_group_keys: ["cloud"] + } + ) + end + + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + context "without events" do + it "does not create a fee" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(0) + end + end + + context "with events" do + before do + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "aws", value: 10} + ) + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "aws", value: 5} + ) + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "gcp", value: 10} + ) + end + + it "creates a fee for each group" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f.grouped_by["cloud"] == "aws" } + expect(fee1).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 30_000, + precise_amount_cents: 30_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 15, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "aws"} + ) + + fee2 = result.fees.find { |f| f.grouped_by["cloud"] == "gcp" } + expect(fee2).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 20_000, + precise_amount_cents: 20_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "gcp"} + ) + end + + context "with adjusted fee" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties:, + fee_type: :charge, + adjusted_units: true, + adjusted_amount: false, + units: 3, + grouped_by: {"cloud" => "aws"} + ) + end + + let(:properties) do + { + charges_from_datetime: boundaries.charges_from_datetime, + charges_to_datetime: boundaries.charges_to_datetime + } + end + + before do + adjusted_fee + invoice.draft! + end + + it "creates a fee for each group" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f.grouped_by["cloud"] == "aws" } + expect(fee1).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 6_000, + precise_amount_cents: 6_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 3, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "aws"} + ) + + fee2 = result.fees.find { |f| f.grouped_by["cloud"] == "gcp" } + expect(fee2).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 20_000, + precise_amount_cents: 20_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "gcp"} + ) + end + end + + context "with recurring weighted sum aggregation" do + let(:billable_metric) { create(:weighted_sum_billable_metric, :recurring, organization:) } + + it "creates a fee and a cached aggregation per group" do + result = charge_subscription_service.call + expect(result).to be_success + + expect(result.fees.count).to eq(2) + expect(result.cached_aggregations.count).to eq(2) + end + end + + context "with custom aggregation" do + let(:billable_metric) do + create(:custom_billable_metric, :recurring, organization:) + end + + it "creates a fee and a cached aggregation" do + result = charge_subscription_service.call + expect(result).to be_success + + expect(result.fees.count).to eq(2) + expect(result.cached_aggregations.count).to eq(2) + end + end + end + end + + context "with pricing_group_keys and standard charge" do + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "20", + pricing_group_keys: ["cloud"] + } + ) + end + + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + context "with filters" do + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "20", + pricing_group_keys: ["region", "country"] + } + ) + end + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[eu na]) + end + let(:country) do + create(:billable_metric_filter, billable_metric:, key: "country", values: %w[us ca fr de]) + end + + let(:eu_filter) do + create(:charge_filter, charge:, properties: {amount: "30", pricing_group_keys: ["region", "country"]}) + end + let(:eu_country_filter_value) { create(:charge_filter_value, charge_filter: eu_filter, billable_metric_filter: country, values: ["fr", "de"]) } + let(:eu_region_filter_value) { create(:charge_filter_value, charge_filter: eu_filter, billable_metric_filter: region, values: ["eu"]) } + + let(:na_filter) do + create(:charge_filter, charge:, properties: {amount: "40", pricing_group_keys: ["region", "country"]}) + end + let(:na_country_filter_value) { create(:charge_filter_value, charge_filter: na_filter, billable_metric_filter: country, values: ["us", "ca"]) } + let(:na_region_filter_value) { create(:charge_filter_value, charge_filter: na_filter, billable_metric_filter: region, values: ["na"]) } + + before do + na_country_filter_value + na_region_filter_value + eu_country_filter_value + eu_region_filter_value + create_event("eu", "fr") + create_event("eu", "de") + create_event("na", "us") + create_event("na", "ca") + create_event("af", "ma") + create_event("af", "ma") + create_event("af", "dz") + end + + def create_event(region, country) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region:, country:, value: 1} + ) + end + + it "creates a fee for each group" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(6) + + sorted_fees = result.fees.sort_by { [it.grouped_by["region"], it.grouped_by["country"]] } + + af_dz_fee = sorted_fees[0] + expect(af_dz_fee).to have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 2000, + precise_amount_cents: 2000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"country" => "dz", "region" => "af"} + ) + + af_ma_fee = sorted_fees[1] + expect(af_ma_fee).to have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 4000, + precise_amount_cents: 4000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 2, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"country" => "ma", "region" => "af"} + ) + + eu_de = sorted_fees[2] + expect(eu_de).to have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 3000, + precise_amount_cents: 3000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 3000, + precise_unit_amount: 30, + grouped_by: {"country" => "de", "region" => "eu"} + ) + + eu_fr = sorted_fees[3] + expect(eu_fr).to have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 3000, + precise_amount_cents: 3000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 3000, + precise_unit_amount: 30, + grouped_by: {"country" => "fr", "region" => "eu"} + ) + + na_ca_fee = sorted_fees[4] + expect(na_ca_fee).to have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 4000, + precise_amount_cents: 4000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 4000, + precise_unit_amount: 40, + grouped_by: {"country" => "ca", "region" => "na"} + ) + + na_us_fee = sorted_fees[5] + expect(na_us_fee).to have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 4000, + precise_amount_cents: 4000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 4000, + precise_unit_amount: 40, + grouped_by: {"country" => "us", "region" => "na"} + ) + end + end + + context "without events" do + it "does not create a fee" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(0) + end + end + + context "with events" do + before do + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "aws", value: 10} + ) + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "aws", value: 5} + ) + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "gcp", value: 10} + ) + end + + it "creates a fee for each group" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f.grouped_by["cloud"] == "aws" } + expect(fee1).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 30_000, + precise_amount_cents: 30_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 15, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "aws"} + ) + + fee2 = result.fees.find { |f| f.grouped_by["cloud"] == "gcp" } + expect(fee2).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 20_000, + precise_amount_cents: 20_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "gcp"} + ) + end + + context "with adjusted fee" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties:, + fee_type: :charge, + adjusted_units: true, + adjusted_amount: false, + units: 3, + grouped_by: {"cloud" => "aws"} + ) + end + + let(:properties) do + { + charges_from_datetime: boundaries.charges_from_datetime, + charges_to_datetime: boundaries.charges_to_datetime + } + end + + before do + adjusted_fee + invoice.draft! + end + + it "creates a fee for each group" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f.grouped_by["cloud"] == "aws" } + expect(fee1).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 6_000, + precise_amount_cents: 6_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 3, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "aws"} + ) + + fee2 = result.fees.find { |f| f.grouped_by["cloud"] == "gcp" } + expect(fee2).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 20_000, + precise_amount_cents: 20_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 2000, + precise_unit_amount: 20, + grouped_by: {"cloud" => "gcp"} + ) + end + end + + context "with recurring weighted sum aggregation" do + let(:billable_metric) { create(:weighted_sum_billable_metric, :recurring, organization:) } + + it "creates a fee and a cached aggregation per group" do + result = charge_subscription_service.call + expect(result).to be_success + + expect(result.fees.count).to eq(2) + expect(result.cached_aggregations.count).to eq(2) + end + end + + context "with custom aggregation" do + let(:billable_metric) do + create(:custom_billable_metric, :recurring, organization:) + end + + it "creates a fee and a cached aggregation" do + result = charge_subscription_service.call + expect(result).to be_success + + expect(result.fees.count).to eq(2) + expect(result.cached_aggregations.count).to eq(2) + end + end + end + + context "with presentation_group_keys" do + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "20", + pricing_group_keys: ["cloud"], + presentation_group_keys: [{value: "region"}] + } + ) + end + + before do + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "aws", region: "us-east-1", value: 10} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "aws", region: "us-central-1", value: 5} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {cloud: "gcp", region: "eu-west-1", value: 3} + ) + end + + it "builds presentation_breakdowns scoped by pricing group on each persisted fee" do + expect { charge_subscription_service.call }.to change(Fee, :count).from(0).to(2) + + result = charge_subscription_service.call + + aws_fee = result.fees.find { |f| f.grouped_by["cloud"] == "aws" } + gcp_fee = result.fees.find { |f| f.grouped_by["cloud"] == "gcp" } + + expect(aws_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "us-east-1"}, {"region" => "us-central-1"}]) + expect(aws_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([10.0, 5.0]) + + expect(gcp_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "eu-west-1"}]) + expect(gcp_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([3.0]) + + expect(result.fees.flat_map(&:presentation_breakdowns)).to all(have_attributes(organization_id: organization.id)) + end + end + end + + context "with accepts_target_wallet enabled" do + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + accepts_target_wallet: true, + properties: { + amount: "20" + } + ) + end + + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + let(:event_wallet_1) do + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {target_wallet_code: "wallet_1", value: 10} + ) + end + + let(:event_wallet_2) do + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {target_wallet_code: "wallet_2", value: 5} + ) + end + + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + event_wallet_1 + event_wallet_2 + end + + it "creates a fee for each target_wallet_code" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_1" } + expect(fee1).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 20_000, + precise_amount_cents: 20_000.0, + amount_currency: "EUR", + units: 10, + grouped_by: {"target_wallet_code" => "wallet_1"} + ) + + fee2 = result.fees.find { |f| f.grouped_by["target_wallet_code"] == "wallet_2" } + expect(fee2).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 10_000, + precise_amount_cents: 10_000.0, + amount_currency: "EUR", + units: 5, + grouped_by: {"target_wallet_code" => "wallet_2"} + ) + end + end + + context "with graduated charge model" do + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + + before do + create_list( + :event, + 4, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16") + ) + end + + it "creates a fee" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 5, + precise_amount_cents: 5.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 4.0, + unit_amount_cents: 1, + precise_unit_amount: 0.0125, + events_count: 4 + ) + end + end + + context "when fee already exists on the period" do + before do + create(:fee, charge:, subscription:, invoice:) + end + + it "does not create a new fee" do + expect { charge_subscription_service.call }.not_to change(Fee, :count) + end + end + + context "when billing an new upgraded subscription" do + let(:previous_plan) { create(:plan, amount_cents: subscription.plan.amount_cents - 20) } + let(:previous_subscription) do + create(:subscription, plan: previous_plan, status: :terminated) + end + + let(:event) do + create( + :event, + organization: invoice.organization, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("10 Apr 2022 00:01:00") + ) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: Time.zone.parse("15 Apr 2022 00:01:00"), + to_datetime: Time.zone.parse("30 Apr 2022 00:01:00"), + charges_from_datetime: subscription.started_at, + charges_to_datetime: Time.zone.parse("30 Apr 2022 00:01:00"), + charges_duration: 30, + timestamp: Time.zone.parse("2022-05-01T00:01:00") + ) + end + + before do + subscription.update!(previous_subscription:) + event + end + + it "creates a new fee for the complete period" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 2000, + precise_amount_cents: 2_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1 + ) + end + end + + context "with all types of aggregation" do + let(:event) do + create( + :event, + code: billable_metric.code, + organization: organization, + external_subscription_id: subscription.external_id, + timestamp: boundaries.charges_to_datetime - 2.days, + properties: {"foo_bar" => 1} + ) + end + + BillableMetric::AGGREGATION_TYPES.keys.each do |aggregation_type| + before do + billable_metric.update!( + aggregation_type:, + field_name: event.properties.keys.first, + weighted_interval: "seconds", + custom_aggregator: "def aggregate(event, agg, aggregation_properties); { total_units: 1, amount: 1 }; end" + ) + end + + context "without pricing unit on the charge" do + it "creates fees" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 2000, + precise_amount_cents: 2000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 2000, + precise_unit_amount: 20 + ) + end + + it "does not create pricing unit usage" do + expect { charge_subscription_service.call }.not_to change(PricingUnitUsage, :count) + end + end + + context "with pricing unit on the charge" do + before do + create( + :applied_pricing_unit, + organization: subscription.organization, + conversion_rate: 0.25, + pricing_unitable: charge + ) + end + + it "creates fees" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 500, + precise_amount_cents: 500.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 500, + precise_unit_amount: 5 + ) + end + + it "creates pricing unit usage" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first.pricing_unit_usage) + .to be_persisted + .and have_attributes( + amount_cents: 2000, + precise_amount_cents: 2000.0, + unit_amount_cents: 2000 + ) + end + end + end + end + + context "when there is adjusted fee" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties:, + fee_type: :charge, + adjusted_units: true, + adjusted_amount: false, + units: 3 + ) + end + let(:properties) do + { + charges_from_datetime: boundaries.charges_from_datetime, + charges_to_datetime: boundaries.charges_to_datetime + } + end + + before do + adjusted_fee + invoice.draft! + end + + context "with adjusted units" do + it "creates a fee" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 6_000, + precise_amount_cents: 6_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 3, + unit_amount_cents: 2_000, + precise_unit_amount: 20, + events_count: 0, + payment_status: "pending" + ) + end + + context "when there is true-up fee" do + before { charge.update!(min_amount_cents: 20_000) } + + it "creates two fees" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + expect(result.fees.pluck(:amount_cents)).to contain_exactly(6_000, 4_968) + expect(result.fees.pluck(:precise_amount_cents)).to contain_exactly(6_000.0, 4_967.74193548387) + expect(result.fees.pluck(:taxes_precise_amount_cents)).to contain_exactly(0.0, 0.0) + expect(result.fees.pluck(:unit_amount_cents)).to contain_exactly(2_000, 4_968) + expect(result.fees.pluck(:precise_unit_amount)).to contain_exactly(20, 49.6774193548387) + end + end + + context "with standard charge, all types of aggregation and presence of filters" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:country) do + create(:billable_metric_filter, billable_metric:, key: "country", values: ["france", "germany", "united kingdom"]) + end + + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:, properties: {amount: "10"}) } + + let(:europe_filter) { create_filter(amount: "20", values: {region => ["europe"]}) } + let(:usa_filter) { create_filter(amount: "30", values: {region => ["usa"]}) } + let(:france_filter) { create_filter(amount: "40.12345", values: {region => ["europe"], country => ["france"]}) } + let(:all_values_filter) do + all_values = [ChargeFilterValue::ALL_FILTER_VALUES] + create_filter(amount: "50", values: {region => all_values, country => all_values}) + end + + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + charge_filter: usa_filter, + properties:, + fee_type: :charge, + adjusted_units: true, + adjusted_amount: false, + units: 3 + ) + end + + before do + region + country + + europe_filter + usa_filter + france_filter + all_values_filter + + # usa filter events + create_event(properties: {region: "usa", foo_bar: 12}) + + # europe filter events + create_event(properties: {region: "europe", foo_bar: 10}) + create_event(properties: {region: "europe", foo_bar: 2}) + create_event(properties: {region: "europe", country: "italy", foo_bar: 3}) + + # france filter events + create_event(properties: {region: "europe", country: "france", foo_bar: 5}) + + # All values filter events + create_event(properties: {region: "europe", country: "united kingdom", foo_bar: 5}) + create_event(properties: {region: "europe", country: "germany", foo_bar: 5}) + + # No filter events + create_event(properties: {region: "asia", country: "japan", foo_bar: 3}) + create_event(properties: {foo_bar: 2}) + end + + def create_event(properties:) + organization = subscription.organization + code = charge.billable_metric.code + create(:event, organization:, subscription:, code:, timestamp: Time.zone.parse("2022-03-16"), properties:) + end + + def create_filter(amount:, values:) + filter = create(:charge_filter, charge:, properties: {amount:}) + values.each do |billable_metric_filter, values| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values:) + end + filter + end + + it "creates expected fees for sum_agg aggregation type" do + billable_metric.update!(aggregation_type: :sum_agg, field_name: "foo_bar") + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(5) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + + usa_fee = created_fees.find { |f| f.charge_filter == usa_filter } + expect(usa_fee).to have_attributes( + charge_filter: usa_filter, + amount_cents: 9_000, + precise_amount_cents: 9_000.0, + taxes_precise_amount_cents: 0.0, + units: 3, + unit_amount_cents: 3000, + precise_unit_amount: 30 + ) + + europe_fee = created_fees.find { |f| f.charge_filter == europe_filter } + expect(europe_fee).to have_attributes( + charge_filter: europe_filter, + amount_cents: 30_000, + precise_amount_cents: 30_000.0, + taxes_precise_amount_cents: 0.0, + units: 15, + unit_amount_cents: 2000, + precise_unit_amount: 20 + ) + + france_fee = created_fees.find { |f| f.charge_filter == france_filter } + expect(france_fee).to have_attributes( + charge_filter: france_filter, + amount_cents: 20062, + precise_amount_cents: 20061.725, + taxes_precise_amount_cents: 0.0, + units: 5, + unit_amount_cents: 4012, + precise_unit_amount: 40.12345 + ) + + all_filter_fee = created_fees.find { |f| f.charge_filter == all_values_filter } + expect(all_filter_fee).to have_attributes( + charge_filter: all_values_filter, + amount_cents: 50000, + precise_amount_cents: 50000.0, + taxes_precise_amount_cents: 0.0, + units: 10, + unit_amount_cents: 5000, + precise_unit_amount: 50.0 + ) + + no_filter_fee = created_fees.find { |f| f.charge_filter.blank? } + expect(no_filter_fee).to have_attributes( + charge_filter: nil, + amount_cents: 5000, + precise_amount_cents: 5000.0, + taxes_precise_amount_cents: 0.0, + units: 5, + unit_amount_cents: 1000, + precise_unit_amount: 10.0 + ) + end + end + end + + context "with adjusted amount" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties:, + fee_type: :charge, + adjusted_units: false, + adjusted_amount: true, + units: 1000, + unit_amount_cents: 0, + unit_precise_amount_cents: 0.1 + ) + end + + it "creates a fee" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 100, + precise_amount_cents: 100.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 1000, + unit_amount_cents: 0, + precise_unit_amount: 0.001, + events_count: 0, + payment_status: "pending" + ) + end + end + + context "with adjusted display name" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties:, + fee_type: :charge, + adjusted_units: false, + adjusted_amount: false, + invoice_display_name: "test123", + units: 3 + ) + end + + it "creates a fee" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 0, + precise_amount_cents: 0.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + payment_status: "pending", + invoice_display_name: "test123" + ) + end + end + + context "with invoice NOT in draft status" do + before { invoice.finalized! } + + it "creates a fee without using adjusted fee attributes" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 0, + amount_currency: "EUR", + units: 0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + payment_status: "pending" + ) + end + end + end + + context "with true-up fee" do + it "creates two fees" do + travel_to(Time.zone.parse("2023-04-01")) do + charge.update!(min_amount_cents: 1000) + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + expect(result.fees.pluck(:amount_cents)).to contain_exactly(0, 548) # 548 is 1000 prorated for 17 days. + expect(result.fees.pluck(:precise_amount_cents)).to contain_exactly(0.0, 548.3870967741935) # 548 is 1000 prorated for 17 days. + expect(result.fees.pluck(:taxes_precise_amount_cents)).to contain_exactly(0.0, 0.0) # 548 is 1000 prorated for 17 days. + expect(result.fees.pluck(:unit_amount_cents)).to contain_exactly(0, 548) + expect(result.fees.pluck(:precise_unit_amount)).to contain_exactly(0, 5.483870967741935) + end + end + + context "with charge using pricing units" do + before do + create( + :applied_pricing_unit, + organization: charge.organization, + conversion_rate: 1, + pricing_unitable: charge + ) + end + + it "persists pricing unit usages" do + travel_to(Time.zone.parse("2023-04-01")) do + charge.update!(min_amount_cents: 1000) + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.map(&:pricing_unit_usage)).to all be_persisted + end + end + end + end + + context "with negative units" do + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + + let(:billable_metric) { create(:weighted_sum_billable_metric, organization:) } + + before do + create( + :event, + organization: subscription.organization, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {value: -10} + ) + end + + it "creates a fee with 0 units but expected amount details" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 0, + precise_amount_cents: 0.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 0, + total_aggregated_units: -10.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 1, + payment_status: "pending", + amount_details: { + "graduated_ranges" => [ + { + "flat_unit_amount" => "0.01", + "from_value" => 0, + "per_unit_amount" => "0.01", + "per_unit_total_amount" => "-0.051612903225806452", + "to_value" => nil, + "total_with_flat_amount" => "-0.041612903225806452", + "units" => "-5.1612903225806452" + } + ] + } + ) + end + end + end + + context "with standard charge, all types of aggregation and presence of filter" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:country) do + create(:billable_metric_filter, billable_metric:, key: "country", values: %w[france]) + end + + let(:europe_filter) { create(:charge_filter, charge:, properties: {amount: "20"}) } + let(:europe_filter_value) do + create(:charge_filter_value, charge_filter: europe_filter, billable_metric_filter: region, values: ["europe"]) + end + + let(:usa_filter) { create(:charge_filter, charge:, properties: {amount: "50"}) } + let(:usa_filter_value) do + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + let(:france_filter) { create(:charge_filter, charge:, properties: {amount: "10.12345"}) } + let(:france_filter_value) do + create(:charge_filter_value, charge_filter: france_filter, billable_metric_filter: country, values: ["france"]) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "10.12345"} + ) + end + + before do + europe_filter_value + usa_filter_value + france_filter_value + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 10} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 5} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {country: "france", foo_bar: 5} + ) + end + + it "creates expected fees for count_agg aggregation type" do + billable_metric.update!(aggregation_type: :count_agg) + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(4) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 4000, + precise_amount_cents: 4000.0, + taxes_precise_amount_cents: 0.0, + units: 2, + unit_amount_cents: 2000, + precise_unit_amount: 20 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 5000, + precise_amount_cents: 5000.0, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 5000, + precise_unit_amount: 50 + ) + + expect(created_fees.third).to have_attributes( + charge_filter: france_filter, + amount_cents: 1012, + precise_amount_cents: 1012.345, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 1012, + precise_unit_amount: 10.12345 + ) + end + + it "creates expected fees for sum_agg aggregation type" do + billable_metric.update!(aggregation_type: :sum_agg, field_name: "foo_bar") + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(4) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 30_000, + precise_amount_cents: 30_000.0, + taxes_precise_amount_cents: 0.0, + units: 15, + unit_amount_cents: 2000, + precise_unit_amount: 20 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 60_000, + precise_amount_cents: 60_000.0, + taxes_precise_amount_cents: 0.0, + units: 12, + unit_amount_cents: 5000, + precise_unit_amount: 50 + ) + + expect(created_fees.third).to have_attributes( + charge_filter: france_filter, + amount_cents: 5062, + precise_amount_cents: 5061.725, + taxes_precise_amount_cents: 0.0, + units: 5, + unit_amount_cents: 1012, + precise_unit_amount: 10.12345 + ) + end + + it "creates expected fees for max_agg aggregation type" do + billable_metric.update!(aggregation_type: :max_agg, field_name: "foo_bar") + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(4) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 20_000, + precise_amount_cents: 20_000.0, + taxes_precise_amount_cents: 0.0, + units: 10, + unit_amount_cents: 2000, + precise_unit_amount: 20 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 60_000, + precise_amount_cents: 60_000.0, + taxes_precise_amount_cents: 0.0, + units: 12, + unit_amount_cents: 5000, + precise_unit_amount: 50 + ) + + expect(created_fees.third).to have_attributes( + charge_filter: france_filter, + amount_cents: 5062, + precise_amount_cents: 5061.725, + taxes_precise_amount_cents: 0.0, + units: 5, + unit_amount_cents: 1012, + precise_unit_amount: 10.12345 + ) + end + + context "when unique_count_agg" do + it "creates expected fees for unique_count_agg aggregation type", transaction: false do + billable_metric.update!(aggregation_type: :unique_count_agg, field_name: "foo_bar") + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(4) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 4000, + precise_amount_cents: 4_000.0, + taxes_precise_amount_cents: 0.0, + units: 2 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 5000, + precise_amount_cents: 5_000.0, + taxes_precise_amount_cents: 0.0, + units: 1 + ) + + expect(created_fees.third).to have_attributes( + charge_filter: france_filter, + amount_cents: 1012, + precise_amount_cents: 1012.345, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 1012, + precise_unit_amount: 10.12345 + ) + end + end + end + + context "with package charge and presence of filters" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:country) do + create(:billable_metric_filter, billable_metric:, key: "country", values: %w[france]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: { + amount: "100", + free_units: 1, + package_size: 8 + } + ) + end + let(:europe_filter_value) do + create( + :charge_filter_value, + charge_filter: europe_filter, + billable_metric_filter: region, + values: ["europe"] + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: { + amount: "50", + free_units: 0, + package_size: 10 + } + ) + end + let(:usa_filter_value) do + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + let(:france_filter) do + create( + :charge_filter, + charge:, + properties: { + amount: "40", + free_units: 1, + package_size: 5 + } + ) + end + let(:france_filter_value) do + create( + :charge_filter_value, + charge_filter: france_filter, + billable_metric_filter: country, + values: ["france"] + ) + end + + let(:charge) do + create( + :package_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "0", + free_units: 0, + package_size: 1 + } + ) + end + + before do + europe_filter_value + usa_filter_value + france_filter_value + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 10} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 5} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {country: "france", foo_bar: 5} + ) + end + + it "creates expected fees for count_agg aggregation type" do + billable_metric.update!(aggregation_type: :count_agg) + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(4) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + units: 2, + amount_cents: 10_000, + precise_amount_cents: 10_000.0, + taxes_precise_amount_cents: 0.0, + unit_amount_cents: 10_000, + precise_unit_amount: 100 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 5000, + precise_amount_cents: 5_000.0, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 5000, + precise_unit_amount: 50 + ) + + expect(created_fees.third).to have_attributes( + charge_filter: france_filter, + amount_cents: 0, + precise_amount_cents: 0.0, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 0, + precise_unit_amount: 0 + ) + end + end + + context "with percentage charge and presence of filters" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:country) do + create(:billable_metric_filter, billable_metric:, key: "country", values: %w[france]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: {rate: "2", fixed_amount: "1"} + ) + end + let(:europe_filter_value) do + create( + :charge_filter_value, + charge_filter: europe_filter, + billable_metric_filter: region, + values: ["europe"] + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: {rate: "1", fixed_amount: "0"} + ) + end + let(:usa_filter_value) do + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + let(:france_filter) do + create( + :charge_filter, + charge:, + properties: {rate: "5", fixed_amount: "1"} + ) + end + let(:france_filter_value) do + create( + :charge_filter_value, + charge_filter: france_filter, + billable_metric_filter: country, + values: ["france"] + ) + end + + let(:charge) do + create( + :percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: {rate: "0", fixed_amount: "0"} + ) + end + + before do + europe_filter_value + usa_filter_value + france_filter_value + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 10} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 5} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {country: "france", foo_bar: 5} + ) + end + + it "creates expected fees for count_agg aggregation type" do + billable_metric.update!(aggregation_type: :count_agg) + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(4) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 200 + 2 * 2, + precise_amount_cents: 200.0 + 2 * 2, + taxes_precise_amount_cents: 0.0, + units: 2, + unit_amount_cents: 102, + precise_unit_amount: 1.02 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 1 * 1, + precise_amount_cents: 1.0 * 1, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 1, + precise_unit_amount: 0.01 + ) + + expect(created_fees.third).to have_attributes( + charge_filter: france_filter, + amount_cents: 100 + 5 * 1, + precise_amount_cents: 100.0 + 5.0 * 1, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 105, + precise_unit_amount: 1.05 + ) + end + end + + context "with graduated charge and presence of filters" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + let(:europe_filter_value) do + create( + :charge_filter_value, + charge_filter: europe_filter, + billable_metric_filter: region, + values: ["europe"] + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.03", + flat_amount: "0.01" + } + ] + } + ) + end + let(:usa_filter_value) do + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0", + flat_amount: "0" + } + ] + } + ) + end + + before do + europe_filter_value + usa_filter_value + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 10} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 5} + ) + end + + context "without pricing unit on the charge" do + it "creates expected fees for count_agg aggregation type" do + billable_metric.update!(aggregation_type: :count_agg) + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(3) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 3, + precise_amount_cents: 3.0, + taxes_precise_amount_cents: 0.0, + units: 2, + unit_amount_cents: 1, + precise_unit_amount: 0.015 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 4, + precise_amount_cents: 4.0, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 4, + precise_unit_amount: 0.04 + ) + end + + it "does not create pricing unit usage" do + expect { charge_subscription_service.call }.not_to change(PricingUnitUsage, :count) + end + end + + context "with pricing unit on the charge" do + before do + create( + :applied_pricing_unit, + organization: subscription.organization, + conversion_rate: 2, + pricing_unitable: charge + ) + end + + it "creates expected fees for count_agg aggregation type" do + billable_metric.update!(aggregation_type: :count_agg) + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(3) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 6, + precise_amount_cents: 6.0, + taxes_precise_amount_cents: 0.0, + units: 2, + unit_amount_cents: 2, + precise_unit_amount: 0.02 + ) + + expect(created_fees.first.pricing_unit_usage) + .to be_persisted + .and have_attributes( + amount_cents: 3, + precise_amount_cents: 3.0, + unit_amount_cents: 1 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 8, + precise_amount_cents: 8.0, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 8, + precise_unit_amount: 0.08 + ) + + expect(created_fees.second.pricing_unit_usage) + .to be_persisted + .and have_attributes( + amount_cents: 4, + precise_amount_cents: 4.0, + unit_amount_cents: 4 + ) + end + end + end + + context "with volume charge and presence of filters" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: nil, per_unit_amount: "2", flat_amount: "10"} + ] + } + ) + end + let(:europe_filter_value) do + create( + :charge_filter_value, + charge_filter: europe_filter, + billable_metric_filter: region, + values: ["europe"] + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: nil, per_unit_amount: "1", flat_amount: "10"} + ] + } + ) + end + let(:usa_filter_value) do + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + let(:charge) do + create( + :volume_charge, + plan: subscription.plan, + billable_metric:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: nil, per_unit_amount: "0", flat_amount: "0"} + ] + } + ) + end + + before do + europe_filter_value + usa_filter_value + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 10} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 5} + ) + end + + it "creates expected fees for count_agg aggregation type" do + billable_metric.update!(aggregation_type: :count_agg) + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(3) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 1400, + precise_amount_cents: 1_400.0, + taxes_precise_amount_cents: 0.0, + units: 2, + unit_amount_cents: 700, + precise_unit_amount: 7 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 1100, + precise_amount_cents: 1_100.0, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 1100, + precise_unit_amount: 11 + ) + end + end + + context "with graduated percentage charge and presence of filters" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: nil, + flat_amount: "0.01", + rate: "2" + } + ] + } + ) + end + let(:europe_filter_value) do + create( + :charge_filter_value, + charge_filter: europe_filter, + billable_metric_filter: region, + values: ["europe"] + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: nil, + flat_amount: "0.01", + rate: "3" + } + ] + } + ) + end + let(:usa_filter_value) do + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + let(:charge) do + create( + :graduated_percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: nil, + flat_amount: "1", + rate: "0" + } + ] + } + ) + end + + before do + europe_filter_value + usa_filter_value + + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", foo_bar: 12} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 10} + ) + create( + :event, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", foo_bar: 5} + ) + end + + it "creates expected fees for count_agg aggregation type" do + billable_metric.update!(aggregation_type: :count_agg) + result = charge_subscription_service.call + expect(result).to be_success + created_fees = result.fees + + expect(created_fees.count).to eq(3) + expect(created_fees).to all( + have_attributes( + invoice_id: invoice.id, + charge_id: charge.id, + amount_currency: "EUR" + ) + ) + expect(created_fees.first).to have_attributes( + charge_filter: europe_filter, + amount_cents: 5, # 2 × 0.02 + 0.01 + precise_amount_cents: 5.0, + taxes_precise_amount_cents: 0.0, + units: 2, + unit_amount_cents: 2, + precise_unit_amount: 0.025 + ) + + expect(created_fees.second).to have_attributes( + charge_filter: usa_filter, + amount_cents: 4, # 1 × 0.03 + 0.01 + precise_amount_cents: 4.0, + taxes_precise_amount_cents: 0.0, + units: 1, + unit_amount_cents: 4, + precise_unit_amount: 0.04 + ) + end + end + + context "with true-up fee and presence of filters" do + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: {amount: "20"} + ) + end + let(:europe_filter_value) do + create( + :charge_filter_value, + charge_filter: europe_filter, + billable_metric_filter: region, + values: ["europe"] + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: {amount: "50"} + ) + end + let(:usa_filter_value) do + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + min_amount_cents: 1000, + properties: {amount: "0"} + ) + end + + before do + europe_filter_value + usa_filter_value + end + + it "creates two fees" do + travel_to(Time.zone.parse("2023-04-01")) do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + + # 548 is 1000 prorated for 17 days. + expect(result.fees.pluck(:amount_cents)).to contain_exactly(0, 548) + expect(result.fees.pluck(:precise_amount_cents)).to contain_exactly(0, 548.3870967741935) + expect(result.fees.pluck(:taxes_precise_amount_cents)).to contain_exactly(0.0, 0.0) + end + end + end + + context "with recurring weighted sum aggregation" do + let(:context) { :recurring } + let(:billable_metric) { create(:weighted_sum_billable_metric, :recurring, organization:) } + + it "creates a fee and a cached aggregation" do + result = charge_subscription_service.call + expect(result).to be_success + created_fee = result.fees.first + cached_aggregation = result.cached_aggregations.first + + expect(created_fee.id).not_to be_nil + expect(created_fee.invoice_id).to eq(invoice.id) + expect(created_fee.charge_id).to eq(charge.id) + expect(created_fee.amount_cents).to eq(0) + expect(created_fee.precise_amount_cents).to eq(0.0) + expect(created_fee.taxes_precise_amount_cents).to eq(0.0) + expect(created_fee.amount_currency).to eq("EUR") + expect(created_fee.units).to eq(0) + expect(created_fee.total_aggregated_units).to eq(0) + expect(created_fee.events_count).to eq(0) + expect(created_fee.payment_status).to eq("pending") + + expect(cached_aggregation.id).not_to be_nil + expect(cached_aggregation.organization).to eq(organization) + expect(cached_aggregation.external_subscription_id).to eq(subscription.external_id) + expect(cached_aggregation.charge_filter_id).to be_nil + expect(cached_aggregation.charge_id).to eq(charge.id) + expect(cached_aggregation.timestamp).to eq(boundaries.from_datetime) + expect(cached_aggregation.current_aggregation).to eq(0.0) + end + end + + context "with aggregation error" do + let(:billable_metric) do + create( + :billable_metric, + aggregation_type: "max_agg", + field_name: "foo_bar" + ) + end + let(:aggregator_service) { instance_double(BillableMetrics::Aggregations::MaxService) } + let(:error_result) do + BaseService::Result.new.service_failure!(code: "aggregation_failure", message: "Test message") + end + + it "returns an error" do + allow(BillableMetrics::Aggregations::MaxService).to receive(:new) + .and_return(aggregator_service) + allow(aggregator_service).to receive(:aggregate) + .and_return(error_result) + + result = charge_subscription_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("aggregation_failure") + expect(result.error.error_message).to eq("Test message") + + expect(BillableMetrics::Aggregations::MaxService).to have_received(:new) + expect(aggregator_service).to have_received(:aggregate) + end + end + + context "when current usage" do + let(:context) { :current_usage } + + context "when charge uses presentation_group_keys" do + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "0", + presentation_group_keys: [{value: "region"}] + } + ) + end + + let(:region) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa apac]) + end + + let(:europe_filter) do + create( + :charge_filter, + charge:, + properties: { + amount: "0", + presentation_group_keys: [{value: "region"}] + } + ) + end + + let(:usa_filter) do + create( + :charge_filter, + charge:, + properties: { + amount: "0", + presentation_group_keys: [{value: "region"}] + } + ) + end + + before do + create(:charge_filter_value, charge_filter: europe_filter, billable_metric_filter: region, values: ["europe"]) + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe", value: 10} + ) + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "usa", value: 5} + ) + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "apac", value: 3} + ) + end + + it "builds presentation_breakdowns on each non-persisted fee" do + expect { charge_subscription_service.call }.not_to change(Fee, :count) + + result = charge_subscription_service.call + + europe_fee = result.fees.find { |f| f.charge_filter_id == europe_filter.id } + usa_fee = result.fees.find { |f| f.charge_filter_id == usa_filter.id } + catch_all_fee = result.fees.find { |f| f.charge_filter_id.nil? } + + expect(europe_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "europe"}]) + expect(usa_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "usa"}]) + expect(catch_all_fee.presentation_breakdowns.map(&:presentation_by)).to match_array([{"region" => "apac"}]) + + expect(europe_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([10.0]) + expect(usa_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([5.0]) + expect(catch_all_fee.presentation_breakdowns.map { |b| b.units.to_f }).to match_array([3.0]) + + expect(result.fees.flat_map(&:presentation_breakdowns)).to all(have_attributes(organization_id: organization.id)) + end + end + + context "with all types of aggregation" do + BillableMetric::AGGREGATION_TYPES.keys.each do |aggregation_type| + before do + billable_metric.update!( + aggregation_type:, + field_name: "foo_bar", + weighted_interval: "seconds", + custom_aggregator: "def aggregate(event, agg, aggregation_properties); agg; end" + ) + + charge.update!(min_amount_cents: 1000) + + allow(AdjustedFee).to receive(:where).and_call_original + end + + it "initializes fees" do + result = charge_subscription_service.call + + expect(result).to be_success + + usage_fee = result.fees.first + + expect(result.fees.count).to eq(1) + expect(usage_fee.id).to be_nil + expect(usage_fee.invoice_id).to eq(invoice.id) + expect(usage_fee.charge_id).to eq(charge.id) + expect(usage_fee.amount_cents).to eq(0) + expect(usage_fee.precise_amount_cents).to eq(0.0) + expect(usage_fee.taxes_precise_amount_cents).to eq(0.0) + expect(usage_fee.amount_currency).to eq("EUR") + expect(usage_fee.units).to eq(0) + end + + it "does not load adjusted fees" do + charge_subscription_service.call + + expect(AdjustedFee).not_to have_received(:where) + end + end + end + + context "with graduated charge model" do + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + charge_model: "graduated", + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + end + + before do + create_list( + :event, + 4, + organization: subscription.organization, + subscription:, + code: charge.billable_metric.code, + timestamp: Time.zone.parse("2022-03-16") + ) + end + + it "initialize a fee" do + result = charge_subscription_service.call + + expect(result).to be_success + + usage_fee = result.fees.first + + expect(usage_fee.id).to be_nil + expect(usage_fee.invoice_id).to eq(invoice.id) + expect(usage_fee.charge_id).to eq(charge.id) + expect(usage_fee.amount_cents).to eq(5) + expect(usage_fee.precise_amount_cents).to eq(5.0) + expect(usage_fee.taxes_precise_amount_cents).to eq(0.0) + expect(usage_fee.amount_currency).to eq("EUR") + expect(usage_fee.units.to_s).to eq("4.0") + end + end + + context "with aggregation error" do + let(:billable_metric) do + create( + :billable_metric, + aggregation_type: "max_agg", + field_name: "foo_bar" + ) + end + let(:aggregator_service) { instance_double(BillableMetrics::Aggregations::MaxService) } + let(:error_result) do + BaseService::Result.new.service_failure!(code: "aggregation_failure", message: "Test message") + end + + it "returns an error" do + allow(BillableMetrics::Aggregations::MaxService).to receive(:new) + .and_return(aggregator_service) + allow(aggregator_service).to receive(:aggregate) + .and_return(error_result) + + result = charge_subscription_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("aggregation_failure") + expect(result.error.error_message).to eq("Test message") + + expect(BillableMetrics::Aggregations::MaxService).to have_received(:new) + expect(aggregator_service).to have_received(:aggregate) + end + end + + context "with non_persistable_charge_cache_optimization flag" do + before do + organization.update!(feature_flags: ["non_persistable_charge_cache_optimization"]) + end + + context "when all fees are zero" do + it "returns a zero fee" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + end + + context "with graduated charge model" do + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "2", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "50"} + ] + } + ) + end + + it "returns zero fee with graduated amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "flat_unit_amount" => "0.0", + "per_unit_amount" => "0.0", + "units" => "0.0", + "per_unit_total_amount" => "0.0", + "total_with_flat_amount" => "0.0" + } + ] + ) + end + end + + context "with package charge model" do + let(:charge) do + create( + :package_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "100", free_units: 10, package_size: 10} + ) + end + + it "returns zero fee with package amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "free_units" => "0.0", + "paid_units" => "0.0", + "per_package_size" => 0, + "per_package_unit_amount" => "0.0" + ) + end + end + + context "with percentage charge model" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + let(:charge) do + create( + :percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: {rate: "0.05", fixed_amount: "2"} + ) + end + + it "returns zero fee with percentage amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "units" => "0.0", + "free_units" => "0.0", + "free_events" => 0, + "paid_units" => "0.0", + "rate" => "0.05", + "per_unit_total_amount" => "0.0", + "paid_events" => 0, + "fixed_fee_unit_amount" => "0.0", + "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + ) + end + end + + context "with volume charge model" do + let(:charge) do + create( + :volume_charge, + plan: subscription.plan, + billable_metric:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}, + {from_value: 101, to_value: nil, per_unit_amount: "1", flat_amount: "0"} + ] + } + ) + end + + it "returns zero fee with volume amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "flat_unit_amount" => "0.0", + "per_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0" + ) + end + end + + context "with graduated_percentage charge model" do + let(:charge) do + create( + :graduated_percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 10, rate: "1", flat_amount: "100"}, + {from_value: 11, to_value: nil, rate: "0.5", flat_amount: "50"} + ] + } + ) + end + + it "returns zero fee with graduated_percentage amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "graduated_percentage_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "flat_unit_amount" => "0.0", + "rate" => "1.0", + "units" => "0.0", + "per_unit_total_amount" => "0.0", + "total_with_flat_amount" => "0.0" + } + ] + ) + end + end + end + + context "when some fees are non-zero" do + before do + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16") + ) + end + + it "returns the fees" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first.units).to eq(1) + end + end + + context "when fee has zero units and amount but non-zero events_count" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + + before do + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {value: 0} + ) + end + + it "returns the fee because events_count is non-zero" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + events_count: 1 + ) + end + end + + context "when fee has an adjusted_fee" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties: { + charges_from_datetime: boundaries.charges_from_datetime, + charges_to_datetime: boundaries.charges_to_datetime + }, + fee_type: :charge, + adjusted_units: true, + adjusted_amount: false, + units: 3 + ) + end + + before do + adjusted_fee + invoice.draft! + end + + it "returns the fee because an adjusted_fee exists" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + end + end + + context "when flag is disabled" do + before do + organization.update!(feature_flags: []) + end + + it "returns the zero fees" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + end + + context "with graduated charge model" do + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "2", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "50"} + ] + } + ) + end + + it "returns zero fee with graduated amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "flat_unit_amount" => "0.0", + "per_unit_amount" => "0.0", + "units" => "0.0", + "per_unit_total_amount" => "0.0", + "total_with_flat_amount" => "0.0" + } + ] + ) + end + end + + context "with package charge model" do + let(:charge) do + create( + :package_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "100", free_units: 10, package_size: 10} + ) + end + + it "returns zero fee with package amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "free_units" => "0.0", + "paid_units" => "0.0", + "per_package_size" => 0, + "per_package_unit_amount" => "0.0" + ) + end + end + + context "with percentage charge model" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + let(:charge) do + create( + :percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: {rate: "0.05", fixed_amount: "2"} + ) + end + + it "returns zero fee with percentage amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "units" => "0.0", + "free_units" => "0.0", + "free_events" => 0, + "paid_units" => "0.0", + "rate" => "0.05", + "per_unit_total_amount" => "0.0", + "paid_events" => 0, + "fixed_fee_unit_amount" => "0.0", + "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + ) + end + end + + context "with volume charge model" do + let(:charge) do + create( + :volume_charge, + plan: subscription.plan, + billable_metric:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}, + {from_value: 101, to_value: nil, per_unit_amount: "1", flat_amount: "0"} + ] + } + ) + end + + it "returns zero fee with volume amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "flat_unit_amount" => "0.0", + "per_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0" + ) + end + end + + context "with graduated_percentage charge model" do + let(:charge) do + create( + :graduated_percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 10, rate: "1", flat_amount: "100"}, + {from_value: 11, to_value: nil, rate: "0.5", flat_amount: "50"} + ] + } + ) + end + + it "returns zero fee with graduated_percentage amount_details" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 0, + amount_cents: 0, + precise_amount_cents: 0.0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + grouped_by: {}, + charge_filter_id: nil, + pay_in_advance: false, + amount_currency: "EUR" + ) + expect(result.fees.first.amount_details).to eq( + "graduated_percentage_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "flat_unit_amount" => "0.0", + "rate" => "1.0", + "units" => "0.0", + "per_unit_total_amount" => "0.0", + "total_with_flat_amount" => "0.0" + } + ] + ) + end + end + end + + context "with charge filters" do + let(:region) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) } + let(:europe_filter) { create(:charge_filter, charge:, properties: {amount: "20"}) } + let(:usa_filter) { create(:charge_filter, charge:, properties: {amount: "50"}) } + + before do + create(:charge_filter_value, charge_filter: europe_filter, billable_metric_filter: region, values: ["europe"]) + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + context "when all filters have zero usage" do + it "returns zero fees for each filter and catch-all" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(3) + expect(result.fees).to all(have_attributes(units: 0, amount_cents: 0, events_count: 0)) + expect(result.fees.map(&:charge_filter_id)).to match_array([europe_filter.id, usa_filter.id, nil]) + end + end + + context "when one filter has non-zero usage" do + before do + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe"} + ) + end + + it "returns non-zero and zero fees for all filters" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(3) + + non_zero_fees = result.fees.select { |f| f.units != 0 || f.events_count != 0 } + expect(non_zero_fees.count).to eq(1) + expect(non_zero_fees.first.charge_filter).to eq(europe_filter) + + zero_fees = result.fees.select { |f| f.units == 0 && f.events_count == 0 } + expect(zero_fees.count).to eq(2) + expect(zero_fees.map(&:charge_filter_id)).to match_array([usa_filter.id, nil]) + end + end + + context "when flag is disabled" do + before do + organization.update!(feature_flags: []) + end + + it "returns zero fees for all filters without reconstruction" do + result = charge_subscription_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(3) + expect(result.fees).to all(have_attributes(units: 0, amount_cents: 0, events_count: 0)) + expect(result.fees.map(&:charge_filter_id)).to match_array([europe_filter.id, usa_filter.id, nil]) + end + end + end + + context "with cache middleware enabled", cache: :memory do + subject(:charge_subscription_service) do + described_class.new( + invoice:, + charge:, + subscription:, + boundaries:, + context: :current_usage, + apply_taxes: false, + filtered_aggregations: nil, + cache_middleware: + ) + end + + let(:cache_middleware) do + Subscriptions::ChargeCacheMiddleware.new( + subscription:, + charge:, + to_datetime: boundaries.charges_to_datetime, + cache: true + ) + end + + let(:cache_key) do + Subscriptions::ChargeCacheService.new( + subscription:, charge:, charge_filter: nil + ).cache_key + end + + around { |test| travel_to(Time.zone.parse("2022-03-16")) { test.run } } + + before { Rails.cache.clear } + + context "when all fees are zero" do + it "caches an empty array" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + end + + it "returns zero fee on subsequent calls from cache" do + first_result = charge_subscription_service.call + second_result = charge_subscription_service.call + + expect(first_result).to be_success + expect(first_result.fees.count).to eq(1) + expect(first_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + end + + context "with graduated charge model" do + let(:charge) do + create( + :graduated_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "2", flat_amount: "100"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "50"} + ] + } + ) + end + + it "caches empty array and returns zero fee with correct amount_details on subsequent call" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + + second_result = charge_subscription_service.call + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + expect(second_result.fees.first.amount_details).to eq( + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "flat_unit_amount" => "0.0", + "per_unit_amount" => "0.0", + "units" => "0.0", + "per_unit_total_amount" => "0.0", + "total_with_flat_amount" => "0.0" + } + ] + ) + end + end + + context "with package charge model" do + let(:charge) do + create( + :package_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "100", free_units: 10, package_size: 10} + ) + end + + it "caches empty array and returns zero fee with correct amount_details on subsequent call" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + + second_result = charge_subscription_service.call + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + expect(second_result.fees.first.amount_details).to eq( + "free_units" => "0.0", + "paid_units" => "0.0", + "per_package_size" => 0, + "per_package_unit_amount" => "0.0" + ) + end + end + + context "with percentage charge model" do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + let(:charge) do + create( + :percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: {rate: "0.05", fixed_amount: "2"} + ) + end + + it "caches empty array and returns zero fee with correct amount_details on subsequent call" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + + second_result = charge_subscription_service.call + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + expect(second_result.fees.first.amount_details).to eq( + "units" => "0.0", + "free_units" => "0.0", + "free_events" => 0, + "paid_units" => "0.0", + "rate" => "0.05", + "per_unit_total_amount" => "0.0", + "paid_events" => 0, + "fixed_fee_unit_amount" => "0.0", + "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + ) + end + end + + context "with percentage charge model and per transaction min/max", :premium do + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") } + let(:charge) do + create( + :percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: {rate: "1", fixed_amount: "1", per_transaction_max_amount: "12", per_transaction_min_amount: "1.75"} + ) + end + + it "caches empty array and returns zero fee without raising on subsequent call" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + + second_result = charge_subscription_service.call + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + expect(second_result.fees.first.amount_details).to eq( + "units" => "0.0", + "free_units" => "0.0", + "free_events" => 0, + "paid_units" => "0.0", + "rate" => "1.0", + "per_unit_total_amount" => "0.0", + "paid_events" => 0, + "fixed_fee_unit_amount" => "0.0", + "fixed_fee_total_amount" => "0.0", + "min_max_adjustment_total_amount" => "0.0" + ) + end + end + + context "with volume charge model" do + let(:charge) do + create( + :volume_charge, + plan: subscription.plan, + billable_metric:, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}, + {from_value: 101, to_value: nil, per_unit_amount: "1", flat_amount: "0"} + ] + } + ) + end + + it "caches empty array and returns zero fee with correct amount_details on subsequent call" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + + second_result = charge_subscription_service.call + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + expect(second_result.fees.first.amount_details).to eq( + "flat_unit_amount" => "0.0", + "per_unit_amount" => "0.0", + "per_unit_total_amount" => "0.0" + ) + end + end + + context "with graduated_percentage charge model" do + let(:charge) do + create( + :graduated_percentage_charge, + plan: subscription.plan, + billable_metric:, + properties: { + graduated_percentage_ranges: [ + {from_value: 0, to_value: 10, rate: "1", flat_amount: "100"}, + {from_value: 11, to_value: nil, rate: "0.5", flat_amount: "50"} + ] + } + ) + end + + it "caches empty array and returns zero fee with correct amount_details on subsequent call" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + + second_result = charge_subscription_service.call + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + expect(second_result.fees.first.amount_details).to eq( + "graduated_percentage_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "flat_unit_amount" => "0.0", + "rate" => "1.0", + "units" => "0.0", + "per_unit_total_amount" => "0.0", + "total_with_flat_amount" => "0.0" + } + ] + ) + end + end + + context "with pricing_group_keys keys" do + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "100", pricing_group_keys: ["region"]} + ) + end + + it "caches empty array and returns zero fee with correct grouped_by on subsequent call" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + expect(cached_value).to eq("[]") + + second_result = charge_subscription_service.call + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, amount_cents: 0, events_count: 0) + expect(second_result.fees.first.grouped_by).to eq("region" => nil) + end + end + end + + context "when fees are non-zero" do + before do + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16") + ) + end + + it "caches the fee data" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + parsed = JSON.parse(cached_value) + expect(parsed.length).to eq(1) + expect(parsed.first["events_count"]).to eq(1) + end + + context "when charge uses presentation_group_keys" do + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "20", + presentation_group_keys: [{value: "region"}] + } + ) + end + + before do + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "eu", value: 10} + ) + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "us", value: 5} + ) + end + + it "keeps presentation_breakdowns on subsequent calls from cache" do + first_result = charge_subscription_service.call + cached_value = Rails.cache.read(cache_key) + second_result = charge_subscription_service.call + + expect(first_result).to be_success + expect(first_result.fees.count).to eq(1) + expect(first_result.fees.first.presentation_breakdowns.map(&:presentation_by)).to match_array( + [{"region" => "eu"}, {"region" => "us"}] + ) + + expect(JSON.parse(cached_value).first["presentation_breakdowns"]).to match_array( + [ + hash_including({"presentation_by" => {"region" => "eu"}, "units" => "10.0", "organization_id" => organization.id}), + hash_including({"presentation_by" => {"region" => "us"}, "units" => "5.0", "organization_id" => organization.id}) + ] + ) + + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first.presentation_breakdowns.map(&:presentation_by)).to match_array( + [{"region" => "eu"}, {"region" => "us"}] + ) + end + end + + it "returns fees from cache on subsequent calls" do + first_result = charge_subscription_service.call + second_result = charge_subscription_service.call + + expect(second_result).to be_success + expect(second_result.fees.count).to eq(first_result.fees.count) + expect(second_result.fees.first.units).to eq(first_result.fees.first.units) + expect(second_result.fees.first.events_count).to eq(first_result.fees.first.events_count) + end + end + + context "when flag is disabled" do + before do + organization.update!(feature_flags: []) + end + + it "caches the zero fee data" do + charge_subscription_service.call + + cached_value = Rails.cache.read(cache_key) + parsed = JSON.parse(cached_value) + expect(parsed.length).to eq(1) + expect(parsed.first["events_count"]).to eq(0) + expect(parsed.first["units"].to_f).to eq(0.0) + end + + it "returns zero fees from cache on subsequent calls" do + charge_subscription_service.call + second_result = charge_subscription_service.call + + expect(second_result).to be_success + expect(second_result.fees.count).to eq(1) + expect(second_result.fees.first).to have_attributes(units: 0, events_count: 0) + end + end + + context "with charge filters" do + let(:region) { create(:billable_metric_filter, billable_metric:, key: "region", values: %w[europe usa]) } + let(:europe_filter) { create(:charge_filter, charge:, properties: {amount: "20"}) } + let(:usa_filter) { create(:charge_filter, charge:, properties: {amount: "50"}) } + + before do + create(:charge_filter_value, charge_filter: europe_filter, billable_metric_filter: region, values: ["europe"]) + create(:charge_filter_value, charge_filter: usa_filter, billable_metric_filter: region, values: ["usa"]) + end + + context "when one filter has events and another does not" do + before do + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "europe"} + ) + end + + it "caches empty array for zero-usage filter and fee data for non-zero filter" do + charge_subscription_service.call + + europe_cache_key = Subscriptions::ChargeCacheService.new( + subscription:, charge:, charge_filter: europe_filter + ).cache_key + europe_cached = JSON.parse(Rails.cache.read(europe_cache_key)) + expect(europe_cached.length).to eq(1) + expect(europe_cached.first["events_count"]).to eq(1) + + usa_cache_key = Subscriptions::ChargeCacheService.new( + subscription:, charge:, charge_filter: usa_filter + ).cache_key + expect(Rails.cache.read(usa_cache_key)).to eq("[]") + end + + it "returns consistent results on subsequent calls from cache" do + first_result = charge_subscription_service.call + second_result = charge_subscription_service.call + + expect(second_result.fees.count).to eq(first_result.fees.count) + first_result.fees.zip(second_result.fees).each do |first_fee, second_fee| + expect(second_fee.units).to eq(first_fee.units) + expect(second_fee.events_count).to eq(first_fee.events_count) + expect(second_fee.amount_cents).to eq(first_fee.amount_cents) + end + end + end + + context "when flag is disabled" do + before do + organization.update!(feature_flags: []) + end + + it "caches zero fee data for all filters" do + charge_subscription_service.call + + [europe_filter, usa_filter].each do |filter| + filter_cache_key = Subscriptions::ChargeCacheService.new( + subscription:, charge:, charge_filter: filter + ).cache_key + cached = JSON.parse(Rails.cache.read(filter_cache_key)) + expect(cached.length).to eq(1) + expect(cached.first["events_count"]).to eq(0) + expect(cached.first["units"].to_f).to eq(0.0) + end + end + + it "returns all zero filter fees from cache on subsequent calls" do + charge_subscription_service.call + second_result = charge_subscription_service.call + + expect(second_result).to be_success + expect(second_result.fees.count).to eq(3) + expect(second_result.fees).to all(have_attributes(units: 0, amount_cents: 0, events_count: 0)) + expect(second_result.fees.map(&:charge_filter_id)).to match_array([europe_filter.id, usa_filter.id, nil]) + end + end + end + end + end + end + + context "when apply taxes" do + let(:apply_taxes) { true } + + before do + create(:tax, :applied_to_billing_entity, organization:, rate: 20) + + create( + :event, + organization: invoice.organization, + subscription:, + code: billable_metric.code, + timestamp: boundaries.charges_to_datetime - 2.days + ) + end + + it "creates a fee with applied taxes" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.first).to have_attributes( + id: String, + invoice_id: invoice.id, + charge_id: charge.id, + amount_cents: 2000, + precise_amount_cents: 2000.0, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 2000, + precise_unit_amount: 20.0, + events_count: 1, + payment_status: "pending", + + taxes_rate: 20.0, + taxes_amount_cents: 400, + taxes_precise_amount_cents: 400.0 + ) + expect(result.fees.first.applied_taxes.count).to eq(1) + end + end + + context "with filtered_aggregations" do + let(:filtered_aggregations) { [] } + + let(:region_filter) do + create(:billable_metric_filter, billable_metric:, key: "region", values: %w[eu us asia]) + end + + let(:eu_charge_filter) { create(:charge_filter, charge:, properties: {amount: "20"}) } + let(:us_charge_filter) { create(:charge_filter, charge:, properties: {amount: "30"}) } + let(:asia_charge_filter) { create(:charge_filter, charge:, properties: {amount: "40"}) } + + before do + create(:charge_filter_value, charge_filter: eu_charge_filter, billable_metric_filter: region_filter, values: ["eu"]) + create(:charge_filter_value, charge_filter: us_charge_filter, billable_metric_filter: region_filter, values: ["us"]) + create(:charge_filter_value, charge_filter: asia_charge_filter, billable_metric_filter: region_filter, values: ["asia"]) + + # Events for each region + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "eu"} + ) + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "us"} + ) + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {region: "asia"} + ) + # Event without filter + create( + :event, + organization:, + subscription:, + code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), + properties: {} + ) + end + + context "when filtered_aggregations includes only specific filter IDs" do + let(:filtered_aggregations) { [eu_charge_filter.id, us_charge_filter.id] } + + it "only aggregates events for the specified filters" do + result = charge_subscription_service.call + expect(result).to be_success + + eu_fee = result.fees.find { |f| f.charge_filter_id == eu_charge_filter.id } + us_fee = result.fees.find { |f| f.charge_filter_id == us_charge_filter.id } + asia_fee = result.fees.find { |f| f.charge_filter_id == asia_charge_filter.id } + default_fee = result.fees.find { |f| f.charge_filter_id.nil? } + + expect(eu_fee).to have_attributes(units: 1, amount_cents: 2_000) + expect(us_fee).to have_attributes(units: 1, amount_cents: 3_000) + # Zero-amount fees are filtered out by default + expect(asia_fee).to be_nil + expect(default_fee).to be_nil + end + end + + context "when filtered_aggregations is an empty array" do + let(:filtered_aggregations) { [] } + + it "bypasses aggregation for all filters and returns no fees" do + result = charge_subscription_service.call + expect(result).to be_success + # All fees have zero amounts, so none are persisted + expect(result.fees).to be_empty + end + end + + context "when filtered_aggregations includes nil for default bucket" do + let(:filtered_aggregations) { [nil] } + + it "only aggregates events for the default bucket" do + result = charge_subscription_service.call + expect(result).to be_success + + default_fee = result.fees.find { |f| f.charge_filter_id.nil? } + eu_fee = result.fees.find { |f| f.charge_filter_id == eu_charge_filter.id } + + expect(default_fee).to have_attributes(units: 1, amount_cents: 2_000) + # Zero-amount fees are filtered out by default + expect(eu_fee).to be_nil + end + end + + context "when filtered_aggregations is nil (default behavior)" do + let(:filtered_aggregations) { nil } + + it "aggregates events for all filters" do + result = charge_subscription_service.call + expect(result).to be_success + + eu_fee = result.fees.find { |f| f.charge_filter_id == eu_charge_filter.id } + us_fee = result.fees.find { |f| f.charge_filter_id == us_charge_filter.id } + asia_fee = result.fees.find { |f| f.charge_filter_id == asia_charge_filter.id } + default_fee = result.fees.find { |f| f.charge_filter_id.nil? } + + expect(eu_fee.units).to eq(1) + expect(us_fee.units).to eq(1) + expect(asia_fee.units).to eq(1) + expect(default_fee.units).to eq(1) + end + end + + context "with recurring billable metric" do + let(:billable_metric) { create(:weighted_sum_billable_metric, :recurring, organization:) } + let(:filtered_aggregations) { [] } + + before do + # Create events with proper value field for weighted_sum + create(:event, organization:, subscription:, code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), properties: {region: "eu", value: 10}) + end + + it "always aggregates regardless of filtered_aggregations" do + result = charge_subscription_service.call + expect(result).to be_success + + # Recurring metrics ignore the filtered_aggregations parameter, so fees should have data + aggregated_fees = result.fees.select { |f| f.units != 0 || f.events_count != 0 } + expect(aggregated_fees).not_to be_empty + end + end + end + + context "with filter_by_group" do + subject(:charge_subscription_service) do + described_class.new( + invoice:, + charge:, + subscription:, + boundaries:, + context: :current_usage, + apply_taxes: false, + filtered_aggregations: nil, + usage_filters: UsageFilters.new(filter_by_group:) + ) + end + + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "20", pricing_group_keys: %w[region cloud]} + ) + end + + let(:filter_by_group) { {"region" => ["eu"]} } + + before do + create(:event, organization:, subscription:, code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), properties: {region: "eu", cloud: "aws", value: 10}) + create(:event, organization:, subscription:, code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), properties: {region: "eu", cloud: "gcp", value: 5}) + create(:event, organization:, subscription:, code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), properties: {region: "us", cloud: "aws", value: 7}) + end + + it "filters by the specified group and keeps remaining group keys" do + result = charge_subscription_service.call + expect(result).to be_success + + # Only eu events, grouped by cloud (region removed from grouped_by) + expect(result.fees.count).to eq(2) + + aws_fee = result.fees.find { |f| f.grouped_by["cloud"] == "aws" } + expect(aws_fee).to have_attributes(units: 10) + + gcp_fee = result.fees.find { |f| f.grouped_by["cloud"] == "gcp" } + expect(gcp_fee).to have_attributes(units: 5) + end + + context "when filter_by_group is sent without array for values" do + let(:filter_by_group) { {"region" => "eu"} } + + it "handles string values by converting them to array" do + result = charge_subscription_service.call + expect(result).to be_success + + # Only eu events, grouped by cloud (region removed from grouped_by) + expect(result.fees.count).to eq(2) + + aws_fee = result.fees.find { |f| f.grouped_by["cloud"] == "aws" } + expect(aws_fee).to have_attributes(units: 10) + + gcp_fee = result.fees.find { |f| f.grouped_by["cloud"] == "gcp" } + expect(gcp_fee).to have_attributes(units: 5) + end + end + end + + context "with filter_by_presentation" do + subject(:charge_subscription_service) do + described_class.new( + invoice:, + charge:, + subscription:, + boundaries:, + context: :current_usage, + apply_taxes: false, + filtered_aggregations: nil, + usage_filters: UsageFilters.new(filter_by_presentation: filter_by_presentation) + ) + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: properties + ) + end + let(:properties) do + { + amount: "10", + presentation_group_keys: presentation_group_keys + } + end + let(:presentation_group_keys) { [{value: "department"}, {value: "region"}] } + let(:filter_by_presentation) { nil } + let(:aggregator) { instance_double("Aggregator") } + let(:aggregation_result) { BaseService::Result.new } + + before do + allow(BillableMetrics::AggregationFactory).to receive(:new_instance).and_call_original + end + + it "calls aggregation factory with presentation_by containing all charge presentation keys" do + charge_subscription_service.call + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + hash_including( + filters: hash_including( + presentation_by: ["department", "region"] + ) + ) + ) + end + + context "when presentation_group_keys is empty" do + let(:presentation_group_keys) { [] } + + it "calls aggregation factory without presentation_by" do + charge_subscription_service.call + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + hash_including( + filters: hash_including( + charge_id: charge.id + ) + ) + ) + end + end + + context "when filter_by_presentation is empty" do + let(:filter_by_presentation) { [] } + + it "calls aggregation factory with presentation_by as empty array" do + charge_subscription_service.call + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + hash_including( + filters: hash_including( + presentation_by: [] + ) + ) + ) + end + + context "when presentation_group_keys is empty" do + let(:presentation_group_keys) { [] } + + it "calls aggregation factory without presentation_by" do + charge_subscription_service.call + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + hash_including( + filters: hash_including( + charge_id: charge.id + ) + ) + ) + end + end + end + + context "when filter_by_presentation values overlaps charge presentation keys" do + let(:filter_by_presentation) { ["region"] } + + it "calls aggregation factory with presentation_by containing only overlapping keys" do + charge_subscription_service.call + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + hash_including( + filters: hash_including( + presentation_by: ["region"] + ) + ) + ) + end + + context "when presentation_group_keys is empty" do + let(:presentation_group_keys) { [] } + + it "calls aggregation factory without presentation_by" do + charge_subscription_service.call + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + hash_including( + filters: hash_including( + charge_id: charge.id + ) + ) + ) + end + end + end + + context "when filter_by_presentation values are not present in presention keys" do + let(:filter_by_presentation) { ["other_name"] } + + it "calls aggregation factory with empty presentation keys" do + charge_subscription_service.call + + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + hash_including( + filters: hash_including( + presentation_by: [] + ) + ) + ) + end + end + end + + context "with skip_grouping" do + subject(:charge_subscription_service) do + described_class.new( + invoice:, + charge:, + subscription:, + boundaries:, + context: :current_usage, + apply_taxes: false, + filtered_aggregations: nil, + usage_filters: UsageFilters.new(skip_grouping: true) + ) + end + + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "sum_agg", field_name: "value") + end + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: {amount: "20", pricing_group_keys: %w[cloud]} + ) + end + + before do + create(:event, organization:, subscription:, code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), properties: {cloud: "aws", value: 10}) + create(:event, organization:, subscription:, code: billable_metric.code, + timestamp: Time.zone.parse("2022-03-16"), properties: {cloud: "gcp", value: 5}) + end + + it "returns a single fee with all events aggregated without grouping" do + result = charge_subscription_service.call + expect(result).to be_success + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + units: 15, + grouped_by: {} + ) + end + end + end +end diff --git a/spec/services/fees/commitments/minimum/create_service_spec.rb b/spec/services/fees/commitments/minimum/create_service_spec.rb new file mode 100644 index 0000000..5a54041 --- /dev/null +++ b/spec/services/fees/commitments/minimum/create_service_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::Commitments::Minimum::CreateService do + subject(:service_call) { described_class.call(invoice_subscription:) } + + let(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + from_datetime:, + to_datetime:, + timestamp: + ) + end + let(:subscription) { create(:subscription, customer:, plan:, started_at: DateTime.parse("2024-01-01T00:00:00")) } + let(:from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:to_datetime) { DateTime.parse("2024-12-31T23:59:59") } + let(:timestamp) { DateTime.parse("2025-01-01T10:00:00") } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, interval: :yearly, pay_in_advance: false) } + let(:organization) { create(:organization) } + + context "when plan has no minimum commitment" do + it "does not create a commitment fee" do + expect { service_call }.not_to change(Fee.commitment, :count) + end + end + + context "when plan has a minimum commitment" do + before { create(:commitment, :minimum_commitment, plan:) } + + context "when invoice already has a minimum commitment fee for the subscription" do + before { create(:minimum_commitment_fee, invoice:, subscription:) } + + it "does not create a commitment fee" do + expect { service_call }.not_to change(Fee.commitment, :count) + end + end + + context "when invoice already has a minimum commitment fee for different subscription" do + before { create(:minimum_commitment_fee, invoice:) } + + it "creates a commitment fee" do + expect { service_call }.to change(Fee.commitment, :count).by(1) + end + end + + # Default behavior: pay in arrears (no explicit context needed) + describe "commitment fee creation" do + it "creates a commitment fee" do + expect { service_call }.to change(Fee.commitment, :count).by(1) + end + + it "creates a fee with correct attributes" do + result = service_call + expect(result).to be_success + + fee = result.fee + expect(fee).to have_attributes( + id: String, + organization_id: organization.id, + billing_entity_id: customer.billing_entity_id, + fee_type: "commitment", + taxes_amount_cents: 0, + precise_amount_cents: 1000.0 + ) + end + + it "stores the current billing period boundaries in properties" do + result = service_call + expect(result).to be_success + + fee = result.fee + # For pay in arrears, commitment reconciles the CURRENT period + # Boundaries should match the invoice_subscription's from/to dates + expect(Time.zone.parse(fee.properties["from_datetime"].to_s).to_date).to eq(from_datetime.to_date) + expect(Time.zone.parse(fee.properties["to_datetime"].to_s).to_date).to eq(to_datetime.to_date) + end + end + + context "when commitment has taxes" do + let(:commitment_tax) { create(:tax, rate: 20) } + + before do + create(:commitment_applied_tax, commitment: plan.minimum_commitment, tax: commitment_tax) + end + + it "creates a commitment fee with zero taxes" do + result = service_call + expect(result).to be_success + + fee = result.fee + expect(fee).to have_attributes( + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0, + taxes_rate: 0.0 + ) + end + end + + context "when plan is pay in advance" do + let(:plan) { create(:plan, organization:, interval: :yearly, pay_in_advance: true) } + + context "when it is the first billing period (no previous invoice subscription)" do + it "does not create a commitment fee" do + # On the first invoice of a pay in advance plan, there's no previous period to reconcile + expect { service_call }.not_to change(Fee.commitment, :count) + end + + it "returns a successful result without a fee" do + result = service_call + expect(result).to be_success + expect(result.fee).to be_nil + end + end + + context "when there is a previous billing period to reconcile" do + let(:previous_from_datetime) { DateTime.parse("2024-01-01T00:00:00") } + let(:previous_to_datetime) { DateTime.parse("2024-12-31T23:59:59") } + let(:previous_invoice) { create(:invoice, customer:, organization:) } + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice: previous_invoice, + from_datetime: previous_from_datetime, + to_datetime: previous_to_datetime, + timestamp: DateTime.parse("2024-01-01T10:00:00") + ) + end + let(:true_up_result) do + BaseService::Result.new.tap do |r| + r.amount_cents = 500 + r.precise_amount_cents = 500.0 + end + end + + before do + previous_invoice_subscription + # Create a subscription fee for the previous invoice so previous_invoice_subscription is found + create(:fee, fee_type: :subscription, subscription:, invoice: previous_invoice) + end + + it "creates a commitment fee with the PREVIOUS billing period boundaries in properties" do + result = service_call + expect(result).to be_success + + fee = result.fee + # For pay in advance, commitment reconciles the PREVIOUS period + # Boundaries should match the previous_invoice_subscription's from/to dates + expect(Time.zone.parse(fee.properties["from_datetime"].to_s).to_date).to eq(previous_from_datetime.to_date) + expect(Time.zone.parse(fee.properties["to_datetime"].to_s).to_date).to eq(previous_to_datetime.to_date) + end + end + end + end +end diff --git a/spec/services/fees/create_pay_in_advance_service_spec.rb b/spec/services/fees/create_pay_in_advance_service_spec.rb new file mode 100644 index 0000000..edbcfab --- /dev/null +++ b/spec/services/fees/create_pay_in_advance_service_spec.rb @@ -0,0 +1,614 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::CreatePayInAdvanceService do + subject(:fee_service) { described_class.new(charge:, event:, billing_at: event.timestamp, estimate:) } + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:estimate) { false } + + let(:charge_filter) { nil } + + let(:charge) { create(:standard_charge, :pay_in_advance, billable_metric:, plan:) } + + let(:event) do + source = create( + :event, + external_subscription_id: subscription.external_id, + external_customer_id: customer.external_id, + organization_id: organization.id, + properties: event_properties + ) + Events::CommonFactory.new_instance(source:) + end + + let(:event_properties) { {} } + + before { tax } + + describe "#call" do + let(:aggregation_result) do + BaseService::Result.new.tap do |result| + result.aggregation = 9 + result.count = 4 + result.options = {} + end + end + + let(:charge_result) do + BaseService::Result.new.tap do |result| + result.amount = 10 + result.precise_amount = 10.0 + result.unit_amount = 0.01111111111 + result.count = 1 + result.units = 9 + end + end + + before do + allow(Charges::PayInAdvanceAggregationService).to receive(:call) + .with(charge:, boundaries: BillingPeriodBoundaries, properties: Hash, event:, charge_filter:) + .and_return(aggregation_result) + + allow(Charges::ApplyPayInAdvanceChargeModelService).to receive(:call) + .with(charge:, aggregation_result:, properties: Hash) + .and_return(charge_result) + end + + it "creates a fee" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + subscription:, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + charge_filter: nil, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + payment_status: "pending", + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111, + + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0 + ) + expect(result.fees.first.applied_taxes.count).to eq(0) + end + + it "does not create pricing unit usage" do + expect { fee_service.call }.not_to change(PricingUnitUsage, :count) + end + + it "delivers a webhook" do + fee_service.call + + expect(SendWebhookJob).to have_been_enqueued + .with("fee.created", Fee) + end + + context "when aggregation fails" do + let(:aggregation_result) do + BaseService::Result.new.service_failure!(code: "failure", message: "Failure") + end + + it "returns a failure" do + result = fee_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("failure") + expect(result.error.error_message).to eq("Failure") + end + end + + context "when charge model fails" do + let(:charge_result) do + BaseService::Result.new.service_failure!(code: "failure", message: "Failure") + end + + it "returns a failure" do + result = fee_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("failure") + expect(result.error.error_message).to eq("Failure") + end + end + + context "when charge has a charge filter" do + let(:event_properties) do + { + payment_method: "card", + card_location: "domestic", + scheme: "visa", + card_type: "credit" + } + end + + let(:card_location) do + create(:billable_metric_filter, billable_metric:, key: "card_location", values: %i[domestic]) + end + let(:scheme) { create(:billable_metric_filter, billable_metric:, key: "scheme", values: %i[visa mastercard]) } + + let(:filter) { create(:charge_filter, charge:) } + let(:filter_values) do + [ + create( + :charge_filter_value, + values: ["domestic"], + billable_metric_filter: card_location, + charge_filter: filter + ), + create( + :charge_filter_value, + values: %w[visa mastercard], + billable_metric_filter: scheme, + charge_filter: filter + ) + ] + end + + let(:charge_filter) { filter } + + before { filter_values } + + it "creates a fee" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + subscription:, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + charge_filter:, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111, + + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0 + ) + expect(result.fees.first.applied_taxes.count).to eq(0) + end + + context "when charge filter has pricing_group_keys defined" do + let(:charge_filter) { create(:charge_filter, charge:, properties: {:amount => "1", "pricing_group_keys" => ["group_key"]}) } + let(:event_properties) do + { + payment_method: "card", + card_location: "international", + scheme: "visa", + card_type: "credit", + group_key: "group_value" + } + end + + it "creates a fee" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + subscription:, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + charge_filter:, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111, + grouped_by: {"group_key" => "group_value"}, + + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0 + ) + expect(result.fees.first.applied_taxes.count).to eq(0) + end + end + + context "when charge filter has a grouped_by defined" do + let(:charge_filter) { create(:charge_filter, charge:, properties: {:amount => "1", "grouped_by" => ["group_key"]}) } + let(:event_properties) do + { + payment_method: "card", + card_location: "international", + scheme: "visa", + card_type: "credit", + group_key: "group_value" + } + end + + it "creates a fee" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + subscription:, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + charge_filter:, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111, + grouped_by: {"group_key" => "group_value"}, + + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0 + ) + expect(result.fees.first.applied_taxes.count).to eq(0) + end + end + + context "when event does not match the charge filter" do + let(:charge_filter) { ChargeFilter } + + let(:event_properties) do + { + payment_method: "card", + card_location: "international", + scheme: "visa", + card_type: "credit" + } + end + + it "creates a fee" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + subscription:, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + charge_filter_id: nil, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111, + + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0 + ) + expect(result.fees.first.applied_taxes.count).to eq(0) + end + end + end + + context "when charge has a grouped_by property" do + let(:charge) do + create( + :standard_charge, + billable_metric:, + pay_in_advance: true, + properties: {"grouped_by" => ["operator"], "amount" => "100"} + ) + end + + let(:event) do + Events::CommonFactory.new_instance( + source: create( + :event, + organization:, + external_subscription_id: subscription.external_id, + properties: {"operator" => "foo"} + ) + ) + end + + it "creates a fee" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + expect(result.fees.first).to have_attributes( + subscription:, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111, + grouped_by: {"operator" => "foo"}, + + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0 + ) + expect(result.fees.first.applied_taxes.count).to eq(0) + end + end + + context "when event is not persisted" do + let(:estimate) { true } + let(:event) do + Events::Common.new( + id: nil, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + organization_id: organization.id, + properties: event_properties, + timestamp: Time.current, + precise_total_amount_cents: nil, + persisted: false + ) + end + + it "does not persist the fee and defers taxes to the caller" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + expect(result.fees.first).not_to be_persisted + expect(result.fees.first).to have_attributes( + subscription:, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111, + + taxes_rate: 0, + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0 + ) + expect(result.fees.first.applied_taxes.size).to eq(0) + end + + it "does not deliver a webhook" do + fee_service.call + + expect(SendWebhookJob).not_to have_been_enqueued + .with("fee.created", Fee) + end + + context "when stimate is false" do + let(:estimate) { false } + + it "raises an argument error" do + expect { fee_service.call } + .to raise_error(ArgumentError, "estimate must be true if event if not persisted") + end + end + end + + context "with pricing unit on the charge" do + before do + create( + :applied_pricing_unit, + organization: subscription.organization, + conversion_rate: 0.5, + pricing_unitable: charge + ) + end + + it "creates a fee with converted values" do + result = fee_service.call + + expect(result).to be_success + + expect(result.fees.count).to eq(1) + fee = result.fees.first + expect(fee).to have_attributes( + subscription:, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + charge:, + amount_cents: 5, + amount_currency: "EUR", + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + events_count: 1, + charge_filter: nil, + pay_in_advance_event_id: event.id, + pay_in_advance_event_transaction_id: event.transaction_id, + payment_status: "pending", + unit_amount_cents: 0, + taxes_rate: 0, + taxes_amount_cents: 0 + ) + expect(fee.precise_amount_cents.to_f).to eq(5.0) + expect(fee.precise_unit_amount.to_f).to eq(0.005) + expect(fee.taxes_precise_amount_cents.to_f).to eq(0.0) + expect(result.fees.first.applied_taxes.count).to eq(0) + end + + it "creates pricing unit usage" do + result = fee_service.call + + expect(result).to be_success + pricing_unit_usage = result.fees.first.pricing_unit_usage + expect(pricing_unit_usage).to be_persisted + expect(pricing_unit_usage.amount_cents).to eq(10) + expect(pricing_unit_usage.precise_amount_cents.to_f).to eq(10.0) + expect(pricing_unit_usage.unit_amount_cents).to eq(1) + end + end + + context "when in current and max aggregation result" do + let(:aggregation_result) do + BaseService::Result.new.tap do |result| + result.amount = 10 + result.count = 1 + result.units = 9 + result.current_aggregation = 9 + result.max_aggregation = 9 + result.max_aggregation_with_proration = nil + end + end + + it "creates a cached aggregation" do + expect { fee_service.call }.to change(CachedAggregation, :count).by(1) + + cached_aggregation = CachedAggregation.last + expect(cached_aggregation.organization_id).to eq(organization.id) + expect(cached_aggregation.event_transaction_id).to eq(event.transaction_id) + expect(cached_aggregation.timestamp.iso8601(3)).to eq(event.timestamp.iso8601(3)) + expect(cached_aggregation.charge_id).to eq(charge.id) + expect(cached_aggregation.external_subscription_id).to eq(event.external_subscription_id) + expect(cached_aggregation.charge_filter_id).to be_nil + expect(cached_aggregation.current_aggregation).to eq(9) + expect(cached_aggregation.current_amount).to be_nil + expect(cached_aggregation.max_aggregation).to eq(9) + expect(cached_aggregation.max_aggregation_with_proration).to be_nil + expect(cached_aggregation.grouped_by).to eq({}) + end + end + + context "when charge is non-invoiceable" do + let(:charge) { create(:standard_charge, :pay_in_advance, billable_metric:, plan:, invoiceable: false) } + + it "applies local taxes eagerly" do + result = fee_service.call + + expect(result).to be_success + + fee = result.fees.first + expect(fee.applied_taxes.count).to eq(1) + expect(fee.taxes_rate).to eq(20.0) + expect(fee.taxes_amount_cents).to eq(2) + end + + context "when customer has tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + json = File.read(p) + + response_data = JSON.parse(json) + response_data["succeededInvoices"].first["fees"].first["item_id"] = fee_id + response_data["succeededInvoices"].first["fees"].first["tax_breakdown"].first["rate"] = "0.10" + response_data["succeededInvoices"].first["fees"].first["tax_breakdown"].first["tax_amount"] = 1 + + response_data.to_json + end + let(:fee_id) { "fee_placeholder" } + + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + allow_any_instance_of(Fee).to receive(:id).and_wrap_original do |m, *_args| # rubocop:disable RSpec/AnyInstance + fee_id + end + end + + it "applies provider taxes instead of local taxes" do + result = fee_service.call + + expect(result).to be_success + + fee = result.fees.first + # Provider returns 2 tax breakdown entries (tax_exempt + exempt) + expect(fee.applied_taxes.count).to eq(2) + expect(fee.applied_taxes.map(&:tax_name)).to include("GST/HST") + expect(fee.taxes_amount_cents).to eq(1) + end + end + end + end +end diff --git a/spec/services/fees/create_true_up_service_spec.rb b/spec/services/fees/create_true_up_service_spec.rb new file mode 100644 index 0000000..7ea4b3b --- /dev/null +++ b/spec/services/fees/create_true_up_service_spec.rb @@ -0,0 +1,188 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::CreateTrueUpService do + let(:create_service) { described_class.new(fee:, used_amount_cents:, used_precise_amount_cents:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, organization:, rate: 20) } + let(:plan) { create(:plan, organization:) } + + let(:charge) { create(:standard_charge, plan:, min_amount_cents: 1000) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:fee) do + create( + :charge_fee, + amount_cents: used_amount_cents, + precise_amount_cents: used_amount_cents, + customer:, + charge:, + properties: { + "from_datetime" => DateTime.parse("2023-08-01 00:00:00"), + "to_datetime" => DateTime.parse("2023-08-31 23:59:59"), + "charges_from_datetime" => DateTime.parse("2023-08-01 00:00:00"), + "charges_to_datetime" => DateTime.parse("2023-08-31 23:59:59"), + "charges_duration" => 31 + } + ) + end + let(:used_amount_cents) { 700 } + let(:used_precise_amount_cents) { 700.0 } + + before { tax } + + describe "#call" do + subject(:result) { create_service.call } + + context "when fee is nil" do + let(:fee) { nil } + + it "does not instantiate a true-up fee" do + expect(result).to be_success + expect(result.true_up_fee).to be_nil + end + end + + context "when min_amount_cents is lower than the fee amount_cents" do + let(:fee) { create(:charge_fee, amount_cents: 1500, precise_amount_cents: 1500.0) } + + it "does not instantiate a true-up fee" do + expect(result).to be_success + expect(result.true_up_fee).to be_nil + end + end + + it "instantiates a true-up fee" do + travel_to(DateTime.new(2023, 4, 1)) do + expect(result).to be_success + + expect(result.true_up_fee).to be_new_record.and have_attributes( + subscription: fee.subscription, + charge: fee.charge, + amount_currency: fee.currency, + fee_type: "charge", + invoiceable: fee.charge, + properties: fee.properties, + payment_status: "pending", + units: 1, + events_count: 0, + charge_filter: nil, + amount_cents: 300, + precise_amount_cents: 300.0, + taxes_amount_cents: 2, + taxes_precise_amount_cents: 2.0000000001, + unit_amount_cents: 300, + precise_unit_amount: 3, + true_up_parent_fee_id: fee.id, + pricing_unit_usage: nil + ) + end + end + + context "when fee's charge uses pricing units" do + before do + create( + :applied_pricing_unit, + organization:, + conversion_rate: 0.25, + pricing_unitable: charge + ) + end + + it "instantiates a true-up fee" do + travel_to(DateTime.new(2023, 4, 1)) do + expect(result).to be_success + + expect(result.true_up_fee).to be_new_record.and have_attributes( + subscription: fee.subscription, + charge: fee.charge, + amount_currency: fee.currency, + fee_type: "charge", + invoiceable: fee.charge, + properties: fee.properties, + payment_status: "pending", + units: 1, + events_count: 0, + charge_filter: nil, + amount_cents: 75, + precise_amount_cents: 75.0, + taxes_amount_cents: 2, + taxes_precise_amount_cents: 2.0000000001, + unit_amount_cents: 75, + precise_unit_amount: 0.75, + true_up_parent_fee_id: fee.id + ) + + expect(result.true_up_fee.pricing_unit_usage).to be_new_record.and have_attributes( + amount_cents: 300, + precise_amount_cents: 300.0, + unit_amount_cents: 300, + precise_unit_amount: 3.00 + ) + end + end + end + + context "when prorated" do + let(:used_amount_cents) { 200 } + let(:used_precise_amount_cents) { 200.0 } + + let(:fee) do + create( + :charge_fee, + amount_cents: used_amount_cents, + precise_amount_cents: used_amount_cents, + charge:, + properties: { + "from_datetime" => DateTime.parse("2022-08-01 00:00:00"), + "to_datetime" => DateTime.parse("2022-08-15 23:59:59"), + "charges_from_datetime" => DateTime.parse("2022-08-01 00:00:00"), + "charges_to_datetime" => DateTime.parse("2022-08-15 23:59:59"), + "charges_duration" => 31 + } + ) + end + + it "instantiates a prorated true-up fee" do + travel_to(DateTime.new(2023, 4, 1)) do + expect(result).to be_success + + expect(result.true_up_fee).to have_attributes( + amount_cents: 284, # (1000 / 31.0 * 15) - 200 + precise_amount_cents: 283.8709677419355 + ) + end + end + end + + context "with customer timezone" do + let(:customer) { create(:customer, organization:, timezone: "Pacific/Fiji") } + + it "instantiates a true-up fee" do + travel_to(DateTime.new(2023, 9, 1)) do + expect(result).to be_success + + expect(result.true_up_fee).to have_attributes( + subscription: fee.subscription, + charge: fee.charge, + amount_currency: fee.currency, + fee_type: "charge", + invoiceable: fee.charge, + properties: fee.properties, + payment_status: "pending", + units: 1, + events_count: 0, + charge_filter: nil, + amount_cents: 300, + precise_amount_cents: 300.0, + unit_amount_cents: 300, + precise_unit_amount: 3, + true_up_parent_fee_id: fee.id + ) + end + end + end + end +end diff --git a/spec/services/fees/estimate_instant/batch_pay_in_advance_service_spec.rb b/spec/services/fees/estimate_instant/batch_pay_in_advance_service_spec.rb new file mode 100644 index 0000000..cd8fead --- /dev/null +++ b/spec/services/fees/estimate_instant/batch_pay_in_advance_service_spec.rb @@ -0,0 +1,277 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::EstimateInstant::BatchPayInAdvanceService do + subject { described_class.new(organization:, external_subscription_id:, events:) } + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:, properties: {rate: "0.1", fixed_amount: "0"}) } + + let(:customer) { create(:customer, organization:) } + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + started_at: 1.year.ago + ) + end + + let(:event) do + { + organization_id:, + code:, + transaction_id:, + external_customer_id:, + external_subscription_id:, + timestamp:, + properties: + } + end + let(:events) { [event] } + + let(:transaction_id) { SecureRandom.uuid } + + let(:properties) { nil } + + let(:code) { billable_metric&.code } + let(:external_customer_id) { customer&.external_id } + let(:external_subscription_id) { subscription&.external_id } + let(:organization_id) { organization.id } + let(:timestamp) { Time.current.to_i.to_s } + let(:currency) { subscription.plan.amount.currency } + + before { charge } + + # TODO: these are copied from the non-batch service. does it make sense to creata a shared_example? + context "with 1 event" do + describe "#call" do + it "returns a list of fees" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).to be_a(Hash) + expect(fee).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: transaction_id + ) + end + + context "when setting event properties" do + let(:properties) { {billable_metric.field_name => 500} } + + it "calculates the fee correctly" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(50) + end + end + + context "when charge is standard charge" do + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, properties: {amount: "10"}) } + + it "returns a list of fees" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).to be_a(Hash) + expect(fee).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: transaction_id + ) + end + + context "when setting event properties" do + let(:properties) { {billable_metric.field_name => 500} } + + it "calculates the fee correctly" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(500000) + end + end + + context "when billable metric has an expression configured" do + let(:billable_metric) { create(:sum_billable_metric, organization:, expression: "event.properties.test * 2") } + let(:properties) { {"test" => 200} } + + it "calculates evaluates the expression before estimating" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(400000) + end + end + end + + context "when billable metric has an expression configured" do + let(:billable_metric) { create(:sum_billable_metric, organization:, expression: "event.properties.test * 2") } + let(:properties) { {"test" => 200} } + + it "calculates evaluates the expression before estimating" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(40) + end + end + + context "when event code does not match an pay_in_advance charge" do + let(:charge) { create(:percentage_charge, plan:, billable_metric:) } + + it "fails with a validation error" do + result = subject.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["does_not_match_an_instant_charge"]) + end + end + + context "when event matches multiple charges" do + let(:charge2) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:) } + + before { charge2 } + + it "returns a fee per charges" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + end + end + + context "when external subscription is not found" do + let(:external_subscription_id) { nil } + + it "fails with a not found error" do + result = subject.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when billable metric does not have field name to run aggregation on" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:, properties: {rate: "10", fixed_amount: "0"}) } + let(:properties) { {} } + + it "takes the whole event as one unit" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(10) + expect(fee[:units]).to eq(1) + end + end + end + end + + context "with multiple events" do + let(:billable_metric2) { create(:sum_billable_metric, organization:) } + let(:charge2) { create(:standard_charge, :pay_in_advance, plan:, billable_metric: billable_metric2, properties: {amount: "1"}) } + let(:event2) do + { + organization_id:, + code: billable_metric2.code, + transaction_id: SecureRandom.uuid, + external_customer_id:, + external_subscription_id:, + timestamp:, + properties: properties2 + } + end + let(:properties2) { nil } + let(:events) { [event, event2] } + + before { charge2 } + + describe "#call" do + it "returns a list of fees" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f[:event_transaction_id] == event[:transaction_id] } + fee2 = result.fees.find { |f| f[:event_transaction_id] == event2[:transaction_id] } + expect(fee1).to be_a(Hash) + expect(fee1).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: transaction_id + ) + expect(fee2).to be_a(Hash) + expect(fee2).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: event2[:transaction_id] + ) + end + end + + context "when properties are set" do + let(:properties) { {billable_metric.field_name => 100} } + let(:properties2) { {billable_metric2.field_name => 500} } + + it "calculates the fee correctly" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + + fee1 = result.fees.find { |f| f[:event_transaction_id] == event[:transaction_id] } + fee2 = result.fees.find { |f| f[:event_transaction_id] == event2[:transaction_id] } + expect(fee1[:amount_cents]).to eq(10) + expect(fee2[:amount_cents]).to eq(50000) + end + end + + context "when external subscription is not found" do + let(:external_subscription_id) { nil } + + it "fails with a not found error" do + result = subject.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("subscription_not_found") + end + end + end +end diff --git a/spec/services/fees/estimate_instant/pay_in_advance_service_spec.rb b/spec/services/fees/estimate_instant/pay_in_advance_service_spec.rb new file mode 100644 index 0000000..41c8c73 --- /dev/null +++ b/spec/services/fees/estimate_instant/pay_in_advance_service_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::EstimateInstant::PayInAdvanceService do + subject { described_class.new(organization:, params:) } + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:sum_billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:, properties: {rate: "0.1", fixed_amount: "0"}) } + + let(:customer) { create(:customer, organization:) } + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + started_at: 1.year.ago + ) + end + + let(:params) do + { + organization_id:, + code:, + transaction_id:, + external_customer_id:, + external_subscription_id:, + timestamp:, + properties: + } + end + + let(:transaction_id) { SecureRandom.uuid } + + let(:properties) { nil } + + let(:code) { billable_metric&.code } + let(:external_customer_id) { customer&.external_id } + let(:external_subscription_id) { subscription&.external_id } + let(:organization_id) { organization.id } + let(:timestamp) { Time.current.to_i.to_s } + let(:currency) { subscription.plan.amount.currency } + + before { charge } + + describe "#call" do + it "returns a list of fees" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).to be_a(Hash) + expect(fee).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: transaction_id + ) + end + + context "when setting event properties" do + let(:properties) { {billable_metric.field_name => 500} } + + it "calculates the fee correctly" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(50) + expect(fee[:units]).to eq(500) + end + + context "when billable metric aggregation does not support field name" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:percentage_charge, :pay_in_advance, plan:, billable_metric:, properties: {rate: "10", fixed_amount: "0"}) } + let(:properties) { {} } + + it "calculates the fee correctly" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(10) + expect(fee[:units]).to eq(1) + end + end + end + + context "when charge is standard charge" do + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, properties: {amount: "10"}) } + + it "returns a list of fees" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).to be_a(Hash) + expect(fee).to include( + pay_in_advance: true, + invoiceable: charge.invoiceable, + events_count: 1, + event_transaction_id: transaction_id + ) + end + + context "when setting event properties" do + let(:properties) { {billable_metric.field_name => 500} } + + it "calculates the fee correctly" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(500000) + end + end + + context "when billable metric has an expression configured" do + let(:billable_metric) { create(:sum_billable_metric, organization:, expression: "event.properties.test * 2") } + let(:properties) { {"test" => 200} } + + it "calculates evaluates the expression before estimating" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(400000) + end + end + end + + context "when billable metric has an expression configured" do + let(:billable_metric) { create(:sum_billable_metric, organization:, expression: "event.properties.test * 2") } + let(:properties) { {"test" => 200} } + + it "calculates evaluates the expression before estimating" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee[:amount_cents]).to eq(40) + end + end + + context "when event code does not match an pay_in_advance charge" do + let(:charge) { create(:percentage_charge, plan:, billable_metric:) } + + it "fails with a validation error" do + result = subject.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["does_not_match_an_instant_charge"]) + end + end + + context "when event matches multiple charges" do + let(:charge2) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:) } + + before { charge2 } + + it "returns a fee per charges" do + result = subject.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + end + end + + context "when external subscription is not found" do + let(:external_subscription_id) { nil } + + it "fails with a not found error" do + result = subject.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("subscription_not_found") + end + end + end +end diff --git a/spec/services/fees/estimate_pay_in_advance_service_spec.rb b/spec/services/fees/estimate_pay_in_advance_service_spec.rb new file mode 100644 index 0000000..5d9a301 --- /dev/null +++ b/spec/services/fees/estimate_pay_in_advance_service_spec.rb @@ -0,0 +1,262 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::EstimatePayInAdvanceService do + subject(:estimate_service) { described_class.new(organization:, params:) } + + let(:organization) { create(:organization) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:, properties: {amount: "100"}) } + + let(:customer) { create(:customer, organization:) } + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + started_at: 1.year.ago + ) + end + + let(:params) do + { + code:, + external_customer_id:, + external_subscription_id: + } + end + + let(:code) { billable_metric&.code } + let(:external_customer_id) { customer&.external_id } + let(:external_subscription_id) { subscription&.external_id } + + before { charge } + + describe "#call" do + it "does not persist any events" do + expect { estimate_service.call }.not_to change(Event, :count) + end + + it "returns a list of fees" do + result = estimate_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).not_to be_persisted + expect(fee).to have_attributes( + subscription:, + charge:, + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + events_count: 1, + pay_in_advance_event_id: nil, + pay_in_advance_event_transaction_id: String + ) + end + + context "with taxes" do + let(:billing_entity_tax) { create(:tax, organization:, code: "be_vat", name: "BE VAT", rate: 20.0) } + + before do + create(:billing_entity_applied_tax, billing_entity: customer.billing_entity, tax: billing_entity_tax) + end + + it "returns fees with taxes applied" do + result = estimate_service.call + + expect(result).to be_success + + fee = result.fees.first + expect(fee.applied_taxes.size).to eq(1) + expect(fee.applied_taxes.first.tax_code).to eq("be_vat") + expect(fee.taxes_rate).to eq(20.0) + expect(fee.taxes_amount_cents).to be_positive + end + + context "when customer has customer-specific taxes" do + let(:customer_tax) { create(:tax, organization:, code: "customer_vat", name: "Customer VAT", rate: 8.0) } + + before do + create(:customer_applied_tax, customer:, tax: customer_tax) + end + + it "applies customer taxes over billing entity taxes" do + result = estimate_service.call + + fee = result.fees.first + expect(fee.applied_taxes.size).to eq(1) + expect(fee.applied_taxes.first.tax_code).to eq("customer_vat") + expect(fee.taxes_rate).to eq(8.0) + end + end + end + + context "when charge model is dynamic" do + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "value") } + let(:charge) { create(:dynamic_charge, :pay_in_advance, plan:, billable_metric:) } + + let(:params) do + { + code:, + external_customer_id:, + external_subscription_id:, + properties: {billable_metric.field_name => 10}, + precise_total_amount_cents: 120_00 + } + end + + it "returns a list of fees" do + result = estimate_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(1) + + fee = result.fees.first + expect(fee).not_to be_persisted + expect(fee).to have_attributes( + subscription:, + charge:, + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + events_count: 1, + pay_in_advance_event_id: nil, + pay_in_advance_event_transaction_id: String, + amount_cents: 120_00 + ) + end + end + + context "with an expression configured on the billable metric" do + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "result", expression: "event.properties.left + event.properties.right") } + + let(:params) do + { + external_subscription_id:, + code:, + properties: {"left" => "1", "right" => "2"} + } + end + + before do + billable_metric + end + + it "creates an event and updates the field name with the result of the expression" do + result = estimate_service.call + + expect(result).to be_success + + fee = result.fees.first + expect(fee).not_to be_persisted + expect(fee).to have_attributes( + subscription:, + charge:, + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + events_count: 1, + pay_in_advance_event_id: nil, + pay_in_advance_event_transaction_id: String, + units: 3, + amount_cents: 300_00 + ) + end + + context "when not all the event properties are not provided" do + let(:params) do + { + external_subscription_id:, + code:, + properties: {} + } + end + + it "returns a service failure when the expression fails to evaluate" do + result = estimate_service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq("expression_evaluation_failed: Variable: left not found") + end + end + end + + context "when event code does not match an pay_in_advance charge" do + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + + it "fails with a validation error" do + result = estimate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["does_not_match_an_instant_charge"]) + end + end + + context "when event matches multiple charges" do + let(:charge2) { create(:standard_charge, :pay_in_advance, plan:, billable_metric:) } + + before { charge2 } + + it "returns a fee per charges" do + result = estimate_service.call + + expect(result).to be_success + expect(result.fees.count).to eq(2) + end + end + + context "when external customer is not found" do + let(:params) do + {code:, external_customer_id: "unknown"} + end + + it "fails with a not found error" do + result = estimate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when external subscription is not found" do + let(:external_subscription_id) { nil } + + it "fails with a not found error" do + result = estimate_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("subscription_not_found") + end + + context "when customer has an active subscription" do + let(:subscription) do + create( + :subscription, + customer:, + plan:, + started_at: 1.year.ago + ) + end + + before { subscription } + + it "returns nothing" do + result = estimate_service.call + + expect(result).not_to be_success + expect(result.fees).to be_nil + end + end + end + end +end diff --git a/spec/services/fees/fixed_charge_service_spec.rb b/spec/services/fees/fixed_charge_service_spec.rb new file mode 100644 index 0000000..8ecf1c6 --- /dev/null +++ b/spec/services/fees/fixed_charge_service_spec.rb @@ -0,0 +1,938 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::FixedChargeService, :premium do + subject(:fixed_charge_service) do + described_class.new(invoice:, fixed_charge:, subscription:, boundaries:, context:, apply_taxes:) + end + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:context) { :finalize } + let(:apply_taxes) { false } + let(:started_at) { Time.zone.parse("2022-03-17") } + + let(:subscription) do + create( + :subscription, + organization:, + status: :active, + started_at:, + customer: + ) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.to_date.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + fixed_charges_from_datetime: subscription.started_at.beginning_of_day, + fixed_charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + timestamp: subscription.started_at.end_of_month.end_of_day + 1.second, + charges_duration: ( + subscription.started_at.end_of_month.end_of_day - subscription.started_at.beginning_of_month + ).fdiv(1.day).ceil, + fixed_charges_duration: ( + subscription.started_at.end_of_month.end_of_day - subscription.started_at.beginning_of_month + ).fdiv(1.day).ceil + ) + end + + let(:invoice) do + create(:invoice, :draft, customer:, organization:) + end + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + prorated: true, + properties: { + amount: "310" + } + ) + end + let(:fixed_charge_tax) { create(:fixed_charge_applied_tax, fixed_charge:) } + + describe ".call" do + context "with standard charge model" do + it "creates a fee but does not persist it" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee.id).to be_nil + expect(result.fee.amount_cents).to eq(0) + end + + context "with preview context and non-persisted subscription" do + let(:context) { :invoice_preview } + let(:subscription) do + Subscription.new( + organization_id: organization.id, + customer:, + plan: create(:plan, organization:), + subscription_at: Time.current, + started_at: Time.current, + billing_time: "calendar" + ) + end + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + units: 8, + properties: {amount: "12.5"} + ) + end + let(:invoice) { Invoice.new(customer:, organization:) } + + it "creates fee with default units from fixed_charge" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(result.fee).to have_attributes( + invoice: invoice, + fixed_charge_id: fixed_charge.id, + units: 8, + amount_cents: 10000, # $12.5 * 8 units = $100 + precise_amount_cents: 10000.0 + ) + end + end + + context "with an event" do + context "when event created_at is within the current billing period" do + let(:event) do + create( + :fixed_charge_event, + organization: subscription.organization, + subscription:, + fixed_charge:, + timestamp: boundaries.charges_to_datetime - 2.days, + created_at: boundaries.charges_to_datetime - 2.days, + units: 10 + ) + end + + before do + event + fixed_charge_tax + end + + # 3 days proration out of 31 of 10 units with price 310 (310 * 3/31 * 10 = 300) + it "creates a fee" do + result = fixed_charge_service.call + expect(result).to be_success + prorated_units = (3.0 / 31 * 10).round(6) + expect(result.fee).to have_attributes( + id: String, + organization_id: organization.id, + billing_entity_id: invoice.customer.billing_entity_id, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 30000, + precise_amount_cents: 310_00 * prorated_units, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 3000, + events_count: nil, + payment_status: "pending" + ) + end + + it "persists fee" do + expect { fixed_charge_service.call }.to change(Fee, :count) + end + + it "sets correct boundaries on the fee properties" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee.properties).to include( + "fixed_charges_from_datetime" => "2022-03-17T00:00:00.000Z", + "fixed_charges_to_datetime" => "2022-03-31T23:59:59.999Z", + "fixed_charges_duration" => 31, + "charges_from_datetime" => nil, + "charges_to_datetime" => nil, + "charges_duration" => nil + ) + end + + context "with preview context" do + let(:context) { :invoice_preview } + + it "does not persist fee" do + expect { fixed_charge_service.call }.not_to change(Fee, :count) + end + end + + context "with not prorated fixed_charge" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", prorated: false, properties: {amount: "20"}) + end + + it "creates a fee" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 20000, + precise_amount_cents: 20000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 2000, + precise_unit_amount: 20, + events_count: nil, + payment_status: "pending" + ) + end + end + + context "when fixed charge is pay_in_advance" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", pay_in_advance: true, properties: {amount: "10"}) + end + + it "sets boundaries of the next billing period" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee.properties).to include( + "fixed_charges_from_datetime" => "2022-04-01T00:00:00.000Z", + "fixed_charges_to_datetime" => "2022-04-30T23:59:59.999Z", + "fixed_charges_duration" => 30, + "charges_from_datetime" => nil, + "charges_to_datetime" => nil, + "charges_duration" => nil + ) + end + end + end + + context "with event created_at is after the current billing period" do + let(:created_at) { Time.zone.parse("2022-05-17") } + # NOTE: subscription started at 2022-03-17, so all charges only start from 17th + let(:event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp:, created_at:, units: 10) + end + + before do + event + end + + context "when event timestamp is before the current billing period" do + let(:timestamp) { Time.zone.parse("2022-01-17") } + + # subscription started at 2022-03-17, so all charges only start from 17th => 15 days + it "finds the event and creates the fee with proration from the beginning of the billing period" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 150000, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 15000, + events_count: nil, + payment_status: "pending" + ) + end + end + + context "when event timestamp is within the current billing period" do + let(:timestamp) { Time.zone.parse("2022-03-22") } + + # 10 days proration + # 10 days proration out of 31 of 10 units with price 310 (310 * 10/31 * 10 = 1000) + it "finds the event and creates the fee with the correct amount" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 100000, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + # the math here is broken because of rounding. Firstly we're calculating the proration: 10/31 = 0.3225806451612903 => 0.322580 + # then we're multiplying by the price: 3.225806 * 31000 = 9999,98 => 9999 + unit_amount_cents: 9999, + events_count: nil, + payment_status: "pending" + ) + end + end + + context "when event timestamp is after the current billing period" do + let(:timestamp) { Time.zone.parse("2022-04-20") } + + it "does not find the event and returns an empty fee" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to be_present + expect(result.fee.amount_cents).to eq(0) + expect(result.fee.id).to be_nil + end + end + end + end + end + + context "with graduated charge model" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "graduated", + prorated: false, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "0.1", + flat_amount: "10" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "2", + flat_amount: "20" + } + ] + } + ) + end + + before do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: boundaries.from_datetime + 5.days, units: 62, created_at: boundaries.from_datetime + 5.days) + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: boundaries.from_datetime + 10.days, units: 3.1, created_at: boundaries.from_datetime + 10.days) + end + + # this is not prorated result! + it "creates a fee" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 1031, + precise_amount_cents: 1031.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 3.1, + unit_amount_cents: 332, + precise_unit_amount: (10.31 / 3.1), + events_count: nil + ) + end + + context "with prorated fixed_charge" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "graduated", + prorated: true, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 5, + per_unit_amount: "0.1", + flat_amount: "10" + }, + { + from_value: 6, + to_value: nil, + per_unit_amount: "2", + flat_amount: "20" + } + ] + } + ) + end + + it "creates a fee" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 1105, + precise_amount_cents: 1105.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 3.1, + unit_amount_cents: 356, + events_count: nil + ) + end + end + end + + context "with volume charge model" do + let(:fixed_charge) do + create(:fixed_charge, + plan: subscription.plan, + charge_model: "volume", + prorated: false, + properties: { + volume_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "0.1", + flat_amount: "10" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "2", + flat_amount: "20" + } + ] + }) + end + + before do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: boundaries.from_datetime + 5.days, units: 31, created_at: boundaries.from_datetime + 5.days) + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: boundaries.from_datetime + 10.days, units: 3.1, created_at: boundaries.from_datetime + 10.days) + end + + it "creates a fee" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 1031, + precise_amount_cents: 1031.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 3.1, + unit_amount_cents: 332, + precise_unit_amount: 10.31 / 3.1, + events_count: nil + ) + end + + context "with prorated fixed_charge" do + let(:fixed_charge) do + create(:fixed_charge, + plan: subscription.plan, + charge_model: "volume", + prorated: true, + properties: { + volume_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "0.1", + flat_amount: "10" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "2", + flat_amount: "20" + } + ] + }) + end + + it "creates a fee" do + result = fixed_charge_service.call + expect(result).to be_success + # (31 * 5 / 31.0 + 3.1 * 5 / 31.0).round(6) + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 1000 + (10 * 5.5), + precise_amount_cents: 1055.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 3.1, + unit_amount_cents: 340, + precise_unit_amount: 10.55 / 3.1, + events_count: nil + ) + end + end + end + + context "when fee already exists on the period" do + before do + create(:fee, fixed_charge:, subscription:, invoice:) + end + + it "does not create a new fee" do + expect { fixed_charge_service.call }.not_to change(Fee, :count) + end + end + + context "when billing a new upgraded subscription" do + let(:previous_plan) { create(:plan, amount_cents: subscription.plan.amount_cents - 20) } + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", prorated: true, properties: {amount: "30"}) + end + let(:previous_subscription) do + create(:subscription, plan: previous_plan, status: :terminated) + end + + let(:event) do + create( + :fixed_charge_event, + organization: invoice.organization, + subscription:, + fixed_charge:, + timestamp: Time.zone.parse("10 Apr 2022 00:01:00"), + units: 10 + ) + end + + let(:started_at) { Time.zone.parse("2022-04-17") } + + let(:subscription) do + create( + :subscription, + organization:, + status: :active, + started_at:, + customer: + ) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: started_at, + to_datetime: started_at.end_of_month, + charges_from_datetime: started_at, + charges_to_datetime: started_at.end_of_month, + fixed_charges_from_datetime: started_at, + fixed_charges_to_datetime: started_at.end_of_month, + charges_duration: 30, + fixed_charges_duration: 30, + timestamp: Time.zone.parse("2022-05-01T00:00:00.000Z") + ) + end + + before do + subscription.update!(previous_subscription:) + event + end + + # proration starts on 17th of April, so 14 days proration + it "creates a new prorated fee for the complete period" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 14000, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10 + ) + end + + context "when there is an already paid fee from prev subscription" do + context "when fee is paid in advance" do + let(:pay_in_advance) { true } + let(:previous_fixed_charge) do + create(:fixed_charge, plan: previous_plan, charge_model: "standard", prorated: true, properties: {amount: prev_price}, add_on: fixed_charge.add_on, pay_in_advance:) + end + let(:previous_fee) do + create(:fee, fixed_charge: previous_fixed_charge, subscription: previous_subscription, + properties: previous_boundaries.to_h, amount_cents: prev_fee_price, organization:, + billing_entity: subscription.customer.billing_entity) + end + let(:previous_timestamp) { Time.zone.parse("11 Apr 2022 00:01:00") } + let(:previous_boundaries) do + BillingPeriodBoundaries.new( + from_datetime: previous_timestamp, + to_datetime: started_at.end_of_month, + charges_from_datetime: previous_timestamp, + charges_to_datetime: started_at.end_of_month, + fixed_charges_from_datetime: previous_timestamp, + fixed_charges_to_datetime: started_at.end_of_month, + charges_duration: 30, + fixed_charges_duration: 30, + timestamp: previous_timestamp + ) + end + + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", prorated: true, properties: {amount: new_price}, pay_in_advance:) + end + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: started_at, + to_datetime: started_at, + charges_from_datetime: started_at, + charges_to_datetime: started_at, + fixed_charges_from_datetime: started_at, + fixed_charges_to_datetime: started_at, + charges_duration: 30, + fixed_charges_duration: 30, + timestamp: Time.zone.parse("2022-04-17T00:01:00.000Z") + ) + end + + before { previous_fee } + + context "when fixed charge price is higher than previous one" do + let(:prev_price) { "3" } + let(:prev_fee_price) { 2000 } # (for 20 days out of 30) + let(:new_price) { "60" } + + it "creates a new prorated fee for the complete period" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + # new_proration = 6000 * 14 / 30 * 10 + # previous_proration = 2000 (for 20 days) + # total = new_proration - previous_proration * 14 / 20 = 28000 - 1400 = 26600 + amount_cents: 26600, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10 + ) + end + + context "when previous fee was issued during the billing period" do + let(:previous_boundaries) do + BillingPeriodBoundaries.new( + from_datetime: previous_timestamp.beginning_of_month, + to_datetime: started_at.end_of_month, + charges_from_datetime: previous_timestamp.beginning_of_month, + charges_to_datetime: started_at.end_of_month, + fixed_charges_from_datetime: previous_timestamp.beginning_of_month, + fixed_charges_to_datetime: started_at.end_of_month, + charges_duration: 30, + fixed_charges_duration: 30, + timestamp: previous_timestamp + ) + end + + it "calculate correct proration for the previous fee" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 26600 + ) + end + end + end + + context "when fixed charge price is lower than previous one" do + let(:prev_price) { "60" } + let(:prev_fee_price) { 40000 } # (for 20 days out of 30, 10 units) + let(:new_price) { "30" } + + it "creates a new prorated fee for the complete period" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + fixed_charge_id: fixed_charge.id, + amount_cents: 0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10 + ) + end + end + end + end + end + + context "when applying taxes" do + let(:apply_taxes) { true } + let(:event) do + create( + :fixed_charge_event, + organization: subscription.organization, + subscription:, + fixed_charge:, + timestamp: boundaries.charges_to_datetime - 2.days, + units: 10 + ) + end + + before do + event + fixed_charge_tax + end + + # 3 days proration out of 31 of 10 units with price 310 (310 * 3/31 * 10 = 300) + it "creates a fee with taxes" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee).to have_attributes( + taxes_amount_cents: 30000 * fixed_charge_tax.tax.rate / 100 + ) + end + end + + context "when fixed charge is pay_in_advance" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", pay_in_advance: true, properties: {amount: "10"}) + end + + it "creates a fee with pay_in_advance boundaries" do + result = fixed_charge_service.call + expect(result).to be_success + expect(result.fee.properties).to include( + "fixed_charges_from_datetime" => Time.parse("2022-04-01T00:00:00.000Z"), + "fixed_charges_to_datetime" => Time.parse("2022-04-30T23:59:59.999Z"), + "fixed_charges_duration" => 30 + ) + end + end + + context "when fixed charge is not pay_in_advance" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", pay_in_advance: false, properties: {amount: "10"}) + end + + it "creates a fee with current boundaries" do + result = fixed_charge_service.call + expect(result).to be_success + # subscription started at 2022-03-17, so all charges only start from 17th + expect(result.fee.properties).to include( + "fixed_charges_from_datetime" => Time.parse("2022-03-17T00:00:00.000Z"), + "fixed_charges_to_datetime" => Time.parse("2022-03-31T23:59:59.999Z"), + "fixed_charges_duration" => 31 + ) + end + end + + context "when there is an adjusted fee for fixed charge" do + let(:event) do + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge:, + timestamp: boundaries.charges_to_datetime - 2.days, + units: 10 + ) + end + + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: :fixed_charge, + adjusted_units: true, + adjusted_amount: false, + units: 5 + ) + end + + let(:properties) do + { + fixed_charges_from_datetime: boundaries.fixed_charges_from_datetime, + fixed_charges_to_datetime: boundaries.fixed_charges_to_datetime + } + end + + before do + event + adjusted_fee + end + + context "with adjusted units" do + it "creates a fee with adjusted units" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice:, + fixed_charge:, + amount_cents: 155_000, + precise_amount_cents: 155_000, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 5, + unit_amount_cents: 31_000, + precise_unit_amount: 310, + payment_status: "pending" + ) + end + + it "updates the adjusted fee with the new fee_id" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(adjusted_fee.reload.fee_id).to eq(result.fee.id) + end + end + + context "with adjusted amount" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: :fixed_charge, + adjusted_units: false, + adjusted_amount: true, + units: 10, + unit_amount_cents: 500, + unit_precise_amount_cents: 500 + ) + end + + it "creates a fee with adjusted amount" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice:, + fixed_charge:, + amount_cents: 5_000, + precise_amount_cents: 5_000.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 500, + precise_unit_amount: 5, + payment_status: "pending" + ) + end + end + + context "with adjusted display name only" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: :fixed_charge, + adjusted_units: false, + adjusted_amount: false, + invoice_display_name: "Custom Fixed Charge Name", + units: 5 + ) + end + + it "creates a fee with adjusted display name" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice:, + fixed_charge:, + amount_cents: 30_000, + precise_amount_cents: 30_000.002, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 3_000, + precise_unit_amount: 30.000002, + invoice_display_name: "Custom Fixed Charge Name", + payment_status: "pending" + ) + end + end + + context "with adjusted units set to zero" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: :fixed_charge, + adjusted_units: true, + adjusted_amount: false, + units: 0 + ) + end + + it "creates and persists a fee with zero units" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice:, + fixed_charge:, + amount_cents: 0, + precise_amount_cents: 0.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 0, + payment_status: "pending" + ) + # Fee should be persisted despite zero units + expect(result.fee.persisted?).to be(true) + end + + it "updates the adjusted fee with the new fee_id" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(adjusted_fee.reload.fee_id).to eq(result.fee.id) + end + end + + context "with invoice NOT in draft status" do + before { invoice.finalized! } + + it "creates a fee without using adjusted fee attributes" do + result = fixed_charge_service.call + + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + invoice:, + fixed_charge:, + amount_cents: 30_000, + precise_amount_cents: 30_000.002, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 3_000, + precise_unit_amount: 30.000002, + payment_status: "pending" + ) + end + end + end + end +end diff --git a/spec/services/fees/init_from_adjusted_charge_fee_service_spec.rb b/spec/services/fees/init_from_adjusted_charge_fee_service_spec.rb new file mode 100644 index 0000000..8bbdb46 --- /dev/null +++ b/spec/services/fees/init_from_adjusted_charge_fee_service_spec.rb @@ -0,0 +1,273 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::InitFromAdjustedChargeFeeService do + subject(:init_service) { described_class.new(adjusted_fee:, boundaries:, properties:) } + + let(:subscription) do + create( + :subscription, + status: :active, + started_at: DateTime.parse("2022-03-15") + ) + end + let(:organization) { invoice.organization } + let(:billing_entity) { organization.default_billing_entity } + let(:invoice) { create(:invoice, status: :draft) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:) } + + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + billable_metric:, + properties: { + amount: "23.45", + amount_currency: "EUR" + } + ) + end + let(:properties) { charge.properties } + + let(:boundaries) do + { + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day + } + end + + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties: {}, + fee_type: :charge, + adjusted_units: true, + adjusted_amount: false, + units: 7 + ) + end + + before do + invoice_subscription + end + + context "with adjusted units" do + context "when adjusted fee's charge has pricing unit associated" do + before do + create( + :applied_pricing_unit, + pricing_unitable: charge, + pricing_unit: create(:pricing_unit, organization:), + conversion_rate: 10 + ) + end + + it "initializes a fee" do + result = init_service.call + + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + invoice:, + subscription:, + charge:, + amount_cents: 164150, + precise_amount_cents: 164150, + taxes_precise_amount_cents: 0.0, + amount_currency: invoice.currency, + units: 7, + unit_amount_cents: 23450, + precise_unit_amount: 234.5, + events_count: 0, + payment_status: "pending", + pricing_unit_usage: PricingUnitUsage + ) + + expect(result.fee.pricing_unit_usage).to have_attributes( + amount_cents: 16415, + precise_amount_cents: 16415, + unit_amount_cents: 2345, + precise_unit_amount: 23.45, + conversion_rate: 10 + ) + end + end + + context "when adjusted fee's charge has no pricing unit associated" do + it "initializes a fee" do + result = init_service.call + + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + invoice:, + subscription:, + charge:, + amount_cents: 16415, + precise_amount_cents: 16415, + taxes_precise_amount_cents: 0.0, + amount_currency: invoice.currency, + units: 7, + unit_amount_cents: 2345, + precise_unit_amount: 23.45, + events_count: 0, + payment_status: "pending", + pricing_unit_usage: nil + ) + end + end + end + + context "with adjusted amount" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties:, + fee_type: :charge, + adjusted_units: false, + adjusted_amount: true, + units: 4, + unit_amount_cents: 200, + unit_precise_amount_cents: 200.0 + ) + end + + context "when adjusted fee's charge has pricing unit associated" do + before do + create( + :applied_pricing_unit, + pricing_unitable: charge, + pricing_unit: create(:pricing_unit, organization:), + conversion_rate: 0.5 + ) + end + + it "initializes a fee" do + result = init_service.call + + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + invoice:, + charge:, + amount_cents: 400, + precise_amount_cents: 400.0, + taxes_precise_amount_cents: 0.0, + amount_currency: invoice.currency, + units: 4, + unit_amount_cents: 100, + precise_unit_amount: 1, + events_count: 0, + payment_status: "pending", + pricing_unit_usage: PricingUnitUsage + ) + + expect(result.fee.pricing_unit_usage).to have_attributes( + amount_cents: 800, + precise_amount_cents: 800.0, + unit_amount_cents: 200, + precise_unit_amount: 2.00, + conversion_rate: 0.5 + ) + end + end + + context "when adjusted fee's charge has no pricing unit associated" do + it "initializes a fee" do + result = init_service.call + + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + invoice:, + charge:, + amount_cents: 800, + precise_amount_cents: 800.0, + taxes_precise_amount_cents: 0.0, + amount_currency: invoice.currency, + units: 4, + unit_amount_cents: 200, + precise_unit_amount: 2, + events_count: 0, + payment_status: "pending" + ) + end + + context "when units are 0" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + charge:, + properties:, + fee_type: :charge, + adjusted_units: false, + adjusted_amount: true, + units: 0, + unit_amount_cents: 0, + unit_precise_amount_cents: 0.0 + ) + end + + it "initializes a fee" do + result = init_service.call + + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + invoice:, + charge:, + amount_cents: 0, + precise_amount_cents: 0.0, + taxes_precise_amount_cents: 0.0, + amount_currency: invoice.currency, + units: 0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + payment_status: "pending" + ) + end + end + end + end + + context "with charge model error" do + let(:error_result) do + BaseService::Result.new.tap do |result| + result.service_failure!(code: "error", message: "message") + end + end + + let(:charge_model_instance) { instance_double(ChargeModels::StandardService) } + + it "returns an error" do + allow(ChargeModels::StandardService).to receive(:new).and_return(charge_model_instance) + allow(charge_model_instance).to receive(:apply).and_return(error_result) + + result = init_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("error") + expect(result.error.error_message).to eq("message") + end + end +end diff --git a/spec/services/fees/init_from_adjusted_fixed_charge_fee_service_spec.rb b/spec/services/fees/init_from_adjusted_fixed_charge_fee_service_spec.rb new file mode 100644 index 0000000..bf1373b --- /dev/null +++ b/spec/services/fees/init_from_adjusted_fixed_charge_fee_service_spec.rb @@ -0,0 +1,358 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::InitFromAdjustedFixedChargeFeeService do + subject(:result) { described_class.call(adjusted_fee:, boundaries:, properties:) } + + let(:subscription) do + create( + :subscription, + status: :active, + started_at: Time.zone.parse("2022-03-15") + ) + end + + let(:organization) { invoice.organization } + let(:billing_entity) { organization.default_billing_entity } + + let(:invoice) { create(:invoice, :draft) } + + let(:fixed_charge) do + create( + "fixed_charge", + plan: subscription.plan, + charge_model: "standard", + properties: { + amount: "20" + } + ) + end + + let(:fixed_charge_fee) do + create(:fixed_charge_fee, invoice:, subscription:, fixed_charge:) + end + + let(:boundaries) do + { + fixed_charges_from_datetime: subscription.started_at.beginning_of_day, + fixed_charges_to_datetime: subscription.started_at.end_of_month.end_of_day + } + end + + let(:properties) { fixed_charge.properties } + + context "with adjusted units" do + let(:adjusted_fee) do + create( + :adjusted_fee, + fee: fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + properties: {}, + fee_type: "fixed_charge", + adjusted_units: true, + adjusted_amount: false, + units: 5 + ) + end + + it "initializes a fixed charge fee with adjusted units" do + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + organization:, + billing_entity:, + invoice:, + subscription:, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + amount_cents: 10_000, + precise_amount_cents: 10_000.0, + precise_unit_amount: 20, + unit_amount_cents: 2_000, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 5, + events_count: 0, + payment_status: "pending", + pricing_unit_usage: nil, + amount_details: be_a(Hash) + ) + end + + context "when fixed charge is prorated" do + let(:fixed_charge) do + create( + "fixed_charge", + plan: subscription.plan, + charge_model: "standard", + prorated: true, + properties: {amount: "20"} + ) + end + + it "skips proration and initializes a fixed charge fee with adjusted units" do + expect(result).to be_success + expect(result.fee).to have_attributes( + id: nil, + organization:, + billing_entity:, + invoice:, + subscription:, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + amount_cents: 10_000, + precise_amount_cents: 10_000.0, + precise_unit_amount: 20, + unit_amount_cents: 2_000, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 5, + events_count: 0, + payment_status: "pending", + pricing_unit_usage: nil, + amount_details: be_a(Hash) + ) + end + end + end + + context "with adjusted amount" do + let(:adjusted_fee) do + create( + :adjusted_fee, + fee: fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: "fixed_charge", + adjusted_units: false, + adjusted_amount: true, + units: 10, + unit_amount_cents: 500, + unit_precise_amount_cents: 500 + ) + end + + it "initializes a fixed charge fee with adjusted amount" do + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + organization:, + billing_entity:, + invoice:, + subscription:, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + amount_cents: 5_000, + precise_amount_cents: 5_000.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 500, + precise_unit_amount: 5, + events_count: 0, + payment_status: "pending" + ) + end + + it "does not include amount_details when amount is adjusted" do + expect(result.fee.amount_details).to eq({}) + end + + context "when units are 0" do + let(:adjusted_fee) do + create( + :adjusted_fee, + fee: fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: "fixed_charge", + adjusted_units: false, + adjusted_amount: true, + units: 0, + unit_amount_cents: 0, + unit_precise_amount_cents: 0.0 + ) + end + + it "initializes a fixed charge fee with zero units" do + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + organization:, + billing_entity:, + invoice:, + subscription:, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + amount_cents: 0, + precise_amount_cents: 0.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 0, + unit_amount_cents: 0, + precise_unit_amount: 0, + events_count: 0, + payment_status: "pending" + ) + end + end + + context "when fixed charge is prorated" do + let(:fixed_charge) do + create( + "fixed_charge", + plan: subscription.plan, + charge_model: "standard", + prorated: true, + properties: {amount: "20"} + ) + end + + let(:adjusted_fee) do + create( + :adjusted_fee, + fee: fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: "fixed_charge", + adjusted_units: false, + adjusted_amount: true, + units: 10, + unit_amount_cents: 350, + unit_precise_amount_cents: 350.0 + ) + end + + it "calculates amounts using the adjusted unit price" do + expect(result).to be_success + expect(result.fee).to have_attributes( + id: nil, + organization:, + billing_entity:, + invoice:, + subscription:, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + amount_cents: 3_500, + precise_amount_cents: 3_500.0, + taxes_precise_amount_cents: 0.0, + amount_currency: "EUR", + units: 10, + unit_amount_cents: 350, + precise_unit_amount: 3.5, + events_count: 0, + payment_status: "pending" + ) + end + end + end + + context "with adjusted display name" do + let(:adjusted_fee) do + create( + :adjusted_fee, + fee: fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: "fixed_charge", + adjusted_units: false, + adjusted_amount: false, + invoice_display_name: "Custom Fixed Charge Name", + units: 5 + ) + end + + it "initializes a fixed charge fee with adjusted display name" do + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + organization:, + billing_entity:, + invoice:, + subscription:, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: "fixed_charge", + invoice_display_name: "Custom Fixed Charge Name", + events_count: 0, + payment_status: "pending" + ) + end + end + + context "with graduated charge model" do + let(:fixed_charge) do + create( + "fixed_charge", + plan: subscription.plan, + charge_model: "graduated", + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "1", + flat_amount: "5" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "0.5", + flat_amount: "10" + } + ] + } + ) + end + + let(:adjusted_fee) do + create( + :adjusted_fee, + fee: fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + properties:, + fee_type: "fixed_charge", + adjusted_units: true, + adjusted_amount: false, + units: 15 + ) + end + + it "initializes a fixed charge fee with graduated charge model applied to adjusted units" do + expect(result).to be_success + expect(result.fee).to be_a(Fee) + expect(result.fee).to have_attributes( + id: nil, + invoice:, + fixed_charge:, + amount_cents: 2750, # 5 + (10 * 1) + 10 + (5 * 0.5) = 27.50 + precise_amount_cents: 2750.0, + amount_currency: "EUR", + units: 15, + fee_type: "fixed_charge", + payment_status: "pending" + ) + end + end +end diff --git a/spec/services/fees/one_off_service_spec.rb b/spec/services/fees/one_off_service_spec.rb new file mode 100644 index 0000000..c54553e --- /dev/null +++ b/spec/services/fees/one_off_service_spec.rb @@ -0,0 +1,322 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::OneOffService do + subject(:one_off_service) do + described_class.new(invoice:, fees:) + end + + let(:invoice) { create(:invoice, organization:, customer:) } + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, billing_entity:) } + let(:tax2) { create(:tax, organization:, applied_to_organization: false) } + let(:add_on_first) { create(:add_on, organization:) } + let(:add_on_second) { create(:add_on, amount_cents: 400, organization:) } + let(:current_time) { DateTime.new(2023, 7, 19, 12, 12) } + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123", + tax_codes: [tax2.code] + }, + { + add_on_code: add_on_second.code + } + ] + end + + before { tax } + + describe "create" do + before { CurrentContext.source = "api" } + + it "creates fees" do + travel_to(current_time) do + result = one_off_service.call + + expect(result).to be_success + + first_fee = result.fees[0] + second_fee = result.fees[1] + + expect(first_fee).to have_attributes( + id: String, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + invoice_id: invoice.id, + add_on_id: add_on_first.id, + description: "desc-123", + unit_amount_cents: 1200, + precise_unit_amount: 12, + units: 2, + amount_cents: 2400, + precise_amount_cents: 2400.0, + amount_currency: "EUR", + fee_type: "add_on", + payment_status: "pending", + properties: { + "from_datetime" => current_time.to_time.utc.iso8601(3), + "to_datetime" => current_time.to_time.utc.iso8601(3), + "timestamp" => current_time + } + ) + expect(first_fee.taxes.map(&:code)).to contain_exactly(tax2.code) + + expect(second_fee).to have_attributes( + id: String, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + invoice_id: invoice.id, + add_on_id: add_on_second.id, + description: add_on_second.description, + unit_amount_cents: 400, + precise_unit_amount: 4, + units: 1, + amount_cents: 400, + precise_amount_cents: 400.0, + amount_currency: "EUR", + fee_type: "add_on", + payment_status: "pending", + properties: { + "from_datetime" => current_time.to_time.utc.iso8601(3), + "to_datetime" => current_time.to_time.utc.iso8601(3), + "timestamp" => current_time + } + ) + expect(second_fee.applied_taxes).to be_empty + end + end + + context "with passed boundaries" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123", + from_datetime: "2022-01-01T00:00:00Z", + to_datetime: "2022-01-31T23:59:59.123Z", + tax_codes: [tax2.code] + } + ] + end + + it "creates fees" do + travel_to(current_time) do + result = one_off_service.call + + expect(result).to be_success + + first_fee = result.fees[0] + + expect(first_fee).to have_attributes( + id: String, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + invoice_id: invoice.id, + add_on_id: add_on_first.id, + description: "desc-123", + unit_amount_cents: 1200, + precise_unit_amount: 12, + units: 2, + amount_cents: 2400, + precise_amount_cents: 2400.0, + amount_currency: "EUR", + fee_type: "add_on", + payment_status: "pending", + properties: { + "from_datetime" => "2022-01-01T00:00:00.000+00:00", + "to_datetime" => "2022-01-31T23:59:59.123+00:00", + "timestamp" => current_time + } + ) + expect(first_fee.taxes.map(&:code)).to contain_exactly(tax2.code) + end + end + end + + context "when add_on_code is invalid" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123" + }, + { + add_on_code: "invalid" + } + ] + end + + it "does not create an invalid fee" do + one_off_service.call + + expect(Fee.find_by(description: add_on_second.description)).to be_nil + end + end + + context "when boundaries have invalid values" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123", + from_datetime: "2022-05-01T00:00:00Z", + to_datetime: "2022-01-31T23:59:59Z", + tax_codes: [tax2.code] + } + ] + end + + it "does not create an invalid fee" do + one_off_service.call + + expect(Fee.find_by(description: add_on_first.description)).to be_nil + end + + it "returns validation failure" do + result = one_off_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:boundaries]).to include("values_are_invalid") + end + end + + context "when one boundary has invalid format" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123", + from_datetime: "2022-01-01T00:00:00Z", + to_datetime: "invalid", + tax_codes: [tax2.code] + } + ] + end + + it "does not create an invalid fee" do + one_off_service.call + + expect(Fee.find_by(description: add_on_first.description)).to be_nil + end + + it "returns validation failure" do + result = one_off_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:boundaries]).to include("values_are_invalid") + end + end + + context "when one boundary is missing" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123", + from_datetime: "2022-01-01T00:00:00Z", + tax_codes: [tax2.code] + } + ] + end + + it "does not create an invalid fee" do + one_off_service.call + + expect(Fee.find_by(description: add_on_first.description)).to be_nil + end + + it "returns validation failure" do + result = one_off_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:boundaries]).to include("values_are_invalid") + end + end + + context "when units is passed as string" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123", + tax_codes: [tax2.code] + } + ] + end + + it "creates fees" do + result = one_off_service.call + + expect(result).to be_success + + first_fee = result.fees[0] + + expect(first_fee).to have_attributes( + id: String, + invoice_id: invoice.id, + add_on_id: add_on_first.id, + description: "desc-123", + unit_amount_cents: 1200, + precise_unit_amount: 12, + units: 2, + amount_cents: 2400, + precise_amount_cents: 2400.0, + amount_currency: "EUR", + fee_type: "add_on", + payment_status: "pending" + ) + expect(first_fee.taxes.map(&:code)).to contain_exactly(tax2.code) + end + end + + context "when customer has tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before { integration_customer } + + it "creates fees without taxes (deferred to provider)" do + result = one_off_service.call + + expect(result).to be_success + + result.fees.each do |fee| + expect(fee.applied_taxes).to be_empty + expect(fee.taxes_amount_cents).to eq 0 + end + end + + context "when explicit tax_codes are in the payload" do + it "skips explicit taxes in favor of provider" do + result = one_off_service.call + + first_fee = result.fees[0] + expect(first_fee.applied_taxes).to be_empty + expect(first_fee.taxes_amount_cents).to eq 0 + end + end + end + end +end diff --git a/spec/services/fees/paid_credit_service_spec.rb b/spec/services/fees/paid_credit_service_spec.rb new file mode 100644 index 0000000..b69a896 --- /dev/null +++ b/spec/services/fees/paid_credit_service_spec.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::PaidCreditService do + subject(:paid_credit_service) do + described_class.new(invoice:, customer:, wallet_transaction:) + end + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + let(:invoice) { create(:invoice, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:, rate_amount: "1.00") } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, amount: "15.00", credit_amount: "15.00") + end + + before { subscription } + + describe ".create" do + it "creates a fee" do + result = paid_credit_service.create + + expect(result).to be_success + expect(result.fee).to have_attributes( + id: String, + fee_type: "credit", + organization_id: organization.id, + billing_entity_id: billing_entity.id, + invoice_id: invoice.id, + invoiceable_type: "WalletTransaction", + invoiceable_id: wallet_transaction.id, + amount_cents: 1500, + precise_amount_cents: 1500.0, + amount_currency: "EUR", + taxes_amount_cents: 0, + taxes_precise_amount_cents: 0.0, + taxes_rate: 0, + unit_amount_cents: 100, + units: 15, + payment_status: "pending", + precise_unit_amount: 1 + ) + end + + context "when fee already exists on the period" do + before do + create( + :fee, + invoiceable_type: "WalletTransaction", + invoiceable_id: wallet_transaction.id, + invoice: + ) + end + + it "does not create a new fee" do + expect { paid_credit_service.create }.not_to change(Fee, :count) + end + end + end +end diff --git a/spec/services/fees/projection_service_spec.rb b/spec/services/fees/projection_service_spec.rb new file mode 100644 index 0000000..dcda841 --- /dev/null +++ b/spec/services/fees/projection_service_spec.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::ProjectionService do + subject(:service) { described_class.new(fees: fees) } + + let(:organization) { create(:organization) } + + let(:fees) { [fee] } + let(:fee) do + build(:fee, + charge: charge, + subscription: subscription, + charge_filter: charge_filter, + properties: fee_properties, + amount_cents: 100, + amount_currency: currency) + end + + let(:billable_metric) do + create(:billable_metric, recurring: false, organization:) + end + + let(:charge) do + create(:standard_charge, + applied_pricing_unit: applied_pricing_unit, + filters: [], + billable_metric: billable_metric) + end + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, organization:, customer:) } + let(:plan) { create(:plan, amount_cents: 100, amount_currency: currency) } + let(:currency) { "EUR" } + + let(:charge_filter) { nil } + let(:applied_pricing_unit) { nil } + + let(:fee_properties) do + { + "from_datetime" => from_datetime, + "to_datetime" => to_datetime, + "charges_duration" => charges_duration + } + end + + let(:from_datetime) { Time.current.beginning_of_month } + let(:to_datetime) { Time.current.end_of_month } + let(:charges_duration) { nil } + + let(:aggregation_result) do + instance_double( + "AggregationResult", + success?: true, + error: nil + ) + end + + let(:charge_model_result) do + instance_double( + "ChargeModelResult", + success?: true, + error: nil, + projected_amount: BigDecimal("100.50"), + projected_units: BigDecimal(10), + unit_amount: BigDecimal("10.05") + ) + end + + before do + allow(BillableMetrics::AggregationFactory).to receive(:new_instance).and_return( + instance_double("Aggregator", aggregate: aggregation_result) + ) + + allow(ChargeModels::Factory).to receive(:new_instance).and_return( + instance_double("ChargeModel", apply: charge_model_result) + ) + + middle_time = from_datetime + ((to_datetime - from_datetime) / 2) + travel_to(middle_time) + end + + after do + travel_back + end + + describe "#call" do + context "when aggregation fails" do + let(:aggregation_result) do + instance_double( + "AggregationResult", + success?: false, + error: StandardError.new("Aggregation failed") + ) + end + + it "returns failure with aggregation error" do + result = service.call + + expect(result).to be_failure + expect(result.error).to be_a(StandardError) + expect(result.error.message).to eq("Aggregation failed") + end + end + + context "when charge model fails" do + let(:charge_model_result) do + instance_double( + "ChargeModelResult", + success?: false, + error: StandardError.new("Charge model failed") + ) + end + + it "returns failure with charge model error" do + result = service.call + + expect(result).to be_failure + expect(result.error).to be_a(StandardError) + expect(result.error.message).to eq("Charge model failed") + end + end + + context "when everything succeeds" do + it "returns projected values" do + result = service.call + + expect(result).to be_success + expect(result.projected_amount_cents).to eq(10050) # 100.50 * 100 + expect(result.projected_units).to eq(BigDecimal(10)) + expect(result.projected_pricing_unit_amount_cents).to eq(nil) # No applied_pricing_unit + end + + it "calls aggregation with correct parameters" do + aggregator = instance_double("Aggregator", aggregate: aggregation_result) + allow(BillableMetrics::AggregationFactory).to receive(:new_instance).and_return(aggregator) + service.call + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + charge: charge, + subscription: subscription, + boundaries: { + from_datetime: match_datetime(from_datetime), + to_datetime: match_datetime(to_datetime), + charges_duration: charges_duration + }, + filters: {charge_id: charge.id}, + current_usage: true + ) + expect(aggregator).to have_received(:aggregate).with(options: {is_current_usage: true}) + end + + it "calls charge model factory with correct parameters" do + from_date = from_datetime.to_date + to_date = to_datetime.to_date + current_date = Time.current.to_date + + total_days = (to_date - from_date).to_i + 1 + days_passed = (current_date - from_date).to_i + 1 + expected_period_ratio = days_passed.fdiv(total_days) + + service.call + + expect(ChargeModels::Factory).to have_received(:new_instance).with( + chargeable: charge, + aggregation_result:, + properties: charge.properties, + period_ratio: expected_period_ratio, + calculate_projected_usage: true + ) + end + end + + context "with charge filter" do + let(:charge_filter) do + create(:charge_filter, properties: {"amount" => "1000"}) + end + + let(:filter_service_result) do + instance_double( + "FilterServiceResult", + matching_filters: ["filter1"], + ignored_filters: ["filter2"] + ) + end + + before do + allow(ChargeFilters::MatchingAndIgnoredService).to receive(:call) + .and_return(filter_service_result) + end + + it "uses charge filter properties and filters" do + allow(service).to receive(:period_ratio).and_return(0.5) # rubocop:disable RSpec/SubjectStub + aggregator = instance_double("Aggregator", aggregate: aggregation_result) + allow(BillableMetrics::AggregationFactory).to receive(:new_instance).and_return(aggregator) + service.call + expect(BillableMetrics::AggregationFactory).to have_received(:new_instance).with( + charge: charge, + subscription: subscription, + boundaries: { + from_datetime: match_datetime(from_datetime), + to_datetime: match_datetime(to_datetime), + charges_duration: charges_duration + }, + filters: { + charge_id: charge.id, + charge_filter: charge_filter, + matching_filters: ["filter1"], + ignored_filters: ["filter2"] + }, + current_usage: true + ) + + expect(ChargeModels::Factory).to have_received(:new_instance).with( + chargeable: charge, + aggregation_result:, + properties: charge_filter.properties, + period_ratio: 0.5, + calculate_projected_usage: true + ) + + service.call + end + end + + context "with applied pricing unit" do + let(:applied_pricing_unit) { build(:applied_pricing_unit) } + let(:pricing_unit_usage) do + instance_double( + "PricingUnitUsage", + to_fiat_currency_cents: {amount_cents: 5000} + ) + end + + before do + allow(PricingUnitUsage).to receive(:build_from_fiat_amounts) + .and_return(pricing_unit_usage) + end + + it "calculates projected pricing unit amount cents" do + result = service.call + + expect(result).to be_success + expect(result.projected_pricing_unit_amount_cents).to eq(5000) + + expect(PricingUnitUsage).to have_received(:build_from_fiat_amounts).with( + amount: BigDecimal("100.50"), + unit_amount: BigDecimal("10.05"), + applied_pricing_unit: applied_pricing_unit + ) + end + end + end + + describe "period_ratio calculation" do + let(:from_datetime) { Time.zone.parse("2025-01-01T00:00:00") } + let(:to_datetime) { Time.zone.parse("2025-01-31T23:59:59") } + + context "when current date is in the middle of period" do + before { travel_to(from_datetime + 10.days) } + + it "calculates correct ratio" do + service.call + + expect(ChargeModels::Factory).to have_received(:new_instance).with( + hash_including(period_ratio: 11.fdiv(31)) # January has 31 days + ) + end + end + + context "when customer is in a different timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/New_York") } + let(:from_datetime) { Time.zone.parse("2025-01-01T05:00:00") } + let(:to_datetime) { Time.zone.parse("2025-02-01T04:59:59") } + + before { travel_to(from_datetime + 10.days) } + + it "calculates correct ratio" do + service.call + + expect(ChargeModels::Factory).to have_received(:new_instance).with( + hash_including(period_ratio: 11.fdiv(31)) + ) + end + end + end + + describe "edge cases" do + context "when projected_amount is nil" do + let(:charge_model_result) do + instance_double( + "ChargeModelResult", + success?: true, + error: nil, + projected_amount: nil, + projected_units: BigDecimal(10), + unit_amount: nil + ) + end + + it "returns 0 for amount cents" do + result = service.call + + expect(result).to be_success + expect(result.projected_amount_cents).to eq(0) + expect(result.projected_pricing_unit_amount_cents).to eq(nil) + end + end + + context "when currency has different exponent" do + let(:currency) { "KWD" } + + it "rounds and converts correctly" do + result = service.call + + expect(result).to be_success + expect(result.projected_amount_cents).to eq(100500) # 100.50 * 1000 + end + end + + context "when on the last day of the period" do + let(:from_datetime) { Time.current.beginning_of_month } + let(:to_datetime) { Time.current.end_of_month } + + before { travel_to(to_datetime - 5.hours) } + + it "returns projected values" do + result = service.call + + expect(result).to be_success + expect(result.projected_amount_cents).to eq(10050) # 100.50 * 100 + expect(result.projected_units).to eq(BigDecimal(10)) + expect(result.projected_pricing_unit_amount_cents).to eq(nil) # No applied_pricing_unit + end + end + end +end diff --git a/spec/services/fees/subscription_service_spec.rb b/spec/services/fees/subscription_service_spec.rb new file mode 100644 index 0000000..7490921 --- /dev/null +++ b/spec/services/fees/subscription_service_spec.rb @@ -0,0 +1,1414 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::SubscriptionService do + subject(:fees_subscription_service) do + described_class.new( + invoice:, + subscription:, + boundaries:, + context: + ) + end + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, organization:, rate: 20) } + let(:started_at) { Time.zone.parse("2022-01-01 00:01") } + let(:created_at) { started_at } + let(:subscription_at) { started_at } + let(:context) { nil } + + let(:plan) do + create( + :plan, + organization:, + amount_cents: 100, + amount_currency: "EUR" + ) + end + let(:invoice) { create(:invoice, organization:, customer:) } + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: Time.zone.parse("2022-03-01 00:00:00"), + to_datetime: Time.zone.parse("2022-03-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2022-03-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2022-03-31 23:59:59"), + charges_duration: 31.days, + timestamp: Time.zone.parse("2022-04-02 00:00").end_of_month.to_i + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + started_at:, + subscription_at:, + customer:, + created_at:, + external_id: "sub_id" + ) + end + + before { tax } + + context "when invoice is on a full period" do + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + organization_id: organization.id, + billing_entity_id: billing_entity.id, + invoice_id: invoice.id, + amount_cents: 100, + precise_amount_cents: 100.0, + amount_currency: "EUR", + units: 1, + events_count: nil, + payment_status: "pending", + unit_amount_cents: 100, + precise_unit_amount: 1, + amount_details: {"plan_amount_cents" => 100} + ) + end + + it "persists fee" do + expect { fees_subscription_service.call }.to change(Fee, :count).by(1) + end + + context "with preview context" do + let(:context) { :preview } + + it "does not persist fee" do + expect { fees_subscription_service.call }.not_to change(Fee, :count) + end + end + + context "when plan has a trial period" do + before do + plan.update(trial_period: trial_duration) + subscription.update(started_at: boundaries.from_datetime) + end + + context "when trial end in period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + amount_cents: 90, + precise_amount_cents: 90.32258064516128, + unit_amount_cents: 90, + precise_unit_amount: 0.9, + amount_details: {"plan_amount_cents" => 100} + ) + end + end + + context "when trial ends after end of period" do + let(:trial_duration) { 45 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + + context "when there is adjusted fee" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + properties:, + adjusted_units: true, + adjusted_amount: false, + units: 3 + ) + end + let(:properties) do + { + from_datetime: boundaries.from_datetime, + to_datetime: boundaries.to_datetime + } + end + + before do + adjusted_fee + invoice.draft! + end + + context "with adjusted units" do + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 300, + precise_amount_cents: 300.0, + amount_currency: "EUR", + units: 3, + events_count: nil, + payment_status: "pending", + unit_amount_cents: 100, + precise_unit_amount: 1 + ) + end + end + + context "with adjusted amount" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + properties:, + adjusted_units: false, + adjusted_amount: true, + units: 3, + unit_amount_cents: 200, + unit_precise_amount_cents: 200.0 + ) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 600, + precise_amount_cents: 600.0, + amount_currency: "EUR", + units: 3, + events_count: nil, + payment_status: "pending", + unit_amount_cents: 200, + precise_unit_amount: 2 + ) + end + + context "when precise unit amounts are used" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + properties:, + adjusted_units: false, + adjusted_amount: true, + units: 1000, + unit_amount_cents: 0, + unit_precise_amount_cents: 0.1 + ) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 100, + precise_amount_cents: 100.0, + amount_currency: "EUR", + units: 1000, + events_count: nil, + payment_status: "pending", + unit_amount_cents: 0, + precise_unit_amount: 0.001 + ) + end + end + end + + context "with adjusted display name" do + let(:adjusted_fee) do + create( + :adjusted_fee, + invoice:, + subscription:, + properties:, + adjusted_units: false, + adjusted_amount: false, + units: 1, + invoice_display_name: "test123" + ) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 100, + precise_amount_cents: 100.0, + amount_currency: "EUR", + units: 1, + events_count: nil, + payment_status: "pending", + unit_amount_cents: 100, + precise_unit_amount: 1, + invoice_display_name: "test123" + ) + end + end + + context "with invoice NOT in draft status" do + before { invoice.finalized! } + + it "creates a fee without using adjusted fee attributes" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 100, + precise_amount_cents: 100.0, + amount_currency: "EUR", + units: 1, + events_count: nil, + payment_status: "pending", + unit_amount_cents: 100, + precise_unit_amount: 1 + ) + end + end + end + end + + context "when subscription has never been billed" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + timestamp: (subscription.started_at.end_of_month + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + context "when plan is weekly" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.to_date.beginning_of_day, + to_datetime: subscription.started_at.end_of_week.end_of_day, + charges_from_datetime: subscription.started_at.to_date.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_week.end_of_day, + charges_duration: 30.days, + timestamp: (subscription.started_at.end_of_week + 1.day).to_i + ) + end + + before do + plan.weekly! + end + + context "when subscription start is on Monday" do + let(:started_at) { Time.zone.parse("2022-06-20 00:01") } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 100, + precise_amount_cents: 100.0, + amount_currency: "EUR", + unit_amount_cents: 100, + precise_unit_amount: 1, + units: 1 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end during period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + # 100 - ((100/7)*3) + expect(result.fee).to have_attributes( + amount_cents: 57, + precise_amount_cents: 57.14285714285714 + ) + end + end + + context "when trial end after end of period" do + let(:trial_duration) { 10 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + + context "when plan is pay in advance" do + before { plan.update(pay_in_advance: true) } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 100, + precise_amount_cents: 100.0 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end in period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 57, + precise_amount_cents: 57.14285714285714 + ) + end + end + + context "when trial end after period" do + let(:trial_duration) { 10 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + end + end + + context "when subscription start is on any other day" do + let(:started_at) { Time.zone.parse("2022-06-22 00:00") } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 71, + precise_amount_cents: 71.42857142857143, + amount_currency: plan.amount_currency, + units: 1 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end during the period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 29, + precise_amount_cents: 28.57142857142857 + ) + end + end + + context "when trial end after the period end" do + let(:trial_duration) { 10 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + + context "when plan is pay in advance" do + before { plan.update(pay_in_advance: true) } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 71, + precise_amount_cents: 71.42857142857143 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end during the period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 29, + precise_amount_cents: 28.57142857142857 + ) + end + end + + context "when trial end after the period end" do + let(:trial_duration) { 10 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + end + + context "when subscription is created in the past" do + context "when plan is pay in advance" do + let(:created_at) { subscription_at + 2.days } + + before { plan.update(pay_in_advance: true) } + + it "creates a full amount fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: result.fee.amount_cents, + precise_amount_cents: result.fee.amount_cents + ) + end + end + + context "when subscription has started before previous billing period" do + let(:created_at) { subscription_at + 8.days } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.created_at.beginning_of_week.beginning_of_day, + to_datetime: subscription.created_at.end_of_week.end_of_day, + timestamp: (subscription.created_at.end_of_week + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + it "creates a full amount fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: result.fee.amount_cents, + precise_amount_cents: result.fee.amount_cents + ) + end + end + end + end + end + + context "when plan is monthly" do + before { plan.monthly! } + + context "when subscription start is on the 1st of the month" do + let(:started_at) { Time.zone.parse("2022-01-01 00:01") } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 100, + precise_amount_cents: 100.0, + amount_currency: "EUR", + unit_amount_cents: 100, + precise_unit_amount: 1, + units: 1 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end during period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 90, + precise_amount_cents: 90.32258064516128 + ) + end + end + + context "when trial end after end of period" do + let(:trial_duration) { 45 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + + context "when plan is pay in advance" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.to_date.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_from_datetime: subscription.started_at.to_date.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days, + timestamp: (subscription.started_at + 1.day).to_i + ) + end + + before { plan.update(pay_in_advance: true) } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 100, + precise_amount_cents: 100.0 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end in period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 90, + precise_amount_cents: 90.32258064516128 + ) + end + end + + context "when trial end after period" do + let(:trial_duration) { 45 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + end + end + + context "when subscription start is on any other day" do + let(:started_at) { Time.zone.parse("2022-03-15 00:00:00") } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 55, + precise_amount_cents: 54.83870967741935, + amount_currency: plan.amount_currency, + units: 1 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end during the period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 45, + precise_amount_cents: 45.16129032258064 + ) + end + end + + context "when trial end after the period end" do + let(:trial_duration) { 45 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + + context "when plan is pay in advance" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.to_date.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_from_datetime: subscription.started_at.to_date.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days, + timestamp: (subscription.started_at + 1.day).to_i + ) + end + + before { plan.update(pay_in_advance: true) } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 55, + precise_amount_cents: 54.83870967741935 + ) + end + + context "when plan has a trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial end during the period" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 45, + precise_amount_cents: 45.16129032258064 + ) + end + end + + context "when trial end after the period end" do + let(:trial_duration) { 45 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + end + end + + context "when subscription is based on anniversary date" do + let(:started_at) { Time.zone.parse("2022-08-31 00:01") } + + let(:plan) do + create( + :plan, + amount_cents: 3000, + amount_currency: "EUR" + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + started_at:, + subscription_at: DateTime.parse("2022-08-31"), + billing_time: :anniversary, + customer:, + external_id: "sub_id" + ) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: Time.zone.parse("2022-08-31 00:00:00"), + to_datetime: Time.zone.parse("2022-09-30 23:59:59"), + charges_from_datetime: Time.zone.parse("2022-08-30 22:00:00"), + charges_to_datetime: Time.zone.parse("2022-09-30 21:59:59"), + charges_duration: 30.days, + timestamp: Time.zone.parse("2022-10-01 00:00").to_i + ) + end + + context "when subscription is pay in advance" do + before { plan.update(pay_in_advance: true) } + + context "when plan has a trial period" do + before { plan.update(trial_period: 15) } + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 1600, + precise_amount_cents: 1600.0 + ) + end + + context "with customer timezone" do + let(:customer) { create(:customer, organization:, timezone: "Europe/Paris") } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: Time.zone.parse("2022-08-30 22:00:00"), + to_datetime: Time.zone.parse("2022-09-30 21:59:59"), + charges_from_datetime: Time.zone.parse("2022-08-30 22:00:00"), + charges_to_datetime: Time.zone.parse("2022-09-30 21:59:59"), + charges_duration: 30.days, + timestamp: Time.zone.parse("2022-10-01 00:00").to_i + ) + end + + it "creates a fee with prorated amount based on trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 1600, + precise_amount_cents: 1600.0 + ) + end + end + end + end + end + end + + context "when plan is yearly" do + before { plan.yearly! } + + context "when subscription start is on the 1st day of the year" do + let(:started_at) { Time.zone.now.beginning_of_year } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_year.beginning_of_day, + to_datetime: subscription.started_at.end_of_year.end_of_day, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_year.end_of_day, + charges_duration: 30.days, + timestamp: (subscription.started_at.end_of_year + 1.day).to_i + ) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 100, + precise_amount_cents: 100, + amount_currency: "EUR", + unit_amount_cents: 100, + precise_unit_amount: 1, + units: 1 + ) + end + + context "when plan is pay in advance" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_year.beginning_of_day, + to_datetime: subscription.started_at.end_of_year.end_of_day, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_year.end_of_day, + charges_duration: 30.days, + timestamp: subscription.started_at.beginning_of_year.beginning_of_day.to_i + ) + end + + before { plan.update(pay_in_advance: true) } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: plan.amount_cents, + precise_amount_cents: plan.amount_cents + ) + end + end + end + + context "when subscription start is on any other day" do + let(:started_at) { Time.zone.parse("2022-03-15 00:00:00") } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_day, + to_datetime: subscription.started_at.end_of_year.end_of_day, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_year.end_of_day, + charges_duration: 30.days, + timestamp: (subscription.started_at.end_of_year + 1.day).to_i + ) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 80, + precise_amount_cents: 80, + amount_currency: plan.amount_currency, + units: 1 + ) + end + + context "when plan is pay in advance" do + before { plan.update(pay_in_advance: true) } + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 80, + precise_amount_cents: 80.0 + ) + end + end + end + end + end + + context "when subscription has already been billed once on an other period" do + let(:started_at) { Time.zone.parse("2022-01-01 00:00") } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + timestamp: (subscription.started_at.end_of_month + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + let(:invoice) do + create( + :invoice, + issuing_date: subscription.started_at.end_of_month.to_date + 1.day + ) + end + + before do + other_invoice = create(:invoice, organization: customer.organization) + create(:fee, subscription:, invoice: other_invoice) + end + + it "creates a fee with full period amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 100, + precise_amount_cents: 100.0 + ) + end + + context "when plan has trial period" do + context "when trial end during period" do + before { plan.update(trial_period: 3) } + + it "creates a fee with prorated amount on trial period" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 90, + precise_amount_cents: 90.32258064516128 + ) + end + + context "when plan is pay in advance" do + before do + plan.update!( + pay_in_advance: true, + trial_period:, + interval: + ) + end + + context "when plan is weekly" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.to_date.end_of_week.beginning_of_day, + to_datetime: (subscription.started_at.end_of_week + 1.week).end_of_day, + timestamp: (subscription.started_at.end_of_week + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + let(:interval) { :weekly } + let(:trial_period) { 5 } + + it "creates a fee with prorated amount on trial period" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 57, + precise_amount_cents: 57.14285714285714 + ) + end + end + + context "when plan is monthly" do + let(:interval) { :monthly } + let(:trial_period) { 15 } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + timestamp: (subscription.started_at + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + it "creates a fee with prorated amount on trial period" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 52, + precise_amount_cents: 51.61290322580645 + ) + end + end + + context "when plan is yearly" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_year.beginning_of_day, + to_datetime: subscription.started_at.end_of_year.end_of_day, + timestamp: (subscription.started_at.beginning_of_year + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + let(:interval) { :yearly } + let(:trial_period) { 35 } + + it "creates a fee with prorated amount on trial period" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 90, + precise_amount_cents: 90.41095890410958 + ) + end + end + end + end + + context "when trial end after period" do + before { plan.update(trial_period: 45) } + + it "creates a fee with 0 amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + end + + context "when already billed fee" do + let(:plan) do + create( + :plan, + amount_cents: 100, + amount_currency: "EUR" + ) + end + + let(:started_at) { Time.zone.parse("2022-01-01 00:00:00") } + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: (subscription.started_at + 1.month).beginning_of_day, + to_datetime: (subscription.started_at + 2.months).end_of_day, + timestamp: (subscription.started_at + 2.months + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + before do + create(:fee, subscription:, invoice:) + end + + it "does not create a fee" do + expect { fees_subscription_service.call }.not_to change(Fee, :count) + end + end + + context "when billing a newly terminated subscription" do + let(:started_at) { Time.zone.parse("2022-03-15 00:00:00") } + + let(:subscription) do + create( + :subscription, + plan:, + status: :terminated, + started_at:, + subscription_at:, + customer:, + external_id: "sub_id" + ) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_month.beginning_of_day, + to_datetime: (subscription.started_at + 5.days).end_of_day, + timestamp: (subscription.started_at + 6.days).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + before do + plan.update!(pay_in_advance: false) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 65, + precise_amount_cents: 64.51612903225806, + amount_currency: plan.amount_currency, + units: 1 + ) + end + + context "with customer timezone" do + let(:customer) { create(:customer, organization:, timezone: "Europe/Paris") } + let(:from_datetime) do + subscription.started_at.to_date.beginning_of_month.in_time_zone(customer.applicable_timezone).utc + end + let(:to_datetime) do + (subscription.started_at + 5.days).to_date.in_time_zone(customer.applicable_timezone).end_of_day.utc + end + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime:, + to_datetime:, + timestamp: (subscription.started_at + 6.days).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 65, + precise_amount_cents: 64.51612903225806, + amount_currency: plan.amount_currency, + units: 1 + ) + end + end + + context "when plan is weekly" do + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_week.beginning_of_day, + to_datetime: (subscription.started_at + 1.day).end_of_day, + timestamp: (subscription.started_at + 2.days).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + before do + plan.weekly! + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 43, + precise_amount_cents: 42.85714285714286, + amount_currency: plan.amount_currency, + units: 1 + ) + end + end + + context "with a next subscription" do + before do + create(:subscription, previous_subscription: subscription) + end + + it "creates a fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 61, # 100/31 * 19 + precise_amount_cents: 61.29032258064516, # 100/31 * 19 + amount_currency: plan.amount_currency, + units: 1 + ) + end + end + + context "when plan has trial period" do + before do + plan.update(trial_period: trial_duration) + create(:subscription, previous_subscription: subscription) + end + + context "when trial end before termination date" do + let(:trial_duration) { 3 } + + it "creates a fee with prorated amount based on trial period" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 6, # 2 days * (100 / 31) + precise_amount_cents: 6.451612903225806 # 2 days * (100 / 31) + ) + end + end + + context "when trial end after termination date" do + let(:trial_duration) { 45 } + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + end + + context "when billing a new upgraded subscription" do + let(:previous_plan) { create(:plan, pay_in_advance: true, amount_cents: 80) } + let(:previous_subscription) do + create( + :subscription, + status: :terminated, + plan: previous_plan, + started_at: started_at - 6.months, + customer:, + external_id: "sub_id" + ) + end + let(:started_at) { Time.zone.parse("2022-03-15 00:00:00") } + + let(:subscription) do + create( + :subscription, + plan:, + started_at:, + subscription_at:, + previous_subscription:, + customer:, + external_id: "sub_id" + ) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at.beginning_of_day, + to_datetime: subscription.started_at.end_of_month.end_of_day, + timestamp: (subscription.started_at.end_of_month + 1.day).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + before { previous_plan.update!(pay_in_advance: false) } + + it "creates a subscription fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 55, + precise_amount_cents: 54.83870967741935, + amount_currency: plan.amount_currency, + units: 1 + ) + end + + context "with customer timezone" do + let(:customer) { create(:customer, organization:, timezone: "Europe/Paris") } + let(:from_datetime) do + subscription.started_at.in_time_zone(customer.applicable_timezone).beginning_of_day.utc + end + let(:to_datetime) do + subscription.started_at.end_of_month.to_date.in_time_zone(customer.applicable_timezone).end_of_day.utc + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime:, + to_datetime:, + timestamp: (subscription.started_at + 17.days).to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + it "creates a subscription fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + id: String, + invoice_id: invoice.id, + amount_cents: 55, + precise_amount_cents: 54.83870967741935, + amount_currency: plan.amount_currency, + units: 1 + ) + end + end + + context "when plan has trial period" do + before { plan.update(trial_period: trial_duration) } + + context "when trial period end before period end" do + let(:trial_duration) { (subscription.started_at.to_date - previous_subscription.started_at.to_date).to_i + 3 } + + it "creates a fee with prorated amount based on the trial" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 45, + precise_amount_cents: 45.16129032258064 + ) + end + end + + context "when trial period end after period end" do + let(:trial_duration) do + (subscription.started_at.to_date - previous_subscription.started_at.to_date).to_i + 45 + end + + it "creates a fee with zero amount" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 0, + precise_amount_cents: 0.0 + ) + end + end + end + + context "when new plan is pay in advance" do + before do + plan.update(pay_in_advance: true) + subscription.previous_subscription.update(terminated_at: subscription.started_at) + end + + let(:boundaries) do + BillingPeriodBoundaries.new( + from_datetime: subscription.started_at, + to_datetime: subscription.started_at.end_of_month, + timestamp: subscription.started_at.to_i, + charges_from_datetime: subscription.started_at.beginning_of_day, + charges_to_datetime: subscription.started_at.end_of_month.end_of_day, + charges_duration: 30.days + ) + end + + it "creates a subscription fee" do + result = fees_subscription_service.call + + expect(result.fee).to have_attributes( + amount_cents: 55, + precise_amount_cents: 54.83870967741935 + ) + end + end + end +end diff --git a/spec/services/fees/update_service_spec.rb b/spec/services/fees/update_service_spec.rb new file mode 100644 index 0000000..5f699ef --- /dev/null +++ b/spec/services/fees/update_service_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Fees::UpdateService do + subject(:update_service) { described_class.new(fee:, params:) } + + let(:charge) { create(:standard_charge) } + let(:old_date) { Time.current - 3.days } + let(:fee) { create(:charge_fee, fee_type: "charge", pay_in_advance: true, invoice: nil, charge:, failed_at: old_date, succeeded_at: old_date, refunded_at: old_date) } + + let(:params) { {payment_status:} } + let(:payment_status) { "succeeded" } + + describe "call" do + it "updates the fee" do + result = update_service.call + + expect(result).to be_success + + expect(result.fee.payment_status).to eq("succeeded") + expect(result.fee.succeeded_at).to be_within(1.minute).of(Time.current) + expect(result.fee.failed_at).to be_nil + expect(result.fee.refunded_at).to be_nil + end + + context "when fee is nil" do + let(:fee) { nil } + + it "returns a not found failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("fee_not_found") + end + end + + context "when fee is part of an invoice" do + let(:fee) { create(:charge_fee, fee_type: "charge", invoice: create(:invoice)) } + + it "returns a not allowed failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoiced_fee") + end + end + + context "when payment_status is invalid" do + let(:payment_status) { "foo" } + + it "returns a validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_status]).to eq(["value_is_invalid"]) + end + end + + context "when payment_status is failed" do + let(:payment_status) { "failed" } + + it "updates the fee" do + result = update_service.call + + expect(result).to be_success + + expect(result.fee.payment_status).to eq("failed") + expect(result.fee.failed_at).to be_within(1.minute).of(Time.current) + expect(result.fee.refunded_at).to be_nil + expect(result.fee.succeeded_at).to be_nil + end + end + + context "when payment_status is refunded" do + let(:payment_status) { "refunded" } + + it "updates the fee" do + result = update_service.call + + expect(result).to be_success + + expect(result.fee.payment_status).to eq("refunded") + expect(result.fee.refunded_at).to be_within(1.minute).of(Time.current) + expect(result.fee.succeeded_at).to be_nil + expect(result.fee.failed_at).to be_nil + end + end + end +end diff --git a/spec/services/fixed_charge_events/aggregations/preview_aggregation_service_spec.rb b/spec/services/fixed_charge_events/aggregations/preview_aggregation_service_spec.rb new file mode 100644 index 0000000..f8a6639 --- /dev/null +++ b/spec/services/fixed_charge_events/aggregations/preview_aggregation_service_spec.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedChargeEvents::Aggregations::PreviewAggregationService do + subject(:result) { described_class.call(fixed_charge:, subscription:, boundaries:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + units: 5, + properties: {amount: "10"} + ) + end + let(:subscription) do + Subscription.new( + organization_id: organization.id, + customer:, + plan:, + subscription_at: Time.current, + started_at: Time.current, + billing_time: "calendar" + ) + end + let(:fixed_charges_from_datetime) { Time.current } + let(:fixed_charges_to_datetime) { 1.month.from_now.to_datetime } + let(:boundaries) do + { + "fixed_charges_from_datetime" => fixed_charges_from_datetime, + "fixed_charges_to_datetime" => fixed_charges_to_datetime, + "fixed_charges_duration" => 30 + } + end + + it "returns the fixed_charge units" do + expect(result).to be_success + expect(result.aggregation).to eq(5) + expect(result.full_units_number).to eq(5) + end + + context "when fixed_charge has different units" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "graduated", + units: 15, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, flat_amount: "0", per_unit_amount: "1"}, + {from_value: 11, to_value: nil, flat_amount: "0", per_unit_amount: "0.5"} + ] + } + ) + end + + it "returns the fixed_charge units" do + expect(result).to be_success + expect(result.aggregation).to eq(15) + expect(result.full_units_number).to eq(15) + end + end + + context "when fixed_charge has zero units" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + units: 0, + properties: {amount: "10"} + ) + end + + it "returns zero" do + expect(result).to be_success + expect(result.aggregation).to eq(0) + expect(result.full_units_number).to eq(0) + end + end + + context "when subscription is persisted" do + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + + it "still returns the fixed_charge units" do + expect(result).to be_success + expect(result.aggregation).to eq(5) + expect(result.full_units_number).to eq(5) + end + end + + context "when fixed_charge is prorated" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + prorated: true, + units: 100, + properties: {amount: "10"} + ) + end + let(:fixed_charges_from_datetime) { Time.zone.parse("2024-06-01 00:00:00") } + let(:fixed_charges_to_datetime) { Time.zone.parse("2024-12-31 23:59:59") } + let(:boundaries) do + { + "fixed_charges_from_datetime" => fixed_charges_from_datetime, + "fixed_charges_to_datetime" => fixed_charges_to_datetime, + "fixed_charges_duration" => 365 # Full year + } + end + + it "returns prorated units based on billing period" do + expect(result).to be_success + + # Billing period: June 1 - Dec 31 = 214 days + # Full period: 365 days + # Prorated units: 100 * (214 / 365) ≈ 58.63 + expect(result.aggregation).to be_within(0.01).of(58.63) + expect(result.full_units_number).to eq(100) + end + + context "with partial month billing period" do + let(:fixed_charges_from_datetime) { Time.zone.parse("2024-03-15 00:00:00") } + let(:fixed_charges_to_datetime) { Time.zone.parse("2024-03-31 23:59:59") } + let(:boundaries) do + { + "fixed_charges_from_datetime" => fixed_charges_from_datetime, + "fixed_charges_to_datetime" => fixed_charges_to_datetime, + "fixed_charges_duration" => 31 # Full month (March) + } + end + + it "returns prorated units for partial period" do + expect(result).to be_success + + # Billing period: March 15-31 = 17 days + # Full period: 31 days (March) + # Prorated units: 100 * (17 / 31) ≈ 54.84 + expect(result.aggregation).to be_within(0.01).of(54.84) + expect(result.full_units_number).to eq(100) + end + end + end +end diff --git a/spec/services/fixed_charge_events/aggregations/prorated_aggregation_service_spec.rb b/spec/services/fixed_charge_events/aggregations/prorated_aggregation_service_spec.rb new file mode 100644 index 0000000..ac96b97 --- /dev/null +++ b/spec/services/fixed_charge_events/aggregations/prorated_aggregation_service_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedChargeEvents::Aggregations::ProratedAggregationService do + subject { described_class.new(fixed_charge:, subscription:, boundaries:) } + + let(:fixed_charge) { create(:fixed_charge) } + let(:subscription) { create(:subscription) } + let(:fixed_charges_from_datetime) { 9.days.ago } # total duration is 10 days + let(:fixed_charges_to_datetime) { Time.current } + let(:fixed_charges_duration) { 10 } + let(:boundaries) do + { + "fixed_charges_from_datetime" => fixed_charges_from_datetime, + "fixed_charges_to_datetime" => fixed_charges_to_datetime, + "fixed_charges_duration" => fixed_charges_duration + } + end + + context "when there are no events" do + it "returns 0" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(0) + end + end + + context "when there are events only in this period" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 2, timestamp: 4.days.ago, created_at: 4.days.ago) + end + + before { events } + + # the result is 2 * 5/10 = 1 + it "returns the prorated aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(1) + end + end + + context "when there are only events in the previous period" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: 30.days.ago, created_at: 30.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 100, timestamp: 15.days.ago, created_at: 15.days.ago) + end + + before { events } + + # the result is 100 * 10/10 = 100 + it "returns the prorated aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(100) + end + end + + context "when there are events in the previous period and in this" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 100, timestamp: 15.days.ago, created_at: 15.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: Time.current, created_at: Time.current) + end + + before { events } + + # the result is 100 * 9/10 + 10 * 1/10 = 91 + it "returns the prorated aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(91) + end + end + + context "when events are issued for the next billing period" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 20, timestamp: 10.days.ago, created_at: 10.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: 1.day.from_now, created_at: 5.days.ago) + end + + before { events } + + context "when aggregating for current period" do + it "returns the prorated aggregation" do + # 20 * 10/10 = 20 + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(20) + end + end + + context "when aggregating for the next billing period" do + let(:fixed_charges_from_datetime) { 1.day.from_now } # total duration is 10 days + let(:fixed_charges_to_datetime) { 10.days.from_now } + + it "returns the prorated aggregation" do + # 10 * 10/10 = 10 + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(10) + end + end + + context "when last event is issued after event for the next billing period cancels event for the next billing period" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 20, timestamp: 10.days.ago, created_at: 10.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: 5.days.from_now, created_at: 5.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 100, timestamp: Time.current, created_at: Time.current) + end + + context "when aggregating for the current period" do + it "returns the prorated aggregation" do + # 20 * 9/10 + 100 * 1/10 = 28 + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(28) + end + end + + context "when aggregating for the next billing period" do + let(:fixed_charges_from_datetime) { 1.day.from_now } # total duration is 10 days + let(:fixed_charges_to_datetime) { 10.days.from_now } + + it "returns the prorated aggregation erasing the event for the next billing period created before last event of this billing period" do + # 100 * 10/10 = 100 + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(100) + end + end + end + + context "when having a lot of events issued for this and following billing periods" do + let(:events_matrix) do + [ + {units: 30, timestamp: Date.new(2024, 1, 1), created_at: Date.new(2025, 1, 1)}, # 1 Jan this year for 1 Jan last year + {units: 10, timestamp: Date.new(2025, 1, 1), created_at: Date.new(2025, 1, 9)}, # 9 Jan for 1 Jan + {units: 5, timestamp: Date.new(2025, 2, 1), created_at: Date.new(2025, 1, 5)}, # 5 Jan for 1 Feb + {units: 77, timestamp: Date.new(2025, 1, 22), created_at: Date.new(2025, 1, 7)}, # 7 Jan for 22 Jan + {units: 7, timestamp: Date.new(2025, 1, 20), created_at: Date.new(2025, 1, 10)}, # 10 Jan for 20 Jan + {units: 12, timestamp: Date.new(2025, 3, 1), created_at: Date.new(2025, 1, 20)}, # 20 Jan for 1 Mar + {units: 70, timestamp: Date.new(2025, 2, 10), created_at: Date.new(2025, 1, 30)} # 30 Jan for 10 Feb + ] + end + + let(:events) do + events_matrix.map do |event| + create(:fixed_charge_event, fixed_charge:, subscription:, **event) + end + end + + context "when billing period is December last year" do # event is created after billed billing period for timestamp before the billing period + let(:fixed_charges_from_datetime) { Date.new(2024, 12, 1) } + let(:fixed_charges_to_datetime) { Date.new(2024, 12, 31) } + let(:fixed_charges_duration) { 31 } + + it "returns the prorated aggregation" do + # 30 * 31/31 = 30 + result = subject.call + expect(result).to be_success + expect(result.aggregation.round(2)).to eq(30) + end + end + + context "when billing period is January" do + let(:fixed_charges_from_datetime) { Date.new(2025, 1, 1) } + let(:fixed_charges_to_datetime) { Date.new(2025, 1, 31) } + let(:fixed_charges_duration) { 31 } + + it "returns the prorated aggregation" do + # 10 * 19/31 + 7 * 12/31 = 8.8387 + result = subject.call + expect(result).to be_success + expect(result.aggregation.round(2)).to eq(8.84) + end + end + + context "when billing period is February" do + let(:fixed_charges_from_datetime) { Date.new(2025, 2, 1) } + let(:fixed_charges_to_datetime) { Date.new(2025, 2, 28) } + let(:fixed_charges_duration) { 28 } + + it "returns the prorated aggregation" do + # 7 * 9/28 + 70 * 19/28 = 49.75 + result = subject.call + expect(result).to be_success + expect(result.aggregation.round(2)).to eq(49.75) + end + end + + context "when billing period is March" do + let(:fixed_charges_from_datetime) { Date.new(2025, 3, 1) } + let(:fixed_charges_to_datetime) { Date.new(2025, 3, 31) } + let(:fixed_charges_duration) { 31 } + + it "returns the prorated aggregation" do + # 70 * 31/31 = 70 + result = subject.call + expect(result).to be_success + expect(result.aggregation.round(2)).to eq(70) + end + end + end + end + + context "when an override was created after subscription started" do + let(:parent_charge) { create(:fixed_charge) } + let(:fixed_charge) { create(:fixed_charge, parent: parent_charge) } + let(:parent_event) { create(:fixed_charge_event, fixed_charge: parent_charge, subscription:, units: 10, timestamp: 12.days.ago, created_at: 10.days.ago) } + + before { parent_event } + + context "when there are only events for the parent charge" do + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(10) + end + end + + context "when there are events for the parent and child charges" do + let(:child_event) { create(:fixed_charge_event, fixed_charge:, subscription:, units: 5, timestamp: 4.days.ago, created_at: 10.days.ago) } + + before { child_event } + + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(7.5) + expect(result.full_units_number).to eq(5) + end + end + end +end diff --git a/spec/services/fixed_charge_events/aggregations/simple_aggregation_service_spec.rb b/spec/services/fixed_charge_events/aggregations/simple_aggregation_service_spec.rb new file mode 100644 index 0000000..49d6bd3 --- /dev/null +++ b/spec/services/fixed_charge_events/aggregations/simple_aggregation_service_spec.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedChargeEvents::Aggregations::SimpleAggregationService do + subject { described_class.new(fixed_charge:, subscription:, boundaries:) } + + let(:fixed_charge) { create(:fixed_charge) } + let(:subscription) { create(:subscription) } + let(:fixed_charges_from_datetime) { 9.days.ago } + let(:fixed_charges_to_datetime) { Time.current } + let(:events) { [] } + let(:boundaries) do + { + "fixed_charges_from_datetime" => fixed_charges_from_datetime, + "fixed_charges_to_datetime" => fixed_charges_to_datetime, + "fixed_charges_duration" => 10 + } + end + + before { events } + + context "when there are no events" do + it "returns 0" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(0) + end + end + + context "when there are events only in this period" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: 4.days.ago) + end + + before { events } + + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(10) + end + end + + context "when there are events only in the previous period" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: 30.days.ago, created_at: 30.days.ago) + end + + before { events } + + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(10) + end + end + + context "when there are events in the previous period and in this" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: 30.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 100, timestamp: 4.days.ago) + end + + before { events } + + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(100) + end + end + + context "when last event is issued after event for the next billing period cancels event for the next billing period" do + let(:events) do + create(:fixed_charge_event, fixed_charge:, subscription:, units: 20, timestamp: 10.days.ago, created_at: 10.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 10, timestamp: 5.days.from_now, created_at: 5.days.ago) + create(:fixed_charge_event, fixed_charge:, subscription:, units: 100, timestamp: Time.current, created_at: Time.current) + end + + context "when aggregating for the current period" do + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(100) + end + end + + context "when aggregating for the next billing period" do + let(:fixed_charges_from_datetime) { 1.day.from_now } # total duration is 10 days + let(:fixed_charges_to_datetime) { 10.days.from_now } + + it "returns the simple aggregation erasing the event for the next billing period created before last event of this billing period" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(100) + end + end + end + + context "when having a lot of events issued for this and following billing periods" do + let(:events_matrix) do + [ + {units: 30, timestamp: Date.new(2024, 1, 1), created_at: Date.new(2025, 1, 1)}, # 1 Jan this year for 1 Jan last year + {units: 10, timestamp: Date.new(2025, 1, 1), created_at: Date.new(2025, 1, 9)}, # 9 Jan for 1 Jan + {units: 5, timestamp: Date.new(2025, 2, 1), created_at: Date.new(2025, 1, 5)}, # 5 Jan for 1 Feb + {units: 77, timestamp: Date.new(2025, 1, 22), created_at: Date.new(2025, 1, 7)}, # 7 Jan for 22 Jan + {units: 7, timestamp: Date.new(2025, 1, 20), created_at: Date.new(2025, 1, 10)}, # 10 Jan for 20 Jan + {units: 12, timestamp: Date.new(2025, 3, 1), created_at: Date.new(2025, 1, 20)}, # 20 Jan for 1 Mar + {units: 70, timestamp: Date.new(2025, 2, 10), created_at: Date.new(2025, 1, 30)} # 30 Jan for 10 Feb + ] + end + + let(:events) do + events_matrix.map do |event| + create(:fixed_charge_event, fixed_charge:, subscription:, **event) + end + end + + context "when billing period is December last year" do # event is created after billed billing period for timestamp before the billing period + let(:fixed_charges_from_datetime) { Date.new(2024, 12, 1) } + let(:fixed_charges_to_datetime) { Date.new(2024, 12, 31) } + let(:fixed_charges_duration) { 31 } + + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(30) + end + end + + context "when billing period is January" do + let(:fixed_charges_from_datetime) { Date.new(2025, 1, 1) } + let(:fixed_charges_to_datetime) { Date.new(2025, 1, 31) } + + it "returns the simple aggregation with latest created at and timestamp" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(7) + end + end + + context "when billing period is February" do + let(:fixed_charges_from_datetime) { Date.new(2025, 2, 1) } + let(:fixed_charges_to_datetime) { Date.new(2025, 2, 28) } + + it "returns the simple aggregation with latest created at and timestamp" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(70) + end + end + + context "when billing period is March" do + let(:fixed_charges_from_datetime) { Date.new(2025, 3, 1) } + let(:fixed_charges_to_datetime) { Date.new(2025, 3, 31) } + + it "returns the simple aggregation with latest created at and timestamp" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(70) + end + end + end + + context "when an override was created after subscription started" do + let(:parent_charge) { create(:fixed_charge) } + let(:fixed_charge) { create(:fixed_charge, parent: parent_charge) } + let(:parent_event) { create(:fixed_charge_event, fixed_charge: parent_charge, subscription:, units: 10, timestamp: 12.days.ago, created_at: 10.days.ago) } + + before { parent_event } + + context "when there are only events for the parent charge" do + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(10) + end + end + + context "when there are events for the parent and child charges" do + let(:child_event) { create(:fixed_charge_event, fixed_charge:, subscription:, units: 5, timestamp: 8.days.ago, created_at: 10.days.ago) } + + before { child_event } + + it "returns the simple aggregation" do + result = subject.call + expect(result).to be_success + expect(result.aggregation).to eq(5) + expect(result.full_units_number).to eq(5) + end + end + end +end diff --git a/spec/services/fixed_charge_events/create_service_spec.rb b/spec/services/fixed_charge_events/create_service_spec.rb new file mode 100644 index 0000000..e2a5898 --- /dev/null +++ b/spec/services/fixed_charge_events/create_service_spec.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedChargeEvents::CreateService do + subject(:create_service) do + described_class.new( + subscription:, + fixed_charge:, + timestamp: + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, organization:, plan:, add_on:) } + let(:timestamp) { Time.current } + + describe "#call" do + subject(:result) { create_service.call } + + it "creates a fixed charge event" do + expect { create_service.call } + .to change(FixedChargeEvent, :count).by(1) + end + + it "returns a successful result with the created fixed charge event" do + freeze_time do + expect(result).to be_success + expect(result.fixed_charge_event).to be_a(FixedChargeEvent) + expect(result.fixed_charge_event).to have_attributes( + organization:, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: + ) + end + end + + context "when required associations are missing" do + let(:subscription) { nil } + + it "returns a validation error" do + expect(result).to be_a_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end +end diff --git a/spec/services/fixed_charges/apply_taxes_service_spec.rb b/spec/services/fixed_charges/apply_taxes_service_spec.rb new file mode 100644 index 0000000..baabc13 --- /dev/null +++ b/spec/services/fixed_charges/apply_taxes_service_spec.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::ApplyTaxesService do + subject(:apply_service) { described_class.new(fixed_charge:, tax_codes:) } + + let(:plan) { create(:plan) } + let(:add_on) { create(:add_on, organization: plan.organization) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:tax1) { create(:tax, organization: plan.organization, code: "tax1") } + let(:tax2) { create(:tax, organization: plan.organization, code: "tax2") } + let(:tax_codes) { [tax1.code, tax2.code] } + + describe "call" do + it "applies taxes to the fixed charge" do + expect { apply_service.call }.to change { fixed_charge.applied_taxes.count }.from(0).to(2) + end + + it "unassigns existing taxes" do + existing = create(:fixed_charge_applied_tax, fixed_charge:) + apply_service.call + expect(FixedCharge::AppliedTax.find_by(id: existing.id)).to be_nil + end + + it "returns applied taxes" do + result = apply_service.call + expect(result.applied_taxes.count).to eq(2) + end + + context "when fixed charge is not found" do + let(:fixed_charge) { nil } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("fixed_charge_not_found") + end + end + + context "when tax is not found" do + let(:tax_codes) { ["unknown"] } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when applied tax is already present" do + it "does not create a new applied tax" do + create(:fixed_charge_applied_tax, fixed_charge:, tax: tax1) + expect { apply_service.call }.to change { fixed_charge.applied_taxes.count }.from(1).to(2) + end + end + + context "when trying to apply twice the same tax" do + let(:tax_codes) { [tax1.code, tax1.code] } + + it "assigns it only once" do + expect { apply_service.call }.to change { fixed_charge.applied_taxes.count }.from(0).to(1) + end + end + end +end diff --git a/spec/services/fixed_charges/cascade_child_plan_update_service_spec.rb b/spec/services/fixed_charges/cascade_child_plan_update_service_spec.rb new file mode 100644 index 0000000..9f0caf0 --- /dev/null +++ b/spec/services/fixed_charges/cascade_child_plan_update_service_spec.rb @@ -0,0 +1,448 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::CascadeChildPlanUpdateService do + subject(:result) { described_class.call(plan:, cascade_fixed_charges_payload:, timestamp:) } + + let(:organization) { create(:organization) } + let(:parent_plan) { create(:plan, organization:) } + let(:plan) { create(:plan, organization:, parent: parent_plan) } + let(:add_on) { create(:add_on, organization:) } + let(:timestamp) { Time.current.to_i } + let(:subscription) { create(:subscription, :pending, plan:) } + let(:parent_fixed_charge) { create(:fixed_charge, plan: parent_plan, add_on:, units: new_units) } + let(:new_units) { 5 } + let(:cascade_fixed_charges_payload) { [] } + + before do + subscription + + allow(FixedCharges::CreateService).to receive(:call!).and_call_original + allow(FixedCharges::UpdateService).to receive(:call!).and_call_original + end + + describe "adding fixed charges" do + let(:cascade_fixed_charges_payload) do + [ + { + action: :create, + parent_id: parent_fixed_charge.id, + code: parent_fixed_charge.code, + add_on_id: add_on.id, + charge_model: "standard", + units: new_units, + properties: {amount: "100"}, + invoice_display_name: "Test Fixed Charge", + pay_in_advance: true, + prorated: false, + apply_units_immediately: true + } + ] + end + + it "calls create service with correct parameters" do + result + + expect(FixedCharges::CreateService).to have_received(:call!).with( + plan:, + params: cascade_fixed_charges_payload.first, + timestamp: + ) + end + + it "creates a new fixed charge for the plan" do + expect { result }.to change(plan.fixed_charges, :count).by(1) + end + + it "returns success result" do + result + + expect(result).to be_success + expect(result.plan).to eq(plan) + end + + it "does not schedule invoice creation jobs for pay in advance fixed charges" do + expect { result }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + + context "when plan has active subscriptions" do + let(:subscription) { create(:subscription, :active, plan:) } + + it "schedules invoice creation jobs for each active subscription" do + expect { result }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + .with(subscription, timestamp) + end + end + + context "when plan has active subscriptions but no pay in advance fixed charges" do + let(:subscription) { create(:subscription, :active, plan:) } + let(:cascade_fixed_charges_payload) do + [ + { + action: :create, + parent_id: parent_fixed_charge.id, + code: parent_fixed_charge.code, + add_on_id: add_on.id, + charge_model: "standard", + units: new_units, + properties: {amount: "100"}, + invoice_display_name: "Test Fixed Charge", + pay_in_advance: false, + prorated: false, + apply_units_immediately: true + } + ] + end + + it "does not schedule invoice creation jobs" do + expect { result }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + + context "when fixed charge creation fails" do + before do + allow(FixedCharges::CreateService) + .to receive(:call!) + .and_raise(BaseService::FailedResult.new(BaseService::Result.new, "Failed to create fixed charge")) + end + + it "returns failed result" do + expect(result).to be_failure + end + end + end + + describe "updating fixed charges" do + let(:parent_fixed_charge) do + create( + :fixed_charge, + :pay_in_advance, + plan: parent_plan, + add_on:, + units: new_units, + properties: {amount: "25"}, + charge_model: "standard" + ) + end + + let(:existing_fixed_charge) do + create( + :fixed_charge, + :pay_in_advance, + plan:, + add_on:, + parent: parent_fixed_charge, + units: parent_fixed_charge.units, + properties: parent_fixed_charge.properties, + charge_model: parent_fixed_charge.charge_model + ) + end + + let(:cascade_fixed_charges_payload) do + [ + { + action: :update, + id: parent_fixed_charge.id, + add_on_id: add_on.id, + charge_model: "standard", + units: 10, + properties: {amount: "100"}, + invoice_display_name: "Test Fixed Charge", + pay_in_advance: true, + prorated: false, + apply_units_immediately: true, + old_parent_attrs: parent_fixed_charge.attributes.merge(units: 5).deep_symbolize_keys + } + ] + end + + before { existing_fixed_charge } + + it "returns success result" do + result + + expect(result).to be_success + expect(result.plan).to eq(plan) + end + + it "calls update service with correct params and cascade options" do + result + + expect(FixedCharges::UpdateService).to have_received(:call!).with( + fixed_charge: existing_fixed_charge, + params: cascade_fixed_charges_payload.first, + timestamp:, + cascade_options: { + cascade: true, + equal_properties: true + }, + trigger_billing: false + ) + end + + it "updates the existing fixed charge for the plan" do + result + + expect(existing_fixed_charge.reload).to have_attributes( + units: 10, + properties: {"amount" => "100"} + ) + end + + it "does not schedule invoice creation jobs for pay in advance fixed charges" do + expect { result }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + + context "when plan has active subscriptions" do + let(:subscription) { create(:subscription, :active, plan:) } + + it "schedules invoice creation jobs for each active subscription" do + expect { result }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + .with(subscription, timestamp) + end + end + + context "when plan has active subscriptions but no pay in advance fixed charges" do + let(:subscription) { create(:subscription, :active, plan:) } + let(:existing_fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + parent: parent_fixed_charge, + charge_model: "standard", + units: 33, + properties: {amount: "10"} + ) + end + + it "does not schedule invoice creation jobs" do + expect { result }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + + context "when fixed charge update fails" do + before do + allow(FixedCharges::UpdateService) + .to receive(:call!) + .and_raise(BaseService::FailedResult.new(BaseService::Result.new, "Failed to update fixed charge")) + end + + it "returns failed result" do + expect(result).to be_failure + end + end + + context "when fixed charge was overriden" do + let(:existing_fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + parent: parent_fixed_charge, + charge_model: "standard", + units: 33, + properties: {amount: "10"} + ) + end + + let(:cascade_fixed_charges_payload) do + [{ + action: :update, + id: parent_fixed_charge.id, + add_on_id: add_on.id, + charge_model: "standard", + units: new_units, + properties: {amount: "50"}, + old_parent_attrs: parent_fixed_charge.attributes.merge(units: 99).deep_symbolize_keys + }] + end + + it "passes equal_properties: false to update service" do + result + + expect(FixedCharges::UpdateService).to have_received(:call!).with( + fixed_charge: existing_fixed_charge, + params: cascade_fixed_charges_payload.first, + timestamp:, + cascade_options: { + cascade: true, + equal_properties: false + }, + trigger_billing: false + ) + end + end + + context "when fixed charge is not found" do + let(:cascade_fixed_charges_payload) do + [{ + action: :update, + id: "invalid_id" + }] + end + + it "returns not found failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("fixed_charge_not_found") + end + end + end + + describe "processing multiple fixed charges in the payload" do + let(:add_on_2) { create(:add_on, organization:) } + + let(:parent_fixed_charge_1) do + create( + :fixed_charge, + :pay_in_advance, + plan: parent_plan, + add_on:, + units: new_units, + properties: {amount: "25"}, + charge_model: "standard" + ) + end + + let(:parent_fixed_charge_2) do + create( + :fixed_charge, + plan: parent_plan, + add_on: add_on_2, + units: new_units, + properties: {amount: "55"}, + charge_model: "standard" + ) + end + + let(:existing_fixed_charge_2) do + create( + :fixed_charge, + :pay_in_advance, + plan:, + add_on: add_on_2, + parent: parent_fixed_charge_2, + units: parent_fixed_charge_2.units, + properties: parent_fixed_charge_2.properties, + charge_model: parent_fixed_charge_2.charge_model + ) + end + + let(:cascade_fixed_charges_payload) do + [ + { + action: :create, + parent_id: parent_fixed_charge.id, + code: parent_fixed_charge.code, + add_on_id: add_on.id, + charge_model: "standard", + units: new_units, + properties: {amount: "100"}, + invoice_display_name: "Test Fixed Charge", + pay_in_advance: true, + prorated: false, + apply_units_immediately: true + }, + { + action: :update, + id: parent_fixed_charge_2.id, + add_on_id: add_on.id, + charge_model: "standard", + units: new_units, + properties: {amount: "100"}, + invoice_display_name: "Test Fixed Charge", + pay_in_advance: true, + prorated: false, + apply_units_immediately: true, + old_parent_attrs: parent_fixed_charge_2.attributes.merge(units: 5).deep_symbolize_keys + } + ] + end + + before { existing_fixed_charge_2 } + + it "calls both create and update services" do + result + + expect(FixedCharges::CreateService).to have_received(:call!).with( + plan:, + params: cascade_fixed_charges_payload.first, + timestamp: + ) + + expect(FixedCharges::UpdateService).to have_received(:call!).with( + fixed_charge: existing_fixed_charge_2, + params: cascade_fixed_charges_payload.last, + timestamp:, + cascade_options: { + cascade: true, + equal_properties: true + }, + trigger_billing: false + ) + end + end + + context "with an unknown action" do + let(:cascade_fixed_charges_payload) do + [ + { + action: :create, + parent_id: parent_fixed_charge.id, + code: parent_fixed_charge.code, + add_on_id: add_on.id, + charge_model: "standard", + units: 10, + properties: {amount: "100"}, + invoice_display_name: "Test Fixed Charge", + pay_in_advance: true, + prorated: false, + apply_units_immediately: true + }, + { + action: :invalid_action, + parent_id: create(:fixed_charge, plan: parent_plan).id, + add_on_id: add_on.id, + charge_model: "standard", + units: 10, + properties: {amount: "100"}, + invoice_display_name: "Another Test Fixed Charge", + pay_in_advance: true, + prorated: false, + apply_units_immediately: true + } + ] + end + + it "raises an error with the unknown action" do + expect(result).to be_failure + expect(result.error).to eq("Unknown action invalid_action for fixed charge cascade") + end + + it "does not create any fixed charges" do + expect { result }.not_to change { plan.fixed_charges.count } + end + + it "does not schedule invoice creation jobs for pay in advance fixed charges" do + expect { result }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + + context "with empty payload" do + let(:cascade_fixed_charges_payload) { [] } + + it "returns success result without creating or updating anything" do + expect { result }.not_to change { plan.fixed_charges.count } + expect(result).to be_success + end + + it "schedules invoice jobs if active subscriptions exist" do + create(:fixed_charge, plan:, pay_in_advance: true) + create(:subscription, :active, plan:) + + expect { result }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end +end diff --git a/spec/services/fixed_charges/create_children_service_spec.rb b/spec/services/fixed_charges/create_children_service_spec.rb new file mode 100644 index 0000000..c3315b4 --- /dev/null +++ b/spec/services/fixed_charges/create_children_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::CreateChildrenService do + subject(:create_service) { described_class.new(child_ids:, fixed_charge:, payload:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, organization:, plan:, add_on:) } + + let(:child_plan) { create(:plan, organization:, parent_id: plan.id) } + let(:child_ids) { child_plan.id } + + let(:payload) { {} } + + before do + fixed_charge + child_plan + end + + describe "#call" do + context "when fixed_charge is not found" do + let(:fixed_charge) { nil } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("fixed_charge_not_found") + end + end + + context "when child fixed charge is successfully added" do + let(:payload) do + { + add_on_id: add_on.id, + charge_model: "standard", + pay_in_advance: false, + prorated: false, + units: 5, + invoice_display_name: "Test Fixed Charge" + } + end + + it "creates new fixed charge" do + expect { create_service.call }.to change(FixedCharge, :count).by(1) + end + + it "does not touch plan" do + freeze_time do + expect { create_service.call }.not_to change { child_plan.reload.updated_at } + end + end + + it "sets correctly attributes" do + create_service.call + + stored_fixed_charge = child_plan.reload.fixed_charges.first + + expect(stored_fixed_charge).to have_attributes( + organization_id: organization.id, + add_on_id: add_on.id, + prorated: false, + pay_in_advance: false, + parent_id: fixed_charge.id, + units: 5, + invoice_display_name: "Test Fixed Charge" + ) + end + end + + context "when payload has no code" do + let(:payload) do + { + add_on_id: add_on.id, + charge_model: "standard" + } + end + + it "uses the parent fixed_charge code" do + create_service.call + + stored_fixed_charge = child_plan.reload.fixed_charges.first + expect(stored_fixed_charge.code).to eq(fixed_charge.code) + end + end + + context "when payload has a code" do + let(:payload) do + { + add_on_id: add_on.id, + code: "custom_code", + charge_model: "standard" + } + end + + it "uses the provided code" do + create_service.call + + stored_fixed_charge = child_plan.reload.fixed_charges.first + expect(stored_fixed_charge.code).to eq("custom_code") + end + end + end +end diff --git a/spec/services/fixed_charges/create_service_spec.rb b/spec/services/fixed_charges/create_service_spec.rb new file mode 100644 index 0000000..a3da9b6 --- /dev/null +++ b/spec/services/fixed_charges/create_service_spec.rb @@ -0,0 +1,461 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::CreateService do + subject(:create_service) { described_class.new(plan:, params:) } + + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + let(:add_on) { create(:add_on, organization:) } + + describe "#call" do + subject(:result) { create_service.call } + + context "when plan is not found" do + let(:plan) { nil } + let(:params) { {} } + + it "returns a failure" do + expect(result).to be_a_failure + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "when plan exists" do + context "when add_on is not found" do + let(:params) do + { + add_on_id: "non-existing-id", + charge_model: "standard" + } + end + + it "returns a failure" do + expect(result).to be_a_failure + expect(result.error.error_code).to eq("add_on_not_found") + end + + it "does not create fixed charge" do + expect { subject }.not_to change(FixedCharge, :count) + end + end + + context "when add_on_code is not found" do + let(:params) do + { + add_on_code: "non-existing-code", + charge_model: "standard" + } + end + + it "returns a failure" do + expect(result).to be_a_failure + expect(result.error.error_code).to eq("add_on_not_found") + end + + it "does not create fixed charge" do + expect { subject }.not_to change(FixedCharge, :count) + end + end + + context "when a fixed charge with the same code already exists on the plan" do + let(:params) do + { + add_on_id: add_on.id, + code: "existing_code", + charge_model: "standard", + properties: {amount: "100"} + } + end + + before do + create(:fixed_charge, plan:, add_on:, code: "existing_code") + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({code: ["value_already_exist"]}) + end + end + + context "when params are invalid" do + let(:params) do + {add_on_id: add_on.id} + end + + it "returns a failure" do + expect(result).to be_a_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + end + + it "does not create fixed charge" do + expect { subject }.not_to change(FixedCharge, :count) + end + end + + context "when params are valid" do + let(:parent_fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:tax1) { create(:tax, organization:, code: "tax1") } + let(:tax2) { create(:tax, organization:, code: "tax2") } + + before do + parent_fixed_charge + end + + context "when using add_on_id" do + let(:params) do + { + add_on_id: add_on.id, + code: "my_fixed_charge_code", + charge_model: "standard", + pay_in_advance: true, + prorated: true, + units: 5, + invoice_display_name: "Custom Display Name", + parent_id: parent_fixed_charge.id, + properties: {amount: "100"}, + tax_codes: [tax1.code, tax2.code] + } + end + + it "creates new fixed charge" do + expect { subject }.to change(FixedCharge, :count).by(1) + end + + it "sets correctly attributes" do + expect(result.fixed_charge).to have_attributes( + organization_id: organization.id, + plan_id: plan.id, + add_on_id: add_on.id, + code: "my_fixed_charge_code", + charge_model: "standard", + pay_in_advance: true, + prorated: true, + units: 5, + invoice_display_name: "Custom Display Name", + parent_id: parent_fixed_charge.id, + properties: {"amount" => "100"} + ) + end + + it "applies taxes when tax_codes are provided" do + expect { subject }.to change(FixedCharge::AppliedTax, :count).by(2) + + expect(result.fixed_charge.taxes.pluck(:code)).to match_array([tax1.code, tax2.code]) + end + + it "returns success result" do + expect(result).to be_success + expect(result.fixed_charge).to be_persisted + end + end + + context "when using add_on_code" do + let(:params) do + { + add_on_code: add_on.code, + code: "add_on_code_fixed_charge", + charge_model: "graduated", + pay_in_advance: false, + prorated: false, + units: 10 + } + end + + it "creates new fixed charge" do + expect { subject }.to change(FixedCharge, :count).by(1) + end + + it "sets correctly attributes" do + expect(result.fixed_charge).to have_attributes( + add_on_id: add_on.id, + charge_model: "graduated", + pay_in_advance: false, + prorated: false, + units: 10 + ) + end + end + + context "when providing both add_on_id and add_on_code" do + let(:other_add_on) { create(:add_on, organization:) } + let(:params) do + { + add_on_id: add_on.id, + add_on_code: other_add_on.code, + code: "both_ids_fixed_charge", + charge_model: "standard" + } + end + + it "prioritizes add_on_id over add_on_code" do + expect(result.fixed_charge.add_on_id).to eq(add_on.id) + end + end + + context "when no properties are provided" do + let(:params) do + { + add_on_id: add_on.id, + code: "no_props_fixed_charge", + charge_model: "standard" + } + end + + it "applies default properties" do + expect(result.fixed_charge.properties).to eq({"amount" => "0"}) + end + end + + context "when properties are provided" do + let(:params) do + { + add_on_id: add_on.id, + code: "graduated_fixed_charge", + charge_model: "graduated", + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "2", + flat_amount: "5" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "1.5", + flat_amount: "0" + } + ] + } + } + end + + it "uses provided properties" do + expect(result.fixed_charge.properties).to eq({ + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "per_unit_amount" => "2", + "flat_amount" => "5" + }, + { + "from_value" => 11, + "to_value" => nil, + "per_unit_amount" => "1.5", + "flat_amount" => "0" + } + ] + }) + end + end + + context "when no tax_codes are provided" do + let(:params) do + { + add_on_id: add_on.id, + code: "no_tax_fixed_charge", + charge_model: "standard" + } + end + + it "does not apply any taxes" do + expect { subject }.not_to change(FixedCharge::AppliedTax, :count) + end + end + + context "when tax application fails" do + let(:params) do + { + add_on_id: add_on.id, + code: "tax_fail_fixed_charge", + charge_model: "standard", + tax_codes: ["non-existing-tax"] + } + end + + it "rolls back the transaction" do + expect { subject }.not_to change(FixedCharge, :count) + end + + it "returns failure result" do + expect(result).to be_a_failure + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "with default values" do + let(:params) do + { + add_on_id: add_on.id, + code: "defaults_fixed_charge", + charge_model: "volume" + } + end + + it "sets default values for optional attributes" do + expect(result.fixed_charge).to have_attributes( + pay_in_advance: false, + prorated: false, + units: 0, + invoice_display_name: nil, + parent_id: nil + ) + end + end + + context "when add_on belongs to different organization" do + let(:other_organization) { create(:organization) } + let(:other_add_on) { create(:add_on, organization: other_organization) } + let(:params) do + { + add_on_id: other_add_on.id, + charge_model: "standard" + } + end + + it "returns a failure" do + expect(result).to be_a_failure + expect(result.error.error_code).to eq("add_on_not_found") + end + end + + context "when filtering properties with complex charge model" do + let(:params) do + { + add_on_id: add_on.id, + code: "filter_props_fixed_charge", + charge_model: "volume", + properties: { + volume_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "1.5", + flat_amount: "10" + } + ], + invalid_property: "should_be_filtered_out" + } + } + end + + it "filters out invalid properties" do + expect(result.fixed_charge.properties.keys).to eq(["volume_ranges"]) + expect(result.fixed_charge.properties["invalid_property"]).to be_nil + end + end + + context "when apply_units_immediately is true" do + let(:params) do + { + add_on_id: add_on.id, + code: "apply_immediately_fixed_charge", + charge_model: "standard", + apply_units_immediately: true + } + end + + before do + allow(FixedCharges::EmitEventsService) + .to receive(:call!) + end + + it "creates new fixed charge" do + expect { result }.to change(FixedCharge, :count).by(1) + + expect(result).to be_success + expect(result.fixed_charge).to be_persisted + end + + it "emits fixed charge events for all active subscriptions" do + result + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + apply_units_immediately: true, + timestamp: be_within(1.second).of(Time.current.to_i) + ) + .once + end + end + + context "when apply_units_immediately is false" do + let(:params) do + { + add_on_id: add_on.id, + code: "no_apply_immediately_fixed_charge", + charge_model: "standard", + apply_units_immediately: false + } + end + + before do + allow(FixedCharges::EmitEventsService) + .to receive(:call!) + end + + it "creates new fixed charge" do + expect { result }.to change(FixedCharge, :count).by(1) + expect(result).to be_success + expect(result.fixed_charge).to be_persisted + end + + it "emits fixed charge events for active subscriptions with apply_units_immediately false" do + result + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + apply_units_immediately: false, + timestamp: be_within(1.second).of(Time.current.to_i) + ) + .once + end + end + end + end + + context "when timestamp is provided" do + subject(:create_service) { described_class.new(plan:, params:, timestamp:) } + + let(:timestamp) { 2.days.ago.to_i } + let(:params) do + { + add_on_id: add_on.id, + code: "timestamp_fixed_charge", + charge_model: "standard", + units: 5, + properties: {amount: "100"}, + apply_units_immediately: true + } + end + + before do + allow(FixedCharges::EmitEventsService) + .to receive(:call!) + .and_call_original + end + + it "passes the custom timestamp to EmitEventsService" do + result + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + apply_units_immediately: true, + timestamp: + ) + .once + end + end + end +end diff --git a/spec/services/fixed_charges/destroy_children_service_spec.rb b/spec/services/fixed_charges/destroy_children_service_spec.rb new file mode 100644 index 0000000..f415f9c --- /dev/null +++ b/spec/services/fixed_charges/destroy_children_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::DestroyChildrenService do + subject(:destroy_service) { described_class.new(fixed_charge) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, :deleted, plan:, add_on:) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, plan: child_plan) } + let(:child_fixed_charge) do + create( + :fixed_charge, + plan: child_plan, + add_on:, + parent: fixed_charge, + properties: {amount: "100"} + ) + end + + before do + child_fixed_charge + subscription + end + + describe "#call" do + it "soft deletes the child fixed charge" do + freeze_time do + expect { destroy_service.call }.to change(FixedCharge, :count).by(-1) + .and change { child_fixed_charge.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "does not touch plan" do + freeze_time do + expect { destroy_service.call }.not_to change { child_plan.reload.updated_at } + end + end + + it "returns success with the fixed charge" do + result = destroy_service.call + + expect(result).to be_success + expect(result.fixed_charge).to eq(fixed_charge) + end + + context "when fixed charge is not found" do + let(:fixed_charge) { nil } + let(:child_fixed_charge) { nil } + + it "returns an empty result" do + result = destroy_service.call + + expect(result).to be_success + expect(result.fixed_charge).to be_nil + end + end + + context "when fixed charge is not deleted" do + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + + it "returns an empty result" do + result = destroy_service.call + + expect(result).to be_success + expect(result.fixed_charge).to be_nil + end + end + + context "when subscription is terminated" do + let(:subscription) { create(:subscription, plan: child_plan, status: :terminated) } + + it "does not delete the child fixed charge" do + expect { destroy_service.call }.not_to change(FixedCharge, :count) + end + end + end +end diff --git a/spec/services/fixed_charges/destroy_service_spec.rb b/spec/services/fixed_charges/destroy_service_spec.rb new file mode 100644 index 0000000..67919ea --- /dev/null +++ b/spec/services/fixed_charges/destroy_service_spec.rb @@ -0,0 +1,128 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::DestroyService do + subject(:destroy_service) { described_class.new(fixed_charge:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + + describe "#call" do + it "soft deletes the fixed charge" do + freeze_time do + expect { destroy_service.call }.to change(FixedCharge, :count).by(-1) + .and change { fixed_charge.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "returns the fixed charge in the result" do + result = destroy_service.call + + expect(result).to be_success + expect(result.fixed_charge).to eq(fixed_charge) + end + + context "when fixed charge is not found" do + let(:fixed_charge) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("fixed_charge_not_found") + end + end + + context "when fixed charge has associated records" do + let(:tax) { create(:tax, organization:) } + let(:applied_tax) { create(:fixed_charge_applied_tax, fixed_charge:, tax:) } + let(:child_fixed_charge) { create(:fixed_charge, plan:, add_on:, parent: fixed_charge) } + let(:fee) { create(:fee, fixed_charge:, organization:) } + + before do + applied_tax + child_fixed_charge + fee + end + + it "soft deletes the fixed charge and keeps associated records" do + freeze_time do + expect { destroy_service.call }.to change(FixedCharge, :count).by(-1) + .and change { fixed_charge.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "does not delete associated applied taxes" do + expect { destroy_service.call }.not_to change(FixedCharge::AppliedTax, :count) + end + + it "does not delete child fixed charges" do + expect { destroy_service.call }.not_to change { child_fixed_charge.reload.deleted_at } + end + + it "does not delete associated fees" do + expect { destroy_service.call }.not_to change { fee.reload.deleted_at } + end + end + + context "when fixed charge is already deleted" do + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, deleted_at: 1.day.ago) } + + it "returns error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("fixed_charge_already_deleted") + end + end + + context "with cascade_updates" do + subject(:destroy_service) { described_class.new(fixed_charge:, cascade_updates: true) } + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, add_on:, parent: fixed_charge) } + + before do + child_fixed_charge + allow(FixedCharges::DestroyChildrenJob).to receive(:perform_later) + end + + it "enqueues FixedCharges::DestroyChildrenJob" do + destroy_service.call + + expect(FixedCharges::DestroyChildrenJob).to have_received(:perform_later).with(fixed_charge.id) + end + + context "when fixed_charge has no children" do + before { child_fixed_charge.update!(parent_id: nil) } + + it "does not enqueue FixedCharges::DestroyChildrenJob" do + destroy_service.call + + expect(FixedCharges::DestroyChildrenJob).not_to have_received(:perform_later) + end + end + end + + context "without cascade_updates when fixed_charge has children" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, add_on:, parent: fixed_charge) } + + before do + child_fixed_charge + allow(FixedCharges::DestroyChildrenJob).to receive(:perform_later) + end + + it "does not enqueue FixedCharges::DestroyChildrenJob" do + destroy_service.call + + expect(FixedCharges::DestroyChildrenJob).not_to have_received(:perform_later) + end + end + end +end diff --git a/spec/services/fixed_charges/emit_events_service_spec.rb b/spec/services/fixed_charges/emit_events_service_spec.rb new file mode 100644 index 0000000..836fe54 --- /dev/null +++ b/spec/services/fixed_charges/emit_events_service_spec.rb @@ -0,0 +1,242 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::EmitEventsService do + subject(:service) do + described_class.new(fixed_charge:, subscription:) + end + + let(:subscription) { nil } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:, interval: :yearly, bill_fixed_charges_monthly: true) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + + let(:customer_1) { create(:customer, organization:) } + let(:customer_2) { create(:customer, organization:) } + let(:active_subscription_1) do + create( + :subscription, + :active, + :anniversary, + plan:, + customer: customer_1, + started_at: 2.days.ago, + subscription_at: 2.days.ago + ) + end + + let(:active_subscription_2) { + create( + :subscription, + :active, + :calendar, + plan:, + customer: customer_2, + started_at: 15.days.ago, + subscription_at: 2.years.ago + ) + } + let(:terminated_subscription) { create(:subscription, :terminated, plan:, customer: customer_1) } + + describe "#call" do + subject(:result) { service.call } + + before do + active_subscription_1 + active_subscription_2 + terminated_subscription + end + + it "returns success result" do + expect(result).to be_success + end + + it "creates fixed charge events for all active subscriptions" do + expect { result }.to change(FixedChargeEvent, :count).by(2) + + events = result.fixed_charge_events + expect(events.size).to eq(2) + + event_1 = events.find { |e| e.subscription_id == active_subscription_1.id } + event_2 = events.find { |e| e.subscription_id == active_subscription_2.id } + + expect(event_1.organization).to eq(active_subscription_1.organization) + expect(event_1.units).to eq(fixed_charge.units) + expect(event_1.timestamp).to be_within(1.second).of(active_subscription_1.started_at.beginning_of_day + 1.month) + + expect(event_2.organization).to eq(active_subscription_2.organization) + expect(event_2.units).to eq(fixed_charge.units) + expect(event_2.timestamp).to be_within(1.second).of(1.month.from_now.beginning_of_month) + end + + it "does not create events for terminated subscriptions" do + result + + expect(FixedChargeEvent.where(subscription: terminated_subscription, fixed_charge:)).not_to exist + end + + context "when there are incomplete subscriptions" do + let(:incomplete_subscription) do + create( + :subscription, + :incomplete, + :anniversary, + plan:, + customer: customer_1, + started_at: 1.day.ago, + subscription_at: 1.day.ago + ) + end + + before { incomplete_subscription } + + it "creates fixed charge events for incomplete subscriptions" do + expect { result }.to change(FixedChargeEvent, :count) + + expect(result.fixed_charge_events.map(&:subscription_id)).to include(incomplete_subscription.id) + end + end + + context "when a provided subscription is incomplete" do + let(:subscription) do + create(:subscription, :incomplete, :anniversary, plan:, started_at: 1.day.ago, subscription_at: 1.day.ago) + end + + it "creates fixed charge event for the incomplete subscription" do + expect { result }.to change(FixedChargeEvent, :count).by(1) + + expect(result.fixed_charge_events.size).to eq(1) + expect(result.fixed_charge_events.first.subscription_id).to eq(subscription.id) + end + end + + context "when there are no active subscriptions" do + let(:active_subscription_1) { nil } + let(:active_subscription_2) { nil } + + it "does not create any events" do + expect { result }.not_to change(FixedChargeEvent, :count) + end + + it "returns success result" do + expect(result).to be_success + end + end + + context "when a subscription is provided" do + let(:subscription) { create(:subscription, :active, :anniversary, plan:) } + let(:other_subscription) { create(:subscription, :active, plan:) } + + before do + subscription + other_subscription + end + + it "returns success result" do + expect(result).to be_success + end + + it "creates fixed charge event only for the provided subscription" do + expect { result }.to change(FixedChargeEvent, :count).by(1) + + event = FixedChargeEvent.find_by(subscription:, fixed_charge:) + expect(event).to be_present + expect(event.organization).to eq(subscription.organization) + expect(event.units).to eq(fixed_charge.units) + expect(event.timestamp).to be_within(1.second).of(subscription.started_at.beginning_of_day + 1.month) + end + + it "does not create events for other subscriptions on the same plan" do + result + + expect(FixedChargeEvent.where(subscription: other_subscription, fixed_charge:)).not_to exist + end + end + + context "when apply_units_immediately is true" do + subject(:service) do + described_class.new(fixed_charge:, subscription:, apply_units_immediately: true) + end + + it "creates fixed charge events for all active subscriptions with timestamp current Time" do + expect { result }.to change(FixedChargeEvent, :count).by(2) + + event_1 = FixedChargeEvent.find_by(subscription: active_subscription_1, fixed_charge:) + event_2 = FixedChargeEvent.find_by(subscription: active_subscription_2, fixed_charge:) + + expect(event_1).to be_present + expect(event_1.organization).to eq(active_subscription_1.organization) + expect(event_1.units).to eq(fixed_charge.units) + expect(event_1.timestamp).to be_within(1.second).of(Time.current) + + expect(event_2).to be_present + expect(event_2.organization).to eq(active_subscription_2.organization) + expect(event_2.units).to eq(fixed_charge.units) + expect(event_2.timestamp).to be_within(1.second).of(Time.current) + end + + context "when passing timestamp as datetime object" do + subject(:service) do + described_class.new( + fixed_charge:, + subscription:, + apply_units_immediately: true, + timestamp: + ) + end + + let(:timestamp) { 2.days.ago } + + it "creates fixed charge events for all active subscriptions" do + expect { result }.to change(FixedChargeEvent, :count).by(2) + + event_1 = FixedChargeEvent.find_by(subscription: active_subscription_1, fixed_charge:) + event_2 = FixedChargeEvent.find_by(subscription: active_subscription_2, fixed_charge:) + + expect(event_1).to be_present + expect(event_1.organization).to eq(active_subscription_1.organization) + expect(event_1.units).to eq(fixed_charge.units) + expect(event_1.timestamp).to eq(Time.zone.at(timestamp.to_i)) + + expect(event_2).to be_present + expect(event_2.organization).to eq(active_subscription_2.organization) + expect(event_2.units).to eq(fixed_charge.units) + expect(event_2.timestamp).to eq(Time.zone.at(timestamp.to_i)) + end + end + + context "when passing timestamp as integer" do + subject(:service) do + described_class.new( + fixed_charge:, + subscription:, + apply_units_immediately: true, + timestamp: + ) + end + + let(:timestamp) { 2.weeks.ago.to_i } + + it "creates fixed charge events for all active subscriptions" do + expect { result }.to change(FixedChargeEvent, :count).by(2) + + event_1 = FixedChargeEvent.find_by(subscription: active_subscription_1, fixed_charge:) + event_2 = FixedChargeEvent.find_by(subscription: active_subscription_2, fixed_charge:) + + expect(event_1).to be_present + expect(event_1.organization).to eq(active_subscription_1.organization) + expect(event_1.units).to eq(fixed_charge.units) + expect(event_1.timestamp).to eq(Time.zone.at(timestamp)) + + expect(event_2).to be_present + expect(event_2.organization).to eq(active_subscription_2.organization) + expect(event_2.units).to eq(fixed_charge.units) + expect(event_2.timestamp).to eq(Time.zone.at(timestamp)) + end + end + end + end +end diff --git a/spec/services/fixed_charges/generate_code_service_spec.rb b/spec/services/fixed_charges/generate_code_service_spec.rb new file mode 100644 index 0000000..e97cf54 --- /dev/null +++ b/spec/services/fixed_charges/generate_code_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::GenerateCodeService do + subject(:result) { described_class.call(plan:, add_on:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:, code: "setup_fee") } + + describe "#call" do + context "when no fixed charges exist for the plan" do + it "generates code without suffix" do + expect(result.code).to eq("setup_fee") + end + end + + context "when a fixed charge exists with the base code" do + before do + create(:fixed_charge, plan:, add_on:, code: "setup_fee") + end + + it "generates code with suffix _2" do + expect(result.code).to eq("setup_fee_2") + end + end + + context "when fixed charges exist with numeric suffixes" do + before do + create(:fixed_charge, plan:, add_on:, code: "setup_fee") + create(:fixed_charge, plan:, add_on:, code: "setup_fee_2") + end + + it "generates code with next available suffix" do + expect(result.code).to eq("setup_fee_3") + end + end + + context "when multiple fixed charges exist with gaps in suffixes" do + before do + create(:fixed_charge, plan:, add_on:, code: "setup_fee") + create(:fixed_charge, plan:, add_on:, code: "setup_fee_3") + create(:fixed_charge, plan:, add_on:, code: "setup_fee_5") + end + + it "generates code with suffix one greater than the maximum" do + expect(result.code).to eq("setup_fee_6") + end + end + + context "when fixed charges exist with similar but non-matching prefixes" do + before do + other_add_on = create(:add_on, organization:, code: "setup_fee_premium") + create(:fixed_charge, plan:, add_on: other_add_on, code: "setup_fee_premium") + end + + it "generates code without suffix" do + expect(result.code).to eq("setup_fee") + end + end + + context "when fixed charges exist without numeric suffixes" do + before do + create(:fixed_charge, plan:, add_on:, code: "setup_fee_custom") + end + + it "generates code without suffix" do + expect(result.code).to eq("setup_fee") + end + end + + context "when child fixed charges exist with base code" do + let(:parent_plan) { create(:plan, organization:) } + let(:parent_fixed_charge) { create(:fixed_charge, plan: parent_plan, add_on:, code: "setup_fee") } + + before do + create(:fixed_charge, plan:, add_on:, code: "setup_fee", parent: parent_fixed_charge) + end + + it "ignores child fixed charges and generates code without suffix" do + expect(result.code).to eq("setup_fee") + end + end + end +end diff --git a/spec/services/fixed_charges/override_service_spec.rb b/spec/services/fixed_charges/override_service_spec.rb new file mode 100644 index 0000000..3d47e54 --- /dev/null +++ b/spec/services/fixed_charges/override_service_spec.rb @@ -0,0 +1,320 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::OverrideService do + subject(:override_service) { described_class.new(fixed_charge:, params:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:plan2) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:tax) { create(:tax, organization:) } + + let(:fixed_charge) do + create( + :fixed_charge, + organization:, + plan:, + add_on:, + properties: {amount: "300"}, + invoice_display_name: "Original Display Name", + units: 5, + plan_id: plan.id + ) + end + + let(:params) do + { + properties: {amount: "200"}, + invoice_display_name: "Overridden Display Name", + units: 10, + tax_codes: [tax.code], + plan_id: plan2.id + } + end + + describe "#call" do + before { fixed_charge } + + context "when lago freemium" do + it "returns without overriding the fixed charge" do + expect { override_service.call }.not_to change(FixedCharge, :count) + end + end + + context "when lago premium", :premium do + before do + allow(FixedCharges::EmitEventsService).to receive(:call!) + end + + it "creates a fixed charge based on the given fixed charge" do + expect { override_service.call }.to change(FixedCharge, :count).by(1) + + new_fixed_charge = FixedCharge.order(:created_at).last + expect(new_fixed_charge).to have_attributes( + organization_id: organization.id, + plan_id: plan2.id, + add_on_id: add_on.id, + charge_model: fixed_charge.charge_model, + pay_in_advance: fixed_charge.pay_in_advance, + prorated: fixed_charge.prorated, + # Parent id + parent_id: fixed_charge.id, + # Overridden attributes + properties: {"amount" => "200"}, + invoice_display_name: "Overridden Display Name", + units: 10 + ) + expect(new_fixed_charge.taxes).to contain_exactly(tax) + end + + it "emits fixed charge events for all active subscriptions" do + result = override_service.call + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + subscription: nil, + apply_units_immediately: false + ) + .once + end + + context "when only properties are provided" do + let(:params) { {properties: {amount: "150"}, plan_id: plan2.id} } + + it "creates a fixed charge with only properties overridden" do + expect { override_service.call }.to change(FixedCharge, :count).by(1) + + new_fixed_charge = FixedCharge.order(:created_at).last + expect(new_fixed_charge).to have_attributes( + parent_id: fixed_charge.id, + properties: {"amount" => "150"}, + invoice_display_name: fixed_charge.invoice_display_name, + units: fixed_charge.units, + plan_id: plan2.id + ) + end + end + + context "when only invoice_display_name is provided" do + let(:params) { {invoice_display_name: "Custom Display Name", plan_id: plan2.id} } + + it "creates a fixed charge with only invoice_display_name overridden" do + expect { override_service.call }.to change(FixedCharge, :count).by(1) + + new_fixed_charge = FixedCharge.order(:created_at).last + expect(new_fixed_charge).to have_attributes( + parent_id: fixed_charge.id, + properties: fixed_charge.properties, + invoice_display_name: "Custom Display Name", + units: fixed_charge.units, + plan_id: plan2.id + ) + end + end + + context "when tax_codes are provided" do + let(:tax2) { create(:tax, organization:, code: "tax2") } + let(:params) { {tax_codes: [tax.code, tax2.code], plan_id: plan2.id} } + + before { tax2 } + + it "applies taxes to the new fixed charge" do + expect { override_service.call }.to change(FixedCharge, :count).by(1) + + new_fixed_charge = FixedCharge.order(:created_at).last + expect(new_fixed_charge.taxes).to contain_exactly(tax, tax2) + end + end + + context "when no params are provided but plan_id" do + let(:params) { {plan_id: plan2.id} } + + it "creates a fixed charge with no overrides for the new plan" do + expect { override_service.call }.to change(FixedCharge, :count).by(1) + + new_fixed_charge = FixedCharge.order(:created_at).last + expect(new_fixed_charge).to have_attributes( + parent_id: fixed_charge.id, + properties: fixed_charge.properties, + invoice_display_name: fixed_charge.invoice_display_name, + units: fixed_charge.units, + plan_id: plan2.id + ) + end + end + + context "when fixed charge has existing taxes" do + let(:existing_tax) { create(:tax, organization:, code: "existing_tax") } + + before do + create(:fixed_charge_applied_tax, fixed_charge:, tax: existing_tax) + end + + it "replaces existing taxes with new ones" do + expect { override_service.call }.to change(FixedCharge, :count).by(1) + + new_fixed_charge = FixedCharge.order(:created_at).last + expect(new_fixed_charge.taxes).to contain_exactly(tax) + expect(new_fixed_charge.taxes).not_to include(existing_tax) + end + end + + context "when validation fails" do + let(:params) { {units: -1, plan_id: plan2.id} } + + it "returns a validation failure" do + result = override_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect { override_service.call }.not_to change(FixedCharge, :count) + end + end + + context "when tax is not found" do + let(:params) { {tax_codes: ["non_existent_tax"], plan_id: plan2.id} } + + it "returns a failure" do + result = override_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect { override_service.call }.not_to change(FixedCharge, :count) + end + end + + context "when fixed charge is being overridden with a different charge model" do + let(:fixed_charge) do + create( + :fixed_charge, + :graduated, + organization:, + plan:, + add_on:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "5", flat_amount: "200"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "300"} + ] + } + ) + end + let(:params) do + { + charge_model: "standard", + properties: {amount: "200"}, + invoice_display_name: "Overridden Display Name", + units: 10, + tax_codes: [tax.code], + plan_id: plan2.id + } + end + + it "raises a forbidden error" do + result = override_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("cannot_override_charge_model") + end + end + + context "when override properties are invalid" do + let(:fixed_charge) do + create( + :fixed_charge, + :graduated, + organization:, + plan:, + add_on:, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "5", flat_amount: "200"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "300"} + ] + } + ) + end + let(:params) { {properties: {amount: 100}, plan_id: plan2.id} } + + it "returns a validation failure" do + result = override_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + # note: properties are being filtered for the matching charge model, + # and in case properties are not matching the charge model, they are fully filtered out + expect(result.error.messages[:properties]).to eq(["value_is_mandatory"]) + end + end + + context "when subscription parameter is passed during plan override" do + # Subscription update with plan override + subject(:override_service) { described_class.new(fixed_charge:, params:, subscription:) } + + let(:subscription) { create(:subscription, plan:, organization:) } + let(:params) do + { + units: 15, + plan_id: plan2.id + } + end + + before do + allow(FixedCharges::EmitEventsService) + .to receive(:call!) + end + + it "creates a fixed charge for the new plan" do + result = override_service.call + + expect(result).to be_success + + override_fixed_charge = result.fixed_charge + expect(override_fixed_charge.plan_id).to eq(plan2.id) + expect(override_fixed_charge.units).to eq(15) + end + + it "creates fixed charge events for the specific subscription" do + result = override_service.call + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + subscription:, + apply_units_immediately: false + ) + .once + end + + context "when apply_units_immediately is true" do + let(:params) do + { + units: 15, + plan_id: plan2.id, + apply_units_immediately: true + } + end + + it "creates fixed charge events for the specific subscription" do + result = override_service.call + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + subscription:, + apply_units_immediately: true + ) + .once + end + end + end + end + end +end diff --git a/spec/services/fixed_charges/update_children_service_spec.rb b/spec/services/fixed_charges/update_children_service_spec.rb new file mode 100644 index 0000000..f42c4ed --- /dev/null +++ b/spec/services/fixed_charges/update_children_service_spec.rb @@ -0,0 +1,133 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::UpdateChildrenService do + subject(:update_service) do + described_class.new( + fixed_charge:, + params:, + old_parent_attrs:, + child_ids: + ) + end + + let(:organization) { create(:organization) } + let(:add_on) { create(:add_on, organization:) } + let(:plan) { create(:plan, organization:) } + let(:old_parent_attrs) { fixed_charge&.attributes } + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + properties: {amount: "300"}, + units: 10 + ) + end + + let(:child_plan) { create(:plan, organization:, parent_id: plan.id) } + let(:child_fixed_charge) do + create( + :fixed_charge, + plan: child_plan, + add_on:, + parent_id: fixed_charge.id, + properties: {amount: "300"}, + units: 10 + ) + end + let(:child_ids) { [child_fixed_charge&.id] } + let(:params) do + { + charge_model: "standard", + properties: {amount: "400"}, + units: 20 + } + end + + describe "#call" do + context "when fixed_charge is not found" do + let(:fixed_charge) { nil } + let(:child_fixed_charge) { nil } + + it "returns an empty result" do + result = update_service.call + + expect(result).to be_success + expect(result.fixed_charge).to be_nil + end + end + + context "when fixed_charge has children that have not been modified" do + it "updates child fixed charge" do + update_service.call + + expect(child_fixed_charge.reload).to have_attributes( + properties: {"amount" => "400"}, + units: 20 + ) + end + + it "does not touch plan" do + freeze_time do + expect { update_service.call }.not_to change { child_plan.reload.updated_at } + end + end + end + + context "when fixed_charge has children that have been modified" do + let(:child_fixed_charge) do + create( + :fixed_charge, + plan: child_plan, + add_on:, + parent_id: fixed_charge.id, + properties: {amount: "500"}, + units: 15 + ) + end + + it "does not update fixed charge properties" do + update_service.call + + expect(child_fixed_charge.reload).to have_attributes( + properties: {"amount" => "500"}, + units: 15 + ) + end + end + + context "when fixed_charge has no children" do + let(:child_fixed_charge) do + create( + :fixed_charge, + plan: child_plan, + add_on:, + parent_id: nil, + properties: {amount: "300"}, + units: 10 + ) + end + + before do + allow(FixedCharges::UpdateService).to receive(:call!).and_call_original + end + + it "does not call the update service" do + update_service.call + + expect(FixedCharges::UpdateService).not_to have_received(:call!) + end + + it "does not update fixed charge properties" do + update_service.call + + expect(child_fixed_charge.reload).to have_attributes( + properties: {"amount" => "300"}, + units: 10 + ) + end + end + end +end diff --git a/spec/services/fixed_charges/update_service_spec.rb b/spec/services/fixed_charges/update_service_spec.rb new file mode 100644 index 0000000..4a5504a --- /dev/null +++ b/spec/services/fixed_charges/update_service_spec.rb @@ -0,0 +1,406 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FixedCharges::UpdateService do + subject(:update_service) do + described_class.new(fixed_charge:, params:, cascade_options:, timestamp:) + end + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:timestamp) { Time.current.to_i } + + let(:fixed_charge) do + create(:fixed_charge, plan:, add_on:, prorated: false, pay_in_advance: false, units: 10) + end + + let(:cascade_options) { {cascade: false} } + let(:params) do + { + charge_model: "standard", + invoice_display_name: "Updated Display Name", + units: 5, + prorated: true, + pay_in_advance: true, + properties: {amount: "200"} + } + end + + describe "#call" do + subject(:result) { update_service.call } + + context "when fixed_charge is missing" do + let(:fixed_charge) { nil } + + it "returns a not found failure" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("fixed_charge_not_found") + end + end + + context "when updating code to one that already exists on the plan" do + let(:params) do + { + charge_model: "standard", + code: "taken_code", + units: 10, + properties: {amount: "100"} + } + end + + before do + create(:fixed_charge, plan:, add_on:, code: "taken_code") + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({code: ["value_already_exist"]}) + end + end + + context "when fixed_charge exists" do + it "updates the fixed charge without updating pay_in_advance and prorated" do + expect(result).to be_success + expect(result.fixed_charge).to have_attributes( + charge_model: "standard", + invoice_display_name: "Updated Display Name", + units: 5, + prorated: false, + pay_in_advance: false, + properties: {"amount" => "200"} + ) + end + + context "when plan is attached to subscriptions" do + before do + create(:subscription, plan:) + end + + it "does not update charge_model" do + original_charge_model = fixed_charge.charge_model + params[:charge_model] = "graduated" + + expect(result).to be_success + expect(result.fixed_charge.charge_model).to eq(original_charge_model) + end + + it "does not update code" do + params[:code] = "updated_code" + + expect { result }.not_to change { fixed_charge.reload.code } + end + + it "does not apply taxes" do + tax = create(:tax, organization: plan.organization, code: "tax1") + params[:tax_codes] = [tax.code] + + expect(result).to be_success + expect(fixed_charge.reload.applied_taxes).to be_empty + end + end + + context "when plan is not attached to subscriptions" do + it "updates charge_model" do + params[:charge_model] = "graduated" + params[:properties] = { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "10", + flat_amount: "0" + } + ] + } + + expect(result).to be_success + expect(result.fixed_charge.charge_model).to eq("graduated") + end + + context "with code in the params" do + before { params[:code] = "updated_code" } + + it "updates fixed charge code" do + expect { result }.to change { fixed_charge.reload.code }.to("updated_code") + end + end + + context "when tax_codes are provided" do + let(:tax1) { create(:tax, organization: plan.organization, code: "tax1") } + let(:tax2) { create(:tax, organization: plan.organization, code: "tax2") } + + before do + params[:tax_codes] = [tax1.code, tax2.code] + end + + it "applies taxes to the fixed charge" do + expect { result }.to change { fixed_charge.reload.applied_taxes.count }.from(0).to(2) + end + + it "returns success" do + expect(result).to be_success + end + end + end + + context "when properties are not provided" do + let(:params) do + { + charge_model: "standard", + invoice_display_name: "Updated Display Name", + units: 5, + prorated: true + } + end + + it "uses default properties for the charge model" do + expect(result).to be_success + expect(result.fixed_charge.properties).to eq({"amount" => "0"}) + end + end + + context "when cascade is true" do + let(:cascade_options) { {cascade: true} } + + context "when charge_model is different" do + before do + params[:charge_model] = "graduated" + end + + it "returns early without updating" do + expect(result).to be_success + expect(result.fixed_charge).to be_nil + end + end + + context "when charge_model is the same" do + it "does not update the display name" do + expect(result).to be_success + expect(result.fixed_charge.invoice_display_name).not_to eq("Updated Display Name") + end + + context "with code in the params" do + before { params[:code] = "cascaded_code" } + + it "updates fixed charge code" do + expect { result }.to change { fixed_charge.reload.code }.to("cascaded_code") + end + end + + context "when equal_properties is true" do + let(:cascade_options) { {cascade: true, equal_properties: true} } + + it "updates properties and units" do + expect(result).to be_success + expect(result.fixed_charge.properties).to eq({"amount" => "200"}) + expect(result.fixed_charge.units).to eq(5) + end + end + + context "when equal_properties is false" do + it "does not update properties nor units" do + original_properties = fixed_charge.properties + expect(result).to be_success + expect(result.fixed_charge.properties).to eq(original_properties) + expect(result.fixed_charge.units).to eq(10) + end + end + end + + it "does not apply taxes" do + tax = create(:tax, organization: plan.organization, code: "tax1") + params[:tax_codes] = [tax.code] + + expect(result).to be_success + expect(fixed_charge.reload.applied_taxes).to be_empty + end + end + + context "with validation errors" do + let(:params) do + { + charge_model: "standard", + units: -1 # Invalid units + } + end + + it "returns a validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + + context "when tax service fails" do + let(:params) do + { + charge_model: "standard", + invoice_display_name: "Updated Display Name", + units: 5, + prorated: true, + properties: {amount: "200"}, + tax_codes: ["non_existent_tax"] + } + end + + it "returns the tax service error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when units have been changed" do + let(:params) do + { + charge_model: "standard", + apply_units_immediately: true, + units: fixed_charge.units + 15, + properties: {amount: "200"} + } + end + + before do + allow(FixedCharges::EmitEventsService) + .to receive(:call!) + end + + it "emits fixed charge events for all active subscriptions" do + result + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + apply_units_immediately: true, + timestamp: + ) + .once + end + + context "when fixed charge is pay_in_advance" do + let(:fixed_charge) do + create(:fixed_charge, plan:, add_on:, prorated: false, pay_in_advance: true, units: 10) + end + + let!(:subscription) { create(:subscription, plan:) } + + it "enqueues pay in advance billing job" do + result + + expect(Invoices::CreatePayInAdvanceFixedChargesJob) + .to have_been_enqueued + .with(subscription, timestamp) + end + end + + context "when apply_units_immediately is false" do + let(:params) do + { + charge_model: "standard", + apply_units_immediately: false, + units: fixed_charge.units + 15, + properties: {amount: "200"} + } + end + + it "emits fixed charge events for all active subscriptions" do + result + + expect(FixedCharges::EmitEventsService) + .to have_received(:call!) + .with( + fixed_charge: result.fixed_charge, + apply_units_immediately: false, + timestamp: + ) + .once + end + + it "does not enqueue pay in advance billing job" do + result + + expect(Invoices::CreatePayInAdvanceFixedChargesJob) + .not_to have_been_enqueued + end + end + end + + context "when units does not change" do + let(:params) do + { + charge_model: "standard", + apply_units_immediately: true, + units: fixed_charge.units, + properties: {amount: "200"} + } + end + + before do + allow(FixedCharges::EmitEventsService) + .to receive(:call!) + end + + it "does not emit any fixed charge events" do + result + + expect(FixedCharges::EmitEventsService) + .not_to have_received(:call!) + end + end + + context "with cascade_updates" do + subject(:update_service) do + described_class.new(fixed_charge:, params:, cascade_options:, timestamp:, cascade_updates: true) + end + + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, organization:, add_on:, parent: fixed_charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_fixed_charge + allow(FixedCharges::UpdateChildrenJob).to receive(:perform_later) + end + + it "triggers cascade update via FixedCharges::UpdateChildrenJob" do + result + + expect(FixedCharges::UpdateChildrenJob).to have_received(:perform_later).with( + params: hash_including("charge_model", "properties", "units"), + old_parent_attrs: hash_including("id" => fixed_charge.id) + ) + end + + context "when fixed_charge has no children" do + before { child_fixed_charge.update!(parent_id: nil) } + + it "does not trigger cascade update" do + result + + expect(FixedCharges::UpdateChildrenJob).not_to have_received(:perform_later) + end + end + end + + context "without cascade_updates when fixed_charge has children" do + let(:child_plan) { create(:plan, organization:, parent: plan) } + let(:child_fixed_charge) { create(:fixed_charge, plan: child_plan, organization:, add_on:, parent: fixed_charge) } + + before do + create(:subscription, plan: child_plan, status: :active) + child_fixed_charge + allow(FixedCharges::UpdateChildrenJob).to receive(:perform_later) + end + + it "does not trigger cascade update" do + result + + expect(FixedCharges::UpdateChildrenJob).not_to have_received(:perform_later) + end + end + end + end +end diff --git a/spec/services/idempotency_records/create_service_spec.rb b/spec/services/idempotency_records/create_service_spec.rb new file mode 100644 index 0000000..70384ae --- /dev/null +++ b/spec/services/idempotency_records/create_service_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IdempotencyRecords::CreateService do + subject(:result) { described_class.call(idempotency_key:, resource:) } + + let(:idempotency_key) { SecureRandom.uuid } + let(:resource) { create(:customer) } + + it "creates a new idempotency record" do + expect { result }.to change(IdempotencyRecord, :count).by(1) + expect(result).to be_success + + idempotency_record = result.idempotency_record + expect(idempotency_record.id).to be_present + expect(idempotency_record.idempotency_key).to eq(idempotency_key) + expect(idempotency_record.resource).to eq(resource) + end + + context "when idempotency record already exists" do + before do + IdempotencyRecord.create!( + idempotency_key: idempotency_key, + resource: resource + ) + end + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq(idempotency_key: ["already_exists"]) + end + end + + context "when resource is not provided" do + let(:resource) { nil } + + it "creates an idempotency record without a resource" do + expect { result }.to change(IdempotencyRecord, :count).by(1) + expect(result).to be_success + + idempotency_record = result.idempotency_record + expect(idempotency_record.resource).to be_nil + end + end +end diff --git a/spec/services/idempotency_records/key_service_spec.rb b/spec/services/idempotency_records/key_service_spec.rb new file mode 100644 index 0000000..91a9cd4 --- /dev/null +++ b/spec/services/idempotency_records/key_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IdempotencyRecords::KeyService do + subject(:result) { described_class.call(**key_parts) } + + let(:key_parts) { {} } + + describe "#call" do + it "returns the same value when called twice" do + expect(result.idempotency_key).to eq(described_class.call(*key_parts).idempotency_key) + end + + context "with one key_part" do + let(:key_parts) { {"key" => "value"} } + + it "returns the same value when called twice" do + expect(result.idempotency_key).to eq(described_class.call(**key_parts).idempotency_key) + end + + it "returns a different value if the value is different" do + key_parts2 = {"key" => "value2"} + expect(result.idempotency_key).not_to eq(described_class.call(**key_parts2).idempotency_key) + end + end + + context "with multiple key_parts" do + let(:key_parts) { {k1: "key1", k2: "key2"} } + let(:key_parts_reversed) { {k2: "key2", k1: "key1"} } + + it "returns the same value when called twice" do + expect(result.idempotency_key).to eq(described_class.call(**key_parts).idempotency_key) + end + + it "returns the same value if the order of the key_parts changes" do + expect(result.idempotency_key).to eq(described_class.call(**key_parts_reversed).idempotency_key) + end + end + + context "when key_parts overlap in content" do + let(:key_parts) { {key1: "k", key2: "ey"} } + let(:key_parts_overlap) { {key1: "ke", key2: "y"} } + + it "does not return the same value" do + expect(result.idempotency_key).not_to eq(described_class.call(**key_parts_overlap).idempotency_key) + end + end + end +end diff --git a/spec/services/idempotency_spec.rb b/spec/services/idempotency_spec.rb new file mode 100644 index 0000000..6f5f0be --- /dev/null +++ b/spec/services/idempotency_spec.rb @@ -0,0 +1,266 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Idempotency, transaction: false do + describe ".transaction" do + let(:customer) { create(:customer) } + let(:invoice) { create(:invoice) } + + context "when no components are added" do + it "raises an ArgumentError" do + expect do + described_class.transaction {} + end.to raise_error(ArgumentError, "At least one resource must be added") + end + end + + context "when already in a transaction" do + it "raises an ArgumentError" do + allow(ApplicationRecord.connection).to receive(:open_transactions).and_return(1) + + expect do + described_class.transaction do + # No operations + end + end.to raise_error(ArgumentError, "An idempotent_transaction cannot be created in a transaction. (1 open transactions)") + end + end + + context "when operation succeeds" do + it "executes the block" do + block_executed = false + + described_class.transaction do + block_executed = true + described_class.unique!(invoice, id: invoice.id, issuing_date: invoice.issuing_date) + end + + expect(block_executed).to be true + end + + it "creates an idempotency record with the correct key and resource" do + described_class.transaction do + described_class.unique!(invoice, id: invoice.id, issuing_date: invoice.issuing_date) + end + end + + it "supports multiple resources in the same transaction" do + described_class.transaction do + described_class.unique!(invoice, id: invoice.id, issuing_date: invoice.issuing_date) + described_class.unique!(customer, id: customer.id) + end + end + + it "supports multiple value arrays for the same resource" do + described_class.transaction do + described_class.unique!(invoice, id: invoice.id, issuing_date: invoice.issuing_date) + described_class.unique!(invoice, id: invoice.customer_id) + end + end + + it "returns the original result of the block" do + block_return_value = "expected return value" + + result = described_class.transaction do + described_class.unique!(invoice, id: invoice.id) + block_return_value + end + + expect(result).to eq(block_return_value) + end + end + + context "when returning early from the transaction" do + it "raises an error" do + # define method so we don't have a local jump error + def test_func + described_class.transaction do + return 1 # rubocop:disable Rails/TransactionExitStatement + end + end + + expect do + test_func + end.to raise_error(Idempotency::IdempotencyError, "You've returned early from an Idempotency transaction, please use `next` instead") + end + + it "does not create an idempotency_record" do + def test_func + described_class.transaction do + described_class.unique!(invoice, id: invoice.id) + return 1 # rubocop:disable Rails/TransactionExitStatement + end + end + + expect do + test_func + rescue # test_func raises so we rescue + nil + end.not_to change(IdempotencyRecord, :count) + end + + it "does not create a customer" do + def test_func + described_class.transaction do + create(:customer) + return 1 # rubocop:disable Rails/TransactionExitStatement + end + end + + expect do + test_func + rescue => e + expect(e).to be_instance_of(Idempotency::IdempotencyError) + end.not_to change(Customer, :count) + end + + it "does not create a customer when raising a rollback" do + def test_func + described_class.transaction do + create(:customer) + raise ActiveRecord::Rollback + end + end + + expect do + test_func + end.not_to change(Customer, :count) + + expect do + test_func + end.not_to raise_error + end + end + + context "when an idempotency error occurs" do + it "raises an IdempotencyError" do + # Execute the transaction once + described_class.transaction do + described_class.unique!(invoice, id: invoice.id) + end + + # This one should now fail! + expect do + described_class.transaction do + described_class.unique!(invoice, id: invoice.id) + end + end.to raise_error(Idempotency::IdempotencyError, "Idempotency key already exists for resource [#{invoice.to_gid}] based on {id: \"#{invoice.id}\"}.") + end + end + + context "when an exception occurs in the block" do + it "cleans up the transaction context" do + begin + described_class.transaction do + described_class.unique!(invoice, id: invoice.id) + raise "Test error" + end + rescue => e + expect(e).to be_instance_of(RuntimeError) + end + + expect(described_class.current_transaction).to be_nil + end + + it "propagates the exception" do + expect do + described_class.transaction do + described_class.unique!(invoice, id: invoice.id) + raise "Test error" + end + end.to raise_error("Test error") + end + end + end + + describe ".unique!" do + context "when called outside of a transaction" do + it "raises an ArgumentError" do + expect do + described_class.unique!("resource", key: "value") + end.to raise_error(ArgumentError, "Idempotency.unique! can only be called within an idempotent_transaction block") + end + end + + context "when called inside a transaction" do + it "adds the values to the resource in the current transaction" do + values_added = nil + resource = create(:event) + + described_class.transaction do + described_class.unique!(resource, v1: "value1", v2: "value2") + values_added = described_class.current_transaction.idempotent_resources[resource] + end + + expect(values_added).to eq({v1: "value1", v2: "value2"}) + end + + it "merges multiple calls to unique for the same resource" do + resource = create(:event) + values_list = nil + + described_class.transaction do + described_class.unique!(resource, v1: "value1") + described_class.unique!(resource, v2: "value2", v3: "value3") + values_list = described_class.current_transaction.idempotent_resources[resource] + end + + expect(values_list).to eq({v1: "value1", v2: "value2", v3: "value3"}) + end + + it "merges multiple calls to unique for the same resource and uses the last key-value pair" do + resource = create(:event) + values_list = nil + + described_class.transaction do + described_class.unique!(resource, v1: "value1") + described_class.unique!(resource, v1: "value2", v3: "value3") + values_list = described_class.current_transaction.idempotent_resources[resource] + end + + expect(values_list).to eq({v1: "value2", v3: "value3"}) + end + + it "returns an error if no key-value pairs are provided" do + resource = create(:event) + expect do + described_class.transaction do + expect { described_class.unique!(resource) }.to raise_error(ArgumentError) + end + end.to raise_error(ArgumentError, "At least one resource must be added") + end + end + end + + describe "Transaction" do + let(:transaction) { described_class::Transaction.new } + let(:invoice) { create(:invoice) } + + describe "#ensure_idempotent!" do + it "creates idempotency records for each resource" do + resource1 = create(:event) + resource2 = create(:event) + values1 = {c1: "a", c2: "D"} + values2 = {c2: "d"} + + transaction.idempotent_resources[resource1] = values1 + transaction.idempotent_resources[resource2] = values2 + + expect { transaction.ensure_idempotent! }.to change(IdempotencyRecord, :count).by(2) + end + end + + describe "#valid?" do + it "returns true when resources are present" do + resource = create(:event) + transaction.idempotent_resources[resource] = [["value"]] + expect(transaction.valid?).to be true + end + + it "returns false when resources are empty" do + expect(transaction.valid?).to be false + end + end + end +end diff --git a/spec/services/inbound_webhooks/create_service_spec.rb b/spec/services/inbound_webhooks/create_service_spec.rb new file mode 100644 index 0000000..edc5ca1 --- /dev/null +++ b/spec/services/inbound_webhooks/create_service_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InboundWebhooks::CreateService do + subject(:result) do + described_class.call( + organization_id: organization.id, + webhook_source:, + code:, + payload:, + signature:, + event_type: + ) + end + + let(:organization) { create :organization } + let(:code) { "stripe_1" } + let(:webhook_source) { "stripe" } + let(:signature) { "signature" } + let(:payload) { event.merge(code:).to_json } + let(:event_type) { "payment_intent.successful" } + let(:validation_payload_result) { BaseService::Result.new } + + let(:event) do + JSON.parse(get_stripe_fixtures("webhooks/payment_intent_succeeded.json")) + end + + before do + allow(InboundWebhooks::ValidatePayloadService) + .to receive(:call) + .and_return(validation_payload_result) + end + + it "creates an inbound webhook" do + expect { result }.to change(InboundWebhook, :count).by(1) + end + + it "returns a pending inbound webhook in the result" do + expect(result.inbound_webhook).to be_a(InboundWebhook) + expect(result.inbound_webhook).to be_pending + end + + it "queues an InboundWebhook::ProcessJob job" do + result + + expect(InboundWebhooks::ProcessJob) + .to have_been_enqueued + .with(inbound_webhook: result.inbound_webhook) + end + + context "with record validation error" do + let(:webhook_source) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:source]).to eq(["value_is_mandatory"]) + end + + it "does not queue an InboundWebhook::ProcessJob job" do + result + + expect(InboundWebhooks::ProcessJob).not_to have_been_enqueued + end + end + + context "when payload validation fails" do + let(:validation_payload_result) do + BaseService::Result.new.service_failure!( + code: "webhook_error", message: "Invalid signature" + ) + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.message).to eq "webhook_error: Invalid signature" + end + end +end diff --git a/spec/services/inbound_webhooks/process_service_spec.rb b/spec/services/inbound_webhooks/process_service_spec.rb new file mode 100644 index 0000000..881fe2e --- /dev/null +++ b/spec/services/inbound_webhooks/process_service_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InboundWebhooks::ProcessService do + subject(:result) { described_class.call(inbound_webhook:) } + + let(:inbound_webhook) { create :inbound_webhook, source: webhook_source } + let(:webhook_source) { "stripe" } + let(:handle_incoming_webhook_service_result) { BaseService::Result.new } + + before do + allow(PaymentProviders::Stripe::HandleIncomingWebhookService) + .to receive(:call) + .and_return(handle_incoming_webhook_service_result) + end + + it "updateds inbound webhook status to processing" do + allow(inbound_webhook).to receive(:processing!) + + result + expect(inbound_webhook).to have_received(:processing!).once + end + + context "when inbound webhook source is invalid" do + let(:webhook_source) { "invalid_source" } + + it "flags inbound webhook as failed and raises an error" do + expect { result } + .to change(inbound_webhook, :status).to("failed") + .and raise_error( + NameError, + "Invalid inbound webhook source: invalid_source" + ) + end + end + + context "when inbound webhook is within processing window" do + let(:inbound_webhook) do + create( + :inbound_webhook, + source: webhook_source, + status: "processing", + processing_at: 119.minutes.ago + ) + end + + it "does not process the webhook" do + expect(result).to be_success + expect(PaymentProviders::Stripe::HandleIncomingWebhookService) + .not_to have_received(:call) + end + end + + context "when inbound webhook is outside the processing window" do + let(:inbound_webhook) do + create( + :inbound_webhook, + source: webhook_source, + status: "processing", + processing_at: 121.minutes.ago + ) + end + + it "processes the webhook as normal" do + expect(result).to be_success + end + end + + context "when inbound webhook has failed" do + let(:inbound_webhook) { create :inbound_webhook, source: webhook_source, status: } + let(:status) { "failed" } + + it "does not process the webhook" do + expect(result).to be_success + expect(PaymentProviders::Stripe::HandleIncomingWebhookService) + .not_to have_received(:call) + end + end + + context "when inbound webhook has been succeeded" do + let(:inbound_webhook) { create :inbound_webhook, source: webhook_source, status: } + let(:status) { "succeeded" } + + it "does not process the webhook" do + expect(result).to be_success + expect(PaymentProviders::Stripe::HandleIncomingWebhookService) + .not_to have_received(:call) + end + end + + context "when webhook source is Stripe" do + let(:webhook_source) { "stripe" } + + before do + allow(PaymentProviders::Stripe::HandleIncomingWebhookService) + .to receive(:call) + .and_return(handle_incoming_webhook_service_result) + end + + it "delegates the call to the Stripe webhook hanlder service" do + expect(result).to be_success + expect(PaymentProviders::Stripe::HandleIncomingWebhookService) + .to have_received(:call) + .with(inbound_webhook:) + end + + it "updated inbound webhook status to succeeded" do + expect { result }.to change(inbound_webhook, :status).to("succeeded") + end + + context "when the stripe webhook handling fails" do + before do + handle_incoming_webhook_service_result.service_failure!( + code: "error", message: "error message" + ) + end + + it "returns the handler results" do + expect(result).not_to be_success + expect(result).to eq(handle_incoming_webhook_service_result) + end + + it "updates inbound webhook status to failed" do + expect { result }.to change(inbound_webhook, :status).to("failed") + end + end + end +end diff --git a/spec/services/inbound_webhooks/validate_payload_service_spec.rb b/spec/services/inbound_webhooks/validate_payload_service_spec.rb new file mode 100644 index 0000000..e7998af --- /dev/null +++ b/spec/services/inbound_webhooks/validate_payload_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InboundWebhooks::ValidatePayloadService do + subject(:result) do + described_class.call( + organization_id: organization.id, + code:, + payload:, + webhook_source:, + signature: + ) + end + + let(:organization) { create(:organization) } + let(:code) { "payment_provider_1" } + let(:payload) { "webhook_payload" } + let(:signature) { "signature" } + let(:webhook_source) { "stripe" } + + context "when webhook source is unknown" do + let(:webhook_source) { "unknown" } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.message).to eq("webhook_error: Invalid webhook source") + end + end + + context "when payment provider is not found" do + it "returns a service failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.message).to eq("payment_provider_not_found: Payment provider not found") + end + end + + context "when webhook source is stripe" do + let(:webhook_source) { "stripe" } + let(:payload) { "webhook_payload" } + + before do + allow(::Stripe::Webhook::Signature).to receive(:verify_header).and_return(true) + create(:stripe_provider, organization:, code:) + end + + it "validates the payload" do + expect(result).to be_success + end + + context "when signature is invalid" do + before do + allow(::Stripe::Webhook::Signature) + .to receive(:verify_header) + .and_raise( + ::Stripe::SignatureVerificationError.new( + "Unable to extract timestamp and signatures from header", + signature, + http_body: payload + ) + ) + end + + it "returns a service failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.message).to eq("webhook_error: Invalid signature") + end + end + end +end diff --git a/spec/services/integration_collection_mappings/create_service_spec.rb b/spec/services/integration_collection_mappings/create_service_spec.rb new file mode 100644 index 0000000..4785032 --- /dev/null +++ b/spec/services/integration_collection_mappings/create_service_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCollectionMappings::CreateService do + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:add_on) { create(:add_on, organization:) } + + describe "#call" do + subject(:service_call) { described_class.call(params: create_args) } + + let(:create_args) do + { + mapping_type: :fallback_item, + integration_id: integration.id, + tax_nexus: "123", + tax_code: "456", + tax_type: "tax-type-1" + } + end + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(IntegrationCollectionMappings::NetsuiteCollectionMapping, :count).by(1) + + integration_collection_mapping = + IntegrationCollectionMappings::NetsuiteCollectionMapping.order(:created_at).last + + expect(integration_collection_mapping.organization).to eq(organization) + expect(integration_collection_mapping.mapping_type).to eq("fallback_item") + expect(integration_collection_mapping.integration_id).to eq(integration.id) + expect(integration_collection_mapping.tax_nexus).to eq(create_args[:tax_nexus]) + expect(integration_collection_mapping.tax_code).to eq(create_args[:tax_code]) + expect(integration_collection_mapping.tax_type).to eq(create_args[:tax_type]) + end + + it "returns an integration collection mapping in result object" do + result = service_call + + expect(result.integration_collection_mapping).to be_a(IntegrationCollectionMappings::NetsuiteCollectionMapping) + end + end + + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:create_args) do + { + mapping_type: :fallback_item, + integration_id: integration.id, + billing_entity_id: billing_entity.id, + tax_nexus: "123", + tax_code: "456", + tax_type: "tax-type-1" + } + end + + it "creates an integration collection mapping with billing entity" do + expect { service_call }.to change(IntegrationCollectionMappings::NetsuiteCollectionMapping, :count).by(1) + + integration_collection_mapping = + IntegrationCollectionMappings::NetsuiteCollectionMapping.order(:created_at).last + + expect(integration_collection_mapping.billing_entity).to eq(billing_entity) + end + end + + context "with invalid billing entity" do + let(:other_organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization: other_organization) } + let(:create_args) do + { + mapping_type: :fallback_item, + integration_id: integration.id, + billing_entity_id: billing_entity.id + } + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + end + end + + context "with non-existent billing entity" do + let(:create_args) do + { + mapping_type: :fallback_item, + integration_id: integration.id, + billing_entity_id: "non-existent-id" + } + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + end + end + + context "with validation error" do + let(:create_args) do + { + mappable_type: "AddOn", + mappable_id: add_on.id + } + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("integration_not_found") + end + end + + context "with invalid currencies format" do + let(:create_args) do + { + mapping_type: :currencies, + integration_id: integration.id, + currencies: {yolo: true} + } + end + + it "returns validation errors for invalid currencies format" do + result = service_call + + expect(result.error.messages[:currencies]).to eq ["invalid_format"] + end + end + end +end diff --git a/spec/services/integration_collection_mappings/destroy_service_spec.rb b/spec/services/integration_collection_mappings/destroy_service_spec.rb new file mode 100644 index 0000000..53af1d7 --- /dev/null +++ b/spec/services/integration_collection_mappings/destroy_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCollectionMappings::DestroyService do + subject(:destroy_service) { described_class.new(integration_collection_mapping:) } + + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe ".call" do + before { integration_collection_mapping } + + context "when integration is present" do + let(:integration_collection_mapping) { create(:netsuite_collection_mapping, integration:) } + + it "destroys the integration mapping" do + expect { destroy_service.call } + .to change(IntegrationCollectionMappings::BaseCollectionMapping, :count).by(-1) + end + end + + context "when integration is not found" do + let(:integration_collection_mapping) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("integration_collection_mapping_not_found") + end + end + end +end diff --git a/spec/services/integration_collection_mappings/update_service_spec.rb b/spec/services/integration_collection_mappings/update_service_spec.rb new file mode 100644 index 0000000..1e43a33 --- /dev/null +++ b/spec/services/integration_collection_mappings/update_service_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCollectionMappings::UpdateService do + let(:integration_collection_mapping) { create(:netsuite_collection_mapping, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration_collection_mapping:, params: update_args) } + + before { integration_collection_mapping } + + let(:update_args) do + { + external_id: "456", + external_name: "Name1", + external_account_code: "code-2", + tax_nexus: "updated-123", + tax_code: "updated-456", + tax_type: "updated-tax-type-1" + } + end + + context "without validation errors" do + it "updates an integration collection mapping" do + service_call + + integration_collection_mapping = + IntegrationCollectionMappings::NetsuiteCollectionMapping.order(:updated_at).last + + expect(integration_collection_mapping.external_id).to eq("456") + expect(integration_collection_mapping.external_name).to eq("Name1") + expect(integration_collection_mapping.external_account_code).to eq("code-2") + expect(integration_collection_mapping.tax_nexus).to eq(update_args[:tax_nexus]) + expect(integration_collection_mapping.tax_code).to eq(update_args[:tax_code]) + expect(integration_collection_mapping.tax_type).to eq(update_args[:tax_type]) + end + + it "returns an integration collection mapping in result object" do + result = service_call + + expect(result.integration_collection_mapping).to be_a(IntegrationCollectionMappings::NetsuiteCollectionMapping) + end + end + + context "with netsuite currencies mapping" do + let(:integration_collection_mapping) { create(:netsuite_currencies_mapping) } + + context "with valid currencies format" do + it "saves the new mapping" do + update_args[:currencies] = {"USD" => "799344"} + result = service_call + expect(result.integration_collection_mapping.reload.currencies).to eq({"USD" => "799344"}) + end + end + + context "with invalid currencies format" do + it "returns validation errors for invalid currencies format" do + update_args[:currencies] = {yolo: true} + result = service_call + + expect(result.error.messages[:currencies]).to eq ["invalid_format"] + end + end + end + end +end diff --git a/spec/services/integration_customers/anrok_service_spec.rb b/spec/services/integration_customers/anrok_service_spec.rb new file mode 100644 index 0000000..0c38b08 --- /dev/null +++ b/spec/services/integration_customers/anrok_service_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::AnrokService do + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + + describe "#create" do + subject(:service_call) { described_class.new(subsidiary_id: nil, integration:, customer:).create } + + it "returns integration customer" do + result = service_call + + expect(result).to be_success + expect(result.integration_customer.external_customer_id).to eq(nil) + expect(result.integration_customer.integration_id).to eq(integration.id) + expect(result.integration_customer.customer_id).to eq(customer.id) + expect(result.integration_customer.type).to eq("IntegrationCustomers::AnrokCustomer") + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::AnrokCustomer, :count).by(1) + end + end +end diff --git a/spec/services/integration_customers/avalara_service_spec.rb b/spec/services/integration_customers/avalara_service_spec.rb new file mode 100644 index 0000000..f2293cd --- /dev/null +++ b/spec/services/integration_customers/avalara_service_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::AvalaraService do + let(:integration) { create(:avalara_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + + describe "#create" do + subject(:service_call) { described_class.new(integration:, customer:, subsidiary_id: nil).create } + + let(:contact_id) { SecureRandom.uuid } + let(:create_result) do + result = BaseService::Result.new + result.contact_id = contact_id + result + end + + before do + allow(Integrations::Aggregator::Contacts::CreateService) + .to receive(:call).and_return(create_result) + end + + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::CreateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer.external_customer_id).to eq(contact_id) + expect(result.integration_customer.integration_id).to eq(integration.id) + expect(result.integration_customer.customer_id).to eq(customer.id) + expect(result.integration_customer.type).to eq("IntegrationCustomers::AvalaraCustomer") + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::AvalaraCustomer, :count).by(1) + end + end +end diff --git a/spec/services/integration_customers/create_or_update_batch_service_spec.rb b/spec/services/integration_customers/create_or_update_batch_service_spec.rb new file mode 100644 index 0000000..b88db0c --- /dev/null +++ b/spec/services/integration_customers/create_or_update_batch_service_spec.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::CreateOrUpdateBatchService do + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + let(:subsidiary_id) { "1" } + let(:integration_customers) do + [ + { + integration_type: "netsuite", + integration_code:, + sync_with_provider:, + external_customer_id:, + subsidiary_id: + } + ] + end + + describe "#call" do + subject(:service_call) { described_class.call(integration_customers:, customer:, new_customer:) } + + context "without integration" do + let(:integration_code) { "not_exists" } + let(:sync_with_provider) { true } + let(:external_customer_id) { nil } + let(:new_customer) { true } + + it "does not call create job" do + expect { service_call }.not_to have_enqueued_job(IntegrationCustomers::CreateJob) + end + + it "does not call update job" do + expect { service_call }.not_to have_enqueued_job(IntegrationCustomers::UpdateJob) + end + end + + context "without customer" do + let(:integration_code) { integration.code } + let(:sync_with_provider) { true } + let(:external_customer_id) { nil } + let(:new_customer) { true } + let(:customer) { nil } + + it "does not call create job" do + expect { service_call }.not_to have_enqueued_job(IntegrationCustomers::CreateJob) + end + + it "does not call update job" do + expect { service_call }.not_to have_enqueued_job(IntegrationCustomers::UpdateJob) + end + end + + context "without external fields set" do + let(:integration_code) { integration.code } + let(:sync_with_provider) { false } + let(:external_customer_id) { nil } + let(:new_customer) { true } + + it "does not call create job" do + expect { service_call }.not_to have_enqueued_job(IntegrationCustomers::CreateJob) + end + + it "does not call update job" do + expect { service_call }.not_to have_enqueued_job(IntegrationCustomers::UpdateJob) + end + end + + context "when removing integration customer" do + let(:integration_customer) { create(:netsuite_customer, customer:, integration:) } + let(:integration_customers) { [] } + let(:new_customer) { false } + + before do + IntegrationCustomers::BaseCustomer.destroy_all + + integration_customer + end + + it "removes integration customer object" do + service_call + + expect(IntegrationCustomers::BaseCustomer.count).to eq(0) + end + + context "with existing integration customers that should be removed and updating ones" do + let(:integration_anrok) { create(:anrok_integration, organization:) } + let(:anrok_customer) { create(:anrok_customer, customer:, integration: integration_anrok) } + let(:integration_customers) do + [ + { + id: anrok_customer.id, + integration_type: "anrok", + integration_code: integration_anrok.code, + sync_with_provider: true, + external_customer_id: nil + } + ] + end + + before { anrok_customer } + + it "calls update job" do + expect { service_call }.to have_enqueued_job(IntegrationCustomers::UpdateJob) + end + + it "removes netsuite integration customer" do + service_call + + expect(IntegrationCustomers::BaseCustomer.count).to eq(1) + end + end + + context "with existing integration customers that should be removed and new ones" do + let(:integration_anrok) { create(:anrok_integration, organization:) } + let(:integration_customers) do + [ + { + integration_type: "anrok", + integration_code: integration_anrok.code, + sync_with_provider: true, + external_customer_id: nil + } + ] + end + + it "calls create job" do + expect { service_call }.to have_enqueued_job(IntegrationCustomers::CreateJob) + end + + it "removes netsuite integration customer" do + service_call + + expect(IntegrationCustomers::BaseCustomer.count).to eq(0) + end + end + end + + context "when updating integration customer" do + let(:integration_customer) { create(:netsuite_customer, customer:, integration:) } + let(:integration_code) { integration.code } + let(:sync_with_provider) { true } + let(:external_customer_id) { "12345" } + let(:new_customer) { false } + + before do + integration_customer + integration_customers.first[:id] = integration_customer.id + end + + it "calls update job" do + expect { service_call }.to have_enqueued_job(IntegrationCustomers::UpdateJob) + end + + it "does not remove any integration customers" do + service_call + + expect(IntegrationCustomers::BaseCustomer.count).to eq(1) + end + end + + context "when creating integration customer" do + let(:integration_code) { integration.code } + let(:sync_with_provider) { true } + let(:external_customer_id) { nil } + let(:new_customer) { true } + + let(:integration_two) { create(:netsuite_integration, organization: organization_two, code: integration.code) } + let(:organization_two) { create(:organization) } + + before { integration_two } + + it "calls create job" do + expect do + service_call + end.to have_enqueued_job(IntegrationCustomers::CreateJob).with(hash_including(integration:)) + end + + context "when updating existing customer without integration customer" do + let(:new_customer) { false } + + it "calls create job" do + expect { service_call }.to have_enqueued_job(IntegrationCustomers::CreateJob) + end + end + + context "with multiple integration customers" do + let(:integration_anrok) { create(:anrok_integration, organization:) } + let(:integration_customers) do + [ + { + integration_type: "netsuite", + integration_code:, + sync_with_provider:, + external_customer_id:, + subsidiary_id: + }, + { + integration_type: "anrok", + integration_code: integration_anrok.code, + sync_with_provider: true, + external_customer_id: nil + } + ] + end + + it "calls create job" do + expect { service_call }.to have_enqueued_job(IntegrationCustomers::CreateJob).exactly(:twice) + end + end + + context "when adding one new integration customer" do + let(:integration_anrok) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, customer:, integration:) } + let(:new_customer) { false } + + let(:integration_customers) do + [ + { + id: integration_customer.id, + integration_type: "netsuite", + integration_code:, + sync_with_provider:, + external_customer_id:, + subsidiary_id: + }, + { + integration_type: "anrok", + integration_code: integration_anrok.code, + sync_with_provider: true, + external_customer_id: nil + } + ] + end + + before do + integration_anrok + + IntegrationCustomers::BaseCustomer.destroy_all + + integration_customer + end + + it "calls create job" do + expect { service_call }.to have_enqueued_job(IntegrationCustomers::CreateJob).exactly(:once) + end + end + + context "when adding a sync integration customer" do + let(:integration_salesforce) { create(:salesforce_integration, organization:) } + let(:integration_customers) do + [ + { + integration_type: "salesforce", + integration_code: integration_salesforce.code, + sync_with_provider: true, + external_customer_id: "12345" + } + ] + end + + before do + IntegrationCustomers::BaseCustomer.destroy_all + end + + it "processes the job immediately" do + expect { service_call }.to change(IntegrationCustomers::BaseCustomer, :count).by(1).and(not_have_enqueued_job(IntegrationCustomers::CreateJob)) + end + end + end + end +end diff --git a/spec/services/integration_customers/create_service_spec.rb b/spec/services/integration_customers/create_service_spec.rb new file mode 100644 index 0000000..d4c3cac --- /dev/null +++ b/spec/services/integration_customers/create_service_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::CreateService do + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + let(:integration_type) { "netsuite" } + + describe "#call" do + subject(:service_call) { described_class.call(params:, integration:, customer:) } + + let(:params) do + { + integration_type:, + integration_code:, + sync_with_provider:, + external_customer_id:, + subsidiary_id: + } + end + + let(:subsidiary_id) { "1" } + + context "with netsuite premium integration present", :premium do + let(:integration_code) { integration.code } + let(:external_customer_id) { nil } + let(:sync_with_provider) { true } + let(:contact_id) { SecureRandom.uuid } + + let(:create_result) do + result = BaseService::Result.new + result.contact_id = contact_id + result + end + + let(:integration_customer) { IntegrationCustomers::BaseCustomer.last } + + before do + organization.update!(premium_integrations: ["netsuite"]) + + allow(Integrations::Aggregator::Contacts::CreateService) + .to receive(:call).and_return(create_result) + end + + context "when sync with provider is true" do + let(:sync_with_provider) { true } + + context "when customer external id is present" do + let(:external_customer_id) { SecureRandom.uuid } + + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::CreateService).not_to have_received(:call) + expect(result).to be_success + expect(result.integration_customer).to eq(integration_customer) + expect(result.integration_customer.external_customer_id).to eq(external_customer_id) + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::BaseCustomer, :count).by(1) + end + + context "when the integration type is salesforce" do + let(:integration) { create(:salesforce_integration, organization:) } + let(:integration_type) { "salesforce" } + + it "returns integration customer with sync_with_provider true" do + result = service_call + + expect(Integrations::Aggregator::Contacts::CreateService).not_to have_received(:call) + expect(result).to be_success + expect(result.integration_customer).to eq(integration_customer) + expect(result.integration_customer.external_customer_id).to eq(external_customer_id) + expect(result.integration_customer.sync_with_provider).to eq(true) + end + end + end + + context "when customer external id is not present" do + let(:external_customer_id) { nil } + + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::CreateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer).to eq(integration_customer) + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::NetsuiteCustomer, :count).by(1) + end + + context "with anrok integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:params) do + { + integration_type: "anrok", + integration_code:, + sync_with_provider:, + external_customer_id: + } + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::AnrokCustomer, :count).by(1) + end + end + end + end + + context "when sync with provider is false" do + let(:sync_with_provider) { false } + + context "when customer external id is present" do + let(:external_customer_id) { SecureRandom.uuid } + + it "does not calls aggregator create service" do + service_call + + expect(Integrations::Aggregator::Contacts::CreateService).not_to have_received(:call) + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::BaseCustomer, :count).by(1) + end + end + + context "when customer external id is not present" do + let(:external_customer_id) { nil } + + it "does not calls aggregator create service" do + service_call + + expect(Integrations::Aggregator::Contacts::CreateService).not_to have_received(:call) + end + + it "does not create integration customer" do + expect { service_call }.not_to change(IntegrationCustomers::BaseCustomer, :count) + end + end + end + end + end +end diff --git a/spec/services/integration_customers/factory_spec.rb b/spec/services/integration_customers/factory_spec.rb new file mode 100644 index 0000000..2c5e7e1 --- /dev/null +++ b/spec/services/integration_customers/factory_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +# spec/factories/integration_customers/factory_spec.rb +require "rails_helper" + +RSpec.describe IntegrationCustomers::Factory do + describe ".new_instance" do + subject { described_class.new_instance(integration:, customer:, subsidiary_id:, **params) } + + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + let(:subsidiary_id) {} + let(:params) { {} } + + context "when the integration is NetsuiteIntegration" do + let(:integration) { create(:netsuite_integration, organization:) } + + it "returns an instance of IntegrationCustomers::NetsuiteService" do + expect(subject).to be_an_instance_of(IntegrationCustomers::NetsuiteService) + end + end + + context "when the integration is AnrokIntegration" do + let(:integration) { create(:anrok_integration, organization:) } + + it "returns an instance of IntegrationCustomers::AnrokService" do + expect(subject).to be_an_instance_of(IntegrationCustomers::AnrokService) + end + end + + context "when the integration is XeroIntegration" do + let(:integration) { create(:xero_integration, organization:) } + + it "returns an instance of IntegrationCustomers::XeroService" do + expect(subject).to be_an_instance_of(IntegrationCustomers::XeroService) + end + end + + context "when the integration is HubspotIntegration" do + let(:integration) { create(:hubspot_integration, organization:) } + + it "returns an instance of IntegrationCustomers::HubspotService" do + expect(subject).to be_an_instance_of(IntegrationCustomers::HubspotService) + end + end + + context "when the integration is SalesforceIntegration" do + let(:integration) { create(:salesforce_integration, organization:) } + + it "returns an instance of IntegrationCustomers::SalesforceService" do + expect(subject).to be_an_instance_of(IntegrationCustomers::SalesforceService) + end + end + + context "when integration is nil" do + let(:integration) { nil } + + it "raises a NotImplementedError" do + expect { subject }.to raise_error(NotImplementedError) + end + end + end +end diff --git a/spec/services/integration_customers/hubspot_service_spec.rb b/spec/services/integration_customers/hubspot_service_spec.rb new file mode 100644 index 0000000..f7e5d4c --- /dev/null +++ b/spec/services/integration_customers/hubspot_service_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::HubspotService do + let(:integration) { create(:hubspot_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:, customer_type: "individual") } + let(:targeted_object) { "contacts" } + + describe "#create" do + subject(:service_call) { described_class.new(integration:, customer:, subsidiary_id: nil, targeted_object:).create } + + let(:contact_id) { SecureRandom.uuid } + let(:create_result) do + result = BaseService::Result.new + result.contact_id = contact_id + result.email = customer.email + result + end + + before do + allow(Integrations::Aggregator::Contacts::CreateService) + .to receive(:call).and_return(create_result) + end + + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::CreateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer.external_customer_id).to eq(contact_id) + expect(result.integration_customer.integration_id).to eq(integration.id) + expect(result.integration_customer.customer_id).to eq(customer.id) + expect(result.integration_customer.email).to eq(customer.email) + expect(result.integration_customer.type).to eq("IntegrationCustomers::HubspotCustomer") + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::HubspotCustomer, :count).by(1) + end + + context "with targeted_object" do + let(:customer) { create(:customer, organization:, customer_type:) } + let(:customer_type) { "individual" } + + context 'when params[:targeted_object] is specified as "contacts"' do + let(:targeted_object) { "contacts" } + + it "uses Integrations::Aggregator::Contacts::CreateService" do + allow(Integrations::Aggregator::Contacts::CreateService).to receive(:call).and_return(create_result) + service_call + expect(Integrations::Aggregator::Contacts::CreateService).to have_received(:call) + end + end + + context 'when params[:targeted_object] is specified as "companies"' do + let(:targeted_object) { "companies" } + + it "uses Integrations::Aggregator::Companies::CreateService" do + allow(Integrations::Aggregator::Companies::CreateService).to receive(:call).and_return(create_result) + service_call + expect(Integrations::Aggregator::Companies::CreateService).to have_received(:call) + end + end + + context "when params[:targeted_object] is not specified and customer is an individual" do + let(:targeted_object) { nil } + let(:customer) { create(:customer, organization:, customer_type: "individual") } + + it "defaults to Integrations::Aggregator::Contacts::CreateService" do + allow(Integrations::Aggregator::Contacts::CreateService).to receive(:call).and_return(create_result) + service_call + expect(Integrations::Aggregator::Contacts::CreateService).to have_received(:call) + end + end + + context "when params[:targeted_object] is not specified and customer is a company" do + let(:targeted_object) { nil } + let(:customer) { create(:customer, organization:, customer_type: "company") } + + it "defaults to Integrations::Aggregator::Companies::CreateService" do + allow(Integrations::Aggregator::Companies::CreateService).to receive(:call).and_return(create_result) + service_call + expect(Integrations::Aggregator::Companies::CreateService).to have_received(:call) + end + end + end + end +end diff --git a/spec/services/integration_customers/netsuite_service_spec.rb b/spec/services/integration_customers/netsuite_service_spec.rb new file mode 100644 index 0000000..7605428 --- /dev/null +++ b/spec/services/integration_customers/netsuite_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::NetsuiteService do + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + let(:subsidiary_id) { "1" } + + describe "#create" do + subject(:service_call) { described_class.new(subsidiary_id:, integration:, customer:).create } + + let(:contact_id) { SecureRandom.uuid } + let(:create_result) do + result = BaseService::Result.new + result.contact_id = contact_id + result + end + + before do + allow(Integrations::Aggregator::Contacts::CreateService) + .to receive(:call).and_return(create_result) + end + + context "when integration customer does not exist" do + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::CreateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer.subsidiary_id).to eq("1") + expect(result.integration_customer.external_customer_id).to eq(contact_id) + expect(result.integration_customer.integration_id).to eq(integration.id) + expect(result.integration_customer.customer_id).to eq(customer.id) + expect(result.integration_customer.type).to eq("IntegrationCustomers::NetsuiteCustomer") + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::NetsuiteCustomer, :count).by(1) + end + end + + context "when integration customer already exists" do + let!(:existing_integration_customer) do + create(:netsuite_customer, integration:, customer:, subsidiary_id:) + end + + it "does not call aggregator contacts create service" do + service_call + expect(Integrations::Aggregator::Contacts::CreateService).not_to have_received(:call) + end + + it "returns existing integration customer" do + result = service_call + + expect(result).to be_success + expect(result.integration_customer).to eq(existing_integration_customer) + expect(IntegrationCustomers::NetsuiteCustomer.count).to eq(1) + end + end + end +end diff --git a/spec/services/integration_customers/salesforce_service_spec.rb b/spec/services/integration_customers/salesforce_service_spec.rb new file mode 100644 index 0000000..dafe08e --- /dev/null +++ b/spec/services/integration_customers/salesforce_service_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::SalesforceService do + let(:integration) { create(:salesforce_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:, customer_type: "individual") } + + describe "#create" do + subject(:service_call) { described_class.new(integration:, customer:, subsidiary_id: nil).create } + + it "returns integration customer" do + result = service_call + + expect(result).to be_success + expect(result.integration_customer.integration_id).to eq(integration.id) + expect(result.integration_customer.customer_id).to eq(customer.id) + expect(result.integration_customer.type).to eq("IntegrationCustomers::SalesforceCustomer") + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::SalesforceCustomer, :count).by(1) + end + end +end diff --git a/spec/services/integration_customers/update_service_spec.rb b/spec/services/integration_customers/update_service_spec.rb new file mode 100644 index 0000000..4b39d78 --- /dev/null +++ b/spec/services/integration_customers/update_service_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::UpdateService do + let(:integration) { create(:netsuite_integration, organization:) } + + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + + describe "#call" do + subject(:service_call) { described_class.call(params:, integration:, integration_customer:) } + + let(:params) do + { + integration_type: "netsuite", + integration_code:, + sync_with_provider:, + external_customer_id:, + subsidiary_id: + } + end + + let(:subsidiary_id) { "1111" } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + + before { integration_customer } + + context "with netsuite premium integration present", :premium do + let(:integration_code) { integration.code } + let(:external_customer_id) { nil } + let(:sync_with_provider) { true } + let(:contact_id) { SecureRandom.uuid } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + + let(:update_result) do + result = BaseService::Result.new + result.contact_id = contact_id + result + end + + before do + organization.update!(premium_integrations: ["netsuite"]) + + integration_customer + + allow(Integrations::Aggregator::Contacts::UpdateService) + .to receive(:call).and_return(update_result) + end + + context "when sync with provider is true" do + let(:sync_with_provider) { true } + + context "when external customer id is present" do + let(:external_customer_id) { SecureRandom.uuid } + + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::UpdateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer).to eq(integration_customer) + expect(result.integration_customer.external_customer_id).to eq(external_customer_id) + end + end + + context "when subsidiary id is present" do + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::UpdateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer).to eq(integration_customer) + end + end + + context "when customer external id is not present" do + let(:external_customer_id) { nil } + + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::UpdateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer).to eq(integration_customer) + end + end + + context "with anrok customer" do + let(:external_customer_id) { SecureRandom.uuid } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + it "does not calls aggregator update service" do + service_call + + expect(Integrations::Aggregator::Contacts::UpdateService).not_to have_received(:call) + end + + it "does not update integration customer" do + service_call + + expect(integration_customer.reload.external_customer_id).not_to eq(external_customer_id) + end + end + + context "with salesforce customer" do + let(:external_customer_id) { SecureRandom.uuid } + let(:integration) { create(:salesforce_integration, organization:) } + let(:integration_customer) { create(:salesforce_customer, integration:, customer:) } + + it "does not calls aggregator update service" do + service_call + + expect(Integrations::Aggregator::Contacts::UpdateService).not_to have_received(:call) + end + + it "does not update integration customer" do + service_call + + expect(integration_customer.reload.external_customer_id).not_to eq(external_customer_id) + end + end + end + + context "when sync with provider is false" do + let(:sync_with_provider) { false } + + context "when customer external id is present" do + let(:external_customer_id) { SecureRandom.uuid } + + it "calls aggregator update service" do + service_call + + expect(Integrations::Aggregator::Contacts::UpdateService).to have_received(:call) + end + + it "updates integration customer" do + result = service_call + + expect(result.integration_customer.external_customer_id).to eq(external_customer_id) + end + end + + context "when customer external id is not present" do + let(:external_customer_id) { nil } + + it "does not calls aggregator update service" do + expect(Integrations::Aggregator::Contacts::UpdateService).not_to have_received(:call) + end + + it "does not update integration customer" do + result = service_call + + expect(result.integration_customer.external_customer_id).not_to eq(external_customer_id) + end + end + end + end + end +end diff --git a/spec/services/integration_customers/xero_service_spec.rb b/spec/services/integration_customers/xero_service_spec.rb new file mode 100644 index 0000000..aa4ac18 --- /dev/null +++ b/spec/services/integration_customers/xero_service_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationCustomers::XeroService do + let(:integration) { create(:xero_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:customer) { create(:customer, organization:) } + + describe "#create" do + subject(:service_call) { described_class.new(integration:, customer:, subsidiary_id: nil).create } + + let(:contact_id) { SecureRandom.uuid } + let(:create_result) do + result = BaseService::Result.new + result.contact_id = contact_id + result + end + + before do + allow(Integrations::Aggregator::Contacts::CreateService) + .to receive(:call).and_return(create_result) + end + + it "returns integration customer" do + result = service_call + + expect(Integrations::Aggregator::Contacts::CreateService).to have_received(:call) + expect(result).to be_success + expect(result.integration_customer.external_customer_id).to eq(contact_id) + expect(result.integration_customer.integration_id).to eq(integration.id) + expect(result.integration_customer.customer_id).to eq(customer.id) + expect(result.integration_customer.type).to eq("IntegrationCustomers::XeroCustomer") + end + + it "creates integration customer" do + expect { service_call }.to change(IntegrationCustomers::XeroCustomer, :count).by(1) + end + end +end diff --git a/spec/services/integration_mappings/create_service_spec.rb b/spec/services/integration_mappings/create_service_spec.rb new file mode 100644 index 0000000..32d9216 --- /dev/null +++ b/spec/services/integration_mappings/create_service_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationMappings::CreateService do + let(:service) { described_class.new(membership.user) } + + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:add_on) { create(:add_on, organization:) } + + describe "#call" do + subject(:service_call) { service.call(**create_args) } + + let(:create_args) do + { + mappable_type: "AddOn", + mappable_id: add_on.id, + integration_id: integration.id, + external_id: "external_123" + } + end + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(IntegrationMappings::NetsuiteMapping, :count).by(1) + + integration_mapping = IntegrationMappings::NetsuiteMapping.order(:created_at).last + + expect(integration_mapping.mappable_type).to eq("AddOn") + expect(integration_mapping.mappable_id).to eq(add_on.id) + expect(integration_mapping.integration_id).to eq(integration.id) + end + + it "returns an integration mapping in result object" do + result = service_call + + expect(result.integration_mapping).to be_a(IntegrationMappings::NetsuiteMapping) + end + + context "with billing entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + let(:create_args) do + { + mappable_type: "AddOn", + mappable_id: add_on.id, + integration_id: integration.id, + billing_entity_id: billing_entity.id, + external_id: "external_123" + } + end + + it "creates an integration mapping with billing entity" do + expect { service_call }.to change(IntegrationMappings::NetsuiteMapping, :count).by(1) + + integration_mapping = IntegrationMappings::NetsuiteMapping.order(:created_at).last + + expect(integration_mapping.mappable_type).to eq("AddOn") + expect(integration_mapping.mappable_id).to eq(add_on.id) + expect(integration_mapping.integration_id).to eq(integration.id) + expect(integration_mapping.billing_entity_id).to eq(billing_entity.id) + expect(integration_mapping.external_id).to eq("external_123") + end + + context "when billing entity belongs to different organization" do + let(:billing_entity) { create(:billing_entity, organization: create(:organization)) } + + it "returns an error" do + expect(service_call).not_to be_success + expect(service_call.error).to be_a(BaseService::NotFoundFailure) + expect(service_call.error.message).to eq("billing_entity_not_found") + end + end + end + end + + context "with validation error" do + let(:create_args) do + { + mappable_type: "AddOn", + mappable_id: add_on.id + } + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("integration_not_found") + end + end + end +end diff --git a/spec/services/integration_mappings/destroy_service_spec.rb b/spec/services/integration_mappings/destroy_service_spec.rb new file mode 100644 index 0000000..1e586da --- /dev/null +++ b/spec/services/integration_mappings/destroy_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationMappings::DestroyService do + subject(:destroy_service) { described_class.new(integration_mapping:) } + + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe ".call" do + before { integration_mapping } + + context "when integration is present" do + let(:integration_mapping) { create(:netsuite_mapping, integration:) } + + it "destroys the integration mapping" do + expect { destroy_service.call } + .to change(IntegrationMappings::BaseMapping, :count).by(-1) + end + end + + context "when integration is not found" do + let(:integration_mapping) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("integration_mapping_not_found") + end + end + end +end diff --git a/spec/services/integration_mappings/update_service_spec.rb b/spec/services/integration_mappings/update_service_spec.rb new file mode 100644 index 0000000..5f782ef --- /dev/null +++ b/spec/services/integration_mappings/update_service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe IntegrationMappings::UpdateService do + let(:integration_mapping) { create(:netsuite_mapping, integration:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration_mapping:, params: update_args) } + + before { integration_mapping } + + let(:update_args) do + { + external_id: "456", + external_name: "Name1", + external_account_code: "code-2" + } + end + + context "without validation errors" do + it "updates an integration mapping" do + service_call + + integration_mapping = IntegrationMappings::NetsuiteMapping.order(:updated_at).last + + expect(integration_mapping.external_id).to eq("456") + expect(integration_mapping.external_name).to eq("Name1") + expect(integration_mapping.external_account_code).to eq("code-2") + end + + it "returns an integration mapping in result object" do + result = service_call + + expect(result.integration_mapping).to be_a(IntegrationMappings::NetsuiteMapping) + end + end + end +end diff --git a/spec/services/integrations/aggregator/account_information_service_spec.rb b/spec/services/integrations/aggregator/account_information_service_spec.rb new file mode 100644 index 0000000..c45c343 --- /dev/null +++ b/spec/services/integrations/aggregator/account_information_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::AccountInformationService do + subject(:service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe ".call" do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/account-information" } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + let(:aggregator_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/account_information_response.json") + JSON.parse(File.read(path)) + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:get).with(headers:).and_return(aggregator_response) + end + + it "successfully fetches account information" do + result = service.call + account_information = result.account_information + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(lago_client).to have_received(:get) + expect(account_information.id).to eq("1234567890") + end + + it_behaves_like "throttles!", :hubspot + end +end diff --git a/spec/services/integrations/aggregator/accounts_service_spec.rb b/spec/services/integrations/aggregator/accounts_service_spec.rb new file mode 100644 index 0000000..ff72bf7 --- /dev/null +++ b/spec/services/integrations/aggregator/accounts_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::AccountsService do + subject(:accounts_service) { described_class.new(integration:) } + + let(:integration) { create(:netsuite_integration) } + + describe ".call" do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:accounts_endpoint) { "https://api.nango.dev/v1/netsuite/accounts" } + let(:params) { {limit: 450} } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "netsuite-tba" + } + end + + let(:aggregator_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/accounts_response.json") + JSON.parse(File.read(path)) + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(accounts_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:get) + .with(headers:, params:) + .and_return(aggregator_response) + end + + it "successfully fetches accounts" do + result = accounts_service.call + account = result.accounts.first + + expect(LagoHttpClient::Client).to have_received(:new).with(accounts_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(lago_client).to have_received(:get) + expect(result.accounts.count).to eq(3) + expect(account.external_id).to eq("12ec4c59-ad56-4a4f-93eb-fb0a7740f4e2") + expect(account.external_account_code).to eq("1111") + expect(account.external_name).to eq("Accounts Payable") + end + end + + describe "#params" do + subject(:method_call) { accounts_service.send(:params) } + + before { accounts_service.instance_variable_set(:@cursor, cursor) } + + context "when cursor is nil" do + let(:cursor) { nil } + let(:params) { {limit: described_class::LIMIT} } + + it "returns params without cursor" do + expect(subject).to eq(params) + end + end + + context "when cursor is blank" do + let(:cursor) { "" } + let(:params) { {limit: described_class::LIMIT} } + + it "returns params without cursor" do + expect(subject).to eq(params) + end + end + + context "when cursor is present" do + let(:cursor) { "next_cursor_value" } + let(:params) { {limit: described_class::LIMIT, cursor: "next_cursor_value"} } + + it "returns params with cursor" do + expect(subject).to eq(params) + end + end + end +end diff --git a/spec/services/integrations/aggregator/base_service_spec.rb b/spec/services/integrations/aggregator/base_service_spec.rb new file mode 100644 index 0000000..e70171f --- /dev/null +++ b/spec/services/integrations/aggregator/base_service_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::BaseService do + subject(:sync_service) { described_class.new(integration:) } + + let(:integration) { create(:netsuite_integration) } + + describe "#request_limit_error?" do + let(:http_error) { instance_double(LagoHttpClient::HttpError, error_body:) } + + context "when error body includes request limit error code" do + let(:error_body) { "Some error message including SSS_REQUEST_LIMIT_EXCEEDED" } + + it "returns true" do + expect(sync_service.send(:request_limit_error?, http_error)).to be true + end + end + + context "when error body does not include request limit error code" do + let(:error_body) { "Some other error message" } + + it "returns false" do + expect(sync_service.send(:request_limit_error?, http_error)).to be false + end + end + end +end diff --git a/spec/services/integrations/aggregator/companies/create_service_spec.rb b/spec/services/integrations/aggregator/companies/create_service_spec.rb new file mode 100644 index 0000000..d180ffe --- /dev/null +++ b/spec/services/integrations/aggregator/companies/create_service_spec.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Companies::CreateService do + subject(:service_call) { described_class.call(integration:, customer:, subsidiary_id:) } + + let(:service) { described_class.new(integration:, customer:, subsidiary_id:) } + let(:customer) { create(:customer, :with_same_billing_and_shipping_address, organization:) } + let(:subsidiary_id) { "1" } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/#{integration_type}/companies" } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => integration_type_key + } + end + + let(:customer_link) do + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{customer.organization.slug}/customer/", customer.id).to_s + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + end + + describe "#initialize" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + context "when customer is a company" do + let(:customer) do + create(:customer, :with_same_billing_and_shipping_address, organization:, customer_type: "company") + end + + it "initializes the service without errors" do + expect { described_class.new(integration:, customer:, subsidiary_id:) }.not_to raise_error + end + end + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when response is a string" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + let(:params) do + { + "properties" => { + "name" => customer.name, + "domain" => anything, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => anything + } + } + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/companies/success_hash_response.json") + File.read(path) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("2e50c200-9a54-4a66-b241-1e75fb87373f") + expect(result.email).to eq("roger@rogers.com") + end + + it "delivers a success webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_created", + customer + ).on_queue(webhook_queue) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when response is a hash" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + let(:params) do + { + "properties" => { + "name" => customer.name, + "domain" => anything, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => anything + } + } + end + + context "when contact is succesfully created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/companies/success_hash_response.json") + File.read(path) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("2e50c200-9a54-4a66-b241-1e75fb87373f") + end + + it "delivers a success webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_created", + customer + ).on_queue(webhook_queue) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when contact is not created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/companies/failure_hash_response.json") + File.read(path) + end + + it "does not return contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + + it_behaves_like "throttles!", :hubspot + end + end + end + + context "when service call is not successful" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + let(:params) do + { + "properties" => { + "name" => customer.name, + "domain" => anything, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => anything + } + } + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + let(:code) { "action_script_runtime_error" } + let(:message) { "submitFields: Missing a required argument: type" } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_error", + customer, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a server payload error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + let(:code) { "TypeError" } + let(:message) { "Please enter value(s) for: Company Name" } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_payload_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_error", + customer, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a client error" do + let(:error_code) { 404 } + let(:code) { "invalid_secret_key_format" } + let(:message) { "Authentication failed. The provided secret key is not a UUID v4." } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_auth_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_error", + customer, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :hubspot + end + end + end + + describe "#process_hash_result" do + subject(:process_hash_result) { service.send(:process_hash_result, body) } + + let(:result) { service.instance_variable_get(:@result) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + before do + allow(service).to receive(:deliver_error_webhook) + end + + context "when contact is successfully created" do + let(:body) do + { + "succeededCompanies" => [ + { + "id" => "2e50c200-9a54-4a66-b241-1e75fb87373f", + "email" => "billing@example.com" + } + ] + } + end + + it "sets the contact_id and email in the result" do + process_hash_result + + expect(result.contact_id).to eq("2e50c200-9a54-4a66-b241-1e75fb87373f") + expect(result.email).to eq("billing@example.com") + end + end + + context "when contact creation fails" do + let(:body) do + { + "failedCompanies" => [ + { + "validation_errors" => [ + {"Message" => "Email is invalid"}, + {"Message" => "Name is required"} + ] + } + ] + } + end + + it "delivers an error webhook" do + process_hash_result + + expect(service).to have_received(:deliver_error_webhook).with( + customer:, + code: "Validation error", + message: "Email is invalid. Name is required" + ) + end + end + + context "when there is a general error" do + let(:body) do + { + "error" => { + "payload" => { + "message" => "An unexpected error occurred" + } + } + } + end + + it "delivers an error webhook" do + process_hash_result + + expect(service).to have_received(:deliver_error_webhook).with( + customer:, + code: "Validation error", + message: "An unexpected error occurred" + ) + end + end + end +end diff --git a/spec/services/integrations/aggregator/companies/payloads/factory_spec.rb b/spec/services/integrations/aggregator/companies/payloads/factory_spec.rb new file mode 100644 index 0000000..2ab112b --- /dev/null +++ b/spec/services/integrations/aggregator/companies/payloads/factory_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Companies::Payloads::Factory do + let(:customer) { integration_customer.customer } + let(:integration) { integration_customer.integration } + let(:subsidiary_id) { "123" } + + describe ".new_instance" do + subject(:new_instance_call) do + described_class.new_instance(integration:, customer:, integration_customer:, subsidiary_id:) + end + + context "when customer is a hubspot customer" do + let(:integration_customer) { FactoryBot.create(:hubspot_customer, customer:) } + let(:customer) { FactoryBot.create(:customer) } + let(:customer_type) { ["company", nil].sample } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Companies::Payloads::Hubspot) + end + end + end +end diff --git a/spec/services/integrations/aggregator/companies/payloads/hubspot_spec.rb b/spec/services/integrations/aggregator/companies/payloads/hubspot_spec.rb new file mode 100644 index 0000000..afb7368 --- /dev/null +++ b/spec/services/integrations/aggregator/companies/payloads/hubspot_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Companies::Payloads::Hubspot do + let(:integration) { integration_customer.integration } + let(:integration_customer) { FactoryBot.create(:hubspot_customer, customer:) } + let(:customer) { create(:customer, customer_type: "company") } + let(:payload) { described_class.new(integration:, customer:, integration_customer:) } + let(:customer_link) { payload.__send__(:customer_url) } + let(:domain) { payload.__send__(:clean_url, customer.url) } + + describe "#create_body" do + subject(:create_body_call) { payload.create_body } + + let(:payload_body) do + { + "properties" => { + "name" => customer.name, + "domain" => domain, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => customer_link + } + } + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + describe "#update_body" do + subject(:update_body_call) { payload.update_body } + + let(:payload_body) do + { + "companyId" => integration_customer.external_customer_id, + "input" => { + "properties" => { + "name" => customer.name, + "domain" => domain, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => customer_link + } + } + } + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + + context "when customer fields are blank" do + let(:customer) { create(:customer, customer_type: "company", name: nil, url: nil) } + + it "excludes blank fields from properties" do + properties = subject.dig("input", "properties") + expect(properties).not_to have_key("name") + expect(properties).not_to have_key("domain") + end + end + end +end diff --git a/spec/services/integrations/aggregator/companies/update_service_spec.rb b/spec/services/integrations/aggregator/companies/update_service_spec.rb new file mode 100644 index 0000000..d1af8d2 --- /dev/null +++ b/spec/services/integrations/aggregator/companies/update_service_spec.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Companies::UpdateService do + subject(:service_call) { described_class.call(integration:, integration_customer:) } + + let(:service) { described_class.new(integration:, integration_customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/#{integration_type}/companies" } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => integration_type_key + } + end + + let(:customer_link) do + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{customer.organization.slug}/customer/", customer.id).to_s + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(service).to receive(:throttle!) + end + + describe "#initialize" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + context "when integration customer is a company" do + it "initializes the service without errors" do + expect { described_class.new(integration:, integration_customer:) }.not_to raise_error + end + end + + context "when integration customer is an individual" do + before do + allow(integration_customer.customer).to receive(:customer_type_individual?).and_return(true) + end + + it "raises an ArgumentError" do + expect { described_class.new(integration:, integration_customer:) } + .to raise_error(ArgumentError, "Integration customer is not a company") + end + end + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + let(:code) { 200 } + + context "when response is a string" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + let(:params) do + { + "companyId" => integration_customer.external_customer_id, + "input" => { + "properties" => { + "name" => customer.name, + "domain" => anything, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => anything + } + } + } + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/companies/success_string_response.json") + File.read(path) + end + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("1") + end + + it_behaves_like "throttles!", :hubspot + end + + context "when response is a hash" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + let(:params) do + { + "companyId" => integration_customer.external_customer_id, + "input" => { + "properties" => { + "name" => customer.name, + "domain" => anything, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => anything + } + } + } + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/companies/success_hash_response.json") + File.read(path) + end + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("2e50c200-9a54-4a66-b241-1e75fb87373f") + end + + it_behaves_like "throttles!", :hubspot + end + end + + context "when service call is not successful" do + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + let(:params) do + { + "companyId" => integration_customer.external_customer_id, + "input" => { + "properties" => { + "name" => customer.name, + "domain" => anything, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_tax_identification_number" => customer.tax_identification_number, + "lago_customer_link" => anything + } + } + } + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + let(:code) { "action_script_runtime_error" } + let(:message) { "submitFields: Missing a required argument: type" } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq("action_script_runtime_error") + expect(result.error.message) + .to eq("action_script_runtime_error: submitFields: Missing a required argument: type") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_error", + customer, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message: "submitFields: Missing a required argument: type", + error_code: "action_script_runtime_error" + } + ) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a server payload error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + let(:code) { "TypeError" } + let(:message) { "Please enter value(s) for: Company Name" } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_payload_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_error", + customer, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a client error" do + let(:error_code) { 404 } + let(:code) { "invalid_secret_key_format" } + let(:message) { "Authentication failed. The provided secret key is not a UUID v4." } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_auth_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.crm_provider_error", + customer, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :hubspot + end + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/create_service_spec.rb b/spec/services/integrations/aggregator/contacts/create_service_spec.rb new file mode 100644 index 0000000..58a1f0a --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/create_service_spec.rb @@ -0,0 +1,476 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::CreateService do + subject(:service_call) { described_class.call(integration:, customer:, subsidiary_id:) } + + let(:service) { described_class.new(integration:, customer:, subsidiary_id:) } + let(:customer) { create(:customer, :with_same_billing_and_shipping_address, organization:) } + let(:subsidiary_id) { "1" } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/#{integration_type}/contacts" } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => integration_type_key + } + end + + let(:customer_link) do + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{customer.organization.slug}/customer/", customer.id).to_s + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when response is a string" do + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_type) { "netsuite" } + let(:integration_type_key) { "netsuite-tba" } + + let(:params) do + { + "type" => "customer", + "isDynamic" => true, + "columns" => { + "companyname" => customer.name, + "isperson" => "F", + "subsidiary" => subsidiary_id, + "custentity_lago_id" => customer.id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_link, + "email" => customer.email, + "phone" => customer.phone, + "entityid" => customer.external_id, + "autoname" => false + }, + "options" => { + "ignoreMandatoryFields" => false + }, + "lines" => [ + { + "lineItems" => [ + { + "defaultshipping" => true, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + } + ], + "sublistId" => "addressbook" + } + ] + } + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/contacts/success_string_response.json") + File.read(path) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("1") + end + + it "delivers a success webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_created", + customer + ).on_queue(webhook_queue) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + + context "when response is a hash" do + let(:integration) { create(:xero_integration, organization:) } + let(:integration_type) { "xero" } + let(:integration_type_key) { "xero" } + + let(:params) do + [ + { + "name" => customer.name, + "firstname" => customer.firstname, + "lastname" => customer.lastname, + "email" => customer.email, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "phone" => customer.phone + } + ] + end + + context "when contact is succesfully created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/contacts/success_hash_response.json") + File.read(path) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("2e50c200-9a54-4a66-b241-1e75fb87373f") + end + + it "delivers a success webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_created", + customer + ).on_queue(webhook_queue) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + + context "when contact is not created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/contacts/failure_hash_response.json") + File.read(path) + end + + it "does not return contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + end + end + + context "when service call is not successful" do + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_type) { "netsuite" } + let(:integration_type_key) { "netsuite-tba" } + + let(:params) do + { + "type" => "customer", + "isDynamic" => true, + "columns" => { + "companyname" => customer.name, + "isperson" => "F", + "subsidiary" => subsidiary_id, + "custentity_lago_id" => customer.id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_link, + "email" => customer.email, + "phone" => customer.phone, + "entityid" => customer.external_id, + "autoname" => false + }, + "options" => { + "ignoreMandatoryFields" => false + }, + "lines" => [ + { + "lineItems" => [ + { + "defaultshipping" => true, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + } + ], + "sublistId" => "addressbook" + } + ] + } + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + + [ + { + ctx: "when the error is not handled specifically", + payload: { + error: "An unexpected error occurred" + }, + code: "unexpected_error", + message: "{\"error\":\"An unexpected error occurred\"}" + }, + { + ctx: "when error is nested in `error.payload`", + payload: { + error: { + message: "The action script failed with an error: {}", + code: "action_script_failure", + payload: { + error: "Error starting integration 'netsuite-customer-create': {\n \"name\": \"TRPCClientError\",\n \"message\": \"fetch failed\"\n}" + } + } + }, + code: "action_script_failure", + message: "Error starting integration 'netsuite-customer-create': {\n \"name\": \"TRPCClientError\",\n \"message\": \"fetch failed\"\n}" + }, + { + ctx: "when error is nested in `error.payload.error`", + payload: { + integration: "netsuite-tba", + action: "netsuite-create-contact", + connection: "netsuite-tba-xyz", + error: { + message: "An error occurred during an HTTP call", + payload: { + error: { + code: "INVALID_LOGIN_ATTEMPT", + message: "Invalid login attempt." + } + } + } + }, + code: "INVALID_LOGIN_ATTEMPT", + message: "Invalid login attempt." + }, + { + ctx: "when error is nested in `payload.message`", + payload: { + type: "action_script_runtime_error", + payload: { + message: "submitFields: Missing a required argument: type" + } + }, + code: "action_script_runtime_error", + message: "submitFields: Missing a required argument: type" + } + ].each do |test_case| + ctx, payload, code, message = test_case.values_at(:ctx, :payload, :code, :message) + context ctx do + let(:body) { payload.to_json } + let(:error_code) { 500 } + let(:result) { service_call } + + it "returns an error" do + expect { result }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_error", + customer, + provider: "netsuite", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + end + end + end + + context "when it is a server payload error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + let(:code) { "TypeError" } + let(:message) { "Please enter value(s) for: Company Name" } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_payload_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_error", + customer, + provider: "netsuite", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + + context "when it is a client error" do + let(:error_code) { 404 } + let(:code) { "invalid_secret_key_format" } + let(:message) { "Authentication failed. The provided secret key is not a UUID v4." } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_auth_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_error", + customer, + provider: "netsuite", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + end + end + + describe "#process_hash_result" do + subject(:process_hash_result) { service.send(:process_hash_result, body) } + + let(:result) { service.instance_variable_get(:@result) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_type) { "hubspot" } + let(:integration_type_key) { "hubspot" } + + before do + allow(service).to receive(:deliver_error_webhook) + end + + context "when contact is successfully created" do + let(:body) do + { + "succeededContacts" => [ + { + "id" => "2e50c200-9a54-4a66-b241-1e75fb87373f", + "email" => "billing@example.com" + } + ] + } + end + + it "sets the contact_id and email in the result" do + process_hash_result + + expect(result.contact_id).to eq("2e50c200-9a54-4a66-b241-1e75fb87373f") + expect(result.email).to eq("billing@example.com") + end + end + + context "when contact creation fails" do + let(:body) do + { + "failedContacts" => [ + { + "validation_errors" => [ + {"Message" => "Email is invalid"}, + {"Message" => "Name is required"} + ] + } + ] + } + end + + it "delivers an error webhook" do + process_hash_result + + expect(service).to have_received(:deliver_error_webhook).with( + customer:, + code: "Validation error", + message: "Email is invalid. Name is required" + ) + end + end + + context "when there is a general error" do + let(:body) do + { + "error" => { + "payload" => { + "message" => "An unexpected error occurred" + } + } + } + end + + it "delivers an error webhook" do + process_hash_result + + expect(service).to have_received(:deliver_error_webhook).with( + customer:, + code: "Validation error", + message: "An unexpected error occurred" + ) + end + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/contacts/payloads/anrok_spec.rb new file mode 100644 index 0000000..f52a577 --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/payloads/anrok_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::Payloads::Anrok do + let(:integration) { integration_customer.integration } + let(:integration_customer) { FactoryBot.create(:anrok_customer, customer:) } + let(:customer) { create(:customer) } + let(:payload) { described_class.new(integration:, customer:, integration_customer:) } + let(:customer_link) { payload.__send__(:customer_url) } + + describe "#create_body" do + subject(:create_body_call) { payload.create_body } + + let(:payload_body) do + [ + { + "name" => customer.display_name(prefer_legal_name: false), + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => customer.email, + "phone" => customer.phone + } + ] + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + describe "#update_body" do + subject(:update_body_call) { payload.update_body } + + let(:payload_body) do + [ + { + "id" => integration_customer.external_customer_id, + "name" => customer.display_name(prefer_legal_name: false), + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => customer.email, + "phone" => customer.phone + } + ] + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/payloads/avalara_spec.rb b/spec/services/integrations/aggregator/contacts/payloads/avalara_spec.rb new file mode 100644 index 0000000..0627455 --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/payloads/avalara_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::Payloads::Avalara do + let(:integration) { create(:avalara_integration, company_id: "12345") } + let(:integration_customer) { create(:avalara_customer, customer:, integration:, external_customer_id: "abc-12345") } + let(:payload) { described_class.new(integration:, customer:, integration_customer:) } + let(:name) { "#{firstname} #{lastname}" } + let(:customer_name) { nil } + let(:customer) do + create( + :customer, + firstname:, + lastname:, + name: customer_name, + shipping_address_line1: "123 Main St", + address_line1: "456 Elm St", + city: "Springfield", + zipcode: "12345", + country: "US", + state: "IL", + tax_identification_number: "123456789" + ) + end + + describe "#create_body" do + subject(:create_body_call) { payload.create_body } + + let(:payload_body) do + [ + { + "company_id" => integration.company_id.to_i, + "external_id" => customer.id, + "name" => name, + "address_line_1" => customer.shipping_address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "tax_number" => customer.tax_identification_number + } + ] + end + + context "when name, firstname and lastname are present" do + let(:firstname) { "John" } + let(:lastname) { "Doe" } + let(:customer_name) { "Mark Doe" } + let(:name) { customer.name } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when firstname and lastname are present" do + let(:firstname) { "John" } + let(:lastname) { "Doe" } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when both firstname and lastname are empty" do + let(:firstname) { "" } + let(:lastname) { "" } + let(:name) { "" } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when lastname is blank" do + let(:firstname) { "John" } + let(:lastname) { "" } + let(:name) { "John" } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + end + + describe "#update_body" do + subject(:update_body_call) { payload.update_body } + + let(:payload_body) do + [ + { + "company_id" => integration.company_id.to_i, + "external_id" => customer.id, + "name" => name, + "address_line_1" => customer.shipping_address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "tax_number" => customer.tax_identification_number + } + ] + end + + context "when firstname and lastname are present" do + let(:firstname) { "John" } + let(:lastname) { "Doe" } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/payloads/factory_spec.rb b/spec/services/integrations/aggregator/contacts/payloads/factory_spec.rb new file mode 100644 index 0000000..18dbff5 --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/payloads/factory_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::Payloads::Factory do + let(:customer) { integration_customer.customer } + let(:integration) { integration_customer.integration } + let(:subsidiary_id) { "123" } + + describe ".new_instance" do + subject(:new_instance_call) do + described_class.new_instance(integration:, customer:, integration_customer:, subsidiary_id:) + end + + context "when customer is a netsuite customer" do + let(:integration_customer) { FactoryBot.create(:netsuite_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Contacts::Payloads::Netsuite) + end + end + + context "when customer is a xero customer" do + let(:integration_customer) { FactoryBot.create(:xero_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Contacts::Payloads::Xero) + end + end + + context "when customer is an anrok customer" do + let(:integration_customer) { FactoryBot.create(:anrok_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Contacts::Payloads::Anrok) + end + end + + context "when customer is a hubspot customer" do + let(:integration_customer) { FactoryBot.create(:hubspot_customer, customer:) } + let(:customer) { FactoryBot.create(:customer, customer_type:) } + let(:customer_type) { "individual" } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Contacts::Payloads::Hubspot) + end + end + end + + describe "#create_body" do + subject(:create_body) do + described_class.new_instance(integration:, customer:, integration_customer:, subsidiary_id:).create_body + end + + context "when customer is a netsuite customer" do + let(:integration_customer) { FactoryBot.create(:netsuite_customer) } + + it "returns payload body" do + expect(subject["columns"]["companyname"]).to eq(customer.name) + end + end + + context "when customer is a xero customer" do + let(:integration_customer) { FactoryBot.create(:xero_customer) } + + it "returns payload body" do + expect(subject.first["name"]).to eq(customer.name) + end + end + + context "when customer is an anrok customer" do + let(:integration_customer) { FactoryBot.create(:anrok_customer) } + + it "returns payload body" do + expect(subject.first["name"]).to eq(customer.display_name(prefer_legal_name: false)) + end + end + end + + describe "#update_body" do + subject(:update_body) do + described_class.new_instance(integration:, customer:, integration_customer:, subsidiary_id:).update_body + end + + context "when customer is a netsuite customer" do + let(:integration_customer) { FactoryBot.create(:netsuite_customer) } + + it "returns payload body" do + expect(subject["recordId"]).to eq(integration_customer.external_customer_id) + expect(subject["columns"]["companyname"]).to eq(customer.name) + expect(subject["columns"]["entityid"]).to eq(customer.external_id) + end + end + + context "when customer is a xero customer" do + let(:integration_customer) { FactoryBot.create(:xero_customer) } + + it "returns payload body" do + expect(subject.first["id"]).to eq(integration_customer.external_customer_id) + expect(subject.first["name"]).to eq(customer.name) + end + end + + context "when customer is an anrok customer" do + let(:integration_customer) { FactoryBot.create(:anrok_customer) } + + it "returns payload body" do + expect(subject.first["id"]).to eq(integration_customer.external_customer_id) + expect(subject.first["name"]).to eq(customer.display_name(prefer_legal_name: false)) + end + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/payloads/hubspot_spec.rb b/spec/services/integrations/aggregator/contacts/payloads/hubspot_spec.rb new file mode 100644 index 0000000..49cbfc4 --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/payloads/hubspot_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::Payloads::Hubspot do + let(:integration) { integration_customer.integration } + let(:integration_customer) { FactoryBot.create(:hubspot_customer, customer:) } + let(:customer) { create(:customer, customer_type: "individual") } + let(:payload) { described_class.new(integration:, customer:, integration_customer:) } + let(:customer_link) { payload.__send__(:customer_url) } + let(:website) { payload.__send__(:clean_url, customer.url) } + + describe "#create_body" do + subject(:create_body_call) { payload.create_body } + + let(:payload_body) do + { + "properties" => { + "email" => customer.email, + "firstname" => customer.firstname, + "lastname" => customer.lastname, + "phone" => customer.phone, + "company" => customer.legal_name, + "website" => website, + "lago_customer_id" => customer.id, + "lago_customer_external_id" => customer.external_id, + "lago_billing_email" => customer.email, + "lago_customer_link" => customer_link + } + } + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + describe "#update_body" do + subject(:update_body_call) { payload.update_body } + + let(:payload_body) do + { + "contactId" => integration_customer.external_customer_id, + "input" => { + "properties" => { + "email" => customer.email, + "firstname" => customer.firstname, + "lastname" => customer.lastname, + "phone" => customer.phone, + "company" => customer.legal_name, + "website" => website + } + } + } + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + + context "when customer fields are blank" do + let(:customer) { create(:customer, customer_type: "individual", phone: nil, url: nil) } + + it "excludes blank fields from properties" do + properties = subject.dig("input", "properties") + expect(properties).not_to have_key("phone") + expect(properties).not_to have_key("website") + end + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/payloads/netsuite_spec.rb b/spec/services/integrations/aggregator/contacts/payloads/netsuite_spec.rb new file mode 100644 index 0000000..80df616 --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/payloads/netsuite_spec.rb @@ -0,0 +1,537 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::Payloads::Netsuite do + let(:integration) { integration_customer.integration } + let(:integration_customer) { FactoryBot.create(:netsuite_customer, customer:) } + let(:customer) { create(:customer, email:, phone:) } + let(:subsidiary_id) { Faker::Number.number(digits: 2) } + let(:payload) { described_class.new(integration:, customer:, integration_customer:, subsidiary_id:) } + let(:customer_link) { payload.__send__(:customer_url) } + let(:email) { "email@test.com,email2@test.com" } + let(:phone) { nil } + + describe "#create_body" do + subject(:create_body_call) { payload.create_body } + + let(:payload_body) do + { + "type" => "customer", + "isDynamic" => true, + "columns" => { + "companyname" => customer.name, + "subsidiary" => subsidiary_id, + "isperson" => "F", + "custentity_lago_id" => customer.id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_link, + "email" => customer.email.to_s.split(",").first&.strip, + "phone" => customer.phone.to_s.split(",").first&.strip, + "entityid" => customer.external_id, + "autoname" => false + }.merge( + customer.customer_type_individual? ? {"firstname" => customer.firstname, "lastname" => customer.lastname} : {} + ), + "options" => { + "ignoreMandatoryFields" => false + }, + "lines" => lines + } + end + + context "when legacy script is false" do + context "when shipping address is present" do + context "when shipping address is not the same as billing address" do + let(:customer) { create(:customer, :with_shipping_address, email:, phone:) } + + let(:lines) do + [ + { + "lineItems" => [ + { + "defaultshipping" => false, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + }, + { + "defaultshipping" => true, + "defaultbilling" => false, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.shipping_address_line1, + "addr2" => customer.shipping_address_line2, + "city" => customer.shipping_city, + "zip" => customer.shipping_zipcode, + "state" => customer.shipping_state, + "country" => customer.shipping_country + } + } + ], + "sublistId" => "addressbook" + } + ] + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when shipping address is the same as billing address" do + let(:customer) { create(:customer, :with_same_billing_and_shipping_address, email:, phone:) } + + let(:lines) do + [ + { + "lineItems" => [ + { + "defaultshipping" => true, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + } + ], + "sublistId" => "addressbook" + } + ] + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + end + + context "when shipping address is not present" do + let(:lines) do + [ + { + "lineItems" => [ + { + "defaultshipping" => true, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + } + ], + "sublistId" => "addressbook" + } + ] + end + + context "when billing address is present" do + let(:customer) { create(:customer, email:, phone:, state: nil) } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + + context "when city name is too long" do + let(:customer) { create(:customer, email:, phone:, state: nil, city: "Lorem ipsum dolor sit amet, consectetur adipiscing elit") } + + it "returns the payload body with truncated city name" do + expect(subject["lines"].first["lineItems"].first["subObject"]["city"]).to eq("Lorem ipsum dolor sit amet, consectetur adipiscing") + end + end + end + + context "when billing address is not present" do + let(:customer) do + create( + :customer, + email:, + phone:, + address_line1: nil, + address_line2: nil, + city: nil, + zipcode: nil, + state: nil, + country: nil + ) + end + + it "returns the payload body without lines" do + expect(subject).to eq payload_body.except("lines") + end + end + end + end + + context "when legacy script is true" do + before { integration.legacy_script = true } + + context "when shipping address is present" do + context "when shipping address is not the same as billing address" do + let(:customer) { create(:customer, :with_shipping_address, email:, phone:) } + + let(:lines) do + [ + { + "lineItems" => [ + { + "defaultshipping" => false, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + }, + { + "defaultshipping" => true, + "defaultbilling" => false, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.shipping_address_line1, + "addr2" => customer.shipping_address_line2, + "city" => customer.shipping_city, + "zip" => customer.shipping_zipcode, + "state" => customer.shipping_state, + "country" => customer.shipping_country + } + } + ], + "sublistId" => "addressbook" + } + ] + end + + it "returns the payload body without lines" do + expect(subject).to eq payload_body.except("lines") + end + end + + context "when shipping address is the same as billing address" do + let(:customer) { create(:customer, :with_same_billing_and_shipping_address, email:, phone:) } + + let(:lines) do + [ + { + "lineItems" => [ + { + "defaultshipping" => true, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + } + ], + "sublistId" => "addressbook" + } + ] + end + + it "returns the payload body without lines" do + expect(subject).to eq payload_body.except("lines") + end + end + end + + context "when shipping address is not present" do + let(:lines) do + [ + { + "lineItems" => [ + { + "defaultshipping" => true, + "defaultbilling" => true, + "subObjectId" => "addressbookaddress", + "subObject" => { + "addr1" => customer.address_line1, + "addr2" => customer.address_line2, + "city" => customer.city, + "zip" => customer.zipcode, + "state" => customer.state, + "country" => customer.country + } + } + ], + "sublistId" => "addressbook" + } + ] + end + + context "when billing address is present" do + let(:customer) { create(:customer, email:, phone:) } + + it "returns the payload body without lines" do + expect(subject).to eq payload_body.except("lines") + end + end + + context "when billing address is not present" do + let(:customer) do + create( + :customer, + email:, + phone:, + address_line1: nil, + address_line2: nil, + city: nil, + zipcode: nil, + state: nil, + country: nil + ) + end + + it "returns the payload body without lines" do + expect(subject).to eq payload_body.except("lines") + end + end + end + end + end + + describe "#update_body" do + subject(:update_body_call) { payload.update_body } + + let(:customer) { create(:customer, customer_type:) } + let(:isperson) { payload.__send__(:isperson) } + + let(:payload_body) do + { + "type" => "customer", + "recordId" => integration_customer.external_customer_id, + "columns" => { + "isperson" => isperson, + "subsidiary" => integration_customer.subsidiary_id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_link, + "email" => customer.email.to_s.split(",").first&.strip, + "phone" => customer.phone.to_s.split(",").first&.strip, + "entityid" => customer.external_id, + "autoname" => false + }.merge(names), + "options" => { + "isDynamic" => false + } + } + end + + context "when customer is an individual" do + let(:customer_type) { :individual } + + let(:names) do + { + "companyname" => customer.name, + "firstname" => customer.firstname, + "lastname" => customer.lastname + } + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when customer is not an individual" do + let(:customer_type) { [nil, :company].sample } + + let(:names) do + {"companyname" => customer.name} + end + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + end + + describe "#email" do + subject(:email_call) { payload.__send__(:email) } + + let(:customer) { create(:customer, email:) } + + context "when email is nil" do + let(:email) { nil } + + it "returns nil" do + expect(subject).to be(nil) + end + end + + context "when email is an empty string" do + let(:email) { "" } + + it "returns nil" do + expect(subject).to be(nil) + end + end + + context "when email contains one email" do + let(:email) { Faker::Internet.email } + + it "returns email" do + expect(subject).to eq(email) + end + end + + context "when email contains comma-separated email addresses" do + let(:email) { "#{email1},#{email2}" } + let(:email1) { Faker::Internet.email } + let(:email2) { Faker::Internet.email } + + it "returns first email address" do + expect(subject).to eq(email1) + end + end + end + + describe "#names" do + subject(:names_call) { payload.__send__(:names) } + + let(:customer) { create(:customer, customer_type:, name:) } + + context "when customer type is nil" do + let(:customer_type) { nil } + let(:name) { Faker::TvShows::SiliconValley.character } + let(:names) { {"companyname" => customer.name} } + + it "returns the result hash" do + expect(subject).to eq(names) + end + end + + context "when customer type is company" do + let(:customer_type) { :company } + let(:name) { Faker::TvShows::SiliconValley.character } + + let(:names) { {"companyname" => customer.name} } + + it "returns the result hash" do + expect(subject).to eq(names) + end + end + + context "when customer type is individual" do + let(:customer_type) { :individual } + + context "when name is present" do + let(:name) { Faker::TvShows::SiliconValley.character } + + let(:names) do + {"companyname" => customer.name, "firstname" => customer.firstname, "lastname" => customer.lastname} + end + + it "returns the result hash" do + expect(subject).to eq(names) + end + end + + context "when name is not present" do + let(:name) { nil } + + let(:names) do + {"firstname" => customer.firstname, "lastname" => customer.lastname} + end + + it "returns the result hash" do + expect(subject).to eq(names) + end + end + end + end + + describe "#isperson" do + subject(:isperson_call) { payload.__send__(:isperson) } + + let(:customer) { create(:customer, customer_type:) } + + context "when customer type is nil" do + let(:customer_type) { nil } + + it "returns F" do + expect(subject).to eq("F") + end + end + + context "when customer type is company" do + let(:customer_type) { :company } + + it "returns F" do + expect(subject).to eq("F") + end + end + + context "when customer type is individual" do + let(:customer_type) { :individual } + + it "returns T" do + expect(subject).to eq("T") + end + end + end + + describe "#phone" do + subject { payload.__send__(:phone) } + + let(:customer) { create(:customer, phone:) } + + context "when phone is nil" do + let(:phone) { nil } + + it "returns nil" do + expect(subject).to be(nil) + end + end + + context "when phone is an empty string" do + let(:phone) { "" } + + it "returns nil" do + expect(subject).to be(nil) + end + end + + context "when phone contains one phone number" do + let(:phone) { Faker::PhoneNumber.phone_number } + + it "returns phone" do + expect(subject).to eq(phone) + end + end + + context "when phone contains comma-separated phone numbers" do + let(:phone) { "#{phone1},#{phone2}" } + let(:phone1) { Faker::PhoneNumber.phone_number } + let(:phone2) { Faker::PhoneNumber.phone_number } + + it "returns first phone number" do + expect(subject).to eq(phone1) + end + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/payloads/xero_spec.rb b/spec/services/integrations/aggregator/contacts/payloads/xero_spec.rb new file mode 100644 index 0000000..91a0916 --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/payloads/xero_spec.rb @@ -0,0 +1,121 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::Payloads::Xero do + let(:integration) { integration_customer.integration } + let(:integration_customer) { FactoryBot.create(:xero_customer, customer:) } + let(:customer) { create(:customer, firstname:, lastname:) } + let(:payload) { described_class.new(integration:, customer:, integration_customer:) } + let(:customer_link) { payload.__send__(:customer_url) } + let(:contact_names) { {"firstname" => firstname, "lastname" => lastname}.compact_blank } + + describe "#create_body" do + subject(:create_body_call) { payload.create_body } + + let(:payload_body) do + [ + { + "name" => customer.name, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => customer.email, + "phone" => customer.phone + }.merge(contact_names) + ] + end + + context "when firstname and lastname are blank" do + let(:firstname) { [nil, ""].sample } + let(:lastname) { [nil, ""].sample } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when both firstname and lastname are present" do + let(:firstname) { Faker::Name.first_name } + let(:lastname) { Faker::Name.last_name } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when firstname is present" do + let(:firstname) { Faker::Name.first_name } + let(:lastname) { [nil, ""].sample } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when lastname is present" do + let(:firstname) { [nil, ""].sample } + let(:lastname) { Faker::Name.last_name } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + end + + describe "#update_body" do + subject(:update_body_call) { payload.update_body } + + let(:payload_body) do + [ + { + "id" => integration_customer.external_customer_id, + "name" => customer.name, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "email" => customer.email, + "phone" => customer.phone + }.merge(contact_names) + ] + end + + context "when firstname and lastname are blank" do + let(:firstname) { [nil, ""].sample } + let(:lastname) { [nil, ""].sample } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when both firstname and lastname are present" do + let(:firstname) { Faker::Name.first_name } + let(:lastname) { Faker::Name.last_name } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when firstname is present" do + let(:firstname) { Faker::Name.first_name } + let(:lastname) { [nil, ""].sample } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + + context "when lastname is present" do + let(:firstname) { [nil, ""].sample } + let(:lastname) { Faker::Name.last_name } + + it "returns the payload body" do + expect(subject).to eq payload_body + end + end + end +end diff --git a/spec/services/integrations/aggregator/contacts/update_service_spec.rb b/spec/services/integrations/aggregator/contacts/update_service_spec.rb new file mode 100644 index 0000000..407df2c --- /dev/null +++ b/spec/services/integrations/aggregator/contacts/update_service_spec.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Contacts::UpdateService do + subject(:service_call) { described_class.call(integration:, integration_customer:) } + + let(:service) { described_class.new(integration:, integration_customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/#{integration_type}/contacts" } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => integration_type_key + } + end + + let(:customer_link) do + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{customer.organization.slug}/customer/", customer.id).to_s + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + let(:code) { 200 } + + context "when response is a string" do + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration_type) { "netsuite" } + let(:integration_type_key) { "netsuite-tba" } + + let(:params) do + { + "type" => "customer", + "recordId" => integration_customer.external_customer_id, + "columns" => { + "companyname" => customer.name, + "isperson" => "F", + "subsidiary" => integration_customer.subsidiary_id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_link, + "email" => customer.email, + "phone" => customer.phone, + "entityid" => customer.external_id, + "autoname" => false + }, + "options" => { + "isDynamic" => false + } + } + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/contacts/success_string_response.json") + File.read(path) + end + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("1") + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + + context "when response is a hash" do + let(:integration) { create(:xero_integration, organization:) } + let(:integration_customer) { create(:xero_customer, integration:, customer:) } + let(:integration_type) { "xero" } + let(:integration_type_key) { "xero" } + + let(:params) do + [ + { + "id" => integration_customer.external_customer_id, + "name" => customer.name, + "firstname" => customer.firstname, + "lastname" => customer.lastname, + "email" => customer.email, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "state" => customer.state, + "phone" => customer.phone + } + ] + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/contacts/success_hash_response.json") + File.read(path) + end + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "returns contact id" do + result = service_call + + expect(result).to be_success + expect(result.contact_id).to eq("2e50c200-9a54-4a66-b241-1e75fb87373f") + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + end + + context "when service call is not successful" do + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration_type) { "netsuite" } + let(:integration_type_key) { "netsuite-tba" } + + let(:params) do + { + "type" => "customer", + "recordId" => integration_customer.external_customer_id, + "columns" => { + "companyname" => customer.name, + "isperson" => "F", + "subsidiary" => integration_customer.subsidiary_id, + "custentity_lago_sf_id" => customer.external_salesforce_id, + "custentity_lago_customer_link" => customer_link, + "email" => customer.email, + "phone" => customer.phone, + "entityid" => customer.external_id, + "autoname" => false + }, + "options" => { + "isDynamic" => false + } + } + end + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + let(:code) { "action_script_runtime_error" } + let(:message) { "submitFields: Missing a required argument: type" } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq("action_script_runtime_error") + expect(result.error.message) + .to eq("action_script_runtime_error: submitFields: Missing a required argument: type") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_error", + customer, + provider: "netsuite", + provider_code: integration.code, + provider_error: { + message: "submitFields: Missing a required argument: type", + error_code: "action_script_runtime_error" + } + ) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + + context "when it is a server payload error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + let(:code) { "TypeError" } + let(:message) { "Please enter value(s) for: Company Name" } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_payload_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_error", + customer, + provider: "netsuite", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + + context "when it is a client error" do + let(:error_code) { 404 } + let(:code) { "invalid_secret_key_format" } + let(:message) { "Authentication failed. The provided secret key is not a UUID v4." } + + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_auth_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.code).to eq(code) + expect(result.error.message).to eq("#{code}: #{message}") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.accounting_provider_error", + customer, + provider: "netsuite", + provider_code: integration.code, + provider_error: { + message:, + error_code: code + } + ) + end + + it_behaves_like "throttles!", :anrok, :hubspot, :netsuite, :xero + end + end + end +end diff --git a/spec/services/integrations/aggregator/credit_notes/create_service_spec.rb b/spec/services/integrations/aggregator/credit_notes/create_service_spec.rb new file mode 100644 index 0000000..2bd2627 --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/create_service_spec.rb @@ -0,0 +1,457 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::CreateService do + subject(:service_call) { described_class.call(credit_note: credit_note.reload) } + + let(:service) { described_class.new(credit_note: credit_note.reload) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/netsuite/creditnotes" } + let(:add_on) { create(:add_on, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:) } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:integration_collection_mapping2) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :coupon, + settings: {external_id: "2", external_account_code: "22", external_name: ""} + ) + end + let(:integration_collection_mapping3) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :subscription_fee, + settings: {external_id: "3", external_account_code: "33", external_name: ""} + ) + end + let(:integration_collection_mapping4) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :minimum_commitment, + settings: {external_id: "4", external_account_code: "44", external_name: ""} + ) + end + let(:integration_collection_mapping5) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :tax, + settings: {external_id: "5", external_account_code: "55", external_name: ""} + ) + end + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on.id, + settings: {external_id: "m1", external_account_code: "m11", external_name: ""} + ) + end + let(:integration_mapping_bm) do + create( + :netsuite_mapping, + integration:, + mappable_type: "BillableMetric", + mappable_id: billable_metric.id, + settings: {external_id: "m2", external_account_code: "m22", external_name: ""} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 8000 + ) + end + let(:credit_note) do + create( + :credit_note, + customer:, + invoice:, + status: "finalized", + organization:, + coupons_adjustment_amount_cents: 2000, + taxes_amount_cents: 8000 + ) + end + let(:fee_sub) do + create( + :fee, + invoice: + ) + end + let(:minimum_commitment_fee) do + create( + :minimum_commitment_fee, + invoice: + ) + end + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + units: 2, + precise_unit_amount: 4.12 + ) + end + + let(:credit_note_item3) { create(:credit_note_item, credit_note:, fee: charge_fee, amount_cents: 212) } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "netsuite-tba" + } + end + + let(:params) do + { + "type" => "creditmemo", + "isDynamic" => true, + "columns" => { + "tranid" => credit_note.number, + "entity" => integration_customer.external_customer_id, + "taxregoverride" => true, + "taxdetailsoverride" => true, + "otherrefnum" => credit_note.number, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_id" => credit_note.id, + "tranId" => credit_note.id + }, + "lines" => [ + { + "sublistId" => "item", + "lineItems" => [ + { + "item" => "m2", + "account" => "m22", + "quantity" => 1, + "rate" => 2.12, + "taxdetailsreference" => anything, + "description" => charge_fee.item_name + }, + { + "item" => "2", + "account" => "22", + "quantity" => 1, + "rate" => -20.0, + "taxdetailsreference" => "coupon_item", + "description" => description + } + ] + } + ], + "options" => { + "ignoreMandatoryFields" => false, + "fullCreditNotePayload" => { + "credit_note_payload" => hash_including( + lago_id: credit_note.id, + billing_entity_code: invoice.billing_entity.code, + sequential_id: credit_note.sequential_id, + number: credit_note.number, + lago_invoice_id: invoice.id, + invoice_number: invoice.number, + issuing_date: credit_note.issuing_date&.iso8601, + credit_status: credit_note.credit_status, + refund_status: credit_note.refund_status, + reason: credit_note.reason, + description: credit_note.description, + currency: credit_note.currency, + total_amount_cents: credit_note.total_amount_cents, + precise_total_amount_cents: credit_note.precise_total&.to_s, + taxes_amount_cents: credit_note.taxes_amount_cents, + precise_taxes_amount_cents: credit_note.precise_taxes_amount_cents&.to_s, + sub_total_excluding_taxes_amount_cents: credit_note.sub_total_excluding_taxes_amount_cents, + balance_amount_cents: credit_note.balance_amount_cents, + credit_amount_cents: credit_note.credit_amount_cents, + refund_amount_cents: credit_note.refund_amount_cents, + offset_amount_cents: credit_note.offset_amount_cents, + coupons_adjustment_amount_cents: credit_note.coupons_adjustment_amount_cents, + taxes_rate: credit_note.taxes_rate, + created_at: credit_note.created_at.iso8601, + updated_at: credit_note.updated_at.iso8601, + customer: hash_including( + lago_id: customer.id, + external_id: customer.external_id, + name: customer.name, + integration_customers: anything + ), + items: credit_note.items.map do |item| + hash_including( + lago_id: item.id + ) + end, + applied_taxes: anything + ) + } + } + } + end + + let(:description) { credit_note.invoice.credits.coupon_kind.map(&:item_name).join(",") } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + charge + credit_note + integration_collection_mapping1 + integration_collection_mapping2 + integration_collection_mapping3 + integration_collection_mapping4 + integration_collection_mapping5 + integration_mapping_add_on + integration_mapping_bm + fee_sub + minimum_commitment_fee + charge_fee + + if credit_note + credit_note_item3 + credit_note.reload + end + + integration.sync_credit_notes = true + integration.save! + end + + describe "#call_async" do + subject(:service_call_async) { described_class.new(credit_note:).call_async } + + context "when credit_note exists" do + it "enqueues credit_note create job" do + expect { service_call_async }.to enqueue_job(Integrations::Aggregator::CreditNotes::CreateJob) + end + end + + context "when credit_note does not exist" do + let(:credit_note) { nil } + + it "returns an error" do + result = service_call_async + + expect(result).not_to be_success + expect(result.error.error_code).to eq("credit_note_not_found") + end + end + end + + describe "#call" do + context "when integration_credit_note exists" do + let(:integration_credit_note) do + create(:integration_resource, integration:, syncable: credit_note, resource_type: "credit_note") + end + + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + integration_credit_note + end + + it "returns result without making an API call" do + expect(lago_client).not_to have_received(:post_with_response) + result = service_call + + expect(result).to be_success + expect(result.external_id).to be_nil + end + end + + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when response is a hash" do + context "when credit note is succesfully created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/credit_notes/success_hash_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("e5a62e05-e192-489f-8965-e01b597b523b") + end + + it "creates integration resource object" do + expect { service_call } + .to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(credit_note.id) + expect(integration_resource.syncable_type).to eq("CreditNote") + expect(integration_resource.resource_type).to eq("credit_note") + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + + context "when credit note is not created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/credit_notes/failure_hash_response.json") + File.read(path) + end + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + end + + context "when response is a string" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/credit_notes/success_string_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("456") + end + + it "creates integration resource object" do + expect { service_call } + .to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(credit_note.id) + expect(integration_resource.syncable_type).to eq("CreditNote") + expect(integration_resource.resource_type).to eq("credit_note") + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { 500 } + + it "returns an error" do + expect do + service_call + end.to raise_error(http_error) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob).and raise_error(http_error) + end + end + + context "when it is a client error" do + let(:error_code) { 400 } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + end + + context "when there is payload error" do + let(:integration) { create(:xero_integration, organization:) } + let(:integration_customer) { create(:xero_customer, integration:, customer:) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/xero/creditnotes" } + let(:integration_collection_mapping1) { nil } + let(:integration_collection_mapping2) { nil } + let(:integration_collection_mapping3) { nil } + let(:integration_collection_mapping4) { nil } + let(:integration_collection_mapping5) { nil } + let(:integration_collection_mapping6) { nil } + let(:integration_mapping_add_on) { nil } + let(:integration_mapping_bm) { nil } + let(:response) { instance_double(Net::HTTPOK) } + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "xero" + } + end + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/credit_notes/success_hash_response.json") + File.read(path) + end + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "sends error webhook" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + end +end diff --git a/spec/services/integrations/aggregator/credit_notes/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/credit_notes/payloads/anrok_spec.rb new file mode 100644 index 0000000..7e5ee8c --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/payloads/anrok_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::Payloads::Anrok do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, credit_note:).body } + + it_behaves_like "an integration payload", :anrok do + def build_expected_payload(mapping_codes) + [ + { + "currency" => "EUR", + "external_contact_id" => integration_customer.external_customer_id, + "fees" => + [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "external_id" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 1.9, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:fixed_charge, :external_account_code), + "description" => "Fixed Charge Fee", + "external_id" => mapping_codes.dig(:fixed_charge, :external_id), + "precise_unit_amount" => 1.4, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "external_id" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 1.8, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "external_id" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 1.7, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "external_id" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 1.6, + "taxes_amount_cents" => 0.0, + "units" => 1 + } + ], + "issuing_date" => "2024-07-08T00:00:00Z", + "number" => credit_note.number, + "status" => "AUTHORISED", + "type" => "ACCRECCREDIT" + } + ] + end + end + end +end diff --git a/spec/services/integrations/aggregator/credit_notes/payloads/factory_spec.rb b/spec/services/integrations/aggregator/credit_notes/payloads/factory_spec.rb new file mode 100644 index 0000000..b33ecd2 --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/payloads/factory_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::Payloads::Factory do + describe ".new_instance" do + subject(:new_instance_call) { described_class.new_instance(integration_customer:, credit_note:) } + + let(:credit_note) { FactoryBot.create(:credit_note) } + + context "when customer is a netsuite customer" do + let(:integration_customer) { FactoryBot.create(:netsuite_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::CreditNotes::Payloads::Netsuite) + end + end + + context "when customer is a xero customer" do + let(:integration_customer) { FactoryBot.create(:xero_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::CreditNotes::Payloads::Xero) + end + end + + context "when customer is an anrok customer" do + let(:integration_customer) { FactoryBot.create(:anrok_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::CreditNotes::Payloads::Anrok) + end + end + end +end diff --git a/spec/services/integrations/aggregator/credit_notes/payloads/netsuite_spec.rb b/spec/services/integrations/aggregator/credit_notes/payloads/netsuite_spec.rb new file mode 100644 index 0000000..a9c2091 --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/payloads/netsuite_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::Payloads::Netsuite do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, credit_note:).body } + + context "when credit note has a fixed_charge fee" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + + let(:add_on) { create(:add_on, organization:) } + let(:plan) { create(:plan, organization:) } + let(:fixed_charge) { create(:fixed_charge, organization:, plan:, add_on:) } + + let(:invoice) { create(:invoice, customer:, organization:) } + let(:fixed_charge_fee) { create(:fixed_charge_fee, invoice:, fixed_charge:, amount_cents: 5000) } + let(:credit_note) { create(:credit_note, customer:, invoice:) } + let(:fixed_charge_credit_note_item) { create(:credit_note_item, credit_note:, fee: fixed_charge_fee, amount_cents: 2500) } + + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on.id, + settings: {external_id: "fc-ext-id", external_account_code: "fc-account", external_name: ""} + ) + end + + before do + integration_customer + integration_mapping_add_on + fixed_charge_credit_note_item + credit_note.reload + end + + it "includes the fixed_charge fee using the add_on mapping" do + line_items = payload["lines"].first["lineItems"] + fixed_charge_line = line_items.find { |item| item["taxdetailsreference"] == fixed_charge_credit_note_item.id } + + expect(fixed_charge_line).to be_present + expect(fixed_charge_line["item"]).to eq("fc-ext-id") + expect(fixed_charge_line["account"]).to eq("fc-account") + end + end + + it_behaves_like "an integration payload", :netsuite do + def build_expected_payload(mapping_codes) + { + "columns" => { + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_id" => credit_note.id, + "entity" => integration_customer.external_customer_id, + "otherrefnum" => credit_note.number, + "taxdetailsoverride" => true, + "taxregoverride" => true, + "tranId" => credit_note.id, + "tranid" => credit_note.number + }, + "isDynamic" => true, + "lines" => [ + { + "lineItems" => [ + { + "account" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on", + "item" => mapping_codes.dig(:add_on, :external_id), + "quantity" => 1, + "rate" => 1.9, + "taxdetailsreference" => add_on_credit_note_item.id + }, + { + "account" => mapping_codes.dig(:fixed_charge, :external_account_code), + "description" => "Fixed Charge Add-on", + "item" => mapping_codes.dig(:fixed_charge, :external_id), + "quantity" => 1, + "rate" => 1.4, + "taxdetailsreference" => fixed_charge_credit_note_item.id + }, + { + "account" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Billable Metric", + "item" => mapping_codes.dig(:billable_metric, :external_id), + "quantity" => 1, + "rate" => 1.8, + "taxdetailsreference" => billable_metric_credit_note_item.id + }, + { + "account" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Plan", + "item" => mapping_codes.dig(:minimum_commitment, :external_id), + "quantity" => 1, + "rate" => 1.7, + "taxdetailsreference" => minimum_commitment_credit_note_item.id + }, + {"account" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Plan", + "item" => mapping_codes.dig(:subscription, :external_id), + "quantity" => 1, + "rate" => 1.6, + "taxdetailsreference" => subscription_credit_note_item.id} + ], + "sublistId" => "item" + } + ], + "options" => { + "ignoreMandatoryFields" => false, + "fullCreditNotePayload" => { + "credit_note_payload" => hash_including( + lago_id: credit_note.id, + billing_entity_code: invoice.billing_entity.code, + sequential_id: credit_note.sequential_id, + number: credit_note.number, + lago_invoice_id: invoice.id, + invoice_number: invoice.number, + issuing_date: credit_note.issuing_date&.iso8601, + credit_status: credit_note.credit_status, + refund_status: credit_note.refund_status, + reason: credit_note.reason, + description: credit_note.description, + currency: credit_note.currency, + total_amount_cents: credit_note.total_amount_cents, + precise_total_amount_cents: credit_note.precise_total&.to_s, + taxes_amount_cents: credit_note.taxes_amount_cents, + precise_taxes_amount_cents: credit_note.precise_taxes_amount_cents&.to_s, + sub_total_excluding_taxes_amount_cents: credit_note.sub_total_excluding_taxes_amount_cents, + balance_amount_cents: credit_note.balance_amount_cents, + credit_amount_cents: credit_note.credit_amount_cents, + refund_amount_cents: credit_note.refund_amount_cents, + offset_amount_cents: credit_note.offset_amount_cents, + coupons_adjustment_amount_cents: credit_note.coupons_adjustment_amount_cents, + taxes_rate: credit_note.taxes_rate, + created_at: credit_note.created_at.iso8601, + updated_at: credit_note.updated_at.iso8601, + customer: hash_including( + lago_id: customer.id, + external_id: customer.external_id, + name: customer.name, + integration_customers: anything + ), + items: credit_note.items.map do |item| + hash_including( + lago_id: item.id + ) + end, + applied_taxes: anything + ) + } + }, + "type" => "creditmemo" + } + end + end + end +end diff --git a/spec/services/integrations/aggregator/credit_notes/payloads/xero_spec.rb b/spec/services/integrations/aggregator/credit_notes/payloads/xero_spec.rb new file mode 100644 index 0000000..1ac770c --- /dev/null +++ b/spec/services/integrations/aggregator/credit_notes/payloads/xero_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CreditNotes::Payloads::Xero do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, credit_note:).body } + + it_behaves_like "an integration payload", :xero do + def build_expected_payload(mapping_codes) + [ + { + "currency" => "EUR", + "external_contact_id" => integration_customer.external_customer_id, + "fees" => + [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "item_code" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 1.9, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:fixed_charge, :external_account_code), + "description" => "Fixed Charge Fee", + "item_code" => mapping_codes.dig(:fixed_charge, :external_id), + "precise_unit_amount" => 1.4, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "item_code" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 1.8, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 1.7, + "taxes_amount_cents" => 0.0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "item_code" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 1.6, + "taxes_amount_cents" => 0.0, + "units" => 1 + } + ], + "issuing_date" => "2024-07-08T00:00:00Z", + "number" => credit_note.number, + "status" => "AUTHORISED", + "type" => "ACCRECCREDIT" + } + ] + end + + context "when there are coupons" do + let(:credit_note) { create(:credit_note, customer:, invoice:, issuing_date: DateTime.new(2024, 7, 8), coupons_adjustment_amount_cents: 50) } + + it "returns coupons with item_code instead of external_id" do + expect(payload.first["fees"]).to include( + { + "account_code" => default_mapping_codes.dig(:coupon, :external_account_code), + "description" => "Coupons", + "item_code" => default_mapping_codes.dig(:coupon, :external_id), + "precise_unit_amount" => -0.5, + "taxes_amount_cents" => 0, + "units" => 1 + } + ) + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/custom_object_service_spec.rb b/spec/services/integrations/aggregator/custom_object_service_spec.rb new file mode 100644 index 0000000..eaaf444 --- /dev/null +++ b/spec/services/integrations/aggregator/custom_object_service_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::CustomObjectService do + subject(:custom_object_service) { described_class.new(integration:, name:) } + + let(:integration) { create(:hubspot_integration) } + let(:name) { "LagoInvoices" } + + describe ".call" do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/custom-object" } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + let(:body) do + { + "name" => name + } + end + + let(:aggregator_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/custom_object_response.json") + JSON.parse(File.read(path)) + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:get).with(headers:, body:).and_return(aggregator_response) + end + + it "successfully fetches custom object" do + result = custom_object_service.call + custom_object = result.custom_object + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(lago_client).to have_received(:get) + expect(custom_object.id).to eq("35482707") + expect(custom_object.objectTypeId).to eq("2-35482707") + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/create_service_spec.rb b/spec/services/integrations/aggregator/invoices/create_service_spec.rb new file mode 100644 index 0000000..c645392 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/create_service_spec.rb @@ -0,0 +1,521 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::CreateService do + subject(:service_call) { described_class.call(invoice:) } + + let(:service) { described_class.new(invoice:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/netsuite/invoices" } + let(:add_on) { create(:add_on, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:) } + let(:current_time) { Time.current } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:integration_collection_mapping2) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :coupon, + settings: {external_id: "2", external_account_code: "22", external_name: ""} + ) + end + let(:integration_collection_mapping3) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :subscription_fee, + settings: {external_id: "3", external_account_code: "33", external_name: ""} + ) + end + let(:integration_collection_mapping4) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :minimum_commitment, + settings: {external_id: "4", external_account_code: "44", external_name: ""} + ) + end + let(:integration_collection_mapping5) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :tax, + settings: {external_id: "5", external_account_code: "55", external_name: ""} + ) + end + let(:integration_collection_mapping6) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :prepaid_credit, + settings: {external_id: "6", external_account_code: "66", external_name: ""} + ) + end + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on.id, + settings: {external_id: "m1", external_account_code: "m11", external_name: ""} + ) + end + let(:integration_mapping_bm) do + create( + :netsuite_mapping, + integration:, + mappable_type: "BillableMetric", + mappable_id: billable_metric.id, + settings: {external_id: "m2", external_account_code: "m22", external_name: ""} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 8000 + ) + end + let(:fee_sub) do + create( + :fee, + invoice:, + created_at: current_time - 3.seconds + ) + end + let(:minimum_commitment_fee) do + create( + :minimum_commitment_fee, + invoice:, + created_at: current_time - 2.seconds + ) + end + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + units: 2, + precise_unit_amount: 4.12121212123337777, + created_at: current_time + ) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "netsuite-tba" + } + end + + let(:invoice_url) do + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{invoice.customer.organization.slug}/customer/#{invoice.customer.id}/", "invoice/#{invoice.id}/overview").to_s + end + + let(:due_date) { invoice.payment_due_date.strftime("%-m/%-d/%Y") } + let(:issuing_date) { invoice.issuing_date.strftime("%-m/%-d/%Y") } + + let(:params) do + { + "type" => "invoice", + "isDynamic" => true, + "columns" => { + "tranid" => invoice.number, + "taxregoverride" => true, + "taxdetailsoverride" => true, + "entity" => integration_customer.external_customer_id, + "custbody_lago_id" => invoice.id, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_invoice_link" => invoice_url, + "trandate" => anything, + "duedate" => due_date, + "lago_plan_codes" => invoice.invoice_subscriptions.map(&:subscription).map(&:plan).map(&:code).join(",") + }, + "lines" => [ + { + "sublistId" => "item", + "lineItems" => [ + { + "item" => "3", + "account" => "33", + "quantity" => 0.0, + "rate" => 0.0, + "amount" => 2.0, + "taxdetailsreference" => anything, + "custcol_service_period_date_from" => anything, + "custcol_service_period_date_to" => anything, + "description" => anything, + "item_source" => anything + }, + { + "item" => "4", + "account" => "44", + "quantity" => 0.0, + "rate" => 0.0, + "amount" => 2.0, + "taxdetailsreference" => anything, + "custcol_service_period_date_from" => anything, + "custcol_service_period_date_to" => anything, + "description" => anything, + "item_source" => anything + }, + { + "item" => "m2", + "account" => "m22", + "quantity" => 2, + "rate" => 4.1212121212334, + "amount" => 2.0, + "taxdetailsreference" => anything, + "custcol_service_period_date_from" => anything, + "custcol_service_period_date_to" => anything, + "description" => anything, + "item_source" => anything + }, + { + "item" => "2", + "account" => "22", + "quantity" => 1, + "rate" => -20.0, + "taxdetailsreference" => "coupon_item", + "description" => anything, + "item_source" => anything + }, + { + "item" => "6", + "account" => "66", + "quantity" => 1, + "rate" => -40.0, + "taxdetailsreference" => "credit_item", + "description" => anything, + "item_source" => anything + }, + { + "item" => "1", # Fallback item instead of credit note + "account" => "11", + "quantity" => 1, + "rate" => -60.0, + "taxdetailsreference" => "credit_note_item", + "description" => anything, + "item_source" => anything + } + ] + } + ], + "options" => { + "ignoreMandatoryFields" => false, + "fullInvoicePayload" => { + "invoice_payload" => hash_including( + lago_id: invoice.id, + billing_entity_code: anything, + sequential_id: invoice.sequential_id, + number: invoice.number, + issuing_date: invoice.issuing_date&.iso8601, + payment_due_date: invoice.payment_due_date&.iso8601, + net_payment_term: invoice.net_payment_term, + invoice_type: invoice.invoice_type, + status: invoice.status, + payment_status: invoice.payment_status, + currency: invoice.currency, + fees_amount_cents: invoice.fees_amount_cents, + taxes_amount_cents: invoice.taxes_amount_cents, + coupons_amount_cents: invoice.coupons_amount_cents, + credit_notes_amount_cents: invoice.credit_notes_amount_cents, + prepaid_credit_amount_cents: invoice.prepaid_credit_amount_cents, + total_amount_cents: invoice.total_amount_cents, + total_due_amount_cents: invoice.total_due_amount_cents, + version_number: invoice.version_number, + self_billed: invoice.self_billed, + customer: hash_including( + lago_id: customer.id, + external_id: customer.external_id, + name: customer.name, + integration_customers: anything + ), + fees: invoice.fees.map do |fee| + hash_including( + lago_id: fee.id, + lago_invoice_id: fee.invoice_id, + lago_subscription_id: fee.subscription_id, + lago_customer_id: fee.customer&.id, + amount_cents: fee.amount_cents, + amount_currency: fee.amount_currency, + taxes_amount_cents: fee.taxes_amount_cents, + total_amount_cents: fee.total_amount_cents, + units: fee.units, + precise_unit_amount: fee.precise_unit_amount, + item: hash_including( + type: fee.fee_type, + code: fee.item_code, + name: fee.item_name + ) + ) + end, + credits: anything, + metadata: anything, + applied_taxes: anything, + billing_periods: anything + ) + } + } + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + charge + integration_collection_mapping1 + integration_collection_mapping2 + integration_collection_mapping3 + integration_collection_mapping4 + integration_collection_mapping5 + integration_collection_mapping6 + integration_mapping_add_on + integration_mapping_bm + fee_sub + minimum_commitment_fee + charge_fee + + integration.sync_invoices = true + integration.save! + end + + describe "#call_async" do + subject(:service_call_async) { described_class.new(invoice:).call_async } + + context "when invoice exists" do + it "enqueues invoice create job" do + expect { service_call_async }.to enqueue_job(Integrations::Aggregator::Invoices::CreateJob) + end + end + + context "when invoice does not exist" do + let(:invoice) { nil } + + it "returns an error" do + result = service_call_async + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + end + + describe "#call" do + context "when integration_invoice exists" do + let(:integration_invoice) { create(:integration_resource, integration:, syncable: invoice) } + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + integration_invoice + end + + it "returns result without making an API call" do + expect(lago_client).not_to have_received(:post_with_response) + result = service_call + + expect(result).to be_success + expect(result.external_id).to be_nil + end + end + + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when response is a hash" do + context "when invoice is succesfully created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/success_hash_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("cc1576cf-7b1c-480e-8f25-ae10fa34d6d1") + end + + it "creates integration resource object" do + expect { service_call }.to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(invoice.id) + expect(integration_resource.syncable_type).to eq("Invoice") + expect(integration_resource.resource_type).to eq("invoice") + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + + context "when invoice is not created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/failure_hash_response.json") + File.read(path) + end + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + end + + context "when response is a string" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/success_string_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("456") + end + + it "creates integration resource object" do + expect { service_call } + .to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(invoice.id) + expect(integration_resource.syncable_type).to eq("Invoice") + expect(integration_resource.resource_type).to eq("invoice") + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { 500 } + + it "returns an error" do + expect do + service_call + end.to raise_error(http_error) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob).and raise_error(http_error) + end + end + + context "when it is a client error" do + let(:error_code) { 400 } + + it "does not raise an error" do + expect { service_call }.not_to raise_error + end + + it "returns a failure result" do + result = service_call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NonRetryableFailure) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + end + end + + context "when there is payload error" do + let(:integration) { create(:xero_integration, organization:) } + let(:integration_customer) { create(:xero_customer, integration:, customer:) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/xero/invoices" } + let(:integration_collection_mapping1) { nil } + let(:integration_collection_mapping2) { nil } + let(:integration_collection_mapping3) { nil } + let(:integration_collection_mapping4) { nil } + let(:integration_collection_mapping5) { nil } + let(:integration_collection_mapping6) { nil } + let(:integration_mapping_add_on) { nil } + let(:integration_mapping_bm) { nil } + let(:response) { instance_double(Net::HTTPOK) } + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "xero" + } + end + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/success_hash_response.json") + File.read(path) + end + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "sends error webhook" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it "returns a failure result" do + result = service_call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NonRetryableFailure) + end + + it_behaves_like "throttles!", :anrok, :netsuite, :xero + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/hubspot/base_service_spec.rb b/spec/services/integrations/aggregator/invoices/hubspot/base_service_spec.rb new file mode 100644 index 0000000..55acd77 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/hubspot/base_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Hubspot::BaseService do + let(:service) { described_class.new(invoice:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe "#initialize" do + it "assigns the invoice" do + expect(service.instance_variable_get(:@invoice)).to eq(invoice) + end + end + + describe "#integration_customer" do + before do + integration_customer + create(:netsuite_customer, customer:) + end + + it "returns the first Hubspot kind integration customer" do + expect(service.send(:integration_customer)).to eq(integration_customer) + end + + it "memoizes the integration customer" do + service.send(:integration_customer) + expect(service.instance_variable_get(:@integration_customer)).to eq(integration_customer) + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/hubspot/create_customer_association_service_spec.rb b/spec/services/integrations/aggregator/invoices/hubspot/create_customer_association_service_spec.rb new file mode 100644 index 0000000..cb1760b --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/hubspot/create_customer_association_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Hubspot::CreateCustomerAssociationService do + subject(:service_call) { service.call } + + let(:service) { described_class.new(invoice:) } + let(:integration) { create(:hubspot_integration, organization:, sync_invoices:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/association" } + let(:invoice_file_url) { invoice.file_url } + let(:due_date) { invoice.payment_due_date.strftime("%Y-%m-%d") } + + let(:invoice) do + create( + :invoice, + status: "finalized", + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 8000 + ) + end + + let(:integration_invoice) { create(:integration_resource, syncable: invoice, integration:) } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + let(:params) do + service.__send__(:payload).customer_association_body + end + + before do + integration_customer + integration_invoice + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:put_with_response).with(params, headers) + end + + describe "#call" do + context "when integration.sync_invoices is false" do + let(:sync_invoices) { false } + + it "returns result without making a request" do + expect(service_call).to be_a(BaseService::Result) + end + end + + context "when integration.sync_invoices is true" do + let(:sync_invoices) { true } + + context "when request is successful" do + before do + allow(service).to receive(:http_client).and_return(lago_client) + allow(Integrations::Hubspot::Invoices::DeployObjectService).to receive(:call) + end + + it "calls the DeployObjectService" do + service_call + expect(Integrations::Hubspot::Invoices::DeployObjectService).to have_received(:call).with(integration: integration) + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it_behaves_like "throttles!", :hubspot + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/hubspot/create_service_spec.rb b/spec/services/integrations/aggregator/invoices/hubspot/create_service_spec.rb new file mode 100644 index 0000000..2446f30 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/hubspot/create_service_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Hubspot::CreateService do + subject(:service_call) { service.call } + + let(:service) { described_class.new(invoice:) } + let(:integration) { create(:hubspot_integration, organization:, invoices_properties_version: 2) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/records" } + let(:invoice_file_url) { invoice.file_url } + let(:file_url) { Faker::Internet.url } + let(:due_date) { invoice.payment_due_date.strftime("%Y-%m-%d") } + + let(:invoice) do + create( + :invoice, + status: "finalized", + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 8000 + ) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + let(:params) do + service.__send__(:payload).create_body + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + integration.sync_invoices = true + integration.save! + end + + describe "#call_async" do + subject(:service_call_async) { described_class.new(invoice:).call_async } + + context "when invoice exists" do + before { allow(invoice).to receive(:file_url).and_return(file_url) } + + it "enqueues invoice create job" do + expect { service_call_async }.to enqueue_job(Integrations::Aggregator::Invoices::Hubspot::CreateJob) + end + end + + context "when invoice does not exist" do + let(:invoice) { nil } + + it "returns an error" do + result = service_call_async + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + end + + describe "#call" do + before { allow(invoice).to receive(:file_url).and_return(file_url) } + + context "when sync_invoices is false" do + before { integration.update!(sync_invoices: false) } + + context "when invoice is not finalized" do + before { invoice.update!(status: "draft") } + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + end + + context "when invoice is finalized" do + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + end + end + + context "when sync_invoices is true" do + context "when invoice is not finalized" do + before { invoice.update!(status: "draft") } + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + end + + context "when invoice is finalized" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when invoice is succesfully created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/hubspot/success_hash_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("123456789") + end + + it "creates integration resource object" do + expect { service_call }.to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(invoice.id) + expect(integration_resource.syncable_type).to eq("Invoice") + expect(integration_resource.resource_type).to eq("invoice") + end + + it_behaves_like "throttles!", :hubspot + end + + context "when invoice is not created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/hubspot/failure_hash_response.json") + File.read(path) + end + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + + it_behaves_like "throttles!", :hubspot + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a client error" do + let(:error_code) { Faker::Number.between(from: 400, to: 499) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/hubspot/update_service_spec.rb b/spec/services/integrations/aggregator/invoices/hubspot/update_service_spec.rb new file mode 100644 index 0000000..4aa53b8 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/hubspot/update_service_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Hubspot::UpdateService do + subject(:service_call) { service.call } + + let(:service) { described_class.new(invoice:) } + let(:integration) { create(:hubspot_integration, organization:, invoices_properties_version: 2) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:integration_invoice) { create(:integration_resource, syncable: invoice, integration:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/records" } + let(:invoice_file_url) { invoice.file_url } + let(:file_url) { Faker::Internet.url } + let(:due_date) { invoice.payment_due_date.strftime("%Y-%m-%d") } + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 8000 + ) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + let(:params) do + service.__send__(:payload).update_body + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + integration.sync_invoices = true + integration.save! + end + + describe "#call_async" do + subject(:service_call_async) { described_class.new(invoice:).call_async } + + context "when invoice exists" do + before { allow(invoice).to receive(:file_url).and_return(file_url) } + + it "enqueues invoice update job" do + expect { service_call_async }.to enqueue_job(Integrations::Aggregator::Invoices::Hubspot::UpdateJob) + end + end + + context "when invoice does not exist" do + let(:invoice) { nil } + + it "returns an error" do + result = service_call_async + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + end + + describe "#call" do + before do + allow(invoice).to receive(:file_url).and_return(file_url) + integration_invoice + end + + context "when sync_invoices is false" do + before { integration.update!(sync_invoices: false) } + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + end + + context "when sync_invoices is true" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when invoice is succesfully updated" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/hubspot/success_hash_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("123456789") + end + + it_behaves_like "throttles!", :hubspot + end + + context "when invoice is not updated" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/invoices/hubspot/failure_hash_response.json") + File.read(path) + end + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it_behaves_like "throttles!", :hubspot + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a client error" do + let(:error_code) { Faker::Number.between(from: 400, to: 499) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/anrok_spec.rb new file mode 100644 index 0000000..596d907 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/payloads/anrok_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Payloads::Anrok do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, invoice:).body } + + it_behaves_like "an integration payload", :anrok do + def build_expected_payload(mapping_codes) + [ + { + "external_contact_id" => integration_customer.external_customer_id, + "status" => "AUTHORISED", + "issuing_date" => "2024-07-08T00:00:00Z", + "payment_due_date" => "2024-07-08T00:00:00Z", + "number" => invoice.number, + "currency" => "EUR", + "type" => "ACCREC", + "fees" => + [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "external_id" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.2e1 + }, + { + "account_code" => mapping_codes.dig(:fixed_charge, :external_account_code), + "description" => "Fixed Charge Fee", + "external_id" => mapping_codes.dig(:fixed_charge, :external_id), + "precise_unit_amount" => 25.0, + "taxes_amount_cents" => 2, + "units" => 0.6e1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "external_id" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.3e1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "external_id" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.4e1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "external_id" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.5e1 + }, + { + "account_code" => mapping_codes.dig(:coupon, :external_account_code), + "description" => "Coupons", + "external_id" => mapping_codes.dig(:coupon, :external_id), + "precise_unit_amount" => -2.0, + "taxes_amount_cents" => -290, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Prepaid credit", + "external_id" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -3.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Usage already billed", + "external_id" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -1.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:credit_note, :external_account_code), + "description" => "Credit note", + "external_id" => mapping_codes.dig(:credit_note, :external_id), + "precise_unit_amount" => -5.0, + "taxes_amount_cents" => 0, + "units" => 1 + } + ] + } + ] + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/base_payload_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/base_payload_spec.rb new file mode 100644 index 0000000..42081e0 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/payloads/base_payload_spec.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Payloads::BasePayload do + let(:payload) { described_class.new(integration_customer:, invoice:) } + let(:integration_customer) { create(:xero_customer, integration:, customer:) } + let(:integration) { create(:xero_integration, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, customer:, organization:) } + + describe "#fees" do + subject(:fees_call) { payload.__send__(:fees) } + + context "when there are fees with positive amount_cents" do + let(:fee1) { create(:fee, invoice:, amount_cents: 1000, created_at: 1.day.ago) } + let(:fee2) { create(:fee, invoice:, amount_cents: 2000, created_at: 2.days.ago) } + + it "returns fees with positive amount_cents ordered by created_at" do + expect(fees_call).to eq([fee2, fee1]) + end + end + + context "when there are no fees with positive amount_cents" do + let(:fee1) { create(:fee, invoice:, amount_cents: 0, created_at: 1.day.ago) } + let(:fee2) { create(:fee, invoice:, amount_cents: -1000, created_at: 2.days.ago) } + + it "returns all fees ordered by created_at" do + expect(fees_call).to eq([fee2, fee1]) + end + end + + context "when there are fees with positive and zero amount_cents" do + let(:fee1) { create(:fee, invoice:, amount_cents: 0, created_at: 1.day.ago) } + let(:fee2) { create(:fee, invoice:, amount_cents: 100, created_at: 2.days.ago) } + let(:fee3) { create(:fee, invoice:, amount_cents: 200, created_at: 3.days.ago) } + + it "returns only positive fees ordered by created_at" do + expect(fees_call).to eq([fee2, fee1]) + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/factory_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/factory_spec.rb new file mode 100644 index 0000000..3e1c8f5 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/payloads/factory_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Payloads::Factory do + describe ".new_instance" do + subject(:new_instance_call) { described_class.new_instance(integration_customer:, invoice:) } + + let(:invoice) { FactoryBot.create(:invoice) } + + context "when customer is a netsuite customer" do + let(:integration_customer) { FactoryBot.create(:netsuite_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Invoices::Payloads::Netsuite) + end + end + + context "when customer is a xero customer" do + let(:integration_customer) { FactoryBot.create(:xero_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Invoices::Payloads::Xero) + end + end + + context "when customer is an anrok customer" do + let(:integration_customer) { FactoryBot.create(:anrok_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Invoices::Payloads::Anrok) + end + end + + context "when customer is a hubspot customer" do + let(:integration_customer) { FactoryBot.create(:hubspot_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Invoices::Payloads::Hubspot) + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/hubspot_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/hubspot_spec.rb new file mode 100644 index 0000000..74df501 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/payloads/hubspot_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Payloads::Hubspot do + let(:payload) { described_class.new(integration_customer:, invoice:) } + let(:integration_customer) { FactoryBot.create(:hubspot_customer, integration:, customer:) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:file_url) { Faker::Internet.url } + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 200, + issuing_date: DateTime.new(2024, 7, 8) + ) + end + + let(:integration_invoice) do + create(:integration_resource, integration:, resource_type: "invoice", syncable: invoice) + end + + before do + integration_invoice + allow(invoice).to receive(:file_url).and_return(file_url) + end + + describe "#create_body" do + subject(:body_call) { payload.create_body } + + let(:create_body) do + { + "objectType" => integration.invoices_object_type_id, + "input" => { + "associations" => [], + "properties" => { + "lago_invoice_id" => invoice.id, + "lago_invoice_number" => invoice.number, + "lago_invoice_issuing_date" => invoice.issuing_date.strftime("%Y-%m-%d"), + "lago_invoice_payment_due_date" => invoice.payment_due_date.strftime("%Y-%m-%d"), + "lago_invoice_payment_overdue" => invoice.payment_overdue, + "lago_invoice_type" => invoice.invoice_type, + "lago_invoice_status" => invoice.status, + "lago_invoice_payment_status" => invoice.payment_status, + "lago_invoice_currency" => invoice.currency, + "lago_invoice_total_amount" => invoice.total_amount_cents / 100.0, + "lago_invoice_total_due_amount" => invoice.total_due_amount_cents / 100.0, + "lago_invoice_subtotal_excluding_taxes" => invoice.sub_total_including_taxes_amount_cents / 100.0, + "lago_invoice_file_url" => invoice.file_url + } + } + } + end + + it "returns payload body" do + expect(subject).to eq(create_body) + end + + context "when invoice file_url is missing" do + before { allow(invoice).to receive(:file_url).and_return(nil) } + + it "raises an error" do + expect { subject }.to raise_error(Integrations::Aggregator::BasePayload::Failure, "invoice.file_url missing") + end + end + end + + describe "#update_body" do + subject(:body_call) { payload.update_body } + + let(:update_body) do + { + "objectId" => integration_invoice.external_id, + "objectType" => integration.invoices_object_type_id, + "input" => { + "properties" => { + "lago_invoice_id" => invoice.id, + "lago_invoice_number" => invoice.number, + "lago_invoice_issuing_date" => invoice.issuing_date.strftime("%Y-%m-%d"), + "lago_invoice_payment_due_date" => invoice.payment_due_date.strftime("%Y-%m-%d"), + "lago_invoice_payment_overdue" => invoice.payment_overdue, + "lago_invoice_type" => invoice.invoice_type, + "lago_invoice_status" => invoice.status, + "lago_invoice_payment_status" => invoice.payment_status, + "lago_invoice_currency" => invoice.currency, + "lago_invoice_total_amount" => invoice.total_amount_cents / 100.0, + "lago_invoice_total_due_amount" => invoice.total_due_amount_cents / 100.0, + "lago_invoice_subtotal_excluding_taxes" => invoice.sub_total_including_taxes_amount_cents / 100.0, + "lago_invoice_file_url" => invoice.file_url + } + } + } + end + + it "returns payload body" do + expect(subject).to eq(update_body) + end + + context "when invoice file_url is missing" do + before { allow(invoice).to receive(:file_url).and_return(nil) } + + it "raises an error" do + expect { subject }.to raise_error(Integrations::Aggregator::BasePayload::Failure, "invoice.file_url missing") + end + end + end + + describe "#customer_association_body" do + subject(:body_call) { payload.customer_association_body } + + let(:customer_association_body) do + { + "objectType" => integration.invoices_object_type_id, + "objectId" => integration_invoice.external_id, + "toObjectType" => integration_customer.object_type, + "toObjectId" => integration_customer.external_customer_id, + "input" => [] + } + end + + it "returns payload body" do + expect(subject).to eq(customer_association_body) + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/netsuite_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/netsuite_spec.rb new file mode 100644 index 0000000..a102918 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/payloads/netsuite_spec.rb @@ -0,0 +1,798 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Payloads::Netsuite do + let(:payload) { described_class.new(integration_customer:, invoice:) } + let(:integration_customer) { create(:xero_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + progressive_billing_credit_amount_cents: 100, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 200, + issuing_date: DateTime.new(2024, 7, 8) + ) + end + + describe "#body" do + subject(:body_call) { payload.body } + + let(:add_on) { create(:add_on, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:) } + let(:current_time) { Time.current } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + let(:integration_collection_mapping2) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :coupon, + settings: {external_id: "2", external_account_code: "22", external_name: ""} + ) + end + + let(:integration_collection_mapping3) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :subscription_fee, + settings: {external_id: "3", external_account_code: "33", external_name: ""} + ) + end + + let(:integration_collection_mapping4) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :minimum_commitment, + settings: {external_id: "4", external_account_code: "44", external_name: ""} + ) + end + + let(:integration_collection_mapping5) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :tax, + settings: {external_id: "5", external_account_code: "55", external_name: ""} + ) + end + + let(:integration_collection_mapping6) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :prepaid_credit, + settings: {external_id: "6", external_account_code: "66", external_name: ""} + ) + end + + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on.id, + settings: {external_id: "m1", external_account_code: "m11", external_name: ""} + ) + end + + let(:integration_mapping_bm) do + create( + :netsuite_mapping, + integration:, + mappable_type: "BillableMetric", + mappable_id: billable_metric.id, + settings: {external_id: "m2", external_account_code: "m22", external_name: ""} + ) + end + + let(:fee_sub) do + create( + :fee, + invoice:, + amount_cents: 10_000, + taxes_amount_cents: 200, + created_at: current_time - 3.seconds + ) + end + + let(:minimum_commitment_fee) do + create( + :minimum_commitment_fee, + invoice:, + created_at: current_time - 2.seconds + ) + end + + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + units: 2, + precise_unit_amount: 4.12121212123337777, + created_at: current_time + ) + end + + let(:charge_fee2) do + create( + :charge_fee, + invoice:, + charge:, + units: 0, + precise_unit_amount: 0.0, + amount_cents: 0, + created_at: current_time + ) + end + + let(:invoice_link) do + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + + URI.join(url, "/#{customer.organization.slug}/customer/#{customer.id}/", "invoice/#{invoice.id}/overview").to_s + end + + let(:due_date) { invoice.payment_due_date.strftime("%-m/%-d/%Y") } + let(:issuing_date) { invoice.issuing_date.strftime("%-m/%-d/%Y") } + + let(:body) do + { + "type" => "invoice", + "isDynamic" => true, + "columns" => columns, + "lines" => [ + { + "sublistId" => "item", + "lineItems" => [ + { + "item" => "3", + "account" => "33", + "quantity" => 0.0, + "rate" => 0.0, + "amount" => 100.0, + "taxdetailsreference" => fee_sub.id, + "custcol_service_period_date_from" => + fee_sub.properties["from_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => fee_sub.properties["to_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => fee_sub.item_name, + "item_source" => fee_sub.item_source + }, + { + "item" => "4", + "account" => "44", + "quantity" => 0.0, + "rate" => 0.0, + "amount" => 2.0, + "taxdetailsreference" => minimum_commitment_fee.id, + "custcol_service_period_date_from" => + minimum_commitment_fee.properties["from_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => + minimum_commitment_fee.properties["to_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => minimum_commitment_fee.item_name, + "item_source" => minimum_commitment_fee.item_source + }, + { + "item" => "m2", + "account" => "m22", + "quantity" => 2, + "rate" => 4.1212121212334, + "amount" => 2.0, + "taxdetailsreference" => charge_fee.id, + "custcol_service_period_date_from" => + charge_fee.properties["charges_from_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => + charge_fee.properties["charges_to_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => charge_fee.item_name, + "item_source" => charge_fee.item_source + }, + { + "item" => "2", + "account" => "22", + "quantity" => 1, + "rate" => -20.0, + "taxdetailsreference" => "coupon_item", + "description" => invoice.credits.coupon_kind.map(&:item_name).join(","), + "item_source" => "coupons" + }, + { + "item" => "6", + "account" => "66", + "quantity" => 1, + "rate" => -40.0, + "taxdetailsreference" => "credit_item", + "description" => "Prepaid credits", + "item_source" => "prepaid_credits" + }, + { + "item" => "6", + "account" => "66", + "quantity" => 1, + "rate" => -1.0, + "taxdetailsreference" => "credit_item_progressive_billing", + "description" => invoice.credits.progressive_billing_invoice_kind.map(&:item_name).join(","), + "item_source" => "progressive_billing_credits" + }, + { + "item" => "1", # Fallback item instead of credit note + "account" => "11", + "quantity" => 1, + "rate" => -60.0, + "taxdetailsreference" => "credit_note_item", + "description" => invoice.credits.credit_note_kind.map(&:item_name).join(","), + "item_source" => "credit_note_credits" + } + ] + } + ], + "options" => { + "ignoreMandatoryFields" => false, + "fullInvoicePayload" => { + "invoice_payload" => hash_including( + lago_id: invoice.id, + billing_entity_code: anything, + sequential_id: invoice.sequential_id, + number: invoice.number, + issuing_date: invoice.issuing_date&.iso8601, + payment_due_date: invoice.payment_due_date&.iso8601, + net_payment_term: invoice.net_payment_term, + invoice_type: invoice.invoice_type, + status: invoice.status, + payment_status: invoice.payment_status, + currency: invoice.currency, + fees_amount_cents: invoice.fees_amount_cents, + taxes_amount_cents: invoice.taxes_amount_cents, + coupons_amount_cents: invoice.coupons_amount_cents, + credit_notes_amount_cents: invoice.credit_notes_amount_cents, + prepaid_credit_amount_cents: invoice.prepaid_credit_amount_cents, + total_amount_cents: invoice.total_amount_cents, + total_due_amount_cents: invoice.total_due_amount_cents, + version_number: invoice.version_number, + self_billed: invoice.self_billed, + customer: hash_including( + lago_id: customer.id, + external_id: customer.external_id, + name: customer.name, + integration_customers: anything + ), + fees: invoice.fees.map do |fee| + hash_including( + lago_id: fee.id, + lago_invoice_id: fee.invoice_id, + lago_subscription_id: fee.subscription_id, + lago_customer_id: fee.customer&.id, + amount_cents: fee.amount_cents, + amount_currency: fee.amount_currency, + taxes_amount_cents: fee.taxes_amount_cents, + total_amount_cents: fee.total_amount_cents, + units: fee.units, + precise_unit_amount: fee.precise_unit_amount, + item: hash_including( + type: fee.fee_type, + code: fee.item_code, + name: fee.item_name + ) + ) + end, + credits: anything, + metadata: anything, + applied_taxes: anything, + billing_periods: anything + ) + } + } + } + end + + let(:column_keys_with_taxes) do + [ + "tranid", + "custbody_ava_disable_tax_calculation", + "custbody_lago_invoice_link", + "trandate", + "duedate", + "taxdetailsoverride", + "custbody_lago_id", + "entity", + "taxregoverride", + "lago_plan_codes" + ] + end + + let(:column_keys_with_taxes_with_nexus) do + column_keys_with_taxes.insert(7, "nexus") + end + + let(:column_keys_without_taxes_with_nexus) do + column_keys_with_taxes.insert(8, "nexus") + end + + before do + integration_customer + charge + integration_collection_mapping1 + integration_collection_mapping2 + integration_collection_mapping3 + integration_collection_mapping4 + integration_collection_mapping6 + integration_mapping_add_on + integration_mapping_bm + fee_sub + minimum_commitment_fee + charge_fee + charge_fee2 + end + + describe "with currency mapping" do + context "when no currencies mapping is defined" do + it "doesn't send the currency attribute" do + expect(subject["columns"]).not_to have_key("currency") + end + end + + context "when currencies mapping is defined and has value for invoice currency" do + it "sends the currency attribute to NetSuite" do + create(:netsuite_currencies_mapping, integration:, settings: { + currencies: { + "EUR" => "312", + "USD" => "7" + } + }) + + expect(subject["columns"]["currency"]).to eq("312") + end + end + + context "when currencies mapping is defined but have an invalid value" do + it "doesn't send the currency attribute" do + mapping = create(:netsuite_currencies_mapping, integration:) + settings = mapping.settings + # NOTE: Model validation prevents this from being saved, but just in case + settings["currencies"] = {"EUR" => "", "USD" => "7"} + mapping.update_column(:settings, settings) # rubocop:disable Rails/SkipsModelValidations + + expect(subject["columns"]).not_to have_key("currency") + end + end + + context "when currencies mapping is defined but doesn't have value for invoice currency" do + it "doesn't send the currency attribute" do + create(:netsuite_currencies_mapping, integration:, settings: { + currencies: { + "USD" => "7", + "GBP" => "312" + } + }) + + expect(subject["columns"]).not_to have_key("currency") + end + end + end + + context "when tax item is mapped" do + before do + integration_collection_mapping5 + end + + context "when tax nexus is not present" do + let(:columns) do + { + "tranid" => invoice.number, + "entity" => integration_customer.external_customer_id, + "taxregoverride" => true, + "taxdetailsoverride" => true, + "custbody_lago_id" => invoice.id, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_invoice_link" => invoice_link, + "trandate" => issuing_date, + "duedate" => due_date, + "lago_plan_codes" => invoice.invoice_subscriptions.map(&:subscription).map(&:plan).map(&:code).join(",") + } + end + + it "returns payload body with tax columns" do + expect(subject).to match(body) + end + + it "has the columns keys in order" do + expect(subject["columns"].keys).to match_array(column_keys_with_taxes) + end + end + + context "when tax nexus is present" do + context "when tax item is mapped completely" do + before do + integration_collection_mapping5.update!( + tax_nexus: "some_nexus", + tax_type: "some_type", + tax_code: "some_code" + ) + + body["taxdetails"] = taxdetails + end + + let(:taxdetails) do + [ + { + "lineItems" => [ + { + "taxamount" => 2.0, + "taxbasis" => 1, + "taxcode" => "some_code", + "taxdetailsreference" => fee_sub.id, + "taxrate" => 0.0, + "taxtype" => "some_type" + }, + { + "taxamount" => 0.02, + "taxbasis" => 1, + "taxcode" => "some_code", + "taxdetailsreference" => minimum_commitment_fee.id, + "taxrate" => 0.0, + "taxtype" => "some_type" + }, + { + "taxamount" => 0.02, + "taxbasis" => 1, + "taxcode" => "some_code", + "taxdetailsreference" => charge_fee.id, + "taxrate" => 0.0, "taxtype" => "some_type" + }, + { + "taxamount" => -0.04, + "taxbasis" => 1, + "taxcode" => "some_code", + "taxdetailsreference" => "coupon_item", + "taxrate" => 0.0, + "taxtype" => "some_type" + }, + { + "taxamount" => 0, + "taxbasis" => 1, + "taxcode" => "some_code", + "taxdetailsreference" => "credit_item", + "taxrate" => 0.0, + "taxtype" => "some_type" + }, + { + "taxamount" => 0, + "taxbasis" => 1, + "taxcode" => "some_code", + "taxdetailsreference" => "credit_item_progressive_billing", + "taxrate" => 0.0, + "taxtype" => "some_type" + }, + { + "taxamount" => 0, + "taxbasis" => 1, + "taxcode" => "some_code", + "taxdetailsreference" => "credit_note_item", + "taxrate" => 0.0, + "taxtype" => "some_type" + } + ], + "sublistId" => "taxdetails" + } + ] + end + + let(:columns) do + { + "tranid" => invoice.number, + "entity" => integration_customer.external_customer_id, + "taxregoverride" => true, + "taxdetailsoverride" => true, + "custbody_lago_id" => invoice.id, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_invoice_link" => invoice_link, + "trandate" => issuing_date, + "duedate" => due_date, + "nexus" => "some_nexus", + "lago_plan_codes" => invoice.invoice_subscriptions.map(&:subscription).map(&:plan).map(&:code).join(",") + } + end + + it "returns payload body with tax columns" do + expect(subject).to match(body) + end + + it "has the columns keys in order" do + expect(subject["columns"].keys).to match_array(column_keys_with_taxes_with_nexus) + end + end + + context "when tax item is not mapped completely" do + before { integration_collection_mapping5.update!(tax_nexus: "some_nexus") } + + let(:columns) do + { + "tranid" => invoice.number, + "entity" => integration_customer.external_customer_id, + "taxregoverride" => true, + "taxdetailsoverride" => true, + "custbody_lago_id" => invoice.id, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_invoice_link" => invoice_link, + "trandate" => issuing_date, + "duedate" => due_date, + "nexus" => "some_nexus", + "lago_plan_codes" => invoice.invoice_subscriptions.map(&:subscription).map(&:plan).map(&:code).join(",") + } + end + + it "returns payload body with tax columns" do + expect(subject).to match(body) + end + + it "has the columns keys in order" do + expect(subject["columns"].keys).to match_array(column_keys_without_taxes_with_nexus) + end + end + end + end + + context "when tax item is not mapped" do + let(:columns) do + { + "tranid" => invoice.number, + "entity" => integration_customer.external_customer_id, + "taxregoverride" => true, + "taxdetailsoverride" => true, + "custbody_lago_id" => invoice.id, + "custbody_ava_disable_tax_calculation" => true, + "custbody_lago_invoice_link" => invoice_link, + "trandate" => issuing_date, + "duedate" => due_date, + "lago_plan_codes" => invoice.invoice_subscriptions.map(&:subscription).map(&:plan).map(&:code).join(",") + } + end + + it "returns payload body with tax columns" do + expect(subject).to match(body) + end + + it "has the columns keys in order" do + expect(subject["columns"].keys).to match_array(column_keys_with_taxes) + end + + context "when a fee has a quantity exceeding NetSuite maximum" do + let(:charge_fee_large) do + create( + :charge_fee, + invoice:, + charge:, + units: 20_000_000_000, + precise_unit_amount: 1.23, + amount_cents: 3_000, + created_at: current_time + 1.second + ) + end + + let(:expected_body) do + { + "type" => "invoice", + "isDynamic" => true, + "columns" => columns, + "lines" => [ + { + "sublistId" => "item", + "lineItems" => [ + { + "item" => "3", + "account" => "33", + "quantity" => 0.0, + "rate" => 0.0, + "amount" => 100.0, + "taxdetailsreference" => fee_sub.id, + "custcol_service_period_date_from" => + fee_sub.properties["from_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => + fee_sub.properties["to_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => fee_sub.item_name, + "item_source" => fee_sub.item_source + }, + { + "item" => "4", + "account" => "44", + "quantity" => 0.0, + "rate" => 0.0, + "amount" => 2.0, + "taxdetailsreference" => minimum_commitment_fee.id, + "custcol_service_period_date_from" => + minimum_commitment_fee.properties["from_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => + minimum_commitment_fee.properties["to_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => minimum_commitment_fee.item_name, + "item_source" => minimum_commitment_fee.item_source + }, + { + "item" => "m2", + "account" => "m22", + "quantity" => 2, + "rate" => 4.1212121212334, + "amount" => 2.0, + "taxdetailsreference" => charge_fee.id, + "custcol_service_period_date_from" => + charge_fee.properties["charges_from_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => + charge_fee.properties["charges_to_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => charge_fee.item_name, + "item_source" => charge_fee.item_source + }, + { + "item" => "m2", + "account" => "m22", + "quantity" => 1, + "rate" => 30.0, + "amount" => 30.0, + "taxdetailsreference" => charge_fee_large.id, + "custcol_service_period_date_from" => + charge_fee_large.properties["charges_from_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "custcol_service_period_date_to" => + charge_fee_large.properties["charges_to_datetime"]&.to_date&.strftime("%-m/%-d/%Y"), + "description" => charge_fee_large.item_name, + "item_source" => charge_fee_large.item_source + }, + { + "item" => "2", + "account" => "22", + "quantity" => 1, + "rate" => -20.0, + "taxdetailsreference" => "coupon_item", + "description" => invoice.credits.coupon_kind.map(&:item_name).join(","), + "item_source" => "coupons" + }, + { + "item" => "6", + "account" => "66", + "quantity" => 1, + "rate" => -40.0, + "taxdetailsreference" => "credit_item", + "description" => "Prepaid credits", + "item_source" => "prepaid_credits" + }, + { + "item" => "6", + "account" => "66", + "quantity" => 1, + "rate" => -1.0, + "taxdetailsreference" => "credit_item_progressive_billing", + "description" => invoice.credits.progressive_billing_invoice_kind.map(&:item_name).join(","), + "item_source" => "progressive_billing_credits" + }, + { + "item" => "1", + "account" => "11", + "quantity" => 1, + "rate" => -60.0, + "taxdetailsreference" => "credit_note_item", + "description" => invoice.credits.credit_note_kind.map(&:item_name).join(","), + "item_source" => "credit_note_credits" + } + ] + } + ], + "options" => { + "ignoreMandatoryFields" => false, + "fullInvoicePayload" => anything + } + } + end + + before do + charge_fee_large + end + + it "sets quantity to 1 and moves the total amount to the rate field for that line" do + expect(subject).to match(expected_body) + end + end + end + + context "when invoice has a fixed_charge fee" do + let(:plan) { create(:plan, organization:) } + let(:fixed_charge) { create(:fixed_charge, organization:, plan:, add_on:) } + let(:fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge:, + amount_cents: 5000, + units: 1, + precise_unit_amount: 50.0, + created_at: current_time + 1.second + ) + end + + before { fixed_charge_fee } + + it "includes the fixed_charge fee using the add_on mapping" do + line_items = subject["lines"].first["lineItems"] + fixed_charge_line = line_items.find { |item| item["taxdetailsreference"] == fixed_charge_fee.id } + + expect(fixed_charge_line).to be_present + expect(fixed_charge_line["item"]).to eq("m1") + expect(fixed_charge_line["account"]).to eq("m11") + expect(fixed_charge_line["amount"]).to eq(50.0) + end + end + end + + describe "#tax_item_complete?" do + subject(:tax_item_complete_call) { payload.__send__(:tax_item_complete?) } + + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :tax, + settings: + ) + end + + let(:settings) do + {external_id: "5", external_account_code: "55", external_name: "", tax_nexus:, tax_type:, tax_code:} + end + + before { integration_collection_mapping } + + context "when tax_item has all required attributes" do + let(:tax_nexus) { "some_nexus" } + let(:tax_type) { "some_type" } + let(:tax_code) { "some_code" } + + it "returns true" do + expect(subject).to be true + end + end + + context "when tax_item is missing tax_nexus" do + let(:tax_nexus) { [nil, ""].sample } + let(:tax_type) { "some_type" } + let(:tax_code) { "some_code" } + + it "returns false" do + expect(subject).to be false + end + end + + context "when tax_item is missing tax_type" do + let(:tax_nexus) { "some_nexus" } + let(:tax_type) { [nil, ""].sample } + let(:tax_code) { "some_code" } + + it "returns false" do + expect(subject).to be false + end + end + + context "when tax_item is missing tax_code" do + let(:tax_nexus) { "some_nexus" } + let(:tax_type) { "some_type" } + let(:tax_code) { [nil, ""].sample } + + it "returns false" do + expect(subject).to be false + end + end + end +end diff --git a/spec/services/integrations/aggregator/invoices/payloads/xero_spec.rb b/spec/services/integrations/aggregator/invoices/payloads/xero_spec.rb new file mode 100644 index 0000000..4a9f0e9 --- /dev/null +++ b/spec/services/integrations/aggregator/invoices/payloads/xero_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Invoices::Payloads::Xero do + describe "#body" do + subject(:payload) { described_class.new(integration_customer:, invoice:).body } + + it_behaves_like "an integration payload", :xero do + def build_expected_payload(mapping_codes) + [ + { + "external_contact_id" => integration_customer.external_customer_id, + "status" => "AUTHORISED", + "issuing_date" => "2024-07-08T00:00:00Z", + "payment_due_date" => "2024-07-08T00:00:00Z", + "number" => invoice.number, + "currency" => "EUR", + "type" => "ACCREC", + "fees" => [ + { + "account_code" => mapping_codes.dig(:add_on, :external_account_code), + "description" => "Add-on Fee", + "item_code" => mapping_codes.dig(:add_on, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.2e1 + }, + { + "account_code" => mapping_codes.dig(:fixed_charge, :external_account_code), + "description" => "Fixed Charge Fee", + "item_code" => mapping_codes.dig(:fixed_charge, :external_id), + "precise_unit_amount" => 25.0, + "taxes_amount_cents" => 2, + "units" => 0.6e1 + }, + { + "account_code" => mapping_codes.dig(:billable_metric, :external_account_code), + "description" => "Standard Charge Fee", + "item_code" => mapping_codes.dig(:billable_metric, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.3e1 + }, + { + "account_code" => mapping_codes.dig(:minimum_commitment, :external_account_code), + "description" => "Minimum Commitment Fee", + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.4e1 + }, + { + "account_code" => mapping_codes.dig(:subscription, :external_account_code), + "description" => "Subscription", + "item_code" => mapping_codes.dig(:subscription, :external_id), + "precise_unit_amount" => 100.0, + "taxes_amount_cents" => 2, + "units" => 0.5e1 + }, + { + "account_code" => mapping_codes.dig(:coupon, :external_account_code), + "description" => "Coupons", + "item_code" => mapping_codes.dig(:coupon, :external_id), + "precise_unit_amount" => -2.0, + "taxes_amount_cents" => -290, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Prepaid credit", + "item_code" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -3.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:prepaid_credit, :external_account_code), + "description" => "Usage already billed", + "item_code" => mapping_codes.dig(:prepaid_credit, :external_id), + "precise_unit_amount" => -1.0, + "taxes_amount_cents" => 0, + "units" => 1 + }, + { + "account_code" => mapping_codes.dig(:credit_note, :external_account_code), + "description" => "Credit note", + "item_code" => mapping_codes.dig(:credit_note, :external_id), + "precise_unit_amount" => -5.0, + "taxes_amount_cents" => 0, + "units" => 1 + } + ] + } + ] + end + end + + context "when a fee has precise_unit_amount with more than 2 decimal places" do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:integration) { create(:xero_integration, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:integration_customer) { create(:xero_customer, customer:, integration:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:plan) { create(:plan, organization:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:invoice) do + invoice = create( + :invoice, + customer:, + organization:, + billing_entity:, + coupons_amount_cents: 0, + prepaid_credit_amount_cents: 0, + progressive_billing_credit_amount_cents: 0, + credit_notes_amount_cents: 0, + taxes_amount_cents: 0, + issuing_date: DateTime.new(2024, 7, 8) + ) + create(:invoice_subscription, invoice:, subscription:) + invoice + end + + let(:high_precision_fee) do + create( + :charge_fee, + invoice:, + charge:, + billable_metric:, + units: 74_759_000, + amount_cents: 89_566, + precise_unit_amount: 0.000018, + taxes_amount_cents: 0 + ) + end + + let(:billable_metric_mapping) do + create( + :xero_mapping, + integration:, + mappable_type: "BillableMetric", + mappable_id: billable_metric.id, + billing_entity:, + settings: {external_id: "metric_ext_id", external_account_code: "100", external_name: "metric"} + ) + end + + before do + billable_metric_mapping + high_precision_fee + end + + it "sends precise_unit_amount as the total amount in currency units instead of amount_cents" do + fee_item = payload.first["fees"].first + + expect(fee_item).to include( + "units" => 1, + "precise_unit_amount" => 895.66 + ) + expect(fee_item).not_to have_key("amount_cents") + end + end + end +end diff --git a/spec/services/integrations/aggregator/items_service_spec.rb b/spec/services/integrations/aggregator/items_service_spec.rb new file mode 100644 index 0000000..60258c6 --- /dev/null +++ b/spec/services/integrations/aggregator/items_service_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::ItemsService do + subject(:items_service) { described_class.new(integration:) } + + let(:integration) { create(:netsuite_integration) } + + describe ".call" do + let(:aggregator_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/items_response.json") + JSON.parse(File.read(path)) + end + + before do + stub_request(:get, "https://api.nango.dev/v1/netsuite/items?limit=450") + .to_return( + status: 200, + body: aggregator_response.to_json, + headers: {"Content-Type" => "application/json"} + ) + + IntegrationItem.destroy_all + end + + it "uses id as external_id for netsuite" do + result = items_service.call + + expect(result.items.pluck("external_id")).to eq(%w[755 745 753 484 828]) + expect(IntegrationItem.count).to eq(5) + end + + context "when cursor is present" do + let(:aggregator_response) do + super().merge("next_cursor" => "abc123") + end + + before do + second_page_response = { + "records" => [ + { + "id" => "799", + "item_code" => "test-lead-conduit-page-2", + "name" => "Test-LeadConduit: Page 2", + "account_code" => "7691" + } + ] + } + stub_request(:get, "https://api.nango.dev/v1/netsuite/items?limit=450&cursor=abc123") + .to_return( + status: 200, + body: second_page_response.to_json, + headers: {"Content-Type" => "application/json"} + ) + end + + it "makes subsequent requests until cursor is nil" do + result = items_service.call + + expect(result.items.pluck("external_id")).to eq(%w[755 745 753 484 828 799]) + expect(IntegrationItem.count).to eq(6) + end + end + + context "with a xero integration" do + let(:integration) { create(:xero_integration) } + + before do + stub_request(:get, "https://api.nango.dev/v1/xero/items?limit=450") + .to_return( + status: 200, + body: aggregator_response.to_json, + headers: {"Content-Type" => "application/json"} + ) + end + + it "uses item_code as external_id for xero" do + result = items_service.call + + expect(result.items.pluck("external_id")).to eq( + ["test-lead-conduit", "test-trusted-form", "test-anura", "test-platform", "test-lead-conduit-add-on"] + ) + expect(IntegrationItem.count).to eq(5) + end + + context "with duplicate item_code in response" do + let(:aggregator_response) do + { + "records" => [ + { + "id" => "old-id", + "item_code" => "VM6", + "name" => "Old Item", + "account_code" => "1234", + "_nango_metadata" => { + "last_modified_at" => "2024-01-01T00:00:00+00:00" + } + }, + { + "id" => "new-id", + "item_code" => "VM6", + "name" => "New Item", + "account_code" => "1234", + "_nango_metadata" => { + "last_modified_at" => "2024-06-01T00:00:00+00:00" + } + } + ], + "next_cursor" => nil + } + end + + it "keeps only the most recent item based on last_modified_at" do + result = items_service.call + + expect(result.items.count).to eq(1) + expect(result.items.first.external_id).to eq("VM6") + expect(result.items.first.external_name).to eq("New Item") + end + + context "when metadata is not present" do + let(:aggregator_response) do + { + "records" => [ + { + "id" => "old-id", + "item_code" => "VM6", + "name" => "Old Item", + "account_code" => "1234" + }, + { + "id" => "new-id", + "item_code" => "VM6", + "name" => "New Item", + "account_code" => "1234", + "_nango_metadata" => { + "last_modified_at" => "2024-06-01T00:00:00+00:00" + } + } + ], + "next_cursor" => nil + } + end + + it "keeps the item with metadata" do + result = items_service.call + + expect(result.items.count).to eq(1) + expect(result.items.first.external_id).to eq("VM6") + expect(result.items.first.external_name).to eq("New Item") + end + end + end + end + end + + describe "#action_path" do + subject(:action_path_call) { items_service.action_path } + + let(:action_path) { "v1/netsuite/items" } + + it "returns the path" do + expect(subject).to eq(action_path) + end + end +end diff --git a/spec/services/integrations/aggregator/payments/create_service_spec.rb b/spec/services/integrations/aggregator/payments/create_service_spec.rb new file mode 100644 index 0000000..076315a --- /dev/null +++ b/spec/services/integrations/aggregator/payments/create_service_spec.rb @@ -0,0 +1,236 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Payments::CreateService do + subject(:service_call) { described_class.call(payment:) } + + let(:service) { described_class.new(payment:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/netsuite/payments" } + let(:payment) { create(:payment, payable: invoice) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:integration_invoice) { create(:integration_resource, syncable: invoice, integration:) } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "netsuite-tba" + } + end + + let(:params) do + { + "isDynamic" => true, + "columns" => { + "customer" => integration_customer.external_customer_id, + "payment" => payment.amount_cents.div(100).to_f + }, + "options" => { + "ignoreMandatoryFields" => false + }, + "type" => "customerpayment", + "lines" => [ + { + "lineItems" => [ + { + "amount" => payment.amount_cents.div(100).to_f, + "apply" => true, + "doc" => integration_invoice.external_id + } + ], + "sublistId" => "apply" + } + ] + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + integration.sync_payments = true + integration.save! + integration_invoice + payment + end + + describe "#call_async" do + subject(:service_call_async) { described_class.new(payment:).call_async } + + context "when payment exists" do + it "enqueues payment create job" do + expect { service_call_async }.to enqueue_job(Integrations::Aggregator::Payments::CreateJob) + end + end + + context "when payment does not exist" do + let(:payment) { nil } + + it "returns an error" do + result = service_call_async + + expect(result).not_to be_success + expect(result.error.error_code).to eq("payment_not_found") + end + end + end + + describe "#call" do + context "when integration_payment exists" do + let(:integration_payment) do + create(:integration_resource, integration:, syncable: payment, resource_type: "payment") + end + + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + integration_payment + end + + it "returns result without making an API call" do + expect(lago_client).not_to have_received(:post_with_response) + result = service_call + + expect(result).to be_success + expect(result.external_id).to be_nil + end + end + + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when response is a string" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/payments/success_string_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("999") + end + + it "creates integration resource object" do + expect { service_call }.to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(payment.id) + expect(integration_resource.syncable_type).to eq("Payment") + expect(integration_resource.resource_type).to eq("payment") + end + + it_behaves_like "throttles!", :netsuite, :xero + end + + context "when response is a hash" do + context "when payment is succesfully created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/payments/success_hash_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("e68f6095-f8d2-4d7a-ac05-7bb919d0330e") + end + + it "creates integration resource object" do + expect { service_call }.to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(payment.id) + expect(integration_resource.syncable_type).to eq("Payment") + expect(integration_resource.resource_type).to eq("payment") + end + + it_behaves_like "throttles!", :netsuite, :xero + end + + context "when payment is not created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/payments/failure_hash_response.json") + File.read(path) + end + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + + it_behaves_like "throttles!", :netsuite, :xero + end + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { 500 } + + it "returns an error" do + expect do + service_call + end.to raise_error(http_error) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob).and raise_error(http_error) + end + end + + context "when it is a client error" do + let(:error_code) { 400 } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :netsuite, :xero + end + end + end +end diff --git a/spec/services/integrations/aggregator/payments/payloads/factory_spec.rb b/spec/services/integrations/aggregator/payments/payloads/factory_spec.rb new file mode 100644 index 0000000..f255486 --- /dev/null +++ b/spec/services/integrations/aggregator/payments/payloads/factory_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Payments::Payloads::Factory do + describe ".new_instance" do + subject(:new_instance_call) { described_class.new_instance(integration:, payment:) } + + let(:payment) { FactoryBot.create(:payment) } + + context "when integration is a netsuite integration" do + let(:integration) { FactoryBot.create(:netsuite_integration) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Payments::Payloads::Netsuite) + end + end + + context "when integration is a xero integration" do + let(:integration) { FactoryBot.create(:xero_integration) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Payments::Payloads::Xero) + end + end + + context "when integration is an anrok integration" do + let(:integration) { FactoryBot.create(:anrok_integration) } + + it "raises NotImplemented" do + expect { subject }.to raise_error(NotImplementedError) + end + end + end +end diff --git a/spec/services/integrations/aggregator/payments/payloads/netsuite_spec.rb b/spec/services/integrations/aggregator/payments/payloads/netsuite_spec.rb new file mode 100644 index 0000000..3eac2ef --- /dev/null +++ b/spec/services/integrations/aggregator/payments/payloads/netsuite_spec.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Payments::Payloads::Netsuite do + let(:payload) { described_class.new(integration:, payment:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:payment) { create(:payment, payable: invoice, amount_cents: 100) } + let(:integration_invoice) { create(:integration_resource, integration:, syncable: invoice) } + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + coupons_amount_cents: 2000, + prepaid_credit_amount_cents: 4000, + credit_notes_amount_cents: 6000, + taxes_amount_cents: 200, + issuing_date: DateTime.new(2024, 7, 8) + ) + end + + let(:body) do + { + "isDynamic" => true, + "columns" => { + "customer" => integration_customer.external_customer_id, + "payment" => payment.amount_cents.div(100).to_f + }, + "options" => { + "ignoreMandatoryFields" => false + }, + "type" => "customerpayment", + "lines" => [ + { + "lineItems" => [ + { + "amount" => payment.amount_cents.div(100).to_f, + "apply" => true, + "doc" => integration_invoice.external_id + } + ], + "sublistId" => "apply" + } + ] + } + end + + before do + integration_customer + integration_invoice + end + + describe "#body" do + subject(:body_call) { payload.body } + + it "returns payload body" do + expect(subject).to eq(body) + end + end +end diff --git a/spec/services/integrations/aggregator/payments/payloads/xero_spec.rb b/spec/services/integrations/aggregator/payments/payloads/xero_spec.rb new file mode 100644 index 0000000..a40b472 --- /dev/null +++ b/spec/services/integrations/aggregator/payments/payloads/xero_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Payments::Payloads::Xero do + let(:payload) { described_class.new(integration:, payment:).body } + + describe "#body" do + it_behaves_like "an integration payload", :xero do + let!(:integration_invoice) { create(:integration_resource, syncable: invoice, integration:) } + + before { integration_invoice } + + def build_expected_payload(mapping_codes) + [ + { + "invoice_id" => integration_invoice.external_id, + "account_code" => mapping_codes.dig(:account, :external_account_code), + "date" => payment.created_at.utc.iso8601, + "amount_cents" => payment.amount_cents + } + ] + end + end + end +end diff --git a/spec/services/integrations/aggregator/send_restlet_endpoint_service_spec.rb b/spec/services/integrations/aggregator/send_restlet_endpoint_service_spec.rb new file mode 100644 index 0000000..2fdf96c --- /dev/null +++ b/spec/services/integrations/aggregator/send_restlet_endpoint_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::SendRestletEndpointService do + subject(:send_restlet_endpoint_service) { described_class.new(integration:) } + + let(:integration) { create(:netsuite_integration) } + + describe ".call" do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/connection/#{integration.connection_id}/metadata" } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response) + + integration.script_endpoint_url = "https://example.com" + integration.save! + end + + it "successfully sends restlet endpoint" do + send_restlet_endpoint_service.call + + expect(LagoHttpClient::Client).to have_received(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(lago_client).to have_received(:post_with_response) do |payload| + expect(payload[:restletEndpoint]).to eq("https://example.com") + end + end + end +end diff --git a/spec/services/integrations/aggregator/subscriptions/hubspot/base_service_spec.rb b/spec/services/integrations/aggregator/subscriptions/hubspot/base_service_spec.rb new file mode 100644 index 0000000..fe17418 --- /dev/null +++ b/spec/services/integrations/aggregator/subscriptions/hubspot/base_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Hubspot::BaseService do + let(:service) { described_class.new(subscription:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:plan) { create(:plan, organization:) } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + + describe "#initialize" do + it "assigns the subscription" do + expect(service.instance_variable_get(:@subscription)).to eq(subscription) + end + end + + describe "#integration_customer" do + before do + integration_customer + create(:netsuite_customer, customer:) + end + + it "returns the first hubspot kind integration customer" do + expect(service.send(:integration_customer)).to eq(integration_customer) + end + + it "memoizes the integration customer" do + service.send(:integration_customer) + expect(service.instance_variable_get(:@integration_customer)).to eq(integration_customer) + end + end +end diff --git a/spec/services/integrations/aggregator/subscriptions/hubspot/create_customer_association_service_spec.rb b/spec/services/integrations/aggregator/subscriptions/hubspot/create_customer_association_service_spec.rb new file mode 100644 index 0000000..46e84a7 --- /dev/null +++ b/spec/services/integrations/aggregator/subscriptions/hubspot/create_customer_association_service_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Hubspot::CreateCustomerAssociationService do + subject(:service_call) { service.call } + + let(:service) { described_class.new(subscription:) } + let(:integration) { create(:hubspot_integration, organization:, sync_subscriptions:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/association" } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, organization:, plan:) } + + let(:integration_subscription) do + create(:integration_resource, resource_type: "subscription", syncable: subscription, integration:) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + let(:params) do + service.__send__(:payload).customer_association_body + end + + before do + integration_customer + integration_subscription + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:put_with_response).with(params, headers) + end + + describe "#call" do + context "when integration.sync_subscriptions is false" do + let(:sync_subscriptions) { false } + + it "returns result without making a request" do + expect(service_call).to be_a(BaseService::Result) + end + end + + context "when integration.sync_subscriptions is true" do + let(:sync_subscriptions) { true } + + context "when request is successful" do + before do + allow(service).to receive(:http_client).and_return(lago_client) + allow(Integrations::Hubspot::Subscriptions::DeployObjectService).to receive(:call) + end + + it "calls the DeployObjectService" do + service_call + expect(Integrations::Hubspot::Subscriptions::DeployObjectService).to have_received(:call).with(integration: integration) + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it_behaves_like "throttles!", :hubspot + end + end + end +end diff --git a/spec/services/integrations/aggregator/subscriptions/hubspot/create_service_spec.rb b/spec/services/integrations/aggregator/subscriptions/hubspot/create_service_spec.rb new file mode 100644 index 0000000..ceb22b3 --- /dev/null +++ b/spec/services/integrations/aggregator/subscriptions/hubspot/create_service_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Hubspot::CreateService do + subject(:service_call) { service.call } + + let(:service) { described_class.new(subscription:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:plan) { create(:plan, organization:) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:lago_properties_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/records" } + let(:properties_endpoint) { "https://api.nango.dev/v1/hubspot/properties" } + let(:subscription_file_url) { subscription.file_url } + let(:file_url) { Faker::Internet.url } + let(:due_date) { subscription.payment_due_date.strftime("%Y-%m-%d") } + let(:params) { service.__send__(:payload).create_body } + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(LagoHttpClient::Client).to receive(:new) + .with(properties_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_properties_client) + + integration_customer + integration.sync_subscriptions = true + integration.save! + end + + describe "#call_async" do + subject(:service_call_async) { described_class.new(subscription:).call_async } + + context "when subscription exists" do + it "enqueues subscription create job" do + expect { service_call_async }.to enqueue_job(Integrations::Aggregator::Subscriptions::Hubspot::CreateJob) + end + end + + context "when subscription does not exist" do + let(:subscription) { nil } + + it "returns an error" do + result = service_call_async + + expect(result).not_to be_success + expect(result.error.error_code).to eq("subscription_not_found") + end + end + end + + describe "#call" do + context "when sync_subscriptions is false" do + before { integration.update!(sync_subscriptions: false) } + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + end + + context "when sync_subscriptions is true" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(lago_properties_client).to receive(:post_with_response) + allow(response).to receive(:body).and_return(body) + end + + context "when subscription is succesfully created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/subscriptions/hubspot/success_hash_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("123456789123") + end + + it "creates integration resource object" do + expect { service_call }.to change(IntegrationResource, :count).by(1) + + integration_resource = IntegrationResource.order(created_at: :desc).first + + expect(integration_resource.syncable_id).to eq(subscription.id) + expect(integration_resource.syncable_type).to eq("Subscription") + expect(integration_resource.resource_type).to eq("subscription") + end + + it_behaves_like "throttles!", :hubspot + end + + context "when subscription is not created" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/subscriptions/hubspot/failure_hash_response.json") + File.read(path) + end + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it "does not create integration resource object" do + expect { service_call }.not_to change(IntegrationResource, :count) + end + + it_behaves_like "throttles!", :hubspot + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + allow(lago_properties_client).to receive(:post_with_response) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a client error" do + let(:error_code) { Faker::Number.between(from: 400, to: 499) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/subscriptions/hubspot/update_service_spec.rb b/spec/services/integrations/aggregator/subscriptions/hubspot/update_service_spec.rb new file mode 100644 index 0000000..010ce8b --- /dev/null +++ b/spec/services/integrations/aggregator/subscriptions/hubspot/update_service_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Hubspot::UpdateService do + subject(:service_call) { service.call } + + let(:service) { described_class.new(subscription:) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:lago_properties_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/records" } + let(:properties_endpoint) { "https://api.nango.dev/v1/hubspot/properties" } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, organization:, plan:) } + + let(:integration_subscription) do + create(:integration_resource, resource_type: "subscription", syncable: subscription, integration:) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "hubspot" + } + end + + let(:params) do + service.__send__(:payload).update_body + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(LagoHttpClient::Client).to receive(:new).with(properties_endpoint, retries_on: [OpenSSL::SSL::SSLError]).and_return(lago_properties_client) + + integration_customer + integration.sync_subscriptions = true + integration.save! + end + + describe "#call_async" do + subject(:service_call_async) { described_class.new(subscription:).call_async } + + context "when subscription exists" do + it "enqueues subscription update job" do + expect { service_call_async }.to enqueue_job(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob) + end + end + + context "when subscription does not exist" do + let(:subscription) { nil } + + it "returns an error" do + result = service_call_async + + expect(result).not_to be_success + expect(result.error.error_code).to eq("subscription_not_found") + end + end + end + + describe "#call" do + before { integration_subscription } + + context "when sync_subscriptions is false" do + before { integration.update!(sync_subscriptions: false) } + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + end + + context "when sync_subscriptions is true" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_return(response) + allow(lago_properties_client).to receive(:post_with_response) + allow(response).to receive(:body).and_return(body) + end + + context "when subscription is succesfully updated" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/subscriptions/hubspot/success_hash_response.json") + File.read(path) + end + + it "returns external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to eq("123456789123") + end + + it_behaves_like "throttles!", :hubspot + end + + context "when subscription is not updated" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/subscriptions/hubspot/failure_hash_response.json") + File.read(path) + end + + it "does not return external id" do + result = service_call + + expect(result).to be_success + expect(result.external_id).to be(nil) + end + + it_behaves_like "throttles!", :hubspot + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:put_with_response).with(params, headers).and_raise(http_error) + allow(lago_properties_client).to receive(:post_with_response) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + + context "when it is a client error" do + let(:error_code) { Faker::Number.between(from: 400, to: 499) } + + it "does not return an error" do + expect { service_call }.not_to raise_error + end + + it "returns result" do + expect(service_call).to be_a(BaseService::Result) + end + + it "enqueues a SendWebhookJob" do + expect { service_call }.to have_enqueued_job(SendWebhookJob) + end + + it_behaves_like "throttles!", :hubspot + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/subscriptions/payloads/factory_spec.rb b/spec/services/integrations/aggregator/subscriptions/payloads/factory_spec.rb new file mode 100644 index 0000000..9fe7a29 --- /dev/null +++ b/spec/services/integrations/aggregator/subscriptions/payloads/factory_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Payloads::Factory do + describe ".new_instance" do + subject(:new_instance_call) { described_class.new_instance(integration_customer:, subscription:) } + + let(:subscription) { FactoryBot.create(:subscription) } + + context "when customer is a hubspot customer" do + let(:integration_customer) { FactoryBot.create(:hubspot_customer) } + + it "returns payload" do + expect(subject).to be_a(Integrations::Aggregator::Subscriptions::Payloads::Hubspot) + end + end + + context "when customer is an anrok customer" do + let(:integration_customer) { FactoryBot.create(:anrok_customer) } + + it "raises NotImplemented" do + expect { subject }.to raise_error(NotImplementedError) + end + end + + context "when customer is an netsuite customer" do + let(:integration_customer) { FactoryBot.create(:netsuite_customer) } + + it "raises NotImplemented" do + expect { subject }.to raise_error(NotImplementedError) + end + end + + context "when customer is an xero customer" do + let(:integration_customer) { FactoryBot.create(:xero_customer) } + + it "raises NotImplemented" do + expect { subject }.to raise_error(NotImplementedError) + end + end + end +end diff --git a/spec/services/integrations/aggregator/subscriptions/payloads/hubspot_spec.rb b/spec/services/integrations/aggregator/subscriptions/payloads/hubspot_spec.rb new file mode 100644 index 0000000..beaa1ec --- /dev/null +++ b/spec/services/integrations/aggregator/subscriptions/payloads/hubspot_spec.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Subscriptions::Payloads::Hubspot do + let(:payload) { described_class.new(integration_customer:, subscription:) } + let(:integration_customer) { FactoryBot.create(:hubspot_customer, integration:, customer:) } + let(:integration) { create(:hubspot_integration, organization:) } + let(:customer) { create(:customer, organization:) } + let(:file_url) { Faker::Internet.url } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:plan) { create(:plan, organization:) } + let(:organization) { create(:organization) } + + let(:integration_subscription) do + create(:integration_resource, integration:, resource_type: "subscription", syncable: subscription) + end + + let(:subscription_url) do + url = ENV["LAGO_FRONT_URL"].presence || "https://app.getlago.com" + URI.join(url, "/#{customer.organization.slug}/customer/#{customer.id}/subscription/#{subscription.id}/overview").to_s + end + + before do + integration_subscription + end + + describe "#create_body" do + subject(:body_call) { payload.create_body } + + let(:create_body) do + { + "objectType" => integration.subscriptions_object_type_id, + "input" => { + "associations" => [], + "properties" => { + "lago_subscription_id" => subscription.id, + "lago_external_subscription_id" => subscription.external_id, + "lago_billing_time" => subscription.billing_time, + "lago_subscription_name" => subscription.name, + "lago_subscription_plan_code" => subscription.plan.code, + "lago_subscription_status" => subscription.status, + "lago_subscription_created_at" => subscription.created_at.strftime("%Y-%m-%d"), + "lago_subscription_started_at" => subscription.started_at&.strftime("%Y-%m-%d"), + "lago_subscription_ending_at" => subscription.ending_at&.strftime("%Y-%m-%d"), + "lago_subscription_at" => subscription.subscription_at&.strftime("%Y-%m-%d"), + "lago_subscription_terminated_at" => subscription.terminated_at&.strftime("%Y-%m-%d"), + "lago_subscription_trial_ended_at" => subscription.trial_ended_at&.strftime("%Y-%m-%d"), + "lago_subscription_link" => subscription_url + } + } + } + end + + it "returns payload body" do + expect(subject).to eq(create_body) + end + end + + describe "#update_body" do + subject(:body_call) { payload.update_body } + + let(:update_body) do + { + "objectId" => integration_subscription.external_id, + "objectType" => integration.subscriptions_object_type_id, + "input" => { + "properties" => { + "lago_subscription_id" => subscription.id, + "lago_external_subscription_id" => subscription.external_id, + "lago_billing_time" => subscription.billing_time, + "lago_subscription_name" => subscription.name, + "lago_subscription_plan_code" => subscription.plan.code, + "lago_subscription_status" => subscription.status, + "lago_subscription_created_at" => subscription.created_at.strftime("%Y-%m-%d"), + "lago_subscription_started_at" => subscription.started_at&.strftime("%Y-%m-%d"), + "lago_subscription_ending_at" => subscription.ending_at&.strftime("%Y-%m-%d"), + "lago_subscription_at" => subscription.subscription_at&.strftime("%Y-%m-%d"), + "lago_subscription_terminated_at" => subscription.terminated_at&.strftime("%Y-%m-%d"), + "lago_subscription_trial_ended_at" => subscription.trial_ended_at&.strftime("%Y-%m-%d"), + "lago_subscription_link" => subscription_url + } + } + } + end + + it "returns payload body" do + expect(subject).to eq(update_body) + end + end + + describe "#customer_association_body" do + subject(:body_call) { payload.customer_association_body } + + let(:customer_association_body) do + { + "objectType" => integration.subscriptions_object_type_id, + "objectId" => integration_subscription.external_id, + "toObjectType" => integration_customer.object_type, + "toObjectId" => integration_customer.external_customer_id, + "input" => [] + } + end + + it "returns payload body" do + expect(subject).to eq(customer_association_body) + end + end +end diff --git a/spec/services/integrations/aggregator/subsidiaries_service_spec.rb b/spec/services/integrations/aggregator/subsidiaries_service_spec.rb new file mode 100644 index 0000000..1409e4a --- /dev/null +++ b/spec/services/integrations/aggregator/subsidiaries_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::SubsidiariesService do + subject(:subsidiaries_service) { described_class.new(integration:) } + + let(:integration) { create(:netsuite_integration) } + + describe ".call" do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:subsidiaries_endpoint) { "https://api.nango.dev/v1/netsuite/subsidiaries" } + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "netsuite-tba" + } + end + + let(:aggregator_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/subsidiaries_response.json") + JSON.parse(File.read(path)) + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(subsidiaries_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:get) + .with(headers:) + .and_return(aggregator_response) + end + + it "successfully fetches subsidiaries" do + result = subsidiaries_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(subsidiaries_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(lago_client).to have_received(:get) + expect(result.subsidiaries.count).to eq(4) + expect(result.subsidiaries.first.external_id).to eq("1") + expect(result.subsidiaries.first.external_name).to eq("Holo, Inc.") + end + end +end diff --git a/spec/services/integrations/aggregator/sync_service_spec.rb b/spec/services/integrations/aggregator/sync_service_spec.rb new file mode 100644 index 0000000..1e8645b --- /dev/null +++ b/spec/services/integrations/aggregator/sync_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::SyncService do + subject(:sync_service) { described_class.new(integration:) } + + let(:integration) { create(:netsuite_integration) } + + describe ".call" do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:sync_endpoint) { "https://api.nango.dev/sync/trigger" } + let(:syncs_list) do + %w[ + netsuite-subsidiaries-sync + ] + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(sync_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response) + end + + it "successfully calls sync endpoint" do + sync_service.call + + expect(LagoHttpClient::Client).to have_received(:new) + .with(sync_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(lago_client).to have_received(:post_with_response) do |payload| + expect(payload[:provider_config_key]).to eq("netsuite-tba") + expect(payload[:syncs]).to eq(syncs_list) + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/avalara/fetch_company_id_service_spec.rb b/spec/services/integrations/aggregator/taxes/avalara/fetch_company_id_service_spec.rb new file mode 100644 index 0000000..9299534 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/avalara/fetch_company_id_service_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::Avalara::FetchCompanyIdService do + subject(:service_call) { described_class.call(integration:) } + + let(:integration) { create(:avalara_integration, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/avalara/companies" } + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "avalara-sandbox" + } + end + let(:params) do + [ + { + "company_code" => integration.company_code + } + ] + end + + before do + integration + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when company fetch is successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/companies/success_response.json") + File.read(path) + end + + it "returns company id" do + result = service_call + + expect(result).to be_success + expect(result.company["id"]).to eq("DEFAULT-12345") + end + end + + context "when company fetch is NOT successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/companies/failed_response.json") + File.read(path) + end + + it "returns errors" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("company_not_found") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "avalara", + provider_code: integration.code, + provider_error: { + message: "Company cannot be found in Avalara based on the provided code", + error_code: "company_not_found" + } + ) + end + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("action_script_runtime_error") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "avalara", + provider_code: integration.code, + provider_error: { + message: "submitFields: Missing a required argument: type", + error_code: "action_script_runtime_error" + } + ) + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/credit_notes/create_service_spec.rb b/spec/services/integrations/aggregator/taxes/credit_notes/create_service_spec.rb new file mode 100644 index 0000000..eccead7 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/credit_notes/create_service_spec.rb @@ -0,0 +1,227 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::CreditNotes::CreateService do + subject(:service_call) { described_class.call(credit_note:) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:, external_customer_id: nil) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:add_on) { create(:add_on, organization:) } + let(:add_on_two) { create(:add_on, organization:) } + let(:current_time) { Time.current } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on.id, + settings: {external_id: "m1", external_account_code: "m11", external_name: ""} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization: + ) + end + let(:fee_add_on) do + create( + :fee, + invoice:, + add_on:, + created_at: current_time - 3.seconds + ) + end + let(:fee_add_on_two) do + create( + :fee, + invoice:, + add_on: add_on_two, + created_at: current_time - 2.seconds + ) + end + let(:credit_note) do + create( + :credit_note, + customer:, + invoice:, + status: "finalized", + organization: + ) + end + + let(:credit_note_item1) do + create(:credit_note_item, credit_note:, fee: fee_add_on, amount_cents: fee_add_on.amount_cents) + end + let(:credit_note_item2) do + create(:credit_note_item, credit_note:, fee: fee_add_on_two, amount_cents: fee_add_on_two.amount_cents) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "anrok" + } + end + + let(:params) do + [ + { + "id" => "cn_#{credit_note.id}", + "issuing_date" => credit_note.issuing_date, + "currency" => credit_note.currency, + "contact" => { + "external_id" => customer.external_id, + "name" => customer.name, + "address_line_1" => customer.address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "taxable" => false, + "tax_number" => nil + }, + "fees" => [ + { + "item_id" => fee_add_on.item_id, + "item_code" => "m1", + "amount_cents" => -200 + }, + { + "item_id" => fee_add_on_two.item_id, + "item_code" => "1", + "amount_cents" => -200 + } + ], + "tax_date" => credit_note.invoice.issuing_date + } + ] + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + integration_collection_mapping1 + integration_mapping_add_on + fee_add_on + fee_add_on_two + credit_note_item1 + credit_note_item2 + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when transaction is created and taxes are successfully fetched for credit note" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(path) + end + + it "returns fees" do + result = service_call + + expect(result).to be_success + expect(result.fees.first.tax_breakdown.first.rate).to eq("0.10") + expect(result.fees.first.tax_breakdown.first.name).to eq("GST/HST") + expect(result.fees.first.tax_breakdown.last.name).to eq("Reverse charge") + expect(result.fees.first.tax_breakdown.last.type).to eq("exempt") + expect(result.fees.first.tax_breakdown.last.rate).to eq("0.00") + end + + it "sets integration customer external id" do + service_call + + expect(integration_customer.reload.external_customer_id).to eq(customer.external_id) + end + end + + context "when taxes are not successfully reported" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "does not return fees" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("taxDateTooFarInFuture") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.tax_provider_error", + customer, + provider: "anrok", + provider_code: integration.code, + provider_error: { + message: "Service failure", + error_code: "taxDateTooFarInFuture" + } + ) + end + + it "does not set integration customer external id" do + service_call + + expect(integration_customer.reload.external_customer_id).to eq(nil) + end + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("action_script_runtime_error") + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/credit_notes/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/anrok_spec.rb new file mode 100644 index 0000000..1fdb19e --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/anrok_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::CreditNotes::Payloads::Anrok do + describe "#body" do + subject(:payload) { described_class.new(integration:, customer:, integration_customer:, credit_note:).body } + + it_behaves_like "an integration payload", :anrok do + def build_expected_payload(mapping_codes) + [ + { + "id" => "cn_#{credit_note.id}", + "issuing_date" => credit_note.issuing_date, + "currency" => credit_note.currency, + "contact" => { + "external_id" => integration_customer.external_customer_id, + "name" => customer.name, + "address_line_1" => customer.address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "taxable" => false, + "tax_number" => nil + }, + "fees" => match_array([ + { + "item_id" => add_on.id, + "amount_cents" => -190, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_id" => fixed_charge_add_on.id, + "amount_cents" => -140, + "item_code" => mapping_codes.dig(:fixed_charge, :external_id) + }, + { + "item_id" => billable_metric.id, + "amount_cents" => -180, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, + { + "item_id" => subscription.id, + "amount_cents" => -170, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) + }, + { + "item_id" => subscription.id, + "amount_cents" => -160, + "item_code" => mapping_codes.dig(:subscription, :external_id) + } + ]), + "tax_date" => credit_note.invoice.issuing_date + } + ] + end + end + + context "with precision edge case" do + let(:integration) { create(:anrok_integration) } + let(:customer) { create(:customer, organization: integration.organization) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:credit_note) { create(:credit_note, customer:) } + let(:fee) { create(:charge_fee, invoice: credit_note.invoice, amount_cents: 167, precise_amount_cents: 166.666666666) } + let(:credit_note_item) { create(:credit_note_item, credit_note:, fee:, amount_cents: 100, precise_amount_cents: 100) } + let(:billable_metric) { fee.charge.billable_metric } + let(:items_relation) { double } + + before do + billable_metric + credit_note_item + integration_customer + + create( + :anrok_mapping, + integration:, + mappable_type: "BillableMetric", + mappable_id: billable_metric.id, + settings: {external_id: "ext_123"} + ) + + allow(credit_note_item).to receive(:sub_total_excluding_taxes_amount_cents).and_return(99.9999) + allow(credit_note).to receive(:items).and_return(items_relation) + allow(items_relation).to receive(:order).with(created_at: :asc).and_return([credit_note_item]) + end + + it "rounds credit note item amounts correctly" do + payload = described_class.new(integration:, customer:, integration_customer:, credit_note:).body + + expect(payload.first["fees"].first["amount_cents"]).to eq(-100) + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/credit_notes/payloads/avalara_spec.rb b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/avalara_spec.rb new file mode 100644 index 0000000..36f4ba5 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/credit_notes/payloads/avalara_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::CreditNotes::Payloads::Avalara do + subject(:payload) { described_class.new(integration:, customer:, integration_customer:, credit_note:).body } + + it_behaves_like "an integration payload", :avalara do + def build_expected_payload(mapping_codes) + [ + { + "id" => "cn_#{credit_note.id}", + "type" => "returnInvoice", + "issuing_date" => credit_note.issuing_date, + "currency" => credit_note.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id || customer.external_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "region" => customer.shipping_state || customer.state, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number + }, + "billing_entity" => { + "address_line_1" => customer.billing_entity.address_line1, + "city" => customer.billing_entity.city, + "zip" => customer.billing_entity.zipcode, + "region" => customer.billing_entity.state, + "country" => customer.billing_entity.country + }, + "fees" => match_array([ + { + "item_id" => add_on.id, + "amount" => "-1.9", + "unit" => 2.0, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_id" => fixed_charge_add_on.id, + "amount" => "-1.4", + "unit" => 6.0, + "item_code" => mapping_codes.dig(:fixed_charge, :external_id) + }, + { + "item_id" => billable_metric.id, + "amount" => "-1.8", + "unit" => 3.0, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, + { + "item_id" => subscription.id, + "amount" => "-1.7", + "unit" => 4.0, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) + }, + { + "item_id" => subscription.id, + "amount" => "-1.6", + "unit" => 5.0, + "item_code" => mapping_codes.dig(:subscription, :external_id) + } + ]) + } + ] + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/create_draft_service_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/create_draft_service_spec.rb new file mode 100644 index 0000000..c283dc3 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/create_draft_service_spec.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::CreateDraftService do + subject(:service_call) { described_class.call(invoice:) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:customer) { create(:customer, :with_shipping_address, organization:) } + let(:organization) { create(:organization) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/draft_invoices" } + let(:add_on) { create(:add_on, organization:) } + let(:add_on_two) { create(:add_on, organization:) } + let(:current_time) { Time.current } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on.id, + settings: {external_id: "m1", external_account_code: "m11", external_name: ""} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization: + ) + end + let(:fee_add_on) do + create( + :fee, + invoice:, + add_on:, + created_at: current_time - 3.seconds + ) + end + let(:fee_add_on_two) do + create( + :fee, + invoice:, + add_on: add_on_two, + created_at: current_time - 2.seconds + ) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "anrok" + } + end + let(:response_status) { 200 } + + let(:params) do + [ + { + "issuing_date" => invoice.issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => integration_customer.external_customer_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1, + "city" => customer.shipping_city, + "zip" => customer.shipping_zipcode, + "country" => customer.shipping_country, + "taxable" => false, + "tax_number" => nil + }, + "fees" => [ + { + "item_key" => fee_add_on.item_key, + "item_id" => fee_add_on.id, + "item_code" => "m1", + "amount_cents" => 200 + }, + { + "item_key" => fee_add_on_two.item_key, + "item_id" => fee_add_on_two.id, + "item_code" => "1", + "amount_cents" => 200 + } + ], + "tax_date" => invoice.issuing_date + } + ] + end + + before do + integration_customer + integration_collection_mapping1 + integration_mapping_add_on + fee_add_on + fee_add_on_two + + stub_request(:post, endpoint).with(body: params.to_json, headers:) + .and_return(status: response_status, body:) + end + + describe "#call" do + context "when service call is successful" do + context "when taxes are successfully fetched" do + let(:base_body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(path) + end + let(:body) { base_body } + + it "returns fees" do + result = service_call + + expect(result).to be_success + expect(result.fees.first.tax_breakdown.first.rate).to eq("0.10") + expect(result.fees.first.tax_breakdown.first.name).to eq("GST/HST") + expect(result.fees.first.tax_breakdown.last.name).to eq("Reverse charge") + expect(result.fees.first.tax_breakdown.last.type).to eq("exempt") + expect(result.fees.first.tax_breakdown.last.rate).to eq("0.00") + end + + context "when special rules applied" do + let(:body) do + parsed_body = JSON.parse(base_body) + parsed_body["succeededInvoices"].first["fees"].first["tax_amount_cents"] = 0 + parsed_body["succeededInvoices"].first["fees"].first["tax_breakdown"] = [ + { + reason: "", + type: rule + } + ] + parsed_body.to_json + end + + special_rules = + [ + {received_type: "notCollecting", expected_name: "Not collecting"}, + {received_type: "productNotTaxed", expected_name: "Product not taxed"}, + {received_type: "jurisNotTaxed", expected_name: "Juris not taxed"}, + {received_type: "jurisHasNoTax", expected_name: "Juris has no tax"}, + {received_type: "specialUnknownRule", expected_name: "Special unknown rule"} + ] + + special_rules.each do |specific_rule| + context "when applied rule is #{specific_rule}" do + let(:rule) { specific_rule[:received_type] } + + it "returns fee object with populated for the specific rule fields" do + result = service_call + + expect(result).to be_success + expect(result.fees.first.tax_breakdown.last.name).to eq(specific_rule[:expected_name]) + expect(result.fees.first.tax_breakdown.last.type).to eq(specific_rule[:received_type]) + expect(result.fees.first.tax_breakdown.last.rate).to eq("0.00") + expect(result.fees.first.tax_breakdown.last.tax_amount).to eq(0) + end + end + end + end + + context "when taxes are paid by seller" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_seller_pays_taxes.json") + File.read(path) + end + + it "returns fee object with empty tax breakdown" do + result = service_call + + expect(result).to be_success + expect(result.fees.first.tax_breakdown.last.name).to eq("Tax") + expect(result.fees.first.tax_breakdown.last.type).to eq("tax") + expect(result.fees.first.tax_breakdown.last.rate).to eq("0.00") + expect(result.fees.first.tax_breakdown.last.tax_amount).to eq(0) + end + end + end + + context "when taxes are not successfully fetched" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "does not return fees" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("taxDateTooFarInFuture") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.tax_provider_error", + customer, + provider: "anrok", + provider_code: integration.code, + provider_error: { + message: "Service failure", + error_code: "taxDateTooFarInFuture" + } + ) + end + + context "when no integration mapping is defined" do + let(:integration_collection_mapping1) { nil } + let(:integration_mapping_add_on) { nil } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + body_string = File.read(path) + body = JSON.parse(body_string) + body["failedInvoices"].first["validation_errors"] = "Request body: \"lineItems\": 0: \"productExternalId\": String must contain at least 1 character(s)." + body.to_json + end + + before do + params.first["fees"].each { |fee| fee["item_code"] = nil } + stub_request(:post, endpoint).with(body: params.to_json, headers:) + .and_return(status: response_status, body:) + end + + it "sends request to anrok with empty link to fallback item" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("validationError") + end + end + + context "when the body contains a bad gateway error" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/bad_gateway_error.html") + File.read(path) + end + + it "raises an HTTP error" do + expect { service_call }.to raise_error(Integrations::Aggregator::BadGatewayError) + end + end + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + context "when the body contains a bad gateway error" do + let(:response_status) { 200 } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/bad_gateway_error.html") + File.read(path) + end + + it "raises an HTTP error" do + expect { service_call }.to raise_error(Integrations::Aggregator::BadGatewayError) + end + end + + context "when the error code is 502" do + let(:response_status) { 502 } + let(:body) { "" } + + it "raises an HTTP error" do + expect { service_call }.to raise_error(Integrations::Aggregator::BadGatewayError) + end + end + + context "when it is a script error" do + let(:response_status) { Faker::Number.between(from: 500, to: 599) } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_script_response.json") + File.read(path) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("action_script_failure") + end + end + + context "when it is another server error" do + let(:response_status) { Faker::Number.between(from: 500, to: 599) } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("action_script_runtime_error") + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/create_service_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/create_service_spec.rb new file mode 100644 index 0000000..db9e6f3 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/create_service_spec.rb @@ -0,0 +1,351 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::CreateService do + subject(:service_call) { described_class.call(invoice:) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:, external_customer_id: nil) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:add_on) { create(:add_on, organization:) } + let(:add_on_two) { create(:add_on, organization:) } + let(:current_time) { Time.current } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + let(:integration_mapping_add_on) do + create( + :netsuite_mapping, + integration:, + mappable_type: "AddOn", + mappable_id: add_on.id, + settings: {external_id: "m1", external_account_code: "m11", external_name: ""} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization: + ) + end + let(:fee_add_on) do + create( + :fee, + invoice:, + add_on:, + created_at: current_time - 3.seconds + ) + end + let(:fee_add_on_two) do + create( + :fee, + invoice:, + add_on: add_on_two, + created_at: current_time - 2.seconds + ) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "anrok" + } + end + let(:response_status) { 200 } + + let(:params) do + [ + { + "issuing_date" => invoice.issuing_date.to_s, + "currency" => invoice.currency, + "contact" => { + "external_id" => customer.external_id, + "name" => customer.name, + "address_line_1" => customer.address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "country" => customer.country, + "taxable" => false, + "tax_number" => nil + }, + "fees" => [ + { + "item_key" => fee_add_on.item_key, + "item_id" => fee_add_on.id, + "item_code" => "m1", + "amount_cents" => 200 + }, + { + "item_key" => fee_add_on_two.item_key, + "item_id" => fee_add_on_two.id, + "item_code" => "1", + "amount_cents" => 200 + } + ], + "tax_date" => invoice.issuing_date.to_s, + "id" => invoice.id + } + ] + end + + before do + integration_customer + integration_collection_mapping1 + integration_mapping_add_on + fee_add_on + fee_add_on_two + + stub_request(:post, endpoint).with(body: params.to_json, headers:) + .and_return(status: response_status, body:) + end + + describe "#call" do + context "when service call is successful" do + context "when taxes are successfully fetched for finalized invoice" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(path) + end + + it "returns fees" do + result = service_call + + expect(result).to be_success + expect(result.fees.first.tax_breakdown.first.rate).to eq("0.10") + expect(result.fees.first.tax_breakdown.first.name).to eq("GST/HST") + expect(result.fees.first.tax_breakdown.last.name).to eq("Reverse charge") + expect(result.fees.first.tax_breakdown.last.type).to eq("exempt") + expect(result.fees.first.tax_breakdown.last.rate).to eq("0.00") + end + + it "sets integration customer external id" do + service_call + + expect(integration_customer.reload.external_customer_id).to eq(customer.external_id) + end + + it "does not create integration resource" do + expect { service_call }.not_to change { invoice.reload.integration_resources.count } + end + end + + context "when Avalara taxes are successfully fetched for finalized invoice" do + let(:integration) { create(:avalara_integration, organization:) } + let(:integration_customer) { create(:avalara_customer, integration:, customer:, external_customer_id: "123") } + let(:endpoint) { "https://api.nango.dev/v1/avalara/finalized_invoices" } + let(:params) do + [ + { + "issuing_date" => invoice.issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => "123", + "name" => customer.name, + "address_line_1" => customer.address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "region" => customer.state, + "country" => customer.country, + "taxable" => false, + "tax_number" => nil + }, + "billing_entity" => { + "address_line_1" => customer.billing_entity&.address_line1, + "city" => customer.billing_entity&.city, + "zip" => customer.billing_entity&.zipcode, + "region" => customer.billing_entity&.state, + "country" => customer.billing_entity&.country + }, + "fees" => [ + { + "item_key" => fee_add_on.item_key, + "item_id" => fee_add_on.id, + "item_code" => "m1", + "unit" => "0.0", + "amount" => "2.0" + }, + { + "item_key" => fee_add_on_two.item_key, + "item_id" => fee_add_on_two.id, + "item_code" => "1", + "unit" => "0.0", + "amount" => "2.0" + } + ], + "id" => invoice.id, + "type" => "salesInvoice" + } + ] + end + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "avalara-sandbox" + } + end + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(path) + end + + it "returns fees" do + result = service_call + + expect(result).to be_success + expect(result.fees.first.tax_breakdown.first.rate).to eq("0.10") + expect(result.fees.first.tax_breakdown.first.name).to eq("GST/HST") + expect(result.fees.first.tax_breakdown.last.name).to eq("Reverse charge") + expect(result.fees.first.tax_breakdown.last.type).to eq("exempt") + expect(result.fees.first.tax_breakdown.last.rate).to eq("0.00") + end + + it "creates integration resource" do + expect { service_call }.to change { invoice.reload.integration_resources.count }.by(1) + end + + context "when invoice is voided" do + let(:params) do + [ + { + "issuing_date" => invoice.issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => "123", + "name" => customer.name, + "address_line_1" => customer.address_line1, + "city" => customer.city, + "zip" => customer.zipcode, + "region" => customer.state, + "country" => customer.country, + "taxable" => false, + "tax_number" => nil + }, + "billing_entity" => { + "address_line_1" => customer.billing_entity&.address_line1, + "city" => customer.billing_entity&.city, + "zip" => customer.billing_entity&.zipcode, + "region" => customer.billing_entity&.state, + "country" => customer.billing_entity&.country + }, + "fees" => [ + { + "item_key" => fee_add_on.item_key, + "item_id" => fee_add_on.id, + "item_code" => "m1", + "unit" => "0.0", + "amount" => "-2.0" + }, + { + "item_key" => fee_add_on_two.item_key, + "item_id" => fee_add_on_two.id, + "item_code" => "1", + "unit" => "0.0", + "amount" => "-2.0" + } + ], + "id" => invoice.id, + "type" => "returnInvoice" + } + ] + end + + before { invoice.voided! } + + it "returns fees for valid request payload" do + result = service_call + + expect(result).to be_success + end + end + end + + context "when taxes are not successfully fetched for finalized invoice" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "does not return fees" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("taxDateTooFarInFuture") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.tax_provider_error", + customer, + provider: "anrok", + provider_code: integration.code, + provider_error: { + message: "Service failure", + error_code: "taxDateTooFarInFuture" + } + ) + end + + it "does not set integration customer external id" do + service_call + + expect(integration_customer.reload.external_customer_id).to eq(nil) + end + + context "when the body contains a bad gateway error" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/bad_gateway_error.html") + File.read(path) + end + + it "raises an HTTP error" do + expect { service_call }.to raise_error(Integrations::Aggregator::BadGatewayError) + end + end + end + end + + context "when service call is not successful" do + context "when the error code is 502" do + let(:response_status) { 502 } + let(:body) { "" } + + it "raises an HTTP error" do + expect { service_call }.to raise_error(Integrations::Aggregator::BadGatewayError) + end + end + + context "when it is a server error" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + let(:response_status) { 500 } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("action_script_runtime_error") + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/negate_service_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/negate_service_spec.rb new file mode 100644 index 0000000..837651d --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/negate_service_spec.rb @@ -0,0 +1,138 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::NegateService do + subject(:service_call) { described_class.call(invoice:) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/negate_invoices" } + let(:current_time) { Time.current } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization: + ) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "anrok" + } + end + + let(:params) do + [ + { + "id" => invoice.id, + "voided_id" => "#{invoice.id}_voided" + } + ] + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + integration_collection_mapping1 + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when negate invoice sync is successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_negate.json") + File.read(path) + end + + it "returns invoice_id" do + result = service_call + + expect(result).to be_success + expect(result.invoice_id).to be_present + end + end + + context "when negate invoice sync is NOT successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "returns errors" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("taxDateTooFarInFuture") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.tax_provider_error", + customer, + provider: "anrok", + provider_code: integration.code, + provider_error: { + message: "Service failure", + error_code: "taxDateTooFarInFuture" + } + ) + end + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("action_script_runtime_error") + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/payloads/anrok_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/payloads/anrok_spec.rb new file mode 100644 index 0000000..e74efc9 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/payloads/anrok_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::Payloads::Anrok do + describe "#body" do + subject(:payload) { described_class.new(integration:, customer:, invoice:, integration_customer:, fees:).body } + + it_behaves_like "an integration payload", :avalara do + def build_expected_payload(mapping_codes) + [ + { + "issuing_date" => invoice.issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id || customer.external_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number + }, + "fees" => match_array([ + { + "item_key" => add_on_fee.item_key, + "item_id" => add_on_fee.id, + "amount_cents" => 200, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_key" => fixed_charge_fee.item_key, + "item_id" => fixed_charge_fee.id, + "amount_cents" => 150, + "item_code" => mapping_codes.dig(:fixed_charge, :external_id) + }, + { + "item_key" => billable_metric_fee.item_key, + "item_id" => billable_metric_fee.id, + "amount_cents" => 300, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, + { + "item_key" => minimum_commitment_fee.item_key, + "item_id" => minimum_commitment_fee.id, + "amount_cents" => 400, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) + }, + { + "item_key" => subscription_fee.item_key, + "item_id" => subscription_fee.id, + "amount_cents" => 500, + "item_code" => mapping_codes.dig(:subscription, :external_id) + } + ]), + "tax_date" => invoice.issuing_date + } + ] + end + + context "when invoice.issuing_date is too far in the future" do + it "uses issuing date 30 days in the future at most" do + invoice.issuing_date = 61.days.from_now.to_date + expect(payload.sole["issuing_date"]).to eq 30.days.from_now.to_date + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/payloads/avalara_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/payloads/avalara_spec.rb new file mode 100644 index 0000000..dc0a0d2 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/payloads/avalara_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::Payloads::Avalara do + describe "#body" do + subject(:payload) { described_class.new(integration:, customer:, invoice:, integration_customer:, fees:).body } + + it_behaves_like "an integration payload", :avalara do + def build_expected_payload(mapping_codes, negative_amount: false) + [ + { + "issuing_date" => invoice.issuing_date, + "currency" => invoice.currency, + "contact" => { + "external_id" => integration_customer&.external_customer_id || customer.external_id, + "name" => customer.name, + "address_line_1" => customer.shipping_address_line1 || customer.address_line1, + "city" => customer.shipping_city || customer.city, + "zip" => customer.shipping_zipcode || customer.zipcode, + "region" => customer.shipping_state || customer.state, + "country" => customer.shipping_country || customer.country, + "taxable" => customer.tax_identification_number.present?, + "tax_number" => customer.tax_identification_number + }, + "billing_entity" => { + "address_line_1" => customer.billing_entity.address_line1, + "city" => customer.billing_entity.city, + "zip" => customer.billing_entity.zipcode, + "region" => customer.billing_entity.state, + "country" => customer.billing_entity.country + }, + "fees" => match_array([ + { + "item_key" => add_on_fee.item_key, + "item_id" => add_on_fee.id, + "amount" => negative_amount ? "-2.0" : "2.0", + "unit" => 2.0, + "item_code" => mapping_codes.dig(:add_on, :external_id) + }, + { + "item_key" => fixed_charge_fee.item_key, + "item_id" => fixed_charge_fee.id, + "amount" => negative_amount ? "-1.5" : "1.5", + "unit" => 6.0, + "item_code" => mapping_codes.dig(:fixed_charge, :external_id) + }, + { + "item_key" => billable_metric_fee.item_key, + "item_id" => billable_metric_fee.id, + "amount" => negative_amount ? "-3.0" : "3.0", + "unit" => 3.0, + "item_code" => mapping_codes.dig(:billable_metric, :external_id) + }, + { + "item_key" => minimum_commitment_fee.item_key, + "item_id" => minimum_commitment_fee.id, + "amount" => negative_amount ? "-4.0" : "4.0", + "unit" => 4.0, + "item_code" => mapping_codes.dig(:minimum_commitment, :external_id) + }, + { + "item_key" => subscription_fee.item_key, + "item_id" => subscription_fee.id, + "amount" => negative_amount ? "-5.0" : "5.0", + "unit" => 5.0, + "item_code" => mapping_codes.dig(:subscription, :external_id) + } + ]) + } + ] + end + + context "when invoice is voided" do + before { invoice.voided! } + + it "returns the payload body" do + expect(payload).to match_array build_expected_payload(default_mapping_codes, negative_amount: true) + end + end + end + end +end diff --git a/spec/services/integrations/aggregator/taxes/invoices/void_service_spec.rb b/spec/services/integrations/aggregator/taxes/invoices/void_service_spec.rb new file mode 100644 index 0000000..566f4b3 --- /dev/null +++ b/spec/services/integrations/aggregator/taxes/invoices/void_service_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Aggregator::Taxes::Invoices::VoidService do + subject(:service_call) { described_class.call(invoice:) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/void_invoices" } + let(:current_time) { Time.current } + + let(:integration_collection_mapping1) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization: + ) + end + + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "anrok" + } + end + + let(:params) do + [ + { + "id" => invoice.id + } + ] + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + + integration_customer + integration_collection_mapping1 + end + + describe "#call" do + context "when service call is successful" do + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when void invoice sync is successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json") + File.read(path) + end + + it "returns invoice_id" do + result = service_call + + expect(result).to be_success + expect(result.invoice_id).to be_present + end + end + + context "when void invoice sync is successful for avalara integration" do + let(:integration) { create(:avalara_integration, organization:) } + let(:integration_customer) { create(:avalara_customer, integration:, customer:) } + let(:endpoint) { "https://api.nango.dev/v1/avalara/void_invoices" } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json") + File.read(path) + end + let(:headers) do + { + "Connection-Id" => integration.connection_id, + "Authorization" => "Bearer #{ENV["NANGO_SECRET_KEY"]}", + "Provider-Config-Key" => "avalara-sandbox" + } + end + let(:params) do + [ + { + "company_code" => integration.company_code, + "id" => invoice.id + } + ] + end + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + it "returns invoice_id" do + result = service_call + + expect(result).to be_success + expect(result.invoice_id).to be_present + end + end + + context "when void invoice sync is NOT successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "returns errors" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("taxDateTooFarInFuture") + end + + it "delivers an error webhook" do + expect { service_call }.to enqueue_job(SendWebhookJob) + .with( + "customer.tax_provider_error", + customer, + provider: "anrok", + provider_code: integration.code, + provider_error: { + message: "Service failure", + error_code: "taxDateTooFarInFuture" + } + ) + end + end + end + + context "when service call is not successful" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/error_response.json") + File.read(path) + end + + let(:http_error) { LagoHttpClient::HttpError.new(error_code, body, nil) } + + before do + allow(lago_client).to receive(:post_with_response).with(params, headers).and_raise(http_error) + end + + context "when it is a server error" do + let(:error_code) { Faker::Number.between(from: 500, to: 599) } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.fees).to be(nil) + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("action_script_runtime_error") + end + end + end + end +end diff --git a/spec/services/integrations/anrok/create_service_spec.rb b/spec/services/integrations/anrok/create_service_spec.rb new file mode 100644 index 0000000..1894c8c --- /dev/null +++ b/spec/services/integrations/anrok/create_service_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Anrok::CreateService do + include_context "with mocked security logger" + + let(:service) { described_class.new(membership.user) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + subject(:service_call) { service.call(**create_args) } + + let(:name) { "Anrok 1" } + + let(:create_args) do + { + name:, + code: "anrok1", + organization_id: organization.id, + connection_id: "conn1", + api_key: "123456789" + } + end + + context "without premium license" do + it "does not create an integration" do + expect { service_call }.not_to change(Integrations::AnrokIntegration, :count) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "with premium license", :premium do + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(Integrations::AnrokIntegration, :count).by(1) + + integration = Integrations::AnrokIntegration.order(:created_at).last + expect(integration.name).to eq(name) + expect(integration.connection_id).to eq("conn1") + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::AnrokIntegration) + end + + it_behaves_like "produces a security log", "integration.created" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end +end diff --git a/spec/services/integrations/anrok/update_service_spec.rb b/spec/services/integrations/anrok/update_service_spec.rb new file mode 100644 index 0000000..55acb00 --- /dev/null +++ b/spec/services/integrations/anrok/update_service_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Anrok::UpdateService do + include_context "with mocked security logger" + + let(:integration) { create(:anrok_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:name) { "Anrok 1" } + + let(:update_args) do + { + name:, + code: "anrok1", + api_key: "123456789" + } + end + + context "without premium license" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "with premium license", :premium do + context "without validation errors" do + it "updates an integration" do + service_call + + integration = Integrations::AnrokIntegration.order(updated_at: :desc).first + expect(integration.name).to eq(name) + expect(integration.api_key).to eq("123456789") + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::AnrokIntegration) + end + + it_behaves_like "produces a security log", "integration.updated" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end +end diff --git a/spec/services/integrations/avalara/create_service_spec.rb b/spec/services/integrations/avalara/create_service_spec.rb new file mode 100644 index 0000000..34468e1 --- /dev/null +++ b/spec/services/integrations/avalara/create_service_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Avalara::CreateService do + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + subject(:service_call) { described_class.call(params: create_args) } + + let(:name) { "Avalara 1" } + + let(:create_args) do + { + name:, + code: "anrok1", + organization_id: organization.id, + connection_id: "conn1", + account_id: "account-id1", + company_code: "company-code1", + license_key: "123456789" + } + end + + context "without premium license" do + it "does not create an integration" do + expect { service_call }.not_to change(Integrations::AvalaraIntegration, :count) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "when avalara premium integration is not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "when avalara premium integration is present" do + before do + organization.update!(premium_integrations: ["avalara"]) + allow(Integrations::Avalara::FetchCompanyIdJob).to receive(:perform_later) + end + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(Integrations::AvalaraIntegration, :count).by(1) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::AvalaraIntegration) + expect(result.integration.name).to eq(name) + expect(result.integration.code).to eq("anrok1") + expect(result.integration.organization).to eq(organization) + expect(result.integration.connection_id).to eq("conn1") + expect(result.integration.company_code).to eq("company-code1") + expect(result.integration.account_id).to eq("account-id1") + expect(result.integration.license_key).to eq("123456789") + end + + it "enqueues the jobs to fetch company id" do + service_call + + integration = Integrations::AvalaraIntegration.order(:created_at).last + expect(Integrations::Avalara::FetchCompanyIdJob).to have_received(:perform_later).with(integration:) + end + + it_behaves_like "produces a security log", "integration.created" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/avalara/fetch_company_id_service_spec.rb b/spec/services/integrations/avalara/fetch_company_id_service_spec.rb new file mode 100644 index 0000000..5583fe8 --- /dev/null +++ b/spec/services/integrations/avalara/fetch_company_id_service_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Avalara::FetchCompanyIdService do + describe "#call" do + let(:service_call) { described_class.call(integration:) } + let(:company_id) { "abc-12345" } + let(:integration) { create(:avalara_integration, company_id: nil) } + let(:result) { BaseService::Result.new } + let(:company) do + { + "id" => company_id + } + end + + before do + result.company = company + allow(Integrations::Aggregator::Taxes::Avalara::FetchCompanyIdService).to receive(:call).and_return(result) + end + + context "when the service response is successful" do + it "saves the company id to the integration" do + expect { service_call }.to change { integration.reload.company_id }.to(company_id) + end + + it "returns a success result" do + result = service_call + expect(result).to be_success + end + end + + context "when the service fails" do + before do + allow(integration).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(integration)) + end + + it "does not change the company id" do + expect { service_call }.not_to change { integration.reload.company_id } + end + + it "returns an error message" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end +end diff --git a/spec/services/integrations/avalara/update_service_spec.rb b/spec/services/integrations/avalara/update_service_spec.rb new file mode 100644 index 0000000..7b08866 --- /dev/null +++ b/spec/services/integrations/avalara/update_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Avalara::UpdateService do + include_context "with mocked security logger" + + let(:integration) { create(:avalara_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:name) { "Avalara 1" } + + let(:update_args) do + { + name:, + code: "anrok1", + license_key: "123456789", + account_id: "acc-id-1" + } + end + + context "without premium license" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "without avalara premium integration" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with avalara premium integration" do + before do + organization.update!(premium_integrations: ["avalara"]) + end + + context "without validation errors" do + it "updates an integration" do + service_call + + integration = Integrations::AvalaraIntegration.order(:updated_at).last + expect(integration.name).to eq(name) + expect(integration.code).to eq("anrok1") + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::AvalaraIntegration) + end + + it_behaves_like "produces a security log", "integration.updated" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/destroy_service_spec.rb b/spec/services/integrations/destroy_service_spec.rb new file mode 100644 index 0000000..a8784cc --- /dev/null +++ b/spec/services/integrations/destroy_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::DestroyService do + subject(:destroy_service) { described_class.new(integration:) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:netsuite_integration, organization:) } + + describe ".call" do + before { integration } + + it "destroys the integration" do + expect { destroy_service.call } + .to change(Integrations::BaseIntegration, :count).by(-1) + end + + it_behaves_like "produces a security log", "integration.deleted" do + before { destroy_service.call } + end + + context "when integration is not found" do + let(:integration) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("integration_not_found") + end + end + end +end diff --git a/spec/services/integrations/hubspot/companies/deploy_properties_service_spec.rb b/spec/services/integrations/hubspot/companies/deploy_properties_service_spec.rb new file mode 100644 index 0000000..d232ef2 --- /dev/null +++ b/spec/services/integrations/hubspot/companies/deploy_properties_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Companies::DeployPropertiesService do + subject(:deploy_properties_service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe ".call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/properties" } + let(:response) { instance_double("Response", success?: true) } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(response) + + integration.companies_properties_version = nil + integration.save! + end + + it "successfully deploys companies properties and updates the companies_properties_version" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(http_client).to have_received(:post_with_response) do |payload, headers| + expect(payload[:objectType]).to eq("companies") + expect(headers["Authorization"]).to include("Bearer") + end + expect(integration.reload.companies_properties_version).to eq(described_class::VERSION) + end + + context "when companies_properties_version is already up-to-date" do + before do + integration.companies_properties_version = described_class::VERSION + integration.save! + end + + it "does not make an API call and keeps the version unchanged" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(http_client).not_to have_received(:post_with_response) + expect(integration.reload.companies_properties_version).to eq(described_class::VERSION) + end + end + + context "when an HTTP error occurs" do + let(:error) { LagoHttpClient::HttpError.new("error message", '{"error": {"message": "unknown failure"}}', nil) } + + before do + allow(http_client).to receive(:post_with_response).and_raise(error) + end + + it "delivers an integration error webhook" do + expect { deploy_properties_service.call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message: "unknown failure", + error_code: "integration_error" + } + ) + end + end + end +end diff --git a/spec/services/integrations/hubspot/contacts/deploy_properties_service_spec.rb b/spec/services/integrations/hubspot/contacts/deploy_properties_service_spec.rb new file mode 100644 index 0000000..dc8f1cb --- /dev/null +++ b/spec/services/integrations/hubspot/contacts/deploy_properties_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Contacts::DeployPropertiesService do + subject(:deploy_properties_service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe ".call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/properties" } + let(:response) { instance_double("Response", success?: true) } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(response) + + integration.contacts_properties_version = nil + integration.save! + end + + it "successfully deploys contacts properties and updates the contacts_properties_version" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(http_client).to have_received(:post_with_response) do |payload, headers| + expect(payload[:objectType]).to eq("contacts") + expect(headers["Authorization"]).to include("Bearer") + end + expect(integration.reload.contacts_properties_version).to eq(described_class::VERSION) + end + + context "when contacts_properties_version is already up-to-date" do + before do + integration.contacts_properties_version = described_class::VERSION + integration.save! + end + + it "does not make an API call and keeps the version unchanged" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(http_client).not_to have_received(:post_with_response) + expect(integration.reload.contacts_properties_version).to eq(described_class::VERSION) + end + end + + context "when an HTTP error occurs" do + let(:error) { LagoHttpClient::HttpError.new("error message", '{"error": {"message": "unknown failure"}}', nil) } + + before do + allow(http_client).to receive(:post_with_response).and_raise(error) + end + + it "delivers an integration error webhook" do + expect { deploy_properties_service.call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message: "unknown failure", + error_code: "integration_error" + } + ) + end + end + end +end diff --git a/spec/services/integrations/hubspot/create_service_spec.rb b/spec/services/integrations/hubspot/create_service_spec.rb new file mode 100644 index 0000000..fd13558 --- /dev/null +++ b/spec/services/integrations/hubspot/create_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::CreateService do + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + subject(:service_call) { described_class.call(params: create_args) } + + let(:name) { "Hubspot 1" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:create_args) do + { + name:, + code: "hubspot1", + organization_id: organization.id, + connection_id: "conn1", + client_secret: "secret", + default_targeted_object: "test", + sync_invoices: false, + sync_subscriptions: false + } + end + + context "without premium license" do + it "does not create an integration" do + expect { service_call }.not_to change(Integrations::HubspotIntegration, :count) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with hubspot premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with hubspot premium integration present" do + before do + organization.update!(premium_integrations: ["hubspot"]) + allow(Integrations::Aggregator::SyncCustomObjectsAndPropertiesJob).to receive(:perform_later) + allow(Integrations::Hubspot::SavePortalIdJob).to receive(:perform_later) + end + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(Integrations::HubspotIntegration, :count).by(1) + + integration = Integrations::HubspotIntegration.order(:created_at).last + expect(integration.name).to eq(name) + expect(integration.code).to eq(create_args[:code]) + expect(integration.connection_id).to eq(create_args[:connection_id]) + expect(integration.default_targeted_object).to eq(create_args[:default_targeted_object]) + expect(integration.sync_invoices).to eq(create_args[:sync_invoices]) + expect(integration.sync_subscriptions).to eq(create_args[:sync_subscriptions]) + expect(integration.organization_id).to eq(organization.id) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::HubspotIntegration) + end + + it "enqueues the jobs to send token and sync objects to Hubspot" do + service_call + + integration = Integrations::HubspotIntegration.order(:created_at).last + expect(Integrations::Aggregator::SyncCustomObjectsAndPropertiesJob).to have_received(:perform_later).with(integration:) + expect(Integrations::Hubspot::SavePortalIdJob).to have_received(:perform_later).with(integration:) + end + + it_behaves_like "produces a security log", "integration.created" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/hubspot/invoices/deploy_object_service_spec.rb b/spec/services/integrations/hubspot/invoices/deploy_object_service_spec.rb new file mode 100644 index 0000000..9c5e242 --- /dev/null +++ b/spec/services/integrations/hubspot/invoices/deploy_object_service_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Invoices::DeployObjectService do + subject(:deploy_object_service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe ".call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:http_client_get) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/object" } + let(:customer_object_endpoint) { "https://api.nango.dev/v1/hubspot/custom-object" } + let(:response) { instance_double("Response", success?: true) } + + let(:get_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/custom_object_response.json") + JSON.parse(File.read(path)) + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client) + allow(LagoHttpClient::Client).to receive(:new) + .with(customer_object_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client_get) + allow(http_client).to receive(:post_with_response).and_return(response) + allow(http_client_get).to receive(:get).and_raise LagoHttpClient::HttpError.new("error", "error", nil) + allow(response).to receive(:body).and_return({"objectTypeId" => "123"}.to_json) + + integration.invoices_properties_version = nil + integration.save! + end + + it "successfully deploys invoice custom object and updates the invoices_properties_version" do + deploy_object_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(http_client).to have_received(:post_with_response) do |payload, headers| + expect(payload[:name]).to eq("LagoInvoices") + expect(headers["Authorization"]).to include("Bearer") + end + expect(integration.reload.invoices_properties_version).to eq(described_class::VERSION) + end + + context "when invoices_properties_version is already up-to-date" do + before do + integration.invoices_properties_version = described_class::VERSION + integration.save! + end + + it "does not make an API call and keeps the version unchanged" do + deploy_object_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(http_client).not_to have_received(:post_with_response) + expect(integration.reload.invoices_properties_version).to eq(described_class::VERSION) + end + end + + context "when custom object service returns a valid objectTypeId" do + let(:custom_object_result) do + instance_double( + "CustomObjectResult", + success?: true, + custom_object: instance_double("CustomObject", objectTypeId: "123") + ) + end + + before do + allow(http_client).to receive(:get).and_return(get_response) + allow(Integrations::Aggregator::CustomObjectService).to receive(:call).and_return(custom_object_result) + end + + it "saves the objectTypeId and updates the invoices_properties_version" do + deploy_object_service.call + + expect(integration.reload.invoices_object_type_id).to eq("123") + expect(integration.reload.invoices_properties_version).to eq(described_class::VERSION) + end + end + + context "when custom object service does not return a valid objectTypeId" do + let(:custom_object_result) do + instance_double("CustomObjectResult", success?: false) + end + + before do + allow(Integrations::Aggregator::CustomObjectService).to receive(:call).and_return(custom_object_result) + end + + it "makes an API call and updates the invoices_properties_version" do + deploy_object_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(http_client).to have_received(:post_with_response) + expect(integration.reload.invoices_properties_version).to eq(described_class::VERSION) + end + end + + context "when an HTTP error occurs" do + let(:error) { LagoHttpClient::HttpError.new("error message", '{"error": {"message": "unknown failure"}}', nil) } + + before do + allow(http_client).to receive(:post_with_response).and_raise(error) + end + + it "delivers an integration error webhook" do + expect { deploy_object_service.call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message: "unknown failure", + error_code: "integration_error" + } + ) + end + end + end +end diff --git a/spec/services/integrations/hubspot/invoices/deploy_properties_service_spec.rb b/spec/services/integrations/hubspot/invoices/deploy_properties_service_spec.rb new file mode 100644 index 0000000..dfb7573 --- /dev/null +++ b/spec/services/integrations/hubspot/invoices/deploy_properties_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Invoices::DeployPropertiesService do + subject(:deploy_properties_service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe ".call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/properties" } + let(:response) { instance_double("Response", success?: true) } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(response) + + integration.invoices_properties_version = nil + integration.save! + end + + it "successfully deploys invoices properties and updates the invoices_properties_version" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(http_client).to have_received(:post_with_response) do |payload, headers| + expect(payload[:objectType]).to eq(integration.invoices_object_type_id) + expect(headers["Authorization"]).to include("Bearer") + end + expect(integration.reload.invoices_properties_version).to eq(described_class::VERSION) + end + + context "when invoices_properties_version is already up-to-date" do + before do + integration.invoices_properties_version = described_class::VERSION + integration.save! + end + + it "does not make an API call and keeps the version unchanged" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(http_client).not_to have_received(:post_with_response) + expect(integration.reload.invoices_properties_version).to eq(described_class::VERSION) + end + end + + context "when an HTTP error occurs" do + let(:error) { LagoHttpClient::HttpError.new("error message", '{"error": {"message": "unknown failure"}}', nil) } + + before do + allow(http_client).to receive(:post_with_response).and_raise(error) + end + + it "delivers an integration error webhook" do + expect { deploy_properties_service.call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message: "unknown failure", + error_code: "integration_error" + } + ) + end + end + end +end diff --git a/spec/services/integrations/hubspot/save_portal_id_service_spec.rb b/spec/services/integrations/hubspot/save_portal_id_service_spec.rb new file mode 100644 index 0000000..d522171 --- /dev/null +++ b/spec/services/integrations/hubspot/save_portal_id_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::SavePortalIdService do + describe "#call" do + let(:portal_id) { "123456" } + let(:integration) { create(:hubspot_integration) } + let(:service_call) { described_class.call(integration:) } + let(:result) { BaseService::Result.new } + let(:account_information) { OpenStruct.new(id: portal_id) } + + before do + result.account_information = account_information + allow(Integrations::Aggregator::AccountInformationService).to receive(:call).and_return(result) + end + + context "when the service is successful" do + it "saves the portal ID to the integration" do + expect { service_call }.to change { integration.reload.portal_id }.to(portal_id) + end + + it "returns a success result" do + result = service_call + expect(result).to be_success + end + end + + context "when the service fails" do + before do + allow(integration).to receive(:update!).and_raise(ActiveRecord::RecordInvalid.new(integration)) + end + + it "does not change the portal ID" do + expect { service_call }.not_to change { integration.reload.portal_id } + end + + it "returns an error message" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end +end diff --git a/spec/services/integrations/hubspot/subscriptions/deploy_object_service_spec.rb b/spec/services/integrations/hubspot/subscriptions/deploy_object_service_spec.rb new file mode 100644 index 0000000..70e9c99 --- /dev/null +++ b/spec/services/integrations/hubspot/subscriptions/deploy_object_service_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Subscriptions::DeployObjectService do + subject(:deploy_object_service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe ".call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:http_client_get) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/object" } + let(:customer_object_endpoint) { "https://api.nango.dev/v1/hubspot/custom-object" } + let(:response) { instance_double("Response", success?: true) } + + let(:get_response) do + path = Rails.root.join("spec/fixtures/integration_aggregator/custom_object_response.json") + JSON.parse(File.read(path)) + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client) + allow(LagoHttpClient::Client).to receive(:new) + .with(customer_object_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client_get) + allow(http_client).to receive(:post_with_response).and_return(response) + allow(http_client_get).to receive(:get).and_raise LagoHttpClient::HttpError.new("error", "error", nil) + allow(response).to receive(:body).and_return({"objectTypeId" => "123"}.to_json) + + integration.subscriptions_properties_version = nil + integration.save! + end + + it "successfully deploys subscription custom object and updates the subscriptions_properties_version" do + deploy_object_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(http_client).to have_received(:post_with_response) do |payload, headers| + expect(payload[:name]).to eq("LagoSubscriptions") + expect(headers["Authorization"]).to include("Bearer") + end + expect(integration.reload.subscriptions_properties_version).to eq(described_class::VERSION) + end + + context "when subscriptions_properties_version is already up-to-date" do + before do + integration.subscriptions_properties_version = described_class::VERSION + integration.save! + end + + it "does not make an API call and keeps the version unchanged" do + deploy_object_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(http_client).not_to have_received(:post_with_response) + expect(integration.reload.subscriptions_properties_version).to eq(described_class::VERSION) + end + end + + context "when an HTTP error occurs" do + let(:error) { LagoHttpClient::HttpError.new("error message", '{"error": {"message": "unknown failure"}}', nil) } + + before do + allow(http_client).to receive(:post_with_response).and_raise(error) + end + + it "delivers an integration error webhook" do + expect { deploy_object_service.call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message: "unknown failure", + error_code: "integration_error" + } + ) + end + end + end +end diff --git a/spec/services/integrations/hubspot/subscriptions/deploy_properties_service_spec.rb b/spec/services/integrations/hubspot/subscriptions/deploy_properties_service_spec.rb new file mode 100644 index 0000000..0cad91d --- /dev/null +++ b/spec/services/integrations/hubspot/subscriptions/deploy_properties_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::Subscriptions::DeployPropertiesService do + subject(:deploy_properties_service) { described_class.new(integration:) } + + let(:integration) { create(:hubspot_integration) } + + describe ".call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/hubspot/properties" } + let(:response) { instance_double("Response", success?: true) } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(response) + + integration.subscriptions_properties_version = nil + integration.save! + end + + it "successfully deploys subscriptions properties and updates the subscriptions_properties_version" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).to have_received(:new).with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(http_client).to have_received(:post_with_response) do |payload, headers| + expect(payload[:objectType]).to eq(integration.subscriptions_object_type_id) + expect(headers["Authorization"]).to include("Bearer") + end + expect(integration.reload.subscriptions_properties_version).to eq(described_class::VERSION) + end + + context "when subscriptions_properties_version is already up-to-date" do + before do + integration.subscriptions_properties_version = described_class::VERSION + integration.save! + end + + it "does not make an API call and keeps the version unchanged" do + deploy_properties_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(http_client).not_to have_received(:post_with_response) + expect(integration.reload.subscriptions_properties_version).to eq(described_class::VERSION) + end + end + + context "when an HTTP error occurs" do + let(:error) { LagoHttpClient::HttpError.new("error message", '{"error": {"message": "unknown failure"}}', nil) } + + before do + allow(http_client).to receive(:post_with_response).and_raise(error) + end + + it "delivers an integration error webhook" do + expect { deploy_properties_service.call }.to enqueue_job(SendWebhookJob) + .with( + "integration.provider_error", + integration, + provider: "hubspot", + provider_code: integration.code, + provider_error: { + message: "unknown failure", + error_code: "integration_error" + } + ) + end + end + end +end diff --git a/spec/services/integrations/hubspot/update_service_spec.rb b/spec/services/integrations/hubspot/update_service_spec.rb new file mode 100644 index 0000000..07db2b3 --- /dev/null +++ b/spec/services/integrations/hubspot/update_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Hubspot::UpdateService do + include_context "with mocked security logger" + + let(:integration) { create(:hubspot_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:name) { "Hubspot 1" } + let(:update_args) do + { + name:, + code: "hubspot1" + } + end + + context "without premium license" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with hubspot premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with hubspot premium integration present" do + before do + organization.update!(premium_integrations: ["hubspot"]) + end + + context "without validation errors" do + it "updates an integration" do + service_call + + integration = Integrations::HubspotIntegration.order(:updated_at).last + expect(integration.name).to eq(name) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::HubspotIntegration) + end + + it_behaves_like "produces a security log", "integration.updated" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/netsuite/create_service_spec.rb b/spec/services/integrations/netsuite/create_service_spec.rb new file mode 100644 index 0000000..bf9fb40 --- /dev/null +++ b/spec/services/integrations/netsuite/create_service_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Netsuite::CreateService do + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + subject(:service_call) { described_class.call(params: create_args) } + + let(:name) { "Netsuite 1" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:create_args) do + { + name:, + code: "netsuite1", + organization_id: organization.id, + connection_id: "conn1", + client_id: "cl1", + client_secret: "secret", + token_id: "xyz", + token_secret: "zyx", + account_id: "acc1", + script_endpoint_url: + } + end + + context "without premium license" do + it "does not create an integration" do + expect { service_call }.not_to change(Integrations::NetsuiteIntegration, :count) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with netsuite premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with netsuite premium integration present" do + before do + organization.update!(premium_integrations: ["netsuite"]) + allow(Integrations::Aggregator::SendRestletEndpointJob).to receive(:perform_later) + allow(Integrations::Aggregator::PerformSyncJob).to receive(:perform_later) + allow(Integrations::Aggregator::FetchItemsJob).to receive(:perform_later) + end + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(Integrations::NetsuiteIntegration, :count).by(1) + + integration = Integrations::NetsuiteIntegration.order(:created_at).last + expect(integration.name).to eq(name) + expect(integration.token_id).to eq("xyz") + expect(integration.token_secret).to eq("zyx") + expect(integration.script_endpoint_url).to eq(script_endpoint_url) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::NetsuiteIntegration) + end + + it "calls Integrations::Aggregator::SendRestletEndpointJob" do + service_call + + integration = Integrations::NetsuiteIntegration.order(:created_at).last + expect(Integrations::Aggregator::SendRestletEndpointJob).to have_received(:perform_later).with(integration:) + end + + it "calls Integrations::Aggregator::PerformSyncJob" do + expect { service_call }.to have_enqueued_job(Integrations::Aggregator::PerformSyncJob) + end + + it_behaves_like "produces a security log", "integration.created" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/netsuite/update_service_spec.rb b/spec/services/integrations/netsuite/update_service_spec.rb new file mode 100644 index 0000000..bb2bddc --- /dev/null +++ b/spec/services/integrations/netsuite/update_service_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Netsuite::UpdateService do + include_context "with mocked security logger" + + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:name) { "Netsuite 1" } + let(:script_endpoint_url) { Faker::Internet.url } + + let(:update_args) do + { + name:, + code: "netsuite1", + script_endpoint_url: + } + end + + context "without premium license" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with netsuite premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with netsuite premium integration present" do + before do + organization.update!(premium_integrations: ["netsuite"]) + allow(Integrations::Aggregator::SendRestletEndpointJob).to receive(:perform_later) + allow(Integrations::Aggregator::PerformSyncJob).to receive(:perform_later) + end + + context "without validation errors" do + it "updates an integration" do + service_call + + integration = Integrations::NetsuiteIntegration.order(:updated_at).last + expect(integration.name).to eq(name) + expect(integration.script_endpoint_url).to eq(script_endpoint_url) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::NetsuiteIntegration) + end + + it_behaves_like "produces a security log", "integration.updated" do + before { service_call } + end + + it "calls Integrations::Aggregator::SendRestletEndpointJob" do + service_call + + expect(Integrations::Aggregator::SendRestletEndpointJob).to have_received(:perform_later).with(integration:) + end + + it "calls Integrations::Aggregator::PerformSyncJob" do + expect { service_call }.to have_enqueued_job(Integrations::Aggregator::PerformSyncJob) + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/okta/create_service_spec.rb b/spec/services/integrations/okta/create_service_spec.rb new file mode 100644 index 0000000..744310d --- /dev/null +++ b/spec/services/integrations/okta/create_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Okta::CreateService do + include_context "with mocked security logger" + + let(:service) { described_class.new(membership.user) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:domain) { "foo.bar" } + let(:host) { "test.com" } + + describe "#call" do + subject(:service_call) { service.call(**create_args) } + + let(:create_args) do + { + organization_id: organization.id, + client_id: "cl1", + client_secret: "secret", + domain:, + organization_name: "Foobar", + host: + } + end + + context "without premium license" do + it "does not create an integration" do + expect { service_call }.not_to change(Integrations::OktaIntegration, :count) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with okta premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with okta premium integration present" do + before { organization.update!(premium_integrations: ["okta"]) } + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(Integrations::OktaIntegration, :count).by(1) + + integration = Integrations::OktaIntegration.order(:created_at).last + expect(integration.domain).to eq(domain) + end + + it "enables okta authentication" do + service_call + expect(organization.reload).to be_okta_authentication_enabled + end + + it_behaves_like "produces a security log", "integration.created" do + before { service_call } + end + end + + context "with validation error" do + let(:domain) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:domain]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/okta/destroy_service_spec.rb b/spec/services/integrations/okta/destroy_service_spec.rb new file mode 100644 index 0000000..e62635c --- /dev/null +++ b/spec/services/integrations/okta/destroy_service_spec.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Okta::DestroyService do + subject(:destroy_service) { described_class.new(integration:) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:integration) { create(:okta_integration, organization:) } + + describe ".call", :premium do + before do + integration + organization.enable_okta_authentication! + end + + it "destroys the integration" do + expect { destroy_service.call } + .to change(Integrations::BaseIntegration, :count).by(-1) + end + + it "removes the authentication_method" do + destroy_service.call + expect(organization.authentication_methods).not_to include("okta") + end + + it_behaves_like "produces a security log", "integration.deleted" do + before { destroy_service.call } + end + + context "when integration is not found" do + let(:integration) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("integration_not_found") + end + end + + context "when destroy is not allowed" do + before do + organization.update!(authentication_methods: ["okta"]) + end + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("enabled_authentication_methods_required") + end + end + + context "when okta authentication is disabled" do + before do + organization.update(authentication_methods: ["email_password"]) + end + + it "destroys the integration" do + expect { destroy_service.call } + .to change(Integrations::BaseIntegration, :count).by(-1) + end + end + end +end diff --git a/spec/services/integrations/okta/update_service_spec.rb b/spec/services/integrations/okta/update_service_spec.rb new file mode 100644 index 0000000..8ddeee9 --- /dev/null +++ b/spec/services/integrations/okta/update_service_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Okta::UpdateService do + include_context "with mocked security logger" + + let(:integration) { create(:okta_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + let(:domain) { "foo.bar" } + let(:organization_name) { "Footest" } + let(:host) { "test.com" } + + describe "#call" do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:update_args) do + { + domain:, + organization_name:, + host: + } + end + + context "without premium license" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with okta premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with okta premium integration present" do + before { organization.update!(premium_integrations: ["okta"]) } + + context "without validation errors" do + it "updates an integration" do + service_call + + integration = Integrations::OktaIntegration.order(:updated_at).last + + expect(integration.domain).to eq(domain) + expect(integration.organization_name).to eq(organization_name) + end + + it_behaves_like "produces a security log", "integration.updated" do + before { service_call } + end + end + + context "with validation error" do + let(:domain) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:domain]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/salesforce/create_service_spec.rb b/spec/services/integrations/salesforce/create_service_spec.rb new file mode 100644 index 0000000..79cc596 --- /dev/null +++ b/spec/services/integrations/salesforce/create_service_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Salesforce::CreateService do + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + subject(:service_call) { described_class.call(params: create_args) } + + let(:name) { "Salesforce 1" } + + let(:create_args) do + { + name:, + code: "salesforce", + organization_id: organization.id, + instance_id: "Instance1" + } + end + + context "without premium license" do + it "does not create an integration" do + expect { service_call }.not_to change(Integrations::SalesforceIntegration, :count) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with salesforce premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with salesforce premium integration present" do + before { organization.update!(premium_integrations: ["salesforce"]) } + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(Integrations::SalesforceIntegration, :count).by(1) + + integration = Integrations::SalesforceIntegration.order(:created_at).last + expect(integration.instance_id).to eq("Instance1") + end + + it_behaves_like "produces a security log", "integration.created" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/salesforce/update_service_spec.rb b/spec/services/integrations/salesforce/update_service_spec.rb new file mode 100644 index 0000000..dafdd6b --- /dev/null +++ b/spec/services/integrations/salesforce/update_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Salesforce::UpdateService do + include_context "with mocked security logger" + + let(:integration) { create(:salesforce_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:name) { "Salesforce updated name" } + let(:update_args) { {name:} } + + context "without premium license" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "with salesforce premium integration not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with salesforce premium integration present" do + before do + organization.update!(premium_integrations: ["salesforce"]) + end + + context "without validation errors" do + it "updates an integration" do + service_call + + integration = Integrations::SalesforceIntegration.order(:updated_at).last + expect(integration.name).to eq(name) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::SalesforceIntegration) + end + + it_behaves_like "produces a security log", "integration.updated" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/xero/create_service_spec.rb b/spec/services/integrations/xero/create_service_spec.rb new file mode 100644 index 0000000..6432a7a --- /dev/null +++ b/spec/services/integrations/xero/create_service_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Xero::CreateService do + include_context "with mocked security logger" + + let(:service) { described_class.new(membership.user) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + subject(:service_call) { service.call(**create_args) } + + let(:name) { "Xero 1" } + + let(:create_args) do + { + name:, + code: "xero1", + organization_id: organization.id, + connection_id: "conn1" + } + end + + context "without premium license" do + it "does not create an integration" do + expect { service_call }.not_to change(Integrations::XeroIntegration, :count) + end + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "when xero premium integration is not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "when xero premium integration is present" do + before do + organization.update!(premium_integrations: ["xero"]) + end + + context "without validation errors" do + it "creates an integration" do + expect { service_call }.to change(Integrations::XeroIntegration, :count).by(1) + + integration = Integrations::XeroIntegration.order(:created_at).last + expect(integration.name).to eq(name) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::XeroIntegration) + end + + it "calls Integrations::Aggregator::PerformSyncJob" do + expect { service_call }.to have_enqueued_job(Integrations::Aggregator::PerformSyncJob) + end + + it_behaves_like "produces a security log", "integration.created" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/integrations/xero/update_service_spec.rb b/spec/services/integrations/xero/update_service_spec.rb new file mode 100644 index 0000000..8c32787 --- /dev/null +++ b/spec/services/integrations/xero/update_service_spec.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Integrations::Xero::UpdateService do + include_context "with mocked security logger" + + let(:integration) { create(:xero_integration, organization:) } + let(:organization) { membership.organization } + let(:membership) { create(:membership) } + + describe "#call" do + subject(:service_call) { described_class.call(integration:, params: update_args) } + + before { integration } + + let(:name) { "Xero 1" } + + let(:update_args) do + { + name:, + code: "xero1" + } + end + + context "without premium license" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "with premium license", :premium do + context "when xero premium integration is not present" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "when xero premium integration is present" do + before do + organization.update!(premium_integrations: ["xero"]) + end + + context "without validation errors" do + it "updates an integration" do + service_call + + integration = Integrations::XeroIntegration.order(updated_at: :desc).first + expect(integration.name).to eq(name) + end + + it "returns an integration in result object" do + result = service_call + + expect(result.integration).to be_a(Integrations::XeroIntegration) + end + + it_behaves_like "produces a security log", "integration.updated" do + before { service_call } + end + end + + context "with validation error" do + let(:name) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end + end + end +end diff --git a/spec/services/invites/accept_service_spec.rb b/spec/services/invites/accept_service_spec.rb new file mode 100644 index 0000000..ab06891 --- /dev/null +++ b/spec/services/invites/accept_service_spec.rb @@ -0,0 +1,139 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invites::AcceptService do + subject(:accept_service) { described_class.new } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { create(:user) } + let(:invite) { create(:invite, organization:, email: user.email) } + let(:accept_args) do + { + token: invite.token, + password: "ILoveLago!", + login_method: Organizations::AuthenticationMethods::EMAIL_PASSWORD + } + end + + describe "#call" do + before { allow(UserDevices::RegisterService).to receive(:call!) } + + it "registers the user device" do + accept_service.call(**accept_args) + expect(UserDevices::RegisterService).to have_received(:call!).with(user:, skip_log: false) + end + + it "sets the recipient of the invite" do + expect { accept_service.call(**accept_args) } + .to change { invite.reload.membership_id }.from(nil) + end + + it "marks the invite as accepted" do + freeze_time do + expect { accept_service.call(**accept_args) } + .to change { invite.reload.status }.from("pending").to("accepted") + .and change(invite, :accepted_at).from(nil).to(Time.current) + end + end + + it "sets user, membership and organization" do + result = accept_service.call(**accept_args) + + expect(result.user).to be_present + expect(result.membership).to be_present + expect(result.organization).to be_present + expect(result.token).to be_present + end + + context "when user have already been invited then revoked" do + let(:revoked_membership) { create(:membership, :revoked, organization:) } + let(:accepted_invite) do + create(:invite, organization:, email: revoked_membership.user.email, status: :accepted) + end + let(:new_invite) { create(:invite, organization:, email: revoked_membership.user.email) } + + it "sets user, membership and organization" do + result = accept_service.call( + password: accept_args[:password], + token: new_invite[:token], + login_method: Organizations::AuthenticationMethods::EMAIL_PASSWORD + ) + + expect(result).to be_success + expect(result.user).to be_present + expect(result.membership).to be_present + expect(result.organization).to be_present + expect(result.token).to be_present + end + end + + context "when invite is already accepted" do + let(:accepted_invite) { create(:invite, organization:, status: :accepted) } + + it "returns invite_not_found error" do + result = accept_service.call( + password: accept_args[:password], + token: accepted_invite[:token], + login_method: Organizations::AuthenticationMethods::EMAIL_PASSWORD + ) + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("invite_not_found") + end + end + + context "when invite is revoked" do + let(:revoked_invite) { create(:invite, organization:, status: :revoked) } + + it "returns invite_not_found error" do + result = accept_service.call( + password: accept_args[:password], + token: revoked_invite[:token], + login_method: Organizations::AuthenticationMethods::EMAIL_PASSWORD + ) + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("invite_not_found") + end + end + + context "without password" do + let(:invite) { create(:invite, organization:, email: Faker::Internet.email) } + + it "returns an error" do + result = accept_service.call(password: nil, **accept_args.slice(:token, :login_method)) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:password]).to eq(["value_is_mandatory"]) + end + + context "without token" do + it "returns invite_not_found error" do + result = accept_service.call(password: accept_args[:password], token: nil) + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("invite_not_found") + end + end + end + + context "with invalid login_method" do + let(:invite) { create(:invite, organization:, email: Faker::Internet.email) } + + before do + organization.disable_email_password_authentication! + end + + it "returns an error" do + result = accept_service.call(**accept_args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:email_password]).to eq(["login_method_not_authorized"]) + end + end + end +end diff --git a/spec/services/invites/create_service_spec.rb b/spec/services/invites/create_service_spec.rb new file mode 100644 index 0000000..713da32 --- /dev/null +++ b/spec/services/invites/create_service_spec.rb @@ -0,0 +1,165 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invites::CreateService do + subject(:create_service) { described_class.new(create_args) } + + include_context "with mocked security logger" + + let(:admin_role) { create(:role, :admin) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + before { create(:membership_role, membership:, role: admin_role) } + + describe "#call" do + let(:create_args) do + { + email: Faker::Internet.email, + current_organization: organization, + user: membership.user, + roles: %w[admin] + } + end + + it "creates an invite" do + expect { create_service.call } + .to change(Invite, :count).by(1) + end + + it_behaves_like "produces a security log", "user.invited" do + before { create_service.call } + end + + context "when non-admin invites with admin role" do + let(:non_admin_membership) { create(:membership, organization:) } + let(:create_args) do + { + email: Faker::Internet.email, + current_organization: organization, + user: non_admin_membership.user, + roles: %w[admin] + } + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("cannot_grant_admin") + end + + it_behaves_like "does not produce a security log" do + before { create_service.call } + end + end + + context "with validation error" do + let(:create_args) do + { + current_organization: organization, + user: membership.user, + roles: %w[admin] + } + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:email]).to eq(%w[invalid_email_format]) + end + + it_behaves_like "does not produce a security log" do + before { create_service.call } + end + end + + context "with missing roles" do + let(:create_args) do + { + email: Faker::Internet.email, + current_organization: organization, + user: membership.user + } + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:roles]).to eq(%w[invalid_role]) + end + + it_behaves_like "does not produce a security log" do + before { create_service.call } + end + end + + context "with invalid roles" do + let(:create_args) do + { + email: Faker::Internet.email, + current_organization: organization, + user: membership.user, + roles: %w[nonexistent_role] + } + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:roles]).to eq(%w[invalid_role]) + end + + it_behaves_like "does not produce a security log" do + before { create_service.call } + end + end + + context "with already existing invite" do + it "returns an error" do + create(:invite, organization: create_args[:current_organization], email: create_args[:email]) + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to eq([:invite]) + end + + it_behaves_like "does not produce a security log" do + before do + create(:invite, organization: create_args[:current_organization], email: create_args[:email]) + create_service.call + end + end + end + + context "with already existing member" do + let(:user) { create(:user, email: create_args[:email]) } + + it "returns an error" do + create(:membership, organization:, user:) + + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to eq([:email]) + end + + it_behaves_like "does not produce a security log" do + before do + create(:membership, organization:, user:) + create_service.call + end + end + end + end +end diff --git a/spec/services/invites/revoke_service_spec.rb b/spec/services/invites/revoke_service_spec.rb new file mode 100644 index 0000000..1607510 --- /dev/null +++ b/spec/services/invites/revoke_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invites::RevokeService do + subject(:revoke_service) { described_class.new(invite) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:invite) { create(:invite, organization:) } + + describe "#call" do + it "revokes the invite" do + freeze_time do + result = revoke_service.call + + expect(result).to be_success + expect(result.invite.id).to eq(invite.id) + expect(result.invite).to be_revoked + expect(result.invite.revoked_at).to eq(Time.current) + end + end + + context "when invite is not found" do + let(:invite) { nil } + + it "returns an error" do + result = revoke_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invite_not_found") + end + end + + context "when invite is revoked" do + let(:invite) { create(:invite, organization:, status: "revoked") } + + it "returns an error" do + result = revoke_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invite_not_found") + end + end + + context "when invite is accepted" do + let(:invite) { create(:invite, organization:, status: "accepted") } + + it "returns an error" do + result = revoke_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invite_not_found") + end + end + end +end diff --git a/spec/services/invites/update_service_spec.rb b/spec/services/invites/update_service_spec.rb new file mode 100644 index 0000000..ede2378 --- /dev/null +++ b/spec/services/invites/update_service_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invites::UpdateService do + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:acting_user) { create(:membership, organization:).user } + let(:invite) { create(:invite, organization:) } + let(:params) { {roles: %w[manager]} } + + describe "#call" do + context "when invite is pending" do + let(:invite) { create(:invite, organization:, status: "pending", roles: %w[admin]) } + let(:params) { {roles: %w[manager]} } + + before { create(:role, :manager) } + + it "updates the roles" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).to be_success + expect(result.invite.reload.roles).to eq(%w[manager]) + end + end + + context "when non-admin sets admin role on invite" do + let(:invite) { create(:invite, organization:, status: "pending", roles: %w[manager]) } + let(:params) { {roles: %w[admin]} } + + before { create(:role, :admin) } + + it "returns an error" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("cannot_grant_admin") + end + end + + context "when admin sets admin role on invite" do + let(:acting_membership) { create(:membership, organization:) } + let(:acting_user) { acting_membership.user } + let(:admin_role) { create(:role, :admin) } + let(:invite) { create(:invite, organization:, status: "pending", roles: %w[manager]) } + let(:params) { {roles: %w[admin]} } + + before { create(:membership_role, membership: acting_membership, role: admin_role) } + + it "updates the roles" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).to be_success + expect(result.invite.reload.roles).to eq(%w[admin]) + end + end + + context "when invite is not found" do + let(:invite) { nil } + + it "returns an error" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invite_not_found") + end + end + + context "when invite is revoked" do + let(:invite) { create(:invite, organization:, status: "revoked") } + + it "returns an error" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).not_to be_success + expect(result.error.code).to eq("cannot_update_revoked_invite") + end + end + + context "when invite is accepted" do + let(:invite) { create(:invite, organization:, status: "accepted") } + + it "returns an error" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).not_to be_success + expect(result.error.code).to eq("cannot_update_accepted_invite") + end + end + + context "when roles are empty" do + let(:invite) { create(:invite, organization:, status: "pending") } + let(:params) { {roles: []} } + + it "returns an error" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:roles]).to eq(%w[invalid_role]) + end + end + + context "when roles do not exist" do + let(:invite) { create(:invite, organization:, status: "pending") } + let(:params) { {roles: %w[nonexistent_role]} } + + it "returns an error" do + result = described_class.call(user: acting_user, invite:, params:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:roles]).to eq(%w[invalid_role]) + end + end + end +end diff --git a/spec/services/invites/validate_service_spec.rb b/spec/services/invites/validate_service_spec.rb new file mode 100644 index 0000000..b031801 --- /dev/null +++ b/spec/services/invites/validate_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" +RSpec.describe Invites::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:user) { membership.user } + let(:args) do + { + current_organization: organization, + email: Faker::Internet.email, + roles: %w[admin] + } + end + + before { create(:role, :admin) } + + describe "#valid?" do + it "returns true" do + expect(validate_service).to be_valid + end + + context "when invite already exists" do + before { create(:invite, email: user.email, recipient: membership, organization:) } + + let(:args) do + { + current_organization: organization, + email: user.email, + roles: %w[admin] + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:invite]).to eq(["invite_already_exists"]) + end + end + + context "when user already exists" do + let(:args) do + { + current_organization: organization, + email: user.email, + roles: %w[admin] + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:email]).to eq(["email_already_used"]) + end + end + + context "when roles is invalid" do + let(:args) do + { + current_organization: organization, + email: Faker::Internet.email, + roles: %w[super_admin] + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:roles]).to eq(%w[invalid_role]) + end + end + end +end diff --git a/spec/services/invoice_custom_sections/attach_to_resource_service_spec.rb b/spec/services/invoice_custom_sections/attach_to_resource_service_spec.rb new file mode 100644 index 0000000..39ce004 --- /dev/null +++ b/spec/services/invoice_custom_sections/attach_to_resource_service_spec.rb @@ -0,0 +1,340 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceCustomSections::AttachToResourceService do + describe "#call" do + subject { service.call } + + let(:resource) { create(:subscription) } + let(:params) do + {invoice_custom_section: {}} + end + + let(:service) { described_class.new(resource:, params:) } + let(:organization) { resource.organization } + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:section_2) { create(:invoice_custom_section, organization:, code: "section_code_2") } + let(:section_3) { create(:invoice_custom_section, organization:, code: "section_code_3") } + let(:section_4) { create(:invoice_custom_section, organization:, code: "section_code_4") } + + before do + CurrentContext.source = "api" + + section_1 + section_2 + section_3 + section_4 + end + + shared_examples "section attachable" do + let(:params) do + { + invoice_custom_section: {invoice_custom_section_codes: ["section_code_1", "section_code_3"]} + } + end + + it "can attach sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to eq(2) + expect(resource.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id, section_3.id) + end + end + + shared_examples "section skippable" do + let(:params) do + {invoice_custom_section: {skip_invoice_custom_sections: true}} + end + + it "can attach sections" do + result = subject + resource.reload + + expect(result.success?).to be(true) + expect(resource.skip_invoice_custom_sections).to be_truthy + expect(resource.applied_invoice_custom_sections.count).to be_zero + end + end + + describe "resource attribute" do + context "when Subscription" do + let(:resource) { create(:subscription) } + + it_behaves_like "section attachable" + it_behaves_like "section skippable" + end + + context "when Wallet" do + let(:resource) { create(:wallet) } + + it_behaves_like "section attachable" + it_behaves_like "section skippable" + end + + context "when RecurringTransactionRule" do + let(:resource) { create(:recurring_transaction_rule) } + + it_behaves_like "section attachable" + it_behaves_like "section skippable" + end + + context "when WalletTransaction" do + let(:resource) { create(:wallet_transaction) } + + it_behaves_like "section attachable" + it_behaves_like "section skippable" + end + end + + describe "params attribute" do + context "without invoice_custom_section param" do + let(:params) { {} } + + before do + allow(service).to receive(:skip_flag).and_call_original + end + + it "does nothing" do + result = subject + + expect(service).not_to have_received(:skip_flag) + expect(result.success?).to be(true) + end + end + + context "with skip flag as true" do + let(:params) do + {invoice_custom_section: {skip_invoice_custom_sections: true}} + end + + before do + create(:subscription_applied_invoice_custom_section, subscription: resource) + end + + it "updates the resource skip_invoice_custom_sections to true" do + result = subject + resource.reload + + expect(result.success?).to be(true) + expect(resource.skip_invoice_custom_sections).to be_truthy + expect(resource.applied_invoice_custom_sections.count).to be_zero + end + end + + context "with skip flag as false" do + let(:params) do + {invoice_custom_section: {skip_invoice_custom_sections: false}} + end + + it "updates the resource skip_invoice_custom_sections to false" do + result = subject + resource.reload + + expect(result.success?).to be(true) + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to be_zero + end + + context "when the record skip flag is previously true" do + before { resource.update(skip_invoice_custom_sections: true) } + + it "updates the skip attribute" do + result = subject + resource.reload + + expect(result.success?).to be(true) + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to be_zero + end + end + end + + context "without skip flag" do + context "when resource#skip_invoice_custom_sections was previously true" do + before { resource.update!(skip_invoice_custom_sections: true) } + + it "does nothing" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_truthy + expect(resource.applied_invoice_custom_sections.count).to be_zero + end + end + + context "when resource#skip_invoice_custom_sections was previously false" do + let(:params) do + { + invoice_custom_section: external_params + } + end + + before do + resource.update!(skip_invoice_custom_sections: false) + end + + context "with new sections" do + context "when comes from api" do + let(:external_params) { + {invoice_custom_section_codes: ["section_code_1", "section_code_2"]} + } + + before { CurrentContext.source = "api" } + + it "attach the custom sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to eq(2) + expect(resource.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id, section_2.id) + end + end + + context "when comes from front" do + let(:external_params) { + {invoice_custom_section_ids: [section_1.id, section_3.id]} + } + + before { CurrentContext.source = "graphql" } + + it "attach the custom sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to eq(2) + expect(resource.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id, section_3.id) + end + end + end + + context "when existing sections" do + context "with single section replace" do + let(:external_params) { + {invoice_custom_section_codes: ["section_code_2"]} + } + + before do + CurrentContext.source = "api" + resource.applied_invoice_custom_sections.create( + invoice_custom_section: section_1, + organization: + ) + end + + it "removes old sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to eq(1) + expect(resource.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_2.id) + end + end + + context "when multiple sections update" do + let(:external_params) { + {invoice_custom_section_codes: ["section_code_1", "section_code_3", "section_code_4"]} + } + + before do + CurrentContext.source = "api" + [section_1, section_2, section_3].each do |section| + resource.applied_invoice_custom_sections.create( + invoice_custom_section: section, + organization: + ) + end + end + + it "replace old sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to eq(3) + expect(resource.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id, section_3.id, section_4.id) + end + end + + context "when zero sections are passed" do + let(:external_params) { + {invoice_custom_section_codes: []} + } + + before do + CurrentContext.source = "api" + [section_1, section_2, section_3].each do |section| + resource.applied_invoice_custom_sections.create( + invoice_custom_section: section, + organization: + ) + end + end + + it "remove all sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to be_zero + end + end + end + end + end + + context "when invoice_custom_section_codes" do + let(:params) do + { + invoice_custom_section: { + invoice_custom_section_codes: + } + } + end + + before do + CurrentContext.source = "api" + [section_1, section_2, section_3].each do |section| + resource.applied_invoice_custom_sections.create( + invoice_custom_section: section, + organization: + ) + end + end + + context "when param is empty" do + let(:invoice_custom_section_codes) { [] } + + it "does remove all sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to be_zero + end + end + + context "when param is not sent" do + let(:params) do + { + invoice_custom_section: {skip_invoice_custom_sections: false} + } + end + + it "does not remove sections" do + subject + resource.reload + + expect(resource.skip_invoice_custom_sections).to be_falsey + expect(resource.applied_invoice_custom_sections.count).to eq(3) + end + end + end + end + end +end diff --git a/spec/services/invoice_custom_sections/create_service_spec.rb b/spec/services/invoice_custom_sections/create_service_spec.rb new file mode 100644 index 0000000..31368ca --- /dev/null +++ b/spec/services/invoice_custom_sections/create_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceCustomSections::CreateService do + describe "#call" do + subject(:service_result) { described_class.call(organization:, create_params:) } + + let(:organization) { create(:organization) } + let(:create_params) { nil } + + context "with valid params" do + let(:create_params) do + { + code: "test", + details: "This text will be displayed in the invoice", + display_name: "This will be the section title", + name: "my firsts section" + } + end + + it "creates an invoice_custom_section that belongs to the organization" do + expect { service_result }.to change(organization.invoice_custom_sections, :count).by(1) + expect(service_result.invoice_custom_section).to be_persisted.and have_attributes(create_params) + end + end + + context "with invalid params" do + let(:params) { {} } + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ValidationFailure) + expect(service_result.error.messages[:code]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/invoice_custom_sections/deselect_all_service_spec.rb b/spec/services/invoice_custom_sections/deselect_all_service_spec.rb new file mode 100644 index 0000000..3151a00 --- /dev/null +++ b/spec/services/invoice_custom_sections/deselect_all_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceCustomSections::DeselectAllService do + describe "#call" do + subject(:service_result) { described_class.call(section:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + let(:section) { create(:invoice_custom_section, organization:) } + + context "when the section is selected" do + before do + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: section) + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: section) + end + + it "deselects the section for the billing entity and customer" do + expect { service_result } + .to change(billing_entity.applied_invoice_custom_sections, :count).from(1).to(0) + .and change(customer.applied_invoice_custom_sections, :count).from(1).to(0) + expect(service_result).to be_success + end + end + + context "when the section is not selected" do + it "returns a success" do + expect(service_result).to be_success + end + end + end +end diff --git a/spec/services/invoice_custom_sections/destroy_service_spec.rb b/spec/services/invoice_custom_sections/destroy_service_spec.rb new file mode 100644 index 0000000..62fc57b --- /dev/null +++ b/spec/services/invoice_custom_sections/destroy_service_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceCustomSections::DestroyService do + subject(:service_result) { described_class.call(invoice_custom_section:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + + before do + allow(InvoiceCustomSections::DeselectAllService).to receive(:call!).and_call_original + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section:) + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section:) + end + + describe "#call" do + context "when destroy is successful" do + it "discards the invoice custom section and destroys all selections" do + result = service_result + + expect(result.invoice_custom_section).to be_discarded + expect(billing_entity.applied_invoice_custom_sections).to be_empty + expect(customer.applied_invoice_custom_sections).to be_empty + expect(InvoiceCustomSections::DeselectAllService).to have_received(:call!) + .with(section: invoice_custom_section) + end + end + end +end diff --git a/spec/services/invoice_custom_sections/funding_instructions_formatter_service_spec.rb b/spec/services/invoice_custom_sections/funding_instructions_formatter_service_spec.rb new file mode 100644 index 0000000..871dae8 --- /dev/null +++ b/spec/services/invoice_custom_sections/funding_instructions_formatter_service_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceCustomSections::FundingInstructionsFormatterService do + describe "#call" do + subject(:result) { service.call } + + let(:service) { described_class.new(funding_data: funding_data, locale: :en) } + + shared_examples "includes bank transfer info intro" do + it "includes the bank transfer info header" do + expect(result.details).to start_with("Bank transfers may take several business days to process. To pay via bank transfer, transfer funds using the following bank information.") + end + end + + context "when funding type is us_bank_transfer" do + let(:funding_data) do + { + type: "us_bank_transfer", + financial_addresses: [ + { + type: "aba", + aba: { + account_holder_name: "Teste", + account_number: "11119987600453127", + bank_name: "US Test Bank", + routing_number: "999999999" + } + }, + { + type: "swift", + swift: { + account_holder_name: "Teste", + account_number: "11119987600453127", + bank_name: "US Test Bank", + swift_code: "TESTUS99XXX" + } + } + ] + } + end + + include_examples "includes bank transfer info intro" + it "formats ABA and SWIFT details correctly with headers" do + aba_section = <<~TEXT.strip + US ACH, Domestic Wire + Bank name: US Test Bank + Account number: 11119987600453127 + Routing number: 999999999 + TEXT + + swift_section = <<~TEXT.strip + SWIFT + Bank name: US Test Bank + Account number: 11119987600453127 + SWIFT code: TESTUS99XXX + TEXT + + expect(result.details).to include(aba_section) + expect(result.details).to include(swift_section) + end + end + + context "when funding type is eu_bank_transfer" do + let(:funding_data) do + { + type: "eu_bank_transfer", + financial_addresses: [ + { + type: "iban", + iban: { + account_holder_name: "Teste", + bic: "AGRIFRPPXXX", + country: "FR", + iban: "FR61284383901570478105144165" + } + } + ] + } + end + + include_examples "includes bank transfer info intro" + it "formats IBAN details correctly" do + expect(result.details).to include("BIC: AGRIFRPPXXX") + expect(result.details).to include("IBAN: FR61284383901570478105144165") + expect(result.details).to include("Country: FR") + expect(result.details).to include("Account holder name: Teste") + end + end + + context "when funding type is mx_bank_transfer" do + let(:funding_data) do + { + type: "mx_bank_transfer", + financial_addresses: [ + { + mx_bank_transfer: { + clabe: "002010077777777771", + bank_name: "Banco MX", + bank_code: "002" + } + } + ] + } + end + + include_examples "includes bank transfer info intro" + it "includes CLABE transfer details" do + expect(result.details).to include("CLABE: 002010077777777771") + expect(result.details).to include("Bank name: Banco MX") + expect(result.details).to include("Bank code: 002") + end + end + + context "when funding type is jp_bank_transfer" do + let(:funding_data) do + { + type: "jp_bank_transfer", + financial_addresses: [ + { + jp_bank_transfer: { + bank_code: "0005", + bank_name: "JP Test Bank", + branch_code: "001", + branch_name: "Tokyo", + account_type: "type_test", + account_number: "1234567", + account_holder_name: "name_account" + } + } + ] + } + end + + include_examples "includes bank transfer info intro" + it "includes Japanese bank transfer details" do + expect(result.details).to include("Bank code: 0005") + expect(result.details).to include("Bank name: JP Test Bank") + expect(result.details).to include("Branch code: 001") + expect(result.details).to include("Branch name: Tokyo") + expect(result.details).to include("Account type: type_test") + expect(result.details).to include("Account number: 1234567") + expect(result.details).to include("Account holder name: name_account") + end + end + + context "when funding type is gb_bank_transfer" do + let(:funding_data) do + { + type: "gb_bank_transfer", + financial_addresses: [ + { + sort_code: { + account_number: "12345678", + sort_code: "12-34-56", + account_holder_name: "Test UK" + } + } + ] + } + end + + include_examples "includes bank transfer info intro" + it "includes GB sort code transfer details" do + expect(result.details).to include("Account number: 12345678") + expect(result.details).to include("Sort code: 12-34-56") + expect(result.details).to include("Account holder name: Test UK") + end + end + end +end diff --git a/spec/services/invoice_custom_sections/update_service_spec.rb b/spec/services/invoice_custom_sections/update_service_spec.rb new file mode 100644 index 0000000..800f494 --- /dev/null +++ b/spec/services/invoice_custom_sections/update_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceCustomSections::UpdateService do + subject(:service_result) { described_class.call(invoice_custom_section:, update_params:) } + + let(:organization) { create(:organization) } + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + let(:update_params) { nil } + + describe "#call" do + context "with valid params" do + let(:update_params) { {name: "Updated Name"} } + + it "updates the invoice custom section" do + result = service_result + + expect(result).to be_success + expect(result.invoice_custom_section.name).to eq("Updated Name") + end + end + + context "with invalid params" do + let(:update_params) { {name: nil} } + + it "handles validation errors" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ValidationFailure) + expect(service_result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/invoice_settlements/create_service_spec.rb b/spec/services/invoice_settlements/create_service_spec.rb new file mode 100644 index 0000000..1edc2b2 --- /dev/null +++ b/spec/services/invoice_settlements/create_service_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe InvoiceSettlements::CreateService do + subject(:service_call) do + described_class.call( + invoice:, + amount_cents:, + amount_currency:, + source_credit_note:, + source_payment: + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) do + create( + :invoice, + organization:, + customer:, + currency: "EUR", + total_amount_cents: 1000, + total_paid_amount_cents: 0 + ) + end + let(:amount_cents) { 500 } + let(:amount_currency) { "EUR" } + let(:source_credit_note) { nil } + let(:source_payment) { nil } + + describe ".call" do + context "with source_credit_note" do + let(:credit_note) do + create(:credit_note, invoice:, customer:, offset_amount_cents: 500, + total_amount_cents: 500, status: :finalized) + end + let(:source_credit_note) { credit_note } + + it "creates settlement with correct attributes and does not mark partial as paid" do + expect { service_call }.to change(InvoiceSettlement, :count).by(1) + + result = service_call + expect(result).to be_success + settlement = result.invoice_settlement + + expect(settlement.organization_id).to eq(organization.id) + expect(settlement.billing_entity_id).to eq(invoice.billing_entity_id) + expect(settlement.target_invoice).to eq(invoice) + expect(settlement.source_credit_note).to eq(credit_note) + expect(settlement.source_payment).to be_nil + expect(settlement.settlement_type).to eq("credit_note") + expect(settlement.amount_cents).to eq(500) + expect(settlement.amount_currency).to eq("EUR") + expect(invoice.reload.payment_status).not_to eq("succeeded") + end + + it "marks invoice as paid when fully settled by single offset" do + cn = create(:credit_note, invoice:, customer:, offset_amount_cents: 1000, + total_amount_cents: 1000, status: :finalized) + described_class.call(invoice:, amount_cents: 1000, amount_currency: "EUR", source_credit_note: cn) + + expect(invoice.reload.payment_status).to eq("succeeded") + end + + it "marks invoice as paid when offset completes partial payment" do + invoice.update!(total_paid_amount_cents: 600) + cn = create(:credit_note, invoice:, customer:, offset_amount_cents: 400, + total_amount_cents: 400, status: :finalized) + described_class.call(invoice:, amount_cents: 400, amount_currency: "EUR", source_credit_note: cn) + + expect(invoice.reload.payment_status).to eq("succeeded") + end + + it "marks invoice as paid when multiple settlements complete payment" do + cn1 = create(:credit_note, invoice:, customer:, offset_amount_cents: 600, + total_amount_cents: 600, status: :finalized) + cn2 = create(:credit_note, invoice:, customer:, offset_amount_cents: 400, + total_amount_cents: 400, status: :finalized) + + described_class.call(invoice:, amount_cents: 600, amount_currency: "EUR", source_credit_note: cn1) + described_class.call(invoice:, amount_cents: 400, amount_currency: "EUR", source_credit_note: cn2) + + expect(invoice.reload.payment_status).to eq("succeeded") + end + + it "marks invoice as paid when offset exactly matches total" do + invoice.update!(total_amount_cents: 500) + cn = create(:credit_note, invoice:, customer:, offset_amount_cents: 500, + total_amount_cents: 500, status: :finalized) + described_class.call(invoice:, amount_cents: 500, amount_currency: "EUR", source_credit_note: cn) + + expect(invoice.reload.payment_status).to eq("succeeded") + end + + it "marks invoice as paid when offset slightly exceeds due amount" do + invoice.update!(total_amount_cents: 1000, total_paid_amount_cents: 999) + cn = create(:credit_note, invoice:, customer:, offset_amount_cents: 2, + total_amount_cents: 2, status: :finalized) + described_class.call(invoice:, amount_cents: 2, amount_currency: "EUR", source_credit_note: cn) + + expect(invoice.reload.payment_status).to eq("succeeded") + end + + it "creates settlement with specified currency" do + cn = create(:credit_note, invoice:, customer:, offset_amount_cents: 500, + offset_amount_currency: "USD", total_amount_cents: 500, status: :finalized) + result = described_class.call(invoice:, amount_cents: 500, amount_currency: "USD", source_credit_note: cn) + + expect(result.invoice_settlement.amount_currency).to eq("USD") + end + end + + context "with source_payment" do + let(:payment) { create(:payment, payable: invoice) } + let(:source_payment) { payment } + + it "creates settlement with correct attributes" do + expect { service_call }.to change(InvoiceSettlement, :count).by(1) + + result = service_call + expect(result).to be_success + settlement = result.invoice_settlement + + expect(settlement.organization_id).to eq(organization.id) + expect(settlement.target_invoice).to eq(invoice) + expect(settlement.source_payment).to eq(payment) + expect(settlement.source_credit_note).to be_nil + expect(settlement.settlement_type).to eq("payment") + expect(settlement.amount_cents).to eq(500) + expect(settlement.amount_currency).to eq("EUR") + end + end + + context "with invalid sources" do + it "raises error when no source provided" do + expect { service_call }.to raise_error(ArgumentError, "Must provide either source_credit_note or source_payment") + end + + it "raises error when both sources provided" do + service = described_class.new( + invoice:, amount_cents: 500, amount_currency: "EUR", + source_credit_note: create(:credit_note, invoice:, customer:), + source_payment: create(:payment, payable: invoice) + ) + expect { service.call }.to raise_error(ArgumentError, "Cannot provide both source_credit_note and source_payment") + end + end + end +end diff --git a/spec/services/invoices/advance_charges_service_spec.rb b/spec/services/invoices/advance_charges_service_spec.rb new file mode 100644 index 0000000..87433d1 --- /dev/null +++ b/spec/services/invoices/advance_charges_service_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::AdvanceChargesService do + subject(:invoice_service) do + described_class.new(initial_subscriptions: subscriptions, billing_at:) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:tax_rate) { 89 } + let(:tax) { create(:tax, organization:, rate: tax_rate) } + + describe "#call" do + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at: started_at.to_date, + started_at:, + created_at: started_at + ) + end + let(:subscriptions) { [subscription] } + + let(:billable_metric) { create(:billable_metric, organization:, code: "new_user") } + let(:billing_at) { Time.zone.now.beginning_of_month + 1.hour } + let(:started_at) { Time.zone.now - 2.years } + + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + + let(:reference) { "Charges paid in advance" } + + def fee_boundaries + prev_month = billing_at - 1.month + charges_from_datetime = prev_month.beginning_of_month + charges_to_datetime = prev_month.end_of_month + + { + timestamp: rand(charges_from_datetime..charges_to_datetime), + charges_from_datetime:, + charges_to_datetime: + } + end + + before do + allow(Invoices::Payments::CreateService).to receive(:call_async) + allow(Invoices::TransitionToFinalStatusService).to receive(:call).and_call_original + end + + context "with existing standalone fees" do + before do + tax + charge = create(:standard_charge, :regroup_paid_fees, plan: subscription.plan) + succeeded_fees = create_list( + :charge_fee, + 3, + organization_id: organization.id, + payment_status: :succeeded, + succeeded_at: billing_at - 1.month, + invoice_id: nil, + subscription:, + charge:, + amount_cents: 61, + taxes_amount_cents: 16, + properties: fee_boundaries + ) + create_list(:charge_fee, 2, :failed, invoice_id: nil, subscription:, charge:, amount_cents: 100, properties: fee_boundaries) + + create( + :charge_fee, + payment_status: :succeeded, + succeeded_at: (billing_at - 1.month).end_of_month + 1.day, + invoice_id: nil, + subscription:, + charge:, + properties: { + timestamp: (billing_at - 1.month).end_of_month + 1.day # ?? + } + ) + + succeeded_fees.each { |fee| Fees::ApplyTaxesService.call(fee:) } + end + + it "creates invoices" do + result = invoice_service.call + expect(result).to be_success + + expect(result.invoice.fees.count).to eq 3 + + expect(result.invoice.total_amount_cents).to eq(61 * 3 + 16 * 3) + expect(result.invoice.taxes_amount_cents).to eq(16 * 3) # Sum of taxes in each paid fees + + expect(result.invoice).to be_finalized.and(have_attributes({ + invoice_type: "advance_charges", + currency: "EUR", + issuing_date: billing_at.to_date, + skip_charges: true, + taxes_rate: (16.0 * 100 / 61).round(2) + })) + + expect(result.invoice.invoice_subscriptions.count).to eq(1) + sub = result.invoice.invoice_subscriptions.first + expect(sub.charges_to_datetime).to match_datetime fee_boundaries[:charges_to_datetime] + expect(sub.charges_from_datetime).to match_datetime fee_boundaries[:charges_from_datetime] + expect(sub.invoicing_reason).to eq "in_advance_charge_periodic" + + expect(SendWebhookJob).to have_been_enqueued.with("invoice.created", result.invoice) + expect(Utils::ActivityLog).to have_produced("invoice.created").with(result.invoice) + expect(Invoices::GenerateDocumentsJob).to have_been_enqueued.with(invoice: result.invoice, notify: false) + expect(SegmentTrackJob).to have_been_enqueued.once + expect(Invoices::TransitionToFinalStatusService).to have_received(:call).with(invoice: result.invoice) + + expect(Payments::ManualCreateJob) + .to have_been_enqueued.once + .with( + organization:, + params: { + invoice_id: result.invoice.id, + amount_cents: result.invoice.total_amount_cents, + reference:, + created_at: result.invoice.created_at + } + ) + end + end + + context "without any standalone fees" do + context "without any pay in advance charge" do + it "does not create an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "when there is a pay in advance charge" do + before do + create(:standard_charge, :regroup_paid_fees, plan: subscription.plan) + end + + it "does not try to create an invoice only to roll back when there are no fees" do + connection = ActiveRecord::Base.connection + allow(connection).to receive(:transaction).and_call_original + + result = invoice_service.call + expect(connection).not_to have_received(:transaction) + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + end + + context "when there is a successful non invoiceable paid in advance fees" do + let(:billable_metric) { create(:sum_billable_metric, :recurring, organization:) } + + let(:charge) do + create( + :charge, + plan:, + billable_metric:, + prorated: true, + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice", + properties: {amount: "1"} + ) + end + + let(:subscription_2) do + create(:subscription, { + external_id: subscription.external_id, + customer: subscription.customer, + status: :terminated, + terminated_at: Time.current, + started_at: Time.current - 1.year, + plan: + }) + end + + let(:paid_in_advance_fee) do + create( + :fee, + :succeeded, + organization_id: organization.id, + succeeded_at: fee_boundaries[:charges_to_datetime] - 2.days, + invoice_id: nil, + subscription: subscription_2, + amount_cents: 999, + taxes_amount_cents: 24, + properties: fee_boundaries, + charge: + ) + end + + before { paid_in_advance_fee } + + it "creates invoices" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_a Invoice + expect(result.invoice.fees.count).to eq 1 + expect(result.invoice.total_amount_cents).to eq(999 + 24) + expect(result.invoice.taxes_amount_cents).to eq 24 + expect(result.invoice.fees_amount_cents).to eq 999 + + expect(result.invoice) + .to be_finalized + .and have_attributes( + invoice_type: "advance_charges", + currency: "EUR", + issuing_date: billing_at.to_date, + skip_charges: true + ) + + expect(result.invoice.invoice_subscriptions.count).to eq(1) + sub = result.invoice.invoice_subscriptions.first + expect(sub.charges_to_datetime).to match_datetime fee_boundaries[:charges_to_datetime] + expect(sub.charges_from_datetime).to match_datetime fee_boundaries[:charges_from_datetime] + expect(sub.invoicing_reason).to eq "in_advance_charge_periodic" + end + end + + context "with integration requiring sync" do + before do + tax + charge = create(:standard_charge, :regroup_paid_fees, plan: subscription.plan) + create( + :charge_fee, + organization_id: organization.id, + payment_status: :succeeded, + succeeded_at: billing_at - 1.month, + invoice_id: nil, + subscription:, + charge:, + amount_cents: 100, + properties: fee_boundaries + ) + + allow_any_instance_of(Invoice).to receive(:should_sync_invoice?).and_return(true) # rubocop:disable RSpec/AnyInstance + end + + it "creates invoices" do + result = invoice_service.call + + expect(Integrations::Aggregator::Invoices::CreateJob).to have_been_enqueued.with(invoice: result.invoice) + end + end + end +end diff --git a/spec/services/invoices/aggregate_amounts_and_taxes_from_fees_spec.rb b/spec/services/invoices/aggregate_amounts_and_taxes_from_fees_spec.rb new file mode 100644 index 0000000..8a41890 --- /dev/null +++ b/spec/services/invoices/aggregate_amounts_and_taxes_from_fees_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::AggregateAmountsAndTaxesFromFees do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, invoice_type: :advance_charges, customer:) } + let(:tax_5) { create(:tax, rate: 5, name: "VAT", description: "VAT 5%", code: "tax-1234") } + let(:tax_12) { create(:tax, rate: 12, name: "VAT", description: "VAT 12%", code: "tax-8901") } + + it do + fee1 = create(:charge_fee, :succeeded, invoice:, amount_cents: 200, taxes_amount_cents: 9) + fee2 = create(:charge_fee, :succeeded, invoice:, amount_cents: 50, taxes_amount_cents: 1) + fee3 = create(:charge_fee, :succeeded, invoice:, amount_cents: 120, taxes_amount_cents: 23) + + create(:fee_applied_tax, fee: fee1, tax: tax_5, tax_name: tax_5.name, tax_description: tax_5.description, tax_rate: tax_5.rate, amount_cents: 16) + create(:fee_applied_tax, fee: fee2, tax: tax_12, tax_name: tax_12.name, tax_description: tax_12.description, tax_rate: tax_12.rate, amount_cents: 2) + create(:fee_applied_tax, fee: fee3, tax: tax_5, tax_name: tax_5.name, tax_description: tax_5.description, tax_rate: tax_5.rate, amount_cents: 9) + create(:fee_applied_tax, fee: fee3, tax: tax_12, tax_name: tax_12.name, tax_description: tax_12.description, tax_rate: tax_12.rate, amount_cents: 11) + + described_class.call(invoice:) + + expect(invoice.fees_amount_cents).to eq(200 + 50 + 120) + # if we compute taxes: 200 * 0.05 + 50 * 0.12 + 120 * 0.05 + 120 * 0.12 = 36.4 + # but this service sums already computed taxes + expect(invoice.taxes_amount_cents).to eq(9 + 1 + 23) + expect(invoice.total_amount_cents).to eq(200 + 50 + 120 + 33) + expect(invoice.sub_total_excluding_taxes_amount_cents).to eq invoice.fees_amount_cents + expect(invoice.sub_total_including_taxes_amount_cents).to eq invoice.total_amount_cents + + expect(invoice.applied_taxes.count).to eq 2 + applied_tax_5 = invoice.applied_taxes.find { |at| at.tax_code == tax_5.code } + expect(applied_tax_5.tax_name).to eq tax_5.name + expect(applied_tax_5.tax_description).to eq tax_5.description + expect(applied_tax_5.tax_rate).to eq tax_5.rate + expect(applied_tax_5.amount_cents).to eq(9 + 16) + expect(applied_tax_5.fees_amount_cents).to eq(200 + 120) + expect(applied_tax_5.taxable_base_amount_cents).to eq(200 + 120) + + applied_tax_12 = invoice.applied_taxes.find { |at| at.tax_code == tax_12.code } + expect(applied_tax_12.tax_name).to eq tax_12.name + expect(applied_tax_12.tax_description).to eq tax_12.description + expect(applied_tax_12.tax_rate).to eq tax_12.rate + expect(applied_tax_12.amount_cents).to eq(2 + 11) + expect(applied_tax_12.fees_amount_cents).to eq(120 + 50) + expect(applied_tax_12.taxable_base_amount_cents).to eq(120 + 50) + end +end diff --git a/spec/services/invoices/apply_invoice_custom_sections_service_spec.rb b/spec/services/invoices/apply_invoice_custom_sections_service_spec.rb new file mode 100644 index 0000000..8c8aca3 --- /dev/null +++ b/spec/services/invoices/apply_invoice_custom_sections_service_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ApplyInvoiceCustomSectionsService do + subject(:invoice_service) { described_class.new(invoice:, resource:, custom_section_ids:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:invoice) { create(:invoice, customer:, billing_entity:) } + let(:custom_section_1) { create(:invoice_custom_section, organization:) } + let(:custom_section_2) { create(:invoice_custom_section, organization:) } + let(:custom_section_3) { create(:invoice_custom_section, organization:) } + let(:resource) { nil } + let(:custom_section_ids) { [] } + + before do + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: custom_section_1) + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: custom_section_2) + end + + describe "#call" do + context "when the customer has skip_invoice_custom_sections flag" do + let(:customer) { create(:customer, organization:, billing_entity:, skip_invoice_custom_sections: true) } + + it "does not apply any custom sections" do + result = invoice_service.call + expect(result).to be_success + expect(result.applied_sections).to be_empty + expect(invoice.reload.applied_invoice_custom_sections).to be_empty + end + end + + context "when the customer belongs to a different billing entity" do + let(:customer) { create(:customer, organization:, billing_entity: create(:billing_entity, organization:)) } + + it "does not apply any custom sections" do + result = invoice_service.call + expect(result).to be_success + expect(result.applied_sections).to be_empty + expect(invoice.reload.applied_invoice_custom_sections).to be_empty + end + end + + context "when the customer has custom sections" do + before do + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: custom_section_3) + end + + it "applies the custom sections to the invoice" do + result = invoice_service.call + expect(result).to be_success + sections = invoice.applied_invoice_custom_sections.reload + expect(sections.map(&:code)).to contain_exactly(custom_section_3.code) + expect(sections.map(&:details)).to contain_exactly(custom_section_3.details) + expect(sections.map(&:display_name)).to contain_exactly(custom_section_3.display_name) + expect(sections.map(&:name)).to contain_exactly(custom_section_3.name) + end + end + + context "when the customer inherits custom sections from the organization" do + it "applies the organization's sections to the invoice" do + result = invoice_service.call + expect(result).to be_success + sections = invoice.applied_invoice_custom_sections.reload + expect(sections.map(&:code)).to contain_exactly(custom_section_1.code, custom_section_2.code) + expect(sections.map(&:details)).to contain_exactly(custom_section_1.details, custom_section_2.details) + expect(sections.map(&:display_name)).to contain_exactly(custom_section_1.display_name, custom_section_2.display_name) + expect(sections.map(&:name)).to contain_exactly(custom_section_1.name, custom_section_2.name) + end + end + + context "with subscription source" do + let(:resource) { create(:subscription, customer:, organization:) } + + context "when skip_invoice_custom_sections is true" do + let(:resource) { create(:subscription, customer:, organization:, skip_invoice_custom_sections: true) } + + it "does not attach custom sections on the invoice" do + result = invoice_service.call + expect(result).to be_success + expect(result.applied_sections).to be_empty + expect(invoice.reload.applied_invoice_custom_sections).to be_empty + end + end + + context "when skip_invoice_custom_sections is false but there is no attached custom sections" do + context "with customer sections" do + before do + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: custom_section_3) + end + + it "applies the customer sections on the invoice" do + result = invoice_service.call + expect(result).to be_success + sections = invoice.applied_invoice_custom_sections.reload + expect(sections.map(&:code)).to contain_exactly(custom_section_3.code) + expect(sections.map(&:details)).to contain_exactly(custom_section_3.details) + expect(sections.map(&:display_name)).to contain_exactly(custom_section_3.display_name) + expect(sections.map(&:name)).to contain_exactly(custom_section_3.name) + end + end + + context "when there is no customer sections" do + it "applies the organization sections on the invoice" do + result = invoice_service.call + expect(result).to be_success + sections = invoice.applied_invoice_custom_sections.reload + expect(sections.map(&:code)).to contain_exactly(custom_section_1.code, custom_section_2.code) + expect(sections.map(&:details)).to contain_exactly(custom_section_1.details, custom_section_2.details) + expect(sections.map(&:display_name)).to contain_exactly(custom_section_1.display_name, custom_section_2.display_name) + expect(sections.map(&:name)).to contain_exactly(custom_section_1.name, custom_section_2.name) + end + end + end + + context "when skip_invoice_custom_sections is false and there are attached custom sections" do + before do + create(:subscription_applied_invoice_custom_section, organization:, subscription: resource, invoice_custom_section: custom_section_2) + end + + it "applies custom sections from the subscription" do + result = invoice_service.call + expect(result).to be_success + sections = invoice.applied_invoice_custom_sections.reload + expect(sections.map(&:code)).to contain_exactly(custom_section_2.code) + expect(sections.map(&:details)).to contain_exactly(custom_section_2.details) + expect(sections.map(&:display_name)).to contain_exactly(custom_section_2.display_name) + expect(sections.map(&:name)).to contain_exactly(custom_section_2.name) + end + end + end + + context "with custom section ids provided" do + let(:custom_section_ids) { [custom_section_4.id] } + let(:custom_section_4) { create(:invoice_custom_section, organization:) } + + it "applies the given sections on the invoice" do + result = invoice_service.call + expect(result).to be_success + sections = invoice.applied_invoice_custom_sections.reload + expect(sections.map(&:code)).to contain_exactly(custom_section_4.code) + expect(sections.map(&:details)).to contain_exactly(custom_section_4.details) + expect(sections.map(&:display_name)).to contain_exactly(custom_section_4.display_name) + expect(sections.map(&:name)).to contain_exactly(custom_section_4.name) + end + end + end +end diff --git a/spec/services/invoices/apply_provider_taxes_service_spec.rb b/spec/services/invoices/apply_provider_taxes_service_spec.rb new file mode 100644 index 0000000..b621f0f --- /dev/null +++ b/spec/services/invoices/apply_provider_taxes_service_spec.rb @@ -0,0 +1,426 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ApplyProviderTaxesService do + subject(:apply_service) { described_class.new(invoice:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + fees_amount_cents:, + coupons_amount_cents:, + sub_total_excluding_taxes_amount_cents: fees_amount_cents - coupons_amount_cents + ) + end + let(:fees_amount_cents) { 3000 } + let(:coupons_amount_cents) { 0 } + let(:result) { BaseService::Result.new } + + let(:fee_taxes) do + [ + OpenStruct.new( + tax_breakdown: [ + OpenStruct.new(name: "tax 1", type: "type1", rate: "0.10") + ] + ), + OpenStruct.new( + tax_breakdown: [ + OpenStruct.new(name: "tax 1", type: "type1", rate: "0.10"), + OpenStruct.new(name: "tax 2", type: "type2", rate: "0.12") + ] + ) + ] + end + + describe "call" do + context "when applying taxes for non-draft invoice" do + before do + result.fees = fee_taxes + allow(Integrations::Aggregator::Taxes::Invoices::CreateService).to receive(:call) + .with(invoice:) + .and_return(result) + end + + context "with non zero fees amount" do + context "with non-zero taxes" do + let(:fee1) do + create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 0) + end + let(:fee1_applied_tax1) do + create( + :fee_applied_tax, + fee: fee1, + amount_cents: 100, + tax_name: "tax 1", + tax_code: "tax_1", + tax_rate: 10.0, + tax_description: "type1" + ) + end + let(:fee2) do + create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 0) + end + let(:fee2_applied_tax_1) do + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 200, + tax_name: "tax 1", + tax_code: "tax_1", + tax_rate: 10.0, + tax_description: "type1" + ) + end + let(:fee2_applied_tax_2) do + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 240, + tax_name: "tax 2", + tax_code: "tax_2", + tax_rate: 12.0, + tax_description: "type2" + ) + end + + before do + fee1 + fee1_applied_tax1 + fee2 + fee2_applied_tax_1 + fee2_applied_tax_2 + end + + it "creates applied taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.find { |item| item.tax_code == "tax_1" }).to have_attributes( + invoice:, + tax_description: "type1", + tax_code: "tax_1", + tax_name: "tax 1", + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 300, + fees_amount_cents: 3000, + taxable_base_amount_cents: 3000 + ) + + expect(applied_taxes.find { |item| item.tax_code == "tax_2" }).to have_attributes( + invoice:, + tax_description: "type2", + tax_code: "tax_2", + tax_name: "tax 2", + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 240, + fees_amount_cents: 2000, + taxable_base_amount_cents: 2000 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 540, + taxes_rate: 18, + fees_amount_cents: 3000 + ) + end + + context "when there is tax deduction" do + let(:fee1) do + create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 0, taxes_base_rate: 0.8) + end + let(:fee1_applied_tax1) do + create( + :fee_applied_tax, + fee: fee1, + amount_cents: 80, + tax_name: "tax 1", + tax_code: "tax_1", + tax_rate: 10.0, + tax_description: "type1" + ) + end + let(:fee2) do + create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 0, taxes_base_rate: 0.8) + end + let(:fee2_applied_tax_1) do + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 160, + tax_name: "tax 1", + tax_code: "tax_1", + tax_rate: 10.0, + tax_description: "type1" + ) + end + let(:fee2_applied_tax_2) do + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 192, + tax_name: "tax 2", + tax_code: "tax_2", + tax_rate: 12.0, + tax_description: "type2" + ) + end + + it "creates applied taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.find { |item| item.tax_code == "tax_1" }).to have_attributes( + invoice:, + tax_description: "type1", + tax_code: "tax_1", + tax_name: "tax 1", + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 240, + fees_amount_cents: 3000, + taxable_base_amount_cents: 2400 + ) + + expect(applied_taxes.find { |item| item.tax_code == "tax_2" }).to have_attributes( + invoice:, + tax_description: "type2", + tax_code: "tax_2", + tax_name: "tax 2", + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 192, + fees_amount_cents: 2000, + taxable_base_amount_cents: 1600 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 432, + taxes_rate: 18, + fees_amount_cents: 3000 + ) + end + end + end + + context "with special provider rules" do + special_rules = + [ + {received_type: "notCollecting", expected_name: "Not collecting", tax_code: "not_collecting"}, + {received_type: "productNotTaxed", expected_name: "Product not taxed", tax_code: "product_not_taxed"}, + {received_type: "jurisNotTaxed", expected_name: "Juris not taxed", tax_code: "juris_not_taxed"} + ] + special_rules.each do |applied_rule| + context "when tax provider returned specific rule applied to fees - #{applied_rule[:expected_name]}" do + let(:fee_taxes) do + [ + OpenStruct.new( + tax_amount_cents: 0, + tax_breakdown: [ + OpenStruct.new(name: applied_rule[:expected_name], type: applied_rule[:received_type], + rate: "0.00", tax_amount: 0) + ] + ), + OpenStruct.new( + tax_amount_cents: 0, + tax_breakdown: [ + OpenStruct.new(name: applied_rule[:expected_name], type: applied_rule[:received_type], + rate: "0.00", tax_amount: 0) + ] + ) + ] + end + let(:fee1) { create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 0) } + let(:fee2) { create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 0) } + let(:fee1_applied_tax) do + create(:fee_applied_tax, fee: fee1, amount_cents: 0, tax_name: applied_rule[:expected_name], + tax_code: applied_rule[:tax_code], tax_rate: 0.0, tax_description: applied_rule[:received_type]) + end + let(:fee2_applied_tax) do + create(:fee_applied_tax, fee: fee2, amount_cents: 0, tax_name: applied_rule[:expected_name], + tax_code: applied_rule[:tax_code], tax_rate: 0.0, tax_description: applied_rule[:received_type]) + end + + before do + fee1_applied_tax + fee2_applied_tax + end + + it "creates applied taxes with #{applied_rule[:expected_name]} params" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + applied_taxes.each do |applied_tax| + expect(applied_tax.tax_description).to eq(applied_rule[:received_type]) + expect(applied_tax.tax_code).to eq(applied_rule[:tax_code]) + expect(applied_tax.tax_name).to eq(applied_rule[:expected_name]) + expect(applied_tax.tax_rate).to eq(0.0) + end + end + end + end + end + + context "with seller paying taxes" do + let(:fee_taxes) do + [ + OpenStruct.new( + tax_amount_cents: 0, + tax_breakdown: [OpenStruct.new(name: "Tax", type: "tax", rate: "0.00", tax_amount: 0)] + ), + OpenStruct.new( + tax_amount_cents: 0, + tax_breakdown: [OpenStruct.new(name: "Tax", type: "tax", rate: "0.00", tax_amount: 0)] + ) + ] + end + let(:fee1) { create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 0) } + let(:fee2) { create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 0) } + let(:fee1_applied_tax) do + create(:fee_applied_tax, fee: fee1, amount_cents: 0, tax_name: "Tax", tax_code: "tax", tax_rate: 0.0, tax_description: "tax") + end + let(:fee2_applied_tax) do + create(:fee_applied_tax, fee: fee2, amount_cents: 0, tax_name: "Tax", tax_code: "tax", tax_rate: 0.0, tax_description: "tax") + end + + before do + fee1_applied_tax + fee2_applied_tax + end + + it "does creates zero-tax" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(1) + expect(applied_taxes.find { |item| item.tax_code == "tax" }).to have_attributes( + invoice:, + tax_description: "tax", + tax_code: "tax", + tax_name: "Tax", + tax_rate: 0, + amount_currency: invoice.currency, + amount_cents: 0, + fees_amount_cents: 3000 + ) + end + end + end + end + + context "when applying taxes for draft invoice" do + let(:invoice) do + create( + :invoice, + :draft, + organization:, + customer:, + fees_amount_cents:, + coupons_amount_cents:, + sub_total_excluding_taxes_amount_cents: fees_amount_cents - coupons_amount_cents + ) + end + + before do + result.fees = fee_taxes + allow(Integrations::Aggregator::Taxes::Invoices::CreateDraftService).to receive(:call) + .with(invoice:) + .and_return(result) + end + + context "with non zero fees amount" do + before do + fee1 = create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 0) + create( + :fee_applied_tax, + fee: fee1, + amount_cents: 100, + tax_name: "tax 1", + tax_code: "tax_1", + tax_rate: 10.0, + tax_description: "type1" + ) + + fee2 = create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 0) + + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 200, + tax_name: "tax 1", + tax_code: "tax_1", + tax_rate: 10.0, + tax_description: "type1" + ) + create( + :fee_applied_tax, + fee: fee2, + amount_cents: 240, + tax_name: "tax 2", + tax_code: "tax_2", + tax_rate: 12.0, + tax_description: "type2" + ) + end + + it "creates applied taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes.find { |item| item.tax_code == "tax_1" }).to have_attributes( + invoice:, + tax_description: "type1", + tax_code: "tax_1", + tax_name: "tax 1", + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 300, + fees_amount_cents: 3000 + ) + + expect(applied_taxes.find { |item| item.tax_code == "tax_2" }).to have_attributes( + invoice:, + tax_description: "type2", + tax_code: "tax_2", + tax_name: "tax 2", + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 240, + fees_amount_cents: 2000 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 540, + taxes_rate: 18, + fees_amount_cents: 3000 + ) + expect(Integrations::Aggregator::Taxes::Invoices::CreateDraftService).to have_received(:call) + end + end + end + end +end diff --git a/spec/services/invoices/apply_taxes_service_spec.rb b/spec/services/invoices/apply_taxes_service_spec.rb new file mode 100644 index 0000000..4184058 --- /dev/null +++ b/spec/services/invoices/apply_taxes_service_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ApplyTaxesService do + subject(:apply_service) { described_class.new(invoice:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + fees_amount_cents:, + coupons_amount_cents:, + sub_total_excluding_taxes_amount_cents: fees_amount_cents - coupons_amount_cents + ) + end + let(:fees_amount_cents) { 3000 } + let(:coupons_amount_cents) { 0 } + + let(:tax1) { create(:tax, organization:, rate: 10, code: "tax1") } + let(:tax2) { create(:tax, organization:, rate: 12, code: "tax2") } + + describe "call" do + context "with non zero fees amount" do + before do + fee1 = create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 0) + create(:fee_applied_tax, tax: tax1, fee: fee1, amount_cents: 100) + + fee2 = create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 0) + create(:fee_applied_tax, tax: tax1, fee: fee2, amount_cents: 200) + create(:fee_applied_tax, tax: tax2, fee: fee2, amount_cents: 240) + end + + it "creates applied taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes.sort_by(&:tax_code) + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes[0]).to have_attributes( + invoice:, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 300, + fees_amount_cents: 3000 + ) + + expect(applied_taxes[1]).to have_attributes( + invoice:, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 240, + fees_amount_cents: 2000 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 540, + taxes_rate: 18, + fees_amount_cents: 3000 + ) + end + end + + context "when invoices fees_amount_cents is zero" do + let(:fees_amount_cents) { 0 } + + before do + fee1 = create(:fee, invoice:, amount_cents: 0, precise_coupons_amount_cents: 0) + create(:fee_applied_tax, tax: tax1, fee: fee1, amount_cents: 0) + + fee2 = create(:fee, invoice:, amount_cents: 0, precise_coupons_amount_cents: 0) + create(:fee_applied_tax, tax: tax1, fee: fee2, amount_cents: 0) + create(:fee_applied_tax, tax: tax2, fee: fee2, amount_cents: 0) + end + + it "creates applied_taxes" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes.sort_by(&:tax_code) + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes[0]).to have_attributes( + invoice:, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 0, + fees_amount_cents: 0 + ) + + expect(applied_taxes[1]).to have_attributes( + invoice:, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 0, + fees_amount_cents: 0 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 0, + taxes_rate: 16, + fees_amount_cents: 0 + ) + end + end + + context "with coupon applied to invoice" do + let(:coupons_amount_cents) { 1000 } + + before do + fee1 = create(:fee, invoice:, amount_cents: 1000, precise_coupons_amount_cents: 1000.fdiv(3)) + create(:fee_applied_tax, tax: tax1, fee: fee1, amount_cents: ((1000 - 1000.fdiv(3)) * 10).fdiv(100)) + + fee2 = create(:fee, invoice:, amount_cents: 2000, precise_coupons_amount_cents: 1000.fdiv(3) * 2) + create(:fee_applied_tax, tax: tax1, fee: fee2, amount_cents: (2000 - 1000.fdiv(3) * 2 * 10).fdiv(100)) + create(:fee_applied_tax, tax: tax2, fee: fee2, amount_cents: (2000 - 1000.fdiv(3) * 2 * 12).fdiv(100)) + end + + it "taxes the coupon at pro-rata of each fees" do + result = apply_service.call + + expect(result).to be_success + + applied_taxes = result.applied_taxes.sort_by(&:tax_code) + expect(applied_taxes.count).to eq(2) + + expect(applied_taxes[0]).to have_attributes( + invoice:, + tax: tax1, + tax_description: tax1.description, + tax_code: tax1.code, + tax_name: tax1.name, + tax_rate: 10, + amount_currency: invoice.currency, + amount_cents: 200, + fees_amount_cents: 2000 + ) + + expect(applied_taxes[1]).to have_attributes( + invoice:, + tax: tax2, + tax_description: tax2.description, + tax_code: tax2.code, + tax_name: tax2.name, + tax_rate: 12, + amount_currency: invoice.currency, + amount_cents: 160, + fees_amount_cents: 1333 + ) + + expect(invoice).to have_attributes( + taxes_amount_cents: 360, + taxes_rate: 18, + fees_amount_cents: 3000 + ) + end + end + end +end diff --git a/spec/services/invoices/calculate_fees_service_spec.rb b/spec/services/invoices/calculate_fees_service_spec.rb new file mode 100644 index 0000000..4bd8cc1 --- /dev/null +++ b/spec/services/invoices/calculate_fees_service_spec.rb @@ -0,0 +1,2895 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CalculateFeesService do + subject(:invoice_service) do + described_class.new( + invoice:, + recurring:, + context: + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:recurring) { false } + let(:context) { nil } + + let(:invoice) do + create( + :invoice, + organization:, + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date, + customer: + ) + end + + let(:subscription) do + create( + :subscription, + organization:, + plan:, + customer:, + billing_time:, + subscription_at: started_at, + started_at:, + created_at:, + status:, + terminated_at: + ) + end + + let(:date_service) do + Subscriptions::DatesService.new_instance( + subscription, + Time.zone.at(timestamp), + current_usage: subscription.terminated? && subscription.upgraded? + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + timestamp:, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime + ) + end + + let(:invoice_subscriptions) { [invoice_subscription] } + + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:add_on) { create(:add_on) } + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:started_at) { Time.zone.now - 2.years } + let(:created_at) { started_at } + let(:terminated_at) { nil } + let(:status) { :active } + + let(:plan) { create(:plan, organization:, interval:, pay_in_advance:, trial_period:) } + let(:pay_in_advance) { false } + let(:billing_time) { :calendar } + let(:interval) { "monthly" } + let(:trial_period) { 0 } + + let(:charge) do + create( + :standard_charge, + plan: subscription.plan, + charge_model: "standard", + billable_metric:, + properties: {amount: "1"} + ) + end + + let(:event) do + create( + :event, + organization: organization, + subscription: subscription, + code: billable_metric.code, + timestamp: event_timestamp + ) + end + + let(:event_timestamp) { [date_service.charges_to_datetime - 2.days, started_at].max } + + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "10"}, units: 10) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 10, created_at: event_timestamp) + end + + before do + tax + charge + invoice_subscriptions + event + fixed_charge + fixed_charge_event + + allow(SegmentTrackJob).to receive(:perform_later) + allow(Invoices::Payments::CreateService).to receive(:call_async).and_call_original + allow(Credits::ProgressiveBillingService).to receive(:call).and_call_original + end + + describe "#call" do + context "when subscription is billed on anniversary date" do + let(:timestamp) { Time.zone.parse("07 Mar 2022") } + let(:started_at) { Time.zone.parse("06 Jun 2021").to_date } + let(:subscription_at) { started_at } + let(:billing_time) { :anniversary } + + it "creates subscription, charge and fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.payment_status).to eq("pending") + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(Time.zone.parse("2022-03-05 23:59:59")), + from_datetime: match_datetime(Time.zone.parse("2022-02-06 00:00:00")) + ) + end + + it "calls the ProgressiveBillingService" do + result = invoice_service.call + expect(result).to be_success + expect(Credits::ProgressiveBillingService).to have_received(:call).with(invoice:) + end + + context "when a progressive_billing invoice is present" do + let(:progressive_invoice) do + create( + :invoice, + :with_subscriptions, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + fees_amount_cents: 50, + issuing_date: timestamp - 5.days, + created_at: timestamp - 5.days + ) + end + + let(:progressive_fee) do + create(:charge_fee, amount_cents: 50, invoice: progressive_invoice) + end + + let(:event) { nil } + + before do + progressive_invoice + progressive_fee + progressive_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_invoice.issuing_date, + timestamp: progressive_invoice.issuing_date + ) + end + + it "creates a credit note for the amount that was billed too much" do + expect { invoice_service.call }.to change(CreditNote, :count).by(1) + + credit_note = progressive_invoice.reload.credit_notes.sole + expect(credit_note.credit_amount_cents).to eq(50) + end + end + + context "when there is tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before do + integration_customer + end + + it "returns tax unknown error and puts invoice in valid status" do + result = invoice_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("tax_error") + expect(result.error.error_message).to eq("unknown taxes") + + expect(invoice.reload.status).to eq("pending") + expect(invoice.reload.tax_status).to eq("pending") + expect(invoice.reload.fees_amount_cents).to eq(10200) + expect(invoice.reload.taxes_amount_cents).to eq(0) + expect(invoice.reload.error_details.count).to eq(0) + end + + context "when calculating fees for draft invoice" do + let(:invoice) do + create( + :invoice, + :draft, + organization:, + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date, + customer: subscription.customer + ) + end + + context "when no context is passed" do + it "returns tax unknown error and puts invoice in valid status" do + result = invoice_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("tax_error") + expect(result.error.error_message).to eq("unknown taxes") + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.tax_status).to eq("pending") + expect(invoice.reload.fees_amount_cents).to eq(10200) + expect(invoice.reload.taxes_amount_cents).to eq(0) + expect(invoice.reload.error_details.count).to eq(0) + end + end + + context "when context is :finalize" do + let(:context) { :finalize } + + it "returns tax unknown error and puts invoice in valid status" do + result = invoice_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("tax_error") + expect(result.error.error_message).to eq("unknown taxes") + + expect(invoice.reload.status).to eq("pending") + expect(invoice.reload.tax_status).to eq("pending") + expect(invoice.reload.fees_amount_cents).to eq(10200) + expect(invoice.reload.taxes_amount_cents).to eq(0) + expect(invoice.reload.error_details.count).to eq(0) + end + end + end + end + + context "when charge is pay_in_advance, not recurring and invoiceable" do + let(:charge) do + create( + :standard_charge, + :pay_in_advance, + plan: subscription.plan, + organization:, + charge_model: "standard", + invoiceable: true + ) + end + + it "does not create a charge fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(0) + end + end + + context "when charge is pay_in_advance, recurring and invoiceable" do + let(:billable_metric) do + create(:billable_metric, aggregation_type: "unique_count_agg", recurring: true, field_name: "item_id") + end + let(:charge) do + create( + :standard_charge, + :pay_in_advance, + plan: subscription.plan, + organization:, + charge_model: "standard", + invoiceable: true, + billable_metric: + ) + end + + it "creates a charge fee", transaction: false do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(1) + end + end + + context "when charge is pay_in_advance, not recurring and not invoiceable" do + let(:charge) do + create( + :standard_charge, + :pay_in_advance, + plan: subscription.plan, + organization:, + charge_model: "standard", + invoiceable: false + ) + end + + it "does not create a charge fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(Fee.charge.count).to eq(0) + end + end + + context "when charge is pay_in_advance, recurring and not invoiceable" do + let(:billable_metric) do + create(:billable_metric, organization:, aggregation_type: "unique_count_agg", recurring: true, field_name: "item_id") + end + let(:charge) do + create( + :standard_charge, + :pay_in_advance, + plan: subscription.plan, + charge_model: "standard", + invoiceable: false, + billable_metric: + ) + end + + it "creates a charge fee" do + result = invoice_service.call + + expect(result).to be_success + expect(Fee.charge.where(invoice_id: nil).count).to eq(1) + end + end + + # when pay_in_advance is true, the fixed charge fee is created, but with period of the next month + context "when fixed charge is pay_in_advance" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: true, + properties: {amount: "10"} + ) + end + + it "creates a fixed charge fee with period of the next month" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(1) + fee_properties = invoice.fees.fixed_charge.first.properties + expect(fee_properties["fixed_charges_from_datetime"]).to match_datetime(Time.parse("2022-03-06T00:00:00.000Z")) + expect(fee_properties["fixed_charges_to_datetime"]).to match_datetime(Time.parse("2022-04-05T23:59:59.999Z")) + end + + context "when subscription is terminated" do + let(:status) { :terminated } + let(:terminated_at) { timestamp - 1.day } + + it "does not create a fixed charge fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(0) + end + end + end + + context "when fixed charge is pay_in_arrears" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: false, + properties: {amount: "10"} + ) + end + + it "creates a fixed charge fee with period of this month" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(1) + fee_properties = invoice.fees.fixed_charge.first.properties + expect(fee_properties["fixed_charges_from_datetime"]).to match_datetime(Time.parse("2022-02-06T00:00:00.000Z")) + expect(fee_properties["fixed_charges_to_datetime"]).to match_datetime(Time.parse("2022-03-05T23:59:59.999Z")) + end + + context "when subscription is terminated" do + let(:status) { :terminated } + let(:terminated_at) { timestamp - 1.day } + + it "does create a fixed charge fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(1) + end + end + end + + context "when there are multiple fixed charges" do + context "when changing the subscription" do + let(:add_on1) { create(:add_on, organization:) } + let(:add_on2) { create(:add_on, organization:) } + + let(:old_plan) { create(:plan, organization:, interval:, pay_in_advance:, trial_period:) } + let(:new_plan) { create(:plan, organization:, interval:, pay_in_advance:, trial_period:, amount_cents: 2000) } + + let(:old_fixed_charge_arrears) do + create( + :fixed_charge, + plan: old_plan, + add_on: add_on1, + charge_model: "standard", + pay_in_advance: false, + properties: {amount: "10"} + ) + end + + let(:new_fixed_charge_advance) do + create( + :fixed_charge, + plan: new_plan, + add_on: add_on1, + charge_model: "standard", + pay_in_advance: true, + properties: {amount: "10"} + ) + end + + let(:new_fixed_charge_arrears) do + create( + :fixed_charge, + plan: new_plan, + add_on: add_on2, + charge_model: "standard", + pay_in_advance: false, + properties: {amount: "15"} + ) + end + + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: true, + properties: {amount: "10"}, + units: 1 + ) + end + + let(:old_subscription) do + create( + :subscription, + plan: old_plan, + organization:, + customer:, + billing_time:, + subscription_at: started_at, + started_at:, + created_at:, + status: :terminated, + terminated_at: timestamp - 1.day + ) + end + + let(:new_subscription) do + create( + :subscription, + plan: new_plan, + organization:, + customer:, + billing_time:, + subscription_at: started_at, + started_at: timestamp, + created_at: timestamp, + status: :active, + previous_subscription: old_subscription + ) + end + + let(:old_fixed_charge_arrears_event) do + create(:fixed_charge_event, fixed_charge: old_fixed_charge_arrears, subscription: old_subscription, timestamp: event_timestamp, units: 10, created_at: event_timestamp) + end + + let(:new_fixed_charge_advance_event) do + create(:fixed_charge_event, fixed_charge: new_fixed_charge_advance, subscription: new_subscription, timestamp:, units: 1) + end + + let(:new_fixed_charge_arrears_event) do + create(:fixed_charge_event, fixed_charge: new_fixed_charge_arrears, subscription: new_subscription, timestamp:, units: 1) + end + + before do + old_fixed_charge_arrears_event + new_fixed_charge_advance_event + new_fixed_charge_arrears_event + end + + # for now we'll just always create the fixed_charge fee. in the next PR we'll handle upgrade/downgrade/termination scenarios + # for terminated subscription do not issue pay in advance fee, issue only pay in arrears fee + context "when subscription is upgraded" do + context "when passing old subscription in the service" do + let(:subscription) { old_subscription } + + it "creates only fee for pay in arrears charges" do + result = invoice_service.call + + expect(result).to be_success + expect(subscription.fixed_charges.count).to eq(2) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.fixed_charge.first.fixed_charge).to eq(old_fixed_charge_arrears) + end + end + + context "when passing new subscription in the service" do + let(:subscription) { new_subscription } + + before do + invoice_subscription[:invoicing_reason] = :subscription_starting + invoice_subscription.save! + end + + it "creates a fixed charge fee for the new subscription only for pay in advance charges" do + result = invoice_service.call + + expect(result).to be_success + expect(subscription.fixed_charges.count).to eq(3) + expect(invoice.fees.fixed_charge.count).to eq(2) + expect(invoice.fees.fixed_charge.map(&:fixed_charge)).to match_array([new_fixed_charge_advance, fixed_charge]) + end + end + end + + context "when subscriptions is downgraded" do + let(:old_plan) { create(:plan, organization:, interval:, pay_in_advance:, trial_period:, amount_cents: 10000) } + let(:new_plan) { create(:plan, organization:, interval:, pay_in_advance:, trial_period:, amount_cents: 2000) } + + context "when passing old subscription in the service" do + let(:subscription) { old_subscription } + + it "creates a pay_in_arrears fee for the previous subscription" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.fixed_charge.first.fixed_charge).to eq(old_fixed_charge_arrears) + end + end + + context "when passing new subscription in the service" do + let(:subscription) { new_subscription } + let(:fixed_charge) do + create( + :fixed_charge, + plan: new_plan, + charge_model: "standard", + pay_in_advance: false, + properties: {amount: "10"}, + units: 1 + ) + end + + before do + invoice_subscription.update!(invoicing_reason: :subscription_starting) + end + + it "creates a fixed charge fee for the new subscription only for pay in advance charges" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.fixed_charge.map(&:fixed_charge)).to match_array([new_fixed_charge_advance]) + end + end + end + end + end + end + + context "when billed for the first time" do + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:started_at) { timestamp - 3.days } + + context "when plan has no other requirements" do + it "creates subscription, charge and fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(0) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime((timestamp - 1.day).end_of_day), + from_datetime: match_datetime(subscription.subscription_at.beginning_of_day) + ) + end + end + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "creates subscription, charge, fixed charge and commitment fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(1) + end + end + + context "when plan has non invoiceable, recurring, pay in advance charge" do + before do + create( + :standard_charge, + :pay_in_advance, + plan: subscription.plan, + charge_model: "standard", + invoiceable: false, + billable_metric: create(:billable_metric, organization:, aggregation_type: "unique_count_agg", recurring: true, field_name: "item_id") + ) + end + + it "creates subscription, charge and commitment fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(1) + expect(Fee.where(invoice_id: nil).count).to eq(1) + end + end + + context "when plan has graduated fixed charge" do + let(:fixed_charge) do + create( + :fixed_charge, + :graduated, + plan: subscription.plan, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, per_unit_amount: "5", flat_amount: "200"}, + {from_value: 11, to_value: nil, per_unit_amount: "1", flat_amount: "300"} + ] + }, + units: 15 + ) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 15) + end + + it "creates subscription, charge and graduated fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(0) + + fixed_charge_fee = invoice.fees.fixed_charge.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + expect(fixed_charge_fee.units).to eq(15) + expect(fixed_charge_fee.amount_cents).to eq(20000 + 30000 + 10 * 500 + 5 * 100) + end + end + + context "when plan has volume fixed charge" do + let(:fixed_charge) do + create( + :fixed_charge, + :volume, + plan: subscription.plan, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 100, per_unit_amount: "2", flat_amount: "1"}, + {from_value: 101, to_value: nil, per_unit_amount: "1", flat_amount: "0"} + ] + } + ) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 150) + end + + it "creates subscription, charge and volume fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(0) + + fixed_charge_fee = invoice.fees.fixed_charge.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + expect(fixed_charge_fee.units).to eq(150) + expect(fixed_charge_fee.amount_cents).to eq(15000) + end + end + + context "when plan has prorated fixed charge" do + let(:timestamp) { Time.zone.parse("01 Mar 2022") } + let(:event_timestamp) { Time.zone.parse("14 Feb 2022") } + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + prorated: true, + properties: {amount: "10"} + ) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 28, created_at: event_timestamp) + end + + it "creates subscription, charge and prorated fixed charge fees" do + result = invoice_service.call + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(0) + + fixed_charge_fee = invoice.fees.fixed_charge.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + # total units on the event is 28 + expect(fixed_charge_fee.units).to eq(28) + # event is prorated for 3 days of 28 days period (28 * 3 / 28 * 10 = 30) + expect(fixed_charge_fee.amount_cents).to eq(3000) + end + end + end + + context "when two subscriptions are given" do + let(:subscription2) do + create( + :subscription, + plan:, + customer: subscription.customer, + subscription_at: (Time.zone.now - 2.years).to_date, + started_at: Time.zone.now - 2.years + ) + end + + let(:invoice_subscription2) do + create( + :invoice_subscription, + subscription: subscription2, + invoice:, + timestamp:, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime + ) + end + + let(:invoice_subscriptions) { [invoice_subscription, invoice_subscription2] } + + context "when plan has no minimum commitment" do + it "creates subscription, charge and fixed charge fees for both" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.subscriptions.to_a).to match_array([subscription, subscription2]) + expect(invoice.payment_status).to eq("pending") + expect(invoice.fees.subscription.count).to eq(2) + expect(invoice.fees.charge.count).to eq(1) # 0 amount charge fee is not created for subscription 2 + expect(invoice.fees.fixed_charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime((timestamp - 1.day).end_of_day), + from_datetime: match_datetime((timestamp - 1.month).beginning_of_day) + ) + end + end + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "creates subscription, charges and commitment fees for both" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.subscriptions.to_a).to match_array([subscription, subscription2]) + expect(invoice.payment_status).to eq("pending") + expect(invoice.fees.subscription.count).to eq(2) + expect(invoice.fees.charge.count).to eq(1) # 0 amount charge fee is not created for subscription 2 + expect(invoice.fees.commitment.count).to eq(2) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime((timestamp - 1.day).end_of_day), + from_datetime: match_datetime((timestamp - 1.month).beginning_of_day) + ) + end + end + end + + context "when subscription is terminated" do + let(:status) { :terminated } + let(:timestamp) { Time.zone.now.beginning_of_month - 1.day } + let(:started_at) { Time.zone.today - 3.months } + let(:terminated_at) { timestamp - 2.days } + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "creates a subscription and a commitment fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(terminated_at), + from_datetime: match_datetime(terminated_at.beginning_of_month) + ) + end + end + + context "when plan has no minimum commitment" do + it "creates a subscription fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.subscription.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(terminated_at), + from_datetime: match_datetime(terminated_at.beginning_of_month) + ) + end + end + + context "when charges are pay in advance and billable metric is recurring" do + let(:billable_metric) do + create(:billable_metric, aggregation_type: "unique_count_agg", recurring: true, field_name: "item_id") + end + + let(:charge) do + create( + :standard_charge, + :pay_in_advance, + plan: subscription.plan, + charge_model: "standard", + invoiceable: true, + billable_metric: + ) + end + + context "when plan no minimum commitment" do + it "does not create a charge fee or a commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(0) + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "does not create a charge fee but it creates a commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(0) + expect(invoice.fees.commitment.count).to eq(1) + end + end + end + + context "when charges are pay in arrear and billable metric is recurring" do + let(:billable_metric) do + create(:billable_metric, aggregation_type: "unique_count_agg", recurring: true, field_name: "item_id") + end + let(:charge) do + create( + :standard_charge, + pay_in_advance: false, + plan: subscription.plan, + charge_model: "standard", + invoiceable: true, + billable_metric:, + prorated: false + ) + end + + context "when plan no minimum commitment" do + it "creates a charge fee but no minimum commitment fee", transaction: false do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + let(:event) { nil } + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "creates a charge fee and a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice).to have_empty_charge_fees + expect(invoice.fees.commitment.count).to eq(1) + end + end + end + + context "when termination is part of upgrade and charges are not billable" do + let(:new_subscription) do + create( + :subscription, + plan:, + previous_subscription: subscription, + subscription_at: started_at.to_date, + started_at: terminated_at + 1.day, + created_at: terminated_at + 1.day + ) + end + + let(:billable_metric) do + create(:billable_metric, aggregation_type: "unique_count_agg", recurring: true, field_name: "item_id") + end + + let(:charge) do + create( + :standard_charge, + :pay_in_advance, + plan: subscription.plan, + charge_model: "standard", + invoiceable: true, + billable_metric: + ) + end + + before { new_subscription } + + context "when plan has no minimum commitment" do + it "does not create a charge fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(0) + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "does not create a charge fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(0) + end + + it "creates a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(1) + end + end + end + + context "when termination is part of upgrade, charges are paid in arrears and BM is recurring" do + let(:new_subscription) do + create( + :subscription, + plan:, + previous_subscription: subscription, + subscription_at: started_at.to_date, + started_at: terminated_at + 1.day, + created_at: terminated_at + 1.day + ) + end + + let(:billable_metric) do + create(:billable_metric, aggregation_type: "unique_count_agg", recurring: true, field_name: "item_id") + end + + let(:charge) do + create( + :standard_charge, + pay_in_advance: false, + plan: subscription.plan, + charge_model: "standard", + invoiceable: true, + billable_metric:, + prorated: false + ) + end + + before { new_subscription } + + context "when plan has no minimum commitment" do + it "does not create a charge fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(0) + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "does not create a charge fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.charge.count).to eq(0) + end + + it "creates a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(1) + end + end + end + + context "when subscription is billed on anniversary date" do + let(:timestamp) { Time.zone.parse("22 Mar 2022") } + let(:started_at) { Time.zone.parse("2021-06-06 05:00:00") } + let(:subscription_at) { started_at } + let(:billing_time) { "anniversary" } + + context "when plan has no minimum commitment" do + it "creates subscription fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.subscription.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(terminated_at), + from_datetime: match_datetime(Time.zone.parse("2022-03-06 00:00:00")) + ) + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "0.001"}, units: 10) + end + + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "creates subscription fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.subscription.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(terminated_at), + from_datetime: match_datetime(Time.zone.parse("2022-03-06 00:00:00")) + ) + end + + it "creates a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.commitment.count).to eq(1) + end + end + end + + context "when fixed charges are pay in advance and subscription is terminated" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: true, + properties: {amount: "10"} + ) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 5, created_at: event_timestamp) + end + + context "when plan has no minimum commitment" do + it "does not create a fixed charge fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(0) + end + end + end + + context "when fixed charges are pay in arrear and subscription is terminated" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: false, + properties: {amount: "10"} + ) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 5, created_at: event_timestamp) + end + + context "when plan has no minimum commitment" do + it "creates a fixed charge fee but no minimum commitment fee", transaction: false do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when termination is part of upgrade" do + let(:new_subscription) do + create( + :subscription, + plan:, + previous_subscription: subscription, + subscription_at: started_at.to_date, + started_at: terminated_at + 1.day, + created_at: terminated_at + 1.day + ) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 5, created_at: event_timestamp) + end + + before do + new_subscription + end + + context "when fixed charge is pay in advance" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: true, + properties: {amount: "10"} + ) + end + + it "does not create a fixed charge fee for the old subscription" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(0) + end + end + + context "when fixed charge is pay in arrear" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: false, + properties: {amount: "10"} + ) + end + + it "does create a fixed charge fee for the old subscription pay in arrears fixed charge" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(1) + end + end + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + context "when billed on anniversary date" do + let(:timestamp) { Time.zone.parse("07 Mar 2022") } + let(:started_at) { Time.zone.parse("06 Jun 2021").to_date } + let(:subscription_at) { started_at } + let(:billing_time) { :anniversary } + + let(:event) { nil } + + context "when plan has no minimum commitment" do + it "creates a subscription fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.payment_status).to eq("pending") + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice).to have_empty_charge_fees + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(Time.zone.parse("2022-04-05 23:59:59")), + from_datetime: match_datetime(Time.zone.parse("2022-03-06 00:00:00")) + ) + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "creates a subscription fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.payment_status).to eq("pending") + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice).to have_empty_charge_fees + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(Time.zone.parse("2022-04-05 23:59:59")), + from_datetime: match_datetime(Time.zone.parse("2022-03-06 00:00:00")) + ) + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when plan is in trial period" do + let(:trial_period) { 45 } + let(:started_at) { 40.days.ago } + + it "does not create a subscription fee" do + subscription.created_at + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.subscription.count).to eq(0) + end + + it "creates a fixed charge fee even during trial period" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.fixed_charge.count).to eq(1) + end + end + + context "when fixed charges are pay in advance" do + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: true, + properties: {amount: "10"} + ) + end + + let(:fixed_charge_event) do + create(:fixed_charge_event, fixed_charge:, subscription:, timestamp: event_timestamp, units: 5, created_at: event_timestamp) + end + + it "creates subscription and fixed charge fees with correct boundaries" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.payment_status).to eq("pending") + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription[:to_datetime].to_date).to eq(Time.zone.today.end_of_month) + expect(invoice_subscription[:from_datetime].to_date).to eq(Time.zone.today.beginning_of_month) + + fixed_charge_fee_properties = invoice.fees.fixed_charge.first.properties + expect(fixed_charge_fee_properties["fixed_charges_from_datetime"].to_date).to match_datetime(Time.zone.today.beginning_of_month) + expect(fixed_charge_fee_properties["fixed_charges_to_datetime"].to_date).to match_datetime(Time.zone.today.end_of_month) + + charge_fee_properties = invoice.fees.charge.first.properties + expect(charge_fee_properties["charges_from_datetime"].to_date).to match_datetime((Time.zone.today - 1.month).beginning_of_month) + expect(charge_fee_properties["charges_to_datetime"].to_date).to match_datetime((Time.zone.today - 1.month).end_of_month) + end + end + + context "when subscription was already billed earlier the same day" do + let(:timestamp) { Time.current } + let(:fixed_charge) do + create( + :fixed_charge, + plan: subscription.plan, + charge_model: "standard", + pay_in_advance: true, + properties: {amount: "10"} + ) + end + + before do + create(:fee, subscription:) + create(:fixed_charge_fee, subscription:, fixed_charge:, created_at: timestamp) + end + + context "when plan has no minimum commitment" do + it "does not create any subscription fees" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.subscription.count).to eq(0) + expect(invoice.invoice_subscriptions.count).to eq(1) + expect(invoice.invoice_subscriptions.first.recurring).to be_falsey + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.commitment.count).to eq(0) + end + + # TODO: this will be solved with the service that calculates delta for fixed charges + # it "does not create a fixed charge fee" do + # result = invoice_service.call + + # expect(result).to be_success + # expect(invoice.fees.fixed_charge.count).to eq(0) + # end + end + + context "when plan has minimum commitment" do + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "does not create any subscription fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.subscription.count).to eq(0) + expect(invoice.invoice_subscriptions.count).to eq(1) + expect(invoice.invoice_subscriptions.first.recurring).to be_falsey + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription started in the past" do + let(:created_at) { timestamp } + + it "creates subscription, charge and fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + end + end + + context "when subscription started on creation day" do + let(:event) { nil } + + it "does not create any charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice).to have_empty_charge_fees + end + end + + context "when subscription is an upgrade" do + let(:timestamp) { Time.zone.parse("30 Sep 2022 00:31:00") } + let(:started_at) { Time.zone.parse("12 Aug 2022 00:31:00") } + let(:terminated_at) { timestamp - 2.days } + let(:previous_plan) { create(:plan, amount_cents: 10_000, interval: :yearly, pay_in_advance: true) } + + let(:previous_subscription) do + create( + :subscription, + plan: previous_plan, + subscription_at: started_at.to_date, + started_at:, + status: :terminated, + terminated_at: + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + previous_subscription:, + subscription_at: started_at.to_date, + started_at: terminated_at + 1.day, + created_at: terminated_at + 1.day + ) + end + + context "when plan has no minimum commitment" do + it "creates pro-rated subscription fee and no charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice).to be_payment_pending + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice).to have_empty_charge_fees + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(subscription.started_at.end_of_month), + from_datetime: match_datetime(subscription.started_at.beginning_of_day) + ) + end + + it "does not create a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + it "creates pro-rated subscription fee and no charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice).to be_payment_pending + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice).to have_empty_charge_fees + expect(invoice.fees.fixed_charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(subscription.started_at.end_of_month), + from_datetime: match_datetime(subscription.started_at.beginning_of_day) + ) + end + + it "does not creates a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + + context "when subscription is terminated after an upgrade" do + let(:next_subscription) do + create( + :subscription, + plan: next_plan, + subscription_at: started_at.to_date, + started_at: terminated_at, + status: :active, + billing_time: :calendar, + previous_subscription: subscription, + customer: subscription.customer + ) + end + + let(:started_at) { Time.zone.parse("07 Mar 2022") } + let(:terminated_at) { Time.zone.parse("17 Oct 2022 12:35:12") } + let(:timestamp) { Time.zone.parse("17 Oct 2022 15:00") } + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at.to_date, + started_at:, + status: :terminated, + terminated_at:, + billing_time: :calendar + ) + end + + let(:next_plan) { create(:plan, interval: :monthly, amount_cents: 2000) } + + before { next_subscription } + + context "when plan has no minimum commitment" do + it "creates only the charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.subscription.count).to eq(0) + expect(invoice).to have_empty_charge_fees + expect(invoice.fees.fixed_charge.count).to eq(1) # fixed charge is pay in arrears - sub termination generated fee + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + charges_from_datetime: match_datetime(Time.zone.parse("2022-10-01 00:00:00")), + charges_to_datetime: match_datetime(terminated_at) + ) + end + + it "does not creates a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + + context "when plan has minimum commitment" do + before do + create(:commitment, :minimum_commitment, plan:, amount_cents: 10_000) + end + + context "when plan has no minimum commitment" do + it "creates only the charge fees" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.subscription.count).to eq(0) + expect(invoice).to have_empty_charge_fees + # Fixed charge is pay in arrears - sub termination generated fee + expect(invoice.fees.fixed_charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + charges_from_datetime: match_datetime(Time.zone.parse("2022-10-01 00:00:00")), + charges_to_datetime: match_datetime(terminated_at) + ) + end + + it "does not creates a minimum commitment fee" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.fees.commitment.count).to eq(0) + end + end + end + end + end + + context "when billed yearly" do + let(:timestamp) { Time.zone.now.beginning_of_year } + let(:interval) { "yearly" } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime((timestamp - 1.day).end_of_day), + from_datetime: match_datetime((timestamp - 1.year).beginning_of_day) + ) + end + + context "when subscription is billed on anniversary date" do + let(:timestamp) { Time.zone.parse("07 Jun 2022") } + let(:started_at) { Time.zone.parse("06 Jun 2020").to_date } + let(:subscription_at) { started_at } + let(:billing_time) { :anniversary } + + let(:event) { nil } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + expect(invoice).to have_empty_charge_fees # Because we didn't fake usage events + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(Time.zone.parse("2022-06-05 23:59:59")), + from_datetime: match_datetime(Time.zone.parse("2021-06-06 00:00:00")) + ) + end + + context "when started_at in the past" do + let(:timestamp) { Time.zone.parse(started_at.to_s).end_of_year + 1.day } + let(:started_at) { created_at - 2.months } + let(:created_at) { Time.zone.parse("2025-01-21T00:10:00") } + let(:event_timestamp) { created_at } + let(:fixed_charge_event) { create(:fixed_charge_event, fixed_charge:, units: 10, timestamp: started_at, created_at: created_at) } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(0) + expect(invoice).to have_empty_charge_fees # Because we didn't fake usage events + + # plan is billed anniversary on 21 of November (started_at, 2024). At the timestamp, 1st Jan 2025, + # previous billing period is "empty" - 21Nov 2024 - 21 Nov 2024, because it's not current usage, so we get an "empty" + # boundaries and cannot fetch any usage + expect(invoice.fees.fixed_charge.count).to eq(0) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice).to have_empty_charge_fees # Because we didn't fake usage events + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(Time.zone.parse("2023-06-05 23:59:59")), + from_datetime: match_datetime(Time.zone.parse("2022-06-06 00:00:00")) + ) + end + end + + context "when plan is pay in advance and started_at in the past" do + let(:pay_in_advance) { true } + let(:timestamp) { Time.current.end_of_month + 1.day } + let(:started_at) { Time.current - 2.months } + let(:created_at) { Time.current } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(0) + expect(invoice).to have_empty_charge_fees # Because we didn't fake usage events + end + end + + context "when plan is pay in advance and started_at in the past and billed on second year" do + let(:pay_in_advance) { true } + let(:timestamp) { started_at + 1.year } + let(:started_at) { Time.current - 2.months } + let(:created_at) { Time.current } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice).to have_empty_charge_fees # Because we didn't fake usage events + end + end + end + + context "when billed yearly on first year" do + let(:timestamp) { Time.zone.parse(started_at.to_s).end_of_year + 1.day } + let(:started_at) { Time.zone.today - 3.months } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime((timestamp - 1.day).end_of_day), + from_datetime: match_datetime(subscription.subscription_at.beginning_of_day) + ) + end + end + end + + context "when billed quarterly" do + let(:timestamp) { Time.zone.now.beginning_of_year } + let(:started_at) { Time.zone.now.beginning_of_year - 2.years } + let(:interval) { "quarterly" } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime((timestamp - 1.day).end_of_day), + from_datetime: match_datetime((timestamp - 2.days).beginning_of_quarter.beginning_of_day), + charges_to_datetime: match_datetime((timestamp - 1.day).end_of_day), + charges_from_datetime: match_datetime((timestamp - 2.days).beginning_of_quarter.beginning_of_day) + ) + end + + context "when subscription is billed on anniversary date" do + let(:timestamp) { Time.zone.parse("07 Jun 2022") } + let(:started_at) { Time.zone.parse("06 Jun 2020").to_date } + let(:subscription_at) { started_at } + let(:billing_time) { :anniversary } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(Time.zone.parse("2022-06-05 23:59:59")), + from_datetime: match_datetime(Time.zone.parse("2022-03-06 00:00:00")), + charges_to_datetime: match_datetime(Time.zone.parse("2022-06-05 23:59:59")), + charges_from_datetime: match_datetime(Time.zone.parse("2022-03-06 00:00:00")) + ) + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:old_invoice_subscription) { create(:invoice_subscription, invoice: old_invoice, subscription:) } + let(:old_invoice) do + create( + :invoice, + created_at: started_at - 3.months, + customer: subscription.customer, + organization: plan.organization + ) + end + + before { old_invoice_subscription } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime(Time.zone.parse("2022-09-05 23:59:59")), + from_datetime: match_datetime(Time.zone.parse("2022-06-06 00:00:00")), + charges_to_datetime: match_datetime(Time.zone.parse("2022-06-05 23:59:59")), + charges_from_datetime: match_datetime(Time.zone.parse("2022-03-06 00:00:00")) + ) + end + end + end + + context "when billed quarterly on first billing day" do + let(:timestamp) { Time.zone.parse("01 Jan 2022") } + let(:started_at) { Time.zone.parse("12 Nov 2021").to_date } + let(:subscription_at) { started_at } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(invoice.subscriptions.first).to eq(subscription) + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.charge.count).to eq(1) + + invoice_subscription = invoice.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + to_datetime: match_datetime((timestamp - 1.day).end_of_day), + from_datetime: match_datetime(subscription.subscription_at.beginning_of_day), + charges_to_datetime: match_datetime((timestamp - 1.day).end_of_day), + charges_from_datetime: match_datetime(subscription.subscription_at.beginning_of_day) + ) + end + end + end + + context "with credit" do + let(:credit_note) do + create( + :credit_note, + customer: subscription.customer, + total_amount_cents: 10, + total_amount_currency: plan.amount_currency, + balance_amount_cents: 10, + balance_amount_currency: plan.amount_currency, + credit_amount_cents: 10, + credit_amount_currency: plan.amount_currency + ) + end + + before { credit_note } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.fees_amount_cents).to eq(10200) + expect(result.invoice.taxes_amount_cents).to eq(2040) + expect(result.invoice.total_amount_cents).to eq(12230) + expect(result.invoice.credits.count).to eq(1) + + credit = result.invoice.credits.first + expect(credit.credit_note).to eq(credit_note) + expect(credit.amount_cents).to eq(10) + end + end + + context "with applied prepaid credits" do + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:wallet) { create(:wallet, :with_inbound_transaction, customer: subscription.customer, balance: "0.30", credits_balance: "0.30") } + + let(:plan) do + create(:plan, interval: "monthly") + end + + before { wallet } + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + + expect(result.invoice.fees.first.properties["to_datetime"]) + .to eq (timestamp - 1.day).end_of_day.as_json + expect(result.invoice.fees.first.properties["from_datetime"]) + .to eq (timestamp - 1.month).beginning_of_day.as_json + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.subscription.count).to eq(1) + expect(result.invoice.fees.charge.count).to eq(1) + expect(result.invoice.fees.fixed_charge.count).to eq(1) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(10200) + expect(result.invoice.taxes_amount_cents).to eq(2040) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(12240) + expect(result.invoice.prepaid_credit_amount_cents).to eq(30) + expect(result.invoice.total_amount_cents).to eq(12210) + expect(result.invoice.wallet_transactions.count).to eq(1) + end + + it "updates wallet balance" do + invoice_service.call + + expect(wallet.reload.balance).to eq(0.0) + end + + context "when invoice amount in cents is zero" do + let(:applied_coupon) do + create( + :applied_coupon, + customer: subscription.customer, + amount_cents: 120, + amount_currency: plan.amount_currency + ) + end + + let(:event) { nil } + let(:fixed_charge_event) { nil } + + before { applied_coupon } + + it "does not create any wallet transactions" do + result = invoice_service.call + + expect(result.invoice.wallet_transactions.exists?).to be(false) + end + end + end + + context "with all types of credits" do + let(:plan) { create(:plan, organization:, interval:, pay_in_advance:, trial_period:, amount_cents: 10_000) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 10) } + let(:billable_metric1) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:billable_metric2) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:billable_metric3) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:billable_metric4) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:coupon1) { create(:coupon, organization:, coupon_type: "percentage", limited_billable_metrics: true, percentage_rate: 50.00) } + let(:coupon2) { create(:coupon, organization:) } + let(:coupon_target) { create(:coupon_billable_metric, coupon: coupon1, billable_metric: billable_metric1) } + let(:wallet) do + create(:wallet, :with_inbound_transaction, organization:, customer: subscription.customer, balance: "50_000", credits_balance: "50_000", allowed_fee_types: %w[charge]) + end + let(:applied_coupon) do + create( + :applied_coupon, + coupon: coupon1, + customer: subscription.customer, + percentage_rate: 50.00 + ) + end + let(:applied_coupon2) do + create( + :applied_coupon, + coupon: coupon2, + customer: subscription.customer, + amount_cents: 1_000 + ) + end + + let(:charge1) do + create( + :standard_charge, + plan: subscription.plan, + organization:, + charge_model: "standard", + billable_metric: billable_metric1, + properties: {amount: "10"} + ) + end + let(:event1) do + create( + :event, + organization: organization, + subscription: subscription, + code: billable_metric1.code, + timestamp: event_timestamp + ) + end + let(:charge2) do + create( + :standard_charge, + plan: subscription.plan, + organization:, + charge_model: "standard", + billable_metric: billable_metric2, + properties: {amount: "20"} + ) + end + let(:event2) do + create( + :event, + organization: organization, + subscription: subscription, + code: billable_metric2.code, + timestamp: event_timestamp + ) + end + let(:charge3) do + create( + :standard_charge, + plan: subscription.plan, + organization:, + charge_model: "standard", + billable_metric: billable_metric3, + properties: {amount: "30"} + ) + end + let(:event3) do + create( + :event, + organization: organization, + subscription: subscription, + code: billable_metric3.code, + timestamp: event_timestamp + ) + end + let(:charge4) do + create( + :standard_charge, + plan: subscription.plan, + organization:, + charge_model: "standard", + billable_metric: billable_metric4, + properties: {amount: "40"} + ) + end + let(:event4) do + create( + :event, + organization: organization, + subscription: subscription, + code: billable_metric4.code, + timestamp: event_timestamp + ) + end + let(:progressive_invoice) do + create( + :invoice, + :with_subscriptions, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription], + fees_amount_cents: 3_000, + issuing_date: timestamp - 5.days, + created_at: timestamp - 5.days + ) + end + let(:progressive_fee) do + create(:charge_fee, amount_cents: 3_000, charge: charge3, invoice: progressive_invoice) + end + let(:credit_note) do + create( + :credit_note, + customer: subscription.customer, + total_amount_cents: 1_000, + total_amount_currency: plan.amount_currency, + balance_amount_cents: 1_000, + balance_amount_currency: plan.amount_currency, + credit_amount_cents: 1_000, + credit_amount_currency: plan.amount_currency + ) + end + + let(:event) { nil } + + before do + charge1 + charge2 + charge3 + charge4 + event1 + event2 + event3 + event4 + progressive_invoice + progressive_fee + progressive_invoice.invoice_subscriptions.first.update!( + charges_from_datetime: progressive_invoice.issuing_date - 1.month, + charges_to_datetime: progressive_invoice.issuing_date, + timestamp: progressive_invoice.issuing_date + ) + applied_coupon + applied_coupon2 + coupon_target + credit_note + wallet + end + + it "updates the invoice accordingly" do + result = invoice_service.call + + expect(result).to be_success + # 100_00 - fixed_charges + # 100_00 - charges + # 100_00 - subscription + expect(result.invoice.fees_amount_cents).to eq(300_00) + # Billing entity tax is 10% + # subtotal = fees_amount_cents - coupons_amount_cents - progressive_billing_credit_amount_cents + expect(result.invoice.progressive_billing_credit_amount_cents).to eq(30_00) + expect(result.invoice.coupons_amount_cents).to eq(15_00) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(255_00) + expect(result.invoice.taxes_amount_cents).to eq(25_50) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(280_50) + + expect(result.invoice.total_amount_cents).to eq(204_15) # 280_50 - 10_00 (credit note) - 66_35 (wallet) + end + end + + # TODO: this should not be the case when we merge everything, as we're going to bill this case differently + context "when subscription that is started" do + let(:subscription) { create(:subscription, plan:, started_at:, customer:) } + let(:date_service) do + Subscriptions::DatesService.new_instance( + subscription, + Time.zone.at(timestamp), + current_usage: false + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + invoicing_reason: :subscription_starting, + timestamp:, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime + ) + end + let(:plan) { create(:plan, pay_in_advance:) } + let(:charge) { create(:standard_charge, plan:, pay_in_advance: charge_pay_in_advance) } + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: fixed_charge_pay_in_advance) } + let(:pay_in_advance) { false } + let(:charge_pay_in_advance) { false } + let(:fixed_charge_pay_in_advance) { false } + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { started_at } + let(:invoice) { create(:invoice) } + let(:fixed_charge_event) { create(:fixed_charge_event, fixed_charge:, units: 10, timestamp: started_at, created_at: started_at) } + + before do + invoice + charge + fixed_charge_event + invoice_subscription + end + + it "creates a subscription fee with amount 0, does not create charge nor fixed charge fees" do + result = invoice_service.call + + expect(result.invoice.fees.subscription.count).to eq(1) + expect(result.invoice.fees.charge.count).to eq(0) + expect(result.invoice.fees.fixed_charge.count).to eq(0) + end + + context "when fixed_charge is pay in advance" do + let(:fixed_charge_pay_in_advance) { true } + + it "does not create a subscription fee, does not create charge fee, creates fixed charge fee" do + result = invoice_service.call + + expect(result.invoice.fees.subscription.count).to eq(0) + expect(result.invoice.fees.charge.count).to eq(0) + # To Be changed when we actually generate fixed charge fees + expect(result.invoice.fees.fixed_charge.count).to eq(0) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "creates a subscription fee, does not create charge fee, does not create fixed charge fee" do + result = invoice_service.call + + expect(result.invoice.fees.subscription.count).to eq(1) + expect(result.invoice.fees.charge.count).to eq(0) + expect(result.invoice.fees.fixed_charge.count).to eq(0) + end + + context "when fixed_charge is pay in advance" do + let(:fixed_charge_pay_in_advance) { true } + + it "creates a subscription fee, does not create charge fee, creates fixed charge fee" do + result = invoice_service.call + + expect(result.invoice.fees.subscription.count).to eq(1) + expect(result.invoice.fees.charge.count).to eq(0) + # To Be changed when we actually generate fixed charge fees + expect(result.invoice.fees.fixed_charge.count).to eq(0) + end + end + end + end + + # this case might happen because we only started populating fixed_charge boundaries in the last PR, + # and we can have some invoices that we're recalculating with old invoice_subscriptions + context "when invoice has an invoice_subscription with empty fixed_charge boundaries" do + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + timestamp:, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: nil, + fixed_charges_to_datetime: nil + ) + end + + it "does not fail and does not create fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.fees.fixed_charge.count).to eq(0) + end + end + end + + context "when subscription is incomplete" do + let(:status) { :incomplete } + let(:timestamp) { Time.zone.parse("07 Mar 2022") } + let(:started_at) { Time.zone.parse("07 Mar 2022") } + let(:billing_time) { :anniversary } + let(:pay_in_advance) { true } + let(:fixed_charge) do + create(:fixed_charge, plan: subscription.plan, charge_model: "standard", properties: {amount: "10"}, units: 10, pay_in_advance: true) + end + + it "creates subscription and fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + expect(invoice.fees.subscription.count).to eq(1) + expect(invoice.fees.fixed_charge.count).to eq(1) + end + end + + describe "#should_create_yearly_subscription_fee?" do + subject(:method_call) { invoice_service.send(:should_create_yearly_subscription_fee?, subscription) } + + let(:timestamp) { DateTime.parse("01 Apr 2022") } + let(:started_at) { DateTime.parse("31 Mar 2021") } + let(:created_at) { started_at } + let(:terminated_at) { nil } + + context "when plan is not yearly" do + let(:interval) { "monthly" } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when plan is yearly" do + let(:interval) { "yearly" } + + context "when plan is pay in arrears" do + let(:pay_in_advance) { false } + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + context "when subscription is terminated" do + it "returns true" do + subscription.status = :terminated + subscription.terminated_at = Time.zone.now + expect(subject).to eq(true) + end + end + + context "when subscription is not terminated" do + context "when it's the first month" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it's not the first month" do + let(:timestamp) { DateTime.parse("01 May 2022") } + + it "returns false when it's not the first month" do + expect(subject).to eq(false) + end + end + end + end + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + context "when subscription is terminated" do + it "returns true" do + subscription.status = :terminated + subscription.terminated_at = Time.zone.now + expect(subject).to eq(true) + end + end + + context "when subscription is not terminated" do + context "when it's the first month" do + let(:timestamp) { DateTime.parse("01 Jan 2023") } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it's not the first month" do + it "returns false when it's not the first month" do + expect(subject).to eq(false) + end + end + end + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + context "when subscription started in the past" do + let(:created_at) { started_at + 1.month } + + context "when it's the first month in yearly period" do + context "when it is not the first month in first yearly period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Jan 2022") + 3.years } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it is the first month in first yearly period" do + let(:timestamp) { DateTime.parse("01 Jan 2022") } + let(:started_at) { DateTime.parse("01 Jan 2022") } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when it's not the first month in yearly period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Feb 2022") + 3.years } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when subscription did not start in the past" do + context "when it's the first month in yearly period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Jan 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns true" do + expect(subject).to eq(true) + end + end + end + + context "when it's not the first month in yearly period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Feb 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + context "when subscription started in the past" do + let(:created_at) { started_at + 1.month } + + context "when it's the first month in yearly period" do + context "when it is not the first month in first yearly period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jun 2022") + 3.years } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it is the first month in first yearly period" do + let(:timestamp) { DateTime.parse("01 Jun 2022") } + let(:started_at) { DateTime.parse("01 Jun 2022") } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when it's not the first month in yearly period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jul 2022") + 3.years } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when subscription did not start in the past" do + context "when it's the first month in yearly period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jun 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns true" do + expect(subject).to eq(true) + end + end + end + + context "when it's not the first month in yearly period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jul 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + end + end + end + end + + describe "#should_create_semiannual_subscription_fee?" do + subject(:method_call) { invoice_service.send(:should_create_semiannual_subscription_fee?, subscription) } + + let(:timestamp) { DateTime.parse("01 Apr 2022") } + let(:started_at) { DateTime.parse("31 Mar 2021") } + let(:created_at) { started_at } + let(:terminated_at) { nil } + + context "when plan is not semiannual" do + let(:interval) { "monthly" } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + context "when plan is pay in arrears" do + let(:pay_in_advance) { false } + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + context "when subscription is terminated" do + it "returns true" do + subscription.status = :terminated + subscription.terminated_at = Time.zone.now + expect(subject).to eq(true) + end + end + + context "when subscription is not terminated" do + context "when it's the first month" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it's the seventh month" do + let(:timestamp) { DateTime.parse("01 Oct 2022") } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it's not the first month" do + let(:timestamp) { DateTime.parse("01 May 2022") } + + it "returns false when it's not the first month" do + expect(subject).to eq(false) + end + end + end + end + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + context "when subscription is terminated" do + it "returns true" do + subscription.status = :terminated + subscription.terminated_at = Time.zone.now + expect(subject).to eq(true) + end + end + + context "when subscription is not terminated" do + context "when it's the first month" do + let(:timestamp) { DateTime.parse("01 Jan 2023") } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it's the seventh month" do + let(:timestamp) { DateTime.parse("01 Jul 2023") } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it's not the first or seventh month" do + let(:timestamp) { DateTime.parse("01 Aug 2023") } + + it "returns false when it's not the first month" do + expect(subject).to eq(false) + end + end + end + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + context "when subscription started in the past" do + let(:created_at) { started_at + 1.month } + + context "when it's the first month in semiannual period" do + context "when it is not the first month in first semiannual period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Jan 2022") + 3.years } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it is the first month in first semiannual period" do + let(:timestamp) { DateTime.parse("01 Jan 2022") } + let(:started_at) { DateTime.parse("01 Jan 2022") } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when it's not the first month in semiannual period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Feb 2022") + 3.years } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when subscription did not start in the past" do + context "when it's the first month in semiannual period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Jan 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns true" do + expect(subject).to eq(true) + end + end + end + + context "when it's not the first month in semiannual period" do + let(:started_at) { DateTime.parse("01 Jan 2022") } + let(:timestamp) { DateTime.parse("01 Feb 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + context "when subscription started in the past" do + let(:created_at) { started_at + 1.month } + + context "when it's the first month in semiannual period" do + context "when it is not the first month in first semiannual period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jun 2022") + 3.years } + + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it is the first month in first semiannual period" do + let(:timestamp) { DateTime.parse("01 Jun 2022") } + let(:started_at) { DateTime.parse("01 Jun 2022") } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when it's not the first month in semiannual period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jul 2022") + 3.years } + + it "returns false" do + expect(subject).to eq(false) + end + end + end + + context "when subscription did not start in the past" do + context "when it's the first month in semiannual period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jun 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns true" do + expect(subject).to eq(true) + end + end + end + + context "when it's not the first month in semiannual period" do + let(:started_at) { DateTime.parse("01 Jun 2022") } + let(:timestamp) { DateTime.parse("01 Jul 2022") + 3.years } + + context "when it has not been billed yet" do + it "returns true" do + expect(subject).to eq(true) + end + end + + context "when it has been billed" do + before do + allow(subscription).to receive(:already_billed?).and_return(true) + end + + it "returns false" do + expect(subject).to eq(false) + end + end + end + end + end + end + end + end +end diff --git a/spec/services/invoices/compute_amounts_from_fees_spec.rb b/spec/services/invoices/compute_amounts_from_fees_spec.rb new file mode 100644 index 0000000..07a36a2 --- /dev/null +++ b/spec/services/invoices/compute_amounts_from_fees_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ComputeAmountsFromFees do + subject(:compute_amounts) { described_class.new(invoice:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + + let(:tax1) { create(:tax, :applied_to_billing_entity, organization:, rate: 10) } + let(:tax2) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + + let(:fee1) { create(:fee, invoice:, amount_cents: 151) } + let(:fee2) { create(:fee, invoice:, amount_cents: 379, precise_coupons_amount_cents: 100) } + + before do + tax1 + tax2 + + fee1 + fee2 + + create(:credit, invoice:, amount_cents: 100) + end + + it "applied taxes to the fees" do + compute_amounts.call + + expect(fee1.reload.applied_taxes.count).to eq(2) + expect(fee1.taxes_rate).to eq(30) + expect(fee1.taxes_amount_cents).to eq(45) # 151 * (10 + 20) / 100 + + expect(fee2.reload.applied_taxes.count).to eq(2) + expect(fee2.taxes_rate).to eq(30) + expect(fee2.taxes_amount_cents).to eq(84) # (379 - 100) * (10 + 20) / 100 + end + + it "sets fees_amount_cents from the list of fees" do + expect { compute_amounts.call }.to change(invoice, :fees_amount_cents).from(0).to(530) + end + + it "sets coupons_amount_cents from the list of fees" do + expect { compute_amounts.call }.to change(invoice, :coupons_amount_cents).from(0).to(100) + end + + it "sets sub_total_excluding_taxes_amount_cents from the list of fees" do + expect { compute_amounts.call }.to change(invoice, :sub_total_excluding_taxes_amount_cents).from(0).to(430) + end + + it "sets taxes_amount_cents from the list of fees" do + expect { compute_amounts.call }.to change(invoice, :taxes_amount_cents).from(0).to(129) + end + + it "sets sub_total_including_taxes_amount_cents" do + expect { compute_amounts.call }.to change(invoice, :sub_total_including_taxes_amount_cents).from(0).to(559) + end + + it "sets total_amount_cents" do + expect { compute_amounts.call }.to change(invoice, :total_amount_cents).from(0).to(559) + end + + context "when invoice is one_off" do + let(:invoice) { create(:invoice, organization:, customer:, invoice_type: :one_off) } + + it "applies taxes to fees regardless of invoice status" do + compute_amounts.call + + expect(fee1.reload.applied_taxes.count).to eq(2) + expect(fee1.taxes_rate).to eq(30) + end + + context "when invoice is pending (deferred tax resolution)" do + let(:invoice) { create(:invoice, :pending, organization:, customer:, invoice_type: :one_off) } + + it "applies taxes to fees" do + compute_amounts.call + + expect(fee1.reload.applied_taxes.count).to eq(2) + end + end + + context "when invoice is failed" do + let(:invoice) { create(:invoice, :failed, organization:, customer:, invoice_type: :one_off) } + + it "applies taxes to fees" do + compute_amounts.call + + expect(fee1.reload.applied_taxes.count).to eq(2) + end + end + end + + context "when invoice is advance_charges" do + let(:invoice) { create(:invoice, organization:, customer:, invoice_type: :advance_charges) } + + it "does not apply taxes to fees" do + compute_amounts.call + + expect(fee1.reload.applied_taxes).to be_empty + end + end + + context "when taxes are fetched from external provider" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:fee2) { create(:fee, invoice: nil) } + + let(:fee_taxes) do + OpenStruct.new( + item_id: fee1.id, + item_code: "lago_default_b2b", + tax_breakdown: [ + OpenStruct.new(name: "tax 1", type: "type1", rate: "0.50", tax_amount: 75.5), + OpenStruct.new(name: "tax 2", type: "type2", rate: "0.30", tax_amount: 45.3) + ] + ) + end + + before do + integration_customer + + invoice.credits.destroy_all + end + + it "creates fee and invoice applied taxes and calculate totals" do + described_class.new(invoice:, provider_taxes: [fee_taxes]).call + + expect(fee1.reload.applied_taxes.count).to eq(2) + expect(fee1.taxes_rate).to eq(80) + expect(fee1.taxes_amount_cents).to eq(121) + + expect(invoice.fees_amount_cents).to eq(151) + expect(invoice.sub_total_excluding_taxes_amount_cents).to eq(151) + expect(invoice.taxes_amount_cents).to eq(121) + expect(invoice.sub_total_including_taxes_amount_cents).to eq(272) + expect(invoice.taxes_rate).to eq(80) + expect(invoice.total_amount_cents).to eq(272) + end + end +end diff --git a/spec/services/invoices/compute_taxes_and_totals_service_spec.rb b/spec/services/invoices/compute_taxes_and_totals_service_spec.rb new file mode 100644 index 0000000..08904cd --- /dev/null +++ b/spec/services/invoices/compute_taxes_and_totals_service_spec.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ComputeTaxesAndTotalsService do + subject(:totals_service) { described_class.new(invoice:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + :finalized, + :with_subscriptions, + customer:, + organization:, + subscriptions: [subscription], + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: "standard", billable_metric:) } + + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:fee_charge) do + create( + :fee, + invoice:, + charge:, + fee_type: :charge, + total_aggregated_units: 100, + amount_cents: 1_000 + ) + end + + before do + fee_subscription + fee_charge + end + + context "when invoice does not exist" do + it "returns an error" do + result = described_class.new(invoice: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when customer has VIES check in progress" do + let(:billing_entity) { create(:billing_entity, organization:, eu_tax_management: true) } + let(:customer) { create(:customer, organization:, billing_entity:) } + + before { create(:pending_vies_check, customer:) } + + it "returns an unknown tax failure" do + result = totals_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::UnknownTaxFailure) + expect(result.error.code).to eq("vies_check_pending") + end + + it "sets invoice status to pending" do + totals_service.call + + expect(invoice.reload.status).to eq("pending") + expect(invoice.reload.tax_status).to eq("pending") + end + + context "when not finalizing" do + subject(:totals_service) { described_class.new(invoice:, finalizing: false) } + + before { invoice.update!(status: :draft) } + + it "does not change invoice status but sets tax_status" do + totals_service.call + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.tax_status).to eq("pending") + end + end + + context "when customer also has tax provider" do + let(:integration) { create(:anrok_integration, organization:) } + + before { create(:anrok_customer, integration:, customer:) } + + it "uses tax provider instead of blocking for VIES" do + expect { totals_service.call } + .to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) + end + + it "does not return vies_check_pending error" do + result = totals_service.call + + expect(result.error.code).to eq("tax_error") + end + end + end + + context "when there is tax provider" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before do + integration_customer + end + + it "enqueues a Invoices::ProviderTaxes::PullTaxesAndApplyJob" do + expect do + totals_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) + end + + it "sets correct statuses on invoice" do + totals_service.call + + expect(invoice.reload.status).to eq("pending") + expect(invoice.reload.tax_status).to eq("pending") + end + + context "when invoice is subscription_gated" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: :payment, timeout_hours: 48, status: :pending}], + plan:, subscription_at: started_at, started_at:, created_at: started_at) + end + + before { invoice.update!(status: :open) } + + it "keeps invoice status as open and sets tax_status to pending" do + totals_service.call + + expect(invoice.reload).to be_open + expect(invoice.reload).to be_tax_pending + end + end + + context "when invoice is draft" do + before { invoice.update!(status: :draft) } + + it "sets only tax status" do + described_class.new(invoice:, finalizing: false).call + + expect(invoice.reload.status).to eq("draft") + expect(invoice.reload.tax_status).to eq("pending") + end + end + + context "when there is no fees" do + let(:fee_subscription) { nil } + let(:fee_charge) { nil } + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::ComputeAmountsFromFees).to receive(:call) + .with(invoice:) + .and_return(result) + end + + it "calls compute amounts service" do + totals_service.call + + expect(Invoices::ComputeAmountsFromFees).to have_received(:call) + end + + it "does not enqueue a Invoices::ProviderTaxes::PullTaxesAndApplyJob" do + expect do + totals_service.call + end.not_to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) + end + end + + context "with zero amount invoice" do + let(:fee_charge) { nil } + let(:result) { BaseService::Result.new } + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 0 + ) + end + + before do + allow(Invoices::ComputeAmountsFromFees).to receive(:call) + .with(invoice:) + .and_return(result) + end + + context "when skip zero amount invoice configuration is used" do + let(:customer) { create(:customer, organization:, finalize_zero_amount_invoice: "skip") } + + it "calls compute amounts service" do + totals_service.call + + expect(Invoices::ComputeAmountsFromFees).to have_received(:call) + end + + it "does not enqueue a Invoices::ProviderTaxes::PullTaxesAndApplyJob" do + expect do + totals_service.call + end.not_to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) + end + end + + context "when finalize zero amount invoice configuration is used" do + let(:customer) { create(:customer, organization:, finalize_zero_amount_invoice: "finalize") } + + it "does not call compute amounts service" do + totals_service.call + + expect(Invoices::ComputeAmountsFromFees).not_to have_received(:call) + end + + it "enqueues a Invoices::ProviderTaxes::PullTaxesAndApplyJob" do + expect do + totals_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) + end + end + end + end + + context "when there is NO tax provider" do + let(:result) { BaseService::Result.new } + + before do + allow(Invoices::ComputeAmountsFromFees).to receive(:call) + .with(invoice:) + .and_return(result) + end + + it "calls the add on create service" do + totals_service.call + + expect(Invoices::ComputeAmountsFromFees).to have_received(:call) + end + end + end +end diff --git a/spec/services/invoices/create_generating_service_spec.rb b/spec/services/invoices/create_generating_service_spec.rb new file mode 100644 index 0000000..dd759ea --- /dev/null +++ b/spec/services/invoices/create_generating_service_spec.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CreateGeneratingService do + subject(:create_service) do + described_class.new(customer:, invoice_type:, currency:, datetime:, charge_in_advance:, invoicing_reason:) + end + + let(:customer) { create(:customer) } + let(:invoice_type) { :one_off } + let(:currency) { "EUR" } + let(:datetime) { Time.current } + let(:charge_in_advance) { false } + let(:invoicing_reason) { "subscription_starting" } + let(:recurring) { false } + + describe "call" do + it "creates an invoice" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to be_persisted + expect(result.invoice).to be_generating + expect(result.invoice.organization).to eq(customer.organization) + expect(result.invoice.customer).to eq(customer) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice).to be_one_off + expect(result.invoice.currency).to eq(currency) + expect(result.invoice.timezone).to eq(customer.applicable_timezone) + expect(result.invoice.issuing_date).to eq(datetime.to_date) + expect(result.invoice.payment_due_date).to eq(datetime.to_date) + expect(result.invoice.net_payment_term).to eq(customer.applicable_net_payment_term) + end + + context "with customer timezone" do + let(:customer) { create(:customer, timezone: "America/Los_Angeles") } + let(:datetime) { Time.zone.parse("2022-11-25 01:00:00") } + + it "assigns the issuing date in the customer timezone" do + result = create_service.call + + expect(result.invoice.timezone).to eq("America/Los_Angeles") + expect(result.invoice.issuing_date.to_s).to eq("2022-11-24") + expect(result.invoice.expected_finalization_date.to_s).to eq("2022-11-24") + end + end + + context "when an explicit billing_entity is passed" do + subject(:create_service) do + described_class.new(customer:, invoice_type:, currency:, datetime:, billing_entity:) + end + + let(:billing_entity) { create(:billing_entity, organization: customer.organization) } + + it "stamps the invoice with the provided billing entity" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice.billing_entity).to eq(billing_entity) + end + end + + context "with applicable net payment term" do + let(:customer) { create(:customer, net_payment_term: 3) } + + it "assigns the payment due date based on the net payment term" do + result = create_service.call + + expect(result.invoice.net_payment_term).to eq(3) + expect(result.invoice.payment_due_date.to_s).to eq((datetime + 3.days).to_date.to_s) + end + end + + context "when a block is passed to the method" do + let(:invoice_type) { :subscription } + let(:subscription) { create(:subscription, customer:, started_at: Time.current - 1.day) } + + it "creates an invoice" do + result = create_service.call do |invoice| + invoice.invoice_subscriptions.create!( + organization: customer.organization, + subscription:, + recurring:, + from_datetime: datetime.beginning_of_month, + to_datetime: datetime.end_of_month, + charges_from_datetime: datetime.end_of_month, + charges_to_datetime: datetime.end_of_month + ) + end + + expect(result).to be_success + expect(result.invoice).to be_persisted + expect(result.invoice).to be_generating + expect(result.invoice.organization).to eq(customer.organization) + expect(result.invoice.customer).to eq(customer) + expect(result.invoice).to be_subscription + expect(result.invoice.currency).to eq(currency) + expect(result.invoice.timezone).to eq(customer.applicable_timezone) + expect(result.invoice.issuing_date).to eq(datetime.to_date) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date) + expect(result.invoice.payment_due_date).to eq(datetime.to_date) + expect(result.invoice.net_payment_term).to eq(customer.applicable_net_payment_term) + + expect(result.invoice.invoice_subscriptions.count).to eq(1) + end + end + + context "when invoice type is subscription" do + let(:invoice_type) { :subscription } + let(:customer) { create(:customer, invoice_grace_period: 3) } + + it "creates an invoice with grace period" do + result = create_service.call + + expect(result.invoice.issuing_date.to_s).to eq((datetime + 3.days).to_date.to_s) + end + + context "when charge pay in advance invoice is generated" do + let(:charge_in_advance) { true } + + it "creates an invoice with correct issuing date" do + result = create_service.call + + expect(result.invoice.issuing_date.to_s).to eq(datetime.to_date.to_s) + expect(result.invoice.expected_finalization_date.to_s).to eq(datetime.to_date.to_s) + end + end + + context "with customer timezone" do + let(:customer) { create(:customer, timezone: "America/Los_Angeles", invoice_grace_period: 3) } + let(:datetime) { Time.zone.parse("2022-11-25 01:00:00") } + + it "assigns the issuing date in the customer timezone" do + result = create_service.call + + expect(result.invoice.timezone).to eq("America/Los_Angeles") + expect(result.invoice.issuing_date.to_s).to eq("2022-11-27") + expect(result.invoice.expected_finalization_date.to_s).to eq("2022-11-27") + end + end + + context "when subscription_gated is true" do + subject(:create_service) do + described_class.new(customer:, invoice_type:, currency:, datetime:, charge_in_advance:, invoicing_reason:, subscription_gated: true) + end + + it "skips grace period and uses current date as issuing date" do + result = create_service.call + + expect(result.invoice.issuing_date.to_s).to eq(datetime.to_date.to_s) + expect(result.invoice.expected_finalization_date.to_s).to eq(datetime.to_date.to_s) + end + end + end + + context "when customer is a partner account", :premium do + let(:customer) { create(:customer, account_type: "partner") } + + it "returns a failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + context "when revenue share premium feature is enabled" do + let(:customer) { create(:customer, organization:, account_type: "partner") } + + let(:organization) do + create(:organization, premium_integrations: ["revenue_share"]) + end + + it "creates an invoice with self billed" do + result = create_service.call + + expect(result.invoice.self_billed).to eq(true) + end + end + end + + context "with issuing date preferences" do + let(:customer) do + create( + :customer, + subscription_invoice_issuing_date_anchor:, + subscription_invoice_issuing_date_adjustment:, + invoice_grace_period: + ) + end + + let(:invoice_type) { :subscription } + let(:invoicing_reason) { "subscription_periodic" } + let(:invoice_grace_period) { 3 } + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the current billing period end date" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date - 1.day) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date + 3.days) + end + + context "with no invoice_grace_period" do + let(:invoice_grace_period) { 0 } + + it "sets issuing_date to the current billing period end date" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date - 1.day) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date) + end + end + end + + context "with current_period_end + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the current billing period end date + grace period" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date + 3.days) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date + 3.days) + end + + context "with no invoice_grace_period" do + let(:invoice_grace_period) { 0 } + + it "sets issuing_date to the current billing period end date" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date - 1.day) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date) + end + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the next billing period start date" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date + 3.days) + end + + context "with no invoice_grace_period" do + let(:invoice_grace_period) { 0 } + + it "sets issuing_date to the next billing period start date" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date) + end + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the next billing period start date + grace period" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date + 3.days) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date + 3.days) + end + + context "with no invoice_grace_period" do + let(:invoice_grace_period) { 0 } + + it "sets issuing_date to the next billing period start date" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date) + end + end + end + + context "with no preferences set on the customer level" do + let(:billing_entity) do + create( + :billing_entity, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + ) + end + + let(:customer) { create(:customer, billing_entity:) } + + it "uses billing_entity preferences" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date - 1.day) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date + 3.days) + end + end + + context "when invoice is not recurring" do + let(:invoicing_reason) { "subscription_starting" } + + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "ignores all issuing date preferences" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date + 3.days) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date + 3.days) + end + end + + context "with a non-subscription invoice" do + let(:invoice_type) { :one_off } + + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:invoice_grace_period) { 3 } + + it "does not include invoice_grace_period" do + result = create_service.call + + expect(result.invoice.issuing_date).to eq(datetime.to_date) + expect(result.invoice.expected_finalization_date).to eq(datetime.to_date) + end + end + end + end +end diff --git a/spec/services/invoices/create_invoice_subscription_service_spec.rb b/spec/services/invoices/create_invoice_subscription_service_spec.rb new file mode 100644 index 0000000..275eeae --- /dev/null +++ b/spec/services/invoices/create_invoice_subscription_service_spec.rb @@ -0,0 +1,310 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CreateInvoiceSubscriptionService do + subject(:create_service) { described_class.new(invoice:, subscriptions:, timestamp:, invoicing_reason:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) { create(:invoice, organization:, customer:, status: :generating) } + let(:subscriptions) { [subscription] } + let(:timestamp) { Time.zone.parse("2022-03-07T00:00:00") } + let(:invoicing_reason) { :subscription_periodic } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + billing_time:, + subscription_at:, + started_at:, + created_at:, + status:, + terminated_at: + ) + end + + let(:started_at) { Time.zone.parse("2021-06-06T00:00:00") } + let(:created_at) { started_at } + let(:subscription_at) { started_at } + let(:terminated_at) { nil } + let(:status) { :active } + let(:plan) { create(:plan, organization:, interval:, pay_in_advance:) } + let(:pay_in_advance) { false } + let(:billing_time) { :anniversary } + let(:interval) { "monthly" } + + describe "#call" do + it "creates invoice subscriptions" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions.count).to eq(1) + + invoice_subscription = result.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + invoice:, + subscription:, + timestamp: match_datetime(timestamp), + from_datetime: match_datetime(Time.zone.parse("2022-02-06 00:00:00")), + to_datetime: match_datetime(Time.zone.parse("2022-03-05 23:59:59")), + charges_from_datetime: match_datetime(Time.zone.parse("2022-02-06 00:00:00")), + charges_to_datetime: match_datetime(Time.zone.parse("2022-03-05 23:59:59")), + fixed_charges_from_datetime: match_datetime(Time.zone.parse("2022-02-06 00:00:00")), + fixed_charges_to_datetime: match_datetime(Time.zone.parse("2022-03-05 23:59:59")), + recurring: true, + invoicing_reason: invoicing_reason.to_s + ) + end + + context "when the plan is pay in advance" do + let(:billing_time) { :calendar } + let(:timestamp) { Time.zone.parse("2023-10-01T00:15:00") } + let(:started_at) { Time.zone.parse("2023-08-01T08:00:01") } + let(:pay_in_advance) { false } + let(:status) { :terminated } + let(:terminated_at) { Time.zone.parse("2023-10-01T00:00:00") } + let(:invoicing_reason) { :subscription_terminating } + + it "creates invoice subscriptions with termination boundaries" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions.count).to eq(1) + + invoice_subscription = result.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + invoice:, + subscription:, + timestamp: match_datetime(timestamp), + from_datetime: match_datetime(Time.zone.parse("2023-09-01T00:00:00")), + to_datetime: match_datetime(Time.zone.parse("2023-09-30T23:59:59")), + charges_from_datetime: match_datetime(Time.zone.parse("2023-09-01T00:00:00")), + charges_to_datetime: match_datetime(Time.zone.parse("2023-09-30T23:59:59")), + fixed_charges_from_datetime: match_datetime(Time.zone.parse("2023-09-01T00:00:00")), + fixed_charges_to_datetime: match_datetime(Time.zone.parse("2023-09-30T23:59:59")), + recurring: false, + invoicing_reason: invoicing_reason.to_s + ) + end + + context "when an existing invoice with the same boundaries" do + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice: old_invoice, + subscription:, + from_datetime: Time.zone.parse("2023-09-01T00:00:00.000Z"), + to_datetime: Time.zone.parse("2023-09-30T23:59:59.999Z").end_of_day, + charges_from_datetime: Time.zone.parse("2023-09-01T00:00:00.000Z"), + charges_to_datetime: Time.zone.parse("2023-09-30T23:59:59.999Z").end_of_day, + fixed_charges_from_datetime: Time.zone.parse("2023-09-01T00:00:00.000Z"), + fixed_charges_to_datetime: Time.zone.parse("2023-09-30T23:59:59.999Z").end_of_day, + recurring: true, + invoicing_reason: "subscription_periodic" + ) + end + + let(:old_invoice) do + create( + :invoice, + created_at: started_at - 3.months, + customer: subscription.customer, + organization: plan.organization + ) + end + + before { invoice_subscription } + + it "creates an invoice subscriptions" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions.count).to eq(1) + + invoice_subscription = result.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + invoice:, + subscription:, + from_datetime: match_datetime(Time.zone.parse("2023-10-01T00:00:00")), + to_datetime: match_datetime(Time.zone.parse("2023-10-01T00:00:00")), + charges_from_datetime: match_datetime(Time.zone.parse("2023-10-01T00:00:00")), + charges_to_datetime: match_datetime(Time.zone.parse("2023-10-01T00:00:00")), + fixed_charges_from_datetime: match_datetime(Time.zone.parse("2023-10-01T00:00:00")), + fixed_charges_to_datetime: match_datetime(Time.zone.parse("2023-10-01T00:00:00")), + recurring: false, + invoicing_reason: invoicing_reason.to_s + ) + end + end + end + + context "when two subscriptions are given" do + let(:subscription2) do + create( + :subscription, + plan:, + customer: subscription.customer, + subscription_at: (Time.zone.now - 2.years).to_date, + started_at: Time.zone.now - 2.years + ) + end + + let(:subscriptions) { [subscription, subscription2] } + + it "creates subscription and charges fees for both" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions.count).to eq(2) + end + + context "when subscriptions are duplicated" do + let(:subscriptions) { [subscription, subscription] } + + it "ensures charges are not duplicated" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions.count).to eq(1) + end + end + end + + context "when recurring and subscription is not active" do + let(:invoicing_reason) { :subscription_periodic } + let(:status) { :terminated } + + it "does not create an invoice subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions).to be_empty + end + end + + context "when invoice_subscription already exists" do + let(:invoicing_reason) { :subscription_periodic } + + let(:date_service) do + Subscriptions::DatesService.new_instance( + subscription, + Time.zone.at(timestamp), + current_usage: false + ) + end + + before do + create( + :invoice_subscription, + subscription:, + recurring: true, + invoicing_reason: invoicing_reason.to_s, + timestamp: timestamp, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime, + fixed_charges_from_datetime: date_service.fixed_charges_from_datetime, + fixed_charges_to_datetime: date_service.fixed_charges_to_datetime + ) + end + + it "returns a service failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("duplicated_invoices") + expect(result.error.error_message).to be_present + end + + context "when plan interval is yearly and charges are not paid on monthly basis" do + let(:plan) do + create(:plan, organization:, interval: "yearly", pay_in_advance: false, bill_charges_monthly: false) + end + + it "returns a service failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("duplicated_invoices") + expect(result.error.error_message).to be_present + end + end + + context "when plan interval is yearly and charges are paid on monthly basis" do + let(:plan) do + create(:plan, organization:, interval: "yearly", pay_in_advance: false, bill_charges_monthly: true) + end + + it "returns a service failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("duplicated_invoices") + expect(result.error.error_message).to be_present + end + end + end + + context "when invoicing reason is upgrading" do + let(:invoicing_reason) { :upgrading } + let(:status) { :terminated } + let(:timestamp) { Time.zone.parse("2023-10-01T00:00:00") } + let(:terminated_at) { timestamp } + + it "creates an invoice subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions.count).to eq(1) + + invoice_subscription = result.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + invoice:, + subscription:, + timestamp: match_datetime(timestamp), + from_datetime: match_datetime(Time.zone.parse("2023-09-06T00:00:00")), + to_datetime: match_datetime(timestamp), + charges_from_datetime: match_datetime(Time.zone.parse("2023-09-06T00:00:00")), + charges_to_datetime: match_datetime(timestamp), + fixed_charges_from_datetime: match_datetime(Time.zone.parse("2023-09-06T00:00:00")), + fixed_charges_to_datetime: match_datetime(timestamp), + recurring: false, + invoicing_reason: "subscription_terminating" + ) + end + end + + context "when invoicing reason is progressive_billing" do + let(:invoicing_reason) { :progressive_billing } + let(:timestamp) { Time.zone.parse("2023-10-01T00:00:00") } + + it "creates an invoice subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice_subscriptions.count).to eq(1) + + invoice_subscription = result.invoice_subscriptions.first + expect(invoice_subscription).to have_attributes( + invoice:, + subscription:, + timestamp: match_datetime(timestamp), + charges_from_datetime: match_datetime(Time.zone.parse("2023-09-06T00:00:00")), + charges_to_datetime: match_datetime("2023-10-05T23:59:59"), + fixed_charges_from_datetime: match_datetime(Time.zone.parse("2023-09-06T00:00:00")), + fixed_charges_to_datetime: match_datetime("2023-10-05T23:59:59"), + recurring: false, + invoicing_reason: "progressive_billing" + ) + end + end + end +end diff --git a/spec/services/invoices/create_one_off_service_spec.rb b/spec/services/invoices/create_one_off_service_spec.rb new file mode 100644 index 0000000..7de904e --- /dev/null +++ b/spec/services/invoices/create_one_off_service_spec.rb @@ -0,0 +1,510 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CreateOneOffService do + let(:args) { {customer:, timestamp: timestamp.to_i, fees:, currency:} } + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:tax) { create(:tax, :applied_to_billing_entity, billing_entity:, organization:, rate: 20) } + let(:currency) { "EUR" } + let(:add_on_first) { create(:add_on, organization:) } + let(:add_on_second) { create(:add_on, amount_cents: 400, organization:) } + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123" + }, + { + add_on_code: add_on_second.code + } + ] + end + + describe "call" do + before do + tax + + allow(SegmentTrackJob).to receive(:perform_later) + allow(Invoices::TransitionToFinalStatusService).to receive(:call).and_call_original + CurrentContext.source = "api" + end + + it "creates an invoice" do + result = described_class.call(**args) + + expect(result).to be_success + + expect(result.invoice.issuing_date.to_date).to eq(timestamp) + expect(result.invoice.invoice_type).to eq("one_off") + expect(result.invoice.payment_status).to eq("pending") + expect(result.invoice.fees.where(fee_type: :add_on).count).to eq(2) + expect(result.invoice.fees.pluck(:description)).to contain_exactly("desc-123", add_on_second.description) + + expect(result.invoice.currency).to eq("EUR") + expect(result.invoice.fees_amount_cents).to eq(2800) + + expect(result.invoice.taxes_amount_cents).to eq(560) + expect(result.invoice.taxes_rate).to eq(20) + expect(result.invoice.applied_taxes.count).to eq(1) + + expect(result.invoice.total_amount_cents).to eq(3360) + expect(result.invoice.voided_invoice_id).to be_nil + + expect(result.invoice).to be_finalized + expect(Invoices::TransitionToFinalStatusService).to have_received(:call).with(invoice: result.invoice) + expect(result.invoice.applied_invoice_custom_sections.count).to eq(0) + end + + context "when voided invoice id is passed" do + let(:voided_invoice_id) { SecureRandom.uuid } + let(:args) { {customer:, timestamp: timestamp.to_i, fees:, currency:, voided_invoice_id:} } + + it "creates an invoice" do + result = described_class.call(**args) + + expect(result).to be_success + expect(result.invoice.voided_invoice_id).to eq(voided_invoice_id) + end + end + + context "with custom sections" do + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + let(:args) do + { + customer:, + timestamp: timestamp.to_i, + fees:, + currency:, + invoice_custom_section: + } + end + + context "when custom section id is passed" do + let(:invoice_custom_section) do + { + invoice_custom_section_codes: [section_1.code] + } + end + + it "creates the invoice correctly with sections" do + result = described_class.call(**args) + + expect(result).to be_success + expect(result.invoice).to be_finalized + expect(result.invoice.applied_invoice_custom_sections.pluck(:code)).to eq([section_1.code]) + end + end + + context "when custom section needs to be skipped" do + let(:invoice_custom_section) do + { + invoice_custom_section_codes: [section_1.code], + skip_invoice_custom_sections: true + } + end + + it "creates the invoice correctly without sections" do + result = described_class.call(**args) + + expect(result).to be_success + expect(result.invoice).to be_finalized + expect(result.invoice.applied_invoice_custom_sections.count).to eq(0) + end + end + end + + it_behaves_like "syncs invoice" do + let(:service_call) { described_class.call(**args) } + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { described_class.call(**args) } + end + + it "calls SegmentTrackJob" do + invoice = described_class.call(**args).invoice + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + described_class.call(**args) + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + context "when skip_payment is true" do + it "does not create a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + described_class.call(**args.merge(skip_psp: true)) + + expect(Invoices::Payments::CreateService).not_to have_received(:call_async) + end + end + + it "enqueues a SendWebhookJob" do + expect do + described_class.call(**args) + end.to have_enqueued_job(SendWebhookJob) + end + + it "enqueues GenerateDocumentsJob with email false" do + expect do + described_class.call(**args) + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + context "when there is tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json") + json = File.read(p) + + # setting item_id based on the test example + response = JSON.parse(json) + response["succeededInvoices"].first["fees"].first["item_id"] = "fee_id_1" + response["succeededInvoices"].first["fees"].first["tax_breakdown"].first["tax_amount"] = 240 + response["succeededInvoices"].first["fees"].last["item_id"] = "fee_id_2" + response["succeededInvoices"].first["fees"].last["tax_breakdown"].first["tax_amount"] = 60 + + response.to_json + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + allow_any_instance_of(Fee).to receive(:id).and_wrap_original do |m, *args| # rubocop:disable RSpec/AnyInstance + fee = m.receiver + if fee.add_on_id == add_on_first.id + "fee_id_1" + elsif fee.add_on_id == add_on_second.id + "fee_id_2" + else + m.call(*args) + end + end + end + + it "creates a pending invoice for async tax resolution" do + result = described_class.call(**args) + + expect(result).to be_success + + expect(result.invoice.issuing_date.to_date).to eq(timestamp) + expect(result.invoice.invoice_type).to eq("one_off") + expect(result.invoice.status).to eq("pending") + expect(result.invoice.tax_status).to eq("pending") + expect(result.invoice.fees.where(fee_type: :add_on).count).to eq(2) + expect(result.invoice.fees.pluck(:description)).to contain_exactly("desc-123", add_on_second.description) + + expect(result.invoice.currency).to eq("EUR") + expect(result.invoice.fees_amount_cents).to eq(2800) # 2400 + 400 + end + + it "does not produce an activity log" do + result = described_class.call(**args) + + expect(Utils::ActivityLog).not_to have_produced("invoice.one_off_created").with(result.invoice) + end + end + + context "when invoice amount in cents is zero" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 0, + units: 2, + description: "desc-123" + } + ] + end + + it "creates a payment_succeeded invoice" do + result = described_class.call(**args) + + expect(result).to be_success + + expect(result.invoice.issuing_date.to_date).to eq(timestamp) + expect(result.invoice.invoice_type).to eq("one_off") + expect(result.invoice.payment_status).to eq("succeeded") + expect(result.invoice.fees.where(fee_type: :add_on).count).to eq(1) + expect(result.invoice.fees.pluck(:description)).to contain_exactly("desc-123") + + expect(result.invoice.currency).to eq("EUR") + expect(result.invoice.fees_amount_cents).to eq(0) + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.taxes_rate).to eq(20) + expect(result.invoice.total_amount_cents).to eq(0) + + expect(result.invoice).to be_finalized + end + end + + context "with lago_premium", :premium do + it "enqueues GenerateDocumentsJob with email true" do + expect do + described_class.call(**args) + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when organization does not have right email settings" do + before { customer.billing_entity.update!(email_settings: []) } + + it "enqueues GenerateDocumentsJob with email false" do + expect do + described_class.call(**args) + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + context "with customer timezone" do + before { customer.update!(timezone: "America/Los_Angeles") } + + let(:timestamp) { DateTime.parse("2022-11-25 01:00:00") } + + it "assigns the issuing date in the customer timezone" do + result = described_class.call(**args) + + expect(result.invoice.issuing_date.to_s).to eq("2022-11-24") + end + end + + context "when currency does not match" do + let(:currency) { "NOK" } + + it "fails" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:currency) + expect(result.error.messages[:currency]).to include("currencies_does_not_match") + end + end + + context "when currency does not present" do + let(:currency) { nil } + + before { customer.update!(currency: nil) } + + it "fails" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:currency) + expect(result.error.messages[:currency]).to include("value_is_mandatory") + end + end + + context "when customer is not found" do + let(:customer) { nil } + + it "returns a not found error" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("customer_not_found") + end + end + + context "when fees are blank" do + let(:fees) { [] } + + it "returns a not found error" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("fees_not_found") + end + end + + context "with invalid payment method" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:args) do + { + customer:, + timestamp: timestamp.to_i, + fees:, + currency:, + payment_method_params: + } + end + + before { payment_method } + + context "when type is invalid" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "fails" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when ID is invalid" do + let(:payment_method_params) do + { + payment_method_id: "invalid", + payment_method_type: "provider" + } + end + + it "fails" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + + context "when multi_entity_billing feature flag is enabled" do + let(:other_billing_entity) { create(:billing_entity, organization:) } + + before do + organization.enable_feature_flag!(:multi_entity_billing) + create(:tax, :applied_to_billing_entity, billing_entity: other_billing_entity, organization:, rate: 20) + end + + context "when billing_entity_id is provided" do + let(:args) { {customer:, timestamp: timestamp.to_i, fees:, currency:, billing_entity_id: other_billing_entity.id} } + + it "stamps the invoice with the resolved billing entity" do + result = described_class.call(**args) + + expect(result).to be_success + expect(result.invoice.billing_entity).to eq(other_billing_entity) + end + end + + context "when billing_entity_code is provided" do + let(:args) { {customer:, timestamp: timestamp.to_i, fees:, currency:, billing_entity_code: other_billing_entity.code} } + + it "stamps the invoice with the resolved billing entity" do + result = described_class.call(**args) + + expect(result).to be_success + expect(result.invoice.billing_entity).to eq(other_billing_entity) + end + end + + context "when neither billing_entity_id nor billing_entity_code is provided" do + it "falls back to the customer's billing entity" do + result = described_class.call(**args) + + expect(result).to be_success + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + end + end + + context "when billing_entity_id is unknown" do + let(:args) { {customer:, timestamp: timestamp.to_i, fees:, currency:, billing_entity_id: SecureRandom.uuid} } + + it "returns a not found error" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + end + end + + context "when billing_entity_code is unknown" do + let(:args) { {customer:, timestamp: timestamp.to_i, fees:, currency:, billing_entity_code: "unknown_code"} } + + it "returns a not found error" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + end + end + end + + context "when multi_entity_billing feature flag is disabled" do + let(:other_billing_entity) { create(:billing_entity, organization:) } + let(:args) { {customer:, timestamp: timestamp.to_i, fees:, currency:, billing_entity_id: other_billing_entity.id} } + + it "ignores the billing_entity param and falls back to the customer's billing entity" do + result = described_class.call(**args) + + expect(result).to be_success + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + end + end + + context "when add_on_code is invalid" do + let(:fees) do + [ + { + add_on_code: add_on_first.code, + unit_amount_cents: 1200, + units: 2, + description: "desc-123" + }, + { + add_on_code: "invalid" + } + ] + end + + it "returns a not found error" do + result = described_class.call(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("add_on_not_found") + end + end + end +end diff --git a/spec/services/invoices/create_pay_in_advance_charge_service_spec.rb b/spec/services/invoices/create_pay_in_advance_charge_service_spec.rb new file mode 100644 index 0000000..c6e9450 --- /dev/null +++ b/spec/services/invoices/create_pay_in_advance_charge_service_spec.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CreatePayInAdvanceChargeService do + subject(:invoice_service) do + described_class.new(charge:, event:, timestamp: timestamp.to_i) + end + + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:organization) { create(:organization) } + let(:billing_entity) { customer.billing_entity } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:charge) { create(:standard_charge, :pay_in_advance, billable_metric:, plan:) } + let(:charge_filter) { nil } + + let(:email_settings) { ["invoice.finalized", "credit_note.created"] } + + let(:event) do + Events::CommonFactory.new_instance( + source: create( + :event, + external_subscription_id: subscription.external_id, + external_customer_id: customer.external_id, + organization_id: organization.id + ) + ) + end + + before do + create(:tax, :applied_to_billing_entity, organization:) + billing_entity.update!(email_settings:) + end + + describe "#call" do + let(:aggregation_result) do + BaseService::Result.new.tap do |result| + result.aggregation = 9 + result.count = 4 + result.options = {} + end + end + + let(:charge_result) do + BaseService::Result.new.tap do |result| + result.amount = 10 + result.precise_amount = 10.0 + result.unit_amount = 0.01111111111 + result.count = 1 + result.units = 9 + result.amount_details = {} + end + end + + before do + allow(Charges::PayInAdvanceAggregationService).to receive(:call) + .with(charge:, boundaries: BillingPeriodBoundaries, properties: Hash, event:, charge_filter:) + .and_return(aggregation_result) + + allow(Charges::ApplyPayInAdvanceChargeModelService).to receive(:call) + .with(charge:, aggregation_result:, properties: Hash) + .and_return(charge_result) + + allow(Invoices::TransitionToFinalStatusService).to receive(:call).and_call_original + end + + it "creates an invoice" do + result = invoice_service.call + + expect(result).to be_success + + expect(result.invoice.issuing_date.to_date).to eq(timestamp) + expect(result.invoice.payment_due_date.to_date).to eq(timestamp) + expect(result.invoice.organization_id).to eq(organization.id) + expect(result.invoice.customer_id).to eq(customer.id) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.payment_status).to eq("pending") + + expect(result.invoice.fees.where(fee_type: :charge).count).to eq(1) + expect(result.invoice.fees.first).to have_attributes( + subscription:, + charge:, + amount_cents: 10, + precise_amount_cents: 10.0, + amount_currency: "EUR", + taxes_rate: 20.0, + taxes_amount_cents: 2, + taxes_precise_amount_cents: 2.0, + fee_type: "charge", + pay_in_advance: true, + invoiceable: charge, + units: 9, + properties: Hash, + events_count: 1, + charge_filter: nil, + pay_in_advance_event_id: event.id, + payment_status: "pending", + unit_amount_cents: 1, + precise_unit_amount: 0.01111111111 + ) + + expect(result.invoice.currency).to eq(subscription.plan_amount_currency) + expect(result.invoice.fees_amount_cents).to eq(10) + + expect(result.invoice.taxes_amount_cents).to eq(2) + expect(result.invoice.taxes_rate).to eq(20) + expect(result.invoice.applied_taxes.count).to eq(1) + + expect(result.invoice.total_amount_cents).to eq(12) + + expect(Invoices::TransitionToFinalStatusService).to have_received(:call).with(invoice: result.invoice) + expect(result.invoice).to be_finalized + end + + it "creates InvoiceSubscription object" do + expect { invoice_service.call.invoice }.to change(InvoiceSubscription, :count).by(1) + end + + it "calls SegmentTrackJob" do + invoice = invoice_service.call.invoice + + expect(SegmentTrackJob).to have_been_enqueued.with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + invoice_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + it "enqueues a SendWebhookJob for the invoice" do + expect do + invoice_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.created", Invoice) + end + + it "enqueues a SendWebhookJob for the fees" do + expect do + invoice_service.call + end.to have_enqueued_job(SendWebhookJob).with("fee.created", Fee) + end + + it "produces an activity log" do + invoice = described_class.call(charge:, event:, timestamp: timestamp.to_i).invoice + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + it "enqueues GenerateDocumentsJob with email false" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + context "with lago_premium", :premium do + it "enqueues GenerateDocumentsJob with email true" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when organization does not have right email settings" do + let(:email_settings) { [] } + + it "enqueues GenerateDocumentsJob with email false" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + context "with customer timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/Los_Angeles") } + let(:timestamp) { DateTime.parse("2022-11-25 01:00:00") } + + it "assigns the issuing date in the customer timezone" do + result = invoice_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2022-11-24") + expect(result.invoice.payment_due_date.to_s).to eq("2022-11-24") + end + end + + context "when there is tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(p) + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + allow_any_instance_of(Fee).to receive(:id).and_return("lago_fee_id") # rubocop:disable RSpec/AnyInstance + end + + it "creates a pending invoice for async tax resolution" do + result = invoice_service.call + + expect(result).to be_success + + expect(result.invoice.status).to eq("pending") + expect(result.invoice.tax_status).to eq("pending") + expect(result.invoice.fees_amount_cents).to eq(10) + end + + it "enqueues fee webhooks but not invoice webhooks" do + invoice_service.call + + expect(SendWebhookJob).to have_been_enqueued.with("fee.created", anything) + expect(SendWebhookJob).not_to have_been_enqueued.with("invoice.created", anything) + end + end + + context "with grace period" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:timestamp) { DateTime.parse("2022-11-25 08:00:00") } + + it "assigns the correct issuing date" do + result = invoice_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2022-11-25") + end + end + + context "when customer has wallet with positive balance" do + before { create(:wallet, :with_inbound_transaction, customer:, balance_cents: 100, credits_balance: 100) } + + it "uses the prepaid credits" do + allow(Credits::AppliedPrepaidCreditsService).to receive(:call).and_call_original + + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.total_amount_cents).to eq(0) + expect(result.invoice.prepaid_credit_amount_cents).to eq(12) + + expect(Credits::AppliedPrepaidCreditsService).to have_received(:call).with( + invoice: result.invoice + ) + end + end + + context "when invoice total amount cents is zero" do + before { create(:credit_note, customer:) } + + it "does not call apply prepaid credits service" do + allow(Credits::AppliedPrepaidCreditsService).to receive(:call).and_call_original + + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.total_amount_cents).to eq(0) + expect(result.invoice.prepaid_credit_amount_cents).to eq(0) + + expect(Credits::AppliedPrepaidCreditsService).not_to have_received(:call) + end + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.call } + end + + context "when an error occurs" do + context "with a stale object error" do + before do + create(:wallet, customer:, balance_cents: 100) + end + + it "propagates the error" do + allow_any_instance_of(Credits::AppliedPrepaidCreditsService) # rubocop:disable RSpec/AnyInstance + .to receive(:call).and_raise(ActiveRecord::StaleObjectError) + + expect { invoice_service.call }.to raise_error(ActiveRecord::StaleObjectError) + end + end + + context "with a failed to acquire lock error" do + it "propagates the error" do + allow_any_instance_of(Credits::AppliedPrepaidCreditsService) # rubocop:disable RSpec/AnyInstance + .to receive(:call).and_raise(Customers::FailedToAcquireLock) + + expect { invoice_service.call }.to raise_error(Customers::FailedToAcquireLock) + end + end + + context "with a sequence error" do + it "propagates the error" do + allow_any_instance_of(Invoice) # rubocop:disable RSpec/AnyInstance + .to receive(:save!).and_raise(Sequenced::SequenceError) + + expect { invoice_service.call }.to raise_error(Sequenced::SequenceError) + end + end + end + end +end diff --git a/spec/services/invoices/create_pay_in_advance_fixed_charges_service_spec.rb b/spec/services/invoices/create_pay_in_advance_fixed_charges_service_spec.rb new file mode 100644 index 0000000..a8ab12a --- /dev/null +++ b/spec/services/invoices/create_pay_in_advance_fixed_charges_service_spec.rb @@ -0,0 +1,742 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CreatePayInAdvanceFixedChargesService do + subject(:invoice_service) do + described_class.new(subscription:, timestamp: timestamp.to_i) + end + + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:organization) { create(:organization) } + let(:billing_entity) { customer.billing_entity } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, status: :active) } + let(:fixed_charge) { create(:fixed_charge, :pay_in_advance, plan:, add_on:, units: 10, properties: {amount: "10"}) } + let(:email_settings) { ["invoice.finalized", "credit_note.created"] } + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + ) + end + + before do + create(:tax, :applied_to_billing_entity, organization:) + billing_entity.update!(email_settings:) + fixed_charge_event + end + + describe "#call" do + before do + allow(Invoices::TransitionToFinalStatusService).to receive(:call).and_call_original + end + + it "creates an invoice with fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.issuing_date.to_date).to eq(timestamp.to_date) + expect(result.invoice.payment_due_date.to_date).to eq(timestamp.to_date) + expect(result.invoice.organization_id).to eq(organization.id) + expect(result.invoice.customer_id).to eq(customer.id) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.payment_status).to eq("pending") + expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.fees.first).to have_attributes( + subscription:, + fixed_charge:, + amount_currency: "EUR", + fee_type: "fixed_charge", + pay_in_advance: true, + invoiceable: fixed_charge, + units: 10, + payment_status: "pending", + unit_amount_cents: 1000, + precise_unit_amount: 10.0 + ) + expect(result.invoice.currency).to eq(subscription.plan_amount_currency) + expect(result.invoice.fees_amount_cents).to eq(10000) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(10000) + expect(result.invoice.taxes_amount_cents).to eq(2000) # factory default 20% tax + expect(result.invoice.total_amount_cents).to eq(12000) # fees + taxes + + expect(Invoices::TransitionToFinalStatusService).to have_received(:call).with(invoice: result.invoice) + expect(result.invoice).to be_finalized + end + + it "creates InvoiceSubscription object" do + expect { invoice_service.call }.to change(InvoiceSubscription, :count).by(1) + end + + it "calls SegmentTrackJob" do + invoice = invoice_service.call.invoice + + expect(SegmentTrackJob).to have_been_enqueued.with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + invoice_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + it "enqueues a SendWebhookJob for the invoice" do + expect do + invoice_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.created", Invoice) + end + + it "enqueues a SendWebhookJob for each fee" do + expect do + invoice_service.call + end.to have_enqueued_job(SendWebhookJob).with("fee.created", Fee) + end + + it "produces an activity log" do + invoice = described_class.call(subscription:, timestamp: timestamp.to_i).invoice + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + it "enqueues GenerateDocumentsJob with email false" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + context "when subscription is not active" do + let(:subscription) { create(:subscription, customer:, plan:, status: :pending) } + + it "returns early without creating an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "when there are no fixed charge events" do + let(:fixed_charge_event) { nil } + + it "returns early without creating an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "when the fixed charge is not pay_in_advance" do + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, pay_in_advance: false) } + + it "returns early without creating an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "with multiple fixed charge events" do + let(:add_on2) { create(:add_on, organization:) } + let(:fixed_charge2) { create(:fixed_charge, :pay_in_advance, plan:, add_on: add_on2, units: 5, properties: {amount: "20"}) } + let(:fixed_charge_event2) do + create( + :fixed_charge_event, + subscription:, + fixed_charge: fixed_charge2, + units: 5, + timestamp: Time.zone.at(timestamp) + ) + end + + before { fixed_charge_event2 } + + it "creates fees for all fixed charge events" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.fees.fixed_charge.count).to eq(2) + end + end + + context "when invoice total_amount_cents is zero" do + let(:customer) { create(:customer, organization:, finalize_zero_amount_invoice: "skip") } + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 0, + timestamp: Time.zone.at(timestamp) + ) + end + + it "creates an invoice with succeeded payment status" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.fees.count).to eq(1) + expect(result.invoice.total_amount_cents).to eq(0) + expect(result.invoice).to be_closed + expect(result.invoice.payment_status).to eq("succeeded") + end + + it "does not create payments" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + result = invoice_service.call + + expect(result).to be_success + expect(Invoices::Payments::CreateService).not_to have_received(:call_async) + end + + it "does not enqueue SendWebhookJob" do + expect do + invoice_service.call + end.not_to have_enqueued_job(SendWebhookJob).with("invoice.created", anything) + end + end + + context "with lago_premium", :premium do + it "enqueues GenerateDocumentsJob with email true" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when organization does not have right email settings" do + let(:email_settings) { [] } + + it "enqueues GenerateDocumentsJob with email false" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + context "with customer timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/Los_Angeles") } + let(:timestamp) { DateTime.parse("2022-11-25 01:00:00") } + + it "assigns the issuing date in the customer timezone" do + result = invoice_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2022-11-24") + expect(result.invoice.payment_due_date.to_s).to eq("2022-11-24") + end + end + + context "with grace period" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3) } + let(:timestamp) { DateTime.parse("2022-11-25 08:00:00") } + + it "assigns the correct issuing date ignoring grace period for pay-in-advance" do + result = invoice_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2022-11-25") + end + end + + context "with credit note credits" do + let(:credit_note) do + create( + :credit_note, + customer:, + balance_amount_cents: 500, + credit_amount_cents: 500, + status: :finalized, + credit_status: :available + ) + end + + before { credit_note } + + it "applies credit note credits to the invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.credits.first.credit_note).to eq(credit_note) + expect(result.invoice.credit_notes_amount_cents).to eq(500) + expect(result.invoice.total_amount_cents).to eq(11500) + end + end + + context "with active wallet" do + let(:wallet) { create(:wallet, :with_inbound_transaction, customer:, balance_cents: 1000, credits_balance: 10.0) } + + before { wallet } + + it "applies prepaid credits to the invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.wallet_transactions.first.wallet).to eq(wallet) + expect(result.invoice.prepaid_credit_amount_cents).to eq(1000) + expect(result.invoice.total_amount_cents).to eq(11000) + end + end + + context "with wallet having zero balance" do + let(:wallet) { create(:wallet, customer:, balance_cents: 0, credits_balance: 0) } + + before { wallet } + + it "does not apply prepaid credits" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.wallet_transactions).to be_empty + expect(result.invoice.prepaid_credit_amount_cents).to eq(0) + expect(result.invoice.total_amount_cents).to eq(12000) + end + end + + context "with applied coupons" do + let(:coupon) { create(:coupon, organization:, amount_cents: 500, coupon_type: :fixed_amount) } + let(:applied_coupon) { create(:applied_coupon, coupon:, customer:, amount_cents: 500) } + + before { applied_coupon } + + it "applies coupons to the invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.coupons_amount_cents).to eq(500) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(9500) # 10000 - 500 coupon + expect(result.invoice.total_amount_cents).to eq(11400) + end + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.call } + end + + context "when fee build service fails" do + before do + allow(Fees::BuildPayInAdvanceFixedChargeService) + .to receive(:call) + .and_return(BaseService::Result.new.service_failure!(code: "code", message: "message")) + end + + it "fails with a service failure" do + result = invoice_service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("code") + expect(result.error.message).to eq("code: message") + end + end + + context "when there is integration sync enabled" do + before do + allow_any_instance_of(Invoice).to receive(:should_sync_invoice?).and_return(true) # rubocop:disable RSpec/AnyInstance + end + + it "enqueues the aggregator invoice creation job" do + expect do + invoice_service.call + end.to have_enqueued_job(Integrations::Aggregator::Invoices::CreateJob) + end + end + + context "when there is hubspot integration sync enabled" do + before do + allow_any_instance_of(Invoice).to receive(:should_sync_hubspot_invoice?).and_return(true) # rubocop:disable RSpec/AnyInstance + end + + it "enqueues the hubspot invoice creation job" do + expect do + invoice_service.call + end.to have_enqueued_job(Integrations::Aggregator::Invoices::Hubspot::CreateJob) + end + end + + context "when EU tax management is enabled" do + before { billing_entity.update!(eu_tax_management: true) } + + context "when VIES check is in progress" do + before { create(:pending_vies_check, customer:) } + + it "sets invoice to pending status" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.status).to eq("pending") + expect(result.invoice.tax_status).to eq("pending") + end + + it "does not enqueue invoice webhooks or payments" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + expect { invoice_service.call } + .not_to have_enqueued_job(SendWebhookJob).with("invoice.created", anything) + + expect(Invoices::Payments::CreateService).not_to have_received(:call_async) + end + + it "enqueues SendWebhookJob for each fee" do + expect { invoice_service.call } + .to have_enqueued_job(SendWebhookJob).with("fee.created", Fee) + end + + it "does not produce invoice.created activity log" do + invoice_service.call + + invoice = customer.invoices.order(created_at: :desc).first + expect(Utils::ActivityLog).not_to have_produced("invoice.created").with(invoice) + end + end + + context "when VIES check is not in progress" do + it "finalizes the invoice normally" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_finalized + end + end + end + + context "when there is tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + File.read(p) + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + integration_customer + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + allow_any_instance_of(Fee).to receive(:id).and_return("lago_fee_id") # rubocop:disable RSpec/AnyInstance + end + + it "creates a pending invoice for async tax resolution" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.status).to eq("pending") + expect(result.invoice.tax_status).to eq("pending") + expect(result.invoice.fees_amount_cents).to eq(10_000) + end + + it "enqueues fee webhooks but not invoice webhooks" do + invoice_service.call + + expect(SendWebhookJob).to have_been_enqueued.with("fee.created", anything) + expect(SendWebhookJob).not_to have_been_enqueued.with("invoice.created", anything) + end + end + + context "when an error occurs" do + context "with a record validation failure" do + before do + allow(Fee).to receive(:new).and_return( + Fee.new.tap do |fee| + fee.errors.add(:base, "test error") + allow(fee).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(fee)) + end + ) + end + + it "returns a validation failure result" do + result = invoice_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + + context "with a stale object error" do + before { create(:wallet, customer:, balance_cents: 100) } + + it "propagates the error" do + allow(Credits::AppliedPrepaidCreditsService) + .to receive(:call!) + .and_raise(ActiveRecord::StaleObjectError) + + expect { invoice_service.call }.to raise_error(ActiveRecord::StaleObjectError) + end + end + + context "with a failed to acquire lock error" do + before do + create(:wallet, customer:, balance_cents: 100) + end + + it "propagates the error" do + allow_any_instance_of(Credits::AppliedPrepaidCreditsService) # rubocop:disable RSpec/AnyInstance + .to receive(:call).and_raise(Customers::FailedToAcquireLock) + + expect { invoice_service.call }.to raise_error(Customers::FailedToAcquireLock) + end + end + + context "with a sequence error" do + it "propagates the error" do + allow_any_instance_of(Invoice) # rubocop:disable RSpec/AnyInstance + .to receive(:save!).and_raise(Sequenced::SequenceError) + + expect { invoice_service.call }.to raise_error(Sequenced::SequenceError) + end + end + end + + context "with graduated fixed charge model" do + let(:fixed_charge) do + create( + :fixed_charge, + :graduated, + :pay_in_advance, + plan:, + add_on: + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 12, + timestamp: Time.zone.at(timestamp) + ) + end + + it "creates an invoice with graduated pricing" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.fees.count).to eq(1) + # 10 units * 5 + 200 flat = 250 + # 2 units * 1 + 300 flat = 302 + # total = 552 + expect(result.invoice.fees.fixed_charge.first.amount_cents).to eq(55200) + # 55200 / 12 = 4600 + expect(result.invoice.fees.fixed_charge.first.unit_amount_cents).to eq(4600) + expect(result.invoice.fees.fixed_charge.first.units).to eq(12) + end + end + + context "with prorated fixed charge" do + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:subscription) do + create( + :subscription, + customer:, + plan:, + status: :active, + billing_time: "calendar", + started_at: Time.zone.parse("2025-05-01"), + subscription_at: Time.zone.parse("2025-05-01") + ) + end + let(:timestamp) { Time.zone.parse("2025-05-20").to_i } + let(:fixed_charge) do + create( + :fixed_charge, + :pay_in_advance, + prorated: true, + plan:, + add_on:, + units: 6, + properties: {amount: "31"} + ) + end + + let(:fixed_charge_fee) do + create( + :fixed_charge_fee, + organization:, + subscription:, + fixed_charge:, + units: 6, + properties: { + "fixed_charges_from_datetime" => subscription.started_at, + "fixed_charges_to_datetime" => subscription.started_at.end_of_month + } + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 7, + timestamp: Time.zone.at(timestamp) + ) + end + + before { fixed_charge_fee } + + it "creates an invoice with prorated pricing" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.fees.count).to eq(1) + # (7 units - 6 units) = 1 unit to prorate + # 1 unit * (12 / 31) = 0.3870967741935484 prorated units + # 0.3870967741935484 * 31 = 12.000000000000002 amount + expect(result.invoice.fees.fixed_charge.first.amount_cents).to eq(1200) + expect(result.invoice.fees.fixed_charge.first.precise_amount_cents.round(4)).to eq(1200.0) + expect(result.invoice.fees.fixed_charge.first.units).to eq(1) + end + end + + context "when fixed charge event timestamp does not match" do + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: 10, + timestamp: Time.zone.at(timestamp) + 1.day + ) + end + + it "returns early without creating an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "when subscription has a terminated status" do + let(:subscription) { create(:subscription, customer:, plan:, status: :terminated) } + + it "returns early without creating an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "when subscription has a canceled status" do + let(:subscription) { create(:subscription, customer:, plan:, status: :canceled) } + + it "returns early without creating an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "when subscription is gated" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}], + customer:, plan:, organization:) + end + + it "creates an open invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_open + end + + it "does not send invoice.created webhook" do + invoice_service.call + + expect(SendWebhookJob).not_to have_been_enqueued.with("invoice.created", anything) + end + + it "does not generate documents" do + invoice_service.call + + expect(Invoices::GenerateDocumentsJob).not_to have_been_enqueued + end + + it "triggers payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + invoice_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + context "when invoice total is zero" do + let(:fixed_charge_event) do + create(:fixed_charge_event, subscription:, fixed_charge:, units: 0, + timestamp: Time.zone.at(timestamp)) + end + let(:rule) { subscription.activation_rules.payment.sole } + + it "marks the payment activation rule as satisfied" do + invoice_service.call + + expect(rule.reload).to be_satisfied + end + + it "activates the subscription" do + invoice_service.call + + expect(subscription.reload).to be_active + end + end + + context "when tax is pending" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:rule) { subscription.activation_rules.payment.sole } + + before { integration_customer } + + it "does not fire the zero-amount activation shortcut" do + invoice_service.call + + expect(rule.reload).to be_pending + expect(subscription.reload).to be_incomplete + end + + it "keeps the invoice open" do + result = invoice_service.call + + expect(result.invoice).to be_open + end + end + end + end +end diff --git a/spec/services/invoices/customer_usage_service_spec.rb b/spec/services/invoices/customer_usage_service_spec.rb new file mode 100644 index 0000000..0089034 --- /dev/null +++ b/spec/services/invoices/customer_usage_service_spec.rb @@ -0,0 +1,686 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::CustomerUsageService, cache: :memory do + subject(:usage_service) do + described_class.with_ids( + organization_id: membership.organization_id, + customer_id:, + subscription_id:, + apply_taxes: + ) + end + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:customer) { create(:customer, organization:) } + let(:customer_id) { customer&.id } + let(:subscription_id) { subscription&.id } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:timestamp) { Time.current } + let(:apply_taxes) { true } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + started_at: Time.zone.now - 2.years + ) + end + + let(:billable_metric) do + create(:billable_metric, aggregation_type: "count_agg") + end + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "12.66"} + ) + end + + let(:events) do + create_list( + :event, + 2, + organization:, + subscription:, + customer:, + code: billable_metric.code, + timestamp: + ) + end + + describe "#call" do + before do + events if subscription + charge + Rails.cache.clear + + tax + end + + it "uses the Rails cache" do + key = [ + "charge-usage", + Subscriptions::ChargeCacheService::CACHE_KEY_VERSION, + charge.id, + subscription.id, + charge.updated_at.iso8601 + ].join("/") + + expect do + usage_service.call + end.to change { Rails.cache.exist?(key) }.from(false).to(true) + end + + context "when initializes an invoice" do + let(:current_date) { DateTime.parse("2025-06-15") } + let(:timestamp) { current_date } + + it "initializes an invoice" do + travel_to(current_date) do + result = usage_service.call + + expect(result).to be_success + expect(result.invoice).to be_a(Invoice) + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.total_paid_amount_cents).to eq(0) + expect(result.invoice.prepaid_credit_amount_cents).to eq(0) + + expect(result.usage).to have_attributes( + from_datetime: Time.current.beginning_of_month.iso8601, + to_datetime: Time.current.end_of_month.iso8601, + issuing_date: Time.zone.today.end_of_month.iso8601, + currency: "EUR", + amount_cents: 2532, # 1266 * 2, + taxes_amount_cents: 506, # 1266 * 2 * 0.2 = 506.4 + total_amount_cents: 3038 + ) + expect(result.usage.fees.size).to eq(1) + expect(result.usage.fees.first.charge.invoice_display_name).to eq(charge.invoice_display_name) + end + end + end + + context "when apply_taxes property is set to false" do + let(:current_date) { DateTime.parse("2025-06-15") } + let(:timestamp) { current_date } + let(:apply_taxes) { false } + + it "initializes an invoice" do + travel_to(current_date) do + result = usage_service.call + + expect(result).to be_success + expect(result.invoice).to be_a(Invoice) + + expect(result.usage).to have_attributes( + from_datetime: Time.current.beginning_of_month.iso8601, + to_datetime: Time.current.end_of_month.iso8601, + issuing_date: Time.zone.today.end_of_month.iso8601, + currency: "EUR", + amount_cents: 2532, # 1266 * 2, + taxes_amount_cents: 0, + total_amount_cents: 2532 + ) + expect(result.usage.fees.size).to eq(1) + expect(result.usage.fees.first.charge.invoice_display_name).to eq(charge.invoice_display_name) + end + end + end + + context "when there is tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/draft_invoices" } + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + integration_customer + end + + context "when there is no error" do + let(:current_date) { DateTime.parse("2025-06-15") } + let(:timestamp) { current_date } + + before do + stub_request(:post, endpoint).to_return do |request| + response = JSON.parse(File.read( + Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + )) + + # setting item_id based on the test example + key = JSON.parse(request.body).first["fees"].last["item_key"] + response["succeededInvoices"].first["fees"].last["item_key"] = key + response["succeededInvoices"].first["fees"].last["item_id"] = charge.billable_metric.id + response["succeededInvoices"].first["fees"].last["amount_cents"] = 2532 + + {body: response.to_json} + end + end + + it "initializes an invoice" do + travel_to(current_date) do + result = usage_service.call + + expect(result).to be_success + expect(result.invoice).to be_a(Invoice) + + expect(result.usage).to have_attributes( + from_datetime: Time.current.beginning_of_month.iso8601, + to_datetime: Time.current.end_of_month.iso8601, + issuing_date: Time.zone.today.end_of_month.iso8601, + currency: "EUR", + amount_cents: 2532, # 1266 * 2, + taxes_amount_cents: 253, # 2532 * 0.1 + total_amount_cents: 2785 + ) + expect(result.usage.fees.size).to eq(1) + expect(result.usage.fees.first.charge.invoice_display_name).to eq(charge.invoice_display_name) + end + end + end + + context "when there is error received from the provider" do + before do + stub_request(:post, endpoint).to_return do |request| + response = File.read( + Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + ) + {body: response} + end + end + + it "returns tax error" do + result = usage_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:tax_error]).to eq(["taxDateTooFarInFuture: Service failure"]) + end + end + end + + context "with subscription started in current billing period" do + before { subscription.update!(started_at: Time.zone.today) } + + it "changes the from date of the invoice" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.from_datetime).to eq(subscription.started_at.iso8601) + end + end + + context "when subscription is billed on anniversary date" do + let(:current_date) { DateTime.parse("2022-06-22") } + let(:started_at) { DateTime.parse("2022-03-07") } + let(:subscription_at) { started_at } + let(:timestamp) { current_date } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at:, + started_at:, + billing_time: :anniversary + ) + end + + it "initializes an invoice" do + travel_to(current_date) do + result = usage_service.call + + expect(result).to be_success + expect(result.invoice).to be_a(Invoice) + + expect(result.usage).to have_attributes( + issuing_date: "2022-07-06", + currency: "EUR", + amount_cents: 2532, # 1266 * 2, + taxes_amount_cents: 506, # 1266 * 2 * 0.2 = 506.4 + total_amount_cents: 3038 + ) + + expect(result.usage.from_datetime.to_date.to_s).to eq("2022-06-07") + expect(result.usage.to_datetime.to_date.to_s).to eq("2022-07-06") + expect(result.usage.fees.size).to eq(1) + end + end + end + + context "when customer is not found" do + let(:customer_id) { "foo" } + + it "returns an error" do + result = usage_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + + context "when no_active_subscription" do + let(:subscription) { nil } + + it "fails" do + result = usage_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("no_active_subscription") + end + end + + context "with filter_by_charge_id" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_id: charge.id) + ) + end + + let(:billable_metric_2) { create(:billable_metric, aggregation_type: "count_agg") } + + let(:charge_2) do + create(:standard_charge, plan:, billable_metric: billable_metric_2, properties: {amount: "5"}) + end + + let(:events_2) do + create_list(:event, 3, organization:, subscription:, customer:, code: billable_metric_2.code, timestamp:) + end + + before do + events_2 + charge_2 + end + + it "returns fees only for the specified charge" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.fees.map(&:charge_id).uniq).to eq([charge.id]) + end + end + + context "with filter_by_charge_code" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_code: charge.code) + ) + end + + let(:billable_metric_2) { create(:billable_metric, aggregation_type: "count_agg") } + + let(:charge_2) do + create(:standard_charge, plan:, billable_metric: billable_metric_2, properties: {amount: "5"}) + end + + let(:events_2) do + create_list(:event, 3, organization:, subscription:, customer:, code: billable_metric_2.code, timestamp:) + end + + before do + events_2 + charge_2 + end + + it "returns fees only for the specified charge" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.fees.map(&:charge_id).uniq).to eq([charge.id]) + end + end + + context "with filter_by_group" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_group: {"cloud" => ["aws"]}) + ) + end + + let(:billable_metric) do + create(:billable_metric, aggregation_type: "sum_agg", field_name: "value") + end + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "10", pricing_group_keys: %w[cloud]} + ) + end + + let(:events) { [] } + + before do + create(:event, organization:, subscription:, customer:, code: billable_metric.code, + timestamp:, properties: {cloud: "aws", value: 10}) + create(:event, organization:, subscription:, customer:, code: billable_metric.code, + timestamp:, properties: {cloud: "gcp", value: 5}) + end + + it "returns fees filtered by the group" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.fees.size).to eq(1) + expect(result.usage.fees.first.units).to eq(10) + end + end + + context "with full_usage" do + let(:billable_metric) do + create(:billable_metric, aggregation_type: "count_agg") + end + + let(:charge) do + create(:standard_charge, plan:, billable_metric:, properties: {amount: "10"}) + end + + let(:events) { [] } + + context "when organization does not have lifetime_usage enabled", :premium do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_id: charge.id, full_usage: true) + ) + end + + it "returns a not_allowed failure" do + result = usage_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("full_usage_not_allowed") + end + end + + context "when granular_lifetime_usage is enabled", :premium do + before do + organization.update!(premium_integrations: %w[granular_lifetime_usage]) + end + + context "when filter_by_charge_id is provided and no prorated charges" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_id: charge.id, full_usage: true) + ) + end + + before do + create_list(:event, 2, organization:, subscription:, customer:, code: billable_metric.code, timestamp:) + end + + it "returns usage successfully" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.fees.size).to eq(1) + end + end + + context "when filter_by_charge_code is provided and no prorated charges" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_code: charge.code, full_usage: true) + ) + end + + before do + create_list(:event, 2, organization:, subscription:, customer:, code: billable_metric.code, timestamp:) + end + + it "returns usage successfully" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.fees.size).to eq(1) + end + end + + context "when no filter is provided" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(full_usage: true) + ) + end + + before do + create_list(:event, 2, organization:, subscription:, customer:, code: billable_metric.code, timestamp:) + end + + it "returns a not_allowed failure" do + result = usage_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("full_usage_not_allowed") + end + end + + context "when a different charge is prorated but filtered charge is not" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_id: charge.id, full_usage: true) + ) + end + + let(:prorated_metric) { create(:billable_metric, :recurring, organization:, aggregation_type: "sum_agg", field_name: "value") } + let(:prorated_charge) do + create(:standard_charge, plan:, billable_metric: prorated_metric, prorated: true, properties: {amount: "5"}) + end + + before do + prorated_charge + create_list(:event, 2, organization:, subscription:, customer:, code: billable_metric.code, timestamp:) + end + + it "returns usage successfully" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.fees.size).to eq(1) + end + end + + context "when the filtered charge itself is prorated" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(filter_by_charge_id: prorated_charge.id, full_usage: true) + ) + end + + let(:prorated_metric) { create(:billable_metric, :recurring, organization:, aggregation_type: "sum_agg", field_name: "value") } + let(:prorated_charge) do + create(:standard_charge, plan:, billable_metric: prorated_metric, prorated: true, properties: {amount: "5"}) + end + + before do + prorated_charge + create_list(:event, 2, organization:, subscription:, customer:, code: prorated_metric.code, timestamp:) + end + + it "returns a not_allowed failure" do + result = usage_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("full_usage_not_allowed") + end + end + + context "when subscription started at current period boundary" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: true, + usage_filters: UsageFilters.new(filter_by_charge_id: charge.id, full_usage: true) + ) + end + + let(:current_date) { DateTime.parse("2025-06-15") } + let(:timestamp) { current_date } + + let(:subscription) do + create(:subscription, plan:, customer:, started_at: DateTime.parse("2025-06-01")) + end + + before do + create_list(:event, 2, organization:, subscription:, customer:, code: billable_metric.code, timestamp:) + end + + it "uses the Rails cache" do + key = [ + "charge-usage", + Subscriptions::ChargeCacheService::CACHE_KEY_VERSION, + charge.id, + subscription.id, + charge.updated_at.iso8601 + ].join("/") + + travel_to(current_date) do + expect { usage_service.call }.to change { Rails.cache.exist?(key) }.from(false).to(true) + end + end + end + + context "when subscription started before current period" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: true, + usage_filters: UsageFilters.new(filter_by_charge_id: charge.id, full_usage: true) + ) + end + + let(:current_date) { DateTime.parse("2025-06-15") } + let(:timestamp) { current_date } + + let(:subscription) do + create(:subscription, plan:, customer:, started_at: DateTime.parse("2025-03-01")) + end + + before do + create_list(:event, 2, organization:, subscription:, customer:, code: billable_metric.code, timestamp:) + end + + it "does not use the Rails cache" do + key = [ + "charge-usage", + Subscriptions::ChargeCacheService::CACHE_KEY_VERSION, + charge.id, + subscription.id, + charge.updated_at.iso8601 + ].join("/") + + travel_to(current_date) do + expect { usage_service.call }.not_to change { Rails.cache.exist?(key) } + end + end + end + end + end + + context "with skip_grouping" do + subject(:usage_service) do + described_class.new( + customer:, + subscription:, + apply_taxes: false, + with_cache: false, + usage_filters: UsageFilters.new(skip_grouping: true) + ) + end + + let(:billable_metric) do + create(:billable_metric, aggregation_type: "sum_agg", field_name: "value") + end + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "10", pricing_group_keys: %w[cloud]} + ) + end + + let(:events) { [] } + + before do + create(:event, organization:, subscription:, customer:, code: billable_metric.code, + timestamp:, properties: {cloud: "aws", value: 10}) + create(:event, organization:, subscription:, customer:, code: billable_metric.code, + timestamp:, properties: {cloud: "gcp", value: 5}) + end + + it "returns a single fee with all events aggregated without grouping" do + result = usage_service.call + + expect(result).to be_success + expect(result.usage.fees.size).to eq(1) + expect(result.usage.fees.first.units).to eq(15) + expect(result.usage.fees.first.grouped_by).to eq({}) + end + end + end +end diff --git a/spec/services/invoices/ensure_completed_vies_check_service_spec.rb b/spec/services/invoices/ensure_completed_vies_check_service_spec.rb new file mode 100644 index 0000000..15a2798 --- /dev/null +++ b/spec/services/invoices/ensure_completed_vies_check_service_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::EnsureCompletedViesCheckService do + subject(:result) { described_class.call(invoice:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:invoice) { create(:invoice, customer:, organization:, billing_entity:, status: :generating) } + + describe "#call" do + context "when invoice is nil" do + let(:invoice) { nil } + + it "returns not found failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("invoice_not_found") + end + end + + context "when EU tax management is disabled" do + before { billing_entity.update!(eu_tax_management: false) } + + it "returns success" do + expect(result).to be_success + expect(invoice.reload.status).to eq("generating") + end + + context "when pending_vies_check exists" do + before { create(:pending_vies_check, customer:) } + + it "returns success without changing invoice status" do + expect(result).to be_success + expect(invoice.reload.status).to eq("generating") + end + end + end + + context "when EU tax management is enabled" do + before { billing_entity.update!(eu_tax_management: true) } + + context "when VIES check is not in progress" do + it "returns success" do + expect(result).to be_success + expect(invoice.reload.status).to eq("generating") + end + end + + context "when VIES check is in progress" do + before { create(:pending_vies_check, customer:) } + + it "sets invoice to pending status and returns unknown tax failure" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::UnknownTaxFailure) + expect(result.error.code).to eq("vies_check_pending") + expect(invoice.reload.status).to eq("pending") + expect(invoice.tax_status).to eq("pending") + end + + context "when invoice is subscription_gated" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: :payment, timeout_hours: 48, status: :pending}], + customer:, organization:) + end + let(:invoice) { create(:invoice, :with_subscriptions, customer:, organization:, billing_entity:, status: :open, subscriptions: [subscription]) } + + it "keeps invoice status as open and sets tax_status to pending" do + expect(result).to be_failure + expect(invoice.reload).to be_open + expect(invoice).to be_tax_pending + end + end + + context "when finalizing is false" do + subject(:result) { described_class.call(invoice:, finalizing: false) } + + let(:invoice) { create(:invoice, customer:, organization:, billing_entity:, status: :draft) } + + it "does not change invoice status but sets tax_status to pending" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::UnknownTaxFailure) + expect(result.error.code).to eq("vies_check_pending") + expect(invoice.reload.status).to eq("draft") + expect(invoice.tax_status).to eq("pending") + end + end + end + + context "when customer has provider taxation" do + before { create(:anrok_customer, customer:) } + + it "returns success" do + expect(result).to be_success + expect(invoice.reload.status).to eq("generating") + end + + context "when VIES check is in progress" do + before { create(:pending_vies_check, customer:) } + + it "returns success without changing invoice status" do + expect(result).to be_success + expect(invoice.reload.status).to eq("generating") + end + end + end + end + end +end diff --git a/spec/services/invoices/finalize_batch_service_spec.rb b/spec/services/invoices/finalize_batch_service_spec.rb new file mode 100644 index 0000000..0a5af5f --- /dev/null +++ b/spec/services/invoices/finalize_batch_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::FinalizeBatchService do + subject(:finalize_batch_service) { described_class.new(organization:) } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + + describe "#call_async" do + it "enqueues a job to finalize all draft invoices" do + expect do + finalize_batch_service.call_async + end.to have_enqueued_job(Invoices::FinalizeAllJob) + end + end + + describe "#call" do + let(:finalize_service) { instance_double(Invoices::RefreshDraftAndFinalizeService) } + let(:result) { BaseService::Result.new } + let(:invoice_ids) { invoices.map(&:id) } + let(:invoices) { create_list(:invoice, 3, status: "draft", customer:) } + + before do + invoices + + result.invoice = Invoice.new + + allow(Invoices::RefreshDraftAndFinalizeService).to receive(:new).and_return(finalize_service) + allow(finalize_service).to receive(:call).and_return(result) + end + + it "returns processed invoices that have correct status" do + result = finalize_batch_service.call(invoice_ids) + + expect(result).to be_success + expect(result.invoices.count).to eq(3) + end + + context "when inner service passes error result" do + before do + result.fail_with_error!(BaseService::MethodNotAllowedFailure.new(result, code: "error")) + end + + it "returns an error" do + result = finalize_batch_service.call(invoice_ids) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("error") + end + end + end +end diff --git a/spec/services/invoices/finalize_open_credit_service_spec.rb b/spec/services/invoices/finalize_open_credit_service_spec.rb new file mode 100644 index 0000000..fe23733 --- /dev/null +++ b/spec/services/invoices/finalize_open_credit_service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::FinalizeOpenCreditService do + let(:service) { described_class.new(invoice:) } + + let(:organization) { create(:organization, email_settings: Organization::EMAIL_SETTINGS) } + let(:invoice) { create(:invoice, organization:, invoice_type: "credit", status: :open, payment_due_date: 1.week.ago.to_date) } + + before do + if invoice + allow(invoice).to receive(:should_sync_invoice?).and_return(true) + end + end + + describe ".call" do + it "updates invoice status and enqueues necessary jobs" do + result = described_class.call(invoice:) + + expect(result.invoice.status).to eq("finalized") + expect(result.invoice.issuing_date).to be_today + expect(result.invoice.payment_due_date).to be_today + + expect(SendWebhookJob).to have_been_enqueued.with("invoice.paid_credit_added", result.invoice) + expect(Invoices::GenerateDocumentsJob).to have_been_enqueued.with(invoice: result.invoice, notify: false) + expect(Integrations::Aggregator::Invoices::CreateJob).to have_been_enqueued.with(invoice: result.invoice) + expect(SegmentTrackJob).to have_been_enqueued.with(membership_id: anything, event: "invoice_created", properties: { + organization_id: result.invoice.organization.id, + invoice_id: result.invoice.id, + invoice_type: result.invoice.invoice_type + }) + expect(Utils::ActivityLog).to have_produced("invoice.paid_credit_added").with(invoice) + end + + context "when invoice is already finalized" do + let(:invoice) { create(:invoice, organization:, invoice_type: "credit", status: :finalized) } + + it "does not update invoice status" do + result = service.call + + expect(result.invoice.status).to eq("finalized") + + expect(SendWebhookJob).not_to have_been_enqueued + expect(Invoices::GenerateDocumentsJob).not_to have_been_enqueued + expect(Integrations::Aggregator::Invoices::CreateJob).not_to have_been_enqueued + expect(SegmentTrackJob).not_to have_been_enqueued + end + end + + context "when invoice is not found" do + let(:invoice) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + end +end diff --git a/spec/services/invoices/finalize_pending_vies_invoice_service_spec.rb b/spec/services/invoices/finalize_pending_vies_invoice_service_spec.rb new file mode 100644 index 0000000..1a23865 --- /dev/null +++ b/spec/services/invoices/finalize_pending_vies_invoice_service_spec.rb @@ -0,0 +1,582 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::FinalizePendingViesInvoiceService do + subject(:finalize_service) { described_class.new(invoice:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + + let(:invoice) do + create( + :invoice, + :pending, + :with_subscriptions, + customer:, + billing_entity:, + organization:, + subscriptions: [subscription], + currency: "EUR", + tax_status: "pending", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: "standard", billable_metric:) } + + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:fee_charge) do + create( + :fee, + invoice:, + charge:, + fee_type: :charge, + total_aggregated_units: 100, + amount_cents: 1_000 + ) + end + + before do + fee_subscription + fee_charge + allow(SegmentTrackJob).to receive(:perform_later) + end + + context "when invoice does not exist" do + it "returns an error" do + result = described_class.new(invoice: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice is not pending" do + before { invoice.update!(status: :finalized) } + + it "does not change the invoice" do + expect { finalize_service.call }.not_to change { invoice.reload.attributes } + end + + it "returns success" do + expect(finalize_service.call).to be_success + end + end + + context "when invoice tax_status is not pending" do + before { invoice.update!(tax_status: "succeeded") } + + it "does not change the invoice" do + expect { finalize_service.call }.not_to change { invoice.reload.attributes } + end + + it "returns success" do + expect(finalize_service.call).to be_success + end + end + + context "when customer has tax provider" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before { integration_customer } + + it "does not change the invoice" do + expect { finalize_service.call }.not_to change { invoice.reload.attributes } + end + + it "returns success" do + expect(finalize_service.call).to be_success + end + end + + context "when VIES check is still in progress" do + let(:billing_entity) { create(:billing_entity, organization:, eu_tax_management: true) } + let(:pending_vies_check) { create(:pending_vies_check, customer:) } + + before { pending_vies_check } + + it "does not change the invoice" do + expect { finalize_service.call }.not_to change { invoice.reload.attributes } + end + + it "returns success" do + expect(finalize_service.call).to be_success + end + end + + context "when invoice is finalized successfully" do + it "changes status from pending to finalized" do + expect { finalize_service.call } + .to change { invoice.reload.status }.from("pending").to("finalized") + end + + it "sets tax_status to succeeded" do + expect { finalize_service.call } + .to change { invoice.reload.tax_status }.from("pending").to("succeeded") + end + + it "computes invoice amounts" do + finalize_service.call + + invoice.reload + expect(invoice.fees_amount_cents).to eq(3_000) + expect(invoice.total_amount_cents).to be_positive + end + + it "applies invoice custom sections" do + allow(Invoices::ApplyInvoiceCustomSectionsService).to receive(:call) + + finalize_service.call + + expect(Invoices::ApplyInvoiceCustomSectionsService).to have_received(:call).with(invoice:) + end + + it "sets payment_status to pending when total is positive" do + finalize_service.call + + expect(invoice.reload.payment_status).to eq("pending") + end + + it "enqueues SendWebhookJob" do + expect { finalize_service.call } + .to have_enqueued_job(SendWebhookJob).with("invoice.created", invoice) + end + + it "produces an activity log" do + finalize_service.call + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + it "enqueues Invoices::GenerateDocumentsJob" do + expect { finalize_service.call } + .to have_enqueued_job(Invoices::GenerateDocumentsJob).with(invoice:, notify: false) + end + + it "calls Invoices::Payments::CreateService" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + finalize_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async).with(invoice:, payment_method_params: {}) + end + + it "returns the invoice in result" do + result = finalize_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + end + + context "when customer has accounting integration" do + let(:netsuite_integration) { create(:netsuite_integration, organization:, sync_invoices: true) } + let(:netsuite_customer) { create(:netsuite_customer, integration: netsuite_integration, customer:) } + + before { netsuite_customer } + + it "enqueues Integrations::Aggregator::Invoices::CreateJob" do + expect { finalize_service.call } + .to have_enqueued_job(Integrations::Aggregator::Invoices::CreateJob).with(invoice:) + end + end + + context "when customer has hubspot integration" do + let(:hubspot_integration) { create(:hubspot_integration, organization:, sync_invoices: true) } + let(:hubspot_customer) { create(:hubspot_customer, integration: hubspot_integration, customer:) } + + before { hubspot_customer } + + it "enqueues Integrations::Aggregator::Invoices::Hubspot::CreateJob" do + expect { finalize_service.call } + .to have_enqueued_job(Integrations::Aggregator::Invoices::Hubspot::CreateJob).with(invoice:) + end + end + + context "when total_amount_cents is zero" do + let(:fee_subscription) do + create(:fee, invoice:, subscription:, fee_type: :subscription, amount_cents: 0) + end + let(:fee_charge) { nil } + + it "sets payment_status to succeeded" do + finalize_service.call + + expect(invoice.reload.payment_status).to eq("succeeded") + end + end + end + + context "with issuing_date handling" do + let(:original_issuing_date) { invoice.issuing_date } + + context "when recurring invoice with keep_anchor adjustment" do + before do + # rubocop:disable Rails/SkipsModelValidations + invoice.invoice_subscriptions.update_all(recurring: true) + # rubocop:enable Rails/SkipsModelValidations + customer.update!(subscription_invoice_issuing_date_adjustment: "keep_anchor") + end + + it "keeps the original issuing_date" do + finalize_service.call + + expect(invoice.reload.issuing_date).to eq(original_issuing_date) + end + end + + context "when not keeping anchor" do + before do + # rubocop:disable Rails/SkipsModelValidations + invoice.invoice_subscriptions.update_all(recurring: false) + # rubocop:enable Rails/SkipsModelValidations + end + + it "updates issuing_date to current date" do + freeze_time do + finalize_service.call + + expect(invoice.reload.issuing_date).to eq(Time.current.to_date) + end + end + end + end + + context "with payment_due_date" do + before do + customer.update!(net_payment_term: 30) + end + + it "sets payment_due_date based on issuing_date and net_payment_term" do + freeze_time do + finalize_service.call + + expect(invoice.reload.payment_due_date).to eq(Time.current.to_date + 30.days) + end + end + end + + context "when invoice is one-off" do + let(:add_on) { create(:add_on, organization:, amount_cents: 5000) } + + let(:invoice) do + create( + :invoice, + :pending, + customer:, + billing_entity:, + organization:, + invoice_type: :one_off, + currency: "EUR", + tax_status: "pending", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:fee_subscription) { nil } + let(:fee_charge) { nil } + + let(:fee_add_on) do + create( + :fee, + invoice:, + add_on:, + fee_type: :add_on, + invoiceable: add_on, + amount_cents: 5000 + ) + end + + before do + fee_add_on + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "changes status from pending to finalized" do + expect { finalize_service.call } + .to change { invoice.reload.status }.from("pending").to("finalized") + end + + it "enqueues SendWebhookJob with one_off webhook type" do + expect { finalize_service.call } + .to have_enqueued_job(SendWebhookJob).with("invoice.one_off_created", invoice) + end + + it "produces an activity log with one_off action" do + finalize_service.call + + expect(Utils::ActivityLog).to have_produced("invoice.one_off_created").with(invoice) + end + + it "does not apply credit note credits" do + finalize_service.call + + expect(invoice.reload.credits.credit_note_kind).to be_empty + end + + it "does not apply prepaid credits" do + finalize_service.call + + expect(invoice.reload.wallet_transactions).to be_empty + end + + context "when invoice has a stored payment method" do + let(:payment_method) { create(:payment_method, customer:) } + + let(:invoice) do + create( + :invoice, + :pending, + customer:, + billing_entity:, + organization:, + invoice_type: :one_off, + currency: "EUR", + tax_status: "pending", + issuing_date: Time.zone.at(timestamp).to_date, + payment_method: + ) + end + + it "passes payment_method_params to CreateService" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + finalize_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + .with(invoice:, payment_method_params: {payment_method_id: payment_method.id}) + end + end + + context "when invoice has skip_automatic_payment set" do + let(:invoice) do + create( + :invoice, + :pending, + customer:, + billing_entity:, + organization:, + invoice_type: :one_off, + currency: "EUR", + tax_status: "pending", + issuing_date: Time.zone.at(timestamp).to_date, + skip_automatic_payment: true + ) + end + + it "does not create a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + finalize_service.call + + expect(Invoices::Payments::CreateService).not_to have_received(:call_async) + end + end + end + + context "when invoice is pay-in-advance fixed charges" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, :pay_in_advance, plan:, add_on:, units: 10, properties: {amount: "10"}) } + + let(:invoice) do + create( + :invoice, + :pending, + customer:, + billing_entity:, + organization:, + currency: "EUR", + tax_status: "pending", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + :boundaries, + invoice:, + subscription:, + invoicing_reason: :in_advance_charge + ) + end + + # Override outer let blocks to prevent creating subscription/charge fees + let(:fee_subscription) { nil } + let(:fee_charge) { nil } + + let(:fee_fixed_charge) do + create( + :fee, + invoice:, + subscription:, + fixed_charge:, + invoiceable: fixed_charge, + fee_type: :fixed_charge, + pay_in_advance: true, + amount_cents: 10_000, + units: 10 + ) + end + + before do + invoice_subscription + fee_fixed_charge + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "changes status from pending to finalized" do + expect { finalize_service.call } + .to change { invoice.reload.status }.from("pending").to("finalized") + end + + it "sets tax_status to succeeded" do + expect { finalize_service.call } + .to change { invoice.reload.tax_status }.from("pending").to("succeeded") + end + + it "computes invoice amounts correctly" do + finalize_service.call + + invoice.reload + expect(invoice.fees_amount_cents).to eq(10_000) + expect(invoice.total_amount_cents).to be_positive + end + + it "enqueues SendWebhookJob" do + expect { finalize_service.call } + .to have_enqueued_job(SendWebhookJob).with("invoice.created", invoice) + end + + it "calls Invoices::Payments::CreateService" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + finalize_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async).with(invoice:, payment_method_params: {}) + end + + it "updates issuing_date to current date (not keeping anchor for non-recurring)" do + freeze_time do + finalize_service.call + + expect(invoice.reload.issuing_date).to eq(Time.current.to_date) + end + end + + context "with credit note credits available" do + let(:credit_note) do + create( + :credit_note, + customer:, + balance_amount_cents: 500, + credit_amount_cents: 500, + status: :finalized, + credit_status: :available + ) + end + + before { credit_note } + + it "applies credit note credits to the invoice" do + finalize_service.call + + invoice.reload + expect(invoice.credits.credit_note_kind.first.credit_note).to eq(credit_note) + expect(invoice.credit_notes_amount_cents).to eq(500) + end + end + + context "with active wallet" do + let(:wallet) { create(:wallet, :with_inbound_transaction, customer:, balance_cents: 1000, credits_balance: 10.0) } + + before { wallet } + + it "applies prepaid credits to the invoice" do + finalize_service.call + + invoice.reload + expect(invoice.wallet_transactions.first.wallet).to eq(wallet) + expect(invoice.prepaid_credit_amount_cents).to eq(1000) + end + end + end + + context "when invoice is subscription_gated" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: :payment, timeout_hours: 48, status: :pending}], + customer:, organization:) + end + let(:invoice) do + create( + :invoice, + :with_subscriptions, + customer:, + organization:, + billing_entity:, + status: :open, + tax_status: :pending, + currency: "EUR", + subscriptions: [subscription] + ) + end + + it "allows processing and triggers payment only" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + finalize_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + expect(SendWebhookJob).not_to have_been_enqueued.with("invoice.created", anything) + end + + context "when invoice total is zero after tax computation" do + let(:rule) { subscription.activation_rules.payment.sole } + let(:fee_subscription) do + create(:fee, invoice:, subscription:, fee_type: :subscription, amount_cents: 0) + end + let(:fee_charge) do + create(:fee, invoice:, charge:, fee_type: :charge, total_aggregated_units: 0, amount_cents: 0) + end + + it "marks the payment activation rule as satisfied" do + finalize_service.call + + expect(rule.reload).to be_satisfied + end + + it "activates the subscription" do + finalize_service.call + + expect(subscription.reload).to be_active + end + end + end + end +end diff --git a/spec/services/invoices/finalize_service_spec.rb b/spec/services/invoices/finalize_service_spec.rb new file mode 100644 index 0000000..f0bea16 --- /dev/null +++ b/spec/services/invoices/finalize_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::FinalizeService do + subject(:service) { described_class.new(invoice:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, :draft, customer:, organization:) } + + describe "#call" do + context "when invoice is not yet finalized" do + it "finalizes the invoice" do + result = service.call + + expect(result).to be_success + expect(result.invoice).to be_persisted + expect(result.invoice.reload).to be_finalized + expect(result.invoice.finalized_at).to be_within(1.second).of(Time.current) + end + end + + context "when invoice is already finalized" do + let(:invoice) do + create(:invoice, :finalized, customer:, organization:, finalized_at:) + end + let(:finalized_at) { 2.days.ago } + + it "returns success without changes" do + result = service.call + + expect(result).to be_success + expect(result.invoice).to be_finalized + expect(result.invoice.finalized_at).to eq(finalized_at) + end + end + + context "when invoice is nil" do + let(:invoice) { nil } + + it "returns a not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("invoice") + end + end + + context "when invoice save fails" do + before do + allow(invoice).to receive(:save!).and_raise(ActiveRecord::RecordInvalid.new(invoice)) + end + + it "returns a failure result" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end +end diff --git a/spec/services/invoices/generate_pdf_service_spec.rb b/spec/services/invoices/generate_pdf_service_spec.rb new file mode 100644 index 0000000..4fdc9d4 --- /dev/null +++ b/spec/services/invoices/generate_pdf_service_spec.rb @@ -0,0 +1,218 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::GeneratePdfService do + let(:context) { "graphql" } + let(:organization) { create(:organization, name: "LAGO") } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { create(:invoice, customer:, status: :finalized, organization:) } + let(:credit) { create(:credit, invoice:) } + let(:fees) { create_list(:fee, 3, invoice:) } + let(:invoice_subscription) { create(:invoice_subscription, :boundaries, invoice:, subscription:) } + let(:blank_pdf_path) { Rails.root.join("spec/fixtures/blank.pdf") } + + before do + invoice_subscription + stub_pdf_generation + end + + describe "#call" do + it "generates the invoice synchronously" do + result = described_class.call(invoice:, context:) + + expect(result.invoice.file).to be_present + end + + it "calls the SendWebhook job" do + expect { described_class.call(invoice:, context:) }.to have_enqueued_job(SendWebhookJob) + end + + it "produces an activity log" do + result = described_class.call(invoice:, context:) + + expect(Utils::ActivityLog).to have_produced("invoice.generated").with(result.invoice) + end + + context "with not found invoice" do + let(:invoice_subscription) { nil } + let(:invoice) { nil } + + it "returns a result with error" do + result = described_class.call(invoice:, context:) + + expect(result.success).to be_falsey + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice is draft" do + let(:invoice) { create(:invoice, customer:, status: :draft, organization:) } + + it "returns a result with error" do + result = described_class.call(invoice:, context:) + + expect(result.success).to be_falsey + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("is_draft") + end + end + + context "with already generated file" do + before do + invoice.file.attach( + io: StringIO.new(File.read(blank_pdf_path)), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + end + + it "does not generate the pdf" do + allow(LagoHttpClient::Client).to receive(:new) + + described_class.call(invoice:, context:) + + expect(LagoHttpClient::Client).not_to have_received(:new) + end + end + + context "when a billable metric is deleted" do + let(:billable_metric) { create(:billable_metric, :deleted) } + let(:fees) { [create(:charge_fee, subscription:, invoice:, charge_filter:, charge:, amount_cents: 10)] } + let(:charge) { create(:standard_charge, :deleted, billable_metric:) } + let(:billable_metric_filter) { create(:billable_metric_filter, :deleted, billable_metric:) } + let(:charge_filter) do + create(:charge_filter, :deleted, charge_id: charge.id, properties: {amount: "10"}) + end + let(:charge_filter_value) do + create( + :charge_filter_value, + :deleted, + charge_filter:, + billable_metric_filter:, + values: [billable_metric_filter.values.first] + ) + end + + before do + charge_filter_value + end + + it "generates the invoice synchronously" do + result = described_class.call(invoice:, context:) + + expect(result.invoice.file).to be_present + end + end + + context "when invoice is self billed" do + let(:invoice) do + create(:invoice, :self_billed, customer:, status: :finalized, organization:) + end + + let(:pdf_generator) { instance_double(Utils::PdfGenerator, call_with_middlewares: pdf_response) } + + let(:pdf_response) do + BaseService::Result.new.tap { |r| r.io = StringIO.new(pdf_content) } + end + + let(:pdf_content) { File.read(blank_pdf_path) } + + before do + allow(Utils::PdfGenerator).to receive(:new).and_return(pdf_generator) + end + + it "calls the self billed template" do + described_class.call(invoice:, context:) + + expect(Utils::PdfGenerator).to have_received(:new).with(template: "invoices/v4/self_billed", context: invoice) + end + end + + context "when in API context" do + let(:context) { "api" } + + it "calls the SendWebhook job" do + expect { described_class.call(invoice:, context:) }.to have_enqueued_job(SendWebhookJob) + end + end + + context "when in Admin context" do + let(:context) { "admin" } + + before do + invoice.file.attach( + io: StringIO.new(File.read(blank_pdf_path)), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + end + + it "generates the invoice synchronously" do + result = described_class.call(invoice:, context:) + + expect(result.invoice.file.filename.to_s).not_to eq("invoice.pdf") + end + end + + context "when create temp files" do + let(:pdf_tempfile) { instance_double(Tempfile).as_null_object } + + before do + allow(pdf_tempfile).to receive(:path).and_return(blank_pdf_path) + allow(Tempfile).to receive(:new).and_call_original + allow(Tempfile).to receive(:new).with([invoice.number, ".pdf"]).and_return(pdf_tempfile) + end + + it "unlink the pdf file at the end" do + described_class.call(invoice:, context:) + + expect(pdf_tempfile).to have_received(:unlink) + end + + context "with einvoicing enabled" do + let(:xml_tempfile) { instance_double(Tempfile).as_null_object } + + before do + invoice.billing_entity.update(country: "FR", einvoicing: true) + + allow(Tempfile).to receive(:new).with([invoice.number, ".xml"]).and_return(xml_tempfile) + allow(Utils::PdfAttachmentService).to receive(:call) + end + + it "unlink all files at the end" do + described_class.call(invoice:, context:) + + expect(pdf_tempfile).to have_received(:unlink) + expect(xml_tempfile).to have_received(:unlink) + end + end + end + + context "when einvoicing is enabled" do + let(:fake_xml) { "content" } + let(:country) { nil } + let(:create_xml_result) { BaseService::Result.new.tap { |result| result.xml = fake_xml } } + + before do + invoice.billing_entity.update(country:, einvoicing: true) + + allow(EInvoices::Invoices::FacturX::CreateService).to receive(:call).and_return(create_xml_result) + allow(Utils::PdfAttachmentService).to receive(:call) + end + + context "with FR country" do + let(:country) { "FR" } + + it "generates the invoice with attached facturx xml synchronously" do + result = described_class.call(invoice:, context:) + + expect(Utils::PdfAttachmentService).to have_received(:call) + expect(EInvoices::Invoices::FacturX::CreateService).to have_received(:call) + expect(result.invoice.file).to be_present + end + end + end + end +end diff --git a/spec/services/invoices/generate_xml_service_spec.rb b/spec/services/invoices/generate_xml_service_spec.rb new file mode 100644 index 0000000..16c1781 --- /dev/null +++ b/spec/services/invoices/generate_xml_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::GenerateXmlService do + let(:context) { "graphql" } + let(:organization) { create(:organization, name: "LAGO") } + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", einvoicing:) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, billing_entity:, organization:, status:) } + let(:status) { :finalized } + let(:einvoicing) { true } + let(:blank_xml_path) { Rails.root.join("spec/fixtures/blank.xml") } + let(:fake_xml) { "content" } + let(:create_xml_result) { BaseService::Result.new.tap { |result| result.xml = fake_xml } } + let(:xml_service) { EInvoices::Invoices::Ubl::CreateService } + + before do + invoice + end + + shared_examples "dont generate" do |section| + it "does not generate the xml" do + described_class.call(invoice:, context:) + + expect(xml_service).not_to have_received(:call) + end + end + + describe "#call" do + before do + allow(xml_service).to receive(:call) + .with(invoice:) + .and_return(create_xml_result) + end + + it "generates the xml synchronously" do + result = described_class.call(invoice:, context:) + + expect(result.invoice.xml_file).to be_present + end + + context "when using temp files" do + let(:xml_tempfile) { instance_double(Tempfile).as_null_object } + + before do + allow(Tempfile).to receive(:new).with([invoice.number, ".xml"]).and_return(xml_tempfile) + allow(xml_tempfile).to receive(:path).and_return(blank_xml_path) + end + + it "removes the temp file at the end" do + described_class.call(invoice:, context:) + + expect(xml_tempfile).to have_received(:unlink) + end + + context "when error happens" do + before do + allow(invoice).to receive(:save).and_raise(ActiveRecord::RecordInvalid.new) + end + + it "always removes the temp file" do + expect { + described_class.call(invoice:, context:) + }.to raise_error(ActiveRecord::RecordInvalid) + + expect(xml_tempfile).to have_received(:unlink) + end + end + end + + context "when cant generate" do + context "with invoice not found" do + let(:invoice) { nil } + + it "results in error" do + result = described_class.call(invoice:, context:) + + expect(result.success).to be_falsey + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "with invoice as draft" do + let(:status) { :draft } + + it "results in error" do + result = described_class.call(invoice:, context:) + + expect(result.success).to be_falsey + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("is_draft") + end + end + + context "with already generated a file" do + before do + invoice.xml_file.attach( + io: StringIO.new(File.read(blank_xml_path)), + filename: "invoice.xml", + content_type: "application/xml" + ) + end + + it_behaves_like "dont generate" + end + + context "when country is not allowed" do + before do + billing_entity.country = "BR" + billing_entity.save!(validate: false) + end + + it_behaves_like "dont generate" + end + + context "when einvoicing is disabled" do + let(:einvoicing) { false } + + it_behaves_like "dont generate" + end + end + end +end diff --git a/spec/services/invoices/issuing_date_service_spec.rb b/spec/services/invoices/issuing_date_service_spec.rb new file mode 100644 index 0000000..7df440a --- /dev/null +++ b/spec/services/invoices/issuing_date_service_spec.rb @@ -0,0 +1,207 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::IssuingDateService do + subject(:issuing_date_service) { described_class.new(customer_settings: customer, recurring:) } + + let(:customer) do + build( + :customer, + subscription_invoice_issuing_date_anchor:, + subscription_invoice_issuing_date_adjustment:, + invoice_grace_period: + ) + end + + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:invoice_grace_period) { 3 } + + describe "#issuing_date_adjustment" do + let(:recurring) { true } + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "returns -1" do + expect(issuing_date_service.issuing_date_adjustment).to eq(-1) + end + end + + context "with current_period_end + align_with_finalization_date" do + context "when invoice_grace_period > 0" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "returns invoice_grace_period" do + expect(issuing_date_service.issuing_date_adjustment).to eq(3) + end + end + + context "when invoice_grace_period is 0" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + let(:invoice_grace_period) { 0 } + + it "returns -1" do + expect(issuing_date_service.issuing_date_adjustment).to eq(-1) + end + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "returns 0" do + expect(issuing_date_service.issuing_date_adjustment).to eq(0) + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "returns grace_period" do + expect(issuing_date_service.issuing_date_adjustment).to eq(3) + end + end + + context "with no preferences set on the customer level" do + let(:billing_entity) do + build( + :billing_entity, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + ) + end + + let(:customer) { build(:customer, billing_entity:) } + + it "returns value based on billing entity settings" do + expect(issuing_date_service.issuing_date_adjustment).to eq(-1) + end + end + + context "when recurring = false" do + let(:recurring) { false } + + it "returns invoice_grace_period" do + expect(issuing_date_service.issuing_date_adjustment).to eq(3) + end + end + + context "with customer as a hash" do + subject(:issuing_date_service) do + described_class.new( + customer_settings: { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + }, + billing_entity_settings: customer.billing_entity, + recurring: + ) + end + + it "returns value based on customer hash" do + expect(issuing_date_service.issuing_date_adjustment).to eq(-1) + end + end + + context "with billing_entity as a hash" do + subject(:issuing_date_service) do + described_class.new( + customer_settings: customer, + billing_entity_settings: { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + }, + recurring: + ) + end + + it "returns value based on billing entity hash" do + expect(issuing_date_service.issuing_date_adjustment).to eq(-1) + end + end + end + + describe "#grace_period" do + let(:recurring) { true } + + context "with preferences set on the customer level" do + let(:invoice_grace_period) { 3 } + + it "returns value based on billing entity settings" do + expect(issuing_date_service.grace_period).to eq(3) + end + end + + context "with no preferences set on the customer level" do + let(:billing_entity) do + build( + :billing_entity, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + ) + end + + let(:customer) { build(:customer, billing_entity:) } + + it "returns value based on billing entity settings" do + expect(issuing_date_service.grace_period).to eq(3) + end + end + + context "with no preferences set on the billing_entity level" do + let(:billing_entity) { build(:billing_entity, invoice_grace_period: nil) } + let(:customer) { build(:customer, billing_entity:, invoice_grace_period: nil) } + + it "returns 0" do + expect(issuing_date_service.grace_period).to eq(0) + end + end + + context "with customer as a hash" do + subject(:issuing_date_service) do + described_class.new( + customer_settings: { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + }, + billing_entity_settings: customer.billing_entity, + recurring: + ) + end + + it "returns value based on customer hash" do + expect(issuing_date_service.grace_period).to eq(3) + end + end + + context "with billing_entity as a hash" do + subject(:issuing_date_service) do + described_class.new( + customer_settings: customer, + billing_entity_settings: { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + }, + recurring: + ) + end + + it "returns value based on billing entity hash" do + expect(issuing_date_service.grace_period).to eq(3) + end + end + end +end diff --git a/spec/services/invoices/lose_dispute_service_spec.rb b/spec/services/invoices/lose_dispute_service_spec.rb new file mode 100644 index 0000000..e7d146a --- /dev/null +++ b/spec/services/invoices/lose_dispute_service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::LoseDisputeService do + subject(:lose_dispute_service) { described_class.new(invoice:) } + + describe "#call" do + context "when invoice does not exist" do + let(:invoice) { nil } + + it "returns a failure" do + result = lose_dispute_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("invoice") + end + + it "does not enqueue a send webhook job for the invoice" do + expect { lose_dispute_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when invoice exists" do + let(:invoice) { create(:invoice, status:) } + + context "when the invoice is voided" do + let(:status) { :voided } + + it "marks the dispute as lost" do + result = lose_dispute_service.call + + expect(result).to be_success + expect(result.invoice.payment_dispute_lost_at).to be_present + end + + it "enqueues a send webhook job for the invoice" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.payment_dispute_lost", invoice, provider_error: nil) + end + + it "enqueues a sync void invoice job" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::VoidJob).with(invoice:) + end + end + + context "when the invoice is draft" do + let(:status) { :draft } + + it "returns a failure" do + result = lose_dispute_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("not_disputable") + end + + it "does not enqueue a send webhook job for the invoice" do + expect { lose_dispute_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when the invoice is finalized" do + let(:status) { :finalized } + + it "marks the dispute as lost" do + result = lose_dispute_service.call + + expect(result).to be_success + expect(result.invoice.payment_dispute_lost_at).to be_present + end + + it "enqueues a send webhook job for the invoice" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.payment_dispute_lost", invoice, provider_error: nil) + end + + it "enqueues a sync void invoice job" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::VoidJob).with(invoice:) + end + end + end + end +end diff --git a/spec/services/invoices/metadata/update_service_spec.rb b/spec/services/invoices/metadata/update_service_spec.rb new file mode 100644 index 0000000..1dc0776 --- /dev/null +++ b/spec/services/invoices/metadata/update_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Metadata::UpdateService do + subject(:update_service) { described_class.new(invoice:, params:) } + + let(:invoice) { create(:invoice) } + let(:invoice_metadata) { create(:invoice_metadata, invoice:) } + let(:another_invoice_metadata) { create(:invoice_metadata, invoice:, key: "test", value: "1") } + let(:params) do + [ + { + id: invoice_metadata.id, + key: "new key", + value: "new value" + }, + { + key: "Added key", + value: "Added value" + } + ] + end + + describe "#call" do + before do + invoice_metadata + another_invoice_metadata + end + + it "updates existing metadata" do + result = update_service.call + + metadata_keys = result.invoice.metadata.pluck(:key) + metadata_ids = result.invoice.metadata.pluck(:id) + + expect(result.invoice.metadata.count).to eq(2) + expect(metadata_keys).to include("new key") + expect(metadata_ids).to include(invoice_metadata.id) + end + + it "adds new metadata" do + result = update_service.call + + metadata_keys = result.invoice.metadata.pluck(:key) + + expect(result.invoice.metadata.count).to eq(2) + expect(metadata_keys).to include("Added key") + end + + it "sanitizes not needed metadata" do + result = update_service.call + + metadata_ids = result.invoice.metadata.pluck(:id) + + expect(result.invoice.metadata.count).to eq(2) + expect(metadata_ids).not_to include(another_invoice_metadata.id) + end + end +end diff --git a/spec/services/invoices/paid_credit_service_spec.rb b/spec/services/invoices/paid_credit_service_spec.rb new file mode 100644 index 0000000..54831e3 --- /dev/null +++ b/spec/services/invoices/paid_credit_service_spec.rb @@ -0,0 +1,173 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::PaidCreditService do + subject(:invoice_service) do + described_class.new(wallet_transaction:, timestamp:, invoice:) + end + + let(:timestamp) { Time.current.to_i } + + describe "call" do + let(:organization) { create(:organization) } + let(:billing_entity) { customer.billing_entity } + let(:customer) { create(:customer, organization:, payment_provider: :stripe) } + let(:subscription) { create(:subscription, plan:, customer:) } + let(:plan) { create(:plan, organization:) } + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, amount: "15.00", credit_amount: "15.00", invoice_requires_successful_payment:) + end + let(:invoice_requires_successful_payment) { false } + + let(:invoice) { nil } + + before do + wallet_transaction + subscription + end + + it "creates an invoice" do + result = invoice_service.call + + expect(result).to be_success + + expect(result.invoice).to have_attributes( + issuing_date: Time.zone.at(timestamp).to_date, + invoice_type: "credit", + payment_status: "pending", + currency: "EUR", + fees_amount_cents: 1500, + sub_total_excluding_taxes_amount_cents: 1500, + taxes_amount_cents: 0, + taxes_rate: 0, + sub_total_including_taxes_amount_cents: 1500, + total_amount_cents: 1500 + ) + + expect(result.invoice.applied_taxes.count).to eq(0) + + expect(result.invoice).to be_finalized + end + + it "assigns invoice to the wallet transaction" do + expect { invoice_service.call } + .to change(wallet_transaction, :invoice).from(nil).to(Invoice) + end + + it "enqueues a SendWebhookJob" do + expect do + invoice_service.call + end.to have_enqueued_job(SendWebhookJob) + end + + it "produces an activity log" do + invoice = invoice_service.call.invoice + + expect(Utils::ActivityLog).to have_produced("invoice.paid_credit_added").with(invoice) + end + + it_behaves_like "syncs invoice" do + let(:service_call) { invoice_service.call } + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.call } + end + + it "does not enqueue an SendEmailJob" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + context "with lago_premium", :premium do + it "enqueues an SendEmailJob" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when organization does not have right email settings" do + before { customer.billing_entity.update!(email_settings: []) } + + it "does not enqueue an SendEmailJob" do + expect do + invoice_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + it "calls SegmentTrackJob" do + invoice = invoice_service.call.invoice + + expect(SegmentTrackJob).to have_been_enqueued.with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + result = invoice_service.call + expect(Invoices::Payments::CreateJob).to have_been_enqueued.with(invoice: result.invoice, payment_provider: :stripe, payment_method_params: {}) + end + + context "with customer timezone" do + before { customer.update!(timezone: "America/Los_Angeles") } + + let(:timestamp) { DateTime.parse("2022-11-25 01:00:00").to_i } + + it "assigns the issuing date in the customer timezone" do + result = invoice_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2022-11-24") + end + end + + context "with provided invoice" do + let(:invoice) do + create(:invoice, organization: customer.organization, customer:, invoice_type: :credit, status: :generating) + end + + it "does not re-create an invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + + expect(result.invoice.fees.count).to eq(1) + + expect(result.invoice.fees_amount_cents).to eq(1500) + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.taxes_rate).to eq(0) + expect(result.invoice.total_amount_cents).to eq(1500) + + expect(result.invoice).to be_finalized + end + end + + context "with wallet_transaction.invoice_requires_successful_payment", :premium do + let(:invoice_requires_successful_payment) { true } + + it "creates an open invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_open + expect(Invoices::Payments::CreateJob).to have_been_enqueued.with(invoice: result.invoice, payment_provider: :stripe, payment_method_params: {}) + + # These jobs should only be enqueued for finalized invoices + expect(SegmentTrackJob).not_to have_been_enqueued + expect(Invoices::GenerateDocumentsJob).not_to have_been_enqueued + expect(SendWebhookJob).not_to have_been_enqueued + end + end + end +end diff --git a/spec/services/invoices/payments/adyen_service_spec.rb b/spec/services/invoices/payments/adyen_service_spec.rb new file mode 100644 index 0000000..2474f31 --- /dev/null +++ b/spec/services/invoices/payments/adyen_service_spec.rb @@ -0,0 +1,255 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::AdyenService do + subject(:adyen_service) { described_class.new(invoice) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:adyen_payment_provider) { create(:adyen_provider, organization:, code:) } + let(:adyen_customer) { create(:adyen_customer, customer:, payment_provider: adyen_payment_provider) } + let(:adyen_client) { instance_double(Adyen::Client) } + let(:payments_api) { Adyen::PaymentsApi.new(adyen_client, 70) } + let(:payment_links_api) { Adyen::PaymentLinksApi.new(adyen_client, 70) } + let(:payment_links_response) { generate(:adyen_payment_links_response) } + let(:checkout) { Adyen::Checkout.new(adyen_client, 70) } + let(:payments_response) { generate(:adyen_payments_response) } + let(:payment_methods_response) { generate(:adyen_payment_methods_response) } + let(:code) { "adyen_1" } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 1000, + total_paid_amount_cents:, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:total_paid_amount_cents) { 0 } + + describe ".update_payment_status" do + let(:payment) do + create( + :payment, + payable: invoice, + provider_payment_id: "ch_123456", + status: "Pending", + payment_provider: adyen_payment_provider + ) + end + + before do + payment + end + + it "updates the payment and invoice payment_status" do + result = adyen_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "Authorised" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("Authorised") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false, + total_paid_amount_cents: 200 + ) + end + + it "enqueues a SendWebhookJob for payment.succeeded" do + expect do + adyen_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "Authorised" + ) + end.to have_enqueued_job(SendWebhookJob).with("payment.succeeded", Payment) + end + + context "when status is failed" do + it "updates the payment and invoice status" do + result = adyen_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "Refused" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("Refused") + expect(result.payment.payable_payment_status).to eq("failed") + expect(result.invoice.reload).to have_attributes( + payment_status: "failed", + ready_for_payment_processing: true + ) + end + end + + context "when invoice is already payment_succeeded" do + before { invoice.payment_succeeded! } + + it "does not update the status of invoice and payment" do + result = adyen_service.update_payment_status( + provider_payment_id: "ch_123456", + status: %w[Authorised SentForSettle SettleScheduled Settled Refunded].sample + ) + + expect(result).to be_success + expect(result.invoice.payment_status).to eq("succeeded") + end + end + + context "with invalid status" do + it "does not update the payment_status of invoice" do + result = adyen_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "foo-bar" + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + + before do + adyen_payment_provider + adyen_customer + end + + it "creates a payment and updates invoice payment status" do + result = adyen_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "succeeded", + metadata: {lago_invoice_id: invoice.id, payment_type: "one-time"} + ) + + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false + ) + end + end + end + + describe "#payment_url_params" do + subject(:payment_url_params) { adyen_service.send(:payment_url_params, payment_intent) } + + let(:payment_intent) { create(:payment_intent) } + + let(:expected_params) do + { + reference: invoice.number, + amount: { + value: invoice.total_due_amount_cents, + currency: invoice.currency.upcase + }, + merchantAccount: adyen_payment_provider.merchant_account, + returnUrl: adyen_service.__send__(:success_redirect_url), + shopperReference: customer.external_id, + storePaymentMethodMode: "enabled", + recurringProcessingModel: "UnscheduledCardOnFile", + expiresAt: payment_intent.expires_at.iso8601, + metadata: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type, + payment_type: "one-time" + }, + shopperEmail: customer.email + } + end + + before do + adyen_payment_provider + adyen_customer + end + + context "when paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "return the payload" do + freeze_time do + expect(payment_url_params).to eq(expected_params) + end + end + end + + context "when paid amount is zero" do + it "returns the payload" do + freeze_time do + expect(payment_url_params).to eq(expected_params) + end + end + end + + context "when customer has an email" do + let(:customer) { create(:customer, payment_provider_code: code, email: "test@example.com") } + + it "includes the shopperEmail in the params" do + expect(payment_url_params[:shopperEmail]).to eq("test@example.com") + end + end + + context "when customer does not have an email" do + let(:customer) { create(:customer, payment_provider_code: code, email: nil) } + + it "does not include the shopperEmail in the params" do + expect(payment_url_params).not_to have_key(:shopperEmail) + end + end + end + + describe "#generate_payment_url" do + let(:payment_intent) { create(:payment_intent) } + + before do + adyen_payment_provider + adyen_customer + + allow(Adyen::Client).to receive(:new) + .and_return(adyen_client) + allow(adyen_client).to receive(:checkout) + .and_return(checkout) + allow(checkout).to receive(:payment_links_api) + .and_return(payment_links_api) + allow(payment_links_api).to receive(:payment_links) + .and_return(payment_links_response) + end + + it "generates payment url" do + adyen_service.generate_payment_url(payment_intent) + + expect(payment_links_api).to have_received(:payment_links) + end + + context "with an error on Adyen" do + before do + allow(payment_links_api).to receive(:payment_links) + .and_raise(Adyen::AdyenError.new(nil, nil, "error")) + end + + it "returns a failed result" do + result = adyen_service.generate_payment_url(payment_intent) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Adyen") + expect(result.error.error_message).to eq("error") + end + end + end +end diff --git a/spec/services/invoices/payments/cashfree_service_spec.rb b/spec/services/invoices/payments/cashfree_service_spec.rb new file mode 100644 index 0000000..c756411 --- /dev/null +++ b/spec/services/invoices/payments/cashfree_service_spec.rb @@ -0,0 +1,295 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::CashfreeService do + subject(:cashfree_service) { described_class.new(invoice) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:cashfree_payment_provider) { create(:cashfree_provider, organization:, code:) } + let(:cashfree_customer) { create(:cashfree_customer, customer:) } + let(:cashfree_client) { instance_double(LagoHttpClient::Client) } + + let(:code) { "cashfree_1" } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 1000, + total_paid_amount_cents:, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:total_paid_amount_cents) { 0 } + + describe ".update_payment_status" do + let(:payment) do + create( + :payment, + payable: invoice, + provider_payment_id: invoice.id, + status: "pending", + payment_provider: cashfree_payment_provider + ) + end + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "PAID", + metadata: {} + ) + end + + before do + payment + end + + it "updates the payment and invoice payment_status" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("PAID") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false, + total_paid_amount_cents: 200 + ) + end + + it "enqueues a SendWebhookJob for payment.succeeded" do + expect do + cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + end.to have_enqueued_job(SendWebhookJob).with("payment.succeeded", Payment) + end + + context "when status is failed" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "EXPIRED", + metadata: {} + ) + end + + it "updates the payment and invoice status" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("EXPIRED") + expect(result.payment.payable_payment_status).to eq("failed") + expect(result.invoice.reload).to have_attributes( + payment_status: "failed", + ready_for_payment_processing: true + ) + end + end + + context "when invoice is already payment_succeeded" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: %w[PARTIALLY_PAID PAID EXPIRED CANCELED].sample, + metadata: {} + ) + end + + before { invoice.payment_succeeded! } + + it "does not update the status of invoice and payment" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.invoice.payment_status).to eq("succeeded") + end + end + + context "with invalid status" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "foo-bar", + metadata: {} + ) + end + + it "does not update the payment_status of invoice" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: invoice.id, + status: "PAID", + metadata: {payment_type: "one-time", lago_invoice_id: invoice.id} + ) + end + + before do + cashfree_payment_provider + cashfree_customer + end + + it "creates a payment and updates invoice payment status" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("PAID") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false + ) + end + end + end + + describe "#payment_url_params" do + subject { cashfree_service.send(:payment_url_params, payment_intent) } + + let(:payment_intent) { create(:payment_intent, invoice:) } + + let(:expected_params) do + { + customer_details: { + customer_phone: customer.phone || "9999999999", + customer_email: customer.email, + customer_name: customer.name + }, + link_notify: { + send_sms: false, + send_email: false + }, + link_meta: { + upi_intent: true, + return_url: cashfree_service.send(:success_redirect_url) + }, + link_notes: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + payment_type: "one-time" + }, + link_id: "#{SecureRandom.uuid}.#{invoice.payment_attempts}", + link_amount: invoice.total_due_amount_cents / 100.to_f, + link_currency: invoice.currency.upcase, + link_purpose: invoice.id, + link_expiry_time: payment_intent.expires_at.iso8601, + link_partial_payments: false, + link_auto_reminders: false + } + end + + before do + allow(SecureRandom).to receive(:uuid).and_return("test-uuid") + allow(Time).to receive(:current).and_return(Time.parse("2023-01-01 12:00:00 UTC")) + cashfree_payment_provider + end + + context "when paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "return the payload" do + expect(subject).to eq(expected_params) + end + end + + context "when paid amount is zero" do + it "returns the payload" do + expect(subject).to eq(expected_params) + end + end + end + + describe ".generate_payment_url" do + subject(:result) { cashfree_service.generate_payment_url(payment_intent) } + + let(:payment_links_response) { Net::HTTPResponse.new("1.0", "200", "OK") } + let(:payment_links_body) { {link_url: "https://payments-test.cashfree.com/links//U1mgll3c0e9g"}.to_json } + let(:payment_intent) { create(:payment_intent) } + + before do + cashfree_payment_provider + cashfree_customer + + allow(LagoHttpClient::Client).to receive(:new) + .and_return(cashfree_client) + allow(cashfree_client).to receive(:post_with_response) + .and_return(payment_links_response) + allow(payment_links_response).to receive(:body) + .and_return(payment_links_body) + end + + it "generates payment url" do + expect(result).to be_success + expect(result.payment_url).to be_present + end + + context "when payment url failed to generate" do + let(:payment_links_response) { Net::HTTPResponse.new("1.0", "400", "Bad Request") } + let(:payment_links_body) do + { + message: "Currency USD is not enabled", + code: "link_post_failed", + type: "invalid_request_error" + }.to_json + end + + before do + cashfree_payment_provider + cashfree_customer + + allow(LagoHttpClient::Client).to receive(:new) + .and_return(cashfree_client) + allow(cashfree_client).to receive(:post_with_response) + .and_raise(::LagoHttpClient::HttpError.new(payment_links_response.code, payment_links_body, nil)) + end + + it "returns a third party error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Cashfree") + expect(result.error.error_message).to eq(payment_links_body) + end + end + end +end diff --git a/spec/services/invoices/payments/create_service_spec.rb b/spec/services/invoices/payments/create_service_spec.rb new file mode 100644 index 0000000..55c0eba --- /dev/null +++ b/spec/services/invoices/payments/create_service_spec.rb @@ -0,0 +1,690 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::CreateService do + subject(:create_service) { described_class.new(invoice:, payment_provider: provider, payment_method_params:) } + + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, customer:, organization:, total_amount_cents: 100) } + let(:customer) { create(:customer, organization:, payment_provider: provider, payment_provider_code:) } + let(:provider) { "stripe" } + let(:payment_provider_code) { "stripe_1" } + let(:payment_provider) { create(:stripe_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:stripe_customer, payment_provider:, customer:) } + let(:default_payment_method) { create(:payment_method, customer:, is_default: true) } + let(:payment_method_params) { {} } + + describe "#call" do + let(:result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "processing") + end + end + + let(:provider_class) { PaymentProviders::Stripe::Payments::CreateService } + let(:provider_service) { instance_double(provider_class) } + + before do + provider_customer + default_payment_method + + allow(provider_class) + .to receive(:new) + .with( + payment: an_instance_of(Payment), + reference: "#{invoice.billing_entity.name} - Invoice #{invoice.number}", + metadata: { + lago_invoice_id: invoice.id, + lago_customer_id: customer.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + ).and_return(provider_service) + allow(provider_service).to receive(:call!) + .and_return(result) + end + + it "creates a payment and calls the stripe service" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_present + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(invoice.total_amount_cents) + expect(payment.amount_currency).to eq(invoice.currency) + expect(payment.payable).to eq(invoice) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + it "updates the invoice payment status" do + create_service.call + + expect(invoice.reload).to be_payment_pending + expect(invoice.payment_attempts).to eq(1) + expect(invoice.ready_for_payment_processing).to be_falsey + expect(invoice.payments.count).to eq(1) + end + + context "when invoice is subscription_gated (payment-gated)" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}], + customer:, organization:) + end + let(:invoice) { create(:invoice, customer:, organization:, total_amount_cents: 100, status: :open) } + let(:expected_reference) { "#{invoice.billing_entity.name} - Invoice #{invoice.id}" } + + before do + create(:invoice_subscription, invoice:, subscription:) + + allow(provider_class) + .to receive(:new) + .with( + payment: an_instance_of(Payment), + reference: expected_reference, + metadata: { + lago_invoice_id: invoice.id, + lago_customer_id: customer.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + ).and_return(provider_service) + end + + it "uses invoice ID instead of number in the reference" do + create_service.call + + expect(provider_class).to have_received(:new).with(hash_including(reference: expected_reference)) + end + end + + context "with gocardless payment provider" do + let(:provider) { "gocardless" } + let(:provider_class) { PaymentProviders::Gocardless::Payments::CreateService } + let(:payment_provider) { create(:gocardless_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:gocardless_customer, payment_provider:, customer:) } + + it "calls the gocardless service" do + create_service.call + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + end + + context "with adyen payment provider" do + let(:provider) { "adyen" } + let(:provider_class) { PaymentProviders::Adyen::Payments::CreateService } + let(:payment_provider) { create(:adyen_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:adyen_customer, payment_provider:, customer:) } + + it "calls the adyen service" do + create_service.call + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + end + + context "with subscription invoice" do + let(:organization) { create(:organization, feature_flags: %w[multiple_payment_methods]) } + let(:subscription_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:plan) { create(:plan, organization:) } + let(:subscription) do + create(:subscription, customer:, plan:, organization:, payment_method: subscription_payment_method) + end + let(:invoice) do + create(:invoice, customer:, organization:, total_amount_cents: 100, invoice_type: :subscription) + end + + before do + create(:invoice_subscription, invoice:, subscription:) + end + + context "when payment method is attached to subscription" do + it "creates payment with subscription payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(subscription_payment_method.id) + end + end + + context "when manual payment method is attached to subscription" do + let(:subscription) do + create(:subscription, customer:, plan:, organization:, payment_method_type: "manual") + end + + it "does not create a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + + context "when payment method is NOT attached to subscription" do + let(:subscription) do + create(:subscription, customer:, plan:, organization:, payment_method: nil) + end + + it "creates payment with customer default payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(default_payment_method.id) + end + end + + context "when payment method is not attached to subscription and there is no default payment method" do + let(:subscription) do + create(:subscription, customer:, plan:, organization:, payment_method: nil) + end + let(:default_payment_method) { nil } + + it "does not create a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + end + + context "with credit invoice" do + let(:organization) { create(:organization, feature_flags: %w[multiple_payment_methods]) } + let(:wallet) { create(:wallet, customer:, organization:) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice:, source: :manual) } + let(:invoice) do + create(:invoice, :credit, customer:, organization:, total_amount_cents: 100) + end + + before do + wallet_transaction + end + + context "when payment method is attached to wallet transaction" do + let(:wt_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, invoice:, source: :manual, payment_method: wt_payment_method) + end + + it "creates payment with wallet transaction payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(wt_payment_method.id) + end + end + + context "when manual payment method is attached to wallet transaction" do + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, invoice:, source: :manual, payment_method_type: "manual") + end + + it "does not create a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + + context "when payment method is attached to recurring rule" do + let(:rule_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:recurring_rule) do + create(:recurring_transaction_rule, wallet:, payment_method: rule_payment_method) + end + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, invoice:, source: :interval) + end + + before { recurring_rule } + + it "creates payment with recurring rule payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(rule_payment_method.id) + end + end + + context "when manual payment method is attached to recurring rule" do + let(:recurring_rule) do + create(:recurring_transaction_rule, wallet:, payment_method_type: "manual") + end + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, invoice:, source: :interval) + end + + before { recurring_rule } + + it "does not create a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + + context "when payment method is attached to wallet" do + let(:wallet_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:wallet) { create(:wallet, customer:, organization:, payment_method: wallet_payment_method) } + + it "creates payment with wallet payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(wallet_payment_method.id) + end + end + + context "when manual payment method is attached to wallet" do + let(:wallet) { create(:wallet, customer:, organization:, payment_method_type: "manual") } + + it "does not create a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + + context "when payment method is NOT attached to any wallet related object" do + it "creates payment with customer default payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(default_payment_method.id) + end + end + end + + context "with one-off invoice" do + let(:organization) { create(:organization, feature_flags: %w[multiple_payment_methods]) } + let(:invoice) do + create(:invoice, customer:, organization:, total_amount_cents: 100, invoice_type: :one_off) + end + + context "when manual payment method is passed in params" do + let(:payment_method_params) { {payment_method_type: "manual"} } + + it "does not create a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + + context "when valid payment method is passed in params" do + let(:one_off_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:payment_method_params) { {payment_method_id: one_off_payment_method.id} } + + it "creates payment with passed payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(one_off_payment_method.id) + end + end + + context "when payment method is NOT passed in params" do + it "creates payment with customer default payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(default_payment_method.id) + end + end + end + + context "when invoice is self_billed" do + let(:invoice) do + create(:invoice, :self_billed, customer:, organization:, total_amount_cents: 100) + end + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + context "when invoice is payment_succeeded" do + before { invoice.payment_succeeded! } + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + context "when invoice is voided" do + before { invoice.voided! } + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + context "when invoice amount is 0" do + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 0, + currency: "EUR" + ) + end + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + expect(result.invoice).to be_payment_succeeded + expect(provider_class).not_to have_received(:new) + end + end + + context "with missing payment provider" do + let(:payment_provider) { nil } + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + context "when customer does not have a provider customer id" do + before { provider_customer.update!(provider_customer_id: nil) } + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + it_behaves_like "syncs payment" do + let(:service_call) { create_service.call } + end + + context "when provider service raises a service failure" do + let(:original_error) { ::Stripe::StripeError.new("card declined") } + let(:result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, status: "failed", payable_payment_status: "failed") + r.error_message = "error" + r.error_code = "code" + r.reraise = true + end + end + + before do + allow(provider_service).to receive(:call!) + .and_raise(BaseService::ServiceFailure.new(result, code: "code", error_message: "error", original_error:)) + end + + it "re-raise the error and delivers an error webhook" do + expect { create_service.call } + .to raise_error(BaseService::ServiceFailure) + .and enqueue_job(SendWebhookJob) + .with( + "invoice.payment_failure", + invoice, + provider_customer_id: provider_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + }, + error_details: Hash + ).on_queue(webhook_queue) + end + + context "when original_error is not set" do + let(:original_error) { nil } + + it "re-raise the error and delivers an error webhook" do + expect { create_service.call } + .to raise_error(BaseService::ServiceFailure) + .and enqueue_job(SendWebhookJob) + .with( + "invoice.payment_failure", + invoice, + provider_customer_id: provider_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + }, + error_details: {} + ).on_queue(webhook_queue) + end + end + + context "when payment has a payable_payment_status" do + let(:result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "failed") + r.error_message = "error" + r.error_code = "code" + r.reraise = true + end + end + + it "updates the invoice payment status" do + expect { create_service.call } + .to raise_error(BaseService::ServiceFailure) + + expect(invoice.reload).to be_payment_failed + end + end + + context "when invoice is credit? and open?" do + let(:invoice) { create(:invoice, :credit, :open, customer:, organization:, total_amount_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction) } + let(:fee) { create(:fee, fee_type: :credit, invoice: invoice, invoiceable: wallet_transaction) } + + before do + fee + + allow(Invoices::Payments::DeliverErrorWebhookService) + .to receive(:call_async).and_call_original + end + + it "delivers an error webhook" do + expect { create_service.call }.to raise_error(BaseService::ServiceFailure) + + expect(Invoices::Payments::DeliverErrorWebhookService).to have_received(:call_async) + expect(SendWebhookJob).to have_been_enqueued + .with( + "wallet_transaction.payment_failure", + wallet_transaction, + provider_customer_id: provider_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + }, + error_details: Hash + ) + end + end + + context "when payable_payment_status is pending" do + let(:result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, status: "failed", payable_payment_status: "pending") + r.error_message = "stripe_error" + r.error_code = "unknown" + end + end + + it "updates the invoice payment status and does not delivers an error webhook" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to be_present + + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("pending") + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + + expect(SendWebhookJob).not_to have_been_enqueued + end + end + + [ + ::PaymentProviders::StripeProvider::AMOUNT_TOO_SMALL_ERROR_CODE, + ::PaymentProviders::StripeProvider::NEED_3DS_ERROR_CODE + ].each do |error_code| + context "when error_code is is pending" do + let(:result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, status: "failed", payable_payment_status: "failed") + r.error_message = "stripe_error" + r.error_code = error_code + end + end + + it "updates the invoice payment status and does not delivers an error webhook" do + expect(invoice.payment_status).to eq "pending" + + result = create_service.call + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + expect(result.invoice.payment_status).to eq "failed" + + expect(SendWebhookJob).not_to have_been_enqueued + end + end + end + end + + context "when a payment exists" do + let(:payment) do + create( + :payment, + payable: invoice, + payment_provider:, + payment_provider_customer: provider_customer, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency, + status: "pending", + payable_payment_status: payment_status + ) + end + + let(:payment_status) { "pending" } + + before { payment } + + it "retrieves the payment for processing" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to eq(payment) + + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(invoice.total_amount_cents) + expect(payment.amount_currency).to eq(invoice.currency) + expect(payment.payable).to eq(invoice) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + context "when payment is already processing" do + let(:payment_status) { "processing" } + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.payment).to eq(payment) + + expect(provider_class).not_to have_received(:new) + expect(provider_service).not_to have_received(:call!) + end + end + end + end + + describe "#call_async" do + it "enqueues a job to create a stripe payment" do + expect { + result = create_service.call_async + expect(result).to be_success + expect(result.payment_provider).to eq(provider.to_sym) + }.to have_enqueued_job_after_commit(Invoices::Payments::CreateJob) + .with(invoice:, payment_provider: :stripe, payment_method_params: {}) + end + + context "with gocardless payment provider" do + let(:provider) { "gocardless" } + + it "enqueues a job to create a gocardless payment" do + expect { create_service.call_async } + .to have_enqueued_job_after_commit(Invoices::Payments::CreateJob) + .with(invoice:, payment_provider: :gocardless, payment_method_params: {}) + end + end + + context "with adyen payment provider" do + let(:provider) { "adyen" } + + it "enqueues a job to create a gocardless payment" do + expect { create_service.call_async } + .to have_enqueued_job_after_commit(Invoices::Payments::CreateJob) + .with(invoice:, payment_provider: :adyen, payment_method_params: {}) + end + end + + context "when payment provider is not set" do + let(:provider) { nil } + + it "does not enqueue a job" do + expect { + result = create_service.call_async + expect(result).to be_success + expect(result.payment_provider).to be_nil + }.not_to have_enqueued_job(Invoices::Payments::CreateJob) + end + end + end +end diff --git a/spec/services/invoices/payments/deliver_error_webhook_service_spec.rb b/spec/services/invoices/payments/deliver_error_webhook_service_spec.rb new file mode 100644 index 0000000..831fbad --- /dev/null +++ b/spec/services/invoices/payments/deliver_error_webhook_service_spec.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::DeliverErrorWebhookService do + subject(:webhook_service) { described_class.new(invoice, params) } + + let(:params) do + { + "provider_customer_id" => "customer", + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + describe ".call_async" do + context "when invoice is visible?" do + let(:invoice) { create(:invoice, invoice_type: :subscription, status: :finalized) } + + it "enqueues a job to send an invoice payment failure webhook" do + expect do + webhook_service.call_async + end.to have_enqueued_job(SendWebhookJob).once.and( + have_enqueued_job(SendWebhookJob).with("invoice.payment_failure", invoice, params) + ) + end + + it "produces an activity log" do + webhook_service.call_async + + expect(Utils::ActivityLog).to have_produced("invoice.payment_failure").with(invoice) + end + end + + context "when invoice is invisible" do + let(:invoice) { create(:invoice, invoice_type: :credit, status: :generating) } + + it "does not send the invoice payment failure webhook" do + expect do + webhook_service.call_async + end.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when the invoice is a subscription invoice and open" do + let(:invoice) { create(:invoice, invoice_type: :subscription, status: :open) } + + it "does not send any webhook" do + expect do + webhook_service.call_async + end.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when the invoice is credit?" do + let(:invoiceable) { create(:wallet_transaction) } + let(:fee) { create(:fee, fee_type: :credit, invoice: invoice, invoiceable:) } + + before do + fee + end + + context "when the invoice is open?" do + let(:invoice) { create(:invoice, :credit, status: :open) } + + it "enqueues a job to send a wallet transaction and an invoice payment failure webhook" do + expect do + webhook_service.call_async + end.to have_enqueued_job(SendWebhookJob).once + .and(have_enqueued_job(SendWebhookJob).with("wallet_transaction.payment_failure", WalletTransaction, params)) + end + + it "produces an activity log" do + webhook_service.call_async + + expect(Utils::ActivityLog).to have_produced("wallet_transaction.payment_failure").with(invoiceable) + end + end + + context "when the invoice is visible?" do + let(:invoice) { create(:invoice, :credit, status: :finalized) } + + it "enqueues a job to send an invoice payment failure webhook" do + expect do + webhook_service.call_async + end.to have_enqueued_job(SendWebhookJob).with("wallet_transaction.payment_failure", WalletTransaction, params) + .and(have_enqueued_job(SendWebhookJob).with("invoice.payment_failure", invoice, params)) + end + + it "produces an activity log" do + webhook_service.call_async + + expect(Utils::ActivityLog).to have_produced("wallet_transaction.payment_failure").with(invoiceable) + end + end + end + end +end diff --git a/spec/services/invoices/payments/flutterwave_service_spec.rb b/spec/services/invoices/payments/flutterwave_service_spec.rb new file mode 100644 index 0000000..e083222 --- /dev/null +++ b/spec/services/invoices/payments/flutterwave_service_spec.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::FlutterwaveService do + subject(:flutterwave_service) { described_class.new(invoice) } + + let(:organization) { create(:organization, name: "Test Organization") } + let(:customer) { create(:customer, organization: organization, email: "customer@example.com", name: "John Doe") } + let(:flutterwave_provider) { create(:flutterwave_provider, organization: organization, secret_key: "FLWSECK_TEST-secret") } + let(:flutterwave_customer) { create(:flutterwave_customer, customer: customer, payment_provider: flutterwave_provider) } + let(:invoice) { create(:invoice, organization: organization, customer: customer, total_amount_cents: 50000, currency: "USD", number: "INV-001") } + + before do + flutterwave_customer + end + + describe "#update_payment_status" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: { + payment_type: "one-time", + lago_invoice_id: invoice.id + } + ) + end + + context "when creating a new payment" do + it "creates a new payment and updates invoice status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment).to be_a(Payment) + expect(result.payment.provider_payment_id).to eq("flw_payment_123") + expect(result.payment.amount_cents).to eq(invoice.total_due_amount_cents) + expect(result.payment.payable).to eq(invoice) + expect(result.invoice).to eq(invoice) + end + + it "increments payment attempts on the invoice" do + expect { flutterwave_service.update_payment_status(organization_id: organization.id, status: :succeeded, flutterwave_payment: flutterwave_payment) } + .to change { invoice.reload.payment_attempts }.by(1) + end + + it "sets correct payment details" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + payment = result.payment + expect(payment.organization_id).to eq(organization.id) + expect(payment.payment_provider_id).to eq(flutterwave_provider.id) + expect(payment.payment_provider_customer_id).to eq(flutterwave_customer.id) + expect(payment.amount_currency).to eq("USD") + end + + it "enqueues a SendWebhookJob for payment.succeeded" do + expect do + flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + end.to have_enqueued_job(SendWebhookJob).with("payment.succeeded", Payment) + end + end + + context "when updating existing payment" do + let(:existing_payment) do + create(:payment, + organization: organization, + payable: invoice, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :pending) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "updates the existing payment status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment.id).to eq(existing_payment.id) + expect(result.payment.status).to eq("succeeded") + end + + it "updates invoice payment status" do + allow(Invoices::UpdateService).to receive(:call).and_return(BaseService::Result.new) + + flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:invoice]).to eq(invoice) + expect(args[:params][:payment_status]).to eq("succeeded") + expect(args[:params][:ready_for_payment_processing]).to be false + end + end + end + + context "when payment is not found" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "nonexistent_payment", + metadata: {payment_type: "recurring"} + ) + end + + it "returns not found failure" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + + context "when payment already succeeded" do + let(:succeeded_invoice) { create(:invoice, organization: organization, customer: customer, payment_status: :succeeded) } + let(:existing_payment) do + create(:payment, + organization: organization, + payable: succeeded_invoice, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :succeeded) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "returns early without processing" do + service = described_class.new(succeeded_invoice) + allow(service).to receive(:update_invoice_payment_status) + + result = service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(service).not_to have_received(:update_invoice_payment_status) + end + end + + context "when payment status calculation includes total paid amount" do + let(:existing_payment1) { create(:payment, payable: invoice, amount_cents: 20000, payable_payment_status: :succeeded) } + let(:existing_payment2) { create(:payment, payable: invoice, amount_cents: 15000, payable_payment_status: :succeeded) } + + before do + existing_payment1 + existing_payment2 + allow(Invoices::UpdateService).to receive(:call).and_return(BaseService::Result.new) + end + + it "calculates total paid amount correctly" do + flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:params][:total_paid_amount_cents]).to eq(85000) # 20000 + 15000 + 50000 (new payment) + end + end + end + end + + describe "#generate_payment_url" do + let(:payment_intent) { double } + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:successful_response) do + instance_double("HTTPResponse", body: { + status: "success", + data: { + link: "https://checkout.flutterwave.com/v3/hosted/pay/test_link" + } + }.to_json) + end + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(successful_response) + end + + it "creates a checkout session and returns payment URL" do + result = flutterwave_service.generate_payment_url(payment_intent) + + expect(result).to be_success + expect(result.payment_url).to eq("https://checkout.flutterwave.com/v3/hosted/pay/test_link") + end + + it "sends correct parameters to Flutterwave API" do + flutterwave_service.generate_payment_url(payment_intent) + + expect(http_client).to have_received(:post_with_response) do |body, headers| + expect(body[:amount]).to eq(500.0) + expect(body[:tx_ref]).to eq(invoice.id) + expect(body[:currency]).to eq("USD") + expect(body[:customer][:email]).to eq("customer@example.com") + expect(body[:customer][:name]).to eq("John Doe") + expect(body[:customizations][:title]).to eq("Test Organization - Invoice Payment") + expect(body[:customizations][:description]).to eq("Payment for Invoice #INV-001") + expect(body[:meta][:lago_customer_id]).to eq(customer.id) + expect(body[:meta][:lago_invoice_id]).to eq(invoice.id) + expect(body[:meta][:lago_organization_id]).to eq(organization.id) + expect(body[:meta][:lago_invoice_number]).to eq("INV-001") + expect(body[:meta][:payment_type]).to eq("one-time") + + expect(headers["Authorization"]).to eq("Bearer FLWSECK_TEST-secret") + expect(headers["Content-Type"]).to eq("application/json") + expect(headers["Accept"]).to eq("application/json") + end + end + + context "when HTTP client raises an error" do + let(:http_error) { LagoHttpClient::HttpError.new(500, "Connection failed", "https://api.example.com") } + + before do + allow(http_client).to receive(:post_with_response).and_raise(http_error) + end + + it "returns third party failure" do + result = flutterwave_service.generate_payment_url(payment_intent) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Flutterwave") + end + end + end + + describe "private methods" do + describe "#create_checkout_session" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(instance_double("HTTPResponse", body: '{"data": {"link": "test_link"}}')) + end + + it "uses correct API endpoint" do + flutterwave_service.send(:create_checkout_session) + + expect(LagoHttpClient::Client).to have_received(:new).with("#{flutterwave_provider.api_url}/payments") + end + + it "handles different currencies correctly" do + invoice.update!(currency: "NGN", total_amount_cents: 1000000) + + flutterwave_service.send(:create_checkout_session) + + expect(http_client).to have_received(:post_with_response) do |body| + expect(body[:amount]).to eq(10000.0) + expect(body[:currency]).to eq("NGN") + end + end + end + + describe "#increment_payment_attempts" do + it "increments payment attempts on invoice" do + expect { flutterwave_service.send(:increment_payment_attempts) } + .to change { invoice.reload.payment_attempts }.by(1) + end + end + + describe "#update_invoice_payment_status" do + let(:payment) { create(:payment, payable: invoice) } + + before do + flutterwave_service.instance_variable_set(:@result, BaseService::Result.new.tap { |r| r.invoice = invoice }) + allow(Invoices::UpdateService).to receive(:call).and_return(BaseService::Result.new) + end + + it "calls invoice update service with correct parameters" do + flutterwave_service.send(:update_invoice_payment_status, payment_status: :succeeded) + + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:invoice]).to eq(invoice) + expect(args[:params][:payment_status]).to eq(:succeeded) + expect(args[:params][:ready_for_payment_processing]).to be false + expect(args[:webhook_notification]).to be true + end + end + + context "when payment status is not succeeded" do + it "sets ready_for_payment_processing to true" do + flutterwave_service.send(:update_invoice_payment_status, payment_status: :failed) + + expect(Invoices::UpdateService).to have_received(:call) do |args| + expect(args[:params][:ready_for_payment_processing]).to be true + end + end + end + end + end +end diff --git a/spec/services/invoices/payments/generate_payment_url_service_spec.rb b/spec/services/invoices/payments/generate_payment_url_service_spec.rb new file mode 100644 index 0000000..e5ea8dd --- /dev/null +++ b/spec/services/invoices/payments/generate_payment_url_service_spec.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::GeneratePaymentUrlService do + subject(:generate_payment_url_service) { described_class.new(invoice:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, payment_provider: provider, payment_provider_code: code) } + let(:invoice) { create(:invoice, customer:) } + let(:provider) { "stripe" } + let(:code) { "stripe_1" } + let(:payment_provider) { create(:stripe_provider, code:, organization:) } + let!(:provider_customer) { create(:stripe_customer, payment_provider:, customer:) } + + describe ".call" do + context "when payment provider is linked" do + before do + provider_customer + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example55.com"}) + end + + it "returns the generated payment url" do + result = generate_payment_url_service.call + + expect(result.payment_url).to eq("https://example55.com") + end + end + + context "when invoice is blank" do + it "returns an error" do + result = described_class.new(invoice: nil).call + + expect(result).not_to be_success + expect(result.error.message).to eq("invoice_not_found") + end + end + + context "when payment provider is blank" do + let(:provider) { nil } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["no_linked_payment_provider"]) + end + end + + context "when payment provider is missing" do + let(:payment_provider) { nil } + let(:provider_customer) { nil } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["missing_payment_provider"]) + end + end + + context "when payment provider customer is missing" do + let(:provider_customer) { nil } + + before { payment_provider } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["missing_payment_provider_customer"]) + end + end + + context "when payment provider is gocardless" do + let(:provider) { "gocardless" } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["invalid_payment_provider"]) + end + end + + context "when invoice payment status is invalid" do + before { invoice.payment_succeeded! } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["invalid_invoice_status_or_payment_status"]) + end + end + + context "when provider service return a third party error" do + let(:provider) { "cashfree" } + let(:code) { "cashfree_1" } + + let(:payment_provider_service) { instance_double(Invoices::Payments::CashfreeService) } + let(:payment_provider) { create(:cashfree_provider, code:, organization:) } + let!(:provider_customer) { create(:cashfree_customer, payment_provider:, customer:) } + + let(:error_result) do + described_class::Result.new.tap do |result| + result.fail_with_error!( + BaseService::ThirdPartyFailure.new( + result, + third_party: "Cashfree", + error_code: "400", + error_message: '{"code: "link_post_failed", "type": "invalid_request_error"}' + ) + ) + end + end + + before do + allow(Invoices::Payments::CashfreeService) + .to receive(:new) + .and_return(payment_provider_service) + + allow(payment_provider_service).to receive(:generate_payment_url) + .and_return(error_result) + end + + it "delivers an error webhook" do + expect { generate_payment_url_service.call } + .to enqueue_job(SendWebhookJob) + .with( + "invoice.payment_failure", + invoice, + provider_customer_id: provider_customer.provider_customer_id, + provider_error: { + message: '{"code: "link_post_failed", "type": "invalid_request_error"}', + error_code: "400" + } + ).on_queue(webhook_queue) + end + + it "returns a third party error" do + result = generate_payment_url_service.call + + expect(result).to eq(error_result) + end + end + + context "when provider service return an error" do + let(:payment_provider_service) { instance_double(Invoices::Payments::StripeService) } + + let(:error_result) do + described_class::Result.new.tap do |result| + result.fail_with_error!( + BaseService::ServiceFailure.new( + result, + code: "400", + error_message: "error" + ) + ) + end + end + + before do + allow(Invoices::Payments::StripeService) + .to receive(:new) + .and_return(payment_provider_service) + + allow(payment_provider_service).to receive(:generate_payment_url) + .and_return(error_result) + end + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).to eq(error_result) + end + end + end +end diff --git a/spec/services/invoices/payments/gocardless_service_spec.rb b/spec/services/invoices/payments/gocardless_service_spec.rb new file mode 100644 index 0000000..5dd3757 --- /dev/null +++ b/spec/services/invoices/payments/gocardless_service_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::GocardlessService do + subject(:gocardless_service) { described_class.new(argument) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:gocardless_payment_provider) { create(:gocardless_provider, organization:, code:) } + let(:gocardless_customer) { create(:gocardless_customer, customer:) } + let(:gocardless_client) { instance_double(GoCardlessPro::Client) } + let(:gocardless_payments_service) { instance_double(GoCardlessPro::Services::PaymentsService) } + let(:gocardless_mandates_service) { instance_double(GoCardlessPro::Services::MandatesService) } + let(:gocardless_list_response) { instance_double(GoCardlessPro::ListResponse) } + let(:argument) { invoice } + let(:code) { "gocardless_1" } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + describe "#update_payment_status" do + let(:payment) do + create( + :payment, + payable: invoice, + provider_payment_id: "ch_123456", + status: "pending_submission", + payment_provider: gocardless_payment_provider + ) + end + + before do + payment + end + + it "updates the payment and invoice payment_status" do + result = gocardless_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "paid_out" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("paid_out") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false, + total_paid_amount_cents: 200 + ) + end + + it "enqueues a SendWebhookJob for payment.succeeded" do + expect do + gocardless_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "paid_out" + ) + end.to have_enqueued_job(SendWebhookJob).with("payment.succeeded", Payment) + end + + context "when status is failed" do + it "updates the payment and invoice status" do + result = gocardless_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "failed" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + expect(result.invoice.reload).to have_attributes( + payment_status: "failed", + ready_for_payment_processing: true + ) + end + end + + context "when invoice is already payment_succeeded" do + before { invoice.payment_succeeded! } + + it "does not update the status of invoice and payment" do + result = gocardless_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "paid_out" + ) + + expect(result).to be_success + expect(result.invoice.payment_status).to eq("succeeded") + end + end + + context "with invalid status" do + it "does not update the payment_status of invoice" do + result = gocardless_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "foo-bar" + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + end + + context "when invoice is not passed to constructor" do + let(:argument) { nil } + + it "updates the payment and invoice payment_status" do + result = gocardless_service.update_payment_status( + provider_payment_id: "ch_123456", + status: "paid_out" + ) + + expect(result).to be_success + expect(result.payment.status).to eq("paid_out") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false + ) + end + end + end +end diff --git a/spec/services/invoices/payments/mark_overdue_service_spec.rb b/spec/services/invoices/payments/mark_overdue_service_spec.rb new file mode 100644 index 0000000..6a68944 --- /dev/null +++ b/spec/services/invoices/payments/mark_overdue_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::MarkOverdueService do + let(:result) { described_class.call(invoice:) } + + let(:invoice) do + create(:invoice, + payment_due_date: invoice_due_date, + status: invoice_status, + payment_status: invoice_payment_status, + payment_dispute_lost_at: invoice_dispute_lost_at) + end + let(:invoice_payment_status) { :pending } + let(:invoice_due_date) { 1.day.ago } + let(:invoice_status) { :finalized } + let(:invoice_dispute_lost_at) { nil } + + describe "#call" do + it "mark the invoice as payment_overdue" do + expect(result.invoice.payment_overdue).to be_truthy + end + + it "sends invoice.payment_overdue hook" do + invoice = result.invoice + + expect(SendWebhookJob).to have_been_enqueued.with("invoice.payment_overdue", invoice) + end + + it "produces an activity log" do + invoice = result.invoice + + expect(Utils::ActivityLog).to have_produced("invoice.payment_overdue").after_commit.with(invoice) + end + + context "when invoice is nil" do + let(:invoice) { nil } + + it "returns a not found error" do + expect(result.success?).to be(false) + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice is not finalized" do + let(:invoice_status) { :draft } + + it "returns not allowed failure" do + expect(result.success?).to be(false) + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoice_not_finalized") + end + end + + context "when invoice payment succeeded" do + let(:invoice_payment_status) { :succeeded } + + it "returns not allowed failure" do + expect(result.success?).to be(false) + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoice_payment_already_succeeded") + end + end + + context "when invoice due date is in future" do + let(:invoice_due_date) { 5.days.from_now } + + it "returns not allowed failure" do + expect(result.success?).to be(false) + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoice_due_date_in_future") + end + end + + context "when invoice is disputed and lost" do + let(:invoice_dispute_lost_at) { 1.day.ago } + + it "returns not allowed failure" do + expect(result.success?).to be(false) + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoice_dispute_lost") + end + end + end +end diff --git a/spec/services/invoices/payments/moneyhash_service_spec.rb b/spec/services/invoices/payments/moneyhash_service_spec.rb new file mode 100644 index 0000000..ea0ce98 --- /dev/null +++ b/spec/services/invoices/payments/moneyhash_service_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::MoneyhashService do + subject(:moneyhash_service) { described_class.new(invoice) } + + let(:invoice) { create(:invoice, organization:, customer:, invoice_type: :subscription, payment_status: :pending) } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:moneyhash_provider) { create(:moneyhash_provider, organization:) } + let(:moneyhash_customer) { create(:moneyhash_customer, customer:, payment_provider: moneyhash_provider) } + + let(:intent_processed_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/intent.processed.json"))) } + let(:provider_payment_id) { intent_processed_json.dig("data", "id") } + + let(:mh_provider_service) { PaymentProviders::MoneyhashService.new } + + let(:payment_url_response) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/checkout_url_response.json"))) } + + describe "#update_payment_status" do + before do + intent_processed_json["data"]["intent"]["custom_fields"]["lago_payable_id"] = invoice.id + + moneyhash_provider + moneyhash_customer + end + + it "creates a payment and updates the invoice payment status" do + result = moneyhash_service.update_payment_status( + organization_id: organization.id, + provider_payment_id: intent_processed_json.dig("data", "intent_id"), + status: "SUCCESSFUL", + metadata: intent_processed_json.dig("data", "intent", "custom_fields") + ).raise_if_error! + + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + expect(result.payment.provider_payment_id).to eq(intent_processed_json.dig("data", "intent_id")) + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.payment_status).to eq("succeeded") + expect(result.invoice.payment_attempts).to eq(1) + end + + it "enqueues a SendWebhookJob for payment.succeeded" do + expect do + moneyhash_service.update_payment_status( + organization_id: organization.id, + provider_payment_id: intent_processed_json.dig("data", "intent_id"), + status: "SUCCESSFUL", + metadata: intent_processed_json.dig("data", "intent", "custom_fields") + ) + end.to have_enqueued_job(SendWebhookJob).with("payment.succeeded", Payment) + end + end + + describe "#generate_payment_url" do + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "#{PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/" } + let(:payment_intent) { create(:payment_intent) } + + before do + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(payment_url_response.to_json) + + moneyhash_provider + moneyhash_customer + end + + it "generates the payment url" do + result = moneyhash_service.generate_payment_url(payment_intent) + expect(result).to be_success + expect(result.payment_url).to eq("#{payment_url_response.dig("data", "embed_url")}?lago_request=generate_payment_url") + end + end +end diff --git a/spec/services/invoices/payments/payment_providers/factory_spec.rb b/spec/services/invoices/payments/payment_providers/factory_spec.rb new file mode 100644 index 0000000..b24ec60 --- /dev/null +++ b/spec/services/invoices/payments/payment_providers/factory_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::PaymentProviders::Factory do + subject(:factory_service) { described_class.new_instance(invoice:) } + + let(:payment_provider) { "stripe" } + let(:invoice) { create(:invoice, customer:) } + let(:customer) { create(:customer, payment_provider:) } + + describe "#self.new_instance" do + context "when stripe" do + it "returns correct class" do + expect(factory_service.class.to_s).to eq("Invoices::Payments::StripeService") + end + end + + context "when adyen" do + let(:payment_provider) { "adyen" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("Invoices::Payments::AdyenService") + end + end + + context "when gocardless" do + let(:payment_provider) { "gocardless" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("Invoices::Payments::GocardlessService") + end + end + + context "when cashfree" do + let(:payment_provider) { "cashfree" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("Invoices::Payments::CashfreeService") + end + end + + context "when moneyhash" do + let(:payment_provider) { "moneyhash" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("Invoices::Payments::MoneyhashService") + end + end + + context "when flutterwave" do + let(:payment_provider) { "flutterwave" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("Invoices::Payments::FlutterwaveService") + end + end + end +end diff --git a/spec/services/invoices/payments/retry_batch_service_spec.rb b/spec/services/invoices/payments/retry_batch_service_spec.rb new file mode 100644 index 0000000..efa7041 --- /dev/null +++ b/spec/services/invoices/payments/retry_batch_service_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::RetryBatchService do + subject(:retry_batch_service) { described_class.new(organization_id: organization.id) } + + let(:customer) { create(:customer, payment_provider: "stripe") } + let(:organization) { customer.organization } + + describe "#call_async" do + it "enqueues a job to retry all payments" do + expect do + retry_batch_service.call_async + end.to have_enqueued_job(Invoices::Payments::RetryAllJob) + end + end + + describe "#call" do + let(:invoice_ids) { [invoice_first.id, invoice_second.id] } + let(:invoice_first) do + create( + :invoice, + customer:, + status: "finalized", + payment_status: "failed", + ready_for_payment_processing: true + ) + end + let(:invoice_second) do + create( + :invoice, + customer:, + status: "finalized", + payment_status: "failed", + ready_for_payment_processing: true + ) + end + let(:invoice_third) do + create( + :invoice, + customer:, + status: "draft", + ready_for_payment_processing: true + ) + end + + before do + invoice_first + invoice_second + invoice_third + end + + it "returns processed invoices that have correct status and payment status" do + result = retry_batch_service.call(invoice_ids) + + expect(result).to be_success + expect(result.invoices.count).to eq(2) + + processed_ids = result.invoices.pluck(:id) + + expect(processed_ids).to include(invoice_first.id) + expect(processed_ids).to include(invoice_second.id) + expect(processed_ids).not_to include(invoice_third.id) + end + + context "when inner service passes error result" do + let(:invoice_second) do + create( + :invoice, + customer:, + status: "finalized", + payment_status: "failed", + ready_for_payment_processing: false + ) + end + + it "returns an error" do + result = retry_batch_service.call(invoice_ids) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("payment_processor_is_currently_handling_payment") + end + end + end +end diff --git a/spec/services/invoices/payments/retry_service_spec.rb b/spec/services/invoices/payments/retry_service_spec.rb new file mode 100644 index 0000000..6a0ce4b --- /dev/null +++ b/spec/services/invoices/payments/retry_service_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::RetryService do + subject(:retry_service) { described_class.new(invoice:) } + + let(:invoice) { create(:invoice, customer:, status: "finalized", organization: customer.organization) } + let(:customer) { create(:customer, payment_provider:) } + let(:payment_provider) { "stripe" } + + describe "#call" do + it "enqueues a job to create a new stripe payment" do + expect do + retry_service.call + end.to have_enqueued_job(Invoices::Payments::CreateJob).with(invoice:, payment_provider: payment_provider.to_sym, payment_method_params: {}) + end + + context "with gocardless payment provider" do + let(:payment_provider) { "gocardless" } + + it "enqueues a job to create a gocardless payment" do + expect do + retry_service.call + end.to have_enqueued_job(Invoices::Payments::CreateJob).with(invoice:, payment_provider: payment_provider.to_sym, payment_method_params: {}) + end + end + + context "when invoice does not exist" do + let(:invoice) { nil } + + it "returns an error" do + result = retry_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice payment status is already succeeded" do + let(:invoice) do + create( + :invoice, + customer:, + status: "finalized", + payment_status: "succeeded", + organization: customer.organization + ) + end + + it "returns an error" do + result = retry_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_status") + end + end + + context "when invoice status is draft" do + let(:invoice) do + create(:invoice, customer:, payment_status: "pending", status: "draft", organization: customer.organization) + end + + it "returns an error" do + result = retry_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invalid_status") + end + end + + context "when invoice is not ready for payment processing" do + let(:invoice) do + create( + :invoice, + customer:, + status: "finalized", + payment_status: "failed", + ready_for_payment_processing: false + ) + end + + it "returns an error" do + result = retry_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("payment_processor_is_currently_handling_payment") + end + end + + context "when no payment provider" do + let(:payment_provider) { nil } + + it "delivers an error webhook" do + expect { retry_service.call } + .to enqueue_job(SendWebhookJob) + .with( + "invoice.payment_failure", + invoice, + error_details: {code: "customer_must_have_payment_provider"} + ).on_queue(webhook_queue) + end + end + end +end diff --git a/spec/services/invoices/payments/stripe_service_spec.rb b/spec/services/invoices/payments/stripe_service_spec.rb new file mode 100644 index 0000000..82660d2 --- /dev/null +++ b/spec/services/invoices/payments/stripe_service_spec.rb @@ -0,0 +1,419 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Payments::StripeService do + subject(:stripe_service) { described_class.new(invoice) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:stripe_payment_provider) { create(:stripe_provider, organization:, code:) } + let(:stripe_customer) { create(:stripe_customer, customer:, payment_method_id: "pm_123456") } + let(:code) { "stripe_1" } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + total_paid_amount_cents:, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + let(:total_paid_amount_cents) { 0 } + + describe "#generate_payment_url" do + let(:payment_intent) { create(:payment_intent) } + + before do + stripe_payment_provider + stripe_customer + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example.com"}) + end + + it "generates payment url" do + stripe_service.generate_payment_url(payment_intent) + + expect(::Stripe::Checkout::Session).to have_received(:create) + end + + describe "#payment_url_payload" do + let(:payment_url_payload) { stripe_service.__send__(:payment_url_payload, payment_intent) } + + let(:payload) do + { + line_items: [ + { + quantity: 1, + price_data: { + currency: invoice.currency.downcase, + unit_amount: invoice.total_due_amount_cents, + product_data: { + name: invoice.number + } + } + } + ], + mode: "payment", + success_url: stripe_service.__send__(:success_redirect_url), + customer: customer.stripe_customer.provider_customer_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + expires_at: payment_intent.expires_at.to_i, + payment_intent_data: { + description: stripe_service.__send__(:description), + setup_future_usage: "off_session", + metadata: { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type, + payment_type: "one-time" + } + } + } + end + + context "when paid amount is not zero" do + let(:total_paid_amount_cents) { 1 } + + it "return the payload" do + expect(payment_url_payload).to eq(payload) + end + end + + context "when paid amount is zero" do + it "returns the payload" do + expect(payment_url_payload).to eq(payload) + end + end + + context "when customer is from India" do + let(:customer) { create(:customer, payment_provider_code: code, country: "IN") } + + it "does not save the card" do + expect(payment_url_payload[:payment_intent_data][:setup_future_usage]).to be_nil + end + end + + context "when customer can use crypto" do + it "does not save the card" do + stripe_customer.provider_payment_methods << "crypto" + stripe_customer.save! + expect(payment_url_payload[:payment_intent_data][:setup_future_usage]).to be_nil + end + end + end + + context "with an error on Stripe" do + before do + allow(::Stripe::Checkout::Session).to receive(:create) + .and_raise(::Stripe::InvalidRequestError.new("error", {})) + end + + it "returns a failed result" do + result = stripe_service.generate_payment_url(payment_intent) + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Stripe") + expect(result.error.error_message).to eq("error") + end + end + end + + describe "#update_payment_status" do + let(:payment) do + create( + :payment, + payable: invoice, + provider_payment_id: "ch_123456" + ) + end + + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: {}, + error_code: nil + ) + end + + before do + allow(SegmentTrackJob).to receive(:perform_later) + payment + end + + it "updates the payment and invoice status" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false, + total_paid_amount_cents: invoice.total_amount_cents + ) + end + + it "enqueues a SendWebhookJob for payment.succeeded" do + expect do + stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + end.to have_enqueued_job(SendWebhookJob).with("payment.succeeded", Payment) + end + + context "when status is failed" do + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "canceled", + metadata: {}, + error_code: nil + ) + end + + it "updates the payment and invoice status" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "failed", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + expect(result.invoice.reload).to have_attributes( + payment_status: "failed", + ready_for_payment_processing: true + ) + end + + context "when there is another payment in requires_action state for the invoice" do + it "updates the payment status but not the invoice status" do + # We can only have one `pending/processing` payment for an invoice + # in this case, we're testing a webhook arriving later when the retry with 3ds support has already started + # This can only happen if the first payment, was already failed. + payment.update!(payable_payment_status: "failed") + old_value = payment.payable.ready_for_payment_processing + create(:payment, payable: invoice, status: "requires_action") + + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "failed", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + + expect(result.invoice.reload).to have_attributes( + payment_status: "pending", + ready_for_payment_processing: old_value + ) + end + end + end + + context "when invoice is already payment_succeeded" do + before { invoice.payment_succeeded! } + + it "does not update the status of invoice and payment" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).to be_success + expect(result.invoice.payment_status).to eq("succeeded") + end + end + + context "with invalid status" do + it "does not update the status of invoice and payment" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "foo-bar", + stripe_payment: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: {lago_invoice_id: invoice.id, payment_type: "one-time"}, + error_code: nil + ) + end + + before do + stripe_payment_provider + stripe_customer + end + + it "creates a payment and updates invoice payment status" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment.organization).to eq(organization) + expect(result.payment.status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false + ) + end + + context "when invoice is not found" do + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: {lago_invoice_id: "invalid", payment_type: "one-time"}, + error_code: nil + ) + end + + it "raises a not found failure" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("invoice_not_found") + end + end + end + + context "when payment is not found" do + let(:payment) { nil } + + it "returns an empty result" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment).to be_nil + end + + context "with invoice id in metadata" do + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: {lago_invoice_id: SecureRandom.uuid}, + error_code: nil + ) + end + + it "returns an empty result" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment).to be_nil + end + + context "when the invoice is found for organization" do + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: {lago_invoice_id: invoice.id}, + error_code: nil + ) + end + + before do + stripe_customer + stripe_payment_provider + end + + it "creates the missing payment and updates invoice status" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.invoice.reload).to have_attributes( + payment_status: "succeeded", + ready_for_payment_processing: false + ) + + expect(invoice.payments.count).to eq(1) + payment = invoice.payments.first + expect(payment).to have_attributes( + payable: invoice, + payment_provider_id: stripe_payment_provider.id, + payment_provider_customer_id: stripe_customer.id, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency, + provider_payment_id: "ch_123456", + status: "succeeded" + ) + end + end + end + end + + context "when payment's payable belongs to another organization" do + let(:invoice) { create(:invoice) } + + it "does not update the payment status" do + result = stripe_service.update_payment_status( + organization_id: organization.id, + status: "succeeded", + stripe_payment: + ) + + expect(result).to be_success + expect(result.payment).to be_nil + expect(invoice.reload.payment_status).to eq("pending") + expect(payment.reload.status).to eq("pending") + end + end + end +end diff --git a/spec/services/invoices/preview/build_subscription_service_spec.rb b/spec/services/invoices/preview/build_subscription_service_spec.rb new file mode 100644 index 0000000..d484967 --- /dev/null +++ b/spec/services/invoices/preview/build_subscription_service_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Preview::BuildSubscriptionService do + describe ".call" do + subject(:result) { described_class.call(customer:, params:) } + + let(:subscriptions) { result.subscriptions } + + context "when customer is missing" do + let(:customer) { nil } + let(:params) { {} } + + it "fails with customer not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("customer_not_found") + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + + context "when customer is present" do + let(:customer) { create(:customer) } + + context "when plan matching code exists in the customer's organization" do + let(:plan) { create(:plan, organization: customer.organization) } + + let(:params) do + { + plan_code: plan&.code, + billing_time:, + subscription_at: subscription_at&.iso8601 + } + end + + before do + create(:plan, organization: customer.organization, code: plan.code, parent: plan) + plan.touch # rubocop:disable Rails/SkipsModelValidations + end + + context "when valid billing time and subscribed at are provided" do + let(:billing_time) { Subscription::BILLING_TIME.sample.to_s } + let(:subscription_at) { generate(:past_date) } + + let(:expected_attributes) do + { + billing_time:, + plan:, + customer:, + subscription_at: subscription_at.change(usec: 0), + started_at: subscription_at.change(usec: 0) + } + end + + it "returns array containing new subscription with provided inputs" do + expect(result).to be_success + expect(subscriptions).to contain_exactly Subscription + + expect(subscriptions.first) + .to be_new_record + .and have_attributes(expected_attributes) + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + + context "when invalid or empty billing time and subscribed at are provided" do + let(:billing_time) { "non-existing-time" } + let(:subscription_at) { nil } + + let(:expected_attributes) do + { + billing_time: "calendar", + plan:, + customer:, + subscription_at: Time.current, + started_at: Time.current + } + end + + before { freeze_time } + + it "returns array containing new subscription with defaults" do + expect(result).to be_success + expect(subscriptions).to contain_exactly Subscription + + expect(subscriptions.first) + .to be_new_record + .and have_attributes(expected_attributes) + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + end + + context "when plan matching code does not exist in the customer's organization" do + let(:params) { {plan_code: create(:plan).code} } + + it "fails with plan not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("plan_not_found") + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + end + end +end diff --git a/spec/services/invoices/preview/credits_service_spec.rb b/spec/services/invoices/preview/credits_service_spec.rb new file mode 100644 index 0000000..385276c --- /dev/null +++ b/spec/services/invoices/preview/credits_service_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Preview::CreditsService do + describe ".call" do + subject(:result) { described_class.call(invoice:, terminated_subscription:) } + + let(:credits) { result.credits.map { |c| c.attributes.symbolize_keys } } + + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:invoice) { build(:invoice, customer:, organization:, total_amount_cents: 10_000) } + + let(:credit_notes) do + create_pair( + :credit_note, + customer:, + invoice: build(:invoice, customer:, organization:) + ) + end + + let(:expected_credits_from_customer) do + credit_notes.map do |note| + hash_including( + amount_cents: note.total_amount_cents, + amount_currency: note.total_amount_currency + ) + end + end + + context "when terminated_subscription is present" do + let(:plan) { create(:plan, organization:, pay_in_advance:, amount_cents: 10_000) } + + let(:subscription) do + create( + :subscription, + organization:, + customer:, + plan:, + subscription_at: Time.zone.parse("2025-02-01"), + started_at: Time.zone.parse("2025-02-01") + ) + end + + let(:terminated_subscription) do + subscription.tap do |sub| + sub.assign_attributes( + status: :terminated, + terminated_at: Time.zone.parse("15-02-2025") + ) + end + end + + before do + BillSubscriptionJob.perform_now( + [subscription], + Time.zone.parse("2025-02-01").to_i, + invoicing_reason: :subscription_starting + ) + + credit_notes + end + + context "when subscription has a credit note" do + let(:pay_in_advance) { true } + + let(:expected_credits_from_subscription) do + [ + hash_including( + amount_cents: 4643, + amount_currency: "EUR" + ) + ] + end + + it "returns credits generated from subscription and customer credit notes" do + expect(result).to be_success + expect(credits).to match_array expected_credits_from_customer + expected_credits_from_subscription + end + end + + context "when subscription has no credit note" do + let(:pay_in_advance) { false } + + it "returns credits generated from customer's credit notes" do + expect(result).to be_success + expect(credits).to match_array expected_credits_from_customer + end + end + end + + context "when terminated_subscription is missing" do + let(:terminated_subscription) { nil } + + before { credit_notes } + + it "returns credits generated from customer's credit notes" do + expect(result).to be_success + expect(credits).to match_array expected_credits_from_customer + end + end + end +end diff --git a/spec/services/invoices/preview/find_subscriptions_service_spec.rb b/spec/services/invoices/preview/find_subscriptions_service_spec.rb new file mode 100644 index 0000000..bdb36ce --- /dev/null +++ b/spec/services/invoices/preview/find_subscriptions_service_spec.rb @@ -0,0 +1,148 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Preview::FindSubscriptionsService do + describe ".call" do + subject(:result) { described_class.call(subscriptions:) } + + let(:subscriptions_result) { result.subscriptions } + + context "when subscriptions are missing" do + let(:subscriptions) { [] } + + it "fails with subscription not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when subscriptions are present" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + context "when subscriptions has no next subscription" do + let(:subscriptions) { create_pair(:subscription, organization:, customer:) } + + it "returns the subscriptions as is" do + expect(result).to be_success + expect(subscriptions_result).to match_array subscriptions + end + + it "does not persist any changes to the subscriptions" do + expect { subject }.not_to change { subscriptions.map { |s| s.reload.attributes } } + end + end + + context "when subscription is pending" do + let(:subscriptions) { [create(:subscription, organization:, customer:, status: :pending)] } + + it "returns the duplicate of subscription" do + expect(result).to be_success + expect(subscriptions_result.size).to eq(1) + expect(subscriptions_result.first.status.to_s).to eq("active") + expect(subscriptions_result.first.persisted?).to eq(false) + expect(subscriptions_result.first.external_id).to eq(subscriptions.first.external_id) + end + + it "does not change original subscription" do + expect(result).to be_success + expect(subscriptions.first.reload.status.to_s).to eq("pending") + expect(subscriptions.first.reload.persisted?).to eq(true) + end + end + + context "when subscription has a next subscription" do + let(:current_plan) { create(:plan, organization:, pay_in_advance: true) } + let(:next_plan) { create(:plan, organization:, pay_in_advance:, amount_cents:) } + let!(:subscription) { create(:subscription, plan: current_plan, customer:, organization:, next_subscriptions: [next_subscription]) } + let!(:next_subscription) { create(:subscription, :pending, plan: next_plan, customer:, organization:) } + + let(:subscriptions) { [subscription] } + + before { travel_to Time.zone.parse("05-02-2025 12:34:56") } + + context "when next plan is pay in advance" do + let(:pay_in_advance) { true } + + context "when next plan is same price or more expensive (upgrade)" do + let(:amount_cents) { current_plan.amount_cents + 100 } + + it "returns the subscriptions as is" do + expect(result).to be_success + expect(subscriptions_result).to match_array subscriptions + end + + it "does not persist any changes to the subscriptions" do + expect { subject }.to not_change { subscription.reload.attributes }.and(not_change { next_subscription.reload.attributes }) + end + end + + context "when next plan is cheaper (downgrade)" do + let(:amount_cents) { current_plan.amount_cents - 100 } + let(:end_of_period) { Time.zone.parse("01-03-2025").end_of_day } + + it "returns array containing terminated current and adjusted next subscription" do + expect(result).to be_success + expect(subscriptions_result.count).to eq(2) + + expect(subscriptions_result.first).to have_attributes( + id: subscription.id, + status: "terminated", + terminated_at: end_of_period + ) + + expect(subscriptions_result.second).to have_attributes( + id: next_subscription.id, + status: "active", + started_at: end_of_period + ) + end + + it "does not persist any changes to the subscriptions" do + expect { subject }.to not_change { subscription.reload.attributes }.and(not_change { next_subscription.reload.attributes }) + end + end + end + + context "when next plan is pay in arrears" do + let(:pay_in_advance) { false } + + context "when next plan is same price or more expensive (upgrade)" do + let(:amount_cents) { current_plan.amount_cents + 100 } + + it "returns the subscriptions as is" do + expect(result).to be_success + expect(subscriptions_result).to match_array subscriptions + end + + it "does not persist any changes to the subscriptions" do + expect { subject }.to not_change { subscription.reload.attributes } + .and(not_change { next_subscription.reload.attributes }) + end + end + + context "when next plan is cheaper (downgrade)" do + let(:amount_cents) { current_plan.amount_cents - 100 } + let(:end_of_period) { Time.zone.parse("01-03-2025").end_of_day } + + it "returns array containing only terminated current subscription" do + expect(result).to be_success + expect(subscriptions_result.count).to eq(1) + + expect(subscriptions_result.first).to have_attributes( + id: subscription.id, + status: "terminated", + terminated_at: end_of_period + ) + end + + it "does not persist any changes to the subscriptions" do + expect { subject }.to not_change { subscription.reload.attributes }.and(not_change { next_subscription.reload.attributes }) + end + end + end + end + end + end +end diff --git a/spec/services/invoices/preview/subscription_plan_change_service_spec.rb b/spec/services/invoices/preview/subscription_plan_change_service_spec.rb new file mode 100644 index 0000000..07fac86 --- /dev/null +++ b/spec/services/invoices/preview/subscription_plan_change_service_spec.rb @@ -0,0 +1,177 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Preview::SubscriptionPlanChangeService do + describe ".call" do + subject(:result) { described_class.call(current_subscription:, target_plan_code:) } + + let(:subscriptions) { result.subscriptions } + + context "when current subscription is missing" do + let(:current_subscription) { nil } + let(:target_plan_code) { nil } + + it "fails with subscription not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when plan matching code does not exist" do + let(:current_subscription) { create(:subscription) } + let(:target_plan_code) { "non-existing-code" } + + it "fails with plan not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "when current subscription and matching plan are present" do + let!(:current_subscription) { create(:subscription, plan: current_plan, organization:) } + let(:current_plan) { create(:plan, organization:) } + let(:organization) { create(:organization) } + let(:target_plan_code) { target_plan.code } + + context "when target plan is the same as current subscription's plan" do + let(:target_plan) { current_plan } + + it "fails with invalid target plan error" do + expect(result).to be_failure + + expect(result.error.messages) + .to match(base: ["new_plan_should_be_different_from_existing_plan"]) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + + context "when target plan is not the same as current subscription's plan" do + let(:target_plan) { create(:plan, organization:, pay_in_advance:, amount_cents:) } + + before do + travel_to Time.zone.parse("05-02-2025 12:34:56") + create(:plan, organization:, code: target_plan.code, parent: target_plan) + target_plan.touch # rubocop:disable Rails/SkipsModelValidations + end + + context "when target plan is pay in advance" do + let(:pay_in_advance) { true } + + context "when target plan is same price or more expensive" do + let(:amount_cents) { current_plan.amount_cents } + + it "returns array containing terminated current and new subscriptions" do + expect(result).to be_success + expect(subscriptions).to match_array [current_subscription, Subscription] + + expect(subscriptions.first).to have_attributes( + status: "terminated", + next_subscription: Subscription, + terminated_at: Time.current + ) + + expect(subscriptions.second) + .to be_new_record + .and have_attributes(status: "active", started_at: Time.current, name: target_plan.name, plan: target_plan) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + + context "when target plan is cheaper" do + let(:amount_cents) { current_plan.amount_cents - 1 } + let(:start_of_next_billing_period) { Time.zone.parse("01-03-2025").end_of_day } + + it "returns array containing terminated current and new subscriptions" do + expect(result).to be_success + expect(subscriptions).to match_array [current_subscription, Subscription] + + expect(subscriptions.first).to have_attributes( + status: "terminated", + next_subscription: Subscription, + terminated_at: start_of_next_billing_period + ) + + expect(subscriptions.second) + .to be_new_record + .and have_attributes(status: "active", started_at: start_of_next_billing_period, name: target_plan.name, plan: target_plan) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + end + + context "when target plan is not pay in advance" do + let(:pay_in_advance) { false } + + context "when target plan is same price or more expensive" do + let(:amount_cents) { current_plan.amount_cents } + + it "returns array containing terminated current subscription" do + expect(result).to be_success + expect(subscriptions).to contain_exactly current_subscription + + expect(subscriptions.first).to have_attributes( + status: "terminated", + next_subscription: Subscription, + terminated_at: Time.current + ) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + + context "when target plan is cheaper" do + let(:amount_cents) { current_plan.amount_cents - 1 } + let(:start_of_next_billing_period) { Time.zone.parse("01-03-2025").end_of_day } + + it "returns array containing terminated current subscription" do + expect(result).to be_success + expect(subscriptions).to contain_exactly current_subscription + + expect(subscriptions.first).to have_attributes( + status: "terminated", + next_subscription: Subscription, + terminated_at: start_of_next_billing_period + ) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + + it "does not create any subscription" do + expect { subject }.not_to change(Subscription, :count) + end + end + end + end + end + end +end diff --git a/spec/services/invoices/preview/subscription_termination_service_spec.rb b/spec/services/invoices/preview/subscription_termination_service_spec.rb new file mode 100644 index 0000000..8ef73ea --- /dev/null +++ b/spec/services/invoices/preview/subscription_termination_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::Preview::SubscriptionTerminationService do + describe ".call" do + subject(:result) do + described_class.call(current_subscription:, terminated_at: terminated_at&.to_s) + end + + let(:subscriptions) { result.subscriptions } + + context "when current subscription is missing" do + let(:current_subscription) { nil } + let(:terminated_at) { nil } + + it "fails with subscription not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when current subscription is present" do + let(:current_subscription) { create(:subscription) } + + context "when termination at is a valid timestamp" do + context "when timestamp is in the past" do + let(:terminated_at) { Time.current - 1.second } + + it "fails with past timestamp error" do + expect(result).to be_failure + expect(result.error.messages).to match(terminated_at: ["cannot_be_in_past"]) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + end + + context "when timestamp is current time" do + let(:terminated_at) { Time.current } + + it "fails with past timestamp error" do + expect(result).to be_failure + expect(result.error.messages).to match(terminated_at: ["cannot_be_in_past"]) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + end + + context "when timestamp is in future" do + let(:terminated_at) { Time.current + 1.second } + + it "returns result with subscriptions marked as terminated" do + expect(result).to be_success + expect(subscriptions).to contain_exactly current_subscription + + expect(subscriptions.first).to have_attributes( + terminated_at: terminated_at.change(usec: 0), + status: "terminated" + ) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + end + end + + context "when termination at is not a valid timestamp" do + let(:terminated_at) { "2025" } + + it "fails with invalid timestamp error" do + expect(result).to be_failure + expect(result.error.messages).to match(terminated_at: ["invalid_timestamp"]) + end + + it "does not persist any changes to the current subscription" do + expect { subject }.not_to change { current_subscription.reload.attributes } + end + end + end + end +end diff --git a/spec/services/invoices/preview/subscriptions_service_spec.rb b/spec/services/invoices/preview/subscriptions_service_spec.rb new file mode 100644 index 0000000..56ec8f4 --- /dev/null +++ b/spec/services/invoices/preview/subscriptions_service_spec.rb @@ -0,0 +1,379 @@ +# frozen_string_literal: true + +RSpec.describe Invoices::Preview::SubscriptionsService do + let(:result) { described_class.call(organization:, customer:, params:) } + + describe ".call" do + subject { result.subscriptions } + + context "when organization is missing" do + let(:organization) { nil } + let(:customer) { nil } + let(:params) { {} } + + it "fails with organization not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("organization_not_found") + end + end + + context "when customer is missing" do + let(:organization) { create(:organization) } + let(:customer) { nil } + let(:params) { {} } + + it "fails with customer not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("customer_not_found") + end + end + + context "when customer and organization are present" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + context "when external_ids are provided" do + let(:subscriptions) { create_pair(:subscription, customer:) } + + context "when terminated at is not provided" do + context "when plan code is present" do + let(:params) do + { + subscriptions: { + external_ids:, + plan_code: target_plan.code + } + } + end + + let(:target_plan) { create(:plan, organization:, pay_in_advance: true) } + + context "when customer is a new record" do + let(:customer) { build(:customer, organization:) } + let(:external_ids) { [SecureRandom.uuid] } + + it "fails with customer not persisted error" do + expect(result).to be_failure + + expect(result.error.messages).to match(customer: ["must_be_persisted"]) + end + end + + context "when customer is a persisted record" do + context "when multiple subscriptions passed" do + let(:external_ids) { subscriptions.map(&:external_id) } + + it "fails with multiple subscriptions error" do + expect(result).to be_failure + + expect(result.error.messages) + .to match(subscriptions: ["only_one_subscription_allowed_for_plan_change"]) + end + end + + context "when single subscription passed" do + let(:external_ids) { [subscriptions.first.external_id] } + + before { freeze_time } + + it "returns result with subscriptions marked as terminated and new subscription" do + expect(result).to be_success + expect(subject).to match_array [subscriptions.first, Subscription] + + expect(subject.first) + .to have_attributes(status: "terminated", terminated_at: Time.current) + + expect(subject.second) + .to be_new_record + .and have_attributes(status: "active", started_at: Time.current, name: target_plan.name) + end + end + end + end + + context "when plan code is missing" do + let(:params) do + { + subscriptions: { + external_ids: + } + } + end + + context "when customer is a new record" do + let(:customer) { build(:customer, organization:) } + let(:external_ids) { [SecureRandom.uuid] } + + it "fails with customer not persisted error" do + expect(result).to be_failure + + expect(result.error.messages).to match(customer: ["must_be_persisted"]) + end + end + + context "when customer is a persisted record" do + let(:external_ids) { subscriptions.map(&:external_id) } + + it "returns persisted customer subscriptions" do + expect(result).to be_success + expect(subject.pluck(:external_id)).to match_array subscriptions.map(&:external_id) + end + end + end + end + + context "when terminated at is provided" do + let(:terminated_at) { generate(:future_date) } + + let(:params) do + { + subscriptions: { + external_ids: external_ids, + terminated_at: terminated_at.to_s + } + } + end + + context "when customer is a new record" do + let(:customer) { build(:customer, organization:) } + let(:external_ids) { [SecureRandom.uuid] } + + it "fails with customer not persisted error" do + expect(result).to be_failure + + expect(result.error.messages).to match(customer: ["must_be_persisted"]) + end + end + + context "when customer is a persisted record" do + context "when multiple subscriptions passed" do + let(:external_ids) { subscriptions.map(&:external_id) } + + it "fails with multiple subscriptions error" do + expect(result).to be_failure + + expect(result.error.messages) + .to match(subscriptions: ["only_one_subscription_allowed_for_termination"]) + end + end + + context "when single subscription passed" do + let(:external_ids) { [subscriptions.first.external_id] } + + it "returns result with subscriptions marked as terminated" do + expect(result).to be_success + + expect(subject).to all( + be_a(Subscription) + .and(have_attributes( + terminated_at: terminated_at.change(usec: 0), + status: "terminated" + )) + ) + end + end + end + end + + context "when subscription is ending" do + let(:external_ids) { [subscriptions.first.external_id] } + let(:ending_at) { end_of_period.iso8601 } + let(:end_of_period) do + Subscriptions::DatesService + .new_instance(subscriptions.first, Time.current, current_usage: true) + .end_of_period + end + let(:params) do + { + subscriptions: { + external_ids: external_ids + } + } + end + + before { subscriptions.first.update!(ending_at:) } + + context "with ending_at in current period" do + it "returns result with subscriptions marked as terminated" do + expect(result).to be_success + + expect(subject).to all( + be_a(Subscription) + .and(have_attributes( + terminated_at: end_of_period.change(usec: 0), + status: "terminated" + )) + ) + end + end + + context "with ending_at in the future" do + let(:ending_at) { (Time.current + 5.months).iso8601 } + + it "returns result with active subscription" do + expect(result).to be_success + + expect(subject).to all( + be_a(Subscription) + .and(have_attributes( + terminated_at: nil, + status: "active" + )) + ) + end + end + + context "without ending_at" do + let(:ending_at) { nil } + + it "returns result with active subscription" do + expect(result).to be_success + + expect(subject).to all( + be_a(Subscription) + .and(have_attributes( + terminated_at: nil, + status: "active" + )) + ) + end + end + end + + context "when subscription is pending" do + let(:pending_subscription) do + create( + :subscription, + customer:, + status: :pending, + subscription_at: + ) + end + let(:external_ids) { [pending_subscription.external_id] } + let(:params) do + { + subscriptions: { + external_ids: + } + } + end + + context "when subscription is starting in the future" do + let(:subscription_at) { Time.current + 2.days } + + it "returns pending subscription for preview" do + expect(result).to be_success + expect(subject.size).to eq(1) + expect(subject.first).to have_attributes( + status: "active", + plan_id: pending_subscription.plan_id, + subscription_at: pending_subscription.subscription_at, + billing_time: pending_subscription.billing_time, + customer_id: pending_subscription.customer_id, + external_id: pending_subscription.external_id + ) + end + end + + context "when subscription is not starting in the future" do + let(:subscription_at) { Time.current - 1.day } + + it "fails with subscription not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when subscription_at is exactly now" do + let(:subscription_at) { Time.current } + + it "fails with subscription not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when multiple pending subscriptions with same external_id exist" do + let(:subscription_at) { Time.current + 2.days } + let(:external_id) { SecureRandom.uuid } + let(:external_ids) { [external_id] } + + before do + create( + :subscription, + customer:, + external_id:, + status: :pending, + subscription_at: Time.current + 2.days + ) + create( + :subscription, + customer:, + external_id:, + status: :pending, + subscription_at: Time.current + 3.days + ) + end + + it "fails with subscription not found error when count is not 1" do + expect(result).to be_failure + expect(result.error.error_code).to eq("subscription_not_found") + end + end + end + end + + context "when external_ids are not provided" do + let(:params) do + { + billing_time:, + plan_code: plan.code, + subscription_at: subscription_at.iso8601 + } + end + + let(:plan) { create(:plan, organization:) } + let(:subscription_at) { generate(:past_date) } + let(:billing_time) { "anniversary" } + + context "when customer is a new record" do + let(:customer) { build(:customer, organization:) } + + it "returns new subscription with provided params" do + expect(result).to be_success + expect(subject).to contain_exactly Subscription + + expect(subject.first) + .to be_new_record + .and have_attributes( + customer:, + plan:, + subscription_at: subscription_at.change(usec: 0), + started_at: subscription_at.change(usec: 0), + billing_time: + ) + end + end + + context "when customer is a persisted record" do + let(:customer) { create(:customer, organization:) } + + it "returns new subscription with provided params" do + expect(result).to be_success + expect(subject).to contain_exactly Subscription + + expect(subject.first) + .to be_new_record + .and have_attributes( + customer:, + plan:, + subscription_at: subscription_at.change(usec: 0), + started_at: subscription_at.change(usec: 0), + billing_time: + ) + end + end + end + end + end +end diff --git a/spec/services/invoices/preview_context_service_spec.rb b/spec/services/invoices/preview_context_service_spec.rb new file mode 100644 index 0000000..6e6bd42 --- /dev/null +++ b/spec/services/invoices/preview_context_service_spec.rb @@ -0,0 +1,329 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::PreviewContextService do + let(:result) { described_class.call(organization:, params:, billing_entity:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:customer) { create(:customer, organization:) } + let(:billing_entity) { organization.default_billing_entity } + + let(:params) do + { + customer: {external_id: customer.external_id}, + plan_code: plan.code, + coupons: [] + } + end + + before do + create(:coupon, organization:) { create(:applied_coupon, coupon: it, customer:) } + end + + it "assigns customer, plan, and applied coupons to result" do + expect(result) + .to be_success + .and have_attributes(customer:, subscriptions: [Subscription], applied_coupons: customer.applied_coupons) + end + end + + describe "#customer" do + subject { result.customer } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + + before { create(:customer, organization:) } + + context "when customer external id is provided" do + let(:params) do + { + customer: { + external_id:, + tax_identification_number: "123", + currency: "USD", + address_line1: "Rue de Tax", + city: "Paris", + zipcode: "75011", + country: "IT", + shipping_address: { + address_line1: Faker::Address.street_address, + city: "Paris", + zipcode: "75011", + country: "IT" + }, + integration_customers: [ + { + integration_type: "anrok", + integration_code: "code" + } + ] + } + } + end + + before { create(:anrok_integration, organization:, code: "code") } + + context "when customer matching external id exists in organization" do + let(:customer) { create(:customer, organization:) } + let(:external_id) { customer.external_id } + + context "when integration matching params exists" do + it "returns customer with overrides from params" do + expect(subject) + .to be_a(Customer) + .and be_persisted + .and have_attributes( + id: customer.id, + name: customer.name, + currency: params.dig(:customer, :currency), + address_line1: params.dig(:customer, :address_line1), + shipping_address_line1: params.dig(:customer, :shipping_address, :address_line1), + integration_customers: array_including(IntegrationCustomers::AnrokCustomer) + ) + end + + it "does not change existing customer" do + expect { subject }.not_to change { customer.reload.updated_at } + end + + it "does not persist integrations" do + expect { subject }.not_to change { customer.reload.integration_customers.empty? } + end + end + + context "when customer matching external_id with another billing_entity" do + let(:billing_entity) { create(:billing_entity, organization:) } + + it "does not change existing customer" do + expect { subject }.not_to change { customer.reload.updated_at } + end + end + end + + context "when customer matching external id does not exist in organization" do + let(:external_id) { SecureRandom.uuid } + + it "returns nil" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("customer_not_found") + + expect(subject).to be_nil + end + end + end + + context "when customer external id is missing" do + let(:params) do + { + customer: { + name: "Mislav M", + tax_identification_number: "123", + currency: "EUR", + address_line1: "Rue de Tax", + address_line2: nil, + state: nil, + city: "Paris", + zipcode: "75011", + country: "IT", + shipping_address: { + address_line1: Faker::Address.street_address, + address_line2: Faker::Address.street_address, + city: "Paris", + state: nil, + zipcode: "75011", + country: "IT" + }, + integration_customers: [ + { + integration_type: "anrok", + integration_code: "code" + } + ] + } + } + end + + context "when integration matching params exists" do + let(:expected_attributes) do + params[:customer].tap do |hash| + hash[:integration_customers] = array_including(IntegrationCustomers::AnrokCustomer) + hash[:billing_entity_id] = billing_entity.id + end + end + + before { create(:anrok_integration, organization:, code: "code") } + + it "returns new customer build from params including integration customers and default billing_entity" do + expect(subject) + .to be_present + .and be_new_record + .and have_attributes(expected_attributes) + end + end + + context "when integration matching params does not exist" do + it "returns nil" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("anrok_integration_not_found") + + expect(subject).to be_nil + end + end + end + end + + describe "#subscriptions" do + subject { result.subscriptions } + + let(:organization) { customer.organization } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer) } + + let(:params) do + { + customer: {external_id: customer.external_id}, + plan_code: plan&.code, + subscription_at: subscription_at&.iso8601, + billing_time: + } + end + + context "with valid params" do + let(:plan) { create(:plan, organization:) } + let(:subscription_at) { generate(:past_date) } + let(:billing_time) { "anniversary" } + + before { freeze_time } + + it "returns new subscription with provided params" do + expect(subject) + .to all( + be_a(Subscription) + .and(have_attributes( + customer:, + plan:, + subscription_at: subscription_at, + started_at: subscription_at, + billing_time: params[:billing_time] + )) + ) + end + end + + context "with invalid params" do + let(:plan) { nil } + let(:subscription_at) { nil } + let(:billing_time) { nil } + + it "returns nil" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("plan_not_found") + + expect(subject).to be_nil + end + end + end + + describe "#applied_coupons" do + subject { result.applied_coupons } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:plan) { create(:plan, organization:) } + + let(:params) do + { + customer: customer_params, + plan_code: plan.code, + coupons: coupon_params + } + end + + context "when customer has applied coupons" do + let(:customer_params) { {external_id: customer.external_id} } + let(:customer) { create(:customer, organization:) } + + before do + create(:coupon, organization:) { create(:applied_coupon, coupon: it, customer:) } + end + + context "when coupons are provided" do + let(:coupon_params) do + [ + { + code: coupon.code + }, + { + code: "coupon_preview", + name: "coupon_preview", + coupon_type: "percentage", + amount_cents: 1200, + amount_currency: "EUR", + percentage_rate: 1 + } + ] + end + + let(:coupon) { create(:coupon, organization:) } + + it "returns customer's applied coupons" do + expect(subject).to be_present.and eq customer.applied_coupons + end + end + + context "when coupons are empty" do + let(:coupon_params) { [] } + + it "returns customer's applied coupons" do + expect(subject).to be_present.and eq customer.applied_coupons + end + end + end + + context "when customer has no applied coupons" do + let(:customer_params) { {name: Faker::Name.name} } + + context "when coupons are provided" do + let(:coupon_params) do + [ + { + code: coupon.code + }, + { + code: "coupon_preview", + name: "coupon_preview", + coupon_type: "percentage", + amount_cents: 1200, + amount_currency: "EUR", + percentage_rate: 1 + } + ] + end + + let(:coupon) { create(:coupon, organization:) } + + it "returns applied coupons build from provided params" do + expect(subject).to be_a(Array).and match_array([AppliedCoupon, AppliedCoupon]) + + expect(subject.map { |ac| ac.coupon.code }) + .to match_array coupon_params.map { |i| i[:code] } + end + end + + context "when coupons are empty" do + let(:coupon_params) { [] } + + it "returns empty collection" do + expect(subject).to be_empty + end + end + end + end +end diff --git a/spec/services/invoices/preview_service_spec.rb b/spec/services/invoices/preview_service_spec.rb new file mode 100644 index 0000000..c3d9186 --- /dev/null +++ b/spec/services/invoices/preview_service_spec.rb @@ -0,0 +1,2359 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::PreviewService, cache: :memory do + subject(:preview_service) { described_class.new(customer:, subscriptions:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, rate: 50.0, organization:, billing_entity:) } + let(:customer) { build(:customer, organization:, billing_entity:) } + let(:timestamp) { Time.zone.parse("30 Mar 2024") } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:billing_time) { "calendar" } + let(:subscriptions) { [subscription] } + let(:subscription) do + build( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + + before { tax } + + context "with Lago freemium" do + it "returns a failure" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end + + context "with Lago premium", :premium do + context "when customer does not exist" do + it "returns an error" do + result = described_class.new(customer: nil, subscriptions: [subscription]).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + + context "when subscriptions are missing" do + let(:subscriptions) { [] } + + it "returns an error" do + result = preview_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when currencies do not match" do + let(:customer) { build(:customer, organization:, billing_entity:, currency: "USD") } + + it "returns an error" do + result = preview_service.call + + expect(result).not_to be_success + expect(result.error.messages[:base]).to include("customer_currency_does_not_match") + end + + context "when multi_currency flag is enabled" do + before { organization.enable_feature_flag!(:multi_currency) } + + it "allows the preview" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice).to be_present + end + end + end + end + + context "when billing periods do not match" do + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:plan1) { create(:plan, organization:, interval: "monthly") } + let(:plan2) { create(:plan, organization:, interval: "monthly") } + let(:subscriptions) { [subscription1, subscription2] } + let(:subscription1) do + create(:subscription, plan: plan1, customer:, subscription_at: Time.current.beginning_of_month - 10.days, billing_time: "anniversary") + end + let(:subscription2) do + create(:subscription, plan: plan2, customer:, subscription_at: Time.current.beginning_of_month - 9.days, billing_time: "anniversary") + end + + before { organization.update!(premium_integrations: ["preview"]) } + + it "returns an error" do + result = preview_service.call + + expect(result).not_to be_success + expect(result.error.messages[:base]).to include("billing_periods_does_not_match") + end + end + + context "with calendar billing" do + it "creates preview invoice for 2 days" do + # Two days should be billed, Mar 30 and Mar 31 + + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(3) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(9) + expect(result.invoice.total_amount_cents).to eq(9) + end + end + + context "with fixed charges for non-persisted subscription" do + let(:add_on) { create(:add_on, organization:) } + + context "with pay in adavnace and standard charge model" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + pay_in_advance: true, + units: 3, + properties: {amount: "15"} + ) + end + + before { fixed_charge } + + it "creates preview invoice with fixed charges using default units" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(2) # subscription + fixed_charge + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + expect(fixed_charge_fee.units).to eq(3) + expect(fixed_charge_fee.amount_cents).to eq(4500) # $15 * 3 units = $45 + + # Total: subscription (6) + fixed charge (4500) = 4506 + expect(result.invoice.fees_amount_cents).to eq(4506) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(4506) + expect(result.invoice.taxes_amount_cents).to eq(2253) # 50% tax + expect(result.invoice.total_amount_cents).to eq(6759) + end + end + end + + context "with volume charge model (pay_in_arrears)" do + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: false) } + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "volume", + pay_in_advance: false, + units: 18, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 10, flat_amount: "0", per_unit_amount: "3"}, + {from_value: 11, to_value: 50, flat_amount: "15", per_unit_amount: "2"}, + {from_value: 51, to_value: nil, flat_amount: "30", per_unit_amount: "1"} + ] + } + ) + end + + before { fixed_charge } + + it "includes volume charge for non-persisted subscription" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(2) # subscription + fixed_charge + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + expect(fixed_charge_fee.units).to eq(18) + # 18 units falls in second tier: $15 flat + (18 * $2) = $51 + expect(fixed_charge_fee.amount_cents).to eq(5100) + end + end + end + + context "with pay_in_arrears fixed charge" do + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: false) } + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + pay_in_advance: false, + units: 5, + properties: {amount: "20"} + ) + end + + before { fixed_charge } + + it "includes pay_in_arrears fixed charge for non-persisted subscription" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(2) # subscription + fixed_charge + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + expect(fixed_charge_fee.units).to eq(5) + expect(fixed_charge_fee.amount_cents).to eq(10000) # $20 * 5 units = $100 + + # Total: subscription fee + fixed charge + expect(result.invoice.fees_amount_cents).to eq(10006) + end + end + end + + context "with prorated fixed charge" do + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + let(:timestamp) { Time.zone.parse("15 Mar 2024") } # Mid-month start + let(:subscription) do + build( + :subscription, + customer:, + plan:, + billing_time: "calendar", + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + prorated: true, + pay_in_advance: true, + units: 100, + properties: {amount: "10"} + ) + end + + before { fixed_charge } + + it "prorates fixed charge based on billing period" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + # Units remain full value (100) - proration happens to amount + expect(fixed_charge_fee.units).to eq(100) + expect(fixed_charge_fee.amount_cents).to eq(54_839) + end + end + end + end + + context "with one persisted subscription" do + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + + before { organization.update!(premium_integrations: ["preview"]) } + + it "creates preview invoice for 2 days" do + # Two days should be billed, Mar 30 and Mar 31 + + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(3) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(9) + expect(result.invoice.total_amount_cents).to eq(9) + end + end + + context "with charge fees" do + let(:billable_metric) do + create(:billable_metric, aggregation_type: "count_agg") + end + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "12.66"} + ) + end + let(:events) do + create_list( + :event, + 2, + organization:, + subscription:, + customer:, + code: billable_metric.code, + timestamp: timestamp + 10.hours + ) + end + + before do + events if subscription + charge + Rails.cache.clear + end + + it "creates preview invoice for 2 days", transaction: false do + # Two days should be billed, Mar 30 and Mar 31 + + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(2) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(2538) # 6.45 + 1266 x 2 = 2538 + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(2538) + expect(result.invoice.taxes_amount_cents).to eq(1269) # 1269 + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(3807) # 3807 + expect(result.invoice.total_amount_cents).to eq(3807) # 3807 + end + end + + it "uses the Rails cache", transaction: false do + key = [ + "charge-usage", + Subscriptions::ChargeCacheService::CACHE_KEY_VERSION, + charge.id, + subscription.id, + charge.updated_at.iso8601 + ].join("/") + + expect do + preview_service.call + end.to change { Rails.cache.exist?(key) }.from(false).to(true) + end + end + + context "with fixed charges" do + let(:add_on) { create(:add_on, organization:) } + + context "with pay_in_advance fixed charge on first invoice" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + pay_in_advance: true, + prorated: false, + units: 2, + properties: {amount: "10"} + ) + end + + before do + fixed_charge + event_timestamp = subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + end + + it "includes pay_in_advance fixed charge on first invoice" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(2) # subscription + fixed_charge + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + expect(fixed_charge_fee.amount_cents).to eq(2000) # $10 * 2 units * 100 + expect(fixed_charge_fee.units).to eq(2) + + # Total: subscription (6) + fixed charge (2000) = 2006 + expect(result.invoice.fees_amount_cents).to eq(2006) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(2006) + expect(result.invoice.taxes_amount_cents).to eq(1003) # 50% tax + expect(result.invoice.total_amount_cents).to eq(3009) + end + end + end + + context "with pay_in_arrears fixed charge on first invoice" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + pay_in_advance: false, + prorated: false, + units: 1, + properties: {amount: "5"} + ) + end + + before do + fixed_charge + + # Create invoice_subscription to mark this as "starting" + create( + :invoice_subscription, + subscription:, + invoicing_reason: :subscription_starting, + from_datetime: subscription.started_at, + to_datetime: subscription.started_at + 1.month + ) + end + + it "does not include pay_in_arrears fixed charge on first invoice" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(1) # only subscription fee + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to be_zero + + # Only subscription fee + expect(result.invoice.fees_amount_cents).to eq(6) + end + end + end + + context "with pay_in_arrears fixed charge on subsequent invoice" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + pay_in_advance: false, + prorated: false, + units: 1, + properties: {amount: "5"} + ) + end + + before do + fixed_charge + + event_timestamp = subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + + # Create invoice_subscriptions to simulate that subscription has been invoiced before + # First invoice (subscription_starting) + create( + :invoice_subscription, + subscription:, + invoicing_reason: :subscription_starting, + from_datetime: subscription.started_at, + to_datetime: subscription.started_at + 1.month, + created_at: 1.month.ago + ) + + # Second invoice (regular billing) + create( + :invoice_subscription, + subscription:, + invoicing_reason: :subscription_periodic, + from_datetime: subscription.started_at + 1.month, + to_datetime: subscription.started_at + 2.months, + created_at: 15.days.ago + ) + end + + it "includes pay_in_arrears fixed charge on subsequent invoice" do + travel_to(timestamp + 1.month) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(2) # subscription + fixed_charge + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge) + expect(fixed_charge_fee.amount_cents).to eq(500) # $5 * 1 unit * 100 + end + end + end + + context "with multiple fixed charges" do + let(:add_on2) { create(:add_on, organization:) } + let(:add_on3) { create(:add_on, organization:) } + + let(:fixed_charge_advance) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + pay_in_advance: true, + units: 1, + properties: {amount: "10"} + ) + end + let(:fixed_charge_advance2) do + create( + :fixed_charge, + plan:, + add_on: add_on2, + charge_model: "standard", + pay_in_advance: true, + units: 2, + properties: {amount: "5"} + ) + end + let(:fixed_charge_in_arrears) do + create( + :fixed_charge, + plan:, + add_on: add_on3, + charge_model: "standard", + pay_in_advance: false, + units: 1, + properties: {amount: "25"} + ) + end + + before do + fixed_charge_advance + fixed_charge_advance2 + fixed_charge_in_arrears + + event_timestamp = subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge: fixed_charge_advance, + units: fixed_charge_advance.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge: fixed_charge_advance2, + units: fixed_charge_advance2.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge: fixed_charge_in_arrears, + units: fixed_charge_in_arrears.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + end + + it "includes all applicable fixed charges" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(3) + + total_fixed_charges = fixed_charge_fees.sum(&:amount_cents) + expect(total_fixed_charges).to eq(4500) # $10 * 1 + $5 * 2 + $25 * 1 = $45 + + # Total: subscription (6) + fixed charges (4500) = 4506 + expect(result.invoice.fees_amount_cents).to eq(4506) + end + end + end + + context "with graduated pricing model" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "graduated", + pay_in_advance: true, + units: 15, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, flat_amount: "0", per_unit_amount: "1"}, + {from_value: 11, to_value: nil, flat_amount: "0", per_unit_amount: "0.5"} + ] + } + ) + end + + before do + fixed_charge + + event_timestamp = subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + end + + it "calculates graduated pricing correctly" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + fixed_charge_fee = fixed_charge_fees.first + # 10 units * $1 + 5 units * $0.5 = $12.5 + expect(fixed_charge_fee.amount_cents).to eq(1250) + end + end + end + + context "with volume pricing model" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "volume", + pay_in_advance: false, + units: 25, + properties: { + volume_ranges: [ + {from_value: 0, to_value: 10, flat_amount: "0", per_unit_amount: "1"}, + {from_value: 11, to_value: 50, flat_amount: "5", per_unit_amount: "0.8"}, + {from_value: 51, to_value: nil, flat_amount: "10", per_unit_amount: "0.5"} + ] + } + ) + end + + before do + fixed_charge + + # Volume pricing requires pay_in_arrears, so we need to simulate subsequent invoice + # Create 2 invoice_subscriptions to mark as subsequent (not starting) + create( + :invoice_subscription, + subscription:, + invoicing_reason: :subscription_starting, + from_datetime: subscription.started_at, + to_datetime: subscription.started_at + 1.month, + created_at: 1.month.ago + ) + + create( + :invoice_subscription, + subscription:, + invoicing_reason: :subscription_periodic, + from_datetime: subscription.started_at + 1.month, + to_datetime: subscription.started_at + 2.months, + created_at: 15.days.ago + ) + + event_timestamp = subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + end + + it "calculates volume pricing correctly" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + fixed_charge_fee = fixed_charge_fees.first + # 25 units falls in second tier: $5 flat + (25 * $0.8) = $25 + expect(fixed_charge_fee.amount_cents).to eq(2500) + end + end + end + end + + context "when preview premium integration does not exist" do + before { organization.update!(premium_integrations: ["netsuite"]) } + + it "returns an error" do + result = preview_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + + context "when subscription is terminated" do + let(:subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + let(:billable_metric) do + create(:billable_metric, aggregation_type: "count_agg") + end + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "12.66"} + ) + end + let(:events) do + create_pair( + :event, + organization:, + subscription:, + customer:, + code: billable_metric.code, + timestamp: timestamp + 5.hours + ) + end + + before do + subscription.assign_attributes( + status: "terminated", + terminated_at: timestamp + 15.hours + ) + + events + charge + Rails.cache.clear + end + + it "creates preview invoice for 1 day", transaction: false do + # One days should be billed, Mar 30 only + + travel_to(subscription.terminated_at) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(2) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-03-30") + expect(result.invoice.fees_amount_cents).to eq(2535) # 3.23 + 1266 x 2 = 2535 + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(2535) + expect(result.invoice.taxes_amount_cents).to eq(1268) # 1268 + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(3803) # 3803 + expect(result.invoice.total_amount_cents).to eq(3803) # 3803 + end + end + + context "with fixed charges" do + let(:add_on_advance) { create(:add_on, organization:) } + let(:add_on_arrears) { create(:add_on, organization:) } + let(:fixed_charge_advance) do + create( + :fixed_charge, + plan:, + add_on: add_on_advance, + charge_model: "standard", + pay_in_advance: true, + units: 1, + properties: {amount: "10"} + ) + end + let(:fixed_charge_arrears) do + create( + :fixed_charge, + plan:, + add_on: add_on_arrears, + charge_model: "standard", + pay_in_advance: false, + units: 1, + properties: {amount: "5"} + ) + end + + before do + fixed_charge_advance + fixed_charge_arrears + + event_timestamp = subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge: fixed_charge_arrears, + units: fixed_charge_arrears.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + end + + it "excludes pay_in_advance and includes pay_in_arrears fixed charges", transaction: false do + travel_to(subscription.terminated_at) do + result = preview_service.call + + expect(result).to be_success + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + expect(fixed_charge_fee.fixed_charge).to eq(fixed_charge_arrears) + expect(fixed_charge_fee.amount_cents).to eq(500) # $5 * 1 unit + + # Should NOT include pay_in_advance fixed charge + fixed_charge_ids = fixed_charge_fees.map(&:fixed_charge_id) + expect(fixed_charge_ids).not_to include(fixed_charge_advance.id) + end + end + end + end + + context "when subscription is upgraded" do + let(:timestamp) { Time.zone.parse("29 Mar 2024") } + let(:plan_new) { create(:plan, organization:, interval: "monthly", amount_cents: 200) } + let(:subscriptions) { [terminated_subscription, upgrade_subscription] } + let(:terminated_subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + let(:upgrade_subscription) do + build( + :subscription, + customer:, + plan: plan_new, + billing_time:, + status: "active", + subscription_at: timestamp + 15.hours, + started_at: timestamp + 15.hours, + created_at: timestamp + 15.hours + ) + end + let(:billable_metric) do + create(:billable_metric, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + end + let(:events) do + create_pair( + :event, + organization:, + subscription: terminated_subscription, + customer:, + code: billable_metric.code, + timestamp: timestamp + 5.hours, + properties: {amount: "5"} + ) + end + + before do + BillSubscriptionJob.perform_now( + [terminated_subscription], + timestamp.to_i, + invoicing_reason: :subscription_starting + ) + + # Create a second invoice_subscription to mark terminated_subscription as subsequent (not starting) + create( + :invoice_subscription, + subscription: terminated_subscription, + invoicing_reason: :subscription_periodic, + from_datetime: timestamp + 1.day, + to_datetime: timestamp + 15.hours, + created_at: timestamp + 1.day + ) + + terminated_subscription.assign_attributes( + status: "terminated", + terminated_at: timestamp + 15.hours + ) + + events + charge + Rails.cache.clear + end + + it "creates preview invoice for 1 day", transaction: false do + # One days should be billed, Mar 30 only + + travel_to(terminated_subscription.terminated_at) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.size).to eq(2) + expect(result.invoice.fees.length).to eq(2) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-03-29") + expect(result.invoice.fees_amount_cents).to eq(35) # 3.23 + 32.26 (charge) = 35 + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(35) + expect(result.invoice.taxes_amount_cents).to eq(18) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(53) + expect(result.invoice.total_amount_cents).to eq(53) + end + end + + context "with fixed charges on both plans" do + let(:add_on_old) { create(:add_on, organization:) } + let(:add_on_new) { create(:add_on, organization:) } + let(:fixed_charge_old_plan) do + create( + :fixed_charge, + plan:, + add_on: add_on_old, + charge_model: "standard", + pay_in_advance: false, + units: 1, + properties: {amount: "5"} + ) + end + let(:fixed_charge_new_plan) do + create( + :fixed_charge, + plan: plan_new, + add_on: add_on_new, + charge_model: "standard", + pay_in_advance: true, + units: 1, + properties: {amount: "8"} + ) + end + + before do + fixed_charge_old_plan + fixed_charge_new_plan + + old_event_timestamp = terminated_subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription: terminated_subscription, + fixed_charge: fixed_charge_old_plan, + units: fixed_charge_old_plan.units, + timestamp: old_event_timestamp, + created_at: old_event_timestamp + ) + + new_event_timestamp = upgrade_subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription: upgrade_subscription, + fixed_charge: fixed_charge_new_plan, + units: fixed_charge_new_plan.units, + timestamp: new_event_timestamp, + created_at: new_event_timestamp + ) + end + + it "includes fixed charges from both old and new plans", transaction: false do + travel_to(terminated_subscription.terminated_at) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.size).to eq(2) + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(2) + + # Old plan pay_in_arrears fixed charge should be included + old_plan_fixed_fees = fixed_charge_fees.select { |f| f.subscription == terminated_subscription } + expect(old_plan_fixed_fees.size).to eq(1) + expect(old_plan_fixed_fees.first.amount_cents).to eq(500) # $5 + + # New plan pay_in_advance fixed charge should be included + new_plan_fixed_fees = fixed_charge_fees.select { |f| f.subscription == upgrade_subscription } + expect(new_plan_fixed_fees.size).to eq(1) + expect(new_plan_fixed_fees.first.amount_cents).to eq(800) # $8 + end + end + end + end + + context "when subscription is downgraded" do + let(:timestamp) { Time.zone.parse("29 Mar 2024") } + let(:rotate_timestamp) { Time.zone.parse("1 Apr 2024 01:00") } + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + let(:plan_new) { create(:plan, organization:, interval: "monthly", amount_cents: 50, pay_in_advance: true) } + let(:subscriptions) { [terminated_subscription, downgraded_subscription] } + + let(:terminated_subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + + let(:downgraded_subscription) do + build( + :subscription, + customer:, + plan: plan_new, + billing_time:, + status: "active", + subscription_at: rotate_timestamp, + started_at: rotate_timestamp, + created_at: rotate_timestamp + ) + end + + let(:billable_metric) do + create(:billable_metric, aggregation_type: "sum_agg", recurring: true, field_name: "amount") + end + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + pay_in_advance: false, + prorated: true, + properties: {amount: "1"} + ) + end + + let(:events) do + create_pair( + :event, + organization:, + subscription: terminated_subscription, + customer:, + code: billable_metric.code, + timestamp: timestamp + 5.hours, + properties: {amount: "5"} + ) + end + + before do + BillSubscriptionJob.perform_now( + [terminated_subscription], + timestamp.to_i, + invoicing_reason: :subscription_starting + ) + + terminated_subscription.assign_attributes( + status: "terminated", + terminated_at: rotate_timestamp, + next_subscriptions: [downgraded_subscription] + ) + + events + charge + Rails.cache.clear + end + + it "creates preview invoice", transaction: false do + # only charges from March (3 days), full April billed by new plan + + travel_to(Time.zone.parse("30 Mar 2024 05:00")) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.size).to eq(2) + expect(result.invoice.fees.length).to eq(2) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(147) # 97 (charges) + 50 (new plan) = 147 + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(147) + expect(result.invoice.taxes_amount_cents).to eq(74) # 49 (charges) + 25 (new plan) = 90 + expect(result.invoice.credit_notes_amount_cents).to eq(0) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(221) + expect(result.invoice.total_amount_cents).to eq(221) + end + end + + context "with fixed charges on both plans" do + let(:add_on_old) { create(:add_on, organization:) } + let(:add_on_new) { create(:add_on, organization:) } + let(:fixed_charge_old_plan) do + create( + :fixed_charge, + plan:, + add_on: add_on_old, + charge_model: "standard", + pay_in_advance: true, + units: 1, + properties: {amount: "10"} + ) + end + let(:fixed_charge_new_plan) do + create( + :fixed_charge, + plan: plan_new, + add_on: add_on_new, + charge_model: "standard", + pay_in_advance: true, + units: 1, + properties: {amount: "3"} + ) + end + + before do + fixed_charge_old_plan + fixed_charge_new_plan + + event_timestamp = downgraded_subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription: downgraded_subscription, + fixed_charge: fixed_charge_new_plan, + units: fixed_charge_new_plan.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + end + + it "includes fixed charges from new plan only", transaction: false do + # Old plan pay_in_advance should NOT be included (terminated) + # New plan pay_in_advance should be included + + travel_to(Time.zone.parse("30 Mar 2024 05:00")) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.size).to eq(2) + + # Should only have fixed charge from new plan (pay_in_advance not charged on terminated subscription) + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + # New plan pay_in_advance fixed charge should be included + new_plan_fixed_fee = fixed_charge_fees.first + expect(new_plan_fixed_fee.subscription).to eq(downgraded_subscription) + expect(new_plan_fixed_fee.amount_cents).to eq(300) # $3 + end + end + end + end + end + + context "with in advance billing in the future" do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:, invoice_grace_period: 2) } + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + let(:subscription) do + build( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp + 1.day, + started_at: timestamp + 1.day, + created_at: timestamp + 1.day + ) + end + + it "creates preview invoice for 1 day" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-02") + expect(result.invoice.fees_amount_cents).to eq(3) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(3) + expect(result.invoice.taxes_amount_cents).to eq(2) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(5) + expect(result.invoice.total_amount_cents).to eq(5) + end + end + end + + context "with in advance billing with persisted subscription" do + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:plan) { create(:plan, organization:, interval: "monthly", pay_in_advance: true) } + let(:subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp - 1.day, + started_at: timestamp - 1.day, + created_at: timestamp - 1.day + ) + end + + before { organization.update!(premium_integrations: ["preview"]) } + + it "creates preview invoice for next invoice" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(100) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(100) + expect(result.invoice.taxes_amount_cents).to eq(50) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(150) + expect(result.invoice.total_amount_cents).to eq(150) + end + end + + context "with terminated subscription" do + let(:subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + status: "terminated", + terminated_at: timestamp, + subscription_at: timestamp - 1.day, + started_at: timestamp - 1.day, + created_at: timestamp - 1.day + ) + end + + it "creates preview invoice without subscription fee since it has already been paid" do + travel_to(subscription.terminated_at) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(0) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-03-30") + expect(result.invoice.fees_amount_cents).to eq(0) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(0) + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(0) + expect(result.invoice.total_amount_cents).to eq(0) + end + end + end + + context "with upgraded subscription" do + let(:timestamp) { Time.zone.parse("29 Mar 2024") } + let(:plan_new) { create(:plan, charges:, organization:, interval: "monthly", amount_cents: 200, pay_in_advance: true) } + let(:subscriptions) { [terminated_subscription, upgrade_subscription] } + let(:terminated_subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp - 1.day, + started_at: timestamp - 1.day, + created_at: timestamp - 1.day + ) + end + let(:upgrade_subscription) do + build( + :subscription, + customer:, + plan: plan_new, + billing_time:, + status: "active", + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + + let(:charges) { [build(:standard_charge)] } + + before do + BillSubscriptionJob.perform_now( + [terminated_subscription], + timestamp.to_i, + invoicing_reason: :subscription_starting + ) + + terminated_subscription.assign_attributes( + status: "terminated", + terminated_at: timestamp + ) + end + + it "creates preview invoice for upgrade case" do + travel_to(terminated_subscription.terminated_at) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.size).to eq(2) + expect(result.invoice.credits.length).to eq(1) + # precise_amount 6.45161 + precise_taxes_amount_cents 3.225805 = 9.677415 ajusted(9) + expect(result.invoice.credits.first.amount_cents).to eq(9) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-03-29") + expect(result.invoice.fees_amount_cents).to eq(19) # 3 x 200 / 31 + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(19) + expect(result.invoice.taxes_amount_cents).to eq(10) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(29) + expect(result.invoice.total_amount_cents).to eq(20) + end + end + end + + context "when preview premium integration does not exist" do + before { organization.update!(premium_integrations: ["netsuite"]) } + + it "returns an error" do + result = preview_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + end + end + end + + context "with applied coupons" do + let(:applied_coupon) do + build( + :applied_coupon, + customer: subscription.customer, + amount_cents: 2, + amount_currency: plan.amount_currency + ) + end + + it "creates preview invoice for 2 days with applied coupons" do + travel_to(timestamp) do + result = described_class.new(customer:, subscriptions: [subscription], applied_coupons: [applied_coupon]).call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.coupons_amount_cents).to eq(2) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(4) + expect(result.invoice.taxes_amount_cents).to eq(2) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(6) + expect(result.invoice.total_amount_cents).to eq(6) + expect(result.invoice.credits.length).to eq(1) + end + end + end + + context "with credit note credits" do + let(:credit_note) do + create( + :credit_note, + customer:, + total_amount_cents: 2, + total_amount_currency: plan.amount_currency, + balance_amount_cents: 2, + balance_amount_currency: plan.amount_currency, + credit_amount_cents: 2, + credit_amount_currency: plan.amount_currency + ) + end + + before { credit_note } + + it "creates preview invoice for 2 days with credits included" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(3) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(9) + expect(result.invoice.credit_notes_amount_cents).to eq(2) + expect(result.invoice.total_amount_cents).to eq(7) + end + end + end + + context "with wallet credits" do + let(:wallet) { build(:wallet, customer:, balance: "0.03", credits_balance: "0.03") } + + before { wallet } + + context "with customer that is not persisted" do + it "does not apply credits" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.total_amount_cents).to eq(9) + expect(result.invoice.prepaid_credit_amount_cents).to eq(0) + end + end + end + + context "with customer that is persisted" do + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:wallet) { create(:wallet, customer:, balance: "0.03", credits_balance: "0.03") } + + it "applies credits" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(3) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(9) + expect(result.invoice.prepaid_credit_amount_cents).to eq(3) + expect(result.invoice.total_amount_cents).to eq(6) + end + end + end + end + + context "with provider taxes" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { build(:anrok_customer, integration:, customer:) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/draft_invoices" } + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + customer.integration_customers = [integration_customer] + end + + context "when there is no error" do + before do + stub_request(:post, endpoint).to_return do |request| + response = JSON.parse(File.read( + Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response.json") + )) + + # setting item_id based on the test example + key = JSON.parse(request.body).first["fees"].last["item_key"] + response["succeededInvoices"].first["fees"].last["item_key"] = key + + {body: response.to_json} + end + end + + it "creates preview invoice for 2 days" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(1) # 6 x 0.1 + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(7) + expect(result.invoice.total_amount_cents).to eq(7) + end + end + end + + context "when there is error received from the provider" do + before do + stub_request(:post, endpoint).to_return do |request| + response = File.read( + Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + ) + {body: response} + end + end + + it "uses zero taxes" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(6) + expect(result.invoice.total_amount_cents).to eq(6) + end + end + end + + context "when there is Net::OpenTimeout error" do + before do + allow(Integrations::Aggregator::Taxes::Invoices::CreateDraftService).to receive(:new) + .and_raise(Net::OpenTimeout) + end + + it "uses zero taxes" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + expect(result.invoice.fees_amount_cents).to eq(6) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(6) + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(6) + expect(result.invoice.total_amount_cents).to eq(6) + end + end + end + end + end + + context "with anniversary billing" do + let(:billing_time) { "anniversary" } + + it "creates preview invoice for full month" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-30") + expect(result.invoice.fees_amount_cents).to eq(100) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(100) + expect(result.invoice.taxes_amount_cents).to eq(50) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(150) + expect(result.invoice.total_amount_cents).to eq(150) + end + end + + context "with fixed charges for non-persisted subscription" do + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 1000) } + let(:add_on) { create(:add_on, organization:) } + + context "with graduated charge model" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "graduated", + pay_in_advance: true, + units: 25, + properties: { + graduated_ranges: [ + {from_value: 0, to_value: 10, flat_amount: "0", per_unit_amount: "2"}, + {from_value: 11, to_value: 20, flat_amount: "5", per_unit_amount: "1.5"}, + {from_value: 21, to_value: nil, flat_amount: "10", per_unit_amount: "1"} + ] + } + ) + end + + before { fixed_charge } + + it "calculates graduated pricing using default units" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(2) # subscription + fixed_charge + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + # Tier 1: 10 units * $2 = $20 + # Tier 2: $5 flat + (10 units * $1.5) = $20 + # Tier 3: $10 flat + (5 units * $1) = $15 + # Total: $20 + $20 + $15 = $55 + expect(fixed_charge_fee.amount_cents).to eq(5500) + expect(fixed_charge_fee.units).to eq(25) + + # Total: subscription (1000) + fixed charge (5500) = 6500 + expect(result.invoice.fees_amount_cents).to eq(6500) + end + end + end + end + + context "with one persisted subscriptions" do + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + + before { organization.update!(premium_integrations: ["preview"]) } + + it "creates preview invoice for full month" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(1) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-30") + expect(result.invoice.fees_amount_cents).to eq(100) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(100) + expect(result.invoice.taxes_amount_cents).to eq(50) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(150) + expect(result.invoice.total_amount_cents).to eq(150) + end + end + + context "with charge fees" do + let(:billable_metric) do + create(:billable_metric, aggregation_type: "count_agg") + end + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + properties: {amount: "12.66"} + ) + end + let(:events) do + create_list( + :event, + 2, + organization:, + subscription:, + customer:, + code: billable_metric.code, + timestamp: timestamp + 10.hours + ) + end + + before do + events if subscription + charge + Rails.cache.clear + end + + it "creates preview invoice for full month", transaction: false do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.fees.length).to eq(2) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-30") + expect(result.invoice.fees_amount_cents).to eq(2632) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(2632) + expect(result.invoice.taxes_amount_cents).to eq(1316) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(3948) + expect(result.invoice.total_amount_cents).to eq(3948) + end + end + end + + context "with fixed charges" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + add_on:, + charge_model: "standard", + pay_in_advance: true, + units: 2, + properties: {amount: "7.5"} + ) + end + + before do + fixed_charge + + event_timestamp = subscription.started_at + 1.second + create( + :fixed_charge_event, + organization:, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: event_timestamp, + created_at: event_timestamp + ) + end + + it "creates preview invoice with fixed charges for anniversary billing" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.fees.size).to eq(2) # subscription + fixed_charge + + fixed_charge_fees = result.invoice.fees.select { |f| f.fee_type == "fixed_charge" } + expect(fixed_charge_fees.size).to eq(1) + + fixed_charge_fee = fixed_charge_fees.first + expect(fixed_charge_fee.amount_cents).to eq(1500) # $7.5 * 2 units = $15 + + # Total: subscription (100) + fixed charge (1500) = 1600 + expect(result.invoice.fees_amount_cents).to eq(1600) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(1600) + expect(result.invoice.taxes_amount_cents).to eq(800) # 50% tax + expect(result.invoice.total_amount_cents).to eq(2400) + end + end + end + end + + context "with multiple persisted subscriptions" do + let(:customer) { create(:customer, organization:, invoice_grace_period: 3, billing_entity:) } + let(:plan1) { create(:plan, organization:, interval: "monthly") } + let(:plan2) { create(:plan, organization:, interval: "monthly") } + let(:subscriptions) { [subscription1, subscription2] } + let(:subscription1) do + create( + :subscription, + customer:, + plan: plan1, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan: plan2, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + + before { organization.update!(premium_integrations: ["preview"]) } + + it "creates preview invoice for full month" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.map { |s| s.id }).to match_array([subscription1.id, subscription2.id]) + expect(result.invoice.fees.length).to eq(2) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-05-03") + expect(result.invoice.fees_amount_cents).to eq(200) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(200) + expect(result.invoice.taxes_amount_cents).to eq(100) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(300) + expect(result.invoice.total_amount_cents).to eq(300) + end + end + end + end + + context "with pending subscription starting in the future" do + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:timestamp) { Time.zone.parse("15 Mar 2024") } + let(:future_start) { Time.zone.parse("1 Apr 2024") } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 1000, pay_in_advance: false) } + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + pay_in_advance: false, + invoiceable: true, + properties: {amount: "10"} + ) + end + let(:subscription) do + build( + :subscription, + customer:, + plan:, + status: :active, + subscription_at: future_start, + started_at: future_start, + billing_time: "calendar", + created_at: future_start + ) + end + let(:subscriptions) { [subscription] } + + before do + charge + organization.update!(premium_integrations: ["preview"]) + end + + it "creates preview invoice for pending subscription with subscription fee only" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.map(&:id)).to eq([subscription.id]) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-05-01") + + subscription_fee = result.invoice.fees.find { |f| f.subscription_id == subscription.id && f.charge_id.nil? } + expect(subscription_fee).to be_present + expect(subscription_fee.amount_cents).to eq(1000) + expect(subscription_fee.units).to eq(1.0) + + charge_fees = result.invoice.fees.select { |f| f.charge_id.present? } + expect(charge_fees).to be_empty + + expect(result.invoice.fees_amount_cents).to eq(1000) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(1000) + expect(result.invoice.taxes_amount_cents).to eq(500) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(1500) + expect(result.invoice.total_amount_cents).to eq(1500) + end + end + + context "with in advance billing in the future" do + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 1000, pay_in_advance: true) } + + it "creates preview invoice for pending subscription with subscription fee only" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.map(&:id)).to eq([subscription.id]) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + + subscription_fee = result.invoice.fees.find { |f| f.subscription_id == subscription.id && f.charge_id.nil? } + expect(subscription_fee).to be_present + expect(subscription_fee.amount_cents).to eq(1000) + expect(subscription_fee.units).to eq(1.0) + + charge_fees = result.invoice.fees.select { |f| f.charge_id.present? } + expect(charge_fees).to be_empty + + expect(result.invoice.fees_amount_cents).to eq(1000) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(1000) + expect(result.invoice.taxes_amount_cents).to eq(500) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(1500) + expect(result.invoice.total_amount_cents).to eq(1500) + end + end + end + + context "with in advance billing in the future and anniversary interval" do + let(:plan) { create(:plan, organization:, interval: "monthly", amount_cents: 1000, pay_in_advance: true) } + let(:future_start) { Time.zone.parse("8 Apr 2024") } + let(:subscription) do + build( + :subscription, + customer:, + plan:, + status: :active, + subscription_at: future_start, + started_at: future_start, + billing_time: "anniversary", + created_at: future_start + ) + end + + it "creates preview invoice for pending subscription with subscription fee only" do + travel_to(timestamp) do + result = preview_service.call + + expect(result).to be_success + expect(result.invoice.organization).to eq(organization) + expect(result.invoice.billing_entity).to eq(customer.billing_entity) + expect(result.invoice.subscriptions.map(&:id)).to eq([subscription.id]) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.issuing_date.to_s).to eq("2024-04-08") + + subscription_fee = result.invoice.fees.find { |f| f.subscription_id == subscription.id && f.charge_id.nil? } + expect(subscription_fee).to be_present + expect(subscription_fee.amount_cents).to eq(1000) + expect(subscription_fee.units).to eq(1.0) + + charge_fees = result.invoice.fees.select { |f| f.charge_id.present? } + expect(charge_fees).to be_empty + + expect(result.invoice.fees_amount_cents).to eq(1000) + expect(result.invoice.sub_total_excluding_taxes_amount_cents).to eq(1000) + expect(result.invoice.taxes_amount_cents).to eq(500) + expect(result.invoice.sub_total_including_taxes_amount_cents).to eq(1500) + expect(result.invoice.total_amount_cents).to eq(1500) + end + end + end + end + + context "with issuing date preferences" do + let(:plan) { create(:plan, organization:, pay_in_advance:, interval: "monthly") } + let(:pay_in_advance) { false } + + before do + organization.update!(premium_integrations: ["preview"]) + end + + context "with no preferences set on the customer level" do + let(:billing_entity) do + create( + :billing_entity, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + ) + end + + let(:customer) { create(:customer, organization:, billing_entity:) } + + it "uses billing_entity preferences" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-03-31") + end + end + end + + context "when invoice is not recurring" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + before do + subscription.terminated_at = Time.zone.now + end + + it "ignores all issuing date preferences" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + end + end + end + + context "with an existing subscription" do + let(:customer) do + create( + :customer, + billing_entity:, + organization:, + subscription_invoice_issuing_date_anchor:, + subscription_invoice_issuing_date_adjustment:, + invoice_grace_period: 3 + ) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + billing_time:, + subscription_at: timestamp, + started_at: timestamp, + created_at: timestamp + ) + end + + context "with pay in advance" do + let(:pay_in_advance) { true } + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the current billing period end date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-30") + end + end + end + + context "with current_period_end + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the current billing period end date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-05-04") + end + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the next billing period start date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-05-01") + end + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the next billing period start date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-05-04") + end + end + end + end + + context "with arrears" do + let(:pay_in_advance) { false } + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the current billing period end date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-30") + end + end + end + + context "with current_period_end + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the current billing period end date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-05-04") + end + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the next billing period start date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-05-01") + end + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the next billing period start date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-05-04") + end + end + end + end + end + + context "without an existing subscription" do + let(:customer) do + build( + :customer, + billing_entity:, + organization:, + subscription_invoice_issuing_date_anchor:, + subscription_invoice_issuing_date_adjustment:, + invoice_grace_period: 3 + ) + end + + context "with pay in advance" do + let(:pay_in_advance) { true } + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the current billing period end date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-02") + end + end + end + + context "with current_period_end + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the current billing period end date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-02") + end + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the next billing period start date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-02") + end + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the next billing period start date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-02") + end + end + end + end + + context "with arrears" do + let(:pay_in_advance) { false } + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the current billing period end date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-03-31") + end + end + end + + context "with current_period_end + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the current billing period end date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-04") + end + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "sets issuing_date to the next billing period start date" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-01") + end + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "sets issuing_date to the next billing period start date + grace period" do + travel_to(timestamp + 5.days) do + result = preview_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2024-04-04") + end + end + end + end + end + end + end + end +end diff --git a/spec/services/invoices/progressive_billing_service_spec.rb b/spec/services/invoices/progressive_billing_service_spec.rb new file mode 100644 index 0000000..462820f --- /dev/null +++ b/spec/services/invoices/progressive_billing_service_spec.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ProgressiveBillingService, transaction: false do + subject(:create_service) { described_class.new(sorted_usage_thresholds:, lifetime_usage:, timestamp:) } + + let(:sorted_usage_thresholds) { [create(:usage_threshold, plan:)] } + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + let(:billing_entity) { customer.billing_entity } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, customer:, started_at: timestamp - 1.week) } + let(:lifetime_usage) { create(:lifetime_usage, subscription:, organization:) } + + let(:timestamp) { Time.zone.parse(Date.current.strftime("%Y-%m-%d 10:00:00")) } + + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:billable_metric) { create(:sum_billable_metric, organization:, field_name: "value") } + let(:charge) { create(:standard_charge, plan:, billable_metric:, properties: {amount: "1"}) } + + let(:event) do + create( + :event, + organization_id: organization.id, + external_subscription_id: subscription.external_id, + code: billable_metric.code, + properties: {billable_metric.field_name => 1}, + timestamp: timestamp - 1.hour + ) + end + + before do + allow(SegmentTrackJob).to receive(:perform_later) + + tax + charge + event + end + + describe "#call" do + it "creates a progressive billing invoice" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to be_present + + invoice = result.invoice + amount_cents = 100 + + expect(invoice).to be_persisted + expect(invoice).to have_attributes( + organization: organization, + customer: customer, + currency: plan.amount_currency, + status: "finalized", + invoice_type: "progressive_billing", + fees_amount_cents: amount_cents, + taxes_amount_cents: amount_cents * tax.rate / 100, + total_amount_cents: amount_cents * (1 + tax.rate / 100) + ) + + expect(invoice.invoice_subscriptions.count).to eq(1) + expect(invoice.fees.count).to eq(1) + expect(invoice.applied_usage_thresholds.count).to eq(1) + + expect(invoice.applied_usage_thresholds.first.lifetime_usage_amount_cents) + .to eq(lifetime_usage.total_amount_cents) + end + + it "makes sure that only 1 invoice is generated for a given threshold" do + result = create_service.call + expect(result).to be_success + + result2 = create_service.call + expect(result2).not_to be_success + end + + context "when there is tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before do + integration_customer + end + + context "when taxes are unknown" do + it "keeps the tax status as pending" do + result = create_service.call + + expect(result).to be_success + + invoice = customer.invoices.order(created_at: :desc).first + expect(invoice.status).to eq("pending") + expect(invoice.tax_status).to eq("pending") + expect(invoice.error_details.count).to eq(0) + end + end + end + + context "with multiple thresholds" do + let(:sorted_usage_thresholds) do + [ + create(:usage_threshold, plan:, amount_cents: 1000), + create(:usage_threshold, plan:, amount_cents: 2500) + ] + end + + it "creates a progressive billing invoice" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to be_present + + invoice = result.invoice + amount_cents = 100 + + expect(invoice).to be_persisted + expect(invoice).to have_attributes( + organization: organization, + customer: customer, + currency: plan.amount_currency, + status: "finalized", + invoice_type: "progressive_billing", + fees_amount_cents: amount_cents, + taxes_amount_cents: amount_cents * tax.rate / 100, + total_amount_cents: amount_cents * (1 + tax.rate / 100) + ) + + expect(invoice.invoice_subscriptions.count).to eq(1) + expect(invoice.fees.count).to eq(1) + expect(invoice.applied_usage_thresholds.count).to eq(2) + end + end + + context "when usage threshold belongs to subscription" do + let(:sorted_usage_thresholds) { [create(:usage_threshold, :for_subscription, subscription:)] } + + it "creates a progressive billing invoice" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to be_present + + invoice = result.invoice + amount_cents = 100 + + expect(invoice).to have_attributes( + organization: organization, + customer: customer, + currency: plan.amount_currency, + status: "finalized", + invoice_type: "progressive_billing", + fees_amount_cents: amount_cents + ) + end + end + + context "when threshold was already billed" do + before do + invoice = create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + fees_amount_cents: 20, + subscriptions: [subscription], + issuing_date: timestamp - 1.day + ) + + create( + :charge_fee, + charge:, + invoice:, + amount_cents: 20 + ) + invoice.invoice_subscriptions.first.update!( + charges_from_datetime: invoice.issuing_date - 2.weeks, + charges_to_datetime: invoice.issuing_date + 2.weeks, + timestamp: invoice.issuing_date + ) + end + + it "creates a progressive billing invoice" do + result = create_service.call + + expect(result).to be_success + expect(result.invoice).to be_present + + invoice = result.invoice + amount_cents = 100 + + expect(invoice).to be_persisted + expect(invoice).to have_attributes( + organization: organization, + customer: customer, + currency: plan.amount_currency, + status: "finalized", + invoice_type: "progressive_billing", + fees_amount_cents: amount_cents, + taxes_amount_cents: (amount_cents - 20) * tax.rate / 100, + total_amount_cents: (amount_cents - 20) * (1 + tax.rate / 100) + ) + + expect(invoice.invoice_subscriptions.count).to eq(1) + expect(invoice.credits.count).to eq(1) + expect(invoice.fees.count).to eq(1) + end + end + + it "enqueues a SendWebhookJob" do + expect { create_service.call }.to have_enqueued_job(SendWebhookJob) + end + + it "enqueue an GenerateDocumentsJob with email false" do + expect { create_service.call } + .to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + it "produces an activity log" do + invoice = described_class.call(sorted_usage_thresholds:, lifetime_usage:, timestamp:).invoice + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + context "with lago_premium", :premium do + it "enqueues an GenerateDocumentsJob with email true" do + expect { create_service.call } + .to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when organization does not have right email settings" do + before { subscription.billing_entity.update!(email_settings: []) } + + it "enqueue an GenerateDocumentsJob with email false" do + expect { create_service.call } + .to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + it "calls SegmentTrackJob" do + invoice = create_service.call.invoice + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + create_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + it_behaves_like "syncs invoice" do + let(:service_call) { create_service.call } + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { create_service.call } + end + + context "when an error occurs" do + context "with a stale object error" do + it "propagates the error" do + allow_any_instance_of(Credits::AppliedPrepaidCreditsService) # rubocop:disable RSpec/AnyInstance + .to receive(:call).and_raise(ActiveRecord::StaleObjectError) + + expect { create_service.call }.to raise_error(ActiveRecord::StaleObjectError) + end + end + + context "with a failed to acquire lock error" do + it "propagates the error" do + allow_any_instance_of(Credits::AppliedPrepaidCreditsService) # rubocop:disable RSpec/AnyInstance + .to receive(:call).and_raise(Customers::FailedToAcquireLock) + + expect { create_service.call }.to raise_error(Customers::FailedToAcquireLock) + end + end + end + end +end diff --git a/spec/services/invoices/provider_taxes/pull_taxes_and_apply_service_spec.rb b/spec/services/invoices/provider_taxes/pull_taxes_and_apply_service_spec.rb new file mode 100644 index 0000000..b7a310e --- /dev/null +++ b/spec/services/invoices/provider_taxes/pull_taxes_and_apply_service_spec.rb @@ -0,0 +1,598 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ProviderTaxes::PullTaxesAndApplyService do + subject(:pull_taxes_service) { described_class.new(invoice:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + + let(:invoice) do + create( + :invoice, + :pending, + :with_tax_error, + :with_subscriptions, + customer:, + billing_entity:, + organization:, + subscriptions: [subscription], + currency: "EUR", + tax_status: "pending", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: "standard", billable_metric:) } + + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:fee_charge) do + create( + :fee, + invoice:, + charge:, + fee_type: :charge, + total_aggregated_units: 100, + amount_cents: 1_000 + ) + end + + let(:integration_tax) { create(:anrok_integration, organization:) } + let(:integration_customer_tax) { create(:anrok_customer, integration: integration_tax, customer:) } + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "https://api.nango.dev/v1/anrok/finalized_invoices" } + let(:endpoint_draft) { "https://api.nango.dev/v1/anrok/draft_invoices" } + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_multiple_fees.json") + json = File.read(path) + + # setting item_id based on the test example + response = JSON.parse(json) + response["succeededInvoices"].first["fees"].first["item_id"] = fee_subscription.id + response["succeededInvoices"].first["fees"].last["item_id"] = fee_charge.id + + response.to_json + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration: integration_tax, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + fee_subscription + fee_charge + + allow(SegmentTrackJob).to receive(:perform_later) + allow(Invoices::Payments::StripeCreateJob).to receive(:perform_later).and_call_original + allow(Invoices::Payments::GocardlessCreateJob).to receive(:perform_later).and_call_original + + integration_customer_tax + + allow(LagoHttpClient::Client).to receive(:new) + .with(endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client) + allow(LagoHttpClient::Client).to receive(:new).with(endpoint_draft, retries_on: [OpenSSL::SSL::SSLError]).and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(body) + end + + context "when invoice does not exist" do + it "returns an error" do + result = described_class.new(invoice: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when integration customer does not exist" do + let(:integration_customer_tax) { nil } + + it "returns an error" do + result = pull_taxes_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("integration_customer_not_found") + end + end + + context "when invoice is not pending" do + before do + invoice.update(status: %i[finalized voided generating].sample) + end + + it "does not change the invoice object" do + expect { pull_taxes_service.call }.not_to change { invoice.reload.attributes } + end + + it "returns result" do + expect(pull_taxes_service.call).to be_success + end + end + + context "when invoice was finalized by a concurrent job" do + before do + allow(invoice).to receive(:reload).and_wrap_original do |method| + method.call + invoice.update(status: "finalized") + invoice + end + end + + it "returns early without emitting webhook or activity log" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(SendWebhookJob).not_to have_been_enqueued.with("invoice.created", anything) + end + end + + context "when taxes are fetched successfully" do + it "marks the invoice as finalized" do + expect { pull_taxes_service.call } + .to change(invoice, :status).from("pending").to("finalized") + end + + it "discards previous tax errors" do + expect { pull_taxes_service.call } + .to change(invoice.error_details.tax_error, :count).from(1).to(0) + end + + context "with a non-recurring invoice" do + let(:billing_entity) { create(:billing_entity, organization:, subscription_invoice_issuing_date_adjustment: "keep_anchor") } + + it "updates the issuing date and payment due date" do + invoice.customer.update(timezone: "America/New_York") + + freeze_time do + current_date = Time.current.in_time_zone("America/New_York").to_date + + expect { pull_taxes_service.call } + .to change { invoice.reload.issuing_date }.to(current_date) + .and change { invoice.reload.payment_due_date }.to(current_date) + end + end + end + + context "with a recurring invoice" do + let(:billing_entity) { create(:billing_entity, organization:, subscription_invoice_issuing_date_adjustment:) } + + before do + invoice.invoice_subscriptions.first.update(recurring: true) + invoice.customer.update(timezone: "America/New_York") + end + + context "with issuing date adjustment set to keep_anchor" do + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "does not update the issuing date" do + expect { pull_taxes_service.call }.not_to change { invoice.reload.issuing_date } + end + end + + context "with issuing date adjustment set to align_with_finalization_date" do + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "updates the issuing date and payment due date" do + freeze_time do + current_date = Time.current.in_time_zone("America/New_York").to_date + + expect { pull_taxes_service.call } + .to change { invoice.reload.issuing_date }.to(current_date) + .and change { invoice.reload.payment_due_date }.to(current_date) + end + end + end + end + + it "generates invoice number" do + customer_slug = "#{billing_entity.document_number_prefix}-#{format("%03d", customer.sequential_id)}" + sequential_id = customer.invoices.where.not(id: invoice.id).order(created_at: :desc).first&.sequential_id || 0 + + expect { pull_taxes_service.call } + .to change { invoice.reload.number } + .from("#{billing_entity.document_number_prefix}-DRAFT") + .to("#{customer_slug}-#{format("%03d", sequential_id + 1)}") + end + + it "generates expected invoice totals" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(result.invoice.fees.charge.count).to eq(1) + expect(result.invoice.fees.subscription.count).to eq(1) + + expect(result.invoice.currency).to eq("EUR") + expect(result.invoice.fees_amount_cents).to eq(3_000) + + expect(result.invoice.taxes_amount_cents).to eq(350) + expect(result.invoice.taxes_rate.round(2)).to eq(11.67) # (0.667 * 10) + (0.333 * 15) + expect(result.invoice.applied_taxes.count).to eq(2) + + expect(result.invoice.total_amount_cents).to eq(3_350) + end + + it_behaves_like "syncs invoice" do + let(:service_call) { pull_taxes_service.call } + end + + it "enqueues a SendWebhookJob" do + expect do + pull_taxes_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.created", Invoice) + end + + it "produces an activity log" do + described_class.call(invoice:) + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + it "enqueues GenerateDocumentsJob with email false" do + expect do + pull_taxes_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + context "with lago_premium", :premium do + it "enqueues GenerateDocumentsJob with email true" do + expect do + pull_taxes_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when billing entity does not have right email settings" do + before { invoice.billing_entity.update!(email_settings: []) } + + it "enqueues GenerateDocumentsJob with email false" do + expect do + pull_taxes_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + it "calls SegmentTrackJob" do + invoice = pull_taxes_service.call.invoice + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + pull_taxes_service.call + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + context "with credit notes" do + let(:credit_note) do + create( + :credit_note, + customer:, + total_amount_cents: 10, + total_amount_currency: "EUR", + balance_amount_cents: 10, + balance_amount_currency: "EUR", + credit_amount_cents: 10, + credit_amount_currency: "EUR" + ) + end + + before { credit_note } + + it "updates the invoice accordingly" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(result.invoice.fees_amount_cents).to eq(3_000) + expect(result.invoice.taxes_amount_cents).to eq(350) + expect(result.invoice.total_amount_cents).to eq(3_340) + expect(result.invoice.credits.count).to eq(1) + + credit = result.invoice.credits.first + expect(credit.credit_note).to eq(credit_note) + expect(credit.amount_cents).to eq(10) + end + + context "when invoice type is one_off" do + before do + invoice.update!(invoice_type: :one_off) + end + + it "does not apply credit note" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(result.invoice.fees_amount_cents).to eq(3_000) + expect(result.invoice.taxes_amount_cents).to eq(350) + expect(result.invoice.total_amount_cents).to eq(3_350) + expect(result.invoice.credits.count).to eq(0) + end + end + end + + context "when status is draft" do + before do + invoice.update!(status: :draft) + end + + it "marks the invoice as draft" do + expect { pull_taxes_service.call } + .not_to change(invoice, :status).from("draft") + end + + it "discards previous tax errors" do + expect { pull_taxes_service.call } + .to change(invoice.error_details.tax_error, :count).from(1).to(0) + end + + it "does not generate invoice number" do + expect { pull_taxes_service.call } + .not_to change { invoice.reload.number } + .from("#{billing_entity.document_number_prefix}-DRAFT") + end + + it "generates expected invoice totals" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(result.invoice.fees.charge.count).to eq(1) + expect(result.invoice.fees.subscription.count).to eq(1) + + expect(result.invoice.currency).to eq("EUR") + expect(result.invoice.fees_amount_cents).to eq(3_000) + + expect(result.invoice.taxes_amount_cents).to eq(350) + expect(result.invoice.taxes_rate.round(2)).to eq(11.67) # (0.667 * 10) + (0.333 * 15) + expect(result.invoice.applied_taxes.count).to eq(2) + + expect(result.invoice.total_amount_cents).to eq(3_350) + end + + it "does not enqueue a SendWebhookJob" do + expect do + pull_taxes_service.call + end.not_to have_enqueued_job(SendWebhookJob).with("invoice.created", Invoice) + end + + it "does not create a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + pull_taxes_service.call + expect(Invoices::Payments::CreateService).not_to have_received(:call_async) + end + + context "with credit notes" do + let(:credit_note) do + create( + :credit_note, + customer:, + total_amount_cents: 10, + total_amount_currency: "EUR", + balance_amount_cents: 10, + balance_amount_currency: "EUR", + credit_amount_cents: 10, + credit_amount_currency: "EUR" + ) + end + + before { credit_note } + + it "does not apply credit note" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(result.invoice.fees_amount_cents).to eq(3_000) + expect(result.invoice.taxes_amount_cents).to eq(350) + expect(result.invoice.total_amount_cents).to eq(3_350) + expect(result.invoice.credits.count).to eq(0) + end + end + end + end + + context "when failed to fetch taxes" do + let(:body) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "puts invoice in failed status" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(invoice.reload.status).to eq("failed") + end + + it "resolves old tax error and creates new one" do + old_error_id = invoice.reload.error_details.last.id + pull_taxes_service.call + expect(invoice.error_details.tax_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_error.count).to be(1) + expect(invoice.error_details.tax_error.order(created_at: :asc).last.discarded?).to be(false) + end + + context "with api limit error" do + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/api_limit_response.json") + File.read(p) + end + + it "puts invoice in failed status" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(invoice.reload.status).to eq("failed") + end + + it "resolves old tax error and creates new one" do + old_error_id = invoice.reload.error_details.last.id + + pull_taxes_service.call + + expect(invoice.error_details.tax_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_error.count).to be(1) + expect(invoice.error_details.tax_error.order(created_at: :asc).last.discarded?).to be(false) + expect(invoice.error_details.tax_error.order(created_at: :asc).last.details["tax_error"]) + .to eq("validationError") + expect(invoice.error_details.tax_error.order(created_at: :asc).last.details["tax_error_message"]) + .to eq("You've exceeded your API limit of 10 per second") + end + end + + context "with script error" do + let(:invoice) do + create( + :invoice, + :draft, + :with_tax_error, + :with_subscriptions, + customer:, + billing_entity:, + organization:, + subscriptions: [subscription], + currency: "EUR", + tax_status: "pending", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:body) do + p = Rails.root.join("spec/fixtures/integration_aggregator/error_script_response.json") + File.read(p) + end + + before do + allow(lago_client).to receive(:post_with_response) + .and_raise(::LagoHttpClient::HttpError.new(500, body, endpoint_draft, response_headers: {})) + end + + it "puts invoice in failed status" do + result = pull_taxes_service.call + + expect(result).to be_success + expect(invoice.reload.status).to eq("draft") + expect(invoice.tax_status).to eq("failed") + end + + it "resolves old tax error and creates new one" do + old_error_id = invoice.reload.error_details.last.id + + pull_taxes_service.call + + expect(invoice.error_details.tax_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_error.count).to be(1) + expect(invoice.error_details.tax_error.order(created_at: :asc).last.discarded?).to be(false) + expect(invoice.error_details.tax_error.order(created_at: :asc).last.details["tax_error"]) + .to eq("action_script_failure") + expect(invoice.error_details.tax_error.order(created_at: :asc).last.details["tax_error_message"]) + .to eq("Error starting integration 'netsuite-customer-create': {\n \"name\": \"TRPCClientError\",\n \"message\": \"fetch failed\"\n}") + end + end + end + + context "when invoice is subscription_gated" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: :payment, timeout_hours: 48, status: :pending}], + customer:, organization:) + end + let(:invoice) do + create(:invoice, :with_subscriptions, customer:, organization:, status: :open, + currency: "EUR", subscriptions: [subscription]) + end + + before do + invoice.update!(tax_status: :pending) + end + + it "allows processing and triggers payment only" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + result = pull_taxes_service.call + + expect(result).to be_success + expect(Invoices::Payments::CreateService).to have_received(:call_async) + expect(SendWebhookJob).not_to have_been_enqueued.with("invoice.created", anything) + end + + context "when invoice total is zero after tax computation" do + let(:rule) { subscription.activation_rules.payment.sole } + let(:fee_subscription) do + create(:fee, invoice:, subscription:, fee_type: :subscription, amount_cents: 0) + end + let(:fee_charge) do + create(:fee, invoice:, charge:, fee_type: :charge, total_aggregated_units: 0, amount_cents: 0) + end + let(:body) do + { + succeededInvoices: [{ + id: invoice.id, + issuing_date: Time.current.to_date.iso8601, + sub_total_excluding_taxes: 0, + taxes_amount_cents: 0, + currency: "EUR", + fees: [ + {item_id: fee_subscription.id, amount_cents: 0, tax_amount_cents: 0, tax_breakdown: []}, + {item_id: fee_charge.id, amount_cents: 0, tax_amount_cents: 0, tax_breakdown: []} + ] + }], + failedInvoices: [] + }.to_json + end + + it "marks the payment activation rule as satisfied" do + pull_taxes_service.call + + expect(rule.reload).to be_satisfied + end + + it "activates the subscription" do + pull_taxes_service.call + + expect(subscription.reload).to be_active + end + end + end + end +end diff --git a/spec/services/invoices/provider_taxes/void_service_spec.rb b/spec/services/invoices/provider_taxes/void_service_spec.rb new file mode 100644 index 0000000..0a138b4 --- /dev/null +++ b/spec/services/invoices/provider_taxes/void_service_spec.rb @@ -0,0 +1,235 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::ProviderTaxes::VoidService do + subject(:void_service) { described_class.new(invoice:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + :voided, + :with_tax_voiding_error, + :with_subscriptions, + customer:, + organization:, + subscriptions: [subscription], + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: "standard", billable_metric:) } + + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:fee_charge) do + create( + :fee, + invoice:, + charge:, + fee_type: :charge, + total_aggregated_units: 100, + amount_cents: 1_000 + ) + end + + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:response1) { instance_double(Net::HTTPOK) } + let(:lago_client1) { instance_double(LagoHttpClient::Client) } + let(:response2) { instance_double(Net::HTTPOK) } + let(:lago_client2) { instance_double(LagoHttpClient::Client) } + let(:void_endpoint) { "https://api.nango.dev/v1/anrok/void_invoices" } + let(:negate_endpoint) { "https://api.nango.dev/v1/anrok/negate_invoices" } + let(:body_void) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_void.json") + File.read(path) + end + let(:body_negate) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/success_response_negate.json") + File.read(path) + end + let(:integration_collection_mapping) do + create( + :netsuite_collection_mapping, + integration:, + mapping_type: :fallback_item, + settings: {external_id: "1", external_account_code: "11", external_name: ""} + ) + end + + before do + integration_collection_mapping + fee_subscription + fee_charge + integration_customer + + allow(LagoHttpClient::Client) + .to receive(:new) + .with(void_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client1) + allow(lago_client1).to receive(:post_with_response).and_return(response1) + allow(response1).to receive(:body).and_return(body_void) + + allow(LagoHttpClient::Client) + .to receive(:new) + .with(negate_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + .and_return(lago_client2) + allow(lago_client2).to receive(:post_with_response).and_return(response2) + allow(response2).to receive(:body).and_return(body_negate) + end + + context "when invoice does not exist" do + it "returns an error" do + result = described_class.new(invoice: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice is not voided" do + before { invoice.finalized! } + + it "returns an error" do + result = void_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("status_not_voided") + end + end + + context "when voided invoice is successfully synced" do + it "returns successful result" do + result = void_service.call + + expect(result).to be_success + expect(result.invoice.id).to eq(invoice.id) + end + + it "discards previous tax errors" do + expect { void_service.call } + .to change(invoice.error_details.tax_voiding_error, :count).from(1).to(0) + end + end + + context "when failed result is returned from void endpoint" do + let(:body_void) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "keeps invoice in voided status" do + result = void_service.call + + expect(result).not_to be_success + expect(LagoHttpClient::Client).to have_received(:new).with(void_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(LagoHttpClient::Client).not_to have_received(:new).with(negate_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(invoice.reload.status).to eq("voided") + end + + it "resolves old tax error and creates new one" do + old_error_id = invoice.reload.error_details.last.id + + void_service.call + + expect(invoice.error_details.tax_voiding_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_voiding_error.count).to be(1) + expect(invoice.error_details.tax_voiding_error.order(created_at: :asc).last).not_to be_discarded + end + end + + context "when failed result is returned from negate endpoint" do + let(:body_void) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response_void.json") + File.read(path) + end + let(:body_negate) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "keeps invoice in voided status" do + result = void_service.call + + expect(result).not_to be_success + expect(LagoHttpClient::Client).to have_received(:new).with(void_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(LagoHttpClient::Client).to have_received(:new).with(negate_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(invoice.reload.status).to eq("voided") + end + + it "resolves old tax error and creates new one" do + old_error_id = invoice.reload.error_details.last.id + + void_service.call + + expect(invoice.error_details.tax_voiding_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_voiding_error.count).to be(1) + expect(invoice.error_details.tax_voiding_error.order(created_at: :asc).last).not_to be_discarded + end + end + + context "when failed result is returned from refund endpoint for avalara customer" do + let(:integration) { create(:avalara_integration, organization:) } + let(:integration_customer) { create(:avalara_customer, integration:, customer:) } + let(:void_endpoint) { "https://api.nango.dev/v1/avalara/void_invoices" } + let(:negate_endpoint) { "https://api.nango.dev/v1/avalara/finalized_invoices" } + let(:body_void) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response_locked_void.json") + File.read(path) + end + let(:body_negate) do + path = Rails.root.join("spec/fixtures/integration_aggregator/taxes/invoices/failure_response.json") + File.read(path) + end + + it "keeps invoice in voided status" do + result = void_service.call + + expect(result).not_to be_success + expect(LagoHttpClient::Client).to have_received(:new).with(void_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(LagoHttpClient::Client).to have_received(:new).with(negate_endpoint, retries_on: [OpenSSL::SSL::SSLError]) + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(invoice.reload.status).to eq("voided") + end + + it "resolves old tax error and creates new one" do + old_error_id = invoice.reload.error_details.last.id + + void_service.call + + expect(invoice.error_details.tax_voiding_error.last.id).not_to eql(old_error_id) + expect(invoice.error_details.tax_voiding_error.count).to be(1) + expect(invoice.error_details.tax_voiding_error.order(created_at: :asc).last).not_to be_discarded + end + end + end +end diff --git a/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb b/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb new file mode 100644 index 0000000..8ec8100 --- /dev/null +++ b/spec/services/invoices/refresh_draft_and_finalize_service_spec.rb @@ -0,0 +1,402 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::RefreshDraftAndFinalizeService do + subject(:finalize_service) { described_class.new(invoice:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + + let(:invoice) do + create( + :invoice, + :draft, + :with_subscriptions, + organization:, + customer:, + subscriptions: [subscription], + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:fee) { create(:fee, invoice:, subscription:) } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:credit_note) { create(:credit_note, :draft, invoice:) } + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + + let(:standard_charge) do + create(:standard_charge, plan: subscription.plan, charge_model: "standard", billable_metric:) + end + + let(:event) do + create( + :event, + organization:, + subscription: subscription, + code: billable_metric.code, + timestamp: Time.current.beginning_of_month - 2.days + ) + end + + before do + standard_charge + event + + allow(SegmentTrackJob).to receive(:perform_later) + allow(Invoices::Payments::CreateService).to receive(:call_async).and_call_original + allow(Invoices::TransitionToFinalStatusService).to receive(:call).and_call_original + end + + [ + :one_off, + :add_on, + :credit, + :advance_charges, + :progressive_billing + ].each do |invoice_type| + context "when invoice is #{invoice_type}" do + let(:invoice) { create(:invoice, :draft, organization:, customer:, invoice_type:) } + + it "returns a forbidden failure" do + result = finalize_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end + + it "marks the invoice as finalized" do + expect { finalize_service.call } + .to change(invoice, :status).from("draft").to("finalized") + expect(Invoices::TransitionToFinalStatusService).to have_received(:call).with(invoice:) + end + + context "with a non-recurring invoice" do + let(:billing_entity) { create(:billing_entity, organization:, subscription_invoice_issuing_date_adjustment: "keep_anchor") } + + it "updates the issuing date" do + invoice.customer.update(timezone: "America/New_York") + + freeze_time do + current_date = Time.current.in_time_zone("America/New_York").to_date + + expect { finalize_service.call } + .to change { invoice.reload.issuing_date }.to(current_date) + .and change { invoice.reload.payment_due_date }.to(current_date) + end + end + end + + context "with a recurring invoice" do + let(:billing_entity) { create(:billing_entity, organization:, subscription_invoice_issuing_date_adjustment:) } + + before do + invoice.invoice_subscriptions.first.update(recurring: true) + invoice.customer.update(timezone: "America/New_York") + end + + context "with issuing date adjustment set to keep_anchor" do + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + + it "does not update the issuing date" do + expect { finalize_service.call }.not_to change { invoice.reload.issuing_date } + end + end + + context "with issuing date adjustment set to align_with_finalization_date" do + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + it "updates the issuing date" do + freeze_time do + current_date = Time.current.in_time_zone("America/New_York").to_date + + expect { finalize_service.call } + .to change { invoice.reload.issuing_date }.to(current_date) + .and change { invoice.reload.payment_due_date }.to(current_date) + end + end + end + end + + it "generates expected fees" do + result = finalize_service.call + + expect(result).to be_success + expect(result.invoice.fees.charge.count).to eq(1) + expect(result.invoice.fees.subscription.count).to eq(1) + end + + it_behaves_like "syncs invoice" do + let(:service_call) { finalize_service.call } + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { finalize_service.call } + end + + it "enqueues a SendWebhookJob" do + expect do + finalize_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.created", Invoice) + end + + it "produces an activity log" do + described_class.call(invoice:) + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + it "enqueues GenerateDocumentsJob with email false" do + expect do + finalize_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + it "flags lifetime usage for refresh" do + create(:usage_threshold, plan:) + + finalize_service.call + + expect(subscription.reload.lifetime_usage.recalculate_invoiced_usage).to be(true) + end + + context "with lago_premium", :premium do + it "enqueues GenerateDocumentsJob with email true" do + expect do + finalize_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when organization does not have right email settings" do + before { invoice.billing_entity.update!(email_settings: []) } + + it "enqueues GenerateDocumentsJob with email false" do + expect do + finalize_service.call + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + it "calls SegmentTrackJob" do + invoice = finalize_service.call.invoice + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + finalize_service.call + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + context "when invoice does not exist" do + it "returns an error" do + result = described_class.new(invoice: nil).call + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when fees already exist" do + it "regenerates them" do + create(:fee, invoice:) + result = finalize_service.call + + expect(result).to be_success + expect(result.invoice.fees.charge.count).to eq(1) + expect(result.invoice.fees.subscription.count).to eq(1) + end + end + + context "with credit notes" do + before do + create(:credit_note_item, credit_note:, fee:) + end + + it "marks the credit notes as finalized" do + expect { finalize_service.call } + .to change { credit_note.reload.status }.from("draft").to("finalized") + end + + it "calls SegmentTrackJob" do + invoice = finalize_service.call.invoice + credit_note = invoice.credit_notes.first + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "credit_note_issued", + properties: { + organization_id: credit_note.organization.id, + credit_note_id: credit_note.id, + invoice_id: credit_note.invoice_id, + credit_note_method: "credit" + } + ) + end + + it "enqueues a SendWebhookJob" do + expect do + finalize_service.call + end.to have_enqueued_job(SendWebhookJob).with("credit_note.created", CreditNote) + end + + it "produces an activity log" do + result = finalize_service.call + + expect(Utils::ActivityLog).to have_produced("credit_note.created").with(result.invoice.credit_notes.first) + end + + it "enqueues CreditNotes::GenerateDocumentsJob" do + expect do + finalize_service.call + end.to have_enqueued_job(CreditNotes::GenerateDocumentsJob) + end + end + + context "when tax integration is set up" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before do + integration_customer + invoice.update(issuing_date: Time.current + 3.months) + + allow(Invoices::ApplyProviderTaxesService).to receive(:call).and_call_original + allow(SendWebhookJob).to receive(:perform_later).and_call_original + allow(Invoices::GenerateDocumentsJob).to receive(:perform_later).and_call_original + allow(Integrations::Aggregator::Invoices::CreateJob).to receive(:perform_later).and_call_original + allow(Invoices::Payments::CreateService).to receive(:new).and_call_original + allow(Utils::SegmentTrack).to receive(:invoice_created).and_call_original + end + + context "when taxes are unknown" do + it "returns pending invoice" do + result = finalize_service.call + expect(invoice.reload.status).to eql("pending") + expect(result.success?).to be(true) + end + + it "moves invoice to pending tax state" do + expect { finalize_service.call }.to change(invoice.reload, :tax_status).from(nil).to("pending") + end + + it "updates fees despite error result" do + expect { finalize_service.call }.to change(invoice.fees.charge, :count).from(0).to(1) + .and change(invoice.fees.subscription, :count).from(0).to(1) + end + + it "does not send any updates" do + finalize_service.call + expect(SendWebhookJob).not_to have_received(:perform_later).with("invoice.created", invoice) + expect(Invoices::GenerateDocumentsJob).not_to have_received(:perform_later) + expect(Integrations::Aggregator::Invoices::CreateJob).not_to have_received(:perform_later) + expect(Invoices::Payments::CreateService).not_to have_received(:new) + expect(Utils::SegmentTrack).not_to have_received(:invoice_created) + end + + it "does not change issuing_date on the invoice" do + expect { finalize_service.call }.not_to change(invoice, :issuing_date) + end + end + end + + context "when sending an invoice that is not draft" do + let(:invoice) do + create( + :invoice, + :failed, + :with_subscriptions, + customer:, + subscriptions: [subscription], + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + it "does not update the invoice" do + expect { finalize_service.call }.not_to change { invoice.reload.status } + end + end + + context "when invoice has invoice_generation_errors" do + let(:backtrace) do + [ + "/app/app/models/invoice.rb:432:in 'generate_organization_sequential_id'", + "/app/app/models/invoice.rb:395:in..." + ] + end + + before do + ErrorDetail.create( + owner: invoice, + organization: invoice.organization, + error_code: :invoice_generation_error, + details: { + backtrace:, + error: "\"#\\u003cSequenced::SequenceError: Unable to acquire lock on the database\\u003e\"", + invoice: invoice.to_json(except: [:file, :xml_file]), + subscriptions: invoice.subscriptions.to_json + } + ) + end + + context "when successfully generated the invoice" do + it "deletes the invoice_generation_errors" do + expect { finalize_service.call }.to change(invoice.error_details.invoice_generation_error, :count).by(-1) + end + end + + context "when failed to generate the invoice" do + before do + allow(Invoices::RefreshDraftService).to receive(:call).and_return(BaseService::Result.new.service_failure!(code: "code", message: "message")) + end + + it "does not delete the invoice_generation_errors" do + expect { finalize_service.call }.to raise_error(BaseService::ServiceFailure) + expect(invoice.error_details.invoice_generation_error).to be_present + end + end + + context "when the backtrace is related to the billing entity" do + let(:backtrace) do + [ + "/app/app/models/invoice.rb:589:in 'Invoice#generate_billing_entity_sequential_id'", + "/app/app/models/invoice.rb:568:in..." + ] + end + + it "deletes the invoice_generation_errors" do + expect { finalize_service.call }.to change(invoice.error_details.invoice_generation_error, :count).by(-1) + end + end + end + end +end diff --git a/spec/services/invoices/refresh_draft_service_spec.rb b/spec/services/invoices/refresh_draft_service_spec.rb new file mode 100644 index 0000000..a2d2691 --- /dev/null +++ b/spec/services/invoices/refresh_draft_service_spec.rb @@ -0,0 +1,254 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::RefreshDraftService do + subject(:refresh_service) { described_class.new(invoice:) } + + describe "#call" do + let(:status) { :draft } + let(:invoice) do + create( + :invoice, + status:, + organization:, + customer:, + taxes_amount_cents: 10, + total_amount_cents: 1000110010, + taxes_rate: 30, + fees_amount_cents: 2600, + sub_total_excluding_taxes_amount_cents: 9900090, + sub_total_including_taxes_amount_cents: 9900100, + progressive_billing_credit_amount_cents: 1239000 + ) + end + + let(:started_at) { 1.month.ago.beginning_of_month } + let(:customer) { create(:customer) } + let(:organization) { customer.organization } + let(:billing_entity) { customer.billing_entity } + + let(:subscription) do + create( + :subscription, + customer:, + organization:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:, recurring: true) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 15) } + + before do + invoice_subscription + tax + allow(Invoices::CalculateFeesService).to receive(:call).and_call_original + end + + [ + :one_off, + :add_on, + :credit, + :advance_charges, + :progressive_billing + ].each do |invoice_type| + context "when invoice is #{invoice_type}" do + let(:invoice) { create(:invoice, :draft, organization:, customer:, invoice_type:) } + + it "returns a forbidden failure" do + result = refresh_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + end + + context "when invoice is ready to be finalized" do + let(:invoice) do + create(:invoice, status:, organization:, customer:, ready_to_be_refreshed: true) + end + + it "updates ready_to_be_refreshed to false" do + expect { refresh_service.call }.to change(invoice, :ready_to_be_refreshed).to(false) + end + end + + context "when invoice is finalized" do + let(:status) { :finalized } + + it "does not refresh it" do + result = refresh_service.call + expect(Invoices::CalculateFeesService).not_to have_received(:call) + expect(result).to be_success + end + end + + context "when refreshing upgrading invoice" do + let(:invoice2) do + create(:invoice, status:, organization:, customer:) + end + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + recurring: false, + invoicing_reason: "subscription_terminating" + ) + end + let(:invoice_subscription2) do + create( + :invoice_subscription, + invoice:, + subscription: subscription2, + recurring: false, + invoicing_reason: "subscription_starting" + ) + end + let(:invoice_subscription3) do + create( + :invoice_subscription, + invoice: invoice2, + subscription: subscription2, + recurring: false, + invoicing_reason: "subscription_terminating" + ) + end + let(:subscription) do + create( + :subscription, + customer:, + organization:, + subscription_at: started_at - 1.month, + started_at: started_at - 1.month, + created_at: started_at - 1.month, + terminated_at: started_at, + status: :terminated + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + organization:, + subscription_at: started_at, + started_at:, + created_at: started_at, + previous_subscription_id: subscription.id + ) + end + + before do + invoice_subscription2 + invoice_subscription3 + + subscription2.mark_as_terminated! + + allow(Invoices::CalculateFeesService).to receive(:call).and_return(BaseService::Result.new) + + invoice.update!(created_at: started_at) + end + + it "correctly creates invoice_subscriptions without duplicating invoicing reason" do + refresh_service.call + + expect(invoice.reload.invoice_subscriptions.pluck(:invoicing_reason)) + .to match_array(%w[subscription_terminating subscription_starting]) + end + end + + it "regenerates fees" do + fee = create(:fee, invoice:) + create(:standard_charge, plan: subscription.plan, charge_model: "standard") + + expect { refresh_service.call } + .to change { invoice.fees.pluck(:id).include?(fee.id) }.from(true).to(false) + .and change { invoice.fees.pluck(:created_at).uniq }.to([invoice.created_at]) + + expect(invoice.invoice_subscriptions.first.recurring).to be_truthy + end + + it "assigns credit notes to new created fee" do + credit_note = create(:credit_note, invoice:) + fee = create(:fee, invoice:, subscription:) + create(:credit_note_item, credit_note:, fee:) + + expect { refresh_service.call }.to change { credit_note.reload.items.pluck(:fee_id) } + end + + it "updates taxes_rate" do + expect { refresh_service.call } + .to change { invoice.reload.taxes_rate }.from(30.0).to(15) + end + + it "recalculates progressive billing amount" do + expect { refresh_service.call } + .to change { invoice.reload.progressive_billing_credit_amount_cents }.from(1239000).to(0) + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { refresh_service.call } + end + + context "when there is a tax_integration set up" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: "standard") } + + before do + integration_customer + charge + end + + context "when taxes are unknown" do + it "regenerates fees" do + expect { refresh_service.call }.to change { invoice.fees.count }.from(0).to(1) + end + + it "sets correct tax status" do + refresh_service.call + + expect(invoice.reload.tax_status).to eq("pending") + end + + it "resets invoice values to calculatable before the error" do + expect { refresh_service.call }.to change(invoice.reload, :taxes_amount_cents).from(10).to(0) + .and change(invoice, :total_amount_cents).from(1000110010).to(0) + .and change(invoice, :taxes_rate).from(30.0).to(0) + .and change(invoice, :fees_amount_cents).from(2600).to(100) + .and change(invoice, :sub_total_excluding_taxes_amount_cents).from(9900090).to(100) + .and change(invoice, :sub_total_including_taxes_amount_cents).from(9900100).to(0) + end + end + end + + context "when invoice has other applied invoice_custom_sections" do + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 4, organization: organization) } + let(:applied_invoice_custom_sections) { create_list(:applied_invoice_custom_section, 2, invoice: invoice) } + + before do + applied_invoice_custom_sections + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: invoice_custom_sections[0]) + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: invoice_custom_sections[1]) + create(:customer_applied_invoice_custom_section, organization:, billing_entity:, customer:, invoice_custom_section: invoice_custom_sections[2]) + end + + it "creates new applied_invoice_custom_sections" do + expect { refresh_service.call }.to change { invoice.reload.applied_invoice_custom_sections.count }.from(2).to(3) + expect(invoice.applied_invoice_custom_sections.map(&:code)).to match_array(customer.selected_invoice_custom_sections.map(&:code)) + end + end + + it "flags lifetime usage for refresh" do + create(:usage_threshold, plan: subscription.plan) + + refresh_service.call + + expect(subscription.reload.lifetime_usage.recalculate_invoiced_usage).to be(true) + end + end +end diff --git a/spec/services/invoices/regenerate_from_voided_service_spec.rb b/spec/services/invoices/regenerate_from_voided_service_spec.rb new file mode 100644 index 0000000..be151d9 --- /dev/null +++ b/spec/services/invoices/regenerate_from_voided_service_spec.rb @@ -0,0 +1,562 @@ +# frozen_string_literal: true + +require "rails_helper" + +describe "Regenerate From Voided Invoice Scenarios", :with_pdf_generation_stub, type: :request do + subject(:regenerate_result) do + Invoices::RegenerateFromVoidedService.call!(voided_invoice:, fees_params:) + end + + let(:voided_invoice) { original_invoice } + let(:organization) { create(:organization) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 1000, pay_in_advance: true) } + + let(:subscription) do + travel_to(DateTime.new(2023, 1, 1)) do + create_subscription( + {external_customer_id: customer.external_id, + external_id: "sub_#{customer.external_id}", + plan_code: plan.code} + ) + end + + customer.reload.subscriptions.first + end + + let(:original_invoice) do + travel_to(DateTime.new(2023, 1, 15)) { perform_billing } + invoice = subscription.invoices.first + invoice.update!(status: :voided) + invoice + end + let(:fees_params) do + [ + { + id: original_fee.id, + subscription_id: subscription.id, + invoice_display_name: "new-dis-name", + units: 10, + unit_amount_cents: 50.50 + } + ] + end + + let(:original_fee) { original_invoice.fees.first } + + describe "#call" do + before do + stub_request(:post, organization.webhook_endpoints.first.webhook_url).to_return(status: 200, body: "") + original_invoice + end + + it "regenerates invoice with adjusted display name, units and unit amount" do + regenerated_fee = regenerate_result.invoice.fees.first + + expect(regenerated_fee.invoice_display_name).to eq "new-dis-name" + expect(regenerated_fee.units).to eq 10 + expect(regenerated_fee.unit_amount_cents).to eq 5050 + expect(regenerated_fee.amount_cents).to eq 10 * 5050 + end + + context "when voided fee has pay_in_advance_event_transaction_id" do + before do + original_fee.update!(pay_in_advance_event_transaction_id: "txn_123", pay_in_advance: true) + end + + it "duplicates pay_in_advance_event_transaction_id and sets original_fee_id" do + regenerated_fee = regenerate_result.invoice.fees.first + + expect(regenerated_fee.pay_in_advance_event_transaction_id).to eq("txn_123") + expect(regenerated_fee.original_fee_id).to eq(original_fee.id) + expect(original_fee.reload.pay_in_advance_event_transaction_id).to eq("txn_123") + end + end + + describe "invoice_subscriptions duplication" do + context "when voided invoice is subscription type" do + it "duplicates invoice_subscriptions to the regenerated invoice" do + regenerated_invoice = regenerate_result.invoice + + expect(regenerated_invoice.invoice_subscriptions).not_to be_empty + expect(regenerated_invoice.invoice_subscriptions.count).to eq(voided_invoice.invoice_subscriptions.count) + end + end + + context "when voided invoice is progressive_billing type" do + let(:voided_invoice) do + create( + :invoice, + :progressive_billing_invoice, + :voided, + customer:, + organization:, + currency: "EUR", + subscriptions: [subscription] + ) + end + let(:original_fee) do + create(:charge_fee, invoice: voided_invoice, subscription:, amount_cents: 1000, unit_amount_cents: 1000) + end + + it "duplicates invoice_subscriptions to the regenerated invoice" do + regenerated_invoice = regenerate_result.invoice + + expect(regenerated_invoice.invoice_subscriptions).not_to be_empty + expect(regenerated_invoice.invoice_subscriptions.count).to eq(voided_invoice.invoice_subscriptions.count) + end + end + + context "when voided invoice is one_off type" do + let(:add_on) { create(:add_on, organization:) } + let(:voided_invoice) do + create(:invoice, :voided, invoice_type: :one_off, customer:, organization:, currency: "EUR") + end + let(:original_fee) do + create(:one_off_fee, invoice: voided_invoice, add_on:, amount_cents: 1000, unit_amount_cents: 1000) + end + let(:fees_params) do + [{id: original_fee.id, units: 2, unit_amount_cents: 1000}] + end + + it "does not create invoice_subscriptions on the regenerated invoice" do + expect(regenerate_result.invoice.invoice_subscriptions).to be_empty + end + end + + context "when voided invoice is credit type" do + let(:voided_invoice) do + create(:invoice, :credit, :voided, customer:, organization:, currency: "EUR") + end + let(:original_fee) do + create(:fee, invoice: voided_invoice, amount_cents: 1000, unit_amount_cents: 1000, fee_type: :credit) + end + let(:fees_params) do + [{id: original_fee.id, units: 2, unit_amount_cents: 1000}] + end + + it "does not create invoice_subscriptions on the regenerated invoice" do + expect(regenerate_result.invoice.invoice_subscriptions).to be_empty + end + end + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + regenerate_result + + expect(Invoices::Payments::CreateService).to have_received(:call_async).once + end + + it "enqueues a SendWebhookJob for the invoice" do + expect do + regenerate_result + end.to have_enqueued_job(SendWebhookJob).with("invoice.created", Invoice) + end + + it "produces an activity log" do + invoice = regenerate_result.invoice + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + it "produces segment event" do + allow(Utils::SegmentTrack).to receive(:invoice_created).and_call_original + + regenerate_result + + expect(Utils::SegmentTrack).to have_received(:invoice_created) + end + + it "enqueues GenerateDocumentsJob with email false" do + expect do + regenerate_result + end.to have_enqueued_job(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + it_behaves_like "syncs invoice" do + let(:service_call) { regenerate_result } + end + + context "with updated units" do + let(:fees_params) do + [ + { + id: original_fee.id, + subscription_id: subscription.id, + units: 3 + } + ] + end + + it "regenerates invoice" do + regenerated_fee = regenerate_result.invoice.fees.first + + expect(regenerated_fee.invoice_display_name).to eq nil + expect(regenerated_fee.units).to eq 3 + expect(regenerated_fee.unit_amount_cents).to eq original_fee.unit_amount_cents + expect(regenerated_fee.amount_cents).to eq 3 * original_fee.unit_amount_cents + end + end + + context "with fixed charge fees" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + charge_model: "standard", + properties: {amount: "10"} + ) + end + + let(:original_invoice) do + travel_to(DateTime.new(2023, 1, 15)) { perform_billing } + invoice = subscription.invoices.first + + # Add a fixed charge fee to the invoice + create( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + amount_cents: 5000, + precise_amount_cents: 5000.0, + units: 5, + unit_amount_cents: 1000, + precise_unit_amount: 10, + properties: { + fixed_charges_from_datetime: subscription.started_at.beginning_of_day, + fixed_charges_to_datetime: subscription.started_at.end_of_month.end_of_day + } + ) + + invoice.update!(status: :voided) + invoice + end + + let(:fixed_charge_fee) { original_invoice.fees.find_by(fee_type: :fixed_charge) } + + context "when adjusting only display name" do + let(:fees_params) do + [ + { + id: fixed_charge_fee.id, + subscription_id: subscription.id, + invoice_display_name: "Custom Fixed Charge Name", + units: 5 + } + ] + end + + it "regenerates invoice with adjusted display name" do + regenerated_fee = regenerate_result.invoice.fees.find_by(fixed_charge_id: fixed_charge.id) + + expect(regenerated_fee.invoice_display_name).to eq "Custom Fixed Charge Name" + expect(regenerated_fee.units).to eq 5 + expect(regenerated_fee.unit_amount_cents).to eq 1000 + expect(regenerated_fee.amount_cents).to eq 5000 + end + end + + context "when adjusting units" do + let(:fees_params) do + [ + { + id: fixed_charge_fee.id, + subscription_id: subscription.id, + units: 10 + } + ] + end + + it "regenerates invoice with adjusted units" do + regenerated_fee = regenerate_result.invoice.fees.find_by(fixed_charge_id: fixed_charge.id) + + expect(regenerated_fee.units).to eq 10 + expect(regenerated_fee.unit_amount_cents).to eq 1000 + expect(regenerated_fee.amount_cents).to eq 10_000 + expect(regenerated_fee.precise_amount_cents).to eq 10_000.0 + end + end + + context "when adjusting unit amount" do + let(:fees_params) do + [ + { + id: fixed_charge_fee.id, + subscription_id: subscription.id, + units: 5, + unit_amount_cents: 15.50 + } + ] + end + + it "regenerates invoice with adjusted unit amount" do + regenerated_fee = regenerate_result.invoice.fees.find_by(fixed_charge_id: fixed_charge.id) + + expect(regenerated_fee.units).to eq 5 + expect(regenerated_fee.unit_amount_cents).to eq 1550 + expect(regenerated_fee.amount_cents).to eq 7750 + expect(regenerated_fee.precise_amount_cents).to eq 7750.0 + expect(regenerated_fee.precise_unit_amount).to eq 15.50 + end + end + + context "when adjusting both units and unit amount" do + let(:fees_params) do + [ + { + id: fixed_charge_fee.id, + subscription_id: subscription.id, + invoice_display_name: "Adjusted Fixed Charge", + units: 8, + unit_amount_cents: 12.75 + } + ] + end + + it "regenerates invoice with all adjustments" do + regenerated_fee = regenerate_result.invoice.fees.find_by(fixed_charge_id: fixed_charge.id) + + expect(regenerated_fee.invoice_display_name).to eq "Adjusted Fixed Charge" + expect(regenerated_fee.units).to eq 8 + expect(regenerated_fee.unit_amount_cents).to eq 1275 + expect(regenerated_fee.amount_cents).to eq 10_200 + expect(regenerated_fee.precise_amount_cents).to eq 10_200.0 + expect(regenerated_fee.precise_unit_amount).to eq 12.75 + end + end + + context "with graduated fixed charge" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + charge_model: "graduated", + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "1", + flat_amount: "5" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "0.5", + flat_amount: "10" + } + ] + } + ) + end + + let(:original_invoice) do + travel_to(DateTime.new(2023, 1, 15)) { perform_billing } + invoice = subscription.invoices.first + + # Add a graduated fixed charge fee + create( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + amount_cents: 1500, + precise_amount_cents: 1500.0, + units: 10, + unit_amount_cents: 150, + precise_unit_amount: 1.5, + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "per_unit_amount" => "1", + "flat_amount" => "5", + "per_unit_total_amount" => "10", + "total_with_flat_amount" => "15", + "units" => "10" + } + ] + }, + properties: { + fixed_charges_from_datetime: subscription.started_at.beginning_of_day, + fixed_charges_to_datetime: subscription.started_at.end_of_month.end_of_day + } + ) + + invoice.update!(status: :voided) + invoice + end + + context "when adjusting units" do + let(:fees_params) do + [ + { + id: fixed_charge_fee.id, + subscription_id: subscription.id, + units: 15 + } + ] + end + + it "regenerates invoice applying graduated model to new units" do + regenerated_fee = regenerate_result.invoice.fees.find_by(fixed_charge_id: fixed_charge.id) + + # Expected: 5 + (10 * 1) + 10 + (5 * 0.5) = 27.50 + expect(regenerated_fee.units).to eq 15 + expect(regenerated_fee.amount_cents).to eq 2750 + expect(regenerated_fee.precise_amount_cents).to eq 2750.0 + + expect(regenerated_fee.amount_details).to be_present + expect(regenerated_fee.amount_details).to eq( + { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "flat_unit_amount" => "5.0", + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "10.0", + "total_with_flat_amount" => "15.0", + "units" => "10.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "flat_unit_amount" => "10.0", + "per_unit_amount" => "0.5", + "per_unit_total_amount" => "2.5", + "total_with_flat_amount" => "12.5", + "units" => "5.0" + } + ] + } + ) + end + end + + context "when adjusting unit amount" do + let(:fees_params) do + [ + { + id: fixed_charge_fee.id, + subscription_id: subscription.id, + units: 10, + unit_amount_cents: 3.00 + } + ] + end + + it "regenerates invoice with adjusted unit amount (not charge model)" do + regenerated_fee = regenerate_result.invoice.fees.find_by(fixed_charge_id: fixed_charge.id) + + # Expected: 10 units * 3.00 = 30.00 (ignores graduated model) + expect(regenerated_fee.units).to eq 10 + expect(regenerated_fee.unit_amount_cents).to eq 300 + expect(regenerated_fee.amount_cents).to eq 3000 + expect(regenerated_fee.precise_amount_cents).to eq 3000.0 + expect(regenerated_fee.precise_unit_amount).to eq 3.0 + end + end + end + + context "with volume fixed charge" do + let(:fixed_charge) do + create( + :fixed_charge, + plan:, + charge_model: "volume", + properties: { + volume_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "2", + flat_amount: "5" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "1.5", + flat_amount: "15" + } + ] + } + ) + end + + let(:original_invoice) do + travel_to(DateTime.new(2023, 1, 15)) { perform_billing } + invoice = subscription.invoices.first + + # Add a volume fixed charge fee + create( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + amount_cents: 2500, + precise_amount_cents: 2500.0, + units: 10, + unit_amount_cents: 250, + precise_unit_amount: 2.5, + amount_details: { + "volume_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "per_unit_amount" => "2", + "flat_amount" => "5", + "per_unit_total_amount" => "20", + "total_with_flat_amount" => "25", + "units" => "10" + } + ] + }, + properties: { + fixed_charges_from_datetime: subscription.started_at.beginning_of_day, + fixed_charges_to_datetime: subscription.started_at.end_of_month.end_of_day + } + ) + + invoice.update!(status: :voided) + invoice + end + + context "when adjusting units" do + let(:fees_params) do + [ + { + id: fixed_charge_fee.id, + subscription_id: subscription.id, + units: 12 + } + ] + end + + it "regenerates invoice applying volume model to new units" do + regenerated_fee = regenerate_result.invoice.fees.find_by(fixed_charge_id: fixed_charge.id) + + # Expected: 15 + (12 * 1.5) = 33.00 (uses second range) + expect(regenerated_fee.units).to eq 12 + expect(regenerated_fee.amount_cents).to eq 3300 + expect(regenerated_fee.precise_amount_cents).to eq 3300.0 + + expect(regenerated_fee.amount_details).to be_present + expect(regenerated_fee.amount_details).to eq( + { + "flat_unit_amount" => "15.0", + "per_unit_amount" => "1.5", + "per_unit_total_amount" => "18.0" + } + ) + end + end + end + end + end +end diff --git a/spec/services/invoices/retry_batch_service_spec.rb b/spec/services/invoices/retry_batch_service_spec.rb new file mode 100644 index 0000000..c0205e0 --- /dev/null +++ b/spec/services/invoices/retry_batch_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::RetryBatchService do + subject(:retry_batch_service) { described_class.new(organization:) } + + let(:customer) { create(:customer, payment_provider: "stripe") } + let(:organization) { customer.organization } + + describe "#call_async" do + it "enqueues a job to retry all payments" do + expect do + retry_batch_service.call_async + end.to have_enqueued_job(Invoices::RetryAllJob) + end + end + + describe "#call" do + let(:retry_service) { instance_double(Invoices::RetryService) } + let(:result) { BaseService::Result.new } + let(:invoice_ids) { [invoice_first.id, invoice_second.id] } + let(:invoice_first) do + create( + :invoice, + customer:, + status: "failed" + ) + end + let(:invoice_second) do + create( + :invoice, + customer:, + status: "failed" + ) + end + let(:invoice_third) do + create( + :invoice, + customer:, + status: "draft" + ) + end + + before do + invoice_first + invoice_second + invoice_third + + result.invoice = Invoice.new + + allow(Invoices::RetryService).to receive(:new).and_return(retry_service) + allow(retry_service).to receive(:call).and_return(result) + end + + it "returns processed invoices that have correct status" do + result = retry_batch_service.call(invoice_ids) + + expect(result).to be_success + expect(result.invoices.count).to eq(2) + end + + context "when inner service passes error result" do + before do + result.fail_with_error!(BaseService::MethodNotAllowedFailure.new(result, code: "error")) + end + + it "returns an error" do + result = retry_batch_service.call(invoice_ids) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("error") + end + end + end +end diff --git a/spec/services/invoices/retry_service_spec.rb b/spec/services/invoices/retry_service_spec.rb new file mode 100644 index 0000000..b9ed95b --- /dev/null +++ b/spec/services/invoices/retry_service_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::RetryService do + subject(:retry_service) { described_class.new(invoice:) } + + describe "#call" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:invoice) do + create( + :invoice, + :failed, + :with_tax_error, + :subscription, + customer:, + organization:, + subscriptions: [subscription], + currency: "EUR", + issuing_date: Time.zone.at(timestamp).to_date + ) + end + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at: started_at, + started_at:, + created_at: started_at + ) + end + + let(:timestamp) { Time.zone.now - 1.year } + let(:started_at) { Time.zone.now - 2.years } + let(:plan) { create(:plan, organization:, interval: "monthly") } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, charge_model: "standard", billable_metric:) } + + let(:fee_subscription) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 2_000 + ) + end + let(:fee_charge) do + create( + :fee, + invoice:, + charge:, + fee_type: :charge, + total_aggregated_units: 100, + amount_cents: 1_000 + ) + end + + before do + fee_subscription + fee_charge + end + + context "when invoice does not exist" do + it "returns an error" do + result = described_class.new(invoice: nil).call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice is not failed" do + before do + invoice.update(status: %i[draft finalized voided generating].sample) + end + + it "returns an error" do + result = retry_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("invalid_status") + end + end + + it "enqueues a Invoices::ProviderTaxes::PullTaxesAndApplyJob" do + expect do + retry_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::PullTaxesAndApplyJob).with(invoice:) + end + + it "sets correct statuses" do + retry_service.call + + expect(invoice.reload.status).to eq("pending") + expect(invoice.reload.tax_status).to eq("pending") + end + + context "when invoice is subscription_gated" do + let(:gated_subscription) do + create( + :subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: :payment, timeout_hours: 48, status: :pending}], + customer:, organization: + ) + end + + before do + create(:invoice_subscription, invoice:, subscription: gated_subscription) + invoice.update!(status: :failed) + end + + it "sets invoice status to open instead of pending" do + retry_service.call + invoice.reload + + expect(invoice).to be_open + expect(invoice).to be_tax_pending + end + end + end +end diff --git a/spec/services/invoices/subscription_service_spec.rb b/spec/services/invoices/subscription_service_spec.rb new file mode 100644 index 0000000..741e268 --- /dev/null +++ b/spec/services/invoices/subscription_service_spec.rb @@ -0,0 +1,677 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::SubscriptionService do + subject(:invoice_service) do + described_class.new( + subscriptions:, + timestamp: timestamp.to_i, + invoicing_reason: + ) + end + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20, billing_entity:) } + + let(:invoicing_reason) { :subscription_periodic } + + describe "#call" do + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at: started_at.to_date, + started_at:, + created_at: started_at + ) + end + let(:subscriptions) { [subscription] } + let(:lifetime_usage) { create(:lifetime_usage, subscription: subscription) } + + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:timestamp) { Time.zone.now.beginning_of_month } + let(:started_at) { Time.zone.parse("2022-10-01T00:00:00.000Z") } + + let(:plan) { create(:plan, interval: "monthly", pay_in_advance:) } + let(:pay_in_advance) { false } + + before do + tax + create(:standard_charge, plan: subscription.plan, charge_model: "standard") + lifetime_usage + + allow(SegmentTrackJob).to receive(:perform_later) + allow(Invoices::Payments::CreateService).to receive(:call_async).and_call_original + allow(Invoices::TransitionToFinalStatusService).to receive(:call).and_call_original + end + + it "calls SegmentTrackJob" do + invoice = invoice_service.call.invoice + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "invoice_created", + properties: { + organization_id: invoice.organization.id, + invoice_id: invoice.id, + invoice_type: invoice.invoice_type + } + ) + end + + it "creates a payment" do + allow(Invoices::Payments::CreateService).to receive(:call_async) + + invoice_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + it "creates an invoice" do + result = invoice_service.call + + expect(result).to be_success + + expect(result.invoice.invoice_subscriptions.first.to_datetime) + .to match_datetime((timestamp - 1.day).end_of_day) + expect(result.invoice.invoice_subscriptions.first.from_datetime) + .to match_datetime((timestamp - 1.month).beginning_of_day) + + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.issuing_date.to_date).to eq(timestamp) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.payment_status).to eq("pending") + expect(result.invoice.fees.subscription.count).to eq(1) + expect(result.invoice.fees.charge.count).to eq(0) + + expect(result.invoice.currency).to eq("EUR") + expect(result.invoice.fees_amount_cents).to eq(100) + + expect(result.invoice.taxes_amount_cents).to eq(20) + expect(result.invoice.taxes_rate).to eq(20) + expect(result.invoice.applied_taxes.count).to eq(1) + + expect(result.invoice.total_amount_cents).to eq(120) + expect(result.invoice.version_number).to eq(4) + expect(Invoices::TransitionToFinalStatusService).to have_received(:call).with(invoice: result.invoice) + expect(result.invoice).to be_finalized + end + + it_behaves_like "syncs invoice" do + let(:service_call) { invoice_service.call } + end + + it_behaves_like "applies invoice_custom_sections" do + let(:service_call) { invoice_service.call } + end + + it "enqueues a SendWebhookJob" do + expect do + invoice_service.call + end.to have_enqueued_job_after_commit(SendWebhookJob).with("invoice.created", Invoice) + end + + it "produces an activity log" do + invoice = described_class.call(subscriptions:, timestamp: timestamp.to_i, invoicing_reason:).invoice + + expect(Utils::ActivityLog).to have_produced("invoice.created").after_commit.with(invoice) + end + + it "enqueues GenerateDocumentsJob with email false" do + expect do + invoice_service.call + end.to have_enqueued_job_after_commit(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + + it "flags lifetime usage for refresh" do + create(:usage_threshold, plan:) + + invoice_service.call + + expect(subscription.reload.lifetime_usage.recalculate_invoiced_usage).to be(true) + end + + context "when there is tax provider integration" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + + before do + integration_customer + end + + it "creates an invoice with pending status and without applied taxes" do + result = invoice_service.call + + expect(result).to be_success + + expect(result.invoice.subscriptions.first).to eq(subscription) + expect(result.invoice.issuing_date.to_date).to eq(timestamp) + expect(result.invoice.invoice_type).to eq("subscription") + expect(result.invoice.payment_status).to eq("pending") + expect(result.invoice.fees.subscription.count).to eq(1) + expect(result.invoice.fees.charge.count).to eq(0) + + expect(result.invoice.currency).to eq("EUR") + expect(result.invoice.fees_amount_cents).to eq(100) + + expect(result.invoice.taxes_amount_cents).to eq(0) + expect(result.invoice.taxes_rate).to eq(0) + expect(result.invoice.applied_taxes.count).to eq(0) + + expect(result.invoice.version_number).to eq(4) + expect(result.invoice).to be_pending + end + end + + context "when periodic but no active subscriptions" do + it "does not create any invoices" do + subscription.terminated! + expect { invoice_service.call }.not_to change(Invoice, :count) + end + end + + context "with lago_premium", :premium do + context "when there is a hubspot integration" do + let(:integration) { create(:hubspot_integration, organization:, sync_invoices:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "enqueues Integrations::Aggregator::Invoices::Hubspot::CreateJob" do + expect do + invoice_service.call + end.to have_enqueued_job_after_commit(Integrations::Aggregator::Invoices::Hubspot::CreateJob) + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "does not enqueue Integrations::Aggregator::Invoices::Hubspot::CreateJob" do + expect do + invoice_service.call + end.not_to have_enqueued_job(Integrations::Aggregator::Invoices::Hubspot::CreateJob) + end + end + end + + context "when there is a netsuite integration" do + let(:integration) { create(:netsuite_integration, organization:, sync_invoices:) } + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + + before { integration_customer } + + context "when sync invoices is true" do + let(:sync_invoices) { true } + + it "enqueues Integrations::Aggregator::Invoices::CreateJob" do + expect do + invoice_service.call + end.to have_enqueued_job_after_commit(Integrations::Aggregator::Invoices::CreateJob) + end + end + + context "when sync invoices is false" do + let(:sync_invoices) { false } + + it "does not enqueue Integrations::Aggregator::Invoices::CreateJob" do + expect do + invoice_service.call + end.not_to have_enqueued_job(Integrations::Aggregator::Invoices::CreateJob) + end + end + end + + it "enqueues GenerateDocumentsJob with email true" do + expect do + invoice_service.call + end.to have_enqueued_job_after_commit(Invoices::GenerateDocumentsJob).with(hash_including(notify: true)) + end + + context "when organization does not have right email settings" do + before { customer.billing_entity.update!(email_settings: []) } + + it "enqueues GenerateDocumentsJob with email false" do + expect do + invoice_service.call + end.to have_enqueued_job_after_commit(Invoices::GenerateDocumentsJob).with(hash_including(notify: false)) + end + end + end + + context "with customer timezone" do + before { subscription.customer.update!(timezone: "America/Los_Angeles", invoice_grace_period: 3) } + + let(:timestamp) { DateTime.parse("2022-11-25 01:00:00") } + + it "assigns the issuing date in the customer timezone" do + result = invoice_service.call + + expect(result.invoice.issuing_date.to_s).to eq("2022-11-27") + end + end + + context "with applicable grace period" do + before do + subscription.customer.update!(invoice_grace_period: 3) + create(:wallet, customer: subscription.customer) + end + + it "does not track any invoice creation on segment" do + invoice_service.call + expect(SegmentTrackJob).not_to have_received(:perform_later) + end + + it "does not create any payment" do + invoice_service.call + expect(Invoices::Payments::CreateService).not_to have_received(:call_async) + end + + it "creates an invoice as draft" do + result = invoice_service.call + expect(result).to be_success + expect(result.invoice).to be_draft + end + + it "enqueues a SendWebhookJob" do + expect do + invoice_service.call + end.to have_enqueued_job_after_commit(SendWebhookJob).with("invoice.drafted", Invoice) + end + + it "produces an activity log" do + invoice = described_class.call(subscriptions:, timestamp: timestamp.to_i, invoicing_reason:).invoice + + expect(Utils::ActivityLog).to have_produced("invoice.drafted").after_commit.with(invoice) + end + + it "does not flag lifetime usage for refresh" do + invoice_service.call + + expect(lifetime_usage.reload.recalculate_invoiced_usage).to be(false) + end + + it "marks customer as awaiting wallet refresh" do + expect { invoice_service.call }.to change { customer.reload.awaiting_wallet_refresh }.from(false).to(true) + end + + context "with keep_anchor as issuing_date adjustment" do + before do + customer.update!(subscription_invoice_issuing_date_adjustment: "keep_anchor") + end + + it "creates an invoice as draft" do + result = invoice_service.call + expect(result).to be_success + expect(result.invoice).to be_draft + end + end + end + + context "when invoice already exists" do + let(:timestamp) { Time.zone.parse("2023-10-01T00:00:00.000Z") } + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice: old_invoice, + subscription:, + from_datetime: Time.zone.parse("2023-09-01T00:00:00.000Z"), + to_datetime: Time.zone.parse("2023-09-30T23:59:59.999Z").end_of_day, + charges_from_datetime: Time.zone.parse("2023-09-01T00:00:00.000Z"), + charges_to_datetime: Time.zone.parse("2023-09-30T23:59:59.999Z").end_of_day, + recurring: invoicing_reason.to_sym == :subscription_periodic, + invoicing_reason: + ) + end + + let(:old_invoice) do + create( + :invoice, + created_at: timestamp + 1.second, + customer: subscription.customer + ) + end + + before { invoice_subscription } + + it "does not raise an error" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_nil + end + end + + context "when skip zero invoices is set" do + before do + customer.update(finalize_zero_amount_invoice: :skip) + end + + context "when invoice total amount is not 0" do + it "creates an invoice in :finalized status" do + result = invoice_service.call + expect(result.invoice.status).to eq("finalized") + expect(result.invoice.number).not_to include("DRAFT") + end + end + + context "when invoice total amount is 0" do + let(:plan) { create(:plan, interval: "monthly", pay_in_advance:, amount_cents: 0) } + + before do + plan + end + + it "creates an invoice in :closed status" do + result = invoice_service.call + expect(result.invoice.status).to eq("closed") + expect(result.invoice.number).to include("DRAFT") + end + + context "when billing entity has grace period" do + let(:billing_entity) { create(:billing_entity, organization:, invoice_grace_period: 30) } + + it "creates an invoice in :draft status" do + result = invoice_service.call + expect(result.invoice.status).to eq("draft") + end + end + end + end + + context "when revenue_analytics is set", :premium do + before do + organization.update!(premium_integrations: %w[revenue_analytics]) + end + + it "enqueues DailyUsages::FillFromInvoiceJob with email false" do + expect { invoice_service.call } + .to have_enqueued_job_after_commit(DailyUsages::FillFromInvoiceJob) + .with(invoice: an_instance_of(Invoice), subscriptions: [subscription]) + end + + context "when subscription is terminating" do + let(:invoicing_reason) { :subscription_terminating } + + it "enqueues DailyUsages::FillFromInvoiceJob with email false" do + expect { invoice_service.call } + .to have_enqueued_job_after_commit(DailyUsages::FillFromInvoiceJob) + .with(invoice: an_instance_of(Invoice), subscriptions: [subscription]) + end + end + end + + context "when creating invoice for partner" do + let(:customer) { create(:customer, :with_salesforce_integration, :with_hubspot_integration, organization:, account_type: "partner") } + let(:salesforce_service) { instance_double(Integrations::Aggregator::Invoices::CreateService) } + let(:hubspot_service) { instance_double(Integrations::Aggregator::Invoices::Hubspot::CreateService) } + let(:result) { BaseService::Result.new } + + before do + allow(Integrations::Aggregator::Invoices::CreateService).to receive(:new).and_return(salesforce_service) + allow(salesforce_service).to receive(:call).and_return(result) + allow(Integrations::Aggregator::Invoices::Hubspot::CreateService).to receive(:new).and_return(hubspot_service) + allow(hubspot_service).to receive(:call).and_return(result) + end + + it "doesn't send update to integrations" do + invoice_service.call + + expect(Integrations::Aggregator::Invoices::CreateService).not_to have_received(:new) + expect(Integrations::Aggregator::Invoices::Hubspot::CreateService).not_to have_received(:new) + end + end + + context "when plan has pay in advance fixed charges" do + let(:plan) { create(:plan, interval: "monthly", pay_in_advance: true, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, pay_in_advance: true, properties: {amount: "100"}) } + + let(:started_at) { Time.zone.now.beginning_of_month } + let(:timestamp) { started_at } + let(:invoicing_reason) { :subscription_starting } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at: started_at.to_date, + started_at:, + created_at: started_at + ) + end + + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: started_at + ) + end + + before do + fixed_charge + fixed_charge_event + end + + it "creates fixed charge fees" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice.fees.subscription.count).to eq(1) + expect(result.invoice.fees.fixed_charge.count).to eq(1) + end + end + + context "when subscription trial period ends with pay in advance fixed charges already billed" do + let(:trial_period) { 15 } + let(:plan) { create(:plan, interval: "monthly", pay_in_advance: true, trial_period:, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:, pay_in_advance: true, properties: {amount: "100"}) } + + let(:started_at) { Time.zone.parse("2024-01-01T00:00:00Z") } + let(:trial_end_timestamp) { started_at + trial_period.days } + let(:timestamp) { trial_end_timestamp } + let(:invoicing_reason) { :subscription_starting } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at: started_at.to_date, + started_at:, + created_at: started_at + ) + end + + let(:billing_period_start) { started_at.beginning_of_month } + let(:billing_period_end) { started_at.end_of_month } + + # Simulate the invoice created on Day 1 for pay in advance fixed charges + let(:existing_invoice) do + create( + :invoice, + customer:, + organization:, + invoice_type: :subscription, + status: :finalized, + created_at: started_at + ) + end + + let(:existing_invoice_subscription) do + create( + :invoice_subscription, + invoice: existing_invoice, + subscription:, + invoicing_reason: :in_advance_charge, + timestamp: started_at + ) + end + + # The fixed charge event created when subscription started (Day 1) + let(:fixed_charge_event) do + create( + :fixed_charge_event, + subscription:, + fixed_charge:, + units: fixed_charge.units, + timestamp: started_at + ) + end + + # The fixed charge fee created on Day 1 + let(:existing_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice: existing_invoice, + subscription:, + fixed_charge:, + amount_cents: 10000, + properties: { + "timestamp" => started_at.iso8601, + "fixed_charges_from_datetime" => billing_period_start.iso8601, + "fixed_charges_to_datetime" => billing_period_end.iso8601 + } + ) + end + + before do + fixed_charge + fixed_charge_event + existing_invoice_subscription + existing_fixed_charge_fee + end + + around do |example| + travel_to(trial_end_timestamp) { example.run } + end + + it "does not create a duplicate fixed charge fee for the same billing period" do + result = invoice_service.call + + expect(result).to be_success + + # Subscription fee should be created (plan is pay_in_advance, trial ending) + expect(result.invoice.fees.subscription.count).to eq(1) + + # Fixed charge fee should NOT be created again since it was already billed on Day 1 + # for the same billing period (Jan 1 - Jan 31) + expect(result.invoice.fees.fixed_charge.count).to eq(0) + end + end + + context "when subscription is gated" do + let(:invoicing_reason) { :subscription_starting } + let(:pay_in_advance) { true } + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}], + plan:, customer:, organization: customer.organization, + subscription_at: started_at.to_date, started_at:, created_at: started_at) + end + + it "creates an open invoice" do + result = invoice_service.call + + expect(result).to be_success + expect(result.invoice).to be_open + end + + it "skips grace period" do + result = invoice_service.call + + expect(result.invoice.issuing_date.to_s).to eq(Time.zone.at(timestamp).to_date.to_s) + end + + it "does not send invoice.created webhook" do + invoice_service.call + + expect(SendWebhookJob).not_to have_been_enqueued.with("invoice.created", anything) + end + + it "does not generate documents" do + invoice_service.call + + expect(Invoices::GenerateDocumentsJob).not_to have_been_enqueued + end + + it "triggers payment" do + invoice_service.call + + expect(Invoices::Payments::CreateService).to have_received(:call_async) + end + + context "when invoice total is zero" do + let(:plan) { create(:plan, interval: "monthly", pay_in_advance: true, amount_cents: 0) } + let(:rule) { subscription.activation_rules.payment.sole } + + it "marks the payment activation rule as satisfied" do + invoice_service.call + + expect(rule.reload).to be_satisfied + end + + it "activates the subscription" do + invoice_service.call + + expect(subscription.reload).to be_active + end + end + + context "when tax is pending" do + let(:integration) { create(:anrok_integration, organization:) } + let(:integration_customer) { create(:anrok_customer, integration:, customer:) } + let(:rule) { subscription.activation_rules.payment.sole } + + before { integration_customer } + + it "does not fire the zero-amount activation shortcut" do + invoice_service.call + + expect(rule.reload).to be_pending + expect(subscription.reload).to be_incomplete + end + + it "keeps the invoice open with tax_status pending" do + result = invoice_service.call + + expect(result.invoice).to be_open + expect(result.invoice.tax_status).to eq("pending") + end + end + end + + context "when an error occurs" do + context "with a stale object error" do + it "propagates the error" do + allow_any_instance_of(Credits::AppliedPrepaidCreditsService) # rubocop:disable RSpec/AnyInstance + .to receive(:call).and_raise(ActiveRecord::StaleObjectError) + + expect { invoice_service.call }.to raise_error(ActiveRecord::StaleObjectError) + end + end + + context "with a failed to acquire lock error" do + it "propagates the error" do + allow_any_instance_of(Credits::AppliedPrepaidCreditsService) # rubocop:disable RSpec/AnyInstance + .to receive(:call).and_raise(Customers::FailedToAcquireLock) + + expect { invoice_service.call }.to raise_error(Customers::FailedToAcquireLock) + end + end + end + end +end diff --git a/spec/services/invoices/sync_salesforce_id_service_spec.rb b/spec/services/invoices/sync_salesforce_id_service_spec.rb new file mode 100644 index 0000000..a7d4d3d --- /dev/null +++ b/spec/services/invoices/sync_salesforce_id_service_spec.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::SyncSalesforceIdService do + subject(:service_call) { sync_salesforce_id_service.call } + + let(:sync_salesforce_id_service) { described_class.new(invoice:, params:) } + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, organization:) } + let(:params) { {} } + + describe "#call" do + context "when the invoice is nil" do + let(:invoice) { nil } + + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when the integration is nil" do + it "returns an error" do + result = service_call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("integration_not_found") + end + end + + context "when the integration resource does not exist" do + let(:integration) { create(:salesforce_integration, organization:) } + let(:params) do + { + integration_code: integration.code, + external_id: "1234" + } + end + + it "creates a new integration resource" do + expect { service_call }.to change(IntegrationResource, :count).by(1) + + result = service_call + expect(result).to be_success + expect(result.invoice).to eq(invoice) + + integration_resource = IntegrationResource.last + expect(integration_resource.integration).to eq(integration) + expect(integration_resource.external_id).to eq(params[:external_id]) + expect(integration_resource.syncable).to eq(invoice) + expect(integration_resource.resource_type).to eq("invoice") + end + end + end +end diff --git a/spec/services/invoices/transition_to_final_status_service_spec.rb b/spec/services/invoices/transition_to_final_status_service_spec.rb new file mode 100644 index 0000000..1bd8747 --- /dev/null +++ b/spec/services/invoices/transition_to_final_status_service_spec.rb @@ -0,0 +1,184 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::TransitionToFinalStatusService do + subject(:result) { described_class.call(invoice:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:, finalize_zero_amount_invoice: billing_entity_setting) } + let(:customer) { create(:customer, organization:, billing_entity:, finalize_zero_amount_invoice: customer_setting) } + let(:billing_entity_setting) { "true" } # default value + let(:customer_setting) { "inherit" } # default value + let(:fees_amount_cents) { 100 } + let(:invoice) do + create( + :invoice, + organization:, + currency: "EUR", + fees_amount_cents:, + issuing_date: Time.zone.now.beginning_of_month, + customer: + ) + end + + context "when invoice fees_amount_cents is not zero" do + it "finalizes the invoice" do + result + + expect(invoice.status).to eq("finalized") + end + + context "with billing entity and customer settings defined to not finalize" do + let(:organization_setting) { "false" } + let(:customer_setting) { "skip" } + + it "finalizes the invoice" do + result + + expect(invoice.status).to eq("finalized") + end + end + end + + context "when invoice is subscription_gated with positive amount" do + let(:fees_amount_cents) { 100 } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}], + organization:, customer:, plan:) + end + let(:invoice) do + create( + :invoice, + organization:, + currency: "EUR", + fees_amount_cents:, + total_amount_cents: fees_amount_cents, + issuing_date: Time.zone.now.beginning_of_month, + customer:, + status: :open + ) + end + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "keeps the invoice as open" do + result + + expect(invoice.status).to eq("open") + end + end + + context "when invoice is subscription_gated with zero amount" do + let(:fees_amount_cents) { 0 } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}], + organization:, customer:, plan:) + end + let(:invoice) do + create( + :invoice, + organization:, + currency: "EUR", + fees_amount_cents:, + total_amount_cents: 0, + issuing_date: Time.zone.now.beginning_of_month, + customer:, + status: :open + ) + end + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "follows the normal finalize/close logic" do + result + + expect(invoice.status).to eq("finalized") + end + end + + context "when invoice is subscription_gated with tax pending" do + let(:fees_amount_cents) { 100 } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}], + organization:, customer:, plan:) + end + let(:invoice) do + create( + :invoice, + organization:, + currency: "EUR", + fees_amount_cents:, + total_amount_cents: 0, + tax_status: :pending, + issuing_date: Time.zone.now.beginning_of_month, + customer:, + status: :open + ) + end + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "keeps the invoice as open while waiting for tax resolution" do + result + + expect(invoice.status).to eq("open") + end + end + + context "when invoice fees_amount_cents is zero" do + let(:fees_amount_cents) { 0 } + + context "with customer setting defined to finalize" do + let(:customer_setting) { "finalize" } + let(:organization_setting) { "false" } + + it "finalizes the invoice" do + result + + expect(invoice.status).to eq("finalized") + end + end + + context "with customer setting defined to skip" do + let(:customer_setting) { "skip" } + let(:organization_setting) { "true" } + + it "closes the invoice" do + result + + expect(invoice.status).to eq("closed") + end + end + + context "with customer setting defined to inherit" do + let(:customer_setting) { "inherit" } + + context "with billing_entity setting to finalize" do + let(:billing_entity_setting) { "true" } + + it "finalizes the invoice" do + result + + expect(invoice.status).to eq("finalized") + end + end + + context "with billing_entity setting to skip" do + let(:billing_entity_setting) { "false" } + + it "closes the invoice" do + result + + expect(invoice.status).to eq("closed") + end + end + end + end +end diff --git a/spec/services/invoices/update_all_invoice_issuing_date_from_billing_entity_service_spec.rb b/spec/services/invoices/update_all_invoice_issuing_date_from_billing_entity_service_spec.rb new file mode 100644 index 0000000..6b631e8 --- /dev/null +++ b/spec/services/invoices/update_all_invoice_issuing_date_from_billing_entity_service_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityService do + subject { described_class.new(billing_entity:, previous_issuing_date_settings:) } + + let(:billing_entity) { create(:billing_entity) } + let(:organization) { billing_entity.organization } + let(:previous_issuing_date_settings) do + { + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "keep_anchor", + invoice_grace_period: 3 + } + end + + context "when billing entity does not have invoices" do + it "enqueues zero jobs" do + expect { subject.call } + .not_to enqueue_job(Invoices::UpdateIssuingDateFromBillingEntityJob) + end + end + + context "when billing entity has draft invoices" do + let(:draft_invoice) { create(:invoice, :draft, organization:) } + + before { draft_invoice } + + it "enqueues 1 job for the draft invoice" do + expect { subject.call } + .to enqueue_job(Invoices::UpdateIssuingDateFromBillingEntityJob) + .with(draft_invoice, previous_issuing_date_settings) + end + end + + context "when billing entity has finalized invoices" do + let(:finalized_invoice) { create(:invoice, :finalized, organization:) } + + before { finalized_invoice } + + it "enqueues zero jobs" do + expect { subject.call } + .not_to enqueue_job(Invoices::UpdateIssuingDateFromBillingEntityJob) + end + end +end diff --git a/spec/services/invoices/update_issuing_date_from_billing_entity_service_spec.rb b/spec/services/invoices/update_issuing_date_from_billing_entity_service_spec.rb new file mode 100644 index 0000000..745dd5f --- /dev/null +++ b/spec/services/invoices/update_issuing_date_from_billing_entity_service_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::UpdateIssuingDateFromBillingEntityService do + subject { described_class.new(invoice:, previous_issuing_date_settings:) } + + let(:invoice) do + create(:invoice, :draft, customer:, issuing_date:, expected_finalization_date:, payment_due_date:, applied_grace_period: 12) + end + + let(:customer) { create(:customer) } + let(:issuing_date) { Time.current + old_grace_period.days } + let(:expected_finalization_date) { Time.current + old_grace_period.days } + let(:payment_due_date) { issuing_date } + + let(:previous_issuing_date_settings) do + { + subscription_invoice_issuing_date_anchor: "next_period_start", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date", + invoice_grace_period: old_grace_period + } + end + + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + + let(:old_grace_period) { 12 } + let(:new_grace_period) { 1 } + + before do + invoice.billing_entity.update!( + subscription_invoice_issuing_date_anchor:, + subscription_invoice_issuing_date_adjustment:, + invoice_grace_period: new_grace_period + ) + end + + shared_examples "does not change invoice dates" do + it "does not change the issuing_date" do + expect { subject.call }.not_to change { invoice.reload.issuing_date } + end + + it "does not change the applied_grace_period" do + expect { subject.call }.not_to change { invoice.reload.applied_grace_period } + end + + it "does not change the payment due date" do + expect { subject.call }.not_to change { invoice.reload.payment_due_date } + end + end + + context "when customer has invoice_grace_period" do + before do + invoice.customer.update!(invoice_grace_period: 12) + end + + it_behaves_like "does not change invoice dates" + end + + context "when invoice is not draft" do + before do + invoice.finalized! + end + + it_behaves_like "does not change invoice dates" + end + + context "when going from 12 to 15 days" do + let(:new_grace_period) { 15 } + + it "changes the issuing_date by 3 days" do + expect { subject.call }.to change(invoice, :issuing_date).by(3) + end + + it "changes the expected_finalization_date by 3 days" do + expect { subject.call }.to change(invoice, :expected_finalization_date).by(3) + end + + it "changes the applied_grace_to 15" do + expect { subject.call }.to change(invoice, :applied_grace_period).to(15) + end + + it "changes the payment_due_date by 3 days" do + expect { subject.call }.to change(invoice, :payment_due_date).by(3) + end + end + + context "when going from 12 to 9 days" do + let(:new_grace_period) { 9 } + + it "changes the issuing_date by 3 days" do + expect { subject.call }.to change(invoice, :issuing_date).by(-3) + end + + it "changes the expected_finalization_date by 3 days" do + expect { subject.call }.to change(invoice, :expected_finalization_date).by(-3) + end + + it "changes the applied_grace_to 9" do + expect { subject.call }.to change(invoice, :applied_grace_period).to(9) + end + + it "changes the payment_due_date by 3 days" do + expect { subject.call }.to change(invoice, :payment_due_date).by(-3) + end + end + + context "with issuing date preferences" do + let(:recurring) { true } + + before do + create(:invoice_subscription, invoice:, recurring:) + end + + context "with current_period_end + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:new_grace_period) { 2 } + + it "updates issuing_date and expected_finalization_date" do + expect { subject.call }.to change(invoice, :issuing_date).by(-13) + .and change(invoice, :expected_finalization_date).by(-10) + end + end + + context "with current_period_end + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + let(:new_grace_period) { 2 } + + it "updates issuing_date and expected_finalization_date" do + expect { subject.call }.to change(invoice, :issuing_date).by(-10) + .and change(invoice, :expected_finalization_date).by(-10) + end + end + + context "with next_period_start + keep_anchor" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:new_grace_period) { 2 } + + it "updates issuing_date and expected_finalization_date" do + expect { subject.call }.to change(invoice, :issuing_date).by(-12) + .and change(invoice, :expected_finalization_date).by(-10) + end + end + + context "with next_period_start + align_with_finalization_date" do + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "align_with_finalization_date" } + let(:new_grace_period) { 2 } + + it "updates issuing_date and expected_finalization_date" do + expect { subject.call }.to change(invoice, :issuing_date).by(-10) + .and change(invoice, :expected_finalization_date).by(-10) + end + end + + context "with preferences set on the customer level" do + let(:customer) do + create( + :customer, + subscription_invoice_issuing_date_anchor: "current_period_end", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date", + invoice_grace_period: 12 + ) + end + + let(:subscription_invoice_issuing_date_anchor) { "next_period_start" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:new_grace_period) { 2 } + + it "ignores billing_entity issuing date preferences" do + expect { subject.call }.not_to change(invoice, :issuing_date) + end + + it "ignores billing_entity inovice_grace_period" do + expect { subject.call }.not_to change(invoice, :expected_finalization_date) + end + end + + context "when invoice is not recurring" do + let(:recurring) { false } + + let(:subscription_invoice_issuing_date_anchor) { "current_period_end" } + let(:subscription_invoice_issuing_date_adjustment) { "keep_anchor" } + let(:new_grace_period) { 2 } + + it "ignores all issuing date preferences" do + expect { subject.call }.to change(invoice, :issuing_date).by(-10) + end + + it "applies new invoice_grace_period to expected_finalization_date" do + expect { subject.call }.to change(invoice, :expected_finalization_date).by(-10) + end + end + end +end diff --git a/spec/services/invoices/update_service_spec.rb b/spec/services/invoices/update_service_spec.rb new file mode 100644 index 0000000..d7ffd57 --- /dev/null +++ b/spec/services/invoices/update_service_spec.rb @@ -0,0 +1,348 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::UpdateService do + subject(:invoice_service) do + described_class.new(invoice:, params: update_args, webhook_notification:) + end + + let(:invoice) { create(:invoice, payment_overdue: true) } + let(:invoice_id) { invoice.id } + let(:webhook_notification) { false } + + let(:update_args) do + { + payment_status: "succeeded", + total_paid_amount_cents: 100 + } + end + + let(:result) { invoice_service.call } + + describe "call" do + it "updates the invoice" do + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.invoice).to have_attributes( + payment_overdue: false, + payment_status: update_args[:payment_status], + total_paid_amount_cents: update_args[:total_paid_amount_cents] + ) + end + + context "when invoices is included in a payment request" do + let(:customer) do + create( + :customer, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: 1.day.ago + ) + end + + let(:invoice) { create(:invoice, payment_overdue: true, customer:) } + + let(:payment_request) do + create(:payment_request, customer:, invoices: [invoice]) + end + + before do + payment_request + end + + it "does not reset customer dunning campaign status counters" do + expect { result && customer.reload } + .to not_change(customer, :last_dunning_campaign_attempt) + .and not_change { customer.last_dunning_campaign_attempt_at.to_i } + end + + context "when payment request belongs to a dunning campaign" do + let(:dunning_campaign) { create(:dunning_campaign) } + let(:payment_request) do + create(:payment_request, customer:, invoices: [invoice], dunning_campaign:) + end + + it "resets customer dunning campaign status counters" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + end + end + end + + context "when updating payment status" do + context "when invoice is in draft status" do + let(:invoice) { create(:invoice, :draft) } + + it "does not update the invoice" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("payment_status_update_on_draft_invoice") + end + end + + context "when invoice is not in draft status" do + it "updates the invoice" do + expect(result).to be_success + expect(result.invoice).to eq(invoice) + expect(result.invoice.payment_status).to eq(update_args[:payment_status]) + end + end + end + + context "with attached fees" do + it "enqueues a job to update the payment_status of the fees" do + expect { result }.to have_enqueued_job_after_commit(Invoices::UpdateFeesPaymentStatusJob).with(invoice) + end + end + + context "with metadata" do + let(:invoice_metadata) { create(:invoice_metadata, invoice:) } + let(:another_invoice_metadata) { create(:invoice_metadata, invoice:, key: "test", value: "1") } + let(:update_args) do + { + metadata: [ + { + id: invoice_metadata.id, + key: "new key", + value: "new value" + }, + { + key: "Added key", + value: "Added value" + } + ] + } + end + + before do + invoice_metadata + another_invoice_metadata + end + + it "updates metadata" do + metadata_keys = result.invoice.metadata.pluck(:key) + metadata_ids = result.invoice.metadata.pluck(:id) + + expect(result.invoice.metadata.count).to eq(2) + expect(metadata_keys).to eq(["new key", "Added key"]) + expect(metadata_ids).to include(invoice_metadata.id) + expect(metadata_ids).not_to include(another_invoice_metadata.id) + end + + context "when invoice is in draft status" do + let(:invoice) { create(:invoice, status: "draft") } + + it "fails to update metadata" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("metadata_on_draft_invoice") + end + end + + context "when more than five metadata objects are provided" do + let(:update_args) do + { + metadata: [ + { + id: invoice_metadata.id, + key: "new key", + value: "new value" + }, + { + key: "Added key1", + value: "Added value1" + }, + { + key: "Added key2", + value: "Added value2" + }, + { + key: "Added key3", + value: "Added value3" + }, + { + key: "Added key4", + value: "Added value4" + }, + { + key: "Added key5", + value: "Added value5" + } + ] + } + end + + it "fails to update invoice with metadata" do + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:metadata) + expect(result.error.messages[:metadata]).to include("invalid_count") + end + end + end + + context "when invoice has hubspot integration" do + let(:sync_invoices) { true } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:integration) { create(:hubspot_integration, organization:, sync_invoices:) } + let(:integration_customer) { create(:hubspot_customer, integration:, customer:, organization:) } + let(:invoice) { create(:invoice, customer: integration_customer.customer, organization:) } + + it "enqueues a job to update the hubspot invoice" do + expect { result }.to have_enqueued_job_after_commit(Integrations::Aggregator::Invoices::Hubspot::UpdateJob).with(invoice:) + end + + context "when it should not sync hubspot invoices" do + let(:sync_invoices) { false } + + it "does not enqueue a job to update the hubspot invoice" do + result + + expect(Integrations::Aggregator::Invoices::Hubspot::UpdateJob).not_to have_been_enqueued + end + end + end + + context "when invoice type is credit" do + let(:subscription) { create(:subscription, customer: invoice.customer) } + let(:wallet) { create(:wallet, customer: invoice.customer, balance: 10.0, credits_balance: 10.0) } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, amount: 15.0, credit_amount: 15.0, status: "pending") + end + let(:fee) do + create( + :fee, + fee_type: "credit", + invoiceable_type: "WalletTransaction", + invoiceable_id: wallet_transaction.id, + invoice: + ) + end + + before do + wallet_transaction + fee + subscription + invoice.update(invoice_type: "credit") + end + + context "when payment_status is succeeded" do + let(:update_args) { {payment_status: "succeeded"} } + + it "calls Invoices::PrepaidCreditJob with the correct arguments" do + expect { result }.to have_enqueued_job_after_commit(Invoices::PrepaidCreditJob).with(invoice, :succeeded) + end + end + + context "when payment_status is failed" do + let(:update_args) { {payment_status: "failed"} } + + it "calls Invoices::PrepaidCreditJob with the correct arguments" do + expect { result }.to have_enqueued_job_after_commit(Invoices::PrepaidCreditJob).with(invoice, :failed) + end + end + end + + context "when invoice is subscription_gated and payment_status changes" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + organization: invoice.organization, customer: invoice.customer, plan:, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}]) + end + let(:plan) { create(:plan, organization: invoice.organization, pay_in_advance: true) } + let(:invoice) { create(:invoice, status: :open, invoice_type: :subscription, payment_overdue: false) } + + before { create(:invoice_subscription, invoice:, subscription:) } + + context "when payment_status is succeeded" do + let(:update_args) { {payment_status: "succeeded"} } + + it "enqueues ResolveJob" do + expect { invoice_service.call } + .to have_enqueued_job_after_commit(Subscriptions::ActivationRules::Payment::ResolveJob) + .with(subscription, invoice, :succeeded) + end + end + + context "when payment_status is failed" do + let(:update_args) { {payment_status: "failed"} } + + it "enqueues ResolveJob" do + expect { invoice_service.call } + .to have_enqueued_job_after_commit(Subscriptions::ActivationRules::Payment::ResolveJob) + .with(subscription, invoice, :failed) + end + end + end + + context "with payment_status update and notification is turned on" do + let(:webhook_notification) { true } + + context "when invoice is visible" do + it "delivers a webhook" do + expect { result }.to have_enqueued_job_after_commit(SendWebhookJob).with("invoice.payment_status_updated", invoice) + end + + it "produces an activity log" do + result + + expect(Utils::ActivityLog).to have_produced("invoice.payment_status_updated").after_commit.with(invoice) + end + end + + context "when invoice is invisible" do + before { invoice.update! status: :open } + + it "does not deliver a webhook" do + expect { result }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when payment status has not changed" do + let(:invoice) { create(:invoice, payment_status: :succeeded) } + + it "does not deliver a webhook" do + expect { result }.not_to have_enqueued_job(SendWebhookJob) + end + end + end + + context "when invoice does not exist" do + let(:invoice) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice payment_status is invalid" do + let(:update_args) do + { + payment_status: "Foo Bar" + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payment_status) + expect(result.error.messages[:payment_status]).to include("value_is_invalid") + end + end + + context "with validation error" do + before do + invoice.issuing_date = nil + invoice.save(validate: false) + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:issuing_date]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/invoices/void_service_spec.rb b/spec/services/invoices/void_service_spec.rb new file mode 100644 index 0000000..78ff0ea --- /dev/null +++ b/spec/services/invoices/void_service_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Invoices::VoidService do + subject(:void_service) { described_class.new(invoice:, params:) } + + let(:params) { {} } + + describe "#call" do + context "when invoice is nil" do + let(:invoice) { nil } + + it "returns a failure" do + result = void_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("invoice") + end + end + + context "when invoice is draft" do + let(:invoice) { create(:invoice, :draft) } + + it "returns a failure" do + result = void_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("not_voidable") + end + end + + context "when the invoice is voided" do + let(:invoice) { create(:invoice, status: :voided) } + + it "returns a failure" do + result = void_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("not_voidable") + end + end + + context "when the invoice is finalized" do + let(:invoice) { create(:invoice, :subscription, subscriptions:, status: :finalized, payment_status:, payment_overdue: true) } + let(:subscriptions) { create_list(:subscription, 1) } + + context "when the payment status is succeeded" do + let(:payment_status) { :succeeded } + + it "voids the invoice" do + result = void_service.call + + expect(result).to be_success + expect(result.invoice).to be_voided + expect(result.invoice.voided_at).to be_present + end + end + + context "when the payment status is not succeeded" do + let(:payment_status) { [:pending, :failed].sample } + + it "voids the invoice" do + result = void_service.call + + expect(result).to be_success + expect(result.invoice).to be_voided + expect(result.invoice.voided_at).to be_present + # expect(result.invoice.balance_amount_cents).to eq(0) + end + + it "enqueues a sync void invoice job" do + expect do + void_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::VoidJob).with(invoice:) + end + + it "marks the invoice's payment overdue as false" do + expect { void_service.call }.to change(invoice, :payment_overdue).from(true).to(false) + end + + it "flags lifetime usage for refresh" do + create(:usage_threshold, plan: subscriptions.first.plan) + + void_service.call + + expect(invoice.subscriptions.first.lifetime_usage.recalculate_invoiced_usage).to be(true) + end + + it "produces an activity log" do + invoice = described_class.call(invoice:).invoice + + expect(Utils::ActivityLog).to have_produced("invoice.voided").after_commit.with(invoice) + end + + context "when the invoice has applied credits from the wallet" do + let(:wallet) { create(:wallet, credits_balance: 100, balance_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice:, transaction_type: "outbound", amount: 100, credit_amount: 100) } + + before do + wallet_transaction + allow(WalletTransactions::RecreditService).to receive(:call).and_call_original + end + + it "recredits the wallet transaction" do + void_service.call + expect(WalletTransactions::RecreditService).to have_received(:call).with(wallet_transaction: wallet_transaction) + expect(wallet.wallet_transactions.count).to eq(2) + expect(wallet.reload.credits_balance).to eq(200) + end + end + + context "when the invoice has applied credits from inactive wallet" do + let(:wallet) { create(:wallet, credits_balance: 100, balance_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice:, transaction_type: "outbound", amount: 100, credit_amount: 100) } + + before do + wallet_transaction + allow(WalletTransactions::RecreditService).to receive(:call).and_call_original + end + + it "dont recredit the wallet transaction" do + wallet.mark_as_terminated! + void_service.call + expect(WalletTransactions::RecreditService).not_to have_received(:call) + expect(wallet.wallet_transactions.count).to eq(1) + expect(wallet.reload.credits_balance).to eq(100) + end + end + + context "when the invoice has credits from applied coupons" do + let(:coupon) { create(:coupon) } + let(:applied_coupon) { create(:applied_coupon, coupon: coupon) } + let!(:credit) { create(:credit, invoice: invoice, applied_coupon: applied_coupon) } + + before do + allow(AppliedCoupons::RecreditService).to receive(:call!).and_call_original + end + + it "calls the recredit service for applied coupons" do + void_service.call + expect(AppliedCoupons::RecreditService).to have_received(:call!).with(credit: credit) + end + end + + context "when the invoice has credits from credit notes" do + let(:credit_note) { create(:credit_note) } + let!(:credit) { create(:credit, invoice: invoice, credit_note: credit_note) } + + before do + allow(CreditNotes::RecreditService).to receive(:call!).and_call_original + end + + it "dont call the recredit service for credit notes" do + void_service.call + expect(CreditNotes::RecreditService).not_to have_received(:call!).with(credit: credit) + end + end + + context "when invoice is a purchase credits invoice" do + let(:invoice) { create(:invoice, :credit, status: :finalized, payment_status:, payment_overdue: true) } + let(:payment_status) { [:pending, :failed].sample } + let(:wallet) { create(:wallet, credits_balance: 100, balance_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice:, transaction_type: "inbound", amount: 100, credit_amount: 100) } + + before do + wallet_transaction + allow(WalletTransactions::RecreditService).to receive(:call).and_call_original + end + + it "voids the invoice" do + result = void_service.call + + expect(result).to be_success + expect(result.invoice).to be_voided + expect(result.invoice.voided_at).to be_present + end + + it "does not recredit the wallet transaction" do + void_service.call + + expect(wallet.wallet_transactions.count).to eq(1) + expect(wallet.reload.credits_balance).to eq(100) + expect(WalletTransactions::RecreditService).not_to have_received(:call) + end + end + end + end + + describe "when generate credit note is true" do + let(:params) { {generate_credit_note: true} } + + context "when invoice is nil" do + let(:invoice) { nil } + + it "returns a failure" do + result = void_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("invoice") + end + end + + context "when the invoice is voided", :premium do + let(:invoice) { create(:invoice, status: :voided) } + + it "returns a failure" do + result = void_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("not_voidable") + end + end + end + end +end diff --git a/spec/services/legacy_result_spec.rb b/spec/services/legacy_result_spec.rb new file mode 100644 index 0000000..1e5a072 --- /dev/null +++ b/spec/services/legacy_result_spec.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe BaseService::LegacyResult do # rubocop:disable RSpec/SpecFilePathFormat + subject(:result) { described_class.new } + + it_behaves_like "a result object" + + it { expect(subject).to be_a(OpenStruct) } +end diff --git a/spec/services/lifetime_usages/calculate_service_spec.rb b/spec/services/lifetime_usages/calculate_service_spec.rb new file mode 100644 index 0000000..75c3efa --- /dev/null +++ b/spec/services/lifetime_usages/calculate_service_spec.rb @@ -0,0 +1,196 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::CalculateService do + subject(:service) { described_class.new(lifetime_usage: lifetime_usage) } + + let(:lifetime_usage) { create(:lifetime_usage, organization:, subscription:, recalculate_current_usage:, recalculate_invoiced_usage:) } + let(:recalculate_current_usage) { false } + let(:recalculate_invoiced_usage) { false } + let(:subscription) { create(:subscription, customer:, subscription_at:) } + let(:organization) { customer.organization } + let(:customer) { create(:customer) } + + let(:billable_metric) { create(:billable_metric, organization:, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:, properties: {amount: "10"}) } + let(:timestamp) { Time.current } + let(:subscription_at) { timestamp - 6.months } + let(:fees) do + create_list( + :charge_fee, + 2, + invoice:, + charge:, + customer:, + organization:, + amount_cents: 100, + precise_coupons_amount_cents: 50 + ) + end + + let(:events) do + create_list( + :event, + 2, + organization:, + subscription:, + customer:, + code: billable_metric.code, + timestamp: + ) + end + + describe "#recalculate_invoiced_usage" do + let(:recalculate_invoiced_usage) { true } + + context "without previous invoices" do + it "calculates the invoiced_usage as zero" do + result = service.call + expect(result.lifetime_usage.invoiced_usage_amount_cents).to be_zero + end + + it "updates the invoiced_usage_amount_refreshed_at" do + expect { service.call }.to change(lifetime_usage, :invoiced_usage_amount_refreshed_at) + end + + it "also changes current_usage_amount_refreshed_at" do + expect { service.call }.to change(lifetime_usage, :current_usage_amount_refreshed_at) + end + end + + context "with draft invoice" do + let(:invoice) { create(:invoice, :draft, :with_subscriptions, customer:, organization:, subscriptions: [subscription]) } + + before do + invoice + fees + end + + it "calculates the invoiced_usage as zero" do + result = service.call + expect(result.lifetime_usage.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.reload.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.recalculate_invoiced_usage).to be false + end + end + + context "with finalized invoice" do + let(:invoice) { create(:invoice, :finalized, :with_subscriptions, organization:, subscriptions: [subscription]) } + + before do + invoice + fees + end + + it "calculates the invoiced_usage_amount_cents correctly" do + result = service.call + expect(result.lifetime_usage.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.reload.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.recalculate_invoiced_usage).to be false + end + end + + context "with finalized invoice and usage" do + let(:invoice) { create(:invoice, :finalized, :with_subscriptions, organization:, subscriptions: [subscription]) } + + before do + invoice + fees + events + charge + Rails.cache.clear + end + + it "calculates the invoiced_usage_amount_cents correctly" do + result = service.call + expect(result.lifetime_usage.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.reload.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.recalculate_invoiced_usage).to be false + end + + it "calculates the current_usage_amount_cents correctly" do + result = service.call + expect(result.lifetime_usage.current_usage_amount_cents).to eq(2000) + expect(lifetime_usage.reload.current_usage_amount_cents).to eq(2000) + expect(lifetime_usage.recalculate_current_usage).to be false + end + end + + context "with invoices from previous subscription" do + let(:subscription) do + create( + :subscription, + customer:, + subscription_at:, + previous_subscription:, + external_id: previous_subscription.external_id + ) + end + + let(:previous_subscription) { create(:subscription, :terminated, customer:, subscription_at:) } + let(:invoice) { create(:invoice, :finalized, :with_subscriptions, customer:, subscriptions: [subscription]) } + + before do + invoice + fees + end + + it "calculates the invoiced_usage_amount_cents correctly" do + result = service.call + expect(result.lifetime_usage.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.reload.invoiced_usage_amount_cents).to eq(200) + expect(lifetime_usage.recalculate_invoiced_usage).to be false + end + end + end + + describe "#recalculate_current_usage" do + let(:recalculate_current_usage) { true } + + context "without usage" do + it "calculates the current_usage as zero" do + result = service.call + expect(result.lifetime_usage.current_usage_amount_cents).to be_zero + end + end + + it "updates the current_usage_amount_refreshed_at" do + expect { service.call }.to change(lifetime_usage, :current_usage_amount_refreshed_at) + end + + it "does not change invoiced_usage_amount_refreshed_at" do + expect { service.call }.not_to change(lifetime_usage, :invoiced_usage_amount_refreshed_at) + end + + context "with terminated subscription" do + before do + lifetime_usage.subscription.mark_as_terminated!(20.seconds.ago) + end + + it "clears the recalculate_current_usage flag" do + result = service.call + expect(result.lifetime_usage.recalculate_current_usage).to eq(false) + end + + it "does not update the current_usage_amount_refreshed_at" do + expect { service.call }.not_to change(lifetime_usage, :current_usage_amount_refreshed_at) + end + end + + context "with usage" do + before do + events + charge + Rails.cache.clear + end + + it "calculates the current_usage_amount_cents correctly" do + result = service.call + expect(result.lifetime_usage.current_usage_amount_cents).to eq(2000) + expect(lifetime_usage.reload.current_usage_amount_cents).to eq(2000) + expect(lifetime_usage.recalculate_current_usage).to be false + end + end + end +end diff --git a/spec/services/lifetime_usages/check_thresholds_service_spec.rb b/spec/services/lifetime_usages/check_thresholds_service_spec.rb new file mode 100644 index 0000000..fd4edd5 --- /dev/null +++ b/spec/services/lifetime_usages/check_thresholds_service_spec.rb @@ -0,0 +1,191 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::CheckThresholdsService, transaction: false do + subject(:service) { described_class.new(lifetime_usage:) } + + let(:lifetime_usage) { create(:lifetime_usage, subscription:, recalculate_current_usage: true, recalculate_invoiced_usage: true, current_usage_amount_cents:) } + let(:current_usage_amount_cents) { 0 } + let(:subscription) { create(:subscription, customer_id: customer.id) } + let(:organization) { subscription.organization } + let(:customer) { create(:customer) } + + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + let(:charge) { create(:standard_charge, plan: subscription.plan, billable_metric:, properties: {amount: "10"}) } + let(:timestamp) { Time.current } + + def create_thresholds(subscription, amounts:, recurring: nil) + amounts.each do |amount| + subscription.plan.usage_thresholds.create!(amount_cents: amount) + end + if recurring + subscription.plan.usage_thresholds.create!(amount_cents: recurring, recurring: true) + end + end + + context "when we pass a threshold" do + let(:current_usage_amount_cents) { 20 } + let(:usage_threshold) { create(:usage_threshold, plan: subscription.plan, amount_cents: 10) } + + before do + usage_threshold + charge + end + + it "ignores the flags" do + service.call + expect(lifetime_usage.recalculate_invoiced_usage).to eq true + expect(lifetime_usage.recalculate_current_usage).to eq true + end + + it "sends a webhook for that threshold" do + expect { service.call }.to enqueue_job(SendWebhookJob) + .with( + "subscription.usage_threshold_reached", + subscription, + usage_threshold: + ).on_queue(webhook_queue) + end + + it "creates an invoice for the usage_threshold" do + expect { service.call }.to change(Invoice, :count).by(1) + end + + context "when there is tax provider error" do + let(:error_result) { BaseService::Result.new.unknown_tax_failure!(code: "tax_error", message: "") } + + before do + allow(Invoices::ProgressiveBillingService).to receive(:call).and_return(error_result) + end + + it "creates a pending invoice without raising error" do + expect { service.call }.not_to raise_error + end + end + end + + context "when we pass multiple thresholds" do + let(:current_usage_amount_cents) { 401 } + let(:usage_threshold) { create(:usage_threshold, plan: subscription.plan, amount_cents: 10) } + let(:usage_threshold2) { create(:usage_threshold, plan: subscription.plan, amount_cents: 400) } + + before do + usage_threshold + usage_threshold2 + charge + end + + it "ignores the flags" do + service.call + expect(lifetime_usage.recalculate_invoiced_usage).to eq true + expect(lifetime_usage.recalculate_current_usage).to eq true + end + + it "sends a webhook for the first threshold" do + expect { service.call }.to enqueue_job(SendWebhookJob) + .with( + "subscription.usage_threshold_reached", + subscription, + usage_threshold: + ).on_queue(webhook_queue) + end + + it "sends a webhook for the last threshold" do + expect { service.call }.to enqueue_job(SendWebhookJob) + .with( + "subscription.usage_threshold_reached", + subscription, + usage_threshold: usage_threshold2 + ).on_queue(webhook_queue) + end + + it "creates an invoice for the current usage" do + expect { service.call }.to change(Invoice, :count).by(1) + end + end + + context "when we pass a threshold with already progressive_billing invoices present" do + let(:current_usage_amount_cents) { 401 } + let(:usage_threshold) { create(:usage_threshold, plan: subscription.plan, amount_cents: 10) } + let(:usage_threshold2) { create(:usage_threshold, plan: subscription.plan, amount_cents: 400) } + let(:progressive_billing_invoice) do + create( + :invoice, + :with_subscriptions, + organization:, + customer:, + status: "finalized", + invoice_type: :progressive_billing, + subscriptions: [subscription] + ) + end + + let(:progressive_billing_fee) { create(:charge_fee, amount_cents: 20, invoice: progressive_billing_invoice) } + + before do + usage_threshold + usage_threshold2 + progressive_billing_fee + charge + lifetime_usage.update! invoiced_usage_amount_cents: progressive_billing_fee.amount_cents + end + + it "ignores the flags" do + service.call + expect(lifetime_usage.recalculate_invoiced_usage).to eq true + expect(lifetime_usage.recalculate_current_usage).to eq true + end + + it "sends a webhook for the last threshold" do + expect { service.call }.to enqueue_job(SendWebhookJob) + .with( + "subscription.usage_threshold_reached", + subscription, + usage_threshold: usage_threshold2 + ).on_queue(webhook_queue) + end + + it "creates an invoice for the current usage" do + expect { service.call }.to change(Invoice, :count).by(1) + end + end + + context "when we pass no thresholds" do + let(:usage_threshold) { create(:usage_threshold, plan: subscription.plan, amount_cents: 3000) } + + before do + usage_threshold + charge + end + + it "ignores the flags" do + service.call + expect(lifetime_usage.recalculate_invoiced_usage).to eq true + expect(lifetime_usage.recalculate_current_usage).to eq true + end + + it "does not send a webhook for the threshold" do + expect { service.call }.not_to enqueue_job(SendWebhookJob) + .with( + "subscription.usage_threshold_reached", + subscription, + usage_threshold: + ).on_queue(:webhook) + end + + it "does not create an invoice for the largest usage_threshold amount" do + expect { service.call }.not_to change(Invoice, :count) + expect(subscription.invoices.progressive_billing).to be_empty + end + end + + context "when subscription is terminated" do + let(:subscription) { create(:subscription, :terminated, customer: customer) } + + it "does not create an invoice for the current usage" do + expect { service.call }.not_to change(Invoice, :count) + expect(subscription.invoices.progressive_billing).to be_empty + end + end +end diff --git a/spec/services/lifetime_usages/find_last_and_next_thresholds_service_spec.rb b/spec/services/lifetime_usages/find_last_and_next_thresholds_service_spec.rb new file mode 100644 index 0000000..ca3532c --- /dev/null +++ b/spec/services/lifetime_usages/find_last_and_next_thresholds_service_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::FindLastAndNextThresholdsService do + subject(:lifetime_usage_result) { described_class.call(lifetime_usage:) } + + let(:lifetime_usage) { create(:lifetime_usage, subscription:, organization:, current_usage_amount_cents:) } + let(:current_usage_amount_cents) { 0 } + + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, customer:) } + + it "computes the amounts" do + expect(lifetime_usage_result.last_threshold_amount_cents).to be_nil + expect(lifetime_usage_result.next_threshold_amount_cents).to be_nil + expect(lifetime_usage_result.next_threshold_ratio).to be_nil + end + + context "with a usage_threshold" do + let(:usage_threshold) { create(:usage_threshold, plan:, amount_cents: 100) } + + before { usage_threshold } + + it "computes the amounts" do + expect(lifetime_usage_result.last_threshold_amount_cents).to be_nil + expect(lifetime_usage_result.next_threshold_amount_cents).to eq(100) + expect(lifetime_usage_result.next_threshold_ratio).to be_zero + end + + context "with a lifetime_usage" do + let(:current_usage_amount_cents) { 23 } + + it "computes the amounts" do + expect(lifetime_usage_result.last_threshold_amount_cents).to be_nil + expect(lifetime_usage_result.next_threshold_amount_cents).to eq(100) + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.23) + end + end + end + + context "with a past threshold" do + let(:usage_threshold1) { create(:usage_threshold, plan:, amount_cents: 100) } + let(:usage_threshold2) { create(:usage_threshold, plan:, amount_cents: 200) } + + let(:applied_usage_threshold) { create(:applied_usage_threshold, usage_threshold: usage_threshold1, invoice:) } + + let(:invoice) { create(:invoice, organization:, customer:) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:) } + + let(:current_usage_amount_cents) { 120 } + + before do + usage_threshold1 + usage_threshold2 + + invoice_subscription + applied_usage_threshold + end + + it "computes the amounts" do + expect(lifetime_usage_result.last_threshold_amount_cents).to eq(100) + expect(lifetime_usage_result.next_threshold_amount_cents).to eq(200) + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.2) + end + + context "when lifetime_usage is above last threshold" do + let(:applied_usage_threshold) { create(:applied_usage_threshold, usage_threshold: usage_threshold2, invoice:) } + let(:current_usage_amount_cents) { 223 } + + it "computes the amounts" do + expect(lifetime_usage_result.last_threshold_amount_cents).to eq(200) + expect(lifetime_usage_result.next_threshold_amount_cents).to be_nil + expect(lifetime_usage_result.next_threshold_ratio).to be_nil + end + end + + context "when next threshold is recurring" do + let(:usage_threshold2) { create(:usage_threshold, :recurring, plan:, amount_cents: 200) } + + it "computes the amounts" do + expect(lifetime_usage_result.last_threshold_amount_cents).to eq(100) + expect(lifetime_usage_result.next_threshold_amount_cents).to eq(300) + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.1) + end + + context "when lifetime_usage is above next threshold" do + let(:applied_usage_threshold) { create(:applied_usage_threshold, usage_threshold: usage_threshold2, invoice:) } + let(:current_usage_amount_cents) { 723 } + + it "computes the amounts" do + expect(lifetime_usage_result.last_threshold_amount_cents).to eq(700) + expect(lifetime_usage_result.next_threshold_amount_cents).to eq(900) + expect(lifetime_usage_result.next_threshold_ratio).to eq(0.115) # (723 - 700) / 200 + end + end + end + end +end diff --git a/spec/services/lifetime_usages/flag_refresh_from_invoice_service_spec.rb b/spec/services/lifetime_usages/flag_refresh_from_invoice_service_spec.rb new file mode 100644 index 0000000..2964331 --- /dev/null +++ b/spec/services/lifetime_usages/flag_refresh_from_invoice_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::FlagRefreshFromInvoiceService, :premium do + subject(:flag_service) { described_class.new(invoice:) } + + let(:invoice) { create(:invoice, :subscription, subscriptions:, organization: customer.organization) } + let(:lifetime_usage) { create(:lifetime_usage, subscription: invoice.subscriptions.first) } + + let(:customer) { create(:customer) } + let(:plan) { create(:plan, organization: customer.organization) } + let(:subscriptions) { create_list(:subscription, 1, plan:) } + + let(:usage_threshold) { create(:usage_threshold, plan:) } + + before do + usage_threshold + lifetime_usage + end + + describe ".call" do + it "flags the lifetime usages for refresh" do + expect { flag_service.call } + .to change { lifetime_usage.reload.recalculate_invoiced_usage }.from(false).to(true) + end + + context "when the invoice is not subscription" do + let(:lifetime_usage) { nil } + let(:invoice) { create(:invoice, invoice_type: "one_off") } + + it { expect(flag_service.call).to be_success } + end + + context "when the invoice is not finalized or voided" do + let(:invoice) { create(:invoice, :subscription, :draft) } + + it { expect(flag_service.call).to be_success } + end + + context "when the lifetime usage does not exists" do + let(:lifetime_usage) { nil } + + it "creates a new lifetime usage" do + expect { flag_service.call } + .to change(LifetimeUsage, :count).by(1) + + expect(invoice.subscriptions.first.lifetime_usage.recalculate_invoiced_usage).to be(true) + end + end + + context "when the invoice has no plan usage thresholds" do + let(:usage_threshold) { nil } + + it "does not flags the lifetime usage" do + expect(flag_service.call).to be_success + expect(lifetime_usage.reload.recalculate_invoiced_usage).to be(false) + end + + context "when organization has lifetime_usage enabled" do + before do + customer.organization.update!(premium_integrations: ["lifetime_usage"]) + end + + it "flags the lifetime usage for refresh" do + expect(flag_service.call).to be_success + expect(lifetime_usage.reload.recalculate_invoiced_usage).to be(true) + end + end + end + end +end diff --git a/spec/services/lifetime_usages/flag_refresh_from_plan_update_service_spec.rb b/spec/services/lifetime_usages/flag_refresh_from_plan_update_service_spec.rb new file mode 100644 index 0000000..18576df --- /dev/null +++ b/spec/services/lifetime_usages/flag_refresh_from_plan_update_service_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::FlagRefreshFromPlanUpdateService do + subject { described_class.call(plan:) } + + let(:plan) { create(:plan) } + let(:result) { subject } + + describe "#call" do + context "when plan has no active subscriptions" do + it "returns zero for updated lifetime usages" do + expect(result.updated_lifetime_usages).to eq(0) + end + end + + context "when plan has active subscriptions with lifetime usages" do + let(:active_subscription1) { create(:subscription, :active, plan: plan) } + let(:active_subscription2) { create(:subscription, :active, plan: plan) } + let(:terminated_subscription) { create(:subscription, :terminated, plan: plan) } + + let(:lifetime_usage1) { create(:lifetime_usage, subscription: active_subscription1) } + let(:lifetime_usage2) { create(:lifetime_usage, subscription: active_subscription2) } + let(:lifetime_usage3) { create(:lifetime_usage, subscription: terminated_subscription) } + + before do + lifetime_usage1 + lifetime_usage2 + lifetime_usage3 + end + + it "flags only lifetime usages of active subscriptions for recalculation" do + expect(result.updated_lifetime_usages).to eq(2) + + expect(lifetime_usage1.reload.recalculate_invoiced_usage).to be_truthy + expect(lifetime_usage2.reload.recalculate_invoiced_usage).to be_truthy + expect(lifetime_usage3.reload.recalculate_invoiced_usage).to be_falsey + end + end + + context "when plan has active subscriptions but no lifetime usages" do + before do + create(:subscription, :active, plan: plan) + create(:subscription, :active, plan: plan) + end + + it "returns zero for updated lifetime usages" do + expect(result.updated_lifetime_usages).to eq(0) + end + end + + context "when there are lifetime usages for other plans" do + let(:other_plan) { create(:plan) } + let(:other_subscription) { create(:subscription, :active, plan: other_plan) } + let(:current_subscription) { create(:subscription, :active, plan: plan) } + + let(:other_lifetime_usage) { create(:lifetime_usage, subscription: other_subscription) } + let(:current_lifetime_usage) { create(:lifetime_usage, subscription: current_subscription) } + + before do + other_lifetime_usage + current_lifetime_usage + end + + it "only flags lifetime usages for the given plan" do + expect(result.updated_lifetime_usages).to eq(1) + + expect(current_lifetime_usage.reload.recalculate_invoiced_usage).to be_truthy + expect(other_lifetime_usage.reload.recalculate_invoiced_usage).to be_falsey + end + end + end +end diff --git a/spec/services/lifetime_usages/update_service_spec.rb b/spec/services/lifetime_usages/update_service_spec.rb new file mode 100644 index 0000000..f7b0fd0 --- /dev/null +++ b/spec/services/lifetime_usages/update_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::UpdateService do + subject(:update_service) { described_class.new(lifetime_usage:, params:) } + + let(:lifetime_usage) { create(:lifetime_usage) } + let(:params) do + { + external_historical_usage_amount_cents: + } + end + let(:external_historical_usage_amount_cents) { 20 } + + describe "#call" do + it "updates the historical usage" do + result = update_service.call + expect(result).to be_success + + expect(result.lifetime_usage.historical_usage_amount_cents).to eq(20) + end + + context "without lifetime_usage" do + let(:lifetime_usage) { nil } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("lifetime_usage_not_found") + end + end + + context "with a negative historical usage amount" do + let(:external_historical_usage_amount_cents) { -20 } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages[:historical_usage_amount_cents]).to eq(["value_is_out_of_range"]) + end + end + end +end diff --git a/spec/services/lifetime_usages/usage_thresholds/check_service_spec.rb b/spec/services/lifetime_usages/usage_thresholds/check_service_spec.rb new file mode 100644 index 0000000..e424014 --- /dev/null +++ b/spec/services/lifetime_usages/usage_thresholds/check_service_spec.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::UsageThresholds::CheckService do + subject(:service) { described_class.new(lifetime_usage:, progressive_billed_amount:) } + + let(:lifetime_usage) { create(:lifetime_usage, subscription:, historical_usage_amount_cents:, recalculate_current_usage:, recalculate_invoiced_usage:) } + let(:progressive_billed_amount) { 0 } + let(:recalculate_current_usage) { true } + let(:recalculate_invoiced_usage) { true } + let(:subscription) { create(:subscription, customer_id: customer.id) } + let(:organization) { subscription.organization } + let(:customer) { create(:customer) } + let(:historical_usage_amount_cents) { 0 } + + def create_thresholds(subscription, amounts:, attach_to:, recurring: nil) + model = if attach_to == :subscription + subscription + elsif attach_to == :plan + subscription.plan + else + raise "invalid attach_to: #{attach_to}" + end + amounts.each do |amount| + model.usage_thresholds.create!(amount_cents: amount, organization:) + end + if recurring + model.usage_thresholds.create!(amount_cents: recurring, recurring: true, organization:) + end + end + + def validate_thresholds(mapping) + mapping.each do |(invoiced, current), expected_threshold_amounts| + lifetime_usage.invoiced_usage_amount_cents = invoiced + lifetime_usage.current_usage_amount_cents = current + result = service.call + + expect(result.passed_thresholds.map(&:amount_cents)).to eq(expected_threshold_amounts), "invoiced:#{invoiced} current:#{current} expected_thresholds: #{expected_threshold_amounts} got: #{result.passed_thresholds.map(&:amount_cents)}" + end + end + + # TODO: usage_thresholds remove loop to always attach to sub + [:subscription, :plan].each do |attach_to| + context "without progressive_billed_amount" do + context "without recurring thresholds" do + context "with no fixed thresholds" do + before do + create_thresholds(subscription, amounts: [], attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [], + [9, 2] => [], + [11, 1] => [], + [11, 10] => [] + }) + end + end + + context "with 1 fixed threshold" do + before do + create_thresholds(subscription, amounts: [10], attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [10], + [9, 2] => [10], + [11, 1] => [], + [11, 10] => [] + }) + end + end + + context "with multiple fixed thresholds" do + before do + create_thresholds(subscription, amounts: [10, 20, 31, 40], attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [10], + [0, 31] => [10, 20, 31], + [9, 2] => [10], + [9, 20] => [10, 20], + [9, 31] => [10, 20, 31, 40], + [11, 1] => [], + [11, 10] => [20], + [21, 20] => [31, 40], + [40, 2] => [], + [50, 0] => [] + }) + end + end + end + + context "with recurring thresholds" do + context "with no fixed thresholds" do + before do + create_thresholds(subscription, amounts: [], recurring: 10, attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [10], + [9, 2] => [10], + [11, 1] => [], + [11, 8] => [], + [11, 9] => [10], + [11, 10] => [10], + [11, 20] => [10], + [202, 7] => [], + [202, 8] => [10] + }) + end + end + + context "with 1 fixed threshold" do + before do + create_thresholds(subscription, amounts: [10], recurring: 5, attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [10], + [0, 15] => [10, 5], + [0, 20] => [10, 5], + [9, 2] => [10], + [9, 6] => [10, 5], + [9, 20] => [10, 5], + [11, 3] => [], + [11, 4] => [5], + [11, 20] => [5] + }) + end + end + + context "with multiple fixed thresholds" do + before do + create_thresholds(subscription, amounts: [10, 20, 31, 40], recurring: 5, attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [10], + [0, 31] => [10, 20, 31], + [0, 44] => [10, 20, 31, 40], + [0, 45] => [10, 20, 31, 40, 5], + [9, 2] => [10], + [9, 20] => [10, 20], + [9, 31] => [10, 20, 31, 40], + [9, 37] => [10, 20, 31, 40, 5], + [11, 1] => [], + [11, 10] => [20], + [21, 20] => [31, 40], + [21, 24] => [31, 40, 5], + [40, 2] => [], + [40, 5] => [5], + [41, 4] => [5], + [49, 1] => [5], + [50, 0] => [], + [50, 5] => [5] + }) + end + end + end + end + + context "with progressive_billed_amount set to 10" do + let(:progressive_billed_amount) { 10 } + + context "without recurring thresholds" do + context "with no fixed thresholds" do + before do + create_thresholds(subscription, amounts: [], attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [], + [9, 2] => [], + [11, 1] => [], + [11, 10] => [] + }) + end + end + + context "with 1 fixed threshold" do + before do + create_thresholds(subscription, amounts: [10], attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [9, 2] => [], + [9, 20] => [], + [11, 10] => [], + [11, 20] => [] + }) + end + end + + context "with multiple fixed thresholds" do + before do + create_thresholds(subscription, amounts: [10, 20, 31, 40], attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 10] => [], + [0, 31] => [20, 31], + [9, 10] => [], + [9, 12] => [20], + [9, 20] => [20], + [9, 31] => [20, 31, 40], + [11, 11] => [], + [11, 20] => [31], + [21, 20] => [40], + [30, 12] => [], + [50, 10] => [] + }) + end + end + end + + context "with recurring thresholds" do + context "with no fixed thresholds" do + before do + create_thresholds(subscription, amounts: [], recurring: 10, attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 10] => [], + [11, 10] => [], + [11, 19] => [10], + [202, 17] => [], + [202, 18] => [10], + [202, 28] => [10] + }) + end + end + + context "with 1 fixed threshold" do + before do + create_thresholds(subscription, amounts: [10], recurring: 5, attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [], + [0, 15] => [5], + [0, 20] => [5], + [9, 2] => [], + [9, 6] => [], + [9, 16] => [5], + [11, 3] => [], + [11, 4] => [], + [11, 14] => [5], + [11, 24] => [5] + }) + end + end + + context "with multiple fixed thresholds" do + before do + create_thresholds(subscription, amounts: [10, 20, 31, 40], recurring: 5, attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 10] => [], + [0, 31] => [20, 31], + [0, 44] => [20, 31, 40], + [0, 45] => [20, 31, 40, 5], + [9, 2] => [], + [9, 20] => [20], + [9, 31] => [20, 31, 40], + [9, 37] => [20, 31, 40, 5], + [11, 1] => [], + [11, 10] => [], + [20, 20] => [31, 40], + [21, 20] => [40], + [21, 24] => [40, 5], + [40, 14] => [], + [40, 15] => [5], + [41, 14] => [5], + [49, 1] => [], + [49, 11] => [5], + [50, 5] => [], + [50, 15] => [5] + }) + end + end + end + end + + context "with historical_usage_amount_cents" do + let(:historical_usage_amount_cents) { 11 } + + context "with multiple fixed thresholds" do + before do + create_thresholds(subscription, amounts: [10, 20, 31, 40], attach_to:) + end + + it "calculates the passed thresholds correctly" do + validate_thresholds({ + [0, 7] => [], + [0, 9] => [20], + [0, 10] => [20], + [9, 0] => [], + [0, 31] => [20, 31, 40], + [8, 2] => [20], + [8, 20] => [20, 31], + [8, 31] => [20, 31, 40], + [11, 1] => [], + [11, 10] => [31], + [21, 20] => [40], + [40, 2] => [], + [50, 0] => [] + }) + end + end + end + end +end diff --git a/spec/services/lifetime_usages/usage_thresholds_completion_service_spec.rb b/spec/services/lifetime_usages/usage_thresholds_completion_service_spec.rb new file mode 100644 index 0000000..b47c918 --- /dev/null +++ b/spec/services/lifetime_usages/usage_thresholds_completion_service_spec.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LifetimeUsages::UsageThresholdsCompletionService do + subject(:result) { described_class.call(lifetime_usage:) } + + let(:lifetime_usage) { create(:lifetime_usage, subscription:, organization:, current_usage_amount_cents:) } + let(:current_usage_amount_cents) { 0 } + + let(:plan) { create(:plan) } + let(:organization) { plan.organization } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, customer:) } + + def create_threshold(attached_to:, **factory_args) + if attached_to == :subscription + create(:usage_threshold, :for_subscription, subscription:, **factory_args) + elsif attached_to == :plan + create(:usage_threshold, plan:, **factory_args) + end + end + + it "computes the usage thresholds" do + expect(result.usage_thresholds).to be_empty + end + + # TODO: usage_thresholds remove loop to always attach to sub + [:subscription, :plan].each do |attached_to| + context "with a usage threshold" do + let(:usage_threshold) { create_threshold(attached_to:, amount_cents: 100) } + + before do + usage_threshold + end + + it "computes the usage thresholds" do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(1) + threshold = thresholds.first + + expect(threshold[:usage_threshold]).to eq(usage_threshold) + expect(threshold[:amount_cents]).to eq(usage_threshold.amount_cents) + expect(threshold[:completion_ratio]).to be_zero + expect(threshold[:reached_at]).to be_nil + end + + context "with a lifetime_usage" do + let(:current_usage_amount_cents) { 23 } + + it "computes the usage thresholds" do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(1) + threshold = thresholds.first + + expect(threshold[:usage_threshold]).to eq(usage_threshold) + expect(threshold[:amount_cents]).to eq(usage_threshold.amount_cents) + expect(threshold[:completion_ratio]).to eq(0.23) + expect(threshold[:reached_at]).to be_nil + end + end + end + + context "with a past threshold" do + let(:usage_threshold1) { create_threshold(attached_to:, amount_cents: 100) } + let(:usage_threshold2) { create_threshold(attached_to:, amount_cents: 200) } + + let(:applied_usage_threshold) { create(:applied_usage_threshold, usage_threshold: usage_threshold1, invoice:) } + + let(:invoice) { create(:invoice, organization:, customer:) } + let(:invoice_subscription) { create(:invoice_subscription, invoice:, subscription:) } + + let(:current_usage_amount_cents) { 120 } + + before do + usage_threshold1 + usage_threshold2 + + invoice_subscription + applied_usage_threshold + end + + it "computes the usage thresholds" do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(2) + threshold1 = thresholds.first + threshold2 = thresholds.last + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + expect(threshold2[:usage_threshold]).to eq(usage_threshold2) + expect(threshold2[:amount_cents]).to eq(usage_threshold2.amount_cents) + expect(threshold2[:completion_ratio]).to eq(0.2) + expect(threshold2[:reached_at]).to be_nil + end + + context "when lifetime_usage is above last threshold" do + let(:applied_usage_threshold2) { create(:applied_usage_threshold, usage_threshold: usage_threshold2, invoice:) } + let(:current_usage_amount_cents) { 223 } + + before do + applied_usage_threshold2 + end + + it "computes the usage thresholds" do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(2) + threshold1 = thresholds.first + threshold2 = thresholds.last + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + expect(threshold2[:usage_threshold]).to eq(usage_threshold2) + expect(threshold2[:amount_cents]).to eq(usage_threshold2.amount_cents) + expect(threshold2[:completion_ratio]).to eq(1) + expect(threshold2[:reached_at]).to eq(applied_usage_threshold2.created_at) + end + end + + context "when next threshold is recurring" do + let(:usage_threshold2) { create_threshold(attached_to:, recurring: true, amount_cents: 200) } + + it "computes the usage thresholds" do + thresholds = result.usage_thresholds.sort_by { it[:amount_cents] } + expect(thresholds.size).to eq(2) + threshold1 = thresholds.first + threshold2 = thresholds.last + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + expect(threshold2[:usage_threshold]).to eq(usage_threshold2) + expect(threshold2[:amount_cents]).to eq(usage_threshold2.amount_cents + usage_threshold1.amount_cents) + expect(threshold2[:completion_ratio]).to eq(0.1) # 20/200 + expect(threshold2[:reached_at]).to be_nil + end + + context "when lifetime_usage is above next threshold" do + let(:applied_usage_threshold2) { create(:applied_usage_threshold, lifetime_usage_amount_cents: 700, usage_threshold: usage_threshold2, invoice:) } + let(:current_usage_amount_cents) { 723 } + + before do + applied_usage_threshold2 + end + + it "computes the usage thresholds" do + thresholds = result.usage_thresholds + expect(thresholds.size).to eq(5) + threshold1 = thresholds.shift + + expect(threshold1[:usage_threshold]).to eq(usage_threshold1) + expect(threshold1[:amount_cents]).to eq(usage_threshold1.amount_cents) + expect(threshold1[:completion_ratio]).to eq(1.0) + expect(threshold1[:reached_at]).to eq(applied_usage_threshold.created_at) + + last_threshold = thresholds.pop + + thresholds.each.with_index do |threshold, index| + expect(threshold[:usage_threshold]).to eq(usage_threshold2) + expect(threshold[:amount_cents]).to eq(100 + (index + 1) * 200) + expect(threshold[:completion_ratio]).to eq(1.0) + expect(threshold[:reached_at]).to eq(applied_usage_threshold2.created_at) + end + + expect(last_threshold[:usage_threshold]).to eq(usage_threshold2) + expect(last_threshold[:amount_cents]).to eq(900) + expect(last_threshold[:completion_ratio]).to eq(0.115) + expect(last_threshold[:reached_at]).to be_nil + end + end + end + end + end +end diff --git a/spec/services/memberships/create_service_spec.rb b/spec/services/memberships/create_service_spec.rb new file mode 100644 index 0000000..b81320b --- /dev/null +++ b/spec/services/memberships/create_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Memberships::CreateService do + subject(:create_service) { described_class.new(user:, organization:) } + + let(:user) { create(:user) } + let(:organization) { create(:organization) } + + describe "#call" do + it "creates a membership" do + result = create_service.call + + expect(result).to be_success + expect(result.membership.user_id).to eq(user.id) + expect(result.membership.organization_id).to eq(organization.id) + end + + context "when user does not exists" do + let(:user) { nil } + + it "returns a result with error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("user_not_found") + end + end + + context "when organization does not exists" do + let(:organization) { nil } + + it "returns a result with error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("organization_not_found") + end + end + + context "when user already has a membership in the organization" do + before do + create(:membership, user:, organization:) + end + + it "returns a result with error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error.messages[:user_id]).to include("value_already_exist") + end + end + end +end diff --git a/spec/services/memberships/revoke_service_spec.rb b/spec/services/memberships/revoke_service_spec.rb new file mode 100644 index 0000000..77de7ea --- /dev/null +++ b/spec/services/memberships/revoke_service_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Memberships::RevokeService do + subject(:revoke_service) { described_class.new(user:, membership:) } + + include_context "with mocked security logger" + + let(:organization) { create(:organization) } + let(:admin_role) { create(:role, :admin) } + let(:finance_role) { create(:role, :finance) } + + let(:user) { create(:user) } + let(:membership) { create(:membership, organization:) } + let(:other_membership) { create(:membership, user:, organization:) } + + describe "#call" do + context "when revoking my own membership" do + let(:membership) { create(:membership, user:, organization:) } + let(:other_membership) { create(:membership, organization:) } + + before { create(:membership_role, membership: other_membership, role: admin_role) } + + it "returns an error" do + result = revoke_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("cannot_revoke_own_membership") + end + + it_behaves_like "does not produce a security log" do + before { revoke_service.call } + end + end + + context "when membership is not found" do + let(:membership) { nil } + + it "returns an error" do + result = revoke_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("membership_not_found") + end + + it_behaves_like "does not produce a security log" do + before { revoke_service.call } + end + end + + context "when revoking another membership" do + before { create(:membership_role, membership: other_membership, role: admin_role) } + + it "revokes the membership" do + freeze_time do + result = revoke_service.call + + expect(result).to be_success + expect(result.membership.id).to eq(membership.id) + expect(result.membership.status).to eq("revoked") + expect(result.membership.revoked_at).to eq(Time.current) + end + end + + it_behaves_like "produces a security log", "user.deleted" do + before { revoke_service.call } + end + end + + context "when removing the last admin" do + before do + create(:membership_role, membership:, role: admin_role) + create(:membership_role, membership: other_membership, role: finance_role) + end + + it "returns an error" do + result = revoke_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("last_admin") + end + + it_behaves_like "does not produce a security log" do + before { revoke_service.call } + end + end + + context "when removing the last active admin (other admins have revoked membership)" do + let(:revoked_membership) { create(:membership, :revoked, organization:) } + + before do + create(:membership_role, membership:, role: admin_role) + create(:membership_role, membership: other_membership, role: finance_role) + create(:membership_role, membership: revoked_membership, role: admin_role) + end + + it "returns an error" do + result = revoke_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("last_admin") + end + + it_behaves_like "does not produce a security log" do + before { revoke_service.call } + end + end + end +end diff --git a/spec/services/memberships/update_service_spec.rb b/spec/services/memberships/update_service_spec.rb new file mode 100644 index 0000000..bcee28e --- /dev/null +++ b/spec/services/memberships/update_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Memberships::UpdateService do + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:acting_user) { create(:membership, organization:).user } + let(:admin_role) { create(:role, :admin) } + let!(:manager_role) { create(:role, :manager) } + let(:params) { {roles: %w[manager]} } + + describe "#call" do + context "when another admin exists" do + before do + create(:membership_role, membership:, role: admin_role) + other_membership = create(:membership, organization:) + create(:membership_role, membership: other_membership, role: admin_role) + end + + it "updates the role" do + result = described_class.call(user: acting_user, membership:, params:) + + expect(result).to be_success + expect(result.membership.roles).to eq([manager_role]) + end + + it_behaves_like "produces a security log", "user.role_edited" do + before { described_class.call(user: acting_user, membership:, params:) } + end + end + + context "when admin grants admin role to another member" do + let(:acting_membership) { create(:membership, organization:) } + let(:acting_user) { acting_membership.user } + let(:params) { {roles: %w[admin]} } + + before do + create(:membership_role, membership: acting_membership, role: admin_role) + create(:membership_role, membership:, role: manager_role) + end + + it "updates the role" do + result = described_class.call(user: acting_user, membership:, params:) + + expect(result).to be_success + expect(result.membership.roles).to eq([admin_role]) + end + + it_behaves_like "produces a security log", "user.role_edited" do + before { described_class.call(user: acting_user, membership:, params:) } + end + end + + context "when non-admin grants admin role to another member" do + let(:params) { {roles: %w[admin]} } + + before do + admin_role + create(:membership_role, membership:, role: manager_role) + end + + it "returns an error" do + result = described_class.call(user: acting_user, membership:, params:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("cannot_grant_admin") + end + + it_behaves_like "does not produce a security log" do + before { described_class.call(user: acting_user, membership:, params:) } + end + end + + context "when membership is the last admin" do + before { create(:membership_role, membership:, role: admin_role) } + + it "returns an error" do + result = described_class.call(user: acting_user, membership:, params:) + + expect(result).not_to be_success + expect(result.error.code).to eq("last_admin") + end + + it_behaves_like "does not produce a security log" do + before { described_class.call(user: acting_user, membership:, params:) } + end + end + + context "when membership is not found" do + it "returns an error" do + result = described_class.call(user: acting_user, membership: nil, params:) + + expect(result).not_to be_success + expect(result.error.error_code).to eq("membership_not_found") + end + + it_behaves_like "does not produce a security log" do + before { described_class.call(user: acting_user, membership: nil, params:) } + end + end + + context "when role is invalid" do + before { create(:membership_role, membership:, role: admin_role) } + + let(:params) { {roles: %w[invalid]} } + + it "returns an error" do + result = described_class.call(user: acting_user, membership:, params:) + + expect(result).not_to be_success + expect(result.error.error_code).to eq("role_not_found") + end + + it_behaves_like "does not produce a security log" do + before { described_class.call(user: acting_user, membership:, params:) } + end + end + end +end diff --git a/spec/services/metadata/delete_item_key_service_spec.rb b/spec/services/metadata/delete_item_key_service_spec.rb new file mode 100644 index 0000000..bd21024 --- /dev/null +++ b/spec/services/metadata/delete_item_key_service_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Metadata::DeleteItemKeyService do + subject(:service) { described_class.new(item:, key:) } + + let(:organization) { create(:organization) } + let(:owner) { create(:credit_note, organization:) } + let(:item) { create(:item_metadata, owner:, organization:, value:) } + let(:value) { {"foo" => "bar", "baz" => "qux"} } + let(:key) { "foo" } + + describe "#call" do + context "when key exists" do + it "removes the key from metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be(true) + expect(item.reload.value).to eq({"baz" => "qux"}) + end + end + + context "when key does not exist" do + let(:key) { "nonexistent" } + + it "does not modify metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be(false) + expect(item.reload.value).to eq({"foo" => "bar", "baz" => "qux"}) + end + end + + context "when key is a symbol" do + let(:key) { :foo } + + it "converts key to string and removes it" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be(true) + expect(item.reload.value).to eq({"baz" => "qux"}) + end + end + + context "when removing the last key" do + let(:value) { {"foo" => "bar"} } + + it "leaves empty hash" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be(true) + expect(item.reload.value).to eq({}) + end + end + + context "when value contains nil" do + let(:value) { {"foo" => nil, "baz" => "qux"} } + + it "removes key with nil value" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be(true) + expect(item.reload.value).to eq({"baz" => "qux"}) + end + end + + context "when value contains empty string" do + let(:value) { {"foo" => "", "baz" => "qux"} } + + it "removes key with empty string" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be(true) + expect(item.reload.value).to eq({"baz" => "qux"}) + end + end + end +end diff --git a/spec/services/metadata/update_item_service_spec.rb b/spec/services/metadata/update_item_service_spec.rb new file mode 100644 index 0000000..08b5c99 --- /dev/null +++ b/spec/services/metadata/update_item_service_spec.rb @@ -0,0 +1,286 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Metadata::UpdateItemService do + subject(:service) { described_class.new(owner:, value:, partial:) } + + let(:organization) { create(:organization) } + let(:owner) { create(:credit_note, organization:) } + let(:value) { nil } + let(:partial) { false } + + describe "#call" do + context "when owner does not support metadata" do + let(:owner) { create(:organization) } + + it "raises an exception" do + expect { service.call }.to raise_exception(NoMethodError) + end + end + + context "with value: nil, partial: true, no existing metadata" do + let(:value) { nil } + let(:partial) { true } + + it "does not create metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be false + expect(owner.reload.metadata).to be_nil + end + end + + context "with value: nil, partial: true, existing metadata" do + let(:value) { nil } + let(:partial) { true } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "bar"}) } + + it "preserves existing metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be false + expect(owner.reload.metadata.value).to eq({"foo" => "bar"}) + end + end + + context "with value: nil, partial: false, no existing metadata" do + let(:value) { nil } + let(:partial) { false } + + it "does not create metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be false + expect(owner.reload.metadata).to be_nil + end + end + + context "with value: nil, partial: false, existing metadata" do + let(:value) { nil } + let(:partial) { false } + let!(:existing_metadata) { create(:item_metadata, owner:, organization:, value: {"foo" => "bar"}) } + + it "deletes existing metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be true + expect(owner.reload.metadata).to be_nil + expect(Metadata::ItemMetadata.find_by(id: existing_metadata.id)).to be_nil + end + end + + context "with value: {}, partial: true, no existing metadata" do + let(:value) { {} } + let(:partial) { true } + + it "does not create metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be false + expect(owner.reload.metadata).to be_nil + end + end + + context "with value: {}, partial: true, existing metadata" do + let(:value) { {} } + let(:partial) { true } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "bar"}) } + + it "preserves existing metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be false + expect(owner.reload.metadata.value).to eq({"foo" => "bar"}) + end + end + + context "with value: {}, partial: false, no existing metadata" do + let(:value) { {} } + let(:partial) { false } + + it "creates metadata with empty hash" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be true + expect(owner.reload.metadata.value).to eq({}) + end + end + + context "with value: {}, partial: false, existing metadata" do + let(:value) { {} } + let(:partial) { false } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "bar"}) } + + it "replaces with empty hash" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be true + expect(owner.reload.metadata.value).to eq({}) + end + end + + context "with value: {foo: bar, baz: qux}, partial: true, no existing metadata" do + let(:value) { {"foo" => "bar", "baz" => "qux"} } + let(:partial) { true } + + it "creates metadata" do + result = service.call + expect(result).to be_success + expect(result.metadata_changed).to be true + + metadata = owner.reload.metadata + expect(metadata.value).to eq({"foo" => "bar", "baz" => "qux"}) + expect(metadata.organization_id).to eq(organization.id) + expect(metadata.owner).to eq(owner) + end + end + + context "with value: {foo: bar, baz: qux}, partial: true, existing metadata" do + let(:value) { {"foo" => "bar", "baz" => "qux"} } + let(:partial) { true } + + before { create(:item_metadata, owner:, organization:, value: {"old" => "value"}) } + + it "merges metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be true + expect(owner.reload.metadata.value).to eq({"old" => "value", "foo" => "bar", "baz" => "qux"}) + end + end + + context "with value: {foo: bar}, partial: false, no existing metadata" do + let(:value) { {"foo" => "bar"} } + let(:partial) { false } + + it "creates metadata" do + expect { service.call }.to change(Metadata::ItemMetadata, :count).by(1) + expect(owner.reload.metadata.value).to eq({"foo" => "bar"}) + end + end + + context "with value: {foo: bar}, partial: false, existing metadata" do + let(:value) { {"foo" => "bar"} } + let(:partial) { false } + + before { create(:item_metadata, owner:, organization:, value: {"old" => "value"}) } + + it "replaces metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be true + expect(owner.reload.metadata.value).to eq({"foo" => "bar"}) + end + end + + context "with metadata overwriting existing key" do + let(:value) { {"foo" => "new"} } + let(:partial) { true } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "old"}) } + + it "overwrites the key" do + result = service.call + + expect(result).to be_success + expect(owner.reload.metadata.value).to eq({"foo" => "new"}) + end + end + + context "with value: {foo: nil}, no existing metadata" do + let(:value) { {"foo" => nil} } + let(:partial) { true } + + it "creates metadata with nil value" do + result = service.call + + expect(result).to be_success + expect(owner.reload.metadata.value).to eq({"foo" => nil}) + end + end + + context "with value: {foo: nil}, existing metadata" do + let(:value) { {"foo" => nil} } + let(:partial) { true } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "bar"}) } + + it "sets key to nil" do + result = service.call + + expect(result).to be_success + expect(owner.reload.metadata.value).to eq({"foo" => nil}) + end + end + + context "with value: {foo: ''}, no existing metadata" do + let(:value) { {"foo" => ""} } + let(:partial) { true } + + it "creates metadata with empty string" do + result = service.call + + expect(result).to be_success + expect(owner.reload.metadata.value).to eq({"foo" => ""}) + end + end + + context "with value: {foo: ''}, existing metadata" do + let(:value) { {"foo" => ""} } + let(:partial) { true } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "old", "bar" => "keep"}) } + + it "merges with empty string" do + result = service.call + + expect(result).to be_success + expect(owner.reload.metadata.value).to eq({"foo" => "", "bar" => "keep"}) + end + end + + context "when replacing with same value" do + let(:value) { {"foo" => "bar"} } + let(:partial) { false } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "bar"}) } + + it "does not change metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be false + expect(owner.reload.metadata.value).to eq({"foo" => "bar"}) + end + end + + context "when merging with same value" do + let(:value) { {"foo" => "bar"} } + let(:partial) { true } + + before { create(:item_metadata, owner:, organization:, value: {"foo" => "bar"}) } + + it "does not change metadata" do + result = service.call + + expect(result).to be_success + expect(result.metadata_changed).to be false + expect(owner.reload.metadata.value).to eq({"foo" => "bar"}) + end + end + end +end diff --git a/spec/services/middlewares/activity_log_middleware_spec.rb b/spec/services/middlewares/activity_log_middleware_spec.rb new file mode 100644 index 0000000..74176da --- /dev/null +++ b/spec/services/middlewares/activity_log_middleware_spec.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +RSpec.describe Middlewares::ActivityLogMiddleware do + let(:service_class) do + action = activity_loggable_action + after_commit = activity_loggable_after_commit + Class.new(BaseService) do + const_set(:Result, BaseResult[:subscription]) + + activity_loggable(action: action, record: -> { subscription }, after_commit:) + + def initialize(subscription:) + @subscription = subscription + super() + end + + def call + subscription.update!(name: "Updated Subscription") + + result.subscription = subscription + result + end + + private + + attr_reader :subscription + end + end + + let(:subscription) { create(:subscription, name: "My Subscription") } + let(:activity_loggable_after_commit) { false } + + def test_service_with_activity_loggable(after_commit:, action_match_updated: false) + expect(service_class).to use_middleware(described_class) + + allow(Utils::ActivityLog).to receive(:produce).and_wrap_original do |m, *args, **kwargs, &block| + if action_match_updated + # For "updated" actions, `Utils::ActivityLog#produce` will execute `BaseService#call` method so subscription is not yet updated here + expect(subscription.name).to eq("My Subscription") + else + # For other actions, `Utils::ActivityLog#produce` is executed after `BaseService#call` method so subscription is already updated here + expect(subscription.name).to eq("Updated Subscription") + end + + result = m.call(*args, **kwargs, &block) + + # Test that `Utils::ActivityLog#produce` returns the result of the service call + expect(result).to be_success + expect(result.subscription).to eq(subscription) + expect(result.subscription.name).to eq("Updated Subscription") + + result + end + + result = service_class.call(subscription:) + + expect(Utils::ActivityLog).to have_received(:produce).with(subscription, activity_loggable_action, after_commit:) + + expect(result).to be_success + expect(result.subscription).to eq(subscription) + expect(result.subscription.name).to eq("Updated Subscription") + end + + context "when action matches /updated/" do + let(:activity_loggable_action) { "subscription.updated" } + + context "when after_commit is true" do + let(:activity_loggable_after_commit) { true } + + it "produces the activity log after commit" do + test_service_with_activity_loggable(after_commit: true, action_match_updated: true) + end + end + + context "when after_commit is false" do + let(:activity_loggable_after_commit) { false } + + it "produces the activity log before commit" do + test_service_with_activity_loggable(after_commit: false, action_match_updated: true) + end + end + end + + context "when action does not match /updated/" do + let(:activity_loggable_action) { "subscription.created" } + + context "when after_commit is true" do + let(:activity_loggable_after_commit) { true } + + it "produces the activity log" do + test_service_with_activity_loggable(after_commit: true, action_match_updated: false) + end + end + + context "when after_commit is false" do + let(:activity_loggable_after_commit) { false } + + it "produces the activity log" do + test_service_with_activity_loggable(after_commit: false, action_match_updated: false) + end + end + end +end diff --git a/spec/services/middlewares/datadog_middleware_spec.rb b/spec/services/middlewares/datadog_middleware_spec.rb new file mode 100644 index 0000000..f3ba1af --- /dev/null +++ b/spec/services/middlewares/datadog_middleware_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rails_helper" +require "datadog/auto_instrument" + +RSpec.describe Middlewares::DatadogMiddleware do + let(:service_class) do + Class.new(BaseService) do + use Middlewares::DatadogMiddleware + + def self.name + "CustomeService" + end + + def call + result + end + end + end + + let(:span) { instance_double(Datadog::Tracing::SpanOperation) } + + before do + allow(Datadog::Tracing).to receive(:trace) + .with("service.call", service: "lago-api", resource: "CustomeService") + .and_return(span) + end + + describe "Tracking" do + it "tracks the service call" do + allow(span).to receive(:set_tag).with("result.status", "success") + allow(span).to receive(:finish) + + service_class.call + + expect(Datadog::Tracing).to have_received(:trace).with("service.call", service: "lago-api", resource: "CustomeService") + expect(span).to have_received(:set_tag).with("result.status", "success") + expect(span).to have_received(:finish) + end + + context "when result is a failure" do + let(:service_class) do + Class.new(BaseService) do + use Middlewares::DatadogMiddleware + + def self.name + "CustomeService" + end + + def call + result.not_found_failure!(resource: "fake") + end + end + end + + it "tracks the service call" do + allow(span).to receive(:set_tag).with("result.status", "failure") + allow(span).to receive(:record_exception).with(BaseService::NotFoundFailure) + allow(span).to receive(:finish) + + service_class.call + + expect(Datadog::Tracing).to have_received(:trace).with("service.call", service: "lago-api", resource: "CustomeService") + expect(span).to have_received(:set_tag).with("result.status", "failure") + expect(span).to have_received(:record_exception).with(BaseService::NotFoundFailure) + expect(span).to have_received(:finish) + end + end + + context "when service raises an error" do + let(:service_class) do + Class.new(BaseService) do + use Middlewares::DatadogMiddleware + + def self.name + "CustomeService" + end + + def call + raise StandardError, "Service error" + end + end + end + + it "tracks the service call" do + allow(span).to receive(:set_tag).with("result.status", "failure") + allow(span).to receive(:record_exception).with(StandardError) + allow(span).to receive(:finish) + + expect { service_class.call }.to raise_error(StandardError) + + expect(Datadog::Tracing).to have_received(:trace).with("service.call", service: "lago-api", resource: "CustomeService") + expect(span).to have_received(:set_tag).with("result.status", "failure") + expect(span).to have_received(:record_exception).with(StandardError) + expect(span).to have_received(:finish) + end + end + end +end diff --git a/spec/services/organizations/create_service_spec.rb b/spec/services/organizations/create_service_spec.rb new file mode 100644 index 0000000..eff1ac0 --- /dev/null +++ b/spec/services/organizations/create_service_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Organizations::CreateService do + describe "#call" do + subject(:service_result) { described_class.call(params) } + + context "with valid params" do + let(:params) do + { + name: Faker::Company.name, + document_numbering: "per_customer", + premium_integrations: ["okta"] + } + end + + it "creates an organization with provided params" do + expect { service_result }.to change(Organization, :count).by(1) + + expect(service_result.organization) + .to be_persisted + .and have_attributes( + name: params[:name], + document_numbering: params[:document_numbering], + premium_integrations: params[:premium_integrations] + ) + end + + it "creates an API key for created organization" do + expect { service_result }.to change(ApiKey, :count).by(1) + + expect(service_result.organization.api_keys).to all( + be_persisted.and(have_attributes(organization: service_result.organization)) + ) + end + + context "when document_numbering is per_customer" do + let(:params) do + { + name: Faker::Company.name, + document_numbering: "per_customer" + } + end + + it "creates a billing entity for created organization" do + expect { service_result }.to change(BillingEntity, :count).by(1) + + billing_entity = service_result.organization.billing_entities.first + expect(billing_entity).to have_attributes( + id: service_result.organization.id, + organization: service_result.organization, + name: service_result.organization.name, + code: service_result.organization.name.parameterize(separator: "_"), + document_number_prefix: service_result.organization.document_number_prefix, + eu_tax_management: false, + document_numbering: "per_customer" + ) + end + end + + context "when document_numbering is per_organization" do + let(:params) do + { + name: Faker::Company.name, + document_numbering: "per_organization", + code: "this_code_will_be_used_for_billing_entity" + } + end + + it "creates billing_entity with number per_billing_entity" do + expect { service_result }.to change(BillingEntity, :count).by(1) + + billing_entity = service_result.organization.billing_entities.first + expect(billing_entity).to have_attributes( + id: service_result.organization.id, + organization: service_result.organization, + name: service_result.organization.name, + code: "this_code_will_be_used_for_billing_entity", + document_numbering: "per_billing_entity" + ) + end + end + end + + context "with invalid params" do + let(:params) { {} } + + it "does not create an organization" do + expect { service_result }.not_to change(Organization, :count) + end + + it "does not create an API key" do + expect { service_result }.not_to change(ApiKey, :count) + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::ValidationFailure) + expect(service_result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/organizations/update_service_spec.rb b/spec/services/organizations/update_service_spec.rb new file mode 100644 index 0000000..386932d --- /dev/null +++ b/spec/services/organizations/update_service_spec.rb @@ -0,0 +1,404 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Organizations::UpdateService do + subject(:update_service) { described_class.new(organization:, params:) } + + let(:organization) { create(:organization) } + + let(:timezone) { nil } + let(:email_settings) { [] } + let(:invoice_grace_period) { 0 } + let(:logo) { nil } + let(:country) { "fr" } + + let(:params) do + { + legal_name: "Foobar", + legal_number: "1234", + tax_identification_number: "2246", + email: "foo@bar.com", + address_line1: "Line 1", + address_line2: "Line 2", + state: "Foobar", + zipcode: "FOO1234", + city: "Foobar", + default_currency: "EUR", + country:, + timezone:, + logo:, + email_settings:, + authentication_methods: ["email_password"], + billing_configuration: { + invoice_footer: "invoice footer", + document_locale: "fr", + invoice_grace_period: + } + } + end + + describe "#call" do + it "updates the organization" do + result = update_service.call + + expect(result.organization.legal_name).to eq("Foobar") + expect(result.organization.legal_number).to eq("1234") + expect(result.organization.tax_identification_number).to eq("2246") + expect(result.organization.email).to eq("foo@bar.com") + expect(result.organization.address_line1).to eq("Line 1") + expect(result.organization.address_line2).to eq("Line 2") + expect(result.organization.state).to eq("Foobar") + expect(result.organization.zipcode).to eq("FOO1234") + expect(result.organization.city).to eq("Foobar") + expect(result.organization.country).to eq("FR") + expect(result.organization.default_currency).to eq("EUR") + expect(result.organization.timezone).to eq("UTC") + expect(result.organization.authentication_methods).to eq(["email_password"]) + + expect(result.organization.invoice_footer).to eq("invoice footer") + expect(result.organization.document_locale).to eq("fr") + end + + context "with email containing unicode lookalike characters" do + let(:params) { {email: "hello@something\u2013other.com"} } + + it "sanitizes the email before saving" do + result = update_service.call + expect(result.organization.email).to eq("hello@something-other.com") + end + end + + it "updates default billing_entity" do + result = update_service.call + + default_billing_entity = result.organization.default_billing_entity + expect(default_billing_entity.legal_name).to eq("Foobar") + expect(default_billing_entity.legal_number).to eq("1234") + expect(default_billing_entity.tax_identification_number).to eq("2246") + expect(default_billing_entity.email).to eq("foo@bar.com") + expect(default_billing_entity.address_line1).to eq("Line 1") + expect(default_billing_entity.address_line2).to eq("Line 2") + expect(default_billing_entity.state).to eq("Foobar") + expect(default_billing_entity.zipcode).to eq("FOO1234") + expect(default_billing_entity.city).to eq("Foobar") + expect(default_billing_entity.country).to eq("FR") + expect(default_billing_entity.default_currency).to eq("EUR") + expect(default_billing_entity.timezone).to eq("UTC") + + expect(default_billing_entity.invoice_footer).to eq("invoice footer") + expect(default_billing_entity.document_locale).to eq("fr") + end + + context "when document_number_prefix is sent" do + before { params[:document_number_prefix] = "abc" } + + it "converts document_number_prefix to upcase version" do + result = update_service.call + + expect(result.organization.document_number_prefix).to eq("ABC") + end + end + + context "when finalize_zero_amount_invoice is sent" do + before { params[:finalize_zero_amount_invoice] = "false" } + + it "converts document_number_prefix to upcase version" do + result = update_service.call + + expect(result.organization.finalize_zero_amount_invoice).to eq(false) + end + end + + context "when document_number_prefix is invalid" do + before { params[:document_number_prefix] = "aaaaaaaaaaaaaaa" } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:document_number_prefix]).to eq(["value_is_too_long"]) + end + end + + context "when slug is sent" do + before { params[:slug] = "new-slug" } + + it "updates the organization slug" do + result = update_service.call + + expect(result).to be_success + expect(result.organization.slug).to eq("new-slug") + end + end + + context "when slug has mixed case and whitespace" do + before { params[:slug] = " My-Slug " } + + it "normalizes the slug before saving" do + result = update_service.call + + expect(result).to be_success + expect(result.organization.slug).to eq("my-slug") + end + end + + context "when slug is invalid" do + before { params[:slug] = "INVALID SLUG!" } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:slug]).to be_present + end + end + + context "when slug is already taken" do + before do + create(:organization, slug: "taken-slug") + params[:slug] = "taken-slug" + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:slug]).to be_present + end + end + + context "when slug is a reserved word" do + before { params[:slug] = "customers" } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:slug]).to be_present + end + end + + context "with premium features", :premium do + let(:timezone) { "Europe/Paris" } + let(:email_settings) { ["invoice.finalized"] } + + it "updates the organization" do + result = update_service.call + + expect(result.organization.timezone).to eq("Europe/Paris") + end + + context "when updating invoice grace period" do + let(:customer) { create(:customer, organization:) } + let(:invoice_grace_period) { 2 } + + let(:invoice_to_be_finalized) do + create(:invoice, status: :draft, customer:, issuing_date: DateTime.parse("19 Jun 2022").to_date, organization:) + end + + let(:invoice_to_not_be_finalized) do + create(:invoice, status: :draft, customer:, issuing_date: DateTime.parse("21 Jun 2022").to_date, organization:) + end + + before do + invoice_to_be_finalized + invoice_to_not_be_finalized + end + + it "triggers async updates grace_period of invoices on default billing entity" do + current_date = DateTime.parse("22 Jun 2022") + old_invoice_grace_period = organization.invoice_grace_period + + travel_to(current_date) do + result = update_service.call + + expect(result.organization.invoice_grace_period).to eq(2) + expect(result.organization.default_billing_entity.invoice_grace_period).to eq(2) + expect(Invoices::UpdateAllInvoiceIssuingDateFromBillingEntityJob) + .to have_been_enqueued + .with( + organization.default_billing_entity, + subscription_invoice_issuing_date_anchor: "next_period_start", + subscription_invoice_issuing_date_adjustment: "align_with_finalization_date", + invoice_grace_period: old_invoice_grace_period + ) + end + end + end + + # Despite we do not use net_payment_term from org anymore, we need this test to ensure that update on billing_entity is + # triggered and correctly handled + # TODO: delete when cleaning up org from billing-entity specific data + context "when updating net_payment_term" do + let(:customer) { create(:customer, organization:) } + + let(:draft_invoice) do + create(:invoice, status: :draft, customer:, created_at: DateTime.parse("19 Jun 2022"), organization:) + end + + let(:params) do + { + net_payment_term: 2 + } + end + + before do + draft_invoice + allow(BillingEntities::UpdateInvoicePaymentDueDateService).to receive(:call).and_call_original + end + + it "updates the corresponding draft invoices" do + current_date = DateTime.parse("22 Jun 2022") + + travel_to(current_date) do + result = update_service.call + expect(result).to be_success + + expect(result.organization.net_payment_term).to eq(2) + expect(BillingEntities::UpdateInvoicePaymentDueDateService).to have_received(:call).with(billing_entity: organization.default_billing_entity, net_payment_term: 2) + end + end + end + end + + context "with base64 logo" do + let(:logo) do + logo_file = File.read(Rails.root.join("spec/factories/images/logo.png")) + base64_logo = Base64.encode64(logo_file) + + "data:image/png;base64,#{base64_logo}" + end + + it "updates the organization with logo" do + result = update_service.call + expect(result.organization.logo.blob).not_to be_nil + end + end + + context "with validation errors" do + let(:country) { "---" } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:country]).to eq(["not_a_valid_country_code"]) + end + end + + context "with eu tax management" do + context "with org within the EU" do + let(:params) { {eu_tax_management: true, country: "fr"} } + + before do + allow(Taxes::AutoGenerateService).to receive(:call) + end + + it "calls the taxes auto generate service" do + result = update_service.call + + expect(result).to be_success + expect(result.organization.eu_tax_management).to eq(true) + expect(Taxes::AutoGenerateService).to have_received(:call).with(organization:).once + end + end + + context "with org outside the EU" do + let(:params) { {eu_tax_management: true, country: "us"} } + + before do + allow(Taxes::AutoGenerateService).to receive(:call) + end + + it "does not call the taxes auto generate service" do + result = update_service.call + + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({eu_tax_management: ["org_must_be_in_eu"]}) + expect(organization.reload.eu_tax_management).to eq(false) + expect(Taxes::AutoGenerateService).not_to have_received(:call) + end + end + + context "with org is outside the EU but feature is already enabled" do + let(:params) { {eu_tax_management: false} } + + before do + organization.country = "us" + organization.eu_tax_management = true + allow(Taxes::AutoGenerateService).to receive(:call) + end + + it "can disable eu_tax_management" do + result = update_service.call + + expect(result).to be_success + expect(result.organization.eu_tax_management).to eq(false) + expect(Taxes::AutoGenerateService).not_to have_received(:call) + end + end + end + + context "when organization does not have active billing_entities" do + it "returns an error and does not update the organization" do + organization.default_billing_entity.discard! + organization.reload + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("billing_entity_not_found") + expect(organization.reload.legal_name).not_to eq("Foobar") + expect(organization.reload.legal_number).not_to eq("1234") + end + end + + context "when updating organization's document_numbering" do + context "when updating to per_organization" do + let(:params) { {document_numbering: "per_organization"} } + + it "updates the organization numbering to per_organization and default billing_entity to per_entity" do + result = update_service.call + + expect(result.organization.document_numbering).to eq("per_organization") + expect(result.organization.default_billing_entity.document_numbering).to eq("per_billing_entity") + end + end + + context "when updating to not existing value" do + let(:params) { {document_numbering: "not_existing_document_numbering"} } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:document_numbering]).to eq(["value_is_invalid"]) + end + end + end + + context "when authentication_methods change" do + subject { described_class.new(organization:, params:, user:) } + + let(:params) { {authentication_methods: ["email_password", "okta"]} } + let(:user) { create(:user) } + let(:additions) { ["okta"] } + let(:deletions) { ["google_oauth"] } + + before { create(:membership, organization:, roles: %i[admin], user:) } + + it "delivers a email notification" do + expect { subject.call }.to have_enqueued_mail(OrganizationMailer, :authentication_methods_updated) + .with(params: {organization:, user:, additions:, deletions:}, args: []) + end + end + end +end diff --git a/spec/services/password_resets/create_service_spec.rb b/spec/services/password_resets/create_service_spec.rb new file mode 100644 index 0000000..fb706b2 --- /dev/null +++ b/spec/services/password_resets/create_service_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PasswordResets::CreateService do + subject(:create_service) { described_class } + + include_context "with mocked security logger" + + describe "#call" do + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:user) { membership.user } + let(:create_args) do + { + user: + } + end + + it "creates a password reset" do + expect { create_service.call(**create_args) } + .to change(PasswordReset, :count).by(1) + end + + it_behaves_like "produces a security log", "user.password_reset_requested" do + before { create_service.call(**create_args) } + end + + context "with multiple active memberships" do + before { create(:membership, user:) } + + it_behaves_like "produces a security log", "user.password_reset_requested" do + before { create_service.call(**create_args) } + end + end + + context "without arguments" do + it "raises an error" do + result = create_service.call(user: nil) + + expect(result).not_to be_success + expect(result.error.error_code).to eq("user_not_found") + end + + it_behaves_like "does not produce a security log" do + before { create_service.call(user: nil) } + end + end + + it "enqueues an SendEmailJob" do + expect do + create_service.call(**create_args) + end.to have_enqueued_job(SendEmailJob) + end + end +end diff --git a/spec/services/password_resets/reset_service_spec.rb b/spec/services/password_resets/reset_service_spec.rb new file mode 100644 index 0000000..7906a1e --- /dev/null +++ b/spec/services/password_resets/reset_service_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PasswordResets::ResetService do + subject(:reset_service) { described_class } + + include_context "with mocked security logger" + + describe "#call" do + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:user) { membership.user } + let(:password_reset) { create(:password_reset, user:) } + let(:reset_args) do + { + token: password_reset.token, + new_password: "HelloLago!2" + } + end + + before { user.update!(password: "HelloLago!1") } + + it "changes the user password" do + reset_service.call(**reset_args) + + expect(user.reload&.authenticate(reset_args[:new_password])).to be_truthy + end + + it "logs in the user" do + allow(SegmentIdentifyJob).to receive(:perform_later) + + result = reset_service.call(**reset_args) + + data = result["user"] + + expect(data).to be_present + expect(SegmentIdentifyJob).to have_received(:perform_later).with( + membership_id: "membership/#{membership.id}" + ) + end + + it_behaves_like "produces a security log", "user.password_edited" do + before { reset_service.call(**reset_args) } + end + + context "with multiple active memberships" do + before { create(:membership, user:) } + + it_behaves_like "produces a security log", "user.password_edited" do + before { reset_service.call(**reset_args) } + end + end + + context "without expected argument" do + it "raises an error if token is not present" do + result = reset_service.call(new_password: reset_args[:new_password], token: nil) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:token]).to eq(["missing_token"]) + end + + it_behaves_like "does not produce a security log" do + before { reset_service.call(new_password: reset_args[:new_password], token: nil) } + end + + it "raises an error if new_password is not present" do + result = reset_service.call(new_password: nil, token: password_reset.token) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:new_password]).to eq(["missing_password"]) + end + + it_behaves_like "does not produce a security log" do + before { reset_service.call(new_password: nil, token: password_reset.token) } + end + end + + context "when demand is expired" do + let(:expired_password_reset) do + create(:password_reset, user:, expire_at: Time.current - 1.minute) + end + + it "raises an error" do + result = reset_service.call(new_password: reset_args[:new_password], token: expired_password_reset.token) + + expect(result).not_to be_success + expect(result.error.error_code).to eq("password_reset_not_found") + end + + it_behaves_like "does not produce a security log" do + before { reset_service.call(new_password: reset_args[:new_password], token: expired_password_reset.token) } + end + end + end +end diff --git a/spec/services/payment_intents/fetch_service_spec.rb b/spec/services/payment_intents/fetch_service_spec.rb new file mode 100644 index 0000000..efef512 --- /dev/null +++ b/spec/services/payment_intents/fetch_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +RSpec.describe PaymentIntents::FetchService do + describe ".call" do + subject(:result) { described_class.call(invoice:) } + + context "when invoice does not exist" do + let(:invoice) { nil } + + it "fails with invoice not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("invoice_not_found") + end + end + + context "when invoice exists" do + let(:invoice) { create(:invoice) } + let(:payment_provider_service) { instance_double("PaymentProviderService") } + let(:payment_url) { "https://example.com/payment" } + + before do + allow(Invoices::Payments::PaymentProviders::Factory) + .to receive(:new_instance) + .with(invoice:) + .and_return(payment_provider_service) + + allow(payment_provider_service) + .to receive(:generate_payment_url) + .with(instance_of(PaymentIntent)) + .and_return(BaseService::Result.new.tap { |r| r.payment_url = payment_url }) + end + + context "when active payment intent exists" do + let!(:payment_intent) { create(:payment_intent, invoice:, payment_url:) } + + it "returns the existing payment intent" do + expect(result).to be_success + expect(result.payment_intent).to eq(payment_intent) + expect(payment_provider_service).not_to have_received(:generate_payment_url) + end + end + + context "when payment intent exists but has no payment URL" do + let!(:payment_intent) { create(:payment_intent, invoice:, payment_url: nil) } + + it "returns intent with generated payment URL" do + expect(result).to be_success + expect(result.payment_intent).to eq(payment_intent) + expect(result.payment_intent.payment_url).to eq(payment_url) + expect(payment_provider_service).to have_received(:generate_payment_url) + end + end + + context "when payment provider fails to generate URL" do + before do + allow(payment_provider_service) + .to receive(:generate_payment_url) + .and_return(BaseService::Result.new.tap { |r| r.payment_url = nil }) + end + + it "fails with payment provider error" do + expect(result).to be_failure + expect(result.error.messages).to eq({base: ["payment_provider_error"]}) + end + end + + context "when awaiting expiration payment intent exists" do + let!(:awaiting_expiration_intent) { create(:payment_intent, :awaiting_expiration, invoice:) } + + it "expires awaiting expiration payment intent" do + expect { result }.to change { awaiting_expiration_intent.reload.status }.to("expired") + end + + it "returns new payment intent" do + expect(result).to be_success + expect(result.payment_intent.payment_url).to eq(payment_url) + expect(payment_provider_service).to have_received(:generate_payment_url) + end + end + end + end +end diff --git a/spec/services/payment_methods/create_from_provider_service_spec.rb b/spec/services/payment_methods/create_from_provider_service_spec.rb new file mode 100644 index 0000000..af4253e --- /dev/null +++ b/spec/services/payment_methods/create_from_provider_service_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethods::CreateFromProviderService do + subject(:create_service) { described_class.new(customer:, params:, provider_method_id:, payment_provider_id:, payment_provider_customer:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:customer) { create(:customer, organization:) } + let(:params) {} + let(:provider_method_id) { "i_cant_be_nil" } + let(:payment_provider_id) { nil } + let(:payment_provider_customer) { nil } + + describe "#call" do + context "without customer" do + let(:customer) { nil } + + it "fails" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("customer_not_found") + end + end + + context "with payment_provider_id" do + let(:payment_provider_id) { create(:stripe_provider).id } + + it "saves the value" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method).not_to be_nil + expect(payment_method.payment_provider_id).to eq(payment_provider_id) + end + end + + context "with payment_provider_customer" do + let(:payment_provider_customer) { create(:stripe_customer, customer:, provider_customer_id: "cus_test") } + + it "saves the reference" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method).not_to be_nil + expect(payment_method.payment_provider_customer).to eq(payment_provider_customer) + end + end + + context "with details" do + subject(:create_service) do + described_class.new(customer:, params:, provider_method_id:, payment_provider_id:, payment_provider_customer:, details:) + end + + let(:details) do + { + type: "card", + last4: "4242", + brand: "visa", + expiration_month: 12, + expiration_year: 2028 + } + end + + it "saves the details" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method).not_to be_nil + expect(payment_method.details).to eq( + { + "type" => "card", + "last4" => "4242", + "brand" => "visa", + "expiration_month" => 12, + "expiration_year" => 2028 + } + ) + end + end + + describe "provider_method_type" do + context "when included in params" do + let(:params) do + {provider_payment_methods: %w[link card sepa_debit]} + end + + it "saves the first param value" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method.provider_method_type).to eq("link") + end + end + + context "when default" do + let(:params) do + {} + end + + it "saves the card value" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method.provider_method_type).to eq("card") + end + end + end + end +end diff --git a/spec/services/payment_methods/destroy_service_spec.rb b/spec/services/payment_methods/destroy_service_spec.rb new file mode 100644 index 0000000..4a7e0a3 --- /dev/null +++ b/spec/services/payment_methods/destroy_service_spec.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethods::DestroyService do + subject(:destroy_service) { described_class.new(payment_method:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:membership) { create(:membership, organization:) } + + let(:payment_method) { create(:payment_method, organization:, customer:, is_default: true) } + + before { payment_method } + + describe "#call" do + subject(:result) { destroy_service.call } + + context "when payment method is not found" do + let(:payment_method) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("payment_method_not_found") + end + end + + context "with payment method" do + it "sets payment method as NOT default" do + expect { destroy_service.call } + .to change { payment_method.reload.is_default } + .from(true) + .to(false) + end + + it "soft deletes the payment method" do + freeze_time do + expect { destroy_service.call }.to change(PaymentMethod, :count).by(-1) + .and change { payment_method.reload.deleted_at }.from(nil).to(Time.current) + end + end + end + end +end diff --git a/spec/services/payment_methods/determine_service_spec.rb b/spec/services/payment_methods/determine_service_spec.rb new file mode 100644 index 0000000..c4997c3 --- /dev/null +++ b/spec/services/payment_methods/determine_service_spec.rb @@ -0,0 +1,292 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethods::DetermineService do + subject(:service) { described_class.new(invoice:, customer:, payment_method_params:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:default_payment_method) { create(:payment_method, customer:, is_default: true) } + let(:payment_method_params) { {} } + + before { default_payment_method } + + describe "#call" do + context "when payment_method_params are present" do + context "when payment_method_type is manual" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment_method_params) { {payment_method_type: "manual"} } + + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when payment_method_id is provided" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:override_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:payment_method_params) { {payment_method_id: override_payment_method.id} } + + it "returns the specified payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(override_payment_method) + end + + context "when the payment_method_id does not exist" do + let(:payment_method_params) { {payment_method_id: "00000000-0000-0000-0000-000000000000"} } + + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + end + + context "when no payment_method_id is provided" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment_method_params) { {payment_method_type: "provider"} } + + it "returns the customer default payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(default_payment_method) + end + end + end + + context "when payment_method_params are absent" do + context "with a subscription invoice" do + let(:plan) { create(:plan, organization:) } + let(:invoice) { create(:invoice, customer:, organization:, invoice_type: :subscription) } + + context "when subscription has no payment method configured" do + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "returns the customer default payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(default_payment_method) + end + end + + context "when subscription has a payment method" do + let(:subscription_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:subscription) { create(:subscription, customer:, plan:, organization:, payment_method: subscription_payment_method) } + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "returns the subscription payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(subscription_payment_method) + end + end + + context "when subscription has manual payment method type" do + let(:subscription) { create(:subscription, customer:, plan:, organization:, payment_method_type: "manual") } + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when invoice has no invoice subscriptions" do + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + end + + context "with an advance_charges invoice" do + let(:plan) { create(:plan, organization:) } + let(:invoice) { create(:invoice, customer:, organization:, invoice_type: :advance_charges) } + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "returns the customer default payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(default_payment_method) + end + end + + context "with a progressive_billing invoice" do + let(:plan) { create(:plan, organization:) } + let(:invoice) { create(:invoice, customer:, organization:, invoice_type: :progressive_billing) } + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + + before { create(:invoice_subscription, invoice:, subscription:) } + + it "returns the customer default payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(default_payment_method) + end + end + + context "with a credit invoice" do + let(:wallet) { create(:wallet, customer:, organization:) } + let(:invoice) { create(:invoice, :credit, customer:, organization:) } + + context "when invoice has no wallet transaction" do + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when wallet transaction has manual payment method type" do + before { create(:wallet_transaction, wallet:, invoice:, source: :manual, payment_method_type: "manual") } + + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when wallet transaction has a payment method" do + let(:wt_payment_method) { create(:payment_method, customer:, is_default: false) } + + before { create(:wallet_transaction, wallet:, invoice:, source: :manual, payment_method: wt_payment_method) } + + it "returns the wallet transaction payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(wt_payment_method) + end + end + + context "when wallet transaction source is interval" do + before { create(:wallet_transaction, wallet:, invoice:, source: :interval) } + + context "when recurring rule has manual payment method type" do + before { create(:recurring_transaction_rule, wallet:, payment_method_type: "manual") } + + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when recurring rule has a payment method" do + let(:rule_payment_method) { create(:payment_method, customer:, is_default: false) } + + before { create(:recurring_transaction_rule, wallet:, payment_method: rule_payment_method) } + + it "returns the recurring rule payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(rule_payment_method) + end + end + + context "when there is no active recurring rule" do + it "falls through to the wallet configuration" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(default_payment_method) + end + end + end + + context "when wallet transaction source is threshold" do + let(:rule_payment_method) { create(:payment_method, customer:, is_default: false) } + + before do + create(:wallet_transaction, wallet:, invoice:, source: :threshold) + create(:recurring_transaction_rule, wallet:, payment_method: rule_payment_method) + end + + it "returns the recurring rule payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(rule_payment_method) + end + end + + context "when wallet has manual payment method type" do + let(:wallet) { create(:wallet, customer:, organization:, payment_method_type: "manual") } + + before { create(:wallet_transaction, wallet:, invoice:, source: :manual) } + + it "returns nil" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when wallet has a payment method" do + let(:wallet_payment_method) { create(:payment_method, customer:, is_default: false) } + let(:wallet) { create(:wallet, customer:, organization:, payment_method: wallet_payment_method) } + + before { create(:wallet_transaction, wallet:, invoice:, source: :manual) } + + it "returns the wallet payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(wallet_payment_method) + end + end + + context "when no payment method is configured on wallet-related objects" do + before { create(:wallet_transaction, wallet:, invoice:, source: :manual) } + + it "returns the customer default payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(default_payment_method) + end + end + end + + context "with a one_off invoice" do + let(:invoice) { create(:invoice, customer:, organization:, invoice_type: :one_off) } + + it "returns the customer default payment method" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(default_payment_method) + end + end + end + end +end diff --git a/spec/services/payment_methods/find_or_create_from_provider_service_spec.rb b/spec/services/payment_methods/find_or_create_from_provider_service_spec.rb new file mode 100644 index 0000000..c94f4ec --- /dev/null +++ b/spec/services/payment_methods/find_or_create_from_provider_service_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethods::FindOrCreateFromProviderService do + subject(:service) do + described_class.new( + customer:, + payment_provider_customer:, + provider_method_id:, + params:, + set_as_default: + ) + end + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:payment_provider_customer) { create(:stripe_customer, customer:) } + let(:provider_method_id) { "pm_123456" } + let(:params) { {} } + let(:set_as_default) { false } + + describe "#call" do + context "without provider_method_id" do + let(:provider_method_id) { nil } + + it "returns success without creating a PaymentMethod" do + expect { service.call }.not_to change(PaymentMethod, :count) + + result = service.call + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when PaymentMethod does not exist" do + it "creates a new PaymentMethod" do + expect { service.call }.to change(PaymentMethod, :count).by(1) + end + + it "returns the created PaymentMethod" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to be_present + expect(result.payment_method.customer).to eq(customer) + expect(result.payment_method.payment_provider_customer).to eq(payment_provider_customer) + expect(result.payment_method.provider_method_id).to eq(provider_method_id) + expect(result.payment_method.provider_method_type).to eq("card") + end + + context "with provider_payment_methods in params" do + let(:params) { {provider_payment_methods: %w[sepa_debit card]} } + + it "uses the first payment method type from params" do + result = service.call + + expect(result.payment_method.provider_method_type).to eq("sepa_debit") + end + end + end + + context "when PaymentMethod already exists" do + let!(:existing_payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer:, + provider_method_id:, + is_default: false + ) + end + + it "does not create a new PaymentMethod" do + expect { service.call }.not_to change(PaymentMethod, :count) + end + + it "returns the existing PaymentMethod" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(existing_payment_method) + end + + it "does not change is_default by default" do + service.call + + expect(existing_payment_method.reload.is_default).to be(false) + end + end + + context "when a concurrent process creates the same PaymentMethod" do + it "rescues RecordNotUnique and returns the existing record" do + allow(PaymentMethods::CreateFromProviderService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique) + + existing_payment_method = create( + :payment_method, + customer:, + payment_provider_customer:, + provider_method_id: + ) + + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(existing_payment_method) + end + end + + context "with set_as_default: true" do + let(:set_as_default) { true } + + context "when PaymentMethod does not exist" do + it "creates PaymentMethod and sets as default" do + result = service.call + + expect(result.payment_method.is_default).to be(true) + end + end + + context "when PaymentMethod already exists" do + let!(:existing_payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer:, + provider_method_id:, + is_default: false + ) + end + + it "sets the existing PaymentMethod as default" do + service.call + + expect(existing_payment_method.reload.is_default).to be(true) + end + end + + context "when PaymentMethod is already default" do + let!(:existing_payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer:, + provider_method_id:, + is_default: true + ) + end + + it "returns the existing PaymentMethod without changes" do + result = service.call + + expect(result).to be_success + expect(result.payment_method).to eq(existing_payment_method) + expect(existing_payment_method.reload.is_default).to be(true) + end + end + end + end +end diff --git a/spec/services/payment_methods/set_as_default_service_spec.rb b/spec/services/payment_methods/set_as_default_service_spec.rb new file mode 100644 index 0000000..098fd31 --- /dev/null +++ b/spec/services/payment_methods/set_as_default_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethods::SetAsDefaultService do + subject(:default_service) { described_class.new(payment_method:) } + + let(:required_permission) { "payment_methods:update" } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:user) { membership.user } + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: false) } + let(:payment_method2) { create(:payment_method, customer:, organization:, is_default: true) } + let(:payment_method3) { create(:payment_method, customer:, organization:, is_default: false) } + + describe "#call" do + context "when payment method exists" do + before do + payment_method + payment_method2 + payment_method3 + end + + it "correctly sets default payment method" do + default_service.call + + expect(payment_method.reload.is_default).to eq(true) + expect(payment_method2.reload.is_default).to eq(false) + expect(payment_method3.reload.is_default).to eq(false) + end + end + + context "when billing entity is nil" do + let(:payment_method) { nil } + + it "returns a not found failure" do + result = default_service.call + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("payment_method_not_found") + end + end + end +end diff --git a/spec/services/payment_methods/update_details_service_spec.rb b/spec/services/payment_methods/update_details_service_spec.rb new file mode 100644 index 0000000..3a5ab35 --- /dev/null +++ b/spec/services/payment_methods/update_details_service_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethods::UpdateDetailsService do + subject(:create_service) { described_class.new(payment_method:, insert:, delete:) } + + let(:customer) { create(:customer) } + let(:payment_method) { create(:payment_method, customer:) } + let(:insert) { {} } + let(:delete) { {} } + + describe "#call" do + context "without payment_method" do + let(:payment_method) { nil } + + it "fails" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("payment_method_not_found") + end + end + + context "with insertions" do + let(:insert) { {test: true, ruby: true} } + + it "insert into details" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method.details).to include( + "test" => true, + "ruby" => true + ) + end + + context "when existing details" do + let(:payment_method) { create(:payment_method, customer:, details: {existing: "yes"}) } + + it "preserves the existing values" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method.details).to eq( + { + "test" => true, + "ruby" => true, + "existing" => "yes" + } + ) + end + + context "when updating" do + let(:insert) { {existing: "for sure", ruby: true} } + + it "replaces only the value" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method.details).to eq( + { + "ruby" => true, + "existing" => "for sure" + } + ) + end + end + end + end + + context "when deletions" do + let(:delete) { {keep: false, remove: "me"} } + + context "when the keys does not exist" do + it "does nothing" do + older_details = payment_method.details + result = create_service.call + payment_method = result.payment_method + + expect(payment_method.details).to eq(older_details) + end + end + + context "with existing keys" do + let(:payment_method) { create(:payment_method, customer:, details: {existing: "yes", keep: false}) } + + it "remove the keys" do + result = create_service.call + payment_method = result.payment_method + + expect(payment_method.details).not_to include("keep" => false) + expect(payment_method.details).to eq({"existing" => "yes"}) + end + end + end + end +end diff --git a/spec/services/payment_methods/validate_service_spec.rb b/spec/services/payment_methods/validate_service_spec.rb new file mode 100644 index 0000000..ca9037a --- /dev/null +++ b/spec/services/payment_methods/validate_service_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentMethods::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:payment_method) { create(:payment_method, organization:) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + let(:args) do + { + payment_method: payment_method_params + } + end + + describe ".valid?" do + context "when there is no payment_method attribute" do + let(:args) do + {} + end + + it "returns true" do + expect(validate_service).to be_valid + end + end + + context "when provider payment method is valid" do + before do + result.payment_method = payment_method + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "when manual payment method is valid" do + let(:payment_method_params) do + { + payment_method_type: "manual" + } + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "with invalid payment method type" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "with invalid payment method reference" do + let(:payment_method_params) do + { + payment_method_id: "invalid", + payment_method_type: "provider" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end +end diff --git a/spec/services/payment_provider_customers/adyen_service_spec.rb b/spec/services/payment_provider_customers/adyen_service_spec.rb new file mode 100644 index 0000000..04ab979 --- /dev/null +++ b/spec/services/payment_provider_customers/adyen_service_spec.rb @@ -0,0 +1,324 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::AdyenService do + let(:adyen_service) { described_class.new(adyen_customer) } + let(:customer) { create(:customer, organization:) } + let(:adyen_provider) { create(:adyen_provider) } + let(:organization) { adyen_provider.organization } + let(:adyen_client) { instance_double(Adyen::Client) } + let(:payment_links_api) { Adyen::PaymentLinksApi.new(adyen_client, 70) } + let(:checkout) { Adyen::Checkout.new(adyen_client, 70) } + let(:payment_links_response) { generate(:adyen_payment_links_response) } + + let(:adyen_customer) do + create(:adyen_customer, customer:, provider_customer_id: nil) + end + + before do + allow(Adyen::Client).to receive(:new).and_return(adyen_client) + allow(adyen_client).to receive(:checkout).and_return(checkout) + allow(checkout).to receive(:payment_links_api).and_return(payment_links_api) + allow(payment_links_api).to receive(:payment_links).and_return(payment_links_response) + end + + describe "#create" do + subject(:adyen_service_create) { adyen_service.create } + + context "when customer does not have an adyen customer id yet" do + it "calls adyen api client payment links" do + adyen_service_create + expect(payment_links_api).to have_received(:payment_links) + end + + it "creates a payment link" do + expect(adyen_service_create.checkout_url).to eq("https://test.adyen.link/test") + end + + it "delivers a success webhook" do + expect { adyen_service_create }.to enqueue_job(SendWebhookJob) + .with( + "customer.checkout_url_generated", + customer, + checkout_url: "https://test.adyen.link/test" + ) + .on_queue(webhook_queue) + end + end + + context "when customer already has an adyen customer id" do + let(:adyen_customer) do + create(:adyen_customer, customer:, provider_customer_id: "cus_123456") + end + + it "does not call adyen API" do + expect(payment_links_api).not_to have_received(:payment_links) + end + end + + context "when failing to generate the checkout link due to an error response" do + let(:payment_links_error_response) { generate(:adyen_payment_links_error_response) } + + before do + allow(payment_links_api).to receive(:payment_links).and_return(payment_links_error_response) + end + + it "delivers an error webhook" do + expect { adyen_service_create }.to enqueue_job(SendWebhookJob) + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "There are no payment methods available for the given parameters.", + error_code: "validation" + } + ).on_queue(webhook_queue) + end + end + + context "when failing to generate the checkout link" do + before do + allow(payment_links_api) + .to receive(:payment_links).and_raise(Adyen::AdyenError.new(nil, nil, "error")) + end + + it "delivers an error webhook" do + expect { adyen_service.create } + .to raise_error(Adyen::AdyenError) + + expect(SendWebhookJob).to have_been_enqueued + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "error", + error_code: nil + } + ) + end + end + + context "with authentication error" do + before do + allow(payment_links_api) + .to receive(:payment_links).and_raise(Adyen::AuthenticationError.new("error", nil)) + end + + it "delivers an error webhook" do + expect(adyen_service.create).to be_success + + expect(SendWebhookJob).to have_been_enqueued + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "error", + error_code: 401 + } + ) + end + end + end + + describe "#update" do + it "returns result" do + expect(adyen_service.update).to be_a(BaseService::Result) + end + end + + describe "#success_redirect_url" do + subject(:success_redirect_url) { adyen_service.__send__(:success_redirect_url) } + + context "when payment provider has success redirect url" do + it "returns payment provider's success redirect url" do + expect(success_redirect_url).to eq(adyen_provider.success_redirect_url) + end + end + + context "when payment provider has no success redirect url" do + let(:adyen_provider) { create(:adyen_provider, success_redirect_url: nil) } + + it "returns the default success redirect url" do + expect(success_redirect_url).to eq(PaymentProviders::AdyenProvider::SUCCESS_REDIRECT_URL) + end + end + end + + describe "#generate_checkout_url" do + context "when adyen payment provider is nil" do + before { adyen_provider.destroy! } + + it "returns a not found error" do + result = adyen_service.generate_checkout_url + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("adyen_payment_provider_not_found") + end + end + + context "when adyen payment provider is present" do + subject(:generate_checkout_url) { adyen_service.generate_checkout_url } + + it "generates a checkout url" do + expect(generate_checkout_url).to be_success + end + + it "delivers a success webhook" do + expect { generate_checkout_url }.to enqueue_job(SendWebhookJob) + .with( + "customer.checkout_url_generated", + customer, + checkout_url: "https://test.adyen.link/test" + ) + .on_queue(webhook_queue) + end + + context "when customer has no currency" do + let(:customer) { create(:customer, organization:, currency: nil) } + + it "falls back to the organization default currency" do + generate_checkout_url + expect(payment_links_api).to have_received(:payment_links).with( + hash_including(amount: hash_including(currency: organization.default_currency)) + ) + end + end + end + end + + describe "#preauthorise" do + subject(:preauthorise) { described_class.new.preauthorise(organization, event) } + + let(:payment_method_id) { "pm_adyen_123456" } + let(:shopper_reference) { customer.external_id } + + let(:event) do + { + "success" => "true", + "additionalData" => { + "shopperReference" => shopper_reference, + "recurring.recurringDetailReference" => payment_method_id + } + } + end + + before { adyen_customer } + + context "when event is successful" do + it "updates adyen_customer with payment_method_id and provider_customer_id" do + preauthorise + + expect(adyen_customer.reload.payment_method_id).to eq(payment_method_id) + expect(adyen_customer.provider_customer_id).to eq(shopper_reference) + end + + it "delivers a success webhook" do + expect { preauthorise }.to enqueue_job(SendWebhookJob) + .with("customer.payment_provider_created", customer) + .on_queue(webhook_queue) + end + + it "does not create a PaymentMethod record" do + expect { preauthorise }.not_to change(PaymentMethod, :count) + end + + context "with multiple_payment_methods feature flag enabled" do + before { organization.enable_feature_flag!(:multiple_payment_methods) } + + it "creates a new PaymentMethod record" do + expect { preauthorise }.to change(PaymentMethod, :count).by(1) + + payment_method = PaymentMethod.last + expect(payment_method.customer).to eq(customer) + expect(payment_method.payment_provider_customer).to eq(adyen_customer) + expect(payment_method.provider_method_id).to eq(payment_method_id) + expect(payment_method.provider_method_type).to eq("card") + expect(payment_method.is_default).to be(true) + end + + it "still updates adyen_customer payment_method_id for backward compatibility" do + preauthorise + + expect(adyen_customer.reload.payment_method_id).to eq(payment_method_id) + end + + context "when PaymentMethod already exists" do + let!(:existing_payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer: adyen_customer, + provider_method_id: payment_method_id, + is_default: false + ) + end + + it "does not create a new PaymentMethod" do + expect { preauthorise }.not_to change(PaymentMethod, :count) + end + + it "sets the existing PaymentMethod as default" do + preauthorise + + expect(existing_payment_method.reload.is_default).to be(true) + end + + context "when payment method lookup raises RecordNotUnique" do + before do + allow(PaymentMethods::FindOrCreateFromProviderService).to receive(:call).and_raise(ActiveRecord::RecordNotUnique) + end + + it "does not raise error" do + expect { preauthorise }.not_to raise_error(ActiveRecord::RecordNotUnique) + end + end + end + end + end + + context "when event is not successful" do + let(:event) do + { + "success" => "false", + "reason" => "Refused", + "eventCode" => "AUTHORISATION", + "additionalData" => { + "shopperReference" => shopper_reference, + "recurring.recurringDetailReference" => payment_method_id + } + } + end + + it "does not update adyen_customer" do + preauthorise + + expect(adyen_customer.reload.payment_method_id).to be_nil + end + + it "delivers an error webhook" do + expect { preauthorise }.to enqueue_job(SendWebhookJob) + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "Refused", + error_code: "AUTHORISATION" + } + ) + .on_queue(webhook_queue) + end + end + + context "when adyen_customer is not found" do + let(:shopper_reference) { "unknown_customer" } + + it "returns a successful result without updating" do + result = preauthorise + + expect(result).to be_success + end + end + end +end diff --git a/spec/services/payment_provider_customers/flutterwave_service_spec.rb b/spec/services/payment_provider_customers/flutterwave_service_spec.rb new file mode 100644 index 0000000..d868855 --- /dev/null +++ b/spec/services/payment_provider_customers/flutterwave_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::FlutterwaveService do + subject(:flutterwave_service) { described_class.new(flutterwave_customer) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization: organization) } + let(:flutterwave_provider) { create(:flutterwave_provider, organization: organization) } + let(:flutterwave_customer) { create(:flutterwave_customer, customer: customer, payment_provider: flutterwave_provider) } + + describe "#create" do + it "returns success with the flutterwave customer" do + result = flutterwave_service.create + + expect(result).to be_success + expect(result.flutterwave_customer).to eq(flutterwave_customer) + end + end + + describe "#update" do + it "returns success" do + result = flutterwave_service.update + + expect(result).to be_success + end + end + + describe "#generate_checkout_url" do + it "returns not allowed failure" do + result = flutterwave_service.generate_checkout_url + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("feature_not_supported") + end + + context "when send_webhook is false" do + it "returns not allowed failure" do + result = flutterwave_service.generate_checkout_url(send_webhook: false) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("feature_not_supported") + end + end + end + + describe "private methods" do + describe "#customer" do + it "delegates to flutterwave_customer" do + expect(flutterwave_service.send(:customer)).to eq(customer) + end + end + end +end diff --git a/spec/services/payment_provider_customers/gocardless_service_spec.rb b/spec/services/payment_provider_customers/gocardless_service_spec.rb new file mode 100644 index 0000000..24735d1 --- /dev/null +++ b/spec/services/payment_provider_customers/gocardless_service_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::GocardlessService do + subject(:gocardless_service) { described_class.new(gocardless_customer) } + + let(:customer) { create(:customer, organization:) } + let(:gocardless_provider) { create(:gocardless_provider) } + let(:organization) { gocardless_provider.organization } + let(:gocardless_client) { instance_double(GoCardlessPro::Client) } + let(:gocardless_customers_service) { instance_double(GoCardlessPro::Services::CustomersService) } + let(:gocardless_billing_request_service) { instance_double(GoCardlessPro::Services::BillingRequestsService) } + let(:gocardless_billing_request_flow_service) { instance_double(GoCardlessPro::Services::BillingRequestFlowsService) } + + let(:gocardless_customer) do + create(:gocardless_customer, customer:, provider_customer_id: nil) + end + + describe ".create" do + before do + allow(GoCardlessPro::Client).to receive(:new) + .and_return(gocardless_client) + allow(gocardless_client).to receive(:customers) + .and_return(gocardless_customers_service) + allow(gocardless_customers_service).to receive(:create) + .and_return(GoCardlessPro::Resources::Customer.new("id" => "123")) + end + + context "when all customer details are present" do + it "creates a customer with company_name, given_name, and family_name" do + gocardless_service.create + expect(gocardless_customers_service).to have_received(:create).with( + hash_including( + params: { + email: customer.email, + company_name: customer.name, + given_name: customer.firstname, + family_name: customer.lastname + } + ) + ) + end + end + + it "creates the gocardless customer" do + result = gocardless_service.create + + expect(gocardless_customers_service).to have_received(:create) + expect(result.gocardless_customer.provider_customer_id).to eq("123") + end + + it "delivers a success webhook" do + gocardless_service.create + + expect(gocardless_customers_service).to have_received(:create) + expect(SendWebhookJob).to have_been_enqueued + .with("customer.payment_provider_created", customer) + end + + it "triggers checkout job" do + gocardless_service.create + + expect(gocardless_customers_service).to have_received(:create) + expect(PaymentProviderCustomers::GocardlessCheckoutUrlJob).to have_been_enqueued + .with(gocardless_customer) + end + + context "when customer already have a gocardless customer id" do + let(:gocardless_customer) do + create(:gocardless_customer, customer:, provider_customer_id: "cus_123456") + end + + it "does not call gocardless API" do + gocardless_service.create + + expect(gocardless_customers_service).not_to have_received(:create) + end + end + + context "when failing to create the customer" do + it "delivers an error webhook" do + allow(GoCardlessPro::Client).to receive(:new) + .and_raise(GoCardlessPro::ApiError.new({"message" => "error"})) + + expect { gocardless_service.create } + .to raise_error(GoCardlessPro::ApiError) + + expect(SendWebhookJob).to have_been_enqueued + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "error", + error_code: nil + } + ) + end + end + end + + describe "#update" do + it "returns result" do + expect(gocardless_service.update).to be_a(BaseService::Result) + end + end + + describe ".generate_checkout_url" do + before do + allow(GoCardlessPro::Client).to receive(:new) + .and_return(gocardless_client) + allow(gocardless_client).to receive(:billing_requests) + .and_return(gocardless_billing_request_service) + allow(gocardless_billing_request_service).to receive(:create) + .and_return(GoCardlessPro::Resources::BillingRequest.new("id" => "123")) + + allow(gocardless_client).to receive(:billing_request_flows) + .and_return(gocardless_billing_request_flow_service) + allow(gocardless_billing_request_flow_service).to receive(:create) + .and_return(GoCardlessPro::Resources::BillingRequestFlow.new("authorisation_url" => "https://example.com")) + end + + it "receives billing request flow response" do + gocardless_service.generate_checkout_url + + expect(gocardless_billing_request_service).to have_received(:create) + expect(gocardless_billing_request_flow_service).to have_received(:create) + end + + it "delivers a webhook with checkout url" do + gocardless_service.generate_checkout_url + + expect(gocardless_billing_request_service).to have_received(:create) + expect(gocardless_billing_request_flow_service).to have_received(:create) + expect(SendWebhookJob).to have_been_enqueued + .with("customer.checkout_url_generated", customer, checkout_url: "https://example.com") + end + end + + describe "#success_redirect_url" do + subject(:success_redirect_url) { gocardless_service.__send__(:success_redirect_url) } + + context "when payment provider has success redirect url" do + it "returns payment provider's success redirect url" do + expect(success_redirect_url).to eq(gocardless_provider.success_redirect_url) + end + end + + context "when payment provider has no success redirect url" do + let(:gocardless_provider) { create(:gocardless_provider, success_redirect_url: nil) } + + it "returns the default success redirect url" do + expect(success_redirect_url).to eq(PaymentProviders::GocardlessProvider::SUCCESS_REDIRECT_URL) + end + end + end +end diff --git a/spec/services/payment_provider_customers/moneyhash_service_spec.rb b/spec/services/payment_provider_customers/moneyhash_service_spec.rb new file mode 100644 index 0000000..5fa3763 --- /dev/null +++ b/spec/services/payment_provider_customers/moneyhash_service_spec.rb @@ -0,0 +1,437 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::MoneyhashService do + subject(:moneyhash_service) { described_class.new(moneyhash_customer) } + + let(:customer) { create(:customer, name: customer_name, organization:) } + let(:moneyhash_provider) { create(:moneyhash_provider) } + let(:organization) { moneyhash_provider.organization } + let(:customer_name) { nil } + + let(:moneyhash_customer) do + create(:moneyhash_customer, customer:, provider_customer_id: nil, payment_provider: moneyhash_provider) + end + + describe "#create" do + context "when provider_customer_id is already present" do + before { + moneyhash_customer.update(provider_customer_id: SecureRandom.uuid) + allow(moneyhash_service).to receive(:create_moneyhash_customer) # rubocop:disable RSpec/SubjectStub + } + + it "does not call moneyhash customers API" do + result = moneyhash_service.create + expect(result).to be_success + expect(moneyhash_service).not_to have_received(:create_moneyhash_customer) # rubocop:disable RSpec/SubjectStub + end + end + + context "when provider_customer_id is not present" do + let(:moneyhash_result) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/create_customer.json"))) } + let(:checkout_url_response) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/checkout_url_response.json"))) } + + let(:response) { instance_double(Net::HTTPOK) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:endpoint) { "#{PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/" } + + before do + allow(moneyhash_service).to receive(:create_moneyhash_customer).and_return(moneyhash_result) # rubocop:disable RSpec/SubjectStub + allow(moneyhash_service).to receive(:deliver_success_webhook) # rubocop:disable RSpec/SubjectStub + + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(checkout_url_response.to_json) + end + + it "creates the moneyhash customer, checkout_url, and sends a success webhook" do + result = moneyhash_service.create + expect(result).to be_success + expect(moneyhash_customer.reload.provider_customer_id).to eq(moneyhash_result["data"]["id"]) + expect(result.checkout_url).to eq("#{checkout_url_response["data"]["embed_url"]}?lago_request=generate_checkout_url") + expect(moneyhash_service).to have_received(:create_moneyhash_customer) # rubocop:disable RSpec/SubjectStub + expect(moneyhash_service).to have_received(:deliver_success_webhook) # rubocop:disable RSpec/SubjectStub + end + end + + describe "#update" do + it "returns a success result" do + result = moneyhash_service.update + expect(result).to be_success + end + end + + describe "#update_payment_method" do + let(:custom_fields) do + { + lago_mit: false, + lago_mh_service: "Invoices::Payments::MoneyhashService", + lago_payable_id: "b4e7e786-7716-4ca1-940d-3606ef971413", + lago_customer_id: "36cfbd82-167d-448e-8c0b-63269347f8ac", + lago_payable_type: "Invoice", + lago_organization_id: "1f6edf98-9eb4-4baf-8c64-7c6be9d0b414" + }.with_indifferent_access + end + + let(:payment_method_id) { SecureRandom.uuid } + + let(:moneyhash_customer) do + create(:moneyhash_customer, customer:, provider_customer_id: SecureRandom.uuid) + end + + it "updates the payment method for existing customer" do + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + expect(result).to be_success + expect(moneyhash_customer.reload.payment_method_id).to eq(payment_method_id) + end + + it "deletes the payment method for existing customer" do + moneyhash_customer.update(payment_method_id: SecureRandom.uuid) + + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: nil + ) + expect(result).to be_success + expect(moneyhash_customer.reload.payment_method_id).to be_nil + end + + it "overrides the payment method for existing customer" do + moneyhash_customer.update(payment_method_id: SecureRandom.uuid) + + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + expect(result).to be_success + expect(moneyhash_customer.reload.payment_method_id).to eq(payment_method_id) + end + + it "returns result directly when lago_customer_id is not present" do + custom_fields.delete("lago_customer_id") + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_success + end + + it "returns result directly when customer is not found" do + custom_fields["lago_customer_id"] = SecureRandom.uuid + + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: SecureRandom.uuid, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_success + end + + it "returns a failure when moneyhash customer is not found" do + customer = create(:customer, organization:) + custom_fields["lago_customer_id"] = customer.id + + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_failure + expect(result.error.to_s).to eq("moneyhash_customer_not_found") + end + + context "when multiple_payment_methods feature flag is enabled" do + before { organization.update!(feature_flags: ["multiple_payment_methods"]) } + + it "creates a PaymentMethod record" do + expect { + moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + }.to change(PaymentMethod, :count).by(1) + + payment_method = PaymentMethod.last + expect(payment_method).to have_attributes( + customer: customer, + payment_provider_customer: moneyhash_customer, + provider_method_id: payment_method_id, + provider_method_type: "card", + is_default: true + ) + end + + it "returns the payment_method in the result" do + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_success + expect(result.payment_method).to be_present + expect(result.payment_method.provider_method_id).to eq(payment_method_id) + end + + context "when card_details is provided" do + let(:card_details) do + { + brand: "Visa", + last4: "4242", + expiration_month: "12", + expiration_year: "25", + card_holder_name: "John Doe" + } + end + + it "stores card details in PaymentMethod.details" do + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields, + card_details: card_details + ) + + expect(result).to be_success + expect(result.payment_method.details).to include( + "brand" => "Visa", + "last4" => "4242", + "expiration_month" => "12", + "expiration_year" => "25", + "card_holder_name" => "John Doe" + ) + end + end + + context "when card_details is empty" do + it "does not call UpdateDetailsService" do + allow(PaymentMethods::UpdateDetailsService).to receive(:call) + + moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields, + card_details: {} + ) + + expect(PaymentMethods::UpdateDetailsService).not_to have_received(:call) + end + end + + it "finds existing PaymentMethod instead of creating duplicate" do + existing_payment_method = create( + :payment_method, + customer:, + payment_provider_customer: moneyhash_customer, + provider_method_id: payment_method_id, + is_default: false + ) + + expect { + moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + }.not_to change(PaymentMethod, :count) + + expect(existing_payment_method.reload.is_default).to be(true) + end + + it "does not create PaymentMethod when payment_method_id is nil" do + expect { + moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: nil, + metadata: custom_fields + ) + }.not_to change(PaymentMethod, :count) + end + end + + context "when multiple_payment_methods feature flag is disabled" do + it "does not create a PaymentMethod record" do + expect { + moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + }.not_to change(PaymentMethod, :count) + end + + it "still updates the legacy payment_method_id" do + result = moneyhash_service.update_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_success + expect(moneyhash_customer.reload.payment_method_id).to eq(payment_method_id) + expect(result.payment_method).to be_nil + end + end + end + + describe "#generate_checkout_url currency fallback" do + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:checkout_url_response) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/checkout_url_response.json"))) } + let(:response) { instance_double(Net::HTTPOK) } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with("#{PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/") + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(checkout_url_response.to_json) + end + + context "when customer has no currency" do + let(:customer) { create(:customer, name: customer_name, organization:, currency: nil) } + + it "falls back to the organization default currency" do + moneyhash_service.generate_checkout_url(send_webhook: false) + expect(lago_client).to have_received(:post_with_response).with( + hash_including(amount_currency: organization.default_currency), + anything + ) + end + end + end + + describe "#delete_payment_method" do + let(:custom_fields) do + { + lago_customer_id: customer.id + }.with_indifferent_access + end + + let(:payment_method_id) { SecureRandom.uuid } + + let(:moneyhash_customer) do + create(:moneyhash_customer, customer:, provider_customer_id: SecureRandom.uuid, payment_method_id:) + end + + it "clears the default payment_method_id when it matches" do + result = moneyhash_service.delete_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_success + expect(moneyhash_customer.reload.payment_method_id).to be_nil + end + + it "does not clear payment_method_id when it does not match" do + other_payment_method_id = SecureRandom.uuid + + result = moneyhash_service.delete_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: other_payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_success + expect(moneyhash_customer.reload.payment_method_id).to eq(payment_method_id) + end + + context "when multiple_payment_methods feature flag is enabled" do + let!(:payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer: moneyhash_customer, + provider_method_id: payment_method_id + ) + end + + before do + organization.update!(feature_flags: ["multiple_payment_methods"]) + end + + it "soft-deletes the PaymentMethod record" do + expect { + moneyhash_service.delete_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + }.to change { payment_method.reload.discarded? }.from(false).to(true) + end + + it "does not fail when PaymentMethod record does not exist" do + payment_method.destroy! + + result = moneyhash_service.delete_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_success + end + end + + context "when multiple_payment_methods feature flag is disabled" do + it "does not call DestroyService" do + allow(PaymentMethods::DestroyService).to receive(:call) + + moneyhash_service.delete_payment_method( + organization_id: organization.id, + customer_id: customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(PaymentMethods::DestroyService).not_to have_received(:call) + end + end + + it "returns a failure when moneyhash customer is not found" do + other_customer = create(:customer, organization:) + custom_fields["lago_customer_id"] = other_customer.id + + result = moneyhash_service.delete_payment_method( + organization_id: organization.id, + customer_id: other_customer.id, + payment_method_id: payment_method_id, + metadata: custom_fields + ) + + expect(result).to be_failure + expect(result.error.to_s).to eq("moneyhash_customer_not_found") + end + end + end +end diff --git a/spec/services/payment_provider_customers/stripe/check_payment_method_service_spec.rb b/spec/services/payment_provider_customers/stripe/check_payment_method_service_spec.rb new file mode 100644 index 0000000..9b6663e --- /dev/null +++ b/spec/services/payment_provider_customers/stripe/check_payment_method_service_spec.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::Stripe::CheckPaymentMethodService do + subject(:check_service) { described_class.new(stripe_customer:, payment_method_id:) } + + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider) } + let(:organization) { stripe_provider.organization } + + let(:stripe_customer) do + create( + :stripe_customer, + payment_provider: stripe_provider, + customer:, + provider_customer_id: "cus_123456", + payment_method_id: + ) + end + + let(:payment_method_id) { "card_12345" } + let(:payment_method) { Stripe::PaymentMethod.new(id: payment_method_id) } + let(:stripe_api_customer) { instance_double(Stripe::Customer) } + + before do + allow(Stripe::Customer).to receive(:new) + .and_return(stripe_api_customer) + end + + describe "#call" do + it "checks for the existence of the payment method" do + allow(stripe_api_customer) + .to receive(:retrieve_payment_method) + .and_return(payment_method) + + result = check_service.call + + expect(result).to be_success + expect(result.payment_method.id).to eq(payment_method_id) + + expect(Stripe::Customer).to have_received(:new) + expect(stripe_api_customer).to have_received(:retrieve_payment_method) + end + + context "when payment method is not found on stripe" do + before do + allow(stripe_api_customer) + .to receive(:retrieve_payment_method) + .and_raise(::Stripe::InvalidRequestError.new("error", {})) + end + + it "returns a failed result" do + result = check_service.call + + expect(result).not_to be_success + + expect(Stripe::Customer).to have_received(:new) + expect(stripe_api_customer).to have_received(:retrieve_payment_method) + end + + context "with multiple payment methods enabled" do + let(:default_payment_method) { create(:payment_method, customer:, provider_method_id: payment_method_id) } + + before do + default_payment_method + organization.update!(feature_flags: ["multiple_payment_methods"]) + end + + it "returns a failed result and discards payment method" do + result = check_service.call + + aggregate_failures do + expect(result).not_to be_success + + expect(Stripe::Customer).to have_received(:new) + expect(stripe_api_customer).to have_received(:retrieve_payment_method) + expect(default_payment_method.reload.deleted_at).to be_present + end + end + end + end + + context "when customer is deleted" do + let(:customer) { create(:customer, :deleted, organization:) } + + it "checks for the existence of the payment method" do + allow(stripe_api_customer) + .to receive(:retrieve_payment_method) + .and_return(payment_method) + + result = check_service.call + + expect(result).to be_success + expect(result.payment_method.id).to eq(payment_method_id) + + expect(Stripe::Customer).to have_received(:new) + expect(stripe_api_customer).to have_received(:retrieve_payment_method) + end + end + end +end diff --git a/spec/services/payment_provider_customers/stripe/retrieve_latest_payment_method_service_spec.rb b/spec/services/payment_provider_customers/stripe/retrieve_latest_payment_method_service_spec.rb new file mode 100644 index 0000000..2988235 --- /dev/null +++ b/spec/services/payment_provider_customers/stripe/retrieve_latest_payment_method_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::Stripe::RetrieveLatestPaymentMethodService do + subject { described_class.new(provider_customer:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:provider_customer_id) { "cus_Rw5Qso78STEap3" } + let(:provider_customer) { create(:stripe_customer, customer:, provider_customer_id:, payment_provider: create(:stripe_provider, organization:), payment_method_id: nil) } + + describe "#call" do + context "when customer has a default payment method in Stripe" do + it do + stub_request(:get, %r{/v1/customers/#{provider_customer_id}$}).and_return( + status: 200, body: get_stripe_fixtures("customer_retrieve_response.json") do |res| + res["invoice_settings"]["default_payment_method"] = "pm_123456" + end + ) + + result = subject.call + expect(result.payment_method_id).to eq "pm_123456" + end + end + + context "when customer has payment method in Stripe but no default" do + it do + stub_request(:get, %r{/v1/customers/#{provider_customer_id}$}).and_return( + status: 200, body: get_stripe_fixtures("customer_retrieve_response.json") + ) + stub_request(:get, %r{/v1/customers/#{provider_customer_id}/payment_methods}).and_return( + status: 200, body: get_stripe_fixtures("customer_list_payment_methods_response.json") + ) + + result = subject.call + expect(result.payment_method_id).to start_with "pm_" + end + end + + context "when customer has no payment method in Stripe" do + it do + stub_request(:get, %r{/v1/customers/#{provider_customer_id}$}).and_return( + status: 200, body: get_stripe_fixtures("customer_retrieve_response.json") + ) + stub_request(:get, %r{/v1/customers/#{provider_customer_id}/payment_methods}).and_return( + status: 200, body: get_stripe_fixtures("customer_list_payment_methods_empty_response.json") + ) + + result = subject.call + expect(result.payment_method_id).to be_nil + end + end + end +end diff --git a/spec/services/payment_provider_customers/stripe/sync_funding_instructions_service_spec.rb b/spec/services/payment_provider_customers/stripe/sync_funding_instructions_service_spec.rb new file mode 100644 index 0000000..aab1e23 --- /dev/null +++ b/spec/services/payment_provider_customers/stripe/sync_funding_instructions_service_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::Stripe::SyncFundingInstructionsService do + subject(:sync_funding_service) { described_class.new(stripe_customer) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, currency: "USD") } + let(:provider_customer_id) { "cus_Rw5Qso78STEap3" } + let(:stripe_customer) { + create(:stripe_customer, customer:, provider_payment_methods:, + provider_customer_id:, payment_provider:) + } + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:provider_payment_methods) { %w[customer_balance] } + + describe "#call" do + context "when customer is not eligible" do + let(:provider_payment_methods) { %w[card] } + + before do + allow(::Stripe::Customer).to receive(:create_funding_instructions) + end + + it "does not fetch Stripe funding instructions" do + sync_funding_service.call + expect(::Stripe::Customer).not_to have_received(:create_funding_instructions) + end + end + + context "when customer is eligible and everything is valid and section does not yet exist" do + let(:bank_transfer_data) { instance_double("BankTransfer", to_hash: {some: "details"}) } + let(:funding_instructions) { instance_double("FundingInstructions", bank_transfer: bank_transfer_data) } + + let(:formatter_service_result) { instance_double("FormatterResult", details: "formatted bank details") } + let(:invoice_custom_section) { create(:invoice_custom_section, organization:) } + + before do + allow(::Stripe::Customer).to receive(:create_funding_instructions).and_return(funding_instructions) + allow(InvoiceCustomSections::FundingInstructionsFormatterService).to receive(:call) + .and_return(formatter_service_result) + allow(InvoiceCustomSections::CreateService).to receive(:call) + .and_return(instance_double("CreateResult", invoice_custom_section: invoice_custom_section)) + allow(Customers::ManageInvoiceCustomSectionsService).to receive(:call) + allow(payment_provider).to receive(:secret_key).and_return("sk_test_123") + end + + it "creates the section and returns success" do + result = sync_funding_service.call + + expect(result).to be_success + expect(::Stripe::Customer).to have_received(:create_funding_instructions) + expect(InvoiceCustomSections::FundingInstructionsFormatterService).to have_received(:call) + expect(InvoiceCustomSections::CreateService).to have_received(:call) + expect(Customers::ManageInvoiceCustomSectionsService).to have_received(:call) + end + end + + context "when customer country is unsupported but billing entity country is supported" do + let(:billing_entity) { create(:billing_entity, organization:, country: "IE") } + let(:customer) { create(:customer, organization:, currency: "EUR", country: "SE", billing_entity:) } + let(:bank_transfer_data) { instance_double("BankTransfer", to_hash: {some: "details"}) } + let(:funding_instructions) { instance_double("FundingInstructions", bank_transfer: bank_transfer_data) } + let(:invoice_custom_section) { build_stubbed(:invoice_custom_section, organization:) } + + before do + allow(::Stripe::Customer).to receive(:create_funding_instructions).and_return(funding_instructions) + allow(InvoiceCustomSections::FundingInstructionsFormatterService).to receive(:call) + .and_return(instance_double("FormatterResult", details: "formatted")) + allow(InvoiceCustomSections::CreateService).to receive(:call) + .and_return(instance_double("CreateResult", invoice_custom_section: invoice_custom_section)) + allow(Customers::ManageInvoiceCustomSectionsService).to receive(:call) + allow(payment_provider).to receive(:secret_key).and_return("sk_test_123") + end + + it "uses billing entity country" do + sync_funding_service.call + + expect(::Stripe::Customer).to have_received(:create_funding_instructions).with( + provider_customer_id, + hash_including( + bank_transfer: { + type: "eu_bank_transfer", + eu_bank_transfer: {country: "IE"} + }, + currency: "eur", + funding_type: "bank_transfer" + ), + {api_key: "sk_test_123"} + ) + end + end + end +end diff --git a/spec/services/payment_provider_customers/stripe/update_payment_method_service_spec.rb b/spec/services/payment_provider_customers/stripe/update_payment_method_service_spec.rb new file mode 100644 index 0000000..624afec --- /dev/null +++ b/spec/services/payment_provider_customers/stripe/update_payment_method_service_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::Stripe::UpdatePaymentMethodService do + subject(:update_service) { described_class.new(stripe_customer:, payment_method_id:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:stripe_customer) { create(:stripe_customer, customer:) } + let(:payment_method_id) { "pm_123456" } + + describe "#call" do + it "updates the customer payment method" do + result = update_service.call + + expect(result).to be_success + expect(result.stripe_customer.payment_method_id).to eq(payment_method_id) + end + + context "with multiple_payment_methods feature flag" do + before do + organization.enable_feature_flag!(:multiple_payment_methods) + end + + context "without payment_method" do + it "creates a new one with provider_method_id" do + expect(customer.payment_methods.count).to eq(0) + result = update_service.call + + expect(result).to be_success + expect(result.payment_method.provider_method_id).to eq(payment_method_id) + expect(result.payment_method.is_default).to be_truthy + expect(customer.payment_methods.count).to eq(1) + end + end + + context "with existing payment_method" do + before do + create(:payment_method, customer:, payment_provider_customer: stripe_customer, provider_method_id: payment_method_id, is_default: false) + end + + it "set as default" do + result = update_service.call + + expect(result).to be_success + expect(result.payment_method.provider_method_id).to eq(payment_method_id) + expect(result.payment_method.is_default).to be_truthy + end + end + + context "when payment_method_id is nil" do + let(:payment_method_id) { nil } + + it "does not create a PaymentMethod" do + expect { update_service.call }.not_to change(PaymentMethod, :count) + end + end + end + + context "without multiple_payment_methods feature flag" do + it "does not create a PaymentMethod" do + expect { update_service.call }.not_to change(PaymentMethod, :count) + end + end + + context "with pending invoices" do + let(:invoice) do + create( + :invoice, + customer:, + total_amount_cents: 200, + currency: "EUR", + status:, + ready_for_payment_processing: + ) + end + + let(:status) { "finalized" } + let(:ready_for_payment_processing) { true } + + before { invoice } + + it "enqueues jobs to reprocess the pending payment" do + result = update_service.call + + expect(result).to be_success + expect(Invoices::Payments::CreateJob).to have_been_enqueued + .with(invoice:, payment_provider: :stripe) + end + + context "when invoices are not finalized" do + let(:status) { "draft" } + + it "does not enqueue jobs to reprocess pending payment" do + result = update_service.call + + expect(result).to be_success + expect(Invoices::Payments::CreateJob).not_to have_been_enqueued + end + end + + context "when invoices are not ready for payment processing" do + let(:ready_for_payment_processing) { "false" } + + it "does not enqueue jobs to reprocess pending payment" do + result = update_service.call + + expect(result).to be_success + expect(Invoices::Payments::CreateJob).not_to have_been_enqueued + end + end + end + + context "when customer is deleted" do + let(:customer) { create(:customer, organization:, deleted_at: Time.current) } + + it "Fails with deleted_customer error" do + stripe_customer.reload + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq(:deleted_customer) + expect(result.error.message).to include("Customer associated to this stripe customer was deleted") + end + end + end +end diff --git a/spec/services/payment_provider_customers/stripe_service_spec.rb b/spec/services/payment_provider_customers/stripe_service_spec.rb new file mode 100644 index 0000000..b79badb --- /dev/null +++ b/spec/services/payment_provider_customers/stripe_service_spec.rb @@ -0,0 +1,625 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::StripeService do + subject(:stripe_service) { described_class.new(stripe_customer) } + + let(:customer) { create(:customer, name: customer_name, organization:) } + let(:stripe_provider) { create(:stripe_provider) } + let(:organization) { stripe_provider.organization } + let(:customer_name) { nil } + + let(:stripe_customer) do + create(:stripe_customer, customer:, provider_customer_id: nil) + end + + describe "#create" do + context "when customer is deleted" do + before do + customer.discard! + stripe_customer.reload + end + + it "returns a deleted_customer failure" do + result = stripe_service.create + + expect(result).to be_success + expect(result.stripe_customer).to be_nil + end + end + + context "when customer name is present" do + let(:customer_name) { "Big inc" } + + it "creates a stripe customer with the customer name" do + allow(Stripe::Customer).to receive(:create) + .and_return(Stripe::Customer.new(id: "cus_123456")) + + expect do + stripe_service.create + end.to have_enqueued_job_after_commit(PaymentProviderCustomers::StripeCheckoutUrlJob).with(stripe_customer) + + expect(Stripe::Customer).to have_received(:create).with(hash_including(name: customer_name), anything) + end + end + + context "when stripe customer is created and has customer_balance payment method" do + before do + allow(Stripe::Customer).to receive(:create) + .and_return(Stripe::Customer.new(id: "cus_123456")) + + stripe_customer.update(provider_payment_methods: ["customer_balance"]) + end + + it "enqueues StripeSyncFundingInstructionsJob" do + stripe_service.create + + expect(PaymentProviderCustomers::StripeSyncFundingInstructionsJob) + .to have_been_enqueued.with(stripe_customer) + expect(PaymentProviderCustomers::StripeCheckoutUrlJob).not_to have_been_enqueued + end + end + + context "when customer name is not present" do + it "creates a stripe customer with the customer firstname and lastname" do + allow(Stripe::Customer).to receive(:create) + .and_return(Stripe::Customer.new(id: "cus_123456")) + stripe_service.create + + expected_name = "#{customer.firstname} #{customer.lastname}" + expect(Stripe::Customer).to have_received(:create).with(hash_including(name: expected_name), anything) + end + end + + it "creates the stripe customer" do + allow(Stripe::Customer).to receive(:create) + .and_return(Stripe::Customer.new(id: "cus_123456")) + + result = stripe_service.create + + expect(Stripe::Customer).to have_received(:create) + + expect(result.stripe_customer.provider_customer_id).to eq("cus_123456") + end + + it "delivers a success webhook" do + allow(Stripe::Customer).to receive(:create) + .and_return(Stripe::Customer.new(id: "cus_123456")) + + stripe_service.create + + expect(Stripe::Customer).to have_received(:create) + + expect(SendWebhookJob).to have_been_enqueued + .with("customer.payment_provider_created", customer) + end + + context "when customer already have a stripe customer id" do + let(:stripe_customer) do + create(:stripe_customer, customer:, provider_customer_id: "cus_123456") + end + + it "does not call stripe API" do + allow(Stripe::Customer).to receive(:create) + + stripe_service.create + + expect(Stripe::Customer).not_to have_received(:create) + end + end + + context "when no payment provider is connected" do + let(:stripe_customer) do + create(:stripe_customer, customer:, provider_customer_id: nil) + end + + before { stripe_provider.destroy! } + + it "does not call stripe API" do + allow(Stripe::Customer).to receive(:create) + + stripe_service.create + + expect(Stripe::Customer).not_to have_received(:create) + end + + it "returns success" do + allow(Stripe::Customer).to receive(:create) + + result = stripe_service.create + + expect(result).to be_success + end + end + + context "when payment provider has incorrect API key" do + before do + allow(Stripe::Customer).to receive(:create) + .and_raise(::Stripe::AuthenticationError.new("API key invalid.")) + end + + it "returns an unauthorized error" do + result = stripe_service.create + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::UnauthorizedFailure) + expect(result.error.message).to eq("Stripe authentication failed. API key invalid.") + end + + it "delivers an error webhook" do + expect { stripe_service.create }.to enqueue_job(SendWebhookJob) + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "API key invalid.", + error_code: nil + } + ).on_queue(webhook_queue) + end + end + + context "when failing to create the customer" do + it "delivers an error webhook" do + allow(Stripe::Customer).to receive(:create) + .and_raise(::Stripe::InvalidRequestError.new("error", {})) + + stripe_service.create + + expect(Stripe::Customer).to have_received(:create) + + expect(SendWebhookJob).to have_been_enqueued + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "error", + error_code: nil + } + ) + end + end + + context "with idempotency issue" do + before do + allow(Stripe::Customer).to receive(:create) + .and_raise(Stripe::IdempotencyError.new("idempotency")) + + allow(Stripe::Customer).to receive(:list) + .and_return([Stripe::Customer.new(id: "cus_123456")]) + end + + it "fetches the stripe customer from the API" do + result = stripe_service.create + + expect(result.stripe_customer.provider_customer_id).to eq("cus_123456") + end + end + end + + describe "#update" do + let(:stripe_customer) do + create(:stripe_customer, customer:, provider_customer_id:) + end + + before { stripe_customer } + + context "when stripe customer provider_customer_id is present" do + let(:provider_customer_id) { "cus_123456" } + + context "when stripe raises an error" do + before do + allow(Stripe::Customer).to receive(:update).and_raise(stripe_error) + end + + context "when stripe raises an invalid request error" do + let(:stripe_error) { ::Stripe::InvalidRequestError.new("Invalid request", nil) } + + it "returns an error result" do + result = stripe_service.update + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Stripe") + expect(result.error.error_code).to be_nil + expect(result.error.error_message).to eq("Invalid request") + end + + it "delivers an error webhook" do + expect { stripe_service.update }.to enqueue_job(SendWebhookJob) + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "Invalid request", + error_code: nil + } + ).on_queue(webhook_queue) + end + end + + context "when stripe raises a permission error" do + let(:stripe_error) { Stripe::PermissionError.new("Permission error") } + + it "returns an error result" do + result = stripe_service.update + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Stripe") + expect(result.error.error_code).to be_nil + expect(result.error.error_message).to eq("Permission error") + end + + it "delivers an error webhook" do + expect { stripe_service.update }.to enqueue_job(SendWebhookJob) + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "Permission error", + error_code: nil + } + ).on_queue(webhook_queue) + end + end + + context "when stripe raises an authentication error" do + let(:stripe_error) { ::Stripe::AuthenticationError.new("Invalid username.") } + + it "returns an error result" do + result = stripe_service.update + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::UnauthorizedFailure) + expect(result.error.message).to eq("Stripe authentication failed. Invalid username.") + end + + it "delivers an error webhook" do + expect { stripe_service.update }.to enqueue_job(SendWebhookJob) + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "Invalid username.", + error_code: nil + } + ).on_queue(webhook_queue) + end + end + end + + context "when no stripe error is raised" do + before do + allow(Stripe::Customer).to receive(:update).and_return(true) + end + + context "when stripe payment provider is present" do + it "calls stripe API" do + stripe_service.update + + expect(Stripe::Customer).to have_received(:update) + end + + it "returns a successful result" do + result = stripe_service.update + + expect(result).to be_success + end + + it "does not deliver an error webhook" do + expect { stripe_service.update }.not_to enqueue_job(SendWebhookJob) + end + end + + context "when stripe payment provider is not present" do + before { stripe_provider.destroy! } + + it "does not call stripe API" do + stripe_service.update + + expect(Stripe::Customer).not_to have_received(:update) + end + + it "returns a successful result" do + result = stripe_service.update + + expect(result).to be_success + end + + it "does not deliver an error webhook" do + expect { stripe_service.update }.not_to enqueue_job(SendWebhookJob) + end + end + end + end + + context "when updating a stripe customer with customer_balance method" do + let(:provider_customer_id) { "cus_123456" } + + before do + stripe_customer.update(provider_payment_methods: ["customer_balance"]) + allow(Stripe::Customer).to receive(:update).and_return(true) + end + + it "enqueues StripeSyncFundingInstructionsJob" do + stripe_service.update + + expect(PaymentProviderCustomers::StripeSyncFundingInstructionsJob) + .to have_been_enqueued.with(stripe_customer) + end + end + + context "when stripe customer provider_customer_id is not present" do + let(:provider_customer_id) { nil } + + before do + allow(Stripe::Customer).to receive(:update).and_return(true) + end + + context "when stripe payment provider is present" do + it "does not call stripe API" do + stripe_service.update + + expect(Stripe::Customer).not_to have_received(:update) + end + + it "returns a successful result" do + result = stripe_service.update + + expect(result).to be_success + end + + it "does not deliver an error webhook" do + expect { stripe_service.update }.not_to enqueue_job(SendWebhookJob) + end + end + + context "when stripe payment provider is not present" do + before { stripe_provider.destroy! } + + it "does not call stripe API" do + stripe_service.update + + expect(Stripe::Customer).not_to have_received(:update) + end + + it "returns a successful result" do + result = stripe_service.update + + expect(result).to be_success + end + + it "does not deliver an error webhook" do + expect { stripe_service.update }.not_to enqueue_job(SendWebhookJob) + end + end + end + end + + describe "#delete_payment_method" do + subject(:stripe_service) { described_class.new } + + let(:payment_method_id) { "card_12345" } + + let(:stripe_customer) do + create( + :stripe_customer, + customer:, + provider_customer_id: "cus_123456", + payment_method_id: + ) + end + + it "removes the customer payment method" do + result = stripe_service.delete_payment_method( + organization_id: organization.id, + stripe_customer_id: stripe_customer.provider_customer_id, + payment_method_id: + ) + + expect(result).to be_success + expect(result.stripe_customer.payment_method_id).to be_nil + end + + context "when customer payment method is not the deleted one" do + it "does not remove the customer payment method" do + result = stripe_service.delete_payment_method( + organization_id: organization.id, + stripe_customer_id: stripe_customer.provider_customer_id, + payment_method_id: "other_payment_method_id" + ) + + expect(result).to be_success + expect(result.stripe_customer.payment_method_id).to eq(payment_method_id) + end + end + + context "when multiple_payment_methods feature flag is enabled" do + let(:payment_method) do + create(:payment_method, customer:, provider_method_id: payment_method_id) + end + + before do + organization.enable_feature_flag!(:multiple_payment_methods) + payment_method + end + + it "discards the payment method" do + result = stripe_service.delete_payment_method( + organization_id: organization.id, + stripe_customer_id: stripe_customer.provider_customer_id, + payment_method_id: + ) + + expect(result).to be_success + expect(result.stripe_customer.payment_method_id).to be_nil + expect(result.payment_method).to eq(payment_method) + expect(payment_method.reload.discarded?).to be true + end + + context "when payment method does not exist" do + it "does not raise an error" do + result = stripe_service.delete_payment_method( + organization_id: organization.id, + stripe_customer_id: stripe_customer.provider_customer_id, + payment_method_id: "non_existent_pm" + ) + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + end + + context "when customer is not found" do + it "returns an empty result" do + result = stripe_service.delete_payment_method( + organization_id: organization.id, + stripe_customer_id: "cus_InvaLid", + payment_method_id: "pm_123456" + ) + + expect(result).to be_success + expect(result.stripe_customer).to be_nil + end + + context "when customer in metadata is not found" do + it "returns an empty response" do + result = stripe_service.delete_payment_method( + organization_id: organization.id, + stripe_customer_id: "cus_InvaLid", + payment_method_id: "pm_123456", + metadata: { + lago_customer_id: SecureRandom.uuid + } + ) + + expect(result).to be_success + expect(result.stripe_customer).to be_nil + end + end + + context "when customer in metadata exists" do + it "returns a not found error" do + result = stripe_service.delete_payment_method( + organization_id: organization.id, + stripe_customer_id: "cus_InvaLid", + payment_method_id: "pm_123456", + metadata: { + lago_customer_id: customer.id + } + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("stripe_customer_not_found") + end + end + end + end + + describe "#generate_checkout_url" do + it "delivers a webhook with checkout url" do + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example.com"}) + + stripe_service.generate_checkout_url + + expect(SendWebhookJob).to have_been_enqueued + .with("customer.checkout_url_generated", customer, checkout_url: "https://example.com") + end + + context "without any customer" do + let(:customer) { create(:customer, :deleted, organization:) } + + it "does not deliver a webhook" do + described_class.new(stripe_customer.reload).generate_checkout_url + + expect(SendWebhookJob).not_to have_been_enqueued + .with("customer.checkout_url_generated", customer, checkout_url: "https://example.com") + end + end + + context "when customer has no payment method to be setup" do + let(:stripe_customer) { create(:stripe_customer, customer:, provider_customer_id: nil, provider_payment_methods: %w[crypto]) } + + it "does not deliver a webhook" do + described_class.new(stripe_customer.reload).generate_checkout_url + + expect(SendWebhookJob).not_to have_been_enqueued + .with("customer.checkout_url_generated", customer, checkout_url: "https://example.com") + end + end + + context "when stripe raises an invalid request error" do + let(:stripe_error) { ::Stripe::InvalidRequestError.new("wrong request!", {}) } + + before { allow(::Stripe::Checkout::Session).to receive(:create).and_raise(stripe_error) } + + it "returns an error result" do + result = described_class.new(stripe_customer).generate_checkout_url + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.message).to eq("Stripe: - wrong request!") + end + end + + context "when stripe raises an authentication error" do + let(:stripe_error) { ::Stripe::AuthenticationError.new("Expired API Key provided") } + + before { allow(::Stripe::Checkout::Session).to receive(:create).and_raise(stripe_error) } + + it "returns an error result" do + result = described_class.new(stripe_customer).generate_checkout_url + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::UnauthorizedFailure) + expect(result.error.message).to eq("Stripe authentication failed. Expired API Key provided") + end + + it "delivers an error webhook" do + expect { described_class.new(stripe_customer).generate_checkout_url }.to enqueue_job(SendWebhookJob) + .with( + "customer.payment_provider_error", + customer, + provider_error: { + message: "Expired API Key provided", + error_code: nil + } + ).on_queue(webhook_queue) + end + end + + context "when payment methods do not require setup" do + let(:stripe_customer) { create(:stripe_customer, customer:, provider_customer_id: nil, provider_payment_methods: %w[crypto]) } + + it "returns an error result" do + result = described_class.new(stripe_customer).generate_checkout_url + + expect(result).not_to be_success + expect(result.error.messages).to eq(provider_payment_methods: ["no_payment_methods_to_setup_available"]) + end + end + end + + describe "#success_redirect_url" do + subject(:success_redirect_url) { stripe_service.__send__(:success_redirect_url) } + + context "when payment provider has success redirect url" do + it "returns payment provider's success redirect url" do + expect(success_redirect_url).to eq(stripe_provider.success_redirect_url) + end + end + + context "when payment provider has no success redirect url" do + let(:stripe_provider) { create(:stripe_provider, success_redirect_url: nil) } + + it "returns the default success redirect url" do + expect(success_redirect_url).to eq(PaymentProviders::StripeProvider::SUCCESS_REDIRECT_URL) + end + end + end +end diff --git a/spec/services/payment_provider_customers/update_service_spec.rb b/spec/services/payment_provider_customers/update_service_spec.rb new file mode 100644 index 0000000..cce52f3 --- /dev/null +++ b/spec/services/payment_provider_customers/update_service_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviderCustomers::UpdateService do + let(:customer) { create(:customer, payment_provider: provider_name.downcase) } + let(:payment_provider) { create(:stripe_provider, organization: customer.organization) } + let(:provider_name) { "Stripe" } + let(:provider_service_class) { "PaymentProviderCustomers::#{provider_name}Service".constantize } + let(:provider_service) { provider_service_class.new(provider_customer) } + let(:provider_customer) { create(:"#{provider_name.downcase}_customer", customer:) } + + before do + allow("PaymentProviderCustomers::#{provider_name}Service".constantize) + .to receive(:new) + .and_return(provider_service) + + allow(provider_service).to receive(:update).and_return(BaseService::Result.new) + allow(Stripe::Customer).to receive(:update).and_return(true) + end + + describe "#call" do + before { payment_provider } + + it "updates the provider customer" do + described_class.call(customer) + + expect(provider_service_class).to have_received(:new).with(provider_customer) + expect(provider_service).to have_received(:update) + end + end +end diff --git a/spec/services/payment_providers/adyen/customers/create_service_spec.rb b/spec/services/payment_providers/adyen/customers/create_service_spec.rb new file mode 100644 index 0000000..58be095 --- /dev/null +++ b/spec/services/payment_providers/adyen/customers/create_service_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Adyen::Customers::CreateService do + let(:create_service) { described_class.new(customer:, payment_provider_id:, params:, async:) } + + let(:customer) { create(:customer) } + let(:adyen_provider) { create(:adyen_provider, organization: customer.organization) } + let(:payment_provider_id) { adyen_provider.id } + let(:params) { {provider_customer_id: "id", sync_with_provider: true} } + let(:async) { true } + + describe ".call" do + it "creates a payment_provider_customer" do + result = create_service.call + + expect(result).to be_success + expect(result.provider_customer).to be_present + expect(result.provider_customer.provider_customer_id).to eq("id") + end + + context "when no provider customer id and should create on service" do + let(:params) do + {provider_customer_id: nil, sync_with_provider: true} + end + + it "enqueues a job to create the customer on the provider" do + expect { create_service.call }.to have_enqueued_job(PaymentProviderCustomers::AdyenCreateJob) + end + end + + context "when removing the provider customer id and should create on service" do + let(:params) do + {provider_customer_id: nil, sync_with_provider: true} + end + + let(:adyen_customer) do + create( + :adyen_customer, + customer:, + payment_provider: adyen_provider + ) + end + + before { adyen_customer } + + it "updates the provider customer" do + expect do + result = create_service.call + + expect(result).to be_success + + expect(result.provider_customer.provider_customer_id).to be_nil + end.not_to have_enqueued_job(PaymentProviderCustomers::AdyenCreateJob) + end + end + + context "when provider customer id is set" do + let(:params) do + {provider_customer_id: "id", sync_with_provider:} + end + + before do + allow(create_service).to receive(:generate_checkout_url).and_return(true) + allow(create_service).to receive(:create_customer_on_provider_service).and_return(true) + end + + context "when sync with provider is blank" do + let(:sync_with_provider) { nil } + let(:provider) { create(:adyen_provider, organization: customer.organization) } + + context "when provider customer exists" do + before do + create(:adyen_customer, customer:, payment_provider_id: provider.id) + end + + it "generates checkout url" do + create_service.call + expect(create_service).to have_received(:generate_checkout_url) + end + + it "does not create customer" do + create_service.call + expect(create_service).not_to have_received(:create_customer_on_provider_service) + end + end + + context "when provider customer does not exist" do + it "does not generate checkout url" do + create_service.call + expect(create_service).not_to have_received(:generate_checkout_url) + end + + it "does not create customer" do + create_service.call + expect(create_service).not_to have_received(:create_customer_on_provider_service) + end + end + end + + context "when sync with provider is true" do + let(:sync_with_provider) { true } + let(:provider) { create(:adyen_provider, organization: customer.organization) } + + it "does not generate checkout url" do + create_service.call + expect(create_service).not_to have_received(:generate_checkout_url) + end + + it "does not enqueue a job to create the customer on the provider" do + expect { create_service.call }.not_to enqueue_job(PaymentProviderCustomers::AdyenCreateJob) + end + end + end + end +end diff --git a/spec/services/payment_providers/adyen/handle_event_service_spec.rb b/spec/services/payment_providers/adyen/handle_event_service_spec.rb new file mode 100644 index 0000000..2bba7ab --- /dev/null +++ b/spec/services/payment_providers/adyen/handle_event_service_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Adyen::HandleEventService do + subject(:event_service) { described_class.new(organization:, event_json:) } + + let(:organization) { create(:organization) } + + describe "#call" do + let(:payment_service) { instance_double(Invoices::Payments::AdyenService) } + let(:payment_provider_service) { instance_double(PaymentProviderCustomers::AdyenService) } + let(:service_result) { BaseService::Result.new } + + before do + allow(Invoices::Payments::AdyenService).to receive(:new) + .and_return(payment_service) + allow(PaymentProviderCustomers::AdyenService).to receive(:new) + .and_return(payment_provider_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + allow(payment_provider_service).to receive(:preauthorise) + .and_return(service_result) + end + + context "when succeeded authorisation event" do + let(:event_json) do + JSON.parse(event_response_json)["notificationItems"] + .first&.dig("NotificationRequestItem").to_json + end + + let(:event_response_json) do + path = Rails.root.join("spec/fixtures/adyen/webhook_authorisation_response.json") + File.read(path) + end + + it "routes the event to an other service" do + event_service.call + + expect(PaymentProviderCustomers::AdyenService).to have_received(:new) + expect(payment_provider_service).to have_received(:preauthorise) + end + end + + context "when succeeded authorisation event for processed one-time payment" do + let(:event_json) do + JSON.parse(event_response_json)["notificationItems"] + .first&.dig("NotificationRequestItem").to_json + end + + let(:event_response_json) do + path = Rails.root.join("spec/fixtures/adyen/webhook_authorisation_payment_response.json") + File.read(path) + end + + it "routes the event to an other service" do + event_service.call + + expect(Invoices::Payments::AdyenService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when succeeded authorisation event for processed one-time payment belonging to a Payment Request" do + let(:payment_service) { instance_double(PaymentRequests::Payments::AdyenService) } + + let(:event_json) do + JSON.parse(event_response_json)["notificationItems"] + .first&.dig("NotificationRequestItem").to_json + end + + let(:event_response_json) do + path = Rails.root.join("spec/fixtures/adyen/webhook_authorisation_payment_response_payment_request.json") + File.read(path) + end + + before do + allow(PaymentRequests::Payments::AdyenService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + end + + it "routes the event to an other service" do + event_service.call + + expect(PaymentRequests::Payments::AdyenService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when succeeded authorisation event for processed one-time payment belonging to an invalid payable type" do + let(:event_json) do + JSON.parse(event_response_json)["notificationItems"] + .first&.dig("NotificationRequestItem").to_json + end + + let(:event_response_json) do + path = Rails.root.join("spec/fixtures/adyen/webhook_authorisation_payment_response_invalid_payable.json") + File.read(path) + end + + it "routes the event to an other service" do + expect { + event_service.call + }.to raise_error(NameError, "Invalid lago_payable_type: InvalidPayableTypeName") + end + end + + context "when succeeded refund event" do + let(:refund_service) { instance_double(CreditNotes::Refunds::AdyenService) } + + let(:event_json) do + JSON.parse(event_response_json)["notificationItems"] + .first&.dig("NotificationRequestItem").to_json + end + + let(:event_response_json) do + path = Rails.root.join("spec/fixtures/adyen/webhook_refund_response.json") + File.read(path) + end + + before do + allow(CreditNotes::Refunds::AdyenService).to receive(:new) + .and_return(refund_service) + allow(refund_service).to receive(:update_status) + .and_return(service_result) + end + + it "routes the event to an other service" do + event_service.call + + expect(CreditNotes::Refunds::AdyenService).to have_received(:new) + expect(refund_service).to have_received(:update_status) + end + end + + context "when ignored event" do + let(:refund_service) { instance_double(CreditNotes::Refunds::AdyenService) } + let(:event_json) do + JSON.parse(event_response_json)["notificationItems"] + .first&.dig("NotificationRequestItem").to_json + end + + %w[report_available recurring_contract offer_closed].each do |event_type| + let(:event_response_json) do + path = Rails.root.join("spec/fixtures/adyen/webhook_#{event_type}_response.json") + File.read(path) + end + + before do + allow(CreditNotes::Refunds::AdyenService).to receive(:new) + .and_return(refund_service) + allow(refund_service).to receive(:update_status) + .and_return(true) + end + + it "does not route the event to an other service" do + event_service.call + + expect(CreditNotes::Refunds::AdyenService).not_to have_received(:new) + expect(refund_service).not_to have_received(:update_status) + end + end + end + end +end diff --git a/spec/services/payment_providers/adyen/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/adyen/handle_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..f7a564c --- /dev/null +++ b/spec/services/payment_providers/adyen/handle_incoming_webhook_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Adyen::HandleIncomingWebhookService do + let(:webhook_service) { described_class.new(organization_id:, body:, code:) } + + let(:organization) { create(:organization) } + let(:organization_id) { organization.id } + let(:adyen_provider) { create(:adyen_provider, organization:, hmac_key: nil) } + let(:code) { nil } + + let(:body) do + JSON.parse(event_response_json)["notificationItems"].first&.dig("NotificationRequestItem") + end + + let(:event_response_json) do + path = Rails.root.join("spec/fixtures/adyen/webhook_authorisation_response.json") + File.read(path) + end + + before { adyen_provider } + + describe "#call" do + it "checks the webhook" do + result = webhook_service.call + + expect(result).to be_success + + expect(result.event).to eq(body) + expect(PaymentProviders::Adyen::HandleEventJob).to have_been_enqueued + end + + context "when organization does not exist" do + let(:organization_id) { "123456789" } + + it "returns an error" do + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Organization not found") + end + end + + context "when payment provider does not exist" do + let(:adyen_provider) { nil } + + it "returns an error" do + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Payment provider not found") + end + end + + context "when failing to validate the signature" do + let(:adyen_provider) { create(:adyen_provider, organization:, hmac_key: "123") } + + it "returns an error" do + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Invalid signature") + end + end + + context "when multiple payment providers exists and no code is provided" do + before { create(:adyen_provider, organization:) } + + it "returns an error" do + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Payment provider code is missing") + end + end + end +end diff --git a/spec/services/payment_providers/adyen/payments/create_service_spec.rb b/spec/services/payment_providers/adyen/payments/create_service_spec.rb new file mode 100644 index 0000000..6239cc2 --- /dev/null +++ b/spec/services/payment_providers/adyen/payments/create_service_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Adyen::Payments::CreateService do + subject(:create_service) { described_class.new(payment:, reference:, metadata:) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:adyen_payment_provider) { create(:adyen_provider, organization:, code:) } + let(:adyen_customer) { create(:adyen_customer, customer:, payment_provider: adyen_payment_provider) } + let(:adyen_client) { instance_double(Adyen::Client) } + let(:payments_api) { Adyen::PaymentsApi.new(adyen_client, 70) } + let(:payment_links_api) { Adyen::PaymentLinksApi.new(adyen_client, 70) } + let(:payment_links_response) { generate(:adyen_payment_links_response) } + let(:checkout) { Adyen::Checkout.new(adyen_client, 70) } + let(:payments_response) { generate(:adyen_payments_response) } + let(:payment_methods_response) { generate(:adyen_payment_methods_response) } + let(:code) { "adyen_1" } + let(:reference) { "organization.name - Invoice #{invoice.number}" } + let(:metadata) do + { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + end + let(:payment_method) { nil } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 1000, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:payment) do + create( + :payment, + payable: invoice, + status: "pending", + payment_provider: adyen_payment_provider, + payment_provider_customer: adyen_customer, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency, + payment_method: + ) + end + + describe "#call" do + before do + adyen_payment_provider + adyen_customer + + allow(::Adyen::Client).to receive(:new) + .and_return(adyen_client) + allow(adyen_client).to receive(:checkout) + .and_return(checkout) + allow(checkout).to receive(:payments_api) + .and_return(payments_api) + allow(payments_api).to receive(:payments) + .and_return(payments_response) + allow(payments_api).to receive(:payment_methods) + .and_return(payment_methods_response) + end + + it "creates an adyen payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(adyen_payment_provider) + expect(result.payment.payment_provider_customer).to eq(adyen_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("Authorised") + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(adyen_customer.reload.payment_method_id) + .to eq(payment_methods_response.response["storedPaymentMethods"].first["id"]) + + expect(payments_api).to have_received(:payments) + end + + context "when payment has a payment method" do + let(:payment_method) { create(:payment_method, payment_provider_customer: adyen_customer, provider_method_id: "pm_test") } + + before do + organization.enable_feature_flag!(:multiple_payment_methods) + end + + it "uses payment method provider id" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.id).to be_present + expect(payments_api).to have_received(:payments) + .with( + hash_including( + paymentMethod: hash_including(storedPaymentMethodId: "pm_test") + ), anything + ) + end + end + + context "with error response from adyen" do + let(:payments_error_response) { generate(:adyen_payments_error_response) } + + before do + allow(payments_api).to receive(:payments).and_return(payments_error_response) + end + + it "returns a failed result" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("adyen_error") + expect(result.error.error_message) + .to eq("validation: There are no payment methods available for the given parameters.") + + expect(result.error_message).to eq("There are no payment methods available for the given parameters.") + expect(result.error_code).to eq("validation") + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + + context "with validation error on adyen" do + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + + let(:subscription) do + create(:subscription, organization:, customer:) + end + + let(:organization) do + create(:organization, webhook_url: "https://webhook.com") + end + + before do + subscription + end + + context "when changing payment method fails with invalid card" do + before do + allow(payments_api).to receive(:payment_methods) + .and_raise(Adyen::ValidationError.new("Invalid card number", nil)) + end + + it "returns a failed result" do + result = create_service.call + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("adyen_error") + expect(result.error.error_message).to eq(": Invalid card number") + + expect(result.error_message).to eq("Invalid card number") + expect(result.error_code).to be_nil + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + + context "when payment fails with invalid card" do + before do + allow(payments_api).to receive(:payments) + .and_raise(Adyen::ValidationError.new("Invalid card number", nil)) + end + + it "returns a success result with error messages" do + result = create_service.call + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("adyen_error") + expect(result.error.error_message).to eq(": Invalid card number") + + expect(result.error_message).to eq("Invalid card number") + expect(result.error_code).to be_nil + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + end + + context "with error on adyen" do + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + + let(:subscription) do + create(:subscription, organization:, customer:) + end + + let(:organization) do + create(:organization, webhook_url: "https://webhook.com") + end + + before do + subscription + + allow(payments_api).to receive(:payments) + .and_raise(Adyen::AdyenError.new(nil, nil, "error", "code")) + end + + it "returns a failed result" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("adyen_error") + expect(result.error.error_message).to eq("code: error") + + expect(result.error_message).to eq("error") + expect(result.error_code).to eq("code") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + end +end diff --git a/spec/services/payment_providers/adyen/webhooks/chargeback_service_spec.rb b/spec/services/payment_providers/adyen/webhooks/chargeback_service_spec.rb new file mode 100644 index 0000000..55344fd --- /dev/null +++ b/spec/services/payment_providers/adyen/webhooks/chargeback_service_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Adyen::Webhooks::ChargebackService do + subject(:service) { described_class.new(organization_id:, event_json:) } + + let(:organization_id) { organization.id } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:customer) { create(:customer, organization:) } + let(:payment) { create(:payment, payable: invoice, provider_payment_id: "9915555555555555") } + let(:invoice) { create(:invoice, customer:, organization:, status:, payment_status: "succeeded") } + + describe "#call" do + before { payment } + + context "when dispute is lost" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/adyen/chargeback_lost_event.json") + File.read(path) + end + + context "when invoice is draft" do + let(:status) { "draft" } + + it "does not updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.not_to change(payment.payable.reload, :payment_dispute_lost_at).from(nil) + end + + it "does not deliver webhook" do + expect { service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when invoice is finalized" do + let(:status) { "finalized" } + + it "updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.to change(payment.payable, :payment_dispute_lost_at).from(nil) + end + + it "delivers a webhook" do + expect do + service.call + payment.payable.reload + end.to have_enqueued_job(SendWebhookJob).with( + "invoice.payment_dispute_lost", + payment.payable, + provider_error: "Merchandise/Services Not Received" + ) + end + end + end + + context "when dispute is won" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/adyen/chargeback_won_event.json") + File.read(path) + end + + context "when invoice is draft" do + let(:status) { "draft" } + + it "does not updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.not_to change(payment.payable.reload, :payment_dispute_lost_at).from(nil) + end + + it "does not deliver webhook" do + expect { service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when invoice is finalized" do + let(:status) { "finalized" } + + it "does not updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.not_to change(payment.payable.reload, :payment_dispute_lost_at).from(nil) + end + + it "does not deliver webhook" do + expect { service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + end + end +end diff --git a/spec/services/payment_providers/adyen_service_spec.rb b/spec/services/payment_providers/adyen_service_spec.rb new file mode 100644 index 0000000..a4e6a80 --- /dev/null +++ b/spec/services/payment_providers/adyen_service_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::AdyenService do + subject(:adyen_service) { described_class.new(membership.user) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:api_key) { "test_api_key_1" } + let(:code) { "code_1" } + let(:name) { "Name 1" } + let(:merchant_account) { "LagoMerchant" } + let(:success_redirect_url) { Faker::Internet.url } + + describe ".create_or_update" do + it "creates an adyen provider" do + expect do + adyen_service.create_or_update(organization:, api_key:, code:, name:, merchant_account:, success_redirect_url:) + end.to change(PaymentProviders::AdyenProvider, :count).by(1) + end + + it_behaves_like "produces a security log", "integration.created" do + before { adyen_service.create_or_update(organization:, api_key:, code:, name:, merchant_account:, success_redirect_url:) } + end + + context "when code was changed" do + let(:new_code) { "updated_code_1" } + let(:adyen_customer) { create(:adyen_customer, payment_provider:, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:payment_provider) do + create( + :adyen_provider, + organization:, + code:, + name:, + api_key: "secret" + ) + end + + before { adyen_customer } + + it "updates payment provider codes of all customers" do + result = adyen_service.create_or_update( + id: payment_provider.id, + organization:, + code: new_code, + name:, + api_key: "secret" + ) + + expect(result).to be_success + expect(result.adyen_provider.customers.first.payment_provider_code).to eq(new_code) + end + end + + context "when organization already has an adyen provider" do + let(:adyen_provider) do + create(:adyen_provider, organization:, api_key: "api_key_789", code:) + end + + before { adyen_provider } + + it "updates the existing provider" do + result = adyen_service.create_or_update( + organization:, + api_key:, + code:, + name:, + success_redirect_url: + ) + + expect(result).to be_success + + expect(result.adyen_provider.id).to eq(adyen_provider.id) + expect(result.adyen_provider.api_key).to eq("test_api_key_1") + expect(result.adyen_provider.code).to eq(code) + expect(result.adyen_provider.name).to eq(name) + expect(result.adyen_provider.success_redirect_url).to eq(success_redirect_url) + end + + it_behaves_like "produces a security log", "integration.updated" do + before do + adyen_service.create_or_update( + organization:, + api_key:, + code:, + name:, + success_redirect_url: + ) + end + end + end + + context "with validation error" do + let(:token) { nil } + + it "returns an error result" do + result = adyen_service.create_or_update( + organization:, + api_key: nil, + merchant_account: nil + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:api_key]).to eq(["value_is_mandatory"]) + expect(result.error.messages[:merchant_account]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/payment_providers/cashfree/customers/create_service_spec.rb b/spec/services/payment_providers/cashfree/customers/create_service_spec.rb new file mode 100644 index 0000000..ee0a34a --- /dev/null +++ b/spec/services/payment_providers/cashfree/customers/create_service_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::Customers::CreateService do + let(:create_service) { described_class.new(customer:, payment_provider_id:, params:, async:) } + + let(:customer) { create(:customer) } + let(:cashfree_provider) { create(:cashfree_provider, organization: customer.organization) } + let(:payment_provider_id) { cashfree_provider.id } + let(:params) { {provider_customer_id: "id", sync_with_provider: true} } + let(:async) { true } + + describe ".call" do + it "creates a payment_provider_customer without provider_customer_id" do + result = create_service.call + + expect(result).to be_success + expect(result.provider_customer).to be_present + expect(result.provider_customer.provider_customer_id).to be_nil + end + end +end diff --git a/spec/services/payment_providers/cashfree/handle_event_service_spec.rb b/spec/services/payment_providers/cashfree/handle_event_service_spec.rb new file mode 100644 index 0000000..65f80aa --- /dev/null +++ b/spec/services/payment_providers/cashfree/handle_event_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::HandleEventService do + subject(:event_service) { described_class.new(organization:, event_json:) } + + let(:organization) { create(:organization) } + + let(:payment_service) { instance_double(Invoices::Payments::CashfreeService) } + let(:service_result) { BaseService::Result.new } + + let(:event_json) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment.json") + File.read(path) + end + + describe ".call" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment_request.json") + File.read(path) + end + + before do + allow(PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService).to receive(:call) + .and_return(service_result) + end + + it "routes the event to an other service" do + expect(event_service.call).to be_success + expect(PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService).to have_received(:call) + end + end +end diff --git a/spec/services/payment_providers/cashfree/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/cashfree/handle_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..6cccf26 --- /dev/null +++ b/spec/services/payment_providers/cashfree/handle_incoming_webhook_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::HandleIncomingWebhookService do + let(:webhook_service) { described_class.new(organization_id:, body:, code:, timestamp:, signature:) } + + let(:organization) { create(:organization) } + let(:organization_id) { organization.id } + let(:cashfree_provider) { create(:cashfree_provider, organization:, client_secret:) } + let(:client_secret) { "cfsk_ma_prod_abc_123456" } + let(:code) { nil } + let(:timestamp) { "1629271506" } + let(:signature) { "MFB3Rkubs4jB97ROS/I4iu9llAAP5ykJ3GZYp95o/Mw=" } + + let(:body) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment.json") + JSON.parse(File.read(path)).to_json # NOTE: Ensure valid sha256 signature + end + + before { cashfree_provider } + + describe "#call" do + it "checks the webhook" do + result = webhook_service.call + + expect(result).to be_success + + expect(PaymentProviders::Cashfree::HandleEventJob).to have_been_enqueued + end + + context "when failing to validate the signature" do + let(:signature) { "signature" } + + it "returns an error" do + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Invalid signature") + end + end + end +end diff --git a/spec/services/payment_providers/cashfree/payments/create_service_spec.rb b/spec/services/payment_providers/cashfree/payments/create_service_spec.rb new file mode 100644 index 0000000..f64f6a8 --- /dev/null +++ b/spec/services/payment_providers/cashfree/payments/create_service_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::Payments::CreateService do + subject(:create_service) { described_class.new(payment:) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:chasfree_payment_provider) { create(:cashfree_provider, organization:, code:) } + let(:cashfree_customer) { create(:cashfree_customer, customer:, payment_provider: chasfree_payment_provider) } + let(:code) { "stripe_1" } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + let(:payment) do + create( + :payment, + payable: invoice, + status: "pending", + payable_payment_status: "pending", + payment_provider: chasfree_payment_provider, + payment_provider_customer: cashfree_customer, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency + ) + end + + describe ".call" do + before do + chasfree_payment_provider + cashfree_customer + end + + it "returns the payment and keeps it pending" do + result = create_service.call + + expect(result).to be_success + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(chasfree_payment_provider) + expect(result.payment.payment_provider_customer).to eq(cashfree_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("pending") + expect(result.payment.payable_payment_status).to eq("pending") + end + end +end diff --git a/spec/services/payment_providers/cashfree/webhooks/payment_link_event_service_spec.rb b/spec/services/payment_providers/cashfree/webhooks/payment_link_event_service_spec.rb new file mode 100644 index 0000000..83b240a --- /dev/null +++ b/spec/services/payment_providers/cashfree/webhooks/payment_link_event_service_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Cashfree::Webhooks::PaymentLinkEventService do + subject(:webhook_service) { described_class.new(organization_id: organization.id, event_json:) } + + let(:organization) { create(:organization) } + let(:event_json) { File.read("spec/fixtures/cashfree/payment_link_event_payment.json") } + + let(:payment_service) { instance_double(Invoices::Payments::CashfreeService) } + let(:service_result) { BaseService::Result.new } + + describe "#call" do + context "when succeeded payment event" do + before do + allow(Invoices::Payments::CashfreeService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + end + + it "routes the event to an other service" do + webhook_service.call + + expect(Invoices::Payments::CashfreeService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when succeeded payment_request event" do + let(:payment_service) { instance_double(PaymentRequests::Payments::CashfreeService) } + + let(:event_json) do + path = Rails.root.join("spec/fixtures/cashfree/payment_link_event_payment_request.json") + File.read(path) + end + + before do + allow(PaymentRequests::Payments::CashfreeService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + end + + it "routes the event to an other service" do + webhook_service.call + + expect(PaymentRequests::Payments::CashfreeService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + end +end diff --git a/spec/services/payment_providers/cashfree_service_spec.rb b/spec/services/payment_providers/cashfree_service_spec.rb new file mode 100644 index 0000000..aed90b0 --- /dev/null +++ b/spec/services/payment_providers/cashfree_service_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::CashfreeService do + subject(:cashfree_service) { described_class.new(membership.user) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:code) { "code_1" } + let(:name) { "Name 1" } + let(:client_id) { "123456_abc" } + let(:client_secret) { "cfsk_ma_prod_abc_123456" } + let(:success_redirect_url) { Faker::Internet.url } + + describe ".create_or_update" do + it "creates a cashfree provider" do + expect do + cashfree_service.create_or_update( + organization:, + code:, + name:, + client_id:, + client_secret:, + success_redirect_url: + ) + end.to change(PaymentProviders::CashfreeProvider, :count).by(1) + end + + it_behaves_like "produces a security log", "integration.created" do + before do + cashfree_service.create_or_update( + organization:, + code:, + name:, + client_id:, + client_secret:, + success_redirect_url: + ) + end + end + + context "when code was changed" do + let(:new_code) { "updated_code_1" } + let(:cashfree_customer) { create(:cashfree_customer, payment_provider:, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:payment_provider) do + create( + :cashfree_provider, + organization:, + code:, + name:, + client_secret: "secret" + ) + end + + before { cashfree_customer } + + it "updates payment provider codes of all customers" do + result = cashfree_service.create_or_update( + id: payment_provider.id, + organization:, + code: new_code, + name:, + client_secret: "secret" + ) + + expect(result).to be_success + expect(result.cashfree_provider.customers.first.payment_provider_code).to eq(new_code) + end + end + + context "when organization already have a cashfree provider" do + let(:cashfree_provider) do + create(:cashfree_provider, organization:, client_id: "123456_abc_old", client_secret: "cfsk_ma_prod_abc_123456_old", code:) + end + + before { cashfree_provider } + + it "updates the existing provider" do + result = cashfree_service.create_or_update( + organization:, + code:, + name:, + client_id:, + client_secret:, + success_redirect_url: + ) + + expect(result).to be_success + + expect(result.cashfree_provider.id).to eq(cashfree_provider.id) + expect(result.cashfree_provider.client_id).to eq("123456_abc") + expect(result.cashfree_provider.client_secret).to eq("cfsk_ma_prod_abc_123456") + expect(result.cashfree_provider.code).to eq(code) + expect(result.cashfree_provider.name).to eq(name) + expect(result.cashfree_provider.success_redirect_url).to eq(success_redirect_url) + end + + it_behaves_like "produces a security log", "integration.updated" do + before do + cashfree_service.create_or_update( + organization:, + code:, + name:, + client_id:, + client_secret:, + success_redirect_url: + ) + end + end + end + + context "with validation error" do + let(:token) { nil } + + it "returns an error result" do + result = cashfree_service.create_or_update( + organization: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:client_id]).to eq(["value_is_mandatory"]) + expect(result.error.messages[:client_secret]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/payment_providers/create_customer_factory_spec.rb b/spec/services/payment_providers/create_customer_factory_spec.rb new file mode 100644 index 0000000..d24f27c --- /dev/null +++ b/spec/services/payment_providers/create_customer_factory_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::CreateCustomerFactory do + subject(:new_instance) { described_class.new_instance(provider:, customer:, payment_provider_id:, params:, async:) } + + let(:customer) { create(:customer) } + let(:payment_provider_id) { create(:stripe_provider, organization: customer.organization).id } + let(:params) { {provider_customer_id: "id", sync_with_provider: true} } + let(:async) { true } + + let(:provider) { "stripe" } + + describe ".new_instance" do + it "creates an instance of the stripe service" do + expect(new_instance).to be_instance_of(PaymentProviders::Stripe::Customers::CreateService) + end + + context "when provider is adyen" do + let(:provider) { "adyen" } + let(:payment_provider_id) { create(:adyen_provider, organization: customer.organization).id } + + it "creates an instance of the adyen service" do + expect(new_instance).to be_instance_of(PaymentProviders::Adyen::Customers::CreateService) + end + end + + context "when provider is cashfree" do + let(:provider) { "cashfree" } + let(:payment_provider_id) { create(:cashfree_provider, organization: customer.organization).id } + + it "creates an instance of the cashfree service" do + expect(new_instance).to be_instance_of(PaymentProviders::Cashfree::Customers::CreateService) + end + end + + context "when provider is gocardless" do + let(:provider) { "gocardless" } + let(:payment_provider_id) { create(:gocardless_provider, organization: customer.organization).id } + + it "creates an instance of the gocardless service" do + expect(new_instance).to be_instance_of(PaymentProviders::Gocardless::Customers::CreateService) + end + end + + context "when provider is flutterwave" do + let(:provider) { "flutterwave" } + let(:payment_provider_id) { create(:flutterwave_provider, organization: customer.organization).id } + + it "creates an instance of the flutterwave service" do + expect(new_instance).to be_instance_of(PaymentProviders::Flutterwave::Customers::CreateService) + end + end + end +end diff --git a/spec/services/payment_providers/create_payment_factory_spec.rb b/spec/services/payment_providers/create_payment_factory_spec.rb new file mode 100644 index 0000000..6aacfc9 --- /dev/null +++ b/spec/services/payment_providers/create_payment_factory_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::CreatePaymentFactory do + subject(:new_instance) { described_class.new_instance(provider:, payment:, reference: "", metadata: {}) } + + let(:provider) { "stripe" } + let(:payment) { create(:payment) } + + describe ".new_instance" do + it "creates an instance of the stripe service" do + expect(new_instance).to be_instance_of(PaymentProviders::Stripe::Payments::CreateService) + end + + context "when provider is adyen" do + let(:provider) { "adyen" } + + it "creates an instance of the adyen service" do + expect(new_instance).to be_instance_of(PaymentProviders::Adyen::Payments::CreateService) + end + end + + context "when provider is gocardless" do + let(:provider) { "gocardless" } + + it "creates an instance of the gocardless service" do + expect(new_instance).to be_instance_of(PaymentProviders::Gocardless::Payments::CreateService) + end + end + end +end diff --git a/spec/services/payment_providers/destroy_service_spec.rb b/spec/services/payment_providers/destroy_service_spec.rb new file mode 100644 index 0000000..b8ea08c --- /dev/null +++ b/spec/services/payment_providers/destroy_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::DestroyService do + subject(:destroy_service) { described_class.new(payment_provider) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:payment_provider) { create(:stripe_provider, organization:) } + + describe ".destroy" do + before { payment_provider } + + it "destroys the payment_provider" do + expect { destroy_service.call } + .to change(PaymentProviders::BaseProvider, :count).by(-1) + end + + it_behaves_like "produces a security log", "integration.deleted" do + before { destroy_service.call } + end + + context "when payment provider is not found" do + let(:payment_provider) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("payment_provider_not_found") + end + end + end +end diff --git a/spec/services/payment_providers/find_service_spec.rb b/spec/services/payment_providers/find_service_spec.rb new file mode 100644 index 0000000..8dd2323 --- /dev/null +++ b/spec/services/payment_providers/find_service_spec.rb @@ -0,0 +1,150 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::FindService do + let(:service) { described_class.new(organization_id:, code:, id:) } + let(:payment_provider) { create(:adyen_provider, organization:) } + let(:organization) { create(:organization) } + let(:id) { nil } + + before { payment_provider } + + describe "#call" do + subject(:result) { service.call } + + context "when organization does not exist" do + let(:organization_id) { "not_an_id" } + + context "when code is blank" do + let(:code) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("payment_provider_not_found") + expect(result.error.error_message).to eq("Payment provider not found") + end + end + + context "when code is present" do + context "when provider with given code does not exist" do + let(:code) { "not_a_code" } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("payment_provider_not_found") + expect(result.error.error_message).to eq("Payment provider not found") + end + end + + context "when provider with given code exists" do + let(:code) { payment_provider.code } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("payment_provider_not_found") + expect(result.error.error_message).to eq("Payment provider not found") + end + end + end + end + + context "when organization exists" do + let(:organization_id) { organization.id } + + context "when code is blank" do + let(:code) { nil } + + context "when id is blank" do + context "when organization has only one provider" do + it "returns a successful result" do + expect(result).to be_success + expect(result.payment_provider).to eq(payment_provider) + end + end + + context "when organization has more than one provider" do + before { create(:adyen_provider, organization:) } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("payment_provider_code_missing") + expect(result.error.error_message).to eq("Payment provider code is missing") + end + end + end + + context "when id is present" do + let(:id) { payment_provider.id } + + context "when organization has only one provider" do + it "returns a successful result" do + expect(result).to be_success + expect(result.payment_provider).to eq(payment_provider) + end + end + + context "when organization has more than one provider" do + before { create(:adyen_provider, organization:) } + + it "returns a successful result" do + expect(result).to be_success + expect(result.payment_provider).to eq(payment_provider) + end + end + end + end + + context "when code is present" do + context "when id is blank" do + context "when provider with given code does not exist" do + let(:code) { "not_a_code" } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("payment_provider_not_found") + expect(result.error.error_message).to eq("Payment provider not found") + end + end + + context "when provider with given code exists" do + let(:code) { payment_provider.code } + + it "returns a successful result" do + expect(result).to be_success + expect(result.payment_provider).to eq(payment_provider) + end + end + end + + context "when id is present" do + let(:id) { payment_provider.id } + + context "when provider with given code does not exist" do + let(:code) { "not_a_code" } + + it "returns a successful result" do + expect(result).to be_success + expect(result.payment_provider).to eq(payment_provider) + end + end + + context "when provider with given code exists" do + let(:code) { another_payment_provider.code } + let(:another_payment_provider) { create(:adyen_provider, organization:) } + + it "returns a successful result containing payment provider by id" do + expect(result).to be_success + expect(result.payment_provider).to eq(payment_provider) + end + end + end + end + end + end +end diff --git a/spec/services/payment_providers/flutterwave/handle_event_service_spec.rb b/spec/services/payment_providers/flutterwave/handle_event_service_spec.rb new file mode 100644 index 0000000..01c5fd2 --- /dev/null +++ b/spec/services/payment_providers/flutterwave/handle_event_service_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Flutterwave::HandleEventService do + subject(:handle_event_service) { described_class.new(organization:, event_json:) } + + let(:organization) { create(:organization) } + let(:event_json) { payload.to_json } + + let(:payload) do + { + event: "charge.completed", + data: { + id: 123456, + status: "successful", + amount: 100.0, + currency: "USD", + tx_ref: "lago_invoice_12345", + meta: { + lago_invoice_id: "12345", + lago_payable_type: "Invoice" + } + } + } + end + + describe "#call" do + context "when event is charge.completed" do + it "calls the charge completed service" do + allow(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .to receive(:call!) + + result = handle_event_service.call + + expect(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .to have_received(:call!) + .with(organization_id: organization.id, event_json:) + expect(result).to be_success + end + + it "returns success even if the service raises an error" do + allow(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .to receive(:call!).and_raise(StandardError.new("Service error")) + + expect { handle_event_service.call }.not_to raise_error + end + end + + context "when event is not supported" do + let(:payload) do + { + event: "charge.failed", + data: {} + } + end + + it "does not call any webhook service" do + allow(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .to receive(:call!) + + result = handle_event_service.call + + expect(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .not_to have_received(:call!) + expect(result).to be_success + end + end + + context "when event_json is invalid JSON" do + let(:event_json) { "invalid json" } + + it "raises a JSON parse error" do + expect { handle_event_service.call }.to raise_error(JSON::ParserError) + end + end + + context "when event key is missing" do + let(:payload) do + { + data: {} + } + end + + it "does not call any webhook service" do + allow(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .to receive(:call!) + + result = handle_event_service.call + + expect(PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService) + .not_to have_received(:call!) + expect(result).to be_success + end + end + end +end diff --git a/spec/services/payment_providers/flutterwave/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/flutterwave/handle_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..26ca5df --- /dev/null +++ b/spec/services/payment_providers/flutterwave/handle_incoming_webhook_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Flutterwave::HandleIncomingWebhookService do + subject(:webhook_service) { described_class.new(organization_id:, body:, secret:, code:) } + + let(:organization) { create(:organization) } + let(:organization_id) { organization.id } + let(:flutterwave_provider) { create(:flutterwave_provider, organization:, webhook_secret:) } + let(:webhook_secret) { "webhook_secret_hash" } + let(:code) { flutterwave_provider.code } + let(:body) { payload.to_json } + let(:secret) { webhook_secret } + + let(:payload) do + { + event: "charge.completed", + data: { + id: 123456, + status: "successful", + amount: 100.0, + currency: "USD", + customer: { + id: 789, + email: "customer@example.com" + }, + tx_ref: "lago_invoice_12345", + meta: { + lago_invoice_id: "12345", + lago_payable_type: "Invoice" + } + } + } + end + + describe "#call" do + context "when secret is valid" do + it "enqueues the webhook processing job" do + expect { webhook_service.call }.to have_enqueued_job(PaymentProviders::Flutterwave::HandleEventJob) + end + + it "returns success result" do + result = webhook_service.call + expect(result).to be_success + expect(result.event).to eq(body) + end + end + + context "when secret is invalid" do + let(:secret) { "invalid_secret" } + + it "returns service failure" do + result = webhook_service.call + expect(result).not_to be_success + expect(result.error.code).to eq("webhook_error") + expect(result.error.message).to eq("webhook_error: Invalid webhook secret") + end + end + + context "when webhook secret is missing" do + let(:webhook_secret) { nil } + let(:secret) { nil } + + before do + secrets = JSON.parse(flutterwave_provider.secrets || "{}") + secrets.delete("webhook_secret") + flutterwave_provider.update!(secrets: secrets.to_json) + end + + it "returns service failure" do + result = webhook_service.call + expect(result).not_to be_success + expect(result.error.code).to eq("webhook_error") + expect(result.error.message).to eq("webhook_error: Webhook secret is missing") + end + end + + context "when payment provider is not found" do + let(:code) { "non_existent_code" } + + it "returns service failure" do + result = webhook_service.call + expect(result).not_to be_success + end + end + end +end diff --git a/spec/services/payment_providers/flutterwave/webhooks/charge_completed_service_spec.rb b/spec/services/payment_providers/flutterwave/webhooks/charge_completed_service_spec.rb new file mode 100644 index 0000000..2ad44cb --- /dev/null +++ b/spec/services/payment_providers/flutterwave/webhooks/charge_completed_service_spec.rb @@ -0,0 +1,526 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Flutterwave::Webhooks::ChargeCompletedService do + subject(:charge_completed_service) { described_class.new(organization_id: organization.id, event_json:) } + + let(:organization) { create(:organization) } + let(:invoice) { create(:invoice, organization:) } + let(:payment_request) { create(:payment_request, organization:) } + let(:flutterwave_provider) { create(:flutterwave_provider, organization:) } + let(:event_json) { payload.to_json } + + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + device_fingerprint: "a42937f4a73ce8bb8b8df14e63a2df31", + amount: 10000, + currency: "NGN", + charged_amount: 10000, + app_fee: 140, + merchant_fee: 0, + processor_response: "Approved by Financial Institution", + auth_model: "PIN", + ip: "197.210.64.96", + narration: "CARD Transaction", + status: "successful", + payment_type: "card", + created_at: "2020-07-06T19:17:04.000Z", + account_id: 17321, + customer: { + id: 215604089, + name: "John Doe", + phone_number: nil, + email: "customer@example.com", + created_at: "2020-07-06T19:17:04.000Z" + }, + card: { + first_6digits: "123456", + last_4digits: "7889", + issuer: "VERVE FIRST CITY MONUMENT BANK PLC", + country: "NG", + type: "VERVE", expiry: "02/23" + }, + meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "Invoice" + } + } + } + end + + let(:verification_response) do + { + "status" => "success", + "data" => { + "id" => 285959875, + "tx_ref" => "lago_invoice_12345", + "flw_ref" => "LAGO/FLW270177170", + "amount" => 10000, + "currency" => "NGN", + "charged_amount" => 10000, + "status" => "successful", + "payment_type" => "card", + "customer" => { + "id" => 215604089, + "name" => "John Doe", + "email" => "customer@example.com" + }, + "card" => { + "first_6digits" => "123456", + "last_4digits" => "7889", + "issuer" => "VERVE FIRST CITY MONUMENT BANK PLC", + "country" => "NG", + "type" => "VERVE" + } + } + } + end + + before do + allow(PaymentProviders::FindService) + .to receive(:call) + .with(organization_id: organization.id, payment_provider_type: "flutterwave") + .and_return(double(success?: true, payment_provider: flutterwave_provider)) # rubocop:disable RSpec/VerifiedDoubles + end + + describe "#call" do + context "when transaction status is successful" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(Invoices::Payments::FlutterwaveService) } + + before do + invoice + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + allow(Invoices::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "verifies the transaction and updates payment status" do + result = charge_completed_service.call + + expect(http_client).to have_received(:get).with( + headers: { + "Authorization" => "Bearer #{flutterwave_provider.secret_key}", + "Content-Type" => "application/json", + "Accept" => "application/json" + } + ) + expect(payment_service).to have_received(:update_payment_status) + expect(result).to be_success + end + + it "builds correct metadata" do + charge_completed_service.call + + expect(payment_service).to have_received(:update_payment_status) do |args| + metadata = args[:flutterwave_payment].metadata + expect(metadata[:lago_invoice_id]).to eq(invoice.id) + expect(metadata[:lago_payable_type]).to eq("Invoice") + expect(metadata[:flutterwave_transaction_id]).to eq(285959875) + expect(metadata[:amount]).to eq(10000) + expect(metadata[:currency]).to eq("NGN") + expect(metadata[:flw_ref]).to eq("lago_invoice_12345") + end + end + end + + context "when transaction status is not successful" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 408136545, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/SM31570678271", + device_fingerprint: "7852b6c97d67edce50a5f1e540719e39", + amount: 100000, + currency: "NGN", + charged_amount: 100000, + app_fee: 1400, + merchant_fee: 0, + processor_response: "invalid token supplied", + auth_model: "PIN", + ip: "72.140.222.142", + narration: "CARD Transaction", + status: "failed", + payment_type: "card", + created_at: "2021-04-16T14:52:37.000Z", + account_id: 82913, + customer: { + id: 255128611, + name: "Test User", + phone_number: nil, + email: "test@example.com", + created_at: "2021-04-16T14:52:37.000Z" + }, + card: { + first_6digits: "536613", + last_4digits: "8816", + issuer: "MASTERCARD ACCESS BANK PLC CREDIT", + country: "NG", + type: "MASTERCARD", + expiry: "12/21" + }, + meta: { + lago_invoice_id: "12345", + lago_payable_type: "Invoice" + } + }, + "event.type": "CARD_TRANSACTION" + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when provider payment id is nil" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + } + # tx_ref is missing, which should cause the service to skip processing + } + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when transaction verification fails" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:failed_response) do + { + "status" => "error", + "message" => "Transaction not found" + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(failed_response) + allow(Invoices::Payments::FlutterwaveService).to receive(:new) + end + + it "does not update payment status" do + result = charge_completed_service.call + + expect(Invoices::Payments::FlutterwaveService).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when payment provider is not found" do + before do + allow(PaymentProviders::FindService) + .to receive(:call) + .and_return(double(success?: false)) # rubocop:disable RSpec/VerifiedDoubles + allow(LagoHttpClient::Client).to receive(:new) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when HTTP error occurs during verification" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_raise(LagoHttpClient::HttpError.new(500, "Connection failed", "https://api.flutterwave.com")) + allow(Rails.logger).to receive(:error) + allow(Invoices::Payments::FlutterwaveService).to receive(:new) + end + + it "logs the error and does not update payment status" do + result = charge_completed_service.call + + expect(Rails.logger).to have_received(:error).with("Error verifying Flutterwave transaction: HTTP 500 - URI: https://api.flutterwave.com.\nError: Connection failed\nResponse headers: {}") + expect(Invoices::Payments::FlutterwaveService).not_to have_received(:new) + expect(result).to be_success + end + end + + context "when payable type is PaymentRequest" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_payment_request_12345", + flw_ref: "LAGO/FLW270177170", + device_fingerprint: "a42937f4a73ce8bb8b8df14e63a2df31", + amount: 50000, + currency: "NGN", + charged_amount: 50000, + app_fee: 700, + merchant_fee: 0, + processor_response: "Approved by Financial Institution", + auth_model: "PIN", + ip: "197.210.64.96", + narration: "CARD Transaction", + status: "successful", + payment_type: "card", + created_at: "2020-07-06T19:17:04.000Z", + account_id: 17321, + customer: { + id: 215604089, + name: "John Doe", + phone_number: nil, + email: "customer@example.com", + created_at: "2020-07-06T19:17:04.000Z" + }, + card: { + first_6digits: "123456", + last_4digits: "7889", + issuer: "VERVE FIRST CITY MONUMENT BANK PLC", + country: "NG", + type: "VERVE", + expiry: "02/23" + }, meta: { + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(PaymentRequests::Payments::FlutterwaveService) } + + before do + payment_request + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + allow(PaymentRequests::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "uses the PaymentRequest service" do + charge_completed_service.call + + expect(PaymentRequests::Payments::FlutterwaveService).to have_received(:new).with(payable: payment_request) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when payable type is invalid" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + charged_amount: 10000, + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "InvalidType" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + invoice # Create the invoice so find_payable doesn't fail first + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + end + + it "raises a NameError" do + expect { charge_completed_service.call }.to raise_error(NameError, "Invalid lago_payable_type: InvalidType") + end + end + + context "when transaction has different currency precision" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 100.50, + currency: "USD", + charged_amount: 100.50, + app_fee: 1.40, + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, + meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "Invoice" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(Invoices::Payments::FlutterwaveService) } + let(:verification_response_usd) do + { + "status" => "success", + "data" => { + "id" => 285959875, + "tx_ref" => "lago_invoice_12345", + "amount" => 100.50, + "currency" => "USD", + "charged_amount" => 100.50, + "status" => "successful" + } + } + end + + before do + invoice + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response_usd) + allow(Invoices::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "handles decimal amounts correctly" do + charge_completed_service.call + + expect(payment_service).to have_received(:update_payment_status) do |args| + metadata = args[:flutterwave_payment].metadata + expect(metadata[:amount]).to eq(100.50) + expect(metadata[:currency]).to eq("USD") + end + end + end + + context "when webhook contains event.type field" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + }, + meta: { + lago_invoice_id: invoice.id, + lago_payable_type: "Invoice" + } + }, + "event.type": "CARD_TRANSACTION" + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:payment_service) { instance_double(Invoices::Payments::FlutterwaveService) } + + before do + invoice + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return(verification_response) + allow(Invoices::Payments::FlutterwaveService).to receive(:new).and_return(payment_service) + allow(payment_service).to receive(:update_payment_status).and_return(instance_double("BaseService::Result", raise_if_error!: nil)) + end + + it "processes the webhook normally" do + result = charge_completed_service.call + + expect(payment_service).to have_received(:update_payment_status) + expect(result).to be_success + end + end + + context "when meta field is missing" do + let(:payload) do + { + event: "charge.completed", + data: { + id: 285959875, + tx_ref: "lago_invoice_12345", + flw_ref: "LAGO/FLW270177170", + amount: 10000, + currency: "NGN", + status: "successful", + payment_type: "card", + customer: { + id: 215604089, + name: "John Doe", + email: "customer@example.com" + } + } + } + end + + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:get).and_return({"status" => "error"}) + end + + it "does not process the transaction" do + result = charge_completed_service.call + + expect(result).to be_success + end + end + end +end diff --git a/spec/services/payment_providers/flutterwave_service_spec.rb b/spec/services/payment_providers/flutterwave_service_spec.rb new file mode 100644 index 0000000..f9baba2 --- /dev/null +++ b/spec/services/payment_providers/flutterwave_service_spec.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::FlutterwaveService do + subject(:flutterwave_service) { described_class.new } + + include_context "with mocked security logger" + + let(:organization) { create(:organization) } + + describe "#create_or_update" do + let(:args) do + { + organization: organization, + code: "flutterwave_1", + name: "Flutterwave Provider", + secret_key: "FLWSECK_TEST-test_secret_key", + success_redirect_url: "https://example.com/success" + } + end + + context "when creating a new provider" do + it "creates a new flutterwave provider" do + result = flutterwave_service.create_or_update(**args) + + expect(result).to be_success + expect(result.flutterwave_provider).to be_a(PaymentProviders::FlutterwaveProvider) + expect(result.flutterwave_provider.organization_id).to eq(organization.id) + expect(result.flutterwave_provider.code).to eq("flutterwave_1") + expect(result.flutterwave_provider.name).to eq("Flutterwave Provider") + expect(result.flutterwave_provider.secret_key).to eq("FLWSECK_TEST-test_secret_key") + expect(result.flutterwave_provider.success_redirect_url).to eq("https://example.com/success") + end + + it_behaves_like "produces a security log", "integration.created" do + before { flutterwave_service.create_or_update(**args) } + end + end + + context "when updating an existing provider" do + let(:existing_provider) do + create( + :flutterwave_provider, + organization: organization, + code: "flutterwave_1", + name: "Old Name", + secret_key: "old_secret_key", + success_redirect_url: "https://old.example.com" + ) + end + + before { existing_provider } + + it "updates the existing provider" do + result = flutterwave_service.create_or_update(**args) + + expect(result).to be_success + expect(result.flutterwave_provider.id).to eq(existing_provider.id) + expect(result.flutterwave_provider.name).to eq("Flutterwave Provider") + expect(result.flutterwave_provider.secret_key).to eq("FLWSECK_TEST-test_secret_key") + expect(result.flutterwave_provider.success_redirect_url).to eq("https://example.com/success") + end + + it_behaves_like "produces a security log", "integration.updated" do + before { flutterwave_service.create_or_update(**args) } + end + + context "when code is updated" do + let(:customer) { create(:customer, organization: organization, payment_provider_code: "flutterwave_1") } + let!(:flutterwave_customer) do + create(:flutterwave_customer, customer: customer, payment_provider: existing_provider) + end + + let(:args) do + { + organization: organization, + id: existing_provider.id, + code: "flutterwave_2", + name: "Updated Flutterwave Provider", + secret_key: "FLWSECK_TEST-updated_secret_key", + success_redirect_url: "https://updated.example.com" + } + end + + it "updates the provider and associated customer codes" do + result = flutterwave_service.create_or_update(**args) + + expect(result).to be_success + expect(result.flutterwave_provider.code).to eq("flutterwave_2") + flutterwave_customer.reload + expect(flutterwave_customer.customer.payment_provider_code).to eq("flutterwave_2") + end + end + end + + context "when partial update with only specific fields" do + let(:existing_provider) do + create( + :flutterwave_provider, + organization: organization, + code: "flutterwave_1", + name: "Original Name", + secret_key: "original_secret_key", + success_redirect_url: "https://original.example.com" + ) + end + + before { existing_provider } + + it "updates only the provided fields" do + partial_args = { + organization: organization, + id: existing_provider.id, + name: "Updated Name Only" + } + + result = flutterwave_service.create_or_update(**partial_args) + + expect(result).to be_success + expect(result.flutterwave_provider.name).to eq("Updated Name Only") + expect(result.flutterwave_provider.secret_key).to eq("original_secret_key") # unchanged + expect(result.flutterwave_provider.success_redirect_url).to eq("https://original.example.com") # unchanged + end + end + + context "when validation fails" do + let(:args) do + { + organization: organization, + code: "flutterwave_test", + name: "", # Invalid empty name + secret_key: "FLWSECK_TEST-test_secret_key" + } + end + + it "returns a failure result with validation errors" do + result = flutterwave_service.create_or_update(**args) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + + context "when finding existing provider by code" do + let(:existing_provider) do + create( + :flutterwave_provider, + organization: organization, + code: "flutterwave_1" + ) + end + + before { existing_provider } + + it "finds and updates the existing provider by code" do + update_args = args.merge(code: "flutterwave_1", name: "Updated via Code") + + result = flutterwave_service.create_or_update(**update_args) + + expect(result).to be_success + expect(result.flutterwave_provider.id).to eq(existing_provider.id) + expect(result.flutterwave_provider.name).to eq("Updated via Code") + end + end + end +end diff --git a/spec/services/payment_providers/gocardless/customers/create_service_spec.rb b/spec/services/payment_providers/gocardless/customers/create_service_spec.rb new file mode 100644 index 0000000..c1a3bd7 --- /dev/null +++ b/spec/services/payment_providers/gocardless/customers/create_service_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::Customers::CreateService do + let(:create_service) { described_class.new(customer:, payment_provider_id:, params:, async:) } + + let(:customer) { create(:customer) } + let(:gocardless_provider) { create(:gocardless_provider, organization: customer.organization) } + let(:payment_provider_id) { gocardless_provider.id } + + let(:params) do + {provider_customer_id: "id", sync_with_provider: true} + end + + let(:async) { true } + + describe ".call" do + it "creates a payment_provider_customer" do + result = create_service.call + + expect(result).to be_success + expect(result.provider_customer).to be_present + expect(result.provider_customer.provider_customer_id).to eq("id") + end + + context "when no provider customer id and should create on service" do + let(:params) do + {provider_customer_id: nil, sync_with_provider: true} + end + + it "enqueues a job to create the customer on the provider" do + expect { create_service.call }.to have_enqueued_job(PaymentProviderCustomers::GocardlessCreateJob) + end + end + + context "when removing the provider customer id and should create on service" do + let(:params) do + {provider_customer_id: nil, sync_with_provider: true} + end + + let(:gocardless_customer) do + create( + :gocardless_customer, + customer:, + payment_provider: gocardless_provider + ) + end + + before { gocardless_customer } + + it "updates the provider customer" do + expect do + result = create_service.call + + expect(result).to be_success + + expect(result.provider_customer.provider_customer_id).to be_nil + end.not_to have_enqueued_job(PaymentProviderCustomers::GocardlessCreateJob) + end + end + + context "when provider customer id is set" do + let(:params) do + {provider_customer_id: "id", sync_with_provider:, provider_payment_methods: %w[card]} + end + + before do + allow(create_service).to receive(:generate_checkout_url).and_return(true) + allow(create_service).to receive(:create_customer_on_provider_service).and_return(true) + end + + context "when sync with provider is blank" do + let(:sync_with_provider) { nil } + let(:provider) { create(:gocardless_provider, organization: customer.organization) } + + context "when provider customer exists" do + before do + create(:gocardless_customer, customer:, payment_provider_id: provider.id) + end + + it "generates checkout url" do + create_service.call + expect(create_service).to have_received(:generate_checkout_url) + end + + it "does not create customer" do + create_service.call + expect(create_service).not_to have_received(:create_customer_on_provider_service) + end + end + + context "when provider customer does not exist" do + it "does not generate checkout url" do + create_service.call + expect(create_service).not_to have_received(:generate_checkout_url) + end + + it "does not create customer" do + create_service.call + expect(create_service).not_to have_received(:create_customer_on_provider_service) + end + end + end + + context "when sync with provider is true" do + let(:sync_with_provider) { true } + + it "does not generate checkout url" do + create_service.call + expect(create_service).not_to have_received(:generate_checkout_url) + end + + it "does not enqueue a job to create the customer on the provider" do + expect { create_service.call }.not_to enqueue_job(PaymentProviderCustomers::GocardlessCreateJob) + end + end + end + end +end diff --git a/spec/services/payment_providers/gocardless/handle_event_service_spec.rb b/spec/services/payment_providers/gocardless/handle_event_service_spec.rb new file mode 100644 index 0000000..420678d --- /dev/null +++ b/spec/services/payment_providers/gocardless/handle_event_service_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::HandleEventService do + subject(:event_service) { described_class.new(payment_provider:, event_json:) } + + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + let(:payment_service) { instance_double(Invoices::Payments::GocardlessService) } + let(:service_result) { BaseService::Result.new } + let(:payment_provider) { create(:gocardless_provider) } + + describe "#call" do + context "when succeeded payment event" do + it "routes the event to an other service" do + allow(Invoices::Payments::GocardlessService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + + event_service.call + + expect(Invoices::Payments::GocardlessService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when event metadata contains payable_type PaymentRequest" do + let(:payment_service) { instance_double(PaymentRequests::Payments::GocardlessService) } + let(:service_result) { BaseService::Result.new } + + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events_payment_request.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + it "routes the event to an other service" do + allow(PaymentRequests::Payments::GocardlessService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + + event_service.call + + expect(PaymentRequests::Payments::GocardlessService).to have_received(:new) + expect(payment_service).to have_received(:update_payment_status) + end + end + + context "when event metadata contains invalid payable_type" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events_invalid_payable_type.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + it "routes the event to an other service" do + expect { + event_service.call + }.to raise_error(NameError, "Invalid lago_payable_type: InvalidPayableTypeName") + end + end + + context "when succeeded refund event" do + let(:refund_service) { instance_double(CreditNotes::Refunds::GocardlessService) } + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events_refund.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + it "routes the event to an other service" do + allow(CreditNotes::Refunds::GocardlessService).to receive(:new) + .and_return(refund_service) + allow(refund_service).to receive(:update_status) + .and_return(service_result) + + event_service.call + + expect(CreditNotes::Refunds::GocardlessService).to have_received(:new) + expect(refund_service).to have_received(:update_status) + end + end + + context "with mandate created event" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events_mandate_created.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + it "routes the event to MandateCreatedService" do + allow(PaymentProviders::Gocardless::Webhooks::MandateCreatedService).to receive(:call) + .and_return(service_result) + + event_service.call + + expect(PaymentProviders::Gocardless::Webhooks::MandateCreatedService).to have_received(:call) + .with(payment_provider:, mandate_id: "index_ID_123") + end + end + + context "with mandate cancelled event from API" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events_mandate_cancelled.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + it "routes the event to MandateCancelledService" do + allow(PaymentProviders::Gocardless::Webhooks::MandateCancelledService).to receive(:call) + .and_return(service_result) + + event_service.call + + expect(PaymentProviders::Gocardless::Webhooks::MandateCancelledService).to have_received(:call) + .with(payment_provider:, mandate_id: "index_ID_123") + end + end + + context "with mandate cancelled event from bank" do + let(:event_json) do + path = Rails.root.join("spec/fixtures/gocardless/events_mandate_cancelled_by_bank.json") + JSON.parse(File.read(path))["events"].first.to_json + end + + it "does not route the event to MandateCancelledService" do + allow(PaymentProviders::Gocardless::Webhooks::MandateCancelledService).to receive(:call) + .and_return(service_result) + + event_service.call + + expect(PaymentProviders::Gocardless::Webhooks::MandateCancelledService).not_to have_received(:call) + end + end + end +end diff --git a/spec/services/payment_providers/gocardless/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/gocardless/handle_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..3918264 --- /dev/null +++ b/spec/services/payment_providers/gocardless/handle_incoming_webhook_service_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::HandleIncomingWebhookService do + let(:webhook_service) { described_class.new(organization_id: organization.id, body:, signature:, code:) } + + let(:organization) { create(:organization) } + let(:gocardless_provider) { create(:gocardless_provider, organization:) } + + let(:events) do + path = Rails.root.join("spec/fixtures/gocardless/events.json") + JSON.parse(File.read(path)) + end + + let(:body) { events.to_json } + let(:events_result) { events["events"].map { |event| GoCardlessPro::Resources::Event.new(event) } } + let(:signature) { "signature" } + let(:code) { nil } + + before { gocardless_provider } + + describe "#call" do + it "checks the webhook" do + allow(GoCardlessPro::Webhook).to receive(:parse) + .and_return(events_result) + + result = webhook_service.call + expect(result).to be_success + + expect(result.events).to eq(events_result) + expect(PaymentProviders::Gocardless::HandleEventJob).to have_been_enqueued + end + + context "when failing to parse payload" do + it "returns an error" do + allow(GoCardlessPro::Webhook).to receive(:parse).and_raise(JSON::ParserError) + + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Invalid payload") + end + end + + context "when failing to validate the signature" do + it "returns an error" do + allow(GoCardlessPro::Webhook).to receive(:parse) + .and_raise(GoCardlessPro::Webhook::InvalidSignatureError.new("error")) + + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Invalid signature") + end + end + end +end diff --git a/spec/services/payment_providers/gocardless/payments/create_service_spec.rb b/spec/services/payment_providers/gocardless/payments/create_service_spec.rb new file mode 100644 index 0000000..039756a --- /dev/null +++ b/spec/services/payment_providers/gocardless/payments/create_service_spec.rb @@ -0,0 +1,178 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::Payments::CreateService do + subject(:create_service) { described_class.new(payment:, reference:, metadata:) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:gocardless_payment_provider) { create(:gocardless_provider, organization:, code:) } + let(:gocardless_customer) { create(:gocardless_customer, customer:, payment_provider: gocardless_payment_provider) } + let(:gocardless_client) { instance_double(GoCardlessPro::Client) } + let(:gocardless_payments_service) { instance_double(GoCardlessPro::Services::PaymentsService) } + let(:gocardless_mandates_service) { instance_double(GoCardlessPro::Services::MandatesService) } + let(:gocardless_list_response) { instance_double(GoCardlessPro::ListResponse) } + let(:code) { "gocardless_1" } + let(:reference) { "organization.name - Invoice #{invoice.number}" } + let(:metadata) do + { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type + } + end + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + let(:payment) do + create( + :payment, + payable: invoice, + status: "pending", + payment_provider: gocardless_payment_provider, + payment_provider_customer: gocardless_customer, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency + ) + end + + describe ".call" do + before do + gocardless_customer + + allow(GoCardlessPro::Client).to receive(:new) + .and_return(gocardless_client) + allow(gocardless_client).to receive(:mandates) + .and_return(gocardless_mandates_service) + allow(gocardless_mandates_service).to receive(:list) + .and_return(gocardless_list_response) + allow(gocardless_list_response).to receive(:records) + .and_return([GoCardlessPro::Resources::Mandate.new("id" => "mandate_id")]) + allow(gocardless_client).to receive(:payments) + .and_return(gocardless_payments_service) + allow(gocardless_payments_service).to receive(:create) + .and_return(GoCardlessPro::Resources::Payment.new( + "id" => "_ID_", + "amount" => invoice.total_amount_cents, + "currency" => invoice.currency, + "status" => "paid_out" + )) + allow(Invoices::PrepaidCreditJob).to receive(:perform_later) + end + + it "creates a gocardless payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(gocardless_payment_provider) + expect(result.payment.payment_provider_customer).to eq(gocardless_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("paid_out") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(gocardless_customer.reload.provider_mandate_id).to eq("mandate_id") + + expect(gocardless_payments_service).to have_received(:create) + end + + context "with error on gocardless" do + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + + let(:subscription) do + create(:subscription, organization:, customer:) + end + + let(:organization) do + create(:organization, webhook_url: "https://webhook.com") + end + + before do + subscription + + allow(gocardless_payments_service).to receive(:create) + .and_raise(GoCardlessPro::Error.new("code" => "code", "message" => "error")) + end + + it "returns a failed result" do + result = create_service.call + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("gocardless_error") + expect(result.error.error_message).to eq("code: error") + + expect(result.error_message).to eq("error") + expect(result.error_code).to eq("code") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + + context "when customer has no mandate to make a payment" do + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + let(:organization) { create(:organization, webhook_url: "https://webhook.com") } + + before do + allow(gocardless_list_response).to receive(:records) + .and_return([]) + + allow(gocardless_payments_service).to receive(:create) + .and_raise(GoCardlessPro::Error.new("code" => "code", "message" => "error")) + end + + it "delivers an error webhook" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("gocardless_error") + expect(result.error.error_message).to eq("no_mandate_error: No mandate available for payment") + + expect(result.error_message).to eq("No mandate available for payment") + expect(result.error_code).to eq("no_mandate_error") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + + context "when multiple payment methods are enabled" do + let(:default_payment_method) { create(:payment_method, customer:, provider_method_id: "mandate_id2") } + + before do + payment.update!(payment_method: default_payment_method) + organization.update!(feature_flags: ["multiple_payment_methods"]) + gocardless_customer.update!(provider_mandate_id: "mandate_id2") + end + + it "creates a gocardless payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(gocardless_payment_provider) + expect(result.payment.payment_provider_customer).to eq(gocardless_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("paid_out") + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(gocardless_customer.reload.provider_mandate_id).to eq("mandate_id2") + expect(gocardless_payments_service).to have_received(:create) + end + end + end +end diff --git a/spec/services/payment_providers/gocardless/webhooks/mandate_cancelled_service_spec.rb b/spec/services/payment_providers/gocardless/webhooks/mandate_cancelled_service_spec.rb new file mode 100644 index 0000000..90f1172 --- /dev/null +++ b/spec/services/payment_providers/gocardless/webhooks/mandate_cancelled_service_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::Webhooks::MandateCancelledService do + subject(:mandate_cancelled_service) { described_class.new(payment_provider:, mandate_id:) } + + let(:organization) { create(:organization) } + let(:payment_provider) { create(:gocardless_provider, organization:) } + let(:customer) { create(:customer, organization:) } + let(:gocardless_customer) do + create( + :gocardless_customer, + customer:, + payment_provider:, + provider_customer_id:, + provider_mandate_id: mandate_id + ) + end + let(:payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer: gocardless_customer, + provider_method_id: mandate_id, + payment_provider: + ) + end + + let(:mandate_id) { "index_ID_123" } + let(:provider_customer_id) { "CU123456" } + + describe "#call" do + before do + gocardless_customer + payment_method + end + + context "when feature flag is enabled" do + before do + organization.enable_feature_flag!(:multiple_payment_methods) + end + + it "destroys the payment method for the customer" do + expect { mandate_cancelled_service.call }.to change(PaymentMethod, :count).by(-1) + end + + it "returns a successful result with the destroyed payment method" do + result = mandate_cancelled_service.call + + expect(result).to be_success + expect(result.payment_method).to be_present + expect(result.payment_method.provider_method_id).to eq(mandate_id) + end + + it "clears the gocardless customer provider_mandate_id" do + mandate_cancelled_service.call + + expect(gocardless_customer.reload.provider_mandate_id).to be_nil + end + end + + context "when feature flag is disabled" do + before do + organization.update!(feature_flags: []) + end + + it "does not destroy any payment method" do + expect { mandate_cancelled_service.call }.not_to change(PaymentMethod, :count) + end + + it "returns a successful result without payment method" do + result = mandate_cancelled_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + + it "does not clear the gocardless customer provider_mandate_id" do + mandate_cancelled_service.call + + expect(gocardless_customer.reload.provider_mandate_id).not_to be_nil + end + end + + context "when payment method is not found" do + let(:payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer: gocardless_customer, + provider_method_id: "test_unknown" + ) + end + + it "does not destroy any payment method" do + expect { mandate_cancelled_service.call }.not_to change(PaymentMethod, :count) + end + + it "returns a successful result without payment method" do + result = mandate_cancelled_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + end +end diff --git a/spec/services/payment_providers/gocardless/webhooks/mandate_created_service_spec.rb b/spec/services/payment_providers/gocardless/webhooks/mandate_created_service_spec.rb new file mode 100644 index 0000000..ff523f3 --- /dev/null +++ b/spec/services/payment_providers/gocardless/webhooks/mandate_created_service_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Gocardless::Webhooks::MandateCreatedService do + subject(:mandate_created_service) { described_class.new(payment_provider:, mandate_id:) } + + let(:organization) { create(:organization) } + let(:payment_provider) { create(:gocardless_provider, organization:) } + let(:customer) { create(:customer, organization:) } + let(:gocardless_customer) do + create(:gocardless_customer, customer:, payment_provider:, provider_customer_id:) + end + + let(:gocardless_client) { instance_double(GoCardlessPro::Client) } + let(:gocardless_mandates_service) { instance_double(GoCardlessPro::Services::MandatesService) } + + let(:mandate_id) { "index_ID_123" } + let(:provider_customer_id) { "CU123456" } + let(:mandate) do + GoCardlessPro::Resources::Mandate.new( + "id" => mandate_id, + "scheme" => "bacs", + "links" => {"customer" => provider_customer_id} + ) + end + + describe "#call" do + before do + gocardless_customer + + allow(GoCardlessPro::Client).to receive(:new).and_return(gocardless_client) + allow(gocardless_client).to receive(:mandates).and_return(gocardless_mandates_service) + allow(gocardless_mandates_service).to receive(:get).with(mandate_id).and_return(mandate) + end + + context "when feature flag is enabled" do + before do + organization.enable_feature_flag!(:multiple_payment_methods) + end + + it "creates a payment method for the customer" do + expect { mandate_created_service.call }.to change(PaymentMethod, :count).by(1) + end + + it "returns a successful result with the payment method" do + result = mandate_created_service.call + + expect(result).to be_success + expect(result.payment_method).to be_present + expect(result.payment_method.provider_method_id).to eq(mandate_id) + expect(result.payment_method.payment_provider_customer).to eq(gocardless_customer) + end + + it "updates the gocardless customer provider_mandate_id" do + mandate_created_service.call + + expect(gocardless_customer.reload.provider_mandate_id).to eq(mandate_id) + end + end + + context "when feature flag is disabled" do + before do + organization.update!(feature_flags: []) + end + + it "does not create a payment method" do + expect { mandate_created_service.call }.not_to change(PaymentMethod, :count) + end + + it "returns a successful result without payment method" do + result = mandate_created_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when mandate fetch fails" do + before do + allow(gocardless_mandates_service).to receive(:get) + .and_raise(GoCardlessPro::Error.new("code" => "not_found", "message" => "Mandate not found")) + end + + it "does not create a payment method" do + expect { mandate_created_service.call }.not_to change(PaymentMethod, :count) + end + + it "returns a successful result without payment method" do + result = mandate_created_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when gocardless customer is not found" do + let(:mandate) do + GoCardlessPro::Resources::Mandate.new( + "id" => mandate_id, + "scheme" => "bacs", + "links" => {"customer" => "unknown_customer_id"} + ) + end + + it "does not create a payment method" do + expect { mandate_created_service.call }.not_to change(PaymentMethod, :count) + end + + it "returns a successful result without payment method" do + result = mandate_created_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + end +end diff --git a/spec/services/payment_providers/gocardless_service_spec.rb b/spec/services/payment_providers/gocardless_service_spec.rb new file mode 100644 index 0000000..3935b80 --- /dev/null +++ b/spec/services/payment_providers/gocardless_service_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::GocardlessService do + subject(:gocardless_service) { described_class.new(membership.user) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:access_code) { "1234567!abc" } + let(:code) { "code_1" } + let(:name) { "Name 1" } + let(:oauth_client) { instance_double(OAuth2::Client) } + let(:auth_code_strategy) { instance_double(OAuth2::Strategy::AuthCode) } + let(:access_token) { instance_double(OAuth2::AccessToken) } + let(:token) { "access_token_554" } + let(:success_redirect_url) { Faker::Internet.url } + + before do + allow(OAuth2::Client).to receive(:new) + .and_return(oauth_client) + allow(oauth_client).to receive(:auth_code) + .and_return(auth_code_strategy) + allow(auth_code_strategy).to receive(:get_token) + .and_return(access_token) + allow(access_token).to receive(:token) + .and_return(token) + end + + describe ".create_or_update" do + it "creates a gocardless provider" do + expect do + gocardless_service.create_or_update( + organization:, + access_code:, + code:, + name:, + success_redirect_url: + ) + end.to change(PaymentProviders::GocardlessProvider, :count).by(1) + end + + it_behaves_like "produces a security log", "integration.created" do + before do + gocardless_service.create_or_update( + organization:, + access_code:, + code:, + name:, + success_redirect_url: + ) + end + end + + context "when code was changed" do + let(:new_code) { "updated_code_3" } + let(:gocardless_customer) { create(:gocardless_customer, payment_provider:, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:payment_provider) do + create( + :gocardless_provider, + organization:, + code:, + name:, + access_token: "secret" + ) + end + + before { gocardless_customer } + + it "updates payment provider codes of all customers" do + result = gocardless_service.create_or_update( + id: payment_provider.id, + organization:, + code: new_code, + name:, + access_token: "secret" + ) + + expect(result).to be_success + expect(result.gocardless_provider.customers.first.payment_provider_code).to eq(new_code) + end + end + + context "when organization already have a gocardless provider" do + let(:gocardless_provider) do + create(:gocardless_provider, organization:, access_token: "access_token_123", code:) + end + + before { gocardless_provider } + + it "updates the existing provider" do + result = gocardless_service.create_or_update( + organization:, + access_code:, + code:, + name:, + success_redirect_url: + ) + + expect(result).to be_success + + expect(result.gocardless_provider.id).to eq(gocardless_provider.id) + expect(result.gocardless_provider.access_token).to eq("access_token_554") + expect(result.gocardless_provider.code).to eq(code) + expect(result.gocardless_provider.name).to eq(name) + expect(result.gocardless_provider.success_redirect_url).to eq(success_redirect_url) + end + + it_behaves_like "produces a security log", "integration.updated" do + before do + gocardless_service.create_or_update( + organization:, + access_code:, + code:, + name:, + success_redirect_url: + ) + end + end + end + + context "with validation error" do + let(:token) { nil } + + it "returns an error result" do + result = gocardless_service.create_or_update( + organization:, + access_code: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:access_token]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/payment_providers/moneyhash/handle_event_service_spec.rb b/spec/services/payment_providers/moneyhash/handle_event_service_spec.rb new file mode 100644 index 0000000..86f8ed8 --- /dev/null +++ b/spec/services/payment_providers/moneyhash/handle_event_service_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Moneyhash::HandleEventService do + subject(:event_service) { described_class.new(organization:, event_json:) } + + let(:organization) { create(:organization) } + let(:moneyhash_provider) { create(:moneyhash_provider, organization:) } + let(:customer) { create(:customer, organization:) } + let(:moneyhash_customer) { create(:moneyhash_customer, customer:) } + + # Intent + # handle event - intent.processed <- + # handle event - intent.time_expired <- + describe "#handle_intent_event" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/intent.processed.json"))) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:payment) { create(:payment, payment_provider: moneyhash_provider, provider_payment_id: event_json.dig("data", "intent_id"), payable: invoice) } + + before do + payment + event_json["data"]["intent"]["custom_fields"]["lago_payable_type"] = "Invoice" + event_json["data"]["intent"]["custom_fields"]["lago_payable_id"] = invoice.id + end + + it "handles intent.processed event" do + result = event_service.call + + payment.reload + expect(result).to be_success + expect(payment.status).to eq("succeeded") + expect(payment.payable_payment_status).to eq("succeeded") + expect(payment.payable.payment_status).to eq("succeeded") + end + + context "when event is intent.time_expired" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/intent.time_expired.json"))) } + + it "handles the event" do + result = event_service.call + + payment.reload + expect(result).to be_success + expect(payment.status).to eq("failed") + expect(payment.payable_payment_status).to eq("failed") + expect(payment.payable.payment_status).to eq("failed") + end + end + end + + # Transaction + # handle event - transaction.purchase.successful <- + # handle event - transaction.purchase.pending_authentication <- + # handle event - transaction.purchase.failed <- + describe "#handle_transaction_event" do + let(:payment) { create(:payment, payment_provider: moneyhash_provider, provider_payment_id: event_json.dig("intent", "id"), payable: invoice) } + let(:invoice) { create(:invoice, organization:, customer:) } + + before do + moneyhash_provider + moneyhash_customer + payment + + event_json["intent"]["custom_fields"]["lago_payable_type"] = "Invoice" + event_json["intent"]["custom_fields"]["lago_payable_id"] = invoice.id + event_json["intent"]["custom_fields"]["lago_customer_id"] = moneyhash_customer.customer_id + end + + context "when event is transaction.purchase.successful" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/transaction.purchase.successful.json"))) } + + it "handles transaction.purchase.successful event" do + result = event_service.call + + payment.reload + expect(result).to be_success + expect(payment.status).to eq("succeeded") + expect(payment.payable_payment_status).to eq("succeeded") + expect(payment.payable.payment_status).to eq("succeeded") + end + end + + context "when event is transaction.purchase.pending_authentication" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/transaction.purchase.pending_authentication.json"))) } + + it "handles the event" do + result = event_service.call + + payment.reload + expect(result).to be_success + expect(payment.status).to eq("processing") + expect(payment.payable_payment_status).to eq("pending") + expect(payment.payable.payment_status).to eq("pending") + end + end + + context "when event is transaction.purchase.failed" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/transaction.purchase.failed.json"))) } + + it "handles the event" do + result = event_service.call + + payment.reload + expect(result).to be_success + expect(payment.status).to eq("failed") + expect(payment.payable_payment_status).to eq("failed") + expect(payment.payable.payment_status).to eq("failed") + end + end + end + + # Card Token + # handle event - card_token.created <- + # handle event - card_token.updated <- + # handle event - card_token.deleted <- + describe "#handle_card_event" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/card_token.created.json"))) } + + before do + moneyhash_provider + moneyhash_customer + + event_json["data"]["card_token"]["custom_fields"]["lago_customer_id"] = moneyhash_customer.customer_id + end + + it "handles card_token.created event" do + result = event_service.call + + expect(result).to be_success + moneyhash_customer.reload + expect(moneyhash_customer.payment_method_id).to eq(event_json.dig("data", "card_token", "id")) + end + + context "when multiple_payment_methods feature flag is enabled" do + before { organization.update!(feature_flags: ["multiple_payment_methods"]) } + + it "extracts and stores card details in PaymentMethod.details" do + result = event_service.call + + expect(result).to be_success + payment_method = PaymentMethod.last + expect(payment_method.details).to include( + "brand" => "Visa", + "last4" => "0000", + "expiration_month" => "02", + "expiration_year" => "26", + "card_holder_name" => "Kevin Smith", + "issuer" => "test" + ) + end + end + + context "when event is card_token.updated" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/card_token.updated.json"))) } + + it "handles the event" do + result = event_service.call + + expect(result).to be_success + moneyhash_customer.reload + expect(moneyhash_customer.payment_method_id).to eq(event_json.dig("data", "card_token", "id")) + end + + context "when multiple_payment_methods feature flag is enabled" do + before { organization.update!(feature_flags: ["multiple_payment_methods"]) } + + it "updates card details in existing PaymentMethod.details" do + result = event_service.call + + expect(result).to be_success + payment_method = PaymentMethod.last + expect(payment_method.details).to include("brand", "last4") + end + end + end + + context "when event is card_token.deleted" do + let(:event_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/card_token.deleted.json"))) } + let(:payment_method_id) { event_json.dig("data", "card_token", "id") } + + before { moneyhash_customer.update!(payment_method_id:) } + + it "handles the event" do + result = event_service.call + + expect(result).to be_success + moneyhash_customer.reload + expect(moneyhash_customer.payment_method_id).to be_nil + end + + context "when not the same card" do + let(:payment_method_id) { "test_payment_id" } + + it "does not clear the default payment_method_id" do + result = event_service.call + + expect(result).to be_success + moneyhash_customer.reload + expect(moneyhash_customer.payment_method_id).to eq("test_payment_id") + end + end + + context "when multiple_payment_methods feature flag is enabled" do + let!(:payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer: moneyhash_customer, + provider_method_id: payment_method_id + ) + end + + before do + organization.update!(feature_flags: ["multiple_payment_methods"]) + end + + it "soft-deletes the PaymentMethod record" do + expect { event_service.call }.to change { payment_method.reload.discarded? }.from(false).to(true) + end + end + end + end +end diff --git a/spec/services/payment_providers/moneyhash/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/moneyhash/handle_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..f6f2185 --- /dev/null +++ b/spec/services/payment_providers/moneyhash/handle_incoming_webhook_service_spec.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Moneyhash::HandleIncomingWebhookService do + subject(:result) { described_class.call(inbound_webhook:) } + + let(:organization) { create(:organization) } + let(:code) { "mh-test" } + let(:moneyhash_provider) { create(:moneyhash_provider, code:, organization:) } + let(:intent_processed_payload) { JSON.parse(Rails.root.join("spec/fixtures/moneyhash/intent.processed.json").read) } + let(:inbound_webhook) { create :inbound_webhook, source: :moneyhash, organization:, code:, payload: intent_processed_payload } + let(:event_result) { intent_processed_payload } + + it "checks the webhook" do + moneyhash_provider + expect(result).to be_success + expect(result.event).to eq(event_result) + expect(PaymentProviders::Moneyhash::HandleEventJob).to have_been_enqueued + end + + context "when failing to find the provider" do + let(:inbound_webhook) { create :inbound_webhook, source: :moneyhash, organization:, code:, payload: "invalid" } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Payment provider not found") + end + end +end diff --git a/spec/services/payment_providers/moneyhash/payments/create_service_spec.rb b/spec/services/payment_providers/moneyhash/payments/create_service_spec.rb new file mode 100644 index 0000000..acdca89 --- /dev/null +++ b/spec/services/payment_providers/moneyhash/payments/create_service_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Moneyhash::Payments::CreateService do + let(:organization) { create(:organization) } + let(:moneyhash_provider) { create(:moneyhash_provider, organization:) } + let(:customer) { create(:customer, organization:) } + let(:moneyhash_customer) { create(:moneyhash_customer, customer:, payment_provider: moneyhash_provider) } + + let(:reference) { "1234567890" } + let(:metadata) { {} } + + let(:invoice) { create(:invoice, organization:, customer:, invoice_type: :subscription) } + let(:payment) { create(:payment, payable: invoice, payment_provider: moneyhash_provider, payment_provider_customer: moneyhash_customer) } + + let(:failure_response) { JSON.parse(File.read("spec/fixtures/moneyhash/recurring_mit_payment_failure_response.json")) } + let(:success_response) { JSON.parse(File.read("spec/fixtures/moneyhash/recurring_mit_payment_success_response.json")) } + + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:response) { instance_double(Net::HTTPOK) } + let(:endpoint) { "#{PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/" } + + describe "#call" do + before do + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + end + + context "when payment succeeds" do + before do + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(success_response.to_json) + end + + it "returns success with payment details" do + result = described_class.call(payment:, reference:, metadata:) + + expect(result).to be_success + expect(result.payment).to have_attributes( + status: "PROCESSED", + provider_payment_id: success_response.dig("data", "id"), + payable_payment_status: "succeeded" + ) + end + end + + context "when payment fails" do + before do + allow(lago_client).to receive(:post_with_response) + .and_raise(LagoHttpClient::HttpError.new(400, failure_response, "")) + end + + it "returns failure with error details" do + result = described_class.call(payment:, reference:, metadata:) + + expect(result).to be_failure + expect(result.error_code).to eq(400) + expect(result.error_message).to eq(failure_response) + expect(payment.status).to eq("PENDING") + expect(payment.payable_payment_status).to eq("processing") + end + end + + context "when multiple_payment_methods feature flag is enabled" do + let(:payment_method) do + create(:payment_method, + customer:, + payment_provider_customer: moneyhash_customer, + provider_method_id: "pm_test_123") + end + + before do + organization.update!(feature_flags: ["multiple_payment_methods"]) + payment.update!(payment_method:) + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(success_response.to_json) + end + + it "uses payment_method provider_method_id as card_token" do + described_class.call(payment:, reference:, metadata:) + + expect(lago_client).to have_received(:post_with_response) do |params, _headers| + expect(params[:card_token]).to eq("pm_test_123") + end + end + end + + context "when multiple_payment_methods feature flag is disabled" do + let(:moneyhash_customer) do + create(:moneyhash_customer, + customer:, + payment_provider: moneyhash_provider, + payment_method_id: "legacy_pm_456") + end + + before do + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(success_response.to_json) + end + + it "uses provider_customer payment_method_id as card_token" do + described_class.call(payment:, reference:, metadata:) + + expect(lago_client).to have_received(:post_with_response) do |params, _headers| + expect(params[:card_token]).to eq("legacy_pm_456") + end + end + end + end +end diff --git a/spec/services/payment_providers/moneyhash/validate_incoming_webhook_service_spec.rb b/spec/services/payment_providers/moneyhash/validate_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..021a1f1 --- /dev/null +++ b/spec/services/payment_providers/moneyhash/validate_incoming_webhook_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Moneyhash::ValidateIncomingWebhookService do + subject(:result) do + described_class.call(payload:, signature:, payment_provider:) + end + + let(:payload) { "webhook_payload" } + let(:signature) { "t=1743090080,v1=placeholder,v2=placeholder,v3=ca13480c8142f2f2b44822c764909027974e84b3e8c94457a314f129d8d60148" } + let(:payment_provider) { create(:moneyhash_provider, signature_key: "test_signature_key") } + + it "return success when signature is valid" do + result = described_class.call(payload:, signature:, payment_provider:) + expect(result).to be_success + end + + it "returns a service failure when signature is invalid" do + signature = "Moneyhash-Signature: t=1743090080,v1=placeholder,v2=placeholder,v3=invalid_signature" + result = described_class.call(payload:, signature:, payment_provider:) + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.message).to eq("webhook_error: Invalid signature") + end +end diff --git a/spec/services/payment_providers/moneyhash_service_spec.rb b/spec/services/payment_providers/moneyhash_service_spec.rb new file mode 100644 index 0000000..b17e4ea --- /dev/null +++ b/spec/services/payment_providers/moneyhash_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::MoneyhashService do + include_context "with mocked security logger" + + let(:organization) { create(:organization) } + let(:moneyhash_provider) { create(:moneyhash_provider, organization:) } + let(:customer) { create(:customer, organization:) } + let(:moneyhash_customer) { create(:moneyhash_customer, customer:) } + + describe "#create_or_update" do + let(:webhook_signature_response) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/webhook_signature_response.json"))) } + + before do + allow_any_instance_of(LagoHttpClient::Client).to receive(:get).and_return(webhook_signature_response) # rubocop:disable RSpec/AnyInstance + end + + it "creates a new moneyhash provider with the webhook signature key" do + result = described_class.new.create_or_update(organization:, code: "test_code", name: "test_name", flow_id: "test_flow_id") + expect(result).to be_success + expect(result.moneyhash_provider).to be_a(PaymentProviders::MoneyhashProvider) + expect(result.moneyhash_provider.signature_key).to eq(webhook_signature_response.dig("data", "webhook_signature_secret")) + expect(result.moneyhash_provider.code).to eq("test_code") + expect(result.moneyhash_provider.name).to eq("test_name") + expect(result.moneyhash_provider.flow_id).to eq("test_flow_id") + end + + it_behaves_like "produces a security log", "integration.created" do + before { described_class.new.create_or_update(organization:, code: "test_code", name: "test_name", flow_id: "test_flow_id") } + end + + it "updates the existing moneyhash provider but leaves the signature key unchanged" do + moneyhash_provider.update!(signature_key: "same_signature_key") + result = described_class.new.create_or_update(organization:, code: moneyhash_provider.code, name: "updated_name", flow_id: "updated_flow_id") + expect(result).to be_success + expect(result.moneyhash_provider).to be_a(PaymentProviders::MoneyhashProvider) + expect(result.moneyhash_provider.signature_key).to eq("same_signature_key") + expect(result.moneyhash_provider.code).to eq(moneyhash_provider.code) + expect(result.moneyhash_provider.name).to eq("updated_name") + expect(result.moneyhash_provider.flow_id).to eq("updated_flow_id") + end + + it_behaves_like "produces a security log", "integration.updated" do + before do + moneyhash_provider.update!(signature_key: "same_signature_key") + described_class.new.create_or_update(organization:, code: moneyhash_provider.code, name: "updated_name", flow_id: "updated_flow_id") + end + end + end +end diff --git a/spec/services/payment_providers/stripe/customers/create_service_spec.rb b/spec/services/payment_providers/stripe/customers/create_service_spec.rb new file mode 100644 index 0000000..b4a88e9 --- /dev/null +++ b/spec/services/payment_providers/stripe/customers/create_service_spec.rb @@ -0,0 +1,209 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Customers::CreateService do + let(:create_service) { described_class.new(customer:, payment_provider_id:, params:, async:) } + + let(:customer) { create(:customer) } + let(:stripe_provider) { create(:stripe_provider, organization: customer.organization) } + let(:payment_provider_id) { stripe_provider.id } + let(:params) { {provider_customer_id: "id", sync_with_provider: true, provider_payment_methods:} } + let(:async) { true } + + let(:provider_payment_methods) { %w[card] } + let(:feature_flags) { ["multiple_payment_methods"] } + + describe ".call" do + before do + customer.organization.update!(feature_flags:) + end + + it "creates a payment_provider_customer" do + result = create_service.call + + expect(result).to be_success + expect(result.provider_customer).to be_present + expect(result.provider_customer.provider_customer_id).to eq("id") + end + + context "when provider customer is persisted" do + before do + create( + :stripe_customer, + customer:, + payment_provider: stripe_provider, + provider_payment_methods: %w[sepa_debit] + ) + end + + context "when provider payment methods are present" do + let(:provider_payment_methods) { %w[card sepa_debit] } + + it "updates payment methods" do + result = create_service.call + + expect(result.provider_customer.provider_payment_methods).to eq(provider_payment_methods) + end + end + + context "when provider payment methods are not present" do + let(:provider_payment_methods) { nil } + + it "does not update payment methods" do + result = create_service.call + + expect(result.provider_customer.provider_payment_methods).to eq(%w[sepa_debit]) + end + end + end + + context "when provider customer is not persisted" do + context "when provider payment methods are present" do + let(:provider_payment_methods) { %w[card sepa_debit] } + + it "saves payment methods" do + result = create_service.call + + expect(result.provider_customer.provider_payment_methods).to eq(provider_payment_methods) + end + end + + context "when provider payment methods are not present" do + let(:provider_payment_methods) { nil } + + it "saves default payment method" do + result = create_service.call + + expect(result.provider_customer.provider_payment_methods).to eq(%w[card]) + end + end + end + + context "when no provider customer id and should create on service" do + let(:params) do + {provider_customer_id: nil, sync_with_provider: true, provider_payment_methods: %w[card]} + end + + it "enqueues a job to create the customer on the provider" do + expect { create_service.call }.to have_enqueued_job(PaymentProviderCustomers::StripeCreateJob) + end + + it "does not enqueue FetchDefaultPaymentMethodJob" do + expect { create_service.call } + .not_to have_enqueued_job(PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodJob) + end + end + + context "when removing the provider customer id and should create on service" do + let(:params) do + {provider_customer_id: nil, sync_with_provider: true} + end + + let(:stripe_customer) do + create( + :stripe_customer, + customer:, + payment_provider: stripe_provider + ) + end + + before { stripe_customer } + + it "updates the provider customer" do + expect do + result = create_service.call + + expect(result).to be_success + + expect(result.provider_customer.provider_customer_id).to be_nil + end.not_to have_enqueued_job(PaymentProviderCustomers::StripeCreateJob) + end + + it "does not enqueue FetchDefaultPaymentMethodJob" do + expect { create_service.call } + .not_to have_enqueued_job(PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodJob) + end + end + + context "when provider customer id is set" do + let(:params) do + {provider_customer_id: "id", sync_with_provider:, provider_payment_methods:} + end + + context "when sync with provider is blank" do + let(:sync_with_provider) { nil } + + let(:provider) { create(:stripe_provider, organization: customer.organization) } + + context "when provider customer exists" do + before do + create(:stripe_customer, customer:, payment_provider_id: provider.id) + end + + context "when provider payment methods require setup" do + let(:provider_payment_methods) { %w[card sepa_debit] } + + it "generates checkout url" do + result = nil + expect do + result = create_service.call + end.to have_enqueued_job_after_commit(PaymentProviderCustomers::StripeCheckoutUrlJob).with do |stripe_customer| + expect(stripe_customer).to eq(result.provider_customer) + end + end + end + + context "when provider payment methods do not require setup" do + let(:provider_payment_methods) { %w[crypto] } + + it "does not generate checkout url" do + expect { create_service.call }.not_to have_enqueued_job(PaymentProviderCustomers::StripeCheckoutUrlJob) + end + end + + it "does not create customer" do + expect { create_service.call }.not_to have_enqueued_job(PaymentProviderCustomers::StripeCreateJob) + end + + it "enqueues FetchDefaultPaymentMethodJob" do + expect { create_service.call } + .to have_enqueued_job(PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodJob) + end + + context "without feature flag" do + let(:feature_flags) { [] } + + it "does not enqueue FetchDefaultPaymentMethodJob" do + expect { create_service.call } + .not_to have_enqueued_job(PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodJob) + end + end + end + + context "when provider customer does not exist" do + it "does not generate checkout url" do + expect { create_service.call }.not_to have_enqueued_job(PaymentProviderCustomers::StripeCheckoutUrlJob) + end + + it "does not create customer" do + expect { create_service.call }.not_to have_enqueued_job(PaymentProviderCustomers::StripeCreateJob) + end + end + end + + context "when sync with provider is true" do + let(:sync_with_provider) { true } + let(:provider) { create(:stripe_provider, organization: customer.organization) } + + it "does not generate checkout url" do + expect { create_service.call }.not_to have_enqueued_job(PaymentProviderCustomers::StripeCheckoutUrlJob) + end + + it "does not enqueue a job to create the customer on the provider" do + expect { create_service.call }.not_to have_enqueued_job(PaymentProviderCustomers::StripeCreateJob) + end + end + end + end +end diff --git a/spec/services/payment_providers/stripe/customers/fetch_default_payment_method_service_spec.rb b/spec/services/payment_providers/stripe/customers/fetch_default_payment_method_service_spec.rb new file mode 100644 index 0000000..64fead8 --- /dev/null +++ b/spec/services/payment_providers/stripe/customers/fetch_default_payment_method_service_spec.rb @@ -0,0 +1,144 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Customers::FetchDefaultPaymentMethodService do + subject(:service) { described_class.new(provider_customer:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:provider_customer_id) { "cus_123456" } + let(:provider_customer) do + create( + :stripe_customer, + customer:, + provider_customer_id:, + payment_provider: stripe_provider, + provider_payment_methods: %w[card] + ) + end + + describe "#call" do + let(:payment_method_id) { "pm_123456" } + let(:retrieve_service_result) do + PaymentProviderCustomers::Stripe::RetrieveLatestPaymentMethodService::Result.new.tap do |r| + r.payment_method_id = payment_method_id + end + end + + before do + allow(PaymentProviderCustomers::Stripe::RetrieveLatestPaymentMethodService) + .to receive(:call!) + .with(provider_customer:) + .and_return(retrieve_service_result) + end + + context "when provider_customer has no provider_customer_id" do + let(:provider_customer_id) { nil } + + it "returns result without creating payment method" do + result = service.call + + expect(result).to be_success + expect(customer.payment_methods.empty?).to be true + end + end + + context "when no payment method is found on Stripe" do + let(:payment_method_id) { nil } + + it "returns result without creating payment method" do + result = service.call + + expect(result).to be_success + expect(customer.payment_methods.empty?).to be true + end + end + + context "when payment method is found on Stripe" do + let(:stripe_payment_method) do + Stripe::PaymentMethod.construct_from( + id: payment_method_id, + type: "card", + card: { + last4: "4242", + display_brand: "visa", + exp_month: 12, + exp_year: 2025 + } + ) + end + + before do + allow(Stripe::PaymentMethod) + .to receive(:retrieve) + .with(payment_method_id, {api_key: stripe_provider.secret_key}) + .and_return(stripe_payment_method) + end + + it "creates a payment method in Lago with details" do + result = service.call + + payment_method = customer.payment_methods.order(created_at: :desc).first + + expect(result).to be_success + expect(payment_method.provider_method_id).to eq(payment_method_id) + expect(payment_method.details["type"]).to eq("card") + expect(payment_method.details["last4"]).to eq("4242") + expect(payment_method.details["brand"]).to eq("visa") + expect(payment_method.details["expiration_month"]).to eq(12) + expect(payment_method.details["expiration_year"]).to eq(2025) + end + + context "when payment method type is not card" do + let(:stripe_payment_method) do + Stripe::PaymentMethod.construct_from( + id: payment_method_id, + object: "payment_method", + type: "link", + customer: provider_customer_id, + link: {email: "this@test.email"}, + billing_details: { + address: {city: nil, country: "US", line1: nil, line2: nil, postal_code: "94105", state: nil}, + email: "this@test.email", + name: nil, + phone: nil + }, + metadata: {} + ) + end + + it "creates a payment method with only the type" do + result = service.call + + payment_method = customer.payment_methods.order(created_at: :desc).first + + expect(result).to be_success + expect(payment_method.provider_method_id).to eq(payment_method_id) + expect(payment_method.details).to eq("type" => "link") + end + end + + context "when payment method already exists in Lago" do + let!(:existing_payment_method) do + create( + :payment_method, + customer:, + payment_provider_customer: provider_customer, + provider_method_id: payment_method_id, + payment_provider: stripe_provider + ) + end + + it "returns the existing payment method without creating a duplicate" do + expect { service.call }.not_to change(PaymentMethod, :count) + + result = service.call + expect(result).to be_success + expect(result.payment_method).to eq(existing_payment_method) + end + end + end + end +end diff --git a/spec/services/payment_providers/stripe/handle_event_service_spec.rb b/spec/services/payment_providers/stripe/handle_event_service_spec.rb new file mode 100644 index 0000000..66eb973 --- /dev/null +++ b/spec/services/payment_providers/stripe/handle_event_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::HandleEventService do + subject(:event_service) { described_class.new(organization:, event_json:) } + + let(:organization) { create(:organization) } + + let(:payment_service) { instance_double(Invoices::Payments::StripeService) } + let(:provider_customer_service) { instance_double(PaymentProviderCustomers::StripeService) } + let(:service_result) { BaseService::Result.new } + + before do + allow(Invoices::Payments::StripeService).to receive(:new) + .and_return(payment_service) + allow(payment_service).to receive(:update_payment_status) + .and_return(service_result) + end + + context "when setup intent event" do + let(:event_json) do + get_stripe_fixtures("webhooks/setup_intent_succeeded.json") + end + + before do + allow(PaymentProviders::Stripe::Webhooks::SetupIntentSucceededService).to receive(:call) + .and_return(service_result) + end + + it "routes the event to an other service" do + result = event_service.call + + expect(result).to be_success + + expect(PaymentProviders::Stripe::Webhooks::SetupIntentSucceededService).to have_received(:call) + end + end + + context "when customer updated event" do + let(:event_json) do + get_stripe_fixtures("webhooks/customer_updated.json") + end + + before do + allow(PaymentProviders::Stripe::Webhooks::CustomerUpdatedService).to receive(:call) + .and_return(service_result) + end + + it "routes the event to an other service" do + result = event_service.call + + expect(result).to be_success + + expect(PaymentProviders::Stripe::Webhooks::CustomerUpdatedService).to have_received(:call) + end + end + + context "when payment method detached event" do + let(:event_json) { get_stripe_fixtures("webhooks/payment_method_detached.json") } + + before do + allow(PaymentProviderCustomers::StripeService).to receive(:new) + .and_return(provider_customer_service) + allow(provider_customer_service).to receive(:delete_payment_method) + .and_return(service_result) + end + + it "routes the event to an other service" do + result = event_service.call + + expect(result).to be_success + + expect(PaymentProviderCustomers::StripeService).to have_received(:new) + expect(provider_customer_service).to have_received(:delete_payment_method) + end + end + + context "when refund updated event" do + let(:refund_service) { instance_double(CreditNotes::Refunds::StripeService) } + + let(:event_json) do + get_stripe_fixtures("webhooks/charge_refund_updated.json") + end + + before do + allow(CreditNotes::Refunds::StripeService).to receive(:new) + .and_return(refund_service) + allow(refund_service).to receive(:update_status) + .and_return(service_result) + end + + it "routes the event to an other service" do + result = event_service.call + + expect(result).to be_success + + expect(CreditNotes::Refunds::StripeService).to have_received(:new) + expect(refund_service).to have_received(:update_status) + end + end + + context "when event does not match an expected event type" do + let(:event_json) do + { + id: "foo", + type: "invalid", + data: { + object: {id: "foo"} + } + }.to_json + end + + it "returns an empty result" do + result = event_service.call + + expect(result).to be_success + + expect(Invoices::Payments::StripeService).not_to have_received(:new) + expect(payment_service).not_to have_received(:update_payment_status) + end + end +end diff --git a/spec/services/payment_providers/stripe/handle_incoming_webhook_service_spec.rb b/spec/services/payment_providers/stripe/handle_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..5e1023d --- /dev/null +++ b/spec/services/payment_providers/stripe/handle_incoming_webhook_service_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::HandleIncomingWebhookService do + subject(:result) { described_class.call(inbound_webhook:) } + + let(:inbound_webhook) { create :inbound_webhook } + let(:webhook_payload) { JSON.parse(inbound_webhook.payload) } + let(:event_result) { Stripe::Event.construct_from(webhook_payload) } + + it "checks the webhook" do + expect(result).to be_success + expect(result.event).to eq(event_result) + expect(PaymentProviders::Stripe::HandleEventJob).to have_been_enqueued + end + + context "when failing to parse payload" do + let(:inbound_webhook) { create :inbound_webhook, payload: "invalid" } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("webhook_error") + expect(result.error.error_message).to eq("Invalid payload") + end + end +end diff --git a/spec/services/payment_providers/stripe/payments/authorize_service_spec.rb b/spec/services/payment_providers/stripe/payments/authorize_service_spec.rb new file mode 100644 index 0000000..eda21c9 --- /dev/null +++ b/spec/services/payment_providers/stripe/payments/authorize_service_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Payments::AuthorizeService do + subject(:authorize_service) { described_class.new(amount:, currency:, provider_customer:, payment_method:, unique_id:, metadata:) } + + let(:amount) { 0.20 } + let(:currency) { "USD" } + let(:provider_customer) { create(:stripe_customer, payment_provider: create(:stripe_provider), customer:, payment_method_id:) } + let(:payment_method) { create(:payment_method, payment_provider_customer: provider_customer, provider_method_id:) } + let(:unique_id) { SecureRandom.uuid } + let(:metadata) { {} } + + let(:customer) { create(:customer) } + let(:provider_method_id) { "pm_from_payment_method" } + let(:payment_method_id) { "pm_from_provider_customer" } + let(:stripe_result) do + result = BaseService::Result.new + result.payment_method_id = "pm_from_stripe" + result + end + + before do + allow(PaymentProviderCustomers::Stripe::RetrieveLatestPaymentMethodService).to receive(:call!).and_return(stripe_result) + end + + describe ".call" do + context "without provider_method_id" do + let(:payment_method) { nil } + let(:payment_method_id) { nil } + let(:stripe_result) do + result = BaseService::Result.new + result.payment_method_id = nil + result + end + + it "fails" do + result = subject.call + + expect(result).not_to be_success + end + end + + context "with provider_method_id" do + let(:payment_intent) do + Stripe::PaymentIntent.construct_from( + id: "pi_#{SecureRandom.hex(6)}" + ) + end + + before { allow(::Stripe::PaymentIntent).to receive(:create).and_return(payment_intent) } + + it "creates the stripe payment intent" do + result = subject.call + + expect(result).to be_success + expect(result.stripe_payment_intent).to be(payment_intent) + end + + it "cancels the payment intent later" do + subject.call + + expect(PaymentProviders::CancelPaymentAuthorizationJob).to have_been_enqueued + end + end + end + + describe "private" do + describe "#find_provider_method_id" do + let(:result) { subject.send(:find_provider_method_id) } + + context "with payment_method" do + it "uses payment_method#provider_method_id" do + expect(result).to eq(provider_method_id) + end + end + + context "without payment_method" do + let(:payment_method) { nil } + + it "uses provider_customer#payment_method_id" do + expect(result).to eq(payment_method_id) + end + end + + context "when none is available" do + let(:payment_method) { nil } + let(:payment_method_id) { nil } + + it "fetch stripe default payment method" do + expect(result).to eq("pm_from_stripe") + end + end + end + end +end diff --git a/spec/services/payment_providers/stripe/payments/create_service_spec.rb b/spec/services/payment_providers/stripe/payments/create_service_spec.rb new file mode 100644 index 0000000..2afcdb0 --- /dev/null +++ b/spec/services/payment_providers/stripe/payments/create_service_spec.rb @@ -0,0 +1,622 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Payments::CreateService do + subject(:create_service) { described_class.new(payment:, reference:, metadata:) } + + let(:customer) { create(:customer, payment_provider_code: code, country:) } + let(:country) { "CA" } + let(:organization) { customer.organization } + let(:stripe_payment_provider) { create(:stripe_provider, organization:, code:) } + let(:stripe_customer) { create(:stripe_customer, customer:, payment_method_id: "pm_123456", payment_provider: stripe_payment_provider) } + let(:code) { "stripe_1" } + let(:reference) { "organization.name - Invoice #{invoice.number}" } + let(:currency) { "EUR" } + let(:metadata) do + { + lago_customer_id: customer.id, + lago_invoice_id: invoice.id, + lago_payment_id: payment.id, + invoice_issuing_date: invoice.issuing_date.iso8601, + invoice_type: invoice.invoice_type, + lago_payable_id: payment.payable_id, + lago_payable_type: payment.payable_type, + lago_organization_id: payment.payable.organization_id, + lago_billing_entity_id: payment.payable.billing_entity.id + } + end + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency:, + ready_for_payment_processing: true + ) + end + + let(:payment) do + create( + :payment, + payable: invoice, + status: "pending", + payment_provider: stripe_payment_provider, + payment_provider_customer: stripe_customer, + amount_cents: invoice.total_amount_cents, + amount_currency: invoice.currency, + provider_payment_id: nil + ) + end + + describe ".call" do + let(:provider_customer_service_result) do + BaseService::Result.new.tap do |result| + result.payment_method = Stripe::PaymentMethod.new(id: "pm_123456") + end + end + + let(:customer_response) do + get_stripe_fixtures("customer_retrieve_response.json") + end + + let(:stripe_payment_intent_data) do + { + id: "pi_123456", + status: payment_status, + amount: invoice.total_amount_cents, + currency: invoice.currency + } + end + + let(:payment_status) { "succeeded" } + + before do + stripe_payment_provider + stripe_customer + + allow(Stripe::PaymentIntent).to receive(:create).and_call_original + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .to_return(body: stripe_payment_intent_data.to_json) + + allow(SegmentTrackJob).to receive(:perform_later) + allow(Invoices::PrepaidCreditJob).to receive(:perform_later) + + allow(PaymentProviderCustomers::Stripe::CheckPaymentMethodService).to receive(:call) + .and_return(provider_customer_service_result) + + stub_request(:get, "https://api.stripe.com/v1/customers/#{stripe_customer.provider_customer_id}") + .to_return(status: 200, body: customer_response, headers: {}) + end + + it "creates a stripe payment and a payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(stripe_payment_provider) + expect(result.payment.payment_provider_customer).to eq(stripe_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(Stripe::PaymentIntent).to have_received(:create) + end + + context "when multiple payment methods are enabled" do + let(:default_payment_method) { create(:payment_method, customer:, provider_method_id: "pm_123456") } + + before do + payment.update!(payment_method: default_payment_method) + organization.update!(feature_flags: ["multiple_payment_methods"]) + end + + it "creates a stripe payment and a payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(stripe_payment_provider) + expect(result.payment.payment_provider_customer).to eq(stripe_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(Stripe::PaymentIntent).to have_received(:create) + end + end + + context "when customer does not have a payment method" do + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_payment_provider) } + + before do + allow(Stripe::Customer).to receive(:retrieve) + .and_return(Stripe::StripeObject.construct_from( + { + invoice_settings: { + default_payment_method: nil + }, + default_source: nil + } + )) + + allow(Stripe::Customer).to receive(:list_payment_methods).and_call_original + stub_request(:get, %r{/v1/customers/#{stripe_customer.provider_customer_id}/payment_methods}).and_return( + status: 200, body: get_stripe_fixtures("customer_list_payment_methods_response.json") do |h| + h[:data][0][:id] = "pm_123456" + end + ) + end + + it "retrieves the payment method" do + result = create_service.call + + expect(result).to be_success + expect(customer.stripe_customer.reload).to be_present + expect(customer.stripe_customer.provider_customer_id).to eq(stripe_customer.provider_customer_id) + expect(customer.stripe_customer.payment_method_id).to eq("pm_123456") + + expect(Stripe::Customer).to have_received(:list_payment_methods).with(stripe_customer.provider_customer_id, {}, anything) + expect(Stripe::PaymentIntent).to have_received(:create) + end + end + + context "with card error on stripe" do + let(:payment_response) do + get_stripe_fixtures("payment_intent_card_declined_response.json") do |h| + h["error"]["payment_intent"]["id"] = "pi_declined" + end + end + + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + + let(:subscription) do + create(:subscription, organization:, customer:) + end + + let(:organization) do + create(:organization, webhook_url: "https://webhook.com") + end + + before do + subscription + + stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .to_return(status: 402, body: payment_response, headers: {}) + end + + it "returns a failed result" do + result = create_service.call + + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("stripe_error") + expect(result.error.error_message).to eq("Your card was declined.") + + expect(result.error_message).to eq("Your card was declined.") + expect(result.error_code).to eq("card_declined") + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + expect(result.payment.error_code).to eq("card_declined") + expect(payment.reload.provider_payment_id).to eq("pi_declined") + end + end + + context "with stripe error" do + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + + let(:subscription) do + create(:subscription, organization:, customer:) + end + + let(:organization) do + create(:organization, webhook_url: "https://webhook.com") + end + + before do + subscription + + allow(Stripe::PaymentIntent).to receive(:create) + .and_raise(::Stripe::StripeError.new("error")) + end + + it "returns a success result with error messages" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("stripe_error") + expect(result.error.error_message).to eq("error") + + expect(result.error_message).to eq("error") + expect(result.error_code).to be_nil + + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + expect(result.payment.error_code).to be_nil + end + end + + context "when invoice has a too small amount" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 20, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + before do + subscription + + allow(Stripe::PaymentIntent).to receive(:create) + .and_raise(::Stripe::InvalidRequestError.new("amount_too_small", {}, code: "amount_too_small")) + end + + it "returns an empty result" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("stripe_error") + expect(result.error.error_message).to eq("amount_too_small") + + expect(result.error_message).to eq("amount_too_small") + expect(result.error_code).to eq("amount_too_small") + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("pending") + end + end + + context "when card requires authentication (3DS)" do + [:invoice, :payment_request].each do |payable_type| + context "when payable_type is #{payable_type}" do + let(:payable) do + if payable_type == :payment_request + create(:payment_request, customer: invoice.customer, amount_cents: invoice.total_amount_cents, currency: invoice.currency, invoices: [invoice]) + else + invoice + end + end + + let(:payment) do + create( + :payment, + payable:, + status: "pending", + payment_provider: stripe_payment_provider, + payment_provider_customer: stripe_customer, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency, + provider_payment_id: nil + ) + end + + context "when it's the first try" do + context "with 3ds support enabled" do + before { stripe_payment_provider.update!(supports_3ds: true) } + + it "enqueued a new payment creation job" do + WebMock.stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with(body: ->(request) { + params = Rack::Utils.parse_query(request) + expect(params["confirm"]).to eq "true" + expect(params["off_session"]).to eq "true" + expect(params["error_on_requires_action"]).to eq "true" + }) + .to_return( + status: 400, + body: get_stripe_fixtures("payment_intent_authentication_required_response.json", version: "2025-04-30.basil") + ) + + result = create_service.call + + expect(result).to be_failure + + expect(result.error_code).to eq "authentication_required" + expect(result.error.code).to eq "stripe_error" + expect(result.reraise).to eq false + expect(result.should_retry).to eq true + expect(Stripe::PaymentIntent).to have_received(:create) + payment.reload + expect(payment.status).to eq "failed" + expect(payment.error_code).to eq "authentication_required" + expect(payment.payable_payment_status).to eq "failed" + expect(payment.provider_payment_id).to eq "pi_3SUpk9Q8iJWBZFaM20I3flZT" + end + end + + context "without 3ds support" do + it "enqueued a new payment creation job" do + WebMock.stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with(body: ->(request) { + params = Rack::Utils.parse_query(request) + expect(params["confirm"]).to eq "true" + expect(params["off_session"]).to eq "true" + expect(params["error_on_requires_action"]).to eq "true" + }) + .to_return( + status: 400, + body: get_stripe_fixtures("payment_intent_authentication_required_response.json", version: "2025-04-30.basil") + ) + + result = create_service.call + + expect(result).to be_failure + + expect(result.error_code).to eq "authentication_required" + expect(result.error.code).to eq "stripe_error" + expect(result.reraise).to eq false + expect(result.should_retry).to be_falsey + expect(Stripe::PaymentIntent).to have_received(:create) + payment.reload + expect(payment.status).to eq "failed" + expect(payment.error_code).to eq "authentication_required" + expect(payment.payable_payment_status).to eq "failed" + expect(payment.provider_payment_id).to eq "pi_3SUpk9Q8iJWBZFaM20I3flZT" + end + end + end + + context "when it's the second try" do + it "enqueued a new payment creation job" do + create(:payment, payable:, payable_payment_status: "failed", status: "failed", error_code: "authentication_required", provider_payment_id: "pi_3SUpk9Q8iJWBZFaM20I3flZT") + + WebMock.stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .with(body: ->(request) { + params = Rack::Utils.parse_query(request) + expect(params["confirm"]).to eq "true" + expect(params).not_to have_key("off_session") + expect(params).not_to have_key("error_on_requires_action") + }) + .to_return(status: 200, body: get_stripe_fixtures("payment_intent_requires_action_response.json", version: "2025-04-30.basil")) + + allow(::Invoices::Payments::CreateService).to receive(:call_async).and_call_original + + result = create_service.call + + expect(result).to be_success + + expect(result.error_code).to be_nil + expect(result.error).to be_nil + expect(result.reraise).to be_nil + expect(result.should_retry).to be_nil + expect(Stripe::PaymentIntent).to have_received(:create) + expect(::Invoices::Payments::CreateService).not_to have_received(:call_async) + + expect(result.payment.status).to eq "requires_action" + expect(result.payment.payable_payment_status).to eq "processing" + expect(result.payment.provider_payment_id).to eq "pi_3SUpkBQ8iJWBZFaM0SuylvJC" + expect(result.payment.provider_payment_data["type"]).to eq "redirect_to_url" + + expect(invoice.reload.payment_status).to eq "pending" + end + end + end + end + end + + context "when invoice amount is too big to pay with Boleto" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 100_000_00, + currency: "BRL", + ready_for_payment_processing: true + ) + end + + before do + subscription + + WebMock.stub_request(:post, "https://api.stripe.com/v1/payment_intents") + .to_return(status: 400, body: { + error: { + code: "amount_too_large", + doc_url: "https://stripe.com/docs/error-codes/amount-too-large", + message: "Amount must be no more than R$ 49,999.99 brl", + param: "amount", + request_log_url: "https://dashboard.stripe.com/test/logs/req_WAmkqXs7ajMNAU?t=1738144303", + type: "invalid_request_error" + } + }.to_json) + end + + it "returns an empty result" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.code).to eq("stripe_error") + expect(result.error.error_message).to eq("Amount must be no more than R$ 49,999.99 brl") + + expect(result.error_message).to eq("Amount must be no more than R$ 49,999.99 brl") + expect(result.error_code).to eq("amount_too_large") + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("failed") + end + end + + context "when payment status is processing" do + let(:payment_status) { "processing" } + + it "creates a stripe payment and a payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payment.id).to be_present + expect(result.payment.payable).to eq(invoice) + expect(result.payment.payment_provider).to eq(stripe_payment_provider) + expect(result.payment.payment_provider_customer).to eq(stripe_customer) + expect(result.payment.amount_cents).to eq(invoice.total_amount_cents) + expect(result.payment.amount_currency).to eq(invoice.currency) + expect(result.payment.status).to eq("processing") + expect(result.payment.payable_payment_status).to eq("processing") + + expect(Stripe::PaymentIntent).to have_received(:create) + end + end + + context "when customers country is IN" do + let(:payment_status) { "requires_action" } + + let(:stripe_payment_intent_data) do + { + id: "pi_123456", + status: payment_status, + amount: invoice.total_amount_cents, + currency: invoice.currency, + next_action: { + redirect_to_url: {url: "https://foo.bar"} + } + } + end + + before do + customer.update(country: "IN") + end + + it "creates a stripe payment and payment with requires_action status" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.status).to eq("requires_action") + expect(result.payment.provider_payment_data).not_to be_empty + end + + it "has enqueued a SendWebhookJob" do + result = create_service.call + + expect(SendWebhookJob).to have_been_enqueued + .with( + "payment.requires_action", + result.payment + ) + end + end + + context "with #payment_intent_payload" do + let(:payment_intent_payload) { create_service.__send__(:payment_intent_payload) } + let(:payload) do + { + amount: invoice.total_amount_cents, + currency: invoice.currency.downcase, + customer: customer.stripe_customer.provider_customer_id, + payment_method: customer.stripe_customer.payment_method_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + confirm: true, + off_session:, + return_url: create_service.__send__(:success_redirect_url), + error_on_requires_action:, + description: reference, + metadata: metadata + } + end + let(:off_session) { true } + let(:error_on_requires_action) { true } + + it "returns the payload" do + expect(payment_intent_payload).to eq(payload) + end + + context "when customers country is IN" do + let(:off_session) { false } + let(:error_on_requires_action) { false } + let(:customer) { create(:customer, payment_provider_code: code, country: "IN") } + + it "returns the payload" do + expect(payment_intent_payload).to eq(payload) + end + end + + context "when using customer balance as a payment method" do + let(:off_session) { false } + let(:stripe_customer) { + create(:stripe_customer, customer:, payment_method_id: "pm_123456", payment_provider: stripe_payment_provider, provider_payment_methods: ["customer_balance"]) + } + + let(:base_payload) do + payload.merge( + payment_method_data: {type: "customer_balance"}, + payment_method_options: { + customer_balance: { + funding_type: "bank_transfer" + } + } + ).tap { |p| p.delete(:payment_method) } + end + + context "when currency is EUR" do + let(:currency) { "EUR" } + let(:country) { "DE" } + + it "includes EU bank transfer details" do + expected_payload = base_payload.deep_merge( + payment_method_options: { + customer_balance: { + bank_transfer: { + eu_bank_transfer: {country:}, + type: "eu_bank_transfer" + } + } + } + ) + + expect(payment_intent_payload).to eq(expected_payload) + end + end + + context "when currency is USD" do + let(:currency) { "USD" } + + it "includes US bank transfer details" do + expected_payload = base_payload.deep_merge( + payment_method_options: { + customer_balance: { + bank_transfer: {type: "us_bank_transfer"} + } + } + ) + expect(payment_intent_payload).to eq(expected_payload) + end + end + + context "when currency is GBP" do + let(:currency) { "GBP" } + + it "includes GBP bank transfer details" do + expected_payload = base_payload.deep_merge( + payment_method_options: { + customer_balance: { + bank_transfer: {type: "gb_bank_transfer"} + } + } + ) + expect(payment_intent_payload).to eq(expected_payload) + end + end + end + end + end +end diff --git a/spec/services/payment_providers/stripe/refresh_webhook_service_spec.rb b/spec/services/payment_providers/stripe/refresh_webhook_service_spec.rb new file mode 100644 index 0000000..21659ba --- /dev/null +++ b/spec/services/payment_providers/stripe/refresh_webhook_service_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::RefreshWebhookService do + subject(:provider_service) { described_class.new(payment_provider) } + + let(:organization) { create(:organization) } + let(:payment_provider) { create(:stripe_provider, organization:, code: "stripe_sandbox", webhook_id: "we_1QzHw4Q8iJWBZFaMg54WCeIn") } + + describe ".call" do + let(:url) { "#{ENV["LAGO_API_URL"]}/webhooks/stripe/#{organization.id}?code=stripe_sandbox" } + let(:expected_request_body) do + { + enabled_events: PaymentProviders::StripeProvider::WEBHOOKS_EVENTS, + url: url + } + end + let(:stripe_api_response) do + get_stripe_fixtures("webhook_endpoint_update_response.json") do |h| + h["url"] = url + end + end + + before do + stub_const("ENV", ENV.to_h.merge("LAGO_API_URL" => "https://billing.example.com")) + stub_request(:post, "https://api.stripe.com/v1/webhook_endpoints/#{payment_provider.webhook_id}") + .with(body: expected_request_body) + .and_return(status: 200, body: stripe_api_response) + end + + it "registers a webhook on stripe" do + result = provider_service.call + + expect(result).to be_success + end + + context "when authentication fails on stripe API" do + before do + allow(::Stripe::WebhookEndpoint) + .to receive(:update) + .and_raise(::Stripe::AuthenticationError.new( + "This API call cannot be made with a publishable API key. Please use a secret API key. You can find a list of your API keys at https://dashboard.stripe.com/account/apikeys." + )) + end + + it "delivers an error webhook" do + result = provider_service.call + + expect(result).to be_success + + expect(SendWebhookJob).to have_been_enqueued + .with( + "payment_provider.error", + payment_provider, + provider_error: { + source: "stripe", + action: "payment_provider.register_webhook", + message: "This API call cannot be made with a publishable API key. Please use a secret API key. You can find a list of your API keys at https://dashboard.stripe.com/account/apikeys.", + code: nil + } + ) + end + end + + context "when the webhook limit is reached" do + before do + allow(::Stripe::WebhookEndpoint) + .to receive(:update) + .and_raise(::Stripe::InvalidRequestError.new( + "You have reached the maximum of 16 test webhook endpoints.", {} + )) + end + + it "delivers an error webhook" do + payment_provider.update!(secret_key: "sk_test_#{payment_provider.secret_key}") + result = provider_service.call + + expect(result).to be_success + + expect(SendWebhookJob).to have_been_enqueued + .with( + "payment_provider.error", + payment_provider, + provider_error: { + source: "stripe", + action: "payment_provider.register_webhook", + message: "You have reached the maximum of 16 test webhook endpoints.", + code: nil + } + ) + end + end + end +end diff --git a/spec/services/payment_providers/stripe/register_webhook_service_spec.rb b/spec/services/payment_providers/stripe/register_webhook_service_spec.rb new file mode 100644 index 0000000..e149585 --- /dev/null +++ b/spec/services/payment_providers/stripe/register_webhook_service_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::RegisterWebhookService do + subject(:provider_service) { described_class.new(payment_provider) } + + let(:organization) { create(:organization) } + let(:payment_provider) { create(:stripe_provider, organization:, code: "stripe_sandbox") } + + describe ".call" do + let(:url) { "#{ENV["LAGO_API_URL"]}/webhooks/stripe/#{organization.id}?code=stripe_sandbox" } + let(:expected_request_body) do + { + enabled_events: PaymentProviders::StripeProvider::WEBHOOKS_EVENTS, + url:, + api_version: ::Stripe.api_version + } + end + let(:stripe_api_response) do + get_stripe_fixtures("webhook_endpoint_create_response.json") do |h| + h["url"] = url + end + end + + before do + stub_const("ENV", ENV.to_h.merge("LAGO_API_URL" => "https://billing.example.com")) + stub_request(:post, "https://api.stripe.com/v1/webhook_endpoints") + .with(body: expected_request_body) + .and_return(status: 200, body: stripe_api_response) + end + + it "registers a webhook on stripe" do + result = provider_service.call + + expect(result).to be_success + end + + context "when authentication fails on stripe API" do + before do + allow(::Stripe::WebhookEndpoint) + .to receive(:create) + .and_raise(::Stripe::AuthenticationError.new( + "This API call cannot be made with a publishable API key. Please use a secret API key. You can find a list of your API keys at https://dashboard.stripe.com/account/apikeys." + )) + end + + it "delivers an error webhook" do + result = provider_service.call + + expect(result).to be_success + + expect(SendWebhookJob).to have_been_enqueued + .with( + "payment_provider.error", + payment_provider, + provider_error: { + source: "stripe", + action: "payment_provider.register_webhook", + message: "This API call cannot be made with a publishable API key. Please use a secret API key. You can find a list of your API keys at https://dashboard.stripe.com/account/apikeys.", + code: nil + } + ) + end + end + + context "when the webhook limit is reached" do + before do + allow(::Stripe::WebhookEndpoint) + .to receive(:create) + .and_raise(::Stripe::InvalidRequestError.new( + "You have reached the maximum of 16 test webhook endpoints.", {} + )) + end + + it "delivers an error webhook" do + payment_provider.update!(secret_key: "sk_test_#{payment_provider.secret_key}") + result = provider_service.call + + expect(result).to be_success + + expect(SendWebhookJob).to have_been_enqueued + .with( + "payment_provider.error", + payment_provider, + provider_error: { + source: "stripe", + action: "payment_provider.register_webhook", + message: "You have reached the maximum of 16 test webhook endpoints.", + code: nil + } + ) + end + end + + context "when overriding version" do + subject(:provider_service) { described_class.new(payment_provider, version: "YYYY-MM-DD.name") } + + let(:expected_request_body) do + { + enabled_events: PaymentProviders::StripeProvider::WEBHOOKS_EVENTS, + url:, + api_version: "YYYY-MM-DD.name" + } + end + + it "registers a webhook on stripe" do + expect(provider_service.call).to be_success + end + end + end +end diff --git a/spec/services/payment_providers/stripe/validate_incoming_webhook_service_spec.rb b/spec/services/payment_providers/stripe/validate_incoming_webhook_service_spec.rb new file mode 100644 index 0000000..c4066c5 --- /dev/null +++ b/spec/services/payment_providers/stripe/validate_incoming_webhook_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::ValidateIncomingWebhookService do + subject(:result) do + described_class.call(payload:, signature:, payment_provider:) + end + + let(:payload) { "webhook_payload" } + let(:signature) { "signature" } + let(:payment_provider) { create(:stripe_provider, webhook_secret:) } + let(:webhook_secret) { "webhook_secret" } + let(:stripe_default_tolerance) { 300 } + + before do + allow(::Stripe::Webhook::Signature).to receive(:verify_header).and_return(true) + end + + it "validates the payload" do + expect(result).to be_success + + expect(::Stripe::Webhook::Signature) + .to have_received(:verify_header) + .with( + payload, + signature, + webhook_secret, + tolerance: stripe_default_tolerance + ).once + end + + context "when signature is invalid" do + before do + allow(::Stripe::Webhook::Signature) + .to receive(:verify_header) + .and_raise( + ::Stripe::SignatureVerificationError.new( + "Unable to extract timestamp and signatures from header", + signature, + http_body: payload + ) + ) + end + + it "returns a service failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ServiceFailure) + expect(result.error.message).to eq("webhook_error: Invalid signature") + end + end +end diff --git a/spec/services/payment_providers/stripe/webhooks/charge_dispute_closed_service_spec.rb b/spec/services/payment_providers/stripe/webhooks/charge_dispute_closed_service_spec.rb new file mode 100644 index 0000000..3b00920 --- /dev/null +++ b/spec/services/payment_providers/stripe/webhooks/charge_dispute_closed_service_spec.rb @@ -0,0 +1,164 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Webhooks::ChargeDisputeClosedService do + subject(:service) { described_class.new(organization_id:, event:) } + + let(:organization_id) { organization.id } + let(:organization) { create(:organization) } + let(:membership) { create(:membership, organization:) } + let(:customer) { create(:customer, organization:) } + let(:intent_id) { "pi_3OzgpDH4tiDZlIUa0Ezzggtg" } + let(:payment) { create(:payment, payable:, provider_payment_id: intent_id) } + let(:event) { ::Stripe::Event.construct_from(JSON.parse(event_json)) } + + before { allow(::Payments::LoseDisputeService).to receive(:call).and_call_original } + + ["2020-08-27", "2025-04-30.basil"].each do |version| + describe "#call" do + before { payment } + + context "when payable is an invoice" do + let(:payable) { create(:invoice, customer:, organization:, status:, payment_status: "succeeded") } + + context "when dispute is lost" do + let(:event_json) do + get_stripe_fixtures("webhooks/charge_dispute_closed.json", version:) do |h| + if h.dig(:data, :object, :payment_intent)&.starts_with? "pi_" + h[:data][:object][:payment_intent] = intent_id + end + h[:data][:object][:status] = "lost" if h.dig(:data, :object, :status) + end + end + + context "when invoice is draft" do + let(:status) { "draft" } + + it "does not updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.not_to change(payment.payable.reload, :payment_dispute_lost_at).from(nil) + end + + it "does not deliver webhook" do + expect { service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when invoice is finalized" do + let(:status) { "finalized" } + + it "updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.to change(payment.payable, :payment_dispute_lost_at).from(nil) + end + + it "delivers a webhook" do + expect do + service.call + payment.payable.reload + end.to have_enqueued_job(SendWebhookJob).with( + "invoice.payment_dispute_lost", + payment.payable, + provider_error: "fraudulent" + ) + end + end + end + + context "when dispute is won" do + let(:event_json) do + get_stripe_fixtures("webhooks/charge_dispute_closed.json", version:) do |h| + if h.dig(:data, :object, :payment_intent)&.starts_with? "pi_" + h[:data][:object][:payment_intent] = intent_id + end + h[:data][:object][:status] = "won" if h.dig(:data, :object, :status) + end + end + + context "when invoice is draft" do + let(:status) { "draft" } + + it "does not updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.not_to change(payment.payable.reload, :payment_dispute_lost_at).from(nil) + end + + it "does not deliver webhook" do + expect { service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when invoice is finalized" do + let(:status) { "finalized" } + + it "does not updates invoice payment dispute lost" do + expect do + service.call + payment.payable.reload + end.not_to change(payment.payable.reload, :payment_dispute_lost_at).from(nil) + end + + it "does not deliver webhook" do + expect { service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + end + end + + context "when payable is a payment request" do + let(:payment) { create(:payment, payable:, provider_payment_id: intent_id) } + let(:payable) { create(:payment_request, customer:, organization:, invoices: [invoice_1, invoice_2]) } + let(:invoice_1) { create(:invoice, customer:, organization:, status: "finalized", payment_status: "succeeded") } + let(:invoice_2) { create(:invoice, customer:, organization:, status: "finalized", payment_status: "succeeded") } + + context "when dispute is lost" do + let(:event_json) do + get_stripe_fixtures("webhooks/charge_dispute_closed.json", version:) do |h| + if h.dig(:data, :object, :payment_intent)&.starts_with? "pi_" + h[:data][:object][:payment_intent] = intent_id + end + h[:data][:object][:status] = "lost" if h.dig(:data, :object, :status) + end + end + + it "flags all the invoices of the PaymentRequests" do + service.call + expect(::Payments::LoseDisputeService).to have_received(:call) + expect(invoice_1.reload.payment_dispute_lost_at).to eq Time.zone.at(event.created) + expect(invoice_2.reload.payment_dispute_lost_at).to eq Time.zone.at(event.created) + + expect(SendWebhookJob).to have_been_enqueued.once + .with("invoice.payment_dispute_lost", invoice_1, provider_error: "fraudulent") + expect(SendWebhookJob).to have_been_enqueued.once + .with("invoice.payment_dispute_lost", invoice_2, provider_error: "fraudulent") + + expect(Invoices::ProviderTaxes::VoidJob).to have_been_enqueued.twice + end + end + + context "when dispute is won" do + let(:event_json) do + get_stripe_fixtures("webhooks/charge_dispute_closed.json", version:) do |h| + if h.dig(:data, :object, :payment_intent)&.starts_with? "pi_" + h[:data][:object][:payment_intent] = intent_id + end + h[:data][:object][:status] = "won" if h.dig(:data, :object, :status) + end + end + + it "does not call LoseDisputeService" do + service.call + expect(::Payments::LoseDisputeService).not_to have_received(:call) + end + end + end + end + end +end diff --git a/spec/services/payment_providers/stripe/webhooks/customer_updated_service_spec.rb b/spec/services/payment_providers/stripe/webhooks/customer_updated_service_spec.rb new file mode 100644 index 0000000..d31d837 --- /dev/null +++ b/spec/services/payment_providers/stripe/webhooks/customer_updated_service_spec.rb @@ -0,0 +1,127 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Webhooks::CustomerUpdatedService do + subject(:webhook_service) { described_class.new(organization_id: organization.id, event:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:event) { Stripe::Event.construct_from(JSON.parse(event_json)) } + let(:provider_customer_id) { event.data.object.id } + let(:payment_method_id) { event.data.object.default_source } + + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) do + create(:stripe_customer, payment_provider: stripe_provider, customer:, provider_customer_id:) + end + + before { stripe_customer } + + ["2020-08-27", "2025-04-30.basil"].each do |version| + describe "#call" do + let(:event_json) { get_stripe_fixtures("webhooks/customer_updated.json", version:) } + + it "updates the customer payment method" do + result = webhook_service.call + + expect(result).to be_success + expect(result.stripe_customer.payment_method_id).to eq(payment_method_id) + end + + context "with multiple_payment_methods feature flag" do + before do + create(:payment_method, customer:, payment_provider_customer: stripe_customer, provider_method_id: "pm_updateMe") + organization.enable_feature_flag!(:multiple_payment_methods) + end + + it "updates the associated customer payment method" do + # TODO + # Does "2025-04-30.basil" do not has the provider_method_id?? + next if payment_method_id.nil? + + result = webhook_service.call + + expect(result).to be_success + expect(result.payment_method).not_to be_nil + expect(result.payment_method.provider_method_id).to eq(payment_method_id) + end + end + + context "when customer is not found" do + let(:provider_customer_id) { "cus_InvaLid" } + + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.stripe_customer).to be_nil + end + + context "when stripe customer is deleted" do + let(:stripe_customer) do + create(:stripe_customer, :deleted, payment_provider: stripe_provider, customer:, provider_customer_id:) + end + + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.stripe_customer).to be_nil + end + end + + context "when customer in metadata is not found" do + let(:event_json) do + get_stripe_fixtures("webhooks/customer_updated.json", version:) do |h| + h["data"]["object"]["metadata"] = { + lago_customer_id: "123456-1234-1234-1234-1234567890", + customer_id: "test_5" + } + end + end + + it "returns an empty response" do + result = webhook_service.call + + expect(result).to be_success + expect(result.stripe_customer).to be_nil + end + end + + context "when customer in metadata exists" do + let(:event_json) do + get_stripe_fixtures("webhooks/customer_updated.json", version:) do |h| + h["data"]["object"]["metadata"] = { + lago_customer_id: customer.id, + customer_id: "test_5" + } + end + end + + context "when customer is linked to another stripe customer" do + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.stripe_customer).to be_nil + end + end + + context "when customer is not linked to another stripe customer" do + let(:stripe_customer) { nil } + + it "returns a not found error" do + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("stripe_customer_not_found") + end + end + end + end + end + end +end diff --git a/spec/services/payment_providers/stripe/webhooks/payment_intent_payment_failed_service_spec.rb b/spec/services/payment_providers/stripe/webhooks/payment_intent_payment_failed_service_spec.rb new file mode 100644 index 0000000..eacd358 --- /dev/null +++ b/spec/services/payment_providers/stripe/webhooks/payment_intent_payment_failed_service_spec.rb @@ -0,0 +1,116 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Webhooks::PaymentIntentPaymentFailedService do + subject(:event_service) { described_class.new(organization_id: organization.id, event:) } + + let(:event) { ::Stripe::Event.construct_from(JSON.parse(event_json)) } + let(:organization) { create(:organization) } + + ["2020-08-27", "2024-09-30.acacia", "2025-04-30.basil"].each do |version| + context "when payment intent event" do + let(:event_json) { get_stripe_fixtures("webhooks/payment_intent_payment_failed.json", version:) } + + it "updates the payment status and save the payment method" do + expect_any_instance_of(Invoices::Payments::StripeService).to receive(:update_payment_status) # rubocop:disable RSpec/AnyInstance + .with( + organization_id: organization.id, + status: "failed", + stripe_payment: PaymentProviders::StripeProvider::StripePayment + ).and_call_original + + invoice = create(:invoice, organization:) + payment = create(:payment, payable: invoice, provider_payment_id: event.data.object.id) + + result = event_service.call + + expect(result).to be_success + expect(payment.reload.error_code).to eq("authentication_required") + end + end + + context "when payment intent is canceled" do + let(:event_json) { get_stripe_fixtures("webhooks/payment_intent_canceled.json", version:) } + + it "updates the payment status and save the payment method" do + expect_any_instance_of(Invoices::Payments::StripeService).to receive(:update_payment_status) # rubocop:disable RSpec/AnyInstance + .with( + organization_id: organization.id, + status: "failed", + stripe_payment: PaymentProviders::StripeProvider::StripePayment + ).and_call_original + + create(:payment, provider_payment_id: event.data.object.id) + + result = event_service.call + + expect(result).to be_success + end + end + + context "when payment intent event for a payment request" do + let(:event_json) do + get_stripe_fixtures("webhooks/payment_intent_payment_failed.json", version:) do |h| + h["data"]["object"]["metadata"] = { + lago_payable_type: "PaymentRequest", + lago_payment_request_id: "a587e552-36bc-4334-81f2-abcbf034ad3f" + } + end + end + + it "routes the event to an other service" do + expect_any_instance_of(PaymentRequests::Payments::StripeService).to receive(:update_payment_status) # rubocop:disable RSpec/AnyInstance + .with( + organization_id: organization.id, + status: "failed", + stripe_payment: PaymentProviders::StripeProvider::StripePayment + ).and_call_original + + payment = create(:payment, provider_payment_id: event.data.object.id) + create(:payment_request, customer: create(:customer, organization:), payments: [payment]) + + result = event_service.call + + expect(result).to be_success + end + end + + context "when payment intent event with an invalid payable type" do + let(:event_json) do + get_stripe_fixtures("webhooks/payment_intent_payment_failed.json", version:) do |h| + h["data"]["object"]["metadata"]["lago_payable_type"] = "InvalidPayableTypeName" + end + end + + it do + expect { event_service.call }.to raise_error(NameError, "Invalid lago_payable_type: InvalidPayableTypeName") + end + end + end + + context "when last_payment_error does not have code" do + let(:event_json) do + get_stripe_fixtures("webhooks/payment_intent_payment_failed.json", version: "2025-04-30.basil") do |h| + h["data"]["object"]["last_payment_error"] = {message: "error"} + end + end + + it "updates the payment status and save the payment method" do + expect_any_instance_of(Invoices::Payments::StripeService).to receive(:update_payment_status) # rubocop:disable RSpec/AnyInstance + .with( + organization_id: organization.id, + status: "failed", + stripe_payment: PaymentProviders::StripeProvider::StripePayment + ).and_call_original + + invoice = create(:invoice, organization:) + payment = create(:payment, payable: invoice, provider_payment_id: event.data.object.id) + + result = event_service.call + + expect(result).to be_success + expect(payment.reload.error_code).to be_nil + end + end +end diff --git a/spec/services/payment_providers/stripe/webhooks/payment_intent_succeeded_service_spec.rb b/spec/services/payment_providers/stripe/webhooks/payment_intent_succeeded_service_spec.rb new file mode 100644 index 0000000..32cd6ec --- /dev/null +++ b/spec/services/payment_providers/stripe/webhooks/payment_intent_succeeded_service_spec.rb @@ -0,0 +1,182 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Webhooks::PaymentIntentSucceededService do + subject(:event_service) { described_class.new(organization_id: organization.id, event:) } + + let(:event) { ::Stripe::Event.construct_from(JSON.parse(event_json)) } + let(:organization) { create(:organization) } + + before do + allow(::Payments::SetPaymentMethodAndCreateReceiptJob).to receive(:perform_later) + .and_invoke(->(args) { ::Payments::SetPaymentMethodAndCreateReceiptJob.perform_now(**args) }) + end + + ["2020-08-27", "2024-09-30.acacia", "2025-04-30.basil"].each do |version| + context "when payment intent event (api_version: #{version})" do + let(:invoice) { create(:invoice, organization:) } + let(:event_json) { get_stripe_fixtures("webhooks/payment_intent_succeeded.json", version:) } + + before do + stub_request(:get, %r{/v1/payment_methods/pm_}).and_return( + status: 200, body: get_stripe_fixtures("retrieve_payment_method_response.json", version:) + ) + end + + it "updates the payment status and save the payment method" do + expect_any_instance_of(Invoices::Payments::StripeService).to receive(:update_payment_status) # rubocop:disable RSpec/AnyInstance + .with( + organization_id: organization.id, + status: "succeeded", + stripe_payment: PaymentProviders::StripeProvider::StripePayment + ).and_call_original + + payment = create(:payment, provider_payment_id: event.data.object.id, payable: invoice) + + result = event_service.call + + expect(result).to be_success + expect(payment.reload.provider_payment_method_id).to start_with "pm_" + expect(payment.provider_payment_method_data["type"]).to eq("card") + expect(payment.provider_payment_method_data["brand"]).to eq("visa") + expect(payment.provider_payment_method_data["last4"]).to eq("4242") + end + + it "does not enqueue a payment receipt job" do + customer = create(:customer, organization:) + payable = create(:invoice, customer:, issuing_date: "2025-03-17", organization:) + create(:payment, payable:, provider_payment_id: event.data.object.id) + + expect { event_service.call }.not_to have_enqueued_job(PaymentReceipts::CreateJob) + end + + context "when issue_receipts_enabled is true", :premium do + before { organization.update!(premium_integrations: %w[issue_receipts]) } + + it "enqueues a payment receipt job" do + customer = create(:customer, organization:) + payable = create(:invoice, customer:, issuing_date: "2025-03-17", organization:) + create(:payment, payable:, provider_payment_id: event.data.object.id) + + expect { event_service.call }.to have_enqueued_job(PaymentReceipts::CreateJob) + end + end + end + + context "when payment intent event for a payment request" do + let(:event_json) do + get_stripe_fixtures("webhooks/payment_intent_succeeded.json", version:) do |h| + h["data"]["object"]["id"] = "pi_12345" + h["data"]["object"]["metadata"] = { + lago_payment_request_id: "a587e552-36bc-4334-81f2-abcbf034ad3f", + lago_payable_type: "PaymentRequest" + } + end + end + + context "when issue_receipts_enabled is true", :premium do + before { organization.update!(premium_integrations: %w[issue_receipts]) } + + it "enqueues a payment receipt job" do + expect_any_instance_of(PaymentRequests::Payments::StripeService).to receive(:update_payment_status) # rubocop:disable RSpec/AnyInstance + .with( + organization_id: organization.id, + status: "succeeded", + stripe_payment: PaymentProviders::StripeProvider::StripePayment.new( + id: "pi_12345", + status: "succeeded", + metadata: { + lago_payment_request_id: "a587e552-36bc-4334-81f2-abcbf034ad3f", + lago_payable_type: "PaymentRequest" + }, + error_code: nil + ) + ).and_call_original + + customer = create(:customer, organization:) + payment = create(:payment, provider_payment_id: event.data.object.id, customer:) + create(:payment_request, customer:, payments: [payment]) + + stub_request(:get, %r{/v1/payment_methods/pm_}).and_return( + status: 200, body: get_stripe_fixtures("retrieve_payment_method_response.json", version:) + ) + + expect { event_service.call }.to have_enqueued_job(PaymentReceipts::CreateJob) + end + end + + it "routes the event to an other service" do + expect_any_instance_of(PaymentRequests::Payments::StripeService).to receive(:update_payment_status) # rubocop:disable RSpec/AnyInstance + .with( + organization_id: organization.id, + status: "succeeded", + stripe_payment: PaymentProviders::StripeProvider::StripePayment.new( + id: "pi_12345", + status: "succeeded", + metadata: { + lago_payment_request_id: "a587e552-36bc-4334-81f2-abcbf034ad3f", + lago_payable_type: "PaymentRequest" + }, + error_code: nil + ) + ).and_call_original + + payment = create(:payment, provider_payment_id: event.data.object.id) + create(:payment_request, customer: create(:customer, organization:), payments: [payment]) + + stub_request(:get, %r{/v1/payment_methods/pm_}).and_return( + status: 200, body: get_stripe_fixtures("retrieve_payment_method_response.json", version:) + ) + + result = event_service.call + + expect(result).to be_success + expect(payment.reload.provider_payment_method_id).to start_with "pm_" + expect(payment.reload.provider_payment_method_data["type"]).to eq("card") + expect(payment.reload.provider_payment_method_data["brand"]).to eq("visa") + expect(payment.reload.provider_payment_method_data["last4"]).to eq("4242") + end + + context "when payment belongs to a payment_request from another organization" do + let(:payment_request_other_organization) do + create(:payment_request, organization: create(:organization)) + end + + let(:payment) do + create(:payment, payable: payment_request_other_organization, provider_payment_id: event.data.object.id) + end + + it "returns an empty result" do + result = event_service.call + expect(result).to be_success + expect(result.payment).to be_nil + end + + it "does not update the payment_status of the payment" do + expect { event_service.call } + .to not_change { payment.reload.status } + end + + it "does not enqueue a payment receipt job" do + expect { event_service.call }.not_to have_enqueued_job(Payments::SetPaymentMethodAndCreateReceiptJob) + end + end + end + + context "when payment intent event with an invalid payable type" do + let(:event_json) do + get_stripe_fixtures("webhooks/payment_intent_succeeded.json", version:) do |h| + h["data"]["object"]["id"] = "pi_12345" + h["data"]["object"]["metadata"] = { + lago_payable_type: "InvalidPayableTypeName" + } + end + end + + it do + expect { event_service.call }.to raise_error(NameError, "Invalid lago_payable_type: InvalidPayableTypeName") + end + end + end +end diff --git a/spec/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service_spec.rb b/spec/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service_spec.rb new file mode 100644 index 0000000..5eee329 --- /dev/null +++ b/spec/services/payment_providers/stripe/webhooks/setup_intent_succeeded_service_spec.rb @@ -0,0 +1,199 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::Stripe::Webhooks::SetupIntentSucceededService do + subject(:webhook_service) { described_class.new(organization_id: organization.id, event:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:event) { Stripe::Event.construct_from(JSON.parse(event_json)) } + let(:provider_customer_id) { event.data.object.customer } + let(:payment_method_id) { event.data.object.payment_method } + + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) do + create(:stripe_customer, payment_provider: stripe_provider, customer:, provider_customer_id:) + end + + before do + stripe_customer + + stub_request(:get, "https://api.stripe.com/v1/payment_methods/#{payment_method_id}") + .to_return(status: 200, body: payment_method, headers: {}) + end + + ["2020-08-27", "2025-04-30.basil"].each do |version| + let(:payment_method) { get_stripe_fixtures("retrieve_payment_method_response.json", version:) } + + describe "#call" do + context "when stripe customer id is nil" do + let(:event_json) { get_stripe_fixtures("webhooks/setup_intent_succeeded.json", version:) } + + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when payment method has no customer" do + let(:event_json) do + get_stripe_fixtures("webhooks/setup_intent_succeeded.json", version:) do |h| + h[:data][:object][:customer] = "cus_123" if h[:data][:object][:customer].nil? + end + end + let(:payment_method) do + get_stripe_fixtures("retrieve_payment_method_response.json", version:) do |h| + h[:customer] = nil + end + end + + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when provider customer id is set" do + let(:event_json) do + get_stripe_fixtures("webhooks/setup_intent_succeeded.json", version:) do |h| + h[:data][:object][:customer] = "cus_123" if h[:data][:object][:customer].nil? + end + end + + before do + allow(Stripe::Customer).to receive(:update).and_return(true) + end + + it "updates provider default payment method" do + result = webhook_service.call + + expect(result).to be_success + expect(result.payment_method_id).to start_with("pm_") + expect(result.payment_method_id).to eq(payment_method_id) + expect(result.stripe_customer).to eq(stripe_customer) + expect(result.stripe_customer.payment_method_id).to eq(payment_method_id) + + expect(Stripe::Customer).to have_received(:update).with( + provider_customer_id, + {invoice_settings: {default_payment_method: payment_method_id}}, + {api_key: stripe_provider.secret_key} + ) + end + + context "when multiple_payment_methods feature flag" do + before { organization.enable_feature_flag!(:multiple_payment_methods) } + + it "create a customer payment_method" do + expect { webhook_service.call }.to change(PaymentMethod, :count).by(1) + end + + it "create a customer payment_method with details" do + result = webhook_service.call + + payment_method = customer.payment_methods.order(created_at: :desc).first + + expect(result).to be_success + expect(payment_method).not_to be_nil + expect(payment_method.customer).to eq(customer) + expect(payment_method.provider_method_id).to eq(payment_method_id) + expect(payment_method.details["type"]).to eq("card") + expect(payment_method.details["last4"]).to eq("4242") + expect(payment_method.details["brand"]).to eq("visa") + expect(payment_method.details["expiration_month"]).to be_present + expect(payment_method.details["expiration_year"]).to be_present + end + + context "when payment method type is not card" do + let(:payment_method) do + get_stripe_fixtures("retrieve_payment_method_response.json", version:) do |h| + h[:type] = "sepa_debit" + h.delete(:card) + h[:sepa_debit] = {country: "FR", last4: "3000", bank_code: "1234"} + end + end + + it "creates a payment method with nil card details" do + result = webhook_service.call + + payment_method = customer.payment_methods.order(created_at: :desc).first + + expect(result).to be_success + expect(payment_method).not_to be_nil + expect(payment_method.details["type"]).to eq("sepa_debit") + expect(payment_method.details["last4"]).to be_nil + expect(payment_method.details["brand"]).to be_nil + expect(payment_method.details["expiration_month"]).to be_nil + expect(payment_method.details["expiration_year"]).to be_nil + end + end + end + end + + context "when stripe customer is not found" do + let(:event_json) do + get_stripe_fixtures("webhooks/setup_intent_succeeded.json", version:) do |h| + h[:data][:object][:customer] = "cus_666" if h[:data][:object][:customer].nil? + h[:data][:object][:metadata] = metadata + end + end + let(:stripe_customer) do + create(:stripe_customer, payment_provider: stripe_provider, customer:, provider_customer_id: "cus_123") + end + + context "when metadata is empty" do + let(:metadata) { {} } + + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when customer in metadata exists in another organization" do + let(:customer) { create(:customer, organization: create(:organization)) } + let(:metadata) { {lago_customer_id: customer.id} } + + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when customer in metadata exists in this org" do + let(:metadata) { {lago_customer_id: customer.id} } + + context "when is linked to another stripe customer" do + it "returns an empty result" do + result = webhook_service.call + + expect(result).to be_success + expect(result.payment_method).to be_nil + end + end + + context "when is not linked to another stripe customer" do + let(:stripe_customer) { nil } + + it "returns a not found error" do + result = webhook_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("stripe_customer_not_found") + end + end + end + end + end + end +end diff --git a/spec/services/payment_providers/stripe_service_spec.rb b/spec/services/payment_providers/stripe_service_spec.rb new file mode 100644 index 0000000..310fb8a --- /dev/null +++ b/spec/services/payment_providers/stripe_service_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentProviders::StripeService do + subject(:stripe_service) { described_class.new(membership.user) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:code) { "code_1" } + let(:name) { "Name 1" } + let(:public_key) { SecureRandom.uuid } + let(:secret_key) { SecureRandom.uuid } + let(:success_redirect_url) { Faker::Internet.url } + + describe ".create_or_update" do + it "creates a stripe provider" do + expect do + result = stripe_service.create_or_update( + organization_id: organization.id, + secret_key:, + code:, + name:, + success_redirect_url:, + supports_3ds: true + ) + + expect(PaymentProviders::Stripe::RegisterWebhookJob).to have_been_enqueued + .with(result.stripe_provider) + expect(result.stripe_provider.supports_3ds).to be(true) + end.to change(PaymentProviders::StripeProvider, :count).by(1) + end + + it_behaves_like "produces a security log", "integration.created" do + before do + stripe_service.create_or_update( + organization_id: organization.id, + secret_key:, + code:, + name:, + success_redirect_url:, + supports_3ds: true + ) + end + end + + context "when code was changed" do + let(:new_code) { "updated_code_2" } + let(:stripe_customer) { create(:stripe_customer, payment_provider:, customer:) } + let(:customer) { create(:customer, organization:) } + + let(:payment_provider) do + create( + :stripe_provider, + organization:, + code:, + name:, + secret_key: "secret" + ) + end + + before { stripe_customer } + + it "updates payment provider codes of all customers" do + result = stripe_service.create_or_update( + id: payment_provider.id, + organization_id: organization.id, + code: new_code, + name:, + secret_key: "secret" + ) + + expect(result).to be_success + expect(result.stripe_provider.customers.first.payment_provider_code).to eq(new_code) + end + end + + context "when organization already have a stripe provider" do + let(:stripe_provider) do + create( + :stripe_provider, + organization:, + code:, + name:, + webhook_id: "we_123456", + secret_key: "secret" + ) + end + + before do + stripe_provider + end + + it "updates the existing provider" do + result = stripe_service.create_or_update( + organization_id: organization.id, + secret_key: "new_key", + code:, + name:, + success_redirect_url: + ) + + expect(result).to be_success + + expect(result.stripe_provider.id).to eq(stripe_provider.id) + expect(result.stripe_provider.secret_key).to eq("secret") + expect(result.stripe_provider.code).to eq(code) + expect(result.stripe_provider.name).to eq(name) + expect(result.stripe_provider.success_redirect_url).to eq(success_redirect_url) + + expect(PaymentProviders::Stripe::RegisterWebhookJob).not_to have_been_enqueued + end + + it_behaves_like "produces a security log", "integration.updated" do + before do + stripe_service.create_or_update( + organization_id: organization.id, + secret_key: "new_key", + code:, + name:, + success_redirect_url: + ) + end + end + end + + context "with validation error" do + it "returns an error result" do + result = stripe_service.create_or_update( + organization_id: organization.id, + secret_key: nil + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:secret_key]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/payment_receipts/create_service_spec.rb b/spec/services/payment_receipts/create_service_spec.rb new file mode 100644 index 0000000..d055f5c --- /dev/null +++ b/spec/services/payment_receipts/create_service_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::CreateService do + let(:invoice) { create(:invoice, customer:, organization:, total_amount_cents: 10000, status: :finalized) } + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, organization:) } + let(:payment) { create(:payment, payable: invoice) } + + describe "#call" do + context "when issuing receipts is not enabled" do + it "returns forbidden failure" do + result = described_class.call(payment:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "when issuing receipts is enabled", :premium do + before { organization.update!(premium_integrations: %w[issue_receipts]) } + + context "when customer is a partner account" do + let(:customer) { create(:customer, organization:, account_type: :partner) } + + it "returns result" do + result = described_class.call(payment:) + + expect(result).to be_success + expect(result.payment_receipt).to be_nil + end + end + + context "when customer is not a partner account" do + context "when payment does not exist" do + let(:payment) { nil } + + it "returns not found failure" do + result = described_class.call(payment:) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + + context "when payment exists" do + context "when payment receipt already exists" do + before { create(:payment_receipt, payment:, organization:) } + + it "returns result" do + result = described_class.call(payment:) + + expect(result).to be_success + expect(result.payment_receipt).to be_nil + end + end + + context "when payment receipt does not exist" do + before { payment.update!(payable_payment_status:) } + + context "when payment is not succeeded" do + let(:payable_payment_status) { Payment::PAYABLE_PAYMENT_STATUS.reject { |status| status == "succeeded" }.sample } + + it "returns result" do + result = described_class.call(payment:) + + expect(result).to be_success + expect(result.payment_receipt).to be_nil + end + end + + context "when payment is succeeded" do + let(:payable_payment_status) { "succeeded" } + let(:payment_receipt) { build(:payment_receipt, organization:) } + + before do + allow(PaymentReceipt).to receive(:new).and_return(payment_receipt) + end + + it "creates the payment receipt" do + expect { described_class.call(payment:) }.to change(PaymentReceipt, :count).by(1) + end + + it "enqueues the webhook job" do + expect do + described_class.call(payment:) + end.to have_enqueued_job(SendWebhookJob).with("payment_receipt.created", payment_receipt) + end + + it "enqueues the generate pdf job" do + expect do + billing_entity.email_settings << "payment_receipt.created" + billing_entity.save! + described_class.call(payment:) + end.to have_enqueued_job(PaymentReceipts::GenerateDocumentsJob).with(payment_receipt:, notify: true) + end + + it "produces an activity log" do + payment_receipt = described_class.call(payment:).payment_receipt + + expect(Utils::ActivityLog).to have_produced("payment_receipt.created").with(payment_receipt) + end + end + end + end + end + end + end +end diff --git a/spec/services/payment_receipts/generate_pdf_service_spec.rb b/spec/services/payment_receipts/generate_pdf_service_spec.rb new file mode 100644 index 0000000..9ecd73f --- /dev/null +++ b/spec/services/payment_receipts/generate_pdf_service_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::GeneratePdfService do + subject(:payment_receipt_generate_service) { described_class.new(payment_receipt:, context:) } + + let(:context) { "graphql" } + let(:organization) { create(:organization, name: "LAGO") } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { create(:invoice, customer:, status: :finalized, organization:) } + let(:payment) { create(:payment, payable: invoice) } + let(:payment_receipt) { create(:payment_receipt, payment:, organization:) } + let(:blank_pdf_path) { Rails.root.join("spec/fixtures/blank.pdf") } + + before do + billing_entity = organization.default_billing_entity + billing_entity.logo.attach( + io: File.open(Rails.root.join("spec/factories/images/logo.png")), + content_type: "image/png", + filename: "logo" + ) + stub_pdf_generation + end + + describe "#call" do + it "generates the payment receipt synchronously" do + result = payment_receipt_generate_service.call + + expect(result.payment_receipt.file).to be_present + end + + it "calls the SendWebhook job" do + expect { payment_receipt_generate_service.call }.to have_enqueued_job(SendWebhookJob) + end + + it "produces an activity log" do + receipt = described_class.call(payment_receipt:, context:).payment_receipt + + expect(Utils::ActivityLog).to have_produced("payment_receipt.generated").with(receipt) + end + + context "with not found payment receipt" do + let(:payment_receipt) { nil } + + it "returns a result with error" do + result = payment_receipt_generate_service.call + + expect(result.success).to be_falsey + expect(result.error.error_code).to eq("payment_receipt_not_found") + end + end + + context "when related to a progressive billing invoice" do + let(:invoice) do + create(:invoice, :progressive_billing_invoice, customer:, status: :finalized, organization:) + end + + it "successfully generates the payment receipt" do + result = payment_receipt_generate_service.call + + expect(result.payment_receipt.file).to be_present + end + end + + context "with already generated file" do + before do + payment_receipt.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "receipt.pdf", + content_type: "application/pdf" + ) + end + + it "does not generate the pdf" do + allow(LagoHttpClient::Client).to receive(:new) + + payment_receipt_generate_service.call + + expect(LagoHttpClient::Client).not_to have_received(:new) + end + + it "does not call the SendWebhook job" do + expect { payment_receipt_generate_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when in API context" do + let(:context) { "api" } + + it "calls the SendWebhook job" do + expect { payment_receipt_generate_service.call }.to have_enqueued_job(SendWebhookJob) + end + end + + context "when in Admin context" do + let(:context) { "admin" } + + before do + payment_receipt.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "receipt.pdf", + content_type: "application/pdf" + ) + end + + it "generates the invoice synchronously" do + result = payment_receipt_generate_service.call + + expect(result.payment_receipt.file.filename.to_s).not_to eq("receipt.pdf") + end + end + + context "when create temp files" do + let(:pdf_tempfile) { instance_double(Tempfile).as_null_object } + + before do + allow(pdf_tempfile).to receive(:path).and_return(blank_pdf_path) + allow(Tempfile).to receive(:new).and_call_original + allow(Tempfile).to receive(:new).with([payment_receipt.number, ".pdf"]).and_return(pdf_tempfile) + end + + it "unlink the pdf file at the end" do + described_class.call(payment_receipt:, context:) + + expect(pdf_tempfile).to have_received(:unlink) + end + + context "with einvoicing enabled" do + let(:xml_tempfile) { instance_double(Tempfile).as_null_object } + + before do + payment_receipt.billing_entity.update(country: "FR", einvoicing: true) + + allow(Tempfile).to receive(:new).with([payment_receipt.number, ".xml"]).and_return(xml_tempfile) + allow(Utils::PdfAttachmentService).to receive(:call) + end + + it "unlink all files at the end" do + described_class.call(payment_receipt:, context:) + + expect(pdf_tempfile).to have_received(:unlink) + expect(xml_tempfile).to have_received(:unlink) + end + end + end + + context "when einvoicing is enabled" do + let(:fake_xml) { "content" } + let(:country) { nil } + let(:create_xml_result) { BaseService::Result.new.tap { |result| result.xml = fake_xml } } + + before do + payment_receipt.billing_entity.update(country:, einvoicing: true) + + allow(EInvoices::Payments::FacturX::CreateService).to receive(:call).and_return(create_xml_result) + allow(Utils::PdfAttachmentService).to receive(:call) + end + + context "with FR country" do + let(:country) { "FR" } + + it "generates the payment_receipt with attached facturx xml synchronously" do + result = described_class.call(payment_receipt:, context:) + + expect(Utils::PdfAttachmentService).to have_received(:call) + expect(EInvoices::Payments::FacturX::CreateService).to have_received(:call) + expect(result.payment_receipt.file).to be_present + end + end + end + end +end diff --git a/spec/services/payment_receipts/generate_xml_service_spec.rb b/spec/services/payment_receipts/generate_xml_service_spec.rb new file mode 100644 index 0000000..acd1441 --- /dev/null +++ b/spec/services/payment_receipts/generate_xml_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentReceipts::GenerateXmlService do + let(:context) { "graphql" } + let(:organization) { create(:organization, name: "LAGO") } + let(:billing_entity) { create(:billing_entity, organization:, country: "FR", einvoicing:) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, total_amount_cents: 1000, number: "INV-24680-OIC-E") } + let(:payment) do + create(:payment, + customer:, + payment_type: "manual", + payable: invoice, + currency: "BRL", + amount_cents: 1000, + reference: "its a payment", + provider_payment_method_data: {last4: "4321"}) + end + let(:payment_receipt) { create(:payment_receipt, payment:, organization:, billing_entity:) } + let(:status) { :finalized } + let(:einvoicing) { true } + let(:blank_xml_path) { Rails.root.join("spec/fixtures/blank.xml") } + let(:fake_xml) { "content" } + let(:create_xml_result) { BaseService::Result.new.tap { |result| result.xml = fake_xml } } + let(:xml_service) { EInvoices::Invoices::Ubl::CreateService } + + before do + payment + end + + shared_examples "dont generate" do |section| + it "does not generate the xml" do + described_class.call(payment_receipt:, context:) + + expect(xml_service).not_to have_received(:call) + end + end + + describe "#call" do + before do + allow(xml_service).to receive(:call) + .with(payment_receipt:) + .and_return(create_xml_result) + end + + it "generates the xml synchronously" do + result = described_class.call(payment_receipt:, context:) + + expect(result.payment_receipt.xml_file).to be_present + end + + context "when using temp files" do + let(:xml_tempfile) { instance_double(Tempfile).as_null_object } + + before do + allow(Tempfile).to receive(:new).with([payment_receipt.number, ".xml"]).and_return(xml_tempfile) + allow(xml_tempfile).to receive(:path).and_return(blank_xml_path) + end + + it "removes the temp file at the end" do + described_class.call(payment_receipt:, context:) + + expect(xml_tempfile).to have_received(:unlink) + end + + context "when error happens" do + before do + allow(payment_receipt).to receive(:save).and_raise(ActiveRecord::RecordInvalid.new) + end + + it "always removes the temp file" do + expect { + described_class.call(payment_receipt:, context:) + }.to raise_error(ActiveRecord::RecordInvalid) + + expect(xml_tempfile).to have_received(:unlink) + end + end + end + + context "when cant generate" do + context "with payment not found" do + let(:payment_receipt) { nil } + + it "results in error" do + result = described_class.call(payment_receipt:, context:) + + expect(result.success).to be_falsey + expect(result.error.error_code).to eq("payment_receipt_not_found") + end + end + + context "with already generated a file" do + before do + payment_receipt.xml_file.attach( + io: StringIO.new(File.read(blank_xml_path)), + filename: "payment_receipt.xml", + content_type: "application/xml" + ) + end + + it_behaves_like "dont generate" + end + + context "when country is not allowed" do + before do + billing_entity.country = "BR" + billing_entity.save!(validate: false) + end + + it_behaves_like "dont generate" + end + + context "when einvoicing is disabled" do + let(:einvoicing) { false } + + it_behaves_like "dont generate" + end + end + end +end diff --git a/spec/services/payment_requests/create_service_spec.rb b/spec/services/payment_requests/create_service_spec.rb new file mode 100644 index 0000000..fa2057a --- /dev/null +++ b/spec/services/payment_requests/create_service_spec.rb @@ -0,0 +1,213 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::CreateService, :premium do + subject(:create_service) { described_class.new(organization:, params:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + + let(:first_invoice) do + create(:invoice, customer:, payment_overdue: true, total_amount_cents: 200, total_paid_amount_cents: 100) + end + + let(:second_invoice) do + create(:invoice, customer:, payment_overdue: true, total_amount_cents: 500, total_paid_amount_cents: 200) + end + + let(:params) do + { + external_customer_id: customer.external_id, + email: "john.doe@example.com", + lago_invoice_ids: [first_invoice.id, second_invoice.id] + } + end + + describe "#call" do + let(:amount_cents) do + first_invoice.total_amount_cents + second_invoice.total_amount_cents - + first_invoice.total_paid_amount_cents - second_invoice.total_paid_amount_cents + end + + context "when organization is not premium" do + before do + allow(License).to receive(:premium?).and_return(false) + end + + it "returns forbidden failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "when customer does not exist" do + before { params[:external_customer_id] = "non-existing-id" } + + it "returns not found failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("customer") + end + end + + context "when invoices are not found" do + before { params[:lago_invoice_ids] = [] } + + it "returns not found failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("invoice") + end + end + + context "when invoices are not overdue" do + before { first_invoice.update!(payment_overdue: false) } + + it "returns not allowed failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoices_not_overdue") + end + end + + context "when invoices have different currencies" do + before { second_invoice.update!(currency: "USD") } + + it "returns not allowed failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoices_have_different_currencies") + end + end + + context "when invoices are not ready for payment processing" do + before { first_invoice.update!(ready_for_payment_processing: false) } + + it "returns not allowed failure" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("invoices_not_ready_for_payment_processing") + end + end + + it "creates a payment request" do + expect { create_service.call }.to change { customer.payment_requests.count }.by(1) + end + + it "assigns the invoices to the created payment request" do + result = create_service.call + + expect(result.payment_request.invoices.count).to eq(2) + end + + it "delivers a webhook" do + create_service.call + expect(SendWebhookJob).to have_been_enqueued.with("payment_request.created", PaymentRequest) + end + + it "creates a payment for the payment request" do + allow(PaymentRequests::Payments::CreateService).to receive(:call_async).and_call_original + + result = create_service.call + + expect(PaymentRequests::Payments::CreateService).to have_received(:call_async).with(payable: result.payment_request, payment_method_params: {}) + end + + context "when Payments::CreateService returns an error" do + before { customer.update!(payment_provider: nil) } + + it "sends an email to the customer" do + expect do + create_service.call + end.to have_enqueued_job(SendEmailJob) + end + end + + it "returns the payment request" do + dunning_campaign = create(:dunning_campaign, organization:) + result = described_class.call(organization:, params:, dunning_campaign:) + + expect(result.payment_request).to be_a(PaymentRequest) + expect(result.payment_request).to have_attributes( + organization:, + customer:, + dunning_campaign:, + amount_cents:, + amount_currency: "EUR", + email: "john.doe@example.com" + ) + end + + context "with offset amounts from credit notes" do + it "deducts finalized offset amounts from total" do + create(:credit_note, invoice: first_invoice, customer:, offset_amount_cents: 50, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 50, status: :finalized) + create(:credit_note, invoice: second_invoice, customer:, offset_amount_cents: 100, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 100, status: :finalized) + + result = create_service.call + expect(result).to be_success + expect(result.payment_request.amount_cents).to eq(250) # 400 - 50 - 100 + end + + it "ignores draft credit note offsets" do + create(:credit_note, invoice: first_invoice, customer:, offset_amount_cents: 50, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 50, status: :draft) + + result = create_service.call + expect(result.payment_request.amount_cents).to eq(400) # Draft not counted + end + + it "only deducts finalized offsets when both draft and finalized exist" do + create(:credit_note, invoice: first_invoice, customer:, offset_amount_cents: 30, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 30, status: :finalized) + create(:credit_note, invoice: second_invoice, customer:, offset_amount_cents: 80, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 80, status: :draft) + + result = create_service.call + expect(result.payment_request.amount_cents).to eq(370) # 400 - 30 + end + + it "excludes invoice when fully offset by credit notes" do + first_invoice.update!(total_amount_cents: 200, total_paid_amount_cents: 0) + create(:credit_note, invoice: first_invoice, customer:, offset_amount_cents: 200, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 200, status: :finalized) + + result = create_service.call + expect(result.payment_request.amount_cents).to eq(300) # Second invoice only: 500 - 200 + end + + it "deducts only offset amounts, not credit or refund amounts" do + create(:credit_note, invoice: first_invoice, customer:, offset_amount_cents: 25, + credit_amount_cents: 25, refund_amount_cents: 0, total_amount_cents: 50, status: :finalized) + + result = create_service.call + expect(result.payment_request.amount_cents).to eq(375) # 400 - 25 (not - 50) + end + + it "sums multiple offset amounts on same invoice" do + create(:credit_note, invoice: first_invoice, customer:, offset_amount_cents: 20, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 20, status: :finalized) + create(:credit_note, invoice: first_invoice, customer:, offset_amount_cents: 30, + credit_amount_cents: 0, refund_amount_cents: 0, total_amount_cents: 30, status: :finalized) + + result = create_service.call + expect(result.payment_request.amount_cents).to eq(350) # 400 - 20 - 30 + end + end + end +end diff --git a/spec/services/payment_requests/payments/adyen_service_spec.rb b/spec/services/payment_requests/payments/adyen_service_spec.rb new file mode 100644 index 0000000..4c42d7e --- /dev/null +++ b/spec/services/payment_requests/payments/adyen_service_spec.rb @@ -0,0 +1,323 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::AdyenService do + subject(:adyen_service) { described_class.new(payment_request) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:adyen_payment_provider) { create(:adyen_provider, organization:, code:) } + let(:adyen_customer) { create(:adyen_customer, customer:) } + let(:adyen_client) { instance_double(Adyen::Client) } + let(:payments_api) { Adyen::PaymentsApi.new(adyen_client, 70) } + let(:checkout) { Adyen::Checkout.new(adyen_client, 70) } + let(:payments_response) { generate(:adyen_payments_response) } + let(:payment_methods_response) { generate(:adyen_payment_methods_response) } + let(:code) { "adyen_1" } + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2] + ) + end + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "USD", + ready_for_payment_processing: true + ) + end + + describe "#generate_payment_url" do + let(:payment_links_api) { Adyen::PaymentLinksApi.new(adyen_client, 70) } + let(:payment_links_response) { generate(:adyen_payment_links_response) } + + before do + adyen_payment_provider + adyen_customer + + allow(Adyen::Client).to receive(:new) + .and_return(adyen_client) + allow(adyen_client).to receive(:checkout) + .and_return(checkout) + allow(checkout).to receive(:payment_links_api) + .and_return(payment_links_api) + allow(payment_links_api).to receive(:payment_links) + .and_return(payment_links_response) + end + + it "generates payment url" do + freeze_time do + adyen_service.generate_payment_url + + expect(payment_links_api) + .to have_received(:payment_links) + .with( + { + amount: { + currency: "USD", + value: 799 + }, + applicationInfo: { + externalPlatform: {integrator: "Lago", name: "Lago"}, + merchantApplication: {name: "Lago"} + }, + expiresAt: Time.current + 70.days, + merchantAccount: adyen_payment_provider.merchant_account, + metadata: { + lago_customer_id: customer.id, + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest", + payment_type: "one-time" + }, + recurringProcessingModel: "UnscheduledCardOnFile", + reference: "Overdue invoices", + returnUrl: adyen_payment_provider.success_redirect_url, + shopperEmail: customer.email, + shopperReference: customer.external_id, + storePaymentMethodMode: "enabled" + } + ) + end + end + + context "with an error on Adyen" do + before do + allow(payment_links_api).to receive(:payment_links) + .and_raise(Adyen::AdyenError.new(nil, nil, "error")) + end + + it "returns a failed result" do + result = adyen_service.generate_payment_url + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Adyen") + expect(result.error.error_message).to eq("error") + end + end + end + + describe "#update_payment_status" do + subject(:result) do + adyen_service.update_payment_status(provider_payment_id:, status:) + end + + let(:status) { "Authorised" } + + let(:payment) do + create( + :payment, + :adyen_payment, + payable: payment_request, + provider_payment_id:, + status: "Pending" + ) + end + + let(:provider_payment_id) { "ch_123456" } + + before do + allow(SendWebhookJob).to receive(:perform_later) + allow(SegmentTrackJob).to receive(:perform_later) + payment + end + + it "updates the payment, payment_request and invoices payment_status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment request belongs to a dunning campaign" do + let(:customer) do + create( + :customer, + payment_provider_code: code, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: Time.zone.now + ) + end + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2], + dunning_campaign: create(:dunning_campaign) + ) + end + + it "resets the customer dunning campaign counters" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(result).to be_success + end + + context "when status is failed" do + let(:status) { "failed" } + + it "doest not reset the customer dunning campaign counters" do + expect { result && customer.reload } + .to not_change(customer, :last_dunning_campaign_attempt) + .and not_change { customer.last_dunning_campaign_attempt_at&.to_i } + + expect(result).to be_success + end + end + end + + context "when status is failed" do + let(:status) { "Refused" } + + it "updates the payment, payment_request and invoices status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("failed") + + expect(result.payable.reload).to be_payment_failed + expect(result.payable.ready_for_payment_processing).to eq(true) + + expect(invoice_1.reload).to be_payment_failed + expect(invoice_1.ready_for_payment_processing).to eq(true) + + expect(invoice_2.reload).to be_payment_failed + expect(invoice_2.ready_for_payment_processing).to eq(true) + + expect(invoice_1.total_paid_amount_cents).to eq(0) + expect(invoice_2.total_paid_amount_cents).to eq(0) + end + + it "sends a payment requested email" do + expect { result }.to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + + context "when payment_request and invoices is already payment_succeeded" do + let(:status) do + %w[Authorised SentForSettle SettleScheduled Settled Refunded].sample + end + + before do + payment_request.payment_succeeded! + invoice_1.payment_succeeded! + invoice_2.payment_succeeded! + end + + it "does not update the status of invoices, payment_request and payment" do + expect { result } + .to not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment_request.reload.payment_status } + .and not_change { payment.reload.status } + + expect(result).to be_success + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "with invalid status" do + let(:status) { "invalid-status" } + + it "does not update the payment_status of payment_request, invoices and payment" do + expect { result } + .to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment.reload.status } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + let(:status) { "succeeded" } + + before do + adyen_payment_provider + adyen_customer + end + + it "creates a payment and updates payment request and invoices payment status" do + result = adyen_service.update_payment_status( + provider_payment_id:, + status:, + metadata: { + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest", + payment_type: "one-time" + } + ) + + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(invoice_1.total_paid_amount_cents).to eq(invoice_1.total_amount_cents) + expect(invoice_2.total_paid_amount_cents).to eq(invoice_2.total_amount_cents) + end + end + end +end diff --git a/spec/services/payment_requests/payments/cashfree_service_spec.rb b/spec/services/payment_requests/payments/cashfree_service_spec.rb new file mode 100644 index 0000000..82b38ec --- /dev/null +++ b/spec/services/payment_requests/payments/cashfree_service_spec.rb @@ -0,0 +1,337 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::CashfreeService do + subject(:cashfree_service) { described_class.new(payment_request) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:cashfree_payment_provider) { create(:cashfree_provider, organization:, code:) } + let(:cashfree_customer) { create(:cashfree_customer, customer:) } + let(:cashfree_client) { instance_double(LagoHttpClient::Client) } + + let(:code) { "cashfree_1" } + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2] + ) + end + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "USD", + ready_for_payment_processing: true + ) + end + + describe "#update_payment_status" do + let(:payment) do + create( + :payment, + :cashfree_payment, + payable: payment_request, + provider_payment_id: payment_request.id, + status: "pending" + ) + end + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "PAID", + metadata: {} + ) + end + + let(:result) do + cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + end + + before do + allow(SendWebhookJob).to receive(:perform_later) + allow(SegmentTrackJob).to receive(:perform_later) + payment + end + + it "updates the payment and invoice payment_status" do + expect(result).to be_success + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payment.payable_payment_status).to eq("succeeded") + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(result.payment.status).to eq("PAID") + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment request belongs to a dunning campaign" do + let(:customer) do + create( + :customer, + payment_provider_code: code, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: Time.zone.now + ) + end + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2], + dunning_campaign: create(:dunning_campaign) + ) + end + + it "resets the customer dunning campaign counters" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(result).to be_success + end + + context "when status is failed" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "EXPIRED", + metadata: {} + ) + end + + it "doest not reset the customer dunning campaign counters" do + expect { result && customer.reload } + .to not_change(customer, :last_dunning_campaign_attempt) + .and not_change { customer.last_dunning_campaign_attempt_at&.to_i } + + expect(result).to be_success + end + end + end + + context "when status is failed" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "EXPIRED", + metadata: {} + ) + end + + it "updates the payment, payment_request and invoices status" do + result = cashfree_service.update_payment_status( + organization_id: organization.id, + status: cashfree_payment.status, + cashfree_payment: + ) + + expect(result).to be_success + expect(result.payment.status).to eq("EXPIRED") + expect(result.payment.payable_payment_status).to eq("failed") + + expect(result.payable.reload).to be_payment_failed + expect(result.payable.ready_for_payment_processing).to eq(true) + + expect(invoice_1.reload).to be_payment_failed + expect(invoice_1.ready_for_payment_processing).to eq(true) + + expect(invoice_2.reload).to be_payment_failed + expect(invoice_2.ready_for_payment_processing).to eq(true) + + expect(invoice_1.total_paid_amount_cents).to eq(0) + expect(invoice_2.total_paid_amount_cents).to eq(0) + end + + it "sends a payment requested email" do + expect { result } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + + context "when payment_request and invoices is already payment_succeeded" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: %w[PARTIALLY_PAID PAID EXPIRED CANCELED].sample, + metadata: {} + ) + end + + before do + payment_request.payment_succeeded! + invoice_1.payment_succeeded! + invoice_2.payment_succeeded! + end + + it "does not update the status of invoices, payment_request and payment" do + expect { result } + .to not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment_request.reload.payment_status } + .and not_change { payment.reload.status } + + expect(result).to be_success + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "with invalid status" do + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "foo-bar", + metadata: {} + ) + end + + it "does not update the payment_status of payment_request, invoices and payment" do + expect { result } + .to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment.reload.status } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + + let(:cashfree_payment) do + PaymentProviders::CashfreeProvider::CashfreePayment.new( + id: payment_request.id, + status: "PAID", + metadata: { + payment_type: "one-time", + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + } + ) + end + + before do + cashfree_payment_provider + cashfree_customer + end + + it "creates a payment and updates invoice payment status" do + expect(result).to be_success + expect(result.payment.status).to eq("PAID") + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(invoice_1.total_paid_amount_cents).to eq(invoice_1.total_amount_cents) + expect(invoice_2.total_paid_amount_cents).to eq(invoice_2.total_amount_cents) + end + end + end + + describe ".generate_payment_url" do + let(:payment_links_response) { Net::HTTPResponse.new("1.0", "200", "OK") } + + before do + cashfree_payment_provider + cashfree_customer + + allow(LagoHttpClient::Client).to receive(:new) + .and_return(cashfree_client) + allow(cashfree_client).to receive(:post_with_response) + .and_return(payment_links_response) + allow(payment_links_response).to receive(:body) + .and_return({link_url: "https://payments-test.cashfree.com/links//U1mgll3c0e9g"}.to_json) + end + + it "generates payment url" do + result = cashfree_service.generate_payment_url + + expect(result.payment_url).to be_present + end + + context "when payment url failed to generate" do + let(:payment_links_response) { Net::HTTPResponse.new("1.0", "400", "Bad Request") } + let(:payment_links_body) do + { + message: "Currency USD is not enabled", + code: "link_post_failed", + type: "invalid_request_error" + }.to_json + end + + before do + allow(LagoHttpClient::Client).to receive(:new) + .and_return(cashfree_client) + allow(cashfree_client).to receive(:post_with_response) + .and_raise(::LagoHttpClient::HttpError.new(payment_links_response.code, payment_links_body, nil)) + end + + it "returns a third party error" do + result = cashfree_service.generate_payment_url + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Cashfree") + expect(result.error.error_message).to eq(payment_links_body) + end + end + end +end diff --git a/spec/services/payment_requests/payments/create_service_spec.rb b/spec/services/payment_requests/payments/create_service_spec.rb new file mode 100644 index 0000000..68cbfe8 --- /dev/null +++ b/spec/services/payment_requests/payments/create_service_spec.rb @@ -0,0 +1,600 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::CreateService do + subject(:create_service) { described_class.new(payable: payment_request, payment_provider: provider, payment_method_params:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, organization:, payment_provider: provider, payment_provider_code:) } + let(:provider) { "stripe" } + let(:payment_provider_code) { "stripe_1" } + let(:payment_method_params) { {} } + let(:default_payment_method) { create(:payment_method, customer:, is_default: true) } + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2] + ) + end + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "USD", + ready_for_payment_processing: true + ) + end + + describe "#call" do + let(:payment_provider) { create(:stripe_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:stripe_customer, payment_provider:, customer:) } + let(:provider_class) { PaymentProviders::Stripe::Payments::CreateService } + let(:provider_service) { instance_double(provider_class) } + + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "succeeded") + end + end + + before do + provider_customer + default_payment_method + + allow(provider_class) + .to receive(:new) + .with( + payment: an_instance_of(Payment), + reference: "#{billing_entity.name} - Overdue invoices", + metadata: { + lago_customer_id: customer.id, + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + } + ).and_return(provider_service) + allow(provider_service).to receive(:call!) + .and_return(service_result) + end + + context "with adyen payment provider" do + let(:provider) { "adyen" } + let(:payment_provider) { create(:adyen_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:adyen_customer, payment_provider:, customer:) } + + let(:provider_class) { PaymentProviders::Adyen::Payments::CreateService } + let(:provider_service) { instance_double(provider_class) } + + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "succeeded") + end + end + + it "creates a payment and calls the adyen service" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + it "updates invoice payment status to succeeded" do + create_service.call + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + end + + it "does not send a payment requested email" do + expect { create_service.call } + .not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when issue_receipts_enabled is true", :premium do + before { organization.update!(premium_integrations: %w[issue_receipts]) } + + it "enqueues a payment receipt job" do + expect { create_service.call }.to have_enqueued_job(PaymentReceipts::CreateJob) + end + end + + context "when the payment fails" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "failed") + end + end + + it "sends a payment requested email" do + expect { create_service.call } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + end + + context "with gocardless payment provider" do + let(:provider) { "gocardless" } + let(:payment_provider) { create(:gocardless_provider, code: payment_provider_code, organization:) } + let(:provider_customer) { create(:gocardless_customer, payment_provider:, customer:) } + + let(:provider_class) { PaymentProviders::Gocardless::Payments::CreateService } + let(:provider_service) { instance_double(provider_class) } + + it "creates a payment and calls the gocardless service" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + it "updates invoice payment status to succeeded" do + create_service.call + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + end + + it "does not send a payment requested email" do + expect { create_service.call } + .not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment fails" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "failed") + end + end + + it "sends a payment requested email" do + expect { create_service.call } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + end + + context "with stripe payment provider" do + it "creates a payment and calls the stripe service" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + it "updates invoice payment status to succeeded" do + create_service.call + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + end + + it "updates invoice paid amount" do + create_service.call + + expect(invoice_1.reload.total_paid_amount_cents).to eq(invoice_1.total_amount_cents) + expect(invoice_2.reload.total_paid_amount_cents).to eq(invoice_2.total_amount_cents) + end + + it "does not send a payment requested email" do + expect { create_service.call } + .not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment fails" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "failed") + end + end + + it "sends a payment requested email" do + expect { create_service.call } + .to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + + context "when manual payment method is passed in params" do + let(:organization) { create(:organization, feature_flags: %w[multiple_payment_methods]) } + let(:payment_method_params) { {payment_method_type: "manual"} } + + it "does not attach payment method to payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to be_nil + end + end + + context "when valid payment method is passed in params" do + let(:organization) { create(:organization, feature_flags: %w[multiple_payment_methods]) } + let(:payment_method) { create(:payment_method, customer:, is_default: false) } + let(:payment_method_params) { {payment_method_id: payment_method.id} } + + it "attaches correct payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(payment_method.id) + end + end + + context "when payment method is NOT passed in params" do + let(:organization) { create(:organization, feature_flags: %w[multiple_payment_methods]) } + + it "creates payment with customer default payment method" do + result = create_service.call + + expect(result).to be_success + expect(result.payment.payment_method_id).to eq(default_payment_method.id) + end + end + end + + context "when payment request payment status is succeeded" do + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + payment_status: "succeeded", + amount_cents: 799, + amount_currency: "EUR", + invoices: [invoice_1, invoice_2] + ) + end + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payable).to be_payment_succeeded + expect(result.payable.payment_attempts).to eq(0) + expect(result.payment).to be_nil + + expect(provider_class).not_to have_received(:new) + end + end + + context "with no payment provider" do + let(:payment_provider) { nil } + + it "does not creates a stripe payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + + expect(provider_class).not_to have_received(:new) + end + end + + context "with 0 amount" do + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 0, + amount_currency: "EUR", + invoices: [invoice] + ) + end + + let(:invoice) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 0, + currency: "EUR" + ) + end + + it "does not creates a stripe payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + expect(result.payable).to be_payment_succeeded + expect(provider_class).not_to have_received(:new) + end + end + + context "when customer does not have a provider customer id" do + before { provider_customer.update!(provider_customer_id: nil) } + + it "does not creates a stripe payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + context "when some invoices are already paid" do + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: false + ) + end + + it "does not create a stripe payment" do + result = create_service.call + + expect(result).to be_success + + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_nil + expect(provider_class).not_to have_received(:new) + end + end + + context "when provider service raises a service failure" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, status: "pending", payable_payment_status: "pending") + r.error_message = "error" + r.error_code = "code" + r.reraise = true + end + end + + before do + allow(provider_service).to receive(:call!) + .and_raise(BaseService::ServiceFailure.new(service_result, code: "code", error_message: "error")) + end + + it "re-reaise the error and delivers an error webhook" do + expect { create_service.call } + .to raise_error(BaseService::ServiceFailure) + .and enqueue_job(SendWebhookJob) + .with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: provider_customer.provider_customer_id, + provider_error: { + message: "error", + error_code: "code" + } + ).on_queue(webhook_queue) + .and have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + + context "when payment has a payable_payment_status" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "failed") + r.error_message = "error" + r.error_code = "code" + r.reraise = true + end + end + + it "updates the payment request payment status" do + expect { create_service.call } + .to raise_error(BaseService::ServiceFailure) + + expect(payment_request.reload).to be_payment_failed + end + end + + context "when payable_payment_status is pending" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, status: "failed", payable_payment_status: "pending") + r.error_message = "stripe_error" + r.error_code = "amount_too_small" + end + end + + it "re-reaise the error and delivers an error webhook" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payment.status).to eq("failed") + expect(result.payment.payable_payment_status).to eq("pending") + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + end + end + + context "when payment status is processing" do + let(:service_result) do + BaseService::Result.new.tap do |r| + r.payment = instance_double(Payment, payable_payment_status: "pending", status: "processing") + end + end + + it "creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to be_present + + expect(result.payable).to be_payment_pending + expect(result.payable.payment_attempts).to eq(1) + expect(result.payable.ready_for_payment_processing).to eq(false) + + payment = result.payment + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + expect(payment.payable_payment_status).to eq("pending") + expect(payment.payable).to eq(payment_request) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + end + + context "when a payment exits" do + let(:payment) do + create( + :payment, + payable: payment_request, + payment_provider:, + payment_provider_customer: provider_customer, + amount_cents: payment_request.total_amount_cents, + amount_currency: payment_request.currency, + status: "pending", + payable_payment_status: payment_status + ) + end + + let(:payment_status) { "pending" } + + before { payment } + + it "retrieves the payment for processing" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to eq(payment) + + expect(payment.payment_provider).to eq(payment_provider) + expect(payment.payment_provider_customer).to eq(provider_customer) + expect(payment.amount_cents).to eq(payment_request.total_amount_cents) + expect(payment.amount_currency).to eq(payment_request.currency) + + expect(provider_class).to have_received(:new) + expect(provider_service).to have_received(:call!) + end + + context "when payment is already processing" do + let(:payment_status) { "processing" } + + it "does not creates a payment" do + result = create_service.call + + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payment).to eq(payment) + + expect(provider_class).not_to have_received(:new) + expect(provider_service).not_to have_received(:call!) + end + end + end + end + + describe "#call_async" do + context "with adyen payment provider" do + let(:payment_provider) { "adyen" } + + it "enqueues a job to create a adyen payment" do + expect do + create_service.call_async + end.to have_enqueued_job(PaymentRequests::Payments::CreateJob) + end + end + + context "with gocardless payment provider" do + let(:payment_provider) { "gocardless" } + + it "enqueues a job to create a gocardless payment" do + expect do + create_service.call_async + end.to have_enqueued_job(PaymentRequests::Payments::CreateJob) + end + end + + context "with strip payment provider" do + let(:payment_provider) { "stripe" } + + it "enqueues a job to create a stripe payment" do + expect do + create_service.call_async + end.to have_enqueued_job(PaymentRequests::Payments::CreateJob) + end + end + end +end diff --git a/spec/services/payment_requests/payments/deliver_error_webhook_service_spec.rb b/spec/services/payment_requests/payments/deliver_error_webhook_service_spec.rb new file mode 100644 index 0000000..9466822 --- /dev/null +++ b/spec/services/payment_requests/payments/deliver_error_webhook_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::DeliverErrorWebhookService do + subject(:webhook_service) { described_class.new(payment_request, params) } + + let(:payment_request) { create(:payment_request) } + let(:params) do + { + "provider_customer_id" => "customer", + "provider_error" => { + "error_message" => "message", + "error_code" => "code" + } + }.with_indifferent_access + end + + describe ".call_async" do + it "enqueues a job to send an payment request payment failure webhook" do + expect do + webhook_service.call_async + end.to have_enqueued_job(SendWebhookJob).with("payment_request.payment_failure", payment_request, params) + end + end +end diff --git a/spec/services/payment_requests/payments/flutterwave_service_spec.rb b/spec/services/payment_requests/payments/flutterwave_service_spec.rb new file mode 100644 index 0000000..ed4318a --- /dev/null +++ b/spec/services/payment_requests/payments/flutterwave_service_spec.rb @@ -0,0 +1,287 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::FlutterwaveService do + subject(:flutterwave_service) { described_class.new(payment_request) } + + let(:organization) { create(:organization, name: "Test Organization") } + let(:customer) { create(:customer, organization: organization, email: "customer@example.com", name: "John Doe") } + let(:flutterwave_provider) { create(:flutterwave_provider, organization: organization, secret_key: "FLWSECK_TEST-secret") } + let(:flutterwave_customer) { create(:flutterwave_customer, customer: customer, payment_provider: flutterwave_provider) } + let(:invoice) { create(:invoice, organization: organization, customer: customer, total_amount_cents: 50000, currency: "USD") } + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + total_amount_cents: 50000, + currency: "USD", + invoices: [invoice] + ) + end + + before do + flutterwave_customer + end + + describe "#call" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + let(:successful_response) do + { + "status" => "success", + "data" => { + "link" => "https://checkout.flutterwave.com/v3/hosted/pay/test_link" + } + } + end + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return(successful_response) + end + + it "creates a checkout session and returns payment URL" do + result = flutterwave_service.call + + expect(result).to be_success + expect(result.payment_url).to eq("https://checkout.flutterwave.com/v3/hosted/pay/test_link") + end + + it "sends correct parameters to Flutterwave API" do + flutterwave_service.call + + expect(http_client).to have_received(:post_with_response) do |body, headers| + expect(body[:amount]).to eq(500.0) + expect(body[:tx_ref]).to eq("lago_payment_request_#{payment_request.id}") + expect(body[:currency]).to eq("USD") + expect(body[:customer][:email]).to eq("customer@example.com") + expect(body[:customer][:name]).to eq("John Doe") + expect(body[:customizations][:title]).to eq("Test Organization - Payment Request") + expect(body[:meta][:lago_customer_id]).to eq(customer.id) + expect(body[:meta][:lago_payment_request_id]).to eq(payment_request.id) + expect(body[:meta][:lago_organization_id]).to eq(organization.id) + + expect(headers["Authorization"]).to eq("Bearer FLWSECK_TEST-secret") + expect(headers["Content-Type"]).to eq("application/json") + expect(headers["Accept"]).to eq("application/json") + end + end + + context "when HTTP client raises an error" do + let(:http_error) { LagoHttpClient::HttpError.new(500, "Connection failed", "https://api.example.com") } + + before do + allow(http_client).to receive(:post_with_response).and_raise(http_error) + allow(SendWebhookJob).to receive(:perform_later) + end + + it "delivers error webhook and returns service failure" do + result = flutterwave_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("action_script_runtime_error") + expect(result.error.message).to include("Connection failed") + end + + it "sends webhook notification about payment failure" do + flutterwave_service.call + + expect(SendWebhookJob).to have_received(:perform_later).with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: flutterwave_customer.provider_customer_id, + provider_error: { + message: "HTTP 500 - URI: https://api.example.com.\nError: Connection failed\nResponse headers: {}", + error_code: 500 + } + ) + end + end + end + + describe "#update_payment_status" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: { + payment_type: "one-time", + lago_payable_id: payment_request.id + } + ) + end + + context "when creating a new payment" do + it "creates a new payment and updates payment request status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment).to be_a(Payment) + expect(result.payment.provider_payment_id).to eq("flw_payment_123") + expect(result.payment.amount_cents).to eq(50000) + expect(result.payment.payable).to eq(payment_request) + expect(result.payable).to eq(payment_request) + end + + it "increments payment attempts on the payment request" do + expect { flutterwave_service.update_payment_status(organization_id: organization.id, status: :succeeded, flutterwave_payment: flutterwave_payment) } + .to change { payment_request.reload.payment_attempts }.by(1) + end + end + + context "when updating existing payment" do + let(:existing_payment) do + create(:payment, + organization: organization, + payable: payment_request, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :pending) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "updates the existing payment status" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(result.payment.id).to eq(existing_payment.id) + expect(result.payment.status).to eq("succeeded") + end + end + + context "when payment is not found" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "nonexistent_payment", + metadata: {payment_type: "recurring"} + ) + end + + it "returns not found failure" do + result = flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + + context "when payment already succeeded" do + let(:succeeded_payment_request) { create(:payment_request, organization: organization, customer: customer, payment_status: :succeeded) } + let(:existing_payment) do + create(:payment, + organization: organization, + payable: succeeded_payment_request, + payment_provider: flutterwave_provider, + provider_payment_id: "flw_payment_123", + status: :succeeded) + end + + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: {payment_type: "recurring"} + ) + end + + before { existing_payment } + + it "returns early without processing" do + service = described_class.new(payable: succeeded_payment_request) + allow(service).to receive(:update_payable_payment_status) + + result = service.update_payment_status( + organization_id: organization.id, + status: :succeeded, + flutterwave_payment: flutterwave_payment + ) + + expect(result).to be_success + expect(service).not_to have_received(:update_payable_payment_status) + end + end + + context "when payment fails" do + let(:flutterwave_payment) do + OpenStruct.new( + id: "flw_payment_123", + metadata: { + payment_type: "one-time", + lago_payable_id: payment_request.id + } + ) + end + + before do + mailer_with_double = instance_double("PaymentRequestMailer") + mailer_message_double = instance_double("ActionMailer::MessageDelivery") + + allow(PaymentRequestMailer).to receive(:with).and_return(mailer_with_double) + allow(mailer_with_double).to receive(:requested).and_return(mailer_message_double) + allow(mailer_message_double).to receive(:deliver_later) + end + + it "sends payment failure email" do + flutterwave_service.update_payment_status( + organization_id: organization.id, + status: :failed, + flutterwave_payment: flutterwave_payment + ) + expect(PaymentRequestMailer).to have_received(:with).with(payment_request: payment_request) + end + end + end + + describe "private methods" do + describe "#create_checkout_session" do + let(:http_client) { instance_double(LagoHttpClient::Client) } + + before do + allow(LagoHttpClient::Client).to receive(:new).and_return(http_client) + allow(http_client).to receive(:post_with_response).and_return({"data" => {"link" => "test_link"}}) + end + + it "uses correct currency conversion" do + payment_request.update!(currency: "NGN", total_amount_cents: 1000000) # 10,000 NGN + + flutterwave_service.call + + expect(http_client).to have_received(:post_with_response) do |body| + expect(body[:amount]).to eq(10000.0) + expect(body[:currency]).to eq("NGN") + end + end + + it "includes correct meta parameters" do + flutterwave_service.call + + expect(http_client).to have_received(:post_with_response) do |body| + meta = body[:meta] + expect(meta[:lago_customer_id]).to eq(customer.id) + expect(meta[:lago_payment_request_id]).to eq(payment_request.id) + expect(meta[:lago_organization_id]).to eq(organization.id) + expect(meta[:lago_invoice_ids]).to eq(invoice.id.to_s) + end + end + end + end +end diff --git a/spec/services/payment_requests/payments/generate_payment_url_service_spec.rb b/spec/services/payment_requests/payments/generate_payment_url_service_spec.rb new file mode 100644 index 0000000..2adb3ec --- /dev/null +++ b/spec/services/payment_requests/payments/generate_payment_url_service_spec.rb @@ -0,0 +1,175 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::GeneratePaymentUrlService do + subject(:generate_payment_url_service) { described_class.new(payable: payment_request) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, payment_provider: provider, payment_provider_code: code) } + let(:payment_request) { create(:payment_request, customer:) } + let(:provider) { "stripe" } + let(:code) { "stripe_1" } + let(:payment_provider) { create(:stripe_provider, code:, organization:) } + let(:provider_customer) { create(:stripe_customer, payment_provider:, customer:) } + + describe ".call" do + before do + provider_customer + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example55.com"}) + end + + it "returns the generated payment url" do + result = generate_payment_url_service.call + + expect(result.payment_url).to eq("https://example55.com") + end + + context "when payment provider is blank" do + let(:provider) { nil } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["no_linked_payment_provider"]) + end + end + + context "when payment provider is gocardless" do + let(:provider) { "gocardless" } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["invalid_payment_provider"]) + end + end + + context "when payment request's payment status is invalid" do + before { payment_request.payment_succeeded! } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["invalid_payment_status"]) + end + end + end + + context "when payment provider is missing" do + let(:payment_provider) { nil } + let(:provider_customer) { nil } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["missing_payment_provider"]) + end + end + + context "when payment provider customer is missing" do + let(:provider_customer) { nil } + + before { payment_provider } + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:base]).to eq(["missing_payment_provider_customer"]) + end + end + + context "when provider service return a third party error" do + let(:provider) { "cashfree" } + let(:code) { "cashfree_1" } + + let(:payment_provider_service) { instance_double(PaymentRequests::Payments::CashfreeService) } + let(:payment_provider) { create(:cashfree_provider, code:, organization:) } + let(:provider_customer) { create(:cashfree_customer, payment_provider:, customer:) } + + let(:error_result) do + BaseService::Result.new.tap do |result| + result.fail_with_error!( + BaseService::ThirdPartyFailure.new( + result, + third_party: "Cashfree", + error_code: "400", + error_message: '{"code: "link_post_failed", "type": "invalid_request_error"}' + ) + ) + end + end + + before do + allow(PaymentRequests::Payments::CashfreeService) + .to receive(:new) + .and_return(payment_provider_service) + + allow(payment_provider_service).to receive(:generate_payment_url) + .and_return(error_result) + end + + it "delivers an error webhook" do + expect { generate_payment_url_service.call } + .to enqueue_job(SendWebhookJob) + .with( + "payment_request.payment_failure", + payment_request, + provider_customer_id: provider_customer.provider_customer_id, + provider_error: { + message: '{"code: "link_post_failed", "type": "invalid_request_error"}', + error_code: "400" + } + ).on_queue(webhook_queue) + end + + it "returns a third party error" do + result = generate_payment_url_service.call + + expect(result).to eq(error_result) + end + end + + context "when provider service return an error" do + let(:payment_provider_service) { instance_double(Invoices::Payments::StripeService) } + + let(:error_result) do + BaseService::Result.new.tap do |result| + result.fail_with_error!( + BaseService::ServiceFailure.new( + result, + code: "400", + error_message: "error" + ) + ) + end + end + + before do + allow(Invoices::Payments::StripeService) + .to receive(:new) + .and_return(payment_provider_service) + + allow(payment_provider_service).to receive(:generate_payment_url) + .and_return(error_result) + end + + it "returns an error" do + result = generate_payment_url_service.call + + expect(result).to eq(error_result) + end + end +end diff --git a/spec/services/payment_requests/payments/gocardless_service_spec.rb b/spec/services/payment_requests/payments/gocardless_service_spec.rb new file mode 100644 index 0000000..8d7138c --- /dev/null +++ b/spec/services/payment_requests/payments/gocardless_service_spec.rb @@ -0,0 +1,250 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::GocardlessService do + subject(:gocardless_service) { described_class.new(payment_request) } + + let(:organization) { create(:organization, webhook_url: "https://webhook.com") } + let(:customer) { create(:customer, organization:, payment_provider_code: code) } + let(:gocardless_payment_provider) { create(:gocardless_provider, organization:, code:) } + let(:gocardless_customer) { create(:gocardless_customer, customer:) } + let(:gocardless_client) { instance_double(GoCardlessPro::Client) } + let(:gocardless_payments_service) { instance_double(GoCardlessPro::Services::PaymentsService) } + let(:gocardless_mandates_service) { instance_double(GoCardlessPro::Services::MandatesService) } + let(:gocardless_list_response) { instance_double(GoCardlessPro::ListResponse) } + let(:code) { "gocardless_1" } + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2] + ) + end + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "USD", + ready_for_payment_processing: true + ) + end + + describe "#update_payment_status" do + subject(:result) do + gocardless_service.update_payment_status(provider_payment_id:, status:) + end + + let(:status) { "paid_out" } + + let(:payment) do + create( + :payment, + :gocardless_payment, + payable: payment_request, + provider_payment_id: provider_payment_id, + status: "pending_submission" + ) + end + + let(:provider_payment_id) { "ch_123456" } + + before do + allow(SegmentTrackJob).to receive(:perform_later) + allow(SendWebhookJob).to receive(:perform_later) + payment + end + + it "updates the payment, payment_request and invoice payment_status" do + expect(result).to be_success + expect(result.payment.status).to eq("paid_out") + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(invoice_1.total_paid_amount_cents).to eq(invoice_1.total_amount_cents) + expect(invoice_2.total_paid_amount_cents).to eq(invoice_2.total_amount_cents) + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment request belongs to a dunning campaign" do + let(:customer) do + create( + :customer, + payment_provider_code: code, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: Time.zone.now + ) + end + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2], + dunning_campaign: create(:dunning_campaign) + ) + end + + it "resets the customer dunning campaign counters" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(result).to be_success + end + + context "when status is failed" do + let(:status) { "failed" } + + it "doest not reset the customer dunning campaign counters" do + expect { result && customer.reload } + .to not_change(customer, :last_dunning_campaign_attempt) + .and not_change { customer.last_dunning_campaign_attempt_at&.to_i } + + expect(result).to be_success + end + end + end + + context "when status is failed" do + let(:status) { "failed" } + + it "updates the payment, payment_request and invoice status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("failed") + + expect(result.payable.reload).to be_payment_failed + expect(result.payable.ready_for_payment_processing).to eq(true) + + expect(invoice_1.reload).to be_payment_failed + expect(invoice_1.ready_for_payment_processing).to eq(true) + + expect(invoice_2.reload).to be_payment_failed + expect(invoice_2.ready_for_payment_processing).to eq(true) + end + + it "sends a payment requested email" do + expect { result }.to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + + context "when payment is not found" do + let(:payment) { nil } + let(:status) { "paid_out" } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.payment).to be_nil + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("gocardless_payment_not_found") + end + end + + context "when payment_request and invoice is already payment_succeeded" do + let(:status) { "paid_out" } + + before do + payment_request.payment_succeeded! + invoice_1.payment_succeeded! + invoice_2.payment_succeeded! + end + + it "does not update the status of invoice, payment_request and payment" do + expect { result } + .to not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment_request.reload.payment_status } + .and not_change { payment.reload.status } + + expect(result).to be_success + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "with invalid status" do + let(:status) { "invalid-status" } + + it "does not update the payment_status of payment_request, invoice and payment" do + expect { result } + .to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment.reload.status } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "when payment request is not passed to constructor" do + let(:gocardless_service) { described_class.new(nil) } + let(:status) { "paid_out" } + + before do + payment_request + end + + it "updates the payment and invoice payment_status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(invoice_1.total_paid_amount_cents).to eq(0) + expect(invoice_2.total_paid_amount_cents).to eq(0) + end + end + end +end diff --git a/spec/services/payment_requests/payments/moneyhash_service_spec.rb b/spec/services/payment_requests/payments/moneyhash_service_spec.rb new file mode 100644 index 0000000..96d9acf --- /dev/null +++ b/spec/services/payment_requests/payments/moneyhash_service_spec.rb @@ -0,0 +1,309 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::MoneyhashService do + subject(:moneyhash_service) { described_class.new(payable) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:moneyhash_provider) { create(:moneyhash_provider, organization:) } + let(:moneyhash_customer) { create(:moneyhash_customer, customer:, payment_provider: moneyhash_provider) } + let(:payable) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2] + ) + end + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "USD", + ready_for_payment_processing: true + ) + end + + let(:payment_response_json) { JSON.parse(File.read(Rails.root.join("spec/fixtures/moneyhash/recurring_mit_payment_success_response.json"))) } + let(:provider_payment_id) { payment_response_json.dig("data", "id") } + + let(:lago_client) { instance_double(LagoHttpClient::Client) } + let(:response) { instance_double(Net::HTTPOK) } + let(:endpoint) { "#{PaymentProviders::MoneyhashProvider.api_base_url}/api/v1.1/payments/intent/" } + + describe "#create" do + before do + moneyhash_provider + moneyhash_customer + moneyhash_customer.update!(payment_method_id: "test_payment_method") + allow(LagoHttpClient::Client).to receive(:new).with(endpoint).and_return(lago_client) + end + + context "when moneyhash customer is missing provider customer id" do + before { moneyhash_customer.update!(provider_customer_id: nil) } + + it "returns not found failure" do + result = moneyhash_service.create + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("moneyhash_customer") + end + end + + context "when payment method is missing" do + before { moneyhash_customer.update!(payment_method_id: nil) } + + it "returns not found failure" do + result = moneyhash_service.create + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("payment_method") + end + end + + context "when payment should not be processed" do + context "when payment already succeeded" do + before { payable.update!(payment_status: :succeeded) } + + it "returns success without payment" do + result = moneyhash_service.create + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + + context "when moneyhash provider is missing" do + before { moneyhash_provider.destroy } + + it "returns success without payment" do + result = moneyhash_service.create + + expect(result).to be_success + expect(result.payment).to be_nil + end + end + end + + context "when payment amount is zero" do + before { payable.update!(amount_cents: 0) } + + it "marks payment as succeeded without processing" do + result = moneyhash_service.create + + expect(result).to be_success + expect(result.payment).to be_nil + expect(payable.reload.payment_status).to eq("succeeded") + end + end + + context "when payment should be processed" do + before do + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(payment_response_json.to_json) + end + + it "increments payment attempts, creates a payment and updates payment statuses for payable and invoices" do + result = moneyhash_service.create + + expect(result).to be_success + expect(result.payment).to be_present + expect(result.payment.status).to eq("succeeded") + expect(result.payment.provider_payment_id).to eq(provider_payment_id) + expect(payable.reload.payment_status).to eq("succeeded") + payable.invoices.each do |invoice| + expect(invoice.payment_status).to eq("succeeded") + end + end + + context "when API request fails" do + before do + allow(lago_client).to receive(:post_with_response) + .and_raise(LagoHttpClient::HttpError.new(422, "error", "error_code")) + end + + it "marks payment as failed" do + result = moneyhash_service.create + + expect(result).to be_success + expect(result.payment).to be_nil + expect(payable.reload.payment_status).to eq("failed") + payable.invoices.each do |invoice| + expect(invoice.payment_status).to eq("pending") + end + end + end + end + + context "when multiple_payment_methods feature flag is enabled" do + let(:payment_method) do + create(:payment_method, + customer:, + payment_provider_customer: moneyhash_customer, + provider_method_id: "pm_test_123") + end + + before do + organization.update!(feature_flags: ["multiple_payment_methods"]) + payment_method + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(payment_response_json.to_json) + end + + it "uses customer default payment_method provider_method_id as card_token" do + moneyhash_service.create + + expect(lago_client).to have_received(:post_with_response) do |params, _headers| + expect(params[:card_token]).to eq("pm_test_123") + end + end + + context "when customer has no default payment method" do + before { payment_method.update!(is_default: false) } + + it "returns not found failure" do + result = moneyhash_service.create + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("payment_method") + end + end + end + + context "when multiple_payment_methods feature flag is disabled" do + before do + moneyhash_customer.update!(payment_method_id: "legacy_pm_456") + allow(lago_client).to receive(:post_with_response).and_return(response) + allow(response).to receive(:body).and_return(payment_response_json.to_json) + end + + it "uses provider_customer payment_method_id as card_token" do + moneyhash_service.create + + expect(lago_client).to have_received(:post_with_response) do |params, _headers| + expect(params[:card_token]).to eq("legacy_pm_456") + end + end + end + end + + describe "#update_payment_status" do + let(:payment) do + create(:payment, + payment_provider: moneyhash_provider, + provider_payment_id:, + payable:, + amount_cents: payable.total_amount_cents, + amount_currency: payable.currency) + end + + before do + moneyhash_provider + moneyhash_customer + payable + payment + payment_response_json["data"]["custom_fields"]["lago_payable_id"] = payable.id + payment_response_json["data"]["custom_fields"]["lago_payable_type"] = payable.class.name + end + + context "when payment exists" do + it "updates payment, payable and invoices status" do + result = moneyhash_service.update_payment_status( + organization_id: organization.id, + provider_payment_id: payment_response_json.dig("data", "id"), + status: payment_response_json.dig("data", "status"), + metadata: payment_response_json.dig("data", "custom_fields") + ) + + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + expect(result.payment.provider_payment_id).to eq(payment_response_json.dig("data", "id")) + expect(result.payable.payment_status).to eq("succeeded") + [invoice_1, invoice_2].each do |invoice| + expect(invoice.reload.payment_status).to eq("succeeded") + end + end + end + + context "when payment does not exist" do + let(:metadata) { {"lago_payable_id" => payable.id} } + + it "creates a new payment" do + result = moneyhash_service.update_payment_status( + organization_id: organization.id, + provider_payment_id: "new_payment_id", + status: "SUCCESSFUL", + metadata: + ) + + expect(result).to be_success + expect(result.payment).to be_present + expect(result.payment.provider_payment_id).to eq("new_payment_id") + expect(result.payment.status).to eq("succeeded") + expect(result.payable.payment_status).to eq("succeeded") + [invoice_1, invoice_2].each do |invoice| + expect(invoice.reload.payment_status).to eq("succeeded") + end + end + + context "when payable is not found" do + let(:metadata) { {"lago_payable_id" => "invalid_id"} } + let(:moneyhash_service) { described_class.new(nil) } + + it "returns not found error" do + result = moneyhash_service.update_payment_status( + organization_id: organization.id, + provider_payment_id: "new_payment_id", + status: "SUCCESSFUL", + metadata: + ) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("payment_request") + end + end + end + + context "when payment already succeeded" do + before do + payable.update!(payment_status: :succeeded) + payment.update!(status: :succeeded) + end + + it "does not update the status" do + result = moneyhash_service.update_payment_status( + organization_id: organization.id, + provider_payment_id: payment_response_json.dig("data", "id"), + status: "FAILED", + metadata: payment_response_json.dig("data", "custom_fields") + ) + + expect(result).to be_success + expect(payment.reload.status).to eq("succeeded") + expect(payable.reload.payment_status).to eq("succeeded") + end + end + end +end diff --git a/spec/services/payment_requests/payments/payment_providers/factory_spec.rb b/spec/services/payment_requests/payments/payment_providers/factory_spec.rb new file mode 100644 index 0000000..c3318e9 --- /dev/null +++ b/spec/services/payment_requests/payments/payment_providers/factory_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::PaymentProviders::Factory do + subject(:factory_service) { described_class.new_instance(payable:) } + + let(:payment_provider) { "stripe" } + let(:payable) { create(:payment_request, customer:) } + let(:customer) { create(:customer, payment_provider:) } + + describe "#self.new_instance" do + context "when stripe" do + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::StripeService") + end + end + + context "when adyen" do + let(:payment_provider) { "adyen" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::AdyenService") + end + end + + context "when cashfree" do + let(:payment_provider) { "cashfree" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::CashfreeService") + end + end + + context "when gocardless" do + let(:payment_provider) { "gocardless" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::GocardlessService") + end + end + + context "when moneyhash" do + let(:payment_provider) { "moneyhash" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::MoneyhashService") + end + end + + context "when flutterwave" do + let(:payment_provider) { "flutterwave" } + + it "returns correct class" do + expect(factory_service.class.to_s).to eq("PaymentRequests::Payments::FlutterwaveService") + end + end + end +end diff --git a/spec/services/payment_requests/payments/stripe_service_spec.rb b/spec/services/payment_requests/payments/stripe_service_spec.rb new file mode 100644 index 0000000..d76fb12 --- /dev/null +++ b/spec/services/payment_requests/payments/stripe_service_spec.rb @@ -0,0 +1,731 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::Payments::StripeService do + subject(:stripe_service) { described_class.new(payment_request) } + + let(:customer) { create(:customer, payment_provider_code: code) } + let(:organization) { customer.organization } + let(:billing_entity) { organization.default_billing_entity } + let(:stripe_payment_provider) { create(:stripe_provider, organization:, code:) } + let(:stripe_customer) { + create(:stripe_customer, customer:, payment_method_id: stripe_payment_method_id) + } + let(:stripe_payment_method_id) { "pm_123456" } + let(:code) { "stripe_1" } + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "EUR", + invoices: + ) + end + + let(:invoices) { [invoice_1, invoice_2] } + + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 200, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + let(:invoice_2) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 599, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + describe "#generate_payment_url" do + before do + stripe_payment_provider + stripe_customer + + allow(::Stripe::Checkout::Session).to receive(:create) + .and_return({"url" => "https://example.com"}) + end + + it "generates payment url" do + stripe_service.generate_payment_url + + expect(::Stripe::Checkout::Session) + .to have_received(:create) + .with( + { + line_items: [ + { + quantity: 1, + price_data: { + currency: invoice_1.currency.downcase, + unit_amount: invoice_1.total_amount_cents, + product_data: {name: invoice_1.number} + } + }, + { + quantity: 1, + price_data: { + currency: invoice_2.currency.downcase, + unit_amount: invoice_2.total_amount_cents, + product_data: {name: invoice_2.number} + } + } + ], + mode: "payment", + success_url: stripe_payment_provider.success_redirect_url, + customer: customer.stripe_customer.provider_customer_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + payment_intent_data: { + description: "#{billing_entity.name} - Overdue invoices", + metadata: { + lago_customer_id: customer.id, + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest", + payment_type: "one-time" + } + } + }, + hash_including({api_key: an_instance_of(String)}) + ) + end + + context "when payment request is related to a single overdue invoice" do + let(:invoices) { [invoice_1] } + + it "includes the invoice number in stripe data" do + stripe_service.generate_payment_url + + expect(::Stripe::Checkout::Session) + .to have_received(:create) + .with( + { + line_items: [ + { + quantity: 1, + price_data: { + currency: invoice_1.currency.downcase, + unit_amount: invoice_1.total_amount_cents, + product_data: {name: invoice_1.number} + } + } + ], + mode: "payment", + success_url: stripe_payment_provider.success_redirect_url, + customer: customer.stripe_customer.provider_customer_id, + payment_method_types: customer.stripe_customer.provider_payment_methods, + payment_intent_data: { + description: "#{billing_entity.name} - Overdue invoices: #{invoice_1.number}", + metadata: { + lago_customer_id: customer.id, + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest", + payment_type: "one-time" + } + } + }, + hash_including({api_key: an_instance_of(String)}) + ) + end + end + + context "with an error on Stripe" do + before do + allow(::Stripe::Checkout::Session).to receive(:create) + .and_raise(::Stripe::InvalidRequestError.new("error", {})) + end + + it "returns a failed result" do + result = stripe_service.generate_payment_url + + expect(result).not_to be_success + + expect(result.error).to be_a(BaseService::ThirdPartyFailure) + expect(result.error.third_party).to eq("Stripe") + expect(result.error.error_message).to eq("error") + end + end + end + + describe "#update_payment_status" do + subject(:result) do + stripe_service.update_payment_status( + organization_id: organization.id, + stripe_payment:, + status: + ) + end + + let(:status) { "succeeded" } + + let(:payment) do + create( + :payment, + payable: payment_request, + provider_payment_id: stripe_payment.id + ) + end + + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: {}, + error_code: nil + ) + end + + before do + allow(SegmentTrackJob).to receive(:perform_later) + allow(SendWebhookJob).to receive(:perform_later) + payment + end + + it "updates the payment, payment_request and invoice payment_status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(invoice_1.total_paid_amount_cents).to eq(invoice_1.total_amount_cents) + expect(invoice_2.total_paid_amount_cents).to eq(invoice_2.total_amount_cents) + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + + context "when the payment request belongs to a dunning campaign" do + let(:customer) do + create( + :customer, + payment_provider_code: code, + last_dunning_campaign_attempt: 3, + last_dunning_campaign_attempt_at: Time.zone.now + ) + end + + let(:payment_request) do + create( + :payment_request, + organization:, + customer:, + amount_cents: 799, + amount_currency: "USD", + invoices: [invoice_1, invoice_2], + dunning_campaign: create(:dunning_campaign) + ) + end + + it "resets the customer dunning campaign counters" do + expect { result && customer.reload } + .to change(customer, :last_dunning_campaign_attempt).to(0) + .and change(customer, :last_dunning_campaign_attempt_at).to(nil) + + expect(result).to be_success + end + + context "when status is failed" do + let(:status) { "failed" } + + it "doest not reset the customer dunning campaign counters" do + expect { result && customer.reload } + .to not_change(customer, :last_dunning_campaign_attempt) + .and not_change { customer.last_dunning_campaign_attempt_at&.to_i } + + expect(result).to be_success + end + end + end + + context "when status is failed" do + let(:status) { "failed" } + + it "updates the payment, payment_request and invoice status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("failed") + + expect(result.payable.reload).to be_payment_failed + expect(result.payable.ready_for_payment_processing).to eq(true) + + expect(invoice_1.reload).to be_payment_failed + expect(invoice_1.ready_for_payment_processing).to eq(true) + + expect(invoice_2.reload).to be_payment_failed + expect(invoice_2.ready_for_payment_processing).to eq(true) + + expect(invoice_1.total_paid_amount_cents).to eq(0) + expect(invoice_2.total_paid_amount_cents).to eq(0) + end + + it "sends a payment requested email" do + expect { result }.to have_enqueued_mail(PaymentRequestMailer, :requested) + .with(params: {payment_request:}, args: []) + end + end + + context "when invoices have offset amounts from credit notes" do + let(:credit_note_1) do + create( + :credit_note, + invoice: invoice_1, + customer:, + offset_amount_cents: 50, + credit_amount_cents: 0, + refund_amount_cents: 0, + total_amount_cents: 50, + status: :finalized + ) + end + + let(:credit_note_2) do + create( + :credit_note, + invoice: invoice_2, + customer:, + offset_amount_cents: 99, + credit_amount_cents: 0, + refund_amount_cents: 0, + total_amount_cents: 99, + status: :finalized + ) + end + + before do + credit_note_1 + credit_note_2 + end + + it "updates invoices considering offset amounts" do + expect(result).to be_success + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_2.reload).to be_payment_succeeded + + # Invoice 1: total_amount_cents = 200, offset = 50, due = 150 + # After payment: total_paid_amount_cents should be 150 (to cover the due amount) + expect(invoice_1.total_paid_amount_cents).to eq(150) + + # Invoice 2: total_amount_cents = 599, offset = 99, due = 500 + # After payment: total_paid_amount_cents should be 500 (to cover the due amount) + expect(invoice_2.total_paid_amount_cents).to eq(500) + end + + it "marks invoices as paid even though paid amount doesn't equal total amount" do + result + + # Due to offsets, paid amount < total amount, but invoice should still be marked as paid + expect(invoice_1.reload.total_paid_amount_cents).to be < invoice_1.total_amount_cents + expect(invoice_2.reload.total_paid_amount_cents).to be < invoice_2.total_amount_cents + + expect(invoice_1).to be_payment_succeeded + expect(invoice_2).to be_payment_succeeded + end + end + + context "when invoice is fully offset by credit note" do + let(:invoice_3) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 100, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + let(:credit_note_3) do + create( + :credit_note, + invoice: invoice_3, + customer:, + offset_amount_cents: 100, + credit_amount_cents: 0, + refund_amount_cents: 0, + total_amount_cents: 100, + status: :finalized + ) + end + + let(:invoices) { [invoice_1, invoice_2, invoice_3] } + + before { credit_note_3 } + + it "does not increase paid amount for fully offset invoice" do + result + + # Invoice 3 is fully offset, so total_due_amount_cents = 0 + # No payment should be applied to it + expect(invoice_3.reload.total_paid_amount_cents).to eq(0) + expect(invoice_3).to be_payment_succeeded + end + end + + context "when invoice is partially paid and has offset amount" do + let(:invoice_1) do + create( + :invoice, + organization:, + customer:, + total_amount_cents: 300, + total_paid_amount_cents: 100, + currency: "EUR", + ready_for_payment_processing: true + ) + end + + let(:credit_note) do + create( + :credit_note, + invoice: invoice_1, + customer:, + offset_amount_cents: 50, + credit_amount_cents: 0, + refund_amount_cents: 0, + total_amount_cents: 50, + status: :finalized + ) + end + + before { credit_note } + + it "updates paid amount correctly" do + result + + # Invoice 1: total = 300, already paid = 100, offset = 50, due = 150 + # After payment: total_paid_amount_cents = 100 + 150 = 250 + expect(invoice_1.reload.total_paid_amount_cents).to eq(250) + expect(invoice_1).to be_payment_succeeded + end + end + + context "when payment_request and invoice is already payment_succeeded" do + before do + payment_request.payment_succeeded! + invoice_1.payment_succeeded! + invoice_2.payment_succeeded! + end + + it "does not update the status of invoice, payment_request and payment" do + expect { result } + .to not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment_request.reload.payment_status } + .and not_change { payment.reload.status } + + expect(result).to be_success + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "with invalid status" do + let(:status) { "invalid-status" } + + it "does not update the payment_status of payment_request, invoice and payment" do + expect { result } + .to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment.reload.status } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:payable_payment_status) + expect(result.error.messages[:payable_payment_status]).to include("value_is_invalid") + end + + it "does not send payment requested email" do + expect { result }.not_to have_enqueued_mail(PaymentRequestMailer, :requested) + end + end + + context "when payment is not found and it is one time payment" do + let(:payment) { nil } + let(:status) { "succeeded" } + + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: { + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest", + payment_type: "one-time" + }, + error_code: nil + ) + end + + before do + stripe_payment_provider + stripe_customer + end + + it "creates a payment and updates payment request and invoice payment status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + end + + context "when payment request is not found" do + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: { + lago_payable_id: "invalid", + lago_payable_type: "PaymentRequest", + payment_type: "one-time" + }, + error_code: nil + ) + end + + it "raises a not found failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("payment_request_not_found") + end + end + end + + context "when the payment is found by stripe payment id" do + let(:payment) do + create( + :payment, + payable: payment_request, + provider_payment_id: stripe_payment.id, + status: "pending" + ) + end + + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: { + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest", + payment_type: "one-time" + }, + error_code: nil + ) + end + + before do + stripe_payment_provider + stripe_customer + payment + end + + it "updates the payment status and related entities" do + expect(result).to be_success + expect(result.payment.status).to eq("succeeded") + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(invoice_1.total_paid_amount_cents).to eq(invoice_1.total_amount_cents) + expect(invoice_2.total_paid_amount_cents).to eq(invoice_2.total_amount_cents) + end + + it "does not create a new payment" do + expect { result }.not_to change(Payment, :count) + end + end + + context "when payment is not found" do + let(:payment) { nil } + let(:status) { "succeeded" } + + it "returns an empty result" do + expect(result).to be_success + expect(result.payment).to be_nil + end + + context "with payment request id in metadata" do + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: { + lago_payable_id: SecureRandom.uuid, + lago_payable_type: "PaymentRequest" + }, + error_code: nil + ) + end + + it "returns an empty result" do + expect(result).to be_success + expect(result.payment).to be_nil + end + + context "when the payment request is found for organization" do + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: { + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + }, + error_code: nil + ) + end + + before do + stripe_customer + stripe_payment_provider + end + + it "creates the missing payment and updates payment_request status" do + expect(result).to be_success + expect(result.payment.status).to eq(status) + expect(result.payment.payable_payment_status).to eq("succeeded") + + expect(result.payable.reload).to be_payment_succeeded + expect(result.payable.ready_for_payment_processing).to eq(false) + + expect(invoice_1.reload).to be_payment_succeeded + expect(invoice_1.ready_for_payment_processing).to eq(false) + expect(invoice_2.reload).to be_payment_succeeded + expect(invoice_2.ready_for_payment_processing).to eq(false) + + expect(payment_request.payments.count).to eq(1) + payment = payment_request.payments.first + expect(payment).to have_attributes( + payable: payment_request, + payment_provider_id: stripe_payment_provider.id, + payment_provider_customer_id: stripe_customer.id, + amount_cents: payment_request.total_amount_cents, + amount_currency: payment_request.currency, + provider_payment_id: "ch_123456", + status: "succeeded" + ) + end + + context "when a concurrent writer has already persisted the payment" do + let(:payment) do + create( + :payment, + payable: payment_request, + provider_payment_id: stripe_payment.id, + payment_provider: stripe_payment_provider, + payment_provider_customer: stripe_customer + ) + end + + before do + payment + # Force the initial lookup to miss so the service falls through to handle_missing_payment. + # The rescue's re-fetch then finds the row the winning writer (a parallel webhook worker + # or PaymentProviders::Stripe::Payments::CreateService) committed in the meantime. + allow(Payment).to receive(:find_by) + .with(provider_payment_id: stripe_payment.id) + .and_return(nil, payment) + end + + it "returns a success result with the persisted payment" do + expect(result).to be_success + expect(result.payment).to eq(payment) + expect(result.payable).to eq(payment_request) + end + end + end + end + end + + context "when payment belongs to a payment_request from another company" do + let(:payment_request_other_organization) do + create(:payment_request, organization: create(:organization)) + end + + let(:payment) do + create(:payment, payable: payment_request_other_organization, provider_payment_id: "ch_123456") + end + + it "returns an empty result" do + expect(result).to be_success + expect(result.payment).to be_nil + end + + it "does not update the payment_status of payment_request, invoice and payment" do + expect { result } + .to not_change { payment_request.reload.payment_status } + .and not_change { invoice_1.reload.payment_status } + .and not_change { invoice_2.reload.payment_status } + .and not_change { payment.reload.status } + end + end + + context "when payment's payable already has a successful payment" do + let(:payment) { nil } + + let(:stripe_payment) do + PaymentProviders::StripeProvider::StripePayment.new( + id: "ch_123456", + status: "succeeded", + metadata: { + lago_payable_id: payment_request.id, + lago_payable_type: "PaymentRequest" + }, + error_code: nil + ) + end + + before do + stripe_customer + stripe_payment_provider + + payment_request.payment_succeeded! + end + + it "returns an empty result" do + expect(result).to be_success + expect(result.payment).to be_nil + expect(result.payable).to be_nil + end + end + end +end diff --git a/spec/services/payment_requests/update_service_spec.rb b/spec/services/payment_requests/update_service_spec.rb new file mode 100644 index 0000000..a2abbf0 --- /dev/null +++ b/spec/services/payment_requests/update_service_spec.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PaymentRequests::UpdateService do + subject(:result) do + described_class.call( + payable: payment_request, + params: update_args, + webhook_notification: + ) + end + + let(:payment_request) { create :payment_request } + let(:webhook_notification) { false } + let(:update_args) { {payment_status: "succeeded"} } + + describe "#call" do + it "updates the invoice" do + expect(result).to be_success + expect(result.payable).to eq(payment_request) + expect(result.payable).to be_payment_succeeded + end + + context "when payment_request does not exist" do + let(:payment_request) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("payment_request_not_found") + end + end + end +end diff --git a/spec/services/payments/lose_dispute_service_spec.rb b/spec/services/payments/lose_dispute_service_spec.rb new file mode 100644 index 0000000..bec8bb3 --- /dev/null +++ b/spec/services/payments/lose_dispute_service_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Payments::LoseDisputeService do + subject(:lose_dispute_service) { described_class.new(payment:) } + + describe "#call" do + context "when payment does not exist" do + let(:payment) { nil } + + it "returns a failure" do + result = lose_dispute_service.call + + expect(result).to be_failure + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("payment") + end + + it "does not enqueue a send webhook job for the invoice" do + expect { lose_dispute_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when payable is not found" do + let(:payment) { create(:payment, payable: create(:payment_request)) } + + before do + payment.payable.destroy! + payment.reload + end + + it "marks all invoices as dispute lost" do + result = lose_dispute_service.call + + expect(result).to be_failure + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("payable") + end + end + + context "when payable is an invoice" do + let(:payment) { create(:payment, payable: create(:invoice, status:)) } + + context "when the invoice is voided" do + let(:status) { :voided } + + it "marks the dispute as lost" do + result = lose_dispute_service.call + + expect(result).to be_success + expect(result.invoices.sole.payment_dispute_lost_at).to be_present + end + + it "enqueues a send webhook job for the invoice" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.payment_dispute_lost", payment.payable, provider_error: nil) + end + + it "enqueues a sync void invoice job" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::VoidJob).with(invoice: payment.payable) + end + end + + context "when the invoice is draft" do + let(:status) { :draft } + + it "returns a failure" do + result = lose_dispute_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("not_disputable") + end + + it "does not enqueue a send webhook job for the invoice" do + expect { lose_dispute_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + end + + context "when the invoice is finalized" do + let(:status) { :finalized } + + it "marks the dispute as lost" do + result = lose_dispute_service.call + + expect(result).to be_success + expect(result.invoices.sole.payment_dispute_lost_at).to be_present + end + + it "enqueues a send webhook job for the invoice" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(SendWebhookJob).with("invoice.payment_dispute_lost", payment.payable, provider_error: nil) + end + + it "enqueues a sync void invoice job" do + expect do + lose_dispute_service.call + end.to have_enqueued_job(Invoices::ProviderTaxes::VoidJob).with(invoice: payment.payable) + end + end + end + + context "when payable is a payment request" do + let(:payment_request) { create(:payment_request, invoices: create_list(:invoice, 3)) } + let(:payment) { create(:payment, payable: payment_request) } + + it "marks all invoices as dispute lost" do + result = lose_dispute_service.call + + expect(result).to be_success + expect(result.invoices.count).to eq 3 + expect(result.invoices.pluck(:payment_dispute_lost_at)).to all be_within(5.seconds).of(Time.current) + expect(SendWebhookJob).to have_been_enqueued.exactly(3).times.with("invoice.payment_dispute_lost", Invoice, provider_error: nil) + expect(Invoices::ProviderTaxes::VoidJob).to have_been_enqueued.exactly(3).times + end + end + end +end diff --git a/spec/services/payments/manual_create_service_spec.rb b/spec/services/payments/manual_create_service_spec.rb new file mode 100644 index 0000000..074f998 --- /dev/null +++ b/spec/services/payments/manual_create_service_spec.rb @@ -0,0 +1,349 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Payments::ManualCreateService do + subject(:service) { described_class.new(organization:, params:) } + + let(:invoice) { create(:invoice, customer:, organization:, total_amount_cents: 10000, status: :finalized) } + let(:invoice_id) { invoice.id } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:params) { {invoice_id:, amount_cents:, reference: "ref1", paid_at:} } + let(:paid_at) { 1.year.ago.iso8601 } + let(:amount_cents) { 10000 } + + describe "#call" do + context "when organization is not premium" do + it "returns forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "when organization is premium", :premium do + context "when invoice does not exist" do + let(:invoice_id) { SecureRandom.uuid } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + end + end + + context "when invoice is in status that does not allow manual payment" do + let(:invoice) { create(:invoice, :draft, customer:, organization:) } + + it "returns forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "when invoice's payment request is succeeded" do + let(:payment_request) { create(:payment_request, payment_status: "succeeded") } + + before do + create(:payment_request_applied_invoice, invoice:, payment_request:) + end + + it "returns validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + + context "when payment amount cents is greater than invoice's remaining amount cents" do + let(:amount_cents) { 10001 } + + it "returns validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + + context "when amount_cents in missing" do + let(:params) { {invoice_id:, amount_in_cents: 123, reference: "ref1", paid_at:} } + + it "returns a validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:amount_cents]).to eq(["invalid_value"]) + end + end + + context "when reference in missing" do + let(:params) { {invoice_id:, amount_cents: 123, paid_at:} } + + it "returns a validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:reference]).to eq(["value_is_mandatory"]) + end + end + + context "when invoice_id in missing" do + let(:params) { {amount_cents: 123, paid_at:} } + + it "returns a validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:invoice_id]).to eq(["value_is_mandatory"]) + end + end + + context "when paid_at format is invalid" do + let(:paid_at) { "invalid_date" } + + it "returns a validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:paid_at]).to eq(["invalid_date"]) + end + end + + context "when paid_at format is valid but different format" do + let(:paid_at) { "2024-01-20" } + + it "creates a payment with valid date" do + result = service.call + + expect(result).to be_success + expect(result.payment.payment_type).to eq("manual") + expect(result.payment.created_at).to eq(paid_at) + end + end + + context "when payment amount cents is smaller than invoice remaining amount cents" do + let(:amount_cents) { 2000 } + + it "creates a payment" do + result = service.call + + expect(result).to be_success + expect(result.payment.payment_type).to eq("manual") + expect(result.payment.created_at).to eq(paid_at) + end + + it "updates invoice's total paid amount cents" do + result = service.call + expect(result.payment.payable.total_paid_amount_cents).to eq(amount_cents) + end + + context "when issue_receipts_enabled is true" do + before { organization.update!(premium_integrations: %w[issue_receipts]) } + + it "enqueues a payment receipt job" do + expect { service.call }.to have_enqueued_job(PaymentReceipts::CreateJob) + end + end + + context "when there is an integration customer" do + let(:integration) do + create( + :netsuite_integration, + organization:, + settings: { + account_id: "acc_12345", + client_id: "cli_12345", + script_endpoint_url: Faker::Internet.url, + sync_payments: true + } + ) + end + + before { create(:netsuite_customer, integration:, customer:) } + + it "enqueues an aggregator payment job" do + expect { service.call }.to have_enqueued_job(Integrations::Aggregator::Payments::CreateJob) + end + end + end + + context "when payment amount cents is equal to invoice remaining amount cents" do + let(:amount_cents) { 10000 } + + it "creates a payment" do + result = service.call + + expect(result).to be_success + expect(result.payment.payment_type).to eq("manual") + end + + it "updates invoice's total paid amount cents" do + result = service.call + + expect(result.payment.payable.total_paid_amount_cents).to eq(amount_cents) + end + + it "updates invoice's payment status to suceeded" do + result = service.call + + expect(result.payment.payable.payment_status).to eq("succeeded") + expect(SendWebhookJob).to have_been_enqueued.with( + "invoice.payment_status_updated", + invoice + ) + end + + it "produces an activity log" do + payment = described_class.call(organization:, params:).payment + + expect(Utils::ActivityLog).to have_produced("payment.recorded").after_commit.with(payment) + end + + context "when issue_receipts_enabled is true" do + before { organization.update!(premium_integrations: %w[issue_receipts]) } + + it "enqueues a payment receipt job" do + expect { service.call }.to have_enqueued_job(PaymentReceipts::CreateJob) + end + end + end + + context "when invoice has offset amounts from credit notes" do + let(:invoice) { create(:invoice, customer:, organization:, total_amount_cents: 10000, status: :finalized) } + let(:credit_note) do + create( + :credit_note, + invoice:, + customer:, + offset_amount_cents: 3000, + credit_amount_cents: 0, + refund_amount_cents: 0, + total_amount_cents: 3000, + status: :finalized + ) + end + let(:invoice_settlement) do + create( + :invoice_settlement, + target_invoice: invoice, + source_credit_note: credit_note, + settlement_type: :credit_note, + amount_cents: 3000, + organization: + ) + end + + before { invoice_settlement } + + context "when payment covers remaining amount after offset" do + let(:amount_cents) { 7000 } # 10000 total - 3000 offset = 7000 remaining + + it "marks invoice as paid" do + result = service.call + + expect(result).to be_success + expect(result.payment.payable.payment_status).to eq("succeeded") + end + + it "updates total_paid_amount_cents correctly" do + result = service.call + + expect(result.payment.payable.total_paid_amount_cents).to eq(7000) + end + + it "sends payment status updated webhook" do + service.call + + expect(SendWebhookJob).to have_been_enqueued.with( + "invoice.payment_status_updated", + invoice + ) + end + end + + context "when payment is less than remaining amount after offset" do + let(:amount_cents) { 5000 } + + it "does not mark invoice as paid" do + result = service.call + + expect(result).to be_success + expect(result.payment.payable.payment_status).not_to eq("succeeded") + end + + it "updates total_paid_amount_cents correctly" do + result = service.call + + expect(result.payment.payable.total_paid_amount_cents).to eq(5000) + end + end + end + + context "when invoice has partial payment and offset" do + let(:invoice) do + create( + :invoice, + customer:, + organization:, + total_amount_cents: 10000, + total_paid_amount_cents: 3000, + status: :finalized + ) + end + let(:credit_note) do + create( + :credit_note, + invoice:, + customer:, + offset_amount_cents: 2000, + status: :finalized + ) + end + + before do + create( + :payment, + payable: invoice, + organization:, + customer:, + amount_cents: 3000, + status: "succeeded", + payable_payment_status: "succeeded" + ) + + create( + :invoice_settlement, + target_invoice: invoice, + source_credit_note: credit_note, + settlement_type: :credit_note, + amount_cents: 2000, + organization: + ) + end + + context "when final payment covers remaining" do + let(:amount_cents) { 5000 } # 10000 - 3000 paid - 2000 offset = 5000 + + it "marks invoice as paid" do + result = service.call + + expect(result).to be_success + expect(result.payment.payable.payment_status).to eq("succeeded") + expect(result.payment.payable.total_paid_amount_cents).to eq(8000) # 3000 + 5000 + end + end + end + end + end +end diff --git a/spec/services/payments/set_payment_method_data_service_spec.rb b/spec/services/payments/set_payment_method_data_service_spec.rb new file mode 100644 index 0000000..6b82e6d --- /dev/null +++ b/spec/services/payments/set_payment_method_data_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Payments::SetPaymentMethodDataService do + subject(:service) { described_class.new(payment:, provider_payment_method_id:) } + + let(:provider_payment_method_id) { "pm_1R2DFsQ8iJWBZFaMw3LLbR0r" } + let(:organization) { create(:organization) } + + describe "#call" do + context "with Stripe" do + let(:payment) { create(:payment, payment_provider: create(:stripe_provider), organization:) } + + it "updates the payment method data" do + stub_request(:get, %r{/v1/payment_methods/pm_}).and_return( + status: 200, body: get_stripe_fixtures("retrieve_payment_method_response.json") + ) + + result = service.call + + expect(result.payment.provider_payment_method_id).to eq "pm_1R2DFsQ8iJWBZFaMw3LLbR0r" + expect(result.payment.provider_payment_method_data["type"]).to eq("card") + expect(result.payment.provider_payment_method_data["brand"]).to eq("visa") + expect(result.payment.provider_payment_method_data["last4"]).to eq("4242") + expect(result.payment&.payment_method&.details).to be_nil + end + + context "with multiple payment methods enabled" do + let(:organization) { create(:organization, feature_flags: ["multiple_payment_methods"]) } + let(:payment_method) { create(:payment_method, organization:) } + let(:payment) { create(:payment, payment_provider: create(:stripe_provider), organization:, payment_method:) } + + it "updates payment data and the payment method details" do + stub_request(:get, %r{/v1/payment_methods/pm_}).and_return( + status: 200, body: get_stripe_fixtures("retrieve_payment_method_response.json") + ) + + result = service.call + + expect(result.payment.provider_payment_method_id).to eq "pm_1R2DFsQ8iJWBZFaMw3LLbR0r" + expect(result.payment.provider_payment_method_data["type"]).to eq("card") + expect(result.payment.provider_payment_method_data["brand"]).to eq("visa") + expect(result.payment.provider_payment_method_data["last4"]).to eq("4242") + expect(result.payment.payment_method.reload.details["type"]).to eq("card") + expect(result.payment.payment_method.reload.details["brand"]).to eq("visa") + expect(result.payment.payment_method.reload.details["last4"]).to eq("4242") + end + end + + context "when the payment method id is already set" do + it "does not call stripe" do + payment.update!( + provider_payment_method_id: provider_payment_method_id, + provider_payment_method_data: {existing: "data"} + ) + result = service.call + expect(result.payment.provider_payment_method_id).to eq provider_payment_method_id + expect(result.payment.provider_payment_method_data).to eq({"existing" => "data"}) + end + end + end + + context "with any other provider" do + let(:payment) { create(:payment, payment_provider: create(:gocardless_provider)) } + + it do + expect { service.call }.to raise_error(NotImplementedError) + end + end + end +end diff --git a/spec/services/plans/apply_taxes_service_spec.rb b/spec/services/plans/apply_taxes_service_spec.rb new file mode 100644 index 0000000..0d4cbc9 --- /dev/null +++ b/spec/services/plans/apply_taxes_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::ApplyTaxesService do + subject(:apply_service) { described_class.new(plan:, tax_codes:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:tax1) { create(:tax, organization:, code: "tax1") } + let(:tax2) { create(:tax, organization:, code: "tax2") } + let(:tax_codes) { [tax1.code, tax2.code] } + + describe "call" do + it "applies taxes to the plan" do + expect { apply_service.call }.to change { plan.applied_taxes.count }.from(0).to(2) + end + + it "returns applied taxes" do + result = apply_service.call + expect(result.applied_taxes.count).to eq(2) + end + + context "when plan is not found" do + let(:plan) { nil } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "when tax is not found" do + let(:tax_codes) { ["unknown"] } + + it "returns an error" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "when applied tax is already present" do + it "does not create a new applied tax" do + create(:plan_applied_tax, plan:, tax: tax1) + expect { apply_service.call }.to change { plan.applied_taxes.count }.from(1).to(2) + end + end + + context "when trying to apply twice the same tax" do + let(:tax_codes) { [tax1.code, tax1.code] } + + it "assigns it only once" do + expect { apply_service.call }.to change { plan.applied_taxes.count }.from(0).to(1) + end + end + end +end diff --git a/spec/services/plans/chargeables_validation_service_spec.rb b/spec/services/plans/chargeables_validation_service_spec.rb new file mode 100644 index 0000000..cc36a99 --- /dev/null +++ b/spec/services/plans/chargeables_validation_service_spec.rb @@ -0,0 +1,200 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::ChargeablesValidationService do + subject(:validation_service) { described_class.call(organization:, charges:, fixed_charges:) } + + let(:organization) { create(:organization) } + let(:charges) { nil } + let(:fixed_charges) { nil } + + describe "validations" do + context "when no charges or fixed_charges are provided" do + it "returns success" do + expect(validation_service).to be_success + end + end + + context "when validating billable metrics" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charges) do + [ + {billable_metric_id: billable_metric.id} + ] + end + + it "returns success when billable metric exists" do + expect(validation_service).to be_success + end + + context "when billable metric does not exist" do + let(:charges) do + [ + {billable_metric_id: "non-existent-id"} + ] + end + + it "returns not found failure" do + expect(validation_service).to be_a_failure + expect(validation_service.error).to be_a(BaseService::NotFoundFailure) + expect(validation_service.error.message).to eq("billable_metrics_not_found") + end + end + + context "when some billable metrics do not exist" do + let(:billable_metric2) { create(:billable_metric, organization:) } + let(:charges) do + [ + {billable_metric_id: billable_metric.id}, + {billable_metric_id: "non-existent-id"} + ] + end + + it "returns not found failure" do + expect(validation_service).to be_a_failure + expect(validation_service.error).to be_a(BaseService::NotFoundFailure) + expect(validation_service.error.message).to eq("billable_metrics_not_found") + end + end + end + + context "when validating add ons by id" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charges) do + [ + {add_on_id: add_on.id} + ] + end + + it "returns success when add on exists" do + expect(validation_service).to be_success + end + + context "when add on id does not exist" do + let(:fixed_charges) do + [ + {add_on_id: "non-existent-id"} + ] + end + + it "returns not found failure" do + expect(validation_service).to be_a_failure + expect(validation_service.error).to be_a(BaseService::NotFoundFailure) + expect(validation_service.error.message).to eq("add_ons_not_found") + end + end + + context "when validating add ons by code" do + let(:fixed_charges) do + [ + {add_on_code: add_on.code} + ] + end + + it "returns success when add on exists" do + expect(validation_service).to be_success + end + + context "when add on does not exist" do + let(:fixed_charges) do + [ + {add_on_code: "non-existent-code"} + ] + end + + it "returns not found failure" do + expect(validation_service).to be_a_failure + expect(validation_service.error).to be_a(BaseService::NotFoundFailure) + expect(validation_service.error.message).to eq("add_ons_not_found") + end + end + end + + context "when validating both add_on_id and add_on_code" do + let(:add_on2) { create(:add_on, organization:) } + let(:fixed_charges) do + [ + {add_on_id: add_on.id}, + {add_on_code: add_on2.code} + ] + end + + it "returns success when both exist" do + expect(validation_service).to be_success + end + end + + context "when add_on_id is nil" do + let(:fixed_charges) do + [ + {add_on_id: nil, add_on_code: add_on.code} + ] + end + + it "ignores nil add_on_id and validates by code" do + expect(validation_service).to be_success + end + end + + context "when add_on_code is nil" do + let(:fixed_charges) do + [ + {add_on_id: add_on.id, add_on_code: nil} + ] + end + + it "ignores nil add_on_code and validates by id" do + expect(validation_service).to be_success + end + end + end + + context "when validating both charges and fixed_charges" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:charges) do + [ + {billable_metric_id: billable_metric.id} + ] + end + let(:fixed_charges) do + [ + {add_on_id: add_on.id} + ] + end + + it "returns success when both exist" do + expect(validation_service).to be_success + end + + context "when billable metric does not exist" do + let(:charges) do + [ + {billable_metric_id: "non-existent-id"} + ] + end + + it "returns not found failure for billable metrics" do + expect(validation_service).to be_a_failure + expect(validation_service.error).to be_a(BaseService::NotFoundFailure) + expect(validation_service.error.message).to eq("billable_metrics_not_found") + end + end + + context "when add-on does not exist" do + let(:fixed_charges) do + [ + {add_on_id: "non-existent-id"} + ] + end + + it "returns not found failure for fixed charges" do + expect(validation_service).to be_a_failure + expect(validation_service.error).to be_a(BaseService::NotFoundFailure) + expect(validation_service.error.message).to eq("add_ons_not_found") + end + end + end + end +end diff --git a/spec/services/plans/create_service_spec.rb b/spec/services/plans/create_service_spec.rb new file mode 100644 index 0000000..524ee8a --- /dev/null +++ b/spec/services/plans/create_service_spec.rb @@ -0,0 +1,957 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::CreateService do + let(:plans_service) { described_class.new(create_args) } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + let(:create_args) do + { + name: plan_name, + invoice_display_name: plan_invoice_display_name, + organization_id: organization.id, + code: "new_plan", + interval:, + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR", + tax_codes: [plan_tax.code], + charges: charges_args, + fixed_charges: fixed_charges_args, + usage_thresholds: usage_thresholds_args, + minimum_commitment: minimum_commitment_args + } + end + + let(:plan_name) { "Some plan name" } + let(:plan_invoice_display_name) { "Some plan invoice name" } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:sum_billable_metric) { create(:sum_billable_metric, organization:, recurring: true) } + let(:add_on) { create(:add_on, organization:) } + let(:plan_tax) { create(:tax, organization:) } + let(:charge_tax) { create(:tax, organization:) } + let(:pricing_unit) { create(:pricing_unit, organization:) } + let(:interval) { "monthly" } + + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric:, key: "payment_method", values: %w[card physical]) + end + + let(:minimum_commitment_args) do + { + amount_cents: minimum_commitment_amount_cents, + invoice_display_name: minimum_commitment_invoice_display_name, + tax_codes: [plan_tax.code] + } + end + + let(:minimum_commitment_invoice_display_name) { "Minimum spending" } + let(:minimum_commitment_amount_cents) { 100 } + + let(:charges_args) do + [ + { + applied_pricing_unit: applied_pricing_unit_args, + billable_metric_id: billable_metric.id, + charge_model: "standard", + min_amount_cents: 100, + tax_codes: [charge_tax.code], + filters: [ + { + values: {billable_metric_filter.key => ["card"]}, + invoice_display_name: "Card filter", + properties: {amount: "90"} + } + ] + }, + { + applied_pricing_unit: applied_pricing_unit_args, + billable_metric_id: sum_billable_metric.id, + charge_model: "graduated", + pay_in_advance: true, + invoiceable: false, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "2", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "3", + flat_amount: "3" + } + ] + } + } + ] + end + + let(:fixed_charges_args) do + [ + { + add_on_id: add_on.id, + charge_model: "standard" + } + ] + end + + let(:applied_pricing_unit_args) do + { + code: pricing_unit.code, + conversion_rate: rand(0.1..5.0) + } + end + + let(:usage_thresholds_args) do + [ + { + threshold_display_name: "Threshold 1", + amount_cents: 1_000 + }, + { + threshold_display_name: "Threshold 2", + amount_cents: 10_000 + }, + { + threshold_display_name: "Threshold 3", + amount_cents: 100, + recurring: true + } + ] + end + + describe "#call" do + subject(:result) { plans_service.call } + + before do + allow(SegmentTrackJob).to receive(:perform_later) + end + + it "creates a plan" do + expect { plans_service.call } + .to change(Plan, :count).by(1) + + plan = Plan.order(:created_at).last + expect(SendWebhookJob).to have_been_enqueued.with("plan.created", plan) + expect(plan.taxes.pluck(:code)).to eq([plan_tax.code]) + expect(plan.invoice_display_name).to eq(plan_invoice_display_name) + end + + it "does not create minimum commitment" do + plans_service.call + + plan = Plan.order(:created_at).last + + expect(plan.minimum_commitment).to be_nil + end + + context "without premium license" do + it "does not create progressive billing thresholds" do + plans_service.call + + plan = Plan.order(:created_at).last + + expect(plan.usage_thresholds.count).to eq(0) + end + + it "does not create applied pricing units" do + expect { result }.not_to change(AppliedPricingUnit, :count) + end + end + + context "with premium license", :premium do + context "when progressive billing premium integration is not present" do + it "does not create progressive billing thresholds" do + plans_service.call + + plan = Plan.order(:created_at).last + + expect(plan.usage_thresholds.count).to eq(0) + end + end + + context "when progressive billing premium integration is present" do + before do + organization.update!(premium_integrations: ["progressive_billing"]) + end + + it "creates progressive billing thresholds" do + plans_service.call + + plan = Plan.order(:created_at).last + usage_thresholds = plan.usage_thresholds.order(threshold_display_name: :asc) + + expect(plan.usage_thresholds.count).to eq(3) + expect(usage_thresholds.first).to have_attributes(amount_cents: 1_000) + expect(usage_thresholds.second).to have_attributes(amount_cents: 10_000) + expect(usage_thresholds.third).to have_attributes(amount_cents: 100) + end + end + + context "when applied pricing params provided" do + context "when params are valid" do + it "creates applied pricing units" do + expect { result }.to change(AppliedPricingUnit, :count).by(2) + end + end + + context "when params are invalid" do + let(:applied_pricing_unit_args) do + {code: "non-existing-code"} + end + + it "fails with a validation error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + + expect(result.error.messages).to match( + conversion_rate: ["value_is_mandatory", "is not a number"], + pricing_unit: ["relation_must_exist"] + ) + end + + it "does not create applied pricing unit" do + expect { result }.not_to change(AppliedPricingUnit, :count) + end + + it "does not create plan" do + expect { result }.not_to change(Plan, :count) + end + end + end + end + + it "creates charges" do + plans_service.call + + plan = Plan.order(:created_at).last + expect(plan.charges.count).to eq(2) + + standard_charge = plan.charges.standard.first + graduated_charge = plan.charges.graduated.first + + expect(standard_charge).to have_attributes( + organization_id: organization.id, + pay_in_advance: false, + prorated: false, + min_amount_cents: 0, + invoiceable: true, + properties: {"amount" => "0"} + ) + expect(standard_charge.taxes.pluck(:code)).to eq([charge_tax.code]) + expect(standard_charge.filters.first).to have_attributes( + invoice_display_name: "Card filter", + properties: {"amount" => "90"} + ) + expect(standard_charge.filters.first.values.first).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: ["card"] + ) + + expect(graduated_charge).to have_attributes( + organization_id: organization.id, + pay_in_advance: true, + invoiceable: true, + prorated: false + ) + end + + it "auto-generates charge codes when not provided" do + plan = result.plan + charges = plan.charges.order(:created_at) + + expect(charges.first.code).to eq(billable_metric.code) + expect(charges.second.code).to eq(sum_billable_metric.code) + end + + it "creates fixed charges" do + plan = result.plan + expect(plan.fixed_charges.count).to eq(1) + + fixed_charge = plan.fixed_charges.first + expect(fixed_charge).to have_attributes( + organization_id: organization.id, + add_on_id: add_on.id, + charge_model: "standard", + pay_in_advance: false, + prorated: false, + units: 0, + properties: {"amount" => "0"} + ) + end + + it "auto-generates fixed charge codes when not provided" do + plan = result.plan + + expect(plan.fixed_charges.first.code).to eq(add_on.code) + end + + it "calls SegmentTrackJob" do + plan = plans_service.call.plan + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "plan_created", + properties: { + code: plan.code, + name: plan.name, + invoice_display_name: plan.invoice_display_name, + description: plan.description, + plan_interval: plan.interval, + plan_amount_cents: plan.amount_cents, + plan_period: "arrears", + trial: plan.trial_period, + nb_charges: 2, + nb_standard_charges: 1, + nb_percentage_charges: 0, + nb_graduated_charges: 1, + nb_package_charges: 0, + nb_fixed_charges: 1, + nb_standard_fixed_charges: 1, + nb_graduated_fixed_charges: 0, + nb_volume_fixed_charges: 0, + organization_id: plan.organization_id, + parent_id: nil + } + ) + end + + context "when bill_charges_monthly is true" do + context "when plan is yearly" do + let(:create_args) do + super().merge(interval: "yearly", bill_charges_monthly: true) + end + + it "persists bill_charges_monthly" do + plan = result.plan + expect(plan.bill_charges_monthly).to eq(true) + end + + context "when not provided" do + let(:create_args) do + super().merge(interval: "yearly").except(:bill_charges_monthly) + end + + it "defaults to false" do + plan = result.plan + expect(plan.bill_charges_monthly).to eq(false) + end + end + end + + context "when plan is semiannual" do + let(:create_args) do + super().merge(interval: "semiannual", bill_charges_monthly: true) + end + + it "persists bill_charges_monthly" do + plan = result.plan + expect(plan.bill_charges_monthly).to eq(true) + end + + context "when not provided" do + let(:create_args) do + super().merge(interval: "semiannual").except(:bill_charges_monthly) + end + + it "defaults to false" do + plan = result.plan + expect(plan.bill_charges_monthly).to eq(false) + end + end + end + + context "when plan is monthly" do + let(:create_args) do + super().merge(interval: "monthly", bill_charges_monthly: true) + end + + it "ignores the flag and sets it to nil" do + plan = result.plan + expect(plan.bill_charges_monthly).to be_nil + end + end + end + + describe "when bill_fixed_charges_monthly is true" do + context "when plan is yearly" do + let(:create_args) do + super().merge(interval: "yearly", bill_fixed_charges_monthly: true) + end + + it "persists bill_fixed_charges_monthly" do + plan = result.plan + expect(plan.bill_fixed_charges_monthly).to eq(true) + end + + context "when not provided" do + let(:create_args) do + super().merge(interval: "yearly").except(:bill_fixed_charges_monthly) + end + + it "defaults to false" do + plan = result.plan + expect(plan.bill_fixed_charges_monthly).to eq(false) + end + end + end + + context "when plan is semiannual" do + let(:create_args) do + super().merge(interval: "semiannual", bill_fixed_charges_monthly: true) + end + + it "persists bill_fixed_charges_monthly" do + plan = result.plan + expect(plan.bill_fixed_charges_monthly).to eq(true) + end + + context "when not provided" do + let(:create_args) do + super().merge(interval: "semiannual").except(:bill_fixed_charges_monthly) + end + + it "defaults to false" do + plan = result.plan + expect(plan.bill_fixed_charges_monthly).to eq(false) + end + end + end + + context "when plan is monthly" do + let(:create_args) do + super().merge(interval: "monthly", bill_fixed_charges_monthly: true) + end + + it "ignores the flag and sets it to nil" do + plan = result.plan + expect(plan.bill_fixed_charges_monthly).to be_nil + end + end + end + + it "produces an activity log" do + result = described_class.call(create_args) + + expect(Utils::ActivityLog).to have_produced("plan.created").after_commit.with(result.plan) + end + + context "when premium", :premium do + let(:charges_args) do + [ + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + min_amount_cents: 100, + tax_codes: [charge_tax.code] + }, + { + billable_metric_id: sum_billable_metric.id, + charge_model: "graduated_percentage", + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice", + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: 10, + rate: "3", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + rate: "2", + flat_amount: "3" + } + ] + } + } + ] + end + + it "saves premium attributes" do + plan = plans_service.call.plan + + expect(plan.minimum_commitment).to have_attributes( + { + amount_cents: minimum_commitment_amount_cents, + invoice_display_name: minimum_commitment_invoice_display_name + } + ) + + expect(plan.charges.standard.first).to have_attributes( + { + organization_id: organization.id, + pay_in_advance: false, + min_amount_cents: 100, + invoiceable: true + } + ) + + expect(plan.charges.graduated_percentage.first).to have_attributes( + { + organization_id: organization.id, + pay_in_advance: true, + invoiceable: false, + regroup_paid_fees: "invoice", + charge_model: "graduated_percentage" + } + ) + end + end + + context "with code already used by a deleted plan" do + it "creates a plan with the same code" do + create(:plan, organization:, code: "new_plan", deleted_at: Time.current) + + expect { plans_service.call }.to change(Plan, :count).by(1) + + plans = organization.plans.with_discarded + expect(plans.count).to eq(2) + expect(plans.pluck(:code).uniq).to eq(["new_plan"]) + end + end + + context "with validation error" do + let(:plan_name) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + + context "with invalid charges" do + let(:plan_name) { "Some plan name" } + + let(:charges_args) do + [ + { + applied_pricing_unit: applied_pricing_unit_args, + billable_metric_id: billable_metric.id, + charge_model: "custom_properties", + min_amount_cents: 100, + tax_codes: [charge_tax.code], + filters: [] + } + ] + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:charge_model]).to eq(["value_is_invalid"]) + end + end + + context "with premium charge model" do + let(:plan_name) { "foo" } + let(:charges_args) do + [ + { + billable_metric_id: sum_billable_metric.id, + charge_model: "graduated_percentage", + pay_in_advance: true, + invoiceable: false, + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: 10, + rate: "3", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + rate: "2", + flat_amount: "3" + } + ] + } + } + ] + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:charge_model]).to eq(["graduated_percentage_requires_premium_license"]) + end + end + + context "with invalid interval" do + let(:interval) { "daily" } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:interval]).to eq(["value_is_invalid"]) + end + end + end + + context "with metrics from other organization" do + let(:billable_metric) { create(:billable_metric) } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("billable_metrics_not_found") + end + end + + context "with add ons from other organization" do + let(:add_on) { create(:add_on) } + + let(:create_args) do + { + name: plan_name, + invoice_display_name: plan_invoice_display_name, + organization_id: organization.id, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR", + fixed_charges: fixed_charges_args + } + end + + let(:fixed_charges_args) do + [ + { + add_on_id: add_on.id, + charge_model: "standard" + } + ] + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("add_ons_not_found") + end + end + end + + describe "#bill_charges_monthly" do + subject(:method_call) { plans_service.send(:bill_charges_monthly, create_args) } + + let(:create_args) do + super().merge(interval:, bill_charges_monthly:) + end + + context "when bill_charges_monthly is false" do + let(:bill_charges_monthly) { false } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + + context "when bill_charges_monthly is true" do + let(:bill_charges_monthly) { true } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(true) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(true) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + + context "when bill_charges_monthly is nil" do + let(:bill_charges_monthly) { nil } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + + context "when bill_charges_monthly is not set" do + let(:bill_charges_monthly) { nil } + + before { create_args.delete(:bill_charges_monthly) } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + end + + describe "#bill_fixed_charges_monthly" do + subject(:method_call) { plans_service.send(:bill_fixed_charges_monthly, create_args) } + + let(:create_args) do + super().merge(interval:, bill_fixed_charges_monthly:) + end + + context "when bill_fixed_charges_monthly is false" do + let(:bill_fixed_charges_monthly) { false } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + + context "when bill_fixed_charges_monthly is true" do + let(:bill_fixed_charges_monthly) { true } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(true) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(true) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + + context "when bill_fixed_charges_monthly is nil" do + let(:bill_fixed_charges_monthly) { nil } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + + context "when bill_fixed_charges_monthly is not set" do + let(:bill_fixed_charges_monthly) { nil } + + before { create_args.delete(:bill_fixed_charges_monthly) } + + context "when plan is yearly" do + let(:interval) { "yearly" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is semiannual" do + let(:interval) { "semiannual" } + + it "returns the correct value" do + expect(subject).to eq(false) + end + end + + context "when plan is monthly" do + let(:interval) { "monthly" } + + it "ignores the flag and sets it to nil" do + expect(subject).to be_nil + end + end + end + end + + describe "metadata" do + let(:create_args) do + { + name: plan_name, + organization_id: organization.id, + code: "plan_with_metadata", + interval: "monthly", + pay_in_advance: false, + amount_cents: 100, + amount_currency: "EUR", + charges: [], + metadata: {key1: "value1", key2: "value2"} + } + end + + it "creates plan with metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata).to be_present + expect(result.plan.metadata.value).to eq("key1" => "value1", "key2" => "value2") + end + + context "when metadata is empty" do + let(:create_args) do + { + name: plan_name, + organization_id: organization.id, + code: "plan_with_empty_metadata", + interval: "monthly", + pay_in_advance: false, + amount_cents: 100, + amount_currency: "EUR", + charges: [], + metadata: {} + } + end + + it "creates plan with empty metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata).to be_present + expect(result.plan.metadata.value).to eq({}) + end + end + + context "when metadata is not provided" do + let(:create_args) do + { + name: plan_name, + organization_id: organization.id, + code: "plan_without_metadata", + interval: "monthly", + pay_in_advance: false, + amount_cents: 100, + amount_currency: "EUR", + charges: [] + } + end + + it "creates plan without metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata).to be_nil + end + end + end +end diff --git a/spec/services/plans/destroy_service_spec.rb b/spec/services/plans/destroy_service_spec.rb new file mode 100644 index 0000000..cef9a92 --- /dev/null +++ b/spec/services/plans/destroy_service_spec.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::DestroyService do + subject(:destroy_service) { described_class.new(plan:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:, pending_deletion: true) } + + before do + plan + end + + describe "#call" do + it "soft deletes the plan" do + freeze_time do + expect { destroy_service.call }.to change(Plan, :count).by(-1) + .and change { plan.reload.deleted_at }.from(nil).to(Time.current) + end + end + + it "sets pending_deletion to false" do + expect { destroy_service.call }.to change { plan.reload.pending_deletion }.from(true).to(false) + end + + it "produces an activity log" do + described_class.call(plan:) + + expect(Utils::ActivityLog).to have_produced("plan.deleted").after_commit.with(plan) + end + + context "when plan is not found" do + let(:plan) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "with active subscriptions" do + let(:subscriptions) { create_list(:subscription, 2, plan:) } + + before { subscriptions } + + it "terminates the subscriptions" do + result = destroy_service.call + + expect(result).to be_success + + subscriptions.each do |subscription| + expect(subscription.reload).to be_terminated + end + end + end + + context "with pending subscriptions" do + let(:subscriptions) { create_list(:subscription, 2, :pending, plan:) } + + before { subscriptions } + + it "cancels the subscriptions" do + result = destroy_service.call + + expect(result).to be_success + + subscriptions.each do |subscription| + expect(subscription.reload).to be_canceled + end + end + end + + context "with draft invoices" do + let(:subscription) { create(:subscription, plan:) } + let(:invoices) { create_list(:invoice, 2, :draft) } + + before do + create(:invoice_subscription, invoice: invoices.first, subscription:, invoicing_reason: :subscription_starting) + create(:invoice_subscription, invoice: invoices.second, subscription:, invoicing_reason: :subscription_periodic) + end + + it "finalizes draft invoices" do + result = destroy_service.call + + expect(result).to be_success + + invoices.each do |invoice| + expect(invoice.reload).to be_finalized + end + end + end + + context "with entitlements" do + let(:entitlement) { create(:entitlement, plan:) } + let(:entitlement_value) { create(:entitlement_value, entitlement: entitlement, privilege: create(:privilege, feature: entitlement.feature)) } + + before do + entitlement + entitlement_value + end + + it "destroys the entitlements" do + destroy_service.call + expect(entitlement.reload).to be_discarded + expect(entitlement_value.reload).to be_discarded + end + end + + context "when plan is already discarded" do + let(:plan) { create(:plan, :deleted, organization:) } + + it "returns the deleted plan" do + result = destroy_service.call + + expect(result).to be_success + expect(result.plan).to eq(plan) + end + end + end +end diff --git a/spec/services/plans/override_service_spec.rb b/spec/services/plans/override_service_spec.rb new file mode 100644 index 0000000..2ce0b56 --- /dev/null +++ b/spec/services/plans/override_service_spec.rb @@ -0,0 +1,294 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::OverrideService do + subject(:override_service) { described_class.new(plan: parent_plan, params:, subscription:) } + + let(:subscription) { nil } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call", :premium do + let(:parent_plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:tax) { create(:tax, organization:) } + + let(:charge) do + create( + :standard_charge, + plan: parent_plan, + billable_metric:, + properties: {amount: "300"} + ) + end + + let(:fixed_charge) do + create(:fixed_charge, plan: parent_plan, add_on:, properties: {amount: "300"}) + end + + let(:usage_threshold) { create(:usage_threshold, plan: parent_plan) } + + let(:filter) do + create( + :charge_filter, + charge:, + properties: {amount: "10"} + ) + end + + let(:filter_value) do + create( + :charge_filter_value, + charge_filter: filter, + billable_metric_filter:, + values: [billable_metric_filter.values.first] + ) + end + + let(:params) do + { + amount_cents: 300, + amount_currency: "USD", + invoice_display_name: "invoice display name", + name: "overridden name", + description: "overridden description", + trial_period: 20, + tax_codes: [tax.code], + charges: charges_params, + fixed_charges: fixed_charges_params, + usage_thresholds: usage_thresholds_args, + minimum_commitment: minimum_commitment_params + } + end + + let(:minimum_commitment_params) do + { + amount_cents: minimum_commitment_amount_cents, + invoice_display_name: minimum_commitment_invoice_display_name, + tax_codes: [tax.code] + } + end + + let(:minimum_commitment_invoice_display_name) { "Minimum spending" } + let(:minimum_commitment_amount_cents) { 100 } + + let(:charges_params) do + [ + { + id: charge.id, + min_amount_cents: 1000 + } + ] + end + + let(:fixed_charges_params) do + [ + { + id: fixed_charge.id, + properties: {amount: "1000"} + } + ] + end + + let(:usage_thresholds_args) do + [ + { + id: usage_threshold.id, + threshold_display_name: "Threshold 1", + amount_cents: 1_000 + } + ] + end + + before do + organization.update!(premium_integrations: ["progressive_billing"]) + charge + fixed_charge + usage_threshold + allow(SegmentTrackJob).to receive(:perform_later) + filter_value + end + + it "creates a plan based from the parent plan" do + expect { override_service.call }.to change(Plan, :count).by(1) + + plan = Plan.order(:created_at).last + expect(plan).to have_attributes( + organization_id: organization.id, + bill_charges_monthly: parent_plan.bill_charges_monthly, + code: parent_plan.code, + interval: parent_plan.interval, + pay_in_advance: parent_plan.pay_in_advance, + # Parent id + parent_id: parent_plan.id, + # Overriden attributes + amount_cents: 300, + amount_currency: "USD", + description: "overridden description", + invoice_display_name: "invoice display name", + name: "overridden name", + trial_period: 20 + ) + + expect(plan.taxes).to contain_exactly(tax) + + expect(plan.minimum_commitment).to have_attributes( + commitment_type: "minimum_commitment", + amount_cents: minimum_commitment_amount_cents, + invoice_display_name: minimum_commitment_invoice_display_name + ) + + expect(plan.usage_thresholds.first).to have_attributes( + threshold_display_name: "Threshold 1", + amount_cents: 1_000 + ) + + expect(plan.minimum_commitment.taxes.first).to eq(tax) + end + + it "calls SegmentTrackJob" do + plan = override_service.call.plan + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: CurrentContext.membership, + event: "plan_created", + properties: { + code: plan.code, + name: plan.name, + invoice_display_name: plan.invoice_display_name, + description: plan.description, + plan_interval: plan.interval, + plan_amount_cents: plan.amount_cents, + plan_period: "arrears", + trial: plan.trial_period, + nb_charges: 1, + nb_fixed_charges: 1, + nb_standard_charges: 1, + nb_percentage_charges: 0, + nb_graduated_charges: 0, + nb_package_charges: 0, + nb_standard_fixed_charges: 1, + nb_graduated_fixed_charges: 0, + nb_volume_fixed_charges: 0, + organization_id: plan.organization_id, + parent_id: plan.parent.id + } + ) + end + + it "creates charges based from the parent plan" do + charge2 = create( + :graduated_charge, + plan: parent_plan, + billable_metric:, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: nil, + per_unit_amount: "0.01", + flat_amount: "0.01" + } + ] + } + ) + + expect { override_service.call }.to change(Plan, :count).by(1) + + plan = Plan.order(:created_at).last + expect(plan.charges.count).to eq(2) + + graduated = plan.charges.graduated.first + expect(graduated).to have_attributes( + plan_id: plan.id, + min_amount_cents: charge2.min_amount_cents, + properties: charge2.properties + ) + + standard = plan.charges.standard.first + expect(standard).to have_attributes( + amount_currency: charge.amount_currency, + billable_metric_id: billable_metric.id, + charge_model: charge.charge_model, + invoiceable: charge.invoiceable, + pay_in_advance: charge.pay_in_advance, + prorated: charge.prorated, + properties: charge.properties, + # Overriden attributes + plan_id: plan.id, + min_amount_cents: 1000 + ) + end + + it "creates fixed charges based from the parent plan" do + expect { override_service.call }.to change(Plan, :count).by(1) + plan = Plan.order(:created_at).last + expect(plan.fixed_charges.count).to eq(1) + expect(plan.fixed_charges.first).to have_attributes( + add_on_id: fixed_charge.add_on_id, + properties: {"amount" => "1000"} + ) + end + + context "when minimum commitment is not valid" do + let(:minimum_commitment_amount_cents) { nil } + + it "returns error" do + expect { override_service.call }.not_to change(Plan, :count) + expect(override_service.call).not_to be_success + end + end + + context "when subscription parameter is provided" do + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan: parent_plan, customer:) } + + it "creates a plan successfully with subscription parameter" do + expect { override_service.call }.to change(Plan, :count).by(1) + + result = override_service.call + expect(result).to be_success + expect(result.plan.parent_id).to eq(parent_plan.id) + end + + context "when fixed charge has apply_units_immediately set to true" do + let(:fixed_charges_params) do + [ + { + id: fixed_charge.id, + properties: {amount: "1000"}, + units: 25, + apply_units_immediately: true + } + ] + end + + before do + allow(FixedCharges::OverrideService).to receive(:call).and_call_original + end + + it "passes subscription parameter to FixedCharges::OverrideService" do + override_service.call + + expect(FixedCharges::OverrideService) + .to have_received(:call) + .with( + fixed_charge: fixed_charge, + params: { + id: fixed_charge.id, + properties: {amount: "1000"}, + units: 25, + apply_units_immediately: true, + plan_id: kind_of(String) + }, + subscription: subscription + ) + end + end + end + end +end diff --git a/spec/services/plans/prepare_destroy_service_spec.rb b/spec/services/plans/prepare_destroy_service_spec.rb new file mode 100644 index 0000000..66bcbc9 --- /dev/null +++ b/spec/services/plans/prepare_destroy_service_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::PrepareDestroyService do + subject(:prepare_destroy_service) { described_class.new(plan:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + + describe "#call" do + it "sets pending_deletion to true" do + expect { prepare_destroy_service.call }.to change { plan.reload.pending_deletion } + .from(false).to(true) + end + + it "enqueues a Plans::DestroyJob" do + prepare_destroy_service.call + expect(Plans::DestroyJob).to have_been_enqueued.with(plan) + expect(SendWebhookJob).to have_been_enqueued.with("plan.deleted", plan) + end + + it "returns plan in the result" do + result = prepare_destroy_service.call + expect(result.plan).to eq(plan) + end + + context "when plan is not found" do + let(:plan) { nil } + + it "returns an error" do + result = prepare_destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + end +end diff --git a/spec/services/plans/update_amount_service_spec.rb b/spec/services/plans/update_amount_service_spec.rb new file mode 100644 index 0000000..ed0e22d --- /dev/null +++ b/spec/services/plans/update_amount_service_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::UpdateAmountService do + subject(:update_service) { described_class.new(plan:, amount_cents:, expected_amount_cents:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:, amount_cents: 111) } + let(:amount_cents) { 222 } + let(:expected_amount_cents) { 111 } + + before { plan } + + describe "#call" do + it "updates the subscription fee" do + update_service.call + + expect(plan.reload.amount_cents).to eq(222) + end + + context "when plan is not found" do + let(:plan) { nil } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "when the expected_amount_cents does not match the plan amount_cents" do + let(:expected_amount_cents) { 10 } + + it "does not update the plan" do + result = update_service.call + + expect(result).to be_success + expect(result.plan.reload.amount_cents).to eq(111) + end + end + + context "when there are pending subscriptions which are not relevant after the amount cents increase" do + let(:original_plan) { create(:plan, organization:, amount_cents: expected_amount_cents) } + let(:subscription) { create(:subscription, plan: original_plan) } + let(:pending_subscription) do + create(:subscription, plan:, status: :pending, previous_subscription_id: subscription.id) + end + let(:plan_upgrade_result) { BaseService::Result.new } + + before do + allow(Subscriptions::PlanUpgradeService) + .to receive(:call) + .and_return(plan_upgrade_result) + + pending_subscription + end + + it "upgrades subscription plan" do + update_service.call + + expect(Subscriptions::PlanUpgradeService).to have_received(:call) + end + + context "when pending subscription does not have a previous one" do + let(:pending_subscription) do + create(:subscription, plan:, status: :pending, previous_subscription_id: nil) + end + + it "does not upgrade it" do + update_service.call + + expect(Subscriptions::PlanUpgradeService).not_to have_received(:call) + end + end + + context "when subscription upgrade fails" do + let(:plan_upgrade_result) do + BaseService::Result.new.validation_failure!( + errors: {billing_time: ["value_is_invalid"]} + ) + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({billing_time: ["value_is_invalid"]}) + end + end + end + end +end diff --git a/spec/services/plans/update_service_spec.rb b/spec/services/plans/update_service_spec.rb new file mode 100644 index 0000000..fe048f0 --- /dev/null +++ b/spec/services/plans/update_service_spec.rb @@ -0,0 +1,1703 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::UpdateService do + subject(:plans_service) { described_class.new(plan:, params: update_args) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:) } + let(:plan_name) { "Updated plan name" } + let(:plan_invoice_display_name) { "Updated plan invoice display name" } + let(:sum_billable_metric) { create(:sum_billable_metric, organization:, recurring: true) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:tax1) { create(:tax, organization:) } + let(:applied_tax) { create(:plan_applied_tax, plan:, tax: tax1) } + let(:tax2) { create(:tax, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charges_args) do + [ + { + add_on_id: add_on.id, + charge_model: "standard", + invoice_display_name: "fixed_charge1", + units: 2, + properties: {amount: "150"}, + tax_codes: [tax1.code] + }, + { + add_on_id: add_on.id, + charge_model: "graduated", + invoice_display_name: "fixed_charge2", + units: 1, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "2", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "3", + flat_amount: "3" + } + ] + } + } + ] + end + + let(:update_args) do + { + name: plan_name, + invoice_display_name: plan_invoice_display_name, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR", + tax_codes: [tax2.code], + charges: charges_args, + fixed_charges: fixed_charges_args + } + end + + let(:minimum_commitment_args) do + { + amount_cents: minimum_commitment_amount_cents, + invoice_display_name: minimum_commitment_invoice_display_name, + tax_codes: [tax1.code] + } + end + + let(:minimum_commitment_invoice_display_name) { "Minimum spending" } + let(:minimum_commitment_amount_cents) { 100 } + + let(:charges_args) do + [ + { + billable_metric_id: sum_billable_metric.id, + charge_model: "standard", + invoice_display_name: "charge1", + min_amount_cents: 100, + tax_codes: [tax1.code] + }, + { + billable_metric_id: billable_metric.id, + charge_model: "graduated", + invoice_display_name: "charge2", + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "2", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "3", + flat_amount: "3" + } + ] + } + } + ] + end + + let(:usage_thresholds_args) do + [ + { + id: threshold1.id, + threshold_display_name: "Threshold 1", + amount_cents: 1_000 + }, + { + id: threshold2.id, + threshold_display_name: "Threshold 2", + amount_cents: 10_000 + }, + { + id: threshold3.id, + threshold_display_name: "Threshold 3", + amount_cents: 100, + recurring: true + } + ] + end + + let(:threshold1) do + create(:usage_threshold, plan:, threshold_display_name: "Threshold 1", amount_cents: 1) + end + + let(:threshold2) do + create(:usage_threshold, plan:, threshold_display_name: "Threshold 2", amount_cents: 2) + end + + let(:threshold3) do + create(:usage_threshold, :recurring, plan:, threshold_display_name: "Threshold 3", amount_cents: 1) + end + + let(:threshold5) do + create(:usage_threshold, plan:, threshold_display_name: "Threshold 5", amount_cents: 123) + end + + describe "call" do + before do + applied_tax + end + + it "updates a plan" do + result = plans_service.call + + updated_plan = result.plan + + expect(SendWebhookJob).to have_been_enqueued.with("plan.updated", updated_plan) + + expect(updated_plan.name).to eq("Updated plan name") + expect(updated_plan.invoice_display_name).to eq(plan_invoice_display_name) + expect(updated_plan.taxes.pluck(:code)).to eq([tax2.code]) + expect(plan.charges.count).to eq(2) + expect(plan.charges.order(created_at: :asc).first.invoice_display_name).to eq("charge1") + expect(plan.charges.order(created_at: :asc).second.invoice_display_name).to eq("charge2") + expect(plan.fixed_charges.count).to eq(2) + expect(plan.fixed_charges.order(created_at: :asc).first.invoice_display_name).to eq("fixed_charge1") + expect(plan.fixed_charges.order(created_at: :asc).second.invoice_display_name).to eq("fixed_charge2") + end + + it "marks invoices as ready to be refreshed" do + subscription = create(:subscription, organization:, plan:) + invoice = create(:invoice, :draft) + create(:invoice_subscription, invoice:, subscription:) + + expect { plans_service.call }.to change { invoice.reload.ready_to_be_refreshed }.to(true) + end + + context "with activity logs" do + context "when no parent" do + it "produces" do + described_class.call(plan:, params: update_args) + + expect(Utils::ActivityLog).to have_produced("plan.updated").after_commit.with(plan) + end + end + + context "when plan is a children" do + let(:parent_id) { plan.id } + let(:child_plan) { create(:plan, organization:, parent_id:) } + + it "does not produce" do + described_class.call(plan: child_plan, params: update_args) + + expect(Utils::ActivityLog).not_to have_received(:produce) + end + end + end + + context "with cascade option" do + let(:child_plan) { create(:plan, organization:, parent_id:) } + let(:parent_id) { plan.id } + + before do + child_plan + update_args[:cascade_updates] = true + end + + context "when cascade is true and there is no children plans" do + let(:parent_id) { nil } + + it "does not enqueue the job for updating subscription fee" do + expect do + plans_service.call + end.not_to have_enqueued_job(Plans::UpdateAmountJob) + end + end + + context "when cascade is true and child plan is already updated" do + let(:child_plan) { create(:plan, organization:, parent_id:, amount_cents: 150) } + + it "does not enqueue the job for updating subscription fee" do + expect do + plans_service.call + end.not_to have_enqueued_job(Plans::UpdateAmountJob) + end + end + + context "when cascade is true with children plans not touched" do + it "enqueues the job for updating subscription fee" do + expect do + plans_service.call + end.to have_enqueued_job(Plans::UpdateAmountJob) + end + end + + context "when cascade is false with children plans not touched" do + before do + update_args[:cascade_updates] = false + end + + it "does not enqueue the job for updating subscription fee" do + expect do + plans_service.call + end.not_to have_enqueued_job(Plans::UpdateAmountJob) + end + end + end + + context "when thresholds are present" do + let(:usage_thresholds) do + updated_plan.usage_thresholds.order(threshold_display_name: :asc) + end + + let(:updated_plan) { plans_service.call.plan } + + before do + threshold1 + threshold2 + threshold3 + threshold5 + end + + context "with premium license", :premium do + context "when progressive billing premium integration is present" do + before do + plan.organization.update!(premium_integrations: ["progressive_billing"]) + end + + context "when thresholds args are passed" do + before do + update_args[:usage_thresholds] = usage_thresholds_args + + update_args[:usage_thresholds] << { + threshold_display_name: "Threshold 4", + amount_cents: 4_000 + } + end + + it "updates the existing thresholds" do + expect(usage_thresholds.first).to have_attributes(amount_cents: 1_000) + expect(usage_thresholds.second).to have_attributes(amount_cents: 10_000) + expect(usage_thresholds.third).to have_attributes(amount_cents: 100) + expect(usage_thresholds.fourth).to have_attributes(amount_cents: 4_000) + end + + it "creates new thresholds and deletes thresholds that are not in the args" do + expect(plan.usage_thresholds.count).to eq(4) + expect(plan.usage_thresholds.order(threshold_display_name: :asc).last.amount_cents).to eq(123) + expect(usage_thresholds.count).to eq(4) + expect(usage_thresholds.fourth).to have_attributes(amount_cents: 4_000) + end + end + + context "when thresholds args are passed as empty array" do + before do + update_args[:usage_thresholds] = [] + end + + it "deletes all existing thresholds" do + expect(usage_thresholds.count).to eq(0) + end + end + + context "when thresholds args are not passed" do + it "does not update the thresholds" do + expect(usage_thresholds.count).to eq(4) + expect(usage_thresholds.fourth).to have_attributes( + threshold_display_name: "Threshold 5" + ) + end + end + end + end + end + + context "when thresholds are not present" do + let(:usage_thresholds) do + updated_plan.usage_thresholds.order(threshold_display_name: :asc) + end + + let(:updated_plan) { plans_service.call.plan } + + context "without premium license" do + it "does not create progressive billing thresholds" do + expect(usage_thresholds.count).to eq(0) + end + end + + context "with premium license", :premium do + context "when progressive billing premium integration is not present" do + it "does not create progressive billing thresholds" do + expect(usage_thresholds.count).to eq(0) + end + end + + context "when progressive billing premium integration is present" do + before do + plan.organization.update!(premium_integrations: ["progressive_billing"]) + end + + context "when thresholds args are passed" do + before do + update_args[:usage_thresholds] = usage_thresholds_args + end + + it "creates new thresholds" do + expect(usage_thresholds.count).to eq(3) + expect(usage_thresholds.first).to have_attributes( + amount_cents: 1_000 + ) + expect(usage_thresholds.second).to have_attributes( + amount_cents: 10_000 + ) + expect(usage_thresholds.third).to have_attributes( + amount_cents: 100 + ) + end + end + end + end + end + + context "when charges are not passed" do + let(:charge) { create(:standard_charge, plan:) } + let(:update_args) do + { + name: plan_name, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR" + } + end + + before { charge } + + it "does not sanitize charges" do + result = plans_service.call + + updated_plan = result.plan + expect(updated_plan.name).to eq("Updated plan name") + expect(plan.charges.count).to eq(1) + end + end + + context "when plan amount is updated" do + let(:new_customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, plan:, customer: new_customer) } + let(:update_args) do + { + name: plan_name, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 5, + amount_currency: "EUR" + } + end + + before { subscription } + + it "correctly updates plan" do + result = plans_service.call + + updated_plan = result.plan + expect(updated_plan.name).to eq("Updated plan name") + expect(updated_plan.amount_cents).to eq(5) + end + + context "when there are pending subscriptions which are not relevant after the amount cents decrease" do + let(:pending_plan) { create(:plan, organization:, amount_cents: 10) } + let(:pending_subscription) do + create(:subscription, plan: pending_plan, status: :pending, previous_subscription_id: subscription.id) + end + + before { pending_subscription } + + it "correctly cancels pending subscriptions" do + result = plans_service.call + + updated_plan = result.plan + expect(updated_plan.name).to eq("Updated plan name") + expect(updated_plan.amount_cents).to eq(5) + expect(Subscription.find_by(id: pending_subscription.id).status).to eq("canceled") + end + end + + context "when there are pending subscriptions which are not relevant after the amount cents increase" do + let(:original_plan) { create(:plan, organization:, amount_cents: 150) } + let(:subscription) { create(:subscription, plan: original_plan, customer: new_customer) } + let(:pending_subscription) do + create(:subscription, plan:, status: :pending, previous_subscription_id: subscription.id) + end + let(:update_args) do + { + name: plan_name, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR" + } + end + let(:plan_upgrade_result) { BaseService::Result.new } + + before do + allow(Subscriptions::PlanUpgradeService) + .to receive(:call) + .and_return(plan_upgrade_result) + + pending_subscription + end + + it "upgrades subscription plan" do + plans_service.call + + expect(Subscriptions::PlanUpgradeService).to have_received(:call) + end + + it "updates the plan" do + result = plans_service.call + + expect(result.plan.name).to eq("Updated plan name") + expect(result.plan.amount_cents).to eq(200) + end + + context "when pending subscription does not have a previous one" do + let(:pending_subscription) do + create(:subscription, plan:, status: :pending, previous_subscription_id: nil) + end + + it "does not upgrade it" do + plans_service.call + + expect(Subscriptions::PlanUpgradeService).not_to have_received(:call) + end + end + + context "when subscription upgrade fails" do + let(:plan_upgrade_result) do + BaseService::Result.new.validation_failure!( + errors: {billing_time: ["value_is_invalid"]} + ) + end + + it "returns an error" do + result = plans_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({billing_time: ["value_is_invalid"]}) + end + end + end + end + + context "when plan is not found" do + let(:applied_tax) { nil } + let(:plan) { nil } + + it "returns an error" do + result = plans_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "with validation error" do + let(:plan_name) { nil } + + it "returns an error" do + result = plans_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + + context "with new charge" do + let(:plan_name) { "foo" } + + let(:charges_args) do + [ + { + billable_metric_id: sum_billable_metric.id, + charge_model: "standard", + pay_in_advance: false, + invoiceable: true, + properties: { + amount: "100" + } + } + ] + end + + it "updates the plan" do + result = plans_service.call + expect(result.plan.charges.count).to eq(1) + end + + it "auto-generates charge code when not provided" do + result = plans_service.call + expect(result.plan.charges.first.code).to eq(sum_billable_metric.code) + end + end + + context "with premium charge model" do + let(:plan_name) { "foo" } + + let(:charges_args) do + [ + { + billable_metric_id: sum_billable_metric.id, + charge_model: "graduated_percentage", + pay_in_advance: true, + invoiceable: false, + properties: { + graduated_percentage_ranges: [ + { + from_value: 0, + to_value: 10, + rate: "3", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + rate: "2", + flat_amount: "3" + } + ] + } + } + ] + end + + it "returns an error" do + result = plans_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:charge_model]).to eq(["graduated_percentage_requires_premium_license"]) + end + + context "when premium", :premium do + it "saves premium charge model" do + plans_service.call + + expect(plan.charges.graduated_percentage.first).to have_attributes( + { + pay_in_advance: true, + invoiceable: false, + charge_model: "graduated_percentage" + } + ) + end + end + end + end + + context "with metrics from other organization" do + let(:billable_metric) { create(:billable_metric) } + + it "returns an error" do + result = plans_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("billable_metrics_not_found") + end + end + + context "when plan has no minimum commitment" do + context "when minimum commitment arguments are present" do + before { update_args.merge!({minimum_commitment: minimum_commitment_args}) } + + context "when license is premium", :premium do + it "creates minimum commitment" do + result = plans_service.call + commitment = result.plan.minimum_commitment + + expect(commitment.amount_cents).to eq(minimum_commitment_args[:amount_cents]) + expect(commitment.invoice_display_name).to eq(minimum_commitment_args[:invoice_display_name]) + end + end + + context "when license is not premium" do + it "does not create minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment).to be_nil + end + end + end + + context "when minimum commitment arguments are not present" do + context "when license is premium", :premium do + it "does not create minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment).to be_nil + end + end + + context "when license is not premium" do + it "does not create minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment).to be_nil + end + end + end + + context "when minimum commitment arguments is an empty hash" do + before { update_args.merge!({minimum_commitment: {}}) } + + context "when license is premium", :premium do + it "does not create minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment).to be_nil + end + end + + context "when license is not premium" do + it "does not create minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment).to be_nil + end + end + end + end + + context "when plan has minimum commitment" do + let(:minimum_commitment) { create(:commitment, plan:) } + + before { minimum_commitment } + + context "when minimum commitment arguments are present" do + before { update_args.merge!({minimum_commitment: minimum_commitment_args}) } + + context "when license is premium", :premium do + it "updates minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment.amount_cents).to eq(minimum_commitment_args[:amount_cents]) + end + end + + context "when license is not premium" do + it "does not update minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment.amount_cents).not_to eq(update_args[:amount_cents]) + end + end + end + + context "when only some minimum commitment arguments are present" do + let(:minimum_commitment_args) do + {invoice_display_name: minimum_commitment_invoice_display_name} + end + + before { update_args.merge!({minimum_commitment: minimum_commitment_args}) } + + context "when license is premium", :premium do + it "does not update minimum commitment args that are not present" do + result = plans_service.call + + expect(result.plan.minimum_commitment.invoice_display_name).to eq(minimum_commitment_invoice_display_name) + expect(result.plan.minimum_commitment.amount_cents).to eq(minimum_commitment.amount_cents) + end + end + + context "when license is not premium" do + it "does not update minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment.invoice_display_name).to eq(minimum_commitment.invoice_display_name) + expect(result.plan.minimum_commitment.amount_cents).to eq(minimum_commitment.amount_cents) + end + end + end + + context "when minimum commitment arguments are not present" do + context "when license is premium", :premium do + it "does not update minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment.amount_cents).not_to eq(update_args[:amount_cents]) + end + end + + context "when license is not premium" do + it "does not update minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment.amount_cents).not_to eq(update_args[:amount_cents]) + end + end + end + + context "when minimum commitment arguments is an empty hash" do + before { update_args.merge!({minimum_commitment: {}}) } + + context "when license is premium", :premium do + it "deletes plan minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment).to be_nil + end + end + + context "when license is not premium" do + it "does not delete minimum commitment" do + result = plans_service.call + + expect(result.plan.minimum_commitment).not_to be_nil + end + end + end + end + + context "with existing charges" do + let!(:existing_charge) do + create( + :standard_charge, + plan_id: plan.id, + billable_metric_id: sum_billable_metric.id, + amount_currency: "USD", + properties: { + amount: "300" + } + ) + end + + let(:billable_metric_filter) do + create( + :billable_metric_filter, + billable_metric: sum_billable_metric, + key: "payment_method", + values: %w[card physical] + ) + end + + let(:update_args) do + { + id: plan.id, + name: plan_name, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR", + charges: [ + { + id: existing_charge.id, + billable_metric_id: sum_billable_metric.id, + charge_model: "standard", + pay_in_advance: true, + prorated: true, + invoiceable: false, + filters: [ + { + invoice_display_name: "Card filter", + properties: {amount: "90"}, + values: {billable_metric_filter.key => ["card"]} + } + ] + }, + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + min_amount_cents: 100, + properties: { + amount: "300" + }, + tax_codes: [tax1.code] + } + ] + } + end + + it "updates existing charge and creates an other one" do + expect { plans_service.call }.to change(Charge, :count).by(1) + + charge = plan.charges.where(pay_in_advance: false).first + expect(charge.taxes.pluck(:code)).to eq([tax1.code]) + end + + it "updates existing charge" do + plans_service.call + + expect(existing_charge.reload).to have_attributes( + prorated: true, + properties: {"amount" => "0"} + ) + + expect(existing_charge.filters.first).to have_attributes( + invoice_display_name: "Card filter", + properties: {"amount" => "90"} + ) + expect(existing_charge.filters.first.values.first).to have_attributes( + billable_metric_filter_id: billable_metric_filter.id, + values: ["card"] + ) + end + + it "does not update premium attributes" do + plan = plans_service.call.plan + + expect(existing_charge.reload).to have_attributes(pay_in_advance: true, invoiceable: true) + expect(plan.charges.where(pay_in_advance: false).first.min_amount_cents).to eq(0) + end + + context "when premium", :premium do + it "saves premium attributes" do + plans_service.call + + expect(existing_charge.reload).to have_attributes(pay_in_advance: true, invoiceable: false) + charge = plan.charges.where(pay_in_advance: false).first + expect(charge.min_amount_cents).to eq(100) + end + end + + context "with cascade option and update charge case" do + let(:child_plan) { create(:plan, organization:, parent_id:) } + let(:parent_id) { plan.id } + let(:charge_parent_id) { existing_charge.id } + let(:child_charge) do + create( + :standard_charge, + plan_id: child_plan.id, + parent_id: charge_parent_id, + billable_metric_id: sum_billable_metric.id, + properties: {amount: "300"} + ) + end + + before do + child_charge + update_args[:cascade_updates] = true + end + + context "when cascade is true and there is no children plans" do + let(:parent_id) { nil } + + it "does not enqueue the job for updating charge" do + expect do + plans_service.call + end.not_to have_enqueued_job(Charges::UpdateChildrenJob) + end + end + + context "when cascade is true and there are children plans" do + it "enqueues the job for updating charge" do + expect do + plans_service.call + end.to have_enqueued_job(Charges::UpdateChildrenJob) + end + end + + context "when cascade is false with children plans" do + before do + update_args[:cascade_updates] = false + end + + it "does not enqueue the job for updating charge" do + expect do + plans_service.call + end.not_to have_enqueued_job(Charges::DestroyChildrenJob) + end + end + end + + context "with cascade option and create charge case" do + let(:child_plan) { create(:plan, organization:, parent_id:) } + let(:parent_id) { plan.id } + + before do + child_plan + update_args[:cascade_updates] = true + end + + context "when cascade is true and there is no children plans" do + let(:parent_id) { nil } + + it "does not enqueue the job for creating new charge" do + expect do + plans_service.call + end.not_to have_enqueued_job(Charges::CreateChildrenJob) + end + end + + context "when cascade is true and there are children plans" do + it "enqueues the job for creating new charge" do + expect do + plans_service.call + end.to have_enqueued_job(Charges::CreateChildrenJob) + .with(charge: Charge, payload: Hash) + end + end + + context "when cascade is false with children plans" do + before do + update_args[:cascade_updates] = false + end + + it "does not enqueue the job for creating new charge" do + expect do + plans_service.call + end.not_to have_enqueued_job(Charges::CreateChildrenJob) + end + end + end + end + + context "with existing charge attached to subscription" do + let(:existing_charge) do + create( + :standard_charge, + plan_id: plan.id, + billable_metric_id: sum_billable_metric.id, + amount_currency: "USD", + properties: { + amount: "300" + } + ) + end + + let(:subscription) { create(:subscription, plan:) } + + let(:update_args) do + { + id: plan.id, + code: "new_plan", + amount_cents: 200, + charges: [ + { + id: existing_charge.id, + billable_metric_id: sum_billable_metric.id, + charge_model: "standard", + tax_codes: [tax2.code] + } + ] + } + end + + before do + existing_charge && subscription + end + + it "updates existing charge" do + expect { plans_service.call }.not_to change(Charge, :count) + expect(plan.charges.first.taxes.pluck(:code)).to eq([tax2.code]) + end + end + + context "with charge to delete" do + let(:subscription) { create(:subscription, plan:) } + let(:charge) do + create( + :standard_charge, + plan_id: plan.id, + billable_metric_id: billable_metric.id, + properties: {amount: "300"} + ) + end + + let(:update_args) do + { + id: plan.id, + name: plan_name, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR", + charges: [] + } + end + + let(:billable_metric) { sum_billable_metric } + + before do + subscription + charge + end + + it "discards the charge" do + freeze_time do + expect { plans_service.call } + .to change { charge.reload.deleted_at }.from(nil).to(Time.current) + end + end + + context "with cascade option" do + let(:child_plan) { create(:plan, organization:, parent_id:) } + let(:parent_id) { plan.id } + let(:charge_parent_id) { charge.id } + let(:child_charge) do + create( + :standard_charge, + plan_id: child_plan.id, + parent_id: charge_parent_id, + billable_metric_id: billable_metric.id, + properties: {amount: "300"} + ) + end + + before do + child_charge + update_args[:cascade_updates] = true + end + + context "when cascade is true and there is no children plans" do + let(:parent_id) { nil } + + it "does not enqueue the job for removing charge" do + expect do + plans_service.call + end.not_to have_enqueued_job(Charges::DestroyChildrenJob) + end + end + + context "when cascade is true and there are children plans" do + it "enqueues the job for removing charge" do + expect do + plans_service.call + end.to have_enqueued_job(Charges::DestroyChildrenJob) + end + end + + context "when cascade is false with children plans" do + before do + update_args[:cascade_updates] = false + end + + it "does not enqueue the job for removing charge" do + expect do + plans_service.call + end.not_to have_enqueued_job(Charges::DestroyChildrenJob) + end + end + end + end + + context "when attached to a subscription" do + let(:existing_charge) do + create( + :standard_charge, + plan_id: plan.id, + billable_metric_id: sum_billable_metric.id, + properties: { + amount: "300" + } + ) + end + + let(:update_args) do + { + id: plan.id, + name: plan_name, + code: "new_plan", + interval: "monthly", + pay_in_advance: false, + amount_cents: 200, + amount_currency: "EUR", + charges: [ + { + id: existing_charge.id, + billable_metric_id: sum_billable_metric.id, + charge_model: "standard", + properties: { + amount: "100" + } + }, + { + billable_metric_id: billable_metric.id, + charge_model: "standard", + properties: { + amount: "300" + } + } + ] + } + end + + before do + create(:subscription, plan:) + end + + it "updates only name description and new charges" do + result = plans_service.call + updated_plan = result.plan + + expect(updated_plan.name).to eq("Updated plan name") + expect(plan.charges.count).to eq(2) + end + end + + context "with bill_charges_monthly functionality" do + context "when interval is yearly and bill_fixed_charges_monthly is sent" do + let(:update_args) do + { + name: plan_name, + interval: "yearly", + bill_charges_monthly: true + } + end + + it "updates bill_charges_monthly" do + result = plans_service.call + + expect(result.plan.bill_charges_monthly).to eq(true) + end + end + + context "when interval is yearly and bill_charges_monthly is not provided" do + let(:update_args) do + { + name: plan_name, + interval: "yearly" + } + end + + it "sets bill_charges_monthly to false" do + result = plans_service.call + + expect(result.plan.bill_charges_monthly).to eq(false) + end + end + + context "when interval is semiannual and bill_charges_monthly is sent" do + let(:update_args) do + { + name: plan_name, + interval: "semiannual", + bill_charges_monthly: true + } + end + + it "updates bill_charges_monthly" do + result = plans_service.call + + expect(result.plan.bill_charges_monthly).to eq(true) + end + end + + context "when interval is semiannual and bill_charges_monthly is not provided" do + let(:update_args) do + { + name: plan_name, + interval: "semiannual" + } + end + + it "sets bill_charges_monthly to false" do + result = plans_service.call + + expect(result.plan.bill_charges_monthly).to eq(false) + end + end + + context "when interval is not yearly or semiannual" do + let(:update_args) do + { + name: plan_name, + interval: "monthly", + bill_charges_monthly: true + } + end + + it "does not set bill_charges_monthly" do + result = plans_service.call + + expect(result.plan.bill_charges_monthly).to be_nil + end + end + end + + context "with bill_fixed_charges_monthly functionality" do + context "when interval is yearly and bill_fixed_charges_monthly is sent" do + let(:update_args) do + { + name: plan_name, + interval: "yearly", + bill_fixed_charges_monthly: true + } + end + + it "updates bill_fixed_charges_monthly" do + result = plans_service.call + + expect(result.plan.bill_fixed_charges_monthly).to eq(true) + end + end + + context "when interval is yearly and bill_fixed_charges_monthly is not provided" do + let(:update_args) do + { + name: plan_name, + interval: "yearly" + } + end + + it "sets bill_fixed_charges_monthly to false" do + result = plans_service.call + + expect(result.plan.bill_fixed_charges_monthly).to eq(false) + end + end + + context "when interval is semiannual and bill_fixed_charges_monthly is sent" do + let(:update_args) do + { + name: plan_name, + interval: "semiannual", + bill_fixed_charges_monthly: true + } + end + + it "updates bill_fixed_charges_monthly" do + result = plans_service.call + + expect(result.plan.bill_fixed_charges_monthly).to eq(true) + end + end + + context "when interval is semiannual and bill_fixed_charges_monthly is not provided" do + let(:update_args) do + { + name: plan_name, + interval: "semiannual" + } + end + + it "sets bill_fixed_charges_monthly to false" do + result = plans_service.call + + expect(result.plan.bill_fixed_charges_monthly).to eq(false) + end + end + + context "when interval is not yearly or semiannual" do + let(:update_args) do + { + name: plan_name, + interval: "monthly", + bill_fixed_charges_monthly: true + } + end + + it "does not set bill_fixed_charges_monthly" do + result = plans_service.call + + expect(result.plan.bill_fixed_charges_monthly).to be_nil + end + end + end + + context "with fixed_charges validation" do + context "when fixed_charges are valid" do + let(:update_args) do + { + name: plan_name, + fixed_charges: fixed_charges_args + } + end + + it "validates fixed_charges successfully" do + result = plans_service.call + + expect(result).to be_success + end + end + + context "when fixed_charges add_on is not found" do + let(:update_args) do + { + name: plan_name, + fixed_charges: [ + { + add_on_id: add_on.code, + charge_model: "standard", + units: 1, + properties: {amount: "100"} + } + ] + } + end + + it "returns validation error" do + result = plans_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("add_ons_not_found") + end + end + + context "when no fixed_charges are provided" do + let(:update_args) do + { + name: plan_name + } + end + + it "does not validate fixed_charges" do + result = plans_service.call + + expect(result).to be_success + end + end + + context "when both charges and fixed_charges are provided" do + let(:update_args) do + { + name: plan_name, + charges: charges_args, + fixed_charges: fixed_charges_args + } + end + + it "validates both successfully" do + result = plans_service.call + + expect(result).to be_success + end + end + end + + context "with fixed_charges flow" do + let(:update_args) do + { + name: plan_name, + interval: "yearly", + bill_fixed_charges_monthly: true, + fixed_charges: fixed_charges_args + } + end + + context "when plan has no fixed_charges" do + before do + allow(FixedCharges::CreateService).to receive(:call!).and_call_original + end + + it "handles adding fixed_charges flow successfully" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.bill_fixed_charges_monthly).to eq(true) + expect(result.plan.fixed_charges.count).to eq(2) + expect(result.plan.fixed_charges.map(&:invoice_display_name)).to match_array(["fixed_charge1", "fixed_charge2"]) + end + + it "auto-generates fixed charge codes when not provided" do + result = plans_service.call + + expect(result.plan.fixed_charges.pluck(:code)).to match_array([add_on.code, "#{add_on.code}_2"]) + end + + it "calls FixedCharges::CreateService with timestamp" do + freeze_time do + plans_service.call + + expect(FixedCharges::CreateService).to have_received(:call!).twice + expect(FixedCharges::CreateService) + .to have_received(:call!) + .with(plan:, params: fixed_charges_args.first.merge(code: add_on.code), timestamp: Time.current.to_i) + + expect(FixedCharges::CreateService) + .to have_received(:call!) + .with(plan:, params: fixed_charges_args.second.merge(code: "#{add_on.code}_2"), timestamp: Time.current.to_i) + end + end + + context "with plan having active subscriptions" do + let(:subscription) { create(:subscription, :active, plan:) } + + before { subscription } + + it "does not enqueue a Invoices::CreatePayInAdvanceFixedChargesJob for active subscriptions" do + expect { plans_service.call } + .not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + + context "when fixed charge params are pay in advance" do + let(:fixed_charges_args) do + [ + { + add_on_id: add_on.id, + charge_model: "standard", + invoice_display_name: "fixed_charge1", + units: 2, + pay_in_advance: true, + properties: {amount: "150"}, + tax_codes: [tax1.code] + } + ] + end + + it "creates fixed charge with pay in advance" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.fixed_charges.count).to eq(1) + expect(result.plan.fixed_charges.first).to be_pay_in_advance + end + + context "with plan having active subscriptions" do + let(:subscription) { create(:subscription, :active, plan:) } + + before { subscription } + + it "enqueues a Invoices::CreatePayInAdvanceFixedChargesJob for active subscriptions" do + freeze_time do + expect { plans_service.call } + .to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + .with(subscription, Time.current.to_i) + end + end + end + + context "without active subscriptions" do + let(:subscription) { create(:subscription, :pending, plan:) } + + before { subscription } + + it "does not enqueue a Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { plans_service.call } + .not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + end + + context "when plan has fixed_charges" do + let(:fixed_charge_to_update) { create(:fixed_charge, plan:, invoice_display_name: "fixed_charge_to_update", units: 1, add_on:) } + let(:fixed_charge_to_delete) { create(:fixed_charge, plan:, invoice_display_name: "fixed_charge_to_delete", units: 2) } + let(:fixed_charges_args) do + [ + { + id: fixed_charge_to_update.id, + add_on_id: add_on.id, + charge_model: "standard", + invoice_display_name: "fixed_charge1", + units: 2, + properties: {amount: "150"}, + tax_codes: [tax1.code] + }, + { + add_on_id: add_on.id, + charge_model: "graduated", + invoice_display_name: "fixed_charge2", + units: 1, + properties: { + graduated_ranges: [ + { + from_value: 0, + to_value: 10, + per_unit_amount: "2", + flat_amount: "0" + }, + { + from_value: 11, + to_value: nil, + per_unit_amount: "3", + flat_amount: "3" + } + ] + } + } + ] + end + + before do + fixed_charge_to_update + fixed_charge_to_delete + update_args[:cascade_updates] = true + + allow(FixedCharges::UpdateService).to receive(:call!).and_call_original + end + + it "handles update, edit and delete fixed_charges flow successfully" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.fixed_charges.count).to eq(2) + expect(result.plan.fixed_charges.map(&:id)).to include(fixed_charge_to_update.id) + expect(result.plan.fixed_charges.map(&:id)).not_to include(fixed_charge_to_delete.id) + end + + it "calls FixedCharges::UpdateService with timestamp" do + freeze_time do + plans_service.call + + expect(FixedCharges::UpdateService).to have_received(:call!).with( + fixed_charge: fixed_charge_to_update, + params: { + id: fixed_charge_to_update.id, + add_on_id: add_on.id, + charge_model: "standard", + invoice_display_name: "fixed_charge1", + units: 2, + properties: {amount: "150"}, + tax_codes: [tax1.code] + }, + timestamp: Time.current.to_i, + trigger_billing: false + ) + end + end + + context "when plan has children" do + let(:parent_id) { plan.id } + let(:child_plan) { create(:plan, organization:, parent_id:) } + + before { child_plan } + + it "schedules job to update fixed_charges of children plans" do + expect do + plans_service.call + end.to have_enqueued_job(FixedCharges::CascadePlanUpdateJob).exactly(1).times + end + + it "schedules job to delete fixed_charges of children plans" do + expect do + plans_service.call + end.to have_enqueued_job(FixedCharges::DestroyChildrenJob).exactly(1).times + end + end + end + end + end + + describe "metadata" do + context "when metadata is provided" do + let(:update_args) do + { + name: plan_name, + metadata: {key1: "value1", key2: "value2"} + } + end + + it "creates metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata.value).to eq("key1" => "value1", "key2" => "value2") + end + end + + context "when metadata already exists" do + before do + create(:item_metadata, owner: plan, organization:, value: {"existing" => "value", "key1" => "old"}) + end + + context "with partial_metadata: false" do + subject(:plans_service) { described_class.new(plan:, params: update_args, partial_metadata: false) } + + let(:update_args) do + { + name: plan_name, + metadata: {key1: "value1", key2: "value2"} + } + end + + it "replaces all metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata.value).to eq("key1" => "value1", "key2" => "value2") + end + end + + context "with partial_metadata: true" do + subject(:plans_service) { described_class.new(plan:, params: update_args, partial_metadata: true) } + + let(:update_args) do + { + name: plan_name, + metadata: {key1: "value1", key2: "value2"} + } + end + + it "merges metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata.value).to eq("existing" => "value", "key1" => "value1", "key2" => "value2") + end + end + end + + context "when metadata is nil" do + before do + create(:item_metadata, owner: plan, organization:, value: {"existing" => "value"}) + end + + let(:update_args) do + { + name: plan_name, + metadata: nil + } + end + + it "deletes metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata).to be_nil + end + end + + context "when metadata is empty hash" do + before do + create(:item_metadata, owner: plan, organization:, value: {"existing" => "value"}) + end + + let(:update_args) do + { + name: plan_name, + metadata: {} + } + end + + it "replaces metadata with empty hash" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata.value).to eq({}) + end + end + + context "when metadata is not provided" do + let(:metadata) { create(:item_metadata, owner: plan, organization:, value: {"existing" => "value"}) } + + let(:update_args) do + { + name: plan_name + } + end + + before do + metadata + end + + it "does not change metadata" do + result = plans_service.call + + expect(result).to be_success + expect(result.plan.metadata.value).to eq("existing" => "value") + end + end + end +end diff --git a/spec/services/plans/update_usage_thresholds_service_spec.rb b/spec/services/plans/update_usage_thresholds_service_spec.rb new file mode 100644 index 0000000..69400b9 --- /dev/null +++ b/spec/services/plans/update_usage_thresholds_service_spec.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Plans::UpdateUsageThresholdsService do + subject { described_class.call(plan:, usage_thresholds_params:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + + before do + allow(LifetimeUsages::FlagRefreshFromPlanUpdateJob).to receive(:perform_after_commit) + end + + context "when usage_thresholds_params is empty" do + let(:usage_thresholds_params) { [] } + + context "when progressive_billing is not enabled" do + it "does not update the plan" do + expect(subject.plan.usage_thresholds).to be_empty + end + end + + context "when progressive_billing is enabled" do + around { |test| premium_integration!(organization, "progressive_billing", &test) } + + it "does not update the plan" do + expect(subject.plan.usage_thresholds).to be_empty + expect(LifetimeUsages::FlagRefreshFromPlanUpdateJob).not_to have_received(:perform_after_commit) + end + end + end + + context "when usage_thresholds_params is not empty" do + let(:usage_thresholds_params) do + [ + { + threshold_display_name: "Threshold 1", + amount_cents: 1000 + } + ] + end + + context "when progressive_billing is not enabled" do + it "does not update the plan" do + expect(subject.plan.usage_thresholds).to be_empty + expect(LifetimeUsages::FlagRefreshFromPlanUpdateJob).not_to have_received(:perform_after_commit).with(plan) + end + end + + context "when progressive_billing is enabled" do + around { |test| premium_integration!(organization, "progressive_billing", &test) } + + it "does update the plan" do + thresholds = subject.plan.usage_thresholds + expect(thresholds.size).to eq(1) + expect(thresholds.first.threshold_display_name).to eq("Threshold 1") + expect(thresholds.first.amount_cents).to eq(1000) + expect(LifetimeUsages::FlagRefreshFromPlanUpdateJob).to have_received(:perform_after_commit) + end + end + end + + context "when plan already has usage_thresholds" do + let(:threshold1) do + create(:usage_threshold, plan:, threshold_display_name: "Threshold 1", amount_cents: 1) + end + + let(:threshold2) do + create(:usage_threshold, plan:, threshold_display_name: "Threshold 2", amount_cents: 2) + end + + before do + threshold1 + threshold2 + end + + context "when usage_thresholds_params is empty" do + let(:usage_thresholds_params) { [] } + + context "when progressive_billing is not enabled" do + it "does not update the plan" do + expect { subject }.not_to change(plan, :usage_thresholds) + end + end + + context "when progressive_billing is enabled" do + around { |test| premium_integration!(organization, "progressive_billing", &test) } + + it "clears the thresholds" do + expect(subject.plan.usage_thresholds).to be_empty + expect(LifetimeUsages::FlagRefreshFromPlanUpdateJob).not_to have_received(:perform_after_commit) + end + end + end + + context "when usage_thresholds_params is not empty" do + let(:usage_thresholds_params) do + [ + { + threshold_display_name: "Other threshold", + amount_cents: 1000 + } + ] + end + + context "when progressive_billing is not enabled" do + it "does not update the plan" do + expect { subject }.not_to change(plan, :usage_thresholds) + end + end + + context "when progressive_billing is enabled" do + around { |test| premium_integration!(organization, "progressive_billing", &test) } + + it "does update the plan" do + thresholds = subject.plan.usage_thresholds + expect(thresholds.size).to eq(1) + expect(thresholds.first.threshold_display_name).to eq("Other threshold") + expect(thresholds.first.amount_cents).to eq(1000) + expect(LifetimeUsages::FlagRefreshFromPlanUpdateJob).to have_received(:perform_after_commit).with(plan) + end + end + end + end +end diff --git a/spec/services/pricing_units/create_service_spec.rb b/spec/services/pricing_units/create_service_spec.rb new file mode 100644 index 0000000..e4d542b --- /dev/null +++ b/spec/services/pricing_units/create_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PricingUnits::CreateService do + describe "#call" do + subject(:result) { described_class.call(params) } + + let(:organization) { create(:organization) } + let(:name) { "Cloud tokens" } + let(:short_name) { "CT" } + let(:description) { "description" } + let(:already_used_code) { "credits" } + + let(:params) do + { + organization:, + name:, + code:, + short_name:, + description: + } + end + + before { create(:pricing_unit, code: already_used_code, organization:) } + + context "with premium organization", :premium do + context "when params are valid" do + let(:code) { "tokens" } + + it "returns a successful result with the pricing unit" do + expect(result).to be_success + + expect(result.pricing_unit) + .to be_a(PricingUnit) + .and have_attributes(params) + end + + it "creates pricing unit" do + expect { result }.to change(PricingUnit, :count).by(1) + end + end + + context "when params are invalid" do + let(:code) { already_used_code } + + it "fails with validation error" do + expect(result).to be_failure + expect(result.error.messages).to match(code: ["value_already_exist"]) + end + + it "does not create pricing unit" do + expect { result }.not_to change(PricingUnit, :count) + end + end + end + + context "with freemium organization" do + context "when params are valid" do + let(:code) { "tokens" } + + it "fails with a forbidden error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + + it "does not create pricing unit" do + expect { result }.not_to change(PricingUnit, :count) + end + end + + context "when params are invalid" do + let(:code) { already_used_code } + + it "fails with a forbidden error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + + it "does not create pricing unit" do + expect { result }.not_to change(PricingUnit, :count) + end + end + end + end +end diff --git a/spec/services/pricing_units/update_service_spec.rb b/spec/services/pricing_units/update_service_spec.rb new file mode 100644 index 0000000..54b124b --- /dev/null +++ b/spec/services/pricing_units/update_service_spec.rb @@ -0,0 +1,136 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe PricingUnits::UpdateService do + describe "#call" do + subject(:result) { described_class.call(pricing_unit:, params:) } + + let(:name) { "Cloud tokens" } + let(:short_name) { "CT" } + let(:description) { "description" } + + let(:params) do + { + name:, + short_name:, + description: + } + end + + context "with premium organization", :premium do + context "when pricing unit is present" do + let!(:pricing_unit) { create(:pricing_unit) } + + context "when params are valid" do + let(:description) { "description" } + + it "returns a successful result with the pricing unit" do + expect(result).to be_success + + expect(result.pricing_unit) + .to be_a(PricingUnit) + .and have_attributes(params) + end + + it "updates the pricing unit" do + expect { result }.to change(pricing_unit, :attributes) + end + end + + context "when params are invalid" do + let(:description) { "a" * 601 } + + it "fails with validation error" do + expect(result).to be_failure + expect(result.error.messages).to match(description: ["value_is_too_long"]) + end + + it "does not update the pricing unit" do + expect { result }.not_to change { pricing_unit.reload.attributes } + end + end + end + + context "when pricing unit is missing" do + let(:pricing_unit) { nil } + + context "when params are valid" do + let(:description) { "description" } + + it "fails with pricing unit not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("pricing_unit_not_found") + end + end + + context "when params are invalid" do + let(:description) { "a" * 601 } + + it "fails with pricing unit not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("pricing_unit_not_found") + end + end + end + end + + context "with freemium organization" do + context "when pricing unit is present" do + let!(:pricing_unit) { create(:pricing_unit) } + + context "when params are valid" do + let(:description) { "description" } + + it "fails with a forbidden error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + + it "does not update the pricing unit" do + expect { result }.not_to change { pricing_unit.reload.attributes } + end + end + + context "when params are invalid" do + let(:description) { "a" * 601 } + + it "fails with a forbidden error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + + it "does not update the pricing unit" do + expect { result }.not_to change { pricing_unit.reload.attributes } + end + end + end + + context "when pricing unit is missing" do + let(:pricing_unit) { nil } + + context "when params are valid" do + let(:description) { "description" } + + it "fails with a forbidden error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + + context "when params are invalid" do + let(:description) { "a" * 601 } + + it "fails with a forbidden error" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end + end + end +end diff --git a/spec/services/quote_versions/approve_service_spec.rb b/spec/services/quote_versions/approve_service_spec.rb new file mode 100644 index 0000000..9032bfd --- /dev/null +++ b/spec/services/quote_versions/approve_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuoteVersions::ApproveService do + subject(:approve_service) { described_class.new(quote_version:) } + + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:quote) { create(:quote, organization:) } + let(:quote_version) { create(:quote_version, quote:, organization:) } + + describe ".call" do + let(:result) { approve_service.call } + + context "when the quote version is approvable", :premium do + it "approves the quote version" do + freeze_time do + expect(result).to be_success + expect(result.quote_version.approved?).to eq(true) + expect(result.quote_version.approved_at).to eq(Time.current) + end + end + end + + context "when the quote version is voided", :premium do + let(:quote_version) { create(:quote_version, :voided, quote:, organization:) } + + it "does not approve the quote version" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("inappropriate_state") + + quote_version.reload + expect(quote_version.approved?).to eq(false) + expect(quote_version.approved_at).to eq(nil) + end + end + + context "when the quote version is already approved", :premium do + let(:quote_version) { create(:quote_version, :approved, quote:, organization:) } + + it "does not approve the quote version" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("inappropriate_state") + end + end + + context "when quote version does not exist", :premium do + let(:quote_version) { nil } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("quote_version_not_found") + end + end + + context "when license is not premium" do + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/quote_versions/clone_service_spec.rb b/spec/services/quote_versions/clone_service_spec.rb new file mode 100644 index 0000000..e4f5128 --- /dev/null +++ b/spec/services/quote_versions/clone_service_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuoteVersions::CloneService do + subject(:clone_service) { described_class.new(quote_version:) } + + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let!(:quote) { create(:quote, organization:) } + let!(:versions) do + QuoteVersion.transaction do + v1 = create(:quote_version, :voided, quote:, organization:) + v2 = create(:quote_version, :voided, quote:, organization:) + [v1, v2] + end + end + let(:quote_version) { versions.last } + + describe ".call" do + let(:result) { clone_service.call } + + context "when the quote version is clonable", :premium do + context "when the source version is voided" do + it "creates a clone and leaves the source untouched" do + expect(result).to be_success + cloned = result.quote_version + expect(cloned.id).not_to eq(quote_version.id) + expect(cloned.organization_id).to eq(quote_version.organization_id) + expect(cloned.quote_id).to eq(quote_version.quote_id) + expect(cloned.version).to eq(quote_version.version + 1) + expect(cloned.draft?).to eq(true) + expect(cloned.void_reason).to eq(nil) + expect(cloned.voided_at).to eq(nil) + expect(cloned.approved_at).to eq(nil) + + expect(quote.reload.current_version).to eq(cloned) + + quote_version.reload + expect(quote_version.voided?).to eq(true) + expect(quote_version.void_reason).to eq("manual") + expect(quote_version.voided_at).not_to eq(nil) + end + end + + context "when the source version is draft" do + let!(:versions) do + QuoteVersion.transaction do + v1 = create(:quote_version, :voided, quote:, organization:) + v2 = create(:quote_version, quote:, organization:) + [v1, v2] + end + end + + it "creates a clone and voids the source" do + expect(result).to be_success + cloned = result.quote_version + expect(cloned.id).not_to eq(quote_version.id) + expect(cloned.version).to eq(quote_version.version + 1) + expect(cloned.draft?).to eq(true) + + expect(quote.reload.current_version).to eq(cloned) + + quote_version.reload + expect(quote_version.voided?).to eq(true) + expect(quote_version.void_reason).to eq("superseded") + expect(quote_version.voided_at).not_to eq(nil) + end + end + end + + context "when any quote version is already approved", :premium do + let!(:versions) do + [create(:quote_version, :approved, quote:, organization:)] + end + let(:quote_version) { versions.first } + + it "rejects the clone" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("inappropriate_state") + end + end + + context "when an older voided version is cloned but the latest is approved", :premium do + let!(:versions) do + QuoteVersion.transaction do + v1 = create(:quote_version, :voided, quote:, organization:) + v2 = create(:quote_version, :approved, quote:, organization:) + [v1, v2] + end + end + let(:quote_version) { versions.first } + + it "rejects the clone because the quote is locked by an approved version" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("inappropriate_state") + end + end + + context "when an unrelated draft is the active version on the quote", :premium do + let!(:versions) do + QuoteVersion.transaction do + v_voided = create(:quote_version, :voided, quote:, organization:) + v_active = create(:quote_version, quote:, organization:) + [v_voided, v_active] + end + end + let(:quote_version) { versions.first } # cloning the older voided one + + it "rejects the clone because the active version is not the source" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("inappropriate_state") + + versions.last.reload + expect(versions.last.draft?).to eq(true) + expect(versions.last.voided_at).to eq(nil) + end + end + + context "when quote_version does not exist", :premium do + let(:quote_version) { nil } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("quote_version_not_found") + end + end + + context "when license is not premium" do + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/quote_versions/create_service_spec.rb b/spec/services/quote_versions/create_service_spec.rb new file mode 100644 index 0000000..4249add --- /dev/null +++ b/spec/services/quote_versions/create_service_spec.rb @@ -0,0 +1,91 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuoteVersions::CreateService do + subject(:create_service) do + described_class.new(quote:, params: create_params) + end + + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:customer) { create(:customer, organization:) } + let(:quote) { create(:quote, organization:, customer:) } + let(:create_params) do + {billing_items: {}, content: "Test content"} + end + + describe ".call" do + let(:result) { create_service.call } + + context "when license is premium", :premium do + it "creates draft quote version" do + expect(result).to be_success + expect(result.quote_version.quote_id).to eq(quote.id) + expect(result.quote_version.organization_id).to eq(quote.organization_id) + expect(result.quote_version.version).to eq(1) + expect(result.quote_version.draft?).to eq(true) + expect(result.quote_version.content).to eq("Test content") + expect(result.quote_version.share_token).not_to be_nil + expect(result.quote_version.billing_items).to eq({}) + end + end + + context "when quote does not exist", :premium do + let(:quote) { nil } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("quote_not_found") + end + end + + context "when an active draft version already exists for the quote", :premium do + before { create(:quote_version, quote:, organization:) } + + it "rejects with active_version_exists" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("active_version_exists") + end + end + + context "when an approved version already exists for the quote", :premium do + before { create(:quote_version, :approved, quote:, organization:) } + + it "rejects with active_version_exists" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("active_version_exists") + end + end + + context "when a concurrent insert wins the unique-index race", :premium do + it "translates the RecordNotUnique into active_version_exists" do + allow(quote.versions).to receive(:create!).and_raise(ActiveRecord::RecordNotUnique) + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("active_version_exists") + end + end + + context "when license is not premium" do + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + + context "when feature flag is disabled", :premium do + let(:organization) { create(:organization) } + + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/quote_versions/update_service_spec.rb b/spec/services/quote_versions/update_service_spec.rb new file mode 100644 index 0000000..cb0030e --- /dev/null +++ b/spec/services/quote_versions/update_service_spec.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuoteVersions::UpdateService do + subject(:update_service) { described_class.new(quote_version:, params: update_params) } + + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:quote) { create(:quote, organization:) } + let(:quote_version) { create(:quote_version, quote:, organization:) } + let(:update_params) { + { + billing_items: {}, + content: "Test content" + } + } + + describe ".call" do + let(:result) { update_service.call } + + context "when draft quote version", :premium do + it "updates the quote version" do + expect(result).to be_success + expect(result.quote_version.id).to eq(quote_version.id) + expect(result.quote_version.quote_id).to eq(quote_version.quote_id) + expect(result.quote_version.organization_id).to eq(quote_version.organization_id) + expect(result.quote_version.version).to eq(quote_version.version) + expect(result.quote_version.draft?).to eq(true) + expect(result.quote_version.billing_items).to eq({}) + expect(result.quote_version.content).to eq("Test content") + end + end + + context "when approved quote version", :premium do + let(:quote_version) { create(:quote_version, :approved, quote:, organization:) } + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("inappropriate_state") + end + end + + context "when voided quote version", :premium do + let(:quote_version) { create(:quote_version, :voided, quote:, organization:) } + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("inappropriate_state") + end + end + + context "when quote version does not exist", :premium do + let(:quote_version) { nil } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("quote_version_not_found") + end + end + + context "when license is not premium" do + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/quote_versions/void_service_spec.rb b/spec/services/quote_versions/void_service_spec.rb new file mode 100644 index 0000000..bc3f769 --- /dev/null +++ b/spec/services/quote_versions/void_service_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe QuoteVersions::VoidService do + subject(:void_service) { described_class.new(quote_version:, reason:) } + + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:quote_version) { create(:quote_version, organization:) } + let(:reason) { "manual" } + + describe ".call" do + let(:result) { void_service.call } + + context "when quote is voidable", :premium do + it "voids the quote version" do + freeze_time do + expect(result).to be_success + expect(result.quote_version.voided?).to eq(true) + expect(result.quote_version.void_reason).to eq(reason) + expect(result.quote_version.voided_at).to eq(Time.current) + expect(result.quote_version.share_token).to eq(nil) + expect(result.quote_version.approved_at).to eq(nil) + end + end + end + + context "when quote version is approved", :premium do + let(:quote_version) { create(:quote_version, :approved, organization:) } + + it "is not voidable" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("inappropriate_state") + end + end + + context "when quote isn't voidable", :premium do + let(:quote_version) { create(:quote_version, :voided, organization:) } + + it "returns method not allowed" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("inappropriate_state") + end + end + + context "when reason is invalid", :premium do + context "when reason is blank" do + let(:reason) { nil } + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:void_reason]).to eq(["invalid"]) + end + end + + context "when reason is undefined" do + let(:reason) { "invalid_reason" } + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:void_reason]).to eq(["invalid"]) + end + end + end + + context "when quote_version does not exist", :premium do + let(:quote_version) { nil } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("quote_version_not_found") + end + end + + context "when license is not premium" do + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/quotes/create_service_spec.rb b/spec/services/quotes/create_service_spec.rb new file mode 100644 index 0000000..845bb33 --- /dev/null +++ b/spec/services/quotes/create_service_spec.rb @@ -0,0 +1,160 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Quotes::CreateService do + subject(:create_service) do + described_class.new( + organization:, + customer:, + subscription:, + params: create_params + ) + end + + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:membership) { create(:membership, organization:) } + let(:owner) { membership.user } + let(:customer) { create(:customer, organization:) } + let(:subscription) { nil } + let(:create_params) do + { + billing_items: {}, + content: "Test content", + order_type: :subscription_creation, + owners: [owner.id] + } + end + + describe ".call" do + let(:result) { create_service.call } + + context "when license is premium", :premium do + it "creates an empty draft quote" do + travel_to(DateTime.new(2025, 3, 11, 20, 0, 0)) do + expect(result).to be_success + expect(result.quote.organization_id).to eq(organization.id) + expect(result.quote.customer_id).to eq(customer.id) + expect(result.quote.sequential_id).to eq(1) + expect(result.quote.number).to eq("QT-2025-0001") + expect(result.quote.order_type).to eq("subscription_creation") + expect(result.quote.owner_ids).to eq([owner.id]) + + expect(result.quote.versions.size).to eq(1) + expect(result.quote.current_version.version).to eq(1) + expect(result.quote.current_version.draft?).to eq(true) + expect(result.quote.current_version.content).to eq("Test content") + end + end + end + + context "when subscription is required and provided correctly", :premium do + let(:subscription) { create(:subscription, organization:, customer:) } + let(:create_params) do + { + billing_items: {}, + content: "Amendment", + order_type: :subscription_amendment, + owners: [owner.id] + } + end + + it "creates the quote linked to the subscription" do + expect(result).to be_success + expect(result.quote.subscription_id).to eq(subscription.id) + expect(result.quote.order_type).to eq("subscription_amendment") + end + end + + context "when subscription belongs to another customer", :premium do + let(:other_customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer: other_customer) } + let(:create_params) do + {order_type: :subscription_amendment, owners: [owner.id]} + end + + it "returns subscription_not_found" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("subscription_not_found") + end + end + + context "when subscription belongs to another organization", :premium do + let(:other_organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:other_customer) { create(:customer, organization: other_organization) } + let(:subscription) { create(:subscription, organization: other_organization, customer: other_customer) } + let(:create_params) do + {order_type: :subscription_amendment, owners: [owner.id]} + end + + it "returns subscription_not_found" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("subscription_not_found") + end + end + + context "when owners include invalid user ids", :premium do + let(:create_params) do + {order_type: :subscription_creation, owners: ["invalid_user_id"]} + end + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:owners]).to eq(["invalid"]) + end + end + + context "when organization does not exist", :premium do + let(:organization) { nil } + let(:customer) { create(:customer) } + let(:membership) { create(:membership) } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("organization_not_found") + end + end + + context "when customer does not exist", :premium do + let(:customer) { nil } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("customer_not_found") + end + end + + context "when subscription is required but not provided", :premium do + let(:create_params) { {order_type: "subscription_amendment"} } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("subscription_not_found") + end + end + + context "when license is not premium" do + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + + context "when feature flag is disabled", :premium do + let(:organization) { create(:organization) } + + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/quotes/update_service_spec.rb b/spec/services/quotes/update_service_spec.rb new file mode 100644 index 0000000..ff49c7a --- /dev/null +++ b/spec/services/quotes/update_service_spec.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Quotes::UpdateService do + subject(:update_service) { described_class.new(quote:, params: update_params) } + + let(:organization) { create(:organization, feature_flags: ["order_forms"]) } + let(:membership) { create(:membership, organization:) } + let(:owner) { membership.user } + let(:quote) { create(:quote, organization:) } + let(:update_params) { {owners: [owner.id]} } + + describe ".call" do + let(:result) { update_service.call } + + it "updates the quote", :premium do + expect(result).to be_success + expect(result.quote.id).to eq(quote.id) + expect(result.quote.organization_id).to eq(quote.organization_id) + expect(result.quote.customer_id).to eq(quote.customer_id) + expect(result.quote.sequential_id).to eq(quote.sequential_id) + expect(result.quote.number).to eq(quote.number) + expect(result.quote.owner_ids).to eq([owner.id]) + end + + context "when owners include invalid user ids", :premium do + let(:update_params) { {owners: ["invalid_user_id"]} } + + it "returns validation failure" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:owners]).to eq(["invalid"]) + end + end + + context "when quote does not exist", :premium do + let(:quote) { nil } + + it "returns a not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("quote_not_found") + end + end + + context "when license is not premium" do + it "returns forbidden status" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("feature_unavailable") + end + end + end +end diff --git a/spec/services/roles/create_service_spec.rb b/spec/services/roles/create_service_spec.rb new file mode 100644 index 0000000..2b9fc75 --- /dev/null +++ b/spec/services/roles/create_service_spec.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Roles::CreateService do + include_context "with mocked security logger" + + describe "#call" do + subject(:result) { described_class.call(organization:, code:, name:, description:, permissions:) } + + let(:organization) { create(:organization) } + let(:code) { "custom_role" } + let(:name) { "Custom Role" } + let(:description) { "A custom role description" } + let(:permissions) { %w[customers:view customers:create] } + + context "with premium license and custom_roles integration", :premium do + before { organization.update!(premium_integrations: ["custom_roles"]) } + + it "creates a new role" do + expect { result }.to change(Role, :count).by(1) + end + + it "returns success" do + expect(result).to be_success + end + + it "returns the created role" do + expect(result.role).to have_attributes( + organization_id: organization.id, + name:, + description:, + permissions: + ) + end + + it_behaves_like "produces a security log", "role.created" do + before { result } + end + + context "with invalid params" do + let(:name) { nil } + + it "does not create a role" do + expect { result }.not_to change(Role, :count) + end + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "with reserved code" do + let(:code) { "admin" } + + it "does not create a role" do + expect { result }.not_to change(Role, :count) + end + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + end + end + + context "with premium license but without custom_roles integration", :premium do + before { organization.update!(premium_integrations: []) } + + it "does not create a role" do + expect { result }.not_to change(Role, :count) + end + + it "returns forbidden error with code" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("premium_integration_missing") + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "without premium license" do + it "does not create a role" do + expect { result }.not_to change(Role, :count) + end + + it "returns forbidden error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + end +end diff --git a/spec/services/roles/destroy_service_spec.rb b/spec/services/roles/destroy_service_spec.rb new file mode 100644 index 0000000..48bb497 --- /dev/null +++ b/spec/services/roles/destroy_service_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Roles::DestroyService do + include_context "with mocked security logger" + + describe "#call" do + subject(:result) { described_class.call(role:) } + + let(:organization) { create(:organization) } + let(:role) { create(:role, organization:) } + + context "when role exists and has no assigned members" do + it "soft-deletes the role" do + expect { result }.to change { role.reload.deleted_at }.from(nil) + end + + it "returns success" do + expect(result).to be_success + expect(result.role).to eq(role) + end + + it_behaves_like "produces a security log", "role.deleted" do + before { result } + end + end + + context "when role is nil" do + let(:role) { nil } + + it "returns not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("role_not_found") + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "when role is predefined" do + let(:role) { create(:role, :predefined, name: "Finance") } + + it "does not delete the role" do + expect { result }.not_to change { role.reload.deleted_at } + end + + it "returns forbidden error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("predefined_role") + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "when role has assigned members" do + let(:membership) { create(:membership, organization:) } + + before { create(:membership_role, membership:, role:) } + + it "does not delete the role" do + expect { result }.not_to change { role.reload.deleted_at } + end + + it "returns forbidden error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("role_assigned_to_members") + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "when role is assigned only to revoked memberships" do + let(:membership) { create(:membership, :revoked, organization:) } + + before { create(:membership_role, membership:, role:) } + + it "soft-deletes the role" do + expect(result).to be_success + expect(role.reload.deleted_at).to be_present + end + end + end +end diff --git a/spec/services/roles/update_service_spec.rb b/spec/services/roles/update_service_spec.rb new file mode 100644 index 0000000..d5309dc --- /dev/null +++ b/spec/services/roles/update_service_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Roles::UpdateService do + include_context "with mocked security logger" + + describe "#call" do + subject(:result) { described_class.call(role:, params:) } + + let(:organization) { create(:organization) } + let(:role) { create(:role, organization:, code: "old_role", name: "Old Name", description: "Old description", permissions: %w[customers:view addons:view]) } + let(:params) { {name: "New Name", description: "New description", permissions: %w[customers:view plans:view]} } + + context "when role exists" do + it "updates the role" do + expect { result }.to change { role.reload.name }.from("Old Name").to("New Name") + .and change { role.reload.description }.from("Old description").to("New description") + end + + it "returns success" do + expect(result).to be_success + expect(result.role).to eq(role) + end + + it_behaves_like "produces a security log", "role.updated" do + before { result } + end + + context "with partial params" do + let(:params) { {name: "New Name"} } + + it "updates only provided attributes" do + expect { result }.to change { role.reload.name }.to("New Name") + .and not_change { role.reload.description } + end + end + + context "with invalid params" do + let(:params) { {name: ""} } + + it "does not update the role" do + expect { result }.not_to change { role.reload.name } + end + + it "returns validation error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + end + + context "when role is nil" do + let(:role) { nil } + + it "returns not found error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("role_not_found") + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "when role is predefined" do + let(:role) { create(:role, :predefined, name: "Finance") } + + it "does not update the role" do + expect { result }.not_to change { role.reload.name } + end + + it "returns forbidden error" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(result.error.code).to eq("predefined_role") + end + + it_behaves_like "does not produce a security log" do + before { result } + end + end + + context "when name is changed and pending invites exist" do + let(:other_role) { create(:role, organization:) } + let!(:invite_with_role) { create(:invite, organization:, roles: [role.code], status: :pending) } + let!(:invite_with_other_role) { create(:invite, organization:, roles: [other_role.code], status: :pending) } + let!(:accepted_invite) { create(:invite, organization:, roles: [role.code], status: :accepted) } + + it "does not update pending invites when name changes (roles store codes)" do + expect { result }.not_to change { invite_with_role.reload.roles } + end + + it "does not update pending invites with other role codes" do + expect { result }.not_to change { invite_with_other_role.reload.roles } + end + + it "does not update accepted invites" do + expect { result }.not_to change { accepted_invite.reload.roles } + end + end + + context "when name is not changed" do + let(:params) { {description: "New description"} } + let!(:invite_with_role) { create(:invite, organization:, roles: [role.code], status: :pending) } + + it "does not update invites" do + expect { result }.not_to change { invite_with_role.reload.roles } + end + end + end +end diff --git a/spec/services/subscriptions/activate_all_pending_service_spec.rb b/spec/services/subscriptions/activate_all_pending_service_spec.rb new file mode 100644 index 0000000..7d286e5 --- /dev/null +++ b/spec/services/subscriptions/activate_all_pending_service_spec.rb @@ -0,0 +1,159 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivateAllPendingService, clickhouse: true do + subject(:activate_service) { described_class.new(timestamp: timestamp.to_i) } + + let(:timestamp) { Time.current } + + describe ".call" do + it "activates all pending subscriptions with subscription date set to today" do + create(:subscription) + create_list(:subscription, 2, :pending, subscription_at: timestamp) + create(:subscription, :pending, subscription_at: timestamp, plan: create(:plan, pay_in_advance: true)) + create_list(:subscription, 2, :pending, subscription_at: (timestamp + 10.days)) + + expect { activate_service.call } + .to change(Subscription.pending, :count).by(-3) + .and change(Subscription.active, :count).by(3) + .and have_enqueued_job(SendWebhookJob).exactly(3).times + .and have_enqueued_job(BillSubscriptionJob).once + expect(Utils::ActivityLog).to have_received(:produce) + .with(an_instance_of(Subscription), "subscription.started").exactly(3).times + end + + context "when plan is pay in advance has fixed charges" do + let(:plan) { create(:plan, pay_in_advance: true) } + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:subscription) { create(:subscription, :pending, subscription_at: timestamp, plan:) } + + before do + fixed_charge_1 + subscription + end + + it "creates fixed charge events for the new subscription" do + expect { activate_service.call }.to change(FixedChargeEvent, :count).by(1) + expect(subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)).to match_array( + [ + [fixed_charge_1.id, be_within(5.seconds).of(Time.current)] + ] + ) + end + + it "schedules BillSubscriptionJob" do + expect { activate_service.call }.to have_enqueued_job(BillSubscriptionJob) + end + + context "when fixed charge is pay in advance" do + let(:fixed_charge_1) { create(:fixed_charge, plan:, pay_in_advance: true) } + + it "does not schedule Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { activate_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + + context "when fixed charge is not pay in advance" do + let(:fixed_charge_1) { create(:fixed_charge, plan:, pay_in_advance: false) } + + it "does not schedule Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { activate_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when plan is not pay in advance has fixed charges" do + let(:plan) { create(:plan) } + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:subscription) { create(:subscription, :pending, subscription_at: timestamp, plan:) } + + before do + fixed_charge_1 + subscription + end + + it "creates fixed charge events for the new subscription" do + expect { activate_service.call }.to change(FixedChargeEvent, :count).by(1) + expect(subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)).to match_array( + [ + [fixed_charge_1.id, be_within(5.seconds).of(Time.current)] + ] + ) + end + + it "does not schedule BillSubscriptionJob" do + expect { activate_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "when fixed charge is pay in advance" do + let(:fixed_charge_1) { create(:fixed_charge, plan:, pay_in_advance: true) } + + it "schedules Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { activate_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + + context "when fixed charge is not pay in advance" do + let(:fixed_charge_1) { create(:fixed_charge, plan:, pay_in_advance: false) } + + it "does not schedule Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { activate_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "with customer timezone" do + let(:timestamp) { DateTime.parse("2023-08-24 00:07:00") } + let(:customer) { create(:customer, :with_hubspot_integration, timezone: "America/Bogota") } + let!(:pending_subscription) do + create( + :subscription, + :pending, + customer:, + subscription_at: timestamp + ) + end + + it "enqueues Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob" do + allow(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).to receive(:perform_later) + activate_service.call + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob) + .to have_received(:perform_later).with(subscription: pending_subscription) + end + + it "takes timezone into account" do + activate_service.call + expect(pending_subscription.reload).to be_active + end + end + + context "with a subscription in trial" do + let(:plan_with_trial) { create(:plan, pay_in_advance: true, trial_period: 10) } + + before do + create(:subscription, :pending, subscription_at: timestamp, plan: create(:plan, pay_in_advance: true)) + create( + :subscription, + :pending, + subscription_at: timestamp, + plan: plan_with_trial + ) + create(:fixed_charge, plan: plan_with_trial, pay_in_advance: true) + end + + it do + expect { activate_service.call } + .to change(Subscription.pending, :count).by(-2) + .and change(Subscription.active, :count).by(2) + .and have_enqueued_job(SendWebhookJob).exactly(2).times + .and have_enqueued_job(BillSubscriptionJob).once + end + + it "enqueues CreatePayInAdvanceFixedChargesJob for pay-in-advance fixed charges even during trial" do + expect { activate_service.call } + .to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob).once + end + end + end +end diff --git a/spec/services/subscriptions/activate_service_spec.rb b/spec/services/subscriptions/activate_service_spec.rb new file mode 100644 index 0000000..db6e41d --- /dev/null +++ b/spec/services/subscriptions/activate_service_spec.rb @@ -0,0 +1,383 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivateService do + subject(:result) { described_class.call(subscription:, timestamp:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, :pending, organization:, customer:, plan:, subscription_at: Time.current) } + let(:timestamp) { Time.current } + + context "when subscription is pending without activation rules" do + it "activates the subscription" do + freeze_time do + expect(result.subscription).to be_active + expect(result.subscription.started_at).to eq(Time.current) + expect(result.subscription.activated_at).to eq(Time.current) + end + end + + it "sends a subscription.started webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", subscription) + end + + it "produces a subscription.started activity log" do + result + + expect(Utils::ActivityLog).to have_produced("subscription.started").with(subscription) + end + + it "does not enqueue billing jobs" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + expect(Invoices::CreatePayInAdvanceFixedChargesJob).not_to have_been_enqueued + end + + context "when subscription has fixed charges" do + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:) } + + it "emits fixed charge events" do + expect { result }.to change(FixedChargeEvent, :count).by(1) + end + end + + context "when subscription should sync with hubspot" do + let(:customer) { create(:customer, :with_hubspot_integration, organization:) } + + it "enqueues hubspot sync job" do + result + + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob) + .to have_been_enqueued.with(subscription:) + end + + context "when activating during subscription creation" do + subject(:result) { described_class.call(subscription:, timestamp:, during_creation: true) } + + it "does not enqueue Hubspot::UpdateJob" do + result + + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).not_to have_been_enqueued + end + end + end + + context "when plan is pay in advance and not in trial" do + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + + it "enqueues BillSubscriptionJob with skip_charges true" do + result + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription], anything, invoicing_reason: :subscription_starting, skip_charges: true) + end + + it "does not enqueue CreatePayInAdvanceFixedChargesJob" do + result + + expect(Invoices::CreatePayInAdvanceFixedChargesJob).not_to have_been_enqueued + end + end + + context "when plan is pay in advance with pay-in-advance fixed charges" do + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "enqueues BillSubscriptionJob but not CreatePayInAdvanceFixedChargesJob" do + result + + expect(BillSubscriptionJob).to have_been_enqueued + expect(Invoices::CreatePayInAdvanceFixedChargesJob).not_to have_been_enqueued + end + end + + context "when plan is pay in arrears with pay-in-advance fixed charges" do + let(:plan) { create(:plan, organization:, pay_in_advance: false) } + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "enqueues CreatePayInAdvanceFixedChargesJob" do + result + + expect(Invoices::CreatePayInAdvanceFixedChargesJob).to have_been_enqueued + end + + it "does not enqueue BillSubscriptionJob" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + end + end + + context "when plan is pay in arrears with non-pay-in-advance fixed charges" do + let(:plan) { create(:plan, organization:, pay_in_advance: false) } + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: false) } + + it "does not enqueue any billing job" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + expect(Invoices::CreatePayInAdvanceFixedChargesJob).not_to have_been_enqueued + end + end + + context "when plan is pay in advance with trial period" do + let(:plan) { create(:plan, organization:, pay_in_advance: true, trial_period: 30) } + + it "does not enqueue BillSubscriptionJob" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + end + + context "when plan has pay-in-advance fixed charges" do + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "enqueues CreatePayInAdvanceFixedChargesJob" do + result + + expect(Invoices::CreatePayInAdvanceFixedChargesJob).to have_been_enqueued + end + + it "does not enqueue BillSubscriptionJob" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + end + end + end + end + + context "when subscription is pending with activation rules (payment, pay-in-advance plan)" do + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) do + create(:subscription, :pending, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48}], + organization:, customer:, plan:, subscription_at: Time.current) + end + + it "evaluates rules and marks the subscription as incomplete" do + expect(result.subscription).to be_incomplete + expect(result.subscription.started_at).to be_present + expect(subscription.activation_rules.sole).to be_pending + end + + it "emits fixed charge events" do + add_on = create(:add_on, organization:) + create(:fixed_charge, plan:, add_on:) + + expect { result }.to change(FixedChargeEvent, :count).by(1) + end + + it "sends a subscription.incomplete webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.incomplete", subscription) + end + + it "produces a subscription.incomplete activity log" do + result + + expect(Utils::ActivityLog).to have_produced("subscription.incomplete").with(subscription) + end + + it "does not sync with hubspot" do + result + + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).not_to have_been_enqueued + end + + it "enqueues BillSubscriptionJob with skip_charges" do + result + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription], anything, invoicing_reason: :subscription_starting, skip_charges: true) + end + + context "when plan is pay in arrears with pay-in-advance fixed charges" do + let(:plan) { create(:plan, organization:, pay_in_advance: false) } + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "enqueues CreatePayInAdvanceFixedChargesJob" do + result + + expect(Invoices::CreatePayInAdvanceFixedChargesJob).to have_been_enqueued + end + + it "does not enqueue BillSubscriptionJob" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + end + end + + context "when plan is pay in advance with trial period" do + let(:plan) { create(:plan, organization:, pay_in_advance: true, trial_period: 30) } + + it "does not enqueue BillSubscriptionJob" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + end + + context "when plan has pay-in-advance fixed charges" do + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "enqueues CreatePayInAdvanceFixedChargesJob" do + result + + expect(Invoices::CreatePayInAdvanceFixedChargesJob).to have_been_enqueued + end + end + end + end + + context "when subscription is pending with activation rules that evaluate to not_applicable" do + let(:plan) { create(:plan, organization:, pay_in_advance: false) } + let(:subscription) do + create(:subscription, :pending, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48}], + organization:, customer:, plan:, subscription_at: Time.current) + end + + it "evaluates rules as not_applicable and activates normally" do + expect(result.subscription).to be_active + expect(subscription.activation_rules.sole).to be_not_applicable + end + + it "sends a subscription.started webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", subscription) + end + end + + context "when subscription is incomplete with satisfied payment rule (post-payment activation)" do + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "satisfied"}], + organization:, customer:, plan:) + end + + it "activates the subscription" do + freeze_time do + expect(result.subscription).to be_active + expect(result.subscription.activated_at).to eq(Time.current) + end + end + + it "sends a subscription.started webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", subscription) + end + + it "produces a subscription.started activity log" do + result + + expect(Utils::ActivityLog).to have_produced("subscription.started").with(subscription) + end + + it "does not enqueue billing jobs (already billed during gating)" do + result + + expect(BillSubscriptionJob).not_to have_been_enqueued + expect(Invoices::CreatePayInAdvanceFixedChargesJob).not_to have_been_enqueued + end + + it "does not emit fixed charge events (already emitted during gating)" do + add_on = create(:add_on, organization:) + create(:fixed_charge, plan:, add_on:) + + expect { result }.not_to change(FixedChargeEvent, :count) + end + + context "when subscription should sync with hubspot" do + let(:customer) { create(:customer, :with_hubspot_integration, organization:) } + + it "enqueues hubspot sync job" do + result + + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob) + .to have_been_enqueued.with(subscription:) + end + end + end + + context "when subscription is incomplete with no payment rules (future non-payment rule resolved)" do + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) { create(:subscription, :incomplete, organization:, customer:, plan:) } + + it "activates and bills the subscription" do + result + + expect(result.subscription).to be_active + expect(BillSubscriptionJob).to have_been_enqueued + end + end + + context "when subscription is incomplete with a failed payment rule" do + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: :payment, timeout_hours: 48, status: :failed}], + organization:, customer:, plan:) + end + + it "does not activate the subscription" do + result + + expect(subscription.reload).to be_incomplete + expect(SendWebhookJob).not_to have_been_enqueued + expect(BillSubscriptionJob).not_to have_been_enqueued + end + end + + context "when subscription is already active" do + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + + it "returns early without changes" do + result + + expect(subscription.reload).to be_active + expect(SendWebhookJob).not_to have_been_enqueued + expect(BillSubscriptionJob).not_to have_been_enqueued + expect(Invoices::CreatePayInAdvanceFixedChargesJob).not_to have_been_enqueued + end + end + + context "when subscription is already gated (incomplete with pending rules)" do + let(:subscription) do + create(:subscription, :incomplete, :with_activation_rules, + activation_rules_config: [{type: "payment", timeout_hours: 48, status: "pending"}], + organization:, customer:, plan:) + end + + it "returns early without changes" do + result + + expect(subscription.reload).to be_incomplete + expect(SendWebhookJob).not_to have_been_enqueued + end + end +end diff --git a/spec/services/subscriptions/activation_rules/apply_service_spec.rb b/spec/services/subscriptions/activation_rules/apply_service_spec.rb new file mode 100644 index 0000000..b3202f2 --- /dev/null +++ b/spec/services/subscriptions/activation_rules/apply_service_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::ApplyService do + subject(:apply_service) { described_class.new(subscription:, activation_rules:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, :pending, customer:, plan:, organization:) } + + describe "#call" do + context "when subscription is not pending" do + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + let(:activation_rules) { [{type: "payment", timeout_hours: 48}] } + + it "returns a validation failure" do + result = apply_service.call + + expect(result).not_to be_success + expect(result.error.messages[:activation_rules]).to eq(["subscription_not_pending"]) + end + end + + context "when activation_rules is nil" do + let(:subscription) { create(:subscription, :pending, :with_activation_rules, customer:, plan:, organization:) } + let(:activation_rules) { nil } + + it "does not change existing rules" do + result = apply_service.call + + expect(result).to be_success + expect(subscription.activation_rules.reload.count).to eq(1) + end + end + + context "when activation_rules is an empty array" do + let(:subscription) { create(:subscription, :pending, :with_activation_rules, customer:, plan:, organization:) } + let(:activation_rules) { [] } + + it "removes all existing rules" do + result = apply_service.call + + expect(result).to be_success + expect(result.activation_rules).to be_empty + expect(subscription.activation_rules.reload).to be_empty + end + end + + context "when activation_rules has a payment rule" do + let(:activation_rules) { [{type: "payment", timeout_hours: 48}] } + + it "creates the rule with inactive status" do + result = apply_service.call + + expect(result).to be_success + expect(result.activation_rules.count).to eq(1) + expect(result.activation_rules.first).to have_attributes( + id: String, + subscription_id: subscription.id, + type: "payment", + timeout_hours: 48, + status: "inactive", + organization_id: organization.id + ) + end + end + + context "when activation_rules replaces existing rules" do + let(:subscription) { create(:subscription, :pending, :with_activation_rules, customer:, plan:, organization:, activation_rules_config: [{type: "payment", timeout_hours: 48}]) } + let(:activation_rules) { [{type: "payment", timeout_hours: 24}] } + + it "deletes old rules and creates new ones" do + old_activation_rules = subscription.activation_rules.to_a + + result = apply_service.call + + expect(result).to be_success + expect(old_activation_rules).to all(be_destroyed) + expect(result.activation_rules.count).to eq(1) + expect(result.activation_rules.first.timeout_hours).to eq(24) + end + end + + context "when timeout_hours is not provided" do + let(:activation_rules) { [{type: "payment"}] } + + it "defaults timeout_hours to 0" do + result = apply_service.call + + expect(result).to be_success + expect(result.activation_rules.first.timeout_hours).to eq(0) + end + end + end +end diff --git a/spec/services/subscriptions/activation_rules/evaluate_service_spec.rb b/spec/services/subscriptions/activation_rules/evaluate_service_spec.rb new file mode 100644 index 0000000..31962d8 --- /dev/null +++ b/spec/services/subscriptions/activation_rules/evaluate_service_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::EvaluateService do + subject(:result) { described_class.call(subscription:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) { create(:subscription, :incomplete, organization:, customer:, plan:) } + + context "when subscription has an inactive payment activation rule" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:) } + + before { rule } + + it "evaluates the rule to pending" do + expect(result).to be_success + expect(result.rules.first).to be_pending + end + end + + context "when subscription has no activation rules" do + it "returns success with empty rules" do + expect(result).to be_success + expect(result.rules).to be_empty + end + end + + context "when subscription has a payment rule that is not applicable" do + let(:plan) { create(:plan, organization:, pay_in_advance: false) } + let(:rule) { create(:payment_subscription_activation_rule, subscription:) } + + before { rule } + + it "evaluates the rule to not_applicable" do + expect(result).to be_success + expect(result.rules.first).to be_not_applicable + end + end + + context "when rules are already in a terminal state" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "satisfied") } + + before { rule } + + it "does not change rule status" do + expect(result).to be_success + expect(result.rules.first).to be_satisfied + end + + it "does not change subscription status" do + expect(result.subscription).to be_incomplete + end + end +end diff --git a/spec/services/subscriptions/activation_rules/payment/evaluate_service_spec.rb b/spec/services/subscriptions/activation_rules/payment/evaluate_service_spec.rb new file mode 100644 index 0000000..cb89662 --- /dev/null +++ b/spec/services/subscriptions/activation_rules/payment/evaluate_service_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::Payment::EvaluateService do + subject(:result) { described_class.call(rule:, status:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) { create(:subscription, :incomplete, organization:, customer:, plan:) } + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: rule_status, timeout_hours:) } + let(:rule_status) { "inactive" } + let(:timeout_hours) { 48 } + let(:status) { nil } + + context "when rule is inactive" do + let(:rule_status) { "inactive" } + + context "when rule is applicable (pay-in-advance plan, no trial)" do + it "transitions rule to pending" do + expect(result).to be_success + expect(result.rule).to be_pending + end + + it "sets expires_at based on timeout_hours" do + freeze_time do + expect(result.rule.expires_at).to eq(Time.current + 48.hours) + end + end + + context "when timeout_hours is zero" do + let(:timeout_hours) { 0 } + + it "transitions rule to pending without expires_at" do + expect(result.rule).to be_pending + expect(result.rule.expires_at).to be_nil + end + end + end + + context "when rule is applicable (pay-in-arrears plan with pay-in-advance fixed charges)" do + let(:plan) { create(:plan, organization:, pay_in_advance: false) } + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "transitions rule to pending" do + expect(result.rule).to be_pending + end + end + + context "when rule is not applicable (pay-in-arrears plan, no fixed charges)" do + let(:plan) { create(:plan, organization:, pay_in_advance: false) } + + it "transitions rule to not_applicable" do + expect(result).to be_success + expect(result.rule).to be_not_applicable + end + end + + context "when rule is not applicable (pay-in-advance plan with trial period)" do + let(:plan) { create(:plan, organization:, pay_in_advance: true, trial_period: 30) } + + it "transitions rule to not_applicable" do + expect(result).to be_success + expect(result.rule).to be_not_applicable + end + end + + context "when plan has trial but has pay-in-advance fixed charges" do + let(:plan) { create(:plan, organization:, pay_in_advance: true, trial_period: 30) } + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "transitions rule to pending" do + expect(result).to be_success + expect(result.rule).to be_pending + end + end + end + + context "when rule is pending" do + let(:rule_status) { "pending" } + + context "when status is satisfied" do + let(:status) { :satisfied } + + it "transitions rule to satisfied" do + expect(result).to be_success + expect(result.rule).to be_satisfied + end + end + + context "when status is failed" do + let(:status) { :failed } + + it "transitions rule to failed" do + expect(result).to be_success + expect(result.rule).to be_failed + end + end + + context "when status is expired" do + let(:status) { :expired } + + it "transitions rule to expired" do + expect(result).to be_success + expect(result.rule).to be_expired + end + end + end +end diff --git a/spec/services/subscriptions/activation_rules/payment/resolve_service_spec.rb b/spec/services/subscriptions/activation_rules/payment/resolve_service_spec.rb new file mode 100644 index 0000000..6310d7a --- /dev/null +++ b/spec/services/subscriptions/activation_rules/payment/resolve_service_spec.rb @@ -0,0 +1,214 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::Payment::ResolveService do + subject(:result) { described_class.call(subscription:, invoice:, payment_status:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) { create(:subscription, :incomplete, organization:, customer:, plan:) } + let(:rule) { create(:subscription_activation_rule, subscription:, status: "pending") } + let(:invoice) do + create(:invoice, organization:, customer:, status: :open, invoice_type: :subscription, + total_amount_cents: 100, fees_amount_cents: 100) + end + let(:payment_status) { :failed } + + before do + rule + create(:invoice_subscription, invoice:, subscription:) + end + + context "when subscription is not incomplete" do + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + + it "returns early without changes" do + result + + expect(rule.reload.status).to eq("pending") + expect(invoice.reload.status).to eq("open") + end + end + + context "when invoice is not open" do + before { invoice.update!(status: :finalized) } + + it "returns early without changes" do + result + + expect(rule.reload.status).to eq("pending") + expect(subscription.reload).to be_incomplete + end + end + + context "when invoice is not a subscription invoice" do + let(:invoice) do + create(:invoice, :credit, organization:, customer:, status: :open, + total_amount_cents: 100, fees_amount_cents: 100) + end + + it "returns early without changes" do + result + + expect(rule.reload.status).to eq("pending") + expect(subscription.reload).to be_incomplete + end + end + + context "when payment_status is succeeded" do + let(:payment_status) { :succeeded } + + it "marks the activation rule as satisfied" do + result + + expect(rule.reload.status).to eq("satisfied") + end + + it "finalizes the invoice" do + result + + expect(invoice.reload.status).to eq("finalized") + end + + it "activates the subscription" do + freeze_time do + result + + expect(subscription.reload).to be_active + expect(subscription.activated_at).to eq(Time.current) + end + end + + it "sends a subscription.started webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", subscription) + end + + it "sends an invoice.created webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("invoice.created", invoice) + end + + it "produces an invoice.created activity log" do + result + + expect(Utils::ActivityLog).to have_produced("invoice.created").with(invoice) + end + + it "enqueues GenerateDocumentsJob with notify false" do + result + + expect(Invoices::GenerateDocumentsJob).to have_been_enqueued.with(invoice:, notify: false) + end + + context "with lago_premium", :premium do + it "enqueues GenerateDocumentsJob with notify true" do + result + + expect(Invoices::GenerateDocumentsJob).to have_been_enqueued.with(invoice:, notify: true) + end + + context "when billing entity does not have invoice.finalized email setting" do + before { invoice.billing_entity.update!(email_settings: []) } + + it "enqueues GenerateDocumentsJob with notify false" do + result + + expect(Invoices::GenerateDocumentsJob).to have_been_enqueued.with(invoice:, notify: false) + end + end + end + + it "tracks invoice creation in segment" do + allow(Utils::SegmentTrack).to receive(:invoice_created) + + result + + expect(Utils::SegmentTrack).to have_received(:invoice_created).with(invoice) + end + + context "when invoice should be synced to accounting integration" do + before { allow(invoice).to receive(:should_sync_invoice?).and_return(true) } + + it "enqueues Aggregator::Invoices::CreateJob" do + result + + expect(Integrations::Aggregator::Invoices::CreateJob).to have_been_enqueued.with(invoice:) + end + end + + context "when invoice should not be synced to accounting integration" do + before { allow(invoice).to receive(:should_sync_invoice?).and_return(false) } + + it "does not enqueue Aggregator::Invoices::CreateJob" do + result + + expect(Integrations::Aggregator::Invoices::CreateJob).not_to have_been_enqueued + end + end + + context "when invoice should be synced to hubspot" do + before { allow(invoice).to receive(:should_sync_hubspot_invoice?).and_return(true) } + + it "enqueues Aggregator::Invoices::Hubspot::CreateJob" do + result + + expect(Integrations::Aggregator::Invoices::Hubspot::CreateJob).to have_been_enqueued.with(invoice:) + end + end + + context "when invoice should not be synced to hubspot" do + before { allow(invoice).to receive(:should_sync_hubspot_invoice?).and_return(false) } + + it "does not enqueue Aggregator::Invoices::Hubspot::CreateJob" do + result + + expect(Integrations::Aggregator::Invoices::Hubspot::CreateJob).not_to have_been_enqueued + end + end + + context "when subscription is already active (idempotency)" do + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + + it "returns early without changes" do + result + + expect(rule.reload.status).to eq("pending") + expect(invoice.reload.status).to eq("open") + end + end + end + + context "when payment_status is failed" do + let(:payment_status) { :failed } + + it "marks the activation rule as failed" do + result + + expect(rule.reload.status).to eq("failed") + end + + it "closes the invoice" do + result + + expect(invoice.reload.status).to eq("closed") + end + + it "cancels the subscription with payment_failed reason" do + result + + expect(subscription.reload).to be_canceled + expect(subscription.cancelation_reason).to eq("payment_failed") + end + + it "sends a subscription.canceled webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.canceled", subscription) + end + end +end diff --git a/spec/services/subscriptions/activation_rules/payment/validate_service_spec.rb b/spec/services/subscriptions/activation_rules/payment/validate_service_spec.rb new file mode 100644 index 0000000..94b18dc --- /dev/null +++ b/spec/services/subscriptions/activation_rules/payment/validate_service_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::Payment::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + let(:rule) { {type: "payment", timeout_hours: 48} } + let(:payment_method_params) { nil } + + let(:args) do + { + rule:, + payment_method: payment_method_params, + subscription:, + customer: + } + end + + describe "#valid?" do + context "with valid payment rule" do + it { is_expected.to be_valid } + end + + context "when timeout_hours is absent" do + let(:rule) { {type: "payment"} } + + it { is_expected.to be_valid } + end + + context "when timeout_hours is zero" do + let(:rule) { {type: "payment", timeout_hours: 0} } + + it { is_expected.to be_valid } + end + + context "when timeout_hours is negative" do + let(:rule) { {type: "payment", timeout_hours: -1} } + + it "is invalid with value_must_be_positive_or_zero error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:timeout_hours]).to eq(["value_must_be_positive_or_zero"]) + end + end + + context "when timeout_hours is not an integer" do + let(:rule) { {type: "payment", timeout_hours: "abc"} } + + it "is invalid with value_must_be_positive_or_zero error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:timeout_hours]).to eq(["value_must_be_positive_or_zero"]) + end + end + + context "when payment_method params are present" do + context "when payment_method_type is manual" do + let(:payment_method_params) { {payment_method_type: "manual"} } + + it "is invalid with invalid_for_payment_activation_rules error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_for_payment_activation_rules"]) + end + end + + context "when payment_method_type is provider" do + let(:payment_method_params) { {payment_method_type: "provider"} } + + it { is_expected.to be_valid } + end + end + + context "when payment_method params are absent" do + context "when subscription exists" do + context "when subscription payment_method_type is manual" do + let(:subscription) { create(:subscription, customer:, plan:, organization:, payment_method_type: "manual") } + + it "is invalid with invalid_for_payment_activation_rules error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_for_payment_activation_rules"]) + end + end + + context "when subscription payment_method_type is provider" do + let(:subscription) { create(:subscription, customer:, plan:, organization:, payment_method_type: "provider") } + + it { is_expected.to be_valid } + end + end + + context "when subscription is nil" do + let(:subscription) { nil } + + context "when customer has a payment provider" do + let(:customer) { create(:customer, organization:, payment_provider: "stripe") } + + it { is_expected.to be_valid } + end + + context "when customer has no payment provider" do + let(:customer) { create(:customer, organization:, payment_provider: nil) } + + it "is invalid with no_linked_payment_provider error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["no_linked_payment_provider"]) + end + end + end + end + end +end diff --git a/spec/services/subscriptions/activation_rules/resolve_subscription_status_service_spec.rb b/spec/services/subscriptions/activation_rules/resolve_subscription_status_service_spec.rb new file mode 100644 index 0000000..7127a92 --- /dev/null +++ b/spec/services/subscriptions/activation_rules/resolve_subscription_status_service_spec.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::ResolveSubscriptionStatusService do + subject(:result) { described_class.call(subscription:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, pay_in_advance: true) } + let(:subscription) { create(:subscription, :incomplete, organization:, customer:, plan:) } + + context "when all rules are satisfied" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "satisfied") } + + before { rule } + + it "activates the subscription" do + freeze_time do + expect(result.subscription).to be_active + expect(result.subscription.activated_at).to eq(Time.current) + end + end + + it "sends a subscription.started webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", subscription) + end + + it "produces a subscription.started activity log" do + result + + expect(Utils::ActivityLog).to have_produced("subscription.started").with(subscription) + end + end + + context "when all rules are not_applicable" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "not_applicable") } + + before { rule } + + it "activates the subscription" do + expect(result.subscription).to be_active + end + end + + context "when a rule has failed" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "failed") } + + before { rule } + + it "cancels the subscription" do + expect(result.subscription).to be_canceled + end + + it "sends a subscription.canceled webhook" do + result + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.canceled", subscription) + end + + it "produces a subscription.canceled activity log" do + result + + expect(Utils::ActivityLog).to have_produced("subscription.canceled").after_commit.with(subscription) + end + end + + context "when a rule has expired" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "expired") } + + before { rule } + + it "cancels the subscription" do + expect(result.subscription).to be_canceled + end + end + + context "when a rule has been declined" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "declined") } + + before { rule } + + it "cancels the subscription" do + expect(result.subscription).to be_canceled + end + end + + context "when rules are still pending" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "pending") } + + before { rule } + + it "does not change subscription status" do + expect(result.subscription).to be_incomplete + end + end + + context "when a rule is still inactive" do + let(:rule) { create(:payment_subscription_activation_rule, subscription:, status: "inactive") } + + before { rule } + + it "does not change subscription status" do + expect(result.subscription).to be_incomplete + end + end + + context "when subscription is not incomplete" do + let(:subscription) { create(:subscription, organization:, customer:, plan:) } + + it "returns the subscription without changes" do + expect(result.subscription).to be_active + end + end + + context "when there are no activation rules" do + it "activates the subscription" do + expect(result.subscription).to be_active + end + end +end diff --git a/spec/services/subscriptions/activation_rules/validate_service_spec.rb b/spec/services/subscriptions/activation_rules/validate_service_spec.rb new file mode 100644 index 0000000..c27d4db --- /dev/null +++ b/spec/services/subscriptions/activation_rules/validate_service_spec.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ActivationRules::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, payment_provider: "stripe") } + let(:plan) { create(:plan, organization:) } + let(:subscription) { nil } + let(:subscription_type) { "create" } + let(:activation_rules) { nil } + let(:payment_method_params) { nil } + + let(:args) do + { + activation_rules:, + subscription:, + subscription_type:, + payment_method: payment_method_params, + customer: + } + end + + describe "#valid?" do + context "when activation_rules is an empty array" do + let(:activation_rules) { [] } + + it { is_expected.to be_valid } + end + + context "when activation_rules is a string" do + let(:activation_rules) { "invalid" } + + it "is invalid with invalid_format error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:activation_rules]).to eq(["invalid_format"]) + end + end + + context "when activation_rules is a hash" do + let(:activation_rules) { {type: "payment"} } + + it "is invalid with invalid_format error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:activation_rules]).to eq(["invalid_format"]) + end + end + + context "when subscription_type is update" do + let(:subscription_type) { "update" } + + context "when subscription is pending" do + let(:subscription) { create(:subscription, :pending, customer:, plan:, organization:) } + let(:activation_rules) { [{type: "payment"}] } + + it { is_expected.to be_valid } + end + + context "when subscription is active" do + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + let(:activation_rules) { [{type: "payment"}] } + + it "is invalid with subscription_not_pending error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:activation_rules]).to eq(["subscription_not_pending"]) + end + end + end + + context "when subscription is nil" do + let(:activation_rules) { [{type: "payment", timeout_hours: 24}] } + + it { is_expected.to be_valid } + end + + context "with valid payment rule" do + let(:activation_rules) { [{type: "payment", timeout_hours: 48}] } + + it { is_expected.to be_valid } + end + + context "with unknown rule type" do + let(:activation_rules) { [{type: "unknown"}] } + + it "is invalid with invalid_type error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:activation_rules]).to eq(["invalid_type"]) + end + end + + context "with duplicate rule types" do + let(:activation_rules) { [{type: "payment", timeout_hours: 24}, {type: "payment", timeout_hours: 48}] } + + it "is invalid with duplicated_type error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:activation_rules]).to eq(["duplicated_type"]) + end + end + + context "with multiple rules including unknown type" do + let(:activation_rules) { [{type: "payment", timeout_hours: 48}, {type: "unknown"}] } + + it "is invalid with invalid_type error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:activation_rules]).to eq(["invalid_type"]) + end + end + end +end diff --git a/spec/services/subscriptions/charge_cache_service_spec.rb b/spec/services/subscriptions/charge_cache_service_spec.rb new file mode 100644 index 0000000..899e22f --- /dev/null +++ b/spec/services/subscriptions/charge_cache_service_spec.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ChargeCacheService do + subject(:cache_service) { described_class.new(subscription:, charge:, charge_filter:) } + + let(:subscription) { create(:subscription) } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:charge_filter) { nil } + + describe "#cache_key" do + it "returns the cache key" do + expect(cache_service.cache_key) + .to eq("charge-usage/#{described_class::CACHE_KEY_VERSION}/#{charge.id}/#{subscription.id}/#{charge.updated_at.iso8601}") + end + + context "with a charge filter" do + let(:charge_filter) { create(:charge_filter) } + + it "returns the cache key with the charge filter" do + expect(cache_service.cache_key) + .to eq("charge-usage/#{described_class::CACHE_KEY_VERSION}/#{charge.id}/#{subscription.id}/#{charge.updated_at.iso8601}/#{charge_filter.id}/#{charge_filter.updated_at.iso8601}") + end + end + end + + describe "#expire_cache" do + it "deletes the cached value" do + allow(Rails.cache).to receive(:delete).with(cache_service.cache_key) + + cache_service.expire_cache + + expect(Rails.cache).to have_received(:delete).with(cache_service.cache_key) + end + end +end diff --git a/spec/services/subscriptions/charge_filters/create_service_spec.rb b/spec/services/subscriptions/charge_filters/create_service_spec.rb new file mode 100644 index 0000000..c762f61 --- /dev/null +++ b/spec/services/subscriptions/charge_filters/create_service_spec.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ChargeFilters::CreateService do + subject(:service) { described_class.new(subscription:, charge:, params:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:params) do + { + invoice_display_name: "New Filter", + properties: {amount: "100"}, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + end + + describe "#call" do + context "without premium license" do + it "returns forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "with premium license", :premium do + before do + charge + subscription + end + + it "creates a plan override" do + expect { service.call }.to change(Plan, :count).by(1) + + new_plan = subscription.reload.plan + expect(new_plan.parent_id).to eq(plan.id) + end + + it "creates a charge override" do + expect { service.call }.to change(Charge, :count).by(1) + end + + it "creates a charge filter on the charge override" do + expect { service.call }.to change(ChargeFilter, :count).by(1) + end + + it "returns the charge filter" do + result = service.call + + expect(result).to be_success + expect(result.charge_filter).to be_a(ChargeFilter) + expect(result.charge_filter.invoice_display_name).to eq("New Filter") + expect(result.charge_filter.properties).to eq({"amount" => "100"}) + end + + it "associates the filter with the charge override" do + result = service.call + + charge_override = subscription.reload.plan.charges.first + expect(result.charge_filter.charge_id).to eq(charge_override.id) + end + + context "when subscription already has a plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + + it "does not create a new plan" do + expect { service.call }.not_to change(Plan, :count) + end + + it "creates the charge override on the existing overridden plan" do + result = service.call + + expect(result.charge_filter.charge.plan_id).to eq(overridden_plan.id) + end + end + + context "when charge override already exists" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let!(:existing_charge_override) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + it "does not create a new charge" do + expect { service.call }.not_to change(Charge, :count) + end + + it "creates filter on the existing charge override" do + result = service.call + + expect(result.charge_filter.charge_id).to eq(existing_charge_override.id) + end + + context "when charge override already has a filter with the same values" do + before do + filter = create(:charge_filter, charge: existing_charge_override, organization:) + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + + it "returns validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({values: ["value_already_exists"]}) + end + end + end + + context "when parent charge has the same filter values" do + before do + filter = create(:charge_filter, charge:, organization:) + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + + it "returns validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({values: ["value_already_exists"]}) + end + end + + context "when values are missing" do + let(:params) do + { + invoice_display_name: "New Filter", + properties: {amount: "100"}, + values: {} + } + end + + it "returns validation failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({values: ["value_is_mandatory"]}) + end + end + + context "when subscription does not exist" do + let(:subscription) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("subscription") + end + end + + context "when charge does not exist" do + let(:charge) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("charge") + end + end + end + end +end diff --git a/spec/services/subscriptions/charge_filters/destroy_service_spec.rb b/spec/services/subscriptions/charge_filters/destroy_service_spec.rb new file mode 100644 index 0000000..fabbc98 --- /dev/null +++ b/spec/services/subscriptions/charge_filters/destroy_service_spec.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ChargeFilters::DestroyService do + subject(:service) { described_class.new(subscription:, charge:, charge_filter:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:charge_filter) do + create(:charge_filter, charge:, organization:, properties: {amount: "50"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + + describe "#call" do + context "without premium license" do + it "returns forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "with premium license", :premium do + before do + charge_filter + subscription + end + + it "creates a plan override" do + expect { service.call }.to change(Plan, :count).by(1) + + new_plan = subscription.reload.plan + expect(new_plan.parent_id).to eq(plan.id) + end + + it "creates a charge override" do + expect { service.call }.to change(Charge, :count).by(1) + end + + it "creates and then destroys the charge filter on the override" do + result = service.call + + expect(result).to be_success + expect(result.charge_filter).to be_discarded + end + + context "when subscription already has a plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + + it "does not create a new plan" do + expect { service.call }.not_to change(Plan, :count) + end + end + + context "when charge override already exists with the filter" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let!(:existing_charge_override) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + let!(:existing_filter_override) do + create(:charge_filter, charge: existing_charge_override, organization:, properties: {amount: "75"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + + it "does not create a new charge" do + expect { service.call }.not_to change(Charge, :count) + end + + it "does not create a new filter" do + expect { service.call }.not_to change(ChargeFilter.unscoped, :count) + end + + it "soft deletes the existing filter override" do + result = service.call + + expect(result.charge_filter.id).to eq(existing_filter_override.id) + expect(result.charge_filter).to be_discarded + end + end + + context "when filter does not exist on the charge override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + + before { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("charge_filter") + end + end + + context "when subscription does not exist" do + let(:subscription) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("subscription") + end + end + + context "when charge does not exist" do + let(:charge) { nil } + let(:charge_filter) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("charge") + end + end + + context "when charge filter does not exist" do + let(:charge_filter) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("charge_filter") + end + end + end + end +end diff --git a/spec/services/subscriptions/charge_filters/update_or_override_service_spec.rb b/spec/services/subscriptions/charge_filters/update_or_override_service_spec.rb new file mode 100644 index 0000000..7a920b9 --- /dev/null +++ b/spec/services/subscriptions/charge_filters/update_or_override_service_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ChargeFilters::UpdateOrOverrideService do + subject(:service) { described_class.new(subscription:, charge:, charge_filter:, params:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:charge_filter) do + create(:charge_filter, charge:, organization:, properties: {amount: "50"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + let(:params) do + { + invoice_display_name: "Overridden Filter", + properties: {amount: "150"} + } + end + + describe "#call" do + context "without premium license" do + it "returns forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "with premium license", :premium do + before do + charge_filter + subscription + end + + it "creates a plan override" do + expect { service.call }.to change(Plan, :count).by(1) + + new_plan = subscription.reload.plan + expect(new_plan.parent_id).to eq(plan.id) + end + + it "creates a charge override" do + expect { service.call }.to change(Charge, :count).by(1) + end + + it "creates a charge filter override" do + expect { service.call }.to change(ChargeFilter, :count).by(1) + end + + it "returns the charge filter with overridden properties" do + result = service.call + + expect(result).to be_success + expect(result.charge_filter).to be_a(ChargeFilter) + expect(result.charge_filter.invoice_display_name).to eq("Overridden Filter") + expect(result.charge_filter.properties).to eq({"amount" => "150"}) + end + + it "preserves the filter values" do + result = service.call + + expect(result.charge_filter.to_h).to eq(charge_filter.to_h) + end + + context "when subscription already has a plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + + it "does not create a new plan" do + expect { service.call }.not_to change(Plan, :count) + end + end + + context "when charge override already exists" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let!(:existing_charge_override) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + it "does not create a new charge" do + expect { service.call }.not_to change(Charge, :count) + end + + it "creates filter on the existing charge override" do + result = service.call + + expect(result.charge_filter.charge_id).to eq(existing_charge_override.id) + end + end + + context "when charge filter override already exists" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let!(:existing_charge_override) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + let!(:existing_filter_override) do + create(:charge_filter, charge: existing_charge_override, organization:, properties: {amount: "75"}).tap do |filter| + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: [billable_metric_filter.values.first], organization:) + end + end + + it "does not create a new charge filter" do + expect { service.call }.not_to change(ChargeFilter, :count) + end + + it "updates the existing filter override" do + result = service.call + + expect(result.charge_filter.id).to eq(existing_filter_override.id) + expect(result.charge_filter.invoice_display_name).to eq("Overridden Filter") + expect(result.charge_filter.properties).to eq({"amount" => "150"}) + end + end + + context "when only updating invoice_display_name" do + let(:params) { {invoice_display_name: "Display Name Only"} } + + it "updates only the invoice_display_name" do + result = service.call + + expect(result.charge_filter.invoice_display_name).to eq("Display Name Only") + end + end + + context "when subscription does not exist" do + let(:subscription) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("subscription") + end + end + + context "when charge does not exist" do + let(:charge) { nil } + let(:charge_filter) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("charge") + end + end + + context "when charge filter does not exist" do + let(:charge_filter) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("charge_filter") + end + end + end + end +end diff --git a/spec/services/subscriptions/consume_subscription_refreshed_queue_service_spec.rb b/spec/services/subscriptions/consume_subscription_refreshed_queue_service_spec.rb new file mode 100644 index 0000000..182ff37 --- /dev/null +++ b/spec/services/subscriptions/consume_subscription_refreshed_queue_service_spec.rb @@ -0,0 +1,140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ConsumeSubscriptionRefreshedQueueService do + subject(:service) { described_class.new } + + let(:redis_client) { instance_double(Redis) } + let(:redis_url) { "localhost:6379" } + + let(:bucket) do + ( + (Time.current.to_i - 20) / described_class::SUBSCRIPTION_BUCKET_DURATION + ) * described_class::SUBSCRIPTION_BUCKET_DURATION + end + let(:values) do + [ + "#{SecureRandom.uuid}:#{subscription_id1}|#{bucket}", + "#{SecureRandom.uuid}:#{subscription_id2}|#{bucket}" + ] + end + let(:loop_values) { [values, []] } + + let(:subscription_id1) { SecureRandom.uuid } + let(:subscription_id2) { SecureRandom.uuid } + + before do + allow(Redis).to receive(:new).and_return(redis_client) + allow(redis_client).to receive(:zrangebyscore).and_return(*loop_values) + allow(redis_client).to receive(:zrem) + + allow(ENV).to receive(:[]).and_call_original + allow(ENV).to receive(:[]).with("LAGO_REDIS_STORE_URL").and_return(redis_url) + end + + describe "#call" do + it "flags all subscriptions as refreshed" do + result = service.call + + expect(result).to be_success + expect(Subscriptions::FlagRefreshedJob).to have_been_enqueued.with(subscription_id1) + expect(Subscriptions::FlagRefreshedJob).to have_been_enqueued.with(subscription_id2) + end + + it "queries with the correct threshold" do + freeze_time do + threshold = (Time.current - described_class::SUBSCRIPTION_BUCKET_DURATION).to_i + + service.call + + expect(redis_client).to have_received(:zrangebyscore) + .with(described_class::REDIS_STORE_NAME, "-inf", threshold, limit: [0, described_class::BATCH_SIZE]) + .at_least(:once) + end + end + + it "removes processed values with zrem" do + service.call + + expect(redis_client).to have_received(:zrem).with(described_class::REDIS_STORE_NAME, values) + end + + context "with multiple batches" do + let(:loop_values) { [values, values, []] } + + it "flags all subscriptions as refreshed" do + result = service.call + + expect(result).to be_success + expect(Subscriptions::FlagRefreshedJob).to have_been_enqueued.exactly(4).times + end + end + + context "with no subscriptions" do + let(:loop_values) { [[]] } + + it "does not flag any subscriptions as refreshed" do + result = service.call + + expect(result).to be_success + expect(Subscriptions::FlagRefreshedJob).not_to have_been_enqueued + end + end + + context "when timeout is reached" do + let(:start_time) { Time.current } + + before do + allow(Time).to receive(:current).and_return( + start_time, + start_time + described_class::PROCESSING_TIMEOUT + 1.second + ) + end + + it "stops processing" do + result = service.call + + expect(result).to be_success + expect(Subscriptions::FlagRefreshedJob).not_to have_been_enqueued + end + end + + context "when the redis env var is not present" do + let(:redis_url) { nil } + + it "does not flag any subscriptions as refreshed" do + result = service.call + + expect(result).to be_success + expect(Subscriptions::FlagRefreshedJob).not_to have_been_enqueued + end + end + + context "when redis env vars contains redis:// prefix" do + let(:redis_url) { "redis://localhost:6379" } + + it "flags all subscriptions as refreshed" do + result = service.call + + expect(result).to be_success + expect(Subscriptions::FlagRefreshedJob).to have_been_enqueued.twice + + expect(Redis).to have_received(:new).with(hash_including(url: redis_url)) + end + end + + context "when redis env vars contains rediss:// prefix" do + let(:redis_url) { "rediss://localhost:6379" } + + it "flags all subscriptions as refreshed" do + result = service.call + + expect(result).to be_success + expect(Subscriptions::FlagRefreshedJob).to have_been_enqueued.twice + + expect(Redis).to have_received(:new).with(hash_including(url: redis_url)) + end + end + end +end diff --git a/spec/services/subscriptions/create_service_spec.rb b/spec/services/subscriptions/create_service_spec.rb new file mode 100644 index 0000000..bc31e4d --- /dev/null +++ b/spec/services/subscriptions/create_service_spec.rb @@ -0,0 +1,1711 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::CreateService do + subject(:create_service) { described_class.new(customer:, plan:, params:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, amount_cents: 100, organization:, amount_currency: "EUR") } + let(:customer) { create(:customer, organization:, currency: "EUR") } + + let(:external_id) { SecureRandom.uuid } + let(:billing_time) { "anniversary" } + let(:subscription_at) { nil } + let(:external_customer_id) { customer.external_id } + let(:plan_code) { plan.code } + let(:subscription_id) { nil } + let(:name) { "invoice display name" } + + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id: + } + end + + describe "#call" do + it "creates a subscription with subscription date set to current date" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription.customer_id).to eq(customer.id) + expect(subscription.plan_id).to eq(plan.id) + expect(subscription.started_at).to be_present + expect(subscription.subscription_at).to be_present + expect(subscription.name).to eq("invoice display name") + expect(subscription).to be_active + expect(subscription.external_id).to eq(external_id) + expect(subscription).to be_anniversary + expect(subscription.lifetime_usage).to be_present + expect(subscription.lifetime_usage.recalculate_invoiced_usage).to eq(true) + expect(subscription.lifetime_usage.recalculate_current_usage).to eq(false) + expect(subscription.payment_method_id).to eq(nil) + expect(subscription.payment_method_type).to eq("provider") + end + + context "when payment method is attached" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + payment_method: { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + } + end + + it "creates a subscription" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription.customer_id).to eq(customer.id) + expect(subscription.plan_id).to eq(plan.id) + expect(subscription).to be_active + expect(subscription.external_id).to eq(external_id) + expect(subscription.payment_method_id).to eq(payment_method.id) + expect(subscription.payment_method_type).to eq("provider") + end + end + + context "when plan has fixed charges" do + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + end + + it "creates fixed charge events for the subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription).to be_active + expect(result.subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)) + .to match_array( + [ + [fixed_charge_1.id, be_within(5.seconds).of(Time.current)], + [fixed_charge_2.id, be_within(5.seconds).of(Time.current)] + ] + ) + end + + context "when all fixed_charges and plan are pay in arrears and subscription is active" do + it "does not enqueue a job to bill the subscription" do + expect { create_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + end + + context "when one of the fixed_charges is pay in advance and subscription is active" do + let(:fixed_charge_1) { create(:fixed_charge, plan:, pay_in_advance: true) } + + it "enqueues a job to bill the subscription" do + expect { create_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when subscription should sync with Hubspot" do + let(:customer) { create(:customer, :with_hubspot_integration, organization:, currency: "EUR") } + + before do + allow(Integrations::Aggregator::Subscriptions::Hubspot::CreateJob).to receive(:perform_later) + end + + it "enqueues the Hubspot create job for a new subscription" do + create_service.call + expect(Integrations::Aggregator::Subscriptions::Hubspot::CreateJob).to have_received(:perform_later) + end + + it "does not enqueue Hubspot::UpdateJob (CreateJob captures the active state)" do + create_service.call + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).not_to have_been_enqueued + end + end + + it "produces an activity log" do + subscription = create_service.call.subscription + + expect(Utils::ActivityLog).to have_produced("subscription.started").with(subscription) + end + + context "when ending_at is passed" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + ending_at: Time.current.beginning_of_day + 3.months + } + end + + it "creates a subscription with ending_at correctly set" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription.ending_at).to eq(Time.current.beginning_of_day + 3.months) + end + end + + context "when progressive_billing_disabled is passed" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + progressive_billing_disabled: true + } + end + + it "creates a subscription with progressive_billing_disabled set to true" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.progressive_billing_disabled).to be(true) + end + end + + context "when customer is invalid in an api context" do + let(:customer) do + build(:customer, organization:, currency: "EUR", external_id: nil) + end + + let(:params) do + { + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id: + } + end + + before { CurrentContext.source = "api" } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:external_customer_id]).to eq(["value_is_mandatory"]) + end + end + + context "when external_id is not given in an api context" do + let(:external_id) { nil } + + before { CurrentContext.source = "api" } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:external_id]).to eq(["value_is_mandatory"]) + end + end + + context "when billing_time is not provided" do + let(:billing_time) { nil } + + it "creates a calendar subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription).to be_calendar + end + + context "when billing time is empty" do + let(:billing_time) { "" } + + it "creates a calendar subscription" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:billing_time]).to eq(["value_is_mandatory"]) + end + end + end + + context "when both usage_thresholds and plan_overrides.usage_thresholds are present" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + usage_thresholds: [{threshold_display_name: "Threshold 1"}], + plan_overrides: { + usage_thresholds: [{threshold_display_name: "Override Threshold"}] + } + } + end + + it "returns a validation error", :premium do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:"plan_overrides.usage_thresholds"]).to eq(["incompatible_params"]) + expect(result.error.messages[:usage_thresholds]).to eq(["incompatible_params"]) + end + end + + context "with valid usage_thresholds", :premium do + let(:usage_thresholds) { [{threshold_display_name: "Threshold 1"}] } + let(:base_params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id: + } + end + + context "when usage_thresholds is part of subscription params" do + let(:params) do + base_params.merge({ + usage_thresholds: + }) + end + + it "returns a validation error" do + allow(Subscriptions::UpdateUsageThresholdsService).to receive(:call).and_return(BaseResult.new) + result = create_service.call + + expect(result).to be_success + expect(Subscriptions::UpdateUsageThresholdsService).to have_received(:call).with( + subscription: result.subscription, usage_thresholds_params: usage_thresholds, partial: false + ) + end + end + + context "when usage_thresholds is part of plan_overrides params" do + let(:params) do + base_params.merge({ + plan_overrides: { + usage_thresholds: + } + }) + end + + it "returns a validation error" do + allow(Subscriptions::UpdateUsageThresholdsService).to receive(:call).and_return(BaseResult.new) + result = create_service.call + + expect(result).to be_success + expect(Subscriptions::UpdateUsageThresholdsService).not_to have_received(:call) + end + end + end + + context "when License is free and plan_overrides is passed" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + plan_overrides: { + amount_cents: 0 + } + } + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("feature_unavailable") + end + end + + context "when License is premium and plan_overrides is passed", :premium do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + started_at:, + plan_overrides: { + fixed_charges: [ + { + id: fixed_charge2.id, + units: 100 + } + ] + } + } + end + + let(:fixed_charge1) { create(:fixed_charge, plan:, units: 5) } + let(:fixed_charge2) { create(:fixed_charge, plan:, units: 9) } + let(:add_on) { create(:add_on, organization:) } + let(:started_at) { Time.current } + + before do + fixed_charge1 + fixed_charge2 + end + + it "creates the subscription with overridden plan" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription).to be_active + expect(result.subscription.plan.parent_id).to eq(plan.id) + + # Both fixed charges should be overridden in the new plan + overridden_plan = result.subscription.plan + expect(overridden_plan.fixed_charges.count).to eq(2) + + fc1_override = overridden_plan.fixed_charges.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = overridden_plan.fixed_charges.find_sole_by(parent_id: fixed_charge2.id) + + expect(fc1_override).to be_present + expect(fc1_override.units).to eq(5) # Original units (not specified in override) + expect(fc2_override).to be_present + expect(fc2_override.units).to eq(100) # Overridden units + end + + it "creates fixed charge events with timestamp = subscription.started_at for active subscription" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription.fixed_charge_events.count).to eq(2) + + overridden_plan = subscription.plan + fc1_override = overridden_plan.fixed_charges.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = overridden_plan.fixed_charges.find_sole_by(parent_id: fixed_charge2.id) + + expect(subscription.fixed_charge_events.pluck(:fixed_charge_id, :units, :timestamp)).to contain_exactly( + [fc1_override.id, 5, be_within(1.second).of(subscription.started_at)], + [fc2_override.id, 100, be_within(1.second).of(subscription.started_at)] + ) + end + + context "when subscription starts in the future" do + let(:subscription_at) { 7.days.from_now } + + it "creates pending subscription with overridden plan but does not create fixed charge events" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription).to be_pending + expect(subscription.started_at).to be_nil + expect(subscription.plan.parent_id).to eq(plan.id) + + # Plan should have overridden fixed charges + overridden_plan = subscription.plan + expect(overridden_plan.fixed_charges.count).to eq(2) + + fc1_override = overridden_plan.fixed_charges.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = overridden_plan.fixed_charges.find_sole_by(parent_id: fixed_charge2.id) + + expect(fc1_override.units).to eq(5) + expect(fc2_override.units).to eq(100) + + # NO fixed charge events should be created for pending subscription + expect(subscription.fixed_charge_events.count).to eq(0) + end + end + + context "with invoice custom sections" do + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + invoice_custom_section: {invoice_custom_section_codes: [section_1.code]} + } + end + + before { + CurrentContext.source = "api" + section_1 + } + + it "attach to subscription" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription.reload + expect(subscription.applied_invoice_custom_sections.count).to be(1) + expect(subscription.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id) + end + end + end + + context "when customer does not exists in API context" do + let(:customer) { Customer.new(organization:, external_id: SecureRandom.uuid, billing_entity: organization.default_billing_entity) } + + before { CurrentContext.source = "api" } + + it "creates the customer" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription.customer.external_id).to eq(customer.external_id) + expect(subscription.plan_id).to eq(plan.id) + expect(subscription.started_at).to be_present + expect(subscription.subscription_at).to be_present + expect(subscription).to be_active + end + + context "when in graphql context" do + let(:customer) { nil } + let(:external_customer_id) { nil } + + before { CurrentContext.source = "graphql" } + + it "returns a customer_not_found error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("customer_not_found") + end + end + end + + context "when plan is pay_in_advance" do + let(:plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: true) } + + context "when subscription_at is current date" do + it "enqueues a job to bill the subscription" do + expect { create_service.call }.to have_enqueued_job(BillSubscriptionJob) + end + end + + context "when subscription_at is in the future" do + let(:subscription_at) { Time.current + 5.days } + + it "does not enqueue a job to bill the subscription" do + expect { create_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + end + + context "when subscription_at is current date but there is a trial period" do + let(:plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: true, trial_period: 10) } + + it "does not enqueue a job to bill the subscription" do + expect { create_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "when plan has pay in advance fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge } + + it "does not enqueue a job to bill the subscription" do + expect { create_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "enqueues a job to bill the pay in advance fixed charges even during trial" do + expect { create_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when plan has pay in advance fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge } + + it "enqueues a job to bill the subscription" do + expect { create_service.call }.to have_enqueued_job(BillSubscriptionJob) + end + + it "does not enqueue a job to bill the pay in advance fixed charges" do + expect { create_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when plan is not pay_in_advance, subscription_at is current date and there are fixed charges" do + let(:plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: false) } + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance:) } + + before do + fixed_charge + end + + context "when at least one fixed charge is pay_in_advance" do + let(:pay_in_advance) { true } + + it "does not queue a job to bill the subscription" do + expect { create_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "enqueues a job to bill the the pay in advance fixed charges" do + expect { create_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + + context "when plan has a trial period" do + let(:plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: true, trial_period: 10) } + + it "does not queue a job to bill the subscription" do + expect { create_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "enqueues a job to bill the pay in advance fixed charges even during trial" do + expect { create_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when all fixed charges are not pay_in_advance" do + let(:pay_in_advance) { false } + + it "does not enqueue a job to bill the subscription" do + expect { create_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "does not enqueue a job to bill fixed charges" do + expect { create_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when customer is missing" do + let(:customer) { nil } + let(:external_customer_id) { nil } + + it "returns a customer_not_found error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("customer_not_found") + end + end + + context "when plan doest not exists" do + let(:plan) { nil } + let(:plan_code) { nil } + + it "returns a plan_not_found error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("plan_not_found") + end + end + + context "when subscription_at is given and is invalid" do + let(:subscription_at) { "2022-99-99T00:00:00Z" } + + it "returns invalid_at error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:subscription_at]).to eq(["invalid_date"]) + end + end + + context "when subscription_at is given and is in the future" do + let(:subscription_at) { Time.current + 5.days } + + it "creates a pending subscription" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription.customer_id).to eq(customer.id) + expect(subscription.plan_id).to eq(plan.id) + expect(subscription.started_at).not_to be_present + expect(subscription.subscription_at.to_s).to eq(subscription_at.to_s) + expect(subscription.name).to eq("invoice display name") + expect(subscription).to be_pending + expect(subscription.external_id).to eq(external_id) + expect(subscription).to be_anniversary + expect(subscription.lifetime_usage).not_to be_present + end + + context "when plan has fixed charges" do + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + end + + it "does not create fixed charge events for the subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription).to be_pending + expect(result.subscription.fixed_charge_events.count).to eq(0) + end + end + end + + context "when subscription_at is given and is in the past" do + let(:subscription_at) { Time.current - 5.days } + + it "creates a active subscription" do + result = create_service.call + + expect(result).to be_success + + subscription = result.subscription + expect(subscription.customer_id).to eq(customer.id) + expect(subscription.plan_id).to eq(plan.id) + expect(subscription.started_at.to_s).to eq(subscription_at.to_s) + expect(subscription.subscription_at.to_s).to eq(subscription_at.to_s) + expect(subscription.name).to eq("invoice display name") + expect(subscription).to be_active + expect(subscription.external_id).to eq(external_id) + expect(subscription).to be_anniversary + expect(subscription.lifetime_usage).to be_present + expect(subscription.lifetime_usage.recalculate_invoiced_usage).to eq(true) + expect(subscription.lifetime_usage.recalculate_current_usage).to eq(false) + end + + context "when plan has pay in advance fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge } + + it "does not enqueue a job to bill the pay in advance fixed charges" do + expect { create_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when billing_time is invalid" do + let(:billing_time) { :foo } + + it "fails" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to eq([:billing_time]) + end + end + + context "with invalid payment method" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + payment_method: payment_method_params + } + end + + before { payment_method } + + context "when type is invalid" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "fails" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when ID is invalid" do + let(:payment_method_params) do + { + payment_method_id: "invalid", + payment_method_type: "provider" + } + end + + it "fails" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + + context "when an active subscription already exists" do + let(:subscription) do + create( + :subscription, + customer:, + plan: old_plan, + status: :active, + subscription_at: Time.current, + started_at: Time.current, + external_id: + ) + end + + let(:old_plan) { plan } + + before do + CurrentContext.source = "api" + subscription + end + + context "when external_id is given" do + it "returns existing subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.id).to eq(subscription.id) + end + end + + context "when subscription_id is given" do + let(:subscription_id) { subscription.id } + + before { CurrentContext.source = "graphql" } + + it "returns existing subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.id).to eq(subscription.id) + end + end + + context "when new plan has different currency than the old plan" do + let(:plan) { create(:plan, amount_cents: 200, organization:, amount_currency: "USD") } + + it "fails" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:currency) + expect(result.error.messages[:currency]).to include("currencies_does_not_match") + end + end + + context "when plan is not the same" do + context "when we upgrade the plan" do + let(:customer) { create(:customer, :with_hubspot_integration, organization:, currency: "EUR") } + let(:plan) { create(:plan, amount_cents: 200, organization:) } + let(:old_plan) { create(:plan, amount_cents: 100, organization:) } + let(:name) { "invoice display name new" } + + before do + subscription.mark_as_active! + end + + it "terminates the existing subscription" do + expect { create_service.call }.to change { subscription.reload.status }.from("active").to("terminated") + end + + it "moves the lifetime_usage to the new subscription" do + lifetime_usage = subscription.lifetime_usage + result = create_service.call + expect(result.subscription.lifetime_usage).to eq(lifetime_usage.reload) + expect(subscription.reload.lifetime_usage).to be_nil + end + + it "sends terminated and started subscription webhooks" do + result = create_service.call + expect(SendWebhookJob).to have_been_enqueued.with("subscription.terminated", subscription) + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", result.subscription) + end + + it "enqueues the Hubspot update job" do + create_service.call + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).to have_been_enqueued.twice.with(subscription:) + end + + it "creates a new subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.id).not_to eq(subscription.id) + expect(result.subscription).to be_active + expect(result.subscription.name).to eq("invoice display name new") + expect(result.subscription.plan.id).to eq(plan.id) + expect(result.subscription.previous_subscription_id).to eq(subscription.id) + expect(result.subscription.subscription_at).to eq(subscription.subscription_at) + expect(result.subscription.payment_method_id).to eq(nil) + expect(result.subscription.payment_method_type).to eq("provider") + end + + context "when plan has fixed charges" do + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + end + + it "creates fixed charge events for the subscription" do + freeze_time do + result = create_service.call + + expect(result).to be_success + expect(result.subscription).to be_active + expect(result.subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)) + .to match_array( + [ + [fixed_charge_1.id, be_within(1.second).of(Time.current)], + [fixed_charge_2.id, be_within(1.second).of(Time.current)] + ] + ) + end + end + end + + context "when payment method is attached" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + payment_method: { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + } + end + + it "creates a new subscription" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.id).not_to eq(subscription.id) + expect(result.subscription).to be_active + expect(result.subscription.name).to eq("invoice display name new") + expect(result.subscription.plan.id).to eq(plan.id) + expect(result.subscription.previous_subscription_id).to eq(subscription.id) + expect(result.subscription.subscription_at).to eq(subscription.subscription_at) + expect(result.subscription.payment_method_id).to eq(payment_method.id) + expect(result.subscription.payment_method_type).to eq("provider") + end + end + + context "when subscription upgrade fails" do + let(:result_failure) do + BaseService::Result.new.validation_failure!( + errors: {billing_time: ["value_is_invalid"]} + ) + end + + before do + allow(Subscriptions::PlanUpgradeService) + .to receive(:call) + .and_return(result_failure) + end + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({billing_time: ["value_is_invalid"]}) + end + end + + context "when current subscription is pending" do + before { subscription.pending! } + + it "returns existing subscription with updated attributes" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.id).to eq(subscription.id) + expect(result.subscription.plan_id).to eq(plan.id) + expect(result.subscription.name).to eq("invoice display name new") + end + + context "when plan has fixed charges" do + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + end + + it "does not create fixed charge events when updating a pending subscription" do + freeze_time do + result = create_service.call + + expect(result).to be_success + expect(result.subscription).to be_pending + expect(result.subscription.fixed_charge_events.count).to eq(0) + end + end + end + end + + context "when old subscription is payed in arrear" do + let(:old_plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: false) } + + it "enqueues a job to bill the existing subscription" do + expect { create_service.call }.to have_enqueued_job(BillSubscriptionJob) + end + end + + context "when old subscription was payed in advance" do + let(:creation_time) { Time.current.beginning_of_month - 1.month } + let(:date_service) do + Subscriptions::DatesService.new_instance( + subscription, + Time.current.beginning_of_month, + current_usage: false + ) + end + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + recurring: true, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime + ) + end + let(:invoice) do + create( + :invoice, + customer:, + currency: "EUR", + sub_total_excluding_taxes_amount_cents: 100, + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120 + ) + end + + let(:last_subscription_fee) do + create( + :fee, + subscription:, + invoice:, + amount_cents: 100, + taxes_amount_cents: 20, + invoiceable_type: "Subscription", + invoiceable_id: subscription.id, + taxes_rate: 20 + ) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan: old_plan, + status: :active, + subscription_at: creation_time, + started_at: creation_time, + external_id:, + billing_time: "anniversary" + ) + end + + let(:old_plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: true) } + + before do + invoice_subscription + last_subscription_fee + end + + it "creates a credit note for the remaining days" do + expect { create_service.call }.to change(CreditNote, :count) + end + end + + context "when new subscription is payed in advance" do + let(:plan) { create(:plan, amount_cents: 200, organization:, pay_in_advance: true) } + + it "enqueues a job to bill the existing subscription" do + expect { create_service.call }.to have_enqueued_job(BillSubscriptionJob) + end + end + + context "with pending next subscription" do + let(:next_subscription) do + create( + :subscription, + status: :pending, + previous_subscription: subscription, + organization: subscription.organization + ) + end + + before { next_subscription } + + it "canceled the next subscription" do + result = create_service.call + + expect(result).to be_success + expect(next_subscription.reload).to be_canceled + end + end + + context "with incomplete next subscription" do + let(:next_plan) { create(:plan, organization:) } + + before do + create( + :subscription, + :incomplete, + customer:, + plan: next_plan, + organization:, + previous_subscription: subscription, + external_id: subscription.external_id + ) + end + + it "returns subscription_incomplete error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:subscription]).to eq(["subscription_incomplete"]) + end + end + end + + context "when we downgrade the plan" do + before do + subscription.mark_as_active! + end + + let(:plan) { create(:plan, amount_cents: 50, organization:) } + let(:old_plan) { create(:plan, amount_cents: 100, organization:) } + let(:name) { "invoice display name new" } + + it "creates a new subscription" do + result = create_service.call + + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription.id).not_to eq(subscription.id) + expect(next_subscription).to be_pending + expect(next_subscription.name).to eq("invoice display name new") + expect(next_subscription.plan_id).to eq(plan.id) + expect(next_subscription.subscription_at).to eq(subscription.subscription_at) + expect(next_subscription.previous_subscription).to eq(subscription) + expect(next_subscription.ending_at).to eq(subscription.ending_at) + expect(next_subscription.lifetime_usage).to be_nil + expect(next_subscription.payment_method_id).to be_nil + expect(next_subscription.payment_method_type).to eq("provider") + end + + it "sends updated subscription webhook" do + create_service.call + expect(SendWebhookJob).to have_been_enqueued.with("subscription.updated", subscription) + end + + it "produces an activity log" do + create_service.call + expect(Utils::ActivityLog).to have_produced("subscription.updated").with(subscription) + end + + it "keeps the current subscription" do + result = create_service.call + + expect(result.subscription.id).to eq(subscription.id) + expect(result.subscription).to be_active + expect(result.subscription.next_subscription).to be_present + expect(result.subscription.lifetime_usage).to be_present + end + + context "with invoice custom sections" do + let(:section_1) { create(:invoice_custom_section, organization:, code: "section_code_1") } + + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + invoice_custom_section: {invoice_custom_section_codes: [section_1.code]} + } + end + + before { section_1 } + + it "attach to new subscription" do + result = create_service.call + + expect(result).to be_success + + next_subscription = result.subscription.next_subscription.reload + expect(next_subscription.applied_invoice_custom_sections.count).to be(1) + expect(next_subscription.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id) + end + end + + context "when plan has fixed charges" do + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + end + + it "creates fixed charge events for the new subscription" do + result = create_service.call + + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription).to be_pending + expect(next_subscription.fixed_charge_events.count).to eq(0) + end + end + + context "when payment method is attached" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + payment_method: { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + } + end + + it "creates a new subscription" do + result = create_service.call + + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription.id).not_to eq(subscription.id) + expect(next_subscription).to be_pending + expect(next_subscription.name).to eq("invoice display name new") + expect(next_subscription.plan_id).to eq(plan.id) + expect(next_subscription.subscription_at).to eq(subscription.subscription_at) + expect(next_subscription.previous_subscription).to eq(subscription) + expect(next_subscription.ending_at).to eq(subscription.ending_at) + expect(next_subscription.lifetime_usage).to be_nil + expect(next_subscription.payment_method_id).to eq(payment_method.id) + expect(next_subscription.payment_method_type).to eq("provider") + end + end + + context "when ending_at is overridden" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + ending_at: Time.current.beginning_of_day + 3.months + } + end + + it "creates a new subscription with correctly set ending_at" do + result = create_service.call + + expect(result).to be_success + + next_subscription = result.subscription.next_subscription + expect(next_subscription.ending_at).to eq(Time.current.beginning_of_day + 3.months) + end + end + + context "when current subscription is pending" do + before { subscription.pending! } + + it "returns existing subscription with updated attributes" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.id).to eq(subscription.id) + expect(result.subscription.plan_id).to eq(plan.id) + expect(result.subscription.name).to eq("invoice display name new") + end + end + + context "with pending next subscription" do + let(:next_subscription) do + create( + :subscription, + status: :pending, + previous_subscription: subscription, + organization: subscription.organization + ) + end + + before { next_subscription } + + it "canceled the next subscription" do + result = create_service.call + + expect(result).to be_success + expect(next_subscription.reload).to be_canceled + end + end + + context "with incomplete next subscription" do + let(:next_plan) { create(:plan, organization:) } + + before do + create( + :subscription, + :incomplete, + customer:, + plan: next_plan, + organization:, + previous_subscription: subscription, + external_id: subscription.external_id + ) + end + + it "returns subscription_incomplete error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:subscription]).to eq(["subscription_incomplete"]) + end + end + end + end + end + + context "when existing subscription with same external_id is incomplete" do + let(:incomplete_subscription) do + create(:subscription, :incomplete, customer:, plan:, organization:, external_id:) + end + + before { incomplete_subscription } + + it "returns a subscription_incomplete error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:subscription]).to eq(["subscription_incomplete"]) + end + end + + context "with activation_rules" do + let(:customer) { create(:customer, organization:, payment_provider: "stripe") } + + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + activation_rules: [{type: "payment", timeout_hours: 48}] + } + end + + context "when subscription_at is in the past" do + let(:subscription_at) { (Time.current - 5.days).iso8601 } + + it "creates active subscription without activation rules" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_active + expect(subscription.activation_rules.count).to eq(0) + end + end + + context "when subscription_at is today" do + let(:subscription_at) { Time.current.beginning_of_day.iso8601 } + + it "creates active subscription with not_applicable activation rules (pay-in-arrears plan)" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_active + expect(subscription.activation_rules.count).to eq(1) + expect(subscription.activation_rules.first).to be_not_applicable + end + + context "when plan is pay in advance" do + let(:plan) { create(:plan, amount_cents: 100, organization:, amount_currency: "EUR", pay_in_advance: true) } + + it "creates incomplete subscription with pending activation rule" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_incomplete + expect(subscription.activation_rules.count).to eq(1) + expect(subscription.activation_rules.first).to be_pending + end + + it "enqueues BillSubscriptionJob" do + expect { create_service.call }.to have_enqueued_job(BillSubscriptionJob) + end + end + + context "when plan is pay in arrears with pay-in-advance fixed charges" do + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "creates incomplete subscription with pending activation rule" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_incomplete + expect(subscription.activation_rules.count).to eq(1) + expect(subscription.activation_rules.first).to be_pending + end + + it "enqueues CreatePayInAdvanceFixedChargesJob" do + expect { create_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + + context "when plan is pay in advance with trial period" do + let(:plan) { create(:plan, amount_cents: 100, organization:, amount_currency: "EUR", pay_in_advance: true, trial_period: 30) } + + it "creates active subscription with not_applicable activation rule" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_active + expect(subscription.activation_rules.count).to eq(1) + expect(subscription.activation_rules.first).to be_not_applicable + end + + context "when plan has pay-in-advance fixed charges" do + let(:add_on) { create(:add_on, organization:) } + + before { create(:fixed_charge, plan:, add_on:, pay_in_advance: true) } + + it "creates incomplete subscription with pending activation rule" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_incomplete + expect(subscription.activation_rules.count).to eq(1) + expect(subscription.activation_rules.first).to be_pending + end + end + end + end + + context "when subscription_at is in the future" do + let(:subscription_at) { (Time.current + 5.days).iso8601 } + + it "creates pending subscription with activation rules" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_pending + expect(subscription.activation_rules.count).to eq(1) + expect(subscription.activation_rules.first).to have_attributes( + type: "payment", + timeout_hours: 48, + status: "inactive" + ) + end + end + + context "with invalid activation rule type" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + activation_rules: [{type: "unknown"}] + } + end + + it "returns validation error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:activation_rules]).to include("invalid_type") + end + end + + context "when timeout_hours is omitted" do + let(:subscription_at) { (Time.current + 5.days).iso8601 } + + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + activation_rules: [{type: "payment"}] + } + end + + it "creates activation rule with timeout_hours defaulting to 0" do + result = create_service.call + + expect(result).to be_success + subscription = result.subscription + expect(subscription).to be_pending + expect(subscription.activation_rules.count).to eq(1) + expect(subscription.activation_rules.first).to have_attributes( + type: "payment", + timeout_hours: 0, + status: "inactive" + ) + end + end + + context "with negative timeout_hours" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + activation_rules: [{type: "payment", timeout_hours: -1}] + } + end + + it "returns validation error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:timeout_hours]).to include("value_must_be_positive_or_zero") + end + end + end + + context "when activation_rules is nil" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + activation_rules: nil + } + end + + it "creates subscription without activation rules" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.activation_rules.count).to eq(0) + end + end + + context "when activation_rules is empty" do + let(:params) do + { + external_customer_id:, + plan_code:, + name:, + external_id:, + billing_time:, + subscription_at:, + subscription_id:, + activation_rules: [] + } + end + + it "creates subscription without activation rules" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.activation_rules.count).to eq(0) + end + end + + describe "billing entity binding" do + let(:billing_entity) { create(:billing_entity, organization:) } + + context "when multi_entity_billing flag is OFF" do + it "ignores billing_entity_code and persists nil" do + params[:billing_entity_code] = billing_entity.code + + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_entity_id).to be_nil + end + + it "ignores billing_entity_id and persists nil" do + params[:billing_entity_id] = billing_entity.id + + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_entity_id).to be_nil + end + end + + context "when multi_entity_billing flag is ON" do + before { organization.enable_feature_flag!(:multi_entity_billing) } + + it "persists nil when no billing entity reference is provided" do + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_entity_id).to be_nil + end + + it "binds the subscription to the entity matched by billing_entity_id" do + params[:billing_entity_id] = billing_entity.id + + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_entity_id).to eq(billing_entity.id) + end + + it "binds the subscription to the entity matched by billing_entity_code" do + params[:billing_entity_code] = billing_entity.code + + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_entity_id).to eq(billing_entity.id) + end + + it "fails with billing_entity_not_found when billing_entity_id is unknown" do + params[:billing_entity_id] = SecureRandom.uuid + + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("billing_entity_not_found") + end + + it "fails with billing_entity_not_found when billing_entity_code is unknown" do + params[:billing_entity_code] = "unknown-entity-code" + + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.error_code).to eq("billing_entity_not_found") + end + + it "prefers billing_entity_id over billing_entity_code when both are provided" do + other_entity = create(:billing_entity, organization:) + params[:billing_entity_id] = billing_entity.id + params[:billing_entity_code] = other_entity.code + + result = create_service.call + + expect(result).to be_success + expect(result.subscription.billing_entity_id).to eq(billing_entity.id) + end + end + end + end +end diff --git a/spec/services/subscriptions/dates/monthly_service_spec.rb b/spec/services/subscriptions/dates/monthly_service_spec.rb new file mode 100644 index 0000000..374267c --- /dev/null +++ b/spec/services/subscriptions/dates/monthly_service_spec.rb @@ -0,0 +1,1063 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::Dates::MonthlyService do + subject(:date_service) { described_class.new(subscription, billing_at, false) } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at:, + billing_time:, + started_at:, + previous_subscription: + ) + end + + let(:billing_entity) { create(:billing_entity) } + let(:customer) { create(:customer, timezone:, billing_entity:) } + let(:plan) { create(:plan, interval: :monthly, pay_in_advance:) } + let(:pay_in_advance) { false } + + let(:subscription_at) { Time.zone.parse("02 Feb 2021") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + let(:started_at) { subscription_at } + let(:timezone) { "UTC" } + + let(:previous_subscription) { nil } + + describe "#from_datetime" do + let(:result) { date_service.from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Mar 2022") } + + it "returns the beginning of the previous month" do + expect(result).to eq("2022-02-01 00:00:00 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-01-01 05:00:00 UTC") + end + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("07 Feb 2022 05:00:00") } + + it "returns the start date" do + expect(result).to eq(started_at.beginning_of_day.utc.to_s) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + end + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Mar 2022") } + + before { subscription.mark_as_terminated!("9 Mar 2022") } + + it "returns the beginning of the month" do + expect(result).to eq("2022-03-01 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the beginning of the month" do + expect(result).to eq("2022-03-01 00:00:00 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns the day in the previous month day" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("08 Feb 2022") } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Mar 2022") } + + before { subscription.mark_as_terminated!("9 Mar 2022") } + + it "returns the previous month day" do + expect(result).to eq("2022-03-02 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the day in the current month" do + expect(result).to eq("2022-03-02 00:00:00 UTC") + end + end + + context "when billing day after last day of billing month" do + let(:billing_at) { Time.zone.parse("29 Mar 2022") } + let(:subscription_at) { Time.zone.parse("31 Mar 2021") } + + it "returns the previous month last day" do + expect(result).to eq("2022-02-28 00:00:00 UTC") + end + end + + context "when billing day on first month of the year" do + before { subscription.mark_as_terminated!("27 Jan 2022") } + + let(:billing_at) { Time.zone.parse("28 Jan 2022") } + let(:subscription_at) { Time.zone.parse("27 Mar 2021") } + + it "returns the previous month last day" do + expect(result).to eq("2021-12-27 00:00:00 UTC") + end + end + end + + context "when plan is in advance and date is on the last day of month" do + let(:pay_in_advance) { true } + + let(:billing_at) { Time.zone.parse("30 apr 2021") } + let(:subscription_at) { Time.zone.parse("31 mar 2021") } + + it "returns the current day" do + expect(result).to eq("2021-04-30 00:00:00 UTC") + end + + context "when billing month is longer than subscription one" do + let(:billing_at) { Time.zone.parse("29 feb 2020") } + let(:subscription_at) { Time.zone.parse("31 jan 2020") } + + it "returns the durrent day" do + expect(result).to eq("2020-02-29 00:00:00 UTC") + end + end + end + + context "when plan is in arrear and date is on the last day of month" do + let(:billing_at) { Time.zone.parse("30 apr 2021") } + let(:subscription_at) { Time.zone.parse("31 mar 2021") } + + it "returns the day current billing day" do + expect(result).to eq("2021-03-31 00:00:00 UTC") + end + + context "when subscription month is shorter than billing one" do + let(:billing_at) { Time.zone.parse("30 mar 2020") } + let(:subscription_at) { Time.zone.parse("30 apr 2019") } + + it "returns the current billing day" do + expect(result).to eq("2020-02-29 00:00:00 UTC") + end + end + end + end + end + + describe "#to_datetime" do + let(:result) { date_service.to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Mar 2022") } + + it "returns the end of the previous month" do + expect(result).to eq("2022-02-28 23:59:59 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-01 04:59:59 UTC") + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the month" do + expect(result).to eq("2022-03-31 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Mar 2022") } + let(:terminated_at) { Time.zone.parse("09 Mar 2022") } + + before { subscription.update!(status: :terminated, terminated_at:) } + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + + context "with pending next subscription" do + let(:subscription_at) { Time.zone.parse("2024-09-09T14:00:01") } + let(:terminated_at) { Time.zone.parse("2024-09-09T16:00:01") } + + let(:downgraded_plan) { create(:plan, interval: :monthly, pay_in_advance: false, amount_cents: 0) } + + before do + create( + :subscription, + status: :pending, + external_id: subscription.external_id, + plan: downgraded_plan, + customer:, + subscription_at:, + billing_time:, + started_at: nil, + previous_subscription: subscription + ) + end + + it "makes sure the to_datetime is not before start date" do + expect(result).to match_datetime(subscription.started_at.utc) + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns the day in the previous month" do + expect(result).to eq("2022-03-01 23:59:59 UTC") + end + + context "when billing last month of year" do + let(:billing_at) { Time.zone.parse("04 Jan 2022") } + + it "returns the day in the previous month" do + expect(result).to eq("2022-01-01 23:59:59 UTC") + end + end + + context "when billing subscription day does not exist in the month" do + let(:subscription_at) { Time.zone.parse("31 Jan 2022") } + let(:billing_at) { Time.zone.parse("28 Feb 2022") } + + it "returns the last day of the month" do + expect(result).to eq("2022-02-27 23:59:59 UTC") + end + + context "when subscription is not the last day of the month" do + let(:subscription_at) { Time.zone.parse("30 Jan 2022") } + + it "returns the last day of the month" do + expect(result).to eq("2022-02-27 23:59:59 UTC") + end + end + end + + context "when anniversary date is first day of the month" do + let(:subscription_at) { Time.zone.parse("01 Jan 2022") } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns the last day of the month" do + expect(result).to eq("2022-02-28 23:59:59 UTC") + end + end + + context "when plan is pay in advance" do + before { plan.update!(pay_in_advance: true) } + + it "returns the end of the current period" do + expect(result).to eq("2022-04-01 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Mar 2022") } + + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("02 Mar 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + describe "#charges_from_datetime" do + let(:result) { date_service.charges_from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Mar 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + charges_to_datetime: "2022-01-31T23:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result).to match_datetime("2022-02-01 00:00:00") + end + end + + context "when timezone has changed and there are no invoices generated in the past" do + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + before do + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes calculates correct datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-02-01 00:00:00 UTC") + end + end + + context "when previous subscription is upgraded" do + let(:started_at) { Time.zone.parse("2024-02-22T16:13:00") } + + let(:previous_subscription) do + create( + :subscription, + :terminated, + terminated_at: started_at + ) + end + + it "returns the beginning of the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) # boundary should be terminated_at + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "#charges_to_datetime" do + let(:result) { date_service.charges_to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime.to_s) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("2022-03-06T12:23:00") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + + context "when subscription was upgraded" do + before do + create( + :subscription, + started_at: terminated_at, + previous_subscription: subscription + ) + end + + it "returns the terminated_at" do + expect(result).to eq(subscription.terminated_at.to_s) + end + + context "when end of previous day is before charges_from_datetime" do + let(:started_at) { Time.zone.parse("2022-03-06T10:23:00") } + let(:terminated_at) { Time.zone.parse("2022-03-06T12:23:00") } + + it "returns the charges_from_datetime" do + expect(result).to eq(subscription.terminated_at.to_s) + end + end + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("1 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + end + + describe "#fixed_charges_from_datetime" do + subject(:result) { date_service.fixed_charges_from_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Mar 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account and returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + fixed_charges_to_datetime: "2022-01-31T23:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account and returns from_datetime" do + expect(result.to_s).to match_datetime("2022-02-01 00:00:00") + end + end + + context "when timezone has changed and there are no invoices generated in the past" do + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + before do + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes calculates correct datetime" do + expect(result).to eq(date_service.from_datetime) + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result.to_s).to eq("2022-02-01 00:00:00 UTC") + end + end + + context "when previous subscription is upgraded" do + let(:started_at) { Time.zone.parse("2024-02-22T16:13:00") } + + before do + create( + :subscription, + :terminated, + terminated_at: started_at + ) + end + + it "returns the beginning of the start date" do + expect(result).to eq(subscription.started_at.utc) # boundary should be terminated_at + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result.to_s).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "#fixed_charges_to_datetime" do + subject(:result) { date_service.fixed_charges_to_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("2022-03-06T12:23:00") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + + context "when subscription was upgraded" do + before do + create( + :subscription, + started_at: terminated_at, + previous_subscription: subscription + ) + end + + it "returns the terminated_at" do + expect(result).to eq(subscription.terminated_at) + end + + context "when end of previous day is before fixed_charges_from_datetime" do + let(:started_at) { Time.zone.parse("2022-03-06T10:23:00") } + let(:terminated_at) { Time.zone.parse("2022-03-06T12:23:00") } + + it "returns the fixed_charges_from_datetime" do + expect(result).to eq(subscription.terminated_at) + end + end + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("1 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + end + end + + describe "#next_end_of_period" do + let(:result) { date_service.next_end_of_period.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the last day of the month" do + expect(result).to eq("2022-03-31 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-04-01 03:59:59 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the end of the billing month" do + expect(result).to eq("2022-04-01 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-04-01 03:59:59 UTC") + end + end + + context "when end of billing month is in next year" do + let(:billing_at) { Time.zone.parse("07 Dec 2021") } + + it { expect(result).to eq("2022-01-01 23:59:59 UTC") } + end + + context "when date is the end of the period" do + let(:billing_at) { Time.zone.parse("01 Mar 2022") } + + it "returns the date" do + expect(result).to eq(billing_at.utc.end_of_day.to_s) + end + end + end + end + + describe "#previous_beginning_of_period" do + let(:result) { date_service.previous_beginning_of_period(current_period:).to_s } + + let(:current_period) { false } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the first day of the previous month" do + expect(result).to eq("2022-02-01 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-01 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the first day of the month" do + expect(result).to eq("2022-03-01 00:00:00 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the beginning of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-01 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the beginning of the current period" do + expect(result).to eq("2022-03-02 00:00:00 UTC") + end + end + end + end + + describe "#single_day_price" do + let(:result) { date_service.single_day_price } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(28)) + end + + context "when passing the plan amount" do + let(:result) { date_service.single_day_price(plan_amount_cents: 1000) } + + it "returns the price of single day" do + expect(result).to eq(1_000.fdiv(28)) + end + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Mar 2020") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(29)) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(28)) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("08 Mar 2020") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(29)) + end + end + end + end + + describe "#charges_duration_in_days" do + let(:result) { date_service.charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the month duration" do + expect(result).to eq(28) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Mar 2020") } + + it "returns the month duration" do + expect(result).to eq(29) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the month duration" do + expect(result).to eq(28) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("08 Mar 2020") } + + it "returns the month duration" do + expect(result).to eq(29) + end + end + end + end + + describe "#fixed_charges_duration_in_days" do + subject(:result) { date_service.fixed_charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the month duration" do + expect(result).to eq(28) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Mar 2020") } + + it "returns the month duration" do + expect(result).to eq(29) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the month duration" do + expect(result).to eq(28) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("08 Mar 2020") } + + it "returns the month duration" do + expect(result).to eq(29) + end + end + end + end + + # In February 2022, customer changed timezone from Asia/Tokyo to America/Los_Angeles. + # The invoice generated in February 2022 had subscription from Feb 1st to Feb 28th + # and usage-based charges from Jan 1st to Jan 31st. + # The dates are correct but *they are in customer timezone*: "2022-01-31 23:59:59 Asia/Tokyo" is "2022-01-31 14:59:59 UTC" + # In Feb, if we use "2022-02-01 00:00:00 America/Los_Angeles", which is "2022-02-01 07:00:00 UTC" we're now missing + # all events between "2022-01-31 14:59:59 UTC" and "2022-02-01 07:00:00 UTC", which is a 16h gap. + # + # We need to use the previous invoice charges_to_datetime as the new invoice charges_from_datetime. + # + # + context "when customer changed timezone" do + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:, invoice_grace_period: 3) } + let(:customer) { create(:customer, timezone:, billing_entity:, organization:) } + let(:plan) { create(:plan, interval: :monthly, pay_in_advance:, organization:) } + + let(:billing_time) { :calendar } + let(:timezone) { "Asia/Tokyo" } + let(:new_timezone) { "America/Los_Angeles" } + + # Clock::SubscriptionsBillerJob will find this subscription as soon as it's March in the customer timezone + let(:billing_at) { Time.new(2022, 3, 1, 0, 10, 0, Time.new(2022, 3, 1, 0, 10, 0).in_time_zone(new_timezone).formatted_offset) } + let(:previous_invoice_charges_to_datetime) { Time.new(2022, 1, 31, 23, 59, 59, Time.new(2022, 1, 31, 23, 59, 59).in_time_zone(timezone).formatted_offset) } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + charges_to_datetime: previous_invoice_charges_to_datetime, + invoice: create(:invoice, timezone: timezone) + ) + end + + before do + previous_invoice_subscription + end + + it "takes previous invoice charges_to_datetime into account and compute correct following month" do + expect(previous_invoice_subscription.charges_to_datetime).to be_utc + expect(date_service.send(:timezone_has_changed?)).to be_falsey + + result = Invoices::SubscriptionService.call( + subscriptions: [subscription], + timestamp: billing_at.to_i, + invoicing_reason: "subscription_periodic", + invoice: nil, + skip_charges: false + ) + + expect(result.invoice.status).to eq "draft" + invoice_sub = result.invoice.invoice_subscriptions.first + expect(invoice_sub.charges_from_datetime.to_s).to match_datetime("2022-01-31 15:00:00 UTC") + expect(invoice_sub.charges_to_datetime.to_s).to match_datetime("2022-02-28 14:59:59 UTC") + + subscription.customer.update!(timezone: new_timezone) + + finalized_result = Invoices::RefreshDraftAndFinalizeService.call(invoice: result.invoice.reload) + invoice_sub = finalized_result.invoice.reload.invoice_subscriptions.first + + expect(invoice_sub.charges_from_datetime.to_s).to match_datetime("2022-01-31 15:00:00 UTC") + # "2022-02-28 23:59:59 America/Los_Angeles" which is "2022-03-01 07:59:59 UTC" + expect(invoice_sub.charges_to_datetime.to_s).to match_datetime("2022-03-01 07:59:59 UTC") + end + end +end diff --git a/spec/services/subscriptions/dates/quarterly_service_spec.rb b/spec/services/subscriptions/dates/quarterly_service_spec.rb new file mode 100644 index 0000000..da509a0 --- /dev/null +++ b/spec/services/subscriptions/dates/quarterly_service_spec.rb @@ -0,0 +1,940 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::Dates::QuarterlyService do + subject(:date_service) { described_class.new(subscription, billing_at, current_usage) } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at:, + billing_time:, + started_at: + ) + end + + let(:customer) { create(:customer, timezone:) } + let(:plan) { create(:plan, interval: :monthly, pay_in_advance:) } + let(:pay_in_advance) { false } + let(:current_usage) { false } + + let(:subscription_at) { Time.zone.parse("02 Feb 2021") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + let(:started_at) { subscription_at } + let(:timezone) { "UTC" } + + describe "from_datetime" do + let(:result) { date_service.from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the beginning of the previous quarter" do + expect(result).to eq("2022-04-01 00:00:00 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-01-01 05:00:00 UTC") + end + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("07 Apr 2022") } + + it "returns the start date" do + expect(result).to eq(started_at.beginning_of_day.utc.to_s) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the start date in the timezone" do + expect(result).to eq("2022-04-06 04:00:00 UTC") + end + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Jul 2022") } + + before { subscription.mark_as_terminated!("9 Jul 2022") } + + it "returns the beginning of the quarter" do + expect(result).to eq("2022-07-01 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the beginning of the quarter" do + expect(result).to eq("2022-07-01 00:00:00 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns the same day in the previous quarter" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("08 Feb 2022") } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + end + end + + context "when date is in first quarter" do + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns the correct day in the previous year" do + expect(result).to eq("2021-11-02 00:00:00 UTC") + end + end + + context "when date is on the last day of the month" do + let(:billing_at) { Time.zone.parse("31 May 2022") } + let(:subscription_at) { Time.zone.parse("28 Feb 2021") } + + it "returns the last day in the previous quarter" do + expect(result).to eq("2022-02-28 00:00:00 UTC") + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 May 2022") } + + before { subscription.mark_as_terminated!("9 May 2022") } + + it "returns the correct day at the beginning of the quarter" do + expect(result).to eq("2022-05-02 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the correct day in the current quarter" do + expect(result).to eq("2022-05-02 00:00:00 UTC") + end + end + + context "when billing day after last day of billing month" do + let(:billing_at) { Time.zone.parse("29 May 2022") } + let(:subscription_at) { Time.zone.parse("30 May 2021") } + + it "returns the previous quarter last day" do + expect(result).to eq("2022-02-28 00:00:00 UTC") + end + end + + context "when billing day in the second month of the year" do + let(:billing_at) { Time.zone.parse("27 Feb 2022") } + let(:subscription_at) { Time.zone.parse("28 Feb 2021") } + + before { subscription.mark_as_terminated!("25 Feb 2022") } + + it "returns the previous quarter last day" do + expect(result).to eq("2021-11-28 00:00:00 UTC") + end + end + end + + context "when plan is in advance and date is on the last day of month" do + let(:pay_in_advance) { true } + + let(:billing_at) { Time.zone.parse("30 Apr 2021") } + let(:subscription_at) { Time.zone.parse("31 Jan 2021") } + + it "returns the current day" do + expect(result).to eq("2021-04-30 00:00:00 UTC") + end + end + + context "when date is not on a billing month" do + let(:billing_at) { Time.zone.parse("8 Aug 2023") } + let(:subscription_at) { Time.zone.parse("6 Apr 2023") } + let(:current_usage) { true } + + it "returns the date in previous billing month" do + expect(result).to eq("2023-07-06 00:00:00 UTC") + end + end + + context "when date is not on a billing month and day is less than subscription day" do + let(:billing_at) { Time.zone.parse("4 Aug 2023") } + let(:subscription_at) { Time.zone.parse("6 Apr 2023") } + let(:current_usage) { true } + + it "returns the date in previous billing month" do + expect(result).to eq("2023-07-06 00:00:00 UTC") + end + end + end + end + + describe "to_datetime" do + let(:result) { date_service.to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the end of the previous quarter" do + expect(result).to eq("2022-06-30 23:59:59 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-04-01 03:59:59 UTC") + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the quarter" do + expect(result).to eq("2022-09-30 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("27 Jun 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns the day in the previous month" do + expect(result).to eq("2022-05-01 23:59:59 UTC") + end + + context "when billing last quarter of the year" do + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns the day in the previous month" do + expect(result).to eq("2022-02-01 23:59:59 UTC") + end + end + + context "when billing subscription day does not exist in the month" do + let(:subscription_at) { Time.zone.parse("30 Nov 2021") } + let(:billing_at) { Time.zone.parse("01 Mar 2022") } + + it "returns the last day of the previous month" do + expect(result).to eq("2022-02-27 23:59:59 UTC") + end + + context "when subscription is not the last day of the month" do + let(:subscription_at) { Time.zone.parse("30 Jan 2022") } + let(:billing_at) { Time.zone.parse("30 Apr 2022") } + + it "returns the last day of the month" do + expect(result).to eq("2022-04-29 23:59:59 UTC") + end + end + end + + context "when anniversary date is first day of the quarter" do + let(:subscription_at) { Time.zone.parse("01 Oct 2021") } + let(:billing_at) { Time.zone.parse("02 Apr 2022") } + + it "returns the last day of the previous quarter" do + expect(result).to eq("2022-03-31 23:59:59 UTC") + end + end + + context "when plan is pay in advance" do + before { plan.update!(pay_in_advance: true) } + + it "returns the end of the current period" do + expect(result).to eq("2022-08-01 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("30 Apr 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + describe "charges_from_datetime" do + let(:result) { date_service.charges_from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Apr 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Apr 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + charges_to_datetime: "2021-12-31T23:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result).to match_datetime("2022-01-01 00:00:00") + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Apr 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("01 Jan 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 May 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "charges_to_datetime" do + let(:result) { date_service.charges_to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime.to_s) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Jun 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Apr 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + end + + describe "#fixed_charges_from_datetime" do + subject(:result) { date_service.fixed_charges_from_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Apr 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account and returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Apr 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + fixed_charges_to_datetime: "2021-12-31T23:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account and returns from_datetime" do + expect(result.to_s).to match_datetime("2022-01-01 00:00:00") + end + end + + context "when timezone has changed and there are no invoices generated in the past" do + let(:billing_at) { Time.zone.parse("02 Apr 2022") } + + before do + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes calculates correct datetime" do + expect(result).to eq(date_service.from_datetime) + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Apr 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("01 Jan 2020") } + + it "returns the start of the previous period" do + expect(result.to_s).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when previous subscription is upgraded" do + let(:started_at) { Time.zone.parse("2024-02-22T16:13:00") } + + before do + create( + :subscription, + :terminated, + terminated_at: started_at + ) + end + + it "returns the beginning of the start date" do + expect(result).to eq(subscription.started_at.utc) # boundary should be terminated_at + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 May 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result.to_s).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "#fixed_charges_to_datetime" do + subject(:result) { date_service.fixed_charges_to_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("2022-06-15T12:23:00") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + + context "when subscription was upgraded" do + before do + create( + :subscription, + started_at: terminated_at, + previous_subscription: subscription + ) + end + + it "returns the terminated_at" do + expect(result).to eq(subscription.terminated_at) + end + + context "when end of previous day is before fixed_charges_from_datetime" do + let(:started_at) { Time.zone.parse("2022-03-06T10:23:00") } + let(:terminated_at) { Time.zone.parse("2022-03-06T12:23:00") } + + it "returns the fixed_charges_from_datetime" do + expect(result).to eq(subscription.terminated_at) + end + end + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Apr 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + # FAILS + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + end + end + + describe "next_end_of_period" do + let(:result) { date_service.next_end_of_period.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("02 Jul 2022") } + + it "returns the last day of the month" do + expect(result).to eq("2022-09-30 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-10-01 03:59:59 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("07 May 2022") } + + it "returns the end of the billing month" do + expect(result).to eq("2022-08-01 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-08-01 03:59:59 UTC") + end + end + + context "when end of billing month is in next year" do + let(:billing_at) { Time.zone.parse("02 Nov 2021") } + + it { expect(result).to eq("2022-02-01 23:59:59 UTC") } + end + + context "when date is the end of the period" do + let(:billing_at) { Time.zone.parse("01 May 2022") } + + it "returns the date" do + expect(result).to eq(billing_at.utc.end_of_day.to_s) + end + end + end + end + + describe "previous_beginning_of_period" do + let(:result) { date_service.previous_beginning_of_period(current_period:).to_s } + + let(:current_period) { false } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("02 Jul 2022") } + + it "returns the first day of the previous month" do + expect(result).to eq("2022-04-01 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-04-01 04:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the first day of the month" do + expect(result).to eq("2022-07-01 00:00:00 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("03 May 2022") } + + it "returns the beginning of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-01 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the beginning of the current period" do + expect(result).to eq("2022-05-02 00:00:00 UTC") + end + end + end + end + + describe "single_day_price" do + let(:result) { date_service.single_day_price } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(91)) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Apr 2020") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(91)) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2020") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(90)) + end + + context "when not on a leap year" do + let(:billing_at) { Time.zone.parse("02 May 2021") } + + it "returns the month duration" do + expect(result).to eq(plan.amount_cents.fdiv(89)) + end + end + end + end + + describe "charges_duration_in_days" do + let(:result) { date_service.charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the quarter duration" do + expect(result).to eq(91) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Apr 2020") } + + it "returns the duration in days" do + expect(result).to eq(91) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2020") } + + it "returns the month duration" do + expect(result).to eq(90) + end + + context "when not on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("02 May 2021") } + + it "returns the duration in days" do + expect(result).to eq(89) + end + end + end + end + + describe "fixed_charges_duration_in_days" do + subject(:result) { date_service.fixed_charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the quarter duration" do + expect(result).to eq(91) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Apr 2020") } + + it "returns the duration in days" do + expect(result).to eq(91) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2020") } + + it "returns the month duration" do + expect(result).to eq(90) + end + + context "when not on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("02 May 2021") } + + it "returns the duration in days" do + expect(result).to eq(89) + end + end + end + end +end diff --git a/spec/services/subscriptions/dates/semiannual_service_spec.rb b/spec/services/subscriptions/dates/semiannual_service_spec.rb new file mode 100644 index 0000000..b9576ce --- /dev/null +++ b/spec/services/subscriptions/dates/semiannual_service_spec.rb @@ -0,0 +1,1322 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::Dates::SemiannualService do + subject(:date_service) { described_class.new(subscription, billing_at, current_usage) } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at:, + billing_time:, + started_at: + ) + end + + let(:customer) { create(:customer, timezone:) } + let(:plan) { create(:plan, interval: :semiannual, pay_in_advance:) } + let(:pay_in_advance) { false } + let(:current_usage) { false } + + let(:subscription_at) { Time.zone.parse("02 Feb 2021") } + let(:started_at) { subscription_at } + let(:timezone) { "UTC" } + + describe "from_datetime" do + let(:result) { date_service.from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the beginning of the previous half year" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2021-07-01 04:00:00 UTC") + end + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("07 Apr 2022") } + + it "returns the start date" do + expect(result).to eq(started_at.beginning_of_day.utc.to_s) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the start date in the timezone" do + expect(result).to eq("2022-04-06 04:00:00 UTC") + end + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Jul 2022") } + + before { subscription.mark_as_terminated!("9 Jul 2022") } + + it "returns the beginning of the half year" do + expect(result).to eq("2022-07-01 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the beginning of the half year" do + expect(result).to eq("2022-07-01 00:00:00 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("02 Nov 2021") } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns the same day in the previous half year" do + expect(result).to eq("2021-11-02 00:00:00 UTC") + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("08 Feb 2022") } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + end + end + + context "when date is in first half year" do + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns the correct day in the previous year" do + expect(result).to eq("2021-11-02 00:00:00 UTC") + end + end + + context "when date is on the last day of the month" do + let(:billing_at) { Time.zone.parse("31 May 2022") } + let(:subscription_at) { Time.zone.parse("30 Nov 2021") } + + it "returns the last day in the previous half year" do + expect(result).to eq("2021-11-30 00:00:00 UTC") + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 May 2022") } + + before { subscription.mark_as_terminated!("9 May 2022") } + + it "returns the correct day at the beginning of the half year" do + expect(result).to eq("2022-05-02 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the correct day in the current quarter" do + expect(result).to eq("2022-05-02 00:00:00 UTC") + end + end + + context "when billing day after last day of billing month" do + let(:billing_at) { Time.zone.parse("29 May 2022") } + let(:subscription_at) { Time.zone.parse("30 May 2021") } + + it "returns the previous half year last day" do + expect(result).to eq("2021-11-30 00:00:00 UTC") + end + end + + context "when billing day in the second month of the year" do + let(:billing_at) { Time.zone.parse("27 Feb 2022") } + let(:subscription_at) { Time.zone.parse("28 Feb 2021") } + + before { subscription.mark_as_terminated!("25 Feb 2022") } + + it "returns the previous half year last day" do + expect(result).to eq("2021-08-28 00:00:00 UTC") + end + end + end + + context "when plan is in advance and date is on the last day of month" do + let(:pay_in_advance) { true } + + let(:billing_at) { Time.zone.parse("30 Apr 2021") } + let(:subscription_at) { Time.zone.parse("31 Jan 2021") } + + it "returns the current day" do + expect(result).to eq("2021-04-30 00:00:00 UTC") + end + end + + context "when date is not on a billing month" do + let(:billing_at) { Time.zone.parse("8 Aug 2023") } + let(:subscription_at) { Time.zone.parse("6 Jan 2023") } + let(:current_usage) { true } + + it "returns the date in previous billing month" do + expect(result).to eq("2023-07-06 00:00:00 UTC") + end + end + + context "when date is not on a billing month and day is less than subscription day" do + let(:billing_at) { Time.zone.parse("4 Aug 2023") } + let(:subscription_at) { Time.zone.parse("6 Jan 2023") } + let(:current_usage) { true } + + it "returns the date in previous billing month" do + expect(result).to eq("2023-07-06 00:00:00 UTC") + end + end + end + end + + describe "to_datetime" do + let(:result) { date_service.to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the end of the previous half year" do + expect(result).to eq("2022-06-30 23:59:59 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-01-01 04:59:59 UTC") + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the half year" do + expect(result).to eq("2022-12-31 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("27 Jun 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("02 Nov 2021") } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns the day in the previous month" do + expect(result).to eq("2022-05-01 23:59:59 UTC") + end + + context "when billing last half year of the year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2021") } + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns the day in the previous month" do + expect(result).to eq("2022-02-01 23:59:59 UTC") + end + end + + context "when billing subscription day does not exist in the month" do + let(:subscription_at) { Time.zone.parse("30 Nov 2021") } + let(:billing_at) { Time.zone.parse("01 Jun 2022") } + + it "returns the last day of the previous month" do + expect(result).to eq("2022-05-29 23:59:59 UTC") + end + + context "when subscription is not the last day of the month" do + let(:subscription_at) { Time.zone.parse("30 Jan 2022") } + let(:billing_at) { Time.zone.parse("30 Jul 2022") } + + it "returns the last day of the month" do + expect(result).to eq("2022-07-29 23:59:59 UTC") + end + end + end + + context "when anniversary date is first day of the half year" do + let(:subscription_at) { Time.zone.parse("01 Oct 2021") } + let(:billing_at) { Time.zone.parse("02 Apr 2022") } + + it "returns the last day of the previous half year" do + expect(result).to eq("2022-03-31 23:59:59 UTC") + end + end + + context "when plan is pay in advance" do + before { plan.update!(pay_in_advance: true) } + + it "returns the end of the current period" do + expect(result).to eq("2022-11-01 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("30 Apr 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + describe "charges_from_datetime" do + let(:result) { date_service.charges_from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Jul 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + charges_to_datetime: "2021-12-31T23:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result).to match_datetime("2022-01-01 00:00:00") + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Apr 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("01 Jan 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_charges_monthly: true) } + + it "returns the begining of the previous month" do + expect(result).to eq("2022-06-01 00:00:00 UTC") + end + + context "when subscription started in the middle of a period" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + end + + context "when plan has fixed_charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :semiannual, pay_in_advance:, bill_fixed_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns charges_from_datetime" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when charges should not be billed" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does not return charges_from_datetime" do + expect(result).to eq("") + end + + context "when current_usage is true" do + let(:current_usage) { true } + + it "returns charges_from_datetime" do + expect(result).to eq("2023-01-01 00:00:00 UTC") + end + end + end + end + + context "when plan has charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :semiannual, pay_in_advance:, bill_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns charges_from_datetime" do + expect(result).to eq("2022-06-01 00:00:00 UTC") + end + end + + context "when charges should billed as monthly" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does return charges_from_datetime" do + expect(result).to eq("2023-01-01 00:00:00 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Aug 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 May 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "charges_to_datetime" do + let(:result) { date_service.charges_to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime.to_s) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Jun 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + + context "when billing charge monthly" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + + before { plan.update!(bill_charges_monthly: true) } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("05 Mar 2022") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated_at date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + it "returns the end of the current period" do + expect(result).to eq("2022-02-28 23:59:59 UTC") + end + end + end + + context "when plan has fixed_charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :semiannual, pay_in_advance:, bill_fixed_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns charges_to_datetime" do + expect(result).to eq("2022-06-30 23:59:59 UTC") + end + end + + context "when charges should not be billed" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does not return charges_to_datetime" do + expect(result).to eq("") + end + + context "when current_usage is true" do + let(:current_usage) { true } + + it "returns charges_to_datetime" do + expect(result).to eq("2023-06-30 23:59:59 UTC") + end + end + end + end + + context "when plan has charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :semiannual, pay_in_advance:, bill_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns charges_to_datetime" do + expect(result).to eq("2022-06-30 23:59:59 UTC") + end + end + + context "when charges should billed as monthly" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does return charges_to_datetime" do + expect(result).to eq("2023-01-31 23:59:59 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Apr 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + end + + describe "fixed_charges_from_datetime" do + let(:result) { date_service.fixed_charges_from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.fixed_charges_from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Jul 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + fixed_charges_to_datetime: "2021-12-31T23:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result).to match_datetime("2022-01-01 00:00:00") + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Apr 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("01 Jan 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when billing fixed charges monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the begining of the previous month" do + expect(result).to eq("2022-06-01 00:00:00 UTC") + end + + context "when subscription started in the middle of a period" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when its the next month" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "returns the beginnig of the previous month" do + expect(result.to_s).to eq("2022-01-01 00:00:00 UTC") + end + end + end + + context "when billing charges monthly" do + before { plan.update!(bill_charges_monthly: true) } + + context "when fixed_charges should be billed(first period)" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the fixed_charge date" do + expect(result.to_s).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when fixed_charges should not be billed" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "does not return the fixed_charge date" do + expect(date_service.fixed_charges_from_datetime).to be_nil + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Aug 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 May 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "fixed_charges_to_datetime" do + let(:result) { date_service.fixed_charges_to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.fixed_charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime.to_s) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Jun 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + + context "when billing fixed charges monthly" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("05 Mar 2022") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated_at date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + it "returns the end of the current period" do + expect(result).to eq("2022-02-28 23:59:59 UTC") + end + end + + context "when its the next month" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "returns the end of the previous month" do + expect(result.to_s).to eq("2022-01-31 23:59:59 UTC") + end + end + end + + context "when billing charges monthly" do + before { plan.update!(bill_charges_monthly: true) } + + context "when billing first period" do + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the fixed_charge date" do + expect(result.to_s).to eq("2022-06-30 23:59:59 UTC") + end + end + + context "when billing run for charges only" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "does not return the fixed_charge date" do + expect(date_service.fixed_charges_to_datetime).to be_nil + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 May 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("15 Apr 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + end + + describe "next_end_of_period" do + let(:result) { date_service.next_end_of_period.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("02 Jul 2022") } + + it "returns the last day of the month" do + expect(result).to eq("2022-12-31 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2023-01-01 04:59:59 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("07 May 2022") } + + it "returns the end of the billing month" do + expect(result).to eq("2022-11-01 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-11-01 03:59:59 UTC") + end + end + + context "when end of billing month is in next year" do + let(:billing_at) { Time.zone.parse("02 Nov 2021") } + + it { expect(result).to eq("2022-05-01 23:59:59 UTC") } + end + + context "when date is the end of the period" do + let(:billing_at) { Time.zone.parse("01 May 2022") } + + it "returns the date" do + expect(result).to eq(billing_at.utc.end_of_day.to_s) + end + end + end + end + + describe "previous_beginning_of_period" do + let(:result) { date_service.previous_beginning_of_period(current_period:).to_s } + + let(:current_period) { false } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("02 Jul 2022") } + + it "returns the first day of the previous month" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-01-01 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the first day of the month" do + expect(result).to eq("2022-07-01 00:00:00 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("03 Aug 2022") } + + it "returns the beginning of the previous period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-01 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the beginning of the current period" do + expect(result).to eq("2022-08-02 00:00:00 UTC") + end + end + end + end + + describe "single_day_price" do + let(:result) { date_service.single_day_price } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("01 Jan 2022") } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(181)) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("01 Jan 2020") } + let(:billing_at) { Time.zone.parse("01 Jul 2020") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(182)) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("01 Jan 2024") } + let(:billing_at) { Time.zone.parse("01 Jul 2024") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(182)) + end + + context "when not on a leap year" do + let(:subscription_at) { Time.zone.parse("01 Jan 2023") } + let(:billing_at) { Time.zone.parse("01 Jul 2023") } + + it "returns the month duration" do + expect(result).to eq(plan.amount_cents.fdiv(181)) + end + end + end + end + + describe "charges_duration_in_days" do + let(:result) { date_service.charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the quarter duration" do + expect(result).to eq(181) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Jul 2020") } + + it "returns the duration in days" do + expect(result).to eq(182) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(30) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("01 Jan 2024") } + let(:billing_at) { Time.zone.parse("01 Jul 2024") } + + it "returns the month duration" do + expect(result).to eq(182) + end + + context "when not on a leap year" do + let(:subscription_at) { Time.zone.parse("01 Jan 2023") } + let(:billing_at) { Time.zone.parse("01 Jul 2023") } + + it "returns the duration in days" do + expect(result).to eq(181) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(30) + end + end + end + end + + describe "fixed_charges_duration_in_days" do + let(:result) { date_service.fixed_charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jul 2022") } + + it "returns the quarter duration" do + expect(result).to eq(181) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Jul 2020") } + + it "returns the duration in days" do + expect(result).to eq(182) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(30) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("01 Jan 2024") } + let(:billing_at) { Time.zone.parse("01 Jul 2024") } + + it "returns the month duration" do + expect(result).to eq(182) + end + + context "when not on a leap year" do + let(:subscription_at) { Time.zone.parse("01 Jan 2023") } + let(:billing_at) { Time.zone.parse("01 Jul 2023") } + + it "returns the duration in days" do + expect(result).to eq(181) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(30) + end + end + end + end + + describe "first_month_in_semiannual_period?" do + let(:result) { date_service.first_month_in_semiannual_period? } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + context "when billing month is January" do + let(:billing_at) { Time.zone.parse("15 Jan 2022") } + + it "returns true" do + expect(result).to be true + end + end + + context "when billing month is July" do + let(:billing_at) { Time.zone.parse("15 Jul 2022") } + + it "returns true" do + expect(result).to be true + end + end + + context "when billing month is not January or July" do + let(:billing_at) { Time.zone.parse("15 Mar 2022") } + + it "returns false" do + expect(result).to be false + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("10 Feb 2021") } + + context "when billing month matches subscription month" do + let(:billing_at) { Time.zone.parse("15 Feb 2022") } + + it "returns true" do + expect(result).to be true + end + end + + context "when billing month is 6 months after subscription month" do + let(:billing_at) { Time.zone.parse("15 Aug 2022") } + + it "returns true" do + expect(result).to be true + end + end + + context "when billing month doesn't match subscription month pattern" do + let(:billing_at) { Time.zone.parse("15 Apr 2022") } + + it "returns false" do + expect(result).to be false + end + end + end + end + + describe "first_month_in_first_semiannual_period?" do + let(:result) { date_service.first_month_in_first_semiannual_period? } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("15 Mar 2021") } + + context "when in January of subscription year" do + let(:billing_at) { Time.zone.parse("15 Jan 2021") } + + it "returns true" do + expect(result).to be true + end + end + + context "when in July of subscription year" do + let(:billing_at) { Time.zone.parse("15 Jul 2021") } + + it "returns true" do + expect(result).to be true + end + end + + context "when in January but not in subscription year" do + let(:billing_at) { Time.zone.parse("15 Jan 2022") } + + it "returns false" do + expect(result).to be false + end + end + + context "when not in January or July" do + let(:billing_at) { Time.zone.parse("15 Mar 2021") } + + it "returns false" do + expect(result).to be false + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:subscription_at) { Time.zone.parse("10 Feb 2021") } + + context "when billing month and year match subscription month and year" do + let(:billing_at) { Time.zone.parse("15 Feb 2021") } + + it "returns true" do + expect(result).to be true + end + end + + context "when billing month matches but year doesn't" do + let(:billing_at) { Time.zone.parse("15 Feb 2022") } + + it "returns false" do + expect(result).to be false + end + end + + context "when billing month is 6 months after subscription month in same year" do + let(:billing_at) { Time.zone.parse("15 Aug 2021") } + + it "returns true" do + expect(result).to be false + end + end + + context "when billing month is 6 months after subscription month but in next year" do + let(:billing_at) { Time.zone.parse("15 Aug 2022") } + + it "returns false" do + expect(result).to be false + end + end + end + end +end diff --git a/spec/services/subscriptions/dates/weekly_service_spec.rb b/spec/services/subscriptions/dates/weekly_service_spec.rb new file mode 100644 index 0000000..8b808bd --- /dev/null +++ b/spec/services/subscriptions/dates/weekly_service_spec.rb @@ -0,0 +1,734 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::Dates::WeeklyService do + subject(:date_service) { described_class.new(subscription, billing_at, false) } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at:, + billing_time:, + started_at: + ) + end + + let(:customer) { create(:customer, timezone:) } + let(:plan) { create(:plan, interval: :weekly, pay_in_advance:) } + let(:pay_in_advance) { false } + + let(:subscription_at) { Time.zone.parse("02 Feb 2021") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + let(:started_at) { subscription_at } + let(:timezone) { "UTC" } + + describe "from_datetime" do + let(:result) { date_service.from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the beginning of the previous week" do + expect(result).to eq("2022-02-28 00:00:00 UTC") + expect(Time.zone.parse(result).wday).to eq(1) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-21 05:00:00 UTC") + end + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("01 Mar 2022 05:00:00") } + + it "returns the start date" do + expect(result).to eq(started_at.beginning_of_day.utc.to_s) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + end + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Mar 2022") } + + before { subscription.mark_as_terminated!("9 Mar 2022") } + + it "returns the beginning of the week" do + expect(result).to eq("2022-03-07 00:00:00 UTC") + expect(Time.zone.parse(result).wday).to eq(1) + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the beginning of the current week" do + expect(result).to eq("2022-03-07 00:00:00 UTC") + expect(Time.zone.parse(result).wday).to eq(1) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("09 Mar 2022") } + + it "returns the previous week week day" do + expect(result).to eq("2022-03-01 00:00:00 UTC") + expect(Time.zone.parse(result).wday).to eq(subscription_at.wday) + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("08 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + expect(Time.zone.parse(result).wday).to eq(subscription_at.wday) + end + end + + context "when subscription is just terminated" do + before { subscription.mark_as_terminated!("8 Mar 2022") } + + it "returns the previous week day" do + expect(result).to eq("2022-03-08 00:00:00 UTC") + expect(Time.zone.parse(result).wday).to eq(subscription_at.wday) + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the current week week day" do + expect(result).to eq("2022-03-08 00:00:00 UTC") + expect(Time.zone.parse(result).wday).to eq(subscription_at.wday) + end + end + end + end + end + + describe "to_datetime" do + let(:result) { date_service.to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + it "returns the end of the previous week" do + expect(result).to eq("2022-03-06 23:59:59 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-28 04:59:59 UTC") + end + end + + context "when plan is pay in advance" do + before { plan.update!(pay_in_advance: true) } + + it "returns the end of the week" do + expect(result).to eq("2022-03-13 23:59:59 UTC") + expect(Time.zone.parse(result).wday).to eq(0) + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + let(:terminated_at) { Time.zone.parse("02 Mar 2022") } + + before do + subscription.update!( + status: :terminated, + terminated_at: + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("09 Mar 2022") } + + it "returns the previous week week day" do + expect(result).to eq("2022-03-07 23:59:59 UTC") + end + + context "when plan is pay in advance" do + before { plan.update!(pay_in_advance: true) } + + it "returns the end of the current period" do + expect(result).to eq("2022-03-14 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("02 Mar 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + describe "charges_from_datetime" do + let(:result) { date_service.charges_from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns from_date" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("08 Mar 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + charges_to_datetime: "2022-02-27T22:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result).to match_datetime("2022-02-27 23:00:00") + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the start of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.week).to_s) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("09 Mar 2022") } + + it "returns from_date" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the start of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.week).to_s) + end + end + end + end + + describe "charges_to_datetime" do + let(:result) { date_service.charges_to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime.to_s) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("06 Mar 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("09 Mar 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("06 Mar 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + end + + describe "#fixed_charges_from_datetime" do + subject(:result) { date_service.fixed_charges_from_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account and returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("08 Mar 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + fixed_charges_to_datetime: "2022-02-27T22:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account and returns from_datetime" do + expect(result.to_s).to match_datetime("2022-02-27 23:00:00") + end + end + + context "when timezone has changed and there are no invoices generated in the past" do + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + before do + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes calculates correct datetime" do + expect(result).to eq(date_service.from_datetime) + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the start of the previous period" do + expect(result).to eq(date_service.from_datetime - 1.week) + end + end + + context "when previous subscription is upgraded" do + let(:started_at) { Time.zone.parse("2024-02-22T16:13:00") } + + before do + create( + :subscription, + :terminated, + terminated_at: started_at + ) + end + + it "returns the beginning of the start date" do + expect(result).to eq(subscription.started_at.utc) # boundary should be terminated_at + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("09 Mar 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the start of the previous period" do + expect(result).to eq(date_service.from_datetime - 1.week) + end + end + end + end + + describe "#fixed_charges_to_datetime" do + subject(:result) { date_service.fixed_charges_to_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("06 Mar 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + + context "when subscription was upgraded" do + before do + create( + :subscription, + started_at: terminated_at, + previous_subscription: subscription + ) + end + + it "returns the terminated_at" do + expect(result).to eq(subscription.terminated_at) + end + + context "when end of previous day is before fixed_charges_from_datetime" do + let(:started_at) { Time.zone.parse("2022-03-06T10:23:00") } + let(:terminated_at) { Time.zone.parse("2022-03-06T12:23:00") } + + it "returns the fixed_charges_from_datetime" do + expect(result).to eq(subscription.terminated_at) + end + end + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("09 Mar 2022") } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("6 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + end + end + + describe "next_end_of_period" do + let(:result) { date_service.next_end_of_period.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the last day of the week" do + expect(result).to eq("2022-03-13 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-03-07 04:59:59 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("08 Mar 2022 20:00:00") } + + it "returns the end of the billing week" do + expect(result).to eq("2022-03-14 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-03-14 03:59:59 UTC") + end + end + + context "when date is the end of the period" do + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + it "returns the date" do + expect(result).to eq(billing_at.utc.end_of_day.to_s) + end + end + end + end + + describe "compute_previous_beginning_of_period" do + let(:result) { date_service.previous_beginning_of_period(current_period:).to_s } + + let(:current_period) { false } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the first day of the previous week" do + expect(result).to eq("2022-02-28 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-21 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the first day of the week" do + expect(result).to eq("2022-03-07 00:00:00 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the beginning of the previous period" do + expect(result).to eq("2022-02-22 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2022-02-21 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the beginning of the current period" do + expect(result).to eq("2022-03-01 00:00:00 UTC") + end + end + end + end + + describe "single_day_price" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("08 Mar 2022") } + let(:result) { date_service.single_day_price } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(7)) + end + end + + describe "charges_duration_in_days" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("08 Mar 2022") } + let(:result) { date_service.charges_duration_in_days } + + it "returns the duration of the period" do + expect(result).to eq(7) + end + end + + describe "#fixed_charges_duration_in_days" do + subject(:result) { date_service.fixed_charges_duration_in_days } + + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("08 Mar 2022") } + + it "returns the duration of the period" do + expect(result).to eq(7) + end + end +end diff --git a/spec/services/subscriptions/dates/yearly_service_spec.rb b/spec/services/subscriptions/dates/yearly_service_spec.rb new file mode 100644 index 0000000..cf6f181 --- /dev/null +++ b/spec/services/subscriptions/dates/yearly_service_spec.rb @@ -0,0 +1,1140 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::Dates::YearlyService do + subject(:date_service) { described_class.new(subscription, billing_at, current_usage) } + + let(:subscription) do + create( + :subscription, + plan:, + customer:, + subscription_at:, + billing_time:, + started_at: + ) + end + + let(:customer) { create(:customer, timezone:) } + let(:plan) { create(:plan, interval: :yearly, pay_in_advance:) } + let(:pay_in_advance) { false } + let(:current_usage) { false } + + let(:subscription_at) { Time.zone.parse("02 Feb 2021") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + let(:started_at) { subscription_at } + let(:timezone) { "UTC" } + + describe "from_datetime" do + let(:result) { date_service.from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + + it "returns the beginning of the previous year" do + expect(result).to eq("2021-01-01 00:00:00 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2020-01-01 05:00:00 UTC") + end + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("07 Feb 2021") } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the start date" do + expect(result).to eq("2021-02-06 05:00:00 UTC") + end + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Mar 2022") } + + before { subscription.mark_as_terminated!("9 Mar 2022") } + + it "returns the beginning of the year" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the beginning of the current year" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns the previous year day and month" do + expect(result).to eq("2021-02-02 00:00:00 UTC") + end + + context "when current usage is true and current month is the same as starting month" do + let(:current_usage) { true } + let(:subscription_at) { Time.zone.parse("29 Mar 2023") } + let(:billing_at) { Time.zone.parse("15 Mar 2024") } + + it "returns the previous year day and month" do + expect(result).to eq("2023-03-29 00:00:00 UTC") + end + end + + context "when date is before the start date" do + let(:started_at) { Time.zone.parse("02 Sep 2022") } + + it "returns the start date" do + expect(result).to eq(started_at.utc.to_s) + end + end + + context "when subscription is just terminated" do + before { subscription.mark_as_terminated!("1 Feb 2022") } + + it "returns the previous year day" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the current year day and month" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + end + + context "when subscription date on 29/02 of a leap year" do + let(:subscription_at) { Time.zone.parse("29 Feb 2020") } + let(:billing_at) { Time.zone.parse("28 Mar 2022") } + + it "returns the previous month last day" do + expect(result).to eq("2022-02-28 00:00:00 UTC") + end + end + + context "when billing month is before subscription month" do + let(:billing_at) { Time.zone.parse("03 Jan 2022") } + + it "returns the previous year day" do + expect(result).to eq("2021-02-02 00:00:00 UTC") + end + end + end + end + end + + describe "to_datetime" do + let(:result) { date_service.to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the end of the previous year" do + expect(result).to eq("2021-12-31 23:59:59 UTC") + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2021-01-01 04:59:59 UTC") + end + end + + context "when plan is pay in advance" do + before { plan.update!(pay_in_advance: true) } + + it "returns the end of the currrent year" do + expect(result).to eq("2022-12-31 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + let(:billing_at) { Time.zone.parse("10 Mar 2022") } + + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("02 Mar 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns the previous year day and month" do + expect(result).to eq("2022-02-01 23:59:59 UTC") + end + + context "when subscription date on 29/02 of a leap year" do + let(:subscription_at) { Time.zone.parse("29 Feb 2020") } + let(:billing_at) { Time.zone.parse("01 Mar 2022") } + + it "returns the previous month last day" do + expect(result).to eq("2022-02-28 23:59:59 UTC") + end + end + + context "when anniversary date is first day of the year" do + let(:subscription_at) { Time.zone.parse("01 Jan 2021") } + let(:billing_at) { Time.zone.parse("02 Mar 2022") } + + it "returns the last day of the year" do + expect(result).to eq("2021-12-31 23:59:59 UTC") + end + end + + context "when anniversary date is first day of a month" do + let(:subscription_at) { Time.zone.parse("01 Dec 2022") } + let(:billing_at) { Time.zone.parse("02 Jan 2024") } + + it "returns the last day of the previous month on next year" do + expect(result).to eq("2023-11-30 23:59:59 UTC") + end + end + + context "when plan is pay in advance" do + before { plan.update!(pay_in_advance: true) } + + it "returns the end of the current period" do + expect(result).to eq("2023-02-01 23:59:59 UTC") + end + end + + context "when subscription is just terminated" do + before do + subscription.update!( + status: :terminated, + terminated_at: Time.zone.parse("02 Jan 2022") + ) + end + + it "returns the termination date" do + expect(result).to match_datetime(subscription.terminated_at.utc) + end + end + end + end + + describe "charges_from_datetime" do + let(:result) { date_service.charges_from_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jan 2023") } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns from_date" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_from_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Jan 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + charges_to_datetime: "2020-12-31T22:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result).to match_datetime("2020-12-31 23:00:00") + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_charges_monthly: true) } + + it "returns the begining of the previous month" do + expect(result).to eq("2022-12-01 00:00:00 UTC") + end + + context "when subscription started in the middle of a period" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns from_date" do + expect(result).to eq(date_service.from_datetime.to_s) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result).to eq("2021-02-02 00:00:00 UTC") + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_charges_monthly: true) } + + it "returns the begining of the previous monthly period" do + expect(result).to eq("2022-01-02 00:00:00 UTC") + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc.to_s) + end + end + end + end + + context "when plan has fixed_charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :yearly, pay_in_advance:, bill_fixed_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jan 2023") } + + it "returns charges_from_datetime" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when charges should not be billed" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does not return charges_from_datetime" do + expect(result).to eq("") + end + + context "when current_usage is true" do + let(:current_usage) { true } + + it "returns charges_from_datetime" do + expect(result).to eq("2023-01-01 00:00:00 UTC") + end + end + end + end + + context "when plan has charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :yearly, pay_in_advance:, bill_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jan 2023") } + + it "returns charges_from_datetime" do + expect(result).to eq("2022-12-01 00:00:00 UTC") + end + end + + context "when charges should billed as monthly" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does return charges_from_datetime" do + expect(result).to eq("2023-01-01 00:00:00 UTC") + end + end + end + end + + describe "charges_to_datetime" do + let(:result) { date_service.charges_to_datetime.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(date_service.charges_to_datetime).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime.to_s) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("06 Mar 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + + context "when billing charge monthly" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + + before { plan.update!(bill_charges_monthly: true) } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("05 Mar 2022") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated_at date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + it "returns the end of the current period" do + expect(result).to eq("2022-02-28 23:59:59 UTC") + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime.to_s) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("6 Jan 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc.to_s) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day.to_s) + end + end + end + + context "when plan has fixed_charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :yearly, pay_in_advance:, bill_fixed_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jan 2023") } + + it "returns charges_to_datetime" do + expect(result).to eq("2022-12-31 23:59:59 UTC") + end + end + + context "when charges should not be billed" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does not return charges_to_datetime" do + expect(result).to eq("") + end + + context "when current_usage is true" do + let(:current_usage) { true } + + it "returns charges_to_datetime" do + expect(result).to eq("2023-12-31 23:59:59 UTC") + end + end + end + end + + context "when plan has charges monthly" do + let(:billing_time) { :calendar } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:plan) { create(:plan, interval: :yearly, pay_in_advance:, bill_charges_monthly: true) } + + context "when charges should be billed" do + let(:billing_at) { Time.zone.parse("01 Jan 2023") } + + it "returns charges_to_datetime" do + expect(result).to eq("2022-12-31 23:59:59 UTC") + end + end + + context "when charges should billed as monthly" do + let(:billing_at) { Time.zone.parse("01 Feb 2023") } + + it "does return charges_to_datetime" do + expect(result).to eq("2023-01-31 23:59:59 UTC") + end + end + end + end + + describe "#fixed_charges_from_datetime" do + subject(:result) { date_service.fixed_charges_from_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + let(:billing_at) { Time.zone.parse("01 Jan 2023") } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account and returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when timezone has changed" do + let(:billing_at) { Time.zone.parse("02 Jan 2022") } + + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + fixed_charges_to_datetime: "2020-12-31T22:59:59Z" + ) + end + + before do + previous_invoice_subscription + subscription.customer.update!(timezone: "America/Los_Angeles") + end + + it "takes previous invoice into account" do + expect(result.to_s).to match_datetime("2020-12-31 23:00:00") + end + end + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result.to_s).to eq("2022-01-01 00:00:00 UTC") + end + end + + context "when billing fixed charges monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the begining of the previous month" do + expect(result).to eq("2022-12-01 00:00:00 UTC") + end + + context "when subscription started in the middle of a period" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when its the next month" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "returns the beginnig of the previous month" do + expect(result.to_s).to eq("2022-01-01 00:00:00 UTC") + end + end + end + + context "when billing charges monthly" do + before { plan.update!(bill_charges_monthly: true) } + + context "when fixed_charges should be billed(first period)" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + + it "returns the fixed_charge date" do + expect(result.to_s).to eq("2021-01-01 00:00:00 UTC") + end + end + + context "when fixed_charges should not be billed" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "does not return the fixed_charge date" do + expect(result).to eq(nil) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns from_datetime" do + expect(result).to eq(date_service.from_datetime) + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + + it "returns the start of the previous period" do + expect(result.to_s).to eq("2021-02-02 00:00:00 UTC") + end + end + + context "when billing fixed charges monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the begining of the previous monthly period" do + expect(result).to eq("2022-01-02 00:00:00 UTC") + end + + context "when subscription started in the middle of a period" do + let(:started_at) { Time.zone.parse("03 Mar 2022") } + + it "returns the start date" do + expect(result).to eq(subscription.started_at.utc) + end + end + end + end + end + + describe "#fixed_charges_to_datetime" do + subject(:result) { date_service.fixed_charges_to_datetime } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is not yet started" do + let(:started_at) { nil } + + it "returns nil" do + expect(result).to be_nil + end + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq(date_service.to_datetime) + end + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("06 Mar 2022") } + + before do + subscription.update!(status: :terminated, terminated_at:) + end + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + + context "when billing fixed charges monthly" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns to_date" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("05 Mar 2022") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated_at date" do + expect(result).to eq(subscription.terminated_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + let(:subscription_at) { Time.zone.parse("02 Feb 2020") } + let(:billing_at) { Time.zone.parse("07 Mar 2022") } + + it "returns the end of the current period" do + expect(result.to_s).to eq("2022-02-28 23:59:59 UTC") + end + end + + context "when its the next month" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "returns the end of the previous month" do + expect(result.to_s).to eq("2022-01-31 23:59:59 UTC") + end + end + end + + context "when billing charges monthly" do + before { plan.update!(bill_charges_monthly: true) } + + context "when billing first period" do + let(:billing_at) { Time.zone.parse("01 Jan 2022") } + + it "returns the fixed_charge date" do + expect(result.to_s).to eq("2021-12-31 23:59:59 UTC") + end + end + + context "when billing run for charges only" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "does not return the fixed_charge date" do + expect(result).to eq(nil) + end + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + let(:billing_at) { Time.zone.parse("02 Feb 2022") } + + it "returns to_datetime" do + expect(result).to eq(date_service.to_datetime) + end + + context "when subscription is terminated in the middle of a period" do + let(:terminated_at) { Time.zone.parse("6 Jan 2022") } + + before { subscription.mark_as_terminated!(terminated_at) } + + it "returns the terminated date" do + expect(result).to eq(subscription.terminated_at.utc) + end + end + + context "when plan is pay in advance" do + let(:pay_in_advance) { true } + + it "returns the end of the previous period" do + expect(result).to eq((date_service.from_datetime - 1.day).end_of_day) + end + end + end + end + + describe "next_end_of_period" do + let(:result) { date_service.next_end_of_period.to_s } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the last day of the year" do + expect(result).to eq("2022-12-31 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2023-01-01 04:59:59 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the end of the billing year" do + expect(result).to eq("2023-02-01 23:59:59 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2023-02-01 04:59:59 UTC") + end + end + + context "when date is the end of the period" do + let(:billing_at) { Time.zone.parse("01 Feb 2022") } + + it "returns the date" do + expect(result).to eq(billing_at.utc.end_of_day.to_s) + end + end + end + end + + describe "compute_previous_beginning_of_period" do + let(:result) { date_service.previous_beginning_of_period(current_period:).to_s } + + let(:current_period) { false } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the first day of the previous year" do + expect(result).to eq("2021-01-01 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2021-01-01 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the first day of the year" do + expect(result).to eq("2022-01-01 00:00:00 UTC") + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the beginning of the previous period" do + expect(result).to eq("2021-02-02 00:00:00 UTC") + end + + context "with customer timezone" do + let(:timezone) { "America/New_York" } + + it "takes customer timezone into account" do + expect(result).to eq("2021-02-01 05:00:00 UTC") + end + end + + context "with current period argument" do + let(:current_period) { true } + + it "returns the beginning of the current period" do + expect(result).to eq("2022-02-02 00:00:00 UTC") + end + end + end + end + + describe "single_day_price" do + let(:result) { date_service.single_day_price } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(365)) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Jan 2021") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(366)) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(365)) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("08 Mar 2021") } + + it "returns the price of single day" do + expect(result).to eq(plan.amount_cents.fdiv(366)) + end + end + end + end + + describe "charges_duration_in_days" do + let(:result) { date_service.charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the year duration" do + expect(result).to eq(365) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Jan 2021") } + + it "returns the year duration" do + expect(result).to eq(366) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(28) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the year duration" do + expect(result).to eq(365) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("08 Mar 2021") } + + it "returns the year duration" do + expect(result).to eq(366) + end + end + + context "when billing charge monthly" do + before { plan.update!(bill_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(28) + end + end + end + end + + describe "#fixed_charges_duration_in_days" do + subject(:result) { date_service.fixed_charges_duration_in_days } + + context "when billing_time is calendar" do + let(:billing_time) { :calendar } + + it "returns the year duration" do + expect(result).to eq(365) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("28 Feb 2019") } + let(:billing_at) { Time.zone.parse("01 Jan 2021") } + + it "returns the year duration" do + expect(result).to eq(366) + end + end + + context "when billing fixed charges monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(28) + end + end + end + + context "when billing_time is anniversary" do + let(:billing_time) { :anniversary } + + it "returns the year duration" do + expect(result).to eq(365) + end + + context "when on a leap year" do + let(:subscription_at) { Time.zone.parse("02 Feb 2019") } + let(:billing_at) { Time.zone.parse("08 Mar 2021") } + + it "returns the year duration" do + expect(result).to eq(366) + end + end + + context "when billing fixed charges monthly" do + before { plan.update!(bill_fixed_charges_monthly: true) } + + it "returns the month duration" do + expect(result).to eq(28) + end + end + end + end +end diff --git a/spec/services/subscriptions/dates_service_spec.rb b/spec/services/subscriptions/dates_service_spec.rb new file mode 100644 index 0000000..508eb44 --- /dev/null +++ b/spec/services/subscriptions/dates_service_spec.rb @@ -0,0 +1,229 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::DatesService do + subject(:date_service) { described_class.new(subscription, billing_date, false) } + + let(:subscription) do + create( + :subscription, + plan:, + subscription_at:, + billing_time: :anniversary, + started_at: + ) + end + + let(:plan) { create(:plan, interval:, pay_in_advance:) } + let(:pay_in_advance) { false } + + let(:subscription_at) { DateTime.parse("02 Feb 2021") } + let(:billing_date) { Time.zone.parse("2022-03-07 04:20:46.011") } + let(:started_at) { subscription_at } + let(:interval) { :monthly } + + describe "#instance" do + let(:result) { described_class.new_instance(subscription, billing_date) } + + context "when interval is weekly" do + let(:interval) { :weekly } + + it "returns a weekly service instance" do + expect(result).to be_a(Subscriptions::Dates::WeeklyService) + end + end + + context "when interval is quarterly" do + let(:interval) { :quarterly } + + it "returns a quarterly service instance" do + expect(result).to be_a(Subscriptions::Dates::QuarterlyService) + end + end + + context "when interval is monthly" do + let(:interval) { :monthly } + + it "returns a monthly service instance" do + expect(result).to be_a(Subscriptions::Dates::MonthlyService) + end + end + + context "when interval is yearly" do + let(:interval) { :yearly } + + it "returns a yearly service instance" do + expect(result).to be_a(Subscriptions::Dates::YearlyService) + end + end + + context "when interval is semiannual" do + let(:interval) { :semiannual } + + it "returns a semiannual service instance" do + expect(result).to be_a(Subscriptions::Dates::SemiannualService) + end + end + + context "when interval is invalid" do + let(:interval) { :weekly } + + before do + allow(plan).to receive(:interval).and_return(:foo) + end + + it "raises a not implemented error" do + expect { result }.to raise_error(NotImplementedError) + + expect(plan).to have_received(:interval) + end + end + end + + describe "from_datetime" do + it "raises a not implemented error" do + expect { date_service.from_datetime } + .to raise_error(NotImplementedError) + end + end + + describe "to_datetime" do + it "raises a not implemented error" do + expect { date_service.to_datetime } + .to raise_error(NotImplementedError) + end + end + + describe "charges_from_datetime" do + it "raises a not implemented error" do + expect { date_service.charges_from_datetime } + .to raise_error(NotImplementedError) + end + end + + describe "charges_to_datetime" do + it "raises a not implemented error" do + expect { date_service.charges_to_datetime } + .to raise_error(NotImplementedError) + end + end + + describe "fixed_charges_from_datetime" do + it "raises a not implemented error" do + expect { date_service.fixed_charges_from_datetime } + .to raise_error(NotImplementedError) + end + end + + describe "fixed_charges_to_datetime" do + it "raises a not implemented error" do + expect { date_service.fixed_charges_to_datetime } + .to raise_error(NotImplementedError) + end + end + + describe "next_end_of_period" do + it "raises a not implemented error" do + expect { date_service.next_end_of_period } + .to raise_error(NotImplementedError) + end + end + + describe "previous_beginning_of_period" do + it "raises a not implemented error" do + expect { date_service.previous_beginning_of_period } + .to raise_error(NotImplementedError) + end + end + + describe "charges_duration_in_days" do + it "raises a not implemented error" do + expect { date_service.charges_duration_in_days } + .to raise_error(NotImplementedError) + end + end + + describe "fixed_charges_duration_in_days" do + it "raises a not implemented error" do + expect { date_service.fixed_charges_duration_in_days } + .to raise_error(NotImplementedError) + end + end + + describe ".fixed_charge_pay_in_advance_interval" do + let(:timestamp) { Time.zone.parse("2022-03-07 04:20:46.011").to_i } + let(:result) { described_class.fixed_charge_pay_in_advance_interval(timestamp, subscription) } + # subscription is anniversary, subscription_at is 02 Feb 2021, Tuesday + + context "when interval is monthly" do + let(:interval) { :monthly } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-03-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-04-01").utc.end_of_day, + fixed_charges_duration: 31 + ) + end + + it "creates a date service instance with current_usage: true" do + allow(described_class).to receive(:new_instance).and_call_original + + result + + expect(described_class).to have_received(:new_instance) + .with(subscription, Time.zone.at(timestamp), current_usage: true) + end + end + + context "when interval is yearly" do + let(:interval) { :yearly } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-02-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2023-02-01").utc.end_of_day, + fixed_charges_duration: 365 + ) + end + end + + context "when interval is semiannual" do + let(:interval) { :semiannual } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-02-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-08-01").utc.end_of_day, + fixed_charges_duration: 181 + ) + end + end + + context "when interval is quarterly" do + let(:interval) { :quarterly } + + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-02-02").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-05-01").utc.end_of_day, + fixed_charges_duration: 89 + ) + end + end + + context "when interval is weekly" do + let(:interval) { :weekly } + + # 2022-03-01 is Tuesday + it "returns the correct fixed charge interval data" do + expect(result).to include( + fixed_charges_from_datetime: Time.parse("2022-03-01").utc.beginning_of_day, + fixed_charges_to_datetime: Time.parse("2022-03-07").utc.end_of_day, + fixed_charges_duration: 7 + ) + end + end + end +end diff --git a/spec/services/subscriptions/emit_fixed_charge_events_service_spec.rb b/spec/services/subscriptions/emit_fixed_charge_events_service_spec.rb new file mode 100644 index 0000000..ac8e012 --- /dev/null +++ b/spec/services/subscriptions/emit_fixed_charge_events_service_spec.rb @@ -0,0 +1,231 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::EmitFixedChargeEventsService do + subject(:service) { described_class.new(subscriptions:, timestamp:) } + + let(:timestamp) { Time.current } + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + + let(:fixed_charge_1) { create(:fixed_charge, plan:, add_on:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:, add_on:) } + + let(:subscription_1) { create(:subscription, :active, plan:) } + let(:subscription_2) { create(:subscription, :active, plan:) } + let(:subscriptions) { [subscription_1, subscription_2] } + + let(:fixed_charge_event_create_service) { FixedChargeEvents::CreateService } + + before do + fixed_charge_1 + fixed_charge_2 + allow(fixed_charge_event_create_service).to receive(:call!) + end + + describe "#call" do + subject(:result) { service.call } + + it "calls FixedChargeEvents::CreateService for each subscription and fixed charge" do + expect(result).to be_success + + expect(fixed_charge_event_create_service).to have_received(:call!).exactly(4).times + + expect(fixed_charge_event_create_service).to have_received(:call!).with( + subscription: subscription_1, + fixed_charge: fixed_charge_1, + timestamp: + ).once + + expect(fixed_charge_event_create_service).to have_received(:call!).with( + subscription: subscription_1, + fixed_charge: fixed_charge_2, + timestamp: + ).once + + expect(fixed_charge_event_create_service).to have_received(:call!).with( + subscription: subscription_2, + fixed_charge: fixed_charge_1, + timestamp: + ).once + + expect(fixed_charge_event_create_service).to have_received(:call!).with( + subscription: subscription_2, + fixed_charge: fixed_charge_2, + timestamp: + ).once + end + + context "when subscriptions have no fixed charges" do + let(:plan_without_fixed_charges) { create(:plan, organization:) } + let(:subscription_without_fixed_charges) { create(:subscription, :active, plan: plan_without_fixed_charges) } + let(:subscriptions) { [subscription_without_fixed_charges] } + + it "does not call the emit service" do + expect(result).to be_success + expect(fixed_charge_event_create_service).not_to have_received(:call!) + end + end + + context "when fixed charges already have events emitted on the same date" do + before do + create( + :fixed_charge_event, + subscription: subscription_1, + fixed_charge: fixed_charge_1, + timestamp: + ) + end + + it "skips fixed charges that already have events and processes others" do + expect(result).to be_success + + expect(fixed_charge_event_create_service) + .not_to have_received(:call!) + .with( + subscription: subscription_1, + fixed_charge: fixed_charge_1, + timestamp: + ) + + expect(fixed_charge_event_create_service) + .to have_received(:call!) + .with( + subscription: subscription_1, + fixed_charge: fixed_charge_2, + timestamp: + ) + .once + + expect(fixed_charge_event_create_service) + .to have_received(:call!) + .with( + subscription: subscription_2, + fixed_charge: fixed_charge_1, + timestamp: + ) + .once + + expect(fixed_charge_event_create_service) + .to have_received(:call!) + .with( + subscription: subscription_2, + fixed_charge: fixed_charge_2, + timestamp: + ) + .once + end + end + + context "when fixed charge events exist on different dates" do + before do + create( + :fixed_charge_event, + subscription: subscription_1, + fixed_charge: fixed_charge_1, + timestamp: timestamp - 1.day + ) + end + + it "processes fixed charges that have events on different dates" do + expect(result).to be_success + + expect(fixed_charge_event_create_service) + .to have_received(:call!) + .with( + subscription: subscription_1, + fixed_charge: fixed_charge_1, + timestamp: + ) + .once + + expect(fixed_charge_event_create_service) + .to have_received(:call!).with( + subscription: subscription_1, + fixed_charge: fixed_charge_2, + timestamp: + ) + .once + end + end + + context "when customer has a timezone" do + let(:customer) { create(:customer, organization:, timezone: "America/New_York") } + let(:subscription) { create(:subscription, :active, plan:, customer:) } + let(:subscriptions) { [subscription] } + let(:timestamp) { Time.zone.parse("2025-09-05 12:00 UTC") } + let(:event_time) { Time.zone.parse("2025-09-05 02:00 UTC") } # Same day in NY timezone + + before do + create( + :fixed_charge_event, + subscription:, + fixed_charge: fixed_charge_1, + timestamp: event_time + ) + end + + it "handles timezone when checking for existing events" do + expect(result).to be_success + + expect(fixed_charge_event_create_service) + .not_to have_received(:call!) + .with( + subscription:, + fixed_charge: fixed_charge_1, + timestamp: + ) + + expect(fixed_charge_event_create_service) + .to have_received(:call!) + .with( + subscription:, + fixed_charge: fixed_charge_2, + timestamp: + ) + .once + end + end + + context "when billing entity has a timezone" do + let(:billing_entity) { create(:billing_entity, timezone: "America/New_York") } + let(:customer) { create(:customer, billing_entity:) } + let(:subscription) { create(:subscription, :active, plan:, customer:) } + let(:subscriptions) { [subscription] } + let(:timestamp) { Time.zone.parse("2025-09-05 12:00 UTC") } + let(:event_time) { Time.zone.parse("2025-09-05 02:00 UTC") } # Same day in NY timezone + + before do + create( + :fixed_charge_event, + subscription:, + fixed_charge: fixed_charge_1, + timestamp: event_time + ) + end + + it "handles timezone when checking for existing events" do + expect(result).to be_success + + expect(fixed_charge_event_create_service) + .not_to have_received(:call!) + .with( + subscription:, + fixed_charge: fixed_charge_1, + timestamp: + ) + + expect(fixed_charge_event_create_service) + .to have_received(:call!) + .with( + subscription:, + fixed_charge: fixed_charge_2, + timestamp: + ) + .once + end + end + end +end diff --git a/spec/services/subscriptions/flag_refreshed_service_spec.rb b/spec/services/subscriptions/flag_refreshed_service_spec.rb new file mode 100644 index 0000000..f5a1c19 --- /dev/null +++ b/spec/services/subscriptions/flag_refreshed_service_spec.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::FlagRefreshedService, :premium do + let(:organization) { create(:organization, premium_integrations: %w[lifetime_usage]) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + + before do + allow(UsageMonitoring::TrackSubscriptionActivityService).to receive(:call).and_call_original + create(:wallet, customer:, organization:) + end + + describe "#call" do + subject(:result) { described_class.call(subscription.id) } + + it "marks customer as awaiting wallet refresh" do + expect { subject }.to change { customer.reload.awaiting_wallet_refresh }.from(false).to(true) + expect(result).to be_success + end + + it "tracks subscription activity" do + subject + expect(result).to be_success + expect(subscription.subscription_activity).to be_present + expected_date = Time.current.in_time_zone(customer.applicable_timezone).to_date + expect(UsageMonitoring::TrackSubscriptionActivityService).to have_received(:call).with(subscription:, date: expected_date) + end + end +end diff --git a/spec/services/subscriptions/free_trial_billing_service_spec.rb b/spec/services/subscriptions/free_trial_billing_service_spec.rb new file mode 100644 index 0000000..e143ead --- /dev/null +++ b/spec/services/subscriptions/free_trial_billing_service_spec.rb @@ -0,0 +1,131 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::FreeTrialBillingService do + subject(:service) { described_class.new(timestamp:) } + + let(:timestamp) { Time.zone.now } + + describe "#call" do + let(:plan) { create(:plan, trial_period: 10, pay_in_advance: true) } + + context "with a plan witout trial period" do + it "does not set trial_ended_at" do + sub = create(:subscription, plan: create(:plan, trial_period: 0, pay_in_advance: true), started_at: 2.days.ago) + sub2 = create(:subscription, plan: create(:plan, pay_in_advance: true), started_at: 2.days.ago) + service.call + expect(sub.reload.trial_ended_at).to be_nil + expect(sub2.reload.trial_ended_at).to be_nil + end + end + + context "without any ending trial subscriptions" do + it "does not set trial_ended_at" do + sub1 = create(:subscription, plan:, started_at: 2.days.ago) + + expect { service.call }.not_to change { sub1.reload.trial_ended_at }.from(nil) + end + end + + context "with ending trial subscriptions" do + it "sets trial_ended_at to trial end date" do + sub = create(:subscription, plan:, started_at: Time.zone.parse("2024-04-05T12:12:00")) + sub2 = create(:subscription, plan:, started_at: 15.days.ago) + service.call + expect(sub.reload.trial_ended_at).to match_datetime(sub.trial_end_datetime) + expect(sub2.reload.trial_ended_at).to match_datetime(sub2.trial_end_datetime) + end + end + + context "with trial ended due to previous subscription with the same external_id" do + it "sets trial_ended_at" do + customer = create(:customer) + attr = {customer:, plan:, external_id: "abc123"} + started_at = timestamp - 10.days - 1.hour + create(:subscription, started_at:, terminated_at: 6.days.ago, status: :terminated, **attr) + sub = create(:subscription, started_at: 6.days.ago, **attr) + + expect { service.call }.to change { sub.reload.trial_ended_at }.from(nil).to(sub.trial_end_datetime) + end + end + + context "with customer timezone" do + let(:timestamp) { DateTime.parse("2024-03-11 13:03:00 UTC") } + + it "sets trial_ended_at to the expected subscription (timezone is irrelevant)" do + started_at = DateTime.parse("2024-03-01 12:00:00 UTC") + customer = create(:customer, timezone: "America/Los_Angeles") + sub = create(:subscription, plan:, customer:, started_at:) + service.call + expect(sub.reload.trial_ended_at).to match_datetime(sub.trial_end_datetime) + end + end + + context "when the subscription should sync with Hubspot" do + it "calls the Hubspot update job" do + customer = create(:customer, :with_hubspot_integration) + plan = create(:plan, trial_period: 10, pay_in_advance: true) + subscription = create(:subscription, customer:, plan:, started_at: 15.days.ago) + allow(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).to receive(:perform_later) + service.call + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob) + .to have_received(:perform_later).with(subscription: subscription) + end + end + + context "with plan pay in arrears" do + let(:plan) { create(:plan, trial_period: 10, pay_in_advance: false) } + + context "when plan has fixed charges" do + context "when fixed_charges are not pay in advance" do + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: false) } + let(:subscription) { create(:subscription, plan:, started_at: 11.days.ago) } + + before do + fixed_charge + subscription + end + + it "does not enqueue a job to bill the subscription" do + expect { service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + end + + context "when fixed_charges are pay in advance" do + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: true) } + let(:subscription) { create(:subscription, plan:, started_at: 11.days.ago) } + + before do + fixed_charge + subscription + end + + it "does not enqueue a job to bill the subscription" do + expect { service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + end + end + end + + context "with plan pay in advance" do + let(:plan) { create(:plan, trial_period: 10, pay_in_advance: true) } + + context "when plan has fixed charges" do + context "when fixed_charges are not pay in advance" do + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: false) } + let(:subscription) { create(:subscription, plan:, started_at: 11.days.ago) } + + before do + fixed_charge + subscription + end + + it "enqueues a job to bill the subscription" do + expect { service.call }.to have_enqueued_job(BillSubscriptionJob) + end + end + end + end + end +end diff --git a/spec/services/subscriptions/organization_billing_service_spec.rb b/spec/services/subscriptions/organization_billing_service_spec.rb new file mode 100644 index 0000000..609816e --- /dev/null +++ b/spec/services/subscriptions/organization_billing_service_spec.rb @@ -0,0 +1,1031 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::OrganizationBillingService do + subject(:billing_service) { described_class.new(organization:, billing_at:) } + + describe ".call" do + let(:billing_entity_timezone) { "UTC" } + let(:billing_entity) { create(:billing_entity, timezone: billing_entity_timezone) } + let(:organization) { billing_entity.organization } + + let(:interval) { :monthly } + let(:bill_charges_monthly) { false } + let(:plan) { create(:plan, organization:, interval:, bill_charges_monthly:) } + + let(:customer_timezone) { nil } + let(:customer) { create(:customer, organization:, billing_entity: billing_entity, timezone: customer_timezone) } + + let(:created_at) { DateTime.parse("20 Feb 2020") } + let(:subscription_at) { DateTime.parse("20 Feb 2021") } + let(:started_at) { DateTime.parse("10 Jun 2022") } + let(:current_date) { DateTime.parse("20 Jun 2022 12:00") } + let(:billing_at) { current_date } + let(:ending_at) { nil } + let(:billing_time) { :calendar } + let(:subscription) do + create( + :subscription, + customer: customer, + plan:, + subscription_at:, + started_at:, + billing_time:, + created_at:, + ending_at: + ) + end + + before { subscription } + + def expect_to_bill_together(subscriptions, date) + expect(BillSubscriptionJob).to have_been_enqueued + .with(subscriptions, date.to_i, invoicing_reason: :subscription_periodic) + expect(BillNonInvoiceableFeesJob).to have_been_enqueued + .with(subscriptions, date) + end + + [ + {interval: :weekly, billing_time: :calendar, billed_on: ["20 Jun 2022", "27 Jun 2022", "04 Jul 2022"]}, + {interval: :weekly, billing_time: :anniversary, billed_on: ["25 Jun 2022", "02 Jul 2022", "09 Jul 2022"]}, + {interval: :monthly, billing_time: :calendar, billed_on: ["01 Jul 2022", "01 Aug 2022", "01 Sep 2022"]}, + {interval: :monthly, billing_time: :anniversary, billed_on: ["20 Jun 2022", "20 Jul 2022", "20 Aug 2022"]}, + # 31st day monthly subscription (month normalization) + { + interval: :monthly, + billing_time: :anniversary, + subscription_at: "31 March 2021", + billed_on: ["28 Feb 2023", "29 Feb 2024", "30 Apr 2023", "31 Jan 2023"] + }, + { + interval: :quarterly, + billing_time: :calendar, + billed_on: ["01 Jul 2022", "01 Oct 2022", "01 Jan 2023", "01 Apr 2030"], + not_billed_on: ["01 Feb 2022", "01 Mar 2022", "01 Dec 2023"] + }, + # Quarterly cycle: Aug/Nov/Feb/May + { + interval: :quarterly, + billing_time: :anniversary, + billed_on: ["20 Aug 2022", "20 Nov 2022", "20 Feb 2023", "20 May 2030"], + not_billed_on: ["20 Sep 2022"] + }, + # Quarterly cycle: Jan/Apr/Jul/Oct + { + interval: :quarterly, + billing_time: :anniversary, + subscription_at: "15 January 2021", + billed_on: ["15 Jul 2022", "15 Oct 2022", "15 Jan 2023", "15 Apr 2024"] + }, + # Quarterly cycle: Mar/Jun/Sep/Dec + { + interval: :quarterly, + billing_time: :anniversary, + subscription_at: "15 March 2021", + billed_on: ["15 Jun 2022", "15 Sep 2022", "15 Dec 2022", "15 Mar 2023"] + }, + # 31st day quarterly subscription (month normalization) + { + interval: :quarterly, + billing_time: :anniversary, + subscription_at: "31 May 2021", + billed_on: ["28 Feb 2023", "29 Feb 2024", "30 Nov 2023", "31 Aug 2023"], + not_billend_on: ["31 Aug 2023"] + }, + # 30th day quarterly subscription (month normalization) + { + interval: :quarterly, + billing_time: :anniversary, + subscription_at: "30 May 2021", + billed_on: ["28 Feb 2023", "29 Feb 2024", "30 Nov 2023", "30 Aug 2023"], + not_billend_on: ["31 Aug 2023"] + }, + # Quarterly with monthly charges is not implemented + # { + # interval: :quarterly, + # billing_time: :calendar, + # bill_charges_monthly: true, + # billed_on: ["01 Aug 2022",], + # not_billed_on: ["02 Aug 2022",], + # }, + # { + # interval: :quarterly, + # billing_time: :anniversary, + # bill_charges_monthly: true, + # billed_on: ["20 Jul 2022",], + # not_billed_on: ["21 Jul 2022",], + # }, + { + interval: :semiannual, + billing_time: :calendar, + billed_on: ["01 Jul 2022", "01 Jan 2023", "01 Jul 2030"], + not_billed_on: ["01 Oct 2022"] + }, + { + interval: :semiannual, + billing_time: :anniversary, + billed_on: ["20 Aug 2022", "20 Feb 2023", "20 Aug 2030"], + not_billed_on: ["20 Nov 2022"] + }, + # 31st day semiannual subscription (month normalization) + { + interval: :semiannual, + billing_time: :anniversary, + subscription_at: "31 Aug 2021", + billed_on: ["28 Feb 2023", "29 Feb 2024", "31 Aug 2023"] + }, + # 30th day semiannual subscription (month normalization) + { + interval: :semiannual, + billing_time: :anniversary, + subscription_at: "30 Aug 2021", + billed_on: ["28 Feb 2023", "29 Feb 2024", "30 Aug 2023"], + not_billed_on: ["31 Aug 2023"] + }, + { + interval: :semiannual, + billing_time: :calendar, + bill_charges_monthly: true, + billed_on: ["01 Aug 2022"], + not_billed_on: ["02 Aug 2022"] + }, + { + interval: :semiannual, + billing_time: :anniversary, + bill_charges_monthly: true, + billed_on: ["20 Jul 2022"], + not_billed_on: ["21 Jul 2022"] + }, + # 31st day semiannual subscription (month normalization) + { + interval: :semiannual, + billing_time: :anniversary, + bill_charges_monthly: true, + subscription_at: "31 Aug 2021", + billed_on: ["28 Feb 2023", "29 Feb 2024", "30 Jun 2022", "31 Jul 2022"] + }, + { + interval: :yearly, + billing_time: :calendar, + billed_on: ["01 Jan 2023", "01 Jan 2024", "01 Jan 2030"], + not_billed_on: ["01 Feb 2023", "01 Dec 2022"] + }, + { + interval: :yearly, + billing_time: :anniversary, + billed_on: ["20 Feb 2023", "20 Feb 2024", "20 Feb 2030"], + not_billed_on: ["20 Jan 2023", "20 Mar 2023"] + }, + # Non-leap year Feb 28 subscription + { + interval: :yearly, + billing_time: :anniversary, + subscription_at: "28 Feb 2021", + billed_on: ["28 Feb 2023", "28 Feb 2024", "28 Feb 2030"], + not_billed_on: ["29 Feb 2024"] + }, + # Leap year Feb 29 subscription + { + interval: :yearly, + billing_time: :anniversary, + subscription_at: "29 Feb 2020", + billed_on: ["28 Feb 2023", "29 Feb 2024", "28 Feb 2030"] + }, + { + interval: :yearly, + billing_time: :calendar, + bill_charges_monthly: true, + billed_on: ["01 Aug 2022", "01 Sep 2022", "01 Oct 2022"], + not_billed_on: ["02 Aug 2022", "31 Aug 2022"] + }, + { + interval: :yearly, + billing_time: :anniversary, + bill_charges_monthly: true, + billed_on: ["20 Jul 2022", "20 Aug 2022", "20 Sep 2022"], + not_billed_on: ["21 Jul 2022", "19 Jul 2022"] + } + ].each do |test_case| + subscription_at = DateTime.parse(test_case[:subscription_at] || "20 Feb 2021") + interval = test_case[:interval] + billing_time = test_case[:billing_time] || :calendar + not_billed_on = test_case.fetch(:not_billed_on, []).map { DateTime.parse(it) } + billed_on = test_case[:billed_on].map { DateTime.parse(it) } + focus = test_case.fetch(:focus, false) + bill_charges_monthly = test_case.fetch(:bill_charges_monthly, false) + + context "when billed #{interval} with #{billing_time} billing time#{" with monthly charges" if bill_charges_monthly}", focus: do + let(:interval) { interval } + let(:billing_time) { billing_time } + let(:bill_charges_monthly) { bill_charges_monthly } + + context "when subscribed on #{subscription_at.to_formatted_s(:long)}" do + let(:subscription_at) { subscription_at } + + billed_on.each do |billed_on| + is_31st = subscription_at.day == 31 + context "when on billing day#{" (31st)" if is_31st} (#{billed_on.to_formatted_s(:long)})" do + let(:billing_at) { billed_on } + + it "enqueues a job" do + billing_service.call + + expect_to_bill_together([subscription], billing_at) + end + end + end + + not_billed_on.each do |not_billed_on| + context "when billing on #{not_billed_on.to_formatted_s(:long)}" do + let(:billing_at) { not_billed_on } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + end + + context "when multiple subscriptions are to be billed on the same day" do + let(:billing_at) { billed_on.first } + + let(:monthly_subscription) do + at = billing_at - 1.month + create( + :subscription, + customer: customer, + plan: create(:plan, organization:, interval: :monthly), + subscription_at: at, + started_at: at, + billing_time: :anniversary, + created_at: at + ) + end + + let(:customer_with_weekly_billing) { create(:customer, organization:) } + let(:subscription_with_weekly_billing) do + at = billing_at - 1.week + create( + :subscription, + customer: customer_with_weekly_billing, + plan: create(:plan, organization:, interval: :weekly), + subscription_at: at, + started_at: at, + billing_time: :anniversary, + created_at: at + ) + end + + let(:not_billed_customer) { create(:customer, organization:) } + let(:not_billed_subscription) do + at = billing_at - 1.week - 1.day + create( + :subscription, + customer: not_billed_customer, + plan: create(:plan, organization:, interval: :weekly), + subscription_at: at, + started_at: at, + billing_time: :anniversary, + created_at: at + ) + end + + before do + monthly_subscription + subscription_with_weekly_billing + not_billed_subscription + end + + it "enqueues jobs for all customers" do + billing_service.call + + expect_to_bill_together(contain_exactly(subscription, monthly_subscription), billing_at) + + expect_to_bill_together([subscription_with_weekly_billing], billing_at) + + expect(BillSubscriptionJob).not_to have_been_enqueued.with([not_billed_subscription], anything, invoicing_reason: :subscription_periodic) + end + end + end + + context "when ending_at is the same as billing day" do + let(:billing_at) { billed_on.first } + let(:ending_at) { billing_at } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when subscription started on billing day" do + let(:started_at) { billed_on.first } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when subscription starts after billing day" do + let(:started_at) { billed_on.first + 1.day } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when it is not yet billing day on customer timezone" do + let(:subscription_at) { DateTime.parse("20 Feb 2021 12:00") } + let(:billing_at) { billed_on.first } + let(:customer_timezone) { "America/Chicago" } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when it is after billing day on customer timezone" do + let(:billing_at) { billed_on.first + 18.hours } + let(:customer_timezone) { "Pacific/Auckland" } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when it is not yet billing day on billing entity timezone" do + let(:subscription_at) { DateTime.parse("20 Feb 2021 12:00") } + let(:billing_at) { billed_on.first } + let(:billing_entity_timezone) { "America/Chicago" } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when it is after billing day on billing entity timezone" do + let(:billing_at) { billed_on.first + 18.hours } + let(:billing_entity_timezone) { "Pacific/Auckland" } + + it "does not bill" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + end + end + + context "when downgraded" do + let(:subscription) do + create( + :subscription, + customer:, + subscription_at:, + started_at: current_date - 10.days, + previous_subscription:, + status: :pending, + created_at: + ) + end + + let(:previous_subscription) do + create( + :subscription, + customer:, + subscription_at:, + started_at: current_date - 10.days, + billing_time: :anniversary, + created_at: + ) + end + + before { subscription } + + it "enqueues a job on billing day" do + billing_service.call + + expect(Subscriptions::TerminateJob).to have_been_enqueued + .with(previous_subscription, current_date.to_i) + end + + context "when all customer subscriptions are downgraded" do + it "does not enqueue billing jobs for that customer" do + billing_service.call + + expect(BillSubscriptionJob).not_to have_been_enqueued + expect(BillNonInvoiceableFeesJob).not_to have_been_enqueued + end + end + end + + context "when on subscription creation day" do + let(:created_at) { subscription_at } + + it "does not enqueue a job" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when subscription was already automatically billed today" do + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoicing_reason: :subscription_periodic, + timestamp: billing_at - 1.hour, + recurring: true + ) + end + + before { invoice_subscription } + + it "does not enqueue a job" do + expect { billing_service.call }.not_to have_enqueued_job + end + end + + context "when grouping subscriptions by currency" do + let(:organization) { create(:organization, feature_flags: ["multi_currency"]) } + let(:interval) { :monthly } + let(:billing_time) { :anniversary } + let(:current_date) { subscription_at.next_month } + + before { subscription.destroy } + + context "when subscriptions have different currencies" do + let(:usd_plan) { create(:plan, organization:, interval:, amount_currency: "USD") } + let(:eur_plan) { create(:plan, organization:, interval:, amount_currency: "EUR") } + + let(:usd_subscription) do + create( + :subscription, + customer:, + plan: usd_plan, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + let(:eur_subscription) do + create( + :subscription, + customer:, + plan: eur_plan, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + + before do + usd_subscription + eur_subscription + end + + it "produces separate billing jobs per currency" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with([usd_subscription], current_date.to_i, invoicing_reason: :subscription_periodic) + expect(BillSubscriptionJob).to have_been_enqueued + .with([eur_subscription], current_date.to_i, invoicing_reason: :subscription_periodic) + end + + context "without feature flag" do + let(:organization) { create(:organization) } + + it "groups them into a single billing job" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(usd_subscription, eur_subscription), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + end + + context "when subscriptions share the same currency" do + let(:plan1) { create(:plan, organization:, interval:, amount_currency: "USD") } + let(:plan2) { create(:plan, organization:, interval:, amount_currency: "USD") } + + let(:subscription1) do + create( + :subscription, + customer:, + plan: plan1, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan: plan2, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + + before do + subscription1 + subscription2 + end + + it "groups them into a single billing job" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(subscription1, subscription2), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + + context "when combined with payment method grouping" do + let(:organization) { create(:organization, feature_flags: %w[multi_currency multiple_payment_methods]) } + let(:usd_plan) { create(:plan, organization:, interval:, amount_currency: "USD") } + let(:eur_plan) { create(:plan, organization:, interval:, amount_currency: "EUR") } + + let(:usd_provider_subscription) do + create( + :subscription, + customer:, + plan: usd_plan, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + let(:usd_manual_subscription) do + create( + :subscription, + customer:, + plan: usd_plan, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "manual" + ) + end + let(:eur_provider_subscription) do + create( + :subscription, + customer:, + plan: eur_plan, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + + before do + usd_provider_subscription + usd_manual_subscription + eur_provider_subscription + end + + it "produces separate billing jobs per payment method and currency" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with([usd_provider_subscription], current_date.to_i, invoicing_reason: :subscription_periodic) + expect(BillSubscriptionJob).to have_been_enqueued + .with([usd_manual_subscription], current_date.to_i, invoicing_reason: :subscription_periodic) + expect(BillSubscriptionJob).to have_been_enqueued + .with([eur_provider_subscription], current_date.to_i, invoicing_reason: :subscription_periodic) + end + end + end + + context "when grouping subscriptions by payment method" do + let(:organization) { create(:organization, feature_flags: ["multiple_payment_methods"]) } + let(:interval) { :monthly } + let(:billing_time) { :anniversary } + let(:current_date) { subscription_at.next_month } + + before { subscription.destroy } + + context "when customer has multiple subscriptions with same payment method type (provider)" do + let(:customer3) { create(:customer, organization:) } + let(:subscription1) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + let(:subscription3) do + create( + :subscription, + customer: customer3, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + + before do + subscription1 + subscription2 + subscription3 + end + + it "groups them into a single billing job for a customer" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(subscription1, subscription2), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + expect(BillSubscriptionJob).to have_been_enqueued + .with( + [subscription3], + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + + context "when customer has subscriptions with different payment method types" do + let(:subscription1) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "manual" + ) + end + + before do + subscription1 + subscription2 + end + + it "groups them into separate billing jobs" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription1], current_date.to_i, invoicing_reason: :subscription_periodic) + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription2], current_date.to_i, invoicing_reason: :subscription_periodic) + end + + context "without feature flag" do + let(:organization) { create(:organization) } + + it "does not group subscriptions into separate billing jobs" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(subscription1, subscription2), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + end + + context "when subscriptions have different explicit payment_method_ids" do + let(:customer3) { create(:customer, organization:) } + let(:payment_method1) { create(:payment_method, customer:, organization:, is_default: true) } + let(:payment_method2) { create(:payment_method, customer:, organization:, is_default: false, provider_method_id: "ext_456") } + + let(:subscription1) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method: payment_method1, + payment_method_type: "provider" + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method: payment_method2, + payment_method_type: "provider" + ) + end + let(:subscription3) do + create( + :subscription, + customer: customer3, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + + before do + subscription1 + subscription2 + subscription3 + end + + it "groups them into separate billing jobs" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription1], current_date.to_i, invoicing_reason: :subscription_periodic) + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription2], current_date.to_i, invoicing_reason: :subscription_periodic) + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription3], current_date.to_i, invoicing_reason: :subscription_periodic) + end + end + + context "when subscription with nil payment_method_id resolves to same as explicit one" do + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: true) } + + let(:subscription1) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method: payment_method, + payment_method_type: "provider" + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method: nil, + payment_method_type: "provider" + ) + end + + before do + subscription1 + subscription2 + end + + it "groups them into a single billing job" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(subscription1, subscription2), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + + context "when customer has default payment method" do + let(:payment_method) { create(:payment_method, customer:, organization:, is_default: true) } + + let(:subscription1) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method: nil, + payment_method_type: "provider" + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method: nil, + payment_method_type: "provider" + ) + end + + before do + subscription1 + subscription2 + end + + it "groups them into a single billing job" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(subscription1, subscription2), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + + context "when single subscription exists" do + let(:subscription1) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at:, + payment_method_type: "provider" + ) + end + + before { subscription1 } + + it "returns single group without grouping logic" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription1], current_date.to_i, invoicing_reason: :subscription_periodic) + end + end + end + + context "when grouping subscriptions by billing entity" do + let(:organization) { create(:organization, feature_flags: ["multi_entity_billing"]) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:other_billing_entity) { create(:billing_entity, organization:) } + let(:interval) { :monthly } + let(:billing_time) { :anniversary } + let(:current_date) { subscription_at.next_month } + + before { subscription.destroy } + + context "when subscriptions have different billing entities" do + let(:subscription_default_entity) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + let(:subscription_other_entity) do + create( + :subscription, + customer:, + plan:, + billing_entity: other_billing_entity, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + + before do + subscription_default_entity + subscription_other_entity + end + + it "produces separate billing jobs per billing entity" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription_default_entity], current_date.to_i, invoicing_reason: :subscription_periodic) + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription_other_entity], current_date.to_i, invoicing_reason: :subscription_periodic) + end + + context "without feature flag" do + let(:organization) { create(:organization) } + + it "groups them into a single billing job" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(subscription_default_entity, subscription_other_entity), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + end + + context "when subscriptions share the same effective billing entity" do + let(:subscription1) do + create( + :subscription, + customer:, + plan:, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + let(:subscription2) do + create( + :subscription, + customer:, + plan:, + billing_entity: customer.billing_entity, + subscription_at:, + started_at: current_date - 10.days, + billing_time:, + created_at: + ) + end + + before do + subscription1 + subscription2 + end + + it "groups them into a single billing job" do + billing_service.call + + expect(BillSubscriptionJob).to have_been_enqueued + .with( + contain_exactly(subscription1, subscription2), + current_date.to_i, + invoicing_reason: :subscription_periodic + ) + end + end + end + end +end diff --git a/spec/services/subscriptions/plan_upgrade_service_spec.rb b/spec/services/subscriptions/plan_upgrade_service_spec.rb new file mode 100644 index 0000000..a41ac4e --- /dev/null +++ b/spec/services/subscriptions/plan_upgrade_service_spec.rb @@ -0,0 +1,298 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::PlanUpgradeService do + subject(:result) do + described_class.call(current_subscription: subscription, plan:, params:) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan: old_plan, + status: :active, + subscription_at: Time.current, + started_at: Time.current, + external_id: SecureRandom.uuid + ) + end + + let(:old_plan) { create(:plan, amount_cents: 100, organization:, amount_currency: currency) } + let(:customer) { create(:customer, :with_hubspot_integration, organization:, currency:) } + let(:organization) { create(:organization) } + let(:currency) { "EUR" } + let(:plan) { create(:plan, amount_cents: 100, organization:) } + let(:params) { {name: subscription_name} } + let(:subscription_name) { "new invoice display name" } + + describe "#call" do + before do + subscription.mark_as_active! + end + + it "terminates the existing subscription" do + expect { result } + .to change { subscription.reload.status }.from("active").to("terminated") + end + + it "moves the lifetime_usage to the new subscription" do + lifetime_usage = subscription.lifetime_usage + expect(result.subscription.lifetime_usage).to eq(lifetime_usage.reload) + expect(subscription.reload.lifetime_usage).to be_nil + end + + it "sends terminated and started subscription webhooks" do + result + expect(SendWebhookJob).to have_been_enqueued.with("subscription.terminated", subscription) + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", result.subscription) + end + + it "produces an activity log" do + result + expect(Utils::ActivityLog).to have_produced("subscription.started").with(result.subscription) + end + + it "enqueues the Hubspot update job" do + # TODO: review this one, this one should fail because the code conditional + # is not meet by the test setup... + # The subscription does not start in the future + result + expect(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).to have_been_enqueued.twice.with(subscription:) + end + + it "creates a new subscription" do + expect(result).to be_success + expect(result.subscription.id).not_to eq(subscription.id) + expect(result.subscription).to be_active + expect(result.subscription.name).to eq(subscription_name) + expect(result.subscription.plan.id).to eq(plan.id) + expect(result.subscription.previous_subscription_id).to eq(subscription.id) + expect(result.subscription.subscription_at).to eq(subscription.subscription_at) + expect(result.subscription.payment_method_id).to eq(nil) + expect(result.subscription.payment_method_type).to eq("provider") + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, organization: subscription.organization, customer: subscription.customer) } + let(:params) do + { + name: subscription_name, + payment_method: { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + } + end + + before { payment_method } + + it "creates a new subscription" do + expect(result).to be_success + expect(result.subscription.id).not_to eq(subscription.id) + expect(result.subscription).to be_active + expect(result.subscription.name).to eq(subscription_name) + expect(result.subscription.plan.id).to eq(plan.id) + expect(result.subscription.previous_subscription_id).to eq(subscription.id) + expect(result.subscription.subscription_at).to eq(subscription.subscription_at) + expect(result.subscription.payment_method_id).to eq(payment_method.id) + expect(result.subscription.payment_method_type).to eq("provider") + end + end + + context "when new plan has fixed charges" do + let(:fixed_charge_1) { create(:fixed_charge, plan:) } + let(:fixed_charge_2) { create(:fixed_charge, plan:) } + + before do + fixed_charge_1 + fixed_charge_2 + end + + it "creates fixed charge events for the new subscription" do + expect { result }.to change(FixedChargeEvent, :count).by(2) + expect(result.subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)) + .to match_array( + [ + [fixed_charge_1.id, be_within(1.second).of(Time.current)], + [fixed_charge_2.id, be_within(1.second).of(Time.current)] + ] + ) + end + end + + context "when current subscription is pending" do + before { subscription.pending! } + + it "returns existing subscription with updated attributes" do + expect(result).to be_success + expect(result.subscription.id).to eq(subscription.id) + expect(result.subscription.plan_id).to eq(plan.id) + expect(result.subscription.name).to eq(subscription_name) + end + end + + context "when old subscription is payed in arrear" do + let(:old_plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: false) } + + it "enqueues a job to bill the existing subscription" do + expect { result }.to have_enqueued_job(BillSubscriptionJob) + end + end + + context "when old subscription was payed in advance" do + let(:creation_time) { Time.current.beginning_of_month - 1.month } + let(:date_service) do + Subscriptions::DatesService.new_instance( + subscription, + Time.current.beginning_of_month, + current_usage: false + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + recurring: true, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + currency:, + sub_total_excluding_taxes_amount_cents: 100, + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120 + ) + end + + let(:last_subscription_fee) do + create( + :fee, + subscription:, + invoice:, + amount_cents: 100, + taxes_amount_cents: 20, + invoiceable_type: "Subscription", + invoiceable_id: subscription.id, + taxes_rate: 20 + ) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan: old_plan, + status: :active, + subscription_at: creation_time, + started_at: creation_time, + external_id: SecureRandom.uuid, + billing_time: "anniversary" + ) + end + + let(:old_plan) { create(:plan, amount_cents: 100, organization:, pay_in_advance: true) } + + before do + invoice_subscription + last_subscription_fee + end + + it "creates a credit note for the remaining days" do + expect { result }.to change(CreditNote, :count) + end + end + + context "when new subscription is pay in advance" do + let(:plan) { create(:plan, amount_cents: 200, organization:, pay_in_advance: true) } + + it "includes new subscription in BillSubscriptionJob" do + new_subscription = result.subscription + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription, new_subscription], kind_of(Integer), invoicing_reason: :upgrading) + end + end + + context "when new subscription is pay in arrears with pay in advance fixed charges" do + let(:plan) { create(:plan, amount_cents: 200, organization:, pay_in_advance: false) } + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge } + + it "includes new subscription in BillSubscriptionJob" do + new_subscription = result.subscription + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription, new_subscription], kind_of(Integer), invoicing_reason: :upgrading) + end + end + + context "when new subscription is pay in arrears without pay in advance fixed charges" do + let(:plan) { create(:plan, amount_cents: 200, organization:, pay_in_advance: false) } + + it "does not include new subscription in BillSubscriptionJob" do + result.subscription + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription], kind_of(Integer), invoicing_reason: :upgrading) + end + end + + context "when new subscription is pay in advance and has trial period" do + let(:plan) { create(:plan, amount_cents: 200, organization:, pay_in_advance: true, trial_period: 3) } + + context "without pay in advance fixed charges" do + it "does not include new subscription in BillSubscriptionJob" do + result.subscription + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription], kind_of(Integer), invoicing_reason: :upgrading) + end + end + + context "with pay in advance fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge } + + it "includes new subscription in BillSubscriptionJob for fixed charges" do + new_subscription = result.subscription + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription, new_subscription], kind_of(Integer), invoicing_reason: :upgrading) + end + end + end + + context "with pending next subscription" do + let(:next_subscription) do + create( + :subscription, + status: :pending, + previous_subscription: subscription, + organization:, + customer: + ) + end + + before { next_subscription } + + it "canceled the next subscription" do + expect(result).to be_success + expect(next_subscription.reload).to be_canceled + end + end + end +end diff --git a/spec/services/subscriptions/progressive_billed_amount_spec.rb b/spec/services/subscriptions/progressive_billed_amount_spec.rb new file mode 100644 index 0000000..5973392 --- /dev/null +++ b/spec/services/subscriptions/progressive_billed_amount_spec.rb @@ -0,0 +1,493 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ProgressiveBilledAmount do + subject(:service) { described_class.new(subscription:, timestamp:) } + + let(:timestamp) { Time.current } + let(:subscription) { create(:subscription, customer_id: customer.id) } + let(:organization) { subscription.organization } + let(:customer) { create(:customer) } + + let(:charges_to_datetime) { timestamp + 1.week } + let(:charges_from_datetime) { timestamp - 1.week } + let(:invoice_type) { :progressive_billing } + + context "without previous progressive billing invoices" do + it "returns 0" do + result = service.call + expect(result.progressive_billed_amount).to be_zero + expect(result.total_billed_amount_cents).to be_zero + expect(result.progressive_billing_invoice).to be_nil + expect(result.to_credit_amount).to be_zero + expect(result.invoice_subscriptions).to be_empty + end + end + + context "with progressive billing invoice for another subscription" do + let(:other_subscription) { create(:subscription, customer_id: customer.id) } + let(:invoice_subscription) { create(:invoice_subscription, subscription: other_subscription, charges_from_datetime:, charges_to_datetime:) } + let(:other_invoice) { invoice_subscription.invoice } + + before do + other_invoice.update!(invoice_type:, fees_amount_cents: 20, total_amount_cents: 20) + end + + it "returns 0" do + result = service.call + expect(result.progressive_billed_amount).to be_zero + expect(result.total_billed_amount_cents).to be_zero + expect(result.progressive_billing_invoice).to be_nil + expect(result.to_credit_amount).to be_zero + expect(result.invoice_subscriptions).to be_empty + end + end + + context "with progressive billing invoice for this subscription" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:fee) { create(:charge_fee, invoice:, subscription:, amount_cents: 20, taxes_amount_cents: 0) } + + before do + fee + invoice.update!(invoice_type:, fees_amount_cents: 20, total_amount_cents: 20) + end + + it "returns the fees_amount_cents from that invoice" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.total_billed_amount_cents).to eq(20) + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.to_credit_amount).to eq(20) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "with failed progressive billing invoice for this subscription" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:fee) { create(:charge_fee, invoice:, subscription:, amount_cents: 20, taxes_amount_cents: 0) } + + before do + fee + invoice.update!(invoice_type:, status: :failed, fees_amount_cents: 20, prepaid_credit_amount_cents: 20) + end + + it "returns the fees_amount_cents from that invoice" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.total_billed_amount_cents).to eq(20) + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.to_credit_amount).to eq(20) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "with generating progressive billing invoice for this subscription" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:fee) { create(:charge_fee, invoice:, subscription:, amount_cents: 20, taxes_amount_cents: 0) } + + before do + fee + invoice.update!(invoice_type:, status: :generating, fees_amount_cents: 20, prepaid_credit_amount_cents: 20) + end + + it "returns 0" do + result = service.call + expect(result.progressive_billed_amount).to be_zero + expect(result.total_billed_amount_cents).to be_zero + expect(result.progressive_billing_invoice).to be_nil + expect(result.to_credit_amount).to be_zero + expect(result.invoice_subscriptions).to be_empty + end + + context "when passing include_generating_invoices: true" do + subject(:service) { described_class.new(subscription:, timestamp:, include_generating_invoices: true) } + + it "returns the fees_amount_cents from that invoice" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.total_billed_amount_cents).to eq(20) + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.to_credit_amount).to eq(20) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + end + + context "with progressive billing invoice for this subscription in previous period" do + let(:charges_to_datetime) { timestamp - 1.week } + let(:charges_from_datetime) { timestamp - 2.weeks } + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + + before do + invoice.update!(invoice_type:, fees_amount_cents: 20, prepaid_credit_amount_cents: 20) + end + + it "returns 0" do + result = service.call + expect(result.progressive_billed_amount).to be_zero + expect(result.total_billed_amount_cents).to be_zero + expect(result.progressive_billing_invoice).to be_nil + expect(result.to_credit_amount).to be_zero + expect(result.invoice_subscriptions).to be_empty + end + end + + context "with multiple progressive billing invoice for this subscription" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:fee1) { create(:charge_fee, invoice:, subscription:, amount_cents: 20, taxes_amount_cents: 0) } + let(:invoice_subscription2) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice2) { invoice_subscription2.invoice } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 40, taxes_amount_cents: 0, precise_coupons_amount_cents: 20) } + + before do + fee1 + fee2 + invoice.update!(invoice_type:, issuing_date: timestamp - 2.days, fees_amount_cents: 20, total_amount_cents: 0, prepaid_credit_amount_cents: 20) + invoice2.update!(invoice_type:, issuing_date: timestamp - 1.day, fees_amount_cents: 40, total_amount_cents: 10, prepaid_credit_amount_cents: 10) + end + + it "returns the last issued invoice fees_amount_cents" do + result = service.call + expect(result.progressive_billed_amount).to eq(40) + expect(result.total_billed_amount_cents).to eq(40) + expect(result.progressive_billing_invoice).to eq(invoice2) + expect(result.to_credit_amount).to eq(40) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription, invoice_subscription2) + end + end + + context "with multiple progressive billing invoice for this subscription and the last one failed" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:fee1) { create(:charge_fee, invoice:, subscription:, amount_cents: 20, taxes_amount_cents: 0) } + let(:invoice_subscription2) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice2) { invoice_subscription2.invoice } + let(:fee2) { create(:charge_fee, invoice:, subscription:, amount_cents: 40, taxes_amount_cents: 0, precise_coupons_amount_cents: 20) } + + before do + fee1 + fee2 + invoice.update!(invoice_type:, issuing_date: timestamp - 2.days, fees_amount_cents: 20) + invoice2.update!(invoice_type:, status: :failed, issuing_date: timestamp - 1.day, fees_amount_cents: 40) + end + + it "returns the last issued invoice fees_amount_cents" do + result = service.call + expect(result.progressive_billed_amount).to eq(40) + expect(result.total_billed_amount_cents).to eq(40) + expect(result.progressive_billing_invoice).to eq(invoice2) + expect(result.to_credit_amount).to eq(40) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription, invoice_subscription2) + end + end + + context "with progressive billing invoice for this subscription, but it has a credit note" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:credit_note) { create(:credit_note, invoice:, credit_amount_cents:) } + + before do + invoice.update!(invoice_type:, fees_amount_cents: 20) + credit_note + end + + context "when fully credited" do + let(:credit_amount_cents) { 20 } + + it "returns the fees_amount_cents from that invoice" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.to_credit_amount).to eq(0) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + + context "when credit note is consumed" do + let(:credit_note) { create(:credit_note, invoice:, credit_amount_cents:, credit_status: :consumed) } + + it "doesn't return amount cents that is fully consumed" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.to_credit_amount).to eq(0) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + end + + context "when partially credited" do + let(:credit_amount_cents) { 10 } + + it "returns the fees_amount_cents from that invoice" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.to_credit_amount).to eq(10) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + end + + context "with progressive billing invoice for this subscription, but it has already been applied to an invoice" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:progressive_billing_invoice) { invoice_subscription.invoice } + let(:other_invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { other_invoice_subscription.invoice } + let(:progressive_billing_credit) do + create(:credit, + invoice:, + progressive_billing_invoice:, + amount_cents: amount_to_credit, + amount_currency: invoice.currency, + before_taxes: true) + end + + before do + progressive_billing_credit + progressive_billing_invoice.update!(invoice_type:, fees_amount_cents: 20) + end + + context "when fully credited" do + let(:amount_to_credit) { 20 } + + it "returns the fees_amount_cents from that invoice" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.progressive_billing_invoice).to eq(progressive_billing_invoice) + expect(result.to_credit_amount).to eq(0) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "when partially credited" do + let(:amount_to_credit) { 10 } + + it "returns the fees_amount_cents from that invoice" do + result = service.call + expect(result.progressive_billed_amount).to eq(20) + expect(result.progressive_billing_invoice).to eq(progressive_billing_invoice) + expect(result.to_credit_amount).to eq(10) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + end + + context "with progressive billing invoice that has 100% coupon discount" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 100, precise_coupons_amount_cents: 100, taxes_amount_cents: 0) } + + before do + fee + invoice.update!(invoice_type:, fees_amount_cents: 100, coupons_amount_cents: 100, sub_total_excluding_taxes_amount_cents: 0, total_amount_cents: 0) + end + + it "returns to_credit_amount of 0 when fees are fully discounted" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to be_zero + expect(result.total_billed_amount_cents).to be_zero + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "with progressive billing invoice that has partial coupon discount" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 100, precise_coupons_amount_cents: 30, taxes_amount_cents: 14) } + + before do + fee + invoice.update!(invoice_type:, fees_amount_cents: 100, coupons_amount_cents: 30, sub_total_excluding_taxes_amount_cents: 70, taxes_amount_cents: 14, total_amount_cents: 84) + end + + it "returns net amount after coupons" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to eq(70) + expect(result.total_billed_amount_cents).to eq(84) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "with multiple progressive billing invoices with coupons" do + let(:invoice_subscription1) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice1) { invoice_subscription1.invoice } + let(:invoice_subscription2) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice2) { invoice_subscription2.invoice } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:fee1) { create(:charge_fee, invoice: invoice1, subscription:, charge:, amount_cents: 50, precise_coupons_amount_cents: 10, taxes_amount_cents: 0) } + let(:fee2) { create(:charge_fee, invoice: invoice2, subscription:, charge:, amount_cents: 100, precise_coupons_amount_cents: 20, taxes_amount_cents: 0) } + + before do + fee1 + fee2 + invoice1.update!(invoice_type:, issuing_date: timestamp - 2.days, fees_amount_cents: 50, coupons_amount_cents: 10, sub_total_excluding_taxes_amount_cents: 40) + invoice2.update!(invoice_type:, issuing_date: timestamp - 1.day, fees_amount_cents: 100, coupons_amount_cents: 20, sub_total_excluding_taxes_amount_cents: 80) + end + + it "returns to_credit_amount from most recent invoice after coupons" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice2) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to eq(80) + expect(result.total_billed_amount_cents).to eq(120) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription1, invoice_subscription2) + end + end + + context "with progressive billing invoice with coupons and existing credits" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 100, precise_coupons_amount_cents: 20, taxes_amount_cents: 0) } + let(:existing_credit) { create(:credit, invoice:, progressive_billing_invoice: invoice, amount_cents: 30) } + + before do + fee + invoice.update!(invoice_type:, fees_amount_cents: 100, coupons_amount_cents: 20, sub_total_excluding_taxes_amount_cents: 80) + existing_credit + end + + it "subtracts existing credits from net amount" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to eq(50) + expect(result.total_billed_amount_cents).to eq(80) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "with progressive billing invoice with coupons and existing credit notes" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 100, precise_coupons_amount_cents: 20, taxes_amount_cents: 0) } + let(:existing_credit_note) { create(:credit_note, invoice:, credit_amount_cents: 20, total_amount_cents: 20, credit_status: :available) } + + before do + fee + invoice.update!(invoice_type:, fees_amount_cents: 100, coupons_amount_cents: 20, sub_total_excluding_taxes_amount_cents: 80) + existing_credit_note + end + + it "subtracts existing credit notes from net amount" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to eq(60) + expect(result.total_billed_amount_cents).to eq(80) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "when coupons and existing credits would result in negative to_credit_amount" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:fee) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 100, precise_coupons_amount_cents: 60, taxes_amount_cents: 0) } + let(:existing_credit) { create(:credit, invoice:, progressive_billing_invoice: invoice, amount_cents: 50) } + + before do + fee + invoice.update!(invoice_type:, fees_amount_cents: 100, coupons_amount_cents: 60, sub_total_excluding_taxes_amount_cents: 40) + existing_credit + end + + it "returns 0 instead of negative value" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to be_zero + expect(result.total_billed_amount_cents).to eq(40) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "with progressive billing invoice that has credit notes with different statuses" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:fee) { create(:charge_fee, invoice:, subscription:, amount_cents: 100, taxes_amount_cents: 0) } + let(:available_credit_note) { create(:credit_note, invoice:, credit_amount_cents: 20, total_amount_cents: 20, credit_status: :available) } + let(:consumed_credit_note) { create(:credit_note, invoice:, credit_amount_cents: 10, total_amount_cents: 10, credit_status: :consumed) } + let(:voided_credit_note) { create(:credit_note, invoice:, credit_amount_cents: 15, total_amount_cents: 15, credit_status: :voided) } + + before do + fee + invoice.update!(invoice_type:, fees_amount_cents: 100) + available_credit_note + consumed_credit_note + voided_credit_note + end + + it "subtracts available and consumed credit notes from to_credit_amount" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to eq(70) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end + + context "with multiple progressive billing invoices with same issuing_date" do + let(:invoice_subscription1) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice1) { invoice_subscription1.invoice } + let(:invoice_subscription2) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice2) { invoice_subscription2.invoice } + let(:fee1) { create(:charge_fee, invoice: invoice1, subscription:, amount_cents: 50, taxes_amount_cents: 0) } + let(:fee2) { create(:charge_fee, invoice: invoice2, subscription:, amount_cents: 100, taxes_amount_cents: 0) } + let(:same_issuing_date) { timestamp - 1.day } + + before do + fee1 + fee2 + invoice1.update!(invoice_type:, issuing_date: same_issuing_date, created_at: timestamp - 2.hours, fees_amount_cents: 50) + invoice2.update!(invoice_type:, issuing_date: same_issuing_date, created_at: timestamp - 1.hour, fees_amount_cents: 100) + end + + it "returns the most recently created invoice when issuing_dates are the same" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice2) + expect(result.progressive_billed_amount).to eq(100) + expect(result.to_credit_amount).to eq(100) + expect(result.total_billed_amount_cents).to eq(150) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription1, invoice_subscription2) + end + end + + context "with progressive billing invoice that has multiple fees with varying coupons" do + let(:invoice_subscription) { create(:invoice_subscription, subscription:, charges_from_datetime:, charges_to_datetime:) } + let(:invoice) { invoice_subscription.invoice } + let(:charge) { create(:standard_charge, plan: subscription.plan) } + let(:fee1) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 100, precise_coupons_amount_cents: 30, taxes_amount_cents: 0) } + let(:fee2) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 50, precise_coupons_amount_cents: 10, taxes_amount_cents: 0) } + let(:fee3) { create(:charge_fee, invoice:, subscription:, charge:, amount_cents: 30, precise_coupons_amount_cents: 0, taxes_amount_cents: 0) } + + before do + fee1 + fee2 + fee3 + invoice.update!(invoice_type:, fees_amount_cents: 180, coupons_amount_cents: 40, sub_total_excluding_taxes_amount_cents: 140) + end + + it "correctly calculates total_billed_amount_cents from multiple fees with varying coupons" do + result = service.call + expect(result.progressive_billing_invoice).to eq(invoice) + expect(result.progressive_billed_amount).to eq(180) + expect(result.to_credit_amount).to eq(140) + expect(result.total_billed_amount_cents).to eq(140) + expect(result.invoice_subscriptions).to contain_exactly(invoice_subscription) + end + end +end diff --git a/spec/services/subscriptions/terminate_service_spec.rb b/spec/services/subscriptions/terminate_service_spec.rb new file mode 100644 index 0000000..5d0a79c --- /dev/null +++ b/spec/services/subscriptions/terminate_service_spec.rb @@ -0,0 +1,448 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::TerminateService do + subject(:terminate_service) { described_class.new(subscription:) } + + let(:on_termination_credit_note) { nil } + + describe ".call" do + subject(:result) { described_class.call(subscription:, on_termination_credit_note:) } + + let(:subscription) { create(:subscription) } + + it "terminates a subscription" do + subject + + expect(result).to be_a(BaseResult) + expect(result).to be_success + expect(result.subscription).to be_present + expect(result.subscription).to be_terminated + expect(result.subscription.terminated_at).to be_present + end + + context "when the subscription should sync with Hubspot" do + let(:customer) { create(:customer, :with_hubspot_integration) } + let(:subscription) { create(:subscription, customer:) } + + it "calls the hubspot update job after commit" do + expect { subject }.to have_enqueued_job_after_commit(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).with(subscription:).twice + end + end + + it "enqueues a BillSubscriptionJob after commit" do + freeze_time do + expect { subject }.to have_enqueued_job_after_commit(BillSubscriptionJob).with([subscription], Time.current, invoicing_reason: :subscription_terminating) + end + end + + it "does not create a credit note for the remaining days" do + expect { subject }.not_to change(CreditNote, :count) + end + + it "enqueues a BillNonInvoiceableFeesJob after commit" do + freeze_time do + expect { subject }.to have_enqueued_job_after_commit(BillNonInvoiceableFeesJob) + .with([subscription], Time.current) + end + end + + it "enqueues a SendWebhookJob after commit" do + expect { subject }.to have_enqueued_job_after_commit(SendWebhookJob).with("subscription.terminated", subscription) + end + + context "when subscription is starting in the future" do + let(:subscription) { create(:subscription, :pending) } + + it "cancels a subscription" do + result = subject + + expect(result.subscription).to be_present + expect(result.subscription).to be_canceled + expect(result.subscription.canceled_at).to be_present + expect(result.subscription.terminated_at).to be_nil + end + + it "does not enqueue a BillSubscriptionJob" do + expect { subject }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "does not send subscription updated webhook" do + subject + expect(SendWebhookJob).not_to have_been_enqueued.with("subscription.updated", Subscription) + end + end + + context "when downgrade subscription is pending" do + let(:subscription) { create(:subscription, :pending, previous_subscription:) } + let(:previous_subscription) { create(:subscription) } + + it "does cancel it" do + subject + + expect(result.subscription).to be_present + expect(result.subscription).to be_canceled + expect(result.subscription.canceled_at).to be_present + end + + it "sends both subscription.terminated for the canceled and subscription.updated for the previous subscription" do + subject + + expect(SendWebhookJob).to have_been_enqueued.with("subscription.terminated", subscription) + expect(SendWebhookJob).to have_been_enqueued.with("subscription.updated", previous_subscription) + end + end + + context "when subscription is incomplete" do + let(:subscription) { create(:subscription, :incomplete) } + + it "returns a not allowed error" do + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("subscription_incomplete") + end + end + + context "when subscription is not found" do + let(:subscription) { nil } + + it "returns an error" do + subject + + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when pending next subscription" do + let(:subscription) { create(:subscription) } + let(:next_subscription) do + create( + :subscription, + previous_subscription: subscription, + status: :pending + ) + end + + before { next_subscription } + + it "cancels the next subscription" do + subject + + expect(result).to be_success + expect(next_subscription.reload).to be_canceled + end + end + + context "when subscription was paid in advance" do + let(:plan) { create(:plan, :pay_in_advance) } + let(:subscription) do + create( + :subscription, + :anniversary, + plan:, + started_at: creation_time, + subscription_at: creation_time, + **(subscription_termination_credit_note ? {on_termination_credit_note: subscription_termination_credit_note} : {}) + ) + end + let(:creation_time) { Time.current.beginning_of_month - 1.month } + let(:date_service) do + Subscriptions::DatesService.new_instance( + subscription, + Time.current.beginning_of_month, + current_usage: false + ) + end + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + recurring: true, + from_datetime: date_service.from_datetime, + to_datetime: date_service.to_datetime, + charges_from_datetime: date_service.charges_from_datetime, + charges_to_datetime: date_service.charges_to_datetime + ) + end + let(:invoice) do + create( + :invoice, + customer: subscription.customer, + currency: "EUR", + sub_total_excluding_taxes_amount_cents: 100, + fees_amount_cents: 100, + taxes_amount_cents: 20, + total_amount_cents: 120 + ) + end + + let(:last_subscription_fee) do + create( + :fee, + subscription:, + invoice:, + amount_cents: 100, + taxes_amount_cents: 20, + invoiceable_type: "Subscription", + invoiceable_id: subscription.id, + taxes_rate: 20 + ) + end + let(:subscription_termination_credit_note) { nil } + + before do + invoice_subscription + last_subscription_fee + end + + [nil, "", "credit"].each do |on_termination_credit_note| + context "when on_termination_credit_note is #{on_termination_credit_note.inspect}" do + let(:on_termination_credit_note) { on_termination_credit_note } + + it "creates a credit note for the remaining days" do + travel_to(Time.current.end_of_month - 4.days) do + expect { subject }.to change(CreditNote, :count).by(1) + end + end + + it "updates the subscription termination behavior" do + travel_to(Time.current.end_of_month - 4.days) do + subject + expect(subscription.reload.on_termination_credit_note).to eq("credit") + end + end + end + end + + context "when on_termination_credit_note is skip" do + let(:on_termination_credit_note) { "skip" } + + it "does not create a credit note for the remaining days" do + travel_to(Time.current.end_of_month - 4.days) do + expect { subject }.not_to change(CreditNote, :count) + end + end + + it "updates the subscription termination behavior" do + travel_to(Time.current.end_of_month - 4.days) do + subject + expect(subscription.reload.on_termination_credit_note).to eq("skip") + end + end + end + + context "when on_termination_credit_note is refund" do + let(:on_termination_credit_note) { "refund" } + + it "creates a credit note for the remaining days with refund" do + travel_to(Time.current.end_of_month - 4.days) do + expect { subject }.to change(CreditNote, :count).by(1) + end + end + + it "updates the subscription termination behavior" do + travel_to(Time.current.end_of_month - 4.days) do + subject + expect(subscription.reload.on_termination_credit_note).to eq("refund") + end + end + end + + context "when on_termination_credit_note is offset" do + let(:on_termination_credit_note) { "offset" } + + it "creates a credit note for the remaining days with offset" do + travel_to(Time.current.end_of_month - 4.days) do + expect { subject }.to change(CreditNote, :count).by(1) + end + end + + it "updates the subscription termination behavior" do + travel_to(Time.current.end_of_month - 4.days) do + subject + expect(subscription.reload.on_termination_credit_note).to eq("offset") + end + end + end + + context "when on_termination_credit_note is not set" do + subject(:result) { described_class.call(subscription:) } + + let(:subscription_termination_credit_note) { "skip" } + + it "rely on the subscription on_termination_credit_notek" do + travel_to(Time.current.end_of_month - 4.days) do + expect { subject }.not_to change(CreditNote, :count) + end + end + end + + context "when on_termination_credit_note is invalid" do + let(:on_termination_credit_note) { "invalid" } + + it "raises an error" do + subject + + expect(result).to be_failure + expect(result.error.messages).to include({on_termination_credit_note: ["invalid_value"]}) + end + end + + context "when invoice subscription is not generated" do + let(:invoice_subscription) { nil } + + it "does not create a credit note for the remaining days" do + expect { subject }.not_to change(CreditNote, :count) + end + end + end + + context "when subscription is pay in arrears" do + let(:on_termination_credit_note) { "credit" } + + before do + subscription.plan.update!(pay_in_advance: false) + end + + it "does not create a credit note" do + expect { subject }.not_to change(CreditNote, :count) + end + + it "updates the subscription termination behavior" do + subject + expect(subscription.reload.on_termination_credit_note).to eq(nil) + end + end + + context "with on_termination_invoice parameter" do + subject(:result) { described_class.call(subscription:, on_termination_invoice:) } + + context "when on_termination_invoice is generate" do + let(:on_termination_invoice) { "generate" } + + it "enqueues a BillSubscriptionJob" do + freeze_time do + expect { subject }.to have_enqueued_job_after_commit(BillSubscriptionJob).with([subscription], Time.current, invoicing_reason: :subscription_terminating) + end + end + + it "updates the subscription on_termination_invoice" do + subject + expect(subscription.reload.on_termination_invoice).to eq("generate") + end + end + + context "when on_termination_invoice is skip" do + let(:on_termination_invoice) { "skip" } + + it "does not enqueue a BillSubscriptionJob" do + expect { subject }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "still enqueues a BillNonInvoiceableFeesJob" do + freeze_time do + expect { subject }.to have_enqueued_job_after_commit(BillNonInvoiceableFeesJob) + .with([subscription], Time.current) + end + end + + it "updates the subscription on_termination_invoice" do + subject + expect(subscription.reload.on_termination_invoice).to eq("skip") + end + end + + context "when on_termination_invoice is invalid" do + let(:on_termination_invoice) { "invalid" } + + it "raises an error" do + subject + + expect(result).to be_failure + expect(result.error.messages).to include({on_termination_invoice: ["invalid_value"]}) + end + end + end + end + + describe "#terminate_and_start_next" do + let(:subscription) { create(:subscription) } + let(:next_subscription) { create(:subscription, :pending, previous_subscription_id: subscription.id) } + let(:timestamp) { Time.zone.now.to_i } + + before do + next_subscription + end + + it "terminates the subscription" do + result = terminate_service.terminate_and_start_next(timestamp:) + + expect(result).to be_success + expect(subscription.reload).to be_terminated + end + + it "starts the next subscription" do + result = terminate_service.terminate_and_start_next(timestamp:) + + expect(result).to be_success + expect(result.subscription.id).to eq(next_subscription.id) + expect(result.subscription).to be_active + end + + it "enqueues a SendWebhookJob" do + terminate_service.terminate_and_start_next(timestamp:) + expect(SendWebhookJob).to have_been_enqueued.with("subscription.terminated", subscription) + expect(SendWebhookJob).to have_been_enqueued.with("subscription.started", next_subscription) + end + + it "produces the activity logs" do + terminate_service.terminate_and_start_next(timestamp:) + expect(Utils::ActivityLog).to have_produced("subscription.terminated").with(subscription) + expect(Utils::ActivityLog).to have_produced("subscription.started").with(next_subscription) + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: next_subscription.plan) } + + before { fixed_charge } + + it "creates fixed charge events for the new subscription" do + result = terminate_service.terminate_and_start_next(timestamp:) + expect(result.subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)) + .to match_array([[fixed_charge.id, be_within(5.seconds).of(Time.zone.at(timestamp))]]) + end + end + + context "when terminated subscription is payed in arrear" do + before { subscription.plan.update!(pay_in_advance: false) } + + it "enqueues a job to bill the existing subscription" do + expect do + terminate_service.terminate_and_start_next(timestamp:) + end.to have_enqueued_job(BillSubscriptionJob).and have_enqueued_job(BillNonInvoiceableFeesJob) + end + end + + context "when next subscription is payed in advance" do + let(:plan) { create(:plan, :pay_in_advance) } + let(:subscription) { create(:subscription, plan:) } + let(:next_subscription_plan) { create(:plan, :pay_in_advance) } + let(:next_subscription) do + create( + :subscription, + previous_subscription_id: subscription.id, + plan: next_subscription_plan, + status: :pending + ) + end + + it "enqueues one job" do + terminate_service.terminate_and_start_next(timestamp:) + + expect(BillSubscriptionJob).to have_been_enqueued + .with([subscription, next_subscription], timestamp, invoicing_reason: :upgrading) + end + end + end +end diff --git a/spec/services/subscriptions/terminated_dates_service_spec.rb b/spec/services/subscriptions/terminated_dates_service_spec.rb new file mode 100644 index 0000000..a20b6d8 --- /dev/null +++ b/spec/services/subscriptions/terminated_dates_service_spec.rb @@ -0,0 +1,448 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::TerminatedDatesService do + subject(:terminated_date_service) { described_class.new(subscription:, invoice:, date_service:, match_invoice_subscription:) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:, interval: :monthly) } + let(:subscription_at) { DateTime.parse("02 Feb 2021") } + let(:started_at) { subscription_at } + let(:billing_date) { DateTime.parse("2022-03-07 04:20:46.011") } + let(:invoice) { create(:invoice, organization:, customer: subscription.customer) } + let(:match_invoice_subscription) { true } + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + timestamp: billing_date + ) + end + + let(:date_service) { instance_double(Subscriptions::DatesService) } + + before do + invoice_subscription + end + + describe "#call" do + subject(:service_call) { terminated_date_service.call } + + let(:service_current_usage) { service_call.__send__(:current_usage) } + + context "when subscription is terminated" do + let(:subscription) do + create( + :subscription, + :terminated, + plan:, + subscription_at:, + billing_time: :calendar, + started_at: + ) + end + + context "when termination date is started_at date" do + let(:billing_date) { started_at } + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + + context "when termination date is earlier than charges_to_datetime date" do + let(:billing_date) { DateTime.parse("2022-06-01 04:20:46.011") } + + let(:new_dates_service) do + instance_double( + Subscriptions::DatesService, + charges_to_datetime: DateTime.parse("2022-06-02 04:20:46.011") + ) + end + + before do + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .and_return(new_dates_service) + end + + it "calls Subscriptions::DatesService.new_instance" do + service_call + + expect(Subscriptions::DatesService) + .to have_received(:new_instance) + .with(kind_of(Subscription), billing_date - 1.day, current_usage: true) + end + + it "returns a new dates service" do + expect(service_call).to eq(date_service) + end + end + + context "when there are more than one day between charges_to_datetime and termination date" do + let(:billing_date) { DateTime.parse("2022-06-03 04:20:46.011") } + + let(:new_dates_service) do + instance_double( + Subscriptions::DatesService, + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + before do + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .and_return(new_dates_service) + end + + it "calls Subscriptions::DatesService.new_instance" do + service_call + + expect(Subscriptions::DatesService) + .to have_received(:new_instance) + .with(kind_of(Subscription), billing_date - 1.day, current_usage: true) + end + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + + context "when termination date is earlier than fixed_charges_to_datetime date" do + let(:billing_date) { DateTime.parse("2022-06-03 04:20:46.011") } + + let(:new_dates_service) do + instance_double( + Subscriptions::DatesService, + charges_to_datetime: DateTime.parse("2022-06-03 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-05 04:20:46.011") + ) + end + + before do + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .and_return(new_dates_service) + end + + it "calls Subscriptions::DatesService.new_instance" do + service_call + + expect(Subscriptions::DatesService) + .to have_received(:new_instance) + .with(kind_of(Subscription), billing_date - 1.day, current_usage: true) + end + + it "returns a new dates service" do + expect(service_call).to eq(date_service) + end + end + + context "when there is more than one day between fixed_charges_to_datetime and termination date" do + let(:billing_date) { DateTime.parse("2022-06-03 04:20:46.011") } + + let(:new_dates_service) do + instance_double( + Subscriptions::DatesService, + charges_to_datetime: DateTime.parse("2022-06-03 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-02 04:20:46.011") + ) + end + + before do + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .and_return(new_dates_service) + end + + it "calls Subscriptions::DatesService.new_instance" do + service_call + + expect(Subscriptions::DatesService) + .to have_received(:new_instance) + .with(kind_of(Subscription), billing_date - 1.day, current_usage: true) + end + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + + context "when not matching invoice subscription" do + let(:match_invoice_subscription) { false } + let(:billing_date) { DateTime.parse("2022-06-01 04:20:46.011") } + + let(:new_dates_service_1) do + instance_double( + Subscriptions::DatesService, + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + let(:new_dates_service_2) do + instance_double( + Subscriptions::DatesService, + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + before do + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .with(kind_of(Subscription), billing_date - 1.day, current_usage: true) + .and_return(new_dates_service_1) + + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .with(kind_of(Subscription), billing_date, current_usage: false) + .and_return(new_dates_service_2) + end + + it "returns a new dates service" do + expect(service_call).to eq(new_dates_service_2) + end + end + + context "when matching invoice subscription" do + let(:match_invoice_subscription) { true } + let(:billing_date) { DateTime.parse("2022-06-01 04:20:46.011") } + + let(:new_dates_service_1) do + instance_double( + Subscriptions::DatesService, + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + let(:new_dates_service_2) do + instance_double( + Subscriptions::DatesService, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + before do + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .with(kind_of(Subscription), billing_date - 1.day, current_usage: true) + .and_return(new_dates_service_1) + + allow(Subscriptions::DatesService) + .to receive(:new_instance) + .with(kind_of(Subscription), billing_date, current_usage: false) + .and_return(new_dates_service_2) + end + + context "when there is not matching invoice subscription" do + it "returns a new dates service" do + expect(service_call).to eq(new_dates_service_2) + end + end + + it "returns the same dates service" do + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + + expect(service_call).to eq(date_service) + end + + context "when plan splits charges in monthly intervals" do + before do + allow(plan).to receive(:charges_billed_in_monthly_split_intervals?).and_return(true) + end + + it "returns a new dates service" do + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + + expect(service_call).to eq(new_dates_service_2) + end + + context "when there is a matching invoice subscription" do + before do + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + end + + context "when plan splits fixed charges in monthly intervals" do + before do + allow(plan).to receive(:fixed_charges_billed_in_monthly_split_intervals?).and_return(true) + end + + it "returns a new dates service" do + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + + expect(service_call).to eq(new_dates_service_2) + end + + context "when there is a matching invoice subscription" do + before do + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + end + + context "when plan splits both charges and fixed charges in monthly intervals" do + before do + allow(plan).to receive(:charges_billed_in_monthly_split_intervals?).and_return(true) + allow(plan).to receive(:fixed_charges_billed_in_monthly_split_intervals?).and_return(true) + end + + it "returns a new dates service" do + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + + expect(service_call).to eq(new_dates_service_2) + end + + context "when there is a matching invoice subscription" do + before do + create( + :invoice_subscription, + subscription:, + recurring: true, + from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_from_datetime: DateTime.parse("2022-06-01 04:20:46.011"), + fixed_charges_to_datetime: DateTime.parse("2022-06-01 04:20:46.011") + ) + end + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + end + end + + context "when pay in advance subscription is yearly with monthly charges in advance and dates_service does not have fixed_charges_boundaries" do + let(:plan) { create(:plan, organization:, interval: :yearly, bill_charges_monthly: true, pay_in_advance: true) } + let(:subscription) { create(:subscription, :terminated, plan:, subscription_at:, billing_time: :anniversary, started_at:) } + let(:subscription_at) { DateTime.parse("02 Feb 2021") } + let(:started_at) { subscription_at } + # in the middle of the yearly billing period fixed_charges won't be charged, so the + # date service won't return fixed_charges boundaries + let(:billing_date) { DateTime.parse("2022-06-02 00:01:46.011") } + + it "returns the new date service because subscription is yearly, but the charges were monthly" do + expect(service_call).not_to eq(date_service) + end + end + end + + context "when subscription has next subscription" do + let(:subscription) do + create(:subscription, plan:, subscription_at:, billing_time: :anniversary, started_at:) + end + + let(:next_subscription) do + create( + :subscription, + :pending, + previous_subscription: subscription, + plan:, + subscription_at:, + billing_time: :anniversary, + started_at: nil + ) + end + + before do + next_subscription + end + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + + context "when subscription is not terminated" do + let(:subscription) do + create(:subscription, plan:, subscription_at:, billing_time: :anniversary, started_at:) + end + + it "returns the same dates service" do + expect(service_call).to eq(date_service) + end + end + end +end diff --git a/spec/services/subscriptions/update_or_override_charge_service_spec.rb b/spec/services/subscriptions/update_or_override_charge_service_spec.rb new file mode 100644 index 0000000..ceedda3 --- /dev/null +++ b/spec/services/subscriptions/update_or_override_charge_service_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::UpdateOrOverrideChargeService do + subject(:service) { described_class.new(subscription:, charge:, params:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:params) do + { + invoice_display_name: "Overridden Charge", + min_amount_cents: 500, + properties: {amount: "150"} + } + end + + describe "#call" do + context "without premium license" do + it "returns forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "with premium license", :premium do + before do + charge + subscription + end + + it "creates a plan override" do + expect { service.call }.to change(Plan, :count).by(1) + + new_plan = subscription.reload.plan + expect(new_plan.parent_id).to eq(plan.id) + end + + it "creates charge override via plan override" do + expect { service.call }.to change(Charge, :count).by(1) + end + + it "returns the charge override with parent_id" do + result = service.call + + expect(result.charge.parent_id).to eq(charge.id) + end + + it "assigns the charge override to the new plan" do + result = service.call + + expect(result.charge.plan_id).not_to eq(plan.id) + expect(result.charge.plan.parent_id).to eq(plan.id) + end + + it "updates the subscription to use the overridden plan" do + service.call + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + it "applies the override params to the charge" do + result = service.call + + expect(result.charge.invoice_display_name).to eq("Overridden Charge") + expect(result.charge.min_amount_cents).to eq(500) + expect(result.charge.properties).to eq({"amount" => "150"}) + end + + context "when subscription already has a plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + + it "does not create a new plan" do + expect { service.call }.not_to change(Plan, :count) + end + + it "creates the charge override on the existing overridden plan" do + result = service.call + + expect(result.charge.plan_id).to eq(overridden_plan.id) + expect(result.charge.parent_id).to eq(charge.id) + end + end + + context "when charge override already exists" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let!(:existing_override) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: charge, code: charge.code) } + + it "does not create a new charge" do + expect { service.call }.not_to change(Charge, :count) + end + + it "updates the existing charge override" do + result = service.call + + expect(result.charge.id).to eq(existing_override.id) + expect(result.charge.invoice_display_name).to eq("Overridden Charge") + expect(result.charge.min_amount_cents).to eq(500) + end + end + + context "when the charge passed is itself an override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let(:parent_charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let!(:charge) { create(:standard_charge, plan: overridden_plan, organization:, billable_metric:, parent: parent_charge, code: parent_charge.code) } + + it "does not create a new charge" do + expect { service.call }.not_to change(Charge, :count) + end + + it "updates the existing charge override" do + result = service.call + + expect(result.charge.id).to eq(charge.id) + expect(result.charge.invoice_display_name).to eq("Overridden Charge") + end + end + + context "with tax_codes" do + let(:tax) { create(:tax, organization:) } + let(:params) do + { + invoice_display_name: "Taxed Charge", + tax_codes: [tax.code] + } + end + + it "applies taxes to the charge override" do + result = service.call + + expect(result.charge.taxes).to include(tax) + end + end + + context "with filters" do + let(:billable_metric_filter) { create(:billable_metric_filter, billable_metric:) } + let(:params) do + { + invoice_display_name: "Filtered Charge", + filters: [ + { + invoice_display_name: "Filter Override", + properties: {amount: "75"}, + values: {billable_metric_filter.key => [billable_metric_filter.values.first]} + } + ] + } + end + + it "applies filters to the charge override" do + result = service.call + + expect(result.charge.filters).to be_present + expect(result.charge.filters.first.invoice_display_name).to eq("Filter Override") + end + end + end + end +end diff --git a/spec/services/subscriptions/update_or_override_fixed_charge_service_spec.rb b/spec/services/subscriptions/update_or_override_fixed_charge_service_spec.rb new file mode 100644 index 0000000..7db6618 --- /dev/null +++ b/spec/services/subscriptions/update_or_override_fixed_charge_service_spec.rb @@ -0,0 +1,202 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::UpdateOrOverrideFixedChargeService do + subject(:service) { described_class.new(subscription:, fixed_charge:, params:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:add_on) { create(:add_on, organization:) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + let(:params) do + { + invoice_display_name: "Overridden Fixed Charge", + units: "10" + } + end + + describe "#call" do + context "without premium license" do + it "returns forbidden failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ForbiddenFailure) + end + end + + context "with premium license", :premium do + before do + fixed_charge + subscription + end + + it "creates a plan override" do + expect { service.call }.to change(Plan, :count).by(1) + + new_plan = subscription.reload.plan + expect(new_plan.parent_id).to eq(plan.id) + end + + it "creates fixed charge override via plan override" do + expect { service.call }.to change(FixedCharge, :count).by(1) + end + + it "returns the fixed charge override with parent_id" do + result = service.call + + expect(result.fixed_charge.parent_id).to eq(fixed_charge.id) + end + + it "assigns the fixed charge override to the new plan" do + result = service.call + + expect(result.fixed_charge.plan_id).not_to eq(plan.id) + expect(result.fixed_charge.plan.parent_id).to eq(plan.id) + end + + it "updates the subscription to use the overridden plan" do + service.call + + subscription.reload + expect(subscription.plan.parent_id).to eq(plan.id) + end + + it "applies the override params to the fixed charge" do + result = service.call + + expect(result.fixed_charge.invoice_display_name).to eq("Overridden Fixed Charge") + expect(result.fixed_charge.units).to eq(10) + end + + context "when subscription is nil" do + let(:subscription) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("subscription") + end + end + + context "when fixed_charge is nil" do + let(:fixed_charge) { nil } + + it "returns not found failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.resource).to eq("fixed_charge") + end + end + + context "when subscription already has a plan override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + + it "does not create a new plan" do + expect { service.call }.not_to change(Plan, :count) + end + + it "creates the fixed charge override on the existing overridden plan" do + result = service.call + + expect(result.fixed_charge.plan_id).to eq(overridden_plan.id) + expect(result.fixed_charge.parent_id).to eq(fixed_charge.id) + end + end + + context "when fixed charge override already exists" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let!(:existing_override) { create(:fixed_charge, plan: overridden_plan, organization:, add_on:, parent: fixed_charge, code: fixed_charge.code) } + + it "does not create a new fixed charge" do + expect { service.call }.not_to change(FixedCharge, :count) + end + + it "updates the existing fixed charge override" do + result = service.call + + expect(result.fixed_charge.id).to eq(existing_override.id) + expect(result.fixed_charge.invoice_display_name).to eq("Overridden Fixed Charge") + expect(result.fixed_charge.units).to eq(10) + end + + it "calls EmitEventsService" do + allow(FixedCharges::EmitEventsService).to receive(:call!) + + service.call + + expect(FixedCharges::EmitEventsService).to have_received(:call!).with( + fixed_charge: existing_override, + subscription:, + apply_units_immediately: false + ) + end + + context "with apply_units_immediately param" do + let(:params) do + { + invoice_display_name: "Overridden Fixed Charge", + units: "10", + apply_units_immediately: true + } + end + + it "calls EmitEventsService with apply_units_immediately true" do + allow(FixedCharges::EmitEventsService).to receive(:call!) + + service.call + + expect(FixedCharges::EmitEventsService).to have_received(:call!).with( + fixed_charge: existing_override, + subscription:, + apply_units_immediately: true + ) + end + end + end + + context "when the fixed charge passed is itself an override" do + let(:overridden_plan) { create(:plan, organization:, parent: plan) } + let(:subscription) { create(:subscription, customer:, plan: overridden_plan) } + let(:parent_fixed_charge) { create(:fixed_charge, plan:, organization:, add_on:) } + let!(:fixed_charge) { create(:fixed_charge, plan: overridden_plan, organization:, add_on:, parent: parent_fixed_charge, code: parent_fixed_charge.code) } + + it "does not create a new fixed charge" do + expect { service.call }.not_to change(FixedCharge, :count) + end + + it "updates the existing fixed charge override" do + result = service.call + + expect(result.fixed_charge.id).to eq(fixed_charge.id) + expect(result.fixed_charge.invoice_display_name).to eq("Overridden Fixed Charge") + end + end + + context "with tax_codes" do + let(:tax) { create(:tax, organization:) } + let(:params) do + { + invoice_display_name: "Taxed Fixed Charge", + tax_codes: [tax.code] + } + end + + it "applies taxes to the fixed charge override" do + result = service.call + + expect(result.fixed_charge.taxes).to include(tax) + end + end + end + end +end diff --git a/spec/services/subscriptions/update_service_spec.rb b/spec/services/subscriptions/update_service_spec.rb new file mode 100644 index 0000000..140e71b --- /dev/null +++ b/spec/services/subscriptions/update_service_spec.rb @@ -0,0 +1,1113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::UpdateService do + subject(:update_service) { described_class.new(subscription:, params:) } + + let(:membership) { create(:membership) } + let(:subscription) { create(:subscription) } + + describe "#call" do + let(:subscription_at) { "2022-07-07T00:00:00Z" } + let(:ending_at) { Time.current.beginning_of_day + 1.month } + + let(:params) do + { + name: "new name", + ending_at:, + subscription_at: + } + end + + before do + subscription + end + + context "when subscription is incomplete" do + let(:subscription) { create(:subscription, :incomplete) } + + it "returns a not allowed error" do + result = update_service.call + + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.code).to eq("subscription_incomplete") + end + end + + context "when both usage_thresholds and plan_overrides.usage_thresholds are present" do + let(:params) do + { + name: "new name", + ending_at:, + subscription_at:, + usage_thresholds: [{threshold_display_name: "Threshold 1"}], + plan_overrides: { + usage_thresholds: [{threshold_display_name: "Override Threshold"}] + } + } + end + + it "returns a validation error", :premium do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:"plan_overrides.usage_thresholds"]).to eq(["incompatible_params"]) + expect(result.error.messages[:usage_thresholds]).to eq(["incompatible_params"]) + end + end + + context "when usage_thresholds are present", :premium do + let(:usage_thresholds) { [amount_cents: 99_00] } + + before do + subscription.organization.update!(premium_integrations: ["progressive_billing"]) + allow(Subscriptions::UpdateUsageThresholdsService).to receive(:call!).and_return(BaseResult.new) + end + + context "when under subscription" do + let(:params) { {usage_thresholds:} } + + it "calls UpdateUsageThresholdsService" do + update_service.call + expect(Subscriptions::UpdateUsageThresholdsService).to have_received(:call!).with(subscription:, usage_thresholds_params: params[:usage_thresholds], partial: false) + end + end + + context "when under plan_overrides" do + it "ignores UpdateUsageThresholdsService" do + update_service.call + expect(Subscriptions::UpdateUsageThresholdsService).not_to have_received(:call!) + end + end + end + + context "when subscription is already active" do + it "updates the subscription and ignores subscription_at" do + result = update_service.call + + expect(result).to be_a(BaseResult) + expect(result).to be_success + + expect(result.subscription.name).to eq("new name") + expect(result.subscription.ending_at).to eq(Time.current.beginning_of_day + 1.month) + expect(result.subscription.subscription_at.to_s).not_to include("2022-07-07") + end + + it "sends updated subscription webhook" do + expect { update_service.call }.to have_enqueued_job_after_commit(SendWebhookJob).with("subscription.updated", subscription) + end + + it "does not sync to Hubspot" do + expect { update_service.call }.not_to have_enqueued_job(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob) + end + + it "produces an activity log after commit" do + described_class.call(subscription:, params:) + + expect(Utils::ActivityLog).to have_produced("subscription.updated").after_commit.with(subscription) + end + + context "when subscription should be synced with Hubspot" do + let(:params) { {name: "new name"} } + let(:customer) { create(:customer, :with_hubspot_integration) } + let(:subscription) { create(:subscription, customer:) } + + it "enqueues a job to update Hubspot subscription" do + expect { + result = update_service.call + + expect(result).to be_success + }.to have_enqueued_job_after_commit(Integrations::Aggregator::Subscriptions::Hubspot::UpdateJob).with(subscription:) + end + end + + context "when subscription_at is not passed at all" do + let(:params) { {name: "new name"} } + + it "updates the subscription" do + result = update_service.call + + expect(result).to be_success + + expect(result.subscription.name).to eq("new name") + expect(result.subscription.subscription_at.to_s).not_to include("2022-07-07") + end + end + + context "when updating on_termination_credit_note" do + let(:params) { {on_termination_credit_note: "credit"} } + + context "with pay_in_advance plan" do + let(:plan) { create(:plan, :pay_in_advance) } + let(:subscription) { create(:subscription, plan:) } + + %w[credit skip].each do |value| + context "when on_termination_credit_note is #{value}" do + let(:params) { {on_termination_credit_note: value} } + + it "accepts the value for pay_in_advance plans" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.on_termination_credit_note).to eq(value) + end + end + end + end + + context "with pay_in_arrears plan" do + it "ignores the value" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.on_termination_credit_note).to be_nil + end + end + end + + context "when updating on_termination_invoice" do + let(:params) { {on_termination_invoice: "generate"} } + + %w[generate skip].each do |value| + context "when on_termination_invoice is #{value}" do + let(:params) { {on_termination_invoice: value} } + + it "accepts the value" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.on_termination_invoice).to eq(value) + end + end + end + end + + context "when updating progressive_billing_disabled" do + let(:params) { {progressive_billing_disabled: true} } + + it "updates progressive_billing_disabled" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.progressive_billing_disabled).to be(true) + end + + context "when setting to false" do + let(:subscription) { create(:subscription, progressive_billing_disabled: true) } + let(:params) { {progressive_billing_disabled: false} } + + it "updates progressive_billing_disabled to false" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.progressive_billing_disabled).to be(false) + end + end + end + + context "when updating payment method" do + let(:payment_method) { create(:payment_method, organization: subscription.organization, customer: subscription.customer) } + let(:params) { {payment_method: payment_method_params} } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + + before { payment_method } + + it "updates the subscription" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.reload.payment_method_id).to eq(payment_method_params[:payment_method_id]) + expect(result.subscription.reload.payment_method_type).to eq("provider") + end + + context "when payment method is already attached" do + before do + subscription.payment_method = payment_method + subscription.payment_method_type = "provider" + end + + let(:payment_method_params) do + { + payment_method_id: nil, + payment_method_type: "provider" + } + end + + it "removes payment_method" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.reload.payment_method_id).to eq(nil) + expect(result.subscription.reload.payment_method_type).to eq("provider") + end + end + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: subscription.plan) } + + before { fixed_charge } + + it "does not create fixed charge events" do + expect { update_service.call }.not_to change(FixedChargeEvent, :count) + end + end + end + + context "when subscription is starting in the future" do + let(:subscription) { create(:subscription, :pending) } + + it "does not produce activity log" do + update_service.call + + expect(Utils::ActivityLog).not_to have_received(:produce) + end + + context "when subscription is pay_in_advance" do + let(:plan) { create(:plan, :pay_in_advance) } + let(:subscription) { create(:subscription, :pending, plan:) } + + context "when subscription_at is set to past date" do + it "updates the subscription_at as well" do + result = update_service.call + + expect(result).to be_success + + expect(result.subscription.name).to eq("new name") + expect(result.subscription.subscription_at.to_s).to eq("2022-07-07 00:00:00 UTC") + end + + it "does not enqueue a job to bill the subscription" do + expect { update_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "when plan has pay in advance fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: subscription.plan, pay_in_advance: true) } + + before { fixed_charge } + + it "creates fixed charge events" do + expect { update_service.call }.to change(FixedChargeEvent, :count).by(1) + expect(subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)) + .to contain_exactly([fixed_charge.id, be_within(1.second).of(subscription.started_at)]) + end + + it "does not enqueue a job to bill the pay in advance fixed charges" do + expect { update_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "when subscription date is set to today" do + around do |test| + travel_to("2022-07-07T01:00:00Z") do + test.run + end + end + + it "activates subscription" do + result = update_service.call + + expect(result).to be_success + + expect(result.subscription.name).to eq("new name") + expect(result.subscription.status).to eq("active") + expect(result.subscription.subscription_at.to_s).to eq subscription.subscription_at.to_s + end + + it "enqueues a job to bill the subscription" do + expect { update_service.call }.to have_enqueued_job_after_commit(BillSubscriptionJob) + .with([subscription], Time.now.to_i, invoicing_reason: :subscription_starting) + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: subscription.plan) } + + before { fixed_charge } + + it "creates fixed charge events" do + expect { update_service.call }.to change(FixedChargeEvent, :count).by(1) + expect(subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)) + .to contain_exactly([fixed_charge.id, be_within(1.second).of(subscription.started_at)]) + end + end + end + + context "when subscription_at is set to future date" do + let(:subscription_at) { 1.week.from_now.iso8601 } + + it "keeps subscription pending and updates subscription_at" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.status).to eq("pending") + expect(result.subscription.subscription_at).to eq(subscription_at) + end + + it "does not enqueue billing job" do + expect { update_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: subscription.plan) } + + before { fixed_charge } + + it "does not create fixed charge events" do + expect { update_service.call }.not_to change(FixedChargeEvent, :count) + end + end + end + end + + context "when plan is NOT pay_in_advance" do + context "when subscription_at is today" do + let(:subscription_at) { Time.current } + + it "does not enqueue billing job" do + expect { update_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "when plan has fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: subscription.plan) } + + before { fixed_charge } + + it "creates fixed charge events" do + expect { update_service.call }.to change(FixedChargeEvent, :count).by(1) + expect(subscription.fixed_charge_events.pluck(:fixed_charge_id, :timestamp)) + .to match_array([[fixed_charge.id, be_within(5.seconds).of(Time.current)]]) + end + + it "does not schedule a BillSubscriptionJob" do + expect { update_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "when at least one fixed_charge is pay in advance" do + let(:fixed_charge_2) { create(:fixed_charge, plan: subscription.plan, pay_in_advance: true) } + + before { fixed_charge_2 } + + it "does not schedule a BillSubscriptionJob" do + expect { update_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "schedules a Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { update_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + end + + context "when subscription_at is in the past" do + it "does not enqueue a job to bill the subscription" do + expect { update_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + context "when plan has pay in advance fixed charges" do + let(:fixed_charge) { create(:fixed_charge, plan: subscription.plan, pay_in_advance: true) } + + before { fixed_charge } + + it "does not enqueue a job to bill the pay in advance fixed charges" do + expect { update_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + end + + context "when updating subscription without changing subscription_at" do + let(:params) { {name: "new name"} } + + it "updates the subscription without processing subscription_at change" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.name).to eq("new name") + end + end + end + + context "when subscription is nil" do + let(:params) do + { + name: "new name" + } + end + + let(:subscription) { nil } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("subscription_not_found") + end + end + + context "when validation fails" do + context "with invalid subscription_at format" do + let(:params) { {subscription_at: "invalid-date"} } + + it "returns validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({subscription_at: ["invalid_date"]}) + end + end + + context "with invalid ending_at format" do + let(:params) { {ending_at: "invalid-date"} } + + it "returns validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({ending_at: ["invalid_date"]}) + end + end + + context "with ending_at in the past" do + let(:params) { {ending_at: 1.day.ago} } + + it "returns validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({ending_at: ["invalid_date"]}) + end + end + + context "with ending_at before subscription_at" do + let(:params) { {ending_at: 1.day.from_now, subscription_at: 2.days.from_now} } + + it "returns validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({ending_at: ["invalid_date"]}) + end + end + + context "when payment method type is not correct" do + let(:payment_method) { create(:payment_method, organization: subscription.organization, customer: subscription.customer) } + let(:params) { {payment_method: payment_method_params} } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when payment method id is not correct" do + let(:payment_method) { create(:payment_method, organization: subscription.organization, customer: subscription.customer) } + let(:params) { {payment_method: payment_method_params} } + let(:payment_method_params) do + { + payment_method_id: "123", + payment_method_type: "provider" + } + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "with invalid on_termination_credit_note" do + let(:params) { {on_termination_credit_note: "invalid_value"} } + + it "returns validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({on_termination_credit_note: ["invalid_value"]}) + end + end + + context "with invalid on_termination_invoice" do + let(:params) { {on_termination_invoice: "invalid_value"} } + + it "returns validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.messages).to eq({on_termination_invoice: ["invalid_value"]}) + end + end + end + + context "when plan_overrides" do + let(:plan) { create(:plan, organization: membership.organization) } + let(:subscription) { create(:subscription, plan:) } + let(:params) do + { + plan_overrides: { + name: "new name" + } + } + end + + context "when License is premium", :premium do + it "creates the new plan accordingly" do + update_service.call + + expect(subscription.plan.name).to eq("new name") + expect(subscription.plan_id).not_to eq(plan.id) + expect(subscription.plan.parent_id).to eq(plan.id) + end + + context "when plan_overrides params are invalid" do + let(:params) do + { + name: "NEW NAME", + plan_overrides: { + amount_currency: "MAGIC-COIN" + } + } + end + + it "returns an error" do + result = update_service.call + + expect(subscription.reload.name).not_to eq "NEW NAME" + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:amount_currency]).to eq(["value_is_invalid"]) + end + end + + context "with overriden plan" do + let(:parent_plan) { create(:plan, organization: membership.organization) } + let(:plan) { create(:plan, organization: membership.organization, parent_id: parent_plan.id) } + + it "updates the plan accordingly" do + update_service.call + + expect(subscription.plan.name).to eq("new name") + expect(subscription.plan_id).to eq(plan.id) + end + + context "when Plans::UpdateService fails" do + let(:failed_result) { BaseResult.new.validation_failure!(errors: {name: ["invalid_name"]}) } + + before do + allow(Plans::UpdateService).to receive(:call!).and_raise( + BaseService::FailedResult.new(failed_result, "Failed to update plan") + ) + end + + it "returns the error from Plans::UpdateService" do + result = update_service.call + + expect(result).to be_failure + expect(result.error.result.error.messages).to eq({name: ["invalid_name"]}) + end + end + + context "when Plans::OverrideService fails" do + let(:plan) { create(:plan, organization: membership.organization) } + let(:failed_result) { BaseResult.new.validation_failure!(errors: {amount_cents: ["invalid_amount"]}) } + + before do + allow(Plans::OverrideService).to receive(:call!).and_raise( + BaseService::FailedResult.new(failed_result, "Failed to override plan") + ) + end + + it "returns the error from Plans::OverrideService" do + result = update_service.call + + expect(result).to be_failure + expect(result.error.result.error.messages).to eq({amount_cents: ["invalid_amount"]}) + end + end + end + end + + context "when License is not premium" do + let(:params) do + { + name: "new name", + plan_overrides: { + amount_cents: 0 + } + } + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("feature_unavailable") + end + end + + context "with fixed charge overrides and apply_units_immediately true", :premium do + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:, interval: :weekly) } + let(:fixed_charge1) { create(:fixed_charge, plan:, units: 5) } + let(:fixed_charge2) { create(:fixed_charge, plan:, units: 10) } + let(:customer) { create(:customer, organization:) } + let(:subscription) do + create( + :subscription, + :calendar, + plan:, + customer:, + subscription_at:, + started_at: + ) + end + let(:subscription_at) { Date.new(2023, 9, 2) } + let(:started_at) { Date.new(2025, 5, 17) } + + let(:params) do + { + plan_overrides: { + fixed_charges: [ + { + id: fixed_charge2.id, + units: 300, + apply_units_immediately: true + } + ] + } + } + end + + before do + fixed_charge1 + fixed_charge2 + subscription + end + + it "creates override fixed charges for both fixed charges" do + expect { update_service.call }.to change(FixedCharge, :count).by(2) + + fc1_override = FixedCharge.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = FixedCharge.find_sole_by(parent_id: fixed_charge2.id) + + expect(fc1_override.units).to eq(fixed_charge1.units) + expect(fc2_override.units).to eq(300) + end + + it "creates 2 fixed charge events with correct timestamps and units" do + travel_to(Time.zone.local(2025, 10, 10, 15, 33)) do # Friday + expect { update_service.call }.to change(FixedChargeEvent, :count).by(2) + + fc1_override = FixedCharge.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = FixedCharge.find_sole_by(parent_id: fixed_charge2.id) + + next_billing_period_start = Time.zone.local(2025, 10, 13) # Next Monday + events = subscription.fixed_charge_events.pluck(%i[fixed_charge_id units timestamp]) + + expect(events).to contain_exactly( + [fc1_override.id, fixed_charge1.units, be_within(1.second).of(next_billing_period_start)], + [fc2_override.id, 300, be_within(1.second).of(Time.current)] + ) + end + end + + it "does not enqueue billing job" do + expect { update_service.call }.not_to have_enqueued_job(BillSubscriptionJob) + end + + it "does not schedule a Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { update_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + + context "when at least one fixed_charge is pay in advance" do + let(:fixed_charge2) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge2 } + + it "schedules a Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { update_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "with fixed charge overrides and apply_units_immediately false", :premium do + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:, interval: :weekly) } + let(:fixed_charge1) { create(:fixed_charge, plan:, units: 5) } + let(:fixed_charge2) { create(:fixed_charge, plan:, units: 10) } + let(:customer) { create(:customer, organization:) } + let(:subscription) do + create( + :subscription, + :calendar, + plan:, + customer:, + subscription_at:, + started_at: + ) + end + let(:subscription_at) { Date.new(2023, 9, 2) } + let(:started_at) { Date.new(2025, 5, 17) } + + let(:params) do + { + plan_overrides: { + fixed_charges: [ + { + id: fixed_charge1.id, + units: 15, + apply_units_immediately: false + }, + { + id: fixed_charge2.id + } + ] + } + } + end + + before do + fixed_charge1 + fixed_charge2 + subscription + end + + it "creates override fixed charges for both fixed charges" do + expect { update_service.call }.to change(FixedCharge, :count).by(2) + + fc1_override = FixedCharge.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = FixedCharge.find_sole_by(parent_id: fixed_charge2.id) + + expect(fc1_override.units).to eq(15) + expect(fc2_override.units).to eq(fixed_charge2.units) + end + + it "creates 2 fixed charge events with correct timestamps and units" do + travel_to(Time.zone.local(2025, 10, 10, 15, 33)) do # Friday + expect { update_service.call }.to change(FixedChargeEvent, :count).by(2) + + fc1_override = FixedCharge.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = FixedCharge.find_sole_by(parent_id: fixed_charge2.id) + + next_billing_period_start = Time.zone.local(2025, 10, 13) # Next Monday + events = subscription.fixed_charge_events.pluck(%i[fixed_charge_id units timestamp]) + + expect(events).to contain_exactly( + [fc1_override.id, 15, be_within(1.second).of(next_billing_period_start)], + [fc2_override.id, fixed_charge2.units, be_within(1.second).of(next_billing_period_start)] + ) + end + end + + it "does not schedule a Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { update_service.call }.not_to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + + context "when at least one fixed_charge is pay in advance" do + let(:fixed_charge2) { create(:fixed_charge, plan:, pay_in_advance: true) } + + before { fixed_charge2 } + + it "schedules a Invoices::CreatePayInAdvanceFixedChargesJob" do + expect { update_service.call }.to have_enqueued_job(Invoices::CreatePayInAdvanceFixedChargesJob) + end + end + end + + context "with pending subscription, fixed charge overrides and mixed apply_units_immediately", :premium do + let(:organization) { membership.organization } + let(:plan) { create(:plan, organization:, interval: :weekly) } + let(:fixed_charge1) { create(:fixed_charge, plan:, units: 5) } + let(:fixed_charge2) { create(:fixed_charge, plan:, units: 10) } + let(:customer) { create(:customer, organization:) } + let(:subscription_at) { 7.days.from_now } + + let(:subscription) do + create( + :subscription, + :calendar, + plan:, + customer:, + subscription_at:, + status: :pending + ) + end + + let(:params) do + { + plan_overrides: { + fixed_charges: [ + { + id: fixed_charge1.id, + units: 200, + apply_units_immediately: true + }, + { + id: fixed_charge2.id, + units: 300, + apply_units_immediately: false + } + ] + } + } + end + + before do + fixed_charge1 + fixed_charge2 + subscription + end + + it "creates override fixed charges for both fixed charges" do + expect { update_service.call }.to change(FixedCharge, :count).by(2) + + fc1_override = FixedCharge.find_sole_by(parent_id: fixed_charge1.id) + fc2_override = FixedCharge.find_sole_by(parent_id: fixed_charge2.id) + + expect(fc1_override.units).to eq(200) + expect(fc2_override.units).to eq(300) + end + + it "does not create fixed charge events for pending subscription" do + travel_to(Time.zone.local(2025, 10, 29, 15, 33)) do + expect { update_service.call }.not_to change(FixedChargeEvent, :count) + + subscription.reload + + expect(subscription).to be_pending + expect(subscription.plan.parent_id).to eq(plan.id) + expect(subscription.fixed_charge_events.count).to be_zero + end + end + end + end + + context "with empty params" do + let(:params) { {} } + + it "succeeds without making changes" do + original_name = subscription.name + result = update_service.call + + expect(result).to be_success + expect(result.subscription.name).to eq(original_name) + end + end + + context "with nil values in params" do + let(:params) { {name: nil, ending_at: nil} } + + it "handles nil values gracefully" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription.name).to be_nil + expect(result.subscription.ending_at).to be_nil + end + end + + context "when customer is missing" do + let(:subscription) { build(:subscription, customer: nil) } + let(:params) { {name: "new name"} } + + it "returns customer not found error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("customer_not_found") + end + end + + context "when plan is missing" do + let(:subscription) { build(:subscription, plan: nil) } + let(:params) { {name: "new name"} } + + it "returns plan not found error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("plan_not_found") + end + end + + context "with activation_rules" do + context "when subscription is pending" do + context "when activation rules exist" do + let(:subscription) { create(:subscription, :pending, :with_activation_rules, activation_rules_config: [{type: "payment", timeout_hours: 48}], subscription_at: Time.current + 5.days) } + + context "when rules are replaced" do + let(:params) { {activation_rules: [{type: "payment", timeout_hours: 12}]} } + + it "replaces existing rules" do + result = update_service.call + + expect(result).to be_success + rules = subscription.activation_rules.reload + expect(rules.count).to eq(1) + expect(rules.first.timeout_hours).to eq(12) + end + end + + context "when activation_rules is empty array" do + let(:params) { {activation_rules: []} } + + it "removes all activation rules" do + result = update_service.call + + expect(result).to be_success + expect(subscription.activation_rules.reload).to be_empty + end + end + + context "when subscription_at changes to past" do + let(:params) { {subscription_at: (Time.current - 5.days).iso8601} } + + it "deletes activation rules and activates the subscription" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription).to be_active + expect(subscription.activation_rules.reload).to be_empty + end + + context "when activation_rules are also provided in params" do + let(:params) { {subscription_at: (Time.current - 5.days).iso8601, activation_rules: [{type: "payment", timeout_hours: 24}]} } + + it "does not apply activation rules and clears existing ones" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription).to be_active + expect(subscription.activation_rules.reload).to be_empty + end + end + end + + context "when subscription_at changes to today" do + let(:params) { {subscription_at: Time.current.beginning_of_day.iso8601} } + + it "keeps activation rules and stays pending" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription).to be_pending + expect(subscription.activation_rules.reload.count).to eq(1) + end + + context "when activation_rules are also provided in params" do + let(:params) { {subscription_at: Time.current.beginning_of_day.iso8601, activation_rules: [{type: "payment", timeout_hours: 24}]} } + + it "applies the new activation rules and stays pending" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription).to be_pending + rules = subscription.activation_rules.reload + expect(rules.count).to eq(1) + expect(rules.first.timeout_hours).to eq(24) + end + end + end + + context "when subscription is not starting in the future" do + let(:subscription) { create(:subscription, :pending, :with_previous_subscription, :with_activation_rules, activation_rules_config: [{type: "payment", timeout_hours: 48}]) } + let(:params) { {activation_rules: [{type: "payment", timeout_hours: 24}]} } + + it "replaces existing activation rules" do + result = update_service.call + + expect(result).to be_success + rules = subscription.activation_rules.reload + expect(rules.count).to eq(1) + expect(rules.first.timeout_hours).to eq(24) + end + end + end + + context "when activation rules do not exist" do + let(:subscription) { create(:subscription, :pending, subscription_at: Time.current + 5.days) } + + it "persists activation rules" do + params = {activation_rules: [{type: "payment", timeout_hours: 24}]} + result = described_class.call(subscription:, params:) + + expect(result).to be_success + rules = subscription.activation_rules.reload + expect(rules.count).to eq(1) + expect(rules.first).to have_attributes( + type: "payment", + timeout_hours: 24, + status: "inactive" + ) + end + + context "when subscription_at changes to past" do + let(:params) { {subscription_at: (Time.current - 5.days).iso8601} } + + it "activates the subscription" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription).to be_active + end + end + + context "when subscription_at changes to today" do + let(:params) { {subscription_at: Time.current.beginning_of_day.iso8601} } + + it "activates the subscription" do + result = update_service.call + + expect(result).to be_success + expect(result.subscription).to be_active + end + end + + context "when subscription is not starting in the future" do + let(:subscription) { create(:subscription, :pending, :with_previous_subscription) } + let(:params) { {activation_rules: [{type: "payment", timeout_hours: 24}]} } + + it "applies activation rules" do + result = update_service.call + + expect(result).to be_success + rules = subscription.activation_rules.reload + expect(rules.count).to eq(1) + expect(rules.first.timeout_hours).to eq(24) + end + end + end + end + + context "when subscription is active" do + let(:subscription) { create(:subscription) } + let(:params) { {activation_rules: [{type: "payment", timeout_hours: 24}]} } + + it "returns validation error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:activation_rules]).to include("subscription_not_pending") + end + end + end + end +end diff --git a/spec/services/subscriptions/update_usage_thresholds_service_spec.rb b/spec/services/subscriptions/update_usage_thresholds_service_spec.rb new file mode 100644 index 0000000..47a7d0a --- /dev/null +++ b/spec/services/subscriptions/update_usage_thresholds_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::UpdateUsageThresholdsService, :premium do + subject(:service) { described_class.new(subscription:, usage_thresholds_params:, partial:) } + + let(:organization) { create(:organization, premium_integrations:) } + let(:premium_integrations) { ["progressive_billing"] } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + let(:lifetime_usage) { create(:lifetime_usage, subscription:, organization:) } + let(:usage_thresholds_params) { [{amount_cents: 1000, threshold_display_name: "Test"}] } + let(:partial) { false } + + before do + lifetime_usage + allow(UsageThresholds::UpdateService).to receive(:call!).and_return(BaseResult.new) + end + + describe "#call" do + context "when progressive_billing is not enabled" do + let(:premium_integrations) { [] } + + it "returns early without calling UsageThresholds::UpdateService" do + result = service.call + + expect(result).to be_success + expect(UsageThresholds::UpdateService).not_to have_received(:call!) + end + end + + context "when progressive_billing is enabled" do + it "calls UsageThresholds::UpdateService with correct arguments" do + service.call + + expect(UsageThresholds::UpdateService).to have_received(:call!).with( + model: subscription, + usage_thresholds_params:, + partial: + ) + end + + context "when partial is true" do + let(:partial) { true } + + it "passes partial: true to UsageThresholds::UpdateService" do + service.call + + expect(UsageThresholds::UpdateService).to have_received(:call!).with( + model: subscription, + usage_thresholds_params:, + partial: true + ) + end + end + + context "when plan is a child plan (override)" do + let(:parent_plan) { create(:plan, organization:) } + let(:plan) { create(:plan, organization:, parent: parent_plan) } + let!(:plan_usage_threshold) { create(:usage_threshold, plan:, organization:) } + + it "soft deletes usage thresholds attached to the plan override" do + expect { service.call }.to change { plan_usage_threshold.reload.deleted_at }.from(nil) + end + end + + context "when plan is not a child plan" do + let!(:plan_usage_threshold) { create(:usage_threshold, plan:, organization:) } + + it "does not delete usage thresholds attached to the plan" do + expect { service.call }.not_to change { plan_usage_threshold.reload.deleted_at } + end + end + + context "when subscription has usage thresholds after update" do + before { create(:usage_threshold, :for_subscription, subscription:, organization:) } + + it "updates lifetime_usage recalculate_invoiced_usage to true" do + expect { service.call }.to change { lifetime_usage.reload.recalculate_invoiced_usage }.to(true) + end + end + + context "when subscription has no usage thresholds after update" do + it "does not update lifetime_usage" do + expect { service.call }.not_to change { lifetime_usage.reload.recalculate_invoiced_usage } + end + end + + context "when UsageThresholds::UpdateService fails" do + let(:failed_result) { BaseResult.new.tap { |r| r.single_validation_failure!(error_code: "error", field: :test) } } + + before do + allow(UsageThresholds::UpdateService).to receive(:call!).and_raise(BaseService::FailedResult.new(failed_result, "error")) + end + + it "returns the failed result" do + result = service.call + + expect(result).not_to be_success + expect(result.error.messages[:test]).to include("error") + end + end + end + end +end diff --git a/spec/services/subscriptions/validate_service_spec.rb b/spec/services/subscriptions/validate_service_spec.rb new file mode 100644 index 0000000..c571442 --- /dev/null +++ b/spec/services/subscriptions/validate_service_spec.rb @@ -0,0 +1,307 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Subscriptions::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription_at) { Time.current.iso8601 } + let(:ending_at) { (Time.current + 1.year).iso8601 } + + let(:args) do + { + customer:, + plan:, + subscription_at:, + ending_at:, + on_termination_credit_note:, + on_termination_invoice:, + subscription:, + subscription_type: + } + end + + let(:on_termination_credit_note) { nil } + let(:on_termination_invoice) { nil } + let(:subscription) { nil } + let(:subscription_type) { "create" } + + describe "#ending_at" do + subject(:method_call) { validate_service.__send__(:ending_at) } + + context "when date contains milliseconds" do + let(:ending_at) { "2020-01-01T00:00:00.123Z" } + + it "returns the date" do + expect(subject).to eq(DateTime.iso8601(ending_at)) + end + end + + context "when date does not contain milliseconds" do + let(:ending_at) { "2020-01-01T00:00:00Z" } + + it "returns the date" do + expect(subject).to eq(DateTime.iso8601(ending_at)) + end + end + end + + describe "#subscription_at" do + subject(:method_call) { validate_service.__send__(:subscription_at) } + + context "when date contains milliseconds" do + let(:subscription_at) { "2021-02-01T00:00:00.123Z" } + + it "returns the date" do + expect(subject).to eq(DateTime.iso8601(subscription_at)) + end + end + + context "when date does not contain milliseconds" do + let(:subscription_at) { "2020-01-01T00:00:00Z" } + + it "returns the date" do + expect(subject).to eq(DateTime.iso8601(subscription_at)) + end + end + end + + describe ".valid?" do + it "returns true" do + expect(validate_service).to be_valid + end + + context "when customer does not exist" do + let(:customer) { nil } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("customer_not_found") + end + end + + context "when plan does not exist" do + let(:plan) { nil } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + + expect(result.error).to be_a(BaseService::NotFoundFailure) + expect(result.error.message).to eq("plan_not_found") + end + end + + context "with invalid subscription_at" do + context "when string is not a valid iso8601 datetime" do + let(:subscription_at) { "2022-12-13 12:00:00Z" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:subscription_at]).to eq(["invalid_date"]) + end + end + + context "when subscription_at is integer" do + let(:subscription_at) { 123 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:subscription_at]).to eq(["invalid_date"]) + end + end + end + + context "with invalid ending_at" do + context "when string cannot be parsed to date" do + let(:ending_at) { "invalid" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:ending_at]).to eq(["invalid_date"]) + end + end + + context "when ending_at is integer" do + let(:ending_at) { 123 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:ending_at]).to eq(["invalid_date"]) + end + end + + context "when ending_at uses an invalid date format" do + let(:ending_at) { "2025-08-20T16:11:39.061+02:00" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:ending_at]).to eq(["invalid_date"]) + end + end + + context "when ending_at is less than subscription_at and current time" do + let(:ending_at) { (Time.current - 1.year).iso8601 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:ending_at]).to eq(["invalid_date"]) + end + end + end + + context "with invalid on_termination_credit_note" do + let(:on_termination_credit_note) { "invalid" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:on_termination_credit_note]).to eq(["invalid_value"]) + end + end + + context "with valid on_termination_credit_note" do + let(:on_termination_credit_note) { "credit" } + + it "returns true" do + expect(validate_service).to be_valid + end + end + + context "with invalid on_termination_invoice" do + let(:on_termination_invoice) { "invalid" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:on_termination_invoice]).to eq(["invalid_value"]) + end + end + + context "with valid on_termination_invoice" do + let(:on_termination_invoice) { "generate" } + + it "returns true" do + expect(validate_service).to be_valid + end + end + + context "with valid on_termination_invoice skip" do + let(:on_termination_invoice) { "skip" } + + it "returns true" do + expect(validate_service).to be_valid + end + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, customer:, organization:) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + let(:args) do + { + customer:, + plan:, + subscription_at:, + ending_at:, + on_termination_credit_note:, + on_termination_invoice:, + payment_method: payment_method_params + } + end + + context "when provider payment method is valid" do + before do + result.payment_method = payment_method + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "when manual payment method is valid" do + let(:payment_method_params) do + { + payment_method_type: "manual" + } + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "with invalid payment method type" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "with invalid payment method reference" do + let(:payment_method_params) do + { + payment_method_id: "invalid", + payment_method_type: "provider" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + + context "with activation_rules" do + let(:args) do + { + customer:, + plan:, + subscription_at:, + ending_at:, + on_termination_credit_note:, + on_termination_invoice:, + subscription:, + subscription_type:, + activation_rules: + } + end + + let(:customer) { create(:customer, organization:, payment_provider: "stripe") } + + context "when activation_rules contains valid payment rule" do + let(:activation_rules) { [{type: "payment", timeout_hours: 48}] } + + it { is_expected.to be_valid } + end + + context "when activation_rules contains invalid rule" do + let(:activation_rules) { [{type: "unknown", timeout_hours: 48}] } + + it "is invalid with invalid_type error" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:activation_rules]).to eq(["invalid_type"]) + end + end + end + end +end diff --git a/spec/services/taxes/auto_generate_service_spec.rb b/spec/services/taxes/auto_generate_service_spec.rb new file mode 100644 index 0000000..7ee731f --- /dev/null +++ b/spec/services/taxes/auto_generate_service_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Taxes::AutoGenerateService do + subject(:auto_generate_service) { described_class.new(organization:) } + + let(:organization) { create(:organization) } + + describe ".call" do + it "creates eu taxes for organization" do + auto_generate_service.call + + expect(organization.taxes.count).to eq(47) # EU taxes + 2 defaults + end + + it "updates eu taxes for organization" do + auto_generate_service.call + organization.taxes.update_all(rate: 99) # rubocop:disable Rails/SkipsModelValidations + expect(organization.taxes.pluck(:rate)).to all eq 99 + + auto_generate_service.call + expect(organization.taxes.count).to eq(47) # No new taxes created + expect(organization.taxes.pluck(:rate)).to all(be < 99) + end + end +end diff --git a/spec/services/taxes/create_service_spec.rb b/spec/services/taxes/create_service_spec.rb new file mode 100644 index 0000000..ced6518 --- /dev/null +++ b/spec/services/taxes/create_service_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Taxes::CreateService do + subject(:create_service) { described_class.new(organization:, params:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:code) { "tax_code" } + let(:params) do + { + name: "Tax", + code:, + rate: 15.0, + description: "Tax Description" + } + end + + describe "#call" do + it "creates a tax" do + expect { create_service.call }.to change(Tax, :count).by(1) + end + + it "returns tax in the result" do + result = create_service.call + expect(result.tax).to be_a(Tax) + end + + it "does not create an applied tax for the default billing entity" do + expect { create_service.call }.not_to change { billing_entity.applied_taxes.count } + end + + context "when applied_to_organization is true" do + let(:params) do + { + name: "Tax", + code:, + rate: 15.0, + description: "Tax Description", + applied_to_organization: true + } + end + + it "creates an applied tax for the default billing entity" do + expect { create_service.call }.to change { billing_entity.applied_taxes.count }.by(1) + end + + context "when there are multiple billing entities" do + let(:billing_entity2) { create(:billing_entity, organization:) } + + before { billing_entity2 } + + it "creates an applied tax for the default billing entity" do + expect { create_service.call }.to change { billing_entity.applied_taxes.count }.by(1) + expect { create_service.call }.not_to change { billing_entity2.applied_taxes.count } + end + end + end + + context "with validation error" do + before { create(:tax, organization:, code:) } + + it "returns an error" do + result = create_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + end +end diff --git a/spec/services/taxes/destroy_service_spec.rb b/spec/services/taxes/destroy_service_spec.rb new file mode 100644 index 0000000..ce8d70c --- /dev/null +++ b/spec/services/taxes/destroy_service_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Taxes::DestroyService do + subject(:destroy_service) { described_class.new(tax:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:) } + let(:customer) { create(:customer, organization:) } + + describe "#call" do + before { tax } + + it "destroys the tax" do + expect { destroy_service.call }.to change(Tax, :count).by(-1) + end + + it "marks invoices as ready to be refreshed" do + draft_invoice = create(:invoice, :draft, organization:, customer:) + + expect { destroy_service.call }.to change { draft_invoice.reload.ready_to_be_refreshed }.to(true) + end + + it "does not remove the other tax from the default billing entity" do + expect { destroy_service.call }.to change { billing_entity.applied_taxes.count }.by(-1) + end + + it "hard-deletes applicable join records and keeps non-draft invoice/fee taxes" do + # Associations that must be removed on discard + customer = create(:customer, organization:) + customer_tax = create(:customer_applied_tax, customer:, tax:) + + # billing_entity = tax.organization.default_billing_entity + billing_entity_tax = tax.billing_entities_taxes.sole + billing_entity2 = create(:billing_entity, organization:) + billing_entity_tax2 = create(:billing_entity_applied_tax, billing_entity: billing_entity2, tax:) + billing_entity3 = create(:billing_entity, organization:) + billing_entity_tax3 = create(:billing_entity_applied_tax, billing_entity: billing_entity3, tax: create(:tax)) + + add_on = create(:add_on, organization:) + add_on_tax = create(:add_on_applied_tax, add_on:, tax:) + + plan = create(:plan, organization:) + plan_tax = create(:plan_applied_tax, plan:, tax:) + + charge = create(:standard_charge, organization:) + charge_tax = create(:charge_applied_tax, charge:, tax:) + + commitment = create(:commitment, organization:) + commitment_tax = create(:commitment_applied_tax, commitment:, tax:) + + fixed_charge = create(:fixed_charge, organization:) + fixed_charge_tax = create(:fixed_charge_applied_tax, fixed_charge:, tax:) + + credit_note = create(:credit_note, organization:) + credit_note_tax = create(:credit_note_applied_tax, credit_note:, tax:) + + # Invoices and fees: draft should be removed, finalized should remain + finalized_invoice = create(:invoice, status: :finalized) + finalized_invoice_tax = create(:invoice_applied_tax, invoice: finalized_invoice, tax:) + finalized_fee = create(:fee, invoice: finalized_invoice) + finalized_fee_tax = create(:fee_applied_tax, fee: finalized_fee, tax:) + + draft_invoice = create(:invoice, status: :draft) + draft_invoice_tax = create(:invoice_applied_tax, invoice: draft_invoice, tax:) + draft_fee = create(:fee, invoice: draft_invoice) + draft_fee_tax = create(:fee_applied_tax, fee: draft_fee, tax:) + + expect { destroy_service.call }.to change { tax.reload.discarded? }.from(false).to(true) + + # join tables removed + expect { customer_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { billing_entity_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { billing_entity_tax2.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { add_on_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { plan_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { charge_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { commitment_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { fixed_charge_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { credit_note_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + + # Draft invoice/fee taxes removed + expect { draft_invoice_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + expect { draft_fee_tax.reload }.to raise_error(ActiveRecord::RecordNotFound) + + # Finalized invoice/fee taxes kept + expect(finalized_invoice.reload.applied_taxes).to include(finalized_invoice_tax) + expect(finalized_fee.reload.applied_taxes).to include(finalized_fee_tax) + + # We ensure that we don't call the BillingEntities::RemoveTaxesService on all BillingEntities + expect(billing_entity_tax3.reload).to be_persisted + end + + context "when tax is not found" do + let(:tax) { nil } + + it "returns an error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + end +end diff --git a/spec/services/taxes/update_service_spec.rb b/spec/services/taxes/update_service_spec.rb new file mode 100644 index 0000000..3a4ac2c --- /dev/null +++ b/spec/services/taxes/update_service_spec.rb @@ -0,0 +1,147 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Taxes::UpdateService do + subject(:update_service) { described_class.new(tax:, params:) } + + let(:organization) { create(:organization) } + let(:billing_entity) { organization.default_billing_entity } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:) } + + let(:customer) { create(:customer, organization:) } + + describe "#call" do + before { tax } + + let(:params) do + { + code: "updated code", + rate: 15.0, + description: "updated desc" + } + end + + it "updates the tax" do + result = update_service.call + + expect(result).to be_success + expect(result.tax).to have_attributes( + name: tax.name, + code: params[:code], + rate: params[:rate], + description: params[:description] + ) + end + + it "returns tax in the result" do + result = update_service.call + expect(result.tax).to be_a(Tax) + end + + context "when applied_to_organization is updated to false" do + let(:params) do + {applied_to_organization: false} + end + + it "marks invoices as ready to be refreshed" do + draft_invoice = create(:invoice, :draft, organization:, customer:) + + expect { update_service.call }.to change { draft_invoice.reload.ready_to_be_refreshed }.to(true) + end + + it "removes the applied tax from billing_entity" do + expect { update_service.call }.to change { billing_entity.applied_taxes.count }.by(-1) + end + + context "when organization has multiple billing entities" do + let(:billing_entity2) { create(:billing_entity, organization:) } + let(:applied_tax2) { create(:billing_entity_applied_tax, billing_entity: billing_entity2, tax:) } + + before { applied_tax2 } + + it "removes the applied tax only from the default billing entity" do + expect { update_service.call }.not_to change { billing_entity2.applied_taxes.count } + expect(billing_entity.reload.applied_taxes.count).to be(0) + end + end + end + + context "when applied_to_organization is updated to true" do + let(:tax) { create(:tax, organization:, applied_to_organization: false) } + let(:params) do + {applied_to_organization: true} + end + + it "marks invoices as ready to be refreshed" do + draft_invoice = create(:invoice, :draft, organization:, customer:) + + expect { update_service.call }.to change { draft_invoice.reload.ready_to_be_refreshed }.to(true) + end + + it "creates applied tax for the default billing entity" do + expect { update_service.call }.to change { billing_entity.applied_taxes.count }.by(1) + expect(billing_entity.applied_taxes.last.tax).to eq(tax) + end + + context "when default billing entity already have this tax applied" do + let(:applied_tax) { create(:billing_entity_applied_tax, billing_entity:, tax:) } + + before { applied_tax } + + it "does not create a new applied tax" do + expect { update_service.call }.not_to change { billing_entity.applied_taxes.count } + end + end + + context "when organization has multiple billing entities" do + let(:billing_entity2) { create(:billing_entity, organization:) } + + before { billing_entity2 } + + it "creates applied tax only for the default billing entity" do + expect { update_service.call }.to change { billing_entity.applied_taxes.count }.by(1).and not_change { billing_entity2.applied_taxes.count } + expect(billing_entity.applied_taxes.last.tax).to eq(tax) + expect(billing_entity2.applied_taxes).to be_empty + end + end + end + + it "marks invoices as ready to be refreshed" do + draft_invoice = create(:invoice, :draft, organization:, customer:) + + expect { update_service.call }.to change { draft_invoice.reload.ready_to_be_refreshed }.to(true) + end + + context "when tax is not found" do + let(:tax) { nil } + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("tax_not_found") + end + end + + context "with validation error" do + let(:params) do + { + id: tax.id, + name: nil, + code: "code", + amount_cents: 100, + amount_currency: "EUR" + } + end + + it "returns an error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:name]).to eq(["value_is_mandatory"]) + end + end + end +end diff --git a/spec/services/usage_monitoring/alerts/create_batch_service_spec.rb b/spec/services/usage_monitoring/alerts/create_batch_service_spec.rb new file mode 100644 index 0000000..3a8bef8 --- /dev/null +++ b/spec/services/usage_monitoring/alerts/create_batch_service_spec.rb @@ -0,0 +1,269 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::Alerts::CreateBatchService do + describe ".call" do + subject(:result) { described_class.call(organization:, alertable:, alerts_params:) } + + let(:organization) { create(:organization) } + let(:alertable) { create(:subscription, organization:) } + let(:billable_metrics) { create_list(:billable_metric, 2, organization:) } + let(:alerts_params) do + [ + { + alert_type: "billable_metric_current_usage_amount", + code: "alert1", + name: "First Alert", + billable_metric_code: billable_metrics[0].code, + thresholds: [{code: "warning", value: 80}] + }, + { + alert_type: "billable_metric_current_usage_amount", + code: "alert2", + name: "Second Alert", + billable_metric_code: billable_metrics[1].code, + thresholds: [{code: "critical", value: 100}] + } + ] + end + + it "creates multiple alerts" do + expect { result }.to change(UsageMonitoring::Alert, :count).by(2) + expect(result).to be_success + expect(result.alerts.map(&:code)).to match_array %w[alert1 alert2] + expect(result.alerts.map(&:name)).to match_array ["First Alert", "Second Alert"] + end + + context "when organization is nil" do + subject(:result) { described_class.call(organization: nil, alertable:, alerts_params:) } + + it "returns a not found failure" do + expect(result).to be_failure + expect(result.error.error_code).to eq("organization_not_found") + end + end + + context "when alertable is nil" do + let(:alertable) { nil } + + it "returns a not found failure" do + expect(result).to be_failure + expect(result.error.error_code).to eq("alertable_not_found") + end + end + + context "when alertable is a wallet" do + let(:alertable) { create(:wallet, organization:) } + + let(:alerts_params) do + [ + { + alert_type: "wallet_balance_amount", + code: "alert1", + name: "First Alert", + thresholds: [{code: "warning", value: 80}] + }, + { + alert_type: "wallet_credits_balance", + code: "alert2", + name: "Second Alert", + thresholds: [{code: "critical", value: 100}] + } + ] + end + + it "creates multiple alerts for the wallet" do + expect { result }.to change(UsageMonitoring::Alert, :count).by(2) + expect(result).to be_success + expect(result.alerts).to match_array([ + have_attributes(code: "alert1", name: "First Alert", wallet: alertable), + have_attributes(code: "alert2", name: "Second Alert", wallet: alertable) + ]) + end + end + + context "when alerts_params is empty" do + let(:alerts_params) { [] } + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error.messages[:alerts]).to include("no_alerts") + end + end + + context "when alerts_params is nil" do + let(:alerts_params) { nil } + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error.messages[:alerts]).to include("no_alerts") + end + end + + context "when one alert has invalid params" do + let(:alerts_params) do + [ + { + alert_type: "current_usage_amount", + code: "alert1", + thresholds: [{value: 80}] + }, + { + alert_type: "invalid_type", + code: "alert2", + thresholds: [{value: 100}] + } + ] + end + + it "rolls back all alerts and returns errors" do + expect { result }.not_to change(UsageMonitoring::Alert, :count) + expect(result).to be_failure + expect(result.alerts).to be_empty + expect(result.error.messages).to have_key(1) + expect(result.error.messages[1][:params]).to eq(alerts_params[1]) + expect(result.error.messages[1][:errors]).to include("invalid_type") + end + end + + context "when alerts have duplicate codes" do + let(:alerts_params) do + [ + { + alert_type: "billable_metric_current_usage_amount", + code: "same_code", + billable_metric_code: billable_metrics[0].code, + thresholds: [{value: 80}] + }, + { + alert_type: "billable_metric_current_usage_amount", + code: "same_code", + billable_metric_code: billable_metrics[1].code, + thresholds: [{value: 100}] + } + ] + end + + it "rolls back all alerts and returns errors" do + expect { result }.not_to change(UsageMonitoring::Alert, :count) + expect(result).to be_failure + expect(result.error.messages).to have_key(1) + expect(result.error.messages[1][:params]).to eq(alerts_params[1]) + expect(result.error.messages[1][:errors]).to include("value_already_exist") + end + end + + context "when creating the same alert type for the same billable metric" do + let(:alerts_params) do + [ + { + alert_type: "billable_metric_current_usage_amount", + code: "alert1", + billable_metric_code: billable_metrics[0].code, + thresholds: [{value: 80}] + }, + { + alert_type: "billable_metric_current_usage_amount", + code: "alert2", + billable_metric_code: billable_metrics[0].code, + thresholds: [{value: 100}] + } + ] + end + + it "rolls back all alerts and returns errors" do + expect { result }.not_to change(UsageMonitoring::Alert, :count) + expect(result).to be_failure + expect(result.error.messages).to have_key(1) + expect(result.error.messages[1][:params]).to eq(alerts_params[1]) + expect(result.error.messages[1][:errors]).to include("alert_already_exists") + end + end + + context "when creating multiple differently invalid alerts" do + let(:alerts_params) do + [ + { # invalid type + alert_type: "invalid_type", + code: "alert1", + thresholds: [{value: 80}] + }, + { # missing thresholds + alert_type: "billable_metric_current_usage_amount", + code: "alert2", + billable_metric_code: billable_metrics[0].code, + thresholds: [] + }, + { # the only correct one + alert_type: "billable_metric_current_usage_amount", + code: "alert3", + billable_metric_code: billable_metrics[1].code, + thresholds: [{value: 100}] + }, + { # duplicated alert type + billable metric + alert_type: "billable_metric_current_usage_amount", + code: "alert4", + billable_metric_code: billable_metrics[1].code, + thresholds: [{value: 100}] + }, + { # duplicated code + alert_type: "current_usage_amount", + code: "alert3", + thresholds: [{value: 100}] + } + ] + end + + it "rolls back all alerts and returns all errors" do + expect { result }.not_to change(UsageMonitoring::Alert, :count) + expect(result).to be_failure + expect(result.error.messages.size).to eq(4) + expect(result.error.messages[0][:params]).to eq(alerts_params[0]) + expect(result.error.messages[0][:errors]).to include("invalid_type") + expect(result.error.messages[1][:params]).to eq(alerts_params[1]) + expect(result.error.messages[1][:errors]).to include("value_is_mandatory") + expect(result.error.messages[3][:params]).to eq(alerts_params[3]) + expect(result.error.messages[3][:errors]).to include("alert_already_exists") + expect(result.error.messages[4][:params]).to eq(alerts_params[4]) + expect(result.error.messages[4][:errors]).to include("value_already_exist") + end + end + + context "when sending existing and non-existing billable metric codes" do + let(:alerts_params) do + [ + { + alert_type: "billable_metric_current_usage_amount", + code: "alert0", + billable_metric_code: "first_non_existing_code", + thresholds: [{value: 50}] + }, + { + alert_type: "billable_metric_current_usage_amount", + code: "alert1", + billable_metric_code: billable_metrics[0].code, + thresholds: [{value: 80}] + }, + { + alert_type: "billable_metric_current_usage_amount", + code: "alert2", + billable_metric_code: "non_existing_code", + thresholds: [{value: 100}] + } + ] + end + + it "rolls back all alerts and returns all errors" do + expect { result }.not_to change(UsageMonitoring::Alert, :count) + expect(result).to be_failure + expect(result.error.messages).to have_key(0) + expect(result.error.messages).to have_key(2) + expect(result.error.messages[0][:params]).to eq(alerts_params[0]) + expect(result.error.messages[0][:errors]).to eq("billable_metric_not_found") + expect(result.error.messages[2][:params]).to eq(alerts_params[2]) + expect(result.error.messages[2][:errors]).to eq("billable_metric_not_found") + end + end + end +end diff --git a/spec/services/usage_monitoring/alerts/destroy_all_service_spec.rb b/spec/services/usage_monitoring/alerts/destroy_all_service_spec.rb new file mode 100644 index 0000000..ca95710 --- /dev/null +++ b/spec/services/usage_monitoring/alerts/destroy_all_service_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::Alerts::DestroyAllService do + describe ".call" do + subject(:result) { described_class.call(alertable: alertable) } + + let(:organization) { create(:organization) } + let(:alertable) { create(:subscription, organization:) } + + context "when alertable is a subscription" do + let!(:alert1) do + create(:usage_current_amount_alert, code: "a1", organization:, + subscription_external_id: alertable.external_id, thresholds: [1, 2]) + end + let!(:alert2) do + create(:lifetime_usage_amount_alert, code: "a2", organization:, + subscription_external_id: alertable.external_id, thresholds: [3]) + end + let!(:other_alert) { create(:alert, organization:, thresholds: [4]) } + + it "discards all alerts for the subscription" do + expect(result).to be_success + + expect(alert1.reload).to be_discarded + expect(alert2.reload).to be_discarded + expect(other_alert.reload).not_to be_discarded + end + + it "deletes all thresholds for the discarded alerts" do + expect { result }.to change(UsageMonitoring::AlertThreshold, :count).by(-3) + end + end + + context "when alertable is a wallet" do + let(:alertable) { create(:wallet, organization:) } + + let!(:alert1) do + create(:wallet_balance_amount_alert, code: "a1", organization:, wallet: alertable, thresholds: [1, 2]) + end + let!(:alert2) do + create(:wallet_credits_balance_alert, code: "a2", organization:, wallet: alertable, thresholds: [3]) + end + let!(:other_alert) { create(:alert, organization:, thresholds: [4]) } + + it "discards all alerts for the wallet" do + expect(result).to be_success + + expect(alert1.reload).to be_discarded + expect(alert2.reload).to be_discarded + expect(other_alert.reload).not_to be_discarded + end + + it "deletes all thresholds for the discarded alerts" do + expect { result }.to change(UsageMonitoring::AlertThreshold, :count).by(-3) + end + end + + context "when alertable is nil" do + let(:alertable) { nil } + let(:alert1) { nil } + let(:alert2) { nil } + + it "returns a not found failure" do + expect(result).to be_failure + expect(result.error.error_code).to eq("alertable_not_found") + end + end + + context "when there are no alerts for the alertable" do + it "returns success with empty alerts" do + expect(result).to be_success + end + end + end +end diff --git a/spec/services/usage_monitoring/create_alert_service_spec.rb b/spec/services/usage_monitoring/create_alert_service_spec.rb new file mode 100644 index 0000000..d1dd2a7 --- /dev/null +++ b/spec/services/usage_monitoring/create_alert_service_spec.rb @@ -0,0 +1,422 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::CreateAlertService do + describe ".call" do + subject(:result) { described_class.call(organization:, alertable: subscription, params:) } + + let(:organization) { create(:organization, premium_integrations:) } + let(:premium_integrations) { [] } + let(:thresholds) { [{code: "warning", value: 80}, {code: "critical", value: 120}] } + let(:params) { {alert_type: "current_usage_amount", name: "Main", thresholds:, code: "first", billable_metric_id: billable_metric&.id} } + let(:subscription) { create(:subscription, organization:) } + let(:billable_metric) { nil } + + it "creates a new alert" do + expect(result).to be_success + + alert = result.alert + expect(alert.organization_id).to eq(organization.id) + expect(alert.subscription_external_id).to eq(subscription.external_id) + expect(alert.billable_metric).to be_nil + expect(alert.alert_type).to eq("current_usage_amount") + expect(alert.name).to eq("Main") + expect(alert.code).to eq("first") + expect(alert.direction).to eq("increasing") + + expect(alert.thresholds.map(&:code)).to eq %w[warning critical] + expect(alert.thresholds.map(&:value)).to eq [80, 120] + expect(alert.thresholds.map(&:recurring)).to all(be_falsey) + expect(alert.thresholds.size).to eq(2) + end + + context "with recurring threshold" do + let(:thresholds) { [{value: 80}, {code: "warning", value: 100}, {value: 32, recurring: true}] } + + it "creates a new alert" do + expect(result).to be_success + expect(result.alert.thresholds.pluck(:code, :value, :recurring)).to contain_exactly( + [nil, 80, false], ["warning", 100, false], [nil, 32, true] + ) + end + end + + context "when code already exists" do + it "returns a record validation failure result" do + create(:billable_metric_current_usage_amount_alert, organization:, code: "first", subscription_external_id: subscription.external_id) + expect(result).to be_failure + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + + context "with billable_metric_current_usage_amount type" do + let(:params) { {alert_type: "billable_metric_current_usage_amount", billable_metric_id: billable_metric.id, thresholds:, code: "first"} } + let(:billable_metric) { create(:billable_metric, organization:) } + + it do + expect(result).to be_success + + alert = result.alert + expect(alert.billable_metric_id).to eq billable_metric.id + expect(alert.alert_type).to eq("billable_metric_current_usage_amount") + end + + context "when billable_metric is missing" do + let(:params) { {alert_type: "billable_metric_current_usage_amount", thresholds:, code: "first"} } + let(:billable_metric) { nil } + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:billable_metric]).to eq(["value_is_mandatory"]) + end + end + + context "when billable_metric is not found" do + let(:params) { {alert_type: "billable_metric_current_usage_amount", billable_metric_code: "not,found", thresholds:, code: "first"} } + let(:billable_metric) { nil } + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.message).to eq "billable_metric_not_found" + end + end + end + + context "when the subscription is not active" do + let(:subscription) { create(:subscription, :terminated) } + + it do + expect(result).to be_success + end + + it "does not create a subscription activity" do + expect { result }.not_to change(UsageMonitoring::SubscriptionActivity, :count) + end + end + + context "when tracking subscription activity", :premium do + it "creates a subscription activity record" do + expect { result }.to change(UsageMonitoring::SubscriptionActivity, :count).by(1) + + activity = UsageMonitoring::SubscriptionActivity.last + expect(activity.organization_id).to eq(organization.id) + expect(activity.subscription_id).to eq(subscription.id) + end + + context "when license is not premium" do + it "does not create a subscription activity" do + allow(License).to receive(:premium?).and_return(false) + expect { result }.not_to change(UsageMonitoring::SubscriptionActivity, :count) + end + end + end + + context "when code is blank" do + let(:params) { {alert_type: "current_usage_amount", code: nil, thresholds: [{value: 100}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:code]).to eq(["value_is_mandatory"]) + end + end + + context "when alert_type is blank" do + let(:params) { {alert_type: nil, code: "ok", thresholds: [{value: 100}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:alert_type]).to eq(%w[value_is_mandatory value_is_invalid]) + end + end + + context "when thresholds are blank" do + let(:params) { {alert_type: "current_usage_amount", code: "ok", thresholds: []} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:thresholds]).to include("value_is_mandatory") + end + end + + context "when thresholds have duplicate values" do + let(:params) { {alert_type: "current_usage_amount", code: "ok", thresholds: [{value: 1}, {value: 1}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:thresholds]).to include("duplicate_threshold_values") + end + end + + context "when thresholds have duplicate values with falsy recurring variants" do + [ + [{value: 1, recurring: false}, {value: 1, recurring: "0"}], + [{value: 1, recurring: false}, {value: 1, recurring: 0}], + [{value: 1, recurring: "false"}, {value: 1, recurring: false}], + [{value: 1, recurring: "0"}, {value: 1, recurring: 0}], + [{value: 1}, {value: 1, recurring: false}] + ].each do |thresholds_pair| + context "with recurring values #{thresholds_pair.map { |t| t[:recurring].inspect }.join(" and ")}" do + let(:params) { {alert_type: "current_usage_amount", code: "ok", thresholds: thresholds_pair} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:thresholds]).to include("duplicate_threshold_values") + end + end + end + end + + context "when thresholds have same value but different recurring flags" do + let(:thresholds) { [{value: 100}, {value: 100, recurring: true}] } + + it "creates the alert" do + expect(result).to be_success + expect(result.alert.thresholds.pluck(:value, :recurring)).to contain_exactly( + [100, false], [100, true] + ) + end + end + + context "when a threshold value is nil" do + let(:params) { {alert_type: "current_usage_amount", code: "ok", thresholds: [{value: nil}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:"thresholds:value"]).to include("value_is_mandatory") + end + end + + context "when a threshold value is not a valid number" do + let(:params) { {alert_type: "current_usage_amount", code: "ok", thresholds: [{value: "abc"}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:"thresholds:value"]).to include("value_is_invalid") + end + end + + context "when threshold values are valid numeric strings" do + let(:params) { {alert_type: "current_usage_amount", code: "ok", thresholds: [{value: "100"}, {value: "200.5"}]} } + + it "creates the alert" do + expect(result).to be_success + expect(result.alert).to be_persisted + end + end + + context "when one-time threshold values are negative" do + let(:params) do + { + alert_type: "current_usage_amount", + code: "ok", + thresholds: [{value: "100"}, {value: "-100"}] + } + end + + it "creates the alert" do + expect(result).to be_success + expect(result.alert).to be_persisted + end + end + + context "when recurring threshold values are negative" do + let(:params) do + { + alert_type: "current_usage_amount", + code: "ok", + thresholds: [ + {value: "100", recurring: "true"}, + {value: "-100", recurring: "true"} + ] + } + end + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:"thresholds:value"]).to eq(["recurring_value_is_negative"]) + end + end + + context "when code is missing" do + let(:params) { {alert_type: "current_usage_amount", thresholds:, code: nil} } + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:code]).to eq(["value_is_mandatory"]) + end + end + + context "when alert_type is invalid" do + let(:params) { {alert_type: "yolo", thresholds:, code: "first"} } + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:alert_type]).to include("invalid_type") + end + end + + context "when wallet alert type is used" do + let(:params) { {alert_type: "wallet_balance_amount", thresholds:, code: "first"} } + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error.messages[:alert_type]).to include("invalid_type") + end + end + + context "with too many thresholds" do + let(:thresholds) do + Array.new(21) do |i| + {code: "warning#{i}", value: 10 + i} + end + end + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.message).to include("too_many_thresholds") + end + end + + context "when creating lifetime_usage alert", :premium do + let(:params) { {alert_type: "lifetime_usage_amount", thresholds:, code: "first"} } + + context "when organization using lifetime usage" do + let(:premium_integrations) { [] } + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:alert_type]).to eq ["feature_not_available"] + end + end + + context "when organization does not use lifetime usage" do + let(:premium_integrations) { ["lifetime_usage"] } + + it "creates the alert" do + expect(result).to be_success + expect(result.alert).to be_persisted + expect(result.alert.alert_type).to eq "lifetime_usage_amount" + end + end + end + + context "when creating billable_metric_lifetime_usage_units alert", :premium do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:params) { {alert_type: "billable_metric_lifetime_usage_units", billable_metric_id: billable_metric.id, thresholds:, code: "first"} } + + context "when organization does not use lifetime usage" do + let(:premium_integrations) { [] } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:alert_type]).to eq ["feature_not_available"] + end + end + + context "when organization uses lifetime usage but not granular" do + let(:premium_integrations) { ["lifetime_usage"] } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:alert_type]).to eq ["feature_not_available"] + end + end + + context "when organization uses only granular lifetime usage" do + let(:premium_integrations) { ["granular_lifetime_usage"] } + + it "creates the alert" do + expect(result).to be_success + expect(result.alert).to be_persisted + expect(result.alert.alert_type).to eq "billable_metric_lifetime_usage_units" + end + end + end + + context "when direction param is passed" do + let(:params) { {alert_type: "current_usage_amount", name: "Main", thresholds:, code: "first", direction: "decreasing"} } + + it "ignores the direction param and uses the default for subscription alerts" do + expect(result).to be_success + expect(result.alert.direction).to eq("increasing") + end + end + + context "when direction is :increasing" do + let(:params) { {alert_type: "current_usage_amount", name: "Main", thresholds:, code: "first"} } + + it "sets previous_value to 0" do + expect(result).to be_success + expect(result.alert.previous_value).to eq(0.0) + end + end + + context "when direction is :decreasing" do + subject(:result) { described_class.call(organization:, alertable: wallet, params:) } + + let(:wallet) { create(:wallet, organization:, balance_cents: 100) } + let(:params) { {alert_type: "wallet_balance_amount", name: "Wallet Alert", thresholds:, code: "wallet_alert"} } + + it "sets previous_value to the current value of alertable metric" do + expect(result).to be_success + expect(result.alert.previous_value).to eq(100) + end + end + + context "when creating wallet alert" do + subject(:result) { described_class.call(organization:, alertable: wallet, params:) } + + let(:wallet) { create(:wallet, organization:) } + let(:params) { {alert_type: "wallet_balance_amount", name: "Wallet Alert", thresholds:, code: "wallet1"} } + + it "sets direction to decreasing for wallet alerts" do + expect(result).to be_success + expect(result.alert.direction).to eq("decreasing") + end + + it "does not create a subscription activity" do + expect { result }.not_to change(UsageMonitoring::SubscriptionActivity, :count) + end + + context "when processing wallet alerts", :premium do + it "enqueues ProcessWalletAlertsJob" do + expect { result }.to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob).with(wallet) + end + + context "when license is not premium" do + it "does not enqueue ProcessWalletAlertsJob" do + allow(License).to receive(:premium?).and_return(false) + expect { result }.not_to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob) + end + end + end + + context "when wallet is terminated" do + let(:wallet) { create(:wallet, :terminated, organization:) } + + it "does not enqueue ProcessWalletAlertsJob" do + expect { result }.not_to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob) + end + end + + context "when direction param is passed" do + let(:params) { {alert_type: "wallet_balance_amount", name: "Wallet Alert", thresholds:, code: "wallet1", direction: "increasing"} } + + it "ignores the direction param" do + expect(result).to be_success + expect(result.alert.direction).to eq("decreasing") + end + end + + context "when subscription alert type is used" do + let(:params) { {alert_type: "current_usage_amount", name: "Wallet Alert", thresholds:, code: "wallet1"} } + + it "returns a validation failure" do + expect(result).to be_failure + expect(result.error.messages[:alert_type]).to include("invalid_type") + end + end + end + end +end diff --git a/spec/services/usage_monitoring/destroy_alert_service_spec.rb b/spec/services/usage_monitoring/destroy_alert_service_spec.rb new file mode 100644 index 0000000..64b73fa --- /dev/null +++ b/spec/services/usage_monitoring/destroy_alert_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::DestroyAlertService do + describe ".call" do + subject(:result) { described_class.call(alert:) } + + let(:alert) { create(:alert, thresholds: [1, 2, 50]) } + + it "discards the alert" do + expect(result).to be_success + expect(result.alert).to be_discarded + expect(result.alert.thresholds.count).to eq 0 + end + end +end diff --git a/spec/services/usage_monitoring/process_alert_service_spec.rb b/spec/services/usage_monitoring/process_alert_service_spec.rb new file mode 100644 index 0000000..128f2c3 --- /dev/null +++ b/spec/services/usage_monitoring/process_alert_service_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessAlertService do + describe "#call" do + subject(:result) { described_class.call(alert:, alertable: subscription, current_metrics:) } + + let(:organization) { create(:organization) } + let(:alert) { create(:usage_current_amount_alert, recurring_threshold: 35, thresholds: [10, 20], previous_value: 4, code: "test", organization:, subscription_external_id: subscription.external_id) } + let(:subscription) { create(:subscription, organization:) } + + context "when no thresholds are crossed" do + let(:current_metrics) { instance_double(SubscriptionUsage, amount_cents: 5) } + + it "updates alert last_processed_at and previous_value" do + expect(result).to be_success + result_alert = result.alert + expect(result_alert.last_processed_at).to be_within(5.seconds).of(Time.current) + expect(result_alert.previous_value).to eq 5 + expect(organization.triggered_alerts.count).to eq 0 + expect(SendWebhookJob).not_to have_been_enqueued + end + end + + context "when 2 thresholds are crossed" do + let(:current_metrics) { instance_double(SubscriptionUsage, amount_cents: 22) } + + it "triggers the alert" do + expect(result).to be_success + result_alert = result.alert + expect(result_alert.last_processed_at).to be_within(5.seconds).of(Time.current) + expect(result_alert.previous_value).to eq 22 + + ta = organization.triggered_alerts.sole + expect(ta.alert).to eq(alert) + expect(ta.organization).to eq(alert.organization) + expect(ta.subscription).to eq(subscription) + expect(ta.current_value).to eq(22) + expect(ta.previous_value).to eq(4) + expect(ta.triggered_at).to be_within(5.seconds).of(Time.current) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to contain_exactly( + {code: "warn10", value: "10.0", recurring: false}, + {code: "warn20", value: "20.0", recurring: false} + ) + + expect(SendWebhookJob).to have_been_enqueued.once.with("alert.triggered", ta) + end + end + + context "when recurring thresholds are crossed" do + let(:current_metrics) { instance_double(SubscriptionUsage, amount_cents: 161) } + + it "triggers the alert" do + expect(result).to be_success + result_alert = result.alert + expect(result_alert.last_processed_at).to be_within(5.seconds).of(Time.current) + expect(result_alert.previous_value).to eq 161 + + ta = organization.triggered_alerts.sole + expect(ta.alert).to eq(alert) + expect(ta.organization).to eq(alert.organization) + expect(ta.subscription).to eq(subscription) + expect(ta.previous_value).to eq(4) + expect(ta.current_value).to eq(161) + expect(ta.triggered_at).to be_within(5.seconds).of(Time.current) + expect(ta.crossed_thresholds.map(&:symbolize_keys)).to contain_exactly( + {code: "warn10", value: "10.0", recurring: false}, + {code: "warn20", value: "20.0", recurring: false}, + {code: "rec", value: "55.0", recurring: true}, + {code: "rec", value: "90.0", recurring: true}, + {code: "rec", value: "125.0", recurring: true}, + {code: "rec", value: "160.0", recurring: true} + ) + + expect(SendWebhookJob).to have_been_enqueued.once.with("alert.triggered", ta) + end + end + + context "when an error occurs during TriggeredAlert creation" do + before do + allow(UsageMonitoring::TriggeredAlert).to receive(:create!).and_raise(StandardError) + end + + it "does not update alert last_processed_at or previous_value" do + expect(SendWebhookJob).not_to have_been_enqueued + expect { service.call }.to raise_error(StandardError) + expect(alert.reload.last_processed_at).to be_nil + expect(alert.previous_value).to eq 4 + end + end + + context "when billable metric is not part of the plan charges" do + let(:alert) do + create( + :billable_metric_current_usage_amount_alert, + thresholds: [10, 20], + previous_value: 0, + code: "test", + organization:, + subscription_external_id: subscription.external_id + ) + end + let(:other_billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric: other_billable_metric) } + let(:fees) { [create(:charge_fee, charge:, amount_cents: 100)] } + let(:current_metrics) { instance_double(SubscriptionUsage, fees:) } + + it "returns success without triggering alert" do + expect(result).to be_success + expect(alert.reload.last_processed_at).to be_nil + expect(alert.previous_value).to eq 0 + expect(organization.triggered_alerts.count).to eq 0 + expect(SendWebhookJob).not_to have_been_enqueued + end + end + end +end diff --git a/spec/services/usage_monitoring/process_all_subscription_activities_service_spec.rb b/spec/services/usage_monitoring/process_all_subscription_activities_service_spec.rb new file mode 100644 index 0000000..710eef3 --- /dev/null +++ b/spec/services/usage_monitoring/process_all_subscription_activities_service_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessAllSubscriptionActivitiesService do + describe "#call" do + subject(:service) { described_class.new } + + before do + allow(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).to receive(:perform_later) + allow(Rails.logger).to receive(:info) + end + + it "enqueues ProcessOrganizationSubscriptionActivitiesJob for organizations with SubscriptionActivity" do + organization1 = create(:organization, premium_integrations: []) + organization2 = create(:organization, premium_integrations: ["progressive_billing"]) + organization3 = create(:organization, premium_integrations: ["salesforce"]) + create_list(:subscription_activity, 2, organization: organization1) + create_list(:subscription_activity, 3, organization: organization2) + + result = service.call + + expect(result).to be_success + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).to have_received(:perform_later).with(organization1.id) + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).to have_received(:perform_later).with(organization2.id) + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).not_to have_received(:perform_later).with(organization3.id) + end + + context "when some organizations are targeted for the dedicated queue" do + let(:dedicated_organization) { create(:organization) } + let(:other_organization) { create(:organization) } + + before do + stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", [dedicated_organization.id]) + create_list(:subscription_activity, 2, organization: dedicated_organization) + create_list(:subscription_activity, 1, organization: other_organization) + end + + it "skips dedicated organizations and enqueues only the others" do + service.call + + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).to have_received(:perform_later).with(other_organization.id) + expect(UsageMonitoring::ProcessOrganizationSubscriptionActivitiesJob).not_to have_received(:perform_later).with(dedicated_organization.id) + end + end + end +end diff --git a/spec/services/usage_monitoring/process_lifetime_usage_alert_service_spec.rb b/spec/services/usage_monitoring/process_lifetime_usage_alert_service_spec.rb new file mode 100644 index 0000000..02eaf23 --- /dev/null +++ b/spec/services/usage_monitoring/process_lifetime_usage_alert_service_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessLifetimeUsageAlertService, :premium do + subject(:service) { described_class.new(alert:, subscription:) } + + let(:organization) { create(:organization, premium_integrations:) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + let!(:charge) { create(:standard_charge, billable_metric:, plan: subscription.plan) } + let(:alert) do + create(:billable_metric_lifetime_usage_units_alert, + billable_metric:, organization:, subscription_external_id: subscription.external_id) + end + let(:mocked_usage) { double("usage") } # rubocop:disable RSpec/VerifiedDoubles + + before do + allow(::Invoices::CustomerUsageService).to receive(:call!) + .and_return(double(success?: true, usage: mocked_usage)) # rubocop:disable RSpec/VerifiedDoubles + allow(::UsageMonitoring::ProcessAlertService).to receive(:call) + end + + context "when lifetime_usage is enabled" do + let(:premium_integrations) { %w[lifetime_usage] } + + it "calls CustomerUsageService and processes the alert" do + service.call + + expect(::Invoices::CustomerUsageService).to have_received(:call!).with( + customer: an_object_having_attributes(id: subscription.customer_id), + subscription: an_object_having_attributes(id: subscription.id), + apply_taxes: false, + with_cache: true, + usage_filters: an_instance_of(UsageFilters) + ) + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call) + .with(alert:, alertable: an_object_having_attributes(id: subscription.id), current_metrics: mocked_usage) + end + end + + context "when lifetime_usage is not enabled" do + let(:premium_integrations) { [] } + let(:usage_error) { BaseService::ServiceFailure.new(nil, code: "full_usage_not_allowed", error_message: "not allowed") } + + before do + allow(::Invoices::CustomerUsageService).to receive(:call!) + .and_raise(usage_error) + end + + it "raises the error from CustomerUsageService" do + expect { service.call }.to raise_error(usage_error) + + expect(::UsageMonitoring::ProcessAlertService).not_to have_received(:call) + end + end + + context "when alert type is not BillableMetricLifetimeUsageUnitsAlert" do + let(:premium_integrations) { %w[lifetime_usage] } + let(:alert) do + create(:usage_current_amount_alert, + organization:, subscription_external_id: subscription.external_id) + end + + it "does not process the alert" do + service.call + + expect(::Invoices::CustomerUsageService).not_to have_received(:call!) + expect(::UsageMonitoring::ProcessAlertService).not_to have_received(:call) + end + end + + context "when subscription is nil" do + let(:premium_integrations) { %w[lifetime_usage] } + let(:subscription) { nil } + let!(:charge) { nil } + let(:alert) do + create(:billable_metric_lifetime_usage_units_alert, + billable_metric:, organization:, subscription_external_id: "nonexistent") + end + + it "does not process the alert" do + service.call + + expect(::Invoices::CustomerUsageService).not_to have_received(:call!) + expect(::UsageMonitoring::ProcessAlertService).not_to have_received(:call) + end + end + + context "when there are no matching charges" do + let(:premium_integrations) { %w[lifetime_usage] } + + before { charge.destroy! } + + it "does not call CustomerUsageService or process the alert" do + service.call + + expect(::Invoices::CustomerUsageService).not_to have_received(:call!) + expect(::UsageMonitoring::ProcessAlertService).not_to have_received(:call) + end + end + + context "when CustomerUsageService fails" do + let(:premium_integrations) { %w[lifetime_usage] } + let(:usage_error) { BaseService::ServiceFailure.new(nil, code: "test_error", error_message: "something went wrong") } + + before do + allow(::Invoices::CustomerUsageService).to receive(:call!) + .and_raise(usage_error) + end + + it "raises the error" do + expect { service.call }.to raise_error(usage_error) + + expect(::UsageMonitoring::ProcessAlertService).not_to have_received(:call) + end + end +end diff --git a/spec/services/usage_monitoring/process_organization_subscription_activities_service_spec.rb b/spec/services/usage_monitoring/process_organization_subscription_activities_service_spec.rb new file mode 100644 index 0000000..dccfe1c --- /dev/null +++ b/spec/services/usage_monitoring/process_organization_subscription_activities_service_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessOrganizationSubscriptionActivitiesService do + describe "#call" do + let(:organization) { create(:organization) } + let(:service) { described_class.new(organization:) } + + before do + allow(ActiveJob).to receive(:perform_all_later) + end + + it "enqueues jobs for subscription activities that are not yet enqueued" do + create_list(:subscription_activity, 3, organization:, enqueued: false) + create_list(:subscription_activity, 2, organization:, enqueued: true) + + result = service.call + + expect(ActiveJob).to have_received(:perform_all_later).once do |jobs| + expect(jobs).to all(be_a(UsageMonitoring::ProcessSubscriptionActivityJob)) + end + + expect(result).to be_success + expect(result.nb_jobs_enqueued).to eq(3) + + expect(organization.subscription_activities.where(enqueued: true).count).to eq(5) + expect(organization.subscription_activities.where(enqueued: false).count).to eq(0) + end + + context "when there are no subscription activities to enqueue" do + it "returns 0 for number of jobs enqueued and does not enqueue any job" do + create_list(:subscription_activity, 2, organization:, enqueued: true) + + expect(ActiveJob).not_to have_received(:perform_all_later) + + result = service.call + + expect(result).to be_success + expect(result.nb_jobs_enqueued).to eq(0) + end + end + + context "with more than BATCH_SIZE subscription activities" do + let(:batch_size) { 3 } + + before do + stub_const("#{described_class}::BATCH_SIZE", batch_size) + end + + it "processes activities in batches" do + create_list(:subscription_activity, batch_size + 1, organization:, enqueued: false) + + result = service.call + + expect(ActiveJob).to have_received(:perform_all_later).twice + expect(result.nb_jobs_enqueued).to eq(batch_size + 1) + end + end + + context "when the organization is targeted for the dedicated queue" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", [organization.id]) } + + it "enqueues ProcessSubscriptionActivityJob instances on the dedicated queue" do + create_list(:subscription_activity, 2, organization:, enqueued: false) + + service.call + + expect(ActiveJob).to have_received(:perform_all_later).once do |jobs| + expect(jobs.map(&:queue_name)).to all(eq("dedicated_alerts")) + end + end + end + + context "when the organization is not targeted for the dedicated queue" do + before { stub_const("Utils::DedicatedWorkerConfig::ORGANIZATION_IDS", []) } + + it "enqueues ProcessSubscriptionActivityJob instances on the default queue" do + create_list(:subscription_activity, 2, organization:, enqueued: false) + + service.call + + expect(ActiveJob).to have_received(:perform_all_later).once do |jobs| + expect(jobs.map(&:queue_name)).to all(eq("default")) + end + end + end + end +end diff --git a/spec/services/usage_monitoring/process_subscription_activity_service_spec.rb b/spec/services/usage_monitoring/process_subscription_activity_service_spec.rb new file mode 100644 index 0000000..a8f36c6 --- /dev/null +++ b/spec/services/usage_monitoring/process_subscription_activity_service_spec.rb @@ -0,0 +1,212 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessSubscriptionActivityService, :premium do + subject(:service) { described_class.new(subscription_activity:) } + + let(:organization) { create(:organization, premium_integrations:) } + let(:mocked_current_usage) { double("current_usage") } # rubocop:disable RSpec/VerifiedDoubles + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:subscription_activity) { create(:subscription_activity, subscription:, organization:) } + + before do + subscription_activity + allow(::Invoices::CustomerUsageService).to receive(:call) + .and_return(double(usage: mocked_current_usage)) # rubocop:disable RSpec/VerifiedDoubles + allow(LifetimeUsages::CalculateService).to receive(:call!) + allow(LifetimeUsages::CheckThresholdsService).to receive(:call!) + end + + context "when both lifetime_usage and progressive_billing are enabled" do + let(:premium_integrations) { %w[lifetime_usage progressive_billing] } + + it "calls both services and deletes subscription_activity" do + result = service.call + + expect(LifetimeUsages::CalculateService).to have_received(:call!).with( + lifetime_usage: subscription.lifetime_usage || an_instance_of(LifetimeUsage), + current_usage: mocked_current_usage + ) + expect(LifetimeUsages::CheckThresholdsService).to have_received(:call!).with( + lifetime_usage: subscription.lifetime_usage || an_instance_of(LifetimeUsage) + ) + + expect(result).to be_success + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "when lifetime_usage and progressive_billing are both disabled" do + let(:premium_integrations) { ["salesforce"] } + + it "calculates and checks thresholds are not called" do + result = service.call + + expect(LifetimeUsages::CalculateService).not_to have_received(:call!) + expect(LifetimeUsages::CheckThresholdsService).not_to have_received(:call!) + + expect(result).to be_success + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) # deleted + end + + it "creates a lifetime usage if does not exist" do + subscription.lifetime_usage&.delete + expect { service.call }.to change { subscription.reload.lifetime_usage.present? }.from(false).to(true) + end + end + + context "when only using lifetime_usage" do + let(:premium_integrations) { ["lifetime_usage"] } + + it "calls calculate service and deletes subscription_activity" do + result = service.call + + expect(LifetimeUsages::CalculateService).to have_received(:call!).with( + lifetime_usage: subscription.lifetime_usage || an_instance_of(LifetimeUsage), + current_usage: mocked_current_usage + ) + expect(LifetimeUsages::CheckThresholdsService).not_to have_received(:call!) + + expect(result).to be_success + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "when progressive_billing_enabled is true" do + let(:premium_integrations) { ["progressive_billing"] } + + it "calls both calculate and check thresholds services and deletes subscription_activity" do + result = service.call + + expect(LifetimeUsages::CalculateService).to have_received(:call!).with( + lifetime_usage: subscription.lifetime_usage || an_instance_of(LifetimeUsage), + current_usage: mocked_current_usage + ) + expect(LifetimeUsages::CheckThresholdsService).to have_received(:call!).with( + lifetime_usage: subscription.lifetime_usage || an_instance_of(LifetimeUsage) + ) + + expect(result).to be_success + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "when lifetime_usage already exists" do + let(:premium_integrations) { %w[progressive_billing] } + + it "does not create a new lifetime usage" do + create(:lifetime_usage, subscription: subscription, organization: organization) + expect { service.call }.not_to change(LifetimeUsage, :count) + end + end + + context "when subscription has alerts" do + let(:premium_integrations) { [] } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:alert) { create(:usage_current_amount_alert, organization:, subscription_external_id: subscription.external_id) } + let(:alert_2) { create(:billable_metric_current_usage_amount_alert, billable_metric:, organization:, subscription_external_id: subscription.external_id) } + let(:alert_3) { create(:billable_metric_current_usage_units_alert, billable_metric:, organization:, subscription_external_id: subscription.external_id) } + let(:alert_4) { create(:lifetime_usage_amount_alert, organization:, subscription_external_id: subscription.external_id) } + + before do + alert + alert_2 + alert_3 + alert_4 + allow(::UsageMonitoring::ProcessAlertService).to receive(:call) + end + + it "processes the alerts" do + service.call + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call).exactly(4).times + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call).with(alert: alert, alertable: subscription, current_metrics: mocked_current_usage) + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call).with(alert: alert_2, alertable: subscription, current_metrics: mocked_current_usage) + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call).with(alert: alert_3, alertable: subscription, current_metrics: mocked_current_usage) + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call).with(alert: alert_4, alertable: subscription, current_metrics: subscription.lifetime_usage) + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context "when alerting fail" do + it "deletes subscription_activity before raising" do + allow(::UsageMonitoring::ProcessAlertService).to receive(:call).and_raise(StandardError, "boom") + expect { service.call }.to raise_error(StandardError, "boom") + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "when one alert fails" do + it "still processes the other alerts and raises the first error" do + allow(::UsageMonitoring::ProcessAlertService).to receive(:call).and_call_original + allow(::UsageMonitoring::ProcessAlertService).to receive(:call) + .with(alert: alert, alertable: subscription, current_metrics: mocked_current_usage) + .and_raise(StandardError, "first alert failed") + allow(::UsageMonitoring::ProcessAlertService).to receive(:call) + .with(alert: alert_2, alertable: subscription, current_metrics: mocked_current_usage) + allow(::UsageMonitoring::ProcessAlertService).to receive(:call) + .with(alert: alert_3, alertable: subscription, current_metrics: mocked_current_usage) + allow(::UsageMonitoring::ProcessAlertService).to receive(:call) + .with(alert: alert_4, alertable: subscription, current_metrics: subscription.lifetime_usage) + + expect { service.call }.to raise_error(StandardError, "first alert failed") + + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call) + .with(alert: alert_2, alertable: subscription, current_metrics: mocked_current_usage) + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call) + .with(alert: alert_3, alertable: subscription, current_metrics: mocked_current_usage) + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call) + .with(alert: alert_4, alertable: subscription, current_metrics: subscription.lifetime_usage) + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context "when progressive_billing fail" do + let(:premium_integrations) { %w[lifetime_usage progressive_billing] } + + it "processes alert and then raise" do + allow(LifetimeUsages::CheckThresholdsService).to receive(:call!).and_raise(StandardError, "boom") + expect { service.call }.to raise_error(StandardError, "boom") + expect(::UsageMonitoring::ProcessAlertService).to have_received(:call).with(alert:, alertable: subscription, current_metrics: mocked_current_usage) + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end + + context "when subscription has billable_metric_lifetime_usage_units alert" do + let(:premium_integrations) { [] } + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, billable_metric:, plan: subscription.plan) } + let(:alert_5) do + create(:billable_metric_lifetime_usage_units_alert, + billable_metric:, organization:, subscription_external_id: subscription.external_id) + end + + let(:job_proxy) { instance_double(ActiveJob::ConfiguredJob) } + + before do + charge + alert_5 + allow(UsageMonitoring::ProcessLifetimeUsageAlertJob).to receive(:set).with(wait: 5.minutes).and_return(job_proxy) + allow(job_proxy).to receive(:perform_later) + end + + it "enqueues ProcessLifetimeUsageAlertJob with a 5 minute delay" do + service.call + + expect(job_proxy).to have_received(:perform_later).with(alert: alert_5, subscription:) + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + + context "when the job enqueue raises" do + before do + allow(job_proxy).to receive(:perform_later).and_raise(StandardError, "enqueue failed") + end + + it "deletes subscription_activity before raising" do + expect { service.call }.to raise_error(StandardError, "enqueue failed") + expect { subscription_activity.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/usage_monitoring/process_wallet_alerts_service_spec.rb b/spec/services/usage_monitoring/process_wallet_alerts_service_spec.rb new file mode 100644 index 0000000..40e93a7 --- /dev/null +++ b/spec/services/usage_monitoring/process_wallet_alerts_service_spec.rb @@ -0,0 +1,189 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::ProcessWalletAlertsService do + describe "#call" do + subject(:result) { described_class.call(wallet:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, organization:, customer:, balance_cents: 400, ongoing_balance_cents: 400, credits_balance: 4.0, credits_ongoing_balance: 4.0) } + + context "when wallet has no alerts" do + it "returns success without processing" do + allow(UsageMonitoring::ProcessAlertService).to receive(:call) + expect(result).to be_success + expect(UsageMonitoring::ProcessAlertService).not_to have_received(:call) + end + end + + context "when wallet has alerts" do + let(:alert) do + create( + :wallet_balance_amount_alert, + wallet:, + organization:, + thresholds: [500, 800], + previous_value: 1000, + direction: "decreasing" + ) + end + + before { alert } + + it "processes the alert and triggers when thresholds are crossed" do + expect(result).to be_success + + triggered_alert = organization.triggered_alerts.sole + expect(triggered_alert.alert).to eq(alert) + expect(triggered_alert.wallet).to eq(wallet) + expect(triggered_alert.subscription).to be_nil + expect(triggered_alert.current_value).to eq(400) + expect(triggered_alert.previous_value).to eq(1000) + expect(triggered_alert.crossed_thresholds.map(&:symbolize_keys)).to contain_exactly( + {code: "warn500", value: "500.0", recurring: false}, + {code: "warn800", value: "800.0", recurring: false} + ) + + expect(SendWebhookJob).to have_been_enqueued.once.with("alert.triggered", triggered_alert) + end + + it "updates alert previous_value and last_processed_at" do + result + + alert.reload + expect(alert.previous_value).to eq(400) + expect(alert.last_processed_at).to be_within(5.seconds).of(Time.current) + end + end + + context "when wallet has credits balance alert" do + let!(:alert) do + create( + :wallet_credits_balance_alert, + wallet:, + organization:, + thresholds: [5, 10], + previous_value: 15, + direction: "decreasing" + ) + end + + it "processes the credits balance alert" do + expect(result).to be_success + + triggered_alert = organization.triggered_alerts.sole + expect(triggered_alert.alert).to eq(alert) + expect(triggered_alert.current_value).to eq(4.0) + expect(triggered_alert.crossed_thresholds.map(&:symbolize_keys)).to contain_exactly( + {code: "warn5", value: "5.0", recurring: false}, + {code: "warn10", value: "10.0", recurring: false} + ) + end + end + + context "when wallet has ongoing_balance_amount alert" do + let!(:alert) do + create( + :wallet_ongoing_balance_amount_alert, + wallet:, + organization:, + thresholds: [500, 800], + previous_value: 1000, + direction: "decreasing" + ) + end + + it "processes the ongoing balance alert" do + expect(result).to be_success + + triggered_alert = organization.triggered_alerts.sole + expect(triggered_alert.alert).to eq(alert) + expect(triggered_alert.current_value).to eq(400) + expect(triggered_alert.previous_value).to eq(1000) + expect(triggered_alert.crossed_thresholds.map(&:symbolize_keys)).to contain_exactly( + {code: "warn500", value: "500.0", recurring: false}, + {code: "warn800", value: "800.0", recurring: false} + ) + end + end + + context "when wallet has credits_ongoing_balance alert" do + let!(:alert) do + create( + :wallet_credits_ongoing_balance_alert, + wallet:, + organization:, + thresholds: [5, 10], + previous_value: 15, + direction: "decreasing" + ) + end + + it "processes the credits balance alert" do + expect(result).to be_success + + triggered_alert = organization.triggered_alerts.sole + expect(triggered_alert.alert).to eq(alert) + expect(triggered_alert.current_value).to eq(4.0) + expect(triggered_alert.previous_value).to eq(15.0) + expect(triggered_alert.crossed_thresholds.map(&:symbolize_keys)).to contain_exactly( + {code: "warn5", value: "5.0", recurring: false}, + {code: "warn10", value: "10.0", recurring: false} + ) + end + end + + context "when no thresholds are crossed" do + let!(:alert) do + create( + :wallet_balance_amount_alert, + wallet:, + organization:, + thresholds: [100, 200], + previous_value: 500, + direction: "decreasing" + ) + end + + it "does not trigger the alert" do + expect(result).to be_success + expect(organization.triggered_alerts.count).to eq(0) + expect(SendWebhookJob).not_to have_been_enqueued + end + + it "updates alert previous_value" do + result + + alert.reload + expect(alert.previous_value).to eq(400) + expect(alert.last_processed_at).to be_within(5.seconds).of(Time.current) + end + end + + context "when alert direction is increasing" do + let(:alert) do + create( + :wallet_balance_amount_alert, + wallet:, + organization:, + thresholds: [300, 500], + previous_value: 200, + direction: "increasing" + ) + end + + before { alert } + + it "triggers when value increases past thresholds" do + expect(result).to be_success + + triggered_alert = organization.triggered_alerts.sole + expect(triggered_alert.crossed_thresholds.map(&:symbolize_keys)).to contain_exactly( + {code: "warn300", value: "300.0", recurring: false} + ) + end + end + end +end diff --git a/spec/services/usage_monitoring/track_subscription_activity_service_spec.rb b/spec/services/usage_monitoring/track_subscription_activity_service_spec.rb new file mode 100644 index 0000000..0160e19 --- /dev/null +++ b/spec/services/usage_monitoring/track_subscription_activity_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::TrackSubscriptionActivityService, :premium do + subject { described_class.new(organization:, subscription:, date:) } + + let(:organization) { create(:organization, premium_integrations: %w[lifetime_usage]) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:date) { Date.new(2025, 1, 15) } + + context "when the plan has usage_thresholds" do + it "tracks activity" do + create(:usage_threshold, plan: subscription.plan) + expect { subject.call }.to change { organization.subscription_activities.count }.by(1) + expect { subject.call }.not_to change { organization.subscription_activities.count } + end + + it "sets last_received_event_on" do + create(:usage_threshold, plan: subscription.plan) + expect { subject.call }.to change { subscription.reload.last_received_event_on }.from(nil).to(date) + end + end + + context "when last_received_event_on is already set to the same date" do + before { subscription.update(last_received_event_on: date) } + + it "does not update the subscription" do + expect { subject.call }.not_to change { subscription.reload.updated_at } + end + end + + context "when organization does use any integration with subscription tracking" do + let(:organization) { create(:organization, premium_integrations: %w[salesforce]) } + + it "does not track activity" do + subject.call + expect(organization.subscription_activities.count).to eq(0) + end + end + + context "when subscription isn't active" do + let(:subscription) { create(:subscription, :terminated, customer:) } + + it "does not track activity" do + subject.call + expect(organization.subscription_activities.count).to eq(0) + end + + it "does not set last_received_event_on" do + subject.call + expect(subscription.reload.last_received_event_on).to be_nil + end + end + + context "when license is not premium" do + it "does not set last_received_event_on" do + allow(License).to receive(:premium?).and_return(false) + subject.call + expect(subscription.reload.last_received_event_on).to be_nil + end + end +end diff --git a/spec/services/usage_monitoring/update_alert_service_spec.rb b/spec/services/usage_monitoring/update_alert_service_spec.rb new file mode 100644 index 0000000..3605bf5 --- /dev/null +++ b/spec/services/usage_monitoring/update_alert_service_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageMonitoring::UpdateAlertService do + subject(:result) { described_class.call(alert:, params:) } + + let(:organization) { create(:organization, premium_integrations:) } + let(:premium_integrations) { [] } + let(:alert) { create(:alert, thresholds: [1, 50], organization: organization) } + + describe "#call" do + let(:params) do + {code: "new_code", name: "Renamed", thresholds: [ + {value: 40}, + {code: :warn, value: 100}, + {code: :critical, value: 200, recurring: true} + ]} + end + + it "updates the alert" do + expect(result).to be_success + expect(result.alert).to eq(alert) + expect(alert.reload.name).to eq("Renamed") + expect(alert.reload.code).to eq("new_code") + expect(alert.reload.thresholds.map(&:value)).to eq [40, 100, 200] + expect(alert.reload.thresholds.map(&:code)).to eq [nil, "warn", "critical"] + end + + context "with a billable_metric_id" do + let(:alert) { create(:billable_metric_current_usage_amount_alert, thresholds: [50]) } + let(:billable_metric) { create(:billable_metric, organization: alert.organization) } + let(:params) do + {code: "new_code", name: "Renamed", billable_metric_id: billable_metric.id, thresholds: [ + {value: 40}, + {code: :warn, value: 100}, + {code: :critical, value: 200, recurring: true} + ]} + end + + it "updates the alert" do + expect(result).to be_success + expect(result.alert.billable_metric_id).to eq(billable_metric.id) + end + + context "when alert is not billable_metric_current_usage_amount" do + let(:alert) { create(:usage_current_amount_alert, thresholds: [50]) } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:billable_metric]).to eq ["value_must_be_blank"] + end + end + + context "when billable_metric is not found" do + let(:params) { {code: "new_code", billable_metric_id: "not-found"} } + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.message).to eq "billable_metric_not_found" + end + end + + context "when code already exists" do + it "returns a record validation failure result" do + create(:billable_metric_current_usage_amount_alert, organization: alert.organization, code: "new_code", subscription_external_id: alert.subscription_external_id) + expect(result).to be_failure + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + end + + context "with too many thresholds" do + let(:params) do + { + thresholds: Array.new(21) do |i| + {code: "warning#{i}", value: 10 + i} + end + } + end + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.message).to include("too_many_thresholds") + end + end + + context "when thresholds have duplicate values" do + let(:params) { {thresholds: [{value: 1}, {value: 1}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:thresholds]).to include("duplicate_threshold_values") + end + end + + context "when thresholds have duplicate values with falsy recurring variants" do + [ + [{value: 1, recurring: false}, {value: 1, recurring: "0"}], + [{value: 1, recurring: false}, {value: 1, recurring: 0}], + [{value: 1, recurring: "false"}, {value: 1, recurring: false}], + [{value: 1, recurring: "0"}, {value: 1, recurring: 0}], + [{value: 1}, {value: 1, recurring: false}] + ].each do |thresholds_pair| + context "with recurring values #{thresholds_pair.map { |t| t[:recurring].inspect }.join(" and ")}" do + let(:params) { {thresholds: thresholds_pair} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:thresholds]).to include("duplicate_threshold_values") + end + end + end + end + + context "when thresholds have same value but different recurring flags" do + let(:params) { {thresholds: [{value: 100}, {value: 100, recurring: true}]} } + + it "updates the alert" do + expect(result).to be_success + expect(alert.reload.thresholds.pluck(:value, :recurring)).to contain_exactly( + [100, false], [100, true] + ) + end + end + + context "when a threshold value is nil" do + let(:params) { {thresholds: [{value: nil}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:"thresholds:value"]).to include("value_is_mandatory") + end + end + + context "when a threshold value is not a valid number" do + let(:params) { {thresholds: [{value: "abc"}]} } + + it "returns a validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:"thresholds:value"]).to include("value_is_invalid") + end + end + + context "when threshold values are valid numeric strings" do + let(:params) { {thresholds: [{value: "100"}, {value: "200.5"}]} } + + it "updates the alert" do + expect(result).to be_success + expect(alert.reload.thresholds.map(&:value)).to eq [100, 200.5] + end + end + + context "when one-time threshold values are negative" do + let(:params) { {thresholds: [{value: "100"}, {value: "-100"}]} } + + it "creates the alert" do + expect(result).to be_success + expect(result.alert).to be_persisted + end + end + + context "when recurring threshold values are negative" do + let(:params) do + { + thresholds: [ + {value: "100", recurring: "true"}, + {value: "-100", recurring: "true"} + ] + } + end + + it "returns a record validation failure result" do + expect(result).to be_failure + expect(result.error.messages[:"thresholds:value"]).to eq(["recurring_value_is_negative"]) + end + end + + context "with direction param" do + let(:alert) { create(:alert, thresholds: [1, 50], organization: organization, direction: "increasing") } + let(:params) { {direction: "decreasing"} } + + it "ignores direction param and does not modify it" do + expect(result).to be_success + expect(alert.reload.direction).to eq("increasing") + end + end + + context "when tracking subscription activity", :premium do + it "creates a subscription activity record" do + expect { result }.to change(UsageMonitoring::SubscriptionActivity, :count).by(1) + + activity = UsageMonitoring::SubscriptionActivity.last + expect(activity.organization_id).to eq(organization.id) + end + + context "when no active subscription matches" do + before do + alert + organization.subscriptions.update_all(status: :terminated) # rubocop:disable Rails/SkipsModelValidations + end + + it "does not create a subscription activity" do + expect { result }.not_to change(UsageMonitoring::SubscriptionActivity, :count) + end + end + + context "when license is not premium" do + it "does not create a subscription activity" do + allow(License).to receive(:premium?).and_return(false) + expect { result }.not_to change(UsageMonitoring::SubscriptionActivity, :count) + end + end + end + + context "when updating a wallet alert" do + let(:alert) { create(:wallet_balance_amount_alert, thresholds: [50], organization:) } + let(:params) { {name: "Updated Wallet Alert"} } + + it "does not create a subscription activity" do + expect { result }.not_to change(UsageMonitoring::SubscriptionActivity, :count) + end + + context "when processing wallet alerts", :premium do + it "enqueues ProcessWalletAlertsJob" do + expect { result }.to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob).with(alert.wallet) + end + + context "when license is not premium" do + it "does not enqueue ProcessWalletAlertsJob" do + allow(License).to receive(:premium?).and_return(false) + expect { result }.not_to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob) + end + end + end + + context "when wallet is terminated" do + before do + alert.wallet.mark_as_terminated! + end + + it "does not enqueue ProcessWalletAlertsJob" do + expect { result }.not_to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob) + end + end + end + end +end diff --git a/spec/services/usage_thresholds/override_service_spec.rb b/spec/services/usage_thresholds/override_service_spec.rb new file mode 100644 index 0000000..32bb927 --- /dev/null +++ b/spec/services/usage_thresholds/override_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageThresholds::OverrideService do + subject(:override_service) { described_class.new(usage_thresholds_params:, new_plan: plan) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + + describe "#call" do + let(:threshold) { create(:usage_threshold, plan:) } + let(:plan) { create(:plan, organization:) } + + let(:usage_thresholds_params) do + [ + { + plan_id: plan.id, + threshold_display_name: "Overridden threshold", + amount_cents: 1000 + } + ] + end + + before { threshold } + + it "creates a threshold based on the given threshold" do + expect { override_service.call }.to change(UsageThreshold, :count).by(1) + + threshold = UsageThreshold.order(:created_at).last + + expect(threshold).to have_attributes( + recurring: threshold.recurring, + # Overridden attributes + plan_id: plan.id, + threshold_display_name: "Overridden threshold", + amount_cents: 1000 + ) + end + + context "when thresholds are not unique" do + let(:usage_thresholds_params) do + [ + { + plan_id: plan.id, + threshold_display_name: "Overridden threshold", + amount_cents: 1000 + }, + { + plan_id: plan.id, + threshold_display_name: "", + amount_cents: 1000 + } + ] + end + + it do + result = override_service.call + expect(result).to be_failure + expect(result.error.messages[:amount_cents]).to contain_exactly("value_already_exist") + end + end + end +end diff --git a/spec/services/usage_thresholds/update_service_table_spec.rb b/spec/services/usage_thresholds/update_service_table_spec.rb new file mode 100644 index 0000000..3456bd8 --- /dev/null +++ b/spec/services/usage_thresholds/update_service_table_spec.rb @@ -0,0 +1,149 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsageThresholds::UpdateService, premium: true do + subject(:result) { described_class.call(model:, usage_thresholds_params:, partial:) } + + let(:organization) { create(:organization, premium_integrations: ["progressive_billing"]) } + let(:model) { create(:plan, organization:) } + + before do + allow(LifetimeUsages::FlagRefreshFromPlanUpdateJob).to receive(:perform_after_commit) + end + + # Helper to build threshold attributes for comparison + def threshold_attrs(thresholds) + thresholds.map { |t| {amount_cents: t.amount_cents, threshold_display_name: t.threshold_display_name, recurring: t.recurring} } + end + + describe "threshold updates" do + # rubocop:disable Layout/LineLength + test_cases = [ + { + description: "creates new threshold when none exist", + partial: false, + existing: [], + params: [{amount_cents: 100, threshold_display_name: "First"}], + expected: [{amount_cents: 100, threshold_display_name: "First", recurring: false}] + }, + { + description: "updates existing threshold matched by amount_cents", + partial: false, + existing: [{amount_cents: 100, threshold_display_name: "Old Name"}], + params: [{amount_cents: 100, threshold_display_name: "New Name"}], + expected: [{amount_cents: 100, threshold_display_name: "New Name", recurring: false}] + }, + { + description: "removes thresholds not in params (full update)", + partial: false, + existing: [ + {amount_cents: 100, threshold_display_name: "Keep"}, + {amount_cents: 200, threshold_display_name: "Remove"} + ], + params: [{amount_cents: 100, threshold_display_name: "Keep"}], + expected: [{amount_cents: 100, threshold_display_name: "Keep", recurring: false}] + }, + { + description: "creates multiple thresholds", + partial: false, + existing: [], + params: [ + {amount_cents: 100, threshold_display_name: "First"}, + {amount_cents: 200, threshold_display_name: "Second"} + ], + expected: [ + {amount_cents: 100, threshold_display_name: "First", recurring: false}, + {amount_cents: 200, threshold_display_name: "Second", recurring: false} + ] + }, + { + description: "handles recurring threshold update", + partial: false, + existing: [{amount_cents: 100, threshold_display_name: "Old", recurring: true}], + params: [{amount_cents: 200, threshold_display_name: "New", recurring: true}], + expected: [{amount_cents: 200, threshold_display_name: "New", recurring: true}] + }, + { + description: "clears all thresholds when params empty", + partial: false, + existing: [{amount_cents: 100, threshold_display_name: "Remove Me"}], + params: [], + expected: [] + }, + { + description: "partial add a new item", + partial: true, + existing: [ + {amount_cents: 100, recurring: false}, + {amount_cents: 150, recurring: false} + ], + params: [{amount_cents: 333}], + expected: [ + {amount_cents: 100, threshold_display_name: nil, recurring: false}, + {amount_cents: 150, threshold_display_name: nil, recurring: false}, + {amount_cents: 333, threshold_display_name: nil, recurring: false} + ] + }, + { + description: "partial creates s recurring threshold update even if using same amount", + partial: true, + existing: [ + {amount_cents: 100, recurring: false}, + {amount_cents: 150, recurring: false} + ], + params: [{amount_cents: 100, threshold_display_name: "New", recurring: true}], + expected: [ + {amount_cents: 100, threshold_display_name: nil, recurring: false}, + {amount_cents: 150, threshold_display_name: nil, recurring: false}, + {amount_cents: 100, threshold_display_name: "New", recurring: true} + ] + }, + { + description: "partial update of non recurring threshold does not clear existing recurring threshold", + partial: true, + existing: [ + {amount_cents: 100, recurring: false}, + {amount_cents: 100, recurring: true} + ], + params: [{amount_cents: 100, threshold_display_name: "New", recurring: true}], + expected: [ + {amount_cents: 100, threshold_display_name: nil, recurring: false}, + {amount_cents: 100, threshold_display_name: "New", recurring: true} + ] + }, + { + description: "partial update of non recurring threshold does not clear existing recurring threshold", + partial: true, + existing: [ + {amount_cents: 100, recurring: false}, + {amount_cents: 100, recurring: true} + ], + params: [{amount_cents: 100, threshold_display_name: "New"}], + expected: [ + {amount_cents: 100, threshold_display_name: "New", recurring: false}, + {amount_cents: 100, threshold_display_name: nil, recurring: true} + ] + } + ] + # rubocop:enable Layout/LineLength + + test_cases.each do |tc| + context "when #{tc[:description]}" do + let(:partial) { tc[:partial] } + let(:usage_thresholds_params) { tc[:params] } + + before do + tc[:existing].each do |attrs| + create(:usage_threshold, plan: model, organization:, threshold_display_name: nil, **attrs) + end + end + + it do + expect(result).to be_success + expect(threshold_attrs(model.usage_thresholds.reload)).to match_array(tc[:expected]) + end + end + end + end +end diff --git a/spec/services/user_devices/register_service_spec.rb b/spec/services/user_devices/register_service_spec.rb new file mode 100644 index 0000000..973a3df --- /dev/null +++ b/spec/services/user_devices/register_service_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UserDevices::RegisterService do + subject(:result) { described_class.call(user:, skip_log:) } + + include_context "with mocked security logger" + + let(:membership) { create(:membership) } + let(:user) { membership.user } + let(:organization) { membership.organization } + let(:skip_log) { false } + let(:device_info) do + { + user_agent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)", + ip_address: "192.168.1.1", + browser: "Chrome 120", + os: "macOS", + device_type: "desktop" + } + end + + before do + membership + CurrentContext.device_info = device_info + allow(organization).to receive(:security_logs_enabled?).and_return(true) + allow(user).to receive(:active_organizations).and_return([organization]) + end + + context "when device_info is nil" do + let(:device_info) { nil } + + it "returns result without creating a device" do + expect { result }.not_to change(UserDevice, :count) + end + end + + context "when device is new" do + it "creates a user device" do + expect { result }.to change(UserDevice, :count).by(1) + + device = user.user_devices.sole + expect(device.fingerprint).to eq(Digest::SHA256.hexdigest(device_info[:user_agent])) + expect(device.browser).to eq("Chrome 120") + expect(device.os).to eq("macOS") + expect(device.device_type).to eq("desktop") + expect(device.last_ip_address).to eq("192.168.1.1") + end + + it "produces security logs" do + result + + expect(security_logger).to have_received(:produce).with( + organization:, + log_type: "user", + log_event: "user.new_device_logged_in", + user: + ) + end + + context "with skip_log: true" do + let(:skip_log) { true } + + it "creates a user device" do + expect { result }.to change(UserDevice, :count).by(1) + end + + it "does not produce security logs" do + result + + expect(security_logger).not_to have_received(:produce) + end + end + end + + context "when device is known" do + let!(:existing_device) do + create(:user_device, + user:, + fingerprint: Digest::SHA256.hexdigest(device_info[:user_agent]), + last_logged_at: 1.day.ago) + end + + it "does not create a new device" do + expect { result }.not_to change(UserDevice, :count) + end + + it "updates last_logged_at" do + result + + expect(existing_device.reload.last_logged_at).to be_within(1.second).of(Time.current) + end + + it "does not produce security logs" do + result + + expect(security_logger).not_to have_received(:produce) + end + end +end diff --git a/spec/services/users_service_spec.rb b/spec/services/users_service_spec.rb new file mode 100644 index 0000000..7008f9c --- /dev/null +++ b/spec/services/users_service_spec.rb @@ -0,0 +1,317 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe UsersService do + subject(:user_service) { described_class.new } + + before { create(:role, :admin) } + + describe "#register" do + include_context "with mocked security logger" + + before { allow(UserDevices::RegisterService).to receive(:call!) } + + it "registers the user device" do + result = user_service.register("email", "password", "organization_name") + + expect(UserDevices::RegisterService).to have_received(:call!).with(user: result.user, skip_log: true) + end + + it "calls SegmentIdentifyJob" do + allow(SegmentIdentifyJob).to receive(:perform_later) + result = user_service.register("email", "password", "organization_name") + + expect(SegmentIdentifyJob).to have_received(:perform_later).with( + membership_id: "membership/#{result.membership.id}" + ) + end + + it "calls SegmentTrackJob" do + allow(SegmentTrackJob).to receive(:perform_later) + result = user_service.register("email", "password", "organization_name") + + expect(SegmentTrackJob).to have_received(:perform_later).with( + membership_id: "membership/#{result.membership.id}", + event: "organization_registered", + properties: { + organization_name: result.organization.name, + organization_id: result.organization.id + } + ) + end + + it_behaves_like "produces a security log", "user.signed_up" do + before { user_service.register("email", "password", "organization_name") } + end + + it "creates an organization, user and membership" do + result = user_service.register("email", "password", "organization_name") + expect(result.user).to be_present + expect(result.membership).to be_present + expect(result.token).to be_present + + decoded = Auth::TokenService.decode(token: result.token) + expect(decoded["login_method"]).to eq(Organizations::AuthenticationMethods::EMAIL_PASSWORD) + + expect(result.organization) + .to be_present + .and have_attributes(name: "organization_name", document_numbering: "per_organization") + end + + context "when user already exists" do + let(:user) { create(:user) } + + it "fails" do + result = user_service.register(user.email, "password", "organization_name") + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages.keys).to include(:email) + expect(result.error.messages[:email]).to include("user_already_exists") + end + + it_behaves_like "does not produce a security log" do + before { user_service.register(user.email, "password", "organization_name") } + end + end + + context "when signup is disabled" do + before do + ENV["LAGO_DISABLE_SIGNUP"] = "true" + end + + after do + ENV["LAGO_DISABLE_SIGNUP"] = nil + end + + it "returns a not allowed error" do + result = user_service.register("email", "password", "organization_name") + + expect(result).not_to be_success + expect(result.error.message).to eq("signup_disabled") + end + + it_behaves_like "does not produce a security log" do + before { user_service.register("email", "password", "organization_name") } + end + end + end + + describe "#register_from_invite" do + let(:email) { Faker::Internet.email } + let(:password) { SecureRandom.hex(16) } + let(:invite) { create(:invite, email:) } + + context "when is existing user" do + let!(:user) { create(:user, email:, password: "old_password") } + + it "reuse user and adds membership" do + result = user_service.register_from_invite(invite, password) + + expect(result.user).to be_persisted + expect(result.user.email).to eq email + expect(result.membership).to be_persisted + expect(result.organization).to eq invite.organization + expect(result.token).to be_present + end + + context "without active memberships" do + before { create(:membership, user:, status: :revoked) } + + it "updates the password" do + result = user_service.register_from_invite(invite, password) + + expect(result.user).to eq user + expect(result.user.authenticate(password).id).to eq(user.id) + expect(result.user.authenticate("old_password")).to be false + expect(result.token).to be_present + end + end + + context "with active memberships" do + before { create(:membership, user:, status: :active) } + + it "keeps the existing password" do + result = user_service.register_from_invite(invite, password) + + expect(result.user).to eq user + expect(result.user.authenticate(password)).to eq false + expect(result.user.authenticate("old_password").id).to eq(user.id) + expect(result.token).to be_present + end + end + end + + context "when is a new user" do + it "creates user and membership" do + result = user_service.register_from_invite(invite, password) + + expect(result.user).to be_persisted + expect(result.user.email).to eq email + expect(result.membership).to be_present + expect(result.organization).to eq invite.organization + expect(result.token).to be_present + end + end + end + + describe "#login" do + subject(:result) { described_class.new.login(email, password) } + + let!(:membership) { create(:membership, :revoked) } + let(:user) { membership.user } + + context "when user with given email exists" do + let(:email) { user.email } + + context "when password is correct" do + let(:password) { user.password } + + context "when user has active membership" do + let!(:active_membership) { create(:membership, user:, organization: membership.organization) } + + before { allow(UserDevices::RegisterService).to receive(:call!) } + + it "registers the user device" do + result + + expect(UserDevices::RegisterService).to have_received(:call!).with(user: result.user) + end + + it "returns success result" do + expect(result).to be_success + expect(result.user).to eq user + expect(result.token).to be_present + end + + it "calls SegmentIdentifyJob with user's first active membership" do + allow(SegmentIdentifyJob).to receive(:perform_later) + subject + + expect(SegmentIdentifyJob).to have_received(:perform_later).with( + membership_id: "membership/#{active_membership.id}" + ) + end + + context "when login succeed" do + it "saves the login method in token" do + expect(result).to be_success + + decoded = Auth::TokenService.decode(token: result.token) + expect(decoded["login_method"]).to eq(Organizations::AuthenticationMethods::EMAIL_PASSWORD) + end + end + + context "when login method is not allowed" do + before { active_membership.organization.disable_email_password_authentication! } + + it "fails with login method not authorized" do + expect(result).to be_failure + expect(result.user).to eq user + expect(result.token).to be nil + expect(result.error.messages).to match(email_password: ["login_method_not_authorized"]) + end + end + end + + context "when user has no active membership" do + it "fails with incorrect credentials error" do + expect(result).to be_failure + expect(result.user).to eq user + expect(result.token).to be nil + expect(result.error.messages).to match(base: ["incorrect_login_or_password"]) + end + + it "does not call SegmentIdentifyJob" do + allow(SegmentIdentifyJob).to receive(:perform_later) + subject + + expect(SegmentIdentifyJob).not_to have_received(:perform_later) + end + end + end + + context "when password is incorrect" do + let(:password) { "invalid-password" } + + context "when user has active membership" do + before { create(:membership, user:, organization: membership.organization) } + + it "fails with incorrect credentials error" do + expect(result).to be_failure + expect(result.user).to be false + expect(result.token).to be nil + expect(result.error.messages).to match(base: ["incorrect_login_or_password"]) + end + + it "does not call SegmentIdentifyJob" do + allow(SegmentIdentifyJob).to receive(:perform_later) + subject + + expect(SegmentIdentifyJob).not_to have_received(:perform_later) + end + end + + context "when user has no active membership" do + it "fails with incorrect credentials error" do + expect(result).to be_failure + expect(result.user).to be false + expect(result.token).to be nil + expect(result.error.messages).to match(base: ["incorrect_login_or_password"]) + end + + it "does not call SegmentIdentifyJob" do + allow(SegmentIdentifyJob).to receive(:perform_later) + subject + + expect(SegmentIdentifyJob).not_to have_received(:perform_later) + end + end + end + + context "when email contains \u0000" do + let(:email) { "email\u0000" } + let(:password) { user.password } + + it "fails with invalid email or password error" do + expect(result).to be_failure + expect(result.user).to be nil + expect(result.token).to be nil + expect(result.error.messages).to match(base: ["incorrect_login_or_password"]) + end + end + + context "when password contains \u0000" do + let(:email) { user.email } + let(:password) { "password\u0000" } + + it "fails with invalid email or password error" do + expect(result).to be_failure + expect(result.user).to be nil + expect(result.token).to be nil + expect(result.error.messages).to match(base: ["incorrect_login_or_password"]) + end + end + end + + context "when user with given does not email exist" do + let(:email) { "non-existing-user@email.com" } + let(:password) { "invalid-password" } + + it "fails with incorrect credentials error" do + expect(result).to be_failure + expect(result.user).to be nil + expect(result.token).to be nil + expect(result.error.messages).to match(base: ["incorrect_login_or_password"]) + end + + it "does not call SegmentIdentifyJob" do + allow(SegmentIdentifyJob).to receive(:perform_later) + subject + + expect(SegmentIdentifyJob).not_to have_received(:perform_later) + end + end + end +end diff --git a/spec/services/utils/activity_log_spec.rb b/spec/services/utils/activity_log_spec.rb new file mode 100644 index 0000000..23dafb8 --- /dev/null +++ b/spec/services/utils/activity_log_spec.rb @@ -0,0 +1,545 @@ +# frozen_string_literal: true + +RSpec.describe Utils::ActivityLog, :capture_kafka_messages do + subject(:activity_log) { described_class } + + let(:membership) { create(:membership) } + let(:api_key) { create(:api_key) } + + let(:organization) { create(:organization) } + let(:coupon) { create(:coupon, organization:) } + + let(:serialized_coupon) do + { + topic: "activity_logs", + key: "#{organization.id}--activity-id", + payload: payload.to_json + } + end + let(:activity_type) { "coupon.created" } + let(:activity_object_changes) { {} } + let(:payload) do + { + activity_source: "api", + api_key_id: api_key.id, + user_id: nil, + activity_type:, + activity_id: "activity-id", + logged_at: Time.current.iso8601[...-1], + created_at: Time.current.iso8601[...-1], + resource_id: coupon.id, + resource_type: "Coupon", + organization_id: organization.id, + activity_object: V1::CouponSerializer.new(coupon).serialize, + activity_object_changes:, + external_customer_id: nil, + external_subscription_id: nil + } + end + + before do + allow(CurrentContext).to receive(:membership).and_return(membership.id) + allow(CurrentContext).to receive(:api_key_id).and_return(api_key.id) + allow(CurrentContext).to receive(:source).and_return("api") + travel_to(Time.zone.parse("2023-03-22 12:00:00")) + end + + around do |example| + if example.metadata[:kafka_configured] + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = "kafka" + ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"] = "activity_logs" + end + example.run + ensure + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"] = nil + end + + describe ".produce_after_commit" do + def test_produce_after_commit(&block) + produce_result = ApplicationRecord.transaction do + produce_result = activity_log.produce_after_commit(coupon, activity_type, activity_id: "activity-id", &block) + + expect(karafka_producer).not_to have_received(:produce_async) + produce_result + end + + expect(karafka_producer).to have_received(:produce_async).with( + **serialized_coupon + ) + + produce_result + end + + context "when kafka is configured", :kafka_configured do + let(:result) { BaseService::Result.new } + + context "when providing a block" do + let(:activity_type) { "coupon.updated" } + let!(:coupon_name_before_update) { coupon.name } + let(:activity_object_changes) { {"name" => [coupon_name_before_update, "new name"]} } + + it "produces the event on kafka after the commit" do + produce_result = test_produce_after_commit { + coupon.update!(name: "new name") + result.coupon = coupon + result + } + + expect(produce_result).to eq(result) + end + end + + context "when not providing a block" do + it "prouce the event after the commit" do + produce_result = test_produce_after_commit + + expect(produce_result).to be_nil + end + end + end + end + + describe ".produce" do + context "when kafka is configured", :kafka_configured do + it "produces the event on kafka" do + activity_log.produce(coupon, "coupon.created", activity_id: "activity-id") { BaseService::Result.new } + + expect(karafka_producer).to have_received(:produce_async).with( + **serialized_coupon + ) + end + + context "when the object is a wallet transaction" do + let(:wallet) { create(:wallet, organization:) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, organization:) } + + it "uses wallet as resource" do + activity_log.produce(wallet_transaction, "wallet_transaction.created", activity_id: "activity-id") { BaseService::Result.new } + + expect(karafka_producer).to have_received(:produce_async).with( + topic: "activity_logs", + key: "#{organization.id}--activity-id", + payload: { + activity_source: "api", + api_key_id: api_key.id, + user_id: nil, + activity_type: "wallet_transaction.created", + activity_id: "activity-id", + logged_at: Time.current.iso8601[...-1], + created_at: Time.current.iso8601[...-1], + resource_id: wallet.id, + resource_type: "Wallet", + organization_id: organization.id, + activity_object: V1::WalletTransactionSerializer.new(wallet_transaction).serialize, + activity_object_changes: {}, + external_customer_id: wallet.customer.external_id, + external_subscription_id: nil + }.to_json + ) + end + end + + context "when the object is a payment receipt" do + let(:payment_receipt) { create(:payment_receipt, organization:) } + + it "uses payment receipt as resource" do + activity_log.produce(payment_receipt, "payment_receipt.created", activity_id: "activity-id") { BaseService::Result.new } + + expect(karafka_producer).to have_received(:produce_async) do |args| + payload = JSON.parse(args[:payload]) + expect(payload).to include("resource_type" => "PaymentReceipt", "resource_id" => payment_receipt.id) + end + end + end + + context "when the object is deleted" do + it "does not set activity_object_changes" do + allow(CurrentContext).to receive(:source).and_return(nil) + activity_log.produce(coupon, "coupon.deleted", activity_id: "activity-id") { BaseService::Result.new } + + expect(karafka_producer).to have_received(:produce_async).with( + topic: "activity_logs", + key: "#{organization.id}--activity-id", + payload: { + activity_source: "system", + api_key_id: api_key.id, + user_id: nil, + activity_type: "coupon.deleted", + activity_id: "activity-id", + logged_at: Time.current.iso8601[...-1], + created_at: Time.current.iso8601[...-1], + resource_id: coupon.id, + resource_type: "Coupon", + organization_id: organization.id, + activity_object: V1::CouponSerializer.new(coupon).serialize, + activity_object_changes: {}, + external_customer_id: nil, + external_subscription_id: nil + }.to_json + ) + end + end + + context "when the object is nil" do + it "does not produce the event" do + activity_log.produce(nil, "coupon.created") { BaseService::Result.new } + expect(karafka_producer).not_to have_received(:produce_async) + end + end + + context "when membership does not belong to the organization" do + before do + membership.update!(organization_id: create(:organization).id) + allow(CurrentContext).to receive(:api_key_id).and_return(nil) + allow(CurrentContext).to receive(:membership).and_return("organization_id/#{membership.id}") + end + + it "does not set user_id" do + activity_log.produce(coupon, "coupon.created", activity_id: "activity-id") { BaseService::Result.new } + + expect(karafka_producer).to have_received(:produce_async).with( + topic: "activity_logs", + key: "#{organization.id}--activity-id", + payload: { + activity_source: "api", + api_key_id: nil, + user_id: nil, + activity_type: "coupon.created", + activity_id: "activity-id", + logged_at: Time.current.iso8601[...-1], + created_at: Time.current.iso8601[...-1], + resource_id: coupon.id, + resource_type: "Coupon", + organization_id: organization.id, + activity_object: V1::CouponSerializer.new(coupon).serialize, + activity_object_changes: {}, + external_customer_id: nil, + external_subscription_id: nil + }.to_json + ) + end + end + + context "when after_commit is true" do + let(:result) { BaseService::Result.new } + + it "produces the event on kafka after the commit" do + ApplicationRecord.transaction do + produce_result = activity_log.produce(coupon, "coupon.created", activity_id: "activity-id", after_commit: true) { result } + + expect(produce_result).to eq(result) + expect(karafka_producer).not_to have_received(:produce_async) + end + + expect(karafka_producer).to have_received(:produce_async).with( + **serialized_coupon + ) + end + end + end + + context "when kafka is not configured" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"] = nil + end + + it "does not produce message" do + activity_log.produce(coupon, "coupon.created") { BaseService::Result.new } + expect(karafka_producer).not_to have_received(:produce_async) + end + end + + describe ".available?" do + subject { activity_log.available? } + + context "without clickhouse" do + before do + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it { is_expected.to be_falsey } + end + + context "without kafka vars" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_ACTIVITY_LOGS_TOPIC"] = nil + ENV["LAGO_CLICKHOUSE_ENABLED"] = "true" + end + + it { is_expected.to be_falsey } + end + + context "with everything configured", :kafka_configured do + before do + ENV["LAGO_CLICKHOUSE_ENABLED"] = "true" + end + + it { is_expected.to be_truthy } + end + end + end + + describe "#object_serialized" do + subject(:method_call) do + activity_log.new(object, "object.created").send(:object_serialized) + end + + let(:object) { create(:credit_note, organization:) } + + let(:serialized_object) do + V1::CreditNoteSerializer.new( + object, root_name: :credit_note, includes: Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:credit_note] + ).serialize + end + + it "returns the serialized object" do + expect(subject).to eq(serialized_object) + end + + context "when the object is an invoice" do + let(:object) { create(:invoice, organization:) } + + let(:serialized_object) do + V1::InvoiceSerializer.new( + object, root_name: :invoice, includes: Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:invoice] - [:fees] + ).serialize + end + + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_FEES", 2) + create_list(:fee, 3, invoice: object) + end + + it "returns the serialized invoice without fees" do + expect(subject).to eq(serialized_object) + end + end + + context "when the object is a plan" do + let(:object) { create(:plan, organization:) } + + let(:serialized_object) do + V1::PlanSerializer.new( + object, root_name: :invoice, includes: Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges] + ).serialize + end + + context "with many charges" do + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGES", 2) + create_list(:standard_charge, 3, plan: object) + end + + it "returns the serialized plan without charges" do + expect(subject).to eq(serialized_object) + end + end + + context "with many charge filters" do + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGE_FILTERS", 2) + charge = create(:standard_charge, plan: object) + create_list(:charge_filter, 3, charge:) + end + + it "returns the serialized plan without charges" do + expect(subject).to eq(serialized_object) + end + end + end + + context "when the object is a subscription" do + let(:object) { create(:subscription, organization:, plan:) } + let(:plan) { create(:plan, organization:) } + + let(:serialized_object) do + V1::SubscriptionSerializer.new( + object, root_name: :invoice, includes: [{plan: Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges]}] + ).serialize + end + + context "with many charges" do + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGES", 2) + create_list(:standard_charge, 3, plan:) + end + + it "returns the serialized subscription with plan excluding charges" do + expect(subject).to eq(serialized_object) + end + end + + context "with many charge filters" do + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGE_FILTERS", 2) + charge = create(:standard_charge, plan:) + create_list(:charge_filter, 3, charge:) + end + + it "returns the serialized plan without charges" do + expect(subject).to eq(serialized_object) + end + end + end + end + + describe "#serializer_includes" do + subject(:method_call) do + activity_log.new(object, "object.created").send(:serializer_includes, root_name) + end + + let(:root_name) { object.class.name.underscore.to_sym } + + context "when object is not an invoice" do + let(:object) { create(:credit_note, organization:) } + + let(:serialized_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:credit_note] } + + it "returns the default includes for the object" do + expect(method_call).to eq(serialized_includes) + end + end + + context "when object is an invoice" do + let(:object) { create(:invoice, organization:) } + + context "when invoice has more fees than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:invoice] - [:fees] } + + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_FEES", 2) + create_list(:fee, 3, invoice: object) + end + + it "excludes fees from the includes" do + expect(subject).to eq(serializer_includes) + end + end + + context "when invoice has fewer or equal fees than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:invoice] } + + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_FEES", 2) + create_list(:fee, 2, invoice: object) + end + + it "includes fees in the includes" do + expect(subject).to eq(serializer_includes) + end + end + end + + context "when object is a plan" do + let(:object) { create(:plan, organization:) } + + context "when plan has more charges than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges] } + + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGES", 2) + create_list(:standard_charge, 3, plan: object) + end + + it "excludes charges from the includes" do + expect(subject).to eq(serializer_includes) + end + end + + context "when plan has fewer charges than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] } + + before { create_list(:standard_charge, 2, plan: object) } + + it "includes charges in the includes" do + expect(subject).to eq(serializer_includes) + end + end + + context "when plan has more charge filters than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges] } + + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGE_FILTERS", 2) + charge = create(:standard_charge, plan: object) + create_list(:charge_filter, 3, charge:) + end + + it "excludes charges from the includes" do + expect(subject).to eq(serializer_includes) + end + end + + context "when plan has fewer charge filters than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] } + + before do + charge = create(:standard_charge, plan: object) + create_list(:charge_filter, 3, charge:) + end + + it "excludes charges from the includes" do + expect(subject).to eq(serializer_includes) + end + end + end + + context "when object is a subscription" do + let(:object) { create(:subscription, organization:, plan:) } + let(:plan) { create(:plan, organization:) } + + context "when subscription's plan has more charges than the limit" do + let(:serializer_includes) { [{plan: Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges]}] } + + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGES", 2) + create_list(:standard_charge, 3, plan:) + end + + it "excludes charges from the includes" do + expect(subject).to eq(serializer_includes) + end + end + + context "when subscription's plan has fewer charges than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:subscription] } + + before { create_list(:standard_charge, 2, plan:) } + + it "includes charges in the includes" do + expect(subject).to eq(serializer_includes) + end + end + + context "when subscription's plan has more charge filters than the limit" do + let(:serializer_includes) { [{plan: Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:plan] - [:charges]}] } + + before do + stub_const("Utils::ActivityLog::MAX_SERIALIZED_CHARGE_FILTERS", 2) + charge = create(:standard_charge, plan:) + create_list(:charge_filter, 3, charge:) + end + + it "excludes charges from the includes" do + expect(subject).to eq(serializer_includes) + end + end + + context "when subscription's plan has fewer charge filters than the limit" do + let(:serializer_includes) { Utils::ActivityLog::SERIALIZED_INCLUDED_OBJECTS[:subscription] } + + before do + charge = create(:standard_charge, plan:) + create_list(:charge_filter, 2, charge:) + end + + it "includes charges in the includes" do + expect(subject).to eq(serializer_includes) + end + end + end + end +end diff --git a/spec/services/utils/api_log_spec.rb b/spec/services/utils/api_log_spec.rb new file mode 100644 index 0000000..897c38c --- /dev/null +++ b/spec/services/utils/api_log_spec.rb @@ -0,0 +1,141 @@ +# frozen_string_literal: true + +RSpec.describe Utils::ApiLog do + subject(:api_log) { described_class } + + let(:api_key) { create(:api_key) } + + let(:fake_request) do + instance_double( + "ActionDispatch::Request", + user_agent: "RSpec", + params: {parameters: [1, 2, 3, 4]}, + path: "/api/v1/customers", + base_url: "https://lago.test", + method_symbol: :post, + request_id: "1234" + ) + end + + let(:fake_response) do + instance_double( + "ActionDispatch::Response", + status: 200, + body: {"success" => true}.to_json + ) + end + + before do + allow(CurrentContext).to receive(:api_key_id).and_return(api_key.id) + travel_to(Time.zone.parse("2023-03-22 12:00:00")) + end + + describe ".produce", :capture_kafka_messages do + let(:organization) { create(:organization) } + + context "when kafka is configured" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = "kafka" + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = "api_logs" + end + + after do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = nil + end + + it "produces the event on kafka" do + api_log.produce(fake_request, fake_response, organization:) + + expect(karafka_producer).to have_received(:produce_async).with( + topic: "api_logs", + key: "#{organization.id}--1234", + payload: { + request_id: "1234", + organization_id: organization.id, + api_key_id: api_key.id, + api_version: "v1", + client: "RSpec", + request_body: {parameters: [1, 2, 3, 4]}, + request_path: "/api/v1/customers", + request_origin: "https://lago.test", + http_method: :post, + request_response: {"success" => true}, + http_status: 200, + logged_at: Time.current.iso8601[...-1], + created_at: Time.current.iso8601[...-1] + }.to_json + ) + end + + context "when request_id is empty" do + let(:fake_request) do + instance_double( + "ActionDispatch::Request", + user_agent: "RSpec", + params: {parameters: [1, 2, 3, 4]}, + path: "/api/v1/customers", + base_url: "https://lago.test", + method_symbol: :post, + request_id: "" + ) + end + + it "generates a random uuid" do + utils = api_log.new(fake_request, fake_response, organization:) + utils.produce + expect(utils.send(:payload)[:request_id]).to match Regex::UUID + end + end + end + + context "when kafka is not configured" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = nil + end + + it "does not produce message" do + api_log.produce(fake_request, fake_response, organization:) + expect(karafka_producer).not_to have_received(:produce_async) + end + end + + describe ".available?" do + subject { api_log.available? } + + context "without clickhouse" do + before do + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + end + + it { is_expected.to be_falsey } + end + + context "without kafka vars" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = nil + ENV["LAGO_CLICKHOUSE_ENABLED"] = "true" + end + + it { is_expected.to be_falsey } + end + + context "with everything configured" do + before do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = "kafka" + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = "api_logs" + ENV["LAGO_CLICKHOUSE_ENABLED"] = "true" + end + + after do + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_API_LOGS_TOPIC"] = nil + end + + it { is_expected.to be_truthy } + end + end + end +end diff --git a/spec/services/utils/datetime_spec.rb b/spec/services/utils/datetime_spec.rb new file mode 100644 index 0000000..0bfc1e5 --- /dev/null +++ b/spec/services/utils/datetime_spec.rb @@ -0,0 +1,146 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::Datetime do + subject(:datetime) { described_class } + + describe ".valid_format?" do + context "when the parameter is a string" do + context "when the date is not in ISO8601 format" do + it "returns false for invalid format" do + expect(datetime).not_to be_valid_format("2022-12-13 12:00:00Z") + end + end + + context "when the date is in ISO8601 format" do + it "returns true" do + expect(datetime).to be_valid_format("2022-12-13T12:00:00Z") + end + end + + context "when the date includes microseconds" do + it "returns true" do + expect(datetime).to be_valid_format("2024-05-30T09:45:44.394316274Z") + end + end + end + + context "when the parameter is a datetime object" do + it "returns true" do + expect(datetime).to be_valid_format(Time.current) + end + end + + context "when :any format is specified" do + context "when the date format is valid" do + it "returns true" do + expect(datetime).to be_valid_format("2022-12-13T12:00:00Z", format: :any) + expect(datetime).to be_valid_format("2022-12-13 12:00:00Z", format: :any) + end + end + + context "when the date is invalid" do + it "returns false" do + expect(datetime).not_to be_valid_format("aaa", format: :any) + end + end + end + end + + describe ".future_date?" do + context "when the date is in the future" do + it "returns true" do + expect(datetime).to be_future_date("2064-12-13T12:00:00Z") + expect(datetime).to be_future_date("2064-12-13 12:00:00") + expect(datetime).to be_future_date("2064-12-13") + end + end + + context "when the date is in the past" do + it "returns false" do + expect(datetime).not_to be_future_date("2022-12-13T12:00:00Z") + expect(datetime).not_to be_future_date("2022-12-13 12:00:00") + expect(datetime).not_to be_future_date("2022-12-13") + end + end + + context "when the format is invalid" do + it "returns false" do + expect(datetime).not_to be_future_date("aaa") + end + end + + context "when the date is an ActiveSupport::TimeWithZone" do + context "when the date is in the future" do + it "returns true" do + expect(datetime).to be_future_date(Time.current + 1.day) + end + end + + context "when the date is in the past" do + it "returns false" do + expect(datetime).not_to be_future_date(Time.current - 1.day) + end + end + end + end + + describe ".date_diff_with_timezone" do + let(:from_datetime) { Time.zone.parse("2023-08-31T23:10:00") } + let(:to_datetime) { Time.zone.parse("2023-09-30T22:59:59") } + let(:timezone) { "Europe/Paris" } + + let(:result) do + datetime.date_diff_with_timezone( + from_datetime, + to_datetime, + timezone + ) + end + + it "returns the number of days between the two datetime" do + expect(result).to eq(30) + end + + context "with positive daylight saving time" do + let(:from_datetime) { Time.zone.parse("2023-09-30T23:10:00") } + let(:to_datetime) { Time.zone.parse("2023-10-31T22:59:59") } + let(:timezone) { "Europe/Paris" } + + it "takes the daylight saving time into account" do + expect(result).to eq(31) + end + end + + context "with negative daylight saving time" do + let(:from_datetime) { Time.zone.parse("2023-02-28T23:10:00") } + let(:to_datetime) { Time.zone.parse("2023-03-31T21:59:59") } + let(:timezone) { "Europe/Paris" } + + it "takes the daylight saving time into account" do + expect(result).to eq(31) + end + end + + context "with to date is the beginning of the day" do + let(:from_datetime) { Time.zone.parse("2023-12-01T00:00:00") } + let(:to_datetime) { Time.zone.parse("2023-12-07T00:00:00") } + let(:timezone) { "UTC" } + + it "ensures it counts the full days" do + expect(result).to eq(7) + end + end + + context "with to date at the beginning of the day in timezone" do + let(:from_datetime) { Time.zone.parse("2023-12-01T00:00:00").in_time_zone(timezone).beginning_of_day.utc } + let(:to_datetime) { Time.zone.parse("2023-12-07T00:00:00").in_time_zone(timezone).beginning_of_day.utc } + let(:timezone) { "America/New_York" } + + it "ensures it counts the full days" do + expect(result).to eq(7) + end + end + end +end diff --git a/spec/services/utils/dedicated_worker_config_spec.rb b/spec/services/utils/dedicated_worker_config_spec.rb new file mode 100644 index 0000000..bdc8589 --- /dev/null +++ b/spec/services/utils/dedicated_worker_config_spec.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::DedicatedWorkerConfig do + describe ".organization_ids" do + context "when the constant is empty" do + before { stub_const("#{described_class}::ORGANIZATION_IDS", []) } + + it "returns an empty array" do + expect(described_class.organization_ids).to eq([]) + end + end + + context "when the constant has ids" do + before { stub_const("#{described_class}::ORGANIZATION_IDS", %w[abc def ghi]) } + + it "returns the ids" do + expect(described_class.organization_ids).to eq(%w[abc def ghi]) + end + end + end + + describe ".enabled_for?" do + before { stub_const("#{described_class}::ORGANIZATION_IDS", %w[org-1 org-2]) } + + it "returns false for nil" do + expect(described_class.enabled_for?(nil)).to be(false) + end + + it "returns false for blank string" do + expect(described_class.enabled_for?("")).to be(false) + end + + it "returns true for a listed id" do + expect(described_class.enabled_for?("org-1")).to be(true) + end + + it "returns false for an unlisted id" do + expect(described_class.enabled_for?("org-3")).to be(false) + end + end + + describe ".refresh_interval" do + around do |example| + previous = ENV["LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS"] + example.run + ensure + ENV["LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS"] = previous + end + + context "when the env var is a positive integer" do + before { ENV["LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS"] = "10" } + + it "returns the configured value" do + expect(described_class.refresh_interval).to eq(10) + end + end + + context "when the env var is not a positive integer" do + [nil, "", "0", "-3"].each do |value| + it "returns the default value of 5 when set to #{value.inspect}" do + if value.nil? + ENV.delete("LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS") + else + ENV["LAGO_DEDICATED_REFRESH_INTERVAL_SECONDS"] = value + end + + expect(described_class.refresh_interval).to eq(5) + end + end + end + end + + describe ".any?" do + context "when the constant is empty" do + before { stub_const("#{described_class}::ORGANIZATION_IDS", []) } + + it "returns false" do + expect(described_class.any?).to be(false) + end + end + + context "when the constant has ids" do + before { stub_const("#{described_class}::ORGANIZATION_IDS", %w[org-1]) } + + it "returns true" do + expect(described_class.any?).to be(true) + end + end + end +end diff --git a/spec/services/utils/device_info_spec.rb b/spec/services/utils/device_info_spec.rb new file mode 100644 index 0000000..4a8db78 --- /dev/null +++ b/spec/services/utils/device_info_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::DeviceInfo do + describe ".parse" do + subject(:result) { described_class.parse(request) } + + let(:request) { instance_double(ActionDispatch::Request, user_agent:, remote_ip: "192.168.1.1") } + + context "with a valid User-Agent" do + let(:user_agent) do + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) \ + AppleWebKit/537.36 (KHTML, like Gecko) \ + Chrome/120.0.0.0 \ + Safari/537.36" + end + + it "returns parsed device info" do + expect(result).to include( + user_agent: request.user_agent, + ip_address: "192.168.1.1", + browser: a_string_including("Chrome"), + os: a_string_including("Mac"), + device_type: "desktop" + ) + end + end + + context "when request is nil" do + let(:request) { nil } + + it { is_expected.to be_nil } + end + + context "when User-Agent is blank" do + let(:user_agent) { "" } + + it { is_expected.to be_nil } + end + + context "when User-Agent is nil" do + let(:user_agent) { nil } + + it { is_expected.to be_nil } + end + end +end diff --git a/spec/services/utils/email_activity_log_spec.rb b/spec/services/utils/email_activity_log_spec.rb new file mode 100644 index 0000000..e7093a5 --- /dev/null +++ b/spec/services/utils/email_activity_log_spec.rb @@ -0,0 +1,276 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::EmailActivityLog, :capture_kafka_messages do + let(:invoice) { create(:invoice) } + let(:organization) { invoice.organization } + let(:customer) { invoice.customer } + + let(:message) do + instance_double( + Mail::Message, + subject: "Test Subject", + to: ["to@example.com"], + cc: nil, + bcc: nil, + text_part: nil, + html_part: instance_double(Mail::Part, body: instance_double(Mail::Body, decoded: "

Hello

Body preview

")), + body: instance_double(Mail::Body, decoded: "") + ) + end + + before do + stub_const("#{described_class}::AVAILABLE", true) + stub_const("#{described_class}::TOPIC", "activity_logs") + + travel_to(Time.zone.parse("2024-01-15 12:00:00")) + allow(SecureRandom).to receive(:uuid).and_return("test-activity-id") + end + + describe ".produce" do + it "sends to kafka with correct topic and key" do + described_class.produce(document: invoice, message:) + + expect(kafka_messages.size).to eq(1) + expect(kafka_messages.first[:topic]).to eq("activity_logs") + expect(kafka_messages.first[:key]).to eq("#{organization.id}--test-activity-id") + end + + it "sets status to sent by default" do + described_class.produce(document: invoice, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + activity_object = payload["activity_object"] + + expect(payload["activity_source"]).to eq("system") + expect(payload["activity_type"]).to eq("email.sent") + expect(activity_object["status"]).to eq("sent") + end + + it "sets status to resent when resend is true" do + described_class.produce(document: invoice, message:, resend: true) + + payload = JSON.parse(kafka_messages.first[:payload]) + activity_object = payload["activity_object"] + + expect(activity_object["status"]).to eq("resent") + end + + it "sets status to failed when error provided" do + described_class.produce(document: invoice, message:, error: StandardError.new("SMTP failed")) + + payload = JSON.parse(kafka_messages.first[:payload]) + activity_object = payload["activity_object"] + + expect(activity_object["status"]).to eq("failed") + expect(JSON.parse(activity_object["error"])).to eq("class" => "StandardError", "message" => "SMTP failed") + end + + it "sets activity_source to api when api_key_id provided" do + described_class.produce(document: invoice, message:, api_key_id: "key-123") + + payload = JSON.parse(kafka_messages.first[:payload]) + expect(payload["activity_source"]).to eq("api") + expect(payload["api_key_id"]).to eq("key-123") + end + + it "sets activity_source to front when user_id provided" do + described_class.produce(document: invoice, message:, user_id: "user-456") + + payload = JSON.parse(kafka_messages.first[:payload]) + expect(payload["activity_source"]).to eq("front") + expect(payload["user_id"]).to eq("user-456") + end + + it "does not send to kafka when not available" do + stub_const("#{described_class}::AVAILABLE", false) + + described_class.produce(document: invoice, message:) + + expect(kafka_messages).to be_empty + end + + it "does not send to kafka when document is nil" do + described_class.produce(document: nil, message:) + + expect(kafka_messages).to be_empty + end + + it "does not send to kafka when message is nil" do + described_class.produce(document: invoice, message: nil) + + expect(kafka_messages).to be_empty + end + + it "logs error and returns nil when kafka raises" do + allow(karafka_producer).to receive(:produce_async).and_raise(StandardError, "Kafka down") + allow(Rails.logger).to receive(:error) + + result = described_class.produce(document: invoice, message:) + + expect(result).to be_nil + expect(Rails.logger).to have_received(:error).with("Failed to produce email activity log: Kafka down") + end + + context "with credit_note document" do + let(:credit_note) { create(:credit_note, invoice:, customer:) } + + it "includes credit_note number in document reference" do + described_class.produce(document: credit_note, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + activity_object = payload["activity_object"] + document = JSON.parse(activity_object["document"]) + + expect(document["type"]).to eq("CreditNote") + expect(document["number"]).to eq(credit_note.number) + end + end + + context "with html-only email" do + it "extracts body preview from html_part with tags stripped" do + described_class.produce(document: invoice, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + email = JSON.parse(payload["activity_object"]["email"]) + + expect(email["body_preview"]).to eq("Hello Body preview") + end + end + + context "with text_part present" do + let(:message) do + instance_double( + Mail::Message, + subject: "Test Subject", + to: ["to@example.com"], + cc: nil, + bcc: nil, + text_part: instance_double(Mail::Part, body: instance_double(Mail::Body, decoded: "Plain text body")), + html_part: instance_double(Mail::Part, body: instance_double(Mail::Body, decoded: "

HTML body

")), + body: instance_double(Mail::Body, decoded: "") + ) + end + + it "prefers text_part over html_part" do + described_class.produce(document: invoice, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + email = JSON.parse(payload["activity_object"]["email"]) + + expect(email["body_preview"]).to eq("Plain text body") + end + end + + context "with simple non-multipart message" do + let(:message) do + instance_double( + Mail::Message, + subject: "Test Subject", + to: ["to@example.com"], + cc: nil, + bcc: nil, + text_part: nil, + html_part: nil, + body: instance_double(Mail::Body, decoded: "Simple body text") + ) + end + + it "falls back to message body" do + described_class.produce(document: invoice, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + email = JSON.parse(payload["activity_object"]["email"]) + + expect(email["body_preview"]).to eq("Simple body text") + end + end + + context "with empty body" do + let(:message) do + instance_double( + Mail::Message, + subject: "Test Subject", + to: ["to@example.com"], + cc: nil, + bcc: nil, + text_part: nil, + html_part: nil, + body: instance_double(Mail::Body, decoded: "") + ) + end + + it "returns empty string" do + described_class.produce(document: invoice, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + email = JSON.parse(payload["activity_object"]["email"]) + + expect(email["body_preview"]).to eq("") + end + end + + context "with long html body" do + let(:long_html) { "

#{"a" * 600}

" } + + let(:message) do + instance_double( + Mail::Message, + subject: "Test Subject", + to: ["to@example.com"], + cc: nil, + bcc: nil, + text_part: nil, + html_part: instance_double(Mail::Part, body: instance_double(Mail::Body, decoded: long_html)), + body: instance_double(Mail::Body, decoded: "") + ) + end + + it "truncates to BODY_PREVIEW_LENGTH" do + described_class.produce(document: invoice, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + email = JSON.parse(payload["activity_object"]["email"]) + + expect(email["body_preview"].length).to be <= described_class::BODY_PREVIEW_LENGTH + end + end + + context "with payment_receipt document" do + let(:payment_receipt) do + payment = create( + :payment, + payable: invoice, + customer:, + payment_provider: nil, + payment_provider_customer: nil, + payment_type: "manual", + reference: "manual-payment-ref", + amount_cents: invoice.total_amount_cents + ) + create(:payment_receipt, payment:, organization:) + end + + it "includes payment_receipt number in document reference" do + described_class.produce(document: payment_receipt, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + activity_object = payload["activity_object"] + document = JSON.parse(activity_object["document"]) + + expect(document["type"]).to eq("PaymentReceipt") + expect(document["number"]).to eq(payment_receipt.number) + end + + it "uses payment_receipt as resource" do + described_class.produce(document: payment_receipt, message:) + + payload = JSON.parse(kafka_messages.first[:payload]) + + expect(payload["resource_type"]).to eq("PaymentReceipt") + expect(payload["resource_id"]).to eq(payment_receipt.id) + end + end + end +end diff --git a/spec/services/utils/entitlement_spec.rb b/spec/services/utils/entitlement_spec.rb new file mode 100644 index 0000000..5d8b808 --- /dev/null +++ b/spec/services/utils/entitlement_spec.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::Entitlement do + subject(:utils_entitlement) { described_class } + + describe ".cast_value" do + context "when value is blank" do + it "returns nil for empty string" do + expect(utils_entitlement.cast_value("", "integer")).to eq 0 + end + + it "returns nil for nil" do + expect(utils_entitlement.cast_value(nil, "integer")).to be_nil + end + end + + context "when type is integer" do + it "casts string to integer" do + expect(utils_entitlement.cast_value("42", "integer")).to eq(42) + end + + it "casts float string to integer" do + expect(utils_entitlement.cast_value("42.5", "integer")).to eq(42) + end + end + + context "when type is boolean" do + it "casts true string to boolean" do + expect(utils_entitlement.cast_value("true", "boolean")).to be(true) + end + + it "casts false string to boolean" do + expect(utils_entitlement.cast_value("false", "boolean")).to be(false) + end + + it "casts false to boolean" do + expect(utils_entitlement.cast_value(false, "boolean")).to be(false) + end + + it "casts 1 to boolean" do + expect(utils_entitlement.cast_value("1", "boolean")).to be(true) + end + + it "casts 0 to boolean" do + expect(utils_entitlement.cast_value("0", "boolean")).to be(false) + end + end + + context "when type is string or unknown" do + it "returns value as-is for string type" do + expect(utils_entitlement.cast_value("hello", "string")).to eq("hello") + end + + it "returns value as-is for unknown type" do + expect(utils_entitlement.cast_value("hello", "unknown")).to eq("hello") + end + end + end + + describe ".same_value?" do + it do + expect(utils_entitlement.same_value?("boolean", "t", true)).to eq true + expect(utils_entitlement.same_value?("boolean", "t", "true")).to eq true + expect(utils_entitlement.same_value?("boolean", "t", 1)).to eq true + expect(utils_entitlement.same_value?("boolean", "t", "1")).to eq true + expect(utils_entitlement.same_value?("boolean", "f", false)).to eq true + expect(utils_entitlement.same_value?("boolean", "f", "false")).to eq true + expect(utils_entitlement.same_value?("boolean", "f", 0)).to eq true + expect(utils_entitlement.same_value?("boolean", "f", "0")).to eq true + + expect(utils_entitlement.same_value?("integer", "1", 1)).to eq true + expect(utils_entitlement.same_value?("integer", "any", "0")).to eq true + + expect(utils_entitlement.same_value?("string", "str", "str")).to eq true + expect(utils_entitlement.same_value?("string", "str", "str2")).to eq false + + # Notice that the same values with "boolean" would be considered equal + expect(utils_entitlement.same_value?("string", "f", "0")).to eq false + end + end + + describe ".convert_gql_input_to_params" do + context "when entitlements array is empty" do + it "returns an empty hash" do + result = utils_entitlement.convert_gql_input_to_params([]) + expect(result).to eq({}) + end + end + + context "when multiple entitlements are provided" do + let(:entitlement1) do + instance_double(::Types::Entitlement::EntitlementInput, + feature_code: "seats", + privileges: [ + instance_double(::Types::Entitlement::EntitlementPrivilegeInput, privilege_code: "max_seats", value: 50) + ]) + end + let(:entitlement2) do + instance_double(::Types::Entitlement::EntitlementInput, + feature_code: "storage", + privileges: [ + instance_double(::Types::Entitlement::EntitlementPrivilegeInput, privilege_code: "limit", value: "1TB"), + instance_double(::Types::Entitlement::EntitlementPrivilegeInput, privilege_code: "enabled", value: false) + ]) + end + let(:entitlement3) do + instance_double(::Types::Entitlement::EntitlementInput, + feature_code: "api_access", + privileges: []) + end + + it "returns hash with all entitlements mapped correctly" do + result = utils_entitlement.convert_gql_input_to_params([entitlement1, entitlement2, entitlement3]) + expect(result).to eq({ + "seats" => { + "max_seats" => 50 + }, + "storage" => { + "limit" => "1TB", + "enabled" => false + }, + "api_access" => {} + }) + end + end + end +end diff --git a/spec/services/utils/pdf_attachment_service_spec.rb b/spec/services/utils/pdf_attachment_service_spec.rb new file mode 100644 index 0000000..de600b8 --- /dev/null +++ b/spec/services/utils/pdf_attachment_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::PdfAttachmentService do + let(:file) { instance_double(File, path: "/tmp/test.pdf") } + let(:attachment) { instance_double(File, path: "/tmp/test.xml") } + + describe "#call" do + subject { described_class.new(file:, attachment:).call } + + it "add the attachment to pdf" do + allow(Kernel).to receive(:system).with("pdfcpu", "attach", "add", file.path, attachment.path).and_return(true) + allow(File).to receive(:file?).with(file).and_return(true) + allow(File).to receive(:file?).with(attachment).and_return(true) + + result = subject + expect(result).to be_success + expect(result.file).to eq(file) + end + + context "when file param is not a file" do + let(:file) { "" } + + it "fails" do + allow(File).to receive(:file?).with(file).and_call_original + + result = subject + expect(result).to be_failure + expect(result.error.message).to eq("file_not_found") + end + end + + context "when file param is not a pdf" do + let(:file) { instance_double(File, path: "/tmp/test.doc") } + + it "fails" do + allow(File).to receive(:file?).with(file).and_return(true) + + result = subject + expect(result).to be_failure + expect(result.error.message).to eq("not_a_pdf_file") + end + end + + context "when attachment param is not a file" do + let(:attachment) { "" } + + before { attachment } + + it "fails" do + allow(File).to receive(:file?).with(file).and_return(true) + allow(File).to receive(:file?).with(attachment).and_call_original + + result = subject + expect(result).to be_failure + expect(result.error.message).to eq("attachment_not_found") + end + end + end +end diff --git a/spec/services/utils/pdf_generator_spec.rb b/spec/services/utils/pdf_generator_spec.rb new file mode 100644 index 0000000..233f6bf --- /dev/null +++ b/spec/services/utils/pdf_generator_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::PdfGenerator do + subject(:pdf_generator_service) { described_class.new(template: "invoices/v2", context: invoice) } + + let(:invoice) { create(:invoice) } + let(:pdf_response) do + File.read(Rails.root.join("spec/fixtures/blank.pdf")) + end + + before do + stub_request(:post, "#{ENV["LAGO_PDF_URL"]}/forms/chromium/convert/html") + .to_return(body: pdf_response, status: 200) + end + + describe ".call" do + it "generated the document synchronously" do + result = pdf_generator_service.call + + expect(result.io).to be_present + end + end +end diff --git a/spec/services/utils/security_log_spec.rb b/spec/services/utils/security_log_spec.rb new file mode 100644 index 0000000..b8c92a4 --- /dev/null +++ b/spec/services/utils/security_log_spec.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Utils::SecurityLog do + subject(:security_log) { described_class } + + let(:karafka_producer) { instance_double(WaterDrop::Producer) } + + before do + allow(Karafka).to receive(:producer).and_return(karafka_producer) + allow(karafka_producer).to receive(:produce_async) + end + + describe ".available?" do + subject { security_log.available? } + + include_context "with security log infrastructure" + + context "when infrastructure is configured" do + it { is_expected.to be_truthy } + end + + context "when clickhouse is not configured" do + let(:clickhouse_enabled) { nil } + + it { is_expected.to be_falsey } + end + + context "when kafka bootstrap servers are not configured" do + let(:kafka_bootstrap_servers) { nil } + + it { is_expected.to be_falsey } + end + + context "when kafka topic is not configured" do + let(:kafka_security_logs_topic) { nil } + + it { is_expected.to be_falsey } + end + end + + describe ".produce" do + subject(:produce) do + security_log.produce( + organization:, + log_type: "user", + log_event: "user.signed_up", + user:, + api_key:, + resources: {user_email: "test@example.com"}, + device_info: {browser: "Chrome"} + ) + end + + let(:organization) { create(:organization, premium_integrations: ["security_logs"]) } + let(:membership) { create(:membership, organization:) } + let(:user) { membership.user } + let(:api_key) { create(:api_key, organization:) } + + include_context "with security log infrastructure" + + before do + allow(License).to receive(:premium?).and_return(true) + travel_to(Time.zone.parse("2024-01-15 12:00:00")) + end + + context "when infrastructure is configured and security_logs enabled" do + it "produces the event on kafka" do + expect(produce).to be true + + expect(karafka_producer).to have_received(:produce_async) do |args| + expect(args[:topic]).to eq("security_logs") + expect(args[:key]).to start_with("#{organization.id}--") + + payload = JSON.parse(args[:payload]) + expect(payload["organization_id"]).to eq(organization.id) + expect(payload["user_id"]).to eq(user.id) + expect(payload["api_key_id"]).to eq(api_key.id) + expect(payload["log_id"]).to be_present + expect(payload["log_type"]).to eq("user") + expect(payload["log_event"]).to eq("user.signed_up") + expect(payload["device_info"]).to eq({"browser" => "Chrome"}) + expect(payload["resources"]).to eq({"user_email" => "test@example.com"}) + expect(payload["logged_at"]).to be_present + expect(payload["created_at"]).to be_present + end + end + end + + context "when infrastructure is not configured" do + let(:clickhouse_enabled) { nil } + + it "does not produce and returns false" do + expect(produce).to be false + expect(karafka_producer).not_to have_received(:produce_async) + end + end + + context "when security_logs is not enabled for organization" do + let(:organization) { create(:organization, premium_integrations: []) } + + it "does not produce and returns false" do + expect(produce).to be false + expect(karafka_producer).not_to have_received(:produce_async) + end + end + + context "when security_logs is not enabled but skip_organization_check is true" do + subject(:produce) do + security_log.produce( + organization:, + log_type: "user", + log_event: "user.signed_up", + user:, + api_key:, + resources: {user_email: "test@example.com"}, + skip_organization_check: true + ) + end + + let(:organization) { create(:organization, premium_integrations: []) } + + it "produces the event on kafka" do + expect(produce).to be true + expect(karafka_producer).to have_received(:produce_async) + end + end + + context "when License is not premium" do + before { allow(License).to receive(:premium?).and_return(false) } + + it "does not produce and returns false" do + expect(produce).to be false + expect(karafka_producer).not_to have_received(:produce_async) + end + end + + context "when user is nil" do + let(:user) { nil } + + it "produces with nil user_id" do + expect(produce).to be true + + expect(karafka_producer).to have_received(:produce_async) do |args| + payload = JSON.parse(args[:payload]) + expect(payload["user_id"]).to be_nil + end + end + end + + context "when api_key is nil" do + let(:api_key) { nil } + + it "produces with nil api_key_id" do + expect(produce).to be true + + expect(karafka_producer).to have_received(:produce_async) do |args| + payload = JSON.parse(args[:payload]) + expect(payload["api_key_id"]).to be_nil + end + end + end + + context "when user is not provided but CurrentContext.membership is set" do + subject(:produce) do + security_log.produce( + organization:, + log_type: "user", + log_event: "user.signed_up", + api_key:, + resources: {} + ) + end + + before do + CurrentContext.membership = "gid://app/Membership/#{membership.id}" + end + + after do + CurrentContext.membership = nil + end + + it "resolves user_id from membership" do + expect(produce).to be true + + expect(karafka_producer).to have_received(:produce_async) do |args| + payload = JSON.parse(args[:payload]) + expect(payload["user_id"]).to eq(user.id) + end + end + end + + context "when user is not provided and CurrentContext.api_key_id is set" do + subject(:produce) do + security_log.produce( + organization:, + log_type: "api_key", + log_event: "api_key.created", + api_key:, + resources: {} + ) + end + + before do + CurrentContext.api_key_id = api_key.id + CurrentContext.membership = "gid://app/Membership/#{membership.id}" + end + + after do + CurrentContext.api_key_id = nil + CurrentContext.membership = nil + end + + it "produces with nil user_id" do + expect(produce).to be true + + expect(karafka_producer).to have_received(:produce_async) do |args| + payload = JSON.parse(args[:payload]) + expect(payload["user_id"]).to be_nil + end + end + end + + context "when user is not provided and CurrentContext.membership is blank" do + subject(:produce) do + security_log.produce( + organization:, + log_type: "user", + log_event: "user.signed_up", + api_key:, + resources: {} + ) + end + + before do + CurrentContext.membership = nil + end + + it "produces with nil user_id" do + expect(produce).to be true + + expect(karafka_producer).to have_received(:produce_async) do |args| + payload = JSON.parse(args[:payload]) + expect(payload["user_id"]).to be_nil + end + end + end + + context "when device_info is not provided but CurrentContext.device_info is set" do + subject(:produce) do + security_log.produce( + organization:, + log_type: "user", + log_event: "user.signed_up", + user:, + api_key:, + resources: {} + ) + end + + before { CurrentContext.device_info = {browser: "Chrome", os: "Mac OS"} } + after { CurrentContext.device_info = nil } + + it "uses device_info from CurrentContext" do + expect(produce).to be true + + expect(karafka_producer).to have_received(:produce_async) do |args| + payload = JSON.parse(args[:payload]) + expect(payload["device_info"]).to eq({"browser" => "Chrome", "os" => "Mac OS"}) + end + end + end + end +end diff --git a/spec/services/validators/decimal_amount_service_spec.rb b/spec/services/validators/decimal_amount_service_spec.rb new file mode 100644 index 0000000..b764a11 --- /dev/null +++ b/spec/services/validators/decimal_amount_service_spec.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Validators::DecimalAmountService do + subject(:decimal_amount_service) { described_class.new(amount) } + + describe ".valid_amount?" do + let(:amount) { "15.00" } + + it "returns true" do + expect(decimal_amount_service).to be_valid_amount + end + + context "with zero amount" do + let(:amount) { "0.00" } + + it "returns true" do + expect(decimal_amount_service).to be_valid_amount + end + end + + context "with negative amount" do + let(:amount) { "-15.00" } + + it "returns false" do + expect(decimal_amount_service).not_to be_valid_amount + end + end + + context "with invalid amount" do + let(:amount) { "foobar" } + + it "returns false" do + expect(decimal_amount_service).not_to be_valid_amount + end + end + + context "with not string amount" do + let(:amount) { 1234 } + + it "returns false" do + expect(decimal_amount_service).not_to be_valid_amount + end + end + end + + describe ".valid_positive_amount?" do + let(:amount) { "1.00" } + + it "returns true" do + expect(decimal_amount_service).to be_valid_positive_amount + end + + context "with zero amount" do + let(:amount) { "0.00" } + + it "returns false" do + expect(decimal_amount_service).not_to be_valid_positive_amount + end + end + + context "with negative amount" do + let(:amount) { "-1.00" } + + it "returns false" do + expect(decimal_amount_service).not_to be_valid_positive_amount + end + end + end +end diff --git a/spec/services/validators/expiration_date_validator_spec.rb b/spec/services/validators/expiration_date_validator_spec.rb new file mode 100644 index 0000000..d661b0c --- /dev/null +++ b/spec/services/validators/expiration_date_validator_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Validators::ExpirationDateValidator do + describe ".valid?" do + subject { described_class.valid?(expiration_at) } + + context "when expiration_at is blank" do + let(:expiration_at) { nil } + + it { is_expected.to be true } + end + + context "when expiration_at is an empty string" do + let(:expiration_at) { "" } + + it { is_expected.to be true } + end + + context "when expiration_at is an invalid format" do + let(:expiration_at) { "invalid-date" } + + it { is_expected.to be false } + end + + context "when expiration_at is an integer" do + let(:expiration_at) { 123 } + + it { is_expected.to be false } + end + + context "when expiration_at is a past date" do + let(:expiration_at) { (Time.current - 1.day).iso8601 } + + it { is_expected.to be false } + end + + context "when expiration_at is a past datetime" do + let(:expiration_at) { (Time.current - 1.hour).iso8601 } + + it { is_expected.to be false } + end + + context "when expiration_at is today but not in the future" do + let(:expiration_at) { Time.current.beginning_of_day.iso8601 } + + it { is_expected.to be false } + end + + context "when expiration_at is a valid future date" do + let(:expiration_at) { (Time.current + 1.day).iso8601 } + + it { is_expected.to be true } + end + + context "when expiration_at is a valid future datetime" do + let(:expiration_at) { (Time.current + 1.hour).iso8601 } + + it { is_expected.to be true } + end + + context "when expiration_at is an ActiveSupport::TimeWithZone object in the future" do + let(:expiration_at) { Time.zone.now + 1.day } + + it { is_expected.to be true } + end + + context "when expiration_at is an ActiveSupport::TimeWithZone object in the past" do + let(:expiration_at) { Time.zone.now - 1.day } + + it { is_expected.to be false } + end + end +end diff --git a/spec/services/validators/metadata_validator_spec.rb b/spec/services/validators/metadata_validator_spec.rb new file mode 100644 index 0000000..a237f5c --- /dev/null +++ b/spec/services/validators/metadata_validator_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Validators::MetadataValidator do + subject(:metadata_validator) { described_class.new(metadata) } + + let(:max_keys) { Validators::MetadataValidator::DEFAULT_CONFIG[:max_keys] } + let(:max_key_length) { Validators::MetadataValidator::DEFAULT_CONFIG[:max_key_length] } + let(:max_value_length) { Validators::MetadataValidator::DEFAULT_CONFIG[:max_value_length] } + + describe ".valid?" do + let(:metadata) { [{"key" => "valid_key", "value" => "valid_value"}] } + + it "returns true for valid metadata" do + expect(metadata_validator).to be_valid + end + + context "when metadata has too many key-value pairs" do + let(:metadata) { (1..max_keys + 1).map { |i| {"key" => "key#{i}", "value" => "value#{i}"} } } + + it "returns false" do + expect(metadata_validator).not_to be_valid + expect(metadata_validator.errors[:metadata]).to include("too_many_keys") + end + end + + context "when metadata contains a key that is too long" do + let(:metadata) { [{"key" => "a" * (max_key_length + 1), "value" => "valid"}] } + + it "returns false" do + expect(metadata_validator).not_to be_valid + expect(metadata_validator.errors[:metadata]).to include("key_too_long") + end + end + + context "when metadata contains a value that is too long" do + let(:metadata) { [{"key" => "key", "value" => "a" * (max_value_length + 1)}] } + + it "returns false" do + expect(metadata_validator).not_to be_valid + expect(metadata_validator.errors[:metadata]).to include("value_too_long") + end + end + + context "when metadata contains nested structures as value" do + let(:metadata) { [{"key" => "key", "value" => {"key" => "nested_value"}}] } + + it "returns false" do + expect(metadata_validator).not_to be_valid + expect(metadata_validator.errors[:metadata]).to include("nested_structure_not_allowed") + end + end + + context "when metadata is a single hash instead of an array" do + let(:metadata) { {"key" => "fixed", "value" => "0"} } + + it "returns false" do + expect(metadata_validator).not_to be_valid + expect(metadata_validator.errors[:metadata]).to include("invalid_key_value_pair") + end + end + + context "when metadata contains a hash with invalid key-value pair structure" do + let(:metadata) { [{"key1" => "value1", "key2" => "value2"}] } + + it "returns false" do + expect(metadata_validator).not_to be_valid + expect(metadata_validator.errors[:metadata]).to include("invalid_key_value_pair") + end + end + + context "when metadata is empty" do + let(:metadata) {} + + it "returns true" do + expect(metadata_validator).to be_valid + end + end + + context "when metadata is an empty array" do + let(:metadata) { [] } + + it "returns true" do + expect(metadata_validator).to be_valid + end + end + + context "when metadata is an empty hash" do + let(:metadata) { {} } + + it "returns false" do + expect(metadata_validator).not_to be_valid + expect(metadata_validator.errors[:metadata]).to include("invalid_type") + end + end + end +end diff --git a/spec/services/validators/wallet_transaction_amount_limits_validator_spec.rb b/spec/services/validators/wallet_transaction_amount_limits_validator_spec.rb new file mode 100644 index 0000000..feee308 --- /dev/null +++ b/spec/services/validators/wallet_transaction_amount_limits_validator_spec.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Validators::WalletTransactionAmountLimitsValidator do + let(:result) { BaseService::LegacyResult.new } + let(:wallet) { create(:wallet, paid_top_up_min_amount_cents:, paid_top_up_max_amount_cents:) } + let(:paid_top_up_min_amount_cents) { 5_00 } + let(:paid_top_up_max_amount_cents) { 100_00 } + let(:credits_amount) { "1.0" } + let(:ignore_validation) { false } + + describe "#raise_if_invalid!" do + context "when invalid" do + subject { described_class.new(result, wallet:, credits_amount:, ignore_validation:).raise_if_invalid! } + + it { expect { subject }.to raise_error(BaseService::ValidationFailure) } + end + + context "when valid" do + subject { described_class.new(result, wallet:, credits_amount:, ignore_validation: true).raise_if_invalid! } + + it { expect { subject }.not_to raise_error } + end + end + + describe "#valid?" do + subject { described_class.new(result, wallet:, credits_amount:, ignore_validation:).valid? } + + context "when ignore_validation is true" do + let(:ignore_validation) { true } + + it { is_expected.to be true } + end + + context "when wallet does not have limits" do + let(:paid_top_up_min_amount_cents) { nil } + let(:paid_top_up_max_amount_cents) { nil } + + it { is_expected.to be true } + end + + context "when credits_amount is blank" do + let(:credits_amount) { nil } + + it do + expect(subject).to be false + expect(result).to be_failure + expect(result.error.messages[:paid_credits]).to eq(["invalid_amount"]) + end + end + + context "when credits_amount is zero" do + let(:credits_amount) { "0.00" } + + it do + expect(subject).to be false + expect(result).to be_failure + expect(result.error.messages[:paid_credits]).to eq(["invalid_amount"]) + end + end + + context "when credits_amount is less than min amount" do + let(:credits_amount) { "4.99" } + + it do + expect(subject).to be false + expect(result).to be_failure + expect(result.error.messages[:paid_credits]).to eq(["amount_below_minimum"]) + end + end + + context "when credits_amount is more than max amount" do + let(:credits_amount) { "100.1" } + + it do + expect(subject).to be false + expect(result).to be_failure + expect(result.error.messages[:paid_credits]).to eq(["amount_above_maximum"]) + end + end + + context "when credits_amount is equal to a limit" do + let(:credits_amount) { "5" } + let(:paid_top_up_max_amount_cents) { paid_top_up_min_amount_cents } + + it { is_expected.to be true } + end + + context "when field_name is provided" do + it "sets the field name in the result errors" do + described_class.new(result, wallet:, credits_amount: "0", ignore_validation: false, field_name: :other_name).valid? + expect(result.error.messages[:other_name]).to eq(["invalid_amount"]) + end + end + end +end diff --git a/spec/services/wallet_transactions/create_from_params_service_spec.rb b/spec/services/wallet_transactions/create_from_params_service_spec.rb new file mode 100644 index 0000000..846a77f --- /dev/null +++ b/spec/services/wallet_transactions/create_from_params_service_spec.rb @@ -0,0 +1,427 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::CreateFromParamsService do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, currency:) } + let(:currency) { "EUR" } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) do + create( + :wallet, + customer:, + currency:, + rate_amount:, + balance_cents: 1000, + credits_balance: 10.0, + ongoing_balance_cents: 1000, + credits_ongoing_balance: 10.0, + invoice_requires_successful_payment: wallet_invoice_requires_successful_payment + ) + end + let(:wallet_invoice_requires_successful_payment) { false } + let(:rate_amount) { 1 } + + before do + subscription + end + + describe "#call" do + subject(:result) { described_class.call(organization:, params:) } + + let(:paid_credits) { "10.00" } + let(:granted_credits) { "15.00" } + let(:voided_credits) { "3.00" } + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + granted_credits:, + voided_credits:, + **((name == :undefined) ? {} : {name:}) + } + end + let(:name) { :undefined } + + it "creates wallet transactions" do + expect { subject }.to change(WalletTransaction, :count).by(3) + end + + it "defaults priority to 50, name to nil and source to manual" do + expect(result.wallet_transactions).to all(have_attributes(priority: 50, name: nil, source: "manual")) + expect(WalletTransaction.where(wallet_id: wallet.id)).to all(have_attributes(priority: 50, name: nil, source: "manual")) + end + + it "sets expected transaction status" do + subject + transactions = WalletTransaction.where(wallet_id: wallet.id) + + expect(transactions.find(&:purchased?).credit_amount).to eq(10) + expect(transactions.find(&:granted?).credit_amount).to eq(15) + expect(transactions.find(&:voided?).credit_amount).to eq(3) + end + + it "enqueues the BillPaidCreditJob" do + expect { subject }.to have_enqueued_job_after_commit(BillPaidCreditJob) + end + + it "updates wallet balance based on granted and voided credits" do + subject + + expect(wallet.reload.balance_cents).to eq(2200) + expect(wallet.reload.credits_balance).to eq(22.0) + end + + it "updates wallet ongoing balance based on granted and voided credits" do + subject + + expect(wallet.reload.ongoing_balance_cents).to eq(2200) + expect(wallet.reload.credits_ongoing_balance).to eq(22.0) + end + + it "enqueues a SendWebhookJob for each wallet transaction" do + expect do + subject + end.to have_enqueued_job(SendWebhookJob).thrice.with("wallet_transaction.created", WalletTransaction) + end + + it "produces an activity log" do + subject + + expect(Utils::ActivityLog).to have_received(:produce).thrice.with(an_instance_of(WalletTransaction), "wallet_transaction.created") + end + + context "when rounding is applied" do + let(:paid_credits) { "0" } + let(:granted_credits) { "10.000000" } + let(:voided_credits) { "4.28444999" } + + it "creates wallet transactions with rounded values" do + transaction = subject.wallet_transactions.find(&:voided?) + + expect(transaction.credit_amount).to eq(4.28444) + expect(transaction.amount).to eq(4.28) + + wallet.reload + + expect(wallet.credits_balance).to eq(15.71556) + expect(wallet.balance.to_d).to eq(15.72) + end + end + + context "with metadata parameter" do + let(:metadata) { [{"key" => "valid_value", "value" => "also_valid"}] } + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + granted_credits:, + voided_credits:, + metadata: metadata + }.with_indifferent_access + end + + it "processes the transaction normally and includes the metadata" do + expect(result).to be_success + transactions = WalletTransaction.where(wallet_id: wallet.id) + expect(transactions).to all(have_attributes(metadata: [{"key" => "valid_value", "value" => "also_valid"}])) + end + end + + context "with priority parameter" do + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + granted_credits:, + voided_credits:, + priority: + } + end + + let(:priority) { 25 } + + it "creates wallet transactions with specified priority" do + expect(result.wallet_transactions).to all(have_attributes(priority:)) + end + end + + context "with source parameter" do + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + granted_credits:, + voided_credits:, + source: :interval + } + end + + it "creates wallet transactions with specified source" do + expect(result.wallet_transactions).to all(have_attributes(source: "interval")) + end + end + + context "with voided_invoice_id parameter" do + let(:voided_invoice) { create(:invoice, :voided, organization:) } + let(:params) do + { + wallet_id: wallet.id, + granted_credits: "10", + voided_invoice_id: voided_invoice.id + } + end + + it "creates granted transaction with voided_invoice_id" do + expect(result.wallet_transactions.first.voided_invoice_id).to eq(voided_invoice.id) + end + end + + context "with name parameter" do + let(:name) { "Custom Top-up Name" } + + it "creates wallet transactions with specified name" do + expect(result.wallet_transactions).to all(have_attributes(name: "Custom Top-up Name")) + end + + context "when name parameter is blank" do + let(:name) { "" } + + it "creates wallet transactions with nil name" do + expect(result.wallet_transactions).to all(have_attributes(name: nil)) + end + end + + context "when name parameter is nil" do + let(:name) { nil } + + it "creates wallet transactions with nil name" do + expect(result.wallet_transactions).to all(have_attributes(name: nil)) + end + end + end + + context "with invoice_requires_successful_payment parameter" do + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + invoice_requires_successful_payment: + } + end + + let(:invoice_requires_successful_payment) { true } + + it "creates wallet transactions with specified invoice_requires_successful_payment" do + expect(result.wallet_transactions).to all(have_attributes(invoice_requires_successful_payment:)) + end + + context "when invoice_requires_successful_payment parameter is false" do + let(:invoice_requires_successful_payment) { false } + + context "when wallet's invoice_requires_successful_payment is true" do + let(:wallet_invoice_requires_successful_payment) { true } + + it "creates wallet transactions with specified invoice_requires_successful_payment" do + expect(result.wallet_transactions).to all(have_attributes(invoice_requires_successful_payment: false)) + end + end + end + + context "when invoice_requires_successful_payment parameter is null" do + let(:invoice_requires_successful_payment) { nil } + + context "when wallet's invoice_requires_successful_payment is true" do + let(:wallet_invoice_requires_successful_payment) { true } + + it "creates wallet transactions with specified invoice_requires_successful_payment" do + expect(result.wallet_transactions).to all(have_attributes(invoice_requires_successful_payment: true)) + end + end + + context "when wallet's invoice_requires_successful_payment is false" do + it "creates wallet transactions with specified invoice_requires_successful_payment" do + expect(result.wallet_transactions).to all(have_attributes(invoice_requires_successful_payment: false)) + end + end + end + end + + context "with payment method" do + it "sets correctly default payment method values" do + expect(result).to be_success + + transactions = WalletTransaction.where(wallet_id: wallet.id) + expect(transactions).to all(have_attributes(payment_method_id: nil)) + expect(transactions).to all(have_attributes(payment_method_type: "provider")) + end + + context "when specific payment method is passed" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + granted_credits:, + voided_credits:, + payment_method: { + payment_method_id: payment_method.id, + payment_method_type: "provider" + }, + **((name == :undefined) ? {} : {name:}) + } + end + + it "sets correctly payment method" do + expect(result).to be_success + + transaction = WalletTransaction.where(wallet_id: wallet.id, transaction_status: :purchased).first + expect(transaction.payment_method_id).to eq(payment_method.id) + expect(transaction.payment_method_type).to eq("provider") + end + end + end + + context "with validation error" do + let(:paid_credits) { "-15.00" } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:paid_credits]).to eq(["invalid_paid_credits", "invalid_amount"]) + end + + context "when paid_credits is below the wallet minimum" do + let(:paid_credits) { "5.00" } + + before { wallet.update! paid_top_up_min_amount_cents: 100_00 } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:paid_credits]).to eq(["amount_below_minimum"]) + end + + context "when ignore_paid_top_up_limits is true" do + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + ignore_paid_top_up_limits: true + } + end + + it "creates wallet transaction" do + expect(result).to be_success + expect(result.wallet_transactions.first.credit_amount).to eq(5) + end + end + end + + context "with invalid payment method" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + granted_credits:, + voided_credits:, + payment_method: payment_method_params, + **((name == :undefined) ? {} : {name:}) + } + end + + before { payment_method } + + context "when type is invalid" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "fails" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when ID is invalid" do + let(:payment_method_params) do + { + payment_method_id: "invalid", + payment_method_type: "provider" + } + end + + it "fails" do + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + end + + context "with decimal value" do + let(:paid_credits) { "4.399999" } + + it "creates wallet transaction with rounded value" do + expect(result.wallet_transactions.first.credit_amount).to eq(4.40) + expect(result.wallet_transactions.first.amount).to eq(4.40) + end + end + + context "with decimal value and small rate amount" do + let(:paid_credits) { "4.399999" } + let(:rate_amount) { 0.01 } + + it "creates wallet transaction with rounded value" do + expect(result.wallet_transactions.first.credit_amount).to eq(4) + expect(result.wallet_transactions.first.amount).to eq(0.04) + end + end + + context "with decimal value and large rate amount" do + let(:paid_credits) { "4.3789" } + let(:rate_amount) { 100 } + + it "creates wallet transaction with rounded value" do + expect(result.wallet_transactions.first.credit_amount).to eq(4.3789) + expect(result.wallet_transactions.first.amount).to eq(437.89) + end + end + + context "with decimal value and currency without digits" do + let(:paid_credits) { "4.39999" } + let(:currency) { "JPY" } + + it "creates wallet transaction with rounded value" do + expect(result.wallet_transactions.first.credit_amount).to eq(4) + expect(result.wallet_transactions.first.amount).to eq(4) + end + end + + context "when invoice_custom_section param exists" do + let(:params) do + { + wallet_id: wallet.id, + paid_credits:, + invoice_custom_section: {invoice_custom_section_codes: ["section_code_1"]} + } + end + + before do + CurrentContext.source = "api" + create(:invoice_custom_section, organization:, code: "section_code_1") + end + + it "creates wallet transaction with invoice_custom_section" do + applied_sections = result.wallet_transactions.first.applied_invoice_custom_sections + expect(applied_sections.count).to eq(1) + expect(applied_sections.first.invoice_custom_section.code).to eq("section_code_1") + end + end + end +end diff --git a/spec/services/wallet_transactions/create_service_spec.rb b/spec/services/wallet_transactions/create_service_spec.rb new file mode 100644 index 0000000..19a9eb1 --- /dev/null +++ b/spec/services/wallet_transactions/create_service_spec.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::CreateService do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, currency:) } + let(:currency) { "EUR" } + let(:wallet_credit) { WalletCredit.new(wallet:, credit_amount:) } + + let(:wallet) do + create( + :wallet, + customer:, + currency:, + balance_cents: 1000, + credits_balance: 10.0, + ongoing_balance_cents: 1000, + credits_ongoing_balance: 10.0 + ) + end + + before do + wallet + end + + describe "#call" do + subject(:result) { described_class.call(wallet:, wallet_credit:, **transaction_params) } + + context "with minimum arguments" do + let(:credit_amount) { 100 } + + let(:transaction_params) do + { + status: :pending, + transaction_type: :inbound, + transaction_status: :purchased + } + end + + it "creates a wallet transaction" do + expect { subject }.to change(WalletTransaction, :count).by(1) + end + + it "sets default values" do + expect(result.wallet_transaction) + .to be_a(WalletTransaction) + .and be_persisted + .and have_attributes( + invoice_requires_successful_payment: false, + metadata: [], + priority: 50, + source: "manual" + ) + end + end + + context "with all arguments" do + let(:credit_amount) { 1000 } + let(:credit_note) { create(:credit_note) } + let(:invoice) { create(:invoice) } + let(:payment_method) { create(:payment_method, customer:, organization:) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + + let(:transaction_params) do + { + status: :pending, + transaction_type: :outbound, + transaction_status: :granted, + source: :threshold, + metadata: [{key: "value"}], + invoice_requires_successful_payment: true, + settled_at: Date.yesterday, + credit_note_id: credit_note.id, + invoice_id: invoice.id, + priority: 25, + name: "Custom Transaction Name", + payment_method: payment_method_params + } + end + + it "creates a wallet transaction" do + expect { subject }.to change(WalletTransaction, :count).by(1) + end + + it "sets all attributes" do + wallet_transaction = result.wallet_transaction + + expect(wallet_transaction.status).to eq("pending") + expect(wallet_transaction.transaction_type).to eq("outbound") + expect(wallet_transaction.transaction_status).to eq("granted") + expect(wallet_transaction.source).to eq("threshold") + expect(wallet_transaction.metadata).to eq([{"key" => "value"}]) + expect(wallet_transaction.invoice_requires_successful_payment).to be true + expect(wallet_transaction.settled_at).to eq(Date.yesterday) + expect(wallet_transaction.credit_note_id).to eq(credit_note.id) + expect(wallet_transaction.invoice_id).to eq(invoice.id) + expect(wallet_transaction.credit_amount).to eq(credit_amount) + expect(wallet_transaction.priority).to eq 25 + expect(wallet_transaction.name).to eq("Custom Transaction Name") + expect(wallet_transaction.payment_method_id).to eq(payment_method.id) + expect(wallet_transaction.payment_method_type).to eq("provider") + end + end + + context "with traceable wallet" do + let(:credit_amount) { 100 } + let(:wallet) { create(:wallet, customer:, currency:, traceable: true) } + + context "when transaction is inbound" do + context "when transaction is granted" do + let(:transaction_params) do + { + status: :settled, + transaction_type: :inbound, + transaction_status: :granted + } + end + + it "sets remaining_amount_cents to amount_cents" do + wallet_transaction = result.wallet_transaction + + expect(wallet_transaction.remaining_amount_cents).to eq(wallet_transaction.amount_cents) + end + end + + context "when transaction is purchased" do + let(:transaction_params) do + { + status: :settled, + transaction_type: :inbound, + transaction_status: :purchased + } + end + + it "does not set remaining_amount_cents" do + wallet_transaction = result.wallet_transaction + + expect(wallet_transaction.remaining_amount_cents).to be_nil + end + end + end + + context "when transaction is outbound" do + let(:transaction_params) do + { + status: :settled, + transaction_type: :outbound, + transaction_status: :invoiced + } + end + + it "does not set remaining_amount_cents" do + wallet_transaction = result.wallet_transaction + + expect(wallet_transaction.remaining_amount_cents).to be_nil + end + end + end + + context "with non-traceable wallet" do + let(:credit_amount) { 100 } + let(:wallet) { create(:wallet, customer:, currency:, traceable: false) } + + let(:transaction_params) do + { + status: :settled, + transaction_type: :inbound, + transaction_status: :purchased + } + end + + it "does not set remaining_amount_cents" do + wallet_transaction = result.wallet_transaction + + expect(wallet_transaction.remaining_amount_cents).to be_nil + end + end + end +end diff --git a/spec/services/wallet_transactions/mark_as_failed_service_spec.rb b/spec/services/wallet_transactions/mark_as_failed_service_spec.rb new file mode 100644 index 0000000..25e4f6f --- /dev/null +++ b/spec/services/wallet_transactions/mark_as_failed_service_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::MarkAsFailedService do + subject(:service) { described_class.new(wallet_transaction:) } + + let(:wallet_transaction) { create(:wallet_transaction, status: "pending") } + + describe ".call" do + context "when wallet_transaction is nil" do + let(:wallet_transaction) { nil } + + it "returns an empty result" do + result = service.call + expect(result.wallet_transaction).to be_nil + end + end + + context "when wallet_transaction is already failed" do + let(:wallet_transaction) { create(:wallet_transaction, status: "failed") } + + it "does not change the wallet_transaction status" do + expect { service.call }.not_to change(wallet_transaction, :status) + end + + it "does not enqueue a SendWebhookJob" do + expect { service.call }.not_to have_enqueued_job(SendWebhookJob) + end + + it "produces an activity log" do + described_class.call(wallet_transaction:) + + expect(Utils::ActivityLog).to have_produced("wallet_transaction.updated").after_commit.with(wallet_transaction) + end + end + + context "when wallet_transaction is not failed" do + it "updates the wallet_transaction status to failed" do + expect { + service.call + }.to change { wallet_transaction.reload.status }.from("pending").to("failed") + end + + it "enqueues a SendWebhookJob with appropriate arguments" do + expect { + service.call + }.to have_enqueued_job(SendWebhookJob).with("wallet_transaction.updated", wallet_transaction) + end + + context "when the wallet_transaction is settled" do + let(:wallet) { create(:wallet, credits_balance: 100, balance_cents: 100) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, status: "settled", amount: 100, credit_amount: 100) } + + before do + wallet_transaction + end + + it "does not do anything" do + expect { + service.call + }.not_to change(wallet_transaction, :status) + end + end + end + end +end diff --git a/spec/services/wallet_transactions/payments/generate_payment_url_service_spec.rb b/spec/services/wallet_transactions/payments/generate_payment_url_service_spec.rb new file mode 100644 index 0000000..46a29e8 --- /dev/null +++ b/spec/services/wallet_transactions/payments/generate_payment_url_service_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +RSpec.describe WalletTransactions::Payments::GeneratePaymentUrlService do + describe ".call" do + subject(:result) { described_class.call(wallet_transaction:) } + + context "when wallet transaction does not exist" do + let(:wallet_transaction) { nil } + + it "fails with wallet transaction not found error" do + expect(result).to be_failure + expect(result.error.error_code).to eq("wallet_transaction_not_found") + end + end + + context "when wallet transaction exists" do + let(:wallet_transaction) { build(:wallet_transaction, status:, transaction_status:) } + + context "when transactions status is purchased" do + let(:transaction_status) { "purchased" } + let(:status) { nil } + + context "when transaction is already settled" do + let(:status) { "settled" } + + it "fails with wallet transaction already settled error" do + expect(result).to be_failure + expect(result.error.messages).to match(base: ["wallet_transaction_already_settled"]) + end + end + + context "when transaction is not settled" do + let(:status) { WalletTransaction::STATUSES.excluding(:settled).sample } + + context "when transaction's invoice is missing" do + it "fails with no attached invoice error" do + expect(result).to be_failure + expect(result.error.messages).to match(base: ["wallet_transaction_has_no_attached_invoice"]) + end + end + + context "when transaction's invoice is present" do + let(:wallet_transaction) do + create(:wallet_transaction, :with_invoice, status:, transaction_status:, customer:) + end + + let(:customer) { create(:customer, :with_stripe_payment_provider) } + let(:checkout_url) { "https://example.com" } + + before do + allow(::Stripe::Checkout::Session).to receive(:create).and_return({"url" => checkout_url}) + + allow(Invoices::Payments::GeneratePaymentUrlService) + .to receive(:call).with(invoice: wallet_transaction.invoice).and_call_original + end + + it "calls Invoices::Payments::GeneratePaymentUrlService" do + subject + + expect(Invoices::Payments::GeneratePaymentUrlService) + .to have_received(:call) + .with(invoice: wallet_transaction.invoice) + + expect(result).to be_success + expect(result.payment_url).to eq checkout_url + end + end + end + end + + context "when transactions status is not purchased" do + let(:transaction_status) { WalletTransaction::TRANSACTION_STATUSES.excluding(:purchased).sample } + let(:status) { nil } + + it "fails with wallet transaction not purchased error" do + expect(result).to be_failure + expect(result.error.messages).to match(base: ["wallet_transaction_not_purchased"]) + end + end + end + end +end diff --git a/spec/services/wallet_transactions/recredit_service_spec.rb b/spec/services/wallet_transactions/recredit_service_spec.rb new file mode 100644 index 0000000..e9dbe5a --- /dev/null +++ b/spec/services/wallet_transactions/recredit_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::RecreditService do + subject(:service) { described_class.new(wallet_transaction:) } + + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + + context "when wallet is terminated" do + let(:wallet) { create(:wallet, :terminated) } + + it "returns a failure" do + result = service.call + + expect(result).not_to be_success + expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) + expect(result.error.message).to eq("wallet_not_active") + end + end + + context "when wallet is active" do + let(:wallet) { create(:wallet, consumed_credits: 1.0) } + + it "recredits the wallet" do + expect { service.call }.to change { wallet.reload.credits_balance }.from(0).to(1.0) + + expect(service.call).to be_success + end + + it "resets consumed credits of the wallet" do + expect { service.call }.to change { wallet.reload.consumed_credits }.from(1.0).to(0) + + expect(service.call).to be_success + end + + context "when wallet transaction has an invoice" do + let(:voided_invoice) { create(:invoice, :voided, organization: wallet.organization) } + let(:wallet_transaction) do + create(:wallet_transaction, + wallet:, + invoice: voided_invoice, + transaction_type: :outbound, + credit_amount: 5.0) + end + + it "creates an inbound transaction linked to the voided invoice with correct attributes" do + expect { service.call }.to change(WalletTransaction.inbound, :count).by(1) + + new_transaction = WalletTransaction.inbound.last + expect(new_transaction).to have_attributes( + voided_invoice_id: voided_invoice.id, + transaction_type: "inbound", + transaction_status: "granted", + credit_amount: wallet_transaction.credit_amount + ) + end + end + end +end diff --git a/spec/services/wallet_transactions/settle_service_spec.rb b/spec/services/wallet_transactions/settle_service_spec.rb new file mode 100644 index 0000000..2ad3c6e --- /dev/null +++ b/spec/services/wallet_transactions/settle_service_spec.rb @@ -0,0 +1,84 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::SettleService do + subject(:service) { described_class.new(wallet_transaction:) } + + let(:wallet_transaction) { create(:wallet_transaction, status: "pending", settled_at: nil) } + + describe ".call" do + it "updates wallet_transaction status" do + expect { + service.call + }.to change { wallet_transaction.reload.status }.from("pending").to("settled") + .and change(wallet_transaction, :settled_at).from(nil) + end + + it "enqueues a SendWebhookJob for each wallet transaction" do + expect do + service.call + end.to have_enqueued_job(SendWebhookJob).with("wallet_transaction.updated", WalletTransaction) + end + + it "produces an activity log" do + described_class.call(wallet_transaction:) + + expect(Utils::ActivityLog).to have_produced("wallet_transaction.updated").after_commit.with(wallet_transaction) + end + + context "with inbound transaction on traceable wallet" do + let(:customer) { create(:customer) } + let(:wallet) { create(:wallet, customer:, traceable: true) } + let(:wallet_transaction) do + create(:wallet_transaction, + wallet:, + status: "pending", + transaction_type: :inbound, + settled_at: nil, + remaining_amount_cents: nil) + end + + it "sets remaining_amount_cents to amount_cents" do + service.call + + expect(wallet_transaction.reload.remaining_amount_cents).to eq(wallet_transaction.amount_cents) + end + end + + context "with outbound transaction" do + let(:wallet_transaction) do + create(:wallet_transaction, + status: "pending", + transaction_type: :outbound, + settled_at: nil, + remaining_amount_cents: nil) + end + + it "does not set remaining_amount_cents" do + service.call + + expect(wallet_transaction.reload.remaining_amount_cents).to be_nil + end + end + + context "with inbound transaction on non-traceable wallet" do + let(:customer) { create(:customer) } + let(:wallet) { create(:wallet, customer:, traceable: false) } + let(:wallet_transaction) do + create(:wallet_transaction, + wallet:, + status: "pending", + transaction_type: :inbound, + settled_at: nil, + remaining_amount_cents: nil) + end + + it "does not set remaining_amount_cents" do + service.call + + expect(wallet_transaction.reload.remaining_amount_cents).to be_nil + end + end + end +end diff --git a/spec/services/wallet_transactions/track_consumption_service_spec.rb b/spec/services/wallet_transactions/track_consumption_service_spec.rb new file mode 100644 index 0000000..875605b --- /dev/null +++ b/spec/services/wallet_transactions/track_consumption_service_spec.rb @@ -0,0 +1,507 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::TrackConsumptionService do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, organization:, balance_cents: 10000, credits_balance: 100.0) } + + describe "#call" do + subject(:result) { described_class.call(outbound_wallet_transaction:) } + + context "when consuming by priority" do + let!(:inbound_granted) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 30, + credit_amount: 30, + remaining_amount_cents: 3000, + priority: 10) + end + + let!(:inbound_purchased) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 70, + credit_amount: 70, + remaining_amount_cents: 7000, + priority: 10) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :invoiced, + status: :settled, + amount: 50, + credit_amount: 50) + end + + it "creates consumption records following granted-first priority" do + expect { result }.to change(WalletTransactionConsumption, :count).by(2) + end + + it "consumes granted credits first" do + result + + consumptions = outbound_wallet_transaction.fundings.order(:consumed_amount_cents) + expect(consumptions.first.inbound_wallet_transaction).to eq(inbound_purchased) + expect(consumptions.first.consumed_amount_cents).to eq(2000) + + expect(consumptions.second.inbound_wallet_transaction).to eq(inbound_granted) + expect(consumptions.second.consumed_amount_cents).to eq(3000) + end + + it "decrements remaining_amount_cents on inbound transactions" do + result + + expect(inbound_granted.reload.remaining_amount_cents).to eq(0) + expect(inbound_purchased.reload.remaining_amount_cents).to eq(5000) + end + + it "returns a success result" do + expect(result).to be_success + end + end + + context "when outbound amount exceeds available inbound amount" do + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 30, + credit_amount: 30, + remaining_amount_cents: 3000) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :invoiced, + status: :settled, + amount: 50, + credit_amount: 50) + end + + before do + inbound_transaction + end + + it "does not create consumption records" do + expect { result }.not_to change(WalletTransactionConsumption, :count) + end + + it "returns a failure result" do + expect(result).to be_failure + expect(result.error.messages[:amount_cents]).to eq(["exceeds_available_amount"]) + end + end + + context "with multiple inbound transactions of different priorities" do + let!(:inbound_low_priority) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 50, + credit_amount: 50, + remaining_amount_cents: 5000, + priority: 20, + created_at: 2.days.ago) + end + + let!(:inbound_high_priority) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 50, + credit_amount: 50, + remaining_amount_cents: 5000, + priority: 10, + created_at: 1.day.ago) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :invoiced, + status: :settled, + amount: 60, + credit_amount: 60) + end + + it "consumes from higher priority (lower number) first" do + result + + expect(inbound_high_priority.reload.remaining_amount_cents).to eq(0) + expect(inbound_low_priority.reload.remaining_amount_cents).to eq(4000) + end + end + + context "with inbound transactions of same priority but different created_at" do + let!(:inbound_older) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 50, + credit_amount: 50, + remaining_amount_cents: 5000, + priority: 10, + created_at: 2.days.ago) + end + + let!(:inbound_newer) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 50, + credit_amount: 50, + remaining_amount_cents: 5000, + priority: 10, + created_at: 1.day.ago) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :invoiced, + status: :settled, + amount: 60, + credit_amount: 60) + end + + it "consumes from older transactions first (FIFO)" do + result + + expect(inbound_older.reload.remaining_amount_cents).to eq(0) + expect(inbound_newer.reload.remaining_amount_cents).to eq(4000) + end + end + + context "when no inbound transactions with remaining balance exist" do + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :invoiced, + status: :settled, + amount: 50, + credit_amount: 50) + end + + it "returns a failure result" do + expect(result).to be_failure + expect(result.error.messages[:amount_cents]).to eq(["exceeds_available_amount"]) + end + end + + context "with full ordering: priority, then granted/purchased, then FIFO" do + # Order should be: + # 1. TX1: priority 1, granted (highest priority) + # 2. TX2: priority 1, purchased (same priority as TX1, but purchased after granted) + # 3. TX3: priority 2, granted, older (lower priority, but granted before purchased) + # 4. TX4: priority 2, granted, newer (same as TX3 but newer - FIFO) + # 5. TX5: priority 2, purchased (same priority as TX3/TX4, but purchased after granted) + + let!(:tx1_prio1_granted) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 1, + created_at: 5.days.ago) + end + + let!(:tx2_prio1_purchased) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 1, + created_at: 4.days.ago) + end + + let!(:tx3_prio2_granted_older) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 2, + created_at: 3.days.ago) + end + + let!(:tx4_prio2_granted_newer) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 2, + created_at: 2.days.ago) + end + + let!(:tx5_prio2_purchased) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 2, + created_at: 1.day.ago) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :invoiced, + status: :settled, + amount: 35, + credit_amount: 35) + end + + it "consumes in order: priority, then granted before purchased, then FIFO" do + result + + # TX1 (prio 1, granted): fully consumed + expect(tx1_prio1_granted.reload.remaining_amount_cents).to eq(0) + # TX2 (prio 1, purchased): fully consumed + expect(tx2_prio1_purchased.reload.remaining_amount_cents).to eq(0) + # TX3 (prio 2, granted, older): fully consumed + expect(tx3_prio2_granted_older.reload.remaining_amount_cents).to eq(0) + # TX4 (prio 2, granted, newer): partially consumed (5 remaining) + expect(tx4_prio2_granted_newer.reload.remaining_amount_cents).to eq(500) + # TX5 (prio 2, purchased): not consumed yet + expect(tx5_prio2_purchased.reload.remaining_amount_cents).to eq(1000) + end + + it "creates consumption records for the correct inbound transactions" do + result + + consumed_transactions = outbound_wallet_transaction.fundings.map(&:inbound_wallet_transaction) + + expect(consumed_transactions).to contain_exactly( + tx1_prio1_granted, + tx2_prio1_purchased, + tx3_prio2_granted_older, + tx4_prio2_granted_newer + ) + # TX5 should NOT be consumed + expect(consumed_transactions).not_to include(tx5_prio2_purchased) + end + end + + context "when outbound amount is zero" do + let!(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 100, + credit_amount: 100, + remaining_amount_cents: 10000) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :invoiced, + status: :settled, + amount: 0, + credit_amount: 0) + end + + it "does not create any consumption records" do + expect { result }.not_to change(WalletTransactionConsumption, :count) + end + + it "does not decrement inbound remaining_amount_cents" do + result + + expect(inbound_transaction.reload.remaining_amount_cents).to eq(10000) + end + + it "returns a success result" do + expect(result).to be_success + end + end + + context "when consuming from specific inbound transaction" do + subject(:result) do + described_class.call( + outbound_wallet_transaction:, + inbound_wallet_transaction_id: specific_inbound.id + ) + end + + let!(:specific_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 50, + credit_amount: 50, + remaining_amount_cents: 5000, + priority: 50) + end + + let!(:other_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 100, + credit_amount: 100, + remaining_amount_cents: 10000, + priority: 1) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :voided, + status: :settled, + amount: 30, + credit_amount: 30) + end + + it "consumes from the specific inbound transaction ignoring priority" do + result + + expect(specific_inbound.reload.remaining_amount_cents).to eq(2000) + expect(other_inbound.reload.remaining_amount_cents).to eq(10000) + end + + it "creates a single consumption record" do + expect { result }.to change(WalletTransactionConsumption, :count).by(1) + + consumption = WalletTransactionConsumption.last + expect(consumption.inbound_wallet_transaction).to eq(specific_inbound) + expect(consumption.outbound_wallet_transaction).to eq(outbound_wallet_transaction) + expect(consumption.consumed_amount_cents).to eq(3000) + end + + it "returns a success result" do + expect(result).to be_success + end + + context "when outbound amount exceeds specific inbound remaining amount" do + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :voided, + status: :settled, + amount: 60, + credit_amount: 60) + end + + it "does not create consumption records" do + expect { result }.not_to change(WalletTransactionConsumption, :count) + end + + it "returns a failure result with specific error" do + expect(result).to be_failure + expect(result.error.messages[:amount_cents]).to eq(["exceeds_remaining_transaction_amount"]) + end + end + + context "when specific inbound has zero remaining amount" do + let!(:specific_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 50, + credit_amount: 50, + remaining_amount_cents: 0) + end + + let(:outbound_wallet_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :outbound, + transaction_status: :voided, + status: :settled, + amount: 10, + credit_amount: 10) + end + + it "returns a failure result" do + expect(result).to be_failure + expect(result.error.messages[:amount_cents]).to eq(["exceeds_remaining_transaction_amount"]) + end + end + end + end +end diff --git a/spec/services/wallet_transactions/validate_service_spec.rb b/spec/services/wallet_transactions/validate_service_spec.rb new file mode 100644 index 0000000..6de5460 --- /dev/null +++ b/spec/services/wallet_transactions/validate_service_spec.rb @@ -0,0 +1,289 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:) } + let(:wallet_id) { wallet.id } + let(:paid_credits) { "1.00" } + let(:granted_credits) { "0.00" } + let(:voided_credits) { "0.00" } + let(:args) do + { + wallet_id:, + customer_id: customer.external_id, + organization_id: organization.id, + paid_credits:, + granted_credits:, + voided_credits:, + **((name == :undefined) ? {} : {name:}) + } + end + let(:name) { :undefined } + + before { subscription } + + describe ".valid?" do + it "returns true" do + expect(validate_service).to be_valid + end + + context "when wallet does not exists" do + let(:wallet_id) { "123456" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:wallet_id]).to eq(["wallet_not_found"]) + end + end + + context "when customer is provided" do + let(:args) do + { + wallet_id:, + customer:, + organization:, + paid_credits:, + granted_credits:, + voided_credits: + } + end + + it "returns true when wallet belongs to the customer" do + expect(validate_service).to be_valid + end + + context "when wallet belongs to another customer" do + let(:other_customer) { create(:customer, organization:) } + let(:other_wallet) { create(:wallet, customer: other_customer) } + let(:wallet_id) { other_wallet.id } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:wallet_id]).to eq(["wallet_not_found"]) + end + end + end + + context "with invalid paid_credits" do + let(:paid_credits) { "foobar" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:paid_credits]).to eq(["invalid_paid_credits", "invalid_amount"]) + end + end + + context "with invalid granted_credits" do + let(:granted_credits) { "foobar" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:granted_credits]).to eq(["invalid_granted_credits", "invalid_amount"]) + end + end + + [ + :voided_credits, + :granted_credits, + :paid_credits + ].each do |attr| + context "with #{attr} >= 10^25" do + let(attr) { 10**25 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[attr]).to eq(["invalid_#{attr}", "invalid_amount"]) + end + end + + context "with #{attr} < 0" do + let(attr) { "-1.00" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[attr]).to eq(["invalid_#{attr}", "invalid_amount"]) + end + end + + context "with #{attr} < 10^25" do + let(attr) { (10**25 - 1).to_s } + let(:wallet) { create(:wallet, customer:, credits_balance: 10**25 - 1) } + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "with #{attr} = 0" do + let(attr) { "0.00" } + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + end + + context "with invalid voided_credits" do + let(:voided_credits) { "foobar" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:voided_credits]).to eq(["invalid_voided_credits", "invalid_amount"]) + end + end + + context "with valid voided_credits but insufficient credits" do + let(:voided_credits) { "1.00" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:voided_credits]).to eq(["insufficient_credits"]) + end + end + + context "with invalid metadata" do + let(:args) do + { + wallet_id:, + customer_id: customer.external_id, + organization_id: organization.id, + paid_credits:, + granted_credits:, + voided_credits:, + metadata: [{"key" => "key", "value" => {"key" => "nested_value"}}] + } + end + + it "returns false and result has errors for metadata" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:metadata]).to eq(["nested_structure_not_allowed"]) + end + end + + context "with valid name" do + let(:name) { "Valid Transaction Name" } + + it { is_expected.to be_valid } + end + + context "with blank name" do + let(:name) { "" } + + it { is_expected.to be_valid } + end + + context "with nil name" do + let(:name) { nil } + + it { is_expected.to be_valid } + end + + context "with name at maximum length" do + let(:name) { "a" * 255 } + + it { is_expected.to be_valid } + end + + context "with name that is too long" do + let(:name) { "a" * 256 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:name]).to eq(["too_long"]) + end + end + + context "with name that is not a string" do + let(:name) { 123 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:name]).to eq(["invalid_value"]) + end + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, customer:, organization:) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + let(:args) do + { + wallet_id:, + customer_id: customer.external_id, + organization_id: organization.id, + paid_credits:, + granted_credits:, + voided_credits:, + payment_method: payment_method_params, + **((name == :undefined) ? {} : {name:}) + } + end + + context "when provider payment method is valid" do + before do + result.payment_method = payment_method + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "when manual payment method is valid" do + let(:payment_method_params) do + { + payment_method_type: "manual" + } + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "with invalid payment method type" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "with invalid payment method reference" do + let(:payment_method_params) do + { + payment_method_id: "invalid", + payment_method_type: "provider" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + end +end diff --git a/spec/services/wallet_transactions/void_service_spec.rb b/spec/services/wallet_transactions/void_service_spec.rb new file mode 100644 index 0000000..b84da0b --- /dev/null +++ b/spec/services/wallet_transactions/void_service_spec.rb @@ -0,0 +1,768 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WalletTransactions::VoidService do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 1000, + credits_balance: 10.0, + ongoing_balance_cents: 1000, + credits_ongoing_balance: 10.0, + traceable: false + ) + end + let(:credit_amount) { BigDecimal("10.00") } + let(:wallet_credit) { WalletCredit.new(wallet:, credit_amount:) } + + before do + subscription + end + + describe "#call" do + subject(:result) { described_class.call(wallet:, wallet_credit:, **args) } + + let(:args) { {} } + + context "when credits amount is zero" do + let(:credit_amount) { BigDecimal("0.00") } + + it "does not create a wallet transaction" do + expect { subject }.not_to change(WalletTransaction, :count) + end + end + + context "with minimum arguments" do + it "creates a wallet transaction" do + expect { subject }.to change(WalletTransaction, :count).by(1) + end + + it "sets default values" do + freeze_time do + expect(result.wallet_transaction) + .to be_a(WalletTransaction) + .and be_persisted + .and have_attributes( + amount: 10, + credit_amount: 10, + transaction_type: "outbound", + status: "settled", + transaction_status: "voided", + settled_at: Time.current, + source: "manual", + metadata: [], + priority: 50, + credit_note_id: nil, + name: nil + ) + end + end + + it "updates wallet balance" do + wallet = result.wallet_transaction.wallet + + expect(wallet.balance_cents).to eq(0) + expect(wallet.credits_balance).to eq(0.0) + end + end + + context "with all arguments" do + let(:metadata) { [{"key" => "valid_value", "value" => "also_valid"}] } + let(:credit_note_id) { create(:credit_note, organization:).id } + + let(:args) do + { + metadata:, + credit_note_id:, + source: :threshold, + priority: 25, + name: "Void Transaction" + } + end + + it "creates a wallet transaction" do + expect { subject }.to change(WalletTransaction, :count).by(1) + end + + it "sets all attributes" do + freeze_time do + expect(result.wallet_transaction) + .to be_a(WalletTransaction) + .and be_persisted + .and have_attributes( + amount: 10, + credit_amount: 10, + transaction_type: "outbound", + status: "settled", + transaction_status: "voided", + settled_at: Time.current, + metadata:, + credit_note_id:, + source: "threshold", + priority: 25, + name: "Void Transaction" + ) + end + end + + it "updates wallet balance" do + wallet = result.wallet_transaction.wallet + + expect(wallet.balance_cents).to eq(0) + expect(wallet.credits_balance).to eq(0.0) + end + end + + context "with nil name" do + let(:args) { {name: nil} } + + it "creates a wallet transaction with nil name" do + expect(result.wallet_transaction.name).to be_nil + end + end + + context "when wallet is traceable" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 1000, + credits_balance: 10.0, + ongoing_balance_cents: 1000, + credits_ongoing_balance: 10.0, + traceable: true + ) + end + + let(:inbound_transaction) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000) + end + + before do + inbound_transaction + end + + it "creates wallet transaction consumption records" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(1) + + expect(WalletTransactionConsumption.last).to have_attributes( + inbound_wallet_transaction_id: inbound_transaction.id, + outbound_wallet_transaction_id: result.wallet_transaction.id, + consumed_amount_cents: 1000 + ) + + expect(inbound_transaction.reload.remaining_amount_cents).to eq(0) + end + + context "with multiple inbound transactions" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 3000, + credits_balance: 30.0, + ongoing_balance_cents: 3000, + credits_ongoing_balance: 30.0, + traceable: true + ) + end + + let(:credit_amount) { BigDecimal("25.00") } + + let!(:inbound_transaction_1) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 50, + created_at: 3.days.ago) + end + + let!(:inbound_transaction_2) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 50, + created_at: 2.days.ago) + end + + let!(:inbound_transaction_3) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 50, + created_at: 1.day.ago) + end + + let(:inbound_transaction) { nil } + + it "consumes from multiple inbounds in order" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(3) + + consumptions = WalletTransactionConsumption.order(:created_at) + + expect(consumptions[0]).to have_attributes( + inbound_wallet_transaction_id: inbound_transaction_1.id, + consumed_amount_cents: 1000 + ) + expect(consumptions[1]).to have_attributes( + inbound_wallet_transaction_id: inbound_transaction_2.id, + consumed_amount_cents: 1000 + ) + expect(consumptions[2]).to have_attributes( + inbound_wallet_transaction_id: inbound_transaction_3.id, + consumed_amount_cents: 500 + ) + + expect(inbound_transaction_1.reload.remaining_amount_cents).to eq(0) + expect(inbound_transaction_2.reload.remaining_amount_cents).to eq(0) + expect(inbound_transaction_3.reload.remaining_amount_cents).to eq(500) + end + end + + context "with partially consumed inbound transaction" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 500, + credits_balance: 5.0, + ongoing_balance_cents: 500, + credits_ongoing_balance: 5.0, + traceable: true + ) + end + + let(:credit_amount) { BigDecimal("5.00") } + + let!(:partially_consumed_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 500, + priority: 50) + end + + let(:inbound_transaction) { nil } + + it "consumes from the remaining amount only" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(1) + + expect(WalletTransactionConsumption.last).to have_attributes( + inbound_wallet_transaction_id: partially_consumed_inbound.id, + consumed_amount_cents: 500 + ) + + expect(partially_consumed_inbound.reload.remaining_amount_cents).to eq(0) + end + end + + context "with fully consumed inbound transaction" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 1000, + credits_balance: 10.0, + ongoing_balance_cents: 1000, + credits_ongoing_balance: 10.0, + traceable: true + ) + end + + let!(:fully_consumed_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 0, + priority: 50, + created_at: 2.days.ago) + end + + let!(:available_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 50, + created_at: 1.day.ago) + end + + let(:inbound_transaction) { nil } + + it "skips fully consumed inbound and consumes from available one" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(1) + + expect(WalletTransactionConsumption.last).to have_attributes( + inbound_wallet_transaction_id: available_inbound.id, + consumed_amount_cents: 1000 + ) + + expect(fully_consumed_inbound.reload.remaining_amount_cents).to eq(0) + expect(available_inbound.reload.remaining_amount_cents).to eq(0) + end + end + + context "with priority rules" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 8000, + credits_balance: 80.0, + ongoing_balance_cents: 8000, + credits_ongoing_balance: 80.0, + traceable: true + ) + end + + let(:credit_amount) { BigDecimal("80.00") } + let(:inbound_transaction) { nil } + + context "when granted credits have higher priority than purchased" do + let!(:purchased_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 30, + credit_amount: 30, + remaining_amount_cents: 3000, + priority: 50, + created_at: 3.days.ago) + end + + let!(:granted_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 50, + credit_amount: 50, + remaining_amount_cents: 5000, + priority: 50, + created_at: 1.day.ago) + end + + it "consumes granted credits before purchased credits at same priority" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(2) + + consumptions = WalletTransactionConsumption.order(:created_at) + + expect(consumptions[0]).to have_attributes( + inbound_wallet_transaction_id: granted_inbound.id, + consumed_amount_cents: 5000 + ) + expect(consumptions[1]).to have_attributes( + inbound_wallet_transaction_id: purchased_inbound.id, + consumed_amount_cents: 3000 + ) + end + end + + context "when lower priority number takes precedence" do + let!(:low_priority_purchased) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 20, + credit_amount: 20, + remaining_amount_cents: 2000, + priority: 1, + created_at: 3.days.ago) + end + + let!(:high_priority_granted) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 60, + credit_amount: 60, + remaining_amount_cents: 6000, + priority: 50, + created_at: 1.day.ago) + end + + it "consumes lower priority number first regardless of transaction status" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(2) + + consumptions = WalletTransactionConsumption.order(:created_at) + + expect(consumptions[0]).to have_attributes( + inbound_wallet_transaction_id: low_priority_purchased.id, + consumed_amount_cents: 2000 + ) + expect(consumptions[1]).to have_attributes( + inbound_wallet_transaction_id: high_priority_granted.id, + consumed_amount_cents: 6000 + ) + end + end + + context "when same priority and same transaction status" do + let!(:older_granted) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 25, + credit_amount: 25, + remaining_amount_cents: 2500, + priority: 2, + created_at: 3.days.ago) + end + + let!(:newer_granted) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 25, + credit_amount: 25, + remaining_amount_cents: 2500, + priority: 2, + created_at: 1.day.ago) + end + + let!(:purchased) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 30, + credit_amount: 30, + remaining_amount_cents: 3000, + priority: 2, + created_at: 2.days.ago) + end + + it "consumes older transactions first within same priority and status" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(3) + + consumptions = WalletTransactionConsumption.order(:created_at) + + expect(consumptions[0]).to have_attributes( + inbound_wallet_transaction_id: older_granted.id, + consumed_amount_cents: 2500 + ) + expect(consumptions[1]).to have_attributes( + inbound_wallet_transaction_id: newer_granted.id, + consumed_amount_cents: 2500 + ) + expect(consumptions[2]).to have_attributes( + inbound_wallet_transaction_id: purchased.id, + consumed_amount_cents: 3000 + ) + end + end + + context "with complex priority scenario from spec" do + # Customer has: $20 granted (priority 1), $25 granted (priority 2 created 3 days ago), + # $25 granted (priority 2 created 1 day ago), $30 purchased (priority 2). + # Then consumes $80 + + let!(:tx1_granted_prio1) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 20, + credit_amount: 20, + remaining_amount_cents: 2000, + priority: 1, + created_at: 4.days.ago) + end + + let!(:tx2_granted_prio2_oldest) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 25, + credit_amount: 25, + remaining_amount_cents: 2500, + priority: 2, + created_at: 3.days.ago) + end + + let!(:tx3_granted_prio2_newer) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 25, + credit_amount: 25, + remaining_amount_cents: 2500, + priority: 2, + created_at: 1.day.ago) + end + + let!(:tx4_purchased_prio2) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 30, + credit_amount: 30, + remaining_amount_cents: 3000, + priority: 2, + created_at: 2.days.ago) + end + + it "consumes in correct order: prio1 -> prio2 granted oldest -> prio2 granted newer -> prio2 purchased" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(4) + + consumptions = WalletTransactionConsumption.order(:created_at) + + expect(consumptions[0]).to have_attributes( + inbound_wallet_transaction_id: tx1_granted_prio1.id, + consumed_amount_cents: 2000 + ) + expect(consumptions[1]).to have_attributes( + inbound_wallet_transaction_id: tx2_granted_prio2_oldest.id, + consumed_amount_cents: 2500 + ) + expect(consumptions[2]).to have_attributes( + inbound_wallet_transaction_id: tx3_granted_prio2_newer.id, + consumed_amount_cents: 2500 + ) + expect(consumptions[3]).to have_attributes( + inbound_wallet_transaction_id: tx4_purchased_prio2.id, + consumed_amount_cents: 1000 + ) + + expect(tx1_granted_prio1.reload.remaining_amount_cents).to eq(0) + expect(tx2_granted_prio2_oldest.reload.remaining_amount_cents).to eq(0) + expect(tx3_granted_prio2_newer.reload.remaining_amount_cents).to eq(0) + expect(tx4_purchased_prio2.reload.remaining_amount_cents).to eq(2000) + end + end + end + + context "when void amount exceeds available balance" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 500, + credits_balance: 5.0, + ongoing_balance_cents: 500, + credits_ongoing_balance: 5.0, + traceable: true + ) + end + + let(:credit_amount) { BigDecimal("10.00") } + + let(:limited_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 5, + credit_amount: 5, + remaining_amount_cents: 500, + priority: 50) + end + + let(:inbound_transaction) { nil } + + before { limited_inbound } + + it "raises a validation error and does not create consumption records" do + expect { subject }.to raise_error(BaseService::ValidationFailure).and not_change(WalletTransactionConsumption, :count) + end + end + end + + context "when wallet is not traceable" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 1000, + credits_balance: 10.0, + ongoing_balance_cents: 1000, + credits_ongoing_balance: 10.0, + traceable: false + ) + end + + it "does not create wallet transaction consumption records" do + expect { subject }.not_to change(WalletTransactionConsumption, :count) + end + end + + context "when there is a concurrent lock" do + before do + stub_const("Customers::LockService::ACQUIRE_LOCK_TIMEOUT", 1.second) + end + + around do |test| + with_advisory_lock("customer-#{customer.id}-prepaid_credit", lock_released_after:) do + test.run + end + end + + context "when it fails to acquire the lock" do + let(:lock_released_after) { 2.seconds } + + it "raises a Customers::FailedToAcquireLock error" do + expect { subject }.to raise_error(Customers::FailedToAcquireLock) + end + end + + context "when the lock is acquired" do + let(:lock_released_after) { 0.6.seconds } + + it "voids the wallet transaction successfully" do + expect { subject }.not_to raise_error + end + end + end + + context "with inbound_wallet_transaction parameter" do + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 3000, + credits_balance: 30.0, + ongoing_balance_cents: 3000, + credits_ongoing_balance: 30.0, + traceable: true + ) + end + + let(:credit_amount) { BigDecimal("10.00") } + + let!(:specific_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :purchased, + status: :settled, + amount: 20, + credit_amount: 20, + remaining_amount_cents: 2000, + priority: 50) + end + + let!(:higher_priority_inbound) do + create(:wallet_transaction, + wallet:, + organization:, + transaction_type: :inbound, + transaction_status: :granted, + status: :settled, + amount: 10, + credit_amount: 10, + remaining_amount_cents: 1000, + priority: 1) + end + + let(:args) { {inbound_wallet_transaction: specific_inbound} } + + it "consumes from the specific inbound transaction" do + expect { subject }.to change(WalletTransactionConsumption, :count).by(1) + + consumption = WalletTransactionConsumption.last + expect(consumption.inbound_wallet_transaction).to eq(specific_inbound) + expect(consumption.consumed_amount_cents).to eq(1000) + + expect(specific_inbound.reload.remaining_amount_cents).to eq(1000) + expect(higher_priority_inbound.reload.remaining_amount_cents).to eq(1000) + end + + context "when void amount exceeds specific inbound remaining amount" do + let(:credit_amount) { BigDecimal("25.00") } + + it "returns a validation failure" do + expect(subject).not_to be_success + expect(subject.error).to be_a(BaseService::ValidationFailure) + expect(subject.error.messages[:amount_cents]).to eq(["exceeds_remaining_transaction_amount"]) + end + + it "does not create a wallet transaction" do + expect { subject }.not_to change(WalletTransaction, :count) + end + + it "does not affect wallet balance" do + expect { subject }.not_to change { wallet.reload.balance_cents } + end + end + end + end +end diff --git a/spec/services/wallets/apply_paid_credits_service_spec.rb b/spec/services/wallets/apply_paid_credits_service_spec.rb new file mode 100644 index 0000000..c7aebd8 --- /dev/null +++ b/spec/services/wallets/apply_paid_credits_service_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::ApplyPaidCreditsService do + subject(:service) { described_class.new(wallet_transaction:) } + + describe ".call" do + let(:wallet) { create(:wallet, balance_cents: 1000, credits_balance: 10.0) } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, amount: 15.0, credit_amount: 15.0, status: "pending") + end + + it "updates wallet balance" do + service.call + + expect(wallet.reload.balance_cents).to eq 2500 + end + + it "settles the wallet transaction" do + result = service.call + + expect(result.wallet_transaction.status).to eq("settled") + end + end +end diff --git a/spec/services/wallets/balance/decrease_service_spec.rb b/spec/services/wallets/balance/decrease_service_spec.rb new file mode 100644 index 0000000..03c941d --- /dev/null +++ b/spec/services/wallets/balance/decrease_service_spec.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::Balance::DecreaseService do + let(:wallet) do + create( + :wallet, + balance_cents: 1000, + ongoing_balance_cents: 800, + credits_balance: 10.0, + credits_ongoing_balance: 8.0 + ) + end + + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, amount: "4.5", credit_amount: BigDecimal("4.5")) + end + + before do + wallet + wallet_transaction + allow(Customers::RefreshWalletsService).to receive(:call).and_call_original + end + + describe ".call" do + subject(:result) { described_class.call!(wallet:, wallet_transaction:, skip_refresh:) } + + let(:skip_refresh) { false } + + it "updates wallet balance" do + expect { subject } + .to change(wallet.reload, :balance_cents).from(1000).to(550) + .and change(wallet, :credits_balance).from(10.0).to(5.5) + end + + it "updates wallet consumed status" do + expect { subject } + .to change(wallet.reload, :consumed_credits).from(0).to(4.5) + .and change(wallet, :consumed_amount_cents).from(0).to(450) + end + + it "refreshes wallet ongoing balance" do + expect { subject } + .to change { wallet.reload.ongoing_balance_cents }.from(800).to(550) + .and change { wallet.reload.credits_ongoing_balance }.from(8.0).to(5.5) + end + + it "sends a `wallet.updated` webhook" do + expect { subject }.to have_enqueued_job(SendWebhookJob).with("wallet.updated", Wallet) + end + + it "enqueues a ProcessWalletAlertsJob" do + expect { subject }.to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob).at_least(:once) + end + + it "calls Customers::RefreshWalletsService" do + subject + expect(Customers::RefreshWalletsService).to have_received(:call).with(customer: wallet.customer, include_generating_invoices: true) + end + + context "when skip refresh flag is set" do + let(:skip_refresh) { true } + + it "does not call Customers::RefreshWalletsService" do + subject + expect(Customers::RefreshWalletsService).not_to have_received(:call) + end + end + + context "when wallet is stale" do + it "retries the update on stale object" do + # Create a stale version by loading the same wallet twice + stale_wallet = Wallet.find(wallet.id) + current_wallet = Wallet.find(wallet.id) + + # Update the current wallet to make stale_wallet outdated + current_wallet.update!(credits_balance: 15.0) + + # Create service with stale wallet + service = described_class.new(wallet: stale_wallet, wallet_transaction:) + + # Should succeed despite the stale wallet + expect { service.call } + .to change { stale_wallet.reload.credits_balance }.from(15.0).to(10.5) + .and change { stale_wallet.consumed_credits }.from(0).to(4.5) + end + end + end +end diff --git a/spec/services/wallets/balance/increase_service_spec.rb b/spec/services/wallets/balance/increase_service_spec.rb new file mode 100644 index 0000000..44a410c --- /dev/null +++ b/spec/services/wallets/balance/increase_service_spec.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::Balance::IncreaseService do + subject(:create_service) { described_class.new(wallet:, wallet_transaction:) } + + let(:credits_amount) { BigDecimal("4.5") } + let(:wallet) do + create( + :wallet, + balance_cents: 1000, + credits_balance: 10.0, + ongoing_balance_cents: 800, + credits_ongoing_balance: 8.0, + consumed_credits: 1.0, + consumed_amount_cents: 100 + ) + end + + let(:wallet_credit) { WalletCredit.new(wallet:, credit_amount: credits_amount) } + let(:credit_amount) { wallet_credit.credit_amount } + let(:amount) { wallet_credit.amount } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, credit_amount:, amount:) } + + before { wallet } + + def call_and_reload_wallet + create_service.call + wallet.reload + end + + describe ".call" do + it "updates wallet balance" do + call_and_reload_wallet + + expect(wallet.balance_cents).to eq(1450) + expect(wallet.credits_balance).to eq(14.5) + end + + it "refreshes wallet ongoing balance" do + call_and_reload_wallet + + expect(wallet.ongoing_balance_cents).to eq(1450) + expect(wallet.credits_ongoing_balance).to eq(14.5) + end + + it "sends a `wallet.updated` webhook" do + expect { create_service.call } + .to have_enqueued_job(SendWebhookJob).with("wallet.updated", Wallet) + end + + it "enqueues a ProcessWalletAlertsJob" do + expect { create_service.call } + .to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob).at_least(:once) + end + + context "with rounding" do + let(:wallet_credit) { WalletCredit.new(wallet:, credit_amount: credits_amount, invoiceable: false) } + let(:credits_amount) { BigDecimal("17.96999") } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, credit_amount: wallet_credit.credit_amount, amount: wallet_credit.amount) } + + it "updates wallet balance" do + expect(wallet_credit.amount).to eq(17.97) + + call_and_reload_wallet + + expect(wallet.balance.to_d).to eq(27.97) + expect(wallet.credits_balance).to eq(0.2796999e2) + end + end + + context "when reset_consumed_credits is true" do + subject(:create_service) { described_class.new(wallet:, wallet_transaction:, reset_consumed_credits: true) } + + let!(:wallet_transaction) { create(:wallet_transaction, wallet:, amount: 0.5, credit_amount: 0.5) } + + it "resets consumed credits" do + call_and_reload_wallet + expect(wallet.consumed_credits).to eq(0.5) + expect(wallet.consumed_amount_cents).to eq(50) + end + + context "when the consumed credits are greater than the credits amount" do + let(:wallet_transaction) { create(:wallet_transaction, wallet:, amount: 2.0, credit_amount: 2.0) } + + it "resets consumed credits" do + call_and_reload_wallet + + expect(wallet.consumed_credits).to eq(0) + expect(wallet.consumed_amount_cents).to eq(0) + end + end + end + end +end diff --git a/spec/services/wallets/balance/refresh_ongoing_usage_service_spec.rb b/spec/services/wallets/balance/refresh_ongoing_usage_service_spec.rb new file mode 100644 index 0000000..5f35f88 --- /dev/null +++ b/spec/services/wallets/balance/refresh_ongoing_usage_service_spec.rb @@ -0,0 +1,224 @@ +# frozen_string_literal: true + +RSpec.describe Wallets::Balance::RefreshOngoingUsageService do + let(:wallet) do + create( + :wallet, + customer:, + depleted_ongoing_balance:, + balance_cents: 1000, + ongoing_balance_cents: 800, + ongoing_usage_balance_cents: 200, + credits_balance: 10.0, + credits_ongoing_balance: 8.0, + credits_ongoing_usage_balance: 2.0 + ) + end + + let(:depleted_ongoing_balance) { false } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:first_subscription) do + create(:subscription, organization:, customer:, started_at: Time.zone.now - 2.years) + end + let(:second_subscription) do + create(:subscription, organization:, customer:, started_at: Time.zone.now - 1.year) + end + let(:timestamp) { Time.current } + let(:billable_metric) { create(:billable_metric, aggregation_type: "count_agg") } + + let(:first_charge) do + create( + :standard_charge, + plan: first_subscription.plan, + billable_metric:, + properties: {amount: "3"} + ) + end + let(:second_charge) do + create( + :standard_charge, + plan: second_subscription.plan, + billable_metric:, + properties: {amount: "5"} + ) + end + + let(:usage_amount_cents) { 1100 } + let(:current_usage_fees) { [] } + let(:draft_invoices_fees) { [] } + let(:progressive_billing_fees) { [] } + let(:pay_in_advance_fees) { [] } + + before do + first_charge + second_charge + wallet + end + + describe ".call" do + subject(:result) do + described_class.call( + wallet:, + usage_amount_cents:, + current_usage_fees:, + draft_invoices_fees:, + progressive_billing_fees:, + pay_in_advance_fees: + ) + end + + context "when there are current usage fees" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:first_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 600, taxes_amount_cents: 0) + end + let(:second_fee) do + create(:charge_fee, charge: second_charge, subscription: second_subscription, + organization:, invoice:, amount_cents: 500, taxes_amount_cents: 0) + end + let(:current_usage_fees) { [first_fee, second_fee] } + + it "updates wallet ongoing balance" do + expect { subject } + .to change(wallet.reload, :ongoing_usage_balance_cents).from(200).to(1100) + .and change(wallet, :credits_ongoing_usage_balance).from(2.0).to(11.0) + .and change(wallet, :ongoing_balance_cents).from(800).to(-100) + .and change(wallet, :credits_ongoing_balance).from(8.0).to(-1.0) + end + + it "returns the wallet" do + expect(result.wallet).to eq(wallet) + end + end + + context "when there are paid in advance fees" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:current_usage_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 1100, taxes_amount_cents: 0) + end + let(:pay_in_advance_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 700, taxes_amount_cents: 0) + end + let(:current_usage_fees) { [current_usage_fee] } + let(:pay_in_advance_fees) { [pay_in_advance_fee] } + + it "updates wallet ongoing balance by deducting pay in advance fees" do + # total_usage = 1100, billed_pay_in_advance = 700 + # ongoing_usage = 1100 - 700 = 400 + expect { subject } + .to change(wallet.reload, :ongoing_usage_balance_cents).from(200).to(400) + .and change(wallet, :credits_ongoing_usage_balance).from(2.0).to(4.0) + .and change(wallet, :ongoing_balance_cents).from(800).to(600) + .and change(wallet, :credits_ongoing_balance).from(8.0).to(6.0) + end + end + + context "when there is a progressive billing invoice" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:current_usage_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 1100, taxes_amount_cents: 0) + end + let(:progressive_billing_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 100, taxes_amount_cents: 10, + precise_coupons_amount_cents: 0) + end + let(:current_usage_fees) { [current_usage_fee] } + let(:progressive_billing_fees) { [progressive_billing_fee] } + + it "deducts progressively_billed amount from the ongoing usage" do + # total_usage = 1100, billed_progressive = 110 (100 + 10 taxes) + # ongoing_usage = 1100 - 110 = 990 + expect { subject } + .to change(wallet.reload, :ongoing_usage_balance_cents).from(200).to(990) + .and change(wallet, :credits_ongoing_usage_balance).from(2.0).to(9.9) + .and change(wallet, :ongoing_balance_cents).from(800).to(10) + .and change(wallet, :credits_ongoing_balance).from(8.0).to(0.1) + end + end + + context "when there are draft invoices fees" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:current_usage_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 1000, taxes_amount_cents: 0) + end + let(:draft_invoice_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 100, taxes_amount_cents: 10, + precise_coupons_amount_cents: 10) + end + let(:current_usage_fees) { [current_usage_fee] } + let(:draft_invoices_fees) { [draft_invoice_fee] } + + it "adds draft invoices amount to the ongoing usage" do + # total_usage = 1000, draft_invoices = 100 (amount) + 10 (taxes) - 10 (coupons) = 100 + # ongoing_usage = 1000 + 100 = 1100 + expect { subject } + .to change(wallet.reload, :ongoing_usage_balance_cents).from(200).to(1100) + .and change(wallet, :credits_ongoing_usage_balance).from(2.0).to(11.0) + .and change(wallet, :ongoing_balance_cents).from(800).to(-100) + .and change(wallet, :credits_ongoing_balance).from(8.0).to(-1.0) + end + end + + context "when recalculated ongoing balance is less than 0" do + let(:invoice) { create(:invoice, customer:, organization:) } + let(:current_usage_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 1100, taxes_amount_cents: 0) + end + let(:current_usage_fees) { [current_usage_fee] } + + before do + allow(Wallets::Balance::UpdateOngoingService).to receive(:call).and_call_original + end + + context "when wallet is not depleted" do + it "sends update params with depleted_ongoing_balance set to true" do + subject + + expect(Wallets::Balance::UpdateOngoingService).to have_received(:call) + .with(wallet: wallet, update_params: hash_including(depleted_ongoing_balance: true), skip_single_wallet_update: false) + end + end + + context "when wallet is depleted before the update" do + let(:depleted_ongoing_balance) { true } + + it "doesn't send update params with depleted_ongoing_balance set to true" do + subject + + expect(Wallets::Balance::UpdateOngoingService).to have_received(:call) + .with(wallet: wallet, update_params: hash_excluding(:depleted_ongoing_balance), skip_single_wallet_update: false) + end + end + end + + context "when ongoing balance becomes positive after being depleted" do + let(:depleted_ongoing_balance) { true } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:current_usage_fee) do + create(:charge_fee, charge: first_charge, subscription: first_subscription, + organization:, invoice:, amount_cents: 500, taxes_amount_cents: 0) + end + let(:current_usage_fees) { [current_usage_fee] } + + before do + allow(Wallets::Balance::UpdateOngoingService).to receive(:call).and_call_original + end + + it "sends update params with depleted_ongoing_balance set to false" do + subject + + expect(Wallets::Balance::UpdateOngoingService).to have_received(:call) + .with(wallet: wallet, update_params: hash_including(depleted_ongoing_balance: false), skip_single_wallet_update: false) + end + end + end +end diff --git a/spec/services/wallets/balance/update_ongoing_service_spec.rb b/spec/services/wallets/balance/update_ongoing_service_spec.rb new file mode 100644 index 0000000..d433ddf --- /dev/null +++ b/spec/services/wallets/balance/update_ongoing_service_spec.rb @@ -0,0 +1,115 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::Balance::UpdateOngoingService do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:, awaiting_wallet_refresh: true) } + + let(:wallet) do + create( + :wallet, + customer:, + balance_cents: 1000, + ongoing_balance_cents: 800, + ongoing_usage_balance_cents: 200, + credits_balance: 10.0, + credits_ongoing_balance: 8.0, + credits_ongoing_usage_balance: 2.0 + ) + end + + let(:update_params) do + { + ongoing_usage_balance_cents: 550, + credits_ongoing_usage_balance: 5.5, + ongoing_balance_cents: 450, + credits_ongoing_balance: 4.5, + depleted_ongoing_balance: + } + end + + before do + wallet + allow(Wallets::ThresholdTopUpService).to receive(:call).and_call_original + end + + describe "#call" do + subject(:result) { described_class.call(wallet:, update_params:) } + + context "when ongoing balance is not depleted" do + let(:depleted_ongoing_balance) { false } + + it "updates wallet balance" do + freeze_time do + expect { subject } + .to change(wallet.reload, :ongoing_usage_balance_cents).from(200).to(550) + .and change(wallet, :credits_ongoing_usage_balance).from(2.0).to(5.5) + .and change(wallet, :ongoing_balance_cents).from(800).to(450) + .and change(wallet, :credits_ongoing_balance).from(8.0).to(4.5) + .and change(wallet, :last_ongoing_balance_sync_at).from(nil).to(Time.current) + .and not_change(wallet, :last_balance_sync_at) + + expect(wallet.depleted_ongoing_balance).to eq false + end + end + + it "does not send depleted_ongoing_balance webhook" do + expect { subject }.not_to have_enqueued_job(SendWebhookJob) + end + + it "calls Wallets::ThresholdTopUpService" do + subject + expect(Wallets::ThresholdTopUpService).to have_received(:call).with(wallet:) + end + + it "enqueues a ProcessWalletAlertsJob" do + expect { subject }.to have_enqueued_job(UsageMonitoring::ProcessWalletAlertsJob).at_least(:once) + end + end + + context "when ongoing balance is depleted" do + let(:depleted_ongoing_balance) { true } + + it "updates wallet balance" do + freeze_time do + expect { subject } + .to change(wallet.reload, :ongoing_usage_balance_cents).from(200).to(550) + .and change(wallet, :credits_ongoing_usage_balance).from(2.0).to(5.5) + .and change(wallet, :ongoing_balance_cents).from(800).to(450) + .and change(wallet, :credits_ongoing_balance).from(8.0).to(4.5) + .and change(wallet, :last_ongoing_balance_sync_at).from(nil).to(Time.current) + .and not_change(wallet, :last_balance_sync_at) + + expect(wallet.depleted_ongoing_balance).to eq true + end + end + + it "sends depleted_ongoing_balance webhook" do + expect { subject } + .to have_enqueued_job(SendWebhookJob) + .with("wallet.depleted_ongoing_balance", Wallet) + end + + it "calls Wallets::ThresholdTopUpService" do + subject + expect(Wallets::ThresholdTopUpService).to have_received(:call).with(wallet:) + end + end + + context "when skip_single_wallet_update is true" do + subject(:result) { described_class.call(wallet:, update_params:, skip_single_wallet_update: true) } + + let(:depleted_ongoing_balance) { false } + + it "updates wallet balance but does not update last_ongoing_balance_sync_at" do + expect { subject } + .to change(wallet.reload, :ongoing_usage_balance_cents).from(200).to(550) + .and change(wallet, :credits_ongoing_usage_balance).from(2.0).to(5.5) + .and change(wallet, :ongoing_balance_cents).from(800).to(450) + .and change(wallet, :credits_ongoing_balance).from(8.0).to(4.5) + .and not_change(wallet, :last_ongoing_balance_sync_at) + end + end + end +end diff --git a/spec/services/wallets/build_allocation_rules_service_spec.rb b/spec/services/wallets/build_allocation_rules_service_spec.rb new file mode 100644 index 0000000..0c5a440 --- /dev/null +++ b/spec/services/wallets/build_allocation_rules_service_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::BuildAllocationRulesService do + describe ".call" do + subject(:result) { described_class.call(customer:) } + + let(:allocation_rules) { result.allocation_rules } + let(:bm_map) { allocation_rules[:bm_map] } + let(:type_map) { allocation_rules[:type_map] } + let(:unrestricted) { allocation_rules[:unrestricted] } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + context "when customer has no wallets" do + it "returns empty allocation rules" do + expect(result).to be_success + + expect(bm_map).to eq({}) + expect(type_map).to eq({}) + expect(unrestricted).to eq([]) + end + end + + context "with a mix of unrestricted, fee type, and metric-targeted wallets" do + let(:bm1) { create(:billable_metric, organization:) } + let(:bm2) { create(:billable_metric, organization:) } + + let!(:w_bm2) { create(:wallet, customer:, organization:, priority: 6) } + let!(:w_charge) { create(:wallet, customer:, organization:, priority: 2, allowed_fee_types: ["charge"]) } + let!(:w_bm1) { create(:wallet, customer:, organization:, priority: 4) } + let!(:w_subscription) { create(:wallet, customer:, organization:, priority: 3, allowed_fee_types: ["subscription"]) } + let!(:w_unres_low) { create(:wallet, customer:, organization:, priority: 5) } + let!(:w_unres_high) { create(:wallet, customer:, organization:, priority: 1) } + + before do + create(:wallet_target, wallet: w_bm1, billable_metric: bm1, organization:) + create(:wallet_target, wallet: w_bm2, billable_metric: bm2, organization:) + end + + it "builds allocation rules ordered by wallet priority" do + expect(result).to be_success + + # Unrestricted wallets apply everywhere and keep priority order + expect(unrestricted).to eq([w_unres_high.id, w_unres_low.id]) + + # Fee type map contains wallets that allow the fee type, and unrestricted + expect(type_map.keys).to match_array(["charge", "subscription"]) + + expect(type_map["charge"]).to eq([w_unres_high.id, w_charge.id, w_unres_low.id]) + expect(type_map["subscription"]).to eq([w_unres_high.id, w_subscription.id, w_unres_low.id]) + + # Billable metric map is built combining charge wallets, unrestricted wallets and targeted wallet + expect(bm_map.keys).to match_array([bm1.id, bm2.id]) + + expect(bm_map[bm1.id]).to eq([w_unres_high.id, w_charge.id, w_bm1.id, w_unres_low.id]) + expect(bm_map[bm2.id]).to eq([w_unres_high.id, w_charge.id, w_unres_low.id, w_bm2.id]) + end + end + + context "with only unrestricted wallets" do + let!(:wallet_priority_50) { create(:wallet, customer:, organization:, priority: 50) } + let!(:wallet_priority_5_newer) { create(:wallet, customer:, organization:, priority: 5, created_at: 2.minutes.ago) } + let!(:wallet_priority_5_older) { create(:wallet, customer:, organization:, priority: 5, created_at: 5.minutes.ago) } + let!(:wallet_priority_1) { create(:wallet, customer:, organization:, priority: 1) } + + it "returns unrestricted list and empty maps" do + expect(result).to be_success + expect(type_map).to eq({}) + expect(bm_map).to eq({}) + + expect(unrestricted).to eq( + [ + wallet_priority_1.id, + wallet_priority_5_older.id, + wallet_priority_5_newer.id, + wallet_priority_50.id + ] + ) + end + end + end +end diff --git a/spec/services/wallets/create_interval_wallet_transactions_service_spec.rb b/spec/services/wallets/create_interval_wallet_transactions_service_spec.rb new file mode 100644 index 0000000..fd36955 --- /dev/null +++ b/spec/services/wallets/create_interval_wallet_transactions_service_spec.rb @@ -0,0 +1,553 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::CreateIntervalWalletTransactionsService do + subject(:create_interval_transactions_service) { described_class.new } + + describe ".call" do + let(:created_at) { DateTime.parse("20 Feb 2021") } + let(:customer) { create(:customer) } + let(:started_at) { nil } + + let(:wallet) do + create( + :wallet, + customer:, + created_at:, + credits_ongoing_balance: 50, + paid_top_up_min_amount_cents: 200_00 + ) + end + + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval:, + created_at: created_at + 1.second, + started_at: + ) + end + + before { recurring_transaction_rule } + + def expect_to_have_scheduled_wallet_transaction(**attrs) + expect(WalletTransactions::CreateJob).to have_been_enqueued + .with( + organization_id: customer.organization_id, + params: { + wallet_id: wallet.id, + paid_credits: recurring_transaction_rule.paid_credits.to_s, + granted_credits: recurring_transaction_rule.granted_credits.to_s, + source: :interval, + invoice_requires_successful_payment: false, + metadata: [], + name: "Recurring Transaction Rule" + }.merge(attrs) + ) + end + + context "when recurring transactions should be created weekly" do + let(:interval) { :weekly } + + let(:current_date) do + DateTime.parse("20 Jun 2022").prev_occurring(created_at.strftime("%A").downcase.to_sym) + end + + it "enqueues a job on correct day" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + + it "does not enqueue a job on other day" do + travel_to(current_date + 1.day) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + context "when recurring transaction rule has no transaction_name" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval:, + created_at: created_at + 1.second, + started_at:, + transaction_name: nil + ) + end + + it "enqueues a job with transaction_name" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction(name: nil) + end + end + end + + context "when started_at is set on the transaction recurring rule" do + let(:started_at) { DateTime.parse("20 Jun 2022") } + + it "does not enqueue a job one week after the creation date" do + travel_to(current_date) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + it "enqueues a job one week after the started_at date" do + current_date = DateTime.parse("20 Jun 2022").next_occurring(started_at.strftime("%A").downcase.to_sym) + + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + + context "when the started_at is the future with the same month day as current" do + let(:started_at) { current_date + 1.month } + let(:interval) { :monthly } + + it "does not enqueue a job before the started_at date" do + travel_to(current_date) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + end + end + + context "when method is target" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval:, + created_at: created_at + 1.second, + method: "target", + target_ongoing_balance: "200" + ) + end + + it "calls wallet transaction create job with expected params" do + travel_to(current_date) do + create_interval_transactions_service.call + expect_to_have_scheduled_wallet_transaction( + paid_credits: "200.0", # the gap is 150 but wallet has min amount set to 200 + granted_credits: "0.0" + ) + end + end + end + end + + context "when recurring transactions should be created monthly" do + let(:interval) { :monthly } + let(:current_date) { created_at.next_month } + + it "enqueues a job on correct day" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + + it "does not enqueue a job on other day" do + travel_to(current_date + 1.day) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + context "when wallet is created on a 31st" do + let(:created_at) { DateTime.parse("31 Mar 2021") } + let(:current_date) { DateTime.parse("30 Apr 2021") } + + it "enqueues a job if the month count less than 31 days" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + end + + context "when started_at is set on the transaction recurring rule" do + let(:created_at) { DateTime.parse("31 Mar 2025") } + let(:started_at) { DateTime.parse("15 Apr 2025") } + + it "does not enqueue a job one month after the creation date" do + current_date = DateTime.parse("30 Apr 2025") + + travel_to(current_date) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + it "enqueues a job one month after the started_at date" do + current_date = DateTime.parse("15 May 2025") + + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + end + end + + context "when recurring transactions should be created quarterly" do + let(:interval) { :quarterly } + let(:current_date) { created_at + 3.months } + + it "enqueues a job on correct day" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + + it "does not enqueue a job on other day" do + travel_to(current_date + 1.day) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + context "when it is March" do + let(:created_at) { DateTime.parse("15 Mar 2021") } + let(:current_date) { DateTime.parse("15 Sep 2022") } + + it "enqueues a job" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + end + + context "when wallet is created on a 31st" do + let(:created_at) { DateTime.parse("31 Mar 2021") } + let(:current_date) { DateTime.parse("30 Jun 2022") } + + it "enqueues a job if the month count less than 31 days" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + end + end + + context "when recurring transactions should be created yearly" do + let(:interval) { :yearly } + let(:current_date) { created_at.next_year } + + it "enqueues a job on correct day" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + + it "does not enqueue a job on other day" do + travel_to(current_date + 1.day) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + context "when wallet is created on 29th of february" do + let(:created_at) { DateTime.parse("29 Feb 2020") } + let(:current_date) { DateTime.parse("28 Feb 2022") } + + it "enqueues a job on 28th of february when year is not a leap year" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + end + end + + context "when on wallet creation day" do + let(:interval) { :monthly } + let(:customer) { create(:customer, timezone:) } + let(:timezone) { nil } + + it "does not enqueue a job" do + travel_to(created_at) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + context "with customer timezone" do + let(:timezone) { "Pacific/Noumea" } + + it "does not enqueue a job" do + travel_to(created_at + 10.hours) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + end + end + + context "when wallet transactions had already been created that day" do + let(:interval) { :monthly } + let(:current_date) { DateTime.parse("20 Mar 2021T12:00:00") } + + let(:wallet_transaction) do + create( + :wallet_transaction, + wallet:, + transaction_type: :inbound, + source: :interval, + created_at: current_date - 1.hour + ) + end + + before { wallet_transaction } + + it "does not enqueue a job" do + travel_to(current_date) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + + context "with customer timezone" do + let(:customer) { create(:customer, timezone:) } + let(:timezone) { "Pacific/Noumea" } + + it "does not enqueue a job" do + travel_to(current_date + 10.hours) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + end + end + + context "when rule requires successful payment" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval:, + created_at: created_at + 1.second, + started_at:, + invoice_requires_successful_payment: true + ) + end + let(:interval) { :weekly } + + let(:current_date) do + DateTime.parse("20 Jun 2022").prev_occurring(created_at.strftime("%A").downcase.to_sym) + end + + it "follows the rule configuration" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction(invoice_requires_successful_payment: true) + end + end + end + + context "when rule have metadata" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval:, + created_at: created_at + 1.second, + started_at:, + transaction_metadata: + ) + end + let(:interval) { :weekly } + + let(:transaction_metadata) { [{"key" => "valid_value", "value" => "also_valid"}] } + + let(:current_date) do + DateTime.parse("20 Jun 2022").prev_occurring(created_at.strftime("%A").downcase.to_sym) + end + + it "enqueues a job with correct configuration" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction(metadata: transaction_metadata) + end + end + end + + context "when rule has transaction_name" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval:, + created_at: created_at + 1.second, + started_at:, + transaction_name: "Monthly Credits Refill" + ) + end + let(:interval) { :weekly } + + let(:current_date) do + DateTime.parse("20 Jun 2022").prev_occurring(created_at.strftime("%A").downcase.to_sym) + end + + it "enqueues a job with the transaction name" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect(WalletTransactions::CreateJob).to have_been_enqueued + .with( + organization_id: customer.organization_id, + params: hash_including(name: "Monthly Credits Refill") + ) + end + end + end + + context "when recurring transaction rule has expired" do + let(:created_at) { DateTime.parse("20 Feb 2021") } + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval:, + created_at: created_at + 1.second, + expiration_at: created_at + 2.hours, + started_at: + ) + end + let(:interval) { :weekly } + + let(:current_date) do + created_at + 2.weeks + end + + it "does not enqueue a job for expired rules" do + travel_to(current_date) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + end + + context "when credits are zero" do + let(:created_at) { DateTime.parse("20 Feb 2021") } + let(:wallet) { create(:wallet, customer:, created_at:) } + let(:current_date) { created_at + 2.weeks } + + context "when both paid and granted credits are zero" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval: :weekly, + created_at:, + method: "target", + target_ongoing_balance: 500, + granted_credits: 0 + ) + end + + before { wallet.update!(credits_ongoing_balance: 500) } + + it "does not enqueue a job" do + travel_to(current_date) do + expect { create_interval_transactions_service.call }.not_to have_enqueued_job + end + end + end + + context "when only paid credits is zero" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval: :weekly, + created_at:, + method: "fixed", + target_ongoing_balance: 500, + granted_credits: 100 + ) + end + + before { wallet.update!(credits_ongoing_balance: 1000) } + + it "enqueues a job" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction + end + end + end + + context "when only granted credits is zero" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval: :weekly, + created_at:, + method: "target", + paid_credits: 100, + target_ongoing_balance: 500 + ) + end + + it "enqueues a job" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction(paid_credits: "500.0", granted_credits: "0.0") + end + end + end + + context "when both paid and granted credits are non-zero" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + trigger: :interval, + wallet:, + interval: :weekly, + created_at:, + method: "target", + paid_credits: 100, + granted_credits: 50, + target_ongoing_balance: 500 + ) + end + + it "enqueues a job" do + travel_to(current_date) do + create_interval_transactions_service.call + + expect_to_have_scheduled_wallet_transaction(paid_credits: "500.0", granted_credits: "0.0") + end + end + end + end + end +end diff --git a/spec/services/wallets/create_service_spec.rb b/spec/services/wallets/create_service_spec.rb new file mode 100644 index 0000000..72a8c33 --- /dev/null +++ b/spec/services/wallets/create_service_spec.rb @@ -0,0 +1,1248 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::CreateService do + subject(:create_service) { described_class.new(params:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:, external_id: "foobar", currency: customer_currency) } + let(:customer_currency) { "EUR" } + + describe "#call" do + let(:paid_credits) { "1.00" } + let(:granted_credits) { "0.00" } + let(:expiration_at) { (Time.current + 1.year).iso8601 } + let(:ignore_paid_top_up_limits_on_creation) { nil } + + let(:params) do + { + name: "New Wallet", + priority: 5, + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "5.00", + expiration_at:, + paid_credits:, + granted_credits:, + paid_top_up_min_amount_cents: 1_00, + paid_top_up_max_amount_cents: 1_000_00, + ignore_paid_top_up_limits_on_creation: + } + end + + let(:service_result) { create_service.call } + + it "creates a wallet" do + expect { service_result }.to change(Wallet, :count).by(1) + + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.customer_id).to eq(customer.id) + expect(wallet.name).to eq("New Wallet") + expect(wallet.priority).to eq(5) + expect(wallet.currency).to eq("EUR") + expect(wallet.rate_amount).to eq(5.0) + expect(wallet.expiration_at.iso8601).to eq(expiration_at) + expect(wallet.recurring_transaction_rules.count).to eq(0) + expect(wallet.invoice_requires_successful_payment).to eq(false) + expect(wallet.paid_top_up_min_amount_cents).to eq(1_00) + expect(wallet.paid_top_up_max_amount_cents).to eq(1_000_00) + end + + it "sends `wallet.created` webhook" do + expect { service_result }.to have_enqueued_job_after_commit(SendWebhookJob).with("wallet.created", Wallet) + end + + it "produces an activity log" do + wallet = described_class.call(params:).wallet + + expect(Utils::ActivityLog).to have_produced("wallet.created").after_commit.with(wallet) + end + + it "flags the customer for ongoing balance refresh" do + expect { service_result }.to change { customer.reload.awaiting_wallet_refresh }.from(false).to(true) + end + + it "enqueues the WalletTransaction::CreateJob" do + expect { service_result }.to have_enqueued_job_after_commit(WalletTransactions::CreateJob).with({ + organization_id: organization.id, + params: { + wallet_id: Regex::UUID, + paid_credits: paid_credits, + granted_credits: granted_credits, + source: :manual, + metadata: nil, + name: nil, + priority: nil, + ignore_paid_top_up_limits: ignore_paid_top_up_limits_on_creation + } + }) + end + + [ + {ctx: "when one of the credits is zero", paid_credits: "10.00", granted_credits: "0.00", schedules_top_up: true}, + {ctx: "when one of the credits is zero", paid_credits: "10.00", granted_credits: nil, schedules_top_up: true}, + {ctx: "when granted_credits and paid_credits are zero", paid_credits: "0.00", granted_credits: "0.00", schedules_top_up: false}, + {ctx: "when granted_credits and paid_credits are nil", paid_credits: nil, granted_credits: nil, schedules_top_up: false}, + {ctx: "when granted_credits and paid_credits are nil or zero", paid_credits: nil, granted_credits: "0.00", schedules_top_up: false} + ].each do |test_case| + context test_case[:ctx] do + let(:paid_credits) { test_case[:paid_credits] } + let(:granted_credits) { test_case[:granted_credits] } + + it "creates a wallet #{test_case[:schedules_top_up] ? "with" : "without"} initial top-up" do + result = nil + + if test_case[:schedules_top_up] + expect { result = create_service.call }.to have_enqueued_job(WalletTransactions::CreateJob).with( + organization_id: organization.id, + params: hash_including( + paid_credits: paid_credits, + granted_credits: granted_credits + ) + ) + else + expect { result = create_service.call }.not_to have_enqueued_job(WalletTransactions::CreateJob) + end + + expect(Wallet.count).to eq(1) + + wallet = result.wallet + expect(wallet.customer_id).to eq(customer.id) + expect(wallet.name).to eq("New Wallet") + expect(wallet.priority).to eq(5) + expect(wallet.currency).to eq("EUR") + expect(wallet.rate_amount).to eq(5.0) + expect(wallet.expiration_at.iso8601).to eq(expiration_at) + expect(wallet.recurring_transaction_rules.count).to eq(0) + end + end + end + + it "creates a traceable wallet" do + expect(service_result).to be_success + expect(service_result.wallet.traceable).to eq(true) + end + + context "when customer has an existing active traceable wallet" do + before { create(:wallet, customer:, organization:, traceable: true) } + + it "creates a traceable wallet" do + expect(service_result).to be_success + expect(service_result.wallet.traceable).to eq(true) + end + end + + context "when customer has an existing active non-traceable wallet" do + before { create(:wallet, customer:, organization:, traceable: false) } + + it "creates a non-traceable wallet" do + expect(service_result).to be_success + expect(service_result.wallet.traceable).to eq(false) + end + end + + context "when customer has an existing terminated non-traceable wallet" do + before { create(:wallet, customer:, organization:, traceable: false, status: :terminated) } + + it "creates a traceable wallet" do + expect(service_result).to be_success + expect(service_result.wallet.traceable).to eq(true) + end + end + + context "with validation error" do + let(:paid_credits) { "-15.00" } + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:paid_credits]).to eq(["invalid_paid_credits", "invalid_amount"]) + end + end + + context "when customer has reached the wallet limit" do + before do + create_list(:wallet, Wallets::ValidateService::MAXIMUM_WALLETS_PER_CUSTOMER, customer:, organization:, status: :active) + end + + it "returns an error" do + expect { service_result }.not_to change(Wallet, :count) + expect(service_result).not_to be_success + expect(service_result.error.messages[:customer]).to eq(["wallet_limit_reached"]) + end + end + + context "when paid_credits is above the maximum" do + let(:paid_credits) { "1002.0" } + + it "returns an error" do + expect { service_result }.not_to change(organization.wallets, :count) + expect(service_result).not_to be_success + expect(service_result.error.messages[:paid_credits]).to eq(["amount_above_maximum"]) + end + end + + context "when paid_credits is above the maximum and ignore validation flag passed" do + let(:paid_credits) { "1002.0" } + let(:ignore_paid_top_up_limits_on_creation) { "true" } + + it "returns an error" do + perform_enqueued_jobs(only: WalletTransactions::CreateJob) do + expect { service_result }.to change(organization.wallets, :count) + expect(service_result).to be_success + transaction = service_result.wallet.wallet_transactions.first + expect(transaction).to have_attributes(credit_amount: 1002.00) + end + end + end + + context "when priority is out of bounds" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + priority: 55 + } + end + + it "defaults to 50" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:priority]).to eq(["value_is_invalid"]) + end + end + + context "when priority is not set" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00" + } + end + + it "defaults to 50" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.priority).to eq(50) + end + end + + context "when priority is nil" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + priority: nil + } + end + + it "defaults to 50" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.priority).to eq(50) + end + end + + context "when invoice_requires_successful_payment is set" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits:, + invoice_requires_successful_payment: + } + end + let(:invoice_requires_successful_payment) { true } + + it "follows the value" do + expect { service_result }.to change(Wallet, :count).by(1) + + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.invoice_requires_successful_payment).to eq(true) + end + + context "when invoice_requires_successful_payment is null" do + let(:invoice_requires_successful_payment) { nil } + + it "defaults to false" do + expect { service_result }.to change(Wallet, :count).by(1) + + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.invoice_requires_successful_payment).to eq(false) + end + end + end + + context "when customer does not have a currency" do + let(:customer_currency) { nil } + + it "applies the currency to the customer" do + service_result + expect(customer.reload.currency).to eq("EUR") + end + + it "sets the wallet currency from customer" do + wallet = service_result.wallet + expect(wallet.currency).to eq(customer.reload.currency) + end + + context "when no currency is provided" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: nil, + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits: + } + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:currency]).to eq(["value_is_invalid"]) + end + end + end + + context "when customer already has a different currency" do + let(:customer_currency) { "USD" } + + it "returns a currency mismatch error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:currency]).to eq(["currencies_does_not_match"]) + end + + it "does not update the customer currency" do + service_result + expect(customer.reload.currency).to eq("USD") + end + end + + context "when customer already has the same currency" do + let(:customer_currency) { "EUR" } + + it "creates the wallet successfully" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.currency).to eq("EUR") + end + + it "does not change the customer currency" do + service_result + expect(customer.reload.currency).to eq("EUR") + end + end + + context "when multi currency is enabled" do + before { organization.update!(feature_flags: ["multi_currency"]) } + + context "when customer does not have a currency" do + let(:customer_currency) { nil } + + it "applies the currency to the customer" do + service_result + expect(customer.reload.currency).to eq("EUR") + end + + it "sets the wallet currency from params" do + wallet = service_result.wallet + expect(wallet.currency).to eq("EUR") + end + end + + context "when customer already has a different currency" do + let(:customer_currency) { "USD" } + + it "does not update the customer currency" do + service_result + expect(customer.reload.currency).to eq("USD") + end + + it "sets the wallet currency from params" do + wallet = service_result.wallet + expect(wallet.currency).to eq("EUR") + end + end + + context "when customer already has the same currency" do + let(:customer_currency) { "EUR" } + + it "creates the wallet successfully" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.currency).to eq("EUR") + end + end + + context "when currency param is nil and customer has a currency" do + let(:customer_currency) { "USD" } + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: nil, + rate_amount: "1.00", + expiration_at:, + paid_credits: "0.00", + granted_credits: "0.00" + } + end + + it "falls back to the customer currency" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.currency).to eq("USD") + end + end + end + + context "when wallet have transaction metadata" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits: "10", + granted_credits: "10", + transaction_metadata: [{"key" => "valid_value", "value" => "also_valid"}] + } + end + + it "enqueues the job with correct metadata" do + expect { service_result }.to have_enqueued_job( + WalletTransactions::CreateJob + ).with(hash_including( + params: hash_including(metadata: params[:transaction_metadata]) + )) + end + end + + context "when transaction_name is provided" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits:, + transaction_name: "Custom Transaction Name" + } + end + + it "enqueues the wallet transaction job with the transaction name" do + expect { service_result }.to have_enqueued_job( + WalletTransactions::CreateJob + ).with(hash_including( + params: hash_including(name: "Custom Transaction Name") + )) + end + end + + context "with recurring transaction rules", :premium do + let(:rules) do + [ + { + interval: "monthly", + method: "target", + paid_credits: "10.0", + granted_credits: "5.0", + target_ongoing_balance: "100.0", + trigger: "interval" + } + ] + end + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits:, + recurring_transaction_rules: rules, + paid_top_up_max_amount_cents: "5000" + } + end + + it "creates a wallet with recurring transaction rules" do + expect { service_result }.to change(Wallet, :count).by(1) + + expect(service_result).to be_success + wallet = service_result.wallet + expect(wallet.name).to eq("New Wallet") + expect(wallet.reload.recurring_transaction_rules.count).to eq(1) + end + + context "when recurring transaction rule has transaction_name" do + let(:rules) do + [ + { + interval: "monthly", + method: "target", + paid_credits: "10.0", + granted_credits: "5.0", + target_ongoing_balance: "100.0", + trigger: "interval", + transaction_name: "Custom Top-up" + } + ] + end + + it "creates a recurring rule with transaction_name" do + expect { service_result }.to change(Wallet, :count).by(1) + + wallet = service_result.wallet + expect(wallet.reload.recurring_transaction_rules.first.transaction_name).to eq("Custom Top-up") + end + end + + context "when number of rules is incorrect" do + let(:rules) do + [ + { + trigger: "interval", + interval: "monthly" + }, + { + trigger: "threshold", + threshold_credits: "1.0" + } + ] + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:recurring_transaction_rules]) + .to eq(["invalid_number_of_recurring_rules"]) + end + end + + context "when trigger is invalid" do + let(:rules) do + [ + { + trigger: "invalid", + interval: "monthly" + } + ] + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:recurring_transaction_rules]).to eq(["invalid_recurring_rule"]) + end + end + + context "when threshold credits value is invalid" do + let(:rules) do + [ + { + trigger: "threshold", + threshold_credits: "abc" + } + ] + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:recurring_transaction_rules]).to eq(["invalid_recurring_rule"]) + end + end + + context "when paid credits exceeds wallet limits" do + let(:rules) do + [ + { + trigger: "interval", + interval: "monthly", + paid_credits: "100" + } + ] + end + + it "returns an error" do + expect(service_result).to be_failure + expect(service_result.error.messages[:recurring_transaction_rules]).to eq(["invalid_recurring_rule"]) + end + end + end + + context "with limitations" do + let(:limitations) do + { + fee_types: %w[charge] + } + end + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits:, + applies_to: limitations + } + end + + it "creates a wallet with correct limitations" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.reload.name).to eq("New Wallet") + expect(wallet.reload.allowed_fee_types).to eq(%w[charge]) + end + + context "when fee limitations are not correct" do + let(:limitations) do + { + fee_types: %w[invalid] + } + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:applies_to]).to eq(["invalid_limitations"]) + end + end + + context "with billable metric limitations in graphql context" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:limitations) do + { + billable_metric_ids: [billable_metric.id] + } + end + + before { CurrentContext.source = "graphql" } + + it "creates a wallet" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + end + + it "creates a wallet target" do + expect { create_service.call } + .to change(WalletTarget, :count).by(1) + end + + context "with invalid billable metric" do + let(:limitations) do + { + billable_metric_ids: [billable_metric.id, "invalid"] + } + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:applies_to]).to eq(["invalid_limitations"]) + end + end + end + + context "with billable metric limitations in api context" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:limitations) do + { + billable_metric_codes: [billable_metric.code] + } + end + + before { CurrentContext.source = "api" } + + it "creates a wallet" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + end + + it "creates a wallet target" do + expect { create_service.call } + .to change(WalletTarget, :count).by(1) + end + end + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits:, + payment_method: payment_method_params + } + end + + before { payment_method } + + it "creates a wallet with correct payment method" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.reload.name).to eq("New Wallet") + expect(wallet.reload.payment_method_id).to eq(payment_method.id) + expect(wallet.reload.payment_method_type).to eq("provider") + end + + context "when payment method id is nil" do + let(:payment_method_params) do + { + payment_method_id: nil, + payment_method_type: "provider" + } + end + + it "successfully creates wallet" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.reload.name).to eq("New Wallet") + expect(wallet.reload.payment_method_id).to eq(nil) + expect(wallet.reload.payment_method_type).to eq("provider") + end + end + + context "when payment method type is not correct" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when payment method id is not correct" do + let(:payment_method_params) do + { + payment_method_id: "123", + payment_method_type: "provider" + } + end + + it "returns an error" do + expect(service_result).not_to be_success + + expect(service_result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + + context "when organization_id is nil" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: nil, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits: + } + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:organization_id]).to eq(["blank"]) + end + end + + context "when organization_id does not match customer's organization_id" do + let(:other_organization) { create(:organization) } + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: other_organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits: + } + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:organization_id]).to eq(["invalid"]) + end + end + + context "with metadata" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits:, + metadata: {"foo" => "bar", "baz" => "qux"} + } + end + + it "creates a wallet with metadata" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.metadata).to be_present + expect(wallet.metadata.value).to eq({"foo" => "bar", "baz" => "qux"}) + end + end + + context "when metadata is nil" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + expiration_at:, + paid_credits:, + granted_credits:, + metadata: nil + } + end + + it "creates a wallet without metadata" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.metadata).to be_nil + end + end + + context "when code is provided" do + let(:params) do + { + name: "New Wallet", + code: "custom_code", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits:, + granted_credits: + } + end + + it "creates a wallet with the provided code" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.code).to eq("custom_code") + end + + context "when code is already taken for the customer" do + let(:wallet) { create(:wallet, customer:, organization:, code: "existing_code") } + let(:params) do + { + name: "New Wallet", + code: "existing_code", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits:, + granted_credits: + } + end + + before { wallet } + + it "returns an error" do + expect { service_result }.not_to change(Wallet, :count) + expect(service_result).not_to be_success + expect(service_result.error.messages[:code]).to eq(["value_already_exist"]) + end + + context "when existing wallet is terminated" do + let(:wallet) { create(:wallet, customer:, organization:, code: "existing_code", status: "terminated") } + + it "creates the wallet successfully" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.code).to eq("existing_code") + end + end + end + end + + context "when code is not provided but name is" do + let(:params) do + { + name: "My Premium Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits:, + granted_credits: + } + end + + it "creates a wallet with code derived from name" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.code).to eq("my_premium_wallet") + end + + context "when name is already taken for the customer" do + let(:wallet) { create(:wallet, customer:, organization:, name: "Existing Name", code: "existing_name") } + + let(:params) do + { + name: "existing name", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits:, + granted_credits: + } + end + + before { wallet } + + it "creates a wallet with timestamp in the code" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.code).to eq("existing_name_#{wallet.created_at.to_i}") + end + + context "when name is already taken by a terminated wallet" do + let(:wallet) { create(:wallet, customer:, organization:, name: "Existing Name", code: "existing_name", status: "terminated") } + + it "creates a wallet with code derived from name" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.code).to eq("existing_name") + end + end + end + end + + context "when multi_entity_billing is enabled" do + let!(:billing_entity) { create(:billing_entity, organization:, code: "be_code") } + + before do + organization.update!(feature_flags: ["multi_entity_billing"]) + end + + context "when billing_entity_code is provided" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_code: "be_code" + } + end + + it "assigns the billing entity to the wallet" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.billing_entity_id).to eq(billing_entity.id) + end + end + + context "when billing_entity_id is provided" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_id: billing_entity.id + } + end + + it "assigns the billing entity to the wallet" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.billing_entity_id).to eq(billing_entity.id) + end + end + + context "when neither billing_entity_code nor billing_entity_id is provided" do + it "creates the wallet without a billing entity" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.billing_entity_id).to be_nil + end + end + + context "when billing_entity_code does not match any entity" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_code: "nonexistent" + } + end + + it "returns a not found error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::NotFoundFailure) + expect(service_result.error.resource).to eq("billing_entity") + end + end + + context "when billing_entity_id belongs to another organization" do + let(:other_organization) { create(:organization) } + let!(:other_billing_entity) { create(:billing_entity, organization: other_organization) } + + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_id: other_billing_entity.id + } + end + + it "returns a not found error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::NotFoundFailure) + expect(service_result.error.resource).to eq("billing_entity") + end + end + + context "when billing_entity_code belongs to another organization" do + let(:other_organization) { create(:organization) } + let!(:other_billing_entity) { create(:billing_entity, organization: other_organization, code: "other_org_be") } + + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_code: "other_org_be" + } + end + + before { other_billing_entity } + + it "returns a not found error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::NotFoundFailure) + expect(service_result.error.resource).to eq("billing_entity") + end + end + + context "when billing_entity_id does not match any entity" do + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_id: SecureRandom.uuid + } + end + + it "returns a not found error" do + expect(service_result).not_to be_success + expect(service_result.error).to be_a(BaseService::NotFoundFailure) + expect(service_result.error.resource).to eq("billing_entity") + end + end + + context "when both billing_entity_id and billing_entity_code are provided" do + let!(:other_billing_entity) { create(:billing_entity, organization:, code: "other_be") } + + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_id: billing_entity.id, + billing_entity_code: other_billing_entity.code + } + end + + it "billing_entity_id takes precedence" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.billing_entity_id).to eq(billing_entity.id) + end + end + end + + context "when multi_entity_billing is not enabled" do + let(:billing_entity) { create(:billing_entity, organization:, code: "be_code") } + + let(:params) do + { + name: "New Wallet", + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits: "0.00", + granted_credits: "0.00", + billing_entity_code: "be_code" + } + end + + before { billing_entity } + + it "does not assign the billing entity even if code is provided" do + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.billing_entity_id).to be_nil + end + end + + context "when neither code nor name is provided" do + let(:params) do + { + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits:, + granted_credits: + } + end + + it "creates a wallet with default code" do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.code).to eq("default") + end + + context "when default code is already taken for the customer" do + before do + create(:wallet, customer:, organization:, name: nil, code: "default") + end + + let(:params) do + { + customer:, + organization_id: organization.id, + currency: "EUR", + rate_amount: "1.00", + paid_credits:, + granted_credits: + } + end + + it "creates a wallet with timestamp in the code" do + Timecop.freeze do + expect { service_result }.to change(Wallet, :count).by(1) + expect(service_result).to be_success + + wallet = service_result.wallet + expect(wallet.code).to eq("default_#{wallet.created_at.to_i}") + end + end + end + end + end +end diff --git a/spec/services/wallets/find_applicable_on_fees_service_spec.rb b/spec/services/wallets/find_applicable_on_fees_service_spec.rb new file mode 100644 index 0000000..b1dbb16 --- /dev/null +++ b/spec/services/wallets/find_applicable_on_fees_service_spec.rb @@ -0,0 +1,240 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::FindApplicableOnFeesService do + describe ".call" do + subject(:result) { described_class.call(allocation_rules:, fee:, customer_id:, fee_targeting_wallets_enabled:) } + + let(:fee_targeting_wallets_enabled) { nil } + let(:customer_id) { nil } + + context "when there are applicable wallets for billable metrics, fee types and unrestricted" do + let(:allocation_rules) do + { + bm_map: { + SecureRandom.uuid => [SecureRandom.uuid, SecureRandom.uuid] + }, + type_map: { + "charge" => [SecureRandom.uuid, SecureRandom.uuid], + "commitment" => [SecureRandom.uuid, SecureRandom.uuid] + }, + unrestricted: [SecureRandom.uuid, SecureRandom.uuid] + } + end + + context "when fee matches by billable metric" do + let(:fee) { create(:charge_fee) } + let(:bm) { fee.charge.billable_metric } + let(:matching_wallet_id) { allocation_rules[:bm_map][bm.id].first } + + before do + allocation_rules[:bm_map][bm.id] = [SecureRandom.uuid, SecureRandom.uuid] + end + + it "returns matching by billable metric wallet" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq matching_wallet_id + end + end + + context "when fee matches by fee type" do + let(:fee) { create(:minimum_commitment_fee) } + let(:matching_wallet_id) { allocation_rules[:type_map]["commitment"].first } + + it "returns matching by fee type wallet" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq matching_wallet_id + end + end + + context "when fee does not match by billable metric or fee type" do + let(:fee) { create(:add_on_fee) } + let(:matching_wallet_id) { allocation_rules[:unrestricted].first } + + it "returns unrestricted wallet" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq matching_wallet_id + end + end + end + + context "when there are applicable wallets only for fee types and unrestricted" do + let(:allocation_rules) do + { + bm_map: {}, + type_map: { + "charge" => [SecureRandom.uuid, SecureRandom.uuid], + "commitment" => [SecureRandom.uuid, SecureRandom.uuid] + }, + unrestricted: [SecureRandom.uuid, SecureRandom.uuid] + } + end + + context "when fee matches by fee type" do + let(:fee) { create(:minimum_commitment_fee) } + let(:matching_wallet_id) { allocation_rules[:type_map]["commitment"].first } + + it "returns matching by fee type wallet" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq matching_wallet_id + end + end + + context "when fee does not match by fee type" do + let(:fee) { create(:add_on_fee) } + let(:matching_wallet_id) { allocation_rules[:unrestricted].first } + + it "returns unrestricted wallet" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq matching_wallet_id + end + end + end + + context "when there are applicable wallets only for fee types" do + let(:fee) { create(:fee, fee_type: "subscription") } + + let(:allocation_rules) do + { + bm_map: {}, + type_map: { + "subscription" => [SecureRandom.uuid, SecureRandom.uuid], + "charge" => [SecureRandom.uuid, SecureRandom.uuid] + }, + unrestricted: [] + } + end + + context "when fee matches by fee type" do + let(:fee) { create(:fee, fee_type: "subscription") } + let(:matching_wallet_id) { allocation_rules[:type_map]["subscription"].first } + + it "returns matching by fee type wallet" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq matching_wallet_id + end + end + + context "when fee does not match by fee type" do + let(:fee) { create(:add_on_fee) } + + it "returns nil" do + expect(result).to be_success + expect(result.top_priority_wallet).to be nil + end + end + end + + context "when there are no applicable wallets" do + let(:fee) { create(:fee) } + + let(:allocation_rules) do + { + bm_map: {}, + type_map: {}, + unrestricted: [] + } + end + + it "returns nil" do + expect(result).to be_success + expect(result.top_priority_wallet).to be_nil + end + end + + context "when fee has target_wallet_code in grouped_by", :premium do + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:, code: "target_wallet") } + let(:charge) { create(:standard_charge, organization:, accepts_target_wallet:) } + let(:accepts_target_wallet) { nil } + let(:customer_id) { customer.id } + let(:fee) do + create(:charge_fee, invoice:, subscription:, charge:, + grouped_by: {"target_wallet_code" => "target_wallet"}) + end + + let(:allocation_rules) do + { + bm_map: {}, + type_map: {}, + unrestricted: [SecureRandom.uuid, SecureRandom.uuid] + } + end + + before { wallet } + + context "when fee_targeting_wallets_enabled is true" do + let(:fee_targeting_wallets_enabled) { true } + let(:organization) { create(:organization, premium_integrations: ["events_targeting_wallets"]) } + + context "when charge accepts target wallet" do + let(:accepts_target_wallet) { true } + + it "returns the wallet matching target_wallet_code" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq(wallet.id) + end + + context "when target wallet does not exist" do + let(:fee) do + create(:charge_fee, invoice:, subscription:, charge:, + grouped_by: {"target_wallet_code" => "nonexistent"}) + end + + it "falls back to allocation rules" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq(allocation_rules[:unrestricted].first) + end + end + + context "when target wallet exists but is not active" do + before { wallet.update!(status: :terminated) } + + it "falls back to allocation rules" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq(allocation_rules[:unrestricted].first) + end + end + + context "when target_wallet_code takes priority over billable metric wallets" do + let(:allocation_rules) do + { + bm_map: {fee.charge.billable_metric_id => [SecureRandom.uuid]}, + type_map: {}, + unrestricted: [] + } + end + + it "returns the wallet matching target_wallet_code" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq(wallet.id) + end + end + end + + context "when charge does not accept target wallet" do + let(:accepts_target_wallet) { false } + + it "ignores target_wallet_code and falls back to allocation rules" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq(allocation_rules[:unrestricted].first) + end + end + end + + context "when fee_targeting_wallets_enabled is false" do + let(:fee_targeting_wallets_enabled) { false } + let(:accepts_target_wallet) { false } + let(:organization) { create(:organization, premium_integrations: []) } + + it "ignores target_wallet_code and falls back to allocation rules" do + expect(result).to be_success + expect(result.top_priority_wallet).to eq(allocation_rules[:unrestricted].first) + end + end + end + end +end diff --git a/spec/services/wallets/recurring_transaction_rules/create_service_spec.rb b/spec/services/wallets/recurring_transaction_rules/create_service_spec.rb new file mode 100644 index 0000000..8c94be3 --- /dev/null +++ b/spec/services/wallets/recurring_transaction_rules/create_service_spec.rb @@ -0,0 +1,391 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::RecurringTransactionRules::CreateService do + subject(:create_service) { described_class.new(wallet:, wallet_params:) } + + let(:wallet) { create(:wallet, paid_top_up_min_amount_cents: 15_00) } + let(:wallet_params) do + { + paid_credits: "100.0", + granted_credits: "50.0", + recurring_transaction_rules: [rule_params] + } + end + + let(:rule_params) do + { + interval: "monthly", + method: "target", + paid_credits: "10.0", + granted_credits: "5.0", + started_at: "2024-05-30T12:48:26Z", + target_ongoing_balance: "100.0", + trigger: "interval", + ignore_paid_top_up_limits: "true" + } + end + + describe "#call" do + context "when freemium" do + it "does not create any recurring transaction rule" do + expect { create_service.call }.not_to change { wallet.reload.recurring_transaction_rules.count } + end + end + + context "when premium", :premium do + it "creates rule with expected attributes" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + granted_credits: 5.0, + interval: "monthly", + method: "target", + paid_credits: 10.0, + started_at: Time.parse("2024-05-30T12:48:26Z"), + target_ongoing_balance: 100.0, + threshold_credits: 0.0, + trigger: "interval", + invoice_requires_successful_payment: false, + ignore_paid_top_up_limits: true + ) + end + + context "when method is fixed" do + let(:rule_params) do + { + trigger: "threshold", + threshold_credits: "1.0", + paid_credits: + } + end + + context "when paid and granted credits are omitted for rule" do + let(:rule_params) do + { + trigger: "threshold", + threshold_credits: "1.0" + } + end + + it "creates rule with paid and granted credits amounts inherited from wallet" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + granted_credits: 50.0, + method: "fixed", + paid_credits: 100.0, + target_ongoing_balance: nil, + threshold_credits: 1.0, + trigger: "threshold" + ) + end + end + + context "when paid credits amount aligned with wallet limits" do + let(:paid_credits) { "15" } + + it "creates rule with expected attributes" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + granted_credits: 0.0, + method: "fixed", + paid_credits: 15.0, + target_ongoing_balance: nil, + threshold_credits: 1.0, + trigger: "threshold" + ) + end + end + + context "when paid credits amount exceeds wallet limits" do + let(:paid_credits) { "5" } + + it "fails with validation error" do + expect { create_service.call }.not_to change { wallet.reload.recurring_transaction_rules.count } + + expect(create_service.call).to be_failure + expect(create_service.call.error.messages).to match({recurring_transaction_rules: ["invalid_recurring_rule"]}) + end + end + + context "when paid credits amount is zero" do + let(:paid_credits) { "0" } + + it "creates rule with expected attributes" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + granted_credits: 0.0, + method: "fixed", + paid_credits: 0.0, + target_ongoing_balance: nil, + threshold_credits: 1.0, + trigger: "threshold" + ) + end + end + end + + context "when method is target" do + let(:rule_params) do + { + trigger: "threshold", + method: "target", + threshold_credits: "1.0", + paid_credits: "5" + } + end + + it "creates rule with expected attributes ignoring wallet limits" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + granted_credits: 0.0, + method: "target", + paid_credits: 5.0, + target_ongoing_balance: nil, + threshold_credits: 1.0, + trigger: "threshold" + ) + end + end + + context "when invoice_requires_successful_payment is present" do + let(:rule_params) do + { + trigger: "threshold", + threshold_credits: "1.0", + invoice_requires_successful_payment: true + } + end + + it "creates rule with expected attributes" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + invoice_requires_successful_payment: true + ) + end + end + + context "when transaction metadata is present" do + let(:rule_params) do + { + trigger: "threshold", + threshold_credits: "1.0", + transaction_metadata: + } + end + + let(:transaction_metadata) { [{"key" => "valid_value", "value" => "also_valid"}] } + + it "creates rule with expected attributes" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + transaction_metadata: transaction_metadata + ) + end + end + + context "when invoice_requires_successful_payment is blank" do + let(:wallet) { create(:wallet, invoice_requires_successful_payment: true) } + let(:wallet_params) do + { + paid_credits: "100.0", + granted_credits: "50.0", + recurring_transaction_rules: [{ + trigger: "threshold", + threshold_credits: "1.0" + }] + } + end + + it "follows the wallet configuration" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + invoice_requires_successful_payment: true + ) + end + end + + context "when expiration_at is set in the rule" do + let(:expiration_at) { (Time.current + 1.year).iso8601 } + let(:wallet_params) do + { + paid_credits: "100.0", + granted_credits: "50.0", + recurring_transaction_rules: [{ + trigger: "threshold", + threshold_credits: "1.0", + expiration_at: + }] + } + end + + it "creates a rule with the correct expiration_at" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + expect(wallet.recurring_transaction_rules.first.expiration_at).to eq(expiration_at) + end + end + + { + "Custom Top-up Name" => "Custom Top-up Name", + "" => nil, + " " => nil, + nil => nil + }.each do |transaction_name, expected_transaction_name| + context "when transaction_name is #{transaction_name.inspect}" do + let(:rule_params) do + { + trigger: "threshold", + threshold_credits: "1.0", + transaction_name: + } + end + + it "creates rule with expected transaction_name" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + transaction_name: expected_transaction_name + ) + end + end + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, organization: wallet.organization, customer: wallet.customer) } + let(:service_result) { create_service.call } + let(:rule_params) do + { + trigger: "threshold", + threshold_credits: "1.0", + payment_method: payment_method_params + } + end + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + + before { payment_method } + + it "creates recurring rule" do + expect { service_result }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + expect(service_result).to be_success + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + payment_method_id: payment_method.id, + payment_method_type: "provider" + ) + end + + context "when payment method id is nil" do + let(:payment_method_params) do + { + payment_method_id: nil, + payment_method_type: "provider" + } + end + + it "creates recurring rule" do + expect { service_result }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + expect(service_result).to be_success + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + payment_method_id: nil, + payment_method_type: "provider" + ) + end + end + + context "when payment method type is not correct" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns an error" do + expect(service_result).not_to be_success + expect(service_result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when payment method id is not correct" do + let(:payment_method_params) do + { + payment_method_id: "123", + payment_method_type: "provider" + } + end + + it "returns an error" do + expect(service_result).not_to be_success + + expect(service_result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + + context "with invoice_custom_section" do + let(:rule_params) do + { + interval: "monthly", + method: "target", + started_at: "2024-05-30T12:48:26Z", + target_ongoing_balance: "100.0", + trigger: "interval", + invoice_custom_section: + } + end + + context "when skip" do + let(:invoice_custom_section) do + {skip_invoice_custom_sections: true} + end + + it "creates the rule skipping sections" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + expect(wallet.recurring_transaction_rules.first).to have_attributes( + skip_invoice_custom_sections: true + ) + end + end + + context "when attaching sections" do + let(:invoice_custom_section) do + {invoice_custom_section_codes: ["section_code_1", "section_code_2"]} + end + + let(:section_1) { create(:invoice_custom_section, organization: wallet.organization, code: "section_code_1") } + let(:section_2) { create(:invoice_custom_section, organization: wallet.organization, code: "section_code_2") } + + before do + CurrentContext.source = "api" + + section_1 + section_2 + end + + it "creates the rule skipping sections" do + expect { create_service.call }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + + sections = wallet.recurring_transaction_rules.first.applied_invoice_custom_sections + expect(sections.count).to eq(2) + expect(sections.pluck(:invoice_custom_section_id)).to include(section_1.id, section_2.id) + end + end + end + end + end +end diff --git a/spec/services/wallets/recurring_transaction_rules/terminate_service_spec.rb b/spec/services/wallets/recurring_transaction_rules/terminate_service_spec.rb new file mode 100644 index 0000000..8540917 --- /dev/null +++ b/spec/services/wallets/recurring_transaction_rules/terminate_service_spec.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::RecurringTransactionRules::TerminateService do + subject(:terminate_service) { described_class.new(recurring_transaction_rule:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:) } + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + wallet: wallet, + status: "active", + expiration_at: Time.zone.now - 40.days + ) + end + + describe "#call" do + it "terminates the recurring transaction rule" do + result = terminate_service.call + + expect(result).to be_success + expect(recurring_transaction_rule.reload).to be_terminated + end + + context "when the recurring transaction rule is already terminated" do + before do + recurring_transaction_rule.mark_as_terminated! + end + + it "does not change the termination date" do + terminated_at = recurring_transaction_rule.terminated_at + + result = terminate_service.call + + expect(result).to be_success + expect(recurring_transaction_rule.reload).to be_terminated + expect(recurring_transaction_rule.terminated_at).to eq(terminated_at) + end + end + end +end diff --git a/spec/services/wallets/recurring_transaction_rules/update_service_spec.rb b/spec/services/wallets/recurring_transaction_rules/update_service_spec.rb new file mode 100644 index 0000000..2ec345d --- /dev/null +++ b/spec/services/wallets/recurring_transaction_rules/update_service_spec.rb @@ -0,0 +1,297 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::RecurringTransactionRules::UpdateService do + let(:wallet) { create(:wallet) } + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, wallet:) } + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + started_at: "2024-05-30T12:48:26Z", + transaction_metadata:, + invoice_custom_section: {skip_invoice_custom_sections: true} + } + ] + end + let(:transaction_metadata) { [] } + + describe "#call" do + subject(:result) { described_class.call(wallet:, params:) } + + before { recurring_transaction_rule } + + it "updates an existing active recurring transaction rule" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + + expect(result.wallet.reload.recurring_transaction_rules.count).to eq(1) + expect(rule).to have_attributes( + granted_credits: 105.0, + id: recurring_transaction_rule.id, + interval: "weekly", + method: "fixed", + paid_credits: 105.0, + started_at: Time.parse("2024-05-30T12:48:26Z"), + threshold_credits: 0.0, + trigger: "interval" + ) + end + + context "when updating an inactive rule" do + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + invoice_custom_section: {skip_invoice_custom_sections: true} + } + ] + end + + it "does not update inactive rules and creates a new one" do + recurring_transaction_rule.mark_as_terminated! + + active_rule = result.wallet.reload.recurring_transaction_rules.active.first + expect(result.wallet.reload.recurring_transaction_rules.count).to eq(2) + expect(result.wallet.reload.recurring_transaction_rules.active.count).to eq(1) + expect(active_rule).to have_attributes( + granted_credits: 105, + id: active_rule.id, + interval: "weekly", + method: "fixed", + paid_credits: 105, + trigger: "interval", + skip_invoice_custom_sections: true + ) + end + end + + context "with added rule without id" do + let(:params) do + [ + { + granted_credits: "105", + interval: "weekly", + method: "target", + paid_credits: "105", + target_ongoing_balance: "300", + trigger: "interval", + payment_method: { + payment_method_id: nil, + payment_method_type: "manual" + } + } + ] + end + + it "creates new recurring transaction rule and terminates existing" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + + expect(result.wallet.reload.recurring_transaction_rules.active.count).to eq(1) + expect(result.wallet.reload.recurring_transaction_rules.terminated.count).to eq(1) + expect(rule).to have_attributes( + granted_credits: 105.0, + interval: "weekly", + method: "target", + paid_credits: 105.0, + target_ongoing_balance: 300.0, + threshold_credits: 0.0, + trigger: "interval", + payment_method_id: nil, + payment_method_type: "manual" + ) + expect(rule.id).not_to eq(recurring_transaction_rule.id) + end + end + + context "when paid_credits and granted_credits are explicitly null" do + let(:recurring_transaction_rule) do + create(:recurring_transaction_rule, wallet:, paid_credits: 200, granted_credits: 200) + end + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + method: "target", + trigger: "interval", + interval: "monthly", + target_ongoing_balance: "5300", + paid_credits: nil, + granted_credits: nil, + threshold_credits: nil + } + ] + end + + it "coerces nil credit values to 0.0 instead of raising NotNullViolation" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + + expect(rule).to have_attributes( + id: recurring_transaction_rule.id, + method: "target", + trigger: "interval", + interval: "monthly", + target_ongoing_balance: 5300.0, + paid_credits: 0.0, + granted_credits: 0.0, + threshold_credits: 0.0 + ) + end + end + + context "when empty array is sent as argument" do + let(:params) { [] } + + it "terminates all existing recurring transaction rules" do + expect(result.wallet.reload.recurring_transaction_rules.active.count).to eq(0) + expect(result.wallet.reload.recurring_transaction_rules.terminated.count).to eq(1) + end + end + + context "when creating a new rule without invoice_requires_successful_payment" do + let(:wallet) { create(:wallet, invoice_requires_successful_payment: true) } + let(:params) do + [ + { + trigger: "interval", + interval: "weekly", + paid_credits: "10", + granted_credits: "10" + } + ] + end + + it "defaults invoice_requires_successful_payment from the wallet" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + expect(rule.invoice_requires_successful_payment).to eq(true) + end + end + + context "when sending transaction_metadata" do + context "when transaction_metadata is valid" do + let(:transaction_metadata) { [{"key" => "key"}, {"value" => "value"}] } + + it "updates existing recurring transaction rule with new transaction_metadata" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + expect(rule.transaction_metadata).to eq(transaction_metadata) + end + end + end + + context "when sending payment_method" do + let(:payment_method) { create(:payment_method, organization: wallet.organization, customer: wallet.customer) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + started_at: "2024-05-30T12:48:26Z", + payment_method: payment_method_params + } + ] + end + + before { payment_method } + + context "with valid payment method" do + it "updates existing recurring transaction rule with new payment method" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + expect(rule.payment_method_id).to eq(payment_method.id) + expect(rule.payment_method_type).to eq("provider") + end + end + + context "when payment method is already attached" do + before do + recurring_transaction_rule.payment_method = payment_method + recurring_transaction_rule.payment_method_type = "provider" + end + + let(:payment_method_params) do + { + payment_method_id: nil, + payment_method_type: "provider" + } + end + + it "removes payment_method" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + expect(rule.payment_method_id).to eq(nil) + expect(rule.payment_method_type).to eq("provider") + end + end + + context "when payment method type is not correct" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when payment method id is not correct" do + let(:payment_method_params) do + { + payment_method_id: "123", + payment_method_type: "provider" + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + + { + "Updated Transaction Name" => "Updated Transaction Name", + "" => nil, + " " => nil, + nil => nil + }.each do |transaction_name, expected_transaction_name| + context "when transaction_name is #{transaction_name.inspect}" do + let(:params) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + transaction_name: + } + ] + end + + it "updates existing recurring transaction rule with new transaction_name" do + rule = result.wallet.reload.recurring_transaction_rules.active.first + expect(rule.transaction_name).to eq(expected_transaction_name) + end + end + end + end +end diff --git a/spec/services/wallets/recurring_transaction_rules/validate_service_spec.rb b/spec/services/wallets/recurring_transaction_rules/validate_service_spec.rb new file mode 100644 index 0000000..45d3f53 --- /dev/null +++ b/spec/services/wallets/recurring_transaction_rules/validate_service_spec.rb @@ -0,0 +1,161 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::RecurringTransactionRules::ValidateService do + subject(:validate_service) { described_class.new(params:) } + + let(:params) do + { + trigger: "interval", + interval: "weekly" + } + end + + describe "#call" do + it "returns true" do + expect(validate_service.call).to be_truthy + end + + context "when invalid interval" do + let(:params) do + { + trigger: "interval", + interval: "invalid" + } + end + + it "returns false" do + expect(validate_service.call).to be_falsey + end + end + + context "when invalid threshold" do + let(:params) do + { + trigger: "threshold", + threshold_credits: "invalid" + } + end + + it "returns false" do + expect(validate_service.call).to be_falsey + end + end + + context "when invalid method" do + let(:params) do + { + method: "target", + trigger: "interval", + interval: "weekly", + target_ongoing_balance: "invalid" + } + end + + it "returns false" do + expect(validate_service.call).to be_falsey + end + end + + context "when valid transaction_metadata" do + let(:params) do + { + trigger: "interval", + interval: "weekly", + transaction_metadata: [{"key" => "valid_key", "value" => "invalid_value"}] + } + end + + it "returns true" do + expect(validate_service.call).to eq true + end + end + + context "when invalid transaction_metadata" do + let(:params) do + { + trigger: "interval", + interval: "weekly", + transaction_metadata: {"key" => "valid_key", "value" => "invalid_value"} + } + end + + it "returns false" do + expect(validate_service.call).to eq false + end + end + + context "when invalid credits" do + let(:params) do + { + trigger: "interval", + interval: "weekly", + paid_credits: "invalid" + } + end + + it "returns false" do + expect(validate_service.call).to be_falsey + end + end + + describe "#valid_expiration_at?" do + context "when expiration_at is blank" do + let(:params) do + { + trigger: "interval", + interval: "weekly", + expiration_at: nil + } + end + + it "returns true" do + expect(validate_service.call).to eq true + end + end + + context "when expiration_at is an invalid format" do + let(:params) do + { + trigger: "interval", + interval: "weekly", + expiration_at: "invalid-date" + } + end + + it "returns false" do + expect(validate_service.call).to be_falsey + end + end + + context "when expiration_at is a past date" do + let(:params) do + { + trigger: "interval", + interval: "weekly", + expiration_at: (Time.current - 1.hour).iso8601 + } + end + + it "returns false" do + expect(validate_service.call).to be_falsey + end + end + + context "when expiration_at is a valid future date" do + let(:params) do + { + trigger: "interval", + interval: "weekly", + expiration_at: (Time.current + 1.hour).iso8601 + } + end + + it "returns true" do + expect(validate_service.call).to eq true + end + end + end + end +end diff --git a/spec/services/wallets/terminate_service_spec.rb b/spec/services/wallets/terminate_service_spec.rb new file mode 100644 index 0000000..7536d7b --- /dev/null +++ b/spec/services/wallets/terminate_service_spec.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::TerminateService do + subject(:terminate_service) { described_class.new(wallet:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:) } + + describe "#call" do + before do + subscription + wallet + end + + it "terminates the wallet" do + result = terminate_service.call + + expect(result).to be_success + expect(result.wallet).to be_terminated + end + + it "sends a `wallet.terminated` webhook" do + expect { terminate_service.call }.to have_enqueued_job_after_commit(SendWebhookJob).with("wallet.terminated", Wallet) + end + + context "when the customer has another active wallet" do + before { create(:wallet, customer:, organization:) } + + it "flags the customer for ongoing balance refresh" do + expect { terminate_service.call }.to change { customer.reload.awaiting_wallet_refresh }.from(false).to(true) + end + end + + context "when terminating the customer's last active wallet" do + it "does not raise and leaves the flag untouched" do + expect { terminate_service.call }.not_to change { customer.reload.awaiting_wallet_refresh } + expect(customer.reload.awaiting_wallet_refresh).to be(false) + end + end + + context "when wallet has recurring transaction rules" do + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, wallet:) } + let(:another_rule) { create(:recurring_transaction_rule, wallet:) } + + before do + recurring_transaction_rule + another_rule + end + + it "terminates all associated recurring transaction rules" do + result = terminate_service.call + expect(result).to be_success + expect(result.wallet.recurring_transaction_rules.count).to be(2) + expect(result.wallet.recurring_transaction_rules.terminated.count).to be(2) + expect(result.wallet.recurring_transaction_rules.active.count).to be(0) + end + end + + context "when wallet is already terminated" do + before { wallet.mark_as_terminated! } + + it "does not impact the wallet" do + wallet.reload + terminated_at = wallet.terminated_at + result = terminate_service.call + + expect(result).to be_success + expect(result.wallet).to be_terminated + expect(result.wallet.terminated_at).to eq(terminated_at) + end + + it "does not send the `wallet.terminated` webhook" do + expect { terminate_service.call }.not_to have_enqueued_job(SendWebhookJob) + end + + it "does not flag the customer for ongoing balance refresh" do + expect { terminate_service.call }.not_to change { customer.reload.awaiting_wallet_refresh } + end + end + end +end diff --git a/spec/services/wallets/threshold_top_up_service_spec.rb b/spec/services/wallets/threshold_top_up_service_spec.rb new file mode 100644 index 0000000..2c5a86c --- /dev/null +++ b/spec/services/wallets/threshold_top_up_service_spec.rb @@ -0,0 +1,192 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::ThresholdTopUpService do + subject(:top_up_service) { described_class.new(wallet:) } + + let(:wallet) do + create( + :wallet, + balance_cents: 1000, + ongoing_balance_cents: 550, + ongoing_usage_balance_cents: 450, + credits_balance: 10.0, + credits_ongoing_balance: 5.5, + credits_ongoing_usage_balance: 4.0, + paid_top_up_min_amount_cents: 205_50 + ) + end + + describe "#call" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + wallet:, + trigger: "threshold", + threshold_credits: "6.0", + paid_credits: "10.0", + granted_credits: "3.0", + ignore_paid_top_up_limits: true + ) + end + + before { recurring_transaction_rule } + + it "calls wallet transaction create job with expected params" do + expect { top_up_service.call }.to have_enqueued_job(WalletTransactions::CreateJob) + .with( + organization_id: wallet.organization.id, + params: { + wallet_id: wallet.id, + paid_credits: "10.0", + granted_credits: "3.0", + source: :threshold, + invoice_requires_successful_payment: false, + metadata: [], + name: "Recurring Transaction Rule", + ignore_paid_top_up_limits: true + }, + unique_transaction: true + ) + end + + context "when rule requires successful payment" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + wallet:, + trigger: "threshold", + threshold_credits: "6.0", + paid_credits: "10.0", + granted_credits: "3.0", + invoice_requires_successful_payment: true + ) + end + + it "calls wallet transaction create job with expected params" do + expect { top_up_service.call }.to have_enqueued_job(WalletTransactions::CreateJob) + .with( + organization_id: wallet.organization.id, + params: hash_including(invoice_requires_successful_payment: true, ignore_paid_top_up_limits: false), + unique_transaction: true + ) + end + end + + context "when rule contains transaction metadata" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + wallet:, + trigger: "threshold", + threshold_credits: "6.0", + paid_credits: "10.0", + granted_credits: "3.0", + transaction_metadata: + ) + end + + let(:transaction_metadata) { [{"key" => "valid_value", "value" => "also_valid"}] } + + it "calls wallet transaction create job with expected params" do + expect { top_up_service.call }.to have_enqueued_job(WalletTransactions::CreateJob) + .with( + organization_id: wallet.organization.id, + params: hash_including(metadata: transaction_metadata), + unique_transaction: true + ) + end + end + + context "when rule does not contain transaction_name" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + wallet:, + trigger: "threshold", + threshold_credits: "6.0", + paid_credits: "10.0", + granted_credits: "3.0", + transaction_name: nil + ) + end + + it "calls wallet transaction create job with the transaction name" do + expect { top_up_service.call }.to have_enqueued_job(WalletTransactions::CreateJob) + .with( + organization_id: wallet.organization.id, + params: hash_including(name: nil), + unique_transaction: true + ) + end + end + + context "when border has NOT been crossed" do + let(:recurring_transaction_rule) do + create(:recurring_transaction_rule, wallet:, trigger: "threshold", threshold_credits: "2.0") + end + + it "does not call wallet transaction create job" do + expect { top_up_service.call }.not_to have_enqueued_job(WalletTransactions::CreateJob) + end + end + + context "with pending transactions" do + it "does not call wallet transaction create job" do + create(:wallet_transaction, wallet:, amount: 1.0, credit_amount: 1.0, status: "pending") + + expect { top_up_service.call }.not_to have_enqueued_job(WalletTransactions::CreateJob) + end + end + + context "when recurring_transaction_rule is expired" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + wallet:, + trigger: "threshold", + threshold_credits: "6.0", + method: "target", + target_ongoing_balance: "200", + expiration_at: 1.day.ago + ) + end + + it "does not call wallet transaction create job" do + expect { top_up_service.call }.not_to have_enqueued_job(WalletTransactions::CreateJob) + end + end + + context "when method is target" do + let(:recurring_transaction_rule) do + create( + :recurring_transaction_rule, + wallet:, + trigger: "threshold", + threshold_credits: "6.0", + method: "target", + target_ongoing_balance: "200" + ) + end + + it "calls wallet transaction create job with expected params" do + expect { top_up_service.call }.to have_enqueued_job(WalletTransactions::CreateJob) + .with( + organization_id: wallet.organization.id, + params: { + wallet_id: wallet.id, + paid_credits: "205.5", # the gap is 194.5 but min transaction is 205.5 + granted_credits: "0.0", + source: :threshold, + invoice_requires_successful_payment: false, + metadata: [], + name: "Recurring Transaction Rule", + ignore_paid_top_up_limits: false + }, + unique_transaction: true + ) + end + end + end +end diff --git a/spec/services/wallets/update_service_spec.rb b/spec/services/wallets/update_service_spec.rb new file mode 100644 index 0000000..3cef8e3 --- /dev/null +++ b/spec/services/wallets/update_service_spec.rb @@ -0,0 +1,885 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::UpdateService do + subject(:result) { described_class.call(wallet:, params:) } + + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:wallet) { create(:wallet, customer:, allowed_fee_types: []) } + let(:expiration_at) { (Time.current + 1.year).iso8601 } + let(:priority) { 5 } + + describe "#call" do + before do + subscription + wallet + end + + let(:params) do + { + id: wallet&.id, + name: "new name", + priority:, + expiration_at:, + invoice_requires_successful_payment: true, + paid_top_up_min_amount_cents: 1_00, + paid_top_up_max_amount_cents: 1_000_00 + } + end + + it "updates the wallet" do + expect(result).to be_success + + expect(result.wallet.name).to eq("new name") + expect(result.wallet.expiration_at.iso8601).to eq(expiration_at) + expect(result.wallet.invoice_requires_successful_payment).to eq(true) + expect(result.wallet.priority).to eq(priority) + expect(wallet.paid_top_up_min_amount_cents).to eq(1_00) + expect(wallet.paid_top_up_max_amount_cents).to eq(1_000_00) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + expect(Utils::ActivityLog).to have_produced("wallet.updated").after_commit.with(wallet) + end + + it "calls Customers::RefreshWalletsService" do + allow(Customers::RefreshWalletsService).to receive(:call) + subject + + expect(Customers::RefreshWalletsService).to have_received(:call).with(customer:) + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + + describe "Customers::RefreshWalletsService gating" do + before { allow(Customers::RefreshWalletsService).to receive(:call) } + + shared_examples "calls refresh" do + it "calls Customers::RefreshWalletsService" do + expect(result).to be_success + expect(Customers::RefreshWalletsService).to have_received(:call).with(customer:) + end + end + + shared_examples "does not call refresh" do + it "does not call Customers::RefreshWalletsService" do + expect(result).to be_success + expect(Customers::RefreshWalletsService).not_to have_received(:call) + end + end + + context "when code changes" do + let(:params) { {id: wallet.id, code: "new_code"} } + + include_examples "calls refresh" + end + + context "when priority changes" do + let(:params) { {id: wallet.id, priority: wallet.priority - 1} } + + include_examples "calls refresh" + end + + context "when allowed_fee_types change" do + let(:params) { {id: wallet.id, applies_to: {fee_types: %w[charge]}} } + + include_examples "calls refresh" + end + + context "when a wallet_target is added" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:params) { {id: wallet.id, applies_to: {billable_metric_ids: [billable_metric.id]}} } + + before { CurrentContext.source = "graphql" } + + include_examples "calls refresh" + end + + context "when a wallet_target is removed" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:params) { {id: wallet.id, applies_to: {billable_metric_ids: []}} } + + before do + CurrentContext.source = "graphql" + create(:wallet_target, wallet:, billable_metric:) + end + + include_examples "calls refresh" + end + + context "when only name changes" do + let(:params) { {id: wallet.id, name: "new name"} } + + include_examples "does not call refresh" + end + + context "when only expiration_at changes" do + let(:params) { {id: wallet.id, expiration_at: (Time.current + 1.year).iso8601} } + + include_examples "does not call refresh" + end + + context "when only paid_top_up_* change" do + let(:params) do + { + id: wallet.id, + paid_top_up_min_amount_cents: 1_00, + paid_top_up_max_amount_cents: 1_000_00 + } + end + + include_examples "does not call refresh" + end + + context "when only payment_method changes" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:params) do + { + id: wallet.id, + payment_method: {payment_method_id: payment_method.id, payment_method_type: "provider"} + } + end + + before { payment_method } + + include_examples "does not call refresh" + end + + context "when only recurring_transaction_rules change", :premium do + let(:params) do + { + id: wallet.id, + recurring_transaction_rules: [ + {trigger: "interval", interval: "weekly", paid_credits: "105", granted_credits: "105"} + ] + } + end + + include_examples "does not call refresh" + end + + context "when only metadata changes" do + let(:params) { {id: wallet.id, metadata: {"foo" => "bar"}} } + + include_examples "does not call refresh" + end + + context "when client resends a refresh-relevant attribute with the same value (no-op)" do + let(:params) { {id: wallet.id, code: wallet.code, name: "new name"} } + + include_examples "does not call refresh" + end + end + + context "when wallet is not found" do + let(:wallet) { nil } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.error_code).to eq("wallet_not_found") + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "with invalid priority" do + let(:priority) { 55 } + + it "returns false and result has errors" do + expect(result).not_to be_success + expect(result.error.messages[:priority]).to eq(["value_is_invalid"]) + end + end + + context "with invalid expiration_at" do + context "when string cannot be parsed to date" do + let(:expiration_at) { "invalid" } + + it "returns false and result has errors" do + expect(result).not_to be_success + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when expiration_at is integer" do + let(:expiration_at) { 123 } + + it "returns false and result has errors" do + expect(result).not_to be_success + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when expiration_at is less than current time" do + let(:expiration_at) { (Time.current - 1.year).iso8601 } + + it "returns false and result has errors" do + expect(result).not_to be_success + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + end + + context "with recurring transaction rules", :premium do + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, wallet:) } + let(:transaction_metadata) { [] } + let(:rules) do + [ + { + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + transaction_metadata: + } + ] + end + let(:params) do + { + id: wallet.id, + name: "new name", + expiration_at:, + recurring_transaction_rules: rules + } + end + + before { recurring_transaction_rule } + + it "creates a new rule and terminates the old one" do + expect(result).to be_success + + rule = result.wallet.reload.recurring_transaction_rules.active.first + + expect(result.wallet.reload.recurring_transaction_rules.active.count).to eq(1) + expect(result.wallet.reload.recurring_transaction_rules.terminated.count).to eq(1) + expect(rule.id).not_to eq(recurring_transaction_rule.id) + expect(rule.trigger).to eq("interval") + expect(rule.interval).to eq("weekly") + expect(rule.threshold_credits).to eq(0.0) + expect(rule.paid_credits).to eq(105.0) + expect(rule.granted_credits).to eq(105.0) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + + context "when editing existing interval rule" do + let(:rules) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105" + } + ] + end + + it "updates the rule" do + expect(result).to be_success + + rule = result.wallet.reload.recurring_transaction_rules.active.first + + expect(result.wallet.reload.recurring_transaction_rules.count).to eq(1) + expect(result.wallet.reload.recurring_transaction_rules.active.count).to eq(1) + expect(result.wallet.reload.recurring_transaction_rules.terminated.count).to eq(0) + expect(rule.id).to eq(recurring_transaction_rule.id) + expect(rule.trigger).to eq("interval") + expect(rule.interval).to eq("weekly") + expect(rule.threshold_credits).to eq(0.0) + expect(rule.paid_credits).to eq(105.0) + expect(rule.granted_credits).to eq(105.0) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when changing the rule into threshold one" do + let(:rules) do + [ + { + lago_id: recurring_transaction_rule.id, + trigger: "threshold", + threshold_credits: "205", + paid_credits: "105", + granted_credits: "105" + } + ] + end + + it "updates the rule" do + expect(result).to be_success + + rule = result.wallet.reload.recurring_transaction_rules.active.first + + expect(result.wallet.reload.recurring_transaction_rules.count).to eq(1) + expect(result.wallet.reload.recurring_transaction_rules.active.count).to eq(1) + expect(result.wallet.reload.recurring_transaction_rules.terminated.count).to eq(0) + expect(rule.id).to eq(recurring_transaction_rule.id) + expect(rule.trigger).to eq("threshold") + expect(rule.threshold_credits).to eq(205.0) + expect(rule.paid_credits).to eq(105.0) + expect(rule.granted_credits).to eq(105.0) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when an empty array is sent as argument" do + let(:rules) { [] } + + it "terminates all existing recurring transaction rules" do + expect(result).to be_success + expect(result.wallet.reload.recurring_transaction_rules.count).to eq(1) + expect(result.wallet.reload.recurring_transaction_rules.active.count).to eq(0) + expect(result.wallet.reload.recurring_transaction_rules.terminated.count).to eq(1) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when number of rules is incorrect" do + let(:rules) do + [ + { + trigger: "interval", + interval: "monthly", + paid_credits: "105", + granted_credits: "105" + }, + { + trigger: "threshold", + threshold_credits: "1.0", + paid_credits: "105", + granted_credits: "105" + } + ] + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:recurring_transaction_rules]).to eq(["invalid_number_of_recurring_rules"]) + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when trigger is invalid" do + let(:rules) do + [ + { + trigger: "invalid", + interval: "monthly", + paid_credits: "105", + granted_credits: "105" + } + ] + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:recurring_transaction_rules]).to eq(["invalid_recurring_rule"]) + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when threshold credits value is invalid" do + let(:rules) do + [ + { + trigger: "threshold", + threshold_credits: "abc", + paid_credits: "105", + granted_credits: "105" + } + ] + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:recurring_transaction_rules]).to eq(["invalid_recurring_rule"]) + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when transaction_rule.transaction_metadata is hash" do + let(:transaction_metadata) { {} } + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:recurring_transaction_rules]).to eq(["invalid_recurring_rule"]) + + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + describe "paid credits validation" do + let(:rules) do + [ + { + lago_id: recurring_transaction_rule&.id, + method:, + paid_credits:, + trigger: "interval", + interval: "weekly", + granted_credits: "105", + ignore_paid_top_up_limits:, + target_ongoing_balance: "5" + + } + ] + end + + let(:method) { "fixed" } + let(:paid_credits) { "10" } + let(:ignore_paid_top_up_limits) { false } + let(:recurring_transaction_rule) { nil } + + context "when method is not fixed" do + let(:method) { "target" } + + it "creates recurring transaction rule" do + expect { result }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + expect(result).to be_success + end + end + + context "when paid credits is 0" do + let(:paid_credits) { "0.000005" } + + it "creates recurring transaction rule" do + expect { result }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + expect(result).to be_success + end + end + + context "when paid credits exceeds wallet limits" do + let(:paid_credits) { "1000" } + + before { wallet.update!(paid_top_up_max_amount_cents: 1_00) } + + it "fails with generic error when amount violates wallet limits" do + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({recurring_transaction_rules: ["invalid_recurring_rule"]}) + end + end + + context "when paid credits exceeds wallet limits but ignore limits flag is passed" do + let(:paid_credits) { "1000" } + let(:ignore_paid_top_up_limits) { true } + + before { wallet.update!(paid_top_up_max_amount_cents: 1_00) } + + it "creates recurring transaction rule" do + expect { result }.to change { wallet.reload.recurring_transaction_rules.count }.by(1) + expect(result).to be_success + end + end + + context "when paid credits is within wallet limits" do + let(:paid_credits) { "105" } + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, wallet:) } + + before { wallet.update!(paid_top_up_min_amount_cents: 1_00) } + + it "creates recurring transaction rule" do + expect { result }.to change { recurring_transaction_rule.reload.attributes } + expect(result).to be_success + end + end + + context "when rule exists and listed in params" do + let(:params) do + { + id: wallet.id, + name: "new name", + expiration_at:, + paid_top_up_min_amount_cents: 1000 + } + end + + before do + create(:recurring_transaction_rule, wallet:, paid_credits: 1) + end + + it "fails with generic error when amount violates wallet limits" do + rule = wallet.reload.recurring_transaction_rules.sole + expect { result }.not_to change { rule.reload.attributes } + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ValidationFailure) + expect(result.error.messages).to eq({recurring_transaction_rules: ["invalid_recurring_rule"]}) + end + end + end + end + + context "when recurring rule paid credits exceeds wallet limits", :premium do + let(:params) do + { + id: wallet.id, + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "weekly", + method: "fixed", + paid_credits: "1000", + granted_credits: "0" + } + ] + } + end + + before { wallet.update!(paid_top_up_max_amount_cents: 1) } + + it "returns an error from nested service and does not enqueue webhook" do + expect(result).to be_failure + expect(result.error.messages[:recurring_transaction_rules]).to eq(["invalid_recurring_rule"]) + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "with limitations" do + let(:limitations) do + { + fee_types: %w[charge] + } + end + let(:params) do + { + id: wallet.id, + name: "new name", + applies_to: limitations + } + end + + it "creates fee limitation" do + expect(result).to be_success + expect(result.wallet.reload.name).to eq(params[:name]) + expect(result.wallet.reload.allowed_fee_types).to eq(limitations[:fee_types]) + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + + context "when an empty array is sent as argument" do + let(:limitations) do + { + fee_types: [] + } + end + + it "removes fee limitations" do + expect(result).to be_success + expect(result.wallet.reload.name).to eq(params[:name]) + expect(result.wallet.reload.allowed_fee_types).to eq(limitations[:fee_types]) + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when fee type is invalid" do + let(:limitations) do + { + fee_types: %w[invalid] + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:allowed_fee_types]).to eq(["invalid_fee_types"]) + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "with new billable metric limitations" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_second) { create(:billable_metric, organization:) } + let(:wallet_target) { create(:wallet_target, wallet:, billable_metric:) } + let(:limitations) do + { + billable_metric_ids: [billable_metric.id, billable_metric_second.id] + } + end + + before do + CurrentContext.source = "graphql" + + billable_metric_second + wallet_target + end + + it "creates new wallet target" do + expect { subject }.to change(WalletTarget, :count).by(1) + end + + context "with API context" do + let(:limitations) do + { + billable_metric_codes: [billable_metric.code, billable_metric_second.code] + } + end + + before { CurrentContext.source = "api" } + + it "creates new wallet target" do + expect { subject }.to change(WalletTarget, :count).by(1) + end + end + + context "with invalid billable metric" do + let(:limitations) do + { + billable_metric_ids: [billable_metric.id, billable_metric_second.id, "invalid"] + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:billable_metrics]).to eq(["invalid_identifier"]) + end + end + end + + context "with wallet targets to delete" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:wallet_target) { create(:wallet_target, wallet:, billable_metric:) } + let(:limitations) do + { + billable_metric_ids: [] + } + end + + before do + CurrentContext.source = "graphql" + + wallet_target + end + + it "deletes a wallet target" do + expect { subject }.to change(WalletTarget, :count).by(-1) + end + end + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, organization:, customer:) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + let(:params) do + { + id: wallet.id, + name: "new name", + payment_method: payment_method_params + } + end + + before { payment_method } + + it "attaches payment_method" do + expect(result).to be_success + expect(result.wallet.reload.name).to eq(params[:name]) + expect(result.wallet.reload.payment_method_id).to eq(payment_method_params[:payment_method_id]) + expect(result.wallet.reload.payment_method_type).to eq(payment_method_params[:payment_method_type]) + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + + context "when payment method is already attached" do + before do + wallet.payment_method = payment_method + wallet.payment_method_type = "provider" + end + + let(:payment_method_params) do + { + payment_method_id: nil, + payment_method_type: "provider" + } + end + + it "removes payment_method" do + expect(result).to be_success + expect(result.wallet.reload.name).to eq(params[:name]) + expect(result.wallet.reload.payment_method_id).to eq(nil) + expect(result.wallet.reload.payment_method_type).to eq("provider") + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when payment method type is not correct" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "when payment method id is not correct" do + let(:payment_method_params) do + { + payment_method_id: "123", + payment_method_type: "provider" + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + + context "when updating code" do + let(:params) do + { + id: wallet.id, + code: "updated_code", + priority: 5 + } + end + + it "updates the wallet code" do + expect(result).to be_success + expect(result.wallet.code).to eq("updated_code") + end + end + + context "when code is not provided in params" do + let(:wallet) { create(:wallet, customer:, code: "existing_code") } + let(:params) do + { + id: wallet.id, + name: "updated name", + priority: 5 + } + end + + it "keeps the existing code" do + expect(result).to be_success + expect(result.wallet.code).to eq("existing_code") + end + end + + context "when updating values to nil" do + let(:params) do + { + id: wallet&.id, + name: nil, + priority: nil, + code: nil, + expiration_at: nil, + invoice_requires_successful_payment: nil, + paid_top_up_min_amount_cents: nil, + paid_top_up_max_amount_cents: nil + } + end + + it "doesn't fail and only updates not required values" do + expect(result).to be_success + expect(result.wallet.priority).not_to eq(nil) + expect(result.wallet.code).not_to eq(nil) + expect(result.wallet.invoice_requires_successful_payment).not_to eq(nil) + + expect(result.wallet.name).to eq(nil) + expect(result.wallet.expiration_at).to eq(nil) + expect(result.wallet.paid_top_up_min_amount_cents).to eq(nil) + expect(result.wallet.paid_top_up_max_amount_cents).to eq(nil) + end + end + + context "when updating code to a value already taken for the customer" do + before do + create(:wallet, customer:, code: "taken_code") + end + + let(:params) do + { + id: wallet.id, + code: "taken_code", + priority: 5 + } + end + + it "returns an error" do + expect(result).not_to be_success + expect(result.error.messages[:code]).to eq(["value_already_exist"]) + end + end + + context "with metadata" do + let(:params) do + { + id: wallet.id, + name: "new name", + priority: 5, + metadata: {"foo" => "bar", "baz" => "qux"} + } + end + + it "creates metadata" do + expect(result).to be_success + expect(result.wallet.metadata).to be_present + expect(result.wallet.metadata.value).to eq({"foo" => "bar", "baz" => "qux"}) + end + + context "when wallet has existing metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {"old" => "value", "foo" => "old"}) } + + it "replaces all metadata" do + expect(result).to be_success + expect(result.wallet.metadata.value).to eq({"foo" => "bar", "baz" => "qux"}) + end + end + + context "when partial_metadata is true" do + subject(:result) { described_class.call(wallet:, params:, partial_metadata: true) } + + context "when wallet has existing metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {"old" => "value", "foo" => "old"}) } + + it "merges metadata" do + expect(result).to be_success + expect(result.wallet.metadata.value).to eq({"old" => "value", "foo" => "bar", "baz" => "qux"}) + end + end + end + + context "when metadata is nil" do + let(:params) do + { + id: wallet.id, + name: "new name", + priority: 5, + metadata: nil + } + end + + context "when wallet has existing metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {"old" => "value"}) } + + it "deletes all metadata" do + expect(result).to be_success + expect(result.wallet.reload.metadata).to be_nil + end + end + end + end + end +end diff --git a/spec/services/wallets/validate_limitations_service_spec.rb b/spec/services/wallets/validate_limitations_service_spec.rb new file mode 100644 index 0000000..9028eea --- /dev/null +++ b/spec/services/wallets/validate_limitations_service_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::ValidateLimitationsService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:args) do + { + applies_to: limitations + } + end + + describe ".valid?" do + context "when there is no applies_to attribute" do + let(:args) do + {} + end + + it "returns true" do + expect(validate_service).to be_valid + end + end + + context "when there is wrong fee type" do + let(:limitations) do + { + fee_types: %w[invalid_fee_type charge] + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:allowed_fee_types]).to eq(["invalid_fee_types"]) + end + end + + context "with billable metric limitations" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_identifiers) { [billable_metric.id, "invalid"] } + let(:limitations) do + { + billable_metric_ids: billable_metric_identifiers + } + end + + before do + result.billable_metrics = [billable_metric] + result.billable_metric_identifiers = billable_metric_identifiers + end + + it "returns false and result has errors if BM is invalid" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:billable_metrics]).to eq(["invalid_identifier"]) + end + end + + context "when limitations are valid" do + let(:limitations) do + { + fee_types: %w[charge subscription] + } + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + end +end diff --git a/spec/services/wallets/validate_recurring_transaction_rules_service_spec.rb b/spec/services/wallets/validate_recurring_transaction_rules_service_spec.rb new file mode 100644 index 0000000..ee33fd6 --- /dev/null +++ b/spec/services/wallets/validate_recurring_transaction_rules_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::ValidateRecurringTransactionRulesService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:args) do + { + recurring_transaction_rules: rules + } + end + + describe ".valid?" do + context "when there is no recurring transaction rules" do + let(:args) do + {} + end + + it "returns true" do + expect(validate_service).to be_valid + end + end + + context "when there is wrong number of recurring transaction rules" do + let(:rules) do + [ + { + trigger: "interval", + interval: "monthly", + paid_credits: "105", + granted_credits: "105" + }, + { + trigger: "threshold", + threshold_credits: "1.0", + paid_credits: "105", + granted_credits: "105" + } + ] + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:recurring_transaction_rules]).to eq(["invalid_number_of_recurring_rules"]) + end + end + end +end diff --git a/spec/services/wallets/validate_service_spec.rb b/spec/services/wallets/validate_service_spec.rb new file mode 100644 index 0000000..6aa62d2 --- /dev/null +++ b/spec/services/wallets/validate_service_spec.rb @@ -0,0 +1,385 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Wallets::ValidateService do + subject(:validate_service) { described_class.new(result, **args) } + + let(:result) { BaseService::Result.new } + let(:membership) { create(:membership) } + let(:organization) { membership.organization } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, customer:) } + let(:customer_id) { customer.external_id } + let(:paid_credits) { "1.00" } + let(:granted_credits) { "0.00" } + let(:expiration_at) { (Time.current + 1.year).iso8601 } + let(:args) do + { + customer:, + organization_id: organization.id, + paid_credits:, + granted_credits:, + expiration_at: + } + end + + before { subscription } + + describe ".valid?" do + it "returns true" do + expect(validate_service).to be_valid + end + + context "when organization_id is blank" do + let(:args) do + { + customer:, + organization_id: nil, + paid_credits:, + granted_credits:, + expiration_at: + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:organization_id]).to eq(["blank"]) + end + end + + context "when organization_id does not match customer's organization_id" do + let(:other_organization) { create(:organization) } + let(:args) do + { + customer:, + organization_id: other_organization.id, + paid_credits:, + granted_credits:, + expiration_at: + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:organization_id]).to eq(["invalid"]) + end + end + + context "when customer does not exist" do + let(:args) do + { + customer: nil, + organization_id: organization.id, + paid_credits:, + granted_credits: + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:customer]).to eq(["customer_not_found"]) + end + end + + context "with invalid paid_credits" do + let(:paid_credits) { "foobar" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:paid_credits]).to eq(["invalid_paid_credits", "invalid_amount"]) + end + end + + context "with invalid granted_credits" do + let(:granted_credits) { "foobar" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:granted_credits]).to eq(["invalid_granted_credits", "invalid_amount"]) + end + end + + context "with invalid expiration_at" do + context "when string cannot be parsed to date" do + let(:expiration_at) { "invalid" } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + end + end + + context "when expiration_at is an integer" do + let(:expiration_at) { 123 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + end + end + + context "when expiration_at is in the past" do + let(:expiration_at) { (Time.current - 1.hour).iso8601 } + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:expiration_at]).to eq(["invalid_date"]) + end + end + + context "when expiration_at is a valid datetime string but in the future" do + let(:expiration_at) { (Time.current + 1.hour).iso8601 } + + it "returns true and has no errors" do + expect(validate_service).to be_valid + end + end + end + + context "with invalid transaction metadata" do + let(:args) do + { + customer:, + organization_id: organization.id, + paid_credits:, + granted_credits:, + expiration_at:, + transaction_metadata: [{key: "valid key", value1: "invalid value"}] + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:metadata]).to eq(["invalid_key_value_pair"]) + end + end + + context "with recurring transaction rules" do + let(:rules) do + [ + { + trigger: "interval", + interval: "monthly" + }, + { + trigger: "threshold", + threshold_credits: "-1.0" + } + ] + end + let(:args) do + { + customer:, + organization_id: organization.id, + paid_credits:, + granted_credits:, + recurring_transaction_rules: rules + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:recurring_transaction_rules]).to eq(["invalid_number_of_recurring_rules"]) + end + end + + context "with limitations" do + let(:limitations) do + { + fee_types: %w[invalid charge] + } + end + let(:args) do + { + customer:, + organization_id: organization.id, + paid_credits:, + granted_credits:, + applies_to: limitations + } + end + + it "returns false and result has errors if fee type is invalid" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:applies_to]).to eq(["invalid_limitations"]) + end + + context "with billable metric limitations" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:billable_metric_identifiers) { [billable_metric.id, "invalid"] } + let(:limitations) do + { + billable_metric_ids: billable_metric_identifiers + } + end + + before do + result.billable_metrics = [billable_metric] + result.billable_metric_identifiers = billable_metric_identifiers + end + + it "returns false and result has errors if BM is invalid" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:applies_to]).to eq(["invalid_limitations"]) + end + end + + context "when limitations are valid" do + let(:limitations) do + { + fee_types: %w[charge] + } + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + end + + context "with multiple wallets" do + let(:max_wallets_limit) { Wallets::ValidateService::MAXIMUM_WALLETS_PER_CUSTOMER } + + describe "maximum wallets per customer" do + it "is 6" do + expect(max_wallets_limit).to eq(6) + end + end + + context "when number of wallets less than limit" do + before do + create_list(:wallet, max_wallets_limit - 2, customer:) + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "when number of wallets equals limit" do + before do + create_list(:wallet, max_wallets_limit - 1, customer:) + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "when number of wallets exceeds limit" do + before do + create_list(:wallet, max_wallets_limit, customer:) + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:customer]).to eq(["wallet_limit_reached"]) + end + end + + context "when org has setting of having more wallets per customer" do + before do + organization.update!(max_wallets: max_wallets_limit + 2) + end + + context "when events_targeting_wallets premium integration is not enabled" do + before do + create_list(:wallet, max_wallets_limit + 1, customer:) + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:customer]).to eq(["wallet_limit_reached"]) + end + end + + context "when events_targeting_wallets premium integration is enabled", :premium do + before do + organization.update!(premium_integrations: ["events_targeting_wallets"]) + create_list(:wallet, max_wallets_limit + 1, customer:) + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + end + end + + context "with payment method" do + let(:payment_method) { create(:payment_method, customer:, organization:) } + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "provider" + } + end + let(:args) do + { + customer:, + organization_id: organization.id, + paid_credits:, + granted_credits:, + payment_method: payment_method_params + } + end + + context "when provider payment method is valid" do + before do + result.payment_method = payment_method + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "when manual payment method is valid" do + let(:payment_method_params) do + { + payment_method_type: "manual" + } + end + + it "returns true and result has no errors" do + expect(validate_service).to be_valid + expect(result.error).to be_nil + end + end + + context "with invalid payment method type" do + let(:payment_method_params) do + { + payment_method_id: payment_method.id, + payment_method_type: "invalid" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + + context "with invalid payment method reference" do + let(:payment_method_params) do + { + payment_method_id: "invalid", + payment_method_type: "provider" + } + end + + it "returns false and result has errors" do + expect(validate_service).not_to be_valid + expect(result.error.messages[:payment_method]).to eq(["invalid_payment_method"]) + end + end + end + end +end diff --git a/spec/services/webhook_endpoints/create_service_spec.rb b/spec/services/webhook_endpoints/create_service_spec.rb new file mode 100644 index 0000000..43bc93a --- /dev/null +++ b/spec/services/webhook_endpoints/create_service_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WebhookEndpoints::CreateService do + subject(:create_service) { described_class.new(organization:, params: create_params) } + + let(:organization) { create(:organization) } + let(:create_params) do + { + webhook_url: "http://foo.bar", + signature_algo: "hmac", + name: "Test Webhook", + event_types: ["customer.created"] + } + end + + describe ".call" do + it "creates the webhook endpoint" do + result = create_service.call + + aggregate_failures do + expect(result).to be_success + expect(result.webhook_endpoint.webhook_url).to eq("http://foo.bar") + expect(result.webhook_endpoint.signature_algo).to eq("hmac") + expect(result.webhook_endpoint.name).to eq("Test Webhook") + expect(result.webhook_endpoint.event_types).to eq(["customer.created"]) + end + end + + context "when creating with partial params" do + let(:create_params) do + { + webhook_url: "http://foo.bar", + signature_algo: "hmac" + } + end + + it "adds only the provided fields" do + result = create_service.call + + aggregate_failures do + expect(result).to be_success + # added fields + expect(result.webhook_endpoint.webhook_url).to eq("http://foo.bar") + expect(result.webhook_endpoint.signature_algo).to eq("hmac") + # default fields + expect(result.webhook_endpoint.name).to be_nil + expect(result.webhook_endpoint.event_types).to be_nil + end + end + end + + context "when webhook url is invalid" do + let(:create_params) do + { + webhook_url: "foobar" + } + end + + it "returns a validation failure" do + result = create_service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error.class).to eq(BaseService::ValidationFailure) + end + end + end + + context "when event types are invalid" do + let(:create_params) do + { + webhook_url: "http://foo.bar", + event_types: ["invalid.event"] + } + end + + it "returns a validation failure" do + result = create_service.call + + aggregate_failures do + expect(result).not_to be_success + expect(result.error.class).to eq(BaseService::ValidationFailure) + end + end + end + end +end diff --git a/spec/services/webhook_endpoints/destroy_service_spec.rb b/spec/services/webhook_endpoints/destroy_service_spec.rb new file mode 100644 index 0000000..89f50d5 --- /dev/null +++ b/spec/services/webhook_endpoints/destroy_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WebhookEndpoints::DestroyService do + subject(:destroy_service) { described_class.new(webhook_endpoint:) } + + include_context "with mocked security logger" + + context "when endpoint exists" do + # rubocop: disable RSpec/LetSetup + let!(:webhook_endpoint) { create(:webhook_endpoint) } + # rubocop: enable RSpec/LetSetup + + it "destroys the webhook endpoint" do + expect { destroy_service.call }.to change(WebhookEndpoint, :count).by(-1) + end + + it_behaves_like "produces a security log", "webhook_endpoint.deleted" do + before { destroy_service.call } + end + end + + context "when webhook endpoint does not exist" do + let(:webhook_endpoint) { nil } + + it "returns a not found error" do + result = destroy_service.call + + expect(result).not_to be_success + expect(result.error.message).to eq("webhook_endpoint_not_found") + end + + it_behaves_like "does not produce a security log" do + before { destroy_service.call } + end + end +end diff --git a/spec/services/webhook_endpoints/update_service_spec.rb b/spec/services/webhook_endpoints/update_service_spec.rb new file mode 100644 index 0000000..873f409 --- /dev/null +++ b/spec/services/webhook_endpoints/update_service_spec.rb @@ -0,0 +1,117 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe WebhookEndpoints::UpdateService do + subject(:update_service) { described_class.new(id: webhook_endpoint.id, organization:, params: update_params) } + + include_context "with mocked security logger" + + let(:organization) { create(:organization) } + let!(:webhook_endpoint) { create(:webhook_endpoint, organization:, name: "Original Webhook", event_types: ["customer.created"]) } + let(:update_params) do + { + webhook_url: "http://foo.bar", + signature_algo: "hmac", + name: "Updated Webhook", + event_types: ["customer.updated"] + } + end + + describe ".call" do + it "updates the webhook endpoint" do + result = update_service.call + + expect(result).to be_success + expect(result.webhook_endpoint.webhook_url).to eq("http://foo.bar") + expect(result.webhook_endpoint.signature_algo).to eq("hmac") + expect(result.webhook_endpoint.name).to eq("Updated Webhook") + expect(result.webhook_endpoint.event_types).to eq(["customer.updated"]) + end + + context "when updating with partial params" do + let(:update_params) do + { + webhook_url: "http://foo.bar", + signature_algo: "hmac" + } + end + + it "updates only the provided fields" do + result = update_service.call + + expect(result).to be_success + # updated fields + expect(result.webhook_endpoint.webhook_url).to eq("http://foo.bar") + expect(result.webhook_endpoint.signature_algo).to eq("hmac") + # unchanged fields + expect(result.webhook_endpoint.name).to eq("Original Webhook") + expect(result.webhook_endpoint.event_types).to eq(["customer.created"]) + end + end + + it_behaves_like "produces a security log", "webhook_endpoint.updated" do + before { update_service.call } + end + + context "when webhook endpoint does not exist" do + let(:webhook_endpoint) { instance_double(WebhookEndpoint, id: "123456") } + + it "returns a not found error" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.message).to eq("webhook_endpoint_not_found") + end + + it_behaves_like "does not produce a security log" do + before { update_service.call } + end + end + + context "when webhook url is invalid" do + let(:update_params) do + { + webhook_url: "foobar" + } + end + + it "returns a validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.class).to eq(BaseService::ValidationFailure) + end + + it_behaves_like "does not produce a security log" do + before { update_service.call } + end + end + + context "when event types are invalid" do + let(:update_params) do + { + event_types: ["invalid.event"] + } + end + + it "returns a validation failure" do + result = update_service.call + + expect(result).not_to be_success + expect(result.error.class).to eq(BaseService::ValidationFailure) + end + end + + context "when event_types is explicitly set to null" do + let(:update_params) { {event_types: nil} } + + it "nullifies event_types" do + result = update_service.call + + expect(result).to be_success + expect(result.webhook_endpoint.event_types).to eq(nil) + end + end + end +end diff --git a/spec/services/webhooks/base_service_spec.rb b/spec/services/webhooks/base_service_spec.rb new file mode 100644 index 0000000..beda464 --- /dev/null +++ b/spec/services/webhooks/base_service_spec.rb @@ -0,0 +1,172 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::BaseService do + subject(:webhook_service) { WebhooksSpec::DummyClass.new(object:) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:object) { invoice } + let(:previous_webhook) { nil } + + describe ".call" do + it "creates a pending webhook" do + webhook_service.call + + webhook = Webhook.order(created_at: :desc).first + + expect(webhook.status).to eq("pending") + expect(webhook.retries).to be_zero + expect(webhook.webhook_type).to eq("dummy.test") + expect(webhook.endpoint).to eq(webhook.webhook_endpoint.webhook_url) + expect(webhook.object_id).to eq(invoice.id) + expect(webhook.object_type).to eq("Invoice") + expect(webhook.http_status).to be_nil + expect(webhook.response).to be_nil + expect(webhook.payload.keys).to eq %w[webhook_type object_type organization_id dummy] + end + + context "when organization has one webhook endpoint" do + it "enqueues one http job" do + webhook_service.call + + expect(SendHttpWebhookJob).to have_been_enqueued.once + end + end + + context "when organization has 2 webhook endpoints" do + it "calls 2 webhooks" do + create(:webhook_endpoint, organization:) + object.reload + webhook_service.call + + expect(SendHttpWebhookJob).to have_been_enqueued.twice + end + end + + context "without webhook endpoint" do + let(:organization) { create(:organization) } + + before do + organization.webhook_endpoints.destroy_all + end + + it "does not create the webhook model" do + webhook_service.call + + expect(SendHttpWebhookJob).not_to have_been_enqueued + expect(Webhook.where(object: invoice)).not_to exist + end + end + + context "with deleted webhook endpoint" do + before do + endpoint = create(:webhook_endpoint, organization:) + + # Preload the webhook end-points + invoice.organization.webhook_endpoints + + # Manually delete the first endpoint to simulate the race condition + Organization.connection.execute("DELETE FROM webhook_endpoints WHERE id = '#{endpoint.id}'") + end + + it "creates only one webhook" do + expect { webhook_service.call }.to change(Webhook, :count).by(1) + + expect(SendHttpWebhookJob).to have_been_enqueued.once + end + end + + context "with event filtering enabled" do + context "when event type does not match" do + before do + webhook_endpoint = organization.webhook_endpoints.first + webhook_endpoint.event_types = ["other.type"] + webhook_endpoint.save(validate: false) # disable validation because "other.type" isn't a correct event type + object.reload + end + + it "does not create the webhook model" do + webhook_service.call + + expect(SendHttpWebhookJob).not_to have_been_enqueued + expect(Webhook.where(object: invoice)).not_to exist + end + end + + context "when event type matches" do + before do + webhook_endpoint = organization.webhook_endpoints.first + webhook_endpoint.event_types = ["dummy.test"] + webhook_endpoint.save(validate: false) # disable validation because "dummy.test" isn't a correct event type + object.reload + end + + it "creates the webhook model" do + webhook_service.call + + expect(SendHttpWebhookJob).to have_been_enqueued.once + expect(Webhook.where(object: invoice)).to exist + end + end + + context "when event_types doesn't contain any types" do + before do + webhook_endpoint = organization.webhook_endpoints.first + webhook_endpoint.event_types = [] + webhook_endpoint.save! + object.reload + end + + it "does not create the webhook model" do + webhook_service.call + + expect(SendHttpWebhookJob).not_to have_been_enqueued + expect(Webhook.where(object: invoice)).not_to exist + end + end + + context "when event type is null" do + before do + webhook_endpoint = organization.webhook_endpoints.first + webhook_endpoint.event_types = nil + webhook_endpoint.save! + object.reload + end + + it "creates the webhook model" do + webhook_service.call + + expect(SendHttpWebhookJob).to have_been_enqueued.once + expect(Webhook.where(object: invoice)).to exist + end + end + end + end +end + +module WebhooksSpec + class DummyClass < Webhooks::BaseService + def current_organization + @current_organization ||= object.organization + end + + def object_serializer + ::V1::InvoiceSerializer.new( + object, + root_name: "invoice" + ) + end + + def webhook_type + "dummy.test" + end + + def object_type + "dummy" + end + end +end diff --git a/spec/services/webhooks/credit_notes/created_service_spec.rb b/spec/services/webhooks/credit_notes/created_service_spec.rb new file mode 100644 index 0000000..cea5a3f --- /dev/null +++ b/spec/services/webhooks/credit_notes/created_service_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::CreditNotes::CreatedService do + subject(:webhook_service) { described_class.new(object: credit_note) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:credit_note) { create(:credit_note, :with_metadata, customer:, invoice:) } + + describe ".call", :bullet do + it_behaves_like "creates webhook", "credit_note.created", "credit_note", { + "customer" => Hash, + "items" => [], + "metadata" => {"key" => "value"} + } + end +end diff --git a/spec/services/webhooks/credit_notes/generated_service_spec.rb b/spec/services/webhooks/credit_notes/generated_service_spec.rb new file mode 100644 index 0000000..e67780e --- /dev/null +++ b/spec/services/webhooks/credit_notes/generated_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::CreditNotes::GeneratedService do + subject(:webhook_service) { described_class.new(object: credit_note) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:credit_note) { create(:credit_note, :with_metadata, customer:, invoice:) } + + describe ".call", :bullet do + it_behaves_like "creates webhook", "credit_note.generated", "credit_note", { + "customer" => Hash, + "metadata" => {"key" => "value"} + } + end +end diff --git a/spec/services/webhooks/credit_notes/payment_provider_refund_failure_service_spec.rb b/spec/services/webhooks/credit_notes/payment_provider_refund_failure_service_spec.rb new file mode 100644 index 0000000..0022012 --- /dev/null +++ b/spec/services/webhooks/credit_notes/payment_provider_refund_failure_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::CreditNotes::PaymentProviderRefundFailureService do + subject(:webhook_service) { described_class.new(object: credit_note, options: webhook_options) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, organization:, customer:) } + let(:credit_note) { create(:credit_note, customer:, invoice:) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + describe ".call" do + it_behaves_like "creates webhook", "credit_note.refund_failure", "credit_note_payment_provider_refund_error" + end +end diff --git a/spec/services/webhooks/customers/created_service_spec.rb b/spec/services/webhooks/customers/created_service_spec.rb new file mode 100644 index 0000000..386d576 --- /dev/null +++ b/spec/services/webhooks/customers/created_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Customers::CreatedService do + subject(:webhook_service) { described_class.new(object: customer) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "customer.created", "customer" + end +end diff --git a/spec/services/webhooks/customers/updated_service_spec.rb b/spec/services/webhooks/customers/updated_service_spec.rb new file mode 100644 index 0000000..cde8331 --- /dev/null +++ b/spec/services/webhooks/customers/updated_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Customers::UpdatedService do + subject(:webhook_service) { described_class.new(object: customer) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "customer.updated", "customer" + end +end diff --git a/spec/services/webhooks/customers/vies_check_service_spec.rb b/spec/services/webhooks/customers/vies_check_service_spec.rb new file mode 100644 index 0000000..06c463a --- /dev/null +++ b/spec/services/webhooks/customers/vies_check_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Customers::ViesCheckService do + subject(:webhook_service) { described_class.new(object: customer) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "customer.vies_check", "customer" + end +end diff --git a/spec/services/webhooks/dunning_campaigns/finished_service_spec.rb b/spec/services/webhooks/dunning_campaigns/finished_service_spec.rb new file mode 100644 index 0000000..f0d89d8 --- /dev/null +++ b/spec/services/webhooks/dunning_campaigns/finished_service_spec.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::DunningCampaigns::FinishedService do + subject(:webhook_service) { described_class.new(object: customer, options: webhook_options) } + + let(:customer) { create(:customer) } + let(:webhook_options) { {dunning_campaign_code: "campaign_code"} } + + it_behaves_like "creates webhook", "dunning_campaign.finished", "dunning_campaign" +end diff --git a/spec/services/webhooks/events/error_service_spec.rb b/spec/services/webhooks/events/error_service_spec.rb new file mode 100644 index 0000000..d77373e --- /dev/null +++ b/spec/services/webhooks/events/error_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Events::ErrorService do + subject(:webhook_service) { described_class.new(object: event, options:) } + + let(:organization) { create(:organization) } + let(:event) { create(:received_event, organization_id: organization.id) } + let(:options) { {error: {transaction_id: ["value_already_exist"]}} } + + describe ".call" do + it_behaves_like "creates webhook", "event.error", "event_error", {"error" => String, "event" => Hash} + end +end diff --git a/spec/services/webhooks/events/validation_errors_service_spec.rb b/spec/services/webhooks/events/validation_errors_service_spec.rb new file mode 100644 index 0000000..74e45ae --- /dev/null +++ b/spec/services/webhooks/events/validation_errors_service_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Events::ValidationErrorsService do + subject(:webhook_service) { described_class.new(object: organization, options:) } + + let(:organization) { create(:organization) } + + let(:options) do + { + errors: { + invalid_code: [SecureRandom.uuid], + missing_aggregation_property: [SecureRandom.uuid], + missing_group_key: [SecureRandom.uuid] + } + } + end + + describe ".call" do + it_behaves_like "creates webhook", "events.errors", "events_errors", { + "invalid_code" => Array, + "missing_aggregation_property" => Array, + "missing_group_key" => Array + } + end +end diff --git a/spec/services/webhooks/features/created_service_spec.rb b/spec/services/webhooks/features/created_service_spec.rb new file mode 100644 index 0000000..48169fd --- /dev/null +++ b/spec/services/webhooks/features/created_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Features::CreatedService do + subject(:webhook_service) { described_class.new(object: feature, options:) } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:options) { {} } + + describe ".call" do + it_behaves_like "creates webhook", "feature.created", "feature", { + "code" => String, + "name" => String, + "description" => String, + "privileges" => Array, + "created_at" => String + } + end +end diff --git a/spec/services/webhooks/features/deleted_service_spec.rb b/spec/services/webhooks/features/deleted_service_spec.rb new file mode 100644 index 0000000..fe50334 --- /dev/null +++ b/spec/services/webhooks/features/deleted_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Features::DeletedService do + subject(:webhook_service) { described_class.new(object: feature, options:) } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:options) { {} } + + describe ".call" do + it_behaves_like "creates webhook", "feature.deleted", "feature", { + "code" => String, + "name" => String, + "description" => String, + "privileges" => Array, + "created_at" => String + } + end +end diff --git a/spec/services/webhooks/features/updated_service_spec.rb b/spec/services/webhooks/features/updated_service_spec.rb new file mode 100644 index 0000000..97cfb5f --- /dev/null +++ b/spec/services/webhooks/features/updated_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Features::UpdatedService do + subject(:webhook_service) { described_class.new(object: feature, options:) } + + let(:organization) { create(:organization) } + let(:feature) { create(:feature, organization:) } + let(:options) { {} } + + describe ".call" do + it_behaves_like "creates webhook", "feature.updated", "feature", { + "code" => String, + "name" => String, + "description" => String, + "privileges" => Array, + "created_at" => String + } + end +end diff --git a/spec/services/webhooks/fees/pay_in_advance_created_service_spec.rb b/spec/services/webhooks/fees/pay_in_advance_created_service_spec.rb new file mode 100644 index 0000000..f4af246 --- /dev/null +++ b/spec/services/webhooks/fees/pay_in_advance_created_service_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Fees::PayInAdvanceCreatedService do + subject(:webhook_service) { described_class.new(object: fee) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:fee) { create(:fee, customer:, subscription:, presentation_breakdowns: [build(:presentation_breakdown)]) } + + describe ".call" do + it_behaves_like "creates webhook", "fee.created", "fee", {"amount_cents" => Integer, "presentation_breakdowns" => [{"presentation_by" => {"department" => "engineering"}, "units" => "60.0"}]} + end +end diff --git a/spec/services/webhooks/integrations/accounting_customer_created_service_spec.rb b/spec/services/webhooks/integrations/accounting_customer_created_service_spec.rb new file mode 100644 index 0000000..518b093 --- /dev/null +++ b/spec/services/webhooks/integrations/accounting_customer_created_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Integrations::AccountingCustomerCreatedService do + subject(:webhook_service) { described_class.new(object: customer) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe ".call" do + it_behaves_like "creates webhook", + "customer.accounting_provider_created", + "customer", + {"integration_customers" => []} + end +end diff --git a/spec/services/webhooks/integrations/accounting_customer_error_service_spec.rb b/spec/services/webhooks/integrations/accounting_customer_error_service_spec.rb new file mode 100644 index 0000000..567736c --- /dev/null +++ b/spec/services/webhooks/integrations/accounting_customer_error_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Integrations::AccountingCustomerErrorService do + subject(:webhook_service) { described_class.new(object: customer, options: webhook_options) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + describe ".call" do + it_behaves_like "creates webhook", "customer.accounting_provider_error", "accounting_provider_customer_error" + end +end diff --git a/spec/services/webhooks/integrations/crm_customer_created_service_spec.rb b/spec/services/webhooks/integrations/crm_customer_created_service_spec.rb new file mode 100644 index 0000000..a7f00c3 --- /dev/null +++ b/spec/services/webhooks/integrations/crm_customer_created_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Integrations::CrmCustomerCreatedService do + subject(:webhook_service) { described_class.new(object: customer) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe ".call" do + it_behaves_like "creates webhook", + "customer.crm_provider_created", + "customer", + {"integration_customers" => []} + end +end diff --git a/spec/services/webhooks/integrations/crm_customer_error_service_spec.rb b/spec/services/webhooks/integrations/crm_customer_error_service_spec.rb new file mode 100644 index 0000000..e4bb840 --- /dev/null +++ b/spec/services/webhooks/integrations/crm_customer_error_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Integrations::CrmCustomerErrorService do + subject(:webhook_service) { described_class.new(object: customer, options: webhook_options) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + describe ".call" do + it_behaves_like "creates webhook", "customer.crm_provider_error", "crm_provider_customer_error" + end +end diff --git a/spec/services/webhooks/integrations/provider_error_service_spec.rb b/spec/services/webhooks/integrations/provider_error_service_spec.rb new file mode 100644 index 0000000..344e07d --- /dev/null +++ b/spec/services/webhooks/integrations/provider_error_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Integrations::ProviderErrorService do + subject(:webhook_service) { described_class.new(object: integration, options: webhook_options) } + + let(:integration) { create(:netsuite_integration, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + describe ".call" do + it_behaves_like "creates webhook", "integration.provider_error", "provider_error" + end +end diff --git a/spec/services/webhooks/integrations/taxes/error_service_spec.rb b/spec/services/webhooks/integrations/taxes/error_service_spec.rb new file mode 100644 index 0000000..f3142ee --- /dev/null +++ b/spec/services/webhooks/integrations/taxes/error_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Integrations::Taxes::ErrorService do + subject(:webhook_service) { described_class.new(object: customer, options: webhook_options) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) do + { + provider_error: {message: "message", error_code: "code"} + } + end + + describe ".call" do + it_behaves_like "creates webhook", "customer.tax_provider_error", "tax_provider_customer_error" + end +end diff --git a/spec/services/webhooks/integrations/taxes/fee_error_service_spec.rb b/spec/services/webhooks/integrations/taxes/fee_error_service_spec.rb new file mode 100644 index 0000000..8fbaab8 --- /dev/null +++ b/spec/services/webhooks/integrations/taxes/fee_error_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Integrations::Taxes::FeeErrorService do + subject(:webhook_service) { described_class.new(object: integration, options: webhook_options) } + + let(:integration) { create(:anrok_integration, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) do + { + provider_error: {message: "message", error_code: "code"}, + event_transaction_id: "123", + lago_charge_id: "456" + } + end + + describe ".call" do + it_behaves_like "creates webhook", "fee.tax_provider_error", "tax_provider_fee_error" + end +end diff --git a/spec/services/webhooks/invoices/created_service_spec.rb b/spec/services/webhooks/invoices/created_service_spec.rb new file mode 100644 index 0000000..6dd02e5 --- /dev/null +++ b/spec/services/webhooks/invoices/created_service_spec.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::CreatedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + before do + create_list(:fee, 1, invoice:, presentation_breakdowns: [create(:presentation_breakdown)]) + create_list(:fee, 3, invoice:) + create_list(:credit, 4, invoice:) + end + + describe ".call" do + it_behaves_like "creates webhook", "invoice.created", "invoice", {"fees" => Array, "credits" => Array} + + it "includes presentation_breakdowns in fees" do + webhook_service.call + + webhook = Webhook.order(created_at: :desc).first + fees = webhook.payload["invoice"]["fees"] + + expect(fees).to include( + hash_including( + "presentation_breakdowns" => [ + hash_including("presentation_by" => {"department" => "engineering"}, "units" => "60.0") + ] + ), + hash_including("presentation_breakdowns" => []) + ) + end + end +end diff --git a/spec/services/webhooks/invoices/drafted_service_spec.rb b/spec/services/webhooks/invoices/drafted_service_spec.rb new file mode 100644 index 0000000..6897471 --- /dev/null +++ b/spec/services/webhooks/invoices/drafted_service_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::DraftedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + # let(:webhook_endpoint) { create(:webhook_endpoint, webhook_url:) } + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + before do + create_list(:fee, 1, invoice:) + create_list(:fee, 1, invoice:, presentation_breakdowns: [build(:presentation_breakdown)]) + create_list(:credit, 2, invoice:) + end + + describe ".call" do + it_behaves_like "creates webhook", "invoice.drafted", "invoice", {"fees" => Array, "credits" => Array} + end +end diff --git a/spec/services/webhooks/invoices/generated_service_spec.rb b/spec/services/webhooks/invoices/generated_service_spec.rb new file mode 100644 index 0000000..52b5d33 --- /dev/null +++ b/spec/services/webhooks/invoices/generated_service_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::GeneratedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:organization) { create(:organization) } + + describe ".call" do + it_behaves_like "creates webhook", "invoice.generated", "invoice", {"fees_amount_cents" => Integer} + end +end diff --git a/spec/services/webhooks/invoices/one_off_created_service_spec.rb b/spec/services/webhooks/invoices/one_off_created_service_spec.rb new file mode 100644 index 0000000..6e60356 --- /dev/null +++ b/spec/services/webhooks/invoices/one_off_created_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::OneOffCreatedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "invoice.one_off_created", "invoice" + end +end diff --git a/spec/services/webhooks/invoices/paid_credit_added_service_spec.rb b/spec/services/webhooks/invoices/paid_credit_added_service_spec.rb new file mode 100644 index 0000000..6898c6a --- /dev/null +++ b/spec/services/webhooks/invoices/paid_credit_added_service_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::PaidCreditAddedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "invoice.paid_credit_added", "invoice" + end +end diff --git a/spec/services/webhooks/invoices/payment_dispute_lost_service_spec.rb b/spec/services/webhooks/invoices/payment_dispute_lost_service_spec.rb new file mode 100644 index 0000000..fd32dc6 --- /dev/null +++ b/spec/services/webhooks/invoices/payment_dispute_lost_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::PaymentDisputeLostService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:invoice) { create(:invoice, :dispute_lost, customer:, organization:) } + + before do + create_list(:fee, 2, invoice:) + create_list(:credit, 2, invoice:) + end + + describe ".call" do + it_behaves_like "creates webhook", "invoice.payment_dispute_lost", "payment_dispute_lost", {"invoice" => Hash} + end +end diff --git a/spec/services/webhooks/invoices/payment_overdue_service_spec.rb b/spec/services/webhooks/invoices/payment_overdue_service_spec.rb new file mode 100644 index 0000000..a360ed0 --- /dev/null +++ b/spec/services/webhooks/invoices/payment_overdue_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::PaymentOverdueService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, payment_overdue: true, customer:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "invoice.payment_overdue", "invoice" + end +end diff --git a/spec/services/webhooks/invoices/payment_status_updated_service_spec.rb b/spec/services/webhooks/invoices/payment_status_updated_service_spec.rb new file mode 100644 index 0000000..b6316f2 --- /dev/null +++ b/spec/services/webhooks/invoices/payment_status_updated_service_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::PaymentStatusUpdatedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "invoice.payment_status_updated", "invoice" + end +end diff --git a/spec/services/webhooks/invoices/resynced_service_spec.rb b/spec/services/webhooks/invoices/resynced_service_spec.rb new file mode 100644 index 0000000..f1c290a --- /dev/null +++ b/spec/services/webhooks/invoices/resynced_service_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::ResyncedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "invoice.resynced", "invoice" + + it "calls the InvoiceSerializer with integration_customers included" do + serializer_instance = instance_double(V1::InvoiceSerializer) + allow(V1::InvoiceSerializer).to receive(:new).and_return(serializer_instance) + allow(serializer_instance).to receive(:serialize).and_return({}) + + webhook_service.call + + expect(V1::InvoiceSerializer).to have_received(:new).with( + invoice, + root_name: "invoice", + includes: array_including(:customer, :integration_customers, :subscriptions, :fees, :credits, :applied_taxes) + ) + end + end +end diff --git a/spec/services/webhooks/invoices/voided_service_spec.rb b/spec/services/webhooks/invoices/voided_service_spec.rb new file mode 100644 index 0000000..aa85d35 --- /dev/null +++ b/spec/services/webhooks/invoices/voided_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Invoices::VoidedService do + subject(:webhook_service) { described_class.new(object: invoice) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + + before do + create_list(:fee, 2, invoice:) + create_list(:credit, 2, invoice:) + end + + describe ".call" do + it_behaves_like "creates webhook", "invoice.voided", "invoice", {"fees" => Array, "credits" => Array} + end +end diff --git a/spec/services/webhooks/payment_providers/customer_checkout_service_spec.rb b/spec/services/webhooks/payment_providers/customer_checkout_service_spec.rb new file mode 100644 index 0000000..52f61ca --- /dev/null +++ b/spec/services/webhooks/payment_providers/customer_checkout_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentProviders::CustomerCheckoutService do + subject(:webhook_service) { described_class.new(object: customer) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe ".call" do + it_behaves_like "creates webhook", "customer.checkout_url_generated", "payment_provider_customer_checkout_url" + end +end diff --git a/spec/services/webhooks/payment_providers/customer_created_service_spec.rb b/spec/services/webhooks/payment_providers/customer_created_service_spec.rb new file mode 100644 index 0000000..5dcc510 --- /dev/null +++ b/spec/services/webhooks/payment_providers/customer_created_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentProviders::CustomerCreatedService do + subject(:webhook_service) { described_class.new(object: customer) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + + describe ".call" do + it_behaves_like "creates webhook", "customer.payment_provider_created", "customer" + end +end diff --git a/spec/services/webhooks/payment_providers/customer_error_service_spec.rb b/spec/services/webhooks/payment_providers/customer_error_service_spec.rb new file mode 100644 index 0000000..b3fd655 --- /dev/null +++ b/spec/services/webhooks/payment_providers/customer_error_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentProviders::CustomerErrorService do + subject(:webhook_service) { described_class.new(object: customer, options: webhook_options) } + + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + describe ".call" do + it_behaves_like "creates webhook", "customer.payment_provider_error", "payment_provider_customer_error" + end +end diff --git a/spec/services/webhooks/payment_providers/error_service_spec.rb b/spec/services/webhooks/payment_providers/error_service_spec.rb new file mode 100644 index 0000000..d17dd7e --- /dev/null +++ b/spec/services/webhooks/payment_providers/error_service_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentProviders::ErrorService do + subject(:webhook_service) { described_class.new(object: payment_provider, options: webhook_options) } + + let(:payment_provider) { create(:stripe_provider, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code", source: "stripe", action: "payment_provider.register_webhook"}} } + + it_behaves_like "creates webhook", "payment_provider.error", "payment_provider_error" +end diff --git a/spec/services/webhooks/payment_providers/invoice_payment_failure_service_spec.rb b/spec/services/webhooks/payment_providers/invoice_payment_failure_service_spec.rb new file mode 100644 index 0000000..beff78f --- /dev/null +++ b/spec/services/webhooks/payment_providers/invoice_payment_failure_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentProviders::InvoicePaymentFailureService do + subject(:webhook_service) { described_class.new(object: invoice, options: webhook_options) } + + let(:invoice) { create(:invoice, customer:, organization:) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + describe ".call" do + it_behaves_like "creates webhook", "invoice.payment_failure", "payment_provider_invoice_payment_error" + end +end diff --git a/spec/services/webhooks/payment_providers/payment_request_payment_failure_service_spec.rb b/spec/services/webhooks/payment_providers/payment_request_payment_failure_service_spec.rb new file mode 100644 index 0000000..f925177 --- /dev/null +++ b/spec/services/webhooks/payment_providers/payment_request_payment_failure_service_spec.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentProviders::PaymentRequestPaymentFailureService do + subject(:webhook_service) { described_class.new(object: payment_request, options: webhook_options) } + + let(:payment_request) { create(:payment_request, organization:, customer:) } + let(:customer) { create(:customer, organization:) } + let(:organization) { create(:organization) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + describe ".call" do + it_behaves_like "creates webhook", "payment_request.payment_failure", "payment_provider_payment_request_payment_error" + end +end diff --git a/spec/services/webhooks/payment_providers/wallet_transaction_payment_failure_service_spec.rb b/spec/services/webhooks/payment_providers/wallet_transaction_payment_failure_service_spec.rb new file mode 100644 index 0000000..58582ad --- /dev/null +++ b/spec/services/webhooks/payment_providers/wallet_transaction_payment_failure_service_spec.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentProviders::WalletTransactionPaymentFailureService do + subject(:webhook_service) { described_class.new(object: wallet_transaction, options: webhook_options) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:, organization:) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + let(:webhook_options) { {provider_error: {message: "message", error_code: "code"}} } + + before do + wallet_transaction + end + + describe ".call" do + it_behaves_like "creates webhook", "wallet_transaction.payment_failure", "payment_provider_wallet_transaction_payment_error" + end +end diff --git a/spec/services/webhooks/payment_receipts/created_service_spec.rb b/spec/services/webhooks/payment_receipts/created_service_spec.rb new file mode 100644 index 0000000..125e40a --- /dev/null +++ b/spec/services/webhooks/payment_receipts/created_service_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentReceipts::CreatedService do + subject(:webhook_service) { described_class.new(object: payment_receipt) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment) { create(:payment, payable: invoice) } + let(:payment_receipt) { create(:payment_receipt, payment:) } + + describe ".call" do + it_behaves_like "creates webhook", "payment_receipt.created", "payment_receipt", {"payment" => Hash} + end +end diff --git a/spec/services/webhooks/payment_receipts/generated_service_spec.rb b/spec/services/webhooks/payment_receipts/generated_service_spec.rb new file mode 100644 index 0000000..8593110 --- /dev/null +++ b/spec/services/webhooks/payment_receipts/generated_service_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentReceipts::GeneratedService do + subject(:webhook_service) { described_class.new(object: payment_receipt) } + + let(:customer) { create(:customer, organization:) } + let(:subscription) { create(:subscription, organization:, customer:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:payment) { create(:payment, payable: invoice) } + let(:payment_receipt) { create(:payment_receipt, payment:) } + let(:organization) { create(:organization) } + + describe ".call" do + it_behaves_like "creates webhook", "payment_receipt.generated", "payment_receipt", {"payment" => Hash} + end +end diff --git a/spec/services/webhooks/payment_requests/created_service_spec.rb b/spec/services/webhooks/payment_requests/created_service_spec.rb new file mode 100644 index 0000000..8b83818 --- /dev/null +++ b/spec/services/webhooks/payment_requests/created_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentRequests::CreatedService do + subject(:webhook_service) { described_class.new(object: payment_request) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:payment_request) { create(:payment_request, customer:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "payment_request.created", "payment_request", {"customer" => Hash, "invoices" => Array} + end +end diff --git a/spec/services/webhooks/payment_requests/payment_status_updated_service_spec.rb b/spec/services/webhooks/payment_requests/payment_status_updated_service_spec.rb new file mode 100644 index 0000000..c3d4325 --- /dev/null +++ b/spec/services/webhooks/payment_requests/payment_status_updated_service_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::PaymentRequests::PaymentStatusUpdatedService do + subject(:webhook_service) { described_class.new(object: payment_request) } + + let(:payment_request) { create(:payment_request) } + + describe ".call" do + it_behaves_like "creates webhook", "payment_request.payment_status_updated", "payment_request" + end +end diff --git a/spec/services/webhooks/payments/requires_action_service_spec.rb b/spec/services/webhooks/payments/requires_action_service_spec.rb new file mode 100644 index 0000000..6c0d6bd --- /dev/null +++ b/spec/services/webhooks/payments/requires_action_service_spec.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Payments::RequiresActionService do + subject(:webhook_service) { described_class.new(object: payment, options: webhook_options) } + + let(:payment) { create(:payment, :requires_action) } + let(:webhook_options) { {provider_customer_id: "customer_id"} } + + it_behaves_like "creates webhook", "payment.requires_action", "payment", { + "lago_id" => String, + "lago_payable_id" => String, + "payable_type" => String, + "payment_provider_code" => String, + "provider_payment_id" => String, + "next_action" => Hash + } +end diff --git a/spec/services/webhooks/payments/succeeded_service_spec.rb b/spec/services/webhooks/payments/succeeded_service_spec.rb new file mode 100644 index 0000000..4e6f69d --- /dev/null +++ b/spec/services/webhooks/payments/succeeded_service_spec.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Payments::SucceededService do + subject(:webhook_service) { described_class.new(object: payment) } + + let(:payment) do + create( + :payment, + payable_payment_status: :succeeded, + payment_method: payment_method + ) + end + let(:payment_method) { create(:payment_method) } + + it_behaves_like "creates webhook", "payment.succeeded", "payment", { + "lago_id" => String, + "lago_payable_id" => String, + "payable_type" => String, + "payment_provider_code" => String, + "provider_payment_id" => String, + "payment_method" => Hash + } +end diff --git a/spec/services/webhooks/plans/created_service_spec.rb b/spec/services/webhooks/plans/created_service_spec.rb new file mode 100644 index 0000000..10a3096 --- /dev/null +++ b/spec/services/webhooks/plans/created_service_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Plans::CreatedService do + subject(:webhook_service) { described_class.new(object: plan) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "plan.created", "plan", { + "code" => String, + "charges" => Array, + "usage_thresholds" => Array, + "taxes" => Array, + "entitlements" => Array + } + end +end diff --git a/spec/services/webhooks/plans/deleted_service_spec.rb b/spec/services/webhooks/plans/deleted_service_spec.rb new file mode 100644 index 0000000..2066d14 --- /dev/null +++ b/spec/services/webhooks/plans/deleted_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Plans::DeletedService do + subject(:webhook_service) { described_class.new(object: plan) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "plan.deleted", "plan" + end +end diff --git a/spec/services/webhooks/plans/updated_service_spec.rb b/spec/services/webhooks/plans/updated_service_spec.rb new file mode 100644 index 0000000..0e83208 --- /dev/null +++ b/spec/services/webhooks/plans/updated_service_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Plans::UpdatedService do + subject(:webhook_service) { described_class.new(object: plan) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "plan.updated", "plan", { + "code" => String, + "charges" => Array, + "usage_thresholds" => Array, + "taxes" => Array, + "entitlements" => Array + } + end +end diff --git a/spec/services/webhooks/retry_service_spec.rb b/spec/services/webhooks/retry_service_spec.rb new file mode 100644 index 0000000..2d4a896 --- /dev/null +++ b/spec/services/webhooks/retry_service_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::RetryService do + subject(:retry_service) { described_class.new(webhook:) } + + let(:webhook) { create(:webhook, :failed) } + + it "enqueues a SendWebhookJob" do + expect { retry_service.call }.to have_enqueued_job(SendHttpWebhookJob).with(webhook) + end + + it "assigns webhook to result" do + result = retry_service.call + + expect(result.webhook.id).to eq(webhook.id) + end + + context "when webhook is not found" do + let(:webhook) { nil } + + it "returns an error" do + result = retry_service.call + + expect(result).not_to be_success + expect(result.error.error_code).to eq("webhook_not_found") + end + end + + context "when webhook is succeeded" do + let(:webhook) { create(:webhook, :succeeded) } + + it "returns an error" do + result = retry_service.call + + expect(result).not_to be_success + expect(result.error.code).to eq("is_succeeded") + end + end +end diff --git a/spec/services/webhooks/send_http_service_spec.rb b/spec/services/webhooks/send_http_service_spec.rb new file mode 100644 index 0000000..e77a351 --- /dev/null +++ b/spec/services/webhooks/send_http_service_spec.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::SendHttpService do + subject(:service) { described_class.new(webhook:) } + + let(:webhook_endpoint) { create(:webhook_endpoint, webhook_url: "https://wh.test.com") } + let(:webhook) { create(:webhook, webhook_endpoint:) } + let(:lago_client) { instance_double(LagoHttpClient::Client) } + + around do |example| + original_value = ENV["LAGO_WEBHOOK_ATTEMPTS"] + ENV["LAGO_WEBHOOK_ATTEMPTS"] = "3" + example.run + ensure + ENV["LAGO_WEBHOOK_ATTEMPTS"] = original_value + end + + context "when client returns a success" do + before do + WebMock.stub_request(:post, "https://wh.test.com").to_return(status: 200, body: "ok") + end + + it "marks the webhook as succeeded" do + service.call + + expect(WebMock).to have_requested(:post, "https://wh.test.com").with( + body: webhook.payload.to_json, + headers: {"Content-Type" => "application/json"} + ) + expect(webhook.status).to eq "succeeded" + expect(webhook.http_status).to eq 200 + expect(webhook.response).to eq "ok" + end + end + + context "when client returns an error" do + let(:error_body) do + { + message: "forbidden" + } + end + let(:expected_timeout_seconds) { 30 } + + before do + allow(LagoHttpClient::Client).to receive(:new) + .with(webhook.webhook_endpoint.webhook_url, read_timeout: expected_timeout_seconds, write_timeout: expected_timeout_seconds, open_timeout: expected_timeout_seconds) + .and_return(lago_client) + allow(lago_client).to receive(:post_with_response).and_raise( + LagoHttpClient::HttpError.new(403, error_body.to_json, "") + ) + allow(SendHttpWebhookJob).to receive(:set).and_return(class_double(SendHttpWebhookJob, perform_later: nil)) + end + + context "when LAGO_WEBHOOK_TIMEOUT_SECONDS is set" do + let(:expected_timeout_seconds) { 45 } + + around do |example| + original_value = ENV["LAGO_WEBHOOK_TIMEOUT_SECONDS"] + ENV["LAGO_WEBHOOK_TIMEOUT_SECONDS"] = "45" + example.run + ensure + ENV["LAGO_WEBHOOK_TIMEOUT_SECONDS"] = original_value + end + + it "uses the configured timeout" do + service.call + + expect(LagoHttpClient::Client).to have_received(:new) + .with(webhook.webhook_endpoint.webhook_url, read_timeout: expected_timeout_seconds, write_timeout: expected_timeout_seconds, open_timeout: expected_timeout_seconds) + end + end + + it "creates a retrying webhook" do + service.call + + expect(webhook).to be_retrying + expect(webhook.http_status).to eq(403) + expect(SendHttpWebhookJob).to have_received(:set) + end + + context "with a retrying webhook" do + let(:webhook) { create(:webhook, :retrying, retries: 1) } + + it "fails the retried webhooks" do + service.call + + expect(webhook).to be_retrying + expect(webhook.http_status).to eq(403) + expect(webhook.retries).to eq(2) + expect(webhook.last_retried_at).not_to be_nil + expect(SendHttpWebhookJob).to have_received(:set) + end + + context "when the webhook failed 3 times" do + let(:webhook) { create(:webhook, :retrying, retries: 2) } + + it "stops trying and marks the webhook as failed" do + service.call + + expect(webhook).to be_failed + expect(webhook.http_status).to eq(403) + expect(webhook.reload.retries).to eq 3 + expect(SendHttpWebhookJob).not_to have_received(:set) + end + end + end + end +end diff --git a/spec/services/webhooks/subscriptions/canceled_service_spec.rb b/spec/services/webhooks/subscriptions/canceled_service_spec.rb new file mode 100644 index 0000000..9519dde --- /dev/null +++ b/spec/services/webhooks/subscriptions/canceled_service_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::CanceledService do + subject(:webhook_service) { described_class.new(object: subscription) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, :canceled, customer:, plan:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.canceled", "subscription", { + "lago_id" => String, + "external_id" => String, + "lago_customer_id" => String, + "external_customer_id" => String, + "plan_code" => String, + "status" => "canceled", + "billing_time" => String, + "created_at" => String, + "customer" => Hash, + "payment_method" => Hash + } + end +end diff --git a/spec/services/webhooks/subscriptions/incomplete_service_spec.rb b/spec/services/webhooks/subscriptions/incomplete_service_spec.rb new file mode 100644 index 0000000..0cd0669 --- /dev/null +++ b/spec/services/webhooks/subscriptions/incomplete_service_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::IncompleteService do + subject(:webhook_service) { described_class.new(object: subscription) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, :incomplete, customer:, plan:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.incomplete", "subscription", { + "lago_id" => String, + "external_id" => String, + "lago_customer_id" => String, + "external_customer_id" => String, + "plan_code" => String, + "status" => "incomplete", + "billing_time" => String, + "started_at" => String, + "created_at" => String, + "customer" => Hash, + "payment_method" => Hash + } + end +end diff --git a/spec/services/webhooks/subscriptions/started_service_spec.rb b/spec/services/webhooks/subscriptions/started_service_spec.rb new file mode 100644 index 0000000..a4c65d2 --- /dev/null +++ b/spec/services/webhooks/subscriptions/started_service_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::StartedService do + subject(:webhook_service) { described_class.new(object: subscription) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:) } + let(:subscription) { create(:subscription, customer:, plan:, organization:) } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.started", "subscription", { + "lago_id" => String, + "external_id" => String, + "lago_customer_id" => String, + "external_customer_id" => String, + "plan_code" => String, + "status" => String, + "billing_time" => String, + "started_at" => String, + "created_at" => String, + "customer" => Hash, + "entitlements" => Array, + "payment_method" => Hash + } + + context "with entitlements" do + let(:feature) { create(:feature, organization:) } + let(:privilege) { create(:privilege, feature:, organization:, value_type: "string") } + let(:plan_entitlement) { create(:entitlement, feature:, plan:, organization:) } + + before do + create(:entitlement_value, entitlement: plan_entitlement, privilege:, organization:, value: "enabled") + end + + it "includes entitlements in the payload" do + webhook_service.call + + webhook = Webhook.order(created_at: :desc).first + entitlements = webhook.payload["subscription"]["entitlements"] + + expect(entitlements).to be_a(Array) + expect(entitlements.size).to eq(1) + expect(entitlements.first).to include( + "code" => feature.code, + "name" => feature.name, + "description" => feature.description, + "privileges" => a_collection_containing_exactly( + hash_including( + "code" => privilege.code, + "value" => "enabled", + "value_type" => "string" + ) + ) + ) + end + end + end +end diff --git a/spec/services/webhooks/subscriptions/terminated_service_spec.rb b/spec/services/webhooks/subscriptions/terminated_service_spec.rb new file mode 100644 index 0000000..20bc62a --- /dev/null +++ b/spec/services/webhooks/subscriptions/terminated_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::TerminatedService do + subject(:webhook_service) { described_class.new(object: subscription) } + + let(:subscription) { create(:subscription, status: :terminated, terminated_at: Time.current) } + let(:organization) { subscription.organization } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.terminated", "subscription" + end +end diff --git a/spec/services/webhooks/subscriptions/termination_alert_service_spec.rb b/spec/services/webhooks/subscriptions/termination_alert_service_spec.rb new file mode 100644 index 0000000..8ce5f47 --- /dev/null +++ b/spec/services/webhooks/subscriptions/termination_alert_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::TerminationAlertService do + subject(:webhook_service) { described_class.new(object: subscription) } + + let(:subscription) { create(:subscription, status: :active) } + let(:organization) { subscription.organization } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.termination_alert", "subscription" + end +end diff --git a/spec/services/webhooks/subscriptions/trial_ended_service_spec.rb b/spec/services/webhooks/subscriptions/trial_ended_service_spec.rb new file mode 100644 index 0000000..a9b0523 --- /dev/null +++ b/spec/services/webhooks/subscriptions/trial_ended_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::TrialEndedService do + subject(:webhook_service) { described_class.new(object: subscription) } + + let(:subscription) { create(:subscription, plan: create(:plan, trial_period: 1)) } + let(:organization) { subscription.organization } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.trial_ended", "subscription" + end +end diff --git a/spec/services/webhooks/subscriptions/updated_service_spec.rb b/spec/services/webhooks/subscriptions/updated_service_spec.rb new file mode 100644 index 0000000..8e49c0d --- /dev/null +++ b/spec/services/webhooks/subscriptions/updated_service_spec.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::UpdatedService do + subject(:webhook_service) { described_class.new(object: subscription) } + + let(:subscription) { create(:subscription) } + let(:organization) { subscription.organization } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.updated", "subscription" + end +end diff --git a/spec/services/webhooks/subscriptions/usage_thresholds_reached_service_spec.rb b/spec/services/webhooks/subscriptions/usage_thresholds_reached_service_spec.rb new file mode 100644 index 0000000..edf4463 --- /dev/null +++ b/spec/services/webhooks/subscriptions/usage_thresholds_reached_service_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Subscriptions::UsageThresholdsReachedService do + subject(:webhook_service) { described_class.new(object: subscription, options: {usage_threshold:}) } + + let(:organization) { create(:organization) } + let(:plan) { create(:plan, organization: organization) } + let(:customer) { create(:customer, organization: organization) } + let(:subscription) { create(:subscription, plan: plan, customer: customer) } + let(:usage_threshold) { create(:usage_threshold, plan: plan) } + + describe ".call" do + it_behaves_like "creates webhook", "subscription.usage_threshold_reached", "subscription", { + "usage_threshold" => Hash, + "applicable_usage_thresholds" => Array + } + end +end diff --git a/spec/services/webhooks/usage_monitoring/alert_triggered_service_spec.rb b/spec/services/webhooks/usage_monitoring/alert_triggered_service_spec.rb new file mode 100644 index 0000000..4094c59 --- /dev/null +++ b/spec/services/webhooks/usage_monitoring/alert_triggered_service_spec.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::UsageMonitoring::AlertTriggeredService do + subject(:webhook_service) { described_class.new(object: triggered_alert) } + + let(:triggered_alert) { create(:triggered_alert) } + + describe ".call" do + it_behaves_like "creates webhook", "alert.triggered", "triggered_alert" + end +end diff --git a/spec/services/webhooks/wallet_transactions/created_service_spec.rb b/spec/services/webhooks/wallet_transactions/created_service_spec.rb new file mode 100644 index 0000000..4f846a8 --- /dev/null +++ b/spec/services/webhooks/wallet_transactions/created_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::WalletTransactions::CreatedService do + subject(:webhook_service) { described_class.new(object: wallet_transaction) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + + describe ".call" do + it_behaves_like "creates webhook", "wallet_transaction.created", "wallet_transaction", + {"lago_id" => String, "wallet" => Hash} + end +end diff --git a/spec/services/webhooks/wallet_transactions/updated_service_spec.rb b/spec/services/webhooks/wallet_transactions/updated_service_spec.rb new file mode 100644 index 0000000..ea4a541 --- /dev/null +++ b/spec/services/webhooks/wallet_transactions/updated_service_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::WalletTransactions::UpdatedService do + subject(:webhook_service) { described_class.new(object: wallet_transaction) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction) { create(:wallet_transaction, wallet:) } + + describe ".call" do + it_behaves_like "creates webhook", "wallet_transaction.updated", "wallet_transaction", + {"lago_id" => String, "wallet" => Hash} + end +end diff --git a/spec/services/webhooks/wallets/created_service_spec.rb b/spec/services/webhooks/wallets/created_service_spec.rb new file mode 100644 index 0000000..bf0d381 --- /dev/null +++ b/spec/services/webhooks/wallets/created_service_spec.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Wallets::CreatedService do + subject(:webhook_service) { described_class.new(object: wallet) } + + let(:wallet) { create(:wallet, balance_cents: 999_00) } + + describe ".call" do + it_behaves_like "creates webhook", "wallet.created", "wallet", { + "balance_cents" => 999_00, + "created_at" => String, + "terminated_at" => nil, + "recurring_transaction_rules" => [] + } + end +end diff --git a/spec/services/webhooks/wallets/depleted_ongoing_balance_service_spec.rb b/spec/services/webhooks/wallets/depleted_ongoing_balance_service_spec.rb new file mode 100644 index 0000000..74b8bf0 --- /dev/null +++ b/spec/services/webhooks/wallets/depleted_ongoing_balance_service_spec.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Wallets::DepletedOngoingBalanceService do + subject(:webhook_service) { described_class.new(object: wallet) } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:wallet) { create(:wallet, customer:) } + + describe ".call" do + it_behaves_like "creates webhook", "wallet.depleted_ongoing_balance", "wallet" + end +end diff --git a/spec/services/webhooks/wallets/terminated_service_spec.rb b/spec/services/webhooks/wallets/terminated_service_spec.rb new file mode 100644 index 0000000..c671af6 --- /dev/null +++ b/spec/services/webhooks/wallets/terminated_service_spec.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Webhooks::Wallets::TerminatedService do + subject(:webhook_service) { described_class.new(object: wallet) } + + let(:wallet) { create(:wallet, :terminated, terminated_at: Time.current) } + + before { Timecop.freeze(DateTime.new(2025, 1, 31, 12, 5, 55)) } + + describe ".call" do + it_behaves_like "creates webhook", "wallet.terminated", "wallet", { + "balance_cents" => 0, + "created_at" => "2025-01-31T12:05:55Z", + "terminated_at" => "2025-01-31T12:05:55.000Z", + "recurring_transaction_rules" => [] + } + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..6554779 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,223 @@ +# frozen_string_literal: true + +require "knapsack_pro" + +# Custom Knapsack Pro config here +KnapsackPro::Adapters::RSpecAdapter.bind + +# This file is copied to spec/ when you run 'rails generate rspec:install' +ENV["RAILS_ENV"] = "test" +require_relative "../config/environment" + +# Explicitly require monkey patches after loading dependencies. +Dir[Rails.root.join("spec/support/monkey_patches/*.rb")].sort.each { |f| require f } + +# Allow remote debugging when RUBY_DEBUG_PORT is set +if ENV["RUBY_DEBUG_PORT"] + require "debug/open_nonstop" +else + require "debug" +end + +require "webmock/rspec" +require "simplecov" +require "money-rails/test_helpers" +require "active_storage_validations/matchers" +require "karafka/testing/rspec/helpers" +require "super_diff/rspec-rails" + +DatabaseCleaner.allow_remote_database_url = true + +SimpleCov.start do + enable_coverage :branch + + add_filter %r{^/config/} + add_filter %r{^/db/} + add_filter "/spec/" + + add_group "Controllers", "app/controllers" + add_group "Models", "app/models" + add_group "Jobs", %w[app/jobs app/workers] + add_group "Services", "app/services" + add_group "GraphQL", "app/graphql" +end + +# Prevent database truncation if the environment is production +abort("The Rails environment is running in production mode!") if Rails.env.production? # rubocop:disable Rails/Exit +require "rspec/rails" +require "paper_trail/frameworks/rspec" +require "sidekiq/testing" +Sidekiq::Testing.fake! +ActiveJob::Uniqueness.test_mode! + +Dir[Rails.root.join("spec/support/**/*.rb")].sort.reject { |f| f.include?("_spec.rb") }.each { |f| require f } + +begin + ActiveRecord::Migration.check_all_pending! +rescue ActiveRecord::PendingMigrationError + FileUtils.cd(Rails.root) { system("bin/rails db:migrate:primary RAILS_ENV=test") } +end + +ENV["STRIPE_API_VERSION"] ||= "2020-08-27" + +RSpec.configure do |config| + config.include ActiveJob::TestHelper + config.include FactoryBot::Syntax::Methods + config.include GraphQLHelper, type: :graphql + config.include AdminHelper, type: :request + config.include ApiHelper, type: :request + config.include ScenariosHelper + config.include LicenseHelper + config.include PdfHelper + config.include StripeHelper + config.include QueuesHelper + config.include XMLHelper + config.include AdvisoryLockHelper + config.include ActiveSupport::Testing::TimeHelpers + config.include ActiveStorageValidations::Matchers + config.include Karafka::Testing::RSpec::Helpers + config.include GraphQL::Testing::Helpers.for(LagoApiSchema) + + # NOTE: these files make real API calls and should be excluded from build + # run them manually when needed + config.exclude_pattern = "spec/integration/**/*_integration_spec.rb" + + # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures + config.fixture_paths = [Rails.root.join("spec/fixtures").to_s] + + # If you're not using ActiveRecord, or you'd prefer not to run each of your + # examples within a transaction, assign false + config.use_transactional_fixtures = false + + config.infer_spec_type_from_file_location! + config.define_derived_metadata(file_path: Regexp.new("/spec/graphql/")) do |metadata| + metadata[:type] = :graphql + end + config.define_derived_metadata(file_path: Regexp.new("/spec/scenarios/")) do |metadata| + metadata[:type] = :request + end + + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + + config.example_status_persistence_file_path = "tmp/rspec_examples.txt" + config.filter_run_when_matching :focus + + # Filter lines from Rails gems in backtraces. + config.filter_rails_from_backtrace! + # arbitrary gems may also be filtered via: + # config.filter_gems_from_backtrace("gem name") + + config.define_derived_metadata do |meta| + unless meta.key?(:aggregate_failures) + meta[:aggregate_failures] = true + end + end + config.define_derived_metadata(file_path: Regexp.new("/spec/scenarios/")) do |metadata| + metadata[:with_pdf_generation_stub] = true unless metadata.key?(:with_pdf_generation_stub) + end + + # NOTE: Database cleaner config to turn off/on transactional mode + config.before(:suite) do |example| + # No need for `DatabaseCleaner[:active_record, db: EventsRecord].clean_with(:deletion)` + # because both connections are using the same database. + DatabaseCleaner[:active_record].clean_with(:deletion) + + # Clean Clickhouse database if any test is using it. + if RSpec.world.all_examples.any? { |ex| ex.metadata[:clickhouse] } + WebMock.disable_net_connect!(allow: ENV.fetch("LAGO_CLICKHOUSE_HOST", "clickhouse")) + DatabaseCleaner[:active_record, db: Clickhouse::BaseRecord].clean_with(:deletion) + end + + WebMock.disable_net_connect! + end + + config.include_context "with Time travel enabled", :time_travel + + config.before(:each, :with_pdf_generation_stub) do |example| + stub_pdf_generation + end + + config.before do |example| + metadata = example.metadata + + ActiveJob::Base.queue_adapter.enqueued_jobs.clear + allow(Utils::ActivityLog).to receive(:produce).and_call_original + + if (clickhouse = metadata[:clickhouse]) + WebMock.disable_net_connect!(allow: ENV.fetch("LAGO_CLICKHOUSE_HOST", "clickhouse")) + + if clickhouse.is_a?(Hash) && clickhouse[:clean_before] + DatabaseCleaner[:active_record, db: Clickhouse::BaseRecord].clean_with(:deletion) + end + end + + if metadata[:cache] + Rails.cache = if example.metadata[:cache].to_sym == :memory + ActiveSupport::Cache.lookup_store(:memory_store) + elsif metadata[:cache].to_sym == :null + ActiveSupport::Cache.lookup_store(:null_store) + elsif metadata[:cache].to_sym == :redis + ActiveSupport::Cache.lookup_store(:redis_cache_store) + else + raise "Unknown cache store: #{example.metadata[:cache]}" + end + end + end + + config.around do |example| + # Important to set the strategy in this block as otherwise the cleaning will always use the transaction strategy + strategy = if example.metadata[:transaction] == false + :deletion + else + :transaction + end + DatabaseCleaner.strategy = strategy + + # We need to set the strategy for the `events` connection as well to properly rollback changes done using the `events` connection. + # DO NOT CHANGE `:db` to `:events` as it will not work properly with `:transaction` strategy. + DatabaseCleaner[:active_record, db: EventsRecord].strategy = if strategy == :transaction + :transaction + else + # If the `deletion` strategy is used for the default connection, we don't need to set it for the `events` connection as they are using the same database. + DatabaseCleaner::NullStrategy.new + end + + # Clickhouse doesn't support transaction so we default to null strategy to skip cleanup when not needed. + DatabaseCleaner[:active_record, db: Clickhouse::BaseRecord].strategy = DatabaseCleaner::NullStrategy.new + + DatabaseCleaner.cleaning do + example.run + end + end + + config.around(:example, :premium) do |example| + lago_premium!(&example) + end + + config.around(:example, :bullet) do |example| + bullet = example.metadata[:bullet].is_a?(Hash) ? example.metadata[:bullet] : {} + + example.example_group.before do + Bullet.enable = true + Bullet.n_plus_one_query_enable = bullet.fetch(:n_plus_one_query, true) + Bullet.unused_eager_loading_enable = bullet.fetch(:unused_eager_loading, true) + Bullet.start_request + end + + example.example_group.after do + Bullet.perform_out_of_channel_notifications if Bullet.notification? + Bullet.end_request + Bullet.enable = false + end + + example.run + end +end diff --git a/spec/support/admin_helper.rb b/spec/support/admin_helper.rb new file mode 100644 index 0000000..6dedab5 --- /dev/null +++ b/spec/support/admin_helper.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec.configure do |config| + config.before(:each, type: :admin) do + allow(Google::Auth::IDTokens) + .to receive(:verify_oidc) + .and_return({email: "test@getlago.com"}) + end +end + +module AdminHelper + def admin_put(path, params = {}, headers = {}) + apply_headers(headers) + put(path, params: params.to_json, headers:) + end + + def admin_post(path, params = {}, headers = {}) + apply_headers(headers) + post(path, params: params.to_json, headers:) + end + + def admin_post_without_bearer(path, params = {}, headers = {}) + apply_headers(headers) + headers.delete("Authorization") + post(path, params: params.to_json, headers:) + end + + def json + return response.body unless response.media_type.include?("json") + + JSON.parse(response.body, symbolize_names: true) + end + + private + + def apply_headers(headers) + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + headers["Authorization"] = "Bearer 123456" + end +end diff --git a/spec/support/advisory_lock_helper.rb b/spec/support/advisory_lock_helper.rb new file mode 100644 index 0000000..00ee94c --- /dev/null +++ b/spec/support/advisory_lock_helper.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module AdvisoryLockHelper + def with_advisory_lock(lock_key, lock_released_after:) + queue = Queue.new + thread = start_lock_thread(queue, lock_key, lock_released_after) + sleep 0.5 + yield + ensure + stop_thread(thread, queue) if thread + end + + private + + def start_lock_thread(queue, lock_key, lock_released_after) + Thread.start do + start_time = Time.zone.now + ApplicationRecord.transaction do + ApplicationRecord.with_advisory_lock!(lock_key, transaction: true) do + until queue.size > 0 || Time.zone.now - start_time > lock_released_after + sleep 0.01 + end + end + end + end + end + + def stop_thread(thread, queue) + queue.push(true) + thread.join + end +end diff --git a/spec/support/api_helper.rb b/spec/support/api_helper.rb new file mode 100644 index 0000000..35220a2 --- /dev/null +++ b/spec/support/api_helper.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module ApiHelper + def get_with_token(organization, path, params = {}, headers = {}) + set_headers(organization, headers) + get(path, params:, headers:) + end + + def post_with_token(organization, path, params = {}, headers = {}) + set_headers(organization, headers) + post(path, params: params.to_json, headers:) + end + + def put_with_token(organization, path, params = {}, headers = {}) + set_headers(organization, headers) + put(path, params: params.to_json, headers:) + end + + def patch_with_token(organization, path, params = {}, headers = {}) + set_headers(organization, headers) + patch(path, params: params.to_json, headers:) + end + + def delete_with_token(organization, path, params = {}, headers = {}) + set_headers(organization, headers) + delete(path, params: params.to_json, headers:) + end + + def json + return response.body unless response.media_type.include?("json") + return {} if response.body.blank? # handle `head(:ok)` + + JSON.parse(response.body, symbolize_names: true) + end + + private + + def set_headers(organization, headers) + headers["Content-Type"] = "application/json" + headers["Accept"] = "application/json" + headers["Authorization"] = "Bearer #{organization.api_keys.first.value}" + end +end diff --git a/spec/support/email_sanitizer_spec.rb b/spec/support/email_sanitizer_spec.rb new file mode 100644 index 0000000..0b2d6ab --- /dev/null +++ b/spec/support/email_sanitizer_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EmailSanitizer do + describe ".call" do + it "returns nil-safe for blank input" do + expect(described_class.call(nil)).to be_nil + expect(described_class.call("")).to eq("") + end + + it "returns a valid email unchanged" do + expect(described_class.call("hello@example.com")).to eq("hello@example.com") + end + + it "replaces en-dash with hyphen" do + expect(described_class.call("hello@something\u2013other.com")).to eq("hello@something-other.com") + end + + it "replaces em-dash with hyphen" do + expect(described_class.call("hello@something\u2014other.com")).to eq("hello@something-other.com") + end + + it "removes zero-width space" do + expect(described_class.call("hello@some\u200Bthing.com")).to eq("hello@something.com") + end + + it "removes zero-width non-joiner" do + expect(described_class.call("hello@some\u200Cthing.com")).to eq("hello@something.com") + end + + it "removes zero-width joiner" do + expect(described_class.call("hello@some\u200Dthing.com")).to eq("hello@something.com") + end + + it "removes non-breaking space" do + expect(described_class.call("hello@some\u00A0thing.com")).to eq("hello@something.com") + end + + it "removes left-to-right mark" do + expect(described_class.call("hello@some\u200Ething.com")).to eq("hello@something.com") + end + + it "removes right-to-left mark" do + expect(described_class.call("hello@some\u200Fthing.com")).to eq("hello@something.com") + end + + it "removes BOM character" do + expect(described_class.call("\uFEFFhello@example.com")).to eq("hello@example.com") + end + + it "strips leading and trailing whitespace" do + expect(described_class.call(" hello@example.com ")).to eq("hello@example.com") + end + + it "handles multiple issues combined" do + expect(described_class.call(" hello@something\u2013other\u200B.com ")).to eq("hello@something-other.com") + end + end +end diff --git a/spec/support/graphql_helper.rb b/spec/support/graphql_helper.rb new file mode 100644 index 0000000..ccfd892 --- /dev/null +++ b/spec/support/graphql_helper.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module GraphQLHelper + def controller + @controller ||= GraphqlController.new.tap do |ctrl| + ctrl.set_request! ActionDispatch::Request.new({}) + end + end + + def execute_query(query:, variables: {}, input: nil) + if input + variables[:input] = input + end + + membership ||= create(:membership, organization:) + + execute_graphql( + current_user: membership.user, + current_organization: organization, + current_membership: membership, + permissions: required_permission, + query:, + variables: + ) + end + + def execute_graphql(current_user: nil, query: nil, current_organization: nil, current_membership: nil, customer_portal_user: nil, request: nil, permissions: nil, **kwargs) # rubocop:disable Metrics/ParameterLists + previous_source = CurrentContext.source + CurrentContext.source = "graphql" + + current_membership ||= membership if defined?(membership) + current_membership ||= current_user.memberships.active.find_by(organization: current_organization) if current_user + + unless permissions.is_a?(Hash) + # we allow passing a single permission string or an array for convenience + permissions = Array.wrap(permissions).index_with { true } + end + + permissions.keys.each do |permission| + next if Permission.permissions_hash.key?(permission) + + raise "Unknown permission: #{permission}" + end + + args = kwargs.merge( + context: { + controller:, + current_user:, + current_organization:, + current_membership:, + customer_portal_user:, + request:, + permissions: + } + ) + + res = LagoApiSchema.execute( + query, + **args + ) + + res["errors"]&.each do |e| + if e.dig("extensions", "code") == "undefinedField" && e.dig("extensions", "fieldName").match?(/_/) + pps "HINT: GraphQL field name should use camelCase even if its declaration is snake_case." + end + end + + CurrentContext.source = previous_source + res + end + + def expect_graphql_error(result:, message:, details: nil) + symbolized_result = result.to_h.deep_symbolize_keys + + expect(symbolized_result[:errors]).not_to be_empty + + error = symbolized_result[:errors].find do |e| + e[:message].to_s == message.to_s || e[:extensions][:code].to_s == message.to_s + end + + if details + expect(error.dig(:extensions, :details)).to eq details + end + + errors = symbolized_result[:errors].map do |error| + formatted_error = "- #{error[:message]}" + if (code = error.dig(:extensions, :code)) + formatted_error += " (#{code})" + end + formatted_error + end.join("\n") + expect(error).to be_present, "error message for #{message} is not present, got:\n#{errors}" + end + + def expect_unauthorized_error(result, details: nil) + expect_graphql_error( + result:, + message: :unauthorized, + details: + ) + end + + def expect_forbidden_error(result, details: nil) + expect_graphql_error( + result:, + message: :forbidden, + details: + ) + end + + def expect_unprocessable_entity(result, details: nil) + expect_graphql_error( + result:, + message: :unprocessable_entity, + details: + ) + end + + def expect_not_found(result, details: nil) + expect_graphql_error( + result:, + message: :not_found, + details: + ) + end +end diff --git a/spec/support/jobs/mock_stripe_webhook_event_job.rb b/spec/support/jobs/mock_stripe_webhook_event_job.rb new file mode 100644 index 0000000..a9a301c --- /dev/null +++ b/spec/support/jobs/mock_stripe_webhook_event_job.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Jobs + class MockStripeWebhookEventJob < ActiveJob::Base + def perform(organization, request_body, response_body) + @organization = organization + @request_body = request_body.deep_symbolize_keys + @response_body = response_body.deep_symbolize_keys + + @status = @response_body[:error] ? "failed" : "succeeded" + + # NOTE: Update Payment in our database because `PaymentRequests::Payments::StripeService.update_payment_status` + # relies on Payment.find_by(provider_payment_id: stripe_payment.id) + lago_payment_id = @request_body[:metadata][:lago_payment_id] + Payment.find(lago_payment_id).update!(provider_payment_id: payment_intent_id) + + result = PaymentProviders::Stripe::HandleEventService.call( + organization:, + event_json: build_event.to_json + ) + result.raise_if_error! + end + + def build_event + { + id: "evt_#{SecureRandom.hex(10)}", + object: "event", + created: Time.current.to_i, + type: (status == "succeeded") ? "payment_intent.succeeded" : "payment_intent.payment_failed", + data: { + object: { + id: payment_intent_id, + object: "payment_intent", + status: status.to_s, + metadata: request_body[:metadata] + } + } + } + end + + private + + attr_reader :organization, :request_body, :status, :response_body + + def payment_intent_id + response_body.dig(:error, :payment_intent, :id) + end + end +end diff --git a/spec/support/kafka_helper.rb b/spec/support/kafka_helper.rb new file mode 100644 index 0000000..cef899f --- /dev/null +++ b/spec/support/kafka_helper.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module KafkaHelper + extend ActiveSupport::Concern + + included do + let(:karafka_producer) { instance_double(WaterDrop::Producer) } + let(:kafka_messages) { [] } + end + + def stub_karafka_producer + allow(Karafka).to receive(:producer).and_return(karafka_producer) + allow(karafka_producer).to receive(:produce_async) { |args| kafka_messages << args } + allow(karafka_producer).to receive(:produce_many_async) { |args| kafka_messages.concat(args) } + end +end + +RSpec.configure do |config| + config.include KafkaHelper, :capture_kafka_messages + config.before(:each, :capture_kafka_messages) { stub_karafka_producer } +end diff --git a/spec/support/license_helper.rb b/spec/support/license_helper.rb new file mode 100644 index 0000000..3bb8f98 --- /dev/null +++ b/spec/support/license_helper.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module LicenseHelper + def lago_premium! + License.instance_variable_set(:@premium, true) + yield + License.instance_variable_set(:@premium, false) + end + + def premium_integration!(organization, premium_integration, &block) + old_integrations = organization.premium_integrations + organization.premium_integrations << premium_integration + organization.save! + + lago_premium!(&block) + + organization.update! premium_integrations: old_integrations + end +end diff --git a/spec/support/matchers/activity_log_matcher.rb b/spec/support/matchers/activity_log_matcher.rb new file mode 100644 index 0000000..733dd60 --- /dev/null +++ b/spec/support/matchers/activity_log_matcher.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :have_produced do |activity_type| + match do |actual| + @actual = actual + @activity_type = activity_type + + expected_params = [@object, activity_type] + expected_params << {after_commit: @after_commit} unless @after_commit.nil? + + expect(actual).to have_received(:produce).with(*expected_params) + end + + chain :with do |object| + @object = object + end + + chain :after_commit do + @after_commit = true + end + + chain :not_after_commit do + @after_commit = false + end + + failure_message do + base_message = "expected #{@actual} to have produced '#{@activity_type}'" + base_message += " with #{@object.inspect} and {after_commit: #{@after_commit}}" + base_message + end + + failure_message_when_negated do + base_message = "expected #{@actual} not to have produced '#{@activity_type}'" + base_message += " with #{@object.inspect} and {after_commit: #{@after_commit}}" + base_message + end + + description do + "produce '#{@activity_type}'" + end +end diff --git a/spec/support/matchers/be_not_found_error_matcher.rb b/spec/support/matchers/be_not_found_error_matcher.rb new file mode 100644 index 0000000..324a429 --- /dev/null +++ b/spec/support/matchers/be_not_found_error_matcher.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# spec/support/matchers/be_not_found_error_matcher.rb +RSpec::Matchers.define :be_not_found_error do |resource| + match do |response| + return false unless response.status == 404 + + begin + json_body = JSON.parse(response.body) + json_body["status"] == 404 && + json_body["error"] == "Not Found" && + json_body["code"] == "#{resource}_not_found" + rescue JSON::ParserError + false + end + end + + failure_message do |response| + if response.status != 404 + "expected response status to be 404, but was #{response.status}" + else + begin + json_body = JSON.parse(response.body) + expected_body = { + "status" => 404, + "error" => "Not Found", + "code" => "#{resource}_not_found" + } + "expected response body to match #{expected_body.inspect}, but was #{json_body.inspect}" + rescue JSON::ParserError + "expected response body to be valid JSON, but was #{response.body.inspect}" + end + end + end + + failure_message_when_negated do |response| + "expected response not to be a not_found_error for #{resource}, but it was" + end + + description do + "be a not found error response for #{resource}" + end +end diff --git a/spec/support/matchers/be_soft_deletable_matcher.rb b/spec/support/matchers/be_soft_deletable_matcher.rb new file mode 100644 index 0000000..f9627da --- /dev/null +++ b/spec/support/matchers/be_soft_deletable_matcher.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# spec/support/matchers/be_soft_deletable.rb +RSpec::Matchers.define :be_soft_deletable do + match do |model_class| + @model_class = model_class + + includes_discard_model? && has_correct_discard_column? && has_correct_default_scope? + end + + failure_message do |model_class| + messages = [] + + unless includes_discard_model? + messages << "expected #{model_class} to include Discard::Model" + end + + unless has_correct_discard_column? + messages << "expected #{model_class}.discard_column to eq :deleted_at, but got #{@model_class.discard_column.inspect}" + end + + unless has_correct_default_scope? + messages << "expected #{model_class} to have a default scope that keeps only undiscarded records" + end + + messages.join(" and ") + end + + failure_message_when_negated do |model_class| + "expected #{model_class} not to be soft deletable" + end + + description do + "be soft deletable (include Discard::Model, have discard_column set to :deleted_at, and have default scope for undiscarded records)" + end + + private + + def includes_discard_model? + @model_class.included_modules.include?(Discard::Model) + end + + def has_correct_discard_column? + @model_class.respond_to?(:discard_column) && @model_class.discard_column.to_sym == :deleted_at + end + + def has_correct_default_scope? + return false unless @model_class.respond_to?(:discard_column) + + # Get the default scope's where clause + default_scope_sql = @model_class.all.to_sql + discard_column = @model_class.discard_column + + # Check if the default scope includes a WHERE clause that filters out discarded records + # This checks for the pattern: WHERE "table_name"."discard_column" IS NULL + table_name = @model_class.table_name + expected_condition = "\"#{table_name}\".\"#{discard_column}\" IS NULL" + + default_scope_sql.include?(expected_condition) + end +end diff --git a/spec/support/matchers/datetime_matcher.rb b/spec/support/matchers/datetime_matcher.rb new file mode 100644 index 0000000..96836cd --- /dev/null +++ b/spec/support/matchers/datetime_matcher.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :match_datetime do |expectation| + match do |subject| + subject = Time.zone.parse(subject).change(usec: 0) if subject.is_a?(String) + expectation = Time.zone.parse(expectation) if expectation.is_a?(String) + + subject.change(usec: 0) == expectation.change(usec: 0) + end +end diff --git a/spec/support/matchers/graphql_field_permissions_matcher.rb b/spec/support/matchers/graphql_field_permissions_matcher.rb new file mode 100644 index 0000000..9e1ac3f --- /dev/null +++ b/spec/support/matchers/graphql_field_permissions_matcher.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +# Usage: +# it { is_expected.to have_a_field(:api_key).with_permissions('developers:manage') } +# +module RSpec + module GraphqlMatchers + module WithPermissions + def with_permissions(expected_permissions) + @expectations << WithPermissionsMatcher.new(expected_permissions) + self + end + alias_method :with_permission, :with_permissions + end + + class HaveAField < BaseMatcher + include WithPermissions + end + + class AcceptArgument < BaseMatcher + include WithPermissions + end + + class WithPermissionsMatcher + def initialize(expected_permissions) + @expected_permissions = Array.wrap(expected_permissions) + end + + def description + "with permissions `#{@expected_permissions}`" + end + + def matches?(actual) + @actual_permissions = actual.permissions + @actual_permissions.sort == @expected_permissions.sort + end + + def failure_message + "#{description}, but it was `#{@actual_permissions}`" + end + end + end +end diff --git a/spec/support/matchers/have_empty_charge_fees.rb b/spec/support/matchers/have_empty_charge_fees.rb new file mode 100644 index 0000000..d30e586 --- /dev/null +++ b/spec/support/matchers/have_empty_charge_fees.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :have_empty_charge_fees do + match do |invoice| + invoice.fees.charge.all? do |fee| + from = Time.zone.parse(fee.properties["charges_from_datetime"]) + to = Time.zone.parse(fee.properties["charges_to_datetime"]) + + fee.total_amount_cents.zero? && from.before?(to) + end + end + + failure_message do |invoice| + "expected that #{invoice} would have empty charge fees but fees were found.\n" \ + "Fees: #{invoice.fees.charge.all.map(&:total_amount_cents)}" + end + + failure_message_when_negated do |invoice| + "expected that #{invoice} would have some charge fees but none were found.\n" \ + "Fees: #{invoice.fees.charge.all.map(&:total_amount_cents)}" + end +end diff --git a/spec/support/matchers/job_matcher.rb b/spec/support/matchers/job_matcher.rb new file mode 100644 index 0000000..f414f9c --- /dev/null +++ b/spec/support/matchers/job_matcher.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +# This matcher ensure that a job is enqueued only after a transaction is committed to ensure no race-condition may +# happen. +RSpec::Matchers.define :have_enqueued_job_after_commit do |job| + supports_block_expectations + match(notify_expectation_failures: true) do |block| + ApplicationRecord.transaction do + block.call + + expect(job).not_to have_been_enqueued, "Expected #{job} to not have been enqueued before commit, but it was." + end + + args = @args || [] + kwargs = @kwargs || {} + + expect(job).to have_been_enqueued.with(*args, **kwargs, &@block).send(expectation_type, expected_number) + end + + match_when_negated do |block| + raise "The `have_enqueued_job_after_commit` matcher does not support negation. Use `expect { ... }.not_to have_enqueued_job` instead." + end + + chain :with do |*args, **kwargs, &block| + @args = args + @kwargs = kwargs + @block = block + end + + chain :twice do + set_expected_number(:exactly, 2) + end + + chain :thrice do + set_expected_number(:exactly, 3) + end + + chain :exactly do |count| + set_expected_number(:exactly, count) + end + + chain :at_least do |count| + set_expected_number(:at_least, count) + end + + chain :at_most do |count| + set_expected_number(:at_most, count) + end + + chain :times do + end + + private + + def set_expected_number(relativity, count) + @expectation_type = relativity + @expected_number = case count + when :once then 1 + when :twice then 2 + when :thrice then 3 + else Integer(count) + end + end + + def expected_number + @expected_number || 1 + end + + def expectation_type + @expectation_type || :exactly + end +end + +RSpec::Matchers.define_negated_matcher :not_have_enqueued_job, :have_enqueued_job diff --git a/spec/support/matchers/middleware_matcher.rb b/spec/support/matchers/middleware_matcher.rb new file mode 100644 index 0000000..fb06d21 --- /dev/null +++ b/spec/support/matchers/middleware_matcher.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :use_middleware do |middleware| + match do |service_class| + service_klass = service_class || described_class + expect(service_klass.middlewares.map(&:first)).to include(middleware) + end +end diff --git a/spec/support/matchers/negated_matchers.rb b/spec/support/matchers/negated_matchers.rb new file mode 100644 index 0000000..8ef4694 --- /dev/null +++ b/spec/support/matchers/negated_matchers.rb @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +RSpec::Matchers.define_negated_matcher :not_change, :change diff --git a/spec/support/matchers/snapshot_matcher.rb b/spec/support/matchers/snapshot_matcher.rb new file mode 100644 index 0000000..c4a82a8 --- /dev/null +++ b/spec/support/matchers/snapshot_matcher.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +# This matcher allows to match an HTML snapshot with the same logic as the `match_snapshot` matcher. +# +# The main difference is that it will add the test context and a `.html` extension to the snapshot name. For instance, if the test is: +# +# ```ruby +# context "when the customer is a company" do +# let(:customer) { create(:customer, :company) } +# +# it "renders the invoice" do +# expect(rendered_template).to match_html_snapshot +# end +# end +# ``` +# +# The snapshot name will be `when_the_customer_is_a_company/renders_the_invoice.html.snap`. +# +# Usage example: +# +# ``` +# it "renders the invoice" do +# expect(rendered_template).to match_html_snapshot +# end +# ``` +RSpec::Matchers.define :match_html_snapshot do |name: nil, strip_style: true, strip_head: true| + match(notify_expectation_failures: true) do |html| + name = [snapshot_name(RSpec.current_example.metadata), sanitize_fragment(name)].compact.join("/") + name += ".html" + html = beautify(html, strip_style: strip_style, strip_head: strip_head) + expect(html).to match_snapshot(name) + end + + private + + def sanitize_fragment(fragment) + return nil if fragment.nil? + + fragment.tr("/", "_").tr(" ", "_") + end + + def snapshot_name(metadata) + description = metadata[:description].empty? ? metadata[:scoped_id] : metadata[:description] + example_group = metadata.key?(:example_group) ? metadata[:example_group] : metadata[:parent_example_group] + + description = sanitize_fragment(description) + if example_group + [snapshot_name(example_group), description].join("/") + else + description + end + end + + def beautify(html, strip_style: true, strip_head: true) + # Remove unnecessary styles and head tags + if strip_style + prev = nil + while html != prev + prev = html + html = html.gsub(%r{.*?}m, "") + end + end + html = html.gsub(%r{.*?}m, "") if strip_head + # Make sure each HTML start tag is on a new line as the beautifier does not always do it + html = html.gsub(%r{><([^/])}, ">\n<\\1") + html = html.gsub(%r{()}, "\\1\n") + html = html.gsub(%r{/>}, "/>\n") + # Ensure the HTML string is properly encoded as UTF-8, otherwise we won't be able to write the snapshot + html = html.force_encoding("UTF-8") if html.encoding != Encoding::UTF_8 + HtmlBeautifier.beautify(html, stop_on_errors: true) + end +end diff --git a/spec/support/matchers/xml_matcher.rb b/spec/support/matchers/xml_matcher.rb new file mode 100644 index 0000000..b87891c --- /dev/null +++ b/spec/support/matchers/xml_matcher.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +RSpec::Matchers.define :contains_xml_node do |xpath| + match do |document| + @xpath = xpath + @node = document.at_xpath(xpath) + + if @node.nil? + @error = :node_not_found + return false + end + + if @node_value && @node.text != @node_value.to_s + @error = :diff_node_value + return false + end + + if @attribute && @attribute_value + if @node.attributes[@attribute].nil? + @error = :attribute_not_found + return false + elsif @node.attributes[@attribute].value != @attribute_value.to_s + @error = :diff_attribute_value + return false + end + end + + true + end + + chain :with_value do |value| + @node_value = value + end + + chain :with_attribute do |attribute, value| + @attribute = attribute + @attribute_value = value + end + + failure_message do |document| + case @error + when :node_not_found + "expected XPath \"#{@xpath}\" to be present, but it was not found in the XML" + when :diff_node_value + "expected XPath \"#{@xpath}\" to have value \"#{@node_value}\", but was \"#{@node.text}\"" + when :attribute_not_found + "expected XPath \"#{@xpath}\" to have attribute \"#{@attribute}\", but attribute was not found" + when :diff_attribute_value + "expected XPath \"#{@xpath}\" to have attribute \"#{@attribute}\" equals to \"#{@attribute_value}\", but was \"#{@node[@attribute]}\"" + end + end +end + +RSpec::Matchers.define :contains_xml_comment do |comment| + match do |document| + document.xpath("//comment()").map(&:text).include?(comment) + end +end diff --git a/spec/support/monkey_patches/database_cleaner.rb b/spec/support/monkey_patches/database_cleaner.rb new file mode 100644 index 0000000..a9694a1 --- /dev/null +++ b/spec/support/monkey_patches/database_cleaner.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +# Monkey patch database cleaner to be compatible with Clickhouse. +module DatabaseCleaner + module ActiveRecord + class Deletion + def delete_table(connection, table_name) + arel = Arel::DeleteManager.new.from(Arel::Table.new(table_name)).where(Arel.sql("1=1")) + connection.delete(arel) + end + end + end +end diff --git a/spec/support/pdf_helper.rb b/spec/support/pdf_helper.rb new file mode 100644 index 0000000..26d164d --- /dev/null +++ b/spec/support/pdf_helper.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module PdfHelper + # This helper stubs the PDF generation request to return the input HTML as response so that it can be used in the tests. + def stub_pdf_generation + stub_request(:post, "#{ENV["LAGO_PDF_URL"]}/forms/chromium/convert/html") + .to_return do |request| + env = { + "CONTENT_TYPE" => request.headers["Content-Type"], + "CONTENT_LENGTH" => request.headers["Content-Length"], + "rack.input" => StringIO.new(request.body) + } + params = Rack::Multipart.parse_multipart(env) + html = params["file1"][:tempfile].read + {body: html, status: 200} + end + end +end diff --git a/spec/support/queues_helper.rb b/spec/support/queues_helper.rb new file mode 100644 index 0000000..f20c560 --- /dev/null +++ b/spec/support/queues_helper.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module QueuesHelper + def webhook_queue + if ActiveModel::Type::Boolean.new.cast(ENV["SIDEKIQ_WEBHOOK"]) + :webhook_worker + else + :webhook + end + end + + # This performs any enqueued-jobs, and continues doing so until the queue is empty. + # Lots of the jobs enqueue other jobs as part of their work, and this ensures that + # everything that's supposed to happen, happens. + # + # ⚠️ Notice that `have_been_enqueued` might not work with perform_all_enqueued_jobs + # because it's only aware of the last run of the loop. + def perform_all_enqueued_jobs(only: nil, except: nil) + until enqueued_jobs(only:, except:).empty? + perform_enqueued_jobs(only:, except:) + Sidekiq::Worker.drain_all + end + end + + def enqueued_jobs(only: nil, except: nil) + super().filter do |job| + if only && !only.include?(job[:job]) + next false + end + if except&.include?(job[:job]) + next false + end + true + end + end +end diff --git a/spec/support/regex_spec.rb b/spec/support/regex_spec.rb new file mode 100644 index 0000000..b9acbde --- /dev/null +++ b/spec/support/regex_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe Regex do + describe "UUID" do + it "matches a valid UUID" do + expect(Regex::UUID).to match("123e4567-e89b-12d3-a456-426614174000") + end + + it "does not match an invalid UUID" do + expect(Regex::UUID).not_to match("123e4567-e89b-12d3-a456-4266141740000") + expect(Regex::UUID).not_to match("string") + end + end + + describe "EMAIL" do + it "matches a valid email" do + expect(Regex::EMAIL).to match("test@example.com") + end + + it "does not match an invalid email" do + expect(Regex::EMAIL).not_to match("test@example.com@invalid") + end + end + + describe "INVISIBLE_CHARS" do + it "matches a valid invisible character" do + expect(Regex::INVISIBLE_CHARS).to match("\u200B") + expect(Regex::INVISIBLE_CHARS).to match("\u200C") + expect(Regex::INVISIBLE_CHARS).to match("\u200D") + expect(Regex::INVISIBLE_CHARS).to match("\u00A0") + expect(Regex::INVISIBLE_CHARS).to match("\u200E") + expect(Regex::INVISIBLE_CHARS).to match("\u200F") + end + end + + describe "ISO8601_DATETIME" do + it "matches a valid ISO8601 datetime" do + expect(Regex::ISO8601_DATETIME).to match("2022-09-05T12:23:12Z") + expect(Regex::ISO8601_DATETIME).to match("2022-09-05T12:23:12.123Z") + expect(Regex::ISO8601_DATETIME).to match("2022-09-05T12:23:12.123+00:00") + end + + it "does not match an invalid ISO8601 datetime" do + expect(Regex::ISO8601_DATETIME).not_to match("2022-09-05 12:23:12+00:00") + end + end +end diff --git a/spec/support/scenarios_helper.rb b/spec/support/scenarios_helper.rb new file mode 100644 index 0000000..f18d10c --- /dev/null +++ b/spec/support/scenarios_helper.rb @@ -0,0 +1,525 @@ +# frozen_string_literal: true + +module ScenariosHelper + def api_call(perform_jobs: true, raise_on_error: true) + yield + + if raise_on_error && response.status >= 400 + request = response.request + message_parts = ["API call failed:", + "- Method: #{request.method}", + "- Path: #{request.path}", + "- Request body: #{request.body.read}", + "- HTTP status: #{response.status}", + "- Response body: #{response.body}"] + message = message_parts.join("\n") + raise message + end + + perform_all_enqueued_jobs if perform_jobs + json.with_indifferent_access + end + + def clock_job + yield + perform_all_enqueued_jobs + end + + ### Organizations + + def update_organization(params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/organizations", {organization: params}) + end + end + + ### Billing entities + + def update_billing_entity(billing_entity, params) + # TODO: use the endpoint to update the billing entity instead when available + BillingEntities::UpdateService.call!(billing_entity: billing_entity.reload, params:) + end + + ### Billable metrics + + def create_metric(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/billable_metrics", {billable_metric: params}) + end + end + + def update_metric(metric, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/billable_metrics/#{metric.code}", {billable_metric: params}) + end + end + + ### Customers + + def create_or_update_customer(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/customers", {customer: params}) + end + end + + def delete_customer(customer, **kwargs) + api_call(**kwargs) do + delete_with_token(organization, "/api/v1/customers/#{customer.external_id}") + end + end + + def fetch_current_usage(customer:, subscription: customer.subscriptions.first, **kwargs) + api_call(**kwargs) do + url = "/api/v1/customers/#{customer.external_id}/current_usage?external_subscription_id=#{subscription.external_id}" + get_with_token(organization, url) + end + end + + ### Plans + + def create_plan(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/plans", {plan: params}) + end + end + + def update_plan(plan, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/plans/#{plan.code}", {plan: params}) + end + end + + def delete_plan(plan, **kwargs) + api_call(**kwargs) do + delete_with_token(organization, "/api/v1/plans/#{plan.code}") + end + end + + ### Plan Charges + + def create_plan_charge(plan, params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/plans/#{plan.code}/charges", {charge: params}) + end + end + + def update_plan_charge(plan, charge_code, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge_code}", {charge: params}) + end + end + + def delete_plan_charge(plan, charge_code, **kwargs) + api_call(**kwargs) do + delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge_code}") + end + end + + ### Plan Charge Filters + + def create_plan_charge_filter(plan, charge_code, params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge_code}/filters", {filter: params}) + end + end + + def update_plan_charge_filter(plan, charge_code, filter_id, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge_code}/filters/#{filter_id}", {filter: params}) + end + end + + def delete_plan_charge_filter(plan, charge_code, filter_id, **kwargs) + api_call(**kwargs) do + delete_with_token(organization, "/api/v1/plans/#{plan.code}/charges/#{charge_code}/filters/#{filter_id}") + end + end + + ### Subscriptions + + def create_subscription(params, authorization = nil, as: :json, **kwargs) + payload = {subscription: params} + payload[:authorization] = authorization if authorization + api_call(**kwargs) do + post_with_token(organization, "/api/v1/subscriptions", payload) + end + parse_result(as, Subscription, :subscription) + end + + def update_subscription(subscription, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/subscriptions/#{subscription.external_id}", {subscription: params}) + end + end + + def terminate_subscription(subscription, params: {}, **kwargs) + api_call(**kwargs) do + delete_with_token(organization, "/api/v1/subscriptions/#{subscription.external_id}?#{params.to_query}") + end + end + + ### Subscription Charges + + def update_subscription_charge(subscription, charge_code, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/subscriptions/#{subscription.external_id}/charges/#{charge_code}", {charge: params}) + end + end + + ### Subscription Charge Filters + + def create_subscription_charge_filter(subscription, charge_code, params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/subscriptions/#{subscription.external_id}/charges/#{charge_code}/filters", {filter: params}) + end + end + + def update_subscription_charge_filter(subscription, charge_code, filter_id, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/subscriptions/#{subscription.external_id}/charges/#{charge_code}/filters/#{filter_id}", {filter: params}) + end + end + + def delete_subscription_charge_filter(subscription, charge_code, filter_id, **kwargs) + api_call(**kwargs) do + delete_with_token(organization, "/api/v1/subscriptions/#{subscription.external_id}/charges/#{charge_code}/filters/#{filter_id}") + end + end + + def create_alert(sub_external_id, params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/subscriptions/#{sub_external_id}/alerts", {alert: params}) + end + end + + def create_wallet_alert(customer_external_id, wallet_code, params, as: :json, **kwargs) + api_call(**kwargs) do + post_with_token( + organization, + "/api/v1/customers/#{customer_external_id}/wallets/#{wallet_code}/alerts", + {alert: params} + ) + end + parse_result(as, UsageMonitoring::Alert, :alert) + end + + ### Invoices + + def refresh_invoice(invoice, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/invoices/#{invoice.id}/refresh", {}) + end + end + + def finalize_invoice(invoice, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/invoices/#{invoice.id}/finalize", {}) + end + end + + def update_invoice(invoice, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/invoices/#{invoice.id}", {invoice: params}) + end + end + + def void_invoice(invoice, params = {}) + post_with_token(organization, "/api/v1/invoices/#{invoice.id}/void", params) + perform_all_enqueued_jobs + invoice.reload + end + + def create_one_off_invoice(customer, addons, taxes: [], currency: "EUR", units: 1, **kwargs) + api_call(**kwargs) do + create_invoice_params = { + external_customer_id: customer.external_id, + currency:, + fees: [], + timestamp: Time.zone.now.to_i + } + addons.each do |fee| + fee_addon_params = { + add_on_id: fee.id, + add_on_code: fee.code, + name: fee.name, + units:, + unit_amount_cents: fee.amount_cents, + tax_codes: taxes + } + create_invoice_params[:fees].push(fee_addon_params) + end + post_with_token(organization, "/api/v1/invoices", {invoice: create_invoice_params}) + end + end + + def retry_invoice_payment(invoice_id, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/invoices/#{invoice_id}/retry_payment") + end + end + + ### Payments + + def create_payment(customer, invoice, amount_cents, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/payments", { + payment: { + invoice_id: invoice.id, + amount_cents:, + reference: SecureRandom.uuid.to_s + } + }) + end + end + + ### Coupons + + def create_coupon(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/coupons", {coupon: params}) + end + end + + def apply_coupon(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/applied_coupons", {applied_coupon: params}) + end + end + + ### Taxes + + def create_tax(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/taxes", {tax: params}) + end + end + + # The mock always return a valid response, + # To get an invalid response, simply use a invalid format (like YY123) + def mock_vies_check!(vat_number) + valvat = instance_double(Valvat) + allow(Valvat).to receive(:new).with(vat_number).and_return(valvat) + allow(valvat).to receive(:exists?).with(detail: true, raise_error: true).and_return({ + countryCode: vat_number[0..1].upcase, + vatNumber: vat_number.upcase + }) + end + + ### Wallets + + def create_wallet(params, as: :json, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/wallets", {wallet: params}) + end + parse_result(as, Wallet, :wallet) + end + + def create_wallet_transaction(params, as: :json, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/wallet_transactions", {wallet_transaction: params}) + end + parse_result(as, WalletTransaction, :wallet_transactions) + end + + def recalculate_wallet_balances + Clock::RefreshLifetimeUsagesJob.perform_later + Clock::RefreshWalletsOngoingBalanceJob.perform_later + perform_all_enqueued_jobs + end + + ### Events + + def ingest_event(subscription, billable_metric, amount) + create_event({ + transaction_id: SecureRandom.uuid, + code: billable_metric.code, + external_subscription_id: subscription.external_id, + properties: {billable_metric&.field_name => amount} + }) + perform_usage_update + end + + def create_event(params, **kwargs) + params[:transaction_id] ||= SecureRandom.uuid + + response = api_call(**kwargs) do + post_with_token(organization, "/api/v1/events", {event: params}) + end + + if organization.clickhouse_events_store? + timestamp = params.key?(:timestamp) ? Time.zone.at(params[:timestamp]) : Time.iso8601(response.dig(:event, :timestamp)) + params = params.merge(timestamp:) + create_clickhouse_event(params) + end + + response + end + + def estimate_event(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/events/estimate_fees", {event: params}) + end + end + + ### Credit notes + + def create_credit_note(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/credit_notes", {credit_note: params}) + end + end + + def estimate_credit_note(params, **kwargs) + api_call(**kwargs) do + post_with_token(organization, "/api/v1/credit_notes/estimate", {credit_note: params}) + end + end + + ### Analytics + + def get_analytics(organization:, analytics_type:, months: 20, **kwargs) + api_call(**kwargs) do + get_with_token(organization, "/api/v1/analytics/#{analytics_type}", months:) + end + end + + ### Payment methods + + def setup_stripe_for(customer:) + stripe_provider = create(:stripe_provider, organization:) + create(:stripe_customer, customer_id: customer.id, payment_provider: stripe_provider) + customer.update!(payment_provider: "stripe", payment_provider_code: stripe_provider.code) + end + + ### Fees + + def update_fee(fee_id, params, **kwargs) + api_call(**kwargs) do + put_with_token(organization, "/api/v1/fees/#{fee_id}", {fee: params}) + end + end + + # Clock jobs + + def perform_billing + clock_job do + Clock::SubscriptionsBillerJob.perform_later + Clock::FreeTrialSubscriptionsBillerJob.perform_later + end + perform_usage_update + end + + def perform_invoices_refresh + clock_job do + Clock::RefreshDraftInvoicesJob.perform_later + end + end + + def perform_finalize_refresh + clock_job do + Clock::FinalizeInvoicesJob.perform_later + end + end + + def perform_usage_update + clock_job do + Clock::ComputeAllDailyUsagesJob.perform_later + Clock::RefreshLifetimeUsagesJob.perform_later + Clock::ProcessAllSubscriptionActivitiesJob.perform_later + end + end + + def perform_wallet_refresh + clock_job do + Clock::RefreshWalletsOngoingBalanceJob.perform_later + end + end + + def perform_overdue_balance_update + clock_job do + Clock::MarkInvoicesAsPaymentOverdueJob.perform_later + end + end + + def perform_dunning + clock_job do + Clock::ProcessDunningCampaignsJob.perform_later + end + end + + private + + def fetch_subscription(external_id) + subscription = Subscription.find_by(external_id:) + if subscription.nil? + raise "Subscription not found for external_id: #{external_id}" + end + subscription + end + + def fetch_billable_metric(code) + billable_metric = BillableMetric.find_by(code:) + if billable_metric.nil? + raise "Billable metric not found for code: #{code}" + end + billable_metric + end + + def fetch_charge(subscription, billable_metric) + charge = subscription.plan.charges.find_by(billable_metric:) + if charge.nil? + raise "Charge not found for billable_metric: #{billable_metric.code}" + end + charge + end + + def parse_result(as, model_class, key) + case as + when :json + json.with_indifferent_access + when :model + array = key.to_s.pluralize == key.to_s + if array + model_class.where(id: json[key].pluck(:lago_id)) + else + model_class.find(json[key][:lago_id]) + end + else + raise "Invalid as: #{as}" + end + end + + def create_clickhouse_event(params) + subscription = fetch_subscription(params[:external_subscription_id]) + billable_metric = fetch_billable_metric(params[:code]) + charge = fetch_charge(subscription, billable_metric) + + params[:organization_id] = organization.id + params[:value] ||= if billable_metric.count_agg? + "1" + else + params.fetch(:properties, {}).with_indifferent_access.fetch(billable_metric.field_name).to_s + end + + enriched_event = Clickhouse::EventsEnriched.create!(params) + + if charge.pay_in_advance? + process_pay_in_advance_clickhouse_event(enriched_event) + end + end + + def process_pay_in_advance_clickhouse_event(enriched_event) + common_event = Events::Common.new( + id: nil, + organization_id: enriched_event.organization_id, + transaction_id: enriched_event.transaction_id, + external_subscription_id: enriched_event.external_subscription_id, + timestamp: enriched_event.timestamp, + code: enriched_event.code, + properties: enriched_event.properties, + precise_total_amount_cents: enriched_event.precise_total_amount_cents + ) + Events::PayInAdvanceJob.perform_later(common_event.as_json) + perform_all_enqueued_jobs + end +end diff --git a/spec/support/shared_context/clickhouse_availability.rb b/spec/support/shared_context/clickhouse_availability.rb new file mode 100644 index 0000000..4a02e79 --- /dev/null +++ b/spec/support/shared_context/clickhouse_availability.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +RSpec.shared_context "with clickhouse availability" do + let(:clickhouse_enabled) { "true" } + + before { ENV["LAGO_CLICKHOUSE_ENABLED"] = clickhouse_enabled } + after { ENV["LAGO_CLICKHOUSE_ENABLED"] = nil } +end diff --git a/spec/support/shared_context/mock_security_logger.rb b/spec/support/shared_context/mock_security_logger.rb new file mode 100644 index 0000000..1625900 --- /dev/null +++ b/spec/support/shared_context/mock_security_logger.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +# Stubs Utils::SecurityLog for testing dependent services. +# +# @param available [Boolean] whether infrastructure is available (default: true) +# +# Usage: +# +# include_context "with mocked security logger" +# include_context "with mocked security logger", available: false +RSpec.shared_context "with mocked security logger" do |available: true| + let!(:security_logger) do # rubocop:disable RSpec/LetSetup + class_double(Utils::SecurityLog, produce: available, available?: available).as_stubbed_const + end +end diff --git a/spec/support/shared_context/security_log_infrastructure.rb b/spec/support/shared_context/security_log_infrastructure.rb new file mode 100644 index 0000000..63a850a --- /dev/null +++ b/spec/support/shared_context/security_log_infrastructure.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.shared_context "with security log infrastructure" do + let(:clickhouse_enabled) { "true" } + let(:kafka_bootstrap_servers) { "kafka" } + let(:kafka_security_logs_topic) { "security_logs" } + + before do + ENV["LAGO_CLICKHOUSE_ENABLED"] = clickhouse_enabled + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = kafka_bootstrap_servers + ENV["LAGO_KAFKA_SECURITY_LOGS_TOPIC"] = kafka_security_logs_topic + end + + after do + ENV["LAGO_CLICKHOUSE_ENABLED"] = nil + ENV["LAGO_KAFKA_BOOTSTRAP_SERVERS"] = nil + ENV["LAGO_KAFKA_SECURITY_LOGS_TOPIC"] = nil + end +end diff --git a/spec/support/shared_context/stripe_customer.rb b/spec/support/shared_context/stripe_customer.rb new file mode 100644 index 0000000..02953f5 --- /dev/null +++ b/spec/support/shared_context/stripe_customer.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.shared_context "with Stripe configured for customer" do + let(:stripe_cus_id) { "cus_123456789" } + let(:stripe_pm_id) { "pm_123456" } + + let(:stripe_provider) { create(:stripe_provider, organization:) } + let(:stripe_customer) { create(:stripe_customer, customer:, payment_provider: stripe_provider, provider_customer_id: stripe_cus_id) } + + let(:stripe_customer_response) do + get_stripe_fixtures("customer_retrieve_response.json") do |h| + h["invoice_settings"]["default_payment_method"] = stripe_pm_id + end + end + let(:stripe_payment_method_response) do + get_stripe_fixtures("retrieve_payment_method_response.json") do |h| + h["id"] = stripe_pm_id + h["customer"] = stripe_cus_id + end + end + + before do + customer.update! payment_provider: :stripe, payment_provider_code: stripe_provider.code + stripe_customer + + stub_request(:get, "https://api.stripe.com/v1/customers/#{stripe_cus_id}") + .and_return(status: 200, body: stripe_customer_response) + stub_request(:get, "https://api.stripe.com/v1/customers/#{stripe_cus_id}/payment_methods/#{stripe_pm_id}") + .and_return(status: 200, body: stripe_payment_method_response) + + WebMock.after_request do |request_signature, response| + if request_signature.uri.path.match?(%r{/v1/payment_intents}) + request_body_hash = if request_signature.url_encoded? + Rack::Utils.parse_nested_query(request_signature.body) + elsif request_signature.body.json_encoded? + JSON.parse(request_signature.body) + end + + Jobs::MockStripeWebhookEventJob.perform_later( + organization, + request_body_hash, + JSON.parse(response.body) + ) + end + end + end +end diff --git a/spec/support/shared_context/time_travel.rb b/spec/support/shared_context/time_travel.rb new file mode 100644 index 0000000..a9487af --- /dev/null +++ b/spec/support/shared_context/time_travel.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +PassedTime = Struct.new(:days) + +RSpec.shared_context "with Time travel enabled" do + # Standard time on which all things start + # can simply be overriden via a let(:time0) {} in your own spec! + let(:time0) { DateTime.new(2022, 12, 1) } + let(:passed_time) { PassedTime.new(0) } + + before do + passed_time.days = 0 + travel_to time0 + end + + def pass_time(amount) + amount_of_days = amount / 1.day + amount_of_days.times do |i| + perform_billing + travel_to time0 + passed_time.days + i.day + end + perform_billing + passed_time.days += amount_of_days + end +end diff --git a/spec/support/shared_context/webhook_tracking.rb b/spec/support/shared_context/webhook_tracking.rb new file mode 100644 index 0000000..fe9a9ff --- /dev/null +++ b/spec/support/shared_context/webhook_tracking.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.shared_context "with webhook tracking" do + let(:webhooks_sent) { [] } + + before do + webhook_url = organization.webhook_endpoints.sole.webhook_url + + stub_request(:post, webhook_url).with do |req| + webhooks_sent << JSON.parse(req.body).with_indifferent_access + true + end.and_return(status: 200) + rescue ActiveRecord::SoleRecordExceeded + raise "`with webhook tracking` shared context only works with a single webhook endpoint" + end +end diff --git a/spec/support/shared_examples/an_integration_payload.rb b/spec/support/shared_examples/an_integration_payload.rb new file mode 100644 index 0000000..f780600 --- /dev/null +++ b/spec/support/shared_examples/an_integration_payload.rb @@ -0,0 +1,318 @@ +# frozen_string_literal: true + +# This is a shared example that is used to test the payload of an integration. +# It will test the fallback behavior of the integration from billing entity to organization. +# +# It expects a `build_expected_payload` method to be defined in the spec +# ``` +# it_behaves_like "an integration payload", :avalara do +# def build_expected_payload(mapping_codes, some_extra_parameter_with_defaults: false) +# [ +# { +# "issuing_date" => invoice.issuing_date, +# "currency" => invoice.currency, +# "some_extra_parameter_with_defaults" => some_extra_parameter_with_defaults, +# "fees" => match_array([ +# { +# "item_key" => add_on_fee.item_key, +# "item_id" => add_on_fee.id, +# "amount" => "2.0", +# "unit" => 2.0, +# "item_code" => mapping_codes.dig(:add_on, :external_id) +# } +# ]) +# } +# ] +# end +# end +# ``` +# +RSpec.shared_examples "an integration payload" do |integration_type| + let(:integration_type) { integration_type.to_sym } + let(:mappings_on) { [:billing_entity, :organization] } + let(:fallback_items_on) { [:billing_entity, :organization] } + + let(:organization) { create(:organization) } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:integration) { create("#{integration_type}_integration", organization:) } + let(:customer) { create(:customer, organization:, billing_entity:) } + let(:integration_customer) { create("#{integration_type}_customer", customer:, integration:) } + + let(:add_on) { create(:add_on, organization:, name: "Add-on") } + let(:fixed_charge_add_on) { create(:add_on, organization:, name: "Fixed Charge Add-on") } + let(:billable_metric) { create(:billable_metric, organization:, name: "Billable Metric") } + let(:plan) { create(:plan, organization:, name: "Plan") } + let(:charge) { create(:standard_charge, plan:, organization:, billable_metric:) } + let(:fixed_charge) { create(:fixed_charge, organization:, plan:, add_on: fixed_charge_add_on) } + let(:subscription) { create(:subscription, organization:, plan:) } + + let(:invoice) do + invoice = create( + :invoice, + customer:, + organization:, + billing_entity:, + coupons_amount_cents: 200, + prepaid_credit_amount_cents: 300, + progressive_billing_credit_amount_cents: 100, + credit_notes_amount_cents: 500, + taxes_amount_cents: 300, + issuing_date: DateTime.new(2024, 7, 8) + ) + create(:invoice_subscription, invoice:, subscription:) + invoice + end + let(:payment) { create(:payment, payable: invoice) } + + let(:add_on_fee) { create(:add_on_fee, invoice:, add_on:, units: 2, amount_cents: 200, precise_unit_amount: 100.0, invoice_display_name: "Add-on Fee") } + let(:billable_metric_fee) { create(:charge_fee, invoice:, billable_metric:, units: 3, amount_cents: 300, charge:, invoice_display_name: "Standard Charge Fee", precise_unit_amount: 100.0) } + let(:minimum_commitment_fee) { create(:minimum_commitment_fee, invoice:, units: 4, amount_cents: 400, invoice_display_name: "Minimum Commitment Fee", precise_unit_amount: 100.0) } + let(:subscription_fee) { create(:fee, invoice:, subscription:, units: 5, amount_cents: 500, precise_unit_amount: 100.0) } + let(:fixed_charge_fee) { create(:fixed_charge_fee, invoice:, fixed_charge:, units: 6, amount_cents: 150, precise_unit_amount: 25.0, invoice_display_name: "Fixed Charge Fee") } + let(:fees) { invoice.fees } + + let(:credit_note) { create(:credit_note, customer:, invoice:, issuing_date: DateTime.new(2024, 7, 8)) } + + let(:add_on_credit_note_item) { create(:credit_note_item, credit_note:, fee: add_on_fee, amount_cents: 190) } + let(:billable_metric_credit_note_item) { create(:credit_note_item, credit_note:, fee: billable_metric_fee, amount_cents: 180) } + let(:minimum_commitment_credit_note_item) { create(:credit_note_item, credit_note:, fee: minimum_commitment_fee, amount_cents: 170) } + let(:subscription_credit_note_item) { create(:credit_note_item, credit_note:, fee: subscription_fee, amount_cents: 160) } + let(:fixed_charge_credit_note_item) { create(:credit_note_item, credit_note:, fee: fixed_charge_fee, amount_cents: 140) } + + let(:add_on_mapping_on_billing_entity) do + settings = {external_id: "add_on_on_billing_entity", external_account_code: "11", external_name: "add_on_on_billing_entity"} + create_mapping("AddOn", add_on.id, billing_entity:, settings:) + end + let(:fixed_charge_add_on_mapping_on_billing_entity) do + settings = {external_id: "fixed_charge_on_billing_entity", external_account_code: "21", external_name: "fixed_charge_on_billing_entity"} + create_mapping("AddOn", fixed_charge_add_on.id, billing_entity:, settings:) + end + let(:billable_metric_mapping_on_billing_entity) do + settings = {external_id: "billable_metric_on_billing_entity", external_account_code: "12", external_name: "billable_metric_on_billing_entity"} + create_mapping("BillableMetric", billable_metric.id, billing_entity:, settings:) + end + let(:commitment_mapping_on_billing_entity) do + settings = {external_id: "commitment_on_billing_entity", external_account_code: "13", external_name: "commitment_on_billing_entity"} + create_collection_mapping(:minimum_commitment, billing_entity:, settings:) + end + let(:subscription_mapping_on_billing_entity) do + settings = {external_id: "subscription_on_billing_entity", external_account_code: "14", external_name: "subscription_on_billing_entity"} + create_collection_mapping(:subscription_fee, billing_entity:, settings:) + end + let(:account_mapping_on_billing_entity) do + settings = {external_id: "account_on_billing_entity", external_account_code: "15", external_name: "account_on_billing_entity"} + create_collection_mapping(:account, billing_entity:, settings:) + end + let(:credit_note_mapping_on_billing_entity) do + settings = {external_id: "credit_note_on_billing_entity", external_account_code: "16", external_name: "credit_note_on_billing_entity"} + create_collection_mapping(:credit_note, billing_entity:, settings:) + end + let(:prepaid_credit_mapping_on_billing_entity) do + settings = {external_id: "prepaid_credit_on_billing_entity", external_account_code: "17", external_name: "prepaid_credit_on_billing_entity"} + create_collection_mapping(:prepaid_credit, billing_entity:, settings:) + end + let(:tax_mapping_on_billing_entity) do + settings = {external_id: "tax_on_billing_entity", external_account_code: "18", external_name: "tax_on_billing_entity"} + create_collection_mapping(:tax, billing_entity:, settings:) + end + let(:coupon_mapping_on_billing_entity) do + settings = {external_id: "coupon_on_billing_entity", external_account_code: "19", external_name: "coupon_on_billing_entity"} + create_collection_mapping(:coupon, billing_entity:, settings:) + end + let(:fallback_item_on_billing_entity) do + settings = {external_id: "fallback_item_on_billing_entity", external_account_code: "20", external_name: "fallback_item_on_billing_entity"} + create_collection_mapping(:fallback_item, billing_entity:, settings:) + end + + let(:add_on_mapping_on_organization) do + settings = {external_id: "add_on_on_organization", external_account_code: "111", external_name: "add_on_on_organization"} + create_mapping("AddOn", add_on.id, billing_entity: nil, settings:) + end + let(:fixed_charge_add_on_mapping_on_organization) do + settings = {external_id: "fixed_charge_on_organization", external_account_code: "121", external_name: "fixed_charge_on_organization"} + create_mapping("AddOn", fixed_charge_add_on.id, billing_entity: nil, settings:) + end + let(:billable_metric_mapping_on_organization) do + settings = {external_id: "billable_metric_on_organization", external_account_code: "112", external_name: "billable_metric_on_organization"} + create_mapping("BillableMetric", billable_metric.id, billing_entity: nil, settings:) + end + let(:commitment_mapping_on_organization) do + settings = {external_id: "commitment_on_organization", external_account_code: "113", external_name: "commitment_on_organization"} + create_collection_mapping(:minimum_commitment, billing_entity: nil, settings:) + end + let(:subscription_mapping_on_organization) do + settings = {external_id: "subscription_on_organization", external_account_code: "114", external_name: "subscription_on_organization"} + create_collection_mapping(:subscription_fee, billing_entity: nil, settings:) + end + let(:account_mapping_on_organization) do + settings = {external_id: "account_on_organization", external_account_code: "115", external_name: "account_on_organization"} + create_collection_mapping(:account, billing_entity: nil, settings:) + end + let(:credit_note_mapping_on_organization) do + settings = {external_id: "credit_note_on_organization", external_account_code: "116", external_name: "credit_note_on_organization"} + create_collection_mapping(:credit_note, billing_entity: nil, settings:) + end + let(:prepaid_credit_mapping_on_organization) do + settings = {external_id: "prepaid_credit_on_organization", external_account_code: "117", external_name: "prepaid_credit_on_organization"} + create_collection_mapping(:prepaid_credit, billing_entity: nil, settings:) + end + let(:tax_mapping_on_organization) do + settings = {external_id: "tax_on_organization", external_account_code: "118", external_name: "tax_on_organization"} + create_collection_mapping(:tax, billing_entity: nil, settings:) + end + let(:coupon_mapping_on_organization) do + settings = {external_id: "coupon_on_organization", external_account_code: "119", external_name: "coupon_on_organization"} + create_collection_mapping(:coupon, billing_entity: nil, settings:) + end + let(:fallback_item_on_organization) do + settings = {external_id: "fallback_item_on_organization", external_account_code: "120", external_name: "fallback_item_on_organization"} + create_collection_mapping(:fallback_item, billing_entity: nil, settings:) + end + + let(:default_mapping_codes) do + { + add_on: {external_id: "add_on_on_billing_entity", external_account_code: "11", external_name: "add_on_on_billing_entity"}, + fixed_charge: {external_id: "fixed_charge_on_billing_entity", external_account_code: "21", external_name: "fixed_charge_on_billing_entity"}, + billable_metric: {external_id: "billable_metric_on_billing_entity", external_account_code: "12", external_name: "billable_metric_on_billing_entity"}, + minimum_commitment: {external_id: "commitment_on_billing_entity", external_account_code: "13", external_name: "commitment_on_billing_entity"}, + subscription: {external_id: "subscription_on_billing_entity", external_account_code: "14", external_name: "subscription_on_billing_entity"}, + account: {external_id: "account_on_billing_entity", external_account_code: "15", external_name: "account_on_billing_entity"}, + credit_note: {external_id: "credit_note_on_billing_entity", external_account_code: "16", external_name: "credit_note_on_billing_entity"}, + prepaid_credit: {external_id: "prepaid_credit_on_billing_entity", external_account_code: "17", external_name: "prepaid_credit_on_billing_entity"}, + tax: {external_id: "tax_on_billing_entity", external_account_code: "18", external_name: "tax_on_billing_entity"}, + coupon: {external_id: "coupon_on_billing_entity", external_account_code: "19", external_name: "coupon_on_billing_entity"}, + fallback_item: {external_id: "fallback_item_on_billing_entity", external_account_code: "20", external_name: "fallback_item_on_billing_entity"} + } + end + + before do + add_on_mapping_on_billing_entity + fixed_charge_add_on_mapping_on_billing_entity + billable_metric_mapping_on_billing_entity + commitment_mapping_on_billing_entity + subscription_mapping_on_billing_entity + account_mapping_on_billing_entity + credit_note_mapping_on_billing_entity + prepaid_credit_mapping_on_billing_entity + tax_mapping_on_billing_entity + coupon_mapping_on_billing_entity + + add_on_mapping_on_organization + fixed_charge_add_on_mapping_on_organization + billable_metric_mapping_on_organization + commitment_mapping_on_organization + subscription_mapping_on_organization + account_mapping_on_organization + credit_note_mapping_on_organization + prepaid_credit_mapping_on_organization + tax_mapping_on_organization + coupon_mapping_on_organization + + fallback_item_on_billing_entity + + fallback_item_on_organization + + integration_customer + add_on_credit_note_item + fixed_charge_credit_note_item + billable_metric_credit_note_item + minimum_commitment_credit_note_item + subscription_credit_note_item + credit_note.reload + + payment + end + + def skip_mapping?(billing_entity) + create_mapping_for_billing_entity = (billing_entity.present? && mappings_on.include?(:billing_entity)) || + (billing_entity.blank? && mappings_on.include?(:organization)) + !create_mapping_for_billing_entity + end + + def skip_fallback_item?(billing_entity) + create_fallback_items_for_billing_entity = (billing_entity.present? && fallback_items_on.include?(:billing_entity)) || + (billing_entity.blank? && fallback_items_on.include?(:organization)) + !create_fallback_items_for_billing_entity + end + + def create_mapping(mappable_type, mappable_id, billing_entity: nil, settings: {}) + return if skip_mapping?(billing_entity) + + create("#{integration_type}_mapping", integration:, mappable_type:, mappable_id:, billing_entity:, settings:) + end + + def create_collection_mapping(mapping_type, billing_entity: nil, settings: {}) + return if mapping_type == :fallback_item && skip_fallback_item?(billing_entity) + return if mapping_type != :fallback_item && skip_mapping?(billing_entity) + + create("#{integration_type}_collection_mapping", integration:, billing_entity:, mapping_type:, settings:) + end + + context "when the mapping is on the billing entity" do + it "returns the payload body" do + expect(payload).to match build_expected_payload(default_mapping_codes) + end + end + + context "when the mapping is not on the billing entity but there are fallback items" do + let(:mappings_on) { [:organization] } + let(:fallback_items_on) { [:billing_entity] } + + it "returns the payload body" do + fallback = {external_id: "fallback_item_on_billing_entity", external_account_code: "20", external_name: "fallback_item_on_billing_entity"} + expect(payload).to match build_expected_payload({ + add_on: fallback, + fixed_charge: fallback, + billable_metric: fallback, + minimum_commitment: fallback, + subscription: fallback, + account: fallback, + credit_note: fallback, + prepaid_credit: fallback, + tax: fallback, + coupon: fallback + }) + end + end + + context "when the mapping is only on the organization" do + let(:mappings_on) { [:organization] } + let(:fallback_items_on) { [:organization] } + + it "returns the payload body" do + expect(payload).to match build_expected_payload({ + add_on: {external_id: "add_on_on_organization", external_account_code: "111", external_name: "add_on_on_organization"}, + fixed_charge: {external_id: "fixed_charge_on_organization", external_account_code: "121", external_name: "fixed_charge_on_organization"}, + billable_metric: {external_id: "billable_metric_on_organization", external_account_code: "112", external_name: "billable_metric_on_organization"}, + minimum_commitment: {external_id: "commitment_on_organization", external_account_code: "113", external_name: "commitment_on_organization"}, + subscription: {external_id: "subscription_on_organization", external_account_code: "114", external_name: "subscription_on_organization"}, + account: {external_id: "account_on_organization", external_account_code: "115", external_name: "account_on_organization"}, + credit_note: {external_id: "credit_note_on_organization", external_account_code: "116", external_name: "credit_note_on_organization"}, + prepaid_credit: {external_id: "prepaid_credit_on_organization", external_account_code: "117", external_name: "prepaid_credit_on_organization"}, + tax: {external_id: "tax_on_organization", external_account_code: "118", external_name: "tax_on_organization"}, + coupon: {external_id: "coupon_on_organization", external_account_code: "119", external_name: "coupon_on_organization"} + }) + end + end + + context "when there are only fallback items on the organization" do + let(:mappings_on) { [] } + let(:fallback_items_on) { [:organization] } + + it "returns the payload body" do + fallback = {external_id: "fallback_item_on_organization", external_account_code: "120", external_name: "fallback_item_on_organization"} + expect(payload).to match build_expected_payload({ + add_on: fallback, + fixed_charge: fallback, + billable_metric: fallback, + minimum_commitment: fallback, + subscription: fallback, + account: fallback, + credit_note: fallback, + prepaid_credit: fallback, + tax: fallback, + coupon: fallback + }) + end + end +end diff --git a/spec/support/shared_examples/api_requirements.rb b/spec/support/shared_examples/api_requirements.rb new file mode 100644 index 0000000..5bede1e --- /dev/null +++ b/spec/support/shared_examples/api_requirements.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a Premium API endpoint" do + it "requires a premium license" do + allow(License).to receive(:premium?).and_return(false) + subject + expect(response).to have_http_status(:forbidden) + expect(json[:error]).to eq("Forbidden") + expect(json[:code]).to eq("feature_unavailable") + # License.premium? is called + # - once for the API key granular permission + # - once by the PremiumFeatureOnly concerns + expect(License).to have_received(:premium?).twice + end +end + +RSpec.shared_examples "requires API permission" do |resource, mode| + describe "permissions", :premium do + let(:api_key) { organization.api_keys.first } + + before do + organization.update!(premium_integrations:) + api_key.update!(permissions: api_key.permissions.merge(resource => modes)) + subject + end + + context "when organization has 'api_permissions' premium integration" do + let(:premium_integrations) { organization.premium_integrations.including("api_permissions") } + + context "when API key allows #{mode} action for #{resource}" do + let(:modes) { [ApiKey::MODES, [mode]].sample } + + it "does not return 403 Forbidden" do + expect(response).not_to have_http_status(:forbidden) + end + end + + context "when API key forbids #{mode} action for #{resource}" do + let(:modes) { ApiKey::MODES.excluding(mode) } + + it "returns 403 Forbidden" do + expect(response).to have_http_status(:forbidden) + expect(json).to match hash_including(code: "#{mode}_action_not_allowed_for_#{resource}") + end + end + end + + context "when organization has no 'api_permissions' premium integration" do + let(:premium_integrations) { organization.premium_integrations.excluding("api_permissions") } + + context "when API key allows #{mode} action for #{resource}" do + let(:modes) { [ApiKey::MODES, [mode]].sample } + + it "does not return 403 Forbidden" do + expect(response).not_to have_http_status(:forbidden) + end + end + + context "when API key forbids #{mode} action for #{resource}" do + let(:modes) { ApiKey::MODES.excluding(mode) } + + it "does not return 403 Forbidden" do + expect(response).not_to have_http_status(:forbidden) + end + end + end + end +end diff --git a/spec/support/shared_examples/applied_coupon_index.rb b/spec/support/shared_examples/applied_coupon_index.rb new file mode 100644 index 0000000..2e4f239 --- /dev/null +++ b/spec/support/shared_examples/applied_coupon_index.rb @@ -0,0 +1,167 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a applied coupon index endpoint" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:params) { {} } + + let(:coupon_1) { create(:coupon, coupon_type: "fixed_amount", organization:) } + let(:coupon_2) { create(:coupon, coupon_type: "fixed_amount", organization:) } + + let!(:applied_coupon_1) do + create( + :applied_coupon, + customer:, + coupon: coupon_1, + amount_cents: 10, + amount_currency: customer.currency + ) + end + let!(:applied_coupon_2) do + create( + :applied_coupon, + customer: customer, + coupon: coupon_2, + amount_cents: 10, + amount_currency: customer.currency + ) + end + + before do + create(:credit, applied_coupon: applied_coupon_1, amount_cents: 2, amount_currency: customer.currency) + end + + include_examples "requires API permission", "applied_coupon", "read" + + it "returns applied coupons" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons].count).to eq(2) + expect(json[:applied_coupons].first[:lago_id]).to eq(applied_coupon_2.id) + expect(json[:applied_coupons].last[:lago_id]).to eq(applied_coupon_1.id) + expect(json[:applied_coupons].last[:amount_cents]).to eq(applied_coupon_1.amount_cents) + expect(json[:applied_coupons].last[:amount_cents_remaining]).to eq(8) + + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(nil) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(1) + expect(json[:meta][:total_count]).to eq(2) + end + + context "with pagination" do + let(:params) { {page: 2, per_page: 1} } + + it "returns paginated applied coupons" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons].count).to eq(1) + expect(json[:applied_coupons].first[:lago_id]).to eq(applied_coupon_1.id) + + expect(json[:meta][:current_page]).to eq(2) + expect(json[:meta][:next_page]).to eq(nil) + expect(json[:meta][:prev_page]).to eq(1) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + + context "with status filter" do + let(:params) { {status: "active"} } + + it "returns only the applied coupons with the specified status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons].count).to eq(2) + expect(json[:applied_coupons].first[:lago_id]).to eq(applied_coupon_2.id) + expect(json[:applied_coupons].last[:lago_id]).to eq(applied_coupon_1.id) + end + + context "when no applied coupons match the status" do + let(:params) { {status: "terminated"} } + + it "returns an empty array" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons]).to be_empty + end + end + end + + context "with coupon_code filter" do + context "when coupon_code fitlering is an array" do + let(:params) { {coupon_code: [coupon_1.code]} } + + it "returns only the applied coupons for the specified coupon code" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons].count).to eq(1) + expect(json[:applied_coupons].first[:lago_id]).to eq(applied_coupon_1.id) + end + + context "when the coupon is deleted" do + let(:coupon_1) { create(:coupon, :deleted, organization:) } + let!(:applied_coupon_1) do + create( + :applied_coupon, + :terminated, + customer:, + coupon: coupon_1, + amount_cents: 10, + amount_currency: customer.currency + ) + end + + it "returns the applied coupon" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons].count).to eq(1) + expect(json[:applied_coupons].first[:lago_id]).to eq(applied_coupon_1.id) + end + end + end + + context "when no applied coupons match the coupon code" do + let(:params) { {coupon_code: ["non_existent_code"]} } + + it "returns an empty array" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons]).to be_empty + end + end + end + + context "when the coupon is deleted" do + let(:coupon_1) { create(:coupon, :deleted, organization:) } + let!(:applied_coupon_1) do + create( + :applied_coupon, + :terminated, + customer:, + coupon: coupon_1, + amount_cents: 10, + amount_currency: customer.currency + ) + end + + it "returns the applied coupon" do + subject + + expect(response).to have_http_status(:success) + expect(json[:applied_coupons].count).to eq(2) + expect(json[:applied_coupons].last[:lago_id]).to eq(applied_coupon_1.id) + expect(json[:applied_coupons].last[:coupon_code]).to eq(coupon_1.code) + expect(json[:applied_coupons].last[:coupon_name]).to eq(coupon_1.name) + expect(json[:applied_coupons].last[:coupon_status]).to eq(coupon_1.status) + expect(json[:applied_coupons].last[:coupon_deleted_at]).to eq(coupon_1.deleted_at.iso8601) + end + end +end diff --git a/spec/support/shared_examples/applied_invoice_custom_sections.rb b/spec/support/shared_examples/applied_invoice_custom_sections.rb new file mode 100644 index 0000000..4ce97df --- /dev/null +++ b/spec/support/shared_examples/applied_invoice_custom_sections.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +RSpec.shared_examples "applies invoice_custom_sections" do + let(:invoice_custom_sections) { create_list(:invoice_custom_section, 4, organization:) } + + before do + invoice_custom_sections[2..3].each do |section| + create(:billing_entity_applied_invoice_custom_section, organization:, billing_entity:, invoice_custom_section: section) + end + end + + context "when the customer has :skip_invoice_custom_sections flag" do + before { customer.update!(skip_invoice_custom_sections: true) } + + it "doesn't create applied_invoice_custom_section" do + expect { service_call }.not_to change(AppliedInvoiceCustomSection, :count) + end + end + + context "when customer follows billing_entity invoice_custom_sections" do + it "creates applied_invoice_custom_sections" do + result = service_call + invoice = result.invoice + expect(invoice.applied_invoice_custom_sections.pluck(:code)).to match_array(billing_entity.selected_invoice_custom_sections.pluck(:code)) + end + end +end diff --git a/spec/support/shared_examples/charges/presentation_group_keys_validation.rb b/spec/support/shared_examples/charges/presentation_group_keys_validation.rb new file mode 100644 index 0000000..ef5eba1 --- /dev/null +++ b/spec/support/shared_examples/charges/presentation_group_keys_validation.rb @@ -0,0 +1,185 @@ +# frozen_string_literal: true + +RSpec.shared_examples "presentation_group_keys property validation" do + let(:grouping_properties) { {"presentation_group_keys" => presentation_group_keys} } + let(:presentation_group_keys) { nil } + + it { expect(validation_service).to be_valid } + + context "when presentation_group_keys is an empty array" do + let(:presentation_group_keys) { [] } + + it "is valid" do + expect(validation_service).to be_valid + end + end + + context "when presentation_group_keys is valid with 1 element" do + let(:presentation_group_keys) { [{"value" => "region"}] } + + it "is valid" do + expect(validation_service).to be_valid + end + end + + context "when presentation_group_keys is valid with 2 elements" do + let(:presentation_group_keys) { [{"value" => "region"}, {"value" => "country"}] } + + it "is valid" do + expect(validation_service).to be_valid + end + end + + context "when presentation_group_keys has options" do + let(:presentation_group_keys) do + [ + {"value" => "region", "options" => {"display_in_invoice" => true}}, + {"value" => "country", "options" => {"display_in_invoice" => false}} + ] + end + + it "is valid" do + expect(validation_service).to be_valid + end + end + + context "when presentation_group_keys has options with non-hash value" do + let(:presentation_group_keys) { [{"value" => "region", "options" => "invalid"}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys has options with unknown key" do + let(:presentation_group_keys) { [{"value" => "region", "options" => {"unknown" => true}}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys has options with extra keys" do + let(:presentation_group_keys) do + [{"value" => "region", "options" => {"display_in_invoice" => true, "extra" => false}}] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys has options with non-boolean display_in_invoice" do + let(:presentation_group_keys) { [{"value" => "region", "options" => {"display_in_invoice" => "true"}}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys has more than 2 elements" do + let(:presentation_group_keys) do + [ + {"value" => "region"}, + {"value" => "country"}, + {"value" => "city"} + ] + end + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("too_many_keys") + end + end + + context "when presentation_group_keys is not an array" do + let(:presentation_group_keys) { "not_an_array" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys contains non-hash elements" do + let(:presentation_group_keys) { ["region", "country"] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys contains hashes without 'value' key" do + let(:presentation_group_keys) { [{"key" => "region"}, {"value" => "country"}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys contains hashes with nil value" do + let(:presentation_group_keys) { [{"value" => nil}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys contains hashes with empty string value" do + let(:presentation_group_keys) { [{"value" => ""}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys contains hashes with numeric value" do + let(:presentation_group_keys) { [{"value" => 123}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end + + context "when presentation_group_keys contains hashes with extra keys" do + let(:presentation_group_keys) { [{"value" => "region", "extra" => "nope"}] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:presentation_group_keys) + expect(validation_service.result.error.messages[:presentation_group_keys]).to include("invalid_type") + end + end +end diff --git a/spec/support/shared_examples/charges/pricing_group_keys_validation.rb b/spec/support/shared_examples/charges/pricing_group_keys_validation.rb new file mode 100644 index 0000000..bcafd29 --- /dev/null +++ b/spec/support/shared_examples/charges/pricing_group_keys_validation.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +RSpec.shared_examples "pricing_group_keys property validation" do + let(:grouping_properties) { {"pricing_group_keys" => pricing_group_keys} } + let(:pricing_group_keys) { [] } + + it { expect(validation_service).to be_valid } + + context "when attribute is not an array" do + let(:pricing_group_keys) { "group" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:pricing_group_keys) + expect(validation_service.result.error.messages[:pricing_group_keys]).to include("invalid_type") + end + end + + context "when attribute is not a list of string" do + let(:pricing_group_keys) { [12, 45] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:pricing_group_keys) + expect(validation_service.result.error.messages[:pricing_group_keys]).to include("invalid_type") + end + end + + context "when attribute is an empty string" do + let(:pricing_group_keys) { "" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:pricing_group_keys) + expect(validation_service.result.error.messages[:pricing_group_keys]).to include("invalid_type") + end + end + + context "when using legacy grouped_by property" do + let(:grouping_properties) { {"grouped_by" => grouped_by} } + let(:grouped_by) { [] } + + it { expect(validation_service).to be_valid } + + context "when attribute is not an array" do + let(:grouped_by) { "group" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:grouped_by) + expect(validation_service.result.error.messages[:grouped_by]).to include("invalid_type") + end + end + + context "when attribute is not a list of string" do + let(:grouped_by) { [12, 45] } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:grouped_by) + expect(validation_service.result.error.messages[:grouped_by]).to include("invalid_type") + end + end + + context "when attribute is an empty string" do + let(:grouped_by) { "" } + + it "is invalid" do + expect(validation_service).not_to be_valid + expect(validation_service.result.error).to be_a(BaseService::ValidationFailure) + expect(validation_service.result.error.messages.keys).to include(:grouped_by) + expect(validation_service.result.error.messages[:grouped_by]).to include("invalid_type") + end + end + end +end diff --git a/spec/support/shared_examples/creates_webhook.rb b/spec/support/shared_examples/creates_webhook.rb new file mode 100644 index 0000000..666eb58 --- /dev/null +++ b/spec/support/shared_examples/creates_webhook.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +RSpec.shared_examples "creates webhook" do |webhook_type, object_type, object = {}| + it "create correct webhook model" do + webhook_service.call + + webhook = Webhook.order(created_at: :desc).first + expect(webhook.payload).to match({ + "webhook_type" => webhook_type, + "object_type" => object_type, + "organization_id" => webhook.organization_id, + object_type => hash_including(object) + }) + end +end diff --git a/spec/support/shared_examples/credit_note_index.rb b/spec/support/shared_examples/credit_note_index.rb new file mode 100644 index 0000000..6db8c79 --- /dev/null +++ b/spec/support/shared_examples/credit_note_index.rb @@ -0,0 +1,458 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a credit note index endpoint" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:params) { {} } + + context "with no params" do + let(:invoices) { create_pair(:invoice, organization:, customer:) } + + let!(:credit_notes) do + invoices.map { |invoice| create(:credit_note, invoice:, customer:) } + end + + include_examples "requires API permission", "credit_note", "read" + + it "returns a list of credit notes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].first[:items]).to be_empty + expect(json[:credit_notes].pluck(:lago_id)).to match_array credit_notes.pluck(:id) + end + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + let(:invoices) { create_pair(:invoice, organization:, customer:) } + + before do + invoices.map { |invoice| create(:credit_note, invoice:, customer:) } + end + + it "returns the metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(1) + + expect(json[:meta]).to include( + current_page: 1, + next_page: 2, + prev_page: nil, + total_pages: 2, + total_count: 2 + ) + end + end + + context "with reason filter" do + let(:params) { {reason: matching_reasons} } + let(:matching_reasons) { CreditNote::REASON.sample(2) } + + let!(:matching_credit_notes) do + matching_reasons.map { |reason| create(:credit_note, reason:, customer:) } + end + + before do + create( + :credit_note, + reason: CreditNote::REASON.excluding(matching_reasons).sample, + customer: + ) + end + + it "returns credit notes with matching reasons" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array matching_credit_notes.pluck(:id) + end + end + + context "with credit status filter" do + let(:params) { {credit_status: matching_credit_statuses} } + let(:matching_credit_statuses) { CreditNote::CREDIT_STATUS.sample(2) } + + let!(:matching_credit_notes) do + matching_credit_statuses.map do |credit_status| + create(:credit_note, credit_status:, customer:) + end + end + + before do + create( + :credit_note, + credit_status: CreditNote::CREDIT_STATUS.excluding(matching_credit_statuses).sample, + customer: + ) + end + + it "returns credit notes with matching credit statuses" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array matching_credit_notes.pluck(:id) + end + end + + context "with refund status filter" do + let(:params) { {refund_status: matching_refund_statuses} } + let(:matching_refund_statuses) { CreditNote::REFUND_STATUS.sample(2) } + + let!(:matching_credit_notes) do + matching_refund_statuses.map do |refund_status| + create(:credit_note, refund_status:, customer:) + end + end + + before do + create( + :credit_note, + refund_status: CreditNote::REFUND_STATUS.excluding(matching_refund_statuses).sample, + customer: + ) + end + + it "returns credit notes with matching refund statuses" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array matching_credit_notes.pluck(:id) + end + end + + context "with types filter" do + let(:params) { {types: types} } + + let(:credit_only) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 0, offset_amount_cents: 0) + end + + let(:refund_only) do + create(:credit_note, customer:, credit_amount_cents: 0, refund_amount_cents: 10, offset_amount_cents: 0) + end + + let(:offset_only) do + create(:credit_note, customer:, credit_amount_cents: 0, refund_amount_cents: 0, offset_amount_cents: 10) + end + + let(:credit_and_refund) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 10, offset_amount_cents: 0) + end + + let(:credit_and_offset) do + create(:credit_note, customer:, credit_amount_cents: 10, refund_amount_cents: 0, offset_amount_cents: 10) + end + + before do + credit_only + refund_only + offset_only + credit_and_refund + credit_and_offset + end + + context "when type is credit" do + let(:types) { "credit" } + + it "returns credit notes with positive credit amount" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array([credit_only.id, credit_and_refund.id, credit_and_offset.id]) + end + end + + context "when type is refund" do + let(:types) { "refund" } + + it "returns credit notes with positive refund amount" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array([refund_only.id, credit_and_refund.id]) + end + end + + context "when type is offset" do + let(:types) { "offset" } + + it "returns credit notes with positive offset amount" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array([offset_only.id, credit_and_offset.id]) + end + end + + context "when multiple types are provided" do + let(:types) { %w[credit refund] } + + it "returns credit notes matching any of the given types" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array([credit_only.id, refund_only.id, credit_and_refund.id, credit_and_offset.id]) + end + end + end + + context "with invoice number filter" do + let(:params) { {invoice_number: matching_credit_note.invoice.number} } + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + invoice = create(:invoice, customer:, number: "FOO-01") + create(:credit_note, customer:, invoice:) + end + + it "returns credit notes with matching invoice number" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to contain_exactly matching_credit_note.id + end + end + + context "with issuing date filters" do + let(:params) do + { + issuing_date_from: credit_notes.second.issuing_date, + issuing_date_to: credit_notes.fourth.issuing_date + } + end + + let!(:credit_notes) do + (1..5).to_a.map do |i| + create(:credit_note, issuing_date: i.days.ago, customer:) + end.reverse # from oldest to newest + end + + it "returns credit notes that were issued between provided dates" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array credit_notes[1..3].pluck(:id) + end + end + + context "with amount filters" do + let(:params) do + { + amount_from: credit_notes.second.total_amount_cents, + amount_to: credit_notes.fourth.total_amount_cents + } + end + + let!(:credit_notes) do + (1..5).to_a.map do |i| + create(:credit_note, total_amount_cents: i.succ * 1_000, customer:) + end # from smallest to biggest + end + + it "returns credit notes with total cents amount in provided range" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to match_array credit_notes[1..3].pluck(:id) + end + end + + context "with self billed invoice filter" do + let(:params) { {self_billed: true} } + + let(:self_billed_credit_note) do + invoice = create(:invoice, :self_billed, customer:, organization:) + + create(:credit_note, invoice:, customer:) + end + + let(:non_self_billed_credit_note) do + create(:credit_note, customer:) + end + + before do + self_billed_credit_note + non_self_billed_credit_note + end + + it "returns self billed credit_notes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(1) + expect(json[:credit_notes].first[:lago_id]).to eq(self_billed_credit_note.id) + end + + context "when self billed is false" do + let(:params) { {self_billed: false} } + + it "returns non self billed credit_notes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(1) + expect(json[:credit_notes].first[:lago_id]).to eq(non_self_billed_credit_note.id) + end + end + + context "when self billed is nil" do + let(:params) { {self_billed: nil} } + + it "returns all credit_notes" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(2) + end + end + end + + context "with search term" do + let(:params) { {search_term: matching_credit_note.invoice.number} } + let!(:matching_credit_note) { create(:credit_note, customer:) } + + before do + invoice = create(:invoice, customer:, number: "FOO-01") + create(:credit_note, customer:, invoice:) + end + + it "returns credit notes matching the search terms" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to contain_exactly matching_credit_note.id + end + end + + context "with billing entity codes filter" do + let(:params) { {billing_entity_codes: [billing_entity.code]} } + let(:billing_entity) { create(:billing_entity, organization:) } + let(:matching_credit_note) { create(:credit_note, customer:, invoice: create(:invoice, billing_entity:)) } + let(:other_credit_note) { create(:credit_note, customer:) } + + before do + matching_credit_note + other_credit_note + end + + it "returns credit notes with matching billing entity code" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].pluck(:lago_id)).to contain_exactly matching_credit_note.id + end + + context "when one of billing entity codes is not found" do + let(:params) { {billing_entity_codes: [billing_entity.code, SecureRandom.uuid]} } + + it "returns an error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("billing_entity_not_found") + end + end + end + + context "with integration_customers in response" do + let(:customer) { create(:customer, :with_tax_integration, organization:) } + + before do + create(:credit_note, customer:) + end + + it "includes integration_customers in the customer payload" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].first[:customer][:integration_customers].count).to eq(1) + expect(json[:credit_notes].first[:customer][:integration_customers].first[:lago_id]).to eq(customer.anrok_customer.id) + end + end + + context "with credit notes containing all associations", :bullet do + before do + # NOTE: Bullet cannot track ActiveStorage's internal blob access through the attachment proxy + Bullet.add_safelist(type: :unused_eager_loading, class_name: "ActiveStorage::Attachment", association: :blob) + # NOTE: The charge include is needed for charge-type fees but true-up fees have no charge + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Fee", association: :charge) + # NOTE: billable_metric is only accessed for charge-type fees; subscription fees never touch it + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Fee", association: :billable_metric) + + # NOTE: Adding the customer payment associations to Bullet safelist, Bullet is right regarding the associations + # not being used in the CreditNoteSerializer, but we need to eager load them in order to prevent + # N+1 queries in the CustomerSerializer when serializing the credit note customer + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :stripe_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :gocardless_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :cashfree_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :adyen_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :moneyhash_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :integration_customers) + + invoices = create_list(:invoice, 3, organization:, customer:) + invoices.each do |invoice| + subscription = create(:subscription, customer:, organization:) + charge = create(:standard_charge, plan: subscription.plan) + charge_filter = create(:charge_filter, charge:) + fee = create(:fee, invoice:, subscription:, charge:, charge_filter:, organization:) + create(:fee, true_up_parent_fee: fee, invoice:, subscription:, organization:) + create(:pricing_unit_usage, fee:, organization:) + + credit_note = create(:credit_note, :with_file, invoice:, customer:) + create(:credit_note_item, credit_note:, fee:, organization:) + create(:credit_note_applied_tax, credit_note:, organization:) + create(:error_detail, owner: credit_note, organization:) + create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar"}) + end + end + + it "does not trigger N+1 queries" do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(3) + json[:credit_notes].each do |credit_note| + expect(credit_note[:items]).not_to be_empty + expect(credit_note[:applied_taxes]).not_to be_empty + expect(credit_note[:error_details]).not_to be_empty + expect(credit_note[:metadata]).to eq(foo: "bar") + end + end + end + + context "with metadata" do + let(:params) { {} } + + before do + # NOTE: Adding the customer payment associations to Bullet safelist, Bullet is right regarding the associations + # not being used in the CreditNoteSerializer, but we need to eager load them in order to prevent + # N+1 queries in the CustomerSerializer when serializing the credit note customer + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :stripe_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :gocardless_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :cashfree_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :adyen_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :moneyhash_customer) + Bullet.add_safelist(type: :unused_eager_loading, class_name: "Customer", association: :integration_customers) + + invoices = create_list(:invoice, 3, organization:, customer:) + invoices.each do |invoice| + credit_note = create(:credit_note, invoice:, customer:) + create(:item_metadata, owner: credit_note, organization:, value: {"foo" => "bar"}) + end + end + + it "returns metadata for each credit note", :bullet do + subject + + expect(response).to have_http_status(:success) + expect(json[:credit_notes].count).to eq(3) + json[:credit_notes].each do |credit_note| + expect(credit_note[:metadata]).to eq(foo: "bar") + end + end + end +end diff --git a/spec/support/shared_examples/graphql_requirements.rb b/spec/support/shared_examples/graphql_requirements.rb new file mode 100644 index 0000000..23083d2 --- /dev/null +++ b/spec/support/shared_examples/graphql_requirements.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +RSpec.shared_examples "requires current user" do + it "requires a current user" do + expect(described_class.ancestors).to include(AuthenticableApiUser) + end +end + +RSpec.shared_examples "requires current organization" do + it "requires a current organization" do + expect(described_class.ancestors).to include(RequiredOrganization) + end +end + +RSpec.shared_examples "requires permission" do |permission| + it "requires #{permission} permission" do + actual = Array.wrap(described_class::REQUIRED_PERMISSION).sort + expected = Array.wrap(permission).sort + expect(actual).to eq expected + end +end + +RSpec.shared_examples "requires Premium license" do + it "returns an error" do + allow(License).to receive(:premium?).and_return(false) + + expect_graphql_error( + result: subject, + message: "forbidden" + ) + expect(License).to have_received(:premium?) + end +end + +RSpec.shared_examples "requires a customer portal user" do + it "requires a customer portal user" do + expect(described_class.ancestors).to include(AuthenticableCustomerPortalUser) + end +end diff --git a/spec/support/shared_examples/integrations.rb b/spec/support/shared_examples/integrations.rb new file mode 100644 index 0000000..d9427ea --- /dev/null +++ b/spec/support/shared_examples/integrations.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +RSpec.shared_examples "syncs invoice" do + context "when it should sync invoice" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_invoices: true) } + + before do + allow(Integrations::Aggregator::Invoices::CreateJob).to receive(:perform_later) + integration_customer + service_call + end + + it "enqueues Integrations::Aggregator::Invoices::CreateJob" do + expect(Integrations::Aggregator::Invoices::CreateJob).to have_received(:perform_later) + end + end +end + +RSpec.shared_examples "syncs credit note" do + context "when it should sync credit note" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_credit_notes: true) } + + before do + allow(Integrations::Aggregator::CreditNotes::CreateJob).to receive(:perform_later) + integration_customer + service_call + end + + it "enqueues Integrations::Aggregator::CreditNotes::CreateJob" do + expect(Integrations::Aggregator::CreditNotes::CreateJob).to have_received(:perform_later) + end + end +end + +RSpec.shared_examples "syncs payment" do + context "when it should sync payment" do + let(:integration_customer) { create(:netsuite_customer, integration:, customer:) } + let(:integration) { create(:netsuite_integration, organization:, sync_payments: true) } + + before do + allow(Integrations::Aggregator::Payments::CreateJob).to receive(:perform_later) + integration_customer + service_call + end + + it "enqueues Integrations::Aggregator::Payments::CreateJob" do + expect(Integrations::Aggregator::Payments::CreateJob).to have_received(:perform_later) + end + end +end + +RSpec.shared_examples "throttles!" do |*providers| + before { allow(service).to receive(:throttle!) } + + it "calls throttle!" do + service.call + expect(service).to have_received(:throttle!).with(*providers) + end +end diff --git a/spec/support/shared_examples/invoice_index.rb b/spec/support/shared_examples/invoice_index.rb new file mode 100644 index 0000000..724f43f --- /dev/null +++ b/spec/support/shared_examples/invoice_index.rb @@ -0,0 +1,489 @@ +# frozen_string_literal: true + +RSpec.shared_examples "an invoice index endpoint" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:tax) { create(:tax, :applied_to_billing_entity, organization:, rate: 20) } + + before { tax } + + context "without params" do + let(:params) { {} } + let!(:invoice) { create(:invoice, :draft, customer:, organization:) } + + include_examples "requires API permission", "invoice", "read" + + it "returns invoices" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first).to include( + lago_id: invoice.id, + payment_status: invoice.payment_status, + status: invoice.status + ) + end + + context "when customer has an integration customer" do + let!(:netsuite_customer) { create(:netsuite_customer, customer:) } + + it "returns an invoice with customer having integration customers" do + subject + + expect(json[:invoices].first[:customer][:integration_customers].first) + .to include(lago_id: netsuite_customer.id) + end + end + end + + context "with pagination" do + let(:params) { {page: 1, per_page: 1} } + + before do + create(:invoice, :draft, customer:, organization:) + create(:invoice, customer:, organization:) + end + + it "returns invoices with correct meta data" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:invoices].count).to eq(1) + expect(json[:meta]).to include( + current_page: 1, + next_page: 2, + prev_page: nil, + total_pages: 2, + total_count: 2 + ) + end + end + + context "when preloading offset amounts" do + let(:params) { {} } + let(:preloadable_invoices) { create_list(:invoice, 2, customer:, organization:) } + + before { preloadable_invoices } + + include_examples "preloads offset amounts" + end + + context "with issuing_date params" do + let(:params) do + {issuing_date_from: 2.days.ago.to_date, issuing_date_to: Date.tomorrow.to_date} + end + + let!(:matching_invoice) do + create(:invoice, customer:, issuing_date: 1.day.ago.to_date, organization:) + end + + before { create(:invoice, customer:, issuing_date: 3.days.ago.to_date, organization:) } + + it "returns invoices with correct issuing date" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + + context "when issuing date is not a valid date" do + let(:params) { {issuing_date_from: "2020 01 01", issuing_date_to: "01/01/2030"} } + + it "returns the result without filtering" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(2) + end + end + end + + context "with status params" do + let(:params) { {status: "finalized"} } + let!(:matching_invoice) { create(:invoice, customer:, organization:) } + + before { create(:invoice, :draft, customer:, organization:) } + + it "returns invoices for the given status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + + context "with statuses params" do + let(:params) { {statuses: ["finalized", "failed"]} } + let(:failed_invoice) { create(:invoice, :failed, customer:, organization:) } + + before { failed_invoice } + + it "returns invoices for the given statuses" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(2) + expect(json[:invoices].map { |i| i[:lago_id] }).to include(matching_invoice.id, failed_invoice.id) + end + end + end + + context "with payment status param" do + let(:params) { {payment_status: "pending"} } + + let!(:matching_invoice) do + create(:invoice, customer:, payment_status: :pending, organization:) + end + let!(:payment_failed_invoice) do + create(:invoice, customer:, payment_status: :failed, organization:) + end + + before do + create(:invoice, customer:, payment_status: :succeeded, organization:) + end + + it "returns invoices with correct payment status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + + context "with multiple payment statuses params" do + let(:params) { {payment_statuses: ["pending", "failed"]} } + + it "returns invoices with correct payment status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(2) + expect(json[:invoices].map { |i| i[:lago_id] }).to include(matching_invoice.id, payment_failed_invoice.id) + end + end + end + + context "with payment overdue param" do + let(:params) { {payment_overdue: true} } + + let!(:matching_invoice) do + create(:invoice, customer:, payment_overdue: true, organization:) + end + + before { create(:invoice, customer:, organization:) } + + it "returns payment overdue invoices" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + end + + context "with invoice type param" do + let(:params) { {invoice_type: "advance_charges"} } + + let!(:matching_invoice) do + create(:invoice, customer:, invoice_type: :advance_charges, organization:) + end + + before { create(:invoice, customer:, invoice_type: :add_on, organization:) } + + it "returns invoices with correct invoice type" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + end + + context "with currency param" do + let(:params) { {currency: "USD"} } + + let!(:matching_invoice) { create(:invoice, customer:, currency: "USD", organization:) } + + before { create(:invoice, customer:, currency: "EUR", organization:) } + + it "returns invoices with correct currency" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + end + + context "with payment dispute lost param" do + let(:params) { {payment_dispute_lost: true} } + + let!(:matching_invoice) { create(:invoice, :dispute_lost, customer:, organization:) } + + before { create(:invoice, customer:, organization:) } + + it "returns invoices with payment dispute lost" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + end + + context "with search term param" do + let(:params) { {search_term: matching_invoice.number} } + + let!(:matching_invoice) do + create(:invoice, customer:, number: SecureRandom.uuid, organization:) + end + + before { create(:invoice, customer:, number: "not-relevant-number", organization:) } + + it "returns invoices matching the search terms" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(matching_invoice.id) + end + end + + context "with amount filters" do + let(:params) do + { + amount_from: invoices.second.total_amount_cents, + amount_to: invoices.fourth.total_amount_cents + } + end + + let!(:invoices) do + (1..5).to_a.map do |i| + create(:invoice, customer:, total_amount_cents: i.succ * 1_000, organization:) + end # from smallest to biggest + end + + it "returns invoices with total cents amount in provided range" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].pluck(:lago_id)).to match_array invoices[1..3].pluck(:id) + end + end + + context "with metadata filters" do + let(:params) do + metadata = matching_invoice.metadata.first + + { + metadata: { + metadata.key => metadata.value + } + } + end + + let(:matching_invoice) { create(:invoice, organization:, customer:) } + + before do + create(:invoice_metadata, invoice: matching_invoice) + create(:invoice, organization:) + end + + it "returns invoices with matching metadata filters" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].pluck(:lago_id)).to contain_exactly matching_invoice.id + end + end + + context "with self billed filters" do + let(:params) { {self_billed: true} } + + let(:self_billed_invoice) do + create(:invoice, :self_billed, customer:, organization:) + end + + let(:non_self_billed_invoice) do + create(:invoice, customer:, organization:) + end + + before do + self_billed_invoice + non_self_billed_invoice + end + + it "returns self billed invoices" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(self_billed_invoice.id) + end + + context "when self billed is false" do + let(:params) { {self_billed: false} } + + it "returns non self billed invoices" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(non_self_billed_invoice.id) + end + end + + context "when self billed is nil" do + let(:params) { {self_billed: nil} } + + it "returns all invoices" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(2) + end + end + end + + context "when invoices are created in multiple billing entities" do + let(:billing_entity2) { create(:billing_entity, organization:) } + let(:params) { {} } + + let(:invoice1) { create(:invoice, :self_billed, customer:, organization:) } + let(:invoice2) { create(:invoice, :self_billed, customer:, organization:, billing_entity: billing_entity2) } + + before do + invoice1 + invoice2 + end + + it "returns all invoices when not filtering by billing entity" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(2) + expect(json[:invoices].pluck(:lago_id)).to match_array([invoice1.id, invoice2.id]) + end + + context "when filtering by billing entity" do + let(:params) { {billing_entity_codes: [billing_entity2.code]} } + + it "returns invoices for the specified billing entity" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(1) + expect(json[:invoices].first[:lago_id]).to eq(invoice2.id) + end + + context "when one of billing entities does not exist" do + let(:params) { {billing_entity_codes: [billing_entity2.code, SecureRandom.uuid]} } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + expect(json[:code]).to eq("billing_entity_not_found") + end + end + end + end + + context "with settlements param" do + let(:params) { {settlements: settlements} } + + let!(:invoice_with_credit_note_settlement) { create(:invoice, customer:, organization:) } + let!(:invoice_with_payment_settlement) { create(:invoice, customer:, organization:) } + + let(:credit_note) do + create( + :credit_note, + invoice: invoice_with_credit_note_settlement, + customer: invoice_with_credit_note_settlement.customer, + organization: + ) + end + + before do + create( + :invoice_settlement, + organization:, + billing_entity: invoice_with_credit_note_settlement.billing_entity, + target_invoice: invoice_with_credit_note_settlement, + settlement_type: :credit_note, + source_credit_note: credit_note + ) + + create( + :invoice_settlement, + organization:, + billing_entity: invoice_with_payment_settlement.billing_entity, + target_invoice: invoice_with_payment_settlement, + settlement_type: :payment, + source_payment: create(:payment) + ) + end + + context "when settlements is credit_note" do + let(:settlements) { "credit_note" } + + it "returns invoices with credit note settlements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].pluck(:lago_id)).to eq([invoice_with_credit_note_settlement.id]) + end + end + + context "when settlements is payment" do + let(:settlements) { "payment" } + + it "returns invoices with payment settlements" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].pluck(:lago_id)).to eq([invoice_with_payment_settlement.id]) + end + end + end + + context "with N+1 query detection", bullet: {n_plus_one_query: true, unused_eager_loading: false} do + let(:params) { {} } + let(:other_billing_entity) { create(:billing_entity, organization:) } + + before do + [customer.billing_entity, other_billing_entity].each do |billing_entity| + invoice = create(:invoice, customer:, organization:, billing_entity:) + create(:invoice_applied_tax, invoice:, tax:, organization:) + create(:invoice_metadata, invoice:, organization:) + + invoice.file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.pdf"))), + filename: "invoice.pdf", + content_type: "application/pdf" + ) + invoice.xml_file.attach( + io: StringIO.new(File.read(Rails.root.join("spec/fixtures/blank.xml"))), + filename: "invoice.xml", + content_type: "application/xml" + ) + end + end + + it "does not trigger N+1 queries on invoice associations" do + subject + + expect(response).to have_http_status(:success) + expect(json[:invoices].count).to eq(2) + json[:invoices].each do |invoice| + expect(invoice[:applied_taxes]).to be_present + expect(invoice[:metadata]).to be_present + expect(invoice[:file_url]).to be_present + expect(invoice[:xml_url]).to be_present + end + end + end +end diff --git a/spec/support/shared_examples/jobs/configurable_queue.rb b/spec/support/shared_examples/jobs/configurable_queue.rb new file mode 100644 index 0000000..6e7448e --- /dev/null +++ b/spec/support/shared_examples/jobs/configurable_queue.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a configurable queue" do |dedicated_queue, env_variable, default_queue = "default"| + let(:arguments) { nil } + + context "when #{env_variable} is true" do + before { ENV[env_variable] = "true" } + after { ENV.delete(env_variable) } + + it "uses the #{dedicated_queue} queue" do + expect { + described_class.perform_later(arguments) + }.to have_enqueued_job.on_queue(dedicated_queue) + end + end + + context "when SIDEKIQ_EVENTS is false or not set" do + before { ENV.delete(env_variable) } + + it "uses the #{default_queue} queue" do + expect { + described_class.perform_later(arguments) + }.to have_enqueued_job.on_queue(default_queue) + end + end +end diff --git a/spec/support/shared_examples/jobs/retry_on_network_errors.rb b/spec/support/shared_examples/jobs/retry_on_network_errors.rb new file mode 100644 index 0000000..349113f --- /dev/null +++ b/spec/support/shared_examples/jobs/retry_on_network_errors.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a retryable on network errors job" do + [ + [LagoHttpClient::HttpError.new(nil, nil, nil), 6], + [Errno::ECONNREFUSED, 6], + [Errno::EHOSTUNREACH, 6], + [Net::OpenTimeout, 6], + [Net::ReadTimeout, 6], + [EOFError, 6] + ].each do |error, attempts| + error_class = error.is_a?(Class) ? error : error.class + + context "when a #{error_class.name} error is raised" do + before do + allow(service_class).to receive(:call).and_raise(error) + end + + it "raises a #{error_class.name} error and retries" do + assert_performed_jobs(attempts, only: [described_class]) do + expect do + if job_arguments.is_a?(Hash) + described_class.perform_later(**job_arguments) + else + described_class.perform_later(job_arguments) + end + end.to raise_error(error_class) + end + end + end + end +end diff --git a/spec/support/shared_examples/jobs/unique_job.rb b/spec/support/shared_examples/jobs/unique_job.rb new file mode 100644 index 0000000..c186c56 --- /dev/null +++ b/spec/support/shared_examples/jobs/unique_job.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a unique job" do + around do |example| + ActiveJob::Uniqueness.reset_manager! + example.run + ActiveJob::Uniqueness.test_mode! + end + + it "does not enqueue duplicate jobs" do + expect do + described_class.perform_later(*job_args) + described_class.perform_later(*job_args) + end.to change { enqueued_jobs.count }.by(1) # rubocop:disable RSpec/ExpectChange + end +end diff --git a/spec/support/shared_examples/organization_feature.rb b/spec/support/shared_examples/organization_feature.rb new file mode 100644 index 0000000..b8911d4 --- /dev/null +++ b/spec/support/shared_examples/organization_feature.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +RSpec.shared_examples "organization premium feature" do |feature_name| + subject { organization.public_send("#{feature_name}_enabled?") } + + it { is_expected.to eq(false) } + + context "when premium features are enabled", :premium do + it { is_expected.to eq(false) } + + context "with #{feature_name} integration enabled" do + let(:organization) do + described_class.new(premium_integrations: [feature_name]) + end + + it { is_expected.to eq(true) } + end + end +end diff --git a/spec/support/shared_examples/paper_trail_traceable.rb b/spec/support/shared_examples/paper_trail_traceable.rb new file mode 100644 index 0000000..a521eb0 --- /dev/null +++ b/spec/support/shared_examples/paper_trail_traceable.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.shared_examples "paper_trail traceable" do + it { is_expected.to be_versioned } + + it "saves expected membership", versioning: true do + CurrentContext.membership = "membership/f03f5cd7-9f6f-4d06-85c4-7ea22d65aa5b" + subject.save! + expect(subject.versions.last.whodunnit).to eq("membership/f03f5cd7-9f6f-4d06-85c4-7ea22d65aa5b") + expect(subject.versions.last.lago_version).to eq("test") + CurrentContext.membership = nil + end +end diff --git a/spec/support/shared_examples/payment_index.rb b/spec/support/shared_examples/payment_index.rb new file mode 100644 index 0000000..7af7a11 --- /dev/null +++ b/spec/support/shared_examples/payment_index.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a payment index endpoint" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + + let(:params) { {} } + + include_examples "requires API permission", "payment", "read" + + it "returns customer's payments" do + invoice = create(:invoice, organization:, customer:) + invoice2 = create(:invoice, organization:, customer:) + payment_request = create(:payment_request, organization:, customer:) + first_payment = create(:payment, payable: invoice, customer:) + second_payment = create(:payment, payable: invoice2, customer:) + third_payment = create(:payment, payable: payment_request, customer:) + + subject + + expect(response).to have_http_status(:success) + expect(json[:payments].count).to eq(3) + expect(json[:payments].map { |r| r[:lago_id] }).to contain_exactly( + first_payment.id, + second_payment.id, + third_payment.id + ) + end + + context "with invoice_id filter" do + let(:invoice) { create(:invoice, organization:, customer:) } + let(:params) { {invoice_id: invoice.id} } + let(:first_payment) { create(:payment, payable: invoice, customer:) } + + before do + first_payment + create(:payment) + end + + it "returns invoice's payments" do + subject + expect(response).to have_http_status(:success) + expect(json[:payments].map { |r| r[:lago_id] }).to contain_exactly(first_payment.id) + expect(json[:payments].first[:invoice_ids].first).to eq(invoice.id) + end + end +end diff --git a/spec/support/shared_examples/payment_request_index.rb b/spec/support/shared_examples/payment_request_index.rb new file mode 100644 index 0000000..77fba8f --- /dev/null +++ b/spec/support/shared_examples/payment_request_index.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a payment request index endpoint" do + let(:organization) { create(:organization) } + let(:params) { {} } + + let(:customer) { create(:customer, organization:) } + let(:payment_request) { create(:payment_request, customer:) } + let(:second_payment_request) { create(:payment_request, customer:) } + + include_examples "requires API permission", "payment_request", "read" + + context "without filters" do + before do + payment_request + second_payment_request + end + + it "returns organization's payment requests" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_requests].count).to eq(2) + expect(json[:payment_requests].map { |r| r[:lago_id] }).to contain_exactly( + payment_request.id, + second_payment_request.id + ) + end + end + + context "with currency filter" do + let!(:usd_payment_request) { create(:payment_request, customer:, amount_currency: "USD") } + let(:params) { {currency: "EUR"} } + + before do + payment_request + usd_payment_request + end + + it "returns only payment requests with matching currency" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_requests].count).to eq(1) + expect(json[:payment_requests].first[:lago_id]).to eq(payment_request.id) + end + end + + context "with payment_status filter" do + let(:second_payment_request) { create(:payment_request, :succeeded, customer:) } + let(:params) { {payment_status: "pending"} } + + before do + payment_request + second_payment_request + end + + it "returns payment requests with the given payment status" do + subject + + expect(response).to have_http_status(:success) + expect(json[:payment_requests].count).to eq(1) + expect(json[:payment_requests].map { |r| r[:lago_id] }).to contain_exactly( + payment_request.id + ) + end + end +end diff --git a/spec/support/shared_examples/preload_offset_amounts.rb b/spec/support/shared_examples/preload_offset_amounts.rb new file mode 100644 index 0000000..4158fb5 --- /dev/null +++ b/spec/support/shared_examples/preload_offset_amounts.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +# Verifies that offset_amount_cents is batch-loaded in a single aggregated query +# instead of N+1 individual queries per invoice. +# +# The caller must define: +# - `preloadable_invoices`: an array of at least 2 invoices to attach credit notes to +# - `subject`: the action that triggers loading and serializing invoices +RSpec.shared_examples "preloads offset amounts" do + before do + preloadable_invoices.each do |invoice| + create(:credit_note, status: :finalized, invoice:, offset_amount_cents: 100) + end + end + + it "uses a single aggregated query instead of N+1" do + query_count = 0 + counter = ->(_name, _start, _finish, _id, payload) { + query_count += 1 if /SELECT SUM.*offset_amount_cents.*FROM.*credit_notes/i.match?(payload[:sql]) + } + + ActiveSupport::Notifications.subscribed(counter, "sql.active_record") do + subject + end + + expect(query_count).to eq(1), "Expected single query to credit_notes table, but got #{query_count}" + end +end diff --git a/spec/support/shared_examples/query_requirements.rb b/spec/support/shared_examples/query_requirements.rb new file mode 100644 index 0000000..a546a3f --- /dev/null +++ b/spec/support/shared_examples/query_requirements.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +shared_examples "an invalid filter" do |filter, value, error_message| + let(:filters) { {filter => value} } + + it "is invalid when #{filter} is set to #{value.inspect}" do + expect(result.success?).to be(false) + expect(result.errors.to_h).to include({filter => error_message}) + end +end diff --git a/spec/support/shared_examples/result_requirements.rb b/spec/support/shared_examples/result_requirements.rb new file mode 100644 index 0000000..b055f0b --- /dev/null +++ b/spec/support/shared_examples/result_requirements.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a result object" do + it { expect(result).to be_success } + it { expect(result).not_to be_failure } + it { expect(result.error).to be_nil } + + it { expect(result.raise_if_error!).to eq(result) } + + describe ".fail_with_error!" do + let(:error) { StandardError.new("custom_error") } + + it "assign the error the result" do + failure = result.fail_with_error!(error) + + expect(failure).to eq(result) + expect(result).not_to be_success + expect(result).to be_failure + expect(result.error).to eq(error) + end + end + + describe ".forbidden_failure!" do + before { result.forbidden_failure! } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ForbiddenFailure) } + it { expect(result.error.code).to eq("feature_unavailable") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::ForbiddenFailure) } + + context "when passing a code to the failure" do + before { result.forbidden_failure!(code: "custom_code") } + + it { expect(result.error.code).to eq("custom_code") } + end + end + + describe ".not_allowed_failure!" do + before { result.not_allowed_failure!(code: "custom_code") } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::MethodNotAllowedFailure) } + it { expect(result.error.code).to eq("custom_code") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::MethodNotAllowedFailure) } + end + + describe ".not_found_failure!" do + before { result.not_found_failure!(resource: "custom_resource") } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::NotFoundFailure) } + it { expect(result.error.error_code).to eq("custom_resource_not_found") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::NotFoundFailure) } + end + + describe ".service_failure!" do + before { result.service_failure!(code: "custom_code", message: "custom_message") } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ServiceFailure) } + it { expect(result.error.code).to eq("custom_code") } + it { expect(result.error.message).to eq("custom_code: custom_message") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::ServiceFailure) } + end + + describe ".unauthorized_failure!" do + before { result.unauthorized_failure! } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::UnauthorizedFailure) } + it { expect(result.error.message).to eq("unauthorized") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::UnauthorizedFailure) } + + context "when passing a code to the failure" do + before { result.unauthorized_failure!(message: "custom_code") } + + it { expect(result.error.message).to eq("custom_code") } + end + end + + describe ".validation_failure!" do + before { result.validation_failure!(errors: {field: ["error"]}) } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ValidationFailure) } + it { expect(result.error.messages).to eq({field: ["error"]}) } + it { expect(result.error.message).to eq('Validation errors: {"field":["error"]}') } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::ValidationFailure) } + end + + describe ".record_validation_failure!" do + let(:record) { Customer.new.tap(&:valid?) } + + before { result.record_validation_failure!(record:) } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ValidationFailure) } + it { expect(result.error.messages.keys).to match_array(%i[external_id organization]) } + it { expect(result.error.message).to eq("Validation errors: #{result.error.messages.to_json}") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::ValidationFailure) } + end + + describe ".single_validation_failure!" do + before { result.single_validation_failure!(error_code: "error_code") } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ValidationFailure) } + it { expect(result.error.messages).to eq({base: ["error_code"]}) } + it { expect(result.error.message).to eq('Validation errors: {"base":["error_code"]}') } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::ValidationFailure) } + + context "when passing a field to the failure" do + before { result.single_validation_failure!(error_code: "error", field: "field") } + + it { expect(result.error.messages).to eq({field: ["error"]}) } + it { expect(result.error.message).to eq('Validation errors: {"field":["error"]}') } + end + end + + describe ".unknown_tax_failure!" do + before { result.unknown_tax_failure!(code: "custom_code", message: "custom_message") } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::UnknownTaxFailure) } + it { expect(result.error.code).to eq("custom_code") } + it { expect(result.error.message).to eq("custom_code: custom_message") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::UnknownTaxFailure) } + end + + describe ".third_party_failure!" do + before { result.third_party_failure!(third_party: "stripe", error_code: "code", error_message: "custom_message") } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::ThirdPartyFailure) } + it { expect(result.error.third_party).to eq("stripe") } + it { expect(result.error.message).to eq("stripe: code - custom_message") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::ThirdPartyFailure) } + end + + describe ".too_many_provider_requests_failure!" do + let(:error) { StandardError.new("custom_error") } + let(:provider_name) { "anrok" } + + before { result.too_many_provider_requests_failure!(provider_name:, error:) } + + it { expect(result).not_to be_success } + it { expect(result).to be_failure } + it { expect(result.error).to be_a(BaseService::TooManyProviderRequestsFailure) } + it { expect(result.error.message).to eq("custom_error") } + it { expect(result.error.provider_name).to eq("anrok") } + + it { expect { result.raise_if_error! }.to raise_error(BaseService::TooManyProviderRequestsFailure) } + end + + describe ".raise_if_error!" do + context "when the result is a failure" do + before { result.fail_with_error!(StandardError.new) } + + it { expect { result.raise_if_error! }.to raise_error(StandardError) } + end + + context "when the result is a success" do + it { expect(result.raise_if_error!).to eq(result) } + end + end +end diff --git a/spec/support/shared_examples/security_logging.rb b/spec/support/shared_examples/security_logging.rb new file mode 100644 index 0000000..54287ea --- /dev/null +++ b/spec/support/shared_examples/security_logging.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +RSpec.shared_examples "produces a security log" do |event| + it "produces a security log" do + expect(security_logger).to have_received(:produce).with(hash_including(log_event: event)).at_least(:once) + end +end + +RSpec.shared_examples "does not produce a security log" do + it "does not produce a security log" do + expect(security_logger).not_to have_received(:produce) + end +end diff --git a/spec/support/shared_examples/services_requirements.rb b/spec/support/shared_examples/services_requirements.rb new file mode 100644 index 0000000..41dbc8e --- /dev/null +++ b/spec/support/shared_examples/services_requirements.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a premium service" do + it "requires a premium license" do + allow(License).to receive(:premium?).and_return(false) + result = subject + expect(result).to be_failure + expect(result.error).to be_a(BaseService::ForbiddenFailure) + expect(License).to have_received(:premium?).once + end +end diff --git a/spec/support/shared_examples/subscription_index.rb b/spec/support/shared_examples/subscription_index.rb new file mode 100644 index 0000000..ed022b7 --- /dev/null +++ b/spec/support/shared_examples/subscription_index.rb @@ -0,0 +1,194 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a subscription index endpoint" do + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, amount_cents: 500, description: "desc") } + let!(:subscription) { create(:subscription, customer:, plan:) } + let(:external_customer_id) { customer.external_id } + let(:params) { {} } + + include_examples "requires API permission", "subscription", "read" + + it "returns subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(subscription.id) + end + + context "with next and previous subscriptions" do + let(:previous_subscription) do + create( + :subscription, + customer:, + plan: create(:plan, organization:), + status: :terminated + ) + end + + let(:next_subscription) do + create( + :subscription, + customer:, + plan: create(:plan, organization:), + status: :pending + ) + end + + before do + subscription.update!(previous_subscription:, next_subscriptions: [next_subscription]) + end + + it "returns next and previous plan code" do + subject + + subscription = json[:subscriptions].first + expect(subscription[:previous_plan_code]).to eq(previous_subscription.plan.code) + expect(subscription[:next_plan_code]).to eq(next_subscription.plan.code) + end + + it "returns the downgrade plan date" do + current_date = DateTime.parse("20 Jun 2022") + + travel_to(current_date) do + subject + + subscription = json[:subscriptions].first + expect(subscription[:downgrade_plan_date]).to eq("2022-07-01") + end + end + end + + context "with pagination" do + let(:params) do + { + page: 1, + per_page: 1 + } + end + + before do + another_plan = create(:plan, organization:, amount_cents: 30_000) + create(:subscription, customer:, plan: another_plan) + end + + it "returns subscriptions with correct meta data" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:subscriptions].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + + context "with plan code" do + let(:params) { {plan_code: plan.code} } + + it "returns subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(subscription.id) + end + end + + context "with overriden filter" do + let(:overridden_plan) { create(:plan, organization:, parent_id: plan.id) } + let!(:overridden_subscription) { create(:subscription, customer:, plan: overridden_plan) } + + context "when filtering overridden subscriptions" do + let(:params) { {overriden: true} } + + it "returns only overridden subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(overridden_subscription.id) + end + end + + context "when filtering non-overridden subscriptions" do + let(:params) { {overriden: false} } + + it "returns only non-overridden subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(subscription.id) + end + end + + context "when using overridden (correct spelling)" do + let(:params) { {overridden: true} } + + it "returns only overridden subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(overridden_subscription.id) + end + end + end + + context "with currency filter" do + let(:brl_plan) { create(:plan, organization:, amount_currency: "BRL") } + let!(:brl_subscription) { create(:subscription, customer:, plan: brl_plan) } + let(:params) { {currency: brl_plan.amount_currency} } + + it "returns only subscriptions with matching currency" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(brl_subscription.id) + end + end + + context "with N+1 query detection", bullet: {n_plus_one_query: true, unused_eager_loading: false} do + before do + create(:subscription, customer:, plan: create(:plan, organization:)) + + prev = create(:subscription, customer:, plan: create(:plan, organization:), status: :terminated) + nxt = create(:subscription, customer:, plan: create(:plan, organization:), status: :pending) + subscription.update!(previous_subscription: prev, next_subscriptions: [nxt]) + end + + it "does not trigger N+1 queries on plan, customer, or related subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to be >= 2 + end + end + + context "with terminated status" do + let!(:terminated_subscription) do + create(:subscription, customer:, plan: create(:plan, organization:), status: :terminated, terminated_at: Time.current) + end + + let(:params) do + { + status: ["terminated"] + } + end + + it "returns terminated subscriptions" do + subject + + expect(response).to have_http_status(:success) + expect(json[:subscriptions].count).to eq(1) + expect(json[:subscriptions].first[:lago_id]).to eq(terminated_subscription.id) + end + end +end diff --git a/spec/support/shared_examples/wallet_actions.rb b/spec/support/shared_examples/wallet_actions.rb new file mode 100644 index 0000000..24cad78 --- /dev/null +++ b/spec/support/shared_examples/wallet_actions.rb @@ -0,0 +1,1324 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a wallet create endpoint" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + priority: 12, + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at:, + invoice_requires_successful_payment: true, + paid_top_up_min_amount_cents: 5_00, + paid_top_up_max_amount_cents: 100_00, + ignore_paid_top_up_limits_on_creation: "true", + payment_method: { + payment_method_type: "provider", + payment_method_id: payment_method.id + } + } + end + + include_examples "requires API permission", "wallet", "write" + + it "creates a wallet" do + allow(WalletTransactions::CreateFromParamsService).to receive(:call!).and_call_original + allow(Validators::WalletTransactionAmountLimitsValidator).to receive(:new).and_call_original + stub_pdf_generation + + subject + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.created", Wallet) + + perform_all_enqueued_jobs(except: [SendWebhookJob]) + + expect(response).to have_http_status(:success) + + expect(json[:wallet][:lago_id]).to be_present + expect(json[:wallet][:name]).to eq(create_params[:name]) + expect(json[:wallet][:priority]).to eq(create_params[:priority]) + expect(json[:wallet][:external_customer_id]).to eq(customer.external_id) + expect(json[:wallet][:expiration_at]).to eq(expiration_at) + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(true) + expect(json[:wallet][:paid_top_up_min_amount_cents]).to eq(5_00) + expect(json[:wallet][:paid_top_up_max_amount_cents]).to eq(100_00) + expect(json[:wallet][:payment_method][:payment_method_type]).to eq("provider") + expect(json[:wallet][:payment_method][:payment_method_id]).to eq(payment_method.id) + + expect(Validators::WalletTransactionAmountLimitsValidator).to have_received(:new).with( + Wallets::CreateService::Result, + wallet: Wallet, + credits_amount: "10", + ignore_validation: "true" + ) + + expect(WalletTransactions::CreateFromParamsService).to have_received(:call!).with( + organization: organization, + params: hash_including( + wallet_id: json[:wallet][:lago_id], + paid_credits: "10", + granted_credits: "10", + source: :manual + ) + ) + end + + context "when paid_credit is below the minimum" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + paid_credits: "10", + paid_top_up_min_amount_cents: 30_00 + } + end + + it "returns a validation error" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:paid_credits]).to eq ["amount_below_minimum"] + end + + context "when the ignore_paid_top_up_limits_on_creation is set to true" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + paid_credits: "10", + paid_top_up_min_amount_cents: 30_00, + ignore_paid_top_up_limits_on_creation: "true" + } + end + + it "ignores the amount limits" do + subject + expect(response).to have_http_status(:success) + expect(json[:wallet][:lago_id]).to be_present + expect(json[:wallet][:external_customer_id]).to eq(customer.external_id) + end + end + end + + context "with transaction metadata" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at:, + invoice_requires_successful_payment: true, + transaction_metadata: [{key: "valid_value", value: "also_valid"}] + } + end + + before do + subject + end + + it "schedules a WalletTransactions::CreateJob with correct parameters" do + expect(WalletTransactions::CreateJob).to have_been_enqueued.with( + organization_id: organization.id, + params: hash_including( + name: nil, + metadata: [{key: "valid_value", value: "also_valid"}] + ) + ) + end + + context "when transaction metadata is a hash" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at:, + invoice_requires_successful_payment: true, + transaction_metadata: {} + } + end + + it "returns a validation error" do + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:metadata]).to include("invalid_type") + end + end + end + + context "when transaction_name is provided" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at:, + transaction_name: "Custom Transaction Name" + } + end + + before do + subject + end + + it "schedules a WalletTransactions::CreateJob with the transaction name" do + expect(WalletTransactions::CreateJob).to have_been_enqueued.with( + organization_id: organization.id, + params: hash_including( + name: "Custom Transaction Name" + ) + ) + end + end + + context "when transaction_priority is provided" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at:, + transaction_priority: 5 + } + end + + before { subject } + + it "schedules a WalletTransactions::CreateJob with the transaction priority" do + expect(WalletTransactions::CreateJob).to have_been_enqueued.with( + organization_id: organization.id, + params: hash_including( + priority: 5 + ) + ) + end + end + + context "with recurring transaction rules", :premium do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at:, + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "monthly", + ignore_paid_top_up_limits: true, + invoice_custom_section: {invoice_custom_section_codes: [section_1.code]}, + payment_method: { + payment_method_type: "provider", + payment_method_id: payment_method.id + } + } + ] + } + end + + it "returns a success" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + + expect(recurring_rules).to be_present + expect(recurring_rules.first[:interval]).to eq("monthly") + expect(recurring_rules.first[:paid_credits]).to eq("10.0") + expect(recurring_rules.first[:granted_credits]).to eq("10.0") + expect(recurring_rules.first[:method]).to eq("fixed") + expect(recurring_rules.first[:trigger]).to eq("interval") + expect(recurring_rules.first[:ignore_paid_top_up_limits]).to eq(true) + custom_section = recurring_rules.first[:applied_invoice_custom_sections].first + expect(custom_section[:invoice_custom_section][:lago_id]).to eq(section_1.id) + expect(recurring_rules.first[:payment_method][:payment_method_type]).to eq("provider") + expect(recurring_rules.first[:payment_method][:payment_method_id]).to eq(payment_method.id) + end + + context "when invoice_requires_successful_payment is set at the wallet level but the rule level" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + expiration_at:, + invoice_requires_successful_payment: true, + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "monthly" + } + ] + } + end + + it "follows the wallet configuration to create the rule" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(true) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:invoice_requires_successful_payment]).to eq(true) + end + end + + context "when invoice_requires_successful_payment is set at the rule level but not present at the wallet level" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + expiration_at:, + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "monthly", + invoice_requires_successful_payment: true + } + ] + } + end + + it "follows the wallet configuration to create the rule" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + expect(response).to have_http_status(:success) + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(false) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:invoice_requires_successful_payment]).to eq(true) + end + end + + context "with expiration_at transaction rule" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "monthly", + expiration_at:, + invoice_requires_successful_payment: true + } + ] + } + end + + it "create the rule with correct expiration_at" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:expiration_at]).to eq(expiration_at) + end + end + + context "with transaction metadata" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + expiration_at:, + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "monthly", + invoice_requires_successful_payment: true, + transaction_metadata: + } + ] + } + end + + let(:transaction_metadata) { [{key: "valid_value", value: "also_valid"}] } + + it "create the rule with correct metadata" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:transaction_metadata]).to eq(transaction_metadata) + end + + context "when transaction metadata is a hash" do + let(:transaction_metadata) { {key: "valid_value", value: "also_valid"} } + + it "returns a validation error" do + subject + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:recurring_transaction_rules]).to include("invalid_recurring_rule") + end + end + end + + context "when transaction_name is set" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + expiration_at:, + recurring_transaction_rules: [ + { + trigger: "interval", + interval: "monthly", + transaction_name: "Custom Wallet Top-up" + } + ] + } + end + + it "creates the rule with transaction_name" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:transaction_name]).to eq("Custom Wallet Top-up") + end + end + end + + context "with limitations" do + let(:bm) { create(:billable_metric, organization:) } + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + paid_credits: "10", + granted_credits: "10", + expiration_at:, + applies_to: { + fee_types: %w[charge], + billable_metric_codes: [bm.code] + } + } + end + + it "returns a success" do + subject + + limitations = json[:wallet][:applies_to] + + expect(response).to have_http_status(:success) + expect(limitations).to be_present + expect(limitations[:fee_types]).to eq(%w[charge]) + expect(limitations[:billable_metric_codes]).to eq([bm.code]) + end + end + + context "with invoice_custom_section" do + let(:invoice_custom_section) { nil } + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + invoice_custom_section: + } + end + + context "when skip_invoice_custom_sections is true" do + let(:invoice_custom_section) { + {skip_invoice_custom_sections: true} + } + + it "set skip_invoice_custom_sections" do + subject + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.skip_invoice_custom_sections).to be_truthy + end + end + + context "when skip_invoice_custom_sections is false" do + let(:invoice_custom_section) { + { + skip_invoice_custom_sections: false, + invoice_custom_section_codes: [section_1.code] + } + } + + it "creates with an attached section" do + subject + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.skip_invoice_custom_sections).to be_falsey + expect(wallet.applied_invoice_custom_sections.count).to be(1) + expect(wallet.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id) + end + end + end + + context "with wallet metadata" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR", + metadata: {"meta_key_1" => "meta_value_1", "meta_key_2" => "meta_value_2"} + } + end + + it "creates a wallet with metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:lago_id]).to be_present + expect(json[:wallet][:metadata]).to eq( + { + meta_key_1: "meta_value_1", + meta_key_2: "meta_value_2" + } + ) + end + end + + context "when code is provided" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + code: "custom_wallet_code", + currency: "EUR" + } + end + + it "creates a wallet with the provided code" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:code]).to eq("custom_wallet_code") + end + end + + context "when code is not provided but name is" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "My Premium Wallet", + currency: "EUR" + } + end + + it "creates a wallet with code derived from name" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:code]).to eq("my_premium_wallet") + end + end + + context "when neither code nor name is provided" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + currency: "EUR" + } + end + + it "creates a wallet with default code" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:code]).to eq("default") + end + end + + context "when code is already taken for the customer" do + before do + create(:wallet, customer:, code: "existing_code") + end + + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + code: "existing_code", + currency: "EUR" + } + end + + it "returns an error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:code]).to eq(["value_already_exist"]) + end + end + + context "with applied_invoice_custom_sections in response" do + it "includes applied_invoice_custom_sections in the serialized response" do + subject + + expect(response).to have_http_status(:success) + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(json[:wallet][:applied_invoice_custom_sections].count).to eq(wallet.applied_invoice_custom_sections.count) + end + end + + context "when multi_entity_billing is enabled" do + before { organization.update!(feature_flags: ["multi_entity_billing"]) } + + context "when billing_entity_code is provided" do + let(:billing_entity) { create(:billing_entity, organization:, code: "be_wallet") } + + before { create_params[:billing_entity_code] = billing_entity.code } + + it "assigns the billing entity to the wallet" do + subject + + expect(response).to have_http_status(:success) + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.billing_entity_id).to eq(billing_entity.id) + end + end + + context "when neither billing_entity_code nor billing_entity_id is provided" do + it "creates the wallet without a billing entity" do + subject + + expect(response).to have_http_status(:success) + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.billing_entity_id).to be_nil + end + end + + context "when billing_entity_code does not match any entity" do + before { create_params[:billing_entity_code] = "nonexistent" } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + context "when multi_entity_billing is not enabled" do + context "when billing_entity_code is provided" do + let(:billing_entity) { create(:billing_entity, organization:, code: "be_wallet") } + + before { create_params[:billing_entity_code] = billing_entity.code } + + it "does not assign a billing entity" do + subject + + expect(response).to have_http_status(:success) + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.billing_entity_id).to be_nil + end + end + end +end + +RSpec.shared_examples "a wallet create endpoint with billing_entity_id" do + let(:create_params) do + { + external_customer_id: customer.external_id, + rate_amount: "1", + name: "Wallet1", + currency: "EUR" + } + end + + context "when multi_entity_billing is enabled" do + before { organization.update!(feature_flags: ["multi_entity_billing"]) } + + context "when billing_entity_id is provided" do + let(:billing_entity) { create(:billing_entity, organization:) } + + before { create_params[:billing_entity_id] = billing_entity.id } + + it "assigns the billing entity to the wallet" do + subject + + expect(response).to have_http_status(:success) + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.billing_entity_id).to eq(billing_entity.id) + end + end + + context "when billing_entity_id does not match any entity" do + before { create_params[:billing_entity_id] = SecureRandom.uuid } + + it "returns a not found error" do + subject + + expect(response).to have_http_status(:not_found) + end + end + end + + context "when multi_entity_billing is not enabled" do + context "when billing_entity_id is provided" do + let(:billing_entity) { create(:billing_entity, organization:) } + + before { create_params[:billing_entity_id] = billing_entity.id } + + it "does not assign a billing entity" do + subject + + expect(response).to have_http_status(:success) + + wallet = Wallet.find(json[:wallet][:lago_id]) + expect(wallet.billing_entity_id).to be_nil + end + end + end +end + +RSpec.shared_examples "a wallet update endpoint" do + let(:wallet) { create(:wallet, customer:) } + let(:expiration_at) { (Time.current + 1.year).iso8601 } + let(:update_params) do + { + name: "wallet1", + expiration_at:, + priority: 5, + invoice_requires_successful_payment: true, + paid_top_up_min_amount_cents: 6_00, + paid_top_up_max_amount_cents: 10_00, + payment_method: { + payment_method_type: "provider", + payment_method_id: payment_method.id + } + } + end + + before { wallet } + + include_examples "requires API permission", "wallet", "write" + + it "updates a wallet" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:wallet][:lago_id]).to eq(wallet.id) + expect(json[:wallet][:name]).to eq(update_params[:name]) + expect(json[:wallet][:priority]).to eq(update_params[:priority]) + expect(json[:wallet][:expiration_at]).to eq(expiration_at) + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(true) + expect(json[:wallet][:paid_top_up_min_amount_cents]).to eq(6_00) + expect(json[:wallet][:paid_top_up_max_amount_cents]).to eq(10_00) + expect(json[:wallet][:payment_method][:payment_method_type]).to eq("provider") + expect(json[:wallet][:payment_method][:payment_method_id]).to eq(payment_method.id) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + + context "when wallet does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + expect(SendWebhookJob).not_to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "with limitations" do + let(:bm) { create(:billable_metric, organization:) } + let(:update_params) do + { + name: "wallet1", + applies_to: { + fee_types: %w[charge], + billable_metric_codes: [bm.code] + } + } + end + + it "updates a wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:lago_id]).to eq(wallet.id) + expect(json[:wallet][:name]).to eq(update_params[:name]) + expect(json[:wallet][:applies_to][:fee_types]).to eq(%w[charge]) + expect(json[:wallet][:applies_to][:billable_metric_codes]).to eq([bm.code]) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "with invoice_custom_section" do + let(:invoice_custom_section) { nil } + let(:update_params) do + { + name: "wallet1", + invoice_custom_section: + } + end + + context "when skip_invoice_custom_sections is true" do + let(:invoice_custom_section) { + {skip_invoice_custom_sections: true} + } + + it "set skip_invoice_custom_sections" do + subject + wallet.reload + + expect(wallet.skip_invoice_custom_sections).to be_truthy + end + end + + context "when skip_invoice_custom_sections is false" do + let(:invoice_custom_section) { + { + skip_invoice_custom_sections: false, + invoice_custom_section_codes: [section_1.code] + } + } + + it "creates with an attached section" do + subject + + wallet.reload + expect(wallet.skip_invoice_custom_sections).to be_falsey + expect(wallet.applied_invoice_custom_sections.count).to be(1) + expect(wallet.applied_invoice_custom_sections.pluck(:invoice_custom_section_id)).to include(section_1.id) + end + end + end + + context "with recurring transaction rules", :premium do + let(:recurring_transaction_rule) { create(:recurring_transaction_rule, wallet:) } + let(:update_params) do + { + name: "wallet1", + recurring_transaction_rules: [ + { + lago_id: recurring_transaction_rule.id, + method: "target", + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + target_ongoing_balance: "300", + invoice_requires_successful_payment: true, + ignore_paid_top_up_limits: true, + invoice_custom_section: {invoice_custom_section_codes: [section_1.code]}, + payment_method: { + payment_method_type: "provider", + payment_method_id: payment_method.id + } + } + ] + } + end + + before { recurring_transaction_rule } + + it "returns a success" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(false) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:lago_id]).to eq(recurring_transaction_rule.id) + expect(recurring_rules.first[:interval]).to eq("weekly") + expect(recurring_rules.first[:paid_credits]).to eq("105.0") + expect(recurring_rules.first[:granted_credits]).to eq("105.0") + expect(recurring_rules.first[:method]).to eq("target") + expect(recurring_rules.first[:trigger]).to eq("interval") + expect(recurring_rules.first[:invoice_requires_successful_payment]).to eq(true) + expect(recurring_rules.first[:ignore_paid_top_up_limits]).to eq(true) + custom_section = recurring_rules.first[:applied_invoice_custom_sections].first + expect(custom_section[:invoice_custom_section][:lago_id]).to eq(section_1.id) + expect(recurring_rules.first[:payment_method][:payment_method_type]).to eq("provider") + expect(recurring_rules.first[:payment_method][:payment_method_id]).to eq(payment_method.id) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + + context "when transaction expiration_at is set" do + let(:expiration_at) { (Time.current + 2.years).iso8601 } + let(:update_params) do + { + name: "wallet1", + invoice_requires_successful_payment: true, + recurring_transaction_rules: [ + { + method: "target", + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + target_ongoing_balance: "300", + expiration_at: + } + ] + } + end + + it "updates the rule" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + expect(response).to have_http_status(:success) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:expiration_at]).to eq(expiration_at) + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when transaction metadata is set" do + let(:update_params) do + { + name: "wallet1", + invoice_requires_successful_payment: true, + recurring_transaction_rules: [ + { + method: "target", + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + target_ongoing_balance: "300", + transaction_metadata: update_transaction_metadata + } + ] + } + end + + let(:update_transaction_metadata) { [{key: "update_key", value: "update_value"}] } + + it "updates the rule" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + expect(response).to have_http_status(:success) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:transaction_metadata]).to eq(update_transaction_metadata) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when transaction_name is updated" do + let(:update_params) do + { + name: "wallet1", + recurring_transaction_rules: [ + { + lago_id: recurring_transaction_rule.id, + method: "target", + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + target_ongoing_balance: "300", + transaction_name: "Updated Transaction Name" + } + ] + } + end + + it "updates the rule with transaction_name" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + expect(response).to have_http_status(:success) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:transaction_name]).to eq("Updated Transaction Name") + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when invoice_requires_successful_payment is updated at the wallet level" do + let(:update_params) do + { + name: "wallet1", + invoice_requires_successful_payment: true, + recurring_transaction_rules: [ + { + lago_id: rule_id, + method: "target", + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + target_ongoing_balance: "300", + expiration_at: + } + ] + } + end + + context "when the rule exists" do + let(:rule_id) { recurring_transaction_rule.id } + + it "updates the wallet and the rule" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(true) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:lago_id]).to eq(recurring_transaction_rule.id) + expect(recurring_rules.first[:invoice_requires_successful_payment]).to eq(false) + expect(recurring_rules.first[:expiration_at]).to eq(expiration_at) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when the rule does not exist" do + let(:rule_id) { "does not exists in the db" } + + it "create a new rule and follow the new wallet configuration" do + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(true) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:invoice_requires_successful_payment]).to eq(true) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + + context "when the rule does not exist but the param is passed explicitly" do + let(:wallet) { create(:wallet, customer:, invoice_requires_successful_payment: true) } + let(:update_params) do + { + name: "wallet1", + invoice_requires_successful_payment: false, + recurring_transaction_rules: [ + { + lago_id: "does not exists in the db", + method: "target", + trigger: "interval", + interval: "weekly", + paid_credits: "105", + granted_credits: "105", + target_ongoing_balance: "300", + invoice_requires_successful_payment: true + } + ] + } + end + + it "create a new rule and ignores wallet configuration" do + expect(wallet.invoice_requires_successful_payment).to eq(true) + + subject + + recurring_rules = json[:wallet][:recurring_transaction_rules] + + expect(response).to have_http_status(:success) + + expect(json[:wallet][:invoice_requires_successful_payment]).to eq(false) + expect(recurring_rules).to be_present + expect(recurring_rules.first[:invoice_requires_successful_payment]).to eq(true) + + expect(SendWebhookJob).to have_been_enqueued.with("wallet.updated", Wallet) + end + end + end + end + + context "with wallet metadata" do + let(:update_params) do + { + name: "wallet1", + metadata: {"meta_key_1" => "updated_meta_value_1", "meta_key_3" => "meta_value_3"} + } + end + + it "updates a wallet with metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:lago_id]).to eq(wallet.id) + expect(json[:wallet][:metadata]).to eq( + { + meta_key_1: "updated_meta_value_1", + meta_key_3: "meta_value_3" + } + ) + end + end + + context "when updating code" do + let(:update_params) do + { + code: "updated_wallet_code" + } + end + + it "updates the wallet code" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:lago_id]).to eq(wallet.id) + expect(json[:wallet][:code]).to eq("updated_wallet_code") + end + end + + context "when updating code to a value already taken for the customer" do + before do + create(:wallet, customer:, code: "taken_code") + end + + let(:update_params) do + { + code: "taken_code" + } + end + + it "returns an error" do + subject + + expect(response).to have_http_status(:unprocessable_content) + expect(json[:error_details][:code]).to eq(["value_already_exist"]) + end + end + + context "with applied_invoice_custom_sections in response" do + before { create(:wallet_applied_invoice_custom_section, wallet:) } + + it "includes applied_invoice_custom_sections in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:applied_invoice_custom_sections].count).to eq(1) + end + end +end + +RSpec.shared_examples "a wallet show endpoint" do + let(:wallet) { create(:wallet, customer:) } + + include_examples "requires API permission", "wallet", "read" + + it "returns a wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:lago_id]).to eq(wallet.id) + expect(json[:wallet][:name]).to eq(wallet.name) + expect(json[:wallet][:priority]).to eq(50) + end + + context "when wallet does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns not found" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "with applied_invoice_custom_sections in response" do + before { create(:wallet_applied_invoice_custom_section, wallet:) } + + it "includes applied_invoice_custom_sections in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:applied_invoice_custom_sections].count).to eq(1) + end + end +end + +RSpec.shared_examples "a wallet terminate endpoint" do + let(:wallet) { create(:wallet, customer:) } + + include_examples "requires API permission", "wallet", "write" + + it "terminates a wallet" do + subject + expect(wallet.reload.status).to eq("terminated") + end + + it "returns terminated wallet" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:lago_id]).to eq(wallet.id) + expect(json[:wallet][:name]).to eq(wallet.name) + end + + it "sends a wallet.terminated webhook" do + expect { subject }.to have_enqueued_job(SendWebhookJob).with("wallet.terminated", Wallet) + end + + context "when wallet does not exist" do + let(:id) { SecureRandom.uuid } + + it "returns not_found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "when wallet id does not belong to the current organization" do + let(:other_org_wallet) { create(:wallet) } + let(:id) { other_org_wallet.id } + + it "returns a not found error" do + subject + expect(response).to have_http_status(:not_found) + end + end + + context "with applied_invoice_custom_sections in response" do + before { create(:wallet_applied_invoice_custom_section, wallet:) } + + it "includes applied_invoice_custom_sections in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallet][:applied_invoice_custom_sections].count).to eq(1) + end + end +end + +RSpec.shared_examples "a wallet index endpoint" do + let!(:wallet) { create(:wallet, customer:) } + let(:external_id) { customer.external_id } + let(:params) { {page: 1, per_page: 1} } + + include_examples "requires API permission", "wallet", "read" + + it "returns wallets" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallets].count).to eq(1) + expect(json[:wallets].first[:lago_id]).to eq(wallet.id) + expect(json[:wallets].first[:name]).to eq(wallet.name) + expect(json[:wallets].first[:recurring_transaction_rules]).to be_empty + expect(json[:wallets].first[:applies_to]).to be_present + end + + context "with pagination" do + before { create(:wallet, customer:) } + + it "returns wallets with correct meta data" do + subject + + expect(response).to have_http_status(:success) + + expect(json[:wallets].count).to eq(1) + expect(json[:meta][:current_page]).to eq(1) + expect(json[:meta][:next_page]).to eq(2) + expect(json[:meta][:prev_page]).to eq(nil) + expect(json[:meta][:total_pages]).to eq(2) + expect(json[:meta][:total_count]).to eq(2) + end + end + + context "with applied_invoice_custom_sections in response" do + let(:params) { {} } + + before { create(:wallet_applied_invoice_custom_section, wallet:) } + + it "includes applied_invoice_custom_sections in the serialized response" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallets].first[:applied_invoice_custom_sections].count).to eq(1) + end + end + + context "with currency filter" do + let!(:brl_wallet) { create(:wallet, customer:, currency: "BRL") } + let(:params) { {currency: "BRL"} } + + it "returns only wallets with matching currency" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallets].count).to eq(1) + expect(json[:wallets].first[:lago_id]).to eq(brl_wallet.id) + end + end + + context "with N+1 query detection", bullet: {n_plus_one_query: true, unused_eager_loading: false} do + let(:params) { {} } + + before do + [wallet, create(:wallet, customer:), create(:wallet, customer:)].each do |w| + create(:wallet_target, wallet: w) + create(:wallet_applied_invoice_custom_section, wallet: w) + create(:recurring_transaction_rule, wallet: w) + end + end + + it "does not trigger N+1 queries on wallet associations" do + subject + + expect(response).to have_http_status(:success) + expect(json[:wallets].count).to eq(3) + json[:wallets].each do |wallet_payload| + expect(wallet_payload[:applies_to][:billable_metric_codes]).to be_present + expect(wallet_payload[:recurring_transaction_rules]).to be_present + expect(wallet_payload[:applied_invoice_custom_sections]).to be_present + end + end + end +end diff --git a/spec/support/shared_examples/wallet_metadata_actions.rb b/spec/support/shared_examples/wallet_metadata_actions.rb new file mode 100644 index 0000000..a2b9b53 --- /dev/null +++ b/spec/support/shared_examples/wallet_metadata_actions.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +RSpec.shared_examples "a wallet metadata create endpoint" do + let(:params) { {foo: "bar", baz: "qux"} } + let(:metadata_params) { {metadata: params} } + + it_behaves_like "requires API permission", "wallet", "write" + + context "when wallet is not found" do + let(:wallet_id) { "invalid_id" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("wallet") + end + end + + context "when wallet has no metadata" do + it "creates metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(wallet.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when wallet has existing metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {old: "value", foo: "old"}) } + + it "replaces all metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(wallet.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when params are empty" do + let(:params) { {} } + + before { create(:item_metadata, owner: wallet, organization:, value: {old: "value"}) } + + it "replaces metadata with empty hash" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq({}) + expect(wallet.reload.metadata.value).to eq({}) + end + end + + context "when params are empty and metadata does not exist" do + let(:params) { {} } + + it "creates metadata with empty hash" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq({}) + expect(wallet.reload.metadata.value).to eq({}) + end + end + + context "when metadata param is not provided" do + let(:metadata_params) { {} } + + it "does not create empty metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(nil) + expect(wallet.reload.metadata).to eq(nil) + end + end +end + +RSpec.shared_examples "a wallet metadata update endpoint" do + let(:params) { {foo: "bar", baz: "qux"} } + let(:metadata_params) { {metadata: params} } + + it_behaves_like "requires API permission", "wallet", "write" + + context "when wallet is not found" do + let(:wallet_id) { "invalid_id" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("wallet") + end + end + + context "when wallet has no metadata" do + it "creates metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(foo: "bar", baz: "qux") + expect(wallet.reload.metadata.value).to eq("foo" => "bar", "baz" => "qux") + end + end + + context "when wallet has existing metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {"old" => "value", "foo" => "old"}) } + + it "merges metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(old: "value", foo: "bar", baz: "qux") + expect(wallet.reload.metadata.value).to eq("old" => "value", "foo" => "bar", "baz" => "qux") + end + end + + context "when params are empty and metadata exists" do + let(:params) { {} } + + before { create(:item_metadata, owner: wallet, organization:, value: {"old" => "value"}) } + + it "keeps existing metadata unchanged" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(old: "value") + expect(wallet.reload.metadata.value).to eq("old" => "value") + end + end + + context "when params are empty and metadata does not exist" do + let(:params) { {} } + + it "does not create metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(wallet.reload.metadata).to be_nil + end + end + + context "when metadata param is not provided" do + let(:metadata_params) { {} } + + it "does not create metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(wallet.reload.metadata).to be_nil + end + + context "when metadata existed before" do + before { create(:item_metadata, owner: wallet, organization:, value: {"old" => "value"}) } + + it "keeps existing metadata unchanged" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(old: "value") + expect(wallet.reload.metadata.value).to eq("old" => "value") + end + end + end +end + +RSpec.shared_examples "a wallet metadata destroy endpoint" do + it_behaves_like "requires API permission", "wallet", "write" + + context "when wallet is not found" do + let(:wallet_id) { "invalid_id" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("wallet") + end + end + + context "when wallet has metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {"foo" => "bar"}) } + + it "deletes all metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(wallet.reload.metadata).to be_nil + end + end + + context "when wallet has no metadata" do + it "returns success with nil metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to be_nil + expect(wallet.reload.metadata).to be_nil + end + end +end + +RSpec.shared_examples "a wallet metadata destroy key endpoint" do + let(:key) { "foo" } + + it_behaves_like "requires API permission", "wallet", "write" + + context "when wallet is not found" do + let(:wallet_id) { "invalid_id" } + + it "returns not found error" do + subject + expect(response).to be_not_found_error("wallet") + end + end + + context "when wallet has no metadata" do + it "returns not found error" do + subject + expect(response).to be_not_found_error("metadata") + end + end + + context "when key exists in metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {"foo" => "bar", "baz" => "qux"}) } + + it "deletes the key" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(baz: "qux") + expect(wallet.reload.metadata.value).to eq("baz" => "qux") + end + end + + context "when key does not exist in metadata" do + before { create(:item_metadata, owner: wallet, organization:, value: {"baz" => "qux"}) } + + it "returns success without changing metadata" do + subject + + expect(response).to have_http_status(:success) + expect(json[:metadata]).to eq(baz: "qux") + expect(wallet.reload.metadata.value).to eq("baz" => "qux") + end + end +end diff --git a/spec/support/shoulda_matchers.rb b/spec/support/shoulda_matchers.rb new file mode 100644 index 0000000..edcf9dd --- /dev/null +++ b/spec/support/shoulda_matchers.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +Shoulda::Matchers.configure do |config| + config.integrate do |with| + with.test_framework :rspec + with.library :rails + end +end diff --git a/spec/support/stripe_helper.rb b/spec/support/stripe_helper.rb new file mode 100644 index 0000000..06edd74 --- /dev/null +++ b/spec/support/stripe_helper.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# +# Helper to retrieve and modify stripe fixtures. +# You can pass a block to modify the response. +# +# Example when retrieving customer via the API +# +# stub_request(:get, %r{/v1/customers/#{provider_customer_id}$}).and_return( +# status: 200, body: get_stripe_fixtures("customer_retrieve_response.json") +# ) +# +# Example when modifying the response. Keep the change minimal, +# we want the fixtures to be as close as possible to real response +# +# stub_request(:get, %r{/v1/customers/#{provider_customer_id}$}).and_return( +# status: 200, body: get_stripe_fixtures("customer_retrieve_response.json") do |res| +# res["metadata"]["lago_customer_id"] = customer.id +# end +# ) +module StripeHelper + def get_stripe_fixtures(file, version: ENV["STRIPE_API_VERSION"]) + full_name = "spec/fixtures/stripe/#{version}/#{file}" + json = File.read(Rails.root.join(full_name)) + return json unless block_given? + h = JSON.parse(json).with_indifferent_access + yield(h) + h.to_json + rescue Errno::ENOENT => e + pps "Fixture not found: #{full_name}" + raise e + end +end diff --git a/spec/support/xml_helper.rb b/spec/support/xml_helper.rb new file mode 100644 index 0000000..1e5a895 --- /dev/null +++ b/spec/support/xml_helper.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module XMLHelper + NAMESPACES = { + factur_x: EInvoices::FacturX::BaseSerializer::ROOT_NAMESPACES, + ubl: EInvoices::Ubl::BaseSerializer::COMMON_NAMESPACES + } + + def xml_document(ns) + Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml| + xml.TestRoot(NAMESPACES[ns]) do + yield xml + end + end.doc + end + + def xml_fragment(ns, &block) + xml_document(ns, &block).root.children.to_xml( + save_with: Nokogiri::XML::Node::SaveOptions::NO_DECLARATION + ) + end +end diff --git a/spec/validators/email_array_validator_spec.rb b/spec/validators/email_array_validator_spec.rb new file mode 100644 index 0000000..f5f19fb --- /dev/null +++ b/spec/validators/email_array_validator_spec.rb @@ -0,0 +1,113 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EmailArrayValidator do + subject(:validator) { described_class.new(attributes: [:bcc_emails]) } + + let(:model) { DunningCampaign.new(bcc_emails:) } + + [ + "test@example.com", + "test+1@example.com", + "test.1@example.com", + "test-1@example.com", + "test_1@example.com", + "test123@example.com", + "!#$%&'*+/=?^_`{|}~-test123!#$%&'*+/=?^_`{|}~-@example.com", + "test@sub.example.com", + "test@dash-in-domain.com", + "with.dot@example.com", + "with.dot+plus-and-dash@example.com", + "x@example.com", + "example@s.example", + "1234567890@example.com", + "_______@example.com", + ["first.user@example.com", "second+user@example.com", "third_user@example.com"] + ].each do |email| + context "when the email is #{email}" do + let(:bcc_emails) { Array(email) } + + it "is valid" do + validator.validate(model) + expect(model.errors.full_messages).to be_empty + end + end + end + + [ + # Unicode + "with.uniçode@example.com", + + # Missing @ + "userdomain.com", + "user.domain.com", + + # Multiple @ + "user@@domain.com", + "user@domain@com", + + # Missing domain + "user@", + "user@.", + "user@.com", + + # Invalid domain format + "user@domain..com", + "user@.domain.com", + "user@domain.com.", + "user@-domain.com", + + # Spaces + "user name@domain.com", + "user@domain name.com", + "user @domain.com", + + # Invalid characters + "username@domain.com", + "user(name@domain.com", + "user)name@domain.com", + + # Consecutive dots + "user..name@domain.com", + "user@domain..com", + + # Dots at start/end + ".user@domain.com", + "user.@domain.com", + "user@.domain.com", + "user@domain.com.", + + # Invalid TLD + # TODO: Handle these cases ? + # + # "user@domain.c", + # "user@domain.123", + + # Invalid IP + "user@[192.168.1.1", + "user@192.168.1.1]", + "user@[256.256.256.256]", + + # Invalid quoted strings + "\"user@domain.com", + "user\"@domain.com", + + # Edge cases + "user@domain..com", + "user@domain...com", + "user....name@domain.com", + "user@domain.com.", + "user@domain.com.." + ].each do |email| + context "when the email is #{email}" do + let(:bcc_emails) { [email] } + + it "is invalid" do + validator.validate(model) + expect(model.errors.to_hash).to eq(bcc_emails: ["invalid_email_format[0,#{email}]"]) + end + end + end +end diff --git a/spec/validators/email_validator_spec.rb b/spec/validators/email_validator_spec.rb new file mode 100644 index 0000000..d0ddb3e --- /dev/null +++ b/spec/validators/email_validator_spec.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe EmailValidator do + subject(:validator) { described_class.new(attributes: [:email]) } + + let(:model) { Customer.new(email:) } + + [ + "test@example.com", + "test+1@example.com", + "test.1@example.com", + "test-1@example.com", + "test_1@example.com", + "test123@example.com", + "!#$%&'*+/=?^_`{|}~-test123!#$%&'*+/=?^_`{|}~-@example.com", + "test@sub.example.com", + "test@dash-in-domain.com", + "with.dot@example.com", + "with.dot+plus-and-dash@example.com", + "x@example.com", + "example@s.example", + "1234567890@example.com", + "_______@example.com", + "first.user@example.com, second+user@example.com, third_user@example.com" + ].each do |email| + context "when the email is #{email}" do + let(:email) { email } + + it "is valid" do + validator.validate(model) + expect(model.errors.full_messages).to be_empty + end + end + end + + [ + # Leading and trailing comma + ",user@domain.com,", + "user@domain.com,,", + ",user@domain.com,,", + "user@domain.com,,", + "user@domain.com,,", + + # Unicode + "with.uniçode@example.com", + + # Missing @ + "userdomain.com", + "user.domain.com", + + # Multiple @ + "user@@domain.com", + "user@domain@com", + + # Missing domain + "user@", + "user@.", + "user@.com", + + # Invalid domain format + "user@domain..com", + "user@.domain.com", + "user@domain.com.", + "user@-domain.com", + + # Spaces + "user name@domain.com", + "user@domain name.com", + "user @domain.com", + + # Invalid characters + "username@domain.com", + "user(name@domain.com", + "user)name@domain.com", + + # Consecutive dots + "user..name@domain.com", + "user@domain..com", + + # Dots at start/end + ".user@domain.com", + "user.@domain.com", + "user@.domain.com", + "user@domain.com.", + + # Invalid TLD + # TODO: Handle these cases ? + # + # "user@domain.c", + # "user@domain.123", + + # Invalid IP + "user@[192.168.1.1", + "user@192.168.1.1]", + "user@[256.256.256.256]", + + # Invalid quoted strings + "\"user@domain.com", + "user\"@domain.com", + + # Edge cases + "user@domain..com", + "user@domain...com", + "user....name@domain.com", + "user@domain.com.", + "user@domain.com.." + ].each do |email| + context "when the email is #{email}" do + let(:email) { email } + + it "is invalid" do + validator.validate(model) + expect(model.errors.to_hash).to eq(email: ["invalid_email_format"]) + end + end + end +end diff --git a/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_fixed_charges_and_coupon_adjustment/renders_fixed_charges_with_coupon_adjustment_in_totals.html.snap b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_fixed_charges_and_coupon_adjustment/renders_fixed_charges_with_coupon_adjustment_in_totals.html.snap new file mode 100644 index 0000000..ef31ef4 --- /dev/null +++ b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_fixed_charges_and_coupon_adjustment/renders_fixed_charges_with_coupon_adjustment_in_totals.html.snap @@ -0,0 +1,170 @@ + + + +
+
+

+ Credit note +

+
+
+
+ + + + + + + + + + + + + +
+ Credit note number + + CN-202510-005 +
+ Invoice number + + LAGO-202509-001 +
+ Issue date + + Oct 05, 2025 +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Credit to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+
+ Apt 202 +
+
+ 10001 + ,   + New York +
+
+ NY +
+
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $15.00 +

+
+ Credited on customer balance on Oct 05, 2025 +
+
+
+ + + + + + + + + + + + + + + + +
ItemTax rateAmount (excl. tax)
Setup Fee +
0.0%
+
$10.00
Onboarding Fee +
0.0%
+
$10.00
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Coupons-$5.00
Sub total (excl. tax)$15.00
Tax (0%)$0.00
Credited on customer balance$15.00
Total$15.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_fixed_charges_having_different_tax_rates/renders_fixed_charges_with_multiple_tax_rates_in_descending_order.html.snap b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_fixed_charges_having_different_tax_rates/renders_fixed_charges_with_multiple_tax_rates_in_descending_order.html.snap new file mode 100644 index 0000000..258a478 --- /dev/null +++ b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_fixed_charges_having_different_tax_rates/renders_fixed_charges_with_multiple_tax_rates_in_descending_order.html.snap @@ -0,0 +1,170 @@ + + + +
+
+

+ Credit note +

+
+
+
+ + + + + + + + + + + + + +
+ Credit note number + + CN-202510-004 +
+ Invoice number + + LAGO-202509-001 +
+ Issue date + + Oct 05, 2025 +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Credit to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+
+ Apt 202 +
+
+ 10001 + ,   + New York +
+
+ NY +
+
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $23.00 +

+
+ Credited on customer balance on Oct 05, 2025 +
+
+
+ + + + + + + + + + + + + + + + +
ItemTax rateAmount (excl. tax)
Setup Fee +
20.0%
+
$10.00
Installation Fee +
20.0%
+
$10.00
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Sub total (excl. tax)$20.00
Sales Tax (20.0% on $10.00)$2.00
VAT (10.0% on $10.00)$1.00
Credited on customer balance$23.00
Total$23.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_only_charge_fees/renders_each_charge_fee_correctly.html.snap b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_only_charge_fees/renders_each_charge_fee_correctly.html.snap new file mode 100644 index 0000000..70fafb4 --- /dev/null +++ b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_only_charge_fees/renders_each_charge_fee_correctly.html.snap @@ -0,0 +1,165 @@ + + + +
+
+

+ Credit note +

+
+
+
+ + + + + + + + + + + + + +
+ Credit note number + + CN-202510-003 +
+ Invoice number + + LAGO-202509-001 +
+ Issue date + + Oct 05, 2025 +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Credit to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+
+ Apt 202 +
+
+ 10001 + ,   + New York +
+
+ NY +
+
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $25.00 +

+
+ Credited on customer balance on Oct 05, 2025 +
+
+
+ + + + + + + + + + + + + + + + +
ItemTax rateAmount (excl. tax)
API Calls +
0.0%
+
$15.00
Storage +
0.0%
+
$10.00
+ + + + + + + + + + + + + + + + + + + + + +
Sub total (excl. tax)$25.00
Tax (0%)$0.00
Credited on customer balance$25.00
Total$25.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_only_fixed_charge_fees/renders_each_fixed_charge_fee_separately.html.snap b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_only_fixed_charge_fees/renders_each_fixed_charge_fee_separately.html.snap new file mode 100644 index 0000000..bad9df1 --- /dev/null +++ b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_only_fixed_charge_fees/renders_each_fixed_charge_fee_separately.html.snap @@ -0,0 +1,165 @@ + + + +
+
+

+ Credit note +

+
+
+
+ + + + + + + + + + + + + +
+ Credit note number + + CN-202510-002 +
+ Invoice number + + LAGO-202509-001 +
+ Issue date + + Oct 05, 2025 +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Credit to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+
+ Apt 202 +
+
+ 10001 + ,   + New York +
+
+ NY +
+
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $22.00 +

+
+ Credited on customer balance on Oct 05, 2025 +
+
+
+ + + + + + + + + + + + + + + + +
ItemTax rateAmount (excl. tax)
Setup Fee +
10.0%
+
$10.00
Installation Fee +
10.0%
+
$10.00
+ + + + + + + + + + + + + + + + + + + + + +
Sub total (excl. tax)$20.00
VAT (10.0% on $20.00)$2.00
Credited on customer balance$22.00
Total$22.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_subscription_fee,_fixed_charge_fee,_and_charge_fee/renders_all_fee_types_correctly.html.snap b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_subscription_fee,_fixed_charge_fee,_and_charge_fee/renders_all_fee_types_correctly.html.snap new file mode 100644 index 0000000..bacbdec --- /dev/null +++ b/spec/views/app/views/templates/credit_notes/__snapshots__/templates_credit_notes_credit_note.slim/with_subscription_fee,_fixed_charge_fee,_and_charge_fee/renders_all_fee_types_correctly.html.snap @@ -0,0 +1,172 @@ + + + +
+
+

+ Credit note +

+
+
+
+ + + + + + + + + + + + + +
+ Credit note number + + CN-202510-001 +
+ Invoice number + + LAGO-202509-001 +
+ Issue date + + Oct 05, 2025 +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Credit to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+
+ Apt 202 +
+
+ 10001 + ,   + New York +
+
+ NY +
+
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $30.00 +

+
+ Credited on customer balance on Oct 05, 2025 +
+
+
+ + + + + + + + + + + + + + + + + + + + + +
ItemTax rateAmount (excl. tax)
Subscription - Premium Plan +
0.0%
+
$10.00
Setup Fee +
0.0%
+
$10.00
API Calls +
0.0%
+
$10.00
+ + + + + + + + + + + + + + + + + + + + + +
Sub total (excl. tax)$30.00
Tax (0%)$0.00
Credited on customer balance$30.00
Total$30.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/credit_notes/credit_note.slim_spec.rb b/spec/views/app/views/templates/credit_notes/credit_note.slim_spec.rb new file mode 100644 index 0000000..7e4c967 --- /dev/null +++ b/spec/views/app/views/templates/credit_notes/credit_note.slim_spec.rb @@ -0,0 +1,728 @@ +# frozen_string_literal: true + +require "rails_helper" + +# This spec relies on `rspec-snapshot` gem (https://github.com/levinmr/rspec-snapshot) in order to serialize and compare +# the rendered invoice HTML. +# +# To update a snapshot, either delete it, or run the tests with `UPDATE_SNAPSHOTS=true` environment variable. + +RSpec.describe "templates/credit_notes/credit_note.slim" do + subject(:rendered_template) do + Slim::Template.new(template, 1, pretty: true).render(credit_note) + end + + let(:template) { Rails.root.join("app/views/templates/credit_notes/credit_note.slim") } + + let(:organization) { build_stubbed(:organization, :with_static_values) } + let(:billing_entity) { build_stubbed(:billing_entity, :with_static_values, organization:) } + let(:customer) { build_stubbed(:customer, :with_static_values, organization:) } + + let(:invoice) do + build_stubbed( + :invoice, + organization:, + billing_entity:, + customer:, + number: "LAGO-202509-001" + ) + end + + let(:plan) { build_stubbed(:plan, organization:, name: "Premium Plan") } + + let(:subscription) do + build_stubbed( + :subscription, + organization:, + customer:, + plan:, + name: "Premium Plan" + ) + end + + before do + I18n.locale = :en + + # Stub through association (credit_note.billing_entity goes through invoice) + allow(credit_note).to receive(:billing_entity).and_return(billing_entity) + + # Stub direct Subscription.find_by call in template + allow(Subscription).to receive(:find_by).with(id: subscription.id).and_return(subscription) + end + + context "with subscription fee, fixed charge fee, and charge fee" do + let(:credit_note) do + build_stubbed( + :credit_note, + organization:, + customer:, + invoice:, + number: "CN-202510-001", + issuing_date: Date.parse("2025-10-05"), + total_amount_currency: "USD", + total_amount_cents: 3000, + taxes_amount_cents: 0, + credit_amount_currency: "USD", + credit_amount_cents: 3000, + items: [subscription_fee_item, fixed_charge_item, charge_item] + ) + end + + let(:subscription_fee_item) do + build_stubbed( + :credit_note_item, + organization:, + fee: subscription_fee, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:subscription_fee) do + build_stubbed( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:fixed_charge_item) do + build_stubbed( + :credit_note_item, + organization:, + fee: fixed_charge_fee, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:fixed_charge) do + build_stubbed( + :fixed_charge, + plan:, + invoice_display_name: "Setup Fee" + ) + end + + let(:fixed_charge_fee) do + build_stubbed( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge:, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:charge_item) do + build_stubbed( + :credit_note_item, + organization:, + fee: charge_fee, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:charge) do + build_stubbed( + :standard_charge, + plan:, + invoice_display_name: "API Calls" + ) + end + + let(:charge_fee) do + build_stubbed( + :charge_fee, + invoice:, + subscription:, + charge:, + amount_cents: 1000, + amount_currency: "USD", + units: 100 + ) + end + + before do + # Stub subscription-related queries + allow(credit_note).to receive(:subscription_ids).and_return([subscription.id]) + allow(credit_note).to receive(:subscription_item).with(subscription.id).and_return(subscription_fee_item) + allow(credit_note).to receive(:subscription_fixed_charge_items).with(subscription.id).and_return([fixed_charge_item]) + + # Create a mock relation for charge items that responds to .where() + charge_items_relation = instance_double(ActiveRecord::Relation) + allow(charge_items_relation).to receive(:where).and_return([charge_item]) + allow(credit_note).to receive(:subscription_charge_items).with(subscription.id).and_return(charge_items_relation) + + allow(credit_note).to receive(:add_on_items).and_return(CreditNoteItem.none) + + # Stub invoice_name methods (computed methods, not attributes) + allow(fixed_charge_fee).to receive(:invoice_name).and_return("Setup Fee") + allow(charge_fee).to receive(:invoice_name).and_return("API Calls") + + # Stub sub_total_excluding_taxes_amount (computed from items) + allow(credit_note).to receive(:sub_total_excluding_taxes_amount).and_return(Money.new(3000, "USD")) + end + + it "renders all fee types correctly" do + expect(rendered_template).to match_html_snapshot("with_all_fee_types") + end + end + + context "with only fixed charge fees" do + let(:credit_note) do + build_stubbed( + :credit_note, + organization:, + customer:, + invoice:, + number: "CN-202510-002", + issuing_date: Date.parse("2025-10-05"), + total_amount_currency: "USD", + total_amount_cents: 2200, + taxes_amount_cents: 200, + credit_amount_currency: "USD", + credit_amount_cents: 2200, + items: [fixed_charge_item1, fixed_charge_item2] + ) + end + + let(:fixed_charge_item1) do + build_stubbed( + :credit_note_item, + organization:, + fee: fixed_charge_fee1, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:fixed_charge_item2) do + build_stubbed( + :credit_note_item, + organization:, + fee: fixed_charge_fee2, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:fixed_charge1) do + build_stubbed( + :fixed_charge, + plan:, + invoice_display_name: "Setup Fee" + ) + end + + let(:fixed_charge2) do + build_stubbed( + :fixed_charge, + plan:, + invoice_display_name: "Installation Fee" + ) + end + + let(:fixed_charge_fee1) do + build_stubbed( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge: fixed_charge1, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:fixed_charge_fee2) do + build_stubbed( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge: fixed_charge2, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:tax) do + build_stubbed( + :tax, + organization:, + rate: 10.0, + name: "VAT" + ) + end + + let(:applied_tax) do + build_stubbed( + :credit_note_applied_tax, + credit_note:, + tax:, + tax_name: "VAT", + tax_code: "vat", + tax_rate: 10.0, + amount_cents: 200, + amount_currency: "USD", + base_amount_cents: 2000 + ) + end + + before do + # Stub subscription-related queries + allow(credit_note).to receive(:subscription_ids).and_return([subscription.id]) + allow(credit_note).to receive(:subscription_item).with(subscription.id).and_return( + build_stubbed(:fee, amount_cents: 0, amount_currency: "USD") + ) + allow(credit_note).to receive(:subscription_fixed_charge_items).with(subscription.id).and_return([fixed_charge_item1, fixed_charge_item2]) + allow(credit_note).to receive(:subscription_charge_items).with(subscription.id).and_return(CreditNoteItem.none) + allow(credit_note).to receive(:add_on_items).and_return(CreditNoteItem.none) + + # Stub invoice_name methods + allow(fixed_charge_fee1).to receive(:invoice_name).and_return("Setup Fee") + allow(fixed_charge_fee2).to receive(:invoice_name).and_return("Installation Fee") + + # Stub applied_taxes for the credit note and items + # The TaxHelper calls item.applied_taxes which calls credit_note.applied_taxes.where(...) + # Then it calls .order().pluck() on the result + item_applied_taxes = instance_double(ActiveRecord::Relation) + allow(item_applied_taxes).to receive(:order).with(tax_rate: :desc).and_return(item_applied_taxes) + allow(item_applied_taxes).to receive(:pluck).with(:tax_rate).and_return([10.0]) + allow(item_applied_taxes).to receive(:present?).and_return(true) + + applied_taxes_relation = instance_double(ActiveRecord::Relation) + allow(applied_taxes_relation).to receive(:where).and_return(item_applied_taxes) + allow(applied_taxes_relation).to receive(:order).with(tax_rate: :desc).and_return([applied_tax]) + allow(applied_taxes_relation).to receive(:present?).and_return(true) + allow(credit_note).to receive(:applied_taxes).and_return(applied_taxes_relation) + + # Stub applied_taxes on fees (returns empty for the select query) + fee_applied_taxes = instance_double(ActiveRecord::Relation) + allow(fee_applied_taxes).to receive(:select).and_return([]) + allow(fixed_charge_fee1).to receive(:applied_taxes).and_return(fee_applied_taxes) + allow(fixed_charge_fee2).to receive(:applied_taxes).and_return(fee_applied_taxes) + + # Stub sub_total_excluding_taxes_amount + allow(credit_note).to receive(:sub_total_excluding_taxes_amount).and_return(Money.new(2000, "USD")) + end + + it "renders each fixed charge fee separately" do + expect(rendered_template).to match_html_snapshot("with_only_fixed_charges") + end + end + + context "with fixed charges having different tax rates" do + let(:credit_note) do + build_stubbed( + :credit_note, + organization:, + customer:, + invoice:, + number: "CN-202510-004", + issuing_date: Date.parse("2025-10-05"), + total_amount_currency: "USD", + total_amount_cents: 2300, + taxes_amount_cents: 300, + credit_amount_currency: "USD", + credit_amount_cents: 2300, + items: [fixed_charge_item1, fixed_charge_item2] + ) + end + + let(:fixed_charge_item1) do + build_stubbed( + :credit_note_item, + organization:, + fee: fixed_charge_fee1, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:fixed_charge_item2) do + build_stubbed( + :credit_note_item, + organization:, + fee: fixed_charge_fee2, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:fixed_charge1) do + build_stubbed( + :fixed_charge, + plan:, + invoice_display_name: "Setup Fee" + ) + end + + let(:fixed_charge2) do + build_stubbed( + :fixed_charge, + plan:, + invoice_display_name: "Installation Fee" + ) + end + + let(:fixed_charge_fee1) do + build_stubbed( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge: fixed_charge1, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:fixed_charge_fee2) do + build_stubbed( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge: fixed_charge2, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:tax1) do + build_stubbed( + :tax, + organization:, + rate: 10.0, + name: "VAT" + ) + end + + let(:tax2) do + build_stubbed( + :tax, + organization:, + rate: 20.0, + name: "Sales Tax" + ) + end + + let(:applied_tax1) do + build_stubbed( + :credit_note_applied_tax, + credit_note:, + tax: tax1, + tax_name: "VAT", + tax_code: "vat", + tax_rate: 10.0, + amount_cents: 100, + amount_currency: "USD", + base_amount_cents: 1000 + ) + end + + let(:applied_tax2) do + build_stubbed( + :credit_note_applied_tax, + credit_note:, + tax: tax2, + tax_name: "Sales Tax", + tax_code: "sales_tax", + tax_rate: 20.0, + amount_cents: 200, + amount_currency: "USD", + base_amount_cents: 1000 + ) + end + + before do + # Stub subscription-related queries + allow(credit_note).to receive(:subscription_ids).and_return([subscription.id]) + allow(credit_note).to receive(:subscription_item).with(subscription.id).and_return( + build_stubbed(:fee, amount_cents: 0, amount_currency: "USD") + ) + allow(credit_note).to receive(:subscription_fixed_charge_items).with(subscription.id).and_return([fixed_charge_item1, fixed_charge_item2]) + allow(credit_note).to receive(:subscription_charge_items).with(subscription.id).and_return(CreditNoteItem.none) + allow(credit_note).to receive(:add_on_items).and_return(CreditNoteItem.none) + + # Stub invoice_name methods + allow(fixed_charge_fee1).to receive(:invoice_name).and_return("Setup Fee") + allow(fixed_charge_fee2).to receive(:invoice_name).and_return("Installation Fee") + + # Stub applied_taxes for items with different tax rates + # Item 1 has 10% tax + item1_applied_taxes = instance_double(ActiveRecord::Relation) + allow(item1_applied_taxes).to receive(:order).with(tax_rate: :desc).and_return(item1_applied_taxes) + allow(item1_applied_taxes).to receive(:pluck).with(:tax_rate).and_return([10.0]) + allow(item1_applied_taxes).to receive(:present?).and_return(true) + + # Item 2 has 20% tax + item2_applied_taxes = instance_double(ActiveRecord::Relation) + allow(item2_applied_taxes).to receive(:order).with(tax_rate: :desc).and_return(item2_applied_taxes) + allow(item2_applied_taxes).to receive(:pluck).with(:tax_rate).and_return([20.0]) + allow(item2_applied_taxes).to receive(:present?).and_return(true) + + # Credit note applied_taxes returns different results based on item + applied_taxes_relation = instance_double(ActiveRecord::Relation) + allow(applied_taxes_relation).to receive(:where).and_return(item1_applied_taxes, item2_applied_taxes) + allow(applied_taxes_relation).to receive(:order).with(tax_rate: :desc).and_return([applied_tax2, applied_tax1]) # Descending order: 20%, 10% + allow(applied_taxes_relation).to receive(:present?).and_return(true) + allow(credit_note).to receive(:applied_taxes).and_return(applied_taxes_relation) + + # Stub applied_taxes on fees + fee_applied_taxes = instance_double(ActiveRecord::Relation) + allow(fee_applied_taxes).to receive(:select).and_return([]) + allow(fixed_charge_fee1).to receive(:applied_taxes).and_return(fee_applied_taxes) + allow(fixed_charge_fee2).to receive(:applied_taxes).and_return(fee_applied_taxes) + + # Stub sub_total_excluding_taxes_amount + allow(credit_note).to receive(:sub_total_excluding_taxes_amount).and_return(Money.new(2000, "USD")) + end + + it "renders fixed charges with multiple tax rates in descending order" do + expect(rendered_template).to match_html_snapshot("with_multiple_tax_rates") + end + end + + context "with fixed charges and coupon adjustment" do + let(:credit_note) do + build_stubbed( + :credit_note, + organization:, + customer:, + invoice:, + number: "CN-202510-005", + issuing_date: Date.parse("2025-10-05"), + total_amount_currency: "USD", + total_amount_cents: 1500, + taxes_amount_cents: 0, + credit_amount_currency: "USD", + credit_amount_cents: 1500, + coupons_adjustment_amount_cents: 500, + items: [fixed_charge_item1, fixed_charge_item2] + ) + end + + let(:fixed_charge_item1) do + build_stubbed( + :credit_note_item, + organization:, + fee: fixed_charge_fee1, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:fixed_charge_item2) do + build_stubbed( + :credit_note_item, + organization:, + fee: fixed_charge_fee2, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:fixed_charge1) do + build_stubbed( + :fixed_charge, + plan:, + invoice_display_name: "Setup Fee" + ) + end + + let(:fixed_charge2) do + build_stubbed( + :fixed_charge, + plan:, + invoice_display_name: "Onboarding Fee" + ) + end + + let(:fixed_charge_fee1) do + build_stubbed( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge: fixed_charge1, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:fixed_charge_fee2) do + build_stubbed( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge: fixed_charge2, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + before do + # Stub subscription-related queries + allow(credit_note).to receive(:subscription_ids).and_return([subscription.id]) + allow(credit_note).to receive(:subscription_item).with(subscription.id).and_return( + build_stubbed(:fee, amount_cents: 0, amount_currency: "USD") + ) + allow(credit_note).to receive(:subscription_fixed_charge_items).with(subscription.id).and_return([fixed_charge_item1, fixed_charge_item2]) + allow(credit_note).to receive(:subscription_charge_items).with(subscription.id).and_return(CreditNoteItem.none) + allow(credit_note).to receive(:add_on_items).and_return(CreditNoteItem.none) + + # Stub invoice_name methods + allow(fixed_charge_fee1).to receive(:invoice_name).and_return("Setup Fee") + allow(fixed_charge_fee2).to receive(:invoice_name).and_return("Onboarding Fee") + + # Stub applied_taxes (no taxes in this scenario) + item_applied_taxes = instance_double(ActiveRecord::Relation) + allow(item_applied_taxes).to receive(:order).with(tax_rate: :desc).and_return(item_applied_taxes) + allow(item_applied_taxes).to receive(:pluck).with(:tax_rate).and_return([0.0]) + allow(item_applied_taxes).to receive(:present?).and_return(false) + + applied_taxes_relation = instance_double(ActiveRecord::Relation) + allow(applied_taxes_relation).to receive(:where).and_return(item_applied_taxes) + allow(applied_taxes_relation).to receive(:present?).and_return(false) + allow(credit_note).to receive(:applied_taxes).and_return(applied_taxes_relation) + + # Stub applied_taxes on fees + fee_applied_taxes = instance_double(ActiveRecord::Relation) + allow(fee_applied_taxes).to receive(:select).and_return([]) + allow(fixed_charge_fee1).to receive(:applied_taxes).and_return(fee_applied_taxes) + allow(fixed_charge_fee2).to receive(:applied_taxes).and_return(fee_applied_taxes) + + # Stub coupons_adjustment_amount for display + allow(credit_note).to receive(:coupons_adjustment_amount).and_return(Money.new(500, "USD")) + + # Stub sub_total_excluding_taxes_amount (after coupon: $20 - $5 = $15) + allow(credit_note).to receive(:sub_total_excluding_taxes_amount).and_return(Money.new(1500, "USD")) + end + + it "renders fixed charges with coupon adjustment in totals" do + expect(rendered_template).to match_html_snapshot("with_coupon_adjustment") + end + end + + context "with only charge fees" do + let(:credit_note) do + build_stubbed( + :credit_note, + organization:, + customer:, + invoice:, + number: "CN-202510-003", + issuing_date: Date.parse("2025-10-05"), + total_amount_currency: "USD", + total_amount_cents: 2500, + taxes_amount_cents: 0, + credit_amount_currency: "USD", + credit_amount_cents: 2500, + items: [charge_item1, charge_item2] + ) + end + + let(:charge_item1) do + build_stubbed( + :credit_note_item, + organization:, + fee: charge_fee1, + amount_cents: 1500, + amount_currency: "USD", + precise_amount_cents: 1500 + ) + end + + let(:charge_item2) do + build_stubbed( + :credit_note_item, + organization:, + fee: charge_fee2, + amount_cents: 1000, + amount_currency: "USD", + precise_amount_cents: 1000 + ) + end + + let(:charge1) do + build_stubbed( + :standard_charge, + plan:, + invoice_display_name: "API Calls" + ) + end + + let(:charge2) do + build_stubbed( + :standard_charge, + plan:, + invoice_display_name: "Storage" + ) + end + + let(:charge_fee1) do + build_stubbed( + :charge_fee, + invoice:, + subscription:, + charge: charge1, + fixed_charge: nil, + amount_cents: 1500, + amount_currency: "USD", + units: 150 + ) + end + + let(:charge_fee2) do + build_stubbed( + :charge_fee, + invoice:, + subscription:, + charge: charge2, + fixed_charge: nil, + amount_cents: 1000, + amount_currency: "USD", + units: 100 + ) + end + + before do + # Stub subscription-related queries + allow(credit_note).to receive(:subscription_ids).and_return([subscription.id]) + allow(credit_note).to receive(:subscription_item).with(subscription.id).and_return( + build_stubbed(:fee, amount_cents: 0, amount_currency: "USD") + ) + allow(credit_note).to receive(:subscription_fixed_charge_items).with(subscription.id).and_return([]) + + # Create a mock relation for charge items that responds to .where() + charge_items_relation = instance_double(ActiveRecord::Relation) + allow(charge_items_relation).to receive(:where).and_return([charge_item1, charge_item2]) + allow(credit_note).to receive(:subscription_charge_items).with(subscription.id).and_return(charge_items_relation) + + allow(credit_note).to receive(:add_on_items).and_return(CreditNoteItem.none) + + # Stub invoice_name methods + allow(charge_fee1).to receive(:invoice_name).and_return("API Calls") + allow(charge_fee2).to receive(:invoice_name).and_return("Storage") + + # Stub sub_total_excluding_taxes_amount + allow(credit_note).to receive(:sub_total_excluding_taxes_amount).and_return(Money.new(2500, "USD")) + end + + it "renders each charge fee correctly" do + expect(rendered_template).to match_html_snapshot("with_only_charges") + end + end +end diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_charge_has_filters_and_minimum_commitment_(true_up_fee)/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_charge_has_filters_and_minimum_commitment_(true_up_fee)/renders_correctly.html.snap new file mode 100644 index 0000000..4139b7e --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_charge_has_filters_and_minimum_commitment_(true_up_fee)/renders_correctly.html.snap @@ -0,0 +1,198 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-004 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Oct 01, 2025 +
+
+
+

Plan with Charge Filters details

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Aug 31, 2025UnitsUnit priceTax rateAmount
+
Usage Charge with Minimum
+
0$0.00 +
0.0%
+
$0.00
+
Usage Charge with Minimum - True-up
+
Minimum spend of €100.00 prorated on days of usage
+
1$30.00 +
0.0%
+
$30.00
+
Usage Charge with Minimum • eu
+
2$20.00 +
0.0%
+
$40.00
+
Usage Charge with Minimum • us
+
3$10.00 +
0.0%
+
$30.00
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_different_boundaries_for_fixed_charges/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_different_boundaries_for_fixed_charges/renders_correctly.html.snap new file mode 100644 index 0000000..e5d6a00 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_different_boundaries_for_fixed_charges/renders_correctly.html.snap @@ -0,0 +1,183 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-003 +
+ Issue Date + + Jan 01, 2026 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ €50.00 +

+
+ Due Jan 01, 2026 +
+
+
+

Annual Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Dec 31, 2025UnitsUnit priceTax rateAmount
Yearly subscription - Annual Plan1.0€999.00 +
0.0%
+
€999.00
+
+
+ + + + + + + + + + + + + + + +
Fees from Dec 01, 2025 to Dec 31, 2025UnitsUnit priceTax rateAmount
+
Monthly Fixed Charge
+
1€100.00 +
0.0%
+
€100.00
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)€50.00
Tax (0%)€0.00
Subtotal (incl. tax)€50.00
Total€50.00
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_multiple_subscriptions/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_multiple_subscriptions/renders_correctly.html.snap new file mode 100644 index 0000000..06eabac --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_multiple_subscriptions/renders_correctly.html.snap @@ -0,0 +1,212 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-005 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $80.00 +

+
+ Due Oct 01, 2025 +
+
+
+ + + + + + + + + + + + + +
ItemAmount
Alpha Plan€30.00
Zebra Plan€50.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$80.00
Tax (0%)$0.00
Subtotal (incl. tax)$80.00
Total$80.00
+
+

+

+
+ Powered by   + Lago Logo +
+

Alpha Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Aug 31, 2025UnitsUnit priceTax rateAmount
Alpha Plan Subscription1.0$30.00 +
0.0%
+
$30.00
+
+
+ + + + + + +
Total€30.00
+
+

Zebra Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Aug 31, 2025UnitsUnit priceTax rateAmount
Zebra Plan Subscription1.0$50.00 +
0.0%
+
$50.00
+
+
+ + + + + + +
Total€50.00
+
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_multiple_subscriptions_with_prepaid_credits/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_multiple_subscriptions_with_prepaid_credits/renders_correctly.html.snap new file mode 100644 index 0000000..9909045 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_has_multiple_subscriptions_with_prepaid_credits/renders_correctly.html.snap @@ -0,0 +1,222 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-006 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $70.00 +

+
+ Due Oct 01, 2025 +
+
+
+ + + + + + + + + + + + + +
ItemAmount
Alpha Plan€50.00
Beta Plan€30.00
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$80.00
Tax (0%)$0.00
Subtotal (incl. tax)$80.00
Free credits-$4.00
Prepaid credits-$6.00
Total$70.00
+
+

+

+
+ Powered by   + Lago Logo +
+

Alpha Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Aug 31, 2025UnitsUnit priceTax rateAmount
Alpha Plan Subscription1.0$50.00 +
0.0%
+
$50.00
+
+
+ + + + + + +
Total€50.00
+
+

Beta Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Aug 31, 2025UnitsUnit priceTax rateAmount
Beta Plan Subscription1.0$30.00 +
0.0%
+
$30.00
+
+
+ + + + + + +
Total€30.00
+
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_a_name/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_a_name/renders_correctly.html.snap new file mode 100644 index 0000000..70ec69d --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_a_name/renders_correctly.html.snap @@ -0,0 +1,137 @@ + + + +
+
+

+ Advance invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-001 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $10.50 +

+
+ Due Sep 04, 2025 +
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Wallet Transaction Name10.51.0$10.50
+ + + + + + +
Total$10.50
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_no_name/when_wallet_has_a_name/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_no_name/when_wallet_has_a_name/renders_correctly.html.snap new file mode 100644 index 0000000..c7ea2e7 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_no_name/when_wallet_has_a_name/renders_correctly.html.snap @@ -0,0 +1,137 @@ + + + +
+
+

+ Advance invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-001 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $10.50 +

+
+ Due Sep 04, 2025 +
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Prepaid credits - Premium Wallet10.51.0$10.50
+ + + + + + +
Total$10.50
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_no_name/when_wallet_has_no_name/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_no_name/when_wallet_has_no_name/renders_correctly.html.snap new file mode 100644 index 0000000..b96b864 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_credit/when_wallet_transaction_has_no_name/when_wallet_has_no_name/renders_correctly.html.snap @@ -0,0 +1,137 @@ + + + +
+
+

+ Advance invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-001 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $10.50 +

+
+ Due Sep 04, 2025 +
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Prepaid credits10.51.0$10.50
+ + + + + + +
Total$10.50
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_progressive_billing_with_prepaid_credits/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_progressive_billing_with_prepaid_credits/renders_correctly.html.snap new file mode 100644 index 0000000..7fcb123 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_progressive_billing_with_prepaid_credits/renders_correctly.html.snap @@ -0,0 +1,179 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-008 +
+ Issue Date + + Sep 15, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $95.00 +

+
+ Due Sep 15, 2025 +
+
+
+
+ + + + + + + + +
Fees from Sep 01, 2025 to Sep 15, 2025UnitsUnit priceTax rateAmount
+
+
+ + + + + + + + +
+
Usage Charge Fee
+
100$1.00 +
0.0%
+
$100.00
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Free credits-$2.00
Prepaid credits-$3.00
Total$95.00
+
+
+

+ This progressive billing is generated because your cumulative usage has reached $100.00, exceeding the $100.00 threshold. +

+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_and_plan_is_paid_in_advance/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_and_plan_is_paid_in_advance/renders_correctly.html.snap new file mode 100644 index 0000000..dcfeb9c --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_and_plan_is_paid_in_advance/renders_correctly.html.snap @@ -0,0 +1,444 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-002 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $50.00 +

+
+ Due Sep 19, 2025 +
+
+
+

Premium Plan details

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Aug 31, 2025UnitsUnit priceTax rateAmount
Graduated Pay in Arrears Fixed Charge Fee
Fee per unit for the first 1010.0$5.00 +
0.0%
+
$50.00
Fee per unit for 11 and above5.0$1.00 +
0.0%
+
$5.00
Flat fee for first 101$200.00 +
0.0%
+
$200.00
Flat fee for 11 and above1$300.00 +
0.0%
+
$300.00
Subtotal$555.00
Graduated Pay in Arrears Prorated Fixed Charge Fee +
The fee is prorated on days of usage, the displayed unit price is an average
+
Fee per unit for the first 1010.0$5.00 +
0.0%
+
$50.00
Fee per unit for 11 and above5.0$1.00 +
0.0%
+
$5.00
Flat fee for first 101$200.00 +
0.0%
+
$200.00
Flat fee for 11 and above1$300.00 +
0.0%
+
$300.00
Subtotal$555.00
+
Standard Pay in Arrears Fixed Charge Fee
+
1$85.00 +
0.0%
+
$85.00
Volume Pay in Arrears Fixed Charge Fee
Fee per unit75$2.00 +
0.0%
+
$150.00
Flat fee for all units1$1.00 +
0.0%
+
$1.00
Subtotal$151.00
Volume Pay in Arrears Prorated Fixed Charge Fee +
The fee is prorated on days of usage, the displayed unit price is an average
+
Fee per unit30$3.00 +
0.0%
+
$90.00
Flat fee for all units1$2.00 +
0.0%
+
$2.00
Subtotal$92.00
Minimum Commitment Fee1$2.00 +
0.0%
+
$2.00
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Pay in Advance Subscription Fee1.0$15.00 +
0.0%
+
$15.00
Graduated Pay in Advance Fixed Charge Fee
Fee per unit for the first 1010.0$5.00 +
0.0%
+
$50.00
Fee per unit for 11 and above5.0$1.00 +
0.0%
+
$5.00
Flat fee for first 101$200.00 +
0.0%
+
$200.00
Flat fee for 11 and above1$300.00 +
0.0%
+
$300.00
Subtotal$555.00
+
Standard Pay in Advance Fixed Charge Fee
+
2$25.00 +
0.0%
+
$50.00
+
Standard Pay in Advance Prorated Fixed Charge Fee
+
The fee is prorated on days of usage, the displayed unit price is an average
+
0.5$100.00 +
0.0%
+
$50.00
+
Zero Pay in Advance Fixed Charge Fee
+
1$0.00 +
0.0%
+
$0.00
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Total$50.00
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_and_plan_is_paid_in_arrears/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_and_plan_is_paid_in_arrears/renders_correctly.html.snap new file mode 100644 index 0000000..15bfa38 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_and_plan_is_paid_in_arrears/renders_correctly.html.snap @@ -0,0 +1,444 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-002 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $50.00 +

+
+ Due Oct 01, 2025 +
+
+
+

Pay in Arrears Premium Plan details

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fees from Aug 01, 2025 to Aug 31, 2025UnitsUnit priceTax rateAmount
Pay in Arrears Subscription Fee1.0$15.00 +
0.0%
+
$15.00
Graduated Pay in Arrears Fixed Charge Fee
Fee per unit for the first 1010.0$5.00 +
0.0%
+
$50.00
Fee per unit for 11 and above5.0$1.00 +
0.0%
+
$5.00
Flat fee for first 101$200.00 +
0.0%
+
$200.00
Flat fee for 11 and above1$300.00 +
0.0%
+
$300.00
Subtotal$555.00
Graduated Pay in Arrears Prorated Fixed Charge Fee +
The fee is prorated on days of usage, the displayed unit price is an average
+
Fee per unit for the first 1010.0$5.00 +
0.0%
+
$50.00
Fee per unit for 11 and above5.0$1.00 +
0.0%
+
$5.00
Flat fee for first 101$200.00 +
0.0%
+
$200.00
Flat fee for 11 and above1$300.00 +
0.0%
+
$300.00
Subtotal$555.00
+
Standard Pay in Arrears Fixed Charge Fee
+
1$85.00 +
0.0%
+
$85.00
Volume Pay in Arrears Fixed Charge Fee
Fee per unit75$2.00 +
0.0%
+
$150.00
Flat fee for all units1$1.00 +
0.0%
+
$1.00
Subtotal$151.00
Volume Pay in Arrears Prorated Fixed Charge Fee +
The fee is prorated on days of usage, the displayed unit price is an average
+
Fee per unit30$3.00 +
0.0%
+
$90.00
Flat fee for all units1$2.00 +
0.0%
+
$2.00
Subtotal$92.00
Minimum Commitment Fee1$2.00 +
0.0%
+
$2.00
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Graduated Pay in Advance Fixed Charge Fee
Fee per unit for the first 1010.0$5.00 +
0.0%
+
$50.00
Fee per unit for 11 and above5.0$1.00 +
0.0%
+
$5.00
Flat fee for first 101$200.00 +
0.0%
+
$200.00
Flat fee for 11 and above1$300.00 +
0.0%
+
$300.00
Subtotal$555.00
+
Standard Pay in Advance Fixed Charge Fee
+
2$25.00 +
0.0%
+
$50.00
+
Standard Pay in Advance Prorated Fixed Charge Fee
+
The fee is prorated on days of usage, the displayed unit price is an average
+
0.5$100.00 +
0.0%
+
$50.00
+
Zero Pay in Advance Fixed Charge Fee
+
1$0.00 +
0.0%
+
$0.00
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Total$50.00
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_both_granted_and_purchased_credits/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_both_granted_and_purchased_credits/renders_correctly.html.snap new file mode 100644 index 0000000..4347044 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_both_granted_and_purchased_credits/renders_correctly.html.snap @@ -0,0 +1,171 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-002 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $45.00 +

+
+ Due Sep 04, 2025 +
+
+
+

Basic Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Basic Plan - Monthly1.0$0.00 +
0.0%
+
$50.00
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Free credits-$3.00
Prepaid credits-$2.00
Total$45.00
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_only_granted_credits/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_only_granted_credits/renders_correctly.html.snap new file mode 100644 index 0000000..c9dcae6 --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_only_granted_credits/renders_correctly.html.snap @@ -0,0 +1,166 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-002 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $45.00 +

+
+ Due Sep 04, 2025 +
+
+
+

Basic Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Basic Plan - Monthly1.0$0.00 +
0.0%
+
$50.00
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Free credits-$5.00
Total$45.00
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_only_purchased_credits/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_only_purchased_credits/renders_correctly.html.snap new file mode 100644 index 0000000..2a0763e --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/with_only_purchased_credits/renders_correctly.html.snap @@ -0,0 +1,166 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-002 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $45.00 +

+
+ Due Sep 04, 2025 +
+
+
+

Basic Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Basic Plan - Monthly1.0$0.00 +
0.0%
+
$50.00
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Prepaid credits-$5.00
Total$45.00
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/without_breakdown_(legacy_behavior)/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/without_breakdown_(legacy_behavior)/renders_correctly.html.snap new file mode 100644 index 0000000..2a0763e --- /dev/null +++ b/spec/views/app/views/templates/invoices/__snapshots__/templates_invoices_v4.slim/when_invoice_type_is_subscription_with_prepaid_credits/without_breakdown_(legacy_behavior)/renders_correctly.html.snap @@ -0,0 +1,166 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-002 +
+ Issue Date + + Sep 04, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $45.00 +

+
+ Due Sep 04, 2025 +
+
+
+

Basic Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Basic Plan - Monthly1.0$0.00 +
0.0%
+
$50.00
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Prepaid credits-$5.00
Total$45.00
+
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4.slim_spec.rb b/spec/views/app/views/templates/invoices/v4.slim_spec.rb new file mode 100644 index 0000000..859b10e --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4.slim_spec.rb @@ -0,0 +1,1741 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "templates/invoices/v4.slim" do + subject(:rendered_template) do + Slim::Template.new(template, 1, pretty: true).render(invoice) + end + + let(:template) { Rails.root.join("app/views/templates/invoices/v4.slim") } + let(:invoice) do + build_stubbed( + :invoice, + :credit, + organization: organization, + billing_entity: billing_entity, + customer: customer, + number: "LAGO-202509-001", + payment_due_date: Date.parse("2025-09-04"), + issuing_date: Date.parse("2025-09-04"), + total_amount_cents: 1050, + currency: "USD", + fees: [fee] + ) + end + + let(:organization) do + build_stubbed(:organization, :with_static_values) + end + + let(:billing_entity) do + build_stubbed(:billing_entity, :with_static_values, organization: organization) + end + + let(:customer) do + build_stubbed(:customer, :with_static_values, organization: organization) + end + + let(:wallet) do + build_stubbed( + :wallet, + customer: customer, + name: wallet_name, + balance_currency: "USD", + rate_amount: BigDecimal("1.0") + ) + end + + let(:wallet_transaction) do + build_stubbed( + :wallet_transaction, + wallet: wallet, + credit_amount: BigDecimal("10.50"), + amount: BigDecimal("10.50"), + name: wallet_transaction_name + ) + end + let(:wallet_transaction_name) { nil } + + let(:fee) do + build_stubbed( + :fee, + id: "87654321-0fed-cba9-8765-4321fedcba90", + fee_type: :credit, + invoiceable: wallet_transaction, + amount_cents: 1050, + amount_currency: "USD" + ) + end + + let(:wallet_name) { "Premium Wallet" } + + before do + I18n.locale = :en + end + + context "when invoice_type is credit" do + context "when wallet transaction has a name" do + let(:wallet_transaction_name) { "Wallet Transaction Name" } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when wallet transaction has no name" do + let(:wallet_transaction_name) { nil } + + context "when wallet has no name" do + let(:wallet_name) { nil } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when wallet has a name" do + let(:wallet_name) { "Premium Wallet" } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + end + end + + context "when invoice_type is subscription with prepaid credits" do + let(:organization) { create(:organization, :with_static_values, webhook_url: nil) } + let(:billing_entity) { create(:billing_entity, :with_static_values, organization:) } + let(:customer) { create(:customer, :with_static_values, organization:, billing_entity:) } + let(:plan) { create(:plan, organization:, interval: "monthly", name: "Basic Plan", invoice_display_name: "Basic Plan", code: "basic_plan", amount_cents: 5000, amount_currency: "USD") } + let(:subscription) { create(:subscription, customer:, organization:, plan:, external_id: "sub_123") } + let(:wallet) { create(:wallet, customer:, organization:, rate_amount: BigDecimal("1.0"), balance_currency: "USD") } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice:, credit_amount: BigDecimal("5.0"), amount: BigDecimal("5.0")) } + let(:subscription_fee) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 5000, + amount_currency: "USD", + units: 1, + invoice_display_name: "Basic Plan - Monthly" + ) + end + let(:invoice) do + create( + :invoice, + invoice_type: :subscription, + organization:, + billing_entity:, + customer:, + number: "LAGO-202509-002", + payment_due_date: Date.parse("2025-09-04"), + issuing_date: Date.parse("2025-09-04"), + fees_amount_cents: 5000, + sub_total_excluding_taxes_amount_cents: 5000, + sub_total_including_taxes_amount_cents: 5000, + taxes_amount_cents: 0, + total_amount_cents: 4500, + prepaid_credit_amount_cents: 500, + prepaid_granted_credit_amount_cents:, + prepaid_purchased_credit_amount_cents:, + currency: "USD" + ) + end + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: DateTime.parse("2025-09-01 00:00:00"), + to_datetime: DateTime.parse("2025-09-30 23:59:59"), + charges_from_datetime: DateTime.parse("2025-09-01 00:00:00"), + charges_to_datetime: DateTime.parse("2025-09-30 23:59:59") + ) + end + let(:prepaid_granted_credit_amount_cents) { nil } + let(:prepaid_purchased_credit_amount_cents) { nil } + + before do + invoice_subscription + subscription_fee + wallet_transaction + end + + context "with only granted credits" do + let(:prepaid_granted_credit_amount_cents) { 500 } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with only purchased credits" do + let(:prepaid_purchased_credit_amount_cents) { 500 } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with both granted and purchased credits" do + let(:prepaid_granted_credit_amount_cents) { 300 } + let(:prepaid_purchased_credit_amount_cents) { 200 } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "without breakdown (legacy behavior)" do + let(:prepaid_granted_credit_amount_cents) { nil } + let(:prepaid_purchased_credit_amount_cents) { nil } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + end + + context "when invoice_type is subscription and plan is paid in arrears" do + let(:organization) { create(:organization, :with_static_values) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + invoice_display_name: "Pay in Arrears Premium Plan" + ) + end + + let(:subscription) do + create(:subscription, customer:, plan:, status: "active") + end + + let(:invoice) do + create( + :invoice, + customer:, + number: "LAGO-202509-002", + payment_due_date: Date.parse("2025-10-01"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 5000, + currency: "USD", + fees_amount_cents: 5000, + sub_total_excluding_taxes_amount_cents: 5000, + sub_total_including_taxes_amount_cents: 5000 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + timestamp: Time.zone.parse("2025-08-31 23:59:59") + ) + end + + let(:subscription_fee) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 1500, + amount_currency: "USD", + units: 1, + unit_amount_cents: 1500, + precise_unit_amount: 15.00, + invoice_display_name: "Pay in Arrears Subscription Fee", + properties: { + from_datetime: "2025-08-01 00:00:00", + to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:add_on) { create(:add_on, organization: organization) } + + let(:standard_fixed_charge) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + let(:standard_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: standard_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + precise_unit_amount: 25.00, + invoice_display_name: "Standard Pay in Advance Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:standard_prorated_fixed_charge) do + create( + :fixed_charge, + :pay_in_advance, + plan:, + add_on:, + prorated: true + ) + end + let(:standard_prorated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: standard_prorated_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 0.5, + unit_amount_cents: 10000, + precise_unit_amount: 100.00, + invoice_display_name: "Standard Pay in Advance Prorated Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:graduated_fixed_charge) do + create( + :fixed_charge, + :graduated, + :pay_in_advance, + plan:, + add_on: + ) + end + let(:graduated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: graduated_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 55500, + amount_currency: "USD", + units: 15, + unit_amount_cents: 3700, + precise_unit_amount: 37.00, + invoice_display_name: "Graduated Pay in Advance Fixed Charge Fee", + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "units" => 10.0, + "per_unit_amount" => "5.0", + "per_unit_total_amount" => "50.0", + "flat_unit_amount" => "200.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "units" => 5.0, + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "5.0", + "flat_unit_amount" => "300.0" + } + ] + }, + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:zero_fixed_charge) do + create( + :fixed_charge, + :pay_in_advance, + plan:, + add_on: + ) + end + let(:zero_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: zero_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 0, + amount_currency: "USD", + units: 1, + unit_amount_cents: 0, + precise_unit_amount: 0.00, + invoice_display_name: "Zero Pay in Advance Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:arrears_fixed_charge) do + create( + :fixed_charge, + plan:, + add_on: + ) + end + let(:arrears_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: arrears_fixed_charge, + subscription:, + amount_cents: 8500, + amount_currency: "USD", + units: 1, + unit_amount_cents: 8500, + precise_unit_amount: 85.00, + invoice_display_name: "Standard Pay in Arrears Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:arrears_graduated_fixed_charge) do + create( + :fixed_charge, + :graduated, + plan:, + add_on: + ) + end + let(:arrears_graduated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: arrears_graduated_fixed_charge, + subscription:, + amount_cents: 55500, + amount_currency: "USD", + units: 15, + unit_amount_cents: 3700, + precise_unit_amount: 37.00, + invoice_display_name: "Graduated Pay in Arrears Fixed Charge Fee", + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "units" => 10.0, + "per_unit_amount" => "5.0", + "per_unit_total_amount" => "50.0", + "flat_unit_amount" => "200.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "units" => 5.0, + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "5.0", + "flat_unit_amount" => "300.0" + } + ] + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:arrears_graduated_prorated_fixed_charge) do + create( + :fixed_charge, + :graduated, + plan:, + add_on:, + prorated: true + ) + end + let(:arrears_graduated_prorated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: arrears_graduated_prorated_fixed_charge, + subscription:, + amount_cents: 55500, + amount_currency: "USD", + units: 15, + unit_amount_cents: 3700, + precise_unit_amount: 37.00, + invoice_display_name: "Graduated Pay in Arrears Prorated Fixed Charge Fee", + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "units" => 10.0, + "per_unit_amount" => "5.0", + "per_unit_total_amount" => "50.0", + "flat_unit_amount" => "200.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "units" => 5.0, + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "5.0", + "flat_unit_amount" => "300.0" + } + ] + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:volume_fixed_charge) do + create( + :fixed_charge, + :volume, + plan:, + add_on: + ) + end + let(:volume_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: volume_fixed_charge, + subscription:, + amount_cents: 15100, + amount_currency: "USD", + units: 75, + unit_amount_cents: 201, + precise_unit_amount: 2.01, + invoice_display_name: "Volume Pay in Arrears Fixed Charge Fee", + amount_details: { + "per_unit_amount" => "2.0", + "per_unit_total_amount" => "150.0", + "flat_unit_amount" => "1.0" + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:volume_prorated_fixed_charge) do + create( + :fixed_charge, + :volume, + plan:, + add_on:, + prorated: true + ) + end + let(:volume_prorated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: volume_prorated_fixed_charge, + subscription:, + amount_cents: 9200, + amount_currency: "USD", + units: 30, + unit_amount_cents: 307, + precise_unit_amount: 3.07, + invoice_display_name: "Volume Pay in Arrears Prorated Fixed Charge Fee", + amount_details: { + "per_unit_amount" => "3.0", + "per_unit_total_amount" => "90.0", + "flat_unit_amount" => "2.0" + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:minimum_commitment_fee) do + create( + :minimum_commitment_fee, + invoice:, + subscription:, + amount_currency: "USD", + invoice_display_name: "Minimum Commitment Fee" + ) + end + + before do + invoice_subscription + subscription_fee + minimum_commitment_fee + standard_fixed_charge_fee + standard_prorated_fixed_charge_fee + graduated_fixed_charge_fee + zero_fixed_charge_fee + arrears_fixed_charge_fee + arrears_graduated_fixed_charge_fee + arrears_graduated_prorated_fixed_charge_fee + volume_fixed_charge_fee + volume_prorated_fixed_charge_fee + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when invoice has different boundaries for fixed charges" do + let(:organization) { create(:organization, :with_static_values) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "yearly", + pay_in_advance: false, + invoice_display_name: "Annual Plan", + bill_fixed_charges_monthly: true + ) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + status: "active" + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + number: "LAGO-202509-003", + payment_due_date: Date.parse("2026-01-01"), + issuing_date: Date.parse("2026-01-01"), + invoice_type: :subscription, + total_amount_cents: 5000, + currency: "EUR", + fees_amount_cents: 5000, + sub_total_excluding_taxes_amount_cents: 5000, + sub_total_including_taxes_amount_cents: 5000 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice: invoice, + subscription: subscription, + organization: organization, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-12-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-12-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-12-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-12-31 23:59:59"), + timestamp: Time.zone.parse("2025-12-31 23:59:59") + ) + end + + let(:subscription_fee) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 99900, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 99900, + precise_unit_amount: 999.00, + invoice_display_name: nil, + properties: { + from_datetime: "2025-08-01 00:00:00", + to_datetime: "2025-12-31 23:59:59" + } + ) + end + + let(:add_on) { create(:add_on, organization:) } + let(:monthly_fixed_charge) do + create( + :fixed_charge, + plan:, + add_on: + ) + end + let(:monthly_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + subscription:, + fixed_charge: monthly_fixed_charge, + amount_cents: 10000, + amount_currency: "EUR", + units: 1, + unit_amount_cents: 10000, + precise_unit_amount: 100.00, + invoice_display_name: "Monthly Fixed Charge", + properties: { + from_datetime: "2025-12-01 00:00:00", + to_datetime: "2025-12-31 23:59:59" + } + ) + end + + before do + invoice_subscription + subscription_fee + monthly_fixed_charge_fee + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when invoice_type is subscription and plan is paid in advance" do + let(:organization) { create(:organization, :with_static_values) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: true, + invoice_display_name: "Premium Plan" + ) + end + + let(:subscription) do + create(:subscription, customer:, plan:, status: "active") + end + + let(:invoice) do + create( + :invoice, + customer:, + number: "LAGO-202509-002", + payment_due_date: Date.parse("2025-09-19"), + issuing_date: Date.parse("2025-09-04"), + invoice_type: :subscription, + total_amount_cents: 5000, + currency: "USD", + fees_amount_cents: 5000, + sub_total_excluding_taxes_amount_cents: 5000, + sub_total_including_taxes_amount_cents: 5000 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + timestamp: Time.zone.parse("2025-08-31 23:59:59") + ) + end + + let(:subscription_fee) do + create( + :fee, + invoice:, + subscription:, + pay_in_advance: true, + fee_type: :subscription, + amount_cents: 1500, + amount_currency: "USD", + units: 1, + unit_amount_cents: 1500, + precise_unit_amount: 15.00, + invoice_display_name: "Pay in Advance Subscription Fee", + properties: { + from_datetime: "2025-09-01 00:00:00", + to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:add_on) { create(:add_on, organization: organization) } + + let(:previous_invoice) do + create(:invoice, customer:) + end + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice: previous_invoice, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-07-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-07-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-07-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-07-31 23:59:59"), + timestamp: Time.zone.parse("2025-07-31 23:59:59") + ) + end + let(:previous_subscription_fee) do + create( + :fee, + invoice: previous_invoice, + subscription:, + pay_in_advance: true, + fee_type: :subscription, + amount_cents: 1500, + amount_currency: "USD", + units: 1, + unit_amount_cents: 1500, + precise_unit_amount: 15.00, + properties: { + from_datetime: "2025-08-01 00:00:00", + to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:standard_fixed_charge) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + let(:standard_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: standard_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + precise_unit_amount: 25.00, + invoice_display_name: "Standard Pay in Advance Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:standard_prorated_fixed_charge) do + create( + :fixed_charge, + :pay_in_advance, + plan:, + add_on:, + prorated: true + ) + end + let(:standard_prorated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: standard_prorated_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 0.5, + unit_amount_cents: 10000, + precise_unit_amount: 100.00, + invoice_display_name: "Standard Pay in Advance Prorated Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:graduated_fixed_charge) do + create( + :fixed_charge, + :graduated, + :pay_in_advance, + plan:, + add_on: + ) + end + let(:graduated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: graduated_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 55500, + amount_currency: "USD", + units: 15, + unit_amount_cents: 3700, + precise_unit_amount: 37.00, + invoice_display_name: "Graduated Pay in Advance Fixed Charge Fee", + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "units" => 10.0, + "per_unit_amount" => "5.0", + "per_unit_total_amount" => "50.0", + "flat_unit_amount" => "200.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "units" => 5.0, + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "5.0", + "flat_unit_amount" => "300.0" + } + ] + }, + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:zero_fixed_charge) do + create( + :fixed_charge, + :pay_in_advance, + plan:, + add_on: + ) + end + let(:zero_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: zero_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 0, + amount_currency: "USD", + units: 1, + unit_amount_cents: 0, + precise_unit_amount: 0.00, + invoice_display_name: "Zero Pay in Advance Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:arrears_fixed_charge) do + create( + :fixed_charge, + plan:, + add_on: + ) + end + let(:arrears_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: arrears_fixed_charge, + subscription:, + amount_cents: 8500, + amount_currency: "USD", + units: 1, + unit_amount_cents: 8500, + precise_unit_amount: 85.00, + invoice_display_name: "Standard Pay in Arrears Fixed Charge Fee", + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:arrears_graduated_fixed_charge) do + create( + :fixed_charge, + :graduated, + plan:, + add_on: + ) + end + let(:arrears_graduated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: arrears_graduated_fixed_charge, + subscription:, + amount_cents: 55500, + amount_currency: "USD", + units: 15, + unit_amount_cents: 3700, + precise_unit_amount: 37.00, + invoice_display_name: "Graduated Pay in Arrears Fixed Charge Fee", + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "units" => 10.0, + "per_unit_amount" => "5.0", + "per_unit_total_amount" => "50.0", + "flat_unit_amount" => "200.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "units" => 5.0, + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "5.0", + "flat_unit_amount" => "300.0" + } + ] + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:arrears_graduated_prorated_fixed_charge) do + create( + :fixed_charge, + :graduated, + plan:, + add_on:, + prorated: true + ) + end + let(:arrears_graduated_prorated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: arrears_graduated_prorated_fixed_charge, + subscription:, + amount_cents: 55500, + amount_currency: "USD", + units: 15, + unit_amount_cents: 3700, + precise_unit_amount: 37.00, + invoice_display_name: "Graduated Pay in Arrears Prorated Fixed Charge Fee", + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "units" => 10.0, + "per_unit_amount" => "5.0", + "per_unit_total_amount" => "50.0", + "flat_unit_amount" => "200.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "units" => 5.0, + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "5.0", + "flat_unit_amount" => "300.0" + } + ] + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:volume_fixed_charge) do + create( + :fixed_charge, + :volume, + plan:, + add_on: + ) + end + let(:volume_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: volume_fixed_charge, + subscription:, + amount_cents: 15100, + amount_currency: "USD", + units: 75, + unit_amount_cents: 201, + precise_unit_amount: 2.01, + invoice_display_name: "Volume Pay in Arrears Fixed Charge Fee", + amount_details: { + "per_unit_amount" => "2.0", + "per_unit_total_amount" => "150.0", + "flat_unit_amount" => "1.0" + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:volume_prorated_fixed_charge) do + create( + :fixed_charge, + :volume, + plan:, + add_on:, + prorated: true + ) + end + let(:volume_prorated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: volume_prorated_fixed_charge, + subscription:, + amount_cents: 9200, + amount_currency: "USD", + units: 30, + unit_amount_cents: 307, + precise_unit_amount: 3.07, + invoice_display_name: "Volume Pay in Arrears Prorated Fixed Charge Fee", + amount_details: { + "per_unit_amount" => "3.0", + "per_unit_total_amount" => "90.0", + "flat_unit_amount" => "2.0" + }, + properties: { + fixed_charges_from_datetime: "2025-08-01 00:00:00", + fixed_charges_to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:minimum_commitment_fee) do + create( + :minimum_commitment_fee, + invoice:, + subscription:, + amount_currency: "USD", + invoice_display_name: "Minimum Commitment Fee" + ) + end + + before do + previous_invoice_subscription + previous_subscription_fee + + invoice_subscription + subscription_fee + minimum_commitment_fee + standard_fixed_charge_fee + standard_prorated_fixed_charge_fee + graduated_fixed_charge_fee + zero_fixed_charge_fee + arrears_fixed_charge_fee + arrears_graduated_fixed_charge_fee + arrears_graduated_prorated_fixed_charge_fee + volume_fixed_charge_fee + volume_prorated_fixed_charge_fee + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when invoice_type is progressive_billing with prepaid credits" do + let(:organization) { create(:organization, :with_static_values) } + let(:customer) { create(:customer, :with_static_values, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Progressive Billing Plan" + ) + end + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + invoice_display_name: "Usage Charge" + ) + end + + let(:usage_threshold) do + create(:usage_threshold, plan:, amount_cents: 10000) + end + + let(:subscription) do + create(:subscription, customer:, plan:, status: "active") + end + + let(:wallet) { create(:wallet, customer:, organization:, rate_amount: BigDecimal("1.0"), balance_currency: "USD") } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice:, credit_amount: BigDecimal("5.0"), amount: BigDecimal("5.0")) } + + let(:invoice) do + create( + :invoice, + customer:, + number: "LAGO-202509-008", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-15"), + invoice_type: :progressive_billing, + total_amount_cents: 9500, + currency: "USD", + fees_amount_cents: 10000, + sub_total_excluding_taxes_amount_cents: 10000, + sub_total_including_taxes_amount_cents: 10000, + prepaid_credit_amount_cents: 500, + prepaid_granted_credit_amount_cents: 200, + prepaid_purchased_credit_amount_cents: 300 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-09-15 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-09-15 23:59:59"), + timestamp: Time.zone.parse("2025-09-15 12:00:00") + ) + end + + let(:charge_fee) do + create( + :charge_fee, + invoice:, + subscription:, + charge:, + amount_cents: 10000, + amount_currency: "USD", + units: 100, + unit_amount_cents: 100, + precise_unit_amount: 1.00, + invoice_display_name: "Usage Charge Fee", + properties: { + "timestamp" => "2025-09-15 12:00:00", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-15 23:59:59" + } + ) + end + + let(:applied_usage_threshold) do + create( + :applied_usage_threshold, + invoice:, + usage_threshold:, + lifetime_usage_amount_cents: 10000 + ) + end + + before do + invoice_subscription + charge_fee + wallet_transaction + applied_usage_threshold + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when charge has filters and minimum commitment (true_up fee)" do + let(:organization) { create(:organization, :with_static_values) } + let(:customer) { create(:customer, :with_static_values, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Plan with Charge Filters" + ) + end + + let(:subscription) do + create(:subscription, customer:, plan:, status: "active") + end + + let(:charge) do + create( + :standard_charge, + plan:, + billable_metric:, + min_amount_cents: 10000, + invoice_display_name: "Usage Charge with Minimum" + ) + end + + let(:billable_metric_filter) do + create(:billable_metric_filter, billable_metric:, key: "region", values: ["us", "eu", "asia"]) + end + + let(:charge_filter_1) do + filter = create(:charge_filter, charge:, properties: {amount: "10"}) + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: ["us"]) + filter + end + + let(:charge_filter_2) do + filter = create(:charge_filter, charge:, properties: {amount: "20"}) + create(:charge_filter_value, charge_filter: filter, billable_metric_filter:, values: ["eu"]) + filter + end + + let(:invoice) do + create( + :invoice, + customer:, + number: "LAGO-202509-004", + payment_due_date: Date.parse("2025-10-01"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 10000, + currency: "USD", + fees_amount_cents: 10000, + sub_total_excluding_taxes_amount_cents: 10000, + sub_total_including_taxes_amount_cents: 10000 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + timestamp: Time.zone.parse("2025-08-31 23:59:59") + ) + end + + let(:base_charge_fee) do + create( + :charge_fee, + invoice:, + subscription:, + charge:, + charge_filter: nil, + amount_cents: 0, + amount_currency: "USD", + units: 0, + unit_amount_cents: 0, + precise_unit_amount: 0, + total_aggregated_units: 0, + invoice_display_name: nil, + properties: { + "timestamp" => "2025-08-31 23:59:59", + "charges_from_datetime" => "2025-08-01 00:00:00", + "charges_to_datetime" => "2025-08-31 23:59:59" + } + ) + end + + let(:filter_1_fee) do + create( + :charge_fee, + invoice:, + subscription:, + charge:, + charge_filter: charge_filter_1, + amount_cents: 3000, + amount_currency: "USD", + units: 3, + unit_amount_cents: 1000, + precise_unit_amount: 10.00, + total_aggregated_units: 3, + invoice_display_name: nil, + properties: { + "timestamp" => "2025-08-31 23:59:59", + "charges_from_datetime" => "2025-08-01 00:00:00", + "charges_to_datetime" => "2025-08-31 23:59:59" + } + ) + end + + let(:filter_2_fee) do + create( + :charge_fee, + invoice:, + subscription:, + charge:, + charge_filter: charge_filter_2, + amount_cents: 4000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2000, + precise_unit_amount: 20.00, + total_aggregated_units: 2, + invoice_display_name: nil, + properties: { + "timestamp" => "2025-08-31 23:59:59", + "charges_from_datetime" => "2025-08-01 00:00:00", + "charges_to_datetime" => "2025-08-31 23:59:59" + } + ) + end + + let(:true_up_fee) do + create( + :charge_fee, + invoice:, + subscription:, + charge:, + charge_filter: nil, + true_up_parent_fee: base_charge_fee, + amount_cents: 3000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 3000, + precise_unit_amount: 30.00, + total_aggregated_units: 1, + events_count: 0, + invoice_display_name: nil, + properties: { + "timestamp" => "2025-08-31 23:59:59", + "charges_from_datetime" => "2025-08-01 00:00:00", + "charges_to_datetime" => "2025-08-31 23:59:59" + } + ) + end + + before do + invoice_subscription + base_charge_fee + filter_1_fee + filter_2_fee + true_up_fee + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when invoice has multiple subscriptions" do + let(:organization) { create(:organization, :with_static_values) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + # Plans are named to test alphabetical ordering - Zebra comes after Alpha alphabetically + # but we create Zebra first to verify the ordering is by name, not insertion order + let(:plan_zebra) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Zebra Plan" + ) + end + + let(:plan_alpha) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Alpha Plan" + ) + end + + # Create Zebra subscription first to ensure ordering is alphabetical, not by insertion + let(:subscription_zebra) do + create(:subscription, customer:, plan: plan_zebra, status: "active") + end + + let(:subscription_alpha) do + create(:subscription, customer:, plan: plan_alpha, status: "active") + end + + let(:invoice) do + create( + :invoice, + customer:, + number: "LAGO-202509-005", + payment_due_date: Date.parse("2025-10-01"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 8000, + currency: "USD", + fees_amount_cents: 8000, + sub_total_excluding_taxes_amount_cents: 8000, + sub_total_including_taxes_amount_cents: 8000 + ) + end + + # Create Zebra invoice_subscription first (will be created before Alpha) + let(:invoice_subscription_zebra) do + create( + :invoice_subscription, + invoice:, + subscription: subscription_zebra, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + timestamp: Time.zone.parse("2025-08-31 23:59:59") + ) + end + + let(:invoice_subscription_alpha) do + create( + :invoice_subscription, + invoice:, + subscription: subscription_alpha, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + timestamp: Time.zone.parse("2025-08-31 23:59:59") + ) + end + + let(:subscription_fee_zebra) do + create( + :fee, + invoice:, + subscription: subscription_zebra, + fee_type: :subscription, + amount_cents: 5000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 5000, + precise_unit_amount: 50.00, + invoice_display_name: "Zebra Plan Subscription", + properties: { + from_datetime: "2025-08-01 00:00:00", + to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:subscription_fee_alpha) do + create( + :fee, + invoice:, + subscription: subscription_alpha, + fee_type: :subscription, + amount_cents: 3000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 3000, + precise_unit_amount: 30.00, + invoice_display_name: "Alpha Plan Subscription", + properties: { + from_datetime: "2025-08-01 00:00:00", + to_datetime: "2025-08-31 23:59:59" + } + ) + end + + before do + # Create Zebra first, then Alpha - but rendered output should show Alpha first (alphabetical) + invoice_subscription_zebra + invoice_subscription_alpha + subscription_fee_zebra + subscription_fee_alpha + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "when invoice has multiple subscriptions with prepaid credits" do + let(:organization) { create(:organization, :with_static_values) } + let(:customer) { create(:customer, :with_static_values, organization:) } + + let(:plan_alpha) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Alpha Plan" + ) + end + + let(:plan_beta) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Beta Plan" + ) + end + + let(:subscription_alpha) do + create(:subscription, customer:, plan: plan_alpha, status: "active") + end + + let(:subscription_beta) do + create(:subscription, customer:, plan: plan_beta, status: "active") + end + + let(:wallet) { create(:wallet, customer:, organization:, rate_amount: BigDecimal("1.0"), balance_currency: "USD") } + let(:wallet_transaction) { create(:wallet_transaction, wallet:, invoice:, credit_amount: BigDecimal("10.0"), amount: BigDecimal("10.0")) } + + let(:invoice) do + create( + :invoice, + customer:, + number: "LAGO-202509-006", + payment_due_date: Date.parse("2025-10-01"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 7000, + currency: "USD", + fees_amount_cents: 8000, + sub_total_excluding_taxes_amount_cents: 8000, + sub_total_including_taxes_amount_cents: 8000, + prepaid_credit_amount_cents: 1000, + prepaid_granted_credit_amount_cents: 400, + prepaid_purchased_credit_amount_cents: 600 + ) + end + + let(:invoice_subscription_alpha) do + create( + :invoice_subscription, + invoice:, + subscription: subscription_alpha, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + timestamp: Time.zone.parse("2025-08-31 23:59:59") + ) + end + + let(:invoice_subscription_beta) do + create( + :invoice_subscription, + invoice:, + subscription: subscription_beta, + from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-08-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-08-31 23:59:59"), + timestamp: Time.zone.parse("2025-08-31 23:59:59") + ) + end + + let(:subscription_fee_alpha) do + create( + :fee, + invoice:, + subscription: subscription_alpha, + fee_type: :subscription, + amount_cents: 5000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 5000, + precise_unit_amount: 50.00, + invoice_display_name: "Alpha Plan Subscription", + properties: { + from_datetime: "2025-08-01 00:00:00", + to_datetime: "2025-08-31 23:59:59" + } + ) + end + + let(:subscription_fee_beta) do + create( + :fee, + invoice:, + subscription: subscription_beta, + fee_type: :subscription, + amount_cents: 3000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 3000, + precise_unit_amount: 30.00, + invoice_display_name: "Beta Plan Subscription", + properties: { + from_datetime: "2025-08-01 00:00:00", + to_datetime: "2025-08-31 23:59:59" + } + ) + end + + before do + invoice_subscription_alpha + invoice_subscription_beta + subscription_fee_alpha + subscription_fee_beta + wallet_transaction + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end +end diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_a_single_standard_charge_fee/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_a_single_standard_charge_fee/renders_correctly.html.snap new file mode 100644 index 0000000..0434ffb --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_a_single_standard_charge_fee/renders_correctly.html.snap @@ -0,0 +1,157 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
API Calls10$5.00 +
0.0%
+
$50.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_charge_filter/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_charge_filter/renders_correctly.html.snap new file mode 100644 index 0000000..47ea69f --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_charge_filter/renders_correctly.html.snap @@ -0,0 +1,157 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
Filtered Charge • 4$5.00 +
0.0%
+
$20.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_coupon_applied/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_coupon_applied/renders_correctly.html.snap new file mode 100644 index 0000000..f1ac206 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_coupon_applied/renders_correctly.html.snap @@ -0,0 +1,162 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-002 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $40.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
Charge with Coupon10$5.00 +
0.0%
+
$50.00
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
20% Discount (€2.00)-$10.00
Subtotal (excl. tax)$40.00
Tax (0%)$0.00
Subtotal (incl. tax)$40.00
Total$40.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_multiple_charge_fees/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_multiple_charge_fees/renders_correctly.html.snap new file mode 100644 index 0000000..1948985 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_multiple_charge_fees/renders_correctly.html.snap @@ -0,0 +1,166 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
API Calls10$5.00 +
0.0%
+
$50.00
Storage GB5$6.00 +
0.0%
+
$30.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_non-invoiceable_charge/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_non-invoiceable_charge/renders_correctly.html.snap new file mode 100644 index 0000000..cb1aaae --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_non-invoiceable_charge/renders_correctly.html.snap @@ -0,0 +1,160 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
One-time Setup
+
Sep 05, 2025
+
1$10.00 +
0.0%
+
$10.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_percentage_charge_with_basic_rate/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_percentage_charge_with_basic_rate/renders_correctly.html.snap new file mode 100644 index 0000000..5e093b6 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_percentage_charge_with_basic_rate/renders_correctly.html.snap @@ -0,0 +1,173 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
Transaction Fee +
Total events:
+
Rate on the amount1005.55% +
0.0%
+
$55.50
Subtotal$55.50
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_percentage_charge_with_detailed_breakdown/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_percentage_charge_with_detailed_breakdown/renders_correctly.html.snap new file mode 100644 index 0000000..7ff0b25 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_percentage_charge_with_detailed_breakdown/renders_correctly.html.snap @@ -0,0 +1,182 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
Payment Processing Fee +
Total events: 50
+
Rate on the amount1005.55% +
0.0%
+
$55.50
Fixed fee per transaction$20.00 +
0.0%
+
$20.00
Subtotal$75.50
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_prepaid_credits/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_prepaid_credits/renders_correctly.html.snap new file mode 100644 index 0000000..602202d --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_prepaid_credits/renders_correctly.html.snap @@ -0,0 +1,167 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-004 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $40.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
API Calls10$5.00 +
0.0%
+
$50.00
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Free credits-$4.00
Prepaid credits-$6.00
Total$40.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_prorated_charge/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_prorated_charge/renders_correctly.html.snap new file mode 100644 index 0000000..ea60130 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_prorated_charge/renders_correctly.html.snap @@ -0,0 +1,160 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
Prorated Seats
+
Used 16 out of 30 days
+
5$5.00 +
0.0%
+
$25.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_taxes_applied/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_taxes_applied/renders_correctly.html.snap new file mode 100644 index 0000000..b1b8e08 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_charge.slim/with_taxes_applied/renders_correctly.html.snap @@ -0,0 +1,157 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-CH-003 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $60.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
Taxable Charge10$5.00 +
0.0%
+
$50.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
VAT (20.0% on $50.00)$10.00
Subtotal (incl. tax)$60.00
Total$60.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_a_single_fixed_charge_fee/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_a_single_fixed_charge_fee/renders_correctly.html.snap new file mode 100644 index 0000000..82edbaf --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_a_single_fixed_charge_fee/renders_correctly.html.snap @@ -0,0 +1,161 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
+
Standard Fixed Charge
+
2$25.00 +
0.0%
+
$50.00
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_coupon_applied/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_coupon_applied/renders_correctly.html.snap new file mode 100644 index 0000000..d07a57a --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_coupon_applied/renders_correctly.html.snap @@ -0,0 +1,166 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-002 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $45.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
+
Fixed Charge with Coupon
+
2$25.00 +
0.0%
+
$50.00
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
10% Off Coupon (€2.00)-$5.00
Subtotal (excl. tax)$45.00
Tax (0%)$0.00
Subtotal (incl. tax)$45.00
Total$45.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_graduated_charge_model/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_graduated_charge_model/renders_correctly.html.snap new file mode 100644 index 0000000..95e27c6 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_graduated_charge_model/renders_correctly.html.snap @@ -0,0 +1,200 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Graduated Fixed Charge
Fee per unit for the first 1010.0$5.00 +
0.0%
+
$50.00
Fee per unit for 11 and above5.0$1.00 +
0.0%
+
$5.00
Flat fee for first 101$200.00 +
0.0%
+
$200.00
Flat fee for 11 and above1$300.00 +
0.0%
+
$300.00
Subtotal$555.00
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_multiple_fixed_charge_fees_in_different_billing_periods/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_multiple_fixed_charge_fees_in_different_billing_periods/renders_correctly.html.snap new file mode 100644 index 0000000..e560a1b --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_multiple_fixed_charge_fees_in_different_billing_periods/renders_correctly.html.snap @@ -0,0 +1,183 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
+
September Fixed Charge
+
2$25.00 +
0.0%
+
$50.00
+
+
+ + + + + + + + + + + + + + + +
Fees from Oct 01, 2025 to Oct 31, 2025UnitsUnit priceTax rateAmount
+
October Fixed Charge
+
1$30.00 +
0.0%
+
$30.00
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_multiple_fixed_charge_fees_in_the_same_billing_period/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_multiple_fixed_charge_fees_in_the_same_billing_period/renders_correctly.html.snap new file mode 100644 index 0000000..f0a35ca --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_multiple_fixed_charge_fees_in_the_same_billing_period/renders_correctly.html.snap @@ -0,0 +1,172 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
+
Fixed Charge A
+
2$25.00 +
0.0%
+
$50.00
+
Fixed Charge B
+
1$30.00 +
0.0%
+
$30.00
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_prepaid_credits/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_prepaid_credits/renders_correctly.html.snap new file mode 100644 index 0000000..bf87f02 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_prepaid_credits/renders_correctly.html.snap @@ -0,0 +1,171 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-003 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $40.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
+
Fixed Charge
+
2$25.00 +
0.0%
+
$50.00
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$50.00
Tax (0%)$0.00
Subtotal (incl. tax)$50.00
Free credits-$4.00
Prepaid credits-$6.00
Total$40.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_prorated_fixed_charge/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_prorated_fixed_charge/renders_correctly.html.snap new file mode 100644 index 0000000..82e6b21 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_prorated_fixed_charge/renders_correctly.html.snap @@ -0,0 +1,162 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
+
Prorated Fixed Charge
+
The fee is prorated on days of usage, the displayed unit price is an average
+
0.5$50.00 +
0.0%
+
$25.00
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_zero_amount_fee/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_zero_amount_fee/renders_correctly.html.snap new file mode 100644 index 0000000..501b3a2 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_fixed_charge.slim/with_zero_amount_fee/renders_correctly.html.snap @@ -0,0 +1,161 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-FC-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Monthly Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
+
Zero Amount Fixed Charge
+
0$0.00 +
0.0%
+
$0.00
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_a_single_add-on_fee/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_a_single_add-on_fee/renders_correctly.html.snap new file mode 100644 index 0000000..ef21185 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_a_single_add-on_fee/renders_correctly.html.snap @@ -0,0 +1,159 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-OO-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
Setup Fee
+
One-time setup fee
+
1$100.00 +
0.0%
+
$100.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_add-on_fee_with_date_range/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_add-on_fee_with_date_range/renders_correctly.html.snap new file mode 100644 index 0000000..db33177 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_add-on_fee_with_date_range/renders_correctly.html.snap @@ -0,0 +1,160 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-OO-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
Premium Support
+
Premium support package
+
From Sep 01, 2025 to Sep 30, 2025
+
1$100.00 +
0.0%
+
$100.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_multiple_add-on_fees/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_multiple_add-on_fees/renders_correctly.html.snap new file mode 100644 index 0000000..003e0a5 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_multiple_add-on_fees/renders_correctly.html.snap @@ -0,0 +1,171 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-OO-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+ + + + + + + + + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
Setup Fee
+
Initial setup
+
1$50.00 +
0.0%
+
$50.00
+
Training Session
+
2 hours of training
+
2$25.00 +
0.0%
+
$50.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_taxes_applied/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_taxes_applied/renders_correctly.html.snap new file mode 100644 index 0000000..66f4a2e --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_one_off.slim/with_taxes_applied/renders_correctly.html.snap @@ -0,0 +1,159 @@ + + + +
+
+

+ Invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-OO-003 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+ Bill to +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+

+ $110.00 +

+
+ Due Sep 15, 2025 +
+
+
+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
Professional Services
+
+
1$100.00 +
0.0%
+
$100.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Sales Tax (10.0% on $100.00)$10.00
Subtotal (incl. tax)$110.00
Total$110.00
+
+

+

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_credit_invoice/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_credit_invoice/renders_correctly.html.snap new file mode 100644 index 0000000..d0ba81f --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_credit_invoice/renders_correctly.html.snap @@ -0,0 +1,138 @@ + + + +
+
+

+ Self-billing invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-SB-003 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+ Bill to +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+

+ $500.00 +

+
+ Due Sep 15, 2025 +
+
+
+ + + + + + + + + + + + + +
ItemUnitsUnit priceAmount
Prepaid Credits500.01.0$500.00
+ + + + + + +
Total$500.00
+
+

+ This self-billing invoice was issued by the client on behalf of the partner, with their consent. The partner has agreed not to issue their own invoice for this transaction. +

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_one-off_invoice/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_one-off_invoice/renders_correctly.html.snap new file mode 100644 index 0000000..b1455f1 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_one-off_invoice/renders_correctly.html.snap @@ -0,0 +1,160 @@ + + + +
+
+

+ Self-billing invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-SB-001 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+ Bill to +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+

+ $500.00 +

+
+ Due Sep 15, 2025 +
+
+
+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
Partner Commission
+
Monthly partner commission
+
1$500.00 +
0.0%
+
$500.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$500.00
Tax (0%)$0.00
Subtotal (incl. tax)$500.00
Total$500.00
+
+

+ This self-billing invoice was issued by the client on behalf of the partner, with their consent. The partner has agreed not to issue their own invoice for this transaction. +

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_subscription_invoice/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_subscription_invoice/renders_correctly.html.snap new file mode 100644 index 0000000..49fc19e --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_subscription_invoice/renders_correctly.html.snap @@ -0,0 +1,162 @@ + + + +
+
+

+ Self-billing invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-SB-002 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+ Bill to +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+

+ $100.00 +

+
+ Due Sep 15, 2025 +
+
+
+

Partner Plan details

+
+ + + + + + + + + + + + + + + +
Fees from Sep 01, 2025 to Sep 30, 2025UnitsUnit priceTax rateAmount
Partner Plan - Monthly1.0$0.00 +
0.0%
+
$100.00
+
+
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$100.00
Tax (0%)$0.00
Subtotal (incl. tax)$100.00
Total$100.00
+
+
+

+ This self-billing invoice was issued by the client on behalf of the partner, with their consent. The partner has agreed not to issue their own invoice for this transaction. +

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_taxes_applied/renders_correctly.html.snap b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_taxes_applied/renders_correctly.html.snap new file mode 100644 index 0000000..5453967 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/__snapshots__/templates_invoices_v4_self_billed.slim/with_taxes_applied/renders_correctly.html.snap @@ -0,0 +1,160 @@ + + + +
+
+

+ Self-billing invoice +

+
+
+
+ + + + + + + + + + + + + +
+ Invoice Number + + LAGO-202509-SB-004 +
+ Issue Date + + Sep 01, 2025 +
+ Payment term + + 0 days +
+
+
+ +
+
+
+
+
+
+ From +
+
+ Doe Corp - John Doe +
+
+ 1234567890 +
+
+ 456 Customer Ave +
+ Apt 202 +
+ New York + , NY + 10001 +
+ United States +
+
+ john.doe@example.com +
+
+
+
+ Bill to +
+
ACME Corporation +
+
+ 123 Business St +
+
+ Suite 100 +
+
+ 94105 + ,   + San Francisco +
+
+ CA +
+
+ United States +
+
+ billing@acme.com +
+
+
+
+

+ $600.00 +

+
+ Due Sep 15, 2025 +
+
+
+ + + + + + + + + + + + + + + +
ItemUnitsUnit priceTax rateAmount
+
Commission
+
+
1$500.00 +
0.0%
+
$500.00
+ + + + + + + + + + + + + + + + + + + + + +
Subtotal (excl. tax)$500.00
VAT (20.0% on $500.00)$100.00
Subtotal (incl. tax)$600.00
Total$600.00
+
+

+ This self-billing invoice was issued by the client on behalf of the partner, with their consent. The partner has agreed not to issue their own invoice for this transaction. +

+
+ Powered by   + Lago Logo +
+
+ + \ No newline at end of file diff --git a/spec/views/app/views/templates/invoices/v4/charge.slim_spec.rb b/spec/views/app/views/templates/invoices/v4/charge.slim_spec.rb new file mode 100644 index 0000000..c85b976 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/charge.slim_spec.rb @@ -0,0 +1,579 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "templates/invoices/v4/charge.slim" do + subject(:rendered_template) do + Slim::Template.new(template, 1, pretty: true).render(invoice) + end + + let(:template) { Rails.root.join("app/views/templates/invoices/v4/charge.slim") } + + let(:organization) { create(:organization, :with_static_values) } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, :with_static_values, organization:) } + let(:billable_metric) { create(:billable_metric, organization:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Monthly Plan" + ) + end + + let(:subscription) do + create( + :subscription, + customer:, + plan:, + status: "active", + started_at: Time.zone.parse("2025-09-01 00:00:00"), + subscription_at: Time.zone.parse("2025-09-01 00:00:00"), + billing_time: :calendar + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-CH-001", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 10000, + currency: "USD", + fees_amount_cents: 10000, + sub_total_excluding_taxes_amount_cents: 10000, + sub_total_including_taxes_amount_cents: 10000, + coupons_amount_cents: 0 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + timestamp: Time.zone.parse("2025-09-01 00:00:00") + ) + end + + before do + I18n.locale = :en + invoice_subscription + end + + context "with a single standard charge fee" do + let(:charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 10, + unit_amount_cents: 500, + precise_unit_amount: 5.00, + invoice_display_name: "API Calls", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before { charge_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with multiple charge fees" do + let(:charge_1) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:charge_2) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:charge_fee_1) do + create( + :charge_fee, + invoice:, + charge: charge_1, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 10, + unit_amount_cents: 500, + precise_unit_amount: 5.00, + invoice_display_name: "API Calls", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + let(:charge_fee_2) do + create( + :charge_fee, + invoice:, + charge: charge_2, + subscription:, + pay_in_advance: true, + amount_cents: 3000, + amount_currency: "USD", + units: 5, + unit_amount_cents: 600, + precise_unit_amount: 6.00, + invoice_display_name: "Storage GB", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before do + charge_fee_1 + charge_fee_2 + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with percentage charge with basic rate" do + let(:percentage_charge) do + create(:percentage_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:percentage_fee) do + create( + :charge_fee, + invoice:, + charge: percentage_charge, + subscription:, + pay_in_advance: true, + amount_cents: 5550, + amount_currency: "USD", + units: 100, + unit_amount_cents: 55, + precise_unit_amount: 0.555, + invoice_display_name: "Transaction Fee", + amount_details: { + "paid_units" => "100", + "rate" => "5.55", + "per_unit_total_amount" => "55.50" + }, + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before { percentage_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with percentage charge with detailed breakdown" do + let(:percentage_charge) do + create(:percentage_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:percentage_fee) do + create( + :charge_fee, + invoice:, + charge: percentage_charge, + subscription:, + pay_in_advance: true, + amount_cents: 7550, + amount_currency: "USD", + units: 100, + events_count: 50, + invoice_display_name: "Payment Processing Fee", + amount_details: { + "paid_units" => "100", + "rate" => "5.55", + "per_unit_total_amount" => "55.50", + "fixed_fee_unit_amount" => "0.20", + "fixed_fee_total_amount" => "20.00", + "min_max_adjustment_total_amount" => "0.00", + "per_transaction_min_amount" => "0.00", + "per_transaction_max_amount" => "0.00" + }, + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before { percentage_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with prorated charge" do + let(:recurring_billable_metric) { create(:unique_count_billable_metric, :recurring, organization:) } + + let(:prorated_charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric: recurring_billable_metric, prorated: true) + end + + let(:pay_in_advance_event) do + create( + :event, + organization:, + subscription:, + customer:, + code: recurring_billable_metric.code, + timestamp: Time.zone.parse("2025-09-15 10:00:00") + ) + end + + let(:prorated_fee) do + create( + :charge_fee, + invoice:, + charge: prorated_charge, + subscription:, + pay_in_advance: true, + pay_in_advance_event_id: pay_in_advance_event.id, + amount_cents: 2500, + amount_currency: "USD", + units: 5, + unit_amount_cents: 500, + precise_unit_amount: 5.00, + invoice_display_name: "Prorated Seats", + properties: { + "from_datetime" => "2025-09-15 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-15 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before { prorated_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with non-invoiceable charge" do + let(:non_invoiceable_charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:, invoiceable: false) + end + + let(:non_invoiceable_fee) do + create( + :charge_fee, + invoice:, + charge: non_invoiceable_charge, + subscription:, + pay_in_advance: true, + succeeded_at: Time.zone.parse("2025-09-05 10:30:00"), + amount_cents: 1000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 1000, + precise_unit_amount: 10.00, + invoice_display_name: "One-time Setup", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before { non_invoiceable_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with charge filter" do + let(:charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:charge_filter) do + create(:charge_filter, charge:) + end + + let(:filtered_fee) do + create( + :charge_fee, + invoice:, + charge:, + charge_filter:, + subscription:, + pay_in_advance: true, + amount_cents: 2000, + amount_currency: "USD", + units: 4, + unit_amount_cents: 500, + precise_unit_amount: 5.00, + invoice_display_name: "Filtered Charge", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before { filtered_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with coupon applied" do + let(:charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 10, + unit_amount_cents: 500, + precise_unit_amount: 5.00, + invoice_display_name: "Charge with Coupon", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + let(:coupon) { create(:coupon, organization:, name: "20% Discount") } + let(:applied_coupon) { create(:applied_coupon, coupon:, customer:) } + let(:credit) do + create( + :credit, + invoice:, + applied_coupon:, + amount_cents: 1000, + amount_currency: "USD" + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-CH-002", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 4000, + currency: "USD", + fees_amount_cents: 5000, + coupons_amount_cents: 1000, + sub_total_excluding_taxes_amount_cents: 4000, + sub_total_including_taxes_amount_cents: 4000 + ) + end + + before do + charge_fee + credit + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with taxes applied" do + let(:charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:tax) { create(:tax, organization:, name: "VAT", rate: 20.0) } + + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 10, + unit_amount_cents: 500, + precise_unit_amount: 5.00, + taxes_amount_cents: 1000, + invoice_display_name: "Taxable Charge", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-CH-003", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 6000, + currency: "USD", + fees_amount_cents: 5000, + taxes_amount_cents: 1000, + coupons_amount_cents: 0, + sub_total_excluding_taxes_amount_cents: 5000, + sub_total_including_taxes_amount_cents: 6000 + ) + end + + let(:applied_tax) do + create( + :invoice_applied_tax, + invoice:, + tax:, + tax_name: "VAT", + tax_code: "vat", + tax_rate: 20.0, + amount_cents: 1000, + amount_currency: "USD", + taxable_base_amount_cents: 5000, + fees_amount_cents: 5000 + ) + end + + before do + charge_fee + applied_tax + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with prepaid credits" do + let(:charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric:) + end + + let(:charge_fee) do + create( + :charge_fee, + invoice:, + charge:, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 10, + unit_amount_cents: 500, + precise_unit_amount: 5.00, + invoice_display_name: "API Calls", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, invoice:, amount: 10, credit_amount: 10) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-CH-004", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 4000, + currency: "USD", + fees_amount_cents: 5000, + coupons_amount_cents: 0, + sub_total_excluding_taxes_amount_cents: 5000, + sub_total_including_taxes_amount_cents: 5000, + prepaid_credit_amount_cents: 1000, + prepaid_granted_credit_amount_cents: 400, + prepaid_purchased_credit_amount_cents: 600 + ) + end + + before do + charge_fee + wallet_transaction + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end +end diff --git a/spec/views/app/views/templates/invoices/v4/fixed_charge.slim_spec.rb b/spec/views/app/views/templates/invoices/v4/fixed_charge.slim_spec.rb new file mode 100644 index 0000000..cd7a754 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/fixed_charge.slim_spec.rb @@ -0,0 +1,460 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe "templates/invoices/v4/fixed_charge.slim" do + subject(:rendered_template) do + Slim::Template.new(template, 1, pretty: true).render(invoice) + end + + let(:template) { Rails.root.join("app/views/templates/invoices/v4/fixed_charge.slim") } + + let(:organization) { create(:organization, :with_static_values) } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, :with_static_values, organization:) } + let(:add_on) { create(:add_on, organization:) } + + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Monthly Plan" + ) + end + + let(:subscription) do + create(:subscription, customer:, plan:, status: "active") + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-FC-001", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 10000, + currency: "USD", + fees_amount_cents: 10000, + sub_total_excluding_taxes_amount_cents: 10000, + sub_total_including_taxes_amount_cents: 10000, + coupons_amount_cents: 0 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + fixed_charges_from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + fixed_charges_to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + timestamp: Time.zone.parse("2025-09-01 00:00:00") + ) + end + + before do + I18n.locale = :en + invoice_subscription + end + + context "with a single fixed charge fee" do + let(:fixed_charge) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge:, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + precise_unit_amount: 25.00, + invoice_display_name: "Standard Fixed Charge", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + before { fixed_charge_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with multiple fixed charge fees in different billing periods" do + let(:fixed_charge_1) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:fixed_charge_2) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:september_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: fixed_charge_1, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + precise_unit_amount: 25.00, + invoice_display_name: "September Fixed Charge", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:october_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: fixed_charge_2, + subscription:, + pay_in_advance: true, + amount_cents: 3000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 3000, + precise_unit_amount: 30.00, + invoice_display_name: "October Fixed Charge", + properties: { + fixed_charges_from_datetime: "2025-10-01 00:00:00", + fixed_charges_to_datetime: "2025-10-31 23:59:59" + } + ) + end + + before do + september_fee + october_fee + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with multiple fixed charge fees in the same billing period" do + let(:fixed_charge_1) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:fixed_charge_2) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:fixed_charge_fee_1) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: fixed_charge_1, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + precise_unit_amount: 25.00, + invoice_display_name: "Fixed Charge A", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:fixed_charge_fee_2) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: fixed_charge_2, + subscription:, + pay_in_advance: true, + amount_cents: 3000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 3000, + precise_unit_amount: 30.00, + invoice_display_name: "Fixed Charge B", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + before do + fixed_charge_fee_1 + fixed_charge_fee_2 + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with graduated charge model" do + let(:graduated_fixed_charge) do + create(:fixed_charge, :graduated, :pay_in_advance, plan:, add_on:) + end + + let(:graduated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: graduated_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 55500, + amount_currency: "USD", + units: 15, + unit_amount_cents: 3700, + precise_unit_amount: 37.00, + invoice_display_name: "Graduated Fixed Charge", + amount_details: { + "graduated_ranges" => [ + { + "from_value" => 0, + "to_value" => 10, + "units" => 10.0, + "per_unit_amount" => "5.0", + "per_unit_total_amount" => "50.0", + "flat_unit_amount" => "200.0" + }, + { + "from_value" => 11, + "to_value" => nil, + "units" => 5.0, + "per_unit_amount" => "1.0", + "per_unit_total_amount" => "5.0", + "flat_unit_amount" => "300.0" + } + ] + }, + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + before { graduated_fixed_charge_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with prorated fixed charge" do + let(:prorated_fixed_charge) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:, prorated: true) + end + + let(:prorated_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: prorated_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 2500, + amount_currency: "USD", + units: 0.5, + unit_amount_cents: 5000, + precise_unit_amount: 50.00, + invoice_display_name: "Prorated Fixed Charge", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + before { prorated_fixed_charge_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with zero amount fee" do + let(:zero_fixed_charge) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:zero_fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge: zero_fixed_charge, + subscription:, + pay_in_advance: true, + amount_cents: 0, + amount_currency: "USD", + units: 0, + unit_amount_cents: 0, + precise_unit_amount: 0.00, + invoice_display_name: "Zero Amount Fixed Charge", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + before { zero_fixed_charge_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with coupon applied" do + let(:fixed_charge) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge:, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + precise_unit_amount: 25.00, + invoice_display_name: "Fixed Charge with Coupon", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:coupon) { create(:coupon, organization:, name: "10% Off Coupon") } + let(:applied_coupon) { create(:applied_coupon, coupon:, customer:) } + let(:credit) do + create( + :credit, + invoice:, + applied_coupon:, + amount_cents: 500, + amount_currency: "USD" + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-FC-002", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 4500, + currency: "USD", + fees_amount_cents: 5000, + coupons_amount_cents: 500, + sub_total_excluding_taxes_amount_cents: 4500, + sub_total_including_taxes_amount_cents: 4500 + ) + end + + before do + fixed_charge_fee + credit + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with prepaid credits" do + let(:fixed_charge) do + create(:fixed_charge, :pay_in_advance, plan:, add_on:) + end + + let(:fixed_charge_fee) do + create( + :fixed_charge_fee, + invoice:, + fixed_charge:, + subscription:, + pay_in_advance: true, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + precise_unit_amount: 25.00, + invoice_display_name: "Fixed Charge", + properties: { + fixed_charges_from_datetime: "2025-09-01 00:00:00", + fixed_charges_to_datetime: "2025-09-30 23:59:59" + } + ) + end + + let(:wallet) { create(:wallet, customer:) } + let(:wallet_transaction) do + create(:wallet_transaction, wallet:, invoice:, amount: 10, credit_amount: 10) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-FC-003", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 4000, + currency: "USD", + fees_amount_cents: 5000, + coupons_amount_cents: 0, + sub_total_excluding_taxes_amount_cents: 5000, + sub_total_including_taxes_amount_cents: 5000, + prepaid_credit_amount_cents: 1000, + prepaid_granted_credit_amount_cents: 400, + prepaid_purchased_credit_amount_cents: 600 + ) + end + + before do + fixed_charge_fee + wallet_transaction + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end +end diff --git a/spec/views/app/views/templates/invoices/v4/one_off.slim_spec.rb b/spec/views/app/views/templates/invoices/v4/one_off.slim_spec.rb new file mode 100644 index 0000000..fb8339c --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/one_off.slim_spec.rb @@ -0,0 +1,198 @@ +# frozen_string_literal: true + +require "rails_helper" + +# This spec relies on `rspec-snapshot` gem (https://github.com/levinmr/rspec-snapshot) in order to serialize and compare +# the rendered invoice HTML. +# +# To update a snapshot, either delete it, or run the tests with `UPDATE_SNAPSHOTS=true` environment variable. + +RSpec.describe "templates/invoices/v4/one_off.slim" do + subject(:rendered_template) do + Slim::Template.new(template, 1, pretty: true).render(invoice) + end + + let(:template) { Rails.root.join("app/views/templates/invoices/v4/one_off.slim") } + + let(:organization) { create(:organization, :with_static_values) } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, :with_static_values, organization:) } + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-OO-001", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :one_off, + total_amount_cents: 10000, + currency: "USD", + fees_amount_cents: 10000, + sub_total_excluding_taxes_amount_cents: 10000, + sub_total_including_taxes_amount_cents: 10000, + coupons_amount_cents: 0 + ) + end + + before do + I18n.locale = :en + end + + context "with a single add-on fee" do + let(:add_on) { create(:add_on, organization:, name: "Setup Fee", description: "One-time setup fee") } + + let(:add_on_fee) do + create( + :one_off_fee, + invoice:, + add_on:, + amount_cents: 10000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 10000, + invoice_display_name: "Setup Fee", + description: "One-time setup fee" + ) + end + + before { add_on_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with multiple add-on fees" do + let(:add_on_1) { create(:add_on, organization:, name: "Setup Fee") } + let(:add_on_2) { create(:add_on, organization:, name: "Training Fee") } + + let(:add_on_fee_1) do + create( + :one_off_fee, + invoice:, + add_on: add_on_1, + amount_cents: 5000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 5000, + invoice_display_name: "Setup Fee", + description: "Initial setup" + ) + end + + let(:add_on_fee_2) do + create( + :one_off_fee, + invoice:, + add_on: add_on_2, + amount_cents: 5000, + amount_currency: "USD", + units: 2, + unit_amount_cents: 2500, + invoice_display_name: "Training Session", + description: "2 hours of training" + ) + end + + before do + add_on_fee_1 + add_on_fee_2 + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with add-on fee with date range" do + let(:add_on) { create(:add_on, organization:, name: "Monthly Support") } + + let(:add_on_fee) do + create( + :one_off_fee, + invoice:, + add_on:, + amount_cents: 10000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 10000, + invoice_display_name: "Premium Support", + description: "Premium support package", + properties: { + "from_datetime" => "2025-09-01", + "to_datetime" => "2025-09-30" + } + ) + end + + before { add_on_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with taxes applied" do + let(:add_on) { create(:add_on, organization:, name: "Professional Services") } + let(:tax) { create(:tax, organization:, name: "Sales Tax", rate: 10.0) } + + let(:add_on_fee) do + create( + :one_off_fee, + invoice:, + add_on:, + amount_cents: 10000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 10000, + taxes_amount_cents: 1000, + invoice_display_name: "Professional Services" + ) + end + + let(:invoice) do + create( + :invoice, + customer:, + organization:, + number: "LAGO-202509-OO-003", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :one_off, + total_amount_cents: 11000, + currency: "USD", + fees_amount_cents: 10000, + taxes_amount_cents: 1000, + coupons_amount_cents: 0, + sub_total_excluding_taxes_amount_cents: 10000, + sub_total_including_taxes_amount_cents: 11000 + ) + end + + let(:applied_tax) do + create( + :invoice_applied_tax, + invoice:, + tax:, + tax_name: "Sales Tax", + tax_code: "sales_tax", + tax_rate: 10.0, + amount_cents: 1000, + amount_currency: "USD", + taxable_base_amount_cents: 10000, + fees_amount_cents: 10000 + ) + end + + before do + add_on_fee + applied_tax + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end +end diff --git a/spec/views/app/views/templates/invoices/v4/self_billed.slim_spec.rb b/spec/views/app/views/templates/invoices/v4/self_billed.slim_spec.rb new file mode 100644 index 0000000..b196b25 --- /dev/null +++ b/spec/views/app/views/templates/invoices/v4/self_billed.slim_spec.rb @@ -0,0 +1,275 @@ +# frozen_string_literal: true + +require "rails_helper" + +# This spec relies on `rspec-snapshot` gem (https://github.com/levinmr/rspec-snapshot) in order to serialize and compare +# the rendered invoice HTML. +# +# To update a snapshot, either delete it, or run the tests with `UPDATE_SNAPSHOTS=true` environment variable. + +RSpec.describe "templates/invoices/v4/self_billed.slim" do + subject(:rendered_template) do + Slim::Template.new(template, 1, pretty: true).render(invoice) + end + + let(:template) { Rails.root.join("app/views/templates/invoices/v4/self_billed.slim") } + + let(:organization) { create(:organization, :with_static_values) } + let(:billing_entity) { organization.default_billing_entity } + let(:customer) { create(:customer, :with_static_values, organization:) } + + before do + I18n.locale = :en + end + + context "with one-off invoice" do + let(:add_on) { create(:add_on, organization:, name: "Partner Commission") } + + let(:invoice) do + create( + :invoice, + :self_billed, + customer:, + organization:, + number: "LAGO-202509-SB-001", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :one_off, + total_amount_cents: 50000, + currency: "USD", + fees_amount_cents: 50000, + sub_total_excluding_taxes_amount_cents: 50000, + sub_total_including_taxes_amount_cents: 50000, + coupons_amount_cents: 0 + ) + end + + let(:add_on_fee) do + create( + :one_off_fee, + invoice:, + add_on:, + amount_cents: 50000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 50000, + invoice_display_name: "Partner Commission", + description: "Monthly partner commission" + ) + end + + before { add_on_fee } + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with subscription invoice" do + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Partner Plan" + ) + end + + let(:subscription) do + create(:subscription, customer:, plan:, status: "active") + end + + let(:invoice) do + create( + :invoice, + :self_billed, + customer:, + organization:, + number: "LAGO-202509-SB-002", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :subscription, + total_amount_cents: 10000, + currency: "USD", + fees_amount_cents: 10000, + sub_total_excluding_taxes_amount_cents: 10000, + sub_total_including_taxes_amount_cents: 10000, + coupons_amount_cents: 0 + ) + end + + let(:invoice_subscription) do + create( + :invoice_subscription, + invoice:, + subscription:, + from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + charges_from_datetime: Time.zone.parse("2025-09-01 00:00:00"), + charges_to_datetime: Time.zone.parse("2025-09-30 23:59:59"), + timestamp: Time.zone.parse("2025-09-01 00:00:00") + ) + end + + let(:subscription_fee) do + create( + :fee, + invoice:, + subscription:, + fee_type: :subscription, + amount_cents: 10000, + amount_currency: "USD", + units: 1, + invoice_display_name: "Partner Plan - Monthly", + properties: { + "from_datetime" => "2025-09-01 00:00:00", + "to_datetime" => "2025-09-30 23:59:59", + "charges_from_datetime" => "2025-09-01 00:00:00", + "charges_to_datetime" => "2025-09-30 23:59:59" + } + ) + end + + before do + invoice_subscription + subscription_fee + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with credit invoice" do + let(:plan) do + create( + :plan, + organization:, + interval: "monthly", + pay_in_advance: false, + invoice_display_name: "Partner Plan" + ) + end + + let(:subscription) do + create(:subscription, customer:, plan:, status: "active") + end + + let(:wallet) { create(:wallet, customer:, balance_cents: 100000, credits_balance: 1000.0) } + + let(:wallet_transaction) do + create( + :wallet_transaction, + wallet:, + transaction_type: "inbound", + amount: 500.0, + credit_amount: 500.0, + status: "settled", + name: "Prepaid Credits" + ) + end + + let(:invoice) do + create( + :invoice, + :self_billed, + :credit, + customer:, + organization:, + number: "LAGO-202509-SB-003", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + total_amount_cents: 50000, + currency: "USD", + fees_amount_cents: 50000, + sub_total_excluding_taxes_amount_cents: 50000, + sub_total_including_taxes_amount_cents: 50000, + coupons_amount_cents: 0 + ) + end + + let(:credit_fee) do + create( + :credit_fee, + invoice:, + wallet_transaction:, + amount_cents: 50000, + amount_currency: "USD", + units: 500, + invoice_display_name: "Prepaid Credits" + ) + end + + before do + credit_fee + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end + + context "with taxes applied" do + let(:add_on) { create(:add_on, organization:, name: "Commission") } + let(:tax) { create(:tax, organization:, name: "VAT", rate: 20.0) } + + let(:invoice) do + create( + :invoice, + :self_billed, + customer:, + organization:, + number: "LAGO-202509-SB-004", + payment_due_date: Date.parse("2025-09-15"), + issuing_date: Date.parse("2025-09-01"), + invoice_type: :one_off, + total_amount_cents: 60000, + currency: "USD", + fees_amount_cents: 50000, + taxes_amount_cents: 10000, + sub_total_excluding_taxes_amount_cents: 50000, + sub_total_including_taxes_amount_cents: 60000, + coupons_amount_cents: 0 + ) + end + + let(:add_on_fee) do + create( + :one_off_fee, + invoice:, + add_on:, + amount_cents: 50000, + amount_currency: "USD", + units: 1, + unit_amount_cents: 50000, + taxes_amount_cents: 10000, + invoice_display_name: "Commission" + ) + end + + let(:applied_tax) do + create( + :invoice_applied_tax, + invoice:, + tax:, + tax_name: "VAT", + tax_code: "vat", + tax_rate: 20.0, + amount_cents: 10000, + amount_currency: "USD", + taxable_base_amount_cents: 50000, + fees_amount_cents: 50000 + ) + end + + before do + add_on_fee + applied_tax + end + + it "renders correctly" do + expect(rendered_template).to match_html_snapshot + end + end +end diff --git a/spec/views/helpers/charge_display_helper_spec.rb b/spec/views/helpers/charge_display_helper_spec.rb new file mode 100644 index 0000000..40d7391 --- /dev/null +++ b/spec/views/helpers/charge_display_helper_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ChargeDisplayHelper do + subject(:helper) { described_class } + + describe ".format_min_amount" do + subject { helper.format_min_amount(charge) } + + let(:plan) { create(:plan, amount_currency: "USD") } + let(:charge) { create(:standard_charge, plan:, min_amount_cents: 500) } + + context "when charge does not have applied pricing unit" do + it "returns the min amount with the appropriate currency symbol" do + expect(subject).to eq "$5.00" + end + end + + context "when charge has applied pricing unit" do + let!(:applied_pricing_unit) { create(:applied_pricing_unit, pricing_unitable: charge) } + + it "returns the min amount with the pricing unit's short name" do + expect(subject).to eq "5.00 #{applied_pricing_unit.pricing_unit.short_name}" + end + end + end +end diff --git a/spec/views/helpers/fee_boundaries_helper_spec.rb b/spec/views/helpers/fee_boundaries_helper_spec.rb new file mode 100644 index 0000000..4e34fcf --- /dev/null +++ b/spec/views/helpers/fee_boundaries_helper_spec.rb @@ -0,0 +1,871 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FeeBoundariesHelper do + subject(:helper) { described_class } + + let(:organization) { create(:organization) } + let(:customer) { create(:customer, organization:) } + let(:plan) { create(:plan, organization:, interval: :monthly) } + let(:subscription) { create(:subscription, customer:, plan:) } + let(:invoice) { create(:invoice, customer:, organization:) } + let(:invoice_subscription) do + create( + :invoice_subscription, + subscription:, + invoice:, + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59"), + charges_from_datetime: DateTime.parse("2025-12-01T00:00:00"), + charges_to_datetime: DateTime.parse("2025-12-31T23:59:59"), + fixed_charges_from_datetime: DateTime.parse("2025-12-01T00:00:00"), + fixed_charges_to_datetime: DateTime.parse("2025-12-31T23:59:59"), + timestamp: DateTime.parse("2026-01-01T00:00:00") + ) + end + + describe ".group_fees_by_billing_period" do + let(:subscription_fee) do + create( + :fee, + fee_type: :subscription, + subscription:, + invoice:, + properties: { + "from_datetime" => "2025-12-01T00:00:00Z", + "to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:charge_fee_arrears) do + create( + :charge_fee, + charge:, + subscription:, + invoice:, + properties: { + "charges_from_datetime" => "2025-12-01T00:00:00Z", + "charges_to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + let(:charge_fee_advance) do + create( + :charge_fee, + charge:, + subscription:, + invoice:, + properties: { + "charges_from_datetime" => "2026-01-01T00:00:00Z", + "charges_to_datetime" => "2026-01-31T23:59:59Z" + } + ) + end + + let(:fees) { [subscription_fee, charge_fee_arrears, charge_fee_advance] } + + it "groups fees by their billing period" do + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(2) + end + + it "sorts groups chronologically" do + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.first.billing_period.from_datetime.to_date).to eq(Date.new(2025, 12, 1)) + expect(grouped.last.billing_period.from_datetime.to_date).to eq(Date.new(2026, 1, 1)) + end + + it "includes subscription fee in correct group" do + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + december_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2025, 12, 1) } + expect(december_group.subscription_fee).to eq(subscription_fee) + end + + it "includes charge fees in correct groups" do + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + december_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2025, 12, 1) } + january_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2026, 1, 1) } + + expect(december_group.charge_fees).to include(charge_fee_arrears) + expect(january_group.charge_fees).to include(charge_fee_advance) + end + + context "with fixed charge fees" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:fixed_charge_fee_december) do + create( + :fixed_charge_fee, + fixed_charge:, + subscription:, + invoice:, + properties: { + "fixed_charges_from_datetime" => "2025-12-01T00:00:00Z", + "fixed_charges_to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + let(:fixed_charge_fee_january) do + create( + :fixed_charge_fee, + fixed_charge:, + subscription:, + invoice:, + properties: { + "fixed_charges_from_datetime" => "2026-01-01T00:00:00Z", + "fixed_charges_to_datetime" => "2026-01-31T23:59:59Z" + } + ) + end + + it "groups fixed charge fees by billing period" do + fees = [fixed_charge_fee_december, fixed_charge_fee_january] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(2) + december_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2025, 12, 1) } + january_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2026, 1, 1) } + + expect(december_group.fixed_charge_fees).to include(fixed_charge_fee_december) + expect(january_group.fixed_charge_fees).to include(fixed_charge_fee_january) + end + + it "includes multiple fixed charge fees in same period" do + fixed_charge2 = create(:fixed_charge, plan:, add_on:) + fixed_charge_fee2 = create( + :fixed_charge_fee, + fixed_charge: fixed_charge2, + subscription:, + invoice:, + properties: { + "fixed_charges_from_datetime" => "2025-12-01T00:00:00Z", + "fixed_charges_to_datetime" => "2025-12-31T23:59:59Z" + } + ) + + fees = [fixed_charge_fee_december, fixed_charge_fee2] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + december_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2025, 12, 1) } + expect(december_group.fixed_charge_fees.size).to eq(2) + expect(december_group.fixed_charge_fees).to include(fixed_charge_fee_december, fixed_charge_fee2) + end + end + + context "with commitment fees" do + let(:commitment) { create(:commitment, :minimum_commitment, plan:) } + + context "with properties" do + let(:commitment_fee) do + create( + :minimum_commitment_fee, + subscription:, + invoice:, + properties: { + "from_datetime" => "2025-12-01T00:00:00Z", + "to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + it "groups commitment fee by billing period from properties" do + fees = [commitment_fee] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime.to_date).to eq(Date.new(2025, 12, 1)) + expect(grouped.first.commitment_fee).to eq(commitment_fee) + end + end + + context "without properties for pay_in_arrears plan" do + let(:plan_arrears) { create(:plan, organization:, interval: :monthly, pay_in_advance: false) } + let(:subscription_arrears) { create(:subscription, customer:, plan: plan_arrears) } + let(:invoice_subscription_arrears) do + create( + :invoice_subscription, + subscription: subscription_arrears, + invoice:, + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + end + + let(:commitment_fee) do + create( + :minimum_commitment_fee, + subscription: subscription_arrears, + invoice:, + properties: {} + ) + end + + it "uses current invoice_subscription boundaries" do + fees = [commitment_fee] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription: invoice_subscription_arrears) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to eq(invoice_subscription_arrears.from_datetime) + expect(grouped.first.billing_period.to_datetime).to eq(invoice_subscription_arrears.to_datetime) + end + end + + context "without properties for pay_in_advance plan" do + let(:plan_advance) { create(:plan, organization:, interval: :monthly, pay_in_advance: true) } + let(:subscription_advance) { create(:subscription, customer:, plan: plan_advance) } + let(:previous_invoice_subscription) do + create( + :invoice_subscription, + subscription: subscription_advance, + invoice: create(:invoice, customer:, organization:), + from_datetime: DateTime.parse("2025-11-01T00:00:00"), + to_datetime: DateTime.parse("2025-11-30T23:59:59"), + timestamp: DateTime.parse("2025-12-01T00:00:00") + ) + end + + let(:invoice_subscription_advance) do + create( + :invoice_subscription, + subscription: subscription_advance, + invoice:, + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59"), + timestamp: DateTime.parse("2026-01-01T00:00:00") + ) + end + + let(:commitment_fee) do + create( + :minimum_commitment_fee, + subscription: subscription_advance, + invoice:, + properties: {} + ) + end + + before do + # Create subscription fee on previous invoice subscription to make it findable + create(:fee, fee_type: :subscription, subscription: subscription_advance, invoice: previous_invoice_subscription.invoice) + end + + it "uses previous invoice_subscription boundaries" do + fees = [commitment_fee] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription: invoice_subscription_advance) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to eq(previous_invoice_subscription.from_datetime) + expect(grouped.first.billing_period.to_datetime).to eq(previous_invoice_subscription.to_datetime) + end + end + + context "with empty string properties" do + let(:commitment_fee) do + create( + :minimum_commitment_fee, + subscription:, + invoice:, + properties: { + "from_datetime" => "", + "to_datetime" => "" + } + ) + end + + it "falls back to invoice_subscription boundaries" do + fees = [commitment_fee] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to eq(invoice_subscription.from_datetime) + expect(grouped.first.billing_period.to_datetime).to eq(invoice_subscription.to_datetime) + end + end + end + + context "with multiple fees of same type in same period" do + let(:charge2) { create(:standard_charge, plan:, billable_metric: create(:billable_metric, organization:)) } + let(:charge_fee2) do + create( + :charge_fee, + charge: charge2, + subscription:, + invoice:, + properties: { + "charges_from_datetime" => "2025-12-01T00:00:00Z", + "charges_to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + it "includes all charge fees in same group" do + fees = [charge_fee_arrears, charge_fee2] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + december_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2025, 12, 1) } + expect(december_group.charge_fees.size).to eq(2) + expect(december_group.charge_fees).to include(charge_fee_arrears, charge_fee2) + end + + it "sorts charge fees alphabetically within group" do + # Create fees with different invoice_sorting_clause values + allow(charge_fee_arrears).to receive(:invoice_sorting_clause).and_return("charge b") + allow(charge_fee2).to receive(:invoice_sorting_clause).and_return("charge a") + + fees = [charge_fee_arrears, charge_fee2] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + december_group = grouped.find { |g| g.billing_period.from_datetime.to_date == Date.new(2025, 12, 1) } + expect(december_group.charge_fees.first).to eq(charge_fee2) + expect(december_group.charge_fees.last).to eq(charge_fee_arrears) + end + end + + context "with missing properties" do + context "when subscription fees" do + let(:subscription_fee_missing) do + create( + :fee, + fee_type: :subscription, + subscription:, + invoice:, + properties: {} + ) + end + + it "falls back to invoice_subscription boundaries" do + fees = [subscription_fee_missing] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to eq(invoice_subscription.from_datetime) + expect(grouped.first.billing_period.to_datetime).to eq(invoice_subscription.to_datetime) + end + end + + context "when charge fees" do + let(:charge_fee_missing) do + create( + :charge_fee, + charge:, + subscription:, + invoice:, + properties: {} + ) + end + + it "falls back to invoice_subscription charge boundaries" do + fees = [charge_fee_missing] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to eq(invoice_subscription.charges_from_datetime) + expect(grouped.first.billing_period.to_datetime).to eq(invoice_subscription.charges_to_datetime) + end + end + + context "when fixed charge fees" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:fixed_charge_fee_missing) do + create( + :fixed_charge_fee, + fixed_charge:, + subscription:, + invoice:, + properties: {} + ) + end + + it "falls back to invoice_subscription fixed charge boundaries" do + fees = [fixed_charge_fee_missing] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to eq(invoice_subscription.fixed_charges_from_datetime) + expect(grouped.first.billing_period.to_datetime).to eq(invoice_subscription.fixed_charges_to_datetime) + end + end + end + + context "with unknown fee types" do + let(:add_on_fee) do + create( + :add_on_fee, + invoice:, + properties: {} + ) + end + + let(:credit_fee) do + create( + :credit_fee, + invoice:, + properties: {} + ) + end + + it "ignores add_on fees (not added to any group)" do + fees = [add_on_fee] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + # Unknown fee types are not handled by the case statement, so they're not added to any group + expect(grouped).to be_empty + end + + it "ignores credit fees (not added to any group)" do + fees = [credit_fee] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + # Unknown fee types are not handled by the case statement, so they're not added to any group + expect(grouped).to be_empty + end + end + + it "groups fees with same date but different times together" do + charge_fee_different_time = create( + :charge_fee, + charge:, + subscription:, + invoice:, + properties: { + "charges_from_datetime" => "2025-12-01T12:00:00Z", + "charges_to_datetime" => "2025-12-31T12:00:00Z" + } + ) + + fees = [charge_fee_arrears, charge_fee_different_time] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + expect(grouped.first.charge_fees.size).to eq(2) + end + + context "with mixed fee types in same period" do + let(:fixed_charge) { create(:fixed_charge, plan:) } + let(:commitment) { create(:commitment, :minimum_commitment, plan:) } + + let(:fixed_charge_fee) do + create( + :fixed_charge_fee, + fixed_charge:, + subscription:, + invoice:, + properties: { + "fixed_charges_from_datetime" => "2025-12-01T00:00:00Z", + "fixed_charges_to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + let(:commitment_fee) do + create( + :minimum_commitment_fee, + subscription:, + invoice:, + properties: { + "from_datetime" => "2025-12-01T00:00:00Z", + "to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + it "includes all fee types in the same group" do + fees = [subscription_fee, charge_fee_arrears, fixed_charge_fee, commitment_fee] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + december_group = grouped.first + + expect(december_group.subscription_fee).to eq(subscription_fee) + expect(december_group.charge_fees).to include(charge_fee_arrears) + expect(december_group.fixed_charge_fees).to include(fixed_charge_fee) + expect(december_group.commitment_fee).to eq(commitment_fee) + end + end + + context "with invalid datetime properties" do + it "handles nil datetime values" do + subscription_fee_nil = create( + :fee, + fee_type: :subscription, + subscription:, + invoice:, + properties: { + "from_datetime" => nil, + "to_datetime" => nil + } + ) + + fees = [subscription_fee_nil] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to eq(invoice_subscription.from_datetime) + expect(grouped.first.billing_period.to_datetime).to eq(invoice_subscription.to_datetime) + end + + it "creates billing period with nil values for invalid datetime strings" do + subscription_fee_invalid = create( + :fee, + fee_type: :subscription, + subscription:, + invoice:, + properties: { + "from_datetime" => "invalid-date", + "to_datetime" => "invalid-date" + } + ) + + fees = [subscription_fee_invalid] + grouped = helper.group_fees_by_billing_period(fees, invoice_subscription:) + + # When parse_datetime fails, it returns nil, and BillingPeriod is created with nil values + # The from && to check passes because the strings exist, but parse_datetime returns nil + expect(grouped.size).to eq(1) + expect(grouped.first.billing_period.from_datetime).to be_nil + expect(grouped.first.billing_period.to_datetime).to be_nil + end + end + end + + describe ".billing_period_for" do + context "when fee is a subscription fee" do + let(:fee) do + create( + :fee, + fee_type: :subscription, + subscription:, + invoice:, + properties: { + "from_datetime" => "2026-01-01T00:00:00Z", + "to_datetime" => "2026-01-31T23:59:59Z" + } + ) + end + + it "returns the billing period from fee properties" do + period = helper.billing_period_for(fee, invoice_subscription:) + + expect(period.from_datetime.to_date).to eq(Date.new(2026, 1, 1)) + expect(period.to_datetime.to_date).to eq(Date.new(2026, 1, 31)) + end + end + + context "when fee is a charge fee" do + let(:billable_metric) { create(:billable_metric, organization:) } + let(:charge) { create(:standard_charge, plan:, billable_metric:) } + let(:fee) do + create( + :charge_fee, + charge:, + subscription:, + invoice:, + properties: { + "charges_from_datetime" => "2026-01-01T00:00:00Z", + "charges_to_datetime" => "2026-01-31T23:59:59Z" + } + ) + end + + it "returns the billing period from charge boundaries in fee properties" do + period = helper.billing_period_for(fee, invoice_subscription:) + + expect(period.from_datetime.to_date).to eq(Date.new(2026, 1, 1)) + expect(period.to_datetime.to_date).to eq(Date.new(2026, 1, 31)) + end + end + + context "when fee is a fixed charge fee" do + let(:add_on) { create(:add_on, organization:) } + let(:fixed_charge) { create(:fixed_charge, plan:, add_on:) } + let(:fee) do + create( + :fixed_charge_fee, + fixed_charge:, + subscription:, + invoice:, + properties: { + "fixed_charges_from_datetime" => "2026-01-01T00:00:00Z", + "fixed_charges_to_datetime" => "2026-01-31T23:59:59Z" + } + ) + end + + it "returns the billing period from fixed charge boundaries in fee properties" do + period = helper.billing_period_for(fee, invoice_subscription:) + + expect(period.from_datetime.to_date).to eq(Date.new(2026, 1, 1)) + expect(period.to_datetime.to_date).to eq(Date.new(2026, 1, 31)) + end + end + + context "when fee is a commitment fee with properties" do + let(:commitment) { create(:commitment, :minimum_commitment, plan:) } + let(:fee) do + create( + :minimum_commitment_fee, + subscription:, + invoice:, + properties: { + "from_datetime" => "2026-01-01T00:00:00Z", + "to_datetime" => "2026-01-31T23:59:59Z" + } + ) + end + + it "returns the billing period from fee properties" do + period = helper.billing_period_for(fee, invoice_subscription:) + + expect(period.from_datetime.to_date).to eq(Date.new(2026, 1, 1)) + expect(period.to_datetime.to_date).to eq(Date.new(2026, 1, 31)) + end + end + + context "when fee has missing properties" do + let(:fee) do + create( + :fee, + fee_type: :subscription, + subscription:, + invoice:, + properties: {} + ) + end + + it "falls back to invoice_subscription boundaries" do + period = helper.billing_period_for(fee, invoice_subscription:) + + expect(period.from_datetime).to eq(invoice_subscription.from_datetime) + expect(period.to_datetime).to eq(invoice_subscription.to_datetime) + end + end + + context "when fee has a different type" do + let(:fee) do + create( + :fee, + fee_type: :add_on, + invoice:, + properties: {} + ) + end + + it "falls back to invoice_subscription boundaries" do + period = helper.billing_period_for(fee, invoice_subscription:) + + expect(period.from_datetime).to eq(invoice_subscription.from_datetime) + expect(period.to_datetime).to eq(invoice_subscription.to_datetime) + end + end + + context "when fee is a recurring pay-in-advance charge (reconciliation fee)" do + # This tests the scenario where: + # - Plan is pay-in-arrears + # - Charge is pay-in-advance with a recurring billable metric + # - Fee has pay_in_advance: false (reconciliation fee, not instant fee) + # - Fee properties have charges_from_datetime matching the arrears period + # - But the billing period should show the pay-in-advance interval (next period) + let(:calendar_subscription) do + create( + :subscription, + customer:, + plan:, + billing_time: :calendar, + subscription_at: DateTime.parse("2025-12-01T00:00:00"), + started_at: DateTime.parse("2025-12-01T00:00:00") + ) + end + let(:calendar_invoice_subscription) do + create( + :invoice_subscription, + subscription: calendar_subscription, + invoice:, + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59"), + charges_from_datetime: DateTime.parse("2025-12-01T00:00:00"), + charges_to_datetime: DateTime.parse("2025-12-31T23:59:59"), + timestamp: DateTime.parse("2026-01-01T00:00:00") + ) + end + let(:recurring_billable_metric) { create(:sum_billable_metric, organization:, recurring: true) } + let(:pay_in_advance_charge) do + create(:standard_charge, :pay_in_advance, plan:, billable_metric: recurring_billable_metric) + end + + # Reconciliation fee: fee.pay_in_advance = false, charge.pay_in_advance = true + # Properties store the arrears period (December), but should display as January + let(:reconciliation_fee) do + create( + :charge_fee, + charge: pay_in_advance_charge, + subscription: calendar_subscription, + invoice:, + pay_in_advance: false, + properties: { + "charges_from_datetime" => "2025-12-01T00:00:00Z", + "charges_to_datetime" => "2025-12-31T23:59:59Z" + } + ) + end + + it "returns the pay-in-advance interval (next period), not the stored properties" do + period = helper.billing_period_for(reconciliation_fee, invoice_subscription: calendar_invoice_subscription) + + # The fee properties say December, but since this is a reconciliation fee + # for a pay-in-advance charge on an arrears plan, it should show January + # (the period that was paid in advance) + expect(period.from_datetime.to_date).to eq(Date.new(2026, 1, 1)) + expect(period.to_datetime.to_date).to eq(Date.new(2026, 1, 31)) + end + end + end + + describe ".format_billing_period" do + let(:billing_period) do + described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + end + + it "formats the billing period using I18n" do + result = helper.format_billing_period(billing_period, customer:) + + expect(result).to eq "Fees from Dec 01, 2025 to Dec 31, 2025" + end + end + + describe "BillingPeriod" do + describe "#to_grouping_key" do + it "returns an array of dates for grouping" do + period = described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + + expect(period.to_grouping_key).to eq([Date.new(2025, 12, 1), Date.new(2025, 12, 31)]) + end + end + + describe "#==" do + it "considers two periods with same dates as equal" do + period1 = described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + period2 = described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-01T12:00:00"), + to_datetime: DateTime.parse("2025-12-31T12:00:00") + ) + + expect(period1).to eq(period2) + end + + context "when periods have different times" do + it "considers two periods with same dates but different times as equal" do + period1 = described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + + period2 = described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + + expect(period1).to eq(period2) + end + end + + context "when periods have different dates" do + it "considers two periods with different dates as not equal" do + period1 = described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-01T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + period2 = described_class::BillingPeriod.new( + from_datetime: DateTime.parse("2025-12-02T00:00:00"), + to_datetime: DateTime.parse("2025-12-31T23:59:59") + ) + + expect(period1).not_to eq(period2) + end + end + end + end + + describe "GroupedFees" do + describe "#has_any_fees?" do + it "returns true when subscription fee is present" do + grouped = described_class::GroupedFees.new( + billing_period: described_class::BillingPeriod.new(from_datetime: Time.current, to_datetime: Time.current), + subscription_fee: build(:fee), + fixed_charge_fees: [], + charge_fees: [], + commitment_fee: nil + ) + + expect(grouped.has_any_fees?).to be true + end + + it "returns true when charge fees are present" do + grouped = described_class::GroupedFees.new( + billing_period: described_class::BillingPeriod.new(from_datetime: Time.current, to_datetime: Time.current), + subscription_fee: nil, + fixed_charge_fees: [], + charge_fees: [build(:fee)], + commitment_fee: nil + ) + + expect(grouped.has_any_fees?).to be true + end + + it "returns true when fixed charge fees are present" do + grouped = described_class::GroupedFees.new( + billing_period: described_class::BillingPeriod.new(from_datetime: Time.current, to_datetime: Time.current), + subscription_fee: nil, + fixed_charge_fees: [build(:fee)], + charge_fees: [], + commitment_fee: nil + ) + + expect(grouped.has_any_fees?).to be true + end + + it "returns true when minimum commitment fees are present" do + grouped = described_class::GroupedFees.new( + billing_period: described_class::BillingPeriod.new(from_datetime: Time.current, to_datetime: Time.current), + subscription_fee: nil, + fixed_charge_fees: [], + charge_fees: [], + commitment_fee: build(:fee) + ) + + expect(grouped.has_any_fees?).to be true + end + + it "returns false when no fees are present" do + grouped = described_class::GroupedFees.new( + billing_period: described_class::BillingPeriod.new(from_datetime: Time.current, to_datetime: Time.current), + subscription_fee: nil, + fixed_charge_fees: [], + charge_fees: [], + commitment_fee: nil + ) + + expect(grouped.has_any_fees?).to be false + end + end + end +end diff --git a/spec/views/helpers/fee_display_helper_spec.rb b/spec/views/helpers/fee_display_helper_spec.rb new file mode 100644 index 0000000..4b7de39 --- /dev/null +++ b/spec/views/helpers/fee_display_helper_spec.rb @@ -0,0 +1,119 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe FeeDisplayHelper do + subject(:helper) { described_class } + + describe ".grouped_by_display" do + let(:charge) { create(:standard_charge, properties:) } + let(:fee) { create(:fee, charge:, fee_type: "charge", grouped_by:, total_aggregated_units: 10) } + let(:grouped_by) do + { + "key_1" => "mercredi", + "key_2" => "week_01", + "key_3" => "2024" + } + end + let(:properties) do + { + "amount" => "5", + "grouped_by" => %w[key_1 key_2 key_3] + } + end + + context "when a standard charge fee has grouped_by property" do + it "formats the grouped_by values with bullet points" do + expect(helper.grouped_by_display(fee)).to eq(" • mercredi • week_01 • 2024") + end + end + + context "when the charge properties are missing the grouped_by property" do + let(:properties) do + { + "amount" => "5" + } + end + + it "returns valid response" do + expect(helper.grouped_by_display(fee)).to eq(" • mercredi • week_01 • 2024") + end + end + + context "when some grouped_by values are nil" do + let(:grouped_by) do + { + "key_1" => nil, + "key_2" => "week_01", + "key_3" => "2024" + } + end + + it "skips nil values and formats only the present values" do + expect(helper.grouped_by_display(fee)).to eq(" • week_01 • 2024") + end + end + end + + describe ".format_with_precision" do + subject { helper.format_with_precision(fee, amount) } + + let(:fee) { create(:fee, amount_currency: "USD") } + let(:amount) { "0.12345678" } + + context "when fee does not have pricing unit usage" do + it "returns the rounded amount with currency symbol" do + expect(subject).to eq "$0.123457" + end + end + + context "when fee has pricing unit usage" do + let!(:pricing_unit_usage) { create(:pricing_unit_usage, fee:) } + + it "returns the rounded amount with the pricing unit's short name" do + expect(subject).to eq "0.123457 #{pricing_unit_usage.short_name}" + end + end + end + + describe ".format_as_currency" do + subject { helper.format_as_currency(fee, amount) } + + let(:fee) { create(:fee, amount_currency: "USD") } + let(:amount) { "10.53" } + + context "when fee does not have pricing unit usage" do + it "returns the amount with the appropriate currency symbol" do + expect(subject).to eq "$10.53" + end + end + + context "when fee has pricing unit usage" do + let!(:pricing_unit_usage) { create(:pricing_unit_usage, fee:) } + + it "returns the amount with the pricing unit's short name" do + expect(subject).to eq "10.53 #{pricing_unit_usage.short_name}" + end + end + end + + describe ".format_amount" do + subject { helper.format_amount(fee) } + + let(:fee) { create(:fee, amount_cents: 1000, amount_currency: "USD") } + + context "when fee does not have pricing unit usage" do + it "returns fee amount with the appropriate currency symbol" do + expect(subject).to eq "$10.00" + end + end + + context "when fee has pricing unit usage" do + let!(:pricing_unit_usage) { create(:pricing_unit_usage, fee:, amount_cents: 505) } + + it "returns fee's pricing unit usage amount and with the unit's short name" do + expect(subject).to eq "5.05 #{pricing_unit_usage.short_name}" + end + end + end +end diff --git a/spec/views/helpers/interval_helper_spec.rb b/spec/views/helpers/interval_helper_spec.rb new file mode 100644 index 0000000..a1ba6a3 --- /dev/null +++ b/spec/views/helpers/interval_helper_spec.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +require "rails_helper" + +# Tests for IntervalHelper placed under app/views/helpers as requested +RSpec.describe IntervalHelper do + describe ".interval_name" do + subject(:result) { described_class.interval_name(interval) } + + context "when interval is :weekly" do + let(:interval) { :weekly } + + it "returns translation" do + expect(subject).to eq("week") + end + end + + context "when interval is 'weekly'" do + let(:interval) { "weekly" } + + it "returns translation" do + expect(subject).to eq("week") + end + end + + context "when interval is :monthly" do + let(:interval) { :monthly } + + it "returns translation" do + expect(subject).to eq("month") + end + end + + context "when interval is 'monthly'" do + let(:interval) { "monthly" } + + it "returns translation" do + expect(subject).to eq("month") + end + end + + context "when interval is :yearly" do + let(:interval) { :yearly } + + it "returns translation" do + expect(subject).to eq("year") + end + end + + context "when interval is 'yearly'" do + let(:interval) { "yearly" } + + it "returns translation" do + expect(subject).to eq("year") + end + end + + context "when interval is :quarterly" do + let(:interval) { :quarterly } + + it "returns translation" do + expect(subject).to eq("quarter") + end + end + + context "when interval is 'quarterly'" do + let(:interval) { "quarterly" } + + it "returns translation" do + expect(subject).to eq("quarter") + end + end + + context "when interval is :semiannual" do + let(:interval) { :semiannual } + + it "returns translation" do + expect(subject).to eq("half-year") + end + end + + context "when interval is 'semiannual'" do + let(:interval) { "semiannual" } + + it "returns translation" do + expect(subject).to eq("half-year") + end + end + + context "when interval is unknown" do + let(:interval) { :daily } + + it "returns nil and does not translate" do + expect(subject).to be_nil + end + end + end +end diff --git a/spec/views/helpers/line_break_helper_spec.rb b/spec/views/helpers/line_break_helper_spec.rb new file mode 100644 index 0000000..f417a22 --- /dev/null +++ b/spec/views/helpers/line_break_helper_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe LineBreakHelper do + subject(:helper) { described_class } + + describe ".break_lines" do + it 'replaces \n with
' do + html = helper.break_lines("t\nt") + + expect(html).to eq("t
t") + end + + it 'removes all \n at the beginning and the end' do + html = helper.break_lines("t\nt\n\n\n") + + expect(html).to eq("t
t") + end + + it 'removes double extra \n' do + html = helper.break_lines("\n\n\nt\n\n\nt\n\n\n") + + expect(html).to eq("t
t") + end + end +end diff --git a/spec/views/helpers/money_helper_spec.rb b/spec/views/helpers/money_helper_spec.rb new file mode 100644 index 0000000..a5bcc73 --- /dev/null +++ b/spec/views/helpers/money_helper_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe MoneyHelper do + subject(:helper) { described_class } + + describe ".format" do + let(:currency) { "USD" } + let(:amount) { Money.new(100, currency) } + + it "formats the amount" do + expect(helper.format(amount)).to eq("$1.00") + end + + context "when currency does not use a well known symbol" do + let(:currency) { "BHD" } + + it "formats the amount" do + expect(helper.format(amount)).to eq("BHD 0.100") + end + end + end + + describe ".format_with_precision" do + let(:currency) { "USD" } + + it "rounds big decimals to 6 digits" do + html = helper.format_with_precision(BigDecimal("123.12345678"), currency) + + expect(html).to eq("$123.123457") + end + + it "shows six significant digits for values < 1" do + html = helper.format_with_precision(BigDecimal("0.000000012345"), currency) + + expect(html).to eq("$0.000000012345") + end + + it "shows only six significant digits for values < 1" do + html = helper.format_with_precision(BigDecimal("0.100000012345"), currency) + + expect(html).to eq("$0.10") + end + + context "when currency does not use a well known symbol" do + let(:currency) { "BHD" } + + it "rounds big decimals to 6 digits" do + html = helper.format_with_precision(BigDecimal("123.12345678"), currency) + + expect(html).to eq("BHD 123.123457") + end + + it "shows six significant digits for values < 1" do + html = helper.format_with_precision(BigDecimal("0.000000012345"), currency) + + expect(html).to eq("BHD 0.000000012345") + end + + it "shows only six significant digits for values < 1" do + html = helper.format_with_precision(BigDecimal("0.100000012345"), currency) + + expect(html).to eq("BHD 0.100") + end + end + end + + describe ".format_pricing_unit" do + subject { helper.format_pricing_unit(amount_cents, currency) } + + let(:currency) { build_stubbed(:pricing_unit) } + let(:amount_cents) { 100 } + + it "formats the amount" do + expect(subject).to eq "100.00 #{currency.short_name}" + end + end + + describe ".format_pricing_unit_with_precision" do + subject { helper.format_pricing_unit_with_precision(amount, currency) } + + let(:currency) { build_stubbed(:pricing_unit) } + + context "when amount bigger than 1" do + let(:amount) { BigDecimal("123.12345678") } + + it "rounds big decimals to 6 digits" do + expect(subject).to eq "123.123457 #{currency.short_name}" + end + end + + context "when amount smaller than 1" do + let(:amount) { BigDecimal("0.000000012345") } + + it "shows six significant digits for values < 1" do + expect(subject).to eq "0.000000012345 #{currency.short_name}" + end + end + end +end diff --git a/spec/views/helpers/rounding_helper_spec.rb b/spec/views/helpers/rounding_helper_spec.rb new file mode 100644 index 0000000..72e3457 --- /dev/null +++ b/spec/views/helpers/rounding_helper_spec.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe RoundingHelper do + subject(:helper) { described_class } + + describe ".round_decimal_part" do + it "rounds the decimal part to the specified significant figures" do + expect(helper.round_decimal_part(123.456789123, 4)).to eq("123.4568") + end + + it "rounds the decimal part to the default significant figures when not specified" do + expect(helper.round_decimal_part(123.456789123)).to eq("123.456789") + end + + it "returns the integer part as a string when there is no decimal part" do + expect(helper.round_decimal_part(123)).to eq("123") + end + + it "handles numbers with leading zeros in the decimal part" do + expect(helper.round_decimal_part(123.0000456789)).to eq("123.000046") + end + + it "handles very small decimal parts correctly" do + expect(helper.round_decimal_part(123.000000000456789)).to eq("123") + end + + it "handles very small decimal parts only correctly" do + expect(helper.round_decimal_part(0.0000000009)).to eq("0.0000000009") + end + + it "handles negative numbers correctly" do + expect(helper.round_decimal_part(-123.456789123)).to eq("-123.456789") + end + + it "handles zero correctly" do + expect(helper.round_decimal_part(0)).to eq("0") + end + end +end diff --git a/storage/.keep b/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/.keep b/tmp/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/pids/.keep b/tmp/pids/.keep new file mode 100644 index 0000000..e69de29 diff --git a/tmp/storage/.keep b/tmp/storage/.keep new file mode 100644 index 0000000..e69de29 diff --git a/vendor/.keep b/vendor/.keep new file mode 100644 index 0000000..e69de29